이펙티브 자바 6장) 열거 타입과 에너테이션

동동주·2025년 11월 20일

이펙티브 자바

목록 보기
7/13

📌 Item 34: int 상수 대신 열거 타입(Enum)을 사용하라

자바 초창기엔 상수를 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 값을 넣어도 컴파일러가 막지 못함.
  • 네임스페이스 부재 → 접두어 남발
    이름 충돌을 막기 위해 APPLE_, ORANGE_를 붙여야 함.
  • 출력/디버깅 불편
    숫자만 찍혀서 의미 파악이 어려움.
  • 상수 목록 순회도 불편
  • 문자열 패턴은 더 위험(오타 감지 X, 성능 저하)

열거 타입(Enum)의 등장

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }

✔ Enum의 장점

  • 타입 안전 (컴파일러가 잘못된 값 사용을 잡아냄)
  • 독립된 네임스페이스 보유
  • 각 상수가 고유한 인스턴스 (싱글톤 보장)
  • 필드/메서드 추가 가능
  • values()로 상수 순회 가능
  • switch, 인터페이스 구현 가능

즉, 정수보다 훨씬 의미 있고 강력한 타입.

예시: 태양계 행성 Enum

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()을 재정의할 수도 있다.

전략 패턴을 이용한 응용 (PayrollDay 예시)

요일에 따라 잔업 계산 방식이 다를 때, switch문은 금방 복잡해짐.
→ 열거 타입 내부에 전략(enum PayType) 으로 로직을 분리하면 깔끔함.

enum PayrollDay {
    MONDAY(WEEKDAY), SATURDAY(WEEKEND);

    enum PayType { WEEKDAY, WEEKEND; }
}

새로운 요일/정책 추가도 유연해진다.

switch문은 언제 쓰나?

  • 모든 상수에 공통 기능이 있고
  • 몇 가지 특별한 상수만 예외적으로 다를 때
    → 이런 경우는 오히려 switch가 더 단순하고 가독성 높을 수 있음.

결론

  • ✔ 열거 타입은 정수 상수보다 가독성, 안전성, 확장성이 모두 뛰어남
  • ✔ 상수별로 다른 데이터/로직을 넣는 데 최적
  • ✔ 상수별 동작은 switch보다 상수별 메서드 구현이 더 안전
  • ✔ 여러 상수가 같은 로직을 공유해야 한다면 전략 enum 패턴이 깔끔한 해법
  • ✔ 언제든 추가될 가능성이 있는 상수 집합이라도 Enum이 훨씬 적합

📌 Item 35: ordinal 메서드 대신 인스턴스 필드를 사용하라

enum은 기본적으로 각 상수가 선언된 순서에 따른 위치(index) 를 반환하는 ordinal() 메서드를 제공한다. 하지만 이 값에 의존해 기능을 구현하는 건 매우 위험한 방식이다.

ordinal()을 사용하면 왜 문제일까?

아래처럼 ordinal을 이용해 음악 앙상블의 인원 수를 계산한다고 해보자:

public int numberOfMusicians() {
    return ordinal() + 1;
}

겉보기엔 잘 동작하지만, 실제로는 유지보수 악몽이다.

  • 상수 순서만 바뀌어도 값이 전부 깨짐
  • 중간 값을 비워둘 수도 없음
  • 같은 값을 가져야 하는 상수를 추가할 수도 없음
  • 기존 로직과 강하게 결합되어 확장이 사실상 불가능

즉, 상수의 “순서”와 “의미”가 억지로 동기화되어버리면서 enum의 장점을 스스로 죽이는 셈.

해결: 값은 ordinal이 아니라 인스턴스 필드에 명시적으로 저장

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;
    }
}

이 방식의 장점은 명확하다:

  • enum 순서가 바뀌어도 안전함
  • 같은 값을 가지는 상수도 문제 없음
  • 의미 있는 값만 필드에 저장하니 가독성 + 유지보수성 모두 상승
  • 미래에 값 변경·추가도 자유로움

