땃쥐네

[이펙티브 자바] Item 01: 생성자 대신 정적 팩터리 메서드를 고려하라 본문

Study/[완료] Effective Java

[이펙티브 자바] Item 01: 생성자 대신 정적 팩터리 메서드를 고려하라

ttasjwi 2023. 9. 3. 14:50

이펙티브 자바의 아이템 1 생성자 대신 정적 팩터리 메서드를 고려하라 부분을 학습하고 정리한 내역을 포스팅해보겠습니다.

책에서는 p.8 ~ p.13 의 내용에 해당합니다.


1. 생성자의 한계, 그리고 정적 팩터리 메서드

Member member = new Member();
  • 클래스의 인스턴스를 얻는 1차적인 방법은 생성자가 있습니다.
  • 하지만 반드시 생성자를 호출하지 않고도 정적 팩터리 메서드를 이용해 객체 인스턴스를 얻어올 수 있습니다.

2. 정적 팩터리 메서드의 장점 1 : 이름을 가질 수 있다

2.1 생성자

public class Member {

    private final String name;
    private final int age;
    private final Role role;

    public Member(String name, int age, Role role) {
        this.name = name;
        this.age = age;
        this.role = role;
    }
}

// 일반 사용자를 생성하려고 했는데, 관리자를 생성
Member member = new Member("땃쥐", 20, Role.ADMIN);
  • 생성자는 이름이 없습니다. 생성자 그 자체만으로는 반환될 객체의 특성을 전달하기 힘들다는 단점이 있습니다.
  • 하나의 메서드 시그니처로는 생성자를 하나만 만들 수 있습니다. 같은 파라미터를 전달하면서 서로 의미를 다르게 주고 싶을 때 생성자로는 분명 한계가 존재합니다.
    • 매개변수 입력 순서를 바꾼다거나 하는 식으로 우회하는 방법도 있지만 혼돈의 여지를 줄 수 있으므로 좋지 못 한 발상이라고 이 책에서 말하고 있습니다.
  • 해당 클래스의 객체 인스턴스를 얻어오는 코드를 작성하는 개발자들이 실수할 여지가 많습니다.
    위의 예제 코드와 같이 일반 사용자를 생성할 때 Role.USER를 넘겨서 생성해야하는데 실수로 Role.ADMIN 열거형 상수를 넘겨서 관리자 객체를 생성하게 코드를 작성할 여지가 있을 수 있죠.

2.2 정적 팩터리 메서드

  public static Member admin(String name, int age) {
        return new Member(name, age, Role.ADMIN);
    }
    
    public static Member user(String name, int age) {
        return new Member(name, age, Role.USER);
    }

    Member member1 = Member.admin("상땃쥐", 15); // 관리자
    Member member2 = Member.user("불꽃땃쥐", 15); // 일반 사용자
  • 반면 정적 팩터리 메서드는 이름을 줄 수 있으므로, 동일한 시그니처로 객체를 생성하더라도 서로 다른 의미를 부여할 수 있습니다.
  • 사용하기 나름에 따라, 구체적으로 내부적으로 어떤 파라미터들을 필요로 하는지 클라이언트에서 알 필요가 없어지는 장점도 생깁니다.
    • 이 기법을 사용하면 위와 같이 Member의 admin, user 메서드를 호출하여 내부적으로 어떤 로직을 통해 관리자/일반 사용자가 생성되는지 숨길 수 있습니다. 복잡한 로직을 Member 클래스 내부로 숨기면 이 클래스를 사용하는 개발자들의 실수를 줄일 수 있습니다.

3. 정적 팩터리 메서드의 장점 2
호출될 때마다 인스턴스를 새로 생성하지 않아도 된다

 

3.1 생성자의 한계 →객체 생성을 통제할 수 없음

// 매번 객체가 새로 생성됨(비싼 비용의 객체이면 성능 면에서 위험부담이 커진다.)
TransactionManager tm1 = new TransactionManager();
TransactionManager tm2 = new TransactionManager(); 
TransactionManager tm3 = new TransactionManager();
TransactionManager tm4 = new TransactionManager();
  • 생성자를 호출하면 반드시 객체 인스턴스가 생성됩니다.
  • 즉, 생성자가 열려있다는 것은 어디든 해당 클래스의 인스턴스를 생성할 수 있다는 것을 의미합니다.
  • 생성자를 열어둔다는 것은 생성비용이 비싼 객체를 매번 생성할 가능성을 열어두게 되는 것입니다.
  • 한번만 생성하고 재사용하게 할 수 없을까요?

