서론
개인 프로젝트를 진행하던 중 예외 코드가 하드 코딩으로 관리되고 있어서 방법을 찾아보던 중
세 가지 후보를 고민했다.
- 설정 파일
- DB
- enum
비지니스 커스텀 예외는 Enum
실무에서는 설정 파일과 DB를 사용했었다.
이미 그렇게 되어있어 고민없이 사용하게 되었다.
이 때 느낀 것은 아래와 같았다.
비지니스 커스텀 예외란 직접 개발자가 throw new
하는 것으로 정의했다.
설정 파일
- 코드를 직접 문자열로 매핑을 해야한다.
생산성 저하 - 객체에 매핑하여 사용할 수 있지만 설정 파일을 읽어오기 위한 빈 주입이 필요하기 때문에 번거로울 수 있다.
DB
- 네트워킹이 필요하다.
하지만 처음 메인화면 오픈 시 캐싱해두면 되기 때문에 문제는 아니다. - 재적용이 아주 쉽다. 배포가 필요가 없다.
최고의 장점이다.
enum
- 코드를 직접 문자열로 작성하지 않고 사용할 수 있다.
생산성을 높여준다. - 순수하게 java만 사용하기 때문에 프레임워크에 종속적이지 않다.(크게 의미 없는듯)
- 관련 정보들을 그룹으로 관리할 수 있다.
개인적으로 유지보수를 생각할 때는 DB 사용하는 것이 좋다고 생각한다. 손쉽게 변경이 가능하기 때문이다.
하지만, 현재 개인 프로젝트는 규모가 작기 때문에 생산성을 위해 enum을 선택했다.
DB에 저장하고, 캐싱하는 로직을 작성하는 것 보다 쉽게 만들 수 있고, 실제 사용 시에도 위와 같은 장점때문에
더 생산성은 좋다고 생각한다.
적용
BizException.java
package project.myblog.exception;
public class BizException extends RuntimeException {
private final ExceptionCode exceptionCode;
public BizException(ExceptionCode exceptionCode) {
super(exceptionCode.getMessage());
this.exceptionCode = exceptionCode;
}
public ExceptionCode getErrorCode() {
return exceptionCode;
}
}
BizException은 비지니스 예외를 추상화한 것이다. 처음에는 각 예외마다 클래스를 생성했는데
항상 클래스가 많아진다는 고민이 있었는데 이번에는 BizException 1개로 관리해보기 위해 선택했다.
ExceptionCode.java
import org.springframework.http.HttpStatus;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
public enum ExceptionCode {
MEMBER_INVALID(BAD_REQUEST, "MEMBER_001", "존재하지 않는 회원입니다."),
MEMBER_AUTHORIZATION(FORBIDDEN, "MEMBER_002", "로그인이 필요합니다."),
MEMBER_AUTHENTICATION(UNAUTHORIZED, "MEMBER_003", "인증되지 않는 사용자입니다.");
public enum ExceptionCode {
MEMBER_INVALID(BAD_REQUEST, "MEMBER_001", "존재하지 않는 회원입니다."),
MEMBER_AUTHORIZATION(FORBIDDEN, "MEMBER_002", "로그인이 필요합니다."),
MEMBER_AUTHENTICATION(UNAUTHORIZED, "MEMBER_003", "인증되지 않는 사용자입니다.");
private final HttpStatus status;
private final String code;
private final String message;
ExceptionCode(HttpStatus status, String code, String message) {
this.status = status;
this.code = code;
this.message = message;
}
public HttpStatus getStatus() {
return status;
}
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
}
예외 코드를 관리하는 enum이다.
MemberService.java
public Member findMemberByEmail(String email) {
return memberRepository.findByEmail(email)
.filter(member -> !member.isDeleted())
.orElseThrow(() -> new BizException(ExceptionCode.MEMBER_INVALID));
}
이와 같이 사용한다. BizException을 생성하는데 enum에서 원하는 예외 코드를 파라미터로 넘겨준다.
즉, 모든 비지니스 예외는 BizException으로 통일되기 때문에 예외 클래스가 늘어나지 않는다는 장점이 된다.
ExceptionAdviceController.java
@RestControllerAdvice
public class ExceptionAdviceController {
@ExceptionHandler(BizException.class)
public ResponseEntity<ExceptionResponse> handlerBizException(BizException e) {
ExceptionResponse exceptionResponse = new ExceptionResponse(e.getErrorCode());
return ResponseEntity.status(exceptionResponse.getStatus()).body(exceptionResponse);
}
}
@ExceptionHandler를 사용한다. enum에 모든 값이 들어있기 때문에 하나의 핸들러로 모든 비지니스 예외 처리가 가능하다.
ExceptionResponse.java
public class ExceptionResponse {
private final HttpStatus status;
private final String code;
private final String message;
public ExceptionResponse(ExceptionCode exceptionCode) {
this.status = exceptionCode.getStatus();
this.code = exceptionCode.getCode();
this.message = exceptionCode.getMessage();
}
public HttpStatus getStatus() {
return status;
}
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
}
클라이언트에게 응답해줄 템플릿이다.
Bean Validation 예외는 설정 파일
요청 파라미터 예외 발생 시 BindException
이 발생한다.
아래와 같은 코드에서 @Valid
가 붙은 것을 의미한다.
@PatchMapping(value = "/members/me/subject", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> updateMemberOfMineSubject(@Login LoginMember loginMember,
@Valid @RequestBody MemberSubjectRequest request) {
memberService.updateMemberOfMineSubject(loginMember.getEmail(), request.getSubject());
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
설정 파일을 선택한 이유
- 비지니스 커스텀 예외는 직접 예외를 발생시킬 수 있어 enum을 주입하면 된다.
하지만 프레임워크가 발생시키는 예외는 컨트롤이 불가능하다 - 어떻게 만들 수는 있겠지만 필드 검증은 비슷한 메시지들이 많이 발생한다.
이런 경우 인자를 파라미터로 받아 인자만 변경하는 경우도 있다.
Size.introduction=한 줄 소개는 {2}자 이상 {1}자 이하여야 합니다.
- @NotNull(message = “널 안돼”)과 같이 사용하면 코드가 지저분해진다.
예외 발생 케이스
@NotNull
과 같이 validation 애노테이션 조건에 만족하지 못하면 발생한다.
public class MemberIntroductionRequest {
@NotNull
@NotBlank
@Size(min = 1, max = 30)
private String introduction;
protected MemberIntroductionRequest() {
}
...
}
MessageCodesResolver를 알아보자.
먼저 Bean Validation을 검증하는 방식을 알아보자.
그 전에 간단하게 LocalValidatorFactoryBean
를 알아보자.
``스프링 부트는 LocalValidatorFactoryBean
을 글로벌 Validator
로 등록한다.
이 Validator
는 @NotNull
같은 애노테이션을 보고 검증을 수행한다.
즉, 우리는 Validator를 만들 필요가 없다.
MessageCodesResolver
- 검증 오류 코드로 메시지 코드들을 생성한다.
MessageCodesResolver
는 인터페이스 이고,DefaultMessageCodesResolver
구현체를 제공한다.- 주로 ObjectError, FieldError와 함께 사용한다.
중요한 건 DefaultMessageCodesResolver의 메시지 생성 규칙이다.
DefaultMessageCodesResolver 메시지 생성 규칙
객체 오류
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
- form 요청된 특정 파라미터가 아닌 복합적인 요소에 의한 것
- BindResult와 ObjectError객체 사용
필드 오류
필드 오류의 경우 다음 순서로4가지 메시지 코드 생성
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code
예) 오류 코드: NotBlank, object name "memberIntroductionRequest", field "introduction", field type: String
1. "NotBlank.memberIntroductionRequest.introduction"
2. "NotBlank.introduction"
3. "NotBlank.java.lang.String"
4. "NotBlank"
- form 요청된 파라미터가 문제일 경우
- BindResult와 FieldError객체 사용
bindingResult*.rejectValue("itemName", "required");
이와 같이 MessageCodesResolver
가 에러 코드를 생성해준다. 그러면 우리는 이 에러코드를 사용하면 된다.
어떻게 사용할까?
생성된 코드 사용하기
타임리프 같은 뷰 템플릿을 사용하면 MessageSource를 사용하여, 알아서 코드를 읽어
바인딩을 한다. 하지만 아쉽게도 스프링은 API일 경우 이를 지원하고 있지 않다.
그렇다면, 직접 만들어야 한다. 하지만 MessageSource
도 스프링이 구현체를 제공하기 때문에 읽을 정보를
넘겨주기만 하면 된다.
MemberIntroductionRequest.java(DTO)
public class MemberIntroductionRequest {
@NotNull
@NotBlank
@Size(min = 1, max = 30)
private String introduction;
protected MemberIntroductionRequest() {
}
...
}
검증될 DTO이다.
우리가 활용할 것은 MessageSource, FieldError
이다.
FieldErrorDetail.java
public class FieldErrorDetail {
private final String objectName;
private final String field;
private final String code;
private final String message;
public FieldErrorDetail(FieldError fieldError, MessageSource messageSource, Locale locale) {
this.objectName = fieldError.getObjectName();
this.field = fieldError.getField();
this.code = fieldError.getCode();
this.message = messageSource.getMessage(fieldError, locale);
System.out.println();
}
public String getObjectName() {
return objectName;
}
public String getField() {
return field;
}
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
}
에러 정보를 담을 클래스이다.
- FieldError : 필드 에러 정보들이 모두 담겨있다.
MessageCodesResolver
가 생성한 코드 정보들이 담겨있다.
참고로ResourceBundleMessageSource
가 구현체이다. - MessageSource : FieldError에 있는 정보를 기반으로 errors.properties에 접근하여,
매핑되는 코드가 있는지 확인한다. 없을 경우 NoSuchMessageException을 발생시킨다.
ValidationResult.java
FieldErrorDetail을 호출하는 검증 결과를 갖는 클래스이다.
public class ValidationResult {
private final List<FieldErrorDetail> errors;
public ValidationResult(Errors errors, MessageSource messageSource, Locale locale) {
this.errors = errors.getFieldErrors()
.stream()
.map(error -> new FieldErrorDetail(error, messageSource, locale))
.collect(Collectors.toList());
}
public List<FieldErrorDetail> getErrors() {
return errors;
}
}
- Errors :
BindException
의 interface이다.
ExceptionAdviceController.java
@RestControllerAdvice
public class ExceptionAdviceController {
private final MessageSource messageSource;
@ExceptionHandler({BindException.class})
public ResponseEntity<ExceptionResponse> handlerMethodArgumentNotValidException(BindException e) {
ValidationResult validationResult = new ValidationResult(e, messageSource, Locale.KOREA);
FieldErrorDetail fieldErrorDetail = validationResult.getErrors().get(0);
ExceptionResponse exceptionResponse = ExceptionResponse.createBind(
HttpStatus.BAD_REQUEST, fieldErrorDetail.getCode(), fieldErrorDetail.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(exceptionResponse);
}
}
ValidationResult
에 BindException
과 messageSource
를 위임한다.
실제 예외는 MethodArgumentNotValidException
이 발생한다. BindException
의 자식이다.
국제화는 적용하지 않기 떄문에 Locale.KOREA
를 사용한다.
- validationResult.getErrors().get(0) : 예외가 여러개 겹칠 수 있다. 이럴 경우 모든 메시지를 보여주지 않고,
랜덤으로 선택된 1개만 응답해준다. 굳이 전부 보여줄 필요는 없다고 생각하기 때문이다.
application.properties
spring.messages.basename=errors
마지막으로 MessageSource가 설정파일을 읽을 수 있도록 errors를 등록한다.
디폴트는 message이다.
'Spring' 카테고리의 다른 글
스프링 순환 참조와 생성자 주입을 사용해야 하는 이유 (0) | 2022.04.20 |
---|---|
JPA Auditing으로 생성시간, 수정시간 자동화하기 (0) | 2022.04.19 |
Controller와 Service 레이어 간 의존성 관리 (0) | 2022.04.15 |
@NotNull vs @Column(nullable = false) (0) | 2022.04.15 |
@Configuration과 @TestConfiguration (0) | 2022.04.15 |