ordinal은 언제 쓰는가?

대부분의 개발자는 절대 직접 사용할 일이 없다.

ordinal은 EnumSet, EnumMap 같은 열거 타입 기반의 자료구조 내부 구현을 위해 존재한다.
→ 즉, 일반 기능 구현에서는 사용 X

결론: ordinal()은 사용하지 말자

enum 상수에 의미 있는 값이 필요하다면,
필드를 선언해서 직접 값을 저장하는 방식을 꼭 사용하자.

ordinal에 의존한 코드는 확장성도, 안전성도, 유지보수성도 모두 떨어진다.


🎯 Item 36: 비트 필드 대신 EnumSet을 사용하라

예전에는 열거된 값들을 집합 형태로 다뤄야 할 때, 각 상수에 서로 다른 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

자바는 이런 문제를 해결하기 위해 EnumSet 이라는 자료구조를 제공한다.

  • 열거 타입 전용 집합
  • 타입 안전
  • Set 인터페이스 구현 → 기존 Set API 다 사용 가능
  • 내부적으로는 비트 벡터 기반이라 성능도 뛰어남

특히 상수 개수가 64개 이하라면, long 하나로 전체를 표현할 수 있어서 비트 필드와 거의 동일한 성능이 나온다.

removeAll, retainAll 같은 연산도 내부에서 비트 연산으로 처리해서 속도가 매우 빠르다.

예시: 스타일 적용

public void applyStyles(Set<Style> styles) {
    //...
}

여기서 매개변수를 EnumSet<Style>가 아니라 Set<Style>로 받는 이유는?

확장성 때문.

일반적으로는 EnumSet을 전달하지만,
혹시 모를 특수한 클라이언트 코드가 다른 Set 구현체를 넘겨도 문제없이 동작하기 때문이다.

결론

비트 필드는 이제 옛날 방식이다.

열거 타입을 집합으로 쓰고 싶다면,
항상 EnumSet을 사용하라.

가독성, 유지보수성, 타입 안정성, 성능—전부 EnumSet이 압승이다.


🎯 Item 37: ordinal 인덱싱 대신 EnumMap을 사용하라

열거 타입을 다룰 때 흔히 떠올리는 방법 중 하나가 ordinal()을 배열 인덱스로 사용하는 방식이다. 하지만 이 방식은 겉보기엔 깔끔해도, 실제로는 유지보수 리스크가 꽤 크다.

아래 예제를 보자.

ordinal()을 인덱스로 쓰는 전통적인 방식

Set<Plant>[] plantsByLifeCycle = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];

for (Plant plant : garden) {
    plantsByLifeCycle[plant.lifeCycle.ordinal()].add(plant);
}

이런 식으로 동작하긴 한다. 하지만 문제는…

  • 배열 + 제네릭은 궁합이 최악 → 비검사 형변환 필요
  • 배열 인덱스가 “맞는 값인지” 컴파일러가 보증해주지 않음
  • ordinal과 배열의 순서가 어긋나면 그대로 터짐
  • 나중에 enum 순서 바꾸면 코드가 전부 깨짐

즉, 실수하기 쉬운 방식이다.

해결: EnumMap 사용하기

위 코드를 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);
}

뭐가 좋아지나?

  • 제네릭 타입 안전성 확보
  • 컴파일러가 키 타입을 체크해줌
  • 가독성 증가
  • 내부는 배열 기반이라 빠름 (ordinal 쓰는 수준의 성능 유지)

스트림으로 더 간단하게

EnumMap<LifeCycle, Set<Plant>> map =
        garden.stream().collect(
                groupingBy(
                        p -> p.lifeCycle,
                        () -> new EnumMap<>(LifeCycle.class),
                        toSet()
                )
        );

EnumMap을 자동으로 구성해주니까 훨씬 깔끔해진다.

