소프트웨어 공학

[Design Go] 단일체 패턴(동시성 프로그래밍을 중심으로)

들어가기에 앞서...

드디어 마지막 생성 패턴이다. 예제는 지난 글과 동일하다. 이번 글은 동시성 프로그래밍의 내용을 포함하고 있기 때문에 해당 내용에 관해 알고 있으면 좋다.

패턴

단일체 패턴은 객체(Singleton)를 단 하나만 생성하도록 하는 패턴이다. 프로그램 내에서 Singleton 객체는 오직 하나이며 프로그램 실행 중 처음 접근할 때만 초기화가 이루어진다. 프로그램 어느 곳에서든지 Singleton 객체에 접근 가능하며 어느 곳에서 접근해도 같은 객체를 가리킨다. 즉, 전역 변수와 같은 기능을 한다.

장점

  • 전역 변수의 상위 호환이다. 전역변수를 쓸 때에는 여러가지 부작용을 감수해야 한다. 단일체 패턴은 이런 전역변수의 부작용을 최소화한다. 이에 관해서는 아래의 장점들에 자세히 풀어놓았다.
  • 해당 클래스의 객체가 단 하나임을 보장받는다. 단일체 패턴을 이용하지 않을 경우 하나여야 할 객체가 의도치 않게 여러 개가 될 수 있다. 이 경우 각자 접근한 객체가 같은 객체임을 보장받지 못한다.
  • 늦은 초기화가 가능하다. 전역변수는 컴파일 시간에 값이 초기화된다. 그러나 단일체 패턴의 경우 객체에 처음 접근할 경우 값이 초기화된다. 즉, 런타임 시간에 값이 초기화된다. 이는 쓸데없는 메모리 낭비를 줄이는 효과가 있다.
  • 설계를 바꾸기 쉽다. 단일체 패턴을 해제할 때에는 생성자와 접근 함수만 조금 손보면 된다.

단점

  • 객체지향을 해칠 수 있다. 결국 전역변수의 역할을 하기 때문이다. 객체지향을 준수하기 위해서는 보통 인터페이스를 중심으로 설계해야 하지만 단일체 패턴은 구현체를 중심으로 설계하게 된다.
  • TDD를 적용하기가 어렵다. 단위 테스트를 할 때에는 우선 테스트할 객체를 만들어야 하는데 단일체는 하나의 객체만 생성하기 때문에 여러 경우를 테스트하는 데에 어려움이 있다.

구현

우선 기본적인 구현이다.

package main

import "fmt"

var singleMaze Maze

func GetInstance() Maze {
	if singleMaze == nil {
		initialize()
	}
	return singleMaze
}

func initialize() {
	fmt.Println("Initialize maze")
	singleMaze = NewMaze()
}

우선 singleMaze 전역 변수를 만든다. 이 때 첫 글자를 소문자로 하여 해당 패키지 내부에서만 접근하게끔 한다. 그 후 singleMaze 객체로의 접근 함수인 GetInstance() 함수와 singleMaze를 생성하는 initialize()함수를 넣는다. 이 때 생성 함수 역시 내부에서만 접근하도록 첫 글자를 소문자로 작성한다.

보기에는 문제가 없을 수 있다. 하지만 Go언어에서 사용하기에는 큰 문제가 있다. 바로 thread-safe하지 않다는 것이다. 동시성 프로그래밍에 적합하지 않다고도 하는데, 이해를 돕기 위해 두 함수가 동시에 위의 객체에 접근하는 상황을 예로 들어보겠다. 하나의 함수가 먼저 singleMaze를 nil로 판별한다. singleMaze == nil이기 때문에 Initialize() 함수를 호출한다. 해당 함수가 Initialize()함수를 호출하는 중에 다른 함수가 객체에 접근한다. 이전 함수가 initialize()함수를 완료하지 않아서 singleMaze == nil로 판별했다. 해당 함수 역시 Initialize()함수를 호출한다. 그 후에 각각의 함수는 initialize()함수를 호출했다. 이렇게 되면 initialize()는 두 번 호출된 것이다. 즉, 한 번 생성되어야 할 객체가 두 번 생성된 것이다.

이번에는 sync 패키지를 이용하여 thread-safe하게 바꾼 방식이다. sync 패키지는 동시성 프로그래밍에 있어서 필수 패키지이기도 하다.

package main

import (
	"fmt"
	"sync"
)

var once sync.Once
var singleMazeEx Maze

func GetInstanceEx() Maze {
	once.Do(func() {
		initializeEx()
	})
	return singleMaze
}

func initializeEx() {
	fmt.Println("Initialize Maze")
	singleMazeEx = NewMaze()
}

전역 변수로 sync.Once 타입의 once를 추가했다. 그리고 once.Do()를 이용해 초기화 작업을 진행한다. 이 때 once.Do()는 프로그램이 작동되는 동안 단 한번만 실행함을 보장한다. 즉, 동시에 다른 함수에서 접근을 하더라도 한 번 실행한 이후에는 더이상 실행하지 않는다.

이제 호출하며 두 방식을 비교해보자.

//...

func Create(maze Maze) Maze {

	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
    
}

func main() {

	var maze Maze
	var mazeEx Maze

	test := func(createFunc func() Maze, maze *Maze){
		var wg sync.WaitGroup
		for i := 0; i < 10; i ++ {
			wg.Add(1)
			go func() {
				*maze = createFunc()
				wg.Done()
			}()
		}
		wg.Wait()
	}

	test(GetInstance, &maze)
	Create(maze)
	play(maze)

	test(GetInstanceEx, &mazeEx)
	Create(mazeEx)
	play(mazeEx)

}

Go언어에 익숙하지 않다면 이해하기 힘들 것이다. 쉽게 설명하자면 각각의 단일체에 10개의 함수를 동시에 접근시킨 후 미로를 설정한 것이다. 실행 결과는 다음과 같다.

Initialize maze
Initialize maze
Initialize maze
Initialize maze
Initialize maze
Initialize maze
Initialize maze
Initialize maze
Initialize maze
Initialize maze
Entered to room 1
door is Open. Successfully entered.
Initialize Maze
Entered to room 1
door is Open. Successfully entered.

위의 Initialize maze가 여러개인 것을 확인할 수 있다. 이 때 Initialize maze의 개수는 실행할 때마다 달라질 수 있다. 동시성 프로그래밍에서는 실행할 때마다 각 명령의 순서가 바뀔 수 있기 때문이다.

반면 아래의 Initialize maze는 thread-safe한 방식으로 구현된 단일체이기 때문에 항상 하나이다.

마치며...

어쩌다보니 단일체 패턴에서 동시성 프로그래밍 부분을 많이 넣게 되었다. 사실 단일체 패턴은 개념적으로나 구현적으로나 쉬운 데다가 무의식적으로라도 이미 사용해 봤을 법한 패턴이다. 때문에 Go언어인 만큼 동시성 프로그래밍 환경에서의 구현을 중심으로 설명하게 되었다.

단일체 패턴은 생성 패턴들 중에서도 이질적이다. 용도가 뚜렷하면서 다른 생성 패턴들과 독립적이다. 그와 동시에 타 디자인 패턴들과 다르게 객체지향에 맞지 않는 특성을 가지고 있다. 다만 단일체 패턴 역시 하나의 디자인 패턴으로 될 만큼 유용한 패턴이고, 잘만 활용하면 역시 좋은 코드를 짜는 데에 도움이 된다는 것을 알았으면 좋겠다.

참고 자료