| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 자바
- 프로그래밍기초
- 가비지컬렉션
- 코딩테스트
- 메모리관리
- 개발자취업
- 객체지향
- HashMap
- 알고리즘
- 자바프로그래밍
- 프로그래머스
- 코딩테스트팁
- 파이썬
- 자바개발
- 코딩테스트준비
- 클린코드
- JVM
- 코딩공부
- 멀티스레드
- Java
- 개발공부
- 자바공부
- 정렬
- 자료구조
- 예외처리
- 자바기초
- 코딩인터뷰
- 알고리즘공부
- 백준
- 개발자팁
- Today
- Total
코드 한 줄의 기록
Java volatile과 happens-before 관계: 멀티스레드 메모리 가시성 이해하기 본문
오늘은 Java 개발자라면 반드시 이해해야 하지만, 개념이 좀 복잡해서 넘어가기 쉬운 주제인 volatile 키워드와 happens-before 관계에 대해 우리가 함께 공부하고 정리해보려고 합니다. 저도 처음에는 이 개념들이 정말 헷갈렸는데, 차근차근 이해하다 보니 멀티스레드 프로그래밍의 핵심이 무엇인지 보이더라고요. 그래서 이번 글에서 제가 학습한 내용을 최대한 쉽고 실용적으로 풀어서 설명하려고 합니다.
문제 상황: 멀티스레드 환경에서 왜 문제가 생길까?
먼저 volatile 키워드가 필요한 이유를 알기 위해, 간단한 예시로 시작해봅시다.
여러분이 만든 애플리케이션에 두 개의 스레드가 있다고 생각해봅시다. Thread A가 어떤 변수 값을 변경하고, Thread B가 그 변수를 읽으려고 합니다. 당연히 Thread B가 읽을 때는 Thread A가 변경한 최신 값을 봐야겠죠? 그런데 문제는 컴퓨터의 구조와 Java 메모리 모델 때문에 이것이 보장되지 않을 수 있다는 점입니다.
왜 그럴까요? 각 CPU 코어는 자신만의 캐시를 가지고 있고, 스레드들이 실행될 때 메인 메모리의 데이터를 이 캐시에 복사해서 사용합니다. 성능 최적화를 위해서죠. 그러면 Thread A가 변경한 값이 Thread A가 실행 중인 CPU 캐시에만 있고, Thread B가 실행 중인 다른 CPU의 캐시에는 아직 반영되지 않을 수 있습니다. 결과적으로 Thread B는 오래된 데이터(stale data)를 계속 읽게 되는 것입니다.
이런 상황을 '메모리 가시성 문제(visibility problem)'라고 부릅니다. 이것이 바로 우리가 volatile 키워드를 배워야 하는 이유입니다.
Volatile 키워드 알아보기
이제 volatile이 어떤 문제를 해결하는지 이해해봅시다.
변수를 volatile로 선언하면, Java는 그 변수에 대해 특별한 취급을 합니다. 구체적으로 어떤 일이 일어나는지 설명해보겠습니다.
첫째, 가시성을 보장합니다. volatile 변수에 값을 쓸 때, 그 값은 즉시 메인 메모리에 기록됩니다. 또한 volatile 변수를 읽을 때는 항상 메인 메모리에서 직접 읽습니다. 즉, CPU 캐시를 우회하고 메인 메모리를 직접 접근하는 것입니다. 이렇게 되면 모든 스레드가 동일한 값을 보게 됩니다.
public class SharedObject {
public volatile int counter = 0;
}
위 코드에서 counter가 volatile로 선언되었으므로, 한 스레드가 counter를 변경하면 그 변경사항은 즉시 메인 메모리에 반영되고, 다른 스레드들이 counter를 읽을 때는 항상 최신 값을 메인 메모리에서 읽게 됩니다.
둘째, 메모리 배리어를 제공합니다. volatile의 역할은 단순히 해당 변수에만 국한되지 않습니다. Thread A가 volatile 변수에 쓰기를 수행하고, Thread B가 같은 volatile 변수를 읽을 때, Thread A가 volatile 변수에 쓰기 전에 보이던 모든 변수들도 Thread B에게 보이게 됩니다. 이것은 매우 중요한 특성입니다.
다시 말해, volatile 변수를 통해 이루어지는 메모리 조작은 메모리 배리어를 만들어서 메모리 순서 변경(reordering)을 제한합니다.
Volatile의 중요한 제한사항
하지만 volatile을 사용할 때 주의할 점이 있습니다. 많은 개발자가 착각하는 부분인데, volatile은 원자성(atomicity)을 보장하지 않습니다.
예를 들어보겠습니다.
public class Counter {
public volatile int count = 0;
public void increment() {
count++; // 이 연산은 원자적이지 않습니다!
}
}
위 코드에서 count++는 실제로는 세 가지 연산의 조합입니다: 1. count의 현재 값을 읽기 2. 1을 더하기 3. 결과값을 count에 쓰기 volatile이 이 세 연산을 하나의 원자적 연산으로 만들어주지는 못합니다. 따라서 여러 스레드가 동시에 increment()를 호출하면, 예상하지 못한 결과가 나올 수 있습니다. 이 경우에는 synchronized 키워드나 AtomicInteger를 사용해야 합니다.
Happens-Before 관계: 메모리 일관성의 보증
이제 본격적으로 happens-before 관계에 대해 알아봅시다. 이것은 volatile과 깊은 연관이 있습니다.
Happens-before 관계란 무엇일까요? 간단히 말해, 어떤 액션 A가 액션 B에 대해 happens-before 관계에 있다면, A의 결과가 B에게 반드시 보인다는 보증입니다.
이것은 단순한 시간 순서가 아닙니다. 실제로 A의 실행이 B보다 먼저 끝났다는 뜻도 아닙니다. 대신 메모리 가시성에 관한 보증입니다. A가 하는 메모리 조작의 결과가 B에게 보인다는 의미입니다.
Java의 메모리 모델에서는 여러 가지 happens-before 관계를 정의하고 있습니다. 그 중에서 우리가 지금 관심 있는 것은 volatile 변수와 관련된 happens-before 관계입니다.
Volatile 변수에 관련된 Happens-Before 규칙
- Thread A가 volatile 변수에 쓰기를 수행하고, Thread B가 같은 volatile 변수의 읽기를 수행할 때, A의 쓰기는 B의 읽기에 대해 happens-before 관계를 가집니다.
- 더 나아가, A가 쓰기 전에 했던 모든 메모리 조작도 B의 읽기 이후 모든 코드에 happens-before 관계를 가집니다.
이것을 구체적인 코드로 살펴봅시다.
public class VolatileExample {
static volatile int x = 0;
static int y = 0;
public static void main(String[] args) {
Thread writer = new Thread(() -> {
y = 1; // 1단계: y에 1을 쓰기
x = 1; // 2단계: volatile 변수 x에 1을 쓰기
});
Thread reader = new Thread(() -> {
if (x == 1) { // 3단계: volatile 변수 x에서 읽기
System.out.println(y); // 4단계: y의 값 출력
}
});
writer.start();
reader.start();
}
}
이 예제에서 중요한 점은, reader 스레드가 x == 1이라는 것을 확인했다면, y의 값은 반드시 1이어야 한다는 것입니다. 왜냐하면 writer가 x에 쓰기 전에 이미 y에 1을 썼고, x에 대한 쓰기와 읽기 사이에 happens-before 관계가 성립하기 때문입니다.
Volatile이 아닌 일반 변수라면?
만약 x가 volatile로 선언되지 않았다면 어떻게 될까요? 그러면 reader가 x의 값을 읽은 시점과 writer가 x를 쓴 시점 사이에 명확한 순서 보증이 없습니다. 따라서 reader가 x == 1을 만족하더라도, y가 항상 1이라는 보증이 없습니다. y가 0일 수도 있습니다.
이것이 바로 volatile의 가치입니다. 단순히 가시성만 제공하는 것이 아니라, 메모리 연산의 순서를 보증해주는 것입니다.
다른 Happens-Before 관계들
Java 메모리 모델에서는 volatile 외에도 여러 가지 happens-before 관계를 정의하고 있습니다.
Thread.start()와 Thread.join(): Thread를 시작하기 전의 모든 코드는 그 스레드 내부의 코드에 대해 happens-before 관계를 가집니다. 마찬가지로 스레드 내부의 모든 코드는 join()의 반환 이후 코드에 대해 happens-before 관계를 가집니다.
모니터 락(Monitor Lock): synchronized 블록에 들어가기 전의 모든 코드는 그 블록 내부의 코드에 대해 happens-before 관계를 가집니다.
초기화: 객체의 기본 초기화는 모든 다른 메모리 조작에 대해 happens-before 관계를 가집니다.
이 모든 관계들이 일관되게 동작하기 때문에 Java의 멀티스레드 프로그래밍이 예측 가능하고 안전해질 수 있습니다.
실제 사용 예제: Double-Checked Locking
이제 volatile과 happens-before를 활용한 실제 예제를 봅시다. Double-Checked Locking 패턴은 많은 프로젝트에서 싱글톤 인스턴스를 만들 때 사용하는 패턴입니다.
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 첫 번째 체크 (락 없이)
synchronized(Singleton.class) {
if (instance == null) { // 두 번째 체크 (락 안에서)
instance = new Singleton();
}
}
}
return instance;
}
}
여기서 instance가 volatile로 선언되는 것이 매우 중요합니다. instance가 volatile이 아니었다면, 한 스레드가 instance를 초기화하는 중간에 다른 스레드가 아직 완전히 초기화되지 않은 부분적으로 초기화된 객체를 읽을 수 있었습니다.
volatile 덕분에, instance에 쓰기가 이루어지면서 완전히 초기화된 Singleton 객체가 메인 메모리에 기록되고, 다른 스레드들이 읽을 때는 항상 완전히 초기화된 객체를 읽게 됩니다. 이것이 바로 volatile과 happens-before 관계의 실제 활용입니다.
성능 고려사항
마지막으로 한 가지 중요한 것은, volatile의 사용은 성능상의 트레이드오프가 있다는 것입니다. volatile 변수에 접근할 때마다 메인 메모리에 직접 접근하므로, CPU 캐시에 접근하는 것보다 느립니다. 따라서 volatile을 남용하면 애플리케이션의 성능이 저하될 수 있습니다.
그렇기 때문에 volatile은 정말 필요한 곳에만 사용해야 합니다. 예를 들어, 상태 플래그나 카운터처럼 간단한 값의 가시성이 필요한 경우에 적합합니다. 하지만 복잡한 동기화가 필요하거나 원자성이 중요한 경우에는 synchronized나 Lock, AtomicInteger 등의 다른 메커니즘을 고려해야 합니다.
오늘 우리가 배운 것을 정리하면
- Volatile 키워드는 메모리 가시성 문제를 해결하고, 메모리 배리어를 제공합니다.
- Happens-before 관계는 메모리 연산의 순서를 보증하여 멀티스레드 환경에서 예측 가능한 동작을 보장합니다.
- Volatile 변수에 대한 쓰기와 읽기 사이에는 특별한 happens-before 관계가 성립합니다.
- Volatile이 보장하지 않는 것은 원자성입니다. 원자성이 필요하면 다른 동기화 메커니즘을 사용해야 합니다.
- 성능을 위해 volatile은 필요한 곳에만 신중하게 사용해야 합니다.
멀티스레드 프로그래밍은 어렵지만, 이런 개념들을 제대로 이해하고 있으면 훨씬 더 안전하고 효율적인 코드를 작성할 수 있습니다. 우리 함께 배운 이 내용이 여러분의 자바 개발 여정에 도움이 되길 바랍니다. 그리고 이제 코딩 인터뷰에서 누군가 "volatile이 뭐야?"라고 물어봐도 자신 있게 설명할 수 있을 것 같죠?
행운을 빕니다!
Java 동시성 멀티스레드 제어: ReentrantLock, Semaphore, CountDownLatch
우리가 백엔드 개발을 하면서 가장 많이 마주치는 문제 중 하나가 바로 동시성 이슈입니다. 여러 개의 스레드가 동시에 같은 자원에 접근하려고 할 때, 우리가 제대로 관리하지 않으면 예상치
byteandbit.tistory.com
'JAVA' 카테고리의 다른 글
| Java 멀티스레드의 악몽, 데드락·라이블락·스타베이션 완벽 정리 (0) | 2025.12.06 |
|---|---|
| Java Executors, Future, Callable의 개념과 활용법을 완벽히 이해하자 (0) | 2025.11.23 |
| Java 동시성 멀티스레드 제어: ReentrantLock, Semaphore, CountDownLatch (0) | 2025.11.19 |
| Java Synchronized·Volatile·원자성 완벽 이해하기 (0) | 2025.11.18 |
| Java 스레드 라이프사이클과 Runnable/Thread 완벽 이해하기 (0) | 2025.11.16 |