언어/Java

Java - 가시성 문제? "volatile, synchronized, Atomic 정확히 언제 써야 할까?

믿을수없는맛 2025. 6. 29. 19:46
반응형

📍 자바 멀티스레딩에서의 함정: 가시성(Visibility)

자바에서 멀티스레드를 사용할 때 흔히 하는 실수 중 하나는 다음과 같은 코드다.

public class VisibilityProblemExample {

    static boolean flag = false;

    public static void main(String[] args) {
        // Thread A: flag가 true가 될 때까지 기다림
        Thread readerThread = new Thread(() -> {
            System.out.println("Reader: 대기 중...");
            while (!flag) {
                // 무한 대기
            }
            System.out.println("Reader: flag가 true가 되었습니다!");
        });

        // Thread B: 1초 뒤에 flag를 true로 변경
        Thread writerThread = new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException ignored) {}

            flag = true;
            System.out.println("Writer: flag를 true로 변경했습니다.");
        });

        readerThread.start();
        writerThread.start();
    }
}

이 코드에서 flagtrue로 바뀌었는데도 reader()는 무한 루프를 탈출하지 못하는 경우가 발생할 수 있다.

왜일까? 바로 가시성(visibility) 문제 때문이다.

📍 가시성이란?

멀티스레드 환경에서 각각의 스레드가 공유된 자원에 대해서 모두 같은 값을 바라보고있을 것을 의미한다.

쉽게 설명 하면 한 스레드에서 변경한 값이 다른 스레드에서 언제 볼 수 있는가를 의미한다.

자바에서는 성능 최적화를 위해 아래 사진과 같이 각 스레드가 변수를 CPU 캐시에 보관할 수 있다. 즉, 어떤 스레드가 값을 바꿔도, 다른 스레드는 여전히 옛날 값을 볼 수 있다.

CPU 캐시 구조 다이어그램

이미지 출처

해결 방법: volatile

이때 해결 방법은 간단하다. flagvolatile로 선언하면 된다.

volatile boolean flag = false;

volatile을 쓰면 다음이 보장된다:

  • 스레드는 항상 메인 메모리에서 변수 값을 읽고 씀
  • 다른 스레드가 변경한 값을 즉시 감지할 수 있음
  • 컴파일러와 CPU의 명령 재배열 방지도 보장
volatile 키워드 사용시 메인 메모리에 직접 접근하기 때문에 성능저하가 있을수도 있다.

📍 volatile은 원자성(atomicity)을 보장하지 않는다

그러면 volatile 하나만 사용하면 만능일까? 아니다.

🙅다음 코드는 안전하지 않다.

volatile int count = 0;

void increase() {
    count++; // 이게 안전해 보일까?
}

왜냐하면 count++는 아래처럼 세 단계로 쪼개진 연산이기 때문:

  1. count 읽기
  2. +1 계산
  3. count에 다시 쓰기

→ 이 중간에 다른 스레드가 끼어들면, 증가값이 덮여쓰여진다 (lost update)

해결 방법 1: synchronized

synchronized void increase() {
    count++;
}
  • 정확함 (원자성, 가시성 보장)
  • 하지만 느림 (락 오버헤드 있음)

해결 방법 2: AtomicInteger

AtomicInteger count = new AtomicInteger(0);

void increase() {
    count.incrementAndGet(); // 💥 이건 원자적으로 동작함!
}
  • 빠르고 정확하게 작동
  • 내부적으로 CAS(Compare-And-Swap) 사용해서 락 없이 연산
  • 가시성과 원자성을 모두 보장
AtomicInteger 외에도 AtomicBoolean, AtomicReference 등 사용가능.

CAS(Compare-And-Swap)란?

"값이 예상한 값과 같을 때만 새로운 값으로 바꾸는 연산" 이다.
이 연산은 원자적으로(atomic) 수행된다.

Atomic* 클래스에서 제공되는 메서드들은 내부적으로 아래와 같은 형태로 동작하여 원자적인 연산을 가능하게 한다.

compareAndSwap() 의사코드

function compareAndSwap(expectedValue, newValue):
    currentValue ← readFromMemory()  // 현재 값 읽기

    if currentValue == expectedValue:
        writeToMemory(newValue)      // 예상한 값과 같으면 새 값으로 교체
        return true                  // 교체 성공
    else:
        return false                 // 예상과 다르면 교체 실패

incrementAndGet() 의사코드

function incrementAndGet():
    loop forever:
        current ← get()                   // ① 현재 값 읽기
        next ← current + 1                // ② 새 값 계산
        if compareAndSwap(current, next) // ③ CAS 성공 시
            return next                  //    계산된 값을 반환하며 종료
        // CAS 실패하면 loop 처음으로 돌아가서 재시도

🆚 차이점

항목 AtomicReference synchronized
동기화 방식 비차단(non-blocking), CAS 기반 차단(blocking), 락 기반
성능 경합이 적을 땐 빠름 (락 오버헤드 없음) 경합이 많아질수록 느림 (락 오버헤드 큼)
사용 대상 변수(참조 1개)에 대한 원자적 연산 블록/메서드 내 모든 코드에 대한 동기화
교착 상태 없음 (Deadlock 없음) 있음 (락 잘못 사용 시 Deadlock 발생 가능)
복잡도 간단한 연산에 적합 복잡한 로직 동기화에 적합

결론

정리하면 즉시 다른 쓰레드도 알아야되는 값이면 volatile 를 사용하고, 값이 계속 누적되고 하나라도 누락되면 안되는 경우는 synchronized, Atomic*를 사용하면 된다.

요약 정리

상황 예시 적절한 방식 설명
즉시 다른 스레드가 변경을 알아야 하는 단순 플래그 boolean running, boolean enabled volatile 가시성(visibility)만 보장하면 되는 경우
하나라도 빠지면 안 되는 숫자 증가, 데이터 누적 등 count++, 리스트에 요소 추가 synchronized, AtomicInteger, ConcurrentList 원자성(atomicity)과 정합성(consistency)이 중요하므로 더 강한 동기화 필요
값이 바뀌더라도 다른 스레드가 몰라도 되는 경우 스레드 로컬 캐시, 로깅용 통계 등 상황에 따라 다름 동기화가 필요 없는 경우도 있음
반응형