본문 바로가기

study/effective java

[item1] 생성자 대신 정적 팩토리 메소드를 고려해라

정적 팩토리 메소드란

정적 팩토리 메소드는 정적으로 생성한 팩토리 메소드로서 객체를 대신 생성해주는 역할을 가진다. 팩토리 메소드라고 이름이 붙이니 GoF의 팩토리 메소드 패턴과 동일하다고 생각할 수 있으나 이들은 다르며 다음의 차이가 있다.

  • 팩토리 메소드: 팩토리 객체에게 생성에 필요한 인자들을 넘겨주어 객체를 반환한다.
  • 정적 팩토리 메소드: static 메소드(정적 팩토리)가 생성자 역할을 대행해준다.

정적 팩토리 없이 객체를 생성하면 new 키워드를 통해 생성하게 된다.

// 사용법: Car car=new Car("Hyundai", "Sonata", 2022)
public class Car {
    private String make;
    private String model;
    private int year;
    
    public Car(String make, String model, int year) {
        this.make = make;
        this.model = model;
        this.year = year;
    }
    
}

정적 팩토리 메소드를 사용하면 정적 메소드에게 객체의 생성 권한을 위임하여 다음과 같이 바뀌게 된다.

// 사용법: Car car=Car.createCar("Hyundai", "Sonata", 2022)
public class Car {
    private String make;
    private String model;
    private int year;
    
    // private 생성자
    private Car(String make, String model, int year) {
        this.make = make;
        this.model = model;
        this.year = year;
    }
    
    // 정적 팩토리 메소드
    public static Car createCar(String make, String model, int year) {
        return new Car(make, model, year);
    }
    
    // ...
}

정적 팩토리 메소드의 이름

정적 팩토리 메소드는 말 그대로 메소드이다. 즉 이름에 의해 생성하는 의미를 부여할 수 있다.

대표적으로 사용하는 정적 팩토리 이름 유형은 다음과 같다.

  • from: 매개변수를 타입을 반환해서 제공한다.
  • of: 여러 매개변수를 받고 적합한 타입으로 변환한다.
  • valueOf: 인자로 준 값과 같은 값을 가지는 객체를 반환한다.
  • instance(getInstance): 인스턴스를 반환한다. 그러나 반환을 요청할 때마다 같은 인스턴스임은 보장하지 않는다.
  • create(newInstance): 요청할 때마다 인스턴스를 새로 만들어서 반환한다.
  • getType: Type 타입을 가진 객체를 반환한다. 단 요청할 때마다 새로 만들지 않고 이미 만들어놓은 객체를 재활용한다.
  • newType: Type 타입을 가진 객체를 요청할 때마다 새로 만들어서 반환한다.
  • parse: 인자로 주어진 값을 파싱해서 새로운 객체를 만들어서 반환한다.

정적 팩토리 메소드 이점

1. 이름을 가진다

new키워드로 만드는 생성자는 단순히 객체를 생성한다는 정보를 보여준다. 생성할 때 사용한 파라미터들이 반드시 생성에 필요한 건지, 혹은 생성자 내부에서 파라미터 값들을 확인하여 객체의 생성을 반려할 수 있는지를 알 수 없다. 이를 메소드 이름으로 해결하는 전략이 정적 팩토리 메소드 이다. 메소드 이름을 통해 객체 생성을 어떻게 하는지를 유추할 수 있도록 가이드를 해준다. 또한 이름을 가지면서 코드 가독성 향상에도 도움을 줄 수 있다.

Car객체를 생성할 때 생성자를 이용한 방법은 아래의 코드와 같이 new 키워드를 이용해 생성한다.

// 객체 생성
Car car=new Car("Hyundai", "Sonata", 2022)

정적 팩토리 메소드를 사용하면 createCar라는 이름을 통해 Car객체를 만들어준다고 명시할 수 있다.

// 객체 생성
Car car = Car.createCar("Hyundai", "Sonata", 2022);

여러 이름을 가진 정적 팩토리 메소드를 사용하는 방법은 좋은 방향이 아니다. 정적 팩토리 메소드의 개수가 많을 수록 다른 개발자가 어떤 팩토리 메소드를 이용해야 할 지를 햇갈리기 때문에 좋지 않는 방법이다.

2. 인스턴스 재사용이 가능하다

