Skip to content

Commit 92ffefe

Browse files
committed
feat(backend): 휘발성 레코드 보존 정리 (멱등 레코드·만료 refresh token)
이 프로젝트는 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 는 발급 때마다 만료분을 지워 스스로 자정하므로 대상이 아니다.
1 parent 15082d2 commit 92ffefe

6 files changed

Lines changed: 197 additions & 0 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.stackup.stackup.auth.application;
2+
3+
import com.stackup.stackup.auth.domain.RefreshTokenRepository;
4+
import java.time.Instant;
5+
import lombok.RequiredArgsConstructor;
6+
import org.slf4j.Logger;
7+
import org.slf4j.LoggerFactory;
8+
import org.springframework.scheduling.annotation.Scheduled;
9+
import org.springframework.stereotype.Component;
10+
import org.springframework.transaction.annotation.Transactional;
11+
12+
/**
13+
* 만료된 refresh token 행을 정리한다.
14+
*
15+
* <p>로그인마다 한 행이 쌓이는데 지우는 쪽이 없어 사실상 영구 보관이었다. 만료된 토큰은
16+
* 검증에서 항상 걸러지므로 남겨둬도 무효다 — 보관해서 얻는 것이 없다.
17+
*
18+
* <p>취소(revoked)된 토큰은 만료 전이라면 남긴다. 탈퇴·로그아웃 시 revoke 한 기록이
19+
* 만료 시각까지는 "이 토큰은 무효"라는 판단 근거로 남아 있어야 한다.
20+
*/
21+
@Component
22+
@RequiredArgsConstructor
23+
public class ExpiredRefreshTokenSweeper {
24+
25+
private static final Logger log = LoggerFactory.getLogger(ExpiredRefreshTokenSweeper.class);
26+
27+
private final RefreshTokenRepository refreshTokenRepository;
28+
29+
@Transactional
30+
@Scheduled(
31+
fixedDelayString = "${auth.refresh-token-sweep-interval-ms:86400000}",
32+
initialDelayString = "${auth.refresh-token-sweep-initial-delay-ms:300000}")
33+
public void sweep() {
34+
int deleted = refreshTokenRepository.deleteExpiredBefore(Instant.now());
35+
if (deleted > 0) {
36+
log.info("expired refresh tokens swept. deleted={}", deleted);
37+
}
38+
}
39+
}

backend/src/main/java/com/stackup/stackup/auth/domain/RefreshTokenRepository.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.stackup.stackup.auth.domain;
22

3+
import java.time.Instant;
34
import java.util.Optional;
45
import org.springframework.data.jpa.repository.JpaRepository;
56
import org.springframework.data.jpa.repository.Modifying;
@@ -10,6 +11,12 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long
1011

1112
Optional<RefreshToken> findByTokenHash(String tokenHash);
1213

14+
// 만료된 refresh token 은 더 이상 어떤 판단에도 쓰이지 않는다 — 검증은 항상
15+
// 만료 시각을 확인하므로 남겨둬도 무효이고, 쌓이기만 한다.
16+
@Modifying(clearAutomatically = true)
17+
@Query("DELETE FROM RefreshToken rt WHERE rt.expiresAt < :now")
18+
int deleteExpiredBefore(@Param("now") Instant now);
19+
1320
void deleteByUser_Id(Long userId);
1421

