[Effective Java]ordinal 인덱싱 대신 EnumMap을 사용하라

열거 타입과 애너테이션, 네 번째 아이템

Posted by SungBeom on April 14, 2020 · 7 mins read

ordinal 메소드

이따금 배열이나 리스트에서 원소를 꺼낼 때 ordinal 메소드로 인덱스를 얻는 코드가 있다. 식물을 간단히 나타낸 다음 클래스를 예로 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Plant {
    enum LifeCycle { ANNUAL, PERNNIAL, BIENNIAL }
 
    final String name;
    final LifeCycle lifeCycle;
 
    Plant(String name, LifeCyle lifeCycle) {
        this.name = name;
        this.lifeCycle = lifeCycle;
    }
 
    @Override public String toString() {
        return name;
    }
}
cs

이제 정원에 심은 식물들을 배열 하나로 관리하고, 이들을 생애주기(한해살이, 여러해살이, 두해살이)별로 묶어보자. 생애주기별로 총 3개의 집합을 만들고 정원을 한 바퀴 돌며 각 식물을 해당 집합에 넣는데, 생애주기의 ordinal 값을 그 배열의 인덱스로 사용한 코드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ordinal()을 배열 인덱스로 사용 - 따라 하지 말 것!
Set<Plant>[] plantsByLifeCycle =
    (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycle.length; i++)
    palntsByLifeCycle[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]);
}
cs

배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것이다. 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다. 가장 심각한 문제는 정수는 열거 타입과 달리 타입 안전하지 않기 때문에, 정확한 정숫값을 사용한다는 것을 여러분이 직접 보증해야 한다는 점이다.

EnumMap

배열은 실질적으로 열거 타입 상수를 값으로 매핑하는 일을 하므로, Map을 사용할 수도 있을 것이다. 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체가 존재하는데, 바로 EnumMap이다. 다음은 위의 코드를 수정하여 EnumMap을 사용하도록 한 코드다.

1
2
3
4
5
6
7
8
// 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);
cs

안전하지 않은 형변환은 쓰지 않고, 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 직접 레이블을 달 일도 없다. 나아가 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 원천봉쇄된다. EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용하기 때문이다. 내부 구현 방식을 안으로 숨겨서 Map의 타입 안전성과 배열의 성능을 모두 얻어낸 것이다.

두 열거 타입을 매핑하느라 ordinal을 두 번이나 쓴 배열들의 배열에서도 EnumMap을 사용하는 편이 훨씬 낫다. 전이 하나를 얻으려면 이전 상태와 이후 상태가 필요하니, 맵 2개를 중첩하면 쉽게 해결할 수 있다. 또한 실제 내부에서는 맵들의 맵이 배열들의 배열로 구현되니 낭비되는 공간과 시간도 거의 없이 명확하고 안전하고 유지보수하기 좋다.

스트림

스트림을 사용해 맵을 관리하면 코드를 더 줄일 수 있다. 다음은 앞 예의 동작을 거의 그대로 모방한 가장 단순한 형태의 스트림 기반 코드다.

1
2
3
// 스트림을 사용한 코드 1 - EnumMap을 사용하지 않는다!
System.out.println(Arrays.stream(garden)
        .collect(groupingBy(p -> p.lifeCycle)));
cs

이 코드는 EnumMap이 아닌 고유한 맵 구현체를 사용했기 때문에 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다는 문제가 있다. 매개변수 3개짜리 Collectors.groupingBy 메소드는 mapFactory 매개변수에 원하는 맵 구현체를 명시해 호출할 수 있다.

1
2
3
4
// 스트림을 사용한 코드 2 - EnumMap을 이용해 데이터와 열거 타입을 매핑했다.
System.out.println(Arrays.stream(garden)
        .collect(groupingBy(p -> p.lifeCycle,
            () -> new EnumMap<>(LifeCycle.class), toSet())));
cs

EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다. 예컨대 정원에 한해살이와 여러해살이 식물만 살고 두해살이는 없다면, EnumMap 버전에서는 맵을 3개 만들고 스트림 버전에서는 2개만 만든다.


핵심 정리

배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 일반적으로 좋지 않으니, 대신 EnumMap을 사용하라. 다차원 관계는 EnumMap<..., EnumMap<...>>으로 표현하라. "애플리케이션 프로그래머는 Enum.ordinal을 웬만해서는 사용하지 말아야 한다"는 일반 원칙의 특수한 사례다.