Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
implementation 'io.minio:minio:8.5.17'
Expand Down
284 changes: 263 additions & 21 deletions docs/design/kangcheolung-#134-ragops-dashboard-summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,33 +79,275 @@ Authorization: Bearer {JWT}

## 4. 조회 구조

### 4.1 Repository
읽는 순서는 Repository(4.1~4.3) → DTO(4.4) → Service(4.5) → Controller(4.6)를 따른다.
Service가 Repository들을 조합해서 DTO를 만들고, Controller는 그 결과를 그대로 HTTP로 감싸기만
한다.

- `DocumentRepository`: `countByDeletedAtIsNull()`, `countByStatus(DocumentStatus)`,
`countByStatusIn(Collection<DocumentStatus>)` — 메서드 이름 기반 자동 쿼리
- `EmbeddingJobRepository`: `countByStatus(EmbeddingJobStatus)`, `findAllByStatus(EmbeddingJobStatus)`
(후속 전체 재처리 Issue에서 재사용 예정), `findAverageProcessingMillis()` — PostgreSQL
`EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000` Native Query, 완료 Job이 없으면 `null` 반환
- `SearchQueryRepository`: `countByCreatedAtAfter(LocalDateTime)`
### 4.1 `DocumentRepository` — 문서 카운트 3종

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

`@Transactional(readOnly = true)` 클래스이며 자체 Repository 3개와 `WorkerNodeQueryService`에
의존한다. 각 카테고리를 독립적으로 조회해 `DashboardSummaryResponse`로 조합한다.
/**
* 대시보드 집계 카드의 전체 문서 수. Soft-delete된 문서는 제외한다.
*/
long countByDeletedAtIsNull();

- Worker 집계는 `resolveEffectiveStatus()`를 다시 구현하지 않고 `WorkerNodeQueryService.getWorkers()`
호출 결과의 `status` 필드(이미 Heartbeat 기준으로 계산됨)를 그대로 센다. Heartbeat 판정 기준이
바뀌어도 이 Service는 수정할 필요가 없다.
- `avgProcessMs`는 Native Query가 `null`을 반환하면 그대로 `null`을 응답하고, 값이 있으면 반올림해
`Long`으로 변환한다. 0으로 기본값을 채우지 않는다 — 완료 Job이 없는 상태에서 "평균 0ms"는 사실과
다른 정보이기 때문이다.
- 최근 24시간 기준 시각은 주입받은 `Clock`으로 계산해 테스트 시 고정 가능하게 한다.
/**
* 대시보드 집계 카드에서 특정 상태 하나에 속하는 문서 수를 센다 (예: 검색 가능 문서 수).
*/
long countByStatus(DocumentStatus status);

### 4.3 DTO
/**
* 대시보드 집계 카드에서 여러 상태에 걸친 문서 수를 센다 (예: 인덱싱 대기 중 문서 수).
*/
long countByStatusIn(Collection<DocumentStatus> statuses);

`DashboardSummaryResponse`가 `DocumentsSummaryResponse` / `JobsSummaryResponse` /
`WorkersSummaryResponse` / `SearchSummaryResponse` 4개를 필드로 갖는다. 각 필드는 `@Schema`로
Swagger 설명을 붙인다.
// ... 기존 메서드들
}
```

세 메서드 다 `@Query` 없이 메서드 이름만으로 Spring Data JPA가 쿼리를 자동 생성한다
(`countByDeletedAtIsNull` → `WHERE deleted_at IS NULL`, `countByStatusIn` → `WHERE status IN (...)`).
`DocumentRepository`는 파일 상단 주석에 "A담당자 영역 — B담당자는 읽기 전용으로만 사용"이라고
이미 명시돼 있어서, 쓰기 메서드는 추가하지 않고 count류만 붙였다.

### 4.2 `EmbeddingJobRepository` — 상태별 카운트 + 평균 처리 시간

```java
/**
* 대시보드 집계 카드(대기/처리 중/실패 작업 수)에 사용하는 상태별 Job 수를 센다.
*/
long countByStatus(EmbeddingJobStatus status);

/**
* 관리자 전체 재처리 대상인 FAILED Job 전체를 조회한다.
*/
List<EmbeddingJob> findAllByStatus(EmbeddingJobStatus status);

