enum이란
enum은 상수(변하지 않는 고정된 값)의 집합을 표현하는 열거형 클래스다.
상수를 단순히 `public static final int`로 정의하지 않고 enum으로 정의하면, 컴파일러가 여러 안전장치를 자동으로 끼워준다.
그 안전장치들이 어떻게 만들어지는지, 왜 enum이 "싱글톤처럼 동작한다"고 불리는지 정리해봤다.
enum은 왜 안전한가
1. 타입 체크 가능
상수를 int나 String으로 관리하면, 메서드 파라미터로 정해진 상수 외의 다른 값이 넘어와도 컴파일러가 막지 못해 런타임에 가서야 터진다.
반면 반면 enum은 타입 자체가 정해져 있어 잘못된 값이 넘어오는 순간 컴파일 에러로 막힌다.
enum Status {
WAITING, PROCESSING, DONE
}
void process(Status status) { ... }
process(999); // 컴파일 에러
process("WAITING"); // 컴파일 에러
process(Status.WAITING); // 정상
2. new로 생성 불가 / 정해진 값만 존재 / 반드시 먼저 초기화
// 개발자가 작성
enum Status {
WAITING, PROCESSING, DONE
}
// 컴파일러가 변환
static {
WAITING = new Status("WAITING", 0);
PROCESSING = new Status("PROCESSING", 1);
DONE = new Status("DONE", 2);
}
흐름을 정리하면 다음과 같다.
컴파일러가 private 생성자 자동 생성
↓
외부에서 new Status() 불가
↓
컴파일러만 static {} 블록에서 new로 생성
↓
클래스 로딩 시점에 딱 1번 미리 생성
↓
정해진 상수만 존재, 반드시 먼저 초기화됨
의문: Oracle 문서는 두 가지 생성자를 허용하는데, 실제는?
Oracle 공식 튜토리얼 문서에는 enum 생성자 관련하여 다음과 같이 적혀 있다.
(Oracle Java Tutorial - Enum Types)

"enum 타입의 생성자는 반드시 package-private 또는 private 접근 제어자를 가져야 한다. 이 생성자는 enum 본문 시작 부분에 정의된 상수들을 자동으로 생성한다. 개발자가 직접 enum 생성자를 호출할 수는 없다."
그래서 package-private과 private 둘 다 가능한 줄 알았다.
그런데 실제로 생성자를 따로 작성하지 않은 enum 코드를 컴파일하고 javap -p -c로 바이트코드를 확인해보니, 결과가 달랐다.
확인에 사용된 코드
public enum Status {
WAITING, PROCESSING, DONE;
}
javap -p -c Status.class 결과 중 일부
public final class Status extends java.lang.Enum<Status> {
public static final Status WAITING;
public static final Status PROCESSING;
public static final Status DONE;
...
private Status(); // ← 소스에 안 적었는데도 private 생성자가 자동 생성됨
Code:
0: aload_0
1: aload_1
2: iload_2
3: invokespecial #28 // Method java/lang/Enum."<init>":(Ljava/lang/String;I)V
6: return
static {}; // ← static 블록에서 상수들을 new로 생성
Code:
0: new #1 // class Status
3: dup
4: ldc #32 // String WAITING
6: iconst_0
7: invokespecial #33 // Method "<init>":(Ljava/lang/String;I)V
10: putstatic #3 // Field WAITING:LStatus;
...
}
생성자를 아예 선언하지 않았는데도 `private Status();`가 박혀 있다. 분명히 default(package-private)도 허용된다고 했는데, 결과는 private이었다.
JLS 8.9.2 확인
JLS(Java Language Specification) 8.9.2를 보면 답을 찾을 수 있다.
(JLS 8.9.2 Enum Body Declarations)

