Effective Java 3rd - Ch06
포스트
취소

Effective Java 3rd - Ch06

Java 기초를 다지기 위해 효과적인 자바란 책을 공부 중입니다.

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

자바에는 특수한 목적의 참조타입이 두가지 있다

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

34) int 상수 대신 enum 타입을 사용하라

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

코드 34-1 정수 열거 패턴 - 상당히 취약하다

1
2
3
4
5
6
7
public static final int APPLE_FUJI         = 0;
public static final int APPLE_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;

정수 열거 패턴 기법에는 단점이 많다

  • 타입 안전을 보장할 방법이 없고 표현력도 좋지 않다
    • ex) 오렌지를 보내야할 메서드에 사과를 보내고 비교를 해도 경고가 없다
    • ex) 같은 이름의 수은(원소)과 수성(행성)을 구분하기 위해 접두어를 붙여야 한다
  • 정수 열거 패턴을 사용한 프로그램은 깨지기 쉽다
    • 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일 해야 한다
  • 정수 상수는 문자열로 출력하기가 다소 까다롭다 (특히 디버깅때)
  • 정수 대신 문자열을 사용하는 문자열 열거 패턴은 더 나쁘다
    • 문자열 값을 그대로 하드 코딩하게 만들어 오타로 런타임 오류 가능
    • 문자열 비교로 인한 성능 저하

코드 34-2 가장 단순한 열거 타입

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

자바의 열거 타입은 완전한 형태의 클래스라서 다른 언어의 열거 타입보다 훨씬 강력하다

  • 열거 타입 자체는 클래스
  • 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개
    • 사실상 final
  • 싱글턴을 일반화한 형태라고 볼 수 있음
    • 반대로 싱글턴은 원소가 하나뿐인 열거 타입
  • 컴파일타임 타입 안전성을 제공
  • 각자의 이름 공간이 있어서 같은 이름의 상수도 공존
  • :bangbang:   임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있다
    • 상수에 연관된 데이터를 내재시킬 수 있음, ex) Apple, Orange 의 색 또는 이미지 반환
  • :bangbang:   Object 메서드를 구현해 놓았고, Comparable 과 Serializable 을 구현해 놓았음

코드 34-3 데이터와 메서드를 갖는 열거 타입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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);

  // **NOTE: enum 항목들의 멤버로 포함된다

  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 으로 하는 것보다 접근자 메서드를 두는게 낫다
  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
  }
}

/////////////////////////

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() )   // enum 의 모든 항목 순회
      System.out.printf("%s 에서의 무게는 %f 이다.%n", p, p.surfaceWeight(mass));
  }
}

열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다

코드 34-4 값에 따라 분기하는 열거 타입 - 이대로 만족하는가?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public enum Operation {
  PLUS, MINUS, TIMES, DIVIDE;

  // 상수가 뜻하는 연산을 수행한다
  public double apply(double x, double y) {
    // **NOTE: 깨지기 쉬운 코드 ==> 항목이 변하면 switch 문도 변경해야 함
    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);
  }
}

코드 34-5 상수별 메서드 구현을 활용한 열거 타입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 );
}

코드 34-6 상수별 클래스 몸체(body)와 데이터를 사용한 열거 타입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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; }
  };

  private final String symbol;

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

  @Override public String toString() { return symbol; }

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

////////////////////////////

public static void main(String[] args) {
  double x = Double.parseDouble(args[0]);
  double y = Double.parseDouble(args[1]);
  for( Operation op : Operation.values() )
    System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}

코드 34-7 열거 타입용 fromString 메서드 구현하기

1
2
3
4
5
6
7
8
9
10
11
12
  // enum 타입을 Map<String,enum항목> 으로 생성
  // **NOTE: 열거타입 상수는 생성자에서 자신의 인스턴스를 맵에 추가 할 수 없다
  private static final Map<String, Operation> stringToEnum =
          Stream.of(values()).collect(
                  toMap(Object::toString, e -> e));

  // 지정한 문자열에 해당하는 Operation을 (존재한다면) 반환한다
  // **NOTE: Optional 은 주어진 문자열이 가리키는 항목이 존재하지 않을 수 있음을
  // 클라이언트에 알리고 그 상황을 대처하도록 한 것이다
  public static Optional<Operation> fromString(String symbol) {
      return Optional.ofNullable(stringToEnum.get(symbol));
  }

