[Springboot] CustomException 처리

홍현기·2025년 3월 31일
0

앞서

API 개발에서 예외 처리는 클라이언트에게 발생한 오류의 원인을 명확히 전달하는 데 중요하다. 이를 통해 클라이언트는 문제를 신속하게 파악하고 해결할 수 있다. 예를 들어, 404 오류는 URI 입력 오류나 API 삭제 등 여러 원인으로 발생할 수 있으며, 이 경우 클라이언트에게 "잘못된 URI" 또는 "API가 삭제되었습니다"와 같은 구체적인 정보를 제공하는 것이 필요하다.

이번엔 Custom Exception 처리를 통해 이러한 오류 메시지를 일관되게 제공하는 방법에 대해 알아보자.


패키지 및 클래스 추가

  • 위와 같이 패키지 및 클래스를 추가하자.

CommonDto

package com.hkhong.study.global.dto;

import com.hkhong.study.global.exception.CustomException;
import lombok.Builder;
import lombok.Getter;

public class CommonDto {

    @Getter
    @Builder
    public static class ErrorResponse {
        private String errorCode;
        private String errorMessage;

        public static ErrorResponse from(CustomException exception) {
            return ErrorResponse.builder()
                    .errorCode(exception.getErrorCode())
                    .errorMessage(exception.getErrorMessage())
                    .build();
        }
    }
}
  • 해당 Dto는 추후 공통된 Dto사용목적을 위해 명칭을 CommonDto로 하였다.
  • 발생한 에러에 대해 리턴할 내부 클래스인 ErrorResponse 클래스를 만들었다. 코드는 간단하니 넘어가자.

CustomException

package com.hkhong.study.global.exception;

import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
public class CustomException extends RuntimeException {
    private final HttpStatus httpStatus;
    private final String errorCode;
    private final String errorMessage;

    public CustomException(ErrorCode errorCode) {
        this.httpStatus = errorCode.getStatus();
        this.errorCode = errorCode.getCode();
        this.errorMessage = errorCode.getMessage();
    }
}
  • CustomExceptiop클래스는 특정 오류 상황을 표현하기 위해 만들어진 사용자 정의 예외다.
  • HTTP 상태 코드, 오류 코드, 오류 메시지를 포함하여 API에서 발생하는 오류를 클라이언트에게 전달할 목적이다.

GlobalExceptionHandler

package com.hkhong.study.global.exception;

import com.hkhong.study.global.dto.CommonDto;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler{

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<CommonDto.ErrorResponse> handleCustomException(CustomException ex) {
        return new ResponseEntity<>(CommonDto.ErrorResponse.from(ex), ex.getHttpStatus());
    }
}
  • GlobalExceptionHandler 클래스는 CustomException이 발생했을때 이를 처리하여 클라이언트에게 일관된 오류 응답을 제공한다.
  • @RestControllerAdvice: @RestController에 대한 전역적으로 발생하는 예외를 처리할 수 있다.
  • @ExceptionHandler(CustomException.class): CustomException이 발생했을 때 이 메서드가 호출된다.
  • CommonDto.ErrorResponse.from(ex): CustomException에서 오류 정보를 가져와 ErrorResponse 객체로 변환한다.

ErrorCode

package com.hkhong.study.global.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor
public enum ErrorCode {
    INVALID_REQUEST("잘못된 요청입니다.", "1000", HttpStatus.BAD_REQUEST),
    USER_NOT_FOUND("사용자를 찾을 수 없습니다.", "1001", HttpStatus.NOT_FOUND),
    INTERNAL_SERVER_ERROR("서버 오류가 발생했습니다.", "1002",HttpStatus.INTERNAL_SERVER_ERROR),

    TOKEN_EXPIRED("JWT 토큰이 만료되었습니다.", "9001",HttpStatus.UNAUTHORIZED),
    INVALID_SIGNATURE("잘못된 JWT 서명입니다.", "9002",HttpStatus.UNAUTHORIZED ),
    TOKEN_ERROR("JWT 토큰 검증 중 오류가 발생했습니다.", "9003",HttpStatus.UNAUTHORIZED),
    ;

    private final String message;
    private final String code;
    private final HttpStatus status;
}
  • 에러를 enum으로 관리하면 유지보수할때 편하기에 에러를 enum으로 관리하고 몇가지에 에러 상황을 추가시켜 놓았다.

Test_강제에러

package com.hkhong.study.controller;

import com.hkhong.study.global.exception.CustomException;
import com.hkhong.study.global.exception.ErrorCode;
import com.hkhong.study.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
public class UserController {

    private final JwtUtil jwtUtil;

    @GetMapping("")
    public ResponseEntity<?> getUserInfo(@RequestHeader("Authorization") String token){
        // "Bearer " 문자열을 제거
        token = token.substring(7);

        //강제에러 발생!!!!!!!!!!!!!!!!!!!!!!
        if(true) throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);

