메인 콘텐츠로 건너뛰기
Deep Thought
← 목록으로
Backend.Concurrency

경쟁 상태와 동시성 문제 해결

신중선-- views
race-conditionconcurrencythread-safetyatomicityvisibility

경쟁 상태란?

경쟁 상태(Race Condition)는 두 개 이상의 스레드가 공유 자원에 동시에 접근할 때 스레드 간의 실행 순서에 따라 결과가 달라지는 현상입니다. 멀티스레드 환경에서 예측할 수 없는 결과를 만들어내는 대표적인 동시성 문제로, 프로그램의 정확성을 크게 해칠 수 있습니다.

이를 해결하려면 **원자성(Atomicity)**과 가시성(Visibility) 두 가지 속성이 모두 보장되어야 합니다. 단순히 하나의 속성만 보장하는 것으로는 완전한 해결이 어렵습니다.

핵심 개념

1. 원자성이 필요한 이유

원자성은 공유 자원에 대한 작업이 더 이상 쪼갤 수 없는 하나의 연산처럼 동작하는 성질입니다. 간단해 보이는 i++ 연산도 실제로는 세 단계로 분리됩니다:

// i++ 연산의 내부 동작
public class Counter {
    private int count = 0;
    
    public void increment() {
        // 1. Read: count 값을 읽음
        int temp = count;
        // 2. Modify: 값에 1을 더함
        temp = temp + 1;
        // 3. Write: 결과를 다시 count에 할당
        count = temp;
    }
}

두 스레드가 동시에 increment()를 호출하면 다음과 같은 문제가 발생할 수 있습니다:

// Thread 1과 Thread 2가 동시에 실행될 때
// count 초기값: 0

// Thread 1: count 값 0을 읽음
// Thread 2: count 값 0을 읽음 (Thread 1의 변경사항 반영 전)
// Thread 1: 0 + 1 = 1, count에 1 저장
// Thread 2: 0 + 1 = 1, count에 1 저장

// 결과: count = 1 (기대값: 2)

2. 가시성이 필요한 이유

가시성은 한 스레드에서 변경한 값이 다른 스레드에서 즉시 확인 가능한 성질입니다. 현대 CPU는 성능 최적화를 위해 각 코어마다 별도의 캐시를 사용합니다:

public class VisibilityExample {
    private boolean flag = false;
    
    // Thread 1에서 실행
    public void setFlag() {
        flag = true; // CPU1의 캐시에만 반영될 수 있음
    }
    
    // Thread 2에서 실행
    public void checkFlag() {
        while (!flag) { // CPU2의 캐시에서 읽어서 계속 false일 수 있음
            // 무한 루프 가능성
        }
        System.out.println("Flag is true!");
    }
}

Thread 1에서 flagtrue로 변경했지만, 이 변경사항이 메인 메모리에 즉시 반영되지 않으면 Thread 2는 계속해서 false 값을 읽게 됩니다.

3. Java에서의 해결 방법

synchronized 키워드로 원자성과 가시성을 모두 보장할 수 있습니다:

public class SafeCounter {
    private int count = 0;
    
    public synchronized void increment() {
        count++; // 원자성 보장
    }
    
    public synchronized int getCount() {
        return count; // 가시성 보장
    }
}

Atomic 클래스를 사용하면 CAS(Compare-And-Swap) 알고리즘으로 락 없이 동기화할 수 있습니다:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);
    
    public void increment() {
        count.incrementAndGet(); // 원자적 연산
    }
    
    public int getCount() {
        return count.get();
    }
}

volatile 키워드는 가시성만 보장합니다. 하나의 스레드만 쓰기를 수행할 때 사용합니다:

public class VolatileExample {
    private volatile boolean flag = false;
    
    public void setFlag() { // 한 스레드에서만 호출
        flag = true;
    }
    
    public boolean isFlag() { // 여러 스레드에서 호출 가능
        return flag;
    }
}

4. 성능 고려사항

각 동기화 방식은 성능 특성이 다릅니다:

// 성능 비교 예시
public class PerformanceComparison {
    private int syncCount = 0;
    private final AtomicInteger atomicCount = new AtomicInteger(0);
    private volatile int volatileCount = 0;
    
    // 가장 무거움 - 락 획득/해제 오버헤드
    public synchronized void syncIncrement() {
        syncCount++;
    }
    
    // 중간 - CAS 재시도 가능성
    public void atomicIncrement() {
        atomicCount.incrementAndGet();
    }
    
    // 가장 가벼움 - 읽기만 수행시
    public int getVolatileCount() {
        return volatileCount;
    }
}

정리

해결 방법 원자성 가시성 성능 사용 상황
synchronized 낮음 복잡한 임계 영역
Atomic 클래스 중간 단순한 원자적 연산
volatile 높음 한 스레드 쓰기, 다수 읽기
Lock 클래스 중간 세밀한 락 제어 필요

경쟁 상태를 완전히 해결하려면 원자성과 가시성이 모두 보장되어야 합니다. 성능과 복잡성을 고려하여 상황에 맞는 동기화 방식을 선택하는 것이 중요합니다.

References