코드 한 줄의 기록

Java Synchronized·Volatile·원자성 완벽 이해하기 본문

JAVA

Java Synchronized·Volatile·원자성 완벽 이해하기

CodeByJin 2025. 11. 18. 07:53
반응형

오늘은 Java를 공부하면서 정말 헷갈렸던 부분을 다루고자 합니다. 바로 Synchronized(동기화), 가시성(Visibility), 원자성(Atomicity)에 관한 내용입니다. 이 세 가지 개념은 멀티스레드 환경에서 매우 중요한데, 많은 개발자분들이 이 개념을 완벽히 이해하지 못한 채 코드를 작성하는 경우가 많습니다. 사실 저도 처음에는 이 개념들이 무엇인지, 어디에 써야 하는지, 왜 필요한지 전혀 알지 못했습니다.

 

처음에는 이 개념들이 비슷하면서도 다르다는 점을 이해하지 못했지만, 실제 프로젝트에서 스레드 관련 버그를 직접 경험하면서 조금씩 깨닫기 시작했습니다. 오늘은 제가 배운 내용을 차근차근 설명드리려고 합니다. 혼자 고민하며 공부하는 것보다는 함께 이해해 나가는 것이 훨씬 낫다고 생각합니다. 특히 멀티스레드 프로그래밍은 매우 까다롭고 복잡하기 때문에, 이런 기초 개념들을 확실히 이해하는 것이 중요합니다.

멀티스레드 환경에서 왜 이 개념들이 중요한가?

먼저 상황을 한 번 상상해 보겠습니다. 당신과 제가 같은 은행 계좌에서 돈을 동시에 인출한다고 가정해 보겠습니다. 계좌에 100만 원이 있는데, 당신은 50만 원을 인출하려 하고 저는 30만 원을 인출하려 합니다. 우리가 동시에 잔액을 조회하고 동시에 인출한다면 어떤 문제가 발생할까요? 이 사례는 멀티스레드가 초래할 수 있는 문제의 핵심입니다.

  • 첫 번째 시나리오: 당신이 50만 원을 빼고, 제가 30만 원을 뺀다. 그러면 계좌에는 20만 원이 남아야 합니다. 이것이 올바른 결과입니다.
  • 잘못된 시나리오: 우리가 동시에 계좌 잔액 100만 원을 조회한 후, 당신은 50만 원을 빼고 저는 30만 원을 뺍니다. 결과적으로 계좌에는 50만 원만 남게 됩니다. 이는 논리적으로 맞지 않습니다. 결국 80만 원이 사라진 셈이 됩니다.

이런 문제를 레이스 컨디션(Race Condition)이라고 부릅니다. 여러 스레드가 공유 자원에 동시에 접근해서 발생하는 문제입니다. 이를 방지하기 위해 배워야 할 개념들이 바로 synchronized, volatile, 가시성, 원자성입니다. 이 개념들을 제대로 이해하지 못하면, 아무리 복잡한 기능을 구현해도 예상치 못한 버그가 발생할 수 있습니다. 특히 이런 버그는 재현이 어렵다는 점에서 더 까다롭습니다.

원자성(Atomicity)부터 이해해 보겠습니다.

원자성은 원래 화학이나 물리학에서 나온 개념으로, 더 이상 나눌 수 없는 가장 작은 단위를 의미합니다. Java에서도 마찬가지로, 원자성은 어떤 작업이 전부 완료되거나 완전히 실패하는 둘 중 하나임을 보장합니다. 절대로 작업이 중간에 끝나거나 부분적으로 완료되어서는 안 됩니다.

 

예를 들어, i++라는 간단한 코드를 생각해 보겠습니다. 이 코드는 사실 세 가지 작업으로 이루어집니다. 첫째, 현재 i의 값을 읽습니다. (Read) 둘째, 읽은 값에 1을 더합니다. (Modify) 셋째, 더한 값을 i에 저장합니다. (Write) 이 세 가지 작업이 중요합니다.

 

이 세 가지 작업이 하나의 원자적 작업이 아니라는 점이 문제입니다. 만약 스레드 A가 i 값을 읽은 후 아직 쓰기 전인데, 스레드 B가 i 값을 읽는다면 두 스레드 모두 같은 값을 읽게 됩니다. 결국 i는 2가 아니라 1만 증가하게 됩니다.

 

자세히 설명드리자면

  1. 스레드 A가 i의 값 0을 읽습니다.
  2. 스레드 B가 i의 값 0을 읽습니다. (스레드 A가 아직 쓰기 전입니다.)
  3. 스레드 A가 0 + 1 = 1을 i에 씁니다.
  4. 스레드 B가 0 + 1 = 1을 i에 씁니다.
  5. 최종적으로 i는 1이 됩니다.
class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

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

        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 100; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

        for (Thread t : threads) {
            t.join();
        }

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

이 코드를 실행하면 매번 다른 결과가 나옵니다. 예상 결과는 1000이지만, 실제로는 950이나 980이 나올 수도 있습니다. 심지어 실행할 때마다 결과가 다르게 나옵니다. 이것이 바로 레이스 컨디션 때문입니다. 이런 문제를 해결하려면 반드시 동기화를 해야 합니다.

