Api::V1::VideosController#upload_candidates (avg 1278ms, max 1438ms)
RCA: Api::V1::VideosController#upload_candidates Latency
Overview#
What Happened#
2026-05-26 09:56~12:06 UTC 사이에 cupixworks-api 서비스의 Api::V1::VideosController#upload_candidates 엔드포인트에서 평균 1278ms, 최대 1438ms의 응답 지연이 발생했다. 동일 시간대 14건의 요청 중 5건이 500ms를 초과했으며, 최악의 경우 2421ms에 달했다. HTTP 에러는 발생하지 않았고 모두 200 OK로 응답했지만, 사용자 체감 성능에 영향을 주는 수준의 latency였다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::VideosController#upload_candidates |
| top_frame | app/repositories/video_repository.rb:232 |
| env | production, us-west-2 |
| avg_duration | 1278ms |
| max_duration | 1438ms (클러스터 기준), 2421ms (같은 시간대 전체 기준) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| ellisdon (us-west-2) | 1 | 1115ms 응답 — 53건 결과 반환 시 serialization 544ms |
| evergreenconst (us-west-2) | 1 | 2421ms 응답 — 167건 결과 반환 시 serialization 1718ms |
| decasult (us-west-2) | 8 | 452~1436ms — 폴링 반복 중 DB 쿼리 지속적으로 ~400ms |
Timeline#
- 2026-05-26 09:56:39Z —
ellisdon팀 요청: DB 653ms + serialization 544ms = 1115ms (53건 반환) - 2026-05-26 11:14:51Z —
evergreenconst팀 요청: DB 978ms + serialization 1718ms = 2421ms (167건 반환) - 2026-05-26 12:02~12:12Z —
decasult사용자 8회 연속 폴링, DB 시간 지속적으로 394~436ms - 2026-05-26 12:06:28Z — 1436ms 응답 (DB 397ms + 미설명 ~1000ms gap)
- 2026-05-27 — RCA 분석 수행
Error Log#
{
"resource_name": "Api::V1::VideosController#upload_candidates",
"service": "cupixworks-api",
"occurrences": 2,
"avg_ms": 1278,
"max_ms": 1438,
"sample_trace_id": "1014089133148918977"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 2 (클러스터 기준), 5건 >500ms (같은 시간대)
- 최초 발생: 2026-05-26T09:56:36.809Z
- 최근 발생: 2026-05-26T12:06:26.952Z
Root Cause Summary#
upload_candidates 엔드포인트의 지연은 두 가지 주요 원인에 의해 발생한다: (1) VideoRepository#upload_candidates에서 readable_facility_ids(fresh: true) 호출이 매 요청마다 캐시를 우회하여 복잡한 permission 쿼리를 실행하고, 이어서 default_joins + permission_joins로 최대 19개의 JOIN이 포함된 SQL을 생성하여 DB 시간이 400978ms에 달한다. (2) 결과 건수가 많을 때(53167건) serialization 과정에서 VideoSerializer의 upload_url 속성이 resources association을 개별 로드(N+1)하고, 각 video에 대해 S3 presigned URL을 생성하여 serialization 시간이 최대 1718ms까지 증가한다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/videos_controller.rb:21 - Repository 호출:
app/repositories/video_repository.rb:232(upload_candidates메서드) - Permission 쿼리:
readable_facility_ids(fresh: true)— 캐시 우회, 매번 DB 조회 - Capture scope 조합:
WHERE record_id IN (...) OR facility_id IN (...)+eager_load(:capture_type)+untrashedcascade - Elasticsearch 검색:
terms: { 'capture.id': _capture_ids }— 대량 ID 배열 전달 - Permission JOIN:
video_repository.rb:59-229— 11개 permission subquery LEFT JOIN - Serialization:
VideoSerializer—upload_url속성에서 N+1 + S3 presigned URL 생성
def upload_candidates
video_query_option = Cupix::QueryOption::Video.new(get_query_option(enable_current_team: false), params)
videos = repository_instance.upload_candidates(video_query_option, {
current_user: current_user,
workspace_id: params[:workspace_id]
})
render_api Renderable.new({
search_result: videos,
is_collection: true,
serializer_option: @serializer_option
})
end
def upload_candidates(query_option, options = {})
# 1. Permission IDs - fresh: true bypasses cache
_record_ids = current_user.directly_accessible_record_ids
_facility_ids = current_user.readable_facility_ids(fresh: true)
# 2. Capture scope with multiple JOINs
captures = ::Capture.where(record_id: _record_ids)
.or(::Capture.where(facility_id: _facility_ids))
.upload_candidate
.created_on_app # eager_load(:capture_type) - LEFT OUTER JOIN
.untrashed # cascades parent (Record) join
# 3. Pluck all capture IDs into memory
_capture_ids = captures.pluck(:id)
# 4. Elasticsearch query with large terms array
# 5. Reload with 19-JOIN permission query
# 6. Serialize with N+1 resources + S3 presigned URLs
end
def self.default_joins(videos)
videos
.joins(:facility, :capture)
.joins("LEFT JOIN records ON ...")
.joins("LEFT JOIN cameras ON ...")
.joins("LEFT JOIN workspaces ON ...")
.joins("LEFT JOIN levels ON ...")
.joins("LEFT JOIN capture_types ON ...")
.includes(:storage)
end
Log Evidence#
Datadog 쿼리:
service:cupixworks-api @http.url_details.path:"/api/v1/videos/upload_candidates" @duration:>500000000
핵심 로그 — 가장 느린 요청 (evergreenconst, 167건 반환):
{
"timestamp": "2026-05-26T11:14:51.348Z",
"duration_ms": 2420.82,
"db_ms": 978.36,
"serialization_ms": 1718,
"total_entries": 167,
"team": "evergreenconst",
"region": "us-west-2",
"host": "ip-10-1-144-228",
"status_code": 200
}
DB 시간이 높은 요청 (ellisdon, 53건):
{
"timestamp": "2026-05-26T09:56:39.414Z",
"duration_ms": 1114.65,
"db_ms": 652.93,
"serialization_ms": 544,
"total_entries": 53,
"team": "ellisdon",
"region": "us-west-2"
}
미설명 latency gap 요청 (decasult, 1건 반환인데 1436ms):
{
"timestamp": "2026-05-26T12:06:28.809Z",
"duration_ms": 1436.47,
"db_ms": 396.77,
"serialization_ms": 11,
"total_entries": 1,
"team": "decasult",
"region": "us-west-2"
}
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | readable_facility_ids(fresh: true) 캐시 우회로 매 요청마다 복잡한 permission 쿼리 실행 → DB 시간 증가 |
video_repository.rb:238에서 fresh: true 명시적 전달. 결과 1건인 decasult도 DB 394~436ms 소요 |
— | Confirmed |
| H2 | 19-JOIN permission query가 결과 수와 무관하게 기본 비용이 높음 | decasult (1건 반환)도 DB 394ms. 8회 반복 모두 일관된 높은 DB 시간 | — | Confirmed |
| H3 | Serialization의 N+1 (resources + S3 presigned URL)이 대량 결과에서 지연 유발 | 167건 → 1718ms serialization, 53건 → 544ms (선형 비례). VideoSerializer에서 resources eager-load 누락 확인 |
1건 반환 시 serialization 10~14ms로 무시할 수준 | Confirmed (대량 결과 시) |
| H4 | 네트워크 지연 또는 cross-region 호출이 원인 | — | 모든 느린 요청이 us-west-2 내부에서 발생. eu-central-1 요청은 상대적으로 빠름 (92~975ms) | Rejected |
| H5 | Ruby GC pause가 미설명 latency gap의 원인 | 12:06:28 요청에서 DB 397ms + serialization 11ms인데 총 1436ms (약 1000ms gap) | 로그에서 GC 정보 확인 불가. 가능성은 있으나 증명 불가 | Inconclusive |
| H6 | Elasticsearch terms 쿼리의 대량 ID 배열이 병목 | _capture_ids 배열이 클 수 있음 (captures.pluck(:id)) |
DB 시간에 ES 쿼리가 포함되어 있을 가능성이 있으나, 로그에서 ES 시간 분리 불가 | Inconclusive |
Fix Recommendation#
즉시 조치 (Critical)#
app/repositories/video_repository.rb:238—readable_facility_ids(fresh: true)에서fresh: true제거 또는 TTL이 짧은 캐시 사용으로 변경. 매 요청마다 캐시를 우회할 이유가 없다면, 기존 캐시 메커니즘을 활용해야 한다.VideoSerializer에서upload_url속성 생성 시 사용하는resourcesassociation을default_joins에서.includes(:resources)로 eager-load 추가 (video_repository.rb:27-56).
단기 개선 (1주 이내)#
permission_joins로직(11개 LEFT JOIN subquery)을 materialized view 또는 사전 계산된 permission 테이블로 대체하여 JOIN 수 감소.upload_candidates결과에 대해 pagination limit 적용 (현재per_page=300). 167건 serialization에 1.7초가 소요되므로, 최대 50건으로 제한하고 cursor-based pagination 도입 검토.- S3 presigned URL 생성을 batch 또는 lazy 방식으로 변경 — 클라이언트가 실제 업로드 시점에 개별 요청하도록 분리.
장기 개선 (재발 방지)#
- Permission 시스템을 읽기 최적화된 별도 서비스/캐시 레이어로 분리. 현재 매 API 호출마다 permission JOIN을 수행하는 구조는 확장성 한계가 명확하다.
upload_candidates엔드포인트의 SLO 설정 (P95 < 500ms) 및 모니터링 대시보드 구축.- Capture scope 쿼리 최적화 —
record_id IN (...)+facility_id IN (...)대신 permission 기반 scope을 DB 레벨에서 사전 필터링하는 방식 검토.
Monitoring#
upload_candidatesP95/P99 latency alert:avg(last_5m):p95:trace.rack.request{resource_name:api::v1::videoscontroller_upload_candidates,env:production} > 1000- DB time 별도 추적:
avg(last_5m):trace.active_record.sql{resource_name:api::v1::videoscontroller_upload_candidates} > 500 - Serialization time이 1초 초과 시 alert 설정
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard —
fresh: true제거와includes(:resources)추가는 단순하나, permission JOIN 리팩토링은 광범위한 영향도 검토 필요