[OOP] 정적 팩토리 메서드를 왜 사용하는가? 어떤 상황에 사용하는게 좋을까? (생성자와 차이)

정적 팩토리 메서드란?

쉽게 말해 생성자로 인스턴스를 생성하지 않고, static Method를 사용해 인스턴스를 생성하는 방식이다.

간단한 예시로 여러 장르(genre)를 가지는 게임(Game) 객체에 대한 코드를 살펴보자.

생성자
public class Game {
    private String genre;

    public Game(String genre) {
        this.genre = genre;
    }
}
    public static void main(String[] args) {
        Game game = new Game("FPS");
    }

 

정적 팩토리 메서드
public class Game {
    private String genre;

    private Game(String genre) {
    }

    public static Game from(String genre) {
        return new Game(genre);
    }
}
    public static void main(String[] args) {
        Game game = Game.from("RPG");
    }

이렇게만 봤을 때는 "그냥 익숙한 생성자 방식을 사용해서 인스턴스를 생성하면 안돼?"라고 생각할 수 있다.

하지만 정적 팩토리 메서드 패턴이 아무 의미 없이 등장하진 않았을 것이다.

그래서 이번 포스팅에서는 정적 팩토리 메서드를 왜 사용하는가, 그리고 언제 사용하면 좋은지에 대해 다뤄보려한다.


왜 정적 팩토리 메서드를 사용하는가?

이펙티브 자바 책을 펼치면 가장 먼저 "생성자 대신 정적 팩토리 메서드를 고려하다"가 나온다.

그리고 이펙티브 자바 책을 사지 않았더라도 이 말에 대해서는 심심찮게 볼 수 있었을 것이다.

그렇다면 왜 생성자 대신 정적 팩토리 메서드를 사용하라고 하는 걸까?

이 이유에 대해 알기 위해 정적 팩토리 메서드를 사용함으로써 얻을 수 있는 장점들에 대해 알아보자.

 

장점 1. 이름을 가질 수 있다.

게임별로 게임 장르와, 이용 등급이 있다고 가정해보자.

생성자를 사용할 때

생성자 방식을 사용했을 경우에는 다음과 같이 사용할 수 있을 것이다.

public class Game {
    private String genre;
    private String rating;
    
    public Game(String genre, String rating) {
        this.genre = genre;
        this.rating = rating;
    }
}
public static void main(String[] args) {
    Game fpsForAdult = new Game("fps", "청소년이용불가")
    Game rpgForAll = new Game("rpg", "전체이용가")
}

필자는 이미 Game 객체의 내부 구조를 알기 때문에 생성자의 몇 번째 인자에 어느 값이 들어가야하는지 알고 있다.

하지만 Game 객체의 내부 구조를 모르는 사람 Game 인스턴스를 생성할 때 몇 번째 인자에 어떤 값이 들어가야 하는지 모른다.

이는 자바 특성상 생성자는 클래스 이름으로 고정이기 때문에 발생하는 문제이다.

이 코드를 혼자서만 보면 상관없지만, 협업같은 환경에서 다른 사람이 코드만으로 의도가 파악할 수가 없다.

정적 팩토리 메서드를 사용한다면 어떻게 바뀔까?

 

정적 팩토리 메서드를 사용할 때

생성자로 인스턴스 생성하는 것을 방지하기 위해 생성자의 접근 제어자를 private로 설정한다.

public class Game {
    private String genre;
    private String rating;

    private Game(String genre, String rating) {
        this.genre = genre;
        this.rating = rating;
    }
    
    public static Game adultAccessFrom(String genre) {
        return new Game(genre, "청소년이용불가");
    }

    public static Game fullAccessFrom(String genre) {
        return new Game(genre, "전체이용가");
    }
}
public static void main(String[] args) {
    Game fpsForAdult = Game.adultAccessFrom("fps");
    Game rpgForAll = Game.fullAccessFrom("rpg");
}

이런식으로 어떤 인스턴스를 생성할 지 메서드 이름을 통해 파악이 가능해지기 때문에

Game 객체 내부 구조를 알 필요가 없다.

훨씬 가독성이 좋아진 것을 볼 수 있다.

 

장점 2. 호출될 때 마다 인스턴스를 새로 생성하지 않아도 된다. (싱글톤으로 활용 가능)

public class Game {
    private static final Game instance = new Game();

    private Game() {}

    public static Game getInstance() {
        return instance;
    }
}
public static void main(String[] args) throws IOException {
    Game game1 = Game.getInstance();
    Game game2 = Game.getInstance();

    System.out.println(game1);
    System.out.println(game2);

    System.out.println(game1 == game2);
}

