본문 바로가기
Computer Sience/Java

[Effective Java] 아이템1: 생성자 대신 정적 팩터리 메소드를 고려해라

by 제우제우 2024. 10. 21.

Chapter 02: 객체 생성과 파괴

객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법

올바른 객체 생성 방법에 대한 설명

정적 팩터리 메서드

클래스 인스턴스를 얻는 기본적인 방법 → public 생성자 

클래스는 생성자와 별도로 정적 팩터리 메소드(static factory method)를 제공할 수 있다.

단순하게 클래스 인스턴스를 반환하는 정적 메소드 

 

Boolean class 

public final class Boolean implements 
		java.io.Serializable, Comparable<Boolean>, Constable {
	
     public static final Boolean TRUE = new Boolean(true);
     public static final Boolean FALSE = new Boolean(false);   
    
     @IntrinsicCandidate
     public static Boolean valueOf(boolean b) {
        return (b ? TRUE : FALSE);
     }
}

 

정적 팩터리 메서드인 valueOf는 기본 타입인 boolean 값을 받아 Boolean 객체 잠조로 변환해 준다. 

Boolean 클래스 생성자는 @Deprecated

 

이미 클래스 로딩 시점에 메모리(Constant Pool)에 저장된(캐싱) 값을 재사용하기 때문에 메모리를 절약한다. 

정적 팩터리 메서드 장점은?

1. 이름을 가질 수 있다. 

Person Class

public class Person {
    private String name;
    private String address;
    private int age;
    private Person(String name) {
        this.name = name;
    }
    private Person(int age) {
        this.age = age;
    }
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    // 정적 팩터리 메소드 
    public static Person personWithName(String name){
        return new Person(name);
    }
    public static Person personWithAge(int age){
        return new Person(age);
    }
    public static Person personWithNameAndAge(String name, int age){
        return new Person(name, age);
    }
}

// Person 생성 클라이언트 
public class App {
    public static void main(String[] args) {
        Person person1 = new Person("배제우", 12);
        Person person2 = Person.personWithNameAndAge("배제우", 12);
    }
}

 

Person 인스턴스를 생성자로 만들어보자 

생성자는 모두 클래스 이름이며 어떤 필드를 설정하면서 객체를 생성하는지 알 수 없다.

반면에 정적 팩토리 메소드는 메소드의 이름을 통해 생성 의도를 명확하게 전달할 수 있다. 

2. 생성자의 시그니처 제약 

public class Person {
    private String name;
    private String address;
    private int age;
    
    public Person(String name) {
        this.name = name;
    }
    // 컴파일 오류
    public Person(String address) {
        this.address = address;
    }   
}

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

이름을 가질 수 있는 정적 팩토리 메소드는 이런 제약이 없다.

public static Person personWithName(String name){
    Person person = new Person();
    person.name = name;
    return person;
}
public static Person personWithAddress(String address){
    Person person = new Person();
    person.address = address;
    return person;
}

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

Boolean 클래스의 valueOf() 메소드는 Constant Pool에 캐싱된 인스턴스의 참조를 반환한다. 

즉 인스턴스를 생성하지 않고 반환한다.

 

기본(원시)타입인 int 래퍼 클래스 Integer 또한 그렇다.

public final class Integer extends Number
        implements Comparable<Integer>, Constable, ConstantDesc {
        
    // 나머지 생략 ...    
    
    @IntrinsicCandidate
    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }    
}        

// -128 ~ 127 사이면 캐싱된 값 아니면 new Integer 생성
Integer num = Integer.valueOf(10);

 

Integer 클래스의 valueOf(int) 메서드도 Boolean과 유사하게 캐싱을 통해 메모리와 성능을 최적화한다.
기본적으로 Integer는 -128에서 127 사이의 정수 값에 대해 캐시된 인스턴스를 반환하며, 이 범위 외의 값에 대해서는 새로운 객체를 생성한다.

 

생성 비용이 큰 같은 객체가 자주 요청되는 상황이라면 많은 성능 향상이 가능하다.

플라이웨이트 패턴(Flyweight Pattern)도 이와 비슷한 기법이라 할 수 있다.

 

반복되는 요청에 같은 객체를 반환하는 식으로 정적 팩토리 방식의 클래스는

인스턴스의 생명 주기를 통제할 수 있다.

