웹개발을 하다보면 항상 발생하는 것이 바로 에러입니다. 대표적으로 404, 500 에러 등이 있습니다. 해당 에러가 발생하는 경우, 브라우저에서 '500 에러가 발생했습니다.' 등 문구가 나오는 것을 확인할 수 있습니다. 그대로 해당 화면이 노출되는 경우도 있으나, 예외처리를 활용하여 해당 에러가 발생하였을 경우 어떤 식으로 대처할지 구현하는 것이 바로 예외 처리입니다. 보통 각 메소드 마다 try-catch를 활용하여 예외처리를 하는 경우가 많습니다. 하지만 예외처리는 try-catch만 있는 것이 아니며, 다른 방식으로도 구현할 수 있습니다. 이번 게시글은 이에 대해 알아보도록 하겠습니다.
사용자가 존재하지 않거나, 입력값이 잘못되었을 때 API는 어떤 응답을 주어야 할까요? 서버 에러(500)를 그대로 내뱉거나 제각각인 응답 형식을 보내는 것은 클라이언트 개발자와의 신뢰를 깨뜨리는 지름길입니다.
스프링이 제공하는 강력한 기능을 활용해, 예외 처리를 한 곳에서 우아하게 관리하는 방법을 알아보겠습니다.
1. 왜 @RestControllerAdvice인가?
과거에는 각 컨트롤러마다 예외 처리 로직을 넣거나, 복잡한 설정이 필요했습니다. 하지만 @RestControllerAdvice를 사용하면 다음과 같은 장점이 있습니다.
- 관심사의 분리: 비즈니스 로직과 예외 처리 로직이 완전히 분리됩니다.
- 중복 제거: 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리합니다.
- 일관된 응답: 어떤 에러가 발생해도 클라이언트는 동일한 규격의 에러 응답을 받게 됩니다.
2. 단계별 구현 전략
1단계: 일관된 에러 응답 객체(ErrorResponse) 만들기
가장 먼저 클라이언트에게 돌려줄 공통 에러 형식을 정의합니다.
@Getter
@Builder
public class ErrorResponse {
private final LocalDateTime timestamp = LocalDateTime.now();
private final int status; // HTTP 상태 코드
private final String error; // 에러명
private final String code; // 커스텀 에러 코드 (비즈니스 구분용)
private final String message; // 상세 에러 메시지
}
2단계: 비즈니스 전용 예외(Custom Exception) 정의
단순히 RuntimeException을 던지는 대신, 우리가 직접 정의한 예외를 사용하면 가독성이 훨씬 좋아집니다.
@Getter
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
}
3단계: 전역 예외 처리기(GlobalExceptionHandler) 작성
이제 핵심인 @RestControllerAdvice 클래스를 만듭니다. 여기서 모든 예외를 낚아채서 처리합니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 1. 커스텀 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
protected ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
ErrorCode errorCode = e.getErrorCode();
ErrorResponse response = ErrorResponse.builder()
.status(errorCode.getStatus().value())
.code(errorCode.getCode())
.message(e.getMessage())
.build();
return new ResponseEntity<>(response, errorCode.getStatus());
}
// 2. 입력값 검증 실패 처리 (Validation)
@ExceptionHandler(MethodArgumentNotValidException.class)
protected ResponseEntity<ErrorResponse> handleValidException(MethodArgumentNotValidException e) {
ErrorResponse response = ErrorResponse.builder()
.status(HttpStatus.BAD_REQUEST.value())
.code("COMMON-001")
.message("입력값이 올바르지 않습니다.")
.build();
return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
}
// 3. 그 외 모든 예외 처리
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
ErrorResponse response = ErrorResponse.builder()
.status(HttpStatus.INTERNAL_SERVER_ERROR.value())
.code("COMMON-500")
.message("서버 내부 에러가 발생했습니다.")
.build();
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
3. 실무 예시: 주식 주문 API에서의 활용
사용자님이 관리하시는 주식 포트폴리오 앱에서 '주문' 기능을 만든다고 가정해 봅시다.
- 상황: 사용자가 잔고보다 많은 금액의 주식을 주문하려 함.
- 비즈니스 로직:
public void placeOrder(OrderRequest request) {
if (userBalance < request.getTotalPrice()) {
// 더 이상 try-catch를 고민하지 않고 예외를 던집니다.
throw new BusinessException(ErrorCode.INSUFFICIENT_BALANCE);
}
// ... 주문 로직
}
- 결과: 위 예외가 발생하면 GlobalExceptionHandler가 이를 가로채서 아래와 같은 JSON 응답을 클라이언트에게 보냅니다.
{
"timestamp": "2026-02-25T11:15:00",
"status": 400,
"error": "Bad Request",
"code": "ORDER-001",
"message": "잔액이 부족하여 주문을 완료할 수 없습니다."
}
4. 주의사항 및 꿀팁
- 로그 기록: ExceptionHandler 안에서 log.error()를 사용해 실제 에러 내용을 서버 로그에 남겨야 추후 트러블슈팅이 가능합니다.
- 보안 주의: 시스템 내부의 구체적인 에러 메시지(예: SQL 문법 에러 등)를 클라이언트에게 그대로 노출하면 보안상 위험합니다. 사용자에게는 친절한 메시지를, 로그에는 상세한 내용을 남기세요.
- @ControllerAdvice vs @RestControllerAdvice: 후자는 응답 본문에 객체를 바로 직렬화해서 넣어주는 기능(@ResponseBody)이 포함되어 있어 REST API 개발에 최적화되어 있습니다.
🏁 마무리하며
@RestControllerAdvice를 도입하면 컨트롤러가 매우 깨끗해집니다. 이제 더 이상 if-else나 try-catch로 에러를 처리하느라 비즈니스 로직을 가리지 마세요.
깔끔하게 정돈된 예외 처리 시스템은 나뿐만 아니라 이 API를 사용하는 동료 개발자들에게도 최고의 선물이 될 것입니다.
개발을 하며 항상 느끼는 것이지만 해당 기능을 구현하는데 있어 방법이 단 한 가지인 경우는 거의 존재하지 않습니다. 이번 경우에서처럼 또 다른 방식이 있는 경우가 있으니, 개발을 하며 매번 한 가지 방식으로 개발하는 것이 아닌, 다른 방법도 찾아보고, 해당 경우에 더 적합한 방식을 선택하여 적용할 수 있도록 하는 것이 바람직할 것입니다. 항상 공부하는 자세로 발전하도록 함께 노력해보는 것이 좋겠습니다. 감사합니다.
'TECH > 웹개발' 카테고리의 다른 글
| [Java/Spring] 로그인 구현의 클래식, 세션(Session) 인증 방식 A to Z (0) | 2026.03.16 |
|---|---|
| JDBC 노가다 탈출! Apache QueryRunner 완벽 가이드 (0) | 2026.03.14 |
| [Java] "돈 계산할 때 double 쓰면 망합니다" — BigDecimal의 필요성과 사용법 (0) | 2026.03.11 |
| [Java] 자바 메모리 구조 완벽 가이드: Stack, Heap, Method Area의 상호작용 (0) | 2026.03.10 |
| [Java] String vs StringBuilder vs StringBuffer: 아직도 헷갈린다면 이 글 하나로 끝내세요 (0) | 2026.03.09 |