티스토리 뷰

728x90
반응형

int 상수대신 열거 타입을 사용하라


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

💡 정수형 열거 패턴의 단점

  • 타입 안전을 보장할 수 없으며 표현력이 좋지 않습니다.
  • 오렌지를 건네야할 메서드에 사과를 보내고 동등 연산자(==)러 비교하더라도 컴파일러는 아무런 경고 메시지를 출혁하지 않습니다.
  • 자바에서 별도의 namespace를 지원하지 않기 때문에 접두어(APPLE, ORANGE)를 붙어야합니다.
  • 단순히 상수를 나열한 것뿐이기 때문에 깨지기 쉽습니다.
    • 컴파일 시 그 값이 클라이언트 파일에 그대로 새겨지는데, 상수의 값이 바뀌면 해당 클라이언트도 다시 컴파일해야 합니다.
  • 이러한 단점으로 정수 열거 패턴의 변형인 문자열 열거 패턴도 있지만 이 역시 좋지는 않습니다. 숫자가 아니라 문자를 사용하게 되면서 미숙한 개발자는 문자열 값을 그대로 사용하며 코딩할 수 있기 때문입니다. 이는 런타임 버그와 문자열 비교에 따른 성능 저하를 유발합니다.
public class Foods {
    
    public static final int APPLE_FUJI = 1;
    public static final int APPLE_PIPPIN = 2;
    public static final int APPLE_FRANNY_SMITH = 3;
    
    private static final int ORANGE_NAVEL = 1;
    private static final int ORANGE_TEMPLE = 2;
    private static final int ORANGE_BLOOD = 3;
}

 

💡 열거 타입

public enum Apple {

    FUJI, PIPPIN, GRANNY_SMITH
}

public enum Orange {

    NAVEL, TEMPLE, BLOOD
}

 

💡 열거타입의 특징

  • 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개합니다.
  • 열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final입니다. (기본생성자의 접근 제어자는 private입니다.)
  • 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없으니 열거 타입 선언으로 만들어진 인스턴스는 딱 하나만 존재함을 보장합니다.
  • 열거 타입은 컴파이타임 타입 안전성을 제공합니다.
  • 열거 타입에는 각자의 이름 공간이 있어서 이름이 같은 상수도 공존할 수 있습니다.
  • 열거 타입에 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 됩니다.
  • 열거 타입에는 임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수 있습니다.
  • 열거 타입은 자신 안에 정의된 상수들을 값을 배열로 담아 반환하는 정적 메서드(values)를 제공합니다.

 

💡 열거 타입이 필요한 상황

  • 태양계의 여덟 행성을 열거 타입으로 작성한다고 예를 들어보면 각 행성에는 질량과 반지름이 있고, 이 속성을 이용해 표면중력을 계산할 수 있습니다. 즉 각각의 행성에 질량, 반지름, 표면중력과 같은 연관정보를 각각의 행성마다 저장하고 싶은 경우 사용할 수 있습니다.
  • 각 열거 타입 상수의 오른쪽 괄호안에 있는 숫자는 생성자에게 넘기는 매개변수 입니다.
  • 열거 타입 상수 각각을 특정 데이터와 연결지으러면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 됩니다.
  • 열거 타입은 기본적으로 불변이기에 모든 필드는 public static final이며 생략 가능합니다.
  • 필드는 public으로 해도되지만 private로 두고 별도의 getter를 통해 접근하는것이 좋습니다.
public enum Planet {

    MERCURY(3.302e+23, 2.439e6), // 생성자에게 넘기는 매개변수 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, 0.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 this.mass; }
    public double radius() { return this.radius; }
    public double surfaceGravity() { return this.surfaceGravity; }
    
    public double surfaceWeight(double mass) {
        return mass * surfaceGravity;
    }
}

 

💡 열거 타입을 잘 쓰기위한 방법

  • 열거 타입의 상수마다 다르게 동작해야하는 경우가 있습니다. 예를 들어 사칙연산의 종류를 열거 타입으로 선언하고 동작하는 연산까지 열거 타입으로 직접 동작하게해야한다면 어떻게 해야할까요?

 

🧨 문제가 되는 코드 1)

  • 실제로 실행을 시켜보면 에러없이 동작을 할것입니다. 하지만 이 코드에는 몇가지 문제가 있습니다.
    • throw 문은 실제로 도달할 일은 없지만 기술적으로 도달할 수 있기에 생략시 컴파일할 수 없습니다.
    • 새로운 상수의 추가 혹은 삭제시 매번 로직을 수정해야해서 깨지기 쉽습니다.
public enum OperationV1 {
    
    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);
    }
}

 

💡 조금 더 나은 코드

  • 열거 타입에 추상 메서드를 선언한 후 각 상수별로 몸체를 재정의하는 방법입니다. 이를 상수별 메서드 구현이라고 합니다.
  • 하지만 이 방법의 단점으로는 apply 메서드가 추상 메서드로 선언되어 바로 아래에 붙어 있기 때문에 새로운 상수를 추가할 때는 반드시 apply 메서드를 재정의해야 합니다.(사용하고 싶지 않아도) 재정의를 하지 않으면 컴파일이 되지 않고 이 방법은 상수별 데이터와 혼용할 수 있습니다.
