본문 바로가기

Reading/Effective Java

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

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

 

1. 정수 열거 패턴(int enum pattern)의 단점


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_BLLOD = 2;

 

1. 타입 안전을 보장할 방법이 없고 표현력이 좋지 않다.

위의 예시코드는 APPLE과 ORANGE를 바꿔써도 상수 내용은 같기 때문에 정상적으로 동작한다는 것이다.

 

2. 자바가 정수 열거 패턴을 위한 별도 이름공간(namespace)을 지원하지 않아 접두어를 써서 이름 충돌을 방지한다.

mercury는 수은(원소), 수성(행성)의 이름을 각각 ELEMENT_MERCURY, PLANET_MERCURY로 구분한다

 

3. 평범한 상수를 나열한 것 뿐이라, 상수의 값이 바뀌면 컴파일 시 올바르지 않게 동작할 수 있다.

 

4. 문자열로 출력하기 까다롭다. 디버깅을 하면 의미가 아닌 단순 숫자로 보여 별 도움이되지 않는다.

문자열 열거패턴(string enum pattern)으로 변형하면 상수의 의미를 출력할 순 있지만 하드코딩으로 인한 런타입 버그와 문자열 비교에 따른 성능 저하가 발생한다. ex)  String APPLE_FUJI = "0";

 

5. 같은 정수 열거 그룹에 속한 모든 상수를 한 바퀴 순회하는 방법도 마땅치 않고 그 안에 상수가 몇 개인지도 알 수 없다.

 

 

열거타입(enum type)


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

 

완전한 형태의 클래스이며 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.

 

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

클라이언트가 인스턴스 직접 생성 및 확장할 수 없으니 열거 타입 선언으로 만들어진 인스턴스들은 유일하게 하나씩만 존재한다.

 

2. 열거 타입은 인스턴스 통제된다.

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

 

3. 열거 타입은 컴파일 타임 타입 안전성을 제공한다.

정수 열거 패턴과 달리, Apple 열거 타입을 매개 변수로 받는 메소드를 선언했으면 건네받은 참조는 null을 제외하고 Apple의 값이다. 다른 타입의 값을 넘기려 하면 컴파일 오류가 발생한다. 

 

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

새 상수 추가, 순서 바꾸기를 해도 공개되는 것이 필드의 이름뿐이라 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 떄문이다. ex) Apple의 JUICE와 Orange의 JUICE가 상수로 공존 가능

 

5. toString 메소드는 출력하기에 적합한 문자열을 내어준다.

즉, 오버라이딩하지 않았다면 상수 필드의 이름을 그대로 반환해준다.

 

6. 열거 타입(enum type)은 임의의 메소드나 필드를 추가할 수 있고, 임의의 인터페이스를 구현하게 할 수도 있다.

Object 메소드들을 높은 품질로 구현해놨고, Comparable과 Serializable을 구현했으며 그 직렬화 형태도 웬만큼 변형을 가해도 문제없이 동작하게끔 구현되어있다.

 

 

열거 타입에 메소드, 필드 추가하기


필드를 추가하는 예로, Apple과 Orange에 색이나 과일의 이미지를 넣을 수 있고, 이에 관한 다양한 메소드들을 만들 수 있다.

그저 상수 모음일 뿐인 열거 타입이지만, 실제로는 클래스이므로 고차원의 추상 개념 하나를 완벽히 표현해낼 수도 있는 것이다.

 

//태양계의  여덟 행성을 거대한 열거 타입으로 나타내기
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
    }
}

 

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

 

 

열거 타입의 values 메소드와 toString 메소드

values() 메소드는 열거 타입 자신 안에 정의된 상수들의 값을 배열에 담아 반환하는 정적 메소드다.

 

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

 

 

위 코드대로면, Planet.values()는 Plannet[]을 반환한다.

 

각 열거 타입 값의 toString 메소드는 상수 이름을 문자열로 반환한다. 따라서 print문을 사용할 때 상수이름으로 바로 출력가능하다.

그런데 열거 타입의 상수 이름을 문자열로 반환하는 name() 메소드도 있다.

 

 

name 메소드에 달린 주석대로면 가장 친화적인 방법인 toString을 주로 쓰고 특수한 상황에 사용하도록 설계되었다고 나와있다.

아마 toString을 재정의해서 상수의 이름을 가져오지 못할때? name 메소드를 사용할 수 있지 않을까 생각한다?

 

 