new 키워드를 사용한 객체 생성은 항상 새로운 객체를 만들어낸다. 그러나 정적 팩토리 메소드를 이용하면 굳이 이럴 필요가 없다. 정적 팩토리 메소드는 말 그대로 '메소드' 이므로 항상 새로운 객체를 만들어서 제공할 의무가 없다. 이를 통해 정적 팩토리 메소드를 가진 클래스를 '인스턴스 제어(instance-controlled)'특성을 가질 수 있도록 한다. 인스턴스를 정적 팩토리 메소드가 통제하므로서 객체의 생명 주기에 관여할 수 있도록 한다. 대표적인 예시는 Gof의 싱글톤과 Flyweight 패턴이다. 다음의 코드는 싱글톤 패턴을 작성한 코드이다.

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    
    // private 생성자
    private Singleton() {}
    
    // 정적 팩토리 메소드
    public static Singleton getInstance() {
        return INSTANCE;
    }
    
    // ...
}

싱글톤 패턴은 하나의 인스턴스를 공유시키는 패턴이다. 싱글톤은 하나의 큰 인스턴스를 이용할 경우 여러 객체에서 이를 생성해서 사용하면 자원 낭비이므로 하나의 인스턴스로 만들어서 이 인스턴스를 공유하기 위해 static메소드를 이용하는데 이 메소드가 정적 팩토리 메소드이다. 정적 팩토리 메소드를 보면 객체를 만들어서 제공하는 것이 아닌 이미 만들어지 객체를 제공한다.

또 다른 예시인 GoF의 Flyweight 패턴은 싱글톤과 유사하게 객체 생성에 필요한 자원을 공유하여 생성 비용을 줄이는 패턴이다.

public class FlyweightExample {
    // 정적 팩토리 메서드를 사용하여 Flyweight 객체를 생성합니다.
    public static Flyweight createFlyweight(String key) {
        return FlyweightFactory.getFlyweight(key);
    }
    
    public static void main(String[] args) {
        Flyweight flyweight1 = createFlyweight("foo");
        Flyweight flyweight2 = createFlyweight("foo");
        Flyweight flyweight3 = createFlyweight("bar");
        
        System.out.println(flyweight1 == flyweight2); // true
        System.out.println(flyweight1 == flyweight3); // false
    }
}

class Flyweight {
    private final String key;
    
    public Flyweight(String key) {
        this.key = key;
    }
    
    public String getKey() {
        return key;
    }
}

class FlyweightFactory {
    private static final Map<String, Flyweight> flyweights = new HashMap<>();
    
    public static Flyweight getFlyweight(String key) {
        Flyweight flyweight = flyweights.get(key);
        
        if (flyweight == null) {
            flyweight = new Flyweight(key);
            flyweights.put(key, flyweight);
        }
        
        return flyweight;
    }
}

Flyweight 클래스가 FlyweightFactory라는 팩토리 책임을 맡은 객에 의해 생성되어서 제공된다. FlyweightFactorygetFlyweight 메소드가 정적 팩토리 메소드이다. 이 메소드를 보면 같은 키값을 가진 객체를 새로 생성하지 않고 이미 만들어놓은 객체를 반환시킨다. 이를 통해 같은 키 값을 가진 객체가 여러 개가 생성하지 않도록 방지한다. 즉 항상 객체를 생성하지 않고 필요할 때만 객체를 생성하므로 메모리 효율성을 높일 수 있다.

이처럼 정적 팩토리 메소드는 항상 객체를 생성하게하지 않고 객체의 재사용을 가능하게 하는 이점을 준다.

3. 반환하는 타입의 하위 타입을 반환할 수 있다

정적 팩토리 메소드는 말 그대로 메소드이기 때문에 반환 타입이 고정되어 있지 않는다. 즉 인터페이스 타입을 반환시키고 정적 팩토리 메소드에서 구현체를 반환하는 방식으로 구현할 수 있다. 아래의 코드는 추상체인 'Animal' 타입을 반환시키고 AnimalFactory 에 구현된 정적 팩토리 메소드를 통해 Animal 의 하위 객체인 Dog를 반환시키는 코드이다.

public interface Animal {
    void makeSound();
}

public class Dog implements Animal {
    @Override
    public void makeSound() {
        System.out.println("Bark!");
    }
}

public class AnimalFactory {
    public static Animal createAnimal(String type) {
        if (type.equals("dog")) {
            return new Dog();
        }
        throw new IllegalArgumentException("Invalid animal type: " + type);
    }
}

public class Main {
    public static void main(String[] args) {
        Animal dog = AnimalFactory.createAnimal("dog");
        Animal cat = AnimalFactory.createAnimal("cat");
        dog.makeSound(); // 출력 결과: Bark!
        cat.makeSound(); // 출력 결과: Meow!
    }
}

