Api::V1::PointcloudsController#update (avg 1200ms, max 1301ms)
RCA: Api::V1::PointcloudsController#update Latency (avg 1200ms)
Overview#
What Happened#
2026-05-27 11:35경 cupixworks-api의 Api::V1::PointcloudsController#update 엔드포인트에서 평균 1200ms, 최대 1301ms의 응답 지연이 3건 발생했다. 모두 us-west-2 리전에서 동일 시간대에 발생한 burst 패턴이며, cupix-agent에 의한 대량 pointcloud 업데이트 요청이 원인이다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::PointcloudsController#update |
| avg_duration | 1200ms |
| max_duration | 1301ms |
| env | production, us-west-2 |
| cluster_type | latency |
Timeline#
- 2026-05-27T11:35:06Z — 첫 번째 고지연 요청 감지 (1295ms, pointcloud 1109767)
- 2026-05-27T11:35:12Z — 두 번째 고지연 요청 (1247ms, pointcloud 1109761)
- 2026-05-27T11:35:20Z — 세 번째 고지연 요청 (1043ms, pointcloud 1108634)
- 2026-05-27T11:35:06~22Z — ~30건의 동시 update 요청이 burst 형태로 발생 (cupix-agent)
Error Log#
{
"resource_name": "Api::V1::PointcloudsController#update",
"service": "cupixworks-api",
"occurrences": 3,
"avg_ms": 1200,
"max_ms": 1301,
"sample_trace_id": "1000527898911535839"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 3
- 최초 발생: 2026-05-27T11:35:06.996Z
- 최근 발생: 2026-05-27T11:35:20.982Z
Root Cause Summary#
Latency의 92~99%가 non-DB 처리 시간이다. 핵심 원인은 두 가지 synchronous callback이 요청 라이프사이클 내에서 실행되는 것이다: (1) MultiLevel::Pointcloud#update_sub_levels가 group pointcloud의 모든 children을 순차적으로 save!하며, (2) 각 save마다 EntityUpdates::Child#reset_parent_cached_entity_updates가 ObjectSpace.each_object로 parent class를 탐색하고 cache를 invalidate한다. 11:35경 cupix-agent가 ~30건의 update 요청을 동시에 보내면서 동일 Facility에 대한 cache invalidation이 중복 실행되어 지연이 증폭되었다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/pointclouds_controller.rb:34 - Repository update:
app/repositories/pointcloud_repository.rb:19 - Model save:
app/repositories/pointcloud_repository.rb:25 - Callback trigger (after_save):
app/models/concerns/multi_level/pointcloud.rb:17 - Synchronous children iteration:
app/models/concerns/multi_level/pointcloud.rb:33-42 - Cache invalidation (after_update on each child):
app/models/concerns/entity_updates/child.rb:8-9 - ObjectSpace scan for parent classes:
app/models/concerns/entity_updates/child.rb:13-18
1. Controller → Repository → save!
def update
@model = repository_instance.update(params)
super
end
def update(params = {})
super
set_parameters(params)
begin
@model.save!
rescue StandardError => e
raise Cupix::Errors::Parameter.new(code: 'ARG10001', reason: 'Invalid argument', message: e.message)
end
@model
end
2. after_save callback — 동기적 children 업데이트 (주요 병목)
Group pointcloud를 업데이트하면 update_associated_levels가 모든 children을 순회하며 각각 save!를 호출한다. 이 과정이 request lifecycle 내에서 동기적으로 실행된다.
after_save :update_associated_levels
def update_associated_levels
return if is_updating_levels
self.is_updating_levels = true
if pointcloud_group?
update_sub_levels
end
self.is_updating_levels = false
end
def update_sub_levels
subs = self.children
return if subs.nil?
subs.each do |sub|
sub.level = self.level
sub.levels = self.levels
sub.is_updating_levels = true
sub.save! # 각 child마다 DB write + 모든 after_update callback 실행
end
end
3. after_update callback — 반복적 cache invalidation
각 child의 save!가 after_update :reset_parent_cached_entity_updates를 트리거한다. 이 메서드는 ObjectSpace.each_object(Class)로 모든 parent class를 동적 탐색한 뒤 cache key를 삭제한다.
after_create :reset_parent_cached_entity_updates
after_update :reset_parent_cached_entity_updates
def reset_parent_cached_entity_updates
self.class.parent_classes.each do |class_name|
Cupix::Logger.info("reset #{class_name} (ID: #{self.send("#{class_name.underscore}_id")}) cached entity updates", class: self.class.name, function: __method__, module: 'EntityUpdates::Child', child: { class: self.class.name, id: id })
Rails.cache.delete(entity_updates_cache_key(class_name, self.send("#{class_name.underscore}_id")))
end
end
parent_classes 메서드가 ObjectSpace.each_object(Class)를 호출하여 Ruby heap의 모든 Class 객체를 순회한다:
def parent_classes
Cupix::Loader.load
ObjectSpace.each_object(Class).select do |model|
model.superclass == ApplicationRecord && !model.name.include?('::') && !model.name.ends_with?('Permission') && model.respond_to?(:entities) && model.entities.include?(self.name.underscore.to_sym)
end.map(&:name)
end
4. Burst 패턴에 의한 증폭
11:35:0622Z 사이에 cupix-agent가 순차 pointcloud ID(11097411109770)에 대해 ~30건의 동시 update 요청을 발생시켰다. 동일 Facility(ID: 10732)에 속한 pointcloud들이므로 동일 cache key에 대한 invalidation이 중복 실행되며 Redis contention이 발생했다.
Log Evidence#
Datadog APM trace 분석에서 확인된 duration 분포:
Datadog query: service:cupixworks-api resource_name:"Api::V1::PointcloudsController#update" env:production @duration:>500ms
Time window: 2026-05-27T10:35:00Z ~ 2026-05-27T12:00:00Z
고지연 요청의 DB time vs non-DB time 비율:
| Timestamp | Duration | DB Time | Non-DB Time | Non-DB % | Pointcloud ID |
|--------------|----------|----------|-------------|----------|---------------|
| 11:35:08Z | 1295ms | 582.79ms | 712ms | 55.0% | 1109767 |
| 11:35:12Z | 1247ms | 73.53ms | 1174ms | 94.1% | 1109761 |
| 11:35:22Z | 1043ms | 613.65ms | 430ms | 41.2% | 1108634 |
reset_parent_cached_entity_updates 호출 로그 (동일 Facility에 대해 반복):
Datadog query: service:cupixworks-api "reset_parent_cached_entity_updates" "Facility"
{
"message": "reset Facility (ID: 10732) cached entity updates",
"class": "Pointcloud",
"function": "reset_parent_cached_entity_updates",
"module": "EntityUpdates::Child"
}
이 로그가 단일 요청 내에서 children 수만큼 반복 출력된다 (10+ 회 확인).
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | MultiLevel::Pointcloud#update_sub_levels의 동기적 children save가 주요 병목 |
non-DB time이 92-99%를 차지; 로그에서 동일 Facility cache reset 10+ 회 반복 확인; multi_level/pointcloud.rb:37-41에서 children.each { save! } |
— | Confirmed |
| H2 | N+1 DB 쿼리 (permission_joins 등)가 원인 | pointcloud 1109767의 DB time 582ms, 1108634의 DB time 613ms | 대부분 요청에서 DB time은 10-73ms로 전체 duration의 6-8%에 불과 | Partially Contributing |
| H3 | 외부 서비스 호출 (S3, Elasticsearch)이 병목 | _update_document "NotFound" 로그 확인; EventService.publish_event 호출 확인 |
이벤트 발행 자체는 비동기; document update는 check_uploading 액션에서만 주로 발생 | Rejected |
| H4 | 동시 요청 burst에 의한 resource contention | 11:35:06~22Z에 ~30건 동시 요청; 동일 Facility cache key 경합 | burst 없는 시간대에도 P95 = 1247ms | Contributing Factor |
Fix Recommendation#
즉시 조치 (Critical)#
app/models/concerns/multi_level/pointcloud.rb:33-42—update_sub_levels를 비동기 worker로 이동. 현재 동기적으로 모든 children을 순회하며save!하는 로직을 Sidekiq worker에서 처리하도록 변경한다. 이렇게 하면 response time에서 children 수 × save overhead가 제거된다.
단기 개선 (1주 이내)#
app/models/concerns/entity_updates/child.rb:13-18—parent_classes메서드의ObjectSpace.each_object(Class)호출을 memoize하거나 class-level constant로 캐시한다. 매 callback 호출마다 Ruby heap 전체를 순회하는 것은 불필요하다.- Bulk update 시 동일 Facility에 대한 중복 cache invalidation을 debounce하는 로직 추가를 검토한다 (예: request-scoped set으로 이미 invalidate된 key 추적).
장기 개선 (재발 방지)#
- cupix-agent의 대량 pointcloud 업데이트를 batch API로 전환하여 개별 HTTP 요청 대신 단일 bulk update endpoint를 사용하도록 한다.
counter_culturecallback(12개)의execute_after_commit: true설정 검증 — 일부가 synchronous하게 실행되지 않는지 확인.
Monitoring#
PointcloudsController#updateP95 duration 모니터:
avg(trace.rack.request.duration){service:cupixworks-api, resource_name:api::v1::pointcloudscontroller#update} by {env} > 1000
reset_parent_cached_entity_updates호출 빈도 모니터 (동일 request_id 내 10회 이상 발생 시 알림):
service:cupixworks-api "reset_parent_cached_entity_updates" | pattern
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard —
update_sub_levels의 비동기화는 기존 Sidekiq 인프라를 활용하면 되나, children 업데이트의 순서 보장과 에러 핸들링을 고려해야 한다.