ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Java] Effective Java 3/E 정리 - 6장 열거 타입과 어노테이션
    Study/이펙티브 자바 2021. 7. 7. 16:21
    반응형

     

    자바에는 특수한 목적의 참조 타입이 두가지 있다.

    클래스의 일종인 열거 타입(enum)과 인터페이스의 일종인 어노테이션(annotation)이다.

    이번 장에서는 이 타입들을 올바르게 사용하는 방법을 알아보자.


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

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

    자바에서 열거 타입을 지원하기 전에는 접두어를 붙이고 정수 상수 나열하여 선언하는 정수 열거 패턴을 사용했다.

    public static final int APPLE_PIPPIN = 1;
    public static final int APPLE_GRANNY_SMITH = 2;
    
    public static final int ORANGE_NAVEL = 0;
    public static final int ORANGE_TEMPLE = 1;
    public static final int ORANGE_BLOOD = 2;

    그러나 정수 열거 패턴은 평범한 상수를 나열한 것이라서 여러 단점이 있다.

    단순한 정수이기 때문에 다른 타입을 사용하더라도 컴파일 에러를 발생하지 않는다.

    그리고 값이 바뀌면 다시 컴파일 해야하며, 단순한 숫자로 보여 문자열로 출력하기도 까다롭다.

    정수 대신 문자열 열거 패턴을 사용할 수도 있지만 문자열 상수의 이름 대신 문자열 값을 그대로 하드코딩하기 때문에 좋다고 할 수는 없다.

     

    위에서 정수 열거 패턴을 열거 타입으로 표현하면 다음과 같다.

    public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
    public enum Orange { NAVEL, TEMPLE, BLOOD }

    자바의 열거 타입은 완전한 형태의 클래스라서 단순한 정숫값일 뿐인 다른 언어의 열거 타입보다 훨씬 강력하다.

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

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

    열거 타입은 정수 열거 패턴의 단점을 해소해준다.

    • 컴파일타임 타입 안정성을 제공한다.
      열거타입을 매개변수로 받을 때, 건네받는 참조가 선언한 타입이 아닌 다른 타입일 경우 컴파일 오류가 발생한다.
    • 열거 타입은 각자의 이름 공간이 존재한다.
      정수 열거 패턴에서는 접두어를 사용해야했으나, 열거 타입에서는 다른 열거타입에서 사용한 이름을 자유롭게 사용할 수 있다.
    • toString 메서드는 출력하기에 적합한 문자열을 내어준다.

    그리고 열거 타입에는 임의의 메서드나 필드를 추가할 수 있다.

    필드를 통해 각 상수와 연관된 데이터를 해당 상수 자체에 내재시킬 수 있다.

    특정 데이터와 연관지으려먼 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.

    열거 타입을 선언한 클래스 혹은 패키지에서만 사용한다면 private, pavakage-private 메서드로 구현한다.

    public enum Planet {
    	//각 상수의 괄호 안의 숫자는 생성자에 넘겨지는 매개 변수다. 
    	MERCURY	  (3.302e+23, 2.439e6),
    	VENUS	  (4.869e+24, 6.052e6),
    	EARTH	  (5.975e+24, 6.378e6),
    	MARS	  (6.419e+23, 3.393e6),
    	JUPITER   (1.899e+27, 7.149e7),
    	SATURN    (5.685e+26, 6.027e7),
    	URANUS    (8.683e+25, 2.556e7),
    	NEPTUNE   (1.024e+26, 2.477e7);
      
    	//각 필드는 public으로 선언해도 되지만, private로 두고 public 접근메서드를 통해 접근 권한을 줄인다.
      	private final double mass;				// 질량(단위: 킬로그램)
      	private final double radius;			// 반지름(단위: 미터)
      	private final double surfaceGravity;	// 표면중력(단위: m / s^2)
      
      	// 중력상수(단위: m^3 / kg s^2)
      	private static final double G = 6.67300E-11;
      
      	// 생성자
      	Planet(double mass, double radius) {
        	this.mass = mass;
        	this.radius = radius;
        	surfaceGravity = G * mass / (radius * radius);
      	}
      
      	public double mass()			{ return mass; }
     	public double radius()			{ return radius; }
      	public double surfaceGravity()	{ return surfaceGravity; }
      
      	public double surfaceWeight(double mass) {
        	return mass * surfaceGravity;	// F = ma
      	}
    }

    열거 타입은 자신 안의 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드인 values를 제공한다. 값은 선언된 순서로 저장된다.

    toString 메서드는 상수 이름을 문자열로 반환하지만 재정의할 수도 있다.

    상수마다 동작이 달라져야하는 상황이 있을 수 있다.

    이런 상황에는 swtich문과 상수별 메서드 구현 두가지 방법을 사용할 수 있다.

    switch문

    상수 값에 따라 switch문을 이용해 분기한다.

    동작은 하지만 상수가 추가될 떄 마다 case를 추가해주어야 하는 단점이 있다.

    public enum Operation {
      PLUS, MINUS, TIMES, DIVIDE;
      
      // 상수가 뜻하는 연산을 수행한다.
      public double apply(double x, double y) {
        switch(this) {
          case PLUS:		return x + y;
          case MINUS: 	return x - y;
          case TIMES:		return x * y;
          case DIVIDE:	return x / y;
        }
        throw new AssertionError("알 수 없는 연산: " + this);
      }
    }

    상수별 메서드 구현

    열거 타입에 apply라는 추상 메서드를 선언하고, 각 상수에서 자신에 맞게 재정의 할 수 있다.

    public enum Operation {
    	PLUS		{public double apply(double x, double y){return x + y;}},
    	MINUS		{public double apply(double x, double y){return x - y;}},
    	TIMES		{public double apply(double x, double y){return x * y;}},
    	DIVIDE		{public double apply(double x, double y){return x / y;}};
    	  
    	public abstract double apply(double x, double y);
    }

    apply가 추상 메서드이기 때문에 재정의를 하지 않을 경우 컴파일 에러가 발생하여 알 수 있다.

    상수별 메서드 구현은 상수별 데이터와 결합하여 사용할 수 있고, toString을 재정의하여 출력을 더욱 편리하게 할 수 있다.

    * toString을 재정의한다면 반대로 출력 문자열을 열거 타입 상수로 변환하는 fromString도 제공하는 것이 좋다. 열거 타입의 상수 이름을 받아 해당 상수를 반환하는 valueOf(String)메서드를 사용하여 구현하면 된다.

     

    단, 상수별 메서드 구현은 열거 타입 상수끼리 코드를 공유하기는 어렵다.

    switch문으로 구현하면 가능하지만 상수마다 case문을 추가해주는 동일한 문제가 발생한다.

    제일 좋은 방법은 중첩 열거 타입을 두어 생성자에서 선택하고, 중첩 열거타입에 따라 분기하는 '전략' 방식을 사용하는 것이다.

    enum PayrollDay {
      MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY), THURDAY(WEEKDAY), 
      FRIDAY(WEEKDAY), SATURDAY(WEEKEND), SUNDAY(WEEKEND);
      
      private final PayType payType; 
      
      PayrollDay(PayType payType) { this.payType = payType; } 
      
      int pay(int minutesWorked, int payRate) {
        return payType.pay(minutesWorked, payRate);
      }
      
      // 전략 열거 타입
      enum PayType {
        WEEKDAY {
          int overtimePay(int minsWorked, int payRate) {
            return minsWorked <= MINS_PER_SHIFT ? 0 : (minsWorked - MINS_PER_SHIFT) * payRate / 2;
          }
        },
        WEEKEND {
          int overtimePay(int minsWorked, int payRate) {
            return minsWorked * payRate / 2;
          }
        };
        
        abstract int overtimePay(int mins, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;
        
        int pay(int minsWorked, int payRate) {
          int basePay = minsWorked * payRate;
          return basePay + overtimePay(minsWorked, payRate);
        }
      }
    }

     

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

    대부분의 열거 타입 상수는 자연스럽게 하나의 정숫값에 대응된다.

    그리고 모든 열거 타입은 해당 상수가 그 열거 타입에서 몇 번째 위치인지를 반환하는 ordinal이라는 메서드를 제공한다.

    그러나 ordinal을 사용하면 상수 선언 순서를 바꾸는 순간 오동작할 가능성이 있다.

    값을 중간에 비워둘 수도 없기 때문에 중간에 사용하지 않는 더미 상수를 추가해야한다.

    따라서 열거 타입에 연결된 값은 ordinal을 사용하지 말고, 인스턴스 필드에 저장하여 사용한다.

    +) ordinal은 EnumSet, EnumMap과 같이 열거 타입 기반의 범용 자료구조에 쓸 목적으로 설계되었다.

     

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

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

    비트별 OR를 사용해 여러 상수를 하나의 집합으로 모은 것을 비트 필드라고 한다.

    public class Text {
      public static final int STYLE_BOLD		= 1 << 0; // 1
      public static final int STYLE_ITALIC		= 1 << 1; // 2
      public static final int STYLE_UNDERLINE	= 1 << 2; // 4
      public static final int STYLE_STRIKETHROUGH	= 1 << 3; // 8
      
      // 매개변수 styles는 0개 이상의 STYLE_ 상수를 비트별 OR한 값이다.
      public void applyStyles(int styles) { ... } 
    }

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

    그러나 비트 필드는 정수 열거 상수의 단점을 그대로 갖고서 다른 단점도 지니고 있다.

    • 비트 필드 값이 그대로 출력되면 단순한 정수 열거 상수를 출력할 때보다 해석이 어렵다.
    • 비트 필드 하나에 녹아 있는 모든 원소를 순회하기가 까다롭다.
    • 최대 몇 비트가 필요한지를 미리 예측하여 적절한 타입(보통 int나 long)을 선택해야 한다.

    그래서 비트 필드 대신 EnumSet 클래스를 사용해 열거 타입 집합을 표현할 수 있다.

    EnumSet의 내부는 비트 벡터로 구현되어 대부분의 경우 EnumSet 전체를 long 변수 하나로 표현하여 비트 필드에 비견되는 성능을 보여주고, 대량 작업도 비트를 효율적으로 처리할 수 있는 산술 연산을 써서 구현했다.

    public class Text {
      public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
      
      // 어떤 Set을 넘겨도 되나, EnumSet이 가장 좋다.
      public void applyStyles(Set<Style> styles) { ... }
    }

    EnumSet은 집합 생성 등 다양한 기능의 정적 팩터리를 제공하는데, 그 중 of 메서드를 다음과 같이 쓸 수 있다.

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

    applyStyles 메서드에서 매개변수로 EnumSet<Style>이 아닌 Set<Style>을 받는다면, 다른 Set 구현체를 넘기더라도 처리할 수 있다.

     

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

    ordinal은 앞서 말했듯 enum의 선언 순서에 대한 인덱스를 가져올 수 있다.

    그러나 ordinal 메서드 값을 배열의 인덱스로 활용한다면 여러 문제가 발생한다.

    • 배열은 제네릭과 호환되지 않아 비검사 형변환을 수행해야 한다.
    • 배열은 각 인덱스의 이ㅢ미를 모르니 출력 결과에 직접 레이블을 달아야 한다.
    • 정확한 정숫값을 사용한다는 타입 안정성이 없다. 잘못된 동작을 수행하거나 ArrayIndexOutOfBoundsException이 발생한다.

    따라서 ordinal 인덱스 대신에 EnumMap을 활용할 수 있다.

    EnumMap을 사용한다면 열거 타입을 키로 사용하고, 그 자체로 출력용 물자열을 제공한다.

    또한 배열 인덱스를 계산하는 과정에서 오류가 날 가능성이 없다.

     

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

    열거 타입은 확장할 수 없다.

    대신 인테이스를 정의하고 열거 타입이 그 인터페이스를 구현하여 비슷한 효과를 낼 수 있다.

    열거 타입은 확장할 수 없지만, 인터페이스는 확장할 수 있어 인터페이스를 연산의 타입으로 이용한다.

    그러면 인터페이스를 구현한 또 다른 열거타입을 정의해 기존에 정의한 열거타입을 대체할 수 있다.

     

    한가지 문제점은 열거 타입끼리 상속은 불가능하다.

    그러나 아무 상태에도 의존하지 않는다면 디폴트 구현을 통해 인터페이스에 추가하거나,

    중복되는 코드가 있다면 별도의 도우미 클래스나 정적 도우미 메서드로 분리하여 사용한다.

     

    Item39. 명명 패턴보다 어노테이션을 사용하라

    도구나 프레임워크가 특별히 다뤄야할 요소에는 구분되는 명명 패턴을 적용해왔다. 예를 들면 테스트 프레임워크인 JUnit은 버전 3까지 테스트 메서드 이름을 test로 시작하게끔 했다.

    효과적이지만 그만큼 치명적인 단점을 갖고 있다.

    • 오타가 날 경우 개발자가 오류를 놓칠 수 있다. JUnit에서 test를 tset과 같이 오타를 낼 경우 무시하고 지나가기 때문에 개발자는 테스트가 통과했다고 오해할 수 있다.
    • 올바른 프로그램 요소에만 사용되리라 보증할 방법이 없다. 메서드가 아닌 클래스 이름을 Test~ 로 지어 JUnit에 던져도 경고 메시지조차 없다.
    • 프로그램 요소를 매개변수로 전달할 방법이 없다. 특정 예외를 던져야 성공하는 테스트가 있을 때 이름에 덧붙이는 방법이 있다. 그러나 컴파일러는 이름에 덧붙인 문자열이 예외를 가르키는지 알 수 없기때문에, 실행하기 전에는 그런 이름의 클래스가 존재하는지 혹은 예외가 맞는지조차 알 수 없다.

    따라서 명명 패턴 대신에 어노테이션을 이용해 이런 문제들을 해결할 수 있다.

    Junit 4에서 도입된 @Test으로 예를 들어보자.

    @Test 은 매개 변수가 없는 정적 메서드 전용 테스트 메서드를 선언하는 어노테이션으로, 예외가 발생시에는 실패로 처리하는 메서드다.

    import java.lang.annotation.*;
    
    /**
     * 테스트 메서드임을 선언하는 어노테이션이다.
     * 매개변수 없는 정적 메서드 전용이다.
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface Test { }

    @Test 어노테이션 타입 선언 자체에도 @Retention, @Target와 같이 메타어노테이션이 달린 것을 볼 수 있다.

    메타어노테이션은 런타임시 유지, 메서드에만 사용 등과 같이 어노테이션의 여러 조건을 명시하는 데 사용할 수 있다.

     

    @Test 어노테이션와 같은 어노테이션을 아무 매개변수없이 대상에 마킹한다는 뜻으로 마커 어노테이션이라고 한다.

    이 어노테이션을 사용하면 프로그래머가 Test 이름에 오타를 내거나 메서드 선언 외의 프로그램 요소에 달면 컴파일 오류를 발생시킨다.

     

    @Test 어노테이션이 클래스의 의미에 직접적인 영향을 주지는 않지만, RunTests와 같은 도구에서 특별히 처리될 수 있다.

    import java.lang.reflect.*;
    
    public class RunTests {
      public static void main(String[] args) throw Exception {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]); 
        for (Method m : testClass.getDeclaredMethods()) { 
          if (m.isAnnotationPresent(Test.class)) { 
            tests++;
            try {
              m.invoke(null);
              passed++;
            } catch (InvocationTargetException wrappedExc) { 	
              Throwable exc = wrappedExc.getCause();					
              System.out.println(m + " 실패: " + exc);				
            } catch (Exception exc) {
              System.out.println("잘못 사용한 @Test: " + m);
            }
          }
        }
        System.out.printf("성공: %d, 실패: %d%n", passed, tests - passed);
      }
    }

    이 테스트 러너는 @Test 어노테이션이 달린 메서드를 차례로 호출한다.

    그리고 예외가 발생하면 리플렉션 메커니즘이 InvocationTargetException으로 감싸서 다시 던진다.

     

    다음 @ExceptionTest는 특정 예외를 던져야 성공하는 테스트를 지원하는 어노테이션이다.

    import java.lang.annotation.*;
    
    /**
     * 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
     */
    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ExceptionTest {
      Class<? extends Throwable> value();
    }

    이 어노테이션의 매개변수 타입인 Class<? extends Throwable>은 Throwable을 확장한 Class객체리는 뜻으로 모든 예외 타입을 다 수용한다.

     

    이 어노테이션을 다룰 수 있도록 아래와 같이 테스트 도구를 수정한다.

    if (m.isAnnotationPresent(ExceptionTest.class)) { 
      tests++;
      try {
        m.invoke(null);
        System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
      } catch (InvocationTargetException wrappedExc) { 	
        Throwable exc = wrappedExc.getCause();
        Class<? extends Throwable> excType = m.getAnnotation(ExceptionTest.class).value();
        if(excType.isInstance(exc)) {
          passed++;
        } else {
          System.out.printf("테스트 %s 실패: 기대한 예외 %s, 발생한 예외 %s%n", m, excType.getName(), exc);
        }
      } catch (Exception exc) {
        System.out.println("잘못 사용한 @ExceptionTest: " + m);
      }
    }

    앞선 코드와 비슷해보이지만, getAnnotation을 통해 매개변수 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인한다.

     

    한걸음 더 나아가 예외를 여러개 명시하고 그 중 하나가 성공하게 만들 수 있다.

    @ExceptionTest의 매개변수 타입을 Class객체의 배열로 수정하자.

    @Retention(RetentionPolicy.RUNTIME)
    @Target(ElementType.METHOD)
    public @interface ExceptionTest {
      Class<? extends Throwable>[] value();
    }


    배열로 변경했으나 앞선 @ExceptionTest들도 수정없이 사용할 수 있다.

    @ExceptionTest 어노테이션에 배열을 매개변수로 전달하기 위해서는 원소들을 중괄호로 감싸고 쉼표로 구분하면 된다.

    @ExceptionTest({ IndexOutOfBoundsException.class, NullPointerException.class })
    public static void doublyBad() {
      List<String> list = new ArrayList<>();
      
      // 자바 API 명세에 따르면 다음 메서드는 IndexOutOfBoundsException이나 NullPointerException을 던질 수 있다.
      list.addAll(5, null);
    }

     

    이 어노테이션을 지원하기 위해 또 테스트 러너를 수정해보자.

    if (m.isAnnotationPresent(ExceptionTest.class)) { 
      tests++;
      try {
        m.invoke(null);
        System.out.printf("테스트 %s 실패: 예외를 던지지 않음%n", m);
      } catch (Throwable wrappedExc) { 	
        Throwable exc = wrappedExc.getCause();
        int oldPassed = passed;
        Class<? extends Throwable>[] excTypes = m.getAnnotation(ExceptionTest.class).value();
        for (Class<? extends Throwable> excType : excTypes) {
        	if(excType.isInstance(exc)) {
          	passed++;
            break;
        	}  
        }
        if (passed == oldPassed)
          System.out.printf("테스트 %s 실패: %s %n", m, exc);
    }

    매개변수 타입을 배열로 받아 반복문을 통해 확인하는 것을 볼 수 있다.

     

     

    자바 8에서는 배열 매개변수 대신에 @Repeatable 메타 어노테이션을 이용해 여러 개의 값을 받는 어노테이션을 만들 수 있다.

    @Repeatable를 단 어노테이션은 하나의 요소에 여러번 달 수 있지만 주의할 점이 있다.

    • @Repeatable을 단 어노테이션을 반환하는 컨테이너 어노테이션을 정의하고, @Repeatable에 이 컨테이너 어노테이션의 class 객체를 매개변수로 전달해야한다.
    • 컨테이너 어노테이션은 내부 어노테이션 타입의 배열을 반환하는 value 메서드를 정의해야 한다.
    • 컨테이너 어노테이션 타입에는 적절한 보존 정책(@Retention)과 적용대상(@Target)을 명시해야 한다.

     

    여러 장점을 통해 어노테이션으로 할 수 있는 일을 명명패턴으로 처리할 이유는 없다는 것을 알 수 잇다.

    자바 프로그래머라면 예외 없이 자바가 제공하는 어노테이션 타입들을 사용해보자.

     

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

    자바가 기본으로 제공하는 어노테이션 중 하나로, 상위 타입의 메서드를 재정의했음을 뜻한다.

    이 어노테이션을 사용하면 매개 변수를 잘못 적어 재정의가 아니라 다중정의를 하는 것과 같은 버그를 막을 수 있다.

     

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

    마커 인터페이스란, 아무 메서드를 담고 있지 않고 자신을 구현하는 클래스가 특정 속성을 가지고 있음을 표시해주는 인터페이스다. 예를 들면 Serializable 인터페이스로 자신을 구현한 클래스는 직렬화할 수 있다고 알려준다.

    마커 어노테이션이 등장하면서 구식이라는 이야기가 있지만 두가지 장점이 있다.

    • 마커 인터페이스를 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있다. 마커 인터페이스를 구현하는 이유가 컴파일타임 오류 검출인데, 마커 어노테이션을 사용하면 런타임에야 발견 할 수 있다.
    • 적용 대상을 더 정밀하게 지정할 수 있다. 어노테이션은 모든 타입(클래스, 인터페이스, 열거 타입, 어노테이션에 달 수 있다. 그러나 마커 인터페이스는 마킹하고 싶은 클래스에서만 구현하고, 마킹된 타입은 자동으로 그 인터페이스의 하위 타입임을 보장된다.

    따라서 메서드 없이 단지 타입 정의가 목적이라면 마커 인터페이스를 선택하는 것이 좋다.

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

    어노테이션을 적극 활용하는 프레임워크에서는 마커 애니테이션쪽이 일관성 있을 것이다.

    댓글