-
[Redis] 트랜잭션 이해 : 원자성과 일관성 보장의 기술적 접근Redis 2025. 6. 4. 00:48728x90반응형
Redis Transaction
Redis에서도 트랜잭션(Transaction) 개념을 지원합니다.
트랜잭션은 여러 개의 명령어를 하나의 묶음으로 실행하는 것처럼 Redis에서 명령합니다.
MULTI DECR coupon:summer_sale:stock LPUSH user:1:coupons "summer_sale" EXEC해당 예시를 설명해보겠습니다.
- MULTI : 트랜젝션 시작
- DECR, LPUSH 명령어를 Queue에 삽입
- EXEC : Queue에 삽입한 명령어를 한번에 실행
기본 구성
Redis 트랜잭션은 다음 세 가지 명령어를 중심으로 이루어집니다.
- MULTI : 시작
- EXEC : 순차적으로 실행
- DISCARD : Queue안에 있는 데이터 모두 제거
작동 방식
Redis 트랜잭션은 위에서 말했듯이 명령어를 모아뒀다가 EXEC이 호출되면 모두 한번에 순차적으로 실행되지만 꼭 주의해야 할점은 원장성이 보장되지 않는다는 점입니다.
- 원자성이란 모두 성공하거나 모두 실패해야한다 라는 내용입니다.
MULTI SET name "Jang" INCR name # 오류 (문자열을 숫자처럼 증가시킬 수 없음) SET city "Ansan" EXEC위에 있는 트랜젝션을 보게 되면 INCR name이 실패했지만 실제로는 이와 상관없이 SET city "Ansan"이 실행되게 됩니다.
조건부 트랜젝션 (WATCH)
WATCH 명령어는 낙관적 락 방식의 조건부 트랜젝션을 지원한다고 합니다.
- 낙관적 락은 우선 충돌이 많이 일어나지 않는다는 전제를 깔고 일단 실행 후 나중에 문제가 있는지 확인하는 방식입니다.
- 조건부 트랙젠션이라고 하는 이유는 key 변경 ? return : MULTI ~ EXEC 라고 볼 수 있기 때문입니다.
WATCH coupon:summer_sale:stock GET coupon:summer_sale:stock -- 변경되지 않음. SET coupon:summer_sale:stock 15 -- 변경됨. -- 1. coupon:summer_sale:stock이 변경되지 않았다면 실행 MULTI ~~~ EXEC -- 2. coupon:summer_sale:stock이 변경되었다면 MULTI ~ EXEC 무시하지만 여전히 롤백 기능이 없기 때문에 해당 문제를 해결하려면 어떤걸 사용해야할지 아래에서 알아보겠습니다.
Redis를 사용할 때 동시에 접근 시 발생하는 동시성 문제와 데이터 일관성 문제는 항상 고려해야 할 중요한 부분입니다.
이를 해결하기 위한 대표적인 두 가지 방식이 바로 Lua 스크립트와 분산 락입니다.
두 방법 모두 원자성을 보장하지만 적용 시점과 다루는 범위가 다르기 때문에 각각 특징을 살펴보고 언제 어떤 방법을 선택하는 것이 가장 효율적이고 좋은지에 대해 자세히 설명합니다.
1. Lua 스크립트
Lua 스크립트는 여러 Redis 명령어를 하나의 스크립트로 묶어 Redis 서버 내부에서 단일 명령어로 실행되도록 하는 기능입니다.
이 말은 Lua 스크립트가 실행되는 동안 Redis 서버는 다른 모든 명령을 블로킹하고 해당 스크립트만 처리한다는 뜻입니다.
또한 여러 명령어를 단일 명령어로 실행하기 때문에 네트워크 비용도 줄일 수 있습니다.
하지만 Lua 스크립트 실행 도중 오류가 발생하게 되면 오류 직전까지 행위는 모두 유지(저장)되며 스크립트가 종료 됩니다.
이때 자동 롤백 기능을 제공하지 않기 때문에 pcall을 사용해서 오류를 감지하고 명시적으로 보상 로직을 구현해줘야 합니다.
단, Redis 외부 자원(DataBase, 외부 API 등)에 대한 접근시에는 순서 보장을 할 수 없다는 단점이 있습니다.
언제 사용하는게 좋을까?
Redis는 싱글 스레드로 동작하며 클라이언트로부터 들어오는 모든 요청을 큐(Command Queue)에 순서대로 넣고 이 큐에서 명령을 하나씩 꺼내 처리합니다.
1. Redis 내부 작업의 완벽한 원자성이 필요한 경우
예를 들어 Method 내부에서 단순히 Exist 확인 후 set 하는 경우를 예시로 들어보겠습니다.
- A 요청이 EXISTS keyTest 명령을 생성하여 큐에 쌓습니다.
- B 요청이 EXISTS keyTest 명령을 생성하여 큐에 쌓습니다.
- 큐에서 A가 쌓은 명령어를 꺼내 실행하게 되고 결과는 false 입니다.
- A 요청이 SET keyTest This is A명령을 생성하여 큐에 쌓습니다.
- 큐에서 B가 쌓은 명령어를 꺼내 실행하게 되고 결과는 false 입니다.
- B 요청이 SET keyTest This is B명령을 생성하여 큐에 쌓습니다.
- 큐에서 A가 쌓은 명령어를 꺼내 실행하게 되고 SET을 합니다.
- 큐에서 B가 쌓은 명령어를 꺼내 실행하게 되고 덮어 쓰게 됩니다.
최종적으로 keyTest의 값은 This is B가 됩니다.
클라이언트 A는 자신이 mykey를 성공적으로 설정했다고 생각하겠지만 실제로는 B에 의해 덮어쓰여져 데이터 불일치가 발생한 것이며 이는 EXISTS와 SET 명령이 Redis의 명령 큐를 통해 분리된 시간대에 처리되었기 때문에 발생하는 문제입니다.
2. 네트워크 비용 최적화
위에 예시를 생각해 본다면 지금 클라이언트 A는 총 2번 Redis와 네트워크 통신을 하고 있다는 것을 알 수 있습니다.
하지만 Lua 스크립트로 해당 명령을 단일 명령으로 만든다면 1번 네트워크 통신으로 똑같은 결과를 만들어 낼 수 있습니다.
3. 예시 코드
아래 예시 코드의 경우에는 전체 코드가 아닌 핵심 코드만 나타내고 있습니다.
-- Lua Script local key = KEYS[1] local value = ARGV[1] local ttl = tonumber(ARGV[2]) if redis.call('EXISTS', key) == 0 then if ttl and ttl > 0 then redis.call('SETEX', key, ttl, value) -- SETEX: SET with EXpiration else redis.call('SET', key, value) end return 1 else return 0 end -- Spring Code public boolean setValueIfNotExists(String key, String value, String ttl) { ... // ⭐ Lua Script의 장점은 여기서 나타납니다. Long result = redisTemplate.execute(setIfNotExistsScript, keys, args.toArray()); if (result != null && result == 1L) { log.info("키 설정에 성공했습니다. '{}'", key); return true; } else { log.info("Key '{}' 이미 존재하는 키입니다.", key); return false; } }execute()로 Lua Script를 큐에 넣어두기 때문에 네트워크 비용은 단 1번 발생하게 되며 Script 전체가 실행되기 때문에 해당 Script에 있는 모든 명령어가 실행되기 때문에 원자성 또한 보장되게 됩니다.
2. 분산 락
분산 락은 여러 서버 인스턴스들이 공유하는 Redis 외부의 자원 (데이터베이스, 외부 API, 파일 시스템 등)에 대한 접근을 제어하여 동시성 문제 및 순서 보장 문제를 해결할 수 있는데 이 말은 여러 서버 인스턴스들이 동일한 DB 테이블, 외부 결제 시스템 등에 동시에 접근하는 것을 막고 한 번에 하나의 인스턴스만 접근하도록 제어합니다.
언제 사용하는게 좋을까?
Redis 외부 자원의 원자적인 접근이 필요한 경우로 예를 들어 여러 서버 인스턴스가 동시에 데이터베이스의 특정 레코드를 업데이트하려고 할 때 분산 락을 사용하여 한 번에 하나의 인스턴스만 업데이트 작업을 수행하도록 보장할 수 있습니다.
동작 방식
- 락 획득 시도
- 특정 Key(락의 이름)를 레디스에 SETNX 명령어로 설정하려고 시도합니다.
- 이때 key의 값은 락을 요청한 고유 ID로 설정하고 만료 시간(TTL)을 함께 부여하여 데드락을 방지합니다.
- 락 획득 성공
- SETNX 명령이 성공하면 클라이언트는 락을 획득한 것으로 간주하고 공유 자원에 대한 작업을 수행합니다.
- 락 획득 실패
- SETNX 명령이 실패하면 클라이언트는 락을 획득하지 못한 것이므로 일정 시간 대기 후 설정에 따라 재시도하거나 작업을 포기합니다.
- 락 해제
- 작업을 완료한 클라이언트는 락을 해제하기 위해 레디스에서 해당 key를 DEL 명령어로 삭제합니다.
- 이때 락을 획득한 클라이언트만이 락을 해제할 수 있도록 키의 값이 자신의 고유 ID와 일치하는지 확인하는 로직이 추가해야 합니다.
분산 락 구현 라이브러리
분산 락을 직접 구현하는 것은 복잡하고 오류 발생 가능성이 높기 때문에 일반적으로 검증된 라이브러리를 사용하는 것이 좋습니다.
Redis 기반 분산 락을 구현하는 대표적인 라이브러리로는 Redisson는 Redlock 알고리즘을 지원하며 락 획득/해제, 락 대기, 자동 만료 등 분산 락 구현에 필요한 다양한 기능을 제공하여 개발 편의성을 높여줍니다.
1. application.yml 설정
redisson: singleServerConfig: address: "redis://localhost:6379" # password: password # 없다면 쓸 필요 없습니다. connectionMinimumIdleSize: 10 # connectionPool 설정 connectionPoolSize: 64 # connectionPool 설정 idleConnectionTimeout: 10000 connectTimeout: 10000 timeout: 3000 # Redis 명령어 타임아웃 설정2. Redisson Code 설정
이 예시에서는 이벤트 참여 횟수 제한 시나리오를 가정해보겠습니다.
여러 서버에서 동시에 한정된 수의 이벤트 참여를 시도할 때 정확히 정해진 횟수만큼만 참여가 이루어지도록 제어하는 상황입니다.
@Service @Slf4j public class EventParticipationService { @Autowired private RedissonClient redissonClient; // 예시를 위해 Redis에 저장된 값 대신 메모리 상의 AtomicInteger를 사용합니다. // 실제라면 이 값은 Redis의 counter나 DB에 저장되어야 합니다. private final AtomicInteger availableParticipations = new AtomicInteger(5); // 총 5번의 참여 기회 public boolean participateInEvent() { String lockName = "event_participation_lock"; RLock lock = redissonClient.getLock(lockName); boolean isLocked = false; try { // 락 획득 시도: 1초 동안 락을 기다리고, 락 획득 시 3초 후 자동으로 락이 해제됩니다. isLocked = lock.tryLock(1, 3, TimeUnit.SECONDS); if (isLocked) { log.info("Lock acquired for event participation by thread: {}", Thread.currentThread().getName()); // ⭐ 락 획득 성공 // 참여 가능 횟수를 감소시킵니다. if (availableParticipations.get() > 0) { availableParticipations.decrementAndGet(); log.info("Event participation successful! Remaining: {}", availableParticipations.get()); return true; } else { log.warn("Event participation failed: No more participations available."); return false; } } else { log.warn("Failed to acquire lock for event participation by thread: {}. Retrying or skipping...", Thread.currentThread().getName()); // 락 획득 실패 시의 처리 로직 (예: 잠시 대기 후 재시도, 실패 메시지 반환 등) return false; } } catch (InterruptedException e) { // 스레드 인터럽트 발생 시 처리 Thread.currentThread().interrupt(); log.error("Thread interrupted while waiting for lock: {}", e.getMessage()); return false; } finally { if (isLocked) { lock.unlock(); // 획득한 락 해제 log.info("Lock released for event participation by thread: {}", Thread.currentThread().getName()); } } } public int getRemainingParticipations() { return availableParticipations.get(); } }728x90반응형'Redis' 카테고리의 다른 글
Redis Replication 정리 (0) 2025.07.24 [Redis] Caching 전략 및 가이드 (0) 2025.06.18 [공식 문서 훑어보기] 6. Redis AOF 동작 원리 및 리스크 관리 대안 (0) 2025.05.06 [공식 문서 훑어보기] 5. Redis Persistence RDB? AOF? 장단점 알아보기 (0) 2025.05.05 [공식 문서 훑어보기] 4. Redis 기초 실무 팁 (0) 2025.04.29