코드 34-8 값에 따라 분기하여 코드를 공유하는 열거 타입 - 좋은 방법인가?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 관리 관점에서는 잘 깨지고 위험한 코드이다
// 휴가기간은?
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;
  }
}

상수별 메서드 구현으로 굽여를 정확히 계산하는 방법은 두 가지

  • 잔업수당을 계산하는 코드를 모든 상수에 중복해서 작성
  • 계산 코드를 평일용과 주말용으로 나눠 각각을 도우미 메서드로 작성하고 각 상수가 적절히 호출

가장 깔끔한 방법은 새로운 상수를 추가할 때 잔업수당 ‘전략’을 선택해도록 하는 것이다

코드 34-9 전략 열거 타입 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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 {
        // 주중용 overtimePay 계산식
        int overtimePay(int minsWorked, int payRate) {
          return minsWorked <= MINS_PER_SHIFT ? 0 :
                    (minsWorked - MINS_PER_SHIFT) * payRate / 2;
        }
    },
    WEEKEND {
        // 주말용 overtimePay 계산식
        int overtimePay(int minsWorked, int payRate) {
          return minsWorked * payRate / 2;
        }
    };

    // 공통되는 추상 메서드 : 전략에 따른 선택사항
    abstract int overtimePay(int mins, 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);
    }
  }
}

////////////////////////

public static void main(String[] args) {
    for (PayrollDay day : values())
        System.out.printf("%-10s%d%n", day, day.pay(8 * 60, 1));
}

코드 34-10 switch 문을 이용해 원래 열거 타입에 없는 기능을 수행한다

1
2
3
4
5
6
7
8
9
10
11
12
// **NOTE: 기존 열거 타입에 상수별 동작을 혼합해 넣을 때는
//    switch 문이 좋은 선택이 될 수 있다
public static Operation inverse( Operation op ) {
  switch( op ){
    case PLUS:    return Operation.MINUS;
    case MINUS:   return Operation.PLUS;
    case TIMES:   return Operation.DIVIDE;
    case DIVIDE:  return Operation.TIMES;

    default: throw new AssertionError("알 수 없는 연산: "+op);
  }
}

열거 타입은 언제 쓰는 것인가?

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

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

열거 타입은 해당 상수가 몇 번째 위치인지를 반환하는 ordinal이라는 메서드를 제공한다

코드 35-1 ordinal 을 잘못 사용한 예 - 따라하지 말 것!

1
2
3
4
5
6
7
8
9
10
// 동작은 하지만 유지보수하기가 끔찍한 코드
public enum Ensemble {
  SOLO, DUET, TRIO, QUARTET, QUINTET,
  SEXTET, SEPTET, OCTET, DOUBLE_QUARTET,
  NONET, DECTET, TRIPLE_QUARTET;

  // 1) 상수 선언 순서를 바꾸는 순간 오작동
  // 2) 중간에 값을 비워둘 수도 없다
  public int numberOfMusicians() { return ordinal() + 1; }
}

열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지 말고 인스턴스 필드에 저장하자.

1
2
3
4
5
6
7
8
9
10
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; }
}

ordinal 메서드는 대부분의 프로그래머가 쓸 일이 없다. EnumSet 과 EnumMap 같이 열거 타입 기반의 범용 자료구조에 쓸 목적으로 설계되었다.

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

코드 36-1 비트 필드 열거 상수 - 구닥다리 기법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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) { ... }
}

//////////////////

// 이렇게 만들어진 집합을 비트 필드(bit field)라 한다
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);

비트 필드의 문제점

  • 해석하기가 어렵다
  • 비트 필드에 녹아 있는 모든 원소를 순회하기도 까다롭다
  • 최대 몇 비트가 필요한지를 API 작성시 미리 예측하여 적절한 타입(int or long)을 선택해야 한다

