From 2da8e51e46dce17e938e7197936dd55b30bddcb4 Mon Sep 17 00:00:00 2001 From: JO HYUNGJOON Date: Sat, 11 Jul 2026 23:12:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Redis=20=EB=A9=B1=EB=93=B1=EC=84=B1?= =?UTF-8?q?=20=ED=82=A4,=20=EC=A7=80=EC=97=B0=20=ED=81=90=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 10 ++- .../config/redis/DelayedJobHandler.java | 14 ++++ .../global/config/redis/DelayedQueue.java | 79 +++++++++++++++++++ .../config/redis/DelayedQueuePoller.java | 43 ++++++++++ .../config/redis/IdempotencyKeyStore.java | 55 +++++++++++++ .../redis/LoggingDelayedJobHandler.java | 21 +++++ .../global/config/redis/RedisConfig.java | 60 ++++++++++++++ src/main/resources/application.yml | 7 +- .../redis/AbstractRedisIntegrationTest.java | 54 +++++++++++++ .../global/config/redis/DelayedQueueIT.java | 72 +++++++++++++++++ .../config/redis/IdempotencyKeyStoreIT.java | 63 +++++++++++++++ 11 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/piuda/callcare/global/config/redis/DelayedJobHandler.java create mode 100644 src/main/java/com/piuda/callcare/global/config/redis/DelayedQueue.java create mode 100644 src/main/java/com/piuda/callcare/global/config/redis/DelayedQueuePoller.java create mode 100644 src/main/java/com/piuda/callcare/global/config/redis/IdempotencyKeyStore.java create mode 100644 src/main/java/com/piuda/callcare/global/config/redis/LoggingDelayedJobHandler.java create mode 100644 src/main/java/com/piuda/callcare/global/config/redis/RedisConfig.java create mode 100644 src/test/java/com/piuda/callcare/global/config/redis/AbstractRedisIntegrationTest.java create mode 100644 src/test/java/com/piuda/callcare/global/config/redis/DelayedQueueIT.java create mode 100644 src/test/java/com/piuda/callcare/global/config/redis/IdempotencyKeyStoreIT.java diff --git a/build.gradle b/build.gradle index 40623c5..e9bfd56 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.testcontainers:junit-jupiter' testCompileOnly 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testAnnotationProcessor 'org.projectlombok:lombok' @@ -58,5 +59,12 @@ dependencyManagement { } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform { + String groups = System.getProperty('groups') + if (groups != null) { + includeTags groups // ./gradlew test -Dgroups=integration → 통합 테스트만 + } else { + excludeTags 'integration' // 기본 실행 → 통합 테스트 제외 (Docker 불필요) + } + } } diff --git a/src/main/java/com/piuda/callcare/global/config/redis/DelayedJobHandler.java b/src/main/java/com/piuda/callcare/global/config/redis/DelayedJobHandler.java new file mode 100644 index 0000000..406ee06 --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/config/redis/DelayedJobHandler.java @@ -0,0 +1,14 @@ +package com.piuda.callcare.global.config.redis; + +/** + * 지연 큐에서 꺼낸 payload를 처리하는 핸들러. + *

