ES /docs

Api::V1::AerialPhotosController#index (avg 33108ms, max 33108ms)

RCA: Api::V1::AerialPhotosController#index latency (avg 42.7s)

Overview#

What Happened#

2026-07-30 08:00~08:04 KST 사이에 cupixworks-api 서비스의 GET /api/v1/aerial_maps/560/aerial_photos 요청 3건이 평균 42,708 ms(최대 42,708 ms) 로 처리되어 latency cluster 로 감지되었다. 모든 요청은 HTTP 200 OK 로 응답을 성공적으로 반환했으나, 응답 소요시간의 대부분(≈97%)이 serializer 단계에서 발생했다. 동일 시간대에 team okland(team id 736) 사용자 한 명이 aerial_map key 560 에 대해 per_page=100 페이지네이션을 페이지 14→15→16 으로 연속 조회한 흐름이 확인된다.

Quick Facts#

Field Value
resource_name Api::V1::AerialPhotosController#index
entry point app/controllers/api/v1/aerial_photos_controller.rb:10-19
top_frame app/serializers/aerial_photo_serializer.rb:28-38 (upload_url / download_url attributes)
avg / max duration 42,708 ms / 42,708 ms
serialization share 52,602 ms (≈97%) — 예: request_id f0dde542-77f3-4853-9fed-c0efcd0ce123
occurrences 3
env production, region us-west-2, tenant cupix
deploy production-us-west-2-20260728t0624z0-812cb7d9-cupixworks
sample trace 1313289666376352913

Affected Teams#

Team / Domain Error Count Impact
team okland (id 736) 3 aerial_map 560 (총 1,553 aerial_photos) 페이지네이션 시 페이지당 최대 54s 지연. 응답은 성공(200).

