본문 바로가기
JAVA/Effective Java

Item1. 정적 팩터리 메소드는 인스턴스를 얻기를 추상화한다

by 민휘 2023. 5. 14.

이 문서가 다루는 것

  • 정적 팩터리 메소드는 인스턴스를 얻기를 추상화한다
  • static 키워드
  • 메소드 시그니처의 추상화 : 상위 관점에서의 협력 기술
  • 인스턴스 얻는 방법의 추상화 : 플라이웨이트(캐싱), 싱글톤
  • 반환 타입의 추상화 : 인터페이스를 보고 프로그래밍하라
  • 서비스 제공자 프레임워크 관점에서의 유연성 : 스프링, ServiceLoader, 브리지 패턴
  • 상속 vs 합성
  • 정적 팩토리 메서드의 네이밍 패턴과 자바 독스
  • Simple Factory, Factory Method, Abstract Factory 패턴 비교

 

 

 

클래스의 인스턴스를 얻는 방법으로 정적 팩터리 메서드를 사용할 수 있다. 정적 팩터리 메소드는 인스턴스를 얻는 방법을 추상화하므로, new 연산자를 사용하는 것보다 훨씬 유연한 작업이 가능하다. 인스턴스 얻는 방법을 추상화한다는 것은, 클라이언트 관점에서 객체의 역할이 ‘인스턴스 얻기’로 단순화되는 것을 뜻한다. 그러면 인스턴스를 구체적으로 어떻게 얻을지는 해당 역할을 구현하는 객체가 정할 수 있다. new를 사용한 객체 생성은 인스턴스를 힙 메모리에 할당하여 인스턴스를 얻는 구체적인 방법 중 하나이다. 인스턴스 얻기를 추상화하면 꼭 메모리에 할당하지 않더라도 상수를 반환하거나 부가기능 등을 적용하여 캐싱, 싱글톤 패턴을 적용할 수 있다.

 

 

static 키워드

 

static은 변수 혹은 메소드 앞에 붙어 정적 변수와 정적 메소드를 만든다. static 변수와 메소드는 객체 레벨이 아닌 클래스 레벨의 타입이므로, 클래스.변수, 클래스.메소드()와 같이 사용할 수 있다.

 

static 멤버는 JVM 메모리 영역 중 메소드 영역에 할당된다. static 멤버를 사용하는 모든 객체는 메모리 영역을 공유하기 때문에, 클래스 로더가 static 키워드를 보는 순간 멤버를 메모리에 할당한다. 이때 static 메소드가 메모리 할당이 되지 않은 일반 필드를 사용하면 즉시 메모리 할당이 불가능하므로 컴파일 오류가 난다. 그래서 static 메소드 안에서는 static 필드만 사용 가능하다. static을 남용하면 코드 전체에 static이 퍼질 위험이 있으므로 필요한 부분에만 사용해야한다.

 

static은 전역 상태의 필드를 만드는 것이므로 캡슐화를 깨뜨리고 시스템 전체의 결합도를 높이는 원인이 될 수 있다. 글로벌 변수는 인스턴트 변수보다 테스트가 까다롭다. 또 프로그램이 종료되기 전에 항상 메모리에 상주하고 있어 GC에 수거되지 못하면 메모리 낭비가 발생한다.

 

그렇다면 static은 언제 사용하면 좋을까? 자주 사용되며 생성 비용이 비싼 객체에 사용하면 적절하다. 비용이 많이 드는 객체 생성 부분을 static final로 한번만 초기화해서 갖다 쓰는 로직으로 변경하면 메모리 낭비를 줄이고 속도를 증가시킬 수 있다. 해당 문제는 지연 초기화를 적용할 수도 있지만, 지연 초기화는 측정 가능한 성능 개선 없이 구현을 복잡하게 한다고 하여 추천하지 않는다.

 

참고자료 : https://honbabzone.com/java/java-static/

 

Java에서 자주 보이는 Static이란 무엇일까?

