Backend 27

GlobalExceptionHandler: Controller의 try-catch를 넘어선 전역 예외 처리

예외가 나는 Controller에서 바로 try-catch 하면 되는데 왜 전역 핸들러가 필요하지?

근거 · 교안 p173-p180

GlobalExceptionHandler: Controller의 try-catch를 넘어선 전역 예외 처리 대표 이미지

1장: 내 try-catch는 왜 ‘충분’하지 않을까?

솔라는 만족스러운 미소를 지으며 자신의 코드를 바라보았다. 모니터에는 BookController.java 파일이 열려 있었다. 사용자가 존재하지 않는 책의 ID로 요청을 보냈을 때의 처리를 막 끝낸 참이었다.

@GetMapping("/books/{id}")
public ResponseEntity<?> findBookById(@PathVariable Long id) {
    try {
        Book book = bookService.findBook(id);
        return ResponseEntity.ok(book);
    } catch (BookNotFoundException e) {
        // 책을 찾지 못했을 때의 처리
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body("해당 ID의 책을 찾을 수 없습니다.");
    }
}

“언니, 이것 봐. BookController에 예외 처리 추가했어.”

옆에서 조용히 자신의 노트북을 보던 루나가 고개를 돌렸다.

“이제 없는 책을 찾으려고 하면, 서버가 터지는 500 에러 대신 ‘책을 찾을 수 없습니다’라는 친절한 메시지를 딱 보여줘. try-catch로 해결하니까 간단하고 좋네. 다들 왜 그렇게 전역 예외 처리, GlobalExceptionHandler 같은 거창한 걸 쓰는지 모르겠어. 그냥 예외가 생기는 곳에서 바로 잡으면 되는데.”

솔라의 말에는 자신의 방식에 대한 명쾌한 확신이 담겨 있었다. 루나는 솔라의 모니터를 잠시 들여다보더니, 고개를 끄덕였다.

“그렇네. BookController에서는 잘 작동하겠다.”

루나는 잠시 말을 멈췄다. 칭찬도, 지적도 아니었다. 그저 사실을 확인하는 목소리였다.

“그런데 우리 애플리케이션에는 ‘작가’를 관리하는 AuthorController도 있잖아. 거기서 존재하지 않는 작가를 조회하면 어떻게 될까?”

“그거야 간단하지!”

솔라는 자신 있게 키보드를 두드렸다. AuthorController.java 파일을 열고, 비슷한 구조의 findAuthorById 메서드를 찾아냈다. 그리고는 망설임 없이 BookController에 있던 try-catch 블록을 복사했다. 붙여넣기를 한 뒤, BookNotFoundExceptionAuthorNotFoundException으로, 에러 메시지의 ‘책’을 ‘작가’로 바꾸는 몇 번의 타이핑으로 작업을 마무리했다.

@GetMapping("/authors/{id}")
public ResponseEntity<?> findAuthorById(@PathVariable Long id) {
    try {
        Author author = authorService.findAuthor(id);
        return ResponseEntity.ok(author);
    } catch (AuthorNotFoundException e) {
        // 작가를 찾지 못했을 때의 처리
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body("해당 ID의 작가를 찾을 수 없습니다.");
    }
}

“봐, 금방 끝났지? 이제 작가를 못 찾아도 똑같이 친절하게 응답할 거야. 만약 ‘출판사’ 컨트롤러가 새로 생겨도 똑같이 해주면 돼.”

“잠깐만.”

코드를 보며 뿌듯해하는 솔라를 루나가 조용히 멈춰 세웠다.

“방금 복사한 그 catch 블록 안의 로직 말이야. BookController에 있던 거랑 거의 똑같지 않아?”

솔라는 잠시 두 파일을 번갈아 쳐다봤다. BookController.javaAuthorController.java. 예외 클래스의 이름과 메시지의 단어 하나만 빼면, ResponseEntity를 만들고 상태 코드를 지정하고 본문을 담는 구조는 판에 박은 듯 똑같았다.

“응, 비슷하네. 예외 종류만 다르고, 결국 ‘데이터를 못 찾았으니 404 에러와 메시지를 보낸다’는 건 똑같으니까.”

