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,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 호출 로그를 정리한다.
*
* <p>LLM 호출마다 한 행이 쌓인다 — 피드백 한 번에 10여 건(패널·첫인상·직무적합·인성·답변별
* 코칭)에 TTS 는 문장마다다. <b>이 코드베이스에서 가장 빨리 자라는 테이블</b>인데 지우는
* 쪽이 없었다(#224 의 processed_messages 와 같은 종류).
*
* <p>보존 기간을 넉넉히(기본 90일) 잡은 이유: 이 테이블의 가치는 <b>비용 추이</b>다.
* 멱등 레코드와 달리 지운다고 동작이 깨지지는 않지만, 짧게 잡으면 "지난 학기 대비
* 토큰이 얼마나 늘었나" 같은 질문에 답할 수 없게 된다. 한 학기를 덮는 값으로 시작하고
* 필요하면 환경변수로 조절한다.
*/
@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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<AiRequestLog, Long> {

List<AiRequestLog> 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);
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +31,7 @@ class VolatileRetentionTest {

@Autowired ProcessedMessageRepository processedMessageRepository;
@Autowired RefreshTokenRepository refreshTokenRepository;
@Autowired AiRequestLogRepository aiRequestLogRepository;
@Autowired UserRepository userRepository;
@Autowired EntityManager em;

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 에서 늦게 재주입된 메시지가
Expand Down
24 changes: 24 additions & 0 deletions docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 가 마스킹 함수를 제공한다.
Expand Down
Loading