본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[자바] 생성자와 정적 팩터리 메소드

생성자와 정적 팩터리 메소드


 

 

첫번째 장점은, 생성자와는 달리 정적 팩터리 메서드에는 이름이 있다는 것 입니다.

두번째 장점은, 생성자와는 달리 호출할 때마다 새로운 객체를 생성할 필요는 없다는 것 입니다.

 

세번째 장점은, 생성자와는 달리 반환값 자료형의 하위 자료형 객체를 반환할 수 있다는 것 입니다. 반환되는 객체의 클래스를 훨씬 유연하게 결정할 수 있습니다. 이 유연성을 활용하면 public으로 선언되지 않은 클래스의 객체를 반환하는 API를 만들 수 있습니다. 그러면 구현 세부사항을 감출 수 있으므로 아주 간결한 API가 가능합니다. 이 기법은 인터페이스 기반 프레임워크 구현에 적합한데, 이 프레임워크에서 인터페이스는 정적 팩터리 메서드의 반환값 자료형으로 이용됩니다. 인터페이스는 정적 메서드를 가질 수 없으므로, 관습상 반환값 자료형이 Type이라는 이름의 인터페이스인 정적 팩터리 메서드는 Types라는 이름의 객체 생성 불가능(noninstantiable) 클래스 안에 둡니다.

 

예를 들어, 자바 컬렉션 프레임워크(Collection Framework)에는 32개의 컬렉션 인터페이스 구현체가 들어 있는데, 변경이 불가능한 컬렉션과 동기화된 컬렉션 등이 있습니다. 이 구현체들 거의 전부는 java.util.Collections라는 객체 생성 불가능한 클래스의 정적 팩터리 메서드를 통해 이용하는데, 반환되는 객체의 실제 클래스는 public이 아닙니다.

 

구현체별로 32개의 public 클래스들을 만들었다면 컬렉션 프레임워크 API의 규모는 더 커졌을 것입니다. 인터페이스 기반 프레임워크 기법 덕에 간단해진 것은 단순히 API 규모가 아니며, 오히려 개념적인 무게감(conceptual weight)입니다. API 사용자는 반환된 객체가 인터페이스에 규정된 내용을 정확하게 따른다는 사실을 알고 있습니다. 따라서 별도의 클래스 사용법 문서를 읽지 않아도 그 구현체를 이용할 수 있습니다. 또한 클라이언트 코드는 반환된 객체의 실제 구현 세부사항이 아니라 인터페이스만 보고 작성하게 되는데, 일반적으로 바람직한 습관입니다.

 

public 정적 팩터리 메서드가 반환하는 객체의 클래스가 public일 필요가 없다는 것은 이미 설명했습니다. 게다가 메서드에 주어지는 인자를 이용하면 어떤 클래스의 객체를 만들지도 동적으로 결정할 수도 있습니다. 반환될 객체의 클래스가 정적 팩터리 메서드의 반환값 자료형에 부합하기만 하면 됩니다. 릴리스되는 소프트웨어 버전에 따라서 반환될 객체의 클래스 구현을 달리할 수 있으므로 소프트웨어를 유지보수하거나 성능을 개선하기도 좋습니다.

 

java.util.EnumSet에는 public으로 선언된 생성자가 없으며, 정적 팩터리 메서드뿐입니다. 이 메서드들은 enum 상수 개수에 따라 두 개 구현체 가운데 하나를 골라 해당 클래스의 객체를 만들어 반환합니다. enum 상수들이 64개 이하일 경우 팩터리 메서드는 RegularEnumSet 객체를 반환하는데, 이 객체는 내부적으로 long 변수 하나만 사용합니다. enum 상수들이 64개보다 많을 경우에는 JumboEnumSet 객체를 반환하는데, 이 객체는 내부적으로 long 형의 배열을 사용합니다.

 

API를 호출하는 코드 쪽에서는 내부적으로 어떤 클래스가 이용되는지 알 수가 없습니다. 따라서 RegularEnumSet 클래스가 enum 상수들이 적은 상황에 맞는 성능을 제공하지 못할 경우, 다음번 릴리스에는 안전하게 다른 클래스로 바꿀 수 있습니다. 클라이언트는 팩터리 메서드가 반환하는 객체의 실제 클래스를 알 수도 없고, 알 필요도 없습니다. 단지 EnumSet의 하위 클래스라는 사실만 중요할 뿐입니다.

 

