애플리케이션 어딘가에 “단 하나여야 하는 것”이 있다. 프린터 스풀러는 프로세스마다 하나여야 인쇄 작업이 뒤엉키지 않고, 시스템 설정을 담은 객체가 둘이면 어느 쪽이 진짜인지 알 수 없어진다. 이럴 때 처음 떠오르는 방법은 그냥 전역 변수 하나를 두는 것이다 — 어디서든 접근되고, 하나뿐이니 됐다. 그런데 전역 변수로는 “하나뿐”을 강제할 수 없다. 누군가 new로 또 만들면 그만이다. 게다가 프로그램이 시작하자마자 그 무거운 객체를 생성해 두어야 하는지, 처음 쓸 때까지 미뤄도 되는지도 전역 변수는 답하지 못한다.

싱글턴(Singleton)은 이 두 요구 — 인스턴스가 정확히 하나임을 클래스 스스로 보장하는 것과, 그 하나에 닿는 전역 접근점을 제공하는 것 — 을 클래스 자신의 책임으로 끌어들인 패턴이다. 그리고 이 시리즈에서 가장 조심스럽게 다뤄야 할 패턴이기도 하다. 배우기는 다섯 줄이면 끝나는데, 잘못 쓰면 그 다섯 줄이 시스템 전체에 숨은 결합을 심는다. 이 노트는 패턴을 소개하는 것만큼이나 왜 이 패턴을 함부로 쓰면 안 되는지를 정직하게 다룬다.

무엇이 문제인가 — 동기

시스템 설정을 담은 AppConfig를 생각해 보자. DB 접속 정보, 기능 플래그, 외부 API 키가 들어 있고, 프로그램 곳곳에서 이 값들을 읽는다. 요구는 두 가지다. 첫째, 이 설정 객체는 하나여야 한다. 파일을 읽어 채운 설정이 두 벌 존재하면, A 모듈은 이 사본을 보고 B 모듈은 저 사본을 봐서 동작이 갈린다. 둘째, 어디서든 이 하나에 닿아야 한다. 설정을 읽는 코드가 수십 군데인데, 그때마다 객체를 인자로 물려주는 건 번거롭다.

전역 변수를 쓰면 두 번째는 풀리지만 첫 번째가 안 풀린다. public static AppConfig config를 두어도 누군가 new AppConfig()를 또 호출하면 두 번째 인스턴스가 태어난다. 언어는 그것을 막지 않는다. “하나뿐”이라는 불변식이 관례에만 의존하는 것이다 — 팀 전체가 “config는 그 전역 것만 써”라고 약속해야 지켜지는데, 약속은 새 팀원이 오거나 급한 밤에 깨진다.

또 하나. 생성 시점의 문제가 있다. AppConfig가 설정 파일을 파싱하고 원격 서버에서 플래그를 당겨 오느라 무거운 객체라면, 프로그램이 뜨자마자 만들어 두는 것(이른 초기화)과 처음 필요해질 때 만드는 것(지연 초기화)은 기동 시간과 자원 사용에서 다르다. 전역 변수의 초기화 순서는 통제하기 어렵고, 특히 여러 전역이 서로를 참조하면 초기화 순서에 따라 터진다.

싱글턴은 이 셋을 한 클래스 안으로 가둔다. 생성자를 막아 외부의 new를 원천 봉쇄하고, 자기 인스턴스를 자기가 붙들며, 그 하나를 돌려주는 통로 하나만 연다. “하나뿐”이 관례가 아니라 컴파일러가 강제하는 규칙이 된다.

판단 기준: “이 객체가 둘 이상 존재하면 논리적으로 틀린가?”가 참이고, 그 유일성을 클래스 밖의 관례에 맡기기 불안하면 싱글턴을 의심한다. 함정: “하나만 있으면 편하다”와 “하나여야만 옳다”는 다르다 — 편의로 싱글턴을 고르면 전역 상태의 대가만 떠안는다.

패턴의 구조 — 누가 무엇을 하나

싱글턴은 배역이 하나뿐이다. 협력이랄 것도 없다 — 클래스가 자기 자신을 관리한다. 그래서 구조는 단순하지만, 그 단순함 안에 세 가지 장치가 맞물려 있다.

private 생성자. 이것이 패턴의 심장이다. 생성자를 private으로 막으면 클래스 바깥의 누구도 new를 호출할 수 없다. 유일성을 관례가 아니라 언어 규칙으로 못박는 장치가 바로 이 한 줄이다. 생성자를 막지 않은 싱글턴은 싱글턴이 아니다 — 그냥 전역 변수에 접근자를 붙인 것이다.

자기 자신을 담는 static 필드. 클래스는 자기 유일한 인스턴스를 static 필드에 붙들고 있다. 인스턴스별(instance)이 아니라 클래스별(static)로 하나 존재하므로, 이 필드가 “그 하나”의 거처다.

getInstance — 유일한 통로. 외부는 생성자를 못 쓰니, 인스턴스를 얻는 길은 static 메서드 하나뿐이다. 이 메서드가 “이미 있으면 그걸 주고, 없으면 만들어서 준다”는 판단을 캡슐화한다. 여기가 지연 초기화와 동시성 제어가 들어앉는 자리다. 그리고 이 메서드가 곧 전역 접근점이다 — 어디서 부르든 같은 하나를 돌려준다.

