여러 스레드가 공유 자원에 동시에 접근하여 읽고 쓸 때, 실행 순서에 따라 결과가 달라지는 현상을 경쟁 상태라고 합니다.
같은 코드가 어떤 때는 정상 동작하고 어떤 때는 잘못된 결과를 내기 때문에 재현과 디버깅이 까다롭습니다.
경쟁 상태는 다음 세 조건이 동시에 성립할 때 발생합니다.
대표적인 예시는 카운터 증가 연산입니다.
public class Counter {
private int count = 0;
public void increment() {
count++; // 단일 연산처럼 보이지만 실제로는 3단계
}
}
공유 자원에 접근하는 코드 구간을 의미합니다. 경쟁 상태를 막으려면 이 구간에 한 번에 하나의 스레드만 진입하도록 제어해야 합니다.
멀티스레드 정확성을 보장하려면 세 가지 속성이 필요합니다.
가장 기본적인 방법으로, 메서드나 블록에 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++;
}
}
장점은 사용이 간단하다는 점이고, 단점은 락을 획득하지 못한 스레드가 대기하므로 성능 저하가 발생할 수 있다는 점입니다.
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()로 대기 중 인터럽트 처리가 가능합니다volatile 키워드는 가시성 문제를 해결합니다. 변수의 읽기/쓰기를 메인 메모리에서 직접 수행하므로 스레드 간 변경이 즉시 반영됩니다.
private volatile boolean running = true;
public void stop() {
running = false;
}
주의할 점은 volatile은 가시성만 보장할 뿐 원자성은 보장하지 않는다는 것입니다. 따라서 count++ 같은 복합 연산에는 사용할 수 없습니다. 단순 플래그 변수에 적합합니다.
java.util.concurrent.atomic 패키지는 CAS(Compare-And-Swap) 연산을 활용하여 락 없이 원자성을 보장합니다.
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
CAS는 다음과 같이 동작합니다.
락을 사용하지 않으므로 경쟁이 적은 환경에서 synchronized보다 빠릅니다. 다만 경쟁이 심한 환경에서는 재시도가 반복되어 성능이 떨어질 수 있습니다.
주요 클래스
AtomicInteger, AtomicLong, AtomicBooleanAtomicReferenceLongAdder, LongAccumulator (높은 경쟁 환경에서 AtomicLong보다 빠름)일반 컬렉션(HashMap, ArrayList) 대신 동시성 컬렉션을 사용합니다.
Map<String, Integer> map = new ConcurrentHashMap<>();
List<String> list = new CopyOnWriteArrayList<>();
Queue<String> queue = new ConcurrentLinkedQueue<>();
BlockingQueue<String> blocking = new LinkedBlockingQueue<>();
공유하지 않고 스레드별로 변수를 분리하는 방법입니다. 애초에 자원을 공유하지 않으므로 경쟁 상태가 발생하지 않습니다.
private static final ThreadLocal<SimpleDateFormat> formatter =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
스레드 풀 환경에서 사용 후 remove()를 호출하지 않으면 메모리 누수가 발생할 수 있으므로 주의해야 합니다.
객체를 한번 생성한 후 상태가 변하지 않도록 설계하면, 여러 스레드가 동시에 접근해도 안전합니다.
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로 선언합니다DB 레벨에서 경쟁 상태를 해결하는 방법입니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
Account findByIdForUpdate(@Param("id") Long id);
SQL 레벨에서 SELECT ... FOR UPDATE를 사용하여 트랜잭션이 끝날 때까지 다른 트랜잭션의 접근을 차단합니다. 충돌 가능성이 높은 환경에 적합합니다.
@Entity
public class Account {
@Id private Long id;
private long balance;
@Version
private Long version;
}
버전 컬럼을 사용하여 UPDATE 시 버전이 일치하지 않으면 예외를 발생시킵니다. 충돌이 드문 환경에 적합하며, 충돌 시 재시도 로직을 직접 구현해야 합니다.
여러 서버 인스턴스가 같은 자원에 접근하는 환경에서는 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을 지정하면 락을 획득한 노드가 죽어도 해당 시간 후 자동 해제됩니다.