← 학습기록 (Java · 이펙티브 자바 3E)

6장 - 열거 타입과 어노테이션

34. int 상수 대신 열거 타입을 사용하라

열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다.

열거타입을 지원하기 전에는 정수 상수를 한 묶음 선언해서 사용하곤 했다.

public static final int APPLE  = 0;
public static final int BANANA = 1;

정수 열거 패턴 기법에는 단점이 많다.

타입 안전을 보장할 방법이 없으며 표현력도 좋지 않다. 값이 같으면 다른 변수를 보내고 동등 연산자로 비교해도 아무런 경고 메시지를 출력하지 않는다.

정수 열거 패턴을 위한 별도의 Namespace를 지원하지 않기 때문에 접두어를 써서 구분하는데, 이 또한 코드가 지저분해진다.

정수 열거 패턴을 사용한 프로그램은 깨지기 쉽다. 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨진다.

따라서 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다.

다시 컴파일하지 않은 클라이언트는 실행이 되더라도 엉뚱하게 동작할 것이다.

정수 대신 문자열 상수를 사용하는 변형 패턴도 있지만, 이 패턴은 더 나쁘다. 문자열 값을 하드코딩하게 만들기 때문이다.

하드코딩한 문자열에 오타가 있어도 컴파일러는 확인할 길이 없으니 자연스럽게 런타임 버그가 생긴다.

이러한 패턴들의 단점을 말끔히 씻어주는 동시에 여러 장점을 안겨주는 대안인 열거 타입을 만들어주었다.

public enum Apple { FUJI, PIPPING }

겉보기에는 다른 언어의 열거 타입과 비슷하지만, 자바의 열거 타입은 완전한 형태의 클래스라서 다른 언어의 열거 타입보다 훨씬 강력하다.

자바 열거 타입을 뒷받침하는 아이디어는 단순하다. 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.

열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final이다.

따라서 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없으니 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재함이 보장된다.

다시 말해 열거 타입은 인스턴스 통제된다.

싱글턴은 원소가 하나뿐인 열거 타입이라 할 수 있고, 거꾸로 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있다.

열거 타입에은 컴파일타임 타입 안정성을 제공한다. 열거 타입을 매개변수로 받는 메서드를 선언했다면, 건네받은 참조는 (null이 아니라면) 해당 열거 타입임이 확실하다.

열거 타입에는 각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공존한다.

열거타입에 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 된다.

공개되는 것이 오직 필드의 이름뿐이라, 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문이다.

마지막으로 열거 타입의 toString 메서드는 출력하기에 적합한 문자열을 내어준다.

또 열거 타입에는 임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있다.

가장 단순하게는 그저 상수 모음일 뿐인 열거 타입이지만, 고차원의 추상 개념 하나를 완벽히 표현해낼 수도 있는 것이다.

열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.

열거 타입은 근본적으로 불변이라 모든 필드는 final이어야 한다.

널리 쓰이는 열거 타입은 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스로 만든다.

35. ordinal 메서드 대신 인스턴스 필드를 사용하라

대부분의 열거 타입 상수는 자연스럽게 하나의 정숫값에 대응된다. 그리고 모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는 ordinal이라는 메서드를 제공한다.

이런 이유로 열거 타입 상수와 연결된 정숫값이 필요하면 ordinal 메서드를 이용하고 싶은 유혹에 빠진다.

열거 타입의 ordinal 메서드를 사용하게 되면, 동작은 하지만 유지보수하기에 끔찍하게 되며 실용성이 떨어진다.

해결책은 간단하다

ordinal 메서드를 사용하지 말고, 인스턴스 필드에 저장하자.

36. 비트 필드 대신 EnumSet을 사용하라

열거한 값들이 주로 집합으로 사용될 경우, 예전에는 각 상수에 서로 다른 2의 거듭제곱 값을 할당한 정수 열거 패턴을 사용해 왔다

public class Text { // 구닥다리 코드!!
	public static final int STYLE_BOLD             = 1 << 0;
	public static final int STYLE_ITALIC           = 1 << 1;
	public static final int STYLE_UNDERLINE        = 1 << 2;
	public static final int STYLE_STRIKETHOROUGH   = 1 << 3;
}

다음과 같은 식으로 비트별 OR를 사용해 여러 상수를 하나의 집합으로 모을 수 있으며, 이렇게 만들어진 집합을 비트 필드라 한다.

text.applyStyles(STYLE_BOLD | STYLE_ITALIC);

비트 필드를 사용하면 비트별 연산을 사용해 합집합과 교집합 같은 집합 연산을 효율적으로 수행할 수 있다.

하지만 비트 필드는 정수 열거 상수의 단점을 그대로 지니며, 추가로 다음과 같은 문제까지 안고 있다.

비트 필드 값이 그대로 출력되면 단순한 정수 열거 상수를 출력할 때보다 해석하기가 훨씬 어렵다.

