ES /docs

Api::V1::FloorplansController#index (avg 1122ms, max 1122ms)

RCA: Api::V1::FloorplansController#index Latency (1122ms)

Overview#

What Happened#

2026-05-26 05:15 UTC에 us-west-2 리전의 cupixworks-api 서비스에서 FloorplansController#index 요청이 1122ms 소요되었다. DB 시간은 5.78ms에 불과했으나, 동일 호스트에서 다수의 heavy 요청이 동시에 처리되면서 Rails 프로세스가 리소스 경합으로 대기한 것이 주요 원인이다.

Quick Facts#

Field Value
resource_name Api::V1::FloorplansController#index
top_frame app/repositories/floorplan_repository.rb:307
env production, us-west-2
duration 1122ms (DB: 5.78ms, Serialization: 17ms)
host ip-10-1-19-190.us-west-2.compute.internal

Timeline#

  1. 2026-05-26T05:15:11Z — FloorplansController#index 요청 수신 (user: itakura.sora.z@takenaka.co.jp, team: umedafm)
  2. 2026-05-26T05:15:13Z — 응답 완료 (200 OK, 1120.68ms 소요)
  3. 2026-05-26T05:15~05:20Z — 동일 호스트에서 ElementTracesController#bulk (2100-2377ms), PanosController#create_tile_upload_credentials (1093-1356ms) 동시 처리 확인

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::FloorplansController#index",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 1122,
  "max_ms": 1122,
  "sample_trace_id": "2612330851721358227"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-05-26T05:15:11.231Z
  • 최근 발생: 2026-05-26T05:15:11.231Z

Root Cause Summary#

FloorplansController#index 요청의 1122ms 지연은 두 가지 요인의 복합 작용이다. 첫째, FloorplanRepository.permission_joins()가 11개 이상의 LEFT JOIN과 GROUP BY를 사용하는 복잡한 SQL을 실행하여 accessible floorplan ID를 조회한다 (line 307). 둘째, 해당 시점 동일 호스트(ip-10-1-19-190)에서 ElementTracesController#bulk (2100-2377ms), PanosController#create_tile_upload_credentials (1093-1356ms) 등 heavy 요청이 동시 처리되어 Rails 프로세스 리소스 경합이 발생했다. DB 시간 5.78ms, serialization 17ms로 실제 처리 시간은 미미하나, ~1097ms가 대기 시간으로 소모되었다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/floorplans_controller.rb:17
  • Permission query: app/repositories/floorplan_repository.rb:307
  • Permission joins definition: app/repositories/floorplan_repository.rb:40-237
  • Base search (2nd permission_joins): app/repositories/base_repository.rb:75-81
  • Elasticsearch query: app/repositories/floorplan_repository.rb:356-363

1. Controller index action:

app/controllers/api/v1/floorplans_controller.rb:17-26ruby
def index
  floorplan_query_option = Cupix::QueryOption::Floorplan.new(get_query_option, params)
  floorplans = repository_instance.search(floorplan_query_option)

  render_api Renderable.new({
    search_result: floorplans,
    is_collection: true,
    serializer_option: @serializer_option
  })
end

2. _search 메서드에서 permission_joins 호출 (1차):

accessible floorplan ID를 가져오기 위해 permission_joins를 실행한 후 .pluck(:id)로 ID 목록을 추출한다.

app/repositories/floorplan_repository.rb:306-312ruby
elsif self.current_user.present?
  accessible_floorplans = FloorplanRepository.permission_joins(::Floorplan, self.current_user, select: 'floorplans.id', review_id: review_id).where(level_id: level_ids)
  self.query_option.query[:bool][:should] << {
    terms: {
      id: accessible_floorplans.pluck(:id)
    }
  }
end

3. permission_joins — 11+ LEFT JOIN 쿼리:

app/repositories/floorplan_repository.rb:40-69ruby
def self.permission_joins(record, current_user, select: nil, review_id: -1, **kwargs)
  if select.present?
    _select = ApplicationRecord.sanitize_sql(select)
  else
    _select = "floorplans.*,
    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(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(GREATEST(
      IFNULL(facility_user_permissions.permission, 0),
      IFNULL(facility_group_permissions.permission, 0),
      ...
    )) AS applied_permission"
  end

이 쿼리는 review_permissions, level_permissions, facility_permissions, workspace_permissions, team_permissions 각각에 대해 user/group/system_group 별 LEFT JOIN을 수행한다 (총 11개 subquery JOIN).

4. BaseRepository#search에서 permission_joins 2차 호출:

Elasticsearch 결과에 대해 다시 permission_joins를 실행하여 권한 정보를 부착한다.

app/repositories/base_repository.rb:70-82ruby
def search(query_option = nil)
  _search(query_option)

  begin
    if self.review.present?
      contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, review_id: self.review.id, skip_join: _skip_join?)
    elsif self.review_id.present?
      contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, review_id: self.review_id, skip_join: _skip_join?)
    elsif self.capture.present?
      contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, capture_id: self.capture.id, skip_join: _skip_join?)
    else
      contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, skip_join: _skip_join?)
    end

