Enum과 어노테이션 — 상수를 다루는 더 좋은 방법
주문 상태, 요일, 권한 등급 같은 "종류"를 코드로 표현해야 할 때,
static final int로 선언하면 되긴 한다. 그런데 아무 정수나 넣어도 컴파일이 되는 이 방식이 정말 안전할까?
Enum은 타입 안전한 상수 그룹 이에요. 어노테이션(Annotation)은 코드에 메타데이터를 붙이는 장치 입니다. 둘 다 Java 5에서 도입됐고, 지금은 없으면 안 될 핵심 기능이에요.
static final int의 문제
Java 5 이전 방식부터 볼게요.
public class Season {
public static final int SPRING = 0;
public static final int SUMMER = 1;
public static final int FALL = 2;
public static final int WINTER = 3;
}
이 방식에는 세 가지 심각한 문제가 있어요.
- **타입 안전성 없음 **:
setSeason(42)처럼 아무 정수나 넣어도 컴파일이 됩니다 - ** 의미 불명 **:
System.out.println(season)출력이2일 때, 이게FALL인지 알 수 없어요 - ** 상수 그룹 충돌 **:
Fruit.APPLE == Company.APPLE이true가 됩니다 (둘 다 int 0)
Enum은 이 세 가지를 한 번에 해결해요.
Enum 기본
public enum Season {
SPRING, SUMMER, FALL, WINTER
}
타입이 Season이므로 setSeason(42)는 컴파일 에러가 나요. 출력하면 숫자가 아니라 SPRING 같은 이름이 나옵니다. 다른 Enum과 비교할 수도 없어요.
Enum은 values()로 모든 상수를 배열로 얻고, valueOf("SPRING")으로 문자열에서 상수를 찾습니다. name()은 상수 이름을 문자열로 반환해요.
ordinal()은 선언 순서(0부터)를 반환하는데, ** 실무에서는 쓰면 안 됩니다 **. 누군가 상수 사이에 새 값을 추가하면 기존 순서가 전부 밀리기 때문이에요. DB에 ordinal() 값을 저장했다면 기존 데이터가 꼬입니다. name()이나 별도 필드를 사용하세요.
Enum에 필드와 메서드 추가
Enum은 단순한 상수가 아니라 ** 클래스처럼 필드, 생성자, 메서드를 가질 수 있습니다 **.
public enum Season {
SPRING("봄", 10),
SUMMER("여름", 30),
FALL("가을", 15),
WINTER("겨울", -5);
private final String koreanName;
private final int avgTemp;
Season(String koreanName, int avgTemp) {
this.koreanName = koreanName;
this.avgTemp = avgTemp;
}
public String getKoreanName() { return koreanName; }
public int getAvgTemp() { return avgTemp; }
}
Enum 생성자는 ** 반드시 private**이에요. public이나 protected로 선언하면 컴파일 에러가 납니다. 외부에서 new Season()으로 인스턴스를 만들 수 없다는 뜻이에요.
추상 메서드로 상수별 동작 분리
각 상수가 다른 동작을 해야 한다면 추상 메서드를 선언합니다.
public enum Operation {
ADD("+") { public double apply(double x, double y) { return x + y; } },
SUBTRACT("-") { public double apply(double x, double y) { return x - y; } },
MULTIPLY("*") { public double apply(double x, double y) { return x * y; } };
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
public abstract double apply(double x, double y);
}
새 상수를 추가할 때 apply() 구현을 빼먹으면 컴파일 에러 가 나므로 안전해요.
Enum과 switch
Enum은 switch문과 궁합이 좋습니다. Java 14+ switch 표현식에서는 모든 상수를 커버하면 default가 필요 없고, 나중에 상수를 추가했는데 switch에서 빠뜨리면 컴파일 에러를 띄워줘요.
return switch (season) {
case SPRING -> "벚꽃 구경";
case SUMMER -> "해수욕";
case FALL -> "단풍 놀이";
case WINTER -> "스키";
};
EnumSet과 EnumMap
EnumSet — 비트 벡터 기반
EnumSet은 Enum 상수를 저장하기 위한 특화된 Set이에요. 내부적으로 비트 벡터 를 사용합니다.
EnumSet<Permission> readWrite = EnumSet.of(Permission.READ, Permission.WRITE);
readWrite.contains(Permission.WRITE); // true
Enum 상수 4개가 있다면 내부적으로 0011(READ + WRITE) 같은 비트로 표현돼요. add는 비트 OR, contains는 비트 AND 한 번으로 끝납니다. 상수가 64개 이하면 long 하나(8바이트)로 전부 표현 가능해요. HashSet이 해시 계산과 버킷 탐색을 하는 것과 비교하면 압도적으로 빠릅니다.
EnumMap — 배열 기반
EnumMap은 Enum을 키로 사용하는 특화된 Map이에요. 내부적으로 ordinal()을 배열 인덱스 로 직접 사용하기 때문에 해시 계산이 필요 없습니다.
| 구분 | EnumSet/EnumMap | HashSet/HashMap |
|---|---|---|
| 내부 구조 | 비트 벡터 / 배열 | 해시 테이블 |
| 메모리 | 매우 적음 | 상대적으로 큼 |
| null 키 | 불가 | HashMap은 가능 |
| 순회 순서 | Enum 선언 순서 보장 | 보장 안 됨 |
Enum을 키나 집합으로 쓸 때는 반드시 EnumSet/EnumMap을 쓰자. 이유 없이 HashMap을 쓰는 건 낭비다.
빌트인 어노테이션
어노테이션은 @ 기호로 시작하는 메타데이터예요. Java가 기본 제공하는 핵심 어노테이션을 정리해 볼게요.
@Override: 부모 메서드를 오버라이딩한다는 표시입니다. 컴파일러가 실제로 오버라이딩이 맞는지 검증해줘요. 오타 방지에 필수예요.@Deprecated: 더 이상 사용하지 말라는 표시입니다. Java 9부터since와forRemoval속성이 추가됐어요.@SuppressWarnings: 컴파일러 경고를 억제합니다. 경고의 원인을 이해한 뒤에만 써야 해요.@FunctionalInterface: 함수형 인터페이스(추상 메서드 1개)임을 컴파일러가 검증하게 합니다.
메타 어노테이션 — 어노테이션을 정의하는 어노테이션
@Target — 어디에 붙일 수 있는가
@Target(ElementType.METHOD) // 메서드에만 붙일 수 있다
public @interface MyAnnotation { }
주요 ElementType: TYPE(클래스/인터페이스), METHOD, FIELD, PARAMETER, CONSTRUCTOR
@Retention — 언제까지 유지되는가
어노테이션 정보가 어느 시점까지 살아있는지를 결정해요.
| 정책 | 유지 범위 | 예시 |
|---|---|---|
SOURCE | 소스 코드에서만 | @Override, @SuppressWarnings |
CLASS | .class 파일까지 (기본값) | 바이트코드 분석 도구용 |
RUNTIME | 런타임까지 | @Autowired, @RequestMapping |
실무에서 커스텀 어노테이션을 만들 때는 대부분 RUNTIME 을 써요. 리플렉션으로 읽어서 처리하는 경우가 많기 때문입니다.
커스텀 어노테이션 만들기
어노테이션을 선언하고 붙이는 것만으로는 아무 일도 일어나지 않아요. 리플렉션으로 읽어서 처리하는 코드 가 있어야 동작합니다.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecutionTime {
String value() default "";
boolean enabled() default true;
}
사용할 때는 이렇게 붙여요.
@LogExecutionTime("사용자 조회")
public void findUser(String id) { ... }
그리고 리플렉션으로 읽어서 처리합니다.
for (Method method : clazz.getDeclaredMethods()) {
if (method.isAnnotationPresent(LogExecutionTime.class)) {
LogExecutionTime anno = method.getAnnotation(LogExecutionTime.class);
if (anno.enabled()) {
long start = System.nanoTime();
method.invoke(instance, args);
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println(anno.value() + " 실행: " + elapsed + "ms");
}
}
}
이것이 Spring의 @Autowired, @Transactional 같은 어노테이션이 동작하는 기본 원리예요. Spring이 리플렉션으로 어노테이션을 읽고, 그에 맞는 처리를 자동으로 해주는 겁니다.
주의할 점
ordinal()을 DB에 저장하면 데이터가 꼬여요
public enum Status {
PENDING, APPROVED, REJECTED // ordinal: 0, 1, 2
}
// 누군가 REVIEW를 추가하면?
public enum Status {
PENDING, REVIEW, APPROVED, REJECTED // APPROVED가 1→2로 밀림
}
DB에 1로 저장된 기존 데이터가 APPROVED에서 REVIEW로 바뀌어 버려요. name()이나 별도 코드 필드를 사용해야 합니다.
@SuppressWarnings 남용은 버그를 숨겨요
경고를 무조건 억제하면 실제 문제도 함께 숨겨집니다. @SuppressWarnings("unchecked")를 남발하면 런타임에 ClassCastException이 터지는 코드가 컴파일 시점에 잡히지 않아요. "왜 이 경고가 나는지 이해했고, 여기서는 안전하다"고 판단될 때만 쓰세요.
Enum 싱글턴
Enum을 활용하면 직렬화/리플렉션/스레드 안전성이 모두 보장되는 싱글턴을 만들 수 있어요.
public enum DatabaseConnection {
INSTANCE;
private String url = "jdbc:mysql://localhost:3306/mydb";
public void query(String sql) { ... }
}
일반 싱글턴은 역직렬화 시 새 인스턴스가 생길 수 있고, 리플렉션으로 생성자를 호출할 수도 있습니다. Enum은 JVM이 이 두 가지를 차단하기 때문에 가장 안전한 싱글턴 구현 방법 이에요.
정리
| 개념 | 핵심 |
|---|---|
| Enum | 타입 안전한 상수 그룹. static final int의 세 가지 문제를 해결 |
| ordinal() | 선언 순서 반환. DB 저장 등 실무 사용 금지 |
| Enum 필드/메서드 | 클래스처럼 필드, 생성자(private), 메서드 추가 가능 |
| EnumSet/EnumMap | 비트 벡터/배열 기반으로 HashSet/HashMap보다 빠르고 가벼움 |
| @Override | 오버라이딩 검증. 오타 방지 필수 |
| @Target | 어노테이션 적용 대상 지정 |
| @Retention | 어노테이션 유지 범위 (SOURCE, CLASS, RUNTIME) |
| 커스텀 어노테이션 | @interface로 선언. 리플렉션으로 읽어서 처리해야 동작 |
| Enum 싱글턴 | 직렬화/리플렉션/스레드 안전이 JVM 레벨에서 보장 |