Design Pattern : 생성 패턴(Creational Patterns)

김하영·2021년 2월 8일
7

생성패턴(Creational Patterns) 이란?

생성패턴은 객체의 생성에 관련된 패턴으로 객체의 생성절차를 추상화하는 패턴이다.
객체를 생성-합성하는 방법 / 객체의 표현방법과 시스템을 분리한다.

생성패턴 특징

  1. 생성패턴은 시스템이 어떤 구체 클래스(구체적인 클래스)를 사용하는지에 대한 정보를 캡슐화한다.

  2. 생성패턴은 이들 클래스의 인스턴스들이 어떻게 만들고 어떻게 서로 맞붙는지에 대한 부분을 완전히 가린다.

즉, 객체의 생성과 조합을 캡슐화해 특정 객체가 생성되거나 변경되어도 프로그램 구조에 영향을 크게 받지 않도록 유연성을 제공한다.

생성패턴 종류

생성패턴에는 아래와 같은 디자인 패턴이 존재한다.

  1. 추상 팩토리 패턴(Abstract Factory Pattern)
    : 동일한 주제의 다른 팩토리를 묶어 준다.

  2. 빌더 패턴(Builder Pattern)
    : 생성(construction)과 표기(representation)를 분리해 복잡한 객체를 생성한다.

  3. 팩토리 메서드 패턴(Factory Method Pattern)
    : 생성할 객체의 클래스를 국한하지 않고 객체를 생성한다.

  4. 프로토타입 패턴(Prototype Pattern)
    : 기존 객체를 복제함으로써 객체를 생성한다.

  5. 싱글턴 패턴(Singleton pattern)
    : 한 클래스에 한 객체만 존재하도록 제한한다.

1. 추상 팩토리 패턴(Abstract Factory Pattern)

  • 추상 팩토리 패턴이란?

비슷한 속성의 객체들을 인터페이스로 규격화된 팩토리에서 일관된 방식으로 생성하고, 생성된 객체끼리는 쉽게 교체될 수 있도록 고안한 패턴이다.

추상 팩토리 패턴은 상세화된 서브클래스를 정의하지 않고도 서로 관련성이 있거나 독립적인 여러 객체의 군을 생성하기 위한 인터페이스를 제공한다.

  • 추상 팩토리 패턴 구조
  1. AbstractFactory: 개념적 제품에 대한 객체를 생성하는 연산으로 인터페이스를 정의한다.
  2. ConcreteFactory: 구체적인 제품에 대한 객체를 생성하는 연산을 구현한다.
  3. AbstractProduct: 개념적 제품 객체에 대한 인터페이스를 정의한다.
  4. ConcreteProduct: 구체적으로 팩토리가 생성할 객체를 정의하고, AbstractProduct가 정의하는 인터페이스를 구현한다.
  5. Client: AbstractFactory와 AbstractProduct 클래스에 선언된 인터페이스를 사용한다.

Abstract Factory는 실제 객체가 정확히 무엇인지 알지 못해도 객체를 생성하고 조작할 수 있도록 해준다.
예를 들어 Iterator를 이용하는데 Iterator 인터페이스만 알면 구현체는 몰라도 된다.

이런 방식으로 Concrete Product를 사용하는 코드를 변경하지 않으면서도 손쉽게 새로운
Concrete Product를 추가할 수 있다.

Abstract Factory는 또한 다양한 환경에서 작동하는 코드를 쉽게 만들 수 있도록 해준다.
예를 들어 시스템은 각 환경에 맞는 고유한 Concrete Factory를 생성하고, 이는 다시 환경에 맞는 Concrete Product를 생성한다. 하지만 이들을 구현 클래스가 아닌 인터페이스를 통해 이용하기 때문에 Client는 사용하고 있는 환경(혹은 Concrete Product)이 무엇인지 알지 못한다.

  • 예제 코드

SuitAbstractFactory : Suit Abstract Factory Interface