+ * 이번 작업은 인프라 토대만 만들며, 실제 처리 로직(FCM 푸시, 전화 재시도 등)은 + * 후속 작업에서 이 인터페이스 구현체를 끼워 넣는다. + * 기본 구현은 로그만 남기는 no-op 스텁({@link LoggingDelayedJobHandler})이다. + */ +public interface DelayedJobHandler { + + // 지연 큐에서 due 상태로 꺼낸 payload 1건을 처리한다 + void handle(String payload); +} \ No newline at end of file diff --git a/src/main/java/com/piuda/callcare/global/config/redis/DelayedQueue.java b/src/main/java/com/piuda/callcare/global/config/redis/DelayedQueue.java new file mode 100644 index 0000000..f525e9d --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/config/redis/DelayedQueue.java @@ -0,0 +1,79 @@ +package com.piuda.callcare.global.config.redis; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +/** + * 지연 큐 프리미티브 (Redis Sorted Set 기반). + *

+ * {@code score = 실행 예정 시각(epoch millis)}으로 payload를 ZSET에 넣고, + * {@code score <= now}인 항목을 꺼낸다. + *

+ * {@link #pollDue}는 조회(ZRANGEBYSCORE)와 삭제(ZREM)를 하나의 Lua 스크립트로 실행해 + * 원자적으로 pop 한다. 동시 폴링에서도 같은 항목이 두 번 나오지 않는다. + * (ZRANGEBYSCORE 후 별도 ZREM 하는 비원자 방식은 레이스가 생기므로 쓰지 않는다.) + *

+ * 키 네이밍 규칙: {@code delayqueue:{큐 이름}} — prefix는 이 큐가 붙인다. + */ +@Component +@RequiredArgsConstructor +public class DelayedQueue { + + private static final String KEY_PREFIX = "delayqueue:"; + private static final int DEFAULT_POLL_LIMIT = 100; + + /** + * score <= now 인 항목을 limit 개까지 꺼내고(ZREM) 그 payload 목록을 반환한다. + * KEYS[1]=큐 키, ARGV[1]=now(millis), ARGV[2]=limit. + */ + private static final RedisScript POLL_DUE_SCRIPT = RedisScript.of( + "local due = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, ARGV[2]) " + + "if #due > 0 then redis.call('ZREM', KEYS[1], unpack(due)) end " + + "return due", + List.class); + + private final StringRedisTemplate stringRedisTemplate; + + /** + * payload를 {@code now + delay} 시각에 실행되도록 큐에 넣는다. + * + * @param queueKey 큐 이름 (prefix 제외) + * @param payload 실행할 작업의 JSON 문자열 + * @param delay 지금부터의 지연 시간 + */ + public void enqueue(String queueKey, String payload, Duration delay) { + long score = Instant.now().toEpochMilli() + delay.toMillis(); + stringRedisTemplate.opsForZSet().add(prefixed(queueKey), payload, score); + } + + // 실행 예정 시각이 지난 항목을 기본 개수(100)까지 원자적으로 꺼낸다 + public List pollDue(String queueKey) { + return pollDue(queueKey, DEFAULT_POLL_LIMIT); + } + + /** + * 실행 예정 시각({@code score})이 현재 시각 이하인 항목을 limit 개까지 원자적으로 꺼낸다. + * 꺼낸 항목은 큐에서 제거되므로 다시 pollDue 해도 중복으로 나오지 않는다. + */ + @SuppressWarnings("unchecked") + public List pollDue(String queueKey, int limit) { + long now = Instant.now().toEpochMilli(); + List due = stringRedisTemplate.execute( + POLL_DUE_SCRIPT, + List.of(prefixed(queueKey)), + String.valueOf(now), + String.valueOf(limit)); + return due != null ? due : List.of(); + } + + private String prefixed(String key) { + return KEY_PREFIX + key; + } +} \ No newline at end of file diff --git a/src/main/java/com/piuda/callcare/global/config/redis/DelayedQueuePoller.java b/src/main/java/com/piuda/callcare/global/config/redis/DelayedQueuePoller.java new file mode 100644 index 0000000..2670e69 --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/config/redis/DelayedQueuePoller.java @@ -0,0 +1,43 @@ +package com.piuda.callcare.global.config.redis; + +import java.util.List; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 지연 큐를 주기적으로 폴링해 due 항목을 {@link DelayedJobHandler}로 넘기는 스케줄러. + *

+ * 폴링 주기는 {@code notification.delayed-queue.poll-interval-ms}(기본 1000ms)로 조정한다. + * {@code notification.delayed-queue.poller-enabled=false}로 폴링을 끌 수 있다(테스트/부분 배포용). + *

+ * 지금은 핸들러가 no-op 스텁이라 실제 도메인 동작으로 이어지지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(name = "notification.delayed-queue.poller-enabled", havingValue = "true", matchIfMissing = true) +public class DelayedQueuePoller { + + // 알림 지연 작업이 쌓이는 기본 큐 이름 (prefix 제외) + public static final String NOTIFICATION_QUEUE_KEY = "notification"; + + private final DelayedQueue delayedQueue; + private final DelayedJobHandler delayedJobHandler; + + @Scheduled(fixedDelayString = "${notification.delayed-queue.poll-interval-ms:1000}") + public void poll() { + List duePayloads = delayedQueue.pollDue(NOTIFICATION_QUEUE_KEY); + for (String payload : duePayloads) { + try { + delayedJobHandler.handle(payload); + } catch (Exception e) { + log.error("[DelayedQueue] payload 처리 중 오류 (payload={}): {}", payload, e.getMessage(), e); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/piuda/callcare/global/config/redis/IdempotencyKeyStore.java b/src/main/java/com/piuda/callcare/global/config/redis/IdempotencyKeyStore.java new file mode 100644 index 0000000..bccd8c0 --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/config/redis/IdempotencyKeyStore.java @@ -0,0 +1,55 @@ +package com.piuda.callcare.global.config.redis; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +/** + * 멱등성 키 프리미티브. + *

+ * "이 키를 지금 처음 잡는가?"를 원자적으로 판정해 같은 알림의 중복 실행을 막는다. + * 반드시 {@code SETNX + TTL}({@code setIfAbsent(value, ttl)}) 원자 연산만 사용하며, + * "조회 후 저장" 같은 비원자 패턴은 쓰지 않는다. + *

+ * 키 네이밍 규칙: {@code idem:{도메인}:{용도 식별자}} — prefix는 이 스토어가 붙인다. + * 호출부는 도메인/용도가 구분되는 논리 키(예: {@code "call:senior:42:2026-07-11:BREAKFAST"})만 넘긴다. + */ +@Component +@RequiredArgsConstructor +public class IdempotencyKeyStore { + + private static final String KEY_PREFIX = "idem:"; + private static final String ACQUIRED_MARKER = "1"; + + private final StringRedisTemplate stringRedisTemplate; + + /** + * 키를 처음 잡으면 TTL과 함께 마킹하고 true, 이미 잡혀 있으면 false를 반환한다(원자적). + * + * @param key 도메인/용도가 구분되는 논리 키 (prefix 제외) + * @param ttl 멱등성 유지 기간 + * @return 이번 호출이 최초 획득이면 true + */ + public boolean tryAcquire(String key, Duration ttl) { + Boolean acquired = stringRedisTemplate.opsForValue() + .setIfAbsent(prefixed(key), ACQUIRED_MARKER, ttl); + return Boolean.TRUE.equals(acquired); + } + + // 이미 처리된(키가 잡혀 있는) 상태인지 확인 + public boolean isProcessed(String key) { + return Boolean.TRUE.equals(stringRedisTemplate.hasKey(prefixed(key))); + } + + // 멱등성 키 해제 (재실행을 허용해야 하는 예외 상황용) + public void release(String key) { + stringRedisTemplate.delete(prefixed(key)); + } + + private String prefixed(String key) { + return KEY_PREFIX + key; + } +} \ No newline at end of file diff --git a/src/main/java/com/piuda/callcare/global/config/redis/LoggingDelayedJobHandler.java b/src/main/java/com/piuda/callcare/global/config/redis/LoggingDelayedJobHandler.java new file mode 100644 index 0000000..a3e6b8f --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/config/redis/LoggingDelayedJobHandler.java @@ -0,0 +1,21 @@ +package com.piuda.callcare.global.config.redis; + +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +/** + * {@link DelayedJobHandler}의 기본 no-op 스텁 구현. + *

+ * 꺼낸 payload를 실제 도메인 동작에 연결하지 않고 로그만 남긴다. + * 실제 처리 핸들러가 추가되면 이 스텁을 교체한다. + */ +@Slf4j +@Component +public class LoggingDelayedJobHandler implements DelayedJobHandler { + + @Override + public void handle(String payload) { + log.info("[DelayedQueue] due payload 수신 (아직 처리 핸들러 없음, no-op): {}", payload); + } +} \ No newline at end of file diff --git a/src/main/java/com/piuda/callcare/global/config/redis/RedisConfig.java b/src/main/java/com/piuda/callcare/global/config/redis/RedisConfig.java new file mode 100644 index 0000000..2787b21 --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/config/redis/RedisConfig.java @@ -0,0 +1,60 @@ +package com.piuda.callcare.global.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; + +import lombok.extern.slf4j.Slf4j; + +/** + * Redis 인프라 공통 설정. + *

+ * 알림 프리미티브(멱등성 키, 지연 큐)는 payload를 JSON 문자열로 다루므로 + * 문자열 직렬화({@link StringRedisTemplate})면 충분하다. + *

+ * {@code @EnableScheduling}은 지연 큐 폴링 스케줄러({@link DelayedQueuePoller})를 위해 활성화한다. + */ +@Slf4j +@Configuration +@EnableScheduling +public class RedisConfig { + + @Value("${spring.data.redis.host:localhost}") + private String host; + + @Value("${spring.data.redis.port:6379}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port)); + } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } + + /** + * 애플리케이션 기동 시 Redis 커넥션 정상 여부를 PING으로 1회 확인해 로그로 남긴다. + * 실패해도 기동은 막지 않고 에러 로그만 남긴다. + */ + @Bean + public ApplicationRunner redisConnectionHealthCheck(RedisConnectionFactory connectionFactory) { + return args -> { + try (RedisConnection connection = connectionFactory.getConnection()) { + String pong = connection.ping(); + log.info("[Redis] 커넥션 확인 성공 (host={}, port={}, ping={})", host, port, pong); + } catch (Exception e) { + log.error("[Redis] 커넥션 확인 실패 (host={}, port={}): {}", host, port, e.getMessage()); + } + }; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1c58937..a68d15c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -49,4 +49,9 @@ coolsms: api-key: ${COOLSMS_API_KEY:} api-secret: ${COOLSMS_API_SECRET:} sender: ${COOLSMS_SENDER_NUMBER:} - mock-enabled: ${COOLSMS_MOCK_ENABLED:false} \ No newline at end of file + mock-enabled: ${COOLSMS_MOCK_ENABLED:false} + +notification: + delayed-queue: + poller-enabled: ${NOTIFICATION_DELAYED_QUEUE_POLLER_ENABLED:true} + poll-interval-ms: ${NOTIFICATION_DELAYED_QUEUE_POLL_INTERVAL_MS:1000} \ No newline at end of file diff --git a/src/test/java/com/piuda/callcare/global/config/redis/AbstractRedisIntegrationTest.java b/src/test/java/com/piuda/callcare/global/config/redis/AbstractRedisIntegrationTest.java new file mode 100644 index 0000000..08be73f --- /dev/null +++ b/src/test/java/com/piuda/callcare/global/config/redis/AbstractRedisIntegrationTest.java @@ -0,0 +1,54 @@ +package com.piuda.callcare.global.config.redis; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Redis 프리미티브 통합 테스트 베이스. + *

+ * redis:7.2 컨테이너를 띄우고, Redis 관련 빈만 담은 최소 컨텍스트를 올린다 + * (전체 앱을 부팅하지 않으므로 MySQL/Elasticsearch가 없어도 실행된다). + * {@code @Tag("integration")}으로 분리되어 {@code ./gradlew test -Dgroups=integration}에서 실행된다. + *

+ * 컨테이너는 싱글턴 패턴으로 JVM당 한 번만 기동하고 종료하지 않는다(Ryuk이 JVM 종료 시 정리). + * 여러 IT 클래스가 동일 설정의 Spring 컨텍스트를 캐시 공유하므로, 컨테이너 포트도 테스트 내내 + * 고정되어야 캐시된 컨텍스트가 끊긴 포트를 바라보는 문제를 피할 수 있다. + */ +@Tag("integration") +@ActiveProfiles("test") +@SpringBootTest(classes = {RedisConfig.class, IdempotencyKeyStore.class, DelayedQueue.class}) +abstract class AbstractRedisIntegrationTest { + + static final GenericContainer REDIS = + new GenericContainer<>(DockerImageName.parse("redis:7.2")).withExposedPorts(6379); + + static { + REDIS.start(); + } + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS::getHost); + registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379)); + } + + @Autowired + protected StringRedisTemplate stringRedisTemplate; + + // 각 테스트가 깨끗한 상태에서 시작하도록 키를 모두 비운다 + @BeforeEach + void flushRedis() { + stringRedisTemplate.execute((org.springframework.data.redis.core.RedisCallback) connection -> { + connection.serverCommands().flushDb(); + return null; + }); + } +} \ No newline at end of file diff --git a/src/test/java/com/piuda/callcare/global/config/redis/DelayedQueueIT.java b/src/test/java/com/piuda/callcare/global/config/redis/DelayedQueueIT.java new file mode 100644 index 0000000..668f36c --- /dev/null +++ b/src/test/java/com/piuda/callcare/global/config/redis/DelayedQueueIT.java @@ -0,0 +1,72 @@ +package com.piuda.callcare.global.config.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("DelayedQueue 통합 테스트") +class DelayedQueueIT extends AbstractRedisIntegrationTest { + + private static final String QUEUE = "notification"; + + @Autowired + private DelayedQueue delayedQueue; + + @Test + @DisplayName("정상 케이스: delay 이전에는 비어 있고 delay 이후에는 payload가 나온다") + void pollDue_returnsPayload_onlyAfterDelay() throws InterruptedException { + // Given + String payload = "{\"type\":\"CALL\",\"seniorId\":42}"; + delayedQueue.enqueue(QUEUE, payload, Duration.ofMillis(500)); + + // When: delay 이전 + List beforeDelay = delayedQueue.pollDue(QUEUE); + + // Then: 아직 나오지 않는다 + assertThat(beforeDelay).isEmpty(); + + // When: delay 이후 + Thread.sleep(700); + List afterDelay = delayedQueue.pollDue(QUEUE); + + // Then: 나온다 + assertThat(afterDelay).containsExactly(payload); + } + + @Test + @DisplayName("원자성: due 항목을 pollDue로 꺼낸 뒤 다시 pollDue 하면 중복으로 나오지 않는다") + void pollDue_removesItem_soSecondPollIsEmpty() { + // Given: 즉시 due 상태로 넣는다 + String payload = "{\"type\":\"PUSH\",\"seniorId\":7}"; + delayedQueue.enqueue(QUEUE, payload, Duration.ZERO); + + // When + List firstPoll = delayedQueue.pollDue(QUEUE); + List secondPoll = delayedQueue.pollDue(QUEUE); + + // Then + assertThat(firstPoll).containsExactly(payload); + assertThat(secondPoll).isEmpty(); + } + + @Test + @DisplayName("정상 케이스: 여러 due 항목을 한 번에 모두 꺼낸다") + void pollDue_returnsAllDueItems() { + // Given + delayedQueue.enqueue(QUEUE, "job-1", Duration.ZERO); + delayedQueue.enqueue(QUEUE, "job-2", Duration.ZERO); + delayedQueue.enqueue(QUEUE, "job-future", Duration.ofMinutes(10)); + + // When + List due = delayedQueue.pollDue(QUEUE); + + // Then: due 2건만 나오고, 미래 항목은 남는다 + assertThat(due).containsExactlyInAnyOrder("job-1", "job-2"); + assertThat(delayedQueue.pollDue(QUEUE)).isEmpty(); + } +} \ No newline at end of file diff --git a/src/test/java/com/piuda/callcare/global/config/redis/IdempotencyKeyStoreIT.java b/src/test/java/com/piuda/callcare/global/config/redis/IdempotencyKeyStoreIT.java new file mode 100644 index 0000000..022b33b --- /dev/null +++ b/src/test/java/com/piuda/callcare/global/config/redis/IdempotencyKeyStoreIT.java @@ -0,0 +1,63 @@ +package com.piuda.callcare.global.config.redis; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("IdempotencyKeyStore 통합 테스트") +class IdempotencyKeyStoreIT extends AbstractRedisIntegrationTest { + + @Autowired + private IdempotencyKeyStore idempotencyKeyStore; + + @Test + @DisplayName("정상 케이스: 같은 키를 처음 잡으면 true, 이미 잡혀 있으면 false를 반환한다") + void tryAcquire_returnsFalse_whenKeyAlreadyHeld() { + // Given + String key = "call:senior:42:2026-07-11:BREAKFAST"; + + // When + boolean first = idempotencyKeyStore.tryAcquire(key, Duration.ofMinutes(10)); + boolean second = idempotencyKeyStore.tryAcquire(key, Duration.ofMinutes(10)); + + // Then + assertThat(first).isTrue(); + assertThat(second).isFalse(); + } + + @Test + @DisplayName("정상 케이스: TTL이 만료되면 같은 키를 다시 잡을 수 있다") + void tryAcquire_returnsTrueAgain_afterTtlExpires() throws InterruptedException { + // Given + String key = "call:senior:42:2026-07-11:LUNCH"; + idempotencyKeyStore.tryAcquire(key, Duration.ofMillis(500)); + + // When: TTL 만료까지 대기 + Thread.sleep(700); + boolean afterExpiry = idempotencyKeyStore.tryAcquire(key, Duration.ofMinutes(10)); + + // Then + assertThat(afterExpiry).isTrue(); + } + + @Test + @DisplayName("보조 메서드: isProcessed는 키 보유 여부를, release는 해제를 반영한다") + void isProcessed_and_release() { + // Given + String key = "call:senior:42:2026-07-11:DINNER"; + + // When / Then + assertThat(idempotencyKeyStore.isProcessed(key)).isFalse(); + + idempotencyKeyStore.tryAcquire(key, Duration.ofMinutes(10)); + assertThat(idempotencyKeyStore.isProcessed(key)).isTrue(); + + idempotencyKeyStore.release(key); + assertThat(idempotencyKeyStore.isProcessed(key)).isFalse(); + assertThat(idempotencyKeyStore.tryAcquire(key, Duration.ofMinutes(10))).isTrue(); + } +} \ No newline at end of file From 1bf9f3dad2cb82857bb2192d3d08538f3685d9b5 Mon Sep 17 00:00:00 2001 From: JO HYUNGJOON Date: Sat, 11 Jul 2026 23:33:18 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20Redis=20=EC=BB=A4=EB=84=A5=EC=85=98?= =?UTF-8?q?=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20=EC=88=98=EB=8F=99=20=EB=B9=88?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=EC=97=90=20=EC=9C=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/redis/RedisConfig.java | 22 +++++-------------- .../redis/AbstractRedisIntegrationTest.java | 3 +++ 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/piuda/callcare/global/config/redis/RedisConfig.java b/src/main/java/com/piuda/callcare/global/config/redis/RedisConfig.java index 2787b21..a1f7881 100644 --- a/src/main/java/com/piuda/callcare/global/config/redis/RedisConfig.java +++ b/src/main/java/com/piuda/callcare/global/config/redis/RedisConfig.java @@ -6,9 +6,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.RedisStandaloneConfiguration; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.EnableScheduling; import lombok.extern.slf4j.Slf4j; @@ -16,10 +13,13 @@ /** * Redis 인프라 공통 설정. *

- * 알림 프리미티브(멱등성 키, 지연 큐)는 payload를 JSON 문자열로 다루므로 - * 문자열 직렬화({@link StringRedisTemplate})면 충분하다. + * 커넥션 팩토리와 {@code StringRedisTemplate}은 Spring Boot 자동 구성에 맡긴다. + * 직접 빈을 선언하면 {@code RedisAutoConfiguration}({@code @ConditionalOnMissingBean})이 비활성화되어 + * {@code spring.data.redis.password/database/ssl/timeout} 등 나머지 속성이 무시되기 때문이다. + * 알림 프리미티브(멱등성 키, 지연 큐)는 payload를 문자열로 다루므로 {@code StringRedisTemplate}이면 충분하다. *

- * {@code @EnableScheduling}은 지연 큐 폴링 스케줄러({@link DelayedQueuePoller})를 위해 활성화한다. + * 이 설정 클래스는 커넥션 헬스체크와, 지연 큐 폴링 스케줄러({@link DelayedQueuePoller})를 위한 + * {@code @EnableScheduling}만 담당한다. */ @Slf4j @Configuration @@ -32,16 +32,6 @@ public class RedisConfig { @Value("${spring.data.redis.port:6379}") private int port; - @Bean - public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory(new RedisStandaloneConfiguration(host, port)); - } - - @Bean - public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { - return new StringRedisTemplate(connectionFactory); - } - /** * 애플리케이션 기동 시 Redis 커넥션 정상 여부를 PING으로 1회 확인해 로그로 남긴다. * 실패해도 기동은 막지 않고 에러 로그만 남긴다. diff --git a/src/test/java/com/piuda/callcare/global/config/redis/AbstractRedisIntegrationTest.java b/src/test/java/com/piuda/callcare/global/config/redis/AbstractRedisIntegrationTest.java index 08be73f..76813a6 100644 --- a/src/test/java/com/piuda/callcare/global/config/redis/AbstractRedisIntegrationTest.java +++ b/src/test/java/com/piuda/callcare/global/config/redis/AbstractRedisIntegrationTest.java @@ -3,6 +3,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.test.context.ActiveProfiles; @@ -25,6 +27,7 @@ @Tag("integration") @ActiveProfiles("test") @SpringBootTest(classes = {RedisConfig.class, IdempotencyKeyStore.class, DelayedQueue.class}) +@ImportAutoConfiguration(RedisAutoConfiguration.class) abstract class AbstractRedisIntegrationTest { static final GenericContainer REDIS =