비트 필드 하나에 녹아 있는 모든 원소를 순회하기도 까다롭다.

마지막으로, 최대 몇 비트가 필요한지를 API 작성 시 미리 예측하여 적절한 타입을 선택해야 한다.

API를 수정하지 않고는 비트 수(32, 64)를 더 늘릴 수 없기 때문이다.

하지만 이제 더 나은 대안이 있다.

java.util 패키지의 EnumSet 클래스는 열거 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다.

Set 인터페이스를 완벽히 구현하며, 타입 안전하고, 다른 어떤 Set 구현체와도 함께 사용할 수 있다.

하지만 EnumSet의 내부는 비트 벡터로 구현되었다.

원소가 총 64개 이하라면, 즉 대부분의 경우에 EnumSet 전체를 long 변수 하나로 표현하여 비트 필드에 비견되는 성능을 보여준다.

removeAll과 retainAll 같은 대량 작업은 (비트 필드를 사용할 때 쓰는 것과 같은) 비트를 효율적으로 처리할 수 있는 산술 연산을 써서 구현했다.

그러면서도 비트를 직접 다룰 때 흔히 겪는 오류들에서 해방된다. 난해한 작업을 EnumSet이 다 처리해주기 때문이다.

앞의 예를 EnumSet을 사용해 수정해보았다. 깔끔하고 안전하다.

public class Text {
	public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
}
text.applyStyles(EnumSet.of(Style.BOLE, Style.ITALIC));

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

이따금 배열이나 리스트에서 원소를 꺼낼 때 ordinal 메서드로 인덱스를 얻는 코드가 있다.

예를 들어 enum 타입안의 원소들을 배열 하나에 넣고 ordinal 값을 그 배열의 인덱스로 사용하려 할 수 있다.

class Plant {
	enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }

	private String name;
	private LifeCycle lifeCycle;
}
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);

//결과 출력

동작은 하겠지만 문제가 한가득이다.

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

정수는 열거 타입과 달리 타인 안전하지 않기 때문이다. 잘못된 값을 사용하면 잘못된 동작을 묵묵히 수행하거나, (운이 좋다면) ArrayIndexOutOfBoundsException을 던질 것이다.

훨씬 멋진 해결책이 있으니 걱정마시라.

여기서 배열은 실질적으로 열거 타입 상수를 값으로 매핑하는 일을 한다. 그러니 Map을 사용할 수도 있을 것이다.

사실 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체가 존재하는데 바로 EnumMap이 그 주인공이다. 다음은 EnumMap을 사용하여 위 코드를 수정한 예시다

Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle = 
	new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
	plantsByLifeCycle.put(lc, new HashSet<>());
...

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

안전하지 않은 형변환은 쓰지 않고, 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 직접 레이블을 달 필요도 없다.

나아가 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 원천봉쇄된다.

EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용하기 때문이다.

내부 구현 방식을 안으로 숨겨서 Map의 타입 안전성과 배열의 성능을 모두 얻어낸 것이다.

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

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

다음은 앞의 예의 동작을 거의 그대로 모방한 가장 단순한 형태의 스트림 기반 코드다.

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

핵심정리

배열의 인덱스를 얻기 위해 ordinal을 쓰는 것은 일반적으로 좋지 않으니 대신 EnumMap을 사용하라.

다차원 관계는 EnumMap<…, EnumMap<…>>으로 표현하라.

“애플리케이션 프로그래머는 Enum.ordinal을 사용하지 말아야 한다”는 일반 원칙의 특수한 사례다

38. 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

열거 타입은 거의 모든 상황에서 “타입 안전 열거 패턴”보다 우수하다. 단, 예외가 하나 있으니, 타입 안전 열거 패턴은 확장할 수 있으나 열거 타입은 그럴 수 없다는 점이다.

실수로 이렇게 설계한 것은 아니다. 사실 대부분 상황에서 열거 타입을 확장하는건 좋지 않은 생각이다.

확장한 타입의 원소는 기반 타입의 원소로 취급하지만 그 반대는 성립하지 않는다면 이상하지 않은가! 기반 타입과 확장된 타입들의 원소 모두를 순회할 방법도 마땅치 않다. 마지막으로, 확장성을 높이려면 고려할 요소가 늘어나 설계와 구현이 더 복잡해진다.

그런데 확장할 수 있는 열거 타입이 어울리는 쓰임이 최소한 하나는 있다. 바로 연산 코드다.

연산 코드의 각 원소는 특정 기계가 수행하는 연산을 뜻한다. 이따금 API가 제공하는 기본 연산 외에 사용자 확장 연산을 추가할 수 있도록 열어줘야 할 때가 있다.

다행히 열거 타입으로 이 효과를 내는 멋진 방법이 있다. 기본 아이디어는 열거 타입이 임의의 인터페이스를 구현할 수 있다는 사실을 이용하는 것이다.