인스턴스 통제(instance-controlled) 클래스 

 

인스턴스 통제의 이유?

  • 필요하다면 하나의 인스턴스(싱글톤)만 존재하도록 강제할 수 있다.
  • 인스턴스화 불가로 만들 수도 있다. 
  • 불변 값 클래스에서 동치인 인스턴스가 단 하나뿐임을 보장할 수 있다. (Boolean 클래스)

싱글톤 

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();  // 단 하나의 인스턴스만 생성
    private Singleton() { }
    public static Singleton getInstance() {
        return INSTANCE;  // 언제나 같은 인스턴스를 반환
    }
}

 

인스턴스화 불가로 만들기

public class MyMathUtils {
    private MathUtils() {  // 인스턴스화 불가능하게 설정
    }
    public static int add(int a, int b) {
        return a + b;
    }
}

어떤 클래스는 인스턴스화할 필요가 없거나, 절대 인스턴스화되지 않아야 하는 클래스가 있다. 

이 경우, 생성자를 private 선언하여 외부에서 인스턴스를 만들 수 없도록 막을 수 있다.

이런 구조는 보통 유틸리티 클래스에서 사용한다.

 

정리하면 인스턴스 통제를 통해서 객체의 생성과 사용을 목적에 따라 할 수 있기 때문이다.

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

반환할 객체의 클래스를 자유롭게 선택할 수 있다. 

public interface Bird {
}

public interface FlyBirds extends Bird {
}

public interface NonFlyBirds extends Bird{
}

/**
 * 독수리: can fly bird
 */
public class Eagle implements FlyBirds{
}

/**
 * 펭귄: cant fly
 */
public class Penguin implements NonFlyBirds{
}

/**
 * Bird 하위 타입 인스턴스를 만드는 유틸성 클래스
 * Java8: 인터페이스에 static method 사용 가능
 * java9: 인터페이스에 private static method 사용 가능
 */
public class Birds {
    private Birds () {}

    /**
     * can fly bird 반환하는 정적 팩토리 메소드
     * return Eagle
     */
    public static FlyBirds flyBird(){
        return new Eagle();
    }
    /**
     * cant fly bird 반환하는 정적 팩토리 메소드
     * return Penguin
     */
    public static NonFlyBirds nonFlyBirds(){
        return new Penguin();
    }
}

 

현재 Birds 클래스는 정적 팩토리 메소드인 flyBird(), nonFlyBird()를 통해서 

FlyBirds와 NoFlyBirds 인터페이스의 구현체를 반환한다. 

이 방식은 여러 장점을 제공한다. 

 

유연성 제공

정적 팩토리 메소드를 사용하면 클라이언트는 구체적인 클래스에 의존하지 않고 인터페이스 타입으로 

객체를 받을 수 있다. 

이렇게 하면 추후에 클래스를 변경하더라도 클라이언트 코드의 수정이 필요 없고, 새로운 구현 클래스를 추가하기도 용이하다.

 

구체 타입에 대한 캡슐화

클라이언트는 flyBird()와 nonFlyBirds() 메소드를 호출하여 각각 Eagle과 Penguin 인스턴스를 얻는다.

이때 클라이언트는 구현체에 대한 세부 정보를 알 필요가 없으며, 단지 인터페이스에 정의된 메서드만 사용하면 된다.
따라서 구현 세부 사항이 감춰져 코드의 가독성과 유지보수성이 향상된다.

 

API의 개념적인 무게 줄이기

클라이언트가 Birds 클래스를 통해 새를 생성하는 방식은 API의 복잡성을 줄여준다. 사용자는 FlyBirds나 NonFlyBirds와 같은 인터페이스만 알면 되며, 이를 통해 어떤 종류의 새가 생성되는지를 신경 쓰지 않아도 된다.
이러한 방식은 클라이언트에게 더 직관적이며 간단한 API를 제공한다.

 

책에서는 자바 컬렉션 프레임워크를 예시로 들고 있다. 

java.util.Collections 클래스는 인스턴스화가 불가능한 클래스이다.

이런 인스턴스화가 불가능하고 인터페이스를 반환하는 클래스를 동반 클래스(companion class)라고 한다. 

