본문 바로가기
쿤즈 Dev/Design pattern

[Design Pattern] 추상 팩토리 패턴: 복잡한 객체 생성의 유연한 관리

by Koonz:) 2024. 7. 19.
728x90

소프트웨어 개발에서는 때때로 복잡한 객체 구조를 생성해야 할 때가 있습니다. 이러한 객체 구조는 종종 여러 개의 관련 객체로 구성되어 있으며, 이들 간의 일관성을 유지하기 위해서는 객체 생성 과정을 체계적으로 관리할 필요가 있습니다. 이럴 때 유용하게 사용할 수 있는 디자인 패턴이 추상 팩토리 패턴입니다.

 

이번 글에서는 추상 팩토리 패턴의 개념과 필요성, 그리고 이를 자바와 스프링 부트를 사용하여 구현하는 방법에 대해서 알아볼게요.


추상 팩토리 패턴이란

추상 팩토리 패턴 (Abstract Factory Pattern)은 객체 생성 패턴 중 하나로, 관련 객체들을 생성하기 위한 인터페이스를 제공합니다.

 

추상 팩토리 패턴은 구체적인 클래스를 지정하지 않고 관련된 객체들을 생성할 수 있는 방법을 제공합니다. 이는 서로 관련되거나 의존적인 객체들을 일관된 방식으로 생성할 때 유용합니다. 그림으로 보면 다소 복잡해 보일 수 있지만 실제 코드를 통해서 이해해 보면 조금 더 편하게 이해가 가능합니다.


추상 팩토리 패턴의 필요성

추상 팩토리 패턴은 다음과 같은 상황에 필요합니다.

 

객체 생성의 일관성 유지

여러 개의 관련 객체를 생성해야 할 때, 이들 객체 간의 일관성을 유지할 수 있습니다. 추상 팩토리 패턴을 사용하면 서로 관련된 객체들을 그룹으로 생성하여, 객체 간의 일관성을 보장할 수 있습니다.

객체 생성 코드의 캡슐화

객체 생성 로직을 캡슐화하여 클라이언트 코드에서 분리할 수 있습니다. 이를 통해 객체 생성 로직을 쉽게 변경할 수 있으며, 클라이언트 코드에 영향을 주지 않습니다.

유연성 향상

새로운 객체들을 추가하거나 기존 객체들을 수정할 때, 클라이언트 코드를 수정하지 않고도 쉽게 확장할 수 있습니다.


추상 팩토리 패턴 적용하기

이번 예제에서도 마찬가지로 커피를 주문하는 시스템에서 커피 주문시에 직접 옵션(예: 원두종류, 시럽)을 생성하는 추상 팩토리 패턴을 구현합니다. 추상 팩토리 패턴은 이름 그래도 추상화를 하기 때문에 대부분 인터페이스로 만들어 작업을 합니다.

 

추상 팩토리 패턴 적용 전

추상 팩토리 패턴을 적용하기 전에 먼저 예제 코드를 만들어 볼게요.

커피는 하나의 원두와 하나의 시럽만을 사용하고 있다고 가정합니다.

package com.koonsland.designpatterns.creational.abstractfactory.example;

public class Coffee {

    private Arabica bean;
    private Sugar syrup;

    public Arabica getBean() {
        return bean;
    }

    public void setBean(Arabica bean) {
        this.bean = bean;
    }

    public Sugar getSyrup() {
        return syrup;
    }

    public void setSyrup(Sugar syrup) {
        this.syrup = syrup;
    }
}

 

Coffee 클래스는 아라비카(Arabica) 원두를 가지고 있는 bean 필드와 설탕(Sugar) 시럽을 가지고 있는 syrup 필드를 가지고 있습니다. 그리고 이들을 수정 및 조회가 가능한 getter/setter를 가지고 있습니다. 아라비카 원두 클래스와 설탕 클래스도 만들어 줍니다.

package com.koonsland.designpatterns.creational.abstractfactory.example;

