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

[개발일지 #039] 리팩토링 - (1) 공통 응답 구조, 공통 예외 처리

by 뚜루리 2025. 6. 13.
728x90
320x100

🎯 오늘의 개발 내용 (요약)

  • 공통 응답 구조 리팩토링
  • 공통 예외 처리 리팩토링

🛠️ 개발내용

MSA 아키텍처를 도입하고, 공통모듈까지 만들다 보니, 기존의 공통 응답 구조와 공통 예외 처리가 부족한 부분이 보여 리팩토링 하게 되었다. 



📌 공통 응답 구조 리팩토링

package com.bookbookclub.common.response;


import com.bookbookclub.common.exception.BaseErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;

/**
 * API 응답 포맷을 표준화하는 클래스
 *
 */
@Getter
public class ApiResponse<T> {
    private final boolean success;
    private final String code;
    private final String message;
    private final T data;

    private ApiResponse(boolean success, String code, String message, T data) {
        this.success = success;
        this.code = code;
        this.message = message;
        this.data = data;
    }

    /**
     * 성공 응답 생성
     */
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, null, null, data);
    }

    /**
     * 성공 응답 생성 (메시지 포함)
     */
    public static <T> ApiResponse<T> success(String message, T data) {
        return new ApiResponse<>(true, "200", message, data);
    }

    /**
     * 성공 응답 생성 (메시지만 포함, data 없이)
     */
    public static <T> ApiResponse<T> success(String message) {
        return new ApiResponse<>(true, "200", message, null);
    }

    /**
     * 에러 응답 생성 (비즈니스/공통 예외)
     */
    public static <T> ApiResponse<T> error(BaseErrorCode errorCode) {
        return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null);
    }

    /**
     * 실패 응답 생성 (에러 코드와 동일)
     */
    public static <T> ApiResponse<T> fail(BaseErrorCode errorCode) {
        return error(errorCode); // alias
    }
}

 

✅ 공통 응답 구조를 사용하는 이유

장점 설명
✅ 일관성 모든 API 응답을 같은 포맷으로 처리할 수 있어 클라이언트 파싱 로직 단순화
✅ 가독성 응답을 보자마자 어떤 상황인지 명확하게 알 수 있음 (success, code, message)
✅ 유지보수 용이 에러 메시지를 enum (BaseErrorCode)으로 관리하므로 메시지 변경이 쉬움
✅ 유연함 성공 메시지를 필요에 따라 포함할 수도, 생략할 수도 있음
✅ 실무에 적합 실제 쿠팡, 카카오 등 대형 서비스에서도 이런 구조 많이 씀 (공통 응답 포맷)

 

💬 응답 예시

1. ✅ 성공 응답 (데이터 O, 메시지 O)

{
  "success": true,
  "code": "200",
  "message": "회원 정보 조회 성공",
  "data": {
    "userId": 1,
    "nickname": "슬기짱",
    "bio": "책 좋아하는 백엔드 개발자"
  }
}

 

2. ✅ 성공 응답 (데이터 X, 메시지만 O)

{
  "success": true,
  "code": "200",
  "message": "이메일 인증이 완료되었습니다.",
  "data": null
}

 

3. ❌ 실패 응답 (비즈니스 예외)

{
  "success": false,
  "code": "USER_001",
  "message": "이미 가입된 이메일입니다.",
  "data": null
}

 

 


📌 공통 예외 처리 리팩토링

바로 전 개발일지에거 공통 예외처리를 분리 했는데, 생각해보니 과도하게 에러 클래스가 많이 생성되는 방법이라는 생각이 들어 좀 더 유지보수성을 높일 수 있는 방법으로 리팩토링 해보았다.

 

BaseErrorCode

package com.bookbookclub.common.exception;

/**
 * 모든 에러 코드 Enum이 구현해야 하는 공통 인터페이스
 *
 * - HttpStatus: HTTP 상태 코드 (예: 400, 404, 500 등)
 * - code: 클라이언트/프론트에 전달되는 고유 에러 식별자 (예: G001, U001 등)
 * - message: 사용자에게 보여질 에러 메시지
 *
 * 각 도메인별 에러코드 Enum (예: GlobalErrorCode, EmailVerificationErrorCode 등)에서 구현
 */
