저자: 한동훈(traxacun@unitel.co.kr)
본 기사는 디자인 패턴에 대한 이야기라기 보다는 Singleton 패턴에 대한 이야기가 될 것이다. 많은 프로그래머들이 문제를 해결하면서 나오게 된 일정한 형태를 ‘패턴’이라고 하며, 이러한 형태를 일반화시킨 것을 ‘디자인 패턴’이라고 한다. 디자인 패턴은 프로그래밍과 개발을 보다 쉽게 할 수 있도록 도와주며 보다 견고한 응용 프로그램을 작성할 수 있게 해줄 뿐만 아니라, 프로그래머들간에 구조에 대해서 이야기할 수 있는 실질적인 어휘를 제공했다는 점에서 높게 평가된다. 그러나 국내에서 수학 정석처럼 공식화, 정형화, 획일화하는 것에 대해서는 개인적으로 달가워하지 않는다. 디자인 패턴은 패턴일 뿐이고, 여러분이 코드를 작성할 때 반드시 그대로 적용되어야 하는 것이 아니기 때문이다. 필자는 디자인 패턴이 리팩토링을 통해서 자연스럽게 도출될 수 있는 것이 패턴이길 바란다.(어디까지나 무지한 본인의 얘기!)
Singleton 패턴
굳이 패턴이라고 이름 붙이기에는 어색할지도 모르겠지만 지금까지 많은 개발자들이 무의식 중에 써왔거나 써오고 있는 것이 바로 Singleton이다. Design Patterns의 정의를 따르자면 ‘클래스가 하나의 인스턴스만 생성할 수 있게 하며, 코드의 어디서나 동일한 인스턴스에 액세스하는 것을 보장한다’라고 할 수 있겠다(이도 어디까지나 필자가 잘못 해석하고, 잘못 이해하지 않았다는 전제 하에서…)
먼저 Singleton 패턴을 구현한 Singleton 클래스부터 살펴보자.
sealed class Singleton
{
private static Singleton s = new Singleton();
private Singleton() { Console.WriteLine("A constructor is called"); }
public static Singleton Create()
{
Console.WriteLine("Create() is called");
return s;
}
}
먼저 클래스 정의는 sealed로 되어 있다. C#에서 sealed의 의미는 봉인된(sealed) 클래스라는 의미이며, 이는 클래스 상속을 금지시킨다. 이와 같이 선언하는 이유는 클래스를 상속하여 상속된 클래스의 인스턴스 생성을 금지시키기 위한 것이다. 즉, 하나의 클래스가 하나의 인스턴스를 갖도록 보장하기 위한 것이다. C#에서 모든 클래스는 System.Object 클래스, 즉 Object 클래스를 상속하기 때문에 sealed로 선언된 Singleton 클래스도 Object 클래스의 메소드를 물려받는다. 이중에 클래스의 복사본을 반환할 수 있는 MemberwiseClone() 메소드는 protected이므로 상속받은 클래스에서 오버라이드(override)할 수 있으며 또 다른 문제는 ICloneable 인터페이스를 상속하여 Clone() 메소드를 상속받은 클래스에서 구현할 수 있다는 것이다. 그러나 sealed로 선언한 클래스는 상속 자체가 금지되기 때문에 이러한 문제에서 자유로울 수 있다. 물론, Singleton.MemberwiseClone()을 사용하기를 원할 수도 있지만 컴파일러에서 컴파일조차 되지 않기 때문에 이런 문제에서도 자유롭다.
생성자 private Singleton은 뼈대만 만들어 두면 되지만 여기서는 내부 프로세스를 보여주기 위해 콘솔에 메시지를 출력하고 있다. New 키워드를 사용해서 인스턴스를 직접 생성하는 것을 금지하기 위해 생성자를 private으로 선언한 것을 눈여겨 보기 바란다. Create() 메소드를 사용하여 Singletone 클래스의 인스턴스를 반환한다. 뭔가 특별한 것을 기대했을지도 모르겠다. 예를 들면 어떻게 하나의 인스턴스가 있는지 확인하는 루틴 같은 것 말이다. static 키워드는 메모리에 반드시 하나만 존재할 수 있게 해주기 때문에 그러한 검사는 필요하지 않다. 또한 클래스 내의 모든 멤버들이 static으로 되어 있기 때문에 코드가 처음 실행될 때 Singleton 클래스의 인스턴스가 생성되지 않는다. 클래스 내의 멤버들에 대한 접근이 이뤄질 때 인스턴스가 생성된다. 프로그램을 실행할 때 프로그램내의 모든 클래스의 인스턴스가 생성된다고 생각해보자. 프로그램은 지독하게 느려질 것이고, 비효율적일 것이다. 이와 같이 처음에 초기화를 하지 않고, 나중에 초기화 되는 것을 게으른 초기화(lazy initialization)라 한다.
Singleton 클래스를 사용하는 완전한 예제는 다음과 같다.
using System;
sealed class Singleton
{
private static Singleton s = new Singleton();
private Singleton() { Console.WriteLine("A constructor is called"); }
public static Singleton Create()
{
Console.WriteLine("Create() is called");
return s;
}
}
class SingletonApp
{
public static void Main()
{
Singleton s1 = Singleton.Create();
Singleton s2 = Singleton.Create();
// can’t use because of sealed class
// Singleton s3 = (Singleton) s2.MemberwiseClone();
Console.WriteLine(Object.ReferenceEquals(s1, s2));
}
}
결과는 다음과 같다.
A constructor is called
Create() is called
Create() is called
True
몇 개의 Create()를 호출하더라도 항상 같은 인스턴스를 갖게 되며, 결과적으로 하나의 인스턴스만 생성할 수 있다. 그러나 위와 같은 구현은 멀티 스레드 환경에서는 불안하다. 멀티 스레드 환경에서도 안전하도록 Singleton 클래스를 바꿔보자. 그리고 C# 언어에 맞도록 적절하게 리팩토링을 해보자.
sealed class Singleton
{
private Singleton() { Console.WriteLine("a instance is created"); }
public static readonly Singleton Instance = new Singleton();
}
새롭게 바꾼 Singleton 클래스는 위와 같다. Singleton() 생성자에는 어떤 코드도 작성하지 않지만 여기서는 어떤식으로 동작하는지 알 수 있게 하기 위해 Console.WriteLine을 사용했다. 어쩌면 위 코드는 조금 당황스러울 수도 있다. 그러나 앞서 작성한 것과 동일하게 동작하는 완전한 Singleton 클래스이며 마찬가지로 멀티 스레드 환경에서도 안전하다.(thread-safety)
먼저 static으로 선언되기 때문에 반드시 하나만 생성되는 것을 보장한다. 또한 게으른 초기화를 위해 사용한 Create() 메소드를 제거했다. 대신에 속성 Instance를 추가하고, 선언에서 초기화하도록 하였다. 닷넷 프레임워크는 어떤 메소드가 static 속성을 처음 사용할 때 클래스를 초기화한다. 마찬가지로 클래스를 sealed로 선언하여 서브 클래싱(sub-classing)을 할 수 없게 하였다. 또한 스레드간의 읽기와 쓰기 경쟁으로 인해 Instance 변수가 null인 상태로 알고 있는 스레드가 인스턴스를 생성하는 것을 막기 위해 readonly를 사용했다.
대부분의 경우에 Singleton 클래스는 클래스 자체로 이용하는 것이 가장 좋다고 생각한다. Singleton 클래스를 상속하고, 복잡한 코드를 추가하여 자식 클래스를 구현하는 것은 좋지 않다고 생각한다. 일반적으로 이러한 작업은 복잡도를 증가시키고, 개발과 테스트를 어렵게 할 뿐이다. 특히 멀티 스레드 환경에서는 더욱 위험하다.
두 번째 Singleton 클래스를 사용한 예제는 다음과 같으며 Singleton 생성자의 Console.WriteLine()을 제거했다는 것에 주의한다.
using System;
using System.Diagnostics;
sealed class Singleton
{
private Singleton() { }
public static readonly Singleton Instance = new Singleton();
}
class SingletonApp
{
public static void Main()
{
Singleton s1 = Singleton.Instance;
Singleton s2 = Singleton.Instance;
Console.WriteLine(Object.ReferenceEquals(s1, s2));
}
}
s1과 s2의 비교결과는 True라는 것을 알 수 있을 것이다. 즉, 하나의 인스턴스만 생성된다.
이제, 이러한 Singleton 클래스를 조금 응용한 것을 만들어보자. 하나의 카운터를 유지하는 SingletonCounter 클래스를 만들어보자.
sealed class SingletonCounter
{
private SingletonCounter() {}
public static readonly SingletonCounter Instance = new SingletonCounter();
private static int count = 0;
public int NextValue()
{
return ++count;
}
}
클래스 코드는 위와 같다. 처음의 Singleton에서 카운터를 유지하기 위해 count 멤버 변수를 추가하고, 값을 증가시키는 NextValue() 메소드를 추가했다. 또한 count를 int 형으로 사용하고 있다. Int 형은 32비트를 사용하기 때문에 32 비트 아키텍처에서 최소 단위 오퍼레이션(atomic operation)을 허용한다. 즉, 인스턴스 수준이거나 클래스 수준(static)이냐에 상관없이 항상 원자성(atomic)을 보장한다. 32 비트 아키텍처에서 64 비트 데이터 형식인 long등을 사용할 수 있지만 static에 대해서만 스레드 안전성을 보장할 뿐이고 인스턴스 수준에서는 스레드 안정성을 보장하지 않는다. Long과 같은 64비트 데이터 형식을 사용할 경우, 하나의 스레드가 하위 32 비트를 조작한 다음에 블록 당할 수 있고, 이러한 불안정한 값을 다른 스레드가 읽어들이는 경우도 생길 수 있다. 따라서 두 스레드가 64 비트 데이터 형식을 업데이트하는 경우에는 결과값을 예측할 수 없게 된다.(하위 32 비트가 먼저 채워지는 것은 Intel, DEC, Alpha 프로세서와 같이 리틀 엔디안(little-endian)을 사용하는 아키텍처의 경우이며, IBM 370, RISC, Motorola 프로세서와 같은 빅엔디안(big-endian)을 사용하는 아키텍처의 경우에는 상위 32비트만 채워진체 반환될 수 있다.) 또한, 32 비트 아키텍처에서 64 비트 데이터 값을 대입하는 것은 원자 연산(atomic operation)이 아니다. 이와 같은 이유로 SingletonCounter 클래스의 내부 변수는 모두 int 형식을 사용하였다.(즉, native int 형의 크기까지만 atomic operation이 보장되며, native int보다 큰 형들은 atomic operation이 보장되지 않는다.)
이제 SingletonCounter 클래스를 사용한 완전한 예제를 살펴보자.
using System;
sealed class SingletonCounter
{
private SingletonCounter() {}
public static readonly SingletonCounter Instance = new SingletonCounter();
private static int Count = 0;
public int NextValue()
{
return ++Count;
}
}
class SingletonApp
{
public static void Main()
{
for (int loopctr = 0; loopctr < 5; loopctr++)
{
Console.WriteLine(SingletonCounter.Instance.NextValue());
}
}
}
실행 결과에서 알 수 있는 것처럼 매우 잘 실행된다는 것을 알 수 있다. 여기서 유일한 단점은 클래스에서 카운터의 값을 감소시킬 수 없다는 것 뿐이다. 전체 응용 프로그램에서 유일한 카운터를 유지하는 경우에도 사용할 수 있으며 일정한 개수의 인스턴스를 생성할 수 있게 구현하여 객체 풀을 생성할 수도 있다. 이러한 객체 풀링에는 ADO.NET등에서 사용하는 연결 풀링(connection pooling)도 있으며 프린터 스풀러와 같은 프린터 스풀(printer spool)도 있다.
지금까지 소개한 Singleton 패턴들은 모두 닷넷 프레임워크와 C#에 대한 이해를 기초로 한 것이다. 즉, 플랫폼이 가지는 이점과 언어가 제공하는 이점을 모두 활용한 것이며, Design Patterns에 소개되고 있는 전형적인 UML 클래스 다이어그램과는 사뭇 그 모습이 다르다. 이와 같은 차이가 생기는 근본적인 이유는 C++에서는 많은 동작방식들이 유연성을 이유로 정의되지 않은채 남아있기 때문이다(이에 대한 언급은 하지 않겠다). 게다가 Design Patterns의 저자들부터 eXtreme Programming의 Kent Beck에 이르기까지 많은 사람들이 Smalltalk folks였다는 것에 주목해야 한다. 즉, 디자인 패턴은 Smalltalk에서 C++이나 자바로 옮겨왔고, 다시 자바라는 언어에서 C#으로 옮겨오고 있기 때문에 실제로 C# 언어에서 필요하지 않은 부가적인 것들이 달려있는 경우가 많다는 것이다. 지금까지 소개한 코드는 이러한 것들을 모두 던져버리고 닷넷 플랫폼과 C#에 맞게 구현한 것이다.
이제, Double-Checked Locking(이하 DCL)에 대해서 생각해보자. 실제로 DCL의 문제는 멀티 스레드 환경에서 Singleton 패턴의 목적, 유일하게 하나의 인스턴스만 있을 수 있도록 하자는 것이다. 멀티 스레드 환경에 안전한 Singleton 패턴을 구현하는 방법은 세 가지가 있다(자세한 내용은 뒤에 소개하겠다). 그 중에 모든 코드를 동기화하는 경우에는 부하가 크기 때문에 이미 인스턴스가 생성된 경우에는 동기화 블록에 들어가지 않도록 하는 방법이 있으며, 이를 위해 두 번의 검사를 하기 때문에 일반적으로 이를 DCL이라한다.
그러나 필자는 단순히 DCL을 구현한 코드나 목적만을 보는 것은 적합하지 않다고 생각한다. 실제로 여기서 고민해야 할 것은 디자인 패턴도, 싱글톤도, 메모리 모델도, 바이트 오더링(byte ordering)도 아니다. Static 인스턴스 변수를 Singleton 패턴을 구현하는 것에서 진정으로 발생하는 문제는 이 변수를 읽어들이려는 스레드와 이 변수에 값을 쓰려는 스레드간에 경쟁이 발생한다는 것이고, 이 경쟁을 어떻게 풀어낼 것인가 하는 것이 진짜 문제가 된다.
이해가 안될 테니, 예제부터 살펴보자.
using System;
class Singleton
{
private static Singleton instance = null;
private Singleton()
{
Console.WriteLine("a constructor is called");
}
public static Singleton Instance()
{
if ( instance == null )
{
instance = new Singleton();
}
return instance;
}
}
class WrongSingletonApp
{
public static void Main()
{
Singleton s1 = Singleton.Instance();
Singleton s2 = Singleton.Instance();
Console.WriteLine(Object.ReferenceEquals(s1, s2));
}
}
위 예제는 C++, Java 등에서 소개되는 전형적인 Singleton 구조를 C#으로 옮긴 것이다. 실행해보면 이 예제는 잘 동작하며, 앞에서 소개한 것과 같아 보일 것이다. 그러나 멀티 스레드 환경에서는 제대로 동작하지 않는다. (앞서 소개한 Singleton과 SingletonCounter 클래스는 멀티 스레드 환경에서 잘 동작한다.) 왜 제대로 동작하지 않는지 살펴보자.
public static Singleton Instance()
{
if ( instance == null ) // 1
{
instance = new Singleton(); // 2
}
return instance;
}
두 스레드가 있을 때 두 가지 시나리오가 가능하다. 첫번째는 A 스레드가 1번 위치까지 실행된 다음에 블록되고 B 스레드가 1번과 2번까지 모두 실행하여 인스턴스를 얻은 다음에 A 스레드에게 제어권이 돌아가면 instance 변수가 null 이라고 알고 있으므로 새로운 인스턴스를 추가로 생성하게 된다. 두 번째는 두 스레드가 각각 사이 좋게 1번까지 번갈아 실행한 경우를 말하는 것으로 두 스레드 모두 instance 변수가 null이라고 알고 있으므로 2번까지 실행하게 되고, 결과적으로 두 개의 인스턴스가 생성된다. 즉, instance 변수의 값을 읽는 스레드와 instance 변수의 값을 쓰는 스레드간의 경쟁이 원인이 되는 것이다.
그리고 이러한 멀티 스레드 환경에서 하나의 인스턴스를 생성하는 것을 보장하는 방법은 크게 세가지가 있다. 이 방법들을 정리하면 다음과 같다.
- static 필드를 사용하는 방법
- 모든 코드를 동기화하는 방법
- 스레드 로컬 스토리지(Thread Local Storage, TLS)를 이용한 방법
앞에서 필자가 소개한 Singleton은 멀티 스레드 환경에서 하나의 인스턴스를 생성하는 것을 보장하기 위해 static 필드를 사용하는 방법을 사용했다. 모든 코드를 동기화하는 방법은 lock()을 사용하여 코드 블록을 동기화하고, [MethodImpl(MethodImplOptions.Synchronized)]를 사용하여 메소드 수준에서 동기화한다. 즉, 모든 코드에서 동기화를 수행하기 때문에 부하가 많다. TLS를 이용한 구현은 Alexander Terekhov가 제안한 방법이지만, 상당히 낮은 성능을 보여주기 때문에 거의 사용되지 않는다.(그러나 매우 독창적인 코드이며, 충분히 감동을 줄만한 코드다)
Instance() 메소드를 멀티 스레드 환경에서도 안전하게 동작할 수 있도록 다음과 같이 바꿔보자.
[MethodImpl(MethodImplOptions.Synchronized)]
public static Singleton Instance()
{
if ( instance == null )
{
instance = new Singleton();
}
return instance;
}
메소드 전체에 대해서 동기화를 하고 있기 때문에, 멀티 스레드 환경에서도 잘 동작할 것이다. 그러나 메소드 전체를 동기화하기 때문에 부하가 높다. 이를 줄이기 위한 다양한 변형이 있지만 여기서는 생략할 것이다. (이러한 변형들은 모두 C++, Java에서만 의미가 있을 뿐이며 완전한 C# 버전은 본 기사의 처음에 소개했다.)
C++나 Java에서 위와 같은 방법은 부하가 크기 때문에 Singleton 클래스의 인스턴스가 이미 생성되었는지 검사하여 동기화 블록에 들어가지 않게하는 방법이 소개되었다. 이 방법을 Double-Checked Locking, DCL이라한다.
새롭게 작성한 Instance() 메소드는 다음과 같다.
public static Singleton Instance()
{
if ( instance == null )
{
lock(typeof(Singleton) )
{
if ( instance == null )
{
instance = new Singleton();
}
}
}
return instance;
}
이 코드는 상당히 잘 동작하는 것 같지만, 멀티 스레드 환경에서 이미 동작하지 않는다는 것을 알 수 있다.(Java를 사용하고 있다면 lock(typeof(Singleton)) 대신에 synchronized(Class.forName(“Singleton”))으로 바꾸면 된다.) 이러한 동작은 메모리 모델, 최적화, 리오더링(reordering)과 같은 복잡한 문제로 동작하지 않는다. 이에 대한 자세한 내용은 관련 자료 링크를 참고하기 바란다. 또한, 자바와 관련하여 많은 내용들이 있지만 그러한 내용들이 C#과 닷넷 플랫폼에 그대로 적용된다고 생각하지 않기 바란다.
DCL을 해결하기 위한 노력이 있었고, DCL에 대한 다양한 변형들이 존재했지만 이것들은 모두 제대로 동작하지 않는다. 이에 대한 많은 글들이 있으며(자바에 관한 글이지만, 내용과 로직은 모든 언어에 적용된다), 그 중에 대표적인 것으로는 IBM의 수석 소프트웨어 엔지니어(Senior Software Engineer) Peter Haggar(
haggar@us.ibm.com)가 2002년 5월에 발표한
Double-Checked Locking and the Singleton Pattern이라는 글이 있다. 그는 이 글에서 자신이 2000년 2월에 출간한 『Practical Java Programming Language Guide』에서 소개한 DCL이 잘못되었으며, 왜 잘못되었는지를 알려주고 있다. (이 책은 국내에도 번역 출간되었으며 번역서의 제목은 『Practical Java)이다. 이 같은 오류는 Peter Haggar뿐만 아니라 Allen Holub의 『Taming Java Threads』에서도 발견된다 이 책은 자바 스레드에 대한 베스트 셀러였으며, 국내에는 『자바 쓰레드 능숙하게 다루기』로 번역 출간되어있다. Allen Holub 역시
JavaWorld에서 DCL에 대한 자신의 생각이 틀렸으며, 왜 사용해서는 안되는지, 그렇다면 올바른 방식은 무엇인지를 소개하고 있다. 여기에서 알 수 있듯이 현재 출간되어 있는 대부분의 자바 스레드 책들중에 DCL을 다룬 책들은 모두 잘못되었다는 것을 알 수 있다. Peter Haggar의 글은 필자의 홈페이지에
서민구씨가 번역한 글이 있다. 원문이 거북스러운 분들은 이 글을 참고하기 바란다.)
그러나 이러한 대부분의 논의는 자바에 대한 것으로 자바의 동기화에서 문제되고 있는 static 역시 닷넷에서는 문제가 되지 않고 있다.(Java Language Specification과 그 해석에 따른 각 벤더들의 JVM 구현으로 인해 static에 대한 구현이 일관성이 없다는 것이지만, 닷넷 플랫폼은 현재 MS에서만 제공하고 있으며, C# Language Specification에 그 동작이 정해져있다. 그러나 C# 역시 모호한 점이 있으며, 이에 대해서는 현재 계속해서 논란거리가 되고 있다.)
자바에서의 static 문제에 대해서는
When is a static not static?이란 글을 참고하기 바란다.
많은 자바 프로그래머들이 DCL을 해결하기 위해 내놓은 것중에 하나가 volatile을 사용하는 것이다. volatile 키워드로 선언된 변수의 의미는 운영 체제, 멀티 스레드 등에 의해 프로그램에서 수정될 수 있다는 것이다. 즉, 닷넷 런타임을 위해 사용되는 WorkingSet에 메모리 내용을 캐시한 캐시가 아닌 메모리에서 직접 그 값을 읽어오는 것을 보장한다. 조금은 아이러니하지만 자바에서 volatile은 사용할 수 없다. 이에 대한 내용은 Peter Haggar의 Double-checked Locking and the Singleton Pattern에 자세히 설명되어 있으며 여기에 잠시 인용하면 다음과 같은 문제가 있다.
JLS(Java Language Specification)을 참고할 때, 변수가 volatile로 선언되면 실행 순서가 일관적인 것으로 여겨지며, 재배치(reordering)이 일어나지 않는다. Peter Haggar은 두 가지 문제를 지적하고 있다. 첫번째는 순서 일관성의 문제가 아니라 최적화를 통해 코드가 옮겨지는 문제, 두번째는 많은 JVM이 volatile에 대한 순서 일관성조차 제대로 구현하고 있지 않다.라는 것이다. 자세한 것은 그의 글을 참고하기 바란다.
이러한 차이점 때문에 Java에서는 volatile을 사용한 방법이 DCL에 대한 해법이 될 수 없지만, C#에서는 volatile을 사용한 방법이 해법이 될 수 있다. 다음은 volatile를 사용해서 C#에서 DCL을 사용한 방법을 보여준다.
#define TRACE
using System;
using System.Threading;
using System.Diagnostics;
public sealed class MTSingleton
{
private static volatile MTSingleton instance = null;
private static object syncRoot = new Object();
private MTSingleton() { Console.WriteLine("Hello"); }
public static MTSingleton Instance
{
get
{
if ( instance == null )
{
lock(syncRoot)
{
if ( instance == null )
instance = new MTSingleton();
}
}
return instance;
}
} // Instance
}
class MTSingletonApp
{
public static void Main()
{
Trace.Listeners.Add(new TextWriterTraceListener(Console.Out));
Trace.AutoFlush = true;
Trace.Indent();
MTSingleton s1 = MTSingleton.Instance;
MTSingleton s2 = MTSingleton.Instance;
MTSingletonApp ap = new MTSingletonApp();
ap.DoTest();
Thread.Sleep(2000);
Console.WriteLine(Object.ReferenceEquals(s1, s2));
Trace.Unindent();
}
public void DoTest()
{
Thread t1 = new Thread(new ThreadStart(CreateMTSingleton));
Thread t2 = new Thread(new ThreadStart(CreateMTSingleton));b
t1.Start();
t2.Start();
Trace.WriteLine(t1.ToString());
Trace.WriteLine(t2.ToString());
}
public void CreateMTSingleton()
{
MTSingleton s1 = MTSingleton.Instance;
MTSingleton s2 = MTSingleton.Instance;
Trace.WriteLine(s2.ToString());
}
}
여기에서 다음을 눈여겨 봐야 한다.
private static volatile MTSingleton instance = null;
private static object syncRoot = new Object();
MTSingleton 클래스에 대한 인스턴스를 instance에 저장하고 있으며, 키워드는 volatile로 정의되어 있다. 스펙에 있는 대로 읽기와 쓰기가 동작하는 경우에 volatile read와 volatile write라고 한다. (The ECMA Common Language Infrastructure(CLI) spec, in Partition I, section 11.6.5와 11.6.7을 보면 volatile read는 ‘acquire semantics’를 의미하고, volatile write는 ‘release semantics’를 의미한다는 것을 알 수 있다.) 자바 역시 스펙에는 volatile의 동작이 정해져 있지만, 왜 동작하지 않는가는 Peter Haggar의 글을 참고하기 바란다. 또한 JLS Chapter 17에 정의된 volatile이 왜 제대로 동작하지 않으며, 새로운 volatile에 대한 정의가 어떻게 진행되는지에 대해서는
JSR 133 - Java Memory Model and Thread Specification Revision를 참고하기 바란다. 그러나 닷넷에서는 잘 동작한다. 두 번째로 선언된 object 변수 syncRoot는 동기화 블록을 얻기 위해 사용한 것이다.
public static MTSingleton Instance
{
get
{
if ( instance == null )
{
lock(syncRoot)
{
if ( instance == null )
instance = new MTSingleton();
}
}
return instance;
}
} // Instance
Instance는 메소드로 하지 않고, 간단히 읽기 전용 속성으로 구현하였다. 이 코드는 자바에서는 잘못된 DCL이지만 C#에서는 문제없이 동작한다(이 문제 역시 Peter Haggar와 다른 자바 커뮤니티의 글을 참고하기 바란다). 물론 이외에도 다양하게 동기화를 할 수 있다. Lock(syncRoot)와 같은 다른 객체를 두어 동기화를 시도하는 것은 자바에서 사용되던 방법이며, 마찬가지로 lock(typeof(MTSingleton))과 같이 사용하는 방법도 있고, MethodImpl을 사용하여 동기화하는 방법도 있다.
Instance를 두 번에 걸쳐서 확인하는 것은 두 스레드가 경쟁하는 경우에 잠금(lock)을 먼저 획득한 스레드가 코드를 배타적으로 수행할 수 있게하고, 대기하는 동안에 변경될지도 모르는 값을 lock() 내부에서 다시 확인하는 것이다. 이미 생성된 인스턴스가 있다면 첫번째 점검(check)에서 코드를 종료할 것이고 부하를 줄이게 된다.
실제로 위 코드는 필자에게 문의한 한 자바 매니아의 코드를 C#으로 살짝 바꿔놓은 것에 불과하다. 그리고 ‘왜 이 자바 코드의 DCL은 실패하는가?’에 대해서 설명할 것을 요구했다. 필자는 두 개의 static 변수를 사용했다. 이 코드는 다시 옮기면 다음과 같다.
private static volatile MTSingleton instance = null;
// 인스턴스화된 객체를 메모리에 확실히 쓰기 위한 동기화 블럭용 객체
private static object syncRoot = new Object();
그리고 다시 volatile을 사용했다. volatile의 의미는 앞에도 적었지만 다시 옮기자면, volatile로 선언된 변수는 메모리를 캐시하는 WorkingSet등을 통해서 값을 액세스하지 않고, 항상 메모리에 있는 값을 직접 액세스한다는 것이다. 즉, volatile로 선언된 변수에 대한 연산은 최소 단위 오퍼레이션(atomic operation)을 보장하지 않는다. 따라서 instance 변수에 대한 액세스는 모두 캐시되지 않으며, 메모리에 직접 액세스한다. 이 동작은 C# Language Specification에 명시되어 있으며, 닷넷 플랫폼에서 일관되게 수행된다. 반면에 자바에서는 JLS에 volatile이 정의되어 있으나, 각 벤더들이 구현한 JVM에서 이들은 일관되게 구현되지 않고 있다. 즉, 여러 스레드가 공유 자원에 대해 액세스할 때 액세스되는 순서에 대한 일관성을 보장하지 않고 있다. 따라서 자바에서는 DCL을 위해 volatile 키워드를 사용하는 것은 특정 JVM에서는 수행되지만 다른 JVM에서는 수행되지 않는 문제점이 있다. 즉, volatile을 사용한 DCL 구현은 사용할 수 없다.(이것으로 그의 질문에 대한 답이 되었으면 좋겠다.)
메모리 모델, 메모리 장벽(Memory Barrier)
ECMA에 제출된 CLI spec.을 살펴보면 메모리 모델에 대한 내용이 있고, 재배치(reordering)에 대한 내용이 있다.(Partition 1, section 11.2, 11.6) 멀티 프로세서 환경에서 메모리에 대한 액세스는 일반적으로 생각하는 것처럼 메모리에 직접 액세스하는 것이 아니라 MU(Memory Unit)을 통하게 된다. 하나의 프로세서가 하나의 MU를 통해서 메모리를 액세스하며, 두 개의 프로세스는 두 개의 MU를 통해서 메모리를 액세스한다고 하자.
하나의 프로세스에서 MU에 R, W, R, W, R, W와 같은 명령을 보낸다고 하고, 두 번째 프로세스는 MU에 R, R, W, W, R, W와 같은 명령을 보낸다면 인접한 명령들을 하나로 재배치할 수 있다. 즉, 두 프로세스의 실제 명령 순서는 달랐지만, 실제로는 R, R, R, W, W, W와 같은 형태로 재배치되어 전달될 것이다(프로세스와 MU간에 데이터를 주고 받는 방식은 FIFO 구조를 사용하지 않는다). 따라서 이러한 재배치를 막기 위해 메모리 장벽(Memory Barrier, MB)을 제공하고 있다. 즉, C#의 lock()이나 자바의 synchronized()등은 모두 MB 코드를 써 넣도록 되어 있다. 이러한 명령들을 순차적으로 나타낸다면 다음과 같다.
unlock() | mb | some code(instruction 1| instruction 2 | instruction 3…) | mb | lock()
우리가 사용하는 lock()은 메모리상에 위와 같이 쓰여질 수 있을 것이며, mb 사이에 있는 코드에 대한 재배치를 막을 것이다. 그러나 mb 내부에 있는 some code들의 순서가 바뀌는 것은 막을 수 없다. 이를 막기 위해 메소드와 속성들의 get 블록과 set 블록 내부도 모두 동기화를 구현해야 한다. 즉, 단 하나의 문장으로 이뤄진 코드일지라도 동기화 블록 안에 배치하여 mb를 사용하게 해야 한다는 것이다. C++에서는 인라인 어셈블리를 사용하여 직접 메모리 장벽에 해당하는 코드를 작성할 수 있으나, 그것은 플랫폼 독립이 아니다. 메모리 모델, 메모리 장벽, 메모리 재배치(reordering 또는 out-of-order wirtes)에 대한 자세한 내용은 각 언어의 명세서를 살펴보기 바란다. 여기서는 자세히 설명하지는 않을 것이다.
자바나 C# 모두 MB를 직접 작성할 수 있는 방법이 없다. C++에서는 mb와 wmb(Write Memory Barrier)를 직접 작성하는 것이 가능하다.(실제로 닷넷 프레임워크에서도 이러한 것을 지원하기 위해 System.Threading.Thread.WriteMemoryBarrier()와 MemoryBarrier()를 제공한다.) wmb는 쓰기 작업이 MB를 넘을 수 없다는 것을 보장한다. 두 번째 mb는 읽기와 쓰기 작업 모두 MB를 넘을 수 없다는 것을 보장한다. 따라서 두 명령어 모두 MB안에서 작업이 수행되도록 해준다. 이 두 메소드의 장점은 lock()이나 [MethodImpl(MethodImplOptions.Synchronized)]를 사용하여 전체를 메모리 장벽으로 감싸는 것에 비해 필요한 부분에 메모리 장벽을 두기 때문에 부하가 적고, 훨씬 효율적인 작업이 가능하다는 것이다. 그러나 이것은 C++에서 사용할 수 있는 방법에 불과하다.
CLI에서는 재배치를 할 수 없게 하기 위해 unaligned.를 사용할 수 있으며 이 OpCode는 Emit 클래스를 사용하여 직접 배치할 수 있다. 즉, volatile과 unaligned와 같은 IL 코드를 함께 배치하여 사용할 수 있으며, 원한다면 직접 제어할 수 있다.
DCL이 필요한가?
C#으로 멀티 스레드 환경의 Singleton을 구현하는 경우에는 DCL이 필요하지 않다. 처음부터 DCL이 필요없으며, 컴파일러의 최적화, JIT 문제, 메모리 장벽등을 전혀 고려하지 않았다. 닷넷 프레임워크에서 이 문제를 해결해주고 있기 때문에 여러분은 닷넷 프레임워크에 맡기면 된다. CLI 스펙에 따르면 32 비트 데이터 형식에 대해서는 최소 단위 오퍼레이션(atomic operation)을 보장한다. 또한, lock()과 같은 동기화 방법은 느리기 때문에 직접적으로 잠금(lock)을 획득하지 않아도 되는 atomic operation을 사용하는 것이 좋다. 또한 Interlocked 클래스는 잠금을 획득하지 않고, atomic operation을 보장한다는 것도 기억하기 바란다. 참고로 CLI의 메모리 모델은 상당히 간단하며 직관적으로 이해하기 쉽다는 것을 알 수 있다. 닷넷 프레임워크는 이러한 동작을 보장해주고 있다. 자바의 메모리 모델은 현재 많은 문제가 있기 때문에 새로운 메모리 모델과 새로운 volatile, 즉, volatile read와 volatile write에 대한 정의가 JSR에서 진행중에 있다. 현재의 CLI는 안전하게 동작하고 있으나 자바는 현재 이와 같은 면에서 뒤쳐지고 있으며 개선중에 있다(CLI의 단순한 메모리 모델은 ‘단순한 것이 가장 좋은 해결책이다’라는 얘기를, 자바의 느려터진 표준안 작업은 ‘민주주의는 독재보다 느리다’는 제임스 고슬링의 말을 떠올리게 한다.). 또한 닷넷에서 volatile로 선언된 경우를 제외한 경우에도 최소 단위 오퍼레이션(atomic operation)을 보장하기 위해 System.Threading.Thread.VolatileWrite()와 VolatileRead()를 제공하고 있으며, 이 메소드에는 64 비트 데이터 형식을 제외한 다른 모든 데이터 형을 인자로 사용할 수 있다.
닷넷은 처음부터 DCL이 필요하지 않으며 멀티 스레드 환경에서 간단하게 Singleton을 구현해 보았다. 그 이후의 모든 논의는 C++와 자바에서의 문제였으며, 이러한 로직이 그대로 C#으로 옮겨지는 것을 많은 글에서 보았다. 단순히 키워드를 바꾸는 것만으로 패턴이 옮겨지는 것이 아니다. 필요한 것은 단순히 키워드를 바꾸는 것으로 옮긴 다음에 플랫폼과 해당 언어의 모든 기능을 사용하기 위해 리팩토링하는 것이다. 패턴은 올바르게 구현될 때 의미를 가질 것이다(이것이 처음에 얘기한 패턴에 대한 개인적인 불만이다). 자바에서는 DCL이 올바르게 동작하지 않는다.
마치며
오랜만에 다시 들여다보는 스레드라서 그런지 조금은 횡설수설한 느낌이든다. 하지만, 가끔은 횡설수설한 글이 있는 것도 나쁘지 않은 것 같다. ^^; 아래에도 참고 자료를 많이 첨부해 두었다. 특히 MS의 Shared Source CLI로 제공되는 Rotor의 코드에 관심을 가져도 좋을 것 같다. 이밖에 Mono도 좋은 자료가 될 것이다. 현재 Mono는 완전하게 돌아가고 있으며 빠르게 개선되고 있다. Mono Mailing List는 매우 활발하며, Mono의 코드를 이해하는 것도 닷넷 플랫폼과 CLI, C# Language Specification을 이해하는 데 도움이 될 것이다.
Singleton 패턴이라 하지만, 반드시 정형화된 형태를 암기할 필요도 없다. 여러분의 필요에 따라 구현해 나가면 된다. 일정 개수의 인스턴스만 유지할 수 있게 해주는 Singleton은 다르게 Pooling 패턴이라고도 한다.(실제로 구현은 많이 틀려지지만) 위 예제들은 모두 Singleton 패턴을 구현한 것이지만 그 구현은 모두 제각각이다. 실제로 10여개 이상의 Singleton 패턴 구현을 보았다. 필요에 따라 적절히 바꿔가며 쓰면 될 것이다. 그 개념을 충실히 이해하고, 응용해서 쓰기 바란다.
DCL에 대한 문제로 열심히 자바 커뮤니티에서 쌈박질을 하는 ‘나는 조폭이었다!’님에게 감사드린다. 많은 참고가 되었으며 문제 제기를 하지 않았다면 결코 DCL이나 Singleton 패턴에 대한 글을 쓰지 않았을 것이다. 또한 자바에서의 문제와 메모리 장벽에 대한 설명을 매우 훌륭하게 했기 때문에 여기서는 그러한 내용을 일일이 다루지 않았다. 게다가 JVM과 CLI는 다르다.
참고자료
- Double-Checked Locking and the Singleton Pattern, Peter Haggar(Senior Software Engineer, IBM)
- Double-Checked Locking and the Singleton Pattern 번역문서, 서민구 역
- Pratical Java Programming Language Guide
- Practical Java
- Taming Java Threads
- 자바 쓰레드 능숙하게 다루기
- Microsoft Shared Source CLI: CLI 구현에 대한 완전한 소스 코드를 제공하고 있으며, 윈도우 XP와 FreeBSD에서 컴파일 할 수 있다.
- Mono: 리눅스에서 CLI와 C# Compiler를 구현하고 있는 프로젝트이다.
- Mono 한글: 노우경씨가 운영하고 있는 모노에 대한 정보를 제공하고 있으며, Mono 홈페이지의 내용들을 번역하여 제공하고 있다. 뿐만 아니라 메일링 리크트도 운영하고 있다.
- DotGNU Portable.NET: Mono와 마찬가지로 리눅스 환경에서 CLI와 C# Compiler를 구현하고 있는 프로젝트이나 Mono 만큼 빠르지 않으며, 아직은 미흡한 단계에 있다. 현재 0.4.2 버전까지 진행되어 있다.
- C# vs Java: C#과 자바에 대한 비교로, 문법간의 차이점 뿐만 아니라 동기화에 사용되는 방법들간의 차이를 알 수 있다. 또한, C#의 sealed와 readonly과 자바의 final의 의미 차이 등을 모두 알 수 있을 것이다.
- Memory Model: Software Level에서의 Memory Model에 대해서는 JVM과 CLI를 설명하고 있으며, Hardware Level에서는 IA32와 IA64 아키텍처에 대해서 설명하고 있다. 필자가 설명하지 않았던 Memory Model과 컴파일러 최적화에 대한 내용과 자세한 소스코드를 일목요연하게 볼 수 있다. 아직 JVM과 CLI의 Memory Model에 대해 모른다면 반드시 보기 바란다.
- Dynamic Loading with Extensions in Java: Class.forName()과 스레드에 대한 내용이 있다.
- When is a static not static?: static 으로 선언된 멤버가 JVM 내에서 유일(unique)하지 않은 경우는 어떨 때 발생하며, 어떤 문제를 발생시킬 수 있는지 설명하고 있다. 주로 EJB 서버와 서블릿 컨테이너에 대해서 설명하고 있다. 페이퍼와 소스 코드 모두 제공된다.
- DotNet Mailing List: ECMA CLI spec.과 C# Language Specification을 웹 상에서 볼 수 있으며, Rotor 메일링 리스트를 운영하고 있다.
- First Rotor Project Workshop: Rotor와 관련된 Workshop 자료이며 스레딩 모델, JIT, GC, 컴파일러 최적화 등에 대한 내용들이 있다.
- Standard ECMA ? 334, C# Language Specification: ECMA에 제출된 C# Language Specification이다.
- Standard ECMA ? 334, Common Language Infrastructure:ECMA에 제출된 CLI spec.이다.
- The “Double-Checked Locking is Broken”Declaration: David Bacon (IBM Research) Joshua Bloch (Javasoft), Jeff Bogda, Cliff Click (Hotspot JVM project), Paul Haahr, Doug Lea, Tom May, Jan-Willem Maessen, John D. Mitchell (jGuru) Kelvin Nilsen, Bill Pugh, Emin Gun Sirer들이 공통으로 이 문제에 대하여 설명한 글이다.
- Double-checked locking implementation: Thinking in Java, 2nd의 DCL 부분이며, DCL의 문제점을 설명하고 있다. Thinking in Java는 3rd까지 나왔으며 참고하기 바란다.
- JSR 133 ? Java Memory Model and Thread Specification Revision: JSR에서 진행중인 것으로 자바의 Memory Model과 JLS ch. 17의 volatile의 문제점과 그에 대한 개선책에 대한 것이다.
- GoF Structure Diagram considered harmful: 디자인 패턴을 공부하는 분들에게 사고를 전환시켜 줄 수 있는 내용이 될 것이다.
- More Effective C++: STL에 대한 내용이나, Item 26:Limiting the number of objects of a class에 대한 내용은 Singleton에 대한 내용이기도 하다. 여기서는 C++에서의 다양한 문제에 대해서 설명하고 있으며, STL에 대한 이해를 도울 수 있을 것이다.
- Pattern Hatching : Design Patterns Applied, John Vlissides, Addison-Wesley: 하나의 파일 시스템을 구축하는 것을 내용으로 하여 디자인 패턴을 적용하고 있다. 전체의 프로젝트를 진행하면서 문제가 생길때마다 패턴을 적용하고 있으며, GoF의 Singleton을 비롯한 몇몇 패턴들의 문제점을 지적하며, 해결책을 제시하고 있다.