public class Arabica {
	// ...
}

 

package com.koonsland.designpatterns.creational.abstractfactory.example;

public class Sugar {
	// ...
}

 

내부 구현체는 이 글의 중요 부분이 아니기에 클래스 정의만 만들었어요.

 

이제 커피를 만드는 CoffeeFactory 인터페이스를 정의합니다.

package com.koonsland.designpatterns.creational.abstractfactory.example;

public interface CoffeeFactory {

    Coffee createCoffee();
}

 

커피 인터페이스에는 커피를 만드는 createCoffee() 메서드 하나를 정의하고 Override 할 수 있도록합니다.

 

다음은 CoffeeFactory 인터페이스를 상속받아서 실제로 커피를 만드는 구현체를 만들어 볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.example;

public class AmericanoFactory implements CoffeeFactory{
    @Override
    public Coffee createCoffee() {
        Coffee coffee = new Coffee();
        coffee.setBean(new Arabica());
        coffee.setSyrup(new Sugar());
        return coffee;
    }
}

 

아메리카노를 만드는 AmericanoFactory 클래스를 만들고 이는 CoffeeFactory를 상속받아서 createCoffee() 메서드의 구현체를 만들어 줍니다. 이때 내부에서는 Coffee 클래스의 인스턴스를 만들면서 bean은 Arabica 인스턴스를, syrup은 Sugar 인스턴스를 만들어 주입하고 coffee 인스턴스를 return 해줍니다.

 

지금까지 만든 내용을 테스트해보도록 할게요.

package com.koonsland.designpatterns.creational.abstractfactory.example;

public class Client {
    public static void main(String[] args) {
        CoffeeFactory coffeeFactory = new AmericanoFactory();
        Coffee coffee = coffeeFactory.createCoffee();
        System.out.println(coffee.getBean().getClass());
        System.out.println(coffee.getSyrup().getClass());
    }
}

 

결과는 다음과 같습니다.

class com.koonsland.designpatterns.creational.abstractfactory.example.Arabica
class com.koonsland.designpatterns.creational.abstractfactory.example.Sugar

 

createCoffee() 메서드 내에서 각각 Arabica, Sugar 인스턴스를 넣어주었고 각 인스턴스가 정상적으로 생성되었다는 것을 볼 수 있습니다. 그렇다면 여기서 원두를 바꾸고 싶다거나, 시럽의 종류를 바꾸고 싶다면 어떻게 해야 할까요?

 

커피 인스턴스를 만드는 AmericanoFactory 구현체 내부의 createCoffee() 메서드를 여러 개 생성하거나 parameter를 바꿔주어야 합니다. 이 경우는 Open-Close Principle (개방-폐쇄 원칙)에 위배되는 문제가 발생합니다.

 

이제 이러한 경우를 해결하기 위해서 추상 팩토리 패턴을 적용해 보도록 할게요.


추상 팩토리 패턴 적용

추상 팩토리 패턴을 적용하기 위해서 먼저 원두, 시럽과 같은 옵션들을 인터페이스로 만들어 줄게요. 아라비카는 원두이기 때문에 커피원두라는 클래스를 만들어 줍니다.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public interface CoffeeBean {
}

 

그리고 이 커피원두(CoffeeBean) 클래스를 상속받아 아라비카 원두를 만들어 볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class Arabica implements CoffeeBean {
}

 

이번에는 시럽을 인터페이스 만들어줍니다.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public interface Syrup {
}

 

이렇게 만든 시럽 인터페이스를 상속받아서 설탕시럽 클래스를 만들어 줍니다.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class Sugar implements Syrup {
}

 

재료들이 모두 준비가 되었기 때문에 Coffee 클래스를 수정해 보도록 하겠습니다.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class Coffee {
    private CoffeeBean bean;
    private Syrup syrup;

    public CoffeeBean getBean() {
        return bean;
    }

    public void setBean(CoffeeBean bean) {
        this.bean = bean;
    }

    public Syrup getSyrup() {
        return syrup;
    }

    public void setSyrup(Syrup syrup) {
        this.syrup = syrup;
    }
}

 