public interface BaseErrorCode {

    int getStatusCode();

    String getCode();
    String getMessage();
}

 

BusinessException

기존에 예외마다 클래스를 생성했다가 에러 코드로 구분해주는게 좋겠다는 생각에 비지니스 관련 예외는 이 클래스를 통해서 하도록 생성했다.

package com.bookbookclub.common.exception;


/**
 * 모든 도메인 비즈니스 예외의 상위 클래스
 *
 * - RuntimeException을 상속하여 스프링의 예외 전파 체계에 맞춤
 * - BaseErrorCode를 포함하여 에러 코드, 상태 코드, 메시지를 추출 가능
 * - GlobalExceptionHandler에서 이 클래스를 기준으로 예외를 처리함
 *
 * 도메인별 예외는 이 클래스를 상속하여 구체화함
 */
public class BusinessException extends RuntimeException {

    private final BaseErrorCode errorCode;

    public BusinessException(BaseErrorCode errorCode) {
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }

    public BaseErrorCode getErrorCode() {
        return errorCode;
    }

    public int getStatusCode() {
        return errorCode.getStatusCode();
    }

    public String getErrorCodeValue() {
        return errorCode.getCode();
    }

    public String getErrorMessage() {
        return errorCode.getMessage();
    }
}

 

 

 

GlobalErrorCode

404, 500 과 같은 시스템 전역 에러 코드를 따로 분리하였다.

package com.bookbookclub.common.exception;


/**
 * 시스템 전역에서 사용되는 공통 에러 코드 정의
 *
 * - 각 에러 항목은 HTTP 상태 코드, 에러 코드 문자열, 사용자 메시지를 포함한다.
 * - 도메인에 속하지 않는 일반적인 예외 상황(400, 403, 404, 500 등)에 사용한다.
 * - BusinessException과 GlobalExceptionHandler에서 참조된다.
 */
public enum GlobalErrorCode implements BaseErrorCode {

    INVALID_INPUT_VALUE(400, "G001", "잘못된 입력값입니다."),
    INTERNAL_SERVER_ERROR(500, "G002", "서버 내부 오류입니다."),
    ACCESS_DENIED(403, "G003", "접근이 거부되었습니다."),

    NOT_FOUND(404, "G001", "요청한 리소스를 찾을 수 없습니다."),
    METHOD_NOT_ALLOWED(405, "G002", "지원하지 않는 HTTP 메서드입니다.");


    private final int statusCode;
    private final String code;
    private final String message;

    GlobalErrorCode(int statusCode, String code, String message) {
        this.statusCode = statusCode;
        this.code = code;
        this.message = message;
    }


    @Override
    public int getStatusCode() {
        return statusCode;
    }

    @Override
    public String getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
}

 

 

AuthException

일단 bbc-user-service에 있는 사용자 예외 처리를 AuthException 클래스로 묶어서 관리하였다.

package com.bookbookclub.bbc_user_service.user.exception;

import com.bookbookclub.common.exception.BaseErrorCode;
import com.bookbookclub.common.exception.BusinessException;

/**
 * 인증/인가 관련 예외를 통합 처리하는 클래스
 */
public class AuthException extends BusinessException {
    public AuthException(BaseErrorCode errorCode) {
        super(errorCode);
    }
}

 

UserErrorCode

에러코드도 bbc-user-serivce에 포함하는 모든 에러코드를 이 클래스에서 관리하였다. bbc-user-serivce에서 발생할 수 있는 모든 사용자 예외 코드를 이 클래스에서만 관리하다보니 비대하게 보이기도 하는데, 아직은 분리할 필요성을 느끼지 않아 코드 분리까진 하지 않았다.

처음엔 에러메시지를 영문과 한글로만 제공하는 방식으로 enum클래스를 만들었는데, 그러다보니 분명 사용자 예외 처리 에러 인데도 불구하고 200 응답코드를 뱉게 되는 상황이 발생되어, 그런 부분들을 변경하고 싶어 에러와 연관 있는 에러코드도 RESTful 하게 구현하려고 노력 하였다.

