자바 메모리 가시성과 Happens Before 기반 스레드 동기화 정리

JV-CO-07calendar_today2026-02-05 10:56#Java #Level2

멀티스레드에서 반복되는 핵심 문제

멀티스레드 버그는 보통 가시성 문제에서 시작합니다.

가시성 문제는 한 스레드의 변경이 다른 스레드에 제때 보이지 않는 현상입니다.

원인은 대부분 동기화 규칙 부재입니다.


자바 메모리 모델의 기본 구조

자바는 스레드마다 **작업 메모리(로컬 캐시)**를 가질 수 있습니다.

모든 스레드가 공유하는 공간은 **메인 메모리(힙 등)**입니다.

  • 작업 메모리의 값은 메인 메모리에 즉시 반영되지 않을 수 있습니다.
    • CPU 캐시와 버퍼링으로 일괄 반영이 일어납니다.
    • 성능 최적화 목적의 지연 반영이 가시성 버그를 만듭니다.

코드 순서와 실제 실행 순서의 분리

소스 코드의 실행 순서는 멀티스레드에서 그대로 보장되지 않습니다.

  • 실행 순서를 바꾸는 주요 요인들입니다.
    • CPU 캐시로 인해 최신 값이 아닌 값을 읽을 수 있습니다.
    • 컴파일러 최적화로 명령어가 재배치될 수 있습니다.
    • JIT 최적화로 재정렬이 더 공격적으로 적용될 수 있습니다.

이 문제를 통제하는 규칙이 happens-before입니다.


happens-before의 의미

happens-before 관계가 성립하면 앞의 쓰기가 뒤의 읽기에 보입니다.

happens-before 관계가 없으면 최신 값 가시성을 기대할 근거가 없습니다.

  • 자주 쓰는 happens-before 기준입니다.
    • synchronized에서 락 해제는 이후 같은 락 획득보다 먼저입니다.
    • volatile write는 이후 volatile read보다 먼저입니다.
    • Thread.start() 이전 작업은 새 스레드 시작 이후보다 먼저입니다.
    • **Thread.join()**으로 기다린 스레드의 종료 전 작업은 join 이후보다 먼저입니다.
    • 클래스 초기화(정적 초기화) 완료는 해당 클래스를 사용하는 스레드에 안전하게 공개됩니다.

일반 변수의 한계

일반 변수는 “동기화 시점”이 정의되지 않습니다.

즉, 메인 메모리 반영 시점과 다른 스레드 관측 시점이 보장되지 않습니다.

  • 위험해지는 조건입니다.
    • 여러 스레드가 같은 변수를 읽고 씁니다.
    • happens-before 관계가 없습니다.
    • 명시적 동기화가 없습니다.

스레드 시작과 종료에서의 동기화 포인트

**Thread.start()**는 대표적인 공개 안전 지점입니다.

  • start 직전 상태와 start 직후 관측 사이에 규칙이 생깁니다.
    • 부모 스레드가 start 이전에 수행한 작업은 새 스레드에 보이도록 정의됩니다.
    • 새 스레드는 동기화된 기준으로 값을 로딩하게 됩니다.

다만 “이미 실행 중인 다른 스레드들까지 함께 동기화”되는 보장은 없습니다.

기존 스레드 간 가시성은 별도의 happens-before가 필요합니다.

**Thread.join()**은 종료 기반 동기화 지점입니다.

  • join 이후에는 대상 스레드의 종료 전 작업이 현재 스레드에 보이도록 정의됩니다.

volatile의 역할과 장단점

volatile은 메모리 가시성을 제공하는 키워드입니다.

volatile 변수는 캐시 값이 아니라 메인 메모리 기준으로 읽고 쓰도록 강제합니다.

  • volatile의 효과입니다.

    • 읽기 시점마다 최신 값이 보이도록 규칙이 걸립니다.
    • 컴파일러와 CPU의 재정렬이 제한됩니다.
    • happens-before 관계를 만들 수 있습니다.
  • 비용과 주의점입니다.

    • 메인 메모리 접근이 늘어 성능 비용이 생길 수 있습니다.
    • 가시성만 보장하고 원자성은 보장하지 않습니다.

volatile만으로 안전하지 않은 대표 케이스

