From 3a4abc7964931a92d37e7bce7307bafbfec4fc43 Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:19:40 +0900 Subject: [PATCH 01/13] =?UTF-8?q?perf(deployment):=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=BF=BC=EB=A6=AC=203=EA=B0=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=82=98=EB=88=88=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 이력 조회 세 곳이 프로젝트의 전체 이력을 엔티티(28컬럼, TEXT 2개)로 읽은 뒤 메모리에서 골라 썼다. 정작 쓰는 것은 각각 11·6·7 컬럼이고, 두 곳은 "version_label 이 있는 LIVE" 한 줌뿐이다. 사용 기간에 비례해 선형으로 무거워지던 자리다. - 목록(getDeploymentHistories): DeploymentHistoryListView 11컬럼만. description TEXT 가 응답에 없는데도 매 행 실려 오던 것이 사라진다 - 버전/배포후보(getVersions·getDeploymentCandidates): version_label·LIVE 필터를 SQL 로 내렸다. 버전별 최신 1건 추리기는 이미 좁아진 행 위의 Map 한 번이라 그대로 둔다 - DomainBindingCommandService.resolveDeploymentUrl: 전체 이력 로드 → "최근 LIVE 1건" 전용 쿼리. 첫 LIVE 의 URL 이 비면 다음 LIVE 로 넘어가지 않는 기존 동작을 유지하려고 공백 판정은 호출부에 남겼다 응답 JSON 은 바뀌지 않는다. 상태·타깃·실패코드는 DB 도 응답도 문자열이라 중간의 enum 왕복만 없앴다. 정렬에 id 를 tiebreaker 로 붙인 것은 동작 고정이다 — triggered_at 이 DATETIME(초) 라 같은 초의 행 순서가 비결정적이었고, 프로젝트 개요가 그 첫 건을 "최신 배포" 로 쓰면서 findLatestByProjectId 와 어긋날 수 있었다. 전체 엔티티를 읽던 findByProjectIdOrderByTriggeredAtDesc 는 호출부가 없어져 지웠다 — 남겨두면 다음 사람이 같은 함정을 다시 밟는다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../query/DeploymentQueryService.java | 99 ++++++++++--------- .../repository/DeploymentHistoryListView.java | 25 +++++ .../DeploymentHistoryRepository.java | 15 ++- .../repository/DeploymentVersionView.java | 23 +++++ .../DeploymentHistoryRepositoryAdapter.java | 27 ++++- ...SpringDataDeploymentHistoryRepository.java | 60 ++++++++++- .../command/DomainBindingCommandService.java | 9 +- .../query/DeploymentQueryServiceTest.java | 20 +++- .../DomainBindingCommandServiceTest.java | 2 +- 9 files changed, 218 insertions(+), 62 deletions(-) create mode 100644 src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryListView.java create mode 100644 src/main/java/com/example/dvely/deployment/domain/repository/DeploymentVersionView.java diff --git a/src/main/java/com/example/dvely/deployment/application/query/DeploymentQueryService.java b/src/main/java/com/example/dvely/deployment/application/query/DeploymentQueryService.java index 8324ef73..28aab0ab 100644 --- a/src/main/java/com/example/dvely/deployment/application/query/DeploymentQueryService.java +++ b/src/main/java/com/example/dvely/deployment/application/query/DeploymentQueryService.java @@ -11,7 +11,9 @@ import com.example.dvely.deployment.application.result.VersionDetailResult; import com.example.dvely.deployment.application.result.VersionResult; import com.example.dvely.deployment.domain.model.DeploymentHistory; +import com.example.dvely.deployment.domain.repository.DeploymentHistoryListView; import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository; +import com.example.dvely.deployment.domain.repository.DeploymentVersionView; import com.example.dvely.deployment.infrastructure.workflow.DeployWorkflowTemplate; import com.example.dvely.project.domain.model.Project; import com.example.dvely.project.domain.repository.ProjectRepository; @@ -107,54 +109,60 @@ private DeploymentStatusResult toStatusResult(DeploymentHistory h, WorkflowRunSt @Transactional(readOnly = true) public List getDeploymentHistories(Long ownerUserId, Long projectId) { findOwnedProject(ownerUserId, projectId); - return deploymentHistoryRepository.findByProjectIdOrderByTriggeredAtDesc(projectId) + return deploymentHistoryRepository.findHistoryListViews(projectId) .stream() - .map(this::toResult) + .map(DeploymentQueryService::toResult) .toList(); } - private DeploymentHistoryResult toResult(DeploymentHistory h) { + /** + * 읽기 모델 → 응답. 상태·타깃·실패코드는 DB 에 문자열로 들어 있고 응답에도 문자열로 나가므로 + * 중간에 enum 으로 되돌리지 않는다 — 값은 예전과 같고 변환 한 왕복이 사라진다. + */ + private static DeploymentHistoryResult toResult(DeploymentHistoryListView h) { return new DeploymentHistoryResult( - h.getId(), - h.getProjectId(), - h.getDeployTargetType().name(), - h.getVersionLabel(), - h.getDeployedUrl(), - h.getStatus().name(), - h.getFailureCode() == null ? null : h.getFailureCode().name(), - h.getErrorMessage(), - h.getTriggeredAt(), - h.getUpdatedAt(), - h.getRetriedFromHistoryId() + h.id(), + h.projectId(), + h.deployTargetType(), + h.versionLabel(), + h.deployedUrl(), + h.status(), + h.failureCode(), + h.errorMessage(), + h.triggeredAt(), + h.updatedAt(), + h.retriedFromHistoryId() ); } @Transactional(readOnly = true) public List getVersions(Long ownerUserId, Long projectId) { findOwnedProject(ownerUserId, projectId); - List histories = deploymentHistoryRepository - .findByProjectIdOrderByTriggeredAtDesc(projectId); + // "versionLabel 이 있는 것만" 은 이제 SQL 이 거른다. 버전별 최신 1건 추리기는 그대로 메모리에 + // 남긴다 — 이미 좁아진 행 위의 Map 한 번이고, SQL 로 옮기면 동작이 미묘하게 달라질 수 있다. + return latestPerVersionLabel(deploymentHistoryRepository.findLabeledVersionViews(projectId)) + .map(h -> new VersionResult( + h.id(), + h.versionLabel(), + h.commitSha(), + h.title(), + h.status(), + h.mergedAt() == null ? h.triggeredAt() : h.mergedAt() + )) + .toList(); + } - // versionLabel 기준으로 그룹화 후 각 버전의 최신 이력만 추출 (null 제외) - Map latestByVersion = histories.stream() - .filter(h -> h.getVersionLabel() != null && !h.getVersionLabel().isBlank()) + /** 최신순으로 들어온 목록에서 version_label 별 첫 건만 남기고 다시 최신순으로 돌려준다. */ + private static java.util.stream.Stream latestPerVersionLabel( + List views) { + Map latestByVersion = views.stream() .collect(Collectors.toMap( - DeploymentHistory::getVersionLabel, - h -> h, + DeploymentVersionView::versionLabel, + view -> view, (existing, replacement) -> existing // 이미 최신순 정렬이므로 첫 번째 유지 )); - return latestByVersion.values().stream() - .sorted(Comparator.comparing(DeploymentHistory::getTriggeredAt).reversed()) - .map(h -> new VersionResult( - h.getId(), - h.getVersionLabel(), - h.getCommitSha(), - h.getTitle(), - h.getStatus().name(), - h.getMergedAt() == null ? h.getTriggeredAt() : h.getMergedAt() - )) - .toList(); + .sorted(Comparator.comparing(DeploymentVersionView::triggeredAt).reversed()); } @Transactional(readOnly = true) @@ -181,24 +189,17 @@ public VersionDetailResult getVersionDetail(Long ownerUserId, Long versionId) { @Transactional(readOnly = true) public List getDeploymentCandidates(Long ownerUserId, Long projectId) { findOwnedProject(ownerUserId, projectId); - return deploymentHistoryRepository.findByProjectIdOrderByTriggeredAtDesc(projectId).stream() - .filter(h -> h.getVersionLabel() != null && !h.getVersionLabel().isBlank()) - .filter(h -> h.getStatus() == DeployStatus.LIVE) - .collect(Collectors.toMap( - DeploymentHistory::getVersionLabel, - h -> h, - (existing, replacement) -> existing - )) - .values().stream() - .sorted(Comparator.comparing(DeploymentHistory::getTriggeredAt).reversed()) + // LIVE + versionLabel 두 조건 모두 SQL 로 내렸다. 예전에는 프로젝트의 전체 이력을 엔티티로 + // 읽어와 둘 다 메모리에서 걸렀다. + return latestPerVersionLabel(deploymentHistoryRepository.findLiveLabeledVersionViews(projectId)) .map(h -> new DeploymentCandidateResult( - h.getId(), - h.getVersionLabel(), - h.getCommitSha(), - h.getTitle(), - h.getStatus().name(), - h.getDeployedUrl(), - h.getUpdatedAt() + h.id(), + h.versionLabel(), + h.commitSha(), + h.title(), + h.status(), + h.deployedUrl(), + h.updatedAt() )) .toList(); } diff --git a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryListView.java b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryListView.java new file mode 100644 index 00000000..b3a8656b --- /dev/null +++ b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryListView.java @@ -0,0 +1,25 @@ +package com.example.dvely.deployment.domain.repository; + +import java.time.LocalDateTime; + +/** + * 배포 이력 목록 전용 읽기 모델. U6(#341) 6-2. + * + *

여기 있는 컬럼이 {@code DeploymentHistoryResult} 가 쓰는 전부다. 예전에는 이력 목록이 엔티티를 + * 통째로(28컬럼, TEXT 2개) 읽은 뒤 절반 넘게 버렸다 — 특히 {@code description TEXT} 는 목록 응답에 + * 없는데도 매 행 실려 왔다. 필드를 늘리기 전에 정말 응답에 나가는 값인지 확인할 것.

+ */ +public record DeploymentHistoryListView( + Long id, + Long projectId, + String deployTargetType, + String versionLabel, + String deployedUrl, + String status, + String failureCode, + String errorMessage, + LocalDateTime triggeredAt, + LocalDateTime updatedAt, + Long retriedFromHistoryId +) { +} diff --git a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java index ba372c96..a1ca36b4 100644 --- a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java +++ b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentHistoryRepository.java @@ -13,7 +13,20 @@ public interface DeploymentHistoryRepository { Optional findById(Long id); - List findByProjectIdOrderByTriggeredAtDesc(Long projectId); + /** U6 6-2: 이력 목록 응답에 실제로 나가는 컬럼만 읽는다. {@link DeploymentHistoryListView} 참고. */ + List findHistoryListViews(Long projectId); + + /** U6 6-2: version_label 이 있는 이력만. 버전 목록 응답 전용. */ + List findLabeledVersionViews(Long projectId); + + /** U6 6-2: version_label 이 있고 LIVE 인 이력만. 배포 후보 응답 전용. */ + List findLiveLabeledVersionViews(Long projectId); + + /** + * U6 6-2: 가장 최근 LIVE 이력의 deployedUrl. 값이 null/공백일 수 있고, 그때 다음 LIVE 로 넘어가지 + * 않는 것이 기존 동작이라 그대로 돌려준다(빈 Optional 과 "빈 값" 을 구분하지 않는다). + */ + Optional findLatestLiveDeployedUrl(Long projectId); Optional findLatestByProjectId(Long projectId); diff --git a/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentVersionView.java b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentVersionView.java new file mode 100644 index 00000000..877a25c7 --- /dev/null +++ b/src/main/java/com/example/dvely/deployment/domain/repository/DeploymentVersionView.java @@ -0,0 +1,23 @@ +package com.example.dvely.deployment.domain.repository; + +import java.time.LocalDateTime; + +/** + * 버전 목록·배포 후보 전용 읽기 모델. U6(#341) 6-2. + * + *

{@code VersionResult} 와 {@code DeploymentCandidateResult} 가 함께 쓴다 — 두 응답이 쓰는 컬럼의 + * 합집합이고, 그 둘 말고는 아무것도 담지 않는다. 예전에는 두 조회가 프로젝트의 전체 이력을 + * 엔티티로 읽어 메모리에서 "version_label 있는 것"·"LIVE 인 것"을 골라냈다. 그 필터는 이제 SQL 에 있다.

+ */ +public record DeploymentVersionView( + Long id, + String versionLabel, + String commitSha, + String title, + String status, + String deployedUrl, + LocalDateTime triggeredAt, + LocalDateTime mergedAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java index 1e39f284..cc606e72 100644 --- a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/DeploymentHistoryRepositoryAdapter.java @@ -1,9 +1,11 @@ package com.example.dvely.deployment.infrastructure.persistence.repository; import com.example.dvely.deployment.domain.model.DeploymentHistory; +import com.example.dvely.deployment.domain.repository.DeploymentHistoryListView; import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository; import com.example.dvely.common.worker.WorkQueue; import com.example.dvely.common.worker.WorkQueuedEvent; +import com.example.dvely.deployment.domain.repository.DeploymentVersionView; import com.example.dvely.deployment.infrastructure.persistence.entity.DeploymentHistoryEntity; import com.example.dvely.project.domain.value.DeployStatus; import java.util.Collection; @@ -53,9 +55,28 @@ public Optional findById(Long id) { } @Override - public List findByProjectIdOrderByTriggeredAtDesc(Long projectId) { - return springDataRepository.findByProjectIdOrderByTriggeredAtDesc(projectId) - .stream().map(DeploymentHistoryEntity::toDomain).toList(); + public List findHistoryListViews(Long projectId) { + return springDataRepository.findHistoryListViews(projectId); + } + + @Override + public List findLabeledVersionViews(Long projectId) { + return springDataRepository.findLabeledVersionViews(projectId); + } + + @Override + public List findLiveLabeledVersionViews(Long projectId) { + return springDataRepository.findLiveLabeledVersionViews(projectId, DeployStatus.LIVE.name()); + } + + @Override + public Optional findLatestLiveDeployedUrl(Long projectId) { + // deployed_url 은 NULL 일 수 있다. 리스트에 null 원소가 담기므로 Stream#findFirst 를 쓰면 + // NPE 다 — 그래서 직접 꺼내 ofNullable 로 감싼다. "LIVE 가 없다" 와 "LIVE 인데 URL 이 + // 비었다" 를 여기서 구분하지 않는 것은 의도다(호출부가 예전처럼 공백까지 판정한다). + List urls = springDataRepository.findLiveDeployedUrls( + projectId, DeployStatus.LIVE.name(), PageRequest.of(0, 1)); + return urls.isEmpty() ? Optional.empty() : Optional.ofNullable(urls.get(0)); } @Override diff --git a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java index 6689576c..2ffcbd07 100644 --- a/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java +++ b/src/main/java/com/example/dvely/deployment/infrastructure/persistence/repository/SpringDataDeploymentHistoryRepository.java @@ -1,5 +1,7 @@ package com.example.dvely.deployment.infrastructure.persistence.repository; +import com.example.dvely.deployment.domain.repository.DeploymentHistoryListView; +import com.example.dvely.deployment.domain.repository.DeploymentVersionView; import com.example.dvely.deployment.infrastructure.persistence.entity.DeploymentHistoryEntity; import java.time.LocalDateTime; import java.util.Collection; @@ -13,7 +15,63 @@ public interface SpringDataDeploymentHistoryRepository extends JpaRepository { - List findByProjectIdOrderByTriggeredAtDesc(Long projectId); + // U6 6-2 ①: 이력 목록. 응답에 나가는 11컬럼만 읽는다(엔티티는 28컬럼 + TEXT 2개). + // triggeredAt 이 DATETIME(초) 이라 같은 초의 행끼리 순서가 비결정적이었다 — id 를 tiebreaker 로 + // 붙여 고정한다. 프로젝트 개요가 이 목록의 첫 건을 "최신 배포" 로 쓰는데, 그 값이 조회마다 + // 달라질 수 있었다(findFirstByProjectIdOrderByTriggeredAtDescIdDesc 와도 어긋났다). + @Query(""" + select new com.example.dvely.deployment.domain.repository.DeploymentHistoryListView( + history.id, history.projectId, history.deployTargetType, history.versionLabel, + history.deployedUrl, history.status, history.failureCode, history.errorMessage, + history.triggeredAt, history.updatedAt, history.retriedFromHistoryId) + from DeploymentHistoryEntity history + where history.projectId = :projectId + order by history.triggeredAt desc, history.id desc + """) + List findHistoryListViews(@Param("projectId") Long projectId); + + // U6 6-2 ②: 버전 목록. version_label 이 비지 않은 행만 — 예전에는 전체를 읽어 메모리에서 걸렀다. + @Query(""" + select new com.example.dvely.deployment.domain.repository.DeploymentVersionView( + history.id, history.versionLabel, history.commitSha, history.title, + history.status, history.deployedUrl, history.triggeredAt, history.mergedAt, + history.updatedAt) + from DeploymentHistoryEntity history + where history.projectId = :projectId + and history.versionLabel is not null + and trim(history.versionLabel) <> '' + order by history.triggeredAt desc, history.id desc + """) + List findLabeledVersionViews(@Param("projectId") Long projectId); + + // U6 6-2 ③: 배포 후보. ② 에 "LIVE 만" 이 더 붙는다. 두 조건 모두 SQL 에 있다. + @Query(""" + select new com.example.dvely.deployment.domain.repository.DeploymentVersionView( + history.id, history.versionLabel, history.commitSha, history.title, + history.status, history.deployedUrl, history.triggeredAt, history.mergedAt, + history.updatedAt) + from DeploymentHistoryEntity history + where history.projectId = :projectId + and history.status = :liveStatus + and history.versionLabel is not null + and trim(history.versionLabel) <> '' + order by history.triggeredAt desc, history.id desc + """) + List findLiveLabeledVersionViews( + @Param("projectId") Long projectId, @Param("liveStatus") String liveStatus); + + // U6 6-2 ④: DomainBindingCommandService 가 쓰는 "가장 최근 LIVE 1건의 URL". 예전에는 전체 이력을 + // 엔티티로 읽어 첫 LIVE 하나만 꺼냈다. 값이 비어 있으면 다음 LIVE 로 넘어가지 않는 기존 + // 동작을 유지하려고 URL 의 공백 판정은 호출부에 남겨둔다 — 여기서 걸러내면 동작이 달라진다. + @Query(""" + select history.deployedUrl + from DeploymentHistoryEntity history + where history.projectId = :projectId + and history.status = :liveStatus + order by history.triggeredAt desc, history.id desc + """) + List findLiveDeployedUrls( + @Param("projectId") Long projectId, @Param("liveStatus") String liveStatus, Pageable pageable); Optional findFirstByProjectIdOrderByTriggeredAtDescIdDesc(Long projectId); diff --git a/src/main/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandService.java b/src/main/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandService.java index 9eb50389..209659e2 100644 --- a/src/main/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandService.java +++ b/src/main/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandService.java @@ -475,11 +475,10 @@ private String resolveDeploymentUrl(Project project) { if (project.getCurrentUrl() != null && !project.getCurrentUrl().isBlank()) { return project.getCurrentUrl(); } - return deploymentHistoryRepository.findByProjectIdOrderByTriggeredAtDesc(project.getId()).stream() - .filter(history -> history.getStatus() == DeployStatus.LIVE) - .findFirst() - .map(history -> history.getDeployedUrl()) - .filter(url -> url != null && !url.isBlank()) + // U6 6-2: 전체 이력을 엔티티로 읽어 첫 LIVE 하나만 꺼내던 자리. 전용 쿼리로 바꿨다. + // 공백 판정은 여기 남는다 — 첫 LIVE 의 URL 이 비면 다음 LIVE 로 넘어가지 않는 기존 동작이다. + return deploymentHistoryRepository.findLatestLiveDeployedUrl(project.getId()) + .filter(url -> !url.isBlank()) .orElse(null); } diff --git a/src/test/java/com/example/dvely/deployment/application/query/DeploymentQueryServiceTest.java b/src/test/java/com/example/dvely/deployment/application/query/DeploymentQueryServiceTest.java index 88fa1891..2839e279 100644 --- a/src/test/java/com/example/dvely/deployment/application/query/DeploymentQueryServiceTest.java +++ b/src/test/java/com/example/dvely/deployment/application/query/DeploymentQueryServiceTest.java @@ -16,6 +16,7 @@ import com.example.dvely.deployment.application.result.DeploymentStatusResult; import com.example.dvely.deployment.domain.model.DeploymentHistory; import com.example.dvely.deployment.domain.repository.DeploymentHistoryRepository; +import com.example.dvely.deployment.domain.repository.DeploymentVersionView; import com.example.dvely.deployment.domain.value.DeployTargetType; import com.example.dvely.deployment.infrastructure.workflow.DeployWorkflowTemplate; import com.example.dvely.project.domain.model.Project; @@ -151,8 +152,8 @@ void getVersions_returnsStoredReleaseMetadata() { ); when(projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(11L, 1L)) .thenReturn(Optional.of(boundProject())); - when(deploymentHistoryRepository.findByProjectIdOrderByTriggeredAtDesc(11L)) - .thenReturn(List.of(history)); + when(deploymentHistoryRepository.findLabeledVersionViews(11L)) + .thenReturn(List.of(versionViewOf(history))); var versions = queryService.getVersions(1L, 11L); var detail = queryService.getVersionDetail(1L, 101L); @@ -244,6 +245,21 @@ private DeploymentHistory deploymentHistory(DeployStatus status, Long workflowRu ); } + /** U6 6-2: 버전 목록은 엔티티가 아니라 읽기 모델을 받는다. 같은 이력에서 그 뷰를 만든다. */ + private DeploymentVersionView versionViewOf(DeploymentHistory history) { + return new DeploymentVersionView( + history.getId(), + history.getVersionLabel(), + history.getCommitSha(), + history.getTitle(), + history.getStatus().name(), + history.getDeployedUrl(), + history.getTriggeredAt(), + history.getMergedAt(), + history.getUpdatedAt() + ); + } + private DeploymentHistory deploymentHistoryWithMetadata(DeployStatus status, Long workflowRunId, String correlationId, diff --git a/src/test/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandServiceTest.java b/src/test/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandServiceTest.java index a15c770c..c3b0f245 100644 --- a/src/test/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandServiceTest.java +++ b/src/test/java/com/example/dvely/domainbinding/application/command/DomainBindingCommandServiceTest.java @@ -132,7 +132,7 @@ void bindManagedSubdomain_usesGithubPagesHostEvenWhenCurrentUrlIsManagedDomain() assertThat(result.certificateStatus()).isEqualTo(CertificateStatus.PENDING); verify(cloudflareDnsPort).createCnameRecord("my-project.qeploy.com", "octo.github.io"); verify(hostingAdapter).bind(any(), org.mockito.ArgumentMatchers.eq("my-project.qeploy.com")); - verify(deploymentHistoryRepository, never()).findByProjectIdOrderByTriggeredAtDesc(11L); + verify(deploymentHistoryRepository, never()).findLatestLiveDeployedUrl(11L); // H10 (design §4): no taskId on this command -> USER actor. org.mockito.ArgumentCaptor auditCaptor = org.mockito.ArgumentCaptor.forClass(AuditEvent.class); verify(auditRecorder).record(auditCaptor.capture()); From 875e095c7a4ccb58dd91728a6d4159c66c796995 Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:20:06 +0900 Subject: [PATCH 02/13] =?UTF-8?q?perf(paging):=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=BB=A4=EC=84=9C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=ED=86=A0=EB=8C=80=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-3·6-4 의 무제한 목록에 상한을 두기 위한 공통 조각. 계약 호환이 설계의 전부다. - 응답 본문은 손대지 않는다. 기존 목록 엔드포인트는 전부 JSON 배열을 그대로 내보내므로 {items, nextCursor} 로 감싸는 순간 FE 가 즉시 깨진다. 커서는 X-Qeploy-Next-Cursor 응답 헤더로만 싣고, 헤더를 모르는 기존 FE 는 예전과 똑같이 동작한다 - 브라우저는 노출 목록에 없는 응답 헤더를 JS 에 보여주지 않으므로 CORS 에 setExposedHeaders 한 줄이 필요하다(allowedHeaders 는 요청 헤더 쪽이라 무관) - "더 있는지" 는 count 쿼리로 묻지 않고 limit+1 건을 읽어 판단한다 — 같은 조건을 두 번 훑는 비용인데, 이 작업의 목적 자체가 읽는 양을 줄이는 것이다 - 잘못된 커서는 400 으로 끊는다. 조용히 첫 페이지로 되돌리면 클라이언트는 자기 페이지 루프가 끝나지 않는 이유를 알 수 없다 Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../dvely/common/paging/CursorPage.java | 25 ++++++++ .../dvely/common/paging/CursorPaging.java | 62 +++++++++++++++++++ .../dvely/common/paging/CursorResponse.java | 29 +++++++++ .../example/dvely/config/SecurityConfig.java | 5 ++ 4 files changed, 121 insertions(+) create mode 100644 src/main/java/com/example/dvely/common/paging/CursorPage.java create mode 100644 src/main/java/com/example/dvely/common/paging/CursorPaging.java create mode 100644 src/main/java/com/example/dvely/common/paging/CursorResponse.java diff --git a/src/main/java/com/example/dvely/common/paging/CursorPage.java b/src/main/java/com/example/dvely/common/paging/CursorPage.java new file mode 100644 index 00000000..3ca0054f --- /dev/null +++ b/src/main/java/com/example/dvely/common/paging/CursorPage.java @@ -0,0 +1,25 @@ +package com.example.dvely.common.paging; + +import java.util.List; + +/** + * 커서 페이지네이션 한 페이지. U6(#341) 6-3·6-4 에서 무제한 목록 조회에 상한을 두기 위해 도입했다. + * + *

응답 본문 모양은 바꾸지 않는다. 기존 목록 엔드포인트는 전부 JSON 배열을 그대로 내보내므로 + * 여기에 커서를 끼워 넣으면 FE 가 즉시 깨진다. 그래서 컨트롤러는 {@link #items()} 만 본문으로 내고 + * {@link #nextCursor()} 는 응답 헤더({@code X-Qeploy-Next-Cursor})로 싣는다 — 커서를 쓰지 않는 + * 기존 FE 는 헤더를 무시하면 예전과 똑같이 동작한다.

+ * + * @param items 이 페이지의 항목. 요청한(또는 기본) 상한 이하다 + * @param nextCursor 다음 페이지의 {@code after} 로 넘길 값. 더 볼 것이 없으면 null + */ +public record CursorPage(List items, String nextCursor) { + + public static CursorPage of(List items) { + return new CursorPage<>(items, null); + } + + public CursorPage map(java.util.function.Function mapper) { + return new CursorPage<>(items.stream().map(mapper).toList(), nextCursor); + } +} diff --git a/src/main/java/com/example/dvely/common/paging/CursorPaging.java b/src/main/java/com/example/dvely/common/paging/CursorPaging.java new file mode 100644 index 00000000..6c248119 --- /dev/null +++ b/src/main/java/com/example/dvely/common/paging/CursorPaging.java @@ -0,0 +1,62 @@ +package com.example.dvely.common.paging; + +import java.util.List; +import java.util.function.Function; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +/** + * 커서 페이지네이션 공통 규칙. U6(#341) 6-3·6-4. + * + *

"더 있는지"를 별도 count 쿼리로 묻지 않는다 — {@code limit + 1} 건을 읽어 한 건이 남으면 더 + * 있는 것이다. count 는 같은 조건을 두 번 훑는 비용인데, 이 작업의 목적 자체가 읽는 양을 줄이는 것이다. + */ +public final class CursorPaging { + + private CursorPaging() { + } + + /** {@code limit + 1} 건을 읽기 위한 Pageable. */ + public static Pageable probe(int limit) { + return PageRequest.of(0, limit + 1); + } + + /** + * {@code limit + 1} 건 읽어온 결과를 한 페이지로 자른다. 넘치면 마지막으로 살아남은 항목에서 + * 커서를 뽑아 싣는다. + */ + public static CursorPage slice(List probed, int limit, Function cursorOf) { + if (probed.size() <= limit) { + return CursorPage.of(probed); + } + List page = probed.subList(0, limit); + return new CursorPage<>(List.copyOf(page), cursorOf.apply(page.get(limit - 1))); + } + + /** + * 요청된 limit 를 [1, max] 로 자른다. 없거나 0 이하면 기본값. 기존 FE 는 limit 를 보내지 않으므로 + * 이 기본값이 곧 "예전 무제한 동작의 상한"이다. + */ + public static int clamp(Integer requested, int defaultLimit, int maxLimit) { + if (requested == null || requested <= 0) { + return defaultLimit; + } + return Math.min(requested, maxLimit); + } + + /** + * 커서를 id 로 되돌린다. 커서는 우리가 직접 만들어 내보낸 값이라 정상 흐름에서는 늘 숫자다 — + * 숫자가 아니면 클라이언트가 손댄 것이므로 400 으로 끊는다(조용히 첫 페이지로 되돌리면 + * 클라이언트는 자기 루프가 끝나지 않는 이유를 알 수 없다). + */ + public static Long parseCursor(String after) { + if (after == null || after.isBlank()) { + return null; + } + try { + return Long.parseLong(after.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("잘못된 커서입니다: " + after); + } + } +} diff --git a/src/main/java/com/example/dvely/common/paging/CursorResponse.java b/src/main/java/com/example/dvely/common/paging/CursorResponse.java new file mode 100644 index 00000000..0584c7a3 --- /dev/null +++ b/src/main/java/com/example/dvely/common/paging/CursorResponse.java @@ -0,0 +1,29 @@ +package com.example.dvely.common.paging; + +import java.util.List; +import org.springframework.http.ResponseEntity; + +/** + * {@link CursorPage} 를 HTTP 응답으로 내보내는 한 곳. U6(#341) 6-3·6-4. + * + *

본문은 예전과 똑같은 JSON 배열이다. 커서는 본문에 넣지 않고 {@value #NEXT_CURSOR_HEADER} + * 헤더로만 싣는다 — 배열을 객체로 감싸는 순간 기존 FE 가 전부 깨지기 때문이다. 커서를 모르는 + * 클라이언트는 헤더를 무시하면 되고, 그때 보이는 것은 "최신 상한 건수"다.

+ */ +public final class CursorResponse { + + /** 다음 페이지 커서. 더 볼 것이 없으면 헤더 자체가 없다(빈 문자열이 아니다). */ + public static final String NEXT_CURSOR_HEADER = "X-Qeploy-Next-Cursor"; + + private CursorResponse() { + } + + public static ResponseEntity> of(CursorPage page) { + if (page.nextCursor() == null) { + return ResponseEntity.ok(page.items()); + } + return ResponseEntity.ok() + .header(NEXT_CURSOR_HEADER, page.nextCursor()) + .body(page.items()); + } +} diff --git a/src/main/java/com/example/dvely/config/SecurityConfig.java b/src/main/java/com/example/dvely/config/SecurityConfig.java index d2a1dc6d..27be7a82 100644 --- a/src/main/java/com/example/dvely/config/SecurityConfig.java +++ b/src/main/java/com/example/dvely/config/SecurityConfig.java @@ -5,6 +5,7 @@ import com.example.dvely.auth.application.port.out.TokenPort; import com.example.dvely.apitoken.application.service.ApiTokenAuthenticator; import com.example.dvely.auth.infrastructure.config.security.JwtAuthenticationFilter; +import com.example.dvely.common.paging.CursorResponse; import com.example.dvely.common.response.ApiResponse; import com.example.dvely.common.response.ErrorCode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -115,6 +116,10 @@ public CorsConfigurationSource corsConfigurationSource(CorsProperties corsProper config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); + // U6(#341) 6-3·6-4: 커서 페이지네이션의 다음 커서. 브라우저는 노출 목록에 없는 응답 헤더를 + // JS 에 아예 보여주지 않으므로(allowedHeaders 는 요청 헤더 쪽이다) 여기 없으면 FE 가 커서를 + // 읽을 수 없다. 목록 응답 본문은 배열 그대로 두고 커서만 헤더로 내보내기 때문에 필요하다. + config.setExposedHeaders(List.of(CursorResponse.NEXT_CURSOR_HEADER)); // 프리뷰 게이트웨이 전용 CORS (Issue #108). CSP sandbox(#102)로 불투명 오리진이 된 // 프리뷰 문서의 module script 는 Origin: null 로 오는데, 위 FE 오리진 목록 기반 From 538513f4166536c9c390278862aa3034fb1818a1 Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:20:06 +0900 Subject: [PATCH 03/13] =?UTF-8?q?perf(change):=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20diff=5Ftext=20=EB=A5=BC?= =?UTF-8?q?=20=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로젝트 개요를 한 번 열면 그 프로젝트의 모든 diff 를 DB 에서 읽고 있었다. getProjectChanges 가 ChangeEntity 를 통째로 로드하는데 diff_text 는 MEDIUMTEXT, 행당 최대 1MB 다(ChangeService 가 그 상한으로 잘라 저장한다). 그런데 응답 DTO ChangeResult 에는 diff 가 없다 — 읽어서 그대로 버렸다. 개요·활동로그가 이 목록을 부른다. ChangeSummaryView 프로젝션으로 바꿨다. 뷰의 컬럼 = ChangeResult 의 필드 13개이고, diff_text 는 목록 경로에서 아예 SELECT 되지 않는다. 단건 조회(getDiff)는 그대로다. 6-4 의 상한·커서도 같은 두 메서드를 건드리므로 함께 넣는다: GET /api/v1/projects/{projectId}/changes 에 ?limit=(1~500, 기본 200)·?after= 를 옵션으로 받는다. 파라미터가 없으면 최신 200건 + X-Qeploy-Next-Cursor 헤더다. 개요·활동로그가 부르는 내부 경로도 같은 200 상한을 쓴다 — 두 화면 모두 최신순 목록을 합쳐 보여주므로 잘리는 쪽은 활동로그 맨 아래고, 변경 건은 사용자 요청 1회당 최대 1건이라 200 이면 최근 200번의 작업을 덮는다. 정렬에 id 를 tiebreaker 로 붙였다. created_at 이 DATETIME(초) 라 같은 초의 행 순서가 비결정적이었는데, 커서 페이지네이션에서 그건 행을 건너뛰거나 두 번 주는 버그가 된다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../application/service/ChangeService.java | 47 +++++++++++++++-- .../SpringDataChangeRepository.java | 52 ++++++++++++++++++- .../change/presentation/ChangeController.java | 21 +++++--- 3 files changed, 109 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/example/dvely/change/application/service/ChangeService.java b/src/main/java/com/example/dvely/change/application/service/ChangeService.java index 4919acd6..53dcb577 100644 --- a/src/main/java/com/example/dvely/change/application/service/ChangeService.java +++ b/src/main/java/com/example/dvely/change/application/service/ChangeService.java @@ -7,7 +7,10 @@ import com.example.dvely.change.application.result.ChangeResult; import com.example.dvely.change.infrastructure.persistence.entity.ChangeEntity; import com.example.dvely.change.infrastructure.persistence.repository.SpringDataChangeRepository; +import com.example.dvely.change.infrastructure.persistence.repository.SpringDataChangeRepository.ChangeSummaryView; import com.example.dvely.common.exception.NotFoundException; +import com.example.dvely.common.paging.CursorPage; +import com.example.dvely.common.paging.CursorPaging; import com.example.dvely.preview.application.result.PreviewSessionInfo; import com.example.dvely.preview.application.service.PreviewSessionService; import com.example.dvely.project.domain.exception.ProjectNotFoundException; @@ -125,14 +128,50 @@ public void markDeployed(String taskId) { }); } + /** + * limit 를 안 받는 호출(프로젝트 개요·활동로그)의 상한. 개요/활동로그는 최신순 목록을 그대로 + * 합쳐 보여주는 화면이라 오래된 꼬리가 잘려도 화면이 달라지지 않는다 — 잘리는 쪽은 활동로그 + * 맨 아래다. 변경 건은 사용자 요청 1회당 최대 1건씩 쌓이므로 200 이면 최근 200번의 작업을 덮는다. + */ + private static final int DEFAULT_CHANGE_LIMIT = 200; + private static final int MAX_CHANGE_LIMIT = 500; + @Transactional(readOnly = true) public List getProjectChanges(Long ownerUserId, Long projectId) { + return getProjectChanges(ownerUserId, projectId, null, null).items(); + } + + @Transactional(readOnly = true) + public CursorPage getProjectChanges(Long ownerUserId, + Long projectId, + Integer limit, + String after) { projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(projectId, ownerUserId) .orElseThrow(() -> new ProjectNotFoundException(projectId, ownerUserId)); - return changeRepository.findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId) - .stream() - .map(ChangeEntity::toResult) - .toList(); + int size = CursorPaging.clamp(limit, DEFAULT_CHANGE_LIMIT, MAX_CHANGE_LIMIT); + List probed = changeRepository.findProjectChangeSummaries( + projectId, ownerUserId, CursorPaging.parseCursor(after), CursorPaging.probe(size)); + return CursorPaging.slice(probed, size, view -> String.valueOf(view.getId())) + .map(ChangeService::toResult); + } + + /** 프로젝션 → ChangeResult. 필드 순서·값은 {@code ChangeEntity#toResult} 와 1:1 이다. */ + private static ChangeResult toResult(ChangeSummaryView view) { + return new ChangeResult( + view.getId(), + view.getProjectId(), + view.getConversationId(), + view.getTaskId(), + view.getPreviewSessionId(), + view.getStatus(), + view.getSummary(), + view.getApprovalId(), + view.getPrNumber(), + view.getMergeCommitSha(), + view.getMergedAt(), + view.getCreatedAt(), + view.getUpdatedAt() + ); } @Transactional(readOnly = true) diff --git a/src/main/java/com/example/dvely/change/infrastructure/persistence/repository/SpringDataChangeRepository.java b/src/main/java/com/example/dvely/change/infrastructure/persistence/repository/SpringDataChangeRepository.java index 8d76a880..2a6129a3 100644 --- a/src/main/java/com/example/dvely/change/infrastructure/persistence/repository/SpringDataChangeRepository.java +++ b/src/main/java/com/example/dvely/change/infrastructure/persistence/repository/SpringDataChangeRepository.java @@ -1,10 +1,14 @@ package com.example.dvely.change.infrastructure.persistence.repository; import com.example.dvely.change.infrastructure.persistence.entity.ChangeEntity; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface SpringDataChangeRepository extends JpaRepository { @@ -12,7 +16,53 @@ public interface SpringDataChangeRepository extends JpaRepository findByIdAndOwnerUserId(Long changeId, Long ownerUserId); - List findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId); + // U6 6-1: 목록은 프로젝션으로만 읽는다. 엔티티로 읽으면 diff_text(MEDIUMTEXT, 행당 최대 1MB)까지 + // 함께 실려 오는데 ChangeResult 에는 diff 가 없다 — 프로젝트 개요를 한 번 열면 그 프로젝트의 + // 모든 diff 를 DB 에서 끌어오고 그대로 버리고 있었다. 아래 뷰의 컬럼 = ChangeResult 의 필드다. + interface ChangeSummaryView { + Long getId(); + Long getProjectId(); + Long getConversationId(); + String getTaskId(); + String getPreviewSessionId(); + String getStatus(); + String getSummary(); + Long getApprovalId(); + Integer getPrNumber(); + String getMergeCommitSha(); + LocalDateTime getMergedAt(); + LocalDateTime getCreatedAt(); + LocalDateTime getUpdatedAt(); + } + + // createdAt 뒤에 id 를 덧붙여 정렬한다 — created_at 이 같은 초인 행들의 순서가 비결정적이면 + // 커서(:after) 페이지네이션이 같은 행을 건너뛰거나 두 번 준다. + @Query(""" + select c.id as id, + c.projectId as projectId, + c.conversationId as conversationId, + c.taskId as taskId, + c.previewSessionId as previewSessionId, + c.status as status, + c.summary as summary, + c.approvalId as approvalId, + c.prNumber as prNumber, + c.mergeCommitSha as mergeCommitSha, + c.mergedAt as mergedAt, + c.createdAt as createdAt, + c.updatedAt as updatedAt + from ChangeEntity c + where c.projectId = :projectId + and c.ownerUserId = :ownerUserId + and (:after is null or c.id < :after) + order by c.createdAt desc, c.id desc + """) + List findProjectChangeSummaries( + @Param("projectId") Long projectId, + @Param("ownerUserId") Long ownerUserId, + @Param("after") Long after, + Pageable pageable + ); // Track Z (#56) review follow-up (BLOCKING-1): backs ResultApprovalService#hasResultGateHistory // — a project-scoped existence check (any status, not just the current task's own Change row) diff --git a/src/main/java/com/example/dvely/change/presentation/ChangeController.java b/src/main/java/com/example/dvely/change/presentation/ChangeController.java index b2d97ca9..f8ae08da 100644 --- a/src/main/java/com/example/dvely/change/presentation/ChangeController.java +++ b/src/main/java/com/example/dvely/change/presentation/ChangeController.java @@ -3,13 +3,17 @@ import com.example.dvely.change.application.service.ChangeService; import com.example.dvely.change.presentation.dto.ChangeDiffResponse; import com.example.dvely.change.presentation.dto.ChangeResponse; +import com.example.dvely.common.paging.CursorResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "Change", description = "Agent CODE 작업 결과(코드 변경) 조회 API. 변경 상태와 diff를 제공합니다.") @@ -21,16 +25,21 @@ public class ChangeController { @Operation( summary = "프로젝트 Change 목록 조회", - description = "프로젝트에서 실행된 모든 Agent CODE 작업(코드 변경) 이력을 조회합니다." + description = "프로젝트에서 실행된 Agent CODE 작업(코드 변경) 이력을 최신순으로 조회합니다. " + + "limit 를 주지 않으면 최신 200건까지 내려가고, 더 있으면 응답 헤더 " + + "X-Qeploy-Next-Cursor 에 다음 커서가 실립니다(after 로 넘겨 이어 받습니다)." ) @GetMapping("/api/v1/projects/{projectId}/changes") - public List getProjectChanges( + public ResponseEntity> getProjectChanges( @AuthenticationPrincipal Long ownerUserId, - @PathVariable Long projectId + @PathVariable Long projectId, + @Parameter(description = "한 번에 받을 최대 건수(1~500). 없으면 200") @RequestParam(required = false) Integer limit, + @Parameter(description = "이전 페이지의 X-Qeploy-Next-Cursor 값. 그 항목보다 오래된 건만 반환") + @RequestParam(required = false) String after ) { - return changeService.getProjectChanges(ownerUserId, projectId).stream() - .map(ChangeResponse::from) - .toList(); + return CursorResponse.of( + changeService.getProjectChanges(ownerUserId, projectId, limit, after) + .map(ChangeResponse::from)); } @Operation( From a4f40d2d7f137d5eeab17caa09a55ae5f284fe1d Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:26:30 +0900 Subject: [PATCH 04/13] =?UTF-8?q?perf(secret):=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B9=84=EB=B0=80=20=EC=BB=AC=EB=9F=BC?= =?UTF-8?q?=EC=9D=84=20=EC=95=84=EC=98=88=20=EC=9D=BD=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 목록이 응답에 실리지도 않는 비밀 컬럼을 행마다 AES 복호화하고 있었다. 전부 MEDIUMTEXT + @Convert(AesEncryptor) 다. - 클라우드 연결 목록: secret_access_key·session_01APAyBZVYxUZzZsVyEXy6Qr·service_account_key_json 세 개를 읽어 평문을 얻고는 != null 세 개의 boolean 으로 바꿔 버렸다. 이제 쿼리가 is not null 만 묻는다 — 값이 아니라 널 여부만 보므로 off-page TEXT 본문을 읽지 않는다 - 환경변수 목록: 조회는 secret 변수의 값을 언제나 null 로 내보내는데(design D4) 그 평문을 읽어와 버렸다. 메타데이터를 env_value 없이 읽고, 평문은 secret=false 인 행에 대해서만 따로 한 번 더 읽는 두 단계로 나눴다 - 프로비저닝 DB 목록: password 는 조회 응답에 계약상 없는데(생성 직후 1회 노출만 별도 경로) 매 행 복호화됐다. 읽기 모델에 그 필드 자체를 두지 않았다 **기존 보장은 약해지지 않고 강해진다.** 예전에는 "평문을 읽어 응답 매핑에서 지운다" 였고, 매핑 한 줄이 틀리면 그게 유출이었다. 이제 비밀 평문은 목록 경로의 메모리에 올라오지 않는다 — 읽기 모델에 담을 필드가 없어 컴파일 단계에서 막힌다. 각 읽기 모델 javadoc 에 "여기에 비밀 필드를 추가하지 말 것" 을 근거와 함께 남겼고, EnvironmentVariableQueryServiceTest 에 "읽지 않는다" 와 "설령 읽혀도 안 내보낸다" 두 방어선을 각각 테스트로 못박았다. 응답 JSON 은 바뀌지 않는다. 상태·타깃류 문자열 컬럼은 DB 도 응답도 문자열이라 중간의 enum 왕복만 없앴다. 6-4 의 환경변수 목록 상한도 같은 메서드를 건드리므로 함께 넣는다: ?limit=(기본 200, 최대 500). 환경변수는 사람이 직접 정의하는 값이라 200 이면 정상 사용을 덮고, 정렬 키가 (scope, key) 라 id 커서와 맞지 않아 커서로 이어 받는 경로는 두지 않았다(상한 도달만 헤더로 알린다). Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../query/CloudConnectionQueryService.java | 34 +++++++++- .../repository/CloudConnectionRepository.java | 3 + .../CloudConnectionSummaryView.java | 36 ++++++++++ .../CloudConnectionRepositoryAdapter.java | 6 ++ .../SpringDataCloudConnectionRepository.java | 22 ++++++ .../facade/EnvironmentVariableFacade.java | 8 ++- .../EnvironmentVariableQueryService.java | 54 +++++++++++++-- .../EnvironmentVariableRepository.java | 14 ++++ .../EnvironmentVariableSummaryView.java | 25 +++++++ .../EnvironmentVariableRepositoryAdapter.java | 26 +++++++ ...ringDataEnvironmentVariableRepository.java | 27 ++++++++ .../EnvironmentVariableController.java | 19 ++++-- .../DatabaseProvisioningQueryService.java | 2 +- .../result/ProvisionedDatabaseResult.java | 18 +++-- .../ProvisionedDatabaseListView.java | 31 +++++++++ .../ProvisionedDatabaseRepository.java | 7 +- .../ProvisionedDatabaseRepositoryAdapter.java | 7 +- ...ringDataProvisionedDatabaseRepository.java | 19 ++++++ .../EnvironmentVariableQueryServiceTest.java | 67 ++++++++++++++++--- .../EnvironmentVariableControllerTest.java | 11 +-- ...vironmentVariableResponseContractTest.java | 5 +- 21 files changed, 396 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionSummaryView.java create mode 100644 src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableSummaryView.java create mode 100644 src/main/java/com/example/dvely/provisioning/domain/repository/ProvisionedDatabaseListView.java diff --git a/src/main/java/com/example/dvely/cloudconnection/application/query/CloudConnectionQueryService.java b/src/main/java/com/example/dvely/cloudconnection/application/query/CloudConnectionQueryService.java index 69c95f9f..c666464f 100644 --- a/src/main/java/com/example/dvely/cloudconnection/application/query/CloudConnectionQueryService.java +++ b/src/main/java/com/example/dvely/cloudconnection/application/query/CloudConnectionQueryService.java @@ -6,6 +6,9 @@ import com.example.dvely.cloudconnection.domain.model.CloudConnection; import com.example.dvely.cloudconnection.domain.model.CloudConnectionVerificationJob; import com.example.dvely.cloudconnection.domain.repository.CloudConnectionRepository; +import com.example.dvely.cloudconnection.domain.repository.CloudConnectionSummaryView; +import com.example.dvely.cloudconnection.domain.value.CloudConnectionStatus; +import com.example.dvely.cloudconnection.domain.value.CloudProvider; import com.example.dvely.cloudconnection.domain.repository.CloudConnectionVerificationJobRepository; import com.example.dvely.common.exception.NotFoundException; import java.util.List; @@ -22,11 +25,38 @@ public class CloudConnectionQueryService { private final CloudConnectionVerificationJobRepository verificationJobRepository; public List getCloudConnections(Long ownerUserId) { - return cloudConnectionRepository.findAllByOwnerUserIdOrderByCreatedAtDesc(ownerUserId).stream() - .map(this::toResult) + return cloudConnectionRepository.findSummariesByOwnerUserIdOrderByCreatedAtDesc(ownerUserId).stream() + .map(CloudConnectionQueryService::toResult) .toList(); } + /** + * 읽기 모델 → 응답. 비밀 세 개는 뷰가 이미 boolean 으로만 들고 있어, 여기서 평문을 볼 방법이 + * 없다 — 예전의 "평문을 읽어 != null 로 바꾼 뒤 버린다" 를 구조적으로 대체한다. + */ + private static CloudConnectionResult toResult(CloudConnectionSummaryView view) { + return new CloudConnectionResult( + view.id(), + CloudProvider.valueOf(view.provider()), + view.displayName(), + view.accountId(), + view.region(), + view.roleArn(), + view.awsCredentialType(), + view.accessKeyId(), + view.secretAccessKeyConfigured(), + view.sessionTokenConfigured(), + view.gcpCredentialType(), + view.serviceAccountKeyConfigured(), + view.gcpProjectId(), + view.serviceAccountEmail(), + CloudConnectionStatus.valueOf(view.status()), + view.lastCheckedAt(), + view.createdAt(), + view.updatedAt() + ); + } + public CloudConnectionResult getCloudConnection(Long ownerUserId, Long cloudConnectionId) { return toResult(resolveCloudConnection(ownerUserId, cloudConnectionId)); } diff --git a/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionRepository.java b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionRepository.java index 6bb85abd..ef5f7952 100644 --- a/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionRepository.java +++ b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionRepository.java @@ -11,6 +11,9 @@ public interface CloudConnectionRepository { List findAllByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId); + /** U6 6-5: 목록 조회 전용. 비밀 컬럼을 읽지 않는다 — {@link CloudConnectionSummaryView} 참고. */ + List findSummariesByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId); + /** 특정 프로바이더의 모든 연결(소유자 무관). 고아 자원 스윕이 전 계정을 훑을 때 쓴다(예: AWS=CloudFront). */ List findAllByProvider(CloudProvider provider); diff --git a/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionSummaryView.java b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionSummaryView.java new file mode 100644 index 00000000..991bf8d7 --- /dev/null +++ b/src/main/java/com/example/dvely/cloudconnection/domain/repository/CloudConnectionSummaryView.java @@ -0,0 +1,36 @@ +package com.example.dvely.cloudconnection.domain.repository; + +import java.time.LocalDateTime; + +/** + * 클라우드 연결 목록 전용 읽기 모델. U6(#341) 6-5. + * + *

비밀 값이 이 레코드에 들어올 자리는 없다. {@code secret_access_key}·{@code session_token}· + * {@code service_account_key_json} 은 MEDIUMTEXT 이고 {@code @Convert(AesEncryptor)} 가 붙어 있어 + * 엔티티로 읽으면 행마다 AES 복호화가 돈다. 그런데 목록 응답이 쓰는 것은 "설정돼 있는가" + * 세 개의 boolean 뿐이다 — 그래서 쿼리가 {@code is not null} 만 묻고 값 자체를 읽지 않는다.

+ * + *

평문이 응답에서 걸러지는 것에 의존하던 예전 구조보다 강하다: 평문이 애초에 메모리에 + * 올라오지 않는다. 여기에 비밀 컬럼을 담는 필드를 추가하지 말 것 — 그 순간 유출 경로가 생긴다.

+ */ +public record CloudConnectionSummaryView( + Long id, + String provider, + String displayName, + String accountId, + String region, + String roleArn, + String awsCredentialType, + String accessKeyId, + boolean secretAccessKeyConfigured, + boolean sessionTokenConfigured, + String gcpCredentialType, + boolean serviceAccountKeyConfigured, + String gcpProjectId, + String serviceAccountEmail, + String status, + LocalDateTime lastCheckedAt, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionRepositoryAdapter.java b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionRepositoryAdapter.java index d232e5ad..65f93927 100644 --- a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/CloudConnectionRepositoryAdapter.java @@ -2,6 +2,7 @@ import com.example.dvely.cloudconnection.domain.model.CloudConnection; import com.example.dvely.cloudconnection.domain.repository.CloudConnectionRepository; +import com.example.dvely.cloudconnection.domain.repository.CloudConnectionSummaryView; import com.example.dvely.cloudconnection.infrastructure.persistence.entity.CloudConnectionEntity; import java.util.List; import java.util.Optional; @@ -40,6 +41,11 @@ public List findAllByOwnerUserIdOrderByCreatedAtDesc(Long owner .toList(); } + @Override + public List findSummariesByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId) { + return springDataRepository.findSummariesByOwnerUserId(ownerUserId); + } + @Override public Optional findByIdAndOwnerUserId(Long id, Long ownerUserId) { return springDataRepository.findByIdAndOwnerUserId(id, ownerUserId) diff --git a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionRepository.java b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionRepository.java index f7274688..120f850a 100644 --- a/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionRepository.java +++ b/src/main/java/com/example/dvely/cloudconnection/infrastructure/persistence/repository/SpringDataCloudConnectionRepository.java @@ -1,14 +1,36 @@ package com.example.dvely.cloudconnection.infrastructure.persistence.repository; +import com.example.dvely.cloudconnection.domain.repository.CloudConnectionSummaryView; import com.example.dvely.cloudconnection.infrastructure.persistence.entity.CloudConnectionEntity; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface SpringDataCloudConnectionRepository extends JpaRepository { List findAllByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId); + // U6 6-5: 목록은 비밀 컬럼을 읽지 않는다. 세 개의 MEDIUMTEXT 는 is not null 로만 묻는다 — + // 엔티티로 읽으면 @Convert(AesEncryptor) 가 행마다 복호화를 돌리는데 응답에는 boolean 세 개만 + // 나간다. is not null 은 값이 아니라 널 여부만 보므로 off-page TEXT 본문을 읽지 않는다. + @Query(""" + select new com.example.dvely.cloudconnection.domain.repository.CloudConnectionSummaryView( + c.id, c.provider, c.displayName, c.accountId, c.region, c.roleArn, + c.awsCredentialType, c.accessKeyId, + case when c.secretAccessKey is not null then true else false end, + case when c.sessionToken is not null then true else false end, + c.gcpCredentialType, + case when c.serviceAccountKeyJson is not null then true else false end, + c.gcpProjectId, c.serviceAccountEmail, c.status, c.lastCheckedAt, + c.createdAt, c.updatedAt) + from CloudConnectionEntity c + where c.ownerUserId = :ownerUserId + order by c.createdAt desc, c.id desc + """) + List findSummariesByOwnerUserId(@Param("ownerUserId") Long ownerUserId); + // provider 는 String 컬럼(CloudConnectionEntity:35). List findAllByProvider(String provider); diff --git a/src/main/java/com/example/dvely/environment/application/facade/EnvironmentVariableFacade.java b/src/main/java/com/example/dvely/environment/application/facade/EnvironmentVariableFacade.java index 2c6265a0..2a9d85f3 100644 --- a/src/main/java/com/example/dvely/environment/application/facade/EnvironmentVariableFacade.java +++ b/src/main/java/com/example/dvely/environment/application/facade/EnvironmentVariableFacade.java @@ -1,6 +1,7 @@ package com.example.dvely.environment.application.facade; import com.example.dvely.environment.application.command.EnvironmentVariableCommandService; +import com.example.dvely.common.paging.CursorPage; import com.example.dvely.environment.application.query.EnvironmentVariableQueryService; import com.example.dvely.environment.application.result.EnvironmentVariableHistoryResult; import com.example.dvely.environment.application.result.EnvironmentVariableResult; @@ -15,8 +16,11 @@ public class EnvironmentVariableFacade { private final EnvironmentVariableQueryService queryService; private final EnvironmentVariableCommandService commandService; - public List getVariables(Long userId, Long projectId, String scope) { - return queryService.getVariables(userId, projectId, scope); + public CursorPage getVariables(Long userId, + Long projectId, + String scope, + Integer limit) { + return queryService.getVariables(userId, projectId, scope, limit); } public List getHistory(Long userId, Long projectId, Integer limit) { diff --git a/src/main/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryService.java b/src/main/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryService.java index b2b8cc64..11379519 100644 --- a/src/main/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryService.java +++ b/src/main/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryService.java @@ -1,16 +1,20 @@ package com.example.dvely.environment.application.query; import com.example.dvely.common.exception.NotFoundException; +import com.example.dvely.common.paging.CursorPage; +import com.example.dvely.common.paging.CursorPaging; import com.example.dvely.environment.application.result.EnvironmentVariableHistoryResult; import com.example.dvely.environment.application.result.EnvironmentVariableResult; import com.example.dvely.environment.domain.model.EnvironmentVariable; import com.example.dvely.environment.domain.model.EnvironmentVariableHistory; import com.example.dvely.environment.domain.repository.EnvironmentVariableHistoryRepository; import com.example.dvely.environment.domain.repository.EnvironmentVariableRepository; +import com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView; import com.example.dvely.environment.domain.value.EnvironmentScope; import com.example.dvely.project.domain.exception.ProjectNotFoundException; import com.example.dvely.project.domain.repository.ProjectRepository; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,16 +33,41 @@ public class EnvironmentVariableQueryService { private static final int DEFAULT_HISTORY_LIMIT = 50; private static final int MAX_HISTORY_LIMIT = 200; + /** + * 변수 목록의 상한. 환경변수는 사람이 직접 정의하는 값이라 프로젝트당 수십 개가 현실적인 상한이고, + * 200 을 넘는 것은 정상 사용이 아니다 — 그래서 커서로 이어 받는 대신 상한만 둔다(정렬 키가 + * (scope, key) 라 id 커서와 맞지 않는 것도 이유다. 필요해지면 복합 커서로 확장한다). + */ + private static final int DEFAULT_VARIABLE_LIMIT = 200; + private static final int MAX_VARIABLE_LIMIT = 500; + private final EnvironmentVariableRepository environmentVariableRepository; private final EnvironmentVariableHistoryRepository environmentVariableHistoryRepository; private final ProjectRepository projectRepository; public List getVariables(Long userId, Long projectId, String scopeParam) { + return getVariables(userId, projectId, scopeParam, null).items(); + } + + /** + * 변수 목록. U6 6-5 로 값을 두 번에 나눠 읽는다: 메타데이터는 env_value 없이 프로젝션으로, + * 평문은 secret 이 아닌 행에 대해서만 따로. 응답은 예전과 같다(secret 이면 value=null) — + * 달라진 것은 비밀 평문이 애초에 읽히지 않는다는 점이다. + */ + public CursorPage getVariables(Long userId, + Long projectId, + String scopeParam, + Integer limitParam) { assertProjectOwner(userId, projectId); - List variables = scopeParam == null - ? environmentVariableRepository.findByProjectIdOrderByScopeAscKeyAsc(projectId) - : environmentVariableRepository.findByProjectIdAndScopeOrderByKeyAsc(projectId, parseScope(scopeParam)); - return variables.stream().map(this::toResult).toList(); + EnvironmentScope scope = scopeParam == null ? null : parseScope(scopeParam); + int size = CursorPaging.clamp(limitParam, DEFAULT_VARIABLE_LIMIT, MAX_VARIABLE_LIMIT); + List probed = + environmentVariableRepository.findSummaries(projectId, scope, size + 1); + CursorPage page = CursorPaging.slice( + probed, size, view -> String.valueOf(view.id())); + Map plainValues = environmentVariableRepository.findPlainValuesByIds( + page.items().stream().map(EnvironmentVariableSummaryView::id).toList()); + return page.map(view -> toResult(view, plainValues)); } public List getHistory(Long userId, Long projectId, Integer limitParam) { @@ -76,6 +105,23 @@ public static EnvironmentScope parseScope(String raw) { } } + /** + * U6 6-5: 목록 경로의 매핑. secret 인 변수는 {@code plainValues} 에 애초에 들어오지 않으므로 + * value 가 null 이 된다 — 마스킹이 아니라 "읽지 않았다" 다. 결과 JSON 은 design D4 와 같다. + */ + private EnvironmentVariableResult toResult(EnvironmentVariableSummaryView view, + Map plainValues) { + return new EnvironmentVariableResult( + view.id(), + view.scope(), + view.key(), + view.secret() ? null : plainValues.get(view.id()), + view.secret(), + view.createdAt(), + view.updatedAt() + ); + } + /** Secret masking happens exactly here: {@code secret ? null : plaintext}, per design D4. */ public EnvironmentVariableResult toResult(EnvironmentVariable variable) { return new EnvironmentVariableResult( diff --git a/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableRepository.java b/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableRepository.java index 41a20544..132ffc65 100644 --- a/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableRepository.java +++ b/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableRepository.java @@ -2,7 +2,9 @@ import com.example.dvely.environment.domain.model.EnvironmentVariable; import com.example.dvely.environment.domain.value.EnvironmentScope; +import java.util.Collection; import java.util.List; +import java.util.Map; import java.util.Optional; public interface EnvironmentVariableRepository { @@ -17,5 +19,17 @@ public interface EnvironmentVariableRepository { List findByProjectIdAndScopeOrderByKeyAsc(Long projectId, EnvironmentScope scope); + /** + * U6 6-5: 목록 조회 전용 — 값(env_value)을 읽지 않는다. {@code scope} 가 null 이면 전 스코프이고, + * 정렬은 (scope asc, key asc) 다. {@link EnvironmentVariableSummaryView} 참고. + */ + List findSummaries(Long projectId, EnvironmentScope scope, int limit); + + /** + * U6 6-5: 주어진 id 중 secret 이 아닌 행의 평문 값만. secret 인 행은 조건에서 빠져 AES + * 복호화가 돌지 않는다 — 비밀 평문이 메모리에 올라오는 경로 자체를 없애기 위한 분리다. + */ + Map findPlainValuesByIds(Collection ids); + void deleteById(Long id); } diff --git a/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableSummaryView.java b/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableSummaryView.java new file mode 100644 index 00000000..2e44b1f0 --- /dev/null +++ b/src/main/java/com/example/dvely/environment/domain/repository/EnvironmentVariableSummaryView.java @@ -0,0 +1,25 @@ +package com.example.dvely.environment.domain.repository; + +import java.time.LocalDateTime; + +/** + * 환경변수 목록 전용 읽기 모델. U6(#341) 6-5. + * + *

값(env_value)이 없다. 그 컬럼은 MEDIUMTEXT + {@code @Convert(AesEncryptor)} 라 엔티티로 + * 읽으면 행마다 AES 복호화가 돈다. 그런데 목록 응답은 secret 인 변수의 값을 언제나 null 로 내보낸다 + * (design D4) — 복호화해서 버리고 있었다.

+ * + *

그래서 값은 이 뷰에 담지 않고, secret 이 아닌 행에 대해서만 따로 한 번 더 읽는다 + * ({@code EnvironmentVariableRepository#findPlainValuesByIds}). 비밀 값은 애초에 메모리에 올라오지 + * 않으므로, "응답 매핑에서 마스킹한다" 에 의존하던 예전 구조보다 강하다 — 여기에 value 필드를 + * 추가하지 말 것.

+ */ +public record EnvironmentVariableSummaryView( + Long id, + String scope, + String key, + boolean secret, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/EnvironmentVariableRepositoryAdapter.java b/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/EnvironmentVariableRepositoryAdapter.java index d0b34325..46329551 100644 --- a/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/EnvironmentVariableRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/EnvironmentVariableRepositoryAdapter.java @@ -2,11 +2,16 @@ import com.example.dvely.environment.domain.model.EnvironmentVariable; import com.example.dvely.environment.domain.repository.EnvironmentVariableRepository; +import com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView; import com.example.dvely.environment.domain.value.EnvironmentScope; import com.example.dvely.environment.infrastructure.persistence.entity.EnvironmentVariableEntity; +import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @Repository @@ -63,6 +68,27 @@ public List findByProjectIdAndScopeOrderByKeyAsc(Long proje .toList(); } + @Override + public List findSummaries(Long projectId, EnvironmentScope scope, int limit) { + return springDataRepository.findSummaries( + projectId, scope == null ? null : scope.name(), PageRequest.of(0, limit)); + } + + @Override + public Map findPlainValuesByIds(Collection ids) { + if (ids.isEmpty()) { + return Map.of(); + } + Map values = new LinkedHashMap<>(); + for (Object[] row : springDataRepository.findPlainValuesByIds(ids)) { + // 값이 NULL 인 행은 스킵한다 — Map#put 은 null 을 담지만 "값이 없다" 와 구분이 안 된다. + if (row[1] != null) { + values.put((Long) row[0], (String) row[1]); + } + } + return values; + } + @Override public void deleteById(Long id) { springDataRepository.deleteById(id); diff --git a/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/SpringDataEnvironmentVariableRepository.java b/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/SpringDataEnvironmentVariableRepository.java index 69e2df1a..32f9dc47 100644 --- a/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/SpringDataEnvironmentVariableRepository.java +++ b/src/main/java/com/example/dvely/environment/infrastructure/persistence/repository/SpringDataEnvironmentVariableRepository.java @@ -1,9 +1,14 @@ package com.example.dvely.environment.infrastructure.persistence.repository; +import com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView; import com.example.dvely.environment.infrastructure.persistence.entity.EnvironmentVariableEntity; +import java.util.Collection; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface SpringDataEnvironmentVariableRepository extends JpaRepository { @@ -14,4 +19,26 @@ public interface SpringDataEnvironmentVariableRepository extends JpaRepository findByProjectIdOrderByScopeAscKeyAsc(Long projectId); List findByProjectIdAndScopeOrderByKeyAsc(Long projectId, String scope); + + // U6 6-5: 목록은 env_value 를 읽지 않는다. scope 가 null 이면 전체 스코프. + @Query(""" + select new com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView( + e.id, e.scope, e.key, e.secret, e.createdAt, e.updatedAt) + from EnvironmentVariableEntity e + where e.projectId = :projectId + and (:scope is null or e.scope = :scope) + order by e.scope asc, e.key asc + """) + List findSummaries( + @Param("projectId") Long projectId, @Param("scope") String scope, Pageable pageable); + + // U6 6-5: 응답에 실제로 실리는 평문만. secret 인 행은 조건에서 빠지므로 AES 복호화가 아예 돌지 + // 않는다 — 목록 페이지에 든 id 로만 좁혀, 페이지 밖 행의 값도 읽지 않는다. + @Query(""" + select e.id, e.value + from EnvironmentVariableEntity e + where e.id in :ids + and e.secret = false + """) + List findPlainValuesByIds(@Param("ids") Collection ids); } diff --git a/src/main/java/com/example/dvely/environment/presentation/EnvironmentVariableController.java b/src/main/java/com/example/dvely/environment/presentation/EnvironmentVariableController.java index de9838c9..626beb9b 100644 --- a/src/main/java/com/example/dvely/environment/presentation/EnvironmentVariableController.java +++ b/src/main/java/com/example/dvely/environment/presentation/EnvironmentVariableController.java @@ -1,5 +1,6 @@ package com.example.dvely.environment.presentation; +import com.example.dvely.common.paging.CursorResponse; import com.example.dvely.environment.application.facade.EnvironmentVariableFacade; import com.example.dvely.environment.application.result.EnvironmentVariableHistoryResult; import com.example.dvely.environment.application.result.EnvironmentVariableResult; @@ -15,6 +16,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -36,18 +38,23 @@ public class EnvironmentVariableController { @Operation( summary = "환경변수 목록 조회", description = "scope 쿼리 파라미터로 필터링(생략 시 전체, scope asc → key asc 정렬). " + - "secret 값은 응답에 포함되지 않습니다(value=null)." + "secret 값은 응답에 포함되지 않습니다(value=null). " + + "limit 기본 200, 최대 500(초과 시 500으로 보정). 상한에 걸리면 응답 헤더 " + + "X-Qeploy-Next-Cursor 가 붙습니다 — 이 목록은 정렬 키가 (scope, key) 라 " + + "커서로 이어 받는 경로는 아직 없고, 상한 도달 자체를 알리는 신호입니다." ) @GetMapping("/api/v1/projects/{projectId}/environment-variables") - public List getVariables( + public ResponseEntity> getVariables( @AuthenticationPrincipal Long userId, @PathVariable Long projectId, @Parameter(description = "필터링할 스코프. 생략 시 전체 조회", schema = @Schema(allowableValues = {"PREVIEW", "PRODUCTION"})) - @RequestParam(required = false) String scope + @RequestParam(required = false) String scope, + @Parameter(description = "조회 개수. 기본 200, 최대 500(초과 시 500으로 보정)") + @RequestParam(required = false) Integer limit ) { - return environmentVariableFacade.getVariables(userId, projectId, scope).stream() - .map(this::toResponse) - .toList(); + return CursorResponse.of( + environmentVariableFacade.getVariables(userId, projectId, scope, limit) + .map(this::toResponse)); } @Operation( diff --git a/src/main/java/com/example/dvely/provisioning/application/query/DatabaseProvisioningQueryService.java b/src/main/java/com/example/dvely/provisioning/application/query/DatabaseProvisioningQueryService.java index 52d28ff6..c99835c1 100644 --- a/src/main/java/com/example/dvely/provisioning/application/query/DatabaseProvisioningQueryService.java +++ b/src/main/java/com/example/dvely/provisioning/application/query/DatabaseProvisioningQueryService.java @@ -26,7 +26,7 @@ public List list(Long ownerUserId, Long projectId) { // EXPIRED 는 DB 단에서 제외한다. 프리뷰 30분 TTL 이라 하루면 수십 개가 쌓이는데, 그걸 다 // 내려주면 "지금 쓸 수 있는 DB"가 지나간 것들에 묻힌다. 행은 감사·이력으로 남기되(워커가 // EXPIRED 로 상태만 넘김) 목록에는 활성 자원만 준다. - return databaseRepository.findActiveByProjectIdOrderByCreatedAtDesc(projectId) + return databaseRepository.findActiveListViewsByProjectId(projectId) .stream().map(ProvisionedDatabaseResult::from).toList(); } diff --git a/src/main/java/com/example/dvely/provisioning/application/result/ProvisionedDatabaseResult.java b/src/main/java/com/example/dvely/provisioning/application/result/ProvisionedDatabaseResult.java index 5c2b4077..8cbd23e1 100644 --- a/src/main/java/com/example/dvely/provisioning/application/result/ProvisionedDatabaseResult.java +++ b/src/main/java/com/example/dvely/provisioning/application/result/ProvisionedDatabaseResult.java @@ -1,6 +1,6 @@ package com.example.dvely.provisioning.application.result; -import com.example.dvely.provisioning.domain.model.ProvisionedDatabase; +import com.example.dvely.provisioning.domain.repository.ProvisionedDatabaseListView; import java.time.LocalDateTime; /** @@ -24,12 +24,16 @@ public record ProvisionedDatabaseResult( LocalDateTime createdAt, LocalDateTime updatedAt ) { - public static ProvisionedDatabaseResult from(ProvisionedDatabase d) { + /** + * U6 6-5: password 를 읽지 않는 읽기 모델에서 만든다. method·engine·origin·status·failureCode 는 + * DB 도 응답도 문자열이라 중간에 enum 으로 되돌리지 않는다 — 값은 예전과 같다. + */ + public static ProvisionedDatabaseResult from(ProvisionedDatabaseListView d) { return new ProvisionedDatabaseResult( - d.getId(), d.getProjectId(), d.getMethod().name(), d.getEngine().name(), - d.getOrigin().name(), d.getStatus().name(), d.getHost(), d.getPort(), d.getDatabaseName(), - d.getUsername(), d.getExpiresAt(), - d.getFailureCode() == null ? null : d.getFailureCode().name(), - d.getErrorMessage(), d.getCreatedAt(), d.getUpdatedAt()); + d.id(), d.projectId(), d.method(), d.engine(), + d.origin(), d.status(), d.host(), d.port(), d.databaseName(), + d.username(), d.expiresAt(), + d.failureCode(), + d.errorMessage(), d.createdAt(), d.updatedAt()); } } diff --git a/src/main/java/com/example/dvely/provisioning/domain/repository/ProvisionedDatabaseListView.java b/src/main/java/com/example/dvely/provisioning/domain/repository/ProvisionedDatabaseListView.java new file mode 100644 index 00000000..ac5a227d --- /dev/null +++ b/src/main/java/com/example/dvely/provisioning/domain/repository/ProvisionedDatabaseListView.java @@ -0,0 +1,31 @@ +package com.example.dvely.provisioning.domain.repository; + +import java.time.LocalDateTime; + +/** + * 프로비저닝된 DB 목록 전용 읽기 모델. U6(#341) 6-5. + * + *

password 가 없다. 그 컬럼은 MEDIUMTEXT + {@code @Convert(AesEncryptor)} 라 엔티티로 읽으면 + * 행마다 AES 복호화가 도는데, 조회 응답({@code ProvisionedDatabaseResult})은 계약상 비밀번호를 담지 + * 않는다 — 생성 직후 1회 노출만 별도 경로다. 즉 복호화해서 곧바로 버리고 있었다.

+ * + *

여기에 password 필드를 추가하지 말 것. 목록 응답에 비밀번호가 실릴 경로가 생긴다.

+ */ +public record ProvisionedDatabaseListView( + Long id, + Long projectId, + String method, + String engine, + String origin, + String status, + String host, + Integer port, + String databaseName, + String username, + LocalDateTime expiresAt, + String failureCode, + String errorMessage, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/com/example/dvely/provisioning/domain/repository/ProvisionedDatabaseRepository.java b/src/main/java/com/example/dvely/provisioning/domain/repository/ProvisionedDatabaseRepository.java index abf3ea78..b7af59c3 100644 --- a/src/main/java/com/example/dvely/provisioning/domain/repository/ProvisionedDatabaseRepository.java +++ b/src/main/java/com/example/dvely/provisioning/domain/repository/ProvisionedDatabaseRepository.java @@ -17,8 +17,11 @@ public interface ProvisionedDatabaseRepository { List findByProjectIdOrderByCreatedAtDesc(Long projectId); - /** 목록용 — EXPIRED 를 뺀 활성 자원만. DB 단에서 거른다. */ - List findActiveByProjectIdOrderByCreatedAtDesc(Long projectId); + /** + * 목록용 — EXPIRED 를 뺀 활성 자원만. DB 단에서 거른다. U6 6-5 로 password 를 읽지 않는 + * 읽기 모델을 돌려준다({@link ProvisionedDatabaseListView}). + */ + List findActiveListViewsByProjectId(Long projectId); /** * 만료 회수의 원자적 클레임. READY 인 행만 EXPIRED 로 넘기고, 성공하면 true. diff --git a/src/main/java/com/example/dvely/provisioning/infrastructure/persistence/repository/ProvisionedDatabaseRepositoryAdapter.java b/src/main/java/com/example/dvely/provisioning/infrastructure/persistence/repository/ProvisionedDatabaseRepositoryAdapter.java index 6cc4adb3..97257918 100644 --- a/src/main/java/com/example/dvely/provisioning/infrastructure/persistence/repository/ProvisionedDatabaseRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/provisioning/infrastructure/persistence/repository/ProvisionedDatabaseRepositoryAdapter.java @@ -1,6 +1,7 @@ package com.example.dvely.provisioning.infrastructure.persistence.repository; import com.example.dvely.provisioning.domain.model.ProvisionedDatabase; +import com.example.dvely.provisioning.domain.repository.ProvisionedDatabaseListView; import com.example.dvely.provisioning.domain.repository.ProvisionedDatabaseRepository; import com.example.dvely.provisioning.domain.value.ProvisionStatus; import com.example.dvely.provisioning.infrastructure.persistence.entity.ProvisionedDatabaseEntity; @@ -47,10 +48,8 @@ public List findByProjectIdOrderByCreatedAtDesc(Long projec } @Override - public List findActiveByProjectIdOrderByCreatedAtDesc(Long projectId) { - return springDataRepository.findByProjectIdAndStatusNotOrderByCreatedAtDesc( - projectId, ProvisionStatus.EXPIRED.name()) - .stream().map(ProvisionedDatabaseEntity::toDomain).toList(); + public List findActiveListViewsByProjectId(Long projectId) { + return springDataRepository.findActiveListViews(projectId, ProvisionStatus.EXPIRED.name()); } @Override diff --git a/src/main/java/com/example/dvely/provisioning/infrastructure/persistence/repository/SpringDataProvisionedDatabaseRepository.java b/src/main/java/com/example/dvely/provisioning/infrastructure/persistence/repository/SpringDataProvisionedDatabaseRepository.java index 01ed236b..2c323278 100644 --- a/src/main/java/com/example/dvely/provisioning/infrastructure/persistence/repository/SpringDataProvisionedDatabaseRepository.java +++ b/src/main/java/com/example/dvely/provisioning/infrastructure/persistence/repository/SpringDataProvisionedDatabaseRepository.java @@ -1,5 +1,6 @@ package com.example.dvely.provisioning.infrastructure.persistence.repository; +import com.example.dvely.provisioning.domain.repository.ProvisionedDatabaseListView; import com.example.dvely.provisioning.infrastructure.persistence.entity.ProvisionedDatabaseEntity; import java.time.LocalDateTime; import java.util.List; @@ -19,6 +20,24 @@ public interface SpringDataProvisionedDatabaseRepository List findByProjectIdAndStatusNotOrderByCreatedAtDesc( Long projectId, String status); + // U6 6-5: 목록은 password 를 읽지 않는다. @Convert(AesEncryptor) 가 붙은 MEDIUMTEXT 라 엔티티로 + // 읽으면 행마다 복호화가 도는데, 조회 응답에는 비밀번호가 계약상 실리지 않는다. + // EXPIRED 제외는 그대로 DB 단에서 한다(프리뷰 30분 TTL 이라 하루면 수십 개가 쌓인다). + @Query(""" + select new com.example.dvely.provisioning.domain.repository.ProvisionedDatabaseListView( + e.id, e.projectId, e.method, e.engine, e.origin, e.status, e.host, e.port, + e.databaseName, e.username, e.expiresAt, e.failureCode, e.errorMessage, + e.createdAt, e.updatedAt) + from ProvisionedDatabaseEntity e + where e.projectId = :projectId + and e.status <> :excludedStatus + order by e.createdAt desc, e.id desc + """) + List findActiveListViews( + @org.springframework.data.repository.query.Param("projectId") Long projectId, + @org.springframework.data.repository.query.Param("excludedStatus") String excludedStatus); + + // 만료 회수의 원자적 클레임. READY 인 행만 EXPIRED 로 넘긴다 — 진 워커/인스턴스 하나만 1을 // 돌려받아 실제 리소스 정리로 진행한다. 이래서 같은 DB 를 두 번 deprovision 하지 않는다. @Modifying(clearAutomatically = true) diff --git a/src/test/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryServiceTest.java b/src/test/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryServiceTest.java index e250ce98..a7e7cade 100644 --- a/src/test/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryServiceTest.java +++ b/src/test/java/com/example/dvely/environment/application/query/EnvironmentVariableQueryServiceTest.java @@ -16,6 +16,7 @@ import com.example.dvely.environment.domain.model.EnvironmentVariableHistory; import com.example.dvely.environment.domain.repository.EnvironmentVariableHistoryRepository; import com.example.dvely.environment.domain.repository.EnvironmentVariableRepository; +import com.example.dvely.environment.domain.repository.EnvironmentVariableSummaryView; import com.example.dvely.environment.domain.value.EnvironmentScope; import com.example.dvely.environment.domain.value.EnvironmentVariableAction; import com.example.dvely.project.domain.exception.ProjectNotFoundException; @@ -28,6 +29,7 @@ import com.example.dvely.project.domain.value.RepositoryVisibility; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -47,13 +49,40 @@ void getVariablesRejectsWhenProjectNotOwnedByUser() { .isInstanceOf(ProjectNotFoundException.class); } + /** + * U6 6-5: secret 변수의 평문은 이제 읽지도 않는다. findPlainValuesByIds 는 secret=false 인 + * 행만 돌려주므로(SQL 조건) 여기서 빈 맵을 준다 — 그 상태로 value 가 null 이어야 한다. 예전처럼 + * "평문을 읽어 응답에서 지운다" 가 아니라는 것이 이 테스트의 요지다. + */ @Test void secretVariableValueIsMaskedToNull() { when(projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(11L, 7L)).thenReturn(Optional.of(project())); - EnvironmentVariable secretVariable = new EnvironmentVariable( - 1L, 11L, EnvironmentScope.PRODUCTION, "STRIPE_SECRET_KEY", "sk_live_xxx", true, LocalDateTime.now(), LocalDateTime.now() - ); - when(repository.findByProjectIdOrderByScopeAscKeyAsc(11L)).thenReturn(List.of(secretVariable)); + when(repository.findSummaries(eq(11L), eq(null), anyInt())).thenReturn(List.of( + new EnvironmentVariableSummaryView( + 1L, "PRODUCTION", "STRIPE_SECRET_KEY", true, LocalDateTime.now(), LocalDateTime.now()) + )); + when(repository.findPlainValuesByIds(List.of(1L))).thenReturn(Map.of()); + + List results = service.getVariables(7L, 11L, null); + + assertThat(results).singleElement().satisfies(result -> { + assertThat(result.secret()).isTrue(); + assertThat(result.value()).isNull(); + }); + } + + /** + * secret 인 행의 평문이 어쩌다 평문 맵에 섞여 들어와도 응답에는 나가지 않는다 — 두 번째 방어선. + * 위 테스트가 "읽지 않는다" 를, 이 테스트가 "설령 읽혀도 안 내보낸다" 를 각각 못박는다. + */ + @Test + void secretVariableValueStaysNullEvenIfAPlaintextSomehowArrives() { + when(projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(11L, 7L)).thenReturn(Optional.of(project())); + when(repository.findSummaries(eq(11L), eq(null), anyInt())).thenReturn(List.of( + new EnvironmentVariableSummaryView( + 1L, "PRODUCTION", "STRIPE_SECRET_KEY", true, LocalDateTime.now(), LocalDateTime.now()) + )); + when(repository.findPlainValuesByIds(List.of(1L))).thenReturn(Map.of(1L, "sk_live_xxx")); List results = service.getVariables(7L, 11L, null); @@ -66,10 +95,12 @@ void secretVariableValueIsMaskedToNull() { @Test void nonSecretVariableValueIsReturnedAsPlaintext() { when(projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(11L, 7L)).thenReturn(Optional.of(project())); - EnvironmentVariable variable = new EnvironmentVariable( - 2L, 11L, EnvironmentScope.PREVIEW, "API_BASE_URL", "https://api.example.com", false, LocalDateTime.now(), LocalDateTime.now() - ); - when(repository.findByProjectIdOrderByScopeAscKeyAsc(11L)).thenReturn(List.of(variable)); + when(repository.findSummaries(eq(11L), eq(null), anyInt())).thenReturn(List.of( + new EnvironmentVariableSummaryView( + 2L, "PREVIEW", "API_BASE_URL", false, LocalDateTime.now(), LocalDateTime.now()) + )); + when(repository.findPlainValuesByIds(List.of(2L))) + .thenReturn(Map.of(2L, "https://api.example.com")); List results = service.getVariables(7L, 11L, null); @@ -82,14 +113,30 @@ void nonSecretVariableValueIsReturnedAsPlaintext() { @Test void filtersByScopeWhenProvided() { when(projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(11L, 7L)).thenReturn(Optional.of(project())); - when(repository.findByProjectIdAndScopeOrderByKeyAsc(11L, EnvironmentScope.PREVIEW)).thenReturn(List.of()); + when(repository.findSummaries(eq(11L), eq(EnvironmentScope.PREVIEW), anyInt())).thenReturn(List.of()); service.getVariables(7L, 11L, "PREVIEW"); - verify(repository).findByProjectIdAndScopeOrderByKeyAsc(11L, EnvironmentScope.PREVIEW); + verify(repository).findSummaries(eq(11L), eq(EnvironmentScope.PREVIEW), anyInt()); + // 엔티티를 통째로 읽는 예전 경로는 목록에서 더 쓰지 않는다(그쪽은 env_value 를 매 행 복호화했다). + verify(repository, never()).findByProjectIdAndScopeOrderByKeyAsc(any(), any()); verify(repository, never()).findByProjectIdOrderByScopeAscKeyAsc(any()); } + /** limit 를 안 주면 기본 200. 상한을 넘겨 달라고 하면 500 으로 깎는다. */ + @Test + void variableLimitDefaultsTo200AndIsClampedTo500() { + when(projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(11L, 7L)).thenReturn(Optional.of(project())); + when(repository.findSummaries(eq(11L), eq(null), anyInt())).thenReturn(List.of()); + + service.getVariables(7L, 11L, null, null); + service.getVariables(7L, 11L, null, 9999); + + // findSummaries 는 "더 있는지" 판단용으로 limit+1 건을 요청한다. + verify(repository).findSummaries(11L, null, 201); + verify(repository).findSummaries(11L, null, 501); + } + @Test void rejectsUnsupportedScopeQueryParameter() { when(projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(11L, 7L)).thenReturn(Optional.of(project())); diff --git a/src/test/java/com/example/dvely/environment/presentation/EnvironmentVariableControllerTest.java b/src/test/java/com/example/dvely/environment/presentation/EnvironmentVariableControllerTest.java index 3a59da87..bae1c309 100644 --- a/src/test/java/com/example/dvely/environment/presentation/EnvironmentVariableControllerTest.java +++ b/src/test/java/com/example/dvely/environment/presentation/EnvironmentVariableControllerTest.java @@ -4,6 +4,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.example.dvely.common.paging.CursorPage; import com.example.dvely.environment.application.facade.EnvironmentVariableFacade; import com.example.dvely.environment.application.result.EnvironmentVariableHistoryResult; import com.example.dvely.environment.application.result.EnvironmentVariableResult; @@ -33,13 +34,13 @@ void getVariablesDelegatesUsingAuthenticatedUserIdProjectIdAndScope() { EnvironmentVariableResult result = new EnvironmentVariableResult( 1L, "PREVIEW", "API_BASE_URL", "https://api.example.com", false, LocalDateTime.now(), LocalDateTime.now() ); - when(facade.getVariables(1L, 11L, "PREVIEW")).thenReturn(List.of(result)); + when(facade.getVariables(1L, 11L, "PREVIEW", null)).thenReturn(CursorPage.of(List.of(result))); - List responses = controller.getVariables(1L, 11L, "PREVIEW"); + List responses = controller.getVariables(1L, 11L, "PREVIEW", null).getBody(); assertThat(responses).hasSize(1); assertThat(responses.get(0).key()).isEqualTo("API_BASE_URL"); - verify(facade).getVariables(1L, 11L, "PREVIEW"); + verify(facade).getVariables(1L, 11L, "PREVIEW", null); } @Test @@ -47,9 +48,9 @@ void getVariablesResponseKeepsSecretValueNull() { EnvironmentVariableResult result = new EnvironmentVariableResult( 2L, "PRODUCTION", "STRIPE_SECRET_KEY", null, true, LocalDateTime.now(), LocalDateTime.now() ); - when(facade.getVariables(1L, 11L, null)).thenReturn(List.of(result)); + when(facade.getVariables(1L, 11L, null, null)).thenReturn(CursorPage.of(List.of(result))); - List responses = controller.getVariables(1L, 11L, null); + List responses = controller.getVariables(1L, 11L, null, null).getBody(); assertThat(responses.get(0).secret()).isTrue(); assertThat(responses.get(0).value()).isNull(); diff --git a/src/test/java/com/example/dvely/environment/presentation/EnvironmentVariableResponseContractTest.java b/src/test/java/com/example/dvely/environment/presentation/EnvironmentVariableResponseContractTest.java index d6a3dd90..a5a0ae7b 100644 --- a/src/test/java/com/example/dvely/environment/presentation/EnvironmentVariableResponseContractTest.java +++ b/src/test/java/com/example/dvely/environment/presentation/EnvironmentVariableResponseContractTest.java @@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.example.dvely.common.response.ApiResponseAdvice; +import com.example.dvely.common.paging.CursorPage; import com.example.dvely.environment.application.facade.EnvironmentVariableFacade; import com.example.dvely.environment.application.result.EnvironmentVariableResult; import com.fasterxml.jackson.databind.ObjectMapper; @@ -82,7 +83,7 @@ void secretVariableJsonHasAPresentButNullValueField() throws Exception { EnvironmentVariableResult secretResult = new EnvironmentVariableResult( 2L, "PRODUCTION", "STRIPE_SECRET_KEY", null, true, LocalDateTime.now(), LocalDateTime.now() ); - when(facade.getVariables(1L, 11L, null)).thenReturn(List.of(secretResult)); + when(facade.getVariables(1L, 11L, null, null)).thenReturn(CursorPage.of(List.of(secretResult))); mockMvc.perform(get("/api/v1/projects/11/environment-variables")) .andExpect(status().isOk()) @@ -99,7 +100,7 @@ void nonSecretVariableJsonKeepsThePlaintextValue() throws Exception { EnvironmentVariableResult plainResult = new EnvironmentVariableResult( 1L, "PREVIEW", "API_BASE_URL", "https://api.example.com", false, LocalDateTime.now(), LocalDateTime.now() ); - when(facade.getVariables(1L, 11L, null)).thenReturn(List.of(plainResult)); + when(facade.getVariables(1L, 11L, null, null)).thenReturn(CursorPage.of(List.of(plainResult))); mockMvc.perform(get("/api/v1/projects/11/environment-variables")) .andExpect(status().isOk()) From abe7a4e66560fbf96a462793df02cd7549c4d468 Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:27:16 +0900 Subject: [PATCH 05/13] =?UTF-8?q?perf(agent):=20=EC=82=B4=EC=95=84?= =?UTF-8?q?=EC=9E=88=EB=8A=94=20=ED=83=9C=EC=8A=A4=ED=81=AC=20=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EB=91=90=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=EC=9C=BC=EB=A1=9C=20=EC=A4=84=EC=9D=B8?= =?UTF-8?q?=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskStore#findActiveTask 는 (taskId, status) 두 값만 쓰는데 findActiveRuns 가 AgentRunEntity 를 통째로 읽었다. 그 엔티티에는 plan_json LONGTEXT 와 TEXT 8개 (summary·error·question·clarification_json·answered_clarification_json·input_value· failure_log·suggested_fix)가 붙어 있다. 대화를 열 때마다 도는 조회다. 프로젝션 인터페이스(ActiveRunView) 로 바꿨다. 쿼리 조건·정렬·Pageable 은 그대로고 반환 타입만 좁아진다 — 같은 파일의 다른 부분은 건드리지 않았다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../repository/SpringDataAgentRunRepository.java | 13 +++++++++++-- .../dvely/agent/infrastructure/store/TaskStore.java | 2 +- .../agent/infrastructure/store/TaskStoreTest.java | 9 +++++---- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataAgentRunRepository.java b/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataAgentRunRepository.java index 4b3224b0..aacae60b 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataAgentRunRepository.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/persistence/repository/SpringDataAgentRunRepository.java @@ -21,15 +21,24 @@ public interface SpringDataAgentRunRepository extends JpaRepository findActiveRuns( + List findActiveRuns( @Param("conversationId") Long conversationId, @Param("ownerUserId") Long ownerUserId, @Param("terminalStatuses") List terminalStatuses, diff --git a/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java b/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java index 412678d1..bb946735 100644 --- a/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java +++ b/src/main/java/com/example/dvely/agent/infrastructure/store/TaskStore.java @@ -107,7 +107,7 @@ public java.util.Optional findActiveTask(Long conversationId, Long u conversationId, userId, TERMINAL_STATUSES, org.springframework.data.domain.PageRequest.of(0, 1)) .stream().findFirst() - .map(run -> new ActiveTask(run.getTaskId(), TaskStatus.valueOf(run.getStatus()))); + .map(view -> new ActiveTask(view.getTaskId(), TaskStatus.valueOf(view.getStatus()))); } /** 대화의 현재 살아있는 태스크 포인터(id + 상태). */ diff --git a/src/test/java/com/example/dvely/agent/infrastructure/store/TaskStoreTest.java b/src/test/java/com/example/dvely/agent/infrastructure/store/TaskStoreTest.java index 7315533f..fdf66092 100644 --- a/src/test/java/com/example/dvely/agent/infrastructure/store/TaskStoreTest.java +++ b/src/test/java/com/example/dvely/agent/infrastructure/store/TaskStoreTest.java @@ -437,10 +437,11 @@ void recoverStuckApprovalTransitionsToQueuedWithASweepSpecificEvent() { @Test void findActiveTask_returnsLatestNonTerminalTask() { - AgentRunEntity run = mock(AgentRunEntity.class); - when(run.getTaskId()).thenReturn("task-live"); - when(run.getStatus()).thenReturn("WAITING_INPUT"); - when(runRepository.findActiveRuns(eq(21L), eq(7L), any(), any())).thenReturn(List.of(run)); + // U6 6-6: 이 조회는 엔티티가 아니라 (taskId, status) 프로젝션을 돌려준다. + SpringDataAgentRunRepository.ActiveRunView view = mock(SpringDataAgentRunRepository.ActiveRunView.class); + when(view.getTaskId()).thenReturn("task-live"); + when(view.getStatus()).thenReturn("WAITING_INPUT"); + when(runRepository.findActiveRuns(eq(21L), eq(7L), any(), any())).thenReturn(List.of(view)); Optional result = taskStore.findActiveTask(21L, 7L); From e74cfe506b1333e433c3b81bf6c0cb28b752be49 Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:32:27 +0900 Subject: [PATCH 06/13] =?UTF-8?q?perf(chat):=20=ED=9C=B4=EC=A7=80=ED=86=B5?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=EC=9D=98=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20N+1=20=EC=9D=84=20=EB=B0=B0=EC=B9=98=202=ED=9A=8C?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=91=EB=8A=94=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 휴지통 대화마다 표시할 프로젝트를 개별 조회했다. 한 대화당 최대 3번이다 — 활성 프로젝트 조회, 없으면 원본 조회, 그래도 없으면 같은 저장소의 활성 프로젝트 조회. 대화 N 건이면 최대 3N 번이었다. 프로젝트 id 를 모아 한 번(삭제 여부를 가리지 않고 읽어 isDeleted 로 갈라 쓴다), 보정이 필요한 저장소를 모아 한 번, 총 2번으로 줄였다. 보정할 저장소가 없으면 두 번째 조회는 돌지 않는다 — 원본이 전부 살아 있는 흔한 경우다. 판정 순서는 예전 대화별 로직과 같다: 원본이 살아 있으면 그것 → 삭제됐으면 같은 저장소의 활성 프로젝트(최신) → 그것도 없으면 삭제된 원본 → 아예 없으면 "삭제된 프로젝트". 저장소 키를 소문자로 맞추는 것은 source_repository 컬레이션이 utf8mb4_unicode_ci 라 DB 가 대소문자 다른 값끼리 매칭해 주기 때문이다(기존 IgnoreCase 계약과 같은 근거). 대체 프로젝트 후보 정렬에 id tiebreaker 를 붙인 것은 updated_at 이 DATETIME(초) 라 같은 초의 순서가 비결정적이었기 때문이다. 응답 JSON 은 바뀌지 않는다. "조회 횟수가 대화 수에 비례하지 않는다" 와 세 갈래 판정을 각각 테스트로 못박았다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../application/query/ChatQueryService.java | 99 +++++++++++++++---- .../domain/repository/ProjectRepository.java | 19 ++++ .../repository/ProjectRepositoryAdapter.java | 25 +++++ .../SpringDataProjectRepository.java | 10 ++ .../query/ChatQueryServiceTest.java | 88 ++++++++++++++++- 5 files changed, 218 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java b/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java index 56736d26..427f5f26 100644 --- a/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java +++ b/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java @@ -11,8 +11,10 @@ import com.example.dvely.project.domain.model.Project; import com.example.dvely.project.domain.repository.ProjectRepository; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.List; -import java.util.Optional; +import java.util.Locale; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,12 +48,21 @@ public ConversationResult getConversation(Long userId, Long conversationId) { ); } + /** + * U6(#341) 6-7: 표시할 프로젝트를 대화마다 개별 조회하던 N+1 을 걷어냈다. 휴지통 대화 N 건이면 + * 예전에는 프로젝트 조회가 최대 3N 번 돌았다(활성 조회 → 원본 조회 → 대체 프로젝트 조회). + * 이제 프로젝트 id 를 모아 한 번, 대체 프로젝트가 필요한 저장소를 모아 한 번, 총 2번이다. + */ public List getTrashConversations(Long userId) { LocalDateTime now = LocalDateTime.now(); - return conversationRepository.findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(userId) + List conversations = conversationRepository + .findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(userId) .stream() .filter(conversation -> !ChatTrashPolicy.isExpired(conversation.getDeletedAt(), now)) - .map(conversation -> toResult(conversation, resolveTrashProject(userId, conversation), now)) + .toList(); + Map displays = resolveTrashProjects(userId, conversations); + return conversations.stream() + .map(conversation -> toResult(conversation, displays.get(conversation.getProjectId()), now)) .toList(); } @@ -102,27 +113,73 @@ private MessageResult toMessageResult(ChatMessage message) { ); } - private ProjectDisplay resolveTrashProject(Long userId, Conversation conversation) { - Long projectId = conversation.getProjectId(); - Optional activeProject = projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(projectId, userId); - if (activeProject.isPresent()) { - Project project = activeProject.get(); - return new ProjectDisplay(project.getId(), project.getName()); + /** + * 대화들이 가리키는 프로젝트를 한꺼번에 풀어 projectId → 표시값 으로 만든다. 판정 순서는 + * 예전 대화별 로직과 같다: 원본이 살아 있으면 그것, 삭제됐으면 같은 저장소의 활성 프로젝트, + * 그것도 없으면 삭제된 원본, 아예 없으면 "삭제된 프로젝트". + */ + private Map resolveTrashProjects(Long userId, List conversations) { + List projectIds = conversations.stream() + .map(Conversation::getProjectId) + .distinct() + .toList(); + if (projectIds.isEmpty()) { + return Map.of(); + } + + // ① 해당 프로젝트들 — 삭제 여부를 가리지 않는다. 예전의 "활성 조회 → 원본 조회" 두 번을 + // 한 번으로 합친다(isDeleted 로 갈라 쓴다). + Map byId = new HashMap<>(); + for (Project project : projectRepository.findAllByIdInAndOwnerUserId(projectIds, userId)) { + byId.put(project.getId(), project); } - Optional originalProject = projectRepository.findByIdAndOwnerUserId(projectId, userId); - Optional replacementProject = originalProject + // ② 대체 프로젝트가 필요한 저장소들 — 원본이 삭제됐고 저장소를 갖고 있는 경우만. + List repositories = projectIds.stream() + .map(byId::get) + .filter(project -> project != null && project.isDeleted()) .map(Project::getSourceRepository) - .filter(sourceRepository -> sourceRepository != null && !sourceRepository.isBlank()) - .flatMap(sourceRepository -> projectRepository - .findFirstByOwnerUserIdAndSourceRepositoryIgnoreCaseAndDeletedFalseOrderByUpdatedAtDesc( - userId, - sourceRepository - )); - Project displayProject = replacementProject.orElseGet(() -> originalProject.orElse(null)); - return displayProject == null - ? new ProjectDisplay(projectId, "삭제된 프로젝트") - : new ProjectDisplay(displayProject.getId(), displayProject.getName()); + .filter(repository -> repository != null && !repository.isBlank()) + .distinct() + .toList(); + // 보정이 필요한 저장소가 없으면 두 번째 조회는 아예 돌지 않는다 — 휴지통 대화의 원본 + // 프로젝트가 전부 살아 있는 흔한 경우가 여기다. + Map replacementByRepository = new HashMap<>(); + for (Project candidate : repositories.isEmpty() + ? List.of() + : projectRepository.findAllActiveByOwnerUserIdAndSourceRepositoryIn(userId, repositories)) { + // 최신순으로 들어오므로 저장소별 첫 건만 남긴다 — 예전 findFirst...OrderByUpdatedAtDesc + // 가 돌려주던 것과 같다. 키를 소문자로 맞추는 것은 컬럼 컬레이션이 대소문자를 구분하지 + // 않아 DB 가 대소문자 다른 값끼리 매칭해 주기 때문이다. + replacementByRepository.putIfAbsent(normalizeRepository(candidate.getSourceRepository()), candidate); + } + + Map displays = new HashMap<>(); + for (Long projectId : projectIds) { + displays.put(projectId, resolveDisplay(projectId, byId.get(projectId), replacementByRepository)); + } + return displays; + } + + private ProjectDisplay resolveDisplay(Long projectId, + Project project, + Map replacementByRepository) { + if (project == null) { + return new ProjectDisplay(projectId, "삭제된 프로젝트"); + } + if (!project.isDeleted()) { + return new ProjectDisplay(project.getId(), project.getName()); + } + String repository = project.getSourceRepository(); + Project replacement = repository == null || repository.isBlank() + ? null + : replacementByRepository.get(normalizeRepository(repository)); + Project displayProject = replacement == null ? project : replacement; + return new ProjectDisplay(displayProject.getId(), displayProject.getName()); + } + + private static String normalizeRepository(String repository) { + return repository.toLowerCase(Locale.ROOT); } private record ProjectDisplay(Long projectId, String projectName) { diff --git a/src/main/java/com/example/dvely/project/domain/repository/ProjectRepository.java b/src/main/java/com/example/dvely/project/domain/repository/ProjectRepository.java index 6d2fd7a9..b0c3b342 100644 --- a/src/main/java/com/example/dvely/project/domain/repository/ProjectRepository.java +++ b/src/main/java/com/example/dvely/project/domain/repository/ProjectRepository.java @@ -1,6 +1,7 @@ package com.example.dvely.project.domain.repository; import com.example.dvely.project.domain.model.Project; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -23,6 +24,24 @@ Optional findFirstByOwnerUserIdAndSourceRepositoryIgnoreCaseAndDeletedF String sourceRepository ); + /** + * U6(#341) 6-7: 여러 프로젝트를 한 번에. 삭제 여부를 가리지 않는다 — 휴지통 목록은 "원본이 + * 살아 있나 / 삭제됐나" 를 구분해야 해서 둘 다 필요하고, 예전에는 그것 때문에 대화마다 + * 조회를 두 번씩 돌렸다. + */ + List findAllByIdInAndOwnerUserId(Collection projectIds, Long ownerUserId); + + /** + * U6(#341) 6-7: 주어진 저장소들을 쓰는 활성 프로젝트를 최신순으로 한 번에. 저장소별 첫 건이 + * {@link #findFirstByOwnerUserIdAndSourceRepositoryIgnoreCaseAndDeletedFalseOrderByUpdatedAtDesc} + * 가 돌려주던 것과 같다(대소문자 무시 근거도 그쪽 javadoc 과 동일). updated_at 이 DATETIME(초) + * 라 같은 초의 행 순서가 비결정적이므로 id 를 tiebreaker 로 붙여 고정했다. + */ + List findAllActiveByOwnerUserIdAndSourceRepositoryIn( + Long ownerUserId, + Collection sourceRepositories + ); + Optional findById(Long projectId); Optional findBySourceRepository(String sourceRepository); diff --git a/src/main/java/com/example/dvely/project/infrastructure/persistence/repository/ProjectRepositoryAdapter.java b/src/main/java/com/example/dvely/project/infrastructure/persistence/repository/ProjectRepositoryAdapter.java index a4b48da8..846dd867 100644 --- a/src/main/java/com/example/dvely/project/infrastructure/persistence/repository/ProjectRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/project/infrastructure/persistence/repository/ProjectRepositoryAdapter.java @@ -3,6 +3,7 @@ import com.example.dvely.project.domain.model.Project; import com.example.dvely.project.domain.repository.ProjectRepository; import com.example.dvely.project.infrastructure.persistence.entity.ProjectEntity; +import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -45,6 +46,30 @@ public Optional findFirstByOwnerUserIdAndSourceRepositoryIgnoreCaseAndD .map(ProjectEntity::toDomain); } + @Override + public List findAllByIdInAndOwnerUserId(Collection projectIds, Long ownerUserId) { + if (projectIds.isEmpty()) { + return List.of(); + } + return springDataProjectRepository.findByIdInAndOwnerUserId(projectIds, ownerUserId).stream() + .map(ProjectEntity::toDomain) + .toList(); + } + + @Override + public List findAllActiveByOwnerUserIdAndSourceRepositoryIn(Long ownerUserId, + Collection sourceRepositories) { + if (sourceRepositories.isEmpty()) { + return List.of(); + } + return springDataProjectRepository + .findByOwnerUserIdAndDeletedFalseAndSourceRepositoryInOrderByUpdatedAtDescIdDesc( + ownerUserId, sourceRepositories) + .stream() + .map(ProjectEntity::toDomain) + .toList(); + } + @Override public Optional findById(Long projectId) { return springDataProjectRepository.findById(projectId).map(ProjectEntity::toDomain); diff --git a/src/main/java/com/example/dvely/project/infrastructure/persistence/repository/SpringDataProjectRepository.java b/src/main/java/com/example/dvely/project/infrastructure/persistence/repository/SpringDataProjectRepository.java index da080937..83d23138 100644 --- a/src/main/java/com/example/dvely/project/infrastructure/persistence/repository/SpringDataProjectRepository.java +++ b/src/main/java/com/example/dvely/project/infrastructure/persistence/repository/SpringDataProjectRepository.java @@ -1,6 +1,7 @@ package com.example.dvely.project.infrastructure.persistence.repository; import com.example.dvely.project.infrastructure.persistence.entity.ProjectEntity; +import java.util.Collection; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -33,4 +34,13 @@ Optional findFirstByOwnerUserIdAndSourceRepositoryAndDeletedFalse Optional findFirstBySourceRepository(String sourceRepository); List findBySourceRepositoryAndDeletedFalse(String sourceRepository); + + // U6(#341) 6-7: 휴지통 목록의 N+1 을 걷어내기 위한 배치 조회 2개. 위 javadoc 과 같은 이유로 + // IgnoreCase 를 붙이지 않는다 — source_repository 컬레이션이 이미 대소문자를 구분하지 않는다. + List findByIdInAndOwnerUserId(Collection projectIds, Long ownerUserId); + + List findByOwnerUserIdAndDeletedFalseAndSourceRepositoryInOrderByUpdatedAtDescIdDesc( + Long ownerUserId, + Collection sourceRepositories + ); } diff --git a/src/test/java/com/example/dvely/chat/application/query/ChatQueryServiceTest.java b/src/test/java/com/example/dvely/chat/application/query/ChatQueryServiceTest.java index 1265afa0..6b01b8fb 100644 --- a/src/test/java/com/example/dvely/chat/application/query/ChatQueryServiceTest.java +++ b/src/test/java/com/example/dvely/chat/application/query/ChatQueryServiceTest.java @@ -1,6 +1,10 @@ package com.example.dvely.chat.application.query; 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.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.example.dvely.chat.domain.model.ChatMessage; @@ -55,8 +59,8 @@ void trashResponseIncludesTitleProjectNameAndRemainingRetentionDays() { Project project = project(7L, 2L, "qeploy-landing"); when(conversationRepository.findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(2L)) .thenReturn(List.of(conversation)); - when(projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(7L, 2L)) - .thenReturn(Optional.of(project)); + when(projectRepository.findAllByIdInAndOwnerUserId(List.of(7L), 2L)) + .thenReturn(List.of(project)); var results = service.getTrashConversations(2L); @@ -68,6 +72,70 @@ void trashResponseIncludesTitleProjectNameAndRemainingRetentionDays() { }); } + /** + * U6(#341) 6-7: 휴지통 목록이 대화 수에 비례해 프로젝트를 조회하지 않는다. 대화 3건이 서로 다른 + * 프로젝트를 가리켜도 프로젝트 조회는 배치 2번(id 묶음 · 대체 저장소 묶음)이 전부다 — 예전에는 + * 대화마다 최대 3번, 즉 최대 9번이었다. + */ + @Test + void trashProjectLookupsDoNotGrowWithConversationCount() { + LocalDateTime deletedAt = LocalDateTime.now().minusDays(1); + when(conversationRepository.findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(2L)) + .thenReturn(List.of( + trashConversation(11L, 7L, deletedAt), + trashConversation(12L, 8L, deletedAt), + trashConversation(13L, 9L, deletedAt))); + when(projectRepository.findAllByIdInAndOwnerUserId(List.of(7L, 8L, 9L), 2L)) + .thenReturn(List.of( + project(7L, 2L, "alpha"), project(8L, 2L, "beta"), project(9L, 2L, "gamma"))); + + assertThat(service.getTrashConversations(2L)).hasSize(3); + + verify(projectRepository, times(1)).findAllByIdInAndOwnerUserId(List.of(7L, 8L, 9L), 2L); + // 살아 있는 프로젝트만이라 대체 저장소 조회는 아예 돌지 않는다. + verify(projectRepository, never()).findAllActiveByOwnerUserIdAndSourceRepositoryIn(any(), any()); + verify(projectRepository, never()).findByIdAndOwnerUserIdAndDeletedFalse(any(), any()); + verify(projectRepository, never()).findByIdAndOwnerUserId(any(), any()); + } + + /** + * 원본 프로젝트가 삭제됐으면 같은 저장소를 쓰는 활성 프로젝트로 보정한다 — 예전 대화별 로직과 + * 같은 판정이고, 그 조회가 배치 1번으로 합쳐졌다는 것만 다르다. + */ + @Test + void trashConversationFallsBackToActiveProjectWithTheSameRepository() { + LocalDateTime deletedAt = LocalDateTime.now().minusDays(1); + when(conversationRepository.findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(2L)) + .thenReturn(List.of(trashConversation(11L, 7L, deletedAt))); + when(projectRepository.findAllByIdInAndOwnerUserId(List.of(7L), 2L)) + .thenReturn(List.of(deletedProject(7L, 2L, "예전 프로젝트", "Otter/Sample-Repo"))); + when(projectRepository.findAllActiveByOwnerUserIdAndSourceRepositoryIn(2L, List.of("Otter/Sample-Repo"))) + .thenReturn(List.of(project(9L, 2L, "새 프로젝트"))); + + var results = service.getTrashConversations(2L); + + assertThat(results).singleElement().satisfies(result -> { + assertThat(result.projectId()).isEqualTo(9L); + assertThat(result.projectName()).isEqualTo("새 프로젝트"); + }); + } + + /** 원본도 없고 같은 저장소의 활성 프로젝트도 없으면 예전처럼 "삭제된 프로젝트" 로 표시한다. */ + @Test + void trashConversationShowsDeletedPlaceholderWhenNoProjectRemains() { + LocalDateTime deletedAt = LocalDateTime.now().minusDays(1); + when(conversationRepository.findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(2L)) + .thenReturn(List.of(trashConversation(11L, 7L, deletedAt))); + when(projectRepository.findAllByIdInAndOwnerUserId(List.of(7L), 2L)).thenReturn(List.of()); + + var results = service.getTrashConversations(2L); + + assertThat(results).singleElement().satisfies(result -> { + assertThat(result.projectId()).isEqualTo(7L); + assertThat(result.projectName()).isEqualTo("삭제된 프로젝트"); + }); + } + @Test void getMessagesLeavesTaskIdNullSinceHistoricalMessagesHaveNoTaskCorrelation() { Conversation conversation = new Conversation( @@ -112,6 +180,22 @@ void trashResponseExcludesConversationAfterSevenDays() { assertThat(service.getTrashConversations(2L)).isEmpty(); } + private Conversation trashConversation(Long conversationId, Long projectId, LocalDateTime deletedAt) { + return new Conversation( + conversationId, 2L, projectId, "휴지통 대화", + true, deletedAt, deletedAt.minusDays(2), deletedAt + ); + } + + private Project deletedProject(Long projectId, Long ownerUserId, String name, String sourceRepository) { + return new Project( + projectId, ownerUserId, name, ProjectStatus.ARCHIVED, "scratch", null, "fast", + DeployStatus.DRAFT, null, null, sourceRepository, sourceRepository, + RepositoryVisibility.PUBLIC, RepositoryBindingStatus.BOUND, RepositoryHealthStatus.HEALTHY, + true, LocalDateTime.now().minusDays(3), LocalDateTime.now() + ); + } + private Project project(Long projectId, Long ownerUserId, String name) { return new Project( projectId, From 0be339ffe51e8929c985a191e8a530c57dd23bb8 Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:34:47 +0900 Subject: [PATCH 07/13] =?UTF-8?q?perf(chat):=20=EB=8C=80=ED=99=94=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=20=EB=8C=80=EC=8B=A0=20=ED=95=9C=20=EB=AC=B8=EC=9E=A5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세 곳이 대화를 N 건 읽어와 한 건씩 save/deleteById 했다. 프로젝트 삭제 한 번에 SELECT 1 + UPDATE N, 만료 청소 한 번에 SELECT 1 + DELETE N 이다. - trashConversationsForProject: 벌크 UPDATE 1회. softDelete 의 "이미 삭제됐으면 무시" 가드는 쿼리의 deleted = false 조건이 대신한다 - deleteConversationsForProject: 벌크 DELETE 1회 - purgeExpiredConversations: 벌크 DELETE 1회. 돌려주는 값은 지운 행 수로, 예전의 "찾은 행 수" 와 같다 동작이 같은 근거를 두 가지 확인했다. - updated_at: chat_sessions.updated_at 컬럼이 ON UPDATE CURRENT_TIMESTAMP 라 벌크 UPDATE 에서도 DB 가 채운다(@UpdateTimestamp 는 벌크 문장에서 돌지 않는다). 휴지통 목록이 updated_at 순이므로 이게 유지돼야 순서가 같다 - 연관 삭제: 벌크 DELETE 도 실제 SQL DELETE 이므로 chat_messages 의 ON DELETE CASCADE (V19)와 approvals·agent_runs 등의 ON DELETE SET NULL(이력 보존)이 예전과 똑같이 돈다 clearAutomatically 는 켜지 않았다. 영속성 컨텍스트를 비우면 같은 트랜잭션의 ProjectRepositoryAdapter#save 가 L1 캐시 히트에 기대는 낙관적 잠금 경로(그쪽 javadoc 의 Case A)를 잃는다 — version 이 null 인 Project 에서는 갱신 분실 방어가 실제로 약해진다. 이 세 메서드는 대화를 읽지 않으므로 비울 이유도 없다. 호출부가 없어진 findAllByUserIdAndProjectId·findAllByDeletedTrueAndDeletedAtLessThanEqual 는 지웠다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../command/ChatCommandService.java | 35 +++++++--------- .../repository/ConversationRepository.java | 12 ++++-- .../ConversationRepositoryAdapter.java | 20 ++++----- .../SpringDataConversationRepository.java | 42 +++++++++++++++++-- .../command/ChatCommandServiceTest.java | 15 +++++-- 5 files changed, 83 insertions(+), 41 deletions(-) diff --git a/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java b/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java index 5f1bf380..67bb6b0f 100644 --- a/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java +++ b/src/main/java/com/example/dvely/chat/application/command/ChatCommandService.java @@ -69,38 +69,31 @@ public ConversationResult restoreConversation(Long userId, Long conversationId) return toResult(conversationRepository.save(conversation), restoreProject, LocalDateTime.now()); } + /** + * U6(#341) 6-8: 한 문장으로 옮긴다. 예전에는 대화 N 건을 엔티티로 읽어 한 건씩 save 했다 — + * 프로젝트 삭제 한 번에 SELECT 1 + UPDATE N 이었다. softDelete 의 "이미 삭제됐으면 무시" 가드는 + * 쿼리의 {@code deleted = false} 조건이 그대로 대신한다. + */ @Transactional public void trashConversationsForProject(Long userId, Long projectId) { - List conversations = conversationRepository - .findAllByUserIdAndProjectIdAndDeletedFalseOrderByUpdatedAtDesc(userId, projectId); - LocalDateTime deletedAt = LocalDateTime.now(); - for (Conversation conversation : conversations) { - conversation.softDelete(deletedAt); - conversationRepository.save(conversation); - } + conversationRepository.softDeleteAllByUserIdAndProjectId(userId, projectId, LocalDateTime.now()); } + /** + * U6 6-8: 한 문장으로 지운다. 메시지는 따로 지우지 않는다(#338) — chat_messages 의 FK 가 V19 + * 부터 ON DELETE CASCADE 라 DB 가 함께 지운다. 벌크 DELETE 도 실제 SQL DELETE 이므로 그 CASCADE + * 와 다른 테이블의 SET NULL(approvals·agent_runs 등 이력 보존)이 예전과 똑같이 돈다. + */ @Transactional public void deleteConversationsForProject(Long userId, Long projectId) { - List conversations = conversationRepository.findAllByUserIdAndProjectId(userId, projectId); - for (Conversation conversation : conversations) { - if (conversation.getId() == null) { - continue; - } - // 메시지는 따로 지우지 않는다(#338). chat_messages 의 FK 는 V19 부터 - // ON DELETE CASCADE 라 아래 한 줄이 메시지까지 지운다. 앞서 있던 - // deleteAllByConversationId 는 엔티티를 N 건 로드해 한 건씩 지운 뒤 CASCADE 가 - // 같은 일을 또 하는 이중 삭제였다. 바로 아래 purgeExpiredConversations 도 - // 예전부터 deleteById 하나로만 지우고 있었다 — 그쪽이 맞는 쪽이었다. - conversationRepository.deleteById(conversation.getId()); - } + conversationRepository.deleteAllByUserIdAndProjectId(userId, projectId); } /** * 만료된 휴지통 대화를 영구 삭제한다. * - *

#340 5-9: 엔티티를 전부 로드한 뒤 {@code deleteById} 를 N 번 부르던 것을 벌크 DELETE - * 한 문장으로 바꿨다. 지우려고 읽을 이유가 없다 — 삭제 조건이 곧 SELECT 조건이었다.

+ *

#340 5-9 · #341 6-8: 엔티티를 전부 로드한 뒤 {@code deleteById} 를 N 번 부르던 것을 벌크 + * DELETE 한 문장으로 바꿨다. 지우려고 읽을 이유가 없다 — 삭제 조건이 곧 SELECT 조건이었다.

*/ @Transactional public int purgeExpiredConversations() { diff --git a/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java b/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java index c0a10e12..3dfae5ea 100644 --- a/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java +++ b/src/main/java/com/example/dvely/chat/domain/repository/ConversationRepository.java @@ -9,11 +9,17 @@ public interface ConversationRepository { List findAllByUserIdAndProjectIdAndDeletedFalseOrderByUpdatedAtDesc(Long userId, Long projectId); - List findAllByUserIdAndProjectId(Long userId, Long projectId); - List findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(Long userId); - List findAllByDeletedTrueAndDeletedAtLessThanEqual(LocalDateTime cutoff); + /** + * U6(#341) 6-8: 프로젝트의 활성 대화를 전부 휴지통으로. 예전에는 N 건을 읽어 한 건씩 save 했다. + * 돌려주는 값은 옮긴 행 수다. + */ + int softDeleteAllByUserIdAndProjectId(Long userId, Long projectId, LocalDateTime deletedAt); + + /** U6 6-8: 프로젝트의 대화를 전부 삭제. 메시지는 FK ON DELETE CASCADE 가 함께 지운다. */ + int deleteAllByUserIdAndProjectId(Long userId, Long projectId); + Optional findByIdAndUserIdAndDeletedFalse(Long conversationId, Long userId); diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java index 252fd9f3..c92d4748 100644 --- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ConversationRepositoryAdapter.java @@ -8,6 +8,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Repository @RequiredArgsConstructor @@ -23,22 +24,21 @@ public List findAllByUserIdAndProjectIdAndDeletedFalseOrderByUpdat } @Override - public List findAllByUserIdAndProjectId(Long userId, Long projectId) { - return springDataConversationRepository.findByUserIdAndProjectId(userId, projectId).stream() - .map(ConversationEntity::toDomain) - .toList(); + @Transactional + public int softDeleteAllByUserIdAndProjectId(Long userId, Long projectId, LocalDateTime deletedAt) { + return springDataConversationRepository.softDeleteByUserIdAndProjectId(userId, projectId, deletedAt); } @Override - public List findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(Long userId) { - return springDataConversationRepository.findByUserIdAndDeletedTrueOrderByUpdatedAtDesc(userId).stream() - .map(ConversationEntity::toDomain) - .toList(); + @Transactional + public int deleteAllByUserIdAndProjectId(Long userId, Long projectId) { + return springDataConversationRepository.deleteByUserIdAndProjectId(userId, projectId); } + @Override - public List findAllByDeletedTrueAndDeletedAtLessThanEqual(LocalDateTime cutoff) { - return springDataConversationRepository.findByDeletedTrueAndDeletedAtLessThanEqual(cutoff).stream() + public List findAllByUserIdAndDeletedTrueOrderByUpdatedAtDesc(Long userId) { + return springDataConversationRepository.findByUserIdAndDeletedTrueOrderByUpdatedAtDesc(userId).stream() .map(ConversationEntity::toDomain) .toList(); } diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java index d5e948a2..e34f290f 100644 --- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java +++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataConversationRepository.java @@ -13,12 +13,8 @@ public interface SpringDataConversationRepository extends JpaRepository findByUserIdAndProjectIdAndDeletedFalseOrderByUpdatedAtDesc(Long userId, Long projectId); - List findByUserIdAndProjectId(Long userId, Long projectId); - List findByUserIdAndDeletedTrueOrderByUpdatedAtDesc(Long userId); - List findByDeletedTrueAndDeletedAtLessThanEqual(LocalDateTime cutoff); - Optional findByIdAndUserIdAndDeletedFalse(Long conversationId, Long userId); Optional findByIdAndUserId(Long conversationId, Long userId); @@ -34,4 +30,42 @@ public interface SpringDataConversationRepository extends JpaRepository{@code updated_at} 은 그대로 갱신된다 — {@code chat_sessions.updated_at} 컬럼이 + * {@code ON UPDATE CURRENT_TIMESTAMP} 라 벌크 UPDATE 에서도 DB 가 채운다(@UpdateTimestamp 는 + * 벌크 문장에서 돌지 않는다). 휴지통 목록이 updated_at 순이므로 이게 유지돼야 순서가 같다.

+ * + *

{@code clearAutomatically} 를 켜지 않는다. 영속성 컨텍스트를 비우면 같은 트랜잭션의 + * {@code ProjectRepositoryAdapter#save} 가 L1 캐시 히트에 기대는 낙관적 잠금 경로(그쪽 javadoc + * 의 Case A)를 잃는다 — 이 메서드는 대화를 읽지 않으므로 비울 이유도 없다.

+ */ + @Modifying(flushAutomatically = true) + @Query(""" + update ConversationEntity c + set c.deleted = true, + c.deletedAt = :deletedAt + where c.userId = :userId + and c.projectId = :projectId + and c.deleted = false + """) + int softDeleteByUserIdAndProjectId( + @Param("userId") Long userId, + @Param("projectId") Long projectId, + @Param("deletedAt") LocalDateTime deletedAt + ); + + /** + * U6 6-8: 프로젝트의 대화를 한 문장으로 지운다. 메시지는 DB 가 지운다 — chat_messages 의 FK 가 + * V19 부터 ON DELETE CASCADE 다(#338). 실제 SQL DELETE 이므로 벌크여도 CASCADE·SET NULL 이 + * 그대로 돈다(approvals·agent_runs 등은 SET NULL 로 이력을 보존한다). + */ + @Modifying(flushAutomatically = true) + @Query("delete from ConversationEntity c where c.userId = :userId and c.projectId = :projectId") + int deleteByUserIdAndProjectId(@Param("userId") Long userId, @Param("projectId") Long projectId); + + // 보관 기간이 지난 휴지통 대화 삭제는 위 deleteExpiredTrash 하나로 충분하다(#340 에서 먼저 들어왔다). } diff --git a/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java b/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java index 5d41807c..94a85e97 100644 --- a/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java +++ b/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java @@ -14,6 +14,7 @@ import com.example.dvely.chat.application.result.MessageResult; import com.example.dvely.chat.domain.model.ChatMessage; import com.example.dvely.chat.domain.model.Conversation; +import com.example.dvely.chat.domain.policy.ChatTrashPolicy; import com.example.dvely.chat.domain.repository.ChatMessageRepository; import com.example.dvely.chat.domain.repository.ConversationRepository; import com.example.dvely.project.domain.model.Project; @@ -27,6 +28,7 @@ import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -199,18 +201,25 @@ void permanentlyDeleteConversationRejectsActiveConversation() { } /** - * #340 5-9: 만료된 휴지통 대화를 벌크 DELETE 한 문장으로 지운다. 예전에는 엔티티를 전부 - * 로드한 뒤 {@code deleteById} 를 N 번 불렀다 — 지우려고 읽고, 지우려고 또 왕복했다. + * #340 5-9 · #341 6-8: 만료된 휴지통 대화를 벌크 DELETE 한 문장으로 지운다. 예전에는 엔티티를 + * 전부 로드한 뒤 {@code deleteById} 를 N 번 불렀다 — 지우려고 읽고, 지우려고 또 왕복했다. * 삭제 조건이 곧 SELECT 조건이었으므로 읽을 이유가 없다. */ @Test void purgeExpiredConversationsDeletesInOneBulkStatement() { + LocalDateTime before = LocalDateTime.now(); when(conversationRepository.deleteExpiredTrash(any())).thenReturn(2); assertThat(chatCommandService.purgeExpiredConversations()).isEqualTo(2); - verify(conversationRepository).deleteExpiredTrash(any()); + ArgumentCaptor cutoff = ArgumentCaptor.forClass(LocalDateTime.class); + verify(conversationRepository).deleteExpiredTrash(cutoff.capture()); + assertThat(cutoff.getValue()) + .isBetween(ChatTrashPolicy.cutoff(before), ChatTrashPolicy.cutoff(LocalDateTime.now())); + verify(conversationRepository, never()).deleteById(any()); verify(conversationRepository, never()).findAllByDeletedTrueAndDeletedAtLessThanEqual(any()); + } + verify(conversationRepository, never()).deleteById(any()); } From 89290843fb9191e61e4e34d2e7e96eaafe41ef2d Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:50:44 +0900 Subject: [PATCH 08/13] =?UTF-8?q?perf(chat):=20=EB=8C=80=ED=99=94=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A1=B0=ED=9A=8C=EC=97=90=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=EA=B3=BC=20=EC=BB=A4=EC=84=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=99=EC=9D=B8=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 대화의 메시지를 전부 내려주고 있었다. content 가 TEXT 라 대화가 길어질수록 한 번의 조회가 선형으로 무거워진다. 사용자 발화 1건마다 어시스턴트 메시지가 함께 쌓이는 구조라 (appendAssistant 호출 지점이 20곳 넘는다 — 계획 시작·승인 안내·스텝 진행·결과·배포 결과 등) 한 요청이 대략 5~9행을 만든다. 계약 변화(옵션 파라미터만 추가, 본문 모양 불변): GET /api/v1/conversations/{conversationId}/messages?limit=&after= - limit: 1~1000, 없으면 500. 500 이면 사용자 턴 55~100회를 덮는다 — 프로젝트 하나의 작업 세션으로는 넉넉하고, TEXT 한 페이지의 크기도 여기서 묶인다 - after: 직전 페이지의 마지막 message id(배타적). 정렬은 예전과 같은 message_id 오름차순 - 상한에 걸리면 응답 헤더 X-Qeploy-Next-Cursor 에 다음 커서. 헤더가 없으면 마지막 페이지 - 본문은 예전과 똑같은 JSON 배열이다. 헤더를 모르는 기존 FE 는 예전처럼 동작한다 커서를 created_at 이 아니라 message_id 로 잡은 이유는 #338 과 같다 — created_at 이 DATETIME(0) 이라 같은 초의 순서가 비결정적이고, 커서에서 그건 행을 건너뛰거나 두 번 주는 버그가 된다. **남는 위험 한 가지**(FE 이슈로 올려야 한다): 파라미터 없는 호출은 오래된 것부터 500건을 준다. 한 대화가 500행을 넘기면 커서를 쓰지 않는 화면은 최신 메시지를 못 본다. 기존 동작 유지(오름차순)를 택한 결과이고, 기본값을 500 으로 잡은 것이 그 완충이다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../chat/application/facade/ChatFacade.java | 8 ++- .../application/query/ChatQueryService.java | 31 +++++++-- .../repository/ChatMessageRepository.java | 6 ++ .../ChatMessageRepositoryAdapter.java | 10 +++ .../SpringDataChatMessageRepository.java | 23 +++++++ .../chat/presentation/ChatController.java | 24 +++++-- .../query/ChatQueryServiceTest.java | 69 ++++++++++++++++++- .../chat/presentation/ChatControllerTest.java | 8 ++- 8 files changed, 162 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/example/dvely/chat/application/facade/ChatFacade.java b/src/main/java/com/example/dvely/chat/application/facade/ChatFacade.java index 5bd8ec23..d2f3931a 100644 --- a/src/main/java/com/example/dvely/chat/application/facade/ChatFacade.java +++ b/src/main/java/com/example/dvely/chat/application/facade/ChatFacade.java @@ -5,6 +5,7 @@ import com.example.dvely.chat.application.query.ChatQueryService; import com.example.dvely.chat.application.result.ConversationResult; import com.example.dvely.chat.application.result.MessageResult; +import com.example.dvely.common.paging.CursorPage; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -48,7 +49,10 @@ public MessageResult sendMessage(Long userId, Long conversationId, String conten return chatCommandService.sendMessage(userId, conversationId, content, aiProvider); } - public List getMessages(Long userId, Long conversationId) { - return chatQueryService.getMessages(userId, conversationId); + public CursorPage getMessages(Long userId, + Long conversationId, + Integer limit, + String after) { + return chatQueryService.getMessages(userId, conversationId, limit, after); } } diff --git a/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java b/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java index 427f5f26..41514488 100644 --- a/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java +++ b/src/main/java/com/example/dvely/chat/application/query/ChatQueryService.java @@ -1,6 +1,8 @@ package com.example.dvely.chat.application.query; import com.example.dvely.chat.application.result.ConversationResult; +import com.example.dvely.common.paging.CursorPage; +import com.example.dvely.common.paging.CursorPaging; import com.example.dvely.chat.application.result.MessageResult; import com.example.dvely.chat.domain.exception.ConversationNotFoundException; import com.example.dvely.chat.domain.model.ChatMessage; @@ -66,14 +68,35 @@ public List getTrashConversations(Long userId) { .toList(); } + /** + * 메시지 목록의 상한. 사용자 발화 1건마다 어시스턴트 메시지가 함께 쌓이는 구조라(계획 시작·승인 + * 안내·스텝 진행·결과·배포 결과 등 appendAssistant 호출 지점이 20곳 넘는다) 한 번의 요청이 + * 대략 5~9행을 만든다. 500 이면 한 대화에서 사용자 턴 55~100회를 덮는다 — 프로젝트 하나의 + * 작업 세션으로는 넉넉하고, content 가 TEXT 라 한 페이지의 크기도 여기서 묶인다. + */ + private static final int DEFAULT_MESSAGE_LIMIT = 500; + private static final int MAX_MESSAGE_LIMIT = 1000; + public List getMessages(Long userId, Long conversationId) { + return getMessages(userId, conversationId, null, null).items(); + } + + /** + * U6(#341) 6-3: 상한 + 커서. 오름차순(오래된 것부터)이라는 기존 순서를 그대로 두고 상한만 얹었다. + * {@code after} 는 직전 페이지의 마지막 message id 로, 그보다 뒤의 메시지를 준다. + */ + public CursorPage getMessages(Long userId, + Long conversationId, + Integer limit, + String after) { conversationRepository.findByIdAndUserIdAndDeletedFalse(conversationId, userId) .orElseThrow(() -> new ConversationNotFoundException(conversationId, userId)); - return chatMessageRepository.findAllByConversationIdOrderByCreatedAtAsc(conversationId) - .stream() - .map(this::toMessageResult) - .toList(); + int size = CursorPaging.clamp(limit, DEFAULT_MESSAGE_LIMIT, MAX_MESSAGE_LIMIT); + List probed = chatMessageRepository.findPageByConversationId( + conversationId, CursorPaging.parseCursor(after), size + 1); + return CursorPaging.slice(probed, size, message -> String.valueOf(message.getId())) + .map(this::toMessageResult); } private Project resolveActiveProject(Long userId, Long projectId) { diff --git a/src/main/java/com/example/dvely/chat/domain/repository/ChatMessageRepository.java b/src/main/java/com/example/dvely/chat/domain/repository/ChatMessageRepository.java index a02ebd33..f0bd2dd0 100644 --- a/src/main/java/com/example/dvely/chat/domain/repository/ChatMessageRepository.java +++ b/src/main/java/com/example/dvely/chat/domain/repository/ChatMessageRepository.java @@ -12,6 +12,12 @@ public interface ChatMessageRepository { */ List findAllByConversationIdOrderByCreatedAtAsc(Long conversationId); + /** + * U6(#341) 6-3: 위와 같은 순서로, 다만 {@code after}(message id, 배타적) 이후부터 최대 + * {@code limit} 건. {@code after} 가 null 이면 처음부터다. + */ + List findPageByConversationId(Long conversationId, Long after, int limit); + // deleteAllByConversationId 는 없다(#338). chat_messages 의 FK 는 V19 부터 // ON DELETE CASCADE 라, 대화 행을 지우면 메시지는 DB 가 지운다. 별도 삭제를 두면 // 엔티티를 N 건 로드해 한 건씩 지운 뒤 CASCADE 가 같은 일을 또 하는 이중 삭제가 된다. diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ChatMessageRepositoryAdapter.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ChatMessageRepositoryAdapter.java index 7a98151e..cbd529c3 100644 --- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ChatMessageRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/ChatMessageRepositoryAdapter.java @@ -5,6 +5,7 @@ import com.example.dvely.chat.infrastructure.persistence.entity.ChatMessageEntity; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; @Repository @@ -20,6 +21,15 @@ public List findAllByConversationIdOrderByCreatedAtAsc(Long convers .toList(); } + @Override + public List findPageByConversationId(Long conversationId, Long after, int limit) { + return springDataChatMessageRepository + .findPageByConversationId(conversationId, after, PageRequest.of(0, limit)) + .stream() + .map(ChatMessageEntity::toDomain) + .toList(); + } + @Override public ChatMessage save(ChatMessage message) { ChatMessageEntity entity = ChatMessageEntity.from(message); diff --git a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataChatMessageRepository.java b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataChatMessageRepository.java index 138eb8bd..ba695169 100644 --- a/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataChatMessageRepository.java +++ b/src/main/java/com/example/dvely/chat/infrastructure/persistence/repository/SpringDataChatMessageRepository.java @@ -2,7 +2,10 @@ import com.example.dvely.chat.infrastructure.persistence.entity.ChatMessageEntity; import java.util.List; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface SpringDataChatMessageRepository extends JpaRepository { @@ -17,4 +20,24 @@ public interface SpringDataChatMessageRepository extends JpaRepository */ List findByConversationIdOrderByIdAsc(Long conversationId); + + /** + * U6(#341) 6-3: 위와 같은 정렬(message_id asc — 그 근거는 위 javadoc)에 상한과 커서를 더한 것. + * {@code after} 가 null 이면 처음부터다. + * + *

커서를 message_id 로 잡은 이유도 같다: created_at 은 DATETIME(0) 이라 같은 초의 행 순서가 + * 비결정적인데, 커서 페이지네이션에서 그건 행을 건너뛰거나 두 번 주는 버그가 된다.

+ */ + @Query(""" + select m + from ChatMessageEntity m + where m.conversationId = :conversationId + and (:after is null or m.id > :after) + order by m.id asc + """) + List findPageByConversationId( + @Param("conversationId") Long conversationId, + @Param("after") Long after, + Pageable pageable + ); } diff --git a/src/main/java/com/example/dvely/chat/presentation/ChatController.java b/src/main/java/com/example/dvely/chat/presentation/ChatController.java index a23b30a7..2a8dd6e5 100644 --- a/src/main/java/com/example/dvely/chat/presentation/ChatController.java +++ b/src/main/java/com/example/dvely/chat/presentation/ChatController.java @@ -5,6 +5,7 @@ import com.example.dvely.chat.presentation.dto.ConversationResponse; import com.example.dvely.chat.presentation.dto.MessageResponse; import com.example.dvely.chat.presentation.dto.SendMessageRequest; +import com.example.dvely.common.paging.CursorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -12,12 +13,14 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -126,17 +129,24 @@ public void permanentlyDeleteConversation( @Operation( summary = "대화 메시지 목록 조회", - description = "대화 세션에 저장된 메시지를 생성순으로 조회합니다. " + - "삭제되지 않은 현재 유저 소유 대화에 대해서만 메시지를 반환합니다." + description = "대화 세션에 저장된 메시지를 생성순(오래된 것부터)으로 조회합니다. " + + "삭제되지 않은 현재 유저 소유 대화에 대해서만 메시지를 반환합니다. " + + "limit 기본 500, 최대 1000(초과 시 1000으로 보정). 더 남아 있으면 응답 헤더 " + + "X-Qeploy-Next-Cursor 에 다음 커서가 실리며, after 로 넘겨 이어 받습니다. " + + "헤더가 없으면 마지막 페이지입니다." ) @GetMapping("/api/v1/conversations/{conversationId}/messages") - public List getMessages( + public ResponseEntity> getMessages( @Parameter(hidden = true) @AuthenticationPrincipal Long userId, - @Parameter(description = "메시지 목록을 조회할 대화 ID") @PathVariable Long conversationId + @Parameter(description = "메시지 목록을 조회할 대화 ID") @PathVariable Long conversationId, + @Parameter(description = "한 번에 받을 최대 건수(1~1000). 없으면 500") + @RequestParam(required = false) Integer limit, + @Parameter(description = "이전 페이지의 X-Qeploy-Next-Cursor 값. 그 메시지보다 뒤의 것만 반환") + @RequestParam(required = false) String after ) { - return chatFacade.getMessages(userId, conversationId).stream() - .map(chatMapper::toMessageResponse) - .toList(); + return CursorResponse.of( + chatFacade.getMessages(userId, conversationId, limit, after) + .map(chatMapper::toMessageResponse)); } @Operation( diff --git a/src/test/java/com/example/dvely/chat/application/query/ChatQueryServiceTest.java b/src/test/java/com/example/dvely/chat/application/query/ChatQueryServiceTest.java index 6b01b8fb..efd133fa 100644 --- a/src/test/java/com/example/dvely/chat/application/query/ChatQueryServiceTest.java +++ b/src/test/java/com/example/dvely/chat/application/query/ChatQueryServiceTest.java @@ -1,7 +1,10 @@ package com.example.dvely.chat.application.query; 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.anyInt; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -150,7 +153,7 @@ void getMessagesLeavesTaskIdNullSinceHistoricalMessagesHaveNoTaskCorrelation() { ChatMessage message = new ChatMessage(31L, 21L, ChatRole.ASSISTANT, "승인 정책에 따라 작업을 시작합니다.", 0, LocalDateTime.now()); when(conversationRepository.findByIdAndUserIdAndDeletedFalse(21L, 2L)) .thenReturn(Optional.of(conversation)); - when(chatMessageRepository.findAllByConversationIdOrderByCreatedAtAsc(21L)) + when(chatMessageRepository.findPageByConversationId(eq(21L), eq(null), anyInt())) .thenReturn(List.of(message)); var results = service.getMessages(2L, 21L); @@ -161,6 +164,62 @@ void getMessagesLeavesTaskIdNullSinceHistoricalMessagesHaveNoTaskCorrelation() { assertThat(results).singleElement().satisfies(result -> assertThat(result.taskId()).isNull()); } + /** + * U6(#341) 6-3: limit 를 안 주면 기본 500, 넘겨 달라고 하면 1000 으로 깎는다. 리포지터리에는 + * "더 있는지" 를 알기 위해 limit+1 을 요청한다. + */ + @Test + void messageLimitDefaultsTo500AndIsClampedTo1000() { + when(conversationRepository.findByIdAndUserIdAndDeletedFalse(21L, 2L)) + .thenReturn(Optional.of(activeConversation())); + when(chatMessageRepository.findPageByConversationId(eq(21L), eq(null), anyInt())) + .thenReturn(List.of()); + + service.getMessages(2L, 21L, null, null); + service.getMessages(2L, 21L, 9999, null); + + verify(chatMessageRepository).findPageByConversationId(21L, null, 501); + verify(chatMessageRepository).findPageByConversationId(21L, null, 1001); + } + + /** 상한에 걸리면 마지막으로 살아남은 메시지의 id 를 다음 커서로 실어 준다(본문은 상한만큼). */ + @Test + void messagePageCarriesNextCursorWhenMoreRemain() { + when(conversationRepository.findByIdAndUserIdAndDeletedFalse(21L, 2L)) + .thenReturn(Optional.of(activeConversation())); + when(chatMessageRepository.findPageByConversationId(21L, null, 3)) + .thenReturn(List.of(message(31L), message(32L), message(33L))); + + var page = service.getMessages(2L, 21L, 2, null); + + assertThat(page.items()).hasSize(2); + assertThat(page.nextCursor()).isEqualTo("32"); + } + + /** 마지막 페이지에는 커서가 없다 — 클라이언트가 루프를 끝낼 신호다. */ + @Test + void messagePageHasNoCursorOnTheLastPage() { + when(conversationRepository.findByIdAndUserIdAndDeletedFalse(21L, 2L)) + .thenReturn(Optional.of(activeConversation())); + when(chatMessageRepository.findPageByConversationId(21L, 32L, 3)) + .thenReturn(List.of(message(33L))); + + var page = service.getMessages(2L, 21L, 2, "32"); + + assertThat(page.items()).hasSize(1); + assertThat(page.nextCursor()).isNull(); + } + + /** 우리가 내보낸 커서만 유효하다. 숫자가 아니면 조용히 첫 페이지로 돌아가지 않고 끊는다. */ + @Test + void messagePageRejectsATamperedCursor() { + when(conversationRepository.findByIdAndUserIdAndDeletedFalse(21L, 2L)) + .thenReturn(Optional.of(activeConversation())); + + assertThatThrownBy(() -> service.getMessages(2L, 21L, null, "not-a-cursor")) + .isInstanceOf(IllegalArgumentException.class); + } + @Test void trashResponseExcludesConversationAfterSevenDays() { LocalDateTime deletedAt = LocalDateTime.now().minusDays(7).minusMinutes(1); @@ -180,6 +239,14 @@ void trashResponseExcludesConversationAfterSevenDays() { assertThat(service.getTrashConversations(2L)).isEmpty(); } + private Conversation activeConversation() { + return new Conversation(21L, 2L, 7L, false, null, LocalDateTime.now(), LocalDateTime.now()); + } + + private ChatMessage message(Long messageId) { + return new ChatMessage(messageId, 21L, ChatRole.ASSISTANT, "내용", 0, LocalDateTime.now()); + } + private Conversation trashConversation(Long conversationId, Long projectId, LocalDateTime deletedAt) { return new Conversation( conversationId, 2L, projectId, "휴지통 대화", diff --git a/src/test/java/com/example/dvely/chat/presentation/ChatControllerTest.java b/src/test/java/com/example/dvely/chat/presentation/ChatControllerTest.java index 77576f39..51d119ea 100644 --- a/src/test/java/com/example/dvely/chat/presentation/ChatControllerTest.java +++ b/src/test/java/com/example/dvely/chat/presentation/ChatControllerTest.java @@ -10,6 +10,7 @@ import com.example.dvely.chat.infrastructure.mapper.ChatMapper; import com.example.dvely.chat.presentation.dto.ConversationResponse; import com.example.dvely.chat.presentation.dto.MessageResponse; +import com.example.dvely.common.paging.CursorPage; import com.example.dvely.chat.presentation.dto.SendMessageRequest; import java.time.LocalDateTime; import java.util.List; @@ -113,14 +114,15 @@ void getMessages_delegatesUsingAuthenticatedUserIdAndConversationId() { , null); - when(chatFacade.getMessages(1L, 20L)).thenReturn(List.of(result)); + when(chatFacade.getMessages(1L, 20L, null, null)) + .thenReturn(CursorPage.of(List.of(result))); when(chatMapper.toMessageResponse(result)).thenReturn(response); - List responses = chatController.getMessages(1L, 20L); + List responses = chatController.getMessages(1L, 20L, null, null).getBody(); assertThat(responses).hasSize(1); assertThat(responses.get(0).messageId()).isEqualTo(100L); - verify(chatFacade).getMessages(1L, 20L); + verify(chatFacade).getMessages(1L, 20L, null, null); } @Test From 0368276c206a23d908cb7cc34b4553d97fed189d Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:50:44 +0900 Subject: [PATCH 09/13] =?UTF-8?q?perf(list):=20=EC=8A=B9=EC=9D=B8=C2=B7?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A9=EB=A1=9D=EC=97=90=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=EA=B3=BC=20=EC=BB=A4=EC=84=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=99=EC=9D=B8=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 무제한이던 두 목록에 상한을 둔다. 변경·환경변수 목록은 각각 6-1·6-5 커밋에 함께 들어갔고 (같은 메서드를 건드린다) 여기는 남은 둘이다. 계약 변화(옵션 파라미터만 추가, 본문 모양 불변): - GET /api/v1/projects/{projectId}/approvals?limit=&after= (기본 200, 최대 500) - GET /api/v1/projects/{projectId}/domains?limit=&after= (기본 200, 최대 500) - 둘 다 최신순이므로 after 는 "그 항목보다 오래된 것" 을 뜻한다(id 내림차순 커서) - 상한에 걸리면 응답 헤더 X-Qeploy-Next-Cursor. 본문은 예전과 똑같은 JSON 배열이다 기본값 근거. 승인은 사용자 요청 1회당 0~2건이라 200 이면 최근 100회 이상의 작업을 덮는다. 도메인은 사람이 직접 연결하는 것이라 한 프로젝트에 수십 개면 이미 비정상이고 200 은 그 훨씬 위다. 개요·활동로그가 부르는 내부 경로도 같은 상한을 쓰는데, 두 화면 모두 최신순 목록을 합쳐 보여주므로 잘리는 쪽은 활동로그 맨 아래다. 개요의 "현재 도메인" 선택도 최신순 목록에서 고르므로 상한에 걸려도 고르는 결과가 같다. 도메인 쪽은 무제한 조회(findByProjectIdOrderByCreatedAtDesc)를 그대로 남겼다 — 배포·도메인 로직 네 곳이 그걸 "프로젝트의 전체 도메인" 으로 쓰고 있고 남의 구역이다. 사용자에게 내보내는 목록만 새 메서드를 쓴다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../application/facade/ApprovalFacade.java | 8 ++++-- .../query/ApprovalQueryService.java | 28 +++++++++++++++---- .../domain/repository/ApprovalRepository.java | 6 +++- .../repository/ApprovalRepositoryAdapter.java | 5 ++-- .../SpringDataApprovalRepository.java | 19 ++++++++++++- .../presentation/ApprovalController.java | 21 +++++++++----- .../facade/DomainBindingFacade.java | 8 ++++-- .../query/DomainBindingQueryService.java | 26 +++++++++++++++-- .../repository/DomainBindingRepository.java | 7 +++++ .../DomainBindingRepositoryAdapter.java | 10 +++++++ .../SpringDataDomainBindingRepository.java | 16 +++++++++++ .../presentation/DomainBindingController.java | 22 +++++++++++---- 12 files changed, 147 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/example/dvely/approval/application/facade/ApprovalFacade.java b/src/main/java/com/example/dvely/approval/application/facade/ApprovalFacade.java index cb111880..86904f74 100644 --- a/src/main/java/com/example/dvely/approval/application/facade/ApprovalFacade.java +++ b/src/main/java/com/example/dvely/approval/application/facade/ApprovalFacade.java @@ -3,6 +3,7 @@ import com.example.dvely.approval.application.command.ApprovalCommandService; import com.example.dvely.approval.application.query.ApprovalQueryService; import com.example.dvely.approval.application.result.ApprovalResult; +import com.example.dvely.common.paging.CursorPage; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,8 +15,11 @@ public class ApprovalFacade { private final ApprovalQueryService queryService; private final ApprovalCommandService commandService; - public List getProjectApprovals(Long ownerUserId, Long projectId) { - return queryService.getProjectApprovals(ownerUserId, projectId); + public CursorPage getProjectApprovals(Long ownerUserId, + Long projectId, + Integer limit, + String after) { + return queryService.getProjectApprovals(ownerUserId, projectId, limit, after); } public ApprovalResult getApproval(Long ownerUserId, Long approvalId) { diff --git a/src/main/java/com/example/dvely/approval/application/query/ApprovalQueryService.java b/src/main/java/com/example/dvely/approval/application/query/ApprovalQueryService.java index d385bed9..1eadd70d 100644 --- a/src/main/java/com/example/dvely/approval/application/query/ApprovalQueryService.java +++ b/src/main/java/com/example/dvely/approval/application/query/ApprovalQueryService.java @@ -8,6 +8,8 @@ import com.example.dvely.approval.domain.model.Approval; import com.example.dvely.approval.domain.repository.ApprovalRepository; import com.example.dvely.common.exception.NotFoundException; +import com.example.dvely.common.paging.CursorPage; +import com.example.dvely.common.paging.CursorPaging; import com.example.dvely.project.domain.exception.ProjectNotFoundException; import com.example.dvely.project.domain.repository.ProjectRepository; import java.util.List; @@ -23,14 +25,30 @@ public class ApprovalQueryService { private final ApprovalRepository approvalRepository; private final ProjectRepository projectRepository; + /** + * limit 를 안 받는 호출(프로젝트 개요·활동로그)의 상한. 승인은 사용자 요청 1회당 0~2건이라 + * 200 이면 최근 100회 이상의 작업을 덮는다. 활동로그는 최신순으로 합쳐 보여주는 화면이라 + * 잘리는 쪽은 맨 아래다. + */ + private static final int DEFAULT_APPROVAL_LIMIT = 200; + private static final int MAX_APPROVAL_LIMIT = 500; + public List getProjectApprovals(Long ownerUserId, Long projectId) { + return getProjectApprovals(ownerUserId, projectId, null, null).items(); + } + + /** U6(#341) 6-4: 상한 + 커서. 최신순이라 {@code after} 는 "그 승인보다 오래된 것" 을 뜻한다. */ + public CursorPage getProjectApprovals(Long ownerUserId, + Long projectId, + Integer limit, + String after) { projectRepository.findByIdAndOwnerUserIdAndDeletedFalse(projectId, ownerUserId) .orElseThrow(() -> new ProjectNotFoundException(projectId, ownerUserId)); - return approvalRepository - .findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId) - .stream() - .map(this::toResult) - .toList(); + int size = CursorPaging.clamp(limit, DEFAULT_APPROVAL_LIMIT, MAX_APPROVAL_LIMIT); + List probed = approvalRepository.findProjectApprovalsPage( + projectId, ownerUserId, CursorPaging.parseCursor(after), size + 1); + return CursorPaging.slice(probed, size, approval -> String.valueOf(approval.getId())) + .map(this::toResult); } public ApprovalResult getApproval(Long ownerUserId, Long approvalId) { diff --git a/src/main/java/com/example/dvely/approval/domain/repository/ApprovalRepository.java b/src/main/java/com/example/dvely/approval/domain/repository/ApprovalRepository.java index b332dc88..0fb1876b 100644 --- a/src/main/java/com/example/dvely/approval/domain/repository/ApprovalRepository.java +++ b/src/main/java/com/example/dvely/approval/domain/repository/ApprovalRepository.java @@ -32,7 +32,11 @@ public interface ApprovalRepository { */ Optional findRoutingInfo(Long approvalId, Long ownerUserId); - List findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId); + /** + * U6(#341) 6-4: 프로젝트 승인 목록 한 페이지. 최신순이고 {@code after}(승인 id, 배타적)보다 + * 오래된 것만 준다. {@code after} 가 null 이면 처음부터다. + */ + List findProjectApprovalsPage(Long projectId, Long ownerUserId, Long after, int limit); List findByTaskIdOrderByIdAsc(String taskId); diff --git a/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/ApprovalRepositoryAdapter.java b/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/ApprovalRepositoryAdapter.java index 759eba2b..2e9f8c75 100644 --- a/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/ApprovalRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/ApprovalRepositoryAdapter.java @@ -41,8 +41,9 @@ public Optional findByIdAndOwnerUserIdForUpdate(Long approvalId, Long } @Override - public List findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId) { - return springDataRepository.findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(projectId, ownerUserId) + public List findProjectApprovalsPage(Long projectId, Long ownerUserId, Long after, int limit) { + return springDataRepository.findProjectApprovalsPage( + projectId, ownerUserId, after, org.springframework.data.domain.PageRequest.of(0, limit)) .stream() .map(ApprovalEntity::toDomain) .toList(); diff --git a/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/SpringDataApprovalRepository.java b/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/SpringDataApprovalRepository.java index eb293ee6..fbf7e570 100644 --- a/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/SpringDataApprovalRepository.java +++ b/src/main/java/com/example/dvely/approval/infrastructure/persistence/repository/SpringDataApprovalRepository.java @@ -4,6 +4,7 @@ import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -23,7 +24,23 @@ Optional findByIdAndOwnerUserIdForUpdate( @Param("approvalId") Long approvalId, @Param("ownerUserId") Long ownerUserId ); - List findByProjectIdAndOwnerUserIdOrderByCreatedAtDesc(Long projectId, Long ownerUserId); + // U6(#341) 6-4: 무제한이던 목록에 상한과 커서를 더했다. created_at 이 DATETIME(초) 라 같은 초의 + // 행 순서가 비결정적인데 커서 페이지네이션에서는 그게 행을 건너뛰거나 두 번 주는 버그가 되므로 + // id 를 tiebreaker 로 붙였다. 최신순이라 커서는 "이 id 보다 작은 것"(= 더 오래된 것)이다. + @Query(""" + select a + from ApprovalEntity a + where a.projectId = :projectId + and a.ownerUserId = :ownerUserId + and (:after is null or a.id < :after) + order by a.createdAt desc, a.id desc + """) + List findProjectApprovalsPage( + @Param("projectId") Long projectId, + @Param("ownerUserId") Long ownerUserId, + @Param("after") Long after, + Pageable pageable + ); // Backs ApprovalRepository#existsByProjectIdAndTypeAndStatus. Reuses the existing // idx_approvals_project_id index (V14) — MySQL scans by project_id then filters type/status diff --git a/src/main/java/com/example/dvely/approval/presentation/ApprovalController.java b/src/main/java/com/example/dvely/approval/presentation/ApprovalController.java index 25749011..ae90dfc7 100644 --- a/src/main/java/com/example/dvely/approval/presentation/ApprovalController.java +++ b/src/main/java/com/example/dvely/approval/presentation/ApprovalController.java @@ -5,15 +5,18 @@ import com.example.dvely.approval.presentation.dto.ApprovalDecisionRequest; import com.example.dvely.approval.presentation.dto.ApprovalInputResponse; import com.example.dvely.approval.presentation.dto.ApprovalResponse; +import com.example.dvely.common.paging.CursorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Tag(name = "Approval", description = "Agent 작업 승인 및 거절 API") @@ -25,17 +28,21 @@ public class ApprovalController { @Operation( summary = "프로젝트 승인 목록 조회", - description = "프로젝트에서 생성된 모든 승인 요청을 상태(PENDING/APPROVED/REJECTED/CANCELLED)와 " + - "무관하게 최신순으로 조회합니다." + description = "프로젝트에서 생성된 승인 요청을 상태(PENDING/APPROVED/REJECTED/CANCELLED)와 " + + "무관하게 최신순으로 조회합니다. limit 기본 200, 최대 500(초과 시 500으로 보정). " + + "더 오래된 것이 남아 있으면 응답 헤더 X-Qeploy-Next-Cursor 에 다음 커서가 실리며, " + + "after 로 넘겨 이어 받습니다." ) @GetMapping("/api/v1/projects/{projectId}/approvals") - public List getProjectApprovals( + public ResponseEntity> getProjectApprovals( @AuthenticationPrincipal Long ownerUserId, - @PathVariable Long projectId + @PathVariable Long projectId, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) String after ) { - return approvalFacade.getProjectApprovals(ownerUserId, projectId).stream() - .map(this::toResponse) - .toList(); + return CursorResponse.of( + approvalFacade.getProjectApprovals(ownerUserId, projectId, limit, after) + .map(this::toResponse)); } @Operation( diff --git a/src/main/java/com/example/dvely/domainbinding/application/facade/DomainBindingFacade.java b/src/main/java/com/example/dvely/domainbinding/application/facade/DomainBindingFacade.java index 207f7943..cab1b84e 100644 --- a/src/main/java/com/example/dvely/domainbinding/application/facade/DomainBindingFacade.java +++ b/src/main/java/com/example/dvely/domainbinding/application/facade/DomainBindingFacade.java @@ -3,6 +3,7 @@ import com.example.dvely.domainbinding.application.command.DomainBindingCommandService; import com.example.dvely.domainbinding.application.command.dto.BindDomainCommand; import com.example.dvely.domainbinding.application.query.DomainBindingQueryService; +import com.example.dvely.common.paging.CursorPage; import com.example.dvely.domainbinding.application.result.DomainBindingResult; import com.example.dvely.domainbinding.application.result.DomainSearchResult; import com.example.dvely.domainbinding.application.result.VerificationGuideResult; @@ -21,8 +22,11 @@ public DomainSearchResult search(String keyword) { return queryService.search(keyword); } - public List getProjectDomains(Long ownerUserId, Long projectId) { - return queryService.getProjectDomains(ownerUserId, projectId); + public CursorPage getProjectDomains(Long ownerUserId, + Long projectId, + Integer limit, + String after) { + return queryService.getProjectDomains(ownerUserId, projectId, limit, after); } public DomainBindingResult bindDomain(Long ownerUserId, Long projectId, BindDomainCommand command) { diff --git a/src/main/java/com/example/dvely/domainbinding/application/query/DomainBindingQueryService.java b/src/main/java/com/example/dvely/domainbinding/application/query/DomainBindingQueryService.java index a4d3cc18..6ec63608 100644 --- a/src/main/java/com/example/dvely/domainbinding/application/query/DomainBindingQueryService.java +++ b/src/main/java/com/example/dvely/domainbinding/application/query/DomainBindingQueryService.java @@ -1,6 +1,8 @@ package com.example.dvely.domainbinding.application.query; import com.example.dvely.common.exception.NotFoundException; +import com.example.dvely.common.paging.CursorPage; +import com.example.dvely.common.paging.CursorPaging; import com.example.dvely.domainbinding.application.result.DomainBindingResult; import com.example.dvely.domainbinding.application.result.DomainSearchCandidateResult; import com.example.dvely.domainbinding.application.result.DomainSearchResult; @@ -63,11 +65,29 @@ public DomainSearchResult search(String keyword) { return new DomainSearchResult(label, results); } + /** + * limit 를 안 받는 호출(프로젝트 개요·활동로그)의 상한. 한 프로젝트의 도메인은 사람이 직접 + * 연결하는 것이라 수십 개면 이미 비정상이고, 200 은 그 훨씬 위다 — 개요의 "현재 도메인" 선택 + * 로직이 최신순 목록에서 고르므로 상한에 걸려도 고르는 결과는 같다. + */ + private static final int DEFAULT_DOMAIN_LIMIT = 200; + private static final int MAX_DOMAIN_LIMIT = 500; + public List getProjectDomains(Long ownerUserId, Long projectId) { + return getProjectDomains(ownerUserId, projectId, null, null).items(); + } + + /** U6(#341) 6-4: 상한 + 커서. 최신순이라 {@code after} 는 "그 도메인보다 오래된 것" 을 뜻한다. */ + public CursorPage getProjectDomains(Long ownerUserId, + Long projectId, + Integer limit, + String after) { resolveProject(ownerUserId, projectId); - return domainBindingRepository.findByProjectIdOrderByCreatedAtDesc(projectId).stream() - .map(this::toResult) - .toList(); + int size = CursorPaging.clamp(limit, DEFAULT_DOMAIN_LIMIT, MAX_DOMAIN_LIMIT); + List probed = domainBindingRepository.findProjectDomainsPage( + projectId, CursorPaging.parseCursor(after), size + 1); + return CursorPaging.slice(probed, size, domain -> String.valueOf(domain.getId())) + .map(this::toResult); } public DomainBindingResult getDomain(Long ownerUserId, Long domainId) { diff --git a/src/main/java/com/example/dvely/domainbinding/domain/repository/DomainBindingRepository.java b/src/main/java/com/example/dvely/domainbinding/domain/repository/DomainBindingRepository.java index 643c24ae..ce05e853 100644 --- a/src/main/java/com/example/dvely/domainbinding/domain/repository/DomainBindingRepository.java +++ b/src/main/java/com/example/dvely/domainbinding/domain/repository/DomainBindingRepository.java @@ -13,6 +13,13 @@ public interface DomainBindingRepository { List findByProjectIdOrderByCreatedAtDesc(Long projectId); + /** + * U6(#341) 6-4: 사용자에게 내보내는 도메인 목록 한 페이지. 최신순이고 {@code after}(도메인 id, + * 배타적)보다 오래된 것만 준다. 위의 무제한 조회는 배포·도메인 로직이 "프로젝트의 전체 도메인" + * 으로 쓰므로 그대로 남긴다. + */ + List findProjectDomainsPage(Long projectId, Long after, int limit); + /** * 해당 상태의 도메인을 오래된 순으로 최대 {@code limit} 건 읽는다. 검증 워커가 매 주기마다 * 외부 API(Cloudflare · 호스팅)를 도메인 수만큼 때리므로 한 번에 집는 양을 묶어야 한다. diff --git a/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/DomainBindingRepositoryAdapter.java b/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/DomainBindingRepositoryAdapter.java index 9c1b1686..b5d831f7 100644 --- a/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/DomainBindingRepositoryAdapter.java +++ b/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/DomainBindingRepositoryAdapter.java @@ -41,6 +41,16 @@ public List findByProjectIdOrderByCreatedAtDesc(Long projectId) { .toList(); } + @Override + public List findProjectDomainsPage(Long projectId, Long after, int limit) { + return springDataRepository + .findProjectDomainsPage(projectId, after, + org.springframework.data.domain.PageRequest.of(0, limit)) + .stream() + .map(DomainBindingEntity::toDomain) + .toList(); + } + @Override public List findByStatus(DomainStatus status, int limit) { return springDataRepository diff --git a/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/SpringDataDomainBindingRepository.java b/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/SpringDataDomainBindingRepository.java index f73a3eeb..4cd44d8c 100644 --- a/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/SpringDataDomainBindingRepository.java +++ b/src/main/java/com/example/dvely/domainbinding/infrastructure/persistence/repository/SpringDataDomainBindingRepository.java @@ -13,6 +13,22 @@ public interface SpringDataDomainBindingRepository extends JpaRepository findByProjectIdOrderByCreatedAtDesc(Long projectId); + // U6(#341) 6-4: 목록 응답 전용 페이지. 위 메서드는 배포·도메인 로직 여러 곳이 "프로젝트의 전체 + // 도메인" 으로 쓰므로 그대로 두고, 사용자에게 내보내는 목록만 상한을 받는다. created_at 이 + // DATETIME(초) 라 id 를 tiebreaker 로 붙였다 — 커서가 행을 건너뛰거나 두 번 주지 않게. + @org.springframework.data.jpa.repository.Query(""" + select d + from DomainBindingEntity d + where d.projectId = :projectId + and (:after is null or d.id < :after) + order by d.createdAt desc, d.id desc + """) + List findProjectDomainsPage( + @org.springframework.data.repository.query.Param("projectId") Long projectId, + @org.springframework.data.repository.query.Param("after") Long after, + org.springframework.data.domain.Pageable pageable + ); + // status 컬럼은 enum 이 아니라 String 이다(DomainBindingEntity:46). List findByStatusOrderByCreatedAtAsc(String status, Pageable pageable); diff --git a/src/main/java/com/example/dvely/domainbinding/presentation/DomainBindingController.java b/src/main/java/com/example/dvely/domainbinding/presentation/DomainBindingController.java index 1b4e4508..04066d41 100644 --- a/src/main/java/com/example/dvely/domainbinding/presentation/DomainBindingController.java +++ b/src/main/java/com/example/dvely/domainbinding/presentation/DomainBindingController.java @@ -1,6 +1,7 @@ package com.example.dvely.domainbinding.presentation; import com.example.dvely.domainbinding.application.command.dto.BindDomainCommand; +import com.example.dvely.common.paging.CursorResponse; import com.example.dvely.domainbinding.application.facade.DomainBindingFacade; import com.example.dvely.domainbinding.application.result.DomainBindingResult; import com.example.dvely.domainbinding.application.result.DomainSearchResult; @@ -21,6 +22,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -60,13 +62,21 @@ public DomainSearchResponse search( return toSearchResponse(domainBindingFacade.search(keyword)); } - @Operation(summary = "프로젝트 도메인 목록 조회", description = "프로젝트에 연결된 도메인 목록을 조회합니다.") + @Operation( + summary = "프로젝트 도메인 목록 조회", + description = "프로젝트에 연결된 도메인 목록을 최신순으로 조회합니다. limit 기본 200, " + + "최대 500(초과 시 500으로 보정). 더 오래된 것이 남아 있으면 응답 헤더 " + + "X-Qeploy-Next-Cursor 에 다음 커서가 실리며, after 로 넘겨 이어 받습니다." + ) @GetMapping("/api/v1/projects/{projectId}/domains") - public List getProjectDomains(@AuthenticationPrincipal Long ownerUserId, - @PathVariable Long projectId) { - return domainBindingFacade.getProjectDomains(ownerUserId, projectId).stream() - .map(this::toDomainResponse) - .toList(); + public ResponseEntity> getProjectDomains( + @AuthenticationPrincipal Long ownerUserId, + @PathVariable Long projectId, + @RequestParam(required = false) Integer limit, + @RequestParam(required = false) String after) { + return CursorResponse.of( + domainBindingFacade.getProjectDomains(ownerUserId, projectId, limit, after) + .map(this::toDomainResponse)); } @Operation( From 6e99ac5ddcced422421be71b680c18c9719317e9 Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:50:44 +0900 Subject: [PATCH 10/13] =?UTF-8?q?test(perf):=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=EC=9D=98=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=EC=84=B1=C2=B7=EB=B9=84=EB=B0=80=20=EB=AF=B8?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=EC=9D=84=20=EC=8B=A4=20DB=20=EB=A1=9C=20?= =?UTF-8?q?=EB=AA=BB=EB=B0=95=EB=8A=94=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U6 의 완료 기준을 테스트로 옮긴 것이다. ListProjectionResponseIdentityTest — 실 MySQL 에 행을 심고, 예전 경로(엔티티를 통째로 읽어 매핑)와 새 경로(프로젝션/전용 쿼리)의 결과를 각각 JSON 으로 직렬화해 문자열 비교한다. 예전 매핑은 테스트 안에 그대로 옮겨 적어 계약을 리터럴로 고정했다 — 본문 코드가 바뀌면 테스트가 같이 바뀌어 버리는 것을 막는 것이 요점이다. 6-1·6-2(3개 응답)·6-5(3개 목록)· 6-6·6-4(상한 아래에서는 커서가 안 붙는다)를 덮는다. 비밀 컬럼을 다루는 목록에는 동일성에 더해 "평문이 응답 JSON 어디에도 없다" 를 붙였다. 프로젝션이 실수로 비밀 컬럼을 포함하면 그건 성능 회귀가 아니라 유출이므로, 그렇게 잡히게 한다. 비밀이 아닌 값은 예전처럼 그대로 나가는 것도 같이 확인한다. ConversationBulkCleanupTest — 6-8 의 벌크 문장은 JPA 생애주기를 타지 않으므로, 예전과 같게 남아야 하는 두 가지를 DB 에 직접 물어본다. ① updated_at 이 여전히 갱신되는지(근거는 ON UPDATE CURRENT_TIMESTAMP 뿐이고, 휴지통 목록 정렬이 거기 달려 있다) ② 메시지가 ON DELETE CASCADE 로 지워지고 승인 이력은 ON DELETE SET NULL 로 보존되는지. 정렬 tiebreaker 때문에 심는 행마다 타임스탬프를 벌려 둔다 — 바뀐 쿼리들은 (created_at desc, id desc) 인데 원래는 created_at 만이었고 그 컬럼이 DATETIME(초) 라 같은 초의 "예전 순서" 라는 것이 애초에 하나로 정해지지 않았다. 비교가 성립하는 조건을 테스트가 직접 만든다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../perf/ConversationBulkCleanupTest.java | 165 +++++++ .../ListProjectionResponseIdentityTest.java | 402 ++++++++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 src/test/java/com/example/dvely/perf/ConversationBulkCleanupTest.java create mode 100644 src/test/java/com/example/dvely/perf/ListProjectionResponseIdentityTest.java diff --git a/src/test/java/com/example/dvely/perf/ConversationBulkCleanupTest.java b/src/test/java/com/example/dvely/perf/ConversationBulkCleanupTest.java new file mode 100644 index 00000000..8ab3fc2d --- /dev/null +++ b/src/test/java/com/example/dvely/perf/ConversationBulkCleanupTest.java @@ -0,0 +1,165 @@ +package com.example.dvely.perf; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.dvely.auth.domain.model.User; +import com.example.dvely.auth.domain.repository.UserRepository; +import com.example.dvely.auth.domain.value.GithubId; +import com.example.dvely.chat.application.command.ChatCommandService; +import com.example.dvely.project.domain.model.Project; +import com.example.dvely.project.domain.repository.ProjectRepository; +import com.example.dvely.project.domain.value.RepositoryVisibility; +import java.time.LocalDateTime; +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; + +/** + * U6(#341) 6-8 의 위험한 부분만 실 DB 로 못박는다. + * + *

대화 일괄 처리를 "엔티티 N 건 로드 → 한 건씩 save/deleteById" 에서 벌크 한 문장으로 바꿨다. + * 벌크 문장은 JPA 의 생애주기 콜백을 타지 않으므로, 예전과 같게 남아야 하는 두 가지가 실제로 + * 같은지는 DB 에 물어봐야만 알 수 있다:

+ *
    + *
  1. updated_at — @UpdateTimestamp 는 벌크 UPDATE 에서 돌지 않는다. 같게 유지되는 근거는 + * {@code chat_sessions.updated_at} 컬럼의 {@code ON UPDATE CURRENT_TIMESTAMP} 뿐이다. 휴지통 + * 목록이 updated_at 순이라 이게 깨지면 순서가 달라진다.
  2. + *
  3. 연관 삭제 — 메시지는 chat_messages 의 {@code ON DELETE CASCADE}(V19)가, 승인 등 + * 이력은 {@code ON DELETE SET NULL} 이 처리한다. 벌크 DELETE 도 실제 SQL DELETE 이므로 그대로 + * 돌아야 한다.
  4. + *
+ */ +@SpringBootTest +class ConversationBulkCleanupTest { + + @Autowired private JdbcTemplate jdbc; + @Autowired private UserRepository userRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private ChatCommandService chatCommandService; + + @Test + void trashingAProjectsConversationsMarksThemAndStillRefreshesUpdatedAt() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + Long first = seedConversation(userId, projectId); + Long second = seedConversation(userId, projectId); + // 이미 휴지통에 있는 대화는 건드리지 않아야 한다(예전 softDelete 의 "이미 삭제됐으면 무시"). + Long alreadyTrashed = seedConversation(userId, projectId); + LocalDateTime originalDeletedAt = LocalDateTime.now().minusDays(3).withNano(0); + jdbc.update("update chat_sessions set is_deleted = 1, deleted_at = ?, updated_at = ?" + + " where chat_session_id = ?", + originalDeletedAt, originalDeletedAt, alreadyTrashed); + jdbc.update("update chat_sessions set updated_at = ? where chat_session_id in (?, ?)", + LocalDateTime.now().minusDays(2), first, second); + + chatCommandService.trashConversationsForProject(userId, projectId); + + assertThat(deletedFlag(first)).isTrue(); + assertThat(deletedFlag(second)).isTrue(); + assertThat(deletedAt(first)).isNotNull(); + // 같은 문장이므로 두 행의 deleted_at 이 동일하다 — 예전 루프도 같은 타임스탬프를 썼다. + assertThat(deletedAt(first)).isEqualTo(deletedAt(second)); + // ON UPDATE CURRENT_TIMESTAMP 가 살아 있는지 — 휴지통 목록 정렬이 여기에 달려 있다. + assertThat(updatedAt(first)).isAfter(LocalDateTime.now().minusMinutes(1)); + // 이미 휴지통에 있던 대화의 deleted_at 은 덮이지 않는다. + assertThat(deletedAt(alreadyTrashed)).isEqualTo(originalDeletedAt); + assertThat(updatedAt(alreadyTrashed)).isEqualTo(originalDeletedAt); + } + + @Test + void deletingAProjectsConversationsCascadesMessagesAndNullsOutApprovalReferences() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + Long conversationId = seedConversation(userId, projectId); + seedMessage(conversationId); + seedMessage(conversationId); + Long approvalId = seedApproval(userId, projectId, conversationId); + + chatCommandService.deleteConversationsForProject(userId, projectId); + + assertThat(count("select count(*) from chat_sessions where chat_session_id = ?", conversationId)) + .isZero(); + assertThat(count("select count(*) from chat_messages where chat_session_id = ?", conversationId)) + .as("메시지는 chat_messages 의 ON DELETE CASCADE 가 지운다 — 벌크 DELETE 에서도 같다") + .isZero(); + assertThat(count("select count(*) from approvals where approval_id = ?", approvalId)) + .as("승인 이력은 보존된다 — FK 가 ON DELETE SET NULL 이다") + .isEqualTo(1); + assertThat(jdbc.queryForObject( + "select chat_session_id from approvals where approval_id = ?", Long.class, approvalId)) + .isNull(); + } + + @Test + void purgingExpiredTrashDeletesOnlyRowsPastTheRetentionCutoff() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + Long expired = seedConversation(userId, projectId); + Long stillWithinRetention = seedConversation(userId, projectId); + jdbc.update("update chat_sessions set is_deleted = 1, deleted_at = ? where chat_session_id = ?", + LocalDateTime.now().minusDays(8), expired); + jdbc.update("update chat_sessions set is_deleted = 1, deleted_at = ? where chat_session_id = ?", + LocalDateTime.now().minusDays(1), stillWithinRetention); + + int purged = chatCommandService.purgeExpiredConversations(); + + assertThat(purged).as("돌려주는 값은 지운 행 수다").isGreaterThanOrEqualTo(1); + assertThat(count("select count(*) from chat_sessions where chat_session_id = ?", expired)).isZero(); + assertThat(count("select count(*) from chat_sessions where chat_session_id = ?", + stillWithinRetention)).isEqualTo(1); + } + + // ---------------------------------------------------------------- 시딩·조회 헬퍼 + + private Long seedUser() { + return userRepository.save( + new User(new GithubId("u6-bulk-" + System.nanoTime()), "octo", null)).getId(); + } + + private Long seedProject(Long userId) { + return projectRepository.save(new Project( + userId, "u6-bulk", "scratch", null, "fast", RepositoryVisibility.PUBLIC)).getId(); + } + + private Long seedConversation(Long userId, Long projectId) { + jdbc.update("insert into chat_sessions (user_id, project_id, title) values (?, ?, ?)", + userId, projectId, "u6-bulk"); + return jdbc.queryForObject("select last_insert_id()", Long.class); + } + + private void seedMessage(Long conversationId) { + jdbc.update("insert into chat_messages (chat_session_id, role, content) values (?, 'user', ?)", + conversationId, "안녕"); + } + + private Long seedApproval(Long userId, Long projectId, Long conversationId) { + jdbc.update(""" + insert into approvals + (user_id, project_id, chat_session_id, approval_type, status, summary) + values (?, ?, ?, 'CHANGE', 'PENDING', '요약') + """, userId, projectId, conversationId); + return jdbc.queryForObject("select last_insert_id()", Long.class); + } + + private int count(String sql, Object... args) { + return jdbc.queryForObject(sql, Integer.class, args); + } + + private boolean deletedFlag(Long conversationId) { + return jdbc.queryForObject( + "select is_deleted from chat_sessions where chat_session_id = ?", Boolean.class, conversationId); + } + + private LocalDateTime deletedAt(Long conversationId) { + return jdbc.queryForObject( + "select deleted_at from chat_sessions where chat_session_id = ?", + LocalDateTime.class, conversationId); + } + + private LocalDateTime updatedAt(Long conversationId) { + return jdbc.queryForObject( + "select updated_at from chat_sessions where chat_session_id = ?", + LocalDateTime.class, conversationId); + } +} diff --git a/src/test/java/com/example/dvely/perf/ListProjectionResponseIdentityTest.java b/src/test/java/com/example/dvely/perf/ListProjectionResponseIdentityTest.java new file mode 100644 index 00000000..9e74279c --- /dev/null +++ b/src/test/java/com/example/dvely/perf/ListProjectionResponseIdentityTest.java @@ -0,0 +1,402 @@ +package com.example.dvely.perf; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.dvely.approval.application.query.ApprovalQueryService; +import com.example.dvely.agent.application.dto.AgentTask; +import com.example.dvely.agent.application.dto.TaskStatus; +import com.example.dvely.agent.infrastructure.persistence.repository.SpringDataAgentRunRepository; +import com.example.dvely.agent.infrastructure.store.TaskStore; +import com.example.dvely.auth.domain.model.User; +import com.example.dvely.auth.domain.repository.UserRepository; +import com.example.dvely.auth.domain.value.GithubId; +import com.example.dvely.change.application.result.ChangeResult; +import com.example.dvely.change.application.service.ChangeService; +import com.example.dvely.change.infrastructure.persistence.entity.ChangeEntity; +import com.example.dvely.change.infrastructure.persistence.repository.SpringDataChangeRepository; +import com.example.dvely.cloudconnection.application.query.CloudConnectionQueryService; +import com.example.dvely.cloudconnection.application.result.CloudConnectionResult; +import com.example.dvely.cloudconnection.domain.model.CloudConnection; +import com.example.dvely.cloudconnection.domain.repository.CloudConnectionRepository; +import com.example.dvely.cloudconnection.domain.value.CloudProvider; +import com.example.dvely.deployment.application.query.DeploymentQueryService; +import com.example.dvely.deployment.application.result.DeploymentCandidateResult; +import com.example.dvely.deployment.application.result.DeploymentHistoryResult; +import com.example.dvely.deployment.application.result.VersionResult; +import com.example.dvely.deployment.infrastructure.persistence.entity.DeploymentHistoryEntity; +import com.example.dvely.deployment.infrastructure.persistence.repository.SpringDataDeploymentHistoryRepository; +import com.example.dvely.environment.application.query.EnvironmentVariableQueryService; +import com.example.dvely.environment.application.result.EnvironmentVariableResult; +import com.example.dvely.environment.domain.model.EnvironmentVariable; +import com.example.dvely.environment.domain.repository.EnvironmentVariableRepository; +import com.example.dvely.environment.domain.value.EnvironmentScope; +import com.example.dvely.project.domain.model.Project; +import com.example.dvely.project.domain.repository.ProjectRepository; +import com.example.dvely.project.domain.value.RepositoryVisibility; +import com.example.dvely.provisioning.application.query.DatabaseProvisioningQueryService; +import com.example.dvely.provisioning.application.result.ProvisionedDatabaseResult; +import com.example.dvely.provisioning.domain.model.ProvisionedDatabase; +import com.example.dvely.provisioning.domain.repository.ProvisionedDatabaseRepository; +import com.example.dvely.provisioning.domain.value.DatabaseEngine; +import com.example.dvely.provisioning.domain.value.ProvisionMethod; +import com.example.dvely.provisioning.domain.value.ProvisionOrigin; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +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; + +/** + * U6(#341) 의 완료 기준: 프로젝션·전용 쿼리로 바꾼 목록들의 응답이 한 글자도 달라지지 않는다. + * + *

방식은 전부 같다 — 실 MySQL 에 행을 심고, 예전 경로(엔티티를 통째로 읽어 매핑)와 + * 새 경로(프로젝션/전용 쿼리)의 결과를 각각 JSON 으로 직렬화해 문자열로 비교한다. 예전 매핑은 + * 이 파일 안에 그대로 옮겨 적어 계약을 리터럴로 고정한다 — 본문 코드가 바뀌어도 이 테스트가 + * 같이 바뀌지 않도록 하는 것이 요점이다.

+ * + *

비밀 컬럼을 다루는 목록(6-5)에는 동일성 비교에 더해 평문이 응답 JSON 어디에도 없다는 + * 검사를 붙인다. 프로젝션이 실수로 비밀 컬럼을 포함하면 그게 곧 유출이므로, 성능 회귀가 아니라 + * 보안 회귀로 잡히게 한다.

+ * + *

정렬 tiebreaker 주의: 바뀐 쿼리들은 (created_at desc, id desc) 처럼 id 를 덧붙였다. 원래는 + * created_at 만이었고 그 컬럼이 DATETIME(초) 라 같은 초의 행 순서가 비결정적이었다(즉 "예전 + * 순서" 라는 것이 애초에 하나로 정해지지 않았다). 그래서 이 테스트는 심는 행마다 created_at 을 + * 다르게 줘서 비교 대상이 유일하게 정해지도록 한다.

+ */ +@SpringBootTest +class ListProjectionResponseIdentityTest { + + private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules(); + + @Autowired private JdbcTemplate jdbc; + @Autowired private UserRepository userRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private TaskStore taskStore; + @Autowired private ChangeService changeService; + @Autowired private SpringDataChangeRepository changeRepository; + @Autowired private DeploymentQueryService deploymentQueryService; + @Autowired private SpringDataDeploymentHistoryRepository deploymentHistoryRepository; + @Autowired private CloudConnectionQueryService cloudConnectionQueryService; + @Autowired private CloudConnectionRepository cloudConnectionRepository; + @Autowired private EnvironmentVariableQueryService environmentVariableQueryService; + @Autowired private EnvironmentVariableRepository environmentVariableRepository; + @Autowired private DatabaseProvisioningQueryService databaseProvisioningQueryService; + @Autowired private ProvisionedDatabaseRepository provisionedDatabaseRepository; + @Autowired private ApprovalQueryService approvalQueryService; + @Autowired private SpringDataAgentRunRepository agentRunRepository; + + // ---------------------------------------------------------------- 6-1 변경 목록 + + @Test + void changeListResponseIsUnchangedAfterDroppingDiffText() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + // diff_text 를 크게 심는 것이 핵심이다 — 예전 경로는 이걸 전부 읽어 버렸다. + String bigDiff = "diff --git a/x b/x\n".repeat(20_000); + for (int i = 0; i < 5; i++) { + seedChange(userId, projectId, i, bigDiff); + } + + List before = changeRepository.findAll().stream() + .filter(change -> projectId.equals(change.getProjectId()) + && userId.equals(change.getOwnerUserId())) + .sorted(Comparator.comparing(ChangeEntity::getCreatedAt).reversed() + .thenComparing(Comparator.comparing(ChangeEntity::getId).reversed())) + .map(ChangeEntity::toResult) + .toList(); + List after = changeService.getProjectChanges(userId, projectId); + + assertThat(before).hasSize(5); + assertThat(json(after)).isEqualTo(json(before)); + } + + // ---------------------------------------------------------------- 6-2 배포 이력 + + @Test + void deploymentHistoryVersionAndCandidateResponsesAreUnchanged() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + seedDeployment(userId, projectId, "v1", "LIVE", 1); + seedDeployment(userId, projectId, "v2", "FAILED", 2); + seedDeployment(userId, projectId, "v2", "LIVE", 3); + seedDeployment(userId, projectId, null, "LIVE", 4); + seedDeployment(userId, projectId, " ", "LIVE", 5); + + List rows = deploymentHistoryRepository.findAll().stream() + .filter(row -> projectId.equals(row.getProjectId())) + .sorted(Comparator.comparing(DeploymentHistoryEntity::getTriggeredAt).reversed() + .thenComparing(Comparator.comparing(DeploymentHistoryEntity::getId).reversed())) + .toList(); + + // ① 이력 목록 — 예전 매핑을 그대로 옮겨 적었다. + List historiesBefore = rows.stream() + .map(h -> new DeploymentHistoryResult( + h.getId(), h.getProjectId(), h.getDeployTargetType(), h.getVersionLabel(), + h.getDeployedUrl(), h.getStatus(), h.getFailureCode(), h.getErrorMessage(), + h.getTriggeredAt(), h.getUpdatedAt(), h.getRetriedFromHistoryId())) + .toList(); + assertThat(json(deploymentQueryService.getDeploymentHistories(userId, projectId))) + .isEqualTo(json(historiesBefore)); + + // ② 버전 목록 — version_label 있는 것만, 라벨별 최신 1건. + List versionsBefore = latestPerLabel(rows).stream() + .map(h -> new VersionResult( + h.getId(), h.getVersionLabel(), h.getCommitSha(), h.getTitle(), h.getStatus(), + h.getMergedAt() == null ? h.getTriggeredAt() : h.getMergedAt())) + .toList(); + assertThat(json(deploymentQueryService.getVersions(userId, projectId))) + .isEqualTo(json(versionsBefore)); + assertThat(versionsBefore).hasSize(2); // v1 · v2 (라벨 없는/공백인 두 건은 빠진다) + + // ③ 배포 후보 — ② 에 "LIVE 만" 이 더 붙는다. + List candidatesBefore = + latestPerLabel(rows.stream().filter(h -> "LIVE".equals(h.getStatus())).toList()).stream() + .map(h -> new DeploymentCandidateResult( + h.getId(), h.getVersionLabel(), h.getCommitSha(), h.getTitle(), + h.getStatus(), h.getDeployedUrl(), h.getUpdatedAt())) + .toList(); + assertThat(json(deploymentQueryService.getDeploymentCandidates(userId, projectId))) + .isEqualTo(json(candidatesBefore)); + } + + /** 예전 코드의 "라벨별 최신 1건" 을 그대로 옮긴 것. 최신순 입력을 전제한다. */ + private List latestPerLabel(List rows) { + Map latest = rows.stream() + .filter(h -> h.getVersionLabel() != null && !h.getVersionLabel().isBlank()) + .collect(Collectors.toMap( + DeploymentHistoryEntity::getVersionLabel, h -> h, (existing, later) -> existing)); + return latest.values().stream() + .sorted(Comparator.comparing(DeploymentHistoryEntity::getTriggeredAt).reversed()) + .toList(); + } + + // ---------------------------------------------------------------- 6-5 비밀 컬럼 + + @Test + void cloudConnectionListResponseIsUnchangedAndLeaksNoSecret() { + Long userId = seedUser(); + String secretKey = "SECRET-" + userId + "-aws-secret-access-key"; + String sessionToken = "SESSION-" + userId + "-token"; + String serviceAccountKeyJson = "{\"private_key\":\"SAKEY-" + userId + "\"}"; + Long awsId = cloudConnectionRepository.save(new CloudConnection( + userId, CloudProvider.AWS, "prod", "123456789012", "ap-northeast-2", null, + "ACCESS_KEY", "AKIAEXAMPLE", secretKey, sessionToken, null, null, null, null)).getId(); + Long gcpId = cloudConnectionRepository.save(new CloudConnection( + userId, CloudProvider.GCP, "gcp-prod", null, "asia-northeast3", null, + null, null, null, null, "SERVICE_ACCOUNT_KEY", serviceAccountKeyJson, + "gcp-project", "sa@example.iam.gserviceaccount.com")).getId(); + // created_at 이 DATETIME(초) 라 같은 초에 심으면 순서가 유일하게 정해지지 않는다(클래스 + // javadoc 참고). 비교가 성립하도록 두 행의 created_at 을 벌려 둔다. + jdbc.update("update cloud_connections set created_at = ? where cloud_connection_id = ?", + LocalDateTime.now().minusMinutes(2), awsId); + jdbc.update("update cloud_connections set created_at = ? where cloud_connection_id = ?", + LocalDateTime.now().minusMinutes(1), gcpId); + + // 예전 경로: 엔티티를 통째로 읽어(= 비밀 세 컬럼을 행마다 AES 복호화해) != null 로 바꿨다. + List before = cloudConnectionRepository + .findAllByOwnerUserIdOrderByCreatedAtDesc(userId).stream() + .map(c -> new CloudConnectionResult( + c.getId(), c.getProvider(), c.getDisplayName(), c.getAccountId(), c.getRegion(), + c.getRoleArn(), c.getAwsCredentialType(), c.getAccessKeyId(), + c.getSecretAccessKey() != null, c.getSessionToken() != null, + c.getGcpCredentialType(), c.getServiceAccountKeyJson() != null, + c.getGcpProjectId(), c.getServiceAccountEmail(), c.getStatus(), + c.getLastCheckedAt(), c.getCreatedAt(), c.getUpdatedAt())) + .toList(); + String after = json(cloudConnectionQueryService.getCloudConnections(userId)); + + assertThat(before).hasSize(2); + assertThat(after).isEqualTo(json(before)); + assertThat(after) + .as("목록 응답에 비밀 평문이 실리면 성능 회귀가 아니라 유출이다") + .doesNotContain(secretKey) + .doesNotContain(sessionToken) + .doesNotContain("SAKEY-" + userId); + } + + @Test + void environmentVariableListResponseIsUnchangedAndLeaksNoSecret() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + String secretValue = "sk_live_" + projectId + "_must_never_appear"; + environmentVariableRepository.save(new EnvironmentVariable( + projectId, EnvironmentScope.PRODUCTION, "STRIPE_SECRET_KEY", secretValue, true)); + environmentVariableRepository.save(new EnvironmentVariable( + projectId, EnvironmentScope.PREVIEW, "API_BASE_URL", "https://api.example.com", false)); + + // 예전 경로: 엔티티를 통째로 읽어(= secret 값까지 복호화해) 응답에서 null 로 지웠다. + List before = environmentVariableRepository + .findByProjectIdOrderByScopeAscKeyAsc(projectId).stream() + .map(v -> new EnvironmentVariableResult( + v.getId(), v.getScope().name(), v.getKey(), + v.isSecret() ? null : v.getValue(), v.isSecret(), + v.getCreatedAt(), v.getUpdatedAt())) + .toList(); + String after = json(environmentVariableQueryService.getVariables(userId, projectId, null)); + + assertThat(before).hasSize(2); + assertThat(after).isEqualTo(json(before)); + assertThat(after).doesNotContain(secretValue); + // 비밀이 아닌 값은 예전처럼 그대로 나간다 — 두 방향을 같이 못박는다. + assertThat(after).contains("https://api.example.com"); + } + + @Test + void provisionedDatabaseListResponseIsUnchangedAndLeaksNoPassword() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + String password = "pw_" + projectId + "_must_never_appear"; + ProvisionedDatabase ready = ProvisionedDatabase.pending( + projectId, ProvisionMethod.LOCAL, DatabaseEngine.MYSQL, ProvisionOrigin.MANUAL); + ready = provisionedDatabaseRepository.save(ready); + ready.markReady("res-1", "127.0.0.1", 13306, "appdb", "appuser", password, + LocalDateTime.now().plusMinutes(30)); + provisionedDatabaseRepository.save(ready); + + // 예전 경로: 엔티티를 통째로 읽어(= password 를 행마다 복호화해) 응답에서 버렸다. + List before = provisionedDatabaseRepository.findById(ready.getId()) + .stream() + .map(d -> new ProvisionedDatabaseResult( + d.getId(), d.getProjectId(), d.getMethod().name(), d.getEngine().name(), + d.getOrigin().name(), d.getStatus().name(), d.getHost(), d.getPort(), + d.getDatabaseName(), d.getUsername(), d.getExpiresAt(), + d.getFailureCode() == null ? null : d.getFailureCode().name(), + d.getErrorMessage(), d.getCreatedAt(), d.getUpdatedAt())) + .toList(); + String after = json(databaseProvisioningQueryService.list(userId, projectId)); + + assertThat(before).hasSize(1); + assertThat(after).isEqualTo(json(before)); + assertThat(after).doesNotContain(password); + } + + // ---------------------------------------------------------------- 6-6 살아있는 태스크 + + @Test + void activeRunProjectionReturnsTheSameTaskIdAndStatusTheEntityHolds() { + Long userId = seedUser(); + Long conversationId = seedConversation(userId, seedProject(userId)); + String taskId = "u6-active-" + System.nanoTime(); + taskStore.save(new AgentTask( + taskId, userId, null, conversationId, TaskStatus.QUEUED, + null, null, null, null, Instant.now())); + + var view = agentRunRepository.findActiveRuns( + conversationId, userId, List.of("DONE", "FAILED", "CANCELLED"), + org.springframework.data.domain.PageRequest.of(0, 1)); + + // 프로젝션 별칭(as taskId / as status)이 실제로 매핑되는지는 실 쿼리로만 확인된다. + assertThat(view).singleElement().satisfies(row -> { + assertThat(row.getTaskId()).isEqualTo(taskId); + assertThat(row.getStatus()).isEqualTo(TaskStatus.QUEUED.name()); + }); + assertThat(taskStore.findActiveTask(conversationId, userId)) + .contains(new TaskStore.ActiveTask(taskId, TaskStatus.QUEUED)); + } + + // ---------------------------------------------------------------- 6-4 승인 목록 + + @Test + void approvalListStaysTheSameWhenItFitsUnderTheCap() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + for (int i = 0; i < 3; i++) { + seedApproval(userId, projectId, i); + } + + var page = approvalQueryService.getProjectApprovals(userId, projectId, null, null); + + assertThat(page.items()).hasSize(3); + assertThat(page.nextCursor()) + .as("상한 아래면 커서가 붙지 않는다 — 기존 FE 는 예전과 똑같이 본다") + .isNull(); + assertThat(json(page.items())) + .isEqualTo(json(approvalQueryService.getProjectApprovals(userId, projectId))); + } + + // ---------------------------------------------------------------- 시딩 + + private String json(Object value) { + try { + return JSON.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private Long seedUser() { + return userRepository.save( + new User(new GithubId("u6-identity-" + System.nanoTime()), "octo", null)).getId(); + } + + private Long seedProject(Long userId) { + return projectRepository.save(new Project( + userId, "u6-identity", "scratch", null, "fast", RepositoryVisibility.PUBLIC)).getId(); + } + + private Long seedConversation(Long userId, Long projectId) { + jdbc.update("insert into chat_sessions (user_id, project_id, title) values (?, ?, ?)", + userId, projectId, "u6"); + return jdbc.queryForObject("select last_insert_id()", Long.class); + } + + /** created_at 을 행마다 다르게 준다 — 비교 대상 순서가 유일하게 정해지도록(클래스 javadoc 참고). */ + private void seedChange(Long userId, Long projectId, int index, String diff) { + String taskId = "u6-change-" + System.nanoTime() + "-" + index; + taskStore.save(new AgentTask( + taskId, userId, projectId, null, TaskStatus.DONE, + null, null, null, null, Instant.now())); + String previewSessionId = seedPreviewSession(userId, projectId, taskId); + jdbc.update(""" + insert into project_changes + (user_id, project_id, task_id, preview_session_id, status, summary, diff_text, + created_at, updated_at, pr_number, merge_commit_sha) + values (?, ?, ?, ?, 'MERGED', ?, ?, ?, ?, ?, ?) + """, + userId, projectId, taskId, previewSessionId, "요약 " + index, diff, + LocalDateTime.now().minusMinutes(index), LocalDateTime.now().minusMinutes(index), + 100 + index, "sha-" + index); + } + + /** project_changes.preview_session_id 에 FK 가 걸려 있어 대응 행이 먼저 있어야 한다. */ + private String seedPreviewSession(Long userId, Long projectId, String taskId) { + String sessionId = java.util.UUID.randomUUID().toString(); + jdbc.update(""" + insert into preview_sessions + (preview_session_id, access_token, user_id, project_id, task_id, container_id, + host_port, status, public_url, expires_at, last_accessed_at) + values (?, ?, ?, ?, ?, 'container', 30000, 'RUNNING', 'http://localhost:30000', ?, ?) + """, + sessionId, sessionId.replace("-", ""), userId, projectId, taskId, + LocalDateTime.now().plusMinutes(30), LocalDateTime.now()); + return sessionId; + } + + private void seedDeployment(Long userId, Long projectId, String versionLabel, String status, int index) { + jdbc.update(""" + insert into deployment_histories + (user_id, project_id, deploy_target_type, version_label, deployed_url, status, + correlation_id, commit_sha, title, description, error_message, triggered_at, updated_at) + values (?, ?, 'LATEST', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + userId, projectId, versionLabel, "https://example.com/" + index, status, + "corr-" + System.nanoTime() + "-" + index, "commit-" + index, "제목 " + index, + "설명 ".repeat(500), "에러 ".repeat(200), + LocalDateTime.now().minusMinutes(index), LocalDateTime.now().minusMinutes(index)); + } + + private void seedApproval(Long userId, Long projectId, int index) { + jdbc.update(""" + insert into approvals + (user_id, project_id, approval_type, status, summary, created_at) + values (?, ?, 'CHANGE', 'PENDING', ?, ?) + """, + userId, projectId, "승인 " + index, LocalDateTime.now().minusMinutes(index)); + } +} From 91e4239836ba7e3c558745b14cef43643e686b99 Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 10:52:46 +0900 Subject: [PATCH 11/13] =?UTF-8?q?test(perf):=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=EB=A5=BC=20=EC=8B=A4=20DB=20=EB=A1=9C=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=20=EB=8F=8C=EB=A0=A4=20=EB=B3=B8=EB=8B=A4?= =?UTF-8?q?=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 커서 쿼리는 `(:after is null or x.id > :after)` 꼴인데, 파라미터가 null 인지 SQL 에서 묻는 이 형태는 Hibernate 가 바인딩 타입을 정하지 못해 **실행 시점에만** 깨질 수 있다. Spring Data 의 부팅 시 JPQL 검증은 문법만 보므로 그건 못 잡는다. 그래서 커서를 안 준 첫 페이지와 커서를 준 다음 페이지를 각각 실제로 실행한다(메시지=오름차순, 도메인=내림차순 양방향). 동시에 "페이지를 이어 받았을 때 행이 빠지거나 겹치지 않는다" 를 확인한다. 메시지 쪽은 created_at 을 전부 같은 초로 심는데, 그게 id tiebreaker 를 붙인 이유 그대로다 — tiebreaker 가 없으면 이 조건에서 커서가 깨진다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../CursorPaginationAgainstRealDbTest.java | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 src/test/java/com/example/dvely/perf/CursorPaginationAgainstRealDbTest.java diff --git a/src/test/java/com/example/dvely/perf/CursorPaginationAgainstRealDbTest.java b/src/test/java/com/example/dvely/perf/CursorPaginationAgainstRealDbTest.java new file mode 100644 index 00000000..0927d9b1 --- /dev/null +++ b/src/test/java/com/example/dvely/perf/CursorPaginationAgainstRealDbTest.java @@ -0,0 +1,119 @@ +package com.example.dvely.perf; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.example.dvely.auth.domain.model.User; +import com.example.dvely.auth.domain.repository.UserRepository; +import com.example.dvely.auth.domain.value.GithubId; +import com.example.dvely.chat.application.query.ChatQueryService; +import com.example.dvely.domainbinding.application.query.DomainBindingQueryService; +import com.example.dvely.project.domain.model.Project; +import com.example.dvely.project.domain.repository.ProjectRepository; +import com.example.dvely.project.domain.value.RepositoryVisibility; +import java.time.LocalDateTime; +import java.util.List; +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; + +/** + * U6(#341) 6-3·6-4 의 커서 쿼리를 실 MySQL 로 한 번 돌린다. + * + *

이유는 하나다. 커서 쿼리는 {@code (:after is null or x.id > :after)} 꼴인데, 이 "파라미터가 + * null 인지 SQL 에서 묻는" 형태는 Hibernate 가 바인딩 타입을 정하지 못해 실행 시점에만 깨질 수 + * 있다. Spring Data 의 부팅 시 JPQL 검증은 문법만 보므로 이건 못 잡는다. 그래서 커서를 안 준 첫 + * 페이지와 커서를 준 다음 페이지를 각각 실제로 실행해 본다.

+ * + *

페이지를 이어 받았을 때 행이 빠지거나 겹치지 않는지도 여기서 확인한다 — 커서 + * 페이지네이션에서 제일 흔한 버그이고, 정렬에 id tiebreaker 를 붙인 것이 그 때문이다.

+ */ +@SpringBootTest +class CursorPaginationAgainstRealDbTest { + + @Autowired private JdbcTemplate jdbc; + @Autowired private UserRepository userRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private ChatQueryService chatQueryService; + @Autowired private DomainBindingQueryService domainBindingQueryService; + + @Test + void messagePagesWalkForwardWithoutSkippingOrRepeatingARow() { + Long userId = seedUser(); + Long conversationId = seedConversation(userId, seedProject(userId)); + // created_at 을 전부 같은 초로 심는다 — id tiebreaker 가 없으면 커서가 깨지는 상황이다. + for (int i = 0; i < 5; i++) { + jdbc.update("insert into chat_messages (chat_session_id, role, content, created_at)" + + " values (?, 'user', ?, ?)", conversationId, "메시지 " + i, LocalDateTime.now()); + } + + var first = chatQueryService.getMessages(userId, conversationId, 2, null); + assertThat(first.items()).hasSize(2); + assertThat(first.nextCursor()).isNotNull(); + + var second = chatQueryService.getMessages(userId, conversationId, 2, first.nextCursor()); + var third = chatQueryService.getMessages(userId, conversationId, 2, second.nextCursor()); + + assertThat(second.items()).hasSize(2); + assertThat(third.items()).hasSize(1); + assertThat(third.nextCursor()).as("마지막 페이지에는 커서가 없다").isNull(); + + List walked = java.util.stream.Stream.of(first, second, third) + .flatMap(page -> page.items().stream()) + .map(message -> message.messageId()) + .toList(); + assertThat(walked).doesNotHaveDuplicates().hasSize(5).isSorted(); + assertThat(walked).isEqualTo(chatQueryService.getMessages(userId, conversationId).stream() + .map(message -> message.messageId()).toList()); + } + + @Test + void domainPagesWalkFromNewestToOldestWithoutSkippingOrRepeatingARow() { + Long userId = seedUser(); + Long projectId = seedProject(userId); + for (int i = 0; i < 3; i++) { + seedDomain(projectId, "d" + i + "-" + System.nanoTime() + ".example.com"); + } + + var first = domainBindingQueryService.getProjectDomains(userId, projectId, 2, null); + assertThat(first.items()).hasSize(2); + assertThat(first.nextCursor()).isNotNull(); + + var second = domainBindingQueryService.getProjectDomains(userId, projectId, 2, first.nextCursor()); + assertThat(second.items()).hasSize(1); + assertThat(second.nextCursor()).isNull(); + + List walked = java.util.stream.Stream.of(first, second) + .flatMap(page -> page.items().stream()) + .map(domain -> domain.domainId()) + .toList(); + assertThat(walked).doesNotHaveDuplicates().hasSize(3); + assertThat(walked).isEqualTo(domainBindingQueryService.getProjectDomains(userId, projectId).stream() + .map(domain -> domain.domainId()).toList()); + } + + private Long seedUser() { + return userRepository.save( + new User(new GithubId("u6-cursor-" + System.nanoTime()), "octo", null)).getId(); + } + + private Long seedProject(Long userId) { + return projectRepository.save(new Project( + userId, "u6-cursor", "scratch", null, "fast", RepositoryVisibility.PUBLIC)).getId(); + } + + private Long seedConversation(Long userId, Long projectId) { + jdbc.update("insert into chat_sessions (user_id, project_id, title) values (?, ?, ?)", + userId, projectId, "u6-cursor"); + return jdbc.queryForObject("select last_insert_id()", Long.class); + } + + private void seedDomain(Long projectId, String hostname) { + jdbc.update(""" + insert into domains + (project_id, domain_type, hosting_target, domain_name, status, verification_method, + https_enforced, certificate_status, created_at) + values (?, 'MANAGED_SUBDOMAIN', 'GITHUB_PAGES', ?, 'CONNECTED', 'CNAME', 1, 'ACTIVE', ?) + """, projectId, hostname, LocalDateTime.now()); + } +} From b84dd4e2f79e73280da4e97cd791e51cebbac4fc Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 11:24:58 +0900 Subject: [PATCH 12/13] =?UTF-8?q?fix(chat):=20=EB=A6=AC=EB=B2=A0=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EC=86=8C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=82=A8=EC=9D=80=20=EC=A4=91=EB=B3=B5=20=EB=8B=A8?= =?UTF-8?q?=EC=96=B8=20=EB=B8=94=EB=A1=9D=EC=9D=84=20=EC=A7=80=EC=9A=B4?= =?UTF-8?q?=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #340 5-9 와 #341 6-8 이 같은 메서드를 각자 벌크 DELETE 로 바꿔 리베이스 충돌이 났다. 양쪽 단언을 합치면서 예전 테스트의 꼬리 세 줄이 메서드 밖에 남아 컴파일이 깨졌다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../dvely/chat/application/command/ChatCommandServiceTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java b/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java index 94a85e97..21b053ae 100644 --- a/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java +++ b/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java @@ -220,9 +220,6 @@ void purgeExpiredConversationsDeletesInOneBulkStatement() { verify(conversationRepository, never()).findAllByDeletedTrueAndDeletedAtLessThanEqual(any()); } - verify(conversationRepository, never()).deleteById(any()); - } - private Project project(Long projectId, Long ownerUserId, String sourceRepository, boolean deleted) { return new Project( projectId, From 8d8ec5376e190ced239357ebbc5dbb4b2668bcfa Mon Sep 17 00:00:00 2001 From: Danto Date: Fri, 11 Sep 2026 11:26:20 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix(chat):=20=EC=82=AC=EB=9D=BC=EC=A7=84?= =?UTF-8?q?=20=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=EB=A5=BC=20=EC=B0=B8=EC=A1=B0=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=8B=A8=EC=96=B8=EC=9D=84=20=EC=A7=80=EC=9A=B4?= =?UTF-8?q?=EB=8B=A4=20[=20#341=20]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 단위가 findAllByDeletedTrueAndDeletedAtLessThanEqual 을 제거했는데, #340 에서 온 단언 한 줄이 그 메서드로 "로드 후 삭제하지 않는다" 를 지키고 있었다. 메서드 자체가 없어졌으니 그 단언은 더 지킬 것이 없다 — 같은 성질은 바로 위 deleteById never() 가 본다. Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr --- .../dvely/chat/application/command/ChatCommandServiceTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java b/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java index 21b053ae..7cbe9123 100644 --- a/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java +++ b/src/test/java/com/example/dvely/chat/application/command/ChatCommandServiceTest.java @@ -217,7 +217,6 @@ void purgeExpiredConversationsDeletesInOneBulkStatement() { assertThat(cutoff.getValue()) .isBetween(ChatTrashPolicy.cutoff(before), ChatTrashPolicy.cutoff(LocalDateTime.now())); verify(conversationRepository, never()).deleteById(any()); - verify(conversationRepository, never()).findAllByDeletedTrueAndDeletedAtLessThanEqual(any()); } private Project project(Long projectId, Long ownerUserId, String sourceRepository, boolean deleted) {