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

문법식·2022년 8월 30일
0

Effective Java 3/E

목록 보기
37/52

배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드(아이템 35)로 인덱스를 얻는 경우가 있다. 식물을 간단히 나타낸 다음 클래스를 예로 살펴본다.

class Plant{
	enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
    
    final String name;
    final LifeCycle lifeCycel;
    
    Plant(String name, LifeCycel lifeCycle){
    	this.name=name;
        this.lifeCycle=lifeCycle;
	}
    
    @Override
    public String toString(){
    	return name;
	}
}

위 클래스를 사용하여 식물들의 생애주기별로 집합으로 묶어서 관리한다. 집합들은 배열 하나에 넣고 생애주기의 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]);
}

동작은 하지만 문제가 많다. 배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않는다. 배열은 각 인덱스의 의미를 모르니 출력 결과에 직접 레이블을 달아야 한다. 가장 심각한 문제는 정확한 정숫값을 사용한다는 것을 우리가 직접 보증해야 한다는 것이다. 정수는 열거 타입과 달리 안전하지 않다. 잘못된 값을 사용하면 잘못된 동작을 묵묵히 수행하거나 운이 좋다면 ArrayIndexOutOfBoundsException을 던질 것이다.

해결책은 EnumMap을 사용하는 것이다. 위 코드에서 배열은 실질적으로 열거 타입 상수를 값으로 매핑하는 일을 한다. 그러니 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체인 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);    

더 짧고 명료하고 안전하고 성능도 원래 버전과 비등하다. 안전하지 않은 형변환은 쓰지 않고, 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 직접 레이블을 달 일도 없다. 나아가 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 없다. EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용하기 때문이다. EnumMap의 생성자가 받은 키 타입의 Class 객체는 한정적 타입 토큰으로 런타임 제네릭 타입 정보를 제공한다.

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

System.out.println(Arrays.stream(garden).collect(groupingBy(p->p.lifeCycle)));

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

System.out.println(Arrys.stream(garden).collect(groupingBy(p->p.lifeCycle, ()->new EnumMap<>(LifeCycle.class), toSet())));

단순한 프로그램에서는 최적화가 굳이 필요 없지만, 맵을 빈번히 사용하는 프로그램에서는 꼭 필요하다.
스트림으 사용하면 EnumMap만 사용했을 때와는 살짝 다르게 동작한다. EnumMap 버전은 언제나 식물의 생애주기당 하나씩의 중첩 맵을 만들지만, 스트림 버전에서는 해당 생애주기에 속하는 식물이 있을 때만 만든다.

두 열거 타입 값들을 매핑하느라 ordianl을 (두 번이나) 쓴 배열들의 배열이 있을 수 있다. 다음 예는 이 방식을 적용해 두 가지 상태(phase)를 전이(Transition)와 매핑하도록 구현한 프로그램이다.

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과 배열 인덱스의 관계를 알 수가 없다. 즉, PhasePhase.Transtion 열거 타입을 수정하면서 TRANSITIONS를 함께 수정하지 않거나 실수로 잘못 수정하면 런타임 오류가 날 것이다. Array.IndexOutOfBoundsException이나 NullPointerException을 던질 수 있고, 운 나쁘면 예외를 던지지 않고 이상하게 동작할 수도 있다. 그리고 상전이 표의 크기는 상태의 가짓수가 늘어나면 제곱해서 커지며 null로 채워지는 칸도 늘어난다.
다시 강조하지만 EnumMap을 사용하는 것이 좋다. 전이 하나를 언드려면 이전 상태(from)와 이후 상태(to)가 필요하니, 맵 2개를 중첩하면 쉽게 해결할 수 있다. 안쪽 맵은 이전 상태와 전이를 연결하고 바깥 맵은 이후 상태와 안쪽 맵을 연결한다. 전이 전후의 두 상태를 열거 타입 Transition의 입력으로 받아, 이 Transtion상수들로 중첩된 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(Phse from, Phase to){
        	return m.get(from).get(to);
		}
	}
}            

코드가 복잡하다. 이 맵의 타입인 Map<Phase, Map<Phase, Transition>>은 "이전 상태에서 '이후 상태에서 전이로의 맵'에 대응시키는 맵"이라는 뜻이다. 이 맵을 초기화하기 위해 Collector 2개를 차례로 사용했다. 첫 번째 CollectorgroupingBy에서는 전이를 이전 상태를 기준으로 묶고, 두 번째 수집기인 toMap에서는 이후 상태를 전이에 대응시키는 EnumMap을 생성한다. 두 번째 수집기의 병합 함수인 (x, y) -> y는 선언만 하고 실제로는 쓰이지 않는데, EnumMap을 얻으려는 맵 팩터리가 필요하고 Collector들은 점층적 팩터리를 제공하기 때문이다.

여기에 새로운 상태인 플라스마(PLASMA를 추가해본다. 이 상태와 연결된 전이는 2개댜. 첫 뻔재는 기체에서 플라스마로 변하는 이온화(IONIZE)이고, 둘째는 플라스마에서 기체로 변하는 탈이온화(DEIONIZE)다. 배열로 만든 코드를 수정하려면 새로운 상수를 Phase에 1개, Phase.Transtion에 2개를 추가하고, 원소 9개짜리인 2차원 배열을 원소 16개짜리로 교체해야 한다. 원소 수를 너무 적거나 많이 기입하거나, 잘못된 순서로 나열하면 이 프로그램은 런타임에 문제가 발생할 수 있다. 반면, EnumMap 버전에서는 상태 목록에 PLASMA를 추가하고, 전이 목록에 IONIZE(GAS, PLASMA)DEIONIZE(PLASMA, GAS)만 추가하면 끝이다. 나머지는 기존 로직에서 잘 처리해주어 잘못 수정할 가능성이 적다.

profile
백엔드

0개의 댓글