만약 열거 타입에서 상수가 삭제된다고 하더라도 클라이언트가 사용했다면 컴파일 에러, 컴파일하지 않았다면 런타임 에러가 발생한다.

제거한 상수를 가지고 있지 않던 클라이언트에게는 아무 영향이 없다.

열거 타입을 선언한 클래스 혹은 그 패키지에서만 유용한 기능은 private이나 package-private 메소드로 구현하면 된다.

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

(ex java.math.RoundingMode는 BigDecimal이 사용하지만, 이 외에도 유용하게 사용되는 개념이라 톱레벨로 올라갔다)

 

 

상수별 메소드 구현(constant-specific method implemetation)


상수마다 동작이 달라져야 하는 상황이 있을 것이다. 사칙연산 계산기의 연산 종류를 열거 타입으로 선언하고 실제 연산까지 열거 타입 상수가 직접 수행해야 한다고 가정해보자. 우선 switch 문을 이용해 상수의 값에 따라 분기하는 방법이다. 

 

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

 

위 방법은 깨지기 쉽다. 새로운 상수를 추가하면 해당 case 문도 추가해야하고, 이를 잊었다면 컴파일은 되지만 런타임 에러가 발생한다.

 

개선된 방법을 살펴보자. 열거 타입에 추상 메소드를 선언하고 각 상수별 클래스 몸체(constant-specific class body) 즉 각 상수에서 자신에 맞게 재정의 하는 방법이다. 이를 상수별 메소드 구현이라한다. 완성된 enum 전체를 보자.

 

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

    private final String symbol;

    Operation(String symbol) { this.symbol = symbol; }

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

    public abstract double apply(double x, double y);
    
    public static void main(String[] args) {
        double x = Double.parseDouble(args[0]);
        double y = Double.parseDouble(args[1]);
        for (Operation op : Operation.values())
            System.out.printf("%f %s %f = %f%n",
                    x, op, y, op.apply(x, y));
    }
}

 

추상 메소드를 선언했기 때문에, 재정의하지 않았다면 컴파일 오류로 알려줄 수 있어 switch문보다 개선된 방식이다.

여기에 Operation의 toString을 symbol을 반환하도록 재정의했기 때문에, 편리하게 계산식 출력을 할 수 있다.

 

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

 

Operation plus = Enum.valueOf(Operation.class,"PLUS"); //Enum을 사용할 경우
Operation plus2 = Operation.valueOf("PLUS"); //Operation을 사용할 경우

 

한편, 열거 타입의 toString 메소드를 재정의했는데, toString이 반환하는 문자열을 해당 열거 타입 상수로 변환해주는 fromString 메소드도 함께 제공하는 걸 고려해보자.

 

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

 

fromString이 Optional로 반환하는 건 주어진 문자열이 가리키는 연산이 존재하지 않음을 알리고 대처를 클라이언트에게 위임하는 것.

 

Operation 상수가 stringToEnum Map에 추가되는 시점은, 열거 타입 상수 생성 후 정적 필드가 초기화될 때다.

자바 8 이전에는 stringToEnum을 빈 배열로 두고 values가 반환한 배열을 순회하며 맵에 추가했었다.

하지만 열거 타입 상수는 생성자에서 자신의 인스턴스를 맵에 추가할 수 없다. 런타임에 NPE가 발생한다.

생성 순서: enum의 상수 변수 -> enum 생성자 -> enum 정적 변수

열거 타입 생성자가 실행되는 시점에서 정적 필드들이 아직 초기화되기 전이라, 자기 자신을 추가하지 못하게 하는 제약이 필요하다.

 

Operation(String symbol) {
    this.symbol = symbol;
    // 생성자나 인스턴스 초기화 때, 정적 필드에 접근할 수 없기에 컴파일 에러
    stringToEnum.put(symbol,this);
    // 메소드를 통해 초기화하려 해도, 정적 필드들이 초기화 전이라 stringToEnum에 접근할 때 NPE가 발생
    put(symbol,this);
}

public void put(String symbol, Operation operation){
    stringToEnum.put(symbol,operation);
}

 

 

전략 열거 타입 패턴


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

값에 따라 분기하여 코드를 공유하는 열거 타입을 살펴보자

 

public enum PayrollDay {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    SATURDAY, SUNDAY;

    private static final int MINS_PER_SHIFT = 8 * 60;

