Api::V1::FeedsController#index (avg 18150ms, max 18150ms)
RCA: Api::V1::FeedsController#index latency (18.15s)
Overview#
What Happened#
2026-07-01 16:58 KST에 cupixworks-api (us-west-2)의 GET /api/v1/feeds 한 건이 18.15초 걸려 완료됐다. HTTP 200으로 성공했지만, 응답 시간의 거의 전부(db: 17754.85ms)를 데이터베이스 쿼리가 차지했다. admin 팀 사용자가 facility_key=2f6b5s (해당 facility의 event 3,664건)를 조회한 요청 한 건이 지연 클러스터로 잡혔다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::FeedsController#index |
| endpoint | GET /api/v1/feeds |
| top_frame | app/repositories/feed_repository.rb:169-177 (_event_query) |
| trace_id | 959803301119832904 |
| request_id | 29e09de6-e8c5-40e9-9bfb-a50ea649b475 |
| duration | 18148.09 ms (db 17754.85 ms, serialization 555 ms, view 0.09 ms) |
| status_code | 200 |
| deploy | production-us-west-2-20260701t0627z0-595dc2ae-cupixworks |
| host | ip-10-1-80-134.us-west-2.compute.internal |
| env | production / us-west-2 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| admin (Cupix 내부, 조회 사용자) | 1 | admin 팀 사용자가 facility_key=2f6b5s (team domain findorff, id 159) feed 조회 시 18초 대기 |
| findorff (조회 대상 facility 소유 팀) | — | 데이터는 정상 반환됨 (200). 사용자 체감 지연만 발생 |
Timeline#
- 2026-07-01 16:58:29 KST — 요청 진입 (
@timestamp - duration역산;first_seen2026-07-01T07:58:27.541Z) - 2026-07-01 16:58:47 KST — 응답 완료,
duration=18148.09ms,db=17754.85ms기록 (Datadog log@timestamp2026-07-01T07:58:47.600Z) - 2026-07-01 16:58:27 KST — error-sweeper collector가 latency cluster로 감지 (
first_seen)
Error Log#
{
"message": "[200] GET /api/v1/feeds (Api::V1::FeedsController#index)",
"controller": "Api::V1::FeedsController",
"action": "index",
"duration": 18148.09,
"db": 17754.85,
"view": 0.09,
"serialization": { "duration": 555 },
"http": { "status_code": 200, "method": "GET", "url_details": { "path": "/api/v1/feeds" } },
"params": { "per_page": "100", "facility_key": "2f6b5s",
"fields": ["id","user","event","recipe","event_object"] },
"pagination": { "current_page": 1, "per_page": 100,
"total_pages": 37, "total_entries": 3664, "next_page": 2 },
"user": { "id": 39791, "team": { "id": 133 } },
"team": { "domain": "findorff", "id": 159 },
"request_id": "29e09de6-e8c5-40e9-9bfb-a50ea649b475",
"environment": "production"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-07-01 16:58 KST
- 최근 발생: 2026-07-01 16:58 KST
- HTTP 결과: 200 (성공) — 에러 없음, 응답 지연만 발생
Root Cause Summary#
Api::V1::FeedsController#index가 admin 팀 사용자 요청에 대해 FeedRepository#search → _event_query로 분기하여 MySQL events 테이블을 조회한다. 이 쿼리는 facility_id로 필터 + eventable_type IN (12개 모델) + LEFT OUTER JOIN event_objects + ORDER BY created_at DESC + LIMIT/OFFSET + total_entries를 위한 COUNT를 한 번에 수행한다. db/schema.rb상 events 테이블에는 단일 컬럼 인덱스(index_events_on_facility_id, index_events_on_eventable_type_and_eventable_id)만 있고 (facility_id, eventable_type, created_at) 복합 인덱스가 없어, 대상 facility의 event 수가 3,664건인 이번 요청은 facility_id만으로 인덱스 스캔 후 filesort로 정렬되고 event_objects와 join되며 DB에서 17.75초를 소비했다. 결과셋 크기와 DB 시간이 선형이 아닌 급격한 상관(총 27건 → 74ms vs 3,664건 → 17,755ms)을 보이는 것이 이를 뒷받침한다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/feeds_controller.rb:2-11 - Dispatch:
FeedRepository#search→current_user.admin_team?분기 (admin이므로_event_query경로) - Failure point (latency 원인):
app/repositories/feed_repository.rb:169-177(_event_query) —Event.eager_load(:event_objects).where(...).order(created_at: :desc).paginate(...)
Controller는 얇은 wrapper다:
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
FeedRepository#search에서 admin 사용자는 Elasticsearch 대신 ActiveRecord 경로로 분기한다:
def search(query_option)
set_query_option(query_option)
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
- 참고:
admin_team?는Rails.cache.fetch(..., expires_in: 4.hours)로 캐시된다 (app/models/concerns/properties/user.rb:139-143) — admin 판정 자체는 병목이 아님.
핵심 쿼리는 _event_query다:
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
FEED_EVENTABLE_MODEL는 12종의 polymorphic type이다:
FEED_EVENTABLE_MODEL = %w[AnnotationLayer Annotation Bim Capture
Facility Floorplan Level Mesh Pointcloud
Record Review Sketch].freeze
이 코드가 MySQL로 만드는 쿼리는 대략 다음과 같다:
SELECT events.*, event_objects.*FROM eventsLEFT OUTER JOIN event_objects ON event_objects.event_id = events.idWHERE events.eventable_type IN ('AnnotationLayer','Annotation',...,'Sketch') AND events.facility_id = <facility.id>ORDER BY events.created_at DESCLIMIT 100 OFFSET 0;-- plus will_paginate COUNT for total_entries:SELECT COUNT(*) FROM eventsWHERE eventable_type IN (...) AND facility_id = <facility.id>;db/schema.rb상 관련 인덱스:
t.index ["eventable_team_id"], name: "index_events_on_eventable_team_id"
t.index ["eventable_type", "eventable_id"], name: "index_events_on_eventable_type_and_eventable_id"
t.index ["facility_id"], name: "index_events_on_facility_id"
t.index ["level_id"], name: "index_events_on_level_id"
t.index ["linked_review_id"], name: "index_events_on_linked_review_id"
t.index ["record_id"], name: "index_events_on_record_id"
t.index ["team_id"], name: "index_events_on_team_id"
t.index ["user_id"], name: "index_events_on_user_id"
t.index ["workspace_id"], name: "index_events_on_workspace_id"
(facility_id, eventable_type, created_at)복합 인덱스 부재 — filter+정렬을 한 번에 처리할 수 있는 인덱스가 없다.- 옵티마이저는
index_events_on_facility_id로 3,664개 행을 잡고,eventable_type IN (…)를 residual filter로 적용한 뒤created_at DESC로 filesort, 그 후event_objects와 LEFT JOIN 하는 형태로 실행됐을 가능성이 크다. - 추가로
paginate가 요구하는SELECT COUNT(*)한 방이 같은 인덱스로 다시 실행돼 부담을 이중으로 준다.
기대 동작: 첫 페이지 100건 조회 시 sub-second (관측된 일반 케이스와 유사).
실제 동작: db 시간 17.75s, view: 0.09ms이고 serialization: 555ms뿐이므로 병목은 순수 DB 쿼리에 있음.
Log Evidence#
Datadog 쿼리 (실제 요청 로그):
service:cupixworks-api "FeedsController"
시간 범위: 2026-07-01T07:55:00Z ~ 2026-07-01T08:05:00Z
문제의 요청 payload (raw log 발췌):
{
"@timestamp": "2026-07-01T07:58:47.600Z",
"duration": 18148.09,
"db": 17754.85,
"view": 0.09,
"serialization": { "duration": 555 },
"controller": "Api::V1::FeedsController",
"action": "index",
"params": { "facility_key": "2f6b5s", "per_page": "100" },
"pagination": { "total_entries": 3664, "total_pages": 37, "current_page": 1 },
"user": { "id": 39791, "team": { "id": 133 } },
"team": { "domain": "findorff", "id": 159 },
"http": { "status_code": 200 }
}
동일 admin 사용자(user.id: 39791)의 지난 14일간 FeedsController 호출 5건 비교 — total_entries(대상 facility의 event 수)와 db 시간이 급격히 비선형으로 증가한다:
Datadog 쿼리:
service:cupixworks-api "FeedsController" @user.id:39791
| @timestamp (UTC) | facility_key | total_entries | db (ms) | duration (ms) |
|---|---|---|---|---|
| 2026-07-01T07:58:47.600Z | 2f6b5s | 3664 | 17754.85 | 18148.09 |
| 2026-06-22T07:28:37.772Z | fduf5o | 184 | 189.12 | 459.51 |
| 2026-06-22T07:32:22.591Z | 5ohz4r | 47 | 55.87 | 229.09 |
| 2026-06-23T11:20:35.648Z | 5lvd2e | 27 | 74.58 | 238.16 |
| 2026-06-18T07:57:48.019Z | 6yl4xh | 69 | 66.20 | 246.23 |
3,664건이 184건 대비 20배지만 db 시간은 189ms → 17,755ms로 93배 증가 — 인덱스가 filter+sort를 커버하지 못하고 filesort/LEFT JOIN 비용이 rows^k (k>1)에 가깝게 붙는 전형적인 pattern.
동일 시간대(±5분) cupixworks-api 서비스에 error/warn 로그 없음 — DB 인프라 전반의 outage가 아니라 이 특정 쿼리 문제임을 확인:
service:cupixworks-api status:error
시간 범위 2026-07-01T07:55:00Z ~ 2026-07-01T08:05:00Z → Found 0 logs
Status board (svc:cupixworks-api::unknown): active 인시던트 없음, 2026-07-01-svc-cupixworks-api--unknown-1은 07-01 01:43~02:02 UTC에 이미 resolved. 이 지연은 별개 이벤트다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | admin path의 _event_query (MySQL)가 대상 facility의 event 수(3,664)에 대해 (facility_id, eventable_type, created_at) 복합 인덱스 부재로 filesort + LEFT JOIN을 실행해 DB 시간 대부분을 소비 |
db=17754.85ms, duration=18148.09ms, view=0.09ms, serialization=555ms; pagination.total_entries=3664; db/schema.rb:1931-1939에 facility_id 단일 인덱스만 존재; 동일 admin의 다른 facility(27feed_repository.rb:169-177 쿼리 형태 |
— | Confirmed |
| H2 | Elasticsearch(non-admin _search 경로) 지연 |
_search 경로는 ES를 쓰지만, 이 요청은 user.team.id=133 (admin_team, team.domain='admin'이 캐시됨)이라 _event_query(MySQL) 경로로 분기 (feed_repository.rb:30); view/serialization이 작음은 ES 결과 파싱 비용도 낮음을 의미 |
상동 | Rejected |
| H3 | 인프라/DB 전면 outage로 인한 잠깐의 지연 (외부 요인) | 같은 window에 다른 slow endpoints도 존재 (CapturesController#update 등 5s+ 로그 다수) | 동일 시간대 cupixworks-api status:error 로그 0건; status board svc:cupixworks-api에 active 인시던트 없음; 슬로우 로그 pattern이 mutating endpoint에 편중 (별개 원인 가능성); Feed 요청 자체가 admin+대량 facility에 대해서만 재현 |
Rejected |
| H4 | admin_team? 캐시 miss로 Rails.cache.fetch 자체가 느렸음 |
— | 캐시 fetch는 ms 이하, view=0.09ms와도 무관; db=17754.85가 대부분 |
Rejected |
| H5 | Serializer/N+1로 인한 지연 | event_object_id = event.event_objects.first는 eager_load 덕에 in-memory 접근이지만 여전히 12개 polymorphic type × targetable include 있음 (FeedRepository.default_joins) |
그러나 default_joins는 admin 경로에서 SearchResult로 감싼 뒤 별도 렌더 시점에 적용되며, serialization=555ms로 DB 시간 대비 3%; 로그의 db 필드가 SQL 시간을 정확히 반영 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 조치 없음(P1 아님). 단발성 200 응답이며 서비스 에러 없음. 다만 admin 사용자가 event 수가 많은 facility를 조회할 때 재현 가능한 UX 저하이므로 아래 단기 개선을 권장.
단기 개선 (1주 이내)#
events테이블에 복합 인덱스 추가:(facility_id, eventable_type, created_at DESC)또는 최소(facility_id, created_at). 대상 파일:db/migrate/신규 마이그레이션. 근거:feed_repository.rb:169-177쿼리 shape가facility_id + eventable_type IN (...)filter +created_at DESCsort +LIMIT이며, 현 인덱스는 filter만 커버하고 sort는 filesort로 처리됨 (db/schema.rb:1931-1939).paginate의 COUNT를 줄이기 위해 admin 경로에서total_entries필요성을 재검토. 필요 없으면paginate(..., total_entries: nil)또는 페이지네이션 방식을 offset → keyset(cursor)로 전환하여 대용량 facility에서도 stable latency 확보.- Ruby side:
event.event_objects.first가eager_load(:event_objects)결과에서 in-memory로 첫 element를 선택하도록 보장돼 있는지 확인 (feed_repository.rb:37-42)..first는 has_many 로딩 상태에 따라 재쿼리를 유발할 수 있음 —event.event_objects.to_a.first또는event.event_objects.loaded? ? event.event_objects[0] : ...형태 검토.
장기 개선 (재발 방지)#
- Admin path가 왜 ES를 쓰지 않고 MySQL로 fallback하는지 재검토:
_search경로는 이미event.team.id/event.user.id필터로 admin-team 콘텐츠를 제외하며, admin 전용 요구사항(모든 팀 event 조회)이 확실하다면 별도 ES alias + 전용 admin index 를 두는 편이 대량 facility에서도 sub-second를 유지한다. - APM에서
Api::V1::FeedsController#indexp95 SLO 및 db time budget 정의. facility별 event 수 폭증(예: 자동 create) 상황 감시. FEED_EVENTABLE_MODELpolymorphic type 확장 시 성능 리뷰 필수.eventable_typecardinality가 낮아 IN 필터의 인덱스 선택성이 약함을 감안한 설계.
Monitoring#
Datadog dashboard (release timeseries widget) 용 쿼리 — writing-datadog-monitoring-queries skill 규칙 준수 (monitor 전용 문법 미사용):
Feed index p95 latency:
p95:trace.rails.request.duration{service:cupixworks-api,resource_name:Api::V1::FeedsController#index}
Feed index high-latency 요청 rate (>5s):
sum:trace.rails.request.hits{service:cupixworks-api,resource_name:Api::V1::FeedsController#index,duration:>5000000000}.as_count()
Feed index DB time contribution:
avg:trace.rails.request.duration{service:cupixworks-api,resource_name:Api::V1::FeedsController#index}
Events 테이블 쿼리 시간 (postgresql 대신 mysql source가 있으면 해당 metric 사용):
avg:mysql.performance.query_run_time_avg{service:cupixworks-api}
Risk Assessment#
- Risk level: low (에러 아님, 200 성공. 단일 발생. UX 영향은 admin 사용자에게 국한)
- 예상 복합도: standard (인덱스 추가 마이그레이션 + 재현/벤치. 대용량
events테이블에 인덱스 추가 시 online DDL 여부 확인 필요)