Programming/Java

[Effective Java] item1 - 생성자 대신 정적 팩토리 메서드를 고려하라.

VSFe 2021. 1. 20. 19:45
생성자 대신 정적 팩토리 메서드를 고려하라!

일반적인 public 생성자를 통한 인스턴스 생성을 고려해보자.

public class ContactBook {
    public static void main(String[] args) {
        Contact contact1 = new Contact();
        Contact contact2 = new Contact("Kim");
        Contact contact3 = new Contact("Lee", 01000001111);
    }
}

public class Contact {
    // 인자가 없을 경우에 default 값은 공백/0이라고 가정하자.
    public Contact() {}
    public Contact(String name) {}
    public Contact(String name, int phoneNum) {}
    public Contact(String name, int phoneNum, int postalCode) {}
}

음... 익숙한 코드이긴 하지만 문제가 있다.

"전화번호는 없고 우편번호만 있으면 어떡하죠?"

Contact(String name, int postalCode) 이런건 불가능하다. 이미 String/int를 인자로 받는 생성자가 있기 때문에 답이 없다...

한~참 머리를 굴리다가 방법이 없어서, 편법을 썼다. Contact(int postalCode, String name)으로. 물론 매우 좋지 않은 코드이지만, 지금 상황에선 답이 없다.

자. 이 코드를 정적 팩토리 메소드(static factory method)를 활용해 구현해보자.

public class ContactBook {
    public static void main(String[] args) {
        Contact contact1 = Contact.makeWithName("Kim");
        Contact contact2 = Contact.makeWithNameAndPhone("Lee", 01000001111);
        Contact contact3 = Contact.makeWithNameAndPostal("Park", 26246);
    }
}

class Contact {
    Contact() {}
    Contact(String name, int phoneNum, int postalCode) {}

    public static Contact makeWithName(String name) {
        return new Contact(name, 0, 0);
    }

    public static Contact makeWithNameAndPhone(String name, int phoneNum) {
        return new Contact(name, phoneNum, 0);
    }

    public static Contact makeWithNameAndPostal(String name, int postalCode) {
        return new Contact(name, 0, postalCode);
    }
    // 이하 생략...
}

오... 뭔가 가독성이 올라간 것 같다! 실제로 정적 팩토리 메소드에는 많은 장점이 있는데, 그 중 하나가 바로 이름을 통한 가독성이다. 그러나 장점이 아닌 단점도 있는데, 이제부터 장점과 단점을 하나씩 알아보자.

장점

먼저 장점은 크게 다섯 가지로 볼 수 있는데, 첫번째가 바로 "이름을 가질 수 있다"는 것이다.

이름을 가지게 된다면 반환되는 객체의 특성을 확인하기 쉽다는 장점이 있다. 당장 위에 있는 예시를 보면, 내가 어떤 정보를 기반으로 객체를 생성하는지 쉽게 확인할 수 있다.

책에서 제시한 예시인 BigInteger.probablePrime을 보자.

public static BigInteger probablePrime(int bitLength, Random rnd) {
    if (bitLength < 2)
        throw new ArithmeticException("bitLength < 2");

    return (bitLength < SMALL_PRIME_THRESHOLD ?
            smallPrime(bitLength, DEFAULT_PRIME_CERTAINTY, rnd) :
            largePrime(bitLength, DEFAULT_PRIME_CERTAINTY, rnd));
}

메소드의 이름을 보기만 해도 "아! 소수인 BigInteger를 리턴하겠구나!" 라는 생각이 들 것이다. 그렇기 때문에 특정 상황을 가정해서 객체를 생성할 때는 public constructor보다 정적 메소드가 더 좋을 것이다.

두번째로, "호출될 때 마다 인스턴스를 새로 생성하지 않아도 된다" 라는 것이다.

이건 모든 클래스에 해당되는게 아니라, 리턴 값의 범위가 한정되어 있을 때 유용한데, 이런 경우엔 굳이 새로 인스턴스를 생성해서 리턴할 필요 없이 단 한번만 생성하고 이후에는 해당 인스턴스를 리턴하면 될 것이다.

책에서 제시한 예시인 Boolean.valueOf를 보자.

public final class Boolean implements java.io.Serializable,
                                      Comparable<Boolean> {

    public static final Boolean TRUE = new Boolean(true);
    public static final Boolean FALSE = new Boolean(false);

    public static Boolean valueOf(boolean b) {
        return (b ? TRUE : FALSE);
    }

    public static Boolean valueOf(String s) {
        return parseBoolean(s) ? TRUE : FALSE;
    }
}

보시다시피 TRUE와 FALSE를 미리 생성하고 final로 고정시킨 뒤, valueOf를 호출 할 때 해당 인스턴스를 리턴하는 것을 볼 수 있다. 이것을 활용하면 불변 클래스 (immutable class)를 만들 때 인스턴스를 미리 만들고 두고두고 써먹을 수 있을 것이다.

참고로, 이런식으로 반복되는 요청에 동일한 객체를 반환하는 클래스를 인스턴트 통제 클래스 (Instance-Controlled Class) 라고 부른다. 이런 클래스를 활용하면 동치인 인스턴스가 단 하나가 되도록 (즉, a == b가 a.equals(b)가 될 수 있도록. 위의 예시를 보면 TRUE와 FALSE가 예시가 될 것이다.) 통제할 수 있고, 이후에 자세히 다룰 플라이웨이트 패턴 (동일한 내적 속성 객체를 공유하는 객체를 대량으로 생산할 수 있도록 하는 디자인 패턴) 을 만들때 효율적이다.

셋째로, "원래의 타입 이외에도 하위 타입 객체를 반환할 수 있다." 라는 점이 있다.

public class MemberShip {
    private String name;
    private int points;

    public static VIPMemberShip vipMember() {
        return new VIPMemberShip();
    }
}

class VIPMemberShip extends MemberShip {}

즉, 다음과 같이 하위 타입의 객체를 언제든 반환할 수 있다.

이것을 조금만 확장하면 네번째 장점에 도달할 수 있는데, "입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다." 라는 점이다.

위쪽의 코드를 약간만 바꿔보자.

public class MemberShip {
    private String name;
    private int points;

    public static MemberShip registerMemberShip(int points) {
        if(points > 10000) return new VIPMemberShip();
        else if (points > 5000) return new GoldMemberShip();
        else if (points > 2500) return new SilverMemberShip();
        else return new BronzeMemberShip();
    }
}

class VIPMemberShip extends MemberShip {}
class GoldMemberShip extends MemberShip {}
class SilverMemberShip extends MemberShip {}
class BronzeMemberShip extends MemberShip {}

이런식으로 상황에 따라 맞는 하위 객체를 생성해서 반환할 수 있다.

책에서 소개하고 있는 JDK의 EnumSet를 열어보자.

public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
    Enum<?>[] universe = getUniverse(elementType);
    if (universe == null)
        throw new ClassCastException(elementType + " not an enum");

    if (universe.length <= 64)
        return new RegularEnumSet<>(elementType, universe);
    else
        return new JumboEnumSet<>(elementType, universe);
}

/* RegularEnumSet.java */
class RegularEnumSet<E extends Enum<E>> extends EnumSet<E> { /* Some Codes... */ }
/* JumboEnumSet.java */
class JumboEnumSet<E extends Enum<E>> extends EnumSet<E> { /* Some Codes... */ }

RegularEnumSet과 JumboEnumSet에 대해 클라이언트는 아는 것이 없지만, 동일한 메소드가 있는 이상 알 필요가 없다. (메소드의 반환 값을 알면 구조를 알 필요가 없는 것과 같은 느낌!)

마지막으로, "정적 팩터리 메소드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다" 는 점이다.

사실 말 자체만 놓고보면 이해가 잘 가지 않아 예제로 언급한 JDBC를 열어봤다.

우선 서비스 제공자 프레임워크에 대한 설명을 보면, 크게 3가지 핵심 컴포넌트로 구성되어 있다고 나와있다.

  • Service Interface: 구현체의 동작을 정의함.
  • Provider Registration API: 제공자가 구현체를 등록할 때 사용하는 API.
  • Service Access API: 클라이언트가 서비스의 인스턴스를 얻을 때 사용하는 API.

Service Access API가 바로 정적 팩터리 메소드이다. JDBC에서는 Connection이 Service Interface 역할을, DriverManager.registerDriver가 Provider Registration API를, 마지막으로 DriverManager.Connection이 Service Access API이다. 그런데 코드를 보면,

public interface Connection extends Wrapper, AutoCloseable { /* Some Code */ }

읭? Connection은 Interface이다. 잠깐 JDBC를 보면,

(출처: https://dyjung.tistory.com/50)

다음과 같은 구조로 되어 있는데, 각각의 DB의 종류에 따라 Connection를 다른 방식으로 구현해 두었고, 우리는 이것을 Driver라고 한다.

즉, Service Access API를 사용해서 연결하는 코드를 작성할 때에는 실제 인스턴스가 아닌 인터페이스를 가리키는 방식으로 코드를 짜겠지만, 실제로 작동할 때는 Driver를 활용해 Connection을 구현한 구현체를 가져오게 될 것이다.

그렇다면 이제 단점을 알아보자. 가장 먼저, "상속이 불가능하다." 라는 점이다. 하위 클래스는 public이나 private 로 이루어진 생성자가 필요하기 때문에 정적 팩터리 메소드만 사용할 경우 생성이 불가능하다. 물론 단점이긴 하나, final 클래스의 경우엔 어차피 상속이 불가능하니 이걸 활용하는 것이 좋을 수 있을 것이다. (이후에 다룰 불변 타입 - item17에서 이것을 활용 할 것이다.)

두번째로, "명세서에 생성자처럼 명확하게 드러나는 것이 아니므로 프로그래머가 사용하기에 어렵다."는 점이다. 이런 문제를 보완하기 위해 널리 알려진 명명법을 사용해 문제를 완화한다.

예시를 보자.

  • from: 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환함.
    • Date d = Date.from(instant);
  • of: 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환함.
    • Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
  • valueOf: from과 of의 더 자세한 버전.
    • BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);

