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

[Design Pattern] 빌더 패턴: 복잡한 객체 생성의 효율적인 관리

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

소프트웨어 개발에서 객체를 생성하는 과정이 점점 복잡해질 때가 있습니다. 기능이 확장되고 필요한 정보가 늘어남에 따라 원하는 정보가 많아지게 되면 계속해서 복잡해지죠. 이를 효율적으로 관리할 수 있는 패턴이 필요합니다. 빌더 패턴은 이러한 상황에서 매우 유용하게 사용될 수 있는 디자인 패턴입니다.

 

이번 글에서는 빌더 패턴의 개념과 필요성, 그리고 이를 자바와 스프링부트를 사용하여 구현하는 방법을 알아보겠습니다.


빌더 패턴 (Builder Pattern)

빌더 패턴(Builder Pattern)은 복잡한 객체를 단계별로 생성할 수 있도록 도와주는 생성 패턴 중 하나입니다.

빌더 패턴

 

빌더 패턴을 사용하면 객체의 생성 과정을 캡슐화하여, 클라이언트가 객체의 내부 구조를 알 필요 없이 객체를 생성할 수 있습니다. 이는 특히 생성자나 정적 팩토리 메서드가 많은 매개변수를 가질 때 유용합니다.


빌더 패턴의 필요성

빌더 패턴은 다음과 같은 상황에서 필요합니다.

 

객체 생성의 유연성

객체의 생성 과정을 유연하게 관리할 수 있습니다. 객체의 속성을 선택적으로 설정할 수 있으며, 필요한 속성만을 설정하여 객체를 생성할 수 있습니다.

가독성 향상

빌더 패턴을 사용하면 객체 생성 코드의 가독성이 향상됩니다. 각 속성을 설정하는 메서드가 체인 형태로 연결되어, 코드가 명확하고 이해하기 쉽습니다.

불변 객체 생성

빌더 패턴을 사용하면 불변 객체(immutable object)를 쉽게 생성할 수 있습니다. 이는 객체의 상태가 변경되지 않도록 보장할 수 있습니다.


빌더 패턴 적용하기

커피 주문 시스템을 만들어보도록 할게요. 우리가 커피를 주문할 때 아이스아메리카노를 간단하게 주문할 수도 있지만 시즌음료를 주문해서 각종 토핑과 원두를 추가/변경할 수 있고 사이즈도 수정하여 복잡한 커피를 주문할 수 있습니다. 이를 코드로 만들어 보겠습니다.

 

🧑🏻‍💻 소스코드(Github): https://github.com/koonsland/design-patterns

 

빌더 패턴 적용 전

빌더 패턴 적용 전 객체 생성로직을 만들어 볼게요. 먼저 Coffee 클래스를 만들고 다양한 필드들을 넣어보겠습니다.

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

public class Coffee {
    private String type;
    private String bean;
    private String size;
    private boolean milk;
    private boolean sugar;
    private String syrup;
    private String temperature;

    public Coffee(String type, String size) {
        this.type = type;
        this.size = size;
    }

    public Coffee(String type, String bean, String size) {
        this(type, size);
        this.bean = bean;
    }

    public Coffee(String type, String bean, String size, boolean milk, boolean sugar, String syrup, String temperature) {
        this(type, bean, size);
        this.milk = milk;
        this.sugar = sugar;
        this.syrup = syrup;
        this.temperature = temperature;
    }

    public String getType() {
        return type;
    }

    public String getBean() {
        return bean;
    }

    public String getSize() {
        return size;
    }

    public boolean isMilk() {
        return milk;
    }

    public boolean isSugar() {
        return sugar;
    }

    public String getSyrup() {
        return syrup;
    }

    public String getTemperature() {
        return temperature;
    }

    @Override
    public String toString() {
        return "Coffee{" + "type='" + type + '\'' + ", bean='" + bean + '\'' + ", size='" + size + '\'' + ", milk=" + milk + ", sugar=" + sugar + ", syrup='" + syrup + '\'' + ", temperature='" + temperature + '\'' + '}';
    }
}

 

