목차
들어가며
SpringBoot를 기반으로 실시간 투표 애플리케이션을 구현하면서, 한 가지 실험을 수행했다.
멀티 스레드 환경에서 다수의 클라이언트 요청이 동시에 하나의 글에 투표를 진행하면 정상적으로 동작할 수 있을까?
실험의 시나리오는 다음과 같다.
[상황]
- 찬성 투표가 100개인 투표 글이 있다.
- 찬성표를 반대표로 변경하기 위한 업데이트 요청을 동시에 100번 수행한다.
[예상 결과]
- 투표 글의 찬성 투표수는 0이 된다.
- 투표 글의 반대 투표수는 100이 된다.
시나리오 테스트 코드를 작성한 결과, 예상한 결과와는 다른 결과를 얻을 수 있었다.
문제 정의
테스트를 수행하면서 마주한 문제는 멀티 스레드 환경에서 접할 수 있는 동시성 문제이다. 애플리케이션은 스프링부트를 기반으로 구현되었고, 내장 톰캣을 이용한다. 이때 톰캣은 스레드 풀을 통해서 멀티 스레드 환경을 제공하고, 이로 인해서 발생할 수 있는 문제이다.
동시성 문제
동시성 문제는 멀티 프로세스 혹은 멀티 스레드에서 하나의 공유 자원에 동시에 접근하게 되고, 이때 접근하는 순서에 따라서 작업에 대한 결과 값이 달라질 수 있는 문제이다. 이러한 상황을 경쟁 상태(race condition)이라고 하고, 접근 순서에 따라서 달라질 수 있는 코드 영역을 임계 영역(critical section)이라고 한다. 임계 영역에 동시에 접근하는 것을 방지하기 위해서 뮤텍스, 세마포어, 모니터 등의 기법을 사용할 수 있다.
동시성 문제를 도식화하면 아래와 같이 표현할 수 있다.
같은 색의 화살표가 하나의 트랜잭션이라고 생각하면 된다. 이때 먼저 시작한 트랜잭션이 끝나기 전에, 각 스레드에서 조회를 통해 공유 정보에 접근하기 때문에 정상적인 갱신 동작이 이루어지지 않은 것이다.
문제 해결
동시성 문제를 해결하기 위해서는 다양한 방법을 생각해 볼 수 있다.
- synchronized
- Pessimistic Lock
- Optimistic Lock
- Named Lock
- Lettuce
- Redisson
synchronized
자바에서는 멀티 스레드 환경에서 동기적으로 메서드가 동작할 수 있도록 synchronized 키워드를 제공한다. synchronized 키워드를 적용한 객체는 JVM에 의해서 Monitor를 생성한다. 모니터를 생성 후 내부적으로는 block과 unblock을 수행한다. block이 된 영역은 사용하는 스레드를 제외하고 모두 접근할 수 없다. 하지만 block과 unblock 과정을 수행하기 때문에 성능저하를 일으킬 수 있다. 또한, 교착 상태가 발생할 수 있다.
Pessimistic Lock
- 데이터에 Lock을 걸어서 정합성을 맞추는 방법이다. Exclusive Lock을 레코드에 걸면 다른 트랜잭션에서는 Lock을 획득하기 위해서 대기한다.
- 단, 데드락이 발생할 수 있기 때문에 주의해야 한다.
- SQL 쿼리는 `SELECT ~ FOR UPDATE` 쿼리가 데이터베이스에 나가게 된다.
Optimistic Lock
- 레코드에 버전을 부여해서 조회와 갱신 시 버전 비교를 통해서 정합성을 맞추는 방법이다. 만약 갱신 시 버전이 맞지 않으면 값을 다시 조회하게 된다.(개발자가 직접 구현해야 한다.)
- 엔티티에 버전을 추가해줘야 한다.
Named Lock
- 말 그대로 이름을 가진 Lock이다.
- 데이터베이스의 별도의 저장소에 이름을 가진 락을 저장하고, 해당 이름에 대한 락을 해제되기 전 까지는 다른 트랜잭션에서 락을 획득할 수 없다.
- 보통 분산락에서 사용하는 방법이다.
Lettuce
- redis를 dependency에 추가하면 별도의 라이브러리 설치 없이 사용할 수 있다.
- Spin Lock 방식으로 동시에 많은 스레드가 Lock 획득을 대기 중이라면, redis 서버에 부하를 발생시킬 수 있다.
- Lock 획득에 대한 재시도가 필요 없는 경우에 사용하면 좋다.
Redisson
- 별도의 라이브러리를 추가해야 사용할 수 있다.
- Pub/Sub 방식으로 되어 있어, Lettuce 보다 서버에 부하를 줄일 수 있다.
- Lettuce와 다르게 분산락을 기본적으로 지원한다.
- Lock 획득을 재시도해야 하는 경우에 사용하면 좋다.
이번 프로젝트에서는 synchronized와 Redisson을 이용하여 락을 수행하였다.
Pessimistic Lock, Optimistic Lock, Names Lock을 이용해서 데이터베이스에서 락을 수행할 수 있지만, 영속성 영역에 부하를 줄 수 있기 때문에 선택하지 않았다.
synchronized 적용
업데이트 요청을 처리하는 메서드에 synchronized를 적용할 수 있지만, 불필요한 영역도 함께 block 될 수 있기 때문에 임계 영역에만 적용해 주었다.
synchronized(this) {
// 트랜잭션 로직 update query
}
테스트 결과는 성공적으로 통과할 수 있었다. 하지만 synchronized는 요청이 처리하는 서버에서는 정상 동적할 수 있지만, 분산화된 서버 환경에서는 정상동작 하지 않을 수 있다.
Redisson 적용
먼저, redisson을 사용하기 위해서는 의존성을 추가해야 한다.
dependencies {
implementation 'org.redisson:redisson-spring-boot-starter:3.23.2'
}
redisson은 분산화된 환경에서도 대응할 수 있도록 redisson을 적용했다. redisson은 자바 언어로 구현된 레디스 클라이언트로 인메모리 데이터 그리드 기능을 갖추고 있다. Redis를 보다 편리하고 쉽게 사용할 수 있다. 그리고 redisson은 분산락을 간단하게 사용할 수 있도록 기능을 제공한다.
redisson을 적용한 로직은 아래와 같다.
final String key = postId + ":lock:key"; // lock 에서 사용할 키를 생성
final RLock lock = redissonClient.getLock(key); // 생성한 키를 통해서 락을 획득
final String worker = Thread.currentThread().getName(); // 로그를 위해서 스레드 이름 획득
try {
// 락 획득을 시도
// 10초 동안 락 회득을 시도하고, 3초 동안 임대
final boolean isAvailable = lock.tryLock(10, 3, TimeUnit.SECONDS);
log.info("[REQUEST] Thread:{}, lock: {}", worker, lock.getName());
// 유효 시간이 지났다면 락 획득에 실패
if (!isAvailable) {
log.info("[FAIL] Get Lock");
return;
}
log.info("[PROGRESS] Current Thread: {}", worker);
// 트랜잭션 로직 update query
} catch(InterruptedException e) {
e.printStackTrace();
} finally {
if (lock != null
&& lock.isLocked() // 잠금이 되었는지 확인
&& lock.isHeldByCurrentThread() // 해당 세션애서 생성한 락인지 확인
){
lock.unlock();
log.info("[SUCCESS] Finish Thread: {}", worker);
}
}
락을 획득하는 것은 tryLock 메서드를 호출한다. tryLock가 Pub/Sub 기반으로 lock의 해제를 구독하게 된다. 아래의 코드에서 해당 내용을 확인할 수 있다. 락 획득을 시도할 때 넘긴 waitTime 만큼 채널을 구독하고 있게 된다.
current = System.currentTimeMillis();
CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
try {
// time -> waitTime
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
if (!subscribeFuture.completeExceptionally(new RedisTimeoutException(
"Unable to acquire subscription lock after " + time + "ms. " +
"Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
subscribeFuture.whenComplete((res, ex) -> {
if (ex == null) {
unsubscribe(res, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
}
redisson도 내부적으로 상호 배제를 위해서 세마포어를 사용한다.
public CompletableFuture<E> subscribe(String entryName, String channelName) {
AsyncSemaphore semaphore = service.getSemaphore(new ChannelName(channelName));
CompletableFuture<E> newPromise = new CompletableFuture<>();
semaphore.acquire().thenAccept(c -> {
if (newPromise.isDone()) {
semaphore.release();
return;
}
E entry = entries.get(entryName);
if (entry != null) {
entry.acquire();
semaphore.release();
entry.getPromise().whenComplete((r, e) -> {
if (e != null) {
newPromise.completeExceptionally(e);
return;
} newPromise.complete(r);
}); return;
...
redisson을 적용 후, 테스트 코드가 정상적으로 통과했다.
이번 글에서는 동시성 문제의 가능성을 실험해 보고, 그 해결방법에 대해서 실습해 보았다.
참고
https://helloworld.kurly.com/blog/distributed-redisson-lock/
https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers/#81-lock