본문 바로가기

소프트웨어 공학

[Design Go] 빌더 패턴

들어가기에 앞서...

지난 글에서 언급했듯 해당 시리즈의 이번 글부터는 각 디자인 패턴에 대해 설명하면서 Go언어로 만들어보도록 하겠다.

책 'GoF의 디자인 패턴'에서는 생성 패턴들을 설명할 때 아래의 미로를 만드는 예제를 사용했다. 이 시리즈도 해당 예제를 이용할 예정이다.

package main

import "fmt"

type Direction int
const (
    East Direction = iota
    West
    South
    North
)

type MapSite interface {
    Enter()
}

type Room interface {
    MapSite
    GetSide(d Direction) MapSite
    SetSide(d Direction, s MapSite)
    GetNo() int
}

type room struct {
    idx int
    sides [4]MapSite
}

func NewRoom(idx int) *room {
    r := new(room)
    r.idx = idx
    for i := range r.sides {
        r.sides[i] = NewWall()
    }
    return r
}

func (r *room) Enter() {
    fmt.Printf("Entered to room %d\n", r.idx)
}

func (r *room) GetSide(d Direction) MapSite {
    return r.sides[d]
}

func (r *room) SetSide(d Direction, s MapSite) {
    r.sides[d] = s
}

func (r *room) GetNo() int {
    return r.idx
}

type Wall interface {
    MapSite
}

type wall struct {
}

func NewWall() *wall {
    return new(wall)
}

func (w *wall) Enter() {
    fmt.Println("Nothing Happened")
}

type Door interface {
    MapSite
    OtherSideFrom(r Room) Room
}

type door struct {
    r1, r2 Room
    isOpen bool
}

func NewDoor(r1, r2 Room) *door {
    d := new(door)
    d.r1 = r1
    d.r2 = r2
    d.isOpen = true
    return d
}

func (d *door) Enter() {
    if d.isOpen {
        fmt.Println("door is Open. Successfully entered.")
    }
}

func (d *door) OtherSideFrom(r Room) Room {
    if r == d.r1 {
        return d.r2
    }
    return d.r1
}

type Maze interface {
    AddRoom(r Room)
    RoomNo(idx int) Room
}

type maze struct {
    rooms map[int] Room
}

func NewMaze() *maze {
    m := new(maze)
    m.rooms = make(map[int]Room, 0)
    return m
}

func (m *maze) AddRoom(r Room) {
    m.rooms[r.GetNo()] = r
}

func (m *maze) RoomNo(idx int) Room {
    return m.rooms[idx]
}

func CreateMaze() *maze {
    maze := NewMaze()
    r1 := NewRoom(1)
    r2 := NewRoom(2)
    d := NewDoor(r1, r2)

    maze.AddRoom(r1)
    maze.AddRoom(r2)

    r1.SetSide(East, d)
    r2.SetSide(West, d)
    return maze
}

type roomWithBomb struct {
    room
}

func NewRoomWithBomb(idx int) *roomWithBomb {
    r := new(roomWithBomb)
    r.idx = idx
    return r
}

func (e roomWithBomb) Enter() {
    e.room.Enter()
    fmt.Println("Bomb!")
}

type bombedDoor struct {
    door
    isBombed bool
}

func NewBombedDoor(r1, r2 Room) *bombedDoor {
    d := NewDoor(r1, r2)
    bombed := new(bombedDoor)
    bombed.door = *d
    bombed.isBombed = true
    return bombed
}

func (d *bombedDoor) Enter() {
    if d.isBombed {
        fmt.Println("Door was bombed. Successfully entered.")
    } else {
        d.door.Enter()
    }
}

func play(m Maze) {
    var p MapSite
    r1 := m.RoomNo(1)
    p = r1
    p.Enter()
    d := r1.GetSide(East)
    p = d
    p.Enter()
}

func main() {
    simpleM := CreateMaze()
    play(simpleM)
}

 

Entered to Room 1
Door is Open. Successfully entered.

한번 쭉 훑어보자. 위의 코드는 디자인 패턴을 적용하기 전의 코드로 가독성은 그럴듯 하지만 코드의 생성 및 변경이 어렵다. 즉, 유지보수하기 힘든 코드이다.

빌더 패턴

빌더 패턴은 객체(builder)에게 거대한 객체(director) 내 각 요소의 생성을 맡기는 디자인 패턴이다. builder 객체는 director 객체의 요소를 생성하는 메소드와 요소가 전부 생성된 director 객체를 반환하는 메소드를 가지고 있다. builder 객체는 director 객체의 요소를 생성하는 메소드를 모두 호출한 후 마지막으로 director객체를 반환하는 메소드를 호출해 사용자가 원하는 director 객체를 생성한다.