복합 연산은 원자 연산이 아닙니다.

따라서 volatile만으로 레이스가 막히지 않습니다.

  • 대표적인 위험 연산입니다.
    • count++
    • count += 1
    • x = x + y 같은 읽기-계산-쓰기 형태

이 경우 필요한 선택지는 아래입니다.

  • synchronized
  • Lock
  • Atomic 클래스

volatile이 적합한 전형적인 사용처

“한 스레드만 쓰고 여러 스레드가 읽는 구조”에 적합합니다.

즉, 최신 값의 가시성만 확보하면 되는 신호 변수에 적합합니다.

  • 대표 사례입니다.
    • 종료 플래그
    • 상태 플래그
    • 구성 리로드 트리거

예시입니다.

  • 종료 플래그 예시입니다.
    • volatile boolean running = true;
    • 작업 스레드는 while (running)으로 루프를 실행합니다.
    • 종료 스레드는 running = false로 종료 신호를 보냅니다.

synchronized, Lock, Atomic 선택 기준

동기화 요구사항을 분해하면 선택이 쉬워집니다.

  • synchronized가 적합한 상황입니다.

    • 임계구역 보호가 필요합니다.
    • 여러 필드의 일관성을 한 덩어리로 보장해야 합니다.
    • 구현 단순성이 중요합니다.
  • Lock이 적합한 상황입니다.

    • 타임아웃, 공정성, 조건 변수 등 고급 제어가 필요합니다.
    • 락 획득과 해제를 더 세밀하게 다루어야 합니다.
  • Atomic이 적합한 상황입니다.

    • 단일 변수의 원자 연산이 핵심입니다.
    • 카운터, 플래그의 CAS 기반 갱신이 필요합니다.

클래스 로딩과 정적 초기화의 공개 안전성

정적 초기화는 클래스 초기화 과정에서 한 번만 실행됩니다.

그리고 초기화 완료 결과는 다른 스레드에 안전하게 공개되도록 규칙이 잡혀 있습니다.

  • 활용 패턴입니다.
    • 초기화 지연 홀더 패턴
    • 정적 내부 클래스 기반 싱글턴

정적 초기화 이후의 상태 변경은 별개 문제입니다.

정적 필드 변경은 별도의 동기화가 필요할 수 있습니다.


디버깅이 재현성을 깨는 이유

브레이크포인트는 실행을 멈추고 타이밍을 크게 바꿉니다.

그 결과 버그가 사라지는 착시가 생깁니다.

  • 흔한 변화 요인입니다.
    • 스케줄링 순서 변화
    • JIT 최적화 조건 변화
    • 특정 시점의 메모리 장벽 효과 동반 가능

“브레이크포인트가 동기화를 보장한다”로 일반화하면 위험합니다. (대개는 타이밍 교란 영향이 더 큽니다)


System.out.println이 동기화처럼 보이는 현상

System.outPrintStream입니다.

PrintStream의 많은 메서드는 내부적으로 동기화를 사용합니다.

그래서 println을 넣으면 문제가 줄어드는 것처럼 보일 수 있습니다.

이 방식은 해결책이 아닙니다.

출력 코드는 타이밍을 바꾸는 부작용이 커서 재발 가능성이 높습니다.


결론

멀티스레드에서 확인할 질문은 하나입니다.

이 읽기와 쓰기 사이에 happens-before 관계가 존재하는가입니다.

  • 빠른 선택 가이드입니다.

    • 가시성만 필요하면 volatile입니다.
    • 복합 연산과 일관성이 필요하면 synchronized 또는 Lock입니다.
    • 원자 카운터가 필요하면 Atomic입니다.
    • 스레드 경계는 start, join으로 규칙을 확보합니다.
  • 피해야 할 착시입니다.

    • 디버거와 println은 버그를 숨길 수 있습니다.
    • 재현이 안 되면 동기화가 된 것이 아니라 환경이 바뀐 것입니다.

레이스 컨디션이 만들어지는 전형적 상황

  • 파일 처리 스레드가 큐에 이벤트를 추가합니다.
  • UI 스레드가 큐에서 이벤트를 삭제합니다.
  • 둘이 같은 시점에 같은 링크를 건드리면 아래가 발생합니다.
    • 조회 중에 노드가 끼어듭니다.
    • 삭제 중에 노드가 다시 연결됩니다.
    • 결과적으로 리스트가 찢어지거나 루프가 생길 수 있습니다.

