# 개요

Spring Boot 프로젝트에서 API 에러 응답을 표준화하기 위해 ErrorCode enum을 설계하면서 정리한 내용이다. enum의 코드 컨벤션부터, "왜 enum 생성자는 private이어야 하는가", "enum 상수는 메모리에 어떻게 적재되는가"까지 다룬다.

# ErrorCode enum 설계

# 컨벤션: {도메인}_{상황}

에러 코드 이름은 {도메인}_{상황} 형태로 짓는다.

  • 도메인: USER, AUTH, TRACK 등 — 어느 영역에서 발생한 에러인지
  • 상황: 발생 원인 — 아래 표 참고
상황 (suffix) HTTP Status 의미 / 예시
NOT_FOUND 404 리소스 없음 (USER_NOT_FOUND, TRACK_NOT_FOUND)
DUPLICATE 409 유니크 제약 위반 (USER_NICKNAME_DUPLICATE)
INVALID 400 형식/값 자체가 잘못됨 (USER_NICKNAME_INVALID)
EXPIRED 401 토큰/세션 만료 (AUTH_TOKEN_EXPIRED)
UNAUTHORIZED 401 인증 안 됨 (AUTH_UNAUTHORIZED)
FORBIDDEN 403 인증은 됐지만 권한 없음
ALREADY_EXISTS 409 상태 충돌 (TRACK_HYPE_ALREADY_EXISTS)
INTERNAL_ERROR 500 공통 fallback (INTERNAL_SERVER_ERROR)

처음부터 모든 코드를 다 정의할 필요는 없다. 진행 중인 기능에서 필요한 코드부터 추가하는 점진적 방식이 일반적이다.

# enum 정의

public enum ErrorCode {
    // USER domain
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."),
    USER_NICKNAME_DUPLICATE(HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."),
    USER_NICKNAME_INVALID(HttpStatus.BAD_REQUEST, "닉네임 형식이 올바르지 않습니다."),

    // Auth domain
    AUTH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "토큰이 만료되었습니다."),
    AUTH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다."),

    // 500 common
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");

    private final HttpStatus httpStatus;
    private final String message;

    ErrorCode(HttpStatus httpStatus, String message) {
        this.httpStatus = httpStatus;
        this.message = message;
    }
}

각 상수가 (HttpStatus, String) 생성자를 호출하면서 자신만의 httpStatus, message 값을 갖는다. 실제로 던질 때는 이 enum을 그대로 던지는 게 아니라, RuntimeException을 상속한 BusinessException에 담아서 던진다.

throw new BusinessException(ErrorCode.USER_NICKNAME_DUPLICATE);

ErrorCode는 데이터(코드/상태/메시지) 묶음일 뿐 Throwable이 아니므로, 실제로 예외를 발생시키려면 이를 감싸는 예외 클래스가 필요하다.

이름 충돌 주의

enum 이름을 Error로 짓지 말 것. java.lang.Error(JVM 심각 오류용 클래스)와 이름이 겹쳐 IDE 자동완성·가독성에서 혼란을 준다. ErrorCode처럼 구체적인 이름을 사용한다.

# enum 생성자는 왜 private이어야 하는가

ErrorCode(HttpStatus httpStatus, String message) { ... }   // OK (private 생략 가능)
public ErrorCode(HttpStatus httpStatus, String message) { ... }  // 컴파일 에러

enum 생성자에 public/protected를 붙이면 컴파일 에러가 난다.

enum은 "정해진 상수들 외에는 인스턴스가 절대 존재하면 안 된다"는 설계 목표를 가진다. 이를 위해 컴파일러는 두 가지를 강제한다.

  1. enum 본문에 선언한 상수(USER_NOT_FOUND 등)는 자동으로 static final 필드가 되어, "이게 전부다"라는 고정 집합을 만든다.
  2. 생성자를 private으로 강제하여, 외부에서 new ErrorCode(...)로 추가 인스턴스를 만들 방법을 차단한다.

static final 필드와 private 생성자는 같은 목표를 위한 별개의 두 장치다 — 일반 싱글턴 패턴이 private 생성자 + static final INSTANCE를 함께 쓰는 것과 같은 맥락이다.

# enum 인스턴스의 메모리 구조

enum 상수도 결국 해당 enum 클래스의 인스턴스이고, 자신이 선언한 필드(httpStatus, message)를 그대로 가진다.

USER_NOT_FOUND 인스턴스
├── name = "USER_NOT_FOUND"   (Enum 부모가 자동 관리)
├── ordinal = 0                (Enum 부모가 자동 관리)
├── httpStatus = → HttpStatus.NOT_FOUND 참조
└── message = "사용자를 찾을 수 없습니다."

