들어가며: REST API에서 예외 처리의 중요성
REST API를 개발할 때 가장 중요한 측면 중 하나는 예외 처리입니다. 적절한 예외 처리는 API의 안정성, 사용성, 그리고 유지보수성을 크게 향상시킵니다. 하지만 많은 개발자들이 "어떤 예외 처리 패턴이 우리 프로젝트에 가장 적합할까?"라는 질문에 직면하게 됩니다.
Spring Boot에서는 주로 두 가지 예외 처리 패턴이 사용됩니다: 글로벌 핸들러를 통한 중앙화된 예외 처리와 컨트롤러별 개별 예외 처리입니다. 이 글에서는 두 접근 방식의 장단점을 비교하고, 실제 프로젝트에서 어떻게 효과적으로 구현할 수 있는지 살펴보겠습니다.
예외 처리가 잘 설계된 API는 클라이언트에게 일관된 오류 메시지를 제공하고, 개발자에게는 문제 해결을 위한 명확한 정보를 제공합니다. 이는 결국 사용자 경험 향상과 개발 생산성 증가로 이어집니다.
REST API 예외 처리의 기본 원칙
효과적인 REST API 예외 처리를 위한 몇 가지 기본 원칙부터 살펴보겠습니다:
- 일관성: 모든 API 엔드포인트에서 동일한 오류 응답 구조를 유지해야 합니다.
- 명확성: 오류 메시지는 문제의 원인과 가능한 해결책을 명확하게 전달해야 합니다.
- 적절한 HTTP 상태 코드: 각 예외 상황에 맞는 HTTP 상태 코드를 사용해야 합니다.
- 보안: 내부 시스템 정보나 스택 트레이스를 노출하지 않아야 합니다.
- 로깅: 디버깅을 위해 적절한 로깅이 필요합니다.
이러한 원칙을 지키면서, Spring Boot에서 제공하는 예외 처리 메커니즘을 어떻게 활용할 수 있는지 알아보겠습니다.
표준 API 오류 응답 구조 설계하기
예외 처리 패턴을 비교하기 전에, 먼저 표준화된 오류 응답 구조를 정의해야 합니다. 이는 일관성 있는 API 응답을 위한 기본 요소입니다.
public class ApiErrorResponse {
private LocalDateTime timestamp;
private int status;
private String error;
private String message;
private String path;
private List<ValidationError> validationErrors;
// getters, setters, constructors
public static class ValidationError {
private String field;
private String message;
// getters, setters, constructors
}
}
이 구조는 다음과 같은 정보를 포함합니다:
- timestamp: 오류 발생 시간
- status: HTTP 상태 코드
- error: 오류 유형
- message: 사용자 친화적인 오류 메시지
- path: 오류가 발생한 API 경로
- validationErrors: 유효성 검사 오류 목록 (필요한 경우)
이러한 표준 구조를 사용하면 클라이언트는 모든 API 응답을 일관되게 처리할 수 있습니다.
글로벌 예외 처리 패턴: @ControllerAdvice와 @ExceptionHandler 활용하기
Spring Boot에서 글로벌 예외 처리의 핵심은 @ControllerAdvice(또는 @RestControllerAdvice)와 @ExceptionHandler 어노테이션입니다. 이를 활용하면 애플리케이션 전체에서 발생하는 예외를 중앙에서 처리할 수 있습니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleResourceNotFoundException(
ResourceNotFoundException ex, WebRequest request) {
ApiErrorResponse errorResponse = new ApiErrorResponse(
LocalDateTime.now(),
HttpStatus.NOT_FOUND.value(),
"Resource Not Found",
ex.getMessage(),
request.getDescription(false)
);
return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorResponse> handleValidationExceptions(
MethodArgumentNotValidException ex, WebRequest request) {
List<ApiErrorResponse.ValidationError> validationErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new ApiErrorResponse.ValidationError(
error.getField(),
error.getDefaultMessage()))
.collect(Collectors.toList());
ApiErrorResponse errorResponse = new ApiErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST.value(),
"Validation Error",
"입력값 검증에 실패했습니다.",
request.getDescription(false),
validationErrors
);
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleAllUncaughtExceptions(
Exception ex, WebRequest request) {
ApiErrorResponse errorResponse = new ApiErrorResponse(
LocalDateTime.now(),
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"Server Error",
"서버에서 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
request.getDescription(false)
);
// 내부적으로 실제 오류 로깅
log.error("Uncaught exception", ex);
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
글로벌 예외 처리의 장점
- 코드 중복 감소: 모든 컨트롤러에서 동일한 예외 처리 로직을 반복적으로 작성할 필요가 없습니다.
- 일관성 유지: 모든 API 응답이 동일한 형식으로 처리됩니다.
- 유지보수 용이성: 예외 처리 로직이 한 곳에 집중되어 있어 변경 사항을 적용하기 쉽습니다.
- 가독성 향상: 컨트롤러 코드가 비즈니스 로직에만 집중할 수 있게 됩니다.
글로벌 예외 처리의 단점
- 컨텍스트 제한: 특정 컨트롤러나 도메인에 국한된 예외 처리가 어려울 수 있습니다.
- 과도한 일반화: 모든 상황에 맞는 단일 예외 처리 방식을 설계하기 어려울 수 있습니다.
- 복잡성 증가: 많은 예외 유형을 처리해야 할 경우 글로벌 핸들러가 비대해질 수 있습니다.
컨트롤러별 예외 처리 패턴: 지역화된 @ExceptionHandler 활용하기
컨트롤러별 예외 처리는 각 컨트롤러 클래스 내에서 @ExceptionHandler 어노테이션을 사용하여 구현합니다. 이 방식은 특정 컨트롤러에 국한된 예외를 처리할 때 유용합니다.
@RestController
@RequestMapping("/api/products")
public class ProductController {
@Autowired
private ProductService productService;
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
Product product = productService.findById(id);
return ResponseEntity.ok(product);
}
@PostMapping
public ResponseEntity<Product> createProduct(@Valid @RequestBody ProductDto productDto) {
Product product = productService.create(productDto);
return new ResponseEntity<>(product, HttpStatus.CREATED);
}
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleProductNotFoundException(
ProductNotFoundException ex, WebRequest request) {
ApiErrorResponse errorResponse = new ApiErrorResponse(
LocalDateTime.now(),
HttpStatus.NOT_FOUND.value(),
"Product Not Found",
ex.getMessage(),
request.getDescription(false)
);
return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(ProductValidationException.class)
public ResponseEntity<ApiErrorResponse> handleProductValidationException(
ProductValidationException ex, WebRequest request) {
ApiErrorResponse errorResponse = new ApiErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST.value(),
"Product Validation Error",
ex.getMessage(),
request.getDescription(false)
);
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
}
컨트롤러별 예외 처리의 장점
- 컨텍스트 인식: 특정 도메인이나 컨트롤러에 맞춘 예외 처리가 가능합니다.
- 세분화된 제어: 각 컨트롤러마다 다른 예외 처리 로직을 적용할 수 있습니다.
- 명확한 책임: 예외 처리 로직이 관련 비즈니스 로직과 함께 위치합니다.
- 독립성: 다른 컨트롤러의 예외 처리와 독립적으로 변경할 수 있습니다.
컨트롤러별 예외 처리의 단점
- 코드 중복: 여러 컨트롤러에서 유사한 예외 처리 로직이 반복될 수 있습니다.
- 일관성 유지 어려움: 각 컨트롤러마다 다른 응답 형식을 사용할 가능성이 있습니다.
- 유지보수 부담: 변경 사항을 여러 컨트롤러에 적용해야 할 수 있습니다.
하이브리드 접근법: 글로벌 + 컨트롤러별 예외 처리 조합하기
실제 프로젝트에서는 두 패턴을 조합한 하이브리드 접근법이 종종 가장 효과적입니다. 이 방식에서는:
- 글로벌 핸들러로 일반적인 예외와 기본 동작을 처리합니다.
- 컨트롤러별 핸들러로 특정 도메인이나 컨텍스트에 맞는 예외를 처리합니다.
Spring의 예외 처리 메커니즘은 가장 구체적인 핸들러를 먼저 찾기 때문에, 컨트롤러별 핸들러가 글로벌 핸들러보다 우선 적용됩니다.
// 글로벌 핸들러
@RestControllerAdvice
public class GlobalExceptionHandler {
// 일반적인 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleGlobalException(
Exception ex, WebRequest request) {
// 기본 오류 응답 생성
}
// 공통 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiErrorResponse> handleBusinessException(
BusinessException ex, WebRequest request) {
// 비즈니스 오류 응답 생성
}
}
// 특정 도메인 예외를 위한 핸들러
@RestControllerAdvice(basePackages = "com.example.api.order")
public class OrderExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleOrderNotFoundException(
OrderNotFoundException ex, WebRequest request) {
// 주문 관련 오류 응답 생성
}
}
// 컨트롤러 내부의 특정 예외 처리
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@ExceptionHandler(PaymentProcessingException.class)
public ResponseEntity<ApiErrorResponse> handlePaymentProcessingException(
PaymentProcessingException ex, WebRequest request) {
// 결제 처리 오류 응답 생성
}
}
이 접근법의 핵심은 예외의 범위와 특성에 따라 적절한 수준의 처리를 선택하는 것입니다:
- 글로벌 수준: 모든 컨트롤러에 적용되는 일반적인 예외
- 도메인 수준: 특정 도메인이나 패키지에 관련된 예외
- 컨트롤러 수준: 특정 컨트롤러에서만 의미 있는 예외
사용자 정의 예외 설계: 효과적인 예외 계층 구조 만들기
효과적인 예외 처리를 위해서는 잘 설계된 사용자 정의 예외 클래스가 필요합니다. 다음은 REST API에서 자주 사용되는 예외 계층 구조의 예시입니다:
// 기본 애플리케이션 예외
public abstract class BaseException extends RuntimeException {
private final String errorCode;
public BaseException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
// 비즈니스 로직 예외
public class BusinessException extends BaseException {
public BusinessException(String message, String errorCode) {
super(message, errorCode);
}
}
// 리소스를 찾을 수 없는 예외
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
super(
String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue),
"RESOURCE_NOT_FOUND"
);
}
}
// 유효성 검증 실패 예외
public class ValidationException extends BusinessException {
private final Map<String, String> errors;
public ValidationException(String message, Map<String, String> errors) {
super(message, "VALIDATION_FAILED");
this.errors = errors;
}
public Map<String, String> getErrors() {
return errors;
}
}
// 권한 관련 예외
public class AccessDeniedException extends BusinessException {
public AccessDeniedException(String message) {
super(message, "ACCESS_DENIED");
}
}
이러한 계층 구조를 통해:
- 모든 예외가 일관된 기본 속성을 가집니다.
- 예외 유형에 따라 적절한 처리가 가능합니다.
- 오류 코드를 통해 클라이언트가 프로그래밍 방식으로 오류를 처리할 수 있습니다.
- 사용자 친화적인 메시지 생성이 용이해집니다.
실제 프로젝트에서의 예외 처리 전략 결정하기
어떤 예외 처리 패턴을 선택할지는 프로젝트의 특성에 따라 달라집니다. 다음 요소들을 고려해보세요:
글로벌 핸들러를 선호해야 하는 경우:
- 대규모 팀 작업: 여러 개발자가 일관된 예외 처리를 해야 하는 경우
- 표준화된 API: 모든 엔드포인트에서 동일한 응답 형식이 요구되는 경우
- 단순한 도메인: 특별한 예외 처리가 필요하지 않은 경우
- 마이크로서비스 아키텍처: 여러 서비스 간 일관성이 중요한 경우
@RestControllerAdvice
public class GlobalApiExceptionHandler {
// 다양한 예외 핸들러 구현
}
컨트롤러별 핸들러를 선호해야 하는 경우:
- 복잡한 도메인: 각 도메인마다 특별한 예외 처리가 필요한 경우
- 레거시 시스템 통합: 서로 다른 형식의 응답이 필요한 경우
- 점진적 마이그레이션: 기존 시스템을 단계적으로 변경하는 경우
- 특별한 컨텍스트: 컨트롤러별로 추가 정보가 필요한 경우
@RestController
@RequestMapping("/api/complex-domain")
public class ComplexDomainController {
// 컨트롤러별 예외 핸들러 구현
}
하이브리드 접근법을 선호해야 하는 경우:
- 중간 규모 이상 프로젝트: 일반적인 예외와 특수한 예외가 모두 있는 경우
- 다양한 클라이언트: 클라이언트별로 다른 응답이 필요한 경우
- 성숙한 API: 시간이 지남에 따라 진화하는 예외 처리가 필요한 경우
- 유연성 요구: 엄격한 표준과 특별한 케이스를 모두 처리해야 하는 경우
// 기본 핸들러
@RestControllerAdvice
public class GlobalExceptionHandler { /* ... */ }
// 도메인별 핸들러
@RestControllerAdvice(basePackages = "com.example.api.finance")
public class FinanceExceptionHandler { /* ... */ }
// 컨트롤러별 핸들러도 필요에 따라 추가
Spring Boot에서 예외 처리 구현 시 고려사항
1. 오류 응답의 국제화 (i18n)
다국어 지원이 필요한 애플리케이션에서는 오류 메시지의 국제화를 고려해야 합니다:
@RestControllerAdvice
public class GlobalExceptionHandler {
@Autowired
private MessageSource messageSource;
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiErrorResponse> handleBusinessException(
BusinessException ex, WebRequest request, Locale locale) {
String localizedMessage = messageSource.getMessage(
"error." + ex.getErrorCode(),
null,
ex.getMessage(),
locale
);
ApiErrorResponse errorResponse = new ApiErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST.value(),
ex.getErrorCode(),
localizedMessage,
request.getDescription(false)
);
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
}
2. 개발 환경과 운영 환경의 분리
개발 환경과 운영 환경에서 서로 다른 상세 수준의 오류 정보를 제공할 수 있습니다:
@RestControllerAdvice
public class GlobalExceptionHandler {
@Value("${spring.profiles.active:production}")
private String activeProfile;
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleException(
Exception ex, WebRequest request) {
ApiErrorResponse errorResponse = new ApiErrorResponse();
errorResponse.setTimestamp(LocalDateTime.now());
errorResponse.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
errorResponse.setError("Server Error");
// 개발 환경에서는 상세 정보 제공
if ("development".equals(activeProfile)) {
errorResponse.setMessage(ex.getMessage());
errorResponse.setDebugInfo(ExceptionUtils.getStackTrace(ex));
} else {
// 운영 환경에서는 일반적인 메시지만 제공
errorResponse.setMessage("서버에서 오류가 발생했습니다.");
}
errorResponse.setPath(request.getDescription(false));
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
3. 로깅 전략
효과적인 디버깅을 위해 적절한 로깅 전략을 수립해야 합니다:
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleException(
Exception ex, WebRequest request) {
// 상세 로깅 (내부용)
log.error("Unhandled exception occurred", ex);
// 고객에게는 최소한의 정보만 제공
ApiErrorResponse errorResponse = new ApiErrorResponse(
LocalDateTime.now(),
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"INTERNAL_ERROR",
"서버에서 오류가 발생했습니다.",
request.getDescription(false)
);
return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
예외 처리 패턴 실무 적용 사례 연구
사례 1: 대규모 전자상거래 플랫폼
대규모 전자상거래 플랫폼에서는 하이브리드 접근법이 효과적입니다:
- 글로벌 핸들러:
- 인증/인가 관련 예외
- 일반적인 유효성 검사 예외
- 시스템 오류
- 도메인별 핸들러:
- 주문 관련 예외 (@RestControllerAdvice(basePackages = "com.example.order"))
- 결제 관련 예외 (@RestControllerAdvice(basePackages = "com.example.payment"))
- 배송 관련 예외 (@RestControllerAdvice(basePackages = "com.example.shipping"))
- 컨트롤러별 핸들러:
- 특별한 프로모션 로직에 관련된 예외
- 외부 시스템 연동에 관련된 예외
사례 2: 금융 API 시스템
금융 API 시스템에서는 엄격한 표준화가 중요하므로 글로벌 핸들러 중심의 접근법이 효과적입니다:
@RestControllerAdvice
public class FinancialApiExceptionHandler {
// 보안 관련 예외
@ExceptionHandler(SecurityException.class)
public ResponseEntity<ApiErrorResponse> handleSecurityException(...) { ... }
// 트랜잭션 관련 예외
@ExceptionHandler(TransactionException.class)
public ResponseEntity<ApiErrorResponse> handleTransactionException(...) { ... }
// 규제 준수 관련 예외
@ExceptionHandler(ComplianceException.class)
public ResponseEntity<ApiErrorResponse> handleComplianceException(...) { ... }
}
각 예외는 명확한 오류 코드와 규제 준수를 위한 정보를 포함합니다.
결론: 프로젝트에 맞는 최적의 예외 처리 패턴 선택하기
REST API 예외 처리는 프로젝트의 품질과 개발자 경험에 직접적인 영향을 미치는 중요한 요소입니다. 이 글에서 살펴본 것처럼, 글로벌 핸들러와 컨트롤러별 핸들러는 각각 고유한 장단점을 가지고 있습니다.
최적의 예외 처리 전략을 선택할 때 고려해야 할 핵심 사항:
- 프로젝트 규모와 복잡성: 작은 프로젝트는 글로벌 핸들러만으로 충분할 수 있지만, 복잡한 프로젝트는 하이브리드 접근법이 필요할 수 있습니다.
- 팀 구성과 개발 방식: 여러 팀이 독립적으로 작업하는 경우, 도메인별 핸들러가 유용할 수 있습니다.
- API 소비자의 다양성: 다양한 클라이언트를 지원해야 하는 경우, 더 유연한 예외 처리가 필요할 수 있습니다.
- 유지보수 전략: 장기적인 유지보수성을 고려하여 일관성과 유연성 사이의 균형을 찾아야 합니다.
어떤 패턴을 선택하든, 가장 중요한 것은 일관성과 명확성입니다. 잘 설계된 예외 처리 시스템은 개발자의 생산성을 높이고, API 사용자에게 더 나은 경험을 제공하며, 문제 해결을 위한 중요한 정보를 효과적으로 전달합니다.
Spring Boot는 이러한 다양한 예외 처리 패턴을 구현하기 위한 강력한 도구를 제공합니다. 프로젝트의 요구사항과 상황에 맞게 이러한 도구를 활용하여 효과적인 예외 처리 전략을 수립하세요.
참고 자료:
- Spring 공식 문서: Exception Handling in Spring MVC
- Spring Boot 공식 문서: Error Handling
- RESTful API 설계 모범 사례
'Spring & Spring Boot 실무 가이드' 카테고리의 다른 글
Spring에서 Bean Scope의 차이 – Singleton, Prototype, Request (0) | 2025.05.18 |
---|---|
코드 한 줄 안 바꾸고 Spring Boot 성능 3배 올리기: JVM 튜닝 실전 가이드 (1) | 2025.05.17 |
실전 코드로 배우는 Redis 캐싱 전략 - TTL, LRU, 캐시 무효화까지 (0) | 2025.05.12 |
Spring Boot에서 Excel 파일 업로드 & 다운로드 처리 – Apache POI 실전 가이드 (0) | 2025.05.10 |
[Java & Spring 실무] JPA Entity 간 N:1, 1:N 관계 설계 베스트 프랙티스 (0) | 2025.05.09 |