astrod

이펙티브 자바 3판 - 열거 타입과 애너테이션

2020-07-18

int 상수 대신 열거 타입을 사용하라

int 상수를 사용하는 대신, enum 클래스를 생성하는 방법

int 상수를 사용하는 방법으로 상당이 취약하다

public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
  • 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다.
  • 문자열로 출력하기가 다소 까다롭다.

이를 처리하기 위해 enum 을 사용하는 것이 훨씬 좋다. java 의 enum 은 클래스로, 다른 언어의 열거 타입보다 훨씬 강력하다.

public enum Apple {FUJI, PIPPIN, GRANNY_SMITH}

열거 타입은 클래스이며, 상수 하나당 자신의 인스턴스를 만들어 public static final 필드로 공개한다. 사실상 fianl 이고 열거 타입은 생성자를 제공하지 않으므로 한 개의 인스턴스만 존재하게 된다.

  • 열거 타입은 컴파일 타임 안정성을 제공한다.
  • 각기 클래스 안에 선언되기 때문에, 이름이 같은 상수도 평화롭게 공존한다.
  • toString 메서드가 읽기 훨씬 좋다.

이 외에도 클래스이기 때문에 임의이 메서드나 필드를 추가할 수 있고, 인터페이스를 구현할 수도 있다.

주의깊게 볼 만한 것은, enum 값에 따라 각기 다른 액션을 시행해야 하는 때이다. 일반적으로는 다음과 같이 구현된다.

public enum Operation {
    PLUS, MINUS, TIMES, DIVIDE;

    public double apply(double x, double y) {
        switch(this) {
            case PLUS : return x + y;
            case MINUS: return x - y;
            case TIMES: return x * y;
            case DIVIDE: return x / y;
        }
        throw new AssertionError("알 수 없는 연산 : " + this);
    }   
}

이 코드는 깨지기 쉬운 코드이다. 새로운 상수를 추가하면 해당 case 문도 추가해야 한다. 혹시라도 깜빡한다면, Assertion 에러가 발생하게 될 것이다. 다행히도 열거 타입은 좀 더 깔끔한 방식을 지원한다.

public enum 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;}},

    public abstract double apply(double x, double y);
}

이와 같이 apply 라는 추상 메서드를 선언하고, 각 상수에서 해당 메서드를 재정의하게 할 수 있다. 이와 같이 처리하면 새 enum 을 추가할 때 만약 로직을 추가하는 것을 잊어버렸다면 컴파일 에러로 알려줄 것이다.

필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자

ordinal 메서드 대신 인스턴스 필드를 사용하라

ordinal 이란, enum 순서를 반환하는 메서드가 있다고 들어본 거 같긴 한데, 한번도 사용한 적은 없다. 특별한 경우가 아니라면 앞으로도 사용하지 않을 거 같다.

비트 필드 대신 EnumSet 을 사용하라

비트 필드는 다음과 같은 구닥다리 기법이다.

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
}

다음과 같은 식으로 여러 상수를 하나의 집합으로 모을 수 있는데, 비트 필드로는 다음과 같이 교집합이나 합집합을 효율적으로 사용할 수 있다.

text.applyStyles(STYLE_BOLD | STYLE_ITALIC);

이는 10 비트와 01 비트의 or 연산으로, 결과값은 11이 되어 두 개 비트가 모두 on 인 합집합인 상태로 전송된다. 이 방법은 많은 문제점이 있다.

  • 해석하기가 enum 상수보다 훨씬 까다롭다.
  • 비트 필드 하나의 모든 원소를 체크하기 어렵다.
  • 최대 몇 비트가 필요한지 API 작성시에 먼저 예측하여야 한다. API 를 수정하지 않고 비트 수를 늘릴 수 없기 때문이다.

EnumSet 은 비트 필드의 상위 호환이다. 원소가 64개 이하라면, 즉 대부분의 경우 EnumSet 전체를 long 변수 하나로 표현하여 비트 필드에 비견되는 성능을 보여준다.

public class Text {
    public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH}

    // 어떤 set 을 넘겨도 되나, EnumSet 이 가장 좋다. 다만 인터페이스를 사용하는 것이 좋기 때문에 파라미터를 Set 으로 선언한다.
    public void applyStyles(Set<Style> styles) {...}
}

text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));

ordinal 인덱싱 대신 EnumMap을 사용하라

ordinal 인덱싱, 즉 enum 의 ordinal 값을 키로 사용하는 대신에, Enum 값 자체를 key 로 하는 맵을 사용하라. EnumMap 이라는 자료구가 존재한다.

Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);
for(Plant.LifeCycle lc : Plant.LifeCycle.values()) {
    plantsByLifeCycle.put(lc, new HashMap<>());
}
for(Plant p : garden) {
    plantsByLifeCycle.get(p.lifeCycle).add(p);
}

스트림을 사용하여 Map 을 쓸 수도 있다.

System.out.println(Arrays.stream(garden)
                .collect(groupingBy(p -> p.lifeCycle)));

다만 이 경우에는 EnumMap 을 사용했을 때 얻을 수 있는 공간과 성능 이점이 사라진다. 이를 방지하기 위해 groupingBy 메서드에 원하는 맵 구현체를 명시해 호출할 수 있다.

System.out.println(Arrays.stream(garden)
                .collect(groupingBy(p -> p.lifeCycle, () -> new EnumMap<>(LifeCycle.class), toSet())));

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