솔라는 아직 무엇이 문제인지 감을 잡지 못한 표정이었다. 루나는 새로운 가정을 던졌다.

“만약 클라이언트 개발자랑 논의해서, 응답 형식을 바꾸기로 했다면 어떨까? 단순히 문자열 메시지만 보내는 게 아니라, 모든 ‘데이터 없음’ 오류에 대해 HTTP 상태 코드는 그대로 404 Not Found를 보내되, 응답 본문은 {'errorCode': 'RESOURCE_NOT_FOUND', 'message': '...'} 같은 일관된 JSON 형식으로 통일하기로 했다고 해봐.”

솔라의 손가락이 순간 멈칫했다. 그녀의 시선이 다시 두 개의 컨트롤러 파일을 향했다. BookControllerAuthorController. 머릿속으로 앞으로 해야 할 작업을 그려보는 듯했다.

“아…”

짧은 탄식과 함께 솔라의 표정이 굳어졌다.

”…그럼 BookController에 있는 catch 블록도 고치고, AuthorController에 있는 catch 블록도 똑같이 고쳐야 하네. PublisherController가 있었으면 거기도 가야 하고, 나중에 OrderController가 생기면 거기도…”

솔라의 목소리가 점점 작아졌다.

“컨트롤러가 수십 개라면… 그 수십 개 파일을 전부 찾아서 하나하나 수정해야 하는 거잖아. 만약 하나라도 빼먹으면, 어떤 API는 새로운 JSON 형식으로 응답하고, 어떤 건 예전처럼 그냥 문자열만 휙 던져주게 되겠네. 클라이언트 쪽에서는 어느 장단에 맞춰야 할지 엄청 헷갈리겠다.”

방금 전까지 명쾌하고 충분해 보였던 try-catch 블록들이 이제는 애플리케이션 곳곳에 숨겨진 시한폭탄처럼 보이기 시작했다. 일관성을 깨뜨리고, 유지보수를 악몽으로 만들 잠재적인 위협이었다.

솔라는 의자에 등을 기댔다. “알겠다. 왜 내 try-catch가 ‘충분’하지 않은 건지. 이건 확장성이 전혀 없는 방식이었어.”

개별적인 처리가 가져오는 중복과 비일관성의 문제를 두 눈으로 확인한 순간이었다.

“그럼 이 반복 작업을 어떻게 해결해야 해? 이 지긋지긋한 복사-붙여넣기 없이, 한 곳에서만 예외 응답 규칙을 정하면 모든 컨트롤러에 쫙 적용되게 할 수는 없는 거야?“

2장: 예외, 이제 한곳에서 모아 처리하는 법: @RestControllerAdvice와 @ExceptionHandler

솔라의 마지막 질문이 허공에 맴돌았다. “한 곳에서만 예외 응답 규칙을 정하면 모든 컨트롤러에 쫙 적용되게 할 수는 없는 거야?” 고민에 잠긴 솔라의 시선은 여전히 BookControllerAuthorController를 오가고 있었다. 두 파일에 똑같이 복사되어 붙여넣어진 catch 블록이 마치 코드에 남은 흉터처럼 보였다.

루나는 말없이 새로운 파일을 하나 생성했다. 솔라의 모니터 옆에 나란히 놓인 루나의 화면에 GlobalExceptionHandler.java라는 이름의 파일과 텅 빈 클래스 블록이 나타났다.

public class GlobalExceptionHandler {

}

이름부터 ‘전역 예외 처리기’라니. 솔라가 찾던 바로 그 ‘한 곳’처럼 보였다. 솔라는 몸을 돌려 루나의 화면에 집중했다.

“이 평범해 보이는 클래스가 어떻게 수십 개 컨트롤러의 예외를 전부 떠맡는다는 거야? 그냥 여기에다 catch 로직을 다 모아두기만 하면 약속이라도 한 것처럼 알아서 실행되는 건 아닐 테고.”

“그냥은 아니지.”

루나가 클래스 선언부 바로 위에 한 줄의 코드를 추가했다.

@RestControllerAdvice
public class GlobalExceptionHandler {

}

