현실적 시나리오
애플리케이션 개발 중 자주 마주치는 상황으로, 서비스 실행 후 특정 API의 총 호출 횟수를 집계하거나, 주문 상태와 누적 매출 금액을 실시간으로 업데이트하는 경우가 있다. 이러한 작업은 다중 스레드 환경에서 수행되므로 동기화 문제에 노출되기 쉽다.
문제 발생 원인
예를 들어, 다음과 같이 단순히 카운터 변수를 증가시키는 방식은 동시 접근 시 정확한 결과를 보장하지 못한다.
private int requestCount = 0;
public void increment() {
requestCount++;
}
두 스레드 A와 B가 동시에 접근할 경우, 둘 다 메인 메모리의 값 0을 읽어와 각각 1씩 증가시키고, 결과적으로 메인 메모리에 1로 기록된다. 그러나 올바른 결과는 2여야 한다.
해결 방법 개요
동시성 문제를 해결하기 위한 대표적인 두 가지 접근법이 있다: 잠금 기반(락)과 비잠금 기반(무잠금). 각각의 장단점을 이해하고 적절한 도구를 선택하는 것이 중요하다.
1. 비잠금 방식 (Non-blocking)
1-1. 지역 변수 사용
스레드 로컬 영역에서 선언된 변수는 각 스레드마다 독립된 복사본을 가지므로 공유되지 않는다.
public void processRequest() {
int localCounter = 0; // 각 스레드마다 고유한 인스턴스
localCounter++; // 처리 로직 수행
System.out.println("처리 수: " + localCounter);
}
1-2. 불변 객체 활용
불변 객체는 생성 후 상태가 변경되지 않으므로, 여러 스레드가 동시에 접근하더라도 데이터 무결성이 유지된다.
public final class Position {
private final double latitude;
private final double longitude;
public Position(double lat, double lon) {
this.latitude = lat;
this.longitude = lon;
}
// getter만 제공, 변경 메서드 없음
public double getLatitude() { return latitude; }
public double getLongitude() { return longitude; }
}
이를 이용해 차량 위치 정보를 관리하는 추적 클래스를 설계하면, 상태 갱신 시 새로운 객체를 생성하여 맵에 재설정함으로써 동시성 문제를 피할 수 있다.
public class VehicleTracker {
private final Map vehiclePositions = new ConcurrentHashMap<>();
public void updatePosition(String id, Position newPosition) {
vehiclePositions.put(id, newPosition); // 새로운 불변 객체 삽입
}
public Position getPosition(String id) {
return vehiclePositions.get(id);
}
}
1-3. ThreadLocal 사용
ThreadLocal은 각 스레드가 자신의 독립된 데이터 복사본을 갖도록 해주는 기술이다. 예를 들어, 요청 카운트를 스레드별로 저장할 수 있다.
private static final ThreadLocal<Integer> threadCounter = new ThreadLocal<>();
public void trackRequest() {
Integer count = threadCounter.get();
if (count == null) {
count = 0;
}
count++;
threadCounter.set(count);
System.out.println("스레드 내 카운트: " + count);
}
1-4. CAS 기반 원자 클래스
CAS(Compare-And-Swap)는 원자성 연산을 통해 동시성 문제를 해결하는 핵심 기술이다. 이는 실제 하드웨어 수준에서 보장되며, 비교 후 교체 작업을 하나의 원자 단위로 수행한다.
private final AtomicInteger accessCounter = new AtomicInteger(0);
public void recordAccess() {
// 원자적으로 증가하고 현재 값을 반환
System.out.println("총 접근 수: " + accessCounter.incrementAndGet());
}
내부적으로는 루프를 통해 실패 시 다시 시도하며, 성능 저하를 최소화하면서도 동기화 없이 안정적인 작동을 보장한다.
2. 잠금 기반 방식 (Lock-based)
2-1. synchronized 키워드
메서드 또는 코드 블록에 `synchronized`를 붙여서 해당 리소스에 대한 동시 접근을 제한할 수 있다.
private int viewCount = 0;
public synchronized void incrementView() {
viewCount++;
}
2-2. ReentrantLock 사용
보다 유연한 조건과 더 많은 제어 기능을 제공하는 명시적 락. `lock()`과 `unlock()`을 직접 관리해야 하며, 반드시 `try-finally` 블록으로 감싸야 락 누수가 발생하지 않는다.
private final ReentrantLock lock = new ReentrantLock();
private int viewCount = 0;
public void incrementWithLock() {
lock.lock();
try {
viewCount++;
} finally {
lock.unlock();
}
}
이러한 방식은 비용이 크지만, 복잡한 동기화 논리를 구현할 때 유용하다.