static을 제대로 이해하지 못하고 코딩하던 시절, 혼란스러웠던 부분이 있어 이를 방지하고자 static이 무엇인지 메모리 구조와 함께 알아보겠습니다.

honbabzone.com

 

 

 

 

정적 팩터리 메소드를 적용했을 때 클래스 작성자가 얻는 장점

 

1. 메소드 시그니처를 다르게 지정할 수 있다.

  • 메소드 이름을 지을 수 있으므로 가독성을 높일 수 있다.
  • 메소드 매개변수의 타입과 순서가 동일하더라도 이름으로 구분할 수 있다.
public static void main(String[] args) {
	// 생성자만 사용하는 것보다 가독성 좋다
	Item1 i1 = new Item1("Min");
	Item1.withName("Min");
	
	// 매개변수 타입 같아도 이름으로 구분 가능
	Item1.withName("Min");
	Item1.withAddress("경기도");
}

 

 

2. 인스턴스 얻는 방법을 추상화한다

 

생성자와 달리 팩토리 메소드는 호출될 때마다 힙에 메모리를 할당하는 방법만을 사용한다는 제한이 없다. 그래서 인스턴스 생성의 통제가 가능하다. 자주 사용되며 생성 비용이 큰 객체는 캐싱을 사용해 얻는 것이 더 유리할 것이다. 이때 사용할 수 있는 디자인 패턴으로 플라이웨이트 패턴이 있다.

 

 

플라이웨이트 패턴

  • 객체를 가볍게 만들어 메모리 사용을 줄이는 패턴
  • 자주 변하는 속성(extrinsit)과 변하지 않는 속성(intrinsit)을 분리하고, 변하지 않는 속성은 재사용한다.
  • FlyWeight는 공유에 사용할 클래스나 인터페이스이고, FlyWeightFactory는 FlyWeight 타입 객체를 얻는 방법을 제공한다. 내부적으로 캐싱을 구현한다. 변하지 않는 속성은 FlyWeight로 관리한다.
// FlyWeight
public final class Font {
    final String family;
    final int size;
    public Font(String family, int size) {
        this.family = family;
        this.size = size;
    }
    // getter
}

// Client
public class Character {
    private char value;
    private String color;
    private Font font;
    public Character(char value, String color, Font font) {
        this.value = value;
        this.color = color;
        this.font = font;
    }
}

// FlyWeightFactory
// Cache 구현
public class FontFactory {
    private Map<String, Font> cache = new HashMap<>();
    public Font getFont(String font) {
        if (cache.containsKey(font)) {
            return cache.get(font);
        } else {
            String[] split = font.split(":");
            // 폰트이름, 폰트사이즈 분리
            Font newFont = new Font(split[0], Integer.parseInt(split[1]));
            cache.put(font, newFont);
            return newFont;
        }
    }
}

// test
public class CharacterClient {
    public static void main(String[] args) {
        FontFactory fontFactory = new FontFactory();
        Character c1 = new Character('h', "white", fontFactory.getFont("nanum:12"));
        Character c2 = new Character('e', "white", fontFactory.getFont("nanum:12"));
        Character c3 = new Character('l', "white", fontFactory.getFont("nanum:12"));

        System.out.println(c1.getFont().equals(c2.getFont()));
        System.out.println(c1.getFont().equals(c3.getFont()));
        System.out.println(c2.getFont().equals(c3.getFont()));
    }
}

 

 

플라이웨이트 패턴 외에도, 프로그램 안에서 객체가 딱 한개만 존재하도록 만드는 싱글톤 패턴이 있다. private 생성자를 두고 public static 팩토리 메소드로 객체를 얻는다.

public final class Font {

    private static Font instance = null;
    private final String family;
    private final int size;

    private Font(String family, int size) {
        this.family = family;
        this.size = size;
    }

    public static synchronized Font getInstance(String family, int size) {
        if (instance == null) {
            instance = new Font(family, size);
        }
        return instance;
    }

}

 

 