public interface SuitAbstractFactory {
    Suit createSuit();
}

CombatFactory : AbstractFactory

public class CombatFactory implements SuitAbstractFactory {

    @Override
    public Suit createSuit() {
        return new CombatSuit();
    }
}

위와 같은 방식으로 SpaceFactory / StealthFactory 클래스를 구현한다.

SuitFactory : ConcreteFactory

public class SuitFactory {
    static public Suit getSuit(SuitAbstractFactory suitAbstractFactory){
        return suitAbstractFactory.createSuit();
    }
}

Main Class

public class SuitMain {
    public static void main(String[] args) {
        Suit suit1 = SuitFactory.getSuit(new CombatFactory());
        System.out.println(suit1.getName());
        Suit suit2 = SuitFactory.getSuit(new SpaceFactory());
        System.out.println(suit2.getName());
        Suit suit3 = SuitFactory.getSuit(new StealthFactory());
        System.out.println(suit3.getName());
    }
}

SuitAbstractFactory 를 작성하고 이를 구현하여(implements) 각 슈트를 생성하는 팩토리 클래스를 구성한다.
그리고 SuitFactory는 이 팩토리를 파라미터로 받아 최종적으로 생성된 슈트 객체를 반환한다.

2. 빌더 패턴(Builder Pattern)

  • 빌드 패턴이란?

복잡한 객체를 생성하는 방법을 정의하는 클래스와 표현하는 방법을 정의하는 클래스를 별도로 분리하여,
서로 다른 표현이라도 이를 생성할 수 있는 동일한 절차를 제공하는 패턴이다.

빌더 패턴은 많은 Optional한 멤버 변수(혹은 파라미터)나 지속성 없는 상태 값들에 대해 처리해야 하는 문제들을 해결한다.

예를 들어, 팩토리 패턴이나 추상 팩토리 패턴에서는 생성해야하는 클래스에 대한 속성 값이 많을 때 아래와 같은 이슈들이 있다.

  1. 클라이언트 프로그램으로부터 팩토리 클래스로 많은 파라미터를 넘겨줄 때 타입, 순서 등에 대한 관리가 어려워져 에러가 발생할 확률이 높아진다.
  2. 경우에 따라 필요 없는 파라미터들에 대해서 팩토리 클래스에 일일이 null 값을 넘겨줘야 한다.
  3. 생성해야 하는 sub class가 무거워지고 복잡해짐에 따라 팩토리 클래스 또한 복잡해진다.

빌더 패턴은 이러한 문제들을 해결하기 위해 별도의 Builder 클래스를 만들어 필수 값에 대해서는 생성자를 통해, 선택적인 값들에 대해서는 메소드를 통해 step-by-step으로 값을 입력받은 후에 build() 메소드를 통해 최종적으로 하나의 인스턴스를 리턴하는 방식이다.

빌더 패턴은 굉장히 자주 사용되는 생성 패턴 중 하나로, Retrofit이나 Okhttp 등 유명 오픈소스에서도 이 빌더 패턴을 사용하고 있다.

  • 빌더 패턴을 구현하는 방법
  1. 빌더 클래스를 Static Nested Class로 생성한다.
    이때, 관례적으로 생성하고자 하는 클래스 이름 뒤에 Builder를 붙인다.
    예를 들어, Computer 클래스에 대한 빌더 클래스의 이름은 ComputerBuilder 라고 정의한다.

  2. 빌더 클래스의 생성자는 public으로 하며, 필수 값들에 대해 생성자의 파라미터로 받는다.

  3. 옵셔널한 값들에 대해서는 각각의 속성마다 메소드로 제공하며, 이때 중요한 것은 메소드의 리턴 값이 빌더 객체 자신이어야 한다.

  4. 마지막 단계로, 빌더 클래스 내에 build() 메소드를 정의하여 클라이언트 프로그램에게 최종 생성된 결과물을 제공한다. 이렇듯 build()를 통해서만 객체 생성을 제공하기 때문에 생성 대상이 되는 클래스의 생성자는 private으로 정의해야 한다.

  • 빌드 패턴 구조

