도입
동시성 제어는 여러 사용자가 동시에 시스템을 사용할 때 발생할 수 있는 데이터 무결성과 성능 문제를 해결하는 데 중요한 역할을 한다. 본 글은 Redis와 Redisson 라이브러리를 활용하여 분산 환경에서 발생하는 동시성 이슈를 해결하기 위한 방안을 탐구하고자 한다. 본 글에서는 사내에서 발생했던 동시성 문제와 비슷한 상황을 예시 코드로 재현하여 문제를 정의하고, 이를 해결하기 위한 방법을 설명한다.
이론적 배경
- 동시성 제어와 분산 락의 중요성
- 동시성 이슈는 분산 환경에서 동일한 데이터에 여러 프로세스가 동시에 접근할 때 발생한다. 이를 해결하기 위해 락(lock) 메커니즘이 널리 사용된다.
- 일반적인 락(mutex lock, spin lock 등)은 단일 시스템 내에서만 작동하며, 분산 락은 여러 시스템 간의 경쟁 상태를 관리한다.
- 단일 시스템의 예로는 하나의 WAS(Tomcat)에서 실행되는 애플리케이션이 있고, 이 환경에서는 스레드 간의 동기화가 주로 필요하다.
- 여러 시스템의 예로는 여러 개의 WAS(Tomcat) 인스턴스가 로드 밸런서를 통해 분산 처리하는 환경이 있으며, 이러한 경우 서버 간 데이터 동기화를 위한 분산 락이 필요하다.
- Redis와 Redisson
- Redis는 인메모리 데이터베이스로 높은 처리 속도와 다양한 데이터 구조를 지원한다. 이를 활용한 분산 락 구현은 효율적인 동시성 제어를 제공한다.
- Redisson은 Redis 클라이언트 라이브러리로, 분산 락을 포함한 다양한 동시성 제어 기능을 제공한다.
연구방법
1. 문제 정의
- 챗봇 대화 서비스를 운영중이었고, 유저가 챗봇과 대화 시 대화에 필요한 서비스 내 재화가 소모 되는 방식이었다.
- 서버 구조는 다음과 같았다:
- 메시지큐를 이용해 채팅 서버로 메시지를 전달하고, 답장 서버에서 답장을 메시지큐를 통해 다시 수신하여 처리하는 구조였다.
- 답장 서버는 8대가 운영 중이었고, 유저는 동시에 여러 챗봇과 대화가 가능했다.
- 메시지큐에 동일한 유저가 여러 챗봇과의 대화를 진행하며 생성된 메시지가 각각 저장되고, 여러 서버에서 이러한 메시지를 동시에 처리하면서 동시성 문제가 발생하여, 유저가 소비해야 하는 재화가 올바르게 차감되지 않는 현상이 나타났다.
- 이를 재현하기 위해 아래와 같은 코드를 작성하여 실험을 설계하였다.
2. 실험 설계
- 문제를 재현하기 위해 아래 코드를 작성하여 실험을 설계하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public ResponseEntity<String> consumeResource(int userId) {
Optional<User> userOpt = resourceRepository.findById(userId);
try {
Thread.sleep(100); // 로직 수행에 100ms가 걸린다고 가정하고, 100ms 지연
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
User user = userOpt.get();
user.consumeResource(1);
resourceRepository.save(user);
log.info("User {} 자원 1 사용, 남은 자원: {}", userId, user.getRemainingResources());
return ResponseEntity.ok("자원 소비. 남은 자원: " + user.getRemainingResources());
}- 자원 소비 중 동시성 문제가 발생하는 현상을 확인하기 위한 예시 코드로, 여러 스레드에서 동일한 유저의 데이터를 동시에 처리할 경우 재화가 예상보다 적게 차감되는 동작을 확인하였다.
- 이 코드는 단일 시스템에서 동작하는 코드로, 분산 락이 아닌 일반적인 락으로도 해결할 수 있는 상황이다. 하지만 실제 문제와 동일하게 여러 스레드나 서버가 하나의 자원에 동시 접근하는 상황을 재현할 수 있으므로, 실험 결과의 신뢰성에는 문제가 없을 것으로 보인다.
2.1. 실험 결과
- wrk를 사용하여 동일 자원에 대해 다수의 요청을 보냈으며, 다음과 같은 결과가 확인되었다.
테스트에 사용된 lua script
1
2
3
4
5
6
7
8
9
10
11
12
13
14-- 요청 수 제한
local max_requests = 10 -- 요청 수 제한
local request_count = 0 -- 현재 요청 수
request = function()
if request_count >= max_requests then
wrk.thread:stop() -- 요청 제한에 도달하면 쓰레드 중지
end
request_count = request_count + 1
local user_id = 1
local path = "/resources/" .. user_id .. "/consume"
return wrk.format("POST", path)
end- 실험 명령어:
1
wrk -t5 -c20 -d1s -s test_consume.lua http://localhost:8080
- 실험 결과 요약:
- 로그에서 동일 자원에 대해 여러 요청이 동시에 처리되며 자원의 남은 개수가 정확히 감소하지 않는 현상이 나타났다.
- 이는 요청 간 자원 상태가 정확히 반영되지 않고 동일한 초기 상태로 처리된 결과이다.
- 예를 들어, 로그에서 “User 1 자원 1 사용, 남은 자원: 9”가 반복 출력되었으며, 이후 일부 요청만 자원이 감소된 상태를 올바르게 반영하였다.
- 문제 분석:
- 요청이 짧은 시간에 집중적으로 발생하며, 트랜잭션 커밋이 완료되기 전에 다른 요청이 동일 자원의 상태를 조회하고 처리하였다.
- 이러한 동시성 이슈는 예시 코드에서 자원 접근 시 락을 사용하지 않았기 때문에 발생한 것으로 보인다.
- 결과적으로, 여러 요청이 자원을 중복해서 처리하며 데이터의 무결성이 훼손되었다.
3. 문제 해결
분산 락 설계:
- Redisson의 tryLock 메서드를 활용하여 특정 리소스에 대한 락을 획득한다.
- 락 획득에 실패한 요청은 대기하거나 재시도하도록 구현한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public ResponseEntity<String> consumeResource(int userId) {
String lockName = "user:" + userId + ":resource:lock";
RLock lock = redissonClient.getLock(lockName);
int remainingResources = 0;
try {
boolean isLocked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (isLocked) {
remainingResources = resourceService.consumeResource(userId);
}
} catch (RuntimeException | InterruptedException e) {
throw new RuntimeException("Lock 획득 실패", e);
} finally {
if (lock.isHeldByCurrentThread()) { // 락 소유 여부 확인
lock.unlock();
}
}
return ResponseEntity.ok("자원 소비. 남은 자원: " + remainingResources);
}
락 해제 매커니즘:
Redisson 내부적으로 락을 관리하고 해제하는 메커니즘은 Redis Pub/Sub 시스템과 AsyncSemaphore를 활용하여 다음과 같은 방식으로 동작한다:Redisson tryLock() Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// 1. 최초 락 획득 시도
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
return true;
} else {
// 2. 타이머 갱신 및 대기
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
current = System.currentTimeMillis();
// 3. Pub/Sub 채널 구독
CompletableFuture<RedissonLockEntry> subscribeFuture = this.subscribe(threadId);
try {
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException var21) {
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) {
this.unsubscribe(res, threadId);
}
});
}
this.acquireFailed(waitTime, unit, threadId);
return false;
} catch (ExecutionException var22) {
ExecutionException e = var22;
LOGGER.error(e.getMessage(), e);
this.acquireFailed(waitTime, unit, threadId);
return false;
}
try {
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
boolean var25 = false;
return var25;
} else {
boolean var16;
do {
long currentTime = System.currentTimeMillis();
ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
var16 = true;
return var16;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
// 4. Latch 대기와 재시도
currentTime = System.currentTimeMillis();
if (ttl >= 0L && ttl < time) {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
} finally {
// 5. 구독 해제
this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}
}
}
}1. 최초 락 획득 시도
- tryAcquire 메서드를 통해 락 획득 가능 여부를 확인하며, null을 반환하면 현재 락을 바로 획득할 수 있음을 의미한다.
- 락이 이미 선점된 경우, ttl(잠금의 남은 유효 시간)을 반환하여 대기할 시간을 계산한다.
2. 타이머 갱신 및 대기
- 대기 중 경과된 시간을 waitTime에서 차감하며 남은 시간이 0 이하일 경우 대기를 종료하고 false를 반환한다.
3. Pub/Sub 채널 구독
- 락 해제 이벤트를 감지하기 위해 Redis Pub/Sub 채널에 구독을 요청한다.
- subscribeFuture를 통해 락이 해제되었거나 구독에 실패한 경우 작업이 비동기적으로 처리된다.
4. latch 대기와 재시도
- Pub/Sub 채널 구독이 성공한 상태에서 tryAcquire를 반복 호출하며 락 획득을 재시도한다.
- TTL 값에 따라 latch 대기 시간이 설정되며, 다른 스레드에서 락이 해제되면 latch가 해제되어 작업이 이어진다.
5. 구독 해제
- 락을 획득하든 못 하든 메서드를 종료하기 전에 Pub/Sub 구독 리소스를 해제하여 시스템 자원을 정리한다.
3.1. Redisson 적용 후 실험 결과:
2.1. 실험과 동일한 script와 wrk 명령어를 이용해 실험을 진행했으며, 다음과 같은 결과가 확인되었다.
실험 결과 요약:
- Redisson을 사용하여 분산 락을 적용한 결과, 모든 요청이 순차적으로 처리되었으며 데이터의 무결성이 유지되었다.
- 로그에서 확인할 수 있듯이 동일한 자원에 대해 락이 적용되어 요청이 병렬적으로 처리되지 않고 순차적으로 처리되었다.
- 예를 들어, “User 1 자원 1 사용, 남은 자원: 9”에서 시작하여 요청이 처리될 때마다 자원이 정확히 감소하는 모습을 확인할 수 있었다.
- 실험 중에는 잘못된 자원 감소나 동시성 문제가 발생하지 않았다.
문제 해결 확인:
- Redisson의 분산 락을 적용함으로써 동시성 문제로 인한 데이터 무결성 훼손이 해결되었다.
- 요청량이 많을 때에도 대기 상태로 처리되며 자원의 상태가 정확히 갱신되었다.
결과
- 테스트 결과
- Redisson 기반 분산 락을 적용한 후, 중복 트랜잭션 문제가 해결되었다.
- 부하 테스트(wrk)에서 동일한 유저의 데이터를 처리할 때, 데이터 무결성이 유지됨을 확인하였다.
- 이로써 분산 서버 환경에서 발생하던 자원 동시성 문제를 해결할 수 있었다.
논의
Pub/Sub 기반 락 메커니즘은 성능과 자원 활용면에서 유리하며, 이를 사용하는 Redisson을 사용하여 분산 환경에서도 효율적으로 동시성 제어가 가능하지만,
Redis를 따로 구축하여야 한다는 점, Redis의 장애 시 락 메커니즘이 정상적으로 동작하지 않을 수 있다는 점과 락 해제 실패 또는 락 대기 시간 초과 시 추가적 오류 처리가 필요하다는 한계점 등이 존재한다.
해당 한계점들을 개선하기 위해 이후에 Redis Cluster를 활용해 고가용성을 확보하여야하고, Redis 외에 기타 다른 도구(Zookeeper, Etcd 등)와의 성능 비교가 필요하다.
결론
본 글에서는 Redis와 Redisson을 활용한 분산 락 메커니즘을 통해 동시성 이슈를 해결하는 방법을 제시하였다.
실험 결과, 제안된 방법은 데이터 무결성을 효과적으로 유지하며, 부하 테스트에서도 우수한 성능을 보였다.
본 글이 분산 환경에서의 동시성 문제를 해결하려는 개발자들에게 실질적인 가이드라인을 제공하길 기대하며, 본 글에서 사용된 tryLock()
의 내부 작동원리는 동시성 제어를 위한 Redisson tryLock 메서드의 작동 원리에서 확인 할 수 있다.