    int pay(int minutesWorked, int payRate) {
        int basePay = minutesWorked * payRate;

        int overtimePay;
        switch (this) {
            case SATURDAY: case SUNDAY: // 주말
                overtimePay = basePay / 2;
                break;
            default: //주중
                overtimePay = minutesWorked <= MINS_PER_SHIFT ?
                        0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
        }

        return basePay + overtimePay;
    }
}

 

휴가와 같은 새로운 값을 열거 타입에 추가하려면 그 값을 처리하는 case문을 쌍으로 넣어줘야 한다. 잊는다면 주중으로 취급되기 때문.

상수별 메소드 구현으로 급여를 정확히 계산하는 방법은 두 가지가 있다.

 

1. 잔업 수당을 계산하는 코드를 모든 상수에 중복해서 넣기

2. 계산 코드를 평일용과 주말용으로 나눠 각각을 도우미 메소드로 작성한 다음 각 상수가 자신에게  필요한 메소드를 적절히 호출

 

두 방식 모두 코드가 장황해져 가독성 하락 및 오류 가능성을 유발한다.

PayrollDay에 평일 잔업수당 계산용 메소드 overtimePay를 구현하고 주말 상수에만 재정의하면 코드의 장황도는 줄어든다.

하지만 switch문을 쓰면 똑같은 단점이 나타난다. 즉, 새로운 상수를 추가하면서 overtimePay 메소드를 재정의 않으면 평일용 코드를 그대로 물려받게 되는 것이다.

 

가장 좋은 방법은 새 상수를 추가할 때 잔업수당 "전략"을 선택하는 것이다. 아래는 전략 열거 타입 패턴이다.

 

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

    public static void main(String[] args) {
        for (PayrollDay day : values())
            System.out.printf("%-10s%d%n", day, day.pay(8 * 60, 1));
    }
}

 

잔업수당 계산을 private 중첩 열거 타입으로 옮기고 PayrollDay 열거 타입의 생성자에서 선택하는 것이다. 더 복잡해지긴 하지만

PayrollDay 열거 타입은 잔업수당 계산을 그 전략 열거 타입에 위임해 switch나 상수별 메소드 구현이 필요없어진다.

 

이렇게 switch 문은 열거 타입의 상수별 동작을 구현하는데 적합하지 않지만,

기존 열거 타입에 상수별 동작을 혼합해 넣을 때는 switch 문이 좋은 선택이 될 수 있다.

예를들어, 서드파티에서 가져온 Operation 열거 타입이 있는데, 각 연산의 반대 연산을 반환하는 메소드가 필요할 때다.

 

    public static Operation inverse(Operation op) {
        switch(op) {
            case PLUS:   return Operation.MINUS;
            case MINUS:  return Operation.PLUS;
            case TIMES:  return Operation.DIVIDE;
            case DIVIDE: return Operation.TIMES;
            default:  throw new AssertionError("Unknown op: " + op);
        }
    }

 

추가하려는 메소드가 의미상 열거 타입에 속하지 않는다면, 직접 만든 열거 타입이라도 이 방식을 적용하는게 좋다. 종종 쓰이지만 열거 타입 안에 포함할만큼 유용치 않는 경우도 포함된다. 열거 타입이 메모리에 올리는 공간과 초기화하는 시간이 들긴 하지만, 체감될 정도는 아니다.

 

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

열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.

열거 타입은 나중에 상수가 추가돼도 바이너리 수준에서 호환되도록 설계되어있다.

 

 

enum의 values()와 valueOf()는 누가 생성해주는걸까?


Enum의 정적 메소드인 valueOf(Class enumType, name)은 Enum 추상클래스에서 제공하는 것을 알 수 있다.

그러면 우리가 선언한 enum의 values()와 valueOf()는 자바 컴파일러가 컴파일 시점에 생성을 한다.

아래 두 사이트를 참조하자.

 

 

How is values() implemented for Java 6 enums?

In Java, you can create an enum as follows: public enum Letter { A, B, C, D, E, F, G; static { for(Letter letter : values()) { // do something with letter } } }

stackoverflow.com

 

 

In java, What does such enum type compile to?

Below is the code that defines enum type. enum Company{ EBAY(30), PAYPAL(10), GOOGLE(15), YAHOO(20), ATT(25); private int value; private Company(int value){ super(this.name())...

stackoverflow.com

 

 

 

* 위 글은 EffectiveJava 3/E 책 내용을 정리한 글로, 저작권 관련 문제가 있다면 댓글로 남겨주시면 즉각 삭제조치 하겠습니다.