자바 동시성 제어 핵심 키워드 정리 (volatile, synchronized, Atomic)

송현진·2025년 5월 27일
0

CS공부

목록 보기
11/17

자바에서의 가시성(Visibility) 문제란?

멀티스레드 프로그래밍에서 가장 자주 마주치는 문제 중 하나가 바로 "가시성 문제"이다. 이는 하나의 스레드에서 변경한 변수 값이 다른 스레드에게 보이지 않는 현상을 의미한다. 자바는 성능 최적화를 위해 각 스레드가 메모리에서 직접 값을 읽기보다는 CPU 캐시나 레지스터에 데이터를 저장해 사용하는 구조를 택하고 있는데 이로 인해 최신 값이 공유되지 않는 문제가 발생한다.

예시 코드

public class VisibilityProblem {
    private static boolean running = true;

    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while (running) {
                // 무한 루프
            }
            System.out.println("Thread stopped.");
        });
        t.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        running = false;
    }
}

위 코드를 보면 main 스레드에서 1초 후 running = false로 바꿔주었기 때문에 보통은 쓰레드가 종료되어야 한다. 하지만 현실에서는 while (running) 조건이 계속 true로 평가되어 종료되지 않는 경우가 있다. 이는 스레드가 running 값을 자신의 CPU 캐시에 저장해두고 메인 메모리에서의 변경을 인식하지 못하기 때문이다. 이게 바로 가시성 문제다.

이 문제를 해결하기 위한 첫 번째 도구가 바로 volatile이다.

volatile

volatile은 자바에서 가시성 문제를 해결하기 위한 핵심 키워드다. 이 키워드를 변수 앞에 붙이면 해당 변수는 항상 메인 메모리에 저장되며 어떤 스레드가 이 값을 읽을 때도 반드시 메인 메모리에서 읽어온다. 즉, 하나의 스레드가 값을 변경하면 다른 스레드는 그 변경 사항을 바로 인식할 수 있다.

아까 봤던 예제를 volatile을 사용해 다시 작성해보자

public class VolatileFixed {
    private static volatile boolean running = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (running) {
                // 무한 루프
            }
            System.out.println("Thread stopped.");
        });
        t.start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        running = false;
    }
}

실행 결과

Thread stopped.

이제는 정상적으로 "Thread stopped."가 출력된다. 이유는 running 변수가 메인 메모리에만 존재하고, 모든 스레드가 항상 그 값을 메모리에서 직접 읽기 때문이다. 하지만 volatile은 단점도 있다. 단순한 읽기/쓰기에는 효과적이지만 복합 연산에는 안전하지 않다. 예를 들어 count++ 같은 연산은 내부적으로 세 가지 단계로 이루어진다. 1. 읽기, 2. 증가, 3. 쓰기 이 과정을 다른 스레드가 끼어들 수 있기 때문에 volatile로는 이러한 복합 연산의 원자성을 보장하지 못한다.

장점

  • 가시성 문제를 간단히 해결할 수 있다.
  • 락을 사용하지 않기 때문에 성능 오버헤드가 적다.

단점

  • 복합 연산의 원자성을 보장하지 못한다.
  • 상태 전이가 복잡하거나 여러 개의 변수 상태가 관련되어 있을 경우에는 적합하지 않다.

해결 방법

복합 연산이 필요한 경우에는 synchronizedAtomic 클래스를 사용하는 것이 더 적절하다.

동시 접근 문제

Java에서는 단순히 변수의 가시성뿐 아니라 여러 스레드가 동시에 값을 수정할 때 발생할 수 있는 동시 접근 문제(Race Condition)도 존재한다.

예시 코드

public class RaceCondition {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                count++;
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Final count: " + count);
    }
}

실행 결과

Final count: 1673

정상적으로는 2000이 출력되어야 하지만 count++가 원자적 연산이 아니기 때문에 여러 스레드가 동시에 접근하면 일부 연산이 유실되어 2000 미만의 결과가 나올 수 있다. 이 문제는 volatile만으로는 해결할 수 없고 synchronized 또는 Atomic 클래스가 필요하다.

synchronized 키워드

