본문 바로가기

Reading Record/이펙티브자바

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

Java에서 열거 타입을 지원하기 전에는 아래와 같이 정수 상수를 한 묶음으로 선언하여 사용하였다. 하지만 이런 '정수 열거 패턴'은 많은 단점들이 존재한다.

public static final int APPLE_PIE = 0;
public static final int APPLE_JAM = 1;

public static final int GRAPE_PIE = 0;
public static final int GRAPE_JAM = 1;

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

위 코드에서 보는 것과 같이 APPLE_PIE와 GRAPE_PIE는 다른 접두어를 써서 겉보기에는 다른 곳에서 사용할것을 알 수 있지만 컴파일러 입장에서는 둘다 정수 0 이기 때문에 APPLE_PIE가 사용되는 메소드에 GRAPE_PIE가 전달되어도 컴파일때 아무런 문제가 없다.

2. 정수 열거 패턴을 사용한 프로그램은 깨지기 쉽다.

평범한 상수를 나열한 것 뿐이라 컴파일하면 해당 값이 클라이언트 파일에 그대로 새겨진다. 만약 상수의 값이 바뀌면 반드시 다시 컴파일 해야 한다.

public static final int APPLE_PIE = 0;

//결국 똑같은 코드!
System.out.println(APPLIE_PIE);
System.out.println(0);

3. 정수 상수는 문자열로 출력하기 어렵다.

public static final int APPLE_PIE = 0;
System.out.println(APPLIE_PIE); // APPLIE_PIE 가 아닌 0이 출력!

4. 같은 정수 열거 그룹에 속한 모든 상수를 한 바퀴 순회하는 방법도 마땅치 않다.

이런 정수 열거 패턴의 단점을 보완하고 여러 장점들을 안겨주는 대안이 바로 자바의 열거 타입 (Enum Type) 이다. 

열거 타입

중요한 것은 열거 타입 자체는 클래스이다. 그리고 열거 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개한다. 또한 열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않는다. 이런 사실들이 열거 타입의 여러 장점을 보여준다.

1. 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재한다.

클라이언트가 인스턴스를 직접 생성하거나 확장 할 수 없기때문이다. 싱글톤은 원소가 하나뿐인 열거 타입이라 할 수 있고, 열거 타입은 싱클톤을 일반화한 형태라고 볼 수있다.

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

public enum WeekDay {
    MONDAY(0),
    TUESDAY(1),
    WEDNESDAY(2),
    THURSDAY(3),
    FRIDAY(4),
    SATURDAY(5),
    SUNDAY(6);

    private final int value;

    WeekDay(int value) {
        this.value = value;
    }
}

 

WeekDay 열거 타입이 아니면 컴파일 에러가 발생한다.

WeekDay 열거타입을 인자로 받으려는 메소드는 반드시 WeekDay의 인스턴스를 건네줘야 한다.

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

어떠한 오류도 발생하지 않았다.

또한 열거 타입에 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일 하지 않아도 된다. 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문이다.

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

아래와 같이 열거 타입의 toString 메소드는 상수 이름을 문자열로 반환한다.

테스트가 통과한다.

5. 열거 타입에는 임의의 메소드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있다.

public enum WeekDay {
    MONDAY(0),
    TUESDAY(1),
    WEDNESDAY(2),
    THURSDAY(3),
    FRIDAY(4),
    SATURDAY(5),
    SUNDAY(6);

	//임의의 필드
    private final int value;

    WeekDay(int value) {
        this.value = value;
    }

	//임의의 메소드
    public void printHi() {
        System.out.println("Hi! This is " + this.name());
    }
    
}

또한 Enum 자체는 Object 메소드들과 Comparable, Serializable을 구현해두었다.

Enum의 구현부

다음은 열거 타입이 고차원의 추상 개념을 완벽히 표현해낼 수 있는것을 보여주는 예시이다.

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),
    URAUS(8.683e+25,2.556e7),
    NEPTUNE(1.024e+26,2.477e7);

    private final double mass;
    private final double radius;
    //표면중력
    private final double surfaceGravity;

    //중력상수
    private static final double G = 6.67300E-11;

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        this.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;
    }
}

Planet 열거타입 질량, 반지름, 표면중력이라는 임의의 필드를 가지고 있다. 상수 오른쪽 괄호는 생성자에 넘겨지는 매개변수들로 여기에선 질량과 반지름이고, 생성자 매개변수로 넘기지 않더라도 생성자로 받은 데이터를 받아 표면중력이라는 필드에 값을 저장하였다.

추가로 열거 타입은 근본적으로 불변이라 모든 필드는 final 이어야 한다

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

열거 타입의 values 메소드

열거 타입의 toString 메소드는 상수 이름을 문자열로 반환한다. 또한 toString 메소드를 재정의하여 사용할 수 있다.

public enum Planet {
    // 중략....
    
    //재정의한 toString, 이름을 소문자로 출력한다.
    @Override
    public String toString() {
        return this.name().toLowerCase();
    }
}

재정의한 toString 메소드

열거 타입의 또 하나의 장점은 열거 타입에 선언한 상수 하나를 제거하더라도 제거한 상수를 참조하지 않는 클라이언트에는 아무 영향이 없다. 또한 그런 상수를 참조를 하더라도 컴파일 에러가 발생할 것이다.

열거 타입을 선언한 클래스 혹은 그 패키지에서 유용한 기능은 private 이나 package-private으로 구현한다. 널리 쓰이는 열거 타입은 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 사용한다면 해당 클래스의 멤버 클래스로 만든다.

상수별 메소드 구현

열거 타입을 구현하면서 열거 타입의 메소드가 상수에 따라 다르게 동작해야 하는 경우가 생길 수도 있다. 다음과 같이 switch 문으로 해결할 수도 있지만, 새로운 상수가 추가된다면 case 문도 추가해야 한다.

