뒤로가기

스레드 안전 싱글톤 패턴: 멀티스레드 환경에서 안전한 단일 인스턴스 보장

design-pattern

싱글톤 패턴(Singleton Pattern)은 클래스의 인스턴스가 오직 하나만 존재하도록 보장하고, 이 인스턴스에 대한 전역 접근점을 제공하는 생성 디자인 패턴입니다. 하지만 멀티스레드 환경에서는 단순한 구현으로는 여러 인스턴스가 생성될 수 있어, **스레드 안전성(Thread Safety)**을 보장하는 기법이 필수적입니다.

이 글에서는 싱글톤 패턴의 구조, 멀티스레드 환경에서의 문제점, 그리고 안전한 구현 방법을 상세히 다룹니다.

싱글톤 패턴이 해결하는 문제

전역 상태 관리의 필요성

애플리케이션에서 하나의 인스턴스만 필요한 경우가 많습니다.

사용 사례:

  1. 설정 관리: 애플리케이션 전역 설정
  2. 데이터베이스 커넥션 풀: 커넥션 재사용
  3. 로거: 전역 로깅 시스템
  4. 캐시: 메모리 캐시 관리
  5. 드라이버: 하드웨어 장치 제어

문제 상황:

class DatabaseConnection {
    init {
        println("Creating expensive database connection...")
        Thread.sleep(1000) // 비용이 큰 초기화
    }
 
    fun query(sql: String) {
        println("Executing: $sql")
    }
}
 
// 여러 곳에서 인스턴스 생성 → 비효율적
fun main() {
    val conn1 = DatabaseConnection() // 1초 대기
    val conn2 = DatabaseConnection() // 또 1초 대기
    val conn3 = DatabaseConnection() // 또 1초 대기
 
    // 총 3초 + 메모리 낭비
}

기본 싱글톤 구현의 문제점

단순한 싱글톤 (비안전)

class UnsafeSingleton private constructor() {
    companion object {
        private var instance: UnsafeSingleton? = null
 
        fun getInstance(): UnsafeSingleton {
            if (instance == null) {
                instance = UnsafeSingleton()
            }
            return instance!!
        }
    }
}

멀티스레드 환경에서의 문제:

시간    스레드 A                       스레드 B
─────────────────────────────────────────────────────
t1     if (instance == null)
t2                                   if (instance == null)
t3     → true, instance = ...
t4                                   → true, instance = ...
결과:  두 개의 인스턴스 생성! [주의]

실제 테스트:

fun testUnsafeSingleton() {
    val instances = mutableSetOf<UnsafeSingleton>()
 
    val threads = (1..100).map {
        Thread {
            instances.add(UnsafeSingleton.getInstance())
        }
    }
 
    threads.forEach { it.start() }
    threads.forEach { it.join() }
 
    println("Created ${instances.size} instances") // 1보다 클 수 있음!
}

스레드 안전 싱글톤 구현 방법

1. Eager Initialization (즉시 초기화)

가장 간단하고 안전한 방법입니다.

class EagerSingleton private constructor() {
    companion object {
        val instance = EagerSingleton()
    }
 
    fun doSomething() {
        println("Eager singleton instance: $this")
    }
}
 
// 사용
EagerSingleton.instance.doSomething()

장점:

  • 스레드 안전 (JVM이 클래스 로드 시 한 번만 초기화 보장)
  • 구현 간단

단점:

  • 인스턴스를 사용하지 않아도 생성됨 (메모리 낭비 가능)
  • 초기화 비용이 큰 경우 앱 시작 시간 증가

2. Lazy Initialization with Synchronized (동기화 방식)

인스턴스 생성을 지연시키면서 스레드 안전성을 보장합니다.

class SynchronizedSingleton private constructor() {
    companion object {
        @Volatile
        private var instance: SynchronizedSingleton? = null
 
        fun getInstance(): SynchronizedSingleton {
            return synchronized(this) {
                if (instance == null) {
                    instance = SynchronizedSingleton()
                }
                instance!!
            }
        }
    }
}

