diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/application/AiRequestLogSweeper.java b/backend/src/main/java/com/stackup/stackup/log/ai/application/AiRequestLogSweeper.java
new file mode 100644
index 00000000..5e4d2983
--- /dev/null
+++ b/backend/src/main/java/com/stackup/stackup/log/ai/application/AiRequestLogSweeper.java
@@ -0,0 +1,48 @@
+package com.stackup.stackup.log.ai.application;
+
+import com.stackup.stackup.log.ai.domain.AiRequestLogRepository;
+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;
+
+/**
+ * 보존 기한이 지난 AI 호출 로그를 정리한다.
+ *
+ *
LLM 호출마다 한 행이 쌓인다 — 피드백 한 번에 10여 건(패널·첫인상·직무적합·인성·답변별
+ * 코칭)에 TTS 는 문장마다다. 이 코드베이스에서 가장 빨리 자라는 테이블인데 지우는
+ * 쪽이 없었다(#224 의 processed_messages 와 같은 종류).
+ *
+ *
보존 기간을 넉넉히(기본 90일) 잡은 이유: 이 테이블의 가치는 비용 추이다.
+ * 멱등 레코드와 달리 지운다고 동작이 깨지지는 않지만, 짧게 잡으면 "지난 학기 대비
+ * 토큰이 얼마나 늘었나" 같은 질문에 답할 수 없게 된다. 한 학기를 덮는 값으로 시작하고
+ * 필요하면 환경변수로 조절한다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AiRequestLogSweeper {
+
+ private static final Logger log = LoggerFactory.getLogger(AiRequestLogSweeper.class);
+
+ private final AiRequestLogRepository logRepository;
+
+ @Value("${observability.ai-log-retention-days:90}")
+ private long retentionDays = 90;
+
+ @Transactional
+ @Scheduled(
+ fixedDelayString = "${observability.ai-log-sweep-interval-ms:86400000}",
+ initialDelayString = "${observability.ai-log-sweep-initial-delay-ms:600000}")
+ public void sweep() {
+ int deleted = logRepository.deleteCreatedBefore(
+ Instant.now().minus(Duration.ofDays(retentionDays)));
+ if (deleted > 0) {
+ log.info("ai request logs swept. deleted={}, retentionDays={}", deleted, retentionDays);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLogRepository.java b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLogRepository.java
index 5f34be59..86d88b95 100644
--- a/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLogRepository.java
+++ b/backend/src/main/java/com/stackup/stackup/log/ai/domain/AiRequestLogRepository.java
@@ -1,9 +1,19 @@
package com.stackup.stackup.log.ai.domain;
+import java.time.Instant;
import java.util.List;
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 AiRequestLogRepository extends JpaRepository {
List findTop100ByUser_IdOrderByCreatedAtDesc(Long userId);
+
+ // 보존 기한이 지난 호출 로그 정리. LLM 호출마다 한 행이라 이 코드베이스에서 가장 빨리
+ // 자라는 테이블인데(피드백 한 번에 10여 건 + TTS 문장마다) 지우는 쪽이 없었다.
+ @Modifying(clearAutomatically = true)
+ @Query("DELETE FROM AiRequestLog l WHERE l.createdAt < :threshold")
+ int deleteCreatedBefore(@Param("threshold") Instant threshold);
}
diff --git a/backend/src/main/resources/db/migration/V33__drop_redundant_user_consents_index.sql b/backend/src/main/resources/db/migration/V33__drop_redundant_user_consents_index.sql
new file mode 100644
index 00000000..553fe290
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V33__drop_redundant_user_consents_index.sql
@@ -0,0 +1,5 @@
+-- idx_user_consents_user_type (user_id, consent_type) 가 이미 있어서 user_id 단독 인덱스는
+-- 그 leftmost prefix 로 커버된다. 실제 쿼리 두 개(findByUser_IdOrderByIdDesc,
+-- findFirstByUser_IdAndConsentType...) 모두 복합 인덱스로 처리된다.
+-- 중복 인덱스는 조회에 보탬이 없고 쓰기마다 갱신 비용만 더한다.
+DROP INDEX IF EXISTS idx_user_consents_user_id;
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
index 15bf3741..c6318c55 100644
--- a/backend/src/test/java/com/stackup/stackup/common/messaging/VolatileRetentionTest.java
+++ b/backend/src/test/java/com/stackup/stackup/common/messaging/VolatileRetentionTest.java
@@ -6,6 +6,9 @@
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.log.ai.domain.AiRequestLog;
+import com.stackup.stackup.log.ai.domain.AiRequestLogRepository;
+import com.stackup.stackup.log.ai.domain.AiRequestStatus;
import com.stackup.stackup.support.PostgresRepositoryTest;
import com.stackup.stackup.user.domain.User;
import com.stackup.stackup.user.domain.UserRepository;
@@ -28,6 +31,7 @@ class VolatileRetentionTest {
@Autowired ProcessedMessageRepository processedMessageRepository;
@Autowired RefreshTokenRepository refreshTokenRepository;
+ @Autowired AiRequestLogRepository aiRequestLogRepository;
@Autowired UserRepository userRepository;
@Autowired EntityManager em;
@@ -66,6 +70,39 @@ void deletesOnlyExpiredRefreshTokens() {
assertThat(refreshTokenRepository.findById(live.getId())).isPresent();
}
+ // LLM 호출마다 한 행이라 가장 빨리 자라는 테이블. 보존 기한이 지난 것만 지운다.
+ @Test
+ void deletesOnlyAiRequestLogsOlderThanThreshold() {
+ Instant now = Instant.now();
+ AiRequestLog old = aiRequestLogRepository.save(aiLog("generate.questions"));
+ AiRequestLog recent = aiRequestLogRepository.save(aiLog("generate.followup"));
+ em.flush();
+ // created_at 은 @CreationTimestamp + updatable=false 라 엔티티를 고쳐도 DB 에 안 간다.
+ // 보존 로직을 시험하려면 실제 컬럼 값을 과거로 밀어야 해서 네이티브 UPDATE 를 쓴다.
+ backdate(old.getId(), now.minus(Duration.ofDays(120)));
+ backdate(recent.getId(), now.minus(Duration.ofDays(10)));
+ em.clear();
+
+ int deleted = aiRequestLogRepository.deleteCreatedBefore(now.minus(Duration.ofDays(90)));
+
+ assertThat(deleted).isEqualTo(1);
+ assertThat(aiRequestLogRepository.findById(old.getId())).isEmpty();
+ // 보존 기간 안의 로그는 남아야 한다 — 비용 추이를 보는 근거다.
+ assertThat(aiRequestLogRepository.findById(recent.getId())).isPresent();
+ }
+
+ private void backdate(Long id, Instant createdAt) {
+ em.createNativeQuery("UPDATE ai_request_logs SET created_at = ?1 WHERE id = ?2")
+ .setParameter(1, createdAt)
+ .setParameter(2, id)
+ .executeUpdate();
+ }
+
+ private AiRequestLog aiLog(String requestType) {
+ return AiRequestLog.of(null, null, requestType, "gemini-3.1-flash",
+ 100, 200, 1200, AiRequestStatus.SUCCESS, null);
+ }
+
private ProcessedMessage processedMessage(String id, Instant processedAt) {
ProcessedMessage pm = ProcessedMessage.of(id, "test-consumer");
ReflectionTestUtils.setField(pm, "processedAt", processedAt);
diff --git a/docs/environment.md b/docs/environment.md
index 15331c73..07da295c 100644
--- a/docs/environment.md
+++ b/docs/environment.md
@@ -304,6 +304,8 @@ STORAGE_ORPHAN_SWEEP_INITIAL_DELAY_MS=60000 # 부팅 후 첫 실행 지연
MESSAGING_PROCESSED_MESSAGE_RETENTION_DAYS=30 # 멱등 레코드 보존 기간
MESSAGING_VOLATILE_SWEEP_INTERVAL_MS=86400000 # 멱등 레코드 정리 주기 (기본 24시간)
AUTH_REFRESH_TOKEN_SWEEP_INTERVAL_MS=86400000 # 만료 refresh token 정리 주기
+OBSERVABILITY_AI_LOG_RETENTION_DAYS=90 # AI 호출 로그 보존 기간 (비용 추이 근거)
+OBSERVABILITY_AI_LOG_SWEEP_INTERVAL_MS=86400000 # AI 호출 로그 정리 주기
```
보존 기간은 **재전달 창보다 길어야 한다** — 너무 짧으면 DLQ 에서 늦게 재주입된 메시지가
diff --git a/docs/observability.md b/docs/observability.md
index ed94a7bd..7a5d0473 100644
--- a/docs/observability.md
+++ b/docs/observability.md
@@ -237,6 +237,30 @@ docker logs stackup-ai | grep '9f4e5b'
---
+## 8.1 로그 테이블 보존
+
+DB 에 쌓이는 로그·휘발성 테이블은 정리 주기가 있다. 없으면 "short-lived 레코드"라는 전제가
+코드로는 지켜지지 않는다(루트 CLAUDE.md).
+
+| 테이블 | 보존 | 정리 |
+|---|---|---|
+| `ai_request_logs` | 90일 (`OBSERVABILITY_AI_LOG_RETENTION_DAYS`) | `AiRequestLogSweeper` |
+| `processed_messages` | 30일 (`MESSAGING_PROCESSED_MESSAGE_RETENTION_DAYS`) | `ProcessedMessageSweeper` |
+| `refresh_tokens` | 만료 즉시 | `ExpiredRefreshTokenSweeper` |
+| `oauth_states` | 만료 즉시 | 발급 시 self-cleaning |
+
+보존 기간의 성격이 테이블마다 다르다.
+
+- `ai_request_logs` 는 **비용 추이**가 가치다 — 짧게 잡으면 "지난 학기 대비 토큰이 얼마나
+ 늘었나" 에 답할 수 없다. 지운다고 동작이 깨지지는 않는다.
+- `processed_messages` 는 **멱등성 보장 기간**이다 — 너무 짧으면 DLQ 에서 늦게 재주입된
+ 메시지가 중복 처리된다(질문 중복·피드백 재생성). 공간 문제가 아니다.
+
+> **미구현**: `activity_logs`(US-31, 사용자 행동 로그)는 테이블·엔티티·리포지토리만 있고
+> 읽기도 쓰기도 없다. 구현 시 여기에 보존 정책을 함께 정한다.
+
+---
+
## 9. PII (Personal Identifiable Information) 마스킹
`backend/src/main/java/com/stackup/stackup/common/log/PiiMasker.java` 가 마스킹 함수를 제공한다.