가시성(Visibility)이란 무엇인가?

가시성은 한 스레드가 변수를 수정했을 때, 다른 스레드가 그 변경 사항을 즉시 볼 수 있는지를 의미합니다. Java는 멀티코어 CPU를 사용하기 때문에, 각 CPU 코어는 자신의 캐시 메모리를 가지고 있습니다. 한 스레드가 변수를 수정했다고 해서 즉시 메인 메모리에 반영되는 것이 아니라 CPU 캐시에만 쓰여질 수 있습니다. 이것이 중요한 이유입니다.

 

멀티코어 시스템에서 각 코어는 L1, L2, L3 캐시를 가지고 있으며, 모두가 공유하는 메인 메모리가 있습니다. 코어 A가 변수를 수정해도 그 값이 코어 B의 캐시에 바로 나타나지 않아서 코어 B는 예전 값을 계속 볼 수 있습니다.

 

예를 들어, 스레드 1이 flag를 true로 설정하고 value를 42로 설정하였으나, 스레드 2는 이 변경 사항을 볼 수 없을 수도 있습니다. 왜냐하면 각 스레드가 자신의 CPU 캐시에만 데이터를 가지고 있기 때문입니다.

class FlagExample {
    private boolean flag = false;
    private int value = 0;

    public void setFlag() {
        value = 42;
        flag = true;
    }

    public void checkFlag() {
        if (flag) {
            System.out.println("Value is: " + value);
        }
    }
}

만약 flag가 volatile이 아니라면, 스레드 2는 flag가 true가 되는 것을 영원히 보지 못할 수도 있습니다. 이것이 메모리 가시성 문제의 핵심이며, 매우 위험한 상황입니다.

Synchronized - 상호 배제를 보장하는 방법

Synchronized는 한 번에 하나의 스레드만 특정 코드 블록에 접근하도록 허용하는 방법입니다. Java의 모든 객체는 모니터(Monitor)라는 잠금 메커니즘을 가지고 있습니다. synchronized를 사용하면 해당 모니터 락을 획득해야만 코드 실행이 가능합니다. 이는 뮤텍스와 유사한 개념입니다.

class SynchronizedExample {
    private int count = 0;

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

    public void incrementBlock() {
        synchronized (this) {
            count++;
        }
    }

    public synchronized int getCount() {
        return count;
    }
}

동작 원리는 다음과 같습니다. 스레드 A가 synchronized 메서드를 호출하면 객체의 모니터 락을 획득합니다. 스레드 B가 같은 synchronized 메서드를 호출하려 하면, 스레드 A가 락을 가지고 있으므로 스레드 B는 대기합니다. 스레드 A가 메서드를 완료하고 락을 해제하면, 스레드 B가 락을 획득하여 실행합니다. 이 과정이 반복됩니다.

 

이렇게 하면 레이스 컨디션을 완전히 방지할 수 있습니다. 하지만 synchronized는 두 가지를 보장합니다. 첫째, 원자성 - 동기화된 블록 내의 모든 작업이 한번에 완료됩니다. 둘째, 가시성 - 한 스레드가 동기화 블록을 빠져나갈 때, 그 스레드가 한 모든 변경 사항이 다른 스레드에게 보입니다.

 

그러나 synchronized의 단점은 성능 저하입니다. 락을 기다리는 스레드들이 생기기 때문인데, 이를 락 경합(Lock Contention)이라고 합니다. 특히 많은 스레드가 경쟁하면 성능이 급격히 떨어집니다. 그래서 보통 synchronized 블록은 최대한 작게 유지합니다.

public class CounterSynchronized {
    private int count = 0;

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

    public synchronized int getCount() {
        return count;
    }

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

        Thread[] threads = new Thread[10];
        long start = System.currentTimeMillis();

        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 100000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

        for (Thread t : threads) {
            t.join();
        }

        long end = System.currentTimeMillis();
        System.out.println("결과: " + counter.getCount());
        System.out.println("소요 시간: " + (end - start) + "ms");
    }
}

이 코드를 실행하면 결과는 항상 1,000,000이 나오지만, 시간이 오래 걸립니다. synchronized 블록이 작을수록 성능이 더 좋습니다.

Volatile - 가시성만 보장하는 방법

Volatile 키워드는 synchronized와 달리 가시성만 보장합니다. 즉, 한 스레드가 volatile 변수를 수정하면 그 변경 사항이 즉시 메인 메모리에 쓰여지고, 다른 스레드는 메인 메모리에서 읽도록 강제합니다. 이는 CPU 캐시로 인한 가시성 문제를 직접 해결하는 방식입니다.

class VolatileExample {
    private volatile boolean flag = false;
    private int value = 0;

    public void setFlag() {
        value = 42;
        flag = true;
    }

    public void checkFlag() {
        if (flag) {
            System.out.println("Value is: " + value);
        }
    }
}

