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
10 changes: 9 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 불필요)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.piuda.callcare.global.config.redis;

/**
* 지연 큐에서 꺼낸 payload를 처리하는 핸들러.
* <p>
* 이번 작업은 인프라 토대만 만들며, 실제 처리 로직(FCM 푸시, 전화 재시도 등)은
* 후속 작업에서 이 인터페이스 구현체를 끼워 넣는다.
* 기본 구현은 로그만 남기는 no-op 스텁({@link LoggingDelayedJobHandler})이다.
*/
public interface DelayedJobHandler {

// 지연 큐에서 due 상태로 꺼낸 payload 1건을 처리한다
void handle(String payload);
}
Original file line number Diff line number Diff line change
@@ -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 기반).
* <p>
* {@code score = 실행 예정 시각(epoch millis)}으로 payload를 ZSET에 넣고,
* {@code score <= now}인 항목을 꺼낸다.
* <p>
* {@link #pollDue}는 조회(ZRANGEBYSCORE)와 삭제(ZREM)를 <b>하나의 Lua 스크립트</b>로 실행해
* 원자적으로 pop 한다. 동시 폴링에서도 같은 항목이 두 번 나오지 않는다.
* (ZRANGEBYSCORE 후 별도 ZREM 하는 비원자 방식은 레이스가 생기므로 쓰지 않는다.)
* <p>
* 키 네이밍 규칙: {@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<List> 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<String> pollDue(String queueKey) {
return pollDue(queueKey, DEFAULT_POLL_LIMIT);
}

/**
* 실행 예정 시각({@code score})이 현재 시각 이하인 항목을 limit 개까지 원자적으로 꺼낸다.
* 꺼낸 항목은 큐에서 제거되므로 다시 pollDue 해도 중복으로 나오지 않는다.
*/
@SuppressWarnings("unchecked")
public List<String> pollDue(String queueKey, int limit) {
long now = Instant.now().toEpochMilli();
List<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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}로 넘기는 스케줄러.
* <p>
* 폴링 주기는 {@code notification.delayed-queue.poll-interval-ms}(기본 1000ms)로 조정한다.
* {@code notification.delayed-queue.poller-enabled=false}로 폴링을 끌 수 있다(테스트/부분 배포용).
* <p>
* 지금은 핸들러가 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<String> 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);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 멱등성 키 프리미티브.
* <p>
* "이 키를 지금 처음 잡는가?"를 원자적으로 판정해 같은 알림의 중복 실행을 막는다.
* 반드시 {@code SETNX + TTL}({@code setIfAbsent(value, ttl)}) 원자 연산만 사용하며,
* "조회 후 저장" 같은 비원자 패턴은 쓰지 않는다.
* <p>
* 키 네이밍 규칙: {@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;
}
}
Original file line number Diff line number Diff line change
@@ -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 스텁 구현.
* <p>
* 꺼낸 payload를 실제 도메인 동작에 연결하지 않고 로그만 남긴다.
* 실제 처리 핸들러가 추가되면 이 스텁을 교체한다.
*/
@Slf4j
@Component
public class LoggingDelayedJobHandler implements DelayedJobHandler {

@Override
public void handle(String payload) {
log.info("[DelayedQueue] due payload 수신 (아직 처리 핸들러 없음, no-op): {}", payload);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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.scheduling.annotation.EnableScheduling;

import lombok.extern.slf4j.Slf4j;

/**
* Redis 인프라 공통 설정.
* <p>
* 커넥션 팩토리와 {@code StringRedisTemplate}은 Spring Boot 자동 구성에 맡긴다.
* 직접 빈을 선언하면 {@code RedisAutoConfiguration}({@code @ConditionalOnMissingBean})이 비활성화되어
* {@code spring.data.redis.password/database/ssl/timeout} 등 나머지 속성이 무시되기 때문이다.
* 알림 프리미티브(멱등성 키, 지연 큐)는 payload를 문자열로 다루므로 {@code StringRedisTemplate}이면 충분하다.
* <p>
* 이 설정 클래스는 커넥션 헬스체크와, 지연 큐 폴링 스케줄러({@link DelayedQueuePoller})를 위한
* {@code @EnableScheduling}만 담당한다.
*/
@Slf4j
@Configuration
@EnableScheduling
public class RedisConfig {

@Value("${spring.data.redis.host:localhost}")
private String host;

@Value("${spring.data.redis.port:6379}")
private int port;

/**
* 애플리케이션 기동 시 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());
}
};
}
}
7 changes: 6 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
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}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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.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;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

/**
* Redis 프리미티브 통합 테스트 베이스.
* <p>
* redis:7.2 컨테이너를 띄우고, Redis 관련 빈만 담은 최소 컨텍스트를 올린다
* (전체 앱을 부팅하지 않으므로 MySQL/Elasticsearch가 없어도 실행된다).
* {@code @Tag("integration")}으로 분리되어 {@code ./gradlew test -Dgroups=integration}에서 실행된다.
* <p>
* 컨테이너는 <b>싱글턴 패턴</b>으로 JVM당 한 번만 기동하고 종료하지 않는다(Ryuk이 JVM 종료 시 정리).
* 여러 IT 클래스가 동일 설정의 Spring 컨텍스트를 캐시 공유하므로, 컨테이너 포트도 테스트 내내
* 고정되어야 캐시된 컨텍스트가 끊긴 포트를 바라보는 문제를 피할 수 있다.
*/
@Tag("integration")
@ActiveProfiles("test")
@SpringBootTest(classes = {RedisConfig.class, IdempotencyKeyStore.class, DelayedQueue.class})
@ImportAutoConfiguration(RedisAutoConfiguration.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<Void>) connection -> {
connection.serverCommands().flushDb();
return null;
});
}
}
Loading