BOOK 6 - 이펙티브 자바(6)

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

  • 자바에는 특수한 목적의 참조 타입이 두가지 있다.
  • 하나는 클래스의 일종인 열거 타입이고, 다른 하나는 인터페이스의 일종인 애너테이션이다.

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

  • 열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다.

정수 열거 패턴

  • 열거 타입을 지원하기 전에는 다음과 같은 정수 열거 패턴을 많이 사용하였다.
public static final int APPLE_FUJI = 0;
public static final int AAPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;

public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;
  • 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다.

정수 열거 패턴의 단점

  • 자바가 정수 열거 패턴을 위한 별도의 namespace를 지원하지 않기 때문에 APPLE , ORANGE 같은 접두어를 사용해서 이름 충돌을 방지해야 한다.
  • 평범한 상수를 나열한 것이기 때문에, 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨진다.
    • 상수의 값이 바뀐다면 반드시 클라이언트도 다시 컴파일해야 한다.
  • 문자열로 출력하기가 까다롭다.
    • 값을 출력하거나 디버거로 확인했을 때 단순히 숫자로만 보여서 크게 도움이 되지 않는다.

열거 타입 (enum type)

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum ORANGE { NAVEL, TEMPLE, BLOOD }
  • 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
  • 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final이다.
    • 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없기 때문에 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재함이 보장된다.
    • 즉, 열거 타입은 인스턴스 통제된다.

열거 타입의 장점

  • 컴파일타임 타입 안정성을 제공한다.
    • Apple 열거 타입을 매개변수로 받는 메서드에 다른 타입의 값을 넘기려 하면 컴파일 오류가 발생한다.
  • 각자의 namespace가 있어서 이름이 같은 상수도 공존할 수 있다.
  • 공개되는 것이 오직 필드의 이름 뿐이기 때문에 상수 값이 클라이언트로 컴파일되어 각인되지 않는다.
  • toString 메서드가 출력하기에 적합한 문자열을 내어준다.

열거 타입에 메서드, 필드 추가

  • 열거 타입에는 메서드나 필드를 추가할 수 있고, 임의의 인터페이스를 구현하게 할 수도 있다.
  • Object 메서드들을 높은 품질로 구현해놨고, ComparableSerializable 을 구현했으며, 그 직렬화 형태도 웬만큼 변형을 가해도 문제없이 동작하게끔 구현해놨다.

  • 즉, 열거 타입으로 고차원의 추상 개념 하나를 완벽히 표현해낼 수 있다.
행성 열거 타입 예제
public enum Planet {
  MERCURY(3.302e+23, 2.439e6),
  VENUS(..., ...),
  EARTH(..., ...),
  MARS(..., ...),
  JUPITER(..., ...),
  SATURN(..., ...),
  URANUS(..., ...),
  NEPTUNE(..., ...);
  
  private final double mass;
  private final double radius;
  private final double surfaceGravity;
  
  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;
  }
}
  • 각 행성에는 질량과 반지름이 있고, 이 두 속성을 이용해 표면중력을 계산할 수 있다.
  • 어떤 객체의 질량이 주어지면 그 객체가 행성 표면에 있을 때의 무게도 계산할 수 있다.

  • 열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.
  • 열거 타입은 근본적으로 불변이라 모든 필드는 final 이어야 한다.
    • 필드를 public 으로 선언해도 되지만, private 으로 두고 별도의 접근자 메서드를 두는게 낫다.
행성 열거 타입 클라이언트 예제
public class WeightTable {
  public static void main(String[] args) {
    double earthWeight = Double.parseDouble(args[0]);
    double mass = earthWeight / Planet.EARTH.surfaceGravity();
    for (Planet p : Planet.values()) {
      System.out.printf("%s에서의 무게는 %f이다.%n", p, p.surfaceWeight(mass));
    }
  }
}
  • 열거 타입은 자신 안에 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드인 values 를 제공한다.
  • toString 메서드는 상수 이름을 문자열로 반환한다.

기타 유의 사항

  • 열거 타입에서 상수 하나를 제거하면 어떻게 될까?
    • 제거된 상수를 참조하하는 클라이언트 코드에서 컴파일 오류가 발생할 것이다.
  • 널리 쓰이는 열거 타입은 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스로 만든다.
    • 예를 들어 소수 자릿수의 반올림 모드를 뜻하는 열거 타입인 java.math.RoundingModeBigDecimal 이 사용한다.
    • 하지만 반올림 모드는 다른 영역에서도 유용한 개념이라 자바 라이브러리 설계자는 RoundingMode 를 톱레벨로 올렸다.