장점:

  • 지연 초기화 (실제 사용 시점에 생성)
  • 스레드 안전

단점:

  • 매번 synchronized 블록 진입 (성능 오버헤드)
  • 인스턴스 생성 후에도 락 획득 필요

3. Double-Checked Locking (DCL)

성능과 안전성을 동시에 만족하는 최적 방법입니다.

class Singleton private constructor() {
    companion object {
        @Volatile
        private var instance: Singleton? = null
 
        fun createInstance(): Singleton {
            // 첫 번째 체크 (synchronized 없이)
            return instance ?: synchronized(this) {
                // 두 번째 체크 (synchronized 안에서)
                instance ?: Singleton().also { instance = it }
            }
        }
    }
 
    fun print() {
        println("Singleton instance: $this")
    }
}

동작 원리:

1. 첫 번째 null 체크 (instance ?:)
   → instance가 이미 존재하면 즉시 반환 (락 없음!)
   → instance가 null이면 synchronized 블록 진입

2. synchronized 블록 진입
   → 다른 스레드가 이미 인스턴스를 생성했을 수 있으므로
   → 다시 한 번 null 체크

3. 두 번째 null 체크 통과 시
   → 인스턴스 생성 및 반환

@Volatile의 역할:

@Volatile은 두 가지를 보장합니다:

  1. 가시성(Visibility): 한 스레드의 변경사항이 다른 스레드에 즉시 보임
  2. 재배치 방지(Reordering Prevention): 인스턴스 생성과 할당이 원자적으로 수행

@Volatile 없을 때의 문제:

// @Volatile 없으면 문제 발생 가능
// Thread A: instance = Singleton() 실행
//   1. 메모리 할당
//   2. 생성자 호출
//   3. instance에 할당
// ↓ 컴파일러 최적화로 순서 변경될 수 있음
//   1. 메모리 할당
//   3. instance에 할당 (생성자 호출 전!)
//   2. 생성자 호출
 
// Thread B: 불완전한 객체 참조 가능!

4. Holder Pattern (Bill Pugh Singleton)

Kotlin/Java에서 가장 권장되는 방식입니다.

class HolderSingleton private constructor() {
    companion object {
        fun getInstance(): HolderSingleton {
            return Holder.instance
        }
    }
 
    private object Holder {
        val instance = HolderSingleton()
    }
 
    fun doSomething() {
        println("Holder singleton: $this")
    }
}

장점:

  • 지연 초기화 (getInstance() 호출 시 Holder 클래스 로드)
  • 스레드 안전 (JVM의 클래스 로더가 보장)
  • 동기화 오버헤드 없음
  • @Volatile 불필요

동작 원리:

1. HolderSingleton 클래스 로드
   → Holder는 아직 로드되지 않음

2. getInstance() 최초 호출
   → Holder 클래스 로드 시작
   → JVM이 클래스 초기화 시 스레드 안전 보장
   → instance = HolderSingleton() 실행

3. 이후 호출
   → 이미 초기화된 instance 반환

5. Enum Singleton (직렬화 안전)

Java/Kotlin에서 가장 안전한 싱글톤 구현입니다.

enum class EnumSingleton {
    INSTANCE;
 
    fun doSomething() {
        println("Enum singleton: $this")
    }
 
    private var counter = 0
 
    fun incrementCounter(): Int {
        return ++counter
    }
}
 
// 사용
EnumSingleton.INSTANCE.doSomething()
EnumSingleton.INSTANCE.incrementCounter()

장점:

  • 스레드 안전
  • 직렬화/역직렬화 시 싱글톤 보장
  • 리플렉션 공격 방어
  • 코드 간결

단점:

  • 상속 불가 (enum은 다른 클래스를 상속할 수 없음)
  • 지연 초기화 불가

