스터디/이펙티브 자바 스터디

[이펙티브 자바] 아이템 1. 생성자 대신 정적 팩터리 메서드를 고려하라

제이온 (Jayon) 2022. 4. 17.

effective-java에서 스터디를 진행하고 있습니다.

 

개요

클래스의 인스턴스를 얻는 전통적인 수단은 public 생성자다.

 

public class Member {

    private String name;

    private int age;

    private String hobby;

    private MemberStatus memberStatus;

    public Member(String name, int age, String hobby, MemberStatus memberStatus) {
        this.name = name;
        this.age = age;
        this.hobby = hobby;
        this.memberStatus = memberStatus;
    }
}

public enum MemberStatus {

    ADVANCED,
    INTERMEDIATE,
    BASIC;
}

 

일반적으로는 public 생성자만으로도 충분하지만, 생성자 외에 정적 팩터리 메서드(static factory method)를 제공하면 사용자 입장에서 의도한 대로 인스턴스를 만들기 쉬워지는 경우가 종종 있다.

 

정적 팩터리 메서드의 대표적인 예시는 Boolean의 valueOf() 메서드가 있다.

 

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

 

위 메서드는 기본 타입인 boolean 값을 받아서 Boolean 객체로 만들어서 반환해 주고 있다.

 

정적 팩터리 메서드의 장점

이름을 가질 수 있다.

생성자에 넘기는 매개변수와 생성자 자체만으로는 반환될 객체의 특성을 제대로 설명하지 못한다. 가령 위의 Member 클래스의 메인 생성자(name, age, hobby, memberStatus)만 보고 어떤 특성을 가진 Member인지 파악하기 어렵다.

 

또한, 하나의 시그니처로는 하나의 생성자를 만들 수 있는데, 정적 팩터리 메서드는 이름을 가질 수 있으므로 하나의 시그니처로 여러 개의 정적 팩터리 메서드를 만들어서 인스턴스를 반환할 수 있다.

 

public class Member {

    private String name;

    private int age;

    private String hobby;

    private MemberStatus memberStatus;

    public Member(String name, int age, String hobby, MemberStatus memberStatus) {
        this.name = name;
        this.age = age;
        this.hobby = hobby;
        this.memberStatus = memberStatus;
    }

    public static Member basicMember(String name, int age, String hobby) {
        return new Member(name, age, hobby, MemberStatus.BASIC);
    }

    public static Member intermediateMember(String name, int age, String hobby) {
        return new Member(name, age, hobby, MemberStatus.INTERMEDIATE);
    }

    public static Member advancedMember(String name, int age, String hobby) {
        return new Member(name, age, hobby, MemberStatus.ADVANCED);
    }

}

 

 

위와 같이 생성자로 MemberStatus를 구분하는 것보다 같은 시그니처를 가진 여러 개의 정적 팩터리 메서드를 만들면, 사용자 입장에서 혼동의 여지 없이 특정 실력을 가진 Member 인스턴스를 생성할 수 있게 된다.

 

JDK에서 정의된 라이브러리를 보자면, 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의 일반 생성자와 정적 팩터리 메서드인 probablePrime() 을 비교했을 때, 값이 소수인 BigInteger를 반환한다.라는 문장은 당연히 후자가 더 잘 설명할 것이다.

 

호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.

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

 

Boolean의 valueOf() 메소드는 인스턴스를 미리 캐싱해뒀다가 반환해 주는 것을 알 수 있다. 이러한 특성은 생성 비용이 큰 객체가 자주 요청되는 상황이라면 성능을 상당히 높여줄 수 있고, 플라이웨이트 패턴도 이와 비슷한 기법으로 볼 수 있다.

 

반복되는 요청에 같은 객체를 반환하는 식으로 정적 팩터리 메서드 방식을 사용하는 클래스는 인스턴스의 생명 주기를 통제할 수 있으므로 인스턴스 통제 클래스 라고 한다. 인스턴스를 통제하면, 싱글톤 클래스를 만들거나 인스턴스화 불가 클래스를 만들 수 있다. 또한 불변 값 클래스에서 같은 인스턴스가 단 하나임을 보장할 수 있다.

 