상수별 메서드 구현

  • 상수마다 동작이 달라져야 하는 경우, 첫번째로 다음과 같이 switch 문을 이용해 분기하는 방법을 쓸 수 있다.
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);
  }
}
  • 이 방식은 동작은 하지만 그리 편리하지는 않다.
  • 실제로 throw 문에 도달할 일이 없지만 컴파일 오류를 막기 위해 추가해주어야 한다.
  • 새로운 상수를 추가하면 해당 case 문도 추가해야 한다.

  • 열거 타입에 apply 라는 추상메서드를 선언하고, 각 상수별 클래스 몸체, 즉 각 상수에서 자신에 맞게 재정의하는 방법으로 더 나은 코드를 작성할 수 있다.
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 appy(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);
}

전략 열거 타입 패턴

  • 상수별 메서드 구현은 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있다.

  • 급여명세서에 쓸 요일을 표현하는 열거 타입을 예로 들어보자.

    • 주중에 오버타임이 발생하면 잔업 수당이 주어지고, 주말에는 무조건 잔업 수당이 주어진다.
switch문 활용
enum PayrollDay {
  MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
  
  private static final int MINS_PER_SHIFT = 8 * 60;
  
  int pay(int minutesWorked, int payRate) {
    int basePay = minutesWorked * payRate;
    
    int overtimePay;
    switch(this) {
      case SATURDAY: case SUNDAY:
        overtimePay = basePay / 2;
        break;
      default:
        overtimePay = minutesWorked <= MINS_PER_SHIFT ?
          0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
    }
    return basePay + overtimePay;
  }
}
  • 간결하지만 관리 관점에서 위험한 코드이다.
  • 휴가와 같은 새로운 값을 열거 타입에 추가하려면 그 값을 처리하는 case 문을 잊지말고 넣어줘야 한다.
상수별 메서드 구현
  1. 잔업수당을 계산하는 코드를 모든 상수에 중복해서 넣는다.
  2. 계산 코드를 평일용, 주말용으로 나눠 가각을 도우미 메서드로 작성한 다음 각 상수가 자신에게 필요한 메서드를 호출하도록 한다.
  • 위 방법들은 코드가 장황해져 가독성이 떨어지고 오류 발생 가능성도 높다.
  • 또 다른 방법으로, 평일 잔업수당 계산용 메서드인 overtimePay 를 구현해놓고, 주말 상수에서만 재정의해 쓰면 코드는 간단해지지만, switch 문을 사용했을 때와 같은 문제가 발생한다.
    • 새로운 상수 추가시 overtimePay 메서드를 재정의하지 않으면 의도대로 동작하지 않을수도 있다.
전력 열거 타입 패턴 활용
  • 잔업 수당 계산을 private 중첩 열거 타입으로 옮기고, PayrollDay 열거 타입의 생성자에서 이 중 적당한 것을 선택한다.
enum PayrollDay {
  MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY), THURSDAY(WEEKDAY), FRIDAY(WEEKDAY), SATURDAY(WEEKEND), SUNDAY(WEEKEND);
  
  private final PayType payType;
  
  PayrollDay(PayType payType) {
    this.payType = payType;
  }
  
  int pay(int minutesWorked, int payRate) {
    return payType.pay(minutesWorked, payRate);
  }
  
  enum PayType {
    WEEKDAY {
      int overtimePay(int minsWorked, int payRate) {
        return minsWorked <= MINS_PER_SHIFT ? 
          0 : (minsWorked - MINS_PER_SHIFT) * payRate / 2;
      }
    },
    WEEKEND {
      int overtimePay(int minsWorked, int payRate) {
        return minsWorked * payRate / 2;
      }
    };
    
    abstract int overtimePay(int minsWorked, int payRate);
    private static final int MINS_PER_SHIFT = 8 * 60;
    
    int pay(int minsWorked, int payRate) {
      int basePay = minsWorked * payRate;
      return basePay + overtimePay(minsWorked, payRate);
    }
  }
}
  • 새로운 상수를 추가할 때 잔업수당 ‘전략’을 선택하도록 한다.
  • PayrollDay 열거 타입은 잔업수당 계산을 전략 열거 타입에 위임하여 switch 문이나 상수별 메서드 구현이 필요없게 된다.

valueOf / fromString

  • 열거 타입에는 상수 이름을 입력받아 그 이름에 해당하는 상수를 반환해주는 valueOf 메서드가 자동 생성된다.
  • 열거 타입의 toString 메서드를 재정의할 때는 toString 이 반환하는 문자열을 해당 열거 타입 상수로 변환해주는 fromString 메서드도 함께 제공하는 것이 좋다.
