CaptureRepository#show N+1 query — DB contention
RCA: CapturesController#download_multiple_resource Latency (3100ms)
Overview#
What Happened#
2026-06-04 09:46 KST에 cupixworks-api 서비스의 Api::V1::CapturesController#download_multiple_resource 엔드포인트에서 3100ms의 응답 지연이 발생했다. 해당 요청은 capture 707591의 processing_options 리소스 다운로드였으며, 동시에 해당 capture의 preprocessor 처리 완료 및 skat master 호출이 진행되는 high-load 시점에 발생했다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::CapturesController#download_multiple_resource |
| top_frame | app/controllers/concerns/multiple_resourcable_controller.rb:20 |
| env | production, us-west-2 |
| avg_duration | 3100ms |
| max_duration | 3100ms |
Timeline#
- 2026-06-04 09:46:10 KST —
download_multiple_resource요청 발생 (3100ms latency, APM trace 3203445943561624117) - 2026-06-04 09:46:14 KST —
[302] GET /api/v1/captures/707591/resources/processing_options/download완료 로그 기록 - 2026-06-04 09:46:54 KST — 동일 capture 707591에 대해 preprocessor_agent_finished, CreateCaptureJob, skat master 호출 등 대량 처리 작업 동시 진행
Error Log#
{
"resource_name": "Api::V1::CapturesController#download_multiple_resource",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 3100,
"max_ms": 3100,
"sample_trace_id": "3203445943561624117"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-06-04 09:46 KST
- 최근 발생: 2026-06-04 09:46 KST
Root Cause Summary#
download_multiple_resource 엔드포인트는 단순히 S3 presigned URL로 302 redirect하는 작업이지만, set_capture before_action이 CaptureRepository.show를 호출하며 15개의 권한 테이블 LEFT JOIN과 7개의 INNER JOIN을 포함한 매우 무거운 SQL 쿼리를 실행한다. 이 쿼리가 capture 707591의 처리 파이프라인(preprocessor 완료, CreateCaptureJob, skat master 호출)과 동시에 실행되면서 DB 경합으로 인해 3100ms까지 응답 시간이 증가했다. 메트릭 데이터에 따르면 해당 시간대 평균 응답이 1.1-1.6초로 전반적으로 높았으며, 이 요청은 P99 outlier에 해당한다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/captures_controller.rb:17—before_action :set_capture set_capture가repository_instance.show(params[:id])를 호출BaseRepository.show→CaptureRepository.default_joins+CaptureRepository.permission_joinsset_multiple_resource에서@model.resources.where(kind:).or(...).first추가 쿼리- Action:
multiple_resourcable_controller.rb:20→@resource.download_url→presigned_url(:get, ...)
def set_capture
@model = repository_instance.show(params[:id])
end
def self.default_joins(record)
record.includes(:reviewers, :storage).joins(:level, :record, :capture_type, :facility, :workspace, :team, :user).select('
captures.*,
records.note AS record_note,
records.captured_at AS record_captured_at,
levels.name AS level_name,
workspaces.name AS workspace_name,
facilities.name AS facility_name,
facilities.key AS facility_key,
users.firstname AS user_firstname,
users.lastname AS user_lastname,
teams.name AS team_name,
teams.domain AS team_domain,
facilities.cycle_state AS applied_cycle_state,
capture_types.name AS capture_type_name,
capture_types.method AS capture_type_method,
capture_types.material AS capture_type_material,
capture_types.creation_platform AS capture_type_creation_platform,
capture_types.migrated_from AS capture_type_migrated_from
')
end
def self.permission_joins(record, current_user, select: nil, review_id: -1, **kwargs)
# 15개의 LEFT JOIN subquery로 review, capture, record, facility,
# workspace, team 수준의 user/group/system_group 권한을 모두 조회
# MAX(GREATEST(...)) 로 applied_permission 계산
record.joins("
LEFT JOIN (...) AS review_public_permissions ...
LEFT JOIN (...) AS review_user_permissions ...
LEFT JOIN (...) AS review_group_permissions ...
LEFT JOIN (...) AS capture_user_permissions ...
LEFT JOIN (...) AS record_user_permissions ...
LEFT JOIN (...) AS record_group_permissions ...
LEFT JOIN (...) AS record_system_group_permissions ...
LEFT JOIN (...) AS facility_user_permissions ...
LEFT JOIN (...) AS facility_group_permissions ...
LEFT JOIN (...) AS facility_system_group_permissions ...
LEFT JOIN (...) AS workspace_user_permissions ...
LEFT JOIN (...) AS workspace_group_permissions ...
LEFT JOIN (...) AS team_user_permissions ...
LEFT JOIN (...) AS team_group_permissions ...
LEFT JOIN (...) AS team_system_group_permissions ...
").group('id').select(_select).where(...)
end
Failure point는 특정 코드 결함이 아니라, 단순 redirect 작업에 비해 과도하게 무거운 set_capture 쿼리가 DB 부하 시점에 병목이 되는 구조적 문제이다.
def download_multiple_resource
if @resource.revision == 0
render_json 404
else
redirect_to @resource.download_url, allow_other_host: true
end
end
def download_url(opts = {})
ver = opts[:ver] || self.revision
raise Cupix::Errors::Resource.new(code: 'ENT10011', reason: "Resource does not uploaded: #{ver}") if ver.zero?
filename = opts[:filename].presence || self.name
case opts[:distribution]
when 'cloudfront'
_rcd = CGI.escape("attachment; filename=#{filename}")
else
if !opts[:exp].blank?
exp = opts[:exp]
else
exp = 3.hours.to_i
end
self.object(ver).presigned_url(:get, expires_in: exp, response_content_disposition: "filename=#{CGI.escape(filename) rescue nil}")
end
end
Log Evidence#
다음 Datadog 쿼리로 해당 시간대의 동일 엔드포인트 요청을 확인:
service:cupixworks-api "CapturesController#download_multiple_resource"
Time: 2026-06-04T00:44:00Z to 2026-06-04T00:48:00Z
해당 시간대에 capture 707591 관련 요청 확인:
[302] GET /api/v1/captures/707591/resources/processing_options/download (Api::V1::CapturesController#download_multiple_resource)
timestamp: 2026-06-04 09:46:14 KST
동일 trace ID로 발견된 동시 진행 작업:
service:cupixworks-api trace_id:3203445943561624117
Time: 2026-06-03T00:00:00Z to 2026-06-04T06:00:00Z
{"timestamp": "2026-06-04 09:46:54", "status": "info", "message": "[Capture] processing_preprocessor_agent_finished", "function": "log_event"}
{"timestamp": "2026-06-04 09:46:54", "status": "info", "message": "Sending message to https://sqs.us-west-2.amazonaws.com/.../cupix-tesla-ece-arm: {:job=>{:id=>1103257}...}", "class": "CreateCaptureJob", "function": "send_message"}
{"timestamp": "2026-06-04 09:46:54", "status": "info", "message": "skatmaster is invoked for capture 707591. job id: 1103257", "class": "Capture", "function": "run_skat_master"}
메트릭 데이터 (최근 2시간, 30초 간격):
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::capturescontroller_download_multiple_resource}
초기 시간대(인시던트 전후): 평균 0.93-1.59초 안정화 후: 평균 0.04-0.08초
이는 DB 부하가 높은 시간대에 해당 endpoint의 응답이 20-40배 느려짐을 확인한다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 복잡한 permission_joins SQL이 DB 부하 시 병목 | 15개 LEFT JOIN + 7개 INNER JOIN 구조 확인 (capture_repository.rb:279-470). 메트릭에서 동시간대 평균 1.1-1.6초로 전반적 지연 확인. 동일 trace에서 대량 처리 작업 동시 진행 확인. |
— | Confirmed |
| H2 | S3 presigned URL 생성 지연 | presigned_url은 로컬 서명 연산으로 네트워크 호출 불필요 (AWS SDK는 credential 캐싱). |
presigned URL은 SDK 로컬 연산이므로 ms 단위 소요. 안정 시 40-80ms 응답은 S3 지연이 주 원인이 아님을 증명. | Rejected |
| H3 | set_multiple_resource의 리소스 조회 쿼리 지연 |
resources.where(kind:).or(resources.where(key:)).first는 추가 쿼리 (multiple_resourcable_controller.rb:148). |
단순 WHERE + OR 쿼리로 단일 테이블 조회. 복잡도가 permission_joins 대비 매우 낮음. | Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
app/controllers/api/v1/captures_controller.rb:17—download_multiple_resourceaction을set_capturebefore_action에서 제외하고, 경량화된 capture 조회 로직을 사용해야 한다.- 현재
set_capture는repository_instance.show(params[:id])를 호출하여 전체 permission_joins를 실행하는데, resource download는 capture 자체의 상세 정보가 필요 없으므로Capture.find(params[:id])수준의 단순 조회 + 기본 권한 확인만으로 충분하다.
단기 개선 (1주 이내)#
download_multiple_resource,show_resource등 리소스 접근 action에 대해skip_permission: true옵션을 활용한 경량 조회 경로를 도입하거나,set_capture에서 호출하는permission_joins를 download 전용으로 간소화한 버전을 제공한다.CaptureRepository.permission_joins의 15개 LEFT JOIN을 필요한 수준에 맞게 분리 (full permission check vs. read-only check).
장기 개선 (재발 방지)#
- 리소스 다운로드 endpoint는 DB 부하와 무관하게 빠른 응답을 보장해야 하므로, presigned URL을 캐싱하거나 (resource revision이 변경되지 않는 한) CloudFront signed URL 경로로 전환하는 것을 검토한다.
default_joins+permission_joins패턴을 action 유형에 따라 최적화된 쿼리 빌더로 리팩토링 — read-only 조회에는 전체 permission hierarchy가 불필요.
Monitoring#
download_multiple_resource엔드포인트에 대한 P99 latency 알림 설정 (threshold: 1000ms)- DB 부하 시점에 해당 endpoint가 영향받는지 correlation 모니터링
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::capturescontroller_download_multiple_resource} > 1
Risk Assessment#
- Risk level: low
- 예상 복잡도: standard —
set_capturebefore_action의 except 목록에 action을 추가하고 경량 조회를 구현하는 수준