인스턴스 통제는 플라이웨이트 패턴의 근간이 되며, 열거 타입은 인스턴스가 하나만을 만들어짐을 보장한다.

플라이웨이트 패턴이 궁금하다면, 아래 [더보기]를 눌러 보자.

 

더보기

Flyweight 패턴이란?

플라이웨이트 패턴은 어떤 클래스의 인스턴스 한 개만 가지고 여러 개의 “가상 인스턴스”를 제공하고 싶을 때 사용하는 패턴이다.

 

예제

마인크래프트에서 나무를 심어야 한다. 만약 나무 객체 하나 당 새로 생성하면, 메모리 오버 플로우가 발생할 여지가 있다.

따라서 위와 같이 빨간 나무와 연두색 나무 객체는 저장해 놓고, 위치만 바꾸어서 반환하면 된다. 물론 색은 2가지 색 외에 더 늘어날 수 있으니, Map 같은 자료 구조에 색깔에 따라 나무를 저장해 놓으면 효율적일 것이다.

 

public class Tree {

    // 나무는 아래와 같이 3개 정보를 가지고 있다.
    private String color;
    private int x;
    private int y;

    // 색상으로만 생성자를 만들어 준다.
    public Tree(String color) {
        this.color = color;
    }

    public void setX(int x) {
        this.x = x;
    }

    public void setY(int y) {
        this.y = y;
    }

    // 나무를 심을 때
    public void install(){
        System.out.println("x:"+x+" y:"+y+" 위치에 "+color+"색 나무를 설치했습니다!");
    }
}

public class TreeFactory {
    // HashMap 자료구조를 활용해서 만들어진 나무들을 관리한다.
    public static final Map<String, Tree> treeMap = new HashMap<>();
    
   
    public static Tree getTree(String treeColor){
        // Map에 입력받은 색상의 나무가 있는지 찾는다. 있으면 그 객체를 제공한다.
        Tree tree = (Tree)treeMap.get(treeColor); 

       // 만약 아직 같은 색상의 나무가 Map에 없다면 새로 객체를 생성해 제공한다.
        if(tree == null){
            tree = new Tree(treeColor);
            treeMap.put(treeColor, tree);
            System.out.println("새 객체 생성");
        }

        return tree;
    }
}

public class Main {
    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        
        System.out.println("원하는 색을 입력해주세요 :)");
        for(int i=0;i<10;i++){
            // 나무 색 입력받기
            String input = scanner.nextLine();
            // 팩토리에서 나무 하나 공급받기
            Tree tree = (Tree)TreeFactory.getTree(input);
            // 나무 x,y 설정하고
            tree.setX((int) (Math.random()*100));
            tree.setY((int) (Math.random()*100));
            // 나무 설치하기
            tree.install();
        }
    }
}

 

싱글톤 패턴과의 차이

싱글톤 패턴은 나무 클래스에 단 한 개의 나무만 만들 수 있다. 따라서 싱글톤 패턴을 사용한다면, 만들어진 단 하나의 객체의 색깔을 바꿔야 한다. 즉, 싱글톤 패턴은 종류 상관 없이 단 하나만 가질 수 있다.

 

사용 사례

Java의 String Constant Pool에서 플라이웨이트 패턴이 사용된다.

 

 

반환 타입의 하위 타입 객체를 반환할 수 있다.

Arrays 유틸 클래스의 asList() 메서드를 사용한 적이 있다면 해당 장점을 이해할 수 있다.

 

public static <T> List<T> asList(T... a) [
    return new ArrayList<>(a);
}

 