실전 구현: 설정 관리 싱글톤

문제 정의

애플리케이션 전역 설정을 관리하는 ConfigManager를 싱글톤으로 구현합니다.

data class AppConfig(
    val apiBaseUrl: String,
    val apiKey: String,
    val timeout: Long,
    val maxRetries: Int,
    val enableLogging: Boolean
)
 
class ConfigManager private constructor() {
    @Volatile
    private var config: AppConfig? = null
 
    companion object {
        @Volatile
        private var instance: ConfigManager? = null
 
        fun getInstance(): ConfigManager {
            return instance ?: synchronized(this) {
                instance ?: ConfigManager().also { instance = it }
            }
        }
    }
 
    fun loadConfig(config: AppConfig) {
        synchronized(this) {
            if (this.config != null) {
                throw IllegalStateException("Config already loaded")
            }
            this.config = config
        }
    }
 
    fun getConfig(): AppConfig {
        return config ?: throw IllegalStateException("Config not loaded")
    }
 
    fun updateApiKey(newKey: String) {
        synchronized(this) {
            val current = getConfig()
            config = current.copy(apiKey = newKey)
        }
    }
}
 
// 사용
fun main() {
    val manager = ConfigManager.getInstance()
 
    manager.loadConfig(
        AppConfig(
            apiBaseUrl = "https://api.example.com",
            apiKey = "secret-key",
            timeout = 5000,
            maxRetries = 3,
            enableLogging = true
        )
    )
 
    println("API URL: ${manager.getConfig().apiBaseUrl}")
 
    manager.updateApiKey("new-secret-key")
    println("Updated API Key: ${manager.getConfig().apiKey}")
}

TypeScript 구현: 싱글톤 패턴

기본 싱글톤

class Singleton {
  private static instance: Singleton | null = null;
  private counter = 0;
 
  private constructor() {
    // private 생성자로 외부 인스턴스화 방지
  }
 
  static getInstance(): Singleton {
    if (this.instance === null) {
      this.instance = new Singleton();
    }
    return this.instance;
  }
 
  incrementCounter(): number {
    return ++this.counter;
  }
 
  getCounter(): number {
    return this.counter;
  }
}
 
// 사용
const singleton1 = Singleton.getInstance();
const singleton2 = Singleton.getInstance();
 
console.log(singleton1 === singleton2); // true
 
singleton1.incrementCounter();
console.log(singleton2.getCounter()); // 1 (같은 인스턴스)

제네릭 싱글톤 팩토리

class SingletonFactory {
  private static instances = new Map<any, any>();
 
  static getInstance<T>(constructor: new () => T): T {
    if (!this.instances.has(constructor)) {
      this.instances.set(constructor, new constructor());
    }
    return this.instances.get(constructor);
  }
 
  static resetInstance<T>(constructor: new () => T): void {
    this.instances.delete(constructor);
  }
 
  static resetAll(): void {
    this.instances.clear();
  }
}
 
// 사용
class Logger {
  log(message: string) {
    console.log(`[LOG] ${message}`);
  }
}
 
class Cache {
  private data = new Map<string, any>();
 
  set(key: string, value: any) {
    this.data.set(key, value);
  }
 
  get(key: string): any {
    return this.data.get(key);
  }
}
 
const logger1 = SingletonFactory.getInstance(Logger);
const logger2 = SingletonFactory.getInstance(Logger);
console.log(logger1 === logger2); // true
 
const cache1 = SingletonFactory.getInstance(Cache);
const cache2 = SingletonFactory.getInstance(Cache);
console.log(cache1 === cache2); // true

모듈 패턴 (ES6 Modules)

ES6 모듈은 기본적으로 싱글톤처럼 동작합니다.

// logger.ts
class Logger {
  private logs: string[] = [];
 
