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#
- 2026-07-30 08:00:02 KST — status board 가
svc:cupixworks-api::unknownincident open (started_at 기록). aerial_map 560 페이지 1건(request duration ~33s) 감지. - 2026-07-30 08:00:45 KST — 첫 aerial_photos index request 200 OK (access log).
- 2026-07-30 08:01:40 ~ 08:03:41 KST — 동일 aerial_map(
key=560) 대상으로 페이지 14~16 연속 조회, 각 페이지 response200. - 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. - 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#
{
"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_seen은first_seen보다 이른 값으로 기록되어 있음 — 원문 그대로 표기)
Root Cause Summary#
AerialPhotosController#index 는 페이지당 100건의 AerialPhoto 를 조회하고 각 항목을 AerialPhotoSerializer 로 직렬화하는데, 이 serializer 의 download_url / upload_url / thumbnail 속성은 레코드마다 S3 presigner 호출을 유발한다(AerialPhoto#image_source 는 self.resource.object(ver).presigned_url(:get, expires_in: …) 를 호출, Resource#upload_url 은 Aws::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 가 실행된다.
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 수준이라 병목이 아니다.
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 를 계산한다.
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 생성이 발생한다.
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_url 은 created / uploading / missing state 일 때 presigned_upload_url 을 호출하며, 매 호출마다 Aws::S3::Presigner.new(client: client) 인스턴스를 생성한다.
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 쿼리:
service:cupixworks-api "AerialPhotosController#index"
2026-07-29T22:59:00Z ~ 2026-07-29T23:05:00Z
동일 시간 창에 aerial_map 560 대상 요청이 페이지 14→15→16 순으로 반복 관측되었다:
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:
{
"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 의
fields에upload_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_source 및 Resource#upload_url 는 각각 Aws::S3::Presigner.new 및 presigned_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#search 에 Elasticsearch::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 생성 병렬화 또는 지연 로딩:
AerialPhotoSerializer의upload_url/download_url은 fields 파라미터가 명시적으로 요구할 때만 계산되도록 유지되어 있으나, 페이지당 100건×2 URL fan-out 자체가 병목이다. 다음 중 하나의 방향을 검토: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)를 생성한다.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:
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 생성 후 사용):
avg:tesla.request.serialization_duration{service:cupixworks-api,resource_name:api::v1::aerialphotoscontroller#index,env:production}
Slow request count (>10s) rate:
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 로 종료되기 어려움.