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#
- 2026-07-25 18:51:34 KST — 슬로우 요청 시작 (cluster
first_seen, trace6132879546781391823). - 2026-07-25 18:51:39 KST — 관련 클러스터
6227ed5a-02f5-4e33-92f2-440dadc62eec발생. - 2026-07-25 18:52:03 KST — 관련 클러스터
e341fab5-5962-4a91-b6e2-c143a092d5cf(ApiController#index15.7 s) 발생, incident 자동 open (2026-07-25-svc-cupixworks-api--unknown-1). - 2026-07-25 18:56:38 KST — 요청 완료 (
303826 ms후, HTTP 200). - 2026-07-25 18:52:03 KST — incident 자동 resolve (같은 fingerprint 신규 발생 없음).
Error Log#
{
"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#search 는 statistics 테이블에 대해 필터를 optional 로만 적용한 뒤 will_paginate 의 .paginate(per_page:, page:) 를 호출한다. 이 경로에는 두 가지 지연 요인이 겹친다. 첫째, response = self.class.default_joins(::Statistic) 이 left_joins(:user) 를 항상 수행하므로 대용량 polymorphic 테이블 statistics 전체가 users 와 LEFT JOIN 된다. 둘째, will_paginate 는 SELECT 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)
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
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 테이블 스키마 (인덱스):
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_paginate가SELECT COUNT(*) FROM statistics LEFT JOIN users ...를 필터 없이(또는 낮은 selectivity 필터로) 실행하면 폭이 넓은 polymorphic 테이블 전체를 훑는다. LEFT JOIN 은 필요 없을 때에도 항상 붙는다 (default_joins).
Log Evidence#
Datadog 쿼리 (재현용):
service:cupixworks-api "/api/v1/admin/statistics"
incident 창 로그 (KST 표기, Datadog 이 이미 KST 로 렌더):
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건:
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 개 클러스터가 같은 서비스에 열림:
{
"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"
]
}
동반 클러스터 e341fab5 는 ApiController#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.1 는 total_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:63default_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+ explicittotal_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 용 쿼리:
p99:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::admin::statisticscontroller#show}
- 5 s 초과 요청 rate:
sum:trace.rack.request.hits{service:cupixworks-api,resource_name:api::v1::admin::statisticscontroller#show}.as_rate()
- 서비스 전체 slow request 관찰 (동반 endpoint 감시):
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 조건화; 스키마 변경 없이 가능)