From ba5ccef70695ea913f6625fefd83c55e673eac9f Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 24 Aug 2026 13:26:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(backend):=20AI=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EB=B3=B4=EC=A1=B4=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?+=20=EC=A4=91=EB=B3=B5=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #224 에서 쓴 렌즈("인덱스는 있는데 그 컬럼을 쓰는 코드가 없다")로 전체 인덱스를 훑은 결과다. ai_request_logs 는 LLM 호출마다 한 행이라 이 코드베이스에서 가장 빨리 자라는 테이블이다 — 피드백 한 번에 10여 건(패널·첫인상·직무적합·인성·답변별 코칭), TTS 는 문장마다. created_at 계열 인덱스가 3개나 있는데 조회하는 코드도, 정리하는 코드도 없었다. 보존 90일로 넉넉히 잡았다. 이 테이블의 가치는 비용 추이라 멱등 레코드와 성격이 다르다 — 지운다고 동작이 깨지지는 않지만 짧게 잡으면 학기 단위 비교가 불가능해진다. 함께: idx_user_consents_user_id 는 idx_user_consents_user_type (user_id, consent_type) 의 leftmost prefix 로 완전히 커버된다. 실제 쿼리 두 개 모두 복합 인덱스로 처리되므로 조회에 보탬 없이 쓰기 비용만 더한다. 훑는 김에 확인한 미구현: activity_logs(US-31)는 테이블·엔티티·리포지토리만 있고 읽기도 쓰기도 없다. 인덱스 3개도 함께 놀고 있다. 삭제 대상이 아니라 계획된 기능이므로 docs/observability.md 에 미구현으로 명시만 했다. --- .../ai/application/AiRequestLogSweeper.java | 48 +++++++++++++++++++ .../log/ai/domain/AiRequestLogRepository.java | 10 ++++ ...33__drop_redundant_user_consents_index.sql | 5 ++ .../messaging/VolatileRetentionTest.java | 27 +++++++++++ docs/environment.md | 2 + docs/observability.md | 24 ++++++++++ 6 files changed, 116 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/log/ai/application/AiRequestLogSweeper.java create mode 100644 backend/src/main/resources/db/migration/V33__drop_redundant_user_consents_index.sql 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..c288eace 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,29 @@ 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")); + ReflectionTestUtils.setField(old, "createdAt", now.minus(Duration.ofDays(120))); + ReflectionTestUtils.setField(recent, "createdAt", now.minus(Duration.ofDays(10))); + em.flush(); + + 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 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` 가 마스킹 함수를 제공한다. From 13208c4268f432b9f210b016f687cc0cf2133166 Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 24 Aug 2026 13:29:40 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test:=20AI=20=ED=98=B8=EC=B6=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EB=B3=B4=EC=A1=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20created=5Fat=20=EB=B0=B1=EB=8D=B0=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=84=A4=EC=9D=B4=ED=8B=B0=EB=B8=8C=20UPDATE=20?= =?UTF-8?q?=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit created_at 은 @CreationTimestamp + updatable=false 라 엔티티 필드를 리플렉션으로 고쳐도 DB 에 반영되지 않는다(플러시 대상에서 제외). 두 행 모두 '방금'으로 남아 보존 대상이 하나도 안 잡혔다. 실제 컬럼 값을 과거로 밀어야 보존 로직을 시험할 수 있다. --- .../common/messaging/VolatileRetentionTest.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 c288eace..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 @@ -76,9 +76,12 @@ void deletesOnlyAiRequestLogsOlderThanThreshold() { Instant now = Instant.now(); AiRequestLog old = aiRequestLogRepository.save(aiLog("generate.questions")); AiRequestLog recent = aiRequestLogRepository.save(aiLog("generate.followup")); - ReflectionTestUtils.setField(old, "createdAt", now.minus(Duration.ofDays(120))); - ReflectionTestUtils.setField(recent, "createdAt", now.minus(Duration.ofDays(10))); 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))); @@ -88,6 +91,13 @@ void deletesOnlyAiRequestLogsOlderThanThreshold() { 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);