연산 코드용 인터페이스를 정의하고 열거 타입이 이 인터페이스를 구현하게 하면 된다.

public interface Operation {
	double apply(double x, double y);
}
public enum BasicOperation implements Operation {
	PLUS ("+") {
		public double apply(double x, double y) {return x + y;}
	}
	private final String symbol;
}

열거 타입인 BasicOperation은 확장할 수 없지만 인터페이스인 Operation은 확장할 수 있고, 이 인터페이를 연산의 타입으로 사용하면 된다.

이렇게 하면 Operation을 구현한 또 다른 열거 타입을 정의해 기본 타입인 BasicOperation을 대체할 수 있다.

public enum ExtendedOperation implements Operation {
	EXP("^") {
		public double apply(double x, double y) {
			return Math.pow(x, y);
		}
	}
	private final String symbol;
}

새로 작성한 연산은 기존 연산을 쓰던 곳이면 어디든 쓸 수 있다. Operation 인터페이스를 사용하도록 작성되어 있기만 하면 된다.

public static void main(String[] args) {
	double x = Double.parseDouble(args[0]);
	double y = Double.parseDouble(args[1]);
	test(Arrays.asList(ExtendedOperation.values()), x, y);
}

private static void test(Collection<? extends Operation> opSet,
	double x, double y) {
	for (Operation op: opSet)
		System.out.printf(op.apply(x, y));
}

39. 명명 패턴보다 애너테이션을 사용하라

전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왓다.

예컨대 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게끔 했다. 효과적인 방법이지만 단점도 크다.

오타가 나면 안된다. 실수로 이름을 tsetSome()이라고 지으면 테스트를 아예 무시하고 넘어가기 때문에 개발자는 통과했다고 오해할 수 있다.

프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다는 것도 문제다.

특정 예외를 던져야만 성공하는 테스트가 있다고 할 때, 기대하는 예외 타입을 테스트에 매개변수로 전달해야 하는 상황이다.

예외의 이름을 메서드 이름에 덧붙이는 방법도 있지만, 보기도 나쁘고 깨지기도 쉽다.

어노테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다.

도구 제작자를 제외하고는, 일반 프로그래머가 어노테이션 타입을 직접 정의할 일은 거의 없다. 하지만 자바 프로그래머라면 예외 없이 자바가 제공하는 어노테이션 타입을은 사용해야 한다.

40. @Override 어노테이션을 일관되게 사용하라

자바가 기본으로 제공하는 어노테이션 중 보통의 프로그래머에게 가장 중요한 것은 @Override일 것이다.

@Override는 메서드 선언에만 달 수 있으며, 이 어노테이션이 달렸다는 것은 상위 타입의 메서드를 재정의했음을 의미한다.

이 어노테이션을 일관되게 사용하면 여러 가지 악명높은 버그들을 예방해준다.

상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 어노테이션을 달자.

예외는 한 가지 뿐이다. 구체 클래스에서 상위 클래스의 추상 메서드를 재정의할 때는 굳이 @Override를 달지 않아도 된다.

41. 정의하려는 것이 타입이라면 마커 인터페이스를 사용하라

아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이를 마커 인터페이스라고 한다.

Serializable 인터페이스가 가장 좋은 예다. Serializable은 자신을 구현한 클래스의 인스턴스는 ObjectOutputStream을 통해 쓸 수 있다고, 즉 직렬화할 수 있다고 알려준다.

마커 어노테이션이 등장하면서 마커 인터페이스는 구식이 되었다는 얘기를 들어보았는가?

하지만 사실이 아니다. 마커 인터페이스는 두 가지 면에서 마커 어노테이션보다 낫다.

첫 번째, 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나 어노테이션은 아니다.

두 번째, 적용 대상을 더 정밀하게 지정할 수 있다는 것이다.

반대로 마커 어노테이션이 마커 인터페이스보다 나은 점으로는 거대한 어노테이션 시스템의 지원을 받는다는 점을 들 수 있다.

따라서 어노테이션을 적극 활용하는 프레임워크에서는 마커 어노테이션을 쓰는 쪽이 일관성을 지키는 데 유리할 것이다.

마커 어노테이션을 쓸 수 밖에 없을 때는, 클래스나 인터페이스 외의 프로그램 요소에 마킹해야 할 때 이다.

핵심정리

마커 인터페이스와 마커 어노테이션은 각자의 쓰임이 있다. 새로 추가하는 메서드 없이 단지 타입 정의가 목적이라면 마커 인터페이스를 선택하자.

적용 대상이 ElementType.TYPE인 마커 어노테이션을 작성하고 있다면, 잠시 여유를 갖고 정말 어노테이션으로 구현하는 게 옳은지, 혹은 마커 인터페이스가 낫지는 않을지 곰곰히 생각해보자.