Redis를 활용한 재고 관리 시스템에서 발생하는 동시성 문제를 해결하기 위한 다양한 락 메커니즘을 살펴본다. 특히 분산 환경에서 안전하게 동작하는 락 구현 방식에 초점을 맞춘다.
문제 상황: 비원자적 재고 차감
다음은 Redis에 저장된 재고를 차감하는 기본 코드다. 읽기와 쓰기가 분리되어 있어 동시 실행 시 데이터 불일치가 발생한다.
@Autowired
private StringRedisTemplate stringRedisTemplate;
public void decreaseStock() {
String currentStock = stringRedisTemplate.opsForValue().get("inventory");
if (StringUtils.hasText(currentStock)) {
int quantity = Integer.parseInt(currentStock);
if (quantity > 0) {
stringRedisTemplate.opsForValue().set("inventory", String.valueOf(quantity - 1));
}
}
}
해결 방안 1: Redis 낙관적 락
Redis의 WATCH, MULTI, EXEC 명령어 조합을 활용한 트잭션 기반 접근법이다.
public void decreaseWithOptimisticLock() {
stringRedisTemplate.execute(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) throws DataAccessException {
operations.watch("inventory");
Object stock = operations.opsForValue().get("inventory");
int available = 0;
if (stock != null && (available = Integer.parseInt(stock.toString())) > 0) {
operations.multi();
operations.opsForValue().set("inventory", String.valueOf(available - 1));
List<Object> results = operations.exec();
if (results == null || results.isEmpty()) {
try {
Thread.sleep(40);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
decreaseWithOptimisticLock();
}
return results;
}
return null;
}
});
}
낙관적 락은 충돌이 드문 환경에서 효과적이나, 높은 동시성 상황에서는 재시도 오버헤드로 인해 성능 저하가 발생할 수 있다.
해결 방안 2: 분산 락 기초 구현
SETNX 기반 단순 락
키의 존재 여부를 활용한 상호 배제 메커니즘이다.
public void decreaseWithBasicLock() {
String lockValue = "acquired";
Boolean obtained = stringRedisTemplate.opsForValue()
.setIfAbsent("resource_lock", lockValue, Duration.ofSeconds(5));
if (!Boolean.TRUE.equals(obtained)) {
try {
Thread.sleep(80);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
decreaseWithBasicLock();
return;
}
try {
String currentStock = stringRedisTemplate.opsForValue().get("inventory");
if (StringUtils.hasText(currentStock)) {
int quantity = Integer.parseInt(currentStock);
if (quantity > 0) {
stringRedisTemplate.opsForValue().set("inventory", String.valueOf(quantity - 1));
}
}
} finally {
stringRedisTemplate.delete("resource_lock");
}
}
만료 시간 설정의 중요성
서버 장애로 인한 데드락 방지를 위해 반드시 TTL을 설정해야 한다. Redis 2.6.12 이상에서는 SET key value EX seconds NX 명령어로 원자적 처리가 가능하다.
고급 구현: 안전한 분산 락
오인 삭제 방지를 위한 소유자 식별
UUID 기반 소유자 검증으로 타 프로세스의 락을 실수로 삭제하는 상황을 방지한다.
public void decreaseWithOwnerVerification() {
String ownerId = UUID.randomUUID().toString();
while (!Boolean.TRUE.equals(stringRedisTemplate.opsForValue()
.setIfAbsent("resource_lock", ownerId, Duration.ofSeconds(3)))) {
try {
Thread.sleep(80);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
try {
processStockDecrease();
} finally {
String currentOwner = stringRedisTemplate.opsForValue().get("resource_lock");
if (ownerId.equals(currentOwner)) {
stringRedisTemplate.delete("resource_lock");
}
}
}
LUA 스크립트를 통한 원자적 검증-삭제
검색과 삭제 사이의 경쟁 조건을 제거하기 위해 단일 원자적 연산으로 통합한다.
public void decreaseWithAtomicRelease() {
String ownerId = UUID.randomUUID().toString();
acquireLockWithRetry("resource_lock", ownerId, 3);
try {
processStockDecrease();
} finally {
String releaseScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
stringRedisTemplate.execute(
new DefaultRedisScript<>(releaseScript, Boolean.class),
Collections.singletonList("resource_lock"),
ownerId
);
}
}
private void acquireLockWithRetry(String lockKey, String ownerId, int ttlSeconds) {
while (!Boolean.TRUE.equals(stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, ownerId, Duration.ofSeconds(ttlSeconds)))) {
try {
Thread.sleep(60);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
재진입 가능한 분산 락 구현
동일 스레드 내에서 락을 중첩 획득할 수 있는 구조가 필요하다. Redis Hash 자료구조와 스레드 로컬 변수를 활용한다.
락 획득 로직 (LUA)
// 획득 스크립트: 락이 없거나 본인 소유면 카운트 증가
String ACQUIRE_SCRIPT =
"if redis.call('exists', KEYS[1]) == 0 then " +
" redis.call('hset', KEYS[1], ARGV[1], 1) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"elseif redis.call('hexists', KEYS[1], ARGV[1]) == 1 then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
락 해제 로직 (LUA)
// 해제 스크립트: 카운트 감소 후 0이면 삭제
String RELEASE_SCRIPT =
"if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then " +
" return nil " +
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
완전한 재진입 락 클래스
public class ReentrantRedisLock implements Lock {
private final StringRedisTemplate template;
private final String resourceName;
private final long defaultTtl;
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
public ReentrantRedisLock(StringRedisTemplate template, String resourceName) {
this(template, resourceName, 30);
}
public ReentrantRedisLock(StringRedisTemplate template, String resourceName, long ttlSeconds) {
this.template = template;
this.resourceName = resourceName;
this.defaultTtl = ttlSeconds;
}
@Override
public void lock() {
tryLock(defaultTtl, TimeUnit.SECONDS);
}
@Override
public boolean tryLock(long timeout, TimeUnit unit) {
String nodeId = obtainNodeIdentifier();
long ttlInSeconds = unit.toSeconds(timeout);
String acquireLua =
"if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then " +
" redis.call('hincrby', KEYS[1], ARGV[1], 1) " +
" redis.call('expire', KEYS[1], ARGV[2]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
while (!Boolean.TRUE.equals(template.execute(
new DefaultRedisScript<>(acquireLua, Boolean.class),
Collections.singletonList(resourceName),
nodeId, String.valueOf(ttlInSeconds)))) {
try {
Thread.sleep(40);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
startRenewalTask(nodeId, ttlInSeconds);
return true;
}
private String obtainNodeIdentifier() {
String id = CONTEXT_HOLDER.get();
if (id == null) {
id = UUID.randomUUID().toString();
CONTEXT_HOLDER.set(id);
}
return id;
}
@Override
public void unlock() {
String nodeId = CONTEXT_HOLDER.get();
if (nodeId == null) {
throw new IllegalMonitorStateException("Lock not held by current thread");
}
String releaseLua =
"if redis.call('hexists', KEYS[1], ARGV[1]) == 0 then " +
" return nil " +
"elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = template.execute(
new DefaultRedisScript<>(releaseLua, Long.class),
Collections.singletonList(resourceName),
nodeId
);
if (result == null) {
throw new IllegalMonitorStateException("Lock ownership mismatch");
}
if (result == 1) {
CONTEXT_HOLDER.remove();
stopRenewalTask();
}
}
// 자동 연장 메커니즘
private final ScheduledExecutorService renewalExecutor = Executors.newSingleThreadScheduledExecutor();
private ScheduledFuture<?> renewalTask;
private void startRenewalTask(String nodeId, long ttlSeconds) {
String renewLua =
"if redis.call('hexists', KEYS[1], ARGV[1]) == 1 then " +
" return redis.call('expire', KEYS[1], ARGV[2]) " +
"else " +
" return 0 " +
"end";
long renewalInterval = ttlSeconds * 1000 / 3;
renewalTask = renewalExecutor.scheduleAtFixedRate(() -> {
template.execute(
new DefaultRedisScript<>(renewLua, Boolean.class),
Collections.singletonList(resourceName),
nodeId, String.valueOf(ttlSeconds)
);
}, renewalInterval, renewalInterval, TimeUnit.MILLISECONDS);
}
private void stopRenewalTask() {
if (renewalTask != null) {
renewalTask.cancel(false);
}
}
@Override
public boolean tryLock() { return tryLock(defaultTtl, TimeUnit.SECONDS); }
@Override
public void lockInterruptibly() throws InterruptedException { lock(); }
@Override
public Condition newCondition() { throw new UnsupportedOperationException(); }
}
클러스터 환경의 한계와 Redlock 알고리즘
Redis 마스터-슬레이브 복제 구조에서는 장애 전환 시 락 유실 가능성이 존재한다. 이를 해결하기 위해 Redis 공식 문서에서 제안하는 Redlock 알고리즘을 적용할 수 있다.
Redlock 핵심 원리
N개의 독립적인 Redis 마스터 인스턴스(보통 5개)를 운영하고, 과반수 이상에서 락 획득에 성공해야 유효한 락으로 인정한다.
public class RedlockClient {
private final List<RedisConnection> redisNodes;
private final int quorumSize;
private final long lockTtlMillis;
public RedlockClient(List<RedisConnection> nodes, long ttlMillis) {
this.redisNodes = nodes;
this.quorumSize = nodes.size() / 2 + 1;
this.lockTtlMillis = ttlMillis;
}
public Optional<RedlockToken> tryAcquire(String resourceKey, String uniqueValue) {
long startTime = System.currentTimeMillis();
int successCount = 0;
List<RedisConnection> lockedNodes = new ArrayList<>();
for (RedisConnection node : redisNodes) {
long individualTimeout = 50; // 밀리초 단위
if (acquireSingleNode(node, resourceKey, uniqueValue, lockTtlMillis, individualTimeout)) {
successCount++;
lockedNodes.add(node);
}
}
long elapsed = System.currentTimeMillis() - startTime;
long remainingTtl = lockTtlMillis - elapsed;
if (successCount >= quorumSize && remainingTtl > 0) {
return Optional.of(new RedlockToken(resourceKey, uniqueValue, lockedNodes, remainingTtl));
}
// 획득 실패 시 즉시 해제 시도
lockedNodes.forEach(node -> releaseSingleNode(node, resourceKey, uniqueValue));
return Optional.empty();
}
private boolean acquireSingleNode(RedisConnection node, String key, String value,
long ttl, long timeoutMillis) {
// 개별 노드에서 SET key value PX milliseconds NX 실행
// timeoutMillis 내에 응답 없으면 실패로 간주
return node.setWithExpiryIfAbsent(key, value, ttl, timeoutMillis);
}
public void release(RedlockToken token) {
token.getNodes().forEach(node ->
releaseSingleNode(node, token.getResource(), token.getValue())
);
}
}
실무에서는 이러한 복잡한 구현을 직접 하기보다 Redisson 라이브러리의 RedissonRedLock 구현체를 활용하는 것이 권장된다.
적용 예시: 재고 차감 서비스
@Service
public class InventoryService {
@Autowired
private StringRedisTemplate redisTemplate;
public void processOrder(String productId, int quantity) {
ReentrantRedisLock lock = new ReentrantRedisLock(redisTemplate, "lock:product:" + productId);
lock.lock();
try {
String key = "stock:" + productId;
String current = redisTemplate.opsForValue().get(key);
if (current == null) throw new IllegalStateException("Product not found");
int available = Integer.parseInt(current);
if (available < quantity) throw new IllegalStateException("Insufficient stock");
redisTemplate.opsForValue().set(key, String.valueOf(available - quantity));
} finally {
lock.unlock();
}
}
}
위 구현은 재진입성, 자동 만료 연장, 안전한 해제를 모두 보장하는 생산 수준의 분산 락 패턴이다.