private static final Map<String, Operation> stringToEnum = 
  Stream.of(values()).collect(toMap(Object::toString, e -> e));

public static Optional<Operation> fromString(String symbol) {
  return Optional.ofNullable(stringToEnum.get(symbol));
}
  • Operation 상수가 stringToEnum 맵에 추가되는 시점은 열거 타입 상수 생성 후 정적 필드가 초기화될 때이다.
    • 열거 타입 상수는 생성자에서 자신의 인스턴스를 맵에 추가할 수 없다.
    • 열거 타입의 정적 필드 중 열거 타입의 생성자에서 접근할 수 있는 것은 상수 변수뿐이다.
    • 열거 타입 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전이라, 자기 자신을 추가하지 못하게 하는 제약이 필요하다.
  • 입력받은 symbol 문자열이 가리키는 연산이 존재하지 않을 수 있음을 클라이언트에 알리기 위해 반환 타입은 Optional<Operation> 으로 설정하였다.

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

  • 모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는 ordinal 메서드를 제공한다.
  • 하지만 다음과 같이 ordinal 메서드를 잘못 사용해서는 안된다.
public enum Ensemble {
  SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET, OCTET, NONET, DECTET;
  
  public int numberOfMusicians() { return ordinal() + 1; }
}
  • 동작은 하지만 유지보수하기 안좋다.
  • 상수 선언 순서를 바꾸는 순간 numberOfMusicians 메서드가 오동작 한다.
  • 이미 사용중인 정수와 값이 같은 상수는 추가할 수 없다.
    • 예를 들어, 8중주(octet) 상수가 이미 있으니 똑같이 8명이 연주하는 복4중주(double quartet)는 추가할 수 없다.
  • 값을 중간에 비워둘 수 없다.
    • 예를 들어, 12명이 연주하는 3중 4중주(triple quartet)를 추가하고 싶으면 중간에 11명짜리 상수도 채워야 한다.
    • 하지만 11명으로 구성된 연주를 일컫는 이름이 없기 때문에 더미 상수를 추가해야만 한다.

해결책

  • 열거 타입 상수에 연결된 값은 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 size) { this.numberOfMusicians = size; }
  
  public int numberOfMusicians() { return numberOfMusicians; }
}

비트 필드 대신 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;
  
  public void applyStyles(int styles) { ... }
}
text.applyStyles(STYLE_BOLD | STYLE_ITALIC)
  • 비트별 OR를 사용해 여러 상수를 하나의 집합으로 모을 수 있다.
  • 비트 필드를 사용하면 비트 연산을 사용해 합집합과 교집합 같은 집합 연산을 효율적으로 수행할 수 있지만, 정수 열거 상수의 단점을 그대로 가지고 있다.
  • 또한, 최대 몇 비트가 필요한지 미리 예측하여 적절한 타입을 선택해야 한다.
    • API를 수정하지 않고는 비트 수를 더 늘릴 수 없기 때문이다.

EnumSet

  • EnumSet 클래스는 열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다.
  • Set 인터페이스를 완벽히 구현하며, 타입 안전하고, 다른 어떤 Set 구현체와도 함께 사용할 수 있다.
public class Text {
  public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
  
  public void applyStyles(Set<Style> styles) { ... }
}
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
  • EnumSet 의 내부는 비트 벡터로 구현되었다.
  • 원소가 64개 이하(즉 대부분의 경우)일 때 EnumSet 전체를 long 변수 하나로 표현하여 비트 필드에 견주는 성능을 보여준다.
    • 그러면서도 비트를 직접 다룰 때 흔히 겪는 오류들에서 해방된다.

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

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

Plant 예제 - ordinal을 사용하는 배열

class Plant {
  enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
  
  final String name;
  final LifeCycle lifeCycle;
  
  Plant(String name, LifeCycle lifeCycle) {
    this.name = name;
    this.lifeCycle = lifeCycle;
  }
  
  @Override 
  public String toString() {
    return name;
  }
}
  • garden 배열에 심은 식물들을 관리하고, 이들을 LifeCycle 별로 묶어 집합에 넣는 코드를 살펴보자.
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 < plantByLifeCycle.length; i++) {
  System.out.printf("%s : %s%n", Plant.LifeCycle.values()[i], plantByLifeCycle[i]);
}
  • 집합들을 배열 plantsByLifeCycle 에 넣고, LifeCycleordinal 값을 그 배열의 인덱스로 사용하고 있다.

  • 위 코드는 동작은 하지만 문제가 많다.
  • 배열은 제네릭과 호환되지 않기 때문에 비검사 형변환을 수행해야 하고, 이는 컴파일 경고를 발생시킨다.
  • 가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 직접 보증해야 한다는 점이다.
    • 정수는 열거 타입과 달리 타입 안전하지 않기 때문이다.
    • 잘못된 값을 사용하면 잘못된 동작을 수행하거나 ArrayIndexOutOfBoundsException 을 던질 것이다.

