[Groupware] Spring Security + Exception

2025. 6. 18. 09:46·Project/Backend

📝 개요

Groupware 프로젝트를 진행하면서 가장 고민을 많이 했던 부분 중 하나가 바로 예외처리를 하는 부분이다. 비즈니스 로직을 개발하다 보면 런타임 예외를 던져야 할 상황이 자주 발생하는데, REST API 환경에서는 “예외를 어떻게 공통적이고 일관된 방식으로 처리할 것인가?” 라는 생각을 하게 되었다.

 

그래서 고민 끝에, 모든 비즈니스 예외를 RuntimeException 기반의 CustomException 으로 통일하고, 예외별 상태코드와 메세지는 Enum(ErrorCode)에 한곳에 정의했다. 그리고 이 예외를 GlobalExceptionHandler 를 통해 전역적으로 한번에 처리하는 구조를 선택했다. 이렇게 설계하니 예외 처리 코드가 중복 없이 재사용성도 높아지고, ErrorCode 를 한 군데에서 관리하지 유지보수도 훨씬 쉬워졌다.

 

그리고 Spring Security 와 연동되는 과정에서는 인증/권한 오류도 로그가 제대로 남지 않고, 동작에 대한 원인을 유추해야되는 상황이 발생했었다. 이 문제는 SecurityFilterChain 의 exceptionHandling 설정을 커스텀 핸들러로 연결해서 해결했다.

 

다음은 프로젝트의 공통 예외처리 및 Spring Security 에서 발생하는 예외(인증/권한)를 어떻게 통합적으로 핸들링하는지 정리한 내용이다.


🚀 공통 예외 처리 (ExceptionHandler, CustomException)

1️⃣ 체크 예외 vs 언체크 예외

체크 예외는 컴파일러가 강제하는 예외이다. 예외가 발생할 수 있는 메서드는 반드시 throws 로 선언 또는 try-catch 로 잡아야 한다. 체크 예외는 “복구 가능한 상황” 에서 주로 사용한다. (파일 없음, 네트워크 오류)

 

언체크 예외는 RuntimeException 및 그 하위 예외들이다. 컴파일러가 강제하지 않고 throws 선언, try-catch 로 잡지 않아도 된다. 언체크 예외는 “복구 불가능하거나, 코드상에서 잡지 않고 전역적으로 처리하고 싶은 경우” 에 사용된다. (비즈니스 로직 오류, 잘못된 인자, 데이터 불일치)

 

체크 예외는 두 가지의 문제를 가지고 있는데 첫번째는 복구 볼가능한 예외에 대한 문제이다. 대부분의 예외는 복구가 불가능하다. 그리고 체크 예외는 예외가 발생하고 해당 레이어에서 처리하지 못하게 되면 throw 하게 되는데 예를 들어 DB 에서 발생한 SQLException 에 대한 예외 처리는 서비스나 컨트롤러 레이어에서 처리가 불가능하다.

 

두번째로는 의존관계에 대한 문제가 있다. 체크 예외는 필수적으로 처리가 되어야 하기 때문에 의존성이 생길 수 밖에 없다. 위에서 든 예시 처럼 DB와 관련되 예외가 발생한다면, 해당 코드를 가진 서비스나 컨트롤러 레이어에서 관련된 Exception 에 대한 기술 의존을 하게 된다.

 

결과적으로 체크 예외를 사용하게 되면 OOP 5대 원칙 중 OCP(Open-Close Principle), DI(Dependency Injection) 를 통해 클라이언트 코드 변경 없이 구현체를 변경할 수 있다는 Spring 프레임워크의 장점이 체크 예외 때문에 사라지게 된다.

 

그래서 서비스/컨트롤러에서 발생하는 비즈니스 예외는 언체크 예외를 기반으로 구성하게된다. 언체크 예외를 사용하면 적절한 위치에서 try-catch 없이 복잡성을 줄이고 ConrollerAdvice 와 같은 전역 핸들러에서 잡아서 처리하게된다. 이는 유지보수, 확장성, 응답 일관성에서 유리하다.