synchronized는 자바에서 스레드 간의 상호 배제(Mutual Exclusion)와 가시성 보장을 함께 제공하는 키워드이다. 특정 메서드나 블록에 synchronized를 붙이면 해당 블록에 여러 스레드가 동시에 접근하지 못하고 하나의 스레드만 접근 가능하다. 또한 락을 해제하는 순간 그 스레드가 변경한 변수들은 메인 메모리에 즉시 반영되며 다른 스레드가 락을 획득할 때 이 변경 사항을 보장받게 된다.

예시 코드

public class SynchronizedCounter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedCounter counter = new SynchronizedCounter();

        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Final count: " + counter.getCount());
    }
}

실행 결과

Final count: 20000

synchronized는 메서드 전체를 감싸거나 특정 블록을 감쌀 수 있다. 메서드에 선언하면 해당 객체 자체에 락이 걸리며 블록에 선언하면 개발자가 지정한 객체에만 락이 걸린다.

장점

  • 복합 연산의 원자성을 완벽하게 보장한다.
  • 변수 간의 상태 일관성을 유지하는 데 적합하다.

단점

  • 락을 획득하고 해제하는 데 오버헤드가 발생해 성능이 떨어질 수 있다.
  • 데드락(Deadlock), 컨텍스트 스위칭 등의 복잡한 문제를 야기할 수 있다.

synchronized는 고성능이 중요한 경우보다는 안전성이 중요한 영역에서 사용하는 것이 적합하다.

Atomic

java.util.concurrent.atomic 패키지에는 AtomicInteger, AtomicLong, AtomicBoolean 등 다양한 클래스들이 존재하며 내부적으로 CAS 연산을 사용해 락 없이 동기화를 제공한다.

예시 코드

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }

    public static void main(String[] args) throws InterruptedException {
        AtomicCounter counter = new AtomicCounter();

        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                counter.increment();
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("Final count: " + counter.getCount());
    }
}

실행 결과

Final count: 20000

여기서 incrementAndGet()은 내부적으로 다음과 같이 동작한다.
1. 현재 값을 읽는다.
2. 새로운 값을 계산한다.
3. CAS 연산을 통해 현재 값이 그대로일 경우 새 값으로 덮어쓴다.
4. 실패하면 다시 시도한다.

장점

  • 락이 없기 때문에 성능이 우수하다.
  • 간단한 연산에는 synchronized보다 훨씬 효율적이다.
  • 가시성과 원자성을 동시에 보장한다.

단점

  • 복잡한 연산이나 여러 값이 동시에 변경되어야 할 때는 부적절하다.
  • 반복적인 CAS 실패로 인한 성능 저하 가능성이 있다.
  • ABA 문제에 대한 방어가 필요할 수 있다.

CAS(Compare-And-Swap) 연산

CAS는 Atomic 클래스의 기반이 되는 개념으로 자바뿐만 아니라 거의 모든 락프리(Lock-Free) 알고리즘에서 사용되는 연산이다.

  1. 현재 메모리 값을 읽는다 (A)
  2. 기대하는 값(B)과 비교한다.
  3. 같다면 새로운 값(C)으로 바꾼다.
  4. 다르면 아무것도 하지 않고 다시 처음부터 시도한다.

이 과정을 하드웨어 수준에서 원자적으로 보장하기 때문에 복잡한 락 없이도 동기화가 가능하다.

CAS의 문제점

  • ABA 문제: 값이 A였다가 B로 바뀌었다가 다시 A로 돌아오면 변경이 없는 것처럼 보이지만 실질적으로는 변경이 일어난 것이다.
  • 반복 실패: 경쟁이 심한 경우 CAS가 계속 실패하면서 오히려 성능이 떨어질 수 있다.

📝 배운점

Java의 멀티스레드 프로그래밍에서 발생할 수 있는 가시성 문제의 본질과 그 해결 방안을 이해할 수 있었다. 단순히 변수에 volatile을 붙인다고 해서 모든 동기화 문제가 해결되지 않는다는 점을 다시금 깨달았고 그 대안으로 synchronizedAtomic 클래스가 어떤 상황에 적합한지를 구분해 사용할 수 있을 거 같다. 특히 Atomic 클래스의 기반이 되는 CAS 연산도 공부하면서 락 없는 동기화 방식이 어떻게 동작하는지 구체적으로 이해할 수 있었다. 앞으로 멀티스레드 환경에서 문제를 설계하고 구현할 때 어떤 동기화 메커니즘을 선택할지에 대한 판단 기준을 확실히 세우게 되었다.

참고

profile
개발자가 되고 싶은 취준생

0개의 댓글