해결책 - EnumMap 사용

  • 열거 타입을 키로 사용하도록 설계한 아주 빠른 EnumMap 구현체가 있다.
Map<Plant.LifeCycle, Set<Plant>> plantByLifeCycle = new EnumMap<>(Plant.LifeCycle.class);

for (Plant.LifeCycle lc : Plant.LifeCycle.values()) {
  plantByLifeCycle.put(lc, new HashSet<>());
}

for (Plant p : garden) {
  plantByLifeCycle.get(p.lifeCycle).add(p);
}

System.out.println(plantByLifeCycle);
  • 안전하지 않은 형변환은 쓰지 않고, 짧고 명료하다.
  • 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 없어진다.
  • 여기서 EnumMap 의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공한다.

스트림 기반으로 변형

System.out.println(Arrays.stream(garden)
                  .collect(groupingBy(p -> p.lifeCycle)));
  • 이 코드는 EnumMap 이 아닌 고유한 맵 구현체를 사용했기 때문에 EnumMap 을 썼을 때 얻는 공간과 성능 이점이 사라진다는 단점이 있다.
  • 다음과 같이 매개변수 3개짜리 Collectors.groupingBy 메서드의 mapFactory 매개변수에 원하는 맵 구현체를 명시해 EnumMap 을 사용하도록 할 수 있다.
System.out.println(Arrays.stream(garden)
                  .collect(groupingBy(p -> p.lifeCycle, 
                                      () -> new EnumMap<>(LifeCycle.class), toSet())));
차이점
  • 스트림을 사용하면 EnumMap을 사용했을 때와 살짝 다르게 동작한다.
  • EnumMap 버전은 언제나 식물의 LifeCycle 당 하나씩의 중첩 맵을 만든다.
  • 스트림 버전은 해당 LifeCycle 에 속하는 식물이 있을 때만 중첩 맵을 만든다.

Phase 예제 - 이차원 배열 사용

  • 두 열거 타입 값들을 매핑하기 위해서 ordinal 을 두번씩 쓴 이차원 배열 예제를 살펴보자.
public enum Phase {
  SOLID, LIQUID, GAS;
  
  public enum Transition {
    MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
    
    private static final Transition[][] TRANSITIONS = {
      {null, MELT, SUBLIME},
      {FREEZE, null, BOIL},
      {DEPOSIT, CONDENSE, null}
    };
    
    public static Transition from(Phase from, Phase to) {
      return TRANSITIONS[from.ordinal()][to.ordinal()];
    }
  }
}
  • 앞의 Plant 예제와 마찬가지로, 컴파일러는 ordinal 과 배열 인덱스의 관계를 알지 못한다.
    • 즉, PhaseTransition 열거 타입을 수정하면서 TRANSITIONS 배열을 함께 수정하지 않으면 런타임 오류가 발생할 것이다.
    • 이상하게 동작하거나 ArrayIndexOutOfBoundsException 이나 NullPointerException 을 던질 것이다.
  • Phase 의 가짓수가 늘어나면 TRANSITIONS 배열의 크기도 제곱해서 커지며 null 이 채워지는 칸도 늘어날 것이다.

해결책 - 중첩 EnumMap 사용

public enum Phase {
  SOLID, LIQUID, GAS;
  
  public enum Transition {
    MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID), BOIL(LIQUID, GAS),
    CONDENSE(GAS, LIQUID), SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);
    
    private final Phase from;
    private final Phase to;
    
    Transition(Phase from, Phase to) {
      this.from = from;
      this.to = to;
    }
    
    private static final Map<Phase, Map<Phase, Transition>> m = 
      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))));
    
    public static Transition from(Phase from, Phase to) {
      return m.get(from).get(to);
    }
  }
}
  • Map<Phase, Map<Phase, Transition>> m 은 “이전 상태에서 ‘이후 상태에서 전이로의 맵’에 대응시키는 맵” 이다.
  • 중첩 맵을 초기화 시키기 위해 Collector 2개를 차례로 사용했다.
    • 첫번째 groupingBy 에서는 from 기준으로 묶고, 두번째 groupingBy 에서는 toTransition 에 대응시켰다.
  • 여기서 Phase 에 새롭게 PLASMA 를 추가할 경우,
    • 위의 배열로 만든 코드에 추가하려면 TRANSITIONS 배열을 원소 16개짜리로 교체해야 한다.
    • 중첩 EnumMap 버전에서는 PhaseTransition 에 원소만 추가하면 끝이다.