임계 영역은 어디로 잡아야 하나

  • 임계 영역은 “공유 불변식”이 깨지는 구간입니다.

  • 연결 리스트의 불변식은 아래처럼 정리할 수 있습니다.

  • 불변식 예시

    • prev.next == this
    • next.prev == this
    • head.nexttail.prev가 항상 일관됩니다.
    • counter가 실제 노드 개수와 일치합니다.
  • 즉 아래가 동시에 바뀌는 구간이 임계 영역입니다.

    • 링크 갱신
    • head tail 갱신
    • 길이 카운터 갱신

예제 코드로 보는 레이스 포인트

노드 추가 appendNode

java
public boolean appendNode(String name) {
    UserData newUser = new UserData(name);
    newUser.prev = tail.prev;
    newUser.next = tail;
    tail.prev.next = newUser; // A Start
    tail.prev = newUser;
    ++counter; // A End
    return true;
}
  • 여기서 위험한 지점은 아래입니다.
    • tail.prev.next = newUser
    • tail.prev = newUser
    • ++counter
  • 이 구간은 “작은 것처럼 보여도” 하나의 트랜잭션입니다.
  • 중간에 끼어들면 tail 체인이 깨질 수 있습니다.

counter도 같이 깨집니다

  • counter++는 원자 연산이 아닙니다.
  • 그래서 동기화 없이 쓰면 값이 틀어질 수 있습니다.
  • counter는 아래 중 하나가 필요합니다.
    • 임계 영역 안에서만 갱신
    • AtomicInteger 사용

노드 삭제 removeAtHead

java
public UserData removeAtHead() {
    if (isEmpty()) return null;
    UserData node = head.next; // 임계영역 Start
    node.next.prev = head;
    head.next = node.next;
    --counter; // 임계영역 End
    return node;
}
  • 여기서도 링크 2개와 카운터 1개가 한 묶음입니다.
  • 이 도중에 append가 끼면 아래가 나옵니다.
    • 삭제한 노드가 다시 참조됩니다.
    • head.next가 잘못된 노드를 가리킵니다.
    • 리스트가 끊기거나 순환할 수 있습니다.

join이 있을 때는 왜 멀쩡해 보이나

  • producer.join()을 하면 생산이 끝난 뒤 소비가 시작됩니다.
  • 그래서 동시에 같은 링크를 건드릴 일이 줄어듭니다.
  • 즉 레이스가 사라진 게 아니라 환경이 안전해진 것입니다.

join 없이 동시에 돌리면 왜 터지나

  • 생산과 소비가 같은 자료구조를 동시에 만집니다.
  • 임계 영역이 없으면 연결 구조가 쉽게 손상됩니다.
  • 그래서 counter가 깨지고 구조 자체도 깨질 수 있습니다.

해결 1 synchronized로 감싸기

  • 가장 쉬운 개선은 메서드를 통째로 동기화하는 것입니다.
java
public synchronized boolean appendNode(String name) { ... }
public synchronized UserData removeAtHead() { ... }
  • 장점
    • 구현이 단순합니다.
    • 구조 손상이 거의 사라집니다.
  • 단점
    • 필요 없는 코드까지 임계 영역이 됩니다.
    • 경쟁이 늘면 병목이 커집니다.

해결 2 ReentrantLock으로 임계 영역만 줄이기

  • 핵심은 “링크 갱신 구간만” 락으로 묶는 것입니다.
java
import java.util.concurrent.locks.ReentrantLock;
class LinkedQueue {
    private final ReentrantLock lock = new ReentrantLock();
    private int counter = 0;
    public boolean appendNode(String name) {
        lock.lock();
        try {
            // 링크 갱신 + counter 갱신만 보호
            // tail.prev.next = newUser;
            // tail.prev = newUser;
            // counter++;
            return true;
        } finally {
            lock.unlock();
        }
    }
    public UserData removeAtHead() {
        lock.lock();
        try {
            // 링크 갱신 + counter 갱신만 보호
            // head.next = node.next;
            // node.next.prev = head;
            // counter--;
            return null;
        } finally {
            lock.unlock();
        }
    }
}
  • 장점
    • 임계 영역을 줄이기 쉽습니다.
    • tryLock() 같은 선택지도 생깁니다.
  • 단점
    • 락 해제를 빼먹으면 장애로 이어집니다.
    • 설계 난도가 올라갑니다.