생성자는 필요한 몇 개만 만들어 보았습니다. 가장 기본적인 클래스의 형태이며 클라이언트를 만들어서 테스트를 진행해 볼게요.

 

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

public class Client {
    public static void main(String[] args) {
        Coffee americano = new Coffee("아메리카노", "TALL");
        System.out.println(americano);

        Coffee latte = new Coffee("카페라떼", "아라비카", "TALL", true, false, null, "HOT");
        System.out.println(latte);

    }
}

 

클라이언트 클래스에서는 Coffee 인스턴스를 만들기 위해서 생성자를 호출합니다. 만약에 필드의 개수가 10개, 혹은 20 이상으로 넘어간다고 가정하면 매번 생성할 때마다 parameter의 순서를 확인하는 것조차도 힘들 것 같습니다.

 

이제 이 코드를 빌더 패턴을 적용해서 다시 만들어 볼게요.

 

빌더 패턴 적용

빌더 패턴을 적용해 보도록 할게요. 기존 Coffee 클래스는 수정할 부분이 없어서 그대로 두도록 하겠습니다. 가장 먼저는 빌더 클래스를 만들어 주는 방법입니다. 이는 두 가지 형태가 존재합니다. 인터페이스를 이용하는 방법과 클래스 내부에 static 클래스를 만들어 주는 방법입니다. static 클래스로 만들어 주는 방법은 추후 롬복(Lombok)을 통해서 자동으로 만들어 주는 방법과 동일한 방법이기 때문에 저는 인터페이스를 통해서 구현해 보도록 할게요.

 

🧑🏻‍💻 롬복(Lombok)을 통한 방법
롬복을 이용하면 @Builder 어노테이션만으로도 빌더패턴을 적용할 수 있습니다. 원리를 알기 위해서 인터페이스로 진행합니다.

 

먼저 CoffeeBuilder 인터페이스를 만들어 줍니다.

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


public interface CoffeeBuilder {

    CoffeeBuilder type(String type);
    CoffeeBuilder bean(String bean);
    CoffeeBuilder size(String size);
    CoffeeBuilder milk(boolean milk);
    CoffeeBuilder sugar(boolean sugar);
    CoffeeBuilder syrup(String syrup);
    CoffeeBuilder temperature(String temperature);
    Coffee makeCoffee();
    
}

 

인터페이스에는 각 필드의 setter와 같은 메서드를 구현해 줍니다. 다만 이때 return 타입은 인터페이스 타입으로 만들어 줍니다. 그리고 마지막으로 makeCoffee() 메서드를 통해서 Coffee 인스턴스를 리턴해줍니다.

 

이번에는 이 CoffeeBuilder 인터페이스의 구현체를 만들어 볼게요.

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

public class BasicCoffeeBuilder implements CoffeeBuilder {

    private String type;
    private String bean;
    private String size;
    private boolean milk;
    private boolean sugar;
    private String syrup;
    private String temperature;

    @Override
    public CoffeeBuilder type(String type) {
        this.type = type;
        return this;
    }

    @Override
    public CoffeeBuilder bean(String bean) {
        this.bean = bean;
        return this;
    }

    @Override
    public CoffeeBuilder size(String size) {
        this.size = size;
        return this;
    }

    @Override
    public CoffeeBuilder milk(boolean milk) {
        this.milk = milk;
        return this;
    }

    @Override
    public CoffeeBuilder sugar(boolean sugar) {
        this.sugar = sugar;
        return this;
    }

    @Override
    public CoffeeBuilder syrup(String syrup) {
        this.syrup = syrup;
        return this;
    }

    @Override
    public CoffeeBuilder temperature(String temperature) {
        this.temperature = temperature;
        return this;
    }

    @Override
    public Coffee makeCoffee() {
        return new Coffee(type, bean, size, milk, sugar, syrup, temperature);
    }
}

 

기본 BasicCoffeeBuilder 구현체를 만들었습니다. 모든 메서드는 @Override를 통해서 내부 구현체를 정의했어요. 각각의 필드는 매개변수로 받은 값을 넣어주고 this를 통해 자신을 리턴하도록 만들었습니다.

 

