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

    Effective Java 아이템 34. int 상수 대신 (enum)을 사용하라

    • 열거 타입은 정수 상수 열거 패턴의 단점을 모두 극복한다.
      • 타입 안정성 보장. 
      • 디버깅 시, 표현력 향상. 
      • 같은 열거 그룹 내의 순회하는 방법 제공. 
    • 하나의 메서드가 상수별로 다르게 동작해야 할 때, Swtich 문 대신 추상 메서드를 선언해서 각 열거형 인스턴스마다 다르게 동작하도록 할 수 있음. 
    • 상수별로 다르게 동작해야 할 때 Switch, 추상 메서드로 너무 복잡해지면 전략 패턴을 이용해 볼 수 있음.
    • 열거 타입은 컴파일 시점에 어떤 원소가 있을지 명확하다면 사용하면 좋음. 
    • 널리 쓰이는 열거 타입은 Top Level 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 멤버 클래스로 만든다. 

    정수 열거 패턴 기법

    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;

    위처럼 정수 상수를 한 묶음 선언해서 사용하던 것을 '정수 열거 패턴'이라고 한다. 이 패턴에는 어떤 단점이 있을까? 

    • 타입 안전을 보장하지 않음. (컴파일 에러보다 런타임 에러가 많이 발생함)
    • 디버깅 시, 표현력도 좋지 않음.
    • 같은 정수 열거 그룹 내의 상수를 순회하는 방법도 마땅하지 않음. 

     

    타입 안전을 보장하지 않음. 

    타입 안전을 보장하지 않는다는 것은 '타입 문제'에 대해서 컴파일 에러로 잡아내지 못한다는 것이다. 단적인 예로 APPLE 타입이 필요한 곳에 ORANGE를 전달하더라도 컴파일러는 'int 타입이 잘 주어졌구나'로 이해를 해서 컴파일 에러가 발생하지 않는다. 

     

    디버깅 시, 표현력도 좋지 않음. 

    이 값이 전달되면 런타임에서는 단순히 '숫자'로 표현된다. 예를 들어 어떤 메서드가 APPLE_FUJI라는 값을 받았고, 이 값을 로그로 찍어보거나 (log.info), 디버그 모드로 값을 찍어봐도 '1'이라는 숫자만 보인다. 1이라는 값이 무엇을 의미하는지 개발자는 정확히 알 수 없다는 문제가 생긴다. 

     

    같은 정수 열거 그룹 내의 상수를 순회하는 방법도 마땅하지 않음. 

    10개의 public final static int 변수가 하나의 열거 그룹이라고 가정을 해보자. 딱히 이 열거 그룹을 순회하는 방법은 존재하지 않으며, 순회를 위해 또 다른 코드를 구현해야 한다. 


    열거 타입 (enum)의 등장

    정수 열거 패턴, 문자열 열거 패턴의 단점을 해결하기 위해 자바는 열거 타입 (enum)을 도입했다. 자바의 enum은 다음 특징을 가진다.

    • 자바의 enum은 클래스임. 
      • 따라서 타입 안전이 보장됨. 
    • enum은 열거 상수 하나당 인스턴스를 만들어 public static final 필드로 공개함. 
    • enum은 밖에서 접근 가능한 생성자를 제공하지 않으므로 열거 상수별로 하나의 인스턴스만 존재함 (싱글톤)
    • 적절한 toString()을 구현해서 제공함. 
    • 클래스이기 때문에 내부적으로 사용할 메서드, 외부에서 사용할 메서드를 제공할 수 있음.

    위에서 사용한 정수 열거 패턴은 아래처럼 enum으로 치환해서 사용할 수 있다.

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

    정수 열거 패턴에서 가장 큰 문제가 되던 '타입 안전'은 enum을 사용하면서 완전히 해결된다. 예를 들어 아래 메서드에 Orange enum에 해당되는 NAVEL, TEMPLE, BLOOD를 제공하려고 한다면 컴파일러는 컴파일 에러를 발생시킬 것이다. 

    public void hello(Apple something){...}

    디버깅 시에 표현력이 좋지 않은 문제점도 완전히 해결된다. enum은 기본적으로 toString()의 결과로 자기 자신의 열거형 상수를 출력한다. 이전에는 APPLE_FUJI가 런타임에서 '0'이라는 값을 보여주었지만, enum을 사용하게 되면 FUJI는 런타임에서 'FUJI'로 보이게 된다. 


    열거 타입에는 임의의 메서드 / 필드 추가 및 인터페이스 구현도 가능함. 

    아래와 같이 열거 타입은 임의의 메서드와 필드를 가질 수 있으며, 더 나아가서 인터페이스도 구현할 수 있다.

    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 radius;
        private final double surfaceGravity;
    
        private static final double G = 6.67330E-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 * this.surfaceGravity;
        }
    }

    다음과 같이 구현하면 좋다.

    • 열거 타입은 근본적으로 불변이기 때문에 필드는 final로 선언한다.
    • private 필드로 선언하고, public 함수로 열어서 접근할 수 있도록 한다. 
    • 열거 타입 내부 / 패키지에서만 사용될 것은 private, package-private 메서드로 구현한다.
    • 널리 쓰이는 열거 타입은 Top Level 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 멤버 클래스로 만든다. 

    상수마다 동작이 달라져야 하는 열거 타입 

    상수마다 동작이 달라져야 하는 열거 타입이라면 다음 두 가지 방법을 이용해서 구현해 볼 수 있다.

    1. Switch 문을 이용해 상수 타입마다 다르게 동작. 
    2. 열거 타입 내부에 추상 메서드를 선언해두고 각 상수의 바디에서 이 메서드를 구현해서 사용 

    아래는 Switch 문을 이용해 구현한 것이다. 두 가지 문제점이 있다.

    1. 또 다른 상수 (PLUS 같은)가 추가되었는데, switch 문에 구현하는 것을 빼먹을 수 있음. 따라서 견고하지 않음.
    2. 코드가 이쁘지 않음. 기술적으로 throw 부분은 도달할 수 없는데, 반드시 return 값이 필요하지만 돌려줄 값이 없으므로 에러를 던짐. 
    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("unknown operation : " + this);
        }
    }

    아래  부분은 열거 타입에 추상 메서드를 추가하면서 처리할 수 있게 된다. 추상 클래스를 이용하면서 두 가지 문제점이 모두 해결된다. 가장 좋은 변화는 견고한 코드가 된다는 것이다.

    추상 메서드로 선언하게 되면서 새로운 열거형 상수가 들어왔을 때, 추상 메서드를 구현해주지 않는 이상 컴파일 에러가 발생한다. 따라서 개발자가 깜빡하고 빼먹을 수 있는 부분까지 컴파일러가 잡아주기 때문에 더 견고한 코드가 된다.

    public enum OperationAbstract {
        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);
    }

     


    상수마다 동작이 달라져야 하는 열거 타입  - 2

    위의 코드에서 내부 상수를 가지는 방향으로 코드를 추가하면, 조금 더 활용성 있게 사용할 수 있다.

    • 기본적으로 위 클래스의 추상 메서드를 그대로 가져감. (견고한 코드) 
    • 필드에 Operation을 상징하는 Symbol을 필드로 받아서, 출력하는데 유용하게 사용할 수도 있음. 
    public enum OperationAbstractWithField {
        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;
    
        OperationAbstractWithField(String symbol) {
            this.symbol = symbol;
        }
    
        public String symbol() {
            return this.symbol;
        }
        
        public abstract double apply(double x, double y);
    
    }

    열거 타입의 toString()을 재정의 하려거든, fromString 메서드도 함께 제공하자. 

    열거 타입의 toString()을 재정의하는 경우, toString()이 반환하는 문자열을 Enum 상수로 변환해주는 fromString 메서드도 함께 제공해주는 것이 좋다. EnumFromValue 열거 타입은 다음 순서로 초기화 된다.

    1. 각 열거 상수에 대한 인스턴스 생성
    2. static 필드 초기화

    열거 상수 인스턴스가 생성되는 시점에 생성자를 이용해 자기 자신 (this)를 넣어주는 방법은 이용할 수 없다. 왜냐하면 열거 상수 인스턴스가 생성되는 시점은 아직 static 필드들이 초기화 되기 직전이기 때문이다. 

    public enum EnumFromValue {
        
        A, B, C, D;
    
    
        public static final Map<String, EnumFromValue> stringToEnum =
                Stream.of(values())
                        .collect(
                                Collectors.toMap(Enum::toString, e -> e)
                        );
    
        public static Optional<EnumFromValue> fromString(String symbol) {
            return Optional.ofNullable(stringToEnum.get(symbol));
        }
        
    }

    위와 같이 구현해서 사용할 수 있다.


    열거 타입의 단점 → 열거 타입 상수 인스턴스끼리 코드 공유가 어려움 

    열거 타입끼리는 비슷한 코드를 공유해서 사용하기가 어렵다. 무슨 말이냐면, 아래 코드를 살펴보면 된다. 아래 코드는 직원의 기본 임금 + 그 날 일한 시간을 넣어주면, 일당이 얼마인지 계산하는 클래스다. 그리고 일당은 평일 / 주말마다 다르게 책정되기 때문에 스위치 문에서 구현되어 있다. 

    그런데 이 코드는 견고하지 않다는 문제점이 있다. 예를 들어서 Holiday(공휴일)이라는 새로운 열거 상수를 추가했을 때, pay() 메서드 내부에 추가하는 것을 빼먹을 수 있다. 컴파일 에러 같은 것들로 강제되어 있지 않기 때문에 실수할 여지가 커진다는 것이다. 

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

    앞서 Switch 문 대신 추상 메서드를 추가해서 처리하는 방법 역시 있었다. 그러나 이 방법은 코드가 불필요하게 장황해져서 가독성을 떨어뜨린다. 아래 코드를 살펴보면 바로 알 수 있다. 

    이것이 '열거형 인스턴스 상수끼리는 코드를 공유할 수 없기 때문이다'의 의미라고 생각한다. 열거형 상수는 각기 달라지는 행동을 표현하기 위해서 자기 자신의 내부에서 각각 코드를 구현해야 하는데, 그 코드가 공통되는 경우에 하나로 모아서 사용할 수 없게 된다. 

    public enum PayrollDayWithAbstract {
    
        MONDAY{
            public int pay(int minutesWorked, int payRate) {
                int basePay = minutesWorked * payRate;
                int overtimePay = minutesWorked <= MINS_PER_SHIFT ?
                        0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
                return basePay + overtimePay;
            }
        }, 
        TUESDAY{ 
            public int pay(int minutesWorked, int payRate) {
                int basePay = minutesWorked * payRate;
                int overtimePay = minutesWorked <= MINS_PER_SHIFT ?
                        0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
                return basePay + overtimePay;
        }}, 
        ...
        SUNDAY{
            public int pay(int minutesWorked, int payRate) {
                return 0;
            }
        };
    
        private static final int MINS_PER_SHIFT = 8 * 60;
        public abstract int pay(int minutesWorked, int payRate);
    }

     


    열거 타입 상수 인스턴스끼리 코드 공유가 어려움  → 전략 패턴으로 해결

     앞서 공통된 코드를 열거형 인스턴스끼리 공유할 수 없어서 코드가 장황해지는 문제가 있었다. 이 부분은 전략 패턴을 사용하면 바로 해결할 수 있게 된다. 

    1. 공통된 코드는 멤버 클래스로 새로운 enum(PayType)을 만들어서 그쪽으로 추출한다.
    2. 탑 클래스 enum은 자신의 타입을 새롭게 만들어진 enum(PayType)을 만들어서 어떻게 행동할지 정하면 된다. 실제 필요한 메서드의 호출은 Deligation을 통해서 처리하면 됨. 

    이렇게 만들어진 패턴은 단순히 Switch 문을 사용하는 것보다는 복잡하지만, 컴파일 에러를 미리 발생시키면서 안전하게 확장할 수 있다는 장점이 있다.

    public enum PayrollDayWithStrategy {
    
        MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY), THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
        SATURDAY(WEEKEND), SUNDAY(WEEKEND);
    
        private final PayType payType;
    
        PayrollDayWithStrategy(PayType payType) {
            this.payType = payType;
        }
    
        int pay(int minutesWorked, int payRate) {
            return payType.pay(minutesWorked, payRate);
        }
    
        enum PayType {
            WEEKDAY{
                int overtimePay(int mins, int payRate) {
                    return  mins <= MINS_PER_SHIFT ?
                            0 : (mins - MINS_PER_SHIFT) * payRate / 2;
                }
            }, 
            WEEKEND{
                int overtimePay(int mins, int payRate) {
                    return (mins * payRate) / 2;
                }
            };
    
            abstract int overtimePay(int mins, int payRate);
    
            private static final int MINS_PER_SHIFT = 8 * 60;
    
            int pay(int minutesWorked, int payRate) {
                int basePay = minutesWorked * payRate;
                int overTimePay = overtimePay(minutesWorked, payRate)
                return basePay + overTimePay;        
            }
        }
    }

     


    열거 타입은 언제 사용할까?

    필요한 원소를 컴파일 타임에 알 수 있다면, 우선은 열거 타입을 사용해라. 예를 들면 요일같은 것이 적당한데, 월 ~ 일로 정해져있으며 다른 요일은 존재하지 않기 때문에 컴파일 시점에 사용될 요일을 모두 알 수 있게 된다. 


    열거 타입에서 제공하는 메서드

    • values()
    • valueOf()

    댓글

    Designed by JB FACTORY