위처럼 싱글톤으로 객체를 하나만 만들어두고 이를 재사용하여 불필요한 객체 생성을 막을 수 있다.

오로지 하나의 객체만 반환하기 때문에 메모리를 절약할 수 있다는 장점이 있다.

그렇다고 무조건 싱글톤 방식이 좋은 것은 아니다.

한번만 생성되어 상태를 공유되어야 하는 경우에 유용하게 사용할 수 있지만, 각 객체가 다른 상태를 가져야 한다면 위 방식은 적절하지 않을 것이다.

상황에 맞게 사용하자.

 

그리고 오해의 여지가 있을 수 있는 부분인데, 정적 팩토리 메서드가 다 싱글톤이 아니다.

여기서는 위 장점을 설명하기 위해 해당 클래스의 인스턴스를 싱글톤으로 만들어서 설명한 것이다. 

 

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

불변 객체를 반환해주는 unmodifiableList() 메서드를 예로 설명해보겠다.

List<String> modifiableList = new ArrayList<>();
modifiableList.add("a");
modifiableList.add("b");

List<String> unmodifiableList = Collections.unmodifiableList(modifiableList);

 

unmodifiableList()를 메서드에서 반환하는 UnmodifiableList<> 타입은 List<> 인터페이스의 구현체이다.

 

unmodifiableList()를 호출하는 반환 타입이 List<> 인터페이스를 구현한 구현체가 아니었다면

우리는 UnmodifiableList<>의 실제 구현에 대해 알 필요가 있었을 것이다.

즉, UnmodifiableList<> 객체를 생성하고 사용하기 위해 해당 클래스의 생성자와 메서드에 대해 직접적으로 알아야하는 것이다.

 

하지만 정적 팩토리 메서드인 Collections.unmodifiableList() 덕분에, 우리는 단순히 이 메서드를 호출하기만 하면 된다.

이 메서드가 내부적으로 어떻게 동작하는지, 어떤 클래스의 인스턴스를 반환하는지 등은 크게 중요하지 않다.

중요한 것은 그 반환값이 List<> 인터페이스를 구현하는 구현체라는 것이다.

따라서, UnmodifiableList<>이라는 구현체가 어떤 구현체인지 알지 못해도 사용할 수 있는것이다.

또한, unmodifiableList() 메서드 내부 구현이 바뀌더라도, 우리 코드는 영향을 받지 않는다.

즉, 단순하게 사용할 수 있고, 유연성을 높일 수 있다.

 

 

장점 4. 파라미터에 따라 다른 객체를 반환할 수 있다.

메서드이기 때문에 파라미터를 받을 수 있고, 그렇기 때문에 파라미터 별로 분기처리하여 다른 객체를 반환하도록 할 수 있다.

3번의 장점과 연결한다면 Car 인터페이스를 구현하는 구현체 클래스들을 파라미터 별로 반환할 수 있는 것이다.

public interface Car {}
class SportsCar implements Car {}
class SuvCar implements Car {}

public static Car createCar(String type) {
    if (type.equals("sports")) {
        return new SportsCar();
    }
    if (type.equals("suv")) {
        return new SuvCar();
    } 
    throw new IllegalArgumentException("해당 타입의 자동차가 존재하지 않습니다 : " + type);
}

 

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

public class CarFactory {

    private CarFactory() {
    }

    public static Car createCar(String type) {
        if (type.equals("sports")) {
            return new SportsCar();
        }
        if (type.equals("suv")) {
            return new SuvCar();
        } 
        throw new IllegalArgumentException("해당 타입의 자동차가 존재하지 않습니다 : " + type);
    }
}

위 장점4의 메서드를 CarFactory라는 클래스에 넣어주었다.

 

만약 여기에 전기자동차 (ElectricCar)가 추가되었다고 가정해보자.

public class CarFactory {

    private CarFactory() {
    }

    public static Car createCar(String type) {
        if (type.equals("sports")) {
            return new SportsCar();
        }
        if (type.equals("suv")) {
            return new SuvCar();
        } 
        if (type.equals("electric")) {
            return new ElectricCar();
        }
        throw new IllegalArgumentException("해당 타입의 자동차가 존재하지 않습니다 : " + type);
    }
}

위 처럼 ElectricCar 클래스를 만들고 CarFactory 클래스의 팩토리 메서드에  ElectricCar객체를 생성하는 메서드만 추가하면 된다.

 

