From 718668f2e649d8dba9d7a8439b840557b1fb8d9f Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 24 Aug 2026 12:33:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EA=B3=BC=EA=B1=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=B6=84=C2=B7=ED=8C=8C=EA=B8=B0=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EB=B6=84=EC=9D=98=20=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EA=B0=9D=EC=B2=B4=20=ED=9A=8C=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #219 는 "앞으로의 삭제"만 다뤘다. 두 부류가 남는다. 1. 그 이전에 지운 자료 — 행만 soft delete 됐고 S3 원본 PDF·분석 마크다운이 그대로다. 앞으로 삭제 이벤트가 다시 날 일이 없어 백필하지 않으면 영원히 남는다. V31(탈퇴자 GitHub 토큰)과 같은 종류의 누락이다. 2. #219 의 파기 실패분 — AFTER_COMMIT 파기는 실패해도 사용자 요청을 실패시키지 않는다(이미 커밋됐고 되돌릴 수도 없다). 스토리지가 잠깐 죽으면 로그 한 줄만 남기고 객체가 샌다. OrphanedObjectSweeper 가 둘을 같은 경로로 회수한다. 임베딩 청크는 DB 안에서 끝나므로 V32 가 한 번에 정리한다. 파괴적 작업이라 대상 선정이 핵심이다. 파생 쿼리 이름에 DeletedTrue 를 박아 조건이 시그니처에 드러나게 하고, 실제 DB 로 "살아있는 행은 절대 안 잡힌다"를 고정했다. 회수 성공 시에만 경로를 비우고(실패는 다음 주기 재시도), 한 건이 실패해도 나머지는 계속 회수한다. V32 는 삭제된 행을 resumes 의 타입별 locator CHECK 에서 제외한다 — 회수 완료 표시로 file_path 를 비워야 하는데 제약이 막는다. users 의 provider 식별자 CHECK(V28)·유니크 인덱스(V3·V22)가 이미 쓰는 규약이다. --- .../application/OrphanedObjectSweeper.java | 97 ++++++++++++++++ .../document/domain/AnalyzedDocument.java | 5 + .../domain/AnalyzedDocumentRepository.java | 4 + .../stackup/stackup/resume/domain/Resume.java | 10 ++ .../resume/domain/ResumeRepository.java | 4 + ...e_content_of_already_deleted_materials.sql | 21 ++++ .../OrphanedObjectSweeperTest.java | 109 ++++++++++++++++++ .../infrastructure/OrphanQueryScopeTest.java | 88 ++++++++++++++ docs/environment.md | 10 ++ docs/security.md | 14 +++ 10 files changed, 362 insertions(+) create mode 100644 backend/src/main/java/com/stackup/stackup/document/application/OrphanedObjectSweeper.java create mode 100644 backend/src/main/resources/db/migration/V32__purge_content_of_already_deleted_materials.sql create mode 100644 backend/src/test/java/com/stackup/stackup/document/application/OrphanedObjectSweeperTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/document/infrastructure/OrphanQueryScopeTest.java 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 00000000..c16ea84f --- /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; + +/** + * 삭제된 자료의 스토리지 객체를 회수한다. + * + *

두 가지를 같은 경로로 처리한다. + * + *

    + *
  1. 과거 삭제분 — #219 이전에 지운 자료는 행만 soft delete 됐고 S3 의 원본 PDF· + * 분석 마크다운이 그대로 남아 있다. 앞으로 삭제 이벤트가 다시 발생할 일이 없으니 + * 백필하지 않으면 영원히 남는다(V31 의 탈퇴자 토큰과 같은 종류의 누락).
  2. + *
  3. 실패분 — #219 의 파기는 AFTER_COMMIT 이고 실패해도 사용자 요청을 실패시키지 + * 않는다(그 시점엔 이미 커밋됐고 되돌릴 수도 없다). 그래서 스토리지가 잠깐 죽어 있으면 + * 객체가 로그 한 줄만 남기고 새어 나간다. 그 구멍을 여기서 닫는다.
  4. + *
+ * + *

파괴적 작업이다. 조회는 반드시 삭제된 행만 잡아야 한다 — 조건이 하나라도 어긋나면 + * 살아있는 사용자의 이력서를 지운다. 그래서 리포지토리 쿼리에 {@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 f257ed6b..6644d66e 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 9eae3c16..b7d7b995 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 5bd05933..364558db 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 43cc6730..d27affe9 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 00000000..fe738148 --- /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 00000000..c5afe1bd --- /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 00000000..8ca692c7 --- /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 6d537053..4cd59058 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 f2eb94b0..c9dbd38a 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 민감정보