From 0e28534bce48944fce1789ef4cbbf5c4538ff0b7 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Tue, 19 May 2026 12:37:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20enqueue/delete=20=EC=9B=90?= =?UTF-8?q?=EC=9E=90=EC=84=B1=20+=20score=20tie-breaker=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20(Lua=20Script)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enqueue.lua / delete.lua: SETNX + ZADD + HSET 을 단일 Lua 스크립트로 통합 (원자성) - score 생성: epoch_milli * 1000000 + INCR 시퀀스 (동시 진입 FIFO 보장) - RedisScriptConfig: DefaultRedisScript 빈 등록 - RedisQueueTokenRepository: Lua 스크립트 기반 enqueue/delete 정정 - deleteAllByProgramId: 역인덱스 compare-and-delete (동시 발급 토큰 보호) + seq 키 정리 Closes #9, #11 --- .../redis/RedisQueueTokenRepository.java | 261 +++++------------- .../redis/RedisScriptConfig.java | 40 +++ src/main/resources/lua/delete.lua | 46 +++ src/main/resources/lua/enqueue.lua | 62 +++++ 4 files changed, 210 insertions(+), 199 deletions(-) create mode 100644 src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisScriptConfig.java create mode 100644 src/main/resources/lua/delete.lua create mode 100644 src/main/resources/lua/enqueue.lua diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java index 7ff0b3b..d2002d6 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java @@ -12,10 +12,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.*; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Repository; import java.nio.charset.StandardCharsets; -import java.time.Duration; import java.util.*; /** @@ -23,13 +23,14 @@ * *

3가지 자료구조를 조합하여 사용한다: *

* - *

저장 시 역인덱스(setIfAbsent)와 트랜잭션(MULTI/EXEC)으로 일관성을 보장한다. - * 자세한 흐름은 {@link #enqueue(QueueToken)} 참고. + *

enqueue / delete 는 Lua 스크립트로 원자 처리한다.

*/ @Slf4j @Repository @@ -41,6 +42,7 @@ public class RedisQueueTokenRepository implements QueueTokenRepository { private static final String PROGRAM_KEY_PREFIX = "program:"; private static final String TOKEN_KEY_PREFIX = "token:"; private static final String USER_KEY_PREFIX = "user:"; + private static final String SEQ_KEY_PREFIX = "queue:seq:"; // ===== Hash 필드 이름 상수 ===== private static final String FIELD_USER_ID = "userId"; @@ -51,97 +53,65 @@ public class RedisQueueTokenRepository implements QueueTokenRepository { private final StringRedisTemplate redisTemplate; private final QueueProperties properties; + private final DefaultRedisScript enqueueScript; + private final DefaultRedisScript deleteScript; /** - * Redis 기반 enqueue 구현. + * Redis 기반 enqueue 구현 (Lua 스크립트로 원자 처리). * - *

2단계 처리: + *

Lua 스크립트 안에서 한 트랜잭션으로: *

    - *
  1. 역인덱스(setIfAbsent)를 락처럼 사용하여 중복 진입 방지
  2. - *
  3. Sorted Set + Hash 를 MULTI/EXEC 트랜잭션으로 저장
  4. + *
  5. 역인덱스 SETNX 로 중복 진입 방지
  6. + *
  7. INCR 시퀀스 + epoch_milli 로 tie-breaker score 생성
  8. + *
  9. Sorted Set 추가 (ZADD)
  10. + *
  11. Hash 메타 저장 (HSET) + TTL (EXPIRE)
  12. *
- * - *

한계: 1단계 성공 후 2단계 실패 시 orphan 역인덱스가 남을 수 있다. - * TTL 로 자동 정리되지만, 진짜 원자성을 위해 향후 Lua Script 도입 예정. - * - *

Sorted Set 의 멤버는 키 단위 TTL 불가하므로, - * {@link #delete(QueueToken)} 호출 시 명시적 ZREM 으로 정리한다. */ @Override public void enqueue(QueueToken token) { - - // 키 생성 String programKey = programKey(token.getProgramId()); String tokenKey = tokenKey(token.getId()); String userProgramKey = userProgramKey(token.getUserId(), token.getProgramId()); + String seqKey = seqKey(token.getProgramId()); String tokenIdStr = token.getId().asString(); - - // Sorted Set의 score로 사용할 진입 시각 (epoch milli) long issuedAtEpochMilli = token.getIssuedAt().toEpochMilli(); + long ttlSeconds = properties.waitingTtl().getSeconds(); + + Long result = redisTemplate.execute( + enqueueScript, + List.of(userProgramKey, programKey, tokenKey, seqKey), + tokenIdStr, + token.getUserId().asString(), + token.getProgramId().asString(), + String.valueOf(issuedAtEpochMilli), + token.getStatus().name(), + String.valueOf(ttlSeconds) + ); - Duration ttl = properties.waitingTtl(); - - // 1단계: 역인덱스를 락처럼 사용하여 중복 진입 방지 - Boolean acquired = redisTemplate.opsForValue() - .setIfAbsent(userProgramKey, tokenIdStr, ttl); - - if (!Boolean.TRUE.equals(acquired)) { + if (result == null || result == 0L) { throw new DuplicateTokenException(); } - - // 2단계: 나머지 키를 트랜잭션으로 저장 - Map tokenFields = Map.of( - FIELD_USER_ID, token.getUserId().asString(), - FIELD_PROGRAM_ID, token.getProgramId().asString(), - FIELD_ISSUED_AT, String.valueOf(issuedAtEpochMilli), - FIELD_STATUS, token.getStatus().name() - ); - - // 트랜잭션으로 Sorted Set + Hash 저장 + Hash TTL 설정 - redisTemplate.execute(new SessionCallback>() { - @SuppressWarnings({"unchecked"}) - @Override - public List execute(RedisOperations operations) { - operations.multi(); - - // 1. Sorted Set: 대기열에 추가 (score = 진입 시각) - operations.opsForZSet().add(programKey, tokenIdStr, issuedAtEpochMilli); - - // 2. Hash: 토큰 메타 데이터 저장 - operations.opsForHash().putAll(tokenKey, tokenFields); - - // 3. Hash 키에 TTL 설정 (Sorted Set은 컬렉션이라 별도 정리 필요) - operations.expire(tokenKey, ttl); - - return operations.exec(); - } - }); } /** * Redis 기반 findById 구현. * *

Hash 전체 조회 후 도메인 객체로 복원한다. - * 토큰이 없으면 빈 Map이 반환되며 (null 아님), Optional.empty()로 처리한다. + * 토큰이 없으면 빈 Map 이 반환되며 (null 아님), Optional.empty() 로 처리한다. */ @Override public Optional findById(QueueTokenId id) { String tokenKey = tokenKey(id); - // 타입 명시한 Map 으로 받기 위해 hashOps 변수 사용 HashOperations hashOps = redisTemplate.opsForHash(); - // Redis 에서 Hash 전체 조회 Map entries = hashOps.entries(tokenKey); - // 토큰 없으면 빈 Map 이 옴 (null 아님) if (entries.isEmpty()) { return Optional.empty(); } - // 깨진 레코드 자동 정리 향후 도입 try { - // Hash 데이터로 QueueToken 객체 만들어서 반환 return Optional.of(toQueueToken(id, entries)); } catch (Exception e) { log.warn("깨진 Hash 레코드 발견. tokenId={}", id.asString(), e); @@ -162,41 +132,29 @@ private QueueToken toQueueToken(QueueTokenId id, Map entries) { return QueueToken.restore(id, userId, programId, issuedAt, status, entryToken); } - /** - * Redis 기반 findByUserIdAndProgramId 구현. - * - *

2단계 조회: - *

    - *
  1. 역인덱스로 tokenId 조회
  2. - *
  3. tokenId로 토큰 전체 조회 ({@link #findById} 재사용)
  4. - *
- */ @Override public Optional findByUserIdAndProgramId(UserId userId, ProgramId programId) { String userProgramKey = userProgramKey(userId, programId); - // 1단계: 역인덱스로 tokenId 조회 String tokenIdStr = redisTemplate.opsForValue().get(userProgramKey); - if (tokenIdStr == null) { return Optional.empty(); } - // 2단계: tokenId로 토큰 전체 조회 (findById 재사용) return findById(QueueTokenId.fromString(tokenIdStr)); } /** - * Redis 기반 delete 구현. + * Redis 기반 delete 구현 (Lua 스크립트로 원자 처리). * - *

3개 키를 트랜잭션으로 묶어 삭제한다: + *

Lua 스크립트 안에서 한 트랜잭션으로: *

    - *
  1. Sorted Set 에서 토큰 멤버 제거 (ZREM)
  2. - *
  3. Hash 키 삭제
  4. - *
  5. 역인덱스 키 삭제
  6. + *
  7. 역인덱스 compare-and-delete (다른 토큰 차지 시 보존)
  8. + *
  9. Sorted Set 멤버 제거 (ZREM)
  10. + *
  11. Hash 키 삭제 (DEL)
  12. *
* - *

이미 만료/삭제된 토큰에 대해서도 안전하게 호출 가능 (멱등). + *

이미 만료 / 삭제된 토큰에 대해서도 안전하게 호출 가능 (멱등).

*/ @Override public void delete(QueueToken token) { @@ -205,75 +163,40 @@ public void delete(QueueToken token) { String userProgramKey = userProgramKey(token.getUserId(), token.getProgramId()); String tokenIdStr = token.getId().asString(); - // 역인덱스 값과 토큰 ID가 일치할 때만 삭제 - // (다른 토큰이 차지한 경우는 그대로 둠 — race condition 방어) - String current = redisTemplate.opsForValue().get(userProgramKey); - if (tokenIdStr.equals(current)) { - redisTemplate.delete(userProgramKey); - } - - redisTemplate.execute(new SessionCallback>() { - @SuppressWarnings({"unchecked"}) - @Override - public List execute(RedisOperations operations) { - operations.multi(); - - // 1. Sorted Set 에서 멤버 제거 - operations.opsForZSet().remove(programKey, tokenIdStr); - - // 2. Hash 키 삭제 - operations.delete(tokenKey); - - return operations.exec(); - } - }); + redisTemplate.execute( + deleteScript, + List.of(userProgramKey, programKey, tokenKey), + tokenIdStr + ); } /** * Redis 기반 findPosition 구현. * - *

2단계 조회: - *

    - *
  1. 역인덱스로 tokenId 조회
  2. - *
  3. Sorted Set의 ZRANK로 순번 조회
  4. - *
- * - *

Redis ZRANK는 0-based이므로 사용자에게 보여줄 1-based로 변환한다. + *

Redis ZRANK 는 0-based 이므로 사용자에게 보여줄 1-based 로 변환한다. */ @Override public Optional findPosition(UserId userId, ProgramId programId) { String userProgramKey = userProgramKey(userId, programId); - // 1단계: 역인덱스로 tokenId 조회 String tokenIdStr = redisTemplate.opsForValue().get(userProgramKey); - if (tokenIdStr == null) { return Optional.empty(); } - // 2단계: Sorted Set에서 순번 조회 (0-based) String programKey = programKey(programId); Long rank = redisTemplate.opsForZSet().rank(programKey, tokenIdStr); - if (rank == null) { return Optional.empty(); } - // 3단계: 1-based로 변환하여 반환 return Optional.of(rank + 1); } /** * Redis 기반 findAdmissionCandidates 구현. * - *

2단계 조회: - *

    - *
  1. Sorted Set ZRANGE로 앞에서 batchSize개의 tokenId 추출
  2. - *
  3. 각 tokenId로 토큰 전체 조회 (N번 Hash 조회)
  4. - *
- * - *

Hash가 만료되어 토큰을 복원할 수 없는 orphan 케이스는 자연 필터링된다. - * 향후 N번 조회를 단일 Lua Script로 통합 검토. + *

Hash 가 만료된 orphan 케이스는 findById 측에서 빈 Map 으로 자연 필터링된다.

*/ @Override public List findAdmissionCandidates(ProgramId programId, int batchSize) { @@ -282,21 +205,15 @@ public List findAdmissionCandidates(ProgramId programId, int batchSi } String programKey = programKey(programId); - - // 1단계: Sorted Set에서 앞에서부터 batchSize명의 tokenId 조회 Set tokenIds = redisTemplate.opsForZSet().range(programKey, 0, batchSize - 1); if (tokenIds == null || tokenIds.isEmpty()) { return List.of(); } - // 2단계: 각 tokenId로 토큰 전체 조회 return tokenIds.stream() - // String → Optional .map(tokenIdStr -> findById(QueueTokenId.fromString(tokenIdStr))) - // 토큰 있는 것만 .filter(Optional::isPresent) - // Optional → QueueToken .map(Optional::get) .toList(); } @@ -304,13 +221,13 @@ public List findAdmissionCandidates(ProgramId programId, int batchSi /** * Redis 기반 admit 구현. * - *

2가지 작업을 트랜잭션으로 처리한다: + *

2 가지 작업을 트랜잭션으로 처리한다: *

    *
  1. Sorted Set 에서 토큰 멤버 제거 (큐에서 빠짐 → position 조회 X)
  2. *
  3. Hash 의 status / entryToken 업데이트
  4. *
* - *

역인덱스는 유지 — 사용자가 GET 으로 자기 토큰 조회 가능 (status: ADMITTED 응답). + *

역인덱스는 유지 — 사용자가 GET 으로 자기 토큰 조회 가능 (status: ADMITTED 응답).

*/ public void admit(QueueToken token) { String programKey = programKey(token.getProgramId()); @@ -327,70 +244,13 @@ public void admit(QueueToken token) { @Override public List execute(RedisOperations operations) { operations.multi(); - - // 1. Sorted Set 에서 멤버 제거 (큐에서 빠짐) operations.opsForZSet().remove(programKey, tokenIdStr); - - // 2. Hash 의 status / entryToken 업데이트 operations.opsForHash().putAll(tokenKey, updates); - return operations.exec(); } }); } -// program-service 와 kafka 이벤트 통합 후 교체 완료 -// -// /** -// * Redis 기반 findActiveProgramIds 구현. -// * -// *

{@code queue:program:*} 패턴으로 SCAN 하여 활성 프로그램 ID 목록을 반환한다. -// * -// *

SCAN 사용 이유: KEYS 명령은 production 에서 블로킹 발생 위험. SCAN 은 점진적. -// * -// *

Future Work

-// * 본 메서드는 MVP 의 가정 (큐 존재 = 활성 프로그램) 을 따른다. -// *

program-service 와 Kafka 이벤트 통합 후엔: -// *

    -// *
  • {@code program.opened} 이벤트 → Redis Set 의 활성 프로그램 추가
  • -// *
  • {@code program.closed} 이벤트 → Set 에서 제거
  • -// *
  • 본 메서드는 Redis Set 직접 조회 (SCAN 불필요)
  • -// *
-// */ -// public List findActiveProgramIds() { -// String pattern = QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX + "*"; -// -// // 1. SCAN 으로 모든 큐 키 수집 -// Set keys = redisTemplate.execute((RedisCallback>) connection -> { -// Set result = new HashSet<>(); -// ScanOptions options = ScanOptions.scanOptions() -// .match(pattern) // queue:program:* 매칭 -// .count(100) // 한 번에 100 개씩 점진적 조회 -// .build(); -// -// // try-with-resources 로 cursor 자동 정리 -// try (Cursor cursor = connection.scan(options)) { -// while (cursor.hasNext()) { -// // Redis 는 byte[] 반환 → UTF-8 문자열로 변환 -// result.add(new String(cursor.next(), StandardCharsets.UTF_8)); -// } -// } -// return result; -// }); -// -// if (keys == null || keys.isEmpty()) { -// return List.of(); -// } -// -// // 2. 키에서 UUID 추출하여 ProgramId 로 변환 -// // 예: "queue:program:abc-123" → "abc-123" → ProgramId.of("abc-123") -// String prefix = QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX; -// return keys.stream() -// .map(key -> key.substring(prefix.length())) -// .map(ProgramId::fromString) -// .toList(); -// } - /** * Redis 기반 deleteAllByProgramId 구현. * @@ -399,22 +259,18 @@ public List execute(RedisOperations operations) { *

처리 단계: *

    *
  1. SCAN 으로 모든 token Hash 키 ({@code queue:token:*}) 조회
  2. - *
  3. 각 Hash 의 programId 필드를 확인하여 일치하는 토큰 식별
  4. - *
  5. 일치 토큰의 Hash + 역인덱스 + Sorted Set 자체를 일괄 삭제
  6. + *
  7. 각 Hash 의 programId 필드 확인하여 일치하는 토큰 식별
  8. + *
  9. 일치 토큰의 Hash + 역인덱스 + Sorted Set 자체 일괄 삭제
  10. *
* - *

주의: 입장 (ADMITTED) 된 토큰은 Sorted Set 에서 이미 빠져 있어 - * Sorted Set 조회로는 못 찾는다. ADMITTED 토큰 정리는 SCAN 으로 추가 처리. - * - *

미래 개선: 프로그램별 토큰 ID Set 을 별도 유지하면 SCAN 불필요. - * 예: {@code queue:program:{programId}:all = {tokenId1, tokenId2, ...}}

+ *

미래 개선: 프로그램별 토큰 ID Set 을 별도 유지하면 SCAN 불필요 + Lua 통합.

*/ @Override public void deleteAllByProgramId(ProgramId programId) { String programKey = programKey(programId); String programIdStr = programId.asString(); + String seqKey = seqKey(programId); - // 1. SCAN 으로 모든 token Hash 키 찾기 (programId 일치하는 것) Set tokenKeysToDelete = new HashSet<>(); List userProgramKeysToDelete = new ArrayList<>(); @@ -422,30 +278,34 @@ public void deleteAllByProgramId(ProgramId programId) { Set allTokenKeys = scanKeys(tokenKeyPattern); HashOperations hashOps = redisTemplate.opsForHash(); + String tokenKeyPrefix = QUEUE_KEY_PREFIX + TOKEN_KEY_PREFIX; for (String tokenKey : allTokenKeys) { String tokenProgramId = hashOps.get(tokenKey, FIELD_PROGRAM_ID); if (programIdStr.equals(tokenProgramId)) { - // 일치 → 삭제 대상 tokenKeysToDelete.add(tokenKey); - // 역인덱스 키도 만들기 위해 userId 조회 String userId = hashOps.get(tokenKey, FIELD_USER_ID); if (userId != null) { String userProgramKey = QUEUE_KEY_PREFIX + USER_KEY_PREFIX + userId + ":" + PROGRAM_KEY_PREFIX + programIdStr; - userProgramKeysToDelete.add(userProgramKey); + + // 역인덱스가 이 토큰을 가리킬 때만 삭제 (동시성 방어) + String tokenIdStr = tokenKey.substring(tokenKeyPrefix.length()); + String currentTokenIdInIndex = redisTemplate.opsForValue().get(userProgramKey); + if (tokenIdStr.equals(currentTokenIdInIndex)) { + userProgramKeysToDelete.add(userProgramKey); + } } } } - // 2. 삭제 대상 모두 모음 (Sorted Set + Hash + 역인덱스) List allKeysToDelete = new ArrayList<>(); - allKeysToDelete.add(programKey); // Sorted Set + allKeysToDelete.add(programKey); // Sorted Set + allKeysToDelete.add(seqKey); // 시퀀스 카운터 allKeysToDelete.addAll(tokenKeysToDelete); allKeysToDelete.addAll(userProgramKeysToDelete); - // 3. 일괄 삭제 Long deletedCount = redisTemplate.delete(allKeysToDelete); log.info("프로그램 토큰 삭제 완료. programId={}, 삭제 키 수={}", @@ -468,7 +328,6 @@ private Set scanKeys(String pattern) { }); } - // ===== 키 생성 헬퍼 ===== private String programKey(ProgramId programId) { @@ -483,4 +342,8 @@ private String userProgramKey(UserId userId, ProgramId programId) { return QUEUE_KEY_PREFIX + USER_KEY_PREFIX + userId.asString() + ":" + PROGRAM_KEY_PREFIX + programId.asString(); } + + private String seqKey(ProgramId programId) { + return SEQ_KEY_PREFIX + programId.asString(); + } } diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisScriptConfig.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisScriptConfig.java new file mode 100644 index 0000000..4ba1542 --- /dev/null +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisScriptConfig.java @@ -0,0 +1,40 @@ +package com.firstticket.queueservice.queuetoken.infrastructure.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.scripting.support.ResourceScriptSource; + +/** + * Redis Lua 스크립트 빈 등록. + * + *

{@code DefaultRedisScript} 는 스크립트 본문을 캐시하고 SHA-1 으로 Redis 에 EVALSHA 호출하여 + * 매 호출마다 본문 전송을 피한다.

+ */ +@Configuration +public class RedisScriptConfig { + /** + * enqueue 원자 처리 스크립트. + * 반환: Long (1=성공, 0=중복) + */ + @Bean + public DefaultRedisScript enqueueScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/enqueue.lua"))); + script.setResultType(Long.class); + return script; + } + + /** + * delete 원자 처리 스크립트. + * 반환: Long (1=역인덱스 같이 삭제, 2=역인덱스 보존) + */ + @Bean + public DefaultRedisScript deleteScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/delete.lua"))); + script.setResultType(Long.class); + return script; + } +} diff --git a/src/main/resources/lua/delete.lua b/src/main/resources/lua/delete.lua new file mode 100644 index 0000000..55c8a03 --- /dev/null +++ b/src/main/resources/lua/delete.lua @@ -0,0 +1,46 @@ +-- delete.lua +-- 대기 토큰 삭제 (원자적) +-- +-- 흐름: +-- 1. 역인덱스 값 검증 (compare-and-delete) +-- — 다른 토큰이 차지했으면 역인덱스 보존 (orphan 방어) +-- 2. Sorted Set 멤버 제거 (ZREM) +-- 3. Hash 키 삭제 (DEL) +-- +-- KEYS: +-- [1] userProgramKey +-- [2] programKey +-- [3] tokenKey +-- +-- ARGV: +-- [1] tokenId +-- +-- 반환: +-- 1 = 역인덱스도 삭제됨 +-- 2 = 역인덱스는 보존 (다른 토큰이 차지) + +local userProgramKey = KEYS[1] +local programKey = KEYS[2] +local tokenKey = KEYS[3] + +local tokenId = ARGV[1] + +-- 1. 역인덱스 compare-and-delete +local current = redis.call('GET', userProgramKey) +local indexDeleted = 0 +if current == tokenId then + redis.call('DEL', userProgramKey) + indexDeleted = 1 +end + +-- 2. Sorted Set 제거 +redis.call('ZREM', programKey, tokenId) + +-- 3. Hash 삭제 +redis.call('DEL', tokenKey) + +if indexDeleted == 1 then + return 1 +else + return 2 +end diff --git a/src/main/resources/lua/enqueue.lua b/src/main/resources/lua/enqueue.lua new file mode 100644 index 0000000..0575415 --- /dev/null +++ b/src/main/resources/lua/enqueue.lua @@ -0,0 +1,62 @@ +-- enqueue.lua +-- 대기 토큰 진입 (원자적) +-- +-- 흐름: +-- 1. 역인덱스 (SETNX) 로 중복 진입 방지 +-- 2. Score 생성 (epoch_milli * 1000000 + INCR) — tie-breaker +-- 3. Sorted Set 추가 (ZADD) +-- 4. Hash 메타 저장 (HSET) + TTL (EXPIRE) +-- +-- KEYS: +-- [1] userProgramKey (queue:user:{userId}:program:{programId}) +-- [2] programKey (queue:program:{programId}) +-- [3] tokenKey (queue:token:{tokenId}) +-- [4] seqKey (queue:seq:{programId}) +-- +-- ARGV: +-- [1] tokenId +-- [2] userId +-- [3] programId +-- [4] issuedAtEpochMilli +-- [5] status +-- [6] ttlSeconds +-- +-- 반환: +-- 1 = 성공 +-- 0 = 이미 존재 (중복 진입) + +local userProgramKey = KEYS[1] +local programKey = KEYS[2] +local tokenKey = KEYS[3] +local seqKey = KEYS[4] + +local tokenId = ARGV[1] +local userId = ARGV[2] +local programId = ARGV[3] +local issuedAtEpochMilli = tonumber(ARGV[4]) +local status = ARGV[5] +local ttlSeconds = tonumber(ARGV[6]) + +-- 1. 역인덱스 (SETNX) — 같은 user+program 토큰 이미 있으면 실패 +local acquired = redis.call('SET', userProgramKey, tokenId, 'NX', 'EX', ttlSeconds) +if not acquired then + return 0 +end + +-- 2. tie-breaker score 생성 (epoch_milli * 1000000 + 시퀀스) +local seq = redis.call('INCR', seqKey) +redis.call('EXPIRE', seqKey, ttlSeconds) +local score = issuedAtEpochMilli * 1000000 + seq + +-- 3. Sorted Set 추가 +redis.call('ZADD', programKey, score, tokenId) + +-- 4. Hash 메타 저장 + TTL +redis.call('HSET', tokenKey, + 'userId', userId, + 'programId', programId, + 'issuedAt', tostring(issuedAtEpochMilli), + 'status', status) +redis.call('EXPIRE', tokenKey, ttlSeconds) + +return 1 From cbcdcc050e1063c377e5aa2bfae1ba5a39e7d54a Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Tue, 19 May 2026 15:06:21 +0900 Subject: [PATCH 2/5] =?UTF-8?q?docs:=20=EC=A3=BC=EC=84=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../redis/RedisQueueTokenRepository.java | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java index d2002d6..80cd95a 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java @@ -98,20 +98,25 @@ public void enqueue(QueueToken token) { * Redis 기반 findById 구현. * *

Hash 전체 조회 후 도메인 객체로 복원한다. - * 토큰이 없으면 빈 Map 이 반환되며 (null 아님), Optional.empty() 로 처리한다. + * 토큰이 없으면 빈 Map이 반환되며 (null 아님), Optional.empty()로 처리한다. */ @Override public Optional findById(QueueTokenId id) { String tokenKey = tokenKey(id); + // 타입 명시한 Map 으로 받기 위해 hashOps 변수 사용 HashOperations hashOps = redisTemplate.opsForHash(); + // Redis 에서 Hash 전체 조회 Map entries = hashOps.entries(tokenKey); + // 토큰 없으면 빈 Map 이 옴 (null 아님) if (entries.isEmpty()) { return Optional.empty(); } + // 깨진 레코드 자동 정리 향후 도입 try { + // Hash 데이터로 QueueToken 객체 만들어서 반환 return Optional.of(toQueueToken(id, entries)); } catch (Exception e) { log.warn("깨진 Hash 레코드 발견. tokenId={}", id.asString(), e); @@ -132,15 +137,26 @@ private QueueToken toQueueToken(QueueTokenId id, Map entries) { return QueueToken.restore(id, userId, programId, issuedAt, status, entryToken); } + /** + * Redis 기반 findByUserIdAndProgramId 구현. + * + *

2단계 조회: + *

    + *
  1. 역인덱스로 tokenId 조회
  2. + *
  3. tokenId로 토큰 전체 조회 ({@link #findById} 재사용)
  4. + *
+ */ @Override public Optional findByUserIdAndProgramId(UserId userId, ProgramId programId) { String userProgramKey = userProgramKey(userId, programId); + // 1단계: 역인덱스로 tokenId 조회 String tokenIdStr = redisTemplate.opsForValue().get(userProgramKey); if (tokenIdStr == null) { return Optional.empty(); } + // 2단계: tokenId로 토큰 전체 조회 (findById 재사용) return findById(QueueTokenId.fromString(tokenIdStr)); } @@ -173,23 +189,32 @@ public void delete(QueueToken token) { /** * Redis 기반 findPosition 구현. * - *

Redis ZRANK 는 0-based 이므로 사용자에게 보여줄 1-based 로 변환한다. + *

2단계 조회: + *

    + *
  1. 역인덱스로 tokenId 조회
  2. + *
  3. Sorted Set의 ZRANK로 순번 조회
  4. + *
+ * + *

Redis ZRANK는 0-based이므로 사용자에게 보여줄 1-based로 변환한다. */ @Override public Optional findPosition(UserId userId, ProgramId programId) { String userProgramKey = userProgramKey(userId, programId); + // 1단계: 역인덱스로 tokenId 조회 String tokenIdStr = redisTemplate.opsForValue().get(userProgramKey); if (tokenIdStr == null) { return Optional.empty(); } + // 2단계: Sorted Set에서 순번 조회 (0-based) String programKey = programKey(programId); Long rank = redisTemplate.opsForZSet().rank(programKey, tokenIdStr); if (rank == null) { return Optional.empty(); } + // 3단계: 1-based로 변환하여 반환 return Optional.of(rank + 1); } @@ -205,15 +230,21 @@ public List findAdmissionCandidates(ProgramId programId, int batchSi } String programKey = programKey(programId); + + // 1단계: Sorted Set에서 앞에서부터 batchSize명의 tokenId 조회 Set tokenIds = redisTemplate.opsForZSet().range(programKey, 0, batchSize - 1); if (tokenIds == null || tokenIds.isEmpty()) { return List.of(); } + // 2단계: 각 tokenId로 토큰 전체 조회 return tokenIds.stream() + // String → Optional .map(tokenIdStr -> findById(QueueTokenId.fromString(tokenIdStr))) + // 토큰 있는 것만 .filter(Optional::isPresent) + // Optional → QueueToken .map(Optional::get) .toList(); } @@ -221,7 +252,7 @@ public List findAdmissionCandidates(ProgramId programId, int batchSi /** * Redis 기반 admit 구현. * - *

2 가지 작업을 트랜잭션으로 처리한다: + *

2가지 작업을 트랜잭션으로 처리한다: *

    *
  1. Sorted Set 에서 토큰 멤버 제거 (큐에서 빠짐 → position 조회 X)
  2. *
  3. Hash 의 status / entryToken 업데이트
  4. @@ -244,7 +275,9 @@ public void admit(QueueToken token) { @Override public List execute(RedisOperations operations) { operations.multi(); + // 1. Sorted Set 에서 멤버 제거 (큐에서 빠짐) operations.opsForZSet().remove(programKey, tokenIdStr); + // 2. Hash 의 status / entryToken 업데이트 operations.opsForHash().putAll(tokenKey, updates); return operations.exec(); } @@ -271,6 +304,7 @@ public void deleteAllByProgramId(ProgramId programId) { String programIdStr = programId.asString(); String seqKey = seqKey(programId); + // 1. SCAN 으로 모든 token Hash 키 찾기 (programId 일치하는 것) Set tokenKeysToDelete = new HashSet<>(); List userProgramKeysToDelete = new ArrayList<>(); @@ -283,8 +317,10 @@ public void deleteAllByProgramId(ProgramId programId) { for (String tokenKey : allTokenKeys) { String tokenProgramId = hashOps.get(tokenKey, FIELD_PROGRAM_ID); if (programIdStr.equals(tokenProgramId)) { + // 일치 → 삭제 대상 tokenKeysToDelete.add(tokenKey); + // 역인덱스 키도 만들기 위해 userId 조회 String userId = hashOps.get(tokenKey, FIELD_USER_ID); if (userId != null) { String userProgramKey = QUEUE_KEY_PREFIX + USER_KEY_PREFIX + userId @@ -300,12 +336,14 @@ public void deleteAllByProgramId(ProgramId programId) { } } + // 2. 삭제 대상 모두 모음 (Sorted Set + Hash + 역인덱스) List allKeysToDelete = new ArrayList<>(); allKeysToDelete.add(programKey); // Sorted Set allKeysToDelete.add(seqKey); // 시퀀스 카운터 allKeysToDelete.addAll(tokenKeysToDelete); allKeysToDelete.addAll(userProgramKeysToDelete); + // 3. 일괄 삭제 Long deletedCount = redisTemplate.delete(allKeysToDelete); log.info("프로그램 토큰 삭제 완료. programId={}, 삭제 키 수={}", From 742a308b53dc4c1070a3a64f1e9f52db09817b07 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Tue, 19 May 2026 17:30:19 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20(score=20=EC=A0=95?= =?UTF-8?q?=EB=B0=80=EB=8F=84=20+=20TOCTOU=20=EC=A0=95=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enqueue.lua: score 생성 시 epoch_milli → epoch_second 정정 double 정밀도 손실 (ULP=256) 로 같은 ms 안에 128 명 이상 enqueue 시 seq 손실 → FIFO 깨지는 본문 정정 - deleteAllByProgram.lua 신규: SCAN + compare-and-delete + 일괄 정리를 단일 Lua 로 통합 TOCTOU 안전 보장 (역인덱스 삭제 시 새 토큰 보호) Java 측 SCAN 제거 - RedisScriptConfig: deleteAllByProgramScript 빈 등록 - RedisQueueTokenRepository.deleteAllByProgramId 정정: Lua 호출로 단일화 Related to #11 --- .../redis/RedisQueueTokenRepository.java | 67 +++++------------ .../redis/RedisScriptConfig.java | 12 ++++ src/main/resources/lua/deleteAllByProgram.lua | 72 +++++++++++++++++++ src/main/resources/lua/enqueue.lua | 3 +- 4 files changed, 103 insertions(+), 51 deletions(-) create mode 100644 src/main/resources/lua/deleteAllByProgram.lua diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java index 80cd95a..137c6b9 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java @@ -55,6 +55,7 @@ public class RedisQueueTokenRepository implements QueueTokenRepository { private final QueueProperties properties; private final DefaultRedisScript enqueueScript; private final DefaultRedisScript deleteScript; + private final DefaultRedisScript deleteAllByProgramScript; /** * Redis 기반 enqueue 구현 (Lua 스크립트로 원자 처리). @@ -285,18 +286,17 @@ public List execute(RedisOperations operations) { } /** - * Redis 기반 deleteAllByProgramId 구현. + * Redis 기반 deleteAllByProgramId 구현 (Lua 스크립트로 원자 처리). * - *

    Program 취소 시 해당 프로그램의 모든 토큰 (대기 + 입장) 정리. - * - *

    처리 단계: + *

    Lua 스크립트 안에서: *

      - *
    1. SCAN 으로 모든 token Hash 키 ({@code queue:token:*}) 조회
    2. - *
    3. 각 Hash 의 programId 필드 확인하여 일치하는 토큰 식별
    4. - *
    5. 일치 토큰의 Hash + 역인덱스 + Sorted Set 자체 일괄 삭제
    6. + *
    7. SCAN 으로 모든 token Hash 키 조회
    8. + *
    9. programId 일치 토큰만 compare-and-delete (역인덱스 + Sorted Set + Hash)
    10. + *
    11. Sorted Set 자체 + seqKey 일괄 삭제
    12. *
    * - *

    미래 개선: 프로그램별 토큰 ID Set 을 별도 유지하면 SCAN 불필요 + Lua 통합.

    + *

    역인덱스 삭제는 compare-and-delete 로 TOCTOU 방어: + * 동시에 새 토큰이 같은 사용자로 enqueue 되어도 새 토큰의 역인덱스는 보존. */ @Override public void deleteAllByProgramId(ProgramId programId) { @@ -304,50 +304,17 @@ public void deleteAllByProgramId(ProgramId programId) { String programIdStr = programId.asString(); String seqKey = seqKey(programId); - // 1. SCAN 으로 모든 token Hash 키 찾기 (programId 일치하는 것) - Set tokenKeysToDelete = new HashSet<>(); - List userProgramKeysToDelete = new ArrayList<>(); - - String tokenKeyPattern = QUEUE_KEY_PREFIX + TOKEN_KEY_PREFIX + "*"; - Set allTokenKeys = scanKeys(tokenKeyPattern); - - HashOperations hashOps = redisTemplate.opsForHash(); - String tokenKeyPrefix = QUEUE_KEY_PREFIX + TOKEN_KEY_PREFIX; - - for (String tokenKey : allTokenKeys) { - String tokenProgramId = hashOps.get(tokenKey, FIELD_PROGRAM_ID); - if (programIdStr.equals(tokenProgramId)) { - // 일치 → 삭제 대상 - tokenKeysToDelete.add(tokenKey); - - // 역인덱스 키도 만들기 위해 userId 조회 - String userId = hashOps.get(tokenKey, FIELD_USER_ID); - if (userId != null) { - String userProgramKey = QUEUE_KEY_PREFIX + USER_KEY_PREFIX + userId - + ":" + PROGRAM_KEY_PREFIX + programIdStr; - - // 역인덱스가 이 토큰을 가리킬 때만 삭제 (동시성 방어) - String tokenIdStr = tokenKey.substring(tokenKeyPrefix.length()); - String currentTokenIdInIndex = redisTemplate.opsForValue().get(userProgramKey); - if (tokenIdStr.equals(currentTokenIdInIndex)) { - userProgramKeysToDelete.add(userProgramKey); - } - } - } - } - - // 2. 삭제 대상 모두 모음 (Sorted Set + Hash + 역인덱스) - List allKeysToDelete = new ArrayList<>(); - allKeysToDelete.add(programKey); // Sorted Set - allKeysToDelete.add(seqKey); // 시퀀스 카운터 - allKeysToDelete.addAll(tokenKeysToDelete); - allKeysToDelete.addAll(userProgramKeysToDelete); - - // 3. 일괄 삭제 - Long deletedCount = redisTemplate.delete(allKeysToDelete); + Long processedCount = redisTemplate.execute( + deleteAllByProgramScript, + List.of(programKey, seqKey), + programIdStr, + QUEUE_KEY_PREFIX + TOKEN_KEY_PREFIX, // tokenKeyPrefix + QUEUE_KEY_PREFIX + USER_KEY_PREFIX, // userProgramKeyPrefix + ":" + PROGRAM_KEY_PREFIX // programKeyInfix + ); log.info("프로그램 토큰 삭제 완료. programId={}, 삭제 키 수={}", - programIdStr, deletedCount); + programIdStr, processedCount); } private Set scanKeys(String pattern) { diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisScriptConfig.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisScriptConfig.java index 4ba1542..a52b401 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisScriptConfig.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisScriptConfig.java @@ -37,4 +37,16 @@ public DefaultRedisScript deleteScript() { script.setResultType(Long.class); return script; } + + /** + * deleteAllByProgram 원자 처리 스크립트 + * 반환: Long + */ + @Bean + public DefaultRedisScript deleteAllByProgramScript() { + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setScriptSource(new ResourceScriptSource(new ClassPathResource("lua/deleteAllByProgram.lua"))); + script.setResultType(Long.class); + return script; + } } diff --git a/src/main/resources/lua/deleteAllByProgram.lua b/src/main/resources/lua/deleteAllByProgram.lua new file mode 100644 index 0000000..67a95b2 --- /dev/null +++ b/src/main/resources/lua/deleteAllByProgram.lua @@ -0,0 +1,72 @@ +-- deleteAllByProgram.lua +-- 프로그램 취소 시 해당 프로그램의 모든 토큰 일괄 삭제 (원자적) +-- +-- 흐름: +-- 1. SCAN 으로 모든 token Hash 키 찾기 (queue:token:*) +-- 2. 각 Hash 의 programId 필드 확인 +-- 3. 일치하면 compare-and-delete: +-- - 역인덱스 (token 의 userId 기반): 현재 값이 이 토큰 ID 일 때만 DEL +-- - Sorted Set 멤버 ZREM +-- - Hash DEL +-- 4. 마지막에 Sorted Set 자체 + seqKey 일괄 DEL +-- +-- KEYS: +-- [1] programKey (queue:program:{programId}) +-- [2] seqKey (queue:seq:{programId}) +-- +-- ARGV: +-- [1] programIdStr +-- [2] tokenKeyPrefix (queue:token:) +-- [3] userProgramKeyPrefix (queue:user:) +-- [4] programKeyInfix (:program:) +-- +-- 반환: 처리된 토큰 수 + +local programKey = KEYS[1] +local seqKey = KEYS[2] + +local programIdStr = ARGV[1] +local tokenKeyPrefix = ARGV[2] +local userProgramKeyPrefix = ARGV[3] +local programKeyInfix = ARGV[4] + +local processedCount = 0 +local cursor = "0" + +repeat + -- 1. SCAN 으로 token Hash 키 페이지 조회 + local result = redis.call('SCAN', cursor, 'MATCH', tokenKeyPrefix .. '*', 'COUNT', 100) + cursor = result[1] + local tokenKeys = result[2] + + -- 2. 각 토큰 처리 + for i, tokenKey in ipairs(tokenKeys) do + local tokenProgramId = redis.call('HGET', tokenKey, 'programId') + + if tokenProgramId == programIdStr then + local userId = redis.call('HGET', tokenKey, 'userId') + local tokenIdStr = string.sub(tokenKey, string.len(tokenKeyPrefix) + 1) + + -- 3. compare-and-delete (역인덱스) + if userId then + local userProgramKey = userProgramKeyPrefix .. userId .. programKeyInfix .. programIdStr + local currentTokenIdInIndex = redis.call('GET', userProgramKey) + if currentTokenIdInIndex == tokenIdStr then + redis.call('DEL', userProgramKey) + end + end + + -- 4. Sorted Set 멤버 제거 + Hash 삭제 + redis.call('ZREM', programKey, tokenIdStr) + redis.call('DEL', tokenKey) + + processedCount = processedCount + 1 + end + end +until cursor == "0" + +-- 5. Sorted Set 자체 + seqKey 일괄 삭제 +redis.call('DEL', programKey) +redis.call('DEL', seqKey) + +return processedCount diff --git a/src/main/resources/lua/enqueue.lua b/src/main/resources/lua/enqueue.lua index 0575415..04b470f 100644 --- a/src/main/resources/lua/enqueue.lua +++ b/src/main/resources/lua/enqueue.lua @@ -46,7 +46,8 @@ end -- 2. tie-breaker score 생성 (epoch_milli * 1000000 + 시퀀스) local seq = redis.call('INCR', seqKey) redis.call('EXPIRE', seqKey, ttlSeconds) -local score = issuedAtEpochMilli * 1000000 + seq +local issuedAtEpochSecond = math.floor(issuedAtEpochMilli / 1000) +local score = issuedAtEpochSecond * 1000000 + seq -- 3. Sorted Set 추가 redis.call('ZADD', programKey, score, tokenId) From b0126c1af787eed3c5a4690aa8e01b617ffbb23b Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Tue, 19 May 2026 17:46:12 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20RedisQueueTokenRepository=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9E=90=20=EB=A7=A4=EA=B0=9C=EB=B3=80?= =?UTF-8?q?=EC=88=98=EC=97=90=20@Qualifier=20=EB=AA=85=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 코드래빗 리뷰 반영 Related to #11 --- .../redis/RedisQueueTokenRepository.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java index 137c6b9..6232e0f 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java @@ -9,8 +9,8 @@ import com.firstticket.queueservice.queuetoken.domain.vo.ProgramId; import com.firstticket.queueservice.queuetoken.domain.vo.QueueTokenId; import com.firstticket.queueservice.queuetoken.domain.vo.UserId; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.*; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Repository; @@ -34,7 +34,6 @@ */ @Slf4j @Repository -@RequiredArgsConstructor public class RedisQueueTokenRepository implements QueueTokenRepository { // ===== 키 prefix 상수 ===== @@ -57,6 +56,20 @@ public class RedisQueueTokenRepository implements QueueTokenRepository { private final DefaultRedisScript deleteScript; private final DefaultRedisScript deleteAllByProgramScript; + public RedisQueueTokenRepository( + StringRedisTemplate redisTemplate, + QueueProperties properties, + @Qualifier("enqueueScript") DefaultRedisScript enqueueScript, + @Qualifier("deleteScript") DefaultRedisScript deleteScript, + @Qualifier("deleteAllByProgramScript") DefaultRedisScript deleteAllByProgramScript + ) { + this.redisTemplate = redisTemplate; + this.properties = properties; + this.enqueueScript = enqueueScript; + this.deleteScript = deleteScript; + this.deleteAllByProgramScript = deleteAllByProgramScript; + } + /** * Redis 기반 enqueue 구현 (Lua 스크립트로 원자 처리). * From 0f90045cc6bbc0f9a3eea388452b9e0efbe99c17 Mon Sep 17 00:00:00 2001 From: rlaxxwls13 Date: Tue, 19 May 2026 18:08:03 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=9E=98=EB=B9=97?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81(=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EB=9E=A8=20=EB=8B=A8=EC=9C=84=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enqueue.lua / delete.lua: 프로그램 단위 토큰 인덱스 (queue:program:{programId}:tokens, Set) SADD/SREM 추가 - deleteAllByProgram.lua 정정: SCAN 제거하고 프로그램 단위 인덱스 SMEMBERS 기반으로 처리 (대규모 데이터 시 Redis 블로킹 회피) Related to #11 --- .../redis/RedisQueueTokenRepository.java | 61 +++++++++-------- src/main/resources/lua/delete.lua | 15 ++-- src/main/resources/lua/deleteAllByProgram.lua | 68 ++++++++----------- src/main/resources/lua/enqueue.lua | 19 ++++-- 4 files changed, 82 insertions(+), 81 deletions(-) diff --git a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java index 6232e0f..4cdcf3b 100644 --- a/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java +++ b/src/main/java/com/firstticket/queueservice/queuetoken/infrastructure/redis/RedisQueueTokenRepository.java @@ -11,26 +11,33 @@ import com.firstticket.queueservice.queuetoken.domain.vo.UserId; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.data.redis.core.*; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisOperations; +import org.springframework.data.redis.core.SessionCallback; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Repository; -import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; /** * 대기 토큰의 Redis 기반 영속성 구현체. * - *

    3가지 자료구조를 조합하여 사용한다: + *

    5가지 자료구조를 조합하여 사용한다: *

      *
    • Sorted Set ({@code queue:program:{programId}}) — 대기 순번 관리. - * score = {@code epoch_milli * 1000000 + sequence} (tie-breaker)
    • + * score = {@code epoch_second * 1000000 + sequence} (tie-breaker) *
    • Hash ({@code queue:token:{tokenId}}) — 토큰 메타 데이터 저장
    • *
    • String ({@code queue:user:{userId}:program:{programId}}) — 역인덱스 (도메인 키 → 토큰 ID)
    • *
    • String ({@code queue:seq:{programId}}) — INCR 시퀀스 (score tie-breaker 용)
    • + *
    • Set ({@code queue:program:{programId}:tokens}) — 프로그램 단위 토큰 인덱스 + * (deleteAllByProgramId 측 SCAN 회피)
    • *
    * - *

    enqueue / delete 는 Lua 스크립트로 원자 처리한다.

    + *

    enqueue / delete / deleteAllByProgramId 는 Lua 스크립트로 원자 처리한다.

    */ @Slf4j @Repository @@ -42,6 +49,7 @@ public class RedisQueueTokenRepository implements QueueTokenRepository { private static final String TOKEN_KEY_PREFIX = "token:"; private static final String USER_KEY_PREFIX = "user:"; private static final String SEQ_KEY_PREFIX = "queue:seq:"; + private static final String PROGRAM_TOKENS_SUFFIX = ":tokens"; // ===== Hash 필드 이름 상수 ===== private static final String FIELD_USER_ID = "userId"; @@ -76,9 +84,10 @@ public RedisQueueTokenRepository( *

    Lua 스크립트 안에서 한 트랜잭션으로: *

      *
    1. 역인덱스 SETNX 로 중복 진입 방지
    2. - *
    3. INCR 시퀀스 + epoch_milli 로 tie-breaker score 생성
    4. + *
    5. INCR 시퀀스 + epoch_second 로 tie-breaker score 생성
    6. *
    7. Sorted Set 추가 (ZADD)
    8. *
    9. Hash 메타 저장 (HSET) + TTL (EXPIRE)
    10. + *
    11. 프로그램 단위 토큰 인덱스 (Set) 추가 (SADD)
    12. *
    */ @Override @@ -87,6 +96,7 @@ public void enqueue(QueueToken token) { String tokenKey = tokenKey(token.getId()); String userProgramKey = userProgramKey(token.getUserId(), token.getProgramId()); String seqKey = seqKey(token.getProgramId()); + String programTokensKey = programTokensKey(token.getProgramId()); String tokenIdStr = token.getId().asString(); long issuedAtEpochMilli = token.getIssuedAt().toEpochMilli(); @@ -94,7 +104,7 @@ public void enqueue(QueueToken token) { Long result = redisTemplate.execute( enqueueScript, - List.of(userProgramKey, programKey, tokenKey, seqKey), + List.of(userProgramKey, programKey, tokenKey, seqKey, programTokensKey), tokenIdStr, token.getUserId().asString(), token.getProgramId().asString(), @@ -182,6 +192,7 @@ public Optional findByUserIdAndProgramId(UserId userId, ProgramId pr *
  5. 역인덱스 compare-and-delete (다른 토큰 차지 시 보존)
  6. *
  7. Sorted Set 멤버 제거 (ZREM)
  8. *
  9. Hash 키 삭제 (DEL)
  10. + *
  11. 프로그램 단위 토큰 인덱스에서 제거 (SREM)
  12. * * *

    이미 만료 / 삭제된 토큰에 대해서도 안전하게 호출 가능 (멱등).

    @@ -191,11 +202,12 @@ public void delete(QueueToken token) { String programKey = programKey(token.getProgramId()); String tokenKey = tokenKey(token.getId()); String userProgramKey = userProgramKey(token.getUserId(), token.getProgramId()); + String programTokensKey = programTokensKey(token.getProgramId()); String tokenIdStr = token.getId().asString(); redisTemplate.execute( deleteScript, - List.of(userProgramKey, programKey, tokenKey), + List.of(userProgramKey, programKey, tokenKey, programTokensKey), tokenIdStr ); } @@ -303,9 +315,9 @@ public List execute(RedisOperations operations) { * *

    Lua 스크립트 안에서: *

      - *
    1. SCAN 으로 모든 token Hash 키 조회
    2. - *
    3. programId 일치 토큰만 compare-and-delete (역인덱스 + Sorted Set + Hash)
    4. - *
    5. Sorted Set 자체 + seqKey 일괄 삭제
    6. + *
    7. 프로그램 단위 토큰 인덱스 (Set) 의 멤버 조회 (SMEMBERS)
    8. + *
    9. 각 토큰별 compare-and-delete (역인덱스 + Hash)
    10. + *
    11. Sorted Set 자체 + seqKey + 프로그램 단위 인덱스 일괄 삭제
    12. *
    * *

    역인덱스 삭제는 compare-and-delete 로 TOCTOU 방어: @@ -316,36 +328,21 @@ public void deleteAllByProgramId(ProgramId programId) { String programKey = programKey(programId); String programIdStr = programId.asString(); String seqKey = seqKey(programId); + String programTokensKey = programTokensKey(programId); Long processedCount = redisTemplate.execute( deleteAllByProgramScript, - List.of(programKey, seqKey), + List.of(programKey, seqKey, programTokensKey), programIdStr, QUEUE_KEY_PREFIX + TOKEN_KEY_PREFIX, // tokenKeyPrefix QUEUE_KEY_PREFIX + USER_KEY_PREFIX, // userProgramKeyPrefix ":" + PROGRAM_KEY_PREFIX // programKeyInfix ); - log.info("프로그램 토큰 삭제 완료. programId={}, 삭제 키 수={}", + log.info("프로그램 토큰 삭제 완료. programId={}, 처리 토큰 수={}", programIdStr, processedCount); } - private Set scanKeys(String pattern) { - return redisTemplate.execute((RedisCallback>) connection -> { - Set result = new HashSet<>(); - ScanOptions options = ScanOptions.scanOptions() - .match(pattern) - .count(100) - .build(); - try (Cursor cursor = connection.scan(options)) { - while (cursor.hasNext()) { - result.add(new String(cursor.next(), StandardCharsets.UTF_8)); - } - } - return result; - }); - } - // ===== 키 생성 헬퍼 ===== private String programKey(ProgramId programId) { @@ -364,4 +361,8 @@ private String userProgramKey(UserId userId, ProgramId programId) { private String seqKey(ProgramId programId) { return SEQ_KEY_PREFIX + programId.asString(); } + + private String programTokensKey(ProgramId programId) { + return QUEUE_KEY_PREFIX + PROGRAM_KEY_PREFIX + programId.asString() + PROGRAM_TOKENS_SUFFIX; + } } diff --git a/src/main/resources/lua/delete.lua b/src/main/resources/lua/delete.lua index 55c8a03..d4b9257 100644 --- a/src/main/resources/lua/delete.lua +++ b/src/main/resources/lua/delete.lua @@ -2,26 +2,24 @@ -- 대기 토큰 삭제 (원자적) -- -- 흐름: --- 1. 역인덱스 값 검증 (compare-and-delete) --- — 다른 토큰이 차지했으면 역인덱스 보존 (orphan 방어) +-- 1. 역인덱스 compare-and-delete -- 2. Sorted Set 멤버 제거 (ZREM) -- 3. Hash 키 삭제 (DEL) +-- 4. 프로그램 단위 토큰 인덱스에서 제거 (SREM) -- -- KEYS: -- [1] userProgramKey -- [2] programKey -- [3] tokenKey +-- [4] programTokensKey ← 새 본문 -- -- ARGV: -- [1] tokenId --- --- 반환: --- 1 = 역인덱스도 삭제됨 --- 2 = 역인덱스는 보존 (다른 토큰이 차지) local userProgramKey = KEYS[1] local programKey = KEYS[2] local tokenKey = KEYS[3] +local programTokensKey = KEYS[4] local tokenId = ARGV[1] @@ -33,12 +31,15 @@ if current == tokenId then indexDeleted = 1 end --- 2. Sorted Set 제거 +-- 2. Sorted Set 멤버 제거 redis.call('ZREM', programKey, tokenId) -- 3. Hash 삭제 redis.call('DEL', tokenKey) +-- 4. 프로그램 단위 토큰 인덱스에서 제거 +redis.call('SREM', programTokensKey, tokenId) + if indexDeleted == 1 then return 1 else diff --git a/src/main/resources/lua/deleteAllByProgram.lua b/src/main/resources/lua/deleteAllByProgram.lua index 67a95b2..6fe528d 100644 --- a/src/main/resources/lua/deleteAllByProgram.lua +++ b/src/main/resources/lua/deleteAllByProgram.lua @@ -2,17 +2,18 @@ -- 프로그램 취소 시 해당 프로그램의 모든 토큰 일괄 삭제 (원자적) -- -- 흐름: --- 1. SCAN 으로 모든 token Hash 키 찾기 (queue:token:*) --- 2. 각 Hash 의 programId 필드 확인 --- 3. 일치하면 compare-and-delete: --- - 역인덱스 (token 의 userId 기반): 현재 값이 이 토큰 ID 일 때만 DEL --- - Sorted Set 멤버 ZREM +-- 1. 프로그램 단위 토큰 인덱스 (Set) 의 멤버 조회 (SMEMBERS) +-- 2. 각 토큰별 compare-and-delete: +-- - 역인덱스 (현재 값 일치 시만 DEL) -- - Hash DEL --- 4. 마지막에 Sorted Set 자체 + seqKey 일괄 DEL +-- 3. Sorted Set 자체 + seqKey + 프로그램 단위 인덱스 일괄 DEL +-- +-- SCAN 사용 X — 프로그램 단위 인덱스로 토큰 범위 제한 (성능 + 블로킹 회피) -- -- KEYS: --- [1] programKey (queue:program:{programId}) --- [2] seqKey (queue:seq:{programId}) +-- [1] programKey (queue:program:{programId}) +-- [2] seqKey (queue:seq:{programId}) +-- [3] programTokensKey (queue:program:{programId}:tokens) -- -- ARGV: -- [1] programIdStr @@ -24,49 +25,40 @@ local programKey = KEYS[1] local seqKey = KEYS[2] +local programTokensKey = KEYS[3] local programIdStr = ARGV[1] local tokenKeyPrefix = ARGV[2] local userProgramKeyPrefix = ARGV[3] local programKeyInfix = ARGV[4] +-- 1. 프로그램 단위 토큰 인덱스 조회 (SCAN X) +local tokenIds = redis.call('SMEMBERS', programTokensKey) local processedCount = 0 -local cursor = "0" - -repeat - -- 1. SCAN 으로 token Hash 키 페이지 조회 - local result = redis.call('SCAN', cursor, 'MATCH', tokenKeyPrefix .. '*', 'COUNT', 100) - cursor = result[1] - local tokenKeys = result[2] - - -- 2. 각 토큰 처리 - for i, tokenKey in ipairs(tokenKeys) do - local tokenProgramId = redis.call('HGET', tokenKey, 'programId') - - if tokenProgramId == programIdStr then - local userId = redis.call('HGET', tokenKey, 'userId') - local tokenIdStr = string.sub(tokenKey, string.len(tokenKeyPrefix) + 1) - -- 3. compare-and-delete (역인덱스) - if userId then - local userProgramKey = userProgramKeyPrefix .. userId .. programKeyInfix .. programIdStr - local currentTokenIdInIndex = redis.call('GET', userProgramKey) - if currentTokenIdInIndex == tokenIdStr then - redis.call('DEL', userProgramKey) - end - end +-- 2. 각 토큰별 정리 +for i, tokenIdStr in ipairs(tokenIds) do + local tokenKey = tokenKeyPrefix .. tokenIdStr + local userId = redis.call('HGET', tokenKey, 'userId') - -- 4. Sorted Set 멤버 제거 + Hash 삭제 - redis.call('ZREM', programKey, tokenIdStr) - redis.call('DEL', tokenKey) - - processedCount = processedCount + 1 + -- 역인덱스 compare-and-delete + if userId then + local userProgramKey = userProgramKeyPrefix .. userId .. programKeyInfix .. programIdStr + local currentTokenIdInIndex = redis.call('GET', userProgramKey) + if currentTokenIdInIndex == tokenIdStr then + redis.call('DEL', userProgramKey) end end -until cursor == "0" --- 5. Sorted Set 자체 + seqKey 일괄 삭제 + -- Hash 삭제 + redis.call('DEL', tokenKey) + + processedCount = processedCount + 1 +end + +-- 3. Sorted Set 자체 + seqKey + 프로그램 단위 인덱스 일괄 삭제 redis.call('DEL', programKey) redis.call('DEL', seqKey) +redis.call('DEL', programTokensKey) return processedCount diff --git a/src/main/resources/lua/enqueue.lua b/src/main/resources/lua/enqueue.lua index 04b470f..777ca34 100644 --- a/src/main/resources/lua/enqueue.lua +++ b/src/main/resources/lua/enqueue.lua @@ -3,15 +3,17 @@ -- -- 흐름: -- 1. 역인덱스 (SETNX) 로 중복 진입 방지 --- 2. Score 생성 (epoch_milli * 1000000 + INCR) — tie-breaker +-- 2. Score 생성 (epoch_second * 1000000 + INCR) — tie-breaker -- 3. Sorted Set 추가 (ZADD) -- 4. Hash 메타 저장 (HSET) + TTL (EXPIRE) +-- 5. 프로그램 단위 토큰 인덱스 (Set) 추가 (SADD) -- -- KEYS: --- [1] userProgramKey (queue:user:{userId}:program:{programId}) --- [2] programKey (queue:program:{programId}) --- [3] tokenKey (queue:token:{tokenId}) --- [4] seqKey (queue:seq:{programId}) +-- [1] userProgramKey +-- [2] programKey +-- [3] tokenKey +-- [4] seqKey +-- [5] programTokensKey -- -- ARGV: -- [1] tokenId @@ -29,6 +31,7 @@ local userProgramKey = KEYS[1] local programKey = KEYS[2] local tokenKey = KEYS[3] local seqKey = KEYS[4] +local programTokensKey = KEYS[5] local tokenId = ARGV[1] local userId = ARGV[2] @@ -43,7 +46,7 @@ if not acquired then return 0 end --- 2. tie-breaker score 생성 (epoch_milli * 1000000 + 시퀀스) +-- 2. tie-breaker score 생성 (epoch_second * 1000000 + 시퀀스) local seq = redis.call('INCR', seqKey) redis.call('EXPIRE', seqKey, ttlSeconds) local issuedAtEpochSecond = math.floor(issuedAtEpochMilli / 1000) @@ -60,4 +63,8 @@ redis.call('HSET', tokenKey, 'status', status) redis.call('EXPIRE', tokenKey, ttlSeconds) +-- 5. 프로그램 단위 토큰 인덱스 (Set) 추가 +redis.call('SADD', programTokensKey, tokenId) +redis.call('EXPIRE', programTokensKey, ttlSeconds) + return 1