“이 어노테이션, @RestControllerAdvice가 이 클래스의 정체성을 바꿔버려. 이제 GlobalExceptionHandler는 그냥 평범한 클래스가 아니라, 우리 애플리케이션의 모든 @RestController를 지켜보는 특별한 조언자(Advisor)가 되는 거야.”

“조언자?”

“응. 모든 컨트롤러들을 멀리서 지켜보다가, 누구든 처리하지 못하고 밖으로 던져버리는 예외가 있으면 자기가 대신 가로채서 처리해주는 역할이지. 컨트롤러에 일일이 try-catch를 설치하는 대신, 예외 처리 전문가를 한 명 고용해서 애플리케이션 전체를 맡기는 것과 비슷해.”

솔라는 고개를 끄덕였다. 개념은 이해가 됐다. “좋아, 그럼 이제 저 조언자한테 일을 시켜야겠네. BookNotFoundException이 발생하면 어떻게 처리할지 알려줘야 하잖아. 그건 어떻게 해?”

“그때 필요한 게 @ExceptionHandler야.”

루나는 GlobalExceptionHandler 클래스 내부에 새로운 메서드를 작성하기 시작했다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<String> handleBookNotFoundException(BookNotFoundException e) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body(e.getMessage());
    }
}

@ExceptionHandler 어노테이션에 우리가 처리하고 싶은 예외 클래스를 지정해주면 돼. 그럼 @RestControllerAdvice 조언자는 BookNotFoundException이 발생했을 때, 정확히 이 메서드를 호출해서 응답을 만들게 할 거야. 메서드 내용은 아까 솔라 네가 컨트롤러 catch 블록에 짰던 거랑 똑같지?”

솔라는 코드를 잠시 들여다봤다. 확실히 ResponseEntity를 만들고 상태 코드와 메시지를 담는 로직은 완전히 동일했다. 하지만 무언가 개운하지 않은 표정이었다.

“결국 똑같은 코드를 컨트롤러에서 여기로 옮겨온 것뿐이잖아. 위치만 바뀐 거지, 달라진 게 없는 것 같은데.”

루나는 대답 대신 솔라의 BookController.java 파일을 가리켰다.

“정말 그런지 확인해볼까? BookController로 돌아가서 findBookById 메서드의 try-catch를 전부 지워봐.”

“어? 그걸 지우면 예외 처리 코드가 아예 없어지는 거잖아. 그럼 서버 터지는 500 에러가 다시 뜨겠지.”

“일단 해봐. 그리고 BookService가 예외를 던질 수 있도록, 메서드 시그니처에 throws BookNotFoundException도 지워버려. 컨트롤러는 이제 예외를 직접 처리하지 않고, 그냥 발생하면 밖으로 던지기만 하는 거야.”

솔라는 반신반의하며 키보드에 손을 올렸다. trycatch로 감싸여 있던 코드를 조심스럽게 풀어헤쳤다. 지저분했던 try-catch 블록이 사라지자, 메서드는 놀랍도록 간결해졌다.

// 수정 전
@GetMapping("/books/{id}")
public ResponseEntity<?> findBookById(@PathVariable Long id) {
    try {
        Book book = bookService.findBook(id);
        return ResponseEntity.ok(book);
    } catch (BookNotFoundException e) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND)
                .body("해당 ID의 책을 찾을 수 없습니다.");
    }
}

// 수정 후
@GetMapping("/books/{id}")
public ResponseEntity<Book> findBookById(@PathVariable Long id) {
    Book book = bookService.findBook(id);
    return ResponseEntity.ok(book);
}

이제 이 메서드에는 성공 로직만 남았다. 예외에 대한 대비책은 어디에도 보이지 않았다. 불안한 마음으로 솔라는 애플리케이션을 다시 실행하고, 존재하지 않는 책 ID로 API를 호출했다.

결과는 놀라웠다. 터질 것이라 예상했던 500 에러 대신, 이전에 try-catch에서 직접 만들었던 것과 똑같은 404 Not Found 응답과 ‘해당 ID의 책을 찾을 수 없습니다.’ 라는 메시지가 화면에 나타났다.

