[Effective Java] 아이템34 - int 상수 대신 열거 타입을 사용하라

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

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

6장에서는 이 두 가지 타입들을 올바르게 사용하는 방법을 알아본다.


먼저 enum이 나오기 전 상수를 어떻게 선언했는지 보자.

// 정수 열거 패턴(int enum pattern)
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 0;
public static final int APPLE_GRANNY_SMITY = 0;

public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 0;
public static final int ORANGE_BLOOD = 0;

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

  • 타입 안전을 보장할 방법이 없다. (이 예제에선 APPLE 과 ORANGE를 다른 타입으로 보는데, int로 선언을 해버리면 두 타입 모두 파라미터로 보낼 수 있다.)
  • 평범한 상수를 나열한것 뿐이라 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨진다. 따라서 상수의 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야한다.
  • 정수 상수는 문자열로 출력하기가 까다롭다. (toString 처럼 출력해주지 않는다.)
  • 하드코딩과 별 다르지 않다.

자바는 이러한 열거 패턴의 단점을 보완하기 위해 enum type을 지원한다.

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

자바의 열거타입은 완전한 형태의 클래스다. 상수 하나당 (FUJI, NAVEL 등) 자신의 인스턴스를 만들어서 public static final 필드로 공개한다. 또한, 열거 타입은 public한 생성자를 제공하지 않으므로 클라이언트가 직접 생성할 수 없기 때문에 싱글턴 처럼 작동한다.

위에서 살펴본 int enum pattern과는 달리 enum type을 이요하면 다음과 같은 장점이 있다.

  • 컴파일타임 타입 안정성을 제공한다. (Apple을 받는 메서드에서 Orange 타입을 넣으면 컴파일 에러)
  • 열거 타입에 상수를 수정하거나 삭제해도 클라이언트가 다시 컴파일하지 않아도 된다.
  • toString 메서드를 지원한다.
  • 상수 뿐만 아니라 임의의 메서드나 필드를 추가할 수 있고, 인터페이스를 구현하게 할 수도 있다.

enum type : 상수 모음을 넘어서 고차원의 추상 개념 하나를 표현한다.

태양계의 여덟 행성은 상수의 개념이다. 행성에는 질량과 반지름이 있고, 이 두 속성을 이용해 표면중력을 계산할 수 있다. 이를 상수 개념으로 보고 enum type으로 구현한 예제이다.

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);

    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
    }
}

enum type은 근본적으로 불변 이라 모든 필드는 final이어야 한다.

mass, radius를 파라미터로 받는 생성자를 만들어서 각 상수들이 인스턴스화 되게한다.

그리고 surfaceWeight 메서드를 enum type에 만들어서 표면중력을 계산할 수 있도록 한다.

이제 아래와 같이 간단하게 지구의 표면중력을 구할 수 있다.

double mass = Planet.EARTH.surfaceGravity();

toString 을 지원한다.

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

for (Planet p : Planet.values())
   System.out.printf("%s에서의 무게는 %f이다.%n",
                     p, p.surfaceWeight(mass));

또한 기본적으로 toString을 제공하기 때문에 %s의 인자로 Planent p를 전달했는데, 타입의 값이 출력된다.

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

toString은 기본적으로 타입의 값을 출력한다. 이를 커스터마이징 하려면 오버라이딩을 하면 된다. toString을 재정의하려거든, fromString메서드도 함께 제공하는 걸 고려해보자

// 코드 34-7 사칙연산 예제
private static final Map<String, Operation> stringToEnum =
        Stream.of(values()).collect(
                toMap(Object::toString, e -> e));

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

이 예제에서는 스트림을 썼는데, 자바8 이전에는 for문을 이용해서 하나씩 넣어줬을 거다. 하지만 열거타입상수는 생성자에서 자신의 인스턴스를 맵에 추가할 수 없기 때문에 뭔가 다른 작업을 해줘야 할 것이다.

열거 타입 상수는 생성자에서 자신의 인스턴스를 참조할 수 없다. 열거 타입의 생성자에서 접근할 수 있는 것은 상수 변수뿐이다. 왜냐하면 열거 타입 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전이라, 자기 자신을 추가하지 못하게 하는 제약이 꼭 필요하다.

(그래서 Stream을 썼다는 얘기다..)

상수별 메서드 구현

상수별로 동작하는 방식을 달리하고 싶을 경우, 열거 타입에 추상 메서드를 구현하고, 각 상수별로 자신에 맞게 재정의하는 방법이다. (장황한 switch문을 대체할 수 있다.)

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 메서드를 반드시 구현해야 하기 때문에, 알 수 없는 연산을 생성할 버그를 미리 막을 수 있다.

private 중첩 열거 타입

위에서 살펴본 상수별 메서드 구현에는 열거 타입 상수끼리는 코드를 공유하기 어렵다. (그럴일은 없지만 PLUS 메서드와 MINUS 메서드가 동일할 경우, 공유하기 어렵다라는 뜻이다.)

switch 문을 쓰는 방법도 있지만, 상수별로 전략을 선택할 수 있도록 하는 방법이 private 중첩 열거 타입이다.

enum PayrollDay {
    MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY),
    THURSDAY(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);
        }
    }

}

열거 타입인 PayrollDay 안에 또 다른 열거타입 PayType을 선언했다. MONDAY(WEEKDAY)를 보면, MONDAY는 WEEKDAY라는 또 다른 열거타입을 파라미터로 갖고, pay 메서드 수행 시, 해당 payType의 pay 메서드가 수행되게 하는 것이다.

Summary

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

단순 상수 집합 개념이 아닌, 고도화 된 싱글턴이라고 볼수도 있을 것 같다.

댓글남기기