List의 하위 구현체인 ArrayList로 값을 래핑하여 반환하는데, 사용자는 이러한 구현체까지 알 필요가 없다. 즉 반환 객체의 클래스를 자유롭게 선택할 수 있다는 유연성은 개발자가 구현체를 공개하지 않고 구현체를 반환할 수 있으므로 API를 작게 유지할 수 있다.

 

자바 인터페이스의 정적 메서드 관련 이야기

자바 8 전에는 인터페이스에 정적 메서드를 선언할 수 없어서 이름이 “Type”인 인터페이스를 반환하는 정적 메서드가 필요하면, “Types”라는 인스턴스화 불가인 동반 클래스를 만들어 그 안에 메서드를 정의하였다.

 

대표적인 예시로 JCF가 제공하는 45개의 유틸리티 구현체가 있는데, 이 구현체 대부분을 단 하나의 동반 클래스인 java.util.Collections 에서 정적 팩터리 메서드를 통해 얻도록 했다. 특히 이 구현체 중에는 public이 아니라서 오로지 정적 팩터리 메서드에 의해서만 인스턴스를 만들 수 있는 구현체도 존재한다. (이 구현체는 당연히 상속을 받을 수 없음.)

 

또한, 45개의 구현체를 공개하지도 않기 때문에 API를 훨씬 작게 만들 수 있었다.

 

// 인터페이스와 동반 클래스의 예
List<String> empty = Collections.emptyList();

 

하지만, Java 8부터는 인터페이스에 바로 정적 메서드를 추가할 수 있기 때문에 동반 클래스를 따로 정의하지 않아도 된다.

 

입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.

단순히 하위 타입을 반환한다는 점을 넘어 파라미터의 값에 따라 다른 하위 타입을 반환할 수 있다. 가령, 점수에 따라 MemberStatus를 다르게 반환하고 싶다면, 아래와 같이 정적 팩터리 메서드를 만들고 그 안에 비교 로직을 세우면 된다.

 

public enum MemberStatus {

    ADVANCED(80, 100),
    INTERMEDIATE(50, 79),
    BASIC(0, 49);

    private final int minScore;
    private final int maxScore;

    MemberStatus(int minScore, int maxScore) {
        this.minScore = minScore;
        this.maxScore = maxScore;
    }

    public static MemberStatus of(int score) {
        return Arrays.stream(values())
                .filter(decideMemberStatus(score))
                .findAny()
                .orElseThrow(() -> new NoSuchElementException("해당하는 MemberStatus 객체가 없습니다."));
    }

    private static Predicate<MemberStatus> decideMemberStatus(int score) {
        return element -> element.minScore <= score && element.maxScore >= score;
    }
}

@DisplayName("MemberStatus 테스트")
class MemberStatusTest {

    @ParameterizedTest
    @CsvSource(value = {"0:BASIC", "30:BASIC", "50:INTERMEDIATE", "70:INTERMEDIATE", "80:ADVANCED", "100:ADVANCED"}, delimiter = ':')
    @DisplayName("점수에 따라 MemberStatus를 다르게 반환한다.")
    void of(int input, MemberStatus expected) {
        assertThat(MemberStatus.of(input)).isEqualTo(expected);
    }
}

 

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

위 문장에서 객체의 클래스는 우리가 작성하는 클래스 파일이 맞다.

 

참고로 Class<?>는 클래스 로더가 클래스를 로딩할 때 힙 영역에 할당하는 Class 객체를 의미한다. 이 Class 객체는 우리가 작성한 클래스의 다양한 메타 데이터를 담고 있다.

 

package algorithm.dataStructure;

public abstract class StaticFactoryMethodType {

    public abstract void getName();

    public static StaticFactoryMethodType getNewInstance() {
        StaticFactoryMethodType temp = null;
        try {
            Class<?> childClass = Class.forName("algorithm.dataStructure.StaticFactoryMethodTypeChild"); // 리플렉션
            temp = (StaticFactoryMethodType) childClass.newInstance(); // 리플렉션

        } catch (ClassNotFoundException e) {
           System.out.println("클래스가 없습니다.");
        } catch (InstantiationException  e) {
            System.out.println("메모리에 올릴수 없습니다.");
        } catch (IllegalAccessException  e) {
            System.out.println("클래스 파일 접근 오류입니다.");
        }

        return temp;
    }
}

 