“어떻게…?”

솔라의 눈이 커졌다. BookController에는 분명 예외 처리 코드가 한 줄도 없는데, 어떻게 예외가 제대로 처리된 걸까. 그녀의 시선이 GlobalExceptionHandler.java 파일로 향했다.

“아!”

탄성과 함께 모든 조각이 맞춰졌다.

BookController에서 BookNotFoundException이 발생했는데, 처리해 줄 catch 블록이 없으니까 예외는 밖으로 던져졌어. 그랬더니 @RestControllerAdvice 조언자가 그걸 발견하고, @ExceptionHandler(BookNotFoundException.class)가 붙은 메서드를 찾아서 실행시킨 거구나! 컨트롤러는 이제 자기가 할 일만 깔끔하게 하면 되고, 골치 아픈 예외 처리는 전부 저 조언자한테 위임해버린 거네.”

방금 전까지 ‘위치만 바뀐 것’이라 생각했던 코드가 전혀 다른 차원의 개념으로 다가왔다. 이것은 단순한 코드 이동이 아니었다. 각자의 역할과 책임을 명확하게 분리하는 구조의 변화였다. 컨트롤러는 요청을 받고 핵심 로직을 수행하는 책임만, GlobalExceptionHandler는 애플리케이션 전반의 예외를 일관되게 처리하는 책임만 맡게 된 것이다.

“맞아. 이제 AuthorController에 있는 try-catch도 똑같이 지우고, GlobalExceptionHandlerAuthorNotFoundException 처리 메서드만 추가하면 돼. 그러면 앞으로 어떤 컨트롤러가 추가되든, ‘데이터를 찾지 못하는’ 종류의 예외 처리는 모두 이 파일 한 곳에서 관리할 수 있게 되지.”

솔라는 흥분하며 말했다. “그러면 유지보수도 엄청 편해지겠네! 아까처럼 응답 형식을 JSON으로 바꾸고 싶으면, 이제 이 파일에 있는 메서드 하나만 고치면 모든 ‘Not Found’ 에러에 적용되는 거잖아!”

그녀는 신나게 AuthorNotFoundException을 처리하는 핸들러를 추가하려다가 문득 손을 멈췄다.

@ExceptionHandler(AuthorNotFoundException.class)
public ResponseEntity<String> handleAuthorNotFoundException(AuthorNotFoundException e) {
    return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(e.getMessage());
}

“잠깐. 이것도 결국 handleBookNotFoundException 메서드랑 내용이 거의 똑같은데? 예외 클래스 이름만 다르지, 404 상태 코드랑 메시지를 담아 보내는 건 똑같잖아. 여기서도 중복이 생기네.”

중앙 처리소를 만들었더니, 이제는 그 처리소 안에 새로운 형태의 중복이 고개를 들고 있었다. 게다가 ‘사용자가 잘못된 형식의 데이터를 보냈을 때(Validation Exception)’처럼 완전히 성격이 다른 예외는 어떻게 처리해야 할까? 지금처럼 단순히 문자열 메시지만 보내는 게 맞을까?

“한 곳에 모으는 데는 성공했는데, 여기서 어떻게 ‘일관된’ 응답을 만들어내지? 예외 종류마다 조금씩 다른데, 이걸 세련되게 처리할 방법은 없을까?”

3장: 일관된 응답으로 소통하는 기술: 예외를 HTTP 상태 코드로 변환하기

솔라의 모니터에는 GlobalExceptionHandler.java 파일이 열려 있었다. 중앙 예외 처리소라는 개념에 흥분했던 것도 잠시, 그녀의 눈앞에는 새로운 형태의 곤란함이 펼쳐져 있었다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<String> handleBookNotFoundException(BookNotFoundException e) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND) // 404 Not Found
                .body(e.getMessage());
    }

    @ExceptionHandler(AuthorNotFoundException.class)
    public ResponseEntity<String> handleAuthorNotFoundException(AuthorNotFoundException e) {
        return ResponseEntity
                .status(HttpStatus.NOT_FOUND) // 404 Not Found
                .body(e.getMessage());
    }
}

