Api::V1::FacilitiesController#index (avg 1118ms, max 1183ms)
RCA: Api::V1::FacilitiesController#index Latency
Overview#
What Happened#
2026-05-26 03:44~12:44 UTC 사이에 cupixworks-api 서비스의 FacilitiesController#index 엔드포인트에서 평균 1158ms, 최대 4125ms의 응답 지연이 3개 리전(ap-southeast-2, us-west-2, eu-central-1)에서 26건 발생했다. 모든 요청은 HTTP 200으로 성공하였으나, 500ms SLA를 초과하는 latency가 지속적으로 관측되었다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::FacilitiesController#index |
| top_frame | app/repositories/base_repository.rb:70 |
| env | production (ap-southeast-2, us-west-2, eu-central-1) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| erwalls (us-west-2) | 3 | 445개 facility 조회 시 최대 4.1초 지연, UX 저하 |
| sinsw (ap-southeast-2) | 2 | 289개 facility 조회 시 2.1~2.3초 지연 |
| built (ap-southeast-2) | 5 | 112개 facility 조회 시 0.8~1.1초 지연 |
| 기타 다수 | 16 | 소규모 팀에서도 0.5~1초 지연 발생 |
Timeline#
- 2026-05-26T03:44:52Z — 최초 latency 감지 (ap-southeast-2)
- 2026-05-26T05:15:45Z — sinsw 팀 2.1초 지연 발생 (289 facilities, per_page:300)
- 2026-05-26T11:50:40Z — erwalls 팀 3.4초 지연 발생 (444 facilities, per_page:300)
- 2026-05-26T11:53:17Z — 최대 지연 4.1초 기록 (erwalls, serialization: 3409ms)
- 2026-05-26T12:44:41Z — 마지막 감지
Error Log#
{
"resource_name": "Api::V1::FacilitiesController#index",
"service": "cupixworks-api",
"occurrences": 6,
"avg_ms": 1118,
"max_ms": 1183,
"sample_trace_id": "2830566889370620766"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 26
- 최초 발생: 2026-05-26T03:44:52.047Z
- 최근 발생: 2026-05-26T12:44:41.539Z
- 영향: 대규모 facility를 보유한 팀의 사용자가 facility 목록 로딩 시 2~4초 대기. 에러는 발생하지 않으나 사용자 경험이 크게 저하됨.
Root Cause Summary#
FacilitiesController#index의 응답 지연은 JSON serialization 단계에서 대량의 facility 레코드를 직렬화할 때 발생하는 비용이 주 원인이다. Datadog 로그 분석 결과, 가장 느린 요청(4125ms)의 82%인 3409ms가 serialization에 소비되었다. FacilitySerializer가 22개 이상의 attribute concern을 포함하며, 각 facility마다 _user, _client, _facility_type, _asset_category_type 등의 association을 lazy-load하는 N+1 패턴이 대량 컬렉션(per_page:300, 289~445건)에서 극대화된다. 부차적으로 permission_joins() 메서드의 9개 LEFT JOIN + GROUP BY 집계가 DB 시간을 증가시키는 요인이다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/facilities_controller.rb:93
def index
facilities = repository_instance.search(
Cupix::QueryOption::Facility.new(get_query_option, params)
)
render_api Renderable.new({
search_result: facilities,
is_collection: true,
serializer_option: @serializer_option
})
end
- Search execution:
app/repositories/base_repository.rb:70-112
Elasticsearch에서 결과를 가져온 후, default_joins()와 permission_joins()를 통해 SQL 재쿼리를 수행한다.
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?)
# ...
else
contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, skip_join: _skip_join?)
end
end
end
- Default JOINs:
app/repositories/facility_repository.rb:245-269
includes(:storage)로 storage만 eager-load하고, workspace/team은 INNER JOIN으로 select하지만, serializer에서 접근하는 user, client, asset_category_type, facility_type 등의 association은 eager-load하지 않는다.
def self.default_joins(records)
records.includes(:storage).joins(:workspace, :team).select('
facilities.*,
workspaces.name AS workspace_name,
teams.qa_preference AS team_qa_preference,
teams.total_bim_pack_count AS team_total_bim_pack_count,
# ... (17개 추가 컬럼)
workspaces.lock_state AS workspace_lock_state
')
end
- Permission JOINs (DB 병목):
app/repositories/facility_repository.rb:271-416
9개의 LEFT JOIN subquery + GROUP BY + MAX 집계를 한 번에 수행한다. 이 쿼리는 facility_permissions, workspace_permissions, team_permissions, grouped_users 테이블을 모두 조인하며, 대량 데이터에서 DB 시간이 증가한다.
record.joins("
LEFT JOIN (
SELECT facility_id, permission
FROM facility_permissions
WHERE facility_permissions.accessor_id = #{sanitized_user_id}
AND facility_permissions.accessor_type = 'User'
) AS facility_user_permissions
ON facility_user_permissions.facility_id = facilities.id
LEFT JOIN (
SELECT facility_id, permission
FROM facility_permissions
LEFT JOIN grouped_users
ON grouped_users.group_id = facility_permissions.accessor_id
AND grouped_users.user_id = #{sanitized_user_id}
WHERE facility_permissions.accessor_type = 'Group'
AND grouped_users.user_id = #{sanitized_user_id}
) AS facility_group_permissions
ON facility_group_permissions.facility_id = facilities.id
-- ... 7 more LEFT JOINs ...
").group('id').select(_select)
- Serialization (주 병목):
app/serializers/facility_serializer.rb:1-118
22개 이상의 include concern과 80개 이상의 attribute를 직렬화한다. _user, _client, _facility_type 등의 메서드는 eager-load 되지 않은 association을 개별 쿼리로 로드한다.
include TeamAttribute
include WorkspaceAttribute
attribute :user, &:_user
attribute :asset_category_type, &:_asset_category_type
attribute :meta
attribute :client, &:_client
include GroundLevelAttribute
include QaPreferenceAttribute
include StorageAttribute
include ConvertedFacilitySizeAttribute
include EntityUpdatesAttribute
include UnitSystemAttribute
include GeoReferencableAttribute
include ResourcableAttribute
include LastUpdatedUserAttribute
include SiteinsightsAttribute
include SiteinsightsLiteAttribute
include TradeMappingAttribute
include SalesforceAttribute
include OptOutableAttribute
include ExpectedQualityAttribute
Log Evidence#
Datadog 검색 쿼리:
service:cupixworks-api "FacilitiesController" @duration:>2000ms
가장 느린 요청 5건의 breakdown:
| Timestamp (UTC) | Duration | DB (ms) | Serialization (ms) | Team | Entries | Per Page |
|-------------------------|----------|----------|--------------------|---------|---------|----------|
| 2026-05-26T11:53:17Z | 4124ms | 472ms | 3409ms | erwalls | 445 | 300 |
| 2026-05-26T11:50:40Z | 3386ms | 1094ms | 3185ms | erwalls | 444 | 300 |
| 2026-05-26T12:12:58Z | 2024ms | 54ms | 1810ms | erwalls | 445 | 300 |
| 2026-05-26T05:15:55Z | 2294ms | 88ms | 2036ms | sinsw | 289 | 300 |
| 2026-05-26T05:15:45Z | 2150ms | 88ms | 1885ms | sinsw | 289 | 300 |
핵심 패턴:
- Serialization 시간이 전체 응답 시간의 80~85% 차지
per_page: 300과total_entries > 200인 요청에서 집중 발생- DB 시간은 대부분 100ms 이하이나, erwalls 팀에서 간헐적으로 472~1094ms 발생 (permission JOIN 비용)
추가 검색 — DB 고비용 요청:
service:cupixworks-api "FacilitiesController" @db:>300
| Timestamp | Duration | DB (ms) | Team | Entries |
|-------------------|----------|----------|----------------|---------|
| 05:21:15 | 537ms | 338ms | jangwi-daewoo | 23 |
| 05:17:34 | 824ms | 354ms | built | 112 |
| 05:06:31 | 529ms | 374ms | takenaka | 3 |
| 05:01:01 | 542ms | 416ms | toyoeng | 2 |
| 04:57:28 | 1041ms | 361ms | kiyeno | 1 |
| 04:51:56 | 926ms | 518ms | kita | 1 |
소규모 팀(entries 123)에서도 DB 시간이 300500ms인 경우가 있음 — permission_joins의 9개 LEFT JOIN subquery가 원인. 데이터 양과 무관하게 JOIN 구조 자체가 비용을 유발함.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Serialization N+1 쿼리로 인한 대량 컬렉션 직렬화 지연 | 로그: serialization 시간이 전체의 80-85% (3409ms/4124ms). 코드: facility_serializer.rb:12-15의 _user, _client, _facility_type 등이 eager-load 없이 호출됨. entries 200+ 요청에서만 2초 이상 발생 |
entries < 30인 요청에서도 일부 500ms+ 발생하나, serialization 비율은 낮음 | Confirmed |
| H2 | Permission JOINs의 9개 LEFT JOIN + GROUP BY가 DB 병목 유발 | 로그: entries 1facility_repository.rb:302-416에 9개 subquery JOIN + GROUP BY 존재 |
대부분 요청의 DB 시간은 50~90ms 범위. 간헐적 spike는 cold cache 또는 lock contention 가능성 | Contributing factor |
| H3 | Elasticsearch 응답 지연 | — | 모든 로그에서 ES 응답은 빠르게 완료됨 (DB 시간에 포함되지 않는 별도 단계). ES circuit breaker 에러 없음 | Rejected |
| H4 | 네트워크 지연 또는 인프라 이슈 | 3개 리전에서 동시 발생 | 3개 리전 모두에서 동일 패턴이므로 인프라가 아닌 코드 레벨 이슈. QA 환경에서도 동일 재현 | Rejected |
| H5 | 큰 per_page 값에 대한 제한 부재 | 로그: per_page:300 요청에서 집중 발생. per_page:30인 요청은 대부분 정상 | per_page는 클라이언트가 설정하므로 서버에서 상한을 두어야 함 | Contributing factor |
Fix Recommendation#
즉시 조치 (Critical)#
-
default_joins()에 eager-load 추가 —app/repositories/facility_repository.rb:245includes(:storage)→includes(:storage, :user, :client, :asset_category_type, :facility_type)로 확장- serializer에서 개별 쿼리 대신 preload된 association 사용하도록 변경
- 이것만으로도 serialization 시간 60-70% 감소 예상
-
per_page상한 설정 —lib/cupix/query_option/facility.rb또는 controllerper_page를 최대 100으로 제한하여 대량 직렬화 방지- 현재 300이 허용되어 445건을 한 번에 직렬화하는 상황 발생
단기 개선 (1주 이내)#
-
Collection 전용 경량 serializer 도입
FacilitySerializer의 22개 concern 중 목록 조회에 불필요한 attribute를 제외하는FacilityListSerializer생성SiteinsightsAttribute,BimAttribute,SalesforceAttribute,TradeMappingAttribute등은 목록에서 불필요
-
Permission JOIN 최적화
facility_permissions,workspace_permissions,team_permissions테이블에 복합 인덱스 추가:facility_permissions(facility_id, accessor_type, accessor_id)workspace_permissions(workspace_id, accessor_type, accessor_id)grouped_users(group_id, user_id)
장기 개선 (재발 방지)#
-
Permission 캐싱 레이어 도입
- 사용자별 facility permission을 Redis에 캐시하여 매 요청마다 9개 JOIN을 실행하지 않도록 함
- Permission 변경 시 cache invalidation
-
ES 결과 직접 반환 (collection mode)
- 목록 조회 시 ES 인덱스에 이미 저장된 데이터를 직접 반환하고, SQL 재쿼리를 생략하는 방안 검토
as_indexed_json()에 이미 65개 이상 필드가 인덱싱되어 있으므로 활용 가능
Monitoring#
- Serialization duration 추적:
service:cupixworks-api resource_name:"Api::V1::FacilitiesController#index" @duration:>1000ms
-
P95 latency 알림 설정:
- Threshold:
FacilitiesController#indexP95 > 800ms 시 alert - Monitor type: APM metric monitor on
trace.rack.request.durationfiltered by resource
- Threshold:
-
대량 요청 추적:
service:cupixworks-api "FacilitiesController" @total_entries:>200
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard — eager-load 추가는 즉시 가능하나, serializer 분리와 permission 캐싱은 테스트가 필요함