public enum Operation {
    PLUS,MINUS,TIMES,DIVDE;

    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 DIVDE:
                return x / y;
        }
        throw new AssertionError("알 수 없는 연산:" + this);
    }
}

여기서 상수별 메소드 구현을 사용하면 조금 더 나은 방식으로 개선할 수 있다.

상수별 메소드 구현은 열거 타입에 추상 메소드를 선언하고 각 상수별로 클래스 몸체를 상수가 자신에 맞게 재정의하는 방법이다.

위의 코드를 상수별 메소드 구현을 이용하여 상수별 데이터와 결합한 방법으로 개선을 해보자.

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;
        }
    },
    DIVDE("/") {
        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);
}

각 상수에 맞게 계산을 한다.

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

상수 이름에 맞는 객체를 반환한다.

그리고 toString 메소드를 재정의했다면, toString이 반환하는 문자열을 해당 혈거 타입 상수로 변환해주는 fromString 메소드도 함께 제공하는 걸 고려해보자. 위의 코드에서 toString 메소드를 재정의해 기존 상수의 이름이 아닌 각 연산자의 기호를 반환하도록 구현하였다.
반대로 fromString 메소드를 구현하여 연산자 기호를 매개변수로 전달하면 알맞은 열거 타입 객체를 반환하도록 해보자

private static final Map<String, Operation> stringToEnum =
            Stream.of(Operation.values())
                    .collect(Collectors.toMap(Operation::toString, operation -> operation));

//Optional로 반환하여 값이 존재하지않을 상황을 클라이언트에게 알린다.
public static Optional<Operation> fromString(String symbol) {
    return Optional.ofNullable(stringToEnum.get(symbol));
}

연산자 기호에 맞는 객체를 반환한다.

여기서 중요하게 봐야할 것은 Map에 Operation 상수가 추가되는 시점이다. Operation 상수들은 정적 필드가 초기화되는 시점에 추가된다.
열거 타입에서 정적 필드는 열거 타입 상수가 생성된 후에 초기화 된다. 그렇기 때문에 열거 타입 생성자에서 정적 필드를 참조하려고 하면 컴파일 에러가 발생한다. 

컴파일 에러가 발생한다.

만약, putString 이라는 메소드로 맵에 추가하려고 하면 어떻게 될까?

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

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

NullPointerException 발생!

열거 타입의 정적 필드 중 열거 타입의 생성자에서 접근할 수 있는 것은 상수 변수뿐이다. 또한 열거 타입 생성자에서 같은 열거 타입의 다른 상수에도 접근할 수 없다.

전략 열거 타입 패턴

상수별 메소드 구현에는 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있다. 다음 코드를 통해서 알아보자.

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. 계산 코드를 평일용과 주말용으로 나눠 각각 도우메 메소드로 작성한 다음 각 상수가 자신에게 필요한 메소드를 적절히 호출하는 방법

3. 평일 잔업수당 계산 메소드를 구현해두고 주말 상수에서만 재정의해서 쓰는 방법

하지만 1, 2번 방식은 코드의 가독성이 떨어지고 오류가 발생한 가능성이 높아지고, 3번의 경우 새로운 상수가 추가되는 경우 switch와 똑같이 메소드를 잊지않고 재정의 해야하는 경우가 생길 수 있다.

이러한 경우에 전략 열거 타입 패턴을 사용해보도록 하자. 

잔업수당 계산을 private 중첩 열거 타입으로 위임하고 PayrollDay 열거 타입 생성자에서 적절한것을 선택하면 된다.

public enum PayrollDay {
    MONDAY(PayType.WEEKDAY),
    TUESDAY(PayType.WEEKDAY),
    WEDNESDAY(PayType.WEEKDAY),
    THURSDAY(PayType.WEEKDAY),
    FRIDAY(PayType.WEEKDAY),
    SATURDAY(PayType.WEEKEND),
    SUNDAY(PayType.WEEKEND);
    
    private final PayType payType;

    PayrollDay(PayType payType) {
        this.payType = payType;
    }

    int pay(int minutesWorked, int payRate) {
        return payType.pay(minutesWorked,payRate);
    }

    private enum PayType {
        WEEKDAY {
            int overtimePay(int minutesWorked, int payRate) {
                return minutesWorked <= MINS_PER_SHIFT ?
                        0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            int overtimePay(int minutesWorked, int payRate) {
                return minutesWorked * payRate / 2;
            }
        };

        abstract int overtimePay(int minutesWorked, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;

        int pay(int minutesWorked, int payRate) {
            int basePay = minutesWorked * payRate;
            return basePay + overtimePay(minutesWorked,payRate);
        }
    }
}

비로소 PayrollDay 열거 타입은 기존의 switch를 사용한 코드보다 더 안전하고 유연해졌다.

그런데 switch 문이 좋은 선택이 될 수 있는 경우가 있는데, 바로 기존 열거 타입에 상수별 동작을 혼합해 넣을 때 이다.
아래와 같이 Operation 열거 타입에서 각 연산의 반대 연산을 반환하는 메소드가 필요할 때이다.

public static Operation inverse(Operation operation) {
        switch (operation) {
            case PLUS:
                return Operation.MINUS;
            case MINUS:
                return Operation.PLUS;
            case TIMES:
                return Operation.DIVDE;
            case DIVDE:
                return Operation.TIMES;
        }
        throw new AssertionError("알 수 없는 연산 : " +operation);
}

지금까지 자바의 열거 타입에 대해서 알아보았다. 그렇다면 열거 타입을 언제 쓰면 좋을까??
필요한 원소를 컴파일 타임에 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자. 추가로 열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.