목차
1. 전역 예외 처리가 필요한 이유
Spring Boot 애플리케이션을 개발하다 보면, 각 Service에서 동일한 예외 처리 패턴이 반복되는 상황을 자주 만난다.
// UserService
@Service
public class UserService {
public UserResponse findById(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
return UserResponse.from(user);
}
}
// ProductService
@Service
public class ProductService {
public ProductResponse findById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다"));
return ProductResponse.from(product);
}
}
// CampingItemService
@Service
public class CampingItemService {
public CampingItemResponse findById(Long id) {
CampingItem item = campingItemRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("캠핑 장비를 찾을 수 없습니다"));
return CampingItemResponse.from(item);
}
}
전역 예외 처리를 구현하는 이유
1. 코드 중복 문제 : 동일한 orElseThrow() 패턴이 모든 Service에서 반복된다.
2. 일관성 부족 : 같은 상황에서도 개발자마다 다른 예외를 던질 수 있다.
// A 개발자
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
// B 개발자
.orElseThrow(() -> new RuntimeException("User not found"));
// C 개발자
.orElseThrow(() -> new EntityNotFoundException("USER_NOT_FOUND"));
3. 유지보수 어려움 : 에러 메시지나 HTTP 상태 코드를 변경하려면 모든 Service를 수정해야 한다.
4. 예외와 HTTP 응답의 분리 부족: Service에서 던진 예외가 어떤 HTTP 상태 코드로 변환될지 예측하기 어렵다.
2. 구현단계
2-1. 커스텀 예외 생성
먼저 비즈니스 로직에서 발생할 수 있는 커스텀 예외를 정의한다. 예를 들어, 데이터를 찾을 수 없을 때 발생하는 예외를 만든다.
package com.rental.camprent.exception;
public class ItemNotFoundException extends RuntimeException {
public ItemNotFoundException(String message) {
super(message);
}
}
- RuntimeException을 상속받아 Unchecked Exception으로 만든다. 이렇게 하면 Service 메서드 시그니처에 throws를 선언하지 않아도 되어 코드가 간결해진다.
2-2. 에러 응답 DTO 설계
모든 에러 응답이 동일한 형식을 갖도록 ErrorResponse DTO를 만든다.
package com.rental.camprent.dto.response;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
@AllArgsConstructor
public class ErrorResponse {
private int status; // HTTP 상태 코드 (404, 400 등)
private String code; // 에러 코드 (ITEM_NOT_FOUND 등)
private String message; // 에러 메시지
private LocalDateTime timestamp; // 발생 시각
// 정적 팩토리 메서드
public static ErrorResponse of(int status, String code, String message) {
return new ErrorResponse(status, code, message, LocalDateTime.now());
}
}
- 정적 팩토리 메서드 of()를 제공하여 객체 생성을 간편하게 만든다. timestamp는 자동으로 현재 시각이 설정된다.
2-3. GlobalExceptionHandler 구현
@RestControllerAdvice를 사용하여 전역 예외 처리기를 만든다. 이 클래스는 모든 Controller에서 발생하는 예외를 한 곳에서 처리한다.
package com.rental.camprent.exception;
import com.rental.camprent.dto.response.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
// ItemNotFoundException 처리 (404)
@ExceptionHandler(ItemNotFoundException.class)
public ResponseEntity<ErrorResponse> handleItemNotFoundException(ItemNotFoundException e) {
ErrorResponse errorResponse = ErrorResponse.of(
HttpStatus.NOT_FOUND.value(),
"ITEM_NOT_FOUND",
e.getMessage()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
// Validation 실패 처리 (400)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
ErrorResponse errorResponse = ErrorResponse.of(
HttpStatus.BAD_REQUEST.value(),
"VALIDATION_FAILED",
message
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
// IllegalArgumentException 처리 (400)
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e) {
ErrorResponse errorResponse = ErrorResponse.of(
HttpStatus.BAD_REQUEST.value(),
"INVALID_ARGUMENT",
e.getMessage()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
// 그 외 모든 예외 처리 (500)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
ErrorResponse errorResponse = ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"INTERNAL_SERVER_ERROR",
"서버 내부 오류가 발생했습니다."
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
- @RestControllerAdvice: 모든 @RestController에 적용되는 전역 예외 처리기를 선언한다.
- @ExceptionHandler(XxxException.class): 특정 예외 타입이 발생했을 때 실행될 메서드를 지정한다.
- 예외 우선순위: 구체적인 예외부터 처리하고, 마지막에 Exception.class로 모든 예외를 받는다.
- 보안: 최후의 보루인 handleException()에서는 e.getMessage()를 사용하지 않는다(내부 구현 정보가 노출될 수 있기 때문).
-
2-4. Service에서 커스텀 예외 사용
기존에 IllegalArgumentException을 던지던 Service 코드를 ItemNotFoundException으로 변경한다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CampingItemService {
private final CampingItemRepository campingItemRepository;
public CampingItemResponse findById(Long id) {
CampingItem entity = campingItemRepository.findById(id)
.orElseThrow(() -> new ItemNotFoundException("장비를 찾을 수 없습니다. id: " + id));
return CampingItemResponse.from(entity);
}
@Transactional
public CampingItemResponse updateStatus(Long id, CampingItemStatus status) {
CampingItem entity = campingItemRepository.findById(id)
.orElseThrow(() -> new ItemNotFoundException("장비를 찾을 수 없습니다. id: " + id));
entity.updateStatus(status);
return CampingItemResponse.from(entity);
}
// update, increaseStock, decreaseStock 등 다른 메서드도 동일하게 수정
}
3. 전체 흐름 정리
1. Service에서 예외 발생: throw new ItemNotFoundException(...)
2. GlobalExceptionHandler가 자동으로 예외 감지: Spring이 해당 예외 타입과 매칭되는 @ExceptionHandler를 찾는다.
3. 적절한 핸들러 메서드 실행: handleItemNotFoundException()이 호출된다.
4. ErrorResponse 생성: 상태 코드, 에러 코드, 메시지, 타임스탬프를 포함한 응답 객체를 만든다.
5. ResponseEntity 반환: HTTP 상태 코드와 함께 JSON 응답을 클라이언트에게 보낸다.
4. 실제 응답 예시
성공 케이스 (200 OK)
GET /api/camping-items/1
HTTP/1.1 200 OK
{
"id": 1,
"name": "4인용 텐트",
"category": "TENT",
"stockQuantity": 10
}
실패 케이스 - 아이템을 찾을 수 없음 (404 NOT FOUND)
GET /api/camping-items/999
HTTP/1.1 404 Not Found
{
"status": 404,
"code": "ITEM_NOT_FOUND",
"message": "장비를 찾을 수 없습니다. id: 999",
"timestamp": "2026-01-21T10:30:45.123"
}
실패 케이스 - 유효성 검사 실패 (400 BAD REQUEST)
POST /api/camping-items
{
"name": "",
"stockQuantity": -5
}
HTTP/1.1 400 Bad Request
{
"status": 400,
"code": "VALIDATION_FAILED",
"message": "이름은 필수입니다.",
"timestamp": "2026-01-21T10:31:20.456"
}
실패 케이스 - 서버 내부 오류 (500 INTERNAL SERVER ERROR
GET /api/camping-items/1
HTTP/1.1 500 Internal Server Error
{
"status": 500,
"code": "INTERNAL_SERVER_ERROR",
"message": "서버 내부 오류가 발생했습니다.",
"timestamp": "2026-01-21T10:32:10.789"
}
5. 주의사항
HTTP 상태 코드를 두 곳에 설정하는 이유
예외 처리 코드를 보면 HTTP 상태 코드가 두 곳에 나온다
ErrorResponse errorResponse = ErrorResponse.of(
HttpStatus.NOT_FOUND.value(), // ← ErrorResponse 내부
"ITEM_NOT_FOUND",
e.getMessage()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND) // ← ResponseEntity
.body(errorResponse);
- ResponseEntity의 status: HTTP 프로토콜 레벨의 상태 코드 (헤더에 들어감)
- ErrorResponse의 status: JSON 본문에 포함되는 정보
프론트엔드에서는 보통 HTTP 상태 코드로 성공/실패를 판단하고, JSON 본문의 code와 message로 구체적인 에러 내용을 파악한다.
보안을 위한 메시지 처리
커스텀 예외(ItemNotFoundException, IllegalArgumentException 등)에서는 e.getMessage()를 사용해도 안전하다.
개발자가 직접 작성한 메시지이기 때문이다.
안전한 경우 (권장)
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 안전: 커스텀 예외 - e.getMessage() 사용 OK
@ExceptionHandler(ItemNotFoundException.class)
public ResponseEntity<ErrorResponse> handleItemNotFoundException(ItemNotFoundException e) {
ErrorResponse errorResponse = ErrorResponse.of(
HttpStatus.NOT_FOUND.value(),
"ITEM_NOT_FOUND",
e.getMessage() // "장비를 찾을 수 없습니다. id: 1"
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
// 안전: 최후의 보루 - 고정 메시지 사용
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
// 개발자용: 로그에 상세 정보 기록
log.error("Unexpected error occurred: {}", e.getMessage(), e);
// 사용자용: 안전한 고정 메시지만 반환
ErrorResponse errorResponse = ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"INTERNAL_SERVER_ERROR",
"서버 내부 오류가 발생했습니다." // 민감한 정보 숨김
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
하지만 최후의 보루인 Exception 핸들러에서는 고정 메시지를 사용한다. 예상치 못한 예외의 메시지에는 데이터베이스 구조, 파일 경로 등 민감한 정보가 포함될 수 있기 때문이다.
위험한 경우
@RestControllerAdvice
public class BadGlobalExceptionHandler {
// 위험: 모든 예외에서 e.getMessage() 그대로 노출
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
ErrorResponse errorResponse = ErrorResponse.of(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"INTERNAL_SERVER_ERROR",
e.getMessage() // 민감한 정보 노출 가능
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
}
}
실제 노출되는 위험한 메시지들
// 데이터베이스 예외 시
"Connection refused: jdbc:postgresql://localhost:5432/camprent"
// 파일 시스템 예외 시
"Access denied: /home/admin/config/database.properties"
// SQL 예외 시
"Table 'camprent.camping_items' doesn't exist"
// 클래스 로딩 예외 시
"Could not load class: com.rental.camprent.secret.ApiKeyManager"
6. 정리
전역 예외처리를 구현할 경우 장점
- Service 코드가 깔끔해진다: 중복된 예외 처리 코드 제거
- 일관된 에러 응답: 모든 API에서 동일한 형식의 에러 응답
- 유지보수성 향상: 에러 처리 방식 변경 시 한 곳만 수정
- 관심사 분리: Service는 비즈니스 로직에만 집중
Service에서 발생하는 중복 예외 처리 패턴을 완전히 해결할 수 있다.