김코딩

[ Effective Java ] 1. 생성자 대신 정적 팩터리 메서드를 고려하라 본문

개발 서적/Effective Java

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

김코딩딩 2025. 9. 25. 23:52

정적 팩터리 메서드란?

객체 생성을 담당하는 정적(static) 메서드이다. 생성자 대신 또는 생성자와 함께 객체를 생성하는 방법을 제공한다.
public class Person {
    private String name;
    private int age;
    
    // private 생성자 (직접 생성 방지)
    private Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // 정적 팩터리 메서드들
    public static Person newBaby(String name) {
        return new Person(name, 0);
    }
    
    public static Person newAdult(String name, int age) {
        if (age < 18) {
            throw new IllegalArgumentException("성인은 18세 이상이어야 합니다.");
        }
        return new Person(name, age);
    }
    
    public static Person newSenior(String name) {
        return new Person(name, 65);
    }
}

사용 방법

// 생성자 방식 (만약 public이라면)
Person person1 = new Person("김철수", 25);  // 의미가 불분명

// 정적 팩터리 메서드 방식
Person baby = Person.newBaby("김아기");      // 명확한 의미
Person adult = Person.newAdult("김어른", 30); // 명확한 의미  
Person senior = Person.newSenior("김할머니"); // 명확한 의미

장점

1. 이름을 가질 수 있다.

 

생성자

- 생성자에 넘기는 매개변수와 생성자 자체만으로는 반환될 객체의 특성을 제대로 설명하지 못한다.

- 하나의 시그니처로는 생성자를 하나만 만들 수 있다.

 

정적 팩터리 메서드

- 정적 팩터리는 이름만 잘지으면 반환될 객체의 특성을 쉽게 묘사할 수 있다.

- 한 클래스에 시그니처가 같은 생성자가 여러 개 필요할 것 같으면, 생성자를 정적 팩터리 메서드로 바꾸고 각각의 차이를 잘 드러내는 이름으로 지을 수 있다.

 

ex) "값이 소수인 BigInteger를 반환한다."

생성자 : BigInteger(int, int, Random)

정적 팩터리 메서드 : BigInteger.probablePrime()

 

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

- 불변 클래스는 인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있다. ex) Boolean.valueOf(boolean)

- 플라이웨이트(Flyweight pattern) 패턴도 이와 비슷한 기법이다.

플라이웨이트 패턴 : 구조적 디자인 패턴 중 하나로, 메모리 사용량을 최소화하기 위해 객체들 간에 상태를 효율적으로 공유하는 패턴

- 정적 팩터리 방식의 클래스는 언제 어느 인스턴스를 살아 있게 할지를 철저히 통제할 수 있다. (인스턴스 통제 클래스 라고 한다.)

- 인스턴스를 통제하면 클래스를 싱글턴(Singleton)으로 만들 수도, 인스턴스화 불가로 만들수도 있다.

 

3. 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.

- 대표적인 예시로 자바의 Collections가 있다.

- 자바 8부터는 인터페이스가 정적 메서드를 가질 수 없다는 제한이 풀렸다.

 

Java 8 이전 방식

// 인터페이스
public interface List<E> extends Collection<E> {
    // 정적 메서드 선언 불가능
}

// 별도의 유틸리티 클래스 필요
public class Collections {
    public static <T> List<T> emptyList() {
        return (List<T>) EMPTY_LIST;
    }
    
    public static <T> List<T> singletonList(T o) {
        return new SingletonList<>(o);
    }
}

// 사용
List<String> empty = Collections.emptyList();

 

Java 8 이후 방식

// 인터페이스 내부에 정적 팩터리 메서드 직접 선언 가능
public interface List<E> extends Collection<E> {
    
    // Java 9부터 실제로 추가된 팩터리 메서드들
    static <E> List<E> of() {
        return ImmutableCollections.emptyList();
    }
    
    static <E> List<E> of(E e1) {
        return new ImmutableCollections.List12<>(e1);
    }
    
    static <E> List<E> of(E e1, E e2) {
        return new ImmutableCollections.List12<>(e1, e2);
    }
    
    @SafeVarargs
    static <E> List<E> of(E... elements) {
        // 구현체는 숨기고 List 타입으로 반환
        return ImmutableCollections.listFromTrustedArray(elements);
    }
}

// 사용 - 더 직관적!
List<String> list = List.of("a", "b", "c");

 

 

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

- 반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체를 반환하든 상관 없다.

대표적인 예시 : EnumSet

public abstract class EnumSet<E extends Enum<E>> {
    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);    // long 배열 사용
    }
}
// 사용 - 클라이언트는 구현체를 신경 쓰지 않음
EnumSet<Color> colors = EnumSet.noneOf(Color.class);        // RegularEnumSet 반환
EnumSet<BigEnum> bigSet = EnumSet.noneOf(BigEnum.class);    // JumboEnumSet 반환 (원소가 64개 초과)

 

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

- 정적 팩터리 메서드를 미리 정의해놓고, 나중에 구현체가 추가되어도 기존 코드 변경 없이 동작 할 수 있다.

대표적인 예시 : JDBC

 

