멀티 스레드 환경에서는 여러 사용자가 동시에 동일한 데이터에 접근하거나 수정할 때 다양한 동시성 문제가 발생할 수 있습니다. 이 프로젝트에서는 자바에서 발생할 수 있는 대표적인 동시성 문제를 정의하고, 이를 해결하기 위한 다양한 동시성 제어 기법을 소개합니다. 또한, 자바에서 제공하는 다양한 동기화 방법을 활용해 동시성 문제를 해결하고 테스트하는 방법에 대해 설명합니다.
GitHub 소스 코드 : https://github.com/onetaek/test-driven-development

A와 스레드는 500포인트, B는 300 포인트를 충전하려 할 때, 마지막에 완료된 작업에 따라 포인트가 1300으로 기록되고 나머지 충전 작업은 사라질 수 있습니다.


자바에서는 위에서 설명한 동시성 문제를 해결하기 위해 다양한 동기화 기법을 제공합니다. 아래는 자바에서 이러한 동시성 문제를 해결하기 위해 사용되는 대표적인 기법들입니다.
synchronized는 자바의 가장 기본적인 동기화 방법입니다. 메서드 전체 혹은 특정 블록을 동기화하여 한 번에 하나의 스레드만 접근할 수 있도록 합니다.
해결하는 문제: Lost Update와 같은 문제를 방지하기 위해, 동일한 자원에 대해 접근을 제한합니다.
public synchronized void charge(long amount) {
// 로직 생략
}
ReentrantLock은 synchronized보다 유연한 동기화 방법을 제공합니다. 명시적으로 락을 획득하고 해제해야 하며, 타임아웃 설정과 같은 추가 기능을 제공합니다.
해결하는 문제: Lost Update, Dirty Read, Non-Repeatable Read 문제 해결에 유용합니다.
private final Lock lock = new ReentrantLock();
public void use(long amount) {
lock.lock();
try {
// 로직 생략
} finally {
lock.unlock();
}
}
ConcurrentHashMap은 동시성에 안전한 해시맵을 제공하여, 자원을 효율적으로 관리합니다. 이를 통해 특정 키에 대한 접근만 동기화할 수 있습니다.
해결하는 문제: 여러 사용자가 동시에 다른 데이터를 접근할 때 발생하는 성능 문제를 줄이고, 특정 사용자에 대해서만 동기화할 수 있습니다.
private final ConcurrentHashMap<Long, Lock> userLocks = new ConcurrentHashMap<>();
public void charge(long userId, long amount) {
Lock lock = userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
lock.lock();
try {
// 로직 생량
} finally {
lock.unlock();
}
}
자바의 java.util.concurrent.atomic 패키지에는 원자적 연산을 제공하는 AtomicInteger, AtomicLong 등의 클래스가 포함되어 있습니다. 이들은 원자적 CAS(Compare-And-Swap) 연산을 통해 동시성 문제를 해결합니다.
해결하는 문제: Lost Update, Dirty Read 문제를 방지합니다.
코드 예시:
private final AtomicLong point = new AtomicLong(0);
public void charge(long amount) {
point.addAndGet(amount);
}
public void use(long amount) {
/// 로직 생략
}
volatile 키워드는 변수에 대한 모든 읽기와 쓰기 작업이 메인 메모리에서 수행되도록 보장하여, 가시성 문제를 해결합니다. 여러 스레드가 동일한 변수를 읽고 쓸 때, 각 스레드가 변수의 최신 값을 즉시 반영할 수 있도록 합니다. 하지만 volatile은 원자성(Atomicity)을 보장하지 않으므로, 복합적인 읽기-수정-쓰기 작업에서 발생하는 동시성 문제를 완전히 해결하지 못합니다. 따라서, volatile은 단순한 상태 플래그와 같은 변수에 유용하게 사용됩니다.
private volatile boolean isActive = true;
public void runTask() {
while (isActive) {
// 로직 생략
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public void deactivate() {
isActive = false;
}
public boolean isActive() {
return isActive;
}
ExecutorService는 스레드 풀을 관리하여 효율적인 스레드 사용을 가능하게 하고, 작업 제출 및 제어 기능을 제공합니다. Semaphore는 특정 자원에 대한 접근을 제한하여 제한된 수의 스레드만 자원에 접근할 수 있도록 합니다.
private final Semaphore semaphore = new Semaphore(3);
public void accessResource() throws InterruptedException {
semaphore.acquire(); // 최대 3개의 스레드만 접근 가능
try {
// 로직 생략
} finally {
semaphore.release(); // 접근 후 자원을 해제
}
}
위 코드에서 Semaphore는 동시에 3개의 스레드만 자원에 접근할 수 있도록 합니다. acquire() 메서드를 호출하여 접근 권한을 얻고, 작업이 끝나면 release()를 호출하여 접근 권한을 반환합니다. 이렇게 하면 동시 접근 제한을 통해 성능을 최적화하고, 불필요한 자원 경합을 방지할 수 있습니다.
StampedLock은 ReentrantReadWriteLock보다 더 높은 성능을 제공하며, 특히 읽기 위주의 상황에서 효율적입니다. StampedLock은 Optimistic Read 기능을 제공하여, 락의 경쟁을 줄이고 더 높은 성능을 구현할 수 있습니다.
private final StampedLock lock = new StampedLock();
private long point = 0;
public long readPoint() {
long stamp = lock.tryOptimisticRead();
long currentPoint = point;
if (!lock.validate(stamp)) { // 다른 스레드에 의해 수정이 이루어졌는지 확인
stamp = lock.readLock(); // 읽기 락 획득
try {
// 로직 생략
} finally {
lock.unlockRead(stamp); // 읽기 락 해제
}
}
return currentPoint;
}
public void charge(long amount) {
long stamp = lock.writeLock(); // 쓰기 락 획득
try {
// 로직 생략
} finally {
lock.unlockWrite(stamp); // 쓰기 락 해제
}
}
StampedLock을 사용하면 Optimistic Read 기능을 통해 락을 걸지 않고 먼저 값을 읽은 후, 다른 스레드가 데이터를 수정했는지 확인할 수 있습니다. 만약 데이터가 변경되지 않았다면 락을 걸 필요 없이 읽기 작업을 완료하고, 변경된 경우에는 읽기 락을 획득하여 안전하게 읽습니다.
ReentrantReadWriteLock은 읽기-쓰기 락을 제공하여 읽기 작업과 쓰기 작업 간의 동시성을 최적화하는 락입니다. 읽기 작업이 많은 경우, 여러 스레드가 동시에 읽기 락을 획득하여 읽기 작업을 병렬로 수행할 수 있습니다.
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
private long point = 0;
public long readPoint() {
readLock.lock(); // 읽기 락 획득
try {
// 로직 생략
} finally {
readLock.unlock(); // 읽기 락 해제
}
}
public void charge(long amount) {
writeLock.lock(); // 쓰기 락 획득
try {
// 로직 생략
} finally {
writeLock.unlock(); // 쓰기 락 해제
}
}
ReentrantReadWriteLock을 사용하면, 여러 스레드가 동시에 읽기 락을 획득하여 읽기 작업을 병렬로 수행할 수 있습니다. 반면에 쓰기 락은 단독으로만 획득할 수 있어, 읽기-쓰기 충돌을 방지합니다.
ConcurrentHashMap과 ReentrantLock을 사용했는가?이번 프로젝트에서는 포인트 충전 및 사용에 대한 동시성 문제를 해결하기 위해 ConcurrentHashMap과 ReentrantLock을 사용했습니다. 처음에는 synchronized를 사용했지만 성능 문제로 인해 최적화된 방법을 찾게 되었습니다.
처음 동시성 문제를 해결하기 위해 작성한 코드는 synchronized를 사용한 것이었습니다.
public synchronized UserPoint chargeSync(long id, long amount) {
// 로직 생략
}
public synchronized UserPoint useSync(long id, long amount) {
// 로직 생략
}
synchronized를 사용하여 포인트 충전 및 사용 로직을 동기화하면, 충전 및 사용 요청에 대해 동시성 문제가 해결되긴 합니다.synchronized를 사용하면 모든 스레드에 대해 해당 메서드에 락이 걸리게 되므로, 한 사용자의 포인트 충전/사용 작업이 다른 사용자의 작업도 차단하게 됩니다.성능 문제를 해결하기 위해 ConcurrentHashMap과 ReentrantLock을 사용하여 사용자별로 동시성을 제어하도록 개선했습니다.
private static final ConcurrentHashMap<Long, Lock> userLocks = new ConcurrentHashMap<>();
public UserPoint charge(long id, long amount) {
Lock lock = getLockForUser(id);
lock.lock();
try {
/// 로직 생략
} finally {
lock.unlock();
}
}
private Lock getLockForUser(long id) {
return userLocks.computeIfAbsent(id, key -> new ReentrantLock());
}
ConcurrentHashMap: 사용자별로 Lock 객체를 관리하기 위해 사용했습니다. 각 사용자에 대해 고유한 Lock 객체를 할당하여, 각 사용자의 충전 및 사용 요청이 서로 충돌하지 않도록 보장했습니다.
ReentrantLock: ReentrantLock을 사용해 명시적으로 락을 획득하고, 작업이 끝난 후에 반드시 락을 해제하도록 하였습니다. synchronized보다 세밀한 락 제어가 가능하고, 특정 상황에서 락 해제를 더 확실하게 보장할 수 있습니다.

- 모든 요청에 대해 전역적으로 락을 사용하다 보니 대기 시간이 길어짐.
- 동시 처리 능력이 낮아지고, 병목 현상이 발생하여 성능이 매우 저하됨.

- 각 사용자의 포인트에 대해 별도의 락을 사용하여, 병렬 처리 능력이 크게 향상됨.
- 락 경쟁이 줄어들고, 다른 사용자의 작업은 독립적으로 수행되기 때문에 성능이 크게 개선됨.
synchronized는 간단한 구현으로 동시성 문제를 해결할 수 있지만, 모든 스레드에 대한 전역 락을 걸기 때문에, 병렬 처리가 불가능하며, 성능이 저하됩니다.
반면에, ConcurrentHashMap과 ReentrantLock을 사용하면 사용자별로 별도의 락을 할당하여 병렬 처리 성능을 높이고, 불필요한 대기를 줄일 수 있었습니다. 이로 인해 성능이 훨씬 우수한 결과를 얻을 수 있었습니다.