ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Software Engineering] Design Pattern: Creational Patterns
    CS/Software Engineering 2026. 4. 8. 21:44

    Design Pattern이란

    디자인 패턴은 객체지향 설계에서 반복적으로 등장하는 문제들을 해결하기 위한 재사용 가능한 해결 방법이다. 특정 맥락(context)에서 검증된 방법을 정리한 것으로, 같은 문제를 매번 처음부터 고민할 필요 없이 패턴을 가져다 적용할 수 있다.

     

    이 개념은 원래 건축가 Christopher Alexander로부터 시작됐다. 그는 패턴을 "특정 맥락에서 반복적으로 발생하는 문제에 대한 검증된 해결 방법"으로 정의했다.

     

    이후 GoF(Gang of Four)가 이 개념을 소프트웨어 설계에 적용해 23가지 패턴을 정리했다.

     

    Architecture Pattern vs Design Pattern

    두 개념은 다루는 수준(level)이 다르다.

    • Architecture Pattern: 시스템 전체 구조를 다룬다 (Microservices, Layered, Client-Server 등)
    • Design Pattern: 클래스/객체 수준의 협력 방식을 다룬다 (Factory, Builder, Singleton 등)

    쉽게 말하면 Architecture는 "이 시스템을 어떤 큰 덩어리들로 구성할까"를 다루고, Design Pattern은 "그 덩어리 안에서 객체들이 어떻게 협력할까"를 다룬다.

     

     


     

    Design Pattern 분류

    GoF는 패턴을 목적에 따라 세 가지로 분류한다.

    • Creational Pattern: 객체를 어떻게 생성할 것인가
    • Structural Pattern: 객체들을 어떻게 유연하게 조합할 것인가
    • Behavioral Pattern: 객체들 사이의 역할과 책임을 어떻게 분배할 것인가

    이 글에서는 Creational Pattern을 다룬다.

     

     

    Creational Patterns (생성 패턴)

    생성 패턴은 객체 생성 방식을 캡슐화하는 것에 집중한다.

    new ClassName()을 코드 여기저기에 흩뿌리는 대신, 생성 책임을 한 곳으로 모으거나 유연하게 분리하는 방법들이다.


    Factory Pattern

    문제

    카페 관리 시스템을 만든다고 가정한다. 처음엔 커피만 팔아서 이렇게 썼다.

    Coffee coffee = new Coffee();
     

    그런데 코드 여러 곳에서 new Coffee()를 직접 호출하다 보면, 나중에 Tea도 팔게 됐을 때 그 모든 호출 지점을 찾아서 고쳐야 한다.

    호출 지점이 많을수록 수정은 고통스러워지고, 빠뜨리는 곳이 생기기 쉽다. 이게 높은 결합도(coupling)의 문제다.

     

    Simple Factory

    가장 먼저 떠올릴 수 있는 해결책은 객체 생성을 한 곳으로 모으는 것이다.

    class BeverageFactory {
        public static Beverage createBeverage(String type) {
            if (type.equals("coffee")) return new Coffee();
            if (type.equals("tea")) return new Tea();
            throw new IllegalArgumentException("Unknown type");
        }
    }
    
    // 클라이언트
    Beverage b = BeverageFactory.createBeverage("coffee");
     

    이제 새로운 음료가 생겨도 BeverageFactory 한 곳만 수정하면 된다.

    클라이언트는 구체 클래스(Coffee, Tea)를 몰라도 되고, 런타임에 어떤 객체를 만들지 결정할 수 있다. 이것이 Simple Factory다.

     

     

    Factory Method Pattern

    Simple Factory도 충분히 유용하지만, 한계가 있다. 새 음료가 생길 때마다 BeverageFactory의 조건문을 수정해야 한다는 점이다.

    열어서 수정하는 게 아니라, 확장할 때 기존 코드에 손을 대지 않는 것이 이상적이다(OCP, Open-Closed Principle).

    GoF의 Factory Method Pattern은 생성 책임을 서브클래스로 위임해서 이 문제를 해결한다.

    abstract class BeverageStore {
        public Beverage orderBeverage() {
            Beverage b = createBeverage(); // 서브클래스가 결정
            b.serve();
            return b;
        }
        protected abstract Beverage createBeverage(); // Factory Method
    }
    
    class CoffeeStore extends BeverageStore {
        protected Beverage createBeverage() { return new Coffee(); }
    }
    
    class TeaStore extends BeverageStore {
        protected Beverage createBeverage() { return new Tea(); }
    }
    
    // 클라이언트
    BeverageStore store = new CoffeeStore();
    store.orderBeverage(); // "Serving coffee"
     

    새로운 음료가 생기면 조건문을 수정하는 게 아니라, 새로운 XxxStore 서브클래스를 추가하기만 하면 된다. BeverageStore의 기존 코드는 건드리지 않는다.

     

    그래서 어떤 Store를 쓸지는 누가 결정하나?

    Factory Method를 적용하면 한 가지 새로운 고민이 생긴다. 어떤 BeverageStore 서브클래스를 쓸지 결정하는 코드가 클라이언트에 남는다.

    BeverageStore store;
    if (type.equals("coffee")) store = new CoffeeStore();
    else if (type.equals("tea")) store = new TeaStore();

     

    이 조건문 자체가 또 다른 결합을 만든다. 이 부분은 Simple Factory로 감싸서 캡슐화할 수 있다.

    class StoreFactory {
        public static BeverageStore createStore(String type) {
            if (type.equals("coffee")) return new CoffeeStore();
            if (type.equals("tea")) return new TeaStore();
            throw new IllegalArgumentException();
        }
    }

     

    결국 완전한 해결책은 없다. 어디선가는 "무엇을 만들지"를 결정해야 하고, 그 결정 지점을 얼마나 한 곳으로 모으고 클라이언트로부터 숨기냐의 문제다.

     

     

    생성자 파라미터가 다를 때

    서브클래스마다 생성자 파라미터가 다른 경우가 있다. 예를 들어 Tea는 허브 여부를 인자로 받는다

    class Tea implements Beverage {
        public Tea(String isHerbal) {  }
    }

     

    해결책 중 하나는 Factory 자체에 설정 상태를 주입하는 것이다.

    class TeaStore extends BeverageStore {
        private boolean herbal;
    
        public TeaStore(boolean herbal) {
            this.herbal = herbal;
        }
    
        protected Beverage createBeverage() {
            return new Tea(herbal ? "yes" : "no");
        }
    }
    
    // 클라이언트
    BeverageStore store = new TeaStore(true);
    store.orderBeverage();

     

    Factory가 생성 조건을 내부에 가지고 있어서, 외부에서 파라미터를 넘겨줄 필요 없이 깔끔하게 객체를 만든다.

     

     

     

    Abstract Factory Pattern

    문제

    Factory Method까지 적용하면 단일 객체 생성의 유연성은 확보된다. 그런데 실제 시스템에서는 객체 하나가 아니라 서로 관련된 여러 객체를 세트로 만들어야 하는 경우가 있다.

    카페 체인점 예시를 보자. 한국 매장과 이탈리아 매장은 판매하는 메뉴 구성이 다르다.

    • 한국: 아이스 아메리카노 + 고구마 케이크
    • 이탈리아: 에스프레소 + 티라미수

    그런데 "음료를 주문하고 디저트를 주문한다"는 동작 자체는 두 나라에서 동일하다. 달라지는 건 구체적으로 어떤 음료, 어떤 디저트냐이다. 이 세트가 문맥에 따라 통째로 바뀌는 것이다.

     

    해결

    Abstract Factory는 관련된 객체들을 세트로 묶어서 생성하는 인터페이스를 정의한다.

    interface CafeFactory {
        Beverage createBeverage();
        Dessert createDessert();
    }
    
    class KoreanCafeFactory implements CafeFactory {
        public Beverage createBeverage() { return new IceAmericano(); }
        public Dessert createDessert() { return new SweetPotatoCake(); }
    }
    
    class ItalianCafeFactory implements CafeFactory {
        public Beverage createBeverage() { return new Espresso(); }
        public Dessert createDessert() { return new Tiramisu(); }
    }
    
    // 클라이언트
    CafeFactory factory = new KoreanCafeFactory(); // 어딘가에서 주입
    Beverage b = factory.createBeverage();
    Dessert d = factory.createDessert();

    클라이언트는 CafeFactory 인터페이스만 알고 있으면 된다. 어떤 구체 클래스가 생성되는지, 어떻게 생성되는지 전혀 알 필요가 없다.

    또한 같은 Factory에서 만들어진 객체들은 항상 서로 호환된다(한국 음료 + 이탈리아 디저트 같은 조합이 실수로 생기지 않는다).

     

    Factory Method vs Abstract Factory

      Factory Method Abstract Factory
    생성 대상 단일 객체 연관된 객체 세트
    핵심 구조 서브클래스 상속으로 생성 위임 인터페이스로 Factory 자체를 추상화
    사용 상황 어떤 하나의 객체를 만들지 유연하게 결정할 때 문맥에 따라 전체 제품군이 바뀔 때

    핵심 차이는 추상화 수준이다. Factory Method는 "무엇을 만들지"를 서브클래스에 위임하고, Abstract Factory는 Factory 자체를 인터페이스 뒤에 숨겨서 "어떤 세트를 쓸지"를 런타임에 결정한다.

     

     

     

     

     

    Builder Pattern

     

    문제

    객체를 생성할 때 파라미터가 많으면 생성자 호출이 금방 읽기 어려워진다.

    Coffee c = new Coffee("arabica", true, false, 0, true, false, true, false);

     

    저 true와 false들이 각각 무엇을 의미하는지 코드만 봐서는 알 수가 없다. 파라미터 순서를 실수로 바꾸면 컴파일 에러도 안 나고 조용히 버그가 생긴다.

     

    여기에 파라미터가 하나 더 추가되면 생성자를 호출하는 모든 곳에서 코드를 수정해야 한다.

     

    해결

    Builder Pattern은 객체 생성 과정을 별도의 클래스로 분리하고, 각 파라미터를 이름 있는 메서드로 하나씩 설정할 수 있게 한다.

    핵심은 각 setter 메서드가 this(Builder 자신)를 반환한다는 점이다. 덕분에 메서드를 연속으로 이어서 호출하는 체이닝(chaining)이 가능하고, 마지막에 build()로 완성된 객체를 얻는다.

    class CoffeeBuilder {
        private String beanType;
        private boolean isHot;
        private int numIceCubes = 0;
        // ... 나머지 필드들
    
        public CoffeeBuilder withBeanType(String beanType) {
            this.beanType = beanType;
            return this; // Builder 자신을 반환
        }
        public CoffeeBuilder isHot(boolean isHot) {
            this.isHot = isHot;
            return this;
        }
        // ... 나머지 setter들
    
        public Coffee build() {
            return new Coffee(beanType, isHot, numIceCubes, ...);
        }
    }
    
    // 클라이언트
    Coffee c = new CoffeeBuilder()
        .withBeanType("arabica")
        .isHot(true)
        .withIceCubes(0)
        .addSugar(true)
        .build();
    파라미터 이름이 코드에 드러나서 읽기 쉽고, 필요 없는 파라미터는 그냥 설정하지 않으면 된다. Coffee 클래스 자체는 수정할 필요가 없다.
     
     
     

     

    Director (선택적)

    Builder 위에 Director를 하나 더 두면, "아메리카노를 만드는 방법", "아이스 라떼를 만드는 방법"처럼 특정 제품을 만드는 절차를 한 곳에서 정의할 수 있다.

    class CoffeeDirector {
        public Coffee makeAmericano(CoffeeBuilder builder) {
            return builder.withBeanType("arabica").isHot(true).addWater(true).build();
        }
        public Coffee makeIcedLatte(CoffeeBuilder builder) {
            return builder.withBeanType("arabica").isHot(false).withIceCubes(4).addSugar(true).build();
        }
    }
     

    Director는 선택 사항이다. 정해진 레시피가 있을 때 유용하고, 그냥 Builder만 써도 된다.

     

     


     

     

    Singleton Pattern

    문제

    DB 연결 객체나 Logger처럼 시스템 전체에서 딱 하나만 존재해야 하는 경우가 있다. 여러 개가 만들어지면 각 인스턴스가 서로 다른 상태를 가지거나, 불필요한 자원을 낭비하게 된다.

    해결

    Singleton은 두 가지를 보장한다.

    1. 클래스의 인스턴스가 오직 하나만 존재한다.
    2. 그 인스턴스에 전역으로 접근할 수 있다.

    구현 핵심은 생성자를 private으로 막는 것이다. 외부에서 new Logger()를 호출하지 못하게 막고, getInstance() 메서드를 통해서만 유일한 인스턴스를 가져가도록 강제한다.

    public class Logger {
        private static Logger instance = new Logger(); // 클래스 로딩 시점에 딱 한 번 생성
        private Logger() { } // 외부에서 new 호출 불가
    
        public static Logger getInstance() {
            return instance;
        }
    
        public void log(String msg) {
            System.out.println("[LOG] " + msg);
        }
    }
    
    // 클라이언트
    Logger logger = Logger.getInstance();
    logger.log("Order received");
     

    어디서 Logger.getInstance()를 호출하든 항상 같은 객체가 반환된다.

     


     

     

    정리

    Creational Pattern들은 공통적으로 "객체 생성 과정을 클라이언트로부터 분리"하는 방향을 지향한다.

    • Factory: 어떤 객체를 만들지 결정하는 책임을 분리한다
    • Abstract Factory: Factory 자체를 추상화해서 연관된 객체 세트를 교체 가능하게 한다
    • Builder: 복잡한 객체의 생성 과정을 단계별로 분리해서 가독성과 유지보수성을 높인다
    • Singleton: 인스턴스 수를 하나로 제한하고 전역 접근을 제공한다

    결국 이 패턴들이 해결하려는 문제는 모두 같다. new 키워드를 직접 쓰는 게 퍼지면 퍼질수록 나중에 바꾸기 어려워진다는 것이다.

    생성 책임을 어디에 두고 어떻게 캡슐화할지를 고민하는 것이 Creational Pattern의 본질이다.

     

     

     

    출처: 경북대학교 손정주 교수님, “소프트웨어공학" 강의 자료

     

Designed by Tistory.