코드 36-2 EnumSet - 비트 필드를 대체하는 현대적 기법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Text {
  public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }

  // 어떤 Set을 넘겨도 되나, EnumSet이 가장 좋다.
  // ==> 그럼에도 Set 으로 받은 이유는,
  //     이왕이면 인터페이스로 받는게 일반적으로 좋은 습관이다
  public void applyStyles(Set<Style> styles) {
    System.out.printf("Applying styles %s to text%n",
              Objects.requireNonNull(styles));
  }
}

////////////////////

public static void main(String[] args) {
  Text text = new Text();
  text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
  }
}

이때 어떤 프로그래머는 집합들을 배열 하나에 넣고 생애주기의 ordinal 값을 그 배열의 인덱스로 사용하려 할 것이다.

코드 37-1 ordinal() 을 배열 인덱스로 사용 - 따라하지 말 것!

1
2
3
4
5
6
7
8
9
10
11
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]);
}

동작은 하지만 문제가 한가득이다

  • 배열은 제네릭과 호환되지 않으니 깔끔히 컴파일되지 않을 것이다
  • 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다
  • 정확한 정수값을 사용하다는 것을 코더가 직접 보증해야 한다 (잘못된 값을 묵묵히 수행)

코드 37-2 EnumMap 을 사용해 데이터와 열거 타입을 매핑한다

1
2
3
4
5
6
7
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =
    new EnumMap<>(Plant.LifeCycle.class);         // class 로부터 EnumMap 생성
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
    plantsByLifeCycle.put(lc, new HashSet<>());
for (Plant p : garden)
    plantsByLifeCycle.get(p.lifeCycle).add(p);    // 항목별 HashSet 에 p 추가
System.out.println(plantsByLifeCycle);

코드 37-3 스트림을 사용한 코드 1 - EnumMap을 사용하지 않는다!

1
2
3
// groupingBY 가 HashSet 사용 목적을 대체
System.out.println(Arrays.stream(garden)
    .collect(groupingBy(p -> p.lifeCycle)));

코드 37-4 스트림을 사용한 코드 2 - EnumMap을 이용해 데이터와 열거 타입을 매핑했다.

1
2
3
4
5
6
System.out.println(Arrays.stream(garden).collect(
    groupingBy(
        p -> p.lifeCycle,                     // classifier
        () -> new EnumMap<>(LifeCycle.class), // mapFactory
        toSet()                               // downstream
    ) ));

참고 : Java 8 – Stream Collectors groupingBy examples

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
//3 apple, 2 banana, others 1
List<String> items = Arrays.asList(
    "apple", "apple", "banana", "apple", "orange", "banana", "papaya");

Map<String, Long> result = items.stream().collect(
  Collectors.groupingBy(    // classifier, downstream
    Function.identity(), Collectors.counting()
  ));

System.out.println(result);
// { papaya=1, orange=1, banana=2, apple=3 }

///////////////////////////

//3 apple, 2 banana, others 1
List<Item> items = Arrays.asList(
    new Item("apple", 10, new BigDecimal("9.99")),
    new Item("banana", 20, new BigDecimal("19.99")),
    new Item("orang", 10, new BigDecimal("29.99")),
    new Item("watermelon", 10, new BigDecimal("29.99")),
    new Item("papaya", 20, new BigDecimal("9.99")),
    new Item("apple", 10, new BigDecimal("9.99")),
    new Item("banana", 10, new BigDecimal("19.99")),
    new Item("apple", 20, new BigDecimal("9.99"))
);

Map<String, Long> counting = items.stream().collect(
    Collectors.groupingBy(Item::getName, Collectors.counting()));
System.out.println(counting);
// papaya=1, banana=2, apple=3, orang=1, watermelon=1

Map<String, Integer> sum = items.stream().collect(
    Collectors.groupingBy(Item::getName, Collectors.summingInt(Item::getQty)));
System.out.println(sum);
// papaya=20, banana=30, apple=40, orang=10, watermelon=10

참고 : Java 8 groupingBy()를 잘 쓰자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// **NOTE: groupingBy()의 코드 절감 효과
// 아래의 두 코드는 정확히 같은 일을 한다.