public enum Phase {
  SOLID, LIQUID, GAS, PLASMA;
  
  public enum Trasition {
    MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID), BOIL(LIQUID, GAS),
    CONDENSE(GAS, LIQUID), SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID),
    IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS);
  
    // 나머지 코드는 그대로다
    ...
  }
}
  • 실제 내부에서는 맵들의 맵이 배열들의 배열로 구현되기 때문에 낭비되는 공간과 시간이 거의 없이 명확하고 안전하게 유지보수하기 쉽다.

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

  • 열거 타입은 확장할 수 없다.
    • 즉, 열거한 값들을 그대로 가져온 다음 값을 더 추가하여 다른 목적으로 쓸 수 없다.
  • 대부분 상황에서 열거 타입을 확장하는 것은 좋지 않지만, 어울리는 쓰임이 최소한 하나는 있다.
  • 인터페이스를 구현하는 열거 타입을 사용한다면 클라이언트가 이 인터페이스를 구현해 자신만의 열거 타입(또는 다른 타입)을 만들 수 있다.
    • API가 열거 타입이 아닌 인터페이스 기반으로 작성된다면 기본 열거 타입 인스턴스가 쓰이는 모든 곳을 새로 확장한 열거 타입 인스턴스로 대체해 사용할 수 있다.

OperationCode 예제 - 인터페이스 이용

  • 가끔 API가 제공하는 기본 연산 외에 사용자 확장 연산을 추가할 수 있도록 열어줘야 할 때가 있다.
  • 기본 아이디어는 열거 타입이 임의의 인터페이스를 구현할 수 있다는 사실을 이용하는 것이다.
  • 연산 코드용 인터페이스를 정의하고 열거 타입이 이 인터페이스를 구현하게 하면 된다.
    • 이 때 열거 타입이 인터페이스의 표준 구현체 역할을 한다.
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;
  }
}
  • 열거 타입인 BasicOperation 은 확장할 수 없지만 인터페이스인 Operation 은 확장할 수 있다.
    • 이 인터페이스를 연산의 타입으로 사용하면 된다.
  • Operation 인터페이스를 구현한 열거 타입을 작성해 지수연산과 나머지 연산을 추가할 수 있다.
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 인터페이스를 사용하던 곳이면 어디든 쓸 수 있다.

타입 수준 활용

  • 개별 인스턴스 수준에서뿐 아니라 타입 수준에서도 기본 열거 타입 대신 확장된 열거 타입을 넘겨 확장된 열거 타입의 원소 모두를 사용하게 할 수도 있다.

첫번째 방법 - class 리터럴

public static void main(String[] args) {
  double x = Double.parseDouble(args[0]);
  double y = Double.parseDouble(args[1]);
  test(ExtendedOperation.class, x, y);
}

private static <T extends Enum<T> & Operation> void test(Class<T> opEnumType, 
                                                         double x, double y) {
  for (Operation op : opEnumType.getEnumConstants()) {
    System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
  }
}
  • test 메서드에 ExtendedOperationclass 리터럴을 넘겨 확장된 연산들이 무엇인지 알려준다.
    • 여기서 class 리터럴은 한정적 타입 토큰 역할을 한다.
  • opEnumType 매개변수 선언 <T extends Enum<T> & Operation>Class 객체가 열거 타입인 동시에 Operation 의 하위 타입이어야 한다는 의미이다.

두번째 방법 - 한정적 와일드카드 타입

public static void main(String[] args) {
  double x = Double.parseDouble(args[0]);
  double y = Double.parseDouble(args[1]);
  test(Arrays.asList(ExtendedOperation.values()), x, y);
}

private static void test(Collection<? extends Operation> opSet, double x, double y) {
  for (Operation op : opSet) {
    System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x,y));
  }
}
  • 첫번째보다 좀 더 유연해졌다.
    • 즉, 여러 구현 타입의 연산을 조합해 호출할 수 있게 되었다.
  • 하지만, 특정 연산에서는 EnumSetEnumMap 을 사용하지 못한다.

인터페이스를 이용한 열거 타입 확장의 문제점

  • 열거 타입끼리 구현을 상속할 수 없다.
  • 아무 상태에도 의존하지 않으면 디폴트 구현을 이용해 인터페이스에 추가할 수 있다.
    • 하지만 상태 값에 따라 로직이 달라지는 Operation 예제 같은 경우, 로직이 BasicOperationExtendedOperation 모두에 들어가야 한다.
  • 공유하는 기능이 많다면 그 부분을 별도의 도우미 클래스나 정적 도우미 메서드로 분리하는 방식으로 코드 중복을 없앨 수 있을 것이다.

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

  • 전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다.
  • 예를 들어 JUnit은 버전3까지 테스트 메서드 이름을 test 로 시작하게끔 했다.
  • 이 방법은 효과적이지만 단점도 크다.

