전역 에러 핸들러로 try-catch를 걷어내기
문제의 시작: MVP 이후 리팩토링의 시작
기능을 하나씩 추가하며 API 엔드포인트를 늘려갈 때였다. AuthController등 컨트롤러들은여러 기능을 담고 있었고, 테스트 할때 문제 인식을 명확하게 하고자 TRY-CATCH 문으로 각 에러들을 핸들러 했다, 이제 테스트도 충분히 했고, 여러 유즈케이스의 대응이 되는거같아서 지저분한 코드 보다는 읽기 쉬운코드로 리팩토링 해보려고 한다.
처음 마주했던 의 모습:AuthController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth/v1")
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<BaseResponse<?>> signup(@RequestBody SignUpRequest req) {
try {
authService.registerUser(req);
} catch (AuthException e) {
// 여기!
return ResponseEntity.badRequest().body(BaseResponse.fail(e.getErrorCode()));
}
return ResponseEntity.ok(BaseResponse.success());
}
@PostMapping("/login")
public ResponseEntity<BaseResponse<LoginResponse>> login(@RequestBody LoginRequest req) {
try {
LoginResponse loginResponse = authService.login(req);
return ResponseEntity.ok(BaseResponse.success(loginResponse));
} catch (AuthException e) {
// 그리고 여기도!
return ResponseEntity.badRequest().body(BaseResponse.fail(e.getErrorCode()));
}
}
// ... 다른 메서드들도 마찬가지였다.
}
모든 메서드가 try로 비즈니스 로직을 감싸고, catch로 AuthException을 잡아 동일한 형태의 실패 응답을 반환했다. 코드는 정상적으로 동작은 하지만, 이제 슬슬 기능 이외의 여러문제가 신경쓰이기 시작하니, 리팩토링을 할때가 된거같다.
- "반복적인 코드는 개발자를 화나게 해요.": 새로운 API를 추가할 때마다 이
try-catch구문을 복사해서 붙여넣고 있는 나를 발견했다. - "컨트롤러의 진짜 역할이 눈에 안보인다 ": 컨트롤러는 요청을 받아 서비스에 넘겨주는 역할에 충실해야 하지 않을까? 예외를 잡아서 를 만드는 일까지 떠맡는 건 과연 옳은 걸까?
이 지저분한 코드를 그대로 둘 수는 없었다. 해결책이 필요했다.
해결의 실마리: @RestControllerAdvice와의 만남
나는 이 문제를 해결하기 위해 Spring이 제공하는 AOP(관점 지향 프로그래밍) 개념을 떠올렸고, 곧 @RestControllerAdvice라는 보석 같은 어노테이션을 발견했다. 이 어노테이션을 사용하면 프로젝트 전역에서 발생하는 예외를 한 곳에서 처리할 수 있었다. 마치 모든 예외를 감시하고 처리하는 지휘자를 두는 것과 같았다.
나는 다음과 같은 계획을 세웠다.
- 이때를 위해 모든 커스텀 예외의 공통 조상을 만들었다. ()
CustomBaseException - 예외 처리만을 전담하는 핸들러를 만든다.
GlobalExceptionHandler - 컨트롤러에서
try-catch를 모두 걷어낸다!
변화의 과정: 코드를 정화하다
1단계: 예외의 체계화
먼저 AuthException, 등 산발적으로 존재하던 예외들을 한 뿌리에서 관리하기 위해 이라는 추상 클래스를 만들고, 모든 커스텀 예외가 이 `CustomBaseException`클래스를 상속하도록 구조를 만들었었다.
2단계: 전역 예외 처리기 구현
그리고 프로젝트의 모든 예외를 처리할 를 만들었다.
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// CustomBaseException을 상속한 모든 예외는 여기서 처리된다!
@ExceptionHandler(CustomBaseException.class)
public ResponseEntity<ExceptionResponse> handleCustomBaseException(CustomBaseException ex) {
// ... 예외에 맞는 응답을 생성하는 로직 ...
return ResponseEntity
.status(ex.getErrorCode().getStatus())
.body(ExceptionResponse.from(ex.getErrorCode()));
}
}
@ExceptionHandler(CustomBaseException.class) 이 한 줄이 핵심이었다. 이제 서비스 레이어 어디에서든 AuthException이나 이 던져지면, Spring이 알아서 이 핸들러로 보내줄 것이었다.
3단계: 마침내, 정화의 시간
가장 기대했던 마지막 단계. 나는 확신을 갖고 AuthController로 돌아가 지저분했던 try-catch 블록들을 삭제하기 시작했다.
새롭게 태어난 AuthController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth/v1")
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<BaseResponse<?>> signup(@RequestBody SignUpRequest req) {
authService.registerUser(req); // 예외가 터지면? 알아서 처리될 것이다!
return ResponseEntity.ok(BaseResponse.success());
}
@PostMapping("/login")
public ResponseEntity<BaseResponse<LoginResponse>> login(@RequestBody LoginRequest req) {
LoginResponse loginResponse = authService.login(req); // 그냥 비즈니스 로직에만 집중
return ResponseEntity.ok(BaseResponse.success(loginResponse));
}
}
코드가 놀랍도록 깔끔해졌다. 컨트롤러는 이제 정말 '컨트롤러'의 역할에만 충실하게 되었다. 더 이상 예외 처리를 걱정하지 않아도 됐다.
회고를 마치며
이 리팩토링 과정을 통해 나는 단순히 코드 몇 줄을 줄인 것 이상의 것을 얻었다.
- 명확해진 책임: 컨트롤러는 요청과 응답을, 서비스는 비즈니스 로직을, 핸들러는 예외 처리를 담당하게 되었다. 각자의 역할이 명확해지니 코드 전체를 이해하기가 훨씬 쉬워졌다.
- 높아진 유지보수성: 이제 예외 응답 정책이 바뀌어도 단 한 곳만 수정하면 된다.
GlobalExceptionHandler - 개발의 즐거움: 무엇보다 깨끗하고 일관성 있는 코드를 작성하게 되면서 개발 과정 자체가 더 즐거워졌다.
작은 불편함에서 시작된 고민이었지만, 그 끝에는 프로젝트의 건강함을 되찾는 값진 경험이 있었다. 앞으로도 나는 내 코드에 계속 질문을 던지며 더 나은 구조를 찾아 나서는 여정을 멈추지 않을 것이다.