public class Collections {
    // Suppresses default constructor, ensuring non-instantiability.
    private Collections() {
    }
    public static <E> SortedSet<E> emptySortedSet() {
        return (SortedSet<E>) UnmodifiableNavigableSet.EMPTY_NAVIGABLE_SET;
    }
}

 

해당 클래스에서는 컬렉션 프레임워크 핵심 인터페이스에 여러가지 기능을 추가한 수많은 유리릴티 구현체를 정적 팩토리 메소드를 통해서 얻도록 하였다.

 

Collections 클래스에서 제공하는 메소드 덕분에, 개발자는 45개의 다양한 컬렉션 구현체를 직접 알고 사용할 필요가 없다.

→ 개념적인 무게를 줄였다. 

 

자바8,9 변화

자바8: 인터페이스에서 public static method 사용 가능 

 

이런 기능의 추가로 Collecions라는 동반 클래스를 만들지 않고도 인터페이스에 구현 가능하다.
하지만 
private static은 자바 9부터 가능하다.

 

자바9: 인터페이스에서 private static method 사용 가능 

자바9를 쓰면 private static까지 인터페이스에 추가할 수 있으니 java.util.Collections 같은 유틸성 클래스들은 필요가 없어진다.

 

Birds 동반 클래스 삭제 → Bird 인터페이스에 정적 팩토리 메소드 정의

/**
 * 자바8 기능
 */
public interface Bird {
    static FlyBirds createFlyBird(){
        return new Eagle();
    }
    static NonFlyBirds createNonFlyBird() {
        return new Penguin();
    }
}

public interface FlyBirds extends Bird {
}
public interface NonFlyBirds extends Bird {
}
/**
 * 독수리: can fly bird
 */
public class Eagle implements FlyBirds {
}
/**
 * 펭귄: cant fly
 */
public class Penguin implements NonFlyBirds {
}

public class App {
    public static void main(String[] args) {
        Eagle eagle = (Eagle)Bird.createFlyBird();
        Penguin penguin = (Penguin) Bird.createNonFlyBird();
    }
}

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

public interface Bird {
    static FlyBirds createFlyBird(){
        return new Eagle();
    }
    static FlyBirds createFlyBird(String type){
        if(type == null || type.isBlank()){
            return createFlyBird();
        }

        if("eagle".equalsIgnoreCase(type)){
            return new Eagle();
        }
        else if("sparrow".equalsIgnoreCase(type)){
            return new Sparrow();
        }
        throw new IllegalArgumentException("Invalid bird type: " + type);
    }
    static NonFlyBirds createNonFlyBird() {
        return new Penguin();
    }
}

public class App {
    public static void main(String[] args) {
        Eagle eagle = (Eagle) Bird.createFlyBird();
        Eagle eagle2 = (Eagle) Bird.createFlyBird("eaGLE");
        Sparrow sparrow = (Sparrow) Bird.createFlyBird("sparrow");
        Eagle eagle3 = (Eagle) Bird.createFlyBird(null);

        System.out.println(eagle.getClass());
        System.out.println(eagle2.getClass());
        System.out.println(sparrow.getClass());
        System.out.println(eagle3.getClass());

        try {
            Bird.createFlyBird("unknown");
        }
        catch (IllegalArgumentException e){
            System.out.println(e.getMessage()); // 출력: Invalid bird type: unknown
        }
    }
}

출력
class com.example.effectivejavacode._02.item1.bird3.Eagle
class com.example.effectivejavacode._02.item1.bird3.Eagle
class com.example.effectivejavacode._02.item1.bird3.Sparrow
class com.example.effectivejavacode._02.item1.bird3.Eagle
Invalid bird type: unknown

 

장점 4인 반환 타입의 하위 타입 객체를 반환할 수 있다의 연장선이다. 

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

→ 유연성 / 캡슐화 

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

"리턴하는 객체의 클래스가 메소드를 작성하는 시점에 반드시 존재하지 않아도 된다"는 유연성과 확장성을 강조하는 매우 중요한 개념이다.

이 개념은 서비스 프로바이더 프레임워크의 핵심 요소로 작용한다.

이 개념은 시스템의 구조와 확장성에서 큰 장점을 제공하며, 새로운 구현체를 시스템에 추가하거나 변경할 때 API나 클라이언트 코드를 수정할 필요가 없다는 점에서 특히 유용하다.

 

