새소식

독서/이펙티브 자바 3판

CH05 열거 타입과 애너테이션

  • -

step34: int 상수 대신 열거 타입을 사용하라

 

 열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다. 정수 열거 패턴기법에는 단점이 많은데, 타입 안전을 보자할 방법이 없으며 표현력도 좋지 않다. 정수 열거 패턴을 사용한 프로그램은 깨지기 쉽다. 평범한 상수를 나열한 것뿐이라 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨진다. 따라서 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다. 다시 컴파일하지 않은 클라이언트는 실행이 되더라도 엉뚱하게 동작할 것이다.

 

 정수 대신 문자열 상수를 사용하는 변형 패턴도 있다. 문자열 열거 패턴 이라 하는 이 번형은 도 나쁘다. 상수의 의미를 출력할 수 있다는 점은 좋지만, 경험이 부족한 프로그래머사 문자열 상수의 이름 대신 문자열 값을 그대로 하드코딩하게 만들기 때문이다.

 

 다행히 자바는 열거 패턴의 단점을 말끔히 씻어주는 동시에 장점을 안겨주는 대안을 제시했는데, 바로 열거 타입이다. 자바 열거 타입을 뒷받침하는 아이디어는 단순하다. 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다. 열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final이다. 따라서 클라이언트가 인스턴스를 직접 생성하거나 확장 할 수 없으니 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재함이 보장되어 인스턴스 통제된다.

 

 싱글턴은 원소가 하나뿐인 열거 타입이라 할 수 있고, 거꾸로 열거 타입은 싱글턴을 일반화한 형태라고 볼 수 있다. 열거 타입은 컴파일타임 타입 안전성을 제공한다. 열거 타입에는 각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공존한다. 열거 타입에 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일 하지 않아도 된다. 공개되는 것이 오직 필드의 이름뿐이라, 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문이다.

 

 열거 타입은 상수별로 다르게 동작하는 코드를 구현하는 더 나은 수단을 제공한다. 열거 타입에 apply라는 추상 메서드를 선언하고 각 상수별 클래스 몸체, 즉 각 상수에서 자신에 맞게 재정의 하는 방법인데 이를 상수별 메서드 구현 이라고 한다.

 

 열거 타입에는 상수 이름을 입력받아 그 이름에 해당하는 상수를 반환해주는 valueOf(String) 메서드가 자동 생성된다. 한편, 열거 타입의 toString 메서드를 재정의하려거든, toString이 반환하는 문자열을 해당 열거 타입 상수로 fromString 메서드도 함께 제공하는 걸 고려해보자. 상수별 메서드 구현에는 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있다. 

 

 swtich문은 열거 타입의 상수별 동작을 구현하는데 적합하지 않지만, 기존 열거 타입에 상수별 동작을 혼합해 넣을 대는 switch 문이 좋은 선택이 될 수 있다. 열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.

 

그래서 열거 타입을 과연 언제 쓰란 말인가!? 

: 필요한 원소를 컴파일타임에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자.

 

 

정리

 

열거 타입은 확실히 정수 상수보다 뛰어나다. 더 읽기 쉽고 안전하고 강력하다. 대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만, 각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작하게 할 때는 필요하다. 드물게는 하나의 메서드가 상수별로 다르게 동작해야 할 때도 있다. 이런 열거 타입에서는 switch 문 대신 상수별 메서드 구현을 사용하자. 열거 타입 상수 일부가 같은 동작을 공유한다면 전략 열거 타입 패턴을 사용하자.

 

 

 

 

step35: ordinal 메서드 대신 인스턴스 필드를 사용하라

 

 대부분의 열거 타입 상수는 자연스럽게 하나의 정숫값에 대응된다. 모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는 ordinal이라는 메서드를 제공한다. 이런 이유로 열거 타입 상수와 연결된 정숫값이 필요하면 ordinal 메서드를 이용하고 싶은 유혹에 빠진다. 하지만 이는 값의 추가나 중간의 값을 비워둘 수 없기 때문에 실용성이 떨어진다. 

 

 열거 타입 상수에 연결된 값은 ordinal 메서드로 얻지말고 인스턴스 필드에 저장하자.

 

 

 

 