2️⃣ Spring MVC / Security 예외 처리 전체 흐름

[Spring MVC + REST API FLOW 참고]

[Client 요청]
   │
   ▼
[Filter Chain (여기에 Security Filter 포함)]
   │
   ▼
[DispatcherServlet]
   │
   ▼
[@RestController, @Service 등에서 예외 발생]
   │
   ▼
[예외 발생 시]
   │
   ├─> (Security Filter Chain에서 발생: 인증/인가 오류)
   │      → AuthenticationEntryPoint/AccessDeniedHandler에서 바로 응답
   │      → (현재 프로젝트 구조: HandlerExceptionResolver 통해 ControllerAdvice로 위임 가능)
   │
   └─> (Controller/Service/유효성 검증 등에서 발생)
             → DispatcherServlet까지 예외가 전파
             → 등록된 @RestControllerAdvice(@ExceptionHandler)에서 잡아서 응답
  1. 클라이언트가 API 호출
  2. Spirng Security FilterChain 이 먼저 요청을 가로챔
    • JWT 등 인증/권한 체크
    • 인증/인가 실패 시 기본적으로 401, 403 응답
    • 현재 프로젝트는 HandlerExceptionResolver 사용 → RestControllerAdvice 에서 예외 처리
  3. Security Filter 통과 시 DispatcherServlet 이 실제 컨트롤러로 요청 위임
  4. Controller/Service/DTO 유효성 등에서 예외 발생 → DispatcherServlet 까지 예외가 올라감
  5. @RestControllerAdvice 가 예외 가로챔
    • 등록된 ExceptionHandler 별로 처리
    • 상태코드/메세지 등 일관된 JSON 응답

3️⃣ ErrorCode (Enum)

/**
 * ErrorCode : 에러코드 (ENUM)
 */
@Getter
@RequiredArgsConstructor
public enum ErrorCode {

    // 인증 관련 오류 (Auth)
    DUPLICATED_USER_ID(HttpStatus.BAD_REQUEST, "이미 사용중인 아이디입니다."),  // 사용자 등록 시 ID 중복 에러
    INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "아이디 또는 비밀번호가 올바르지 않습니다."),  // 사용자 로그인 시 오류
    ...

    // 토큰 관련 오류 (Auth)
    MISSING_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "리프레시 토큰이 없습니다."),  // 리프레시 토큰이 없는 경우

    ... 각 도메인 별 예외 상황 생략

    // DTO 검증 관련 오류 (GlobalException)
    INVALID_DATA_REQUEST(HttpStatus.BAD_REQUEST, "요청한 데이터 형식이 올바르지 않습니다."),  // DTO Valid 예외

    // 공통
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증되지 않은 사용자입니다."),  // UNAUTHORIZED(401) : 인증되지 사용자의 않은 요청
    FORBIDDEN(HttpStatus.FORBIDDEN, "접근 권한이 없습니다."),  // FORBIDDEN(403) : 접근 권한이 없을 때
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "예상하지 못한 서버 오류입니다.");  // INTERNAL_SERVER_ERROR(500) : 예상하지 못한 서버 오류

    private final HttpStatus status;  // HTTP 상태
    private final String message;  // 에러 메세지
}

비즈니스 로직에서 발생할 수 있는 예외를 ErrorCode(Enum) 로 정의했다. 이는 HttpStatus(상태코드) 와 메세지를 한번에 관리할 수 있게 해주고, @RequriedArgsConstructor 덕분에 상수 선언 시 파라미터로 값을 쉽게 넘겨 줄 수 있다.

 

비즈니스 로직마다 달라지는 예외 메세지나 상태코드를 정의하는 것이 어찌보면 귀찮을 수 있지만, 해당 비즈니스 로직뿐만 아니라, 중복되는 예외 또한 동일한 포맷으로 재사용이 가능하다.