Coffee 클래스는 원두인 CoffeeBean 클래스와 시럽인 Syrup 클래스를 필드로 가지고 있습니다. 그리고 모두 구체적인 클래스가 아닌 인터페이스만을 가지고 있어요. 그래서 각각의 구체적인 클래스들은 확장이 가능한 형태가 됩니다.

 

이제는 커피원두와 시럽을 선택하도록 하는 인터페이스를 각각 만들어 볼게요. 먼저 커피 원두를 선택하는 CoffeeBeanFactory 인터페이스를 만들어 보겠습니다.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public interface CoffeeBeanFactory {

    CoffeeBean choiceBean();
}

 

CoffeeBeanFactory 인터페이스에서는 추상 메서드만 정의합니다. 원두를 선택하는 메서드만 정의하고 구체적은 구현체는 상속받아서 사용하는 클래스에서 만들도록 합니다. 그럼 인터페이스를 상속받아서 아라비카원두를 선택하는 구현체를 만들어볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class ArabicaCoffeeBeanFactory implements CoffeeBeanFactory{
    @Override
    public CoffeeBean choiceBean() {
        return new Arabica();
    }
}

 

ArabicaCoffeeBeanFactory 클래스에서는 choiceBean() 메서드를 오버라이드 해서 아라비카(Arabica) 인스턴스를 return 하도록 합니다. 이때 Arabica 인스턴스는 CoffeeBean 인터페이스를 상속받았기 때문에 return 값의 클래스를 CoffeeBean으로 정의할 수 있습니다.

 

이번에는 시럽을 선택하는 SyrupFactory 인터페이스를 만들어볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public interface SyrupFactory {

    Syrup choiceSyrup();
}

 

동일하게 시럽을 선택하는 메서드 하나만 만들어두고 이를 상속받아서 사용하는 클래스에서 구체적인 내용을 만들 수 있도록 합니다. 동일하게 설탕시럽을 만드는 SurgarSyrupFactory 클래스를 만들어보겠습니다.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class SugarSyrupFactory implements SyrupFactory{
    @Override
    public Syrup choiceSyrup() {
        return new Sugar();
    }
}

 

SugarSyrupFactory 클래스는 SyrupFactory 클래스를 상속받고 choiceSyrup() 메서드를 구현하며 Sugar인스턴스를 반환하도록 구현해 두었습니다.

 

지금까지 원두와 시럽이 인터페이스를 만들고 각각 구체적인 구현 클래스를 만들어서 확장했습니다. 또한, 각각의 생성방법은 Factory 클래스의 인터페이스를 만들고 구현체에서 원하는 원두와 시럽을 리턴하는 구현 클래스를 만들었습니다. 이제 준비가 끝났으므로 커피를 만드는 AmericanoFactory를 수정해 볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class AmericanoFactory implements CoffeeFactory {

    private final CoffeeBeanFactory coffeeBeanFactory;
    private final SyrupFactory syrupFactory;

    public AmericanoFactory(CoffeeBeanFactory coffeeBeanFactory, SyrupFactory syrupFactory) {
        this.coffeeBeanFactory = coffeeBeanFactory;
        this.syrupFactory = syrupFactory;
    }

    @Override
    public Coffee createCoffee() {
        Coffee coffee = new Coffee();
        coffee.setBean(coffeeBeanFactory.choiceBean());
        coffee.setSyrup(syrupFactory.choiceSyrup());
        return coffee;
    }
}

 

AmericanoFactory 클래스는 CoffeeFactory 인터페이스를 상속받아서 createCoffee() 메서드를 구현합니다. 이때 setBean() 메서드에 넘겨주던 구체적인 인스턴스였던 new Arabica()를 추상화된 인터페이스 타입으로 넘겨줍니다. 이를 위해서는 AmericanoFactory에서 CoffeeBeanFactory 인스턴스를 주입받고 이 인스턴스에서 choiceBean() 메서드를 호출해서 주입해 줍니다.

 