  log(message: string): void {
    const timestamp = new Date().toISOString();
    const logEntry = `[${timestamp}] ${message}`;
    this.logs.push(logEntry);
    console.log(logEntry);
  }
 
  getLogs(): string[] {
    return [...this.logs];
  }
 
  clearLogs(): void {
    this.logs = [];
  }
}
 
// 모듈 레벨에서 인스턴스 생성
export const logger = new Logger();
 
// app.ts
import { logger } from './logger';
 
logger.log('Application started');
 
// another-file.ts
import { logger } from './logger';
 
logger.log('Processing request');
// 동일한 logger 인스턴스 사용

실전 사용 사례

1. 커넥션 풀 싱글톤

import java.util.concurrent.ConcurrentLinkedQueue
 
class Connection(val id: Int) {
    fun execute(query: String) {
        println("[Connection-$id] Executing: $query")
    }
 
    fun close() {
        println("[Connection-$id] Closed")
    }
}
 
class ConnectionPool private constructor() {
    private val pool = ConcurrentLinkedQueue<Connection>()
    private val maxConnections = 10
    private var connectionCount = 0
 
    companion object {
        @Volatile
        private var instance: ConnectionPool? = null
 
        fun getInstance(): ConnectionPool {
            return instance ?: synchronized(this) {
                instance ?: ConnectionPool().also {
                    instance = it
                    println("ConnectionPool initialized")
                }
            }
        }
    }
 
    fun getConnection(): Connection {
        return pool.poll() ?: synchronized(this) {
            if (connectionCount < maxConnections) {
                Connection(++connectionCount).also {
                    println("Created connection ${it.id}")
                }
            } else {
                throw RuntimeException("Connection pool exhausted")
            }
        }
    }
 
    fun releaseConnection(connection: Connection) {
        pool.offer(connection)
        println("Released connection ${connection.id}")
    }
 
    fun getPoolSize(): Int = pool.size
    fun getTotalConnections(): Int = connectionCount
}
 
// 사용
fun main() {
    val pool = ConnectionPool.getInstance()
 
    val conn1 = pool.getConnection()
    conn1.execute("SELECT * FROM users")
 
    val conn2 = pool.getConnection()
    conn2.execute("SELECT * FROM orders")
 
    pool.releaseConnection(conn1)
    pool.releaseConnection(conn2)
 
    println("Pool size: ${pool.getPoolSize()}") // 2
    println("Total connections: ${pool.getTotalConnections()}") // 2
}

2. 글로벌 로거

enum LogLevel {
  DEBUG,
  INFO,
  WARN,
  ERROR
}
 
interface LogEntry {
  level: LogLevel;
  message: string;
  timestamp: Date;
  context?: Record<string, any>;
}
 
class Logger {
  private static instance: Logger | null = null;
  private logs: LogEntry[] = [];
  private minLevel: LogLevel = LogLevel.INFO;
 
  private constructor() {}
 
  static getInstance(): Logger {
    if (this.instance === null) {
      this.instance = new Logger();
    }
    return this.instance;
  }
 
  setMinLevel(level: LogLevel): void {
    this.minLevel = level;
  }
 
  private log(level: LogLevel, message: string, context?: Record<string, any>): void {
    if (level < this.minLevel) return;
 
    const entry: LogEntry = {
      level,
      message,
      timestamp: new Date(),
      context
    };
 
    this.logs.push(entry);
 
    const levelName = LogLevel[level];
    const contextStr = context ? ` ${JSON.stringify(context)}` : '';
    console.log(`[${levelName}] ${message}${contextStr}`);
  }
 
  debug(message: string, context?: Record<string, any>): void {
    this.log(LogLevel.DEBUG, message, context);
  }
 
  info(message: string, context?: Record<string, any>): void {
    this.log(LogLevel.INFO, message, context);
  }
 
  warn(message: string, context?: Record<string, any>): void {
    this.log(LogLevel.WARN, message, context);
  }
 
