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

뚝딱이·2024년 1월 7일
0

이펙티브 자바

목록 보기
2/55
post-thumbnail

정적 팩터리 메서드

우리가 보편적으로 클래스를 만들어 인스턴스를 생성하기 위해서 사용하는 것은 public 생성자이다. 하지만 우리는 public 생성자 말고도 인스턴스를 생성하기 위해 사용할 수 있는 기법이 존재한다. 바로 정적 팩터리 메서드를 사용하는 것이다.

그렇다고 해서 디자인 패턴의 팩토리 메서드 패턴등을 사용하라는 것이 아니다. 그저 클래스의 인스턴스를 반환하는 단순한 정적 메서드를 사용하는 것을 말한다.

이 기법에는 5가지의 장점과 2가지의 단점이 존재한다. 이에 대해서 살펴보자.

장점

이름을 가질 수 있다.

생성자에 넘기는 매개변수와 생성자 자체만으로는 반환될 객체의 특성을 제대로 설명하지 못한다. 하지만, 정적 팩터리 메서드를 만들어 사용하면 반환될 객체의 즉성을 설명할 수 있다. 아래의 예시를 살펴보자.

우리가 만약 이름, 나이, 키를 필드로 가진 클래스 Human이 있다고 가정해보자.

public class Human {
    
    private String name;
    private int age;
    private int height;
    
}

이때 우리는 나이이름만을 가지는 Human이름만을 가지는 Human을 각각 만들고 싶어 각각의 생성자를 아래와 같이 만들고 싶다고 생각해보자.

	public Human(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public Human(String name, int height) {
        this.name = name;
        this.height = height;
    }

위와 같은 경우 컴파일 에러가 뜬다. 하나의 시그니처로는 생성자를 하나만 만들 수 있는데, 두 생성자의 시그니처가 같기 때문이다. 매개변수의 이름만 다른 것은 영향을 줄 수 없다. 따라서 우리는 위에서 우리가 하고 싶었던 Human 둘 중 하나만을 선택해야 한다. 하지만, 정적 팩터리 메서드를 사용하면 이를 해결할 수 있다.

    public Human(String name, int age, int height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    public static Human createWithAge(String name, int age) {
        return new Human(name, age, 0);
    }

    public static Human createWithHeight(String name, int height) {
        return new Human(name, 0, height);
    }

이렇게 정적 팩터리 메서드를 만들어 createWithAge, createWithHeight를 각각 사용하면 우리가 원하는 타입의 Human을 각각 만들 수 있다.

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

불변 클래스는 인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있다. 대표적인 예로는 Boolean.valueOf(boolean)이 있는데 이는 객체를 생성하지 않는다. 아래에서 살펴보자.

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);
    
    @HotSpotIntrinsicCandidate
    public static Boolean valueOf(boolean b) {
        return (b ? TRUE : FALSE);
    }
}

위를 보면 이미 public final로 생성해놓은 인스턴스인 TRUE, FALSE를 매개변수인 b에 따라 반환해주고 있는 것을 볼 수 있다. 즉, 정적 팩터리 메서드를 사용해 인스턴스를 매번 생성하는 것이 아닌 미리 생성해놓고 필요할 때 마다 가져다 쓰는 것을 볼 수 있다.

이는 생성 비용이 큰 같은 객체가 자주 요청 될 때 성능을 올려줄 수 있다.

자주 사용하는 인스턴스들을 매번 생성하는 것이 아닌 미리 캐싱해놓고 꺼내 쓰는 디자인 패턴인 플라이웨이트 패턴과도 유사하다.

인스턴스 통제

반복되는 요청에 같은 객체를 반환하는 식으로 언제 어느 인스턴스를 살아 있게 할지를 철저히 통제할 수 있었는데, 이를 인스턴스 통제라고 한다. 즉, 내가 인스턴스를 생성하고 싶을 때 생성할 수 있는 것을 말한다.

그렇다면 왜 인스턴스 통제를 사용할까?

  • 싱글턴
  • 인스턴스화 불가
  • 불변값 클래스에서 동치인 인스턴스가 단 하나뿐임을 보장

생성자는 호출될 때마다 new 연산자를 사용해 인스턴스를 생성한다. 항상 인스턴스를 생성하기 때문에 생성자로는 인스턴스를 통제할 수 없다.

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

메서드에의 리턴 타입은 인터페이스, 반환은 상황에 맞는 구현체로 할 수 있는 것이다. 물론, 인터페이스가 아닌 클래스를 사용해 상속받은 클래스를 반환할 수 있다. 이를 통해 유연성을 가지게 된다.

생성자는 생성하는 클래스의 인스턴스만 반환 가능하기 때문에 이러한 유연성이 불가능하다.

또한 이를 통해 클라이언트에게 구현체를 숨길 수도 있는 것이다. 아래의 예제를 살펴보자.

public interface Human {

}

public class Man implements Human {

    private String name;
    private int age;
    private int height;