HttpStatus도 enum이므로, httpStatus 필드는 HttpStatus.NOT_FOUND라는 또 다른 싱글턴 인스턴스에 대한 참조다.

# 클래스 로딩 시점

enum 상수들은 "애플리케이션 시작 시점"이 아니라, **해당 enum 클래스가 처음 참조되는 시점(클래스 로딩 시점)**에 한꺼번에 생성된다. Java의 클래스 로딩은 기본적으로 지연 로딩(lazy)이기 때문이다.

  • ErrorCode 클래스가 코드 어디선가 처음 사용될 때(ErrorCode.USER_NOT_FOUND 접근 등) JVM이 클래스를 로드
  • 로딩 시점에 static 초기화 블록이 실행되며 모든 상수 인스턴스가 한 번에 생성됨
  • 이후 JVM 종료까지 동일 인스턴스를 계속 참조 (재생성 없음)

타입 100개 × 상수 3개라면, 모두 한 번씩 참조된다는 가정 하에 총 300개의 인스턴스가 메모리에 적재된다.

# GC와의 관계

enum 인스턴스는 힙(Heap)에 생성되지만, GC의 대상이 되지 않는다 (정확히는 "수거되지 않는다").

  • GC는 도달 가능성(reachability) 기준으로 객체를 수거하는데, enum 상수는 클래스의 static final 필드가 참조하고 있다.
  • 이 static 필드는 GC Root로 취급되므로, enum 인스턴스는 항상 "도달 가능한 살아있는 객체"로 판정된다.
  • 클래스 자체가 언로드되지 않는 한 (보통 JVM 종료 시까지 유지) 그 인스턴스들도 함께 살아있다.

정리하면, enum 인스턴스(객체 데이터)는 힙에 위치하고 static final 필드(참조)와 클래스 메타정보는 메타스페이스(Metaspace)에 위치하지만, 결과적으로는 애플리케이션 전체 수명 동안 거의 영구히 상주하는 작은 싱글턴들의 모음이라고 이해하면 된다. 따라서 enum을 적당히 사용하는 한 메모리 부담은 무시할 수준이다.

# static 참조 → 힙 객체 → 필드들의 참조 구조

USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_NOT_FOUND")처럼 정의했을 때, 메타스페이스의 static 참조와 힙 객체, 그리고 그 객체가 들고 있는 필드들의 관계를 그림으로 보면 다음과 같다.

[메타스페이스]                          [힙]
ErrorCode.USER_NOT_FOUND (static final 참조)
        │
        └──────────────────────────▶  USER_NOT_FOUND 인스턴스 (ErrorCode 객체)
                                            ├── name = "USER_NOT_FOUND"
                                            ├── ordinal = 0
                                            ├── httpStatus ──▶ HttpStatus.NOT_FOUND 인스턴스 (힙의 enum 객체)
                                            └── message ──▶ "USER_NOT_FOUND" (String 객체, String Pool)
  • ErrorCode.USER_NOT_FOUND라는 static 참조는 메타스페이스에 있고, 실제 ErrorCode 객체(인스턴스)는 에 있다.
  • 그 힙 객체 안의 httpStatus, message 필드는 각각 또 다른 객체(HttpStatus.NOT_FOUND enum 싱글턴, String 리터럴)에 대한 참조다.
  • 즉 "static 참조 → 힙 객체 → 그 안에서 다시 다른 객체들에 대한 참조"로 이어지는 구조이며, 모두 reachable이라 GC 대상에서 제외된다.

# 런타임 코드는 항상 static을 거쳐서 힙에 접근한다

ErrorCode code = ErrorCode.USER_NOT_FOUND;

이 코드는 바이트코드 레벨에서 getstatic 명령어로 컴파일된다.

getstatic com/rta/dignify/global/exception/ErrorCode.USER_NOT_FOUND : Lcom/rta/dignify/global/exception/ErrorCode;

흐름:

  1. JVM이 getstatic을 실행 → ErrorCode 클래스의 static 필드 영역(메타스페이스)에서 USER_NOT_FOUND 슬롯을 찾는다.
  2. 그 슬롯에 들어있는 값 = 힙에 있는 ErrorCode 객체의 참조(주소).
  3. 그 참조값을 로컬 변수 code에 복사한다.

ErrorCode.USER_NOT_FOUND를 코드 어디서 쓰든 항상 같은 static 슬롯을 거쳐서 동일한 힙 객체를 가리키게 되고, 그래서 enum은 == 비교가 항상 안전하다. static 필드는 "주소록의 항목"이고 힙 객체는 "실제 건물"이라 비유할 수 있다 — 코드가 건물 주소(힙 주소)를 직접 들고 있는 게 아니라, 매번 주소록을 거쳐서 찾아가는 셈이다.