List<DataStoreStatistics> dataStoreStatsList =
    this.dataStoreStatisticsRepository.findByDate(searchStartDate, searchEndDate);

// for 버전
Map<Long, List<DataStoreStatistics>> datasByDataSourceIdMap = new HashMap<>();
for( DataStoreStatistics dss: dataStoreStatsList ) {
  Long dataSourceId = dss.getDataSourceId();
  List<DataStoreStatistics> dssListByDataSource = Optional
      .ofNullable( datasByDataSourceIdMap.get(dataSourceId) )
      .orElse( new ArrayList<>() );
  dssListByDataSource.add( dss );
  datasByDataSourceIdMap.put( dataSourceId, dssListByDataSource );
}

// groupingBy 버전
Map<Long, List<DataStoreStatistics>> datasByDataSourceIdMap = dataStoreStatsList.stream()
    .collect( groupingBy(DataStoreStatistics::getDataSourceId) );

코드 37-5 배열들의 배열의 인덱스에 ordinal() 을 사용 - 따라하지 말 것

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public enum Phase {
  SOLID, LIQUID, GAS;

  public enum Transition {
    MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;

    // 행은 from 의 ordinal 을, 열은 to 의 ordinal 을 인덱스로 쓴다
    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() ];
    }
  }
}

멋져 보이지만, 컴파일러는 ordinal 과 배열 인덱스의 관계를 알 도리가 없다.

  • EnumMap 을 사용하는 편이 훨씬 낫다
  • 이전 상태(from)와 이후 상태(to)가 필요하니 맵 2개를 중첩하면 쉽게 해결

코드 37-6 중첩 EnumMap 으로 데이터와 열거 타입 쌍을 연결했다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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),     // from 열
                toMap(                                // downstream
                    t -> t.to,                        // toMap::keyMapper
                    t -> t,                           // toMap::valueMapper
                    (x, y) -> y,                      // toMap::mergeFunction
                    () -> new EnumMap<>(Phase.class)  // toMap::mapSupplier
                )                                     // to 행 (중첩 EnumMap)
            )
        );

    public static Transition from(Phase from, Phase to) {
        return m.get(from).get(to);
    }
  }
}

코드 37-7 EnumMap 버전에 새로운 상태 추가하기

1
2
3
4
5
6
7
8
9
10
11
12
13
// 잘못 수정할 가능성이 극히 작다. 안전하고 유지보수하기 좋다
public enum Phase {
  SOLID, LIQUID, GAS, PLASMA;                     // 추가

  public enum Transition {
    MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
    BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
    SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID),
    IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS);   // 추가

    ... // 그대로
  }
}

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

열거타입 자체는 확장할 수 없지만, 인터페이스와 그 인터페이스를 구현하는 기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있다.

  • 클라이언트는 이 인터페이스를 구현해 자신만의 열거타입을 만들 수 있다
  • API 가 인터페이스 기반으로 작성되었다면 기본 열거타입의 인스턴스가 쓰이는 모든 곳을 새로 확장한 열거타입의 인스턴스로 대체할 수 있다

인터페이스를 이용해 확장 가능한 열거 타입을 흉내내는 방법에 대해 알아본다.

코드 38-1 인터페이스를 이용해 확장 가능 열거 타입을 흉내 냈다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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;
  }
}

코드 38-2 확장 가능 열거 타입

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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;
  }
}

/////////////////////////////////////

// 열거 타입의 Class 객체를 이용해 확장된 열거 타입의 모든 원소를 사용하는 예 (234쪽)
public static void main(String[] args) {
  double x = Double.parseDouble(args[0]);
  double y = Double.parseDouble(args[1]);
  test(ExtendedOperation.class, x, y);
}

// **NOTE: onEnumType 매개변수의 선언의 의미 (복잡하지만)
//     ==> Class 객체가 열거 타입인 동시에 Operation의 하위 타입이어야 한다
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));
}

두번째 대안 : Class 객체 대신 한정적 와일드카드 타입인 Collection<? extends Operation> 을 넘기는 방법