대부분 상황에서 enum 을 확장하는 것은 좋지 못한 생각이다. 확장한 타입의 원소는 기반 타입의 원소로 성립하지만, 그 반대는 성립하지 않기 때문이다. 그러나 연산 코드(operation code, 혹은 opcode) 는 클라이언트에서 사용자 확장 연산을 추가할 수 있게 열어줘야 할 필요가 있다. 이 경우 인터페이스를 사용할 수 있다. 먼저, 연산 코드용 인터페이스를 정의하고 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;
    }
}

이제 이 인터페이스를 확장하여 연산의 타입으로 사용하면 된다. 지수 연선과 나머지 연산을 추가해 보자

public enum ExtendedOperation implements Operation {
    EXP("^") {
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMINDER("%") {
        public double apply(double x, double y) {
            return x % y;
        }
    };

    private final String symbol;

    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }
}

명명 패턴보다 애너테이션을 사용하라

메서드 시작을 test 로 한다는 깨지기 쉬운 규약보단, 애너테이션을 사용하여 규약을 강제하는 편이 좋다. 도구 제작자를 제외하고 일반 개발자가 애너테이션을 제작할 일은 거의 없다. 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다. 다음 테스트 도구 제작 예제를 확인해 보자

// 매개변수 없는 정적 메서드 전용이다.
@Retention(RetentionPolicy.RUNTIME) // 해당 애노테이션이 runtime 에도 유지되어야 한다.
@Target(ElementType.METHOD) // @Test 는 반드시 메서드에만 사용되어야 한다.
public @interface Test {

}

public class Sample {
    @Test public static void m1(){ } // 성공해야 한다.
    public static void m2(){} 
    @Test public static void m2(){ // 실패해야 한다.
        throw new RuntimeExectpion("실패");
    } 
    @Test public void m5() { } // 잘못 사용한 예 : 정적 메서드가 아니다.
}

Test 를 붙이지 않은 메서드는 테스트 도구가 무시할 것이다. @Test 는 클래스에 무언가 의미를 부여하는 것이 아니다. 다만, 다른 프로그램이 해당 클래스의 애노테이션을 확인하여 처리할 수 있다.

public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) { // 테스트 메서드가 예외를 던진 경우 리플렉선 메커니즘이 이 에러로 감싸서 다시 에러를 반환한다.
                    Throwable exc = wrappedExc.getCause();
                    System.out.println(m + " 실패: " + exc);
                } catch (Exception exc) {
                    System.out.println("잘못 사용한 @Test : " + m);
                }
            }
        } 
        System.out.println("성공 : %d, 실패\: %d%n", passed, tests - passed); 
    }
}

이제 특정 예외를 던져야 성공하는 테스트를 지원해 보자

// 명시한 예외를 던져야 성공하는 테스트 메서드용 애너테이션
@Retention(RetentionPolicy.RUNTIME) // 해당 애노테이션이 runtime 에도 유지되어야 한다.
@Target(ElementType.METHOD) // @Test 는 반드시 메서드에만 사용되어야 한다.
public @interface ExceptionTest {
    Calss<? extends Throwable> value();
}

public class Sample2 {

    @ExceptionTest(ArithmeticException.class) 
    public static void m1() {// 성공해야 한다.
        int i = 0;
        i = i / i; 
    } 

    @ExceptionTest(ArithmeticException.class) 
    public static void m2() {// 다른 예외 발생. 실패해야 한다
        int [] a = new int[0];
        int i = a[1];
    } 

    @ExceptionTest(ArithmeticException.class) 
    public static void m3() {// 예외가 발생하지 않음. 실패해야 한다
    } 
}

이제 이 애너테이션을 다룰 수 있게 테스트 코드를 수정하자

public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        for (Method m : testClass.getDeclaredMethods()) {
            if (m.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) { // 테스트 메서드가 예외를 던진 경우 리플렉선 메커니즘이 이 에러로 감싸서 다시 에러를 반환한다.
                    Throwable exc = wrappedExc.getCause();
                    Class<? extends Throwable> excType = m.getAnnotation(ExecptionTest.class).value();
                    if(exeType.isInstance(exc)) {
                        passed++;
                    } else {
                        System.out.println("테스트 %s 실패 : 기대한 예외 %s, 발생한 예외 %s%n", m, excType.getName(), exc);
                    }
                } catch (Exception exc) {
                    System.out.println("잘못 사용한 @Test : " + m);
                }
            }
        } 
        System.out.println("성공 : %d, 실패\: %d%n", passed, tests - passed); 
    }
}

애너테이션을 선언하고 처리하는 부분에서 코드 양이 늘어나며, 처리 코드가 복잡해 질 수 있어 오류가 커질 수 있음을 명심하자.

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

상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 달자. 예외는 단 한 가지뿐이다. 구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때는 @Override 를 달지 않아도 된다.

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

아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스를 마커 인터페이스라 한다. 마커 애너테이션이 등장하면서 마커 인터페이스는 구식이 되었다는 이야기도 있지만, 사실이 아니다. 마커 인터페이스는 두 가지 장점이 있다.

  1. 마커 인터페이스는 이를 구현한 클래스의 인스턴스를 구분하는 타입으로 사용할 수 있다.
  2. 적용 대상을 더 정밀하게 지정할 수 있다.

반대로 마커 애너테이션은 거대한 애너테이션 시스템의 지원을 받는다는 점에서 마커 인터페이스보다 낫다. 그러면 어느 때에 어떤 것을 사용하여야 하는가?

  마커 애너테이션 마커 인터페이스
클래스와 인터페이스 외의 프로그램 요소에 마킹 O X
이 마킹이 된 객체를 매개변수로 받을 필요 있음 X O
애너테이션을 적극적으로 사용하는 프로젝트 O X

다음과 같은 판단 기준을 활용하라


Similar Posts

Content