3.2 정적 팩터리 메서드 : 인스턴스화 통제, 플라이웨이트 패턴

public class TransactionManager {

    private static final TransactionManager SINGLETON = new TransactionManager();

    public static TransactionManager getInstance() {
        return SINGLETON;
    }

    private TransactionManager() {} // 생성자를 은닉

}

public static void main(String[] args) {
        // TransactionManager tm = new TransactionManager(); 컴파일 에러

				// 모두 같은 인스턴스가 반환된다
        TransactionManager tm1 = TransactionManager.getInstance();
        TransactionManager tm2 = TransactionManager.getInstance();
        TransactionManager tm3 = TransactionManager.getInstance();
}
// java.lang.Boolean : 실제로 True, False 인스턴스를 하나 만들어두고 캐싱해둠
public static Boolean valueOf(boolean b) {
        return b ? TRUE : FALSE;
}
  • 인스턴스 생성 통제 : 생성자를 외부에서 호출하지 못 하도록 접근제어를 걸고 오직 정적 팩터리 메서드를 통해서만 객체 인스턴스를 얻을 수 있게 통제할 수 있습니다. 이렇게 실제로 객체가 생성되는 지 여부조차도 모르게 은닉시키고, 인스턴스 생성 자체를 통제시킬 수 있습니다.
  • 싱글턴 패턴 : 이 방식을 이용하여, 내부적으로는 객체를 단 하나만 생성해두고 정적 팩터리 메서드가 호출될 때는, 미리 만들어둔 객체 인스턴스를 반환하는 것이 가능합니다.
  • 불변 클래스의 캐싱 : 불변 값 객체를 한번 생성하고 캐싱한 뒤 객체 생성을 통제하면, 동등한 값의 동일성을 보장할 수도 있습니다.
    • JPA와 함께 쓸 때 주의 : 캐싱해둔 객체에 대해 동일성 보장 기능을 JPA 값 객체에 사용하면 좀 곤란한 경우가 많습니다. JPA에 의해 생성되는 객체 인스턴스는 생성자가 아닌 프로퍼티 주입을 통해 생성되기 때문에 내부 값이 같더라도 같은 객체임을 보장할 수 없으니 동일성 보장이 깨지는 경우가 발생합니다. 
  • 플라이 웨이트 패턴 : 자주 사용되는 인스턴스, 값들을 미리 캐싱해두고 꺼내서 사용하는 방식입니다.

4. 정적 팩터리 메서드의 장점 3
반환 타입의 하위 타입 객체를 반환할 수 있다.

# kotlin.collections
public fun <T> mutableListOf(vararg elements: T): MutableList<T> =
    if (elements.size == 0) ArrayList() else ArrayList(ArrayAsCollection(elements, isVarargs = true))
  • 구체적인 구현 클래스를 공개하지 않고 그 객체를 반환하게 할 수 있어서 사용하는 측에서는 구체적인 구현체를 몰라도 됨
  • 추후 어떤 구현체가 새로 생기든 몰라도 된다 → 인터페이스 기반 프레임워크를 만드는 핵심 기술
    • 인터페이스 기반 프레임워크의 API를 이용할 때 우리는 인터페이스에 의존한다.
    • 실제 컴파일 타임에 우리 코드에서는 특정 구체 구현체를 의존하지 않아도 됨
  • (참고) 자바8 이후에서는 인터페이스의 정적 팩터리 생성 제한이 해제되며, 상위 인터페이스의 정적 팩터리 메서드를 통해 하위 구현체 클래스를 얻어올 수 있게 됐다.

5. 정적 팩터리 메서드의 장점 4
입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다

public static Money of(long value, String currency) {
        return switch (currency) {
            case "won" -> new Won(value);
            case "yen" -> new Yen(value);
            default -> new Dollar(value);
        };
}
  • 입력 매개변수에 따라 다른 타입의 인스턴스를 반환해도 됨
  • 구체 타입이 은닉됨 → 심지어, 다음 배포 이후에는 동일 파라미터에 대해 다른 타입의 인스턴스를 반환하게 해도 됨
  • 클라이언트는 이런 것들을 몰라도 됨

