트렌드브리프
싱글턴 패턴, 스레드 안전 구현과 안티패턴 논쟁

싱글턴 패턴, 스레드 안전 구현과 안티패턴 논쟁

· 게시 · 약 5분 분량

싱글턴 패턴은 클래스 인스턴스를 하나만 만들어 공유하는 생성 패턴이다. 순진한 지연 초기화의 경쟁 상태, 스위프트·자바·코틀린의 스레드 안전 관용 구현, 전역 상태가 부르는 테스트·결합도 문제와 의존성 주입 대안을 정리했다.

목차
  1. 싱글턴 패턴은 무엇을 해결하나
  2. 스레드 안전은 왜 문제가 되나
  3. 언어별 관용 구현
  4. 싱글턴이 안티패턴으로 불리는 이유
  5. 언제 쓰고 언제 피할까

싱글턴 패턴은 한 클래스의 인스턴스를 오직 하나만 만들고, 프로그램 어디서든 그 하나에 접근하게 하는 생성 패턴이다. 설정 관리자, 로거, 데이터베이스 커넥션 풀처럼 애플리케이션 전체가 공유해야 하는 자원에 주로 쓴다. 개념은 단순하지만 실무에서는 두 가지가 발목을 잡는다. 여러 스레드가 동시에 접근할 때 인스턴스가 둘 이상 생기는 경쟁 상태, 그리고 전역 접근점이 만드는 테스트와 결합도 문제다.

싱글턴은 1994년 출간된 GoF(Gang of Four, 네 명의 저자)의 『디자인 패턴』이 정리한 생성 패턴 중 하나다. 이 글은 객체지향 언어에서 싱글턴을 구현하고 언제 쓸지 판단하는 기준을 다루며, 예시는 스위프트·자바·코틀린을 중심으로 든다. 특정 프레임워크의 의존성 주입 컨테이너 설정은 다루지 않는다.

싱글턴 패턴은 무엇을 해결하나

공유 자원을 하나만 두고, 그 자원에 접근하는 지점을 한곳으로 모으는 문제를 해결한다. 여러 코드가 각자 인스턴스를 만들면 상태가 갈라지거나 자원을 중복으로 쥔다.

구체적인 예를 보자. 로그를 파일에 쓰는 객체가 인스턴스마다 따로 파일 핸들을 열면, 운영체제가 한 프로세스에 허용하는 핸들 수를 낭비하고 여러 쓰기가 뒤섞여 로그가 깨진다. 데이터베이스 커넥션 풀도 마찬가지다. 재사용할 연결을 선입선출(FIFO) 큐에 담아 순서대로 내주는 풀이 두 벌 생기면, '동시 연결 20개'라는 상한이 사실상 40개가 되어 버린다. 이런 자원은 프로그램에 하나만 있어야 하고, 그 하나를 어디서든 같은 방식으로 꺼내 쓸 수 있어야 한다.

싱글턴은 이 두 요구를 한 번에 만족시킨다. 생성자를 private으로 막아 외부에서 새 인스턴스를 못 만들게 하고, 클래스가 자기 인스턴스를 하나 들고 있다가 전역 접근점으로 내준다.

스레드 안전은 왜 문제가 되나

처음 필요할 때 인스턴스를 만드는 지연 초기화를 순진하게 짜면, 여러 스레드가 동시에 '아직 없네'를 통과해 인스턴스를 둘 이상 만들 수 있기 때문이다. 아래 자바 코드가 전형적인 함정이다.

public class Config {
  private static Config instance;

  public static Config getInstance() {
    if (instance == null) {    // 두 스레드가 동시에 통과할 수 있다
      instance = new Config();  // 인스턴스가 둘 이상 생긴다
    }
    return instance;
  }
}

스레드 A와 B가 거의 동시에 getInstance()를 부르면, 둘 다 instance가 null인 것을 보고 각자 new Config()를 실행한다. 결과적으로 인스턴스가 두 개 생기고, 싱글턴이라는 약속이 깨진다.

고전적인 해법은 두 가지다. 하나는 클래스가 로드될 때 미리 만들어 두는 이른 초기화(eager initialization)로, 경쟁 상태 자체를 없앤다. 다른 하나는 이중 검사 잠금(double-checked locking)이다. 잠금 밖에서 한 번, 잠금 안에서 다시 한 번 null을 검사해 잠금 비용을 줄이는 방식인데, 이때 인스턴스 필드에 volatile을 붙이지 않으면 절반만 초기화된 객체가 다른 스레드에 보일 수 있다. 자바 5부터 volatile 필드에 happens-before 규칙이 적용되면서 이중 검사 잠금이 안전해졌다.

