이펙티브 자바 - 6장 열거타입과 애너테이션

JeongJun Min·2024년 11월 5일

JAVA

목록 보기
5/7
post-thumbnail

열거 타입과 애너테이션

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

  1. 클래스의 일종인 열거 타입(enum)
  2. 인터페이스의 일종인 애너테이션(annotation)

열거 타입(enum)
서로 연관된 상수들의 집합
코드의 가독성을 좋게하며, 인스턴스 생성과 상속을 방지하여 상수값의 타입 안정성이 보장된다.

애너테이션(annotation)
주석처럼 프로그램에 영향을 미치지 않으면서, 유용한 정보를 제공하는 것
간단히 말하면 코드 사이에 주석처럼 쓰이며 특별한 의미, 기능을 제공하는 것이다.

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

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

자바에서 열거 타입(Enum type)을 지원하기 전에는 정수 열거 패턴(int enum pattern)과

문자열 열거 패턴(string enum pattern)이 존재했다.

ex) 정수 열거 패턴

public static final int APPLE_FUJI            = 0;
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;

하지만 위 기법에는 아래와 같은 단점들이 존재한다.

  • 타입 안전을 보장할 방법이 없다.

  • 표현력도 좋지 않다.

  • 오렌지를 건네야 할 메서드에 사과를 보내고 동등 연산자(==) 로 비교하더라도 컴파일러는 아무런 경고 메세지를 출력하지 않는다.

     `APPLE_FUJI == ORANGE_NAVEL;    ---------->    true` 
  • 평범한 상수를 나열한 것이기 때문에 컴파일시 그 값이 클라이언트 파일에 그대로 들어간다.

    • 상수의 값이 변하면 클라이언트도 반드시 다시 컴파일 해줘야 한다.
  • 정수 상수는 문자열로 출력하기가 어렵다. 그 값을 출력하거나 디버거로 살펴볼 때도 의미가 아닌 숫자로만 보이며, 같은 정수 열거 그룹에 속한 상수가 몇개인지도 알 수 없다.

문자열 상수를 사용하는 패턴또한 아래와 같은 이유로 단점이 존재한다.

  • 상수의 의미를 출력할 수 있지만, 경험이 부족한 프로그래머의 경우 문자열 상수의 이름 대신 문자열 값을 그대로 하드코딩하게 만든다.
    • 하드코딩한 문자열에 오타가 있을 경우 컴파일러는 런타임 버그를 발생시킨다.

ex) 열거 타입

public enum Week {MONDAY, TUESDAY, WEDNESDAY, THURSDAY, ...};

열거 타입 자체는 완벽한 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다. (열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final 이다.)

즉, 열거 타입은 인스턴스가 통제되고, 싱글턴은 원소가 하나뿐인 열거 타입이라 할 수 있다.

열거 타입은 컴파일타임에서의 타입 안정성을 제공한다.

  • Week에서 null이 아니라면 일곱 값 중에 하나임이 확실하며 다른 값을 넘기려 하는 경우 컴파일 오류가 발생한다.

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

  • 필드 이름으로 공개 하므로 순서가 바뀌거나 새로운 상수를 추가해도 컴파일 하지 않아도 된다.
Week today = Week.TUESDAY;

열거 타입은 toString 메서드는 출력하기에 적합한 문자열을 내어준다.

  • 헤당 상수의 이름이 문자열 형태로 반환된다.

열거 타입은 클래스이므로 추상 개념 하나를 완벽히 표현해낼 수도 있다.

ex) 데이터와 메서드를 갖는 열거 타입

public enum Planet {
    MERCURY(3.302e+23, 2.439e6),
    VENUS(4.869e+24, 6.052e6),
    EARTH(5.975e+24, 6.378e6);

    private final double mass;          // 질량
    private final double raduis;        // 반지름
    private final double surfaceGravity; // 표면중력

    private static final double G = 6.67300E-11;

    Planet(double mass, double raduis) {
        this.mass = mass;
        this.raduis = raduis;
        this.surfaceGravity = G * mass / (raduis * raduis);
    }

    public double mass() {
        return mass;
    }

    public double radius() {
        return raduis;
    }

