Skip to content

Commit 3087fe0

Browse files
authored
[Feat] 문서 인덱싱 상태 조회 API 구현
[Feat] 문서 인덱싱 상태 조회 API 구현
2 parents c471395 + 14fedb1 commit 3087fe0

14 files changed

Lines changed: 988 additions & 0 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# Issue #36 문서 인덱싱 상태 조회 API 설계
2+
3+
## 1. 목적
4+
5+
문서 업로드는 원본 파일과 `PENDING` 임베딩 작업을 생성한 뒤 즉시 응답한다. 실제 파싱, 청킹,
6+
임베딩은 비동기로 진행되므로 클라이언트가 현재 검색 가능한 버전과 처리 중인 버전을 구분해서
7+
확인할 수 있는 조회 API가 필요하다.
8+
9+
```http
10+
GET /api/documents/{documentId}/status
11+
Authorization: Bearer {token}
12+
```
13+
14+
이번 이슈는 상태를 조회만 한다. Worker 실행, 상태 변경, 인덱싱 완료, 실패·재시도는 포함하지 않는다.
15+
16+
## 2. 핵심 응답 계약
17+
18+
### 최초 버전 처리 중
19+
20+
최초 업로드에서는 `documents.current_version_id`가 Version 1을 가리키더라도 Version 1이 아직
21+
검색 가능한 상태는 아니다. 따라서 `currentVersion``null`이고 Version 1은
22+
`processingVersion`으로 반환한다.
23+
24+
```json
25+
{
26+
"documentId": 10,
27+
"documentStatus": "UPLOADED",
28+
"currentVersion": null,
29+
"processingVersion": {
30+
"versionNo": 1,
31+
"status": "UPLOADED",
32+
"jobStatus": "PENDING"
33+
}
34+
}
35+
```
36+
37+
### 새 버전 처리 중
38+
39+
Version 2가 처리되는 동안에는 기존 `INDEXED` Version 1을 검색 가능 버전으로 유지한다.
40+
41+
```json
42+
{
43+
"documentId": 10,
44+
"documentStatus": "INDEXED",
45+
"currentVersion": {
46+
"versionNo": 1,
47+
"status": "INDEXED"
48+
},
49+
"processingVersion": {
50+
"versionNo": 2,
51+
"status": "PARSING",
52+
"jobStatus": "PROCESSING"
53+
}
54+
}
55+
```
56+
57+
### 처리 완료
58+
59+
처리 중 Version이 없으면 `processingVersion``null`이다.
60+
61+
```json
62+
{
63+
"documentId": 10,
64+
"documentStatus": "INDEXED",
65+
"currentVersion": {
66+
"versionNo": 2,
67+
"status": "INDEXED"
68+
},
69+
"processingVersion": null
70+
}
71+
```
72+
73+
## 3. 상태 판정
74+
75+
`currentVersion``documents.current_version_id`가 가리키는 Version이 `INDEXED`일 때만 반환한다.
76+
최초 Version이 `UPLOADED`, `PARSING`, `CHUNKED`, `EMBEDDING` 또는 `FAILED`이면 검색 가능한
77+
버전이 아니므로 `null`이다.
78+
79+
처리 중 Version 상태는 기존 부분 유니크 인덱스의 조건과 동일하다.
80+
81+
```text
82+
UPLOADED
83+
PARSING
84+
CHUNKED
85+
EMBEDDING
86+
```
87+
88+
처리 중 Version에 연결된 활성 Job 상태는 다음 둘 중 하나다.
89+
90+
```text
91+
PENDING
92+
PROCESSING
93+
```
94+
95+
처리 중 Version은 있는데 활성 Job이 없거나 활성 Job이 중복되어 조회 행이 여러 개라면 정상 상태로
96+
숨기지 않고 `INDEXING_STATUS_INCONSISTENT` 오류로 처리한다.
97+
98+
## 4. 조회 일관성
99+
100+
Document, 현재 Version, 처리 중 Version, EmbeddingJob을 각각 순차 조회하면 Worker가 상태를 바꾸는
101+
중간에 서로 다른 시점의 값이 섞일 수 있다.
102+
103+
```text
104+
Version 조회: PARSING
105+
Job 조회: INDEXED
106+
```
107+
108+
이를 방지하기 위해 하나의 JPQL Projection 쿼리로 다음 관계를 함께 조회한다.
109+
110+
```text
111+
Document
112+
LEFT JOIN current DocumentVersion
113+
LEFT JOIN processing DocumentVersion
114+
LEFT JOIN active EmbeddingJob
115+
```
116+
117+
Projection은 조회에 필요한 ID, 문서 상태, 버전 번호, 버전 상태, Job 상태만 선택한다. Entity 전체를
118+
Controller에 노출하지 않으며 조회 과정에서 Dirty Checking 대상 상태를 변경하지 않는다.
119+
120+
## 5. 권한
121+
122+
상태 조회에는 기존 `PermissionQueryService.canReadDocument()`를 사용한다.
123+
124+
```text
125+
OWNER
126+
PUBLIC
127+
USER_CACHE
128+
ROLE
129+
DEPARTMENT
130+
```
131+
132+
읽기 권한이 없으면 `PERMISSION_DENIED`를 반환하고 상태 Projection 조회를 실행하지 않는다. 존재하지
133+
않는 문서는 `DOCUMENT_NOT_FOUND`, soft delete된 문서도 `DOCUMENT_NOT_FOUND`로 처리한다.
134+
135+
API는 기존 Security 설정의 `anyRequest().authenticated()` 적용을 받으므로 별도 Security 경로 변경은
136+
필요하지 않다.
137+
138+
## 6. 구현 구조
139+
140+
```text
141+
DocumentQueryController
142+
→ DocumentQueryService
143+
→ PermissionQueryService.canReadDocument()
144+
→ DocumentRepository.findDocumentStatus()
145+
→ DocumentStatusConverter
146+
→ DocumentStatusResponse
147+
```
148+
149+
응답 DTO는 현재 검색 가능한 버전과 처리 중 버전의 필드 차이를 명확히 하기 위해 분리한다.
150+
151+
```text
152+
DocumentStatusResponse
153+
├─ CurrentVersionStatusResponse
154+
└─ ProcessingVersionStatusResponse
155+
```
156+
157+
`ProcessingVersionStatusResponse``jobStatus`를 포함한다. 외부 클라이언트가 내부 작업을 직접
158+
조작하지 않으므로 Version ID와 Job ID는 이번 응답에 포함하지 않는다.
159+
160+
## 7. 오류 응답
161+
162+
| 상황 | HTTP | 오류 코드 |
163+
|---|---:|---|
164+
| 인증되지 않은 요청 | 401 | `COMMON-007` |
165+
| 문서 없음 또는 삭제된 문서 | 404 | `DOCUMENT-001` |
166+
| 문서 읽기 권한 없음 | 403 | `ROLE-002` |
167+
| Version과 Job 상태 불일치 | 500 | `DOCUMENT-STATUS-001` |
168+
169+
상태 불일치 오류는 외부에 DB 상세를 노출하지 않고 문서 ID와 조회 행 개수만 서버 오류 로그에 남긴다.
170+
171+
## 8. 제외 범위와 후속 계약
172+
173+
- 인덱싱 완료 시 `current_version_id`를 교체하는 기능
174+
- 실패한 Version과 Job 상태 전환
175+
- 자동 재시도 및 Lock 만료 복구
176+
- FAILED Version 수동 재처리
177+
- 실패 Version 상세 조회
178+
- Worker, 파서, 청커, 임베딩 서버 호출
179+
180+
후속 인덱싱 완료 기능은 새 Version을 `INDEXED`로 만들고 `current_version_id`를 교체한다. 이 API는
181+
변경된 DB 상태를 같은 응답 계약으로 그대로 반환한다. 실패 상세는 실패·재시도 기능에서 별도 필드나
182+
조회 계약으로 확장한다.
183+
184+
## 9. 테스트
185+
186+
- 최초 Version 처리 중 `currentVersion=null`
187+
- Version 1 `INDEXED` 상태에서 `processingVersion=null`
188+
- Version 1 검색 가능 상태를 유지하면서 Version 2 처리 상태 반환
189+
- 처리 중 Version과 활성 Job 상태 함께 반환
190+
- 처리 중 Version에 활성 Job이 없으면 상태 불일치 오류
191+
- 활성 Job이 중복되면 상태 불일치 오류
192+
- 읽기 권한 없음, 문서 없음, 삭제 문서 오류
193+
- 인증된 요청과 미인증 요청의 Controller 응답
194+
- 실제 OpenSQL 스키마에서 Projection 쿼리 검증
195+
196+
## 10. 완료 기준
197+
198+
- 읽기 권한이 있는 사용자가 문서 상태를 조회할 수 있다.
199+
- 검색 가능한 `INDEXED` Version만 `currentVersion`으로 반환한다.
200+
- 처리 중 Version과 Job 상태를 하나의 조회 스냅샷으로 반환한다.
201+
- 새 Version 처리 중 기존 검색 가능 Version이 유지된다.
202+
- 상태 불일치를 정상 응답으로 숨기지 않는다.
203+
- 조회 과정에서 Document, Version, Job 상태를 변경하지 않는다.
204+
- Repository, Service, Converter, Controller 테스트와 전체 빌드가 통과한다.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.opensource.docgrid.domain.document.controller;
2+
3+
import org.springframework.http.ResponseEntity;
4+
import org.springframework.web.bind.annotation.GetMapping;
5+
import org.springframework.web.bind.annotation.PathVariable;
6+
import org.springframework.web.bind.annotation.RequestMapping;
7+
import org.springframework.web.bind.annotation.RestController;
8+
9+
import com.opensource.docgrid.domain.auth.annotation.CurrentUser;
10+
import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse;
11+
import com.opensource.docgrid.domain.document.service.query.DocumentQueryService;
12+
import com.opensource.docgrid.global.common.response.ApiResponse;
13+
import com.opensource.docgrid.global.common.response.ResponseUtils;
14+
15+
import io.swagger.v3.oas.annotations.Operation;
16+
import io.swagger.v3.oas.annotations.Parameter;
17+
import io.swagger.v3.oas.annotations.tags.Tag;
18+
import lombok.RequiredArgsConstructor;
19+
20+
@Tag(name = "Document", description = "문서 관련 API")
21+
@RestController
22+
@RequestMapping("/api/documents")
23+
@RequiredArgsConstructor
24+
public class DocumentQueryController {
25+
26+
private final DocumentQueryService documentQueryService;
27+
28+
@Operation(
29+
summary = "문서 인덱싱 상태 조회",
30+
description = "현재 검색 가능한 INDEXED 버전과 처리 중인 버전 및 임베딩 작업 상태를 함께 조회합니다. "
31+
+ "최초 버전이 아직 처리 중이면 currentVersion은 null입니다. 문서 읽기 권한이 필요합니다."
32+
)
33+
@GetMapping("/{documentId}/status")
34+
public ResponseEntity<ApiResponse<DocumentStatusResponse>> getDocumentStatus(
35+
@PathVariable Long documentId,
36+
@Parameter(hidden = true) @CurrentUser Long userId
37+
) {
38+
return ResponseUtils.ok(documentQueryService.getDocumentStatus(userId, documentId));
39+
}
40+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.opensource.docgrid.domain.document.converter;
2+
3+
import org.springframework.stereotype.Component;
4+
5+
import com.opensource.docgrid.domain.document.dto.response.CurrentVersionStatusResponse;
6+
import com.opensource.docgrid.domain.document.dto.response.DocumentStatusResponse;
7+
import com.opensource.docgrid.domain.document.dto.response.ProcessingVersionStatusResponse;
8+
import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;
9+
import com.opensource.docgrid.domain.document.repository.DocumentStatusProjection;
10+
11+
@Component
12+
public class DocumentStatusConverter {
13+
14+
public DocumentStatusResponse toResponse(DocumentStatusProjection projection) {
15+
CurrentVersionStatusResponse currentVersion = null;
16+
if (projection.getCurrentVersionStatus() == DocumentVersionStatus.INDEXED) {
17+
currentVersion = new CurrentVersionStatusResponse(
18+
projection.getCurrentVersionNo(),
19+
projection.getCurrentVersionStatus()
20+
);
21+
}
22+
23+
ProcessingVersionStatusResponse processingVersion = null;
24+
if (projection.getProcessingVersionNo() != null) {
25+
processingVersion = new ProcessingVersionStatusResponse(
26+
projection.getProcessingVersionNo(),
27+
projection.getProcessingVersionStatus(),
28+
projection.getProcessingJobStatus()
29+
);
30+
}
31+
32+
return new DocumentStatusResponse(
33+
projection.getDocumentId(),
34+
projection.getDocumentStatus(),
35+
currentVersion,
36+
processingVersion
37+
);
38+
}
39+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.opensource.docgrid.domain.document.dto.response;
2+
3+
import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
@Schema(description = "현재 검색 가능한 문서 버전 상태")
8+
public record CurrentVersionStatusResponse(
9+
@Schema(description = "문서 버전 번호") int versionNo,
10+
@Schema(description = "문서 버전 상태", example = "INDEXED") DocumentVersionStatus status
11+
) {
12+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.opensource.docgrid.domain.document.dto.response;
2+
3+
import com.opensource.docgrid.domain.document.enums.DocumentStatus;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
@Schema(description = "문서 인덱싱 상태")
8+
public record DocumentStatusResponse(
9+
@Schema(description = "문서 ID") Long documentId,
10+
@Schema(description = "문서 상태") DocumentStatus documentStatus,
11+
@Schema(description = "현재 검색 가능한 버전. 아직 검색 가능한 버전이 없으면 null", nullable = true)
12+
CurrentVersionStatusResponse currentVersion,
13+
@Schema(description = "현재 처리 중인 버전. 처리 중인 버전이 없으면 null", nullable = true)
14+
ProcessingVersionStatusResponse processingVersion
15+
) {
16+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.opensource.docgrid.domain.document.dto.response;
2+
3+
import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;
4+
import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus;
5+
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
8+
@Schema(description = "현재 처리 중인 문서 버전과 임베딩 작업 상태")
9+
public record ProcessingVersionStatusResponse(
10+
@Schema(description = "문서 버전 번호") int versionNo,
11+
@Schema(description = "문서 버전 상태", example = "PARSING") DocumentVersionStatus status,
12+
@Schema(description = "임베딩 작업 상태", example = "PROCESSING") EmbeddingJobStatus jobStatus
13+
) {
14+
}

src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.opensource.docgrid.domain.document.repository;
22

3+
import java.util.Collection;
4+
import java.util.List;
35
import java.util.Optional;
46

57
import org.springframework.data.jpa.repository.Lock;
@@ -11,11 +13,37 @@
1113
import org.springframework.data.jpa.repository.JpaRepository;
1214

1315
import com.opensource.docgrid.domain.document.entity.Document;
16+
import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;
17+
import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus;
1418

1519
// A담당자 영역 — B담당자는 존재 확인 등 읽기 전용으로만 사용
1620
public interface DocumentRepository extends JpaRepository<Document, Long> {
1721

1822
@Lock(LockModeType.PESSIMISTIC_WRITE)
1923
@Query("SELECT d FROM Document d WHERE d.id = :documentId")
2024
Optional<Document> findByIdForUpdate(@Param("documentId") Long documentId);
25+
26+
@Query("""
27+
SELECT d.id AS documentId,
28+
d.status AS documentStatus,
29+
cv.versionNo AS currentVersionNo,
30+
cv.status AS currentVersionStatus,
31+
pv.versionNo AS processingVersionNo,
32+
pv.status AS processingVersionStatus,
33+
ej.status AS processingJobStatus
34+
FROM Document d
35+
LEFT JOIN d.currentVersion cv
36+
LEFT JOIN DocumentVersion pv
37+
ON pv.document = d
38+
AND pv.status IN :processingVersionStatuses
39+
LEFT JOIN EmbeddingJob ej
40+
ON ej.documentVersion = pv
41+
AND ej.status IN :activeJobStatuses
42+
WHERE d.id = :documentId
43+
""")
44+
List<DocumentStatusProjection> findDocumentStatus(
45+
@Param("documentId") Long documentId,
46+
@Param("processingVersionStatuses") Collection<DocumentVersionStatus> processingVersionStatuses,
47+
@Param("activeJobStatuses") Collection<EmbeddingJobStatus> activeJobStatuses
48+
);
2149
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.opensource.docgrid.domain.document.repository;
2+
3+
import com.opensource.docgrid.domain.document.enums.DocumentStatus;
4+
import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;
5+
import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus;
6+
7+
public interface DocumentStatusProjection {
8+
9+
Long getDocumentId();
10+
11+
DocumentStatus getDocumentStatus();
12+
13+
Integer getCurrentVersionNo();
14+
15+
DocumentVersionStatus getCurrentVersionStatus();
16+
17+
Integer getProcessingVersionNo();
18+
19+
DocumentVersionStatus getProcessingVersionStatus();
20+
21+
EmbeddingJobStatus getProcessingJobStatus();
22+
}

0 commit comments

Comments
 (0)