와이유스토리

[도트타이머] 6. 예외처리(CustomException, ExceptionHandler, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler) 본문

프로젝트/백엔드

[도트타이머] 6. 예외처리(CustomException, ExceptionHandler, JwtAuthenticationEntryPoint, JwtAccessDeniedHandler)

유(YOO) 2022. 12. 22. 22:07

CustomException

CustomException 생성 방법에는 2가지가 있다.

1. CustomException 클래스 한 개에 Enum으로 ErrorCode 여러 개 사용

2. CustomException 클래스를 상속받는 클래스 여러 개 생성

 

1. CustomException 클래스 한 개에 Enum으로 ErrorCode 여러 개 사용

클래스 여러 개 생성하지 않아도 되므로 간편

package com.dotetimer.exception;

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

@Getter
@AllArgsConstructor
public class CustomException extends RuntimeException{
    ErrorCode errorCode;
}
package com.dotetimer.infra.exception;

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

/* Exception
- RuntimeException
400 NullPointException
400 IllegalArgumentException
400 IllegalStateException
400 MethodArgumentNotValidException
404 NotFoundException -> Service
404 NoHandlerFoundException
405 HttpRequestMethodNotSupportedException
415 HttpMediaTypeException
500 Exception
500 RuntimeException
ConstraintViolationException
MissingArgumentTypeMismatchException
DateTimeParseException

- Custom
401 UnauthorizedException -> Controller
401 AccessDeniedException
401 AuthenticationEntryPoint
403 AccessDeniedHandler
404 NotFoundDataException
DuplicateException
NoSuchDataException
InvalidReqParamException
InvalidReqBodyException
S3Exception
*/

@Getter
@AllArgsConstructor
public enum ErrorCode {
    // 400 BAD_REQUEST : 잘못된 요청
    INVALID_LOGIN(HttpStatus.BAD_REQUEST, "이메일이 잘못되거나 비밀번호 길이가 8자 미만입니다"),
    INVALID_EMAIL(HttpStatus.BAD_REQUEST, "올바른 형식의 이메일 주소여야 합니다"),
    INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "refresh token이 유효하지 않습니다"),
    INVALID_DATA(HttpStatus.BAD_REQUEST, "잘못된 데이터입니다"),
    LIMIT_DATA(HttpStatus.BAD_REQUEST, "데이터 저장이 제한되었습니다"),
    MISMATCH_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "refresh token의 유저 정보가 일치하지 않습니다"),
    CANNOT_FOLLOW_MYSELF(HttpStatus.BAD_REQUEST, "자기 자신은 팔로우할 수 없습니다"),

    // 401 UNAUTHORIZED : 인증되지 않은 사용자
    INVALID_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, "인증 정보가 없는 토큰입니다"), // 토큰 잘못된 경우(시그니처 불일치)
    EXPIRE_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, "만료된 토큰입니다"), // 토큰 만료된 경우

    // 403 FORBIDDEN : 권한 제한 사용자
    FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없는 요청입니다"),

    // 404 NOT_FOUND : Resource를 찾을 수 없음
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저 정보를 찾을 수 없습니"),
    MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 리소스를 찾을 수 없습니다"),
    PASSWORD_NOT_FOUND(HttpStatus.NOT_FOUND, "비밀번호가 틀렸습니다"),
    REFRESH_TOKEN_NOT_FOUND(HttpStatus.NOT_FOUND, "refresh token을 찾을 수 없습니다"),
    NOT_FOLLOW(HttpStatus.NOT_FOUND, "팔로우 중이지 않습니다"),

    // 409 CONFLICT : Resource 의 현재 상태와 충돌. 보통 중복된 데이터 존재
    DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "이미 존재하는 데이터입니다");

    private final HttpStatus httpStatus;
    private final String detail;
}
package com.dotetimer.exception;

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

import java.time.LocalDateTime;

@Getter
@Builder
public class ErrorResponse {
    private final LocalDateTime timestamp = LocalDateTime.now();
    private final int status;
    private final String error;
    private final String message;
}

2. CustomException 클래스를 상속받는 클래스 여러 개 생성

package com.dotetimer.exception;

import lombok.Getter;

public class CustomException extends RuntimeException{
    @Getter
    String name;
    
    public CustomException(String message) {
    	super(message);
    }
}
package com.dotetimer.exception;

public class NotFoundDataException extends CustomException{
    NotFoundDataException(String message) {
    	super(message);
        name = "NotFoundDataException";
    }
}

ExceptionHandler

1. 컨트롤러마다 ExceptionHandler 생성

2. 모든 컨트롤러에서 사용할 수 있는 GeneralExceptionHandler

 

1번은 번거로우니 2번으로 만들었다.

package com.dotetimer.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.data.crossstore.ChangeSetPersister.NotFoundException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.servlet.NoHandlerFoundException;

@Slf4j
@ControllerAdvice
public class GeneralExceptionHandler {
    // Response 생성
    private ResponseEntity<ErrorResponse> newResponse(String message, HttpStatus status) {
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add("Content-Type", "application/json");
        ErrorResponse errorResponse = ErrorResponse.builder()
                .status(status.value())
                .error(status.name())
                .message(message)
                .build();
        return new ResponseEntity<>(errorResponse, httpHeaders, status);
    }