장점

  • 객체의 생성과 구현을 분리한다. 객체의 요소를 생성할 때 값을 직접 넣는 경우도 있지만 값을 가공해서 넣는 경우도 있다. 빌더 패턴은 이 가공 과정을 별도의 클래스로 분리해 놓기 때문에 객체를 생성할 때에는 객체의 생성 자체에만 집중할 수 있다.
  • 코드의 가독성이 좋아진다. 요소를 생성할 때에는 그저 해당 생성 메소드만 호출하면 된다.
  • 코드의 유연성이 높아진다. 요소를 추가/제거할 때에는 그에 맞춰 builder의 메소드를 추가/제거하면 된다. 요소의 생성 방법을 바꾸고 싶을 때 해당 요소의 builder의 메소드만 바꾸면 된다. 게다가 builder 자체를 매개변수화 시켜서 builder 객체에 따라 director 객체를 완전히 바꿀 수도 있다.

단점

  • 객체의 규모가 작을수록 빌더 패턴의 효용이 없어진다. 빌더 패턴은 큰 규모의 객체의 생성에 적합하다. 작은 규모의 객체라면 생성자에 때려박거나 객체 자체에 함수를 하나정도 만드는 것이 더 효율적이다. 물론 이러한 경우에도 유연성 자체는 빌더 패턴이 낫다.

구현

빌더 패턴을 사용하기 위해 빌더 패턴의 인터페이스를 정의하였다.

package main

type MazeBuilder interface {
    //요소 생성 메소드
    BuildMaze()
    BuildRoom(idx int)
    BuildDoor(from, to int)
    //반환 메소드
    GetMaze() Maze
}

해당 인터페이스는 추후에 나올 builder 클래스 둘이 이용할 예정이다. 해당 인터페이스의 요소 생성 메소드는 반환값이 없는데, 이는 해당 메소드를 호출하면 해당 메소드 내에서 요소의 생성을 직접 처리하기 때문이다. 디자인 패턴을 쓰지 않은 예제와 비교해보자.

이번에는 기본적인 미로를 생성하는 builder 클래스이다.

package main

type simpleMazeBuilder struct {
    maze Maze
}

func NewSimpleMazeBuilder() *simpleMazeBuilder{
    b := new(simpleMazeBuilder)
    b.maze = nil
    return b
}

func (b *simpleMazeBuilder) GetMaze() Maze{
    return b.maze
}

func (b *simpleMazeBuilder) CommonWall(from, to *Room) Direction {
    if from.idx == (to.idx-1) {
        return East
    } else if from.idx == (to.idx+1) {
        return West
    } else if from.idx > to.idx {
        return South
    } else {
        return North
    }
}

func (b *simpleMazeBuilder) BuildMaze() {
    b.maze = NewMaze()
}

func (b *simpleMazeBuilder) BuildRoom(idx int) {
    if b.maze.RoomNo(idx) == nil {
        r := NewRoom(idx)
        b.maze.AddRoom(r)
    }
}

func (b *simpleMazeBuilder) BuildDoor(r1, r2 int) {
    room1 := b.maze.RoomNo(r1)
    room2 := b.maze.RoomNo(r2)
    d := NewDoor(room1, room2)
    room1.SetSide(b.CommonWall(room1, room2), d)
    room2.SetSide(b.CommonWall(room2, room1), d)
}

참고로 CommonWall 메소드는 기존 예제에서 구현을 특정하지 않아 임의로 작성했다. 이 builder 클래스의 장점은 buildDoor에서 잘 나타나 있다. 기존의 방식은 문을 생성할 때 기존에 생성한 방을 불러오는 과정, 방과 문을 잇는 과정, 방의 인접부를 연결하는 과정 등을 직접 구현해야 했다. 이러한 방식은 문을 추가할 때마다 이 위의 작업을 반복해야 하기 때문에 코드가 길어질 뿐만 아니라 한 과정이라도 빠지면 오류가 날 위험이 있다. 그러나 builder 객체를 이용하면 buildDoor 메소드만으로 문이 생성되기 때문에 이러한 문제로부터 자유롭다.

다음은 방과 문의 갯수만 세는 미로를 생성하는 builder 클래스이다.

package main

type countingMazeBuilder struct {
    rooms, doors int
}

func NewCountingMazeBuilder() *countingMazeBuilder{
    b := new(countingMazeBuilder)
    b.rooms, b.doors = 0, 0
    return b
}

func (b *countingMazeBuilder) BuildMaze() {
    b.rooms = 0
    b.doors = 0
}

func (b *countingMazeBuilder) BuildRoom(idx int) {
    b.rooms ++
}

func (b *countingMazeBuilder) BuildDoor(from, to int) {
    b.doors ++
}

