Api::V1::FeedsController#index (avg 1246ms, max 1591ms)
RCA: FeedsController#index Slow Response (avg 2063ms, max 3672ms)
Overview#
What Happened#
2026-05-26 03:4913:24 UTC 동안 99.6%를 차지하며 심각한 성능 저하가 확인되었다.Api::V1::FeedsController#index 엔드포인트에서 평균 2063ms, 최대 35,273ms의 응답 지연이 발생했다. 피드 수가 많은 대규모 facility를 가진 팀(downergroup: 9,264건, umedafm: 2,640건)에서 DB 쿼리 시간이 전체 응답의 95
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::FeedsController#index |
| top_frame | app/repositories/feed_repository.rb:172 |
| runtime | Ruby on Rails (cupixworks-api) |
| env | production (ap-southeast-2, ap-southeast-1, us-west-2, eu-central-1) |
Affected Teams#
| Team / Domain | Feed Count | Impact |
|---|---|---|
| downergroup (ap-southeast-2) | 9,264 | 18.8초 응답 지연, 사용자 UX 심각 저하 |
| umedafm (us-west-2) | 2,640 | 35.2초 응답 지연 (타임아웃 근접) |
| hec-test-dangjin (us-west-2) | 219 | 1.9초 응답 지연 |
| shapoorji (eu-central-1) | 73~75 | 1.3초 간헐적 지연 |
Timeline#
- 2026-05-26T03:49:18Z — 최초 감지 (aceplp 팀, ap-southeast-1)
- 2026-05-26T06:38:58Z — 최대 지연 발생 (umedafm 팀, 35,273ms)
- 2026-05-26T08:40:19Z — 대규모 시설 지연 (downergroup 팀, 18,857ms)
- 2026-05-26T13:24:04Z — 마지막 감지
Error Log#
{
"resource_name": "Api::V1::FeedsController#index",
"service": "cupixworks-api",
"occurrences": 3,
"avg_ms": 1246,
"max_ms": 1591,
"sample_trace_id": "4014661093071533843"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 6
- 최초 발생: 2026-05-26T03:49:18.595Z
- 최근 발생: 2026-05-26T13:24:04.356Z
Root Cause Summary#
FeedRepository#_event_query 메서드(line 172)에서 Event.eager_load(:event_objects) 사용 시 LEFT OUTER JOIN이 발생하며, 대규모 facility(수천 건의 이벤트)에서 단일 쿼리로 전체 JOIN 결과를 생성한 후 정렬 및 페이지네이션을 수행한다. facility_id 단일 인덱스만 존재하고 (facility_id, eventable_type, created_at) 복합 인덱스가 없어, DB가 전체 결과셋을 filesort 해야 하며 이로 인해 DB time이 18~35초까지 증가한다. 추가로 will_paginate가 COUNT(*) 쿼리를 별도 실행하므로 동일한 비효율적 JOIN이 2회 수행된다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/feeds_controller.rb:2—indexaction 호출 FeedRepository#search호출:app/repositories/feed_repository.rb:27- admin_team? 분기:
app/repositories/feed_repository.rb:30— admin 사용자는 DB 직접 쿼리 경로로 진입 _event_query실행:app/repositories/feed_repository.rb:169-177— Failure point
def index
feed_query_option = Cupix::QueryOption::Feed.new(get_query_option, params)
feeds = repository_instance.search(feed_query_option)
render_api Renderable.new(
search_result: feeds,
is_collection: true,
serializer_option: @serializer_option
)
end
admin 사용자가 요청하면 Elasticsearch 경로를 건너뛰고 직접 DB 쿼리 경로(_event_query)로 진입한다:
if current_user.admin_team?
facility = FacilityRepository.new(current_user: self.current_user).show(self.query_option.facility_key)
contents = _event_query(
facility_id: facility.id,
per_page: self.query_option.per_page,
page: self.query_option.page
)
feed_contents = contents.map do |event|
_event_object_id = event.event_objects.first
Feed.new({
event: event,
event_object_id: _event_object_id
})
end
핵심 문제 쿼리:
def _event_query(params)
raise Cupix::Errors::Parameter.new(code: 'ARG10001', reason: 'facility_id is required') if params[:facility_id].blank?
Event.eager_load(:event_objects).where(
eventable_type: FEED_EVENTABLE_MODEL
).where(
facility_id: params[:facility_id]
).order(created_at: :desc).paginate(per_page: params[:per_page], page: params[:page])
end
기대 동작: 페이지네이션으로 100건만 로드되어 빠른 응답
실제 동작: eager_load가 LEFT OUTER JOIN을 생성하여 전체 결과(9,264건 × event_objects)를 JOIN한 후 filesort + LIMIT 적용. will_paginate의 COUNT 쿼리도 동일한 JOIN을 실행하여 2배의 부하 발생.
생성되는 SQL 패턴:
SELECT COUNT(DISTINCT `events`.`id`) FROM `events`
LEFT OUTER JOIN `event_objects` ON `event_objects`.`event_id` = `events`.`id`
WHERE `events`.`eventable_type` IN ('AnnotationLayer','Annotation','Bim','Capture','Facility','Floorplan','Level','Mesh','Pointcloud','Record','Review','Sketch')
AND `events`.`facility_id` = ?
SELECT `events`.*, `event_objects`.* FROM `events`
LEFT OUTER JOIN `event_objects` ON `event_objects`.`event_id` = `events`.`id`
WHERE `events`.`eventable_type` IN (...)
AND `events`.`facility_id` = ?
ORDER BY `events`.`created_at` DESC
LIMIT 100 OFFSET 0
인덱스 상태 (db/schema.rb:1926-1934):
index_events_on_facility_id— facility_id 단일 인덱스만 존재(facility_id, eventable_type, created_at)복합 인덱스 미존재
Log Evidence#
Datadog 쿼리:
service:cupixworks-api "FeedsController" "index"
시간 범위: 2026-05-26T02:49:18Z ~ 2026-05-26T13:24:04Z
극단적 지연 사례:
{
"timestamp": "2026-05-26T06:38:58.552Z",
"duration_ms": 35273.50,
"db_ms": 35130.86,
"serialization_ms": 109,
"team_domain": "umedafm",
"team_id": 1192,
"facility_key": "5v5ar2",
"total_entries": 2640,
"region": "us-west-2",
"user": "adrian.kim@cupix.com"
}
{
"timestamp": "2026-05-26T08:40:19.159Z",
"duration_ms": 18857.15,
"db_ms": 18666.44,
"serialization_ms": 235,
"team_domain": "downergroup",
"team_id": 17,
"facility_key": "o2nc98",
"total_entries": 9264,
"region": "ap-southeast-2",
"user": "summer.han@cupix.com"
}
DB time 비율 분석:
- umedafm: 35,130ms / 35,273ms = 99.6% DB time
- downergroup: 18,666ms / 18,857ms = 99.0% DB time
- 소규모 팀(aceplp, 3건): 20~30ms DB time — 정상 수준
에러/워닝 로그 검색:
service:cupixworks-api (status:error OR status:warn) "FeedsController"
결과: 0건 — 모든 요청이 HTTP 200으로 성공. 순수 성능 문제이며 기능적 오류는 없음.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | eager_load LEFT OUTER JOIN + 복합 인덱스 미비로 대규모 facility에서 DB 쿼리 급격히 느려짐 |
DB time이 99%+ 차지, total_entries와 지연 비례 관계 (9264건→18.8s, 2640건→35.1s), facility_id 단일 인덱스만 존재 | — | Confirmed |
| H2 | N+1 쿼리 — serialization 중 추가 DB 호출 발생 | event.event_objects.first (line 38) 호출 패턴 |
eager_load(:event_objects)로 이미 preload됨, serialization time은 109~235ms로 미미 |
Rejected |
| H3 | Redis cache miss로 _event/_event_object 직렬화 중 DB fallback 대량 발생 |
fetch_cache는 cache miss 시 DB 조회 (application_record.rb:65) |
admin 경로에서는 Feed.new()로 새 인스턴스 생성 — persisted 아니므로 serializer 경로가 다름, serialization time 미미 | Rejected |
| H4 | will_paginate COUNT 쿼리가 별도로 전체 JOIN을 수행하여 2배 부하 |
paginate 메서드는 내부적으로 COUNT + SELECT 2회 쿼리 실행, 동일한 JOIN 조건 적용 |
umedafm 35s 중 COUNT 비중 정확히 분리 불가 (단일 db time 속성에 합산) | Confirmed (부분) |
Fix Recommendation#
즉시 조치 (Critical)#
- 파일:
app/repositories/feed_repository.rb:172 eager_load(:event_objects)를includes(:event_objects)로 변경하여 LEFT OUTER JOIN 대신 별도 SELECT 2회로 분리. 이렇게 하면 events 쿼리의 ORDER BY + LIMIT이 먼저 적용된 후 결과 event_id만으로 event_objects를 조회하게 됨.- 변경 방향:
Event.includes(:event_objects).where(...)— JOIN이 아닌 separate query 전략
단기 개선 (1주 이내)#
(facility_id, eventable_type, created_at DESC)복합 인덱스 추가 — 쿼리 플랜이 인덱스만으로 정렬+필터 가능하도록 함will_paginate의 COUNT 쿼리에 대해total_entries옵션을 활용하여 별도의 count 캐싱 또는 approximate count 적용 검토- admin 경로에서
per_page기본값을 100→25로 줄여 결과셋 크기 제한
장기 개선 (재발 방지)#
- admin 경로도 Elasticsearch를 사용하도록 통합하여 DB 직접 쿼리 제거
- 대규모 facility에 대한 feed 아카이빙/TTL 정책 도입으로 events 테이블 row 수 관리
- APM에 slow query 임계값(>3s) 알림 설정으로 DB 성능 회귀 조기 감지
Monitoring#
service:cupixworks-api resource_name:"Api::V1::FeedsController#index" @duration:>3000알림 추가- 복합 인덱스 추가 후 p95 응답 시간 변화 추적
- 대규모 facility(total_entries > 1000) 요청 빈도 대시보드
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard —
eager_load→includes변경은 1줄 수정이나 동작 확인 필요, 인덱스 추가는 마이그레이션 필요