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,97 @@
package com.stackup.stackup.document.application;

import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.resume.domain.Resume;
import com.stackup.stackup.resume.domain.ResumeRepository;
import java.util.List;
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;

/**
* 삭제된 자료의 스토리지 객체를 회수한다.
*
* <p>두 가지를 같은 경로로 처리한다.
*
* <ol>
* <li><b>과거 삭제분</b> — #219 이전에 지운 자료는 행만 soft delete 됐고 S3 의 원본 PDF·
* 분석 마크다운이 그대로 남아 있다. 앞으로 삭제 이벤트가 다시 발생할 일이 없으니
* 백필하지 않으면 영원히 남는다(V31 의 탈퇴자 토큰과 같은 종류의 누락).</li>
* <li><b>실패분</b> — #219 의 파기는 AFTER_COMMIT 이고 <b>실패해도 사용자 요청을 실패시키지
* 않는다</b>(그 시점엔 이미 커밋됐고 되돌릴 수도 없다). 그래서 스토리지가 잠깐 죽어 있으면
* 객체가 로그 한 줄만 남기고 새어 나간다. 그 구멍을 여기서 닫는다.</li>
* </ol>
*
* <p><b>파괴적 작업이다.</b> 조회는 반드시 삭제된 행만 잡아야 한다 — 조건이 하나라도 어긋나면
* 살아있는 사용자의 이력서를 지운다. 그래서 리포지토리 쿼리에 {@code DeletedTrue} 를 이름으로
* 박아 두고(파생 쿼리라 조건이 시그니처에 드러난다) 테스트로 고정한다.
*
* <p>회수에 성공하면 경로를 비운다({@code markContentPurged}). 객체가 없는데 키만 남으면
* "아직 회수 안 됨"과 구분되지 않아 매 주기마다 다시 지우려 든다.
*/
@Component
@RequiredArgsConstructor
public class OrphanedObjectSweeper {

private static final Logger log = LoggerFactory.getLogger(OrphanedObjectSweeper.class);

private final ResumeRepository resumeRepository;
private final AnalyzedDocumentRepository documentRepository;
private final ObjectStorageClient storage;

@Transactional
@Scheduled(
fixedDelayString = "${storage.orphan-sweep-interval-ms:900000}",
initialDelayString = "${storage.orphan-sweep-initial-delay-ms:60000}")
public void sweep() {
int resumes = sweepResumes();
int documents = sweepDocuments();
if (resumes + documents > 0) {
log.info("orphaned object sweep done. resumeFiles={}, analyzedDocs={}", resumes, documents);
}
}

private int sweepResumes() {
List<Resume> targets = resumeRepository.findTop100ByDeletedTrueAndFilePathIsNotNull();
int purged = 0;
for (Resume resume : targets) {
if (purge(resume.getFilePath())) {
resume.markContentPurged();
purged++;
}
}
return purged;
}

private int sweepDocuments() {
List<AnalyzedDocument> targets = documentRepository.findTop100ByDeletedTrueAndDocumentPathIsNotNull();
int purged = 0;
for (AnalyzedDocument doc : targets) {
if (purge(doc.getDocumentPath())) {
doc.markContentPurged();
purged++;
}
}
return purged;
}

/**
* 객체 하나를 지운다. 실패하면 경로를 비우지 않아 다음 주기에 다시 시도한다 —
* 여기서 예외를 전파하면 같은 배치의 나머지 회수까지 롤백된다.
*/
private boolean purge(String key) {
try {
storage.delete(key);
log.info("orphaned storage object purged. key={}", key);
return true;
} catch (Exception e) {
log.warn("orphaned storage object purge failed — 다음 주기에 재시도. key={}", key, e);
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,4 +124,9 @@ public void markFailed(String errorCode, String errorMessage) {
public void markDeleted() {
this.deleted = true;
}

/** 분석 마크다운이 파기됐음을 표시한다(경로를 비운다). {@code Resume#markContentPurged} 와 같은 이유. */
public void markContentPurged() {
this.documentPath = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ Optional<AnalyzedDocument> findByIdAndResume_User_IdOrIdAndRepository_User_Id(
""")
List<Long> findActiveIdsByOwner(@Param("userId") Long userId);

// 분석 마크다운이 아직 남아 있는 **삭제된** 문서. OrphanedObjectSweeper 전용 —
// 위와 같은 이유로 deleted=true 조건이 안전의 핵심이다.
List<AnalyzedDocument> findTop100ByDeletedTrueAndDocumentPathIsNotNull();

@Query("""
SELECT d FROM AnalyzedDocument d
WHERE d.coverLetter.id = :coverLetterId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,14 @@ public void markFailed() {
public void markDeleted() {
this.deleted = true;
}

/**
* 스토리지 원본이 파기됐음을 표시한다(경로를 비운다).
*
* <p>객체가 없는데 키만 들고 있으면 "아직 회수 안 됨"과 구분되지 않아 스위퍼가 매번
* 다시 지우려 든다. 삭제된 행은 타입별 locator CHECK 에서 제외된다(V32).
*/
public void markContentPurged() {
this.filePath = null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ public interface ResumeRepository extends JpaRepository<Resume, Long> {
Optional<Resume> findByIdAndUser_IdAndDeletedFalse(Long id, Long userId);

boolean existsByUser_IdAndSourceUrlAndDeletedFalse(Long userId, String sourceUrl);

// 스토리지 객체가 아직 남아 있는 **삭제된** 이력서. OrphanedObjectSweeper 전용이고
// 객체를 실제로 지우는 데 쓰이므로 deleted=true 조건을 절대 빼면 안 된다.
List<Resume> findTop100ByDeletedTrueAndFilePathIsNotNull();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- #219 는 "앞으로의 삭제"만 다뤘다. 그 전에 지운 자료의 내용물은 그대로 남아 있다 —
-- S3 의 원본 PDF·분석 마크다운과 document_embeddings 의 청크 원문.
-- V31(탈퇴자 GitHub 토큰)과 같은 종류의 빠진 백필이다.
--
-- 임베딩은 DB 안에서 끝나므로 여기서 정리한다. S3 객체는 애플리케이션이 지워야 해서
-- OrphanedObjectSweeper 가 맡는다 — 그쪽은 #219 의 AFTER_COMMIT 파기가 실패했을 때
-- (설계상 로그만 남기고 넘어간다) 남는 객체까지 같은 경로로 회수한다.
DELETE FROM document_embeddings
WHERE document_id IN (SELECT id FROM analyzed_documents WHERE is_deleted = TRUE);

-- 스위퍼가 S3 객체를 지운 뒤 file_path 를 NULL 로 비워 "회수 완료"를 표시하려면
-- 타입별 locator CHECK 이 삭제된 행을 예외로 둬야 한다. users 의 provider 식별자
-- CHECK(V28)·유니크 인덱스(V3·V22)가 이미 쓰는 규약과 같다 — 제약의 의도는
-- "살아있는 행은 갖춰야 한다" 이지 "지운 행도 영원히 들고 있어라" 가 아니다.
ALTER TABLE resumes DROP CONSTRAINT IF EXISTS chk_resumes_locator_by_type;
ALTER TABLE resumes ADD CONSTRAINT chk_resumes_locator_by_type
CHECK (
is_deleted = TRUE
OR (file_type = 'PDF' AND file_path IS NOT NULL)
OR (file_type = 'WEB' AND source_url IS NOT NULL)
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.stackup.stackup.document.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.resume.domain.Resume;
import com.stackup.stackup.resume.domain.ResumeFileType;
import com.stackup.stackup.resume.domain.ResumeRepository;
import com.stackup.stackup.user.domain.User;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;

/**
* 스위퍼는 S3 객체를 실제로 지운다 — 대상 선정이 틀리면 살아있는 사용자의 이력서가 사라진다.
* 그래서 "무엇을 지우는가"보다 "무엇을 건드리지 않는가"를 더 촘촘히 고정한다.
*/
@ExtendWith(MockitoExtension.class)
class OrphanedObjectSweeperTest {

@Mock ResumeRepository resumeRepository;
@Mock AnalyzedDocumentRepository documentRepository;
@Mock ObjectStorageClient storage;
@InjectMocks OrphanedObjectSweeper sweeper;

@Test
void purgesStorageObjectsOfDeletedMaterialsAndClearsPaths() {
Resume resume = deletedResume("resumes/raw/1/a.pdf");
AnalyzedDocument doc = deletedDocument("analyzed/resume/1/summary.md");
when(resumeRepository.findTop100ByDeletedTrueAndFilePathIsNotNull()).thenReturn(List.of(resume));
when(documentRepository.findTop100ByDeletedTrueAndDocumentPathIsNotNull()).thenReturn(List.of(doc));

sweeper.sweep();

verify(storage).delete("resumes/raw/1/a.pdf");
verify(storage).delete("analyzed/resume/1/summary.md");
// 경로를 비워 "회수 완료"를 표시한다 — 안 그러면 매 주기마다 같은 키를 다시 지우려 든다.
assertThat(resume.getFilePath()).isNull();
assertThat(doc.getDocumentPath()).isNull();
}

// 삭제 실패 시 경로를 남겨 다음 주기에 재시도한다. 지우지도 못했는데 완료 표시를 하면
// 객체가 영구히 고아로 남는다.
@Test
void keepsPathWhenStorageDeleteFails() {
Resume resume = deletedResume("resumes/raw/1/a.pdf");
when(resumeRepository.findTop100ByDeletedTrueAndFilePathIsNotNull()).thenReturn(List.of(resume));
when(documentRepository.findTop100ByDeletedTrueAndDocumentPathIsNotNull()).thenReturn(List.of());
doThrow(new RuntimeException("storage down")).when(storage).delete(anyString());

sweeper.sweep();

assertThat(resume.getFilePath()).isEqualTo("resumes/raw/1/a.pdf");
}

// 한 건이 실패해도 나머지는 계속 회수해야 한다 — 예외를 전파하면 같은 배치가 통째로 막힌다.
@Test
void continuesAfterIndividualFailure() {
Resume failing = deletedResume("resumes/raw/1/bad.pdf");
AnalyzedDocument ok = deletedDocument("analyzed/resume/1/summary.md");
when(resumeRepository.findTop100ByDeletedTrueAndFilePathIsNotNull()).thenReturn(List.of(failing));
when(documentRepository.findTop100ByDeletedTrueAndDocumentPathIsNotNull()).thenReturn(List.of(ok));
doThrow(new RuntimeException("boom")).when(storage).delete("resumes/raw/1/bad.pdf");

sweeper.sweep();

assertThat(failing.getFilePath()).isNotNull();
assertThat(ok.getDocumentPath()).isNull();
}

@Test
void doesNothingWhenNoOrphansRemain() {
when(resumeRepository.findTop100ByDeletedTrueAndFilePathIsNotNull()).thenReturn(List.of());
when(documentRepository.findTop100ByDeletedTrueAndDocumentPathIsNotNull()).thenReturn(List.of());

sweeper.sweep();

verify(storage, never()).delete(anyString());
}

private Resume deletedResume(String key) {
User user = User.createGithubUser(1L, "u", null, null, "t");
ReflectionTestUtils.setField(user, "id", 1L);
Resume resume = Resume.create(user, "a.pdf", key, ResumeFileType.PDF, 10L);
resume.markDeleted();
return resume;
}

private AnalyzedDocument deletedDocument(String path) {
User user = User.createGithubUser(1L, "u", null, null, "t");
ReflectionTestUtils.setField(user, "id", 1L);
Resume resume = Resume.create(user, "a.pdf", "resumes/raw/1/a.pdf", ResumeFileType.PDF, 10L);
AnalyzedDocument doc = AnalyzedDocument.forResume(resume);
ReflectionTestUtils.setField(doc, "documentPath", path);
doc.markDeleted();
return doc;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.stackup.stackup.document.infrastructure;

import static org.assertj.core.api.Assertions.assertThat;

import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.resume.domain.Resume;
import com.stackup.stackup.resume.domain.ResumeFileType;
import com.stackup.stackup.resume.domain.ResumeRepository;
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 org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

/**
* OrphanedObjectSweeper 의 대상 조회는 S3 객체를 실제로 지우는 데 쓰인다 —
* 살아있는 행이 하나라도 섞이면 사용자의 이력서 원본이 사라진다.
*
* <p>파생 쿼리라 조건이 메서드 이름에 드러나지만, 이름을 고치다 조건이 빠지는 일은
* 컴파일로 잡히지 않는다. 실제 DB 로 스코프를 고정한다.
*/
@PostgresRepositoryTest
class OrphanQueryScopeTest {

@Autowired UserRepository userRepository;
@Autowired ResumeRepository resumeRepository;
@Autowired AnalyzedDocumentRepository documentRepository;
@Autowired EntityManager em;

@Test
void resumeOrphanQuerySelectsOnlyDeletedRowsWithRemainingFile() {
User user = userRepository.save(User.createGithubUser(96001L, "orphan-user", null, null, "t"));
Resume alive = resumeRepository.save(
Resume.create(user, "alive.pdf", "resumes/raw/x/alive.pdf", ResumeFileType.PDF, 10L));
Resume deleted = resumeRepository.save(
Resume.create(user, "gone.pdf", "resumes/raw/x/gone.pdf", ResumeFileType.PDF, 10L));
deleted.markDeleted();
Resume alreadyPurged = resumeRepository.save(
Resume.create(user, "done.pdf", "resumes/raw/x/done.pdf", ResumeFileType.PDF, 10L));
alreadyPurged.markDeleted();
alreadyPurged.markContentPurged();
em.flush();

assertThat(resumeRepository.findTop100ByDeletedTrueAndFilePathIsNotNull())
.extracting(Resume::getId)
.contains(deleted.getId())
// 살아있는 자료는 절대 대상이 아니다.
.doesNotContain(alive.getId())
// 이미 회수된 건 다시 잡지 않는다.
.doesNotContain(alreadyPurged.getId());
}

@Test
void documentOrphanQuerySelectsOnlyDeletedRowsWithRemainingMarkdown() {
User user = userRepository.save(User.createGithubUser(96002L, "orphan-doc-user", null, null, "t"));
Resume resume = resumeRepository.save(
Resume.create(user, "r.pdf", "resumes/raw/y/r.pdf", ResumeFileType.PDF, 10L));
AnalyzedDocument alive = documentRepository.save(AnalyzedDocument.forResume(resume));
alive.markAnalyzed("analyzed/alive.md", "s", "[]", 1);

AnalyzedDocument deleted = documentRepository.save(AnalyzedDocument.forResume(resume));
deleted.markAnalyzed("analyzed/gone.md", "s", "[]", 1);
deleted.markDeleted();
em.flush();

assertThat(documentRepository.findTop100ByDeletedTrueAndDocumentPathIsNotNull())
.extracting(AnalyzedDocument::getId)
.contains(deleted.getId())
.doesNotContain(alive.getId());
}

// 회수 완료 표시(file_path=NULL)가 DB 제약에 막히지 않아야 한다 — V32 가 삭제된 행을
// 타입별 locator CHECK 에서 제외한다. 이게 막히면 스위퍼가 매 주기 같은 키를 다시 지운다.
@Test
void clearingFilePathIsAllowedForDeletedResume() {
User user = userRepository.save(User.createGithubUser(96003L, "purge-mark-user", null, null, "t"));
Resume resume = resumeRepository.save(
Resume.create(user, "x.pdf", "resumes/raw/z/x.pdf", ResumeFileType.PDF, 10L));
resume.markDeleted();
resume.markContentPurged();

em.flush();

assertThat(resume.getFilePath()).isNull();
}
}
10 changes: 10 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,13 @@ cd frontend && npm install && npm run dev
```

상세는 `infra/CLAUDE.md` 참조.

### 스토리지 고아 객체 회수 (Core)

```
STORAGE_ORPHAN_SWEEP_INTERVAL_MS=900000 # 스위퍼 주기 (기본 15분)
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`).
14 changes: 14 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ public class GithubTokenCipher {
- 임베딩 청크에는 이력서 **원문 조각**이 들어 있다. 검색에서 제외하는 것(`ACTIVE_DOC_JOIN`)과
파기는 다른 문제다 — 행이 남아 있는 한 본문이 DB 에 그대로 있다.

스토리지 회수는 두 경로가 있다.

1. **삭제 시점** — `ObjectPurgeListener` 가 커밋 직후 지운다(위 표).
2. **스위퍼** — `OrphanedObjectSweeper` 가 주기적으로(기본 15분) 삭제된 행 중 경로가 남아 있는
것을 찾아 회수하고 경로를 비운다. 두 가지를 덮는다:
- 이 기능(#219) **이전에 지운 자료** — 삭제 이벤트가 다시 발생하지 않으므로 백필이 필요하다
- **1의 실패분** — 1은 실패해도 사용자 요청을 실패시키지 않으므로(로그만 남긴다) 스토리지가
잠깐 죽어 있으면 객체가 새어 나간다

회수에 성공해야만 경로를 비운다 — 실패하면 다음 주기에 재시도한다. 삭제된 행은 타입별
locator CHECK 에서 제외된다(V32).

임베딩 청크는 DB 안에서 끝나므로 과거 삭제분도 V32 가 한 번에 정리한다.

> 회원 탈퇴 시 사용자의 모든 자료를 일괄 파기하는 것은 별건이다(§5.3 의 hard delete, Phase 2).

### 5.2 민감정보
Expand Down
Loading