잠깐, 뭐가 자세하다는 걸까? 찾아봐도 딱히 정보가 없었기에 예제로 나온 저 세개의 코드를 직접 열어봤다.

public static Date from(Instant instant) {
    try {
        return new Date(instant.toEpochMilli());
    } catch (ArithmeticException ex) {
        throw new IllegalArgumentException(ex);
    }
}
public static <E extends Enum<E>> EnumSet<E> of(E e) {
    EnumSet<E> result = noneOf(e.getDeclaringClass());
    result.add(e);
    return result;
}
public static BigInteger valueOf(long val) {
    // If -MAX_CONSTANT < val < MAX_CONSTANT, return stashed constant
    if (val == 0)
        return ZERO;
    if (val > 0 && val <= MAX_CONSTANT)
        return posConst[(int) val];
    else if (val < 0 && val >= -MAX_CONSTANT)
        return negConst[(int) -val];

    return new BigInteger(val);
}

음... 뭔가 감이 오는데, 그래도 한가지 예시를 더 찾아보면 좋을 것 같아서 List를 뜯어봤다.

static <E> List<E> of() {
        return ImmutableCollections.emptyList();
}
static <E> List<E> of(E e1) {
    return new ImmutableCollections.List12<>(e1);
}
/* ... More Code... */

static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9, E e10) {
    return new ImmutableCollections.ListN<>(e1, e2, e3, e4, e5,
                                            e6, e7, e8, e9, e10);
}

static <E> List<E> of(E... elements) {
    switch (elements.length) { // implicit null check of elements
        case 0:
            return ImmutableCollections.emptyList();
        case 1:
            return new ImmutableCollections.List12<>(elements[0]);
        case 2:
            return new ImmutableCollections.List12<>(elements[0], elements[1]);
        default:
            return new ImmutableCollections.ListN<>(elements);
    }
}

사실 어떨때 of를 사용해야 하고, 어떨때 valueOf를 사용해야 할지에 대한 규약이 없지만, 위 코드들을 통해 "입력하는 값의 범위나 조건에 따라 다른 인스턴스를 반환하게 되는 경우라면 valueOf를 써야 할 것 같다"고 생각할 수 있을 것이다.

나머지도 마저 보자.

  • instance (또는 getInstance): 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지는 않는다.
    • StackWalker luke = StackWalker.getInstace(options);

이 친구도 왜 보장하지 않는지 궁금해져서 코드를 살~짝 열어봤다.

public static StackWalker getInstance(Option option) {
    return getInstance(EnumSet.of(Objects.requireNonNull(option)));
}

public static StackWalker getInstance(Set<Option> options) {
    if (options.isEmpty()) {
        return DEFAULT_WALKER;
    }

    EnumSet<Option> optionSet = toEnumSet(options);
    checkPermission(optionSet);
    return new StackWalker(optionSet);
}

비어있으면 이미 생성된 인스턴스를, 그 이외에는 새롭게 생성해서 리턴하는 것을 볼 수 있다.

  • create (또는 newInstance): 역할은 위와 비슷하지만, 반드시 새로운 인스턴스를 생성해 반환한다.
    • Object newArray = Array.newInstance(classObject, arrayLen);
  • getType: getInstace와 유사하지만, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 사용한다.
    • FileStore fs = Files.getFileStore(path);
  • newType: newInstance와 유사하지만, getType처럼 다른 클래스에 팩터리 메서드를 정의할 떄 사용한다.
    • BufferedReader br = Files.newBufferedReader(path);
  • type: getType과 newType의 간결한 버전.
    • List<Complaint> litany = Collections.list(legacyLitany);

물론 여기적힌 이름은 일종의 약속일 뿐, 명문화 된 것이 없어 실제 사용할 때는 약간 다르게 사용될 수 있다. 그러나 우리가 정적 팩터리 메서드를 정의할 일이 있으면 지켜서 사용하면 좋을 것이다.

 

후... 생각보다 한 줄 한 줄이 중요한 내용이라 공부하는데 많은 시간이 걸렸다. 쓸데없는 궁금증 때문에 JDK도 계속 열고, 직접 짜보려고 노력도 했지만, 너무 오래 걸리는게 문제다...

어쨌거나 이제 하나를 마쳤는데, item 90까지 마칠 수 있을까?