6. 정적 팩터리 메서드의 장점 5
정적 팩터리 메서드를 작성하는 시점에는
반환할 객체의 클래스가 존재하지 않아도 된다

// 설정 클래스 - 드라이버 구현체 등록
DriverManager.registerDriver(...);

// 데이터베이스 접근 계층 - 커넥션 획득
Connection connection = DriverManager.getConnection();

반환하는 구체 타입이 팩터리 메서드 작성 시점에 없어도 되고 이것이 서비스 제공자 프레임워크의 기반이 된다... 이런 얘기가 나오는데 이 부분은 설명이 약간 길어질 것 같아서 아래의 12. 서비스 제공자 프레임워크 쪽에서 다루겠습니다.


7. 정적 팩터리 메서드의 단점

7.1 private 생성자만 열어두고, 정적 팩터리 메서드만 제공하면, 하위 클래스를 만들 수 없다.

  • private 생성자만 열어두면, 상속을 할 수 없음
  • 이 책에서는 상속 대신 컴포지션(합성)을 통해 기능을 확장하는 대안을 제안함

7.2 정적 팩터리 메서드는 프로그래머가 찾기 어렵다

생성자는 new 키워드를 통해 호출하기 때문에 개발자가 바로 찾기 쉽지만 정적 팩터리 메서드는 다른 정적 메서드들과 함께 노출되기 때문에, 무엇이 이 클래스의 인스턴스를 생성하는 정적 팩터리 메서드인지 동료 개발자가 찾기 매우 힘듭니다.

 

그래서, 이 책에서는 팀 차원에서 컨벤션을 정해두고 정적 팩터리 메서드를 생성하기를 권하고 있습니다.

바로 아래에서 이를 다룹니다.


8. 정적 팩터리 메서드 네이밍 컨벤션

생성자는 딱 new 키워드를 통해 호출하기만 하면 되겠지만, 정적 팩터리는 무슨 이름을 호출해야하는지 정해진 게 없습니다.

결국 팀 내에서 컨벤션을 정해두는게 좋습니다.

 

8.1 이 책에서 제시하는 네이밍 컨벤션

  • of : 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환
  • from : 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환
  • valueOf : from과 of의 더 자세한 버전
  • instance 혹은 getInstance : (매개변수를 받는다면) 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지 않는다.
  • create 또는 newInstance : instance 혹은 getInstance와 같지만 매번 새로운 인스턴스를 생성해 반환함을 보장
  • getType : getInstacne와 같으나, 생성한 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다. 여기서 "Type"은 반환할 객체의 타입이다.
    • 예) FileStore fs = Files.getFileStore(path);
  • newType : newInstance와 같으나, 생성한 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 씀. 여기서 "Type"은 반환할 객체의 타입이다.
    • 예) BufferedReader br = Files.newBufferedReader(path);
  • type : getType, newType의 간결한 버전
    • 예)List<Complaint> litany = Collections.list(legacyLitany);

8.2 Java에서 제공하는 네이밍 컨벤션

이 외에도 제각각 다른 컨벤션들이 존재합니다. 대표적으로 Java의 DateTime API 구현 시 Java에서 사용한 네이밍 컨벤션입니다.

8.3 JavaDoc을 통해 문서화를 하자

만약 팀 내에서 JavaDoc을 적극적으로 만들고 실제로 페이지로 구성해서 JavaDoc을 열어서 자주 사용한다면, JavaDoc을 통해 문서화를 해서, 협업자들이 찾기 쉽게 하는 것이 중요합니다. 동료 개발자들이 찾아 쓰기 편해집니다.


9. Enum, EnumSet

가령 EnumSet 클래스의 정적 팩터리 메서드...
원소의 갯수가 64개 이하이면 long 변수 하나로 관리하는 RegularEnumSet
원소의 갯수가 65개 이상이면 JumboEnumSet의 인스턴스를 반환한다.
- p.11

 

이 부분은 책에서 언급이 있어서 가볍게 다루고 넘어가지만,

당장 몰라도 이후 Item 34~ 38 에서 깊이있게 다루므로 그냥 넘어가도 됩니다.

9.1 Enum

public enum Operation {

    PLUS, MINUS, TIMES, DIVIDE;

