[Java] 경쟁 상태(Race Condition)

dustle·2026년 6월 7일

경쟁 상태란

여러 스레드가 공유 자원에 동시에 접근하여 읽고 쓸 때, 실행 순서에 따라 결과가 달라지는 현상을 경쟁 상태라고 합니다.
같은 코드가 어떤 때는 정상 동작하고 어떤 때는 잘못된 결과를 내기 때문에 재현과 디버깅이 까다롭습니다.

발생 원인

경쟁 상태는 다음 세 조건이 동시에 성립할 때 발생합니다.

  1. 공유 자원이 존재 (인스턴스 변수, static 변수, 파일, DB 등)
  2. 여러 스레드가 그 자원을 동시에 접근
  3. 그 접근 중 최소 하나가 쓰기 작업

대표적인 예시는 카운터 증가 연산입니다.

public class Counter {
    private int count = 0;

    public void increment() {
        count++; // 단일 연산처럼 보이지만 실제로는 3단계
    }
}

관련 개념

임계 영역(Critical Section)

공유 자원에 접근하는 코드 구간을 의미합니다. 경쟁 상태를 막으려면 이 구간에 한 번에 하나의 스레드만 진입하도록 제어해야 합니다.

원자성(Atomicity), 가시성(Visibility), 순서(Ordering)

멀티스레드 정확성을 보장하려면 세 가지 속성이 필요합니다.

  • 원자성: 연산이 중간에 끊기지 않고 한 번에 완료됩니다
  • 가시성: 한 스레드의 변경이 다른 스레드에 즉시 보입니다
  • 순서: 컴파일러나 CPU가 명령을 재배치해도 의도한 순서가 보장됩니다

해결 전략

1. synchronized

가장 기본적인 방법으로, 메서드나 블록에 synchronized 키워드를 붙여 한 번에 하나의 스레드만 진입하도록 합니다.

public class Counter {
    private int count = 0;

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

블록 단위로도 사용할 수 있습니다.

private final Object lock = new Object();

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

장점은 사용이 간단하다는 점이고, 단점은 락을 획득하지 못한 스레드가 대기하므로 성능 저하가 발생할 수 있다는 점입니다.

2. ReentrantLock

java.util.concurrent.locks.ReentrantLock을 사용하면 synchronized보다 세밀한 제어가 가능합니다.

private final ReentrantLock lock = new ReentrantLock();

public void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

synchronized와 비교한 차이점은 다음과 같습니다.

  • tryLock()으로 락 획득 실패 시 즉시 반환할 수 있습니다
  • lockInterruptibly()로 대기 중 인터럽트 처리가 가능합니다
  • 공정성(fair) 옵션을 지정할 수 있습니다 (FIFO 순서 보장)
  • Condition 객체로 세밀한 wait/notify 제어가 가능합니다

3. volatile

volatile 키워드는 가시성 문제를 해결합니다. 변수의 읽기/쓰기를 메인 메모리에서 직접 수행하므로 스레드 간 변경이 즉시 반영됩니다.

private volatile boolean running = true;

public void stop() {
    running = false;
}

주의할 점은 volatile은 가시성만 보장할 뿐 원자성은 보장하지 않는다는 것입니다. 따라서 count++ 같은 복합 연산에는 사용할 수 없습니다. 단순 플래그 변수에 적합합니다.

4. Atomic 클래스

java.util.concurrent.atomic 패키지는 CAS(Compare-And-Swap) 연산을 활용하여 락 없이 원자성을 보장합니다.

private final AtomicInteger count = new AtomicInteger(0);

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

CAS는 다음과 같이 동작합니다.

  1. 메모리의 현재 값을 읽습니다
  2. 예상값과 비교합니다
  3. 일치하면 새 값으로 교체하고, 일치하지 않으면 재시도합니다

락을 사용하지 않으므로 경쟁이 적은 환경에서 synchronized보다 빠릅니다. 다만 경쟁이 심한 환경에서는 재시도가 반복되어 성능이 떨어질 수 있습니다.

주요 클래스

  • AtomicInteger, AtomicLong, AtomicBoolean
  • AtomicReference
  • LongAdder, LongAccumulator (높은 경쟁 환경에서 AtomicLong보다 빠름)

5. 동시성 컬렉션

일반 컬렉션(HashMap, ArrayList) 대신 동시성 컬렉션을 사용합니다.

Map<String, Integer> map = new ConcurrentHashMap<>();
List<String> list = new CopyOnWriteArrayList<>();
Queue<String> queue = new ConcurrentLinkedQueue<>();
BlockingQueue<String> blocking = new LinkedBlockingQueue<>();
  • ConcurrentHashMap: 세그먼트 단위 락(Java 8부터는 버킷 단위 CAS)으로 동시 읽기/쓰기를 처리합니다
  • CopyOnWriteArrayList: 쓰기 시 배열을 복사하는 방식으로, 읽기가 많고 쓰기가 적은 환경에 적합합니다
  • BlockingQueue: 생산자-소비자 패턴에 사용합니다

6. ThreadLocal

공유하지 않고 스레드별로 변수를 분리하는 방법입니다. 애초에 자원을 공유하지 않으므로 경쟁 상태가 발생하지 않습니다.

private static final ThreadLocal<SimpleDateFormat> formatter =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

스레드 풀 환경에서 사용 후 remove()를 호출하지 않으면 메모리 누수가 발생할 수 있으므로 주의해야 합니다.

7. 불변 객체(Immutable Object)

객체를 한번 생성한 후 상태가 변하지 않도록 설계하면, 여러 스레드가 동시에 접근해도 안전합니다.

public final class Money {
    private final long amount;
    private final String currency;

    public Money(long amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    public Money add(Money other) {
        return new Money(this.amount + other.amount, this.currency);
    }
}
  • 필드를 final로 선언합니다
  • setter를 제공하지 않습니다
  • 상태 변경이 필요하면 새 객체를 반환합니다

8. 데이터베이스 락

DB 레벨에서 경쟁 상태를 해결하는 방법입니다.

  • 비관적 락(Pessimistic Lock)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
Account findByIdForUpdate(@Param("id") Long id);

SQL 레벨에서 SELECT ... FOR UPDATE를 사용하여 트랜잭션이 끝날 때까지 다른 트랜잭션의 접근을 차단합니다. 충돌 가능성이 높은 환경에 적합합니다.

  • 낙관적 락(Optimistic Lock)
@Entity
public class Account {
    @Id private Long id;
    private long balance;

    @Version
    private Long version;
}

버전 컬럼을 사용하여 UPDATE 시 버전이 일치하지 않으면 예외를 발생시킵니다. 충돌이 드문 환경에 적합하며, 충돌 시 재시도 로직을 직접 구현해야 합니다.

9. 분산 락

여러 서버 인스턴스가 같은 자원에 접근하는 환경에서는 JVM 내부의 락만으로는 부족합니다. Redis나 Zookeeper를 사용한 분산 락이 필요합니다.

Redisson을 활용한 예시입니다.

RLock lock = redissonClient.getLock("order:" + orderId);
try {
    boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);
    if (acquired) {
        // 임계 영역
    }
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

tryLock(waitTime, leaseTime, unit)에서 leaseTime을 지정하면 락을 획득한 노드가 죽어도 해당 시간 후 자동 해제됩니다.

0개의 댓글