Volatile은 synchronized와 달리 상호 배제를 제공하지 않습니다. 즉, 여러 스레드가 동시에 volatile 변수에 접근할 수 있습니다. 하지만 원자성이 없으므로, i++ 같은 복합 연산에는 사용할 수 없습니다. volatile은 단지 메모리 가시성만 보장합니다.

 

volatile의 가장 좋은 사용 사례는 boolean 플래그 같은 간단한 값을 다룰 때입니다. 예를 들어, 스레드를 종료할지 말지를 결정하는 플래그나 초기화 완료 플래그 등이 있습니다. 이런 경우 synchronized보다 훨씬 빠르고 효율적입니다.

class Worker extends Thread {
    private volatile boolean running = true;

    public void stopWorker() {
        running = false;
    }

    @Override
    public void run() {
        while (running) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        System.out.println("워커가 종료되었습니다.");
    }

    public static void main(String[] args) throws InterruptedException {
        Worker worker = new Worker();
        worker.start();

        Thread.sleep(500);
        worker.stopWorker();
        worker.join();
    }
}

volatile 변수에 쓰기 시 메모리 배리어(Memory Barrier)가 삽입되어 CPU가 명령어 재정렬을 하지 못하도록 강제합니다. 이렇게 해서 가시성을 보장합니다. 매우 영리한 메커니즘입니다.

메모리 가시성과 Happens-Before 관계

Java 메모리 모델(Java Memory Model, JMM)은 JVM이 메모리 연산을 어떻게 보장하는지 정의합니다. 그 중 Happens-Before 관계는 한 연산이 다른 연산보다 먼저 일어나고 그 영향이 보인다는 의미입니다. 멀티스레드 환경에서 매우 핵심적인 개념입니다.

class HappensBeforeExample {
    static int x = 0;
    static int y = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            x = 1;
            y = x;
        });

        Thread t2 = new Thread(() -> {
            int temp = y;
            System.out.println("y: " + temp);
        });

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

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

이 코드에서 x와 y가 volatile이 아니면, 스레드 2가 y를 읽을 때 0을 읽을 가능성이 있습니다. 각 스레드가 자신의 캐시에만 값을 가지고 있기 때문입니다. 하지만 y를 volatile로 선언하면 Happens-Before 규칙에 의해 스레드 1이 y에 값을 쓸 때부터 스레드 2가 y를 읽을 때까지의 관계가 보장됩니다. 복잡하지만 중요한 개념입니다.

Atomic 변수 - 성능과 안전성의 균형

Java 1.5부터 java.util.concurrent.atomic 패키지가 추가되었습니다. 이 안에는 AtomicInteger, AtomicLong, AtomicBoolean 등이 있습니다. 이들은 락 없이도(Lock-Free) 원자성을 보장하는 놀라운 기술입니다.

 

어떻게 작동할까요? 이들은 Compare-And-Swap(CAS) 알고리즘을 사용합니다. CPU가 지원하는 원자적 연산입니다. 간단히 말하면 현재 값을 읽고, 기대하는 값과 같은지 비교한 후, 같으면 새로운 값으로 바꿉니다. 이 모든 과정이 CPU 레벨에서 원자적으로 수행됩니다.

class AtomicExample {
    private AtomicInteger count = new AtomicInteger(0);

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

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

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

        Thread[] threads = new Thread[10];
        long start = System.currentTimeMillis();

        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 100000; j++) {
                    counter.increment();
                }
            });
            threads[i].start();
        }

        for (Thread t : threads) {
            t.join();
        }

        long end = System.currentTimeMillis();
        System.out.println("결과: " + counter.getCount());
        System.out.println("소요 시간: " + (end - start) + "ms");
    }
}

AtomicInteger의 장점은 다음과 같습니다. 첫째, 락이 없어서 스레드가 대기하지 않고 성능이 좋습니다. 둘째, volatile과 마찬가지로 가시성을 보장합니다. 셋째, 여러 복합 연산도 원자적으로 수행할 수 있습니다. Atomic 변수는 특히 카운터나 통계 정보를 관리할 때 유용하며, 락이 없기에 데드락도 발생하지 않습니다.

 

오늘 배운 내용을 정리하면, 멀티스레드 프로그래밍은 어렵지만 이 기초 개념들을 확실히 이해하면 훨씬 쉬워집니다. 혼자 공부하다 막히는 부분이 있으면 이 글을 다시 참고하고, 직접 코드를 작성해 실행해 보시는 것을 추천드립니다. 이론뿐 아니라 직접 테스트해 봐야만 진짜 이해할 수 있기 때문입니다.

 

 

Java 스레드 라이프사이클과 Runnable/Thread 완벽 이해하기

처음 멀티스레딩을 공부할 때 가장 헷갈리는 부분이 바로 스레드의 상태 변화와 Runnable 인터페이스 vs Thread 클래스의 선택이다. 나도 이 부분을 깊게 파고들어야 면접에서 자신 있게 답할 수 있

byteandbit.tistory.com

 

반응형