Series/내가 해본

ErrorMessage를 관리하는 방법

Hyunec 2022. 6. 15. 02:29
 

GitHub - Hyune-c/blogcode-errormessage

Contribute to Hyune-c/blogcode-errormessage development by creating an account on GitHub.

github.com

HTTP status code는 3자리의 코드와 설명으로 표현됩니다.
하지만 실무에서는 더 많고 다양한 종류의 에러 표현을 위해 name, code, reason을 활용하곤 합니다.
그리고 간결함과 생산성을 위해 프로젝트 내에서도 code와 reason이 직관적으로 연결되기를 기대합니다.

 

STEP 1. Enum으로 관리하기

@Getter
@RequiredArgsConstructor
public enum Errorcode {

 NOT_EXIST_PRODUCT("1001", "대상 상품 없음"),
 OUT_OF_STOCK("1200", "상품 재고 없음"),
 UNKNOWN("9999", "알 수 없는 에러"),
 ;

 private final String code;
 private final String reason;
}


@Entity
public class Product {

 ...
 
 public void order(final Integer quantity) {
  if (this.quantity < quantity) {
   throw new ProductException(Errorcode.OUT_OF_STOCK);
  }

  this.quantity -= quantity;
 }
}


@RestControllerAdvice
public class ControllerAdvice {

 @ExceptionHandler(ProductException.class)
 @ResponseStatus(HttpStatus.BAD_REQUEST)
 protected ErrorResponse handleBusinessException(final ProductException ex) {
  return new ErrorResponse(ex.getErrorcode());
 }
}

장점

  • 작은 프로젝트에 적합합니다.

단점

  • name, code 변경 시 배포가 필요합니다.
  • admin 개발이 필요한 경우 코드 중복이 발생합니다.
  • 프로젝트 밖에서 전체 에러 목록을 알기 힘듭니다.
  • 메시지 국제화가 어렵습니다.

 

구현 방법이 아주 쉬워 사이드 프로젝트를 할 때 많이 썼던 관리 방법이지만 단점이 꽤 있습니다.

 

STEP 2. MessageSource로 관리하기

@Getter
@RequiredArgsConstructor
public enum Errorcode {

 NOT_EXIST_PRODUCT("1001", "product.not_exist_product"),
 OUT_OF_STOCK("1200", "product.out_of_stock"),
 UNKNOWN("9999", "product.unknown"),
 ;

 private final String code;
 private final String propertiesCode;
 private String reason;

 @RequiredArgsConstructor
 @Component
 public static class ErrorreasonInjector {

  private final MessageSource messageSource;

  @Value("${application.locale}")
  private String locale;

  @PostConstruct
  public void postConstruct() {
   Arrays.stream(Errorcode.values())
     .forEach(errorcode -> errorcode.reason = messageSource.getMessage(errorcode.getPropertiesCode(), null, Locale.forLanguageTag(this.locale)));
  }
 }
}


// messages.properties
product.not_exist_product=대상 상품 없음
product.out_of_stock=상품 재고 없음
product.unknown=알 수 없는 에러

장점

  • 작은 프로젝트에 적합합니다.
  • 메시지 국제화가 용이합니다.

단점

  • name과 code 변경 시 배포가 필요합니다.
  • admin 개발이 필요한 경우 코드 중복이 발생합니다.
  • 프로젝트 밖에서 전체 에러 목록을 알기 힘듭니다.
  • MessageSource는 Map 자료구조와 같이 key, value 한 쌍만을 지원합니다.

 

실무 코드와 인프런의 김영한님 스프링 MVC 2편 - 메시지, 국제화를 보고 구현했습니다.
하지만 여전히 STEP 1의 단점을 가지고 있으며, 에러 표현에 필요한 name, code, reason의 값을 보여주기 힘듭니다.

 

STEP 3. DB에서 가져오기

@Getter
@RequiredArgsConstructor
public enum Errorcode {

 NOT_EXIST_PRODUCT,
 OUT_OF_STOCK,
 UNKNOWN;