ordinal로 2차원 배열 조합? → 더 위험함

ordinal()을 2차원 배열 인덱스로 쓰는 예:

private static final Transition[][] TRANSITIONS = {
    {null, MELT, SUBLIME},
    {FREEZE, null, BOIL},
    {DEPOSIT, CONDENSE, null}
};

TRANSITIONS[from.ordinal()][to.ordinal()]
이런 식으로 쓰는 건 더더욱 문제가 있다.

  • enum 순서를 바꾸는 순간 테이블 전체가 무너짐
  • null이 왕창 생김
  • 컴파일러가 아무것도 보장해주지 않음
  • 실수해도 바로 안 드러나서 디버깅 지옥 예약

EnumMap 중첩으로 2차원 관계 해결

이걸 완벽하게 해결한 것:

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)
            )
        )
    );

장점

  • 새 Phase(PLASMA 같은)를 추가해도 기존 코드 변화 없음
  • null 제거
  • 타입 안전
  • 관계 구조가 더 “의도”에 가깝게 표현됨
  • 유지보수성 압도적으로 상승

결론

  • ordinal()을 배열 인덱스로 쓰는 건 위험하다.
  • 대신 EnumMap을 써라.
  • 다차원 관계는 EnumMap을 중첩해서 표현하라.
  • ordinal은 대부분의 경우 쓸 이유가 없다.

🎯 Item 38 — 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

일반적인 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을 인터페이스로 추상화하고, 실제 연산은 enum이 그 인터페이스를 구현하게 만들자.”

인터페이스 기반 확장 가능한 enum 예시

Operation 인터페이스

public interface Operation {
    double apply(double x, double y);
}

기본 연산 BasicOperation (확장은 불가능)

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; }
}

확장 연산 ExtendedOperation

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)을 별도로 추가해도 기존 코드가 깨지지 않는다!

타입 레벨 확장도 가능 (Class 넘기기)

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 전체 순회 가능.

값(Level) 확장도 가능 (Collection 넘기기)

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);

⚠️ 하지만 한계도 있다

1️⃣ EnumMap / EnumSet과 함께 사용할 수 없다

EnumSet<Operation>
EnumMap<Operation, ...>
→ 불가능.

왜냐하면 EnumSet/EnumMap은 하나의 enum 타입에만 동작한다.
Operation은 인터페이스라서 두 enum(BasicOperation, ExtendedOperation)을 섞어서 담을 수가 없다.

2️⃣ 구현 상속이 안 된다

BasicOperation과 ExtendedOperation 모두 symbol, toString() 로직을 중복으로 갖는다.

enum은 상속이 불가능해서 공통 구현을 부모 클래스로 올릴 수도 없다.
(인터페이스 default 메서드로 완전히 해결되지 않는 타입도 있음)

결론

  • enum은 기본적으로 확장 불가하다.

  • 하지만 인터페이스를 사용하면 “확장 가능한 enum 구조”를 만들 수 있다.

  • 이 패턴이 특히 잘 맞는 경우:
    연산(Operation)처럼 나중에 기능을 계속 추가할 수 있는 상황

  • 장점:

    • 기존 코드 변경 없이 새 연산(enum) 추가 가능
    • 타입 안전 유지
  • 단점:

    • EnumSet, EnumMap과 호환되지 않음
    • enum 간 구현 상속 불가 → 코드 중복 발생

🎯 Item 39: 명명 패턴보다 애너테이션을 사용하라

명명 패턴(Naming Pattern)이란

예전엔 테스트 메서드 이름을 testSomething()처럼 특정 패턴으로 짓고, 리플렉션으로 “이름이 test로 시작하면 테스트로 인정!” 이런 식으로 처리하곤 했다.