    // CustomException 처리
    @ExceptionHandler({CustomException.class})
    public ResponseEntity<?> handleCustomException(CustomException e) {
        log.info(e.getMessage());
        return newResponse(e.getErrorCode().getDetail(), e.getErrorCode().getHttpStatus());
    }

    // 400 Bad Request 처리
    @ExceptionHandler({NullPointerException.class, IllegalArgumentException.class, IllegalStateException.class, MethodArgumentNotValidException.class, DuplicateKeyException.class})
    public ResponseEntity<?> handleBadRequestException(Exception e) {
        log.info(e.getMessage());
        if (e instanceof MethodArgumentNotValidException) {
            return newResponse(((MethodArgumentNotValidException) e).getBindingResult().getAllErrors().get(0).getDefaultMessage(), HttpStatus.BAD_REQUEST);
        }
        return newResponse(e.getMessage(), HttpStatus.BAD_REQUEST);
    }

    // 404 Not Found 처리
    @ExceptionHandler({NoHandlerFoundException.class, NotFoundException.class})
    public ResponseEntity<?> handleNotFoundException(Exception e) {
        log.info(e.getMessage());
        return newResponse(e.getMessage(), HttpStatus.NOT_FOUND);
    }

    // 404 Method Not Allowed 에러
    @ExceptionHandler({HttpRequestMethodNotSupportedException.class})
    public ResponseEntity<?> handleMethodNotAllowedException(Exception e) {
        log.info(e.getMessage());
        return newResponse(e.getMessage(), HttpStatus.METHOD_NOT_ALLOWED);
    }

    // 415 Unsupported Media Type 에러
    @ExceptionHandler({HttpMediaTypeException.class})
    public ResponseEntity<?> handleHttpMediaTypeException(Exception e) {
        log.info(e.getMessage());
        return newResponse(e.getMessage(), HttpStatus.UNSUPPORTED_MEDIA_TYPE);
    }

    // 500 Internal Server Error 에러
    @ExceptionHandler({Exception.class, RuntimeException.class})
    public ResponseEntity<?> handleException(Exception e) {
        log.info(e.getMessage());
        return newResponse(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

Filter에서 던지는 에러

JwtAuthenticationFilter가 던지는 에러를 처리하기 위한 클래스들이다. 다른 블로그를 참고하니 Custom Exception은 Spring의 영역이나, Spring Security는 Spring 이전에 필터링 하므로 아무리 Security단=에서 예외가 발생해도 Spring의 DispatcherServlet까지 닿을 수가 없다고 한다.

JwtAuthenticationEntryPoint.class

package com.dotetimer.exception;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.json.JSONObject;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

import static com.dotetimer.exception.ErrorCode.EXPIRE_AUTH_TOKEN;
import static com.dotetimer.exception.ErrorCode.INVALID_AUTH_TOKEN;

// 401 Unauthorized Exception 처리(인증 실패)
@Slf4j
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
        ErrorResponse errorResponse;
        if (authException.equals(EXPIRE_AUTH_TOKEN)) {
            errorResponse = ErrorResponse.builder()
                    .status(EXPIRE_AUTH_TOKEN.getHttpStatus().value())
                    .error(EXPIRE_AUTH_TOKEN.getHttpStatus().name())
                    .message(EXPIRE_AUTH_TOKEN.getDetail())
                    .build();
        }
        else {
            errorResponse = ErrorResponse.builder()
                    .status(INVALID_AUTH_TOKEN.getHttpStatus().value())
                    .error(INVALID_AUTH_TOKEN.getHttpStatus().name())
                    .message(INVALID_AUTH_TOKEN.getDetail())
                    .build();
        }

        JSONObject json = new JSONObject();
        json.put("timestamp", errorResponse.getTimestamp());
        json.put("status", errorResponse.getStatus());
        json.put("error", errorResponse.getError());
        json.put("message", errorResponse.getMessage());
        json.put("path", request.getRequestURI());

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setHeader("Content-Type", "application/json");
        response.setCharacterEncoding("utf-8");
        // response.getWriter().write("401 Exception");
        // response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
        response.getWriter().print(json);
        response.getWriter().flush();
        response.getWriter().close();
        response.sendRedirect("/api/user/sign_in"); // 마지막 순서 주의

        log.info(json.toString());
        log.error(authException.getMessage());
    }
}

JwtAccessDeniedHandler.class

package com.dotetimer.exception;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

import org.json.JSONObject;

import static com.dotetimer.exception.ErrorCode.FORBIDDEN;

// 403 Forbidden Exception 처리(권한 제한)
@Slf4j
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ErrorResponse errorResponse;
        errorResponse = ErrorResponse.builder()
                .status(FORBIDDEN.getHttpStatus().value())
                .error(FORBIDDEN.getHttpStatus().name())
                .message(FORBIDDEN.getDetail())
                .build();

        JSONObject json = new JSONObject();
        json.put("timestamp", errorResponse.getTimestamp());
        json.put("status", errorResponse.getStatus());
        json.put("error", errorResponse.getError());
        json.put("message", errorResponse.getMessage());
        json.put("path", request.getRequestURI());

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setHeader("content-type", "application/json"); // response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().print(json);
        response.getWriter().flush();
        response.getWriter().close();

        log.info(json.toString());
        log.error(accessDeniedException.getMessage());
    }
}

 

* 참고

https://beemiel.tistory.com/11

https://bcp0109.tistory.com/303

Comments