diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/ExpiredRefreshTokenSweeper.java b/backend/src/main/java/com/stackup/stackup/auth/application/ExpiredRefreshTokenSweeper.java new file mode 100644 index 00000000..69e7eb58 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/auth/application/ExpiredRefreshTokenSweeper.java @@ -0,0 +1,39 @@ +package com.stackup.stackup.auth.application; + +import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 만료된 refresh token 행을 정리한다. + * + *
로그인마다 한 행이 쌓이는데 지우는 쪽이 없어 사실상 영구 보관이었다. 만료된 토큰은 + * 검증에서 항상 걸러지므로 남겨둬도 무효다 — 보관해서 얻는 것이 없다. + * + *
취소(revoked)된 토큰은 만료 전이라면 남긴다. 탈퇴·로그아웃 시 revoke 한 기록이
+ * 만료 시각까지는 "이 토큰은 무효"라는 판단 근거로 남아 있어야 한다.
+ */
+@Component
+@RequiredArgsConstructor
+public class ExpiredRefreshTokenSweeper {
+
+ private static final Logger log = LoggerFactory.getLogger(ExpiredRefreshTokenSweeper.class);
+
+ private final RefreshTokenRepository refreshTokenRepository;
+
+ @Transactional
+ @Scheduled(
+ fixedDelayString = "${auth.refresh-token-sweep-interval-ms:86400000}",
+ initialDelayString = "${auth.refresh-token-sweep-initial-delay-ms:300000}")
+ public void sweep() {
+ int deleted = refreshTokenRepository.deleteExpiredBefore(Instant.now());
+ if (deleted > 0) {
+ log.info("expired refresh tokens swept. deleted={}", deleted);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java
index fb5f6c85..cf42bbac 100644
--- a/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java
+++ b/backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java
@@ -1,5 +1,6 @@
package com.stackup.stackup.auth.domain;
+import java.time.Instant;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
@@ -10,6 +11,12 @@ public interface RefreshTokenRepository extends JpaRepository 이 프로젝트는 Redis 를 쓰지 않고 "휘발성 데이터는 PostgreSQL 의 short-lived 레코드"로
+ * 처리하기로 했다(루트 CLAUDE.md). 그런데 아래 둘은 지우는 쪽이 없어 사실상 영구 보관
+ * 이었다 — short-lived 라는 전제가 코드로는 지켜지지 않았다.
+ *
+ * processed_messages 는 AI 콜백마다 한 행씩 쌓인다(질문·꼬리질문·피드백·분석·음성·TTS).
+ * {@code idx_processed_messages_processed_at} 인덱스가 처음부터 있는데 그 컬럼으로 조회하는
+ * 코드가 하나도 없었다 — 보존 정리를 전제로 만든 인덱스인데 정작 정리가 없었던 셈이다.
+ *
+ * 멱등 레코드의 보존 기간은 재전달 창보다 길어야 한다. 너무 일찍 지우면 DLQ 에서
+ * 늦게 재주입된 메시지가 "처음 보는 메시지"가 되어 다시 처리된다(질문이 두 번 붙거나
+ * 피드백이 다시 생성될 수 있다). 기본 30일은 그 창보다 충분히 길다.
+ *
+ * oauth_states 는 여기서 다루지 않는다 — 발급할 때마다 만료분을 지워 스스로 자정한다
+ * ({@code OAuthStateService.issueStateWithPkce}).
+ */
+@Component
+@RequiredArgsConstructor
+public class ProcessedMessageSweeper {
+
+ private static final Logger log = LoggerFactory.getLogger(ProcessedMessageSweeper.class);
+
+ private final ProcessedMessageRepository processedMessageRepository;
+
+ @Value("${messaging.processed-message-retention-days:30}")
+ private long processedRetentionDays = 30;
+
+ @Transactional
+ @Scheduled(
+ fixedDelayString = "${messaging.volatile-sweep-interval-ms:86400000}",
+ initialDelayString = "${messaging.volatile-sweep-initial-delay-ms:300000}")
+ public void sweep() {
+ Instant now = Instant.now();
+ int processed = processedMessageRepository.deleteProcessedBefore(
+ now.minus(Duration.ofDays(processedRetentionDays)));
+ if (processed > 0) {
+ log.info("processed message sweep done. deleted={}, retentionDays={}",
+ processed, processedRetentionDays);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessageRepository.java b/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessageRepository.java
index 9b2ad3ed..d393b0a6 100644
--- a/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessageRepository.java
+++ b/backend/src/main/java/com/stackup/stackup/common/messaging/domain/ProcessedMessageRepository.java
@@ -1,8 +1,18 @@
package com.stackup.stackup.common.messaging.domain;
+import java.time.Instant;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface ProcessedMessageRepository extends JpaRepository 멱등 레코드는 너무 일찍 지우면 안 된다 — DLQ 에서 늦게 재주입된 메시지가
+ * "처음 보는 메시지"가 되어 다시 처리되면 질문이 두 번 붙거나 피드백이 재생성된다.
+ * 그래서 "기한이 지난 것만" 지우는지를 양쪽으로 확인한다.
+ */
+@PostgresRepositoryTest
+class VolatileRetentionTest {
+
+ @Autowired ProcessedMessageRepository processedMessageRepository;
+ @Autowired RefreshTokenRepository refreshTokenRepository;
+ @Autowired UserRepository userRepository;
+ @Autowired EntityManager em;
+
+ @Test
+ void deletesOnlyProcessedMessagesOlderThanThreshold() {
+ Instant now = Instant.now();
+ ProcessedMessage old = processedMessage("m-old", now.minus(Duration.ofDays(40)));
+ ProcessedMessage recent = processedMessage("m-recent", now.minus(Duration.ofDays(1)));
+ processedMessageRepository.save(old);
+ processedMessageRepository.save(recent);
+ em.flush();
+
+ int deleted = processedMessageRepository.deleteProcessedBefore(now.minus(Duration.ofDays(30)));
+
+ assertThat(deleted).isEqualTo(1);
+ assertThat(processedMessageRepository.existsById("m-old")).isFalse();
+ // 최근 것은 남아야 한다 — 지우면 재전달된 메시지가 중복 처리된다.
+ assertThat(processedMessageRepository.existsById("m-recent")).isTrue();
+ }
+
+ @Test
+ void deletesOnlyExpiredRefreshTokens() {
+ User user = userRepository.save(User.createGithubUser(95001L, "token-user", null, null, "t"));
+ Instant now = Instant.now();
+ RefreshToken expired = refreshTokenRepository.save(
+ RefreshToken.issue(user, "hash-expired", null, now.minus(Duration.ofDays(1))));
+ RefreshToken live = refreshTokenRepository.save(
+ RefreshToken.issue(user, "hash-live", null, now.plus(Duration.ofDays(7))));
+ em.flush();
+
+ int deleted = refreshTokenRepository.deleteExpiredBefore(now);
+
+ assertThat(deleted).isEqualTo(1);
+ assertThat(refreshTokenRepository.findById(expired.getId())).isEmpty();
+ // 아직 유효한 토큰은 건드리지 않는다 — 지우면 로그인 세션이 끊긴다.
+ assertThat(refreshTokenRepository.findById(live.getId())).isPresent();
+ }
+
+ private ProcessedMessage processedMessage(String id, Instant processedAt) {
+ ProcessedMessage pm = ProcessedMessage.of(id, "test-consumer");
+ ReflectionTestUtils.setField(pm, "processedAt", processedAt);
+ return pm;
+ }
+}
diff --git a/docs/environment.md b/docs/environment.md
index 4cd59058..15331c73 100644
--- a/docs/environment.md
+++ b/docs/environment.md
@@ -297,3 +297,14 @@ STORAGE_ORPHAN_SWEEP_INITIAL_DELAY_MS=60000 # 부팅 후 첫 실행 지연
`storage.orphan-sweep-interval-ms` / `storage.orphan-sweep-initial-delay-ms` 로 주입된다.
삭제된 자료의 S3 객체를 회수한다 (`docs/security.md §5.1.1`).
+
+### 휘발성 레코드 보존 (Core)
+
+```
+MESSAGING_PROCESSED_MESSAGE_RETENTION_DAYS=30 # 멱등 레코드 보존 기간
+MESSAGING_VOLATILE_SWEEP_INTERVAL_MS=86400000 # 멱등 레코드 정리 주기 (기본 24시간)
+AUTH_REFRESH_TOKEN_SWEEP_INTERVAL_MS=86400000 # 만료 refresh token 정리 주기
+```
+
+보존 기간은 **재전달 창보다 길어야 한다** — 너무 짧으면 DLQ 에서 늦게 재주입된 메시지가
+'처음 보는 메시지'가 되어 중복 처리된다(질문 중복·피드백 재생성).