1522
@Modifying(clearAutomatically = true)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.stackup.stackup.common.messaging.application;
2+
3+
import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository;
4+
import java.time.Duration;
5+
import java.time.Instant;
6+
import lombok.RequiredArgsConstructor;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.beans.factory.annotation.Value;
10+
import org.springframework.scheduling.annotation.Scheduled;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
/**
15+
* 보존 기한이 지난 멱등 레코드를 정리한다.
16+
*
17+
* <p>이 프로젝트는 Redis 를 쓰지 않고 "휘발성 데이터는 PostgreSQL 의 short-lived 레코드"로
18+
* 처리하기로 했다(루트 CLAUDE.md). 그런데 아래 둘은 <b>지우는 쪽이 없어 사실상 영구 보관</b>
19+
* 이었다 — short-lived 라는 전제가 코드로는 지켜지지 않았다.
20+
*
21+
* <p>processed_messages 는 AI 콜백마다 한 행씩 쌓인다(질문·꼬리질문·피드백·분석·음성·TTS).
22+
* {@code idx_processed_messages_processed_at} 인덱스가 처음부터 있는데 그 컬럼으로 조회하는
23+
* 코드가 하나도 없었다 — 보존 정리를 전제로 만든 인덱스인데 정작 정리가 없었던 셈이다.
24+
*
25+
* <p>멱등 레코드의 보존 기간은 <b>재전달 창보다 길어야</b> 한다. 너무 일찍 지우면 DLQ 에서
26+
* 늦게 재주입된 메시지가 "처음 보는 메시지"가 되어 다시 처리된다(질문이 두 번 붙거나
27+
* 피드백이 다시 생성될 수 있다). 기본 30일은 그 창보다 충분히 길다.
28+
*
29+
* <p>oauth_states 는 여기서 다루지 않는다 — 발급할 때마다 만료분을 지워 스스로 자정한다
30+
* ({@code OAuthStateService.issueStateWithPkce}).
31+
*/
32+
@Component
33+
@RequiredArgsConstructor
34+
public class ProcessedMessageSweeper {
35+
36+
private static final Logger log = LoggerFactory.getLogger(ProcessedMessageSweeper.class);
37+
38+
private final ProcessedMessageRepository processedMessageRepository;
39+
40+
@Value("${messaging.processed-message-retention-days:30}")
41+
private long processedRetentionDays = 30;
42+
43+
@Transactional
44+
@Scheduled(
45+
fixedDelayString = "${messaging.volatile-sweep-interval-ms:86400000}",
46+
initialDelayString = "${messaging.volatile-sweep-initial-delay-ms:300000}")
47+
public void sweep() {
48+
Instant now = Instant.now();
49+
int processed = processedMessageRepository.deleteProcessedBefore(
50+
now.minus(Duration.ofDays(processedRetentionDays)));
51+
if (processed > 0) {
52+
log.info("processed message sweep done. deleted={}, retentionDays={}",
53+
processed, processedRetentionDays);
54+
}
55+
}
56+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
package com.stackup.stackup.common.messaging.domain;
22

3+
import java.time.Instant;
34
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.data.jpa.repository.Modifying;
6+
import org.springframework.data.jpa.repository.Query;
7+
import org.springframework.data.repository.query.Param;
48

59
public interface ProcessedMessageRepository extends JpaRepository<ProcessedMessage, String> {
610

711
boolean existsById(String messageId);
12+
13+
// 보존 기한이 지난 멱등 레코드 정리. idx_processed_messages_processed_at 이 이 조회를
14+
// 위해 처음부터 있었는데(다른 어떤 쿼리도 processed_at 을 안 쓴다) 정작 지우는 쪽이 없었다.
15+
@Modifying(clearAutomatically = true)
16+
@Query("DELETE FROM ProcessedMessage pm WHERE pm.processedAt < :threshold")
17+
int deleteProcessedBefore(@Param("threshold") Instant threshold);
818
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.stackup.stackup.common.messaging;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.stackup.stackup.auth.domain.RefreshToken;
6+
import com.stackup.stackup.auth.domain.RefreshTokenRepository;
7+
import com.stackup.stackup.common.messaging.domain.ProcessedMessage;
8+
import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository;
9+
import com.stackup.stackup.support.PostgresRepositoryTest;
10+
import com.stackup.stackup.user.domain.User;
11+
import com.stackup.stackup.user.domain.UserRepository;
12+
import jakarta.persistence.EntityManager;
13+
import java.time.Duration;
14+
import java.time.Instant;
15+
import org.junit.jupiter.api.Test;
16+
import org.springframework.beans.factory.annotation.Autowired;
17+
import org.springframework.test.util.ReflectionTestUtils;
18+
19+
/**
20+
* 휘발성 레코드의 보존 정리. 지우는 쪽이 없어 사실상 영구 보관이던 두 테이블이다.
21+
*
22+
* <p>멱등 레코드는 <b>너무 일찍 지우면 안 된다</b> — DLQ 에서 늦게 재주입된 메시지가
23+
* "처음 보는 메시지"가 되어 다시 처리되면 질문이 두 번 붙거나 피드백이 재생성된다.
24+
* 그래서 "기한이 지난 것만" 지우는지를 양쪽으로 확인한다.
25+
*/
26+
@PostgresRepositoryTest
27+
class VolatileRetentionTest {
28+
29+
@Autowired ProcessedMessageRepository processedMessageRepository;
30+
@Autowired RefreshTokenRepository refreshTokenRepository;
31+
@Autowired UserRepository userRepository;
32+
@Autowired EntityManager em;
33+
34+
@Test
35+
void deletesOnlyProcessedMessagesOlderThanThreshold() {
36+
Instant now = Instant.now();
37+
ProcessedMessage old = processedMessage("m-old", now.minus(Duration.ofDays(40)));
38+
ProcessedMessage recent = processedMessage("m-recent", now.minus(Duration.ofDays(1)));
39+
processedMessageRepository.save(old);
40+
processedMessageRepository.save(recent);
41+
em.flush();
42+
43+
int deleted = processedMessageRepository.deleteProcessedBefore(now.minus(Duration.ofDays(30)));
44+
45+
assertThat(deleted).isEqualTo(1);
46+
assertThat(processedMessageRepository.existsById("m-old")).isFalse();
47+
// 최근 것은 남아야 한다 — 지우면 재전달된 메시지가 중복 처리된다.
48+
assertThat(processedMessageRepository.existsById("m-recent")).isTrue();
49+
}
50+
51+
@Test
52+
void deletesOnlyExpiredRefreshTokens() {
53+
User user = userRepository.save(User.createGithubUser(95001L, "token-user", null, null, "t"));
54+
Instant now = Instant.now();
55+
RefreshToken expired = refreshTokenRepository.save(
56+
RefreshToken.issue(user, "hash-expired", null, now.minus(Duration.ofDays(1))));
57+
RefreshToken live = refreshTokenRepository.save(
58+
RefreshToken.issue(user, "hash-live", null, now.plus(Duration.ofDays(7))));
59+
em.flush();
60+
61+
int deleted = refreshTokenRepository.deleteExpiredBefore(now);
62+
63+
assertThat(deleted).isEqualTo(1);
64+
assertThat(refreshTokenRepository.findById(expired.getId())).isEmpty();
65+
// 아직 유효한 토큰은 건드리지 않는다 — 지우면 로그인 세션이 끊긴다.
66+
assertThat(refreshTokenRepository.findById(live.getId())).isPresent();
67+
}
68+
69+
private ProcessedMessage processedMessage(String id, Instant processedAt) {
70+
ProcessedMessage pm = ProcessedMessage.of(id, "test-consumer");
71+
ReflectionTestUtils.setField(pm, "processedAt", processedAt);
72+
return pm;
73+
}
74+
}

docs/environment.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,14 @@ STORAGE_ORPHAN_SWEEP_INITIAL_DELAY_MS=60000 # 부팅 후 첫 실행 지연
297297

298298
`storage.orphan-sweep-interval-ms` / `storage.orphan-sweep-initial-delay-ms` 로 주입된다.
299299
삭제된 자료의 S3 객체를 회수한다 (`docs/security.md §5.1.1`).
300+
301+
### 휘발성 레코드 보존 (Core)
302+
303+
```
304+
MESSAGING_PROCESSED_MESSAGE_RETENTION_DAYS=30 # 멱등 레코드 보존 기간
305+
MESSAGING_VOLATILE_SWEEP_INTERVAL_MS=86400000 # 멱등 레코드 정리 주기 (기본 24시간)
306+
AUTH_REFRESH_TOKEN_SWEEP_INTERVAL_MS=86400000 # 만료 refresh token 정리 주기
307+
```
308+
309+
보존 기간은 **재전달 창보다 길어야 한다** — 너무 짧으면 DLQ 에서 늦게 재주입된 메시지가
310+
'처음 보는 메시지'가 되어 중복 처리된다(질문 중복·피드백 재생성).

0 commit comments

Comments
 (0)