    public double apply(double x, double y) {
        return switch (this) {
            case PLUS -> x + y;
            case MINUS -> x - y;
            case TIMES -> x * y;
            case DIVIDE -> x / y;
        };
    }

    public static Operation inverse(Operation op) {
        return switch (op) {
            case PLUS -> MINUS;
            case MINUS -> PLUS;
            case TIMES -> DIVIDE;
            case DIVIDE -> TIMES;
        };
    }
}
  • 열거타입은 Java 1.5 이후 도입된 타입입니다.
  • 완전한 형태의 클래스이고, 상수 하나당 자신의 인스턴스를 하나씩 만들고 외부에 공개하고, 유일성이 보장됩니다.
  • 컴파일 타임 타입 안전성을 제공합니다. (==을 통해 비교할 수 있고, 타입이 다르면 비교 자체가 안 됨)
  • 근본적으로 불변이라 모든 상태는 final 이어야 합니다.
  • Item 34: int 상수 대신 열거 타입을 사용하라 에서 다룹니다.

9.2 EnumSet

    public abstract class EnumSet<E extends Enum<E>> extends AbstractSet<E> implements Cloneable, Serializable {
        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");
            } else {
                return (EnumSet)(universe.length <= 64 ? new RegularEnumSet(elementType, universe) : new JumboEnumSet(elementType, universe));
            }
        }
}
  • 제네릭 클래스입니다. 생성 시 Enum 클래스의 Class 리터럴을 전달받거나 복수의 인자들을 전달받아 타입을 추론하여 인스턴스를 생성합니다. 
  • Enum들의 Set을 선언할 때 HashSet 과 같은 구현체를 사용해도 되긴합니다만, 더 좋은 선택지는 EnumSet입니다.
  • EnumSet<T>는 추상클래스이고, 정적 팩터리 메서드를 통해 EnumSet 구현체를 제공합니다. 내부적으로 어떤 구현체가 제공되는지 추상화됩니다.
class RegularEnumSet<E extends Enum<E>> extends EnumSet<E> {
    private static final long serialVersionUID = 3411599620347842686L;
    private long elements = 0L;

    RegularEnumSet(Class<E> elementType, Enum<?>[] universe) {
        super(elementType, universe);
    }

    void addRange(E from, E to) {
        this.elements = -1L >>> from.ordinal() - to.ordinal() - 1 << from.ordinal();
    }

    void addAll() {
        if (this.universe.length != 0) {
            this.elements = -1L >>> -this.universe.length;
        }

}
class JumboEnumSet<E extends Enum<E>> extends EnumSet<E> {
    private static final long serialVersionUID = 334349849919042784L;
    private long[] elements;
    private int size = 0;

    JumboEnumSet(Class<E> elementType, Enum<?>[] universe) {
        super(elementType, universe);
        this.elements = new long[universe.length + 63 >>> 6];
    }