    public double surfaceGravity() {
        return surfaceGravity;
    }

    public double surfaceWeight(double mass) {
        return mass * surfaceGravity; // F = ma
    }
}

열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에 데이터를 받아 인스턴스 필드에 저장하면된다. 열거 타입은 근본적으로 불변이라 모든 필드는 final 이어야한다. 필드를 public으로 선언해도 되지만, private으로 두고 별도의 public 접근자 메서드를 두는 게 낫다.

public class WeightTable {
    public static void main(String[] args) {
        double earthWeight = Double.parseDouble(args[0]);
        double mass = earthWeight / Planet.EARTH.surfaceGravity();
        for (Planet p : **Planet.values()**) {
            System.out.printf("%s에서의 무게는 %f이다.%n", p, p.surfaceWeight(mass) );
        }
    }
}

열거 타입은 values()를 사용하여 자신 안에 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드를 제공한다.

MERCURY에서의 무게는 69.912739이다.
VENUS에서의 무게는 167.434436이다.
EARTH에서의 무게는 185.000000이다.
...

열거 타입을 올바르게 사용하기 위해서 다음과 같은 경우를 참고한다

  • 클라이언트에게 노출해야할 합당한 이유가 없다면 private로, 혹은 package-private로 선언하라
  • 널리 쓰이는 열거타입은 톱레벨 클래스로 구현
  • 특정 톱레벨 클래스에서만 사용하는 경우 해당 클래스의 멤버 클래스로 구현

톱레벨 클래스
소스파일에서 가장 바깥에 존재하는 클래스
A top level class is a class that is not a nested class.
중첩 클래스가 아닌 클래스

열거 타입은 상수별로 다르게 동작하는 코드를 구현할 수 있다.

ex) 상수별 메서드 구현을 활용한 열거 타입

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 메서드가 상수 선언 바로 옆에 붙어 새로운 상수를 추가 할때 혹은 컴파일 오류가 발생하기 때문에 apply를 재정의해야 한다는 사실을 깜빡하기 어려울 것이다.

valueOf

열거 타입에는 상수 이름을 입력받아 그 이름에 해당하는 상수를 반환해주는 valueOf 메서드가 자동으로 생성된다.

Operation o = Operation.valueOf("TIMES");
System.out.println(o); // TIMES

fromString

toString 메서드를 재정의 하는 경우 toString이 반환하는 문자열을 해당 열거 타입 상수로 변환해주는 fromString 메서드도 함께 제공하는 것을 고려해보자

@Override public String toString() { return symbol; }

// 지정한 문자열에 해당하는 Operation을 (존재한다면) 반환한다.
public static Optional<Operation> fromString(String symbol) {
    return Optional.ofNullable(stringToEnum.get(symbol));
}

전략 열거 타입 패턴

열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있다.

전략 열거 타입 패턴을 사용하여 상수 일부가 같은 동작을 공유할 수 있다.

전략 열거 타입
열거형을 사용하여 다양한 전략을 구현하고, 동적으로 선택하여 사용할 수 있는 구조를 제공합니다

새로운 상수를 추가할 때 잔업수당 계산을 PayType 전략 열거 타입에 위임하여 switch문이나 상수별 메서드 구현이 필요없다.

열거 타입은 정수상수와 성능이 비슷하다. 열거 타입을 메모리에 올리는 공간과 초기화하는 시간이 들긴 하지만 체감될 정도는 아니다.

열거타입은

  • 필요한 원소를 컴파일타임에 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자
  • 열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.

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

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

ex) ordinal을 잘못 사용한 예

public enum Esemble {
    SOLO, DUET, TRIO, QUARTET, QUINTET, SEXTET, SEPTET, OCTET, NONET, DECTET;

    public int numberOfMusicians() {
        return ordinal() + 1;
    }
}

위 처럼 코드를 작성 시 동일 상수 값을 추가할 방법이 없다.

상수 선언 순서가 바뀌면 numberOfMusicians가 오작동하게 된다.

8중주(octet)가 이미 존재하므로 똑같이 8명이 연주하는 복4중주(double quartet)을 추가할 수 없다.

