상세 컨텐츠

본문 제목

예외처리 프로세스 | ControllerAdvice, AOP

Project/Meta ESG

by yooputer 2024. 3. 5. 11:46

본문

기존에 있던 레거시 프로젝트는 try-catch문으로 떡칠되어 있었다.

try-catch와 비즈니스 로직을 섞어놓으면 가독성이 나빠진다. 

그래서 신규 구축 프로젝트를 맡았을때 예외처리 프로세스와 비즈니스 로직을 분리해야겠다고 생각했고,
새로운 예외처리 프로세스를 짜기로 했다. 


예외처리 프로세스 요구사항

새로운 예외처리 프로세스에 대한 요구사항은 다음과 같다. 

1. try-catch문은 Controller에서만 사용한다. (단, IOException과 같이 정적 예외는 예외가 발생한 곳에서 처리하거나 throws한다)

2. 예외가 발생했을 때 Controller인 경우 에러 페이지를 반환하고, RestController인 경우 에러 객체를 반환한다.

3. 예외가 발생하면 자동으로 exception_log 테이블에 INERT한다.


구현 전략

위와 같은 요구사항을 만족하기 위해 CustomException 클래스를 구현하고 ControllerAdvice와 AOP를 사용하기로 했다.

 

ControllerAdvice의 ExceptionHadler를 사용하면 컨트롤러에서 특정 Exception이 throw되었을때 반환값을 컨트롤할 수 있다.

AOP의 AfterThrowing을 사용하면 특정 경로에 존재하는 클래스에서 Exception이 throw되었을 때 별도의 로직을 실행할 수 있다. 

 

예외처리 프로세스 순서도이다. 


구현 과정

AOP dependency 추가

우선 AOP를 사용하기 위해 build.gradle에 dependency를 추가한다.

 dependencies {
 	(생략)
 
 	// aop
    implementation 'org.springframework:spring-aop:6.0.6'
    implementation 'aspectj:aspectjrt:1.5.4'
    runtimeOnly 'org.aspectj:aspectjweaver:1.9.19'
}

 


ErrorCode Enum 구현

CustomException에서 사용할 ErrorCode 이넘을 만든다.

디비에 저장하고 싶은 예외가 생길때마다 value를 추가하면 된다. 

@AllArgsConstructor
@Getter
public enum ErrorCode {
    /* 400 BAD_REQUEST : 잘못된 요청 */
    SESSION_EXPIRED(HttpStatus.BAD_REQUEST, "세션이 만료되었습니다."),
    UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "유효한 인증 정보가 부족하여 요청이 거부되었습니다."),

    /* 401 UNAUTHORIZED : 인증되지 않은 사용자 */
    INVALID_AUTH_TOKEN(HttpStatus.UNAUTHORIZED, "권한 정보가 없는 토큰입니다."),

    /* 403 FORBIDDEN : 허가되지 않은 사용자 */
    NO_PERMISSION(HttpStatus.FORBIDDEN, "권한이 없습니다."),

    /* 404 NOT_FOUND : Resource를 찾을 수 없음 */
    INVALID_URL(HttpStatus.NOT_FOUND, "존재하지 않는 페이지입니다."),
    FORBIDDEN_URL(HttpStatus.NOT_FOUND, "접근이 거부되었습니다."),
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."),

    /* 500 : INTERNAL_SERVER_ERROR : 서버 오류 */
    INVALID_ID(HttpStatus.INTERNAL_SERVER_ERROR, "유효하지 않은 ID입니다."),
    UNCHECKED_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "작업 중 오류가 발생하였습니다."),
    FILE_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, "존재하지 않는 파일입니다."),
    SEND_EMAIL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "이메일 전송 과정에서 오류가 발생했습니다."),
    ENCODING_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "인코딩에 실패하였습니다,"),
    NOT_SUPPORTED_BY_LOCAL_SERVER(HttpStatus.INTERNAL_SERVER_ERROR, "로컬 환경에서 지원하지 않는 기능입니다."),
    XSS_ATTACK_DETECTED(HttpStatus.INTERNAL_SERVER_ERROR, "XSS 공격이 감지되었습니다."),


    ;

    private final HttpStatus httpStatus;
    private final String message;
}

