Api::V1::Admin::SitetracksController#index (avg 1179ms, max 1179ms)
RCA: Api::V1::Admin::SitetracksController#index Latency (1179ms)
Overview#
What Happened#
2026-05-27 01:21:56 UTC에 cupixworks-api 서비스의 Api::V1::Admin::SitetracksController#index 엔드포인트가 1179ms 응답 시간을 기록했다. us-west-2 리전에서 Retool 대시보드를 통해 admin 사용자가 빈 필터로 sitetrack 목록을 조회할 때 발생했으며, 10,000건의 total_entries를 반환하면서 DB 시간(60.9ms)과 직렬화 시간(87ms)을 크게 초과하는 전체 응답 시간이 관찰되었다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::Admin::SitetracksController#index |
| top_frame | app/repositories/base_repository.rb:70 |
| env | production, us-west-2 |
| duration | 1179ms (DB: 60.9ms, serialization: 87ms) |
| user_agent | Retool/2.0 |
Timeline#
- 2026-05-27T01:21:56Z —
SitetracksController#index요청 시작 (Retool, grace.yoon@cupix.com) - 2026-05-27T01:21:58Z — 응답 완료 (200 OK, 1177ms duration)
- 2026-05-27T01:29:21Z — 동일 엔드포인트에서 5795ms 극단적 스파이크 관찰
- 2026-05-27 — error-sweeper가 latency 클러스터로 감지
Error Log#
{
"resource_name": "Api::V1::Admin::SitetracksController#index",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 1179,
"max_ms": 1179,
"sample_trace_id": "4976055434690249282"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1 (단일 클러스터 이벤트, 하지만 반복 패턴 확인됨)
- 최초 발생: 2026-05-27T01:21:56.074Z
- 최근 발생: 2026-05-27T01:21:56.074Z
- 영향 범위: Retool 기반 admin 대시보드 사용자 (grace.yoon@cupix.com). 36시간 동안 동일 엔드포인트에서 1000ms 초과 응답 9건 확인됨.
Root Cause Summary#
SitetracksController#index의 1179ms 지연은 11개의 LEFT JOIN을 포함한 복잡한 permission 쿼리(permission_joins)와 직렬화 단계에서의 N+1 쿼리 패턴이 복합적으로 작용하여 발생했다. Datadog 로그에서 DB 시간이 60.9ms로 기록되었지만, 이는 Elasticsearch 검색 쿼리만 측정한 것이며, 이후 실행되는 permission_joins의 복잡한 SQL(11개 LEFT JOIN + GROUP BY + GREATEST 집계)과 직렬화 시 statistics 테이블에 대한 레코드별 개별 쿼리(N+1)가 나머지 ~1030ms를 차지한다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/admin/sitetracks_controller.rb:6 - Repository search:
app/repositories/base_repository.rb:70 - Elasticsearch query:
_search(query_option)→ Elasticsearch에서 20건의 ID 반환 - Permission joins:
app/repositories/sitetrack_repository.rb:53-227 - Serialization:
app/serializers/sitetrack_serializer.rb→StatAttribute포함 - N+1 statistics:
app/models/concerns/statisticable/sitetrack.rb:10-16
1단계: Controller → Repository 호출
def index
sitetracks = repository_instance.search(
Cupix::QueryOption::Sitetrack.new(get_query_option(enable_current_team: false), params)
)
render_api Renderable.new(
search_result: sitetracks,
is_collection: true,
serializer_option: @serializer_option.merge(params: { current_user: current_user })
)
end
Admin 엔드포인트는 enable_current_team: false로 전체 팀에 대한 sitetrack을 조회한다.
2단계: Elasticsearch 검색 후 Permission JOIN 실행
def search(query_option = nil)
_search(query_option)
begin
# ...
contents = self.class.permission_joins(
self.class.default_joins(self.response.records),
self.current_user, skip_join: _skip_join?
)
end
end
Elasticsearch에서 반환된 레코드 ID들을 기반으로 DB에서 재조회하면서 11개의 permission LEFT JOIN을 수행한다.
3단계: 11개 LEFT JOIN + GROUP BY + GREATEST 집계 (핵심 병목)
def self.permission_joins(record, current_user, select: nil, review_id: -1, **kwargs)
_select = "
MAX(facility_user_permissions.permission) AS facility_user_permission,
MAX(facility_group_permissions.permission) AS facility_group_permission,
MAX(facility_system_group_permissions.permission) AS facility_system_group_permission,
MAX(workspace_user_permissions.permission) AS workspace_user_permission,
MAX(workspace_group_permissions.permission) AS workspace_group_permission,
MAX(team_user_permissions.permission) AS team_user_permission,
MAX(team_group_permissions.permission) AS team_group_permission,
MAX(team_system_group_permissions.permission) AS team_system_group_permission,
MAX(review_user_permissions.permission) AS review_user_permission,
MAX(review_group_permissions.permission) AS review_group_permission,
MAX(review_public_permissions.permission) AS review_public_permission,
MAX(GREATEST(
IFNULL(facility_user_permissions.permission, 0),
...
)) AS applied_permission"
11개의 LEFT JOIN 서브쿼리가 각각 grouped_users 테이블을 참조하며, 최종 GROUP BY id와 GREATEST() 집계를 수행한다. 20개 레코드에 대해서도 이 전체 쿼리가 한 번에 실행된다.
4단계: N+1 Statistics 쿼리 (직렬화 단계)
def statistic_to_json
stats = all_statistic
{
processing: _processing_stat_json(stats),
duration: _duration_stat_json(stats),
error: _error_stat_json
}
end
def _error_stat_json
{
error_count: error_statistics.count
}
end
all_statistic은 statistics.group(:name, :phase).maximum(:created_at)을 호출하고, error_statistics.count는 별도 COUNT 쿼리를 실행한다. 20건의 sitetrack에 대해 최소 40회의 추가 DB 쿼리가 발생한다.
Log Evidence#
Datadog 검색 쿼리:
service:cupixworks-api "SitetracksController" @duration:>500
해당 요청의 실제 로그 항목:
{
"timestamp": "2026-05-27T01:21:58.430Z",
"duration_ms": 1177.01,
"controller": "Api::V1::Admin::SitetracksController",
"action": "index",
"method": "GET",
"path": "/api/v1/admin/sitetracks",
"status": 200,
"host": "ip-10-1-19-190.us-west-2.compute.internal",
"user_agent": "Retool/2.0 (+https://docs.tryretool.com/docs/apis)",
"user": "grace.yoon@cupix.com",
"db_ms": 60.9,
"serialization_ms": 87,
"total_entries": 10000,
"per_page": 20,
"total_pages": 500,
"params_filter": "",
"params_order_by": "created_at",
"params_sort": "desc",
"params_fields": ["team", "facility", "stat"]
}
36시간 동안의 동일 엔드포인트 1000ms 초과 패턴 (Datadog 기준):
| Timestamp | Duration | total_entries | db_ms | ser_ms | User Agent |
|----------------------|----------|---------------|--------|--------|------------|
| 2026-05-27T02:20:16Z | 1088ms | 101 | 18.2 | 52 | Chrome |
| 2026-05-27T01:29:21Z | 5795ms | 172 | 7.57 | 4 | Retool |
| 2026-05-27T01:21:58Z | 1177ms | 10000 | 60.9 | 87 | Retool |
| 2026-05-27T00:34:33Z | 1094ms | 457 | 43.4 | 8 | Retool |
| 2026-05-26T20:04:21Z | 1024ms | 1 | 27.7 | 0 | Retool |
| 2026-05-26T05:54:57Z | 2083ms | 710 | 248.9 | 902 | Chrome |
특이점: total_entries=1이거나 total_entries=172인 경우에도 1000ms+ 지연이 발생하여, permission_joins의 복잡성이 결과 수와 무관하게 지연을 유발함을 확인.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Permission JOIN 복잡성 — 11개 LEFT JOIN + GROUP BY가 주요 병목 | sitetrack_repository.rb:53-227에서 11개 서브쿼리 확인. total_entries=1일 때도 1024ms 지연 발생 (DB 크기와 무관). Datadog에서 db_ms(60.9ms)가 Elasticsearch 쿼리만 측정하고 permission_joins는 별도 측정되지 않음 |
— | Confirmed |
| H2 | N+1 Statistics 쿼리 — serializer에서 레코드별 statistics 쿼리 | statisticable/sitetrack.rb:10에서 all_statistic 호출, _error_stat_json에서 별도 COUNT. Chrome 요청에서 serialization 902ms 기록 |
Admin 인시던트에서 serialization은 87ms로 상대적으로 낮음 (fields 파라미터로 stat 포함 여부에 따라 달라짐) | Confirmed (contributing) |
| H3 | Elasticsearch 검색 자체가 느림 | — | db_ms 60.9ms로 정상 범위. Elasticsearch 에러 로그 없음 | Rejected |
| H4 | GVL/Thread Pool 경합 — Ruby 프로세스 블로킹 | 5795ms 스파이크에서 DB 7.57ms, serialization 4ms로 프로세스 블로킹 시사 | 1179ms 인시던트에서는 DB+ser가 148ms로 나머지를 permission_joins로 설명 가능 | Inconclusive (5795ms 케이스만 해당) |
| H5 | Pagination COUNT 쿼리 병목 | total_entries=10000이 반복적으로 관찰됨 | Elasticsearch가 pagination을 처리하므로 별도 SQL COUNT 없음. total_entries는 ES 결과의 total_count | Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
app/repositories/sitetrack_repository.rb:53-227의permission_joins메서드: Admin 컨트롤러에서는 이미 admin 권한이 확인된 사용자이므로, Admin 엔드포인트 전용으로 permission_joins를 skip하는 옵션을 추가한다.skip_join파라미터가 이미 존재하므로 (_skip_join?메서드), Admin 컨트롤러에서 이를 활성화하는 방향.app/controllers/api/v1/admin/sitetracks_controller.rb: Admin 사용자는 모든 리소스에 접근 가능하므로applied_permission을 하드코딩하고 permission_joins를 생략할 수 있다.
단기 개선 (1주 이내)#
app/models/concerns/statisticable/sitetrack.rb:statistics연관에 대해 eager loading을 추가한다.default_joins메서드에.includes(:statistics, :error_statistics)를 포함시켜 N+1을 해소한다.app/serializers/sitetrack_serializer.rb:stat필드가 요청된 경우에만 statistics 쿼리를 실행하도록 조건부 로딩을 구현한다 (현재fields파라미터로 필드 선택이 가능하므로 이를 활용).
장기 개선 (재발 방지)#
- Permission 계산을 materialized view 또는 캐시 레이어로 분리. 현재 매 요청마다 11개 JOIN을 실행하는 것은 확장 불가능.
- Admin 엔드포인트와 일반 사용자 엔드포인트의 Repository를 완전히 분리하여 admin은 permission check 없이 직접 조회하도록 아키텍처 변경.
- Retool 대시보드의 polling 주기를 조정하거나, 서버 측에서 admin list 결과를 Redis에 단기 캐시하여 반복 호출의 부하를 감소.
Monitoring#
SitetracksController#indexp95/p99 latency 알림 추가 (임계값: 800ms)- Permission_joins 실행 시간 측정을 위한 custom instrumentation 추가
service:cupixworks-api "SitetracksController" @duration:>1000
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard — Admin 전용 permission skip은 기존
skip_join메커니즘을 활용하면 비교적 안전하게 적용 가능. N+1 해소는 eager loading 추가로 해결 가능하나 메모리 사용량 증가에 주의 필요.