
싱글톤 패턴: 전 세계에 딱 하나뿐인 인스턴스
대통령은 나라에 한 명뿐입니다. DB 커넥션 풀도 하나뿐. 전역 관리자를 만들 때 쓰는 가장 유명하면서도 논쟁적인 패턴. 편리하지만 테스트 지옥의 지름길.

대통령은 나라에 한 명뿐입니다. DB 커넥션 풀도 하나뿐. 전역 관리자를 만들 때 쓰는 가장 유명하면서도 논쟁적인 패턴. 편리하지만 테스트 지옥의 지름길.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

회사 코드를 보다가 이런 걸 봤습니다:
const config = Config.getInstance();
const logger = Logger.getInstance();
const db = Database.getInstance();
처음 보는 패턴이었습니다. 다른 클래스는 전부 new Car(), new User() 이런 식으로 만드는데, 이 클래스들만 getInstance()라는 메서드를 쓰고 있었습니다.
선배에게 물어봤습니다.
"왜 new Config()가 아니라 getInstance()를 쓰나요?"
"그거 싱글톤 패턴이야. 전역에서 하나만 있어야 하는 객체니까."
그때는 이해한 척했지만, 사실 잘 몰랐습니다. 전역에서 하나? 그냥 전역 변수 쓰면 되는 거 아닌가? 왜 굳이 getInstance() 같은 메서드를 만들어서 복잡하게 하지?
더 헷갈렸던 건, 어떤 개발자는 싱글톤을 "편리한 패턴"이라고 하고, 어떤 사람은 "안티패턴"이라고 욕을 하더라는 겁니다. 같은 걸 두고 왜 이렇게 평가가 갈리는 거지?
싱글톤에 대해 찾아보면서 이런 것들이 이해가 안 갔습니다:
1. 왜new로 못 만들게 막아야 하지?
일반 클래스는 new로 만들면 됩니다. 근데 싱글톤은 new를 막고 getInstance()를 쓰게 합니다. 이게 왜 필요한지 몰랐습니다.
// 전역 변수
const config = { apiKey: 'abc' };
// 싱글톤
const config = Config.getInstance();
둘 다 전역에서 접근 가능한데, 뭐가 다른 건지 감이 안 왔습니다.
3. 왜 어떤 사람은 "안티패턴"이라고 하지?디자인 패턴인데 안티패턴? 이건 뭔 소리인가 싶었습니다. 편리하면 좋은 거 아닌가요?
4. 멀티스레드에서 문제가 생긴다는데, 뭐가 문제지?Java 코드를 보니까 synchronized, volatile 같은 키워드가 막 나오더라구요. 싱글톤 하나 만드는데 왜 이렇게 복잡한 거지?
한국에서 일하는 외국인 개발자가 싱글톤을 이렇게 설명해줬습니다:
"대통령은 나라에 한 명뿐입니다.
new President()를 100번 호출한다고 대통령이 100명 생기면 안 됩니다. 처음 뽑힌 그 사람이, 두 번째 세 번째 호출에도 똑같이 나와야 합니다.이게 싱글톤입니다."
이 비유를 듣는 순간 머릿속에 확 들어왔습니다.
사무실에 프린터가 한 대 있다고 생각해봤습니다. 개발자 A가 "프린터 객체 하나 줘"라고 하고, 개발자 B가 "나도 프린터 객체 하나 줘"라고 할 때, 두 사람한테 같은 프린터를 줘야 합니다. 프린터가 두 대면 안 되니까요.
DB 커넥션 풀도 마찬가지입니다. 서비스 전체에서 DB 커넥션 풀은 하나만 있어야 합니다. 모듈 A가 풀 하나 만들고, 모듈 B가 또 풀을 만들면 커넥션이 낭비됩니다. 그래서 "전역에 딱 하나만 존재해야 하는 것"을 만들 때 싱글톤을 쓴다는 걸 이해했습니다.
그리고 이제 왜 전역 변수가 아니라 getInstance()를 쓰는지도 이해가 됐습니다. 전역 변수는 프로그램 시작할 때부터 메모리를 차지합니다. 근데 getInstance()는 필요할 때만 만듭니다 (Lazy Initialization). 안 쓰면 안 만들어지니까 메모리를 절약할 수 있습니다.
클래스의 인스턴스가 프로그램 전체에서 딱 하나만 존재하도록 보장하는 패턴입니다.
핵심은 두 가지입니다:
일반 클래스랑 어떻게 다른지 코드로 보면 확실히 이해가 됩니다.
class Car {
constructor(name) {
this.name = name;
}
}
const car1 = new Car('소나타');
const car2 = new Car('아반떼');
console.log(car1 === car2); // false (서로 다른 객체)
class President {
constructor(name) {
if (President.instance) {
return President.instance; // 이미 있으면 기존 거 반환
}
this.name = name;
President.instance = this;
}
}
const p1 = new President('김대통령');
const p2 = new President('이대통령'); // 무시됨
console.log(p1 === p2); // true (같은 객체!)
console.log(p1.name); // '김대통령' (처음 값 유지)
이제 핵심을 이해했습니다. 그런데 "어떻게 딱 하나만 만들어지게 보장하지?"라는 의문이 생겼습니다. 여러 가지 구현 방법이 있더라구요.
처음에 싱글톤 구현 방법을 찾아보니까 JavaScript, Java, Python마다 다 다르게 만들더라구요. "왜 이렇게 방법이 많지?" 싶었는데, 알고 보니 각각 해결하려는 문제가 달랐습니다.
class DatabaseConnection {
constructor() {
if (DatabaseConnection.instance) {
return DatabaseConnection.instance;
}
this.connection = this.connect();
DatabaseConnection.instance = this;
}
connect() {
console.log('DB 연결 생성');
return { host: 'localhost', port: 5432 };
}
static getInstance() {
if (!DatabaseConnection.instance) {
DatabaseConnection.instance = new DatabaseConnection();
}
return DatabaseConnection.instance;
}
}
// 사용
const db1 = DatabaseConnection.getInstance();
const db2 = DatabaseConnection.getInstance();
console.log(db1 === db2); // true
const Config = (function() {
let instance;
function createInstance() {
return {
apiKey: 'abc123',
baseUrl: 'https://api.example.com'
};
}
return {
getInstance: function() {
if (!instance) {
instance = createInstance();
}
return instance;
}
};
})();
const config1 = Config.getInstance();
const config2 = Config.getInstance();
console.log(config1 === config2); // true
public class Singleton {
private static Singleton instance;
// 생성자를 private으로 막음
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
여기서부터 복잡해집니다. Java로 서버를 만들다 보면 멀티스레드 환경을 마주치게 됩니다.
문제 상황:// 스레드 A와 스레드 B가 동시에 getInstance() 호출
// 둘 다 instance == null을 보고 각자 new Singleton() 실행
// 결과: 인스턴스가 2개 생김!
이걸 막으려면 synchronized로 락을 걸어야 합니다. 근데 매번 락을 걸면 성능이 느려집니다. 그래서 나온 게 Double-Checked Locking입니다.
public class Singleton {
// volatile: 멀티스레드 환경에서 안전
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 1차 체크 (락 없이)
synchronized (Singleton.class) { // 락 걸기
if (instance == null) { // 2차 체크 (락 걸고)
instance = new Singleton();
}
}
}
return instance;
}
}
이걸 처음 봤을 때 "왜 두 번이나 체크하지?"라고 생각했습니다. 그런데 이해하고 나니 천재적이더라구요:
volatile 키워드는 "메모리 재배치 금지"를 뜻합니다. 이게 없으면 JVM이 명령어 순서를 바꿔서 반쯤 만들어진 객체를 리턴할 수 있습니다.
정말 복잡합니다. 그래서 Java에서는 더 쉬운 방법이 있습니다.
__new__ 방식Python도 싱글톤을 만들 수 있습니다. __new__ 메서드를 오버라이드하면 됩니다.
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# 사용
s1 = Singleton()
s2 = Singleton()
print(s1 is s2) # True
Python은 GIL(Global Interpreter Lock) 때문에 Java처럼 복잡한 스레드 세이프 처리는 필요 없습니다. 한 번에 한 스레드만 Python 코드를 실행하니까요.
사실 JavaScript에서는 클래스로 싱글톤 만들 필요가 없습니다. 모듈 자체가 싱글톤이니까요.
// logger.js
const logs = [];
export const log = (message) => {
logs.push(message);
console.log(message);
};
export const getLogs = () => logs;
// 이 파일을 여러 곳에서 import해도, logs 배열은 하나만 존재함
Node.js와 브라우저의 모듈 시스템은 한 번 로드한 모듈을 캐싱합니다. require('./logger') 또는 import './logger'를 100번 해도, 실제로는 파일이 한 번만 실행되고 그 결과가 재사용됩니다.
이게 제일 간단하고 자연스러운 싱글톤입니다. 클래스도 필요 없고, getInstance() 같은 메서드도 필요 없습니다.
싱글톤을 처음 이해했을 때는 "오, 이거 편하네!"라고 생각했습니다. 전역에서 바로 접근할 수 있으니까 편리했습니다. 그래서 처음엔 이것저것 다 싱글톤으로 만들었습니다.
근데 나중에 보니까 문제가 생기더라구요. 어떤 경우에 써야 하고, 어떤 경우에 쓰면 안 되는지 정리해봤습니다.
class AppConfig {
constructor() {
if (AppConfig.instance) return AppConfig.instance;
this.config = {
apiUrl: process.env.API_URL,
dbHost: process.env.DB_HOST,
maxConnections: 100
};
AppConfig.instance = this;
}
get(key) {
return this.config[key];
}
}
// 어디서든 같은 설정 접근
const config = new AppConfig();
console.log(config.get('apiUrl'));
class Logger {
constructor() {
if (Logger.instance) return Logger.instance;
this.logs = [];
Logger.instance = this;
}
log(message) {
const timestamp = new Date().toISOString();
this.logs.push(`[${timestamp}] ${message}`);
console.log(`[${timestamp}] ${message}`);
}
getLogs() {
return this.logs;
}
}
// 모든 로그가 한 곳에 모임
const logger1 = new Logger();
logger1.log('User logged in');
const logger2 = new Logger();
logger2.log('Data saved');
console.log(logger1.getLogs()); // 두 로그 모두 있음
class ConnectionPool {
constructor() {
if (ConnectionPool.instance) return ConnectionPool.instance;
this.connections = [];
this.maxConnections = 10;
for (let i = 0; i < this.maxConnections; i++) {
this.connections.push(this.createConnection());
}
ConnectionPool.instance = this;
}
createConnection() {
return { id: Math.random(), connected: true };
}
getConnection() {
return this.connections.pop();
}
releaseConnection(conn) {
this.connections.push(conn);
}
}
이 세 가지 예시는 싱글톤을 써도 괜찮은 경우입니다. 왜냐하면:
근데 이런 경우가 아닌데도 싱글톤을 남발하면 문제가 생깁니다.
"싱글톤은 안티패턴이다"라는 말을 처음 들었을 때는 이해가 안 갔습니다. 편리한데 왜? 근데 직접 삽질해보니까 이해가 됐습니다.
전역 상태는 누가, 언제, 어디서 바꿨는지 추적이 어렵습니다.
class Counter {
constructor() {
if (Counter.instance) return Counter.instance;
this.count = 0;
Counter.instance = this;
}
increment() {
this.count++;
}
}
// 모듈 A
const counter = new Counter();
counter.increment();
console.log(counter.count); // 1
// 모듈 B (나중에 실행)
const counter2 = new Counter();
counter2.increment();
console.log(counter2.count); // 2 ← 모듈 A의 영향을 받음!
누가 어디서 상태를 바꿨는지 추적이 어렵습니다.
제가 실제로 겪은 버그가 있습니다. 서비스에서 사용자 수를 세는 UserCounter 싱글톤이 있었는데, 갑자기 숫자가 이상하게 나왔습니다. 로그인 10명인데 카운터는 53명이라고 나오는 겁니다.
원인을 찾는 데 3시간이 걸렸습니다. 알고 보니 다른 팀원이 테스트 코드에서 UserCounter.getInstance().add(43)을 호출했는데 리셋을 안 한 거였습니다. 전역 상태는 이런 식으로 예상치 못한 곳에서 영향을 받습니다.
// 테스트 1
test('Counter starts at 0', () => {
const counter = new Counter();
expect(counter.count).toBe(0); // ✅ 통과
});
// 테스트 2 (테스트 1 이후 실행)
test('Counter increments', () => {
const counter = new Counter();
counter.increment();
expect(counter.count).toBe(1); // ❌ 실패! (아직 2임)
});
테스트 간 상태가 공유되어 테스트 격리가 안 됩니다.
좋은 테스트는 독립적이어야 합니다. 테스트 A가 실패하든 성공하든, 테스트 B에 영향을 주면 안 됩니다. 근데 싱글톤은 상태를 공유하기 때문에 이게 불가능합니다.
해결 방법은 매 테스트마다 reset() 메서드를 호출하는 건데, 이것도 문제입니다:
class Counter {
// ... 싱글톤 코드 ...
// 테스트를 위해 억지로 만든 메서드
reset() {
this.count = 0;
}
}
// 모든 테스트에서 이걸 해줘야 함
afterEach(() => {
Counter.getInstance().reset();
});
프로덕션 코드에 테스트를 위한 메서드가 들어간다는 게 이상합니다. 게다가 reset()을 깜빡하면 테스트가 실패합니다.
class UserService {
createUser(name) {
const db = Database.getInstance(); // 숨겨진 의존성!
db.save({ name });
}
}
UserService가 Database에 의존하는데, 생성자를 봐도 알 수가 없습니다.
함수 시그니처만 보면 createUser(name)는 이름만 받는 것처럼 보입니다. 하지만 실제로는 Database 싱글톤이 이미 초기화되어 있어야 합니다. 이게 숨겨진 의존성입니다.
이러면 테스트가 어려워집니다. UserService를 테스트하고 싶은데, Database.getInstance()가 실제 DB에 연결하려고 하면 테스트가 느려지고 불안정해집니다. Mock으로 바꾸고 싶은데 방법이 없습니다.
클래스가 두 가지 일을 하게 됩니다:
이게 SOLID의 S(Single Responsibility Principle)를 위반합니다.
싱글톤의 문제점을 이해하고 나서, "그럼 어떻게 해야 하지?"라는 의문이 들었습니다.
답은 의존성 주입(Dependency Injection)이었습니다. 싱글톤처럼 "내가 직접 가져온다"가 아니라, "누군가 나한테 준다"는 개념입니다.
// ❌ 싱글톤
class UserService {
createUser(name) {
const db = Database.getInstance();
db.save({ name });
}
}
// ✅ 의존성 주입
class UserService {
constructor(database) { // 명시적으로 주입
this.db = database;
}
createUser(name) {
this.db.save({ name });
}
}
// 사용
const db = new Database();
const userService = new UserService(db);
// 테스트
const mockDb = { save: jest.fn() };
const userService = new UserService(mockDb);
이렇게 바꾸니까 테스트가 엄청 쉬워졌습니다. mockDb를 만들어서 주입하면 끝입니다. 실제 DB 연결 필요 없습니다.
맞습니다. 하지만 차이는 이겁니다:
main() 함수에서 한 번만 만들어서 여기저기 전달함결과는 비슷하지만, DI가 훨씬 유연합니다. 테스트할 때는 Mock을 주입하고, 프로덕션에서는 실제 DB를 주입하면 됩니다.
"근데 new Database()를 여기저기서 하면 또 여러 개 만들어지는 거 아니야?"
맞습니다. 그래서 DI 컨테이너를 씁니다.
// container.js
class Container {
constructor() {
this.services = {};
}
register(name, instance) {
this.services[name] = instance;
}
get(name) {
return this.services[name];
}
}
// app.js (프로그램 시작점)
const container = new Container();
container.register('db', new Database());
container.register('logger', new Logger());
const userService = new UserService(container.get('db'));
const orderService = new OrderService(container.get('db'));
컨테이너에 등록한 건 하나만 존재합니다. 싱글톤처럼 동작하지만, 클래스 자체는 일반 클래스입니다.
Spring, NestJS, Angular 같은 프레임워크는 이런 DI 컨테이너를 내장하고 있습니다. 그래서 @Injectable() 같은 데코레이터만 붙이면 프레임워크가 알아서 주입해줍니다.
제가 처음 만든 서비스는 싱글톤 투성이였습니다.
class Analytics {
static getInstance() { /* ... */ }
track(event) { /* ... */ }
}
class Cache {
static getInstance() { /* ... */ }
set(key, value) { /* ... */ }
}
class FeatureFlag {
static getInstance() { /* ... */ }
isEnabled(flag) { /* ... */ }
}
function handleUserSignup(email) {
Analytics.getInstance().track('signup', { email });
if (FeatureFlag.getInstance().isEnabled('new_ui')) {
// ...
}
Cache.getInstance().set(`user:${email}`, { status: 'pending' });
}
편리했습니다. 어디서든 getInstance()만 호출하면 됐으니까요.
handleUserSignup 함수가 뭘 쓰는지 시그니처만 봐서는 모름특히 테스트를 작성할 때 Mock으로 바꾸는 게 너무 어려웠습니다. Analytics.getInstance()를 어떻게 Mock으로 바꿔요? 전역 상태를 건드려야 해서 테스트 코드가 지저분해졌습니다.
// 의존성을 명시적으로 받음
function handleUserSignup(email, { analytics, cache, featureFlag }) {
analytics.track('signup', { email });
if (featureFlag.isEnabled('new_ui')) {
// ...
}
cache.set(`user:${email}`, { status: 'pending' });
}
// 프로덕션 (container.js에서 한 번만 생성)
const container = {
analytics: new Analytics(),
cache: new Cache(),
featureFlag: new FeatureFlag()
};
handleUserSignup('test@example.com', container);
// 테스트 (쉽게 Mock 주입)
const mocks = {
analytics: { track: jest.fn() },
cache: { set: jest.fn() },
featureFlag: { isEnabled: () => true }
};
handleUserSignup('test@example.com', mocks);
// track이 호출됐는지 검증
expect(mocks.analytics.track).toHaveBeenCalledWith('signup', { email: 'test@example.com' });
리팩토링 후 좋아진 점:
코드가 조금 길어지긴 했지만, 유지보수성은 훨씬 좋아졌습니다.
그럼 싱글톤은 절대 쓰면 안 되는 걸까요? 아닙니다. 다음 경우에는 괜찮다고 받아들였습니다:
로그는 전역에서 하나의 파일/스트림에 써야 합니다. 순서가 중요하니까요. 로그 싱글톤은 합리적입니다.
환경 변수 같은 건 런타임에 바뀌지 않습니다. 전역에서 읽기 전용으로 쓰는 거니까 상태 문제가 없습니다.
프린터가 물리적으로 한 대면, 코드에서도 프린터 객체가 하나여야 합니다. 이런 경우는 싱글톤이 현실을 잘 반영합니다.
DB 커넥션은 한정된 리소스입니다. 풀을 여러 개 만들면 낭비니까 하나로 관리하는 게 맞습니다.
공통점: 이런 경우들은 "물리적으로 하나"이거나 "상태가 안 바뀌는" 것들입니다.
반대로 비즈니스 로직이나 상태를 관리하는 서비스를 싱글톤으로 만들면 문제가 생깁니다.
싱글톤을 공부하면서 이해한 것들을 정리해봅니다.
__new__ 오버라이드volatile, synchronized 필요main()에서 한 번만 생성해서"편리하다고 무조건 쓰지 말자. 정말 필요한 경우에만 쓰자."
싱글톤은 마치 전역 변수 같습니다. 당장은 편하지만, 프로젝트가 커지면 독이 됩니다. 특히 테스트를 작성할 때 그 고통이 확 느껴집니다.
지금은 비즈니스 로직에는 DI를 쓰고, 로깅이나 설정 같은 인프라 레벨에만 싱글톤을 씁니다. 그랬더니 코드가 훨씬 명확해지고 테스트도 쉬워졌습니다.
결국 싱글톤 패턴은 "편리함"과 "유지보수성" 사이의 트레이드오프였습니다. 처음엔 편리함을 택했다가, 나중에 유지보수성을 택하게 되더라구요.