ES /docs

Api::V1::AerialPhotosController#update (avg 1049ms, max 1049ms)

RCA: Api::V1::AerialPhotosController#update Latency (1049ms)

Overview#

What Happened#

2026-05-27 17:34:59 UTC에 cupixworks-api 서비스의 Api::V1::AerialPhotosController#update 엔드포인트에서 1049ms의 응답 지연이 발생했다. 해당 요청은 aerial_map 452에 대한 약 200건 이상의 연속적인 aerial photo 업데이트 배치 작업 중 하나였으며, HTTP 200으로 정상 응답했으나 latency threshold(500ms)를 초과했다.

Quick Facts#

Field Value
resource_name Api::V1::AerialPhotosController#update
top_frame app/models/concerns/searchable.rb:55 (_update_document)
env production, us-west-2
duration 1049ms (DB: 18.47ms, View: 0.1ms, App logic: ~1028ms)
HTTP status 200

Timeline#

  1. 17:31:35 UTCAerialMap 452 step function 실행 (invoke_process), preprocessing 시작
  2. 17:33:49 UTC — 대량 aerial photo update 배치 요청 시작 (~200건)
  3. 17:34:59 UTC — 해당 latency 이벤트 발생 (aerial_photo ID 147693, 1049ms)
  4. 17:35:58 UTC — 배치 완료, processing started 알림 발생

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::AerialPhotosController#update",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 1049,
  "max_ms": 1049,
  "sample_trace_id": "2031135757626936881"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-05-27T17:34:59.707Z
  • 최근 발생: 2026-05-27T17:34:59.707Z
  • 영향: BNBuilders 팀(team ID 630)의 aerial map 452 preprocessing 워크플로우에서 개별 API 응답 지연 발생. 사용자 체감 영향은 제한적 — 자동화된 배치 호출(axios/1.7.7)이며 모두 HTTP 200 성공 응답.

Root Cause Summary#

AerialPhotosController#updateafter_commit 콜백에서 Elasticsearch 문서를 동기적으로 업데이트(_update_document)하는 것이 주요 latency 원인이다. Datadog 로그에서 DB 시간은 18.47ms, View 시간은 0.1ms로 측정됐으나 총 응답 시간은 1049ms였다. 나머지 약 1028ms는 Rails after_commit 콜백 내의 Elasticsearch HTTP 요청과 S3 presigned URL 생성에 소요된 것으로 판단된다. 특히 약 200건의 aerial photo가 2분 내에 연속 업데이트되면서 Elasticsearch에 동시 부하가 집중되어 개별 ES 요청 응답 시간이 증가한 것이 직접적 원인이다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/aerial_photos_controller.rb:27update action
  • before_action: app/controllers/api/v1/aerial_photos_controller.rb:60-62set_aerial_photo (permission_joins 쿼리)
  • Repository update: app/repositories/aerial_photo_repository.rb:15-27 — 파라미터 설정 + save!
  • Failure point: app/models/concerns/searchable.rb:55-121after_commit 콜백에서 동기 ES 업데이트
  • Serialization: app/serializers/aerial_photo_serializer.rb:36-37 — S3 presigned URL 생성

1. Controller update action:

app/controllers/api/v1/aerial_photos_controller.rb:27-30ruby
def update
  @model = repository_instance.update(params)
  super  # renders response via render_api
end

2. set_aerial_photo before_action (permission_joins — 11 LEFT JOINs):

app/controllers/api/v1/aerial_photos_controller.rb:60-62ruby
def set_aerial_photo
  @model = repository_instance.show(params[:id])
end

이 메서드는 AerialPhotoRepository.permission_joins를 호출하여 11개의 LEFT JOIN을 포함하는 복합 SQL을 실행하지만, 이번 trace에서 DB 시간은 18.47ms로 병목이 아니었다.

3. Searchable _update_document (after_commit 콜백):

app/models/concerns/searchable.rb:55-106ruby
def _update_document
  Cupix::Logger.debug('begin - _update_document', class: self.class.name, function: __method__)
  return if @skip_index_document == true

  if (attributes_in_database = __elasticsearch__.instance_variable_get(:@__changed_model_attributes).presence)
    attributes = if respond_to?(:as_indexed_json)
                   # ... attribute transformation logic ...
                   __elasticsearch__.as_indexed_json.select { |k, v| column_names.include?(k.to_s) }
                 end

    unless attributes.empty?
      results = __elasticsearch__.client.update(request.merge({ index: __elasticsearch__.index_name }))
      # ↑ 동기 HTTP 요청 — 응답 대기 중 request cycle 블로킹
    end
  end
end

이 콜백은 after_commit에서 실행되지만, Rails에서 after_commit 콜백은 HTTP 응답 전송 전에 실행된다. 따라서 ES 서버의 응답 시간이 전체 API 응답 시간에 직접 합산된다.

4. S3 presigned URL 생성 (serialization 단계):

app/models/concerns/aerialable/aerial_photo.rb:9-21ruby
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, ...)
  else
    self.resource.object(ver).presigned_url(:get, expires_in: 1.days.to_i)
  end
end

Serializer에서 download_urlthumbnail 속성에 대해 각각 S3 presigned URL을 생성한다. 그러나 이번 요청에서 fields: [id, key]만 요청했으므로 이 속성들은 직렬화되지 않았을 가능성이 높다.

5. Session touch (조건부 추가 DB write + ES index):