func (b *countingMazeBuilder) GetMaze() Maze {
    return nil
}

func (b *countingMazeBuilder) GetCounts() (int, int) {
    return b.rooms, b.doors
}

해당 빌더는 Maze를 nil로 반환한다. 대신 추가된 GetCounts 메소드로 방과 문의 개수를 출력한다. 위에 정의한 MazeBuilder 인터페이스를 지키고 있기 때문에 역시 MazeBuilder로 쓸 수 있다.

마지막은 다른 방식의 builder 클래스 구현법이다. 해당 클래스는 기교가 많이 섞여 있기 때문에 한번에 이해하긴 어려울 수 있다.

package main

type buildAction func()

type modernMazeBuilder struct {
    maze Maze
    actions []buildAction
}

func NewModernMazeBuilder() *modernMazeBuilder {
    b := new(modernMazeBuilder)
    b.maze = nil
    b.actions = make([]buildAction, 0)
    return b
}

func (b *modernMazeBuilder) InitMaze() *modernMazeBuilder{
    b.maze = NewMaze()
    return b
}

func (b *modernMazeBuilder) appendAction(action buildAction) {
    b.actions = append(b.actions, action)
}

func (b *modernMazeBuilder) AddRoom(idx int) *modernMazeBuilder {
    b.appendAction(func() {
        b.maze.AddRoom(NewRoom(idx))
    })
    return b
}

func (b *modernMazeBuilder) AddDoor(from, to int) *modernMazeBuilder {
    b.appendAction(func() {
        r1 := b.maze.RoomNo(from)
        r2 := b.maze.RoomNo(to)
        d := NewDoor(r1, r2)
        r1.SetSide(East, d)
        r2.SetSide(West, d)
    })
    return b
}

func (b *modernMazeBuilder) build() {
    for _, action := range b.actions {
        action()
    }
}

func (b *modernMazeBuilder) GetMaze() Maze {
    b.build()
    return b.maze
}

modernMazeBuilder 클래스의 멤버 변수 actions는 함수 객체의 배열이다. 이는 Go언어가 함수를 객체로 취급하기 때문에 가능한 기술이다. 각 요소의 build 메소드는 요소의 생성을 직접 처리하지 않고 actions에 해당 요소의 생성을 처리하는 함수를 집어넣는다. 이 메소드가 반환하는 builder는 곧 보겠지만 각 요소의 build 메소드를 줄줄이 호출하기 위해서 집어넣었다. builder에서 GetMaze 메소드를 호출하면 actions에 있는 일련의 함수들을 호출하는 build 메소드를 호출한 후 미로를 반환한다.

이제 호출해보자.

//...

func CreateMazeByBuilder(b MazeBuilder) {
    b.BuildMaze()
    b.BuildRoom(1)
    b.BuildRoom(2)
    b.BuildDoor(1,2)
}

func main() {
    simpleM := CreateMaze()
    play(simpleM)

    simpleBuilder := NewSimpleMazeBuilder()
    CreateMazeByBuilder(simpleBuilder)
    play(simpleBuilder.GetMaze())

    countingBuilder := NewCountingMazeBuilder()
    CreateMazeByBuilder(countingBuilder)
    rooms, doors := countingBuilder.GetCounts()
    fmt.Printf("There are %d rooms and %d doors.\n", rooms, doors)

    modernBuilder := NewModernMazeBuilder()
    modernBuilder.InitMaze().AddRoom(1).AddRoom(2).AddDoor(1, 2)
    play(modernBuilder.GetMaze())
}

 

Entered to Room 1
Door is Open. Successfully entered.
Entered to Room 1
Door is Open. Successfully entered.
There are 2 rooms and 1 doors.
Entered to Room 1
Door is Open. Successfully entered.

성공적으로 호출되는 것을 알 수 있다.

마치며...

빌더 패턴은 위에서 언급했다시피 대규모 객체의 생성에 유용하다. 다른 생성패턴들과는 다르게 빌더 패턴은 큰 단점이 없고 활용성에도 딱히 제한이 없다. 이러한 이유로 빌더 패턴은 언제나 무난하게 쓸 수 있는, 단골 맛집같은 디자인 패턴이 아닌가 싶다.

참고 자료

'소프트웨어 공학' 카테고리의 다른 글

[Design Go] 원형 패턴  (0) 2021.01.07
[Design Go] 팩토리 메소드 패턴  (0) 2020.12.24
[Design Go] 추상 팩토리 패턴  (2) 2020.12.17
[Design Go] 빌더 패턴  (0) 2020.12.10
[Design Go] 좋은 코드와 디자인 패턴  (0) 2020.12.03
[Go with TDD] TDD란?  (0) 2020.09.11