혹은 enum으로도 싱글톤을 보장할 수 있다. enum은 type safety를 위해 제공되는 클래스이므로 역직렬화나 리플렉션에 의해 private 생성자가 호출될 염려가 없으므로 안전하게 사용 가능하다.

public enum Font {
    INSTANCE;

    private final String family;
    private final int size;

    private Font() {
        this.family = "Default Font";
        this.size = 12;
    }

    private Font(String family, int size) {
        this.family = family;
        this.size = size;
    }

    public String getFamily() {
        return family;
    }

    public int getSize() {
        return size;
    }
}

이외에도 메모리를 재사용할 수 있는 방법으로 static을 사용할 수 있다. 다만 이렇게 객체를 재사용하는 방법은 전역 변수가 되어 결합도를 높일 위험이 있으므로, 반드시 필요한 곳에만 사용해야 한다.

 

 

 

3. 반환 타입을 추상화한다

 

팩토리 메소드는 시그니처 정의의 제약이 적기 때문에 반환 타입을 역할로 지정할 수 있다. 반환 타입이 역할이 되면, 클라이언트는 컴파일 시점에 역할의 메시지에만 의존한다. 클라이언트가 기대하는 메시지를 이해할 수 있는 모든 객체가 런타임 시점의 의존성 후보가 될 수 있으므로, 객체의 제공자는 클라이언트에 아무런 영향 없이 반환 클래스를 변경할 수 있다. 인터페이스에 맞춰서 코딩하라는 원칙을 자연스럽게 지킬 수 있다.

public class HelloServiceFactory {
		public static HelloService of(String lang) {
        if (lang.equals("ko")) {
            return new KoreanHelloService();
        } else if (lang.equals("en")) {
            return new EnglishHelloService();
        }
        return new NoneHelloService();
    }
}

public class App {
    public static void main(String[] args) {
        HelloService hs = HelloServiceFactory.of("ko");
				hs.hello();
				hs = HelloServiceFactory.of("en");
				hs.hello();
    }
}

 

 

자바8부터 인퍼페이스에 퍼블릭 스태틱 메소드를 추가할 수 있다. 인터페이스에 정적 스태틱 팩토리 메소드를 포함할 수 있으므로, Factory 클래스를 따로 만들 필요 없이 인터페이스의 팩토리 메소드를 호출하여 객체를 생성할 수 있다.

public interface HelloService {

    String hello();

    // default : public
    public static HelloService of(String lang) {
        if (lang.equals("ko")) {
            return new KoreanHelloService();
        } else if (lang.equals("en")) {
            return new EnglishHelloService();
        }
        return new NoneHelloService();
    }

}

 

참고 : 보조 기능을 위해 자바8에 추가한 인터페이스의 스태틱 메소드와 디폴트 메소드

 

인터페이스 문법은 객체지향의 역할을 문법으로 구체화한 것이다. 역할은 책임의 집합이자 추상화된 책임이므로 퍼블릭 메시지만을 포함해야 한다. 그래서 원래 자바 인터페이스에는 퍼블릭 메시지만을 작성할 수 있다. 정적 메서드도 구현된 메서드라는 점에서 인터페이스의 추상성을 해친다는 점에서 스태틱 메소드 추가를 허용하지 않았다.

 

그래서 인터페이스를 구현하는 여러 객체에서 보조 메소드가 필요할 때, 인터페이스의 동반 클래스를 함께 제공했다. 대표적으로 Collection 인터페이스와 Collections 동반 클래스가 있다.

Collection<String> empty = Collections.emptyList();

 

Collections 클래스에는 Collection 인터페이스의 메소드를 보조하는 다양한 메소드들이 포함된다. 대표적인 예로는 sort, binarySearch, reverse, shuffle 등이 있다. 이 메소드들은 Collection 인터페이스를 구현한 객체에 대해서 동작한다.

 