정리하면 싱글턴은 “생성을 막고(private 생성자), 하나를 붙들고(static 필드), 그 하나만 내주는(getInstance) 클래스”다. 협력자가 없는 대신, 이 셋이 어긋나면 유일성이 깨진다. 특히 여러 스레드가 getInstance를 동시에 부를 때 “없으면 만든다”의 판단이 겹쳐 인스턴스가 둘 생기는 사고가 패턴 구현의 가장 흔한 함정이다.

판단 기준: 세 장치 중 하나라도 빠졌는지 본다 — 생성자가 열려 있으면 유일성이 관례로 후퇴하고, getInstance가 동시성을 고려 안 하면 멀티스레드에서 유일성이 깨진다. 함정: “static 메서드로 인스턴스 준다”만 흉내 내고 생성자를 안 막으면, 봉인 없는 금고다.

코드로 — 패턴이 자라나는 과정

전역 변수에서 출발해 생성자를 봉인하고, 지연 초기화와 동시성을 차례로 얹은 뒤, 자바가 이 모든 씨름을 언어 차원에서 끝내 주는 지점까지 따라간다.

Refactoring Step 전역 변수 — 하나를 '강제'하지 못한다
1/5
public class AppConfig {    public String dbUrl;    public boolean featureX;    // 생성자가 열려 있다.}// 어디선가 이렇게 쓴다.public class GlobalHolder {    public static AppConfig config = new AppConfig();  // 전역 하나}// 하지만 아무도 이걸 못 막는다 —AppConfig another = new AppConfig();   // 두 번째 인스턴스가 태어난다// "하나뿐"이 팀의 약속에만 의존한다. 약속은 깨진다.

두 갈래를 짚어 둔다. 지연이 필요 없으면(객체가 가볍거나 어차피 항상 쓰인다면) 두 번째 스텝의 이른 초기화 static final 한 줄이 가장 단순하고 안전하다. 지연이 필요하면 직접 if (instance == null)을 짜서 동시성과 씨름하지 말고 holder idiom이나 enum으로 밀어라 — 자바에서는 언어가 이미 그 씨름을 끝내 놓았다. C++ 원서 시절 double-checked locking으로 골머리를 앓던 문제가, 자바에서는 클래스 로딩 규약과 enum으로 대부분 사라진다.

판단 기준: 인스턴스가 무겁고 안 쓰일 수도 있으면 지연(holder/enum), 항상 쓰이면 이른 초기화. 함정: if (instance == null)을 손으로 짜면서 synchronized를 빠뜨리는 것 — 멀티스레드에서 유일성이 조용히 깨지고, 재현이 들쭉날쭉해 디버깅이 지옥이 된다.

결과 — 무엇을 얻고 무엇을 내주나

싱글턴이 주는 것은 명확하다. 유일성이 클래스 규칙으로 보장되고, 전역 접근점이 생기며, 지연 초기화로 생성 시점을 통제할 수 있다. 여기까지는 동기 그대로다. 문제는 대가인데, 이 패턴은 GoF 카탈로그에서 대가가 가장 논쟁적인 패턴이다. 그냥 “복잡도가 는다” 수준이 아니라, 싱글턴 자체가 안티패턴으로 취급되어야 하는가라는 논쟁의 중심에 있다. 정직하게 하나씩 짚는다.

첫째, 싱글턴은 전역 상태다. 이름만 클래스일 뿐, getInstance()로 어디서든 값을 읽고 바꿀 수 있다면 그것은 전역 변수와 같은 성질을 갖는다. 우리가 수십 년간 전역 변수를 경계해 온 이유가 그대로 상속된다 — 누가 언제 그 상태를 바꿨는지 추적이 어렵고, 프로그램 곳곳이 그 하나의 상태에 암묵적으로 얽힌다. 싱글턴이 불변(읽기 전용 설정)이면 이 죄가 가볍지만, 가변 상태를 담는 순간 전역 가변 상태라는 가장 위험한 물건이 된다.

둘째, 테스트가 어려워진다. 이것이 실무에서 싱글턴을 미워하게 되는 결정적 이유다. getInstance()를 직접 부르는 코드는 그 싱글턴에 하드코딩으로 묶인다. 단위 테스트에서 가짜 설정이나 목(mock)으로 바꿔 끼울 수가 없다 — 생성자가 막혀 있어 주입이 불가능하고, static 필드는 테스트 사이에 상태가 새어 다음 테스트를 오염시킨다. “이 함수를 테스트하려면 진짜 DB 싱글턴이 떠 있어야 한다”는 상황이 여기서 나온다.

셋째, 결합이 숨는다. 어떤 클래스가 AppConfig.getInstance()를 몸속에서 부르면, 그 의존은 생성자 시그니처에도, 메서드 인자에도 드러나지 않는다. 겉보기엔 아무에게도 의존하지 않는 깨끗한 클래스인데, 실제로는 전역 하나에 묶여 있다. 이 보이지 않는 의존은 코드를 읽는 사람을 속인다 — 오브젝트 1장의 표현을 빌리면, 의존성이 시그니처라는 정직한 통로가 아니라 뒷문으로 숨어든 것이다.

여기서 대안을 제시하는 게 정직하다. 위 세 죄의 뿌리는 유일성이 아니라 전역 접근점에 있다. “인스턴스가 하나”인 것은 죄가 아니다 — 죄는 “그 하나를 아무 데서나 getInstance로 끌어 쓰는” 방식이다. 그래서 현대의 답은 **의존성 주입(DI)**이다. 인스턴스를 하나만 만드는 일은 유지하되, 그것을 getInstance로 꺼내 쓰지 않고 필요한 객체의 생성자로 넘겨준다. 스프링 같은 DI 컨테이너에서 빈(bean)이 기본적으로 싱글턴 스코프인 것이 정확히 이 그림이다 — 컨테이너가 인스턴스 하나를 관리해 유일성은 얻고, 각 객체는 그것을 생성자로 주입받아 전역 접근점 없이 쓴다. 그러면 테스트에서는 가짜를 주입하면 되고, 의존은 생성자에 정직하게 드러난다. 유일성은 살리고 전역 접근점은 버리는 것이다.

판단 기준: 싱글턴을 놓기 전에 “유일성만 필요한가, 전역 접근점까지 필요한가”를 나눠 묻는다. 유일성만 필요하면 인스턴스를 하나 만들어 DI로 주입하라 — getInstance를 몸속에서 부르지 마라. 함정: 가변 상태를 싱글턴에 담는 것 — 전역 가변 상태는 동시성 버그와 테스트 오염의 온상이고, 이 순간 싱글턴은 편의 도구에서 부채로 바뀐다.

언제 쓰고, 언제 과용인가

싱글턴이 그나마 정당한 자리는 좁다. 진짜로 하나여야 하는 물리적/논리적 자원 — 하드웨어 프린터 스풀러, 프로세스 단위 로그 파일 핸들, 애플리케이션 전체가 공유하는 불변 설정처럼, 둘이 존재하면 논리적으로 모순인 것. 그리고 그 상태가 불변이거나 스레드 안전하게 관리되어, 전역 가변 상태의 죄를 짓지 않는 경우. 자바 표준의 Runtime.getRuntime()이 그 예다 — JVM 하나에 런타임 하나라는 사실이 자연스럽고, 남용의 여지가 적다.

과용의 냄새는 훨씬 흔하고 강하다. 이 패턴은 가장 자주 남용되는 패턴이라는 오명을 안고 있다. “여기저기서 쓰니까 전역으로 두면 편하겠다”는 이유로 서비스 객체, 유틸리티, 캐시, DB 연결을 싱글턴으로 만드는 순간, 앞 절의 세 죄를 그대로 떠안는다. 편의가 동기라면 그건 싱글턴을 쓸 이유가 아니라 DI를 쓸 이유다. 특히 가변 상태를 담은 싱글턴, 비즈니스 로직을 든 싱글턴은 거의 항상 잘못된 선택이다 — 전자는 전역 가변 상태고, 후자는 그 로직을 테스트에서 갈아 끼울 수 없게 만든다.

경계할 유혹 하나가 더 있다. “전역에서 하나로 관리하면 편하다”는 사고는 다른 패턴에도 스며든다. 추상팩토리(Abstract Factory)의 팩토리는 대개 애플리케이션에 하나만 있으면 되므로 싱글턴으로 구현하고 싶어진다 — 이때도 팩토리를 getInstance로 전역에서 끌어 쓸지, 주입할지를 같은 기준으로 판단해야 한다. [감시자]에서도 이벤트를 중앙에서 관리하는 전역 이벤트 버스를 싱글턴으로 두려는 유혹이 흔한데, 전역 이벤트 버스는 “누가 이 이벤트를 구독하는가”를 코드에서 지워 결합을 숨기는 전형적 함정이다. “전역으로 하나 두면 편하다”는 생각이 들 때가 가장 위험한 순간이라는 걸 기억하면 된다.

판단 기준: “둘이면 논리적으로 틀린가”가 참이고 그 상태가 불변에 가까울 때만 싱글턴을 고려하되, 그마저도 getInstance 전역 접근보다 DI 주입을 먼저 검토한다. 함정: 상태가 없는 유틸 메서드 묶음을 싱글턴으로 만드는 것 — 상태가 없으면 그냥 static 메서드거나 무상태 객체를 주입하면 되지, 인스턴스 하나를 붙들 이유가 없다.

싱글턴은 이 서재에서 유일하게 “가급적 쓰지 마라”에 가까운 조언을 붙이는 패턴이다. 배우기 쉬워 가장 먼저 손이 가지만, 그 쉬움이 함정이다. 이 패턴을 안다는 것은 “이렇게 만든다”를 아는 게 아니라, **“이걸 쓰면 무엇을 잃는지, 그리고 대개 DI로 대신할 수 있다”**를 아는 것이다.

다음으로 어댑터