Builder Pattern에서 중요한 객체 4가지 개념이 있다.
4가지 개념을 적절히 조화시켜 구현하는 방법이 Builder Pattern이다.

  • Director : Builder를 이용하여 객체를 생성(반환)
  • Builder : 객체를 생성하는 방법과 형태를 가진 인터페이스
  • ConcreteBuilder : Builder 인터페이스의 구현체로 구현하고자하는 각 객체의 특징(형태)을 정의
  • Product : Director가 Builder를 이용해 생성하는 결과 객체

Builder는 객체를 구성하는 기능이 명시되어 있고, Director는 이러한 Builder를 활용하여 객체를 생성하고 반환하는 역할을 하게된다.
UML 분석을 간단하게 하면 Builder와 Director는 집합연관 관계에 있어 Director에서 Builder클래스의 변수를 받아 저장 후 사용하게 된다.
ConcreteBuilder는 Builder와 인터페이스 상 관계로서 Builder에서 작성방법 명시된 것을 가지고 실제 어떻게 구성이 되어야 하는지를 ConcreteBuilder에서 구현하게 된다. ConcreteBuilder와 Product는 의존관계로서 ConcreteBuilder 클래스에서 Product 클래스의 변수를 사용하여 구성하게 된다.

  • 예제 코드

NutritionFacts : Director / ConcreteBuilder

public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder {
        // Required parameters : 필수 인자
        private final int servingSize;
        private final int servings;

        // Optional parameters - initialized to default values : 선택적 인자는 기본값으로 초기화
        private int calories      = 0;
        private int fat           = 0;
        private int carbohydrate  = 0;
        private int sodium        = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings    = servings;
        }

        public Builder calories(int val) {
            calories = val;
            return this;    // 이렇게 하면 . 으로 체인을 이어갈 수 있다.
        }
        public Builder fat(int val) {
            fat = val;
            return this;
        }
        public Builder carbohydrate(int val) {
            carbohydrate = val;
            return this;
        }
        public Builder sodium(int val) {
            sodium = val;
            return this;
        }
        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }

    @Override
    public String toString() {
        return "NutritionFacts{" +
                "servingSize=" + servingSize +
                ", servings=" + servings +
                ", calories=" + calories +
                ", fat=" + fat +
                ", sodium=" + sodium +
                ", carbohydrate=" + carbohydrate +
                '}';
    }
}

Main Class

public class NutritionFactsMain {
    public static void main(String[] args) {
        NutritionFacts cocaCola = new NutritionFacts
                .Builder(240, 8)    // 필수값 입력
                .calories(100)
                .sodium(35)
                .carbohydrate(27)
                .build();           // build() 가 객체를 생성해 돌려준다.

        System.out.println(cocaCola.toString());
    }
}

이런 스타일의 빌더 패턴이라면 롬복의 @Builder 애노테이션으로 쉽게 사용할 수 있다.

3. 팩토리 메서드 패턴(Factory Method Pattern)

  • 팩토리 메서드 패턴이란?

팩토리 메서드 패턴은 객체를 생성하기 위해 인터페이스를 정의하지만 어떤 클래스의 인스턴스를 생성할 지에 대한 결정은 서브클래스가 내리도록 할 때 유용하게 사용된다.
어떤 클래스가 자신이 생성해야 하는 객체의 클래스를 예측할 수 없을때 사용한다.

  • 팩토리 메서드 패턴 구현시 고려할 점들

팩토리 메소드 패턴의 구현 방법은 크게 두 가지가 있다.

  1. Creator를 추상 클래스로 정의하고, 팩토리 메소드는 abstract로 선언하는 방법.
  2. Creator가 구체 클래스이고, 팩토리 메소드의 기본 구현을 제공하는 방법.

