Redis 기반 분산 락 구현과 고급 최적화 기법

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();
        }
    }
}

위 구현은 재진입성, 자동 만료 연장, 안전한 해제를 모두 보장하는 생산 수준의 분산 락 패턴이다.

태그: Redis 분산락 Redlock Lua 동시성제어

6월 14일 16:38에 게시됨