Log Evidence#

Datadog 쿼리:

text
service:cupixworks-api "FloorplansController#index" @duration:>500
Time: 2026-05-26 04:00 ~ 06:00 UTC

해당 요청 로그 (1122ms 소요):

json
{
  "host": "ip-10-1-19-190.us-west-2.compute.internal",
  "duration_ms": 1120.68,
  "db_ms": 5.78,
  "serialization_ms": 17,
  "view_ms": 0.08,
  "status": 200,
  "team": "umedafm",
  "team_id": 1192,
  "user": "itakura.sora.z@takenaka.co.jp",
  "params": {"level_id": "79752", "per_page": "25", "page": "1"},
  "pagination": {"total_entries": 5, "total_pages": 1}
}

동일 호스트에서 동시 처리된 heavy 요청들:

text
service:cupixworks-api @host.name:"ip-10-1-19-190.us-west-2.compute.internal" @duration:>200
Time: 2026-05-26 05:10 ~ 05:20 UTC
text
ElementTracesController#bulk: 2100-2377ms (DB 470-512ms)
PanosController#create_tile_upload_credentials: 1093-1356ms (DB 6ms)
Admin::EditingsController#index: 10,000 entries pagination

us-west-2 리전 FloorplansController#index 전체 패턴:

text
05:15:13 — 1120.68ms (DB 5.78ms)  host: ip-10-1-19-190
05:07:11 — 1043.24ms (DB 12.83ms) host: ip-10-1-144-228
05:01:25 — 1343.60ms (DB 37.04ms) host: ip-10-1-19-190
04:50:22 — 646.38ms  (DB 9.15ms)  host: ip-10-1-144-228
04:46:24 — 786.62ms  (DB 9.68ms)  host: ip-10-1-19-190
04:36:02 — 588.82ms  (DB 562.32ms) host: ip-10-192-144-216

DB 시간이 매우 낮음에도 총 duration이 높은 패턴은 프로세스 레벨의 리소스 경합을 시사한다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 permission_joins 쿼리의 내재적 복잡도 + 호스트 리소스 경합으로 인한 Rails 프로세스 대기 DB 5.78ms인데 총 1122ms 소요, 동일 호스트에서 2000ms+ 요청 동시 처리 확인, 18건의 >500ms 요청이 동일 2시간 내 발생 DB 시간 자체는 매우 짧음 (5.78ms) Confirmed
H2 permission_joins SQL 쿼리 자체의 느린 실행 (missing index) 04:36 요청에서 DB 562ms 확인 (ip-10-192-144-216), 11개 LEFT JOIN + GROUP BY 구조 해당 요청(1122ms)의 DB는 5.78ms로 빠름 Partially confirmed (간헐적)
H3 Elasticsearch 쿼리 지연 ES circuit breaker 에러 핸들링 코드 존재 (base_repository.rb:88-93) 해당 시간대 ES 관련 에러 로그 없음, 정상 200 응답 Rejected
H4 Serialization N+1 문제 (fetch_cache miss) FloorplanSerializer에서 _user, _team, _workspace 등 5+개 fetch_cache 호출 Serialization 17ms로 빠름, cache hit 상태 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 현재 1건의 단발성 이벤트로, 즉각적인 코드 수정은 불필요하다.
  • 호스트 ip-10-1-19-190의 동시 요청 부하 모니터링을 강화한다.

단기 개선 (1주 이내)#

  • app/repositories/floorplan_repository.rb:307permission_joins의 1차 호출 시 select: 'floorplans.id'만 조회하므로, level_id 필터를 subquery 내부로 이동하여 JOIN 대상 row 수를 줄인다.
  • Puma worker 수 또는 thread 수를 검토하여 heavy 요청이 다른 요청을 blocking하지 않도록 한다.

장기 개선 (재발 방지)#

  • permission_joins 패턴을 캐시 기반 또는 materialized view로 리팩토링하여, 매 요청마다 11개 LEFT JOIN을 실행하지 않도록 한다.
  • us-west-2 인스턴스의 autoscaling 정책을 검토하여, 동시 heavy 요청 시 capacity를 확보한다.
  • ElementTracesController#bulk 같은 heavy endpoint를 별도 worker pool이나 background job으로 분리한다.

Monitoring#

  • FloorplansController#index의 P95/P99 latency 추적:
text
service:cupixworks-api resource_name:"Api::V1::FloorplansController#index" @duration:>500ms
  • 호스트별 동시 요청 수 모니터링:
text
service:cupixworks-api @duration:>1000ms | group by @host.name
  • permission_joins DB 시간 이상 탐지:
text
service:cupixworks-api "FloorplansController" @http.status_code:200 @duration:>2000ms

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard