자료를 지우면 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