272. 더블 체크드 락킹 (Double-Checked Locking) 안티패턴
⚠️ 이 문서는 싱글톤(Singleton) 객체를 생성할 때 성능을 올리겠다고 개발자들이 썼던 얕은 꼼수(더블 체크드 락킹)가, 어떻게 멀티코어 CPU와 컴파일러의 '코드 재배치(Reordering)' 최적화와 만나 최악의 널 포인터 예외(NullPointerException) 버그를 터뜨리는 안티패턴이 되었는지, 그리고 이를
volatile키워드로 어떻게 해결하는지를 다룹니다.
핵심 인사이트 (3줄 요약)
- 본질: 더블 체크드 락킹(DCL)은 객체가 생성되었는지 락(Lock) 없이 1차로 확인하고, 안 되어 있을 때만 락을 걸고 2차로 확인하여 객체를 생성하는, 동기화 오버헤드를 줄이려는 목적의 디자인 패턴이다.
- 가치 (문제점): 완벽해 보이는 이 논리는, 최신 컴파일러와 CPU 코어가 속도를 높이기 위해 메모리에 값을 쓰는 순서(메모리 가시성)를 뒤죽박죽으로 바꿔버리는 '명령어 재배치(Instruction Reordering)' 현상 때문에 완전히 붕괴되며, 다른 스레드에게 '속이 텅 빈 껍데기 객체'를 리턴해버리는 치명적 버그(안티패턴)를 낳는다.
- 융합: 이 문제를 해결하려면 자바(Java)나 C++ 등에서 변수에
volatile키워드를 반드시 붙여, 컴파일러와 CPU에게 "이 변수와 관련된 작업 순서는 절대로 네 맘대로 섞지 마라!(Memory Barrier)"라고 강제명령을 내려야 한다.
Ⅰ. 개요: 싱글톤(Singleton)과 락(Lock)의 딜레마
애플리케이션 전역에서 단 1개만 존재해야 하는 객체를 만드는 '싱글톤 패턴'을 작성할 때, 다중 스레드 환경에서는 두 스레드가 동시에 if (instance == null)을 통과하여 객체를 2개 만들어버리는 불상사가 생긴다.
-
초보자의 해결책: 메서드 전체에 무식하게 락(Synchronized)을 건다.
- 문제점: 객체를 생성하는 건 맨 처음 1번뿐인데, 이후 100만 번 동안 객체를 가져다 쓸 때마다 스레드들이 락을 기다리며 줄을 서게 되어 엄청난 **성능 저하(병목)**가 발생한다.
-
똑똑한 척하는 개발자의 해결책 (Double-Checked Locking 도입)
- "락을 안 걸고 일단 인스턴스가
null인지 1차로 검사하자.null일 때만 락을 걸고, 혹시 그 찰나에 다른 놈이 만들었을 수 있으니 락 안에서 2차로 또 검사하자!" - 이렇게 하면 객체가 이미 만들어진 후에는 락을 거치지 않으므로 성능이 날아다닐 것이라고 믿었다. (그리고 이것은 악몽의 시작이었다.)
- "락을 안 걸고 일단 인스턴스가
// [ DCL (Double-Checked Locking) 안티패턴 코드 ]
public static Singleton getInstance() {
if (instance == null) { // 1차 체크 (락 없음 - 엄청 빠름!)
synchronized (Singleton.class) { // 락 획득
if (instance == null) { // 2차 체크
instance = new Singleton(); // 🚨 여기서 파국이 발생함!
}
}
}
return instance;
}
📢 섹션 요약 비유: 은행 금고를 만들 때, 매번 철문을 열고 닫기 귀찮으니 금고 밖에서 유리창(1차 체크)으로 안에 돈이 있는지 보고, 돈이 없을 때만 철문 락을 풀고 들어가서 돈을 채워놓자(2차 체크)는 아주 기발하고 효율적인 아이디어처럼 보였습니다.
Ⅱ. 파국의 원인: 명령어 재배치 (Instruction Reordering)
위의 DCL 코드가 왜 완벽한 쓰레기(안티패턴)일까? 문제는 instance = new Singleton(); 이 한 줄의 코드에 숨어 있다.
개발자 눈에는 저 코드가 한 줄이지만, 컴파일러와 CPU(기계)의 눈에는 다음 3단계 명령어로 번역된다.
- (1) 객체를 담을 텅 빈 메모리 공간을 할당한다.
- (2) 그 메모리 공간에 초기값(생성자)을 채워 넣어 객체를 완성시킨다.
- (3) 완성된 공간의 주소를
instance라는 **변수 이름표에 연결(할당)**한다.
🔥 컴파일러의 반란 (최적화) 컴파일러나 CPU는 속도를 높이기 위해 실행 결과만 똑같다면 자기 맘대로 실행 순서를 (1) $\rightarrow$ (3) $\rightarrow$ (2) 로 뒤섞어 버린다! (Instruction Reordering)
[ 대형 사고의 시나리오 ]
- 스레드 A가 락 안으로 들어와서 객체를 만든다. CPU가 순서를 섞어서 (1)빈 공간을 만들고, (3)
instance이름표를 딱 붙였다! (아직 (2)생성자 실행을 안 해서 속이 텅 빈 껍데기 객체다.) - 이 찰나의 순간, 스레드 B가 1차 체크
if (instance == null)구문에 도달한다. - B의 눈에
instance는null이 아니다! (A가 방금 껍데기에 이름표를 붙였으니까). - B는 신나서 락을 패스하고 그 미완성된 껍데기 객체를 그대로 리턴받아 사용하려고 메서드를 호출한다. $\rightarrow$
NullPointerException쾅! 시스템 붕괴.
Ⅲ. 해결책: volatile 키워드와 메모리 배리어
이 황당한 기계의 최적화 반란을 억누르려면 어떻게 해야 할까?
자바(Java) 1.5 이후부터는 변수 선언 앞에 volatile 키워드를 하나 딱 붙여주면 모든 문제가 해결된다.
// 완벽하게 고쳐진 DCL 패턴
private static volatile Singleton instance; // 핵심: volatile 선언!
volatile 의 위대한 2가지 마법
- 메모리 배리어 (Memory Barrier / Fence) 발동: 컴파일러와 CPU에게 **"이 변수를 읽고 쓰는 작업 앞뒤로는 절대 명령어를 섞지(Reordering) 마라! 무조건 (1)->(2)->(3) 정석대로 실행해라!"**라고 채찍질을 가한다. 이제 스레드 B는 완벽히 생성(2)이 끝난 객체만 볼 수 있게 된다.
- 캐시 무효화 (가시성 보장): 각 스레드가 자기 CPU 캐시에 값을 숨겨두지 못하게 하고, 변수를 변경하는 즉시 메인 메모리에 쏴버려서 모든 스레드가 항상 가장 최신의 똑같은 값을 보게(Visibility) 만든다.
*(※ 팁: 자바에서는 이보다 더 완벽하고 안전한 **"Initialization-on-demand holder idiom (내부 정적 클래스 활용)"**이나 **"Enum 싱글톤"*을 쓰는 것이 현대적 표준이다. DCL은 면접 단골 질문이자 과거의 흑역사로 기억된다.)
Ⅳ. 결론
"인간의 논리는 완벽했을지 몰라도, 기계의 탐욕(최적화)을 계산하지 못했다."
더블 체크드 락킹(DCL)의 몰락은 동시성 프로그래밍이 얼마나 기괴하고 무서운 분야인지를 보여주는 역사적 교훈이다. 코드는 분명 순서대로 적혀 있지만 멀티코어 하드웨어는 결코 순서대로 실행해 주지 않는다. 따라서 운영체제와 언어가 제공하는 volatile 같은 최하위 레벨의 동기화 제어 키워드(메모리 배리어)를 명확히 이해하지 못하면, 수백만 번에 한 번씩 원인을 알 수 없는 서버 크래시를 겪으며 평생 밤을 새우게 될 것이다.
📌 관련 개념 맵
- 전제 지식: 싱글톤 패턴 (Singleton Pattern), 락 경합 (Lock Contention)
- 문제의 근원: 명령어 재배치 (Instruction Reordering / Out-of-Order Execution)
- 해결의 열쇠: volatile 키워드, 메모리 배리어 (Memory Barrier)
- 궁극적 해결책: 내부 정적 클래스(Bill Pugh Solution) 또는 Enum을 통한 싱글톤 구현
👶 어린이를 위한 3줄 비유 설명
- 장난감 상자(싱글톤)를 만들 때, 사람들이 자꾸 줄 서서 기다리는 게 싫어서 "상자가 닫혀있는지 창문으로 슬쩍 보고(1차 체크), 비어있을 때만 문을 열자(2차 체크)"는 똑똑한 아이디어를 냈어요.
- 그런데 일꾼(CPU)이 일 효율을 높인다고, 빈 상자부터 먼저 갖다 놓고(이름표 붙임) 장난감은 나중에 채워 넣는 꼼수(순서 섞기)를 부려버렸어요.
- 밖에서 본 다른 친구는 "오! 상자가 있네!" 하고 냅다 빈 상자를 들고 가서 놀려고 하다가 에러가 터져버린 거죠. 이 일꾼에게 "절대 순서 섞지 말고 정석대로 해!"라고 때리는 회초리가 바로
volatile키워드랍니다.