명명 패턴의 단점

  • 오타가 나면 안된다.
    • 실수로 명명 패턴의 이름을 틀려도 컴파일러는 이를 잡아내지 못한다.
    • 즉, 메서드 이름을 tsetSafetyOverride 로 지으면 JUnit3은 이 메서드를 무시하고 지나치기 때문에 테스트를 적절히 수행하지 못한다.
  • 올바른 프로그램 요소에만 사용되리라 보증할 방법이 없다.
    • 즉, 클래스 이름을 TestSafetyMechanisms 라고 짓고 개발자는 이 클래스에 정의된 테스트 메서드들을 수행해주길 기대하겠지만 JUnit은 클래스 이름에는 관심이 없기 때문에 무시한다.
  • 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다.

애너테이션 - @Test 예제

// 매개변수 없는 정적 메서드 전용
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @Interface Test {
}
  • @Retention 이나 @Target 같이 애너테이션 선언에 다는 애너테이션을 meta-annotation 이라 한다.
    • @Retention(RetentionPolicy.RUNTIME) : @Test 가 런타임에도 유지되어야 한다는 표시
    • @Target(ElementType.METHOD) : @Test 가 반드시 메서드 선언에서만 사용돼야 한다는 표시
  • 주석에 쓰여있는 매개변수 없는 정적 메서드 전용 이라는 제약을 컴파일러가 강제하도록 하려면 javax.annotation.processing 을 사용하여 애너테이션 처리기를 직접 구현해야 한다.

  • 적절한 애너테이션 처리기 없이 인스턴스 메서드나 매개변수가 있는 메서드에 애너테이션을 달면 컴파일은 되겠지만 테스트 도구 실행시에 문제가 발생할 것이다.

  • 위와 같은 애너테이션을 마커 애너테이션이라 한다.

    • 아무 매개변수 없이 단순히 대상에 마킹한다는 의미이다.
  • 이 애너테이션 사용시에 이름에 오타가 있거나 메서드 외의 프로그램 요소에 달면 컴파일 오류가 발생한다.

마커 애너테이션 적용

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 void m5() { }
  
  public static void m6() { }
  
  @Test
  public static void m7() {
    throw new RuntimeException("실패");
  }
  
  public static void m8() { }
}
  • m5 메서드는 인스턴스 메서드이므로 @Test 를 잘못 사용한 경우이다.
  • m3m7 메서드는 예외를 던지기 때문에 테스트에 실패할 것이다.
  • m1 메서드는 테스트에 성공할 것이다.
  • @Test 를 붙이지 않는 나머지 메서드는 테스트 도구가 무시할 것이다.

테스트 도구

  • @Test 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지는 않는다.
  • 이 애너테이션에 관심있는 프로그램에게 추가 정보를 제공할 뿐이다. 즉, 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심있는 도구에서 특별한 처리를 할 기회를 준다.
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.printf("성공: %d, 실패: %d%n", passed, tests - passed);
  }
}
  • 위 테스트 도구는 명령줄로부터 정규화된 클래스 이름을 받아 해당 클래스에서 @Test 애너테이션이 달린 메서드를 차례로 호출한다.
  • 테스트 메서드가 실행 중 예외를 던진다면 리플렉션 매커니즘이 InvocationTargetException 으로 예외를 감싸서 던진다.
    • 따라서 InvocationTargetException 을 캐치해 원래 예외를 추출해 출력하도록 했다.
  • InvocationTargetException 외의 예외가 발생했다면 @Test 애너테이션을 잘못 사용했다는 뜻이다.
    • 인스턴스 메서드나 매개변수가 있는 메서드 등에 애너테이션을 달았을 것이다.

매개변수를 포함한 애너테이션

  • 특정 예외를 던져야만 성공하는 테스트를 지원하도록 해보자.
  • 매개변수 하나를 받는 애너테이션 타입을 만들어야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @Interface ExceptionTest {
  Class<? extends Throwable> value();
}
  • 이 애너테이션의 매개변수 타입은 Class<? extends Throwable> 이다.
    • 즉, Throwable 을 확장한 클래스의 Class 객체이다.

@ExceptionTest 적용

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 () { }									// 실패 (예외 발생하지 않음)
}

