페이먼츠의 길은 멀고도 험한 것 같다ㅎㅎ;;
테스트 모드에서 카드결제, 빌링 프로세스는 완료했는데 Exception부분의 코드가 부족한 것을 느꼈다.
문제라고 생각해서 무조건 해결해야겠다고 생각했고,
제대로된 예외처리를 위해 공부한 기록을 남기려 한다.
레퍼런스 블로그를 보고 열심히 공부해 보았다.
[제 공부를 위해 적으면서 머리에 넣은 거라 원본 글 보시는 게 더 좋을 거예요~!!]
먼저 스프링은 예외처리를 위해 다양한 어노테이션을 제공한다고 한다.
레퍼런스 블로그의 글은 일관성 있는 코드 스타일을 유지하면서 Exception을 처리하는 방법에 대해 소개하고 있다.
통일된 Error Response 객체
Error Response 객체는 항상 동일한 Error Response를 가져야 한다.
이유: 그렇지 않으면, 클라이언트에서 예외 처리를 항상 동일한 로직으로 처리하기 여렵다.
Error Response 객체를 유연하게 처리하기 위해서 Map<Key, Value> 형식으로 처리하는 것은 좋지 않다.
Map은 런타임 시, 정확한 형태를 갖추기 때문에 객체를 처리하는 개발자들도 정확히 무슨 키에 무슨 데이터가 있는지 확인하기 어렵다.
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException (MethodArgumentNotValidException e) {
log.error("handleMethodArgumentNotValidException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
해당 예제 코드처럼 리턴 타입이 ResponseEntity<ErrorResponse>로 무슨 데이터가 있는지 명확하게 추론하기 쉽도록 구성하는 것이 바람직하다.
Error Response JSON
{
"message": "Invalid Input Value",
"status": 400,
"errors": [
{
"field": "name.last",
"value": "",
"reason": "must not be empty"
},
{
"field": "name.first",
"value": "",
"reason": "must not be empty"
}
],
"code": "C001"
}
ErrorResponse 객체의 JSON이다.
- message: 에러에 대한 message를 작성한다.
- status: http status code를 작성한다. header 정보에도 포함된 정보이니 굳이 추가하지 않아도 된다.
- errors: 요청 값에 대한 field, value, reason을 작성한다. 일반적으로 @Valid 어노테이션으로 JSR 303: Bean Validation에 대한 검증을 진행한다.
- 만약 errors에 바인딩된 결과가 없을 경우 null이 아니라 빈 배열을 응답해준다. null 객체는 절대 리턴하지 않는다.(null이 의미하는 것이 애매하기 때문)
- code: 에러에 할당되는 유니크한 코드 값
Error Response 객체
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ErrorResponse {
private String message;
private int status;
private List<FieldError> errors;
private String code;
...
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public static class FieldError {
private String field;
private String value;
private String reason;
...
}
}
ErrorResponse 객체이다. POJO(Plain Old Java Object) 객체로 관리하면 errorResponse.get~~(); 로 명확하게 객체에 있는 값을 가져올 수 있다. 그 밖에 특정 Exception에 대해서 ErrorResponse 객체를 어떻게 만들 것인가에 대한 책임을 명확하게 갖는 구조로 설계할 수있다.
@ControllerAdvice로 모든 예외를 핸들링
@ControllerAdvice 어노테이션으로 모든 예외를 한 곳에서 처리할 수 있다.
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* javax.validation.Valid or @Validated 으로 binding error 발생시 발생한다.
* HttpMessageConverter 에서 등록한 HttpMessageConverter binding 못할 경우 발생
* 주로 @RequestBody, @RequestPart 어노테이션에서 발생
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.error("handleMethodArgumentNotValidException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* @ModelAttribute로 binding error 발생시 BindException이 발생한다.
*/
@ExceptionHandler(BindException.class)
protected ResponseEntity<ErrorResponse> handleBindExcepton(BindException e) {
log.error("handleBindException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INVALID_INPUT_VALUE, e.getBindingResult());
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* enum type이 일치하지 않아 binding 못할 경우 발생
* 주로 @RequestParam enum으로 binding 못했을 경우 발생
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
protected ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(HttpRequestMethodNotSupportedException e) {
log.error("handleMethodArgumentTypeMismatchException", e);
final ErrorResponse response = ErrorResponse.of(e);
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
/**
* 지원하지 않은 HTTP method 호출 할 경우 발생
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
protected ResponseEntity<ErrorResponse> handleHttpRequestMethodNotSupportedExcepton(HttpRequestMethodNotSupportedException e) {
log.error("handleHttprequestMethodNotSupportedException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.METHOD_NOT_ALLOWED);
return new ResponseEntity<>(response, HttpStatus.METHOD_NOT_ALLOWED);
}
/**
* Authentication 객체가 필요한 권한을 보유하지 않은 경우 발생
*/
@ExceptionHandler(AccessDeniedException.class)
protected ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException e) {
log.error("handleAccessDeniedException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.HANDLE_ACCESS_DENIED);
return new ResponseEntity<>(response, HttpStatus.valueOf(ErrorCode.HANDLE_ACCESS_DENIED.getStatus()));
}
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(final BusinessException e) {
log.error("handleEntityNotFoundException", e);
final ErrorCode errorCode = e.getErrorCode();
final ErrorResponse response = ErrorResponse.of(errorCode);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("handleEntityNotFoundException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
handleMethodArgumentNotValidException
HttpMessageConverter에서 등록한 HttpMessageConverter binding이 안된 경우 주로 발생,
@RequestBody, @RequestPart 어노테이션에서 발생
handleBindException
@ModelAttribute으로 binding 에러 발생하는 경우 BindException이 발생
MethodArgumentTypeMismatchException
enum type이 일치하지 않아 binding 못하는 경우 발생
주로 @RequestParam enum으로 binding 못하는 경우 발생
handleHttpRequestMethodNotSupportedException
지원하지 않은 HTTP method 호출 할 경우 발생
handleAccessDeniedException
Authentication 객체가 필요한 권한을 보유하지 않은 경우 발생
Security에서 던지는 예외
handleException
그 밖에 발생하는 모든 예외 처리, NullPointException 등등
개발자가 직접 핸들링해서 다른 예외로 던지지 않으면 모두 이곳으로 모인다.
handleBusinessException
비즈니스 요구사항에 따른 Exception
스프링 라이브러리 등 자체적으로 발생하는 예외는 @ExceptionHandler로 추가해서 적절한 Error Response를 만들고 비즈니스 요구사항에 대한 예외일 때는 BusinessException으로 통일성 있게 처리하는 것을 목표로 해야한다.
Error Code 정의
public enum ErrorCode {
// Common
INVALID_INPUT_VALUE(400, "C001", "Invalid Input Value"),
METHOD_NOT_ALLOWED(405, "C002", "Invalid Input Value"),
...
HANDLE_ACCESS_DENIED(403, "C006", "Access is Denied"),
// Member
EMAIL_DUPLICATION(400, "M001", "Email is Duplication"),
LOGIN_INPUT_INVALID(400, "M002", "Login input is invalid"),
private final String code;
private final String message;
private int status;
ErrorCode(final int status, final String code, final String message) {
this.status = status;
this.message = message;
this.code = code;
}
}
Error Code는 enum 타입으로 한 곳에서 관리한다.
에러 코드가 전체적으로 흩어져있을 경우 코드, 메세지 중복을 방지하기 어렵고 전체적으로 관리하는 것이 어렵다.
C001 같은 코드도 동일하게 Enum으로 관리하면 좋다. 에러 메세지는 Common과 각 도메인 별로 관리하는 것이 효율적이다.
Business Exception 처리
요구사항에 맞지 않을 경우 발생시키는 Exception을 말한다.
(블로그에서 쿠폰의 예를 들어주셨다.)
만약 쿠폰을 사용하려고 하는데 이미 사용한 쿠폰인 경우에는 더이상 정상적인 흐름을 이어갈 수 없게 된다. 이런 경우 적절한 Exception을 발생시키고 로직을 종료 시켜야한다.
더 쉽게 정리하면 요구사항에 맞게 개발자가 직접 Exception을 발생시키는 것들이 Business Exception이라고 할 수 있다.
쿠폰을 입력해서 상품을 주문했을 때, 상품 계산 로직에서 이미 사용해버린 쿠폰이면 로직을 이어나갈 수 없다. (계산 로직 책임 증가)
계산 로직은 특정 공식에 의해서 제품의 가격을 계산하는 것이 책임, 쿠폰 이미 사용한 경우, 만료된 경우, 매진된 경우에 해당하는 처리에 대한 책임을 갖는 순간 유지보수하기 어려운 코드가 된다. 객체의 적절한 책임을 주기 위해서라도 적절한 Exception 발생이 필요하다.
public class DeviceController {
...
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// 디바이스 상태를 점검한다.
if (handle != DeviceHandle.INVALID) {
// 레코드 필드에 디바이스 상태를 저장한다.
retrieveDeviceRecord(handle);
// 디바이스가 일시정지 상태가 아니라면 종료한다.
if (record.getStatus() != DEVICE_SUSPENDED) {
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
} else {
logger.log("Device suspended. Unable to shut down");
}
} else {
logger.log("Invalid handle for: " + DEV1.toString());
}
}
...
}
if else의 반복으로 인해 sendShutDown 핵심 비즈니스 코드의 이해가 어렵다.
public class DeviceController {
...
public void sendShutDown() {
try {
tryToShutDown();
} catch (DeviceShutDownError e) {
logger.log(e);
}
}
private void tryToShutDown() throws DeviceShutDownError() {
DeviceHandle handle = getHandle(DEV1);
DeviceRecord record = retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
private DeviceHandle getHandle(DeviceID id) {
...
throw new DeviceShutDownError("Invalid handle for: " + id.toString());
...
}
...
}
객체 본인의 책임 외적인 것들은 DeviceShutDownError 예외를 발생시키고 있다. (코드의 가독성과 책임이 분명하게 드러남)
비즈니스 예외를 위한 Business Exception 클래스
최상위 BusinessException을 상속받는 InvalidValueException, EntityNotFoundException 등이 있다.
- InvalidValueException: 유효하지 않은 값일 경우 예외를 던지는 Exception
- 쿠폰 만료, 이미 사용한 쿠폰 등의 이유로 더이상 진행이 안되는 경우
- EntityNotFoundException: 각 엔티티들을 못찾았을 경우
- findById, findByCode 메서드에서 조회가 안 되는 경우
최상위 BusinessException을 기준으로 예외를 발생시키면 통일감 있는 예외 처리를 가질 수 있다. 비즈니스 로직을 수행하는 코드 흐름에서 로직의 흐름을 진행할 수 없는 상태인 경우에는 적절한 BusinessException 중에서 예외를 발생시키거나 직접 정의한다.
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(final BusinessException e) {
log.error("handleEntityNotFoundException", e);
final ErrorCode errorCode = e.getErrorCode();
final ErrorResponse response = ErrorResponse.of(errorCode);
return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatus()));
}
이렇게 발생한 모든 예외는 handleBusinessException에서 동일하게 핸들링 된다. 예외 발생시 알람을 받는 등의 추가 기능도 가능하다. 또 BusinessException 클래스의 하위 클래스 중에서 특정 예외에 대해서 다른 알람을 받는 등의 더 디테일한 핸들링도 가능하다.
Coupon Code
public class Coupon {
...
public void use() {
verifyExpiration();
verifyUsed();
this.used = true;
}
private void verifyUsed() {
if (used) throw new CouponAlreadyUseException();
}
private void verifyExpiration() {
if (LocalDate.now().isAfter(getExpirationDate())) throw new CouponExpireException();
}
}
쿠폰의 use 메서드이고, 만료일과 사용 여부를 확인하고 예외가 발생하면 적절한 Exception을 발생시킨다.
컨트롤러 예외 처리
컨트롤러에서 모든 요청에 대한 값 검증을 진행하고 이상이 없을 시에 서비스 레이어를 호출해야 한다. 잘못된 값이 있으면 서비스 레이어에서 정상적인 작업을 진행하기 어렵다. 무엇보다 컨트롤러의 책임을 다 하고 있지 않으면 그 책임은 자연스럽게 다른 레이어로 전해지게 된다. 이 때, 넘겨받은 책임을 처리하는 큰 비용과 유지보수하기 어려워지는 문제가 생기게 된다.
컨트롤러의 중요한 책임 중 하나는 요청에 대한 값 검증이다. 스프링은 JSR 303 기반 어노테이션으로 값 검증을 쉽고 일관성 있게 처리할 수 있도록 도와준다. 모든 예외는 @ControllerAdvice로 선언된 객체에서 핸들링 된다. 컨트롤러로 본인이 직접 예외까지 처리하지 않고 예외가 발생하면 그냥 던져버리는 패턴으로 일관성있게 개발할 수 있다.
Controller
@RestController
@RequestMapping("/members")
public class MemberApi {
private final MemberSignUpService memberSignUpService;
@PostMapping
public MemberResponse create(@RequestBody @valid final SignUpRequest dto) {
final Member member = memberSignUpService.doSignUp(dto);
return new MemberResponse(member);
}
}
public class SignUpRequest {
@Valid private Email email;
@Valid private Name name;
}
public class Name {
@NotEmpty private String first;
private String middle;
@NotEmpty private String last;
}
public class Email {
@javax.validation.constraints.Email
private String value;
}
회원 가입 Request Body 중에서 유효하지 않은 값이 있을 때, @Valid 어노테이션으로 예외를 발생시킬 수 있다. 이 예외는 @ControllerAdvice에서 적절하게 핸들링 된다. @NotEmpty 외에도 다양한 어노테이션 제공
Try Catch 전략
기본적으로 예외가 발생하면 로직의 흐름을 끊고 종료 시켜야 한다.
try {
// 비즈니스 로직 수행...
} catch (Exception e) {
e.printStackTrace();
}
위와 같은 코드는 지양해야 하는 패턴이다. 최소한의 양심으로 e.printStackTrace(); 로그라도 출력했지만, 이미 예외가 발생했음에도 불구하고 다음 로직을 실행하게 된다. 이런식의 try catch를 최대한 지양해야 한다.
하지만 Checked Exception 같은 경우에는 예외를 반드시 감싸야 하므로 이러한 경우에는 try catch를 사용해야 한다.
try {
// 비즈니스 로직 수행
} catch (Exception e) {
e.printStackTrace();
throw new 비즈니스 로직 예외(e);
}
try catch를 사용해야 하는 경우라면 더 구체적인 예외로 Exception을 발생시키는 것이 좋다.
- try catch를 최대한 지양
- try catch를 사용한다면 로그라도 추가
- try catch를 사용한다면 된다면 더 구체적인 Exception을 발생시키는 것이 좋다.
Reference
https://cheese10yun.github.io/spring-guide-exception/
'Spring' 카테고리의 다른 글
Builder 패턴 (0) | 2022.09.02 |
---|---|
ResponseEntity (0) | 2022.09.02 |
RestTemplate, WebClient (0) | 2022.08.24 |
@RequestParam, @PathVariable (0) | 2022.08.19 |
HttpClient, HttpEntity (0) | 2022.08.18 |