보통 "클래스의 인스턴스를 생성하는 방법이 무엇이냐?"라고 묻는다면, 대부분 public 생성자를 이용하는 것을 말할 것이다. (그나마 Builder 패턴을 사용하여 조금 개선시킬 수 있다 정도?)
그런데 인스턴스를 만드는 방법은 한 가지 더 있는데, 바로 정적 팩터리 메서드(static factory method)다.
1. public 생성자
public class Foo {
public Foo() {}
}
2. 정적 팩터리 메서드
public class Foo {
private static Foo FOO = new Foo();
private Foo() {} // 인스턴스화 불가
public static final Foo getInstance() { // factory method
retirm FOO
}
}
📌 What is static factory method?
static으로 선언된 정적인 메서드이며, new 키워드를 사용하지 않고 인스턴스를 생성할 수 있다.
boolean 기본 타입의 박싱 클래스인 Boolean 객체는 다음과 같이 정의되어 있다.
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
정적 팩터리 메서드를 사용하여 인스턴스를 생성하면 장&단점 모두 존재한다.
pros1. 이름을 가질 수 있다.
한 클래스에 시그니처가 같은 생성자가 여러 개 필요할 것 같으면, 생성자를 정적 팩터리 메서드로 바꾸고 이름을 지어주자.
public 생성자를 이용하면 보통 아래의 메인 생성자를 이용한다.
public class Foo {
private String name;
private int age;
private String addr;
private Status status;
public Foo(String name, int age, String addr, Status status) {
this.name = name;
this.age = age;
this.addr = addr;
this.status = status;
}
}
하지만 모델링을 함께 한 팀원이 아니라면, 설령 그렇다 하더라도 몇 개월 지난 후에 이 객체를 다시 참조하게 됐을 때
Foo의 속성값만 보고는 해당 클래스가 어떤 특성을 가지고 있는지 쉽게 파악하기가 힘들다.
이럴때 정적 팩터리 메서드를 이용하면 명확하게 반환될 객체의 특성을 묘사할 수가 있다.
public class Foo {
private String name;
private int age;
private String addr;
private Status status;
public Foo(String name, int age, String addr, Status status) {
this.name = name;
this.age = age;
this.addr = addr;
this.status = status;
}
public static Foo basicFoo(String name, int age, String addr) {
return new Foo(name, age, addr, Status.BASIC);
}
public static Foo specialFoo(String name, int age, String addr) {
return new Foo(name, age, addr, Status.SPECIAL);
}
}
enum Status {
BASIC,
SPECIAL
}
정적 팩터리 메서드의 네이밍만으로 basic한 status를 가진 객체인지, special한 status를 가진 객체인지를 쉽게 파악할 수 있고 각각의 정적 팩터리 메서드가 어떠한 Instance를 반환할지 명시적으로 알려줄 수 있다.
또한 동일한 시그니처를 가진 생성자는 여러개를 만들 수 없다.
하지만 정적 팩터리 메서드를 사용하면 이 또한 가능해지므로, 이런 경우에 사용하는 것도 바람직하다.
public class Foo {
public Foo(String name) { ... }
public Foo(int age) { ... } // compile error
}
public class Foo {
public Foo() {...}
public static byName(String name) {
Foo foo = new Foo();
foo.name = name;
return foo;
}
public static byAge(int age) {
Foo foo = new Foo();
foo.age = age;
return foo;
}
}
📌 BigInteger.probablePrime()
BigInteger의 probablePrime 메서드나 Boolean의 valueOf 메서드가 하나의 예시다.
BigInteger 클래스에는 다양한 생성자가 있는데, BigInteger(int, int, Random)이 어떤 값을 리턴할지 쉽게 파악할 수 없다.
하지만 정적 팩터리 메서드를 사용하면 BigInteger.probablePrime(int, Random)로 코드를 작성하므로 명시적으로 이해할 수 있다.
pros2. 호출될 때마다 인스턴스를 새로 생성하지는 않아도 된다.
불변 클래스 생성이 가능해진다.
📌 불변 클래스(Immutable class; Item 17)
말 그대로 변경이 불가능한 클래스로, 레퍼런스 타입 객체이기 때문에 heap 영역에 생성된다.
변경은 불가능 하나, 재할당은 가능하다. (ex. String str = "a"에서 str = "b"를 하면, "b"라는 새로운 객체를 만들어 해당 객체를 str 변수가 참조하게 하는 것이다.)
이렇게 하면 Thread-safe이므로 객체가 안전하고, 멀티 스레드 환경에서도 동기화 작업 없 안정적으로 돌아간다.
단, 객체가 가지는 값마다 새로운 객체가 필요하기 때문에 메모리 누수와 성능 저하 측면에서 trade-off를 고려해야 한다.
불변 클래스(Immutable class)를 이용하면 인스턴스를 미리 생성하거나, 새로 생성한 인스턴스를 캐싱하여 재활용함으로써 불필요한 객체 생성을 막을 수 있게 된다.
public class Foo {
public static void main(String[] args) {
Bar bar = Bar.getInstance();
}
}
class Bar {
private static final Bar bar = new Bar();
public Bar() {}
public static Bar getInstance() { return bar; }
}
Bar 오브젝트의 인스턴스인 bar를 미리 만들어 놓고, getInstance()가 호출되면 동일한 Bar 객체가 return 된다.
하지만 아직까진 public 생성자를 동시에 선언하고 있는데, 이 방식을 사용할 때는 new 키워드를 통한 객체 생성을 통제하는 것이 일반적이다.
왜냐하면, 같은 객체를 반환하는 테크닉은 단순히 새로운 객체를 생성하지 않기 위함이라기 보다는 더 쓰임이 분명한 곳이 있기 때문이다.
정적 팩터리 방식의 클래스는 언제 어느 인스턴스를 살아 있게 할지를 철저하게 통제할 수 있다. (이를 인스턴스 통제 클래스, instance-controlled class라 한다.)
인스턴스를 통제함으로써 얻을 수 있는 이점은 다음과 같다.
1. 클래스를 싱글톤(singleton; Item 3)으로 만들 수 있다. → 인스턴스를 오직 하나만 생성할 수 있는 클래스
2. 인스턴스화 불가(noninstantiable; Item 4)로 만들 수도 있다.
3. 불변 값 클래스에서 동치인 인스턴스가 단 하나뿐임을 보장한다. → a==b일 때만 a.equals(b)가 성립한다.
비슷한 기법으로 플라이 웨이트 패턴이라는 것이 존재한다.
📌 플라이웨이트 패턴 (Flyweight pattern)
어떤 클래스의 인스턴스 하나만을 사용하여 여러 개의 가상 인스턴스를 제공하여 자원을 "공유"한다.
즉, 이미 존재하는 인스턴스가 있다면 공유를 하고, 없다면 새로 생성하여 Pool로 관리한다.
예시 코드는 다음과 같다.
package Chat2.Item1;
import java.util.HashMap;
interface Flyweight {
public void run();
}
class ConcreteFlyweight implements Flyweight {
private String type;
private int x;
private int y;
public ConcreteFlyweight(String type) {
this.type = type;
}
public void setType(String type) {
this.type = type;
}
public void setX(int x) {
this.x = x;
}
public void setY(int y) {
this.y = y;
}
@Override
public void run() {
System.out.println("ConcreteFlyweight (type="+type+"/x="+x+"/y="+y+")");
}
}
class FlyweightFactory {
private static final HashMap<String, ConcreteFlyweight> flyweightMap = new HashMap<>();
public static ConcreteFlyweight getConcreteFlyweight(String type) {
ConcreteFlyweight cf = (ConcreteFlyweight) flyweightMap.get(type);
if (cf == null) {
cf = new ConcreteFlyweight(type);
flyweightMap.put(type, cf);
System.out.println("새로 생성된 타입 : " + type);
}
return cf;
}
}
public class Foo {
public static void main(String[] args) {
String[] type = {"BASIC", "SPECIAL", "REGULAR"};
for (int i=0; i<10; i++) {
ConcreteFlyweight cf = (ConcreteFlyweight)FlyweightFactory.getConcreteFlyweight(type[(int)(Math.random()*3)]);
cf.setX((int) (Math.random()*100));
cf.setY((int) (Math.random()*100));
cf.run();
}
}
}
실행 결과를 확인해보면 같은 타입의 객체는 하나만 생성되며, 이 객체를 공유하고 있음을 알 수 있다.
싱글톤 패턴 또한 위와 비슷하나 정적 팩터리 메서드를 이용하면 다음과 같이 작성할 수 있다.
class Singleton {
private static Singleton singleton = null;
private Singleton() {}
static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
public class Foo {
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2); // true
}
}
pros3. 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.
반환할 객체의 클래스를 자유롭게 선택할 수 있게 하는 "유연성"을 제공한다.
(처음에 이 부분이 쉽게 와닿지 않았는데, 샘플 코드를 몇 가지 작성해보니 이해가 되었다. ^^)
다음과 같은 인터페이스와 구현하는 두 개의 클래스가 있다고 가정해보자.
public interface Shape {
void draw();
}
public class Circle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a circle");
}
}
public class Square implements Shape {
@Override
public void draw() {
System.out.println("Drawing a square");
}
}
이 코드를 정적 팩터리 메서드로 대체해보자.
public class ShapeFactory {
public static Circle createCircle() {
return new Circle();
}
public static Square createSquare() {
return new Square();
}
}
// 이후 인스턴스 생성
Circle circle = ShapeFactory.createCircle();
Square square = ShapeFactory.createSquare();
ShapeFactory 클래스의 각각의 정적 팩토리 메서드마다 여러 타입을 반환할 수 있다.
자바 8부터는 (인스턴스화 불가인) 동반 클래스(companion class) 없이 인터페이스를 반환하는 정적 메서드를 구현할 수도 있게 되었다.
// Java 8 이전
public interface Foo {
void foo();
public static class Companion {
public static void bar() {
System.out.println("Companion method bar called");
}
}
}
// Java 8 이후
public interface Foo {
void foo();
public static void bar() {
System.out.println("Static method bar called");
}
}
이 기능의 강력한 점은 API를 만들 때, 구현 클래스를 공개하지 않으면서 해당 객체를 반환할 수 있어 API를 작게 유지할 수 있다. (인터페이스 기반 프레임워크를 만드는 핵심 기술이기도 하다.)
API가 작아진다는 말은 곧 성능 측면 뿐만 아니라 개발자가 API를 사용하기 위해서 익혀야 하는 개념이 줄어들게 되었기 때문에 생산성 또한 증대된다.
즉, 제공자는 구현 클래스의 상세를 숨길 수 있고, 사용자는 인터페이스만으로 객체를 다룰 수 있다.
(클라이언트 입장에서는 실제 구현체를 알 필요조차 없다. 명시한 인터페이스대로 동작하는 객체임을 알기 때문에 사용법만 익히면 사용할 수 있다.)
예로 자바 컬렉션 프레임워크인 java.util.Collections가 있다.
핵심 인터페이스들에 수정 불가나 동기화 등의 기능을 덧붙인 45개의 유틸리티 구현체를 제공하나, 이 구현체 대부분을 단 하나의 인스턴스화 불가 클래스인 Collections에서 정적 팩터리 메서드를 통해 얻게 하였다.
단, 자바 8에서도 인터페이스에는 public 정적 멤버만 허용하기 때문에 정적 메서드 구현을 위한 코드는 별도의 package-private 클래스에 두어야 할 수 있다.
자바 9부터는 private 정적 메서드까지는 허락하지만 정적 필드와 정적 멤버 클래스는 여전히 public이어야 하므로 사용에 유의해야 한다.
한 가지 예시를 더 들어보기 위해 샘플 코드를 추가해보았다.
public interface Car {
void start();
void stop();
static Car createCar(String carType) {
switch(carType) {
case "sedan":
return new Sedan();
case "suv":
return new SUV();
default:
throw new IllegalArgumentException("Invalid car type");
}
}
}
public class Sedan implements Car {
// Sedan 구현체의 start(), stop() 메서드 구현
}
public class SUV implements Car {
// SUV 구현체의 start(), stop() 메서드 구현
}
Car 인터페이스는 Sedan과 SUV라는 두 개의 구현체를 가지고 있지만, 구현체 내부에는 팩토리 메서드가 따로 존재하지 않는다.
따라서 Car 인터페이스에 createCar()라는 정적 팩토리 메서드를 추가하여 Sedan과 SUV 객체를 생성할 수 있도록 하였다.
이렇게 함으로써, Car 인터페이스 구현체를 사용하는 클라이언트는 구현체를 직접 생성하는 것이 아니라 팩토리 메서드를 통해 원하는 구현체를 생성할 수 있게 된다.
pros4. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체도 반환할 수 있다.
EnumSet 클래스는 public 생성자 없이 public static 메서드, allOf(), of() 등과 같이 정적 팩토리만을 제공한다.
리턴하는 객체 타입은 enum 타입의 개수에 따라서 64개 이하면 RegularEnumSet 인스턴스를, 65개 이상이면 JumboEnumSet 인스턴스를 반환한다.
클라이언트는 두 클래스의 존재를 모르고 알 필요도 없다.
RegularEnumSet이 성능상 이점이 없다고 판단되면, 다음 릴리즈에서는 삭제해도 무방하며, 반대로 다른 클래스 인스턴스를 반환하는 기능을 추가해도 된다.
중요한 것은 리턴되는 객체가 EnumSet의 하위 클래스이기만 하면 되는 것이다.
pros5. 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
서비스 제공자 프레임워크(service provider framework)를 만드는 근간이 된다.
서비스 제공자 프레임 워크는 3개 핵심 컴포넌트로 이루어져 있다.
1. 서비스 인터페이스(service interface): 구현체 동작 정의
2. 제공자 등록 API(provider registration API): 제공자가 구현체를 등록할 때 사용
3. 서비스 접근 API(service access API): 클라이언트가 서비스의 인스턴스를 얻을 때 사용
그리고 3개의 핵심 컴포넌트와 더불어 종종 네 번째 컴포넌트가 쓰이기도 한다.
4. 서비스 제공자 인터페이스(service provider interface): 서비스 인터페이스의 인스턴스를 생성하는 팩토리 객체 설명
서비스 제공자 인터페이스가 없다면 각 구현체를 인스턴스로 만들 때 리플렉션(Item 65)을 사용해야 한다.
대표적인 서비스 제공자 프레임 워크로는 JDBC(Java Database Connectivity)가 있다.
이는 MySQL, OcacleDB, MariaDB 같은 DB를 JDBC라는 프레임 워크로 관리할 수 있게 해준다.
예를 들어, 'java.sql.Driver' 인터페이스를 정의하고 각각의 드라이버는 해당 인터페이스를 구현한 클래스를 제공하게끔 만들어두었다.
이후 클라이언트가 'java.sql.DriverManager' 클래스의 'getConnection()' 메서드를 호출할 때, 해당 메서드는 'java.sql.Driver'인터페이스를 구현한 클래스를 찾아서 인스턴스를 반환해준다.
따라서, 인터페이스와 인터페이스 구현체를 분리시키고, 정적 팩토리 메서드를 사용하여 인터페이스 구현체의 인스턴스를 반환하는 방식을 사용할 수 있으므로 높은 유연성을 제공한다.
📌 JDBC(Java Database Connectivity)
JDBC에서는 Connection이 서비스 인터페이스, DriverManager.registerDriver가 제공자 등록 API, DriverManager.getConnection이 서비스 접근 API, Driver가 서비스 제공자 인터페이스 역할을 수행한다.
getConnetion()을 호출했을 때, 반환하는 객체는 DB Driver마다 다르다.
이 부분을 코드와 함께 조금 더 파헤쳐보자.
1. Connection Interface
public interface Connection extends Wrapper, AutoCloseable {
Statement createStatement() throws SQLException;
PreparedStatement prepareStatement(String sql)
throws SQLException;
CallableStatement prepareCall(String sql) throws SQLException;
String nativeSQL(String sql) throws SQLException;
void setAutoCommit(boolean autoCommit) throws SQLException;
boolean getAutoCommit() throws SQLException;
void commit() throws SQLException;
void rollback() throws SQLException;
void close() throws SQLException;
boolean isClosed() throws SQLException;
(...)
}
Connection 인터페이스는 동작을 정의한다.
2. DriverManager.registerDriver
public class DriverManager {
(...)
public static synchronized void registerDriver(java.sql.Driver driver)
throws SQLException {
registerDriver(driver, null);
}
public static synchronized void registerDriver(java.sql.Driver driver,
DriverAction da)
throws SQLException {
if(driver != null) {
registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
} else {
throw new NullPointerException();
}
println("registerDriver: " + driver);
}
(...)
}
여기서 헷갈렸던 것이 처음에 제공자 등록 API라고 해서 Connection 인터페이스 구현체라고 생각했는데 그게 아니었다.
Connection 구현체는 JDBC 드라이버를 제공하는 벤더(vendor)가 제공하며, 이 드라이버는 JDBC API 일부분인 java.sql.Driver 인터페이스를 구현하고, DriverManager 클래스를 통해 "등록"이 되는 것이다.
즉, 벤더는 자신이 제공하는 JDBC 드라이버 정보를 Driver 인터페이스를 통해 제공하고, 이 드라이버가 Connection 인터페이스를 구현하는 구체적인 클래스를 생성하는 것이다. 그렇게 등록된 드라이버 중에서 Connection을 제공할 수 있는 드라이버를 DriverManger 클래스가 찾아서 구현체를 사용하는 것이다.
3. DriverMager.getConnection
private static Connection getConnection(
String url, java.util.Properties info, Class<?> caller) throws SQLException {
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
synchronized(DriverManager.class) {
if (callerCL == null) {
callerCL = Thread.currentThread().getContextClassLoader();
}
}
if(url == null) {
throw new SQLException("The url cannot be null", "08001");
}
println("DriverManager.getConnection(\"" + url + "\")");
SQLException reason = null;
for(DriverInfo aDriver : registeredDrivers) {
if(isDriverAllowed(aDriver.driver, callerCL)) {
try {
println(" trying " + aDriver.driver.getClass().getName());
Connection con = aDriver.driver.connect(url, info);
if (con != null) {
// Success!
println("getConnection returning " + aDriver.driver.getClass().getName());
return (con);
}
} catch (SQLException ex) {
if (reason == null) {
reason = ex;
}
}
} else {
println(" skipping: " + aDriver.getClass().getName());
}
}
if (reason != null) {
println("getConnection failed: " + reason);
throw reason;
}
println("getConnection: no suitable driver found for "+ url);
throw new SQLException("No suitable driver found for "+ url, "08001");
}
Connection con = aDriver.driver.connect(url, info); 를 보면 Driver로 부터 Connection 객체를 가져오고 있음을 알 수 있다.
4. Driver
public interface Driver {
Connection connect(String url, java.util.Properties info)
throws SQLException;
boolean acceptsURL(String url) throws SQLException;
DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info)
throws SQLException;
int getMajorVersion();
int getMinorVersion();
boolean jdbcCompliant();
public Logger getParentLogger() throws SQLFeatureNotSupportedException;
}
Driver는 서비스 제공자로서 등록되어, 인터페이스(Connetion)의 인스턴스를 생성한다.
여기서 connect() 메서드는 java.sql.Connection 인터페이스를 구현한 객체를 반환한다.
이제 단점을 알아보자.
cons1. 상속을 위해선 public/protected 생성자가 필요하니, 정적 팩터리 메서드만 제공하면 하위 클래스 생성 불가.
상속을 위해서는 super()를 호출하여 부모 클래스 함수들을 호출해야 하지만, 부모 클래스 생성자가 private라면 상속이 불가능하다. (물론 public 생성자로 만들면 끝나는 문제지만, 보통 정적 팩토리 메서드만 제공하는 경우 생성자를 통한 인스턴스 생성을 막아둔다. 예로 Collections같은 객체는 private로 구현되어 있어 상속이 불가능하다.)
하지만 이 제약으로 인해 상속보다 컴포지션(Item 18, 기존 클래스 확장 대신, 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조하는 방법)을 사용하도록 유도하며, 불변 타입으로 만들기 위해서 이 제약을 지켜야 하기 때문에 장점으로 작용하기도 한다.
cons2. 정적 팩터리 메서드는 프로그래머가 찾기 어렵다.
보통 API 문서들을 찾아보면 생성자는 제일 위에 따로 분류해둔다.
정적 팩토리 메서드는 메서드 개요에서 구분 없이 보여주기 때문에 쉽게 구분하기가 힘들다.
📌 정적 팩터리 메서드 명명 방식
위의 문제를 완화하기 위해서 개발자 간에 정적 팩터리 메서드 이름을 짓는 암묵적 룰들이 생겼다.
• from: 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
Date d = Date.from(instance);
• of: 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
• valueOf : from과 of의 상세 버전
BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
• instance 혹은 getInstance: 매개변수로 명시한 인스턴스를 반환, 같은 인스턴스임을 보장하지는 않는다.
StackWalker luke = StackWalker.getInstance(options);
• create 혹은 newInstance: instance 혹은 getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다.
Object newArray = Array.newInstance(classObject, arrayLen);
• getType: getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 사용한다. ("Type"은 팩터리 메서드가 반환할 객체의 타입이다.)
FileStore fs = Files.getFileStore(path);
• newType: newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 사용한다.
BufferedReader br = Files.newBufferedReader(path);
• type: getType과 newType의 간결한 버전
List<Complaint> litany = Collections.list(legacyLitany);
✒️ Summary
정적 팩터리 메서드와 public 생성자는 각각의 쓰임이 있다.
하지만 일반적인 경우 정적 팩터리를 사용하는 것이 유리한 경우가 많으므로 상대적인 장단점을 이해하고 사용할 수 있도록 하자.