테스트 도구 수정

  • @ExceptionTest 애너테이션을 다룰 수 있도록 테스트 도구를 수정해보자.
if (m.isAnnotationPresent(ExceptionTest.class)) {
  tests++;
  try {
    m.invoke(null);
    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
  } catch (InvocationTargetException wrappedEx) {
    Throwable exc = wrappedExc.getCause();
    Class<? extends Throwable> excType = m.getAnnotation(ExceptionTest.class).value();
    
    if (excType.isInstance(exc)) {
      passed++;
    } else {
      System.out.printf("테스트 %s 실패: 기대한 예외 %s, 발생한 예외 %s%n", 
                        m, excType.getName(), exc);
    }
  } catch (Exception exc) {
    System.out.println("잘못 사용한 @ExceptionTest: " + m);
  }
}
  • 애너테이션 매개변수의 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인한다.

배열 매개변수를 받는 애너테이션

  • 예외를 여러 개 명시하고 그 중 하나가 발생하면 성공하게 만들 수 있다.
  • 이를 위해 @ExceptionTest 애너테이션의 매개변수 타입을 Class 객체의 배열로 수정한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @Interface ExceptionTest {
  Class<? extends Throwable>[] value();
}
  • 배열 매개변수를 받는 애너테이션 문법은 아주 유연하기 때문에, 앞의 @ExceptionTest 적용 코드들도 모두 수정없이 수용한다.
  • 여러 매개변수를 지정할 때는 다음과 같이 원소들을 중괄호로 감싸고 쉼표로 구분해주면 된다.
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() {
  List<string> list = new ArrayList<>();
  ...
}
테스트 도구 수정
  • 배열 매개변수를 받는 @ExceptionTest 애너테이션을 지원하도록 아래와 같이 수정한다.
if (m.isAnnotationPresent(ExceptionTest.class)) {
  tests++;
  try {
    m.invoke(null);
    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
  } catch (Throwable wrappedExc) {
    Throwable exc = wrappedExc.getCause();
    int oldPassed = passed;
    Class<? extends Throwable>[] excTypes = m.getAnnotation(ExceptionTest.class).value();
    
    for (Class<? extends Throwable> excType : excTypes) {
      if (excType.isInstance(exc)) {
        passed++;
        break;
      }
    }
    
    if (passed == oldPassed) {
      System.out.printf("테스트 %s 실패: %s%n", m, exc);
    }
  }
}

@Repeatable 반복 가능 애너테이션

  • 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다.
  • 배열 매개변수를 사용하는 대신 애너테이션에 @Repeatable 메타 애너테이션을 다는 방식이다.
  • @Repeatable 을 단 애너테이션은 하나의 프로그램 요소에 여러 번 반복해서 달 수 있다.

반복 가능 애너테이션 정의

  • 먼저, 컨테이너 애너테이션을 정의해야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @Interface ExceptionTestContainer {
  ExceptionTest[] value();
}
  • 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.
  • 또한 적절한 보존 정책(@Retention) 과 적용 대상(@Target)을 명시해야 한다.

  • 그리고, 애너테이션 선언에서 @Repeatable 에 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다.
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @Interface ExceptionTest {
  Class<? extends Throwable> value();
}

반복 가능 애너테이션 적용

@ExceptionTest(IndexOutOfBoundsException.class)
@ExceptionTest(NullPointerException.class)
public static void doublyBad() {
  ...
}

테스트 도구 수정

  • 반복 가능 애너테이션을 여러 개 달면 하나만 달았을 때와 구분하기 위해 해당 컨테이너 애너테이션 타입이 적용된다.
  • isAnnotationPresent 메서드는 이 둘을 명확히 구분한다.
    • 즉, 반복 가능 애너테이션을 여러개 단 후 isAnnotationPresent 로 반복 가능 애너테이션이 달렸는지 확인하면 false 를 반환할 것이다.
      • 컨테이너 애너테이션이 달렸기 때문이다.
    • 같은 이유로, 반복 가능 애너테이션을 한번만 단 후 컨테이너 애너테이션이 달렸는지 확인하면 역시 false 를 반환할 것이다.
      • 이 때는 반복 가능 애너테이션이 달렸기 때문이다.
    • 따라서 애너테이션이 달려있는 수와 상관없이 모두 검사하려면 둘을 따로따로 확인해야 한다.