심지어 정적 팩터리 메서드가 반환하는 객체의 클래스는 정적 팩터리 메서드가 정의된 클래스의 코드가 작성되는 순간에 존재하지 않아도 무방합니다. JDBC와 같은 서비스 제공자 프레임워크의 근간을 이루는 것이 바로 그런 유연한 정적 팩터리 메서드들입니다. 서비스 제공자 프레임워크는 다양한 서비스 제공자들이 하나의 서비스를 구성하는 시스템인데, 클라이언트가 실제 구현된 서비스를 이용할 수 있도록 합니다. 물론 그 세부적인 구현 내용은 몰라도 이용할 수 있습니다.

 

서비스 제공자 프레임워크는 세가지의 핵심 컴포넌트로 구성됩니다. 첫번째는 서비스 인터페이스인데, 서비스 제공자가 구현합니다. 두번째는 제공자 등록 API(provider registration API)입니다. 구현체를 시스템에 등록하여 클라이언트가 쓸 수 있도록 합니다. 세번째는 서비스 접근 API(Service access API)입니다. 클라이언트에게 실제 서비스 구현체를 제공합니다. 서비스 접근 API를 호출할 때 제공자 선택 기준을 제시할 수는 있지만, 반드시 그래야 하는 것은 아닙니다. 기준을 제시하지 않았을 경우 서비스 접근 API는 기본 구현체의 객체를 반환합니다. 서비스 접근 API는 결국 "유연한 정적 팩터리"이며, 서비스 제공자 프레임워크의 근간을 이루게 됩니다.

 

서비스 제공자 프레임워크를 구성하는 네번째 컴포넌트는 서비스 제공자 인터페이스입니다. 이 컴포넌트는 옵션이며 서비스 제공자가 구현합니다. 서비스 구현체의 객체를 생성하기 위한 것으로, 서비스 제공자 인터페이스가 없는 경우 구현체는 클래스 이름으로 등록되며 자바의 리플렉션 기능을 통해 객체로 만들어집니다. JDBC의 경우 Connection이 서비스 인터페이스, DriverManager.registerDriver가 제공자 등록 API, DriverManager.getConnection이 서비스 접근 API 역할을 하게 되고, Driver가 서비스 제공자 인터페이스 역할을 합니다.

 

// 서비스 제공자 인터페이스의 대략적인 모습

// 서비스 인터페이스
public interface Service {
  ... // 서비스에 고유한 메서드들이 이 자리에 온다.
}

// 서비스 제공자 인터페이스
public interface Provider {
  Service newService();
}

// 서비스 등록과 접근에 사용되는 객체 생성 불가능 클래스
public class Services {
  private Services() { } // 객체 생성 방지
  
  // 서비스 이름과 서비스 간 대응관계 보관
  private static final Map<String, Provider> providers = 
    new ConcurrentHashMap<String, Provider>();
  public static final String DEFAULT_PROVIDER_NAME = "<def>";
  
  // 제공자 등록 API
  public static void registerDefaultProvider(Provider p) {
    registerProvider(DEFAULT_PROVIDER_NAME, p);
  }
  public static void registerProvider(String name, Provider p) {
    providers.put(name, p);
  }
  
  // 서비스 접근 API
  public static Service newInstance() {
    return newInstance(DEFAULT_PROVIDER_NAME);
  }
  public static Service newInstance(String name) {
    Provider p = providers.get(name);
    if (p == null) 
      throw new IllegalArgumentException("No provider registered with name: " + name);
    return p.newService();
  }
}

 

서비스 제공자 프레임워크 패턴에는 다양한 변종들이 있습니다. 예를 들어 서비스 접근 API에 어댑터 패턴을 적용하면 제공자에게 요구되는 것보다 풍부한 기능을 제공하는 서비스 인터페이스 객체를 반환할 수 있습니다. 아래에 서비스 제공자 프레임워크 패턴의 간단한 구현 예를 보였습니다.

 

정적 팩터리 메서드의 네 번째 장점은, 형인자 자료형(parameterized type) 객체를 만들때 편하다는 점입니다.

 

