From 92ffefe190feef591b4cd1b1bec52e845ebb3a9e Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 24 Aug 2026 13:14:41 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20=ED=9C=98=EB=B0=9C=EC=84=B1=20?= =?UTF-8?q?=EB=A0=88=EC=BD=94=EB=93=9C=20=EB=B3=B4=EC=A1=B4=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(=EB=A9=B1=EB=93=B1=20=EB=A0=88=EC=BD=94=EB=93=9C?= =?UTF-8?q?=C2=B7=EB=A7=8C=EB=A3=8C=20refresh=20token)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 프로젝트는 Redis 를 쓰지 않고 "휘발성 데이터는 PostgreSQL 의 short-lived 레코드"로 처리하기로 했다(루트 CLAUDE.md). 그런데 아래 둘은 지우는 쪽이 없어 사실상 영구 보관이었다 — short-lived 라는 전제가 코드로 지켜지지 않았다. - processed_messages: AI 콜백마다 한 행(질문·꼬리질문·피드백·분석·음성·TTS). idx_processed_messages_processed_at 인덱스가 처음부터 있는데 그 컬럼으로 조회하는 코드가 하나도 없었다 — 보존 정리를 전제로 만든 인덱스인데 정작 정리가 없었던 셈이다. - refresh_tokens: 로그인마다 한 행. 만료된 것은 검증에서 항상 걸러지므로 남겨둬도 무효인데 쌓이기만 한다. 보존 기간은 재전달 창보다 길어야 한다 — 너무 일찍 지우면 DLQ 에서 늦게 재주입된 메시지가 "처음 보는 메시지"가 되어 다시 처리된다(질문 중복·피드백 재생성). 기본 30일. 스위퍼를 도메인별로 나눴다. 하나로 묶으면 common 이 auth.domain 을 참조하게 되는데 common 은 최하위 공용 레이어라 방향이 거꾸로다(ArchUnit 은 통과했지만 설계상 아님). oauth_states 는 발급 때마다 만료분을 지워 스스로 자정하므로 대상이 아니다. --- .../ExpiredRefreshTokenSweeper.java | 39 ++++++++++ .../auth/domain/RefreshTokenRepository.java | 7 ++ .../application/ProcessedMessageSweeper.java | 56 ++++++++++++++ .../domain/ProcessedMessageRepository.java | 10 +++ .../messaging/VolatileRetentionTest.java | 74 +++++++++++++++++++ docs/environment.md | 11 +++ 6 files changed, 197 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/auth/application/ExpiredRefreshTokenSweeper.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/application/ProcessedMessageSweeper.java create mode 100644 backend/src/test/java/com/stackup/stackup/common/messaging/VolatileRetentionTest.java 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 findByTokenHash(String tokenHash); + // 만료된 refresh token 은 더 이상 어떤 판단에도 쓰이지 않는다 — 검증은 항상 + // 만료 시각을 확인하므로 남겨둬도 무효이고, 쌓이기만 한다. + @Modifying(clearAutomatically = true) + @Query("DELETE FROM RefreshToken rt WHERE rt.expiresAt < :now") + int deleteExpiredBefore(@Param("now") Instant now); + void deleteByUser_Id(Long userId); @Modifying(clearAutomatically = true) diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/application/ProcessedMessageSweeper.java b/backend/src/main/java/com/stackup/stackup/common/messaging/application/ProcessedMessageSweeper.java new file mode 100644 index 00000000..7b0161f5 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/application/ProcessedMessageSweeper.java @@ -0,0 +1,56 @@ +package com.stackup.stackup.common.messaging.application; + +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import java.time.Duration; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 보존 기한이 지난 멱등 레코드를 정리한다. + * + *

이 프로젝트는 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 { boolean existsById(String messageId); + + // 보존 기한이 지난 멱등 레코드 정리. idx_processed_messages_processed_at 이 이 조회를 + // 위해 처음부터 있었는데(다른 어떤 쿼리도 processed_at 을 안 쓴다) 정작 지우는 쪽이 없었다. + @Modifying(clearAutomatically = true) + @Query("DELETE FROM ProcessedMessage pm WHERE pm.processedAt < :threshold") + int deleteProcessedBefore(@Param("threshold") Instant threshold); } diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/VolatileRetentionTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/VolatileRetentionTest.java new file mode 100644 index 00000000..15bf3741 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/VolatileRetentionTest.java @@ -0,0 +1,74 @@ +package com.stackup.stackup.common.messaging; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.auth.domain.RefreshToken; +import com.stackup.stackup.auth.domain.RefreshTokenRepository; +import com.stackup.stackup.common.messaging.domain.ProcessedMessage; +import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; +import com.stackup.stackup.support.PostgresRepositoryTest; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import jakarta.persistence.EntityManager; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.util.ReflectionTestUtils; + +/** + * 휘발성 레코드의 보존 정리. 지우는 쪽이 없어 사실상 영구 보관이던 두 테이블이다. + * + *

멱등 레코드는 너무 일찍 지우면 안 된다 — 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 에서 늦게 재주입된 메시지가 +'처음 보는 메시지'가 되어 중복 처리된다(질문 중복·피드백 재생성).