본문 바로가기
Design Pattern/생성 패턴(Creational Patterns)

빌더(Builder) - 생성 패턴(Creational Patterns)

by 김 민 준 2024. 5. 14.

빌더 패턴은 개체의 생성 과정을 단계별로 분할하고, 그 과정을 캡슐화하여 복잡한 개체를 조립할 수 있게 도와주는 패턴이다. 이 패턴은 특히 복잡한 구성을 가진 개체를 생성할 때 유용하다. 동일한 생성 과정을 통해 다양한 표현과 구성의 개체를 만들 수 있다. 

 

이 패턴의 구성을 먼저 보자 

 

Builder : 개체의 여러 부분을 생헝하는 방법을 정의한 인터페이스이다. 여러단계에 걸쳐 복잡한 개체의 각 부분을 어떻게 생성할지 대한 세부사항을 포함한다.

 

Concrete Builder : Builder 인터페이스를 구현하며, 구체적인 개체의 부품을 조립하는 방법과 최종 개체를 반환하는 메서드를 포함한다. 

 

Director : Builder 인터페이스를 사용해 개체를 단계뼐로 생한다. Director는 어떤 순서로 빌딩 과정이 진행될지 결정하지만 구체적인 개체의 구성은 Concrete Builder가 담당한다. 

 

Product : 최종 생성될 개체이다. 복잡한 개체가 Builder를 통해 단계적으로 생성된다. 

 

 

 

빌더를 예시로 집을 만드는것으로 이해해보자.

 

 

집을 만든다고 할때 기초공사를 하고 벽을 세우고 지붕을 올리고 단계별로 진행할텐데 이 과정에서 각 단계별로 어떤 재료를 이용해서 진행할지 어떤 순서로 진행할지에 대한 결정이 필요할것이다.

 

 

Builder는 집을 짓는 방법을 정의하는것이다. 예를 들어서 기초공사를 하고 벽을 세우고 지붕을 올리는식으로 말이다. 

Concrete Builder는 목재로 할지 벽돌로 할지 등 특정한 유형의 집을 지을 수 있는 방법들을 정의하는것이다.

Director는 건축 계획에 따라 빌더에게 집을 단계별로 짓도록 지시하는것이다.

Product는 완성된 집을 의미한다. 

 

빌더 패턴을 사용하면, 복잡한 생성 과정을 각각의 단계로 나누어 관리할 수 있다. 

동일한 과정을 사용하여 다양한형태의 집을 지을 수 있기에 개체의 생성 과정이 복잡하거나 개체의 여러 구성이 다양할때 유용하다. 

 

예제 소스코드를 보자 

 

// Product: 최종적으로 생성될 객체인 집
class House {
    private String foundation; // 기초
    private String structure; // 구조
    private String roof; // 지붕
    private String interior; // 내부

    public void setFoundation(String foundation) {
        this.foundation = foundation;
    }

    public void setStructure(String structure) {
        this.structure = structure;
    }

    public void setRoof(String roof) {
        this.roof = roof;
    }

    public void setInterior(String interior) {
        this.interior = interior;
    }

    @Override
    public String toString() {
        return "집 건설 완료: 기초 = " + foundation + ", 구조 = " + structure +
               ", 지붕 = " + roof + ", 내부 = " + interior;
    }
}

// Builder 인터페이스
interface HouseBuilder {
    void buildFoundation();
    void buildStructure();
    void buildRoof();
    void buildInterior();
    House getHouse();
}

// ConcreteBuilder: 실제 집을 건설하는 구체적인 방법
class ConcreteHouseBuilder implements HouseBuilder {
    private House house;

    public ConcreteHouseBuilder() {
        this.house = new House();
    }

    @Override
    public void buildFoundation() {
        house.setFoundation("콘크리트, 철근");
    }

    @Override
    public void buildStructure() {
        house.setStructure("콘크리트, 블록");
    }

    @Override
    public void buildRoof() {
        house.setRoof("콘크리트");
    }

    @Override
    public void buildInterior() {
        house.setInterior("페인트, 타일");
    }

    @Override
    public House getHouse() {
        return this.house;
    }
}

// Director: 건설 과정을 지휘
class ConstructionEngineer {
    private HouseBuilder houseBuilder;

    public ConstructionEngineer(HouseBuilder houseBuilder) {
        this.houseBuilder = houseBuilder;
    }

    public House constructHouse() {
        houseBuilder.buildFoundation();
        houseBuilder.buildStructure();
        houseBuilder.buildRoof();
        houseBuilder.buildInterior();
        return houseBuilder.getHouse();
    }
}

// 클라이언트 코드
public class BuilderPatternDemo {
    public static void main(String[] args) {
        HouseBuilder builder = new ConcreteHouseBuilder();
        ConstructionEngineer engineer = new ConstructionEngineer(builder);
        House house = engineer.constructHouse();
        System.out.println(house);
    }
}

 

1. House클래스는 만들 집의 구성요소를 가진다.

2. HouseBuilder 인터페이스는 집을 건설할 때 필요한 메서드들을 선언한다.

3. ConcreteHouseBuilder 클래스는 HouseBuilder의 메서드들을 구현하여 실제 집을 어떻게 건설할지 정의한다. 

4. ConstructionEngineer클래스는 HouseBuilder를 사용하여 집을 단계별로 건설한다.

클라이언트 코드에서는 빌더와 디렉터를 생성하고 디렉터를 통해 집을 건설한다. 

 

 

그럼 실무에서는 어떻게 주로 사용하는지 소스코드를 살펴보자.

 