팩토리 메소드의 인자를 통해 다양한 Product를 생성하게 한다.

  1. 팩토리 메소드에 잘못된 인자가 들어올 경우의 런타임 에러 처리에 대해 고민할 것.
  2. Enum 등을 사용하는 것도 고려할 필요가 있다.

프로그래밍 언어별로 구현 방법에 차이가 있을 수 있다.

  • 팩토리 메서드 패턴 구조

팩토리 인터페이스를 정의하여 이를 서브 클래스가 상속받아 구현하는 형태이다.
서브 클래스는 Product 타입 객체를 생성하고 반환하는 createProduct 함수를 정의해야한다.

  • 예제 코드

Product : Product Interface

public abstract class Product {
    public abstract void use();
}

IDCard : Product 구현 클래스

public class IDCard extends Product {
    private String owner;

    public IDCard(String owner) {
        System.out.println(owner + "의 카드를 만듭니다.");
        this.owner = owner;
    }

    @Override
    public void use() {
        System.out.println(owner + "의 카드를 사용합니다.");
    }

    public String getOwner() {
        return owner;
    }
}

Factory : Factory Interface

public abstract class Factory {
    public final Product create(String owner) {
        Product p = createProduct(owner);
        registerProduct(p);
        return p;
    }
    protected abstract Product createProduct(String owner);
    protected abstract void registerProduct(Product p);
}

IDCardFactory : Factory 구현 클래스

public class IDCardFactory extends Factory {
    private List<String> owners = new ArrayList<>();

    @Override
    protected Product createProduct(String owner) {
        return new IDCard(owner);
    }

    @Override
    protected void registerProduct(Product p) {
        owners.add(((IDCard) p).getOwner());
    }

    public List<String> getOwners() {
        return owners;
    }
}

Main Class

public class IDCardMain {
    public static void main(String[] args) {
        Factory factory = new IDCardFactory();
        Product card1 = factory.create("홍길동");
        Product card2 = factory.create("이순신");
        Product card3 = factory.create("강감찬");
        card1.use();
        card2.use();
        card3.use();
    }
}

4. 프로토타입 패턴(Prototype Pattern)

  • 프로토 타입 패턴이란?

프로토타입은 주로 실제 제품을 만들기에 앞서 대략적인 샘플 정도의 의미로 사용되는 단어이다.

생성할 객체들의 타입이 프로토타입인 인스턴스로부터 결정되도록 하며 인스턴스는 새 객체를 만들기 위해 자신을 복제(clone)하게 된다.

패턴을 구현하려면 우선 clone() 메소드를 선언하는 추상 클래스를 하나 만든다.
다형적 생성자(polymorphic constructor) 기능이 필요한 클래스가 있다면 위의 추상 클래스를 상속받게 한 후, clone() 메소드 내의 코드를 구현한다.

  • 프로토 타입 패턴 특징
  1. 추상 팩토리 패턴과는 반대로 클라이언트 응용 프로그램 코드 내에서 객체 창조자(creator)를 서브클래스(subclass)하는 것을 피할 수 있게 해준다.
  2. 새로운 객체는 일반적인 방법(예를 들어, new를 사용해서라든지)으로 객체를 생성(create)하는 고유의 비용이 주어진 응용 프로그램 상황에 있어서 불가피하게 매우 클 때, 이 비용을 감내하지 않을 수 있게 해준다.

즉, 객체를 복사하여 생성하는 방식이므로 다수의 객체를 생성해야 할 경우 객체 생성의 비용을 효과적으로 감소시킬 수 있다. 프로토 타입이 존재하고 복제만 해서 객체를 생성하게 되므로 서브 클래스의 수를 줄일 수 있다는 장점도 있다.

  • 프로토 타입 패턴 구조

