From fcd8d660aeacd9cabeea99e442337734e3ea26a9 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Thu, 30 Apr 2026 17:40:11 +0900 Subject: [PATCH 01/28] =?UTF-8?q?refactor:=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=B5=9C=EC=8B=A0=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EB=B0=B0=EC=B9=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../branch/dao/mysql/BranchRepository.java | 21 ++++++++ .../app/create/DocCreateMySqlTxService.java | 2 + .../docsa/domain/doc/dto/LatestSaveIdDto.java | 7 +++ .../domain/doc/dto/RecentActivityDto.java | 22 -------- .../doc/dto/response/DocPageResponse.java | 3 +- .../dto/response/DocSimplePageResponse.java | 7 ++- .../domain/doc/swagger/GetDocListDocs.java | 15 ++---- .../doc/swagger/GetDocSidebarListDocs.java | 15 ++---- .../domain/doc/swagger/SearchDocDocs.java | 15 ++---- .../domain/doc/util/DocListAssembler.java | 47 ++++++++--------- .../docsa/domain/doc/util/DocMapper.java | 10 ++-- .../DocServiceIntegrationTests.java | 52 ++++++++++++++++--- .../doc/unit/DocControllerUnitTests.java | 12 ++--- .../domain/doc/unit/DocServiceUnitTests.java | 12 ++--- .../docsa/domain/doc/util/DocTestUtils.java | 38 +++++--------- 15 files changed, 136 insertions(+), 142 deletions(-) create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/dto/LatestSaveIdDto.java delete mode 100644 src/main/java/io/ejangs/docsa/domain/doc/dto/RecentActivityDto.java diff --git a/src/main/java/io/ejangs/docsa/domain/branch/dao/mysql/BranchRepository.java b/src/main/java/io/ejangs/docsa/domain/branch/dao/mysql/BranchRepository.java index bf5acc35..d3abeeb8 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/dao/mysql/BranchRepository.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/dao/mysql/BranchRepository.java @@ -2,6 +2,7 @@ import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; import io.ejangs.docsa.domain.branch.entity.Branch; +import io.ejangs.docsa.domain.doc.dto.LatestSaveIdDto; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -28,6 +29,26 @@ public interface BranchRepository extends JpaRepository { """) List findBranchGraphDtoList(@Param("docId") Long docId); + @Query(""" + SELECT new io.ejangs.docsa.domain.doc.dto.LatestSaveIdDto( + b.doc.id, + s.id + ) + FROM Branch b + JOIN b.save s + WHERE b.doc.id IN :docIds + AND NOT EXISTS ( + SELECT 1 + FROM Branch newer + WHERE newer.doc.id = b.doc.id + AND ( + newer.updatedAt > b.updatedAt + OR (newer.updatedAt = b.updatedAt AND newer.id > b.id) + ) + ) + """) + List findLatestSaveIdsByDocIds(@Param("docIds") List docIds); + boolean existsByIdAndDocIdAndDocUserId(Long branchId, Long documentId, Long userId); boolean existsByFromCommitIdIn(List commitIds); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java index 906cb674..c351dd41 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java @@ -10,6 +10,7 @@ import io.ejangs.docsa.domain.save.app.SaveQueryService; import io.ejangs.docsa.domain.save.entity.Save; import io.ejangs.docsa.domain.user.entity.User; +import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -34,6 +35,7 @@ public DocCreateResponse createMySqlPart(String title, User user, Doc doc = docQueryService.create(user, title); Branch defaultBranch = branchQueryService.createBranch(doc, defaultBranchName); Save defaultSave = saveQueryService.createSave(defaultBranch, saveContentId); + RenewUpdatedAtHelper.touch(defaultSave); thumbnailRepository.save(Thumbnail.builder() .doc(doc) .build()); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/dto/LatestSaveIdDto.java b/src/main/java/io/ejangs/docsa/domain/doc/dto/LatestSaveIdDto.java new file mode 100644 index 00000000..5a9d6d6b --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/dto/LatestSaveIdDto.java @@ -0,0 +1,7 @@ +package io.ejangs.docsa.domain.doc.dto; + +public record LatestSaveIdDto( + Long docId, + Long saveId +) { +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/dto/RecentActivityDto.java b/src/main/java/io/ejangs/docsa/domain/doc/dto/RecentActivityDto.java deleted file mode 100644 index f78e7095..00000000 --- a/src/main/java/io/ejangs/docsa/domain/doc/dto/RecentActivityDto.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.ejangs.docsa.domain.doc.dto; - -import io.ejangs.docsa.domain.commit.entity.Commit; -import io.ejangs.docsa.domain.save.entity.Save; - -public record RecentActivityDto( - RecentType recentType, - Long recentTypeId -) { - - public enum RecentType { - SAVE, COMMIT - } - - public static RecentActivityDto from(Save save) { - return new RecentActivityDto(RecentType.SAVE, save.getId()); - } - - public static RecentActivityDto from(Commit commit) { - return new RecentActivityDto(RecentType.COMMIT, commit.getId()); - } -} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocPageResponse.java b/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocPageResponse.java index 41ea9a4d..bd384748 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocPageResponse.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocPageResponse.java @@ -1,6 +1,5 @@ package io.ejangs.docsa.domain.doc.dto.response; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import java.time.LocalDateTime; @@ -11,7 +10,7 @@ public record DocPageResponse( LocalDateTime updatedAt, String thumbnailUrl, ThumbnailStatus thumbnailStatus, - RecentActivityDto recent + Long recentSaveId ) { public DocPageResponse { diff --git a/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocSimplePageResponse.java b/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocSimplePageResponse.java index 88559d5e..a3286140 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocSimplePageResponse.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/dto/response/DocSimplePageResponse.java @@ -1,6 +1,5 @@ package io.ejangs.docsa.domain.doc.dto.response; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto; import java.time.LocalDateTime; public record DocSimplePageResponse( @@ -8,15 +7,15 @@ public record DocSimplePageResponse( String title, LocalDateTime createdAt, LocalDateTime updatedAt, - RecentActivityDto recent + Long recentSaveId ) { public DocSimplePageResponse(Long id, String title, LocalDateTime createdAt, - LocalDateTime updatedAt, RecentActivityDto recent) { + LocalDateTime updatedAt, Long recentSaveId) { this.id = id; this.title = title; this.createdAt = createdAt.plusHours(9L); this.updatedAt = updatedAt.plusHours(9L); - this.recent = recent; + this.recentSaveId = recentSaveId; } } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocListDocs.java b/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocListDocs.java index c295450b..3945fa58 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocListDocs.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocListDocs.java @@ -66,10 +66,7 @@ "createdAt": "2025-07-22T04:48:43.036421+09:00", "updatedAt": "2025-07-22T04:48:43.065674+09:00", "preview": "이문서의 마지막 저장or커밋의 일부분미리보기 부분~~", - "recent": { - "recentType": "SAVE", - "recentTypeId": 3 - } + "recentSaveId": 3 }, { "id": 2, @@ -77,10 +74,7 @@ "createdAt": "2025-07-22T04:48:40.02776+09:00", "updatedAt": "2025-07-22T04:48:40.056721+09:00", "preview": "미리보기 없음", - "recent": { - "recentType": "SAVE", - "recentTypeId": 2 - } + "recentSaveId": 2 }, { "id": 1, @@ -88,10 +82,7 @@ "createdAt": "2025-07-22T04:48:35.242338+09:00", "updatedAt": "2025-07-22T04:48:35.348708+09:00", "preview": "미리보기 없음", - "recent": { - "recentType": "SAVE", - "recentTypeId": 1 - } + "recentSaveId": 1 } ], "pageable": { diff --git a/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocSidebarListDocs.java b/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocSidebarListDocs.java index 064159d3..2032f140 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocSidebarListDocs.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/swagger/GetDocSidebarListDocs.java @@ -65,30 +65,21 @@ "title": "새 문서 제목 3", "createdAt": "2025-07-22T04:48:43.036421+09:00", "updatedAt": "2025-07-22T04:48:43.065674+09:00", - "recent": { - "recentType": "SAVE", - "recentTypeId": 3 - } + "recentSaveId": 3 }, { "id": 2, "title": "새 문서 제목 2", "createdAt": "2025-07-22T04:48:40.02776+09:00", "updatedAt": "2025-07-22T04:48:40.056721+09:00", - "recent": { - "recentType": "COMMIT", - "recentTypeId": 2 - } + "recentSaveId": 2 }, { "id": 1, "title": "새 문서 제목", "createdAt": "2025-07-22T04:48:35.242338+09:00", "updatedAt": "2025-07-22T04:48:35.348708+09:00", - "recent": { - "recentType": "SAVE", - "recentTypeId": 1 - } + "recentSaveId": 1 } ], "pageable": { diff --git a/src/main/java/io/ejangs/docsa/domain/doc/swagger/SearchDocDocs.java b/src/main/java/io/ejangs/docsa/domain/doc/swagger/SearchDocDocs.java index 5a51c8b3..9c404ac9 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/swagger/SearchDocDocs.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/swagger/SearchDocDocs.java @@ -74,10 +74,7 @@ "createdAt": "2025-07-24T23:08:36.088612+09:00", "updatedAt": "2025-07-24T23:08:36.114375+09:00", "preview": "미리보기 없음", - "recent": { - "recentType": "SAVE", - "recentTypeId": 3 - } + "recentSaveId": 3 }, { "id": 2, @@ -85,10 +82,7 @@ "createdAt": "2025-07-24T23:08:32.066607+09:00", "updatedAt": "2025-07-24T23:08:32.088371+09:00", "preview": "미리보기 없음", - "recent": { - "recentType": "SAVE", - "recentTypeId": 2 - } + "recentSaveId": 2 }, { "id": 1, @@ -96,10 +90,7 @@ "createdAt": "2025-07-24T23:08:27.511586+09:00", "updatedAt": "2025-07-24T23:08:27.648299+09:00", "preview": "미리보기 없음", - "recent": { - "recentType": "SAVE", - "recentTypeId": 1 - } + "recentSaveId": 1 } ], "pageable": { diff --git a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java index c201da98..b2ae0874 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java @@ -1,13 +1,12 @@ package io.ejangs.docsa.domain.doc.util; -import io.ejangs.docsa.domain.branch.entity.Branch; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto; +import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; +import io.ejangs.docsa.domain.doc.dto.LatestSaveIdDto; import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.doc.thumbnail.dao.ThumbnailRepository; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; -import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -21,6 +20,7 @@ @RequiredArgsConstructor public class DocListAssembler { + private final BranchRepository branchRepository; private final ThumbnailRepository thumbnailRepository; @Value("${cloud.aws.s3.public-base-url}") @@ -31,6 +31,7 @@ public Page assembleDocList(Page docs) { .map(Doc::getId) .toList(); + Map latestSaveIdByDocId = latestSaveIdByDocId(docIds); Map thumbnailByDocId = thumbnailRepository .findAllByDocIdInWithCurrentImage(docIds) .stream() @@ -40,15 +41,13 @@ public Page assembleDocList(Page docs) { )); return docs.map(doc -> { - Branch recentBranch = getMostRecentBranch(doc); - RecentActivityDto recent = getRecentActivity(recentBranch); Thumbnail thumbnail = thumbnailByDocId.get(doc.getId()); return DocMapper.toListResponse( doc, buildThumbnailUrl(thumbnail), thumbnailStatusOf(thumbnail), - recent + latestSaveIdByDocId.get(doc.getId()) ); }); } @@ -70,28 +69,28 @@ private Thumbnail.ThumbnailStatus thumbnailStatusOf(Thumbnail thumbnail) { public Page assembleDocListSimple(Page docs) { - return docs - .map(doc -> { - Branch recentBranch = getMostRecentBranch(doc); - RecentActivityDto recent = getRecentActivity(recentBranch); - return DocMapper.toListSimpleResponse(doc, recent); - }); - } + List docIds = docs.getContent().stream() + .map(Doc::getId) + .toList(); + Map latestSaveIdByDocId = latestSaveIdByDocId(docIds); - private Branch getMostRecentBranch(Doc doc) { - return doc.getBranches().stream() - .max(Comparator.comparing(Branch::getUpdatedAt)) - .orElse(null); + return docs.map(doc -> DocMapper.toListSimpleResponse( + doc, + latestSaveIdByDocId.get(doc.getId()) + )); } - private RecentActivityDto getRecentActivity(Branch branch) { - if (branch.getSave() != null) { - return RecentActivityDto.from(branch.getSave()); - } - if (branch.getLeafCommit() != null) { - return RecentActivityDto.from(branch.getLeafCommit()); + private Map latestSaveIdByDocId(List docIds) { + if (docIds.isEmpty()) { + return Map.of(); } - return null; + + return branchRepository.findLatestSaveIdsByDocIds(docIds) + .stream() + .collect(Collectors.toMap( + LatestSaveIdDto::docId, + LatestSaveIdDto::saveId + )); } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/util/DocMapper.java b/src/main/java/io/ejangs/docsa/domain/doc/util/DocMapper.java index 8a9b0d83..6ddb8328 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/util/DocMapper.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/util/DocMapper.java @@ -1,6 +1,5 @@ package io.ejangs.docsa.domain.doc.util; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; @@ -23,19 +22,18 @@ public static DocTitleUpdateResponse toUpdateResponse(Doc doc) { ); } - public static DocSimplePageResponse toListSimpleResponse(Doc doc, RecentActivityDto recent) { + public static DocSimplePageResponse toListSimpleResponse(Doc doc, Long recentSaveId) { return new DocSimplePageResponse( doc.getId(), doc.getTitle(), doc.getCreatedAt(), doc.getUpdatedAt(), - recent + recentSaveId ); } public static DocPageResponse toListResponse(Doc doc, String thumbnailUrl, - ThumbnailStatus thumbnailStatus, - RecentActivityDto recent) { + ThumbnailStatus thumbnailStatus, Long recentSaveId) { return new DocPageResponse( doc.getId(), doc.getTitle(), @@ -43,7 +41,7 @@ public static DocPageResponse toListResponse(Doc doc, String thumbnailUrl, doc.getUpdatedAt(), thumbnailUrl, thumbnailStatus, - recent + recentSaveId ); } } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java index d0f6d550..3ba73dd4 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java @@ -17,7 +17,6 @@ import io.ejangs.docsa.domain.doc.app.create.DocCreateMySqlTxService; import io.ejangs.docsa.domain.doc.app.DocService; import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto.RecentType; import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; @@ -288,7 +287,7 @@ void documentCreateFailTestNotFoundUser() { } @Test - @DisplayName("사이드바 문서리스트 조회 - 최근 활동이 커밋 또는 저장 중 최신으로 설정됨") + @DisplayName("사이드바 문서리스트 조회 - 최근 저장 id가 설정됨") void getSimpleDocumentList() throws Exception { // given User user = userRepository.save(DocTestUtils.createUser()); @@ -309,13 +308,11 @@ void getSimpleDocumentList() throws Exception { DocSimplePageResponse first = results.get(0); // 최신 updatedAt 기준으로 정렬되었다고 가정 DocSimplePageResponse second = results.get(1); - // 저장이 없음 -> 최신 커밋 assertEquals("문서 1", first.title()); - assertEquals(RecentType.COMMIT, first.recent().recentType()); + assertThat(first.recentSaveId()).isNotNull(); - // 저장이 있음 assertEquals("문서 2", second.title()); - assertEquals(RecentType.SAVE, second.recent().recentType()); + assertThat(second.recentSaveId()).isNotNull(); } @Test @@ -340,12 +337,51 @@ void getDocListWithPreview() throws Exception { DocPageResponse second = results.getContent().get(1); assertEquals("문서 1", second.title()); - assertEquals(RecentType.COMMIT, second.recent().recentType()); assertEquals(ThumbnailStatus.EMPTY, second.thumbnailStatus()); + assertThat(second.recentSaveId()).isNotNull(); assertEquals("문서 2", first.title()); - assertEquals(RecentType.SAVE, first.recent().recentType()); assertEquals(ThumbnailStatus.EMPTY, first.thumbnailStatus()); + assertThat(first.recentSaveId()).isNotNull(); + } + + @Test + @DisplayName("문서 리스트 조회 - 최신 브랜치의 저장 id를 응답한다") + void getDocListReturnsLatestBranchSaveId() { + // given + User user = userRepository.save(DocTestUtils.createUser()); + Doc doc = Doc.builder() + .title("최신 저장 id 테스트") + .user(user) + .build(); + Branch firstBranch = Branch.builder() + .name(defaultBranchName) + .doc(doc) + .build(); + Save.builder() + .branch(firstBranch) + .saveMongoId("save-content-1") + .build(); + + Branch secondBranch = Branch.builder() + .name("feature") + .doc(doc) + .build(); + Save latestSave = Save.builder() + .branch(secondBranch) + .saveMongoId("save-content-2") + .build(); + + docRepository.saveAndFlush(doc); + + Pageable pageable = PageableFactory.create("updatedAt", "desc", 0, 10); + + // when + Page result = docService.getPage(user.getId(), pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().recentSaveId()).isEqualTo(latestSave.getId()); } @Test diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java index d7428316..dea251f6 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java @@ -14,8 +14,6 @@ import io.ejangs.docsa.domain.doc.api.DocController; import io.ejangs.docsa.domain.doc.app.DocService; import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto.RecentType; import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; import io.ejangs.docsa.domain.edge.dto.GraphResponse; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; @@ -101,14 +99,14 @@ void getSimpleDocList() throws Exception { "마이크로소프트", LocalDateTime.of(2025, 7, 6, 12, 0), LocalDateTime.of(2025, 7, 7, 9, 0), - new RecentActivityDto(RecentType.SAVE, 10L) + 10L ), new DocSimplePageResponse( 2L, "구글", LocalDateTime.of(2025, 6, 28, 15, 30), LocalDateTime.of(2025, 7, 1, 10, 45), - new RecentActivityDto(RecentType.COMMIT, 11L) + 11L ) ); @@ -127,11 +125,9 @@ void getSimpleDocList() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.content.length()").value(2)) .andExpect(jsonPath("$.content[0].title").value("마이크로소프트")) - .andExpect(jsonPath("$.content[0].recent.recentType").value("SAVE")) - .andExpect(jsonPath("$.content[0].recent.recentTypeId").value(10)) + .andExpect(jsonPath("$.content[0].recentSaveId").value(10)) .andExpect(jsonPath("$.content[1].title").value("구글")) - .andExpect(jsonPath("$.content[1].recent.recentType").value("COMMIT")) - .andExpect(jsonPath("$.content[1].recent.recentTypeId").value(11)) + .andExpect(jsonPath("$.content[1].recentSaveId").value(11)) .andDo(print()); } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java index b1d3312b..8844ebaf 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java @@ -17,8 +17,6 @@ import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; import io.ejangs.docsa.domain.edge.app.EdgeService; import io.ejangs.docsa.domain.edge.dao.mysql.EdgeRepository; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto.RecentType; import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; @@ -153,14 +151,14 @@ void getSimpleDocumentListSuccess() throws Exception { "테스트 문서 1", LocalDateTime.of(2025, 7, 16, 2, 0), LocalDateTime.of(2025, 7, 16, 2, 0), - new RecentActivityDto(RecentType.SAVE, 10L) + 10L ), new DocSimplePageResponse( 2L, "테스트 문서 2", LocalDateTime.of(2025, 7, 16, 3, 0), LocalDateTime.of(2025, 7, 16, 3, 0), - new RecentActivityDto(RecentType.COMMIT, 200L) + 200L ) ); Page dummyPage = new PageImpl<>(expectedResponses, pageable, @@ -177,12 +175,10 @@ void getSimpleDocumentListSuccess() throws Exception { assertEquals(2, result.size()); assertEquals("테스트 문서 1", result.get(0).title()); - assertEquals(RecentType.SAVE, result.get(0).recent().recentType()); - assertEquals(10L, result.get(0).recent().recentTypeId()); + assertEquals(10L, result.get(0).recentSaveId()); assertEquals("테스트 문서 2", result.get(1).title()); - assertEquals(RecentType.COMMIT, result.get(1).recent().recentType()); - assertEquals(200L, result.get(1).recent().recentTypeId()); + assertEquals(200L, result.get(1).recentSaveId()); verify(docQueryService).getPageByUserId(userId, pageable); verify(docListAssembler).assembleDocListSimple(docs); diff --git a/src/test/java/io/ejangs/docsa/domain/doc/util/DocTestUtils.java b/src/test/java/io/ejangs/docsa/domain/doc/util/DocTestUtils.java index f0d01669..1601226f 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/util/DocTestUtils.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/util/DocTestUtils.java @@ -9,8 +9,6 @@ import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.commit.document.CommitBlockSequence; import io.ejangs.docsa.domain.commit.entity.Commit; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto; -import io.ejangs.docsa.domain.doc.dto.RecentActivityDto.RecentType; import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; @@ -24,8 +22,6 @@ import java.util.Comparator; import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.stream.Stream; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; @@ -158,7 +154,16 @@ public static List createDocumentListForIntegrationTest(User user, .branch(branch1) .build(); + SaveContent doc1SaveContent = saveContentRepository.save(SaveContent.builder() + .content(List.of(parsedJson1.get(1))) + .build()); + Save doc1Save = Save.builder() + .branch(branch1) + .saveMongoId(doc1SaveContent.getId()) + .build(); + branch1.updateLeafCommit(commit2); + branch1.setSave(doc1Save); doc1.addBranch(branch1); docs.add(doc1); @@ -367,29 +372,14 @@ public static Page convertToDocListResponsePage(List docs, LocalDateTime createdAt = doc.getCreatedAt(); LocalDateTime updatedAt = doc.getUpdatedAt(); String thumbnailUrl = null; - - // 최근 활동 (SAVE > COMMIT 우선) - RecentActivityDto recent = doc.getBranches().stream() - .flatMap(branch -> { - Stream activityStream = Stream.of( - branch.getSave() != null - ? new RecentActivityDto(RecentType.SAVE, - branch.getSave().getId()) - : null, - branch.getLeafCommit() != null - ? new RecentActivityDto(RecentType.COMMIT, - branch.getLeafCommit().getId()) - : null - ); - return activityStream.filter(Objects::nonNull); - }) - .sorted(Comparator.comparing( - dto -> dto.recentType() == RecentType.SAVE ? 0 : 1)) - .findFirst() + Long recentSaveId = doc.getBranches().stream() + .max(Comparator.comparing(Branch::getUpdatedAt)) + .map(Branch::getSave) + .map(Save::getId) .orElse(null); return new DocPageResponse(docId, title, createdAt, updatedAt, thumbnailUrl, - ThumbnailStatus.EMPTY, recent); + ThumbnailStatus.EMPTY, recentSaveId); }) .toList(); From e9a186dfffaae25cef71073eb299e5c05684c78c Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Sat, 2 May 2026 01:28:40 +0900 Subject: [PATCH 02/28] =?UTF-8?q?refactor:=20EntityGraph=EB=A1=9C=20thumbn?= =?UTF-8?q?ail,=20thumbnail.currentImage=20=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docsa/domain/doc/dao/mysql/DocRepository.java | 3 +++ .../doc/thumbnail/dao/ThumbnailRepository.java | 9 --------- .../docsa/domain/doc/util/DocListAssembler.java | 13 +------------ 3 files changed, 4 insertions(+), 21 deletions(-) diff --git a/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java b/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java index 3c0ab5c5..1bd74047 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -17,8 +18,10 @@ public interface DocRepository extends JpaRepository { boolean existsByIdAndUserId(Long Id, Long userId); + @EntityGraph(attributePaths = {"thumbnail", "thumbnail.currentImage"}) Page findAllByUserId(Long userId, Pageable pageable); + @EntityGraph(attributePaths = {"thumbnail", "thumbnail.currentImage"}) @Query(""" SELECT d FROM Doc d diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java index a24865e3..72a0e97e 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java @@ -21,13 +21,4 @@ public interface ThumbnailRepository extends JpaRepository { where t.doc.id = :docId """) Optional findByDocIdForUpdate(@Param("docId") Long docId); - - - @Query(""" - select t - from Thumbnail t - left join fetch t.currentImage - where t.doc.id in :docIds - """) - List findAllByDocIdInWithCurrentImage(@Param("docIds") Collection docIds); } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java index b2ae0874..67d69a7f 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java @@ -9,7 +9,6 @@ import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; import java.util.List; import java.util.Map; -import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -21,7 +20,6 @@ public class DocListAssembler { private final BranchRepository branchRepository; - private final ThumbnailRepository thumbnailRepository; @Value("${cloud.aws.s3.public-base-url}") private String cdnUrl; @@ -32,16 +30,9 @@ public Page assembleDocList(Page docs) { .toList(); Map latestSaveIdByDocId = latestSaveIdByDocId(docIds); - Map thumbnailByDocId = thumbnailRepository - .findAllByDocIdInWithCurrentImage(docIds) - .stream() - .collect(Collectors.toMap( - thumbnail -> thumbnail.getDoc().getId(), - Function.identity() - )); return docs.map(doc -> { - Thumbnail thumbnail = thumbnailByDocId.get(doc.getId()); + Thumbnail thumbnail = doc.getThumbnail(); return DocMapper.toListResponse( doc, @@ -92,6 +83,4 @@ private Map latestSaveIdByDocId(List docIds) { LatestSaveIdDto::saveId )); } - - } From 004f77022cb822c5efe5bb53694e9265e8c41cb7 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Sat, 2 May 2026 03:57:50 +0900 Subject: [PATCH 03/28] =?UTF-8?q?refactor:=20Mongo=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?outbox=20originType=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20MongoDel?= =?UTF-8?q?eteOutboxFactory=EC=9D=98=20=EC=9C=A0=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=A9=94=EC=84=9C=EB=93=9C=ED=99=94?= =?UTF-8?q?=EB=A1=9C=20=ED=98=B8=EC=B6=9C=EB=B6=80=EC=97=90=EC=84=9C?= =?UTF-8?q?=EC=9D=98=20=EC=9D=98=EB=AF=B8=20=EB=93=A4=EC=96=B4=EB=82=98?= =?UTF-8?q?=EA=B2=8C=20=EB=B3=80=EA=B2=BD=20-=20flyway=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20sql=EB=AC=B8=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1(originType=20=EC=82=AD=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/branch/app/BranchService.java | 11 +--- .../app/create/BranchCreateOrchestrator.java | 11 +--- .../branch/merge/app/MergeOrchestrator.java | 13 +---- .../domain/commit/app/CommitService.java | 11 +--- .../app/create/CommitCreateOrchestrator.java | 11 +--- .../docsa/domain/doc/app/DocService.java | 11 +--- .../doc/app/create/DocCreateOrchestrator.java | 11 +--- .../event/entity/DomainEventOutbox.java | 4 ++ .../mongo/app/MongoDeleteOutboxFactory.java | 51 ++++++++++++++----- .../mysql/MongoDeleteOutboxRepository.java | 4 +- .../mongo/entity/MongoDeleteOutbox.java | 17 +------ ...__drop_mongo_delete_outbox_origin_type.sql | 8 +++ .../domain/branch/app/BranchServiceTest.java | 8 +-- ...ranchCreateConsistencyIntegrationTest.java | 2 - .../create/BranchCreateOrchestratorTest.java | 14 ++--- .../app/CommitCreateOrchestratorTest.java | 10 +--- .../app/CreateCommitIntegrationTest.java | 1 - .../DocServiceIntegrationTests.java | 2 +- .../doc/unit/DocCreateOrchestratorTest.java | 10 +--- .../app/MongoDeleteOutboxFactoryUnitTest.java | 14 ++--- .../app/MongoDeleteOutboxIntegrationTest.java | 23 ++------- .../MongoDeleteOutboxRaceIntegrationTest.java | 12 ++--- 22 files changed, 79 insertions(+), 180 deletions(-) create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/entity/DomainEventOutbox.java create mode 100644 src/main/resources/db/migration/V3__drop_mongo_delete_outbox_origin_type.sql diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java index d419599d..6a78f40a 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java @@ -17,9 +17,6 @@ import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import io.ejangs.docsa.global.outbox.mongo.util.MongoDeleteMapper; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; @@ -135,13 +132,7 @@ public void deleteBranch(Long documentId, Long branchId, Long userId) { // 8. 브랜치, 나머지 RDB 브랜치 메타데이터 CASCADE 삭제 branchQueryService.delete(branch); - mongoDeleteOutboxFactory.create( - TriggerType.DELETE, - DomainType.BRANCH, - OriginType.BRANCH_ID, - branchId, - deletableMongoIds - ); + mongoDeleteOutboxFactory.createBranchDelete(branchId, deletableMongoIds); } diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java index 3ebfe126..e4840313 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java @@ -5,9 +5,6 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -32,13 +29,7 @@ public BranchCreateResponse create(BranchCreateContext context) { } catch (Exception e) { log.warn("[SAGA] 브랜치/저장 생성 실패 -> Mongo 삭제 Outbox 기록.", e); MongoIdsDto compensateTarget = new MongoIdsDto(List.of(saveContentId), null, null); - mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.BRANCH, - OriginType.SAVE_CONTENT_ID, - saveContentId, - compensateTarget - ); + mongoDeleteOutboxFactory.createBranchCreateCompensation(saveContentId, compensateTarget); throw new CustomException(BranchErrorCode.FAIL_CREATE_BRANCH); } } diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java index 232dfe32..5eb6e8c7 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java @@ -1,16 +1,11 @@ package io.ejangs.docsa.domain.branch.merge.app; import io.ejangs.docsa.domain.branch.merge.app.MergeService.MergeContext; -import io.ejangs.docsa.domain.commit.entity.Commit; -import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.branch.merge.dto.request.MergeRequest; import io.ejangs.docsa.domain.branch.merge.dto.response.MergeResponse; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -41,13 +36,7 @@ public MergeResponse merge(MergeContext context, MergeRequest request) { null, null ); - mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.MERGE, - OriginType.SAVE_ID, - saveMongoId, - compensateMongoIds - ); + mongoDeleteOutboxFactory.createMergeCompensation(saveMongoId, compensateMongoIds); throw new CustomException(CommitErrorCode.FAIL_MERGE); } } diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java index 46f98244..87b9274a 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java @@ -14,9 +14,6 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; @@ -102,13 +99,7 @@ public void deleteCommit(Long docId, Long commitId, Long userId) { commitQueryService.deleteById(commit.getId()); - mongoDeleteOutboxFactory.create( - TriggerType.DELETE, - DomainType.COMMIT, - OriginType.COMMIT_ID, - commitId, - commitDeleteMongoIds - ); + mongoDeleteOutboxFactory.createCommitDelete(commitId, commitDeleteMongoIds); } private void checkFromOrMergeTargetCommit(Commit commit) { diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java index 25af5748..e6d6331b 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java @@ -7,9 +7,6 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,13 +31,7 @@ public Commit create(CreateCommitRequest request, String baseCommitCbsMongoId, D request, createdCbsId); } catch (Exception e) { log.warn("[SAGA] 커밋 생성 실패 -> Mongo 삭제 Outbox 기록. ", e); - mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.COMMIT, - OriginType.CBS_ID, - createdCbsId, - compensateTarget - ); + mongoDeleteOutboxFactory.createCommitCreateCompensation(createdCbsId, compensateTarget); throw new CustomException(CommitErrorCode.FAIL_CREATE_COMMIT); } } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java index 0b739562..0b896524 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java @@ -25,9 +25,6 @@ import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; import java.util.List; @@ -126,13 +123,7 @@ public void delete(Long docId, Long userId) { user.removeDocument(doc); - mongoDeleteOutboxFactory.create( - TriggerType.DELETE, - DomainType.DOC, - OriginType.DOC_ID, - docId, - docDeleteMongoIds - ); + mongoDeleteOutboxFactory.createDocDelete(docId, docDeleteMongoIds); } } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java index b5bc52c9..b83933b5 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java @@ -7,9 +7,6 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import java.util.List; import lombok.RequiredArgsConstructor; @@ -43,12 +40,6 @@ public DocCreateResponse create(String title, User user) { private void compensateMongo(String saveContentId) { MongoIdsDto dto = new MongoIdsDto(List.of(saveContentId), null, null); - mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.DOC, - OriginType.SAVE_CONTENT_ID, - saveContentId, - dto - ); + mongoDeleteOutboxFactory.createDocCreateCompensation(saveContentId, dto); } } diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/entity/DomainEventOutbox.java b/src/main/java/io/ejangs/docsa/global/outbox/event/entity/DomainEventOutbox.java new file mode 100644 index 00000000..f9eef3b6 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/entity/DomainEventOutbox.java @@ -0,0 +1,4 @@ +package io.ejangs.docsa.global.outbox.event.entity; + +public class DomainEventOutbox { +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java index 90a7e42c..62d264de 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java @@ -6,7 +6,6 @@ import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import java.util.Objects; import lombok.RequiredArgsConstructor; @@ -20,10 +19,43 @@ public class MongoDeleteOutboxFactory { private final MongoDeleteOutboxCreateService mongoDeleteOutboxCreateService; private final MongoDeleteOutboxRepository mongoDeleteOutboxRepository; - public MongoDeleteOutbox create( + /* + * originId는 outbox 중복 생성을 막기 위한 기준 ID다. + * 실제 값의 의미는 각 생성 메서드의 파라미터 이름으로 표현한다. + * - 삭제 outbox: 삭제 요청의 기준이 된 MySQL id + * - 보상 outbox: Mongo에 먼저 생성된 데이터의 id + */ + public MongoDeleteOutbox createDocDelete(Long docId, MongoIdsDto ids) { + return create(TriggerType.DELETE, DomainType.DOC, docId, ids); + } + + public MongoDeleteOutbox createBranchDelete(Long branchId, MongoIdsDto ids) { + return create(TriggerType.DELETE, DomainType.BRANCH, branchId, ids); + } + + public MongoDeleteOutbox createCommitDelete(Long commitId, MongoIdsDto ids) { + return create(TriggerType.DELETE, DomainType.COMMIT, commitId, ids); + } + + public MongoDeleteOutbox createDocCreateCompensation(String saveContentId, MongoIdsDto ids) { + return create(TriggerType.COMPENSATE, DomainType.DOC, saveContentId, ids); + } + + public MongoDeleteOutbox createBranchCreateCompensation(String saveContentId, MongoIdsDto ids) { + return create(TriggerType.COMPENSATE, DomainType.BRANCH, saveContentId, ids); + } + + public MongoDeleteOutbox createCommitCreateCompensation(String commitBlockSequenceId, MongoIdsDto ids) { + return create(TriggerType.COMPENSATE, DomainType.COMMIT, commitBlockSequenceId, ids); + } + + public MongoDeleteOutbox createMergeCompensation(String saveMongoId, MongoIdsDto ids) { + return create(TriggerType.COMPENSATE, DomainType.MERGE, saveMongoId, ids); + } + + private MongoDeleteOutbox create( TriggerType triggerType, DomainType domainType, - OriginType originType, Long originId, MongoIdsDto ids ) { @@ -31,20 +63,18 @@ public MongoDeleteOutbox create( if (originId <= 0) { throw new IllegalArgumentException("originId must be positive"); } - return create(triggerType, domainType, originType, String.valueOf(originId), ids); + return create(triggerType, domainType, String.valueOf(originId), ids); } - public MongoDeleteOutbox create( + private MongoDeleteOutbox create( TriggerType triggerType, DomainType domainType, - OriginType originType, String originId, MongoIdsDto ids ) { Objects.requireNonNull(triggerType, "triggerType is required"); Objects.requireNonNull(domainType, "domainType is required"); - Objects.requireNonNull(originType, "originType is required"); Objects.requireNonNull(ids, "mongo ids is required"); if (ids.saveContentsIds().isEmpty() @@ -59,10 +89,9 @@ public MongoDeleteOutbox create( } MongoDeleteOutbox existing = mongoDeleteOutboxRepository - .findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId( + .findByTriggerTypeAndDomainTypeAndOriginId( triggerType, domainType, - originType, normalizedOriginId ) .orElse(null); @@ -73,7 +102,6 @@ public MongoDeleteOutbox create( MongoDeleteOutbox newOutbox = MongoDeleteOutbox.open( triggerType, domainType, - originType, normalizedOriginId, ids.saveContentsIds(), ids.commitBlockSequenceIds(), @@ -84,10 +112,9 @@ public MongoDeleteOutbox create( mongoDeleteOutboxCreateService.tryCreate(newOutbox); } catch (DataIntegrityViolationException e) { return mongoDeleteOutboxRepository - .findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId( + .findByTriggerTypeAndDomainTypeAndOriginId( triggerType, domainType, - originType, normalizedOriginId ) .orElseThrow(() -> new CustomException(DatabaseErrorCode.DATABASE_ERROR)); diff --git a/src/main/java/io/ejangs/docsa/global/outbox/mongo/dao/mysql/MongoDeleteOutboxRepository.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/dao/mysql/MongoDeleteOutboxRepository.java index d81c1bd4..29651b72 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/mongo/dao/mysql/MongoDeleteOutboxRepository.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/dao/mysql/MongoDeleteOutboxRepository.java @@ -3,7 +3,6 @@ import io.ejangs.docsa.global.outbox.OutboxStatus; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import java.time.LocalDateTime; import java.util.List; @@ -35,10 +34,9 @@ List findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( """, nativeQuery = true) int claimOpenById(@Param("outboxId") Long outboxId); - Optional findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId( + Optional findByTriggerTypeAndDomainTypeAndOriginId( TriggerType triggerType, DomainType domainType, - OriginType originType, String originId ); } diff --git a/src/main/java/io/ejangs/docsa/global/outbox/mongo/entity/MongoDeleteOutbox.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/entity/MongoDeleteOutbox.java index fad8a65f..d3c37210 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/mongo/entity/MongoDeleteOutbox.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/entity/MongoDeleteOutbox.java @@ -25,7 +25,7 @@ uniqueConstraints = { @UniqueConstraint( name = "uk_mongo_delete_outbox_trigger_domain_origin", - columnNames = {"trigger_type", "domain_type", "origin_type", "origin_id"} + columnNames = {"trigger_type", "domain_type", "origin_id"} ) }, indexes = { @@ -50,15 +50,6 @@ public enum DomainType { SAVE } - public enum OriginType { - DOC_ID, - BRANCH_ID, - COMMIT_ID, - SAVE_ID, - SAVE_CONTENT_ID, - CBS_ID - } - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -71,10 +62,6 @@ public enum OriginType { @Column(name = "domain_type", length = 64, nullable = false) private DomainType domainType; - @Enumerated(EnumType.STRING) - @Column(name = "origin_type", length = 64, nullable = false) - private OriginType originType; - @Column(name = "origin_id", length = 255, nullable = false) private String originId; @@ -96,7 +83,6 @@ public enum OriginType { public static MongoDeleteOutbox open( TriggerType triggerType, DomainType domainType, - OriginType originType, String originId, List saveIds, List commitIds, @@ -106,7 +92,6 @@ public static MongoDeleteOutbox open( MongoDeleteOutbox outbox = new MongoDeleteOutbox(); outbox.triggerType = triggerType; outbox.domainType = domainType; - outbox.originType = originType; outbox.originId = originId; outbox.saveContentIds = saveIds; outbox.commitBlockSequenceIds = commitIds; diff --git a/src/main/resources/db/migration/V3__drop_mongo_delete_outbox_origin_type.sql b/src/main/resources/db/migration/V3__drop_mongo_delete_outbox_origin_type.sql new file mode 100644 index 00000000..11d3c8e1 --- /dev/null +++ b/src/main/resources/db/migration/V3__drop_mongo_delete_outbox_origin_type.sql @@ -0,0 +1,8 @@ +ALTER TABLE mongo_delete_outbox + DROP INDEX uk_mongo_delete_outbox_trigger_domain_origin; + +ALTER TABLE mongo_delete_outbox + DROP COLUMN origin_type; + +ALTER TABLE mongo_delete_outbox + ADD UNIQUE KEY uk_mongo_delete_outbox_trigger_domain_origin (trigger_type, domain_type, origin_id); diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java index db23d3be..d5a17533 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java @@ -18,9 +18,6 @@ import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -282,10 +279,7 @@ void deleteBranch_success() { // Outbox 적재 검증 ArgumentCaptor captor = ArgumentCaptor.forClass(MongoIdsDto.class); - verify(mongoDeleteOutboxFactory).create( - eq(TriggerType.DELETE), - eq(DomainType.BRANCH), - eq(OriginType.BRANCH_ID), + verify(mongoDeleteOutboxFactory).createBranchDelete( eq(branchId), captor.capture() ); diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateConsistencyIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateConsistencyIntegrationTest.java index 7917438d..860e11fd 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateConsistencyIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateConsistencyIntegrationTest.java @@ -30,7 +30,6 @@ import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import java.util.HashSet; import java.util.List; @@ -180,7 +179,6 @@ void createBranch_mysqlFailure_createsCompensateOutboxAndKeepsSaveContent() { assertThat(outbox.getTriggerType()).isEqualTo(TriggerType.COMPENSATE); assertThat(outbox.getDomainType()).isEqualTo(DomainType.BRANCH); - assertThat(outbox.getOriginType()).isEqualTo(OriginType.SAVE_CONTENT_ID); assertThat(outbox.getOriginId()).isEqualTo(persistedSaveContentId); assertThat(loadOutboxSaveContentIds(outbox.getId())).containsExactly(persistedSaveContentId); } diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java index 092ba87a..62a672f8 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java @@ -17,9 +17,6 @@ import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -56,8 +53,7 @@ void create_success() { BranchCreateResponse result = orchestrator.create(context); assertThat(result).isEqualTo(expected); - verify(mongoDeleteOutboxFactory, never()).create(any(), any(), any(), any(String.class), - any()); + verify(mongoDeleteOutboxFactory, never()).createBranchCreateCompensation(any(String.class), any()); } @Test @@ -74,10 +70,7 @@ void create_fail_whenMySqlFails_thenCompensateMongo() { .isInstanceOf(CustomException.class) .hasMessage(BranchErrorCode.FAIL_CREATE_BRANCH.getMessage()); - verify(mongoDeleteOutboxFactory).create( - eq(TriggerType.COMPENSATE), - eq(DomainType.BRANCH), - eq(OriginType.SAVE_CONTENT_ID), + verify(mongoDeleteOutboxFactory).createBranchCreateCompensation( eq("save-content-1"), eq(new MongoIdsDto(java.util.List.of("save-content-1"), null, null)) ); @@ -96,8 +89,7 @@ void create_fail_whenMongoFails_thenDoNotTouchMySqlOrOutbox() { .hasMessage("mongo fail"); verify(branchCreateMySqlTxService, never()).createMySqlPart(any(), any()); - verify(mongoDeleteOutboxFactory, never()).create(any(), any(), any(), any(String.class), - any()); + verify(mongoDeleteOutboxFactory, never()).createBranchCreateCompensation(any(String.class), any()); } private BranchCreateContext createContext(String fromCommitMongoId) { diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java index c0e3ca37..f966a79e 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java @@ -17,9 +17,6 @@ import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import java.util.Collections; @@ -60,7 +57,7 @@ void create_success() { Commit result = orchestrator.create(request, "base-cbs", doc, branch); assertThat(result).isEqualTo(commit); - verify(mongoDeleteOutboxFactory, never()).create(any(), any(), any(), any(String.class), any()); + verify(mongoDeleteOutboxFactory, never()).createCommitCreateCompensation(any(String.class), any()); } @Test @@ -79,10 +76,7 @@ void create_fail_compensateMongo() { .isInstanceOf(CustomException.class) .hasMessage(CommitErrorCode.FAIL_CREATE_COMMIT.getMessage()); - verify(mongoDeleteOutboxFactory).create( - eq(TriggerType.COMPENSATE), - eq(DomainType.COMMIT), - eq(OriginType.CBS_ID), + verify(mongoDeleteOutboxFactory).createCommitCreateCompensation( eq("cbs-1"), eq(ids) ); diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java index 1375ffa7..1e74bed4 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java @@ -240,7 +240,6 @@ void mysqlFail_compensateMongoDelete() { .anySatisfy(outbox -> { assertThat(outbox.getTriggerType()).isEqualTo(MongoDeleteOutbox.TriggerType.COMPENSATE); assertThat(outbox.getDomainType()).isEqualTo(MongoDeleteOutbox.DomainType.COMMIT); - assertThat(outbox.getOriginType()).isEqualTo(MongoDeleteOutbox.OriginType.CBS_ID); assertThat(outbox.getStatus()).isEqualTo(OutboxStatus.OPEN); assertThat(outbox.getOriginId()).isNotBlank(); }); diff --git a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java index 3ba73dd4..9b7c295e 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java @@ -212,7 +212,7 @@ void mysqlFail_createCompensateOutbox() { MongoDeleteOutbox outbox = outboxes.getFirst(); assertThat(outbox.getTriggerType()).isEqualTo(MongoDeleteOutbox.TriggerType.COMPENSATE); assertThat(outbox.getDomainType()).isEqualTo(MongoDeleteOutbox.DomainType.DOC); - assertThat(outbox.getOriginType()).isEqualTo(MongoDeleteOutbox.OriginType.SAVE_CONTENT_ID); + assertThat(outbox.getOriginId()).isNotBlank(); assertThat(outbox.getStatus()).isEqualTo(OutboxStatus.OPEN); } } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java index 46d107fa..fc659366 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java @@ -18,9 +18,6 @@ import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -59,7 +56,7 @@ void create_success() { DocCreateResponse result = orchestrator.create("doc", user); assertThat(result).isEqualTo(expected); - verify(mongoDeleteOutboxFactory, never()).create(any(), any(), any(), anyString(), any()); + verify(mongoDeleteOutboxFactory, never()).createDocCreateCompensation(anyString(), any()); } @Test @@ -77,10 +74,7 @@ void create_fail_compensateMongo() { .isInstanceOf(CustomException.class) .hasMessage(DocErrorCode.FAIL_CREATE_DOCUMENT.getMessage()); - verify(mongoDeleteOutboxFactory).create( - eq(TriggerType.COMPENSATE), - eq(DomainType.DOC), - eq(OriginType.SAVE_CONTENT_ID), + verify(mongoDeleteOutboxFactory).createDocCreateCompensation( eq("save-1"), argThat(ids -> ids.saveContentsIds().contains("save-1") diff --git a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java index b8c05f94..d21617b7 100644 --- a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java @@ -11,7 +11,6 @@ import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -40,14 +39,12 @@ class MongoDeleteOutboxFactoryUnitTest { void createReturnsExistingOutboxWhenUniqueConflictOccurs() { TriggerType triggerType = TriggerType.COMPENSATE; DomainType domainType = DomainType.DOC; - OriginType originType = OriginType.DOC_ID; String originId = "race-origin-1"; MongoIdsDto ids = new MongoIdsDto(List.of("save-1"), List.of(), List.of()); MongoDeleteOutbox existing = MongoDeleteOutbox.open( triggerType, domainType, - originType, originId, ids.saveContentsIds(), ids.commitBlockSequenceIds(), @@ -55,30 +52,25 @@ void createReturnsExistingOutboxWhenUniqueConflictOccurs() { ); ReflectionTestUtils.setField(existing, "id", 99L); - when(mongoDeleteOutboxRepository.findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId( + when(mongoDeleteOutboxRepository.findByTriggerTypeAndDomainTypeAndOriginId( triggerType, domainType, - originType, originId )).thenReturn(java.util.Optional.empty(), java.util.Optional.of(existing)); doThrow(new DataIntegrityViolationException("duplicate key")) .when(mongoDeleteOutboxCreateService) .tryCreate(any(MongoDeleteOutbox.class)); - MongoDeleteOutbox result = mongoDeleteOutboxFactory.create( - triggerType, - domainType, - originType, + MongoDeleteOutbox result = mongoDeleteOutboxFactory.createDocCreateCompensation( originId, ids ); assertThat(result.getId()).isEqualTo(99L); verify(mongoDeleteOutboxRepository, times(2)) - .findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId( + .findByTriggerTypeAndDomainTypeAndOriginId( triggerType, domainType, - originType, originId ); verify(mongoDeleteOutboxCreateService).tryCreate(any(MongoDeleteOutbox.class)); diff --git a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java index a8bd4b0b..a4815bb0 100644 --- a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java @@ -7,9 +7,6 @@ import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import io.ejangs.docsa.global.outbox.OutboxStatus; import java.time.LocalDateTime; import java.util.List; @@ -101,17 +98,11 @@ void workerFailedAfterMaxRetry() { @DisplayName("동일 키 create 중복 호출 시 outbox는 1건만 유지된다") void factoryDedupeWithSameKey() { String originId = "dup-" + UUID.randomUUID(); - MongoDeleteOutbox first = mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.DOC, - OriginType.DOC_ID, + MongoDeleteOutbox first = mongoDeleteOutboxFactory.createDocCreateCompensation( originId, new MongoIdsDto(List.of("save-" + originId), List.of(), List.of()) ); - MongoDeleteOutbox second = mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.DOC, - OriginType.DOC_ID, + MongoDeleteOutbox second = mongoDeleteOutboxFactory.createDocCreateCompensation( originId, new MongoIdsDto(List.of("save-" + originId), List.of(), List.of()) ); @@ -171,10 +162,7 @@ void completeServiceNoOpOnStatusMismatch() { void workerProcessesAtMost100OpenRowsPerRun() { for (int i = 0; i < 101; i++) { String originId = "batch-" + i + "-" + UUID.randomUUID(); - mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.DOC, - OriginType.DOC_ID, + mongoDeleteOutboxFactory.createDocCreateCompensation( originId, new MongoIdsDto(List.of("save-" + originId), List.of(), List.of()) ); @@ -199,10 +187,7 @@ void workerProcessesAtMost100OpenRowsPerRun() { private MongoDeleteOutbox createOpenOutbox() { String originId = UUID.randomUUID().toString(); - return mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.DOC, - OriginType.DOC_ID, + return mongoDeleteOutboxFactory.createDocCreateCompensation( originId, new MongoIdsDto(List.of("save-" + originId), List.of(), List.of()) ); diff --git a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java index 828a5b41..9f34708a 100644 --- a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java @@ -5,9 +5,6 @@ import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.DomainType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.OriginType; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox.TriggerType; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -60,10 +57,7 @@ void concurrentCreateRace() throws Exception { ExecutorService executor = Executors.newFixedThreadPool(2); Callable task = () -> { try { - MongoDeleteOutbox outbox = mongoDeleteOutboxFactory.create( - TriggerType.COMPENSATE, - DomainType.DOC, - OriginType.DOC_ID, + MongoDeleteOutbox outbox = mongoDeleteOutboxFactory.createDocCreateCompensation( originId, ids ); @@ -119,11 +113,11 @@ static void clear() { RaceBarrierAspect.barrier = null; } - @Around("execution(* io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository.findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId(..))") + @Around("execution(* io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository.findByTriggerTypeAndDomainTypeAndOriginId(..))") Object awaitAfterEmptyLookup(ProceedingJoinPoint joinPoint) throws Throwable { @SuppressWarnings("unchecked") Optional result = (Optional) joinPoint.proceed(); - String originIdArg = (String) joinPoint.getArgs()[3]; + String originIdArg = (String) joinPoint.getArgs()[2]; CyclicBarrier currentBarrier = barrier; if (currentBarrier != null && originIdArg.equals(armedOriginId) && result.isEmpty()) { currentBarrier.await(5, TimeUnit.SECONDS); From 18bd5232614a3595418ef304914a7a22e7744ecd Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Sun, 3 May 2026 01:55:27 +0900 Subject: [PATCH 04/28] =?UTF-8?q?refactor:=20Mongo/S3=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20Outbox=20=EA=B4=80=EB=A0=A8=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=EC=97=AD=ED=95=A0=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=A0=95=EB=A6=AC=20=20-=20Factory=20->=20JobEnque?= =?UTF-8?q?uer=20=20-=20DeleteService=20->=20DeleteExecutor=20=20-=20Mongo?= =?UTF-8?q?DeleteOutboxCreateService=EB=A5=BC=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20JobEnqueuer=EC=97=90=EC=84=9C=20saveAndFlu?= =?UTF-8?q?sh=EB=A5=BC=20=EC=A7=81=EC=A0=91=20=ED=98=B8=EC=B6=9C=20=20-=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EB=90=9C=20=EC=9D=B4=EB=A6=84=EA=B3=BC=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=B6=A9=EB=8F=8C=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/branch/app/BranchService.java | 6 +-- .../app/create/BranchCreateOrchestrator.java | 6 +-- .../branch/merge/app/MergeOrchestrator.java | 6 +-- .../domain/commit/app/CommitService.java | 6 +-- .../app/create/CommitCreateOrchestrator.java | 6 +-- .../app/create/CommitMySqlTxService.java | 4 +- .../docsa/domain/doc/app/DocService.java | 6 +-- .../doc/app/create/DocCreateOrchestrator.java | 6 +-- .../doc/thumbnail/app/ThumbnailService.java | 6 +-- ...eService.java => MongoDeleteExecutor.java} | 2 +- ...ctory.java => MongoDeleteJobEnqueuer.java} | 39 +++++++++---------- .../app/MongoDeleteOutboxCreateService.java | 19 --------- .../mongo/app/MongoDeleteOutboxWorker.java | 4 +- ...leteService.java => S3DeleteExecutor.java} | 2 +- ...xFactory.java => S3DeleteJobEnqueuer.java} | 3 +- .../outbox/s3/app/S3DeleteOutboxWorker.java | 4 +- .../domain/branch/app/BranchServiceTest.java | 6 +-- .../create/BranchCreateOrchestratorTest.java | 10 ++--- .../app/CommitCreateOrchestratorTest.java | 8 ++-- .../app/ThumbnailServiceUnitTest.java | 9 ++--- .../doc/unit/DocCreateOrchestratorTest.java | 8 ++-- ...va => MongoDeleteJobEnqueuerUnitTest.java} | 17 ++++---- .../app/MongoDeleteOutboxIntegrationTest.java | 16 ++++---- .../MongoDeleteOutboxRaceIntegrationTest.java | 4 +- ...est.java => S3DeleteExecutorUnitTest.java} | 14 +++---- 25 files changed, 96 insertions(+), 121 deletions(-) rename src/main/java/io/ejangs/docsa/global/outbox/mongo/app/{MongoDeleteService.java => MongoDeleteExecutor.java} (96%) rename src/main/java/io/ejangs/docsa/global/outbox/mongo/app/{MongoDeleteOutboxFactory.java => MongoDeleteJobEnqueuer.java} (68%) delete mode 100644 src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxCreateService.java rename src/main/java/io/ejangs/docsa/global/outbox/s3/app/{S3DeleteService.java => S3DeleteExecutor.java} (96%) rename src/main/java/io/ejangs/docsa/global/outbox/s3/app/{S3DeleteOutboxFactory.java => S3DeleteJobEnqueuer.java} (88%) rename src/test/java/io/ejangs/docsa/global/outbox/mongo/app/{MongoDeleteOutboxFactoryUnitTest.java => MongoDeleteJobEnqueuerUnitTest.java} (83%) rename src/test/java/io/ejangs/docsa/global/outbox/s3/app/{S3DeleteServiceUnitTest.java => S3DeleteExecutorUnitTest.java} (87%) diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java index 6a78f40a..514a4a6e 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java @@ -17,7 +17,7 @@ import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.mongo.util.MongoDeleteMapper; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; @@ -40,7 +40,7 @@ public class BranchService { private final CommitBlockSequenceRepository commitBlockSequenceRepository; private final EdgeService edgeService; private final BranchCreateOrchestrator branchCreateOrchestrator; - private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; public BranchCreateResponse createBranch(Long documentId, BranchCreateRequest request, Long userId) { @@ -132,7 +132,7 @@ public void deleteBranch(Long documentId, Long branchId, Long userId) { // 8. 브랜치, 나머지 RDB 브랜치 메타데이터 CASCADE 삭제 branchQueryService.delete(branch); - mongoDeleteOutboxFactory.createBranchDelete(branchId, deletableMongoIds); + mongoDeleteJobEnqueuer.enqueueBranchDeletion(branchId, deletableMongoIds); } diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java index e4840313..2ba6b6c0 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestrator.java @@ -5,7 +5,7 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,7 +18,7 @@ public class BranchCreateOrchestrator { private final BranchCreateMongoTxService branchCreateMongoTxService; private final BranchCreateMySqlTxService branchCreateMySqlTxService; - private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; public BranchCreateResponse create(BranchCreateContext context) { String saveContentId = branchCreateMongoTxService.createSaveContentFromCommit( @@ -29,7 +29,7 @@ public BranchCreateResponse create(BranchCreateContext context) { } catch (Exception e) { log.warn("[SAGA] 브랜치/저장 생성 실패 -> Mongo 삭제 Outbox 기록.", e); MongoIdsDto compensateTarget = new MongoIdsDto(List.of(saveContentId), null, null); - mongoDeleteOutboxFactory.createBranchCreateCompensation(saveContentId, compensateTarget); + mongoDeleteJobEnqueuer.enqueueBranchCreateCompensation(saveContentId, compensateTarget); throw new CustomException(BranchErrorCode.FAIL_CREATE_BRANCH); } } diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java index 5eb6e8c7..dbe80fda 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeOrchestrator.java @@ -6,7 +6,7 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -19,7 +19,7 @@ public class MergeOrchestrator { private final MergeMongoTxService mergeMongoTxService; private final MergeMySqlTxService mergeMySqlTxService; - private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; public MergeResponse merge(MergeContext context, MergeRequest request) { String saveMongoId = mergeMongoTxService.createMongoPart(request.content()); @@ -36,7 +36,7 @@ public MergeResponse merge(MergeContext context, MergeRequest request) { null, null ); - mongoDeleteOutboxFactory.createMergeCompensation(saveMongoId, compensateMongoIds); + mongoDeleteJobEnqueuer.enqueueMergeCompensation(saveMongoId, compensateMongoIds); throw new CustomException(CommitErrorCode.FAIL_MERGE); } } diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java index 87b9274a..989a5bad 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java @@ -14,7 +14,7 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import java.util.List; @@ -37,7 +37,7 @@ public class CommitService { private final CommitCreateOrchestrator commitCreateOrchestrator; private final CommitContentAssembler assembler; private final MongoIdsCollector mongoIdsCollector; - private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; public CreateCommitResponse createCommit(Long docId, CreateCommitRequest request, @@ -99,7 +99,7 @@ public void deleteCommit(Long docId, Long commitId, Long userId) { commitQueryService.deleteById(commit.getId()); - mongoDeleteOutboxFactory.createCommitDelete(commitId, commitDeleteMongoIds); + mongoDeleteJobEnqueuer.enqueueCommitDeletion(commitId, commitDeleteMongoIds); } private void checkFromOrMergeTargetCommit(Commit commit) { diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java index e6d6331b..3173ea18 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitCreateOrchestrator.java @@ -7,7 +7,7 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,7 +19,7 @@ public class CommitCreateOrchestrator { private final CommitMySqlTxService commitMySqlTxService; private final CommitMongoTxService commitMongoTxService; - private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; public Commit create(CreateCommitRequest request, String baseCommitCbsMongoId, Doc doc, Branch branch) { MongoIdsDto compensateTarget = commitMongoTxService.createMongoPart(request, baseCommitCbsMongoId); @@ -31,7 +31,7 @@ public Commit create(CreateCommitRequest request, String baseCommitCbsMongoId, D request, createdCbsId); } catch (Exception e) { log.warn("[SAGA] 커밋 생성 실패 -> Mongo 삭제 Outbox 기록. ", e); - mongoDeleteOutboxFactory.createCommitCreateCompensation(createdCbsId, compensateTarget); + mongoDeleteJobEnqueuer.enqueueCommitCreateCompensation(createdCbsId, compensateTarget); throw new CustomException(CommitErrorCode.FAIL_CREATE_COMMIT); } } diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java index 08b4aae4..832bf5de 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java @@ -10,7 +10,7 @@ import io.ejangs.docsa.domain.edge.entity.Edge; import io.ejangs.docsa.domain.edge.util.EdgeMapper; import io.ejangs.docsa.domain.save.app.SaveQueryService; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -24,7 +24,7 @@ public class CommitMySqlTxService { private final CommitQueryService commitQueryService; private final SaveQueryService saveQueryService; private final EdgeService edgeService; - private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; @Transactional(rollbackFor = Exception.class) public Commit createMySqlPart(Doc doc, Branch branch, CreateCommitRequest request, String commitCbsMongoId) { diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java index 0b896524..63ec66b2 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java @@ -25,7 +25,7 @@ import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; import java.util.List; import lombok.RequiredArgsConstructor; @@ -47,7 +47,7 @@ public class DocService { private final EdgeService edgeService; private final DocCreateOrchestrator docCreateOrchestrator; - private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; private final DocListAssembler docListAssembler; private final MongoIdsCollector mongoIdsCollector; @@ -123,7 +123,7 @@ public void delete(Long docId, Long userId) { user.removeDocument(doc); - mongoDeleteOutboxFactory.createDocDelete(docId, docDeleteMongoIds); + mongoDeleteJobEnqueuer.enqueueDocDeletion(docId, docDeleteMongoIds); } } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java index b83933b5..26063e7d 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java @@ -7,7 +7,7 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,7 +20,7 @@ public class DocCreateOrchestrator { private final SaveQueryService saveQueryService; private final DocCreateMySqlTxService docCreateMySqlTxService; - private final MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; public DocCreateResponse create(String title, User user) { // 문서 생성에 경우 SaveContent 1개의 문서만 insert하여 단일 문서 트랜잭션은 보장되어 별도의 트랜잭션 처리 필요없음. @@ -40,6 +40,6 @@ public DocCreateResponse create(String title, User user) { private void compensateMongo(String saveContentId) { MongoIdsDto dto = new MongoIdsDto(List.of(saveContentId), null, null); - mongoDeleteOutboxFactory.createDocCreateCompensation(saveContentId, dto); + mongoDeleteJobEnqueuer.enqueueDocCreateCompensation(saveContentId, dto); } } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java index 15c79843..77dc0acf 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java @@ -7,7 +7,7 @@ import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; import io.ejangs.docsa.domain.image.app.ImageQueryService; import io.ejangs.docsa.domain.image.entity.Image; -import io.ejangs.docsa.global.outbox.s3.app.S3DeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.s3.app.S3DeleteJobEnqueuer; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.ImageErrorCode; import io.ejangs.docsa.global.exception.errorcode.ThumbnailErrorCode; @@ -23,7 +23,7 @@ public class ThumbnailService { private final ThumbnailQueryService thumbnailQueryService; private final DocQueryService docQueryService; private final ImageQueryService imageQueryService; - private final S3DeleteOutboxFactory s3DeleteOutboxFactory; + private final S3DeleteJobEnqueuer s3DeleteJobEnqueuer; @Value("${cloud.aws.s3.public-base-url}") private String cdnUrl; @@ -94,6 +94,6 @@ private void enqueuePreviousThumbnailDeletion(Image previousImage, Image current return; } - s3DeleteOutboxFactory.enqueueImageDeletion(previousImage); + s3DeleteJobEnqueuer.enqueueImageDeletion(previousImage); } } diff --git a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteService.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteExecutor.java similarity index 96% rename from src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteService.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteExecutor.java index fe401b31..cb70dfab 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteService.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteExecutor.java @@ -10,7 +10,7 @@ @Service @RequiredArgsConstructor -public class MongoDeleteService { +public class MongoDeleteExecutor { private final SaveContentRepository saveContentRepository; private final CommitBlockSequenceRepository commitBlockSequenceRepository; diff --git a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteJobEnqueuer.java similarity index 68% rename from src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java rename to src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteJobEnqueuer.java index 62d264de..d02fb812 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactory.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteJobEnqueuer.java @@ -14,9 +14,8 @@ @Component @RequiredArgsConstructor -public class MongoDeleteOutboxFactory { +public class MongoDeleteJobEnqueuer { - private final MongoDeleteOutboxCreateService mongoDeleteOutboxCreateService; private final MongoDeleteOutboxRepository mongoDeleteOutboxRepository; /* @@ -25,35 +24,35 @@ public class MongoDeleteOutboxFactory { * - 삭제 outbox: 삭제 요청의 기준이 된 MySQL id * - 보상 outbox: Mongo에 먼저 생성된 데이터의 id */ - public MongoDeleteOutbox createDocDelete(Long docId, MongoIdsDto ids) { - return create(TriggerType.DELETE, DomainType.DOC, docId, ids); + public MongoDeleteOutbox enqueueDocDeletion(Long docId, MongoIdsDto ids) { + return enqueue(TriggerType.DELETE, DomainType.DOC, docId, ids); } - public MongoDeleteOutbox createBranchDelete(Long branchId, MongoIdsDto ids) { - return create(TriggerType.DELETE, DomainType.BRANCH, branchId, ids); + public MongoDeleteOutbox enqueueBranchDeletion(Long branchId, MongoIdsDto ids) { + return enqueue(TriggerType.DELETE, DomainType.BRANCH, branchId, ids); } - public MongoDeleteOutbox createCommitDelete(Long commitId, MongoIdsDto ids) { - return create(TriggerType.DELETE, DomainType.COMMIT, commitId, ids); + public MongoDeleteOutbox enqueueCommitDeletion(Long commitId, MongoIdsDto ids) { + return enqueue(TriggerType.DELETE, DomainType.COMMIT, commitId, ids); } - public MongoDeleteOutbox createDocCreateCompensation(String saveContentId, MongoIdsDto ids) { - return create(TriggerType.COMPENSATE, DomainType.DOC, saveContentId, ids); + public MongoDeleteOutbox enqueueDocCreateCompensation(String saveContentId, MongoIdsDto ids) { + return enqueue(TriggerType.COMPENSATE, DomainType.DOC, saveContentId, ids); } - public MongoDeleteOutbox createBranchCreateCompensation(String saveContentId, MongoIdsDto ids) { - return create(TriggerType.COMPENSATE, DomainType.BRANCH, saveContentId, ids); + public MongoDeleteOutbox enqueueBranchCreateCompensation(String saveContentId, MongoIdsDto ids) { + return enqueue(TriggerType.COMPENSATE, DomainType.BRANCH, saveContentId, ids); } - public MongoDeleteOutbox createCommitCreateCompensation(String commitBlockSequenceId, MongoIdsDto ids) { - return create(TriggerType.COMPENSATE, DomainType.COMMIT, commitBlockSequenceId, ids); + public MongoDeleteOutbox enqueueCommitCreateCompensation(String commitBlockSequenceId, MongoIdsDto ids) { + return enqueue(TriggerType.COMPENSATE, DomainType.COMMIT, commitBlockSequenceId, ids); } - public MongoDeleteOutbox createMergeCompensation(String saveMongoId, MongoIdsDto ids) { - return create(TriggerType.COMPENSATE, DomainType.MERGE, saveMongoId, ids); + public MongoDeleteOutbox enqueueMergeCompensation(String saveMongoId, MongoIdsDto ids) { + return enqueue(TriggerType.COMPENSATE, DomainType.MERGE, saveMongoId, ids); } - private MongoDeleteOutbox create( + private MongoDeleteOutbox enqueue( TriggerType triggerType, DomainType domainType, Long originId, @@ -63,10 +62,10 @@ private MongoDeleteOutbox create( if (originId <= 0) { throw new IllegalArgumentException("originId must be positive"); } - return create(triggerType, domainType, String.valueOf(originId), ids); + return enqueue(triggerType, domainType, String.valueOf(originId), ids); } - private MongoDeleteOutbox create( + private MongoDeleteOutbox enqueue( TriggerType triggerType, DomainType domainType, String originId, @@ -109,7 +108,7 @@ private MongoDeleteOutbox create( ); try { - mongoDeleteOutboxCreateService.tryCreate(newOutbox); + mongoDeleteOutboxRepository.saveAndFlush(newOutbox); } catch (DataIntegrityViolationException e) { return mongoDeleteOutboxRepository .findByTriggerTypeAndDomainTypeAndOriginId( diff --git a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxCreateService.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxCreateService.java deleted file mode 100644 index a20e7de3..00000000 --- a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxCreateService.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.ejangs.docsa.global.outbox.mongo.app; - -import io.ejangs.docsa.global.outbox.mongo.dao.mysql.MongoDeleteOutboxRepository; -import io.ejangs.docsa.global.outbox.mongo.entity.MongoDeleteOutbox; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class MongoDeleteOutboxCreateService { - - private final MongoDeleteOutboxRepository mongoDeleteOutboxRepository; - - @Transactional - public void tryCreate(MongoDeleteOutbox newOutbox) { - mongoDeleteOutboxRepository.saveAndFlush(newOutbox); - } -} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxWorker.java b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxWorker.java index e07eadb5..0ff38e31 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxWorker.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxWorker.java @@ -27,7 +27,7 @@ public class MongoDeleteOutboxWorker { private static final Duration PROCESSING_TIMEOUT = Duration.ofMinutes(5); private final MongoDeleteOutboxRepository mongoDeleteOutboxRepository; - private final MongoDeleteService mongoDeleteService; + private final MongoDeleteExecutor mongoDeleteExecutor; private final MongoDeleteOutboxLifecycleService mongoDeleteOutboxLifecycleService; @Scheduled( @@ -58,7 +58,7 @@ private void deleteTarget(List outboxes) { continue; } try { - mongoDeleteService.deleteTarget(target); + mongoDeleteExecutor.deleteTarget(target); mongoDeleteOutboxLifecycleService.done(outbox.getId()); } catch (Exception e) { log.error("[Outbox Worker] Error : {}", e.getMessage(), e); diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteService.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteExecutor.java similarity index 96% rename from src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteService.java rename to src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteExecutor.java index f3f6f383..050d9c0d 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteService.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteExecutor.java @@ -10,7 +10,7 @@ @Service @RequiredArgsConstructor -public class S3DeleteService { +public class S3DeleteExecutor { private final S3Client s3Client; diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxFactory.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteJobEnqueuer.java similarity index 88% rename from src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxFactory.java rename to src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteJobEnqueuer.java index ec11e2ce..f867fc01 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxFactory.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteJobEnqueuer.java @@ -2,14 +2,13 @@ import io.ejangs.docsa.domain.image.entity.Image; import io.ejangs.docsa.global.outbox.s3.dao.S3DeleteOutboxRepository; -import io.ejangs.docsa.global.outbox.s3.entity.S3DeleteOutbox; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor -public class S3DeleteOutboxFactory { +public class S3DeleteJobEnqueuer { private final S3DeleteOutboxRepository s3DeleteOutboxRepository; diff --git a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxWorker.java b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxWorker.java index 006b9aaa..f23f7a21 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxWorker.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteOutboxWorker.java @@ -27,7 +27,7 @@ public class S3DeleteOutboxWorker { private static final Duration PROCESSING_TIMEOUT = Duration.ofMinutes(5); private final S3DeleteOutboxRepository s3DeleteOutboxRepository; - private final S3DeleteService s3DeleteService; + private final S3DeleteExecutor s3DeleteExecutor; private final S3DeleteOutboxLifecycleService s3DeleteOutboxLifecycleService; @Scheduled( @@ -60,7 +60,7 @@ private void deleteTargets(List outboxes) { } try { - s3DeleteService.deleteTarget(target); + s3DeleteExecutor.deleteTarget(target); s3DeleteOutboxLifecycleService.done(outbox.getId()); } catch (Exception e) { log.error("[S3 Outbox Worker] Error : {}", e.getMessage(), e); diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java index d5a17533..774988c5 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java @@ -18,7 +18,7 @@ import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -59,7 +59,7 @@ class BranchServiceTest { private BranchCreateOrchestrator branchCreateOrchestrator; @Mock - private MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; @Test @DisplayName("새 브랜치 이름이 중복되면 BRANCH_NAME_DUPLICATED") @@ -279,7 +279,7 @@ void deleteBranch_success() { // Outbox 적재 검증 ArgumentCaptor captor = ArgumentCaptor.forClass(MongoIdsDto.class); - verify(mongoDeleteOutboxFactory).createBranchDelete( + verify(mongoDeleteJobEnqueuer).enqueueBranchDeletion( eq(branchId), captor.capture() ); diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java index 62a672f8..06a8c015 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateOrchestratorTest.java @@ -15,7 +15,7 @@ import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -34,7 +34,7 @@ class BranchCreateOrchestratorTest { private BranchCreateMySqlTxService branchCreateMySqlTxService; @Mock - private MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; @InjectMocks private BranchCreateOrchestrator orchestrator; @@ -53,7 +53,7 @@ void create_success() { BranchCreateResponse result = orchestrator.create(context); assertThat(result).isEqualTo(expected); - verify(mongoDeleteOutboxFactory, never()).createBranchCreateCompensation(any(String.class), any()); + verify(mongoDeleteJobEnqueuer, never()).enqueueBranchCreateCompensation(any(String.class), any()); } @Test @@ -70,7 +70,7 @@ void create_fail_whenMySqlFails_thenCompensateMongo() { .isInstanceOf(CustomException.class) .hasMessage(BranchErrorCode.FAIL_CREATE_BRANCH.getMessage()); - verify(mongoDeleteOutboxFactory).createBranchCreateCompensation( + verify(mongoDeleteJobEnqueuer).enqueueBranchCreateCompensation( eq("save-content-1"), eq(new MongoIdsDto(java.util.List.of("save-content-1"), null, null)) ); @@ -89,7 +89,7 @@ void create_fail_whenMongoFails_thenDoNotTouchMySqlOrOutbox() { .hasMessage("mongo fail"); verify(branchCreateMySqlTxService, never()).createMySqlPart(any(), any()); - verify(mongoDeleteOutboxFactory, never()).createBranchCreateCompensation(any(String.class), any()); + verify(mongoDeleteJobEnqueuer, never()).enqueueBranchCreateCompensation(any(String.class), any()); } private BranchCreateContext createContext(String fromCommitMongoId) { diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java index f966a79e..7aad2e6d 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitCreateOrchestratorTest.java @@ -17,7 +17,7 @@ import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.CommitErrorCode; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import java.util.Collections; import org.junit.jupiter.api.DisplayName; @@ -37,7 +37,7 @@ class CommitCreateOrchestratorTest { private CommitMongoTxService commitMongoTxService; @Mock - private MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; @InjectMocks private CommitCreateOrchestrator orchestrator; @@ -57,7 +57,7 @@ void create_success() { Commit result = orchestrator.create(request, "base-cbs", doc, branch); assertThat(result).isEqualTo(commit); - verify(mongoDeleteOutboxFactory, never()).createCommitCreateCompensation(any(String.class), any()); + verify(mongoDeleteJobEnqueuer, never()).enqueueCommitCreateCompensation(any(String.class), any()); } @Test @@ -76,7 +76,7 @@ void create_fail_compensateMongo() { .isInstanceOf(CustomException.class) .hasMessage(CommitErrorCode.FAIL_CREATE_COMMIT.getMessage()); - verify(mongoDeleteOutboxFactory).createCommitCreateCompensation( + verify(mongoDeleteJobEnqueuer).enqueueCommitCreateCompensation( eq("cbs-1"), eq(ids) ); diff --git a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java index eda7b8a1..55aaa73f 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java @@ -15,10 +15,9 @@ import io.ejangs.docsa.domain.image.entity.Image; import io.ejangs.docsa.domain.image.entity.Image.ImageStatus; import io.ejangs.docsa.domain.image.entity.Image.Purpose; -import io.ejangs.docsa.global.outbox.s3.app.S3DeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.s3.app.S3DeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.s3.dao.S3DeleteOutboxRepository; import io.ejangs.docsa.global.outbox.s3.entity.S3DeleteOutbox; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -46,13 +45,13 @@ class ThumbnailServiceUnitTest { @BeforeEach void setUp() { - S3DeleteOutboxFactory s3DeleteOutboxFactory = - new S3DeleteOutboxFactory(s3DeleteOutboxRepository); + S3DeleteJobEnqueuer s3DeleteJobEnqueuer = + new S3DeleteJobEnqueuer(s3DeleteOutboxRepository); thumbnailService = new ThumbnailService( thumbnailQueryService, docQueryService, imageQueryService, - s3DeleteOutboxFactory + s3DeleteJobEnqueuer ); ReflectionTestUtils.setField(thumbnailService, "cdnUrl", "https://cdn.example.com"); } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java index fc659366..117fcdb5 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java @@ -18,7 +18,7 @@ import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteOutboxFactory; +import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -37,7 +37,7 @@ class DocCreateOrchestratorTest { private DocCreateMySqlTxService docCreateMySqlTxService; @Mock - private MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; @InjectMocks private DocCreateOrchestrator orchestrator; @@ -56,7 +56,7 @@ void create_success() { DocCreateResponse result = orchestrator.create("doc", user); assertThat(result).isEqualTo(expected); - verify(mongoDeleteOutboxFactory, never()).createDocCreateCompensation(anyString(), any()); + verify(mongoDeleteJobEnqueuer, never()).enqueueDocCreateCompensation(anyString(), any()); } @Test @@ -74,7 +74,7 @@ void create_fail_compensateMongo() { .isInstanceOf(CustomException.class) .hasMessage(DocErrorCode.FAIL_CREATE_DOCUMENT.getMessage()); - verify(mongoDeleteOutboxFactory).createDocCreateCompensation( + verify(mongoDeleteJobEnqueuer).enqueueDocCreateCompensation( eq("save-1"), argThat(ids -> ids.saveContentsIds().contains("save-1") diff --git a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteJobEnqueuerUnitTest.java similarity index 83% rename from src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java rename to src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteJobEnqueuerUnitTest.java index d21617b7..1ffe60f2 100644 --- a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxFactoryUnitTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteJobEnqueuerUnitTest.java @@ -23,20 +23,17 @@ import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) -class MongoDeleteOutboxFactoryUnitTest { +class MongoDeleteJobEnqueuerUnitTest { @Mock private MongoDeleteOutboxRepository mongoDeleteOutboxRepository; - @Mock - private MongoDeleteOutboxCreateService mongoDeleteOutboxCreateService; - @InjectMocks - private MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; @Test @DisplayName("유니크 충돌 시 재조회하여 기존 outbox를 반환한다") - void createReturnsExistingOutboxWhenUniqueConflictOccurs() { + void enqueueReturnsExistingOutboxWhenUniqueConflictOccurs() { TriggerType triggerType = TriggerType.COMPENSATE; DomainType domainType = DomainType.DOC; String originId = "race-origin-1"; @@ -58,10 +55,10 @@ void createReturnsExistingOutboxWhenUniqueConflictOccurs() { originId )).thenReturn(java.util.Optional.empty(), java.util.Optional.of(existing)); doThrow(new DataIntegrityViolationException("duplicate key")) - .when(mongoDeleteOutboxCreateService) - .tryCreate(any(MongoDeleteOutbox.class)); + .when(mongoDeleteOutboxRepository) + .saveAndFlush(any(MongoDeleteOutbox.class)); - MongoDeleteOutbox result = mongoDeleteOutboxFactory.createDocCreateCompensation( + MongoDeleteOutbox result = mongoDeleteJobEnqueuer.enqueueDocCreateCompensation( originId, ids ); @@ -73,6 +70,6 @@ void createReturnsExistingOutboxWhenUniqueConflictOccurs() { domainType, originId ); - verify(mongoDeleteOutboxCreateService).tryCreate(any(MongoDeleteOutbox.class)); + verify(mongoDeleteOutboxRepository).saveAndFlush(any(MongoDeleteOutbox.class)); } } diff --git a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java index a4815bb0..fefed754 100644 --- a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxIntegrationTest.java @@ -26,7 +26,7 @@ class MongoDeleteOutboxIntegrationTest { @Autowired - private MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; @Autowired private MongoDeleteOutboxRepository mongoDeleteOutboxRepository; @@ -41,7 +41,7 @@ class MongoDeleteOutboxIntegrationTest { private JdbcTemplate jdbcTemplate; @MockitoSpyBean - private MongoDeleteService mongoDeleteService; + private MongoDeleteExecutor mongoDeleteExecutor; @BeforeEach void cleanOutbox() { @@ -67,7 +67,7 @@ void workerDone() { void workerRetryToOpen() { MongoDeleteOutbox outbox = createOpenOutbox(); doThrow(new RuntimeException("mongo delete fail")) - .when(mongoDeleteService).deleteTarget(any()); + .when(mongoDeleteExecutor).deleteTarget(any()); mongoDeleteOutboxWorker.run(); @@ -82,7 +82,7 @@ void workerRetryToOpen() { void workerFailedAfterMaxRetry() { MongoDeleteOutbox outbox = createOpenOutbox(); doThrow(new RuntimeException("always fail")) - .when(mongoDeleteService).deleteTarget(any()); + .when(mongoDeleteExecutor).deleteTarget(any()); for (int i = 0; i < 10; i++) { mongoDeleteOutboxWorker.run(); @@ -98,11 +98,11 @@ void workerFailedAfterMaxRetry() { @DisplayName("동일 키 create 중복 호출 시 outbox는 1건만 유지된다") void factoryDedupeWithSameKey() { String originId = "dup-" + UUID.randomUUID(); - MongoDeleteOutbox first = mongoDeleteOutboxFactory.createDocCreateCompensation( + MongoDeleteOutbox first = mongoDeleteJobEnqueuer.enqueueDocCreateCompensation( originId, new MongoIdsDto(List.of("save-" + originId), List.of(), List.of()) ); - MongoDeleteOutbox second = mongoDeleteOutboxFactory.createDocCreateCompensation( + MongoDeleteOutbox second = mongoDeleteJobEnqueuer.enqueueDocCreateCompensation( originId, new MongoIdsDto(List.of("save-" + originId), List.of(), List.of()) ); @@ -162,7 +162,7 @@ void completeServiceNoOpOnStatusMismatch() { void workerProcessesAtMost100OpenRowsPerRun() { for (int i = 0; i < 101; i++) { String originId = "batch-" + i + "-" + UUID.randomUUID(); - mongoDeleteOutboxFactory.createDocCreateCompensation( + mongoDeleteJobEnqueuer.enqueueDocCreateCompensation( originId, new MongoIdsDto(List.of("save-" + originId), List.of(), List.of()) ); @@ -187,7 +187,7 @@ void workerProcessesAtMost100OpenRowsPerRun() { private MongoDeleteOutbox createOpenOutbox() { String originId = UUID.randomUUID().toString(); - return mongoDeleteOutboxFactory.createDocCreateCompensation( + return mongoDeleteJobEnqueuer.enqueueDocCreateCompensation( originId, new MongoIdsDto(List.of("save-" + originId), List.of(), List.of()) ); diff --git a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java index 9f34708a..3bdc338b 100644 --- a/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/mongo/app/MongoDeleteOutboxRaceIntegrationTest.java @@ -36,7 +36,7 @@ private record CreateAttempt(MongoDeleteOutbox outbox, Throwable error) { } @Autowired - private MongoDeleteOutboxFactory mongoDeleteOutboxFactory; + private MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; @Autowired private MongoDeleteOutboxRepository mongoDeleteOutboxRepository; @@ -57,7 +57,7 @@ void concurrentCreateRace() throws Exception { ExecutorService executor = Executors.newFixedThreadPool(2); Callable task = () -> { try { - MongoDeleteOutbox outbox = mongoDeleteOutboxFactory.createDocCreateCompensation( + MongoDeleteOutbox outbox = mongoDeleteJobEnqueuer.enqueueDocCreateCompensation( originId, ids ); diff --git a/src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteServiceUnitTest.java b/src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteExecutorUnitTest.java similarity index 87% rename from src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteServiceUnitTest.java rename to src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteExecutorUnitTest.java index fb603666..f084f1ee 100644 --- a/src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/global/outbox/s3/app/S3DeleteExecutorUnitTest.java @@ -21,17 +21,17 @@ import software.amazon.awssdk.services.s3.model.S3Exception; @ExtendWith(MockitoExtension.class) -class S3DeleteServiceUnitTest { +class S3DeleteExecutorUnitTest { @Mock private S3Client s3Client; - private S3DeleteService s3DeleteService; + private S3DeleteExecutor s3DeleteExecutor; @BeforeEach void setUp() { - s3DeleteService = new S3DeleteService(s3Client); - ReflectionTestUtils.setField(s3DeleteService, "bucket", "docsa-image-bucket"); + s3DeleteExecutor = new S3DeleteExecutor(s3Client); + ReflectionTestUtils.setField(s3DeleteExecutor, "bucket", "docsa-image-bucket"); } @Test @@ -41,7 +41,7 @@ void deleteTarget_success() { when(s3Client.deleteObject(any(DeleteObjectRequest.class))) .thenReturn(DeleteObjectResponse.builder().build()); - s3DeleteService.deleteTarget(target); + s3DeleteExecutor.deleteTarget(target); ArgumentCaptor captor = ArgumentCaptor.forClass(DeleteObjectRequest.class); @@ -58,7 +58,7 @@ void deleteTarget_success_whenObjectMissing() { when(s3Client.deleteObject(any(DeleteObjectRequest.class))) .thenThrow(S3Exception.builder().statusCode(404).build()); - s3DeleteService.deleteTarget(target); + s3DeleteExecutor.deleteTarget(target); } @Test @@ -68,7 +68,7 @@ void deleteTarget_fail_whenS3ErrorOccurs() { RuntimeException s3Exception = S3Exception.builder().statusCode(500).build(); when(s3Client.deleteObject(any(DeleteObjectRequest.class))).thenThrow(s3Exception); - assertThatThrownBy(() -> s3DeleteService.deleteTarget(target)) + assertThatThrownBy(() -> s3DeleteExecutor.deleteTarget(target)) .isSameAs(s3Exception); } } From ddca378aeec393fe31abca6bf3f89df4450b74f4 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Mon, 4 May 2026 17:57:06 +0900 Subject: [PATCH 05/28] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20CQRS=20=EA=B8=B0=EB=B0=98=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20Outbox=20=EC=B6=94=EA=B0=80=20-=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=9A=A9=20outbox=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=99=80=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=EC=B6=94=EA=B0=80=20-=20outbox=20relay,?= =?UTF-8?q?=20lifecycle=20service,=20wake-up=20listener=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EB=A1=9C=EC=BB=AC=20dispatcher?= =?UTF-8?q?=EB=A5=BC=20=ED=86=B5=ED=95=B4=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20projector=EB=A1=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EA=B5=AC=EC=84=B1=20-=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20Mongo=20read=20model?= =?UTF-8?q?=EA=B3=BC=20payload=EB=A5=BC=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20JPA/Mongo=20repository=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A5=BC=20=EC=8A=A4=EC=BA=94=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EB=93=B1=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/doc/dao/mysql/DocRepository.java | 1 - .../doc/readmodel/app/DocListProjector.java | 56 ++++++++++ .../mongodb/DocListReadModelRepository.java | 18 +++ .../readmodel/document/DocListReadModel.java | 65 +++++++++++ .../doc/readmodel/dto/DocListPayload.java | 16 +++ .../ejangs/docsa/global/config/JpaConfig.java | 1 + .../docsa/global/config/MongoConfig.java | 1 + .../DomainEventOutboxLifecycleService.java | 70 ++++++++++++ .../event/app/DomainEventOutboxPublisher.java | 37 +++++++ .../event/app/DomainEventOutboxRelay.java | 103 ++++++++++++++++++ .../app/DomainEventOutboxWakeUpListener.java | 24 ++++ .../app/dispatcher/DomainEventDispatcher.java | 8 ++ .../LocalDomainEventDispatcher.java | 20 ++++ .../dao/DomainEventOutboxRepository.java | 34 ++++++ .../outbox/event/dto/DomainEventMessage.java | 15 +++ .../dto/DomainEventOutboxWakeUpEvent.java | 5 + .../event/entity/DomainEventOutbox.java | 76 ++++++++++++- .../outbox/event/model/AggregateType.java | 9 ++ .../outbox/event/model/DomainEventType.java | 9 ++ src/main/resources/application-local.yml | 6 +- src/main/resources/application.yml | 24 ++++ .../V4__create_domain_event_outbox.sql | 29 +++++ 22 files changed, 621 insertions(+), 6 deletions(-) create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/dao/mongodb/DocListReadModelRepository.java create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/DocListPayload.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxLifecycleService.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxPublisher.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/app/dispatcher/DomainEventDispatcher.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/app/dispatcher/LocalDomainEventDispatcher.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/dao/DomainEventOutboxRepository.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/dto/DomainEventMessage.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/dto/DomainEventOutboxWakeUpEvent.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/model/AggregateType.java create mode 100644 src/main/java/io/ejangs/docsa/global/outbox/event/model/DomainEventType.java create mode 100644 src/main/resources/db/migration/V4__create_domain_event_outbox.sql diff --git a/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java b/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java index 1bd74047..baf2f499 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java @@ -1,6 +1,5 @@ package io.ejangs.docsa.domain.doc.dao.mysql; -import io.ejangs.docsa.domain.doc.dto.response.DocTitleOnlyResponse; import io.ejangs.docsa.domain.doc.entity.Doc; import java.util.Optional; import org.springframework.data.domain.Page; diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java new file mode 100644 index 00000000..770d1fd5 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java @@ -0,0 +1,56 @@ +package io.ejangs.docsa.domain.doc.readmodel.app; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.ejangs.docsa.domain.doc.readmodel.dao.mongodb.DocListReadModelRepository; +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import io.ejangs.docsa.domain.doc.readmodel.dto.DocListPayload; +import io.ejangs.docsa.global.outbox.event.dto.DomainEventMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DocListProjector { + + private final DocListReadModelRepository docListReadModelRepository; + private final ObjectMapper objectMapper; + + public void project(DomainEventMessage message) { + DocListPayload payload = readPayload(message); + + switch (message.eventType()) { + case DOC_CREATED -> docListReadModelRepository.save(DocListReadModel.create(payload, message.eventId())); + case DOC_TITLE_CHANGED, DOC_ACTIVITY_CHANGED, DOC_THUMBNAIL_CHANGED -> upsert(payload, message.eventId()); + case DOC_DELETED -> markDeleted(payload.docId(), message.eventId()); + } + } + + private DocListPayload readPayload(DomainEventMessage message) { + try { + return objectMapper.readValue(message.payload(), DocListPayload.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Domain event payload deserialize failed", e); + } + } + + private void upsert(DocListPayload payload, Long eventId) { + DocListReadModel model = docListReadModelRepository.findById(payload.docId()) + .orElseGet(() -> DocListReadModel.create(payload, eventId)); + + if (model.getLastProjectedEventId() != null && model.getLastProjectedEventId() >= eventId) { + return; + } + + model.apply(payload, eventId); + docListReadModelRepository.save(model); + } + + private void markDeleted(Long docId, Long eventId) { + docListReadModelRepository.findById(docId) + .ifPresent(model -> { + model.markDeleted(eventId); + docListReadModelRepository.save(model); + }); + } +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dao/mongodb/DocListReadModelRepository.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dao/mongodb/DocListReadModelRepository.java new file mode 100644 index 00000000..caa1607a --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dao/mongodb/DocListReadModelRepository.java @@ -0,0 +1,18 @@ +package io.ejangs.docsa.domain.doc.readmodel.dao.mongodb; + +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface DocListReadModelRepository extends MongoRepository { + + Page findByUserIdAndDeletedFalse(Long userId, Pageable pageable); + + Page findByUserIdAndDeletedFalseAndTitleContainingIgnoreCase( + Long userId, + String title, + Pageable pageable + ); + +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java new file mode 100644 index 00000000..c0091864 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java @@ -0,0 +1,65 @@ +package io.ejangs.docsa.domain.doc.readmodel.document; + +import io.ejangs.docsa.domain.doc.readmodel.dto.DocListPayload; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document("doc_list_read_models") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DocListReadModel { + + @Id + private Long id; // docId + + @Indexed + private Long userId; + + private String title; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private Long recentSaveId; + + private String thumbnailObjectKey; + private ThumbnailStatus thumbnailStatus; + + private boolean deleted; + + private Long lastProjectedEventId; + + public static DocListReadModel create(DocListPayload payload, Long eventId) { + DocListReadModel model = new DocListReadModel(); + model.id = payload.docId(); + model.userId = payload.userId(); + model.title = payload.title(); + model.createdAt = payload.createdAt(); + model.updatedAt = payload.updatedAt(); + model.recentSaveId = payload.recentSaveId(); + model.thumbnailObjectKey = payload.thumbnailObjectKey(); + model.thumbnailStatus = payload.thumbnailStatus(); + model.deleted = false; + model.lastProjectedEventId = eventId; + return model; + } + + public void apply(DocListPayload payload, Long eventId) { + this.title = payload.title(); + this.updatedAt = payload.updatedAt(); + this.recentSaveId = payload.recentSaveId(); + this.thumbnailObjectKey = payload.thumbnailObjectKey(); + this.thumbnailStatus = payload.thumbnailStatus(); + this.deleted = false; + this.lastProjectedEventId = eventId; + } + + public void markDeleted(Long eventId) { + this.deleted = true; + this.lastProjectedEventId = eventId; + } +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/DocListPayload.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/DocListPayload.java new file mode 100644 index 00000000..4d6f584e --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/DocListPayload.java @@ -0,0 +1,16 @@ +package io.ejangs.docsa.domain.doc.readmodel.dto; + +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import java.time.LocalDateTime; + +public record DocListPayload( + Long docId, + Long userId, + String title, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Long recentSaveId, + String thumbnailObjectKey, + ThumbnailStatus thumbnailStatus +) { +} diff --git a/src/main/java/io/ejangs/docsa/global/config/JpaConfig.java b/src/main/java/io/ejangs/docsa/global/config/JpaConfig.java index 3b7e2b36..b0b1a46f 100644 --- a/src/main/java/io/ejangs/docsa/global/config/JpaConfig.java +++ b/src/main/java/io/ejangs/docsa/global/config/JpaConfig.java @@ -21,6 +21,7 @@ "io.ejangs.docsa.domain.user.dao.mysql", "io.ejangs.docsa.global.outbox.mongo.dao.mysql", "io.ejangs.docsa.domain.image.dao", + "io.ejangs.docsa.global.outbox.event.dao", "io.ejangs.docsa.global.outbox.s3.dao" }) public class JpaConfig { diff --git a/src/main/java/io/ejangs/docsa/global/config/MongoConfig.java b/src/main/java/io/ejangs/docsa/global/config/MongoConfig.java index 49906e23..a6a39212 100644 --- a/src/main/java/io/ejangs/docsa/global/config/MongoConfig.java +++ b/src/main/java/io/ejangs/docsa/global/config/MongoConfig.java @@ -18,6 +18,7 @@ "io.ejangs.docsa.domain.block.dao.mongodb", "io.ejangs.docsa.domain.commit.dao.mongodb", "io.ejangs.docsa.domain.save.dao.mongodb", + "io.ejangs.docsa.domain.doc.readmodel.dao.mongodb", }) public class MongoConfig { diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxLifecycleService.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxLifecycleService.java new file mode 100644 index 00000000..108305fc --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxLifecycleService.java @@ -0,0 +1,70 @@ +package io.ejangs.docsa.global.outbox.event.app; + +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.event.dao.DomainEventOutboxRepository; +import io.ejangs.docsa.global.outbox.event.entity.DomainEventOutbox; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DomainEventOutboxLifecycleService { + + private final DomainEventOutboxRepository domainEventOutboxRepository; + + @Transactional + public DomainEventOutbox claimOpen(Long outboxId) { + int claimed = domainEventOutboxRepository.claimOpenById(outboxId); + if (claimed == 0) { + return null; + } + + return domainEventOutboxRepository.findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) + .orElse(null); + } + + @Transactional + public void done(Long outboxId) { + DomainEventOutbox outbox = domainEventOutboxRepository.findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) + .orElse(null); + if (outbox == null) { + return; + } + + outbox.markDone(); + domainEventOutboxRepository.save(outbox); + } + + @Transactional + public void retry(Long outboxId, String errorMessage) { + DomainEventOutbox outbox = domainEventOutboxRepository.findByIdAndStatus(outboxId, OutboxStatus.PROCESSING) + .orElse(null); + if (outbox == null) { + return; + } + + outbox.markRetry(errorMessage); + domainEventOutboxRepository.save(outbox); + } + + @Transactional + public int recoverTimedOutProcessing(LocalDateTime timeoutThreshold) { + List stuckOutboxes = + domainEventOutboxRepository.findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( + OutboxStatus.PROCESSING, + timeoutThreshold + ); + if (stuckOutboxes.isEmpty()) { + return 0; + } + + stuckOutboxes.forEach(outbox -> + outbox.recoverProcessingTimeout("PROCESSING timeout recovered") + ); + domainEventOutboxRepository.saveAll(stuckOutboxes); + return stuckOutboxes.size(); + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxPublisher.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxPublisher.java new file mode 100644 index 00000000..13d8dbf8 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxPublisher.java @@ -0,0 +1,37 @@ +package io.ejangs.docsa.global.outbox.event.app; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.ejangs.docsa.global.outbox.event.dao.DomainEventOutboxRepository; +import io.ejangs.docsa.global.outbox.event.dto.DomainEventOutboxWakeUpEvent; +import io.ejangs.docsa.global.outbox.event.entity.DomainEventOutbox; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DomainEventOutboxPublisher { + + private final DomainEventOutboxRepository repository; + private final ObjectMapper objectMapper; + private final ApplicationEventPublisher applicationEventPublisher; + + public void publish(DomainEventType eventType, AggregateType aggregateType, Long aggregateId, Object payload) { + try { + String json = objectMapper.writeValueAsString(payload); + DomainEventOutbox outbox = repository.save(DomainEventOutbox.open( + eventType, + aggregateType, + String.valueOf(aggregateId), + json + )); + + applicationEventPublisher.publishEvent(new DomainEventOutboxWakeUpEvent(outbox.getId())); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Domain event payload serialize failed", e); + } + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java new file mode 100644 index 00000000..121d7184 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelay.java @@ -0,0 +1,103 @@ +package io.ejangs.docsa.global.outbox.event.app; + +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.event.app.dispatcher.DomainEventDispatcher; +import io.ejangs.docsa.global.outbox.event.dao.DomainEventOutboxRepository; +import io.ejangs.docsa.global.outbox.event.entity.DomainEventOutbox; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "domain.event.outbox.worker", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class DomainEventOutboxRelay { + + private static final Duration PROCESSING_TIMEOUT = Duration.ofMinutes(5); + + private final DomainEventOutboxRepository repository; + private final DomainEventOutboxLifecycleService lifecycleService; + private final DomainEventDispatcher dispatcher; + private final AtomicBoolean running = new AtomicBoolean(false); + + @Scheduled( + fixedDelayString = "${domain.event.outbox.worker.fixed-delay:PT10S}", + initialDelayString = "${domain.event.outbox.worker.initial-delay:PT5S}" + ) + public void runScheduled() { + run(); + } + + public void run() { + if (!running.compareAndSet(false, true)) { + return; + } + + try { + doRun(); + } finally { + running.set(false); + } + } + + public void run(Long outboxId) { + if (!running.compareAndSet(false, true)) { + return; + } + + try { + processOne(outboxId); + } finally { + running.set(false); + } + } + + private void doRun() { + recoverTimedOutProcessing(); + + List events = + repository.findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus.OPEN); + + for (DomainEventOutbox event : events) { + processOne(event.getId()); + } + } + + private void processOne(Long outboxId) { + DomainEventOutbox event = lifecycleService.claimOpen(outboxId); + if (event == null) { + return; + } + + try { + dispatcher.dispatch(event.toMessage()); + lifecycleService.done(outboxId); + } catch (Exception e) { + log.error("[DomainEventOutboxRelay] Dispatch failed. outboxId={}, message={}", + outboxId, e.getMessage(), e); + lifecycleService.retry(outboxId, e.getMessage()); + } + } + + private void recoverTimedOutProcessing() { + int recovered = lifecycleService.recoverTimedOutProcessing( + LocalDateTime.now().minus(PROCESSING_TIMEOUT) + ); + + if (recovered > 0) { + log.warn("[DomainEventOutboxRelay] Recovered timed-out PROCESSING rows: {}", recovered); + } + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java new file mode 100644 index 00000000..39924372 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java @@ -0,0 +1,24 @@ +package io.ejangs.docsa.global.outbox.event.app; + +import io.ejangs.docsa.global.outbox.event.dto.DomainEventOutboxWakeUpEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class DomainEventOutboxWakeUpListener { + + private final DomainEventOutboxRelay relay; + + @Async + @TransactionalEventListener( + phase = TransactionPhase.AFTER_COMMIT, + fallbackExecution = true + ) + public void handle(DomainEventOutboxWakeUpEvent event) { + relay.run(event.outboxId()); + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/dispatcher/DomainEventDispatcher.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/dispatcher/DomainEventDispatcher.java new file mode 100644 index 00000000..a973e078 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/dispatcher/DomainEventDispatcher.java @@ -0,0 +1,8 @@ +package io.ejangs.docsa.global.outbox.event.app.dispatcher; + +import io.ejangs.docsa.global.outbox.event.dto.DomainEventMessage; + +public interface DomainEventDispatcher { + + void dispatch(DomainEventMessage message); +} \ No newline at end of file diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/dispatcher/LocalDomainEventDispatcher.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/dispatcher/LocalDomainEventDispatcher.java new file mode 100644 index 00000000..97e5ef14 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/dispatcher/LocalDomainEventDispatcher.java @@ -0,0 +1,20 @@ +package io.ejangs.docsa.global.outbox.event.app.dispatcher; + +import io.ejangs.docsa.domain.doc.readmodel.app.DocListProjector; +import io.ejangs.docsa.global.outbox.event.dto.DomainEventMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LocalDomainEventDispatcher implements DomainEventDispatcher { + + private final DocListProjector docListProjector; + + @Override + public void dispatch(DomainEventMessage message) { + switch (message.aggregateType()) { + case DOC -> docListProjector.project(message); + } + } +} \ No newline at end of file diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/dao/DomainEventOutboxRepository.java b/src/main/java/io/ejangs/docsa/global/outbox/event/dao/DomainEventOutboxRepository.java new file mode 100644 index 00000000..1685933d --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/dao/DomainEventOutboxRepository.java @@ -0,0 +1,34 @@ +package io.ejangs.docsa.global.outbox.event.dao; + +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.event.entity.DomainEventOutbox; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DomainEventOutboxRepository extends JpaRepository { + + List findTop100ByStatusOrderByCreatedAtAsc(OutboxStatus status); + + List findTop100ByStatusAndUpdatedAtBeforeOrderByUpdatedAtAsc( + OutboxStatus status, + LocalDateTime updatedAt + ); + + Optional findByIdAndStatus(Long id, OutboxStatus status); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = """ + update domain_event_outbox + set status = 'PROCESSING', + updated_at = current_timestamp, + version = version + 1 + where id = :outboxId + and status = 'OPEN' + """, nativeQuery = true) + int claimOpenById(@Param("outboxId") Long outboxId); +} \ No newline at end of file diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/dto/DomainEventMessage.java b/src/main/java/io/ejangs/docsa/global/outbox/event/dto/DomainEventMessage.java new file mode 100644 index 00000000..15afab9d --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/dto/DomainEventMessage.java @@ -0,0 +1,15 @@ +package io.ejangs.docsa.global.outbox.event.dto; + +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; +import java.time.LocalDateTime; + +public record DomainEventMessage( + Long eventId, + DomainEventType eventType, + AggregateType aggregateType, + String aggregateId, + String payload, + LocalDateTime occurredAt +) { +} \ No newline at end of file diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/dto/DomainEventOutboxWakeUpEvent.java b/src/main/java/io/ejangs/docsa/global/outbox/event/dto/DomainEventOutboxWakeUpEvent.java new file mode 100644 index 00000000..6af226f7 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/dto/DomainEventOutboxWakeUpEvent.java @@ -0,0 +1,5 @@ +package io.ejangs.docsa.global.outbox.event.dto; + +public record DomainEventOutboxWakeUpEvent(Long outboxId) { + +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/entity/DomainEventOutbox.java b/src/main/java/io/ejangs/docsa/global/outbox/event/entity/DomainEventOutbox.java index f9eef3b6..3abe0ab9 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/event/entity/DomainEventOutbox.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/entity/DomainEventOutbox.java @@ -1,4 +1,76 @@ package io.ejangs.docsa.global.outbox.event.entity; -public class DomainEventOutbox { -} +import io.ejangs.docsa.global.outbox.BaseOutboxEntity; +import io.ejangs.docsa.global.outbox.event.dto.DomainEventMessage; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "domain_event_outbox", + indexes = { + @Index(name = "idx_domain_event_outbox_status_created_at", columnList = "status, created_at"), + @Index(name = "idx_domain_event_outbox_status_updated_at", columnList = "status, updated_at") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DomainEventOutbox extends BaseOutboxEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 64) + private DomainEventType eventType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 64) + private AggregateType aggregateType; + + @Column(nullable = false, length = 255) + private String aggregateId; + + @Column(nullable = false, columnDefinition = "json") + private String payload; + + public static DomainEventOutbox open( + DomainEventType eventType, + AggregateType aggregateType, + String aggregateId, + String payload + ) { + DomainEventOutbox outbox = new DomainEventOutbox(); + outbox.eventType = eventType; + outbox.aggregateType = aggregateType; + outbox.aggregateId = aggregateId; + outbox.payload = payload; + outbox.initOutbox(); + return outbox; + } + + public DomainEventMessage toMessage() { + return new DomainEventMessage( + id, + eventType, + aggregateType, + aggregateId, + payload, + getCreatedAt() + ); + } + +} \ No newline at end of file diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/model/AggregateType.java b/src/main/java/io/ejangs/docsa/global/outbox/event/model/AggregateType.java new file mode 100644 index 00000000..e46f62db --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/model/AggregateType.java @@ -0,0 +1,9 @@ +package io.ejangs.docsa.global.outbox.event.model; + +public enum AggregateType { + DOC, + BRANCH, + COMMIT, + SAVE, + THUMBNAIL +} \ No newline at end of file diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/model/DomainEventType.java b/src/main/java/io/ejangs/docsa/global/outbox/event/model/DomainEventType.java new file mode 100644 index 00000000..a5b2faa5 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/model/DomainEventType.java @@ -0,0 +1,9 @@ +package io.ejangs.docsa.global.outbox.event.model; + +public enum DomainEventType { + DOC_CREATED, + DOC_TITLE_CHANGED, + DOC_ACTIVITY_CHANGED, + DOC_THUMBNAIL_CHANGED, + DOC_DELETED +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 0b843e06..c54a993c 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -26,9 +26,9 @@ spring: ddl-auto: ${DDL_AUTO:create-drop} properties: hibernate: - show_sql: false - format_sql: false - highlight_sql: false + show_sql: true + format_sql: true + highlight_sql: true decorator: datasource: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 629ed6b8..fa11beea 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -44,3 +44,27 @@ cloud: bucket: ${AWS_BUCKET} presigned-expire-minutes: ${AWS_PRESIGNED_EXPIRE_MIN:5} public-base-url: ${CDN_URL} + +mongo: + delete: + outbox: + worker: + enabled: true + fixed-delay: PT1M + initial-delay: PT0S + +s3: + delete: + outbox: + worker: + enabled: true + fixed-delay: PT1M + initial-delay: PT0S + +domain: + event: + outbox: + worker: + enabled: true + fixed-delay: PT5S + initial-delay: PT3S diff --git a/src/main/resources/db/migration/V4__create_domain_event_outbox.sql b/src/main/resources/db/migration/V4__create_domain_event_outbox.sql new file mode 100644 index 00000000..9716df11 --- /dev/null +++ b/src/main/resources/db/migration/V4__create_domain_event_outbox.sql @@ -0,0 +1,29 @@ +ALTER TABLE mongo_delete_outbox + MODIFY COLUMN trigger_type enum('COMPENSATE','DELETE') NOT NULL, + MODIFY COLUMN domain_type enum('BRANCH','COMMIT','DOC','MERGE','SAVE') NOT NULL, + MODIFY COLUMN status enum('DONE','FAILED','OPEN','PROCESSING') NOT NULL; + +CREATE TABLE domain_event_outbox ( + max_retry int NOT NULL, + retry_count int NOT NULL, + created_at datetime(6) NOT NULL, + done_at datetime(6) DEFAULT NULL, + id bigint NOT NULL AUTO_INCREMENT, + updated_at datetime(6) DEFAULT NULL, + version bigint DEFAULT NULL, + last_error varchar(2000) DEFAULT NULL, + aggregate_id varchar(255) NOT NULL, + aggregate_type enum('DOC','BRANCH','COMMIT','SAVE','THUMBNAIL') NOT NULL, + event_type enum( + 'DOC_CREATED', + 'DOC_TITLE_CHANGED', + 'DOC_ACTIVITY_CHANGED', + 'DOC_THUMBNAIL_CHANGED', + 'DOC_DELETED' + ) NOT NULL, + payload json NOT NULL, + status enum('DONE','FAILED','OPEN','PROCESSING') NOT NULL, + PRIMARY KEY (id), + KEY idx_domain_event_outbox_status_created_at (status, created_at), + KEY idx_domain_event_outbox_status_updated_at (status, updated_at) +); From db46aecc6f3e67d3bb3218eea1f00bcc7037c761 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Fri, 8 May 2026 01:58:28 +0900 Subject: [PATCH 06/28] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20read=20model=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20payloa?= =?UTF-8?q?d=20=EB=B6=84=EB=A6=AC=20-=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20projection=20payload=EB=A5=BC=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=83=80=EC=9E=85=EB=B3=84=20record=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20-=20DocListProjector=EA=B0=80=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=ED=83=80=EC=9E=85=EB=B3=84=20payload?= =?UTF-8?q?=EB=A5=BC=20=EC=97=AD=EC=A7=81=EB=A0=AC=ED=99=94=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20-=20DocListReadModel?= =?UTF-8?q?=EC=97=90=20=EC=83=9D=EC=84=B1/=EC=A0=9C=EB=AA=A9/=ED=99=9C?= =?UTF-8?q?=EB=8F=99/=EC=8D=B8=EB=84=A4=EC=9D=BC/=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=B3=84=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=20-?= =?UTF-8?q?=20DocPayloadFactory=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20payload=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=EC=9D=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../doc/readmodel/app/DocListProjector.java | 68 ++++++++++++++----- .../readmodel/document/DocListReadModel.java | 40 +++++++++-- .../payload/DocActivityChangedPayload.java | 10 +++ .../DocCreatedPayload.java} | 4 +- .../dto/payload/DocDeletedPayload.java | 6 ++ .../payload/DocThumbnailChangedPayload.java | 10 +++ .../dto/payload/DocTitleChangedPayload.java | 10 +++ .../doc/readmodel/util/DocPayloadFactory.java | 62 +++++++++++++++++ 8 files changed, 185 insertions(+), 25 deletions(-) create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocActivityChangedPayload.java rename src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/{DocListPayload.java => payload/DocCreatedPayload.java} (79%) create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocDeletedPayload.java create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocThumbnailChangedPayload.java create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocTitleChangedPayload.java create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocPayloadFactory.java diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java index 770d1fd5..6e15cb8c 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java @@ -4,7 +4,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.ejangs.docsa.domain.doc.readmodel.dao.mongodb.DocListReadModelRepository; import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; -import io.ejangs.docsa.domain.doc.readmodel.dto.DocListPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocActivityChangedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocDeletedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocThumbnailChangedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocTitleChangedPayload; import io.ejangs.docsa.global.outbox.event.dto.DomainEventMessage; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -17,39 +21,69 @@ public class DocListProjector { private final ObjectMapper objectMapper; public void project(DomainEventMessage message) { - DocListPayload payload = readPayload(message); - switch (message.eventType()) { - case DOC_CREATED -> docListReadModelRepository.save(DocListReadModel.create(payload, message.eventId())); - case DOC_TITLE_CHANGED, DOC_ACTIVITY_CHANGED, DOC_THUMBNAIL_CHANGED -> upsert(payload, message.eventId()); - case DOC_DELETED -> markDeleted(payload.docId(), message.eventId()); + case DOC_CREATED -> create(message); + case DOC_TITLE_CHANGED -> changeTitle(message); + case DOC_ACTIVITY_CHANGED -> changeActivity(message); + case DOC_THUMBNAIL_CHANGED -> changeThumbnail(message); + case DOC_DELETED -> delete(message); } } - private DocListPayload readPayload(DomainEventMessage message) { + private T readPayload(DomainEventMessage message, Class payloadType) { try { - return objectMapper.readValue(message.payload(), DocListPayload.class); + return objectMapper.readValue(message.payload(), payloadType); } catch (JsonProcessingException e) { throw new IllegalStateException("Domain event payload deserialize failed", e); } } - private void upsert(DocListPayload payload, Long eventId) { - DocListReadModel model = docListReadModelRepository.findById(payload.docId()) - .orElseGet(() -> DocListReadModel.create(payload, eventId)); + private void create(DomainEventMessage message) { + DocCreatedPayload payload = readPayload(message, DocCreatedPayload.class); - if (model.getLastProjectedEventId() != null && model.getLastProjectedEventId() >= eventId) { + if (docListReadModelRepository.existsById(payload.docId())) { return; } - model.apply(payload, eventId); - docListReadModelRepository.save(model); + docListReadModelRepository.save(DocListReadModel.create(payload, message.eventId())); + } + + private void changeTitle(DomainEventMessage message) { + DocTitleChangedPayload payload = readPayload(message, DocTitleChangedPayload.class); + + docListReadModelRepository.findById(payload.docId()) + .ifPresent(model -> { + model.changeTitle(payload, message.eventId()); + docListReadModelRepository.save(model); + }); + } + + private void changeActivity(DomainEventMessage message) { + DocActivityChangedPayload payload = readPayload(message, DocActivityChangedPayload.class); + + docListReadModelRepository.findById(payload.docId()) + .ifPresent(model -> { + model.changeActivity(payload, message.eventId()); + docListReadModelRepository.save(model); + }); + } + + private void changeThumbnail(DomainEventMessage message) { + DocThumbnailChangedPayload payload = readPayload(message, DocThumbnailChangedPayload.class); + + docListReadModelRepository.findById(payload.docId()) + .ifPresent(model -> { + model.changeThumbnail(payload, message.eventId()); + docListReadModelRepository.save(model); + }); } - private void markDeleted(Long docId, Long eventId) { - docListReadModelRepository.findById(docId) + private void delete(DomainEventMessage message) { + DocDeletedPayload payload = readPayload(message, DocDeletedPayload.class); + + docListReadModelRepository.findById(payload.docId()) .ifPresent(model -> { - model.markDeleted(eventId); + model.markDeleted(); docListReadModelRepository.save(model); }); } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java index c0091864..496c5c96 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java @@ -1,6 +1,9 @@ package io.ejangs.docsa.domain.doc.readmodel.document; -import io.ejangs.docsa.domain.doc.readmodel.dto.DocListPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocActivityChangedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocThumbnailChangedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocTitleChangedPayload; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import java.time.LocalDateTime; import lombok.AccessLevel; @@ -33,7 +36,7 @@ public class DocListReadModel { private Long lastProjectedEventId; - public static DocListReadModel create(DocListPayload payload, Long eventId) { + public static DocListReadModel create(DocCreatedPayload payload, Long eventId) { DocListReadModel model = new DocListReadModel(); model.id = payload.docId(); model.userId = payload.userId(); @@ -48,18 +51,43 @@ public static DocListReadModel create(DocListPayload payload, Long eventId) { return model; } - public void apply(DocListPayload payload, Long eventId) { + public void changeTitle(DocTitleChangedPayload payload, Long eventId) { + if (isAlreadyProjected(eventId)) { + return; + } + this.title = payload.title(); this.updatedAt = payload.updatedAt(); + this.deleted = false; + this.lastProjectedEventId = eventId; + } + + public void changeActivity(DocActivityChangedPayload payload, Long eventId) { + if (isAlreadyProjected(eventId)) { + return; + } + this.recentSaveId = payload.recentSaveId(); + this.updatedAt = payload.updatedAt(); + this.deleted = false; + this.lastProjectedEventId = eventId; + } + + public void changeThumbnail(DocThumbnailChangedPayload payload, Long eventId) { + if (isAlreadyProjected(eventId)) { + return; + } + this.thumbnailObjectKey = payload.thumbnailObjectKey(); this.thumbnailStatus = payload.thumbnailStatus(); - this.deleted = false; this.lastProjectedEventId = eventId; } - public void markDeleted(Long eventId) { + public void markDeleted() { this.deleted = true; - this.lastProjectedEventId = eventId; + } + + private boolean isAlreadyProjected(Long eventId) { + return this.lastProjectedEventId != null && this.lastProjectedEventId >= eventId; } } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocActivityChangedPayload.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocActivityChangedPayload.java new file mode 100644 index 00000000..a02dba0b --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocActivityChangedPayload.java @@ -0,0 +1,10 @@ +package io.ejangs.docsa.domain.doc.readmodel.dto.payload; + +import java.time.LocalDateTime; + +public record DocActivityChangedPayload( + Long docId, + Long recentSaveId, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/DocListPayload.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocCreatedPayload.java similarity index 79% rename from src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/DocListPayload.java rename to src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocCreatedPayload.java index 4d6f584e..20c9d55c 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/DocListPayload.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocCreatedPayload.java @@ -1,9 +1,9 @@ -package io.ejangs.docsa.domain.doc.readmodel.dto; +package io.ejangs.docsa.domain.doc.readmodel.dto.payload; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import java.time.LocalDateTime; -public record DocListPayload( +public record DocCreatedPayload( Long docId, Long userId, String title, diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocDeletedPayload.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocDeletedPayload.java new file mode 100644 index 00000000..83653df0 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocDeletedPayload.java @@ -0,0 +1,6 @@ +package io.ejangs.docsa.domain.doc.readmodel.dto.payload; + +public record DocDeletedPayload( + Long docId +) { +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocThumbnailChangedPayload.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocThumbnailChangedPayload.java new file mode 100644 index 00000000..64625e59 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocThumbnailChangedPayload.java @@ -0,0 +1,10 @@ +package io.ejangs.docsa.domain.doc.readmodel.dto.payload; + +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; + +public record DocThumbnailChangedPayload( + Long docId, + String thumbnailObjectKey, + ThumbnailStatus thumbnailStatus +) { +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocTitleChangedPayload.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocTitleChangedPayload.java new file mode 100644 index 00000000..ebea18b1 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/dto/payload/DocTitleChangedPayload.java @@ -0,0 +1,10 @@ +package io.ejangs.docsa.domain.doc.readmodel.dto.payload; + +import java.time.LocalDateTime; + +public record DocTitleChangedPayload( + Long docId, + String title, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocPayloadFactory.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocPayloadFactory.java new file mode 100644 index 00000000..0a39e8c8 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocPayloadFactory.java @@ -0,0 +1,62 @@ +package io.ejangs.docsa.domain.doc.readmodel.util; + +import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocActivityChangedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocDeletedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocThumbnailChangedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocTitleChangedPayload; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; + +public final class DocPayloadFactory { + + private DocPayloadFactory() { + } + + public static DocCreatedPayload created(Doc doc, Long userId, Long recentSaveId) { + return new DocCreatedPayload( + doc.getId(), + userId, + doc.getTitle(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + recentSaveId, + null, + ThumbnailStatus.EMPTY + ); + } + + public static DocTitleChangedPayload titleChanged(Doc doc) { + return new DocTitleChangedPayload( + doc.getId(), + doc.getTitle(), + doc.getUpdatedAt() + ); + } + + public static DocActivityChangedPayload activityChanged(Doc doc, Long recentSaveId) { + return new DocActivityChangedPayload( + doc.getId(), + recentSaveId, + doc.getUpdatedAt() + ); + } + + public static DocThumbnailChangedPayload thumbnailChanged( + Doc doc, + String thumbnailObjectKey, + ThumbnailStatus thumbnailStatus + ) { + return new DocThumbnailChangedPayload( + doc.getId(), + thumbnailObjectKey, + thumbnailStatus + ); + } + + public static DocDeletedPayload deleted(Long docId) { + return new DocDeletedPayload( + docId + ); + } +} From 8cb086720864cccc37433b5f9736201a32b09be6 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Fri, 8 May 2026 01:58:54 +0900 Subject: [PATCH 07/28] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EA=B3=BC=20=EC=A0=9C=EB=AA=A9=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9=EB=A1=9D=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?-=20=EB=AC=B8=EC=84=9C=20=EC=83=9D=EC=84=B1=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=EC=97=90=EC=84=9C=20DOC=5FCREATED?= =?UTF-8?q?=20outbox=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A5=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=97=B0=EA=B2=B0=20-?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=20=EC=A0=9C=EB=AA=A9=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=8B=9C=20DOC=5FTITLE=5FCHANGED=20outbox=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=A5=BC=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=97=B0=EA=B2=B0=20-=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=EB=B3=80=EA=B2=BD=20=EB=8B=A8=EC=9C=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20outbox=20publish=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/ejangs/docsa/domain/doc/app/DocService.java | 12 ++++++++++++ .../doc/app/create/DocCreateMySqlTxService.java | 12 +++++++++++- .../docsa/domain/doc/unit/DocServiceUnitTests.java | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java index 63ec66b2..f37975ba 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java @@ -5,6 +5,8 @@ import io.ejangs.docsa.domain.commit.app.CommitQueryService; import io.ejangs.docsa.domain.doc.app.create.DocCreateOrchestrator; import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.edge.app.EdgeService; import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; @@ -24,9 +26,13 @@ import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; +import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -52,6 +58,8 @@ public class DocService { private final DocListAssembler docListAssembler; private final MongoIdsCollector mongoIdsCollector; + private final DomainEventOutboxPublisher domainEventOutboxPublisher; + public DocCreateResponse create(DocTitleRequest request, Long userId) { User user = docQueryService.getUserOrThrow(userId); String title = request.title(); @@ -89,6 +97,10 @@ public DocTitleUpdateResponse updateTitle(Long userId, Long docId, DocTitleReque docQueryService.checkTitleDuplicate(userId, title); doc.updateTitle(title); + doc.updateTimestamp(); + + domainEventOutboxPublisher.publish(DomainEventType.DOC_TITLE_CHANGED, AggregateType.DOC, + doc.getId(), DocPayloadFactory.titleChanged(doc)); return DocMapper.toUpdateResponse(doc); } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java index c351dd41..c5d49a24 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java @@ -4,12 +4,18 @@ import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.doc.thumbnail.dao.ThumbnailRepository; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.doc.util.DocMapper; import io.ejangs.docsa.domain.save.app.SaveQueryService; import io.ejangs.docsa.domain.save.entity.Save; import io.ejangs.docsa.domain.user.entity.User; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; @@ -25,6 +31,8 @@ public class DocCreateMySqlTxService { private final SaveQueryService saveQueryService; private final ThumbnailRepository thumbnailRepository; + private final DomainEventOutboxPublisher domainEventOutboxPublisher; + @Value("${default.branch}") private String defaultBranchName; @@ -40,9 +48,11 @@ public DocCreateResponse createMySqlPart(String title, User user, .doc(doc) .build()); + domainEventOutboxPublisher.publish(DomainEventType.DOC_CREATED, AggregateType.DOC, + doc.getId(), DocPayloadFactory.created(doc, user.getId(), defaultSave.getId())); + return DocMapper.toCreateResponse(doc, defaultSave); } - } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java index 8844ebaf..ed47f765 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java @@ -6,6 +6,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import io.ejangs.docsa.domain.branch.app.BranchQueryService; import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; @@ -33,6 +35,9 @@ import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; import java.time.LocalDateTime; import java.util.Comparator; @@ -91,6 +96,9 @@ public class DocServiceUnitTests { @Mock private ApplicationEventPublisher eventPublisher; + @Mock + private DomainEventOutboxPublisher domainEventOutboxPublisher; + @Test @DisplayName("문서 생성 성공 - QueryService 검증 후 Orchestrator 호출(CQRS 분리)") void createDoc_delegatesToQueryServiceAndOrchestrator() { @@ -257,6 +265,12 @@ void updateDocTitleSuccess() throws Exception { //then verify(docQueryService).checkTitleDuplicate(userId, newTitle); verify(docQueryService).getByIdAndUserId(docId, userId); + verify(domainEventOutboxPublisher).publish( + eq(DomainEventType.DOC_TITLE_CHANGED), + eq(AggregateType.DOC), + eq(docId), + any() + ); assertEquals(newTitle, doc.getTitle()); assertEquals(response.id(), doc.getId()); assertEquals(response.title(), result.title()); From c0b32bf7bb4593d10c33c856ede5e7d649df089c Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Fri, 8 May 2026 01:59:16 +0900 Subject: [PATCH 08/28] =?UTF-8?q?fix:=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=84=9C=20=EA=B0=B1=EC=8B=A0=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20Slack=20payload=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/scripts/cert_renew.sh | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/infra/scripts/cert_renew.sh b/infra/scripts/cert_renew.sh index 6b4c0dbc..bb8ebfb0 100755 --- a/infra/scripts/cert_renew.sh +++ b/infra/scripts/cert_renew.sh @@ -11,19 +11,21 @@ ENV_FILE="$ROOT/docsa-alert.env" notify_slack () { local msg="$1" [[ -z "${SLACK_WEBHOOK_URL:-}" ]] && return 0 + + local payload + payload="$(python3 -c 'import json, sys; print(json.dumps({"text": sys.argv[1]}))' "$msg")" + curl -sS -X POST -H 'Content-type: application/json' \ - --data "{\"text\":${msg@Q}}" \ + --data "$payload" \ "$SLACK_WEBHOOK_URL" >/dev/null 2>&1 || true } # ---- 1) certbot renew 실행 ---- out="$( docker compose run --rm --no-deps certbot_renew \ - certbot renew --webroot -w /var/www/certbot 2>&1 || true + renew --webroot -w /var/www/certbot 2>&1 || true )" -echo "$out" - # ---- 2) 실패 감지 ---- # certbot이 실패할 때 자주 나오는 문구 위주로 (오탐 줄임) if echo "$out" | grep -Eqi \ From 5ccea77078ca6a491953e119455573265036a980 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Fri, 8 May 2026 01:59:32 +0900 Subject: [PATCH 09/28] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20PreviewExtractor=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/doc/util/PreviewExtractor.java | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 src/main/java/io/ejangs/docsa/domain/doc/util/PreviewExtractor.java diff --git a/src/main/java/io/ejangs/docsa/domain/doc/util/PreviewExtractor.java b/src/main/java/io/ejangs/docsa/domain/doc/util/PreviewExtractor.java deleted file mode 100644 index 4a9f32c3..00000000 --- a/src/main/java/io/ejangs/docsa/domain/doc/util/PreviewExtractor.java +++ /dev/null @@ -1,28 +0,0 @@ -package io.ejangs.docsa.domain.doc.util; - -import java.util.List; -import java.util.Map; - -public class PreviewExtractor { - - public static String doExtractPreview(List> blocks) { - for (int i = blocks.size() - 1; i >= 0; i--) { - Map block = blocks.get(i); - if (block == null) { - continue; - } - Object type = block.get("type"); - Object dataObj = block.get("data"); - - if (!"paragraph".equals(type) || !(dataObj instanceof Map)) { - continue; - } - - Object textObj = ((Map) dataObj).get("text"); - if (textObj instanceof String text && !text.isBlank()) { - return text.length() > 200 ? text.substring(0, 200) + "..." : text; - } - } - return "미리보기 없음"; - } -} From 23cc61867a0c6a373b710bbdcbb20b0d9275f712 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Fri, 8 May 2026 02:00:12 +0900 Subject: [PATCH 10/28] =?UTF-8?q?chore:=20eventoutbox=20=EC=A3=BC=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95(application.yml)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fa11beea..50fefc05 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -66,5 +66,5 @@ domain: outbox: worker: enabled: true - fixed-delay: PT5S - initial-delay: PT3S + fixed-delay: PT10S + initial-delay: PT5S From 869306c63274d2a9c355b99a94b9f449e2bf8798 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Fri, 8 May 2026 02:29:52 +0900 Subject: [PATCH 11/28] =?UTF-8?q?chore:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20outbox=20wake-up=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20Async=20executor=20=EC=B6=94=EA=B0=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20import=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?-=20@EnableAsync=20=EC=84=A4=EC=A0=95=EC=9D=84=20AsyncConfig?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20-=20outbox=20wake-up=20listene?= =?UTF-8?q?r=EA=B0=80=20=EC=A0=84=EC=9A=A9=20executor=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20-?= =?UTF-8?q?=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=EB=A7=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=ED=95=98=EB=8F=84=EB=A1=9D=20fallbackExecuti?= =?UTF-8?q?on=EC=9D=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/ejangs/docsa/DocsaApplication.java | 2 -- .../docsa/domain/doc/app/DocService.java | 2 -- .../app/create/DocCreateMySqlTxService.java | 2 -- .../docsa/global/config/AsyncConfig.java | 25 +++++++++++++++++++ .../app/DomainEventOutboxWakeUpListener.java | 6 ++--- 5 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 src/main/java/io/ejangs/docsa/global/config/AsyncConfig.java diff --git a/src/main/java/io/ejangs/docsa/DocsaApplication.java b/src/main/java/io/ejangs/docsa/DocsaApplication.java index 30409793..76c1fc0f 100644 --- a/src/main/java/io/ejangs/docsa/DocsaApplication.java +++ b/src/main/java/io/ejangs/docsa/DocsaApplication.java @@ -4,7 +4,6 @@ import io.swagger.v3.oas.annotations.info.Info; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @OpenAPIDefinition( @@ -14,7 +13,6 @@ description = "Docsa의 백엔드 API 명세입니다." ) ) -@EnableAsync @EnableScheduling @SpringBootApplication public class DocsaApplication { diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java index f37975ba..1472c9ec 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java @@ -5,7 +5,6 @@ import io.ejangs.docsa.domain.commit.app.CommitQueryService; import io.ejangs.docsa.domain.doc.app.create.DocCreateOrchestrator; import io.ejangs.docsa.domain.doc.app.create.DocQueryService; -import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.edge.app.EdgeService; import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; @@ -32,7 +31,6 @@ import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; -import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java index c5d49a24..6e44fc71 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java @@ -4,11 +4,9 @@ import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; import io.ejangs.docsa.domain.doc.entity.Doc; -import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.doc.thumbnail.dao.ThumbnailRepository; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; -import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.doc.util.DocMapper; import io.ejangs.docsa.domain.save.app.SaveQueryService; import io.ejangs.docsa.domain.save.entity.Save; diff --git a/src/main/java/io/ejangs/docsa/global/config/AsyncConfig.java b/src/main/java/io/ejangs/docsa/global/config/AsyncConfig.java new file mode 100644 index 00000000..9dcfa3fc --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/config/AsyncConfig.java @@ -0,0 +1,25 @@ +package io.ejangs.docsa.global.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + public static final String OUTBOX_WAKE_UP_EXECUTOR = "outboxWakeUpExecutor"; + + @Bean(name = OUTBOX_WAKE_UP_EXECUTOR) + public Executor outboxWakeUpExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("outbox-wakeup-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java index 39924372..8d504dc5 100644 --- a/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java +++ b/src/main/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxWakeUpListener.java @@ -1,5 +1,6 @@ package io.ejangs.docsa.global.outbox.event.app; +import io.ejangs.docsa.global.config.AsyncConfig; import io.ejangs.docsa.global.outbox.event.dto.DomainEventOutboxWakeUpEvent; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Async; @@ -13,10 +14,9 @@ public class DomainEventOutboxWakeUpListener { private final DomainEventOutboxRelay relay; - @Async + @Async(AsyncConfig.OUTBOX_WAKE_UP_EXECUTOR) @TransactionalEventListener( - phase = TransactionPhase.AFTER_COMMIT, - fallbackExecution = true + phase = TransactionPhase.AFTER_COMMIT ) public void handle(DomainEventOutboxWakeUpEvent event) { relay.run(event.outboxId()); From 95a25060fa00bdf6c41a70b5c3a843099e01b1b1 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Sat, 9 May 2026 01:26:25 +0900 Subject: [PATCH 12/28] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20read=20model=20=EA=B0=B1=EC=8B=A0=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EB=B0=9C=ED=96=89=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?-=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EC=83=9D=EC=84=B1,=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9,=20=EC=BB=A4=EB=B0=8B=20=EC=83=9D=EC=84=B1,?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=88=98=EC=A0=95,=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=20=ED=99=95=EC=A0=95,=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=20=ED=9D=90=EB=A6=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=AA=A9=EB=A1=9D=20read=20model=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EC=9A=A9=20domain=20event=20outbox=EB=A5=BC?= =?UTF-8?q?=20=EB=B0=9C=ED=96=89=20=EC=97=B0=EA=B2=B0=20-=20=EB=B8=8C?= =?UTF-8?q?=EB=9E=9C=EC=B9=98=EA=B0=80=20save=EB=A5=BC=20=EA=B0=80?= =?UTF-8?q?=EC=A7=84=EB=8B=A4=EB=8A=94=20=ED=98=84=EC=9E=AC=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=B0=20=EC=BB=A4=EB=B0=8B=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20fixture=EB=A5=BC=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/create/BranchCreateMySqlTxService.java | 8 ++++++++ .../branch/merge/app/MergeMySqlTxService.java | 10 +++++++++- .../commit/app/create/CommitMySqlTxService.java | 14 +++++++++----- .../ejangs/docsa/domain/doc/app/DocService.java | 2 ++ .../doc/readmodel/util/DocPayloadFactory.java | 17 +++++++++++------ .../doc/thumbnail/app/ThumbnailService.java | 10 ++++++++++ .../docsa/domain/save/app/SaveService.java | 12 +++++++++++- .../commit/util/CommitIntegrationTestUtils.java | 10 +++++++++- .../thumbnail/app/ThumbnailServiceUnitTest.java | 7 ++++++- .../domain/save/app/SaveServiceUnitTest.java | 3 +++ 10 files changed, 78 insertions(+), 15 deletions(-) diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java index 72a32197..0a639189 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java @@ -5,8 +5,12 @@ import io.ejangs.docsa.domain.branch.dto.response.BranchCreateResponse; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.branch.util.BranchMapper; +import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.save.app.SaveQueryService; import io.ejangs.docsa.domain.save.entity.Save; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -18,6 +22,7 @@ public class BranchCreateMySqlTxService { private final BranchQueryService branchQueryService; private final SaveQueryService saveQueryService; + private final DomainEventOutboxPublisher domainEventOutboxPublisher; @Transactional(rollbackFor = Exception.class) public BranchCreateResponse createMySqlPart(BranchCreateContext context, String saveContentId) { @@ -27,6 +32,9 @@ public BranchCreateResponse createMySqlPart(BranchCreateContext context, String Save save = saveQueryService.createSave(newBranch, saveContentId); RenewUpdatedAtHelper.touch(save); + domainEventOutboxPublisher.publish(DomainEventType.DOC_ACTIVITY_CHANGED, AggregateType.DOC, context.doc() + .getId(), DocPayloadFactory.activityChanged(context.doc().getId(), save)); + return BranchMapper.toBranchCreateResponse(newBranch, save); } } diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java index 6f72ce06..88ecd024 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java @@ -7,8 +7,12 @@ import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.branch.merge.dto.request.MergeRequest; import io.ejangs.docsa.domain.branch.merge.dto.response.MergeResponse; +import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.save.app.SaveQueryService; import io.ejangs.docsa.domain.save.entity.Save; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -21,6 +25,7 @@ public class MergeMySqlTxService { private final SaveQueryService saveQueryService; private final BranchQueryService branchQueryService; + private final DomainEventOutboxPublisher domainEventOutboxPublisher; public MergeResponse createMySqlPart(MergeContext context, MergeRequest request, String saveMongoId) { @@ -30,7 +35,10 @@ public MergeResponse createMySqlPart(MergeContext context, MergeRequest request, Save save = saveQueryService.createSave(newBranch, saveMongoId); newBranch.setSave(save); branchQueryService.save(newBranch); - RenewUpdatedAtHelper.touch(newBranch); + RenewUpdatedAtHelper.touch(save); + + domainEventOutboxPublisher.publish(DomainEventType.DOC_ACTIVITY_CHANGED, AggregateType.DOC, context.doc() + .getId(), DocPayloadFactory.activityChanged(context.doc().getId(), save)); return new MergeResponse(newBranch.getId(), save.getId()); } diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java index 832bf5de..763d5776 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java @@ -5,12 +5,14 @@ import io.ejangs.docsa.domain.commit.dto.request.CreateCommitRequest; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.commit.util.CommitMapper; +import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.edge.app.EdgeService; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.edge.entity.Edge; import io.ejangs.docsa.domain.edge.util.EdgeMapper; -import io.ejangs.docsa.domain.save.app.SaveQueryService; -import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -22,9 +24,8 @@ public class CommitMySqlTxService { private final CommitQueryService commitQueryService; - private final SaveQueryService saveQueryService; private final EdgeService edgeService; - private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; + private final DomainEventOutboxPublisher domainEventOutboxPublisher; @Transactional(rollbackFor = Exception.class) public Commit createMySqlPart(Doc doc, Branch branch, CreateCommitRequest request, String commitCbsMongoId) { @@ -43,7 +44,10 @@ public Commit createMySqlPart(Doc doc, Branch branch, CreateCommitRequest reques edgeService.saveEdge(newEdge); } - RenewUpdatedAtHelper.touch(branch); + RenewUpdatedAtHelper.touch(branch.getSave()); + + domainEventOutboxPublisher.publish(DomainEventType.DOC_ACTIVITY_CHANGED, AggregateType.DOC, doc.getId(), + DocPayloadFactory.activityChanged(doc.getId(), branch.getSave())); return newCommit; } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java index 1472c9ec..1b71ae6e 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java @@ -133,6 +133,8 @@ public void delete(Long docId, Long userId) { user.removeDocument(doc); + domainEventOutboxPublisher.publish(DomainEventType.DOC_DELETED, AggregateType.DOC, docId, + DocPayloadFactory.deleted(docId)); mongoDeleteJobEnqueuer.enqueueDocDeletion(docId, docDeleteMongoIds); } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocPayloadFactory.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocPayloadFactory.java index 0a39e8c8..8be4e696 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocPayloadFactory.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocPayloadFactory.java @@ -7,6 +7,7 @@ import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocThumbnailChangedPayload; import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocTitleChangedPayload; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import io.ejangs.docsa.domain.save.entity.Save; public final class DocPayloadFactory { @@ -34,21 +35,25 @@ public static DocTitleChangedPayload titleChanged(Doc doc) { ); } - public static DocActivityChangedPayload activityChanged(Doc doc, Long recentSaveId) { + public static DocActivityChangedPayload activityChanged( + Long docId, + Save save + ) { return new DocActivityChangedPayload( - doc.getId(), - recentSaveId, - doc.getUpdatedAt() + docId, + save.getId(), + save.getUpdatedAt() ); } + public static DocThumbnailChangedPayload thumbnailChanged( - Doc doc, + Long docId, String thumbnailObjectKey, ThumbnailStatus thumbnailStatus ) { return new DocThumbnailChangedPayload( - doc.getId(), + docId, thumbnailObjectKey, thumbnailStatus ); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java index 77dc0acf..1c679185 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java @@ -2,11 +2,16 @@ import io.ejangs.docsa.domain.doc.app.create.DocQueryService; import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailResponse; import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.image.app.ImageQueryService; import io.ejangs.docsa.domain.image.entity.Image; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import io.ejangs.docsa.global.outbox.s3.app.S3DeleteJobEnqueuer; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.ImageErrorCode; @@ -25,6 +30,8 @@ public class ThumbnailService { private final ImageQueryService imageQueryService; private final S3DeleteJobEnqueuer s3DeleteJobEnqueuer; + private final DomainEventOutboxPublisher domainEventOutboxPublisher; + @Value("${cloud.aws.s3.public-base-url}") private String cdnUrl; @@ -65,7 +72,10 @@ public ThumbnailResponse finalizeThumbnail( Image previousImage = thumbnail.getCurrentImage(); thumbnail.complete(image, signature); + enqueuePreviousThumbnailDeletion(previousImage, image); + domainEventOutboxPublisher.publish(DomainEventType.DOC_THUMBNAIL_CHANGED, AggregateType.DOC, docId, + DocPayloadFactory.thumbnailChanged(docId, image.getObjectKey(), ThumbnailStatus.READY)); return new ThumbnailResponse( image.getId(), diff --git a/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java b/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java index c47a0afe..87ff4ad8 100644 --- a/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java +++ b/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java @@ -1,6 +1,7 @@ package io.ejangs.docsa.domain.save.app; import com.mongodb.DuplicateKeyException; +import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.doc.thumbnail.app.ThumbnailService; import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse; import io.ejangs.docsa.domain.save.document.SaveContent; @@ -12,6 +13,9 @@ import io.ejangs.docsa.domain.save.util.SaveMapper; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.SaveErrorCode; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,6 +32,8 @@ public class SaveService { private final SaveQueryService saveQueryService; private final ThumbnailService thumbnailService; + private final DomainEventOutboxPublisher domainEventOutboxPublisher; + @Transactional(readOnly = true) public SaveGetResponse getSave(SaveIdentifierDto dto) { Save findSave = getValidSave(dto); @@ -51,7 +57,8 @@ public SaveUpdateResponse updateSave(SaveIdentifierDto dto, SaveUpdateRequest re // MongoDB 저장 try { - SaveContent saveContent = saveQueryService.getSaveContentById(findSave.getSaveMongoId()); + SaveContent saveContent = saveQueryService.getSaveContentById( + findSave.getSaveMongoId()); saveContent.updateContent(request.content()); saveQueryService.saveSaveContent(saveContent); } catch (DuplicateKeyException e) { @@ -63,6 +70,9 @@ public SaveUpdateResponse updateSave(SaveIdentifierDto dto, SaveUpdateRequest re throw new CustomException(SaveErrorCode.FAIL_TO_SAVE); } + domainEventOutboxPublisher.publish(DomainEventType.DOC_ACTIVITY_CHANGED, AggregateType.DOC, + dto.documentId(), DocPayloadFactory.activityChanged(dto.documentId(), findSave)); + return SaveMapper.toSaveUpdateResponse(findSave.getUpdatedAt(), thumbnailSyncResponse); } diff --git a/src/test/java/io/ejangs/docsa/domain/commit/util/CommitIntegrationTestUtils.java b/src/test/java/io/ejangs/docsa/domain/commit/util/CommitIntegrationTestUtils.java index 4a1cf115..e14d4dd0 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/util/CommitIntegrationTestUtils.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/util/CommitIntegrationTestUtils.java @@ -159,7 +159,7 @@ public static TestDocIntegrationDto createDocumentForIntegrationTest(User user, .map(b -> Block.builder().content(b).build()) .toList()); - // 문서 1: 저장 없음, 커밋 여러 개 + // 문서 1: 커밋 여러 개 Doc doc1 = Doc.builder().title("문서 1").user(user).build(); CommitBlockSequence commitSeq10 = commitBlockSequenceRepository.save( @@ -185,6 +185,10 @@ public static TestDocIntegrationDto createDocumentForIntegrationTest(User user, // 브랜치 1의 커밋 설정 Branch branch1 = Branch.builder().name("브랜치 1").doc(doc1).build(); + Save.builder() + .branch(branch1) + .saveMongoId("save-content-branch-1") + .build(); Commit commit10 = Commit.builder() .title("커밋 1").description("desc1") @@ -204,6 +208,10 @@ public static TestDocIntegrationDto createDocumentForIntegrationTest(User user, // 브랜치 2 커밋 설정 Branch branch2 = Branch.builder().name("브랜치 2").doc(doc1).fromCommit(commit20).build(); + Save.builder() + .branch(branch2) + .saveMongoId("save-content-branch-2") + .build(); Commit commit21 = Commit.builder() .title("커밋 A").description("descA") diff --git a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java index 55aaa73f..d1635d73 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java @@ -15,6 +15,7 @@ import io.ejangs.docsa.domain.image.entity.Image; import io.ejangs.docsa.domain.image.entity.Image.ImageStatus; import io.ejangs.docsa.domain.image.entity.Image.Purpose; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; import io.ejangs.docsa.global.outbox.s3.app.S3DeleteJobEnqueuer; import io.ejangs.docsa.global.outbox.s3.dao.S3DeleteOutboxRepository; import io.ejangs.docsa.global.outbox.s3.entity.S3DeleteOutbox; @@ -41,6 +42,9 @@ class ThumbnailServiceUnitTest { @Mock private S3DeleteOutboxRepository s3DeleteOutboxRepository; + @Mock + private DomainEventOutboxPublisher domainEventOutboxPublisher; + private ThumbnailService thumbnailService; @BeforeEach @@ -51,7 +55,8 @@ void setUp() { thumbnailQueryService, docQueryService, imageQueryService, - s3DeleteJobEnqueuer + s3DeleteJobEnqueuer, + domainEventOutboxPublisher ); ReflectionTestUtils.setField(thumbnailService, "cdnUrl", "https://cdn.example.com"); } diff --git a/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java index ae6c252f..1d9fb0e2 100644 --- a/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java @@ -21,6 +21,7 @@ import io.ejangs.docsa.domain.save.util.SaveMapper; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.SaveErrorCode; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -45,6 +46,8 @@ class SaveServiceUnitTest { private Save mockSave; @Mock private SaveContent mockSaveContent; + @Mock + private DomainEventOutboxPublisher domainEventOutboxPublisher; @InjectMocks private SaveService saveService; From 2f22942256dcd260338ff8b5ed9f9110dfbbba90 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Sat, 9 May 2026 02:18:48 +0900 Subject: [PATCH 13/28] =?UTF-8?q?test:=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20read=20model=20projection=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DocListProjector의 이벤트별 read model 반영과 eventId 기반 idempotency를 단위 테스트로 검증 - 오래된 DOC_DELETED 이벤트가 최신 read model 상태를 덮어쓰지 않도록 lastProjectedEventId 반영을 보정 - DomainEventOutboxRelay의 DONE/retry/FAILED 상태 전이를 통합 테스트로 검증 - relay, dispatcher, projector, Mongo read model까지 이어지는 DOC_CREATED projection 통합 테스트를 추가 --- .../doc/readmodel/app/DocListProjector.java | 20 +- .../readmodel/document/DocListReadModel.java | 23 ++- .../app/DocListProjectorIntegrationTest.java | 109 ++++++++++ .../app/DocListProjectorUnitTest.java | 191 ++++++++++++++++++ ...DomainEventOutboxRelayIntegrationTest.java | 98 +++++++++ src/test/resources/application-test.yml | 9 +- 6 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java create mode 100644 src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java create mode 100644 src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java index 6e15cb8c..07af8354 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjector.java @@ -53,8 +53,9 @@ private void changeTitle(DomainEventMessage message) { docListReadModelRepository.findById(payload.docId()) .ifPresent(model -> { - model.changeTitle(payload, message.eventId()); - docListReadModelRepository.save(model); + if (model.changeTitle(payload, message.eventId())) { + docListReadModelRepository.save(model); + } }); } @@ -63,8 +64,9 @@ private void changeActivity(DomainEventMessage message) { docListReadModelRepository.findById(payload.docId()) .ifPresent(model -> { - model.changeActivity(payload, message.eventId()); - docListReadModelRepository.save(model); + if (model.changeActivity(payload, message.eventId())) { + docListReadModelRepository.save(model); + } }); } @@ -73,8 +75,9 @@ private void changeThumbnail(DomainEventMessage message) { docListReadModelRepository.findById(payload.docId()) .ifPresent(model -> { - model.changeThumbnail(payload, message.eventId()); - docListReadModelRepository.save(model); + if (model.changeThumbnail(payload, message.eventId())) { + docListReadModelRepository.save(model); + } }); } @@ -83,8 +86,9 @@ private void delete(DomainEventMessage message) { docListReadModelRepository.findById(payload.docId()) .ifPresent(model -> { - model.markDeleted(); - docListReadModelRepository.save(model); + if (model.markDeleted(message.eventId())) { + docListReadModelRepository.save(model); + } }); } } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java index 496c5c96..4b5a576f 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java @@ -51,40 +51,49 @@ public static DocListReadModel create(DocCreatedPayload payload, Long eventId) { return model; } - public void changeTitle(DocTitleChangedPayload payload, Long eventId) { + public boolean changeTitle(DocTitleChangedPayload payload, Long eventId) { if (isAlreadyProjected(eventId)) { - return; + return false; } this.title = payload.title(); this.updatedAt = payload.updatedAt(); this.deleted = false; this.lastProjectedEventId = eventId; + return true; } - public void changeActivity(DocActivityChangedPayload payload, Long eventId) { + public boolean changeActivity(DocActivityChangedPayload payload, Long eventId) { if (isAlreadyProjected(eventId)) { - return; + return false; } this.recentSaveId = payload.recentSaveId(); this.updatedAt = payload.updatedAt(); this.deleted = false; this.lastProjectedEventId = eventId; + return true; } - public void changeThumbnail(DocThumbnailChangedPayload payload, Long eventId) { + public boolean changeThumbnail(DocThumbnailChangedPayload payload, Long eventId) { if (isAlreadyProjected(eventId)) { - return; + return false; } this.thumbnailObjectKey = payload.thumbnailObjectKey(); this.thumbnailStatus = payload.thumbnailStatus(); this.lastProjectedEventId = eventId; + return true; } - public void markDeleted() { + public boolean markDeleted(Long eventId) { + if (isAlreadyProjected(eventId)) { + return false; + } + this.deleted = true; + this.lastProjectedEventId = eventId; + return true; } private boolean isAlreadyProjected(Long eventId) { diff --git a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java new file mode 100644 index 00000000..9540857e --- /dev/null +++ b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java @@ -0,0 +1,109 @@ +package io.ejangs.docsa.domain.doc.readmodel.app; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.ejangs.docsa.domain.doc.readmodel.dao.mongodb.DocListReadModelRepository; +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxRelay; +import io.ejangs.docsa.global.outbox.event.dao.DomainEventOutboxRepository; +import io.ejangs.docsa.global.outbox.event.entity.DomainEventOutbox; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class DocListProjectorIntegrationTest { + + @Autowired + private DomainEventOutboxRelay domainEventOutboxRelay; + + @Autowired + private DomainEventOutboxRepository domainEventOutboxRepository; + + @Autowired + private DocListReadModelRepository docListReadModelRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long docId; + + @BeforeEach + void setUp() { + docId = System.currentTimeMillis(); + domainEventOutboxRepository.deleteAllInBatch(); + domainEventOutboxRepository.flush(); + docListReadModelRepository.deleteById(docId); + } + + @AfterEach + void cleanUp() { + docListReadModelRepository.deleteById(docId); + } + + @Test + @DisplayName("Domain event relay가 DOC_CREATED를 dispatch하면 문서 목록 read model이 생성된다") + void relayProjector_success_docCreated() throws Exception { + LocalDateTime createdAt = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime updatedAt = LocalDateTime.of(2026, 1, 2, 10, 0); + DocCreatedPayload payload = new DocCreatedPayload( + docId, + 2L, + "테스트 문서", + createdAt, + updatedAt, + 10L, + "thumbnail-1", + ThumbnailStatus.READY + ); + DomainEventOutbox outbox = domainEventOutboxRepository.saveAndFlush( + DomainEventOutbox.open( + DomainEventType.DOC_CREATED, + AggregateType.DOC, + docId.toString(), + objectMapper.writeValueAsString(payload) + ) + ); + jdbcTemplate.update( + "update domain_event_outbox set payload = ? format json where id = ?", + objectMapper.writeValueAsString(payload), + outbox.getId() + ); + + domainEventOutboxRelay.run(); + + DomainEventOutbox done = domainEventOutboxRepository.findById(outbox.getId()).orElseThrow(); + Optional readModel = docListReadModelRepository.findById(docId); + + assertThat(done.getStatus()).isEqualTo(OutboxStatus.DONE); + assertThat(readModel).isPresent(); + DocListReadModel model = readModel.get(); + assertThat(model.getId()).isEqualTo(docId); + assertThat(model.getUserId()).isEqualTo(2L); + assertThat(model.getTitle()).isEqualTo("테스트 문서"); + assertThat(model.getCreatedAt()).isEqualTo(createdAt); + assertThat(model.getUpdatedAt()).isEqualTo(updatedAt); + assertThat(model.getRecentSaveId()).isEqualTo(10L); + assertThat(model.getThumbnailObjectKey()).isEqualTo("thumbnail-1"); + assertThat(model.getThumbnailStatus()).isEqualTo(ThumbnailStatus.READY); + assertThat(model.getLastProjectedEventId()).isEqualTo(outbox.getId()); + } +} diff --git a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java new file mode 100644 index 00000000..4e9ae1df --- /dev/null +++ b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorUnitTest.java @@ -0,0 +1,191 @@ +package io.ejangs.docsa.domain.doc.readmodel.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import io.ejangs.docsa.domain.doc.readmodel.dao.mongodb.DocListReadModelRepository; +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocActivityChangedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocDeletedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocThumbnailChangedPayload; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocTitleChangedPayload; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import io.ejangs.docsa.global.outbox.event.dto.DomainEventMessage; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DocListProjectorUnitTest { + + @Mock + private DocListReadModelRepository docListReadModelRepository; + + private ObjectMapper objectMapper; + private DocListProjector docListProjector; + + private final Long docId = 1L; + private final Long userId = 2L; + private final LocalDateTime createdAt = LocalDateTime.of(2026, 1, 1, 10, 0); + private final LocalDateTime updatedAt = LocalDateTime.of(2026, 1, 2, 10, 0); + + @BeforeEach + void setUp() { + objectMapper = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .build(); + docListProjector = new DocListProjector(docListReadModelRepository, objectMapper); + } + + @Test + @DisplayName("DOC_CREATED 이벤트를 문서 목록 read model로 생성한다") + void project_success_docCreated() throws Exception { + DocCreatedPayload payload = createdPayload(); + when(docListReadModelRepository.existsById(docId)).thenReturn(false); + + docListProjector.project(message(1L, DomainEventType.DOC_CREATED, payload)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(DocListReadModel.class); + verify(docListReadModelRepository).save(captor.capture()); + + DocListReadModel saved = captor.getValue(); + assertThat(saved.getId()).isEqualTo(docId); + assertThat(saved.getUserId()).isEqualTo(userId); + assertThat(saved.getTitle()).isEqualTo("초기 제목"); + assertThat(saved.getCreatedAt()).isEqualTo(createdAt); + assertThat(saved.getUpdatedAt()).isEqualTo(updatedAt); + assertThat(saved.getRecentSaveId()).isEqualTo(10L); + assertThat(saved.getThumbnailObjectKey()).isEqualTo("thumbnail-1"); + assertThat(saved.getThumbnailStatus()).isEqualTo(ThumbnailStatus.READY); + assertThat(saved.isDeleted()).isFalse(); + assertThat(saved.getLastProjectedEventId()).isEqualTo(1L); + } + + @Test + @DisplayName("DOC_TITLE_CHANGED 이벤트를 문서 목록 read model에 반영한다") + void project_success_docTitleChanged() throws Exception { + DocListReadModel model = existingModel(1L); + LocalDateTime titleUpdatedAt = LocalDateTime.of(2026, 1, 3, 10, 0); + DocTitleChangedPayload payload = new DocTitleChangedPayload(docId, "변경 제목", titleUpdatedAt); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(2L, DomainEventType.DOC_TITLE_CHANGED, payload)); + + verify(docListReadModelRepository).save(model); + assertThat(model.getTitle()).isEqualTo("변경 제목"); + assertThat(model.getUpdatedAt()).isEqualTo(titleUpdatedAt); + assertThat(model.isDeleted()).isFalse(); + assertThat(model.getLastProjectedEventId()).isEqualTo(2L); + } + + @Test + @DisplayName("DOC_ACTIVITY_CHANGED 이벤트를 문서 목록 read model에 반영한다") + void project_success_docActivityChanged() throws Exception { + DocListReadModel model = existingModel(1L); + LocalDateTime activityUpdatedAt = LocalDateTime.of(2026, 1, 4, 10, 0); + DocActivityChangedPayload payload = new DocActivityChangedPayload(docId, 20L, activityUpdatedAt); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(2L, DomainEventType.DOC_ACTIVITY_CHANGED, payload)); + + verify(docListReadModelRepository).save(model); + assertThat(model.getRecentSaveId()).isEqualTo(20L); + assertThat(model.getUpdatedAt()).isEqualTo(activityUpdatedAt); + assertThat(model.isDeleted()).isFalse(); + assertThat(model.getLastProjectedEventId()).isEqualTo(2L); + } + + @Test + @DisplayName("DOC_THUMBNAIL_CHANGED 이벤트를 문서 목록 read model에 반영한다") + void project_success_docThumbnailChanged() throws Exception { + DocListReadModel model = existingModel(1L); + DocThumbnailChangedPayload payload = + new DocThumbnailChangedPayload(docId, "thumbnail-2", ThumbnailStatus.PENDING); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(2L, DomainEventType.DOC_THUMBNAIL_CHANGED, payload)); + + verify(docListReadModelRepository).save(model); + assertThat(model.getThumbnailObjectKey()).isEqualTo("thumbnail-2"); + assertThat(model.getThumbnailStatus()).isEqualTo(ThumbnailStatus.PENDING); + assertThat(model.getLastProjectedEventId()).isEqualTo(2L); + } + + @Test + @DisplayName("DOC_DELETED 이벤트를 문서 목록 read model에 반영한다") + void project_success_docDeleted() throws Exception { + DocListReadModel model = existingModel(1L); + DocDeletedPayload payload = new DocDeletedPayload(docId); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(2L, DomainEventType.DOC_DELETED, payload)); + + verify(docListReadModelRepository).save(model); + assertThat(model.isDeleted()).isTrue(); + assertThat(model.getLastProjectedEventId()).isEqualTo(2L); + } + + @Test + @DisplayName("이미 처리한 eventId 이하의 이벤트는 무시한다") + void project_ignore_alreadyProjectedEvent() throws Exception { + DocListReadModel model = existingModel(10L); + DocDeletedPayload payload = new DocDeletedPayload(docId); + when(docListReadModelRepository.findById(docId)).thenReturn(Optional.of(model)); + + docListProjector.project(message(9L, DomainEventType.DOC_DELETED, payload)); + + verify(docListReadModelRepository, never()).save(any()); + assertThat(model.isDeleted()).isFalse(); + assertThat(model.getLastProjectedEventId()).isEqualTo(10L); + } + + private DocListReadModel existingModel(Long eventId) { + return DocListReadModel.create(createdPayload(), eventId); + } + + private DocCreatedPayload createdPayload() { + return new DocCreatedPayload( + docId, + userId, + "초기 제목", + createdAt, + updatedAt, + 10L, + "thumbnail-1", + ThumbnailStatus.READY + ); + } + + private DomainEventMessage message( + Long eventId, + DomainEventType eventType, + Object payload + ) throws JsonProcessingException { + return new DomainEventMessage( + eventId, + eventType, + AggregateType.DOC, + docId.toString(), + objectMapper.writeValueAsString(payload), + LocalDateTime.now() + ); + } +} diff --git a/src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java b/src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java new file mode 100644 index 00000000..972ddd3f --- /dev/null +++ b/src/test/java/io/ejangs/docsa/global/outbox/event/app/DomainEventOutboxRelayIntegrationTest.java @@ -0,0 +1,98 @@ +package io.ejangs.docsa.global.outbox.event.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +import io.ejangs.docsa.global.outbox.OutboxStatus; +import io.ejangs.docsa.global.outbox.event.app.dispatcher.DomainEventDispatcher; +import io.ejangs.docsa.global.outbox.event.dao.DomainEventOutboxRepository; +import io.ejangs.docsa.global.outbox.event.dto.DomainEventMessage; +import io.ejangs.docsa.global.outbox.event.entity.DomainEventOutbox; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest +@ActiveProfiles("test") +class DomainEventOutboxRelayIntegrationTest { + + @Autowired + private DomainEventOutboxRelay domainEventOutboxRelay; + + @Autowired + private DomainEventOutboxRepository domainEventOutboxRepository; + + @MockitoBean + private DomainEventDispatcher domainEventDispatcher; + + @BeforeEach + void cleanOutbox() { + domainEventOutboxRepository.deleteAllInBatch(); + domainEventOutboxRepository.flush(); + } + + @Test + @DisplayName("Domain event relay가 OPEN 건을 처리하면 DONE으로 완료된다") + void relayDone() { + DomainEventOutbox outbox = createOpenOutbox(); + + domainEventOutboxRelay.run(); + + DomainEventOutbox done = domainEventOutboxRepository.findById(outbox.getId()).orElseThrow(); + assertThat(done.getStatus()).isEqualTo(OutboxStatus.DONE); + assertThat(done.getRetryCount()).isEqualTo(0); + assertThat(done.getDoneAt()).isNotNull(); + verify(domainEventDispatcher).dispatch(any(DomainEventMessage.class)); + } + + @Test + @DisplayName("Domain event relay 처리 중 예외가 발생하면 retryCount 증가 후 OPEN 상태로 복귀한다") + void relayRetryToOpen() { + DomainEventOutbox outbox = createOpenOutbox(); + doThrow(new RuntimeException("dispatch fail")) + .when(domainEventDispatcher).dispatch(any(DomainEventMessage.class)); + + domainEventOutboxRelay.run(); + + DomainEventOutbox retried = domainEventOutboxRepository.findById(outbox.getId()).orElseThrow(); + assertThat(retried.getStatus()).isEqualTo(OutboxStatus.OPEN); + assertThat(retried.getRetryCount()).isEqualTo(1); + assertThat(retried.getLastError()).contains("dispatch fail"); + } + + @Test + @DisplayName("Domain event relay 예외가 maxRetry(10회) 누적되면 FAILED 상태가 된다") + void relayFailedAfterMaxRetry() { + DomainEventOutbox outbox = createOpenOutbox(); + doThrow(new RuntimeException("always fail")) + .when(domainEventDispatcher).dispatch(any(DomainEventMessage.class)); + + for (int i = 0; i < 10; i++) { + domainEventOutboxRelay.run(); + } + + DomainEventOutbox failed = domainEventOutboxRepository.findById(outbox.getId()).orElseThrow(); + assertThat(failed.getStatus()).isEqualTo(OutboxStatus.FAILED); + assertThat(failed.getRetryCount()).isEqualTo(10); + assertThat(failed.getLastError()).contains("always fail"); + } + + private DomainEventOutbox createOpenOutbox() { + DomainEventOutbox outbox = DomainEventOutbox.open( + DomainEventType.DOC_CREATED, + AggregateType.DOC, + UUID.randomUUID().toString(), + "{}" + ); + return domainEventOutboxRepository.saveAndFlush(outbox); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 04c2b1c6..d6728217 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -15,7 +15,7 @@ spring: jpa: hibernate: ddl-auto: create - show-sql: true + show-sql: false database-platform: org.hibernate.dialect.H2Dialect h2: console: @@ -70,6 +70,13 @@ s3: fixed-delay: PT1M initial-delay: PT24H +domain: + event: + outbox: + worker: + fixed-delay: PT1M + initial-delay: PT24H + cloud: aws: From a555f64f089e89e12db53edb468f4aa64d67a9a2 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Sat, 9 May 2026 02:24:24 +0900 Subject: [PATCH 14/28] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20im?= =?UTF-8?q?port=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docsa/domain/branch/merge/app/MergeMySqlTxService.java | 2 -- .../docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java | 2 -- .../java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java | 1 - .../docsa/domain/commit/app/CreateCommitIntegrationTest.java | 1 - .../domain/doc/integration/DocServiceIntegrationTests.java | 1 - .../doc/readmodel/app/DocListProjectorIntegrationTest.java | 1 - 6 files changed, 8 deletions(-) diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java index 88ecd024..26e1af9a 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java @@ -3,8 +3,6 @@ import io.ejangs.docsa.domain.branch.app.BranchQueryService; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.branch.merge.app.MergeService.MergeContext; -import io.ejangs.docsa.domain.commit.entity.Commit; -import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.branch.merge.dto.request.MergeRequest; import io.ejangs.docsa.domain.branch.merge.dto.response.MergeResponse; import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java index 72a0e97e..6ca46b3b 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/dao/ThumbnailRepository.java @@ -2,8 +2,6 @@ import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; import jakarta.persistence.LockModeType; -import java.util.Collection; -import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; diff --git a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java index 67d69a7f..7090c689 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java @@ -5,7 +5,6 @@ import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; import io.ejangs.docsa.domain.doc.entity.Doc; -import io.ejangs.docsa.domain.doc.thumbnail.dao.ThumbnailRepository; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; import java.util.List; import java.util.Map; diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java index 1e74bed4..83e1b2b3 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CreateCommitIntegrationTest.java @@ -4,7 +4,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.when; import com.fasterxml.jackson.core.JsonProcessingException; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java index 9b7c295e..e55f622c 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java @@ -4,7 +4,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java index 9540857e..30ad0848 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListProjectorIntegrationTest.java @@ -15,7 +15,6 @@ import io.ejangs.docsa.global.outbox.event.model.DomainEventType; import java.time.LocalDateTime; import java.util.Optional; -import java.util.UUID; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; From a0d5fd0542886b07a476e064c2b352f4f43b16c3 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Mon, 11 May 2026 01:10:09 +0900 Subject: [PATCH 15/28] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20read=20model=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20QueryService=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20-=20DocService=EB=A5=BC=20DocCommandService=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EA=B3=A0=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D/=EA=B2=80=EC=83=89/=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=ED=94=84=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20DocQueryService?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20-=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D/=EA=B2=80=EC=83=89=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20DocListReadModel=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=A4=ED=95=91=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20-=20=EA=B8=B0=EC=A1=B4=20SQL=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=EB=A6=BD=EC=9A=A9=20DocListAssembler?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20read=20model?= =?UTF-8?q?=20mapper=EB=A5=BC=20=EC=B6=94=EA=B0=80=20-=20command/query=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=EC=97=90=20=EB=A7=9E=EC=B6=B0=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../domain/branch/app/BranchService.java | 6 +- .../domain/branch/merge/app/MergeService.java | 6 +- .../domain/commit/app/CommitService.java | 10 +- .../docsa/domain/doc/api/DocController.java | 20 +- ...DocService.java => DocCommandService.java} | 77 +--- .../docsa/domain/doc/app/DocQueryService.java | 80 ++++ .../DocQueryService.java => DocReader.java} | 16 +- .../app/create/DocCreateMySqlTxService.java | 5 +- .../util/DocListReadModelMapper.java | 40 ++ .../doc/thumbnail/app/ThumbnailService.java | 8 +- .../domain/doc/util/DocListAssembler.java | 85 ----- .../docsa/domain/image/app/ImageService.java | 6 +- .../domain/branch/app/BranchServiceTest.java | 12 +- .../commit/app/CommitServiceMockTest.java | 18 +- .../domain/commit/app/GetCommitMockTest.java | 12 +- ...=> DocCommandServiceIntegrationTests.java} | 178 +-------- .../integration/DocGraphIntegrationTest.java | 6 +- .../app/ThumbnailServiceUnitTest.java | 6 +- .../doc/unit/DocCommandServiceUnitTests.java | 182 +++++++++ .../doc/unit/DocControllerUnitTests.java | 20 +- .../doc/unit/DocQueryServiceUnitTests.java | 193 ++++++++++ .../domain/doc/unit/DocServiceUnitTests.java | 345 ------------------ .../image/app/ImageServiceUnitTest.java | 12 +- .../merge/app/MergeServiceMockTest.java | 12 +- 25 files changed, 606 insertions(+), 751 deletions(-) rename src/main/java/io/ejangs/docsa/domain/doc/app/{DocService.java => DocCommandService.java} (50%) create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java rename src/main/java/io/ejangs/docsa/domain/doc/app/{create/DocQueryService.java => DocReader.java} (79%) create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocListReadModelMapper.java delete mode 100644 src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java rename src/test/java/io/ejangs/docsa/domain/doc/integration/{DocServiceIntegrationTests.java => DocCommandServiceIntegrationTests.java} (60%) create mode 100644 src/test/java/io/ejangs/docsa/domain/doc/unit/DocCommandServiceUnitTests.java create mode 100644 src/test/java/io/ejangs/docsa/domain/doc/unit/DocQueryServiceUnitTests.java delete mode 100644 src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java diff --git a/build.gradle b/build.gradle index f9ce7be2..7d6b5125 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,7 @@ repositories { test { testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" + events "passed", "skipped", "failed" exceptionFormat "full" showCauses true showExceptions true diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java index 514a4a6e..236f2570 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java @@ -10,7 +10,7 @@ import io.ejangs.docsa.domain.commit.app.CommitQueryService; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.commit.entity.Commit; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.edge.app.EdgeService; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.global.exception.CustomException; @@ -34,7 +34,7 @@ @RequiredArgsConstructor public class BranchService { - private final DocQueryService docQueryService; + private final DocReader docReader; private final BranchQueryService branchQueryService; private final CommitQueryService commitQueryService; private final CommitBlockSequenceRepository commitBlockSequenceRepository; @@ -52,7 +52,7 @@ public BranchCreateResponse createBranch(Long documentId, BranchCreateRequest re private BranchCreateContext prepareBranchCreateContext(Long documentId, BranchCreateRequest request, Long userId) { - docQueryService.checkByIdAndUserId(documentId, userId); + docReader.checkByIdAndUserId(documentId, userId); Long fromCommitId = request.fromCommitId(); diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java index ecb7111f..7c77c484 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java @@ -4,7 +4,7 @@ import io.ejangs.docsa.domain.commit.app.CommitContentAssembler; import io.ejangs.docsa.domain.commit.app.CommitQueryService; import io.ejangs.docsa.domain.commit.entity.Commit; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.branch.merge.dto.request.MergeRequest; import io.ejangs.docsa.domain.branch.merge.dto.response.MergeResponse; @@ -16,7 +16,7 @@ @RequiredArgsConstructor public class MergeService { - private final DocQueryService docQueryService; + private final DocReader docReader; private final BranchQueryService branchQueryService; private final CommitQueryService commitQueryService; private final CommitContentAssembler commitContentAssembler; @@ -32,7 +32,7 @@ public MergeResponse merge(Long docId, MergeRequest mergeRequest, Long userId) { } private MergeContext validateMerge(Long docId, MergeRequest mergeRequest, Long userId) { - Doc doc = docQueryService.getByIdAndUserId(docId, userId); + Doc doc = docReader.getByIdAndUserId(docId, userId); branchQueryService.checkDuplicatedWithBranchName(docId, mergeRequest.branchName()); Commit baseCommit = commitQueryService.getById(mergeRequest.baseCommitId()); diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java index 989a5bad..83deb505 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java @@ -8,7 +8,7 @@ import io.ejangs.docsa.domain.commit.dto.response.CreateCommitResponse; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.commit.util.CommitMapper; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.edge.app.EdgeService; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.global.exception.CustomException; @@ -29,7 +29,7 @@ @RequiredArgsConstructor public class CommitService { - private final DocQueryService docQueryService; + private final DocReader docReader; private final BranchQueryService branchQueryService; private final CommitQueryService commitQueryService; private final EdgeService edgeService; @@ -45,7 +45,7 @@ public CreateCommitResponse createCommit(Long docId, branchQueryService.checkBranchInDocOwnedByUser(docId, request.branchId(), userId); - Doc doc = docQueryService.getById(docId); + Doc doc = docReader.getById(docId); Branch branch = branchQueryService.getById(request.branchId()); String baseCommitCbsMongoId = commitQueryService.resolveBaseCommitCbsMongoId(branch); @@ -57,7 +57,7 @@ public CreateCommitResponse createCommit(Long docId, } public CommitResponse getCommit(Long docId, Long commitId, Long userId) { - docQueryService.checkByIdAndUserId(docId, userId); + docReader.checkByIdAndUserId(docId, userId); List> content = getWholeContent(commitId); return CommitMapper.toCommitResponse(content); } @@ -69,7 +69,7 @@ private List> getWholeContent(Long commitId) { @Transactional(rollbackFor = Exception.class) public void deleteCommit(Long docId, Long commitId, Long userId) { - Doc doc = docQueryService.getByIdAndUserId(docId, userId); + Doc doc = docReader.getByIdAndUserId(docId, userId); Commit commit = commitQueryService.getById(commitId); // LeafCommit일 경우에만 삭제 가능 diff --git a/src/main/java/io/ejangs/docsa/domain/doc/api/DocController.java b/src/main/java/io/ejangs/docsa/domain/doc/api/DocController.java index ebd8c9c8..76bfb634 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/api/DocController.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/api/DocController.java @@ -1,6 +1,7 @@ package io.ejangs.docsa.domain.doc.api; -import io.ejangs.docsa.domain.doc.app.DocService; +import io.ejangs.docsa.domain.doc.app.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocCommandService; import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; import io.ejangs.docsa.domain.edge.dto.GraphResponse; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; @@ -40,7 +41,8 @@ @RequestMapping("/api/document") public class DocController { - private final DocService docService; + private final DocCommandService docCommandService; + private final DocQueryService docQueryService; @PostMapping @CreateDocDocs @@ -49,7 +51,7 @@ public ResponseEntity create( @Valid @RequestBody DocTitleRequest request) { return ResponseEntity .status(HttpStatus.CREATED) - .body(docService.create(request, userDetails.getId())); + .body(docCommandService.create(request, userDetails.getId())); } @GetMapping("/sidebar") @@ -65,7 +67,7 @@ public ResponseEntity> readListSidebar( return ResponseEntity .status(HttpStatus.OK) - .body(docService.getSimplePage(userDetails.getId(), pageable)); + .body(docQueryService.getSimplePage(userDetails.getId(), pageable)); } @GetMapping @@ -81,7 +83,7 @@ public ResponseEntity> readList( return ResponseEntity .status(HttpStatus.OK) - .body(docService.getPage(userDetails.getId(), pageable)); + .body(docQueryService.getPage(userDetails.getId(), pageable)); } @SearchDocDocs @@ -96,7 +98,7 @@ public ResponseEntity> search( Pageable pageable = PageableFactory.create(sort, order, page, size); return ResponseEntity .status(HttpStatus.OK) - .body(docService.searchList(userDetails.getId(), keyword, pageable)); + .body(docQueryService.searchList(userDetails.getId(), keyword, pageable)); } @RenameDocDocs @@ -107,7 +109,7 @@ public ResponseEntity rename( @Valid @RequestBody DocTitleRequest request) { return ResponseEntity .status(HttpStatus.OK) - .body(docService.updateTitle(userDetails.getId(), docId, request)); + .body(docCommandService.updateTitle(userDetails.getId(), docId, request)); } @GetDocGraphDocs @@ -116,7 +118,7 @@ public ResponseEntity getGraph( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long docId) { return ResponseEntity.status(HttpStatus.OK) - .body(docService.getGraph(userDetails.getId(), docId)); + .body(docQueryService.getGraph(userDetails.getId(), docId)); } @DeleteDocDocs @@ -124,7 +126,7 @@ public ResponseEntity getGraph( public ResponseEntity delete( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long docId) { - docService.delete(docId, userDetails.getId()); + docCommandService.delete(docId, userDetails.getId()); return ResponseEntity .status(HttpStatus.NO_CONTENT).build(); } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocCommandService.java similarity index 50% rename from src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java rename to src/main/java/io/ejangs/docsa/domain/doc/app/DocCommandService.java index 1b71ae6e..ec15aed0 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocCommandService.java @@ -1,41 +1,27 @@ package io.ejangs.docsa.domain.doc.app; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; import io.ejangs.docsa.domain.branch.entity.Branch; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; import io.ejangs.docsa.domain.doc.app.create.DocCreateOrchestrator; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; -import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; -import io.ejangs.docsa.domain.edge.app.EdgeService; -import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; -import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; -import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; -import io.ejangs.docsa.domain.edge.dto.GraphResponse; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; import io.ejangs.docsa.domain.doc.dto.response.DocTitleUpdateResponse; import io.ejangs.docsa.domain.doc.entity.Doc; -import io.ejangs.docsa.domain.edge.entity.Edge; -import io.ejangs.docsa.domain.doc.util.DocListAssembler; +import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.doc.util.DocMapper; -import io.ejangs.docsa.domain.edge.util.GraphMapper; +import io.ejangs.docsa.domain.edge.app.EdgeService; +import io.ejangs.docsa.domain.edge.entity.Edge; import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.exception.CustomException; -import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; import io.ejangs.docsa.global.outbox.event.model.AggregateType; import io.ejangs.docsa.global.outbox.event.model.DomainEventType; -import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import io.ejangs.docsa.global.outbox.mongo.app.MongoDeleteJobEnqueuer; +import io.ejangs.docsa.global.outbox.mongo.dto.MongoIdsDto; import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -43,57 +29,36 @@ @Slf4j @Service @RequiredArgsConstructor -public class DocService { +public class DocCommandService { - private final DocQueryService docQueryService; - private final BranchQueryService branchQueryService; - private final CommitQueryService commitQueryService; + private final DocReader docReader; private final EdgeService edgeService; private final DocCreateOrchestrator docCreateOrchestrator; private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; - private final DocListAssembler docListAssembler; private final MongoIdsCollector mongoIdsCollector; private final DomainEventOutboxPublisher domainEventOutboxPublisher; public DocCreateResponse create(DocTitleRequest request, Long userId) { - User user = docQueryService.getUserOrThrow(userId); + User user = docReader.getUserOrThrow(userId); String title = request.title(); - docQueryService.checkTitleDuplicate(userId, title); + docReader.checkTitleDuplicate(userId, title); return docCreateOrchestrator.create(title, user); } - @Transactional(readOnly = true) - public Page getSimplePage(Long userId, Pageable pageable) { - Page docs = docQueryService.getPageByUserId(userId, pageable); - return docListAssembler.assembleDocListSimple(docs); - } - - @Transactional(readOnly = true) - public Page getPage(Long userId, Pageable pageable) { - Page docs = docQueryService.getPageByUserId(userId, pageable); - return docListAssembler.assembleDocList(docs); - } - - @Transactional(readOnly = true) - public Page searchList(Long userId, String keyword, Pageable pageable) { - Page docs = docQueryService.searchByTitle(keyword, userId, pageable); - return docListAssembler.assembleDocList(docs); - } - @Transactional(rollbackFor = Exception.class) public DocTitleUpdateResponse updateTitle(Long userId, Long docId, DocTitleRequest request) { String title = request.title(); - Doc doc = docQueryService.getByIdAndUserId(docId, userId); + Doc doc = docReader.getByIdAndUserId(docId, userId); if (title.equals(doc.getTitle())) { throw new CustomException(DocErrorCode.SAME_AS_CURRENT_TITLE); } - docQueryService.checkTitleDuplicate(userId, title); + docReader.checkTitleDuplicate(userId, title); doc.updateTitle(title); doc.updateTimestamp(); @@ -103,28 +68,10 @@ public DocTitleUpdateResponse updateTitle(Long userId, Long docId, DocTitleReque return DocMapper.toUpdateResponse(doc); } - // 문서 조회시 그래프를 그리기 위한 응답 생성 - @Transactional(readOnly = true) - public GraphResponse getGraph(Long userId, Long documentId) { - - String docTitle = docQueryService.getByIdAndUserId(documentId, userId).getTitle(); - - // Branch, Commit, Edge 각각 별도 조회 (Projection 쿼리) - List branches = branchQueryService.getBranchGraphList(documentId); - if (branches.isEmpty()) { - throw new CustomException(BranchErrorCode.BRANCH_NOT_FOUND); - } - List commits = commitQueryService.getCommitGraphList(documentId); - List edges = edgeService.getEdgeDtoByDocId(documentId); - - return GraphMapper.toCommitGraphResponse(docTitle, commits, edges, branches); - } - - @Transactional(rollbackFor = Exception.class) public void delete(Long docId, Long userId) { - User user = docQueryService.getUserOrThrow(userId); - Doc doc = docQueryService.getByIdAndUserId(docId, userId); + User user = docReader.getUserOrThrow(userId); + Doc doc = docReader.getByIdAndUserId(docId, userId); List edges = doc.getEdges(); List branches = doc.getBranches(); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java new file mode 100644 index 00000000..1a75027e --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java @@ -0,0 +1,80 @@ +package io.ejangs.docsa.domain.doc.app; + +import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; +import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; +import io.ejangs.docsa.domain.doc.readmodel.dao.mongodb.DocListReadModelRepository; +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import io.ejangs.docsa.domain.doc.readmodel.util.DocListReadModelMapper; +import io.ejangs.docsa.domain.edge.app.EdgeService; +import io.ejangs.docsa.domain.edge.dto.GraphResponse; +import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; +import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; +import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; +import io.ejangs.docsa.domain.edge.util.GraphMapper; +import io.ejangs.docsa.global.exception.CustomException; +import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class DocQueryService { + + private final DocListReadModelRepository docListReadModelRepository; + + private final DocReader docReader; + private final BranchQueryService branchQueryService; + private final CommitQueryService commitQueryService; + private final EdgeService edgeService; + + @Value("${cloud.aws.s3.public-base-url}") + private String cdnUrl; + + @Transactional(readOnly = true) + public Page getPage(Long userId, Pageable pageable) { + return getDocListReadModelPage(userId, pageable).map( + model -> DocListReadModelMapper.toListResponse(model, cdnUrl)); + } + + @Transactional(readOnly = true) + public Page getSimplePage(Long userId, Pageable pageable) { + return getDocListReadModelPage(userId, pageable).map( + DocListReadModelMapper::toListSimpleResponse); + } + + @Transactional(readOnly = true) + public Page searchList(Long userId, String keyword, Pageable pageable) { + return docListReadModelRepository + .findByUserIdAndDeletedFalseAndTitleContainingIgnoreCase(userId, keyword, pageable) + .map(model -> DocListReadModelMapper.toListResponse(model, cdnUrl)); + } + + private Page getDocListReadModelPage(Long userId, Pageable pageable) { + return docListReadModelRepository.findByUserIdAndDeletedFalse(userId, pageable); + } + + // 문서 조회시 그래프를 그리기 위한 응답 생성 + @Transactional(readOnly = true) + public GraphResponse getGraph(Long userId, Long documentId) { + + String docTitle = docReader.getByIdAndUserId(documentId, userId).getTitle(); + + // Branch, Commit, Edge 각각 별도 조회 (Projection 쿼리) + List branches = branchQueryService.getBranchGraphList(documentId); + if (branches.isEmpty()) { + throw new CustomException(BranchErrorCode.BRANCH_NOT_FOUND); + } + List commits = commitQueryService.getCommitGraphList(documentId); + List edges = edgeService.getEdgeDtoByDocId(documentId); + + return GraphMapper.toCommitGraphResponse(docTitle, commits, edges, branches); + } + +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocQueryService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocReader.java similarity index 79% rename from src/main/java/io/ejangs/docsa/domain/doc/app/create/DocQueryService.java rename to src/main/java/io/ejangs/docsa/domain/doc/app/DocReader.java index 6eccc4be..3bdab932 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocQueryService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocReader.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.app.create; +package io.ejangs.docsa.domain.doc.app; import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; import io.ejangs.docsa.domain.doc.entity.Doc; @@ -8,14 +8,12 @@ import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; import io.ejangs.docsa.global.exception.errorcode.UserErrorCode; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor -public class DocQueryService { +public class DocReader { private final UserRepository userRepository; private final DocRepository docRepository; @@ -59,14 +57,4 @@ public void checkByIdAndUserId(Long docId, Long userId) { } } - @Transactional(readOnly = true) - public Page getPageByUserId(Long userId, Pageable pageable) { - return docRepository.findAllByUserId(userId, pageable); - } - - @Transactional(readOnly = true) - public Page searchByTitle(String keyword, Long userId, Pageable pageable) { - return docRepository.searchDocByTitle(keyword, userId, pageable); - } - } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java index 6e44fc71..ea230399 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java @@ -2,6 +2,7 @@ import io.ejangs.docsa.domain.branch.app.BranchQueryService; import io.ejangs.docsa.domain.branch.entity.Branch; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; @@ -24,7 +25,7 @@ @RequiredArgsConstructor public class DocCreateMySqlTxService { - private final DocQueryService docQueryService; + private final DocReader docReader; private final BranchQueryService branchQueryService; private final SaveQueryService saveQueryService; private final ThumbnailRepository thumbnailRepository; @@ -38,7 +39,7 @@ public class DocCreateMySqlTxService { public DocCreateResponse createMySqlPart(String title, User user, String saveContentId) { - Doc doc = docQueryService.create(user, title); + Doc doc = docReader.create(user, title); Branch defaultBranch = branchQueryService.createBranch(doc, defaultBranchName); Save defaultSave = saveQueryService.createSave(defaultBranch, saveContentId); RenewUpdatedAtHelper.touch(defaultSave); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocListReadModelMapper.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocListReadModelMapper.java new file mode 100644 index 00000000..979e9e41 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/util/DocListReadModelMapper.java @@ -0,0 +1,40 @@ +package io.ejangs.docsa.domain.doc.readmodel.util; + +import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; +import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; + +public final class DocListReadModelMapper { + + private DocListReadModelMapper() { + } + + public static DocSimplePageResponse toListSimpleResponse(DocListReadModel model) { + return new DocSimplePageResponse( + model.getId(), + model.getTitle(), + model.getCreatedAt(), + model.getUpdatedAt(), + model.getRecentSaveId() + ); + } + + public static DocPageResponse toListResponse(DocListReadModel model, String cdnUrl) { + return new DocPageResponse( + model.getId(), + model.getTitle(), + model.getCreatedAt(), + model.getUpdatedAt(), + buildThumbnailUrl(model.getThumbnailObjectKey(), cdnUrl), + model.getThumbnailStatus(), + model.getRecentSaveId() + ); + } + + private static String buildThumbnailUrl(String objectKey, String cdnUrl) { + if (objectKey == null || objectKey.isBlank()) { + return null; + } + return "%s/%s".formatted(cdnUrl, objectKey); + } +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java index 1c679185..c64dd9b3 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java @@ -1,6 +1,6 @@ package io.ejangs.docsa.domain.doc.thumbnail.app; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailResponse; @@ -26,7 +26,7 @@ public class ThumbnailService { private final ThumbnailQueryService thumbnailQueryService; - private final DocQueryService docQueryService; + private final DocReader docReader; private final ImageQueryService imageQueryService; private final S3DeleteJobEnqueuer s3DeleteJobEnqueuer; @@ -37,7 +37,7 @@ public class ThumbnailService { @Transactional public ThumbnailSyncResponse requestUpdate(Long userId, Long docId) { - Doc doc = docQueryService.getByIdAndUserId(docId, userId); + Doc doc = docReader.getByIdAndUserId(docId, userId); Thumbnail thumbnail = thumbnailQueryService.getOrCreateByDocForUpdate(doc); @@ -58,7 +58,7 @@ public ThumbnailResponse finalizeThumbnail( Long requestToken, String signature ) { - docQueryService.checkByIdAndUserId(docId, userId); + docReader.checkByIdAndUserId(docId, userId); Thumbnail thumbnail = thumbnailQueryService.getByDocIdForUpdate(docId); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java b/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java deleted file mode 100644 index 7090c689..00000000 --- a/src/main/java/io/ejangs/docsa/domain/doc/util/DocListAssembler.java +++ /dev/null @@ -1,85 +0,0 @@ -package io.ejangs.docsa.domain.doc.util; - -import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; -import io.ejangs.docsa.domain.doc.dto.LatestSaveIdDto; -import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; -import io.ejangs.docsa.domain.doc.entity.Doc; -import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.data.domain.Page; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class DocListAssembler { - - private final BranchRepository branchRepository; - - @Value("${cloud.aws.s3.public-base-url}") - private String cdnUrl; - - public Page assembleDocList(Page docs) { - List docIds = docs.getContent().stream() - .map(Doc::getId) - .toList(); - - Map latestSaveIdByDocId = latestSaveIdByDocId(docIds); - - return docs.map(doc -> { - Thumbnail thumbnail = doc.getThumbnail(); - - return DocMapper.toListResponse( - doc, - buildThumbnailUrl(thumbnail), - thumbnailStatusOf(thumbnail), - latestSaveIdByDocId.get(doc.getId()) - ); - }); - } - - private String buildThumbnailUrl(Thumbnail thumbnail) { - if (thumbnail == null || thumbnail.getCurrentImage() == null) { - return null; - } - - return "%s/%s".formatted(cdnUrl, thumbnail.getCurrentImage().getObjectKey()); - } - - private Thumbnail.ThumbnailStatus thumbnailStatusOf(Thumbnail thumbnail) { - if (thumbnail == null) { - return Thumbnail.ThumbnailStatus.EMPTY; - } - return thumbnail.getStatus(); - } - - - public Page assembleDocListSimple(Page docs) { - List docIds = docs.getContent().stream() - .map(Doc::getId) - .toList(); - Map latestSaveIdByDocId = latestSaveIdByDocId(docIds); - - return docs.map(doc -> DocMapper.toListSimpleResponse( - doc, - latestSaveIdByDocId.get(doc.getId()) - )); - } - - private Map latestSaveIdByDocId(List docIds) { - if (docIds.isEmpty()) { - return Map.of(); - } - - return branchRepository.findLatestSaveIdsByDocIds(docIds) - .stream() - .collect(Collectors.toMap( - LatestSaveIdDto::docId, - LatestSaveIdDto::saveId - )); - } -} diff --git a/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java b/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java index 44030fc8..45866392 100644 --- a/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java +++ b/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java @@ -1,6 +1,6 @@ package io.ejangs.docsa.domain.image.app; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.image.dao.ImageRepository; import io.ejangs.docsa.domain.image.dto.request.ImageUploadUrlRequest; import io.ejangs.docsa.domain.image.dto.response.ImageUploadCompleteResponse; @@ -33,7 +33,7 @@ public class ImageService { private final ImageRepository imageRepository; private final ImageQueryService imageQueryService; - private final DocQueryService docQueryService; + private final DocReader docReader; private final S3Presigner s3Presigner; private final S3Client s3Client; @@ -51,7 +51,7 @@ public class ImageService { @Transactional public ImageUploadUrlResponse createUploadUrl(Long userId, ImageUploadUrlRequest request) { - docQueryService.getByIdAndUserId(request.docId(), userId); + docReader.getByIdAndUserId(request.docId(), userId); validateImage(request.contentType(), request.size()); diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java index 774988c5..d4ec242b 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java @@ -10,7 +10,7 @@ import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.commit.document.CommitBlockSequence; import io.ejangs.docsa.domain.commit.entity.Commit; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.edge.app.EdgeService; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.user.entity.User; @@ -44,7 +44,7 @@ class BranchServiceTest { private CommitQueryService commitQueryService; @Mock - private DocQueryService docQueryService; + private DocReader docReader; @Mock private EdgeService edgeService; @@ -79,7 +79,7 @@ void createBranch_fail_whenDifferentNameButDuplicated() { when(doc.getId()).thenReturn(documentId); when(commitQueryService.getById(commitId)).thenReturn(commit); - doNothing().when(docQueryService).checkByIdAndUserId(documentId, userId); + doNothing().when(docReader).checkByIdAndUserId(documentId, userId); doThrow(new CustomException(BranchErrorCode.BRANCH_NAME_DUPLICATED)) .when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); @@ -109,7 +109,7 @@ void createBranch_success_whenDifferentNameIsRequested() { when(doc.getId()).thenReturn(documentId); when(commitQueryService.getById(commitId)).thenReturn(commit); - doNothing().when(docQueryService).checkByIdAndUserId(documentId, userId); + doNothing().when(docReader).checkByIdAndUserId(documentId, userId); doNothing().when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); when(branchCreateOrchestrator.create(any())) .thenReturn(new BranchCreateResponse(101L, 201L)); @@ -153,7 +153,7 @@ void createBranch_success_fromIntermediateCommit() { when(commit.getCommitMongoId()).thenReturn("mongo-1"); fromBranch.updateLeafCommit(commit); when(commitQueryService.getById(commitId)).thenReturn(commit); - doNothing().when(docQueryService).checkByIdAndUserId(documentId, userId); + doNothing().when(docReader).checkByIdAndUserId(documentId, userId); doNothing().when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); when(branchCreateOrchestrator.create(any())) .thenReturn(new BranchCreateResponse(100L, 200L)); @@ -196,7 +196,7 @@ void createBranch_fail_whenCommitBelongsToAnotherDocument() { when(commitDoc.getId()).thenReturn(999L); when(commitQueryService.getById(commitId)).thenReturn(commit); - doNothing().when(docQueryService).checkByIdAndUserId(documentId, userId); + doNothing().when(docReader).checkByIdAndUserId(documentId, userId); // when & then CustomException ex = assertThrows(CustomException.class, diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java index a8f4a926..0e3f72ae 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java @@ -17,7 +17,7 @@ import io.ejangs.docsa.domain.commit.dto.request.CreateCommitRequest; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.commit.util.CommitMockTestUtils; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.edge.app.EdgeService; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.save.app.SaveService; @@ -46,7 +46,7 @@ class CommitServiceMockTest { private CommitBlockSequenceRepository cbsRepository; @Mock - private DocQueryService docQueryService; + private DocReader docReader; @Mock private CommitQueryService commitQueryService; @@ -108,7 +108,7 @@ void setUp() { @Test @DisplayName("커밋 생성 성공 - leafCommit 기반으로 오케스트레이터 호출") void createCommit_success_withLeafCommit() { - when(docQueryService.getById(docId)).thenReturn(doc); + when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); when(commitQueryService.resolveBaseCommitCbsMongoId(branch)).thenReturn("leaf-cbs-id"); when(createdCommit.getId()).thenReturn(101L); @@ -127,7 +127,7 @@ void createCommit_success_withLeafCommit() { @Test @DisplayName("커밋 생성 성공 - leafCommit이 null이면 fromCommit을 base로 사용") void createCommit_success_useFromCommitWhenLeafCommitIsNull() { - when(docQueryService.getById(docId)).thenReturn(doc); + when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); when(commitQueryService.resolveBaseCommitCbsMongoId(branch)).thenReturn("from-cbs-id"); when(createdCommit.getId()).thenReturn(101L); @@ -143,7 +143,7 @@ void createCommit_success_useFromCommitWhenLeafCommitIsNull() { @Test @DisplayName("커밋 생성 성공 - 최초 커밋이면 baseCommitCbsMongoId는 null") void createCommit_success_initialCommit_baseIsNull() { - when(docQueryService.getById(docId)).thenReturn(doc); + when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); when(commitQueryService.resolveBaseCommitCbsMongoId(branch)).thenReturn(null); when(createdCommit.getId()).thenReturn(101L); @@ -165,7 +165,7 @@ void createCommit_fail_branchOwnershipCheck() { assertThatThrownBy(() -> commitService.createCommit(docId, createCommitRequest, userDetails.getId())) .isInstanceOf(CustomException.class); - verify(docQueryService, never()).getById(docId); + verify(docReader, never()).getById(docId); verify(branchQueryService, never()).getById(branchId); verifyNoInteractions(commitCreateOrchestrator); } @@ -173,7 +173,7 @@ void createCommit_fail_branchOwnershipCheck() { @Test @DisplayName("커밋 생성 실패 - 브랜치 조회 실패 시 오케스트레이터 미호출") void createCommit_fail_whenBranchLookupFails() { - when(docQueryService.getById(docId)).thenReturn(doc); + when(docReader.getById(docId)).thenReturn(doc); doThrow(new CustomException(BlockSequenceErrorCode.BLOCK_SEQUENCE_NOT_FOUND)) .when(branchQueryService).getById(branchId); @@ -186,7 +186,7 @@ void createCommit_fail_whenBranchLookupFails() { @Test @DisplayName("커밋 생성 실패 - base commit 조회 중 repository 예외 전파") void createCommit_fail_whenBaseCommitLookupFails() { - when(docQueryService.getById(docId)).thenReturn(doc); + when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); doThrow(new RuntimeException("repo fail")) .when(commitQueryService).resolveBaseCommitCbsMongoId(branch); @@ -201,7 +201,7 @@ void createCommit_fail_whenBaseCommitLookupFails() { @Test @DisplayName("커밋 생성 실패 - 오케스트레이터 예외는 그대로 전파") void createCommit_fail_whenOrchestratorThrows() { - when(docQueryService.getById(docId)).thenReturn(doc); + when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); when(commitQueryService.resolveBaseCommitCbsMongoId(branch)).thenReturn("base-cbs-id"); doThrow(new RuntimeException("orchestrator fail")) diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/GetCommitMockTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/GetCommitMockTest.java index b90e96b2..9449ac7a 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/GetCommitMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/GetCommitMockTest.java @@ -12,7 +12,7 @@ import io.ejangs.docsa.domain.commit.dto.response.CommitResponse; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.commit.util.CommitMockTestUtils; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.domain.user.security.CustomUserDetails; @@ -36,7 +36,7 @@ class GetCommitMockTest { private CommitQueryService commitQueryService; @Mock - private DocQueryService docQueryService; + private DocReader docReader; @Mock private CommitContentAssembler assembler; @@ -90,7 +90,7 @@ void getCommit_Success() { assertThat(response).isNotNull(); assertThat(response.content()).isEqualTo(mockContent); - verify(docQueryService).checkByIdAndUserId(docId, userDetails.getId()); + verify(docReader).checkByIdAndUserId(docId, userDetails.getId()); verify(commitQueryService).getById(commitId); verify(assembler).assemble(commitMongoId); } @@ -109,7 +109,7 @@ void getCommit_Commit_NotFound() { assertThatThrownBy(() -> commitService.getCommit(docId, commitId, userDetails.getId())) .isInstanceOf(CustomException.class); - verify(docQueryService).checkByIdAndUserId(docId, userDetails.getId()); + verify(docReader).checkByIdAndUserId(docId, userDetails.getId()); verify(commitQueryService).getById(commitId); verify(assembler, never()).assemble(any()); } @@ -122,13 +122,13 @@ void getCommit_Doc_NotFound() { Long commitId = 1L; doThrow(new CustomException(DocErrorCode.DOCUMENT_NOT_FOUND)) - .when(docQueryService).checkByIdAndUserId(docId, userDetails.getId()); + .when(docReader).checkByIdAndUserId(docId, userDetails.getId()); // when & then assertThatThrownBy(() -> commitService.getCommit(docId, commitId, userDetails.getId())) .isInstanceOf(CustomException.class); - verify(docQueryService).checkByIdAndUserId(docId, userDetails.getId()); + verify(docReader).checkByIdAndUserId(docId, userDetails.getId()); verify(commitQueryService, never()).getById(any()); verify(assembler, never()).assemble(any()); } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocCommandServiceIntegrationTests.java similarity index 60% rename from src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java rename to src/test/java/io/ejangs/docsa/domain/doc/integration/DocCommandServiceIntegrationTests.java index e55f622c..3e1050b0 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocServiceIntegrationTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocCommandServiceIntegrationTests.java @@ -14,20 +14,16 @@ import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.doc.app.create.DocCreateMySqlTxService; -import io.ejangs.docsa.domain.doc.app.DocService; +import io.ejangs.docsa.domain.doc.app.DocCommandService; import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; import io.ejangs.docsa.domain.doc.entity.Doc; -import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; import io.ejangs.docsa.domain.doc.util.DocTestUtils; import io.ejangs.docsa.domain.save.dao.mongodb.SaveContentRepository; import io.ejangs.docsa.domain.save.dao.mysql.SaveRepository; import io.ejangs.docsa.domain.save.document.SaveContent; import io.ejangs.docsa.domain.save.entity.Save; -import io.ejangs.docsa.domain.save.util.PageableFactory; import io.ejangs.docsa.domain.user.dao.mysql.UserRepository; import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.exception.CustomException; @@ -45,10 +41,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -60,10 +52,10 @@ @Transactional @ActiveProfiles("test") @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) -public class DocServiceIntegrationTests { +public class DocCommandServiceIntegrationTests { @Autowired - private DocService docService; + private DocCommandService docCommandService; @Autowired private DocRepository docRepository; @@ -102,7 +94,7 @@ void documentCreateSuccess() throws Exception { DocTitleRequest request = new DocTitleRequest(title); // when: 문서 생성 요청 - DocCreateResponse response = docService.create(request, user.getId()); + DocCreateResponse response = docCommandService.create(request, user.getId()); // then: 문서 저장 검증 Doc savedDoc = docRepository.findById(response.id()) @@ -137,7 +129,7 @@ void documentCreateSuccess() throws Exception { class MongoFailureTest { @Autowired - private DocService docService; + private DocCommandService docCommandService; @Autowired private UserRepository userRepository; @Autowired @@ -161,7 +153,7 @@ void MongoFailRdbTransaction() { when(saveContentRepository.save(any())) .thenThrow(new MongoTimeoutException("Mongo 연결 실패")); - assertThatThrownBy(() -> docService.create(request, user.getId())) + assertThatThrownBy(() -> docCommandService.create(request, user.getId())) .isInstanceOf(CustomException.class) .hasMessageContaining(DatabaseErrorCode.DATABASE_ERROR.getMessage()); } @@ -172,7 +164,7 @@ void MongoFailRdbTransaction() { class MySqlFailureTest { @Autowired - private DocService docService; + private DocCommandService docCommandService; @Autowired private UserRepository userRepository; @@ -199,7 +191,7 @@ void mysqlFail_createCompensateOutbox() { .thenThrow(new RuntimeException("MySQL 생성 실패")); // when & then - assertThatThrownBy(() -> docService.create(request, user.getId())) + assertThatThrownBy(() -> docCommandService.create(request, user.getId())) .isInstanceOf(CustomException.class) .hasMessage(DocErrorCode.FAIL_CREATE_DOCUMENT.getMessage()); @@ -220,7 +212,7 @@ void mysqlFail_createCompensateOutbox() { class MySqlFailureWithCompensateOutboxTest { @Autowired - private DocService docService; + private DocCommandService docCommandService; @Autowired private UserRepository userRepository; @@ -244,7 +236,7 @@ void mysqlFail_storeOpenOutbox() { .thenThrow(new RuntimeException("MySQL 생성 실패")); // when & then - assertThatThrownBy(() -> docService.create(request, user.getId())) + assertThatThrownBy(() -> docCommandService.create(request, user.getId())) .isInstanceOf(CustomException.class) .hasMessage(DocErrorCode.FAIL_CREATE_DOCUMENT.getMessage()); @@ -262,10 +254,10 @@ void documentCreateFailByDuplicateTitle() { // given User user = userRepository.save(DocTestUtils.createUser()); String title = "중복 제목 테스트"; - docService.create(new DocTitleRequest(title), user.getId()); // 첫 번째 저장 + docCommandService.create(new DocTitleRequest(title), user.getId()); // 첫 번째 저장 // when & then - assertThatThrownBy(() -> docService.create(new DocTitleRequest(title), user.getId())) + assertThatThrownBy(() -> docCommandService.create(new DocTitleRequest(title), user.getId())) .isInstanceOf(CustomException.class) .hasMessageContaining("이미 사용중인 제목입니다."); } @@ -279,154 +271,10 @@ void documentCreateFailTestNotFoundUser() { // when & then CustomException ex = assertThrows(CustomException.class, () -> - docService.create(request, nonexistentUserId) + docCommandService.create(request, nonexistentUserId) ); assertEquals(UserErrorCode.USER_NOT_FOUND, ex.getErrorCode()); } - @Test - @DisplayName("사이드바 문서리스트 조회 - 최근 저장 id가 설정됨") - void getSimpleDocumentList() throws Exception { - // given - User user = userRepository.save(DocTestUtils.createUser()); - - List docs = DocTestUtils.createDocumentListForIntegrationTest(user, - saveContentRepository, commitBlockSequenceRepository, blockRepository); - docRepository.saveAll(docs); - - Pageable pageable = PageableFactory.create("updatedAt", "asc", 0, 10); - - // when - Page page = docService.getSimplePage(user.getId(), pageable); - List results = page.getContent(); - - // then - assertEquals(2, results.size()); - - DocSimplePageResponse first = results.get(0); // 최신 updatedAt 기준으로 정렬되었다고 가정 - DocSimplePageResponse second = results.get(1); - - assertEquals("문서 1", first.title()); - assertThat(first.recentSaveId()).isNotNull(); - - assertEquals("문서 2", second.title()); - assertThat(second.recentSaveId()).isNotNull(); - } - - @Test - @DisplayName("문서 리스트 조회 - 최신 활동 기준 정렬 및 미리보기 제공") - void getDocListWithPreview() throws Exception { - // given - User user = userRepository.save(DocTestUtils.createUser()); - - List docs = DocTestUtils.createDocumentListForIntegrationTest(user, - saveContentRepository, commitBlockSequenceRepository, blockRepository); - docRepository.saveAll(docs); - - Pageable pageable = PageableFactory.create("updatedAt", "desc", 0, 10); - - // when - Page results = docService.getPage(user.getId(), pageable); - - // then - assertEquals(2, results.getContent().size()); - - DocPageResponse first = results.getContent().getFirst(); // updatedAt 기준 최신 - DocPageResponse second = results.getContent().get(1); - - assertEquals("문서 1", second.title()); - assertEquals(ThumbnailStatus.EMPTY, second.thumbnailStatus()); - assertThat(second.recentSaveId()).isNotNull(); - - assertEquals("문서 2", first.title()); - assertEquals(ThumbnailStatus.EMPTY, first.thumbnailStatus()); - assertThat(first.recentSaveId()).isNotNull(); - } - - @Test - @DisplayName("문서 리스트 조회 - 최신 브랜치의 저장 id를 응답한다") - void getDocListReturnsLatestBranchSaveId() { - // given - User user = userRepository.save(DocTestUtils.createUser()); - Doc doc = Doc.builder() - .title("최신 저장 id 테스트") - .user(user) - .build(); - Branch firstBranch = Branch.builder() - .name(defaultBranchName) - .doc(doc) - .build(); - Save.builder() - .branch(firstBranch) - .saveMongoId("save-content-1") - .build(); - - Branch secondBranch = Branch.builder() - .name("feature") - .doc(doc) - .build(); - Save latestSave = Save.builder() - .branch(secondBranch) - .saveMongoId("save-content-2") - .build(); - - docRepository.saveAndFlush(doc); - - Pageable pageable = PageableFactory.create("updatedAt", "desc", 0, 10); - - // when - Page result = docService.getPage(user.getId(), pageable); - - // then - assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().getFirst().recentSaveId()).isEqualTo(latestSave.getId()); - } - - @Test - @DisplayName("문서 리스트 조회 - 페이지네이션 테스트 100개의 문서를 만들고 페이지 0에서는 10개만 조회한다.") - void getDocListInPage() throws Exception { - //given - User user = userRepository.save(DocTestUtils.createUser()); - List docs = DocTestUtils.createDocList(100, user); - docRepository.saveAll(docs); - - Pageable pageable = PageableFactory.create("updatedAt", "desc", 0, 10); - - //when - Page result = docService.getPage(user.getId(), pageable); - - //then - assertEquals(10, result.getContent().size()); - DocPageResponse first = result.getContent().getFirst(); - assertEquals("문서 keyword포함100", first.title()); - assertEquals(100L, first.id()); - - DocPageResponse last = result.getContent().getLast(); - assertEquals("테스트 문서 91", last.title()); - assertEquals(91L, last.id()); - } - - @Test - @DisplayName("문서 리스트 검색 - 300개의 전체 문서중 150개의 키워드포함 문서를 검색하여 페이지로 응답한다.") - void searchListSuccess() { - // given - User user = userRepository.save(DocTestUtils.createUser()); - - List docs = DocTestUtils.createDocList(300, user); - docRepository.saveAll(docs); - - String keyword = "keyword"; - Pageable pageable = PageRequest.of(0, 10, Sort.by("updatedAt").descending()); - - // when - Page result = docService.searchList(user.getId(), keyword, pageable); - - // then - assertThat(result.getContent()).hasSize(10); - assertThat(result.getContent()) - .extracting(DocPageResponse::title) - .allMatch(title -> title.contains("keyword")); - assertThat(result.getContent().getFirst().id()).isEqualTo(300); - } } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocGraphIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocGraphIntegrationTest.java index 8ac0f8d5..8f6dfece 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocGraphIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/integration/DocGraphIntegrationTest.java @@ -2,7 +2,7 @@ import io.ejangs.docsa.domain.block.dao.mongodb.BlockRepository; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.doc.util.DocTestUtils; @@ -43,7 +43,7 @@ public class DocGraphIntegrationTest { private BlockRepository blockRepository; @InjectMocks - private DocQueryService docQueryService; + private DocReader docReader; private User user; @@ -65,7 +65,7 @@ void testGetDocumentGraph() throws Exception { when(docRepository.findById(doc.getId())).thenReturn(Optional.of(doc)); - Doc result = docQueryService.getById(doc.getId()); + Doc result = docReader.getById(doc.getId()); assertThat(result).isNotNull(); assertThat(result.getTitle()).isEqualTo("브랜치 2개 있는 문서임당"); diff --git a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java index d1635d73..12a82ae9 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java @@ -7,7 +7,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailResponse; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; @@ -34,7 +34,7 @@ class ThumbnailServiceUnitTest { private ThumbnailQueryService thumbnailQueryService; @Mock - private DocQueryService docQueryService; + private DocReader docReader; @Mock private ImageQueryService imageQueryService; @@ -53,7 +53,7 @@ void setUp() { new S3DeleteJobEnqueuer(s3DeleteOutboxRepository); thumbnailService = new ThumbnailService( thumbnailQueryService, - docQueryService, + docReader, imageQueryService, s3DeleteJobEnqueuer, domainEventOutboxPublisher diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCommandServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCommandServiceUnitTests.java new file mode 100644 index 00000000..f31c32d1 --- /dev/null +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCommandServiceUnitTests.java @@ -0,0 +1,182 @@ +package io.ejangs.docsa.domain.doc.unit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; +import io.ejangs.docsa.domain.commit.dao.mysql.CommitRepository; +import io.ejangs.docsa.domain.doc.app.DocReader; +import io.ejangs.docsa.domain.doc.app.DocCommandService; +import io.ejangs.docsa.domain.doc.app.create.DocCreateOrchestrator; +import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; +import io.ejangs.docsa.domain.edge.app.EdgeService; +import io.ejangs.docsa.domain.edge.dao.mysql.EdgeRepository; +import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; +import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; +import io.ejangs.docsa.domain.doc.dto.response.DocTitleUpdateResponse; +import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.util.DocTestUtils; +import io.ejangs.docsa.domain.user.entity.User; +import io.ejangs.docsa.global.exception.CustomException; +import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; +import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; +import io.ejangs.docsa.global.outbox.event.model.AggregateType; +import io.ejangs.docsa.global.outbox.event.model.DomainEventType; +import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +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; + +@ExtendWith(MockitoExtension.class) +public class DocCommandServiceUnitTests { + + @InjectMocks + private DocCommandService docCommandService; + + @Mock + private DocRepository docRepository; + + @Mock + private DocReader docReader; + + @Mock + private DocCreateOrchestrator docCreateOrchestrator; + + @Mock + private BranchRepository branchRepository; + + @Mock + private CommitRepository commitRepository; + + @Mock + private EdgeRepository edgeRepository; + + @Mock + private EdgeService edgeService; + + @Mock + private MongoIdsCollector mongoIdsCollector; + + @Mock + private DomainEventOutboxPublisher domainEventOutboxPublisher; + + @Test + @DisplayName("문서 생성 성공 - QueryService 검증 후 Orchestrator 호출(CQRS 분리)") + void createDoc_delegatesToQueryServiceAndOrchestrator() { + Long userId = 1L; + String title = "새 문서"; + DocTitleRequest request = new DocTitleRequest(title); + User user = DocTestUtils.createUser(); + ReflectionTestUtils.setField(user, "id", userId); + DocCreateResponse expected = new DocCreateResponse(10L, 100L); + + when(docReader.getUserOrThrow(userId)).thenReturn(user); + when(docCreateOrchestrator.create(title, user)).thenReturn(expected); + + DocCreateResponse result = docCommandService.create(request, userId); + + assertEquals(expected.id(), result.id()); + assertEquals(expected.saveId(), result.saveId()); + verify(docReader).getUserOrThrow(userId); + verify(docReader).checkTitleDuplicate(userId, title); + verify(docCreateOrchestrator).create(title, user); + verifyNoInteractions(docRepository, branchRepository, commitRepository, edgeRepository); + } + + @Test + @DisplayName("문서 생성 실패 - 제목 중복이면 Orchestrator 호출 안함") + void createDoc_fail_duplicateTitle() { + Long userId = 1L; + String title = "중복 문서"; + DocTitleRequest request = new DocTitleRequest(title); + User user = DocTestUtils.createUser(); + ReflectionTestUtils.setField(user, "id", userId); + + when(docReader.getUserOrThrow(userId)).thenReturn(user); + doThrow(new CustomException(DocErrorCode.TITLE_DUPLICATION)) + .when(docReader).checkTitleDuplicate(userId, title); + + CustomException exception = assertThrows(CustomException.class, () -> docCommandService.create(request, userId)); + + assertEquals(DocErrorCode.TITLE_DUPLICATION, exception.getErrorCode()); + verifyNoInteractions(docCreateOrchestrator); + } + + @Test + @DisplayName("문서 제목 수정 성공 테스트") + void updateDocTitleSuccess() throws Exception { + //given + Long userId = 1L; + + User user = DocTestUtils.createUser(); + ReflectionTestUtils.setField(user, "id", userId); + + Long docId = 10L; + String newTitle = "new title"; + + Doc doc = Doc.builder().title("old title").user(user).build(); + ReflectionTestUtils.setField(doc, "id", docId); + ReflectionTestUtils.setField(doc, "updatedAt", LocalDateTime.now()); + + DocTitleRequest request = new DocTitleRequest(newTitle); + + DocTitleUpdateResponse response = + new DocTitleUpdateResponse(docId, newTitle, LocalDateTime.now()); + + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); + + //when + DocTitleUpdateResponse result = docCommandService.updateTitle(userId, docId, request); + + //then + verify(docReader).checkTitleDuplicate(userId, newTitle); + verify(docReader).getByIdAndUserId(docId, userId); + verify(domainEventOutboxPublisher).publish( + eq(DomainEventType.DOC_TITLE_CHANGED), + eq(AggregateType.DOC), + eq(docId), + any() + ); + assertEquals(newTitle, doc.getTitle()); + assertEquals(response.id(), doc.getId()); + assertEquals(response.title(), result.title()); + } + + @Test + @DisplayName("문서 제목 수정 실패 테스트 - 문서이름 중복") + void updateDocTitleFailByDuplicateTitle() throws Exception { + // given + Long userId = 1L; + Long docId = 10L; + String duplicateTitle = "중복된 제목"; + DocTitleRequest request = new DocTitleRequest(duplicateTitle); + + User user = DocTestUtils.createUser(); + ReflectionTestUtils.setField(user, "id", userId); + + Doc doc = Doc.builder().title("기존 제목").user(user).build(); + ReflectionTestUtils.setField(doc, "id", docId); + + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); + doThrow(new CustomException(DocErrorCode.TITLE_DUPLICATION)) + .when(docReader).checkTitleDuplicate(userId, duplicateTitle); + + // when & then + CustomException exception = assertThrows(CustomException.class, + () -> docCommandService.updateTitle(userId, docId, request)); + + assertEquals(DocErrorCode.TITLE_DUPLICATION, exception.getErrorCode()); + } + +} diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java index dea251f6..32694df2 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java @@ -12,7 +12,8 @@ import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; import io.ejangs.docsa.domain.doc.api.DocController; -import io.ejangs.docsa.domain.doc.app.DocService; +import io.ejangs.docsa.domain.doc.app.DocCommandService; +import io.ejangs.docsa.domain.doc.app.DocQueryService; import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; import io.ejangs.docsa.domain.edge.dto.GraphResponse; @@ -51,7 +52,10 @@ class DocControllerUnitTests { private ObjectMapper objectMapper; @MockitoBean - private DocService docService; + private DocCommandService docCommandService; + + @MockitoBean + private DocQueryService docQueryService; @Test @DisplayName("문서 생성 성공 컨트롤러 테스트") @@ -63,7 +67,7 @@ void createDocSuccess() throws Exception { Long saveId = 2L; //when, then - when(docService.create(any(DocTitleRequest.class), anyLong())) + when(docCommandService.create(any(DocTitleRequest.class), anyLong())) .thenReturn(new DocCreateResponse(documentId, saveId)); mockMvc.perform(MockMvcRequestBuilders.post("/api/document") @@ -118,7 +122,7 @@ void getSimpleDocList() throws Exception { content.size() ); - when(docService.getSimplePage(anyLong(), any(Pageable.class))).thenReturn(responseList); + when(docQueryService.getSimplePage(anyLong(), any(Pageable.class))).thenReturn(responseList); // when, then mockMvc.perform(MockMvcRequestBuilders.get("/api/document/sidebar")) @@ -147,7 +151,7 @@ void updateDocTitleSuccess() throws Exception { LocalDateTime.now() ); - when(docService.updateTitle(userId, docId, request)).thenReturn(response); + when(docCommandService.updateTitle(userId, docId, request)).thenReturn(response); //when & then mockMvc.perform(MockMvcRequestBuilders.patch("/api/document/" + docId) @@ -168,7 +172,7 @@ void updateDocTitleFailByDuplicationTitle() throws Exception { DocTitleRequest request = new DocTitleRequest(title); - when(docService.updateTitle(userId, documentId, request)) + when(docCommandService.updateTitle(userId, documentId, request)) .thenThrow(new CustomException(DocErrorCode.TITLE_DUPLICATION)); // when & then @@ -213,7 +217,7 @@ void getGraphSuccess() throws Exception { List.of(branch) ); - when(docService.getGraph(userId, docId)).thenReturn(response); + when(docQueryService.getGraph(userId, docId)).thenReturn(response); // when & then mockMvc.perform(MockMvcRequestBuilders.get("/api/document/{docId}/graph", docId)) @@ -231,7 +235,7 @@ void getGraphFailByNotFound() throws Exception { // given Long docId = 999L; Long userId = 1L; - when(docService.getGraph(userId, docId)) + when(docQueryService.getGraph(userId, docId)) .thenThrow(new CustomException(DocErrorCode.DOCUMENT_NOT_FOUND)); // when & then mockMvc.perform(MockMvcRequestBuilders.get("/api/document/{docId}/graph", docId)) diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocQueryServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocQueryServiceUnitTests.java new file mode 100644 index 00000000..6eccd25e --- /dev/null +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocQueryServiceUnitTests.java @@ -0,0 +1,193 @@ +package io.ejangs.docsa.domain.doc.unit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.doc.app.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; +import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; +import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; +import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.readmodel.dao.mongodb.DocListReadModelRepository; +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import io.ejangs.docsa.domain.doc.util.DocTestUtils; +import io.ejangs.docsa.domain.edge.app.EdgeService; +import io.ejangs.docsa.domain.edge.dto.GraphResponse; +import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; +import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; +import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; +import io.ejangs.docsa.domain.user.entity.User; +import io.ejangs.docsa.global.exception.CustomException; +import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class DocQueryServiceUnitTests { + + @Mock + private DocListReadModelRepository docListReadModelRepository; + + @Mock + private DocReader docReader; + + @Mock + private BranchQueryService branchQueryService; + + @Mock + private CommitQueryService commitQueryService; + + @Mock + private EdgeService edgeService; + + @InjectMocks + private DocQueryService docQueryService; + + private final Long userId = 1L; + private final Pageable pageable = PageRequest.of(0, 10); + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(docQueryService, "cdnUrl", "https://cdn.test.invalid"); + } + + @Test + @DisplayName("문서 목록 read model을 사이드바 응답으로 변환한다") + void getSimplePage_success() { + DocListReadModel model = readModel(1L, "문서 1", 10L, null, ThumbnailStatus.EMPTY); + when(docListReadModelRepository.findByUserIdAndDeletedFalse(userId, pageable)) + .thenReturn(new PageImpl<>(List.of(model), pageable, 1)); + + Page result = docQueryService.getSimplePage(userId, pageable); + + assertThat(result.getContent()).hasSize(1); + DocSimplePageResponse response = result.getContent().getFirst(); + assertThat(response.id()).isEqualTo(1L); + assertThat(response.title()).isEqualTo("문서 1"); + assertThat(response.recentSaveId()).isEqualTo(10L); + } + + @Test + @DisplayName("문서 목록 read model을 목록 응답으로 변환한다") + void getPage_success() { + DocListReadModel model = readModel(1L, "문서 1", 10L, "thumb-1.webp", ThumbnailStatus.READY); + when(docListReadModelRepository.findByUserIdAndDeletedFalse(userId, pageable)) + .thenReturn(new PageImpl<>(List.of(model), pageable, 1)); + + Page result = docQueryService.getPage(userId, pageable); + + assertThat(result.getContent()).hasSize(1); + DocPageResponse response = result.getContent().getFirst(); + assertThat(response.id()).isEqualTo(1L); + assertThat(response.title()).isEqualTo("문서 1"); + assertThat(response.thumbnailUrl()).isEqualTo("https://cdn.test.invalid/thumb-1.webp"); + assertThat(response.thumbnailStatus()).isEqualTo(ThumbnailStatus.READY); + assertThat(response.recentSaveId()).isEqualTo(10L); + } + + @Test + @DisplayName("검색 키워드를 포함한 문서 목록 read model을 페이지로 반환한다") + void searchList_success() { + String keyword = "문서"; + DocListReadModel model = readModel(1L, "문서 1", 10L, null, ThumbnailStatus.EMPTY); + when(docListReadModelRepository.findByUserIdAndDeletedFalseAndTitleContainingIgnoreCase( + userId, + keyword, + pageable + )).thenReturn(new PageImpl<>(List.of(model), pageable, 1)); + + Page result = docQueryService.searchList(userId, keyword, pageable); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().title()).isEqualTo("문서 1"); + } + + @Test + @DisplayName("그래프 조회 성공") + void getGraph_success() { + Long docId = 10L; + String docTitle = "Test Document"; + User user = DocTestUtils.createUser(); + ReflectionTestUtils.setField(user, "id", userId); + Doc doc = Doc.builder().title(docTitle).user(user).build(); + ReflectionTestUtils.setField(doc, "id", docId); + LocalDateTime now = LocalDateTime.now(); + List branches = List.of( + new BranchGraphDto(1L, "main", now, null, null, null, null, null) + ); + List commits = List.of( + new CommitGraphDto(100L, 1L, "Initial Commit", "desc", now) + ); + List edges = List.of(new EdgeDto(100L, 101L)); + + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); + when(branchQueryService.getBranchGraphList(docId)).thenReturn(branches); + when(commitQueryService.getCommitGraphList(docId)).thenReturn(commits); + when(edgeService.getEdgeDtoByDocId(docId)).thenReturn(edges); + + GraphResponse response = docQueryService.getGraph(userId, docId); + + assertThat(response.title()).isEqualTo(docTitle); + assertThat(response.branches()).isEqualTo(branches); + assertThat(response.commits()).isEqualTo(commits); + assertThat(response.edges()).isEqualTo(edges); + } + + @Test + @DisplayName("그래프 조회 시 브랜치가 없으면 예외가 발생한다") + void getGraph_fail_branchNotFound() { + Long docId = 10L; + User user = DocTestUtils.createUser(); + ReflectionTestUtils.setField(user, "id", userId); + Doc doc = Doc.builder().title("문서").user(user).build(); + + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); + when(branchQueryService.getBranchGraphList(docId)).thenReturn(List.of()); + + assertThatThrownBy(() -> docQueryService.getGraph(userId, docId)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) + .isEqualTo(BranchErrorCode.BRANCH_NOT_FOUND)); + } + + private DocListReadModel readModel( + Long docId, + String title, + Long recentSaveId, + String thumbnailObjectKey, + ThumbnailStatus thumbnailStatus + ) { + LocalDateTime createdAt = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime updatedAt = LocalDateTime.of(2026, 1, 2, 10, 0); + return DocListReadModel.create( + new DocCreatedPayload( + docId, + userId, + title, + createdAt, + updatedAt, + recentSaveId, + thumbnailObjectKey, + thumbnailStatus + ), + docId + ); + } +} diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java deleted file mode 100644 index ed47f765..00000000 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocServiceUnitTests.java +++ /dev/null @@ -1,345 +0,0 @@ -package io.ejangs.docsa.domain.doc.unit; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; - -import io.ejangs.docsa.domain.branch.app.BranchQueryService; -import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; -import io.ejangs.docsa.domain.commit.dao.mysql.CommitRepository; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; -import io.ejangs.docsa.domain.doc.app.DocService; -import io.ejangs.docsa.domain.doc.app.create.DocCreateOrchestrator; -import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; -import io.ejangs.docsa.domain.edge.app.EdgeService; -import io.ejangs.docsa.domain.edge.dao.mysql.EdgeRepository; -import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; -import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; -import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; -import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; -import io.ejangs.docsa.domain.edge.dto.GraphResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; -import io.ejangs.docsa.domain.doc.dto.response.DocTitleUpdateResponse; -import io.ejangs.docsa.domain.doc.entity.Doc; -import io.ejangs.docsa.domain.doc.util.DocListAssembler; -import io.ejangs.docsa.domain.doc.util.DocTestUtils; -import io.ejangs.docsa.domain.save.util.PageableFactory; -import io.ejangs.docsa.domain.user.entity.User; -import io.ejangs.docsa.global.exception.CustomException; -import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; -import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; -import io.ejangs.docsa.global.outbox.event.model.AggregateType; -import io.ejangs.docsa.global.outbox.event.model.DomainEventType; -import io.ejangs.docsa.global.outbox.mongo.util.MongoIdsCollector; -import java.time.LocalDateTime; -import java.util.Comparator; -import java.util.List; -import org.junit.jupiter.api.DisplayName; -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.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.test.util.ReflectionTestUtils; - -@ExtendWith(MockitoExtension.class) -public class DocServiceUnitTests { - - @InjectMocks - private DocService docService; - - @Mock - private DocRepository docRepository; - - @Mock - private DocQueryService docQueryService; - - @Mock - private DocCreateOrchestrator docCreateOrchestrator; - - @Mock - private DocListAssembler docListAssembler; - - @Mock - private BranchRepository branchRepository; - - @Mock - private BranchQueryService branchQueryService; - - @Mock - private CommitRepository commitRepository; - - @Mock - private CommitQueryService commitQueryService; - - @Mock - private EdgeRepository edgeRepository; - - @Mock - private EdgeService edgeService; - - @Mock - private MongoIdsCollector mongoIdsCollector; - - @Mock - private ApplicationEventPublisher eventPublisher; - - @Mock - private DomainEventOutboxPublisher domainEventOutboxPublisher; - - @Test - @DisplayName("문서 생성 성공 - QueryService 검증 후 Orchestrator 호출(CQRS 분리)") - void createDoc_delegatesToQueryServiceAndOrchestrator() { - Long userId = 1L; - String title = "새 문서"; - DocTitleRequest request = new DocTitleRequest(title); - User user = DocTestUtils.createUser(); - ReflectionTestUtils.setField(user, "id", userId); - DocCreateResponse expected = new DocCreateResponse(10L, 100L); - - when(docQueryService.getUserOrThrow(userId)).thenReturn(user); - when(docCreateOrchestrator.create(title, user)).thenReturn(expected); - - DocCreateResponse result = docService.create(request, userId); - - assertEquals(expected.id(), result.id()); - assertEquals(expected.saveId(), result.saveId()); - verify(docQueryService).getUserOrThrow(userId); - verify(docQueryService).checkTitleDuplicate(userId, title); - verify(docCreateOrchestrator).create(title, user); - verifyNoInteractions(docRepository, branchRepository, commitRepository, edgeRepository); - } - - @Test - @DisplayName("문서 생성 실패 - 제목 중복이면 Orchestrator 호출 안함") - void createDoc_fail_duplicateTitle() { - Long userId = 1L; - String title = "중복 문서"; - DocTitleRequest request = new DocTitleRequest(title); - User user = DocTestUtils.createUser(); - ReflectionTestUtils.setField(user, "id", userId); - - when(docQueryService.getUserOrThrow(userId)).thenReturn(user); - doThrow(new CustomException(DocErrorCode.TITLE_DUPLICATION)) - .when(docQueryService).checkTitleDuplicate(userId, title); - - CustomException exception = assertThrows(CustomException.class, () -> docService.create(request, userId)); - - assertEquals(DocErrorCode.TITLE_DUPLICATION, exception.getErrorCode()); - verifyNoInteractions(docCreateOrchestrator); - } - - @Test - @DisplayName("사이드바 문서 목록 조회 성공 테스트") - void getSimpleDocumentListSuccess() throws Exception { - // given - Long userId = 1L; - User user = DocTestUtils.createUser(); - ReflectionTestUtils.setField(user, "id", userId); - - List content = DocTestUtils.createDocumentListForUnitTest(2, user); - Pageable pageable = PageableFactory.create("updatedAt", "desc", 0, 10); - Page docs = new PageImpl<>(content, pageable, content.size()); - - List expectedResponses = List.of( - new DocSimplePageResponse( - 1L, - "테스트 문서 1", - LocalDateTime.of(2025, 7, 16, 2, 0), - LocalDateTime.of(2025, 7, 16, 2, 0), - 10L - ), - new DocSimplePageResponse( - 2L, - "테스트 문서 2", - LocalDateTime.of(2025, 7, 16, 3, 0), - LocalDateTime.of(2025, 7, 16, 3, 0), - 200L - ) - ); - Page dummyPage = new PageImpl<>(expectedResponses, pageable, - expectedResponses.size()); - - when(docQueryService.getPageByUserId(userId, pageable)).thenReturn(docs); - when(docListAssembler.assembleDocListSimple(docs)).thenReturn(dummyPage); - - // when - Page page = docService.getSimplePage(userId, pageable); - List result = page.getContent(); - - // then - assertEquals(2, result.size()); - - assertEquals("테스트 문서 1", result.get(0).title()); - assertEquals(10L, result.get(0).recentSaveId()); - - assertEquals("테스트 문서 2", result.get(1).title()); - assertEquals(200L, result.get(1).recentSaveId()); - - verify(docQueryService).getPageByUserId(userId, pageable); - verify(docListAssembler).assembleDocListSimple(docs); - } - - @Test - @DisplayName("검색 키워드를 포함한 제목을 가진 문서가 있으면 검색 결과를 페이지로 반환한다.") - void searchDocTitleSuccess() throws Exception { - // given - String keyword = "문서 1"; - Long userId = 1L; - User user = DocTestUtils.createUser(); - ReflectionTestUtils.setField(user, "id", userId); - - // 전체 문서 생성 - List allDocs = DocTestUtils.createDocumentListForUnitTest(100, user); - - // 키워드 필터링 + 정렬 - List filtered = allDocs.stream() - .filter(d -> d.getTitle().contains(keyword)) - .sorted(Comparator.comparing(Doc::getUpdatedAt).reversed()) - .toList(); - - Pageable pageable = PageableFactory.create("updatedAt", "desc", 0, 10); - int start = (int) pageable.getOffset(); - int end = Math.min(start + pageable.getPageSize(), filtered.size()); - List pagedDocs = filtered.subList(start, end); - - Page docsPage = new PageImpl<>(pagedDocs, pageable, filtered.size()); - Page responsesPage = DocTestUtils.convertToDocListResponsePage(pagedDocs, - pageable); - - when(docQueryService.searchByTitle(keyword, userId, pageable)).thenReturn(docsPage); - when(docListAssembler.assembleDocList(docsPage)).thenReturn(responsesPage); - - // when - Page page = docService.searchList(userId, keyword, pageable); - List result = page.getContent(); - - // then - assertEquals(10, result.size()); - assertEquals("테스트 문서 100", result.get(0).title()); - assertEquals("테스트 문서 19", result.get(1).title()); - assertEquals("테스트 문서 11", result.getLast().title()); - - verify(docQueryService).searchByTitle(keyword, userId, pageable); - verify(docListAssembler).assembleDocList(docsPage); - } - - @Test - @DisplayName("문서 제목 수정 성공 테스트") - void updateDocTitleSuccess() throws Exception { - //given - Long userId = 1L; - - User user = DocTestUtils.createUser(); - ReflectionTestUtils.setField(user, "id", userId); - - Long docId = 10L; - String newTitle = "new title"; - - Doc doc = Doc.builder().title("old title").user(user).build(); - ReflectionTestUtils.setField(doc, "id", docId); - ReflectionTestUtils.setField(doc, "updatedAt", LocalDateTime.now()); - - DocTitleRequest request = new DocTitleRequest(newTitle); - - DocTitleUpdateResponse response = - new DocTitleUpdateResponse(docId, newTitle, LocalDateTime.now()); - - when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(doc); - - //when - DocTitleUpdateResponse result = docService.updateTitle(userId, docId, request); - - //then - verify(docQueryService).checkTitleDuplicate(userId, newTitle); - verify(docQueryService).getByIdAndUserId(docId, userId); - verify(domainEventOutboxPublisher).publish( - eq(DomainEventType.DOC_TITLE_CHANGED), - eq(AggregateType.DOC), - eq(docId), - any() - ); - assertEquals(newTitle, doc.getTitle()); - assertEquals(response.id(), doc.getId()); - assertEquals(response.title(), result.title()); - } - - @Test - @DisplayName("문서 제목 수정 실패 테스트 - 문서이름 중복") - void updateDocTitleFailByDuplicateTitle() throws Exception { - // given - Long userId = 1L; - Long docId = 10L; - String duplicateTitle = "중복된 제목"; - DocTitleRequest request = new DocTitleRequest(duplicateTitle); - - User user = DocTestUtils.createUser(); - ReflectionTestUtils.setField(user, "id", userId); - - Doc doc = Doc.builder().title("기존 제목").user(user).build(); - ReflectionTestUtils.setField(doc, "id", docId); - - when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(doc); - doThrow(new CustomException(DocErrorCode.TITLE_DUPLICATION)) - .when(docQueryService).checkTitleDuplicate(userId, duplicateTitle); - - // when & then - CustomException exception = assertThrows(CustomException.class, - () -> docService.updateTitle(userId, docId, request)); - - assertEquals(DocErrorCode.TITLE_DUPLICATION, exception.getErrorCode()); - } - - @Test - @DisplayName("그래프 조회 성공") - void getGraph_shouldReturnGraphResponse_whenDocExists() { - Long userId = 1L; - Long docId = 10L; - String docTitle = "Test Document"; - - User user = DocTestUtils.createUser(); - ReflectionTestUtils.setField(user, "id", userId); - - Doc doc = Doc.builder().title(docTitle).user(user).build(); - ReflectionTestUtils.setField(doc, "id", docId); - - // Mock 문서 제목 조회 - when(docQueryService.getByIdAndUserId(docId, userId)) - .thenReturn(doc); - - // Mock Branch, Commit, Edge 리스트 - LocalDateTime now = LocalDateTime.now(); - List branches = List.of( - new BranchGraphDto(1L, "main", now, null, null, null, null, null) - ); - List commits = List.of( - new CommitGraphDto(100L, 1L, "Initial Commit", "desc", now) - ); - List edges = List.of( - new EdgeDto(100L, 101L) - ); - - when(branchQueryService.getBranchGraphList(docId)).thenReturn(branches); - when(commitQueryService.getCommitGraphList(docId)).thenReturn(commits); - when(edgeService.getEdgeDtoByDocId(docId)).thenReturn(edges); - - GraphResponse response = docService.getGraph(userId, docId); - - assertEquals(docTitle, response.title()); - assertEquals(branches, response.branches()); - assertEquals(commits, response.commits()); - assertEquals(edges, response.edges()); - } -} diff --git a/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java index fbc9f852..32e25d2f 100644 --- a/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java @@ -8,7 +8,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.image.dao.ImageRepository; import io.ejangs.docsa.domain.image.dto.request.ImageUploadUrlRequest; @@ -48,7 +48,7 @@ class ImageServiceUnitTest { private ImageQueryService imageQueryService; @Mock - private DocQueryService docQueryService; + private DocReader docReader; @Mock private S3Presigner s3Presigner; @@ -63,7 +63,7 @@ class ImageServiceUnitTest { @BeforeEach void setUp() { - imageService = new ImageService(imageRepository, imageQueryService, docQueryService, + imageService = new ImageService(imageRepository, imageQueryService, docReader, s3Presigner, s3Client); ReflectionTestUtils.setField(imageService, "bucket", "docsa-image-bucket"); ReflectionTestUtils.setField(imageService, "expireMinutes", 5L); @@ -78,7 +78,7 @@ void createUploadUrl_success() throws Exception { ImageUploadUrlRequest request = new ImageUploadUrlRequest(docId, "sample.png", "image/png", 1024L, Purpose.DOC_CONTENT); - when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); when(imageRepository.save(any(Image.class))).thenAnswer(invocation -> { Image image = invocation.getArgument(0); ReflectionTestUtils.setField(image, "id", 10L); @@ -119,7 +119,7 @@ void createUploadUrl_success_whenPurposeIsThumbnail() throws Exception { ImageUploadUrlRequest request = new ImageUploadUrlRequest(docId, "thumbnail.webp", "image/webp", 1024L, Purpose.DOC_THUMBNAIL); - when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); when(imageRepository.save(any(Image.class))).thenAnswer(invocation -> { Image image = invocation.getArgument(0); ReflectionTestUtils.setField(image, "id", 10L); @@ -153,7 +153,7 @@ void createUploadUrl_fail_whenContentTypeInvalid() { ImageUploadUrlRequest request = new ImageUploadUrlRequest(docId, "sample.svg", "image/svg+xml", 1024L, Purpose.DOC_CONTENT); - when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(org.mockito.Mockito.mock(Doc.class)); assertThatThrownBy(() -> imageService.createUploadUrl(userId, request)) .isInstanceOf(CustomException.class) diff --git a/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java b/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java index 5881eb30..8381e591 100644 --- a/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java @@ -19,7 +19,7 @@ import io.ejangs.docsa.domain.commit.app.CommitQueryService; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.commit.util.CommitMockTestUtils; -import io.ejangs.docsa.domain.doc.app.create.DocQueryService; +import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.branch.merge.dto.request.MergeRequest; import io.ejangs.docsa.domain.branch.merge.dto.response.MergeResponse; @@ -41,7 +41,7 @@ class MergeServiceMockTest { @Mock - private DocQueryService docQueryService; + private DocReader docReader; @Mock private BranchQueryService branchQueryService; @@ -80,7 +80,7 @@ void merge_success_whenBaseAndTargetAreSameBranch() { Long baseCommitId = 11L; Long targetCommitId = 22L; - when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(doc); + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); when(baseCommit.getId()).thenReturn(baseCommitId); when(targetCommit.getId()).thenReturn(targetCommitId); when(commitQueryService.getById(baseCommitId)).thenReturn(baseCommit); @@ -117,7 +117,7 @@ void merge_success_whenBaseAndTargetAreSameBranch() { void merge_fail_whenBranchNameDuplicated() { Long baseCommitId = 31L; Long targetCommitId = 32L; - when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(doc); + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); doThrow(new CustomException(BranchErrorCode.BRANCH_NAME_DUPLICATED)) .when(branchQueryService).checkDuplicatedWithBranchName(docId, "merged-branch"); @@ -140,7 +140,7 @@ void merge_fail_whenBranchNameDuplicated() { void merge_fail_whenBaseAndTargetAreSameCommit() { Long sameCommitId = 41L; - when(docQueryService.getByIdAndUserId(docId, userId)).thenReturn(doc); + when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); when(baseCommit.getId()).thenReturn(sameCommitId); when(commitQueryService.getById(sameCommitId)).thenReturn(baseCommit); doThrow(new CustomException(CommitErrorCode.INVALID_MERGE_REQUEST)) @@ -175,7 +175,7 @@ void merge_fail_whenDocLookupFails() { ); doThrow(new CustomException(DocErrorCode.DOCUMENT_NOT_FOUND)) - .when(docQueryService).getByIdAndUserId(docId, userId); + .when(docReader).getByIdAndUserId(docId, userId); assertThatThrownBy(() -> mergeService.merge(docId, request, userId)) .isInstanceOf(CustomException.class); From cad4620d357c5de212ad94b618b0e4a0dcf58857 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Mon, 11 May 2026 01:40:32 +0900 Subject: [PATCH 16/28] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EB=B0=8F=20=ED=99=98=EA=B2=BD=EB=B3=84=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?-=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=9D=98=20MockMvc=20print=20=EC=B6=9C=EB=A0=A5?= =?UTF-8?q?=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=B4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A1=9C=EA=B7=B8=20=EB=85=B8=EC=9D=B4=EC=A6=88?= =?UTF-8?q?=EB=A5=BC=20=EC=A4=84=EC=9E=84=20-=20test/local/stg/prod=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=9D=98=20SQL=20=EB=B0=8F=20bind=20paramete?= =?UTF-8?q?r=20=EB=A1=9C=EA=B7=B8=20=EB=A0=88=EB=B2=A8=EC=9D=84=20?= =?UTF-8?q?=EB=AA=85=EC=8B=9C=20-=20local=20=ED=99=98=EA=B2=BD=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=8A=94=20=ED=95=84=EC=9A=94=20=EC=8B=9C=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EB=B3=80=EC=88=98=EB=A1=9C=20SQL=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=BC=A4=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 20 ++++++--- src/main/resources/application-prod.yml | 11 +++++ src/main/resources/application-stg.yml | 9 +++- .../domain/auth/api/AuthControllerTest.java | 40 ++++++----------- .../api/integration/AuthIntegrationTest.java | 9 ++-- .../doc/unit/DocControllerUnitTests.java | 27 +++++------- .../domain/save/api/SaveControllerTest.java | 13 ++---- .../domain/user/api/UserControllerTest.java | 44 +++++-------------- .../user/integration/UserIntegrationTest.java | 15 ++----- src/test/resources/application-test.yml | 12 +++++ 10 files changed, 90 insertions(+), 110 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c54a993c..5f226ef8 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -24,31 +24,37 @@ spring: jpa: hibernate: ddl-auto: ${DDL_AUTO:create-drop} + show-sql: ${JPA_SHOW_SQL:false} properties: hibernate: - show_sql: true - format_sql: true - highlight_sql: true + format_sql: ${HIBERNATE_FORMAT_SQL:false} + highlight_sql: ${HIBERNATE_HIGHLIGHT_SQL:false} decorator: datasource: datasource-proxy: - enabled: false + enabled: ${DATASOURCE_PROXY_ENABLED:false} logging: slf4j multiline: true format-sql: true count-query: true query: - enable-logging: true + enable-logging: ${SQL_QUERY_LOGGING_ENABLED:false} log-level: debug slow-query: - enable-logging: true + enable-logging: ${SLOW_QUERY_LOGGING_ENABLED:true} log-level: warn threshold: 100 logging: level: - net.ttddyy.dsproxy.listener: OFF + root: INFO + io.ejangs.docsa: DEBUG + org.hibernate.SQL: ${LOG_LEVEL_HIBERNATE_SQL:OFF} + org.hibernate.orm.jdbc.bind: ${LOG_LEVEL_HIBERNATE_BIND:OFF} + org.springframework: INFO + org.mongodb.driver: WARN + net.ttddyy.dsproxy.listener: ${LOG_LEVEL_DATASOURCE_PROXY:OFF} server: port: 8080 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 91e8e597..4121abf6 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -64,3 +64,14 @@ springdoc: decorator: datasource: enabled: false + +logging: + level: + root: WARN + io.ejangs.docsa: INFO + org.hibernate.SQL: OFF + org.hibernate.orm.jdbc.bind: OFF + org.springframework: WARN + org.mongodb.driver: WARN + com.zaxxer.hikari: WARN + net.ttddyy.dsproxy.listener: WARN diff --git a/src/main/resources/application-stg.yml b/src/main/resources/application-stg.yml index 18c0faf4..1d90406d 100644 --- a/src/main/resources/application-stg.yml +++ b/src/main/resources/application-stg.yml @@ -87,4 +87,11 @@ decorator: logging: level: - net.ttddyy.dsproxy.listener: DEBUG \ No newline at end of file + root: INFO + io.ejangs.docsa: INFO + org.hibernate.SQL: OFF + org.hibernate.orm.jdbc.bind: OFF + org.springframework: WARN + org.mongodb.driver: WARN + com.zaxxer.hikari: WARN + net.ttddyy.dsproxy.listener: WARN diff --git a/src/test/java/io/ejangs/docsa/domain/auth/api/AuthControllerTest.java b/src/test/java/io/ejangs/docsa/domain/auth/api/AuthControllerTest.java index 751d6319..a2b07383 100644 --- a/src/test/java/io/ejangs/docsa/domain/auth/api/AuthControllerTest.java +++ b/src/test/java/io/ejangs/docsa/domain/auth/api/AuthControllerTest.java @@ -7,7 +7,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -57,8 +56,7 @@ void sendSignupCode_Success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(content().string("")) - .andDo(print()); + .andExpect(content().string("")); verify(authService).sendSignupCode(request); } @@ -73,8 +71,7 @@ void sendSignupCode_InvalidEmail() throws Exception { mockMvc.perform(post("/api/auth/code/signup-email") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andDo(print()); + .andExpect(status().isBadRequest()); verify(authService, never()).sendSignupCode(any()); } @@ -89,8 +86,7 @@ void sendSignupCode_NullEmail() throws Exception { mockMvc.perform(post("/api/auth/code/signup-email") .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) - .andExpect(status().isBadRequest()) - .andDo(print()); + .andExpect(status().isBadRequest()); verify(authService, never()).sendSignupCode(any()); } @@ -105,8 +101,7 @@ void sendSignupCode_EmptyEmail() throws Exception { mockMvc.perform(post("/api/auth/code/signup-email") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andDo(print()); + .andExpect(status().isBadRequest()); verify(authService, never()).sendSignupCode(any()); } @@ -127,8 +122,7 @@ void sendSignupCode_DuplicateEmail() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.message").value("이미 가입된 이메일입니다.")) - .andExpect(jsonPath("$.error").value("DUPLICATE_EMAIL")) - .andDo(print()); + .andExpect(jsonPath("$.error").value("DUPLICATE_EMAIL")); verify(authService).sendSignupCode(request); } @@ -148,8 +142,7 @@ void checkCode_Success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.passCode").value("generatedPass123")) - .andDo(print()); + .andExpect(jsonPath("$.passCode").value("generatedPass123")); verify(authService).checkCode(request); } @@ -169,8 +162,7 @@ void checkCode_Expired() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("EXPIRED_CODE")) - .andExpect(jsonPath("$.message").value("인증 코드가 만료되었습니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("인증 코드가 만료되었습니다.")); verify(authService).checkCode(request); } @@ -190,8 +182,7 @@ void checkCode_Invalid() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("INVALID_CODE")) - .andExpect(jsonPath("$.message").value("인증 코드가 일치하지 않습니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("인증 코드가 일치하지 않습니다.")); verify(authService).checkCode(request); } @@ -206,8 +197,7 @@ void checkCode_EmptyFields() throws Exception { mockMvc.perform(post("/api/auth/code/check") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()) - .andDo(print()); + .andExpect(status().isBadRequest()); verify(authService, never()).checkCode(any()); } @@ -225,8 +215,7 @@ void sendPwdResetCode_Success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(content().string("")) - .andDo(print()); + .andExpect(content().string("")); verify(authService).sendResetPwdCode(request); } @@ -246,8 +235,7 @@ void sendResetPwdCode_UserNotFound() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error").value("USER_NOT_FOUND")) - .andExpect(jsonPath("$.message").value("해당 사용자를 찾을 수 없습니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("해당 사용자를 찾을 수 없습니다.")); verify(authService).sendResetPwdCode(any(PwdResetCodeRequest.class)); } @@ -268,8 +256,7 @@ void checkCode_SignupWithExistingUser() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("ALREADY_REGISTERED_USER")) - .andExpect(jsonPath("$.message").value("이미 가입한 사용자입니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("이미 가입한 사용자입니다.")); verify(authService).checkCode(request); } @@ -290,8 +277,7 @@ void checkCode_ResetPasswordWithUnknownUser() throws Exception { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error").value("USER_NOT_FOUND")) - .andExpect(jsonPath("$.message").value("해당 사용자를 찾을 수 없습니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("해당 사용자를 찾을 수 없습니다.")); verify(authService).checkCode(request); } diff --git a/src/test/java/io/ejangs/docsa/domain/auth/api/integration/AuthIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/auth/api/integration/AuthIntegrationTest.java index 62e64549..b3171874 100644 --- a/src/test/java/io/ejangs/docsa/domain/auth/api/integration/AuthIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/auth/api/integration/AuthIntegrationTest.java @@ -1,10 +1,9 @@ package io.ejangs.docsa.domain.auth.api.integration; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.domain.user.security.CustomUserDetails; @@ -41,8 +40,7 @@ void checkSession_Authenticated() throws Exception { .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(1L)) - .andExpect(jsonPath("$.name").value("테스트유저")) - .andDo(print()); + .andExpect(jsonPath("$.name").value("테스트유저")); } @Test @@ -53,8 +51,7 @@ void checkSession_Unauthenticated() throws Exception { .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.status").value(401)) .andExpect(jsonPath("$.message").value("로그인이 필요합니다.")) - .andExpect(jsonPath("$.error").value("LOGIN_REQUIRED")) - .andDo(print()); + .andExpect(jsonPath("$.error").value("LOGIN_REQUIRED")); } private User createTestUser() { diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java index 32694df2..d1fe1768 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java @@ -4,22 +4,21 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; -import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; -import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; import io.ejangs.docsa.domain.doc.api.DocController; import io.ejangs.docsa.domain.doc.app.DocCommandService; import io.ejangs.docsa.domain.doc.app.DocQueryService; -import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; import io.ejangs.docsa.domain.doc.dto.request.DocTitleRequest; -import io.ejangs.docsa.domain.edge.dto.GraphResponse; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; import io.ejangs.docsa.domain.doc.dto.response.DocTitleUpdateResponse; +import io.ejangs.docsa.domain.edge.dto.GraphResponse; +import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; +import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; +import io.ejangs.docsa.domain.edge.dto.graph.EdgeDto; import io.ejangs.docsa.domain.save.util.PageableFactory; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.DocErrorCode; @@ -76,8 +75,7 @@ void createDocSuccess() throws Exception { .with(csrf())) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(documentId)) - .andExpect(jsonPath("$.saveId").value(saveId)) - .andDo(print()); + .andExpect(jsonPath("$.saveId").value(saveId)); } @Test @@ -89,8 +87,7 @@ void createDocFailByBlankTitle() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("문서제목을 입력해주세요.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("문서제목을 입력해주세요.")); } @Test @@ -131,8 +128,7 @@ void getSimpleDocList() throws Exception { .andExpect(jsonPath("$.content[0].title").value("마이크로소프트")) .andExpect(jsonPath("$.content[0].recentSaveId").value(10)) .andExpect(jsonPath("$.content[1].title").value("구글")) - .andExpect(jsonPath("$.content[1].recentSaveId").value(11)) - .andDo(print()); + .andExpect(jsonPath("$.content[1].recentSaveId").value(11)); } @Test @@ -194,8 +190,7 @@ void updateDocTitleFailByTooLongTitle() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("문서제목은 50자를 초과 할 수 없습니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("문서제목은 50자를 초과 할 수 없습니다.")); } @Test @@ -225,8 +220,7 @@ void getGraphSuccess() throws Exception { .andExpect(jsonPath("$.title").value("문서 제목")) .andExpect(jsonPath("$.commits").isArray()) .andExpect(jsonPath("$.branches").isArray()) - .andExpect(jsonPath("$.edges").isArray()) - .andDo(print()); + .andExpect(jsonPath("$.edges").isArray()); } @Test @@ -239,7 +233,6 @@ void getGraphFailByNotFound() throws Exception { .thenThrow(new CustomException(DocErrorCode.DOCUMENT_NOT_FOUND)); // when & then mockMvc.perform(MockMvcRequestBuilders.get("/api/document/{docId}/graph", docId)) - .andExpect(status().isNotFound()) - .andDo(print()); + .andExpect(status().isNotFound()); } } diff --git a/src/test/java/io/ejangs/docsa/domain/save/api/SaveControllerTest.java b/src/test/java/io/ejangs/docsa/domain/save/api/SaveControllerTest.java index 13d61f07..2d13d5e7 100644 --- a/src/test/java/io/ejangs/docsa/domain/save/api/SaveControllerTest.java +++ b/src/test/java/io/ejangs/docsa/domain/save/api/SaveControllerTest.java @@ -4,7 +4,6 @@ import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -75,8 +74,7 @@ void updateSave_success() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.updatedAt").exists()) - .andDo(print()); + .andExpect(jsonPath("$.updatedAt").exists()); } @ParameterizedTest @@ -93,8 +91,7 @@ void updateSave_fail_invalidPathVariables(String documentId, String saveId) thro .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(result -> assertInstanceOf(MethodArgumentTypeMismatchException.class, - result.getResolvedException())) - .andDo(print()); + result.getResolvedException())); } @Test @@ -112,8 +109,7 @@ void getSave_success() throws Exception { .andExpect(jsonPath("$.updatedAt").exists()) .andExpect(jsonPath("$.content").isArray()) .andExpect(jsonPath("$.content[0].text1").value("Key features")) - .andExpect(jsonPath("$.content[1].text2").value("Key features")) - .andDo(print()); + .andExpect(jsonPath("$.content[1].text2").value("Key features")); } @ParameterizedTest @@ -130,7 +126,6 @@ void getSave_fail_invalidPathVariables(String documentId, String saveId) throws .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()) .andExpect(result -> assertInstanceOf(MethodArgumentTypeMismatchException.class, - result.getResolvedException())) - .andDo(print()); + result.getResolvedException())); } } \ No newline at end of file diff --git a/src/test/java/io/ejangs/docsa/domain/user/api/UserControllerTest.java b/src/test/java/io/ejangs/docsa/domain/user/api/UserControllerTest.java index 56c12eaa..54787e91 100644 --- a/src/test/java/io/ejangs/docsa/domain/user/api/UserControllerTest.java +++ b/src/test/java/io/ejangs/docsa/domain/user/api/UserControllerTest.java @@ -5,9 +5,8 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; @@ -74,8 +73,7 @@ void signup_Success() throws Exception { .content(objectMapper.writeValueAsString(signupRequest))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(1L)) - .andExpect(jsonPath("$.name").value("이장님")) - .andDo(print()); + .andExpect(jsonPath("$.name").value("이장님")); verify(userService).signup(any(UserSignupRequest.class)); } @@ -94,8 +92,7 @@ void signup_DuplicateEmail() throws Exception { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.status").value(400)) .andExpect(jsonPath("$.message").value("이미 가입된 이메일입니다.")) - .andExpect(jsonPath("$.error").value("DUPLICATE_EMAIL")) - .andDo(print()); + .andExpect(jsonPath("$.error").value("DUPLICATE_EMAIL")); verify(userService).signup(any(UserSignupRequest.class)); } @@ -113,9 +110,7 @@ void signup_ExpiredCode() throws Exception { .content(objectMapper.writeValueAsString(signupRequest))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("EXPIRED_CODE")) - .andExpect(jsonPath("$.message").value("인증 코드가 만료되었습니다.")) - .andDo(print()); - + .andExpect(jsonPath("$.message").value("인증 코드가 만료되었습니다.")); verify(userService).signup(any(UserSignupRequest.class)); } @@ -132,9 +127,7 @@ void signup_InvalidCode() throws Exception { .content(objectMapper.writeValueAsString(signupRequest))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("INVALID_CODE")) - .andExpect(jsonPath("$.message").value("인증 코드가 일치하지 않습니다.")) - .andDo(print()); - + .andExpect(jsonPath("$.message").value("인증 코드가 일치하지 않습니다.")); verify(userService).signup(any(UserSignupRequest.class)); } @@ -153,9 +146,7 @@ void signup_InvalidEmail() throws Exception { mockMvc.perform(post("/api/user/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(invalidRequest))) - .andExpect(status().isBadRequest()) - .andDo(print()); - + .andExpect(status().isBadRequest()); verify(userService, never()).signup(any()); } @@ -169,9 +160,7 @@ void signup_NullEmail() throws Exception { mockMvc.perform(post("/api/user/signup") .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) - .andExpect(status().isBadRequest()) - .andDo(print()); - + .andExpect(status().isBadRequest()); verify(userService, never()).signup(any()); } @@ -190,9 +179,7 @@ void signup_EmptyEmail() throws Exception { mockMvc.perform(post("/api/user/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(invalidRequest))) - .andExpect(status().isBadRequest()) - .andDo(print()); - + .andExpect(status().isBadRequest()); verify(userService, never()).signup(any()); } @@ -211,9 +198,7 @@ void signup_EmptyName() throws Exception { mockMvc.perform(post("/api/user/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(invalidRequest))) - .andExpect(status().isBadRequest()) - .andDo(print()); - + .andExpect(status().isBadRequest()); verify(userService, never()).signup(any()); } @@ -232,9 +217,7 @@ void signup_ShortPassword() throws Exception { mockMvc.perform(post("/api/user/signup") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(invalidRequest))) - .andExpect(status().isBadRequest()) - .andDo(print()); - + .andExpect(status().isBadRequest()); verify(userService, never()).signup(any()); } @@ -245,9 +228,7 @@ void resetPassword_Success() throws Exception { mockMvc.perform(post("/api/user/password") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(passwordResetRequest))) - .andExpect(status().isOk()) - .andDo(print()); - + .andExpect(status().isOk()); verify(userService).resetPassword(any(PasswordResetRequest.class)); } @@ -264,7 +245,6 @@ void resetPassword_SameAsOldPassword() throws Exception { .content(objectMapper.writeValueAsString(passwordResetRequest))) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.error").value("SAME_AS_OLD_PASSWORD")) - .andExpect(jsonPath("$.message").value("기존 비밀번호와 동일한 비밀번호입니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("기존 비밀번호와 동일한 비밀번호입니다.")); } } \ No newline at end of file diff --git a/src/test/java/io/ejangs/docsa/domain/user/integration/UserIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/user/integration/UserIntegrationTest.java index 00d638cc..407bb4ea 100644 --- a/src/test/java/io/ejangs/docsa/domain/user/integration/UserIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/user/integration/UserIntegrationTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -77,7 +76,6 @@ void login_Success_SessionAndResponse() throws Exception { .content(objectMapper.writeValueAsString(loginRequest))) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(user.getId())) - .andDo(print()) .andReturn(); // 세션 확인 @@ -106,8 +104,7 @@ void login_Fail_InvalidEmail() throws Exception { .content(objectMapper.writeValueAsString(loginRequest))) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.error").value("INVALID_CREDENTIALS")) - .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 일치하지 않습니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 일치하지 않습니다.")); } @Test @@ -120,8 +117,7 @@ void login_Fail_InvalidPassword() throws Exception { .content(objectMapper.writeValueAsString(loginRequest))) .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.error").value("INVALID_CREDENTIALS")) - .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 일치하지 않습니다.")) - .andDo(print()); + .andExpect(jsonPath("$.message").value("이메일 또는 비밀번호가 일치하지 않습니다.")); } @Test @@ -133,8 +129,7 @@ void login_Fail_InvalidInput() throws Exception { mockMvc.perform(post("/api/user/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(invalidEmailRequest))) - .andExpect(status().isBadRequest()) - .andDo(print()); + .andExpect(status().isBadRequest()); // 빈 비밀번호 UserLoginRequest invalidPasswordRequest = new UserLoginRequest("test@example.com", ""); @@ -142,8 +137,7 @@ void login_Fail_InvalidInput() throws Exception { mockMvc.perform(post("/api/user/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(invalidPasswordRequest))) - .andExpect(status().isBadRequest()) - .andDo(print()); + .andExpect(status().isBadRequest()); } @Test @@ -245,7 +239,6 @@ void logout_Success() throws Exception { MvcResult logoutResult = mockMvc.perform(post("/api/user/logout") .session(session)) .andExpect(status().isOk()) - .andDo(print()) .andReturn(); // 세션 무효화 확인 diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index d6728217..2bd87e38 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -85,3 +85,15 @@ cloud: bucket: docsa-test-bucket presigned-expire-minutes: 5 public-base-url: https://cdn.test.invalid +logging: + level: + root: WARN + io.ejangs.docsa: INFO + org.hibernate.SQL: OFF + org.hibernate.orm.jdbc.bind: OFF + org.springframework: WARN + org.springframework.test: WARN + org.springframework.transaction: WARN + org.springframework.data.mongodb: WARN + org.mongodb.driver: WARN + com.zaxxer.hikari: WARN From 2b6568b166f156fdfd43b6dd0cf2b74a33d5d914 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Mon, 11 May 2026 01:55:19 +0900 Subject: [PATCH 17/28] =?UTF-8?q?chore:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20pa?= =?UTF-8?q?ssed=20=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 7d6b5125..5e9ad4f5 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,8 @@ repositories { test { testLogging { - events "passed", "skipped", "failed" + events "skipped", "failed" + showStandardStreams = false exceptionFormat "full" showCauses true showExceptions true From d8c8057492af3dae0d72ebbe218241528b49e6b6 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Mon, 11 May 2026 01:58:23 +0900 Subject: [PATCH 18/28] =?UTF-8?q?fix:=20local=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20boolean=20=EB=B0=94=EC=9D=B8=EB=94=A9=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-local.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 5f226ef8..60cba511 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -24,25 +24,25 @@ spring: jpa: hibernate: ddl-auto: ${DDL_AUTO:create-drop} - show-sql: ${JPA_SHOW_SQL:false} + show-sql: false properties: hibernate: - format_sql: ${HIBERNATE_FORMAT_SQL:false} - highlight_sql: ${HIBERNATE_HIGHLIGHT_SQL:false} + format_sql: false + highlight_sql: false decorator: datasource: datasource-proxy: - enabled: ${DATASOURCE_PROXY_ENABLED:false} + enabled: false logging: slf4j multiline: true format-sql: true count-query: true query: - enable-logging: ${SQL_QUERY_LOGGING_ENABLED:false} + enable-logging: false log-level: debug slow-query: - enable-logging: ${SLOW_QUERY_LOGGING_ENABLED:true} + enable-logging: true log-level: warn threshold: 100 From 072fd82f4b98cceec25651599f90840ff893260d Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Tue, 12 May 2026 00:52:30 +0900 Subject: [PATCH 19/28] =?UTF-8?q?test:=20=EB=AC=B8=EC=84=9C=20QueryService?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?URL=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 문서 도메인 테스트를 api/app 단위와 통합 테스트 패키지로 정리 - DocQueryService 목록 응답에서 thumbnailObjectKey null/blank 처리 검증 추가 - thumbnailObjectKey가 있으면 CDN URL로 변환되는지 검증 --- .../unit/DocControllerUnitTests.java | 2 +- .../DocCommandServiceIntegrationTests.java | 2 +- .../integration/DocGraphIntegrationTest.java | 11 ++--- .../unit/DocCommandServiceUnitTests.java | 2 +- .../unit/DocCreateOrchestratorTest.java | 2 +- .../unit/DocQueryServiceUnitTests.java | 48 ++++++++++++++++++- 6 files changed, 56 insertions(+), 11 deletions(-) rename src/test/java/io/ejangs/docsa/domain/doc/{ => api}/unit/DocControllerUnitTests.java (99%) rename src/test/java/io/ejangs/docsa/domain/doc/{ => app}/integration/DocCommandServiceIntegrationTests.java (99%) rename src/test/java/io/ejangs/docsa/domain/doc/{ => app}/integration/DocGraphIntegrationTest.java (98%) rename src/test/java/io/ejangs/docsa/domain/doc/{ => app}/unit/DocCommandServiceUnitTests.java (99%) rename src/test/java/io/ejangs/docsa/domain/doc/{ => app}/unit/DocCreateOrchestratorTest.java (98%) rename src/test/java/io/ejangs/docsa/domain/doc/{ => app}/unit/DocQueryServiceUnitTests.java (78%) diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/api/unit/DocControllerUnitTests.java similarity index 99% rename from src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java rename to src/test/java/io/ejangs/docsa/domain/doc/api/unit/DocControllerUnitTests.java index d1fe1768..8130d449 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocControllerUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/api/unit/DocControllerUnitTests.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.unit; +package io.ejangs.docsa.domain.doc.api.unit; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocCommandServiceIntegrationTests.java b/src/test/java/io/ejangs/docsa/domain/doc/app/integration/DocCommandServiceIntegrationTests.java similarity index 99% rename from src/test/java/io/ejangs/docsa/domain/doc/integration/DocCommandServiceIntegrationTests.java rename to src/test/java/io/ejangs/docsa/domain/doc/app/integration/DocCommandServiceIntegrationTests.java index 3e1050b0..2b9cedc1 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocCommandServiceIntegrationTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/app/integration/DocCommandServiceIntegrationTests.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.integration; +package io.ejangs.docsa.domain.doc.app.integration; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocGraphIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/doc/app/integration/DocGraphIntegrationTest.java similarity index 98% rename from src/test/java/io/ejangs/docsa/domain/doc/integration/DocGraphIntegrationTest.java rename to src/test/java/io/ejangs/docsa/domain/doc/app/integration/DocGraphIntegrationTest.java index 8f6dfece..336675c1 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/integration/DocGraphIntegrationTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/app/integration/DocGraphIntegrationTest.java @@ -1,4 +1,7 @@ -package io.ejangs.docsa.domain.doc.integration; +package io.ejangs.docsa.domain.doc.app.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; import io.ejangs.docsa.domain.block.dao.mongodb.BlockRepository; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; @@ -9,6 +12,7 @@ import io.ejangs.docsa.domain.save.dao.mongodb.SaveContentRepository; import io.ejangs.docsa.domain.user.dao.mysql.UserRepository; import io.ejangs.docsa.domain.user.entity.User; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -18,11 +22,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) public class DocGraphIntegrationTest { diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCommandServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCommandServiceUnitTests.java similarity index 99% rename from src/test/java/io/ejangs/docsa/domain/doc/unit/DocCommandServiceUnitTests.java rename to src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCommandServiceUnitTests.java index f31c32d1..4733991d 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCommandServiceUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCommandServiceUnitTests.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.unit; +package io.ejangs.docsa.domain.doc.app.unit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCreateOrchestratorTest.java similarity index 98% rename from src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java rename to src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCreateOrchestratorTest.java index 117fcdb5..be3696ab 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCreateOrchestratorTest.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.unit; +package io.ejangs.docsa.domain.doc.app.unit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocQueryServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java similarity index 78% rename from src/test/java/io/ejangs/docsa/domain/doc/unit/DocQueryServiceUnitTests.java rename to src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java index 6eccd25e..950919ac 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/unit/DocQueryServiceUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.unit; +package io.ejangs.docsa.domain.doc.app.unit; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -119,6 +119,52 @@ void searchList_success() { assertThat(result.getContent().getFirst().title()).isEqualTo("문서 1"); } + @Test + @DisplayName("문서 목록 조회시 thumbnailObjectKey가 없으면 thumbnailUrl은 null") + void getPage_objectKeyNull_thumbnailUrlNull() { + DocListReadModel model = readModel(1L, "문서 1", 10L, null, ThumbnailStatus.EMPTY); + + when(docListReadModelRepository.findByUserIdAndDeletedFalse(userId, pageable)) + .thenReturn(new PageImpl<>(List.of(model), pageable, 1)); + + Page result = docQueryService.getPage(userId, pageable); + + assertThat(result.getContent()).hasSize(1); + DocPageResponse response = result.getContent().getFirst(); + assertThat(response.thumbnailUrl()).isNull(); + } + + @Test + @DisplayName("문서 목록 조회시 thumbnailObjectKey가 blank면 thumbnailUrl은 null") + void getPage_objectKeyBlank_thumbnailUrlNull() { + DocListReadModel model = readModel(1L, "문서 1", 10L, " ", ThumbnailStatus.EMPTY); + + when(docListReadModelRepository.findByUserIdAndDeletedFalse(userId, pageable)) + .thenReturn(new PageImpl<>(List.of(model), pageable, 1)); + + Page result = docQueryService.getPage(userId, pageable); + + assertThat(result.getContent()).hasSize(1); + DocPageResponse response = result.getContent().getFirst(); + assertThat(response.thumbnailUrl()).isNull(); + } + + @Test + @DisplayName("문서 목록 조회시 thumbnailObjectKey가 있으면 CDN URL을 조합한다") + void getPage_objectKeyExists_buildThumbnailUrl() { + DocListReadModel model = readModel(1L, "문서 1", 10L, "thumbnail/doc-1.webp", ThumbnailStatus.READY); + + when(docListReadModelRepository.findByUserIdAndDeletedFalse(userId, pageable)) + .thenReturn(new PageImpl<>(List.of(model), pageable, 1)); + + Page result = docQueryService.getPage(userId, pageable); + + assertThat(result.getContent()).hasSize(1); + DocPageResponse response = result.getContent().getFirst(); + assertThat(response.thumbnailUrl()).isEqualTo("https://cdn.test.invalid/thumbnail/doc-1.webp"); + assertThat(response.thumbnailStatus()).isEqualTo(ThumbnailStatus.READY); + } + @Test @DisplayName("그래프 조회 성공") void getGraph_success() { From 85c7d0e6d25a83c6ad8ee5fbfc33ce19e6b3abec Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Tue, 12 May 2026 01:33:18 +0900 Subject: [PATCH 20/28] =?UTF-8?q?feat:=20local/test=20Mongo=20cleanup=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local 프로필에서 Mongo DB를 시작/종료 시 정리하는 cleanup 컴포넌트 추가 - test 프로필에서 테스트 DB 정리를 위한 cleanup 컴포넌트 추가 - 안전한 DB 이름(-local, -test)에 대해서만 drop 하도록 보호 로직 추가 - cleanup 동작을 검증하는 단위 테스트 추가 --- .../docsa/global/init/LocalMongoCleanup.java | 55 +++++++++++++++++++ .../docsa/global/init/TestMongoCleanup.java | 40 ++++++++++++++ src/main/resources/application-local.yml | 3 + .../init/LocalMongoCleanupUnitTest.java | 52 ++++++++++++++++++ .../global/init/TestMongoCleanupUnitTest.java | 42 ++++++++++++++ src/test/resources/application-test.yml | 3 + 6 files changed, 195 insertions(+) create mode 100644 src/main/java/io/ejangs/docsa/global/init/LocalMongoCleanup.java create mode 100644 src/main/java/io/ejangs/docsa/global/init/TestMongoCleanup.java create mode 100644 src/test/java/io/ejangs/docsa/global/init/LocalMongoCleanupUnitTest.java create mode 100644 src/test/java/io/ejangs/docsa/global/init/TestMongoCleanupUnitTest.java diff --git a/src/main/java/io/ejangs/docsa/global/init/LocalMongoCleanup.java b/src/main/java/io/ejangs/docsa/global/init/LocalMongoCleanup.java new file mode 100644 index 00000000..f444ec6e --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/init/LocalMongoCleanup.java @@ -0,0 +1,55 @@ +package io.ejangs.docsa.global.init; + +import com.mongodb.client.MongoDatabase; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@Profile("local") +@RequiredArgsConstructor +@Order(Ordered.HIGHEST_PRECEDENCE) +@ConditionalOnProperty( + prefix = "mongo.local-cleanup", + name = "enabled", + havingValue = "true" +) +public class LocalMongoCleanup implements ApplicationRunner { + + private final MongoTemplate mongoTemplate; + + @Override + public void run(ApplicationArguments args) { + dropDatabase("startup"); + } + + @PreDestroy + public void cleanup() { + dropDatabase("shutdown"); + } + + private void dropDatabase(String phase) { + MongoDatabase database = mongoTemplate.getDb(); + String databaseName = database.getName(); + if (!isSafeDatabaseName(databaseName)) { + log.warn("[LocalMongoCleanup] Skip {} cleanup. Unsafe databaseName={}", phase, databaseName); + return; + } + database.drop(); + log.info("[LocalMongoCleanup] Dropped MongoDB database on {}: {}", phase, databaseName); + } + + private boolean isSafeDatabaseName(String databaseName) { + return databaseName != null + && (databaseName.endsWith("-local") || databaseName.endsWith("-test")); + } +} diff --git a/src/main/java/io/ejangs/docsa/global/init/TestMongoCleanup.java b/src/main/java/io/ejangs/docsa/global/init/TestMongoCleanup.java new file mode 100644 index 00000000..060426da --- /dev/null +++ b/src/main/java/io/ejangs/docsa/global/init/TestMongoCleanup.java @@ -0,0 +1,40 @@ +package io.ejangs.docsa.global.init; + +import com.mongodb.client.MongoDatabase; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@Profile("test") +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "mongo.test-cleanup", + name = "enabled", + havingValue = "true" +) +public class TestMongoCleanup { + + private final MongoTemplate mongoTemplate; + + @PreDestroy + public void cleanup() { + MongoDatabase database = mongoTemplate.getDb(); + String databaseName = database.getName(); + if (!isSafeDatabaseName(databaseName)) { + log.warn("[TestMongoCleanup] Skip cleanup. Unsafe databaseName={}", databaseName); + return; + } + database.drop(); + log.info("[TestMongoCleanup] Dropped MongoDB database: {}", databaseName); + } + + private boolean isSafeDatabaseName(String databaseName) { + return databaseName != null && databaseName.endsWith("-test"); + } +} diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 60cba511..85175f78 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -71,6 +71,9 @@ springdoc: swagger-ui: path: /swagger-ui.html +mongo: + local-cleanup: + enabled: true perf: seed: diff --git a/src/test/java/io/ejangs/docsa/global/init/LocalMongoCleanupUnitTest.java b/src/test/java/io/ejangs/docsa/global/init/LocalMongoCleanupUnitTest.java new file mode 100644 index 00000000..180c0059 --- /dev/null +++ b/src/test/java/io/ejangs/docsa/global/init/LocalMongoCleanupUnitTest.java @@ -0,0 +1,52 @@ +package io.ejangs.docsa.global.init; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.mongodb.client.MongoDatabase; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; + +@ExtendWith(MockitoExtension.class) +class LocalMongoCleanupUnitTest { + + @Mock + private MongoTemplate mongoTemplate; + + @Mock + private MongoDatabase mongoDatabase; + + @Test + void cleanup_dropDatabase_whenLocalDatabase() { + when(mongoTemplate.getDb()).thenReturn(mongoDatabase); + when(mongoDatabase.getName()).thenReturn("docsa-local"); + + new LocalMongoCleanup(mongoTemplate).cleanup(); + + verify(mongoDatabase).drop(); + } + + @Test + void run_dropDatabase_whenLocalDatabase() { + when(mongoTemplate.getDb()).thenReturn(mongoDatabase); + when(mongoDatabase.getName()).thenReturn("docsa-local"); + + new LocalMongoCleanup(mongoTemplate).run(null); + + verify(mongoDatabase).drop(); + } + + @Test + void cleanup_skipDrop_whenUnsafeDatabase() { + when(mongoTemplate.getDb()).thenReturn(mongoDatabase); + when(mongoDatabase.getName()).thenReturn("docsa"); + + new LocalMongoCleanup(mongoTemplate).cleanup(); + + verify(mongoDatabase, never()).drop(); + } +} diff --git a/src/test/java/io/ejangs/docsa/global/init/TestMongoCleanupUnitTest.java b/src/test/java/io/ejangs/docsa/global/init/TestMongoCleanupUnitTest.java new file mode 100644 index 00000000..bb7d6ecc --- /dev/null +++ b/src/test/java/io/ejangs/docsa/global/init/TestMongoCleanupUnitTest.java @@ -0,0 +1,42 @@ +package io.ejangs.docsa.global.init; + +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.mongodb.client.MongoDatabase; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.MongoTemplate; + +@ExtendWith(MockitoExtension.class) +class TestMongoCleanupUnitTest { + + @Mock + private MongoTemplate mongoTemplate; + + @Mock + private MongoDatabase mongoDatabase; + + @Test + void cleanup_dropDatabase_whenTestDatabase() { + when(mongoTemplate.getDb()).thenReturn(mongoDatabase); + when(mongoDatabase.getName()).thenReturn("docsa-test"); + + new TestMongoCleanup(mongoTemplate).cleanup(); + + verify(mongoDatabase).drop(); + } + + @Test + void cleanup_skipDrop_whenUnsafeDatabase() { + when(mongoTemplate.getDb()).thenReturn(mongoDatabase); + when(mongoDatabase.getName()).thenReturn("docsa"); + + new TestMongoCleanup(mongoTemplate).cleanup(); + + verify(mongoDatabase, never()).drop(); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 2bd87e38..814ed37a 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -57,6 +57,9 @@ app: allowed-origin: mongo: + test-cleanup: + enabled: true + delete: outbox: worker: From dad62b396ab702ab5a20aac51c597f37d8004554 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Tue, 12 May 2026 01:56:33 +0900 Subject: [PATCH 21/28] =?UTF-8?q?refactor:=20CommitQueryService=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커밋 조회/검증 책임을 CommitReader로 분리 - 커밋 저장/삭제 책임을 CommitWriter로 분리 - CQRS QueryService와 혼동되는 기존 CommitQueryService 명명을 제거 --- .../domain/branch/app/BranchService.java | 6 ++-- .../domain/branch/merge/app/MergeService.java | 10 +++---- .../commit/app/CommitContentAssembler.java | 2 +- ...mitQueryService.java => CommitReader.java} | 18 +----------- .../domain/commit/app/CommitService.java | 11 ++++---- .../docsa/domain/commit/app/CommitWriter.java | 28 +++++++++++++++++++ .../app/create/CommitMySqlTxService.java | 6 ++-- .../docsa/domain/doc/app/DocQueryService.java | 6 ++-- .../domain/branch/app/BranchServiceTest.java | 12 ++++---- .../commit/app/CommitServiceMockTest.java | 15 ++++++---- .../domain/commit/app/GetCommitMockTest.java | 15 ++++++---- .../app/unit/DocQueryServiceUnitTests.java | 6 ++-- .../merge/app/MergeServiceMockTest.java | 18 ++++++------ 13 files changed, 86 insertions(+), 67 deletions(-) rename src/main/java/io/ejangs/docsa/domain/commit/app/{CommitQueryService.java => CommitReader.java} (78%) create mode 100644 src/main/java/io/ejangs/docsa/domain/commit/app/CommitWriter.java diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java index 236f2570..333aa6eb 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java @@ -7,7 +7,7 @@ import io.ejangs.docsa.domain.branch.dto.response.BranchRenameResponse; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.branch.util.BranchMapper; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.doc.app.DocReader; @@ -36,7 +36,7 @@ public class BranchService { private final DocReader docReader; private final BranchQueryService branchQueryService; - private final CommitQueryService commitQueryService; + private final CommitReader commitReader; private final CommitBlockSequenceRepository commitBlockSequenceRepository; private final EdgeService edgeService; private final BranchCreateOrchestrator branchCreateOrchestrator; @@ -56,7 +56,7 @@ private BranchCreateContext prepareBranchCreateContext(Long documentId, Long fromCommitId = request.fromCommitId(); - Commit fromCommit = commitQueryService.getById(fromCommitId); + Commit fromCommit = commitReader.getById(fromCommitId); Branch fromBranch = fromCommit.getBranch(); if (!fromBranch.getDoc().getId().equals(documentId)) { diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java index 7c77c484..d28b432b 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java @@ -2,7 +2,7 @@ import io.ejangs.docsa.domain.branch.app.BranchQueryService; import io.ejangs.docsa.domain.commit.app.CommitContentAssembler; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.entity.Doc; @@ -18,7 +18,7 @@ public class MergeService { private final DocReader docReader; private final BranchQueryService branchQueryService; - private final CommitQueryService commitQueryService; + private final CommitReader commitReader; private final CommitContentAssembler commitContentAssembler; private final MergeOrchestrator mergeOrchestrator; @@ -35,10 +35,10 @@ private MergeContext validateMerge(Long docId, MergeRequest mergeRequest, Long u Doc doc = docReader.getByIdAndUserId(docId, userId); branchQueryService.checkDuplicatedWithBranchName(docId, mergeRequest.branchName()); - Commit baseCommit = commitQueryService.getById(mergeRequest.baseCommitId()); - Commit targetCommit = commitQueryService.getById(mergeRequest.targetCommitId()); + Commit baseCommit = commitReader.getById(mergeRequest.baseCommitId()); + Commit targetCommit = commitReader.getById(mergeRequest.targetCommitId()); - commitQueryService.checkTwoCommitsInDocOwnedByUser( + commitReader.checkTwoCommitsInDocOwnedByUser( baseCommit.getId(), targetCommit.getId(), docId, diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitContentAssembler.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitContentAssembler.java index 00d75ec4..af0426b4 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitContentAssembler.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitContentAssembler.java @@ -17,7 +17,7 @@ @Component @RequiredArgsConstructor -//TODO: commitQueryService로 or cbsQueryService 신설(block부분은 blockService로 이동) 모르겠다. +//TODO: CBS 조회 책임 분리 검토(block부분은 blockService로 이동) public class CommitContentAssembler { private final CommitBlockSequenceRepository commitBlockSequenceRepository; diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitQueryService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitReader.java similarity index 78% rename from src/main/java/io/ejangs/docsa/domain/commit/app/CommitQueryService.java rename to src/main/java/io/ejangs/docsa/domain/commit/app/CommitReader.java index 3f0b6e88..e52a51cf 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitQueryService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitReader.java @@ -1,9 +1,7 @@ package io.ejangs.docsa.domain.commit.app; import io.ejangs.docsa.domain.branch.entity.Branch; -import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.commit.dao.mysql.CommitRepository; -import io.ejangs.docsa.domain.commit.document.CommitBlockSequence; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.edge.dto.graph.CommitGraphDto; import io.ejangs.docsa.global.exception.CustomException; @@ -16,10 +14,9 @@ @Service @RequiredArgsConstructor -public class CommitQueryService { +public class CommitReader { private final CommitRepository commitRepository; - private final CommitBlockSequenceRepository commitBlockSequenceRepository; @Transactional(readOnly = true) public Commit getById(Long commitId) { @@ -68,17 +65,4 @@ public void checkTwoCommitsInDocOwnedByUser(Long commitId1, Long commitId2, Long throw new CustomException(CommitErrorCode.COMMIT_NOT_FOUND); } } - - public Commit saveAndFlush(Commit commit) { - return commitRepository.saveAndFlush(commit); - } - - public void deleteById(Long commitId) { - commitRepository.deleteById(commitId); - } - - public CommitBlockSequence saveCommitBlockSequence(CommitBlockSequence commitBlockSequence) { - return commitBlockSequenceRepository.save(commitBlockSequence); - } - } diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java index 83deb505..565f0120 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java @@ -31,7 +31,8 @@ public class CommitService { private final DocReader docReader; private final BranchQueryService branchQueryService; - private final CommitQueryService commitQueryService; + private final CommitReader commitReader; + private final CommitWriter commitWriter; private final EdgeService edgeService; private final CommitCreateOrchestrator commitCreateOrchestrator; @@ -48,7 +49,7 @@ public CreateCommitResponse createCommit(Long docId, Doc doc = docReader.getById(docId); Branch branch = branchQueryService.getById(request.branchId()); - String baseCommitCbsMongoId = commitQueryService.resolveBaseCommitCbsMongoId(branch); + String baseCommitCbsMongoId = commitReader.resolveBaseCommitCbsMongoId(branch); Commit newCommit = commitCreateOrchestrator.create(request, baseCommitCbsMongoId, doc, branch); @@ -63,7 +64,7 @@ public CommitResponse getCommit(Long docId, Long commitId, Long userId) { } private List> getWholeContent(Long commitId) { - Commit commit = commitQueryService.getById(commitId); + Commit commit = commitReader.getById(commitId); return assembler.assemble(commit.getCommitMongoId()); } @@ -71,7 +72,7 @@ private List> getWholeContent(Long commitId) { public void deleteCommit(Long docId, Long commitId, Long userId) { Doc doc = docReader.getByIdAndUserId(docId, userId); - Commit commit = commitQueryService.getById(commitId); + Commit commit = commitReader.getById(commitId); // LeafCommit일 경우에만 삭제 가능 checkLeafCommit(commit); // 어느 브랜치의 FromCommit이나 MergeTargetCommit일 경우 삭제 불가능 @@ -97,7 +98,7 @@ public void deleteCommit(Long docId, Long commitId, Long userId) { MongoIdsDto commitDeleteMongoIds = mongoIdsCollector.collectFrom(prevCommits, commit); - commitQueryService.deleteById(commit.getId()); + commitWriter.deleteById(commit.getId()); mongoDeleteJobEnqueuer.enqueueCommitDeletion(commitId, commitDeleteMongoIds); } diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitWriter.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitWriter.java new file mode 100644 index 00000000..8366e85d --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitWriter.java @@ -0,0 +1,28 @@ +package io.ejangs.docsa.domain.commit.app; + +import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; +import io.ejangs.docsa.domain.commit.dao.mysql.CommitRepository; +import io.ejangs.docsa.domain.commit.document.CommitBlockSequence; +import io.ejangs.docsa.domain.commit.entity.Commit; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CommitWriter { + + private final CommitRepository commitRepository; + private final CommitBlockSequenceRepository commitBlockSequenceRepository; + + public Commit saveAndFlush(Commit commit) { + return commitRepository.saveAndFlush(commit); + } + + public void deleteById(Long commitId) { + commitRepository.deleteById(commitId); + } + + public CommitBlockSequence saveCommitBlockSequence(CommitBlockSequence commitBlockSequence) { + return commitBlockSequenceRepository.save(commitBlockSequence); + } +} diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java index 763d5776..e26d72bb 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/create/CommitMySqlTxService.java @@ -1,7 +1,7 @@ package io.ejangs.docsa.domain.commit.app.create; import io.ejangs.docsa.domain.branch.entity.Branch; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.commit.app.CommitWriter; import io.ejangs.docsa.domain.commit.dto.request.CreateCommitRequest; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.commit.util.CommitMapper; @@ -23,7 +23,7 @@ @RequiredArgsConstructor public class CommitMySqlTxService { - private final CommitQueryService commitQueryService; + private final CommitWriter commitWriter; private final EdgeService edgeService; private final DomainEventOutboxPublisher domainEventOutboxPublisher; @@ -31,7 +31,7 @@ public class CommitMySqlTxService { public Commit createMySqlPart(Doc doc, Branch branch, CreateCommitRequest request, String commitCbsMongoId) { Commit newCommit = CommitMapper.toEntity(branch, request); newCommit.initializeCommitMongoId(commitCbsMongoId); - newCommit = commitQueryService.saveAndFlush(newCommit); + newCommit = commitWriter.saveAndFlush(newCommit); branch.updateRootCommit(newCommit); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java index 1a75027e..3c45e076 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java @@ -1,7 +1,7 @@ package io.ejangs.docsa.domain.doc.app; import io.ejangs.docsa.domain.branch.app.BranchQueryService; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; import io.ejangs.docsa.domain.doc.readmodel.dao.mongodb.DocListReadModelRepository; @@ -31,7 +31,7 @@ public class DocQueryService { private final DocReader docReader; private final BranchQueryService branchQueryService; - private final CommitQueryService commitQueryService; + private final CommitReader commitReader; private final EdgeService edgeService; @Value("${cloud.aws.s3.public-base-url}") @@ -71,7 +71,7 @@ public GraphResponse getGraph(Long userId, Long documentId) { if (branches.isEmpty()) { throw new CustomException(BranchErrorCode.BRANCH_NOT_FOUND); } - List commits = commitQueryService.getCommitGraphList(documentId); + List commits = commitReader.getCommitGraphList(documentId); List edges = edgeService.getEdgeDtoByDocId(documentId); return GraphMapper.toCommitGraphResponse(docTitle, commits, edges, branches); diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java index d4ec242b..d3f28e38 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java @@ -6,7 +6,7 @@ import io.ejangs.docsa.domain.branch.dto.response.BranchCreateResponse; import io.ejangs.docsa.domain.branch.dto.response.BranchRenameResponse; import io.ejangs.docsa.domain.branch.entity.Branch; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; import io.ejangs.docsa.domain.commit.document.CommitBlockSequence; import io.ejangs.docsa.domain.commit.entity.Commit; @@ -41,7 +41,7 @@ class BranchServiceTest { private BranchService branchService; @Mock - private CommitQueryService commitQueryService; + private CommitReader commitReader; @Mock private DocReader docReader; @@ -78,7 +78,7 @@ void createBranch_fail_whenDifferentNameButDuplicated() { when(branch.getDoc()).thenReturn(doc); when(doc.getId()).thenReturn(documentId); - when(commitQueryService.getById(commitId)).thenReturn(commit); + when(commitReader.getById(commitId)).thenReturn(commit); doNothing().when(docReader).checkByIdAndUserId(documentId, userId); doThrow(new CustomException(BranchErrorCode.BRANCH_NAME_DUPLICATED)) .when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); @@ -108,7 +108,7 @@ void createBranch_success_whenDifferentNameIsRequested() { when(branch.getDoc()).thenReturn(doc); when(doc.getId()).thenReturn(documentId); - when(commitQueryService.getById(commitId)).thenReturn(commit); + when(commitReader.getById(commitId)).thenReturn(commit); doNothing().when(docReader).checkByIdAndUserId(documentId, userId); doNothing().when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); when(branchCreateOrchestrator.create(any())) @@ -152,7 +152,7 @@ void createBranch_success_fromIntermediateCommit() { when(commit.getBranch()).thenReturn(fromBranch); when(commit.getCommitMongoId()).thenReturn("mongo-1"); fromBranch.updateLeafCommit(commit); - when(commitQueryService.getById(commitId)).thenReturn(commit); + when(commitReader.getById(commitId)).thenReturn(commit); doNothing().when(docReader).checkByIdAndUserId(documentId, userId); doNothing().when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); when(branchCreateOrchestrator.create(any())) @@ -195,7 +195,7 @@ void createBranch_fail_whenCommitBelongsToAnotherDocument() { when(branch.getDoc()).thenReturn(commitDoc); when(commitDoc.getId()).thenReturn(999L); - when(commitQueryService.getById(commitId)).thenReturn(commit); + when(commitReader.getById(commitId)).thenReturn(commit); doNothing().when(docReader).checkByIdAndUserId(documentId, userId); // when & then diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java index 0e3f72ae..420ecb37 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java @@ -49,7 +49,10 @@ class CommitServiceMockTest { private DocReader docReader; @Mock - private CommitQueryService commitQueryService; + private CommitReader commitReader; + + @Mock + private CommitWriter commitWriter; @Mock private BranchQueryService branchQueryService; @@ -110,7 +113,7 @@ void setUp() { void createCommit_success_withLeafCommit() { when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); - when(commitQueryService.resolveBaseCommitCbsMongoId(branch)).thenReturn("leaf-cbs-id"); + when(commitReader.resolveBaseCommitCbsMongoId(branch)).thenReturn("leaf-cbs-id"); when(createdCommit.getId()).thenReturn(101L); when(commitCreateOrchestrator.create(createCommitRequest, "leaf-cbs-id", doc, branch)) .thenReturn(createdCommit); @@ -129,7 +132,7 @@ void createCommit_success_withLeafCommit() { void createCommit_success_useFromCommitWhenLeafCommitIsNull() { when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); - when(commitQueryService.resolveBaseCommitCbsMongoId(branch)).thenReturn("from-cbs-id"); + when(commitReader.resolveBaseCommitCbsMongoId(branch)).thenReturn("from-cbs-id"); when(createdCommit.getId()).thenReturn(101L); when(commitCreateOrchestrator.create(createCommitRequest, "from-cbs-id", doc, branch)) .thenReturn(createdCommit); @@ -145,7 +148,7 @@ void createCommit_success_useFromCommitWhenLeafCommitIsNull() { void createCommit_success_initialCommit_baseIsNull() { when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); - when(commitQueryService.resolveBaseCommitCbsMongoId(branch)).thenReturn(null); + when(commitReader.resolveBaseCommitCbsMongoId(branch)).thenReturn(null); when(createdCommit.getId()).thenReturn(101L); when(commitCreateOrchestrator.create(createCommitRequest, null, doc, branch)) .thenReturn(createdCommit); @@ -189,7 +192,7 @@ void createCommit_fail_whenBaseCommitLookupFails() { when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); doThrow(new RuntimeException("repo fail")) - .when(commitQueryService).resolveBaseCommitCbsMongoId(branch); + .when(commitReader).resolveBaseCommitCbsMongoId(branch); assertThatThrownBy(() -> commitService.createCommit(docId, createCommitRequest, userDetails.getId())) .isInstanceOf(RuntimeException.class) @@ -203,7 +206,7 @@ void createCommit_fail_whenBaseCommitLookupFails() { void createCommit_fail_whenOrchestratorThrows() { when(docReader.getById(docId)).thenReturn(doc); when(branchQueryService.getById(branchId)).thenReturn(branch); - when(commitQueryService.resolveBaseCommitCbsMongoId(branch)).thenReturn("base-cbs-id"); + when(commitReader.resolveBaseCommitCbsMongoId(branch)).thenReturn("base-cbs-id"); doThrow(new RuntimeException("orchestrator fail")) .when(commitCreateOrchestrator).create(createCommitRequest, "base-cbs-id", doc, branch); diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/GetCommitMockTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/GetCommitMockTest.java index 9449ac7a..2b32ffd1 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/GetCommitMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/GetCommitMockTest.java @@ -33,7 +33,10 @@ class GetCommitMockTest { @Mock - private CommitQueryService commitQueryService; + private CommitReader commitReader; + + @Mock + private CommitWriter commitWriter; @Mock private DocReader docReader; @@ -80,7 +83,7 @@ void getCommit_Success() { Long commitId = 1L; String commitMongoId = "mongo-commit-id"; - given(commitQueryService.getById(commitId)).willReturn(targetCommit); + given(commitReader.getById(commitId)).willReturn(targetCommit); given(assembler.assemble(commitMongoId)).willReturn(mockContent); // when @@ -91,7 +94,7 @@ void getCommit_Success() { assertThat(response.content()).isEqualTo(mockContent); verify(docReader).checkByIdAndUserId(docId, userDetails.getId()); - verify(commitQueryService).getById(commitId); + verify(commitReader).getById(commitId); verify(assembler).assemble(commitMongoId); } @@ -102,7 +105,7 @@ void getCommit_Commit_NotFound() { Long docId = 1L; Long commitId = 999L; - given(commitQueryService.getById(commitId)) + given(commitReader.getById(commitId)) .willThrow(new CustomException(CommitErrorCode.COMMIT_NOT_FOUND)); // when & then @@ -110,7 +113,7 @@ void getCommit_Commit_NotFound() { .isInstanceOf(CustomException.class); verify(docReader).checkByIdAndUserId(docId, userDetails.getId()); - verify(commitQueryService).getById(commitId); + verify(commitReader).getById(commitId); verify(assembler, never()).assemble(any()); } @@ -129,7 +132,7 @@ void getCommit_Doc_NotFound() { .isInstanceOf(CustomException.class); verify(docReader).checkByIdAndUserId(docId, userDetails.getId()); - verify(commitQueryService, never()).getById(any()); + verify(commitReader, never()).getById(any()); verify(assembler, never()).assemble(any()); } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java index 950919ac..7035c32f 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java @@ -5,7 +5,7 @@ import static org.mockito.Mockito.when; import io.ejangs.docsa.domain.branch.app.BranchQueryService; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.doc.app.DocQueryService; import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; @@ -52,7 +52,7 @@ class DocQueryServiceUnitTests { private BranchQueryService branchQueryService; @Mock - private CommitQueryService commitQueryService; + private CommitReader commitReader; @Mock private EdgeService edgeService; @@ -185,7 +185,7 @@ void getGraph_success() { when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); when(branchQueryService.getBranchGraphList(docId)).thenReturn(branches); - when(commitQueryService.getCommitGraphList(docId)).thenReturn(commits); + when(commitReader.getCommitGraphList(docId)).thenReturn(commits); when(edgeService.getEdgeDtoByDocId(docId)).thenReturn(edges); GraphResponse response = docQueryService.getGraph(userId, docId); diff --git a/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java b/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java index 8381e591..8c2c3152 100644 --- a/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java @@ -16,7 +16,7 @@ import io.ejangs.docsa.domain.branch.merge.app.MergeService; import io.ejangs.docsa.domain.branch.merge.app.MergeService.MergeContext; import io.ejangs.docsa.domain.commit.app.CommitContentAssembler; -import io.ejangs.docsa.domain.commit.app.CommitQueryService; +import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.commit.util.CommitMockTestUtils; import io.ejangs.docsa.domain.doc.app.DocReader; @@ -47,7 +47,7 @@ class MergeServiceMockTest { private BranchQueryService branchQueryService; @Mock - private CommitQueryService commitQueryService; + private CommitReader commitReader; @Mock private CommitContentAssembler commitContentAssembler; @@ -83,8 +83,8 @@ void merge_success_whenBaseAndTargetAreSameBranch() { when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); when(baseCommit.getId()).thenReturn(baseCommitId); when(targetCommit.getId()).thenReturn(targetCommitId); - when(commitQueryService.getById(baseCommitId)).thenReturn(baseCommit); - when(commitQueryService.getById(targetCommitId)).thenReturn(targetCommit); + when(commitReader.getById(baseCommitId)).thenReturn(baseCommit); + when(commitReader.getById(targetCommitId)).thenReturn(targetCommit); MergeResponse expected = new MergeResponse(999L, 1001L); doReturn(expected) .when(mergeOrchestrator) @@ -101,7 +101,7 @@ void merge_success_whenBaseAndTargetAreSameBranch() { assertThat(response).isEqualTo(expected); verify(branchQueryService).checkDuplicatedWithBranchName(docId, "merged-branch"); - verify(commitQueryService) + verify(commitReader) .checkTwoCommitsInDocOwnedByUser(baseCommitId, targetCommitId, docId, userId); verify(mergeOrchestrator).merge( argThat(context -> @@ -131,7 +131,7 @@ void merge_fail_whenBranchNameDuplicated() { assertThatThrownBy(() -> mergeService.merge(docId, request, userId)) .isInstanceOf(CustomException.class); - verifyNoInteractions(commitQueryService); + verifyNoInteractions(commitReader); verifyNoInteractions(mergeOrchestrator); } @@ -142,9 +142,9 @@ void merge_fail_whenBaseAndTargetAreSameCommit() { when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); when(baseCommit.getId()).thenReturn(sameCommitId); - when(commitQueryService.getById(sameCommitId)).thenReturn(baseCommit); + when(commitReader.getById(sameCommitId)).thenReturn(baseCommit); doThrow(new CustomException(CommitErrorCode.INVALID_MERGE_REQUEST)) - .when(commitQueryService) + .when(commitReader) .checkTwoCommitsInDocOwnedByUser(sameCommitId, sameCommitId, docId, userId); MergeRequest request = new MergeRequest( @@ -158,7 +158,7 @@ void merge_fail_whenBaseAndTargetAreSameCommit() { .isInstanceOf(CustomException.class) .hasMessageContaining("동일한 커밋을 병합할 수 없습니다."); - verify(commitQueryService).checkTwoCommitsInDocOwnedByUser( + verify(commitReader).checkTwoCommitsInDocOwnedByUser( sameCommitId, sameCommitId, docId, userId ); verifyNoInteractions(mergeOrchestrator); From 506fc023bb4324dd0fdc3a87dc449c76e4fa4d3d Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Tue, 12 May 2026 02:48:11 +0900 Subject: [PATCH 22/28] =?UTF-8?q?refactor:=20BranchQueryService=20?= =?UTF-8?q?=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브랜치 조회/검증 책임을 BranchReader로 분리 - 브랜치 생성/저장/삭제 책임을 BranchWriter로 분리 - CQRS QueryService와 혼동되는 기존 BranchQueryService 명명을 제거 --- ...nchQueryService.java => BranchReader.java} | 24 +------------ .../domain/branch/app/BranchService.java | 17 ++++----- .../docsa/domain/branch/app/BranchWriter.java | 35 +++++++++++++++++++ .../create/BranchCreateMySqlTxService.java | 6 ++-- .../branch/merge/app/MergeMySqlTxService.java | 8 ++--- .../domain/branch/merge/app/MergeService.java | 8 ++--- .../domain/commit/app/CommitService.java | 10 +++--- .../docsa/domain/doc/app/DocQueryService.java | 6 ++-- .../app/create/DocCreateMySqlTxService.java | 6 ++-- .../domain/branch/app/BranchServiceTest.java | 31 ++++++++-------- .../commit/app/CommitServiceMockTest.java | 22 ++++++------ .../app/unit/DocQueryServiceUnitTests.java | 8 ++--- .../merge/app/MergeServiceMockTest.java | 8 ++--- 13 files changed, 102 insertions(+), 87 deletions(-) rename src/main/java/io/ejangs/docsa/domain/branch/app/{BranchQueryService.java => BranchReader.java} (69%) create mode 100644 src/main/java/io/ejangs/docsa/domain/branch/app/BranchWriter.java diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchQueryService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchReader.java similarity index 69% rename from src/main/java/io/ejangs/docsa/domain/branch/app/BranchQueryService.java rename to src/main/java/io/ejangs/docsa/domain/branch/app/BranchReader.java index 9719c527..27191173 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchQueryService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchReader.java @@ -4,10 +4,8 @@ import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.edge.dto.graph.BranchGraphDto; -import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.BranchErrorCode; -import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -15,7 +13,7 @@ @Service @RequiredArgsConstructor -public class BranchQueryService { +public class BranchReader { private final BranchRepository branchRepository; @@ -29,31 +27,11 @@ public List getBranchGraphList(Long docId) { return branchRepository.findBranchGraphDtoList(docId); } - public Branch save(Branch branch) { - return branchRepository.save(branch); - } - - public Branch createBranch(Doc doc, String branchName) { - Branch branch = branchRepository.save(Branch.builder().name(branchName).doc(doc).build()); - RenewUpdatedAtHelper.touch(branch); - return branch; - } - - public Branch createBranch(Doc doc, String branchName, Commit fromCommit) { - Branch branch = Branch.builder().name(branchName).doc(doc).fromCommit(fromCommit).build(); - return branchRepository.save(branch); - } - @Transactional(readOnly = true) public boolean existsSubBranchByFromCommitIds(List commitIds) { return branchRepository.existsByFromCommitIdIn(commitIds); } - public void delete(Branch branch) { - branchRepository.delete(branch); - } - - //TODO: 검증하는 메소드는 별도의 ex.ValidationService로 분리(모든 도메인) public void checkBranchInDocOwnedByUser(Long documentId, Long branchId, Long userId) { boolean exists = branchRepository.existsByIdAndDocIdAndDocUserId(branchId, documentId, userId); diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java index 333aa6eb..57638eec 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchService.java @@ -35,7 +35,8 @@ public class BranchService { private final DocReader docReader; - private final BranchQueryService branchQueryService; + private final BranchReader branchReader; + private final BranchWriter branchWriter; private final CommitReader commitReader; private final CommitBlockSequenceRepository commitBlockSequenceRepository; private final EdgeService edgeService; @@ -63,7 +64,7 @@ private BranchCreateContext prepareBranchCreateContext(Long documentId, throw new CustomException(DocErrorCode.COMMIT_NOT_IN_DOCUMENT); } - branchQueryService.checkDuplicatedWithBranchName(documentId, request.name()); + branchReader.checkDuplicatedWithBranchName(documentId, request.name()); return new BranchCreateContext( @@ -80,8 +81,8 @@ public BranchRenameResponse renameBranch(Long documentId, Long branchId, String Long userId) { // 1. 브랜치 검증 - branchQueryService.checkBranchInDocOwnedByUser(documentId, branchId, userId); - Branch branch = branchQueryService.getById(branchId); + branchReader.checkBranchInDocOwnedByUser(documentId, branchId, userId); + Branch branch = branchReader.getById(branchId); checkDefaultBranch(branch); // 2. 브랜치 이름 수정 후 브랜치와 문서의 수정시각 갱신 @@ -102,8 +103,8 @@ public BranchRenameResponse renameBranch(Long documentId, Long branchId, String public void deleteBranch(Long documentId, Long branchId, Long userId) { // 1. 브랜치 검증 - branchQueryService.checkBranchInDocOwnedByUser(documentId, branchId, userId); - Branch branch = branchQueryService.getById(branchId); + branchReader.checkBranchInDocOwnedByUser(documentId, branchId, userId); + Branch branch = branchReader.getById(branchId); // 2. main브랜치는 삭제가 불가능하도록 함 checkDefaultBranch(branch); @@ -112,7 +113,7 @@ public void deleteBranch(Long documentId, Long branchId, Long userId) { List branchCommits = branch.getCommits(); List commitsIds = branchCommits.stream().map(Commit::getId).toList(); - if (branchQueryService.existsSubBranchByFromCommitIds(commitsIds)) { + if (branchReader.existsSubBranchByFromCommitIds(commitsIds)) { throw new CustomException(BranchErrorCode.SUB_BRANCH_DELETE_UNAVAILABLE); } @@ -130,7 +131,7 @@ public void deleteBranch(Long documentId, Long branchId, Long userId) { doc.getBranches().remove(branch); // 8. 브랜치, 나머지 RDB 브랜치 메타데이터 CASCADE 삭제 - branchQueryService.delete(branch); + branchWriter.delete(branch); mongoDeleteJobEnqueuer.enqueueBranchDeletion(branchId, deletableMongoIds); diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/BranchWriter.java b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchWriter.java new file mode 100644 index 00000000..875b5262 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/BranchWriter.java @@ -0,0 +1,35 @@ +package io.ejangs.docsa.domain.branch.app; + +import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; +import io.ejangs.docsa.domain.branch.entity.Branch; +import io.ejangs.docsa.domain.commit.entity.Commit; +import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.global.util.RenewUpdatedAtHelper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class BranchWriter { + + private final BranchRepository branchRepository; + + public Branch save(Branch branch) { + return branchRepository.save(branch); + } + + public Branch createBranch(Doc doc, String branchName) { + Branch branch = branchRepository.save(Branch.builder().name(branchName).doc(doc).build()); + RenewUpdatedAtHelper.touch(branch); + return branch; + } + + public Branch createBranch(Doc doc, String branchName, Commit fromCommit) { + Branch branch = Branch.builder().name(branchName).doc(doc).fromCommit(fromCommit).build(); + return branchRepository.save(branch); + } + + public void delete(Branch branch) { + branchRepository.delete(branch); + } +} diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java index 0a639189..8d70d981 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java @@ -1,6 +1,6 @@ package io.ejangs.docsa.domain.branch.app.create; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.branch.app.BranchWriter; import io.ejangs.docsa.domain.branch.dto.BranchCreateContext; import io.ejangs.docsa.domain.branch.dto.response.BranchCreateResponse; import io.ejangs.docsa.domain.branch.entity.Branch; @@ -20,14 +20,14 @@ @RequiredArgsConstructor public class BranchCreateMySqlTxService { - private final BranchQueryService branchQueryService; + private final BranchWriter branchWriter; private final SaveQueryService saveQueryService; private final DomainEventOutboxPublisher domainEventOutboxPublisher; @Transactional(rollbackFor = Exception.class) public BranchCreateResponse createMySqlPart(BranchCreateContext context, String saveContentId) { - Branch newBranch = branchQueryService.createBranch(context.doc(), context.branchName(), context.fromCommit()); + Branch newBranch = branchWriter.createBranch(context.doc(), context.branchName(), context.fromCommit()); Save save = saveQueryService.createSave(newBranch, saveContentId); RenewUpdatedAtHelper.touch(save); diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java index 26e1af9a..997b2661 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java @@ -1,6 +1,6 @@ package io.ejangs.docsa.domain.branch.merge.app; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.branch.app.BranchWriter; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.branch.merge.app.MergeService.MergeContext; import io.ejangs.docsa.domain.branch.merge.dto.request.MergeRequest; @@ -22,17 +22,17 @@ public class MergeMySqlTxService { private final SaveQueryService saveQueryService; - private final BranchQueryService branchQueryService; + private final BranchWriter branchWriter; private final DomainEventOutboxPublisher domainEventOutboxPublisher; public MergeResponse createMySqlPart(MergeContext context, MergeRequest request, String saveMongoId) { - Branch newBranch = branchQueryService.createBranch(context.doc(), request.branchName(), context.baseCommit()); + Branch newBranch = branchWriter.createBranch(context.doc(), request.branchName(), context.baseCommit()); newBranch.updateMergeTargetCommit(context.targetCommit()); Save save = saveQueryService.createSave(newBranch, saveMongoId); newBranch.setSave(save); - branchQueryService.save(newBranch); + branchWriter.save(newBranch); RenewUpdatedAtHelper.touch(save); domainEventOutboxPublisher.publish(DomainEventType.DOC_ACTIVITY_CHANGED, AggregateType.DOC, context.doc() diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java index d28b432b..9a2c7250 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeService.java @@ -1,7 +1,6 @@ package io.ejangs.docsa.domain.branch.merge.app; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; -import io.ejangs.docsa.domain.commit.app.CommitContentAssembler; +import io.ejangs.docsa.domain.branch.app.BranchReader; import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.commit.entity.Commit; import io.ejangs.docsa.domain.doc.app.DocReader; @@ -17,9 +16,8 @@ public class MergeService { private final DocReader docReader; - private final BranchQueryService branchQueryService; + private final BranchReader branchReader; private final CommitReader commitReader; - private final CommitContentAssembler commitContentAssembler; private final MergeOrchestrator mergeOrchestrator; @Transactional(rollbackFor = Exception.class) @@ -33,7 +31,7 @@ public MergeResponse merge(Long docId, MergeRequest mergeRequest, Long userId) { private MergeContext validateMerge(Long docId, MergeRequest mergeRequest, Long userId) { Doc doc = docReader.getByIdAndUserId(docId, userId); - branchQueryService.checkDuplicatedWithBranchName(docId, mergeRequest.branchName()); + branchReader.checkDuplicatedWithBranchName(docId, mergeRequest.branchName()); Commit baseCommit = commitReader.getById(mergeRequest.baseCommitId()); Commit targetCommit = commitReader.getById(mergeRequest.targetCommitId()); diff --git a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java index 565f0120..12fe6246 100644 --- a/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java +++ b/src/main/java/io/ejangs/docsa/domain/commit/app/CommitService.java @@ -1,6 +1,6 @@ package io.ejangs.docsa.domain.commit.app; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.branch.app.BranchReader; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.commit.app.create.CommitCreateOrchestrator; import io.ejangs.docsa.domain.commit.dto.request.CreateCommitRequest; @@ -30,7 +30,7 @@ public class CommitService { private final DocReader docReader; - private final BranchQueryService branchQueryService; + private final BranchReader branchReader; private final CommitReader commitReader; private final CommitWriter commitWriter; private final EdgeService edgeService; @@ -44,10 +44,10 @@ public CreateCommitResponse createCommit(Long docId, CreateCommitRequest request, Long userId) { - branchQueryService.checkBranchInDocOwnedByUser(docId, request.branchId(), userId); + branchReader.checkBranchInDocOwnedByUser(docId, request.branchId(), userId); Doc doc = docReader.getById(docId); - Branch branch = branchQueryService.getById(request.branchId()); + Branch branch = branchReader.getById(request.branchId()); String baseCommitCbsMongoId = commitReader.resolveBaseCommitCbsMongoId(branch); @@ -104,7 +104,7 @@ public void deleteCommit(Long docId, Long commitId, Long userId) { } private void checkFromOrMergeTargetCommit(Commit commit) { - if (branchQueryService.checkFromCommitOrMergeCommitInBranch(commit)) { + if (branchReader.checkFromCommitOrMergeCommitInBranch(commit)) { throw new CustomException(CommitErrorCode.CAN_NOT_DELETE_COMMIT); } } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java index 3c45e076..f2fdff67 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/DocQueryService.java @@ -1,6 +1,6 @@ package io.ejangs.docsa.domain.doc.app; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.branch.app.BranchReader; import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.doc.dto.response.DocPageResponse; import io.ejangs.docsa.domain.doc.dto.response.DocSimplePageResponse; @@ -30,7 +30,7 @@ public class DocQueryService { private final DocListReadModelRepository docListReadModelRepository; private final DocReader docReader; - private final BranchQueryService branchQueryService; + private final BranchReader branchReader; private final CommitReader commitReader; private final EdgeService edgeService; @@ -67,7 +67,7 @@ public GraphResponse getGraph(Long userId, Long documentId) { String docTitle = docReader.getByIdAndUserId(documentId, userId).getTitle(); // Branch, Commit, Edge 각각 별도 조회 (Projection 쿼리) - List branches = branchQueryService.getBranchGraphList(documentId); + List branches = branchReader.getBranchGraphList(documentId); if (branches.isEmpty()) { throw new CustomException(BranchErrorCode.BRANCH_NOT_FOUND); } diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java index ea230399..3c26fbc0 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java @@ -1,6 +1,6 @@ package io.ejangs.docsa.domain.doc.app.create; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.branch.app.BranchWriter; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.doc.app.DocReader; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; @@ -26,7 +26,7 @@ public class DocCreateMySqlTxService { private final DocReader docReader; - private final BranchQueryService branchQueryService; + private final BranchWriter branchWriter; private final SaveQueryService saveQueryService; private final ThumbnailRepository thumbnailRepository; @@ -40,7 +40,7 @@ public DocCreateResponse createMySqlPart(String title, User user, String saveContentId) { Doc doc = docReader.create(user, title); - Branch defaultBranch = branchQueryService.createBranch(doc, defaultBranchName); + Branch defaultBranch = branchWriter.createBranch(doc, defaultBranchName); Save defaultSave = saveQueryService.createSave(defaultBranch, saveContentId); RenewUpdatedAtHelper.touch(defaultSave); thumbnailRepository.save(Thumbnail.builder() diff --git a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java index d3f28e38..9a76c586 100644 --- a/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java +++ b/src/test/java/io/ejangs/docsa/domain/branch/app/BranchServiceTest.java @@ -53,7 +53,10 @@ class BranchServiceTest { private CommitBlockSequenceRepository commitBlockSequenceRepository; @Mock - private BranchQueryService branchQueryService; + private BranchReader branchReader; + + @Mock + private BranchWriter branchWriter; @Mock private BranchCreateOrchestrator branchCreateOrchestrator; @@ -81,7 +84,7 @@ void createBranch_fail_whenDifferentNameButDuplicated() { when(commitReader.getById(commitId)).thenReturn(commit); doNothing().when(docReader).checkByIdAndUserId(documentId, userId); doThrow(new CustomException(BranchErrorCode.BRANCH_NAME_DUPLICATED)) - .when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); + .when(branchReader).checkDuplicatedWithBranchName(documentId, "new-branch"); // when & then CustomException ex = assertThrows(CustomException.class, @@ -110,7 +113,7 @@ void createBranch_success_whenDifferentNameIsRequested() { when(commitReader.getById(commitId)).thenReturn(commit); doNothing().when(docReader).checkByIdAndUserId(documentId, userId); - doNothing().when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); + doNothing().when(branchReader).checkDuplicatedWithBranchName(documentId, "new-branch"); when(branchCreateOrchestrator.create(any())) .thenReturn(new BranchCreateResponse(101L, 201L)); @@ -154,7 +157,7 @@ void createBranch_success_fromIntermediateCommit() { fromBranch.updateLeafCommit(commit); when(commitReader.getById(commitId)).thenReturn(commit); doNothing().when(docReader).checkByIdAndUserId(documentId, userId); - doNothing().when(branchQueryService).checkDuplicatedWithBranchName(documentId, "new-branch"); + doNothing().when(branchReader).checkDuplicatedWithBranchName(documentId, "new-branch"); when(branchCreateOrchestrator.create(any())) .thenReturn(new BranchCreateResponse(100L, 200L)); @@ -202,7 +205,7 @@ void createBranch_fail_whenCommitBelongsToAnotherDocument() { CustomException ex = assertThrows(CustomException.class, () -> branchService.createBranch(documentId, request, userId)); assertEquals(DocErrorCode.COMMIT_NOT_IN_DOCUMENT, ex.getErrorCode()); - verify(branchQueryService, never()).checkDuplicatedWithBranchName(anyLong(), anyString()); + verify(branchReader, never()).checkDuplicatedWithBranchName(anyLong(), anyString()); verifyNoInteractions(branchCreateOrchestrator); } @@ -217,8 +220,8 @@ void renameBranch_success() { Branch branch = Branch.builder().name("기존이름").doc(mock(Doc.class)).fromCommit(commit).build(); - doNothing().when(branchQueryService).checkBranchInDocOwnedByUser(docId, branchId, userId); - when(branchQueryService.getById(branchId)).thenReturn(branch); + doNothing().when(branchReader).checkBranchInDocOwnedByUser(docId, branchId, userId); + when(branchReader.getById(branchId)).thenReturn(branch); BranchRenameResponse response = branchService.renameBranch(docId, branchId, newName, userId); @@ -230,7 +233,7 @@ void renameBranch_success() { @DisplayName("브랜치가 문서에 없거나 유저 소유가 아니면 예외 발생") void renameBranch_branchOwnershipCheckFailed() { doThrow(new CustomException(BranchErrorCode.BRANCH_NOT_FOUND)) - .when(branchQueryService).checkBranchInDocOwnedByUser(anyLong(), anyLong(), + .when(branchReader).checkBranchInDocOwnedByUser(anyLong(), anyLong(), anyLong()); CustomException e = assertThrows(CustomException.class, @@ -262,10 +265,10 @@ void deleteBranch_success() { CommitBlockSequence seq2 = CommitBlockSequence.builder().blockOrders(List.of("block3")).build(); - doNothing().when(branchQueryService) + doNothing().when(branchReader) .checkBranchInDocOwnedByUser(documentId, branchId, userId); - when(branchQueryService.getById(branchId)).thenReturn(branch); - when(branchQueryService.existsSubBranchByFromCommitIds(any())).thenReturn(false); + when(branchReader.getById(branchId)).thenReturn(branch); + when(branchReader.existsSubBranchByFromCommitIds(any())).thenReturn(false); doNothing().when(edgeService).deleteEdgesConnectedToCommits(any()); when(commitBlockSequenceRepository.findById("seq1")).thenReturn(Optional.of(seq1)); @@ -275,7 +278,7 @@ void deleteBranch_success() { branchService.deleteBranch(documentId, branchId, userId); // then - 브랜치 실제 삭제 - verify(branchQueryService).delete(branch); + verify(branchWriter).delete(branch); // Outbox 적재 검증 ArgumentCaptor captor = ArgumentCaptor.forClass(MongoIdsDto.class); @@ -299,8 +302,8 @@ void deleteBranch_mainBranch_fail() { Long userId = 3L; Branch mainBranch = Branch.builder().name("main").doc(mock(Doc.class)).build(); - doNothing().when(branchQueryService).checkBranchInDocOwnedByUser(docId, branchId, userId); - when(branchQueryService.getById(branchId)).thenReturn(mainBranch); + doNothing().when(branchReader).checkBranchInDocOwnedByUser(docId, branchId, userId); + when(branchReader.getById(branchId)).thenReturn(mainBranch); CustomException ex = assertThrows(CustomException.class, () -> branchService.deleteBranch(docId, branchId, userId)); diff --git a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java index 420ecb37..27e12260 100644 --- a/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/commit/app/CommitServiceMockTest.java @@ -9,7 +9,7 @@ import static org.mockito.Mockito.when; import io.ejangs.docsa.domain.block.app.BlockService; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.branch.app.BranchReader; import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.commit.app.create.CommitCreateOrchestrator; import io.ejangs.docsa.domain.commit.dao.mongodb.CommitBlockSequenceRepository; @@ -55,7 +55,7 @@ class CommitServiceMockTest { private CommitWriter commitWriter; @Mock - private BranchQueryService branchQueryService; + private BranchReader branchReader; @Mock private CommitCreateOrchestrator commitCreateOrchestrator; @@ -112,7 +112,7 @@ void setUp() { @DisplayName("커밋 생성 성공 - leafCommit 기반으로 오케스트레이터 호출") void createCommit_success_withLeafCommit() { when(docReader.getById(docId)).thenReturn(doc); - when(branchQueryService.getById(branchId)).thenReturn(branch); + when(branchReader.getById(branchId)).thenReturn(branch); when(commitReader.resolveBaseCommitCbsMongoId(branch)).thenReturn("leaf-cbs-id"); when(createdCommit.getId()).thenReturn(101L); when(commitCreateOrchestrator.create(createCommitRequest, "leaf-cbs-id", doc, branch)) @@ -121,7 +121,7 @@ void createCommit_success_withLeafCommit() { var result = commitService.createCommit(docId, createCommitRequest, userDetails.getId()); assertThat(result.id()).isEqualTo(101L); - verify(branchQueryService).checkBranchInDocOwnedByUser(docId, branchId, userDetails.getId()); + verify(branchReader).checkBranchInDocOwnedByUser(docId, branchId, userDetails.getId()); verify(commitCreateOrchestrator).create(createCommitRequest, "leaf-cbs-id", doc, branch); verifyNoInteractions(cbsRepository, blockService, saveService, edgeService, commitRepository); @@ -131,7 +131,7 @@ void createCommit_success_withLeafCommit() { @DisplayName("커밋 생성 성공 - leafCommit이 null이면 fromCommit을 base로 사용") void createCommit_success_useFromCommitWhenLeafCommitIsNull() { when(docReader.getById(docId)).thenReturn(doc); - when(branchQueryService.getById(branchId)).thenReturn(branch); + when(branchReader.getById(branchId)).thenReturn(branch); when(commitReader.resolveBaseCommitCbsMongoId(branch)).thenReturn("from-cbs-id"); when(createdCommit.getId()).thenReturn(101L); when(commitCreateOrchestrator.create(createCommitRequest, "from-cbs-id", doc, branch)) @@ -147,7 +147,7 @@ void createCommit_success_useFromCommitWhenLeafCommitIsNull() { @DisplayName("커밋 생성 성공 - 최초 커밋이면 baseCommitCbsMongoId는 null") void createCommit_success_initialCommit_baseIsNull() { when(docReader.getById(docId)).thenReturn(doc); - when(branchQueryService.getById(branchId)).thenReturn(branch); + when(branchReader.getById(branchId)).thenReturn(branch); when(commitReader.resolveBaseCommitCbsMongoId(branch)).thenReturn(null); when(createdCommit.getId()).thenReturn(101L); when(commitCreateOrchestrator.create(createCommitRequest, null, doc, branch)) @@ -163,13 +163,13 @@ void createCommit_success_initialCommit_baseIsNull() { @DisplayName("커밋 생성 실패 - 브랜치 권한 검증 실패 시 오케스트레이터 미호출") void createCommit_fail_branchOwnershipCheck() { doThrow(new CustomException(BlockSequenceErrorCode.BLOCK_SEQUENCE_NOT_FOUND)) - .when(branchQueryService).checkBranchInDocOwnedByUser(docId, branchId, userDetails.getId()); + .when(branchReader).checkBranchInDocOwnedByUser(docId, branchId, userDetails.getId()); assertThatThrownBy(() -> commitService.createCommit(docId, createCommitRequest, userDetails.getId())) .isInstanceOf(CustomException.class); verify(docReader, never()).getById(docId); - verify(branchQueryService, never()).getById(branchId); + verify(branchReader, never()).getById(branchId); verifyNoInteractions(commitCreateOrchestrator); } @@ -178,7 +178,7 @@ void createCommit_fail_branchOwnershipCheck() { void createCommit_fail_whenBranchLookupFails() { when(docReader.getById(docId)).thenReturn(doc); doThrow(new CustomException(BlockSequenceErrorCode.BLOCK_SEQUENCE_NOT_FOUND)) - .when(branchQueryService).getById(branchId); + .when(branchReader).getById(branchId); assertThatThrownBy(() -> commitService.createCommit(docId, createCommitRequest, userDetails.getId())) .isInstanceOf(CustomException.class); @@ -190,7 +190,7 @@ void createCommit_fail_whenBranchLookupFails() { @DisplayName("커밋 생성 실패 - base commit 조회 중 repository 예외 전파") void createCommit_fail_whenBaseCommitLookupFails() { when(docReader.getById(docId)).thenReturn(doc); - when(branchQueryService.getById(branchId)).thenReturn(branch); + when(branchReader.getById(branchId)).thenReturn(branch); doThrow(new RuntimeException("repo fail")) .when(commitReader).resolveBaseCommitCbsMongoId(branch); @@ -205,7 +205,7 @@ void createCommit_fail_whenBaseCommitLookupFails() { @DisplayName("커밋 생성 실패 - 오케스트레이터 예외는 그대로 전파") void createCommit_fail_whenOrchestratorThrows() { when(docReader.getById(docId)).thenReturn(doc); - when(branchQueryService.getById(branchId)).thenReturn(branch); + when(branchReader.getById(branchId)).thenReturn(branch); when(commitReader.resolveBaseCommitCbsMongoId(branch)).thenReturn("base-cbs-id"); doThrow(new RuntimeException("orchestrator fail")) .when(commitCreateOrchestrator).create(createCommitRequest, "base-cbs-id", doc, branch); diff --git a/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java index 7035c32f..415da2d4 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocQueryServiceUnitTests.java @@ -4,7 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.when; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.branch.app.BranchReader; import io.ejangs.docsa.domain.commit.app.CommitReader; import io.ejangs.docsa.domain.doc.app.DocQueryService; import io.ejangs.docsa.domain.doc.app.DocReader; @@ -49,7 +49,7 @@ class DocQueryServiceUnitTests { private DocReader docReader; @Mock - private BranchQueryService branchQueryService; + private BranchReader branchReader; @Mock private CommitReader commitReader; @@ -184,7 +184,7 @@ void getGraph_success() { List edges = List.of(new EdgeDto(100L, 101L)); when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); - when(branchQueryService.getBranchGraphList(docId)).thenReturn(branches); + when(branchReader.getBranchGraphList(docId)).thenReturn(branches); when(commitReader.getCommitGraphList(docId)).thenReturn(commits); when(edgeService.getEdgeDtoByDocId(docId)).thenReturn(edges); @@ -205,7 +205,7 @@ void getGraph_fail_branchNotFound() { Doc doc = Doc.builder().title("문서").user(user).build(); when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); - when(branchQueryService.getBranchGraphList(docId)).thenReturn(List.of()); + when(branchReader.getBranchGraphList(docId)).thenReturn(List.of()); assertThatThrownBy(() -> docQueryService.getGraph(userId, docId)) .isInstanceOf(CustomException.class) diff --git a/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java b/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java index 8c2c3152..0083d0be 100644 --- a/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java +++ b/src/test/java/io/ejangs/docsa/domain/merge/app/MergeServiceMockTest.java @@ -11,7 +11,7 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import io.ejangs.docsa.domain.branch.app.BranchQueryService; +import io.ejangs.docsa.domain.branch.app.BranchReader; import io.ejangs.docsa.domain.branch.merge.app.MergeOrchestrator; import io.ejangs.docsa.domain.branch.merge.app.MergeService; import io.ejangs.docsa.domain.branch.merge.app.MergeService.MergeContext; @@ -44,7 +44,7 @@ class MergeServiceMockTest { private DocReader docReader; @Mock - private BranchQueryService branchQueryService; + private BranchReader branchReader; @Mock private CommitReader commitReader; @@ -100,7 +100,7 @@ void merge_success_whenBaseAndTargetAreSameBranch() { var response = mergeService.merge(docId, request, userId); assertThat(response).isEqualTo(expected); - verify(branchQueryService).checkDuplicatedWithBranchName(docId, "merged-branch"); + verify(branchReader).checkDuplicatedWithBranchName(docId, "merged-branch"); verify(commitReader) .checkTwoCommitsInDocOwnedByUser(baseCommitId, targetCommitId, docId, userId); verify(mergeOrchestrator).merge( @@ -119,7 +119,7 @@ void merge_fail_whenBranchNameDuplicated() { Long targetCommitId = 32L; when(docReader.getByIdAndUserId(docId, userId)).thenReturn(doc); doThrow(new CustomException(BranchErrorCode.BRANCH_NAME_DUPLICATED)) - .when(branchQueryService).checkDuplicatedWithBranchName(docId, "merged-branch"); + .when(branchReader).checkDuplicatedWithBranchName(docId, "merged-branch"); MergeRequest request = new MergeRequest( "merged-branch", From 2a02f97be8da33b0745f417e55ad60981af4ff04 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Tue, 12 May 2026 03:12:57 +0900 Subject: [PATCH 23/28] =?UTF-8?q?refactor:=20SaveQueryService=20=EC=B1=85?= =?UTF-8?q?=EC=9E=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장 조회/검증 책임을 SaveReader로 분리 - 저장 생성/갱신 책임을 SaveWriter로 분리 - CQRS QueryService와 혼동되는 기존 SaveQueryService 명명을 제거 --- .../create/BranchCreateMySqlTxService.java | 6 +-- .../branch/merge/app/MergeMongoTxService.java | 6 +-- .../branch/merge/app/MergeMySqlTxService.java | 6 +-- .../app/create/DocCreateMySqlTxService.java | 6 +-- .../doc/app/create/DocCreateOrchestrator.java | 6 +-- .../docsa/domain/save/app/SaveReader.java | 35 ++++++++++++++++ .../docsa/domain/save/app/SaveService.java | 15 +++---- ...{SaveQueryService.java => SaveWriter.java} | 21 +--------- .../app/unit/DocCreateOrchestratorTest.java | 8 ++-- .../domain/save/app/SaveServiceUnitTest.java | 40 ++++++++++--------- 10 files changed, 84 insertions(+), 65 deletions(-) create mode 100644 src/main/java/io/ejangs/docsa/domain/save/app/SaveReader.java rename src/main/java/io/ejangs/docsa/domain/save/app/{SaveQueryService.java => SaveWriter.java} (66%) diff --git a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java index 8d70d981..095a9dbb 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/app/create/BranchCreateMySqlTxService.java @@ -6,7 +6,7 @@ import io.ejangs.docsa.domain.branch.entity.Branch; import io.ejangs.docsa.domain.branch.util.BranchMapper; import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; -import io.ejangs.docsa.domain.save.app.SaveQueryService; +import io.ejangs.docsa.domain.save.app.SaveWriter; import io.ejangs.docsa.domain.save.entity.Save; import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; import io.ejangs.docsa.global.outbox.event.model.AggregateType; @@ -21,7 +21,7 @@ public class BranchCreateMySqlTxService { private final BranchWriter branchWriter; - private final SaveQueryService saveQueryService; + private final SaveWriter saveWriter; private final DomainEventOutboxPublisher domainEventOutboxPublisher; @Transactional(rollbackFor = Exception.class) @@ -29,7 +29,7 @@ public BranchCreateResponse createMySqlPart(BranchCreateContext context, String Branch newBranch = branchWriter.createBranch(context.doc(), context.branchName(), context.fromCommit()); - Save save = saveQueryService.createSave(newBranch, saveContentId); + Save save = saveWriter.createSave(newBranch, saveContentId); RenewUpdatedAtHelper.touch(save); domainEventOutboxPublisher.publish(DomainEventType.DOC_ACTIVITY_CHANGED, AggregateType.DOC, context.doc() diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMongoTxService.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMongoTxService.java index c74678d2..45a6f227 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMongoTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMongoTxService.java @@ -1,6 +1,6 @@ package io.ejangs.docsa.domain.branch.merge.app; -import io.ejangs.docsa.domain.save.app.SaveQueryService; +import io.ejangs.docsa.domain.save.app.SaveWriter; import io.ejangs.docsa.domain.save.document.SaveContent; import java.util.List; import java.util.Map; @@ -12,7 +12,7 @@ @RequiredArgsConstructor public class MergeMongoTxService { - private final SaveQueryService saveQueryService; + private final SaveWriter saveWriter; @Transactional(transactionManager = "mongoTransactionManager", rollbackFor = Exception.class) public String createMongoPart(List> content) { @@ -20,7 +20,7 @@ public String createMongoPart(List> content) { .content(content) .build(); - SaveContent saved = saveQueryService.saveSaveContent(saveContent); + SaveContent saved = saveWriter.saveSaveContent(saveContent); return saved.getId(); } } diff --git a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java index 997b2661..9651c700 100644 --- a/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/branch/merge/app/MergeMySqlTxService.java @@ -6,7 +6,7 @@ import io.ejangs.docsa.domain.branch.merge.dto.request.MergeRequest; import io.ejangs.docsa.domain.branch.merge.dto.response.MergeResponse; import io.ejangs.docsa.domain.doc.readmodel.util.DocPayloadFactory; -import io.ejangs.docsa.domain.save.app.SaveQueryService; +import io.ejangs.docsa.domain.save.app.SaveWriter; import io.ejangs.docsa.domain.save.entity.Save; import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; import io.ejangs.docsa.global.outbox.event.model.AggregateType; @@ -21,7 +21,7 @@ @RequiredArgsConstructor public class MergeMySqlTxService { - private final SaveQueryService saveQueryService; + private final SaveWriter saveWriter; private final BranchWriter branchWriter; private final DomainEventOutboxPublisher domainEventOutboxPublisher; @@ -30,7 +30,7 @@ public MergeResponse createMySqlPart(MergeContext context, MergeRequest request, Branch newBranch = branchWriter.createBranch(context.doc(), request.branchName(), context.baseCommit()); newBranch.updateMergeTargetCommit(context.targetCommit()); - Save save = saveQueryService.createSave(newBranch, saveMongoId); + Save save = saveWriter.createSave(newBranch, saveMongoId); newBranch.setSave(save); branchWriter.save(newBranch); RenewUpdatedAtHelper.touch(save); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java index 3c26fbc0..a1f84141 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateMySqlTxService.java @@ -9,7 +9,7 @@ import io.ejangs.docsa.domain.doc.thumbnail.dao.ThumbnailRepository; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; import io.ejangs.docsa.domain.doc.util.DocMapper; -import io.ejangs.docsa.domain.save.app.SaveQueryService; +import io.ejangs.docsa.domain.save.app.SaveWriter; import io.ejangs.docsa.domain.save.entity.Save; import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; @@ -27,7 +27,7 @@ public class DocCreateMySqlTxService { private final DocReader docReader; private final BranchWriter branchWriter; - private final SaveQueryService saveQueryService; + private final SaveWriter saveWriter; private final ThumbnailRepository thumbnailRepository; private final DomainEventOutboxPublisher domainEventOutboxPublisher; @@ -41,7 +41,7 @@ public DocCreateResponse createMySqlPart(String title, User user, Doc doc = docReader.create(user, title); Branch defaultBranch = branchWriter.createBranch(doc, defaultBranchName); - Save defaultSave = saveQueryService.createSave(defaultBranch, saveContentId); + Save defaultSave = saveWriter.createSave(defaultBranch, saveContentId); RenewUpdatedAtHelper.touch(defaultSave); thumbnailRepository.save(Thumbnail.builder() .doc(doc) diff --git a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java index 26063e7d..e90dd80d 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/app/create/DocCreateOrchestrator.java @@ -1,7 +1,7 @@ package io.ejangs.docsa.domain.doc.app.create; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; -import io.ejangs.docsa.domain.save.app.SaveQueryService; +import io.ejangs.docsa.domain.save.app.SaveWriter; import io.ejangs.docsa.domain.save.document.SaveContent; import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.exception.CustomException; @@ -18,14 +18,14 @@ @RequiredArgsConstructor public class DocCreateOrchestrator { - private final SaveQueryService saveQueryService; + private final SaveWriter saveWriter; private final DocCreateMySqlTxService docCreateMySqlTxService; private final MongoDeleteJobEnqueuer mongoDeleteJobEnqueuer; public DocCreateResponse create(String title, User user) { // 문서 생성에 경우 SaveContent 1개의 문서만 insert하여 단일 문서 트랜잭션은 보장되어 별도의 트랜잭션 처리 필요없음. // 다른 도메인에서는 다중 문서 트랜잭션을 위해 트랜잭션 설정 필요. - SaveContent defaultSaveContent = saveQueryService.createSaveContent(); + SaveContent defaultSaveContent = saveWriter.createSaveContent(); String saveContentId = defaultSaveContent.getId(); try { diff --git a/src/main/java/io/ejangs/docsa/domain/save/app/SaveReader.java b/src/main/java/io/ejangs/docsa/domain/save/app/SaveReader.java new file mode 100644 index 00000000..21edaa3f --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/save/app/SaveReader.java @@ -0,0 +1,35 @@ +package io.ejangs.docsa.domain.save.app; + +import io.ejangs.docsa.domain.save.dao.mongodb.SaveContentRepository; +import io.ejangs.docsa.domain.save.dao.mysql.SaveRepository; +import io.ejangs.docsa.domain.save.document.SaveContent; +import io.ejangs.docsa.domain.save.entity.Save; +import io.ejangs.docsa.global.exception.CustomException; +import io.ejangs.docsa.global.exception.errorcode.SaveErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SaveReader { + + private final SaveContentRepository saveContentRepository; + private final SaveRepository saveRepository; + + public Save getSaveById(Long id) { + return saveRepository.findById(id) + .orElseThrow(() -> new CustomException(SaveErrorCode.SAVE_NOT_FOUND)); + + } + + public SaveContent getSaveContentById(String id) { + return saveContentRepository.findById(id) + .orElseThrow(() -> new CustomException(SaveErrorCode.SAVE_NOT_FOUND)); + } + + public void checkSaveAndDocOwner(Save save, Long userId, Long documentId) { + if (!saveRepository.validateSaveOwnership(save.getId(), documentId, userId)) { + throw new CustomException(SaveErrorCode.SAVE_NOT_OWNER); + } + } +} diff --git a/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java b/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java index 87ff4ad8..99341d3b 100644 --- a/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java +++ b/src/main/java/io/ejangs/docsa/domain/save/app/SaveService.java @@ -29,7 +29,8 @@ @RequiredArgsConstructor public class SaveService { - private final SaveQueryService saveQueryService; + private final SaveReader saveReader; + private final SaveWriter saveWriter; private final ThumbnailService thumbnailService; private final DomainEventOutboxPublisher domainEventOutboxPublisher; @@ -38,7 +39,7 @@ public class SaveService { public SaveGetResponse getSave(SaveIdentifierDto dto) { Save findSave = getValidSave(dto); - SaveContent saveContent = saveQueryService.getSaveContentById(findSave.getSaveMongoId()); + SaveContent saveContent = saveReader.getSaveContentById(findSave.getSaveMongoId()); return SaveMapper.toSaveGetResponse(findSave.getUpdatedAt(), saveContent.getContent()); } @@ -48,7 +49,7 @@ public SaveUpdateResponse updateSave(SaveIdentifierDto dto, SaveUpdateRequest re // MySQL 먼저 저장 RenewUpdatedAtHelper.touch(findSave); - saveQueryService.saveSave(findSave); + saveWriter.saveSave(findSave); ThumbnailSyncResponse thumbnailSyncResponse = thumbnailService.requestUpdate( dto.userId(), @@ -57,10 +58,10 @@ public SaveUpdateResponse updateSave(SaveIdentifierDto dto, SaveUpdateRequest re // MongoDB 저장 try { - SaveContent saveContent = saveQueryService.getSaveContentById( + SaveContent saveContent = saveReader.getSaveContentById( findSave.getSaveMongoId()); saveContent.updateContent(request.content()); - saveQueryService.saveSaveContent(saveContent); + saveWriter.saveSaveContent(saveContent); } catch (DuplicateKeyException e) { log.warn("중복 키로 Mongo 저장 실패 - saveId={}, mongoId={}, message={}", findSave.getId(), findSave.getSaveMongoId(), e.getMessage()); @@ -78,9 +79,9 @@ public SaveUpdateResponse updateSave(SaveIdentifierDto dto, SaveUpdateRequest re private Save getValidSave(SaveIdentifierDto dto) { - Save findSave = saveQueryService.getSaveById(dto.saveId()); + Save findSave = saveReader.getSaveById(dto.saveId()); - saveQueryService.checkSaveAndDocOwner(findSave, dto.userId(), dto.documentId()); + saveReader.checkSaveAndDocOwner(findSave, dto.userId(), dto.documentId()); return findSave; } diff --git a/src/main/java/io/ejangs/docsa/domain/save/app/SaveQueryService.java b/src/main/java/io/ejangs/docsa/domain/save/app/SaveWriter.java similarity index 66% rename from src/main/java/io/ejangs/docsa/domain/save/app/SaveQueryService.java rename to src/main/java/io/ejangs/docsa/domain/save/app/SaveWriter.java index bd44759f..040bc77a 100644 --- a/src/main/java/io/ejangs/docsa/domain/save/app/SaveQueryService.java +++ b/src/main/java/io/ejangs/docsa/domain/save/app/SaveWriter.java @@ -7,7 +7,6 @@ import io.ejangs.docsa.domain.save.entity.Save; import io.ejangs.docsa.global.exception.CustomException; import io.ejangs.docsa.global.exception.errorcode.DatabaseErrorCode; -import io.ejangs.docsa.global.exception.errorcode.SaveErrorCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -15,7 +14,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class SaveQueryService { +public class SaveWriter { private final SaveContentRepository saveContentRepository; private final SaveRepository saveRepository; @@ -40,22 +39,4 @@ public Save saveSave(Save save) { public SaveContent saveSaveContent(SaveContent saveContent) { return saveContentRepository.save(saveContent); } - - public Save getSaveById(Long id) { - return saveRepository.findById(id) - .orElseThrow(() -> new CustomException(SaveErrorCode.SAVE_NOT_FOUND)); - - } - - public SaveContent getSaveContentById(String id) { - return saveContentRepository.findById(id) - .orElseThrow(() -> new CustomException(SaveErrorCode.SAVE_NOT_FOUND)); - } - - public void checkSaveAndDocOwner(Save save, Long userId, Long documentId) { - if (!saveRepository.validateSaveOwnership(save.getId(), documentId, userId)) { - throw new CustomException(SaveErrorCode.SAVE_NOT_OWNER); - } - } - } diff --git a/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCreateOrchestratorTest.java b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCreateOrchestratorTest.java index be3696ab..2fc2640b 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCreateOrchestratorTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/app/unit/DocCreateOrchestratorTest.java @@ -13,7 +13,7 @@ import io.ejangs.docsa.domain.doc.app.create.DocCreateMySqlTxService; import io.ejangs.docsa.domain.doc.app.create.DocCreateOrchestrator; import io.ejangs.docsa.domain.doc.dto.response.DocCreateResponse; -import io.ejangs.docsa.domain.save.app.SaveQueryService; +import io.ejangs.docsa.domain.save.app.SaveWriter; import io.ejangs.docsa.domain.save.document.SaveContent; import io.ejangs.docsa.domain.user.entity.User; import io.ejangs.docsa.global.exception.CustomException; @@ -31,7 +31,7 @@ class DocCreateOrchestratorTest { @Mock - private SaveQueryService saveQueryService; + private SaveWriter saveWriter; @Mock private DocCreateMySqlTxService docCreateMySqlTxService; @@ -50,7 +50,7 @@ void create_success() { ReflectionTestUtils.setField(saved, "id", "save-1"); DocCreateResponse expected = new DocCreateResponse(10L, 20L); - when(saveQueryService.createSaveContent()).thenReturn(saved); + when(saveWriter.createSaveContent()).thenReturn(saved); when(docCreateMySqlTxService.createMySqlPart("doc", user, "save-1")).thenReturn(expected); DocCreateResponse result = orchestrator.create("doc", user); @@ -66,7 +66,7 @@ void create_fail_compensateMongo() { SaveContent saved = SaveContent.builder().build(); ReflectionTestUtils.setField(saved, "id", "save-1"); - when(saveQueryService.createSaveContent()).thenReturn(saved); + when(saveWriter.createSaveContent()).thenReturn(saved); when(docCreateMySqlTxService.createMySqlPart("doc", user, "save-1")) .thenThrow(new RuntimeException("mysql fail")); diff --git a/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java index 1d9fb0e2..24b37268 100644 --- a/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/save/app/SaveServiceUnitTest.java @@ -39,7 +39,9 @@ class SaveServiceUnitTest { @Mock - private SaveQueryService saveQueryService; + private SaveReader saveReader; + @Mock + private SaveWriter saveWriter; @Mock private ThumbnailService thumbnailService; @Mock @@ -72,11 +74,11 @@ void getSave_success() { SaveGetResponse expectedResponse = new SaveGetResponse(LocalDateTime.now(), data); SaveContent saveContent = SaveContent.builder().content(data).build(); - when(saveQueryService.getSaveById(idDto.saveId())).thenReturn(mockSave); - doNothing().when(saveQueryService) + when(saveReader.getSaveById(idDto.saveId())).thenReturn(mockSave); + doNothing().when(saveReader) .checkSaveAndDocOwner(mockSave, idDto.userId(), idDto.documentId()); when(mockSave.getSaveMongoId()).thenReturn("mongo-1"); - when(saveQueryService.getSaveContentById("mongo-1")).thenReturn(saveContent); + when(saveReader.getSaveContentById("mongo-1")).thenReturn(saveContent); when(mockSave.getUpdatedAt()).thenReturn(LocalDateTime.now()); try (MockedStatic mockedMapper = mockStatic(SaveMapper.class)) { @@ -92,9 +94,9 @@ void getSave_success() { @Test @DisplayName("문서의 주인이 아닌 사람이 저장 요청 시 예외가 발생한다") void getSave_fail_invalidUser() { - when(saveQueryService.getSaveById(idDto.saveId())).thenReturn(mockSave); + when(saveReader.getSaveById(idDto.saveId())).thenReturn(mockSave); doThrow(new CustomException(SaveErrorCode.SAVE_NOT_OWNER)) - .when(saveQueryService) + .when(saveReader) .checkSaveAndDocOwner(mockSave, idDto.userId(), idDto.documentId()); assertThatThrownBy(() -> saveService.getSave(idDto)) @@ -105,7 +107,7 @@ void getSave_fail_invalidUser() { @Test @DisplayName("존재하지 않는 저장 ID로 조회 요청 시 예외가 발생한다") void getSave_fail_invalidSave() { - when(saveQueryService.getSaveById(idDto.saveId())).thenThrow( + when(saveReader.getSaveById(idDto.saveId())).thenThrow( new CustomException(SaveErrorCode.SAVE_NOT_FOUND)); assertThatThrownBy(() -> saveService.getSave(idDto)) @@ -121,11 +123,11 @@ void updateSave_success() { SaveUpdateResponse expectedResponse = new SaveUpdateResponse(LocalDateTime.now(), thumbnailSyncResponse); - when(saveQueryService.getSaveById(idDto.saveId())).thenReturn(mockSave); - doNothing().when(saveQueryService) + when(saveReader.getSaveById(idDto.saveId())).thenReturn(mockSave); + doNothing().when(saveReader) .checkSaveAndDocOwner(mockSave, idDto.userId(), idDto.documentId()); when(mockSave.getSaveMongoId()).thenReturn("mongo-1"); - when(saveQueryService.getSaveContentById("mongo-1")).thenReturn(mockSaveContent); + when(saveReader.getSaveContentById("mongo-1")).thenReturn(mockSaveContent); when(mockSave.getUpdatedAt()).thenReturn(LocalDateTime.now()); when(thumbnailService.requestUpdate(idDto.userId(), idDto.documentId())) .thenReturn(thumbnailSyncResponse); @@ -138,18 +140,18 @@ void updateSave_success() { SaveUpdateResponse actualResponse = saveService.updateSave(idDto, request); assertEquals(expectedResponse, actualResponse); - verify(saveQueryService).saveSave(mockSave); + verify(saveWriter).saveSave(mockSave); verify(mockSaveContent).updateContent(data); - verify(saveQueryService).saveSaveContent(mockSaveContent); + verify(saveWriter).saveSaveContent(mockSaveContent); } } @Test @DisplayName("문서의 주인이 아닌 사람이 수정 요청 시 예외가 발생한다") void updateSave_fail_invalidUser() { - when(saveQueryService.getSaveById(idDto.saveId())).thenReturn(mockSave); + when(saveReader.getSaveById(idDto.saveId())).thenReturn(mockSave); doThrow(new CustomException(SaveErrorCode.SAVE_NOT_OWNER)) - .when(saveQueryService) + .when(saveReader) .checkSaveAndDocOwner(mockSave, idDto.userId(), idDto.documentId()); assertThatThrownBy(() -> saveService.updateSave(idDto, request)) @@ -160,7 +162,7 @@ void updateSave_fail_invalidUser() { @Test @DisplayName("존재하지 않는 저장 ID로 수정 요청 시 예외가 발생한다") void updateSave_fail_invalidSave() { - when(saveQueryService.getSaveById(idDto.saveId())).thenThrow( + when(saveReader.getSaveById(idDto.saveId())).thenThrow( new CustomException(SaveErrorCode.SAVE_NOT_FOUND)); assertThatThrownBy(() -> saveService.updateSave(idDto, request)) @@ -171,12 +173,12 @@ void updateSave_fail_invalidSave() { @Test @DisplayName("Mongo 저장 실패 시 저장 실패 예외가 발생한다") void updateSave_fail_whenMongoSaveFails() { - when(saveQueryService.getSaveById(idDto.saveId())).thenReturn(mockSave); - doNothing().when(saveQueryService) + when(saveReader.getSaveById(idDto.saveId())).thenReturn(mockSave); + doNothing().when(saveReader) .checkSaveAndDocOwner(mockSave, idDto.userId(), idDto.documentId()); when(mockSave.getSaveMongoId()).thenReturn("mongo-1"); - when(saveQueryService.getSaveContentById("mongo-1")).thenReturn(mockSaveContent); - when(saveQueryService.saveSaveContent(mockSaveContent)) + when(saveReader.getSaveContentById("mongo-1")).thenReturn(mockSaveContent); + when(saveWriter.saveSaveContent(mockSaveContent)) .thenThrow(new RecoverableDataAccessException("mongo write failed")); assertThatThrownBy(() -> saveService.updateSave(idDto, request)) From 993d5936dfe8c9fa53cdcd15dfb8f3200ceb2df6 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Tue, 12 May 2026 03:26:17 +0900 Subject: [PATCH 24/28] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EC=99=80=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20QueryService=20?= =?UTF-8?q?=EB=AA=85=EB=AA=85=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미지 조회 전용 서비스를 ImageReader로 변경 - 썸네일 영속성 접근을 ThumbnailStore로 정리 - CQRS QueryService와 혼동되는 기존 QueryService 명명을 제거 --- .../doc/thumbnail/app/ThumbnailService.java | 12 ++++++------ ...ilQueryService.java => ThumbnailStore.java} | 3 +-- ...ImageQueryService.java => ImageReader.java} | 3 +-- .../docsa/domain/image/app/ImageService.java | 4 ++-- .../app/ThumbnailServiceUnitTest.java | 18 +++++++++--------- .../domain/image/app/ImageServiceUnitTest.java | 8 ++++---- 6 files changed, 23 insertions(+), 25 deletions(-) rename src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/{ThumbnailQueryService.java => ThumbnailStore.java} (96%) rename src/main/java/io/ejangs/docsa/domain/image/app/{ImageQueryService.java => ImageReader.java} (95%) diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java index c64dd9b3..05e6bdf9 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailService.java @@ -7,7 +7,7 @@ import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailSyncResponse; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; -import io.ejangs.docsa.domain.image.app.ImageQueryService; +import io.ejangs.docsa.domain.image.app.ImageReader; import io.ejangs.docsa.domain.image.entity.Image; import io.ejangs.docsa.global.outbox.event.app.DomainEventOutboxPublisher; import io.ejangs.docsa.global.outbox.event.model.AggregateType; @@ -25,9 +25,9 @@ @RequiredArgsConstructor public class ThumbnailService { - private final ThumbnailQueryService thumbnailQueryService; + private final ThumbnailStore thumbnailStore; private final DocReader docReader; - private final ImageQueryService imageQueryService; + private final ImageReader imageReader; private final S3DeleteJobEnqueuer s3DeleteJobEnqueuer; private final DomainEventOutboxPublisher domainEventOutboxPublisher; @@ -39,7 +39,7 @@ public class ThumbnailService { public ThumbnailSyncResponse requestUpdate(Long userId, Long docId) { Doc doc = docReader.getByIdAndUserId(docId, userId); - Thumbnail thumbnail = thumbnailQueryService.getOrCreateByDocForUpdate(doc); + Thumbnail thumbnail = thumbnailStore.getOrCreateByDocForUpdate(doc); Long requestToken = thumbnail.requestUpdate(); @@ -60,13 +60,13 @@ public ThumbnailResponse finalizeThumbnail( ) { docReader.checkByIdAndUserId(docId, userId); - Thumbnail thumbnail = thumbnailQueryService.getByDocIdForUpdate(docId); + Thumbnail thumbnail = thumbnailStore.getByDocIdForUpdate(docId); if (!thumbnail.isCurrentToken(requestToken)) { throw new CustomException(ThumbnailErrorCode.STALE_THUMBNAIL_REQUEST); } - Image image = imageQueryService.getByIdAndUserId(imageId, userId); + Image image = imageReader.getByIdAndUserId(imageId, userId); validateThumbnailImage(docId, image); diff --git a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailQueryService.java b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailStore.java similarity index 96% rename from src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailQueryService.java rename to src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailStore.java index 25dca073..febea8b0 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailQueryService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailStore.java @@ -10,7 +10,7 @@ @Service @RequiredArgsConstructor -public class ThumbnailQueryService { +public class ThumbnailStore { private final ThumbnailRepository thumbnailRepository; @@ -25,5 +25,4 @@ public Thumbnail getOrCreateByDocForUpdate(Doc doc) { .doc(doc) .build())); } - } diff --git a/src/main/java/io/ejangs/docsa/domain/image/app/ImageQueryService.java b/src/main/java/io/ejangs/docsa/domain/image/app/ImageReader.java similarity index 95% rename from src/main/java/io/ejangs/docsa/domain/image/app/ImageQueryService.java rename to src/main/java/io/ejangs/docsa/domain/image/app/ImageReader.java index f93eee2b..e9964d30 100644 --- a/src/main/java/io/ejangs/docsa/domain/image/app/ImageQueryService.java +++ b/src/main/java/io/ejangs/docsa/domain/image/app/ImageReader.java @@ -9,7 +9,7 @@ @Service @RequiredArgsConstructor -public class ImageQueryService { +public class ImageReader { private final ImageRepository imageRepository; @@ -17,5 +17,4 @@ public Image getByIdAndUserId(Long imageId, Long userId) { return imageRepository.findByIdAndUserId(imageId, userId) .orElseThrow(() -> new CustomException(ImageErrorCode.IMAGE_NOT_FOUND)); } - } diff --git a/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java b/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java index 45866392..c789aadc 100644 --- a/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java +++ b/src/main/java/io/ejangs/docsa/domain/image/app/ImageService.java @@ -32,7 +32,7 @@ public class ImageService { private static final long MAX_IMAGE_SIZE = 5 * 1024 * 1024; private final ImageRepository imageRepository; - private final ImageQueryService imageQueryService; + private final ImageReader imageReader; private final DocReader docReader; private final S3Presigner s3Presigner; private final S3Client s3Client; @@ -96,7 +96,7 @@ public ImageUploadUrlResponse createUploadUrl(Long userId, ImageUploadUrlRequest @Transactional public ImageUploadCompleteResponse complete(Long userId, Long imageId) { - Image image = imageQueryService.getByIdAndUserId(imageId, userId); + Image image = imageReader.getByIdAndUserId(imageId, userId); String objectKey = image.getObjectKey(); diff --git a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java index 12a82ae9..ecb3c651 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/thumbnail/app/ThumbnailServiceUnitTest.java @@ -11,7 +11,7 @@ import io.ejangs.docsa.domain.doc.entity.Doc; import io.ejangs.docsa.domain.doc.thumbnail.dto.ThumbnailResponse; import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; -import io.ejangs.docsa.domain.image.app.ImageQueryService; +import io.ejangs.docsa.domain.image.app.ImageReader; import io.ejangs.docsa.domain.image.entity.Image; import io.ejangs.docsa.domain.image.entity.Image.ImageStatus; import io.ejangs.docsa.domain.image.entity.Image.Purpose; @@ -31,13 +31,13 @@ class ThumbnailServiceUnitTest { @Mock - private ThumbnailQueryService thumbnailQueryService; + private ThumbnailStore thumbnailStore; @Mock private DocReader docReader; @Mock - private ImageQueryService imageQueryService; + private ImageReader imageReader; @Mock private S3DeleteOutboxRepository s3DeleteOutboxRepository; @@ -52,9 +52,9 @@ void setUp() { S3DeleteJobEnqueuer s3DeleteJobEnqueuer = new S3DeleteJobEnqueuer(s3DeleteOutboxRepository); thumbnailService = new ThumbnailService( - thumbnailQueryService, + thumbnailStore, docReader, - imageQueryService, + imageReader, s3DeleteJobEnqueuer, domainEventOutboxPublisher ); @@ -71,8 +71,8 @@ void finalizeThumbnail_enqueuePreviousThumbnailDeletion() { Image newImage = activeThumbnailImage(11L, userId, docId, "new.webp"); Thumbnail thumbnail = thumbnailWithCurrentImage(oldImage, requestToken); - when(thumbnailQueryService.getByDocIdForUpdate(docId)).thenReturn(thumbnail); - when(imageQueryService.getByIdAndUserId(newImage.getId(), userId)).thenReturn(newImage); + when(thumbnailStore.getByDocIdForUpdate(docId)).thenReturn(thumbnail); + when(imageReader.getByIdAndUserId(newImage.getId(), userId)).thenReturn(newImage); ThumbnailResponse response = thumbnailService.finalizeThumbnail( userId, @@ -101,8 +101,8 @@ void finalizeThumbnail_skipDeletionWhenImageIsSame() { Image image = activeThumbnailImage(10L, userId, docId, "same.webp"); Thumbnail thumbnail = thumbnailWithCurrentImage(image, requestToken); - when(thumbnailQueryService.getByDocIdForUpdate(docId)).thenReturn(thumbnail); - when(imageQueryService.getByIdAndUserId(image.getId(), userId)).thenReturn(image); + when(thumbnailStore.getByDocIdForUpdate(docId)).thenReturn(thumbnail); + when(imageReader.getByIdAndUserId(image.getId(), userId)).thenReturn(image); thumbnailService.finalizeThumbnail( userId, diff --git a/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java index 32e25d2f..ec2f11e6 100644 --- a/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/image/app/ImageServiceUnitTest.java @@ -45,7 +45,7 @@ class ImageServiceUnitTest { private ImageRepository imageRepository; @Mock - private ImageQueryService imageQueryService; + private ImageReader imageReader; @Mock private DocReader docReader; @@ -63,7 +63,7 @@ class ImageServiceUnitTest { @BeforeEach void setUp() { - imageService = new ImageService(imageRepository, imageQueryService, docReader, + imageService = new ImageService(imageRepository, imageReader, docReader, s3Presigner, s3Client); ReflectionTestUtils.setField(imageService, "bucket", "docsa-image-bucket"); ReflectionTestUtils.setField(imageService, "expireMinutes", 5L); @@ -180,7 +180,7 @@ void complete_success() { .purpose(Purpose.DOC_CONTENT) .build(); - when(imageQueryService.getByIdAndUserId(imageId, userId)).thenReturn(image); + when(imageReader.getByIdAndUserId(imageId, userId)).thenReturn(image); when(s3Client.headObject(any(HeadObjectRequest.class))).thenReturn( HeadObjectResponse.builder() .contentType("image/png") @@ -219,7 +219,7 @@ void complete_fail_whenUploadNotCompleted() { .purpose(Purpose.DOC_CONTENT) .build(); - when(imageQueryService.getByIdAndUserId(imageId, userId)).thenReturn(image); + when(imageReader.getByIdAndUserId(imageId, userId)).thenReturn(image); when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow( S3Exception.builder() .statusCode(404) From f3016a8e03e250763d4ed833fe7b9506c7f53758 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Wed, 13 May 2026 04:05:03 +0900 Subject: [PATCH 25/28] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20read=20model=20backfill=20job=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 Doc 데이터를 batch 단위로 조회해 DocListReadModel을 초기 생성 - Mongo setOnInsert upsert로 기존 read model을 덮어쓰지 않도록 처리 - backfill profile에서 실행되는 one-off job과 테스트 설정 추가 --- .../domain/doc/dao/mysql/DocRepository.java | 10 + .../app/DocListReadModelBackfillJob.java | 34 +++ .../app/DocListReadModelBackfillService.java | 146 +++++++++++++ .../readmodel/document/DocListReadModel.java | 24 +++ src/main/resources/application-backfill.yml | 25 +++ ...cListReadModelBackfillServiceUnitTest.java | 195 ++++++++++++++++++ 6 files changed, 434 insertions(+) create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillJob.java create mode 100644 src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillService.java create mode 100644 src/main/resources/application-backfill.yml create mode 100644 src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillServiceUnitTest.java diff --git a/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java b/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java index baf2f499..fa2b7ac8 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/dao/mysql/DocRepository.java @@ -1,6 +1,7 @@ package io.ejangs.docsa.domain.doc.dao.mysql; import io.ejangs.docsa.domain.doc.entity.Doc; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,6 +21,15 @@ public interface DocRepository extends JpaRepository { @EntityGraph(attributePaths = {"thumbnail", "thumbnail.currentImage"}) Page findAllByUserId(Long userId, Pageable pageable); + @EntityGraph(attributePaths = {"user", "thumbnail", "thumbnail.currentImage"}) + @Query(""" + SELECT d + FROM Doc d + WHERE d.id > :lastDocId + ORDER BY d.id ASC + """) + List findBackfillBatch(@Param("lastDocId") Long lastDocId, Pageable pageable); + @EntityGraph(attributePaths = {"thumbnail", "thumbnail.currentImage"}) @Query(""" SELECT d diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillJob.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillJob.java new file mode 100644 index 00000000..ecec0ab4 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillJob.java @@ -0,0 +1,34 @@ +package io.ejangs.docsa.domain.doc.readmodel.app; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@Profile("backfill") +@RequiredArgsConstructor +public class DocListReadModelBackfillJob implements ApplicationRunner { + + private final DocListReadModelBackfillService backfillService; + private final ConfigurableApplicationContext context; + + @Value("${backfill.doc-list.batch-size:1000}") + private int batchSize; + + @Override + public void run(ApplicationArguments args) { + log.info("[DocListReadModelBackfillJob] started. batchSize={}", batchSize); + + int inserted = backfillService.backfillAll(batchSize); + + log.info("[DocListReadModelBackfillJob] completed. inserted={}", inserted); + SpringApplication.exit(context, () -> 0); + } +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillService.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillService.java new file mode 100644 index 00000000..22f944d1 --- /dev/null +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillService.java @@ -0,0 +1,146 @@ +package io.ejangs.docsa.domain.doc.readmodel.app; + +import com.mongodb.client.result.UpdateResult; +import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; +import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; +import io.ejangs.docsa.domain.doc.dto.LatestSaveIdDto; +import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import io.ejangs.docsa.domain.image.entity.Image; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DocListReadModelBackfillService { + + private final DocRepository docRepository; + private final BranchRepository branchRepository; + private final MongoTemplate mongoTemplate; + + public int backfillAll(int batchSize) { + validateBatchSize(batchSize); + + long lastDocId = 0L; + int totalInserted = 0; + + while (true) { + List docs = findBatch(lastDocId, batchSize); + if (docs.isEmpty()) { + log.info("[DocListReadModelBackfill] done. totalInserted={}", totalInserted); + return totalInserted; + } + + int inserted = backfillDocs(docs); + totalInserted += inserted; + lastDocId = docs.getLast().getId(); + + log.info( + "[DocListReadModelBackfill] batch processed. lastDocId={}, scanned={}, inserted={}, totalInserted={}", + lastDocId, + docs.size(), + inserted, + totalInserted + ); + } + } + + public int backfillBatch(Long lastDocId, int size) { + validateBatchSize(size); + + List docs = findBatch(lastDocId, size); + int inserted = backfillDocs(docs); + + log.info( + "[DocListReadModelBackfill] single batch processed. lastDocId={}, scanned={}, inserted={}", + lastDocId, + docs.size(), + inserted + ); + + return inserted; + } + + private List findBatch(Long lastDocId, int size) { + return docRepository.findBackfillBatch(lastDocId, PageRequest.of(0, size)); + } + + private int backfillDocs(List docs) { + if (docs.isEmpty()) { + return 0; + } + + List docIds = docs.stream() + .map(Doc::getId) + .toList(); + + Map recentSaveIds = branchRepository.findLatestSaveIdsByDocIds(docIds).stream() + .collect(Collectors.toMap( + LatestSaveIdDto::docId, + LatestSaveIdDto::saveId, + (left, right) -> right + )); + + return docs.stream() + .map(doc -> toReadModel(doc, recentSaveIds.get(doc.getId()))) + .map(this::insertIfAbsent) + .mapToInt(inserted -> inserted ? 1 : 0) + .sum(); + } + + private boolean insertIfAbsent(DocListReadModel model) { + Query query = Query.query(Criteria.where("_id").is(model.getId())); + Update update = new Update() + .setOnInsert("_id", model.getId()) + .setOnInsert("userId", model.getUserId()) + .setOnInsert("title", model.getTitle()) + .setOnInsert("createdAt", model.getCreatedAt()) + .setOnInsert("updatedAt", model.getUpdatedAt()) + .setOnInsert("recentSaveId", model.getRecentSaveId()) + .setOnInsert("thumbnailObjectKey", model.getThumbnailObjectKey()) + .setOnInsert("thumbnailStatus", model.getThumbnailStatus()) + .setOnInsert("deleted", model.isDeleted()); + + UpdateResult result = mongoTemplate.upsert(query, update, DocListReadModel.class); + return result.getUpsertedId() != null; + } + + private void validateBatchSize(int size) { + if (size <= 0) { + throw new IllegalArgumentException("batch size must be positive"); + } + } + + private DocListReadModel toReadModel( + Doc doc, + Long recentSaveId + ) { + Thumbnail thumbnail = doc.getThumbnail(); + Image currentImage = thumbnail != null ? thumbnail.getCurrentImage() : null; + String thumbnailObjectKey = currentImage != null ? currentImage.getObjectKey() : null; + ThumbnailStatus thumbnailStatus = thumbnail != null ? thumbnail.getStatus() : ThumbnailStatus.EMPTY; + + return DocListReadModel.backfill( + doc.getId(), + doc.getUser().getId(), + doc.getTitle(), + doc.getCreatedAt(), + doc.getUpdatedAt(), + recentSaveId, + thumbnailObjectKey, + thumbnailStatus + ); + } +} diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java index 4b5a576f..a7c00e6f 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/document/DocListReadModel.java @@ -51,6 +51,30 @@ public static DocListReadModel create(DocCreatedPayload payload, Long eventId) { return model; } + public static DocListReadModel backfill( + Long docId, + Long userId, + String title, + LocalDateTime createdAt, + LocalDateTime updatedAt, + Long recentSaveId, + String thumbnailObjectKey, + ThumbnailStatus thumbnailStatus + ) { + DocListReadModel model = new DocListReadModel(); + model.id = docId; + model.userId = userId; + model.title = title; + model.createdAt = createdAt; + model.updatedAt = updatedAt; + model.recentSaveId = recentSaveId; + model.thumbnailObjectKey = thumbnailObjectKey; + model.thumbnailStatus = thumbnailStatus; + model.deleted = false; + model.lastProjectedEventId = null; + return model; + } + public boolean changeTitle(DocTitleChangedPayload payload, Long eventId) { if (isAlreadyProjected(eventId)) { return false; diff --git a/src/main/resources/application-backfill.yml b/src/main/resources/application-backfill.yml new file mode 100644 index 00000000..4fa8f12d --- /dev/null +++ b/src/main/resources/application-backfill.yml @@ -0,0 +1,25 @@ +spring: + main: + web-application-type: none + +mongo: + delete: + outbox: + worker: + enabled: false + +s3: + delete: + outbox: + worker: + enabled: false + +domain: + event: + outbox: + worker: + enabled: false + +backfill: + doc-list: + batch-size: ${DOC_LIST_READ_MODEL_BACKFILL_BATCH_SIZE:1000} diff --git a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillServiceUnitTest.java new file mode 100644 index 00000000..9a1fbe60 --- /dev/null +++ b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillServiceUnitTest.java @@ -0,0 +1,195 @@ +package io.ejangs.docsa.domain.doc.readmodel.app; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.mongodb.client.result.UpdateResult; +import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; +import io.ejangs.docsa.domain.doc.dao.mysql.DocRepository; +import io.ejangs.docsa.domain.doc.dto.LatestSaveIdDto; +import io.ejangs.docsa.domain.doc.entity.Doc; +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import io.ejangs.docsa.domain.image.entity.Image; +import io.ejangs.docsa.domain.image.entity.Image.Purpose; +import io.ejangs.docsa.domain.user.entity.User; +import java.time.LocalDateTime; +import java.util.List; +import org.bson.BsonInt64; +import org.bson.Document; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class DocListReadModelBackfillServiceUnitTest { + + @Mock + private DocRepository docRepository; + + @Mock + private BranchRepository branchRepository; + + @Mock + private MongoTemplate mongoTemplate; + + private DocListReadModelBackfillService backfillService; + + @BeforeEach + void setUp() { + backfillService = new DocListReadModelBackfillService( + docRepository, + branchRepository, + mongoTemplate + ); + } + + @Test + @DisplayName("문서 목록 read model이 없으면 setOnInsert로 생성한다") + void backfillBatch_success_insertIfAbsent() { + User user = user(10L); + Doc doc = doc(2L, user, "문서 2", LocalDateTime.of(2026, 1, 2, 10, 0)); + attachReadyThumbnail(doc, "thumbnail-2.webp"); + + when(docRepository.findBackfillBatch(0L, PageRequest.of(0, 100))) + .thenReturn(List.of(doc)); + when(branchRepository.findLatestSaveIdsByDocIds(List.of(2L))) + .thenReturn(List.of(new LatestSaveIdDto(2L, 200L))); + when(mongoTemplate.upsert(any(Query.class), any(Update.class), eq(DocListReadModel.class))) + .thenReturn(UpdateResult.acknowledged(0, 0L, new BsonInt64(2L))); + + int count = backfillService.backfillBatch(0L, 100); + + assertThat(count).isEqualTo(1); + + ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(Update.class); + verify(mongoTemplate).upsert(any(Query.class), updateCaptor.capture(), eq(DocListReadModel.class)); + + Document setOnInsert = updateCaptor.getValue() + .getUpdateObject() + .get("$setOnInsert", Document.class); + assertThat(setOnInsert.get("_id")).isEqualTo(2L); + assertThat(setOnInsert.get("userId")).isEqualTo(10L); + assertThat(setOnInsert.get("title")).isEqualTo("문서 2"); + assertThat(setOnInsert.get("recentSaveId")).isEqualTo(200L); + assertThat(setOnInsert.get("thumbnailObjectKey")).isEqualTo("thumbnail-2.webp"); + assertThat(setOnInsert.get("thumbnailStatus")).isEqualTo(ThumbnailStatus.READY); + assertThat(setOnInsert.get("deleted")).isEqualTo(false); + } + + @Test + @DisplayName("문서가 없으면 read model을 저장하지 않는다") + void backfillBatch_emptyDocs() { + when(docRepository.findBackfillBatch(10L, PageRequest.of(0, 100))).thenReturn(List.of()); + + int count = backfillService.backfillBatch(10L, 100); + + assertThat(count).isZero(); + verify(branchRepository, never()).findLatestSaveIdsByDocIds(anyList()); + verify(mongoTemplate, never()).upsert(any(Query.class), any(Update.class), eq(DocListReadModel.class)); + } + + @Test + @DisplayName("read model이 이미 있으면 생성 개수에 포함하지 않는다") + void backfillBatch_exists_notInserted() { + User user = user(10L); + Doc doc = doc(1L, user, "문서 1", LocalDateTime.of(2026, 1, 1, 10, 0)); + + when(docRepository.findBackfillBatch(0L, PageRequest.of(0, 100))).thenReturn(List.of(doc)); + when(branchRepository.findLatestSaveIdsByDocIds(List.of(1L))).thenReturn(List.of()); + when(mongoTemplate.upsert(any(Query.class), any(Update.class), eq(DocListReadModel.class))) + .thenReturn(UpdateResult.acknowledged(1, 0L, null)); + + int count = backfillService.backfillBatch(0L, 100); + + assertThat(count).isZero(); + verify(mongoTemplate).upsert(any(Query.class), any(Update.class), eq(DocListReadModel.class)); + } + + @Test + @DisplayName("전체 backfill은 마지막 docId 이후 batch를 반복 처리한다") + void backfillAll_success() { + User user = user(10L); + Doc first = doc(1L, user, "문서 1", LocalDateTime.of(2026, 1, 1, 10, 0)); + Doc second = doc(2L, user, "문서 2", LocalDateTime.of(2026, 1, 2, 10, 0)); + + when(docRepository.findBackfillBatch(0L, PageRequest.of(0, 1))).thenReturn(List.of(first)); + when(docRepository.findBackfillBatch(1L, PageRequest.of(0, 1))).thenReturn(List.of(second)); + when(docRepository.findBackfillBatch(2L, PageRequest.of(0, 1))).thenReturn(List.of()); + when(branchRepository.findLatestSaveIdsByDocIds(List.of(1L))).thenReturn(List.of()); + when(branchRepository.findLatestSaveIdsByDocIds(List.of(2L))).thenReturn(List.of()); + when(mongoTemplate.upsert(any(Query.class), any(Update.class), eq(DocListReadModel.class))) + .thenReturn(UpdateResult.acknowledged(0, 0L, new BsonInt64(1L))) + .thenReturn(UpdateResult.acknowledged(0, 0L, new BsonInt64(2L))); + + int count = backfillService.backfillAll(1); + + assertThat(count).isEqualTo(2); + verify(mongoTemplate, times(2)).upsert(any(Query.class), any(Update.class), eq(DocListReadModel.class)); + } + + @Test + @DisplayName("batch size가 0 이하면 예외가 발생한다") + void backfillBatch_fail_invalidSize() { + assertThatThrownBy(() -> backfillService.backfillBatch(0L, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + private User user(Long userId) { + User user = User.builder() + .email("test@test.com") + .name("tester") + .password("password") + .build(); + ReflectionTestUtils.setField(user, "id", userId); + return user; + } + + private Doc doc(Long docId, User user, String title, LocalDateTime time) { + Doc doc = Doc.builder() + .title(title) + .user(user) + .build(); + ReflectionTestUtils.setField(doc, "id", docId); + ReflectionTestUtils.setField(doc, "createdAt", time); + ReflectionTestUtils.setField(doc, "updatedAt", time.plusHours(1)); + return doc; + } + + private void attachReadyThumbnail(Doc doc, String objectKey) { + Image image = Image.builder() + .userId(doc.getUser().getId()) + .docId(doc.getId()) + .originalFileName("thumbnail.webp") + .objectKey(objectKey) + .contentType("image/webp") + .size(1024L) + .purpose(Purpose.DOC_THUMBNAIL) + .build(); + image.activate(); + + Thumbnail thumbnail = Thumbnail.builder() + .doc(doc) + .build(); + thumbnail.complete(image, "signature"); + ReflectionTestUtils.setField(doc, "thumbnail", thumbnail); + } + +} From 2069b20278e6635d3f00988dd3597905272fc441 Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Wed, 13 May 2026 04:26:58 +0900 Subject: [PATCH 26/28] =?UTF-8?q?refactor:=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20backfill=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{app => backfill}/DocListReadModelBackfillJob.java | 2 +- .../{app => backfill}/DocListReadModelBackfillService.java | 2 +- .../DocListReadModelBackfillServiceUnitTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/main/java/io/ejangs/docsa/domain/doc/readmodel/{app => backfill}/DocListReadModelBackfillJob.java (95%) rename src/main/java/io/ejangs/docsa/domain/doc/readmodel/{app => backfill}/DocListReadModelBackfillService.java (98%) rename src/test/java/io/ejangs/docsa/domain/doc/readmodel/{app => backfill}/DocListReadModelBackfillServiceUnitTest.java (99%) diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillJob.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillJob.java similarity index 95% rename from src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillJob.java rename to src/main/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillJob.java index ecec0ab4..0ac43ab2 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillJob.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillJob.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.readmodel.app; +package io.ejangs.docsa.domain.doc.readmodel.backfill; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillService.java b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillService.java similarity index 98% rename from src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillService.java rename to src/main/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillService.java index 22f944d1..431d085e 100644 --- a/src/main/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillService.java +++ b/src/main/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillService.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.readmodel.app; +package io.ejangs.docsa.domain.doc.readmodel.backfill; import com.mongodb.client.result.UpdateResult; import io.ejangs.docsa.domain.branch.dao.mysql.BranchRepository; diff --git a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillServiceUnitTest.java b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillServiceUnitTest.java similarity index 99% rename from src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillServiceUnitTest.java rename to src/test/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillServiceUnitTest.java index 9a1fbe60..3bdc8ff8 100644 --- a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/app/DocListReadModelBackfillServiceUnitTest.java +++ b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/backfill/DocListReadModelBackfillServiceUnitTest.java @@ -1,4 +1,4 @@ -package io.ejangs.docsa.domain.doc.readmodel.app; +package io.ejangs.docsa.domain.doc.readmodel.backfill; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; From afdbedb32fafc7c33c71144e88376a3d1caacb0b Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Wed, 13 May 2026 16:20:46 +0900 Subject: [PATCH 27/28] =?UTF-8?q?chore:=20backfill=20=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - local backfill 실행 스크립트를 추가 - backfill profile에서 local initializer와 cleanup이 실행되지 않도록 조정 - backfill 실행 시 domain event relay bean 의존성은 유지하되 scheduler 실행은 지연 --- infra/scripts/backfill-doc-list-local.sh | 23 +++++++++++++++++++ .../docsa/global/init/LocalMongoCleanup.java | 2 +- .../global/init/PerfDataInitializer.java | 2 +- .../global/init/TestUserInitializer.java | 2 +- src/main/resources/application-backfill.yml | 6 ++++- 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 infra/scripts/backfill-doc-list-local.sh diff --git a/infra/scripts/backfill-doc-list-local.sh b/infra/scripts/backfill-doc-list-local.sh new file mode 100644 index 00000000..acd7089a --- /dev/null +++ b/infra/scripts/backfill-doc-list-local.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ENV_FILE="${ENV_FILE:-"$ROOT_DIR/infra/.local.env"}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "env file not found: $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +export SPRING_PROFILES_ACTIVE="${BACKFILL_PROFILES:-local,backfill}" +export DDL_AUTO="${BACKFILL_DDL_AUTO:-none}" +export MONGO_LOCAL_CLEANUP_ENABLED="false" +export PERF_SEED_USER_COUNT="0" +export DOC_LIST_READ_MODEL_BACKFILL_BATCH_SIZE="${DOC_LIST_READ_MODEL_BACKFILL_BATCH_SIZE:-100}" + +bash "$ROOT_DIR/gradlew" bootRun diff --git a/src/main/java/io/ejangs/docsa/global/init/LocalMongoCleanup.java b/src/main/java/io/ejangs/docsa/global/init/LocalMongoCleanup.java index f444ec6e..41e51d12 100644 --- a/src/main/java/io/ejangs/docsa/global/init/LocalMongoCleanup.java +++ b/src/main/java/io/ejangs/docsa/global/init/LocalMongoCleanup.java @@ -15,7 +15,7 @@ @Slf4j @Component -@Profile("local") +@Profile("local & !backfill") @RequiredArgsConstructor @Order(Ordered.HIGHEST_PRECEDENCE) @ConditionalOnProperty( diff --git a/src/main/java/io/ejangs/docsa/global/init/PerfDataInitializer.java b/src/main/java/io/ejangs/docsa/global/init/PerfDataInitializer.java index eece2920..33ca4993 100644 --- a/src/main/java/io/ejangs/docsa/global/init/PerfDataInitializer.java +++ b/src/main/java/io/ejangs/docsa/global/init/PerfDataInitializer.java @@ -27,7 +27,7 @@ @Slf4j @Component @RequiredArgsConstructor -@Profile({"local", "stg"}) +@Profile({"local & !backfill", "stg & !backfill"}) public class PerfDataInitializer implements ApplicationRunner { private static final String PASSWORD = "Testtest1"; diff --git a/src/main/java/io/ejangs/docsa/global/init/TestUserInitializer.java b/src/main/java/io/ejangs/docsa/global/init/TestUserInitializer.java index ceba0bb2..ed4dc40a 100644 --- a/src/main/java/io/ejangs/docsa/global/init/TestUserInitializer.java +++ b/src/main/java/io/ejangs/docsa/global/init/TestUserInitializer.java @@ -10,7 +10,7 @@ import org.springframework.transaction.annotation.Transactional; @Component -@Profile({"local", "stg"}) +@Profile({"local & !backfill", "stg & !backfill"}) @Transactional(rollbackFor = Exception.class) @RequiredArgsConstructor public class TestUserInitializer { diff --git a/src/main/resources/application-backfill.yml b/src/main/resources/application-backfill.yml index 4fa8f12d..ab01bfa0 100644 --- a/src/main/resources/application-backfill.yml +++ b/src/main/resources/application-backfill.yml @@ -3,6 +3,8 @@ spring: web-application-type: none mongo: + local-cleanup: + enabled: false delete: outbox: worker: @@ -18,7 +20,9 @@ domain: event: outbox: worker: - enabled: false + enabled: true + fixed-delay: PT1M + initial-delay: PT24H backfill: doc-list: From 0dfd1a86850d326843d2a3be75375308aeb93e1b Mon Sep 17 00:00:00 2001 From: lunarbae628 Date: Wed, 13 May 2026 23:10:26 +0900 Subject: [PATCH 28/28] =?UTF-8?q?test:=20=EB=AC=B8=EC=84=9C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20read=20model=20=EC=82=AD=EC=A0=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=EC=A1=B0=ED=9A=8C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록 조회에서 deleted=true read model이 제외되는지 검증 - 제목 검색 조회에서도 삭제된 read model이 제외되는지 검증 --- ...istReadModelRepositoryIntegrationTest.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/test/java/io/ejangs/docsa/domain/doc/readmodel/dao/mongodb/DocListReadModelRepositoryIntegrationTest.java diff --git a/src/test/java/io/ejangs/docsa/domain/doc/readmodel/dao/mongodb/DocListReadModelRepositoryIntegrationTest.java b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/dao/mongodb/DocListReadModelRepositoryIntegrationTest.java new file mode 100644 index 00000000..17618b03 --- /dev/null +++ b/src/test/java/io/ejangs/docsa/domain/doc/readmodel/dao/mongodb/DocListReadModelRepositoryIntegrationTest.java @@ -0,0 +1,96 @@ +package io.ejangs.docsa.domain.doc.readmodel.dao.mongodb; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.ejangs.docsa.domain.doc.readmodel.document.DocListReadModel; +import io.ejangs.docsa.domain.doc.readmodel.dto.payload.DocCreatedPayload; +import io.ejangs.docsa.domain.doc.thumbnail.entity.Thumbnail.ThumbnailStatus; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class DocListReadModelRepositoryIntegrationTest { + + @Autowired + private DocListReadModelRepository docListReadModelRepository; + + private final Long userId = System.currentTimeMillis(); + private final Long activeDocId = userId + 1; + private final Long deletedDocId = userId + 2; + + @BeforeEach + void setUp() { + docListReadModelRepository.deleteAllById(List.of(activeDocId, deletedDocId)); + } + + @AfterEach + void cleanUp() { + docListReadModelRepository.deleteAllById(List.of(activeDocId, deletedDocId)); + } + + @Test + @DisplayName("문서 목록 조회시 삭제된 read model은 제외한다") + void findByUserIdAndDeletedFalse_excludesDeleted() { + DocListReadModel active = readModel(activeDocId, "문서 1", 1L); + DocListReadModel deleted = readModel(deletedDocId, "문서 2", 2L); + deleted.markDeleted(3L); + docListReadModelRepository.saveAll(List.of(active, deleted)); + + Page result = docListReadModelRepository.findByUserIdAndDeletedFalse( + userId, + PageRequest.of(0, 10) + ); + + assertThat(result.getContent()) + .extracting(DocListReadModel::getId) + .containsExactly(activeDocId); + } + + @Test + @DisplayName("문서 목록 검색시 삭제된 read model은 제외한다") + void searchByTitle_excludesDeleted() { + DocListReadModel active = readModel(activeDocId, "검색 문서", 1L); + DocListReadModel deleted = readModel(deletedDocId, "검색 문서", 2L); + deleted.markDeleted(3L); + docListReadModelRepository.saveAll(List.of(active, deleted)); + + Page result = + docListReadModelRepository.findByUserIdAndDeletedFalseAndTitleContainingIgnoreCase( + userId, + "검색", + PageRequest.of(0, 10) + ); + + assertThat(result.getContent()) + .extracting(DocListReadModel::getId) + .containsExactly(activeDocId); + } + + private DocListReadModel readModel(Long docId, String title, Long eventId) { + LocalDateTime createdAt = LocalDateTime.of(2026, 1, 1, 10, 0); + LocalDateTime updatedAt = LocalDateTime.of(2026, 1, 2, 10, 0); + return DocListReadModel.create( + new DocCreatedPayload( + docId, + userId, + title, + createdAt, + updatedAt, + 10L, + null, + ThumbnailStatus.EMPTY + ), + eventId + ); + } +}