ES /docs

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에서 resourcesmasks 연관 객체를 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#

  1. 2026-05-26T03:41:53Z — 최초 slow trace 감지 (ap-southeast-2)
  2. 2026-05-26T04:59:44Z — us-west-2에서 최대 지연 1186ms 기록 (capture_id=702401, 634 panos)
  3. 2026-05-26T06:12:40Z — ap-southeast-2에서 thundering herd 발생 (17 페이지 동시 요청)
  4. 2026-05-26T06:12:56Z — 마지막 slow trace 기록

Error Log#

Datadog Logs

json
{
  "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.resourcespano.masks를 개별 쿼리로 로드하고, 각 resource/mask의 storage 연관까지 추가 쿼리를 발생시키는 N+1 패턴이 존재한다. PanoRepository.default_joinsincludes(: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:70app/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:

app/controllers/api/v1/panos_controller.rb:16-27ruby
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:

app/repositories/pano_repository.rb:76-112ruby
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):

app/serializers/pano_serializer.rb:101-103ruby
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):

app/serializers/pano_serializer.rb:163-174ruby
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 쿼리:

text
service:cupixworks-api "PanosController#index" @duration:>500

가장 느린 요청 (us-west-2, serialization이 93.8% 차지):

json
{
  "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 클라이언트):

text
service:cupixworks-api "PanosController#index" @http.useragent:"axios/1.6.7"
json
{
  "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_joinsresources: :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_joinsincludes(: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 시간 메트릭 추가 모니터링:
text
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:Api::V1::PanosController#index} by {region}
  • N+1 쿼리 감지 알림 (Bullet gem 또는 custom instrumentation):
text
service:cupixworks-api "PanosController#index" @serialization.duration:>500
  • Thundering herd 감지 (동일 capture_id 동시 요청 수):
text
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 추가는 기존 동작을 변경하지 않고 쿼리 수만 감소시킴. 다만 대량 데이터에서 메모리 사용량 증가 가능성 모니터링 필요