    public Man(String name, int age, int height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    public static Human createWithAge(String name, int age) {
        return new Man(name, age, 0);
    }
}

public class Main {
    public static void main(String[] args) {
        Human human = Man.createWithAge("effective java", 3);
    }
}

Human이라는 인터페이스가 있고, Man이라는 클래스가 존재할 때, Man에서는 Human을 메서드의 반환타입으로 사용해 반환할 수 있다. 이를 main 메서드에서 적절히 사용했음을 볼 수 있다.

java 8부터는 인터페이스에 static 메서드를 구현해 바로 사용할 수 있으므로 아래와 같은 예제도 생각해볼 수 있다.

public class Man implements Human {

    private String name;
    private int age;
    private int height;

    public Man(String name, int age, int height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    public static Man createWithHeight(String name, int height) {
        return new Man(name, 0, height);
    }

}

public interface Human {
    public static Human create() {
        return Man.createWithHeight("test", 3);
    }
}

public class Main {
    public static void main(String[] args) {
        Human human = Human.create();
    }
}

위의 예제를 보면 Main에서 클라이언트는 Human이라는 인터페이스만 알 뿐, 구현체인 Man에 대해서는 알 수 없다.

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

위의 장점에서 보았던 Human을 응용해보자.

public class Woman implements Human{

    private String name;
    private int age;

    private Woman(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public static Woman createWithAge(String name, int age) {
        return new Woman(name, age);
    }
}

public class Man implements Human {

    private String name;
    private int age;
    private int height;

    private Man(String name, int age, int height) {
        this.name = name;
        this.age = age;
        this.height = height;
    }

    public static Man createWithHeight(String name, int height) {
        return new Man(name, 0, height);
    }

}

Human을 구현(implement)한 WomanMan 클래스를 각각 만들어주었다. 그리고 Human에서 매개변수인 gender에 따라 둘 중 한 클래스를 반환하도록 했다.

public interface Human {
    public static Human create(String gender) {
        if (gender.equals("female")) {
            return Woman.createWithAge("woman", 25);
        }
        if (gender.equals("male")) {
            return Man.createWithHeight("test", 3);
        }
        throw new IllegalArgumentException();
    }
}

위 처럼 매개변수에 따라 Woman 클래스를 반환할 수도, Man 클래스를 반환할 수도 있는 것이다.

사용할 때는 이전의 장점과 마찬가지로 클라이언트는 Woman을 구현한 것인지 Man을 구현한 것인지 모른다.

public class Main {
    public static void main(String[] args) {
        Human human = Human.create("female");
    }
}

클라이언트는 Human만을 볼 수 있기 때문이다.

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

JDBC를 생각해보자. 우리는 사용할 데이터베이스를 미리 알고 코드를 작성하는 경우도 있지만, 사용할 데이터베이스를 모른채로 코드를 작성하는 경우가 있을 수 있다. 이때 특정 데이터베이스에 특화되지 않도록 driver만 바꿔 끼우면 동작할 수 있도록 도와주는 것이 JDBC이다.

JDBCClass.forName(String className)을 통해 해당 클래스를 JVM에 로드하게 된다. 클래스 로딩 프로세스는 드라이버 인스턴스를 DriverManager에 등록하고 이 클래스를 oracle 또는 postgres와 같은 데이터베이스 엔진 식별자와 연결하는 정적 초기화 루틴을 촉진한다.

Class.forName("oracle.jdbc.driver.OracleDriver");

그 후 아래와 같은 방식으로 드라이버를 등록한다.

public static void register() throws SQLException {
    if (isRegistered()) {
        throw new IllegalStateException("Driver is already registered. It can only be registered once.");
    } else {
        Driver registeredDriver = new Driver();
        DriverManager.registerDriver(registeredDriver);
        Driver.registeredDriver = registeredDriver;
    }
}

따라서 클래스를 생성할 땐 어떤 Driver가 들어오는지 모르지만, 사용자가 어떤 Driver를 입력 값에 넣느냐에 따라 실행시점에 Driver이름 (class name)을 통해 클래스를 가져와 인스턴스를 초기화시킨다.

단점

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

하지만 상속보다 컴포지션을 사용하도록 유도하고 불변 타입으로 만들려면 이 제약을 지켜야 한다는 점에서 오히려 장점으로 받아 들일 수도 있다.

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

생성자처럼 정해져 있는 포맷이 없으므로 사용자는 클래스에서 정적 팩터리 메서드를 일일이 찾아야 한다. 따라서 API 문서를 잘 써놓고 메서드 이름도 널리 알려진 규약을 따라 지어 이 문제를 완화할 수 있다.

흔히 사용하는 명명 방식들

메서드 명설명
from매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
of여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
valueOffrom과 of의 더 자세한 버전
instance 혹은 getInstance(매개변수를 받는다면) 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지는 않는다.
create 혹은 newInstanceinstance 혹은 getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다.
getTypegetInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다. (Type은 팩터리 메서드가 반환할 객체의 타입 ex. FileStore fs = Files.getFileStore(path))
newTypenewInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다. (Type은 팩터리 메서드가 반환할 객체의 타입 ex. BufferedReader br = Files.newBufferedReader(path))
typegetType과 newType의 간결한 버전

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

출처

Loading JDBC Drivers
이펙티브 자바 3/E

profile
백엔드 개발자 지망생

0개의 댓글

관련 채용 정보