ES /docs

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 동안 Api::V1::FeedsController#index 엔드포인트에서 평균 2063ms, 최대 35,273ms의 응답 지연이 발생했다. 피드 수가 많은 대규모 facility를 가진 팀(downergroup: 9,264건, umedafm: 2,640건)에서 DB 쿼리 시간이 전체 응답의 9599.6%를 차지하며 심각한 성능 저하가 확인되었다.

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#

  1. 2026-05-26T03:49:18Z — 최초 감지 (aceplp 팀, ap-southeast-1)
  2. 2026-05-26T06:38:58Z — 최대 지연 발생 (umedafm 팀, 35,273ms)
  3. 2026-05-26T08:40:19Z — 대규모 시설 지연 (downergroup 팀, 18,857ms)
  4. 2026-05-26T13:24:04Z — 마지막 감지

Error Log#

Datadog Logs

json
{
  "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:2index action 호출
  • 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-177Failure point
app/controllers/api/v1/feeds_controller.rb:2-11ruby
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)로 진입한다:

app/repositories/feed_repository.rb:30-55ruby
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

핵심 문제 쿼리:

app/repositories/feed_repository.rb:169-177ruby
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 패턴:

text
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 쿼리:

text
service:cupixworks-api "FeedsController" "index"

시간 범위: 2026-05-26T02:49:18Z ~ 2026-05-26T13:24:04Z

극단적 지연 사례:

json
{
  "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"
}
json
{
  "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 — 정상 수준

에러/워닝 로그 검색:

text
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_loadincludes 변경은 1줄 수정이나 동작 확인 필요, 인덱스 추가는 마이그레이션 필요