두 개의 메서드는 예외 클래스의 이름만 다를 뿐, 로직은 완전히 똑같았다. 컨트롤러에서 보았던 코드 중복이 장소만 옮겨 그대로 재현되고 있었다. 한 곳으로 모으는 데는 성공했지만, 이 안에서 어떻게 일관성을 유지하고 세련되게 처리할지에 대한 답은 보이지 않았다.

“결국 여기서도 똑같네. PublisherNotFoundException, OrderNotFoundException… ‘~를 찾을 수 없음’ 예외가 추가될 때마다 이 비슷한 메서드를 계속 복사해서 만들어야 하는 거잖아. 그리고 응답도 그냥 문자열 메시지 하나만 툭 던져주는 게 최선일까? 사용자에게 어떤 모습으로 돌아가는지 생각해보면, 뭔가 부족한 것 같은데.”

솔라의 질문에 루나는 솔라의 화면을 가만히 들여다보다가, 새로운 질문을 던졌다.

“그럼 이건 어때? 우리 애플리케이션에 책을 새로 등록하는 API가 있고, 사용자가 책 제목을 비워둔 채로 요청을 보냈다고 해보자. 이것도 ‘찾을 수 없음(Not Found)’ 오류일까?”

“아니, 그건 ‘찾을 수 없는’ 게 아니라 사용자가 요청을 ‘잘못 보낸’ 거지.” 솔라가 즉시 대답했다.

“맞아. 오류의 성격이 완전히 다르지.” 루나는 솔라가 BookController에 새로운 메서드를 추가하도록 유도했다. 책 정보를 담은 BookCreateRequest 객체를 받아 저장하는 간단한 POST API였다. 루나의 조언에 따라 솔라는 요청 객체의 title 필드 위에 @NotBlank라는 어노테이션을 추가했다.

// BookCreateRequest.java
public class BookCreateRequest {
    @NotBlank(message = "제목은 비어 있을 수 없습니다.")
    private String title;
    // ... getters and setters
}

// BookController.java
@PostMapping("/books")
public ResponseEntity<Void> createBook(@RequestBody @Valid BookCreateRequest request) {
    bookService.create(request);
    return ResponseEntity.status(HttpStatus.CREATED).build();
}

솔라는 애플리케이션을 실행하고, 의도적으로 title을 빈 값으로 하여 API를 호출했다. 예상대로 서버 로그에는 MethodArgumentNotValidException이라는 낯선 이름의 예외가 찍혔고, 클라이언트에게는 거대한 HTML 오류 페이지와 함께 500 Internal Server Error가 반환되었다.

“이것도 예외니까 GlobalExceptionHandler에 추가하면 되겠네.”

솔라는 익숙하게 handleAuthorNotFoundException 메서드를 복사해서 새로운 핸들러를 만들기 시작했다.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<String> handleValidationException(MethodArgumentNotValidException e) {
    // ???
    return ResponseEntity
            .status(HttpStatus.NOT_FOUND) // 일단 404로...?
            .body("입력값이 올바르지 않습니다.");
}

코드를 작성하던 솔라의 손가락이 멈칫했다. “상태 코드는 뭘로 하지? ‘찾을 수 없음’은 아니니까 404는 이상한데. 그리고 메시지도 그냥 ‘입력값이 올바르지 않다’라고만 하면, 클라이언트 쪽에서는 대체 어느 필드가 잘못된 건지 알 수가 없잖아.”

“바로 그거야.”

루나가 말했다. “클라이언트 개발자 입장에서 생각해봐. 404 응답을 받으면 ‘아, 내가 요청한 리소스의 ID가 없구나. 다른 ID로 시도해봐야겠다’고 생각하겠지. 하지만 방금 그 오류는 ID의 문제가 아니라, 클라이언트가 보낸 데이터 자체가 유효하지 않다는 뜻이야. 서버는 ‘네가 보낸 요청에 문제가 있으니, 고쳐서 다시 보내’라는 명확한 신호를 줘야 해.”

루나는 HttpStatus 목록에서 BAD_REQUEST를 가리켰다. 상태 코드 400. ‘잘못된 요청’.