만약 생성자로부터 넘겨받은 CoffeeBeanFactory의 구체적인 클래스가 ArabicaCoffeeBeanFactory라면, choiceBean() 메서드를 호출할 경우 아라비카 커피가 주입되게 됩니다.

 

시럽도 마찬가지입니다. setSyrup() 메서드를 이용해서 주입하던 구체적인 new Sugar() 인스턴스를 제외하고 외부에서 주입받은 SyrupFactory 타입의 구현체를 이용해서 choiceSyryp() 메서드로 구체적인 구현체를 주입합니다.

 

만약 생성자로부터 넘겨받은 SyrupFactory의 구체적인 클래스가 SugarSyrupFactory라면 Sugar 인스턴스를 넘겨주게 됩니다.

 

이제 클라이언트 프로그램을 만들어서 테스트해 볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class Client {
    public static void main(String[] args) {
        CoffeeFactory coffeeFactory = new AmericanoFactory(new ArabicaCoffeeBeanFactory(), new SugarSyrupFactory());
        Coffee americano = coffeeFactory.createCoffee();
        System.out.println(americano.getBean().getClass());
        System.out.println(americano.getSyrup().getClass());
    }
}

 

AmericanoFactory의 생성자는 CoffeeBeanFactory와 SyrupFactory 타입을 받습니다. 이때 주입하는 인자는 아라비카를 생성하는 팩토리와 설탕을 생성하는 팩토리를 넣어주었어요. 이렇게 넣고 createCoffee() 메서드를 실행하면 다음과 같은 결과를 얻을 수 있습니다.

class com.koonsland.designpatterns.creational.abstractfactory.after.Arabica
class com.koonsland.designpatterns.creational.abstractfactory.after.Sugar

 

새로운 원두, 시럽 추가하기

새로운 원두를 추가해 볼게요. 기존의 소스들은 수정하지 않고 확장을 할 수 있습니다.

로부스타라는 원두가 있고 이 클래스인 Robusta를 만들어 볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class Robusta implements CoffeeBean {
}

 

Robusta 클래스 역시 CoffeeBean 클래스를 상속받아서 사용합니다.

 

또한, 동일하게 로부스타 원두를 만드는 팩토리 클래스도 만들어 줍니다.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class RobustaCoffeeBeanFactory implements CoffeeBeanFactory {
    @Override
    public CoffeeBean choiceBean() {
        return new Robusta();
    }
}

 

구현 메서드인 choiceBean()에서는 Robusta 인스턴스를 return 합니다. Robusta 인스턴스 역시 CoffeeBean 인터페이스를 상속받았기 때문에 리턴 타입이 CoffeeBean으로 가능합니다.

 

지금까지 기존에 구현된 클래스들은 구현 없이 새로운 원두를 추가하도록 해서 확장이 가능해졌습니다. Open-Closed Principle(개방-폐쇄 원칙)에 잘 맞게 만들어졌어요.

 

이제 다시 클라이언트에서 테스트해 볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class Client {
    public static void main(String[] args) {
        CoffeeFactory coffeeFactory = new AmericanoFactory(new ArabicaCoffeeBeanFactory(), new SugarSyrupFactory());
        Coffee americano1 = coffeeFactory.createCoffee();
        System.out.println(americano1.getBean().getClass());
        System.out.println(americano1.getSyrup().getClass());

        coffeeFactory = new AmericanoFactory(new RobustaCoffeeBeanFactory(), new SugarSyrupFactory());
        Coffee americano2 = coffeeFactory.createCoffee();
        System.out.println(americano2.getBean().getClass());
        System.out.println(americano2.getSyrup().getClass());
    }
}

 