이 코드를 보면 Animal 타입을 반환하는 AnimalFactory는 정적 팩토리 메소드를 사용한다. 이 팩토리 메소드의 반환타입이 정적 팩토리 메소드의 반환값으로 명시한 Animal 타입과 일치하지 않는다. Animal 이라는 타입 대신에 하위 객체인 Dog 객체를 반환시킨다.

이처럼 정적 팩토리 메소드는 말 그대로 메소드이기 때문에 반환 타입을 선택해서 제공할 수 있다.

4. 메소드 매개변수에 따라 다른 반환 타입을 제공할 수 있다

생성자 처럼 하나의 타입이 아닌 입력값에 따라 여러개의 타입을 반환할 수 있다. 이는 3번의 내용에 겹치는 부분이 있다. 3번에서 하위 타입을 반환할 수 있는 이점이라고 설명했다. 여기서 질문이다. 하위타입이 과연 한개만 있을 수 있을까? 답은 아니다. 3번에서 설명했던 코드에서 정적 팩토리 메소드 부분을 보면 반환 타입이 Animal 추상체이다. 즉 Animal 추상체를 가진 모든 객체는 하위 타입이며 이들 모두 반환시킬 수 있는 대상이 된다. 그렇기에 여러개의 하위 타입을 만들고 어느 조건에 의해 하나의 타입을 선택해서 반환하는 메소드로도 만들 수 있으며 여기서 조건에 영향을 주는 부분이 메소드 매개변수 이다.

3번에서 설명한 코드에 Animal를 추상체로 가진 Cat를 추가해 설명하겠다.

public interface Animal {
    void makeSound();
}

public class Dog implements Animal {
    @Override
    public void makeSound() {
        System.out.println("Bark!");
    }
}

public class Cat implements Animal {
    @Override
    public void makeSound() {
        System.out.println("Meow!");
    }
}

public class AnimalFactory {
    public static Animal createAnimal(String type) {
        if (type.equals("dog")) {
            return new Dog();
        } else if (type.equals("cat")) {
            return new Cat();
        }
        throw new IllegalArgumentException("Invalid animal type: " + type);
    }
}

public class Main {
    public static void main(String[] args) {
        Animal dog = AnimalFactory.createAnimal("dog");
        Animal cat = AnimalFactory.createAnimal("cat");
        dog.makeSound(); // 출력 결과: Bark!
        cat.makeSound(); // 출력 결과: Meow!
    }
}

코드로 설명하자면 AnimalFactory 가 가지는 정적 팩토리 메소드는 입력값이 "dog" 면 Dog 객체를 "cat" 이면 Cat 객체를 반환한다. 즉 createAnimal 이라는 정적 팩토리 메소드는 입력값에 따라 같은 추상체를 가지지만 다른 타입인 Dog 혹은 Cat 객체를 생성해서 반환해준다.

즉 정적 팩토리 메소드 createAnimal(String type)type 이라는 메소드 매개변수에 따라 Animal이라는 같은 상위 객체를 가진 두 객체 DogCat를 제공할 수 있다.

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

new 키워드를 통한 생성자 사용은 객체에 대한 구현체를 즉시 만들어야 한다는 문제점이 있다. 그러나 정적 팩토리 메소드는 그럴 필요가 없다. 그저 런타임 시점에 구현체만 있으면 충분하다.

이에 대한 예시로 JPA로 설명하겠다. JPA를 사용하기 위해서는 Entity Manager Factory가 Entity Manager를 만들어서 제공하는 방식을 이용한다. 이를 위한 코드는 다음과 같다.

EntityManagerFactory emf = Persistence.createEntityManagerFactory("unit_name")
EntityManager em = emf.createEntityManager();

persistence.xml에 정의된 속성들 중에 이름이 "unit_name"인 속성을 가져와 Entity Manager Factory(이하 emf)를 만들고 emf를 통해 Entity Manager(이하 em)를 만들어서 제공해주는 코드이다. 이 코드는 emf, em를 생성함에도 불구하고 new 키워드를 사용하지 않는다. 이것이 정적 팩토리 메소드이다. 추상체인 JPA에서 정적 팩토리 메소드로 객체를 생성하는 가이드를 주면 구현체를 만드는 벤더사가 정적 팩토리 메소드의 내부를 구현해서 제공한다. 이를 통해 JPA는 구현체를 Hibernate를 쓰더라도 구현체에 의존하는 기능을 사용하지 않는 한 Eclipse Link같은 다른 구현체로 갈아탈 수 있다는 이점을 준다.