UML 구조는 위와 같다.
핵심적인 클래스 3가지로 Prototype, ConcretePrototype, Client가 있다.

  • Prototype : 복제하는 인터페이스 정의
  • ConcretePrototype : 복제하는 연산 구현
  • Client : 복제를 요청 및 새로운 객체를 생성

패턴 구현 시 우선 clone()메소드를 선언하는 추상 베이스 클래스를 하나 만든다.
다형적 생성자 기능이 필요한 클래스가 있다면, 그것을 앞서 만든 클래스를 상속받게 한 후 clone() 메소드 내의 코드를 구현한다.

  • 예제 코드

Employees : Prototype

public class Employees implements Cloneable{

    private List<String> empList;

    public Employees(){
        empList = new ArrayList<String>();
    }

    public Employees(List<String> list){
        this.empList=list;
    }

    public void loadData(){
        //read all employees from database and put into the list
        empList.add("Pankaj");
        empList.add("Raj");
        empList.add("David");
        empList.add("Lisa");
    }

    public List<String> getEmpList() {
        return empList;
    }

    @Override
    public Object clone() throws CloneNotSupportedException{
        List<String> temp = new ArrayList<String>();
        for(String s : this.empList){
            temp.add(s);
        }
        return new Employees(temp);
    }
}

Main Class

public class EmployeeMain {
    public static void main(String[] args) throws CloneNotSupportedException {
        Employees emps = new Employees();
        emps.loadData();

        //Use the clone method to get the Employee object
        Employees empsNew = (Employees) emps.clone();
        Employees empsNew1 = (Employees) emps.clone();
        List<String> list = empsNew.getEmpList();
        list.add("John");
        List<String> list1 = empsNew1.getEmpList();
        list1.remove("Pankaj");

        System.out.println("emps List: "+emps.getEmpList());
        System.out.println("empsNew List: "+list);
        System.out.println("empsNew1 List: "+list1);
    }
}

5. 싱글턴 패턴(Singleton pattern)

  • 싱글턴 패턴이란?

싱글턴 패턴은 인스턴스를 하나만 만들어 사용하기 위한 패턴이다.
커넥션 풀, 스레드 풀, 디바이스 설정 객체 등의 경우 인스턴스를 여러 개 만들게 되면 자원을 낭비하게 되거나 버그를 발생시킬 수 있으므로 오직 하나만 생성하고 그 인스턴스를 사용하도록 하는 것이 이 패턴의 목적이다.

하나의 인스턴스만을 유지하기 위해 인스턴스 생성에 특별한 제약을 걸어둬야 한다.
new를 실행할 수 없도록 생성자에 private 접근 제어자를 지정하고, 유일한 단일 객체를 반환할 수 있도록 정적 메소드를 지원해야 한다. 또한 유일한 단일 객체를 참조할 정적 참조변수가 필요하다.

  • 싱글턴 패턴 구조

Singleton : 하나의 인스턴스만을 생성하는 책임이 있으며 getInstance 메서드를 통해 모든 클라이언트에게 동일한 인스턴스를 반환하는 작업을 수행한다.

  • 예제코드

Printer : Singleton

public class Printer {
    private static Printer printer = null;

    private Printer(){}

    public static Printer getInstance() {
        if(printer == null) {
            printer = new Printer();
        }
        return printer;
    }

    public void print(String input) {
        System.out.println(input);
    }

    public static void main(String[] args) {
        Printer.getInstance().print("Singleton Printer print");
    }
}

기본생성자를 private 를 사용하여 생성을 불가능하게 하고 getInstance를 통해서만 생성할 수 있다.
getInstance는 내부적으로 생성되지 않았다면 생성하고, 기존에 생성된 값이 존재한다면 생성된 인스턴스를 리턴하는 형태로 프로그램 전반에 걸쳐 하나의 인스턴스를 유지한다.

또한 참고할 점은 인스턴스를 제공하는 메서드와 인스턴스 변수 모두 Static으로 선언된 정적 변수 및 메서드이다. 당연히 기본생성자를 통해 생성할 수 없기 때문에 외부에서 인스턴스에 접근하려면 클래스 변수 및 메서드에 접근을 허용해야하기 때문에 두 메서드는 정적타입으로 선언되어 있다.

  • 문제점