빌더패턴에는 생성할 모든 필드들을 전부 가지고 있어야 합니다. 그리고 최종적으로 Coffee 인스턴스를 생성해서 리턴해 주는 패턴입니다.

 

이번에는 클라이언트를 만들어서 테스트를 진행해 볼게요.

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

public class Client1 {
    public static void main(String[] args) {
        CoffeeBuilder coffeeBuilder = new BasicCoffeeBuilder();

        Coffee coffee = coffeeBuilder.type("아메리카노")
                .size("TALL")
                .makeCoffee();

        System.out.println(coffee);

        Coffee latte = coffeeBuilder.type("라떼")
                .size("GRANDE")
                .milk(true)
                .syrup("바닐라")
                .bean("아라비카")
                .temperature("HOT")
                .makeCoffee();

        System.out.println(latte);
    }
}

 

클라이언트는 CoffeeBuilder 타입으로 인스턴스를 만들며, 구체적인 구현체는 BasicCoffeeBuilder를 통해서 만들어 줍니다.

CoffeeBuilder 인스턴스는 필요한 값들을 체이닝(Chaining)을 통해서 입력받고 마지막에 makeCoffee() 메서드를 통해서 최종적으로 원하는 Coffee 인스턴스를 만들어 줍니다.

 

디렉터(Director) 만들기

이번에는 디렉터를 통해 만들어 보겠습니다. 빌더를 사용하더라도 매번 같은 값을 만들어 줘야 한다면 굳이 매번 parameter들을 입력해 줄 필요 없이 디렉터를 이용해 주도록 합니다. 디렉터는 다음과 같은 만들어 볼게요.

 

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

public class CoffeeDirector {

    private final CoffeeBuilder coffeeBuilder;

    public CoffeeDirector(CoffeeBuilder coffeeBuilder) {
        this.coffeeBuilder = coffeeBuilder;
    }

    public Coffee makeAmericano() {
        return coffeeBuilder
                .type("아메리카노")
                .size("TALL")
                .makeCoffee();
    }

    public Coffee makeVanillaLatte() {
        return coffeeBuilder.type("라떼")
                .size("GRANDE")
                .milk(true)
                .syrup("바닐라")
                .bean("아라비카")
                .temperature("HOT")
                .makeCoffee();
    }
}

 

디렉터는 커피를 만드는 CoffeeBuilder를 가지고 있고 생성자를 통해서 주입받습니다. 인터페이스 타입이기 때문에 BasicCoffeeBuilder가 아니더라도 추가적인 기능이 들어간 UpgradeCoffeeBuilder와 같은 클래스를 만들어도 주입이 가능합니다. 인터페이스 타입을 사용하면 이렇게 느슨한 결합(Loose Coupling)을 만들어 줄 수 있습니다.

 

디렉터 내부에서는 아주 미리 만들어놓을 수 있는 것들을 메서드 형태로 만들고 내부에서 빌더패턴을 이용해서 인스턴스를 리턴하도록 만들어 놓았습니다. 그럼 클라이언트에서 다시 사용해 볼게요.

 

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

public class Client2 {
    public static void main(String[] args) {
        CoffeeDirector coffeeDirector = new CoffeeDirector(new BasicCoffeeBuilder());
        Coffee coffee = coffeeDirector.makeAmericano();
        System.out.println(coffee);

        coffee = coffeeDirector.makeVanillaLatte();
        System.out.println(coffee);
    }
}

 

CoffeeDirector를 통해서 인스턴스를 만들 때, 생성자 parameter로 BasicCoffeeBuilder를 넣어주었습니다. 그리고 이를 통해서 makeAmericano() 또는 makeVanillaLatee() 메서드를 통해서 Coffee 인스턴스를 만들어 줄 수 있습니다.

 


빌더 패턴의 장점

