Ver2.0 Spring @ControllerAdvice로 전역 예외 처리 마스터하기 🛡️ - 실전 가이드

Spring @ControllerAdvice로 전역 예외 처리 마스터하기 🛡️ - 실전 가이드
📅 2025년 03월 27일 기준 최신 Spring 버전 정보 반영
안녕하세요 여러분! 오늘은 Spring 개발할 때 진짜 꿀팁인 @ControllerAdvice를 활용한 전역 예외 처리에 대해 함께 알아볼게요. 개발하다 보면 예외 처리가 은근 귀찮고 코드도 지저분해지기 쉽잖아요? 이걸 깔끔하게 해결할 수 있는 방법을 소개해드릴게요! 😊
📚 목차
- 전역 예외 처리가 필요한 이유
- @ControllerAdvice와 @ExceptionHandler 이해하기
- 기본적인 전역 예외 처리 구현 방법
- 다양한 예외 유형별 처리 전략
- 실전 예제: REST API에서의 예외 처리
- 응답 형식 표준화하기
- 로깅과 모니터링 연동
- 테스트 코드 작성법
- 성능 최적화 팁
- 마무리 및 추가 자료
1. 전역 예외 처리가 필요한 이유 🤔
여러분, 개발하다가 이런 경험 있으시죠? 컨트롤러마다 try-catch 블록 넣고, 똑같은 예외 처리 코드 복붙하고... 진짜 노가다 작업이잖아요! ㅋㅋㅋ 코드는 점점 비대해지고 가독성은 떨어지고... 😫
기존 예외 처리의 문제점
1. 중복 코드가 너무 많아요! (DRY 원칙 위반) 🔄
2. 비즈니스 로직과 예외 처리 로직이 섞여서 코드가 복잡해져요 🍝
3. 일관된 예외 응답 형식을 유지하기 어려워요 📏
4. 새로운 예외 타입이 추가될 때마다 여러 곳을 수정해야 해요 🔨
이런 문제를 해결하기 위해 Spring은 @ControllerAdvice라는 강력한 기능을 제공해요. 이걸 사용하면 애플리케이션 전체에서 발생하는 예외를 한 곳에서 깔끔하게 처리할 수 있어요! 👍
재능넷 같은 복잡한 플랫폼을 개발할 때도 이런 전역 예외 처리는 정말 필수적이에요. 다양한 API와 서비스가 있을 때 일관된 에러 응답을 제공하는 건 사용자 경험에도 큰 영향을 주거든요! 🌟
2. @ControllerAdvice와 @ExceptionHandler 이해하기 🧠
자, 이제 본격적으로 @ControllerAdvice가 뭔지 알아볼게요! 간단히 말하면 컨트롤러에서 발생하는 예외를 잡아서 한 곳에서 처리할 수 있게 해주는 어노테이션이에요.
핵심 개념 정리 📝
@ControllerAdvice: 여러 컨트롤러에 전역적으로 적용되는 코드를 작성할 수 있게 해주는 어노테이션이에요. Spring 3.2부터 도입되었어요.
@RestControllerAdvice: @ControllerAdvice + @ResponseBody의 조합이에요. REST API에서 주로 사용해요.
@ExceptionHandler: 특정 예외가 발생했을 때 실행될 메서드를 지정하는 어노테이션이에요.
Spring 6.0 버전(2025년 3월 기준 최신)에서는 이런 기능들이 더 강화되었어요. 특히 비동기 처리와의 통합이 개선되었답니다! 😎
@ControllerAdvice vs @RestControllerAdvice
이 둘의 차이점이 뭘까요? 간단해요!
@ControllerAdvice
- 주로 MVC 애플리케이션에서 사용해요
- View를 반환할 수 있어요
- @ResponseBody를 메서드에 추가로 붙여야 JSON 응답을 반환할 수 있어요
@RestControllerAdvice
- 주로 REST API에서 사용해요
- 기본적으로 모든 메서드에 @ResponseBody가 적용돼요
- JSON/XML 같은 데이터를 직접 반환하기 좋아요
요즘 트렌드는 마이크로서비스 아키텍처와 REST API가 대세라 @RestControllerAdvice를 더 많이 사용하는 추세예요! 재능넷 같은 현대적인 플랫폼도 아마 REST API를 많이 사용할 거예요. ㅎㅎ
3. 기본적인 전역 예외 처리 구현 방법 🛠️
이론은 충분히 알아봤으니 이제 실제로 코드를 작성해볼까요? 기본적인 전역 예외 처리기를 만드는 방법을 단계별로 알려드릴게요!
Step 1: 기본 예외 처리 클래스 만들기
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.http.ResponseEntity;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
ErrorResponse error = new ErrorResponse("SERVER_ERROR", ex.getMessage());
return ResponseEntity.internalServerError().body(error);
}
}
이렇게 하면 애플리케이션에서 발생하는 모든 처리되지 않은 예외를 이 핸들러가 잡아줄 거예요! 진짜 편하죠? ㅋㅋㅋ
Step 2: 표준 에러 응답 객체 만들기
public class ErrorResponse {
private String code;
private String message;
private LocalDateTime timestamp;
public ErrorResponse(String code, String message) {
this.code = code;
this.message = message;
this.timestamp = LocalDateTime.now();
}
// Getters and setters
}
이렇게 표준화된 응답 형식을 만들면 클라이언트 측에서도 일관된 방식으로 에러를 처리할 수 있어요. 프론트엔드 개발자들이 여러분에게 감사할 거예요! 😉
Step 3: 특정 예외 유형에 대한 핸들러 추가하기
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception ex) {
ErrorResponse error = new ErrorResponse("SERVER_ERROR", ex.getMessage());
return ResponseEntity.internalServerError().body(error);
}
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleResourceNotFoundException(ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse("RESOURCE_NOT_FOUND", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ErrorResponse> handleValidationException(ValidationException ex) {
ErrorResponse error = new ErrorResponse("VALIDATION_ERROR", ex.getMessage());
return ResponseEntity.badRequest().body(error);
}
}
이런 식으로 다양한 예외 유형에 맞게 핸들러를 추가할 수 있어요. 예외 유형별로 다른 HTTP 상태 코드와 응답 메시지를 반환할 수 있다는 게 정말 좋죠! 👍
"좋은 예외 처리는 사용자에게는 친절한 메시지를, 개발자에게는 디버깅에 필요한 정보를 제공해야 합니다."
- 어느 현명한 개발자 🧙♂️
4. 다양한 예외 유형별 처리 전략 🎯
실제 애플리케이션에서는 다양한 예외가 발생할 수 있어요. 각 예외 유형별로 어떻게 처리하는 게 좋을지 알아볼게요!
4.1 유효성 검증 예외 처리하기
Spring의 @Valid 어노테이션을 사용할 때 발생하는 MethodArgumentNotValidException을 처리해볼까요?
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
// 모든 유효성 검증 오류 메시지 수집
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ErrorResponse errorResponse = new ErrorResponse("VALIDATION_FAILED", "입력값 검증에 실패했습니다.");
errorResponse.setErrors(errors);
return ResponseEntity.badRequest().body(errorResponse);
}
이렇게 하면 폼 유효성 검증 실패 시 어떤 필드가 문제인지 자세히 알려줄 수 있어요! 사용자 친화적이죠? 😊
4.2 권한 관련 예외 처리하기
@ExceptionHandler({AccessDeniedException.class})
public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedException ex) {
ErrorResponse errorResponse = new ErrorResponse(
"ACCESS_DENIED",
"이 작업을 수행할 권한이 없습니다."
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse);
}
보안 관련 예외는 민감한 정보를 노출하지 않도록 주의해야 해요. 실제 예외 메시지를 그대로 반환하면 보안 취약점이 될 수 있어요! 🔒
4.3 비즈니스 로직 예외 처리하기
비즈니스 규칙 위반 같은 예외는 커스텀 예외를 만들어 처리하는 게 좋아요.
// 커스텀 예외 클래스
public class BusinessException extends RuntimeException {
private String errorCode;
public BusinessException(String message, String errorCode) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
// 예외 처리기에 추가
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException ex) {
ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage());
return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse);
}
이런 식으로 비즈니스 로직에 특화된 예외를 정의하면 코드의 의도가 더 명확해져요! 👌
4.4 외부 API 통신 예외 처리하기
@ExceptionHandler({HttpClientErrorException.class, HttpServerErrorException.class})
public ResponseEntity<ErrorResponse> handleHttpClientException(Exception ex) {
String message = "외부 서비스 연동 중 오류가 발생했습니다.";
ErrorResponse errorResponse = new ErrorResponse("EXTERNAL_API_ERROR", message);
// 로그에는 상세 정보 기록
log.error("External API error: {}", ex.getMessage(), ex);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse);
}
외부 API 호출 시 발생하는 예외는 사용자에게는 간단한 메시지를 보여주고, 로그에는 상세 정보를 남기는 게 좋아요. 이렇게 하면 디버깅도 쉬워지죠! 🔍
💡 개발자 꿀팁!
예외 처리할 때 로그 레벨을 적절히 사용하는 것도 중요해요:
- ERROR: 실제 오류 상황 (서버 내부 오류, 데이터베이스 연결 실패 등)
- WARN: 잠재적인 문제 상황 (deprecated API 사용, 성능 이슈 등)
- INFO: 일반적인 정보 (애플리케이션 시작/종료, 중요 프로세스 완료 등)
- DEBUG: 개발 시 디버깅에 유용한 상세 정보
- TRACE: 가장 상세한 정보 (거의 사용하지 않음)
5. 실전 예제: REST API에서의 예외 처리 🚀
이제 실제 REST API 프로젝트에서 어떻게 전역 예외 처리를 구현하는지 더 자세한 예제를 살펴볼게요!
5.1 전체 예외 처리 구조 설계
먼저 전체적인 구조를 설계해볼게요. 계층화된 예외 처리 구조를 만드는 게 좋아요!
// 기본 예외 클래스
public abstract class BaseException extends RuntimeException {
private final ErrorCode errorCode;
public BaseException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
// 에러 코드 열거형
public enum ErrorCode {
// 클라이언트 오류 (4xx)
INVALID_INPUT_VALUE(400, "C001", "잘못된 입력값입니다."),
RESOURCE_NOT_FOUND(404, "C002", "요청한 리소스를 찾을 수 없습니다."),
METHOD_NOT_ALLOWED(405, "C003", "지원하지 않는 HTTP 메서드입니다."),
ACCESS_DENIED(403, "C004", "접근 권한이 없습니다."),
// 서버 오류 (5xx)
INTERNAL_SERVER_ERROR(500, "S001", "서버 내부 오류가 발생했습니다."),
DATABASE_ERROR(500, "S002", "데이터베이스 오류가 발생했습니다.");
private final int status;
private final String code;
private final String message;
ErrorCode(int status, String code, String message) {
this.status = status;
this.code = code;
this.message = message;
}
// Getters
public int getStatus() { return status; }
public String getCode() { return code; }
public String getMessage() { return message; }
}
이런 식으로 에러 코드를 열거형으로 관리하면 일관된 에러 처리가 가능해요. 코드 가독성도 좋아지고요! 👍
5.2 구체적인 예외 클래스 구현
// 리소스를 찾을 수 없을 때 사용하는 예외
public class ResourceNotFoundException extends BaseException {
public ResourceNotFoundException() {
super(ErrorCode.RESOURCE_NOT_FOUND);
}
public ResourceNotFoundException(String message) {
super(ErrorCode.RESOURCE_NOT_FOUND);
}
}
// 입력값 검증 실패 시 사용하는 예외
public class InvalidValueException extends BaseException {
public InvalidValueException() {
super(ErrorCode.INVALID_INPUT_VALUE);
}
public InvalidValueException(String message) {
super(ErrorCode.INVALID_INPUT_VALUE);
}
}
이렇게 구체적인 예외 클래스를 만들면 코드에서 예외를 던질 때 의도가 명확해져요! 가독성이 훨씬 좋아지죠. ㅎㅎ
5.3 완성된 전역 예외 처리기
@RestControllerAdvice
public class GlobalExceptionHandler {
// 기본 예외 처리
@ExceptionHandler(BaseException.class)
public ResponseEntity<ErrorResponse> handleBaseException(BaseException ex) {
ErrorCode errorCode = ex.getErrorCode();
ErrorResponse response = new ErrorResponse(errorCode.getCode(), errorCode.getMessage());
return ResponseEntity.status(errorCode.getStatus()).body(response);
}
// 유효성 검증 예외 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationExceptions(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ErrorResponse response = new ErrorResponse(
ErrorCode.INVALID_INPUT_VALUE.getCode(),
ErrorCode.INVALID_INPUT_VALUE.getMessage()
);
response.setErrors(errors);
return ResponseEntity.status(ErrorCode.INVALID_INPUT_VALUE.getStatus()).body(response);
}
// 그 외 모든 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleAllExceptions(Exception ex) {
// 로그에 상세 정보 기록
log.error("Unhandled exception occurred: ", ex);
ErrorResponse response = new ErrorResponse(
ErrorCode.INTERNAL_SERVER_ERROR.getCode(),
ErrorCode.INTERNAL_SERVER_ERROR.getMessage()
);
return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()).body(response);
}
}
이제 모든 예외가 일관된 형식으로 처리되고 있어요! 클라이언트는 항상 동일한 형식의 에러 응답을 받게 되죠. 😎
6. 응답 형식 표준화하기 📋
API 응답 형식을 표준화하는 것은 일관된 사용자 경험을 제공하는 데 매우 중요해요. 에러 응답뿐만 아니라 성공 응답도 일관된 형식으로 제공하면 좋아요!
6.1 표준 응답 형식 설계
// 기본 응답 클래스
public class ApiResponse<T> {
private boolean success;
private T data;
private ErrorInfo error;
// 성공 응답 생성
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.success = true;
response.data = data;
return response;
}
// 에러 응답 생성
public static <T> ApiResponse<T> error(String code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.success = false;
response.error = new ErrorInfo(code, message);
return response;
}
// 내부 에러 정보 클래스
@Getter @Setter
public static class ErrorInfo {
private String code;
private String message;
private List<String> details;
public ErrorInfo(String code, String message) {
this.code = code;
this.message = message;
}
}
// Getters and setters
}
이런 식으로 성공과 실패 응답을 동일한 구조로 제공하면 클라이언트 측에서 처리하기가 훨씬 편해져요! 😊
6.2 응답 래퍼 메서드 추가
GlobalExceptionHandler에 응답 래퍼 메서드를 추가해볼게요.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 기본 예외 처리
@ExceptionHandler(BaseException.class)
public ResponseEntity<ApiResponse<Void>> handleBaseException(BaseException ex) {
ErrorCode errorCode = ex.getErrorCode();
ApiResponse<Void> response = ApiResponse.error(
errorCode.getCode(),
errorCode.getMessage()
);
return ResponseEntity.status(errorCode.getStatus()).body(response);
}
// 다른 예외 핸들러들...
}
이제 컨트롤러에서도 동일한 형식으로 응답을 반환하면 완벽한 일관성을 유지할 수 있어요!
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<UserDto>> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id);
return ResponseEntity.ok(ApiResponse.success(user));
}
// 다른 엔드포인트들...
}
이렇게 하면 성공이든 실패든 항상 동일한 구조의 응답이 반환되어 클라이언트 측 코드가 훨씬 단순해져요! 👍
6.3 응답 예시
성공 응답 예시
{
"success": true,
"data": {
"id": 123,
"name": "홍길동",
"email": "hong@example.com"
},
"error": null
}
에러 응답 예시
{
"success": false,
"data": null,
"error": {
"code": "C002",
"message": "요청한 리소스를 찾을 수 없습니다.",
"details": ["사용자 ID 123은 존재하지 않습니다."]
}
}
이런 식으로 일관된 응답 형식을 유지하면 프론트엔드 개발자들이 정말 좋아할 거예요! 재능넷 같은 플랫폼에서도 이런 표준화된 응답 형식이 API 개발에 큰 도움이 될 거예요. 😉
7. 로깅과 모니터링 연동 📊
예외 처리는 단순히 사용자에게 에러 메시지를 보여주는 것 이상의 의미가 있어요. 시스템 모니터링과 문제 해결에도 중요한 역할을 해요!
7.1 효과적인 로깅 전략
@RestControllerAdvice
public class GlobalExceptionHandler {
private final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAllExceptions(Exception ex, WebRequest request) {
// 요청 정보 추출
String requestURI = ((ServletWebRequest)request).getRequest().getRequestURI();
String method = ((ServletWebRequest)request).getRequest().getMethod();
// MDC에 요청 정보 추가 (로그 추적용)
MDC.put("requestId", UUID.randomUUID().toString());
MDC.put("requestURI", requestURI);
MDC.put("method", method);
// 상세 로그 기록
log.error("Unhandled exception occurred: URI={}, Method={}", requestURI, method, ex);
// 응답 생성
ApiResponse<Void> response = ApiResponse.error(
ErrorCode.INTERNAL_SERVER_ERROR.getCode(),
ErrorCode.INTERNAL_SERVER_ERROR.getMessage()
);
// MDC 정리
MDC.clear();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
}
이런 식으로 MDC(Mapped Diagnostic Context)를 활용하면 로그에 요청 정보를 포함시킬 수 있어요. 나중에 로그 분석할 때 정말 유용하죠! 👍
7.2 모니터링 시스템 연동
Prometheus나 Grafana 같은 모니터링 도구와 연동하면 예외 발생 현황을 실시간으로 모니터링할 수 있어요.
@Component
public class ExceptionMetrics {
private final Counter totalExceptionCounter;
private final Counter validationExceptionCounter;
private final Counter resourceNotFoundCounter;
private final Counter serverErrorCounter;
public ExceptionMetrics(MeterRegistry registry) {
this.totalExceptionCounter = Counter.builder("app.exceptions.total")
.description("Total number of exceptions")
.register(registry);
this.validationExceptionCounter = Counter.builder("app.exceptions.validation")
.description("Number of validation exceptions")
.register(registry);
this.resourceNotFoundCounter = Counter.builder("app.exceptions.not_found")
.description("Number of resource not found exceptions")
.register(registry);
this.serverErrorCounter = Counter.builder("app.exceptions.server")
.description("Number of server error exceptions")
.register(registry);
}
public void incrementTotalExceptions() {
this.totalExceptionCounter.increment();
}
public void incrementValidationExceptions() {
this.validationExceptionCounter.increment();
}
// 다른 메서드들...
}
이제 GlobalExceptionHandler에서 이 메트릭을 사용하면 돼요!
@RestControllerAdvice
public class GlobalExceptionHandler {
private final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private final ExceptionMetrics metrics;
public GlobalExceptionHandler(ExceptionMetrics metrics) {
this.metrics = metrics;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException ex) {
// 메트릭 증가
metrics.incrementTotalExceptions();
metrics.incrementValidationExceptions();
// 로깅 및 응답 생성 로직...
}
// 다른 핸들러들...
}
이렇게 하면 예외 발생 현황을 실시간으로 모니터링할 수 있어요. 어떤 유형의 예외가 많이 발생하는지 파악하고 개선할 수 있죠! 📈
7.3 알림 시스템 연동
심각한 예외가 발생했을 때 개발팀에 알림을 보내는 기능도 추가할 수 있어요.
@Component
public class AlertService {
private final SlackClient slackClient;
public AlertService(SlackClient slackClient) {
this.slackClient = slackClient;
}
public void sendCriticalErrorAlert(String errorMessage, String stackTrace) {
SlackMessage message = SlackMessage.builder()
.text("❗ 심각한 오류 발생")
.attachment(SlackAttachment.builder()
.color("danger")
.title("오류 정보")
.text("메시지: " + errorMessage + "\n스택트레이스: " + stackTrace)
.build())
.build();
slackClient.sendMessage(message);
}
}
이제 GlobalExceptionHandler에서 심각한 예외가 발생했을 때 알림을 보낼 수 있어요!
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ApiResponse<Void>> handleDataAccessException(DataAccessException ex) {
// 로깅
log.error("Database error occurred", ex);
// 메트릭 증가
metrics.incrementTotalExceptions();
metrics.incrementDatabaseExceptions();
// 알림 전송
String stackTrace = ExceptionUtils.getStackTrace(ex);
alertService.sendCriticalErrorAlert(ex.getMessage(), stackTrace);
// 응답 생성
ApiResponse<Void> response = ApiResponse.error(
ErrorCode.DATABASE_ERROR.getCode(),
ErrorCode.DATABASE_ERROR.getMessage()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
이런 식으로 중요한 예외가 발생했을 때 즉시 알림을 받을 수 있어요. 문제를 빠르게 인지하고 대응할 수 있죠! 🚨
8. 테스트 코드 작성법 🧪
전역 예외 처리기가 제대로 동작하는지 테스트하는 것도 중요해요! 단위 테스트와 통합 테스트를 통해 예외 처리 로직을 검증해볼게요.
8.1 단위 테스트 작성하기
@ExtendWith(MockitoExtension.class)
class GlobalExceptionHandlerTest {
@InjectMocks
private GlobalExceptionHandler exceptionHandler;
@Mock
private ExceptionMetrics metrics;
@Test
void handleResourceNotFoundException_ShouldReturnNotFound() {
// Given
ResourceNotFoundException ex = new ResourceNotFoundException("리소스를 찾을 수 없습니다.");
// When
ResponseEntity<ApiResponse<Void>> response = exceptionHandler.handleResourceNotFoundException(ex);
// Then
assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
assertFalse(response.getBody().isSuccess());
assertEquals(ErrorCode.RESOURCE_NOT_FOUND.getCode(), response.getBody().getError().getCode());
assertEquals(ErrorCode.RESOURCE_NOT_FOUND.getMessage(), response.getBody().getError().getMessage());
// 메트릭 증가 확인
verify(metrics).incrementTotalExceptions();
verify(metrics).incrementResourceNotFoundExceptions();
}
// 다른 테스트 메서드들...
}
이런 식으로 각 예외 핸들러 메서드를 개별적으로 테스트할 수 있어요. 단위 테스트는 빠르게 실행되고 문제를 빨리 발견할 수 있어서 좋아요! 👍
8.2 통합 테스트 작성하기
@SpringBootTest
@AutoConfigureMockMvc
class ExceptionHandlingIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Test
void whenResourceNotFound_thenReturns404() throws Exception {
// Given
long nonExistentId = 9999L;
// When & Then
mockMvc.perform(get("/api/users/{id}", nonExistentId))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value(ErrorCode.RESOURCE_NOT_FOUND.getCode()))
.andExpect(jsonPath("$.error.message").value(ErrorCode.RESOURCE_NOT_FOUND.getMessage()));
}
@Test
void whenInvalidInput_thenReturns400() throws Exception {
// Given
UserCreateDto invalidUser = new UserCreateDto();
// name이 null이라 유효성 검증 실패
// When & Then
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(invalidUser)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.error.code").value(ErrorCode.INVALID_INPUT_VALUE.getCode()))
.andExpect(jsonPath("$.error.details").isArray())
.andExpect(jsonPath("$.error.details[0]").isNotEmpty());
}
// 다른 테스트 메서드들...
}
통합 테스트를 통해 실제 HTTP 요청 흐름에서 예외 처리가 제대로 동작하는지 확인할 수 있어요. 실제 상황과 유사한 테스트라 더 신뢰할 수 있죠! 🔍
8.3 테스트 커버리지 확인하기
JaCoCo 같은 도구를 사용하면 테스트 커버리지를 확인할 수 있어요.
// build.gradle
plugins {
id 'jacoco'
}
jacoco {
toolVersion = "0.8.9"
}
jacocoTestReport {
reports {
xml.enabled true
html.enabled true
}
afterEvaluate {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
"com/example/demo/config/**",
"com/example/demo/DemoApplication.class"
])
}))
}
}
test {
finalizedBy jacocoTestReport
}
이렇게 설정하면 테스트 실행 후 자동으로 커버리지 리포트가 생성돼요. 예외 처리 코드가 얼마나 테스트되고 있는지 확인할 수 있죠! 📊
🔥 실전 예제: 완전한 GlobalExceptionHandler 구현
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
private final ExceptionMetrics metrics;
private final AlertService alertService;
public GlobalExceptionHandler(ExceptionMetrics metrics, AlertService alertService) {
this.metrics = metrics;
this.alertService = alertService;
}
// 애플리케이션 예외 처리
@ExceptionHandler(BaseException.class)
public ResponseEntity<ApiResponse<Void>> handleBaseException(BaseException ex) {
ErrorCode errorCode = ex.getErrorCode();
log.error("[{}] {}", errorCode.getCode(), ex.getMessage());
metrics.incrementTotalExceptions();
ApiResponse<Void> response = ApiResponse.error(
errorCode.getCode(),
errorCode.getMessage()
);
return ResponseEntity.status(errorCode.getStatus()).body(response);
}
// 유효성 검증 예외 처리
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationExceptions(MethodArgumentNotValidException ex) {
log.error("Validation error: {}", ex.getMessage());
metrics.incrementTotalExceptions();
metrics.incrementValidationExceptions();
List<String> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.toList());
ApiResponse<Void> response = ApiResponse.error(
ErrorCode.INVALID_INPUT_VALUE.getCode(),
ErrorCode.INVALID_INPUT_VALUE.getMessage()
);
response.getError().setDetails(errors);
return ResponseEntity.status(ErrorCode.INVALID_INPUT_VALUE.getStatus()).body(response);
}
// 데이터베이스 예외 처리
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ApiResponse<Void>> handleDataAccessException(DataAccessException ex) {
log.error("Database error: ", ex);
metrics.incrementTotalExceptions();
metrics.incrementDatabaseExceptions();
// 심각한 오류는 알림 전송
String stackTrace = ExceptionUtils.getStackTrace(ex);
alertService.sendCriticalErrorAlert("데이터베이스 오류", ex.getMessage(), stackTrace);
ApiResponse<Void> response = ApiResponse.error(
ErrorCode.DATABASE_ERROR.getCode(),
ErrorCode.DATABASE_ERROR.getMessage()
);
return ResponseEntity.status(ErrorCode.DATABASE_ERROR.getStatus()).body(response);
}
// 권한 관련 예외 처리
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(AccessDeniedException ex) {
log.error("Access denied: {}", ex.getMessage());
metrics.incrementTotalExceptions();
metrics.incrementSecurityExceptions();
ApiResponse<Void> response = ApiResponse.error(
ErrorCode.ACCESS_DENIED.getCode(),
ErrorCode.ACCESS_DENIED.getMessage()
);
return ResponseEntity.status(ErrorCode.ACCESS_DENIED.getStatus()).body(response);
}
// 그 외 모든 예외 처리
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAllExceptions(Exception ex, WebRequest request) {
String requestURI = ((ServletWebRequest)request).getRequest().getRequestURI();
String method = ((ServletWebRequest)request).getRequest().getMethod();
log.error("Unhandled exception: URI={}, Method={}", requestURI, method, ex);
metrics.incrementTotalExceptions();
metrics.incrementServerExceptions();
// 예상치 못한 오류는 알림 전송
String stackTrace = ExceptionUtils.getStackTrace(ex);
alertService.sendCriticalErrorAlert("예상치 못한 서버 오류", ex.getMessage(), stackTrace);
ApiResponse<Void> response = ApiResponse.error(
ErrorCode.INTERNAL_SERVER_ERROR.getCode(),
ErrorCode.INTERNAL_SERVER_ERROR.getMessage()
);
return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus()).body(response);
}
}
9. 성능 최적화 팁 🚀
전역 예외 처리도 성능에 영향을 줄 수 있어요. 몇 가지 최적화 팁을 알아볼게요!
9.1 예외 생성 비용 줄이기
예외 객체를 생성하고 스택 트레이스를 수집하는 것은 생각보다 비용이 많이 들어요.
// 비효율적인 방법
if (user != null) {
return user;
} else {
throw new ResourceNotFoundException("사용자를 찾을 수 없습니다: " + id);
}
// 더 효율적인 방법
if (user == null) {
throw new ResourceNotFoundException("사용자를 찾을 수 없습니다: " + id);
}
return user;
두 번째 방법이 예외 경로와 정상 경로를 명확히 분리해서 더 효율적이에요. 예외는 정말 예외적인 상황에서만 사용하는 게 좋아요! ⚡
9.2 예외 래핑 최소화하기
예외를 여러 번 래핑하면 성능이 저하될 수 있어요.
// 비효율적인 방법
try {
return userRepository.findById(id);
} catch (DataAccessException ex) {
throw new ServiceException("데이터베이스 오류", ex);
}
// 더 효율적인 방법 (필요한 경우에만 래핑)
try {
return userRepository.findById(id);
} catch (DataAccessException ex) {
log.error("데이터베이스 오류: {}", ex.getMessage());
throw ex; // 원래 예외를 그대로 전파
}
꼭 필요한 경우에만 예외를 래핑하고, 가능하면 원래 예외를 그대로 전파하는 게 좋아요. 스택 트레이스가 더 깔끔해지고 성능도 좋아져요! 👍
9.3 로깅 최적화하기
// 비효율적인 방법 (항상 문자열 연산 발생)
log.error("User not found: " + userId + ", requestId: " + requestId);
// 더 효율적인 방법 (로그 레벨이 활성화된 경우에만 문자열 연산 발생)
log.error("User not found: {}, requestId: {}", userId, requestId);
SLF4J의 파라미터화된 로깅을 사용하면 불필요한 문자열 연산을 줄일 수 있어요. 로그 양이 많을 때 성능 차이가 꽤 커요! 📝
9.4 캐싱 활용하기
자주 발생하는 예외에 대한 응답을 캐싱할 수도 있어요.
@Component
public class ErrorResponseCache {
private final Map<String, ApiResponse<Void>> cache = new ConcurrentHashMap<>();
public ApiResponse<Void> getErrorResponse(String errorCode, String message) {
return cache.computeIfAbsent(
errorCode,
code -> ApiResponse.error(code, message)
);
}
}
// 예외 핸들러에서 사용
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleResourceNotFoundException(ResourceNotFoundException ex) {
String errorCode = ErrorCode.RESOURCE_NOT_FOUND.getCode();
String message = ErrorCode.RESOURCE_NOT_FOUND.getMessage();
ApiResponse<Void> response = errorResponseCache.getErrorResponse(errorCode, message);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
이런 식으로 자주 사용되는 에러 응답을 캐싱하면 객체 생성 비용을 줄일 수 있어요. 특히 트래픽이 많은 시스템에서 효과적이에요! 🚀
10. 마무리 및 추가 자료 📚
지금까지 Spring의 @ControllerAdvice를 이용한 전역 예외 처리에 대해 알아봤어요! 깔끔하고 일관된 예외 처리는 애플리케이션의 품질을 크게 향상시킬 수 있어요. 😊
10.1 정리
- @ControllerAdvice와 @ExceptionHandler를 사용하면 애플리케이션 전체의 예외를 한 곳에서 처리할 수 있어요.
- 표준화된 응답 형식을 사용하면 클라이언트 측에서 일관된 방식으로 에러를 처리할 수 있어요.
- 다양한 예외 유형에 맞는 처리 전략을 구현하면 더 정확한 피드백을 제공할 수 있어요.
- 로깅과 모니터링을 연동하면 예외 발생 현황을 실시간으로 파악할 수 있어요.
- 테스트 코드를 작성하여 예외 처리 로직이 제대로 동작하는지 검증할 수 있어요.
- 성능 최적화 기법을 적용하여 예외 처리로 인한 성능 저하를 최소화할 수 있어요.
이런 기법들을 적용하면 재능넷과 같은 복잡한 플랫폼에서도 안정적이고 사용자 친화적인 예외 처리가 가능해져요! 👍
10.2 추가 학습 자료
- Spring 공식 문서: Exception Handling
- Baeldung 튜토리얼: Error Handling for REST with Spring
- GitHub 예제 프로젝트: Spring Boot Samples
- Spring Boot Actuator: Production-ready Features
- Martin Fowler의 블로그: Replace Throwing Exceptions with Notification in Validations
10.3 마무리 메시지
예외 처리는 단순한 기술적 문제가 아니라 사용자 경험과 직결되는 중요한 부분이에요. 잘 설계된 예외 처리 시스템은 사용자에게 친절한 메시지를 제공하고, 개발자에게는 디버깅에 필요한 정보를 제공하며, 시스템 안정성을 높여줘요.
Spring의 @ControllerAdvice는 이런 목표를 달성하는 데 아주 유용한 도구예요. 여러분의 프로젝트에 이 글에서 소개한 패턴들을 적용해보세요! 더 안정적이고 유지보수하기 쉬운 애플리케이션을 만들 수 있을 거예요. 😄
혹시 이 글이 도움이 되셨다면, 재능넷에서 더 많은 개발 관련 지식을 찾아보세요! 다양한 개발자들이 자신의 지식과 경험을 공유하고 있답니다. 함께 성장해요! 🌱
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.