[잔디일기] 에러 핸들링 하기

1. 목표

시스템 전반에서 발생할 수 있는 다양한 예외를 포괄적으로 관리하고, 일관된 방식으로 예외를 처리하기 위해 예외 처리 로직이 필요합니다.
에러 핸들링을 위한 예외 처리 로직 구현 과정을 정리해 보았습니다.

 

*아직 리팩토링이 진행중인 상태로, 생성되지 않은 예외들이 있어서 추후 에러코드들이 거의 완성된다면 추가 해두겠습니다.

2. 예외 코드 정의

먼저, 클라이언트와 서버에서 발생할 수 있는 다양한 예외 상황에 대해 정의합니다.
에러의 경우 서버와 클라이언트에서 생성되는 2가지의 오류가 있기 때문에 ClientErrorCodeServerErrorCode를 enum 클래스로 나누어 정의해 주었습니다.

이 때, ErrorCodeModel이라는 인터페이스를 생성해주었습니다.

ErrorCodeModel 인터페이스의 장점

  • 추후 새로 생성될 xxxErrorCode들의 구조를 지정해 줄 수 있습니다.
  • 또한 여러 xxxErrorCode 클래스들이 생성되어도 예외 처리시 ErrorCodeModel 타입을 이용하여 하나의 처리 메서드에서 다룰 수 있어 유연한 예외 처리가 가능합니다.

ClientErrorCode Enum

public enum ClientErrorCode implements ErrorCodeModel {
    UNAUTHORIZED(401, "UNAUTHORIZED", "인증이 필요합니다. 로그인 해주세요."),
    AUTHENTICATION_FAILED(401, "AUTHENTICATION_FAILED", "인증을 실패했습니다."),
    AUTH_INVALID_ACCESS_TOKEN(401, "AUTH_INVALID_ACCESS_TOKEN", "잘못된 액세스 토큰입니다."),
    AUTH_EXPIRED_ACCESS_TOKEN(401, "AUTH_EXPIRED_ACCESS_TOKEN", "액세스 토큰이 만료되었습니다."),
    AUTH_INVALID_SIGNATURE(401, "AUTH_INVALID_SIGNATURE", "잘못된 시그니처입니다."),
    AUTH_JWT_ERROR(401, "AUTH_JWT_ERROR", "JWT 관련 오류가 발생했습니다."),
    AUTH_MISSING_ID_IN_ACCESS_TOKEN(401, "AUTH_MISSING_ID_IN_ACCESS_TOKEN", "JWT 액세스 토큰에 ID가 누락되었습니다."),
    AUTH_TOKEN_EXTRACTION_FAILED(401, "AUTH_TOKEN_EXTRACTION_FAILED", "액세스 토큰 추출에 실패했습니다."),
    AUTH_SESSION_EXPIRED(440, "AUTH_SESSION_EXPIRED", "세션이 만료되었습니다. 다시 로그인 해주세요."),

    MEMBER_NOT_FOUND_ERR(404, "MEMBER_NOT_FOUND_ERR", "요청하신 사용자를 찾을 수 없습니다."),
    DIARY_ALREADY_EXISTS(409, "DIARY_ALREADY_EXISTS", "이미 해당 날짜에 작성된 일기가 있습니다."),
    DIARY_NOT_FOUND_ERR(404, "DIARY_NOT_FOUND_ERR", "요청하신 다이어리를 찾을 수 없습니다."),
    DIARY_LIKE_NOT_FOUND(404, "DIARY_LIKE_NOT_FOUND", "해당 다이어리에 좋아요를 누르지 않았습니다."),
    DIARY_LIKE_ALREADY_EXISTS(409, "DIARY_LIKE_ALREADY_EXISTS", "다이어리에 좋아요를 이미 눌렀습니다."),
    INVALID_IMAGE_FORMAT(400, "INVALID_IMAGE_FORMAT", "허용되지 않는 파일 형식입니다."),
    IMAGE_FILE_EMPTY(400, "IMAGE_FILE_EMPTY", "이미지 파일이 비어 있습니다."),

    // 아래는 추후에 사용할 수도 있을 것 같은 예외들을 정의 해두었다.
    VALIDATION_ERR(400, "VALIDATION_ERR", "잘못된 입력입니다. 올바른 값을 입력해주세요."),
    PERMISSION_ERR(403, "PERMISSION_ERR", "접근 권한이 없습니다. 관리자에게 문의하세요."),
    TIMEOUT_ERR(408, "TIMEOUT_ERR", "요청 시간이 초과되었습니다. 다시 시도해주세요."),
    ILLEGAL_ACCESS(400, "ILLEGAL_ACCESS", "잘못된 요청입니다. 올바른 경로로 접근해주세요."),
    BAD_REQUEST_ERR(400, "BAD_REQUEST_ERR", "요청 형식이 잘못되었습니다. 올바른 형식으로 요청해주세요."),
    UNSUPPORTED_MEDIA_ERR(415, "UNSUPPORTED_MEDIA_ERR", "지원되지 않는 미디어 타입입니다. 올바른 형식으로 요청해주세요."),
    MISSING_PARAM_ERR(400, "MISSING_PARAM_ERR", "필수 파라미터가 누락되었습니다. 모든 필드를 입력해주세요."),
    CONFLICT_ERR(409, "CONFLICT_ERR", "데이터 충돌이 발생했습니다. 요청을 다시 확인해주세요."),
    PASSWORD_POLICY_ERR(400, "PASSWORD_POLICY_ERR", "비밀번호가 정책을 위반합니다. 다른 비밀번호를 사용해주세요."),
    RATE_LIMIT_EXCEEDED(429, "RATE_LIMIT_EXCEEDED", "API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요."),
    ACCOUNT_LOCKED(423, "ACCOUNT_LOCKED", "사용자 계정이 잠겼습니다. 관리자에게 문의하세요.");
}