step36: 비트 필드 대신 EnumSet을 사용하라

 

 열거한 값들이 주로 집합으로 사용될 경우, 예전에는 각 상수에 서로 다른 2의 거듭제콥 값을 할당한 정수 열거 패턴을 사용해왔다. 각 상수마다 public static final int STYLE_BOLD = 1<< 0;       과 같은 식으로 비트별 OR를 사용해 상수를 하나의 집합으로 모을 수 있으며 이렇게 만들어진 집합을 비트 필드라 한다.

 

 비트 필드를 사용하면 비트별 연산을 사용해 합집합과 교집합과 같은 집합 연산을 효율적으로 수행할 수 있다. 하지만 비트 필드는 정수 열거 상수의 단점을 그댜로 지니며 추가로 아래와 같은 문제까지 안고 있다.

 

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

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

3rd 최대 몇 비트가 필요한지 API작성 시 미리 예측하여 적절한 타입을 선택해야 한다. API를 수정하지 않고는 비트 수를 늘릴 수 없기 때문이다.

 

 이제는 더 나은 대안으로 EnumSet 클래스가 있다. Set 인터페이스를 완벽히 구현하며, 타입 안전하고, 다른 어떤 Set 구현체와도 함께 사용할 수 있다. 하지만 EnumSet의 내부는 비트 벡터로 구현되어 있다. 원소가 총 64개 이하라면, 대부분의 경우에 EnumSet 전체를 long 변수 하나로 표현하여 비트 필드에 비견되는 성능을 보여준다. removeAll과 retainAll 같은 대량 작업은 비트를 효율적으로 처리할 수 있는 산술연산을 써서 구현했다. 

 

EnumSet은 집합 생성 등 다양한 기능의 정적 팩터리를 제공하는데, 다음 코드에서는 그중 of 메서드를 사용했다.

ex) text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));

 

applyStyles 메서드가 EnumSet<Style>이 아닌 Set<Style>을 받은 이유를 생각해보자. 모든 클라이언트가 EnumSet을 건네리라 짐작되는 상황이라도 이왕 인터페이스로 받는게 일반적으로 좋은 습관이다. 이렇게 하면 좀 특이한 클라이언트가 다른 Set 구현체를 넘기더라도 처리할 수 있으니 말이다. 

 

 

정리

 

 열거할 수 있는 타입을 한데 모아 집합 형태로 사용한다고 해도 비트 필드를 사용할 이유는 없다. EnumSet 클래스가 비트 필드 수준의 명료함과 성능을 제공하고 아이템 34에서 설명한 열거 타입의 장점까지 선사하기 때문이다. EnumSet의 유일한 단점이라면 불변 EnumSet으로 만들 수 없다는 것이다. 향후 릴리스에서 수정되기 전까지 Collections.unmodifiableSet으로 EnumSet을 감싸 사용하면 되겠다.

 

 

 

 

step37: ordinal 인덱싱 대신 EnumMap을 사용하라

 

 열거 타입을 키로 사용하도록 설계한 아주 빠른 Map 구현체가 존재하는데, 바로  EnumMap이다. ordinal() 대신 EnumMap을 사용하여 더 간단하고 안전하다. 안전하지 않은 형변환은 쓰지 않고, 맵의 키인 열거 타입이 그 자체로 출력용 문자열을 제공하니 출력 결과에 직접 레이블을 달 일도 없다. 나아가 배열 인덱스를 계산하는 과정에서 오류가 날 가능성도 원천봉쇄된다.

 

 EnumMap의 성능이 ordinal을 쓴 배열에 비견되는 이유는 그 내부에서 배열을 사용하기 때문이다. -> 내부 구현방식을 안으로 숨겨 Map의 타입 안전성과 배열의 성능을 모두 얻어낸 것이다. ( 여기서 EnumMap의 생성자가 받는 키 타입의 Class 객체는 한정적 타입 토큰으로, 런타임 제네릭 타입 정보를 제공한다. )

 

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

 

 

