건수 두 개와 작업 하나를 읽는 데 도메인마다 세 번 조회했다

작업 현황 화면에서 대기 건수, 처리 중 건수, 가장 오래 기다린 작업을 읽었다. 슬라이드·보이스·영상·과목·다운로드의 다섯 테이블에 같은 조회를 반복하니 큐 현황만 15개 요청이었다. 각 도메인 안에서도 조회는 병렬로 실행됐다. 줄일 대상은 순차 대기 15번이 아니라 DB에 보내는 요청 수였다.

조회가 비슷해 보여도 한 SQL로 옮길 때는 결과의 뜻을 함께 확인해야 했다. 대기 시간이 같은 작업 중 어느 ID를 보여 줄지, 실패 후 성공한 재시도를 실패 건수에 남길지, 동률인 오류 코드 중 무엇을 상위 다섯 개에 넣을지가 응답 계약에 포함돼 있었다. 변경은 이 조건을 SQL에 담으면서 큐 현황을 한 번에 읽도록 바꿨다.

건수와 가장 오래된 작업을 같은 분기에서 구한다

각 작업 테이블에서 QUEUED와 PROCESSING만 읽고, 조건부 집계로 상태별 건수를 나눈다. 대기 중인 작업의 가장 이른 시각은 MIN, 해당 ID는 생성 시각과 ID 순으로 정렬한 배열의 첫 원소로 구한다. 아래는 도메인 표시 열을 생략한 한 테이블의 집계다.

SELECT
  COUNT(*) FILTER (WHERE status = 'QUEUED') AS queued,
  COUNT(*) FILTER (WHERE status = 'PROCESSING') AS processing,
  (ARRAY_AGG(id ORDER BY "createdAt" ASC, id ASC)
    FILTER (WHERE status = 'QUEUED'))[1] AS oldest_id,
  MIN("createdAt") FILTER (WHERE status = 'QUEUED') AS oldest_at
FROM "SlideJob"
WHERE status IN ('QUEUED', 'PROCESSING');

가장 이른 시각만 읽은 뒤 다른 조회에서 ID를 찾으면 두 조회 사이의 변경을 고려해야 한다. 같은 분기에서 함께 구하고, 생성 시각이 같을 때 ID를 보조 정렬 키로 써 선택을 일정하게 만들었다. 다섯 분기를 UNION ALL로 합친 뒤 응답은 고정된 도메인 순서로 조립한다. UNION의 반환 순서에 기대지는 않는다.

테이블명은 코드 안의 고정 매핑에서만 가져와 Prisma.raw로 넣는다. 도메인 값과 시간 경계 같은 동적 값은 바인딩한다. 테이블명을 문자열로 넣는 부분이 있다고 해서 요청에서 받은 임의의 이름을 SQL에 연결하는 구조는 아니다.

전체 이력의 마지막 시도와 조회 창의 마지막 시도는 다르다

작업 로그에는 같은 작업의 여러 시도가 남는다. 실패한 첫 시도와 성공한 두 번째 시도를 모두 세면 재시도가 성공해도 실패율이 높아진다. 집계는 먼저 시간 범위 안의 기록을 고른 뒤, 작업별로 가장 높은 attempt를 남긴다. attempt가 없는 기록은 작업 ID로 합치지 않고 각 행을 독립적으로 취급한다.

시간별 추이는 현재와 이전 기간을 함께 다루는 48시간 창, 소요시간과 오류 코드는 최근 24시간 창에서 각각 순위를 매긴다. 아래 예시는 한 집계 창의 의미를 설명하는 가상 기록이다.

기록의 위치그 창에서 남는 기록실패 집계
실패 1차 · 성공 2차 모두 창 안성공 2차실패로 세지 않음
실패 1차는 창 안 · 성공 2차는 창 밖실패 1차그 창에서는 실패로 셈

전체 이력에서 최신 시도를 먼저 고른 뒤 시간을 거르는 방법은 다른 질문에 답한다. 현재 작업의 최종 결과를 조회하기에는 적합할 수 있지만, 이 화면이 정한 “기간 안 기록 중 마지막 시도”를 바꾼다. 두 창의 순위 계산을 하나로 합치면 SQL은 짧아져도 시간별 추이와 일간 오류 집계의 뜻이 달라질 수 있다.

범위는 UTC 정각으로 계산하고 하한 포함·상한 제외로 조회한다. 바인딩한 시간은 ::timestamp로 해석해 DB 세션 시간대에 따라 버킷이 옮겨지지 않게 한다. 화면의 최근 24시간은 진행 중인 정각 버킷을 포함한 24개 버킷이며, 호출 시각에서 정확히 24시간을 빼 만든 이동 구간과도 구분된다.

잘라 낸 여섯 번째 오류는 나중에 정렬해도 돌아오지 않는다

오류 코드 상위 다섯 개는 건수 내림차순, 코드 오름차순으로 선택한다. 동률인 코드가 여섯 개라면 비교 규칙은 배열을 예쁘게 정렬하는 규칙을 넘어 어떤 코드가 응답에 포함되는지를 정한다. DB에서 다섯 개를 자른 다음 JavaScript의 로케일 비교로 재정렬해도 이미 제외된 코드를 복구할 수 없다.

ROW_NUMBER() OVER (
  ORDER BY failed DESC, error_code COLLATE "C" ASC
) AS ord
-- 이 순위를 매긴 뒤 ord <= 5로 제한한다.

코드 비교를 DB의 COLLATE "C"로 고정하고 순위 ord를 함께 반환한다. JavaScript는 UNION 결과를 ord 순서로 배치한다. 누락된 오류 코드는 UNKNOWN으로 합치고, 정렬과 상위 개수 제한은 같은 규칙 안에서 끝낸다.

요청은 줄었지만 SQL 안의 일까지 사라진 것은 아니다

현재 현황 응답은 큐 현황, 작업 로그 집계, 정체 이벤트의 세 raw 쿼리를 병렬로 호출한다. 단위 테스트에는 호출 수 3개와 DB 순위에 따른 배열 조립을 확인하는 단언이 있다. 실제 PostgreSQL을 사용하는 통합 테스트는 손으로 센 재시도·시간 경계·백분위 기대값, Asia/Seoul 세션에서 같은 결과, 동률 오류 여섯 개 중 정해진 다섯 개의 선택을 확인한다.

15개에서 1개로 줄어든 것은 큐 현황 요청 수다. 응답 지연이나 DB CPU가 같은 비율로 줄었다는 측정은 없다. 가장 오래된 ID를 하나 얻기 위해 대기 ID 전체의 정렬 배열을 만드는 비용도 남는다. 대기열이 커지면 이 집계와 인덱스를 이용한 별도 최소 행 조회를 실행 계획으로 비교할 수 있지만, 요청 수만으로 어느 쪽이 더 빠르다고 결정할 수는 없다.

세 SQL이 병렬로 시작해도 하나의 공통 스냅샷을 읽는 트랜잭션은 아니다. 큐 현황과 로그가 시스템의 정확히 같은 순간을 보여 준다는 보장까지 더하지 않았다. AI Commons의 이 변경에서 고정한 것은 조회 횟수와 각 집계의 의미, 그리고 응답에 들어올 행의 선택 순서다.