diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java index e8ff2fbc..30ba8796 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/converter/CollectionConverter.java @@ -7,7 +7,10 @@ import com.opensource.docgrid.domain.collection.dto.response.CollectionResponse; import com.opensource.docgrid.domain.collection.entity.CollectionDocument; import com.opensource.docgrid.domain.collection.entity.DocumentCollection; +import com.opensource.docgrid.domain.collection.enums.CollectionStatus; +import com.opensource.docgrid.domain.collection.repository.CollectionRow; import com.opensource.docgrid.domain.document.converter.DocumentSummaryConverter; +import com.opensource.docgrid.domain.document.enums.VisibilityType; import lombok.RequiredArgsConstructor; @@ -38,6 +41,20 @@ public CollectionResponse toResponse(DocumentCollection collection) { ); } + // findReadableCollections 네이티브 쿼리 프로젝션 결과를 그대로 변환 (owner 엔티티를 거치지 않음) + public CollectionResponse toResponse(CollectionRow row) { + return new CollectionResponse( + row.getCollectionId(), + row.getName(), + row.getDescription(), + row.getOwnerUserId(), + row.getParentCollectionId(), + VisibilityType.valueOf(row.getVisibility()), + CollectionStatus.valueOf(row.getStatus()), + row.getCreatedAt() + ); + } + public CollectionDocumentResponse toDocumentResponse(CollectionDocument cd) { Long addedById = cd.getAddedBy() != null ? cd.getAddedBy().getId() : null; diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java index 1358ff62..695ba5e9 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRepository.java @@ -2,21 +2,22 @@ import java.util.List; -import org.springframework.data.domain.Page; -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; import com.opensource.docgrid.domain.collection.entity.DocumentCollection; -import com.opensource.docgrid.domain.collection.enums.CollectionStatus; public interface CollectionRepository extends JpaRepository { /** - * 사용자가 읽을 수 있는 컬렉션 ID 전체 (GET /collections pre-filter). + * 사용자가 읽을 수 있는 컬렉션을 페이지 단위로 조회 (GET /collections). * 4가지 접근 경로: OWNER / PUBLIC / USER 직접 권한 / ROLE·DEPARTMENT live(부모 컬렉션 체인 상속 포함). * ACTIVE 상태만 대상으로 하며, keyword가 있으면 이름·설명 부분일치로도 필터링한다(keyword는 null 가능). + * + *

"읽을 수 있는 것 전체를 먼저 찾고 그중 일부를 다시 조회"하는 2단계 구조를 쓰지 않고, + * COUNT(*) OVER() 윈도우 함수로 페이지 내용과 전체 개수를 한 쿼리에서 함께 계산한다 — + * 콘텐츠 쿼리와 count 쿼리를 따로 두면 재귀 CTE가 두 번 계산되므로 일부러 합쳤다. */ @Query(value = """ WITH RECURSIVE collection_ancestors AS ( @@ -26,48 +27,168 @@ WITH RECURSIVE collection_ancestors AS ( FROM collection_ancestors ca JOIN collections c ON c.id = ca.ancestor_id WHERE c.parent_collection_id IS NOT NULL + ), + readable AS ( + SELECT c.id FROM collections c + WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_permissions cp ON cp.collection_id = c.id + WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_ancestors ca ON ca.collection_id = c.id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id + JOIN user_roles ur ON ur.role_id = cp.role_id + WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_ancestors ca ON ca.collection_id = c.id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id + JOIN users u ON u.department_id = cp.department_id + WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) ) - SELECT c.id FROM collections c - WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE' - AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) - UNION - SELECT c.id FROM collections c - WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE' - AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) - UNION - SELECT c.id FROM collections c - JOIN collection_permissions cp ON cp.collection_id = c.id - WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true - AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) - AND c.status = 'ACTIVE' - AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) - UNION - SELECT c.id FROM collections c - JOIN collection_ancestors ca ON ca.collection_id = c.id - JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id - JOIN user_roles ur ON ur.role_id = cp.role_id - WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true - AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) - AND c.status = 'ACTIVE' - AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) - UNION - SELECT c.id FROM collections c - JOIN collection_ancestors ca ON ca.collection_id = c.id - JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id - JOIN users u ON u.department_id = cp.department_id - WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true - AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) - AND c.status = 'ACTIVE' - AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + SELECT + c.id AS collection_id, + c.name AS name, + c.description AS description, + c.owner_user_id AS owner_user_id, + c.parent_collection_id AS parent_collection_id, + c.visibility AS visibility, + c.status AS status, + c.created_at AS created_at, + COUNT(*) OVER() AS total_count + FROM collections c + JOIN readable r ON r.id = c.id + ORDER BY c.created_at DESC, c.id DESC + LIMIT :limit OFFSET :offset """, nativeQuery = true) - List findReadableCollectionIds(@Param("userId") Long userId, @Param("keyword") String keyword); + List findReadableCollections( + @Param("userId") Long userId, + @Param("keyword") String keyword, + @Param("limit") int limit, + @Param("offset") long offset); - // pre-filter로 걸러진 ID를 받아 정렬·페이징만 담당 (GET /collections) - @Query("SELECT c FROM DocumentCollection c JOIN FETCH c.owner WHERE c.id IN :ids") - Page findAllByIdIn(@Param("ids") List ids, Pageable pageable); + /** + * findReadableCollections()가 요청한 offset이 실제 결과 범위를 넘어가 0건을 반환했을 때만 + * 호출한다 — COUNT(*) OVER()는 반환된 행에만 얹혀 계산되므로, 행이 0개면 전체 개수 자체를 + * 알 수 없다(빈 페이지인지, 정말 0건인지 구분이 안 됨). readable CTE는 findReadableCollections + * 와 동일한 조건을 그대로 유지해야 두 쿼리의 판정 결과가 어긋나지 않는다. + */ + @Query(value = """ + WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL + ), + readable AS ( + SELECT c.id FROM collections c + WHERE c.owner_user_id = :userId AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + WHERE c.visibility = 'PUBLIC' AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_permissions cp ON cp.collection_id = c.id + WHERE cp.target_type = 'USER' AND cp.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_ancestors ca ON ca.collection_id = c.id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id + JOIN user_roles ur ON ur.role_id = cp.role_id + WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + UNION + SELECT c.id FROM collections c + JOIN collection_ancestors ca ON ca.collection_id = c.id + JOIN collection_permissions cp ON cp.collection_id = ca.ancestor_id + JOIN users u ON u.department_id = cp.department_id + WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId AND cp.can_read = true + AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + AND c.status = 'ACTIVE' + AND (:keyword IS NULL OR c.name ILIKE CONCAT('%', :keyword, '%') OR c.description ILIKE CONCAT('%', :keyword, '%')) + ) + SELECT COUNT(*) FROM readable + """, nativeQuery = true) + long countReadableCollections(@Param("userId") Long userId, @Param("keyword") String keyword); - // 직계 자식 컬렉션 목록 조회 (GET /collections/{id}/children) - List findAllByParentCollectionIdAndStatus(Long parentCollectionId, CollectionStatus status); + /** + * 직계 자식 중 사용자가 읽을 수 있는 것만 조회 (GET /collections/{id}/children). + * 부모(및 그 위 조상들)로부터 상속받는 ROLE/DEPARTMENT 권한은 모든 자식이 공유하는 값이라 + * parent_ancestors 서브쿼리로 한 번만 계산한다 — 자식마다 다시 계산하지 않는다. + */ + @Query(value = """ + WITH RECURSIVE parent_ancestors AS ( + SELECT id, parent_collection_id FROM collections WHERE id = :parentId + UNION ALL + SELECT c.id, c.parent_collection_id + FROM collections c + JOIN parent_ancestors a ON c.id = a.parent_collection_id + ) + SELECT c.* FROM collections c + WHERE c.parent_collection_id = :parentId AND c.status = 'ACTIVE' + AND ( + c.owner_user_id = :userId + OR c.visibility = 'PUBLIC' + OR EXISTS ( + SELECT 1 FROM collection_permissions cp + WHERE cp.collection_id = c.id AND cp.target_type = 'USER' AND cp.user_id = :userId + AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + ) + OR EXISTS ( + SELECT 1 FROM collection_permissions cp + JOIN user_roles ur ON ur.role_id = cp.role_id + WHERE cp.collection_id = c.id AND cp.target_type = 'ROLE' AND ur.user_id = :userId + AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + ) + OR EXISTS ( + SELECT 1 FROM collection_permissions cp + JOIN users u ON u.department_id = cp.department_id + WHERE cp.collection_id = c.id AND cp.target_type = 'DEPARTMENT' AND u.id = :userId + AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + ) + OR EXISTS ( + SELECT 1 FROM collection_permissions cp + JOIN parent_ancestors pa ON pa.id = cp.collection_id + JOIN user_roles ur ON ur.role_id = cp.role_id + WHERE cp.target_type = 'ROLE' AND ur.user_id = :userId + AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + ) + OR EXISTS ( + SELECT 1 FROM collection_permissions cp + JOIN parent_ancestors pa ON pa.id = cp.collection_id + JOIN users u ON u.department_id = cp.department_id + WHERE cp.target_type = 'DEPARTMENT' AND u.id = :userId + AND cp.can_read = true AND (cp.expires_at IS NULL OR cp.expires_at > NOW()) + ) + ) + ORDER BY c.created_at DESC, c.id DESC + """, nativeQuery = true) + List findReadableChildren(@Param("parentId") Long parentId, @Param("userId") Long userId); /** * 자기 자신 + 모든 조상 컬렉션 ID (권한 상속 판단용). diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRow.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRow.java new file mode 100644 index 00000000..dcd86531 --- /dev/null +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/repository/CollectionRow.java @@ -0,0 +1,21 @@ +package com.opensource.docgrid.domain.collection.repository; + +import java.time.LocalDateTime; + +/** + * 읽기 가능한 컬렉션 목록 네이티브 쿼리 프로젝션 (GET /collections). + * + *

