자바 열거형 enum과 어노테이션 - 상수보다 enum 이 나은 이유 (자바 중급 14단원)
이 단원에서 배우는 것
11단원에서 대출과 반납을 다뤘지만 "대출 건이 지금 어떤 상태인가"는 표현한 적이 없다. 실제 도서관 시스템이라면 대출 건은 대출중 → 연체 → 반납완료 를 거치고, 상태마다 할 수 있는 일이 다르다. 이 "정해진 몇 가지 중 하나"를 자바에서 표현하는 문법이 enum 이다.
그리고 9단원부터 @Override 를, 13단원에서 @SuppressWarnings 와 @SafeVarargs 를 별 설명 없이 써 왔다. 이 @ 가 붙은 것들의 정체가 어노테이션이다. 이번 단원에서 정리한다.
enum이static final int상수보다 나은 지점을 코드로 설명한다- 상수마다 다른 동작을
enum안에 넣는 방법을 안다 - 어노테이션을 직접 정의하고 리플렉션으로 읽는다
왜 필요한가
대출 상태를 상수로 표현하면 보통 이렇게 시작한다.
public class LoanStatusConst {
public static final int LOANED = 1;
public static final int OVERDUE = 2;
public static final int RETURNED = 3;
public static final int RESERVED = 4;
}
// 사용
loan.setStatus(LoanStatusConst.LOANED);
loan.setStatus(7); // 컴파일 통과. 7 이라는 상태는 없는데
loan.setStatus(memberGrade); // 회원등급을 넣어도 통과한다
문제가 다섯 가지다.
- 타입 안전하지 않다.
int를 받으므로 아무 숫자나 들어간다. 위 두 줄 다 컴파일된다. - 출력이 쓸모없다. 로그에
status=3만 찍히고 그게 무엇인지 알려면 소스를 봐야 한다. - 전체 목록을 얻을 수 없다. 화면에 상태 드롭다운을 만들려면 목록을 또 하드코딩해야 한다.
- 상태별 동작을 담을 데가 없다. "연장 가능한가?"를 물으려면
switch를 어딘가에 또 만들어야 한다. - 값이 바뀌면 조용히 깨진다. 상수 값을 재배치하고 재컴파일하지 않은 클래스가 하나 있으면 그 클래스는 옛날 숫자를 그대로 쓴다.
enum 은 이 다섯을 전부 해결한다. Java 5부터 있었고, 실무에서 "정해진 몇 가지 중 하나"는 예외 없이 enum 으로 쓴다.
문법과 예제
기본형
public enum LoanStatus {
LOANED, OVERDUE, RETURNED, RESERVED
}
이 한 줄로 얻는 것이 많다. LoanStatus 타입 변수에는 저 네 개 외에 null 밖에 못 들어간다. 각 상수는 JVM 안에 딱 하나만 존재하므로 == 로 비교할 수 있다(그리고 equals 보다 == 를 쓰는 게 관례다. 한쪽이 null 이어도 NPE가 안 난다).
필드와 메서드를 가진 enum
enum 은 사실 클래스다. 필드도 메서드도 생성자도 가질 수 있다.
public enum LoanStatus {
LOANED("대출중", true),
OVERDUE("연체", false),
RETURNED("반납완료", false),
RESERVED("예약대기", false);
private final String label;
private final boolean extendable; // 대출 연장이 가능한 상태인가
/** enum 생성자는 항상 private. 밖에서 new 할 수 없다 */
LoanStatus(String label, boolean extendable) {
this.label = label;
this.extendable = extendable;
}
public String label() { return label; }
public boolean extendable() { return extendable; }
}
import java.util.Arrays;
public class LoanStatusDemo {
public static void main(String[] args) {
LoanStatus s = LoanStatus.LOANED;
System.out.println(s); // LOANED (toString 기본은 상수 이름)
System.out.println(s.label()); // 대출중
System.out.println(s.extendable()); // true
System.out.println(s.name() + " / " + s.ordinal()); // LOANED / 0
System.out.println(Arrays.toString(LoanStatus.values()));
System.out.println(LoanStatus.valueOf("OVERDUE").label());
}
}
LOANED
대출중
true
LOANED / 0
[LOANED, OVERDUE, RETURNED, RESERVED]
연체
values() 로 전체 목록을 얻는다. 화면 드롭다운을 만들 때 하드코딩된 목록을 유지할 필요가 없어진다. valueOf() 는 이름이 없으면 IllegalArgumentException 을 던진다.
상수마다 다른 동작 — switch 를 없애는 방법
회원 등급별로 대출 한도와 연체료 감면율이 다르다. 이걸 switch 로 짜면 등급이 늘어날 때마다 그 switch 를 전부 찾아 고쳐야 한다. 9단원에서 자료 종류로 겪은 것과 같은 문제다. enum 은 상수별 메서드 구현으로 이걸 없앨 수 있다.
public enum MemberGrade {
NORMAL("일반", 5) {
@Override public long discount(long fee) { return 0; }
},
STUDENT("학생", 10) {
@Override public long discount(long fee) { return fee / 2; }
},
SENIOR("우대", 10) {
@Override public long discount(long fee) { return fee; } // 전액 면제
};
private final String label;
private final int maxLoanCount;
MemberGrade(String label, int maxLoanCount) {
this.label = label;
this.maxLoanCount = maxLoanCount;
}
public String label() { return label; }
public int maxLoanCount() { return maxLoanCount; }
/** 상수마다 반드시 구현해야 한다 — 새 등급을 추가하면 컴파일러가 잡아준다 */
public abstract long discount(long fee);
}
public class MemberGradeDemo {
public static void main(String[] args) {
for (MemberGrade g : MemberGrade.values()) {
System.out.println(g.label() + " 한도 " + g.maxLoanCount() + "권, "
+ "연체료 1500원 -> " + (1500 - g.discount(1500)) + "원");
}
}
}
일반 한도 5권, 연체료 1500원 -> 1500원
학생 한도 10권, 연체료 1500원 -> 750원
우대 한도 10권, 연체료 1500원 -> 0원
새 등급 STAFF("직원", 20) 을 추가하면 discount() 를 구현하지 않는 한 컴파일이 안 된다. switch 로 짰다면 default 로 빠져 조용히 잘못된 감면액이 계산됐을 것이다. 이것이 enum 을 쓰는 가장 큰 실무적 이유다.
9단원의 FeePolicy 와 비교해두면 좋다. 감면 정책이 런타임에 조합되거나 외부에서 주입돼야 하면 인터페이스, 회원 등급처럼 종류가 고정되어 있고 전체 목록을 열거해야 하면 enum 이다.
enum 전용 컬렉션
import java.util.EnumMap;
import java.util.EnumSet;
public class EnumCollectionDemo {
public static void main(String[] args) {
EnumMap<LoanStatus, Integer> counts = new EnumMap<>(LoanStatus.class);
counts.merge(LoanStatus.OVERDUE, 2, Integer::sum);
counts.merge(LoanStatus.LOANED, 5, Integer::sum);
System.out.println(counts); // enum 선언 순서대로 나온다
EnumSet<LoanStatus> active = EnumSet.of(LoanStatus.LOANED, LoanStatus.OVERDUE);
System.out.println(active.contains(LoanStatus.RETURNED));
System.out.println(EnumSet.complementOf(active)); // 나머지 전부
}
}
{LOANED=5, OVERDUE=2}
false
[RETURNED, RESERVED]
EnumMap 은 내부가 배열이라 HashMap 보다 빠르고 키가 항상 선언 순서로 정렬된다. EnumSet 은 비트 벡터로 구현돼 있어 매우 가볍다. 키가 enum 이면 이 둘을 쓴다.
switch 에서 쓰기
// Java 14+ switch 표현식 — Java 17 에서 정식 문법이다
String message = switch (status) {
case LOANED -> "대출 중입니다";
case OVERDUE -> "연체된 자료가 있습니다";
case RETURNED -> "반납이 완료되었습니다";
case RESERVED -> "예약 대기 중입니다";
};
case 에 LoanStatus.LOANED 가 아니라 LOANED 만 쓴다는 점에 주의한다. 그리고 enum 을 대상으로 하는 switch 표현식은 모든 상수를 다루면 default 없이도 컴파일된다. 반대로 default 를 넣으면 상수가 추가돼도 컴파일 에러가 나지 않으므로, 새 상수를 놓치고 싶지 않다면 default 를 쓰지 않는 편이 낫다.
어노테이션
어노테이션은 코드에 붙이는 메타데이터다. 그 자체로는 아무 일도 하지 않고, 컴파일러나 프레임워크가 읽어서 무언가를 한다.
| 어노테이션 | 읽는 주체 | 역할 |
|---|---|---|
@Override | 컴파일러 | 재정의가 맞는지 검사 |
@Deprecated | 컴파일러 | 사용 시 경고 |
@SuppressWarnings | 컴파일러 | 특정 경고 숨김 |
@FunctionalInterface | 컴파일러 | 추상 메서드가 정확히 하나인지 검사 |
@Entity, @Service 등 | 프레임워크 | 런타임/빌드 시점 처리 |
어노테이션 직접 만들기
11단원에서 소장자료 CSV를 읽었다. 컬럼 인덱스를 코드에 흩어 쓰는 대신 필드에 붙여보자.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME) // 런타임까지 살아남아야 리플렉션으로 읽을 수 있다
@Target(ElementType.FIELD) // 필드에만 붙일 수 있다
public @interface CsvColumn {
int index(); // 값이 필수인 요소
String name() default ""; // 기본값이 있는 요소
}
@Retention 이 핵심이다. 기본값은 CLASS 라서 런타임에는 읽을 수 없다. 이걸 RUNTIME 으로 안 바꿔서 "어노테이션을 붙였는데 아무 일도 안 일어난다"로 헤매는 일이 흔하다.
import java.lang.reflect.Field;
public class CsvMapper {
static class ItemRow {
@CsvColumn(index = 0, name = "등록번호") String isbn;
@CsvColumn(index = 1, name = "제목") String title;
@CsvColumn(index = 2, name = "분류") String category;
String internalMemo; // 어노테이션 없음 → 매핑 대상 아님
}
public static void main(String[] args) throws Exception {
String line = "B-001,자바의 정석,컴퓨터";
String[] col = line.split(",", -1);
ItemRow row = new ItemRow();
for (Field f : ItemRow.class.getDeclaredFields()) {
CsvColumn c = f.getAnnotation(CsvColumn.class);
if (c == null) continue; // 붙어 있지 않으면 건너뛴다
f.setAccessible(true);
f.set(row, col[c.index()]);
System.out.println(c.name() + " -> " + col[c.index()]);
}
System.out.println(row.isbn + " / " + row.title + " / " + row.category
+ " / memo=" + row.internalMemo);
}
}
등록번호 -> B-001
제목 -> 자바의 정석
분류 -> 컴퓨터
B-001 / 자바의 정석 / 컴퓨터 / memo=null
Spring, JPA, Jackson 같은 프레임워크가 하는 일이 정확히 이 구조다. 어노테이션으로 표시하고 리플렉션으로 읽는다. 직접 만들 일은 많지 않지만, 프레임워크가 마법이 아니라는 것을 아는 것이 중요하다.
실무에서 자주 틀리는 것
1. ordinal() 을 DB에 저장한다
// 하지 마라
int code = status.ordinal(); // LOANED=0, OVERDUE=1, ...
saveToDb(code);
ordinal() 은 선언 순서를 반환한다. 나중에 LOANED 와 OVERDUE 사이에 EXTENDED(연장됨)를 끼워 넣는 순간 DB에 이미 저장된 모든 값의 의미가 한 칸씩 밀린다. 연체 상태이던 대출 건이 전부 반납완료로 바뀐다. 저장할 때는 name() 을 쓰거나, 안정적인 코드값을 필드로 따로 둔다.
public enum LoanStatus {
LOANED("L"), OVERDUE("O"), RETURNED("R"), RESERVED("V");
private final String code;
LoanStatus(String code) { this.code = code; }
public String code() { return code; }
public static LoanStatus fromCode(String code) {
for (LoanStatus s : values()) {
if (s.code.equals(code)) return s;
}
throw new IllegalArgumentException("알 수 없는 상태 코드: " + code);
}
}
JPA를 쓴다면 @Enumerated(EnumType.STRING) 를 쓴다. 기본값이 ORDINAL 이라서 그대로 두면 같은 사고가 난다.
2. valueOf() 에 외부 입력을 그대로 넣는다
LoanStatus s = LoanStatus.valueOf(request.getParameter("status"));
// null 이면 NullPointerException, 없는 이름이면 IllegalArgumentException
사용자 입력, API 요청 파라미터, 파일 내용은 언제든 잘못된 값일 수 있다. 감싸서 처리한다.
public static Optional<LoanStatus> parse(String name) {
if (name == null) return Optional.empty();
for (LoanStatus s : values()) {
if (s.name().equalsIgnoreCase(name)) return Optional.of(s);
}
return Optional.empty();
}
3. enum 에 가변 상태를 둔다
public enum LoanStatus {
LOANED, OVERDUE;
private int count; // 위험
public void increase() { count++; }
}
enum 상수는 JVM 전체에서 단 하나의 인스턴스다. 여기에 가변 필드를 두면 애플리케이션 전역에서 공유되는 상태가 되고, 여러 스레드가 동시에 건드리면 값이 틀어진다. 상태별 건수를 세고 싶으면 enum 이 아니라 EnumMap 에 담는다. enum 의 필드는 예외 없이 final 로 둔다.
4. @Retention 을 빠뜨리고 리플렉션으로 읽으려 한다
public @interface CsvColumn { int index(); } // @Retention 없음 = CLASS
f.getAnnotation(CsvColumn.class); // 항상 null
기본 보존 정책은 RetentionPolicy.CLASS 다. 클래스 파일에는 기록되지만 JVM이 로드할 때 버린다. 리플렉션으로 읽을 계획이면 반드시 @Retention(RetentionPolicy.RUNTIME) 을 붙인다. 컴파일 에러도 경고도 없이 그냥 null 이 나오므로 원인을 찾기 어렵다.
스스로 확인하기
- 9단원에서
LibraryItem계층으로 만들었던 자료 종류를enum ItemType으로 만든다.BOOK(14일, 100원),DVD(3일, 500원),MAGAZINE(7일, 200원)이고int loanDays()와long dailyFee()를 제공한다. 이 경우 클래스 계층과enum중 어느 쪽이 나은지도 한 문장으로 판단하라. - 다음 코드가 위험한 이유를 설명하라.
// DB 컬럼: status INT loan.setStatusCode(LoanStatus.OVERDUE.ordinal()); @interface Timed { }를 정의하고 메서드에 붙였는데method.getAnnotation(Timed.class)가 계속null을 반환한다. 원인과 해결 방법은?
정답
- 다음과 같다.
자료 종류가 고정되어 있고 종류별로 값만 다르다면public enum ItemType { BOOK("도서", 14, 100), DVD("DVD", 3, 500), MAGAZINE("정기간행물", 7, 200); private final String label; private final int loanDays; private final long dailyFee; ItemType(String label, int loanDays, long dailyFee) { this.label = label; this.loanDays = loanDays; this.dailyFee = dailyFee; } public String label() { return label; } public int loanDays() { return loanDays; } public long dailyFee() { return dailyFee; } }enum이 낫다. 전체 목록을values()로 얻을 수 있고 파일이 하나로 끝난다. 반대로 종류마다 추가 필드가 필요하다면(Dvd의 디스크 수,Magazine의 최신호 여부) 9단원처럼 클래스 계층이 맞다. ordinal()은 선언 순서에 종속된다. 나중에 상수를 중간에 추가하거나 순서를 바꾸면 DB에 이미 저장된 값들이 전부 다른 상태를 가리키게 된다. 코드 리뷰에서도 잡히지 않고, 마이그레이션 스크립트로도 되돌리기 어렵다.name()또는 별도의 코드 필드를 저장한다.@Retention을 지정하지 않아 기본값RetentionPolicy.CLASS가 적용됐다. 클래스 파일에는 남지만 JVM이 로드하면서 버리므로 리플렉션으로 조회되지 않는다.@Retention(RetentionPolicy.RUNTIME)을 붙이고, 메서드에 붙일 것이면@Target(ElementType.METHOD)도 함께 지정해 잘못된 위치에 붙는 것을 컴파일 단계에서 막는다.