즉 정적 팩토리 메소드는 생성하는 방식을 강제하고 이를 따르도록 준수하여 JPA같은 표준을 만들 때 유리하다.

단점

정적 팩토리 메소드는 생성자가 아니라 메소드인 만큼 생성자에 의존하는 기능들을 사용하기 어려워진다. 단점들은 다음과 같다.

1. 정적 팩토리 메소드만으로는 상속을 재현할 수 없다

상속이 필요한 모듈에 의존할 경우 정적 팩토리 메소드만으로 생성자를 대체할 수 없다.

가장 대표적인 예시는 JPA이다. JPA에서는 객체 간의 상속 관계를 매핑하기 위해 @Inheritance 어노테이션을 제공한다. 이를 이용하면, 부모 클래스에 객체가 가져야할 공통 정보(createdAt, createdBy 등 운영에 필요한 정보)를 자식 클래스 간의 상속 관계를 매핑시켜 중복된 속성을 하나의 부모 객체로 묶을 수 있다.

그러나 정적 팩토리 메소드만으로는 상속을 재현할 수 없다. 이는 정적 팩토리 메소드가 클래스의 인스턴스를 생성하기 위해 private 생성자를 사용하는 경우가 많기 때문이다. 이 경우에는 자식 클래스에서 상위 클래스의 private 생성자에 접근할 수 없기에 자식 클래스의 인스턴스 생성이 불가능하다.

따라서, JPA에서도 엔티티 클래스의 상속 구조를 매핑할 때, 생성자를 protected 혹은 public으로 선언해야 상속을 구현할 수 있다. 이를 통해 자식 클래스에서도 상위 클래스의 생성자에 접근하여 인스턴스를 생성할 수 있게 한다. 이러한 방식으로 JPA에서는 상속 구조를 구현할 수 있지만, 이 경우에는 정적 팩토리 메소드를 사용할 수 없는 단점이 있다.

결론적으로, JPA에서 엔티티 클래스의 상속 구조를 매핑할 때는 생성자를 protected 혹은 public으로 선언하여 상속 구조를 구현이 필요하다. 이 방법을 통해 자식 클래스에서도 상위 클래스의 생성자에 접근할 수 있게 되어 상속 관계를 구현할 수 있으나 정적 팩토리 메소드를 온전히 사용할 수 없다는 단점이 있다. 생성자가 노출되어 있기에 정적 팩토리 메소드의 가이드를 따르지 않고 생성자를 통해 객체 생성을 시도할 수 있기 때문이다.

2. 잘못된 메소드 이름은 잘못된 의미로 받아드려진다

개발자에게 가장 어려운 일은 무엇일까? 바로 이름짓기이다. 뉴스를 찾아보거나 기사를 찾아보면 이 내용이 많이 나온다. 왜 어려울까를 생각해보면 이름을 통해 요소의 책임과 역할을 쉽게 유추하기 위해서다.

정적 팩토레도 메소드이다. 메소드로 이름을 잘못 표기한다면 정적 팩토리 메소드라는 것을 알지 못하고 생성자를 직접 호출해 만들거나 내부 코드를 분석해서 정적 팩토리 메소드임을 알아내야한다. 이는 정적 팩토리 메소드를 사용하는 의미가 없어진다.

그렇기에 정적 팩토리 메소드는 블로그 초반에 설명한 이름들 처럼 이미 알려진 이름 패턴을 사용하는 것이 다른 개발자들도 정적 팩토리 메소드임을 알고 사용하기 쉬워진다.

정적 팩토리 메소드도 메소드이다. 기업이나 단체에서 사용하는 네이밍 컨벤션이 있으면 이를 준수하거나 잘 알려진 정적 팩토리 메소드 이름 패턴을 이용해서 메소드명을 지어서 혼용 없이 만들어야 한다.

결론

정적 팩토리 메소드는 생성자 대신에 정적 메소드로 객체를 생성하자는 주장이다. 그렇기에 메소드로서 가질 수 있는 모든 장점을 가지지만 생성자가 아니기에 생성자가 반드시 필요한 기능들을 정적 팩토리 메소드가 대체하기엔 어려움이 있다. 정적 팩토리 메소드를 통해 메소드의 이점을 가진 생성자를 사용하고 생성자에 의존된 기능의 사용을 포기할 지, 생성자를 사용해 생성자에 의존된 기능을 사용하는 대신에 메소드를 이용해 얻을 수 있는 이점을 포기할 지를 상황에 따라 잘 선택해서 사용해야 한다.


Reference