BindProject

[Backend] exception (CustomErrorCode 구현)

dding-shark 2025. 6. 17. 01:41
728x90

예외 모듈 설계와 공통 처리 전략

Bind 프로젝트는 서비스 구조를 퍼블릭/서비스 모듈로 분리하여 확장성과 재사용성을 확보하는 데 초점을 맞추고 있습니다.
이번 글에서는 퍼블릭 레이어 중 하나인 exception 모듈을 설계하게 된 이유와,
이를 어떻게 구현하고 테스트했는지를 설명합니다.


1. 도입 및 배경

초기에는 서비스마다 각기 다른 방식으로 예외 처리를 하다 보니 다음과 같은 문제가 발생했습니다:

  • 클라이언트로 전달되는 에러 응답 형식이 제각각
  • 예외 메시지에 일관성이 없음
  • 도메인별 에러 코드를 추적하거나 관리하기 어려움

이를 해결하고자 예외 처리 방식을 통합한 공통 예외 처리 모듈을 설계하게 되었습니다.


2. 설계 목표 및 구조

  • BusinessException을 중심으로 한 공통 예외 처리 방식 정립
  • ErrorCode를 enum 기반으로 도메인화하여 관리 가능하게 함
  • HTTP 상태 코드, 메시지, 에러 코드의 일관성 유지
  • 서비스/도메인 모듈에서 재사용 가능한 경량 모듈

3. 설계 시 고려한 사항

  • 퍼블릭 모듈로써 Spring에 과도하게 종속되지 않아야 함
  • Kafka, Redis, Scheduler 등 비동기 시스템에서도 재사용 가능해야 함
  • 도메인별로 에러 코드를 분리할 수 있도록 확장성을 확보해야 함
  • API 응답에만 국한되지 않고 로깅, DLQ 처리 등 내부 사용도 고려

4. 모듈 구조

exception/
├── CustomErrorCode.java              # 에러 코드 정의 인터페이스
├── GlobalErrorCode.java           # 공통 에러 enum
├── BusinessException.java         # 커스텀 런타임 예외
├── ExceptionResponse.java         # 클라이언트에 반환할 DTO
└── GlobalExceptionHandler.java    # 전역 예외 핸들러

5. 핵심 구현 코드

CustomErrorCode.java

public interface CustomErrorCode {
    String getCode();
    String getMessage();
    HttpStatus getStatus();
}

GlobalErrorCode.java

public enum GlobalErrorCode implements CustomErrorCode {
    INVALID_INPUT("COMMON-001", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST),
    INTERNAL_ERROR("COMMON-999", "서버 내부 오류입니다.", HttpStatus.INTERNAL_SERVER_ERROR);
}

BusinessException.java

public class BusinessException extends RuntimeException {
    private final CustomErrorCode errorCode;

    public BusinessException(BindErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public BindErrorCode getErrorCode() {
        return errorCode;
    }
}

ExceptionResponse.java

public record ExceptionResponse(String code, String message, int status) {
    public static ExceptionResponse from(BindErrorCode code) {
        return new ExceptionResponse(code.getCode(), code.getMessage(), code.getStatus().value());
    }
}

GlobalExceptionHandler.java

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ExceptionResponse> handleBusiness(BusinessException ex) {
        return ResponseEntity
                .status(ex.getErrorCode().getStatus())
                .body(ExceptionResponse.from(ex.getErrorCode()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ExceptionResponse> handleUnexpected(Exception ex) {
        return ResponseEntity
                .status(GlobalErrorCode.INTERNAL_ERROR.getStatus())
                .body(ExceptionResponse.from(GlobalErrorCode.INTERNAL_ERROR));
    }
}

6. 테스트 전략

단위 테스트

  • BusinessExceptionErrorCode를 제대로 포함하는지 확인
  • ExceptionResponse.from(...)가 정확히 변환되는지 검증

통합 테스트

@SpringBootTest(classes = GlobalExceptionHandlerTest.TestConfig.class)
@AutoConfigureMockMvc
class GlobalExceptionHandlerTest {

    @Autowired private MockMvc mockMvc;

    @Test
    void shouldReturnCustomErrorResponse() throws Exception {
        mockMvc.perform(get("/sample/error"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("COMMON-001"));
    }

    @TestConfiguration
    @RestController
    static class TestConfig {
        @GetMapping("/sample/error")
        public void errorEndpoint() {
            throw new BusinessException(GlobalErrorCode.INVALID_INPUT);
        }
    }
}

퍼블릭 모듈에는 @SpringBootApplication이 없기 때문에
@SpringBootTest(classes = ... ) 로 명시적인 컨텍스트를 제공해야 합니다.


7. 회고 및 확장 가능성

  • 에러 코드 분리로 문제 추적성과 관리 효율성이 크게 개선됨
  • API 응답 포맷이 표준화되어 프론트와의 계약도 훨씬 명확해졌음
  • 추후 Kafka 이벤트 발행 실패에도 동일한 BusinessException 기반으로 처리 가능
  • 서비스별로 UserErrorCode, OrderErrorCode 등을 정의하면 도메인 확장도 무리 없이 가능함

728x90