위 코드를 보면 인터페이스 구현체의 위치를 통해 Class 객체를 생성하고, 리플렉션 기술을 사용하여 실제 구현체를 초기화하는 것을 확인할 수 있다. 이때 정적 팩터리 메서드를 작성하는 시점에는 StaticFactoryMethodTypeChild 클래스는 존재하지 않아도 된다.

 

만약 정적 팩터리 메서드를 사용하는 시점에 algorithm.dataStructure.StaticFactoryMethodTypeChild 경로에 대해 구현체가 없다면 에러가 발생하겠지만, 정적 팩터리 메서드를 작성하는 시점에는 문제가 없으므로 유연하다고 하는 것이다.

 

public interface Test {

    int sum(int a, int b);

    // Test는 인터페이스고 구현체가 없더라도 정적 팩터리 메서드 작성 시점에는 문제가 발생하지 않는다.
    static Test create() {
        return null;
    }
}

public class Main {

    public static void main(String[] args) {
        Test test = Test.create();
        System.out.println(test.sum(1, 2)); // NPE 발생
    }
}

 

리플렉션을 사용하지 않더라도 똑같은 유연함을 얻을 수 있다. Test의 정적 팩터리 메서드인 create() 를 보면, 구현체가 없더라도 작성 시점에는 문제가 발생하지 않는다. 물론 실제 사용 시점에는 NPE가 발생하니, 나중에 구현체를 반환해 주어야 한다.

 

이러한 유연함은 서비스 제공자 프레임워크를 만드는 근간이 되는데, 대표적으로 JDBC가 있다. JDBC 서비스 제공자 프레임워크의 제공자는 서비스의 구현체고, 이 구현체들을 클라이언트에 제공하는 역할을 프레임워크가 통제하여, 클라이언트를 구현체로부터 분리한다.(DIP)

 

  • 서비스 제공자 프레임워크의 컴포넌트
    • 서비스 인터페이스
      • 구현체의 동작을 정의함
      • JDBC의 Connection
    • 제공자 등록 API
      • 제공자가 구현체를 등록함
      • JDBC의 DriverManager.registerDriver()
    • 서비스 접근 API
      • 클라이언트가 서비스의 인스턴스를 얻을 때 사용하며, 조건을 명시하지 않을 경우 기본 구현체 혹은 지원하는 구현체를 돌아가며 반환한다.
      • 정적 팩터리 메소드의 해당함
      • JDBC의 DriverManager.getConnection()
    • (옵션) 서비스 제공자 인터페이스
      • 이것이 없다면 각 구현체를 인스턴스로 만들 때 리플렉션을 사용해야 함
      • JDBC의 Driver

 

서비스 제공자 프레임워크 패턴은 여러 변형이 있으며, 브릿지 패턴, 의존 객체 주입 프레임워크 등이 있다.

 

전형적인 JDBC 예제

Class.forName("oracle.jdbc.driver.OracleDriver"); 
Connection connection = null; 
connection = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521:ORA92", "root", "root"); 

// 각종 Statement를 활용한 sql 로직

 

일반적으로 JDBC는 위와 같이 작성한다. Class.forName() 을 통해 Driver의 구현체 중 하나인 OracleDriver를 등록하고, DriverManager.getConnection() 을 통해 Connection의 구현체 중 하나인 OracleDriver용 Connection을 가져온다.

 

여기서 Connection은 서비스 인터페이스, DriverManager.getConnection() 은 서비스 접근 API, Driver는 서비스 제공자 인터페이스임을 알 수 있다. 하지만 제공자 등록 API인 DriverManager.registerDriver() 는 사용되지 않았다. 그럼에도 불구하고, 우리는 Class.forName() 만 가지고도 Driver의 구현체인 OracleDriver를 등록할 수 있다. 어떻게 이것이 가능할까?

 