CustomException, AjaxException 구현

CustomException은 DAO, Service, Controller단에서 모두 throw할 수 있는 Exception이다. 

ErrorCode 필드를 가지고 있는데, 예외 발생이 필요할 때 ErrorCode를 파라미터로 넘겨 CustomException 객체를 생성한 후 throw하면 된다.

 

AjaxException은 Controller단에서만 throw할 수 있다.

CustomException을 상속하기 때문에 CustomException과 마찬가지로 errorCode 필드를 가지고 있다. 

만약 error 페이지가 아니라 error엔티티를 반환해야 하면 AjaxException을 throw한다.

@Getter
public class CustomException extends RuntimeException{
    private final ErrorCode errorCode;

    public CustomException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public CustomException(Exception e) {
        super(e.getMessage(), e);
        errorCode = e instanceof CustomException ? ((CustomException) e).getErrorCode() : null;
    }
}
@Getter
public class AjaxException extends CustomException{

    public AjaxException(ErrorCode errorCode) {
        super(errorCode);
    }

    public AjaxException(Exception e) {
        super(e);
    }
}

Service에서 예외 발생시키는 예시

    public void checkIsAbleToSetReportTableForm(ProjectInfoDto projectInfo, ReportInfoDto reportInfo) {
        MemberVo loginMember = MemberUtil.getLoginMemberVoOrElseThrow();
        boolean isProjectAdmin = loginMember.isProjectAdmin(projectInfo);
        boolean isSuperAdmin = loginMember.isSuperAdmin();

        // 보고서 관리자이거나 슈퍼 관리자인지 체크
        if(!isProjectAdmin && !isSuperAdmin){
            throw new CustomException(ErrorCode.NO_PERMISSION);
        }
    }

 

컨트롤러에서 예외처리 예시

// 일반 Controller 메서드
@GetMapping("")
public String main(Model model
) {
    try {
        // 코드
    } catch (Exception e) {
        logger.error("Exception", e);
        throw new CustomException(e);
    }
}

// RestController 메서드
@PostMapping("")
@ResponseBody
public void ajax() {
    try {
    	// 코드
    } catch (Exception e) {
        logger.error("Exception", e);
        throw new AjaxException(e);
    }
}

ErrorResponseEntity 구현

ErrorResponseEntity는 AjaxException이 발생했을때 컨트롤러에서 반환할 객체이다.

httpStatusCode, ErrorCode 이넘명, ErrorCode message를 필드로 가진다.

@Data
@Builder
public class ErrorResponseEntity {
    private int status;
    private String code;
    private String message;

    public static ResponseEntity<ErrorResponseEntity> toResponseEntity(ErrorCode e){
        return ResponseEntity
                .status(e.getHttpStatus())
                .body(ErrorResponseEntity.builder()
                        .status(e.getHttpStatus().value())
                        .code(e.name())
                        .message(e.getMessage())
                        .build());
    }
}

CustomControllerAdvice 구현

컨트롤러에서 CustomException이 throw되는 경우 ErrorCode가 null이 아니면 ErrorCode.status에 맞는 에러 페이지를 반환하고 ErrorCode가 null이면 500에러 페이지를 반환한다.

 

컨트롤러에서 AjaxException이 throw되는 경우 ErrorCode가 null이 아니면 ErrorCode를 ErrorResponseEntity로 변환하여 반환하고 ErrorCode가 null이면 ErrorCode.UNCHECKED_ERROR를 ErrorResponseEntity로 변환하여 반환한다. 

 

ErrorCode가 null인경우 ErrorCode.UNCHECKED_ERROR를 반환하는 이유는 ErrorCode가 null인 경우 errorMessage에 서버에서 발생한 날것(?)의 오류 메시지가 담겨있는데, 이 메시지는 사용자에게 노출되면 안되기 때문에 해당 메시지는 DB에만 저장하고 사용자에게 반환하지 않기 위함이다. 

