반응형

서론


개인 프로젝트를 진행하던 중 예외 코드가 하드 코딩으로 관리되고 있어서 방법을 찾아보던 중
세 가지 후보를 고민했다.

  1. 설정 파일
  2. DB
  3. 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);
    }
}

ValidationResultBindExceptionmessageSource를 위임한다.
실제 예외는 MethodArgumentNotValidException이 발생한다. BindException의 자식이다.
국제화는 적용하지 않기 떄문에 Locale.KOREA를 사용한다.

  • validationResult.getErrors().get(0) : 예외가 여러개 겹칠 수 있다. 이럴 경우 모든 메시지를 보여주지 않고,
    랜덤으로 선택된 1개만 응답해준다. 굳이 전부 보여줄 필요는 없다고 생각하기 때문이다.

application.properties

spring.messages.basename=errors

마지막으로 MessageSource가 설정파일을 읽을 수 있도록 errors를 등록한다.
디폴트는 message이다.

참고 자료

반응형
복사했습니다!