app/controllers/api/v1/api_controller.rb:49-63ruby
def session
  return nil if @session.nil?

  if @session.user_updated_at.nil? ||
     @session.team_updated_at.nil? ||
     @session.user_updated_at.to_i < @current_user.try(:updated_at).to_i ||
     @session.team_updated_at.to_i < @current_team.try(:updated_at).to_i ||
     @session.created_at > 2.second.ago ||
     @session.show_option == true

    @session.touch_updated_at
    SessionSerializer.new(@session).serializable_hash[:data][:attributes]
  end
end

touch_updated_at는 Session 모델을 save하며, Session도 Searchable을 include하면 추가 ES 인덱싱이 발생할 수 있다.

Log Evidence#

Datadog에서 확인한 trace 정보:

text
service:cupixworks-api resource_name:"Api::V1::AerialPhotosController#update" env:production @duration:>500ms
json
{
  "timestamp": "2026-05-27T17:35:02.024Z",
  "status": "info",
  "message": "[200] PUT /api/v1/aerial_maps/452/aerial_photos/147693 (Api::V1::AerialPhotosController#update)",
  "duration_ms": 1046.72,
  "db_ms": 18.47,
  "view_ms": 0.1,
  "serialization_ms": 0,
  "controller": "Api::V1::AerialPhotosController",
  "action": "update",
  "params": { "id": "147693", "key": "452", "fields": ["id", "key"] },
  "user": "nick.gass@bnbuilders.com",
  "team": "BNBuilders (630)",
  "user_agent": "axios/1.7.7",
  "request_id": "583050d8-d00e-4906-8b4c-d78a5367d36e"
}

배치 컨텍스트 — 동일 시간대 관련 로그:

text
service:cupixworks-api "AerialMap 452"
text
2026-05-27T17:31:35Z — "Aerial map step function executed on AerialMap 452" (invoke_process)
2026-05-27T17:31:35Z — Preprocessing started notification (AerialMap 452)
2026-05-27T17:33:49Z — Batch PUT requests start (~200 aerial_photos)
2026-05-27T17:35:58Z — Processing started notification (AerialMap 452)

핵심 관찰: DB 18.47ms + View 0.1ms = 18.57ms vs Total 1046.72ms. 차이 ~1028ms는 Rails 미들웨어/콜백/외부 I/O에서 소요. after_commit의 ES 동기 호출이 유일한 외부 네트워크 I/O (fields: [id, key]이므로 S3 URL 생성은 스킵됨).

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 after_commit의 동기 Elasticsearch _update_document 호출이 batch 부하 상황에서 ES 응답 지연을 유발 DB 18.47ms vs Total 1049ms 차이(~1028ms), searchable.rb:94의 동기 HTTP 요청, 200건+ 동시 업데이트 배치 ES 응답 시간을 직접 측정하는 로그 없음 Confirmed
H2 permission_joins의 11 LEFT JOIN 쿼리가 느린 SQL 실행을 유발 aerial_photo_repository.rb:33-203의 복잡한 조인 쿼리 DB 시간 18.47ms로 매우 빠름 — SQL이 병목 아님 Rejected
H3 S3 presigned URL 생성(download_url, thumbnail)이 latency 추가 aerial_photo_serializer.rb:36-37에서 매 요청마다 presigned URL 생성 요청 params에 fields: [id, key]만 있어 download_url/thumbnail 직렬화 스킵됨, serialization_ms = 0 Rejected
H4 session.touch_updated_at의 추가 DB write + ES indexing api_controller.rb:59에서 조건부 session save 발생, Session도 Searchable이면 추가 ES 호출 session의 DB write는 DB time에 포함될 것이나 18.47ms에 합산됨. 그러나 after_commit 내 ES 호출은 별도 Inconclusive

Fix Recommendation#

즉시 조치 (Critical)#

  • app/models/concerns/searchable.rb:16-18after_commit on: [:update] 콜백을 비동기로 전환
  • 현재 _update_document는 ES HTTP 요청을 request cycle 내에서 동기 실행하므로, 이를 Sidekiq worker로 위임하면 API 응답 시간에서 ES latency를 제거할 수 있다
  • 변경 대상: app/models/concerns/searchable.rb:16-18

단기 개선 (1주 이내)#

  • 배치 업데이트 시나리오에서 ES 인덱싱을 bulk 방식으로 통합 — 200건의 개별 ES update 대신 batch가 완료된 후 BulkIndexWorker로 한 번에 처리
  • AerialPhotosController#update에서 batch 패턴(동일 aerial_map의 다수 photo 연속 업데이트) 감지 시 skip_index_document!를 활용하고, 배치 완료 후 bulk reindex 실행

장기 개선 (재발 방지)#

  • Searchable 모듈의 after_commit ES 인덱싱을 전체적으로 비동기 이벤트 기반 아키텍처로 전환 (예: ActiveJob + dedicated ES indexing queue)
  • Aerial photo preprocessing 워크플로우에서 step function이 대량 API 호출 대신 내부 batch update + 단일 bulk index를 사용하도록 리팩터링

Monitoring#

  • Elasticsearch _update_document 호출 시간을 측정하는 custom metric 추가
  • 배치 업데이트 감지를 위한 Datadog 쿼리:
text
service:cupixworks-api resource_name:"Api::V1::AerialPhotosController#update" @duration:>500ms
  • ES 응답 시간 모니터링:
text
service:cupixworks-api "TimeoutError" OR "ElasticsearchError" class:AerialPhoto function:_update_document

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard
  • 이 latency는 단발성 이벤트(1건)이며, 배치 업데이트라는 특수 상황에서 발생했다. 사용자 체감 영향은 없으나(자동화 클라이언트), 대규모 aerial map 업로드 시 반복 가능성이 있다.