어떤 타입의 객체를 반환할지 결정하는 로직이 팩토리 메서드 내부에 있기 때문에

클라이언트는 팩토리 메서드를 호출하기만 하면 되고, 클라이언트는 어떤 클래스의 객체가 생성되는지 알 필요가 없다.

새로운 타입의 객체가 필요해진다면 팩토리 메서드만 수정하면 된다.

즉, 유연한 확장성을 가질 수 있고, 내부 구현을 드러내지 않아 캡슐화할 수 있다는 장점도 있다.

 


정적 팩토리의 문제점

물론 장점만 있는 것은 아니다.

아래와 같은 단점도 있으니 무조건 정적 팩토리 메서드 사용을 고려하기 보단 장단점을 잘 파악해 사용하는 것이 중요하다.

 

단점 1. 상속 불가능

정적 팩토리 메서드는 생성자로 인스턴스를 생성하는 것을 막기 위해 생성자 접근 제어자를 private로 설정한다.

이때, 생성자가 private이기 때문에 해당 생성자에 접근할수가 없어 상속이 불가능하다는 문제가 생긴다.

하지만, 이 제약은 무조건 나쁘다고만 말할 수 없다고 한다.

"상속 보단 합성" 원칙을 보면 상속에 대해 단점과 한계에 대해 많이 설명하고 있고, 상속보다는 합성을 사용하는 것을 권한다.

 

생성자 방식 (상속이 안되지만 예시를 위해 추가)
public class Car {
    private Car() {
    }

    public static Car createCar() {
        return new Car();
    }
}

public class SportsCar extends Car { 
}

 

상속 대신 합성 사용
public class Car {
    private Car() {
    }

    public static Car createCar() {
        return new Car();
    }
}

public class SportsCar {
    private final Car car;

    public SportsCar(Car car) {
        this.car = car;
    }

    // ...
}

위 코드는 SportsCar 객체가 더 이상 Car를 상속 받지 않고, Car를 인스턴스 변수로 갖고 있다.

이런식으로 합성을 사용하면 직접적으로 Car의 생성자를 호출할 필요가 없기 때문에 이 단점이 해결된다.

또한 상속을 사용했을 때보다 결합도가 낮아지고 유연성이 증가하게 된다.

 

그래서 정적 팩토리 메서드의 상속이 불가능 하다는 단점은 단점이라고 뽑기 애매한 부분인 것 같다.

 

단점 2. 정적 팩토리 메서드를 다른 개발자들이 찾기 어렵다.

javadoc의 내용을 보면 생성자는 문서로 정리되어 있지만 정적 팩토리는 따로 정리되어 있지 않다고 한다.

아래는 Boolean 클래스의 javadoc 문서이다.

보면 생성자는 따로 구분되어 있어서 바로 찾을 수 있지만, 정적 팩토리 메서드는 많은 메서드 중에서 해당 클래스를 반환하는 메서드를 따로 찾아야 함으로 좀 불편함 감이 있어보인다.

 

이 문제는 API 문서를 깔끔하게 작성하거나, 정적 팩토리 메서드 네이밍 규칙을 지킴으로써 단점을 극복하기도 한다고 한다.

 

지금까지 단점에 대해 알아봤지만 단점을 해결가능한 부분이 많기 때문에, 이펙티브 자바에서 "생성자 대신 정적 팩토리 메서드를 고려하라"

라는 말을 하지 않았나 싶다.  


언제 사용하는게 좋을까?

지금까지 정적 팩토리 메서드의 장단점에 알아보았다.

개인적으로는 정적 팩토리 메서드를 사용하는데 큰 단점은 못느꼈기에 목적없이 사용하는 것에는 상관이 없다고 생각한다.

하지만 아무래도 생성자 방식이 더 익숙한 만큼 직관적이고 가독성이 좋다고 생각하기 때문에

모든 것에 정적 팩토리 메서드를 적용하는 것은 지양해야 한다고 생각한다.

일반적으로 복잡한 초기화 로직이 없다면 생산자 방식을 사용하고

복잡한 초기화로 로직이 필요하거나 정적 팩토리 메서드의 장점을 살릴 수 잇는 상황이라면 정적 팩토리 메서드를 사용 하는게 좋지 않을까라는 의견이다.

 

그렇다면 언제 사용하는게 좋을까 다음은 정적 팩토리를 적용하기 좋은 상황을 정리한 것이다.

 

생성자에 넘길 매개변수가 많을 때

매개변수가 많을 경우, 생성자에서는 어떤 매개변수가 어떤 값을 의미하는지 알기 어렵다.