다음과 같이 연결된 값은 ordinal 메서드로 얻지 말고 인스턴스 필드에 저장하자.

public enum Ensemble {
    SOLO(1), DUET(2), TRI0(3), QUARTET(4), QUINTET(5), 
    SEXTET(6), SEPTET(7), **0CTET(8), D0UBLE_QUARTET(8),** 
    N0NET(9), DECTET(10), TRIPLE_QUARTET(12);
    
    private final int numberOfMusicians;
    Ensemble(int size) { this.numberOfMusicians = size; }
    public int numberOfMusicians() { return numberOfMusicians; }
}

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

비트 필드
메모리를 절약하기 위해서 비트 단위로 데이터를 저장하는 필드

public class Permissions {
    public static final int READ       = 1 << 0;    // 0001
    public static final int WRITE      = 1 << 1;    // 0010
    public static final int EXECUTE    = 1 << 2;    // 0100
    public static final int DELETE     = 1 << 3;    // 1000

    public void addPermission(int permission) { ... }

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

하지만 위에서 말한 정수 열거 상수의 단점을 그대로 지니며, 추가적인 문제까지 안고 있다.

  • 비트 필드 값이 그대로 출력되면 해석하기 어려움
    • permissions.addPermission(READ | WRITE) 에서 OR 연산으로 3이 저장
  • 모든 원소를 순회하기 까다로움
  • API를 수정하지 않고는 비트 수를 늘릴 수 없기 때문에 미리 적절한 타입(int, long)을 선택해야 한다.
    • Enum 개수가 늘어날 때마다 그만큼 쉬프트 연산하는 비트 값도 커짐

EnumSet

Enum의 요소를 Set으로 만든 것, Set 인터페이스를 완벽히 구현하며, 타입 안전하고, 다른 어떤 Set 구현체와 함께 사용 가능

public class Permissions {
    public enum Permission {
        READ, WRITE, EXECUTE, DELETE
    }
    
    public void addPermission(Set<Permission> Permissions) { ... } 
}

EnumSet의 내부는 비트 벡터로 구현되어 있으며 원소의 개수가 64개 이하라면 long 변수 하나로 표현할 수 있어서 비트필드에 비견되는 성능을 보여준다.

비트 벡터
비트를 이용해 데이터를 압축적으로 저장하는 자료 구조, 각 비트를 배열처럼 사용하여 여러 값을 하나의 변수에 표현하는 방식

EnumSet은 열거 타입 전용으로 사용되며 비트 벡터 방식으로 메모리 효율이 좋고 속도가 빠르다.


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

public class User {
  enum Permission { READ, WRITE, EXECUTE, DELETE }

  final String name;
  final Permission permission;

  User(String name, Permission permission) {
    this.name = name;
    this.permission = permission;
  }

  @Override
  public String toString() {
    return name;
  }
}

유저들을 배열 하나로 관리하고, 이들의 권한별로 묶어보자.

ex) ordinal()을 배열 인덱스로 사용

 Set<User>[] userByPermission = 
		 (Set<User>[]) new Set[Permission.values().length];

  for (int i = 0 ; i < userByPermission.length ; i++) {
    userByPermission[i] = new HashSet<>();
  }
  
  for (User u : users) {
    userByPermission[**u.permission.ordinal()**].add(u);
  }

  for (int i = 0; i < userByPermission.length; i++) {
    System.out.printf("%s: %s%n", 
			    User.permission.values()[i], userByPermission[i]);
  }

배열은 제네릭과 호환되지 않으니 비검사 형변환을 수행해야 하고 깔끔히 컴파일되지 않을 것이다.

비검사 형변환
제네릭 타입은 런타임 시 타입 검사를 하지 않고, 컴파일 시에만 타입 검사가 이루어짐
배열은 런타임에서 구체적인 타입을 강제함

또한 정수는 열거 타입과 달리 타입 안전하지 않기 때문에 정확한 정수값을 사용해야 한다.

  • ArrayIndexOutOfBoundException 이 발생할 수 있다.

EnumMap
Enum을 키로 사용하도록 설계한 Map

Map<User.Permission, Set<User>> userByPermission = 
		new EnumMap<>(User.Permission.class);

