Builder Pattern을 알아보자!

oto·2023년 4월 18일
0

Builder Pattern 왜 쓸까?

복잡한 인스턴스를 만드는 경우, 많은 속성들을 초기화하는 과정이 필요합니다. 이 과정은 대개 매개변수가 많은 거대한 생성자에 내장되거나, 클라이언트 코드에 흩어져 있게 됩니다. 그렇다고 모든 매개 변수를 제어하는 subclass들을 계속해서 만든다면, 계층 구조를 확장시키는 문제가 발생할 수 있습니다. 아니면, 모든 매개 변수를 제어하는 기본 클래스에 거대한 생성자를 만드는 방법이 있습니다. swift의 경우에 단일상속이라 보통은 이 방법을 사용하겠습니다. 하지만, 이 방법 역시 문제가 발생합니다. 사용하지 않는 대부분의 매개변수는 생성자 호출에서 상당히 보기 복잡해진다. 이러한 해결책으로 빌더 패턴을 사용하여 복잡한 객체를 만들 수 있습니다. 이를 통해 생성자 호출이 보기 좋아지고, 복잡한 객체를 만들기 위한 코드를 클라이언트 코드로부터 분리시킬 수 있습니다.

빌더 패턴은 객체 구성 코드를 빌더 객체로 이동시켜 일련의 단계로 구성하는 패턴입니다. 이 패턴은 모든 단계를 호출할 필요 없이 필요한 단계만 호출할 수 있습니다. 또한, 다른 구현이 필요한 경우 다른 빌더 클래스를 작성하여 여러 종류의 개체를 생성할 수 있습니다.

Builder Pattern 어디에 쓸까?

제 친구 gpt에게 builder pattern이 어디에 쓰이냐고 물어보니, 생각보다 많은 곳에서 다양한 경우에 사용하고 있었습니다.

  1. UI 구성 요소 생성
    Button이나 Label, Text 등의 UI 구성 요소는 다양한 속성을 가집니다. 이러한 속성들은 모두 복잡한 생성자의 인수로 전달될 수 있습니다. 빌더 패턴을 사용하면 특정 속성에 대한 빌더 클래스를 작성하고 이를 조합하여 UI 구성 요소를 만들 수 있습니다.

  2. 객체 생성 과정이 복잡한 객체 생성
    복잡한 객체의 경우 생성자의 매개변수 수가 많아질 수 있습니다. 이러한 경우, 많은 수의 선택적 인수를 갖는 생성자보다는 빌더 패턴을 사용하는 것이 더 나을 수 있습니다.

  3. 객체 구성을 구성 요소로 나눈 경우
    복잡한 객체를 만들 때 구성 요소를 조합해야하는 경우 Builder 패턴이 유용합니다. 예를 들어, 게임에서 캐릭터를 만들 때 머리, 몸, 다리 등으로 구성하는 경우 이러한 부분을 빌더 패턴을 사용하여 캐릭터 개체를 만들 수 있습니다.

  4. 다양한 종류의 객체 생성
    다양한 종류의 객체를 생성하는 경우, Builder 패턴은 각 객체 유형의 빌더 클래스를 만들어 구현할 수 있습니다. 이를 통해 구성 단계 집합을 구현하고 다양한 종류의 객체를 생성할 수 있습니다. 예를 들어, 쉐이크를 만들 때 사용되는 재료가 다양할 수 있습니다. 이러한 재료에 따라 빌더 클래스를 구현하고 구성 단계를 구성하여 다양한 종류의 쉐이크를 만들 수 있습니다.

Director

Builder Pattern에서 Director는 Builder 객체를 사용하여 복잡한 객체를 생성하는 역할을 합니다. Director는 객체의 생성 및 조립 프로세스를 관리하며, 빌더를 통해 단계적으로 객체를 구성합니다. 즉, Builder 객체의 메소드를 순서대로 호출하여 객체를 생성합니다.

Director는 일반적으로 구체적인 빌더 클래스의 인스턴스를 받아서, 그 빌더의 메소드를 이용하여 객체를 생성하고 반환합니다. 이렇게 하면 클라이언트는 복잡한 객체를 생성하는 과정을 알 필요 없이, Director가 제공하는 간단한 인터페이스만 사용하여 객체를 생성할 수 있습니다.

director는 클라이언트와 빌더 객체 사이에 인터페이스 역할을 수행하여 빌더 객체가 제공하는 메소드들을 일관적으로 사용할 수 있도록 해줍니다. 이를 통해 개발자는 다양한 빌더 객체를 구현할 수 있지만, 사용 방법은 모두 동일하게 유지됩니다. 이는 코드의 가독성과 유지보수성을 높여줍니다.

프로그램에서 디렉터 클래스를 가지고 있을 필요는 없습니다. 클라이언트 코드에서 직접 특정한 순서로 빌딩 단계를 호출할 수 있습니다. 하지만 디렉터 클래스는 프로그램 전체에서 재사용할 수 있는 다양한 구축 루틴을 위치시키기에 좋을 수 있습니다.

또한 디렉터 클래스는 제품 구축의 세부 정보를 클라이언트 코드로부터 완전히 숨겨줍니다. 클라이언트는 빌더를 디렉터와 연결하고, 디렉터로 구축을 시작하고, 빌더로부터 결과를 가져오기만 하면 됩니다.

