| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- SpringCloud
- 마이크로서비스아키텍처
- 백엔드면접준비
- github actions
- 도커
- CS
- GitHub Packages
- PostgreSQL
- springboot
- Flyway
- 마이그레이션
- 컨테이너
- Java 8
- GCP
- Database
- 백엔드
- 마이크로서비스
- dockercompose
- 공통모듈
- 자바
- 트러블슈팅
- 멀티모듈
- java
- ci/cd
- 아키텍처
- 인프라
- MSA
- docker
- gradle
- 분산시스템
- Today
- Total
NYO_O
공통 모듈 적용 - 일관된 전역 예외 처리 로직 구축 본문
지난 시간, 공통 모듈을 도메인 서비스에 연동하는 전체 과정을 살펴보았습니다. Auto-configuration을 구성해두면 의존성 선언만으로 공통 모듈의 기능이 각 서비스에 자연스럽게 녹아드는 구조였습니다.
이제 실제로 공통 모듈에 무언가를 담아볼 차례입니다. 가장 먼저 다룰 주제는 예외 처리입니다.
MSA 환경에서는 서비스가 여러 개로 나뉘어 있기 때문에, 예외 처리 방식이 서비스마다 제각각이면 프론트엔드 팀은 서비스별로 다른 에러 응답 형식을 모두 파악해야 합니다. 공통 모듈에 예외 처리 로직을 구축해두면 모든 서비스가 동일한 형태의 에러 응답을 보장할 수 있습니다. 오늘은 이 구조를 어떻게 설계하고 구현하는지 정리해 보겠습니다.
에러 관리 전략 비교
본격적인 구현에 앞서, 에러를 어떻게 관리할 것인지 전략을 먼저 고민해볼 필요가 있습니다. 흔하게 사용하는 두 가지 방식을 살펴보겠습니다.
Enum ErrorCode 방식
가장 일반적으로 사용하는 방식입니다. Enum 내부에 HTTP 상태 코드와 에러 메시지를 직접 정의합니다.
public enum TradeErrorCode {
CANCEL_REASON_INVALID(HttpStatus.BAD_REQUEST, "유효하지 않은 취소 사유입니다.");
private final HttpStatus status;
private final String message;
}
IDE 자동완성과 컴파일 타임 검증이 가능해서 개발 편의성이 높습니다. 존재하지 않는 에러 코드를 작성하면 바로 컴파일 에러가 발생하기 때문에 휴먼 에러를 미리 차단할 수 있습니다.
다만 에러 메시지 문구 하나를 바꾸려면 자바 코드를 직접 수정하고 서버를 재배포해야 합니다. 더 근본적인 문제는 관심사 분리(SoC) 위배입니다. 비즈니스 규칙만 담아야 할 도메인 계층이 웹 계층의 정보인 HttpStatus와 표현 계층의 정보인 에러 메시지를 직접 들고 있게 됩니다.
Enum은 '어떤 에러가 발생했는지' 식별하는 용도로는 좋지만, '어떤 메시지를 보여줄 것인지' 담당하기에는 너무 무겁고 유연하지 못합니다.
application-error.yaml 방식
Enum의 단점을 극복하기 위해, 에러에 대한 모든 정보를 YAML 파일에 저장하고 자바 코드에서는 문자열 키(Key)만 호출하는 방식입니다.
error:
trade:
validation:
traded-item-id:
required:
code: "T001"
status: 400
message: "거래 상품 ID는 필수입니다."
에러 메시지가 바뀌어도 자바 코드는 수정하지 않아도 됩니다. 향후 Spring Cloud Config Server와 연동하면 서버 재배포 없이 실시간으로 메시지를 교체하는 것도 가능합니다.
반면 문자열 키는 IDE 자동완성이 되지 않아 오타가 나더라도 컴파일러가 잡아주지 못합니다. 해당 로직이 실제로 호출되는 순간에야 500 에러로 드러나기 때문에, 잘못 작성한 키 하나가 운영 서버 장애로 직결될 수 있습니다.
Enum + YAML 혼합 방식
두 방식의 단점을 상호 보완하는 방법이 있습니다. 에러의 식별자(Key)는 Enum이 담당하고, 메시지의 내용은 YAML이 담당하도록 역할을 분리하는 것입니다.
[도메인 계층] [공통 예외 처리기] [YAML]
TradeErrorCode.XXX → getErrorKey()로 키 추출 → 계층형 구조에서 조회
자바 코드에서는 오타가 날 수 없는 Enum 객체만 다루므로 IDE 자동완성과 컴파일 타임 검증을 그대로 사용할 수 있습니다. 동시에 에러 메시지가 바뀌어도 자바 코드는 수정할 필요가 없습니다. 도메인 계층은 "어떤 비즈니스 예외가 발생했는지"만 신경 쓰고, HTTP 상태 코드와 메시지 관리는 설정 파일과 공통 예외 처리기가 전담하는 구조입니다.
에러를 하나 추가할 때 Enum과 YAML 두 곳에 모두 작성해야 하는 번거로움이 있고, Enum에는 추가했지만 YAML에 빠뜨리면 런타임 에러가 발생할 수 있다는 점은 단점입니다. 이 부분은 테스트 코드로 방어하는 것이 좋습니다.
오늘은 이 혼합 방식을 기준으로 공통 모듈을 구축하는 과정을 정리해 보겠습니다.
공통 모듈 구현
ErrorCode 인터페이스
package org.example.common.exception;
public interface ErrorCode {
String getErrorKey();
}
모든 도메인의 에러 Enum은 이 인터페이스를 구현하도록 강제합니다. 어떤 도메인의 에러든 동일한 방식(getErrorKey())으로 문자열 식별자를 꺼낼 수 있도록 계약을 맺는 역할입니다.
CustomException
package org.example.common.exception;
import lombok.Getter;
@Getter
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
private final String field;
public CustomException(ErrorCode errorCode) {
this(errorCode, null);
}
public CustomException(ErrorCode errorCode, String field) {
this.errorCode = errorCode;
this.field = field;
}
}
각 도메인에서 예외를 발생시킬 때는 이 CustomException(또는 이를 상속받은 도메인 예외)을 사용합니다. field는 유효성 검증 오류 등에서 어떤 필드가 문제였는지 함께 전달할 때 활용합니다.
ErrorConfigProperties (YAML 바인더)
애플리케이션이 기동될 때 application-*-error.yaml 파일들을 읽어 자바 메모리에 올려두는 역할을 합니다. 도메인별 계층형 YAML 구조를 그대로 반영할 수 있도록 중첩 클래스로 구성합니다.
package org.example.common.exception;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "error")
public class ErrorConfigProperties {
// 최상위: 도메인명 (trade, order, member, ...)
private Map<String, DomainErrors> domains = new HashMap<>();
@Getter
@Setter
public static class DomainErrors {
// 두 번째 계층: 카테고리 (validation, not-found, ...)
private Map<String, CategoryErrors> categories = new HashMap<>();
}
@Getter
@Setter
public static class CategoryErrors {
// 세 번째 계층: 필드명 (traded-item-id, traded-item-name, ...)
private Map<String, FieldErrors> fields = new HashMap<>();
}
@Getter
@Setter
public static class FieldErrors {
// 네 번째 계층: 에러 종류 (required, length-exceeded, ...)
private Map<String, ErrorDetail> errors = new HashMap<>();
}
@Getter
@Setter
public static class ErrorDetail {
private String code;
private int status;
private String message;
}
}
YAML의 error.trade.validation.traded-item-id.required 경로가 domains → DomainErrors → CategoryErrors → FieldErrors → ErrorDetail로 자연스럽게 매핑됩니다.
ErrorDetail을 조회하는 편의 메서드를 함께 추가해두면 GlobalExceptionAdvice에서 활용하기 좋습니다.
// ErrorConfigProperties에 추가
public Optional<ErrorDetail> findDetail(String errorKey) {
// errorKey 형식: "domain.category.field.errorType"
// 예: "trade.validation.traded-item-id.required"
String[] parts = errorKey.split("\\.");
if (parts.length != 4) return Optional.empty();
return Optional.ofNullable(domains.get(parts[0]))
.map(d -> d.getCategories().get(parts[1]))
.map(c -> c.getFields().get(parts[2]))
.map(f -> f.getErrors().get(parts[3]));
}
GlobalExceptionAdvice
도메인 로직에서 CustomException이 던져지면 이를 잡아 최종 처리하는 곳입니다. Enum과 YAML이 여기서 결합됩니다.
@Slf4j
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionAdvice {
private final ErrorConfigProperties errorConfigProperties;
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
String errorKey = e.getErrorCode().getErrorKey();
return errorConfigProperties.findDetail(errorKey)
.map(detail -> {
log.error("[TraceID: {}] CustomException: field={}, errorKey={}, message={}",
MDC.get("traceId"), e.getField(), errorKey, detail.getMessage(), e);
HttpStatus status = HttpStatus.valueOf(detail.getStatus());
return ResponseEntity
.status(status)
.body(ErrorResponse.of(status, detail.getCode(), detail.getMessage()));
})
.orElseGet(() -> {
log.error("[TraceID: {}] Undefined Error Key: field={}, errorKey={}",
MDC.get("traceId"), e.getField(), errorKey, e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, e.getField(), "정의되지 않은 서버 에러가 발생했습니다."));
});
}
}
동작 흐름을 간단히 정리하면 다음과 같습니다.
- 발생한 예외 객체에서 Enum을 꺼내 식별자(Key)를 추출합니다.
- findDetail()로 YAML의 계층 구조를 탐색해 ErrorDetail을 조회합니다.
- ErrorDetail에서 HTTP 상태 코드와 메시지를 꺼내 응답을 구성합니다.
- YAML에 정의되지 않은 Key라면 500으로 처리하고 로그를 남깁니다.
도메인 서비스에서의 적용 방법
공통 모듈 구현이 완료되면 각 도메인 서비스에서는 세 단계만 거치면 됩니다.
Step 1. application-{도메인}-error.yaml 작성
도메인에서 사용할 에러 메시지를 계층형 YAML 구조로 작성합니다. 최상위는 error → 도메인명 → 카테고리 → 필드명 → 에러 종류 순으로 뎁스를 구성합니다.
# application-trade-error.yaml
error:
trade:
validation:
traded-item-id:
required:
code: "T001"
status: 400
message: "거래 상품 ID는 필수입니다."
traded-item-name:
required:
code: "T002"
status: 400
message: "거래 상품명은 필수입니다."
length-exceeded:
code: "T003"
status: 400
message: "거래 상품명은 255자 이하이어야 합니다."
traded-item-price:
required:
code: "T004"
status: 400
message: "거래 상품의 가격은 필수입니다."
invalid-range:
code: "T005"
status: 400
message: "거래 상품의 가격은 0보다 커야 합니다."
계층형 구조 덕분에 어떤 도메인의 어떤 필드에서 어떤 종류의 에러인지 YAML만 봐도 한눈에 파악할 수 있습니다. 새로운 도메인이 추가되더라도 최상위 키만 다르게 작성하면 충돌 없이 독립적으로 관리됩니다.
작성한 YAML 파일은 서비스의 application.yaml에서 profile로 포함시켜 줍니다.
# application.yaml
spring:
profiles:
include:
- trade-error
Step 2. ErrorCode Enum 작성
공통 모듈의 ErrorCode 인터페이스를 구현하여 도메인 전용 에러 코드 Enum을 작성합니다. getErrorKey()가 반환하는 문자열은 YAML 경로를 도트(.)로 이은 형태여야 합니다.
package org.example.trade.domain.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.example.common.exception.ErrorCode;
@Getter
@RequiredArgsConstructor
public enum TradeErrorCode implements ErrorCode {
// Traded Item
TRADED_ITEM_ID_REQUIRED("trade.validation.traded-item-id.required"),
TRADED_ITEM_NAME_REQUIRED("trade.validation.traded-item-name.required"),
TRADED_ITEM_NAME_LENGTH_EXCEEDED("trade.validation.traded-item-name.length-exceeded"),
TRADED_ITEM_PRICE_REQUIRED("trade.validation.traded-item-price.required"),
TRADED_ITEM_PRICE_INVALID_RANGE("trade.validation.traded-item-price.invalid-range");
private final String errorKey;
}
Enum의 errorKey 값이 YAML의 error.{errorKey} 경로와 정확히 대응됩니다. trade.validation.traded-item-id.required라면 error.trade.validation.traded-item-id.required를 탐색하는 방식입니다.
Step 3. 비즈니스 로직에서 사용
비즈니스 로직(application, domain 계층)에서는 HTTP 상태 코드나 에러 메시지를 직접 작성하지 않고, ErrorCode Enum만 예외에 담아 던집니다.
private void validateRequired(UUID tradedItemId, BigDecimal price) {
if (tradedItemId == null) {
throw new TradeDomainException(TradeErrorCode.TRADED_ITEM_ID_REQUIRED);
}
if (price == null) {
throw new TradeDomainException(TradeErrorCode.TRADED_ITEM_PRICE_REQUIRED);
}
}
도메인 계층은 "어떤 비즈니스 규칙이 위반됐는지"만 표현하면 됩니다. 어떤 HTTP 상태 코드로 응답할지, 어떤 메시지를 내보낼지는 공통 모듈의 GlobalExceptionAdvice가 알아서 처리합니다.
전체 흐름 정리
지금까지 구성한 전체 흐름을 한눈에 보면 다음과 같습니다.
[도메인 계층]
throw new TradeDomainException(TradeErrorCode.TRADED_ITEM_ID_REQUIRED)
│ errorKey = "trade.validation.traded-item-id.required"
▼
[GlobalExceptionAdvice]
errorConfigProperties.findDetail("trade.validation.traded-item-id.required")
│ domains["trade"] → categories["validation"] → fields["traded-item-id"] → errors["required"]
▼
[ErrorConfigProperties (YAML)]
code: "T001", status: 400, message: "거래 상품 ID는 필수입니다."
│
▼
[클라이언트 응답]
HTTP 400
{ "code": "T001", "message": "거래 상품 ID는 필수입니다." }
마무리
오늘은 Enum과 계층형 YAML을 결합하여 타입 세이프함과 유연성을 함께 확보하는 예외 처리 구조를 살펴보았습니다. 중첩 클래스 기반의 ErrorConfigProperties를 통해 YAML의 계층 구조를 그대로 자바 객체로 매핑하고, 도메인 계층은 어떤 예외가 발생했는지만 표현하면 나머지는 공통 모듈이 전담하는 관심사 분리가 핵심이었습니다.
다음 시간에는 공통 모듈에 구축할 두 번째 기능으로, 여러 서비스에서 일관된 페이징 응답을 보장하기 위한 Custom PageResolver를 다루어 보겠습니다.
'Architecture > MSA' 카테고리의 다른 글
| 페이징 표준화를 위한 Custom PageResolver (0) | 2026.05.27 |
|---|---|
| 공통 모듈의 역할별 분리 전략 (0) | 2026.05.27 |
| 공통 모듈 의존성 주입 및 연동 가이드 (0) | 2026.05.27 |
| GitHub Packages를 활용한 공통 모듈 배포 파이프라인 구축 (0) | 2026.05.27 |
| 공통 모듈, 무엇을 담고 무엇을 빼야할까? (1) | 2026.05.27 |