싱글턴 패턴
개요
프로그램 곳곳에서 같은 읽기 전용 분류표를 쓰는데 매번 새 객체를 만들 필요가 없다고 하자. 한 객체를 만들고 모두 그 객체에 접근하도록 생성 경로를 제한할 수 있다.
싱글턴(Singleton)은 하나의 인스턴스와 그 접근점을 제공하는 패턴이다. 이 예제에서는 생성자를 숨기고 Instance를 통해 같은 객체를 받는다.
UML
호출부는 AppCatalog.Instance에 접근한다. AppCatalog의 생성자는 private이며, 정적 Lazy<AppCatalog>가 처음 요청될 때 인스턴스를 만든다.
역사
GoF는 1994년 Design Patterns에서 Singleton을 생성 패턴으로 정리했다. 클래스의 인스턴스를 하나로 제한하고 그 접근점을 제공하는 구조다.1
왜 사용하는가?
공유할 객체의 생성 위치가 명확해지고 같은 분류표를 재사용한다. 다만 전역 접근이 호출부의 의존성을 숨기므로, 테스트마다 다른 객체가 필요하다면 생성해서 주입하는 편이 다루기 쉽다.
여기서 하나라는 범위는 이 런타임의 같은 로드 문맥 안에 있는 정적 필드다. 서버 프로세스가 여러 개면 각자 하나씩 가지며, 분산 작업의 중복 실행을 막는 장치로 사용할 수 없다.
예제
두 번 얻은 객체의 참조가 같은지 확인한다.
using System;
using System.Collections.Generic;
using System.Linq;
Console.WriteLine(ReferenceEquals(AppCatalog.Instance, AppCatalog.Instance));
// 생성 경로를 모아 호출부마다 별도 분류표를 만들지 않게 한다.
sealed class AppCatalog
{
private static readonly Lazy<AppCatalog> shared = new(() => new AppCatalog());
private AppCatalog() { }
// 동기 접근에서 처음 한 번 생성하고 같은 모듈 안에서는 그 객체를 돌려준다.
public static AppCatalog Instance => shared.Value;
public string Version => "v1";
}
TrueLazy<T>가 초기화를 동기화한다. 이것은 초기화 보장이고, 이후 객체에 추가할 가변 동작까지 자동으로 스레드 안전해지는 것은 아니다.
범위 넓힌 예제
여러 작업에서 동시에 처음 접근해도 같은 인스턴스를 얻는지 확인한다. 분류표는 예제에서 읽기 전용으로 쓴다.
using System;
using System.Collections.Generic;
using System.Linq;
var tasks = Enumerable.Range(0, 8)
.Select(_ => System.Threading.Tasks.Task.Run(() => AppCatalog.Instance)).ToArray();
var values = await System.Threading.Tasks.Task.WhenAll(tasks);
Console.WriteLine(values.All(value => ReferenceEquals(value, values[0])));
Console.WriteLine(values[0].Version);
// 생성 경로를 모아 호출부마다 별도 분류표를 만들지 않게 한다.
sealed class AppCatalog
{
private static readonly Lazy<AppCatalog> shared = new(() => new AppCatalog());
private AppCatalog() { }
// 동기 접근에서 처음 한 번 생성하고 같은 모듈 안에서는 그 객체를 돌려준다.
public static AppCatalog Instance => shared.Value;
public string Version => "v1";
}
True
v1동시 접근에서도 객체 하나를 받지만 보장 범위는 같은 정적 필드를 공유하는 호출들이다. 설정을 사용자별로 다르게 유지해야 한다면 이 공유 범위 자체가 요구사항과 맞는지 먼저 봐야 한다.
TypeScript 예제
여러 호출부가 같은 분류표를 쓰도록 생성과 접근 지점을 AppCatalog 안에 모은다. 호출부는 새 객체를 만들지 않고 instance에서 공유 객체를 받는다.
// 생성 경로를 모아 호출부마다 별도 분류표를 만들지 않게 한다.
class AppCatalog {
static #shared: AppCatalog | undefined;
readonly version = "v1";
private constructor() {}
// 동기 접근에서 처음 한 번 생성하고 같은 모듈 안에서는 그 객체를 돌려준다.
static get instance(): AppCatalog {
return AppCatalog.#shared ??= new AppCatalog();
}
}
const first = AppCatalog.instance;
const second = AppCatalog.instance;
console.log(first === second);
console.log(first.version);true
v1공유 범위는 같은 JavaScript 모듈 인스턴스다. TypeScript의 private 생성자는 타입 검사 단계의 제한이므로 JavaScript 호출까지 막는 보안 장치로 쓰지 않는다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.