이전 프로젝트에서 대기열 기능을 개발하며 동시성 문제를 만났으나, 해결하지 못했다.
이를 해결하기 위해 공부한 내용을 정리한다.
대기열 정보를 저장하는 객체에 초당 1000번의 Read작업 & 1번의 Write작업이 발생하던 상황에서, 잘못된 값이 나오던 문제가 있었다.
이 상황을 최대한 간소화해서 재현해보았다.
A.java
@Getter @Setter
public class A {
private int a;
private long b;
private String c;
public A(int a, long b, String c) {
this.a = a;
this.b = b;
this.c = c;
}
public static A createWithZero() {
return new A(0, 0L, "0");
}
public void increase() {
this.a++;
this.b++;
this.c = Integer.parseInt(this.c) + 1 + "";
}
}
TargetService.java
public class TargetService {
private A A;
public TargetService(A a) {
A = a;
}
public ReadResult read() {
try {
int a = A.getA();
Thread.sleep(0, 1);
long b = A.getB();
Thread.sleep(0, 1);
String c = A.getC();
return new ReadResult(a, b, c);
} catch (Exception e) {
}
return null;
}
/**
* A 안의 값을 1씩 올린다.
*/
public void update() {
A.increase();
}
}
테스트를 진행해보았다.
2000개의 쓰레드를 생성해 200번마다 1번씩, 총 10번의 Write작업과
1990번의 Read작업을 비동기로 실행했다.
문제가 발생한 경우(A.a, A.b, A.c의 숫자 값이 다른 경우)는 값을 출력하고,
총 횟수 / 성공 횟수 / 실패 횟수도 출력했다.