package com.bookbookclub.bbc_user_service.user.exception;

import com.bookbookclub.common.exception.BaseErrorCode;
import lombok.Getter;
import org.springframework.http.HttpStatus;

/**
 * 사용자 도메인 전용 에러 코드 정의
 */
@Getter
public enum UserErrorCode implements BaseErrorCode {

    // 사용자 기본 정보 관련 (U001 ~ U099)
    USER_NOT_FOUND(404, "U001", "회원을 찾을 수 없습니다."),
    USER_WITHDRAWN(403, "U002", "탈퇴한 회원입니다."),
    DUPLICATE_EMAIL(409, "U003", "이미 사용 중인 이메일입니다."),
    DUPLICATE_NICKNAME(409, "U004", "이미 사용 중인 닉네임입니다."),
    INVALID_PASSWORD(401, "U005", "비밀번호가 일치하지 않습니다."),
    INVALID_TOKEN(401, "U006", "유효하지 않은 토큰입니다."),
    REJOIN_RESTRICTED(403, "U007", "재가입이 제한된 계정입니다."),
    INVALID_REFRESH_TOKEN(401, "U008", "Refresh Token이 유효하지 않습니다."),

    // 프로필 이미지 관련 (U100 ~ U199)
    INVALID_PROFILE_IMAGE_TYPE(400, "U100", "이미지 파일만 업로드 가능합니다."),
    PROFILE_IMAGE_TOO_LARGE(400, "U101", "파일 용량은 5MB 이하만 가능합니다."),
    PROFILE_IMAGE_UPLOAD_FAIL(500, "U102", "프로필 이미지 업로드에 실패했습니다."),
    PROFILE_IMAGE_DELETE_FAIL(500, "U103", "프로필 이미지 삭제에 실패했습니다."),
    INVALID_PROFILE_IMAGE_URL(400, "U104","유효하지 않은 프로필 이미지 URL입니다."),

    // 이메일 인증 관련 (U200 ~ U299)
    EMAIL_VERIFICATION_INVALID_TOKEN(400, "U200", "유효하지 않은 이메일 인증 토큰입니다."),
    EMAIL_VERIFICATION_LIMIT_EXCEEDED(429, "U201", "이메일 인증 시도 횟수가 초과되었습니다."),

    // 유효성 검증 관련 (U300 ~ U399)
    INVALID_EMAIL_FORMAT(400, "U300", "이메일 형식이 올바르지 않습니다."),
    EMAIL_DOMAIN_NOT_ALLOWED(400, "U301", "허용되지 않은 이메일 도메인입니다."),
    INVALID_NICKNAME_FORMAT(400, "U310", "닉네임은 한글/영문/숫자 2~12자 이내여야 하며, 공백은 사용할 수 없습니다."),
    BANNED_WORD_DETECTED(400, "U311", "금칙어가 포함되어 있습니다."),
    INVALID_PASSWORD_FORMAT(400, "U320", "비밀번호는 영문, 숫자, 특수문자를 포함한 8~20자여야 합니다."),
    TOO_SIMPLE_PASSWORD(400, "U321", "비밀번호에 연속되거나 반복되는 문자를 사용할 수 없습니다.");

    private final int statusCode;
    private final String code;
    private final String message;

    UserErrorCode(int statusCode, String code, String message) {
        this.statusCode = statusCode;
        this.code = code;
        this.message = message;
    }

    @Override
    public int getStatusCode() {
        return statusCode;
    }

    @Override
    public String getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
}

 

 사용자 예외 처리 사용

만든 사용자 예외 처리는 아래와 같이 사용한다. 예외 상황마다 각 별도의 클래스가 있던 것과 달리 인증/인가/유저에 관련된 클래스는 AuthExcepiton에서 통합처리하고 그대신 에러코드만 에러 상황에 맞춰 내보내주는 방식이다.

    /**
     * 유저 ID로 유저 조회
     */
    public User findById(Long userId) {
        return userRepository.findById(userId)
                .orElseThrow(() -> new AuthException(UserErrorCode.USER_NOT_FOUND));
    }
728x90
320x100