본문 바로가기

Java

[Effective Java][아이템34] ENUM 기본 사용법과 여러 구현 케이스 소개

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

 

 

WHY: 정수 열거 패턴의 단점을 보완할 수 있다.

정수 상수를 static final로 선언해서 사용.

public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_SMITH = 2;

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

단점

1) 타입 안전성을 보장할 수 없다.

// 향긋한 오렌지 향의 사과 소스
int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

오렌지를 보내야 할 메서드에 사과를 보내도 컴파일러는 아무런 메시지를 출력하지 않는다.

 

2) 표현방법도 별로이다.. (이름 공간이 없기 때문에)

이름 충돌을 방지하기 위해

사과용 상수의 이름은 모두 APPLE_로 시작하고, 오렌지 상수의 이름은 모두 ORANGE_라는 접두어를 붙여야 한다.

 

3) 상수의 값이 바뀌면 클라이언트도 다시 컴파일 해야 한다.

 

4) 정수를 문자열로 출력할 때 정수가 의미하는 값을 같이 출력하도록 수정 해줘야한다.

정수만으로는 무엇을 의미하는지 알 수 없기 때문에,

"ORANGE_TEMPLE = "+ ORANGE_TEMPLE 식으로 수정해서 출력해야 한다. 

 

+) 정수 대신 문자열 상수를 사용하면 어떨까? (문자열 열거 패턴)

의미있는 변수명을 지어 문자열을 상수처럼 사용하는 방법인데, 결론적으로는 더 안좋은 방법이다.

문자열 출력에는 용이하지만, 

비교문을 작성 할 때  프로그래머가 상수명을 일일히 다 써야하기 때문에. 오타가 있어도 컴파일러로 잡을 수 없다.

그리고 비교 비용도 문자열이 더 크기 때문에, 성능저하가 발생한다.

 


WHAT: 위 패턴들의 단점을 극복한  ENUM

 

아이디어

- enum은 클래스이다.

- 상수 당 하나의 인스턴스를 만든다.

- 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없으니, 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재한다. -> 싱글턴

 

장점

1. 타입 안전성이 보장된다.

-> 위 예시를 enum APPLE, enum ORANGE로 재구현하면 APPLE을 넘겨야 할 때 ORANGE를 넘기면 컴파일 에러가 발생한다.

 

2. 중복이름도 사용할 수 있다.

 

3. 바꿀때마다 컴파일할 필요가 없다.

-> 상수값이 클라이언트로 컴파일되어 넘겨지는게 아니기 때문에.

 

4. 문자열도 예쁘게 출력해준다.

-> enum의 toString은 출력하기에 적합한 형태로 재정의 되어 있다. 

 

5. 메서드, 필드를 추가할 수 있고, 인터페이스도 구현할 수 있다.

일반 클래스처럼 사용하면 된다.!!

 

 

+ 성능은 어떨까?

정수 상수와 비슷하다. enum을 메모리에 올리는 공간, 초기화 시간이 들긴 하지만 체감될 정도는 아니다.

 

 


HOW: 기본 사용법과 분기 케이스에 사용되는 ENUM

언제 사용하면 될까? 

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

 

case1. 기본 사용법

예시1) 데이터와 메서드를 갖는 열거 타입

여덟 행성을 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),
    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
    }
}

 

예시2) 어떤 행성에서의 무게를 계산하여 출력 

// 어떤 객체의 지구에서의 무게를 입력받아 여덟 행성에서의 무게를 출력
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));
   }
}

enum을 사용하면 여덟 행성에서의 표면에서의 무게를 간단하게 계산&출력할 수 있다.

 

이 예시는 여러 enum상수에 공통적이 메서드를 적용한 것이다.

그렇다면 상수마다 다른 동작을 수행하려면 어떻게 구현해야 할까?

 

 

case2. enum 상수마다 다른 동작을 수행하고 싶을 때 

예시3) [BAD] 값에 따라 분기하는 열거타입

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문도 추가해야 한다는 점이다. 

즉, 하나를 수정하면 따라오는 관리포인트가 있다는건데

추가하는걸 깜빡하면 추가된 연산임에도 "알 수 없는 연산"으로 런타임 오류가 발생한다.

 

 

다행히!! ENUM은 상수별로 다르게 동작할 수 있는 수단을 제공하고 있다.

 

예시4)[GOOD] 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; }
    };


    public abstract double apply(double x, double y);

apply메서드가 상수 바로 옆에 있으니 까먹을 가능성이 적고,

추상메서드이기 때문에 재정의하지 않으면 컴파일 오류가 발생해 런타임 전에 미리 해결할 수 있다. 

 

 

case3. 상수별로 구현한 메서드를 상수와 결합하고 싶을 때  -> ENUM 이름에 value(상수)값 추가

예시5) 

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

	// enum값을 map으로 변형
    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));
    }

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

enum에서는 valueOf(String) 메서드가 자동 생성된다.

 +  toString을 재정의하려면 반환문자열을 해당 열거타입 상수로 변환해주는 fromString도 함께 제공해주는게 좋다.

+ static final이 실행되는 시점: 열거타입 상수 생성 후 정적 필드가 초기화 될 때!!

 

 

 

case4. 열거 타입 상수가  같은 동작을 일부 공유할 때 

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

 

예시6) 직원의 임금 계산. 주중에 오버타임이 발생하면 잔업수당을 주고, 주말에는 무조건 잔업 수당으로 준다.

아래처럼  switch문으로 주말, 주중을 구분하고 주중에는 근무시간을 계산하여 오버타임인 경우에만 잔업수당을 주도록 구현할 수 있다.

enum PayrolLDay {
    MONDAY, TUESDAY, WEDSENDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;

    private static final int MINS_PER_SHIFT = 8 * 60;
    
    int pay(int minutesWorked, int payRate) {
        int basePay = minuitesWorked * payRate;
        
        int overtimePay;
        switch(this) {
            case SATURDAY: case SUNDAY:
                overtimePay = basePay /2;
                break;
            default:
                overtimePay = minuutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate /  2;
        }
        return basePay + overtimePay;
    }
}

단점

관리가 어렵다. 상수가 추가되면 case문도 쌍으로 넣어줘야 한다. 

-> HOLIDAY(휴가)라는 상수가 추가되었을 때 switch문에 주말쪽에 넣어주지 않으면.. 잔업수당을 못받게 된다.

 

해결

전략 열거타입: enum 상수를 추가할 때 "전략을 선택"하도록 한다.

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(day.pay(480, 1));
    }
}

 

방법

하위 열거 타입( -> PayType, 전략 열거 타입이라 명명)을 만들어서 "잔업수당 계산"을 전략 열거타입에 위임.

상위 enum의 인스턴스 값으로 하위 enum을 넣어준다.

상위 enum 생성자에서 적당한 값을 선택하여 넣어준다.