/**
* 완료된 Job의 평균 처리 시간을 밀리초 단위로 계산한다.
*
* <p>Queue 대기 시간({@code created_at})은 제외하고 Worker가 실제로 처리한 구간({@code started_at}
* ~ {@code completed_at})만 반영한다. 완료된 Job이 없으면 {@code null}을 반환한다.
*/
@Query(
value = """
SELECT AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000)
FROM embedding_jobs
WHERE status = 'INDEXED'
AND started_at IS NOT NULL
AND completed_at IS NOT NULL
""",
nativeQuery = true
)
Double findAverageProcessingMillis();
```

`countByStatus`는 4.1과 같은 메서드 이름 자동 쿼리. `findAllByStatus`는 이번 이슈에서 직접
쓰지 않고, 후속 이슈(FAILED 전체 재처리)가 `EmbeddingJobRepository.findAllByStatus(FAILED)`로
재사용할 걸 미리 준비해둔 것이다.

`findAverageProcessingMillis()`는 `AVG(completed_at - started_at)`을 쓰지 `AVG(completed_at -
created_at)`을 쓰지 않는다 — `created_at`부터 재면 Queue에서 대기한 시간까지 "처리 시간"에 섞여서
지표 의미가 흐려진다. PostgreSQL 전용 함수 `EXTRACT(EPOCH FROM ...)`를 쓰기 때문에 JPQL이 아니라
`nativeQuery = true`로 짰다. `AVG`는 대상 행이 0개면 SQL 표준상 `NULL`을 반환하므로, 반환 타입도
기본값 `0.0`이 아니라 `Double`(nullable)로 선언해서 "완료된 Job이 아예 없다"는 사실을 그대로
드러낸다.

### 4.3 `SearchQueryRepository` — 최근 검색 수

```java
public interface SearchQueryRepository extends JpaRepository<SearchQuery, Long> {

/**
* 대시보드 집계 카드의 최근 검색 요청 수. 기준 시각 이후 생성된 검색 Query를 센다.
*/
long countByCreatedAtAfter(LocalDateTime since);
}
```

원래 이 인터페이스는 `JpaRepository`만 상속하고 메서드가 하나도 없었다. 이번에 처음 추가한
메서드다. `since` 기준 시각(now - 24h)은 4.5절 Service가 계산해서 넘긴다.

### 4.4 DTO — `DashboardSummaryResponse` + 하위 4개

```java
public record DashboardSummaryResponse(
@Schema(description = "문서 현황")
DocumentsSummaryResponse documents,

@Schema(description = "인덱싱 작업 현황")
JobsSummaryResponse jobs,

@Schema(description = "Worker 현황")
WorkersSummaryResponse workers,

@Schema(description = "검색 현황")
SearchSummaryResponse search
) { }

public record DocumentsSummaryResponse(
@Schema(description = "전체 문서 수 (Soft-delete 제외)", example = "25368")
long total,

@Schema(description = "검색 가능 문서 수 (INDEXED 상태)", example = "21742")
long searchable,

@Schema(description = "인덱싱 대기 중인 문서 수 (UPLOADED, INDEXING 상태)", example = "132")
long pendingIndex
) { }

public record JobsSummaryResponse(
@Schema(description = "인덱싱 대기 작업 수 (PENDING)", example = "132")
long pending,

@Schema(description = "처리 중인 작업 수 (PROCESSING)", example = "8")
long processing,

@Schema(description = "실패 작업 수 (FAILED)", example = "27")
long failed,

@Schema(description = "평균 임베딩 처리 시간(ms). Queue 대기 시간은 제외한 순수 처리 시간이며, "
+ "완료된 Job이 없으면 null", example = "3200")
Long avgProcessMs
) { }

public record WorkersSummaryResponse(
@Schema(description = "정상(ACTIVE·IDLE) Worker 수", example = "5")
long activeCount,

@Schema(description = "전체 등록 Worker 수", example = "6")
long totalCount
) { }