이런 클래스의 생성자를 호출할 때는, 설사 문맥상 형인자가 명백하더라도 반드시 인자로 형인자를 전달해야 합니다. 그래서 보통 형인자는 연달아 두 번 사용하게 됩니다.

 

Map<String, List<Strubg> m> = new HashMap<String, List<String>>();

이처럼 자료형 명세를 중복하면, 형인자가 늘어남에 따라 길고 복잡한 코드가 만들어집니다. 하지만 정적 팩터리 메서드를 사용하면 컴파일러가 형인자를 스스로 알아내도록 할 수 있습니다. 이런 기법을 자료형 유추(type interface)라고 부릅니다. 예를 들어, HashMap 클래스가 아래의 제네릭 정적 팩터리 메서드르 제공한다고 가정해보겠습니다.

 

public static<K, V> HashMap<K, V> newInstance() {
  return new HashMap<K, V>();
}

이런 메서드가 있으면 앞서 살펴본 선언문을 좀더 간결하게 작성할 수 있습니다.

 

Map<String, List<String>> m = HashMap.newInstance();

 

바람직한 방법은 형인자 유틸리티 클래스 안에 정적 팩터리 메서드를 넣는 것입니다. 정적 팩터리 메서드만 있는 클래스를 만들면 생기는 가장 큰 문제는, public이나 protected로 선언된 생성자가 없으므로 하위 클래스를 만들 수 없다는 것입니다.

 

public 정적 팩터리 메서드가 반환하는 비-public 클래스도 마찬가지입니다. 예를 들어, 자바 컬렉션(Collections) 프레임워크에 포함된 기본 구현 클래스들의 하위 클래스는 만들 수 없습니다. 논쟁의 소지가 있긴 하지만 그래서 더 좋다는 사람도 있는데, 계승(inheritance) 대신 구성(composition) 기법을 쓰도록 장려한다는 이유에서 입니다.

 

두번째, 단점은 정적 팩터리 메서드가 다른 정적 메서드와 확연히 구분되지 않는다는 것입니다. API문서를 보면 생성자는 다른 메서드와 뚜렷이 구별되지만, 정적 팩터리 메서드는 그렇지 않습니다. 그러니 생성자 대신 정적 팩터리 메서드를 통해 객체를 만들어야 하는 클래스는 사용법을 파악하기가 쉽지 않습니다. Javadoc이 언젠가는 정적 팩터리 메서드를 다른 메서드와 다르게 표시해주는 날이 올지도 모르지만, 지금으로서는 클래스나 인터페이스 주석을 통해 정적 팩터리 메서드임을 널리 알리거나, 정적 팩터리 메서드 이름을 적을 때 조심하는 수밖에 없습니다. 보통 정적 팩터리 메서드의 이름으로는 다음과 같은 것들을 사용합니다.

 

valueOf: 단순하게 말하자면, 인자로 주어진 값과 같은 값을 갖는 객체를 반환한다는 뜻입니다. 따라서 이런 정적 팩터리 메서드는 형변환 메서드입니다.

of: valueOf를 더 간단하게 쓴 것입니다. EnumSet 덕분에 인기를 모은 이름입니다.

getInstance: 인자에 기술된 객체를 반환하지만, 인자와 같은 값을 갖지 않을 수도 있습니다. 싱글턴 패턴을 따를 경우, 이 메서드는 인자없이 항상 같은 객체를 반환합니다.

newInstance: getInstance와 같지만, 호출할때마다 다른 객체를 반환합니다.

getType: getInstance와 같지만, 반환될 객체의 클래스와 다른 클래스에 팩터리 메서드가 있을때 사용합니다. Type은 팩터리 메서드가 반환할 객체의 자료형입니다.

newType: newInstance와 같지만, 반환될 객체의 클래스와 다른 클래스에 팩터리 메서드가 있을 때 사용합니다. Type은 팩터리 메서드가 반환할 객체의 자료형입니다.

 

정적 팩터리 메서드와 public 생성자는 용도가 서로 다르며, 그 차이와 장단점을 이해하는 것이 중요합니다. 정적 팩터리 메서드가 효과적인 경우가 많으니, 정적 팩터리 메서드를 고려해 보지도 않고 무조건 public 생성자를 만드는 것은 삼가기 바랍니다.