Timeline#

  1. 2026-07-30 08:00:02 KST — status board 가 svc:cupixworks-api::unknown incident open (started_at 기록). aerial_map 560 페이지 1건(request duration ~33s) 감지.
  2. 2026-07-30 08:00:45 KST — 첫 aerial_photos index request 200 OK (access log).
  3. 2026-07-30 08:01:40 ~ 08:03:41 KST — 동일 aerial_map(key=560) 대상으로 페이지 14~16 연속 조회, 각 페이지 response 200.
  4. 2026-07-30 08:04:14 KST — 마지막 page 16 응답 200 OK, duration=54,147ms, serialization=52,602ms, db=2,890ms, pagination.total_entries=1553, per_page=100.
  5. 2026-07-30 08:13:17 KST — 인접 latency cluster 018f6326-a687-4e1a-919e-cf0739f18a49(VideosController#upload_url, 10.3s) 감지되어 동일 svc-scope incident 로 병합. 이후 aerial_photos 요청은 별도 spike 없이 종료.

Error Log#

Datadog Logs

text
{
  "resource_name": "Api::V1::AerialPhotosController#index",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 33108,
  "max_ms": 33108,
  "sample_trace_id": "1313289666376352913"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 3
  • 최초 발생: 2026-07-30 08:03 KST
  • 최근 발생: 2026-07-30 08:00 KST (cluster 파일의 last_seenfirst_seen 보다 이른 값으로 기록되어 있음 — 원문 그대로 표기)

Root Cause Summary#

AerialPhotosController#index 는 페이지당 100건의 AerialPhoto 를 조회하고 각 항목을 AerialPhotoSerializer 로 직렬화하는데, 이 serializer 의 download_url / upload_url / thumbnail 속성은 레코드마다 S3 presigner 호출을 유발한다(AerialPhoto#image_sourceself.resource.object(ver).presigned_url(:get, expires_in: …) 를 호출, Resource#upload_urlAws::S3::Presigner 클라이언트를 새로 생성해 presigned_url(:put_object, …) 를 호출). 페이지에 100건이 있으면 presigner 호출은 페이지당 최대 200회(download+upload) 발생하고, 팀 okland 의 aerial_map 560 은 총 total_entries=1553 건의 photo 를 가지고 있어 사용자가 페이지 14~16 을 연속 조회하는 동안 페이지마다 serializer 가 52초 내외를 소모했다. DB 쿼리는 2.9s 로 비교적 작고, ES _search + default_joins(:storage) preload 도 정상 동작하므로, latency 는 순수하게 serializer 단계의 per-record presigned URL 생성(및 그에 부수한 상태 전이 self.uploading unless self.uploading?)에서 발생하는 fan-out 이 원인이다.

Technical Analysis#

Code Path#

Entry point — controller 는 repository 검색 결과를 그대로 Renderable 로 넘기고, per-record serializer 가 실행된다.

app/controllers/api/v1/aerial_photos_controller.rb:10-19ruby
def index
  aerial_photo_query_option = Cupix::QueryOption::AerialPhoto.new(get_query_option(enable_current_team: false), params)
  aerial_photos = repository_instance.search(aerial_photo_query_option)

  render_api Renderable.new({
    search_result: aerial_photos,
    is_collection: true,
    serializer_option: @serializer_option
  })
end

Repository 는 Elasticsearch 로 _search 를 수행하고, BaseRepository#search 가 후속으로 default_joins(:storage) + permission_joins 로 DB 레코드를 로드한다 — 이 단계는 로그상 db=2,890ms 수준이라 병목이 아니다.

app/repositories/aerial_photo_repository.rb:247-278ruby
def _search(query_option = nil)
  set_query_option(query_option)
  # …
  response = ::AerialPhoto.search(
    self.query_option.serializable_hash
  ).paginate(
    per_page: self.query_option.per_page,
    page: self.query_option.page
  )
  # …
end

Serializer 는 페이지의 각 aerial_photo 에 대해 upload_url, download_url, thumbnail, storage 등 fan-out attribute 를 계산한다.

app/serializers/aerial_photo_serializer.rb:28-38ruby
attribute :upload_url do |aerial_photo, params|
  if %i[created uploading missing].include?(aerial_photo.state_name)
    aerial_photo.resource_upload_url
  else
    nil
  end
end

attribute :download_url do |aerial_photo, params|
  aerial_photo.image_source(attachment: false)
end

Failure point — image_source 는 record 별로 S3 presigner 호출을 수행한다. 페이지당 100건이면 최대 100회의 presigned URL 생성이 발생한다.

app/models/concerns/aerialable/aerial_photo.rb:9-22ruby
def image_source(filename: nil, attachment: true)
  return nil unless self.state_uploaded?

  ver = self.resource.revision

  filename ||= self.resource.name
  download_filename = self.file_extension.present? ? "#{filename}.#{self.file_extension}" : filename

  if attachment
    self.resource.object(ver).presigned_url(:get, expires_in: 3.hours.to_i, response_content_disposition: "attachment; filename=#{CGI.escape(download_filename) rescue nil}")
  else
    self.resource.object(ver).presigned_url(:get, expires_in: 1.days.to_i)
  end
end

upload_urlcreated / uploading / missing state 일 때 presigned_upload_url 을 호출하며, 매 호출마다 Aws::S3::Presigner.new(client: client) 인스턴스를 생성한다.

app/models/concerns/storagable/resource.rb:101-126ruby
def upload_url(revision = nil, **kwags)
  self.uploading unless self.uploading?
  self.presigned_upload_url(revision, force: kwags[:force])
end

# …
def presigned_upload_url(revision, force: false)
  revision ||= self.revision + 1

  if !force && revision < self.revision
    raise Cupix::Errors::Parameter.new(code: 'ARG10001', reason: "Invalid revision: #{revision}")
  end

  client = Cupix::StorageService.client(storage_option: storage_option)
  signer = Aws::S3::Presigner.new(client: client)
  bucket_name = storage_option.s3_source_bucket_name
  expires_in = 2.hour.to_i
  # …
end

기대 동작: 페이지 크기 100 에 대해 응답은 수초 이내여야 한다. 실제 동작: serializer 단계에서 페이지당 최대 200회의 presigner 초기화 + Aws::S3::Presigner#presigned_url 호출이 직렬 실행되어 52초가 소요되고, 총 request duration 이 42~54초로 상승했다.

Log Evidence#

Datadog logs 쿼리:

text
service:cupixworks-api "AerialPhotosController#index"
2026-07-29T22:59:00Z ~ 2026-07-29T23:05:00Z

동일 시간 창에 aerial_map 560 대상 요청이 페이지 14→15→16 순으로 반복 관측되었다:

text
2026-07-29T23:00:45.683Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:01:40.776Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:01:45.775Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:01:51.784Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:02:19.810Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:02:21.811Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:02:36.897Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:02:54.170Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:03:19.829Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:03:41.888Z [200] GET /api/v1/aerial_maps/560/aerial_photos
2026-07-29T23:04:14.327Z [200] GET /api/v1/aerial_maps/560/aerial_photos

대표 slow request (page 15, request_id f0dde542-77f3-4853-9fed-c0efcd0ce123) 의 log attributes:

json
{
  "duration": 54147.88,
  "serialization": { "duration": 52602 },
  "db": 2890.75,
  "view": 0.11,
  "pagination": {
    "per_page": 100,
    "current_page": 15,
    "total_pages": 16,
    "total_entries": 1553
  },
  "params": {
    "per_page": "100",
    "page": "15",
    "fields": [
      "id","name","state","state_updated_at","captured_at",
      "thumbnail_state","thumbnail","filesize","meta","file_extension",
      "upload_url","aerial_map","created_at","updated_at",
      "download_url","image_error","camera_calibration","building","storage"
    ],
    "key": "560"
  },
  "team": { "domain": "okland", "id": 736 },
  "user": { "id": 38102, "email": "sam.hayes@okland.com" },
  "http": { "status_code": 200, "method": "GET" }
}

증거 요약:

  • serialization.duration 이 총 duration 의 97% 를 차지 (52,602 / 54,147ms).
  • pagination.total_entries=1553, per_page=100 → 페이지당 100건씩 serialize.
  • 요청 params 의 fieldsupload_url, download_url, thumbnail, storage 가 모두 포함 → serializer fan-out 최대치가 발생.
  • 응답은 모두 200 OK — 기능적 오류가 아닌 순수 latency 이슈.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 AerialPhotoSerializer 의 per-record upload_url / download_url (S3 presigner 호출) 이 페이지당 100건 fan-out 되어 serialization 이 52s 이상 소요 serialization.duration=52,602ms (총 duration 의 97%), 응답 params.fields 에 upload_url/download_url/thumbnail 포함, image_sourceResource#upload_url 는 각각 Aws::S3::Presigner.newpresigned_url 호출을 요청 시점에 수행 (app/models/concerns/aerialable/aerial_photo.rb:9-22, app/models/concerns/storagable/resource.rb:101-126) Confirmed
H2 DB N+1 쿼리(예: resource, storage) 가 병목 default_joins(:storage) preload (app/repositories/aerial_photo_repository.rb:29-31) 로 storage 는 eager load 됨, 이론적으로 resource 는 미preload 라 관련 있을 수 있음 log 의 db=2,890ms 로 총 duration 의 5% 미만. 병목의 주 축이 아님. Rejected
H3 Elasticsearch 지연 (예: circuit breaker 429) BaseRepository#searchElasticsearch::Transport::Transport::ServerError (429) rescue 존재 해당 rescue 로 인한 SYS20000 로그가 이 시간대에 없음. 응답은 200 OK 로 성공적으로 반환됨. Rejected
H4 외부 dependency 장애 (S3/AWS 리전) 로 인해 presigner 자체가 느려짐 이론적으로 가능. presigner 도 latency 를 유발할 수 있음. status-board 는 이 클러스터를 svc:cupixworks-api::unknown 로 분류(외부 dep 스코프 미매핑), 같은 시간대 dep:* incident 없음. 동일 노드의 다른 endpoint 는 정상 latency. presigned URL 자체는 서명 계산이므로 S3 왕복이 없다. Rejected
H5 트래픽 급증에 의한 노드 리소스 고갈 같은 노드(ip-10-1-144-228.us-west-2, ip-10-1-80-134.us-west-2) 에서 요청 처리 인접 latency spike 는 다른 endpoint(VideosController#upload_url 10.3s) 하나뿐이며, 같은 시간 aerial_photos 요청 중 페이지 크기가 작을 것으로 예상되는 request 는 6.6s 로 훨씬 짧음 → 노드 전체 문제가 아닌 응답별 페이로드 크기와 상관관계가 있음. Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 사실상 코드 변경 없이 per_page 파라미터를 낮추는 클라이언트/컨트롤러 정책으로 즉시 완화 가능하나, 이는 프런트엔드/클라이언트와의 조율이 필요하므로 자동화된 코드 수정 대상이 아니다. 우선적으로 다음 옵저버빌리티 조치를 통해 blast radius 를 지속 모니터링한다:
    • app/serializers/aerial_photo_serializer.rb:28-38 에서 upload_url, download_url, thumbnail 이 활성화된 request 의 latency 를 Datadog trace 에 span 태그로 노출하는 방향 검토 (지금은 serialization.duration 만 존재).

단기 개선 (1주 이내)#

  • presigned URL 생성 병렬화 또는 지연 로딩: AerialPhotoSerializerupload_url / download_url 은 fields 파라미터가 명시적으로 요구할 때만 계산되도록 유지되어 있으나, 페이지당 100건×2 URL fan-out 자체가 병목이다. 다음 중 하나의 방향을 검토:
    1. Aws::S3::Presigner 인스턴스를 request 단위로 재사용하도록 Resource#presigned_upload_url (app/models/concerns/storagable/resource.rb:118-119) 와 Aerialable::S3 계열 헬퍼를 리팩터링. 현재는 record 마다 Aws::S3::Presigner.new(client: client) 를 생성한다.
    2. AerialPhoto#image_source (app/models/concerns/aerialable/aerial_photo.rb:9-22) 를 페이지 단위 batch API 또는 lazy-hydration 으로 감싸 direct S3 왕복 없는 서명 연산을 병렬 fiber/thread pool 로 처리.
  • fields 기반 opt-in 강제: 클라이언트가 실제로 download_url / upload_url 이 필요 없는 화면에서도 default fields 로 이를 요청하고 있는지 조사하여, 불필요한 fan-out 을 없앤다. 현 로그의 request 는 두 필드를 모두 요청함.

장기 개선 (재발 방지)#

  • serializer-level cost budgeting: request 당 serializer 가 초당 발생시키는 S3 presigner 호출 수에 상한(예: 요청당 200개)을 두고 초과 시 warn 로그 + 알림을 발생. AerialPhotoSerializer / PanoSerializer / VideoSerializer 등 유사한 fan-out 패턴을 가진 serializer 를 일괄 점검.
  • presigned URL 캐싱 계층 도입: aerial_photo 는 (resource_id, revision, expires_bucket) 단위로 URL 이 결정되므로, 짧은 TTL(예: 5분) 의 Redis 캐시로 반복 요청 시 재계산을 회피 가능.
  • 페이지 크기 상한 정책 및 커서 기반 페이지네이션 도입: per_page 를 무제한으로 허용하는 대신 서버 기본값(예: 50)과 상한(예: 100)을 명시적으로 강제.

Monitoring#

Datadog 대시보드 timeseries 위젯에 넣을 수 있는 쿼리 (모두 writing-datadog-monitoring-queries 규칙 준수 — pipe/stats 문법 미사용):

95th percentile latency for the endpoint:

text
p95:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::aerialphotoscontroller#index,env:production}

Average serialization time (from log-based metric — 필요 시 log-based metric 생성 후 사용):

text
avg:tesla.request.serialization_duration{service:cupixworks-api,resource_name:api::v1::aerialphotoscontroller#index,env:production}

Slow request count (>10s) rate:

text
sum:trace.rack.request.hits{service:cupixworks-api,resource_name:api::v1::aerialphotoscontroller#index,env:production,@duration:>10s}.as_rate()

Alert 방향:

  • p95 > 10s for 5m → PagerDuty warning (재발 감지)
  • 페이지당 total_entries 상위 aerial_map 을 주간 리포트로 추출해 페이지네이션 UX 이슈 조기 발견

Risk Assessment#

  • Risk level: medium — 응답은 성공(200) 이지만 사용자 waited 시간이 40~50 초 수준이라 UX 및 클라이언트 timeout 위험이 있음. total_entries 가 큰 aerial_map 이 늘어날수록 반복 재현.
  • 예상 복잡도: standard — 즉시 조치는 옵저버빌리티 개선만 자동화 가능하고, 근본 개선(serializer fan-out 최적화, 캐싱)은 코드 리팩터링과 클라이언트 조율이 필요하므로 단일 PR 로 종료되기 어려움.