티스토리 뷰

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

enum type(열거 타입)은 일정 개수의 상수 값을 저장하고 그 외의 값은 허용하지 않는다.

public static final int 김치 = 0;
public static final int 찌개 = 1;
public static final int 된장 = 2;

// 프로젝트하다 본 상수 타입 (자바 7 버전임)
public static final int STATUS_SUCCESSFUL = 0;
public static final int STATUS_MISSING_DATA = 1;
public static final int STATUS_NOT_DETECTED = 2;

enum type이 지원되기 이전에는 위와 같이 정수 타입을 열거하여 사용했다. 이는 type safe하지 않고 김치와 STATUS_SUCCESSFUL를 ==로 비교해도 컴파일러는 알아차리지 못한다. 김치 성공!

정수형 열거 타입을 참조해 작성한 클라이언트 코드가 있을 때, 정수의 값을 변경하고 다시 컴파일하면 클라이언트도 반드시 컴파일 해야한다. 정수 값이 이미 클라이언트 파일에 새겨진 이후이기 때문이다. 또한 이 정수 상수의 이름을 출력하기가 매우 까다롭다. 이런 정수 대신 문자열 상수를 이용할 수 있지만 이건 더 큰일이다.

public static final String STATUS_SUCCESSFUL = "STATUS_SUCCESSFUL";
public static final String STATUS_MISSING_DATA = "STATUS_MISSING_DATA";
public static final String STATUS_NOT_DETECTED = "STATUS_NOT_DETECTED";

위와 같이 사용하는 것인데 상수의 이름을 그대로 값으로 쓰는 것이다. 하지만 딱봐도 상수 타입의 정의를 개발자의 하드코딩에 맡기면 실수한 여지가 엄청 많아 보인다. 문자열 compare의 오버헤드 또한 감수해야할 문제이다.

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

이러한 문제들을 해결한 것이 enum type이다. enum type은 Apple을 예시로 들면 FUJI, PIIPPIN, GRANNY_SMITH와 같은 각각의 상수에 대해 고유한 인스턴스를 하나씩 생성하고 이를 public static final 필드로 공개한다. enum type은 외부로 공개되는 생성자가 자동으로 생기지 않고, 개발자의 정의로 생성자를 생성하거나 상속해 하위 객체를 만들 수 없기 때문(final)에 싱글톤으로 생성됨이 보장된다. string compare와 비교해 인스턴스 주소 비교(Object의 기본 method)를 통해 빠르게 비교 가능하다.

enum type의 또다른 장점은 임의의 method나 field를 추가 할 수 있다는 점이다.

// 코드 34-3 데이터와 메서드를 갖는 열거 타입 (211쪽)
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
    }
}

태양계의 여덟개 행성을 나타내는 enum type이다. enum type은 immutable하기 때문에 내부에 두고 싶은 field는 final value로 두어야한다. 이런 소스코드 구조를 이해하기 쉽게 설명하면, Planet이라는 class가 존재하고 이를 상속 받는 MERCURY, VENUS...의 객체들을 컴파일 타임에 싱글톤으로 생성해 Planet의 내부 필드로 지니게하는 구조라고 생각하면 편할 것 같다.

// 어떤 객체의 지구에서의 무게를 입력받아 여덟 행성에서의 무게를 출력한다. (212쪽)
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은 내부에 정의 된 상수들을 배열에 담아 반환하는 values() method를 제공한다. 이 method는 상수의 선언 순서에 따라 순서가 정해진다.

enum이 어떤 class 내부에서 선언되어 이 class에서만 유용하다면 공개 범위를 private, 패키지 내부에서만 유용하다면 package-private으로 선언하는 것이 좋다. 만약 여기 저기서 쓰이는 enum이라면 top level class로 설계하는 것이 좋다.

enum에서 method를 작성해 제공하는 예제를 Planet에서 보았다. 그렇다면 상수마다 method의 동작을 다르게 할 순 없을까?

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

위와 같이 switch문을 통해 apply method를 분기할 수 있다. 현재 상황에는 큰 문제가 없고 Exception에 도달할 일도 없다. 하지만 언제나 개발은 수정과 확장을 고려하며 ㅎㅎ..

만약 다른 상수가 추가 됐을 때 위의 코드는 실수할 여지가 있다. switch 문의 case에 새로 추가 된 상수의 분기를 작성하지 않으면 Exception이 발생하고만다. 컴파일러가 이를 감지해주지 못한다.

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

enum은 method의 재정의를 지원해준다. apply method가 abstract로 선언되어 있기 때문에 이 method가 각자 상수에 구현되어 있지 않는다면 컴파일러는 에러를 뱉는다.

상수별 method 구현을 상수별 필드와 결합할 수 있다.