처음에는 인터페이스와 팩터리 메서드만 존재

// java.sql 패키지에 미리 정의됨
public class DriverManager {
    // 이 메서드가 작성될 때는 MySQL, Oracle 등의 구체적인 드라이버가 없었음!
    public static Connection getConnection(String url) {
        // 등록된 드라이버들 중에서 url에 맞는 것을 찾아 반환
        for (DriverInfo aDriver : registeredDrivers) {
            if (aDriver.driver.acceptsURL(url)) {
                Connection con = aDriver.driver.connect(url, info);
                if (con != null) {
                    return con;  // 구체적인 Connection 구현체 반환
                }
            }
        }
        throw new SQLException("No suitable driver found");
    }
}

 

나중에 구현체들이 추가됨

// MySQL 드라이버 (나중에 개발됨)
public class MySQLDriver implements Driver {
    static {
        // 드라이버 자동 등록
        DriverManager.registerDriver(new MySQLDriver());
    }
    
    public Connection connect(String url, Properties info) {
        return new MySQLConnection(url, info);  // MySQL 전용 Connection
    }
}

// Oracle 드라이버 (나중에 개발됨)
public class OracleDriver implements Driver {
    static {
        DriverManager.registerDriver(new OracleDriver());
    }
    
    public Connection connect(String url, Properties info) {
        return new OracleConnection(url, info);  // Oracle 전용 Connection
    }
}

 

클라이언트 코드는 변경 없이 동작

// DriverManager.getConnection()이 작성될 때는
// MySQLConnection, OracleConnection 클래스가 존재하지 않았지만
// 나중에 추가되어도 기존 코드는 그대로 동작!

Connection mysqlConn = DriverManager.getConnection("jdbc:mysql://localhost/test");
// -> MySQLConnection 인스턴스 반환

Connection oracleConn = DriverManager.getConnection("jdbc:oracle:thin:@localhost:1521:xe");
// -> OracleConnection 인스턴스 반환

단점

1. 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.

 

- 정적 팩터리 메서드만 제공하려면 보통 생성자를 private으로 만드는데, 이렇게 되면 상속이 불가능해진다.
- 해결 방법 : 

  1. 생성자를 protected로 만들기
  2. public 생성자정적 팩터리 메서드를 함께 제공(이러면 정적 팩터리 메서드를 쓰는 이유가 있을까..?)
  3. 컴포지션 사용

상속의 기본 원리

// 부모 클래스
public class Parent {
    private String name;
    
    public Parent(String name) {  // public 생성자
        this.name = name;
    }
}

// 자식 클래스
public class Child extends Parent {
    private int age;
    
    public Child(String name, int age) {
        super(name);  // 부모의 생성자를 반드시 호출해야 함!
        this.age = age;
    }
}

 

문제 상황 : private 생성자

// 정적 팩터리 메서드만 제공하는 클래스
public class Person {
    private String name;
    
    // 생성자를 private으로 숨김
    private Person(String name) {
        this.name = name;
    }
    
    // 정적 팩터리 메서드만 제공
    public static Person create(String name) {
        return new Person(name);
    }
}

// 상속 시도 - 컴파일 에러!
public class Student extends Person {
    private String school;
    
    public Student(String name, String school) {
        super(name);  //컴파일 에러 private 생성자에 접근 불가
        this.school = school;
    }
}

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

- 생성자처럼 API 설명에 명확히 드러나지 않으니 사용자는 정적 팩터리 메서드 방식 클래스를 인스턴스화할 방법을 알아내야 한다.

 

정적 팩터리 메서드에서 흔히 사용하는 명명 방식

더보기

- from : 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드

의미: "~으로부터" 변환하여 생성

// Date에서 Instant로 변환
Date date = new Date();
Instant instant = Instant.from(date.toInstant());

// String에서 BigInteger로 변환  
BigInteger bigInt = BigInteger.from("12345");

// 다른 타입에서 변환하는 경우
LocalDate date = LocalDate.from(localDateTime);  // LocalDateTime → LocalDate

 

- of : 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드

의미: "~의", 여러 값들을 조합하여 생성

// 여러 원소로 Set 생성
Set<String> colors = Set.of("red", "green", "blue");

// 년, 월, 일로 LocalDate 생성
LocalDate christmas = LocalDate.of(2023, 12, 25);

// 여러 원소로 List 생성
List<Integer> numbers = List.of(1, 2, 3, 4, 5);

// EnumSet 생성
enum Day { MON, TUE, WED, THU, FRI }
EnumSet<Day> weekdays = EnumSet.of(Day.MON, Day.TUE, Day.WED, Day.THU, Day.FRI);

 

- valueOf : from과 of의 더 자세한 버전

의미: "~의 값으로", 주로 문자열이나 기본 타입을 객체로 변환

// String을 Boolean으로 변환
Boolean bool = Boolean.valueOf("true");   // "true" → Boolean.TRUE
Boolean bool2 = Boolean.valueOf(false);   // boolean → Boolean

// String을 Integer로 변환  
Integer num = Integer.valueOf("123");     // "123" → Integer(123)
Integer num2 = Integer.valueOf(42);       // int → Integer

