개발을 하면서 다양한 예외처리를 하게 된다. 기본적으로는 if, else
등으로 분기를 나누어 아웃풋을 다르게 하는 경우가 있지만 대부분의 경우(특히 범용적으로 사용될 수 있도록 만들어진 라이브러리, 프레임워크) 에서는 exception
을 통해 예외처리를 하게 된다.
어플리케이션 서버를 개발할 때에는 이러한 예외처리들의 결과가 정해진 형식의 응답을 따르도록 해야한다. 예를 들어서 만약 사용자의 로그인 정보를 입력 받는 컨트롤러가 있다고 해보자.
@PostMapping("/login")
public ResponseEntity<ResponseType> login(LoginRequest loginRequest){
// 데이터1을 처리...
// 데이터1에 대한 예외처리...
// 데이터2를 처리...
// 데이터2에 대한 예외처리...
// 데이터3을 처리(또 다른 third-party 라이브러리 사용)
// 데이터3이 사용한 third-party 라이브러리에 대한 예외처리...
}
이런식으로 컨트롤러의 함수가 작성되고 모든 데이터에 대한 예외처리가 하나의 Endpoint
즉 ResponseEntitiy
의 형태가 될 것이다.
어떻게 하면 좋을지 구글링을 해보며 종합해본 예외처리의 Best Practice
에 대해 간단히 적어볼까 한다.
이 글은 정답이 아니며 예외 처리를 하는 방법은 아주 다양하지만 저 처럼 일정 수준의 Best Practice를 고집하는 사람들에게 조금이라도 참고가 되길 바라며 작성하는 글입니다. 이 후 코드들은 복사 붙여넣기 한다고 작동하는 것이 아닌 concept에 대한 내용입니다.
가장 먼저 우리가 직접 작성한 컨트롤러에 대한 예외들을 처리해볼 것이다.
@PostMapping("/login")
public ResponseEntity<TokenContainingResponse> login(@RequestBody LoginRequest loginRequest) throws Exception {
String token = userService.loginAndGenerateToken(loginRequest);
TokenContainingResponse response = new TokenContainingResponse(HttpStatus.OK, Controller.LOG_IN_SUCCESS_MESSAGE, token);
return new ResponseEntity<>(response, HttpStatus.OK);
}
먼저 API의 기본적인 엔드포인트를 담당하는 컨트롤러 함수이다. 서비스 클래스에서 토큰을 생성하고 그 와중에 사용되는 모든 에러처리를 하나의 결과, 즉 TokenContainingResponse
에 담도록 할 것이다.
public class TokenContainingResponse {
private HttpStatus httpStatus;
private String message;
private String token;
}
// TokenContainingResponse.java
그리고 서비스 클래스 내의 메소드는 아래와 같다.
public String loginAndGenerateToken(LoginRequest loginRequest) throws Exception {
String email = Optional.ofNullable(loginRequest.getEmail()).orElseThrow(EmptyValueExistException::new);
String password = Optional.ofNullable(loginRequest.getPassword()).orElseThrow(EmptyValueExistException::new);
Optional<User> user = userRepository.findByEmail(email);
if(!user.isPresent()){
throw new UserNotExistException();
}
try{
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
loginRequest.getEmail(),
loginRequest.getPassword()
));
}catch(Exception e){
throw new LoginFailException();
}
CustomUserDetails userDetails = userDetailsService.loadUserByUsername(user.get().getEmail());
String token = jwtUtil.generateToken(email);
return token;
}
먼저
Optional<User> user = userRepository.findByEmail(email);
if(user.isEmpty()){
throw new UserNotExistException();
}
이 부분만 보면 데이터베이스에서 user
의 정보를 email
에 기반하여 찾고 존재하지 않으면 바로 UserNotExistException
을 띄워버린다. 코드 상으로는 굉장히 깔끔하지만 이 부분만 확인하면 무책임해 보인다. 하지만 스프링은 이러한 Exception이 발생할때마다 따로 캐치해서 처리해 주는 방법이 존재한다.
먼저 예외의 기본적인 형태부터 정해준다.
@AllArgsConstructor
@Getter
public class ApiException {
private final String message;
private final HttpStatus httpStatus;
private final ZonedDateTime timestamp;
}
// ApiException.java
public class UserNotExistException extends RuntimeException{
}
UserNotExistException.java
이렇게 예외에 대한 코멘트를 해줄 message
와 상태코드, 그리고 예외의 발생 시간을 기록할 수 있도록 한다. 그리고 실제로 띄울 Exception
도 정해준다. 위와 같이 class를 작성하고 RuntimeException
을 상속받도록 한다.
@ControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(value = {UserNotExistException.class})
public ResponseEntity<Object> handleUserNotExistException(UserNotExistException e){
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
ApiException apiException = new ApiException(
ExceptionMessage.USER_NOT_EXIST_MESSAGE,
httpStatus,
ZonedDateTime.now(ZoneId.of("Z"))
);
return new ResponseEntity<>(apiException, httpStatus);
}
예외 핸들러를 작성해준다. 먼저 @ControllerAdvice
는 @Controller
로 선언된 메소드 내에서 예외가 발생하면 그 처리를 이 핸들러가 하도록 한다.
그리고 @ExceptionHandler(value = {UserNotExistException.class})
로 처리할 예외의 class를 적어주면 된다.
어떤 컨트롤러에서든
UserNotExistException
이 발생하면 이@ControllerAdvice
어노테이션이 선언된 클래스로 와서 맞는@ExceptionHandler
를 찾고 그에 대한 처리를 하는 것이다.
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
ApiException apiException = new ApiException(
ExceptionMessage.USER_NOT_EXIST_MESSAGE, // "유저가 존재하지 않습니다."
httpStatus,
ZonedDateTime.now(ZoneId.of("Z"))
);
return new ResponseEntity<>(apiException, httpStatus);
내부에서 처리는 간단하다. 먼저 상태코드를 정해주고 우리가 만든 ApiException
을 작성한다음 ResponseEntity
에 담아서 돌려주도록 하면 된다. 이렇게 하면 어떤 컨트롤러에서든 우리가 정한 예외만 띄워준다면 알아서 ResponseEntitiy
의 형태로 돌려줄 수 있다.
로그인의 인증 구현을 위해 Spring Security
와 JWT
를 사용을 해보았는데, 여기서 발생하는 예외는 컨트롤러에 속하지 않으므로 @ControllerAdvice
가 알아차리지 못하였다. 따라서 생각치 못한 예외가 발생하므로 정상적으로 ResponseEntity
를 클라이언트 측에 반환하지 못한다.
즉 컨트롤러에서 발생한 예외가 아니면 같은 UserNotExistException
과 같은 커스텀 예외 방식을 사용할 수 없었다.
위 그림처럼 다른 라이브러리에 대한 예외가 @ControllerAdvice
에 전달되지 않는 것이 문제였다. 그렇다면 이러한 예외를 처리하기 위해서는
이렇게 다른 라이브러리에서 예외가 발생하면 이렇게 컨트롤러에 띄워주도록 하면 된다.
먼저 ExceptionHandleController
를 작성해준다.
@RestController
@RequestMapping("/exception")
public class ExceptionHandleController {
@GetMapping("/jwt")
public void JwtException(){
throw new UserNotExistException();
}
}
위처럼 Jwt관련 문제가 생기면 방금 생성한 UserNotExistException
을 똑같이 띄워주도록 했다. 그리고 Spring Security
설정 파일에의 configure
메소드에는
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers(<URLS>)
.permitAll()
.antMatchers(<URLS>)
.permitAll()
.anyRequest()
.authenticated()
.and()
.exceptionHandling().authenticationEntryPoint(new AuthenticationExceptionHandler())
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
}
위처럼 authenticationEntryPoint
에서의 예외처리를 AuthenticationExceptionHandler
가 처리하도록 해주었다. 즉 이제부터 Security
의 인증과정에 문제가 생기면 AuthenticationExceptionHandler
가 처리한다.
그리고 AuthenticationExceptionHandler
를 아래처럼 작성해준다.
@Component
public class AuthenticationExceptionHandler implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException {
httpServletResponse.sendRedirect("/exception/jwt");
}
}
이렇게 response
를 받아서 sendRedirect
를 우리가 작성한 ExceptionHandlerController
와 맞는 URL로 보내주면 된다. 이렇게 하면 ExceptionHandlerController
가 작동하고 그 안의 UserNotExistException
이 발생하고 그 예외를 @ControllerAdvice
가 처리하게 된다.
조금 복잡하지만 이렇게 예외를 처리해놓으면 다른 라이브러리의 사용에서Exception
의 결과가 똑같다면 재사용할 수 있는 여지가 많고 어플리케이션 서버의 스케일을 확장시키기에도 용이하다. 개념과 예외 처리의 흐름만 설명하기 위해 코드를 간단하게 적었다. 실제로 작동하는 코드는 여기에서 확인할 수 있다.
정말 좋은글이네요. 내공에 감탄하고 갑니다ㅎㅎ