자바8부터는 인터페이스에 퍼블릭 스태틱 메소드와 디폴트 메소드를 허용해서 보조 메소드 추가가 가능하다. 이 인터페이스를 구현하는 모든 인스턴스는 해당 메소드를 호출할 수 있다. 스태틱 메소드는 공유하는 메모리고, 디폴트 메모리는 인스턴스가 각각 갖는 메모리라는 점에서 차이가 있다.

 

만약 인터페이스를 구현한 클래스가 많은데, 인터페이스에 공통으로 필요한 기능 추가(보조 메소드)가 필요한 상황이다. 이때 인터페이스에 구현을 추가하면 기존 클래스를 깨트리지 않고, 동반 클래스 없이 새 기능을 추가할 수 있다. 특히 타입 헬퍼 또는 유틸리티 메소드를 제공할 때 유리하다.

public interface HelperMethods {
    public static String preMr(String name) {
        return "Mr. " + name;
    }
    public static String preMs(String name) {
        return "Ms. " + name;
    }
    public static String preDr(String name) {
        return "Dr. " + name;
    }
		public default void help() {}
}

public class Test {
    public static void main(String[] args) {
        String name = "Manav";
        System.out.println(HelperMethods.preMr(name)); // Mr. Manav
    }
}

 

만약 인터페이스를 구현할 때 기본 메소드의 동작이 문제가 된다면 기본 메소드를 오버라이딩한다.

public class DefaultFoo implements Foo {
    // 기본 메소드의 동작이 문제가 된다면
    // 기본 메소드 오버라이딩
    @Override
    public void printNameUpperClass() {
        System.out.println(getName().toUpperCase());
    }

		@Override
    public String getName() {
        return this.name;
    }
}

인터페이스에 구현을 추가할 수 있게 되면서부터 인터페이스의 기능이 풍부해졌고, 많은 헬퍼나 유틸리티 클래스가 deprecated 되었다. 하지만 아직 인터페이스는 멤버와 같은 상태를 가질 수 없으므로 헬퍼나 유틸리티 클래스가 여전히 유효하다.

 

 

4. 서비스 제공자 프레임워크 관점에서의 유연성

확장 가능한 애플리케이션을 만드는 방법을 알아보자.

애플리케이션 코드는 유지되면서 외적인 요인을 변경했을 때 애플리케이션의 동작을 바꿀 수 있는 애플리케이션을 확장 가능하다고 한다. 즉 기존 코드를 유지하면서 런타임에 주입하는 구현체를 바꿀 수 있도록 한다. 그러려면 서비스 클라이언트가 구체적인 구현체의 존재를 모르고 인터페이스만 알아야 한다.

 

 

구성 요소

  • 서비스 제공자 인터페이스(SPI)와 서비스 제공자(서비스 구현체)
  • 서비스 제공자 등록 API : 서비스 인터페이스의 구현체를 등록하는 방법
  • 서비스 접근 API : 서비스 클라이언트가 서비스 인터페이스의 인스턴스를 가져올 때 사용하는 API, 유연한 정적 팩토리의 실체. 즉 인스턴스를 얻는 방법을 추상화하여 제공하는 API.

 

변형

  • 의존 객체 주입 프레임워크
  • java.util.ServiceLoader
  • 브릿지 패턴

 

DI 프레임워크 사용 예제

  • SPI : HelloService
  • 서비스 제공자 : ChineseHelloService, 다른 패키지의 JAR로 제공되어도 됨
  • 서비스 제공자 등록 API : 스프링의 @Configuration 사용
  • 서비스 접근 API : 애컨, getBean

 

ServiceLoader의 예제

  • SPI : HelloService
  • 서비스 제공자 : ChineseHelloService (JAR로 제공됨)
  • 서비스 제공자 등록 api : META-INF.services 외부 파일에 서비스 구현체 풀 패키지 경로
  • 서비스 접근 api : 서비스 로더

 

