이펙티브 자바, 34. int 상수 대신 열거 타입을 사용하라

열거타입과 기존 열거 패턴의 문제점

  • 열거타입, enum은 일정 개수의 상수를 정의한 다음, 그 외의 값을 허용하지 않는 타입이다.
  • 기존에는 아래와 같은 방식으로 정수 상수를 정의하였다.
public class LegacyEnumPattern {
    public static final int SUCCESS = 0;
    public static final int ERROR = 1;

    public static final String HIS_NAME = "kim";
    public static final String HER_NAME = "choi";
}
  • 정수 열거 타입(int enum pattern)은 많은 문제를 가진다.
  • 값으로 관리되기 때문에 깨지기 쉽다.
  • 로그나 통신 과정에서는 정수로 표현되기 때문에 직관적으로 그것이 무엇인지 파악하기 어렵다.
  • 상수의 갯수를 통제하기 어렵다.
  • 컴파일러 과정에서 문제를 찾을 수 없다.
  • 문자열로 할 경우 문자열 비교를 해야하는 성능 상 문제가 발생한다.
  • 하드코딩을 하는 문제가 발생한다.

  • enum이 있는 현대 자바에서는 기존의 열거패턴을 사용할 이유가 없다.
public enum Enum {
    FUJI, PIPPIN, GRANNY_SMITH;
}
  • 열거 타입 각각은 모두 클래스이다.
  • 오류에 대해서 컴파일러 시점에서 찾아낼 수 있다. 클라이언트 오류 역시 컴파일러 시점에서 확인 가능하다.
  • 싱글턴을 객체를 보장한다. 각각의 열거타입은 public static final 필드의 객체로서 메모리에 적재되어 사용된다.
  • 클래스로서 다양한 기능과 메서드 등을 추가할 수 있다.
  • enum의 갯수를 파악할 수 있다.(values())

enum의 활용. 메서드와 필드

  • enum은 아래와 같은 형태로 필드, 메서드, 생성자 등을 활용 가능하다. 기존의 클래스와 큰 차이를 가지지 않는다.
  • 상수 각각이 final 속성을 가지는 것처럼 필드 역시도 그러하다. 그러나 응집도를 위하여 필드는 public 으로 바로 공개하지 않는다.
  • 상수를 추가하거나 삭제할 경우 클라이언트는 컴파일러 에러를 통해 문제를 확인할 수 있다.
  • values()를 통해 모든 상수에 접근할 수 있다.
public enum Planet {
    // 아래의 인자는 임의로 막 작성했다ㅠ
	MERCURY(100000, 324),
	VENUS(2342, 334),
	EARTH(12343, 2342),
	MARS(345, 2342),
	JUPITER(34543, 234432),
	SATURN(3434, 343),

	// 상수 하나를 제거하더라도 전혀 문제가 없다.
	// 클라이언트에서 참조한다면 컴파일 에러를 발생한다.
	// URANUS(12343, 34),

	NEPTUNE(3245435, 2342);

	// 필드는 기본적으로 final 이다.
	private final double mass;
	private final double radius;
	private final double surfaceGravity;

	private static final double G = 6.213123213;

	// 생성자로 객체를 생성하여 사용한다.
	Planet(double mass, double radius){
		this.mass = mass;
		this.radius = radius;
		surfaceGravity = G*mass/(radius*radius); // 성능 최적화를 위하여 생성자 때 계산을 한다.
	}

	// 필드를 public으로 공개하지 않고, 메서드를 통해 값을 리턴한다.
	// 열거타입은 getMass 가 아닌 mass() 형태로 getter를 연다.
	public double mass() {
		return mass;
	}

	public double radius() {
		return radius;
	}

	public double surfaceGravity() {
		return surfaceGravity;
	}
}
@Test
void test() {
    System.out.println("Planet.EARTH.mass() = "+ Planet.EARTH.mass());
    
    for(Planet p : Planet.values()) {
        System.out.println(p+" : "+p.surfaceGravity());
    }
}

상수에 따른 로직의 구현

  • 열거타입에 공통의 기능을 부여하고, 각각의 상수마다 가지는 기능을 달리 할 수 있다. 이런 방식으로 유연한 코딩이 가능하며 그것의 예제는 아래와 같다.
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();
	}

	public static void main(String[] args) {
		Double result = Operation.TIMES.apply(4, 10);
		System.out.println("result : "+result);
	}
}
  • 위의 코드를 보면 switch를 통해 분기하여, 상수마다의 기능을 달리함을 확인할 수 있다.
  • 다만, 상수을 새롭게 정의할 경우 switch를 재정의해야 하는 등 깨지기 쉬운 메서드임은 분명하다.

  • 이보다 더 유연하게 코드를 작성하면 아래와 같다.
