
Redis는 단일 스레드 기반의 이벤트 루프를 사용하기 때문에, 대부분의 경우 동시성에 문제가 발생하지 않습니다.
하지만 애플리케이션 설계에 따라 동시성 문제가 발생할 수 있습니다.
| App1 | Redis | App2 |
|---|---|---|
| Count 조회 | Count = 0 | |
| Count = 0 | Count 조회 | |
| Count += 20; | Count = 20 | |
| Count = 10 | Count += 10; | |
| 나는 | 30을 | 원했는데…. |
Race Condition은 프로그램의 결과값이나 동작이 실행 순서에 따라 달라질 수 있는 상태를 말합니다.
여러 스레드(또는 프로세스)가 동시에 동일한 자원(예: 변수, 메모리, 파일 등)에 접근하고 변경하려고 할 때 발생합니다.
자원을 공유하거나 비동기 방식으로 실행했을 때 발생할 수 있습니다.
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void raceCondition() {
// Thread 1
String val = jedis.get("counter"); // 현재 값 읽기
int newVal = Integer.parseInt(val) + 1; // 값 증가
redisTemplate.opsForValue().set("counter", String.valueOf(newVal)); // 저장
// Thread 2
String val2 = jedis.get("counter"); // 동일한 시점에서 읽음
int newVal2 = Integer.parseInt(val2) + 1; // 값 증가
redisTemplate.opsForValue().set("counter", String.valueOf(newVal2)); // 저장
}
'
'
원했던 값 : 2
실제 값 : 1
Redis에서 제공하는 원자적 명령 (INCR, DECR, HSETNX 등)을 사용할 수 있습니다.
원자적 명령을 사용한다고 모든 동시성을 해결할 수 있지는 않습니다.
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void incrementCounter() {
String key = "counter";
// INCR 명령은 원자적으로 실행됨
redisTemplate.opsForValue().increment(key, 1);
}
Redis의 트랜잭션 기능 (MULTI와 EXEC)을 사용하여 여러 명령을 원자적으로 실행할 수 있으며, 여러 명령이 중간에 방해받지 않고 실행되도록 보장합니다.
Redis에서는 트랜잭션을 체크 포인팅 수단으로 사용하거나, 나중에 취소해야 할 수도 있는 작업들에 사용하지 않습니다.
@Autowired
private RedisTemplate<String, String> redisTemplate;
public void executeTransaction() {
String key = "counter";
// 트랜잭션 시작
redisTemplate.execute(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
// WATCH로 키 감시
operations.watch(key);
// 값 읽기
String val = (String) operations.opsForValue().get(key);
if (val != null) {
int currentValue = Integer.parseInt(val);
int newValue = currentValue + 1; // 값 증가
// 트랜잭션 시작
operations.multi(); // MULTI 명령 호출
operations.opsForValue().set(key, String.valueOf(newValue)); // 값 설정
// 트랜잭션 실행
return operations.exec(); // EXEC 명령 호출
}
return null; // 키가 없을 경우 null 반환
}
});
}
[WATCH]
WATCH 명령은 트랜잭션(MULTI/EXEC)과 함께 사용되는 낙관적 잠금(Optimistic Locking) 메커니즘을 제공합니다. 이를 통해 트랜잭션이 실행되는 동안 특정 키의 변경 여부를 감시하여, 다른 클라이언트가 데이터를 변경하면 트랜잭션이 실패하도록 합니다.
[문제 예시]
ex) 입찰 금액이 5달러인 상황에서 10달러 입찰 15달러 입찰을 요청하게되면 유효한 입찰임에도 동시성 문제로 10달러로 변환하게됨.
→ 일정 횟수 재시도하는 로직을 추가해 해결할 수 있지만 많은 부하가 발생할 것
[관련 메서드]
| multi() | Redis 트랜잭션 시작 |
|---|---|
| exeu() | 큐에 저장된 모든 명령 실행 및 트랙잭션 종료. 리스트 반환 |
| discard() | 현재 트랜잭션 취소 및 큐에 저장된 명령 삭제. |
| watch(K key) | key를 감시. 해당 메서드 호출 이후 감시되고 있는 키의 값이 변경되면 exec() 메서드 호출 시 트랜잭션 실패. 빈 결과를 반환 |
| watch(Collection keys) | 전달된 key들을 감시. |
| unwatch() | watch()로 설정된 모든 키의 감시를 해제. exec() 호출 시 자동으로 감시되는 키들은 해제된다. |
| execute(SessionCallback session) | SessionCallback 구현체를 인자로 전달 받아 트랜잭션을 실행하고 트랜잭션 실행 결과를 리턴 받는다. |
| 트랜잭션 처리 혹은 파이프라이닝에 사용된다. |
Redis는 싱글 스레드 기반이기 때문에 SET NX 명령어를 통해 간단한 lock 구현이 가능하다.
[Spring Data Redis]
@Service
public class RedisLockService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 락을 걸기 위한 메서드
public boolean lock(String lockKey, String value) {
// 직접 RedisTemplate을 사용하여 락을 설정
Boolean isLockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, value);
// 락을 걸었다면, TTL(Time to Live)을 설정하여 락이 영구히 유지되지 않도록 합니다.
if (isLockAcquired != null && isLockAcquired) {
// TTL 설정: 10초 동안 락 유지
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
}
return isLockAcquired != null && isLockAcquired;
}
// 락을 해제하는 메서드
public void unlock(String lockKey, String value) {
// 직접 RedisTemplate을 사용하여 락을 해제
String currentValue = redisTemplate.opsForValue().get(lockKey);
// 현재 락을 설정한 값과 동일한 값만 락을 해제할 수 있도록 설정
if (value.equals(currentValue)) {
redisTemplate.delete(lockKey); // 락 해제
}
}
// 락을 사용하여 실행할 작업
public void performTaskWithLock(String lockKey, String value) {
boolean isLocked = lock(lockKey, value);
if (isLocked) {
try {
// 락을 획득한 후 작업 수행
System.out.println("작업을 진행합니다...");
// 여기서 실제 작업을 진행합니다.
Thread.sleep(5000); // 예시로 5초 동안 작업을 진행
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 작업이 끝난 후 락을 해제
unlock(lockKey, value);
System.out.println("작업 완료 후 락 해제.");
}
} else {
System.out.println("이미 다른 프로세스가 락을 보유 중입니다.");
}
}
}
Lettuce(Spring Data Redis)는 ‘스핀락’이라고 불리는 폴링 기법을 통해 Lock 획득을 재시도한다. 이는 Redis에 지속적인 요청을 보내기때문에 부하가 발생할 수 있다.
Redisson은 Redis의 Pub/Sub 기능을 사용해 Lock 획득을 재시도한다.
[Redisson]
@Service
public class LockService {
// RedissonClient에 대한 설정 파일이 따로 있어야한다
@Autowired
private RedissonClient redissonClient;
public void performLockedOperation() {
RLock lock = redissonClient.getLock("myLock");
try {
// 락을 획득
lock.lock();
// 락을 획득한 후 수행할 작업
System.out.println("작업을 진행 중입니다...");
} finally {
// 락 해제
lock.unlock();
}
}
}
[다중 인스턴스 환경]
Redis 클러스터에서 여러 샤드에 락을 걸려면 Redisson의 RedLock과 같은 분산 락 알고리즘을 이용해야한다.
LUA 스크립트는 Redis 서버에서 실행되므로, 스크립트 내의 모든 명령어는 원자적으로 실행됩니다.
Redis에서 루아 스크립트를 실행시키면 Redis는 다른 어떠한 명령도 처리하지 않게 된다. → Why? 단일 스레드
@Autowired
private RedisTemplate<String, String> redisTemplate;
public boolean acquireLock(String lockKey, String lockValue, long timeout) {
// LUA 스크립트: key를 설정하고 값이 없으면 성공적으로 설정
String script =
"if redis.call('exists', KEYS[1]) == 0 then " +
" redis.call('setex', KEYS[1], ARGV[1], ARGV[2]); " +
" return 1; " +
"else " +
" return 0; " +
"end";
// executeScript를 사용하여 LUA 스크립트 실행
Long result = (Long) redisTemplate.execute(
(redisConnection) -> redisConnection.eval(
script.getBytes(),
ReturnType.INTEGER,
1, // key 갯수
lockKey.getBytes(),
String.valueOf(timeout).getBytes(),
lockValue.getBytes()
)
);
// 1이면 락 획득 성공, 0이면 락이 이미 획득됨
return result != null && result == 1;
}
public void releaseLock(String lockKey, String lockValue) {
// LUA 스크립트: key가 lockValue와 일치하면 삭제
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" redis.call('del', KEYS[1]); " +
" return 1; " +
"else " +
" return 0; " +
"end";
redisTemplate.execute(
(redisConnection) -> redisConnection.eval(
script.getBytes(),
ReturnType.INTEGER,
1, // key 갯수
lockKey.getBytes(),
lockValue.getBytes()
)
);
}
[Lua Script를 사용하는 경우]
[단점]