아이템 37. ordinal 인덱싱 대신 EnumMap을 사용하라

wisdom·2022년 8월 19일
0

Effetctive Java

목록 보기
37/80
post-thumbnail

1. EnumMap - 데이터와 열거 타입 매핑

이따금씩 배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드로 인덱스를 얻는 코드가 있다.
Plant 예제를 보자. 식물은 생애주기(LifeCycle)을 가지며, ANNUAL(한해살이), PERENNIAL(여러해살이), BIENNIAL(두해살이)가 있다.

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;
    }

1️⃣ ordinal()을 배열로 인덱스로 사용하는 경우

 // 코드 37-1 ordinal()을 배열 인덱스로 사용 - 따라 하지 말 것! 
Set<Plant>[] plantsByLifeCycleArr = (Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycleArr.length; i++)
	plantsByLifeCycleArr[i] = new HashSet<>();
    
for (Plant p : garden)
	plantsByLifeCycleArr[p.lifeCycle.ordinal()].add(p);
                
// 결과 출력
for (int i = 0; i < plantsByLifeCycleArr.length; i++) {
	System.out.printf("%s: %s%n", Plant.LifeCycle.values()[i], plantsByLifeCycleArr[i]);
}

문제점

위의 코드는 동작은 하지만 문제가 많다.

  1. 제네릭과 호환되지 않으니 (아이템 28) 비검사 형변환을 수행해야 하고, 깔끔히 컴파일되지 않는다.
  2. 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
  3. 정확한 정숫값을 사용한다는 것을 직접 보증해야 한다. 잘못된 값을 사용하면 잘못된 동작을 수행하거나, 운이 좋다면 ArrayIndexOutOfBoundException을 던질 것이다.

2️⃣ EnumMap을 사용하는 경우

💡 EnumMap

열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체다.

위에서 배열은 실질적으로 열거 타입 상수 값으로 매핑하는 일을 했으니 EnumMap으로 대체할 수 있으며, 위에서의 문제를 모두 해결할 수 있다.

// 코드 37-2 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);

장점

  1. 더 짧고 명료하다.
  2. 안전하다.
    안전하지 않은 형변환을 쓰지 않고, 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 날려버린다.
  3. 출력 결과에 직접 레이블을 달 필요도 없다.
    맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하기 때문이다.
  4. 배열의 성능과 비등하다.
    EnumMap은 내부에서 배열을 사용하기 때문에 배열의 성능을 얻을 수 있다.

3️⃣ 스트림을 사용하는 경우

스트림을 사용해 맵을 관리하면 코드를 더 줄일 수 있다.

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

이 코드는 EnumMap을 사용하지 않고, 고유한 맵 구현체를 사용한다.
따라서 EnumMap의 공간과 성능의 이점이 사라진다는 문제가 있다.

Q. 스트림에서 EnumMap을 사용하려면 어떻게 해야할까?

매개변수 3개짜리 Collectors.groupingBy 메서드를 사용하면 된다.
두 번째 매개변수인 mapFactory에 원하는 맵 구현체를 명시해 호출할 수 있는데, 이 부분에 EnumMap을 사용하면 된다.

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

Q. 스트림을 사용할 때와 EnumMap만 사용할 때의 차이는?

두 가지는 살짝 다르게 동작한다.
EnumMap 버전은 항상 식물의 생애주기당 중첩 맵을 하나씩 만든다. (즉, 모든 생애주기는 맵을 하나씩 가진다.)
반면에 스트림 버전은 해당 생애주기에 속하는 식물이 있을 때만 만든다.

예를 들어, 만약 한해살이와 여러해살이 식물만 있으면 두해살이 식물이 없다면
EnumMap 버전은 맵을 3개 만들지만, 스트림 버전은 2개만 만든다.


2. 중첩 EnumMap - 데이터와 열거 타입 쌍 매핑

두 열거 타입 값들을 매핑할 때 ordinal을 두 번씩 쓴 배열들의 배열을 본 적이 있을 것이다.
두 가지 상태(Phase)를 전이(Transition)와 매핑하는 예제를 보자.

액체(LIQUID) → 고체(SOLID) : 응고(Freeze)
액체(LIQUID) → 기체(GAS) : 기화(BOIL)

1️⃣ 배열의 배열 인덱스에 ordinal()를 사용하는 경우

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()];
	}
}

문제점

멋져보이지만, 이 코드에도 문제점이 많다.

  1. Phase나 Phase.Transition 열거 타입을 수정할 때, 상전이 표인 TRANSITIONS도 정확하게 수정해야 한다.
    컴파일러는 ordianl과 배열 인덱스의 관계를 알지 못 하기 때문에, 실수로 수정하지 않거나 잘못 수정하면 런타임 오류가 발생할 것이다. ArrayIndexOutOfBoundsException이나 NullPointerException을 던질 수도 있고, 운이 나쁘면 예외도 던지지 않고 이상하게 동작할 수도 있다.
  2. 공간이 비효율적이다.
    상전이 표의 크기는 상태의 가짓수가 늘어남에 따라 제곱씩 커진다.
    또한 가짓수가 늘어날수록 null로 채워지는 칸도 늘어난다.

2️⃣ 중첩 EnumMap을 사용하는 경우

// 코드 37-6 중첩 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>>은 "이전 상태에서 '이후 상태에서 전이로의 맵'에 대응시키는 맵"이라는 뜻이다.
이러한 맵을 초기화하기 위해 Collector 2개를 차례로 사용했다.

  • groupingBy : 전이를 이전 상태를 기준으로 묶는다.
  • toMap : 이후 상태를 전이에 대응시키는 EnumMap을 생성한다.

❗️ 새로운 상태를 추가하는 경우

여기에 새로운 상태인 플라즈마(PLASMA)를 추가해보자.

기체 → 플라즈마 : 이온화(IONIZE)
플라즈마 → 기체 : 탈이온화(DEIONIZE)

배열에 플라즈마를 추가해야 한다면, 새로운 상수 Phase에 1개, Phase.Transition에 2갤르 추가하고, 원소 9개짜리인 배열들의 배열을 원소 16개짜리로 교체해야 한다.

반면에, EnumMap 버전에 추가할 때는 상태 목록에 플라즈마(PLASMA)를 추가하고, 전이 목록에 IONIZE(GAS, PLASMA)와 DEIONIZE(PLASMA, GAS)만 추가하면 된다.

// 코드 37-7 EnumMap 버전에 새로운 상태 추가하기
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);
		... 
    }
}

📌 핵심 정리

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

profile
백엔드 개발자

0개의 댓글