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#
- 09:14:52Z — 사용자(Kristoffer Lundkvist, nestle team)가 aerial_map 451에 29개 aerial_photo 업로드 시작
- 09:15:20Z —
GET /api/v1/aerial_maps/451/aerial_photos요청 (fields에 upload_url 포함, per_page=250) - 09:15:21Z — 응답 완료 (1075ms 소요, DB=10ms, 나머지 ~1065ms는 presigned URL 생성)
- 09:16:06Z — 동일 엔드포인트 재요청 (1138ms, fields에 download_url 포함)
- 09:38:51Z — 서버 부하 감소 후 동일 요청 245ms로 정상 응답
Error Log#
{
"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한다:
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을 생성한다:
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_url은 Resourcable concern을 통해 S3 presigned upload URL을 생성한다:
def resource_upload_url
self.upload_url
end
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을 생성하는 코드:
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을 동기 생성한다:
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에서 동일 시간대 요청을 검색한 쿼리:
service:cupixworks-api @http.url_details.path:"/api/v1/aerial_maps/451/aerial_photos" @http.method:GET
핵심 로그 (Request A, 1075ms):
{
"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 — 서버 부하 감소 후):
{
"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+ 동시 요청):
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-34—upload_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회 호출에서 병목 가능
resourceassociation에 대한 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 알림 설정:
service:cupixworks-api resource_name:"Api::V1::AerialPhotosController#index" @duration:>500ms
- S3 presigned URL 생성 시간을 custom span으로 계측하여 bottleneck 식별
Risk Assessment#
- Risk level: low
- 예상 복잡도: standard