정리

 

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

 

 

 

 

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

 

 열거 타입은 거의 모든 상황에서 소개한 타입 안전 열거 패턴보다 우수하다. 예외가 있으니, 타입 안전 열거 패턴은 확장 가능하나 열거 타입은 그럴 수 없다는 점이다. 하지만 확장할 수 있는 열거 타입이 어울리는 쓰임이 최소한 하나 있는데 바로 연산 코드다. 연산 코드의 각 원소는 특정 기계가 수행하는 연산을 뜻한다.

 

 다행히 열거 타입으로 이 효과를 내는 멋진 방법이 있다. 기본 아이디어는 열거 타입이 임의의 인터페이스를 구현할 수 있다는 사실을 이용하는 것이다. 연산 코드용 인터페이스를 정의하고 열거 타입이 이 인터페이스를 구현하게 하면 된다. 이때 열거 타입이 그 인터페이스의 표준 구현체 역할을 한다.

 이

 두번째 대안은 Class 객체 대신 한정적 와일드카드 타입인 Collection<? extends Operation>을 넘기는 방법이다. 인터페이스를 이용해 확장 가능한 열거 타입을 흉내내는 방식에도 한 가지 문제가 있는데 그것은 열거 타입끼리 상속할 수 없다는 점이다, 아무 상태에서도 의존하지 않은 경우에는 디폴트 구현을 이용해 인터페이스에 추가하는 방법이 있다.

 

 반면 Operation 예는 연산 기호를 저장하고 찾는 로직이 BasicOperatrion, ExtendedOperation 모두 들어가야 한다.이 경우 중복량이 적어 문제되지 않지만, 공유하는 기능이 많다면 그 부분을 별도의 도우미 클래스나 정적 도우미 메서드로 분리하는 방식으로 코드 중복을 없앨 수 있을 것이다.

 

 

정리

 

 열거 타입 자체는 확장할 수 없지만, 인터페이스와 그 인터페이스를 구현하는 기본 열거 타입을 함께 사용해 같은 효과를 낼 수 있다. 이렇게 하면 클라이언트는 이 인터페이스를 구현해 자신만의 열거 타입(혹은 다른 타입)을 만들 수 있다. 그리고 API가 (기본 열거 타입을 직접 명시하지 않고) 인터페이스 기반으로 작성되었다면 기본 열거 타입의 인스턴스가 쓰이는 모든 곳을 새로 확장한 열거 타입의 인스턴스로 대체해 사용할 수 있다.

 

 

 

 

step39: 명명 패턴보다 애너테이션을 사용하라

 

 전통적으로 도구나 프레임워크가 특별히 다뤄야 할 프로그램 요소에는 딱 구분되는 명명 패턴을 적용해왔다. 테스트 프레임워크인 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게끔 했다. 효과적인 방법이지만 단점도 크다. 

 

 첫 번째, 오타가 나면 안된다. 실수로 이름을 tsetSafetyOverride로 지으면 JUnit 3은 이 메서드를 무시하고 지나치기 때문에 개발자는 이 테스트가 통과햇다고 오해할 수 있다.

 두 번째 단점은 올바른 프로그램 요소에만 사용되리라 보증할 방법이 없다.

 세번째 단점은 프로그램 요소를 매개 변수로 전달할 마땅한 방법이 없다는 것이다. 특정 예외를 던져야만 성공하는 테스트가 있다고 해보자. 예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만 보기도 나쁘고 깨지기 쉽다.

 

 애너테이션은 위 모든 문제를 해결해주는 멋진 개념이다. JUnit도 버전 4부터 전면 도입했다.

 

