volatile을 붙이면 무엇이 보이고, 무엇은 여전히 깨지는가

seonwoo_jung·2026년 6월 19일

1. volatile은 왜 필요한가

한 스레드가 값을 바꾸고 다른 스레드가 그 변화를 읽는 코드는 겉보기에는 단순하다. 예를 들어 작업 스레드를 종료하기 위해 boolean runningfalse로 바꾸면 루프도 곧 멈출 것처럼 보인다. 하지만 동기화가 없는 데이터 레이스 상황에서는 그렇게 기대할 근거가 없다. 컴파일러와 CPU는 단일 스레드의 의미를 보존하는 범위에서 명령을 재배치할 수 있고, 각 스레드가 관찰하는 값의 시점도 달라질 수 있기 때문이다.

Java Language Specification(JLS) §17.4는 이런 문제를 Java 메모리 모델의 규칙으로 설명한다. 여기서 volatile은 단순히 “메인 메모리에서 매번 읽는 변수”라기보다, 쓰기와 읽기 사이에 happens-before 관계를 만드는 동기화 수단으로 이해하는 편이 정확하다.

이 글에서는 다음 세 가지를 구분해 보려 한다.

  • volatile 쓰기와 읽기가 어떤 가시성 보장을 만드는가
  • 그 보장이 주변의 일반 변수까지 어떻게 전달되는가
  • 가시성이 확보되어도 왜 복합 연산의 원자성은 보장되지 않는가

volatile의 핵심은 최신 값이라는 막연한 표현보다, 특정 쓰기 이전의 효과를 특정 읽기 이후에 관찰할 수 있게 하는 happens-before 규칙이다.

2. happens-before로 읽는 volatile

happens-before는 두 동작의 실제 실행 시각을 말하는 규칙이 아니다. 한 동작 A가 다른 동작 B보다 happens-before라면, 메모리 모델상 A의 결과가 B에 보이도록 정렬되어야 한다는 뜻이다. JLS §17.4.5는 프로그램 순서, 모니터 잠금 해제와 획득, 스레드 시작과 종료 등과 함께 volatile 규칙을 정의한다.

핵심 규칙은 다음과 같이 요약할 수 있다.

volatile 필드에 대한 쓰기는, 이후 다른 스레드가 수행하는 그 필드의 읽기보다 happens-before다.

여기서 중요한 조건은 “같은 필드”와 “그 쓰기를 관찰하는 이후의 읽기”다. 서로 다른 volatile 필드라고 해서 자동으로 직접 연결되는 것은 아니다. 또한 happens-before는 전이적이므로 프로그램 순서와 결합했을 때 더 큰 범위의 가시성 효과를 만든다.

다음 흐름을 보자.

Writer 스레드                    Reader 스레드

data = 42
   │  프로그램 순서
   ▼
ready = true (volatile)  ─────▶  ready 읽기 (volatile) == true
                                  │  프로그램 순서
                                  ▼
                                data 읽기

Writer에서 data = 42ready = true보다 프로그램 순서상 앞선다. ready에 대한 volatile 쓰기는 Reader의 volatile 읽기보다 happens-before다. Reader에서 data 읽기는 ready 읽기보다 뒤에 있다. 이 세 관계를 전이시키면 Writer의 data = 42가 Reader의 data 읽기보다 happens-before가 된다.

따라서 data 자체가 일반 필드여도, Writer가 데이터를 먼저 완성한 뒤 volatile 플래그를 게시하고 Reader가 플래그를 확인한 뒤 데이터를 읽는 패턴에서는 앞선 쓰기들이 함께 보인다. 흔히 이를 release/acquire와 비슷한 게시 패턴으로 설명한다.

다만 이 설명을 “volatile 변수를 읽으면 세상의 모든 최신 값을 본다”로 넓히면 안 된다. 보장은 해당 volatile 쓰기와 읽기를 연결점으로 삼아 happens-before 경로가 형성된 동작에 적용된다. 그 경로 밖의 경쟁 쓰기까지 하나의 일관된 스냅샷으로 묶어 주지는 않는다.

3. 보장되는 것과 보장되지 않는 것