 private String code;
 private String reason;

 @RequiredArgsConstructor
 @Component
 public static class ErrorreasonInjector {

  private final ErrorcodeDataRepository repository;

  @Value("${application.locale}")
  private String locale;

  @PostConstruct
  public void postConstruct() {
   Arrays.stream(Errorcode.values())
     .forEach(errorcode ->
       repository.findByNameAndLocale(errorcode.name(), locale)
         .ifPresent(e -> {
            errorcode.code = e.getCode();
            errorcode.reason = e.getReason();
           }
         )
     );
  }
 }
}


// data.sql
insert into errorcode_data(locale, name, code, reason)
values ('ko', 'NOT_EXIST_PRODUCT', '1001', '대상 상품 없음'),
       ('ko', 'OUT_OF_STOCK', '1200', '상품 재고 없음'),
       ('ko', 'UNKNOWN', '9999', '알 수 없는 에러');

장점

  • name, code, reason을 모두 표현할 수 있습니다.
  • 메시지 국제화가 용이합니다.
  • 프로젝트 밖에서 전체 에러 목록을 알기 쉽습니다.

단점

  • name 변경시 배포가 필요합니다.
  • DB 관리가 필요합니다.

 

앞에서 문제로 있던 것들을 조합하여 DB를 통해 구현했습니다.
name은 의도적으로 프로젝트 내에 두어 직관성을 높였습니다.

 

코드로 구현하지는 않았지만 더 생각해볼 것도 있습니다.

  • EnumMap을 활용할 수 있습니다.
  • DB 검색을 하는 STEP 3에서는 캐시를 활용할 수 있습니다.
    • 빠른 변경 반영을 위해 evict 정책, 백도어 API, 또는 pub/sub를 통한 갱신을 도입할 수 있습니다.
  • 에러 메시지를 동적으로 관리하고 싶다면
    • 메시지를 String.format() 형태로 만들고 CustomException을 만들 때 매개변수를 받을 수 있습니다.
    • 또는 CustomException 생성 시 별도의 필드를 활용할 수도 있습니다.
  • 완벽한 국제화는 BCP47 등 고려해야될 점이 더 많습니다.
  • 백엔드는 code만 관리하고 사용자에게 노출되는 reason은 프론트엔드가 관리하게 하는 방법도 있습니다.

 

마치며

지금의 회사는 조금 다른 형태로 사용하고 있습니다.
// 에러코드만으로 예외를 throw
throw new CustomException("4012");

// Supplier를 통해 네이밍은 했지만 일괄 검색은 힘듬
public static final Supplier<CustomException> ERROR_SLIP_DTL_NOT_FOUND = CustomException.supply("4012");

String의 4자리 숫자 code를 MessageSource의 find code 값으로 활용하고 있습니다.
MessageSource 기술을 활용하고 있긴 애매한 부분이 있다고 생각합니다.

  • 지금의 회사는 국제화를 고려하는 단계가 아닙니다.
  • code가 String이기에 type safe하지 않습니다.
  • 프로젝트 내에서 직관적인 code - reason 매핑이 힘듭니다.
  • 매핑을 위해서는 별도 관리되는 엑셀이나 DB를 직접 검색해야 합니다.

 

제가 선호하는 방식은 STEP 2와 STEP 3입니다.

 

에러 코드는 일반적인 도메인과 다른 성격을 가지고 있습니다.

  • 보통 한번 만들어진 code는 변경되지 않습니다.
  • 새로운 에러 유형이 생기면 새로운 code를 생성합니다.
  • 반면 reason은 좀 더 구체화되거나 에러를 나누는 등 변경될 수 있습니다.

 

이 관점에서 볼 때 STEP 2의 방법은

  • reason만 messages.properties로 관리됩니다.
  • properties 파일을 공유하는 방식으로 프로젝트 외부에서도 접근할 수 있습니다.

 

반면 STEP 3의 방법은 DB 관리가 필요하긴 하지만 간결함과 생산성을 모두 가져갈 수도 있습니다.