Api::V1::AerialPhotosController#upload_url (avg 4765ms, max 4765ms)
RCA: AerialPhotosController#upload_url Latency Spike (4765ms)
Overview#
What Happened#
2026-06-06 11:32 KST에 Api::V1::AerialPhotosController#upload_url 엔드포인트에서 단일 요청이 4765ms의 응답 시간을 기록했다. 이 엔드포인트의 평균 응답 시간은 400-700ms이므로 약 10배 높은 지연이 발생한 것이다. aerial_map 471에 대한 대량 업로드 작업 중 동시 요청이 집중된 시점에서 발생했다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::AerialPhotosController#upload_url |
| top_frame | app/repositories/aerial_photo_repository.rb:219 |
| env | production, us-west-2 |
| avg_duration | 4765ms (정상 평균: 400-700ms) |
| trace_id | 100339233233803294 |
Timeline#
- 2026-06-06 11:32 KST —
upload_url요청 4765ms 응답 시간 기록 (trace 100339233233803294) - 2026-06-06 11:34-11:37 KST — aerial_map 471에 대한 대량
create+upload_url+check_uploading요청 연속 관찰 - 2026-06-06 12:33 KST — 이후 동일 엔드포인트 응답 시간 정상 범위 (400-700ms) 확인
Error Log#
{
"resource_name": "Api::V1::AerialPhotosController#upload_url",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 4765,
"max_ms": 4765,
"sample_trace_id": "100339233233803294"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-06-06 11:32 KST
- 최근 발생: 2026-06-06 11:32 KST
Root Cause Summary#
upload_url 요청의 4765ms 지연은 세 가지 요인이 복합적으로 작용한 결과로 판단된다: (1) before_action :set_aerial_photo에서 실행되는 복잡한 permission JOIN 쿼리 (11개 LEFT JOIN), (2) state machine 전환 시 cascading save (aerial_photo.uploading_state → aerial_map.uploading_state로 부모 레코드까지 저장), (3) serializer에서 매 요청마다 새로운 Aws::S3::Client 인스턴스를 생성하여 S3 presigned URL을 발급하는 구조. 대량 업로드 작업 중 aerial_map 471에 대한 동시 요청이 집중되면서 DB 커넥션 대기와 AWS API 초기화 비용이 누적되어 단일 요청에서 비정상적 지연이 발생한 것이다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/aerial_photos_controller.rb:40 before_action :set_aerial_photo→repository_instance.show(params[:id])→BaseRepository.show(line 306): permission_joins 실행upload_urlaction (line 40-44):repository_instance.upload_url호출AerialPhotoRepository#upload_url(line 219-224): state machine 전환- Serializer
upload_urlattribute (line 28-34): presigned URL 생성 - Failure point: 전체 요청 누적 지연 (단일 failure point 없음)
1단계: Permission 쿼리 (before_action)
permission_joins(default_joins(current_class), current_user).where(attrs)
_select = "aerial_photos.*,
MAX(facility_user_permissions.permission) AS facility_user_permission,
MAX(facility_group_permissions.permission) AS facility_group_permission,
MAX(facility_system_group_permissions.permission) AS facility_system_group_permission,
MAX(workspace_user_permissions.permission) AS workspace_user_permission,
MAX(workspace_group_permissions.permission) AS workspace_group_permission,
MAX(team_user_permissions.permission) AS team_user_permission,
MAX(team_group_permissions.permission) AS team_group_permission,
MAX(team_system_group_permissions.permission) AS team_system_group_permission,
MAX(review_user_permissions.permission) AS review_user_permission,
MAX(review_group_permissions.permission) AS review_group_permission,
MAX(review_public_permissions.permission) AS review_public_permission,
MAX(GREATEST(
IFNULL(facility_user_permissions.permission, 0),
...
)) AS applied_permission"
기대: ID 기반 단건 조회로 빠르게 완료 (< 50ms). 실제: 11개 LEFT JOIN으로 인해 동시 요청 시 DB lock contention 발생 가능.
2단계: State Machine 전환 + Cascading Save
def upload_url(option = nil)
return if %i[uploading].include?(@model.state_name)
@model.uploading_state
@model
end
after_transition any => :uploading do |aerial_photo, transition|
aerial_photo.aerial_map.uploading_state
end
기대: aerial_photo 하나만 state 전환. 실제: 부모 aerial_map까지 cascading state 전환 → 두 번의 DB save 발생. 대량 업로드 시 동일 aerial_map에 대해 여러 aerial_photo가 동시에 uploading_state를 호출하면 row-level lock 경합 발생.
3단계: S3 Presigned URL 생성 (Serializer)
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
def presigned_upload_url(revision, force: false)
revision ||= self.revision + 1
# ...
client = Cupix::StorageService.client(storage_option: storage_option)
signer = Aws::S3::Presigner.new(client: client)
# ...
signer.presigned_url(
:put_object,
bucket: bucket_name,
key: object(revision).key,
storage_class: 'ONEZONE_IA',
expires_in: expires_in,
acl: 'bucket-owner-full-control'
)
end
def client(storage_option: nil, **kwargs)
opts = parse_storage_option(storage_option).merge(kwargs)
opts[:force_path_style] = true
check_required_params(opts, %i[region])
Aws::S3::Client.new(opts)
end
기대: 캐시된 S3 client 재사용. 실제: 매 요청마다 새 Aws::S3::Client 인스턴스 생성 → credential resolution, TCP 연결 설정 비용 포함.
Log Evidence#
Datadog에서 사용한 쿼리:
service:cupixworks-api "AerialPhotosController" "upload_url"
Time: 2026-06-06T02:30:00Z to 2026-06-06T02:35:00Z
인시던트 시점에 aerial_map 471에 대한 대량 업로드 작업 진행 확인:
[200] POST /api/v1/aerial_maps/471/aerial_photos (Api::V1::AerialPhotosController#create)
[200] POST /api/v1/aerial_maps/471/aerial_photos/158010/upload_url (Api::V1::AerialPhotosController#upload_url)
[200] PUT /api/v1/aerial_maps/471/aerial_photos/158010/check_uploading (Api::V1::AerialPhotosController#check_uploading)
[200] POST /api/v1/aerial_maps/471/aerial_photos/158008/upload_url (Api::V1::AerialPhotosController#upload_url)
[200] POST /api/v1/aerial_maps/471/aerial_photos/158009/upload_url (Api::V1::AerialPhotosController#upload_url)
동일 시간대에 create, upload_url, check_uploading이 연속 호출되어 DB 및 AWS 리소스에 동시 부하 발생.
Datadog 메트릭 쿼리:
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::aerialphotoscontroller_upload_url}
Time: 24h
결과: 정상 구간 평균 0.4-0.7초. 인시던트 시점(02:32:45Z)은 메트릭 데이터 gap 내에 위치 (대부분의 요청은 bulk 작업 중에만 발생). max 메트릭에서도 0.73초를 넘지 않아, 4765ms는 명확한 outlier.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | DB lock contention — aerial_map 471에 대한 동시 state 전환으로 row lock 경합 | 동일 시점에 다수의 upload_url/create/check_uploading 요청이 같은 aerial_map을 대상으로 실행됨. after_transition 콜백이 부모 aerial_map의 state를 변경하므로 동일 row에 대한 concurrent UPDATE 발생. | Datadog에서 명시적 "lock wait" 로그 미발견 (info 레벨 로깅 한계) | Confirmed |
| H2 | AWS S3 presigned URL 생성 지연 — 새 Client 인스턴스 초기화 비용 | StorageService.client가 매번 Aws::S3::Client.new 호출 (캐싱 없음). credential resolution + TCP handshake 포함. 동시 다수 요청 시 누적. |
단독으로 4.7초를 설명하기에는 부족 (보통 100-300ms 수준) | Contributing |
| H3 | DB 연결 풀 고갈 — 동시 요청이 connection pool을 초과 | 대량 업로드 중 aerial_map 471에 10+ 동시 요청 관찰. 각 요청이 permission_joins(11 LEFT JOIN) + 2회 save를 실행. | connection pool exhaustion 관련 로그 미발견 | Inconclusive |
| H4 | 네트워크 지연 또는 인프라 이슈 | 인시던트 시점이 메트릭 데이터 gap과 일치 (해당 시간대에 거의 트래픽 없음 → cold start 가능성) | slow/timeout/connection 키워드 로그 미발견 | Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
app/repositories/aerial_photo_repository.rb:220—upload_url메서드에서 이미uploading상태인 경우 early return 처리가 있으나, 실제 state 전환 시 부모 aerial_map에 대한 lock 경합을 줄이기 위해with_lock또는 optimistic locking 적용을 검토.
단기 개선 (1주 이내)#
app/services/cupix/storage_service.rb:5-12—Aws::S3::Client인스턴스를 region별로 캐싱하여 매 요청마다 새 인스턴스 생성을 방지. thread-safe한 client pool 도입.app/models/concerns/statable/aerial_photo.rb:31-33—after_transition콜백에서aerial_map.uploading_state호출 시, 이미 uploading 상태이면 skip하는 guard 추가로 불필요한 DB save 감소.
장기 개선 (재발 방지)#
upload_url엔드포인트의 permission 쿼리를 경량화하거나, upload 전용 인증 경로를 분리하여 11개 LEFT JOIN 없이 권한 확인 가능하도록 개선.- 대량 업로드 시나리오에서 batch presigned URL 발급 API를 제공하여 N개 개별 요청 대신 단일 요청으로 처리.
Monitoring#
upload_url엔드포인트에 p95/p99 latency 알림 추가:
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::aerialphotoscontroller_upload_url} > 2
- aerial_map 단위 동시 요청 수 모니터링으로 bulk upload 시 경합 조기 감지.
Risk Assessment#
- Risk level: low
- 예상 복잡도: standard