Volatile 란?

방지환·2026년 1월 7일

Java

목록 보기
11/19

[Java] Volatile 키워드란?

정의

volatile은 Java 변수를 Main Memory에 저장하겠다는 것을 명시하는 키워드이다. 즉, 매번 변수의 값을 Read할 때마다 CPU Cache가 아닌 Main Memory에서 읽고, Write할 때마다 Main Memory에 작성하는 것이다.

등장 배경

CPU Cache와 Main Memory

현대의 컴퓨터는 성능 향상을 위해 CPU Cache를 사용한다. CPU는 Main Memory보다 CPU Cache에 훨씬 빠르게 접근할 수 있기 때문에, 변수를 Main Memory에서 읽어와 CPU Cache에 저장하고, 이후에는 CPU Cache에서 변수를 읽어 처리한다.

CPU Cache와 Main Memory 구조

[Thread 1] -----> [CPU Cache 1] -----> [Main Memory]
[Thread 2] -----> [CPU Cache 2] -----> [Main Memory]

Multi-Thread 환경에서의 문제점

Multi-Thread 환경에서는 각 Thread가 변수를 CPU Cache에 저장하게 되는데, 이로 인해 가시성(Visibility) 문제가 발생한다.

가시성 문제 예시

public class SharedObject {
    public int counter = 0;
}
  1. Thread 1은 counter 값을 읽어와 CPU Cache 1에 저장
  2. Thread 2는 counter 값을 읽어와 CPU Cache 2에 저장
  3. Thread 1이 counter 값을 증가시키고 CPU Cache 1에 저장 (Main Memory에는 아직 반영 안됨)
  4. Thread 2가 counter 값을 읽으면 여전히 이전 값을 읽게 됨
  5. 결과적으로 Thread 2는 Thread 1이 수정한 값을 볼 수 없음

Volatile의 동작 원리

1. Main Memory 직접 접근

volatile 키워드가 붙은 변수는 CPU Cache를 거치지 않고 Main Memory에서 직접 읽고 쓴다.

public class SharedObject {
    public volatile int counter = 0;
}

2. 가시성 보장

한 Thread에서 volatile 변수를 수정하면, 다른 Thread에서 즉시 변경된 값을 볼 수 있다.

[Thread 1] -----> [Main Memory] <----- [Thread 2]
              (직접 읽기/쓰기)

Volatile 사용 예시

예시 1: Flag 변수를 사용한 Thread 제어

volatile 없이 (문제 발생)

public class VolatileExample {
    private boolean running = true;  // volatile 없음
    
    public void stop() {
        running = false;
        System.out.println("Stop 호출됨");
    }
    
    public void run() {
        System.out.println("Thread 시작");
        while (running) {
            // 작업 수행
            // Thread가 멈추지 않을 수 있음!
        }
        System.out.println("Thread 종료");
    }
    
    public static void main(String[] args) throws InterruptedException {
        VolatileExample example = new VolatileExample();
        
        Thread thread = new Thread(example::run);
        thread.start();
        
        Thread.sleep(1000);
        example.stop();  // running을 false로 변경
        // 하지만 thread는 계속 실행될 수 있음!
    }
}

volatile 사용 (정상 동작)

public class VolatileExample {
    private volatile boolean running = true;  // volatile 추가
    
    public void stop() {
        running = false;
        System.out.println("Stop 호출됨");
    }
    
    public void run() {
        System.out.println("Thread 시작");
        while (running) {
            // 작업 수행
        }
        System.out.println("Thread 종료");  // 정상적으로 종료됨
    }
    
    public static void main(String[] args) throws InterruptedException {
        VolatileExample example = new VolatileExample();
        
        Thread thread = new Thread(example::run);
        thread.start();
        
        Thread.sleep(1000);
        example.stop();  // Thread가 정상적으로 종료됨
    }
}

예시 2: Singleton 패턴 (Double-Checked Locking)

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();
                    // 문제: 객체 생성이 완료되기 전에
                    // 다른 Thread가 instance를 읽을 수 있음
                }
            }
        }
        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();
                    // volatile로 인해 완전히 초기화된 객체만 보임
                }
            }
        }
        return instance;
    }
}

예시 3: 상태 플래그 관리

public class TaskRunner {
    private volatile boolean isReady = false;
    private volatile boolean isDone = false;
    
    public void prepareTask() {
        // 준비 작업 수행
        System.out.println("작업 준비 중...");
        isReady = true;  // 다른 Thread에서 즉시 확인 가능
    }
    
