싱글턴 패턴
생성 경로와 공유 접근점을 제한해 한 인스턴스를 쓰는 생성 패턴과, 그 보장의 범위.
· 마지막 업데이트: 2026-10-25 · 7 min read
개요
프로그램 곳곳에서 같은 읽기 전용 분류표를 쓰는데 매번 새 객체를 만들 필요가 없다고 하자. 한 객체를 만들고 모두 그 객체에 접근하도록 생성 경로를 제한할 수 있다.
싱글턴(Singleton)은 하나의 인스턴스와 그 접근점을 제공하는 패턴이다. 이 예제에서는 생성자를 숨기고 Instance를 통해 같은 객체를 받는다.
UML
호출부는 AppCatalog.Instance에 접근한다. AppCatalog의 생성자는 private이며, 정적 Lazy<AppCatalog>가 처음 요청될 때 인스턴스를 만든다.
역사
GoF는 1994년 Design Patterns에서 Singleton을 생성 패턴으로 정리했다. 클래스의 인스턴스를 하나로 제한하고 그 접근점을 제공하는 구조다.1
왜 사용하는가?
싱글턴은 편하지만, 전역 접근으로 의존성을 숨겨 결합도를 망가뜨리고 문제를 자주 일으키는 패턴이다. 호출부가 AppCatalog.Instance에 직접 묶이면 테스트 대역 교체와 사용자별 설정 분리가 어려워지고, 가변 상태까지 더하면 한 호출의 변경이 다른 호출에도 번진다. 이런 편의성이 전역 상태를 늘리는 습관으로 굳으면 안티패턴으로 변질된다. 객체를 하나만 재사용해야 하는 이유와 어디서나 전역으로 꺼내 써야 하는 이유는 따로 따져야 한다.
여기서 하나라는 범위는 이 런타임의 같은 로드 문맥 안에 있는 정적 필드다. 서버 프로세스가 여러 개면 각자 하나씩 가지며, 분산 작업의 중복 실행을 막는 장치로 사용할 수 없다.
예제
공유할 객체의 예로 버전 문자열만 가진 AppCatalog를 쓰고, 두 번 얻은 참조가 같은지 확인한다.
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() { }
// Lazy<T>가 첫 초기화를 동기화하고, 이 정적 필드를 쓰는 호출은 같은 객체를 받는다.
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() { }
// Lazy<T>가 첫 초기화를 동기화하고, 이 정적 필드를 쓰는 호출은 같은 객체를 받는다.
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() { Object.freeze(this); }
// 동기 접근에서 처음 한 번 생성하고 같은 모듈 안에서는 그 객체를 돌려준다.
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 모듈 인스턴스 안이다. 이 예제는 Object.freeze(this)로 버전 값을 고정한다. private 생성자는 타입 검사 제한이며 JavaScript 호출자를 막는 보안 경계는 아니다.
전역 접근 없이 같은 객체를 공유하기
공유할 객체는 프로그램을 조립하는 곳에서 한 번 만들고, 사용하는 함수에 인자로 전달하면 된다. 의존성 주입(DI)은 이처럼 필요한 것을 밖에서 받는 방식이며, 별도 컨테이너가 없어도 쓸 수 있다. 아래 함수는 전역 분류표를 찾지 않으므로 테스트에서는 다른 값을 바로 전달할 수 있다.
C#
using System;
ICatalog shared = new Catalog("v1");
// 조회 함수는 전역 객체를 찾지 않고 필요한 의존성을 인자로 받는다.
string Heading(ICatalog catalog) => "catalog " + catalog.Version;
Console.WriteLine(Heading(shared));
Console.WriteLine(Heading(new Catalog("test")));
interface ICatalog { string Version { get; } }
sealed record Catalog(string Version) : ICatalog;TypeScript
type Catalog = Readonly<{ version: string }>;
const shared: Catalog = Object.freeze({ version: "v1" });
// 같은 객체를 재사용할지, 테스트 값을 줄지는 호출부가 결정한다.
const heading = (catalog: Catalog): string => "catalog " + catalog.version;
console.log(heading(shared));
console.log(heading(Object.freeze({ version: "test" })));catalog v1
catalog test객체를 하나만 재사용하는 수명 정책과 어디서나 그 객체를 꺼내 쓰는 전역 접근은 별개다. DI로 공유 수명을 정해도 가변 상태의 동시 접근과 자원 해제 책임은 여전히 정해야 한다.
Microsoft: Dependency injection guidelines
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.