ES /docs

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#

  1. 2026-06-06 11:32 KSTupload_url 요청 4765ms 응답 시간 기록 (trace 100339233233803294)
  2. 2026-06-06 11:34-11:37 KST — aerial_map 471에 대한 대량 create + upload_url + check_uploading 요청 연속 관찰
  3. 2026-06-06 12:33 KST — 이후 동일 엔드포인트 응답 시간 정상 범위 (400-700ms) 확인

Error Log#

Datadog Logs

json
{
  "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_stateaerial_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_photorepository_instance.show(params[:id])BaseRepository.show (line 306): permission_joins 실행
  • upload_url action (line 40-44): repository_instance.upload_url 호출
  • AerialPhotoRepository#upload_url (line 219-224): state machine 전환
  • Serializer upload_url attribute (line 28-34): presigned URL 생성
  • Failure point: 전체 요청 누적 지연 (단일 failure point 없음)

1단계: Permission 쿼리 (before_action)

app/repositories/base_repository.rb:335-337ruby
permission_joins(default_joins(current_class), current_user).where(attrs)
app/repositories/aerial_photo_repository.rb:37-61ruby
_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

app/repositories/aerial_photo_repository.rb:219-224ruby
def upload_url(option = nil)
  return if %i[uploading].include?(@model.state_name)

  @model.uploading_state
  @model
end
app/models/concerns/statable/aerial_photo.rb:31-33ruby
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)

app/serializers/aerial_photo_serializer.rb:28-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
app/models/concerns/storagable/resource.rb:111-131ruby
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
app/services/cupix/storage_service.rb:5-12ruby
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에서 사용한 쿼리:

text
service:cupixworks-api "AerialPhotosController" "upload_url"
Time: 2026-06-06T02:30:00Z to 2026-06-06T02:35:00Z

인시던트 시점에 aerial_map 471에 대한 대량 업로드 작업 진행 확인:

text
[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 메트릭 쿼리:

text
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:220upload_url 메서드에서 이미 uploading 상태인 경우 early return 처리가 있으나, 실제 state 전환 시 부모 aerial_map에 대한 lock 경합을 줄이기 위해 with_lock 또는 optimistic locking 적용을 검토.

단기 개선 (1주 이내)#

  • app/services/cupix/storage_service.rb:5-12Aws::S3::Client 인스턴스를 region별로 캐싱하여 매 요청마다 새 인스턴스 생성을 방지. thread-safe한 client pool 도입.
  • app/models/concerns/statable/aerial_photo.rb:31-33after_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 알림 추가:
text
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