브리지 패턴

  • 구체적인 것과 추상적인 것을 분리하고, 그 사이를 다리로 연결한다. 합성을 사용한다.
  • 구체적인 모듈과 추상적인 모듈이 독립적으로 발전할 수 있도록 하기 위함이다. 코드를 그대로 유지하면서 구현체를 바꿀 수 있다.
  • 전략 패턴과 비슷해보이지만 행동이 약간 다르다. 전략 패턴은 같은 행동을 다른 알고리즘으로 바꿔 끼는 것이고, 브리지 패턴은 하나의 동작을 추상화해서 서로 다른 구조(db, network 등)을 수행한다.

 

정적 팩터리 메소드의 단점

 

1. 정적 팩터리 메소드만 사용한다면 상속이 불가능하다

 

자식 클래스의 생성자는 항상 부모 클래스의 생성자를 먼저 호출한다. 그런데 자식이 부모 생성자에 접근 권한이 없다면 자식 클래스의 생성 자체가 불가능하다. public이나 protected 생성자 없이 정적 팩토리만 사용한다면 상속이 불가능하다.

하지만 이 제약은 상속보다 합성 사용을 유도하고 불변 타입으로 만들기 위해 필요하기도 하다.

 

참고 : 상속 vs 합성

코드를 재사용하는 목적에서 평가해보면, 상속보다 합성이 훨씬 유연하다. 상속을 코드 재사용 목적으로 사용한다면 부모와 자식이 구현에 의해 강하게 결합된다. 반면 합성은 메시지를 통해 느슨하게 의존하므로 변경이 발생하더라도 영향을 덜 받는다. 결합도가 낮다.

 

 

2. 정적 팩토리 메소드의 네이밍 패턴

 

생성자와 정적 팩토리 메소드를 함께 제공하는 경우, 정적 팩토리 메소드 시그니처를 읽었을 때 빠르게 파악하기 어렵다. 그래서 정적 팩토리 메소드의 네이밍 패턴을 따라 작명하는 것이 좋다.

 

  • from : 하나의 매개변수를 받는 형변환 팩토리 메소드
  • of : 여러 매개변수를 받는 팩토리 메소드
  • valueOf : from, of의 통합 네이밍
Date d = Date.from(instant);
Point p = Point.of(10, 20);
Color c = Color.valueOf("#FF0000");

 

  • create 혹은 newInstance : 새로운 인스턴스를 반환한다.
  • instance 혹은 getInstance : 기존에 만들어둔 인스턴스를 반환한다.
public static Calendar getInstance() {
    return getCalendar(TimeZone.getDefaultLocale());
}

public static <T> ArrayBlockingQueue<T> newInstance(int capacity) {
    if (capacity <= 0)
        throw new IllegalArgumentException();
    return new ArrayBlockingQueue<T>(capacity);
}
  • new{Type} : 자신이 아닌 다른 클래스를 생성해 반환한다.
  • get{Type} : 자신이 아닌 다른 클래스를 반환한다.
  • {type} : getType, newType의 통합 네이밍
// getType
public class Files {
	public static FileStore getFileStore(Path path) throws IOException {
	    Objects.requireNonNull(path);
	    FileSystemProvider provider = path.getFileSystem().provider();
	    if (provider.getFileStoreAttributeViewClass() == null)
	        throw new UnsupportedOperationException("The provider [" + provider + "] does not support accessing file stores");
	    return provider.getFileStore(path);
	}
}

 

 

→ 가장 좋은 방법은 문서화를 잘 해두는 것!

 

JavaDoc 생성

  • Maven : mvn javadoc:javadoc
  • Gradle : build.gradle에 java { withJavadocJar() } 추가
  • target이나 out 하위에 index.html 파일을 브라우저로 열어 확인한다.

 

자바독 주석

  • /** */ 사용
  • 클래스에 대한 설명
  • @see 참조 : See also 문단에 외부 링크 참조

 

 

 

 

Simple Factory, Factory Method, Abstract Factory 패턴 비교

 