4️⃣ CustomException (사용자 지정 Exception : RuntimeException)

/**
 * CustomException : 사용자 정의 예외
 */
@Getter
public class CustomException extends RuntimeException {
    private final ErrorCode errorCode;  // 에러 코드

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}

RuntimeException (언체크 예외) 를 상속받아 CustomException 을 사용하는 모든 비즈니스 오류는 RuntimeException 이 되도록 한다. ErrorCode (Enum) 을 받아 정의된 예외 메세지와 HttpStatus 를 받고 CustomException 을 통해서 서비스 코드에서 받도록 한다. 이는 코드/메세지/상태코드 까지 하나의 객체로 관리할 수 있고, 컨트롤러/핸들러는 이걸 기반으로 구조적인 JSON 응답이 가능하다.


5️⃣ GlobalExceptionHandler

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // CustomException Handler
    @ExceptionHandler(CustomException.class)
    public ResponseEntity<ApiResponse<Void>> handleCustomException(CustomException e) {
        return ResponseEntity
                .status(e.getErrorCode().getStatus())
                .body(ApiResponse.error(e.getErrorCode()));
    }

    // MethodArgumentNotValidException Handler : DTO Valid Error
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Map<String, String>>> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        Map<String, String> errors = new HashMap<>();

        // 필드별 에러 추가
        for (FieldError error : e.getBindingResult().getFieldErrors()) {
            errors.put(error.getField(), error.getDefaultMessage());
        }

        return ResponseEntity
                .status(ErrorCode.INVALID_DATA_REQUEST.getStatus())
                .body(ApiResponse.error(errors, ErrorCode.INVALID_DATA_REQUEST));
    }

    // HttpMessageNotReadableException Handler : JSON parse error (enum 변환 실패 등)
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ApiResponse<Void>> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
        log.warn("[HttpMessageNotReadableException] 잘못된 요청 본문 : {}", e.getMessage());
        return ResponseEntity
                .status(ErrorCode.INVALID_DATA_REQUEST.getStatus())
                .body(ApiResponse.error(ErrorCode.INVALID_DATA_REQUEST));
    }

    // Exception Handler : 500 Error
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleException(Exception e) {
        log.error("[ERROR] : ", e);
        return ResponseEntity
                .status(ErrorCode.INTERNAL_SERVER_ERROR.getStatus())
                .body(ApiResponse.error(ErrorCode.INTERNAL_SERVER_ERROR));
    }
}

@RestControllerAdvice 를 이용한 전역 예외 처리 컴포넌트이다. 모든 @RestController, @Controller 에서 발생하는 예외를 전역에서 잡아주고 예외 발생 시 @RestControllerAdvice 에 등록된 @ExceptionHandler 에 의해서 예외를 처리하고 응답을 만들어준다.

 

현재 프로젝트에 정의된 ExceptionHanlder 는 4개이다.

  • handleCustomException : 비즈니스 예외 처리, ErrorCode의 값으로 공통 포맷에 맞춰 JSON 응답
  • handleMethodArgumentNotValidException : DTO @Valid 검증 실패 예외 처리
    → 필드별 에러 메세지 Map 으로 매핑하여 응답, 프론트엔드에서 필드별 에러 메세지 출력 가능
  • handleHttpMessageNotReadableException : 요청 본문 파싱 실패 (JSON 파싱 오류 등)
  • handleException : 위에서 걸리지 않은 모든 예외 (알 수 없는 오류), 서버 에러(500)로 통일

💥 Spring Security FilterChain ExceptionHandling

