DEV ℧ Developer Diary

[EffectiveJava] item37 - ordinal 인덱싱 대신 EnumMap을 사용하라

이따금 배열이나 리스트에 원소를 꺼낼 때 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;
    }
}

이제 정원에 심은 식물들을 배열 하나로 관리하고, 이들을 생애주기(한해살이, 여러해살이, 두해살이) 별로 묶어보자.

ordinal의 단점

아래와 같이 식물의 생애주기를 정리할때 ordinal 값을 그 배열의 인덱스로 사용할 수도 있다.

@Test
void print_식물_구분_Test() {
    Plant[] garden = new Plant[5];
    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]);
    }
  }

하지만 위의 같은 코드는 동작은 하지만 문제가 한가득이다.

배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고 깔끔하게 컴파일 되지 않을 것이다. 또한 각 인덱스의 의미를 알 수 없으니 출력 결과에 직접 레이블을 달아야 한다.

만약 배열이 틀어지거나 내부의 값을 하나 삭제하면, ArrayIndexOutOfBoundsException이 발생할 수 도 있다.

EnumMap

그렇다면 어떻게 구현하는것이 좋을까?

ordinal값을 사용하는 것보다는 더욱 멋진 해결책을 찾을 수 있다.

바로 Map을 사용하는것인데, 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체인 EnumMap을 사용하면 된다. 그렇다면 EnumMap을 사용해서 위의 코드를 수정해보도록 하자.

@Test
void print_식물_구분_by_EnumMap_Test() {
    Plant[] garden = new Plant[5];
    Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle = new EnumMap<Plant.LifeCycle, Set<Plant>>(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);
}

더 짧고 명료하고 안전하고 성능도 원래 버전과 비등하다.

위 코드에서 발견된 형변환과, 배열 indexOutOfBounds 와 같은 모든 문제들이 해결되어 오류가 날 가능성이 사라진다.

EnumMap의 성능이 ordinal을 쓴 배열에 비견하는 이유는 그 내부에서 배열을 사용하기 때문이다. 내무 구현 방식을 안으로 숨겨서 Map의 타입 안정성과 배열의 성능을 모두 얻어낸 것이다.

여기서 EnumMap의 생성자가 받는 키타입의 Class 객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공한다.

EnumMap의 Stream 적용

해당 코드에 Stream을 사용해 맵을 관리하면 더 코드를 줄일 수 있다.

@Test
void print_식물_구분_by_EnumMap_Stream_Test() {
    Plant[] garden = new Plant[5];
    System.out.println(Arrays.stream(garden)
            .collect(groupingBy(p -> p.lifeCycle)));
}

이 코드는 EnumMap이 아닌 고유한 맵 구현체를 사용했기 때문에 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다는 문제가 있다.

좀더 구체적으로 살펴보도록 하자. 매개변수 3개짜리 Collectors.groupingBy 메서드는 mapFactory 매개변수에 원하는 맵 구현체를 명시해 호출할 수 있다.

@Test
void print_식물_구분_by_EnumMap_Stream2_Test() {
    Plant[] garden = new Plant[5];
    System.out.println(Arrays.stream(garden)
            .collect(groupingBy(p -> p.lifeCycle,
                    () -> new EnumMap<>(Plant.LifeCycle.class), toSet())));
}

단순한 프로그램에서는 최적화가 굳이 필요 없지만, 맵을 빈번히 사용하는 프로그램에서는 꼭필요한 최적화이다.

스트림을 사용하면 EnumMap만 사용했을 때와는 살짝 다르게 동작한다.

EnumMap 버전은 언제나 식물의 생애주기당 하나씩 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.

예컨대 정원에 한해살이와 여러해살이 식물만 살고 두해살이는 없다면 EnumMap 버전에서는 맵을 3개 만들고 스트림 버전에서는 2개만 만든다.

2차 배열 인덱스의 ordinal()

두 열거 타입 값들을 매핑하느라 ordinal을 두번이나 쓴 배열들의 배열을 본적이 있을 것이다.

다음은 이 방식을 적용해 두 가지 상태(Phase)를 전이(Transition)와 매핑하도록 구현한 프로그램이다.

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과 배열 인덱스의 관계를 알 도리가 없다.

즉, Phase나 Phase.Transition 열거 타입을 수정하면서 상전이 표 TRANSITIONS을 함께 수정하지 않거나 실수로 잘못 수정하면 런타임 오류가 날 것이다.

ArrayIndexOutOfBoundsException이나 NullPointerException을 던질 수도 있고, 예외를 던지지 않고 이상하게 동작할 수도 있다.

그리고, 상전이 표의 크기는 상태의 가짓수가 늘어나면 제곱해서 커지며 null로 채워지는 칸도 늘어날 것이다.

2차 배열의 EnumMap

EnumMap을 사용하는 편이 훨씬 낫다. 전이 하나를 얻으려면 이전 상태(from)와 이후 상태(to)가 필요하니, 맵 2개를 중첩하면 쉽게 해결 할 수 있다.

안쪽 맵은 이전 상태와 전이를 연결하고 바깥 맵은 이후 상태와 안쪽 맵을 연결한다. 전이 전후의 두 상태를 전이 열거 타입 Transition의 입력으로 받아, 이 Transition 상수들로 중첩된 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, Transition>(Phase.class))));

        /** 한 상태에서 다른 상태로의 전이를 반환한다. */
        public static Transition from(PhaseOrdinal from, PhaseOrdinal to) {
            return m.get(from).get(to);
        }
    }
}

상태 전이 맵을 초기화하는 코드는 제법 복잡하다.

먼저 이 맵의 타입인 Map\<Phase, Map\<Phase, Transition\>\>“이전 상태에서 ‘이후 상태에서 전이로의 맵’에 대응시키는 맵” 이라는 뜻이다.

이러한 맵의 맵을 초기화 하기 위해 java.util.stream.Collector 2개를 차례로 사용했다.

첫번채 Collector인 groupingBy에서는 전이를 이전 상태를 기준으로 묶고, 두번째 Collector인 toMap에서는 이후 상태를 전이에 대응시키는 EnumMap을 생성한다.

두번째 Collector.toMap의 병합 함수인 (x,y) -> y는 선언만 하고 실제로는 쓰이지 않는데, 이는 단지 EnumMap을 얻으려면 맵 팩터리가 필요하고 수집기들은 점층적 팩터리(telescoping factory)를 제공하기 때문이다.

이제 여기에 새로운 상태인 플라스마(PLASMA)를 추가해보자. 이상태에 연결된 전이는 2개다

첫번째는 기체에서 플라즈마로 변하는 이온화(IONIZE)이고, 둘째는 플라즈마에서 기체로 변화는 탈 이온화(DEIONIZE)이다.

이 상태를 배열로 만든 코드에 적용하려면 새로운 상수를 Phase와, Transition에 추가하고, TRANSITIONS배열이 3X3에서 4X4 배열로 늘어나면서 안에 들어갈 null값도 많아졌을 것이다.

반면 아래의 EnumMap을 이용한 코드에 새로운 상태를 추가하면 Phase와 Transition에 상수만 추가하면 끝이다.

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)

      ... /** 나머지 코드는 그대로다. */
    }
}

나머지는 기존로직에서 잘 처리해주기 때문에 잘못 수정할 가능성이 극히 작다. 실제 내부에서는 맵들의 맵이 배열들의 배열로 구현되니, 낭비되는 공간과 시간도 거의 없이 명확하고 안전하고 유지보수하기 좋다.