첫 번째 AmericanoFactory는 기존과 동일하게 아라비카와 설탕을 선택하는 팩토리를 넣어주었어요. 두 번째는 새롭게 만든 로부스타 원두를 선택하는 팩토리를 주입해 주었습니다. 결과를 볼게요.

class com.koonsland.designpatterns.creational.abstractfactory.after.Arabica
class com.koonsland.designpatterns.creational.abstractfactory.after.Sugar
class com.koonsland.designpatterns.creational.abstractfactory.after.Robusta
class com.koonsland.designpatterns.creational.abstractfactory.after.Sugar

 

원하는 결과가 나왔습니다. 같은 방법으로 설탕뿐만 아니라 다른 시럽들도 추가할 수 있습니다. 같은 방법으로 바닐라 시럽을 추가해 볼게요.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class Vanilla implements Syrup{
}
package com.koonsland.designpatterns.creational.abstractfactory.after;

public class VanillaSyrupFactory implements SyrupFactory{
    @Override
    public Syrup choiceSyrup() {
        return new Vanilla();
    }
}

 

이제 확장은 굉장히 쉬워졌다는 것을 알 수 있습니다.

package com.koonsland.designpatterns.creational.abstractfactory.after;

public class Client {
    public static void main(String[] args) {
        CoffeeFactory coffeeFactory = new AmericanoFactory(new ArabicaCoffeeBeanFactory(), new SugarSyrupFactory());
        Coffee americano1 = coffeeFactory.createCoffee();
        System.out.println(americano1.getBean().getClass());
        System.out.println(americano1.getSyrup().getClass());

        coffeeFactory = new AmericanoFactory(new RobustaCoffeeBeanFactory(), new SugarSyrupFactory());
        Coffee americano2 = coffeeFactory.createCoffee();
        System.out.println(americano2.getBean().getClass());
        System.out.println(americano2.getSyrup().getClass());

        coffeeFactory = new AmericanoFactory(new RobustaCoffeeBeanFactory(), new VanillaSyrupFactory());
        Coffee americano3 = coffeeFactory.createCoffee();
        System.out.println(americano3.getBean().getClass());
        System.out.println(americano3.getSyrup().getClass());
    }
}

 

마지막에 VanillaSyrupFactory를 주입해 주면 결과는 다음과 같습니다.

class com.koonsland.designpatterns.creational.abstractfactory.after.Arabica
class com.koonsland.designpatterns.creational.abstractfactory.after.Sugar
class com.koonsland.designpatterns.creational.abstractfactory.after.Robusta
class com.koonsland.designpatterns.creational.abstractfactory.after.Sugar
class com.koonsland.designpatterns.creational.abstractfactory.after.Robusta
class com.koonsland.designpatterns.creational.abstractfactory.after.Vanilla

 


추상 팩토리 패턴의 장점

  • 객체 생성의 일관성: 관련 객체들을 일관된 방식으로 생성할 수 있습니다.
  • 코드 재사용성: 객체 생성 로직을 캡슐화하여 코드의 재사용성을 높일 수 있습니다.
  • 유연한 확장성: 새로운 객체들을 쉽게 추가할 수 있으며, 클라이언트 코드를 수정하지 않고도 확장할 수 있습니다.

결론

추산 팩토리 패턴은 객체 구조를 일관성 있게 생성하고 관리할 수 있는 유용한 디자인 패턴입니다. 이를 통해 객체 생성 로직을 캡슐화하고, 클라이언트 코드의 유연성과 확장성을 높일 수 있습니다. 스프링 부트와 함께 사용하면 DI(Depencency Injection)를 활용하여 더욱 유연한 객체 생성 시스템을 구축할 수 있습니다.

 

이번 글에서는 커피 주문 시스템 예제를 통해 추상 팩토리 패턴의 개념과 구현 방법을 살펴보았습니다. 도움이 되셨기를 바랍니다. 궁금한 부분이 있으시다면 댓글로 남겨주세요. 감사합니다.

 

댓글