Java/Effective Java 3E

[이펙티브자바 3판] ITEM34. int 상수 대신 열거 타입을 사용하라

잭피 2020. 10. 28. 00:18

이번장의 핵심은...

열거 타입은 확실히 정수 상수보다 뛰어나다

더 읽기 쉽고 안전하고 강력하다

대다수 열거 타입이 명시적 생성자나 메서드 없이 쓰이지만,

각 상수를 특정 데이터와 연결짓거나 상수마다 다르게 동작하게 할 때는 필요하다

드물게는 하나의 메서드가 상수별로 다르게 동작해야 할 때도 있다

이런 열거 타입에서는 switch 문 대신 상수별 메서드 구현을 사용하자

열거 타입 상수 일부가 같은 동작을 공유한다면 전략 열거 타입 패턴을 사용하자


정수 열거 패턴

자바에서 열거 타입을 지원하기 전에는 정수 상수를 한 묶음 선언해서 사용

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

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

이런 정수 열거 패턴 기법에는 단점이 많다

  • 타입 안전을 보장할 방법이 없으며 표현력도 좋지 않음

    오렌지를 건네야 할 메서드에 사과를 보내도 값이 같으면 컴파일러는 아무런 경고 메시지를 출력하지 않음

  • 이름공간을 지원하지 않아 접두어를 써서 충돌을 방지해야함

    ex) MERCURY → ELEMENT_MERCURY, PLANET_MERCURY

  • 프로그램이 깨지기 쉽다

    상수 값이 바뀌면 클라이언트도 반드시 다시 컴파일해야 한다

  • 정수 상수는 문자열로 출력하기가 까다롭다

    그 값을 출력하거나 디버거로 살펴보면 의미가 아닌 숫자로만 보임

열거 타입 (Enum type)

위와 같은 열거 패턴의 단점을 모두 없애며 여러 장점이 있음

// 가장 단순한 열거 타입
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
  • 자바의 열거 타입은 완전한 형태의 클래스이다

  • 열거 타입 자체는 클래스이고, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개

  • 밖에서 접근할 수 있는 생성자는 제공하지 않음 (사실상 final)

    → 따라서 클라이언트가 인스턴스를 직접 생성, 확장할 수 없으니 열거 타입 선언으로 만들어진 인스턴스들은 딱 하나씩만 존재함이 보장 (싱글턴)

  • 컴파일타임 타입 안정성을 제공 (Apple, Orange..)

  • 각자의 이름공간이 있어서 이름이 같은 상수도 평화롭게 공존한다

  • 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 된다

  • toString 메서드는 출력하기에 적합한 문자열을 내어준다

  • 임의의 메서드나 필드를 추가할 수 있고, 임의의 인터페이스를 구현하게 할 수도 있다

열거 타입에 메서드나 필드를 추가?

예) 태양계의 여덟 행성

각 행성에는 질량과 반지름이 있고, 이 속성을 이용해 표면중력을 계산할 수 있다

 

열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아서 인스턴스 필드에 저장하면 된다

- 열거 타입은 정적 메서드인 values를 제공 (정의된 상수들의 값을 배열로 담아 반환) 

- 열거 타입에서 상수를 하나 제거해도 클라이언트에는 아무 영향 없다 (제거한 상수를 참조하지 않는다면)

- 만약 참조하고 있더라고 다시 컴파일을 하거나 런타임에 유용한 예외가 발생할 것이다

(정수 열거 패턴에서는 기대할 수 없는 예외)

- 열거 타입 상수는 자신을 선언한 클래스 혹은 패키지에서만 사용할 수 있는 기능을 담게 된다

일반 클래스처럼 그 기능을 클라이언트에 노출해야 할 합당한 이유가 없다면 private or package-private로 선언

- 널리 쓰이는 열거 타입은 톱레벨 클래스로 만들자

- 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스로 만들자

- 상수마다 동작이 달라져야 하는 상황

예) 사칙연산 계산기

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

동작은 하지만 좋지 않음

- 코드가 예쁘지 않고, throw 문은 실제로 도달할 일이 없지만 생략하면 컴파일이 안됨

- 더 나쁜점은 깨지기 쉬운 코드이다

- 새로운 상수를 추가할 때마다 case문을 추가해야 한다

- 더 나은 수단

// 상수별 메서드 구현을 활용한 열거 타입
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 메소드가 상수 선언 옆에 붙어 있으니 새로운 상수 추가 시, 같이 재정의하기 쉽다

