diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/ObjectPurgeEvent.java b/backend/src/main/java/com/stackup/stackup/common/storage/ObjectPurgeEvent.java new file mode 100644 index 0000000..b03aa60 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/storage/ObjectPurgeEvent.java @@ -0,0 +1,24 @@ +package com.stackup.stackup.common.storage; + +import java.util.List; + +/** + * 스토리지 객체 즉시 파기 요청. + * + *

자료를 지우면 DB 행은 soft delete 로 남지만(세션 컨텍스트가 FK 로 참조한다) 내용물은 + * 남길 이유가 없다. 이력서 원본 PDF 에는 이름·연락처·주소가 들어 있고 + * (`docs/security.md §5.2`), 분석 마크다운은 그걸 재구성한 문서다. + * + *

발행은 도메인 트랜잭션 안에서, 실제 삭제는 {@link ObjectPurgeListener} 가 커밋 이후에 + * 한다 — 롤백된 삭제로 객체를 날리면 복구할 방법이 없다. + */ +public record ObjectPurgeEvent(List keys) { + + public ObjectPurgeEvent { + keys = keys == null ? List.of() : keys.stream().filter(k -> k != null && !k.isBlank()).toList(); + } + + public boolean isEmpty() { + return keys.isEmpty(); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/storage/ObjectPurgeListener.java b/backend/src/main/java/com/stackup/stackup/common/storage/ObjectPurgeListener.java new file mode 100644 index 0000000..b4da798 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/storage/ObjectPurgeListener.java @@ -0,0 +1,40 @@ +package com.stackup.stackup.common.storage; + +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * {@link ObjectPurgeEvent} 를 받아 스토리지 객체를 실제로 지운다. + * + *

AFTER_COMMIT 인 이유: 삭제 트랜잭션이 롤백됐는데 객체를 먼저 지우면 DB 는 살아 있고 + * 내용물만 사라진 상태가 된다 — 되돌릴 수 없다. + * + *

실패해도 사용자의 삭제 요청을 실패시키지 않는다. 이 시점엔 이미 커밋돼 행이 soft delete + * 상태라 어차피 도달 불가이고, 여기서 예외를 던져봐야 삭제를 되돌릴 수도 없다. 대신 키를 + * ERROR 로 남겨 수동 회수가 가능하게 한다 — 조용히 삼키면 파기됐다고 착각하게 된다. + */ +@Component +@RequiredArgsConstructor +public class ObjectPurgeListener { + + private static final Logger log = LoggerFactory.getLogger(ObjectPurgeListener.class); + + private final ObjectStorageClient storage; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void on(ObjectPurgeEvent event) { + for (String key : event.keys()) { + try { + storage.delete(key); + log.info("storage object purged. key={}", key); + } catch (Exception e) { + // 키를 남긴다 — 이 로그가 없으면 어떤 객체가 남았는지 알 방법이 없다. + log.error("storage object purge failed — 수동 회수 필요. key={}", key, e); + } + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java index 4258d48..761500a 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListener.java @@ -1,19 +1,23 @@ package com.stackup.stackup.document.application; +import com.stackup.stackup.common.storage.ObjectPurgeEvent; import com.stackup.stackup.coverletter.application.event.CoverLetterDeletedEvent; import com.stackup.stackup.document.domain.AnalyzedDocument; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; import com.stackup.stackup.github.application.event.RepositoryDeletedEvent; import com.stackup.stackup.resume.application.event.ResumeDeletedEvent; import java.util.List; import lombok.RequiredArgsConstructor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -// Resume / GithubRepository soft delete 발생 시 관련 AnalyzedDocument 도 cascade soft delete. +// Resume / GithubRepository / CoverLetter soft delete 발생 시 관련 AnalyzedDocument 도 +// cascade soft delete + **분석 내용물 즉시 파기**(분석 마크다운 객체 + 임베딩 청크). // 도메인 cycle 회피 — resume/github 가 document 도메인을 직접 import 하지 않고 이벤트로 위임. @Component @RequiredArgsConstructor @@ -22,17 +26,17 @@ public class AnalyzedDocumentCascadeListener { private static final Logger log = LoggerFactory.getLogger(AnalyzedDocumentCascadeListener.class); private final AnalyzedDocumentRepository analyzedDocumentRepository; + private final DocumentEmbeddingRepository embeddingRepository; + private final ApplicationEventPublisher events; @EventListener @Transactional public void on(ResumeDeletedEvent event) { List docs = analyzedDocumentRepository .findActiveByResumeIdAndOwner(event.resumeId(), event.userId()); - for (AnalyzedDocument doc : docs) { - doc.markDeleted(); - } + purge(docs); if (!docs.isEmpty()) { - log.info("AnalyzedDocument cascade soft delete (resume). userId={}, resumeId={}, count={}", + log.info("AnalyzedDocument cascade soft delete + purge (resume). userId={}, resumeId={}, count={}", event.userId(), event.resumeId(), docs.size()); } } @@ -42,11 +46,9 @@ public void on(ResumeDeletedEvent event) { public void on(RepositoryDeletedEvent event) { List docs = analyzedDocumentRepository .findActiveByRepositoryIdAndOwner(event.repositoryId(), event.userId()); - for (AnalyzedDocument doc : docs) { - doc.markDeleted(); - } + purge(docs); if (!docs.isEmpty()) { - log.info("AnalyzedDocument cascade soft delete (repository). userId={}, repositoryId={}, count={}", + log.info("AnalyzedDocument cascade soft delete + purge (repository). userId={}, repositoryId={}, count={}", event.userId(), event.repositoryId(), docs.size()); } } @@ -56,12 +58,34 @@ public void on(RepositoryDeletedEvent event) { public void on(CoverLetterDeletedEvent event) { List docs = analyzedDocumentRepository .findActiveByCoverLetterIdAndOwner(event.coverLetterId(), event.userId()); - for (AnalyzedDocument doc : docs) { - doc.markDeleted(); - } + purge(docs); if (!docs.isEmpty()) { - log.info("AnalyzedDocument cascade soft delete (cover letter). userId={}, coverLetterId={}, count={}", + log.info("AnalyzedDocument cascade soft delete + purge (cover letter). userId={}, coverLetterId={}, count={}", event.userId(), event.coverLetterId(), docs.size()); } } + + /** + * 문서를 soft delete 하고 **내용물은 즉시 파기**한다. + * + *

행을 지우지 않는 이유: session_contexts 가 analyzed_documents 를 FK 로 참조한다. + * 하지만 남길 이유가 있는 건 참조 무결성뿐이고, 분석 마크다운(이력서를 재구성한 문서)과 + * 임베딩 청크(원문 조각)는 남길 이유가 없다. + * + *

임베딩은 같은 트랜잭션에서 지운다(DB). 스토리지 객체는 커밋 이후에 지운다 — + * 롤백된 삭제로 객체를 날리면 되돌릴 수 없다. + */ + private void purge(List docs) { + if (docs.isEmpty()) { + return; + } + List ids = docs.stream().map(AnalyzedDocument::getId).toList(); + List paths = docs.stream().map(AnalyzedDocument::getDocumentPath).toList(); + for (AnalyzedDocument doc : docs) { + doc.markDeleted(); + } + int removed = embeddingRepository.deleteByDocumentIds(ids); + log.info("document embeddings purged. documentIds={}, chunks={}", ids, removed); + events.publishEvent(new ObjectPurgeEvent(paths)); + } } diff --git a/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java index 46ff1a0..d27b6bc 100644 --- a/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/domain/DocumentEmbeddingRepository.java @@ -8,6 +8,10 @@ public interface DocumentEmbeddingRepository { int countByDocumentId(long documentId); + // 자료 삭제 시 청크 원문을 즉시 파기한다. 청크에는 이력서 본문이 그대로 들어 있어 + // 행이 남아 있는 한 "지웠다"고 할 수 없다(검색에서 빼는 것과는 다른 문제다). + int deleteByDocumentIds(List documentIds); + // 임베딩 검색. queryText 가 주어지면 벡터 + full-text(BM25) 를 RRF 로 융합한 // 하이브리드 검색, 없으면(null/blank) pgvector cosine 단독 검색. // documentIds 가 비어 있으면 전체 대상. diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java index 88eba71..32b62b4 100644 --- a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java @@ -28,6 +28,9 @@ ON CONFLICT (document_id, chunk_index) private static final String COUNT_SQL = "SELECT count(*) FROM document_embeddings WHERE document_id = ?"; + private static final String DELETE_BY_DOCS_SQL = + "DELETE FROM document_embeddings WHERE document_id IN (:documentIds)"; + // 삭제된 문서의 청크는 검색에서 제외한다. // // 세션 생성 뒤 사용자가 워크스페이스에서 자료를 지워도 session_contexts 에는 그 문서 id 가 @@ -78,6 +81,14 @@ public int countByDocumentId(long documentId) { return n == null ? 0 : n; } + @Override + public int deleteByDocumentIds(List documentIds) { + if (documentIds == null || documentIds.isEmpty()) { + return 0; + } + return namedJdbc.update(DELETE_BY_DOCS_SQL, Map.of("documentIds", documentIds)); + } + @Override public List search( float[] queryEmbedding, String queryText, List documentIds, int topK) { diff --git a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java index ff7b6cc..a74f200 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java +++ b/backend/src/main/java/com/stackup/stackup/resume/application/ResumeService.java @@ -2,6 +2,7 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.storage.ObjectPurgeEvent; import com.stackup.stackup.common.storage.ObjectStorageClient; import com.stackup.stackup.resume.application.dto.ResumeResult; import com.stackup.stackup.resume.application.dto.ResumeUploadCommand; @@ -104,8 +105,14 @@ public ResumeResult get(Long userId, Long resumeId) { public void delete(Long userId, Long resumeId) { Resume resume = loadOwned(userId, resumeId); resume.markDeleted(); - // 분석 결과 cascade — document 도메인 listener 가 ResumeDeletedEvent 받아 AnalyzedDocument soft delete. - // 직접 의존 회피 (ArchUnit 도메인 cycle 방지). + // 업로드 원본 즉시 파기. 이력서 PDF 에는 이름·연락처·주소가 들어 있어 + // (docs/security.md §5.2) 행만 soft delete 하고 객체를 남기면 "지웠다"고 할 수 없다. + // 웹 이력서(URL)는 업로드 원본이 없어 filePath 가 null 이다. + // 실제 삭제는 ObjectPurgeListener 가 커밋 이후에 한다. + events.publishEvent(new ObjectPurgeEvent(List.of( + resume.getFilePath() == null ? "" : resume.getFilePath()))); + // 분석 결과 cascade — document 도메인 listener 가 ResumeDeletedEvent 받아 AnalyzedDocument soft delete + // + 분석 마크다운·임베딩 파기. 직접 의존 회피 (ArchUnit 도메인 cycle 방지). events.publishEvent(new ResumeDeletedEvent(userId, resumeId)); } diff --git a/backend/src/test/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListenerTest.java b/backend/src/test/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListenerTest.java index b7da0d7..23a4fb8 100644 --- a/backend/src/test/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListenerTest.java +++ b/backend/src/test/java/com/stackup/stackup/document/application/AnalyzedDocumentCascadeListenerTest.java @@ -1,76 +1,95 @@ package com.stackup.stackup.document.application; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; 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.application.dto.AnalyzedDocumentResult; -import com.stackup.stackup.document.domain.AnalysisStatus; +import com.stackup.stackup.common.storage.ObjectPurgeEvent; import com.stackup.stackup.document.domain.AnalyzedDocument; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; -import com.stackup.stackup.document.domain.DocumentStatus; -import com.stackup.stackup.github.application.event.RepositoryDeletedEvent; -import com.stackup.stackup.github.domain.GithubRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; import com.stackup.stackup.resume.application.event.ResumeDeletedEvent; import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +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.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; +/** + * 자료를 지우면 분석 내용물도 즉시 파기한다 — 분석 마크다운 객체 + 임베딩 청크. + * + *

행은 soft delete 로 남는다(session_contexts 가 FK 로 참조). 남길 이유가 있는 건 + * 참조 무결성뿐이고 내용물은 아니다. + */ @ExtendWith(MockitoExtension.class) class AnalyzedDocumentCascadeListenerTest { @Mock AnalyzedDocumentRepository documentRepository; - @Mock ObjectStorageClient storage; + @Mock DocumentEmbeddingRepository embeddingRepository; + @Mock ApplicationEventPublisher events; @InjectMocks AnalyzedDocumentCascadeListener listener; @Test - void resumeDeleted_softDeletesRelatedAnalyzedDocuments() { - AnalyzedDocument first = AnalyzedDocument.forResume(mock(Resume.class)); - AnalyzedDocument second = AnalyzedDocument.forResume(mock(Resume.class)); - when(documentRepository.findActiveByResumeIdAndOwner(10L, 1L)).thenReturn(List.of(first, second)); + void resumeDeleted_softDeletesDocsAndPurgesContent() { + AnalyzedDocument doc = analyzedDocument(11L, "analyzed/resume/11/summary.md"); + when(documentRepository.findActiveByResumeIdAndOwner(5L, 1L)).thenReturn(List.of(doc)); - listener.on(new ResumeDeletedEvent(1L, 10L)); + listener.on(new ResumeDeletedEvent(1L, 5L)); - assertThat(first.isDeleted()).isTrue(); - assertThat(second.isDeleted()).isTrue(); + assertThat(doc.isDeleted()).isTrue(); + // 임베딩은 같은 트랜잭션에서 바로 지운다 — 청크에 이력서 본문이 그대로 들어 있다. + verify(embeddingRepository).deleteByDocumentIds(List.of(11L)); + + // 스토리지 객체는 커밋 이후 파기 — 이벤트로 넘긴다. + ArgumentCaptor published = ArgumentCaptor.forClass(Object.class); + verify(events).publishEvent(published.capture()); + assertThat(published.getValue()) + .isInstanceOfSatisfying(ObjectPurgeEvent.class, e -> + assertThat(e.keys()).containsExactly("analyzed/resume/11/summary.md")); } + // 분석 전에 지운 자료는 documentPath 가 없다 — 빈 키로 파기 이벤트를 내지 않는다. @Test - void repositoryDeleted_softDeletesRelatedAnalyzedDocuments() { - AnalyzedDocument document = AnalyzedDocument.forRepository(mock(GithubRepository.class)); - when(documentRepository.findActiveByRepositoryIdAndOwner(20L, 1L)).thenReturn(List.of(document)); + void resumeDeleted_skipsPurgeEventWhenNoDocumentPath() { + AnalyzedDocument doc = analyzedDocument(12L, null); + when(documentRepository.findActiveByResumeIdAndOwner(5L, 1L)).thenReturn(List.of(doc)); - listener.on(new RepositoryDeletedEvent(1L, 20L)); + listener.on(new ResumeDeletedEvent(1L, 5L)); - assertThat(document.isDeleted()).isTrue(); + verify(embeddingRepository).deleteByDocumentIds(List.of(12L)); + ArgumentCaptor published = ArgumentCaptor.forClass(Object.class); + verify(events).publishEvent(published.capture()); + assertThat(published.getValue()) + .isInstanceOfSatisfying(ObjectPurgeEvent.class, e -> assertThat(e.isEmpty()).isTrue()); } @Test - void listForUser_usesActiveDocumentQuerySoDeletedDocumentsAreExcluded() { - AnalyzedDocumentQueryService queryService = new AnalyzedDocumentQueryService(documentRepository, storage); - AnalyzedDocument active = mockDocument(); - when(documentRepository.findActiveByOwner(1L)).thenReturn(List.of(active)); + void resumeDeleted_isNoopWhenNoAnalyzedDocuments() { + when(documentRepository.findActiveByResumeIdAndOwner(5L, 1L)).thenReturn(List.of()); - List results = queryService.listForUser(1L, null, null); + listener.on(new ResumeDeletedEvent(1L, 5L)); - assertThat(results).hasSize(1); - verify(documentRepository).findActiveByOwner(1L); - verify(documentRepository, never()).findByResume_User_IdOrRepository_User_Id(1L, 1L); + verify(embeddingRepository, never()).deleteByDocumentIds(anyList()); + verify(events, never()).publishEvent(any()); } - private AnalyzedDocument mockDocument() { - AnalyzedDocument document = mock(AnalyzedDocument.class); - when(document.getTechStack()).thenReturn("[\"Java\"]"); - when(document.getEmbeddingChunkCount()).thenReturn(1); - when(document.getAnalysisStatus()).thenReturn(AnalysisStatus.ANALYZED); - when(document.getStatus()).thenReturn(DocumentStatus.ACTIVE); - return document; + private AnalyzedDocument analyzedDocument(Long id, String documentPath) { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 1L); + Resume resume = Resume.create(user, "r.pdf", "resumes/raw/1/r.pdf", ResumeFileType.PDF, 10L); + AnalyzedDocument doc = AnalyzedDocument.forResume(resume); + ReflectionTestUtils.setField(doc, "id", id); + ReflectionTestUtils.setField(doc, "documentPath", documentPath); + return doc; } } diff --git a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java index 5c7b562..4a43524 100644 --- a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java +++ b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java @@ -139,6 +139,31 @@ void serviceReturnsEmptyWhenUserOwnsNothing() { .isEmpty(); } + // 자료를 지우면 청크 원문도 즉시 파기한다. 검색에서 빼는 것(#216)과는 다른 문제다 — + // 행이 남아 있는 한 이력서 본문이 DB 에 그대로 있다. + @Test + void deleteByDocumentIdsRemovesChunks() { + AnalyzedDocument kept = document(97009L, "kept-purge"); + AnalyzedDocument removed = document(97010L, "removed-purge"); + embeddingRepository.upsertAll(kept.getId(), "test-model", + List.of(new EmbeddingChunk(0, "남아야 하는 청크", vector(0.4f)))); + embeddingRepository.upsertAll(removed.getId(), "test-model", + List.of(new EmbeddingChunk(0, "파기 대상", vector(0.4f)), + new EmbeddingChunk(1, "파기 대상 2", vector(0.4f)))); + em.flush(); + + assertThat(embeddingRepository.deleteByDocumentIds(List.of(removed.getId()))).isEqualTo(2); + + assertThat(embeddingRepository.countByDocumentId(removed.getId())).isZero(); + // 다른 문서의 청크까지 쓸어가면 안 된다. + assertThat(embeddingRepository.countByDocumentId(kept.getId())).isEqualTo(1); + } + + @Test + void deleteByDocumentIdsIsNoopForEmptyInput() { + assertThat(embeddingRepository.deleteByDocumentIds(List.of())).isZero(); + } + private List search(List documentIds) { return embeddingRepository.search(vector(0.9f), null, documentIds, 10); } diff --git a/docs/security.md b/docs/security.md index ffbe88a..8039be9 100644 --- a/docs/security.md +++ b/docs/security.md @@ -133,6 +133,28 @@ public class GithubTokenCipher { - `user_consents` 테이블에 IP, 동의 시각, 버전 기록 - 동의 철회 → `revoked_at` 기록 + 관련 데이터 삭제 절차 안내 +### 5.1.1 자료 삭제 시 즉시 파기 + +이력서·레포·자소서를 지우면 **내용물을 즉시 파기**한다. 행 자체는 soft delete 로 남는다 — +`session_contexts` 가 `analyzed_documents` 를 FK 로 참조해서 행을 지우면 제약 위반이다. +남길 이유가 있는 건 참조 무결성뿐이고 내용물은 아니다. + +| 대상 | 시점 | +|---|---| +| 업로드 원본 (`resumes.file_path`) | 커밋 이후 (`ObjectPurgeListener`) | +| 분석 마크다운 (`analyzed_documents.document_path`) | 커밋 이후 | +| 임베딩 청크 (`document_embeddings`) | 삭제와 같은 트랜잭션 | + +- 스토리지 삭제가 커밋 **이후**인 이유: 롤백된 삭제로 객체를 날리면 DB 는 살아 있고 내용물만 + 사라진 상태가 되어 되돌릴 수 없다. +- 스토리지 삭제 실패는 사용자 요청을 실패시키지 않는다 — 이 시점엔 이미 커밋돼 행이 도달 + 불가이고, 예외를 던져도 삭제를 되돌릴 수 없다. 대신 키를 ERROR 로 남겨 수동 회수가 가능하게 + 한다(조용히 삼키면 파기됐다고 착각하게 된다). +- 임베딩 청크에는 이력서 **원문 조각**이 들어 있다. 검색에서 제외하는 것(`ACTIVE_DOC_JOIN`)과 + 파기는 다른 문제다 — 행이 남아 있는 한 본문이 DB 에 그대로 있다. + +> 회원 탈퇴 시 사용자의 모든 자료를 일괄 파기하는 것은 별건이다(§5.3 의 hard delete, Phase 2). + ### 5.2 민감정보 이력서 PDF에 포함될 수 있는 민감정보: - 이름, 연락처, 이메일, 주소, 학력, 경력, 사진