@ControllerAdvice
public class CustomControllerAdvice {
    @ExceptionHandler(CustomException.class)
    protected String handleCustomException(CustomException ce) {
        if (ce.getErrorCode() != null) {
            if (ce.getErrorCode().getHttpStatus() == HttpStatus.NOT_FOUND){
                return "error/404";
            }
            if (ce.getErrorCode().getHttpStatus() == HttpStatus.FORBIDDEN){
                return "error/403";
            }
            return "error/500";
        }

        return "error/500";
    }

    @ExceptionHandler(AjaxException.class)
    protected ResponseEntity<ErrorResponseEntity> handleCustomException(AjaxException ae) {
        if (ae.getErrorCode() != null) {
            return ErrorResponseEntity.toResponseEntity(ae.getErrorCode());
        }

        return ErrorResponseEntity.toResponseEntity(ErrorCode.UNCHECKED_ERROR);
    }
}

ControllerExceptionAspect 구현

마지막으로 AOP를 사용해 컨트롤러에서 Exception이 throw되면 exception_log 테이블에 INSERT되도록 한다.

 

만약 throw된 Exception이 CustomException/AjaxException이고 ErrorCode가 null이 아니면 ErrorCode.massage를 DB에 저장한다. 

ErrorCode가 null이면 Exception의 message를 DB에 저장한다.

 

에러메시지와 더불어 요청자 아이디, 요청 url, 서버 타입을 같이 저장한다.

요청자 아이디와 요청 URL을 저장하면 누가 언제 오류가 났는지 파악하기 쉽다.

서버타입을 저장하는 이유는 운영 환경에서는 서버가 여러대이기 때문에 어떤 서버에서 오류가 났는지 파악하기 쉽기 때문이다. 

@Aspect
public class ControllerExceptionAspect {

    @Autowired
    private ExceptionService exceptionService;

    @AfterThrowing(value="execution(* com.패키지.controller..*.*(..))", throwing="ex")
    public void afterControllerThrowing(JoinPoint joinPoint, Throwable ex){
        String errorMsg = ex.getMessage();
        String requestUrl = getRequestUrl(joinPoint);

        if (ex instanceof CustomException) {
            CustomException ex2 = (CustomException) ex;

            if (ex2.getErrorCode() != null) {
                errorMsg = ex2.getErrorCode().getMessage();
            }
        }

        exceptionService.insertException(ExceptionLogVo.builder()
                .errorMsg(errorMsg)
                .requestUrl(requestUrl)
                .memId(MemberUtil.getLoginMemberId())
                .serverType(ServerInfoUtil.getServerType())
                .build());
    }

    // request url 조회
    private String getRequestUrl(JoinPoint joinPoint) {
        Class c = joinPoint.getTarget().getClass();
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        RequestMapping requestMapping = (RequestMapping) c.getAnnotation(RequestMapping.class);
        String baseUrl = requestMapping.value()[0];

        return Stream.of( GetMapping.class, PutMapping.class, PostMapping.class,
                        PatchMapping.class, DeleteMapping.class, RequestMapping.class)
                .filter(mappingClass -> method.isAnnotationPresent(mappingClass))
                .map(mappingClass -> getUrl(method, mappingClass, baseUrl))
                .findFirst().orElse(null);
    }

    //  httpMethod + requestURL 조회
    private String getUrl(Method method, Class<? extends Annotation> annotationClass, String baseUrl){
        Annotation annotation = method.getAnnotation(annotationClass);
        String[] value;
        String httpMethod = null;

        try {
            value = (String[])annotationClass.getMethod("value").invoke(annotation);
            httpMethod = (annotationClass.getSimpleName().replace("Mapping", "")).toUpperCase();
        } catch (IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
            return null;
        }

        return String.format("%s %s%s", httpMethod, baseUrl, value.length > 0 ? value[0] : "");
    }
}

 

exception_log에 저장할 때 사용하는 ExceptionLogVo 클래스는 다음과 같다.

@Builder
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ExceptionLogVo {
    private String errorMsg;
    private String requestUrl;
    private Long memId;
    private String serverType;
    private LocalDateTime regDate;
}

결과

결과적으로 서버에서 발생한 자세한 오류 로그(StackTrace 등)는 logger에 의해 log파일에 저장되고, 

오류에 대한 간단한 로깅(언제, 어디서, 누가, 어떻게)은 DB에 저장된다.

 

관련글 더보기