public enum Operation2 {
	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;}
	};

	// switch의 분기는 깨지기 쉬운 메서드이다. 상수의 추가에 따라 변동이 심하다.
	// 각 객체마다의 매서드를 정의하여 더 좋은 코드를 구현한다.
	// abstract 을 통해 코드 구현을 강제한다.
	public abstract double apply(double x, double y);


	public static void main(String[] args) {
		Double result = Operation2.TIMES.apply(4, 10);
		System.out.println("result : "+result);
	}
}
  • abstract 메서드를 추가하여 각 각의 상수마다 메서드 구현을 강제한다. 컴파일 시점에서 문제를 확인할 수 있다.

  • 열거타입은 문자열을 통해 상수에 접근할 수 있는 기능(valueOf())를 제공한다.
  • 하지만 상수 그 자체의 이름보다 필드값으로 접근하고 싶을 수 있다. 이때는 아래와 같은 방식으로 코드를 작성한다.
public enum Operation3 {
    // 각각의 상수마다 symbol을 정의한다. 더하기는 + 이다.
	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;

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

	public String symbol() {
		return symbol;
	}

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

	// 아래의 정적타입필드는 열거타입 상수 생성 후이다. 그러므로 아래의 코드는 의도한대로 정상 동작한다.
    private static final Map<String, Operation3> stringToEnum =
    		Stream.of(values()).collect(Collectors.toMap(e -> e.symbol(), e -> e));

    public static Optional<Operation3> fromString(String symbol){
    	return Optional.ofNullable(stringToEnum.get(symbol));
    }
}
  • 열거타입의 컴파일 방식은 각각의 상수를 객체(인스턴스)로 초기화 한 후, 각각의 객체를 필드값으로 가지는 형태이다.
  • 정적타입필드는 상수의 초기화 후 동작한다. 그러므로 stringToEnum의 맵 컬렉션은 정상적으로 값이 들어간다.
  • fromString 메서드를 통해, 필드값으로 원하는 상수에 접근할 수 있다.
@Test
void test2() {
    // valueOf로 객체를 꺼낼 수 있지만
    System.out.println("Operation3.valueOf(\"MINUS\"); = "+Operation3.valueOf("MINUS"));

    // fromString이란 메서드로 구현할 수 있다.
    System.out.println("Operation3.fromString(\"-\") = "+ Operation3.fromString("-"));
    System.out.println("Operation3.fromString(\"+\") = "+ Operation3.fromString("+"));
    System.out.println("Operation3.fromString(\"&\") = "+ Operation3.fromString("&"));
}

공유 로직과 개별 로직의 구현, 전략 열거 타입 패턴

  • 앞서의 예제는 apply() 라는 공통의 로직을 각각의 상수에서 구현하였다.
  • 공통의 로직과 상수 마다의 로직을 분리할 수는 없을까?

  • 아래의 예제는 근무시간에 따른 임금을 계산하는 상수 타입이다.
  • 주말에 대해서는 switch로 분기하여 오버타임에 대한 추가 수당을 구현하였다.
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;
	}
}
  • 열거타입에 대한 열거타입을 구현하여 좀 더 유연한 코드를 작성할 수 있다.
  • 이러한 방식을 전략 열거 타입 패턴이라 하며 그 코드는 아래와 같다.
public enum PayrollDay2 {
	MONDAY,TUESDAY,WEDNESDAY,THURSDAY,FRIDAY
	,SATURDAY(WEEKEND),SUNDAY(WEEKEND);

    private final PayType payType;

	PayrollDay2(){
		this.payType = WEEKDAY;
	}

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

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

	// 전략 열거 타입 패턴
	// 열거 타입의 열거 타입을 정의하고, 해당 열거 타입에 대한 매서드를 구현한다.
	enum PayType{

		// switch를 사용하지 않아 안전하고 유연하다.
		WEEKDAY {
			int overtimePay(int minutesWorked, int payRate) {
                return minutesWorked <= MINS_PER_SHIFT ? 0 :
                    (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
			}
		}, WEEKEND{
			int overtimePay(int minutesWorked, int payRate) {
                return minutesWorked * payRate / 2;
			}
		};

		// 차이를 가지는 오버타임에 대해서만 구현 객체로 만든다.
		abstract int overtimePay(int minutesWorked, int payRate);

		private static final int MINS_PER_SHIFT = 8*60;

		int pay(int minutesWorked, int payRate) {
			int basePay = minutesWorked * payRate;
			return basePay + overtimePay(minutesWorked, payRate);
		}

	}    
}
  • 결과가 동일하게 나오는 것을 확인할 수 있다.
@Test
void test_v1() {
    System.out.println("PayrollDay.FRIDAY.pay(60*7, 10) = "+PayrollDay.FRIDAY.pay(60*10, 10));
    System.out.println("PayrollDay.SUNDAY.pay(60*7, 10) = "+PayrollDay.SUNDAY.pay(60*10, 10));
}

@Test
void test_v2() {
    System.out.println("PayrollDay.FRIDAY.pay(60*7, 10) = "+ PayrollDay2.FRIDAY.pay(60*10, 10));
    System.out.println("PayrollDay.SUNDAY.pay(60*7, 10) = "+PayrollDay2.SUNDAY.pay(60*10, 10));
}

정리

  • 필요한 원소를 컴파일 타임에 다 알 수 있는 상수집합이라면 항상 열거 타입을 사용한다.