1
2
3
4
5
6
7
8
9
10
11
12
13
// 컬렉션 인스턴스를 이용해 확장된 열거 타입의 모든 원소를 사용하는 예 (235쪽)
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);
}

// **NOTE: 많이 간단해 졌는데,
//    대신 열거 타입이라는 선언이 없기 때문에 EnumSet, EnumMap 을 사용 못한다
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));
}

Operation 예는 연산 기호를 저장하고 찾는 로직이 BasicOperation 과 ExtendedOperation 모두에 들어가야 한다. 그 때문에 공유하는 기능이 많아 중복량이 많다면

그 부분을 별도의 도우미 클래스나 정적 도우미 메서드로 분리하는 방식으로 코드 중복을 없앨 수 있을 것이다.

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

전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해 왔다.

명명 패턴의 단점

  • 오타가 나면 안된다
  • 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다
  • 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다

애너테이션은 이 모든 문제를 해결해 주는 멋진 개념

코드 39-1 마커(marker) 애너테이션 타입 선언

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.lang.annotation.*;

/**
 * 테스트 메서드임을 선언하는 애너테이션이다.
 * 매개변수 없는 정적 메서드 전용이다.
 */

// @Test 가 런타임에도 유지되어야 한다는 표시
@Retention(RetentionPolicy.RUNTIME)
// @Test 가 반드시 메서드 선언에서만 사용돼야 한다고 알려준다
@Target(ElementType.METHOD)
public @interface Test {
}                             // 아무 내용(조건)이 없다

이와 같이 애너테이션을 ‘아무 매개변수 없이 단순히 대상에 마킹한다’는 뜻에서 마커 애너테이션이라 한다.

코드 39-2 마커 애너테이션을 사용한 프로그램 예

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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() { }
}

@Test 애너테이션이 Sample 클래스의 의미에 직접적인 영향을 주지는 않는다.

  • 그저 이 애너테이션에 관심 있는 프로그램에게 추가 정보를 제공할 뿐
  • 대상 코드의 의미는 그대로 둔 채 그 애너테이션에 관심있는 도구에서 특별한 처리를 할 기회를 준다

코드 39-3 마커 애너테이션을 처리하는 프로그램

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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);
  }
}

////////////////////////////
// 실행 결과
/*
public static void Sample.m3() failed: RuntimeException: Boom
Invalid @Test: public void Sample.m5()
public static void Sample.m7() failed: RuntimeException: Crash
성공: 1, 실패: 3
*/

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

코드 39-4 매개변수 하나를 받는 애너테이션 타입

1
2
3
4
5
6
7
8
9
10
11
12
import java.lang.annotation.*;