1️⃣ SecuriyConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private static final String[] SWAGGER_WHITELIST = {
            "/swagger-ui.html",
            "/swagger-ui/**",
            "/v3/api-docs/**",
            "/swagger-resources/**",
            "/webjars/**",
            "/swagger"
    };

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomAccessDeniedHandler accessDeniedHandler;
    private final CustomAuthenticationEntryPoint authenticationEntryPoint;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf((AbstractHttpConfigurer::disable))
                .formLogin(AbstractHttpConfigurer::disable)
                .logout(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(exception -> exception
                        // 인증/인가 단계에서 예외 발생 시 Handler 처리 지정
                        .accessDeniedHandler(accessDeniedHandler)
                        .authenticationEntryPoint(authenticationEntryPoint)
                )
                .authorizeHttpRequests(auth -> auth
                        // Swagger URL
                        .requestMatchers(SWAGGER_WHITELIST).permitAll()

                        // HealthCheck
                        .requestMatchers("/api/health/**").permitAll()

                        // 인증 관련 API
                        .requestMatchers("/api/auth/login", "/api/auth/logout").permitAll()
                        .requestMatchers("/api/auth/unlock/{userId}").hasAnyRole("ADMIN", "MANAGER")
                        .requestMatchers("/api/auth/refresh").authenticated()

                        ... 나머지 API 요청에 대한 설정 생략
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

SecurityConfig 의 FilterChain 에 대한 설정이다. .exceptionHandling 을 통해서 FilterChain(필터 단계) 에서 인증/인가 관련 예외 발생 시 어떤 핸들러가 처리할 지 지정한다.


2️⃣ CustomAccessDeniedHandler

@Slf4j
@Configuration
@RequiredArgsConstructor
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Qualifier("handlerExceptionResolver")
    private final HandlerExceptionResolver handlerExceptionResolver;

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        log.warn("인가 실패 : {}", accessDeniedException.getMessage());

        CustomException customException = new CustomException(ErrorCode.FORBIDDEN);

        //noinspection DataFlowIssue
        handlerExceptionResolver.resolveException(request, response, null, customException);
    }
}

CustomAccessDeniedHandler 는 AccessDeniedHandler 를 구현한다. 해당 핸들러는 인증은 통과했지만, 권한이 부족할 때 (ex : USER가 ADMIN 권한 API 에 접근) 발생하는 예외를 처리한다. Spring Security 는 여기서 403 응답을 내려주는데, 이렇게 Handler 를 통해서 지정해주지 않는 경우 필터 단에서 응답을 끝내버리므로 로그도 매끄럽지 않고 개발자가 원하는대로의 결과를 얻을 수 없다.

 

그래서 handlerExceptionResolver 를 통해서 해당 예외에 대해서 customException 을 정의하고 @RestControllerAdvice 에서 처리 되도록 일반 예외 흐름으로 변환해준다.


3️⃣ CustomAuthenticationEntryPoint

@Slf4j
@Component
@RequiredArgsConstructor
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Qualifier("handlerExceptionResolver")
    private final HandlerExceptionResolver handlerExceptionResolver;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        log.warn("인증 실패 : {}", authException.getMessage());

        CustomException customException = (CustomException) request.getAttribute("exception");

        if (customException == null) {
            customException = new CustomException(ErrorCode.UNAUTHORIZED);
        }

        //noinspection DataFlowIssue
        handlerExceptionResolver.resolveException(request, response, null, customException);
    }
}

CustomAuthenticationEntryPoint 는 “인증 자체” 가 실패할 때 의 예외를 처리한다. (ex : JWT 없음/만료/변조, 로그인 안됨) 기본적으로 Spring Security 는 여기서 401응답을 내리고 위에서 설명한 바와 같이 동작한다.


4️⃣ Lombok Config

// lombok.config (프로젝트 루트에 위치)
config.stopBubbling = true
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

// handlerExceptionResolver 사용을 위한 @Qualifier 어노테이션 사용
@Qualifier("handlerExceptionResolver")
private final HandlerExceptionResolver handlerExceptionResolver;

@Qualifier("handlerExceptionResolver")
private final HandlerExceptionResolver handlerExceptionResolver;