"enum 선언 안에서 접근 제어자를 생략한 생성자는 (default가 아니라) private으로 해석된다."
즉 소스 코드에서 접근 제어자를 생략해서 작성하면 일반 클래스와 달리 package-private이 아니라 private으로 해석된다고 명세에 적혀 있다.
그럼 왜 생성자를 private으로 강등시킬까?
핵심은 JVM 차원에서 enum 인스턴스의 유일성을 보장하기 위해서다.
그리고 이 유일성 보장이 곧 enum이 "싱글톤처럼 동작한다" 고 불리는 이유다.
싱글톤은 하나의 JVM(프로세스) 안에서 인스턴스가 딱 1개만 존재하는 것이다. 그 조건은 두 가지다.
1. 단 하나의 인스턴스가 만들어진다.
- 클래스 로딩 시점에 static {} 블록에서 1회 생성
2. 그 외 어떤 경로로도 추가 인스턴스가 만들어질 수 없다.
- 직접 new 호출 → private 생성자로 차단
- 그 외 우회 경로(리플렉션, 역직렬화, clone) → JVM 차원에서 별도 차단
이 중 첫 번째 통로인 직접 new 호출을 막는 장치가 바로 private 생성자다. 만약 enum 생성자가 package-private이면, 같은 패키지 안의 다른 클래스가 그냥 new Status(...)로 새 인스턴스를 만들어버릴 수 있다.
그래서 enum 생성자의 접근 제어자를 private 또는 생략(= 암묵적 private) 으로만 허용한다. 개발자가 public이나 protected로 선언하면 컴파일 에러가 난다.
equals()가 아니라 ==로 비교해도 true가 나온다. 같은 주소를 가리키는 같은 객체이기 때문이다.
Status.WAITING == Status.WAITING // 항상 true
직접 구현한 싱글톤은 리플렉션, 역직렬화, clone() 같은 우회로로 뚫릴 수 있지만, enum은 이 모든 경로까지 막혀 있다. 그 차단 방식은 아래와 같다.
enum 인스턴스의 유일성은 어떻게 보장될까
private 생성자만으로는 부족하다. 자바에는 생성자를 우회해서 객체를 만들 수 있는 경로가 더 있기 때문이다.
enum은 이 경로들도 전부 막혀 있다.
1. 같은 패키지 내에서의 직접 호출
생성자가 package-private이면 같은 패키지의 다른 클래스가 그냥 호출할 수 있다. private이면 같은 패키지여도 호출 불가다.
2. 리플렉션을 통한 호출
Constructor.newInstance()로 우회 생성을 시도할 수 있다. JVM은 enum 타입에 대해 이 호출 자체를 명시적으로 차단한다.
Constructor.newInstance()가 enum 타입이면 IllegalArgumentException("Cannot reflectively create enum objects")을 던지도록 구현되어 있다.
(https://github.com/openjdk/jdk/blob/master/src/java.base/share/classes/java/lang/reflect/Constructor.java)

3. 직렬화/역직렬화
일반 클래스는 역직렬화 시 생성자를 거치지 않고 객체가 만들어질 수 있다.
그래서 enum은 직렬화 메커니즘에서 특별 취급되어, 역직렬화 시 `Enum.valueOf()`로만 인스턴스를 가져오도록 강제되어 있다.

"enum 상수를 역직렬화할 때, ObjectInputStream은 스트림에서 상수의 이름만 읽어온다. 그리고 그 enum 타입과 이름을 Enum.valueOf()에 넘겨서, 이미 존재하는 상수를 찾아오는 방식으로 복원한다."
4. clone()
일반 객체는 clone()으로 똑같은 인스턴스를 하나 더 만들 수 있다. Enum.clone()은 final로 선언되어 있고 호출 시 CloneNotSupportedException을 던지도록 되어 있어, 오버라이드 자체가 불가능하다.
JVM 메모리 구조 관점에서 (static 관련)
enum 상수가 JVM 안에 왜 딱 1개밖에 존재할 수 없는지는 메모리 구조를 보면 명확해진다.
ClassLoader
└── Class 객체 (Status.class) — 힙에 존재
├── static final WAITING → Status 인스턴스 (힙)
├── static final PROCESSING → Status 인스턴스 (힙)
└── static final DONE → Status 인스턴스 (힙)
- static 블록은 해당 enum 클래스가 처음 사용되어 로딩되는 시점에 딱 한 번 실행된다.
- Status.class 객체 안에 WAITING, PROCESSING, DONE의 참조값이 저장된다.
- 각 Status 인스턴스 객체는 힙의 별도 공간에 존재하고, Class 객체가 그것을 참조하고 있는 구조다.
- Class 객체는 ClassLoader가 살아있는 한 함께 존재하기 때문에, enum 상수는 사실상 프로세스 종료 전까지 GC 대상이 되지 않는다.
즉, enum 상수는 클래스가 처음 로딩된 순간부터 프로세스 종료 시점까지 같은 주소에 살아있는 객체다.
정리
- enum은 상수의 집합을 표현하는 열거형 클래스이다.
- 타입 체크가 컴파일 시점에 이루어져, 잘못된 값이 들어갈 수 없다.
- enum의 생성자를 private으로 강등시키는 이유는, JVM 차원에서 enum 인스턴스 유일성 보장 체계를 작동시키기 위해서이다.
- Class 객체가 ClassLoader 생명주기와 함께 가기 때문에, enum 상수는 프로세스 종료 전까지 GC되지 않고 같은 주소를 유지한다.
'Java' 카테고리의 다른 글
| [Java] Arrays.copyOf가 얕은 복사인 이유 — 어셈블리까지 내려가기 (0) | 2026.05.19 |
|---|---|
| [Java] 다형성, 컴포지션, DI - 세 개념을 한 줄로 잇기 (0) | 2026.05.17 |
| [Java] equals() & hashCode() 깊게 보기 - 31의 의미, String 내부 동작 (2) | 2026.05.12 |
| [Java] synchronized 동작 원리 - 모니터 락, CAS (0) | 2026.05.12 |