API 개발에서 예외 처리는 클라이언트에게 발생한 오류의 원인을 명확히 전달하는 데 중요하다. 이를 통해 클라이언트는 문제를 신속하게 파악하고 해결할 수 있다. 예를 들어, 404 오류는 URI 입력 오류나 API 삭제 등 여러 원인으로 발생할 수 있으며, 이 경우 클라이언트에게 "잘못된 URI" 또는 "API가 삭제되었습니다"와 같은 구체적인 정보를 제공하는 것이 필요하다.
이번엔 Custom Exception 처리를 통해 이러한 오류 메시지를 일관되게 제공하는 방법에 대해 알아보자.
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();
}
}
}
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();
}
}
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());
}
}
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;
}
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));
}
}
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 메소드 내부에서 에러를 던져보았다. 그러나 아무값도 리턴값으로 넘어오지 않았다.
왜 그랬을까?
왜 CustomException이 클라이언트에게 전달되지 않을까?
해결 방법은 필터에서 예외를 처리하자
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을 만들어보고 에러 테스트까지 해보았다. 토큰 에러를 구현하는 과정에서 시간을 많이 허비했지만 그래도 왜 그런지 알았으니 그걸로 만족하자.