크게 4가지를 생각했다.
재진입
예를 들어, 다음과 같은 상황이다.public class Sample { public synchronized void methodA() { methodB(); } public synchronized void methodB() { System.out.println(1); return; } }
- 특정 쓰레드가 methodA()를 호출하면, 락을 획득한다.
- 그 상태로 methodB()를 호출한다.
- Sample객체의 락이 이미 점유된 상태에서 또다시 락 획득을 시도하지만, 같은 쓰레드라면 이를 허용한다.
재진입이 불가능하다라는 경우는, 위의 경우에 같은 쓰레드라도 methodB()에 진입이 허용되지 않는 경우를 말한다.
현재는 Read작업과 Write작업을 다르게 처리하는 것이 필요하므로, 이 방식은 적합하지 않았다.
Atomic변수는 연산에 원자성이 보장된다.
private AtomicInteger count; // 현재 Read작업중인 쓰레드 수
private AtomicBoolean isLocked;
위 2가지 변수를 활용하여 구현하는 방식을 생각했다.
대략 아래와 같은 방식이다.
LockUsingAtomic.java
public class LockUsingAtomic {
private AtomicInteger count;
private AtomicBoolean isLocked;
public int read() {
if (isLocked) wait();
count++;
// read
count--;
}
public void write() {
if (count != 0) wait();
isLocked = true;
// write
isLocked = false;
}
}
Atomic변수는, CAS알고리즘을 통해 non-blocking 방식으로 연산의 원자성을 보장한다.
CAS(Compare And Swap) 알고리즘
count + 1 하는 경우를 예시로 설명해보면,
- 힙에서 count값을 복사하여, CPU 캐시에 저장
- CPU에서 + 1 연산 후, 연산 전의 값 == 힙의 count값인지 비교
a. 같으면 힙에 결과값을 쓰고, true 반환
b. 다르면 false를 반환하고, 1번 단계로 돌아감
그러나 결과적으로 동시성 문제를 완전히 해결하지 못하고, 효율성 면에서도 문제가 있어 선택하지 않았다. 자세한 내용은 뒤에서 설명한다.
Read락과 Write락, 2가지를 구분지어 관리하는 락이다.
Read락이 필요한 상황끼리는, 동시에 실행될 수 있다.
Write락이 포함되는 상황에서는, 단일 Write락의 점유만 가능하다.
후술될 StampedLock과 비교했을 때, (필요한 경우)매번 Read락을 걸게 되어, 일반적으로 효율이 떨어진다.
자바에서, 구현체인 ReentrantReadWriteLock은 Unfair 모드(default)와 Fair모드를 지원한다.
Sample.java
public class Sample {
private ReadWriteLock rwLock = new ReentrantReadWriteLock(); // Unfair 모드
// private ReadWriteLock rwLock = new ReentrantReadWriteLock(true); // Fair 모드
private Lock readLock = rwLock.readLock();
private Lock writeLock = rwLock.writeLock();
public void read() {
readLock.lock();
// read작업 수행
readLock.unlock();
}
public void write() {
writeLock.lock();
// write작업 수행
writeLock.unlock();
}
}
ReadWriteLock과 유사한 기능에, 추가 기능을 제공한다.(상속한 객체는 아니다.)
낙관적 읽기(락을 걸지 않는 읽기)를 지원한다.
쓰기 작업과의 충돌이 빈번하지 않은 경우, 즉 읽기 작업이 많은 경우에 적합하다.
Unfair하게 처리된다.(기아 현상이 발생할 수 있다.)
낙관적 읽기
우선 락을 걸지 않은 채 읽기를 시도하고, 필요한 경우에만 락을 건다. 대략 아래 과정처럼 진행된다.
- 락 객체에서 Stamp값(수정이 일어날 때마다 변하는 값)을 가져온다.
- 데이터를 읽는다.
- Stamp값을 사용해, 읽는 중에 데이터 수정이 있었는지 체크한다.
- 있었다면, 이번엔 읽기 락을 걸고 데이터를 읽는다.
Sample.java
public class Sample {
private StampedLock stampedLock = new StampedLock();
private int data = 0;
public int read() {
long stamp = stampedLock.tryOptimisticRead();
int readData = this.data;
if (!stampedLock.validate(stamp)) {
// 읽기 중 수정이 발생하여 stamp가 변하면, 락을 걸고 읽는다.
stamp = stampedLock.readLock();
try {
readData = this.data;
} finally {
stampedLock.unlockRead(stamp);
}
}
return readData;
}
Atomic변수를 활용하는 방식은 크게 2가지 문제점이 있다.
첫째로, 수많은 읽기 쓰레드들이 count++,-- 연산을 하는 과정에서 CAS방식으로 인한 오버헤드가 발생한다. 특히 여타 경우와는 다르게 Read작업을 하는 모든 쓰레드가 count연산을 시도하므로, CAS방식의 오버헤드가 특히 더 많다.
둘째로, 로직 내에서 또다른 동시성 문제가 발생할 수 있다.
위의 내용을 다시 보자.
LockUsingAtomic.java
public class LockUsingAtomic {
private AtomicInteger count;
private AtomicBoolean isLocked;
public int read() {
if (isLocked) wait();
count++;
// read
count--;
}
public void write() {
if (count != 0) wait();
isLocked = true;
// write
isLocked = false;
}
}
여기서 read()와 write()가 같이 수행된다면,
Write 쓰레드가 조건문을 통과하고 isLocked = true로 변경하기 전에 Read쓰레드가 조건문을 통과할 수 있다.
이런 경우에 대한 처리 로직을 포함하면 차라리 Lock객체들을 쓰는게 더 간단할 것이라 생각했고,
CAS방식의 오버헤드도 생각했을 때 Atomic변수 방식은 적합하지 않다고 생각했다.
StampedLock 방식은 Unfair모드의 문제점때문에 선택하지 않았다.
속도 면에서, 낙관적 읽기와 Unfair모드로 인한 장점이 있겠지만,
Write작업이 지연되거나, 특정 Read작업이 오래 지연되는 것의 비용이 더 큰 상황에서 이를 막아줄 수 있느냐가 우선이었다.
이러한 이유로, 우선 ReadWriteLock(ReentrantReadWriteLock)을 Fair모드로 사용해보고, 효율성 면에서 문제가 크다면 StampedLock을 상속한 클래스를 만들거나, Atomic변수 방식을 사용하기로 했다.
실제 문제 상황이었던 프로젝트 코드는 현재 관심 대상인 로직만 실행하기엔 어려운 관계로, 앞에서 재현했던 테스트코드에 ReadWriteLock을 적용해보았다.
TargetService.java
public class TargetService {
private A A;
// 공정성 유지; 특정 작업이 오래 밀리면 안됨
private ReadWriteLock lock = new ReentrantReadWriteLock(true);
private Lock readLock = lock.readLock();
private Lock writeLock = lock.writeLock();
public TargetService(A a) {
A = a;
}
public ReadResult read() {
readLock.lock();
try {
int a = A.getA();
Thread.sleep(0, 1);
long b = A.getB();
Thread.sleep(0, 1);
String c = A.getC();
return new ReadResult(a, b, c);
} catch (InterruptedException e) {
} finally {
readLock.unlock();
}
return null;
}
/**
* A 안의 값을 1씩 올린다.
*/
public void update() {
writeLock.lock();
A.increase();
writeLock.unlock();
}
}
이전과 마찬가지로 2000개의 쓰레드(Write 10 + Read 1990)로 작업을 실행했고, 잘못된 값과 총 성공/실패 작업 수를 출력했다.

모두 문제 없이 처리된 것을 확인할 수 있었다.
참고 링크