Class.forName()의 동작 원리

해당 메소드는 물리적인 클래스 파일 명을 인자로 넣어주면, JVM에게 이 클래스를 로드하라고 요청한다. 그러면 클래스 로더는 클래스의 메타 데이터를 메서드 영역에 저장하는 한편, Class 객체를 힙 영역에 할당하게 된다. 또한, 클래스 로딩이 끝나게 되면 static 필드 및 static 블록이 초기화되며, 이때 제공자 등록 API가 활용된다.

 

public class OracleDriver implements Driver {

    static {
        defaultDriver = null;
        Timestamp timestamp = Timestamp.valueOf("2000-01-01 00:00:00.0");
        try {
            if (defaultDriver == null) {
                defaultDriver = new OracleDriver();
                DriverManager.registerDriver(defaultDriver); // OracleDriver 등록
            }
        } catch (RuntimeException runtimeexception) {
        } catch (SQLException sqlexception) {
        }
    }

    ...
}

 

실제로 OracleDriver를 보면 static 블록 안에서 DriverManager.registerDriver() 를 활용하여 Driver의 구현체인 OracleDriver를 등록하는 것을 알 수 있다.

 

DriverManager 클래스 분석

public class DriverManager {

    private DriverManager() {
    }

    private static final Map<String, Driver> drivers = new ConcurrentHashMap<String, Driver>();
    public static final String DEFAULT_DRIVER_NAME = "default";

    public static void registerDefaultPrivider(Driver d) {
        System.out.println("Driver 등록");
        registerDriver(DEFAULT_DRIVER_NAME, d);
    }

    public static void registerDriver(String name, Driver d) {
        drivers.put(name, d);
    }

    public static Connection getConnection() {
        return getConnection(DEFAULT_DRIVER_NAME);
    }

    public static Connection getConnection(String name) {
        Driver d = drivers.get(name);
        if (d == null) throw new IllegalArgumentException();
        return d.getConnection();
    }
}

 

DriverManager 클래스는 실제로 훨씬 복잡하겠지만, 핵심만 간추려서 쉽게 보면 위와 유사하다. 위에서 설명한 대로 registerDriver() 를 OracleDriver의 static 블록에서 호출하여 OracleDriver를 등록하고, getConnection() 를 호출하여 사용자는 Connection의 구현체를 얻어올 수 있다.

 

사용자 접근 API인 getConnetion() 을 자세히 보면, Driver 인터페이스로부터 Connection을 얻어오는 것을 확인할 수 있다. 만약 서비스 제공 인터페이스인 Driver가 없다면, 원하는 Connection 구현체를 반환하기 위해 Class.forName() 과 같은 리플렉션을 사용할 수 있다. 이때 Connection 구현체는 정적 팩터리 작성 시점에는 존재하지 않아도 된다.

 

대신 우리는 Driver 인터페이스를 사용하며, 동적으로 Driver의 구현체를 등록한 뒤, 이 Driver에 맞는 Connection 구현체를 쉽게 얻어올 수 있다.

 

참고로 DriverManager의 getConnection() 메서드의 실제 JDK 코드를 분석해 보았는데, 크게 관심이 없다면 건너뛰어도 무방하다.

 

@CallerSensitive
public static Connection getConnection(String url,
    String user, String password) throws SQLException {
    java.util.Properties info = new java.util.Properties();

    if (user != null) {
        info.put("user", user);
    }
    if (password != null) {
        info.put("password", password);
    }

    return (getConnection(url, info, Reflection.getCallerClass()));
}

 