    void addRange(E from, E to) {
        int fromIndex = from.ordinal() >>> 6;
        int toIndex = to.ordinal() >>> 6;
        if (fromIndex == toIndex) {
  • 해당 선언된 Enum의 갯수가 64개 이하일 때는 RegularEnumSet이라는 구현체를, 65개 이상일 때는 JumboEnumSet 이라는 구현체를 제공합니다.
  • RegularEnumSet은 내부적으로 long 타입 변수(64비트)를 가지고 있는데 이 변수의 비트를 통해 특정 Enum의 포함여부를 판별하고 관리합니다. Hash 방식보다, 비트를 기반으로 특정 위치 비트가 1인지 0인지에 따라 존재 여부를 판별할 수 있기 때문에 매우 빠릅니다.
  • JumboEnumSet은 내부적으로 long 타입 배열을 관리합니다. Enum 갯수가 64개를 넘어가니 long 변수 하나로는 관리할 수 없기 때문입니다. 대신 특정 인덱스의 특정 long 값의 비트 배치를 토대로 특정 Enum이 존재하는지 여부를 판단하는 차이입니다.(예를 들어 0~63번째 열거형 상수 인스턴스의 존재여부는 0번 인덱스의 long값 비트로 판단하고, 64~127번째 열거형 상수 인스턴스의 존재 여부는 1번 인덱스의 long값 비트로 판단하는 식...)
  • 결국 어떤 Enum의 Set을 관리할 때는 HashSet 과 같은 구현체를 쓰기보다 EnumSet의 정적 팩터리 메서드를 통해 Set을 생성하는 것이 좋습니다.

10. 플라이웨이트 패턴

호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
...
인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있다.
...
플라이웨이트 패턴(Flyweight pattern)도 이와 비슷한 기법이라 할 수 있다.
p.9
   /**
    * 여행 기간이 정해지지 않았을 때의, 여행 기간
    *
    * @see TripStatus#UNDECIDED
    */
    private static final TripPeriod EMPTY_PERIOD = new TripPeriod(null, null);
    
    public TripPeriod static emptyPeriod() {
        return EMPTY_PERIOD;
    }
  • 객체 인스턴스를 가능한 재사용하여 메모리 사용을 줄이는 기법입니다.
  • 자주 변하는 속성과 자주 변하지 않는 속성을 분리한 뒤, 자주 변하지 않고 자주 사용되는 객체를 어딘가 캐싱해두면 매번 재생성할 필요 없이 캐싱하여 재사용할 수 있게 됩니다.
  • 위의 예제 코드를 보면, TripPeriod.EMPTY 라는 자주 쓰이는 객체 인스턴스를 단 한번만 생성해두고 정적 팩터리 메서드만 노출 시켜서, 같은 객체 인스턴스를 재사용해서 쓸 수 있습니다.

11. 자바 8 이후의 인터페이스 사양변화 - 정적 메서드, 디폴트 메서드

11.1 Java 8 이전

public class Collections {

    private Collections() { /* compiled code */ }

    public static <T extends java.lang.Comparable<? super T>> void sort(java.util.List<T> list) { /* compiled code */ }

    public static <T> void sort(java.util.List<T> list, java.util.Comparator<? super T> c) { /* compiled code */ }

    public static <T> int binarySearch(java.util.List<? extends java.lang.Comparable<? super T>> list, T key) { /* compiled code */ }

    private static <T> int indexedBinarySearch(java.util.List<? extends java.lang.Comparable<? super T>> list, T key) { /* compiled code */ }

    private static <T> int iteratorBinarySearch(java.util.List<? extends java.lang.Comparable<? super T>> list, T key) { /* compiled code */ }

    private static <T> T get(java.util.ListIterator<? extends T> i, int index) { /* compiled code */ }

    public static <T> int binarySearch(java.util.List<? extends T> list, T key, java.util.Comparator<? super T> c) { /* compiled code */ }

    private static <T> int indexedBinarySearch(java.util.List<? extends T> l, T key, java.util.Comparator<? super T> c) { /* compiled code */ }

    private static <T> int iteratorBinarySearch(java.util.List<? extends T> l, T key, java.util.Comparator<? super T> c) { /* compiled code */ }

// .. 생략

}
  • 인터페이스에 정적 메서드를 선언할 수 없었습니다.
  • 인스턴스 공통적으로 사용될만한 디폴트 메서드 기능도 이 시점엔 지원되지 않았습니다.
  • 자바8 이전 : java.util.Collections 와 같은 별도의 유틸리티 클래스의 정적 팩터리 메서드를 통해 하위 인스턴스를 얻곤 했습니다.

11.2 Java 8 이후 : 인터페이스의 정적 메서드, 디폴트 메서드 생성 제한이 해제됨

@FunctionalInterface
public interface Comparator<T> {
    int compare(T var1, T var2);

    default Comparator<T> reversed() {
            return Collections.reverseOrder(this);
    }
    
    
    static <T> Comparator<T> nullsLast(Comparator<? super T> comparator) {
        return new Comparators.NullComparator(false, comparator);
    }
    
    // 생략
    

    static <T, U> Comparator<T> comparing(Function<? super T, ? extends U> keyExtractor, Comparator<? super U> keyComparator) {
        Objects.requireNonNull(keyExtractor);
        Objects.requireNonNull(keyComparator);
        return (Comparator)((Serializable)((c1, c2) -> {
            return keyComparator.compare(keyExtractor.apply(c1), keyExtractor.apply(c2));
        }));
    }
}
  • 별도의 팩토리 메서드를 통해 인터페이스, 추상 클래스의 인스턴스를 만들 필요 없이, 해당 인터페이스에 정적 팩터리 메서드를 두고 해당 클래스에서 하위 타입 객체를 반환시키게 할 수 있다.
  • 기존 인터페이스의 기능이 좀 더 풍부해짐(java 8 이전에는 불가능했던 일)
    • 예) Comparator의 디폴트 메서드 reveresed()
  • 이런 변화로 인해 기존의 많은 동반 클래스들의 메서드들이 deprecated 되었다.

11.3 Java9 이후 : private static, private 메서드 지원

public interface Hello {

    static void staticSay() {
        sayStaticHello();
    }

    private static void sayStaticHello() {
        System.out.println("hello");
    }
    
    default void defaultSay() {
        defaultSayAloha();
    }
    
    private void defaultSayAloha() {
        System.out.println("aloha");
    }

}

인터페이스에서 정적 메서드를 정의해둘 수 있는데, 내부적인 구현이 복잡해진다면 코드 관리가 까다로워지겠죠?

적절히 private 메서드로 복잡한 로직을 분리할 수 있게 됐습니다. java 9 이후에 지원되므로...

 

아직까지 우리나라 현업에서 많이 사용되는 java8 기준으로는 쓸 수 없습니다.... (어쩔 수 없이 Java8 이전 버전 쓰는 기업도 많다고 들었는데... 현업자님들 화이팅이에요...)

 

11.4 (참고) 코틀린

interface Flyable {

    val legCount: Int // 추상 프로퍼티를 인터페이스 차원에서 선언할 수 있음

    companion object {
        val SOME_CONSTANT: Int = 3
        private val WING_COUNT: Int = 2 // private static 변수 사용 가능
        
        // static 메서드
        fun staticMethod() {
            println("hello")
        }
    }

    // 디폴트 메서드
    fun act() {
        defaultAct()
        println(legCount) // 추상 프로퍼티에 디폴트 메서드에서 접근하여 사용할 수 있음
    }
    
    // private 메서드를 인터페이스 차원에서 쓸 수 있음
    private fun defaultAct() {
        print("파닥파닥")
    }
}

코틀린에서는 여기서 더 나아가, 인터페이스에 추상 프로퍼티 선언을 넣을 수 있고 정적 상수도 둘 수 있습니다.

private 상수도 둘 수 있습니다... 와와와


12. 서비스 제공자 프레임워크

정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
이런 유연함은 서비스 제공자 프레임워크를 만드는 근간이 된다.
p.11

 

책에서 서비스 제공자 프레임워크라는 개념을 아주 짤막하게 몇 줄로 설명하고 끝냈는데 도저히 이해가 잘 안 되서 이 부분에 대해서 검색을 했습니다. (책 저자님이 이런건 참 불친절하네요...) 제가 이해한 수준에서 한번 정리를 해보겠습니다. (틀릴 수도 있습니다 ㅠㅠ)

 

어떤 용어에 대해 생각하기보다 구체적인 사례를 통해 생각하는게 좋을 것 같고, 이 책에서 예시로 든 Jdbc가 제 입장에서 가장 좋은 사례인 것 같아서 Jdbc 기준으로 생각해보겠습니다.

 

12.1 사례로 이해하는 서비스 제공자 프레임워크 : Jdbc의 경우

 

Java에서는 'Jdbc'라는 표준 명세를 정의하였는데 이 기술을 통해 우리는 데이터베이스에 접근하는 방법을 추상화하여 사용할 수 있습니다. 이 기술이 없다면 여러가지 데이터베이스에 접근하는 코드가 제각각 달라지기 때문에 개발자들은 사용하는 데이터베이스를 바꾸면 데이터베이스에 접근하는 기술을 또 새로 배우고 해당 코드들을 전부 뜯어고쳐야하는 고생을 해야합니다. 이런 개발자의 고통을 경감시키기 위해 자바 진영 차원에서 표준 명세인 Jdbc를 제공하게 됐습니다.

 

Jdbc는 데이터베이스에 접근하는 방법들을 추상화하고, 개발자들은 Jdbc에서 제공하는 인터페이스들의 구현체를 등록하기만 하면 데이터베이스가 바뀌더라도 거의 똑같은 방식으로 데이터베이스에 접근할 수 있게 되어 많은 수고를 줄일 수 있게 됐습니다.(거의 똑같다는 말은 일부 다르다는 것인데요, 데이터베이스별로 SQL이 달라지는 부분은 결국 수정해야 하기 때문입니다.)

 

시간을 되돌아가 Jdbc라는 명세를 정의한 사람 입장에서 생각해봅시다. 

 

시간이 지나면 지날수록 데이터베이스의 종류는 늘어날텐데 Jdbc 명세를 정의하는 개발자는 일일이 그런 구현체를 만들 책임지기도 힘들고 그럴 필요도 없겠죠. 그렇다면 단순히 데이터베이스에 접근하여 질의할 수 있는 방법을 정의한 인터페이스를 정의해두고 이들의 협력에 해당하는 부분만 추상화해서 코드를 짜둔뒤 이 명세를 공개해둔 뒤, 구현체를 만들 책임은 각각의 데이터베이스 벤더들에게 위임합니다.

 

데이터베이스 벤더들은 Jdbc 명세에 따라, 자신들의 데이터베이스에 접근할 수 있는 Jdbc 인터페이스 구현체(드라이버)를 만듭니다.

 

개발자는 단순히 Jdbc API를 사용하는 방법만 학습하고, 데이터베이스 벤더들이 구현해둔 Jdbc 인터페이스를 구현한 구현체(드라이버)를 등록하는 작업(설정 파일을 통해 설정하거나, 자바 코드를 통해 설정을 구성하거나...)만 하면 됩니다. 소스 코드 상에서는 어떤 구현체를 염두해둔 코드를 작성하지 않아도 됩니다. (SQL만 빼고요...)

 

12.2 좀 더 추상화시켜서 생각해보자 

이제 이 구체적인 사례를 추상화시켜서 서비스 제공자 프레임워크라는 개념을 이해해보도록 하겠습니다.

 

어떤 API 명세가 있고, 이들 구현체가 따로 있고, 이 API 를 사용하는 사람들이 따로 있을 때 인터페이스를 정의하고, 구현체를 등록하는 방법, 구현체를 획득해오는 방법(소스 코드상에서는 구현체가 구체적으로 뭔지 모르게) 수준까지만 정의해둔다면

 

서비스 제공자 인터페이스를 정의하는 측은 굳이 구현체를 만들지 않아도 되고

서비스 제공자 구현체를 만드는 측은 명세가 나온지 한참 지나더라도 뒤늦게 새로운 구현체를 만들어서 제공해도 되고

서비스 제공자 인터페이스를 사용하는 측에서는 구현체 등록만 하고, 실제 소스코드에서 특정 구현체를 의존하지 않고 단지 서비스 제공자 인터페이스를 사용하는 방법만 이해하고 사용하면 됩니다.

 

12.3 서비스 제공자 인터페이스, 서비스 제공자

  • 서비스 제공자 인터페이스 : 인터페이스를 정의한 측. 구현체의 동작 명세를 정의한다.
    • 예) Jdbc의 Connection 인터페이스
  • 서비스 제공자 : 인터페이스의 구현체. 외부의 프로젝트에서 구현하는 경우도 존재
    • 예) 각종 DB 벤더에서 구현한 Connection 구현체들...

12.4 서비스 제공자 등록 API

서비스 제공자를 등록하는 방법을 제공하는 API입니다. 보통 서비스 제공자 인터페이스를 정의한 측에서 이 API도 함께 제공합니다.