위와 같은 싱글턴 패턴은 Multi-Thread 환경에서 안전하지 않다.
여러 쓰레드가 공유되고 있는 상황에서는 아래의 블럭에서 조건문이 동시에 두번 돌 수 있기때문에 하나의 인스턴스가 아닌 여러개의 인스턴스가 발생 할 위험이 있다.

	public static Printer getInstance() {
		if(printer == null) {
			printer = new Printer();
		}
		return printer;
	}

뿐만 아니라 인스턴스가 상태를 유지해야하는 상황에서 싱글톤은 더 고려해야할 점이 많다.
아래의 예제에서 count값은 각기 다른 쓰레드에서 공유하고 있고 서로 다른 프로세스에서 처리하고 있기 때문에 값이 일관되지 않을 수 있다.

    public class Printer {
    	private static Printer printer = null;
    	private int count = 0;
    
    	private Printer(){}
    
    	public static Printer getInstance() {
    		if(printer == null) {
    			printer = new Printer();
    		}
    		return printer;
    	}
    
    	public void print(String input) {
    		count++;
    		System.out.println(input + "count : "+ count);
    	}
    }
  • 해결 방법

멀티쓰레드 환경에서 싱글톤의 문제를 해결 할 수 있는 방법은 두가지가 있다.

  1. 정적 변수에 인스턴스를 만들어 바로 초기화 하는 방법
  2. 인스턴스를 만드는 메서드에 동기화하는 방법

정적 변수는 객체가 생성되기 전 클래스가 메모리에 로딩할 때 만들어져 초기화가 한 번만 실행된다. 또한 정적 변수는 프로그램이 시작될 때부터 종료될 때까지 없어지지 않고 메모리에 계속 상주하며 클래스에서 생성된 모든 객체에서 참조할 수 있다. 따라서 기존에 조건문에서 체크하던 부분이 원천적으로 제거된다.

하지만 이 경우에도 Count 값은 각기 다르게 접근하기 때문에 쓰레드마다 값이 달라진다.
객체 생성 자체는 로드 시점에서 결정되어 하나의 객체만을 사용하지만 Count에 접근하는 것은 동시적으로 접근하기 때문에 그렇다. 이를 해결하기 위해서는 아래와 같이 synchronized 라는 키워드를 통해 여러 쓰레드에서 동시에 접근하는 것을 막는 방법으로 이를 해결 할 수 있다.

public class Printer {
	private static Printer printer = new Printer();
	private static int count = 0;

	private Printer(){}

	public static Printer getInstance() {
		return printer;
	}

	public synchronized static void print(String input) {
		count++;
		System.out.println(input + "count : "+ count);
	}
}

위와 같이 정적 클래스를 이용하면 객체를 전혀 생성하지 않고 메서드를 사용할 수 있고 인스턴스 메서드를 사용하는 것보다 성능 면에서 우수하다고 볼 수 있다.

  • 싱글턴 패턴의 문제점
  1. 싱글톤은 프로그램 전체에서 하나의 객체만을 공통으로 사용하고 있기 때문에 각 객체간의 결합도가 높아지고 변경에 유연하게 대처할 수 없다. 싱글톤 객체가 변경되면 이를 참조하고 있는 모든 값들이 변경되어야 하기 때문이다.

  2. 멀티쓰레드 환경에서 대처가 어느정도 가능하지만 고려해야 할 요소가 많아 사용이 어렵고, 프로그램 전반에 걸쳐서 필요한 부분에만 사용한다면 장점이 있다. 하지만 그 포인트를 잡기가 어렵다

profile
Back-end Developer

1개의 댓글

우와..유익한 내용 감사드려요! 포스팅 잘 읽고갑니다!

답글 달기