/**
 * 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
  // Throwable 을 확장한 클래스의 Class 객체라는 의미
  // ==> 모든 예외 타입을 수용
  Class<? extends Throwable> value();
}

코드 39-5 매개변수 하나짜리 애너테이션을 사용한 프로그램

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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() {       // 실패해야 한다.
  }                               // 예외가 발생하지 않음
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  // ExceptionTest 애너테이션을 다룰 수 있는 테스트 도구
  if (m.isAnnotationPresent(ExceptionTest.class)) {
      tests++;
      try {
          m.invoke(null);
          System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
      } catch (InvocationTargetException wrappedEx) {
          Throwable exc = wrappedEx.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);
      }
  }

예외를 여러개 명시하고 그중 하나가 발생하면 성공하게 만들 수도 있다.

코드 39-6 배열 매개변수를 받는 애너테이션 타입

1
2
3
4
5
6
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
  // 매개변수 타입을 배열로 수정해보자
  Class<? extends Exception>[] value();
}

코드 39-7 배열 매개변수를 빧는 애너테이션을 사용하는 코드

1
2
3
4
5
6
7
8
9
10
11
12
public class Sample3 {
  // 배열을 지정할 때는, 원소들을 중괄호로 감싸고 쉼표로 구분해주기만 하면 된다
  @ExceptionTest({ IndexOutOfBoundsException.class,
                   NullPointerException.class })
  public static void doublyBad() {                // 성공해야 한다.
    List<String> list = new ArrayList<>();

    // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나
    // NullPointerException을 던질 수 있다.
    list.addAll(5, null);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  // @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);
      }
  }

자바 8에서는 여러개의 값을 받는 애너테이션을 @Repeatable 메타애너테이션으로도 만들 수 있다.

  • @Repeatable 을 단 애너테이션을 반환하는 ‘컨테이너 애너테이션’을 하나 더 정의하고, @Repeatable 에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다
  • 컨테이너 애너테이션은 내부 애너테이션 타입의 빼열을 반환하는 value 메서드를 정의해야 한다
  • 컨테이너 애너테이션 타입에는 적절한 보존 정책(@Retention)과 적용 대상(@Target)을 명시해야 한다

코드 39-8 반복 가능한 애너테이션 타입

1
2
3
4
5
6
7
8
9
10
11
12
13
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Repeatable(ExceptionTestContainer.class)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}

// 반복 가능한 애너테이션의 컨테이너 애너테이션 (244쪽)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTestContainer {
    ExceptionTest[] value();
}

코드 39-9 반복 가능한 애너테이션을 두번 단 코드

1
2
3
4
5
public class Sample4 {
  @ExceptionTest(IndexOutOfBoundsException.class)
  @ExceptionTest(NullPointerException.class)
  public static void doublyBad() { ... }
}

코드 39-10 반복 가능 애너테이션 다루기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  // 코드 39-10 반복 가능 애너테이션 다루기 (244-245쪽)
  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);
      }
  }

도구 제작자를 제외하고, 일반 프로그래머가 애너테이션 타입을 직접 정의할 일은 거의 없다. 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다.

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

자바가 기본으로 제공하는 애너테이션 중 보통의 프로그래머에게 가장 중요한 것은 @Override 일 것이다.

:bangbang:   핵심 정리

재정의한 모든 메서드에 @Override 애너테이션을 의식적으로 달면 여러분이 실수했을 때 컴파일러가 바로 알려줄 것이다

  • 예외는 한가지뿐이다.
    • 구체 클래스에서 상위 클래스의 추상 메서드를 재정의한 경우

코드 40-1 영어 알파벳 2개로 구성된 문자열을 표현하는 클래스 - 버그를 찾아보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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));

    // WRONG: 26 이 출력될 거 같지만 260 이 출력되었다!!
    System.out.println(s.size());
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 버그를 고친 바이그램 클래스 (247쪽)
public class Bigram2 {
    private final char first;
    private final char second;

    public Bigram2(char first, char second) {
        this.first  = first;
        this.second = second;
    }

    @Override public boolean equals(Object o) {
        if (!(o instanceof Bigram2))
            return false;
        Bigram2 b = (Bigram2) o;
        return b.first == first && b.second == second;
    }

    public int hashCode() {           // 이건 안붙여도 되나?
        return 31 * first + second;
    }

    public static void main(String[] args) {
        Set<Bigram2> s = new HashSet<>();
        for (int i = 0; i < 10; i++)
            for (char ch = 'a'; ch <= 'z'; ch++)
                s.add(new Bigram2(ch, ch));
        System.out.println(s.size());
    }
}

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

마커 인터페이스

아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스 (ex: Serializable 인터페이스)

  • 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나, 마커 애너테이션은 그렇지 않다
    • 마커 인터페이스는 컴파일 타임에 오류를 잡을 수 있다
  • 적용 대상을 정밀하게 지정할 수 있다
    • 그냥 마킹하고 싶은 클래스에만 그 인터페이스를 구현하면 된다
    • 반대로 마커 애너테이션은 클래스, 인터페이스 등 모든 타입에 달 수 있다

핵심 정리

마커 인터페이스와 마커 애너테이션은 각자의 쓰임이 있다.

  • 마커 인터페이스 선택
    • 새로 추가하는 메서드 없이 단지 타입 정의가 목적
  • 마커 애너테이션 선택
    • 클래스나 인터페이스 외의 프로그램 요소에 마킹해야 하거나
    • 애너테이션을 적극 활용하는 프레임워크의 일부로 그 마커를 편입시키고자 한다면
    • 거대한 애너테이션 시스템의 지원을 받는다

 
 

끝!   읽어주셔서 감사합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.