마커 애너테이션 타입 선언

 위처럼 애너테이션 선언에 다는 애너테이션을 메타애너테이션이라 한다. @Retention 메타애너테이션은 @Test가 런타임에도 유지되어야 한다는 표시다. 만약 이 메타애너테이션을 생략하면 테스트 도구는 @Test를 인식하지 못한다. 한편 @Target 메타애너테이션은 @Test가 반드시 메서드 선언에서만 사용돼야 한다고 알려준다.

 

 @Test 애너테이션을 적용했을 때 이와 같은 애너테이션을 "아무 매개변수 없이 단순히 대상에 마킹한다" 는 뜻에서 마커 애너테이션이라 한다. 이 애너테이션을 사용하면 프로그래머가 Test 이름에 오타를 내거나 선언 외 프로그램 요소에 달면 컴파일 오류를 내준다.

 

 자바 8에서는 여러 개의 값을 받는 애너테이션을 다른 방식으로도 만들 수 있다. 배열 매개변수를 사용하는 대신 애너테이션에 @Repeatable 메타 애너테이션을 다는 방식이다. 이를 사용하면 하나의 프로그램 요소에 여러번 달 수 있다. 단 주의할 점이 있는데 첫 번째, @Repeatable을 단 애너테이션을 반환하는 '컨테이너 애너테이션'을 하나 더 정의하고, @Repeatable에 이 컨테이너 애너테이션의 class 객체를 매개변수로 전달해야 한다. 두 번째, 컨테이너 애너테이션은 내부 애너테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다. 마지막으로 컨테이너 애너테이션 타입에는 적절한 보존 정책(@Retention)과 적용 대상(@Target)을 명시해야 한다. 그렇지 않으면 컴파일 되지 않는다.

 

 애너테이션으로 할 수 있는 일을 명명 패턴으로 처리할 이유는 없다. 자바 프로그래머라면 예외 없이 자바가 제공하는 애너테이션 타입들은 사용해야 한다. 

 

 

 

 

step40: @Override 애너테이션을 일관되게 사용하라

 

 자바가 기본으로 제공하는 애너테이션 중 보통의 프로그래머에게 가장 중요한 것은 @Override일 것이다. 메서드 선언에만 달 수 있으며, 이 애너테이션은 상위 타입의 메서드를 재정의했음을 뜻한다. 이 애너테이션을 일관되게 사용하면 여러가지 악명 높은 버그들 예방해준다. 

@Override 애너테이션을 달고 컴파일하면, 잘못한 부분을 명확히 알려주므로 상위 클래스의 메서드를 재정의하려는 모든 메서드에 @Override 애너테이션을 달자.

 

 예외는 한 가지 뿐이다. 구체 클래스애서 상위 클래스의 추상메서드를 재정의 할때는 굳이 달지 않아도 된다. 구체 클래스인데 아직 구현하지 않은 추상 메서드가 남아 있다면 컴파일러가 그 사실을 바로 알려주기 때문이다. 물론 재정의 메서드 모두에 @Override를 일괄로 붙여두는게 좋아보인다면 그래도 상관없다. 대부분의 IDE는 재정의할 메서드를 선택하면 @Override를 자동으로 붙여주니 참고하자. 추상 클래스나 인터페이스에서는 상위 클래스나 상위 인터페이스의 메서드를 재정의하는 모든 메서드에 @Override를 다는 것이 좋다. 상위 클래스가 구체 클래스든 추상 클래스든 마찬가지다. 예컨대 Set 인터페이스는 Collection 인터페이스를 확장했지만 새로 추사한 메서드는 없다. 따라서 모든 메서드 선언에 @Override를 달아 실수로 추가한 메서드가 없음을 보장했다.

 

 

