프로그래밍/Java

[스터디8] 06. 열거 타입과 애너테이션

mandus 2025. 6. 7. 19:54

목차

    6장. 열거 타입과 애너테이션

    자바에는 참조 타입이 두 가지가 있다.

    • 열거 타입(enum; 열거형): 클래스의 일종
    • 애너테이션(annotation): 인터페이스의 일종

    아이템 34. int 상수 대신 열거 타입을 사용하라

    • 열거 타입: 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입
    • 자바에서 열거 타입을 지원하기 전에는 정수 상수를 묶음 선언해서 사용했다.
      • 정수 열거 패턴(int enum pattern) 기법은 타입 안전을 보장할 방법이 없으며 표현력이 좋지 않다.
      • 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다.
      • 정수 상수는 문자열로 출력하기가 까다롭다.
      • 이러한 열거 패턴의 단점을 해결하는 대안으로 열거 타입(enum type)이 도입되었다.
    // 정수 열거 패턴 - 사용X
    public static final int APPLE_FUJI = 0;
    public static final int APPLE_PIPPIN = 1;
    public static final int APPLE_GRANNY_SMITH = 2;
    // 열거 타입
    public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
    • 열거 타입은 생성자를 제공하지 않으므로 final이다.
    • 따라서 싱글턴은 원소가 하나뿐인 열거 타입이라고 할 수 있고, 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있다.
    • 열거 타입은 컴파일타임 타입 안전성을 제공한다.
    • 열거 타입에는 임의의 메서드나 필드를 추가할 수 있고, 임의의 인터페이스를 구현하게 할 수 있다.
    // 데이터와 메서드를 가진 열거 타입
    public enum Planet {
        MERCURY(3.302e+23, 2.439e6),
        VENUS  (4.869e+24, 6.052e6),
        EARTH  (5.975e+24, 6.378e6),
        MARS   (6.419e+23, 3.393e6),
        JUPITER(1.899e+27, 7.149e7),
        SATURN (5.685e+26, 6.027e7),
        URANUS (8.683e+25, 2.556e7),
        NEPTUNE(1.024e+26, 2.477e7);
    
        private final double mass;      // 질량 (단위: 킬로그램)
        private final double radius;    // 반지름 (단위: 미터)
        private final double surfaceGravity; // 표면중력 (단위: m / s^2)
    
        // 중력상수 (단위: m^3 / kg s^2)
        private static final double G = 6.67300E-11;
    
        // 생성자
        Planet(double mass, double radius) {
            this.mass = mass;
            this.radius = radius;
            surfaceGravity = G * mass / (radius * radius);
        }
    
        public double mass() { return mass; }
        public double radius() { return radius; }
        public double surfaceGravity() { return surfaceGravity; }
    
        public double surfaceWeight(double mass) {
            return mass * surfaceGravity; // F = ma
        }
    }

    위와 같이 열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다. 열거 타입에서 상수를 하나 제거하면, 제거한 상수를 참조하지 않는 클라이언트에는 아무 영향이 없다.

     

    책에서는 필요한 원소를 컴파일티타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하라고 한다. 열거 타입에 정의된 상수 개수가 고정 불변일 필요는 없다.

     

    궁금한 점: 예전에 멘토님한테 '여기엔 열거 타입을 쓰지 말라'는 조언을 받은 적이 있는데 왜 안 되는 걸까?

     

    당시의 상황이 자세히 기억이 나지 않아 해당 질문을 AI에게 물어봤다. 다음과 같은 경우가 있는데, 아마도 2번째 상황 때문에 안 된다고 하셨던 것 같다.

     

    1. 열거 타입이 너무 자주 변경되거나 확장될 가능성이 있는 경우

    • 어떤 경우인가?
      • 서비스의 정책, 상태 코드, 타입 등이 매우 유동적이어서 자주 추가되거나 수정될 가능성이 있는 경우입니다. 예를 들어, 웹훅 이벤트 타입, 결제 수단 종류, 상품 카테고리 등이 초기에 예측하기 어려운 정도로 빠르게 변경될 수 있습니다.
      • 열거 타입은 컴파일 시점에 모든 상수가 결정되므로, 새로운 상수가 추가되면 애플리케이션을 다시 컴파일하고 배포해야 합니다. 이는 CI/CD 파이프라인이 잘 구축되어 있더라도 빈번한 배포를 야기하고, 특히 운영 환경에서 장애의 위험을 높일 수 있습니다.
    • 대안은 무엇인가?
      • 데이터베이스 기반 관리: 변경 가능성이 높은 타입들은 데이터베이스 테이블에 저장하여 런타임에 조회하도록 합니다.
        • 장점: 코드 변경 없이 데이터만으로 관리 가능, 유연한 확장, 운영 편의성 증대.
        • 예시: payment_methods 테이블에 id, name, code, is_active 등의 컬럼을 두어 관리.
      • 외부 설정 파일 (예: YAML, properties): 비교적 변경 빈도가 낮지만 재배포 없이 변경이 필요한 경우, 외부 설정 파일을 활용할 수 있습니다.
        • 장점: 코드와 분리하여 관리, 재배포 없이 변경 가능.
        • 단점: 데이터베이스만큼 유연하지 않음, 복잡한 비즈니스 로직 연동은 어려움.
      • 서비스 레이어 패턴 (Service Layer Pattern): 각 타입별로 다른 비즈니스 로직이 필요한 경우, 전략 패턴(Strategy Pattern)과 함께 데이터베이스에 저장된 코드를 기반으로 특정 구현체를 동적으로 주입받아 사용하는 방식.
        • 장점: 유연한 비즈니스 로직 확장, OCP(개방-폐쇄 원칙) 준수.

    2. 열거 타입이 너무 많은 데이터를 포함하거나 복잡한 로직을 가질 때

    • 어떤 경우인가?
      • 열거 타입 내부에 너무 많은 필드(속성)가 있거나, 각 상수에 따라 복잡한 비즈니스 로직이 달라지는 경우입니다.
      • 열거 타입이 마치 작은 클래스처럼 기능하며, 해당 열거 타입의 역할이 "단순한 상수 집합"을 넘어설 때.
    • 대안은 무엇인가?
      • 도메인 객체(Domain Object) 또는 값 객체(Value Object) 사용: 열거 타입 상수에 연결된 데이터가 많다면, 별도의 클래스로 분리하여 도메인 객체로 관리합니다.
        • 장점: 객체 지향적 설계, 책임 분리, 테스트 용이성 증대.
        • 예시: OrderStatus Enum이 아니라 OrderStatus 클래스를 만들고, 이 클래스에 name, description, nextPossibleStates 등 필드를 정의.
      • 전략 패턴(Strategy Pattern) 또는 Command 패턴: 각 상수에 따라 달라지는 복잡한 로직은 별도의 전략(Strategy) 구현체로 분리하고, 열거 타입은 해당 전략 구현체를 선택하는 "키" 역할만 하도록 합니다.
        • 장점: 각 전략별 구현체는 자신의 책임만 가지므로 응집도 높아짐, 새로운 전략 추가 시 기존 코드 수정 최소화.

    3. 클라이언트가 열거 타입의 존재를 몰라야 하는 경우 (숨겨야 할 때)

    • 어떤 경우인가?
      • API 응답으로 열거 타입 이름을 직접 노출하는 것을 피하고 싶을 때. 예를 들어, 내부적으로는 Enum을 사용하지만 외부에는 표준화된 문자열 코드나 숫자로만 노출하고 싶을 때.
    • 대안은 무엇인가?
      • DTO (Data Transfer Object) 활용: API 응답 시 열거 타입을 직접 반환하는 대신, DTO를 통해 특정 문자열 값이나 코드 값을 반환합니다. DTO 내부에서 Enum을 String으로 변환하여 노출합니다.
      • Converter/Formatter 사용: 스프링에서 제공하는 Converter나 Formatter 인터페이스를 구현하여, 특정 타입과 열거 타입 간의 변환 로직을 정의할 수 있습니다. 이를 통해 API 입력/출력에서 유연성을 확보합니다.

    아이템 35. ordinal 메서드 대신 인스턴스 필드를 사용하라

    대부분의 열거 타입 상수는 하나의 정수값에 대응된다. 그리고 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는 ordinal 메서드를 제공한다.

    • ordinal을 잘못 사용하면 코드가 깔끔하지 못할 뿐만 아니라, 쓰이지 않는 값이 많아질수록 실용성이 떨어진다.
    • 열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지 말고, 인스턴스 필드에 저장한다.

    아이템 36. 비트 필드 대신 EnumSet을 사용하라

    • 비트 필드(bit field): 비트별 OR을 사용해 여러 상수를 모은 하나의 집합
      • 비트별 연산을 사용해 합집합과 교집합 같은 집합 연산을 효율적으로 수행한다.
      • 정수 열거 상수의 단점을 가지고 있으며, 비트 필드 값이 그대로 출력되면 단순한 정수 열거 상수를 출력할 때보다 해석하기 어렵다.
      • EnumSet 클래스는 열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다. EnumSet의 내부는 비트 백터로 구현되어 있어 EnumSet 전체를 long 변수 하나로 표현하여 비트 필드에 비견되는 성능을 보인다. EnumSet의 유일한 단점은 불변 EnumSet을 만들 수 없다는 것이다.
    // 비트 필드 열거 상수 - 사용X
    public class Text {
        public static final int STYLE_BOLD          = 1 << 0; // 1
        public static final int STYLE_ITALIC        = 1 << 1; // 2
        public static final int STYLE_UNDERLINE     = 1 << 2; // 4
        public static final int STYLE_STRIKETHROUGH = 1 << 3; // 8
    
        // 매개변수 styles는 0개 이상의 STYLE_ 상수를 비트별 OR 값이다.
        public void applyStyles(int styles) { ... }
    }
    
    text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
    // EnumSet - 비트 필드 대체
    public class Text {
        public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
    
        // 어떤 Set을 넘겨도 되나, EnumSet이 가장 좋다.
        public void applyStyles(Set<Style> styles) { ... }
    }
    
    text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));

    아이템 37. ordinal 인덱싱 대신 EnumMap을 사용하라

    배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드로 인덱스를 얻는 경우가 있다.

    • 배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고, 깔끔하게 컴파일되지 않는다.
    • 열거 타입을 키로 사용하도록 설계한 EnumMap을 사용할 수 있다.
    // ordinal()을 배열 인덱스로 사용 - 사용X
    Set<Plant>[] plantsByLifeCycle =
        (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
    for (int i = 0; i < plantsByLifeCycle.length; i++)
        plantsByLifeCycle[i] = new HashSet<>();
    
    for (Plant p : garden)
        plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
    
    // 결과 출력
    for (int i = 0; i < plantsByLifeCycle.length; i++) {
        System.out.printf("%s: %s%n",
                          Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
    }
    // EnumMap으로 데이터와 열거 타입 매핑
    Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =
        new EnumMap<>(Plant.LifeCycle.class);
    for (Plant.LifeCycle lc : Plant.LifeCycle.values())
        plantsByLifeCycle.put(lc, new HashSet<>());
    for (Plant p : garden)
        plantsByLifeCycle.get(p.lifeCycle).add(p);
    System.out.println(plantsByLifeCycle);

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

    열거 타입은 거의 모든 상황에서 타입 안전 열거 패턴(typesafe enum pattern)보다 우수하다.

    • 타입 안전 열거 패턴은 확장할 수 있으나 열거 타입은 그럴 수 없다. 따라서 인터페이스와 그 인터페이스를 구현하는 기본 열거 타입을 함께 사용해 같은 효과를 낸다.
    • 연산 코드(operation code 또는 opcode)에서는 인터페이스를 구현한 확장할 수 있는 열거 타입을 사용한다.
    • 인터페이스를 이용해 확장 가능한 열거 타입을 흉내내는 방식은 열거 타입끼리 구현을 상속할 수 없다.
    // 확장 가능 열거 타입
    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;
        }
    }

    아이템 39. 명명 패턴보다 애너테이션을 사용하라

    전통적으로 도구나 프레임워크가 특별이 다루는 프로그램 요소에는 구분되는 명명 패턴을 적용해왔다. 하지만 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.

    • 명명 패턴의 단점
      1. 오타가 나면 안 된다.
      2. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
      3. 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.
    • 메타 애너테이션(meta-annotation): 애너테이션 선언에 다는 애너테이션. 예) @Retention, @Target
    • 마커(marker) 애너테이션: 아무 매개변수 없이 단순히 대상에 마킹하는 애너테이션. 예) Test
    // 마커 애너테이션 타입 선언
    import java.lang.annotation.*;
    
    /**
     * 테스트 메서드임을 선언하는 애너테이션이다.
     * 매개변수 없는 정적 메서드 전용이다.
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface Test {
    }
    // 마커 애너테이션을 사용한 프로그램
    public class Sample {
        @Test public static void m1() { } // 성공해야 한다.
        public static void m2() { }
        @Test public static void m3() { // 실패해야 한다.
            throw new RuntimeException("실패");
        }
        public static void m4() { }
        @Test public static void m5() { } // 잘못 사용한 예: 정적 메서드가 아니다.
        public static void m6() { }
        @Test public static void m7() { // 실패해야 한다.
            throw new RuntimeException("실패");
        }
        public static void m8() { }
    }

    아이템 40. @Override 애너테이션을 일관되게 사용하라

    @Override는 메서드 선언에만 달 수 있으며, 이 애너테이션이 달렸다는 것은 상위 타입의 메서드를 재정의했음을 뜻한다. 상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 다는 것이 좋다.

    // 영어 알파벳 2개로 구성된 문자열을 표현하는 클래스 - @Override 컴파일 오류 발생
    public class Bigram {
        private final char first;
        private final char second;
    
        public Bigram(char first, char second) {
            this.first = first;
            this.second = second;
        }
    
        @Override public boolean equals(Bigram b) {
            return b.first == first && b.second == second;
        }
    
        public int hashCode() {
            return 31 * first + second;
        }
    
        public static void main(String[] args) {
            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));
            System.out.println(s.size());
        }
    }

    아이템 41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라

    • 마커 인터페이스(marker interface): 아무 메서드도 담고 있지 않고, 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스. 예) Serializable 인터페이스
    • 마커 애너테이션과 비교
      1. 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나, 마커 애너테이션은 그렇지 않다.
      2. 마커 인터페이스는 적용 대상을 더 정밀하게 지정할 수 있다.
      3. 마커 애너테이션은 거대한 애너테이션 시스템의 지원을 받는다.
    • 새로 추가하는 메서드 없이 타입 정의가 목적이라면 마커 인터페이스를, 클래스나 인터페이스 외의 프로그램 요소에 마킹해야 하거나 애너테이션을 적극 활용하는 프레임워크의 일부로 그 마커를 편입시키고자 한다면 마커 애너테이션을 선택한다.
      마커 인터페이스 마커 애너테이션
    목적 타입 정의, 컴파일타임 검사 메타데이터 추가, 런타임/도구 처리
    적용 대상 클래스, 인터페이스 모든 프로그램 요소
    타입 검사 컴파일타임 타입 검사 가능 직접적인 타입 검사 불가 (도구 사용)
    확장성 메서드 추가 시 기존 코드 변경 필요 요소 추가 시 기존 코드 변경 불필요
    계층 타입 계층 구조의 일부 독립적인 메타데이터
    예시 Serializable, Cloneable Override, Deprecated, Test

     

    이 글은 『 이펙티브 자바』 책을 학습한 내용을 정리한 것입니다.