// enum 변환
enum Color { RED, GREEN, BLUE }
Color color = Color.valueOf("RED");       // "RED" → Color.RED

 

- instance 혹은 getInstance : 매개변수를 받아 매개변수로 명시한 인스턴스 반환

의미: 싱글턴이거나 매개변수에 맞는 기존 인스턴스 반환 (새로 만들지 않을 수 있음)

// 캘린더 인스턴스 (로케일별로 다른 구현체)
Calendar cal = Calendar.getInstance();              // 기본 로케일
Calendar usCal = Calendar.getInstance(Locale.US);   // 미국 로케일

// 시간대별 인스턴스
TimeZone seoulTz = TimeZone.getInstance("Asia/Seoul");
TimeZone nyTz = TimeZone.getInstance("America/New_York");

// 커스텀 예시
public class DatabaseConnection {
    private static final Map<String, DatabaseConnection> instances = new HashMap<>();
    
    public static DatabaseConnection getInstance(String database) {
        return instances.computeIfAbsent(database, DatabaseConnection::new);
    }
}

 

- create 또는 newInstance : 매번 새로운 인스턴스를 생성해서 반환

의미: 항상 새로운 객체 생성을 보장

// Array 생성 (항상 새로운 배열)
Object newArray = Array.newInstance(String.class, 10);  // String[10] 생성

// Thread 생성
Thread thread = Thread.newThread(() -> System.out.println("Hello"));

// 커스텀 예시  
public class Report {
    public static Report create(String title) {
        return new Report(title);  // 항상 새 인스턴스
    }
    
    public static Report newInstance(String title, String content) {
        return new Report(title, content);  // 항상 새 인스턴스
    }
}

// 사용
Report report1 = Report.create("2023 분기 보고서");
Report report2 = Report.newInstance("연간 보고서", "상세 내용...");

 

- getType : 다른 타입의 인스턴스를 생성, Type은 반환할 객체의 타입

의미: "~타입을 가져와", 보통 팩터리 클래스에서 다른 타입 생성

// Files 클래스에서 다른 타입들 생성
Path path = Paths.get("/tmp/file.txt");
FileStore fileStore = Files.getFileStore(path);        // FileStore 타입 반환
FileSystem fileSystem = FileSystems.getFileSystem(uri); // FileSystem 타입 반환

// 커스텀 예시
public class ConnectionFactory {
    public static DatabaseConnection getConnection(String url) {
        return new DatabaseConnection(url);
    }
    
    public static HttpClient getHttpClient() {
        return HttpClient.newHttpClient();
    }
    
    public static FtpClient getFtpClient(String server) {
        return new FtpClient(server);
    }
}

 

- newType : 다른 타입의 새로운 인스턴스를 생성

의미: "새로운 ~타입", 매번 새로운 다른 타입의 객체 생성

// Files에서 다양한 타입의 새 객체 생성
Path path = Paths.get("/tmp/input.txt");
BufferedReader reader = Files.newBufferedReader(path);     // BufferedReader 생성
BufferedWriter writer = Files.newBufferedWriter(path);     // BufferedWriter 생성
InputStream inputStream = Files.newInputStream(path);      // InputStream 생성

// 커스텀 예시
public class IOFactory {
    public static BufferedReader newReader(String filename) throws IOException {
        return Files.newBufferedReader(Paths.get(filename));
    }
    
    public static PrintWriter newWriter(String filename) throws IOException {
        return new PrintWriter(Files.newBufferedWriter(Paths.get(filename)));
    }
}

 

- type : getType과 newType의 간결한 버전

의미: 타입 이름만으로 간단히 표현

// Collections에서 다양한 컬렉션 타입 반환
List<String> list = Collections.list(enumeration);        // List 타입 반환
Set<String> set = Collections.set(array);                  // Set 타입 반환  

// 커스텀 예시
public class CollectionUtils {
    public static <T> List<T> list(T... elements) {
        return Arrays.asList(elements);
    }
    
    public static <T> Set<T> set(T... elements) {
        return new HashSet<>(Arrays.asList(elements));
    }
    
    public static <K, V> Map<K, V> map(K key, V value) {
        Map<K, V> map = new HashMap<>();
        map.put(key, value);
        return map;
    }
}

// 사용
List<String> colors = CollectionUtils.list("red", "green", "blue");
Set<Integer> numbers = CollectionUtils.set(1, 2, 3, 4, 5);
Map<String, Integer> ages = CollectionUtils.map("Alice", 25);

 

명명 규칙 선택 가이드

 

  • 변환: from, valueOf
  • 조합/생성: of
  • 싱글턴/캐싱: getInstance
  • 항상 새로 생성: create, newInstance
  • 다른 타입 반환: getType, newType, type

Effective Java 에서

정적 팩터리 메서드와 public 생성자는 각자의 쓰임새가 있으니 상대적인 장단점을 이해하고 사용하는 것이 좋다. 그렇다고 하더라도 정적 팩터리 메서드를 사용하는게 유리한 경우가 더 많으므로 무작정 public 생성자를 제공하던 습관이 있으면 고치자