    public void executeTask() {
        while (!isReady) {
            // isReady가 true가 될 때까지 대기
        }
        
        System.out.println("작업 실행 중...");
        // 실제 작업 수행
        
        isDone = true;  // 작업 완료 표시
    }
    
    public void checkStatus() {
        while (!isDone) {
            // 작업 완료 대기
        }
        System.out.println("작업 완료 확인!");
    }
}

예시 4: Producer-Consumer 패턴

public class ProducerConsumer {
    private volatile int data = 0;
    private volatile boolean hasData = false;
    
    // Producer Thread
    public void produce(int value) {
        while (hasData) {
            // Consumer가 데이터를 소비할 때까지 대기
        }
        
        data = value;
        hasData = true;  // 데이터 준비 완료
        System.out.println("생산: " + value);
    }
    
    // Consumer Thread
    public int consume() {
        while (!hasData) {
            // Producer가 데이터를 생산할 때까지 대기
        }
        
        int value = data;
        hasData = false;  // 데이터 소비 완료
        System.out.println("소비: " + value);
        return value;
    }
}

Volatile의 특징

1. 가시성(Visibility) 보장

  • 한 Thread에서 변경한 값을 다른 Thread에서 즉시 볼 수 있음
  • CPU Cache를 거치지 않고 Main Memory에서 직접 읽기/쓰기

2. 원자성(Atomicity) 보장 안 함

  • 중요: volatile은 단순 읽기/쓰기에 대해서만 원자성을 보장
  • 복합 연산(예: counter++)은 원자성을 보장하지 않음
public class VolatileCounter {
    private volatile int counter = 0;
    
    public void increment() {
        counter++;  // 원자적이지 않음!
        // 실제로는 3단계: 1. 읽기, 2. 증가, 3. 쓰기
        // 여러 Thread가 동시에 실행하면 값이 손실될 수 있음
    }
}

3. Happens-Before 관계 보장

  • volatile 변수에 대한 쓰기 작업은 이후의 읽기 작업보다 먼저 발생함을 보장
  • 이를 통해 메모리 가시성 문제를 해결
public class HappensBeforeExample {
    private int normalVariable = 0;
    private volatile boolean flag = false;
    
    // Thread 1
    public void writer() {
        normalVariable = 42;  // 1. 일반 변수 쓰기
        flag = true;          // 2. volatile 변수 쓰기
    }
    
    // Thread 2
    public void reader() {
        if (flag) {           // 3. volatile 변수 읽기
            // normalVariable의 값이 42임이 보장됨
            System.out.println(normalVariable);  // 4. 일반 변수 읽기
        }
    }
}

Volatile vs Synchronized

비교표

특징volatilesynchronized
가시성 보장OO
원자성 보장X (단순 읽기/쓰기만 O)O
성능빠름 (Lock 없음)느림 (Lock 사용)
사용 범위변수에만 사용메서드, 블록에 사용
Block하지 않음할 수 있음

Volatile 사용이 적합한 경우

public class VolatileUsage {
    // 1. Flag 변수 (단순 상태 체크)
    private volatile boolean isRunning = true;
    
    // 2. 최근 값 저장 (읽기만 하는 경우)
    private volatile long lastUpdateTime;
    
    // 3. 참조 타입 (객체 교체)
    private volatile Configuration config;
}

Synchronized 사용이 필요한 경우

public class SynchronizedUsage {
    private int counter = 0;  // volatile로는 불충분
    
    // 복합 연산은 synchronized 필요
    public synchronized void increment() {
        counter++;  // 읽기 + 증가 + 쓰기 (3단계)
    }
    
    // 또는 AtomicInteger 사용
    private AtomicInteger atomicCounter = new AtomicInteger(0);
    
    public void incrementAtomic() {
        atomicCounter.incrementAndGet();  // 원자적 연산
    }
}

Volatile의 한계와 대안

1. 복합 연산 처리 불가

문제 상황

public class VolatileProblem {
    private volatile int count = 0;
    
    public void increment() {
        count++;  // Race Condition 발생 가능!
        // 1. count 읽기
        // 2. count + 1 계산
        // 3. 결과를 count에 쓰기
        // → 3단계가 원자적이지 않음
    }
}

해결 방법 1: synchronized 사용

public class SynchronizedSolution {
    private int count = 0;
    
    public synchronized void increment() {
        count++;  // 원자적으로 실행됨
    }
}

해결 방법 2: AtomicInteger 사용

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicSolution {
    private AtomicInteger count = new AtomicInteger(0);
    
    public void increment() {
        count.incrementAndGet();  // 원자적으로 실행됨
    }
    
    public int get() {
        return count.get();
    }
}

2. 여러 변수의 일관성 보장 불가

