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#
- 2026-05-26T05:15:11Z — FloorplansController#index 요청 수신 (user: itakura.sora.z@takenaka.co.jp, team: umedafm)
- 2026-05-26T05:15:13Z — 응답 완료 (200 OK, 1120.68ms 소요)
- 2026-05-26T05:15~05:20Z — 동일 호스트에서 ElementTracesController#bulk (2100-2377ms), PanosController#create_tile_upload_credentials (1093-1356ms) 동시 처리 확인
Error Log#
{
"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:
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 목록을 추출한다.
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 쿼리:
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를 실행하여 권한 정보를 부착한다.
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 쿼리:
service:cupixworks-api "FloorplansController#index" @duration:>500
Time: 2026-05-26 04:00 ~ 06:00 UTC
해당 요청 로그 (1122ms 소요):
{
"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 요청들:
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
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 전체 패턴:
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:307—permission_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 추적:
service:cupixworks-api resource_name:"Api::V1::FloorplansController#index" @duration:>500ms
- 호스트별 동시 요청 수 모니터링:
service:cupixworks-api @duration:>1000ms | group by @host.name
- permission_joins DB 시간 이상 탐지:
service:cupixworks-api "FloorplansController" @http.status_code:200 @duration:>2000ms
Risk Assessment#
- Risk level: low
- 예상 복잡도: standard