반응형
개발을 하다 보면 수많은 예외(Exception)를 마주하게 된다. 하지만 모든 컨트롤러에서 try-catch를 남발한다면 코드는 지저분해지고 유지보수는 어려워진다. 오늘은 예외를 한 곳에서 처리해야 하는 이유와 더 견고한 애플리케이션을 위한 실무적인 예외 처리 전략을 정리해 보았다.
1. 예외 처리 흐름도 (Visual Flow)
예외가 발생했을 때 애플리케이션 내부에서 어떤 경로로 처리되는지 시각화하면 다음과 같습니다.

[동작 순서]
- Client: API 요청 전송.
- Controller: 비즈니스 로직 수행 중 예외 발생 (throw).
- GlobalExceptionHandler: @RestControllerAdvice가 예외를 인터셉트.
- Server Log: 상세한 에러 정보(Trace ID, Stack Trace)를 로그에 기록.
- Client Response: 정제된 에러 메시지와 상태 코드를 JSON으로 반환.
2. 예외를 한 곳에서 처리하는 이유 (Why?)
가장 큰 이유는 일관성과 관심사의 분리입니다.
- 중복 제거: 컨트롤러마다 반복되는 예외 처리 로직을 제거할 수 있습니다.
- 일관된 응답 구조: 에러 발생 시 사용자에게 항상 동일한 포맷의 JSON 응답을 내려줄 수 있습니다.
- 비즈니스 로직 집중: 컨트롤러는 본연의 기능에만 집중하고, 예외 처리는 전담 클래스(GlobalExceptionHandler)에게 맡깁니다.
3. 실무형 개선 포인트 반영: 구조화된 예외 처리
단순히 예외를 잡는 것을 넘어, 확장성 있게 설계하는 것이 중요합니다. 아래 코드는 에러 코드의 Enum화와 데이터 검증 예외 처리를 반영한 모습입니다.
① 에러 코드 정의 (ErrorCode Enum)
상태 코드와 메시지를 별도로 관리하여 코드의 가독성을 높입니다.
@Getter
@AllArgsConstructor
public enum ErrorCode {
INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "입력 값이 올바르지 않습니다."),
ENTITY_NOT_FOUND(HttpStatus.NOT_FOUND, "C002", "찾을 수 없는 리소스입니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "S001", "서버 내부에 오류가 발생했습니다.");
private final HttpStatus status;
private final String code;
private final String message;
}
② GlobalExceptionHandler 구현
개선된 포인트를 적용하여 더 정밀하게 예외를 처리합니다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* @Valid 검증 실패 시 발생하는 예외 처리
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpServletRequest request) {
logClientError(ErrorCode.INVALID_INPUT_VALUE.getCode(), request, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE.getCode(), "검증에 실패한 필드가 있습니다."));
}
/**
* 비즈니스 로직 중 발생하는 잘못된 인자 예외 처리
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e, HttpServletRequest request) {
logClientError(ErrorCode.INVALID_INPUT_VALUE.getCode(), request, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(ErrorCode.INVALID_INPUT_VALUE.getCode(), ErrorCode.INVALID_INPUT_VALUE.getMessage()));
}
// 서버 로그 기록 (Observability 전략)
private void logClientError(String code, HttpServletRequest request, Exception e) {
log.warn("[CLIENT ERROR] code={} endpoint={} method={} message={}",
code, request.getRequestURI(), request.getMethod(), e.getMessage());
}
}
4. 서버를 위한 전략: Observability (관측 가능성)
에러 발생 시 "어디서, 왜" 발생했는지 모르면 운영 단계에서 대응이 불가능합니다.
- Trace ID 사용: 분산 환경에서 요청의 시작부터 끝까지를 식별할 수 있는 ID를 로그에 함께 남깁니다.
- Sanitization (오염 제거): 내부 DB 에러나 라이브러리 메시지를 사용자에게 그대로 노출하지 마세요. 이는 보안 사고로 이어질 수 있습니다. 사용자에게는 "시스템 오류가 발생했습니다"라고 말하고, 서버 로그에는 정확한 원인과 스택 트레이스를 남겨야 합니다.
5. 요약 및 결론
- 중앙 제어: @RestControllerAdvice로 예외 처리 창구를 단일화하세요.
- 구조화: ErrorCode Enum을 사용하여 에러 메시지와 상태 코드를 관리하세요.
- 검증: MethodArgumentNotValidException 등을 처리하여 클라이언트에게 친절한 가이드를 제공하세요.
- 보안: 사용자에게 노출되는 메시지와 개발자가 보는 로그를 철저히 분리하세요.
예외 처리는 단순한 에러 막기가 아니라, 서비스의 안정성과 보안을 결정짓는 중요한 설계입니다. 여러분의 프로젝트에도 견고한 예외 처리 시스템을 구축해 보세요!
반응형
'Spring > Common' 카테고리의 다른 글
| [Spring] 빈 생명주기 콜백: 애플리케이션의 시작과 종료 관리 (0) | 2026.05.10 |
|---|---|
| [Spring] 스프링 싱글톤 컨테이너: 왜 모든 빈은 '하나'여야 할까? (0) | 2026.05.09 |
| [Spring] 스프링 프레임워크와 객체 지향의 본질: 역할과 구현의 분리 (0) | 2026.05.04 |
| [Spring] 엔티티 메타데이터 자동화: JPA Auditing을 활용한 일관된 데이터 관리 (0) | 2026.04.25 |
| [Spring] TraceId 로깅 시스템 실무 구축 : MDC와 Filter를 이용한 로그 데이터화 (0) | 2026.04.24 |