Spring으로 API를 개발하다 보면 예외 처리 코드가 점점 늘어납니다. 사용자가 없으면 UserNotFoundException, 권한이 없으면 AccessDeniedException, 입력값이 잘못되면 IllegalArgumentException... 이런 예외들을 컨트롤러마다 try-catch로 잡다 보면 코드가 지저분해지는 경험, 한 번쯤 있으실 겁니다. 오늘은 이 문제를 깔끔하게 해결하는 방법을 알아보겠습니다.
문제 상황: try-catch가 여기저기 널려있다
먼저 흔히 볼 수 있는 코드를 보겠습니다.
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "사용자를 찾을 수 없습니다"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "서버 오류가 발생했습니다"));
}
}
@PostMapping
public ResponseEntity<?> createUser(@RequestBody UserRequest request) {
try {
User user = userService.create(request);
return ResponseEntity.ok(user);
} catch (DuplicateEmailException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "이미 존재하는 이메일입니다"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "서버 오류가 발생했습니다"));
}
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
try {
userService.delete(id);
return ResponseEntity.ok().build();
} catch (UserNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "사용자를 찾을 수 없습니다"));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "서버 오류가 발생했습니다"));
}
}
}
메소드가 3개밖에 안 되는데도 벌써 try-catch가 도배되어 있습니다. 문제점을 정리해보면:
- 똑같은 예외 처리 코드가 반복됩니다
- 컨트롤러가 늘어날수록 복사-붙여넣기가 늘어납니다
- 에러 응답 형식을 바꾸려면 모든 컨트롤러를 수정해야 합니다
- 비즈니스 로직과 예외 처리 코드가 섞여서 가독성이 떨어집니다
마치 아파트 각 호수마다 경비원을 따로 두는 것과 같습니다. 비효율적이죠?
해결책: @ControllerAdvice로 예외 처리를 한 곳에 모으자
@ControllerAdvice는 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리할 수 있게 해주는 어노테이션입니다. 아파트 입구에 경비실을 하나 두는 것과 같습니다.
[예외 발생] → [ControllerAdvice(경비실)] → [적절한 에러 응답]
기본 구조
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
ErrorResponse response = new ErrorResponse("USER_NOT_FOUND", "사용자를 찾을 수 없습니다");
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
}
이게 기본 형태입니다. 하나씩 뜯어보겠습니다.
1단계: 에러 응답 형식 정의하기
먼저 API에서 일관되게 사용할 에러 응답 형식을 정의합니다.
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();
}
// Getter 생략
}
이렇게 하면 모든 에러 응답이 같은 형식을 갖게 됩니다:
{
"code": "USER_NOT_FOUND",
"message": "사용자를 찾을 수 없습니다",
"timestamp": "2024-01-15T10:30:00"
}
클라이언트(프론트엔드) 개발자 입장에서는 에러 응답이 일관되어 있으면 처리하기 훨씬 편합니다.
2단계: 커스텀 예외 클래스 만들기
비즈니스 로직에서 사용할 예외 클래스를 만듭니다.
// 최상위 비즈니스 예외
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}
// 사용자를 찾을 수 없을 때
public class UserNotFoundException extends BusinessException {
public UserNotFoundException(Long userId) {
super("USER_NOT_FOUND", "사용자를 찾을 수 없습니다. ID: " + userId);
}
}
// 이메일이 중복될 때
public class DuplicateEmailException extends BusinessException {
public DuplicateEmailException(String email) {
super("DUPLICATE_EMAIL", "이미 사용 중인 이메일입니다: " + email);
}
}
// 권한이 없을 때
public class AccessDeniedException extends BusinessException {
public AccessDeniedException() {
super("ACCESS_DENIED", "접근 권한이 없습니다");
}
}
왜 RuntimeException을 상속할까요? RuntimeException은 언체크 예외(Unchecked Exception)라서 메소드 시그니처에 throws를 붙이지 않아도 됩니다. 코드가 훨씬 깔끔해집니다.
3단계: @RestControllerAdvice로 전역 예외 처리기 만들기
이제 핵심인 전역 예외 처리기를 만들어 보겠습니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 1. 비즈니스 예외 처리
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
ErrorResponse response = new ErrorResponse(e.getCode(), e.getMessage());
// 예외 종류에 따라 HTTP 상태 코드 결정
HttpStatus status = determineHttpStatus(e);
return ResponseEntity.status(status).body(response);
}
// 2. 입력값 검증 실패 (@Valid)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationException(
MethodArgumentNotValidException e) {
String message = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
ErrorResponse response = new ErrorResponse("VALIDATION_ERROR", message);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
}
// 3. 그 외 모든 예외 (최후의 방어선)
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
// 로그는 남기되, 클라이언트에게는 자세한 정보를 노출하지 않음
log.error("예상치 못한 오류 발생", e);
ErrorResponse response = new ErrorResponse(
"INTERNAL_ERROR",
"서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
private HttpStatus determineHttpStatus(BusinessException e) {
if (e instanceof UserNotFoundException) {
return HttpStatus.NOT_FOUND;
}
if (e instanceof DuplicateEmailException) {
return HttpStatus.CONFLICT;
}
if (e instanceof AccessDeniedException) {
return HttpStatus.FORBIDDEN;
}
return HttpStatus.BAD_REQUEST;
}
}
@ControllerAdvice와 @RestControllerAdvice의 차이가 궁금하실 수 있습니다. @RestControllerAdvice는 @ControllerAdvice + @ResponseBody입니다. REST API를 만들 때는 @RestControllerAdvice를 쓰면 됩니다.
4단계: 컨트롤러가 깔끔해진다
이제 컨트롤러를 다시 작성해 보겠습니다.
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userService.findById(id);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest request) {
User user = userService.create(request);
return ResponseEntity.ok(user);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.delete(id);
return ResponseEntity.ok().build();
}
}
try-catch가 전부 사라졌습니다. 컨트롤러는 "무엇을 할 것인가"에만 집중하고, "예외가 발생하면 어떻게 할 것인가"는 GlobalExceptionHandler가 담당합니다.
5단계: 서비스 레이어에서 예외 던지기
서비스 레이어에서는 적절한 상황에 예외를 던지면 됩니다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public User findById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
}
public User create(UserRequest request) {
// 이메일 중복 체크
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException(request.getEmail());
}
User user = new User(request.getName(), request.getEmail());
return userRepository.save(user);
}
public void delete(Long id) {
User user = findById(id); // 없으면 UserNotFoundException 발생
userRepository.delete(user);
}
}
서비스 레이어는 비즈니스 로직에만 집중합니다. 예외가 발생하면 그냥 던지고, 처리는 GlobalExceptionHandler에게 맡깁니다.
더 나아가기: HTTP 상태 코드를 예외 클래스에 담기
위에서 determineHttpStatus() 메소드로 예외 종류에 따라 HTTP 상태 코드를 결정했는데, 예외가 많아지면 이것도 복잡해집니다. 예외 클래스 자체에 HTTP 상태 코드를 담으면 더 깔끔해집니다.
public class BusinessException extends RuntimeException {
private final String code;
private final HttpStatus httpStatus;
public BusinessException(String code, String message, HttpStatus httpStatus) {
super(message);
this.code = code;
this.httpStatus = httpStatus;
}
public String getCode() {
return code;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}
public class UserNotFoundException extends BusinessException {
public UserNotFoundException(Long userId) {
super("USER_NOT_FOUND",
"사용자를 찾을 수 없습니다. ID: " + userId,
HttpStatus.NOT_FOUND);
}
}
public class DuplicateEmailException extends BusinessException {
public DuplicateEmailException(String email) {
super("DUPLICATE_EMAIL",
"이미 사용 중인 이메일입니다: " + email,
HttpStatus.CONFLICT);
}
}
이제 예외 처리기가 더 단순해집니다:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
ErrorResponse response = new ErrorResponse(e.getCode(), e.getMessage());
return ResponseEntity.status(e.getHttpStatus()).body(response);
}
}
새로운 예외를 추가할 때마다 GlobalExceptionHandler를 수정할 필요가 없어집니다. 예외 클래스만 만들면 됩니다.
@ControllerAdvice의 범위 제한하기
모든 컨트롤러가 아니라 특정 컨트롤러에만 적용하고 싶을 수도 있습니다.
// 특정 패키지의 컨트롤러에만 적용
@RestControllerAdvice(basePackages = "com.example.api")
public class ApiExceptionHandler { }
// 특정 컨트롤러에만 적용
@RestControllerAdvice(assignableTypes = {UserController.class, OrderController.class})
public class SpecificExceptionHandler { }
// 특정 어노테이션이 붙은 컨트롤러에만 적용
@RestControllerAdvice(annotations = RestController.class)
public class RestApiExceptionHandler { }
예외 처리 우선순위
여러 @ExceptionHandler가 있을 때, Spring은 가장 구체적인 예외를 처리하는 핸들러를 선택합니다.
@RestControllerAdvice
public class GlobalExceptionHandler {
// 더 구체적인 예외 → 먼저 매칭
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException e) {
// ...
}
// 덜 구체적인 예외 → 위에서 매칭 안 되면 여기로
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException e) {
// ...
}
// 가장 일반적인 예외 → 최후의 방어선
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
// ...
}
}
UserNotFoundException이 발생하면 handleUserNotFound()가 처리합니다. UserNotFoundException은 BusinessException의 하위 클래스이지만, 더 구체적인 핸들러가 우선입니다.
주의할 점
1. 예외 정보를 너무 많이 노출하지 말 것
// 나쁜 예 - 스택 트레이스를 그대로 노출
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(Exception e) {
ErrorResponse response = new ErrorResponse(
"ERROR",
e.getMessage() + "\n" + Arrays.toString(e.getStackTrace()) // 위험!
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
클라이언트에게 스택 트레이스나 내부 구현 정보를 노출하면 보안에 취약해집니다. 로그에는 자세히 남기되, 응답에는 일반적인 메시지만 보내세요.
2. @ExceptionHandler와 try-catch를 섞어 쓰지 말 것
// 일관성 없는 코드
@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
try {
User user = userService.findById(id);
return ResponseEntity.ok(user);
} catch (UserNotFoundException e) {
// 여기서 처리하면 GlobalExceptionHandler를 안 탐
return ResponseEntity.notFound().build();
}
}
일부는 try-catch로, 일부는 @ControllerAdvice로 처리하면 코드가 혼란스러워집니다. 한 가지 방식으로 통일하세요.
3. 비즈니스 로직에서 예외를 삼키지 말 것
// 나쁜 예 - 예외를 삼킴
public User findById(Long id) {
try {
return userRepository.findById(id).orElseThrow();
} catch (Exception e) {
return null; // 예외를 삼키고 null 반환
}
}
예외를 삼키면 문제가 어디서 발생했는지 추적하기 어렵습니다. 예외는 적절히 던지고, 상위에서 처리하세요.
정리
- @ControllerAdvice는 모든 컨트롤러의 예외를 한 곳에서 처리할 수 있게 해준다
- @ExceptionHandler로 특정 예외 타입에 대한 처리 로직을 정의한다
- 컨트롤러는 비즈니스 로직에만 집중하고, 예외 처리는 전역 핸들러에 위임한다
- 에러 응답 형식을 통일하면 클라이언트가 처리하기 편하다
- 커스텀 예외 클래스에 에러 코드와 HTTP 상태를 담으면 확장성이 좋아진다
- 예외는 삼키지 말고 적절히 던지되, 클라이언트에게는 필요한 정보만 노출한다
try-catch를 컨트롤러마다 반복해서 쓰고 있었다면, @ControllerAdvice를 도입해보세요. 코드가 훨씬 깔끔해지고, 유지보수도 쉬워집니다.
'프레임워크 > 스프링' 카테고리의 다른 글
| @RestController vs @Controller: API 서버와 화면 서버의 차이 (0) | 2025.12.30 |
|---|---|
| JPA N+1 문제: 개발자를 가장 괴롭히는 성능 이슈와 해결책 (0) | 2025.12.28 |
| JPA 영속성 컨텍스트(Persistence Context): 1차 캐시와 쓰기 지연이 주는 이점 (0) | 2025.12.28 |
| @Transactional의 동작 원리: 트랜잭션은 어떻게 시작되고 롤백될까? (0) | 2025.12.26 |
| AOP(관점 지향 프로그래밍): 어떻게 메소드 실행 전후에 로그를 남길까? (0) | 2025.12.24 |