컬럼 alias가 snake_case일 때 Spring Data JPA가 camelCase getter로 자동 매핑한다. + * totalCount는 COUNT(*) OVER()로 모든 행에 동일하게 실려오는 전체 개수다. + */ +public interface CollectionRow { + Long getCollectionId(); + String getName(); + String getDescription(); + Long getOwnerUserId(); + Long getParentCollectionId(); + String getVisibility(); + String getStatus(); + LocalDateTime getCreatedAt(); + Long getTotalCount(); +} diff --git a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java index 662c52e9..cc356b32 100644 --- a/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java +++ b/backend/src/main/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryService.java @@ -4,6 +4,7 @@ import java.util.List; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -18,6 +19,7 @@ import com.opensource.docgrid.domain.collection.enums.CollectionStatus; import com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository; import com.opensource.docgrid.domain.collection.repository.CollectionRepository; +import com.opensource.docgrid.domain.collection.repository.CollectionRow; import com.opensource.docgrid.domain.document.enums.DocumentStatus; import com.opensource.docgrid.domain.document.repository.DocumentRepository; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; @@ -64,23 +66,40 @@ public CollectionResponse getCollection(Long userId, Long collectionId) { /** * 사용자가 읽을 수 있는 컬렉션 목록 페이지 조회 (owner + PUBLIC + 권한부여 + 부모 상속, ACTIVE만). * keyword가 있으면 이름·설명 부분일치로도 필터링한다. + * + *

"전체를 찾은 뒤 페이지를 자르는" 2단계 조회 대신, 페이지 내용과 전체 개수를 한 번의 + * 쿼리로 함께 얻는다(COUNT(*) OVER()) — 콘텐츠 쿼리와 count 쿼리를 따로 두면 권한 판단이 + * 두 번 계산되는 걸 피하기 위함이다. */ public PageResponse getCollections(Long userId, String keyword, int page, int size) { + // 1. 페이지 요청 파라미터 정리 Pageable pageable = PageRequest.of(page, size, COLLECTION_SORT); - List readableIds = collectionRepository.findReadableCollectionIds(userId, keyword); - if (readableIds.isEmpty()) { - return PageResponse.from(Page.empty(pageable), List.of()); - } - Page collections = collectionRepository.findAllByIdIn(readableIds, pageable); - List content = collections.getContent().stream() + // 2. 컬렉션 목록과 전체 개수를 한 번에 조회 (CollectionRow 프로젝션, COUNT(*) OVER()) + List rows = collectionRepository.findReadableCollections( + userId, keyword, pageable.getPageSize(), pageable.getOffset()); + + // 3. 전체 개수 추출 — 요청한 offset이 실제 결과 범위를 넘어가 0건이 반환되면 + // COUNT(*) OVER()가 아무 행에도 안 얹혀서 전체 개수를 알 수 없다. 이때만 별도로 + // count 쿼리를 한 번 더 불러 "빈 페이지"와 "정말 0건"을 구분한다. + long totalElements = rows.isEmpty() + ? collectionRepository.countReadableCollections(userId, keyword) + : rows.get(0).getTotalCount(); + + // 4. DTO 변환 및 페이지 응답 조립 + List content = rows.stream() .map(collectionConverter::toResponse) .toList(); - return PageResponse.from(collections, content); + Page resultPage = new PageImpl<>(content, pageable, totalElements); + return PageResponse.from(resultPage, content); } - // 직계 자식 컬렉션 목록 조회 — 부모 읽기 권한 확인 후, 자식 각각의 읽기 권한도 확인 - // (자식 owner/visibility가 부모와 다를 수 있으므로 부모 권한만으로 자식을 노출하면 안 됨) + /** + * 직계 자식 컬렉션 목록 조회 — 부모 읽기 권한 확인 후, 권한 조건이 반영된 자식만 조회한다. + * 자식별 읽기 권한 필터는 findReadableChildren 쿼리 안에서 함께 처리되므로, 자식 개수만큼 + * canReadCollection을 반복 호출하지 않는다(부모 상속 여부는 자식 전체가 공유하는 값이라 + * 쿼리 안에서 한 번만 계산됨). + */ public List getChildren(Long userId, Long collectionId) { DocumentCollection parent = collectionRepository.findById(collectionId) .filter(c -> c.getStatus() != CollectionStatus.DELETED) @@ -89,9 +108,9 @@ public List getChildren(Long userId, Long collectionId) { throw new DocGridException(ErrorCode.PERMISSION_DENIED); } - return collectionRepository.findAllByParentCollectionIdAndStatus(collectionId, CollectionStatus.ACTIVE) + // 부모 읽기 권한이 있는 경우에만, 자식 컬렉션 중 읽기 가능한 것들을 조회한다. + return collectionRepository.findReadableChildren(collectionId, userId) .stream() - .filter(child -> permissionQueryService.canReadCollection(userId, child)) .map(collectionConverter::toResponse) .toList(); } diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java index 0c389cab..a434d950 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/repository/CollectionTreeRepositoryTest.java @@ -15,7 +15,6 @@ import com.opensource.docgrid.domain.collection.entity.CollectionDocument; import com.opensource.docgrid.domain.collection.entity.DocumentCollection; -import com.opensource.docgrid.domain.collection.enums.CollectionStatus; import com.opensource.docgrid.domain.document.entity.Document; import com.opensource.docgrid.domain.document.enums.DocumentSourceType; import com.opensource.docgrid.domain.document.enums.DocumentStatus; @@ -41,8 +40,8 @@ /** * 컬렉션 트리(부모-자식) 재귀 쿼리와 직계 자식 조회를 3단 트리(root→child→grandchild)로 검증한다. - * findReadableCollectionIds의 owner/PUBLIC 노출, keyword 필터, 부모 컬렉션으로부터의 DEPARTMENT 권한 - * 상속도 함께 검증한다. + * findReadableCollections의 owner/PUBLIC 노출, keyword 필터, 페이지네이션/totalCount, 부모 컬렉션으로부터의 + * ROLE·DEPARTMENT 권한 상속과, findReadableChildren의 권한 필터·다단계 상속도 함께 검증한다. * 이동/수정 API가 없어 순환 참조가 API상 불가능하므로 순환 참조 케이스는 검증하지 않는다. */ @DataJpaTest @@ -121,25 +120,81 @@ void findEffectiveCollectionIdsForDocument_returnsEmpty_whenDocumentInNoCollecti } @Test - @DisplayName("findAllByParentCollectionIdAndStatus는 직계 자식만 반환하고 손자는 포함하지 않는다") - void findAllByParentCollectionIdAndStatus_returnsDirectChildrenOnly() { + @DisplayName("findReadableChildren는 직계 자식만 반환하고 손자는 포함하지 않는다") + void findReadableChildren_returnsDirectChildrenOnly() { User owner = saveOwner(); DocumentCollection root = saveCollection(owner, null); DocumentCollection child = saveCollection(owner, root); DocumentCollection grandchild = saveCollection(owner, child); flushAndClear(); - List children = collectionRepository.findAllByParentCollectionIdAndStatus( - root.getId(), CollectionStatus.ACTIVE - ); + List children = collectionRepository.findReadableChildren(root.getId(), owner.getId()); assertThat(children).extracting(DocumentCollection::getId).containsExactly(child.getId()); assertThat(children).extracting(DocumentCollection::getId).doesNotContain(grandchild.getId()); } @Test - @DisplayName("findReadableCollectionIds는 owner의 PRIVATE 컬렉션은 owner에게만, PUBLIC 컬렉션은 누구에게나 보여준다") - void findReadableCollectionIds_ownerAndPublic() { + @DisplayName("findReadableChildren는 읽기 권한 없는 자식은 제외한다") + void findReadableChildren_excludesChild_whenNoPermission() { + User owner = saveOwner(); + User stranger = saveOwner(); + DocumentCollection root = saveCollection(owner, null); + saveCollection(owner, root); // PRIVATE 자식, stranger는 권한 없음 + flushAndClear(); + + List forStranger = collectionRepository.findReadableChildren(root.getId(), stranger.getId()); + List forOwner = collectionRepository.findReadableChildren(root.getId(), owner.getId()); + + assertThat(forStranger).isEmpty(); + assertThat(forOwner).hasSize(1); + } + + @Test + @DisplayName("findReadableChildren는 조부모(2단계 위)에 부여된 ROLE 권한도 상속해서 자식을 보여준다") + void findReadableChildren_inheritsRolePermissionFromGrandparent() { + Role role = roleRepository.save( + Role.builder().name("자식조회 테스트 역할").code("CH-ROLE-" + UUID.randomUUID()).build() + ); + User owner = saveOwner(); + User roleMember = userRepository.save( + User.builder() + .email("children-role-" + UUID.randomUUID() + "@test.com") + .passwordHash("hash") + .name("자식조회 테스트 역할 보유자") + .status(UserStatus.ACTIVE) + .build() + ); + userRoleRepository.save( + UserRole.builder().user(roleMember).role(role).assignedAt(LocalDateTime.now()).build() + ); + DocumentCollection grandparent = saveCollection(owner, null); + DocumentCollection parent = saveCollection(owner, grandparent); + DocumentCollection child = saveCollection(owner, parent); + collectionPermissionRepository.save( + CollectionPermission.builder() + .collection(grandparent) + .targetType(PermissionTargetType.ROLE) + .role(role) + .permissionType(PermissionType.READ) + .canRead(true) + .canWrite(false) + .canAdmin(false) + .grantedBy(owner) + .grantedAt(LocalDateTime.now()) + .build() + ); + flushAndClear(); + + // parent 자체는 grandparent로부터 상속받아 읽을 수 있고, parent의 자식(child)도 같은 체인으로 상속받는다. + List childrenOfParent = collectionRepository.findReadableChildren(parent.getId(), roleMember.getId()); + + assertThat(childrenOfParent).extracting(DocumentCollection::getId).containsExactly(child.getId()); + } + + @Test + @DisplayName("findReadableCollections는 owner의 PRIVATE 컬렉션은 owner에게만, PUBLIC 컬렉션은 누구에게나 보여준다") + void findReadableCollections_ownerAndPublic() { User owner = saveOwner(); User stranger = saveOwner(); DocumentCollection privateCollection = saveCollection(owner, null); @@ -152,8 +207,8 @@ void findReadableCollectionIds_ownerAndPublic() { ); flushAndClear(); - List ownerReadable = collectionRepository.findReadableCollectionIds(owner.getId(), null); - List strangerReadable = collectionRepository.findReadableCollectionIds(stranger.getId(), null); + List ownerReadable = readableIds(owner.getId(), null); + List strangerReadable = readableIds(stranger.getId(), null); assertThat(ownerReadable).contains(privateCollection.getId(), publicCollection.getId()); assertThat(strangerReadable).contains(publicCollection.getId()); @@ -161,8 +216,8 @@ void findReadableCollectionIds_ownerAndPublic() { } @Test - @DisplayName("findReadableCollectionIds는 keyword가 있으면 이름·설명에 부분일치하는 컬렉션만 반환한다") - void findReadableCollectionIds_filtersByKeyword() { + @DisplayName("findReadableCollections는 keyword가 있으면 이름·설명에 부분일치하는 컬렉션만 반환한다") + void findReadableCollections_filtersByKeyword() { User owner = saveOwner(); DocumentCollection matching = collectionRepository.save( DocumentCollection.builder().owner(owner).name("개발 문서").description("백엔드 관련").visibility(VisibilityType.PRIVATE).build() @@ -172,15 +227,37 @@ void findReadableCollectionIds_filtersByKeyword() { ); flushAndClear(); - List result = collectionRepository.findReadableCollectionIds(owner.getId(), "개발"); + List result = readableIds(owner.getId(), "개발"); assertThat(result).contains(matching.getId()); assertThat(result).doesNotContain(nonMatching.getId()); } @Test - @DisplayName("findReadableCollectionIds는 부모 컬렉션에 부여된 DEPARTMENT 권한을 자식 컬렉션까지 상속해서 보여준다") - void findReadableCollectionIds_inheritsDepartmentPermissionFromParent() { + @DisplayName("findReadableCollections는 limit/offset으로 페이지를 나누고, 모든 행에 동일한 totalCount를 함께 반환한다") + void findReadableCollections_paginatesAndReturnsTotalCountOnEveryRow() { + User owner = saveOwner(); + for (int i = 0; i < 3; i++) { + saveCollection(owner, null); + } + flushAndClear(); + + List firstPage = collectionRepository.findReadableCollections(owner.getId(), null, 2, 0L); + List secondPage = collectionRepository.findReadableCollections(owner.getId(), null, 2, 2L); + + assertThat(firstPage).hasSize(2); + assertThat(secondPage).hasSize(1); + assertThat(firstPage).allSatisfy(row -> assertThat(row.getTotalCount()).isEqualTo(3)); + assertThat(secondPage.get(0).getTotalCount()).isEqualTo(3); + // 두 페이지에 중복 없이 전부 다른 컬렉션이 나뉘어 담겨야 한다. + List firstPageIds = firstPage.stream().map(CollectionRow::getCollectionId).toList(); + List secondPageIds = secondPage.stream().map(CollectionRow::getCollectionId).toList(); + assertThat(firstPageIds).doesNotContainAnyElementsOf(secondPageIds); + } + + @Test + @DisplayName("findReadableCollections는 부모 컬렉션에 부여된 DEPARTMENT 권한을 자식 컬렉션까지 상속해서 보여준다") + void findReadableCollections_inheritsDepartmentPermissionFromParent() { Department department = departmentRepository.save( Department.builder().name("컬렉션목록 테스트 부서").code("CL-DEPT-" + UUID.randomUUID()).status(CommonStatus.ACTIVE).build() ); @@ -211,14 +288,14 @@ void findReadableCollectionIds_inheritsDepartmentPermissionFromParent() { ); flushAndClear(); - List readable = collectionRepository.findReadableCollectionIds(deptMember.getId(), null); + List readable = readableIds(deptMember.getId(), null); assertThat(readable).contains(parent.getId(), child.getId()); } @Test - @DisplayName("findReadableCollectionIds는 부모 컬렉션에 부여된 ROLE 권한을 자식 컬렉션까지 상속해서 보여준다") - void findReadableCollectionIds_inheritsRolePermissionFromParent() { + @DisplayName("findReadableCollections는 부모 컬렉션에 부여된 ROLE 권한을 자식 컬렉션까지 상속해서 보여준다") + void findReadableCollections_inheritsRolePermissionFromParent() { Role role = roleRepository.save( Role.builder().name("컬렉션목록 테스트 역할").code("CL-ROLE-" + UUID.randomUUID()).build() ); @@ -251,11 +328,20 @@ void findReadableCollectionIds_inheritsRolePermissionFromParent() { ); flushAndClear(); - List readable = collectionRepository.findReadableCollectionIds(roleMember.getId(), null); + List readable = readableIds(roleMember.getId(), null); assertThat(readable).contains(parent.getId(), child.getId()); } + // findReadableCollections는 limit/offset을 받는 페이지 조회라, ID만으로 assertThat(...).contains 하던 + // 기존 테스트들이 그대로 동작하도록 넉넉한 limit(100)으로 감싸는 헬퍼. + private List readableIds(Long userId, String keyword) { + return collectionRepository.findReadableCollections(userId, keyword, 100, 0L) + .stream() + .map(CollectionRow::getCollectionId) + .toList(); + } + private User saveOwner() { return userRepository.save( User.builder() diff --git a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java index 1996fddf..9c0ce744 100644 --- a/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java +++ b/backend/src/test/java/com/opensource/docgrid/domain/collection/service/query/CollectionQueryServiceTest.java @@ -29,6 +29,7 @@ import com.opensource.docgrid.domain.collection.fixture.CollectionFixture; import com.opensource.docgrid.domain.collection.repository.CollectionDocumentRepository; import com.opensource.docgrid.domain.collection.repository.CollectionRepository; +import com.opensource.docgrid.domain.collection.repository.CollectionRow; import com.opensource.docgrid.domain.document.repository.DocumentRepository; import com.opensource.docgrid.domain.permission.service.query.PermissionQueryService; import com.opensource.docgrid.global.common.response.PageResponse; @@ -113,58 +114,112 @@ void getCollection_throws_when_noReadPermission() { @Test @DisplayName("읽을 수 있는 컬렉션이 없으면 빈 페이지를 반환한다") void getCollections_returnsEmptyPage_whenNoReadableCollection() { - given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, null)).willReturn(List.of()); + given(collectionRepository.findReadableCollections( + org.mockito.ArgumentMatchers.eq(CollectionFixture.USER_ID), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.eq(20), + org.mockito.ArgumentMatchers.eq(0L))) + .willReturn(List.of()); + given(collectionRepository.countReadableCollections(CollectionFixture.USER_ID, null)) + .willReturn(0L); PageResponse result = collectionQueryService.getCollections(CollectionFixture.USER_ID, null, 0, 20); assertThat(result.content()).isEmpty(); assertThat(result.totalElements()).isZero(); - then(collectionRepository).should(never()).findAllByIdIn(org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.any()); } @Test - @DisplayName("읽을 수 있는 컬렉션 ID로 페이지를 조회해서 응답으로 변환한다") + @DisplayName("요청한 페이지가 마지막 페이지를 넘어가 0건이 반환돼도, 별도 count 쿼리로 실제 전체 개수를 정확히 반영한다") + void getCollections_returnsAccurateTotalElements_whenPageBeyondLastPage() { + // COUNT(*) OVER()는 반환된 행에만 얹혀 계산되므로, offset이 범위를 넘어 0건이 반환되면 + // findReadableCollections만으로는 전체 개수(실제로는 3건)를 전혀 알 수 없다 — 이걸 그대로 + // totalElements=0으로 응답하면 "정말 0건"과 "빈 페이지"를 구분 못 하는 버그가 된다. + given(collectionRepository.findReadableCollections( + org.mockito.ArgumentMatchers.eq(CollectionFixture.USER_ID), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.eq(20), + org.mockito.ArgumentMatchers.eq(100L))) + .willReturn(List.of()); + given(collectionRepository.countReadableCollections(CollectionFixture.USER_ID, null)) + .willReturn(3L); + + PageResponse result = collectionQueryService.getCollections(CollectionFixture.USER_ID, null, 5, 20); + + assertThat(result.content()).isEmpty(); + assertThat(result.totalElements()).isEqualTo(3); + } + + @Test + @DisplayName("읽을 수 있는 컬렉션을 페이지로 조회해서 응답으로 변환하고, 첫 행의 totalCount를 전체 개수로 쓴다") void getCollections_returnsPagedResponses() { - DocumentCollection collection = CollectionFixture.createCollection(); + // size=1로 첫 페이지만 요청 — totalCount(3)이 이번 페이지 content 크기(1)보다 크다는 걸 + // PageImpl이 "모순"으로 보정하지 않도록, 실제로 더 남은 페이지가 있는 상황으로 맞춘다. + CollectionRow row = mockCollectionRow(3L); CollectionResponse expected = CollectionFixture.createCollectionResponse(); - List readableIds = List.of(collection.getId()); - given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, null)).willReturn(readableIds); - given(collectionRepository.findAllByIdIn(org.mockito.ArgumentMatchers.eq(readableIds), org.mockito.ArgumentMatchers.any(Pageable.class))) - .willReturn(new PageImpl<>(List.of(collection), PageRequest.of(0, 20), 1)); - given(collectionConverter.toResponse(collection)).willReturn(expected); + given(collectionRepository.findReadableCollections( + org.mockito.ArgumentMatchers.eq(CollectionFixture.USER_ID), + org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.eq(1), + org.mockito.ArgumentMatchers.eq(0L))) + .willReturn(List.of(row)); + given(collectionConverter.toResponse(row)).willReturn(expected); - PageResponse result = collectionQueryService.getCollections(CollectionFixture.USER_ID, null, 0, 20); + PageResponse result = collectionQueryService.getCollections(CollectionFixture.USER_ID, null, 0, 1); assertThat(result.content()).containsExactly(expected); - assertThat(result.totalElements()).isEqualTo(1); + assertThat(result.totalElements()).isEqualTo(3); + // 정상 경로(행이 반환됨)에서는 별도 count 쿼리를 부르지 않는다 — 이게 이 설계의 핵심 + // 최적화(콘텐츠+count를 한 쿼리로 합침)이므로, 불필요하게 두 번째 쿼리가 나가지 않는지도 검증한다. + then(collectionRepository).should(never()).countReadableCollections( + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } @Test - @DisplayName("keyword를 그대로 repository에 전달한다") - void getCollections_passesKeywordToRepository() { - given(collectionRepository.findReadableCollectionIds(CollectionFixture.USER_ID, "개발")).willReturn(List.of()); + @DisplayName("keyword와 page/size로 계산한 limit/offset을 그대로 repository에 전달한다") + void getCollections_passesKeywordAndOffsetToRepository() { + given(collectionRepository.findReadableCollections( + org.mockito.ArgumentMatchers.eq(CollectionFixture.USER_ID), + org.mockito.ArgumentMatchers.eq("개발"), + org.mockito.ArgumentMatchers.eq(20), + org.mockito.ArgumentMatchers.eq(20L))) + .willReturn(List.of()); + + collectionQueryService.getCollections(CollectionFixture.USER_ID, "개발", 1, 20); - collectionQueryService.getCollections(CollectionFixture.USER_ID, "개발", 0, 20); + then(collectionRepository).should().findReadableCollections(CollectionFixture.USER_ID, "개발", 20, 20L); + } - then(collectionRepository).should().findReadableCollectionIds(CollectionFixture.USER_ID, "개발"); + // collectionConverter.toResponse(row)를 목으로 대체하므로, 서비스가 직접 읽는 + // getTotalCount()만 스텁하면 충분하다 (다른 getter는 이 단위 테스트에서 호출되지 않음). + private CollectionRow mockCollectionRow(long totalCount) { + CollectionRow row = org.mockito.Mockito.mock(CollectionRow.class); + given(row.getTotalCount()).willReturn(totalCount); + return row; } @Test - @DisplayName("부모 읽기 권한이 있으면 자식 컬렉션 목록을 반환한다") + @DisplayName("부모 읽기 권한이 있으면 findReadableChildren이 반환한 자식 목록을 응답으로 변환하고, " + + "자식마다 canReadCollection을 다시 호출하지 않는다(N+1 제거 검증)") void getChildren_returnsResponses_when_parentIsReadable() { DocumentCollection parent = CollectionFixture.createCollection(); - DocumentCollection child = CollectionFixture.createChildCollection(parent.getOwner(), parent, 2L); + // 자식을 3개 반환하도록 스텁 — 만약 서비스가 예전처럼 자식마다 canReadCollection을 다시 + // 호출한다면 아래 verify(times(1))가 실패해서 잡아낸다 (자식 1개짜리로는 이 회귀를 못 잡음). + DocumentCollection child1 = CollectionFixture.createChildCollection(parent.getOwner(), parent, 2L); + DocumentCollection child2 = CollectionFixture.createChildCollection(parent.getOwner(), parent, 3L); + DocumentCollection child3 = CollectionFixture.createChildCollection(parent.getOwner(), parent, 4L); CollectionResponse expected = CollectionFixture.createCollectionResponse(); given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent)); given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, parent)).willReturn(true); - given(collectionRepository.findAllByParentCollectionIdAndStatus(CollectionFixture.COLLECTION_ID, CollectionStatus.ACTIVE)) - .willReturn(List.of(child)); - given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, child)).willReturn(true); - given(collectionConverter.toResponse(child)).willReturn(expected); + given(collectionRepository.findReadableChildren(CollectionFixture.COLLECTION_ID, CollectionFixture.USER_ID)) + .willReturn(List.of(child1, child2, child3)); + given(collectionConverter.toResponse(org.mockito.ArgumentMatchers.any(DocumentCollection.class))).willReturn(expected); List result = collectionQueryService.getChildren(CollectionFixture.USER_ID, CollectionFixture.COLLECTION_ID); - assertThat(result).containsExactly(expected); + assertThat(result).hasSize(3); + then(permissionQueryService).should(org.mockito.Mockito.times(1)) + .canReadCollection(org.mockito.ArgumentMatchers.eq(CollectionFixture.USER_ID), org.mockito.ArgumentMatchers.any(DocumentCollection.class)); } @Test @@ -178,28 +233,8 @@ void getChildren_throws_when_parentReadIsDenied() { assertThatThrownBy(() -> collectionQueryService.getChildren(otherUserId, CollectionFixture.COLLECTION_ID)) .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PERMISSION_DENIED); - then(collectionRepository).should(never()).findAllByParentCollectionIdAndStatus( - org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.any()); - } - - @Test - @DisplayName("부모는 읽을 수 있어도 자식은 개별 읽기 권한이 없으면 목록에서 제외된다") - void getChildren_excludesChild_when_childReadIsDenied() { - DocumentCollection parent = CollectionFixture.createCollection(); - DocumentCollection readableChild = CollectionFixture.createChildCollection(parent.getOwner(), parent, 2L); - DocumentCollection deniedChild = CollectionFixture.createChildCollection(parent.getOwner(), parent, 3L); - CollectionResponse expected = CollectionFixture.createCollectionResponse(); - given(collectionRepository.findById(CollectionFixture.COLLECTION_ID)).willReturn(Optional.of(parent)); - given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, parent)).willReturn(true); - given(collectionRepository.findAllByParentCollectionIdAndStatus(CollectionFixture.COLLECTION_ID, CollectionStatus.ACTIVE)) - .willReturn(List.of(readableChild, deniedChild)); - given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, readableChild)).willReturn(true); - given(permissionQueryService.canReadCollection(CollectionFixture.USER_ID, deniedChild)).willReturn(false); - given(collectionConverter.toResponse(readableChild)).willReturn(expected); - - List result = collectionQueryService.getChildren(CollectionFixture.USER_ID, CollectionFixture.COLLECTION_ID); - - assertThat(result).containsExactly(expected); + then(collectionRepository).should(never()).findReadableChildren( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); } @Test diff --git a/docs/design/kangcheolung-#16-collection-crud.md b/docs/design/kangcheolung-#16-collection-crud.md index 9c422aab..09e51723 100644 --- a/docs/design/kangcheolung-#16-collection-crud.md +++ b/docs/design/kangcheolung-#16-collection-crud.md @@ -322,7 +322,7 @@ BUILD SUCCESSFUL 수동 QA(시나리오 4) 중 `parentCollectionId`가 스키마에만 있고 실제로 死코드라는 게 재발견되어, 이슈 #229에서 실제 트리 기능으로 완성했다. 상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고. 이 문서와 직접 관련된 변경만 요약: - `createCollection()`에 부모 컬렉션 **쓰기권한 체크**(`canWriteCollection(parent)`) 추가 — 예전엔 부모 존재 여부만 확인해서, 남의 컬렉션 밑에도 마음대로 자식을 매달 수 있는 버그였다. -- `CollectionRepository`에 `findAllByParentCollectionIdAndStatus`(직계 자식 조회) 신규. +- ~~`CollectionRepository`에 `findAllByParentCollectionIdAndStatus`(직계 자식 조회) 신규.~~ → **(2026-08-19, 이슈 #240) 삭제되고 `findReadableChildren`로 교체됨**: 조건 없이 전체 자식을 가져온 뒤 자바에서 자식마다 권한을 반복 확인하던(N+1) 방식을, 권한 조건을 SQL `WHERE`절에 넣어 쿼리 1번으로 끝내는 방식으로 바꿨다. 상세는 `docs/design/kangcheolung-#240-collection-list-pagination.md` 참고. - `CollectionQueryService`에 `getChildren()` 신규, `GET /collections/{id}/children` 엔드포인트 추가. - 순환 참조 방지 로직은 만들지 않았다 — 컬렉션 이동/수정 API가 없어 생성 시점에만 부모를 지정할 수 있고, 존재하지 않는 컬렉션은 자기 자신의 조상이 될 수 없으므로 현재 API 구조상 순환 참조가 원천적으로 불가능하기 때문(검토 완료). diff --git a/docs/design/kangcheolung-#21-permission-query-service.md b/docs/design/kangcheolung-#21-permission-query-service.md index 69aeba66..2a403604 100644 --- a/docs/design/kangcheolung-#21-permission-query-service.md +++ b/docs/design/kangcheolung-#21-permission-query-service.md @@ -230,7 +230,7 @@ BUILD SUCCESSFUL - 문서 판단 4종(`canReadDocument`/`canWriteDocument`/`canAdminDocument`/`checkDocumentPermission`)은 `CollectionRepository.findEffectiveCollectionIdsForDocument()`(문서가 속한 컬렉션+그 조상 전체)를 마지막 단계로 추가. - 컬렉션 판단 3종(`canReadCollection`/`canWriteCollection`/`canAdminCollection`)은 `CollectionRepository.findAncestorIdsInclusive()`(자기 자신+조상 전체)를 마지막 단계로 추가. - 전부 **기존 로직은 안 건드리고 끝에 새 단계만 이어붙이는 방식**으로 넣었다 — 대규모 기존 테스트(`PermissionQueryServiceTest` 817줄, 원래 47개 케이스)를 한 줄도 안 고치고 그대로 통과시키기 위한 선택. -- 컬렉션 목록(`GET /collections`)도 이번에 owner-only에서 "읽을 수 있는 전체"로 넓어졌는데, 그건 `PermissionQueryService`가 아니라 `CollectionRepository.findReadableCollectionIds()`라는 별도 native 쿼리로 구현했다 — 6개 판정 그룹과는 별개 경로다. +- 컬렉션 목록(`GET /collections`)도 이번에 owner-only에서 "읽을 수 있는 전체"로 넓어졌는데, 그건 `PermissionQueryService`가 아니라 별도 native 쿼리로 구현했다 — 6개 판정 그룹과는 별개 경로다. (2026-08-19, 이슈 #240) 그 쿼리 이름이 `findReadableCollectionIds()`(ID만 조회 후 재조회하는 2단계 구조)에서 `findReadableCollections()`(COUNT(*) OVER()로 페이지 내용+총개수를 한 쿼리에서 처리)로 바뀌었다 — `docs/design/kangcheolung-#240-collection-list-pagination.md` 참고. 상세 설계는 신규 문서 `docs/design/kangcheolung-#229-collection-tree.md` 참고. diff --git a/docs/design/kangcheolung-#229-collection-tree.md b/docs/design/kangcheolung-#229-collection-tree.md index 9514c383..cf65489a 100644 --- a/docs/design/kangcheolung-#229-collection-tree.md +++ b/docs/design/kangcheolung-#229-collection-tree.md @@ -374,7 +374,9 @@ document/collection/permission/search/user 도메인 전체 통과(354개 중 35 ## 남은 이슈 / TODO (백로그, 이번 스코프 아님) -- **컬렉션 상속용 재귀 CTE(`collection_ancestors`)가 매 호출마다 컬렉션 테이블 전체를 스캔한다** — `findAncestorIdsInclusive(collectionId)`처럼 `WHERE id = :collectionId`로 범위를 좁힌 쿼리는 문제없지만, `findReadableDocumentIds`/`findReadableDocumentIdsInCollection`/`findReadableCollectionIds` 안의 closure는 범위 제한이 없다. 검색·문서목록·컬렉션목록처럼 호출 빈도가 높은 화면에 다 걸려있어서, 컬렉션 수가 많아지면 병목 후보 1순위다. 지금 규모(수십~수백 개 추정)에선 무해. +- **컬렉션 상속용 재귀 CTE(`collection_ancestors`)가 매 호출마다 컬렉션 테이블 전체를 스캔한다** — `findAncestorIdsInclusive(collectionId)`처럼 `WHERE id = :collectionId`로 범위를 좁힌 쿼리는 문제없지만, `findReadableDocumentIds`/`findReadableDocumentIdsInCollection`/`findReadableCollections`(구 `findReadableCollectionIds`) 안의 closure는 범위 제한이 없다. 검색·문서목록·컬렉션목록처럼 호출 빈도가 높은 화면에 다 걸려있어서, 컬렉션 수가 많아지면 병목 후보 1순위다. 지금 규모(수십~수백 개 추정)에선 무해. (2026-08-19: 아래 두 항목은 `#240`으로 해결됐지만, 이 CTE 전체 스캔 자체는 여전히 남아있는 별개 이슈 — `docs/design/kangcheolung-#240-collection-list-pagination.md`의 "남은 이슈" 참고) +- ~~`GET /collections`가 전체 ID를 먼저 찾고 그중 일부를 재조회하는 2단계 구조~~ → `#240`에서 `findReadableCollections`(COUNT(*) OVER()로 콘텐츠+총개수 한 쿼리)로 해결 +- ~~`GET /collections/{id}/children`이 자식마다 `canReadCollection`을 반복 호출(N+1)~~ → `#240`에서 `findReadableChildren`(권한 조건을 SQL로 이관)로 해결 - `DOCUMENT_MANAGER` role 관련 작업은 이번에도 스코프 제외 (별도 논의 필요). - 프론트 트리 탐색 UI는 "클릭해서 한 단계씩 열람"만 구현 — 여러 단계를 한 번에 펼쳐 보여주는 UI는 안 만듦(파인더 방식 그대로). diff --git a/docs/design/kangcheolung-#240-collection-list-pagination.md b/docs/design/kangcheolung-#240-collection-list-pagination.md new file mode 100644 index 00000000..b7d27fea --- /dev/null +++ b/docs/design/kangcheolung-#240-collection-list-pagination.md @@ -0,0 +1,350 @@ +# #240 컬렉션 목록/자식 조회 — 권한 필터링을 앱단이 아니라 SQL에서 처리 + +closes #240 + +--- + +## 배경 + +이슈 #229(컬렉션 트리) 구현 완료 후 코드래빗 리뷰 대응 과정에서, `GET /collections`와 +`GET /collections/{id}/children` 두 API가 권한 필터링을 DB 쿼리 한 번에 끝낼 수 있는데도 +애플리케이션 코드가 대신 반복/재조회를 하고 있다는 걸 발견했다. 지금 컬렉션 규모(수십~수백 +개 추정)에선 체감이 없어서 한동안 백로그로 남겨뒀다가, 이번에 정식으로 고쳤다. + +--- + +## 문제상황 + +### 문제 1 — GET /collections (컬렉션 목록) + +`CollectionQueryService.getCollections()`가 페이지 하나(예: 20개)를 보여주기 위해 +쿼리를 2단계로 나눠서 불렀다. + +```java +// 수정 전 +public PageResponse getCollections(Long userId, String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page, size, COLLECTION_SORT); + List readableIds = collectionRepository.findReadableCollectionIds(userId, keyword); // ① 전체 ID + if (readableIds.isEmpty()) { + return PageResponse.from(Page.empty(pageable), List.of()); + } + Page collections = collectionRepository.findAllByIdIn(readableIds, pageable); // ② 그중 20개 + ... +} +``` + +①번 쿼리는 owner/PUBLIC/USER 직접권한/ROLE/DEPARTMENT 5개 조건을 `UNION`으로 묶고, +부모 컬렉션 상속 판단을 위해 `WITH RECURSIVE collection_ancestors`까지 쓰는데, `LIMIT`이 +없어서 "이 사용자가 읽을 수 있는 컬렉션"을 페이지 크기와 무관하게 전부 계산해서 +`List`으로 돌려줬다. ②번 쿼리가 그 리스트를 `WHERE id IN (:ids)`로 다시 조회해서 +20개로 잘랐다. + +사용자가 읽을 수 있는 컬렉션 수(N)가 늘어날수록, 페이지 크기와 무관하게 N에 비례해서 +전송량·서버 메모리·`IN` 절 크기가 커지는 구조였다. + +### 문제 2 — GET /collections/{id}/children (직계 자식 조회) + +`CollectionQueryService.getChildren()`이 자식을 전부 가져온 뒤, 자식마다 권한 판단 +함수를 반복 호출했다(N+1 쿼리 패턴). + +```java +// 수정 전 +return collectionRepository.findAllByParentCollectionIdAndStatus(collectionId, CollectionStatus.ACTIVE) + .stream() + .filter(child -> permissionQueryService.canReadCollection(userId, child)) // 자식마다 반복 + .map(collectionConverter::toResponse) + .toList(); +``` + +`canReadCollection` 내부는 자식 하나당 최악의 경우 쿼리 6개(직접 USER/ROLE/DEPARTMENT +권한 3개 + 조상 체인 조회 1개 + 조상 ROLE/DEPARTMENT 확인 2개)까지 나갔다. 자식이 M개면 +최악의 경우 요청 하나에서 최대 6M개 쿼리가 발생할 수 있었다. + +이 문제는 1번과 달리, 권한 판단에 필요한 최소 계산이 아니라 SQL 조건 하나로 대체 가능한 +걸 코드에서 반복하고 있던 순수한 낭비였다. + +--- + +## 설계 + +### 문제 1 해결 설계 + +Spring Data의 `Page` + 별도 `countQuery` 조합은 일부러 쓰지 않았다. 이 방식은 콘텐츠 +쿼리와 count 쿼리가 각각 독립 실행되는데, 둘 다 내부에서 재귀 CTE를 처음부터 다시 +계산한다 — 즉 재귀 계산이 원래 1번(①번 쿼리)만 돌던 게, 순진하게 "Page+countQuery"로 +바꾸면 오히려 1번→2번으로 늘어난다. 대신 `COUNT(*) OVER()` 윈도우 함수로 한 쿼리 안에서 +콘텐츠와 총개수를 동시에 계산하도록 설계했다. + +Spring Data JPA는 이 형태(entity 컬럼 + 윈도우 함수로 얹은 추가 컬럼)를 `Page`로 +자동 매핑해주지 못하므로, `total_count` 필드가 있는 프로젝션 인터페이스(`CollectionRow`)로 +결과를 받아서 서비스 레이어에서 `new PageImpl<>(content, pageable, totalCount)`으로 직접 +조립하는 방식으로 설계했다. + +### 문제 2 해결 설계 + +같은 부모 밑의 자식들은 "부모(및 그 위 조상들)로부터 상속받는 ROLE/DEPARTMENT 권한이 +있는지"를 전부 똑같이 공유한다 — 자식마다 다시 계산할 필요가 없다. 이 부분을 `WITH +RECURSIVE parent_ancestors`로 부모 기준 한 번만 계산하고, 자식별로 다른 부분 +(owner/PUBLIC/자기 자신에게 직접 부여된 권한)만 자식마다 `EXISTS` 조건으로 뒀다. 참고로 +`parent_ancestors`를 참조하는 `EXISTS` 서브쿼리는 바깥쪽 `c`(자식 행)와 상관관계가 없는 +비상관 서브쿼리라, PostgreSQL이 쿼리당 한 번만 평가하도록 최적화해주는 경우가 일반적이다 +(InitPlan) — 다만 이 최적화 여부와 무관하게 결과의 정확성은 항상 보장된다. + +--- + +## 해결 (구현) + +### 1. `CollectionRow.java` — 신규 프로젝션 인터페이스 + +```java +public interface CollectionRow { + Long getCollectionId(); + String getName(); + String getDescription(); + Long getOwnerUserId(); + Long getParentCollectionId(); + String getVisibility(); + String getStatus(); + LocalDateTime getCreatedAt(); + Long getTotalCount(); +} +``` + +기존 `VectorSearchRow`(pgvector 검색 결과 프로젝션)와 동일한 컨벤션 — 컬럼 alias가 +snake_case면 Spring Data JPA가 camelCase getter로 자동 매핑해준다. + +### 2. `CollectionRepository.java` — `findReadableCollections` / `findReadableChildren` 신규 + +`findReadableCollectionIds`(전체 ID 조회) + `findAllByIdIn`(재조회), `findAllByParentCollectionIdAndStatus`(조건 없는 전체 자식 조회) 세 메서드를 삭제하고 대체했다. + +```java +@Query(value = """ + WITH RECURSIVE collection_ancestors AS ( + SELECT id AS collection_id, id AS ancestor_id FROM collections + UNION ALL + SELECT ca.collection_id, c.parent_collection_id AS ancestor_id + FROM collection_ancestors ca + JOIN collections c ON c.id = ca.ancestor_id + WHERE c.parent_collection_id IS NOT NULL + ), + readable AS ( + -- 기존 5개 UNION 브랜치(owner/PUBLIC/USER/ROLE/DEPARTMENT) 그대로 + ... + ) + SELECT + c.id AS collection_id, c.name AS name, c.description AS description, + c.owner_user_id AS owner_user_id, c.parent_collection_id AS parent_collection_id, + c.visibility AS visibility, c.status AS status, c.created_at AS created_at, + COUNT(*) OVER() AS total_count + FROM collections c + JOIN readable r ON r.id = c.id + ORDER BY c.created_at DESC, c.id DESC + LIMIT :limit OFFSET :offset + """, nativeQuery = true) +List findReadableCollections( + @Param("userId") Long userId, @Param("keyword") String keyword, + @Param("limit") int limit, @Param("offset") long offset); +``` + +```java +@Query(value = """ + WITH RECURSIVE parent_ancestors AS ( + SELECT id, parent_collection_id FROM collections WHERE id = :parentId + UNION ALL + SELECT c.id, c.parent_collection_id FROM collections c + JOIN parent_ancestors a ON c.id = a.parent_collection_id + ) + SELECT c.* FROM collections c + WHERE c.parent_collection_id = :parentId AND c.status = 'ACTIVE' + AND ( + c.owner_user_id = :userId + OR c.visibility = 'PUBLIC' + OR EXISTS (... USER 직접권한 ...) + OR EXISTS (... ROLE 직접권한 ...) + OR EXISTS (... DEPARTMENT 직접권한 ...) + OR EXISTS (... parent_ancestors 거쳐서 ROLE 상속 ...) + OR EXISTS (... parent_ancestors 거쳐서 DEPARTMENT 상속 ...) + ) + ORDER BY c.created_at DESC, c.id DESC + """, nativeQuery = true) +List findReadableChildren(@Param("parentId") Long parentId, @Param("userId") Long userId); +``` + +### 3. `CollectionConverter.java` — `CollectionRow` → `CollectionResponse` 변환 오버로드 추가 + +```java +// findReadableCollections 네이티브 쿼리 프로젝션 결과를 그대로 변환 (owner 엔티티를 거치지 않음) +public CollectionResponse toResponse(CollectionRow row) { + return new CollectionResponse( + row.getCollectionId(), row.getName(), row.getDescription(), row.getOwnerUserId(), + row.getParentCollectionId(), VisibilityType.valueOf(row.getVisibility()), + CollectionStatus.valueOf(row.getStatus()), row.getCreatedAt() + ); +} +``` + +owner 엔티티를 `JOIN FETCH`해서 지연 로딩을 피할 필요가 없어졌다 — `owner_user_id`를 +컬럼으로 바로 받기 때문에 엔티티 프록시를 거치지 않는다. + +### 4. `CollectionQueryService.java` — 두 메서드 교체 + +```java +public PageResponse getCollections(Long userId, String keyword, int page, int size) { + Pageable pageable = PageRequest.of(page, size, COLLECTION_SORT); + List rows = collectionRepository.findReadableCollections( + userId, keyword, pageable.getPageSize(), pageable.getOffset()); + long totalElements = rows.isEmpty() ? 0 : rows.get(0).getTotalCount(); + + List content = rows.stream().map(collectionConverter::toResponse).toList(); + Page resultPage = new PageImpl<>(content, pageable, totalElements); + return PageResponse.from(resultPage, content); +} + +public List getChildren(Long userId, Long collectionId) { + DocumentCollection parent = collectionRepository.findById(collectionId) + .filter(c -> c.getStatus() != CollectionStatus.DELETED) + .orElseThrow(() -> new DocGridException(ErrorCode.COLLECTION_NOT_FOUND)); + if (!permissionQueryService.canReadCollection(userId, parent)) { + throw new DocGridException(ErrorCode.PERMISSION_DENIED); + } + return collectionRepository.findReadableChildren(collectionId, userId) + .stream() + .map(collectionConverter::toResponse) + .toList(); +} +``` + +`getChildren()`은 `.filter(child -> canReadCollection(...))` 줄이 통째로 사라졌다 — 권한 +조건이 리포지토리 쿼리 안으로 옮겨갔기 때문에 서비스는 결과를 DTO로 변환만 한다. + +--- + +## 고도화 결과 + +| | 수정 전 | 수정 후 | +|---|---|---| +| GET /collections 쿼리 횟수 | 2번(전체 ID 조회 + IN 재조회), 빈 페이지 요청 시 최대 3번(count 폴백 포함) | 1번(빈 페이지 요청 시에만 count 폴백 1번 추가) | +| GET /collections 전송량(N=읽을 수 있는 컬렉션 수) | N에 비례 | 페이지 크기만큼만, N과 무관 | +| GET /collections/{id}/children 중 **자식 조회** 쿼리 횟수(M=자식 수) | M당 최대 6번 반복(총 최대 6M) | 자식 수와 무관하게 1번 | +| 재귀 CTE(부모 상속 확인) 계산 횟수 | 각 API당 1번 | 동일하게 1번 유지 (콘텐츠+count를 한 쿼리로 합쳐서 유지) | + +**재귀 계산 자체(부모 상속 확인 비용)는 이 기능이 존재하는 한 없앨 수 없는 부분이라 그대로 +남는다** — 이번 수정으로 없앤 건 그 위에 얹혀있던 불필요한 낭비(N배로 커지는 전송/메모리/ +두 번째 쿼리, 자식 개수만큼 반복되던 권한 확인)다. + +**주의**: `GET /collections/{id}/children` 엔드포인트 전체는 자식 조회 앞에 부모 조회 +(`findById`)와 부모 자신에 대한 `canReadCollection` 권한 확인이 먼저 있어, 이 두 단계는 +이번 수정 대상이 아니라 그대로 남아있다 — 위 표는 그중 **자식 조회 부분**만의 비교다. 즉 +"엔드포인트가 항상 쿼리 1번으로 끝난다"는 뜻이 아니라, "자식 개수(M)에 비례해서 늘어나던 +부분이 사라졌다"는 뜻이다. + +### 검증 방법 — 회귀 테스트가 실제로 회귀를 잡아내는지 직접 확인 + +테스트를 작성한 뒤, "이 테스트가 진짜 문제를 잡아내는가"를 확인하려고 일부러 `getChildren()`을 +예전 N+1 코드로 되돌려서 테스트를 돌려봤다. + +```java +// 일부러 되돌린 코드 +return collectionRepository.findReadableChildren(collectionId, userId) + .stream() + .filter(child -> permissionQueryService.canReadCollection(userId, child)) // N+1 재현 + .map(collectionConverter::toResponse) + .toList(); +``` + +결과: +```text +Expected size: 3 but was: 0 +``` +`canReadCollection(userId, child)`가 테스트에서 스텁되지 않은 자식에 대해 기본값 +`false`를 반환해서 자식 3개가 전부 걸러졌다 — 처음 작성했던 자식 1개짜리 테스트로는 +이 회귀를 못 잡았을 것이다. 테스트를 자식 3개 + `then(permissionQueryService) +.should(times(1)).canReadCollection(...)` 검증으로 보강한 뒤 다시 확인하니, 정상 코드에서는 +통과하고 되돌린 코드에서는 실패하는 걸 재확인했다. 코드는 원상복구했다. + +--- + +## 로컬 검증 + +- `./backend/gradlew -p backend compileJava compileTestJava` 통과 +- `CollectionQueryServiceTest`(12개), `CollectionTreeRepositoryTest`(12개, 실제 로컬 + Postgres로 `findReadableCollections`/`findReadableChildren` 검증 — owner/PUBLIC 노출, + keyword 필터, 페이지네이션(limit/offset 분할과 totalCount 일치), ROLE/DEPARTMENT 부모 + 상속(직계 + 조부모 2단계), 자식 권한 필터링/제외 케이스), `CollectionControllerTest`(4개), + `CollectionCommandServiceTest`(17개) 전부 통과 +- collection/permission/document 도메인 전체 테스트(`./gradlew test --tests + "com.opensource.docgrid.domain.{collection,permission,document}.*"`) 통과 + +### 신규/변경된 테스트 케이스 (`CollectionTreeRepositoryTest`) + +- `findReadableCollections_ownerAndPublic` / `_filtersByKeyword` — 기존 케이스를 + `findReadableCollections` 기준으로 이관 +- `findReadableCollections_paginatesAndReturnsTotalCountOnEveryRow` — 컬렉션 3개 생성 후 + limit=2로 두 페이지 조회, 각 페이지 크기(2, 1)와 모든 행의 `totalCount`(=3)가 맞는지, + 두 페이지 사이에 중복이 없는지 검증 (신규) +- `findReadableCollections_inheritsDepartmentPermissionFromParent` / `_inheritsRolePermissionFromParent` — 기존 케이스 이관 +- `findReadableChildren_returnsDirectChildrenOnly` — 기존 `findAllByParentCollectionIdAndStatus` 테스트를 이관 +- `findReadableChildren_excludesChild_whenNoPermission` — 권한 없는 자식은 제외되는지 (신규, + 기존엔 이 필터링을 서비스 단위 테스트가 mock으로만 검증했는데 실제 DB 쿼리로 검증하도록 보강) +- `findReadableChildren_inheritsRolePermissionFromGrandparent` — 조부모(2단계 위)에 부여된 + ROLE 권한도 상속되는지 (신규, `parent_ancestors`가 직계 부모 1단계만이 아니라 여러 단계를 + 타고 올라가는지 검증) + +--- + +## PR 리뷰(CodeRabbit)로 발견한 버그 — 빈 페이지 요청 시 totalElements가 틀리게 나옴 + +`COUNT(*) OVER()`는 **반환된 행 위에만** 얹혀서 계산된다. 그런데 요청한 offset이 실제 결과 +범위를 넘어가면(예: 읽을 수 있는 컬렉션이 3개뿐인데 `page=5&size=20`으로 요청) `LIMIT/OFFSET` +자체가 0건을 반환하고, 그러면 `COUNT(*) OVER()`를 얹을 행 자체가 없어서 전체 개수를 전혀 알 +수 없다. 수정 전 코드는 이 경우를 그냥 `totalElements = 0`으로 처리했다: + +```java +// 수정 전 — "빈 페이지"와 "정말 0건"을 구분 못 함 +long totalElements = rows.isEmpty() ? 0 : rows.get(0).getTotalCount(); +``` + +실제로는 3건이 존재하는데 응답은 "totalElements: 0, totalPages: 0"으로 나가는 버그였다. + +**고침**: `rows`가 비어있을 때만 별도의 `countReadableCollections()` 쿼리(콘텐츠 없이 +`readable` CTE의 `COUNT(*)`만 계산, `findReadableCollections`와 동일한 권한 조건 유지)를 +한 번 더 호출한다. 정상 경로(행이 반환되는 대부분의 경우)는 여전히 쿼리 1번으로 끝나고, +"페이지 번호가 마지막 페이지를 넘어간" 드문 경우에만 쿼리가 1번 추가된다 — 이 설계가 +애초에 피하려던 "콘텐츠/count 쿼리 분리로 인한 재귀 CTE 이중 계산" 문제와는 다르다(그때는 +매 요청마다 항상 2번이었지만, 지금은 예외적인 경우에만 1번 추가되는 구조). + +```java +long totalElements = rows.isEmpty() + ? collectionRepository.countReadableCollections(userId, keyword) + : rows.get(0).getTotalCount(); +``` + +`getCollections_returnsAccurateTotalElements_whenPageBeyondLastPage`(신규)로 이 시나리오를 +검증하고, 정상 경로에서는 `countReadableCollections`가 호출되지 않는지도 +`then(collectionRepository).should(never())...`로 같이 확인했다. + +--- + +## 설계 결정 요약 + +- **`Page` + `countQuery` 대신 `COUNT(*) OVER()`**: 재귀 CTE 계산이 두 번 되는 걸 피하기 + 위해 의도적으로 Spring Data의 일반적인 페이지네이션 패턴을 안 썼다. +- **`parent_ancestors` 서브쿼리를 자식마다 재계산하지 않고 부모 기준 1번만 둠**: 상관관계 + 없는(비상관) 서브쿼리라 PostgreSQL이 자동으로 한 번만 평가해주는 걸 기대할 수 있지만, + 이건 최적화일 뿐 정확성의 전제 조건은 아니다. +- **owner 엔티티 `JOIN FETCH` 제거**: `CollectionRow` 프로젝션이 `owner_user_id`를 컬럼으로 + 바로 받아서, 엔티티 지연 로딩을 거칠 필요가 없어졌다. + +## 남은 이슈 / TODO + +- `findReadableChildren`의 EXISTS 서브쿼리 6개가 실제 PostgreSQL 실행계획에서 InitPlan으로 + 한 번만 평가되는지는 `EXPLAIN ANALYZE`로 별도 확인하지 않았다 — 정확성엔 영향 없지만, 나중에 + 성능을 더 다듬을 필요가 생기면 확인해볼 것. +- [[project_collection_ancestors_cte_perf_risk]]에 기록된 "재귀 CTE가 컬렉션 테이블 + 전체를 스캔"하는 이슈는 이번 스코프가 아니다 — `findReadableCollections`의 + `collection_ancestors` CTE는 여전히 범위 제한 없이 전체를 계산한다. + +## 다음 단계 + +머지 후 `docs/test-results/`에 테스트 결과 문서 별도 작성(`docs-management.md` +컨벤션). [[project_collection_list_pagination_perf_debt]] 메모리를 "해결됨"으로 갱신. diff --git a/docs/design/kangcheolung-#29-collection-management.md b/docs/design/kangcheolung-#29-collection-management.md index 43641e9a..c021790a 100644 --- a/docs/design/kangcheolung-#29-collection-management.md +++ b/docs/design/kangcheolung-#29-collection-management.md @@ -260,7 +260,7 @@ BUILD SUCCESSFUL **`getMyCollections()` → `getCollections()` — 권한 반영 + 페이지네이션 + 검색 (관련 작업)** - 이름 그대로 "owner 것만"이라 문서 목록(`GET /api/documents`, owner+PUBLIC+권한부여 전부 포함)과 비대칭이었던 게 QA 중 재발견됨. -- `CollectionRepository.findReadableCollectionIds(userId, keyword)` 신규 — owner+PUBLIC+USER직접권한+ROLE+DEPARTMENT(+부모 컬렉션 상속)를 전부 포함하는 native 쿼리. `DocumentRepository.findReadableDocumentIds`와 동일한 UNION 패턴. +- ~~`CollectionRepository.findReadableCollectionIds(userId, keyword)` 신규~~ — owner+PUBLIC+USER직접권한+ROLE+DEPARTMENT(+부모 컬렉션 상속)를 전부 포함하는 native 쿼리. `DocumentRepository.findReadableDocumentIds`와 동일한 UNION 패턴. → **(2026-08-19, 이슈 #240) `findReadableCollections()`로 교체됨**: ID만 조회한 뒤 그 ID로 재조회하던 2단계 구조 대신, `COUNT(*) OVER()` 윈도우 함수로 페이지 내용과 총개수를 한 쿼리에서 함께 계산하도록 바꿨다 — `docs/design/kangcheolung-#240-collection-list-pagination.md` 참고. - `GET /collections?keyword=&page=&size=`로 페이지네이션과 이름/설명 검색까지 같이 추가. - 안 쓰이게 된 `findAllByOwnerIdAndStatus()`는 삭제.