ES /docs

AerialPhotosController#index serializer — synchronous S3 presigned_url calls causing bottleneck

RCA: Api::V1::AerialPhotosController#index Latency (1085ms)

Overview#

What Happened#

2026-05-27 09:15경, cupixworks-api의 AerialPhotosController#index 엔드포인트에서 1085ms 응답 지연이 발생했다. 동일 시간대에 동일 aerial_map(id: 451)에 대한 대량 업로드 워크플로우(29개 사진 동시 생성/업로드)가 진행 중이었으며, serializer가 각 레코드에 대해 S3 presigned URL을 동기적으로 생성하면서 응답 시간이 크게 증가했다.

Quick Facts#

Field Value
resource_name Api::V1::AerialPhotosController#index
top_frame app/serializers/aerial_photo_serializer.rb:29
env production, us-west-2
deploy production-us-west-2-20260527t0533z0-650f3601-cupixworks

Timeline#

  1. 09:14:52Z — 사용자(Kristoffer Lundkvist, nestle team)가 aerial_map 451에 29개 aerial_photo 업로드 시작
  2. 09:15:20ZGET /api/v1/aerial_maps/451/aerial_photos 요청 (fields에 upload_url 포함, per_page=250)
  3. 09:15:21Z — 응답 완료 (1075ms 소요, DB=10ms, 나머지 ~1065ms는 presigned URL 생성)
  4. 09:16:06Z — 동일 엔드포인트 재요청 (1138ms, fields에 download_url 포함)
  5. 09:38:51Z — 서버 부하 감소 후 동일 요청 245ms로 정상 응답

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::AerialPhotosController#index",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 1085,
  "max_ms": 1085,
  "sample_trace_id": "776029696073822875"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-05-27T09:15:20.195Z
  • 최근 발생: 2026-05-27T09:15:20.195Z

Root Cause Summary#

AerialPhotosController#index의 serializer가 upload_url 필드 요청 시 각 AerialPhoto 레코드에 대해 Aws::S3::Object#presigned_url을 동기적으로 호출한다. 29개 레코드 × presigned URL 생성(~36ms/건)이 순차 실행되면서 약 1065ms가 소요되었다. 동시에 진행 중이던 대량 업로드 워크플로우(100+ 동시 요청)로 인한 S3 API rate limiting 또는 connection pool 경합이 개별 presigned_url 호출의 지연을 악화시켰다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/aerial_photos_controller.rb:10
  • Serializer: app/serializers/aerial_photo_serializer.rb:29-34
  • URL 생성: app/models/concerns/aerialable/aerial_photo.rb:9-22
  • S3 호출: Aws::S3::Object#presigned_url (aws-sdk-s3 gem)

Controller의 index 액션이 repository를 통해 Elasticsearch 검색 후 결과를 serialize한다:

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

Serializer에서 upload_url 필드가 요청되면 각 레코드에 대해 S3 presigned URL을 생성한다:

app/serializers/aerial_photo_serializer.rb:29-34ruby
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

resource_upload_urlResourcable concern을 통해 S3 presigned upload URL을 생성한다:

app/models/concerns/resourcable/aerial_photo.rb:40-42ruby
def resource_upload_url
  self.upload_url
end
app/models/concerns/resourcable.rb:70-80ruby
def upload_url(revision = nil, **kwags)
  if self.resource.nil?
    return nil
  end

  if kwags[:whitelist_headers].present?
    self.resource.upload_url_with_whitelist_headers(revision, kwags[:whitelist_headers])
  else
    self.resource.upload_url(revision, force: kwags[:force])
  end
end

최종적으로 S3 presigned URL을 생성하는 코드:

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

download_url 필드도 유사하게 S3 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

기대 동작: 29개 레코드에 대한 index 응답이 200-300ms 이내에 완료. 실제 동작: presigned URL 생성이 동기적으로 순차 실행되며 1085ms 소요. DB 쿼리(10ms)와 serialization(0ms) 외 ~1065ms가 S3 API 호출에 소비됨.

Log Evidence#

Datadog에서 동일 시간대 요청을 검색한 쿼리:

text
service:cupixworks-api @http.url_details.path:"/api/v1/aerial_maps/451/aerial_photos" @http.method:GET

핵심 로그 (Request A, 1075ms):

