|
| 1 | +# 문서 목록 조회 API |
| 2 | + |
| 3 | +closes #153 |
| 4 | + |
| 5 | +## 배경 |
| 6 | + |
| 7 | +문서 도메인에는 업로드(`POST /api/documents`)와 단건 인덱싱 상태 조회 |
| 8 | +(`GET /api/documents/{documentId}/status`)만 있고, **사용자가 읽을 수 있는 문서 목록을 |
| 9 | +반환하는 API가 없었다.** 문서를 한 번 업로드하고 나면 그 `documentId`를 따로 기억하지 않는 한 |
| 10 | +다시 찾아갈 방법이 없어, 문서 화면을 구성할 수 없는 상태였다. |
| 11 | + |
| 12 | +목록에 필요한 권한 판정은 이미 존재한다. 검색 pre-filter가 쓰는 |
| 13 | +`DocumentRepository.findReadableDocumentIds`가 5가지 접근 경로(OWNER / PUBLIC / |
| 14 | +USER 캐시 / ROLE live / DEPARTMENT live)를 UNION으로 판정한다. 다만 이 쿼리는 |
| 15 | +`d.status = 'INDEXED'`가 하드코딩되어 있어 그대로는 목록에 쓸 수 없다. 목록에서는 인덱싱 중 |
| 16 | +(`INDEXING`)이거나 실패(`FAILED`)한 문서도 보여야 사용자가 진행 상황을 확인할 수 있기 |
| 17 | +때문이다. |
| 18 | + |
| 19 | +## 설계 판단 |
| 20 | + |
| 21 | +### 쿼리를 복제하지 않고 상태 조건만 파라미터화했다 |
| 22 | + |
| 23 | +UNION 7개 브랜치짜리 네이티브 쿼리를 목록용으로 복사하면 권한 정책이 두 벌이 되어, 이후 접근 |
| 24 | +경로가 추가될 때 한쪽만 고치는 사고가 나기 쉽다. `d.status = 'INDEXED'`를 |
| 25 | +`d.status IN (:statuses)`로 바꾸고, 검색은 호출부에서 `INDEXED`만 넘겨 기존 동작을 그대로 |
| 26 | +유지한다. |
| 27 | + |
| 28 | +| 호출자 | 넘기는 상태 | |
| 29 | +|---|---| |
| 30 | +| `AccessibleDocumentQueryService` (검색) | `INDEXED` | |
| 31 | +| `DocumentQueryService` (목록) | `DELETED`를 제외한 전체, 또는 요청한 단일 상태 | |
| 32 | + |
| 33 | +`statuses`는 네이티브 쿼리라 `DocumentStatus.name()` 문자열 목록으로 넘긴다. |
| 34 | + |
| 35 | +### 권한 판정과 페이징을 분리했다 |
| 36 | + |
| 37 | +권한 pre-filter로 읽을 수 있는 문서 ID를 먼저 구하고, 그 ID 집합에 대해 JPQL 페이지 쿼리로 |
| 38 | +정렬·페이징만 수행한다. 응답에 현재 버전 번호·상태를 담아야 하는데 `Document.currentVersion`이 |
| 39 | +`LAZY`라 `LEFT JOIN FETCH`로 즉시 로딩한다(`countQuery`는 별도 지정). |
| 40 | + |
| 41 | +읽을 수 있는 문서가 하나도 없으면 페이지 쿼리를 아예 실행하지 않고 빈 응답을 반환한다. |
| 42 | + |
| 43 | +### 정렬은 고정이다 |
| 44 | + |
| 45 | +외부 `sort` 파라미터를 받지 않고 `createdAt DESC, id DESC`로 고정한다. 정렬 키를 열어두면 |
| 46 | +인덱스 없는 컬럼 정렬 요청을 그대로 DB에 흘리게 되고, 응답 계약도 불안정해진다. |
| 47 | + |
| 48 | +## API 명세 |
| 49 | + |
| 50 | +### 요청 |
| 51 | + |
| 52 | +```http |
| 53 | +GET /api/documents?status=INDEXED&page=0&size=20 |
| 54 | +Authorization: Bearer {accessToken} |
| 55 | +``` |
| 56 | + |
| 57 | +| 파라미터 | 타입 | 필수 | 기본값 | 설명 | |
| 58 | +|---|---|---|---|---| |
| 59 | +| `status` | `DocumentStatus` | X | 없음 | 지정 시 해당 상태만 조회. 미지정 시 `DELETED` 제외 전체 | |
| 60 | +| `page` | int | X | `0` | 0부터 시작하는 페이지 번호 | |
| 61 | +| `size` | int | X | `20` | 페이지 크기, 1~100 | |
| 62 | + |
| 63 | +`DocumentStatus`: `DRAFT` / `UPLOADED` / `INDEXING` / `INDEXED` / `FAILED` / `ARCHIVED` / `DELETED` |
| 64 | + |
| 65 | +### 응답 200 |
| 66 | + |
| 67 | +```json |
| 68 | +{ |
| 69 | + "success": true, |
| 70 | + "status": 200, |
| 71 | + "data": { |
| 72 | + "content": [ |
| 73 | + { |
| 74 | + "documentId": 12, |
| 75 | + "title": "2026 상반기 운영 가이드", |
| 76 | + "description": "운영팀 공유용", |
| 77 | + "documentType": "PDF", |
| 78 | + "status": "INDEXED", |
| 79 | + "visibility": "PRIVATE", |
| 80 | + "ownerUserId": 3, |
| 81 | + "currentVersionNo": 2, |
| 82 | + "currentVersionStatus": "INDEXED", |
| 83 | + "createdAt": "2026-08-10T09:12:33", |
| 84 | + "updatedAt": "2026-08-11T14:02:10" |
| 85 | + } |
| 86 | + ], |
| 87 | + "page": 0, |
| 88 | + "size": 20, |
| 89 | + "totalElements": 1, |
| 90 | + "totalPages": 1, |
| 91 | + "first": true, |
| 92 | + "last": true |
| 93 | + }, |
| 94 | + "timestamp": "2026-08-12 19:30:00" |
| 95 | +} |
| 96 | +``` |
| 97 | + |
| 98 | +아직 인덱싱이 끝난 버전이 없으면 `currentVersionNo`와 `currentVersionStatus`는 `null`이다. |
| 99 | + |
| 100 | +### 에러 케이스 |
| 101 | + |
| 102 | +| 상황 | HTTP | 응답 | |
| 103 | +|---|---|---| |
| 104 | +| 인증 토큰 없음 또는 만료 | 401 | 인증 실패 | |
| 105 | +| `page < 0`, `size < 1`, `size > 100` | 400 | 제약 조건 위반 | |
| 106 | +| `status`에 정의되지 않은 값 | 400 | 타입 변환 실패 | |
| 107 | +| 읽을 수 있는 문서 없음 | 200 | `content: []`, `totalElements: 0` (에러 아님) | |
| 108 | + |
| 109 | +읽을 수 있는 문서가 없는 것은 정상 상태이므로 404가 아니라 빈 페이지를 반환한다. 권한 없는 문서는 |
| 110 | +목록에서 조용히 제외되며, 존재 여부를 응답으로 노출하지 않는다. |
| 111 | + |
| 112 | +## 변경 파일 |
| 113 | + |
| 114 | +| 파일 | 변경 | |
| 115 | +|---|---| |
| 116 | +| `DocumentRepository` | 권한 pre-filter 쿼리 2개 상태 파라미터화, 목록 페이지 쿼리 `findAllByIdIn` 추가 | |
| 117 | +| `AccessibleDocumentQueryService` | 검색 호출부에서 `INDEXED` 상태를 명시적으로 전달 | |
| 118 | +| `DocumentSummaryResponse` | 신규 응답 record | |
| 119 | +| `DocumentSummaryConverter` | 신규 Converter | |
| 120 | +| `DocumentQueryService` | `getMyDocuments` 추가 | |
| 121 | +| `DocumentQueryController` | 목록 API 추가, `@Validated`로 page·size 범위 검증 | |
| 122 | + |
| 123 | +## 테스트 |
| 124 | + |
| 125 | +**단위 — `DocumentQueryServiceTest`** |
| 126 | + |
| 127 | +- 읽을 수 있는 문서를 페이지 응답으로 변환해 반환한다 |
| 128 | +- 읽을 수 있는 문서가 없으면 문서를 조회하지 않고 빈 페이지를 반환한다 |
| 129 | +- `status`를 지정하지 않으면 `DELETED`를 제외한 전체 상태로 조회한다 |
| 130 | +- `status`를 지정하면 해당 상태만으로 조회한다 |
| 131 | + |
| 132 | +**Repository — `DocumentReadableIdsRepositoryTest` (`@DataJpaTest`)** |
| 133 | + |
| 134 | +- `INDEXED`만 요청하면 인덱싱 중인 문서는 제외한다 |
| 135 | +- `INDEXING`을 함께 요청하면 인덱싱 중인 문서도 반환한다 |
| 136 | +- soft delete된 문서는 `DELETED` 상태를 요청해도 제외한다 |
| 137 | +- 컬렉션 범위 조회도 요청한 상태만 반환한다 |
| 138 | + |
| 139 | +seed 데이터에 PUBLIC·INDEXED 문서가 있어 모든 사용자 조회 결과에 포함되므로, 테스트가 생성한 |
| 140 | +문서만 포함·제외로 검증한다. |
| 141 | + |
| 142 | +**검색 회귀** |
| 143 | + |
| 144 | +상태 조건을 파라미터화하면서 검색 경로가 바뀌지 않았는지 |
| 145 | +`AccessibleDocumentQueryServiceTest`, `SearchFacadeTest`, |
| 146 | +`DocumentIndexingCompletionIntegrationTest`로 확인했다. |
| 147 | + |
| 148 | +```bash |
| 149 | +./gradlew build |
| 150 | +``` |
| 151 | + |
| 152 | +760개 테스트 전부 통과. |
0 commit comments