diff --git a/backend/src/main/java/com/stackup/stackup/document/application/OrphanedObjectSweeper.java b/backend/src/main/java/com/stackup/stackup/document/application/OrphanedObjectSweeper.java
new file mode 100644
index 0000000..c16ea84
--- /dev/null
+++ b/backend/src/main/java/com/stackup/stackup/document/application/OrphanedObjectSweeper.java
@@ -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;
+
+/**
+ * 삭제된 자료의 스토리지 객체를 회수한다.
+ *
+ *
두 가지를 같은 경로로 처리한다.
+ *
+ *
+ * - 과거 삭제분 — #219 이전에 지운 자료는 행만 soft delete 됐고 S3 의 원본 PDF·
+ * 분석 마크다운이 그대로 남아 있다. 앞으로 삭제 이벤트가 다시 발생할 일이 없으니
+ * 백필하지 않으면 영원히 남는다(V31 의 탈퇴자 토큰과 같은 종류의 누락).
+ * - 실패분 — #219 의 파기는 AFTER_COMMIT 이고 실패해도 사용자 요청을 실패시키지
+ * 않는다(그 시점엔 이미 커밋됐고 되돌릴 수도 없다). 그래서 스토리지가 잠깐 죽어 있으면
+ * 객체가 로그 한 줄만 남기고 새어 나간다. 그 구멍을 여기서 닫는다.
+ *
+ *
+ * 파괴적 작업이다. 조회는 반드시 삭제된 행만 잡아야 한다 — 조건이 하나라도 어긋나면
+ * 살아있는 사용자의 이력서를 지운다. 그래서 리포지토리 쿼리에 {@code DeletedTrue} 를 이름으로
+ * 박아 두고(파생 쿼리라 조건이 시그니처에 드러난다) 테스트로 고정한다.
+ *
+ *
회수에 성공하면 경로를 비운다({@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 targets = resumeRepository.findTop100ByDeletedTrueAndFilePathIsNotNull();
+ int purged = 0;
+ for (Resume resume : targets) {
+ if (purge(resume.getFilePath())) {
+ resume.markContentPurged();
+ purged++;
+ }
+ }
+ return purged;
+ }
+
+ private int sweepDocuments() {
+ List 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;
+ }
+ }
+}
diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java
index f257ed6..6644d66 100644
--- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java
+++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocument.java
@@ -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;
+ }
}
diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java
index 9eae3c1..b7d7b99 100644
--- a/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java
+++ b/backend/src/main/java/com/stackup/stackup/document/domain/AnalyzedDocumentRepository.java
@@ -60,6 +60,10 @@ Optional findByIdAndResume_User_IdOrIdAndRepository_User_Id(
""")
List findActiveIdsByOwner(@Param("userId") Long userId);
+ // 분석 마크다운이 아직 남아 있는 **삭제된** 문서. OrphanedObjectSweeper 전용 —
+ // 위와 같은 이유로 deleted=true 조건이 안전의 핵심이다.
+ List findTop100ByDeletedTrueAndDocumentPathIsNotNull();
+
@Query("""
SELECT d FROM AnalyzedDocument d
WHERE d.coverLetter.id = :coverLetterId
diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java
index 5bd0593..364558d 100644
--- a/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java
+++ b/backend/src/main/java/com/stackup/stackup/resume/domain/Resume.java
@@ -106,4 +106,14 @@ public void markFailed() {
public void markDeleted() {
this.deleted = true;
}
+
+ /**
+ * 스토리지 원본이 파기됐음을 표시한다(경로를 비운다).
+ *
+ * 객체가 없는데 키만 들고 있으면 "아직 회수 안 됨"과 구분되지 않아 스위퍼가 매번
+ * 다시 지우려 든다. 삭제된 행은 타입별 locator CHECK 에서 제외된다(V32).
+ */
+ public void markContentPurged() {
+ this.filePath = null;
+ }
}
diff --git a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java
index 43cc673..d27affe 100644
--- a/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java
+++ b/backend/src/main/java/com/stackup/stackup/resume/domain/ResumeRepository.java
@@ -11,4 +11,8 @@ public interface ResumeRepository extends JpaRepository {
Optional findByIdAndUser_IdAndDeletedFalse(Long id, Long userId);
boolean existsByUser_IdAndSourceUrlAndDeletedFalse(Long userId, String sourceUrl);
+
+ // 스토리지 객체가 아직 남아 있는 **삭제된** 이력서. OrphanedObjectSweeper 전용이고
+ // 객체를 실제로 지우는 데 쓰이므로 deleted=true 조건을 절대 빼면 안 된다.
+ List findTop100ByDeletedTrueAndFilePathIsNotNull();
}
diff --git a/backend/src/main/resources/db/migration/V32__purge_content_of_already_deleted_materials.sql b/backend/src/main/resources/db/migration/V32__purge_content_of_already_deleted_materials.sql
new file mode 100644
index 0000000..fe73814
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V32__purge_content_of_already_deleted_materials.sql
@@ -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)
+ );
diff --git a/backend/src/test/java/com/stackup/stackup/document/application/OrphanedObjectSweeperTest.java b/backend/src/test/java/com/stackup/stackup/document/application/OrphanedObjectSweeperTest.java
new file mode 100644
index 0000000..c5afe1b
--- /dev/null
+++ b/backend/src/test/java/com/stackup/stackup/document/application/OrphanedObjectSweeperTest.java
@@ -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;
+ }
+}
diff --git a/backend/src/test/java/com/stackup/stackup/document/infrastructure/OrphanQueryScopeTest.java b/backend/src/test/java/com/stackup/stackup/document/infrastructure/OrphanQueryScopeTest.java
new file mode 100644
index 0000000..8ca692c
--- /dev/null
+++ b/backend/src/test/java/com/stackup/stackup/document/infrastructure/OrphanQueryScopeTest.java
@@ -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 객체를 실제로 지우는 데 쓰인다 —
+ * 살아있는 행이 하나라도 섞이면 사용자의 이력서 원본이 사라진다.
+ *
+ * 파생 쿼리라 조건이 메서드 이름에 드러나지만, 이름을 고치다 조건이 빠지는 일은
+ * 컴파일로 잡히지 않는다. 실제 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();
+ }
+}
diff --git a/docs/environment.md b/docs/environment.md
index 6d53705..4cd5905 100644
--- a/docs/environment.md
+++ b/docs/environment.md
@@ -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`).
diff --git a/docs/security.md b/docs/security.md
index f2eb94b..c9dbd38 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -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 민감정보