Volatile
Multi-thread 환경의 가시성 문제
멀티쓰레드 환경에서 발생하는 대표적인 문제 중 하나는 데이터 불일치 (Data Inconsistency)다. 이것은 각 쓰레드가 성능 최적화를 위해 메인 메모리가 아닌 CPU 레지스터나 캐시에 변수의 복사본을 보관하면서 연산하기 때문에 발생한다.
즉, 한 쓰레드가 값을 변경하더라도 다른 쓰레드가 그 변경을 즉시 보지 못하는 현상이 생긴다.

| 유형 | 설명 |
| 쓰기 지연 (Write Delay) | Thread 1이 공유 변수의 값을 변경하더라도, 그 값이 즉시 메인 메모리에 반영되지 않고 CPU 캐시에만 머무를 수 있다. |
| 읽기 지연 (Read Delay) | Thread 2는 메인 메모리의 값을 읽어오기 때문에, A가 캐시에 변경한 최신 값을 보지 못한다. |
이처럼 한 쓰레드의 변경이 다른 쓰레드에게 보이지 않는 문제를 가시성(visibilty) 문제라고 한다.
Volatile의 두 가지 핵심 보장
volatile 키워드는 이러한 가시성 문제를 해결하기 위해 사용된다. Java 5부터(JVM 개선 이후) volatile은 단순히 "메인 메모리에 직접 접근한다" 수준을 넘어 메모리 가시성과 Happens-Before 관계를 함께 보장된다.
1. 전체 가시성 보장 (Full Visibility Guarantee)
volatile 변수에 대한 쓰기/읽기 연산은 JVM의 메모리 장벽을 통해 다른 쓰레드에서 항상 최신 상태로 보이도록 한다.
| 시점 | 보장 내용 |
| volatile 변수 쓰기 시 | 해당 thread가 volatile 변수에 쓰기 이전에 변경했던 모든 변수가 메인 메모리에 함께 반영된다. |
| volatile 변수 읽기 시 | 해당 thread가 volatile 변수를 읽기 이후에 접근하는 다른 모든 변수를 메인 메모리에서 재차 읽어오도록(re-read) 강제한다. |
즉, volatile 변수의 읽기/쓰기는 단순한 필드 접근이 아니라, "가시성을 위한 메모리 동기화 지점(Synchronization Point)" 역할을 한다.
2. Happens-Before 보장 (순서 재배치 방지)
JVM과 CPU는 성능을 높이기 위해 명령어 재배치(Instruction Reordering)를 수행할 수 있다.
하지만 volatile은 이 재배치를 엄격히 제한하여, 개발자가 작성한 코드의 논리적 순서를 보장한다.
| 시점 | 보장 내용 |
| volatile 변수 쓰기 시 | 쓰기 이전의 모든 연산은 volatile 쓰기 이후로 재배치될 수 없다. (Release Fence) |
| volatile 변수 읽기 시 | 읽기 이후의 모든 연산은 volatile 읽기 이전으로 재배치될 수 없다. (Acquire Fence) |
덕분에 코드의 실행 순서가 보장되어, 멀티 쓰레드 환경에서도 의도한 논리 흐름이 유지된다.
대표적 사용 예시 1 - 상태 플래그(Flag)로 사용할 때
@Volatile private var ready = false
fun main() {
val wait = Thread {
println("[Thread-1] Wait: waiting for ready signal...")
while (!ready) {
// busy-wait (Spin-Wait)
Thread.yield()
}
println("[Thread-1] Wait: detected ready=true, start task")
}
val signal = Thread {
Thread.sleep(500)
ready = true
println("[Thread-2] Signal: ready set to true")
}
wait.start()
signal.start()
wait.join()
signal.join()
}
/*
[Thread-1] Wait: waiting for ready signal...
[Thread-2] Signal: ready set to true
[Thread-1] Wait: detected ready=true, start task
*/
대표적 사용 예시 2 - 캐시 무효화 및 설정 값 변경 시
class ConfigManager {
@Volatile
var refreshInterval: Long = 1000L // 밀리초 단위 리프레시 주기
fun reloadConfig(newInterval: Long) {
refreshInterval = newInterval
}
fun runTask() {
while (true) {
println("현재 주기: $refreshInterval ms")
Thread.sleep(refreshInterval)
}
}
}
fun main() {
val config = ConfigManager()
Thread { config.runTask() }.start()
Thread.sleep(2000)
config.reloadConfig(500) // 메인 메모리에 즉시 반영되어 다른 스레드가 바로 감지함
}
/*
현재 주기: 1000 ms
현재 주기: 1000 ms
현재 주기: 500 ms
현재 주기: 500 ms
현재 주기: 500 ms
...
*/
Volatile의 한계
1. 원자성 부족
volatile은 가시성과 happens-before를 보장하지만, 원자성(Atomicity)을 보장하지 않는다.
즉, counter++, x += 10 같은 연산은 중간 단계에서 다른 쓰레드가 개입할 수 있다.
이 문제를 해결하기 위해선 아래와 같은 대안이 존재한다.
- @Synchronized (락 기반 동기화)
- AtomicInteger, AtomicReference 등 원자 클래스
- 불변 객체를 단일 volatile 참조로 교체하는 방법
2. 성능 오버헤드
volatile은 내부적으로 메모리 장벽을 사용하기 때문에 일부 CPU 캐시 최적화와 명령 재정렬을 제한한다.
즉, volatile은 락보다 가볍지만, 일반 변수보단 느리다.
따라서, volatile은 "하나의 쓰레드만 쓰고, 여러 쓰레드가 읽는" (Single Writer, Multiple Reader) 구조에 가장 이상적이다.
※ 최근엔 주로 thread 탈출 flag 혹은 memory ordering을 보장할 때 사용된다.
잘못된 사용 예시 (@Volatile)
class MyClass {
var years = 0
var months = 0
@Volatile var days = 0
fun update(years: Int, months: Int, days: Int) {
this.years = years
this.months = months
this.days = days
}
fun readSnapshot(): Triple<Int, Int, Int> {
val d = this.days
val m = this.months
val y = this.years
return Triple(y, m, d)
}
}
수정 예시 1. 불변 객체 (Immutable) + 단일 @Volatile 참조
data class Snapshot(val years: Int, val months: Int, val days: Int)
class MyClass {
@Volatile private var snapshot = Snapshot(0, 0, 0)
fun update(years: Int, months: Int, days: Int) {
snapshot = Snapshot(years, months, days) // 통째로 교체 (atomic write)
}
fun readSnapshot(): Snapshot = snapshot // 단일 volatile read
}
수정 예시 2. @Synchronized 사용
class MyClass {
private var years = 0
private var months = 0
private var days = 0
@Synchronized
fun update(y: Int, m: Int, d: Int) {
years = y
months = m
days = d
}
@Synchronized
fun readSnapshot(): Triple<Int, Int, Int> {
return Triple(years, months, days)
}
}
@Synchronized로 완전한 일관성 확보
volatile 만으로는 충분하지 않은 경우, synchronized를 사용하면 가시성과 원자성을 모두 확보할 수 있다.
락을 이용하면 여러 필드를 한꺼번에 안전히 갱신할 수 있고, 모든 쓰레드가 동일한 시점의 일관된 데이터를 보게 된다.
| 구분 | volatile | synchronized |
| 가시성 | ✅ 보장 | ✅ 보장 |
| 명령 재배치 방지 | ✅ 보장 | ✅ 보장 |
| 원자성 | ❌ | ✅ |
| 성능 오버헤드 | 낮음 | 높음 |
| 사용 시점 | 단일 값 공유, 읽기 많은 경우 | 복합 연산, 다중 필드 동기화 필요 시 |
참고 자료
- 코틀린/자바의 volatile에 대해서 | 찰스의 안드로이드
- java volatile : 변수의 가시성과 최적화
Powered By. Gemini
'Backend' 카테고리의 다른 글
| [Backend] Kotlin N:N Chat Application (Blocking Socket) (0) | 2025.10.14 |
|---|---|
| [Backend] Pub-Sub 패턴: 경쟁적 소비 모델 vs. Broadcast (0) | 2025.10.10 |
| [Backend] TCP/IP 4계층 & 1024 이하 Known ports (0) | 2025.09.26 |
| [Backend] Concurrent Collections (동시성 컬렉션) (1) | 2025.09.25 |
| [Backend] Pub/Sub Pattern (1) | 2025.09.24 |