회원 정보를 제공하는 API를 만든다고 생각해보자.

이름, 이메일, 프로필 사진, 핸드폰번호에 대한 정보를 가지고 있는데 어떤 API에서는 이름과 이메일만 필요하고 어떤 API에서는 이름과 프로필 사진만 필요하다고 생각해보자. 이때 가장 쉬운 방법은 하나의 API에 그냥 모든 정보를 담아서 계속 주고받는것이 있다. 하지만 이렇게 하다보면 개인정보가 노출될 수 있지 않겠는가? 혹은 지금의 예시는 작은 데이터이지만 더 많은 데이터를 주고 받는다고 생각하면 불필요한 데이터를 주고받는것에서 낭비가 발생할 수 있다. 

 

아래 소스코드는 빌더패턴을 활용해서 이름, 이메일, 프로필사진만 제공하는 API소스의 예시이다. 

 

// Product: 최종적으로 생성될 사용자 프로필 객체
class UserProfile {
    private String name;
    private String email;
    private String profilePictureUrl;
    private String phoneNumber;

    // Setters
    public void setName(String name) {
        this.name = name;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public void setProfilePictureUrl(String profilePictureUrl) {
        this.profilePictureUrl = profilePictureUrl;
    }

    public void setPhoneNumber(String phoneNumber) {
        this.phoneNumber = phoneNumber;
    }

    @Override
    public String toString() {
        return "UserProfile{" +
               "name='" + name + '\'' +
               ", email='" + email + '\'' +
               ", profilePictureUrl='" + profilePictureUrl + '\'' +
               ", phoneNumber='" + phoneNumber + '\'' +
               '}';
    }
}

// Builder Interface
interface UserProfileBuilder {
    UserProfileBuilder setName(String name);
    UserProfileBuilder setEmail(String email);
    UserProfileBuilder setProfilePicture(String url);
    UserProfileBuilder setPhoneNumber(String phone);
    UserProfile build();
}

// ConcreteBuilder: 실제 사용자 프로필을 구축하는 구체적인 방법
class ConcreteUserProfileBuilder implements UserProfileBuilder {
    private UserProfile userProfile;

    public ConcreteUserProfileBuilder() {
        this.userProfile = new UserProfile();
    }

    @Override
    public UserProfileBuilder setName(String name) {
        userProfile.setName(name);
        return this;
    }

    @Override
    public UserProfileBuilder setEmail(String email) {
        userProfile.setEmail(email);
        return this;
    }

    @Override
    public UserProfileBuilder setProfilePicture(String url) {
        userProfile.setProfilePictureUrl(url);
        return this;
    }

    @Override
    public UserProfileBuilder setPhoneNumber(String phone) {
        userProfile.setPhoneNumber(phone);
        return this;
    }

    @Override
    public UserProfile build() {
        return userProfile;
    }
}

// 클라이언트 코드
public class BuilderPatternDemo {
    public static void main(String[] args) {
        UserProfileBuilder builder = new ConcreteUserProfileBuilder();
        UserProfile userProfile = builder.setName("홍길동")
                                        .setEmail("hong@example.com")
                                        .setProfilePicture("http://example.com/picture.jpg")
                                        .build();
        System.out.println(userProfile);
    }
}

 

그렇다면 방금 작성한 소스코드에서 프로필이미지는 사용하지 않으니 안받아도 되는데 이번에 구현해야 하는것은 핸드폰번호까지도 받고싶다라고 하면 어떻게 수정을 하면 될까?

 

그냥 .setPhoneNumber("010-1234-1234")를 넣으면 되는것이다. 

 

예시이지만 만약 REST API 방식으로 해당 소스를 수정하면 아래와 같은 소스코드로 수정될 수 있다. 

 

 

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserProfileController {

    @GetMapping("/profile")
    public UserProfile getUserProfile() {
        UserProfileBuilder builder = new ConcreteUserProfileBuilder();
        UserProfile userProfile = builder.setName("홍길동")
                                          .setEmail("hong@example.com")
                                          .setPhoneNumber("010-1234-1234")
                                          .build();
        return userProfile;
    }
}

 

이렇게 작성된 서버코드를 프론트엔드개발자는 아래와 같은 형태로 data를 받을 수 있다.

 

{
    "name": "홍길동",
    "email": "hong@example.com",
    "phoneNumber": "010-1234-1234"
}

 

 

동일한 소스코드를 Nodejs 의 Express 로 구현 한다고 하면 아래 소스처럼 구현 하면 된다.

 

const express = require('express');
const app = express();
const PORT = 3000;

// UserProfile 빌더 클래스 정의
class UserProfile {
    constructor() {
        this.name = '';
        this.email = '';
        this.phoneNumber = '';
    }

    setName(name) {
        this.name = name;
        return this;
    }

    setEmail(email) {
        this.email = email;
        return this;
    }

    setPhoneNumber(phoneNumber) {
        this.phoneNumber = phoneNumber;
        return this;
    }

    build() {
        return {
            name: this.name,
            email: this.email,
            phoneNumber: this.phoneNumber
        };
    }
}

// GET 요청에 대한 라우트 정의
app.get('/profile', (req, res) => {
    const userProfile = new UserProfile()
        .setName("홍길동")
        .setEmail("hong@example.com")
        .setPhoneNumber("010-1234-1234")
        .build();

    res.json(userProfile);
});

// 서버 시작
app.listen(PORT, () => {
    console.log(`서버 실행중 http://localhost:${PORT}`);
});