Simple Factory

  • 객체 생성을 위한 하나의 중앙 팩토리 클래스를 두고, 이 클래스에서 요청에 따라 객체를 생성하여 반환한다.
  • 팩토리 클래스는 일반적으로 static 메소드를 가지며, 객체 생성을 위한 로직을 내부에 구현한다.
  • 객체 생성을 캡슐화하는 간단한 방법이지만, 팩토리 클래스가 담당하는 객체의 종류가 많아지거나 객체 생성 로직이 복잡해지면 유지보수가 어렵다.
public class SimplePizzaFactory {
    public Pizza createPizza(String type) {
        Pizza pizza = null;
        if (type.equals("cheese")) {
            pizza = new CheesePizza();
        } else if (type.equals("pepperoni")) {
            pizza = new PepperoniPizza();
        } else if (type.equals("clam")) {
            pizza = new ClamPizza();
        } else if (type.equals("veggie")) {
            pizza = new VeggiePizza();
        }
        return pizza;
    }
}

 

Factory Method

  • 객체 생성을 위한 인터페이스를 추상 클래스나 인터페이스 정의하고, 이를 하위 클래스에서 구현하여 객체 생성을 담당한다.
  • 하위 클래스에서 객체 생성 방식을 결정하므로, 객체 생성 방식을 확장할 수 있다.
  • 마찬가지로 하위 클래스가 많아지면 관리와 유지보수가 어렵다.
public abstract class PizzaStore {
 
    public Pizza orderPizza(String type) {
        Pizza pizza;
 
        pizza = createPizza(type);
 
        pizza.prepare();
        pizza.bake();
        pizza.cut();
        pizza.box();
 
        return pizza;
    }
 
    abstract Pizza createPizza(String type);
}
 
public class NYPizzaStore extends PizzaStore {
 
    Pizza createPizza(String item) {
        if (item.equals("cheese")) {
            return new NYStyleCheesePizza();
        } else if (item.equals("veggie")) {
            return new NYStyleVeggiePizza();
        } else if (item.equals("clam")) {
            return new NYStyleClamPizza();
        } else if (item.equals("pepperoni")) {
            return new NYStylePepperoniPizza();
        } else return null;
    }
}
 
public class ChicagoPizzaStore extends PizzaStore {
 
    Pizza createPizza(String item) {
        if (item.equals("cheese")) {
            return new ChicagoStyleCheesePizza();
        } else if (item.equals("veggie")) {
            return new ChicagoStyleVeggiePizza();
        } else if (item.equals("clam")) {
            return new ChicagoStyleClamPizza();
        } else if (item.equals("pepperoni")) {
            return new ChicagoStylePepperoniPizza();
        } else return null;
    }
}

 

Abstract Factory

  • 추상 팩토리 클래스를 사용하여, 관련성이 있는 객체들을 생성하는 방식을 묶어놓았다.
  • 하나의 추상 팩토리 클래스로 여러 종류의 객체를 생성할 수 있으며, 이를 조합하여 객체를 생성하는 방식이 가능하다.
  • 객체 생성 방식이 복잡해질수록 구현이 복잡해진다.
public interface PizzaIngredientFactory {
    public Dough createDough();
    public Sauce createSauce();
    public Cheese createCheese();
    public Veggies[] createVeggies();
    public Pepperoni createPepperoni();
    public Clams createClam();
}
 
public class NYPizzaIngredientFactory implements PizzaIngredientFactory {
 
    public Dough createDough() {
        return new ThinCrustDough();
    }
 
    public Sauce createSauce() {
        return new MarinaraSauce();
    }
 
    public Cheese createCheese() {
        return new ReggianoCheese();
    }
 
    public Veggies[] createVeggies() {
        Veggies veggies[] = { new Garlic(), new Onion(), new Mushroom(), new RedPepper() };
        return veggies;
    }
 
    public Pepperoni createPepperoni() {
        return new SlicedPepperoni();
    }
 
    public Clams createClam() {
        return new FreshClams();
    }
}

 

 

 

 

참고 자료