ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Study] 토비의 스프링 3주차 스터디 회고: 예외를 던지는 이유를 이해
    Spring 2025. 11. 18. 16:57

    3주차 스프링 스터디에서 토비의 스프링 4장 예외를 공부하면서, 기존에 예외(Exception)라고 생각했던 개념이 얼마나 얕았는지 깨닫게 되었다. 처음에는 그냥 try-catch 정도로 생각했지만, 책과 실무 경험을 함께 고민하면서

    왜 스프링은 거의 모든 예외를 언체크로 통일하려 하는지,
    왜 입력값 오류조차 언체크인지,
    왜 필터에서 발생하는 예외는 @RestControllerAdvice가 받지 못하는지 같은 내용들을 깊이 이해하게 되었다.

    이건 단순히 예외 문법을 공부한 게 아니라, 예외가 설계에 미치는 영향까지 이해한 시간이었다.

     

     


     

    흔한 예외 처리

    try {
        ...
    } catch (SQLException e) {
        e.printStackTrace();
    }

    이 코드는 개발자가 가장 많이 쓰는 예외 처리이다. 겉으로는 예외를 처리한 것 같지만, 사실상 아무 일도 하지 않는다.
    운영 환경에서는 이런 예외는 로그 속에 묻혀버리고, 실제 문제는 아무도 모르게 누적된다.

    스프링의 철학은 명확했다.

    예외는 반드시 복구되거나, 아니면 명확히 전달되어야 한다.

    삼키는(catch 후 아무 일도 하지 않는)예외는 최악이다.

     

     


    자바 예외 구조를 다시 이해

    자바에서 예외는 크게 세 가지 계층으로 나뉜다.

    Error 애플리케이션이 복구할 수 없는 시스템 레벨 오류
    Checked Exception 컴파일러가 반드시 처리(try/catch 또는 throws)하도록 강제
    Unchecked Exception (= RuntimeException) 개발자의 실수/로직 오류, 복구 불가능한 예외

    그동안 나는 복구 가능 → checked, 복구 불가능 → unchecked로만 알고 있었다. (여기서 복구 가능은 외부에서 복구할 수 있는지이다)
    하지만 스프링을 공부하며 확실히 느낀 건, 자바의 체크 예외 모델이 실제 비즈니스와 기술 구조에 완전히 들어맞지는 않는다는 사실이다.

     

    예를 들어 SQLException은 checked 예외인데, 실무에서는 거의 복구 불가능하다.

    DB 연결 실패를 서비스 코드에서 어떻게 복구한단 말인가? 이 모순이 스프링에서 예외 전환(exception translation)이라는 개념이 필요해진 이유였다.

     

    체크 예외를 던지면 인터페이스가 망가진다

    스터디 중 내가 질문했던 것 중 하나가 이것이었다.

    DAO 인터페이스에 throws SQLException을 적으면 뭐가 문제인가?

    문제는 DAO 인터페이스가 특정 기술에 종속된다는 것이다.

     
    public interface UserDao {
        void add(User user) throws SQLException;
    }

    이 코드만 보면 이 DAO는 JDBC 기반이구나하고 바로 알 수 있다.
    인터페이스인데 구현 기술이 드러난다.

     

    스프링의 철학은

    "인터페이스는 기술을 감추고 계약만 드러내야 한다"이다

    그래서 체크 예외를 런타임 예외로 바꿔버리는 것이 필수적인 방향이 된다.

     

    예외 처리 전략: 복구, 회피, 전환

    예외를 처리하는 방법은 세 가지로 요약할 수 있었다.

     

    1) 예외 복구

    다른 흐름으로 자연스럽게 유도함.

     

    2) 예외 회피

    상위 계층으로 던진다(throws), 하지만 의도가 분명해야 한다.

     

    3) 예외 전환

    더 의미 있는 예외로 바꾸거나(비즈니스 의미), checked → unchecked로 바꾼다.

     

    특히 JWT 토큰 검증 과정에서 예외 전환의 의미가 확 와닿았다.

    catch (ExpiredJwtException e) {
        throw new BusinessException(TOKEN_EXPIRED, e);
    }

    JWT 예외를 그대로 던지면 프론트엔드는 뭐가 문제인지 모른다.
    하지만 의미를 가진 RuntimeException인 BusinessException으로 바꾸면 토큰 만료라는 상황을 명확하게 알리고, 이를 HTTP 401로 해석해 프론트엔드에게 전달할 수 있다.

     

     


    질문 1: "입력값 오류는 외부에서 복구 가능한데, 왜 언체크인가?"

    스터디 중 내가 던진 질문 중 가장 중요한 질문이었다.

    질문

    • 입력이 잘못된 건 사용자(UI)가 고치면 되는 것 아닌가?
    • 그럼 복구 가능한 상황이니까 Checked Exception이어야 하지 않나?

    답을 정리하자면:

    입력값 오류는 예외 처리로 복구하는 것이 아니라 검증(validation) 단계에서 걸러내는 것이기 때문이다.

     

    즉, try/catch로 해결하는 것이 아니라 컨트롤러 계층에서 @Valid, @NotNull, @RequestParam 타입 변환 등으로 애초에 잘못된 입력이 도달하지 않도록 한다.

     

    그렇기 때문에 입력 오류는 RuntimeException 계열로 분류된다. 예시로 많이 등장하는 IllegalArgumentException도 언체크이다.

    입력 오류는

    • 예외 복구 대상이 아니라
    • 검증 실패로 처리되는 도메인 규칙 위반이다.

    그래서 전부 Unchecked로 설계된다.

     

     

    질문 2: "MethodArgumentNotValidException은 Checked 같은데 왜 언체크처럼 동작하나?"

    이건 진짜 헷갈릴 수밖에 없다. MethodArgumentNotValidException 상속 구조는 이렇게 생겼다:

    Exception → BindException → MethodArgumentNotValidException

     

    즉, 형식상 Checked Exception이다.

    하지만 스프링 MVC는 이 예외를 개발자가 직접 throws 하는 구조로 쓰지 않고, DispatcherServlet 내부에서 자동 처리한다.

     

    실제 흐름

     → HandlerMethodArgumentResolver가 바인딩 수행
     → @Valid 검증 수행
     → 검증 실패 → MethodArgumentNotValidException 발생
     → DispatcherServlet이 내부에서 catch
     → ExceptionResolver로 전달
     → @RestControllerAdvice @ExceptionHandler에 매핑
     → HTTP 400 응답 생성

    형식은 checked, 동작은 unchecked

     

    개발자는 try/catch나 throws를 전혀 사용할 필요가 없고, 스프링이 모든 처리를 자동으로 해버린다.

    이 예외를 checked로 만드는 건 Java 설계의 유산이지만, 스프링 MVC는 이를 런타임 예외 흐름처럼 전환해서 처리하고 있다.

     

    질문 3: "필터에서 발생한 예외는 왜 ControllerAdvice가 못 잡나?"

    이것도 스터디에서 내가 던졌던 질문이다. 스프링 MVC 요청 흐름은 다음과 같다.

    [Filter](DispatcherServlet 바깥) 
        ↓
    [DispatcherServlet] (MVC 시작)
        ↓
    [Interceptor]
        ↓
    [Controller]
     

    @RestControllerAdvice는 DispatcherServlet 내부에서만 동작한다. 그러나 Filter는 DispatcherServlet 앞단에서 동작한다.

    그래서 Filter에서 예외를 던지면 ControllerAdvice까지 도달하지 못한다.

     

    필터에서 예외 처리하는 방법

    • 직접 try/catch하고 JSON 응답을 만들어서 내려보내야 한다.
    • Spring Security 환경에서는 AuthenticationEntryPoint, AccessDeniedHandler를 사용한다.

    이 부분은 실무 JWT 필터를 구현할 때 진짜 중요한 내용이었다.

     


     

    스프링이 왜 런타임 예외 중심 전략을 선택했는가

    이유 설명
    체크 예외는 throws를 남발하여 인터페이스를 오염시킴 DAO에 throws SQLException → 기술 종속
    대부분의 기술 예외는 복구 불가능 DB 연결 실패, 네트워크 실패 등
    스프링 트랜잭션은 RuntimeException에 반응함 checked는 롤백 안됨
    예외 전환을 통한 의미 제공이 더 중요 단순 SQLException → DuplicateUserIdException
    ControllerAdvice가 런타임 예외 중심으로 설계됨 전역 에러 핸들러는 언체크 중심

    즉,

    체크 예외는 실무에서는 오히려 좋지 않기 때문에 스프링은 예외를 런타임 중심 구조로 단순화했다.

     

    DataAccessException: 기술 독립적인 예외 추상화

    DB마다 예외가 다르다:

    • MySQL: MySQLIntegrityConstraintViolationException
    • Oracle: ORA-00001
    • JPA(Hibernate): ConstraintViolationException
    • JDBC: SQLException

    이걸 서비스 계층에서 모두 처리하라고 하면 현실적으로 불가능하다. 그래서 스프링은 이들을 모두 DataAccessException 계층 구조로 감싼다.

     

    예: 

    DataIntegrityViolationException
    DuplicateKeyException
    BadSqlGrammarException

     

    그러면 서비스 계층은 이렇게만 작성하면 된다:

    try {
        userDao.add(user)
    } catch (DataIntegrityViolationException e) {
        throw new BusinessException(USER_ALREADY_EXISTS);
    }

    DB 벤더를 바꿔도 서비스 계층은 하나도 수정할 필요가 없다.

     


     

    마무리

    이번 4장을 공부하면서 예외를 단순히 문제 발생 시 쓰는 도구가 아니라 시스템의 의도를 나타내는 설계 요소라는 걸 정확히 이해하게 되었다.

    • 복구 가능한 예외만 체크 예외로 사용한다
    • 복구 불가능하거나 기술 예외는 RuntimeException으로 전환한다
    • 입력 검증 실패는 예외가 아니라 도메인 규칙 위반이다
    • 필터에서 발생한 예외는 스프링 바깥에서 직접 처리해야 한다
    • DB 예외는 DataAccessException으로 통일해 기술 독립성을 확보한다
    • 의미 없는 throws Exception은 제거하고, 도메인 예외로 전환한다

    이제는 "예외를 볼 때 이걸 어떻게 잡지?"가 아니라 "이 예외가 어떤 의도를 드러내야 하지?"라는 관점으로 보게 되었다.

    그리고 스프링이 왜 런타임 예외 중심으로 설계되었는지 체크/언체크의 본질과, 요청 처리 흐름과, 예외 전환 철학을 이해한 한 뒤 완전히 이해할 수 있었다. 

     

     

     

     

     

Designed by Tistory.