public record SearchSummaryResponse(
@Schema(description = "최근 24시간 검색 요청 수", example = "342")
long recent24hCount
) { }
```

5개 파일로 나눈 이유는 프로젝트 컨벤션(`dto/response`에 응답 DTO 하나당 파일 하나)을 따른 것이다.
`documents`/`jobs`/`workers`/`search`가 각자 독립된 record라, 나중에 특정 카테고리 하나만 API로
따로 빼야 할 일이 생겨도 재사용하기 쉽다. `avgProcessMs`만 `Long`(기본 타입 `long`이 아니라
Wrapper)인 이유는 4.2에서 설명한 `null` 가능성을 DTO까지 그대로 전달하기 위해서다.

### 4.5 `DashboardQueryService`

```java
@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class DashboardQueryService {

private static final List<DocumentStatus> PENDING_INDEX_STATUSES =
List.of(DocumentStatus.UPLOADED, DocumentStatus.INDEXING);

private final DocumentRepository documentRepository;
private final EmbeddingJobRepository embeddingJobRepository;
private final SearchQueryRepository searchQueryRepository;
private final WorkerNodeQueryService workerNodeQueryService;
private final Clock clock;

public DashboardSummaryResponse getSummary() {
return new DashboardSummaryResponse(
getDocumentsSummary(),
getJobsSummary(),
getWorkersSummary(),
getSearchSummary()
);
}

private DocumentsSummaryResponse getDocumentsSummary() {
return new DocumentsSummaryResponse(
documentRepository.countByDeletedAtIsNull(),
documentRepository.countByStatus(DocumentStatus.INDEXED),
documentRepository.countByStatusIn(PENDING_INDEX_STATUSES)
);
}

private JobsSummaryResponse getJobsSummary() {
Double averageMillis = embeddingJobRepository.findAverageProcessingMillis();
return new JobsSummaryResponse(
embeddingJobRepository.countByStatus(EmbeddingJobStatus.PENDING),
embeddingJobRepository.countByStatus(EmbeddingJobStatus.PROCESSING),
embeddingJobRepository.countByStatus(EmbeddingJobStatus.FAILED),
averageMillis == null ? null : Math.round(averageMillis)
);
}

private WorkersSummaryResponse getWorkersSummary() {
List<WorkerNodeResponse> workers = workerNodeQueryService.getWorkers();
long activeCount = workers.stream()
.filter(worker -> worker.status() == WorkerStatus.ACTIVE || worker.status() == WorkerStatus.IDLE)
.count();
return new WorkersSummaryResponse(activeCount, workers.size());
}

private SearchSummaryResponse getSearchSummary() {
LocalDateTime since = LocalDateTime.now(clock).minusHours(24);
return new SearchSummaryResponse(searchQueryRepository.countByCreatedAtAfter(since));
}
}
```

- `getSummary()`가 4개의 `private` 메서드를 호출해서 `DashboardSummaryResponse`를 조립하는
단순 오케스트레이션이다. 카테고리 4개가 서로 의존하지 않아 순서는 상관없다.
- `getDocumentsSummary()`는 4.1의 세 메서드를 그대로 호출한다. `PENDING_INDEX_STATUSES`를
상수로 뺀 건 `pendingIndex`의 정의(`UPLOADED`, `INDEXING`)가 이 클래스 밖에서도 참조될 일이
없어서 필드 상수로 충분하다고 판단했다.
- `getJobsSummary()`가 `averageMillis == null ? null : Math.round(averageMillis)`로 널 처리를
명시적으로 한다 — `Math.round(null)`은 컴파일이 안 되고, 여기서 `0`으로 기본값을 채우면 "완료
Job이 없다"와 "평균이 정확히 0ms다"를 구분 못 하게 된다.
- `getWorkersSummary()`가 이 Service에서 유일하게 자기 Repository가 아니라 **다른 도메인의
Query Service**(`WorkerNodeQueryService`)를 부른다. `WorkerNodeQueryService.getWorkers()`가
반환하는 `WorkerNodeResponse.status()`는 이미 Heartbeat 기준으로 계산된 값이라(`WorkerNode.resolveEffectiveStatus()`),
여기서 그 판정 로직을 다시 만들 필요가 없다 — `ACTIVE`/`IDLE`인 것만 세면 된다.
- `getSearchSummary()`는 `LocalDateTime.now()`를 직접 안 쓰고 주입받은 `Clock`으로 현재 시각을
구한다. 이러면 테스트에서 `Clock.fixed(...)`로 시간을 고정해 "24시간 전"을 결정론적으로
검증할 수 있다(6.1절 테스트 참고).

### 4.6 `DashboardController`

```java
@Tag(name = "Admin - Dashboard", description = "관리자 전용 RAGOps Dashboard 집계 지표 API")
@RestController
@RequestMapping("/admin/dashboard")
@RequiredArgsConstructor
public class DashboardController {

private final DashboardQueryService dashboardQueryService;

@Operation(
summary = "대시보드 집계 지표 조회",
description = "문서·인덱싱 작업·Worker·검색 현황을 하나의 응답으로 집계해서 반환합니다. "
+ "모든 지표는 조회 시점 기준 Snapshot이며, 실시간 WebSocket push는 이 API의 범위가 아닙니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "집계 지표 조회 성공"),
@ApiResponse(
responseCode = "403",
description = "인증되지 않았거나 ADMIN 권한 없음",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
)
})
@GetMapping(value = "/summary", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ApiResponse<DashboardSummaryResponse>> getSummary() {
return ResponseUtils.ok(dashboardQueryService.getSummary());
}
}
```

`@RequestMapping("/admin/dashboard")` + `@GetMapping("/summary")`로 최종 경로가
`/admin/dashboard/summary`가 된다. `/admin/**`는 `SecurityConfig`에 이미 `hasRole("ADMIN")`
규칙이 있어서 이 Controller엔 별도 권한 어노테이션이 없다 — 경로만 `/admin` 아래 두면 자동으로
ADMIN 전용이 된다. 메서드 본문은 `dashboardQueryService.getSummary()` 결과를
`ResponseUtils.ok()`로 감싸는 게 전부다 — 입력 파라미터가 없어서 검증 로직도 없다.

`description`의 마지막 문장("실시간 WebSocket push는 이 API의 범위가 아닙니다")은 CodeRabbit
리뷰로 정정한 부분이다. 원래는 "WebSocket push로 제공됩니다"라고 써서, 아직 구현되지도 않은
기능(이슈#137에서 구현)이 이미 있는 것처럼 문서화하는 실수가 있었다.

## 5. 오류 계약

Expand Down
Loading