솔라는 고개를 끄덕였다. “아, HTTP 상태 코드가 그냥 숫자가 아니라, 클라이언트와 서버가 소통하는 약속 같은 거구나. ‘찾을 수 없음’이라는 메시지보다 ‘404’라는 코드가, ‘잘못된 요청’이라는 메시지보다 ‘400’이라는 코드가 훨씬 더 정확한 정보였어.”

깨달음과 함께 솔라는 코드를 수정하기 시작했다. 단순히 상태 코드를 바꾸는 것에서 그치지 않았다. MethodArgumentNotValidException 객체가 어떤 필드가 왜 잘못되었는지에 대한 상세한 정보를 모두 담고 있다는 것을 발견했다. 그녀는 그 정보를 추출해 훨씬 더 유용한 응답을 만들었다.

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST) // 상태 코드를 400으로 지정
public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
    Map<String, String> errors = new HashMap<>();
    ex.getBindingResult().getAllErrors().forEach(error -> {
        String fieldName = ((FieldError) error).getField();
        String errorMessage = error.getDefaultMessage();
        errors.put(fieldName, errorMessage);
    });
    return errors;
}

다시 API를 호출하자, 결과는 완전히 달라졌다.

상태 코드: 400 Bad Request

{
  "title": "제목은 비어 있을 수 없습니다."
}

단순한 문자열 대신, 어떤 필드가(title) 어떤 이유로(제목은 비어 있을 수 없습니다.) 잘못되었는지 명확히 알려주는 JSON 객체가 반환되었다. 클라이언트는 이 응답만 보고도 무엇을 수정해야 할지 즉시 알 수 있었다.

솔라는 자신의 GlobalExceptionHandler.java 파일을 다시 바라봤다. 이제 거기에는 두 종류의 핸들러가 나란히 있었다.

  • BookNotFoundException -> 404 Not Found + 간단한 메시지
  • MethodArgumentNotValidException -> 400 Bad Request + 상세한 오류 필드 정보

그녀는 환하게 웃었다.

“알겠다. GlobalExceptionHandler는 그냥 예외를 한곳에 모으는 통이 아니었어. 애플리케이션 내부에서 발생하는 온갖 종류의 예외라는 ‘혼돈’을, 클라이언트가 이해할 수 있는 HTTP라는 ‘질서’로 변환해주는 번역기였구나!”

중복과 비일관성은 기술을 잘못 사용해서가 아니라, 그 목적을 제대로 이해하지 못했기 때문에 발생한 문제였다. 예외의 종류에 따라 적절한 상태 코드와 일관된 형식의 응답을 제공하는 것. 그것이 전역 예외 처리의 핵심이었다.

솔라는 자신감이 붙은 얼굴로 키보드에 손을 올렸다.

“좋아. 그럼 아까 그 BookNotFoundExceptionAuthorNotFoundException의 중복 문제는, 둘 다 ‘리소스를 찾을 수 없다’는 공통점이 있으니 ResourceNotFoundException이라는 부모 예외를 만들고, 그걸 처리하는 핸들러 하나만 두면 되겠네. 그 핸들러는 항상 404 상태 코드와 예외 메시지를 담은 일관된 JSON을 반환하도록 하고.”

그녀는 혼잣말처럼 말을 이었다. “만약 나중에 권한 없는 사용자가 중요한 데이터에 접근하려 할 때 터지는 AccessDeniedException이 생긴다면? 그건 ‘요청이 잘못되거나(400) 리소스가 없는(404)’ 것과는 또 다른 문제니까… 그래, ‘금지됨’을 뜻하는 403 Forbidden 상태 코드를 반환하는 핸들러를 새로 추가하면 되는 거야.”

솔라는 더 이상 예외 처리를 귀찮은 방어 코드로 보지 않았다. 그것은 애플리케이션의 안정성을 높이고, 클라이언트와 명확하게 소통하는 세련된 기술이었다. 그녀는 자신의 첫 try-catch 블록이 얼마나 단편적인 생각이었는지 떠올리며 미소 지었다. 이제 그녀는 애플리케이션 전체를 조망하며 예외 처리 전략을 설계할 수 있게 되었다.