명명 패턴의 문제점

  1. 오탈자 나면 끝
    tsetSomething 이런 식으로 쓰면 테스트가 실행되지도 않고, IDE가 잡아주지도 않음.

  2. 적용 대상을 세밀하게 제한할 수 없음
    “메서드에만” 또는 “클래스에는 쓰면 안됨” 같은 규칙을 강제하지 못함.

  3. 메타 정보 전달 불가
    “이 메서드는 꼭 특정 예외를 던져야 성공” 같은 정보 전달이 불가능.

결국 패턴은 강제력 부족 + 안전성 부족이라는 치명적 약점을 가짐.

✔ 애너테이션(annotation)이 왜 훨씬 나은가?

애너테이션은 이 문제를 전부 해결함.

  • “어디에 붙을 수 있는지” 제한 가능 → @Target
  • 언제까지 유지할지 지정 가능 → @Retention
  • 메타정보 전달 가능 (인수 전달)
  • 런타임에도 리플렉션으로 존재 여부 확인 가능 → isAnnotationPresent()

🧪 예제 1 — 기본 마커 애너테이션 @MethodTest

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MethodTest { }
  • “테스트 메서드”라는 의미가 이름에 안 묻힘, 명확함
  • static만 허용한다든가 하는 규칙 위반을 바로 잡아낼 수 있음, (invoke(null) → 정적 아니면 NPE)

🧪 예제 2 — 특정 예외가 발생해야 성공하는 @ExceptionSingleTest

public @interface ExceptionSingleTest {
    Class<? extends Throwable> value();
}

애너테이션 값으로 기대하는 예외 타입을 넣어줌.

“이 메서드는 ArithmeticException이 터지면 오히려 성공이다.”

명명 패턴으로는 이런 동작 불가능.

🧪 예제 3 — 여러 예외를 허용하는 @ExceptionArrayTest

Class<? extends Throwable>[] value();

단일 예외가 아니라 예외 여러 개 허용.

A 예외 또는 B 예외가 나면 성공
이런 테스트도 애너테이션만으로 표현됨.

🧪 예제 4 — @Repeatable 로 가독성 개선하기

자바 8 이후 ‘반복 가능한 애너테이션’ 기능이 생김.

이전:

@ExceptionArrayTest({A.class, B.class, C.class})

이후(더 읽기 쉬움):

@ExceptionRepeatableTest(A.class)
@ExceptionRepeatableTest(B.class)
@ExceptionRepeatableTest(C.class)

그냥 붙이고 싶은 만큼 붙이면 됨.
대신 이를 지원하려면 컨테이너 애너테이션이 필요함.

배열 방식보다 훨씬 깔끔하고 읽기 쉬움.

결론

🔸 애너테이션을 쓰면…

  • 적용 범위(@Target) 명확히 제한 가능
  • 생존 기간(@Retention) 제어 가능
  • 매개변수(value, 배열 등)로 메타정보 전달 가능
  • 리플렉션에서 안전하고 정확하게 접근 가능
  • 반복 애너테이션(@Repeatable)로 가독성도 챙김

🔸 명명 패턴은 과거 방식일 뿐

오타, 강제력, 표현력 문제 때문에 modern Java에서는 사실상 쓸 이유가 없음.


🎯 Item 40: @Override 애너테이션을 일관되게 사용하라

❗ @Override를 안 쓰면 생기는 대표적인 실수

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.

이유는 간단함:

  • Set은 중복 판단을 equals와 hashCode로 판단
  • 근데 equals 오버라이드가 아닌 오버로딩이라
    → Object 기본 equals(== 주소 비교)가 호출됨
  • 매번 다른 Bigram 객체니까 중복이라고 판단되지 않음

그래서 결국 매번 새로운 객체로 저장 → 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를 붙이면?

  • 부모 클래스 메서드를 정확히 재정의하지 않으면 컴파일 에러로 알려줌
  • equals 시그니처가 조금이라도 틀리면 바로 잡아줌
  • 불필요한 오버로딩 실수를 매우 강하게 방지

결론

