본문 바로가기

Reading/Effective Java

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

문제: ordinal 값을 배열의 인덱스로 사용하는 방법


아래 코드는 식물을 나타내기 위한 클래스다.

 

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

 

    Plant[] garden = {
            new Plant("바질",    LifeCycle.ANNUAL),
            new Plant("캐러웨이", LifeCycle.BIENNIAL),
            new Plant("딜",      LifeCycle.ANNUAL),
            new Plant("라벤더",   LifeCycle.PERENNIAL),
            new Plant("파슬리",   LifeCycle.BIENNIAL),
            new Plant("로즈마리", LifeCycle.PERENNIAL)
    };

 

 

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

이 때 아래 코드는 ordinal 값을 그 배열의 인덱스로 사용한 코드다 

 

public static void main(String[] args) {
    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]);
    }
}

 

정상적으로 동작하지만 여기에는 여러 문제가 있다.

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

따라서 좋지 않은 방법이다.

 

 

대안: 열거 타입을 키로 사용하도록 설계한 EnumMap 사용


 

public static void main(String[] args) {
    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);
}

 

더 짧고 명료하며 안전하고 성능도 ordinal을 인덱스로 사용하는 배열과 비등하다. 안전하지 않은 형변환을 쓰지 않고 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 레이블을 달 일도 없다. 인덱스와 관련된 이슈도 사라지게 된다.

위 코드에서 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로 런타임 제네릭 타입 정보를 제공한다.

 

EnumMap은 내부에서 배열을 사용한다. 내부 구현 방식을 안으로 숨겨 Map의 타입 안전성과 배열의 성능을 모두 얻은 것이다.

 

내부를 보면, value를 배열로 사용함을 알 수 있다.

 

EnumMap의 vals에는 이 Map의 value가 들어간다. 그렇다면 제일 기본적인 get, put, remove 메소드는 어떻게 작용할까?

 

 

모든 메소드에서 key 매개변수가 현재 타입이 지정한 열거타입이 맞는지 확인 후, 그 열거타입의 oridinal을 가져와서 배열의 인덱스로 사용하는 것을 알 수 있다. 키가 유효한지 검사하는 것은 매개변수로 들어온 key의 클래스 타입과 상위 클래스 타입을 확인하는데 이 때 처음 생성자에서 지정해둔 keyType과 비교해서 유효성을 판단한다.

 

 

Stream을 사용한 관리


스트림을 통해 맵을 관리하면 코드를 더 줄일 수 있다. 가장 단순한 형태의 스트림 기반  두가지 코드를 보자.

 

//EnumMap을 사용하지 않음
System.out.println(Arrays.stream(garden)
        .collect(groupingBy(p -> p.lifeCycle)));

//EnumMap을 이용해 데이터와 열거타입 매핑
System.out.println(Arrays.stream(garden)
        .collect(groupingBy(p -> p.lifeCycle,
                () -> new EnumMap<>(LifeCycle.class), toSet())));

 

첫 번째 코드는 groupby()에 추가적으로 매개변수를 전달하지 않아 HashMap으로 생성된다. 

따라서  EnumMap이 아닌  HashMap 구현체를 써서 EnumMap을 써서 얻는 공간과 성능 이점이 사라진다.

key, hash, value를 가지는 Node를 배열로 가지는 HashMap과 달리 enumType만 가지고 value 배열로 직접 접근하는 enumMap이 공간상 이점을 가지고, 해스테이블에서 같은 인덱스를 가지는 Node 탐색 과정, 키를 해시하기 위한 과정 등이 추가로 들어가는 HashMap과 달리 EnumMap은 ordinal을 이용한 배열을 사용하여 직접 접근하기에 성능상의 이점이 있다는 의미에서 쓰인 것 같다.

그리고 value의 구현체도 매개변수를 전달하지 않아 내부의 toList() 메소드에 의해 ArrayList가된다.

 

이 예처럼 단순한 경우에는 필요없지만, 맵을 빈번히 사용하는 프로그램에서는 필요할 것이다.

두 번째 코드가 매개변수에 원하는 Map 구현체를 명시해 호출한 것이다.

매개변수 3개짜리 groupBy 메소드는 mapFactory 매개변수에 원하는 맵 구현체를 명시해 호출할 수 있다.

중복을 방지하기 위해 value는 toSet()메소드로 인해 HashSet으로 설정된다. 

 