CAS Compare And Swap이란 무엇인가

  • CAS는 Compare And Swap 또는 Compare And Set입니다.
  • 락 없이 원자적으로 바꾸기 위한 핵심 원리입니다.
  • CPU가 제공하는 원자 명령을 이용합니다.
    • 대표적으로 cmpxchg 계열이 유명합니다. (CPU/아키텍처마다 표현은 다릅니다)
  • CAS는 아래를 “한 번에” 처리합니다.
    • 현재 값이 기대값인지 비교
    • 기대값이면 새 값으로 교체
    • 성공 실패를 반환

왜 CAS가 Lock free를 가능하게 하나

  • 임계 영역을 “잠그는 방식”이 아닙니다.
  • 대신 “충돌하면 다시 시도하는 방식”입니다.
  • 그래서 커널 블로킹을 줄일 수 있습니다.

Spin lock은 무엇인가

  • 스핀 락은 락을 얻을 때까지 반복문으로 재시도합니다.
  • 반복문은 CPU 시간을 계속 씁니다.
  • 그래서 스핀 락은 아래에서만 이득입니다.
  • 이득 조건
    • 임계 영역이 매우 짧습니다.
    • 경쟁 스레드 수가 적습니다.
    • 락이 곧 풀릴 가능성이 큽니다.

CAS 기반 SpinLock 예제

  • 보통은 직접 구현하지 않습니다.
  • 학습 목적이나 특수한 경우에만 구현합니다.

AtomicBoolean + LockSupport.parkNanos 예제

java
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.LockSupport;
class SpinLock {
    private final AtomicBoolean locked = new AtomicBoolean(false);
    public void lock() {
        while (!locked.compareAndSet(false, true)) {
            // 너무 공격적으로 돌면 CPU를 태움
            LockSupport.parkNanos(1_000); // 1us 정도 쉬며 재시도
        }
    }
    public void unlock() {
        locked.set(false);
    }
}
  • compareAndSet(false, true)가 CAS입니다.
  • parkNanos는 불필요한 CPU 소모를 줄이는 데 도움이 될 수 있습니다.
  • 단, 값은 상황별로 튜닝이 필요합니다. (정답은 없습니다)

Lock free 큐가 좋은 예가 되는 이유

  • 큐는 선형 자료구조입니다.
  • 단일 연결 리스트 기반 큐는 핵심 참조가 단순합니다.
    • head 참조
    • tail 참조
    • node.next 참조
  • 이 참조 변경에 CAS를 적용하면 lock free 접근이 가능해집니다.

AtomicReference를 쓰는 이유

  • 참조자 변경이 “원자적으로” 이뤄져야 합니다.
  • 그래서 AtomicReference<Node>로 head나 tail을 감쌉니다.
  • 핵심은 “참조자 한 칸 교체”를 CAS로 만드는 것입니다.

lock free가 항상 이기나

  • lock free는 경합이 적고 임계 영역이 작을 때 유리할 수 있습니다.
  • 경합이 심해지면 재시도가 늘어서 비용이 커질 수 있습니다.
  • 그래서 어느 순간에는 ReentrantLock과 큰 차이가 없어질 수 있습니다. (상황 의존입니다)

결론

  • 멀티스레드에서 큐가 자주 등장하고 자주 깨집니다.
  • 연결 리스트는 포인터 갱신이 여러 단계라 레이스에 취약합니다.
  • 해결은 단계적으로 접근하는 게 좋습니다.
  • 1단계
    • synchronized로 correctness를 먼저 확보합니다.
  • 2단계
    • ReentrantLock으로 임계 영역을 줄입니다.
  • 3단계
    • CAS 기반으로 lock free 구조를 검토합니다.
  • 임계 영역 판단이 결국 성능과 안정성을 갈라놓습니다.