Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 4 additions & 60 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,10 @@ services:
- "8080"
# t3.small 메모리 한도 고려한 app 한도 (OTel Agent 80MB 포함 600M)
environment:
OTEL_SERVICE_NAME: catchtable-backend
# OTel Collector로 전송 (Collector에서 path 기반 노이즈 필터링 후 Jaeger로 전달)
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_TRACES_EXPORTER: otlp
OTEL_METRICS_EXPORTER: none # 메트릭은 Micrometer/Prometheus로 처리
OTEL_LOGS_EXPORTER: none # 로그는 Promtail/Loki로 처리
OTEL_RESOURCE_ATTRIBUTES: deployment.environment=production
# 샘플링 100% — 노이즈 제거는 Collector에서 path 기반으로 처리
OTEL_TRACES_SAMPLER: parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG: "1.0"
OTEL_INSTRUMENTATION_SPRING_SCHEDULING_ENABLED: "false"
OTEL_INSTRUMENTATION_LOGBACK_APPENDER_ENABLED: "true"
OTEL_INSTRUMENTATION_LOGBACK_MDC_ENABLED: "true"
# 분산 트레이싱(Jaeger + OTel Collector) 제거 — OTel javaagent SDK 전체 비활성화.
# 메트릭은 Micrometer/Prometheus, 로그는 Promtail/Loki가 담당하므로 트레이싱만 끔.
# 트레이싱 복원 시: 이 줄 제거 후 export/sampler 설정 복구 + otel-collector/jaeger 서비스 재추가.
OTEL_SDK_DISABLED: "true"
deploy:
resources:
limits:
Expand Down Expand Up @@ -135,50 +125,6 @@ services:
networks:
- catchtable-net

otel-collector:
image: otel/opentelemetry-collector-contrib:0.152.1
container_name: catchtable-otel-collector
volumes:
- ./monitoring/otel-collector-config.yml:/etc/otelcol-contrib/config.yaml:ro
command: ["--config=/etc/otelcol-contrib/config.yaml"]
expose:
- "4317" # OTLP gRPC (app에서 전송)
depends_on:
- jaeger
deploy:
resources:
limits:
memory: 150M
restart: unless-stopped
networks:
- catchtable-net

jaeger:
image: jaegertracing/all-in-one:1.62.0
container_name: catchtable-jaeger
user: "0" # named volume mkdir 퍼미션 회피 (내부 도구라 보안 영향 없음)
environment:
COLLECTOR_OTLP_ENABLED: "true"
SPAN_STORAGE_TYPE: badger
BADGER_EPHEMERAL: "false"
BADGER_DIRECTORY_VALUE: /badger/data
BADGER_DIRECTORY_KEY: /badger/key
BADGER_SPAN_STORE_TTL: 72h0m0s
volumes:
- jaeger-data:/badger
ports:
- "16686:16686" # Jaeger UI (외부 접근)
expose:
- "4317" # OTLP gRPC (앱에서 전송)
- "4318" # OTLP HTTP
deploy:
resources:
limits:
memory: 400M
restart: unless-stopped
networks:
- catchtable-net

grafana:
image: grafana/grafana:11.3.0
container_name: catchtable-grafana
Expand All @@ -194,7 +140,6 @@ services:
depends_on:
- prometheus
- loki
- jaeger
deploy:
resources:
limits:
Expand Down Expand Up @@ -277,7 +222,6 @@ networks:
volumes:
prometheus-data:
loki-data:
jaeger-data:
grafana-data:
promtail-positions:
kafka-prod-data:
Expand Down
13 changes: 0 additions & 13 deletions grafana/provisioning/datasources/datasources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,3 @@ datasources:
access: proxy
url: http://loki:3100
editable: true
jsonData:
derivedFields:
- datasourceUid: jaeger
matcherRegex: 'trace_id=(\w+)'
name: TraceID
url: '$${__value.raw}'

- name: Jaeger
type: jaeger
access: proxy
url: http://jaeger:16686
uid: jaeger
editable: true
10 changes: 9 additions & 1 deletion src/main/java/com/catchtable/coupon/entity/Coupon.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@
import java.time.LocalDateTime;

@Entity
@Table(name = "coupons")
@Table(
name = "coupons",
uniqueConstraints = {
@UniqueConstraint(
name = "uq_coupons_user_template",
columnNames = {"user_id", "coupon_template_id"}
)
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.catchtable.coupon.entity;

import com.catchtable.global.exception.CustomException;
import com.catchtable.global.exception.ErrorCode;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
Expand Down Expand Up @@ -50,11 +48,4 @@ public class CouponTemplate {
@Column(name = "is_deleted", nullable = false)
@Builder.Default
private Boolean isDeleted = false;

public void decreaseRemain() {
if (this.remain <= 0) {
throw new CustomException(ErrorCode.COUPON_EXHAUSTED);
}
this.remain--;
}
}
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 src/main/java/com/catchtable/coupon/redis/CouponWarmupRunner.java
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 src/main/java/com/catchtable/coupon/redis/RedisCouponIssuer.java
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));
}
Comment on lines +108 to +112

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

💡 불필요한 빈 Set EXPIREAT 호출 제거

존재하지 않는 키에 대한 EXPIREAT 호출은 무시되므로, Lua 스크립트에서 동적으로 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

💡 불필요한 빈 Set EXPIREAT 호출 제거 (warmUpIfAbsent)

warmUp과 마찬가지로, 존재하지 않는 빈 Set에 대한 expireAt 호출을 제거하여 불필요한 Redis 커맨드 전송을 방지합니다.

    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;
};
}
}
}
Loading
Loading