본문 바로가기
💻 뚝딱뚝딱/북북클럽

[개발일지 #014] 글로벌 예외처리 및 API 응답 포맷 통일

by 뚜루리 2025. 4. 24.
728x90
320x100

🎯 오늘의 목표

  • 글로벌 예외처리 및 API 응답 포맷 통일

⚙️ 진행한 작업

  • 글로벌 예외처리
  • API 응답 포맷 통일

🛠️ 개발내용

📌 공통 응답 구조 ApiResponse<T> 설계 (ApiResponse.java)

package ddururi.bookbookclub.global.common;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class ApiResponse<T> {
    private boolean success;
    private T data;
    private String message;

    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, data, null);
    }

    public static <T> ApiResponse<T> success(T data, String message) {
        return new ApiResponse<>(true, data, message);
    }

    public static <T> ApiResponse<T> fail(String message) {
        return new ApiResponse<>(false, null, message);
    }
}

→ API 응답을 성공/실패 관계없이 동일한 구조로 반환되도록 함.

 

📌 ErrorCode enum 클래스 정의 (ErrorCode.java)

package ddururi.bookbookclub.global.exception;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ErrorCode {
    EMAIL_NOT_VERIFIED("EMAIL_NOT_VERIFIED", "이메일 인증이 완료되지 않았습니다."),
    USER_WITHDRAWN("USER_WITHDRAWN", "탈퇴한 사용자입니다."),
    INVALID_PASSWORD("INVALID_PASSWORD", "비밀번호가 일치하지 않습니다."),
    REJOIN_RESTRICTED("REJOIN_RESTRICTED", "탈퇴 후 6개월 이내에는 재가입할 수 없습니다."),
    USER_NOT_FOUND("USER_NOT_FOUND", "사용자를 찾을 수 없습니다."),
    DUPLICATE_EMAIL("DUPLICATE_EMAIL", "이미 가입된 이메일입니다."),
    DUPLICATE_NICKNAME("DUPLICATE_NICKNAME", "이미 사용 중인 닉네임입니다."),
    INTERNAL_SERVER_ERROR("INTERNAL_SERVER_ERROR", "알 수 없는 서버 오류가 발생했습니다.");

    private final String code;
    private final String message;
}

 

📌 @RestControllerAdvice 기반의 글로벌 예외 처리기 작성 (GlobalExceptionHandler.java)

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(EmailNotVerifiedException.class)
    public ResponseEntity<ApiResponse<Void>> handleEmailNotVerified(EmailNotVerifiedException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

    @ExceptionHandler(UserWithdrawnException.class)
    public ResponseEntity<ApiResponse<Void>> handleWithdrawn(UserWithdrawnException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

    @ExceptionHandler(InvalidPasswordException.class)
    public ResponseEntity<ApiResponse<Void>> handleInvalidPassword(InvalidPasswordException e) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

    @ExceptionHandler(RejoinRestrictionException.class)
    public ResponseEntity<ApiResponse<Void>> handleRejoinRestricted(RejoinRestrictionException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<?>> handleAllException(Exception ex) {
        ex.printStackTrace(); // 운영 환경에서는 로거로 처리
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.fail(ErrorCode.INTERNAL_SERVER_ERROR));
    }
    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleUserNotFound(UserNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

    @ExceptionHandler(DuplicateEmailException.class)
    public ResponseEntity<ApiResponse<Void>> handleDuplicateEmail(DuplicateEmailException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

    @ExceptionHandler(DuplicateNicknameException.class)
    public ResponseEntity<ApiResponse<Void>> handleDuplicateNickname(DuplicateNicknameException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(ApiResponse.fail(e.getErrorCode()));
    }

}

예외 발생 시 ApiResponse.fail() 형태로 자동 변환하여 통일된 응답 제공

 

📌 상황에 맞는 커스텀 예외 정의 (Ex. EmailNotVerifiedException.java)

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

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

→ 기존 IllegalStateException, IllegalArgumentException을 도메인 기반 커스텀 예외로 치환하여 예외 의미를 더 명확하게 전달

 

📌 커스텀 예외 적용 (Ex. UserService.java)

    private void validateEmailVerification(String email) {
        if (!emailVerificationService.isEmailVerified(email)) {
            throw new EmailNotVerifiedException();
        }
    }

 

장점

항목 설명
응답 일관성 확보 모든 API 응답이 같은 구조 (success, data, message)로 반환돼, 프론트 개발자 입장에서 처리하기 쉬움
중복 코드 제거 컨트롤러마다 try-catch 하지 않아도 됨
유지보수 용이성 새로운 예외 발생 시 handler만 추가하면 됨
도메인 기반 예외 분리 UserWithdrawnException, InvalidPasswordException 같은 구체적이고 읽기 쉬운 구조
실서버 대응 용이 Exception.class에서 로그 출력 처리 → 원인 추적 가능

 

728x90
320x100