// 코드 34-6 상수별 클래스 몸체(class body)와 데이터를 사용한 열거 타입 (215-216쪽)
public enum Operation {
    PLUS("+") {
        public double apply(double x, double y) { return x + y; }
        @Override public String toString() { return "플러스";}
    },
    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);
}

toString을 재정의 하여 각각 "+", "-", "*", "/"을 반환하게 할 수 있다. 또한 상수 필드 내부에서도 method를 Overrid 할 수 있다. Operation.PLUS.toString()은 "플러스"를 반환한다.

toString이 반환하는 결과를 통해 enum 인스턴스를 반환하는 fromString() method를 구현하는 법이다.

// 코드 34-7 열거 타입용 fromString 메서드 구현하기 (216쪽)
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));
}

위의 코드는 필드들의 생성 순서를 알아야한다. enum type의 인스턴스가 먼저 생성 된 이후 static field가 초기화 된다. 따라서 enum.values를 호출해서 배열을 얻을 수 있고 이를 toString의 반환값과 enum의 인스턴스를 key value로 매핑한다. 자 이제 fromString을 사용해 stringToEnum map을 통해 enum 인스턴스를 가져올 수 있다. 반환 객체를 Optional로 wrapping하여 클라이언트에게 반환 객체가 null이 될 수 있음을 명시할 수 있다.

abstract method를 통해 모든 상수에 대해 method를 구현하면 코드의 공유가 힘들다. 아래는 코드를 공유하는 방법의 예시이다.

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

자 다시 switch문이다. 월요일부터 금요일은 평일급여를 제공하고, 토요일 일요일은 주중 급여를 제공하는 method이다. 이를 abstract method를 통해 구현하면 확장에는 용이하나 월-금에 똑같은 중복 코드를 작성해야한다. 그리고 평일 월급 지급 정책이 변경되면 월-금의 모든 method를 수정해야한다. 코드를 switch문으로 묶어서 사용할 수 있지만 이미 switch문을 통한 method분기에는 문제가 있음을 확인했다.

// 코드 34-9 전략 열거 타입 패턴 (218-219쪽)
enum PayrollDay {
    MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY),
    THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
    SATURDAY(WEEKEND), SUNDAY(WEEKEND);
    // (역자 노트) 원서 1~3쇄와 한국어판 1쇄에는 위의 3줄이 아래처럼 인쇄돼 있습니다.
    // 
    // MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    // SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
    //
    // 저자가 코드를 간결하게 하기 위해 매개변수 없는 기본 생성자를 추가했기 때문인데,
    // 열거 타입에 새로운 값을 추가할 때마다 적절한 전략 열거 타입을 선택하도록 프로그래머에게 강제하겠다는
    // 이 패턴의 의도를 잘못 전달할 수 있어서 원서 4쇄부터 코드를 수정할 계획입니다.

    private final PayType payType;

    PayrollDay(PayType payType) { this.payType = payType; }
    // PayrollDay() { this(PayType.WEEKDAY); } // (역자 노트) 원서 4쇄부터 삭제

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

개선하는 방법은 위와 같다. overtimePay() method를 다른 enum안에 가둔다. PayType에 상수로 주중(WEEKDAY)과 주말(WEEKEND)를 두고 이 enum 내부에서 abstract method를 선언한다. 코드가 조금 장황해지긴 하나 훌륭하게 method를 공유할 수 있게 된다. 상수로 뿡요일이 추가되고 이는 주말이라고 가정하면 뿡요일(WEEKEND)로 손쉽게 확장할 수 있다.

enum type은 성능상 느린가? 컴파일 타임에 메모리의 올리는 공간 및 시간이 들지만 체감될 정도는 아니라고 한다. 팍팍 써야겠다.

 

enum은 언제 쓰나요?? 필요한 원소를 컴파일 타임에 전부 알 수 있으면 (가변적인 파라미터에 의해 좌지우지 되지 않는 상수라면) 적절히 사용하면 되겠다. enum의 상수 개수가 불변일 필요는 없다. 지우는건 클라이언트 코드에 빨간줄이 쭉쭉 그어져 문제가 될 것 같지만 추가는 큰 문제가 없다. 상수가 추가되면 바이너리 수준에서 호환되게 설계돼 있다고 한다.

 

프로젝트 할 때 여러번 써본 enum type이다. 그냥 enum 상수의 name만 사용하고 나머지 기능은 잘 모르고 썼었다. enum의 다양한 기능에 대해 알게 됐고 앞으로의 프로젝트에 맛있게 써야겠다.

 

 

 

GitHub - ssafystudy/EffectiveJava: 이펙티브 자바 스터디

이펙티브 자바 스터디. Contribute to ssafystudy/EffectiveJava development by creating an account on GitHub.

github.com

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함