if (m.isAnnotationPresent(ExceptionTest.class) || m.isAnnotationPresent(ExceptionTestContainer.class)) {
  tests++;
  try {
    m.invoke(null);
    System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
  } catch (Throwable wrappedExc) {
    Throwable exc = wrappedExc.getCause();
    int oldPassed = passed;
    
 		ExceptionTest[] excTests = m.getAnnotationsByType(ExceptionTest.class);
    for (ExceptionTest excTest : excTests) {
      if (excTest.value().isInstance(exc)) {
        passed++;
        break;
      }
    }
    
    if (passed == oldPassed) {
      System.out.printf("테스트 %s 실패: %s%n", m, exc);
    }
  }
}
  • getAnnotationsByType 은 반복 가능 애너테이션과 그 컨테이너 애너테이션을 구분하지 않아서 모두 가져온다.

정리

  • 다른 프로그래머가 소스코드에 추가 정보를 제공할 수 있는 도구를 만드는 일을 한다면 적당한 애너테이션 타입도 함께 정의해 제공하자.
  • 도구 제작자를 제외하고는 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없다.
  • 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.
  • IDE나 정적 분석 도구가 제공하는 애너테이션을 사용하면 해당 도구가 제공하는 진단 정보의 품질을 높여줄 것이다.

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

  • @Override 애너테이션이 달렸다는 것은 상위 타입의 메서드를 재정의했음을 뜻한다.
  • 이 애너테이션을 일관되게 사용하면 여러가지 악명 높은 버그들을 예방해준다.

Bigram 예제

public 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;
  }
  
  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());
  }
}
  • Set은 중복을 허용하지 않기 때문에 위 코드를 실행했을 때 출력값이 10이 나올 것 같지만, 실제로는 260이 출력된다.
  • 위 코드에서 equals 메서드를 재정의하려고 했으나 사실은 overloading 해버렸다.
    • Objectequals 메서드를 재정의하려면 매개변수 타입이 Object 여야 하는데, Bigram 으로 선언했기 때문이다.
  • Objectequals 메서드는 == 연산자와 똑같이 객체 식별성만 확인하기 때문에, 같은 소문자를 소유한 바이그램 10개 각각을 서로 다른 객체로 인식하여 각각 Set에 저장한 것이다.
  • @Override 애너테이션을 달면 컴파일러가 이 오류를 찾아낼 수 있다.

@Override 추가

@Override
public boolean equals(Object o) {
  if (!o instanceof Bigram) return false;
  
  Bigram b = (Bigram) o;
  return b.first == first && b.second == second;
}
  • 상위 클래스의 메서드를 재정의하려는 모든 메서드에서 @Override 애너테이션을 다는 것이 좋다.

예외

  • 구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때는 굳이 @Override 를 달지 않아도 된다.
  • 구체 클래스인데 아직 구현하지 않은 추상 메서드가 남아있다면 컴파일러가 그 사실을 알려주기 때문이다.
  • 물론 재정의 메서드 모두에 @Override 애너테이션을 일괄로 붙여두는게 좋아보인다면 그래도 상관없다.

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

  • 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스를 마커 인터페이스 라고 한다.

    • Serializable 인터페이스가 좋은 예이다.

    • Serializable 은 자신을 구현한 클래스의 인스턴스는 ObjectOutputStream 을 통해 write 할 수 있다고, 즉 직렬화할 수 있다고 알려준다.

마커 인터페이스의 장점

  • 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있다.
  • 적용 대상을 더 정밀하게 지정할 수 있다.
    • 마커 애너테이션의 경우, @TargetElementType.TYPE 으로 선언하면 모든 타입에 애너테이션을 달 수 있다.
    • 타입을 더 세밀하게 제한하지는 못한다는 뜻이다.
    • 그러나 마커 인터페이스의 경우, 그냥 마킹하고 싶은 클래스에서만 그 인터페이스를 구현하면 된다.

마커 애너테이션의 장점

  • 거대한 애너테이션 시스템의 지원을 받는다.
    • 애너테이션을 적극 활용하는 프레임워크에서는 마커 애너테이션을 쓰는 쪽이 일관성을 지키는 데 유리할 것이다.

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

  • 클래스와 인터페이스 이외의 프로그램 요소(모듈, 패키지, 필드, 지역변수 등)에 마킹해야할 때는 애너테이션을 쓸 수 밖에 없다.
  • 마커를 클래스나 인터페이스에 적용해야 한다면,
    • “이 마킹이 된 객체를 매개변수로 받는 메서드를 작성할 일이 있을까?” 라고 자문해보자.
    • 그렇다면, 마커 인터페이스를 써야 한다.
      • 마커 인터페이스를 해당 메서드의 매개변수 타입으로 사용하여 컴파일 타임에 오류를 잡아낼 수 있기 때문이다.
    • 아니라면, 마커 애너테이션을 사용하는 것이 나은 선택일 것이다.