✔ 재정의하는 모든 메서드에 반드시 @Override 붙이기

  • equals, hashCode, toString 같은 Object 메서드 오버라이딩 시 특히 필수
  • 실수하면 바로 컴파일 에러라 실수 예방 효과가 큼

✔ 단 하나의 예외

  • 구체 클래스(Concrete Class)에서
    상위 클래스의 추상 메서드 구현하는 경우는 @Override 없어도 오류가 잘 안 나지만
    그래도 붙여도 문제 없음 → 오히려 유지보수 시 더 안전함

🎯 Item 41: 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라

마커 인터페이스란?

‘아무 메서드도 없는 인터페이스’
하지만 이걸 구현한 클래스는 “특정 속성을 가진다”는 것을 의미적으로 표시함.

예)
Serializable → 이 인터페이스를 구현한 객체는 직렬화 가능함.

public class User implements Serializable { ... }

이 한 줄만으로 “User는 직렬화 가능한 타입이다”라고 알려주는 셈.


마커 애너테이션 vs 마커 인터페이스

둘 다 “특정 특성 부여” 역할은 같지만, 각자 강점이 있음.

💪 마커 인터페이스의 장점

1) 타입으로 사용할 수 있다 → 컴파일 타임 오류 감지 가능

인터페이스라서 당연히 파라미터, 반환 타입으로 쓸 수 있음.

void save(Serializable data)

→ “직렬화 가능한 타입만 받겠다”는 제약을 컴파일 단계에서 걸 수 있음.

마커 애너테이션(@Something) 은 타입이 아니기 때문에 이런 식으로 사용할 수 없음.
그래서 실수도 런타임까지 가야 발견됨.


2) 적용 범위를 더 정확하게 제한 가능

특정 클래스 계층에만 적용하고 싶다면?

→ 그 계층의 인터페이스에만 ‘implements Marker’ 를 넣으면 됨.

즉,
“이 인터페이스를 구현한 클래스만 XXX 성질을 가진다” 라는 걸 구조적으로 보장할 수 있음.

애너테이션은 이런 “상속 구조 제약”을 표현하기 어려움.

ObjectOutputStream의 대표적 설계 미스

Serializable이 마커 인터페이스임에도 불구하고,
writeObject() 메서드의 시그니처가 이렇게 되어 있음:

public final void writeObject(Object obj)

타입이 Serializable이어야 하는데 Object로 받아버림.
그래서 직렬화 불가능한 객체를 전달해도 컴파일러가 아무 말도 못 함
결국 실행 중에야 오류가 발생.

out.writeObject(new NotSerializableClass()) // 컴파일 OK → 런타임 에러

이건 마커 인터페이스의 장점을 제대로 활용하지 못한 대표적인 사례.

마커 애너테이션의 장점

✔ 애너테이션 시스템의 풍부한 기능 사용 가능

스프링, JUnit 같은 애너테이션 기반 프레임워크에서는
@Service, @Test 같은 마커 애너테이션이 훨씬 자연스럽고 유연함.

✔ 리플렉션 기반 처리 / 툴링 친화적

프레임워크 입장에서 특정 기능을 붙여서 스캔하려면
애너테이션 방식이 훨씬 잘 맞음.

언제 무엇을 쓰면 좋은가?

타입으로 다뤄야 한다면 → 마커 인터페이스

  • 파라미터로 받고 싶다
  • 반환 타입으로 쓰고 싶다
  • 컴파일 타임에 실수를 잡고 싶다
  • 특정 클래스 계층에만 적용하고 싶다
    → 인터페이스가 더 적합

프레임워크에서 동작을 추가하기 위함 → 마커 애너테이션

  • DI, AOP, 설정, 테스트 프레임워크 같은 환경
    → 마커 애너테이션이 더 자연스러움

ElementType.TYPE 마커 애너테이션을 만들고 있다면?

→ “이거 인터페이스로 만드는 게 더 좋은 구조일까?” 다시 한번 생각해볼 것.


참고 블로그:
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

0개의 댓글