- 또한, apply가 추상 메서드이니 재정의하지 않으면 컴파일 오류로 알려줌

- 상수별 메서드 구현을 상수별 데이터와 결합할 수도 있다

// 상수별 클래스 몸체(class body)와 데이터를 사용한 열거 타입
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);

- toString을 재정의할 때, toString이 반환해주는 문자열을 해당 열거 타입 상수로 변환해주는 fromString 메서드도 함께 제공하는 걸 고려해보자

(단, 타입 이름을 적절히 바꿔야하고 모든 상수의 문자열 표현이 고유해야 한다)

// 열거타입용 fromString 메서드
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));
}

Operation 상수가 stringToEnum 맵에 추가되는 시점은 열거 타입 상수 생성 후, 정적 필드가 초기화될 때다

열거 타입의 정적 필드 중 열거 타입의 생성자에서 접근할 수 있는 것은 상수 변수뿐

  • 열거 타입 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전이라, 자기 자신을 추가하지 못하게 하는 제약이 꼭 필요하다

    이 제약의 특수한 예) 열거 타입 생성자에서 같은 열거 타입의 다른 상수에도 접근할 수 없다

  • fromString이 그냥 Operation이 아닌 Optional<Operation>을 반환하는 점도 주의

    주어진 문자열이 가르키는 연산이 존재하지 않을 수 있음을 클라이언트에 알리고, 그 상황을 클라이언트에게 대처하도록 한 것

 

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

예) 급여명세서에서 쓸 요일을 표현하는 열거 타입

// 값에 따라 분기하여 코드를 공유하는 열거 타입 - 좋은 방법인가?
enum PayrollDay {
	MONDAY, TUSEDAY, 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;
	}
}

간결하지만, 관리 관점에서 위험한 코드

- 휴가와 같은 새로운 값을 추가하면 그 값을 처리하는 case 문을 같이 추가해야함

 

- 상수별 메서드 구현으로 급여를 정확히 계산하는 방법

1. 잔업 수당을 계산하는 코드를 모든 상수에 중복해서 넣으면 된다

2. 계산 코드를 평일용과 주말용으로 나눠 각각을 도우미 메서드로 작성한 다음 각 상수가 자신에게 필요한 메서드를 적절히 호출

-> 두 방식 모두 코드가 장황해져 가독성이 크게 떨어지고 오류 발생 가능성이 높음

 

- 가장 깔끔한 방법

새로운 상수를 추가할 때 잔업수당 '전략'을 선택하도록 하는 것

잔업 수당 계산을 private 중첩 열거 타입(PayType)으로 옮기고 PayrollDay 열거 타입의 생성자에서 이 중 적당한 것을 선택한다

→ PayrollDay 열거 타입은 잔업수당 계산을 그 전략 열거 타입에 위임하여, switch 문이나 상수별 메서드 구현이 필요 없게 된다

이 패턴은 switch 문보다 복잡하지만 더 안전하고 유용하다

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

switch 문은 열거 타입의 상수별 동작을 구현하는 데 적합하지 않다

 

하지만, 기존 열거 타입에 상수별 동작을 혼합해 넣을 때는 switch 문이 좋은 선택이 될 수 있다

서드 파티에서 가져온 Operation 열거 타입이 있는데, 원래 열거 타입에 없는 기능을 수행

// switch 문을 이용해 원래 열거 타입에 없는 기능을 수행
public static Operation inverse(Operation op) {
	switch(op) {
        case PLUS:   return Operation.MINUS;
        case MINUS:  return Operation.PLUS;
        case TIMES:  return Operation.DIVIDE;
        case DIVIDE: return Operation.TIMES;

        default:  throw new AssertionError("Unknown op: " + op);
    }
}
  • 추가하려는 메서드가 의미상 열거 타입에 속하지 않으면 이 방식을 적용하는 게 좋음 (직접 만든게 아니라도)
  • 열거 타입 안에 포함할 만큼 유용하지 않은 경우도 괜찮음
  • 열거 타입을 메모리에 올리는 공간과 초기화하는 시간이 들긴 하지만 체감될 정도는 아님

열거 타입은 언제 쓰지?

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

 

열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다

나중에 상수가 추가돼도 바이너리 수준에서 호환되도록 설계되었다

 

 

 

이 글은 “이펙티브 자바 3판” 책 내용을 정리한 글입니다.

만약 저작권 관련 문제가 있다면 “shk3029@kakao.com”로 메일을 보내주시면, 바로 삭제하도록 하겠습니다.