두 스레드가 잠긴 게이트로 들어와 하나만 통과해 단일 인스턴스를 만들고, 두 스레드가 모두 그 하나의 큐브를 가리키는 선화 일러스트
스레드가 몇 개 몰려도 잠금을 통과해 만들어지는 인스턴스는 단 하나 — 모두 그 하나를 나눠 쓴다.

언어별 관용 구현

다행히 현대 언어 대부분은 스레드 안전한 싱글턴을 언어 기능으로 제공한다. 직접 잠금을 짤 일은 거의 없다.

스위프트가 특히 간결하다. 스위프트 공식 문서 The Swift Programming Language의 프로퍼티 장에 따르면, 저장 타입 프로퍼티는 최초 접근 시 지연 초기화되고 여러 스레드가 동시에 접근해도 오직 한 번만 초기화되는 것이 보장된다. 그래서 다음 한 줄이면 별도 잠금 없이 스레드 안전한 싱글턴이 된다.

final class Logger {
  static let shared = Logger()
  private init() {}

  func log(_ message: String) {
    // 파일이나 콘솔에 기록
  }
}

// 어디서든 같은 인스턴스를 쓴다
Logger.shared.log("서버 시작")

자바에서는 조슈아 블로크가 『이펙티브 자바』에서 단일 원소 enum을 싱글턴 구현의 가장 좋은 방법으로 제시한다. JVM이 enum 상수를 한 번만 생성하고, 직렬화나 리플렉션으로 인스턴스가 복제되는 것도 막아 주기 때문이다. 코틀린은 object 선언이 같은 일을 한다. 코틀린 공식 문서의 Object declarations 문서에 따르면 object는 최초 접근 시 지연 초기화되며 그 초기화가 스레드 안전하다. 파이썬은 모듈이 최초 import 때 한 번만 실행되고 이후 캐시되므로, 모듈 수준에 만든 객체가 자연스러운 싱글턴이 된다.

언어관용 구현스레드 안전 근거
스위프트static let shared런타임이 최초 접근 시 1회 초기화 보장
자바단일 원소 enumJVM이 enum 상수 1회 생성
코틀린object 선언최초 접근 시 지연·스레드 안전 초기화
파이썬모듈 수준 객체모듈 최초 import 시 1회 실행

싱글턴이 안티패턴으로 불리는 이유

전역 접근점은 편리하지만 대가가 따른다. 싱글턴을 안티패턴이라 부르는 비판은 대개 다음 세 가지를 향한다.

대안은 의존성 주입(dependency injection)이다. 필요한 객체를 생성자나 매개변수로 넘겨받으면, 인스턴스는 여전히 하나만 유지하면서(수명은 DI 컨테이너나 조립 코드가 관리) 테스트에서는 가짜 객체로 쉽게 바꿀 수 있다. 즉 '인스턴스를 하나만 둔다'는 목표와 '전역 어디서나 직접 꺼내 쓴다'는 접근 방식은 분리할 수 있고, 문제가 되는 쪽은 대개 후자다.

언제 쓰고 언제 피할까

가변 상태가 거의 없고 진짜로 하나여야 하는 자원이면 싱글턴이 무난하다. 읽기 전용 설정, 무상태 유틸리티, 로거가 여기에 해당한다. 이런 대상은 테스트에서 교체할 일이 적고, 전역으로 꺼내 써도 부작용이 작다.

반대로 가변 상태를 여러 모듈이 함께 바꾸거나 단위 테스트가 중요한 도메인 로직이라면, 싱글턴을 피하고 의존성 주입을 택하는 편이 안전하다. 판단이 애매할 때는 이렇게 나눠 보면 된다. 공유하는 상태가 가변이고 그 객체가 테스트 대상이면 주입으로, 상태가 없거나 불변이면 싱글턴으로. 언어가 제공하는 스레드 안전한 기본형(스위프트 static let, 코틀린 object, 자바 enum)을 쓰면 최소한 경쟁 상태라는 함정은 처음부터 피할 수 있다.

더 많은 글 보기 RSS 구독