  error(message: string, context?: Record<string, any>): void {
    this.log(LogLevel.ERROR, message, context);
  }
 
  getLogs(level?: LogLevel): LogEntry[] {
    if (level === undefined) {
      return [...this.logs];
    }
    return this.logs.filter(entry => entry.level === level);
  }
 
  clearLogs(): void {
    this.logs = [];
  }
}
 
// 사용
const logger = Logger.getInstance();
 
logger.setMinLevel(LogLevel.DEBUG);
 
logger.debug('Debug message', { userId: 123 });
logger.info('User logged in', { userId: 123, ip: '192.168.1.1' });
logger.warn('High memory usage', { usage: 85 });
logger.error('Database connection failed', { error: 'ECONNREFUSED' });
 
console.log(`Total logs: ${logger.getLogs().length}`);
console.log(`Error logs: ${logger.getLogs(LogLevel.ERROR).length}`);

3. 앱 상태 관리 (Redux-like)

type Subscriber = (state: AppState) => void;
 
interface AppState {
  user: { id: string; name: string } | null;
  theme: 'light' | 'dark';
  notifications: string[];
}
 
type Action =
  | { type: 'SET_USER'; payload: { id: string; name: string } }
  | { type: 'LOGOUT' }
  | { type: 'SET_THEME'; payload: 'light' | 'dark' }
  | { type: 'ADD_NOTIFICATION'; payload: string }
  | { type: 'CLEAR_NOTIFICATIONS' };
 
class Store {
  private static instance: Store | null = null;
  private state: AppState;
  private subscribers: Set<Subscriber> = new Set();
 
  private constructor() {
    this.state = {
      user: null,
      theme: 'light',
      notifications: []
    };
  }
 
  static getInstance(): Store {
    if (this.instance === null) {
      this.instance = new Store();
    }
    return this.instance;
  }
 
  getState(): AppState {
    return { ...this.state };
  }
 
  dispatch(action: Action): void {
    this.state = this.reduce(this.state, action);
    this.notify();
  }
 
  private reduce(state: AppState, action: Action): AppState {
    switch (action.type) {
      case 'SET_USER':
        return { ...state, user: action.payload };
 
      case 'LOGOUT':
        return { ...state, user: null };
 
      case 'SET_THEME':
        return { ...state, theme: action.payload };
 
      case 'ADD_NOTIFICATION':
        return {
          ...state,
          notifications: [...state.notifications, action.payload]
        };
 
      case 'CLEAR_NOTIFICATIONS':
        return { ...state, notifications: [] };
 
      default:
        return state;
    }
  }
 
  subscribe(callback: Subscriber): () => void {
    this.subscribers.add(callback);
 
    // Unsubscribe 함수 반환
    return () => {
      this.subscribers.delete(callback);
    };
  }
 
  private notify(): void {
    this.subscribers.forEach(callback => callback(this.state));
  }
}
 
// 사용
const store = Store.getInstance();
 
const unsubscribe = store.subscribe(state => {
  console.log('State updated:', state);
});
 
store.dispatch({ type: 'SET_USER', payload: { id: '123', name: 'John' } });
store.dispatch({ type: 'SET_THEME', payload: 'dark' });
store.dispatch({ type: 'ADD_NOTIFICATION', payload: 'Welcome!' });
 
console.log('Current state:', store.getState());
 
unsubscribe(); // 구독 해제

안티패턴과 주의사항

1. 싱글톤 남용

[주의] 잘못된 사용:

// 모든 것을 싱글톤으로 만들면 안 됨
class UserService private constructor() {
    companion object {
        val instance = UserService()
    }
}
 
class ProductService private constructor() {
    companion object {
        val instance = ProductService()
    }
}
 
// 의존성 주입으로 해결 가능한 경우

[권장] 대안:

// 의존성 주입 사용
class UserService(private val repository: UserRepository)
class ProductService(private val repository: ProductRepository)
 
