ES /docs

Api::V1::Admin::StatisticsController#show (avg 303826ms, max 303826ms)

RCA: Api::V1::Admin::StatisticsController#show 지연 (약 304 s)

Overview#

What Happened#

2026-07-25 18:51:34 KST 에 production cupixworks-api 에서 GET /api/v1/admin/statistics 요청 한 건이 303 826 ms (약 5분 4초) 동안 실행되었다. 응답 자체는 HTTP 200 으로 종료되었으나 (Datadog log [200] GET /api/v1/admin/statistics) 지연 시간이 latency 임계값을 초과하여 error-sweeper 가 latency 클러스터로 감지했다. 동일 30 초 창(window)에서 ApiController#index 도 15 678 ms 지연 클러스터로 함께 감지되었다.

Quick Facts#

Field Value
resource_name Api::V1::Admin::StatisticsController#show
service cupixworks-api
cluster_type latency
avg_duration_ms 303826
max_duration_ms 303826
sample_trace_id 6132879546781391823
env production, us-west-2
tenant cupix

Affected Teams#

Team / Domain Error Count Impact
cupixworks-api (admin console) 1 Admin 통계 조회 UX 지연 — 5분간 요청 응답 대기, 앱 서버 puma worker 1개 점유

Timeline#

  1. 2026-07-25 18:51:34 KST — 슬로우 요청 시작 (cluster first_seen, trace 6132879546781391823).
  2. 2026-07-25 18:51:39 KST — 관련 클러스터 6227ed5a-02f5-4e33-92f2-440dadc62eec 발생.
  3. 2026-07-25 18:52:03 KST — 관련 클러스터 e341fab5-5962-4a91-b6e2-c143a092d5cf (ApiController#index 15.7 s) 발생, incident 자동 open (2026-07-25-svc-cupixworks-api--unknown-1).
  4. 2026-07-25 18:56:38 KST — 요청 완료 (303826 ms 후, HTTP 200).
  5. 2026-07-25 18:52:03 KST — incident 자동 resolve (같은 fingerprint 신규 발생 없음).

Error Log#

Datadog Logs

text
{
  "resource_name": "Api::V1::Admin::StatisticsController#show",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 303826,
  "max_ms": 303826,
  "sample_trace_id": "6132879546781391823"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-07-25 18:51:34 KST
  • 최근 발생: 2026-07-25 18:51:34 KST

Root Cause Summary#

Admin::StatisticRepository#searchstatistics 테이블에 대해 필터를 optional 로만 적용한 뒤 will_paginate.paginate(per_page:, page:) 를 호출한다. 이 경로에는 두 가지 지연 요인이 겹친다. 첫째, response = self.class.default_joins(::Statistic)left_joins(:user) 를 항상 수행하므로 대용량 polymorphic 테이블 statistics 전체가 users 와 LEFT JOIN 된다. 둘째, will_paginateSELECT COUNT(*) 를 별도로 실행하는데, 필터가 없거나 선택도(selectivity)가 낮은 파라미터로 호출되면 이 count 쿼리가 전체 테이블 스캔이 된다. statistics 테이블은 Capture, Editing, Sitetrack, Deviation, User, Voxels, RecordStatus 등 7 개 이상 모델의 start_statistic/finish_statistic/error_statistic 호출로 쌓이는 write-heavy 테이블이라 프로덕션에서 수천만 행 규모로 커진다. 이 특정 요청은 count + 스캔이 5분 이상 걸린 것으로 판단되며, 같은 창에서 ApiController#index 도 15.7 s 지연을 보인 점 (e341fab5 클러스터) 은 이 시점 DB 리소스가 함께 눌렸을 가능성을 시사한다 — uncertain, needs verification.

Technical Analysis#

Code Path#

  • Entry: app/controllers/api/v1/admin/statistics_controller.rb:4
  • Repository: app/repositories/admin/statistic_repository.rb:7
  • Failure point (slow): app/repositories/admin/statistic_repository.rb:28-31 (paginate on unordered relation)
app/controllers/api/v1/admin/statistics_controller.rb:4-13ruby
def show
  statistic_query_option = Cupix::QueryOption::Statistic.new(get_query_option(enable_current_team: false), params)
  statistics = repository_instance.search(statistic_query_option)

  render_api Renderable.new({
    search_result: statistics,
    is_collection: true,
    serializer_option: @serializer_option
  })
end
app/repositories/admin/statistic_repository.rb:7-44ruby
def search(query_option = nil)
  set_query_option(query_option)

  response = self.class.default_joins(::Statistic)  # left_joins(:user) — 항상 수행

  if self.query_option.user_ids.present?
    response = response.where(user_id: self.query_option.user_ids)
  end

  if self.query_option.names.present?
    response = response.where(name: self.query_option.names)
  end

  if self.query_option.phase_names.present?
    response = response.where(phase: self.query_option.phase_names)
  end

  if query_option.statisticable_id.present? && query_option.statisticable_type.present?
    response = response.where(statisticable_id: self.query_option.statisticable_id, statisticable_type: self.query_option.statisticable_type)
  end

  response = response.paginate(  # ORDER BY 없이 페이지네이션 — will_paginate 가 별도 COUNT(*) 실행
    per_page: self.query_option.per_page,
    page: self.query_option.page
  )

  SearchResult.new({
    contents: response,
    pagination: {
      total_entries: response.total_entries,
      total_pages: response.total_pages,
      per_page: response.per_page,
      previous_page: response.previous_page,
      current_page: response.current_page,
      next_page: response.next_page
    }
  })
end

statistics 테이블 스키마 (인덱스):

db/schema.rb — statisticsruby
create_table "statistics", ... do |t|
  t.datetime "created_at", null: false
  t.string "name"
  t.string "phase"
  t.bigint "statisticable_id"
  t.string "statisticable_type"
  t.datetime "updated_at", null: false
  t.bigint "user_id"
  t.index ["created_at"], name: "index_statistics_on_created_at"
  t.index ["name"], name: "index_statistics_on_name"
  t.index ["phase"], name: "index_statistics_on_phase"
  t.index ["statisticable_type", "statisticable_id"], name: "index_statistics_on_statisticable_type_and_statisticable_id"
  t.index ["user_id"], name: "index_statistics_on_user_id"
end

기대 동작 vs 실제 동작:

  • 기대: 관리자 API 이므로 sub-초 응답으로 최근 통계 30건 (default_per_page = 30) 반환.
  • 실제: 요청이 303 826 ms 동안 실행됨. will_paginateSELECT COUNT(*) FROM statistics LEFT JOIN users ... 를 필터 없이(또는 낮은 selectivity 필터로) 실행하면 폭이 넓은 polymorphic 테이블 전체를 훑는다. LEFT JOIN 은 필요 없을 때에도 항상 붙는다 (default_joins).

Log Evidence#

Datadog 쿼리 (재현용):

text
service:cupixworks-api "/api/v1/admin/statistics"

incident 창 로그 (KST 표기, Datadog 이 이미 KST 로 렌더):

text
2026-07-25 18:51:34  info  [200] GET /api/v1/admin/statistics (Api::V1::Admin::StatisticsController#show)
2026-07-25 18:56:38  info  [200] GET /api/v1/admin/statistics (Api::V1::Admin::StatisticsController#show)

동시 창 error 검색 결과 — 0건:

text
Query: service:cupixworks-api status:error
Time: 2026-07-25T09:45:00 → 2026-07-25T10:00:00
Result: Found 0 logs

에러/예외는 없고 응답 코드는 200 이므로 이 클러스터는 순수 latency 이슈다.

Status board (incident grouping) — 동일 30 초 창에서 3 개 클러스터가 같은 서비스에 열림:

json
{
  "id": "2026-07-25-svc-cupixworks-api--unknown-1",
  "started_at": "2026-07-25T09:51:34.302Z",
  "resolved_at": "2026-07-25T09:52:03.330Z",
  "cluster_ids": [
    "83691355-5b5c-4ea2-800c-b04cb6a89ae9",
    "6227ed5a-02f5-4e33-92f2-440dadc62eec",
    "e341fab5-5962-4a91-b6e2-c143a092d5cf"
  ]
}

동반 클러스터 e341fab5ApiController#index 가 15 678 ms — 별개 endpoint 지만 같은 시점 DB 부하 영향 가능성 있음 (uncertain — needs verification via APM span).

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 필터 없는 statistics 테이블 스캔 + will_paginate COUNT(*) 가 폭 넓은 polymorphic 테이블에서 폭발 statistic_repository.rb:12-26 모든 where 절이 .present? 조건부; will_paginate 3.3.1total_entries 위해 COUNT(*) 필수; 7 개 이상 모델이 Statisticable include (app/models/concerns/statisticable.rb:7) 로 인해 테이블 volume 이 큼 요청 파라미터를 로그에서 확인 불가 (info 레벨에서 query string 미포함) Likely / needs param confirmation
H2 LEFT JOIN users 로 인한 조인 폭발 default_joins(::Statistic) 이 항상 left_joins(:user) 실행 (statistic_repository.rb:63); statistics.user_id 는 nullable user_id 컬럼에 인덱스 있음 (index_statistics_on_user_id); LEFT JOIN 만으로 5 분 걸리는 것은 count 문제 없이는 설명 어려움 Contributing, not primary
H3 DB 전체 성능 저하 (다른 slow query 로 인한 리소스 경합) 같은 30 초 창에 ApiController#index 15.7 s 지연 클러스터 (e341fab5) 공존 status:error 0 건, postgresql.* 메트릭 Datadog 에 등록되어 있지 않아 확증 불가 Inconclusive
H4 외부 dependency 장애 status board scope 이 svc:* (dep 아님), dep 관련 클러스터 없음 Rejected
H5 대량 페이지 요청 (per_page=300 또는 page=99999 로 deep offset) will_paginate 는 offset 이 크면 LIMIT ... OFFSET N 로 앞 N행 스캔 요청 파라미터를 확인할 수 없음 (info 레벨에서 query string 미포함) Possible / needs verification

Fix Recommendation#

즉시 조치 (Critical)#

  • app/repositories/admin/statistic_repository.rb:28-31 페이지네이션 직전에 명시적 ORDER BY 를 추가하여 실행 계획을 안정화. default_sort (id: desc) 를 base repository 처럼 적용하는 방향.
  • app/repositories/admin/statistic_repository.rb:63 default_joins 에서 left_joins(:user) 를 optional 로 바꾸는 방향 검토. 이 endpoint 의 응답이 실제로 user 를 필요로 하는지 (StatisticSerializer_user 참조) 확인 후, includes 로 preload 하거나 필터가 user_id 를 요구할 때만 join.
  • Admin controller 에 관리자 전용 timeout guard (예: rack-timeout 또는 puma worker timeout) 를 검토. 5 분간 puma worker 를 점유하는 것은 다른 admin 요청에도 영향.

단기 개선 (1주 이내)#

  • will_paginate 대신 paginate + explicit total_entries: nil 옵션이나 count 를 별도 캐시로 분리하는 접근 검토. 관리자 목록은 정확한 total 이 반드시 필요하지 않은 경우가 많다.
  • Cupix::QueryOption::Statistic 에 필수 필터를 강제 (예: statisticable_type, phase_names, 또는 created_at 범위) — 관리자 UI 에서 필터 없이 호출되는 경로가 있으면 프런트엔드와 협의하여 기본 필터 적용.

장기 개선 (재발 방지)#

  • statistics 테이블 보존 정책 검토. Statisticable 은 사실상 이벤트 로그이므로 오래된 행을 아카이브하거나 파티션 (created_at 기준 monthly partition) 도입.
  • Admin API 에도 APM slow query 알림 (예: resource_name 별 p99 > 5 s) 을 추가하여 이런 latency 를 사후가 아닌 실시간으로 감지.

Monitoring#

  • Admin 통계 endpoint p99 지연 timeseries. Release dashboard widget 용 쿼리:
text
p99:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::admin::statisticscontroller#show}
  • 5 s 초과 요청 rate:
text
sum:trace.rack.request.hits{service:cupixworks-api,resource_name:api::v1::admin::statisticscontroller#show}.as_rate()
  • 서비스 전체 slow request 관찰 (동반 endpoint 감시):
text
p99:trace.rack.request.duration{service:cupixworks-api} by {resource_name}

Risk Assessment#

  • Risk level: medium (단일 발생, error 없음. 그러나 admin puma worker 5 분 점유는 재발 시 다른 admin 기능 영향)
  • 예상 복잡도: standard (repository 에 order 추가 + join 조건화; 스키마 변경 없이 가능)