for (User.Permission permission : User.Permission.values()) {
  userByPermission.put(permission, new HashSet<>());
}

for (User u : users) {
  userByPermission.get(u.permission).add(u);
}

System.out.println(users);
  

코드가 더 간결해지고 성능도 비슷하다. 타입이 안전하기 때문에 배열 인덱스 계산하는 과정에서 오류가 날 가능성도 없다. EnumMap은 내부에서 배열을 사용하고 있으며, 내부 구현방식을 안으로 숨겨 Map의 타입 안전성과 배열의 성능을 가지고 있다

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

System.out.println(Arrays.stream(Users)
    .collect(groupingBy(u -> u.permission)));

하지만 EnumMap을 써서 얻은 공간과 성능 이점이 사라진다는 문제가 있다.

System.out.println(Arrays.stream(users)
    .collect(groupingBy(u -> u.permission, 
       () -> new EnumMap<>(Permission.class), toSet())));

EnumMap만 사용했을 때는 항상 유저의 권한(permission) 당 중첩 맵을 한개씩 만들지만, 스트림을 사용하여 EnumMap을 사용하면 해당 권한에 속하는 유저가 있을 때만 만든다.

  • EnumMap만 사용할 때: 각 권한에 대해 중첩 맵을 항상 생성하므로, 사용하지 않는 권한에 대해서도 메모리를 차지합니다.
  • 스트림을 사용한 경우: 유저가 해당 권한을 가질 때만 EnumMap을 생성하므로, 메모리 사용을 최적화하고 필요 없는 맵 생성을 방지할 수 있습니다.

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

대부분 상황에서 열거 타입은 확장할 수 없지만 인터페이스를 정의하고 열거 타입이 이 인터페이스를 구현하게 하면 같은 효과를 낼 수 있다.

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; } 
    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; } 
    };
    
    private final String symbol;
    
    BasicOperation(String symbol) { 
      this.symbol = symbol;
    }
    
    @Override public String toString() { 
      return symbol;
    }
  }

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

추가된 지수 연산과 나머지 연산

public enum ExtendedOperation implements Operation {
    EXP("^") {
        public double apply(double x, double y) {
            return Math.pow(x, y);
        }
    },
    REMAINDER("%") {
        public double apply(double x, double y) {
            return x % y;
        }
    };
    ...
}

열거 타입 ExtendedOperation 을 만들어 추가 연산을 구현하였다. API가 Operation 인터페이스를 사용하도록 작성하면 기본 열거 타입의 인스턴스가 쓰이는 모든 곳을 새로 확장한 열거 타입의 인스턴스로 대체해 사용할 수 있다.

인터페이스를 이용해 확장 가능한 열거타입을 구현하는 방법에도 열거 타입끼리 구현을 상속할수 없다는 문제점이 있다. 아무상태에도 의존하지 않는 경우에는 디폴트 구현을 이용해 인터페이스에 추가하는 방법이 있다.


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

명명 패턴
특정 객체, 클래스, 변수, 메서드 등의 이름을 일관되게 짓기 위한 규칙 또는 원칙
ex) 클래스와 인터페이스 - 카멜케이스 UserDetail
변수와 메서드 - 소문자 카멜 케이스 userName, getUserName()
JUnit 3 - testMethod()

명명 패턴에는 3가지 단점이 있다

  1. 오타가 나면 안 된다.
    • tsetSafetyOverride() 로 실수로 지으면 JUnit 3은 이 메서드를 무시하고 지나가기 때문에 테스트가 실패하지 않아 통과했다고 오해할 수 있다.
  2. 올바른 프로그램 요소에서만 사용되리라 보증할 방법이 없다.
    • JUnit 3에서는 메서드가 아닌 클래스 이름을 TestSafetyMechanisms 로 지어서 던졌지만 내부 메서드가 test로 시작하지 않는다면, JUnit은 경고 메세지조차 출력하지 않고 의도한 테스트는 전혀 수행되지 않는다.
  3. 프로그램 요소를 매개변수로 전달할 방법이 없다.
    • 특정 예외를 던져야만 성공하는 테스트가 있을 때, 기대하는 예외 타입을 테스트에 매개변수로 전달해야 하는 상황에 예외 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 컴파일러는 메서드 이름에 붙인 문자열이 예외를 가르키는 이름인지 알 수 없다.