        // 유저정보 리턴
        return ResponseEntity.ok(jwtUtil.extractClaims(token));
    }
}
  • 저번에 만들어 두었던 유저정보 조회 API 중간에 강제로 에러를 발생시켜 보았다.

  • 이와 같이 에러정보가 넘어오는걸 볼 수가 있다.

Test2_토큰에러

  • 필자는 API호출 시 토큰의 유효성을 검사하여 토큰만료, 토큰서명에러등에 대해 에러메세지를 클라이언트에게 리턴값으로 전달하고 싶었다.

1. 유효성검사 메소드 내부에서 던지는 에러

package com.hkhong.study.config;

import com.hkhong.study.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        String header = request.getHeader("Authorization");
        String token = null;
        String username = null;

        // 1. Authorization 헤더 확인
        if (header != null && header.startsWith("Bearer ")) {
            token = header.substring(7);
            // 토큰 유효성 검사
            if (jwtUtil.validateToken(token)) {
                username = jwtUtil.extractUsername(token);
            }
        }

        // 2. 사용자 인증 처리
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
            authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        chain.doFilter(request, response);
    }
}
//토큰 유효성 검증
public boolean validateToken(String token){
    try {
            return !isTokenExpired(token);
    } catch (ExpiredJwtException e) {
        // 토큰이 만료된 경우
        throw new CustomException(ErrorCode.TOKEN_EXPIRED);
    } catch (SignatureException e) {
        // 서명 검증 실패
        throw new CustomException(ErrorCode.INVALID_SIGNATURE);
    } catch (Exception e) {
        // 그 외 예외 처리
        throw new CustomException(ErrorCode.TOKEN_ERROR);
    }
}

이와 같이 validateToken 메소드 내부에서 에러를 던져보았다. 그러나 아무값도 리턴값으로 넘어오지 않았다.

왜 그랬을까?

  • OncePerRequestFilter의 동작 방식과 예외 처리의 흐름과 관련이 있다. Spring Security의 OncePerRequestFilter는 필터 체인에서 예외가 발생해도 기본적으로 필터가 예외를 자동으로 처리하지 않습니다.
  • Spring Security에서 필터(JwtAuthenticationFilter)는 모든 요청을 통과하는 첫 번째 단계이다. 이 필터에서 예외가 발생하면, 그 예외는 기본적으로 필터 자체에서만 처리되고, 자동으로 클라이언트에게 전달되지 않는다. 즉, validateToken에서 CustomException을 던지더라도, 필터가 그 예외를 따로 처리하지 않으면 클라이언트는 아무런 정보를 받지 못한다.

왜 CustomException이 클라이언트에게 전달되지 않을까?

  • validateToken 메서드에서 CustomException을 던졌을 때, 이 예외는 필터가 호출한 메서드에서 발생한 것이기 때문에 필터 내부에서 예외를 처리하지 않으면 그 예외는 무시된다. 그래서 클라이언트가 에러 내용을 받지 못한다.
  • Spring의 컨트롤러나 서비스에서 발생한 예외는 자동으로 @ControllerAdvice나 @ExceptionHandler로 처리될 수 있지만, 필터에서는 예외 처리가 자동으로 이루어지지 않는다. 그래서 필터 내부에서 직접 예외를 처리해줘야 한다.

해결 방법은 필터에서 예외를 처리하자

  • 필터에서 발생하는 예외는 try-catch로 잡아야 한다 즉, doFilterInternal 메서드 안에서 validateToken을 호출할 때 예외가 발생하면 그 예외를 catch하고, 클라이언트에게 적절한 에러 메시지와 상태 코드를 돌려줘야 한다.

2. doFilterInternal에서 예외처리

package com.hkhong.study.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.hkhong.study.global.dto.CommonDto;
import com.hkhong.study.global.exception.CustomException;
import com.hkhong.study.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        String header = request.getHeader("Authorization");
        String token = null;
        String username = null;

        try {
            // 1. Authorization 헤더 확인
            if (header != null && header.startsWith("Bearer ")) {
                token = header.substring(7);
                if (jwtUtil.validateToken(token)) {
                    username = jwtUtil.extractUsername(token);
                }
            }

            // 2. 사용자 인증 처리
            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }

            chain.doFilter(request, response);
        } catch (CustomException ex) {
            // CustomException 발생 시 오류 응답 처리
            response.setStatus(ex.getHttpStatus().value());
            response.setCharacterEncoding("UTF-8");
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write(new ObjectMapper().writeValueAsString(CommonDto.ErrorResponse.from(ex)));
        }
    }
}


마치며

이렇게해서 CustomException을 만들어보고 에러 테스트까지 해보았다. 토큰 에러를 구현하는 과정에서 시간을 많이 허비했지만 그래도 왜 그런지 알았으니 그걸로 만족하자.

0개의 댓글