스터디/이펙티브 자바

[Effective Java] Item34. int 대신 Enum

📝 작성 : 2022.06.19  ⏱ 수정 : 
728x90

정수 열거 패턴(int enum pattern) - 사용 X

열거 타입이 생기기 전 공통적으로 사용되는 상수를 한데 묶어서 사용했었습니다.

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_NAVLE  = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD  = 2; 

이런 식으로 사용한 것을 정수 열거 패턴(int enum pattern)이라고 합니다.

정수 열거 패턴의 단점

  1. 타입 안전을 보장할 방법이 없습니다.
  2. 표현력도 좋지 않습니다.
  3. 오렌지를 건네야할 메서드에 사과를 보내고 동등 연산자(==)로 비교하더라도 컴파일 오류가 발생하지 않습니다.
  4. 별도 이름공간을 지원하지 않기 때문에 접두어를 써서 이름 충돌을 방지해야 합니다.
  5. 값을 출력하거나 디버깅 할 때 의미가 아닌 단순한 숫자로만 보이기 때문에 큰 도움이 되지 않습니다.
  6. 같은 그룹(APPLE그룹 또는 ORANGE그룹)에 속한 모든 상수를 한 바퀴 순회하는 방법이 없습니다.
  7. 같은 그룹에 속한 상수가 몇 개인지 알 수 없습니다.

열거 타입(Enum type)

위와 같은 정수 열거 패턴의 단점을 해결하기 위해 열거 타입을 사용합니다.

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

열거 타입 자체가 클래스이며, 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final필드로 공개합니다.(외부에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 final입니다.)
클라이언트가 인스턴스를 직접 생성하거나 확장 할 수 없으니 열거 타입 인스턴스들은 딱 하나씩만 존재합니다.

열거 타입의 장점

  1. 컴파일타임 타입 안전성을 제공 - getApple(Apple apple) 메서드에 Orange의 값을 넘기려하면 컴파일 오류가 발생합니다.
  2. 이름공간이 있어 이름이 같은 상수도 사용 가능합니다.
  3. 새로운 상수를 추가하거나 순서를 바꿔도 다시 컴파일하지 않아도 됩니다.
  4. 열거 타입의 toString 메서드는 이쁘게 출력해줍니다.
  5. 열거 타입에 속한 모든 상수를 한 바퀴 순회할 수 있습니다.
  6. 열거 타입에 속한 상수가 몇 개인지 알 수 있습니다.

이처럼 정수 열거 패턴의 단점을 모두 보완해줍니다. 여기에 더해 열거 타입에는 임의의 메서드나 필드를 추가할 수 있고 임의의 인터페이스를 구현하게 할 수도 있습니다.

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

public enum Planet {
    MERCURY(3.302e+23, 2.439e6),
    VENUS(4.869e+24, 6.052e6),
    EARTH(5.975e+24, 6.378e6),
    PLUTO(1.3e+22, 1.188e6);

    private final double mass; // 질량(kg)
    private final double radius; // 반지름(m)
    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;
        this.surfaceGravity = G * mass / (radius * radius);
    }

    public double surfaceWeight(double mass) {
        return mass * surfaceGravity;
    }

    public double mass() {
        return mass;
    }

    public double radius() {
        return radius;
    }

    public double surfaceGravity() {
        return surfaceGravity;
    }
}

열거 타입은 불변이라 모든 필드는 fianl이어야 합니다.
필드의 접근 제한은 무엇이든 상관없지만 private으로 두고 별도의 public 접근자 메서드를 두는 것이 좋습니다.

public static void main(String[] args) {
    double weightInEarth = 185;
    double mass = weightInEarth / Planet.EARTH.surfaceGravity();
    for (Planet p : Planet.values()) {
        System.out.printf("%s에서의 무게는 %f이다.%n", p, p.surfaceWeight(mass));
    }
}

결과
MERCURY에서의 무게는 69.912739이다.
VENUS에서의 무게는 167.434436이다.
EARTH에서의 무게는 185.000000이다.
PLUTO에서의 무게는 11.601478이다.

여기서 PLUTO(명왕성)은 태양계에서 퇴출되었으므로 이를 제거해보겠습니다. 명왕성을 제거해도 위의 코드(PLUTO를 참조하지 않은 클라이언트)에는 아무런 영향이 없습니다. 단지 출력시 PLUTO에서의 무게는 출력되지 않을 뿐 입니다.

PLUTO를 참조하는 클라이언트는 클라이언트 프로그램을 컴파일하면 PLUTO 참조부분에서 오류가 발생할 것이고, 클라이언트를 다시 컴파일하지 않으면 런타임에 예외가 발생할 것입니다.

상수별 메서드

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

위 코드는 몇 가지 문제가 있습니다. 우선, 정상적인 상황이라면 실제로는 AssertionError에 도달할 일이 없지만 이를 생략하면 컴파일에러가 발생합니다.
만약 제곱근을 구하기 위해 SQRT란 상수를 추가했지만 apply 메서드에 이를 추가하지 않을 수도 있습니다.

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라는 추상 메서드를 선언하고 각 상수에서 알맞게 구현하는 방법으로 코드를 작성하면 필요없는 AssertionError를 작성할 일도 없고 새롭게 상수가 추가되더라도 apply 메서드를 빼놓을 일이 없습니다. 이렇게 구현한 것을 상수별 메서드 구현이라고 합니다.

상수별 메서드 구현과 상수별 데이터의 결합

public static void main(String[] args) {
    double x = 4;
    double y = 2;
    for (Operation operation : Operation.values()) {
        System.out.printf("%.0f %s %.0f = %.0f%n", x, operation, y, operation.apply(x, y));
    }
}

결과
4 PLUS 2 = 6
4 MINUS 2 = 2
4 TIMES 2 = 8
4 DIVIDE 2 = 2

Operation의 toString을 재정의하여 연산 기호를 반환하도록 만들어보겠습니다.

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

결과
4 + 2 = 6
4 - 2 = 2
4 * 2 = 8
4 / 2 = 2

valueOf()

열거 타입에는 상수 이름을 입력받아 그 이름에 해당하는 상수를 반환해주는 valueOf() 메서드가 자동 생성됩니다.

public static void main(String[] args) {
    System.out.println(Operation.valueOf("PLUS"));
}

결과
PLUS

fromString()

toString 메서드를 재정의하면 toString이 반환하는 문자열(+, -, *, /)을 열거타입의 상수로 변환해주는 fromString 메서드도 함께 제공하는 것을 고려하는 것이 좋습니다.

public static Optional<Operation> fromString(String symbol) {
    return Optional.ofNullable(stringToEnum.get(symbol));
}

private static final Map<String, Operation> stringToEnum = Stream.of(values())
        .collect(Collectors.toMap(Object::toString, e -> e));

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

반응형