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
Original file line number Diff line number Diff line change
@@ -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 행을 정리한다.
*
* <p>로그인마다 한 행이 쌓이는데 지우는 쪽이 없어 사실상 영구 보관이었다. 만료된 토큰은
* 검증에서 항상 걸러지므로 남겨둬도 무효다 — 보관해서 얻는 것이 없다.
*
* <p>취소(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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,6 +11,12 @@ public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long

Optional<RefreshToken> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 보존 기한이 지난 멱등 레코드를 정리한다.
*
* <p>이 프로젝트는 Redis 를 쓰지 않고 "휘발성 데이터는 PostgreSQL 의 short-lived 레코드"로
* 처리하기로 했다(루트 CLAUDE.md). 그런데 아래 둘은 <b>지우는 쪽이 없어 사실상 영구 보관</b>
* 이었다 — short-lived 라는 전제가 코드로는 지켜지지 않았다.
*
* <p>processed_messages 는 AI 콜백마다 한 행씩 쌓인다(질문·꼬리질문·피드백·분석·음성·TTS).
* {@code idx_processed_messages_processed_at} 인덱스가 처음부터 있는데 그 컬럼으로 조회하는
* 코드가 하나도 없었다 — 보존 정리를 전제로 만든 인덱스인데 정작 정리가 없었던 셈이다.
*
* <p>멱등 레코드의 보존 기간은 <b>재전달 창보다 길어야</b> 한다. 너무 일찍 지우면 DLQ 에서
* 늦게 재주입된 메시지가 "처음 보는 메시지"가 되어 다시 처리된다(질문이 두 번 붙거나
* 피드백이 다시 생성될 수 있다). 기본 30일은 그 창보다 충분히 길다.
*
* <p>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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ProcessedMessage, String> {

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);
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 휘발성 레코드의 보존 정리. 지우는 쪽이 없어 사실상 영구 보관이던 두 테이블이다.
*
* <p>멱등 레코드는 <b>너무 일찍 지우면 안 된다</b> — 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;
}
}
11 changes: 11 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 에서 늦게 재주입된 메시지가
'처음 보는 메시지'가 되어 중복 처리된다(질문 중복·피드백 재생성).
Loading