ServerErrorCode Enum

public enum ServerErrorCode implements ErrorCodeModel {
    SERVER_ERR(500, "SERVER_ERR", "서버 내부 오류가 발생했습니다. 잠시 후 다시 시도해주세요."),
    IMAGE_UPLOAD_FAILED(500, "IMAGE_UPLOAD_FAILED", "이미지 업로드에 실패했습니다."),
    REWARD_HISTORY_SAVE_FAILED(500, "REWARD_HISTORY_SAVE_FAILED", "일기 히스토리 저장에 실패했습니다."),

    // 아래는 추후에 사용할 수도 있을 것 같은 예외들을 정의 해두었다.
    DB_CONNECTION_ERR(503, "DB_CONNECTION_ERR", "서비스를 이용할 수 없습니다. 나중에 다시 시도해주세요."),
    EXTERNAL_API_ERR(502, "EXTERNAL_API_ERR", "외부 서비스와의 통신에 문제가 발생했습니다. 잠시 후 다시 시도해주세요."),
    OUT_OF_MEMORY(500, "OUT_OF_MEMORY", "서버에 메모리 부족이 발생했습니다. 잠시 후 다시 시도해주세요."),
    NOT_IMPLEMENTED(501, "NOT_IMPLEMENTED", "현재 지원되지 않는 기능입니다."),
    SERVICE_UNAVAILABLE(503, "SERVICE_UNAVAILABLE", "현재 서비스가 사용 불가합니다. 나중에 다시 시도해주세요.");
}

3. 예외 클래스 정의

각 예외 상황을 처리해줄 수 있는 클래스를 작성합니다.

RuntimeException을 상속하여 해당 예외를 잡을 수 있게 해주었습니다.

xxxErrorCode 클래스를 ErrorCodeModel를 이용해 작성해주었기 때문에 하나의 클래스로 처리가 가능하게 되었습니다.
시스템의 전반적인 예외를 담당하기 위해 SystemException이라는 클래스 명으로 지어주었습니다.

SystemException 클래스

@Getter
public class SystemException extends RuntimeException {
    private final ErrorCodeModel errorCode;

    public SystemException(ErrorCodeModel errorCode) {
        super(errorCode.getErrorMessage());
        this.errorCode = errorCode;
    }

    public SystemException(ErrorCodeModel errorCode, Throwable cause) {
        super(errorCode.getErrorMessage(), cause);
        this.errorCode = errorCode;
    }
}

4. GlobalExceptionHandler 정의

글로벌 예외 핸들러를 작성하여, 시스템 전반에서 발생하는 예외를 처리할 수 있도록 합니다.

@RestControllerAdvice 애너테이션을 사용하여 모든 컨트롤러에서 발생하는 예외를 하나의 중앙집중식 클래스에서 처리할 수 있도록 해줍니다.

GlobalExceptionHandler 클래스

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(SystemException.class)
    public ResponseEntity<Response> handleCustomException(SystemException exception, WebRequest request) {
        ErrorCodeModel errorCode = exception.getErrorCode();
        Response response = Response.error(errorCode);
        return new ResponseEntity<>(response, HttpStatus.valueOf(errorCode.getStatusCode()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<Response> handleGlobalException(Exception exception, WebRequest request) {
        Response response = new Response(
                HttpStatus.INTERNAL_SERVER_ERROR.value(), // status: 500
                "INTERNAL_SERVER_ERROR",
                "예기치 않은 오류가 발생했습니다."
        );
        return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

5. 예외 발생 시 로직 수정

필요했던 각 예외 상황에 맞는 예외를 던지도록 기존 로직을 수정하였습니다.

예외 발생을 위한 구현체 전달 방법(예시)

// 클라이언트에서 요청한 사용자를 찾을 수 없을 때
throw new SystemException(ClientErrorCode.MEMBER_NOT_FOUND_ERR)

// 클라이언트에서 인증되지 않은 사용자가 요청을 할 때
new SystemException(ClientErrorCode.UNAUTHORIZED)

// 서버가 이미지 저장을 실패할 때
throw new SystemException(ServerErrorCode.IMAGE_UPLOAD_FAILED)

6. 예외 리스트 정리

클라이언트와 서버에서 발생할 수 있는 예외를 표로 정리하여, 팀원들과 함께 볼 수 있도록 공유하였습니다.

 


참고