Double-Checked Locking에서 Volatile이 필수

방지환·2026년 1월 7일

Java

목록 보기
12/19

Double-Checked Locking에서 Volatile이 필수인 이유

문제 상황: Volatile 없이 사용하는 경우

public class Singleton {
    private static Singleton instance;  // volatile 없음 - 문제 발생!
    
    public static Singleton getInstance() {
        if (instance == null) {           // 첫 번째 체크 (동기화 밖)
            synchronized (Singleton.class) {
                if (instance == null) {   // 두 번째 체크 (동기화 안)
                    instance = new Singleton();  // 문제 발생 지점
                }
            }
        }
        return instance;
    }
}

왜 문제가 발생할까?

객체 생성 과정의 3단계

instance = new Singleton()은 실제로 3단계로 나뉩니다:

// instance = new Singleton(); 의 실제 동작

// 1단계: 메모리 할당
memory = allocate();

// 2단계: 생성자 호출, 객체 초기화
constructorCall(memory);

// 3단계: instance 변수가 메모리 주소 참조
instance = memory;

명령어 재정렬(Instruction Reordering) 문제

JVM과 CPU는 성능 최적화를 위해 명령어 순서를 재정렬할 수 있습니다:

// 최적화로 인해 순서가 바뀔 수 있음!

// 1단계: 메모리 할당
memory = allocate();

// 3단계: instance 변수가 메모리 주소 참조 (초기화 전!)
instance = memory;

// 2단계: 생성자 호출, 객체 초기화
constructorCall(memory);

구체적인 문제 시나리오

// Thread A가 실행 중
if (instance == null) {
    synchronized (Singleton.class) {
        if (instance == null) {
            // 1. 메모리 할당
            // 2. instance = 메모리 주소 (초기화 안 된 상태!)
            // ← 여기서 Thread B가 개입!
            // 3. 생성자 호출 (아직 실행 안됨)
        }
    }
}

// Thread B가 실행
if (instance == null) {  // instance != null (메모리 주소는 할당됨)
    // synchronized 블록을 건너뜀
}
return instance;  // 초기화되지 않은 객체 반환! - 버그 발생!

해결책: Volatile 사용

public class Singleton {
    private static volatile Singleton instance;  // volatile 필수!
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Volatile이 해결하는 방법

1. 명령어 재정렬 방지

volatile은 happens-before 관계를 보장합니다:

// volatile을 사용하면 순서가 보장됨

// 1단계: 메모리 할당
memory = allocate();

// 2단계: 생성자 호출, 객체 초기화 (반드시 먼저!)
constructorCall(memory);

// 3단계: instance 변수가 메모리 주소 참조
instance = memory;  // volatile 쓰기

2. 메모리 가시성 보장

  • Thread A가 instance에 쓰기 작업을 하면
  • Thread B는 완전히 초기화된 객체만 볼 수 있음

실제 동작 흐름 비교

Volatile 없는 경우 (위험)

[Thread A]
1. instance == null 체크 (true)
2. synchronized 진입
3. instance == null 체크 (true)
4. 메모리 할당
5. instance = 메모리주소 (초기화 안됨!)[Thread B 개입]
   6. instance == null 체크 (false) ← 문제!
   7. 초기화 안된 객체 반환 ← 버그!8. 생성자 호출 (너무 늦음)

Volatile 있는 경우 (안전)

[Thread A]
1. instance == null 체크 (true)
2. synchronized 진입
3. instance == null 체크 (true)
4. 메모리 할당
5. 생성자 호출 (완전히 초기화!)
6. instance = 메모리주소 (volatile 쓰기)[Thread B]
   7. instance == null 체크 (false)
   8. 완전히 초기화된 객체 반환 ← 안전!

실제 버그 재현 코드

public class SingletonBugDemo {
    private static Singleton instance;  // volatile 없음
    
    public static void main(String[] args) {
        // 여러 Thread가 동시에 getInstance 호출
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                Singleton s = Singleton.getInstance();
                // 초기화되지 않은 객체의 필드에 접근하면
                // NullPointerException이나 이상한 값 발생 가능
                System.out.println(s.getData());
            }).start();
        }
    }
}

class Singleton {
    private String data;
    
    private Singleton() {
        // 시간이 걸리는 초기화
        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        this.data = "Initialized";
    }
    
    public String getData() {
        return data;  // null이 반환될 수 있음!
    }
}

대안: Lazy Holder 패턴 (Volatile 불필요)

Double-Checked Locking이 복잡하다면, Lazy Holder 패턴을 사용할 수 있습니다:

public class Singleton {
    // private 생성자
    private Singleton() {}
    
    // static 내부 클래스
    private static class LazyHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return LazyHolder.INSTANCE;
    }
}

장점:

  • volatile 키워드 불필요
  • JVM의 클래스 로더 메커니즘이 Thread-safe를 보장
  • getInstance() 호출 시점에 LazyHolder가 로드되어 Lazy Initialization 달성
  • 더 간단하고 안전함

대안: Enum 싱글톤 (가장 권장)

public enum Singleton {
    INSTANCE;
    
    private String data;
    
    Singleton() {
        this.data = "Initialized";
    }
    
    public String getData() {
        return data;
    }
    
    public void doSomething() {
        System.out.println("Doing something");
    }
}

// 사용
Singleton.INSTANCE.doSomething();

장점:

  • 가장 간결함
  • Thread-safe 보장
  • 직렬화/역직렬화 문제 없음
  • 리플렉션 공격 방어

정리

패턴Volatile 필요?복잡도안정성권장도
Double-Checked Locking필수높음주의 필요중간
Lazy Holder불필요중간높음높음
Enum불필요낮음매우 높음매우 높음

결론:

  • Double-Checked Locking을 사용한다면 volatile반드시 필요
  • 하지만 가능하면 Lazy Holder 또는 Enum 패턴을 사용하는 것을 권장
  • Enum 싱글톤이 가장 간결하고 안전한 방법입니다!

0개의 댓글