FireDrago

[Redis] Redis에서 원자성을 어떻게 보장할까? 본문

DB

[Redis] Redis에서 원자성을 어떻게 보장할까?

화이용 2026. 1. 6. 20:37

코드래빗의 리뷰내용

현재 상태를 읽고(Read), 이를 바탕으로 로직을 판단하여, 결과를 업데이트(Write)'하는 과정은 Redis를 사용할 때 매우 빈번하게 발생한다. 흔히 Redis는 싱글 스레드라 안전하다고 생각하기 쉽지만, 애플리케이션 레벨에서 여러 명령어를 조합하는 순간 데이터 정합성은 깨질 위험이 생긴다. 개인프로젝트 코드에서 원자성 문제를 리뷰받고 해결방법을 정리했다.

 

오늘은 Java 환경에서 발생할 수 있는 원자성 결여 문제를 살펴보고, `MULTI/EXEC 트랜잭션`과 `Lua 스크립트`를 비교하여, 상황에 맞는 해결책을 찾아보자

문제코드 예시

public void updateStreams(List<String> newIds, Map<String, String> newData) {
    // [1] 조회: 기존 목록을 가져옴
    Set<String> oldIds = redis.opsForSet().members("active.id");

    // [2] 계산: Java 메모리에서 삭제할 대상(차집합) 계산
    List<String> toDelete = oldIds.stream().filter(id -> !newIds.contains(id)).toList();

    // [3] 수정: 삭제 후 교체 (위험 구간: 1번과 3번 사이에 데이터가 변하면?)
    redis.delete("active.id");
    redis.opsForSet().add("active.id", newIds.toArray(String[]::new));
    
    if (!toDelete.isEmpty()) {
        redis.opsForHash().delete("info", toDelete.toArray());
    }
    redis.opsForHash().putAll("info", newData);
}

먼저, 왜 기존의 Java 방식이 동시성 환경에서 왜 위험한지 살펴보자

이 코드는 읽기-변경-작성패턴의 전형적인 경쟁 상태 문제를 가지고 있다. `members()`를 통해 데이터를 읽어온 시점과 `delete()`를 수행하는 시점 사이에 간격이 있다. 만약 그 사이 다른 서버가 데이터를 수정했다면, 현재 서버는 이미 과거가 되어버린 데이터를 바탕으로 엉뚱한 삭제 및 업데이트 작업을 수행하게 되어 데이터 불일치가 발생한다.

Redis 트랜잭션: MULTI/EXEC

public void updateWithTransaction(List<String> newIds, Map<String, String> newData) {
    // ⚠️ 여전히 조사는 트랜잭션 '밖'에서 해야 함 (내부에서는 결과 참조 불가)
    Set<String> oldIds = redis.opsForSet().members("active.id");
    List<String> toDelete = calculateDiff(oldIds, newIds);

    redis.execute(new SessionCallback<>() {
        public Object execute(RedisOperations ops) {
            ops.multi(); // 큐잉 시작
            
            ops.delete("active.id");
            ops.opsForSet().add("active.id", newIds.toArray());
            ops.opsForHash().putAll("info", newData);
            if (!toDelete.isEmpty()) ops.opsForHash().delete("info", toDelete.toArray());
            
            return ops.exec(); // 일괄 실행
        }
    });
}

그럼 트랜잭션으로 묶으면 되지 않을까?

Redis는 `MULTI/EXEC` 를 사용하여 Redis 트랜잭션을 적용할 수 있다. Redis 트랜잭션은 명령어를 큐에 쌓아두었다가 한 번에 실행하는 방식이다. 따라서 트랜잭션 내부에서 조회한 결과를 바탕으로 if/else 같은 조건부 로직을 실행할 수 없다. 결국 조회는 트랜잭션 밖에서 해야 하고, 그 사이 데이터가 변하는 것을 막으려면 `WATCH`를 도입해 복잡한 낙관적 락(Optimistic Lock) 로직과 재시도 처리를 직접 구현해야 하는 번거로움이 생긴다.

 

또 한가지 RDBMS에 익숙한 사람들에게는 충격적인 사실이 있는데, Redis 트랜잭션 내에서 명령어가 실행되다가 하나가 에러 나도,
나머지 명령어들은 그대로 실행되고 끝난다. 즉, 이전 상태로 되돌리는 '롤백' 기능이 없다.
Redis는 극도의 성능을 지향하기 때문이다. 롤백을 지원하려면 복잡한 상태 관리가 필요한데, 이를 과감히 포기하고 속도를 선택했다.

Lua Script

-- Redis 내부에서 실행될 Lua 로직
local actual_key = KEYS[1] -- "active.id"
local info_key = KEYS[2]   -- "info"
local new_ids = ARGV       -- 새로 들어온 ID들

-- 1. 기존 데이터와 비교하여 삭제할 ID 추출 (SDIFF)
local old_ids = redis.call('SMEMBERS', actual_key)
local to_delete = redis.call('SDIFF', actual_key, 'temp_key_placeholder') -- 실제 로직에선 temp set 활용

-- 2. 원자적 교체
redis.call('DEL', actual_key)
redis.call('SADD', actual_key, unpack(new_ids))

-- 3. 상세 정보 정리
if #to_delete > 0 then
    redis.call('HDEL', info_key, unpack(to_delete))
end
-- ... 나머지 HSET 로직

Redis는 Lua 스크립트를 통해 명령어를 원자적으로 실행할 수 있다. 조회, 비교 로직, 수정을 Redis 서버 내부에서 단 하나의 연산으로 처리하는 해결책이다. 스크립트가 실행되는 동안 다른 명령어가 끼어들지 못하도록 보장하고, Java와 Redis 사이를 여러 번 왔다 갔다 할 필요 없이, 스크립트 한번으로 명령어를 실행하여 네트워크 비용을 절감할 수 있다. 또한 트랜잭션과 달리 스크립트 내부에서 변수를 선언하고 조건문과 반복문을 자유롭게 사용할 수 있다.

대신 스크립트는 최대한 작고 빠르게 실행되도록 작성해야 한다. 만약 스크립트 안에 무한 루프가 있거나 수백만 개의 데이터를 처리하는 무거운 로직이 있다면, 그동안 다른 모든 클라이언트의 요청은 대기 상태에 빠지게된다. Redis는 싱글스레드로 동작하기 때문이다.

 

또한 if문 분기를 통해 논리적으로 변경을 수행하지 않는 구조를 만들 수 있다. Lua 스크립트 역시 호출한 명령어가 잘못 동작했다고 해서 자동 롤백을 제공하지는 않는다. 따라서 write 이전에 조건을 검사하고, 특정 상황에서는 return을 통해 수정 로직 자체를 실행하지 않음으로써 논리적으로 롤백과 유사한 동작을 구현한다.

무엇을 선택해야 할까?

단순히 명령어를 묶어서 실행하는 것만으로 충분하다면 `MULTI/EXEC`를 사용할 수 있다. 하지만 현재 상태를 확인하고 그에 따라 데이터가 유기적으로 변해야 한다면, 데이터 정합성을 위해 Lua 스크립트를 사용하는 것이 가장 안전하고 강력한 선택지다.

+) Lua 잘 다루면 쌀먹도 가능하다고 한다...
https://youtu.be/tYKhCx-8Ogo

'DB' 카테고리의 다른 글

[DB] 트랜잭션  (0) 2024.02.26
[mySQL] 제약조건  (0) 2023.08.07
[mySQL] JOIN 문  (0) 2023.08.03
[DB] SELECT문 총정리  (0) 2023.08.01
[Oracle] INSERT / SELECT / UPDATE / DELETE 기본문법  (0) 2023.05.13