  • 예) DriverManager.registerDriver API를 통해 서비스 제공자를 등록할 수 있습니다.
  • 이렇게 구체적인 서비스 제공자를 등록하는 방법(계약)을 추상화시킴으로서, 구체적으로 런타임에 어떤 구현체를 통해 비즈니스 로직이 돌아가는 지 몰라도 됩니다.

12.5 서비스 제공자 접근 API

서비스를 가져오는 방법을 정의합니다.

  • 예) DriverManager.getConnection()

 

이 부분이 서비스 제공자 프레임워크를 만드는 근간이 된다고 하는 부분인데요. 이 인터페이스를 정의한 시점에서는 구현체 클래스가 존재하지 않아도 됩니다. 프레임워크(예:Jdbc)를 만드는 입장에서는 추상화된 타입을 반환하게 하고 인터페이스를 기반으로 한 코드를 작성해두기만 하면 되겠죠. 

12.6 변형

  • 브리지 패턴 : 구체적인 것과 추상적인 것을 분리
  • 의존객체 주입 프레임워크

12.7 그 외 참고할만한 자료


13. 리플렉션

서비스 제공자 인터페이스가 없다면 각 구현체를 인스턴스로 만들 때 리플렉션(아이템 65)을 사용해야한다.
p.12
public static void main(String[] args) throws Exception{
        Class<?> clazz = Class.forName("com.ttasjwi.example.Fire");
        Constructor<?> constructor = clazz.getConstructor(int.class);
        Fire fire = (Fire) constructor.newInstance(100);
        System.out.println(fire);
}
  • 클래스 로더를 통해 읽어온 클래스 정보를 사용하여 객체 인스턴스의 내부적인 정보를 얻어오고 조작할 수 있는 기술입니다. 객체 인스턴스를 마음대로 조작할 수 있다는 점에서 매우 마법같은 무기지만 객체의 캡슐화를 깨트리고 유지보수성을 깨트릴 수 있으니 남발하면 매우 위험합니다. 
  • 리플렉션을 사용하면 우리가 명시한 특정 클래스를 읽어오거나, 인스턴스를 생성하거나, 메서드를 실행하거나, 필드의 값을 가져오거나 변경하는 것이 가능합니다.
  • 사용사례
    • 특정 어노테이션이 붙어있는 필드 또는 메서드 읽어오기(JUnit, Spring, …)
  • Oracle Tutorial