먼저 public 정적 메서드인 getConnection() 이 호출되며, url, Properties 및 CallerClass가 private 정적 메서드인 getConnection() 의 인자로 넘어간다. 이때 Reflection.getCallerClass() 는 해당 public 정적 메서드인 getConnection() 을 호출한 클래스를 얻어 오는 역할을 한다. 만약 Car 클래스가 getConnection() 을 호출했다면, Reflection.getCallerClass() 에 의해 Class 객체를 얻어올 수 있다.

 

private static Connection getConnection(String url, java.util.Properties info, Class<?> caller) throws SQLException {
    ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
    synchronized(DriverManager.class) {
        if (callerCL == null) {
            callerCL = Thread.currentThread().getContextClassLoader();
        }
    }

    if(url == null) {
        throw new SQLException("The url cannot be null", "08001");
    }

    SQLException reason = null;
    for(DriverInfo aDriver : registeredDrivers) {
        if(isDriverAllowed(aDriver.driver, callerCL)) {
            try {
                Connection con = aDriver.driver.connect(url, info);
                if (con != null) {
                    return (con);
                }
            } catch (SQLException ex) {
                if (reason == null) {
                    reason = ex;
                }
            }
        }
    }

    if (reason != null)    {
        throw reason;
    }
    throw new SQLException("No suitable driver found for "+ url, "08001");
}

 

callerCL은 클래스 로더 객체이며, caller 혹은 현재 스레드의 클래스 로더에 의해 만들어 진다. 이후 현 애플리케이션에서 등록된 Driver 목록인 registeredDrivers에서 aDriver를 하나씩 꺼내온다. 그리고 이 Driver가 isDriverAllowed() 에 의해 true가 나왔다면, 그 Driver로 Connection 객체를 얻어오고 이를 반환한다. isDriverAllowed() 는 caller에서 aDriver가 존재하는지 확인하는 역할을 한다.

 

JDBC 프레임워크의 장점

JDBC 프레임워크의 요점은 Driver, Connection 인터페이스와 그 실제 인터페이스를 구현하는 구현체 클래스가 완전히 분리되어 제공된다는 것이다. 인터페이스를 사용하여 틀을 만들어 놓고, 그 틀에 맞춰서 각각 구현 클래스를 만들면 되니 매우 유연하다는 장점이 있다.

 

그래서 다른 DBMS가 나와도 그 벤더사는 Driver와 Connection 인터페이스를 구현하여 제공하면, Java를 사용하는 개발자가 다른 DBMS 드라이버와 동일한 API를 사용할 수 있게 된다.

 

정적 팩터리 메서드의 단점

상속을 할 때 public or protected 생성자가 필요하므로 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.

그러나 이러한 제약은 상속보다는 컴포지션을 유도하고, 불변 타입을 만들기 위해 이 제약을 지켜야한다는 점에서 오히려 장점이 될 수도 있다.

 

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

생성자처럼 API 설명에 명확히 드러나지 않기 때문에, 개발자는 API 문서를 잘 써 놓고 메서드 이름도 널리 알려진 규약을 따라 짓는 식으로 문제를 완화해야 한다.

 

정적 팩터리 메서드 명명 방식

  • from
    • 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환
    • Date date = Date.from(instant);
  • of
    • 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환
    • Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
  • valueOf
    • from과 of의 더 자세한 버전
    • BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
  • instance 혹은 getInstance
    • 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지는 않는다.
    • StackWalker luke = StackWalker.getInstance(options);
  • create 혹은 newInstance
    • instance 혹은 getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다.
    • Object newArray = Array.newInstance(classObject, arraylen);
  • getType
    • getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩토리 메소드를 정의할 때 쓴다.
    • FileStore fs = Files.getFileStore(path);
  • newType
    • newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩토리 메소드를 정의할 때 쓴다.
    • BufferedReader br = Files.newBufferedReader(path);
  • type
    • getType과 newType의 간결한 버전
    • List<Complaint> litany = Collections.list(legacyLitany);

 

정리

정적 팩터리 메서드와 public 생성자는 각자의 쓰임새가 있으니 적절하게 사용하자.

 

출처

댓글

추천 글