스트림을 사용했을 때 EnumMap을 선언해서 추가하여 사용했을때와 살짝 다르게 동작한다. EnumMap을 선언해 추가하면, 언제나 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을때만 만든다.

아래 forMapCount 필드는 PERNNIAL 생애주기를 제외하고 아래와 같이 배열로 만들었다.

 

Plant[] forMapCount = {
        new Plant("바질",    LifeCycle.ANNUAL),
        new Plant("딜",      LifeCycle.ANNUAL),
        new Plant("캐러웨이", LifeCycle.BIENNIAL),
};

 

이렇게 만들었을때, 스트림의 2가지 버전과 EnumMap 버전을 각각 출력해보면 아래와 같다.

 

 

복잡한 예제: ordinal을 배열 인덱스로 사용하는 경우와 EnumMap 사용하는 경우 


1. ordinal을 인덱스로 사용하는 배열 방식 

 

아래는 두 열거타입의 인덱스를 배열 인덱스로 매핑해 사용한 예다. 

예를들어 액체(LIQUID)에서 고체(SOLID)로의 전이는 응고(FREEZE)이다. 이런 방식으로 적용을하면

3가지 Phase 대해 각각 매핑시키면 총 9개의 transition이 나온다. 같은 Phase는 변화 상태가 없으니 null이다.

 

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

 

이 방법 또한 컴파일러는 ordinal과 배열 인덱스의 관계를 알 도리가 없다.

Phase나 Transition 열거타입을 수정하면서 TRANSITIONS[][] 내부 배열을 수정하지 않거나 실수로 잘못 수정한다면

ArrayIndexOutOfBoundsException이나 NullPointerException을 던질 수 있고, 운이 나쁘면 예외도 없이 이상하게 작동할 수 있다.

그리고 Phase가 가짓수가 늘어나면 제곱해서 커지며 null로 채워지는 칸도 많아지게 된다. 

 

2. 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.class))));

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

 

우선 groupBy에서 키를 이전 상태를 기준으로 묶는다(t.from을 통해). 그리고  2번째 파라미터인 mapFactory에 EnumMap 구현체를 사용하겠다고 전달하고, 세번째 파라미터는 toMap을 통해 value 값을 이후 상태를 전이에 대응시키는 EnumMap을 생성한다.

toMap()의 첫 번째 파라미터는 키를 이후 상태를 기준으로 묶고(t.to를 통해) value는 전이에 대응시키는 EnumMap을 생성한다.

toMap()의 세 번째 파라미터는 병합 함수인데 여기서는 선언만 하고 실제로 쓰이지 않는다. 이는 단지 EnumMap을 얻으려면

맵 팩토리가 필요하고 점층적 팩토리로 제공하기 때문이다. 

 

toMap()은 key,valueMapper 두개 인자부터 시작

 

다음 점층적 팩토리는 mergeFunction을 추가한 파라미터 3개 메소드

 

다음 점층적 팩토리는 이에 mapFactory를 추가한 파라미터 4개 메소드

 

따라서 점층적 팩토리로 구현되어있기에 mapFactory를 사용하려면 mergeFunction을 꼭 넣어야해서 처리한 것이다.

*점층적 팩토리 방식:  매개변수 개수만큼 생성자를 늘리는 방식

 

병합정렬은 키 값이 같은 것들의 value를 병합할때 쓰인다. 자세한 내용은 아래 사이트를 참조하자. 

 

 

Collectors toMap() method in Java with Examples - GeeksforGeeks

A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.

www.geeksforgeeks.org

 

이제 여기에 새로운 Phase가 추가되도 그 추가한 Phase와 연결되는 Transition 내용만 추가하면 된다. 배열로 만든 코드였다면 원소9개짜리를 16개짜리로 교체해야한다. 원소 수를 너무 적거나 많이 기입하거나, 잘못된 순서로 나열하면 이 프로그램은 런타임에 문제가 생길것이다. 반면 EnumMap 버전은 Phase목록과 Transition 목록만 추가하면 된다. PLASM라는 상태를 추가하면 아래와 같다.

 

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 Docs에도 나오듯이 ordinal을 웬만해선 사용하지 말자. 

 

 

? -> EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공한다. (잘 이해못함,  의미 나중에 더 상세히 찾아보기)

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.