7 minute read

열거(enum) 타입

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

정수 열거 패턴 ( 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_BLOOD  = 2;
  • 타입 안전을 보장할 방법이 없으며, 표현력도 좋지 않다.
    • ORANGE를 건네야 할 메서드에 APPLE을 보내고 연산자로 비교하더라도 컴파일러가 경고 메시지를 출력하지 않는다.
      • APPLE_FUJI == ORANGE_NAVEL 의 결과가 true가 된다.
  • 정수 열거 패턴을 위한 별도 이름공간을 지원하지 않기 때문에, 어쩔 수 없이 접두어 APPLE, ORANGE ... 를 사용해 이름 충돌을 방지하는 것이다.
  • 상수의 값이 변경되면 클라이언트 파일도 다시 컴파일해야 하는데, 다시 컴파일하지 않는다면 오작동할 것이다.

문자열 열거 패턴 ( String enum pattern )

public static final String APPLE_FUJI                   = "apple fuji";
public static final String APPLE_PIPPIN                 = "apple pippin";
public static final String APPLE_GRANNY_SMITH           = "apple granny smith";
  • 상수의 의미를 출력할 수 있다는 점은 좋으나, 더 나쁘다.
  • 하드코딩한 문자열에 오타가 있어도 컴파일러에서 확인할 수 없고, 이에 따른 런타임 버그가 발생할 것이다.
  • 문자열 비교에 따른 성능저하가 발생한다.

열거 타입

자바의 열거 타입은 클래스로 제공된다.

public enum Apple  { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
  • 상수 하나당 해당되는 인스턴스를 하나씩 만들어 public static final 필드로 공개한다.
  • 밖에서 접근할 수 있는 생성자를 제공하지 않으므로, 사실상 final 이다.
    • 즉, 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없으므로, 열거 타입 선언으로 만들어진 인스턴스들은 딱 한개씩만 존재한다.
    • 싱글턴은 원소가 하나뿐인 열거 타입이라 할 수 있고, 반대로 열거타입은 싱글턴을 일반화한 형태라고 볼 수 있다.
  • 열거 타입은 컴파일타임에서의 타입 안전성을 제공한다.
    • 위의 예제 코드를 사용해 Apple 열거 타입을 선언했다면, 이는 null 혹은 Apple 의 값 중 한개임이 확실하다.
    • 다른 타입의 값을 넘기려고 하면 컴파일 오류가 발생할 것이다.
  • 각자의 이름공간이 있으므로, 열거 타입에 새로운 상수를 추가하거나 순서를 바꾸더라도 다시 컴파일하지 않아도 된다.
/**  
 * Returns the name of this enum constant, exactly as declared in its * enum declaration. * * <b>Most programmers should use the {@link #toString} method in  
 * preference to this one, as the toString method may return * a more user-friendly name.</b>  This method is designed primarily for * use in specialized situations where correctness depends on getting the * exact name, which will not vary from release to release. * * @return the name of this enum constant  
 */
 public final String name() {  
    return name;  
}

/**  
 * Returns the name of this enum constant, as contained in the * declaration.  This method may be overridden, though it typically * isn't necessary or desirable.  An enum class should override this * method when a more "programmer-friendly" string form exists. * * @return the name of this enum constant  
 */
 public String toString() {  
    return name;  
}
  • toString()이 출력하기에 적합한 문자열을 제공한다.
    • Enum 클래스를 확인해 보면, 기본적으로 선언된 name을 반환하도록 되어 있으며 오버라이드해 재정의하여 사용할 것을 권장하고 있다.
  • Comparable, Serializable 인터페이스를 구현하였으며 직렬화 형태도 구현되어 있다.

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

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

  • 임의의 메서드나 필드를 추가할 수 있고, 임의의 인터페이스를 구현하게 할 수도 있다.
    • 상수와 연관된 데이터를 해당 상수 자체에 내재시키고 싶다면 메서드 혹은 필드를 선언해 사용하자.
    • 제거한 상수를 참조하는 곳에서는 컴파일 오류가 발생할 것이며, 이때 어떤 값에서 발생하는지 바로 알 수 있을 것이다.
  • 열거타입에 선언한 클래스 혹은 그 패키지에서만 유용한 기능은 private 이나 package-private 메서드로 구현하자.

상수별 메서드 구현

열거 타입에서 상수마다 메서드 구현을 달리 하고 싶을 때가 있을 것이다.

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 문도 추가해야 한다.
  • 혹시라도 깜빡한 경우, 컴파일은 되지만 AssertionError 오류가 발생할 것이다.

추상 메서드 선언

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 재정의를 잊기는 어려울 것이다.
    • 재정의를 깜빡하더라도 컴파일 오류로 알려준다.
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);
}

상수별 메서드 구현을 상수별 데이터와 결합할 수도 있다.

fromString

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

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

    // 열거 타입 상수 생성 후 정적 필드가 초기화될 때 추가된다.
    private static final Map<String, Operation> stringToEnum = Stream.of(values()).collect(Collectors.toMap(Object::toString, e->e));

    public static Optional<Operation> fromString(String symbol) {
        return Optional.ofNullable(stringToEnum.get(symbol)); 
        // 주어진 연산이 가리키는 상수가 존재하지 않을 수 있다.
    }
}
  • 열거타입의 정적필드 중 생성자에서 접근할 수 있는 것은 상수 변수 뿐이다.
  • 열거 타입 생성자가 실행되는 시점에는 정적 필드들이 초기화되기 전이라 자기 자신을 추가하지 못하도록 제약이 꼭 필요하다.

전략 열거 타입 패턴

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

}

위 코드는 간결해보이지만, 관리에 있어서는 위험한 코드이다.

  • 새로운 값을 열거타입에 추가한다면, switch-case문에도 이 값에 대한 처리를 반드시 넣어줘야 한다.
    • 추가하지 않는다면, 휴일 수당을 받아야하는데 평일 수당을 받게되는 경우가 발생할 수도 있다.
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 minsWorked, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;

		// 잔업수당을 계산하는 메서드.
        public int pay(int minsWorked, int payRate){
            int basePay = minsWorked * payRate;
            return basePay + overtimePay(minsWorked, payRate);
        }
    }

}

위와 같이 전략 열거 타입 패턴을 사용한다면, switch 문이나 상수별 메서드 구현이 필요 없다. 이는 switch문보다 복잡하지만 더 안전하고 유연하다.

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

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("알 수 없는 연산: " + this);
  }
}

결론

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

  • 필요한 원소를 컴파일타임에 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자.
    • 예: 요일, 메뉴 아이템, 연산 코드 등… 허용하는 값 모두를 이미 알고 있다면 쓸 수 있다.
  • 열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.
    • 나중에 상수를 추가하거나 제거하더라도 해당 상수를 참조할때 디버깅이 쉽다.

Leave a comment