Builder Pattern 구현해보기

저는 라멘을 만들어봤습니다.

protocol RamenBuilder {
    func select(soup: Soup) -> RamenBuilder
    func select(amount: Amount) -> RamenBuilder
    func add(topping: Topping) -> RamenBuilder
    func reset() -> RamenBuilder
    func getRamen() -> Ramen
}

먼저 RamenBulder의 protocol을 작성해줍니다.

enum Soup: String {
    case pork = "돈골육수"
    case chiken = "닭육수"
}

enum Amount: String {
    case normal = "일반"
    case double = "곱빼기"
}

enum Topping: String {
    case chashu = "차슈"
    case chiken = "닭고기"
    case ajitama = "삶은 달걀"
    case nori = "김"
    case negi = "파"
    case eggYolk = "노른자"
}
struct Ramen {
    var soup: Soup = .pork
    var amount: Amount = .normal
    var toppings: [Topping] = []
    
    func description() -> String {
        
        let soup = soup.rawValue
        let amount = amount.rawValue
        let toppings = toppings.map { $0.rawValue }.joined(separator: ", ")
        
        return "\(soup) 베이스 국물에 \(toppings)가 들어간 \(amount) 라멘입니다."
    }
}

Ramen Model을 구현해줍니다.

class BasicRamenBuilder: RamenBuilder {
    
    private var ramen: Ramen = Ramen()
    
    func select(soup: Soup) -> RamenBuilder {
        self.ramen.soup = soup
        return self
    }
    
    func select(amount: Amount) -> RamenBuilder {
        self.ramen.amount = amount
        return self
    }
    
    func add(topping: Topping) -> RamenBuilder {
        self.ramen.toppings.append(topping)
        return self
    }
    
    func reset() -> RamenBuilder {
        self.ramen = Ramen()
        return self
    }
    
    func getRamen() -> Ramen {
        return ramen
    }
}

지금은 RamenBuilder 프로토콜을 따르는 BasicRamenBuilder를 작성해봤습니다. RamenBuilder를 프로토콜을 준수하는 돈코츠라멘이나 쇼유라멘을 ConcreateBuilder로 만들 수도 있습니다. 저는 따로 ConcreateBuilder를 만들지 않고, 아래에서 Director를 작성할 때 다른 라면을 추가해보겠습니다.

var customRamen = BasicRamenBuilder()
let ramen = customRamen
    .select(soup: .pork)
    .select(amount: .normal)
    .add(topping: .chashu)
    .add(topping: .nori)
    .add(topping: .negi)
    .getRamen()

ramen.description()
// 돈골육수 베이스 국물에 차슈, 김, 파가 들어간 일반 라멘입니다.

빌더는 이렇게 사용하면 됩니다.

그럼 이제 director를 작성해서 활용해보겠습니다.

class ramenDirector {
    private var builder: RamenBuilder
    
    init(builder: RamenBuilder) {
        self.builder = builder
    }
    
    func constructCustomRamen() -> Ramen {
        return self.builder.getRamen()
    }
    
    func constrcutTonkotsuRamen() -> Ramen {
        builder
        	.reset()
            .select(amount: .normal)
            .select(soup: .pork)
            .add(topping: .chashu)
            .add(topping: .nori)
            .add(topping: .negi)
        return self.builder.getRamen()
    }
    
    func constrcutShoyuRamen() -> Ramen {
        builder
        	.reset()
            .select(amount: .normal)
            .select(soup: .chiken)
            .add(topping: .chiken)
            .add(topping: .nori)
            .add(topping: .negi)
        return self.builder.getRamen()
    }
}

director를 클라이언트에서 사용할 때, builder에서 어떤 과정들이 거쳐지는지 알수 없도록 캡슐화한다는 장점이 있습니다.

var builder = BasicRamenBuilder()
var ramenDirector = RamenDirector(builder: builder)
ramenDirector.constrcutShoyuRamen().description()
ramenDirector.constrcutTonkotsuRamen().description()

클라이언트 파트에서 사용하며, 빌더와 디렉터를 연결해준다고 생각하면 됩니다ㅏ.

결론

코드가 더 길어지는 단점이 있습니다. 하지만 속성 값이 많은 인스턴스를 생성할 때, 생성자에 주렁주렁 달려있는 인자들을 생각한다면 굉장히 깔끔한 해결책이라고 생각합니다. 또 ConcreateBuilder를 활용해서 인스턴스 생성 파트와 속성 설정 파트를 따로 구현하기때문에 유지보수에 용이합니다.

Builder Pattern에서 디렉터는 꼭 사용하지 않아도 무방합니다. 하지만, Builder의 종류가 다양하고 인스턴스의 생성과정(속성 설정 과정)이 같을 때 클라이언트 부분에서 코드를 수정할 필요없이 Builder를 추가해서 다양하게 사용할 수 있는 장점이 있습니다. 이렇게되면 클라이언트 부분에서 내부가 어떻게 돌아가는지 상관없이 사용할 수 있습니다.

profile
iOS Developer

0개의 댓글