json
{
  "timestamp": "2026-05-27T09:15:21.945Z",
  "duration_ms": 1075.46,
  "db_runtime_ms": 10.12,
  "view_runtime_ms": 0.09,
  "serialization_ms": 0,
  "params": "per_page=250, page=1, fields=[id, name, state, state_updated_at, filesize, meta, upload_url]",
  "pagination": "total_entries=29, total_pages=1",
  "host": "ip-10-1-19-190.us-west-2.compute.internal",
  "request_id": "5e343a32-0d2e-4b05-adbc-973c58969ccb"
}

비교 요청 (Request C, 245ms — 서버 부하 감소 후):

json
{
  "timestamp": "2026-05-27T09:38:51.427Z",
  "duration_ms": 245.56,
  "db_runtime_ms": 47.34,
  "serialization_ms": 196,
  "params": "per_page=100, page=1, fields=[id, key, name, state, download_url, captured_at, longitude, latitude, altitude]"
}

동시 업로드 활동 (09:14:52 ~ 09:16:36, 100+ 동시 요청):

text
POST /api/v1/aerial_maps/451/aerial_photos (create)
POST /api/v1/aerial_photos/{id}/upload_url (presigned URL 요청)
PUT /api/v1/aerial_photos/{id}/check_uploading (상태 확인)
PUT /api/v1/aerial_photos/{id} (update)

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 S3 presigned URL 동기 생성이 29개 레코드에 대해 순차 실행되며 지연 발생 DB=10ms, serialization=0ms이지만 총 1075ms. upload_url 필드 요청됨. ~1065ms unaccounted time이 presigned_url 호출 수와 일치 (29×~36ms) Confirmed
H2 N+1 쿼리 또는 DB 느린 쿼리로 인한 지연 DB runtime = 10ms로 매우 빠름. Slow query 로그 없음. Elasticsearch + permission join 모두 정상 범위 Rejected
H3 동시 업로드 부하로 인한 connection pool 경합 같은 시간 100+ 동시 요청 존재. Request B(1138ms)는 다른 호스트에서도 유사한 지연 발생 Request C(245ms)는 동일 download_url이지만 부하 감소 후 정상. serialization_ms=196ms로 URL 생성이 serialization 내에서 처리됨 Contributing factor
H4 Permission JOIN의 복잡성으로 인한 SQL 지연 Repository에 11+ LEFT JOIN 존재 DB runtime = 10ms로 영향 없음 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/serializers/aerial_photo_serializer.rb:29-34upload_url 속성의 presigned URL 생성을 lazy하게 변경하거나, collection 응답에서는 upload_url/download_url 필드를 제외하는 것을 검토
  • 또는 presigned URL을 batch로 생성하여 S3 API 호출 횟수를 줄이는 방안 고려 (S3 presigned URL은 로컬 서명이므로 실제로는 네트워크 호출이 아님 — AWS SDK의 credential refresh가 병목일 가능성)

단기 개선 (1주 이내)#

  • AWS SDK credential caching 확인: presigned URL 생성 시 매번 credential을 새로 가져오는지 확인. EC2 instance metadata를 통한 credential refresh가 순차적으로 발생하면 29회 호출에서 병목 가능
  • resource association에 대한 eager loading 추가: default_joins에서 :storage만 include하고 있으나, resource 접근 시 추가 쿼리가 발생할 수 있음 (app/repositories/aerial_photo_repository.rb:29-31)
  • 동시 업로드 워크플로우 시 index 요청의 fields에서 URL 필드를 자동 제외하는 로직 추가 검토

장기 개선 (재발 방지)#

  • Presigned URL을 서버 사이드에서 생성하지 않고, 클라이언트가 별도 API 호출로 필요 시에만 가져오는 패턴으로 변경
  • Collection 엔드포인트에서 URL 생성 필드의 응답 시간 metric을 추가하여 지연 발생 시 조기 감지

Monitoring#

  • 추가할 메트릭: AerialPhotosController#index 응답 시간에서 DB/view를 제외한 "unaccounted time" 추적
  • Datadog APM에서 해당 리소스의 p95 latency 알림 설정:
text
service:cupixworks-api resource_name:"Api::V1::AerialPhotosController#index" @duration:>500ms
  • S3 presigned URL 생성 시간을 custom span으로 계측하여 bottleneck 식별

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard