-
Notifications
You must be signed in to change notification settings - Fork 0
쿠폰 고도화 / 비관적 락 -> Redis Lua #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
40 changes: 40 additions & 0 deletions
40
src/main/java/com/catchtable/coupon/redis/CouponRedisCircuitBreakerLogger.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.catchtable.coupon.redis; | ||
|
|
||
| import io.github.resilience4j.circuitbreaker.CircuitBreaker; | ||
| import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; | ||
| import jakarta.annotation.PostConstruct; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| /** | ||
| * coupon-redis 회로의 상태 전환을 운영 로그에 남긴다. | ||
| * 회로가 OPEN 으로 갔다 → Redis 장애 신호. | ||
| * HALF_OPEN → CLOSED 면 복구 완료. | ||
| * 사후 추적·알람 룰 작성의 근거가 된다. | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class CouponRedisCircuitBreakerLogger { | ||
|
|
||
| private static final String INSTANCE = "coupon-redis"; | ||
|
|
||
| private final CircuitBreakerRegistry circuitBreakerRegistry; | ||
|
|
||
| @PostConstruct | ||
| void registerEventListeners() { | ||
| CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker(INSTANCE); | ||
| cb.getEventPublisher() | ||
| .onStateTransition(event -> | ||
| log.warn("[CircuitBreaker:{}] 상태 전환 {} → {}", | ||
| INSTANCE, | ||
| event.getStateTransition().getFromState(), | ||
| event.getStateTransition().getToState())) | ||
| .onCallNotPermitted(event -> | ||
| log.debug("[CircuitBreaker:{}] OPEN 상태로 호출 차단됨", INSTANCE)) | ||
| .onError(event -> | ||
| log.debug("[CircuitBreaker:{}] 호출 실패: {}", | ||
| INSTANCE, event.getThrowable().toString())); | ||
| } | ||
| } |
57 changes: 57 additions & 0 deletions
57
src/main/java/com/catchtable/coupon/redis/CouponWarmupRunner.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| package com.catchtable.coupon.redis; | ||
|
|
||
| import com.catchtable.coupon.entity.CouponTemplate; | ||
| import com.catchtable.coupon.repository.CouponTemplateRepository; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.boot.ApplicationArguments; | ||
| import org.springframework.boot.ApplicationRunner; | ||
| import org.springframework.core.annotation.Order; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 서버 부팅 시 발급 가능한 쿠폰 템플릿의 Redis 상태를 점검·복원한다. | ||
| * | ||
| * 시나리오: | ||
| * - Redis 가 재기동되어 coupon:stock:{id} 키가 사라진 경우, | ||
| * DB 의 remain 값으로 워밍업하지 않으면 Lua 가 NOT_AVAILABLE 을 반환한다. | ||
| * - DB 에는 멀쩡한 템플릿이 있는데 발급 API 가 안 되는 운영 사고로 직결됨. | ||
| * | ||
| * 안전장치: | ||
| * - setIfAbsent 의미라 Redis 가 정상이고 운영 중 차감된 값이 있으면 덮어쓰지 않는다. | ||
| * - 따라서 평시 부팅에는 no-op, Redis 콜드 스타트일 때만 실효. | ||
| * | ||
| * 한계: | ||
| * - "Redis 가 죽은 동안 발급된 건수"는 복원 불가. Redis HA + AOF 영속화가 1차 방어선이다. | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @Order(Integer.MAX_VALUE) // 다른 ApplicationRunner 이후 실행 | ||
| @RequiredArgsConstructor | ||
| public class CouponWarmupRunner implements ApplicationRunner { | ||
|
|
||
| private final CouponTemplateRepository couponTemplateRepository; | ||
| private final RedisCouponIssuer redisCouponIssuer; | ||
|
|
||
| @Override | ||
| public void run(ApplicationArguments args) { | ||
| List<CouponTemplate> activeTemplates = couponTemplateRepository.findActiveTemplates(LocalDateTime.now()); | ||
| if (activeTemplates.isEmpty()) { | ||
| log.info("쿠폰 Redis 워밍업: 활성 템플릿 없음, 스킵"); | ||
| return; | ||
| } | ||
|
|
||
| for (CouponTemplate t : activeTemplates) { | ||
| try { | ||
| redisCouponIssuer.warmUpIfAbsent(t.getId(), t.getRemain(), t.getExpiredAt()); | ||
| } catch (Exception e) { | ||
| // 한 템플릿 실패가 다른 템플릿 워밍업을 막지 않게 한다. | ||
| log.error("쿠폰 Redis 워밍업 실패: templateId={}", t.getId(), e); | ||
| } | ||
| } | ||
| log.info("쿠폰 Redis 워밍업 완료: 활성 템플릿 {}건 점검", activeTemplates.size()); | ||
| } | ||
| } |
152 changes: 152 additions & 0 deletions
152
src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| package com.catchtable.coupon.redis; | ||
|
|
||
| import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; | ||
| import jakarta.annotation.PostConstruct; | ||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.redisson.api.RScript; | ||
| import org.redisson.api.RedissonClient; | ||
| import org.redisson.client.codec.StringCodec; | ||
| import org.springframework.core.io.ClassPathResource; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.util.StreamUtils; | ||
|
|
||
| import java.nio.charset.StandardCharsets; | ||
| import java.time.Duration; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 선착순 쿠폰 발급의 원자 결정(재고/중복/차감/마킹)을 Redis Lua 로 처리한다. | ||
| * | ||
| * 격리: | ||
| * - Redis 호출은 동기. 1ms 미만으로 끝나는 가벼운 호출이라 별도 executor 가 오히려 비용. | ||
| * - 인프라 장애 격리는 @CircuitBreaker 만 사용. 일정 실패율 누적 시 회로 OPEN → 즉시 폴백. | ||
| * - 단일 명령 hang 보호는 Redisson 글로벌 timeout(기본 3초)에 위임. | ||
| * - 비즈니스 예외(DUPLICATE/EXHAUSTED)는 yaml 의 ignore-exceptions 로 회로 카운트에서 제외. | ||
| * | ||
| * 발급자 SET TTL 동기화: | ||
| * - 빈 SET 에 미리 EXPIREAT 을 거는 방식은 Redis 가 무시하므로 메모리 누수가 발생한다. | ||
| * - 따라서 Lua 스크립트가 SADD 직후 stock 키의 TTL 을 읽어 issued SET 에 동기화한다. | ||
| * - 자바 코드에서는 stock 키만 TTL 을 갖도록 관리하고 issued SET 의 TTL 은 건드리지 않는다. | ||
| */ | ||
| @Slf4j | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class RedisCouponIssuer { | ||
|
|
||
| private static final String STOCK_KEY_PREFIX = "coupon:stock:"; | ||
| private static final String ISSUED_KEY_PREFIX = "coupon:issued:"; | ||
| private static final String RESILIENCE_INSTANCE = "coupon-redis"; | ||
|
|
||
| private final RedissonClient redissonClient; | ||
|
|
||
| private String issueLuaScript; | ||
|
|
||
| @PostConstruct | ||
| void loadScript() { | ||
| try (var in = new ClassPathResource("lua/issue_coupon.lua").getInputStream()) { | ||
| this.issueLuaScript = StreamUtils.copyToString(in, StandardCharsets.UTF_8); | ||
| } catch (Exception e) { | ||
| throw new IllegalStateException("issue_coupon.lua 로드 실패", e); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 발급 시도. 동기 호출. | ||
| * Redis 인프라 장애는 회로가 OPEN 되어 fallback 으로 UNAVAILABLE 반환. | ||
| */ | ||
| @CircuitBreaker(name = RESILIENCE_INSTANCE, fallbackMethod = "tryIssueFallback") | ||
| public IssueResult tryIssue(Long templateId, Long userId) { | ||
| Long code = redissonClient | ||
| .getScript(StringCodec.INSTANCE) | ||
| .eval( | ||
| RScript.Mode.READ_WRITE, | ||
| issueLuaScript, | ||
| RScript.ReturnType.INTEGER, | ||
| List.of(stockKey(templateId), issuedKey(templateId)), | ||
| String.valueOf(userId) | ||
| ); | ||
| return IssueResult.of(code); | ||
| } | ||
|
|
||
| /** | ||
| * Circuit Breaker 폴백. | ||
| * 비즈니스 예외는 ignore-exceptions 로 걸러져 여기엔 인프라 장애만 들어온다. | ||
| */ | ||
| @SuppressWarnings("unused") // Resilience4j 가 리플렉션으로 호출 | ||
| private IssueResult tryIssueFallback(Long templateId, Long userId, Throwable t) { | ||
| log.warn("쿠폰 발급 Redis 호출 실패. templateId={}, userId={}, cause={}", | ||
| templateId, userId, t.toString()); | ||
| return IssueResult.UNAVAILABLE; | ||
| } | ||
|
|
||
| /** | ||
| * Lua 통과 후 DB INSERT 가 실패한 경우의 보상. | ||
| * stock 을 1 복구하고 발급자 SET 에서 userId 를 제거한다. | ||
| * | ||
| * 주의: best-effort. 보상 자체가 실패하면 재고 1건이 영구 소실될 수 있다. | ||
| * 운영에서는 별도 알람 + 정합성 점검 잡으로 보완. | ||
| */ | ||
| public void compensate(Long templateId, Long userId) { | ||
| redissonClient | ||
| .getScript(StringCodec.INSTANCE) | ||
| .eval( | ||
| RScript.Mode.READ_WRITE, | ||
| "if redis.call('EXISTS', KEYS[1]) == 1 then redis.call('INCR', KEYS[1]) end; " + | ||
| "redis.call('SREM', KEYS[2], ARGV[1]); return 1", | ||
| RScript.ReturnType.INTEGER, | ||
| List.of(stockKey(templateId), issuedKey(templateId)), | ||
| String.valueOf(userId) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * 템플릿 생성 직후 호출. stock 키만 만료시각까지 살려둔다. | ||
| * issued SET 은 Lua 스크립트가 SADD 시점에 stock TTL 로 동기화한다. | ||
| */ | ||
| public void warmUp(Long templateId, Integer remain, LocalDateTime expiredAt) { | ||
| long ttlSeconds = ttlSeconds(expiredAt); | ||
| var stock = redissonClient.getBucket(stockKey(templateId), StringCodec.INSTANCE); | ||
| stock.set(String.valueOf(remain), Duration.ofSeconds(ttlSeconds)); | ||
| } | ||
|
|
||
| /** | ||
| * 서버 부팅 시 호출. 이미 키가 있으면(Redis 운영 중 상태 유지 중) 건드리지 않는다. | ||
| * Redis 콜드 스타트일 때만 DB 의 remain 으로 워밍업. | ||
| */ | ||
| public void warmUpIfAbsent(Long templateId, Integer remain, LocalDateTime expiredAt) { | ||
| long ttlSeconds = ttlSeconds(expiredAt); | ||
| var stock = redissonClient.getBucket(stockKey(templateId), StringCodec.INSTANCE); | ||
| boolean inserted = stock.setIfAbsent(String.valueOf(remain), Duration.ofSeconds(ttlSeconds)); | ||
| if (inserted) { | ||
| log.info("Redis 콜드 스타트 워밍업: templateId={}, remain={}", templateId, remain); | ||
| } | ||
| } | ||
|
Comment on lines
+118
to
+125
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 불필요한 빈 Set EXPIREAT 호출 제거 (warmUpIfAbsent)
public void warmUpIfAbsent(Long templateId, Integer remain, LocalDateTime expiredAt) {
long ttlSeconds = ttlSeconds(expiredAt);
var stock = redissonClient.getBucket(stockKey(templateId), StringCodec.INSTANCE);
boolean inserted = stock.setIfAbsent(String.valueOf(remain), Duration.ofSeconds(ttlSeconds));
if (inserted) {
log.info("Redis 콜드 스타트 워밍업: templateId={}, remain={}", templateId, remain);
}
} |
||
|
|
||
| private static long ttlSeconds(LocalDateTime expiredAt) { | ||
| return Math.max(Duration.between(LocalDateTime.now(), expiredAt).getSeconds(), 60L); | ||
| } | ||
|
|
||
| private static String stockKey(Long templateId) { | ||
| return STOCK_KEY_PREFIX + templateId; | ||
| } | ||
|
|
||
| private static String issuedKey(Long templateId) { | ||
| return ISSUED_KEY_PREFIX + templateId; | ||
| } | ||
|
|
||
| public enum IssueResult { | ||
| SUCCESS, DUPLICATE, EXHAUSTED, NOT_AVAILABLE, UNAVAILABLE; | ||
|
|
||
| static IssueResult of(Long code) { | ||
| if (code == null) return NOT_AVAILABLE; | ||
| return switch (code.intValue()) { | ||
| case 1 -> SUCCESS; | ||
| case 0 -> DUPLICATE; | ||
| case -1 -> EXHAUSTED; | ||
| default -> NOT_AVAILABLE; | ||
| }; | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 불필요한 빈 Set EXPIREAT 호출 제거
존재하지 않는 키에 대한
EXPIREAT호출은 무시되므로, Lua 스크립트에서 동적으로 TTL을 동기화하도록 개선한 후에는 이 불필요한 코드를 제거할 수 있습니다.