반면, 정적 팩토리 메서드는 장점 1과 같이 메서드 이름을 통해 의도를 명확한 표현이 필요할 때 사용할 수 있다.

 

명시적인 객체 생성이 불필요할 때

장점 2와 같이 불필요한 객체를 여러번 생성할 필요가 없을 때 싱글턴 객체를 반환하도록 할 수 있다.

이로 인해 캐시하여 재사용하는 방식으로 리소스를 효율적으로 사용할 수 있게 된다.

 

반환 타입의 하위 타입 객체를 반환할 때

정적 팩토리 메서드를 사용하면 장점 3과 같이 반환 타입의 어떤 하위 타입 객체라도 반환할 수 있다.

이를 통해 입력 매개변수에 따라 다양한 타입의 객체를 반환하는 유연성이 필요할 때 사용할 수 있다.

 

입력 파라미터에 따라 매번 다른 클래스의 객체를 반환할 필요가 있을 때

장점 4와 같이 메서드의 파라미터에 따라 생성되는 객체가 달라질 필요가 있을 때사용할 수 있다.

 

초기화에 복잡한 로직이 필요할 때

위 경우와 살짝 중복되는데 객체 초기화 과정이 복잡하고 많은 단계를 거쳐야 한다면

이 로직을 정적 팩토리 메서드로 구현하여 객체 생성을 간단하게 만들 수 있다.

이로인해 클라이언트는 내부 구조를 파악하지 않아도 정적 팩토리 메서드를 통해 간편하게 객체를 생성할 수 있다.

 

유연한 확장성이 필요할 때

장점 5에 해당하는 내용으로, 프레임워크나 라이브러리를 만들 때 특히 유용하다고 한다. (만들어 본적은 없다..)

JDBC와 같은 DB Connection 라이브러리를 예를 들어보자.

각각의 관계형 데이터베이스는 커넥션 연결 방법, SQL 전달 방식 그리고 결과를 응답 받는 방법이 모두 다르다.

이는 데이터베이스를 변경할 때마다 애플리케이션 서버에 개발된 데이터베이스 사용 코드도 같이 변경해야 하고,

개발자가 각 데이터베이스마다 커넥션 연결, SQL 전달 및 응답 결과를 받는 방법을 새로 배워야 하는 불편함을 초래한다.

이러한 문제를 해결하기 위해 등장한 것이 바로 JDBC이다.

이 JDBC의 핵심적인 기능 중 하나가 적절한 데이터베이스 드라이버를 찾아 연결 객체를 생성하고 반환해주는데

정적 팩토리 메서드의 특징 중 하나인 "정적 팩토리 메서드를 작성하는 시점에 반환할 객체의 클래스가 존재하지 않아도 된다"는 장점이 잘 드러나는 부분이다.

실제로 메서드를 작성하는 시점에는 어떤 데이터베이스 드라이버가 사용될지, 어떤 연결 객체가 생성될지 알 수 없다.

이는 실제 메서드를 호출하는 시점에 결정되는 것이며, 따라서 개발자는 메서드를 호출하기만 하면 되고, 데이터베이스에 대한 구체적인 지식 없이도 데이터베이스 접근이 가능해 진다.

이 처럼 위 장점을 잘 활용한다면, 개발의 편의성을 크게 향상시킬 수 있을 것이다.


정적 메서드 응용 (번외)

필자의 경우에는 정적 팩토리 메서드는 아니지만 상태값을 갖지 않는 유틸 클래스에 정적 메서드를 적용하고 있다.

 

예를 들어서 아래 클래스를 보자.

public class OutputView {

    private OutputView() {
    }

    public static void printHello() {
        System.out.println("Hello");
    }
}

OutputView 클래스상태 (인스턴스 변수)를 갖지 않고 단순히 값을 출력하는 메서드만 가지고 있다.

 

이 클래스에 대해 printHello()를 사용하려고 불필요한 OutputView 객체를 생성해서 해당 메서드를 사용해야할까?

필자의 생각은 해당 메서드만 호출해서 사용하면 되기 때문에 굳이 불필요한 객체를 생성할 필요가 없다고 생각했다.

그래서 위 같이 상태를 갖지 않는 유틸리티 클래스들은 생성자 생성을 막고 클래스 별 역할에 맞는 정적  메서드를 넣어 사용하는 방식을 사용하고 있다.

 

이런식으로 사용했을 때 불필요한 객체 생성을 막아 메모리 절약과 해당 클래스를 사용하는데 코드도 더 간결해진다는 장점이 있다고 생각한다.