// DI 컨테이너에서 생명주기 관리

2. 테스트 어려움

[주의] 문제:

class PaymentProcessor {
    fun process(amount: Double) {
        val logger = Logger.getInstance() // 하드 코딩된 의존성
        logger.log("Processing $amount")
        // ...
    }
}
 
// 테스트 시 Logger를 Mock으로 교체하기 어려움

[권장] 해결:

// 의존성 주입으로 변경
class PaymentProcessor(private val logger: Logger) {
    fun process(amount: Double) {
        logger.log("Processing $amount")
        // ...
    }
}
 
// 테스트
class PaymentProcessorTest {
    @Test
    fun testProcess() {
        val mockLogger = MockLogger()
        val processor = PaymentProcessor(mockLogger)
        processor.process(100.0)
        // mockLogger 검증
    }
}

3. 직렬화 문제

[주의] 문제:

class SerializableSingleton private constructor() : Serializable {
    companion object {
        val instance = SerializableSingleton()
    }
 
    var counter = 0
}
 
// 역직렬화 시 새 인스턴스 생성!

[권장] 해결:

class SerializableSingleton private constructor() : Serializable {
    companion object {
        val instance = SerializableSingleton()
    }
 
    var counter = 0
 
    // 역직렬화 시 기존 인스턴스 반환
    private fun readResolve(): Any {
        return instance
    }
}

또는 Enum 사용:

enum class SafeSingleton {
    INSTANCE;
 
    var counter = 0
}

성능 비교

벤치마크

import kotlin.system.measureTimeMillis
 
fun benchmarkSingletons() {
    val iterations = 10_000_000
 
    // Eager Initialization
    val eagerTime = measureTimeMillis {
        repeat(iterations) {
            EagerSingleton.instance
        }
    }
 
    // DCL
    val dclTime = measureTimeMillis {
        repeat(iterations) {
        val obj = Singleton.createInstance()
        }
    }
 
    // Holder Pattern
    val holderTime = measureTimeMillis {
        repeat(iterations) {
            HolderSingleton.getInstance()
        }
    }
 
    println("Eager: ${eagerTime}ms")
    println("DCL: ${dclTime}ms")
    println("Holder: ${holderTime}ms")
}
 
// 결과 (예시):
// Eager: 45ms
// DCL: 52ms
// Holder: 48ms

결론:

  • Eager와 Holder가 가장 빠름 (동기화 없음)
  • DCL은 첫 번째 체크만 빠르고 이후 약간 느림
  • 실제 차이는 미미하므로 안전성과 유지보수성 우선

싱글톤 체크리스트

  • 정말 싱글톤이 필요한가? (의존성 주입으로 대체 가능한가?)
  • 멀티스레드 환경인가? (스레드 안전성 필요?)
  • 지연 초기화가 필요한가? (메모리/초기화 비용 고려)
  • 직렬화가 필요한가? (readResolve 구현 필요)
  • 테스트 가능성을 고려했는가? (Mock 주입 방법)

결론

싱글톤 패턴은 강력하지만 신중하게 사용해야 합니다.

핵심 권장사항:

  1. Kotlin/Java: Holder Pattern 또는 Enum 사용
  2. TypeScript: 모듈 패턴 또는 기본 싱글톤
  3. 멀티스레드: Double-Checked Locking + @Volatile
  4. 테스트: 의존성 주입으로 대체 가능한지 검토

적용 시기:

  • 전역 상태 관리 (설정, 캐시)
  • 리소스 공유 (커넥션 풀, 로거)
  • 하드웨어 접근 (드라이버)

주의사항:

  • 싱글톤 남용 금지
  • 테스트 가능성 고려
  • 의존성 주입과 비교 검토

올바르게 구현된 스레드 안전 싱글톤은 안전하고 효율적인 전역 상태 관리를 제공합니다.

관련 아티클