자바 초창기엔 상수를 public static final int로 만들어 쓰는 정수 열거 패턴(int enum pattern)이 흔했음.
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
...
public static final int ORANGE_BLOOD = 2;
하지만 이 방식엔 문제가 많다.
APPLE_, ORANGE_를 붙여야 함.public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
즉, 정수보다 훨씬 의미 있고 강력한 타입.
public enum Planet {
EARTH(5.975e+24, 6.378e6);
...
}
각 행성마다 질량/반지름을 갖고, 이를 이용해 표면 중력 등을 계산할 수 있음.
→ 상수와 관련 데이터를 Enum에 직접 담을 수 있다는 점이 큰 장점.
그 상수를 사용한 코드만 컴파일 오류가 나므로 안정적.
잘못된 방식:
switch(this) { ... }
→ 상수 추가 시 switch문도 수정해야 해서 취약함.
public enum Operation {
PLUS {
public double apply(double x, double y) { return x + y; }
},
...
public abstract double apply(double x, double y);
}
또한 symbol 등을 필드로 두고 toString()을 재정의할 수도 있다.
요일에 따라 잔업 계산 방식이 다를 때, switch문은 금방 복잡해짐.
→ 열거 타입 내부에 전략(enum PayType) 으로 로직을 분리하면 깔끔함.
enum PayrollDay {
MONDAY(WEEKDAY), SATURDAY(WEEKEND);
enum PayType { WEEKDAY, WEEKEND; }
}
새로운 요일/정책 추가도 유연해진다.
switch보다 상수별 메서드 구현이 더 안전enum은 기본적으로 각 상수가 선언된 순서에 따른 위치(index) 를 반환하는 ordinal() 메서드를 제공한다. 하지만 이 값에 의존해 기능을 구현하는 건 매우 위험한 방식이다.
아래처럼 ordinal을 이용해 음악 앙상블의 인원 수를 계산한다고 해보자:
public int numberOfMusicians() {
return ordinal() + 1;
}
겉보기엔 잘 동작하지만, 실제로는 유지보수 악몽이다.
즉, 상수의 “순서”와 “의미”가 억지로 동기화되어버리면서 enum의 장점을 스스로 죽이는 셈.
public enum Ensemble {
SOLO(1),
DUET(2),
TRIO(3),
QUARTET(4),
QUINTET(5),
SEXTET(6),
SEPTET(7),
OCTET(8),
DOUBLE_QUARTET(8),
NONET(9),
DECTET(10),
TRIPLE_QUARTET(12);
private final int numberOfMusicians;
Ensemble(int numberOfMusicians) {
this.numberOfMusicians = numberOfMusicians;
}
public int numberOfMusicians() {
return numberOfMusicians;
}
}
이 방식의 장점은 명확하다:
대부분의 개발자는 절대 직접 사용할 일이 없다.
ordinal은
EnumSet,EnumMap같은 열거 타입 기반의 자료구조 내부 구현을 위해 존재한다.
→ 즉, 일반 기능 구현에서는 사용 X
enum 상수에 의미 있는 값이 필요하다면,
필드를 선언해서 직접 값을 저장하는 방식을 꼭 사용하자.
ordinal에 의존한 코드는 확장성도, 안전성도, 유지보수성도 모두 떨어진다.
예전에는 열거된 값들을 집합 형태로 다뤄야 할 때, 각 상수에 서로 다른 2의 거듭제곱 값을 줘서 비트 연산으로 처리하는 패턴을 많이 썼다.
public class Text {
public static final int STYLE_BOLD = 1 << 0;
public static final int STYLE_ITALIC = 1 << 1;
public static final int STYLE_UNDERLINE = 1 << 2;
public static final int STYLE_STRIKETHROUGH = 1 << 3;
}
그리고 이렇게 조합해서 사용했다:
Text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
이렇게 만들어진 정수 기반 집합을 비트 필드(bit field) 라고 한다.
비트 연산으로 빠르게 집합 연산이 가능해서 그 시절엔 나름 괜찮은 방식이었다.
비트 필드는 기능은 되는데, 개발자 입장에선 꽤 까다롭다.
비트가 OR돼서 나온 값이 그냥 정수라서, 무슨 의미인지 해석하기 힘듦
하나의 비트 필드 안에 있는 개별 요소를 순회하기가 어려움
미리 몇 비트가 필요할지 API 설계 단계에서 결정해야 함
요약하면: 유지보수성이 너무 낮다.
자바는 이런 문제를 해결하기 위해 EnumSet 이라는 자료구조를 제공한다.
특히 상수 개수가 64개 이하라면, long 하나로 전체를 표현할 수 있어서 비트 필드와 거의 동일한 성능이 나온다.
removeAll, retainAll 같은 연산도 내부에서 비트 연산으로 처리해서 속도가 매우 빠르다.
public void applyStyles(Set<Style> styles) {
//...
}
여기서 매개변수를 EnumSet<Style>가 아니라 Set<Style>로 받는 이유는?
확장성 때문.
일반적으로는 EnumSet을 전달하지만,
혹시 모를 특수한 클라이언트 코드가 다른 Set 구현체를 넘겨도 문제없이 동작하기 때문이다.
비트 필드는 이제 옛날 방식이다.
열거 타입을 집합으로 쓰고 싶다면,
항상 EnumSet을 사용하라.
가독성, 유지보수성, 타입 안정성, 성능—전부 EnumSet이 압승이다.
열거 타입을 다룰 때 흔히 떠올리는 방법 중 하나가 ordinal()을 배열 인덱스로 사용하는 방식이다. 하지만 이 방식은 겉보기엔 깔끔해도, 실제로는 유지보수 리스크가 꽤 크다.
아래 예제를 보자.
Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (Plant plant : garden) {
plantsByLifeCycle[plant.lifeCycle.ordinal()].add(plant);
}
즉, 실수하기 쉬운 방식이다.
위 코드를 EnumMap으로 바꾸면 이렇게 된다:
Map<LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(LifeCycle.class);
for (LifeCycle lc : LifeCycle.values()) {
plantsByLifeCycle.put(lc, new HashSet<>());
}
for (Plant plant : garden) {
plantsByLifeCycle.get(plant.lifeCycle).add(plant);
}
EnumMap<LifeCycle, Set<Plant>> map =
garden.stream().collect(
groupingBy(
p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class),
toSet()
)
);
EnumMap을 자동으로 구성해주니까 훨씬 깔끔해진다.
ordinal()을 2차원 배열 인덱스로 쓰는 예:
private static final Transition[][] TRANSITIONS = {
{null, MELT, SUBLIME},
{FREEZE, null, BOIL},
{DEPOSIT, CONDENSE, null}
};
TRANSITIONS[from.ordinal()][to.ordinal()]
이런 식으로 쓰는 건 더더욱 문제가 있다.
이걸 완벽하게 해결한 것:
private static final Map<Phase, Map<Phase, Transition>> map =
Stream.of(values()).collect(
groupingBy(
t -> t.from,
() -> new EnumMap<>(Phase.class),
toMap(
t -> t.to,
t -> t,
(x, y) -> y,
() -> new EnumMap<>(Phase.class)
)
)
);
일반적인 enum은 강력하고 안전하지만 확장할 수 없다는 단점이 있다.
반면, 과거의 타입 안전 열거 패턴(Typesafe Enum Pattern) 은 확장이 가능했다.
그 차이부터 짚고 가자.
class Suit {
private final String name;
public static final Suit CLUBS = new Suit("clubs");
public static final Suit DIAMONDS = new Suit("diamonds");
private Suit(String name) {
this.name = name;
}
}
이런 식은 “새 상수”를 만들려고 하면, 같은 패키지에서 같은 방식으로 하나 더 만들 수 있었다.
= 확장이 된다.
하지만 modern Java에서는 enum이 타입 안정성, 읽기, 성능면에서 훨씬 우수하고, 일반적으로 구 방식을 쓸 일이 없다.
다만 단 하나, enum을 “확장하고 싶은 경우”가 있다.
그게 바로 연산(Operation) 처럼 새로운 기능을 계속 추가할 수 있는 상황.
“확장할 enum을 인터페이스로 추상화하고, 실제 연산은 enum이 그 인터페이스를 구현하게 만들자.”
public interface Operation {
double apply(double x, double y);
}
public enum BasicOperation implements Operation {
PLUS("+") { public double apply(double x, double y) { return x + y; }},
MINUS("-") { public double apply(double x, double y) { return x - y; }},
TIMES("*") { public double apply(double x, double y) { return x * y; }},
DIVIDE("/") { public double apply(double x, double y) { return x / y; }};
private final String symbol;
BasicOperation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
}
public enum ExtendedOperation implements Operation {
EXP("^") { public double apply(double x, double y) { return Math.pow(x, y); }},
REMAINDER("%") { public double apply(double x, double y) { return x % y; }};
private final String symbol;
ExtendedOperation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
}
이제 Operation 인터페이스를 사용하는 모든 코드에서
새 enum(ExtendedOperation)을 별도로 추가해도 기존 코드가 깨지지 않는다!
public static <T extends Enum<T> & Operation> void test(Class<T> opType, double x, double y) {
for (Operation op : opType.getEnumConstants()) {
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
}
test(ExtendedOperation.class, 1, 2); 하면 해당 enum 전체 순회 가능.
public static void test(Collection<? extends Operation> ops, double x, double y) {
for (Operation op : ops) {
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
}
이 방식은 BasicOperation + ExtendedOperation을 한 번에 넘길 수도 있음:
test(List.of(BasicOperation.values()), 1, 2);
test(List.of(ExtendedOperation.values()), 1, 2);
EnumSet<Operation>
EnumMap<Operation, ...>
→ 불가능.
왜냐하면 EnumSet/EnumMap은 하나의 enum 타입에만 동작한다.
Operation은 인터페이스라서 두 enum(BasicOperation, ExtendedOperation)을 섞어서 담을 수가 없다.
BasicOperation과 ExtendedOperation 모두 symbol, toString() 로직을 중복으로 갖는다.
enum은 상속이 불가능해서 공통 구현을 부모 클래스로 올릴 수도 없다.
(인터페이스 default 메서드로 완전히 해결되지 않는 타입도 있음)
enum은 기본적으로 확장 불가하다.
하지만 인터페이스를 사용하면 “확장 가능한 enum 구조”를 만들 수 있다.
이 패턴이 특히 잘 맞는 경우:
→ 연산(Operation)처럼 나중에 기능을 계속 추가할 수 있는 상황
장점:
단점:
예전엔 테스트 메서드 이름을 testSomething()처럼 특정 패턴으로 짓고, 리플렉션으로 “이름이 test로 시작하면 테스트로 인정!” 이런 식으로 처리하곤 했다.
오탈자 나면 끝
tsetSomething 이런 식으로 쓰면 테스트가 실행되지도 않고, IDE가 잡아주지도 않음.
적용 대상을 세밀하게 제한할 수 없음
“메서드에만” 또는 “클래스에는 쓰면 안됨” 같은 규칙을 강제하지 못함.
메타 정보 전달 불가
“이 메서드는 꼭 특정 예외를 던져야 성공” 같은 정보 전달이 불가능.
결국 패턴은 강제력 부족 + 안전성 부족이라는 치명적 약점을 가짐.
애너테이션은 이 문제를 전부 해결함.
@Target@RetentionisAnnotationPresent()@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MethodTest { }
public @interface ExceptionSingleTest {
Class<? extends Throwable> value();
}
애너테이션 값으로 기대하는 예외 타입을 넣어줌.
“이 메서드는 ArithmeticException이 터지면 오히려 성공이다.”
명명 패턴으로는 이런 동작 불가능.
Class<? extends Throwable>[] value();
단일 예외가 아니라 예외 여러 개 허용.
A 예외 또는 B 예외가 나면 성공
이런 테스트도 애너테이션만으로 표현됨.
자바 8 이후 ‘반복 가능한 애너테이션’ 기능이 생김.
이전:
@ExceptionArrayTest({A.class, B.class, C.class})
이후(더 읽기 쉬움):
@ExceptionRepeatableTest(A.class)
@ExceptionRepeatableTest(B.class)
@ExceptionRepeatableTest(C.class)
그냥 붙이고 싶은 만큼 붙이면 됨.
대신 이를 지원하려면 컨테이너 애너테이션이 필요함.
배열 방식보다 훨씬 깔끔하고 읽기 쉬움.
오타, 강제력, 표현력 문제 때문에 modern Java에서는 사실상 쓸 이유가 없음.
static class Bigram {
private final char first;
private final char second;
public Bigram(char first, char second) {
this.first = first;
this.second = second;
}
public boolean equals(Bigram b) { // ← 실수 포인트
return b.first == first && b.second == second;
}
public int hashCode() {
return 31 * first + second;
}
}
equals(Bigram b) 는 얼핏 보면 "오버라이드한 equals 맞네?" 싶은데…
전혀 아님!
Object가 가진 equals의 시그니처는:
public boolean equals(Object obj)
Bigram에서 파라미터 타입이 다르기 때문에 오버라이드가 아니라 오버로딩이 되어버림.
@Override가 없기 때문.
테스트를 보면:
Set<Bigram> s = new HashSet<>();
for (int i = 0; i < 10; i++) {
for (char ch = 'a'; ch <= 'z'; ch++) {
s.add(new Bigram(ch, ch));
}
}
Assertions.assertEquals(26, s.size()); // 실제 값 260
원한 값은 알파벳 26개지만
실제 결과는 260.
이유는 간단함:
그래서 결국 매번 새로운 객체로 저장 → 26 * 10 = 260개.
@Override
public boolean equals(Object o) {
if (!(o instanceof Bigram)) return false;
Bigram b = (Bigram) o;
return b.first == first && b.second == second;
}
여기에 @Override를 붙이면?
‘아무 메서드도 없는 인터페이스’
하지만 이걸 구현한 클래스는 “특정 속성을 가진다”는 것을 의미적으로 표시함.
예)
Serializable → 이 인터페이스를 구현한 객체는 직렬화 가능함.
public class User implements Serializable { ... }
이 한 줄만으로 “User는 직렬화 가능한 타입이다”라고 알려주는 셈.
둘 다 “특정 특성 부여” 역할은 같지만, 각자 강점이 있음.
인터페이스라서 당연히 파라미터, 반환 타입으로 쓸 수 있음.
void save(Serializable data)
→ “직렬화 가능한 타입만 받겠다”는 제약을 컴파일 단계에서 걸 수 있음.
마커 애너테이션(@Something) 은 타입이 아니기 때문에 이런 식으로 사용할 수 없음.
그래서 실수도 런타임까지 가야 발견됨.
특정 클래스 계층에만 적용하고 싶다면?
→ 그 계층의 인터페이스에만 ‘implements Marker’ 를 넣으면 됨.
즉,
“이 인터페이스를 구현한 클래스만 XXX 성질을 가진다” 라는 걸 구조적으로 보장할 수 있음.
애너테이션은 이런 “상속 구조 제약”을 표현하기 어려움.
Serializable이 마커 인터페이스임에도 불구하고,
writeObject() 메서드의 시그니처가 이렇게 되어 있음:
public final void writeObject(Object obj)
타입이 Serializable이어야 하는데 Object로 받아버림.
그래서 직렬화 불가능한 객체를 전달해도 컴파일러가 아무 말도 못 함 →
결국 실행 중에야 오류가 발생.
out.writeObject(new NotSerializableClass()) // 컴파일 OK → 런타임 에러
이건 마커 인터페이스의 장점을 제대로 활용하지 못한 대표적인 사례.
스프링, JUnit 같은 애너테이션 기반 프레임워크에서는
@Service, @Test 같은 마커 애너테이션이 훨씬 자연스럽고 유연함.
프레임워크 입장에서 특정 기능을 붙여서 스캔하려면
애너테이션 방식이 훨씬 잘 맞음.
→ “이거 인터페이스로 만드는 게 더 좋은 구조일까?” 다시 한번 생각해볼 것.
참고 블로그:
https://jake-seo-dev.tistory.com/96?category=906605
https://github.com/back-end-study/effective-java/tree/main/6%EC%9E%A5_%EC%97%B4%EA%B1%B0_%ED%83%80%EC%9E%85%EA%B3%BC_%EC%95%A0%EB%84%88%ED%85%8C%EC%9D%B4%EC%85%98