애너테이션

@Retention(RetentionPolicy.RUNTIME)     // @Test가 런타임에도 유지되어야 한다는 표시
@Target(ElementType.METHOD)          // @Test가 반드시 메서드 선언에서만 사용돼야 한다는 표시
public @interface Test {
}

마커 애너테이션
아무 매개변수 없이 단순히 대상에 마킹하는 애너테이션

public class Sample {
    @Test public static void m1(){}
    public static void m2(){}
    @Test public static void m3(){
        throw new RuntimeException("fail");
    }
}
---------------------------------------------------
public class RunTests {
  public static void main(String[] args) throws Exception{
  ...
  
	if (m.isAnnotationPresent(Test.class)) {	//Test 애너테이션이 선언된 메서드만을 호출한다.
	...
	}
}

특정 예외를 던져야만 성공하는 테스트

매개변수를 받는 애너테이션

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

이 애너테이션의 매개변수 타입은 Throwable을 확장한 클래스 객체이므로 모든 예외와 오류 타입을 수용한다. @Test와 달리 매개변수 값을 추출하여 테스트 메서드가 올바른 예외를 던지는지 확인하는데 사용한다.

Throwable
자바의 모든 오류와 예외의 최상위 클래스

예외 클래스 파일이 컴파일 타임에는 존재했으나 런타임에는 존재하지 않을 수 있으며, 이런 경우 TypeNotPresentException 예외가 발생한다.

배열 매개변수를 받는 애너테이션

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

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


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

@Override는 메서드 선언에만 달 수 있으며, 상위 타입의 매서드를 재정의했음을 뜻한다.

@Override를 붙이게되면 컴파일러는 해당 메서드가 상위 메서드와 다르다는 것을 찾아 오류가 발생하며, 올바르게 수정할 수 있다.

즉, 상위 클래스의 메서드를 재정의하는 모든 메서드에 @Override 어노테이션을 다는것을 권장한다. 단, 상위 클래스의 추성 메서드는 굳이 @Override를 달지 않아도 된다.


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

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

ex) Serializable - Java에서 객체를 직렬화할 수 있게 해주는 마커 인터페이스

public interface Serializable {
}

마커 인터페이스 vs 마커 애너테이션

마커 애너테이션이 등장하면서 마커 인터페이스는 구식이 되었다는 이야기가 있지만 마커 인터페이스는 두 가지 면에서 마커 애너테이션보다 낫다.

  • 마커 인터페이스는 이를 구현한 클래스의 인스턴스들을 구분하는 타입으로 쓸 수 있으나, 마커 애너테이션은 그렇지 않다.
    • 마커 인터페이스는 컴파일 타임에 오류를 잡을 수 있다.
  • 마커 인터페이스는 적용 대상을 더 정밀하게 지정할 수 있다.
    • ElevmentType.Type 으로 타겟을 지정하므로 모든 타입(클래스, 인터페이스, 열거 타입, 어노테이션)에 적용된다.

반대로 마커 애너테이션이 마커 인터페이스보다 나은 점으로는 거대한 애너테이션 시스템의 지원을 받는다는 점이다. 즉, 클래스나 메서드, 필드 등 다양한 곳에 붙일 수 있으며 빌드 툴, IDE, 테스트 프레임워크 등 여러 시스템에서 사용할 수 있다.

마커 애너테이션을 사용하는 경우

  • 클래스, 인터페이스 외 프로그램 요소(모듈, 패키지, 필드, 지역변수 등)에 마킹해야하는 경우 (클래스와 인터페이스만이 인터페이스를 구현하거나 확장이 가능)
  • 어노테이션을 적극적으로 사용하는 프레임워크

마커 인터페이스를 사용하는 경우

  • 마킹된 객체를 매개변수로 받는 메서드를 작성해야하는 경우 (마커 인터페이스를 해당 메서드의 매개변수 타입으로 사용하면 컴파일타임에 오류 발생)
profile
개발계발

0개의 댓글