Search

Exception Custom

Person
태그
예외처리
목차

예외 처리 커스텀의 중요성

예외 처리를 커스텀 하는 이유는 뭘까요? 첫 프로젝트에서 협업을 하지 않고 백엔드끼리 개발을 하다보니 사실상 필요성을 크게 느끼지 못했습니다. 배워나가는 단계라 커스텀을 하기보다는 그 에러가 뭐고, 어디서 터졌고, 어떻게 수정해야할까가 더 중요했기 때문입니다.
하지만 프로젝트에 프론트팀이 들어오게 되면서 예외 처리를 커스텀하는것이 중요하다는것을 느꼈습니다. SSR 에서 CSR 로의 변환과 restApi 로의 변환을 겪으면서 뼈저리게 느꼈는데요, 첫번째로 느낀 시점은 프팀의 요청이였습니다.
프팀: 회원가입과 로그인시에 필요한 예외 입니다. 백팀: 네? (해본적 없음) 네...
열심히 고민하고, 공부해서 결론적으로는 커스텀 완료 하였습니다. 정말 거슬렸던 부분까지 해결 완료! 해서 매우 흡족합니다. 껄껄 이제부터 어떻게 예외처리를 하였는지 보여드릴게요!

특정한 예외 만들기

이곳에서는 기본적으로 제공되는 예외가 아닌, 서비스단에서 발생시키고 싶은 에러를 만드는 방법을 알려드릴게요.

기본사항

travel-planner 백팀은 예외처리를 아래와 같이 하였습니다.
{ "status" : "403 FORBIDDEN" "errorCode" : "AUTH-002" "message" : "소셜 로그인으로 이미 가입한 아이디가 존재합니다." }
JSON
복사

1. ErrorType.java

필요한 에러를 enum 타입으로 관리하는 클래스입니다.
@Getter @ToString public enum ErrorType { ... TODO_NOT_FOUND(HttpStatus.NOT_FOUND, "PLANNER-003", "투두가 존재하지 않습니다."), DATE_NOT_FOUND(HttpStatus.NOT_FOUND, "PLANNER-004", "데이트가 존재하지 않습니다."), DATE_NOT_AUTHORIZED(HttpStatus.FORBIDDEN, "DATE-001", "플래너에 포함된 데이트가 아니기때문에 접근할 수 없습니다."), TODO_NOT_AUTHORIZED(HttpStatus.FORBIDDEN, "TODO-001", "데이트에 포함된 투두가 아니기때문에 접근할 수 없습니다."), GROUP_MEMBER_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "PLANNER-05", "그룹 멤버가 이미 존재합니다."); // ================================================================================================================== private final HttpStatus status; private final String errorCode; private final String message; ErrorType(HttpStatus status, String errorCode, String message) { this.status = status; this.errorCode = errorCode; this.message = message; } public HttpStatus getStatus() { return status; } public String getErrorCode() { return errorCode; } public String getMessage() { return message; } }
Java
복사

2. ApiException.java

public class ApiException extends RuntimeException { private final ErrorType errorType; public ApiException(ErrorType errorType) { super(errorType.getMessage()); this.errorType = errorType; } public ErrorType getErrorType() { return errorType; } }
Java
복사
RuntimeException 을 상속받는 클래스 입니다. 때문에 실행되면서 발생하는 예외들을 캐치하여 위에서 커스텀 예외를 반환할수 있습니다.

3. ApiExceptionResponse.java

위에서 반환된 예외를 담아 응답으로 보내주기위한 dto 입니다.
@Getter @Setter @AllArgsConstructor @Schema(description = "API 응답 에러 DTO") public class ApiExceptionResponse { @Schema(description = "상태 코드") private int status; @Schema(description = "에러 코드") private String errorCode; @Schema(description = "에러 메시지") private String message; }
Plain Text
복사

4.ApiExceptionHandler.java

프론트엔드팀이 커스텀한 예외를 볼 수 있도록 @RestControllerAdvice 를 이용, json 형태로 응답을 내려주는 클래스 입니다.
@RestControllerAdvice public class ApiExceptionHandler { @ExceptionHandler({ApiException.class}) public ResponseEntity<ApiExceptionResponse> handleApiException(ApiException e) { ErrorType errorType = e.getErrorType(); ApiExceptionResponse response = new ApiExceptionResponse( errorType.getStatus().value(), errorType.getErrorCode(), errorType.getMessage() ); return ResponseEntity.status(errorType.getStatus()).body(response); } }
Plain Text
복사

5. 사용

// 이메일로 멤버 조회 Optional<Member> member = memberRepository.findByEmail(request.getEmail()); if (member.isPresent()) { throw new ApiException(ErrorType.ALREADY_EXIST_EMAIL); }
Plain Text
복사

