이펙티브 자바 #item38 확장할 수 있는 열거 타입이 필요하면 인터페이스를 사용하라

임현규·2023년 3월 23일
0

이펙티브 자바

목록 보기
38/47
post-thumbnail

열거 타입은 기본적으로 확장이 불가능하다.

기본적으로 enum은 자바에서 제공하는 완벽한 싱글턴 형태이다. 컴파일 시점에 이미 인스턴스를 생성하고 이를 접근하는 식으로 사용한다. 그러나 이것에 상속과 같은 기능으로 확장한다면 가능은 하겠지만, 싱글턴 취지에 맞지 않다.

예를 들어 보자. 만약 상속을 허용하는 경우, 하위 클래스는 부모 클래스의 생성자를 호출한다. 이 때 부모 클래스의 생성자는 유일한 인스턴스를 생성하는 메서드를 호출한다. 그렇기에 사실상 2개의 인스턴스를 호출하지는 않는다.

그러나 하위 클래스에서 인스턴스를 생성하는 경우, 부모 클래스를 생성하기 전에 하위 클래스의 생성자에서 새로운 인스턴스를 생성할 수 있고 보통 싱글턴의 경우 클래스와 독립적으로 static을 활용해 인스턴스를 관리한다. static 인스턴스의 경우 static 메서드로만 접근할 수 있고 이 또한 상속을 허용하지 않는다. 어쩌면 당연한 것이 static은 클래스에 종속된 modifier가 아니기 때문이다.

class Parent {

    public static final Parent HELLO;
    public static final Parent WORLD;
    private static final Parent[] values;

    static {
        HELLO = new Parent("hello");
        WORLD = new Parent("world");
        values = new Parent[]{HELLO, WORLD};
    }

    private final String name;

    protected Parent(String name) {
        this.name = name;
        System.out.println("Enums 인스턴스 생성자 호출 name: " + this.name);
    }

    public static Parent[] values() {
        return values;
    }

    @Override
    public String toString() {
        return "Parent{" +
            "name='" + name + '\'' +
            '}';
    }
}

싱글턴과 유사하게 코드를 짯다. static 블럭을 이용해 early initialize를 했다. 위 코드의 경우 상속을 위해 생성자를 private에서 protected로 했다.

이 코드를 상속하면 instance에 접근하기 위해 static 메서드를 여러개 만들어야 한다. 문제점은 부모의 인스턴스를 호출하는 getInstance()와 자식의 인스턴스를 호출하는 메서드 모두가 API로 노출되고, 자식의 내부 변수를 추가에 생성자에서 새로운 인스턴스를 만든다고 가정하면 부모 인스턴스와 자식 인스턴스 모두 만들어지는 부작용이 생긴다. 이를 해결하려면 해결할 수 있겠지만 굳이 싱글턴 패턴을 사용할 이유가 없어지고, 상속에 따른 부작용에서 자유로울 수 없다.

인터페이스를 활용한 수평 확장

수직 확장인 상속은 부작용이 너무 크다는 것을 알게 됬다(애초에 enum은 상속 불가능). 그렇다면 상속에 비해 side 이펙트가 적은 interface를 활용하는 것은 어떨까? 다행히 enum에서는 인터페이스를 활용해서 확장이 가능하다.

abstract를 활용한 enum 전략 패턴 문제점

보통 인터페이스를 이용한 확장은 Enum을 활용한 전략 패턴에서 유용하다. 전략 패턴이란 API와 구현 알고리즘을 분리한 패턴으로 생성자 주입이나 직접 참조를 이용해 클래스를 하나의 구현 전략으로 사용한다. 왜 전략 패턴에 유용하냐면 전략 패턴은 알고리즘에 따라 확장하는 경우가 많기 때문이다.

abstract를 활용한 enum 전략 패턴을 활용한 예제를 보자

enum BasicOperation {

    PLUS("+") {
        @Override
        public double apply(double a, double b) {
            return a + b;
        }
    },
    MINUS("-") {
        @Override
        public double apply(double a, double b) {
            return a - b;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double a, double b) {
            return a * b;
        }
    },
    DIVIDE("/") {
        @Override
        public double apply(double a, double b) {
            return a / b;
        }
    };

    private final String symbol;

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

    public abstract double apply(double a, double b);
}

이 패턴의 단점은 무엇일까? 만약 연산기능을 추가해서 확장하려면 코드를 수정해야 한다. EXP와 REMINDER를 추가해보자

enum BasicOperation {

    PLUS("+") {
        @Override
        public double apply(double a, double b) {
            return a + b;
        }
    },
    MINUS("-") {
        @Override
        public double apply(double a, double b) {
            return a - b;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double a, double b) {
            return a * b;
        }
    },
    DIVIDE("/") {
        @Override
        public double apply(double a, double b) {
            return a / b;
        }
    },
    EXP("^") {
        @Override
        public double apply(double a, double b) {
            return Math.pow(a, b);
        }
    },
    REMINDER("%") {
        @Override
        public double apply(double a, double b) {
            return a % b;
        }
    };

    private final String symbol;

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

    public abstract double apply(double a, double b);
}

.java 파일을 수정하는건 나쁘지 않지만 SOLID 원칙 중 OCP 원칙에 의하면 확장할 때 해당 코드를 수정하는 방식보다는 코드를 추가하는 방식을 지향한다. 이를 인터페이스를 활용해서 해결해보자

interface를 활용해 확장에 유연하게 하기

interface Operation {
    
    double apply(double a, double b);
}

인터페이스를 정의해준다.

enum BasicOperation implements Operation {

    PLUS("+") {
        @Override
        public double apply(double a, double b) {
            return a + b;
        }
    },
    MINUS("-") {
        @Override
        public double apply(double a, double b) {
            return a - b;
        }
    },
    TIMES("*") {
        @Override
        public double apply(double a, double b) {
            return a * b;
        }
    },
    DIVIDE("/") {
        @Override
        public double apply(double a, double b) {
            return a / b;
        }
    };

    private final String symbol;

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

    @Override
    public String toString() {
        return symbol;
    }
}

만약 확장하고 싶다면 다음과 같이 구현체만 정의해주면 된다.

enum ExtendedOperation implements Operation {
    EXP("^") {
        @Override
        public double apply(double a, double b) {
            return Math.pow(a, b);
        }
    },
    REMINDER("%") {
        @Override
        public double apply(double a, double b) {
            return a % b;
        }
    };

    private final String symbol;

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

    @Override
    public String toString() {
        return symbol;
    }
}

기존 코드 없이 Operation의 구현체를 구현해주면 된다. enum은 다르지만 결국은 Operation 타입이기 때문에 다형성을 활용해서 쉽게 사용 가능하다.

가져와서 활용하기

	private <T extends Enum<T> & Operation> void operationTest(
        Class<T> opEnumType, double x, double y
    ) {
        for (Operation op : opEnumType.getEnumConstants()) {
            System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
        }
    }

해당 메서드는 enum의 모든 인스턴스를 가져와 테스트하는 예제이다.
책에서는 T extends enum<T> & Operation 을 사용했는데 Operation만 사용해도 동작하긴한다. 그러나 (.Class).getEnumConstant()를 가져오는 메서드이니 명시적으로 Enum 타입이면서 Operation 타입임을 명시적으로 표시하는 것이 더 좋을듯 하다.

정리

열거 타입 자체는 확장할 수 없지만 인터페이스와 이를 구현하는 열거 타입을 통해 같은 효과를 낼 수 있다. 만약 확장에 유연한 전략 패턴의 enum을 구현하고자 한다면 인터페이스를 활용해보자.

profile
엘 프사이 콩그루

0개의 댓글