문제 상황

public class VolatileInconsistency {
    private volatile int x = 0;
    private volatile int y = 0;
    
    public void update() {
        x = 10;  // Thread 1이 여기까지 실행
        // Thread 2가 여기서 x, y를 읽으면 일관성 깨짐
        y = 20;  // Thread 1이 여기 실행
    }
}

해결 방법: synchronized 사용

public class SynchronizedConsistency {
    private int x = 0;
    private int y = 0;
    
    public synchronized void update() {
        x = 10;
        y = 20;  // x, y가 함께 업데이트됨을 보장
    }
    
    public synchronized void read() {
        System.out.println("x: " + x + ", y: " + y);
        // 일관된 상태를 읽음
    }
}

실무 사용 예시

1. 서비스 상태 관리

public class ServiceManager {
    private volatile boolean isServiceActive = false;
    
    public void startService() {
        // 서비스 시작 로직
        System.out.println("서비스 시작 중...");
        // 초기화 작업...
        
        isServiceActive = true;  // 모든 Thread에게 즉시 알림
        System.out.println("서비스 활성화 완료");
    }
    
    public void stopService() {
        isServiceActive = false;  // 모든 Thread에게 즉시 알림
        System.out.println("서비스 비활성화");
    }
    
    public void processRequest() {
        if (!isServiceActive) {
            throw new IllegalStateException("서비스가 활성화되지 않음");
        }
        // 요청 처리...
    }
}

2. 설정 값 Hot Reload

public class ConfigurationManager {
    private volatile Configuration currentConfig;
    
    public ConfigurationManager() {
        this.currentConfig = loadConfiguration();
    }
    
    public void reloadConfiguration() {
        Configuration newConfig = loadConfiguration();
        currentConfig = newConfig;  // 원자적 교체, 즉시 가시적
        System.out.println("설정 리로드 완료");
    }
    
    public Configuration getConfiguration() {
        return currentConfig;  // 항상 최신 설정 반환
    }
    
    private Configuration loadConfiguration() {
        // 설정 파일에서 로드
        return new Configuration();
    }
}

class Configuration {
    private final String dbUrl;
    private final int timeout;
    
    // 불변 객체로 구성
    public Configuration() {
        this.dbUrl = "jdbc:mysql://localhost:3306/db";
        this.timeout = 3000;
    }
}

3. 캐시 무효화 플래그

public class CacheManager {
    private volatile boolean cacheInvalid = false;
    private Map cache = new ConcurrentHashMap<>();
    
    public Object get(String key) {
        if (cacheInvalid) {
            refreshCache();
        }
        return cache.get(key);
    }
    
    public void invalidateCache() {
        cacheInvalid = true;  // 모든 Thread에게 즉시 알림
    }
    
    private synchronized void refreshCache() {
        if (cacheInvalid) {  // Double-checked locking
            // 캐시 갱신 로직
            cache.clear();
            // 새로운 데이터 로드...
            
            cacheInvalid = false;
        }
    }
}

성능 고려사항

Volatile의 성능 특성

  • CPU Cache를 사용하지 않으므로 일반 변수보다 느림
  • 하지만 synchronized보다는 빠름 (Lock이 없음)
  • 읽기 작업이 많고 쓰기 작업이 적을 때 효과적

성능 비교

public class PerformanceComparison {
    private int normal = 0;
    private volatile int volatileVar = 0;
    private int synchronizedVar = 0;
    
    // 가장 빠름
    public void normalAccess() {
        int value = normal;
    }
    
    // 중간 속도
    public void volatileAccess() {
        int value = volatileVar;
    }
    
    // 가장 느림
    public synchronized void synchronizedAccess() {
        int value = synchronizedVar;
    }
}

정리

Volatile을 사용해야 하는 경우

  • 변수를 여러 Thread에서 읽고 쓸 때
  • 단순 읽기/쓰기 작업만 수행하는 경우
  • Flag 변수나 상태 변수를 관리할 때
  • 불변 객체의 참조를 교체할 때

Volatile을 사용하면 안 되는 경우

  • 복합 연산(증가, 감소 등)을 수행할 때
  • 여러 변수의 일관성을 유지해야 할 때
  • 원자성이 필요한 연산을 수행할 때

대안

  • synchronized: 복합 연산과 일관성이 필요한 경우
  • Atomic 클래스: 원자적 연산이 필요한 경우 (AtomicInteger, AtomicLong 등)
  • Lock: 더 복잡한 동기화가 필요한 경우
  • ConcurrentHashMap: Thread-safe한 컬렉션이 필요한 경우

참고 자료

0개의 댓글