AuthenticationException

에러분기를 마쳣다 하고 뿌듯해하고 있던 찰나 청천벽력같은 소식이 들려왔습니다.
프팀: 회원가입 에러분기는 잘 되는데, 로그인에서 안되네요? 백팀: 네? (대략 동공지진) ... 수정... 해볼게요...
왜... 어째서... 안되는가... 로 고민하던 찰나, 시큐리티관련 예외의 경우 AuthenticationException 을 상속받아 분기처리를 해야한다는 사실을 알게 되었습니다.

시큐리티 예외 분기처리 하기

인증과 인가

인증: 유저가 누구인지 확인하는 절차
인가: 유저의 권한을 확인하는 절차
시큐리티에서 OAuth2 인증에 실패한 경우와 특정 요청시 인가에 실패한 경우 InsufficientAuthenticationException 가 발생하였습니다. OAuth2 로그인이 아닌, 일반 로그인의 경우 두가지의 예외가 발생하였습니다.
아이디가 알맞지 않은 경우: UsernameNotFoundException
비밀번호가 알밎지 않은 경우: BadCredentialsException
이 예외는 전부 AuthenticationException 의 종류 중 하나입니다.
때문에 이를 상속받아 분기를 해야 합니다.

JWT 토큰과 시큐리티

토큰 필터와 시큐리티 필터의 순서에따라 다르겠지만, 저희팀의 경우 토큰 필터가 먼저 작동하도록 설정하였습니다. 때문에 토큰이 만료되어 토큰 만료 예외가 먼저 발생하고, 그로인한 인가 실패로 InsufficientAuthenticationException 이 발생합니다. 때문에 후자만 커스텀하면 됩니다.

1. CustomAuthenticationEntryPoint.java

시큐리티 예외가 발생했을때 상황에 따라 분기처리를 하는 클래스 입니다. 발생하는 예외를 가져와 이전에 커스텀한 에러로 바꿔서 프론트에 보내줄 수 있습니다.
그렇다면 왜 굳이 커스텀해서 보내줘야 할까요? 똑같은 예외와 상태코드라도 의미하는바는 다르기 때문입니다! 똑같은 403 이라도 상황에 따라 토큰이 만료되서 인가를 받지 못한건지, 아니면 다른 이슈인건지 모릅니다. 아니면 특정 상황에 맞게 상태코드를 수정해야할 수도 있습니다. 때문에 코드와 메세지를 잘 작성해서 보내주는게 중요하겠죵?
@Component @RequiredArgsConstructor public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { ErrorType errorType = null; String requestURI = request.getRequestURI(); if (requestURI.startsWith("/error") && authException instanceof InsufficientAuthenticationException) { // 소셜 로그인에서 인증에 실패한 경우 setResponse(response, ErrorType.USER_ALREADY_AUTHORIZED); } else if (authException instanceof UsernameNotFoundException) { // 로그인시 유저 아이디가 일치하지 않는 경우의 에러 커스텀 setResponse(response, ErrorType.CHECK_EMAIL_AGAIN); } else if (authException instanceof BadCredentialsException) { // 로그인시 유저 비밀번호가 일치하지 않는 경우의 에러 커스텀 setResponse(response, ErrorType.CHECK_PASSWORD_AGAIN); } else if (authException instanceof InsufficientAuthenticationException) { // 인가에 실패한 경우 setResponse(response, ErrorType.ACCESS_TOKEN_EXPIRED); } } private void setResponse(HttpServletResponse response, ErrorType errorType) throws IOException { response.setContentType("application/json;charset=UTF-8"); int status = Integer.parseInt(String.valueOf(errorType.getStatus()).substring(0,3)); response.setStatus(status); response.getWriter().println( "{\\"status\\" : \\"" + errorType.getStatus() + "\\"," + "\\"errorCode\\" : \\"" + errorType.getErrorCode() + "\\"," + " \\"message\\" : \\"" + errorType.getMessage() + "\\"}"); } }
Plain Text
복사

2. SecurityConfig.java

이제 이 커스텀한 엔트리포인트 클래스를 시큐리티설정에 등록해줍시다.
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf().disable() .cors() .and() .authorizeRequests() .requestMatchers("/ws/**").permitAll() .requestMatchers("/feed/**").permitAll() .requestMatchers(HttpMethod.GET, "/planner/**").permitAll() .requestMatchers("/oauth/**", "/favicon.ico", "/login/**").permitAll() .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/**").permitAll() .requestMatchers("/auth/**").permitAll() .anyRequest().authenticated() .and() .exceptionHandling().authenticationEntryPoint(customAuthenticationEntryPoint); ... ...
Plain Text
복사
간단해보이지만 쉽지 않았던 예외 커스텀이였습니다.