HandlerExceptionResolver + @Qualifier 가 필요한 이유는 Spring 에서는 HandlerExceptionResolver 타입 빈이 여러 개 존재하기 때문이다. HandlerExceptionResolver 는 인터페이스 타입으로 여러 개의 구현체 빈 (ex : DefaultHandlerExceptionResolver, ExceptionHandlerExceptionResolver 등) 을 자동 등록한다. 그리고 ExceptionHandlerExceptionResolver 구현체가 해당 Exception 을 처리한다.

 

그래서 HandlerExceptionResolver 타입으로 DI 하게 되면 여러 개의 빈이 존재하기 때문에 타입만으로는 어느 빈을 쓸지 애매해진다. 스프링은 이런 상황에서 NoUniqueBeanDefinitionException 발생 가능하다. @Qualifier 를 사용하여 handlerExceptionResolver 이름의 빈을 명확히 지정해줌으로써 해당 상황을 해결한다.

 

lombok.copyableAnnotations 를 설정해주는 이유는 @RequiredArgsConstructor 가 생성자 파라미터를 자동으로 만들어 줄 때 파라미터에 붙은 어노테이션은 기본적으로 복사하지 않기 때문이다. 따라서 @Qualifier 어노테이션이 생성자 파라미터에 복사되지 않으면 위에서 설명한 빈에 대한 지정이 없기 때문에 오류가 발생한다. 위 설정은 생성자 주입에서도 어노테이션이 생성자 파라미터까지 안전하게 복사되게 해주는 역할을 한다.


🔗 참고

 

[Java] 체크 예외(Check Exception)와 언체크 예외/런타임 예외 (Uncheck Exception, Runtime Exception)의 차이와

1. 체크 예외(Check Exception)와 언체크 예외/런타임 예외 (Uncheck Exception, Runtime Exception)의 차이 [ 예외(Exception)의 종류 ] 에러(Error) 예외(Exception) 체크 예외(Check Exception) 언체크 예외(Uncheck Exception) 에

mangkyu.tistory.com

 

[Java] 체크 예외 vs 언체크 예외

예외란 프로그램 실행 중에 발생하는, 정상 로직에서 벗어난 의도하지 않은 상황이다. 자바는 예외 상황을 처리하기 위해 예외를 객체로써 다룬다. 따라서 예외 객체는 다른 객체와 마찬가지로

velog.io

 

Java 23, SpringBoot 3.3.4: API Flow & Logging — Part 4

In the previous session (Part 3), we explored the Logback configuration, which provides precise control over logging in a Spring Boot…

faun.pub

 

'Project > Backend' 카테고리의 다른 글

[Groupware] Spring-Boot : 테스트 코드  (2) 2025.06.26
[Groupware] Spring-Boot : Spring Security + JWT  (2) 2025.06.16
[Groupware] Spring-Boot : API 공통 응답 객체  (1) 2025.05.30
[Groupware] Spring-Boot : Swagger 연동  (0) 2025.05.29
'Project/Backend' 카테고리의 다른 글
  • [Groupware] Spring-Boot : 테스트 코드
  • [Groupware] Spring-Boot : Spring Security + JWT
  • [Groupware] Spring-Boot : API 공통 응답 객체
  • [Groupware] Spring-Boot : Swagger 연동
arraysort
arraysort
arraysort 님의 블로그 입니다.
  • arraysort
    arraysort 님의 블로그
    arraysort
  • 전체
    오늘
    어제
    • 분류 전체보기 (14)
      • Study (5)
        • Java (3)
        • DataBase (1)
        • Spring-Boot (1)
        • React (0)
        • WEB (0)
      • Project (9)
        • Backend (5)
        • Frontend (2)
        • Database (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    backend
    Groupware
    SQL
    utilityclass
    DTO
    oracle
    lombok
    Database
    SQL Mapper
    java
    CDB
    mabatis
    react
    VO
    FilterChain
    TypeScript
    API
    objects.eqauls()
    Spring Security
    spring boot
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
arraysort
[Groupware] Spring Security + Exception
상단으로

티스토리툴바