빌더 패턴은 다음과 같은 장점이 있습니다.

  • 객체 생성의 유연성: 객체의 속성을 선택적으로 설정할 수 있으며, 필요한 속성만을 설정하여 객체를 생성할 수 있습니다.
  • 코드 가독성 향상: 빌더 패턴을 사용하면 객체 생성 코드의 가독성이 향상됩니다. 읽기 좋은 코드를 만드는 방법이기도 합니다.
  • 불변 객체 생성: 빌더 패턴을 사용하면 불변 객체를 쉽게 생성할 수 있습니다.

스프링부트와 빌더 패턴

스프링부트 프로젝트에서 빌더 패턴을 활용하여 복잡한 객체를 유연하게 생성할 수 있습니다. 예제를 통해 스프링 부트에서 빌더 패턴을 적용하는 방법을 알아볼게요.

 

@RestController("/creational/builder")
public class CoffeeController {


    @PostMapping("/orders")
    public Coffee orderCoffee(@RequestBody CoffeeRequest request) {
        CoffeeBuilder coffeeBuilder = new BasicCoffeeBuilder();

        return coffeeBuilder
                .type(request.type())
                .size(request.size())
                .milk(request.milk())
                .sugar(request.sugar())
                .makeCoffee();
    }
}

 

Request로 받은 데이터를 Builder에 넣어서 Coffee 인스턴스를 만들고 return 하는 API입니다. 이번에는 빌더와 디렉터와 같은 클래스들은 모두 @Bean으로 등록하고 디렉터를 이용해서 아메리카노를 만들어 보겠습니다. 먼저 CoffeeConfig 클래스를 만들어서 빈으로 등록해 볼게요.

 

package com.koonsland.designpatterns.creational.builder.api;

import com.koonsland.designpatterns.creational.builder.after.BasicCoffeeBuilder;
import com.koonsland.designpatterns.creational.builder.after.CoffeeBuilder;
import com.koonsland.designpatterns.creational.builder.after.CoffeeDirector;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class CoffeeConfig {

    @Bean
    public CoffeeBuilder coffeeBuilder() {
        return new BasicCoffeeBuilder();
    }

    @Bean
    public CoffeeDirector coffeeDirector() {
        return new CoffeeDirector(coffeeBuilder());
    }
}

 

이렇게 빈으로 등록한 클래스를 이용해서 컨트롤러에서 주입받아서 사용해 볼게요.

 

package com.koonsland.designpatterns.creational.builder.api;

import com.koonsland.designpatterns.creational.builder.after.BasicCoffeeBuilder;
import com.koonsland.designpatterns.creational.builder.after.Coffee;
import com.koonsland.designpatterns.creational.builder.after.CoffeeBuilder;
import com.koonsland.designpatterns.creational.builder.after.CoffeeDirector;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/creational/builder")
public class CoffeeController {

    private final CoffeeDirector coffeeDirector;

    public CoffeeController(CoffeeDirector coffeeDirector) {
        this.coffeeDirector = coffeeDirector;
    }

    @PostMapping("/orders")
    public Coffee orderCoffee(@RequestBody CoffeeRequest request) {
        CoffeeBuilder coffeeBuilder = new BasicCoffeeBuilder();

        return coffeeBuilder
                .type(request.type())
                .size(request.size())
                .milk(request.milk())
                .sugar(request.sugar())
                .makeCoffee();
    }

    @PostMapping("/orders/americano")
    public Coffee orderAmericano() {
        return coffeeDirector.makeAmericano();
    }
}

 

CoffeeDirector클래스는 주입받고 /orders/americano API에서는 디렉터를 바로 사용하되 아메리카노 기본메서드를 사용해서 Coffee 인스턴스를 리턴해줍니다.


결론

빌더 패턴은 복잡한 객체를 유연하게 생성할 수 있는 유용한 디자인 패턴입니다. 이를 통해 객체 생성 코드의 가독성을 높이고, 다양한 옵션을 가진 객체를 쉽게 생성할 수 있습니다. 스프링 부트와 함께 사용하면, 복잡한 객체 생성 로직을 더운 유연하게 관리할 수 있습니다. 이번 글에서는 커피 주문 시스템 예제를 통해 빌더 패턴의 개념과 구현 방법을 알아보았습니다. 읽어주셔서 감사드립니다.

댓글