public enum OperationV2 {

    PLUS {
        @Override
        public double apply(double x, double y) {
            return x + y;
        }
    },
    MiNUS {
        @Override
        public double apply(double x, double y) {
            return x - y;
        }
    },
    TIMES {
        @Override
        public double apply(double x, double y) {
            return x * y;
        }
    },
    DIVIDE {
        @Override
        public double apply(double x, double y) {
            return x / y;
        }
    };

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

 

💡 개선된 코드

  • 각 상수에 연결된 데이터로 상수별 심볼을 선언해줬고 toString을 재정의하여 해당 연산을 의미하는 심볼을 반환하게 했습니다. 이를 사용하면 사칙연산 열거 타입을 편하게 사용할수 있습니다.
public enum OperationV3 {

    PLUS("+") {
        @Override
        public double apply(double x, double y) {
            return x + y;
        }
    },
    MINUS("-") {
        @Override
        public double apply(double x, double y) {
            return x - y;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double x, double y) {
            return x * y;
        }
    },
    DIVIDE("/") {
        @Override
        public double apply(double x, double y) {
            return x / y;
        }
    };

    private final String symbol;

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

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


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

public class EffectiveJavaApplication {

    public static void main(String[] args) throws Exception {
       double x = 10;
       double y = 20;

       for (OperationV3 op : OperationV3.values()) {
           System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x,y));
       }
    }
}

 

💡 상수별 메서드 구현의 단점과 그 해결책

  • 위에서 상수별 메서드 구현을 통해 각각의 상숙 다른 동작을 할 수 있도록 구현을 했습니다. 위의 사칙연산과 같은 예제에서는 각각의 상수가 모두 다른 동작을 하기 때문에 별 문제가 없지만 만약 동작이 같은 타입도 있는 경우에는 어떻게 해야할까요? 이처럼 상수별 메서드 구현에는 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 있습니다.
  • 업무 시간(분): 8 * 60(1일 8시간 근무 기준)
  • 조건
    • 평일 오버타임은 잔업시간이 주어집니다.
    • 주말에는 무조건 전업시간이 주어집니다.
  • 아래 코드는 나름 간결하게 잘 작성되어 있지만 단점이 하니 있습니다. 이 코드는 주말과 평일을 구분하고 평일에 오버타임에 대해 구분을 해놓았지만 다른 상황에는 고려가 전혀 되어 있지 않습니다. 예를 들어 휴가기간에 일을 하게될 수도 있고, 등등 다른 상황이 있을 때마다 case를 추가하거나 조건들을 수정해야하는 경우가 있습니다.
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;
    }
}

 

💡 전략 패턴을 사용한 해결 방법

  • 잔업 수장을 계산하는 로직 자체 두가지(평일, 주말)를 private 중첩 열거 타입으로 만들어 옮기고 PayrollDay2 열거 타입의 생성자에서 주입받도록 하는 것입니다. 이렇게 되면 잔업수당 계산을 전략 열거 타입에 위임하여 따로 분기처리나 상수별 메서드 구현이 필요없게 됩니다.
import static com.effectivejava.study.chapter06.Item34.PayrollDayV2.PayType.WEEKDAY;
import static com.effectivejava.study.chapter06.Item34.PayrollDayV2.PayType.WEEKEND;

public enum PayrollDayV2 {

    MONDAY(WEEKDAY), TUESDAY(WEEKDAY),
    WEDNESDAY(WEEKDAY), THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
    SATURDAY(WEEKEND), SUNDAY(WEEKEND);

    private final PayType payType;

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

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

    enum PayType {

        WEEKEND {
            @Override
            int overtimePay(int mins, int payRate) {
                return mins * payRate / 2;
            }
        },

        WEEKDAY {
            @Override
            int overtimePay(int mins, int payRate) {
                return mins <= MINS_PER_SHIFT ? 0 : (mins - MINS_PER_SHIFT) * payRate / 2;
            }
        };

        abstract int overtimePay(int mins, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;
        
        int pay(int minutesWorkd, int payRate) {
            int basePay = minutesWorkd * payRate;
            return basePay + overtimePay(minutesWorkd, payRate);
        }
    }
}

 

💡 정리

  • 열거 타입은 정수 상수보다 가독성 및 안전성부분에서 뛰어납니다.
  • 각 상수를 각각 다른 데이터와 동작과 연결시킬 수 있습니다.
  • 하나의 메서드가 상수별로 다른 동작을 해야할 경우 switch가 아니라 상수별 메서드 구현을 사용합시다.
  • 상수 몇몇이 동작을 공유할 경우 전략 열거 타입 패턴을 사용합시다.

 

 

 

 

 

728x90
반응형