Api::V1::PanosController#index (avg 1364ms, max 2937ms)
RCA: Api::V1::PanosController#index Latency
Overview#
What Happened#
2026-05-26 03:41~06:12 UTC 동안 cupixworks-api의 PanosController#index 엔드포인트에서 평균 1364ms, 최대 2937ms의 응답 지연이 4개 리전(ap-southeast-2, us-west-2, ap-southeast-1, eu-central-1)에서 39건 발생했다. 주요 원인은 serializer에서 resources와 masks 연관 객체를 preload 없이 접근하는 N+1 쿼리 패턴과, 클라이언트의 병렬 pagination 요청에 의한 thundering herd 현상이다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::PanosController#index |
| top_frame | app/serializers/pano_serializer.rb:101-102 |
| runtime | Ruby on Rails (cupixworks-api) |
| env | production (ap-southeast-2, us-west-2, ap-southeast-1, eu-central-1) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| walmart (us-west-2) | 7 | 대량 pano 조회 시 페이지당 922-1186ms 지연, 사용자 체감 느린 로딩 |
| cupix (ap-southeast-2) | 17 | 병렬 pagination으로 첫 페이지 826-911ms 지연 |
Timeline#
- 2026-05-26T03:41:53Z — 최초 slow trace 감지 (ap-southeast-2)
- 2026-05-26T04:59:44Z — us-west-2에서 최대 지연 1186ms 기록 (capture_id=702401, 634 panos)
- 2026-05-26T06:12:40Z — ap-southeast-2에서 thundering herd 발생 (17 페이지 동시 요청)
- 2026-05-26T06:12:56Z — 마지막 slow trace 기록
Error Log#
{
"resource_name": "Api::V1::PanosController#index",
"service": "cupixworks-api",
"occurrences": 39,
"avg_ms": 1364,
"max_ms": 2937,
"sample_trace_id": "4376004021269166722"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 39
- 최초 발생: 2026-05-26T03:41:53.245Z
- 최근 발생: 2026-05-26T06:12:56.232Z
- 영향 리전: ap-southeast-2, us-west-2, ap-southeast-1, eu-central-1
- 영향 팀: walmart (team_id: 1124), cupix
Root Cause Summary#
PanoSerializer에서 resources 속성(line 101-102)과 masks 속성(line 163-174)을 렌더링할 때, 각 pano 객체마다 pano.resources와 pano.masks를 개별 쿼리로 로드하고, 각 resource/mask의 storage 연관까지 추가 쿼리를 발생시키는 N+1 패턴이 존재한다. PanoRepository.default_joins는 includes(:storage)로 Pano 자체의 storage만 preload하고, resources/masks의 storage는 preload하지 않는다. per_page=100일 때 페이지당 최대 120-180건의 추가 쿼리가 발생하며, 이로 인해 serialization 시간이 전체 응답의 90-94%를 차지한다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/panos_controller.rb:16-27 - Repository search:
app/repositories/base_repository.rb:70→app/repositories/pano_repository.rb:459(Elasticsearch query) - Record hydration:
app/repositories/pano_repository.rb:76-112(default_joins) - Serialization:
app/serializers/pano_serializer.rb:101-102(N+1 trigger point)
Controller - index action:
def index
pano_query_option = Cupix::QueryOption::Pano.new(get_query_option(enable_current_team: false), params)
panos = repository_instance.search(pano_query_option)
render_api Renderable.new({
search_result: panos,
is_collection: true,
serializer_option: @serializer_option.merge!({
params: params.permit(:revision_type).to_h
})
})
end
default_joins - Pano의 storage만 preload:
def self.default_joins(record)
record.includes(:storage).joins(:capture, { capture: :level }, :record, :facility, :workspace, :team).joins("
LEFT JOIN clusters ON clusters.id = panos.cluster_id
LEFT JOIN masks ON masks.id = panos.mask_id AND masks.maskable_type = 'Pano'
LEFT JOIN capture_types ON capture_types.id = captures.capture_type_id
LEFT JOIN cameras ON cameras.id = captures.camera_id
").select('
panos.*,
clusters.name AS cluster_name,
...
')
end
기대 동작: includes(:storage)가 resources와 masks의 storage도 포함해야 하지만, 실제로는 Pano 모델의 직접 storage 연관만 eager loading 된다.
Serializer - N+1 트리거 지점 (resources):
attribute :resources do |pano|
pano.resources.map { |resource| { id: resource.id, storage_bucket_key: resource.object_key, s3_source_bucket_name: resource.storage.s3_source_bucket_name, s3_bucket_region: resource.storage.s3_bucket_region, bucket_endpoint: resource.storage.endpoint } }
end
각 pano마다 pano.resources (1 query) + 각 resource의 resource.storage (N queries) 발생. per_page=100일 때 최대 100 + 200 = 300 추가 쿼리.
Serializer - N+1 트리거 지점 (masks):
attribute :masks do |pano|
pano.masks.map do |mask|
{
id: mask.id,
type: mask.mask_type,
storage_bucket_key: mask.mask_object_key,
s3_hosting_bucket_name: mask.storage.s3_hosting_bucket_name,
s3_bucket_region: mask.storage.s3_bucket_region,
state: mask.state
}
end
end
동일한 패턴: pano.masks (1 query) + mask.storage (N queries).
Log Evidence#
Datadog 쿼리:
service:cupixworks-api "PanosController#index" @duration:>500
가장 느린 요청 (us-west-2, serialization이 93.8% 차지):
{
"timestamp": "2026-05-26T04:59:44.968Z",
"message": "[200] GET /api/v1/panos (Api::V1::PanosController#index)",
"duration": 1186.56,
"db": 200.45,
"serialization": {"duration": 1113},
"view": 0.16,
"controller": "Api::V1::PanosController",
"action": "index",
"region": "region:us-west-2",
"host": "ip-10-1-80-134.us-west-2.compute.internal",
"user_agent": "cupix-agent",
"team": {"domain": "walmart", "id": 1124},
"params": {
"per_page": "100",
"page": "6",
"capture_id": "702401",
"fields": ["id","name","state","meta","created_at","pano_type","version","tile_size","resource_state","tile_state","mask_state","stitched","origin","exif","cluster","capture","level","record","facility","georeference","constants","brightness","exif_create_data","thumbnail_urls","original_upload_url","updated_at","captured_at","exif_create_date","upload_url","mask_upload_url","timestamp","resources"]
},
"pagination": {"per_page": 100, "total_pages": 7, "total_entries": 634, "current_page": 6}
}
Serialization vs DB 시간 비교 (us-west-2, capture_id=702401):
| Page | Total (ms) | DB (ms) | Serialization (ms) | Serialization % |
|---|---|---|---|---|
| 6 | 1186 | 200 | 1113 | 93.8% |
| 2 | 1110 | 198 | 1012 | 91.1% |
| 5 | 1069 | 162 | 986 | 92.2% |
| 4 | 1007 | 193 | 917 | 91.1% |
| 3 | 980 | 141 | 898 | 91.6% |
| 1 | 922 | 150 | 846 | 91.7% |
Thundering herd 패턴 (ap-southeast-2, axios/1.6.7 클라이언트):
service:cupixworks-api "PanosController#index" @http.useragent:"axios/1.6.7"
{
"timestamp": "2026-05-26T06:12:40.305Z",
"message": "[200] GET /api/v1/panos (Api::V1::PanosController#index)",
"duration": 911.28,
"db": 91.42,
"params": {"capture_id": "73250", "per_page": "30", "page": "1", "fields": ["id","name","state","meta","created_at","pano_type"]},
"pagination": {"per_page": 30, "total_pages": 17, "total_entries": 488, "current_page": 1},
"host": "ip-10-1-17-211"
}
17개 페이지가 동시에 요청됨 — 동일 호스트에 6개씩 병렬 연결로 인해 첫 페이지에서 DB connection contention 발생 (91ms vs 일반적인 13-33ms).
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Serializer의 resources/masks N+1 쿼리가 serialization 시간 폭증의 원인 |
default_joins에 resources: :storage, masks: :storage 미포함 (pano_repository.rb:76). Serialization이 전체 응답의 91-94% 차지. 32 fields 요청 시(resources 포함) 1000ms+ serialization |
— | Confirmed |
| H2 | DB 쿼리 자체가 느려서 지연 발생 | — | DB 시간은 138-200ms로 전체의 7-9%만 차지. Elasticsearch + MySQL hydration은 정상 범위 | Rejected |
| H3 | Thundering herd로 인한 DB connection pool 고갈 | ap-southeast-2에서 17 페이지 동시 요청 시 첫 페이지 DB 91ms (정상 대비 3-7배). 같은 호스트에 6+ 동시 연결 | fields가 6개뿐이라 serialization은 106-147ms로 낮음. 주 지연은 connection wait | Contributing factor |
| H4 | S3 presigned URL 생성의 CPU overhead | upload_url, tile_upload_urls 등 attributes에서 presigning 발생 가능 |
Presigning은 네트워크 호출 없이 로컬 계산. 6 fields 요청(URL 미포함) 시에도 지연 발생하므로 주요 원인 아님 | Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 파일:
app/repositories/pano_repository.rb:76 - 방향:
default_joins의includes(:storage)를includes(:storage, resources: :storage, masks: :storage)로 변경하여 N+1 제거 - 이 변경으로 per_page=100일 때 최대 300개의 개별 쿼리가 3개의 bulk query로 대체됨
- Serialization 시간이 현재 846-1113ms에서 100-200ms 수준으로 감소 예상
단기 개선 (1주 이내)#
PanoSerializer에서resources,masks속성이fields파라미터에 포함된 경우에만 실제 로드하도록 조건부 serialization 적용- 현재 fields 파라미터로 속성을 필터링하는 로직이 있다면 해당 로직이 eager loading과 연동되는지 확인 필요
- Client-side에서
per_page=100+ 32 fields 조합의 사용 패턴 검토 — 필요한 fields만 요청하도록 클라이언트 최적화 권고
장기 개선 (재발 방지)#
- API rate limiting 또는 request coalescing 도입으로 thundering herd 방지 (동일 capture_id의 동시 요청 제한)
- Serializer에서 field-based eager loading: 요청된 fields에 따라 동적으로
includes를 조정하는 패턴 도입 - 대량 pano 조회에 대한 cursor-based pagination 고려 (offset pagination의 성능 한계 회피)
Monitoring#
- Serialization 시간 메트릭 추가 모니터링:
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:Api::V1::PanosController#index} by {region}
- N+1 쿼리 감지 알림 (Bullet gem 또는 custom instrumentation):
service:cupixworks-api "PanosController#index" @serialization.duration:>500
- Thundering herd 감지 (동일 capture_id 동시 요청 수):
service:cupixworks-api "PanosController#index" @http.useragent:"axios*" | count by capture_id per 5s > 10
Risk Assessment#
- Risk level: medium
- 예상 복잡도: trivial (includes 변경 1줄로 핵심 문제 해결 가능)
- 수정 시 regression 가능성: 낮음 — eager loading 추가는 기존 동작을 변경하지 않고 쿼리 수만 감소시킴. 다만 대량 데이터에서 메모리 사용량 증가 가능성 모니터링 필요