14. 프로젝트에서 사용한 예시

    public static Trip create(TripTitle tripTitle, Long tripperId) {
        return Trip.builder()
                .tripperId(tripperId)
                .tripTitle(tripTitle)
                .status(TripStatus.UNDECIDED)
                .tripImage(TripImage.defaultImage())
                .tripPeriod(TripPeriod.empty())
                .build();
    }
    /*
    * 테스트의 편의성을 위해 Builder accessLevel = PUBLIC 으로 설정
    */
    @Builder(access = AccessLevel.PUBLIC)
    private Trip(Long id, Long tripperId, TripTitle tripTitle, TripStatus status, TripPeriod tripPeriod, TripImage tripImage, List<Day> days, List<Schedule> temporaryStorage) {
        this.id = id;
        this.tripperId = tripperId;
        this.tripTitle = tripTitle;
        this.status = status;
        this.tripPeriod = tripPeriod;
        this.tripImage = tripImage == null ? TripImage.defaultImage() : tripImage;
        if (days != null) {
            this.days.addAll(days);
        }
        if (temporaryStorage != null) {
            this.temporaryStorage.addAll(temporaryStorage);
        }
    }
  • 객체가 가지는 파라미터가 7-8개가 넘어가는데 이들을 모두 생성자를 통해 주입하는 것은 가독성도 좋지 못 하고, 객체 생성을 하는 클래스에서 지나치게 많은 정보를 알게되는 문제가 발생함.
  • 필수적인 파라미터만 외부에서 전달받고 내부에서 알아서 처리해도 되는 것들은 내부로 은닉시키면, 여행 생성 서비스에서는 단지 create라는 하나의 메서드만 알고 사용하면 되지 않을까?(다만 생성자 자체를 막기에는 테스트 코드 작성이 여러모로 힘들어져서 막지는 않았다. 비즈니스 로직에서 생성자 호출은 사용하지 않도록 컨벤션을 정함)
  • 이런 관점에서 정적 팩터리 메서드를 적극적으로 사용했다.

15. 코틀린에서

class Person private constructor(
    val name: String,
    val age: Int
) {
    init {
        if (age < 0) {
            throw IllegalArgumentException("나이는 ${age}일 수 없습니다.")
        }
        println("초기화 블록")
    }
    
    companion object {
        
        fun adult(name:String): Person {
            return Person(name, 20)
        }
    }

}
  • companion object를 통해 정적 팩터리 메서드를 구현할 수 있다
  • java와 마찬가지로 생성자의 접근제어를 private로 막고 정적 팩터리 메서드만 열어둘 수 있다.

이상으로 이펙티브 자바 Item 01: 생성자 대신 정적 팩터리 메서드를 고려하라 정리 글을 마무리하겠습니다.

'Study > [완료] Effective Java' 카테고리의 다른 글

[Study 001] 이펙티브 자바  (0) 2023.07.28
Comments