서비스 프로바이더 프레임워크

서비스 프로바이더 프레임워크는 서비스 인터페이스, 서비스 구현체(프로바이더), 서비스 액세스 API를 통해 유연하게 다양한 구현체를 관리하고 제공하는 구조를 의미한다.

이를 통해 정적 팩토리 메소드를 사용해 서비스의 구현체를 동적으로 선택할 수 있으며, 이런 방식으로 여러 구현체를 쉽게 확장할 수 있다.

 

서비스 인터페이스

구현체를 대표하는 인터페이스. 클라이언트는 이 인터페이스를 사용해 다양한 구현체와 상호작용할 수 있다.

 

프로바이더 등록 API

서비스 구현체를 시스템에 등록하는 역할

 

서비스 액세스 API

클라이언트가 서비스 인스턴스를 가져오는 데 사용하는 API

 

서비스 프로바이더 인터페이스 (선택적)

실제 서비스의 구현체를 제공하는 인터페이스로, 리플렉션을 통해 구현체를 생성할 수도 있다.

 

JDBC의 예

JDBC는 서비스 프로바이더 프레임워크의 대표적인 예로, 정적 팩토리 메소드를 통해 서비스의 구현체를 동적으로 로드하고 다양한 DB 드라이버와 연결할 수 있도록 한다.

 

DriverManager.registerDriver(): 드라이버(서비스 프로바이더)를 등록하는 API이다.

DriverManager.getConnection(): 클라이언트가 데이터베이스 연결(서비스 인스턴스)을 가져오는 데 사용하는 서비스 액세스 API이다.

Driver: 서비스 프로바이더 인터페이스로, 다양한 데이터베이스 드라이버가 이 인터페이스를 구현한다.

 

이 프레임워크는 정적 팩토리 메소드를 통해 특정 데이터베이스와의 연결을 캡슐화하고, 클라이언트는 구체적인 드라이버 구현체를 알 필요 없이 DriverManager.getConnection()만 호출하면 된다.

 

정리

이러한 방식의 정적 팩토리 메소드는 구현체가 작성 시점에 존재하지 않더라도 다양한 서비스 프로바이더를 유연하게 관리할 수 있다는 장점이 있다. 시스템을 더욱 확장 가능하게 하고, 클라이언트는 구체적인 구현체에 의존하지 않기 때문에 API의 개념적인 무게가 줄어들고 유연성과 확장성이 크게 증가한다.

 

 

JDBC 정리 

  1. JDBC 드라이버 등록
    • 각 데이터베이스(DB)는 JDBC 드라이버라는 구현체를 제공한다.
    • JDBC 드라이버는 JDBC 인터페이스를 구현한 클래스이다.
    • JDBC 드라이버는 DriverManager.registerDriver() API를 통해 등록된다.
  2. 드라이버 자동 등록
    • 직접 registerDriver()를 호출하는 대신, 대부분의 경우 자동 설정(AutoConfiguration)을 사용한다.
    • 스프링 부트 같은 프레임워크에서는 application.yml 또는 application.properties 파일에 DB URL, 사용자 이름, 비밀번호를 설정하면, JDBC 드라이버가 자동으로 등록된다.
  3. DB 연결
    • 드라이버가 등록되면 DriverManager.getConnection()을 통해 DB 연결을 획득할 수 있다.
    • 이때 getConnection()은 설정된 DB URL, 사용자 이름, 비밀번호를 사용하여 연결을 만든다.
  4. 쿼리 실행
    • DB 연결이 성공하면, 해당 연결 객체인 Connection을 통해 SQL 쿼리를 실행할 수 있다.
    • 이를 통해 데이터베이스와 통신하고, 데이터를 읽거나 쓰는 작업을 수행한다.

이 과정은 서비스 프로바이더 프레임워크의 한 예시로 볼 수 있으며, JDBC는 해당 패턴을 따르는 대표적인 라이브러리 중 하나이다.

정적 팩터리 메서드 단점은?

프로그래머가 static 팩토리 메소드를 찾는게 어렵다.

생성자는 자바 API 문서에서 별도의 섹션으로 표시되지만, static 팩토리 메소드는 자동으로 별도의 섹션으로 다뤄지지 않기 때문에, 이를 명확하게 문서화하는 것이 중요하다.