volatile이 제공하는 효과를 가시성, 순서, 원자성으로 나누면 혼동이 줄어든다.

구분volatile이 제공하는 것한계
가시성volatile 쓰기를 관찰한 읽기는 그 쓰기 이전 효과를 볼 수 있다관련 없는 경쟁 쓰기 전체의 최신 스냅샷을 보장하지 않는다
순서volatile 접근을 경계로 필요한 재배치가 제한된다모든 명령의 전역 실행 순서를 하나로 만들지는 않는다
단일 접근volatile 필드의 개별 읽기와 쓰기는 원자적으로 취급된다읽기-수정-쓰기 복합 연산은 원자적이지 않다
상호 배제없음여러 스레드가 동시에 임계 구역을 실행할 수 있다

가장 자주 틀리는 예는 count++다. countvolatile int여도 증가 연산은 개념적으로 세 단계다.

1. count를 읽는다
2. 읽은 값에 1을 더한다
3. 결과를 count에 쓴다

두 스레드가 동시에 같은 값 10을 읽으면 둘 다 11을 계산해 쓸 수 있다. 각 읽기와 쓰기는 정상적으로 보이지만, 두 증가 중 하나가 사라지는 lost update가 발생한다. 가시성과 원자성은 서로 다른 문제다.

class Counter {
    private volatile int count = 0;

    void increment() {
        count++; // 원자적 증가가 아니다
    }

    int get() {
        return count;
    }
}

이 경우에는 AtomicInteger.incrementAndGet()처럼 원자적 읽기-수정-쓰기 연산을 제공하는 도구나, 여러 상태를 함께 보호할 수 있는 synchronized 또는 Lock이 필요하다.

또 다른 한계는 여러 필드의 불변식을 보호하지 못한다는 점이다. 예를 들어 lower <= upper를 유지해야 하는 두 값이 있을 때 각각을 volatile로 선언해도 두 변경이 하나의 원자적 상태 전이로 묶이지 않는다. Reader는 한 필드의 새 값과 다른 필드의 이전 값을 조합해 볼 수 있다. 이럴 때는 불변 객체 하나를 volatile 참조로 교체하거나 잠금으로 복합 상태를 보호하는 편이 맞다.

record Range(int lower, int upper) {
    Range {
        if (lower > upper) throw new IllegalArgumentException();
    }
}

class RangeHolder {
    private volatile Range range = new Range(0, 10);

    void update(int lower, int upper) {
        range = new Range(lower, upper); // 완성된 상태를 한 번에 게시
    }

    Range snapshot() {
        return range;
    }
}

여기서는 Range가 불변이고, 새 인스턴스를 완성한 뒤 volatile 참조에 기록한다. Reader는 이전 Range 또는 새 Range를 보지만, 두 인스턴스의 필드가 섞인 중간 상태를 보지는 않는다. 단, 여러 Writer가 서로의 갱신을 조건부로 합쳐야 한다면 참조 교체만으로 충분하지 않고 CAS나 잠금이 추가로 필요하다.

4. 종료 플래그와 안전한 게시

volatile이 자연스럽게 맞는 대표 사례는 한 스레드가 쓰고 다른 스레드가 관찰하는 상태 플래그다.

class Worker implements Runnable {
    private volatile boolean running = true;

    @Override
    public void run() {
        while (running) {
            doOneUnit();
        }
    }

    void stop() {
        running = false;
    }

    private void doOneUnit() {
        // 짧은 작업 한 단위
    }
}

stop()의 volatile 쓰기와 루프의 volatile 읽기 사이에 메모리 모델의 연결이 생기므로, 동기화 없는 일반 필드보다 종료 신호를 전달하기에 적합하다. 하지만 이 예시도 운영 코드에서는 몇 가지를 더 봐야 한다.

첫째, 작업 스레드가 블로킹 I/O나 긴 대기 상태에 들어가면 플래그를 다시 읽기까지 오래 걸릴 수 있다. volatile은 스레드를 깨우는 기능이 아니다. 인터럽트를 지원하는 대기라면 Thread.interrupt()와 인터럽트 상태 처리가 더 적합할 수 있다.

