이번 글에서는 사이드 프로젝트를 진행하며 구성했던 전역 예외 처리(Global Exception Handling) 로직에 대해 정리 후 공유해보려고 합니다.
프로젝트 초기, “어떻게 하면 Error Code를 템플릿화하여 모든 도메인에서 공통적으로 사용할 수 있을까?”라는 고민이 가장 컸습니다. 특히 추후 Spring 프레임워크나 라이브러리에서 발생하는 표준 예외에도 적용할 수 있도록 확장성을 고려한 설계를 목표로 했습니다.
아래의 방법이 정답이라고 할 수는 없지만, 제가 고민하고 구현했던 기록을 공유합니다.
설계 목표 : 일관된 에러 응답 및 관심사 분리
이번 설계의 핵심 목표는 두 가지입니다.
- 일관된 에러 응답 제공: 애플리케이션 전체에서 발생하는 다양한 예외를 잡아 클라이언트에게 표준화된 형식의 JSON 에러 응답을 반환합니다.
- 관심사 분리 (SoC): 비즈니스 로직(Service/Controller)은 로직 수행에만 집중하고, 에러 처리(예외 발생 → 응답 포맷 변환)는 ExceptionAdvice에 완전히 위임합니다.
프로젝트 구조
예외처리와 관련된 모든 클래스를 global/exception 패키지 아래에 모아 모듈화하였습니다.
<project>/global/exception
├── BadRequestException.java // 400 Bad Request
├── ForbiddenException.java // 403 Forbidden
├── NotFoundException.java // 404 Not Found
├── MethodNotAllowedException.java // 405 Method Not Allowed
├── GlobalException.java // 500 Internal Server Error (최상위 예외)
├── ErrorResponseStatus.java // 에러 코드 및 메시지 정의 (Enum)
├── ErrorResponse.java // 클라이언트에게 반환할 응답 형식
└── ExceptionAdvice.java // 전역 예외 처리 핸들러 (핵심)
ErrorResponseStatus : 에러의 타입별 상태를 관리하는 Enum
애플리케이션 내부에서 발생하는 모든 오류 유형을 Enum으로 정의하고, 고유한 내부 에러 코드와 메시지를 부여하여 중앙에서 관리합니다.
public enum ErrorResponseStatus {
// 2000 : Request 오류
REQUEST_ERROR(false, 2001, "입력값을 확인해주세요."),
FAILED_TO_LOGIN_JWT(false,2004,"유효하지 않은 토큰입니다."),
// 4000 : 비즈니스 로직 에러
// ex) 4100: Place
NOT_FOUND_PLACE_CODE(false, 4100, "등록된 code가 없습니다."),
// 5000 : Server connection 오류
SERVER_ERROR(false, 5000, "서버와의 연결에 실패하였습니다.")
;
}
- 코드 중앙화: 모든 에러 상태를 Enum으로 관리하여 일관성을 유지하고 휴먼 에러를 방지합니다.
- 명확성 : REQUEST_ERROR와 같이 의미를 가지는 상수를 사용하여 코드의 가독성을 높입니다.
- HTTP 상태 코드와의 분리: 2000, 4100과 같은 코드는 HTTP 상태 코드(400, 500)와는 별개로, 비즈니스 로직의 상세 에러를 나타냅니다. 클라이언트는 HTTP 상태 코드와 이 내부 코드를 모두 사용하여 에러를 세밀하게 추적할 수 있습니다.
- 클라이언트 문서화: 이 코드를 바탕으로 백엔드-프론트엔드 간의 명확한 에러 코드를 정의할 수 있습니다.
Domain Method Code Message HTTP status Global FAILED_TO_LOGIN_JWT 2004 유효하지 않은 토큰입니다. 401 Place NOT_FOUND_PLACE_CODE 4100 등록된 code가 없습니다. 400 Global REQUEST_ERROR 2001 입력값을 확인해주세요. 400
ErrorResponse : 클라이언트에게 전송할 표준화된 에러 응답 형식 정의
클라이언트에게 반환되는 JSON 응답의 구조를 정의합니다.
@Getter
@AllArgsConstructor
public class ErrorResponse {
private final String timeStamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
private final boolean isSuccess;
private final int code;
private final String message;
public ErrorResponse(ErrorResponseStatus status) {
this.isSuccess = status.isSuccess();
this.code = status.getCode();
this.message = status.getMessage();
}
}
- TimeStamp: 예외 발생 시각을 기록하여 디버깅에 활용합니다.
- 불변성: 모든 필드를 final로 선언하여 객체의 안전성을 높였습니다.
- ErrorResponseStatus 객체만 넘기면 code와 message가 자동으로 채워져 표준화된 응답이 생성됩니다.
사용자 정의 예외 클래스
각 HTTP 상태 코드(400, 403, 404 등)에 대응하는 사용자 정의 예외 클래스를 구현합니다.
RuntimeException 상속 클래스
// 예시: ForbiddenException.java
public class ForbiddenException extends RuntimeException{
private ErrorResponseStatus status;
public ForbiddenException(ErrorResponseStatus status) {
this.status = status;
}
public ErrorResponseStatus getStatus() {
return this.status;
}
}
- RuntimeException 상속: 체크되지 않은 예외(Unchecked Exception)로 분류되어, Service나 Controller에서 반드시 try-catch를 사용하지 않아도 예외 흐름이 Spring 프레임워크로 자연스럽게 넘어갑니다.
- 상태 정보 포함: 예외 객체 생성 시 ErrorResponseStatus를 인자로 받아 내부 필드에 저장합니다. 이 정보가 최종적으로 ErrorResponse를 만드는 데 사용됩니다.
예외 클래스와 HTTP 상태 코드 매핑
아래의 클래스 모두가 같은 구성이지만, 각 HTTP 상태의 의미에 맞는 Response 값을 전달하기 위해 별도로 구분하여 정리했습니다.
예외 클래스 HTTP 상태
| 예외 클래스 | HTTP 상태 코드 |
| BadRequestException | 400 Bad Request |
| ForbiddenException | 403 Forbidden |
| NotFoundException | 404 Not Found |
| MethodNotAllowedException | 405 Method Not Allowed |
| GlobalException | 500 Internal Server Error |
ExceptionAdvice : 애플리케이션 전체의 예외를 잡아 처리하는 전역 핸들러
@RestControllerAdvice를 사용하여 애플리케이션 전역의 예외를 처리하는 중앙 집중식 핸들러를 구성합니다.
//Controller Exception 관리
@Slf4j
@RestControllerAdvice
public class ExceptionAdvice {
//400 BAD_REQUEST
@ExceptionHandler(BadRequestException.class)
public ResponseEntity<Object> BadRequestException(BadRequestException e){
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse(e.getStatus()));
}
//403 FORBIDDEN
@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<Object> ForbiddenException(ForbiddenException e){
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new ErrorResponse(e.getStatus()));
}
// 404, 405, 500 핸들러 생략
}
- @RestControllerAdvice: 이 클래스를 애플리케이션 전체의 Controller에서 발생하는 예외를 가로채 처리하는 전역 컨트롤러 어드바이스로 지정합니다.
- @ExceptionHandler(ExceptionType.class): 특정 예외(BadRequestException 등)가 발생했을 때 이 메서드를 실행하도록 지정합니다. 이것이 바로 예외 처리의 핵심입니다.
처리 과정:
- Service 또는 Controller에서 throw new BadRequestException(EXIST_NAME); 와 같이 예외를 던집니다.
- Spring 프레임워크가 이 예외를 가로채서 ExceptionAdvice 클래스에 정의된 BadRequestException 핸들러 메서드를 호출합니다.
- 핸들러 메서드는 예외 객체(e)에서 e.getStatus()를 통해 **ErrorResponseStatus*를 추출합니다.
- 이 상태를 이용해 new ErrorResponse(...) 객체를 생성합니다.
- ResponseEntity.status(HttpStatus.BAD_REQUEST).body(...)를 사용하여 HTTP 상태 코드를 400으로 설정하고, 표준화된 ErrorResponse 객체를 응답 본문(Body)에 담아 클라이언트에게 반환합니다.
Service에서 예외처리 예시
해당 코드는 Service 및 Controller에서 공통적으로 사용하기 위해 고안된 설계입니다.
이 구성을 적용하여 Service나 Controller는 try-catch 없이 비즈니스 로직에만 집중할 수 있습니다.
// ex. 회원가입 시 사용자 이름 유효성 체크 로직
@Transactional(readOnly = true)
public boolean checkNameDuplication(String name){
boolean nameDuplicate = userRepository.existsByname(name);
if(nameDuplicate)
throw new BadRequestException(EXIST_NAME);
if(name.length() < 2 || name.length() > 10)
throw new BadRequestException(NOT_VALID_LENGTH);
return !nameDuplicate;
}
// ex. code를 입력하여 Place 정보 반환
public Place getPlaceByCode(String code){
// DB에 정보가 없을 경우, 400(Bad Request) 예외와 함께 상세 에러 코드(4100)를 던짐
return placeRepository.getPlaceByCode(code).orElseThrow(() -> new BadRequestException(NOT_FOUND_PLACE_CODE));
}
향후 개선 방안
현재 구조는 기본적인 공통 예외 처리에는 충분하지만, 더 견고한 시스템을 위해 다음과 같은 보완이 필요하다고 생각했습니다.
- 사용자 정의 예외 클래스 로직 중복 제거: BadRequestException 등의 클래스에 ErrorResponseStatus 필드와 getStatus() 메서드가 반복됩니다. 이 로직을 최상위 추상 클래스(GlobalException)로 옮겨 중복을 줄여야 합니다.
- Spring 표준 예외 처리 추가: @Valid 실패 시 발생하는 MethodArgumentNotValidException이나, JSON 파싱 오류(HttpMessageNotReadableException) 등 Spring 프레임워크 자체 예외에 대한 핸들러가 누락되어 있습니다.
- 동적 메시지 포맷팅: 현재 ErrorResponseStatus의 메시지는 정적입니다. "사용자 [user123]이 이미 존재합니다"와 같이 예외 발생 상황의 동적 값을 메시지에 포맷팅하여 전달하는 로직 개선이 필요합니다.
- 로그 명확성 강화: 에러 코드를 활용하여 백엔드 로그에 ERROR 로그를 더 명확하고 추적하기 쉽게 남기는 방법을 고려해야 합니다.
다음 글에서는 위의 보완점들을 적용하여 예외 처리 구조를 어떻게 개선했는지 상세히 정리해보겠습니다. 감사합니다.
'Backend' 카테고리의 다른 글
| Spring Bean 이란? (0) | 2026.01.05 |
|---|---|
| Maven vs Gradle vs Ant : 빌드 방식의 변화 (0) | 2025.12.27 |
| Spring Boot 테스트 코드를 통한 안정적인 애플리케이션 관리 (0) | 2025.12.10 |
| Spring에서 기상청 API 사용하기 (0) | 2023.09.08 |
| Jackson 라이브러리란? (0) | 2023.09.07 |