오늘의 추천 곡
백엔드 개발을 하다보면 느끼는 게 있는데, 항상 최적화라던가 중복을 피한다거나 효율적인 로직을 짜는 게 중요하다고 생각은 한다.
물론 모든 걸 완벽하게 지키기란 쉽지 않지만 하나씩 하나씩 알아가고 배워가는 과정에서 성장하고 있음을 느끼는 것 같다.
아무튼 오늘은 예외처리에 관한 이야기를 해보고자 한다.
처음에는 컨트롤러단에서 try-catch로 처리하는 게 당연한 줄 알았다.
그도 그럴게 대학교 4년간 많은 서적과 각종 AI가 그렇게 써왔기 때문에 그게 당연한 건줄 알았는데 ResponseEntity에 대해서 찾아보다가 스프링의 @RestControllerAdvice에 대해서 알게되었고, 전공자이지만 이런 걸 몰랐다는 나를 자책하면서 글을 작성한다.....
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
try {
authService.login(request);
return ResponseEntity.ok(ApiResponse.success());
} catch (PasswordMismatchException e) {
return ResponseEntity.ok(ApiResponse.fail("PASSWORD_MISMATCH"));
}
}
일단 위 코드는 문제 없이 잘 동작은 한다.
예외도 잘 잡히고 응답도 원하는 대로 내려온다.
내용을 살펴보면, PasswordMismatchException이 발생했을 때 catch 블록에서 잡아서 실패 응답을 보내주고 있다.
하지만 이 하나의 메서드만 볼 게 아니고 컨트롤러 전체에 이런 try-catch 구문이 반복된다면?..
일단 가독성도 박살날 것이고 예외를 처리하는 것도 계속 반복될 것이다.
즉, 컨트롤러와 메서드가 하나둘 늘어나면서 비즈니스 로직보다 예외 처리 코드가 더 길어지고, 똑같은 catch 블록이 여기저기 중복되는 현상이 발생한 것.
@RestControllerAdvice
Spring에는 컨트롤러 전역에서 발생하는 예외를 한 곳에서 처리하는 방식이 있다.
그것이 바로 @RestControllerAdvice 이다.
@RestControllerAdvice
public class GlobalExceptionHandler {
}
애노테이션을 통해서 쉽게 선언할 수 있는데, 처음 딱 코드를 봤을 때는 얘를 어디서 주입하고 어디서 사용해야하지?
MVC 패턴에서 컨트롤러 전과 후로 어디에 위치해야할지 감이 안잡혔었다.
약간 서비스, 컨트롤러와 같은 명시적으로 부르는 명칭이 없었기 때문이다.
근데 사실 킹갓 스프링답게 그냥 정의만 해두면 스프링 컨테이너가 알아서 예외를 가로채서 처리해준다.
우선 예외를 처리할 핸들러를 정의해보면 다음과 같다.
@ExceptionHandler(PasswordMismatchException.class)
public ResponseEntity<?> handlePasswordMismatch(PasswordMismatchException e) {
return ResponseEntity.ok(ApiResponse.fail("PASSWORD_MISMATCH", "비밀번호 불일치"));
}
이렇게만 정의해두면, 서비스나 컨트롤러 어디서든 PasswordMismatchException이 터졌을 때 Spring이 이 메서드를 찾아서 실행한다.
주입도, 호출도, 설정도 필요 없다. 그냥 예외 타입 기준으로 매칭된다.
그럼 try-catch는 이제 안 쓰는 건가?
사실 이 부분에서 꽤나 헷갈리는 내용이 많았다.
언제 어떻게 사용해야 하는건지에 대한 기준이 확립되지 않아서 더욱 그렇게 느낀 게 아닐까?
결국에는 "역할이 다르다"는 걸 명심해야 한다.
try-catch를 써야 하는 경우 (흐름 제어)
예외가 발생해도 복구가 가능하거나, 다음 로직을 이어가야 할 때 사용한다.
이건 예외 처리라기보단 프로그램의 흐름 제어(Flow Control)에 가깝다.
- 외부 API 호출 실패 시 재시도(`retry`)
- 특정 파일이 없으면 기본 파일 생성
- 현재 시점의 날씨 데이터를 못가져와서 1시간 전 데이터랃 보여주기
이처럼 에러가 났지만 내가 흐름을 제어할 수 있어서 정상적으로 흘러가게 만들 수 있다면 try-catch문을 사용하면 된다.
전역 예외 처리를 써야 하는 경우
비즈니스 규칙을 위반해서 더 이상 처리할 게 없을 때, 즉 사용자에게 명시적으로 에러를 알리려고 할 때 사용하면 된다.
if (!passwordMatches) {
throw new PasswordMismatchException();
}
이런 경우엔 컨트롤러에서 잡지 말고 그냥 던져버리면 전역 핸들러가 낚아채서 에러 응답을 만들어서 전달해준다.
이때 에러 응답을 만들 때는
공통 APIResponse를 만들어서 사용하면 된다!
아래는 이번 프로젝트에서 사용해보고자 하는 응답 형식이다.
{
"api_version": "v1",
"status": "success | fail | error",
"response_code": 200,
"error_code": "ERROR_CODE", // fail 또는 error일 때만 포함
"message": "응답 메시지",
"count": 1, // data 개수 (배열은 배열의 길이를, 객체면 1, null이면 0
"data": [] // 실제 응답 데이터 (리스트의 경우 대괄호로 묶고 그렇지 않으면 중괄호로 단일 객체 전달)
}
사실 이건 사족임
코드 전문 - GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PasswordMismatchException.class)
public ResponseEntity<?> handlePasswordMismatch(PasswordMismatchException e) {
// 로깅 등 추가 작업 가능
return ResponseEntity.ok(ApiResponse.fail("PASSWORD_MISMATCH", e.getMessage()));
}
// 다른 예외들도 여기서 추가로 정의 가능
}
- Service: 로직 수행 중 문제가 생기면 예외를 던짐(Throw)
- Controller: 정상적인 흐름만 처리. (try-catch 최소화)
- GlobalExceptionHandler: 던져진 예외를 잡아서 적절한 응답(Response)으로 변환.
처음에는 try-catch를 안 쓰는 게 뭔가 불안하고 이상하게 느껴졌는데, 역할을 명확히 나눠놓고 보니 오히려 코드가 훨씬 깔끔해졌다.
단, 모든 기술에는 트레이드 오프가 있는 법. 무엇이든 과한 건 좋지 않다...
무조건적으로 전역 처리를 맹신하면 안 되고, 복구 가능한 건 잡고(catch), 의미 있는 실패는 던져서(throw) 전역에서 처리하자는 기준을 잡는 게 핵심인 것 같다.
복습을 철저히..
지피티가 알려준 내용
네가 알고 있던 세계 (전통적인 try-catch)
@PostMapping("/login")
public ResponseEntity<?> login() {
try {
service.login();
return ResponseEntity.ok("성공");
} catch (PasswordMismatchException e) {
return ResponseEntity.badRequest().body("비밀번호 틀림");
} catch (Exception e) {
return ResponseEntity.status(500).body("서버 오류");
}
}
이 구조에서:
- ❌ 문제점
- 컨트롤러마다 try-catch 복붙
- 비즈니스 로직 + 예외 처리 섞임
- 컨트롤러가 점점 더러워짐
스프링 방식 (네가 지금 배우는 거)
@PostMapping("/login")
public ResponseEntity<?> login() {
service.login(); // 여기서 예외 터지면?
return ResponseEntity.ok(ApiResponse.success());
}
❓ 여기서 예외 터지면 어디서 잡음?
👉 스프링 컨테이너가 잡음
흐름을 정확히 그리면 이거임
Controller
↓
Service
↓
예외 발생 💥
↓
DispatcherServlet (스프링 핵심)
↓
@RestControllerAdvice 탐색
↓
@ExceptionHandler 매칭
↓
ResponseEntity 반환
즉,
try는 네가 썼고,
catch는 스프링이 대신 써준 셈
'Spring > TIL' 카테고리의 다른 글
| [Spring/TIL] @RequestParam, @ModelAttribute, @RequestBody 차이 (0) | 2026.01.11 |
|---|