둘째, doOneUnit()이 끝나지 않으면 루프 조건을 다시 평가하지 않는다. 값의 가시성이 보장된다는 것과 애플리케이션이 즉시 반응한다는 것은 다르다.

셋째, 종료 과정에서 여러 자원의 정리 순서나 상태 전이가 필요하면 boolean 하나로 표현하기 어려울 수 있다. RUNNING, STOPPING, TERMINATED 같은 상태 전이가 경쟁한다면 CAS나 잠금, CountDownLatch 같은 동기화 도구를 함께 검토해야 한다.

안전한 게시에도 volatile을 사용할 수 있다. Writer가 객체를 완전히 초기화한 뒤 volatile 참조에 저장하고, Reader가 그 참조를 volatile로 읽으면 초기화 과정의 앞선 쓰기들이 Reader에 전달된다.

class ConfigService {
    private volatile Config current;

    void reload() {
        Config loaded = loadAndValidate();
        current = loaded; // 초기화가 끝난 객체를 게시
    }

    Config current() {
        return current;
    }
}

이 패턴의 안전성은 게시된 객체가 이후 변경되지 않거나, 변경 자체도 별도의 동기화 규칙을 따를 때 가장 이해하기 쉽다. volatile 참조는 참조가 가리키는 객체의 모든 미래 변경을 자동으로 thread-safe하게 만들지 않는다. 게시 이후 내부의 가변 컬렉션을 여러 스레드가 동기화 없이 수정하면 다시 데이터 레이스가 생긴다.

5. 어떤 도구를 선택할 것인가

문제를 “다른 스레드에서 보여야 한다”로만 표현하면 volatile을 과하게 선택하기 쉽다. 실제로는 상태를 읽고 쓰는 방식에 따라 도구가 달라진다.

  • 독립적인 플래그나 완성된 불변 스냅샷을 게시한다면 volatile이 잘 맞는다.
  • 숫자 증가, 조건부 갱신, compare-and-set이 필요하다면 AtomicInteger, AtomicReference 같은 원자 클래스를 먼저 검토할 수 있다.
  • 여러 필드를 하나의 불변식으로 보호하거나 여러 동작을 임계 구역으로 묶어야 한다면 synchronizedLock이 더 직접적이다.
  • 스레드 종료, 완료 대기, 생산자-소비자 조정이 목적이라면 interrupt, CountDownLatch, BlockingQueue처럼 목적에 맞는 고수준 도구가 의도를 더 잘 드러낸다.

선택 기준을 한 문장으로 줄이면 다음과 같다.

volatile은 “한 번의 읽기나 쓰기로 표현되는 상태 전달”에는 적합하지만, “현재 값을 바탕으로 다음 값을 결정하는 경쟁 연산”을 혼자 보호하지 못한다.

volatile을 이해할 때 CPU 캐시의 구현 세부부터 시작하면 특정 하드웨어 설명에 매이기 쉽다. Java 코드가 의존해야 하는 계약은 JLS의 메모리 모델이다. 구현은 그 계약을 만족하기 위해 컴파일러 장벽이나 CPU 명령을 활용할 수 있지만, 개발자가 판단할 기준은 happens-before 관계가 실제 코드에 형성되는지 여부다.

마지막으로 점검할 질문은 세 가지다.

  1. Reader가 관찰하는 volatile 읽기는 어떤 Writer의 volatile 쓰기와 연결되는가?
  2. 전달하려는 일반 필드 쓰기는 그 volatile 쓰기보다 프로그램 순서상 앞에 있는가?
  3. 필요한 연산이 단일 읽기·쓰기인가, 아니면 읽기-수정-쓰기 또는 여러 필드의 불변식인가?

앞의 두 질문에 답할 수 있고 세 번째가 단일 상태 전달이라면 volatile이 간결한 선택이 될 수 있다. 세 번째가 복합 연산이라면 가시성만으로는 부족하다.

참고 자료

  • Java Language Specification, §17.4 Memory Model
  • Java Language Specification, §17.4.5 Happens-before Order
  • Java API Documentation, java.util.concurrent.atomic 패키지

0개의 댓글