정리

 

 재정의한 모든 메서드에 @Override 애너테이션을 의식적으로 달면 여러분이 실수했을 때 컴팡일러가 바로 알려줄 것이다. 예외는 한가지고, 구체 클래스에서 상위 클래스의 추상 메서드를 재정의한 경우엔 이 애너테이션을 다지 않아도 된다.(단다고 해서 해로울 것 도 없다.)

 

 

 

 

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

 

 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해주는 인터페이스를 마커 인터페이스라 한다. Serializable 인터페이스가 가장 좋은 예다. Serializable은 자신을 구현한 클래스의 인스턴스는 ObjectOutputStream을 통해 쓸 수 있다고, 즉 직렬화할 수 있다고 알려준다.

 

 마커 애너테이션이 등장하면서 마커 인터페이스는 구식이 되었다는 이야기를 들어보았을 것이다. 하지만 사실은 아니다. 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나, 마커 애너테이션은 그렇지 않다. 마커 인터페이스는 어엿한 타입이기 때문에, 마커 애너테이션을 사용했다면 런타임에야만 발견될 오류를 컴파일타입에 잡을 수 있다.

 

 마커 인터페이스가 나은 점 두 번째는 적용 대상을 더 정밀하게 지정할 수 있다는 것이다. 적용 대상을 (@Target)을 ElementType.TYPE으로 선언한 애너테이션은 모든 타입(클래스, 인터페이스, 열거타입, 애너테이션)에 달 수 있다. 부착할 수 있는 타입을 더 세밀하게 제한하지는 못한다는 뜻이다. 그런데 특정 인터페이스를 구현한 클래스에만 적용하고 싶은 마커가 있다고 해보자. 

 

 이 마커를 인터페이스로 정의했다면 그냥 마킹하고 싶은 클래스에서만 그 인터페이스를 구현하면 된다. 그러면 마킹된 타입은 자동으로 그 인터페이스의 하위 타입임이 보장되는 것이다.

 

 반대로 마커 애너테이션이 마커 인터페이스보다 나은 점으로는 거대한 에너테이션 시스템의 지원을 받는다는 점을 들 수 있다. 따라서 에너테이션을 적극 활용하는 프레임워크에서는 마커 애너테이션을 쓰는 쪽이 일관성을 지키는 데 유리할 것이다.

 

 그렇다면 어떤 때 마커 애너테이션을, 어떤 때 마커인터페이스를 써야 하는가? 확실한 것은, 클래스와 인터페이스 외의 프로그램 요소(모듈, 패키지, 필드, 지역변수)에 마킹해야할 때 애너테이션을 쓸 수 밖에 없다. 이유는 클래스와 인터페이스만이 인터페이스를 구현하거나 확장할 수 있기 때문이다.

 

 마커를 클래스나 인터페이스에 적용해야 한다면, 이 마킹이 된 객체를 매개변수로 받는 메서드를 작성할 일이 있을까 라고 자문해보자. 답이 그렇다면 마커 인터페이스를 사용해야 한다. 이렇게 함으로 마커 인터페이스를 해당 매섣의 메서드의 매개변수 타입으로 사용하여 컴파일 타임에 오류를 잡아낼 수 있다. 만약 절대 작성할 일이 없다고 확신한다면 마커 애너테이션이 나은 선택일 것이다.

 

 

 

정리

 

 마커 인터페이스와 마커 애너테이션은 각자의 쓰임이 있다. 새로 추가하는 메서드 없이 단지 타입 정의가 목적이라면 마커 인터페이스를 선택하자. 클래스나 인터페이스 외의 프로그램 요소에 마킹해야 하거나, 애너테이션을 적극 활용하는 프레임워크의 일부로 그 마커를 편입시키고자 한다면 마커 애너테이션이 옳은 선택이다. 적용 대상이 ElementType.TYPE인 마커 애너테이션을 작성하고 있다면, 잠시 여유를 갖고 애너테이션으로 구현하는게 옳은지, 혹은 마커 인터페이스가 낫지는 않을지 곰곰히 생각해보자.

 

'독서 > 이펙티브 자바 3판' 카테고리의 다른 글

CH6 람다와 스트림  (0) 2023.05.15
CH04 제네릭  (0) 2023.05.02
Ch02 모든 객체의 공통 메서드  (0) 2023.04.25
Ch03 클래스와 인터페이스  (0) 2023.04.25
Ch01 객체 생성과 파괴  (0) 2023.04.05
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.