ES /docs

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#

  1. 2026-05-27T01:21:56ZSitetracksController#index 요청 시작 (Retool, grace.yoon@cupix.com)
  2. 2026-05-27T01:21:58Z — 응답 완료 (200 OK, 1177ms duration)
  3. 2026-05-27T01:29:21Z — 동일 엔드포인트에서 5795ms 극단적 스파이크 관찰
  4. 2026-05-27 — error-sweeper가 latency 클러스터로 감지

Error Log#

Datadog Logs

json
{
  "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.rbStatAttribute 포함
  • N+1 statistics: app/models/concerns/statisticable/sitetrack.rb:10-16

1단계: Controller → Repository 호출

app/controllers/api/v1/admin/sitetracks_controller.rb:6-15ruby
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 실행

app/repositories/base_repository.rb:70-98ruby
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 집계 (핵심 병목)

app/repositories/sitetrack_repository.rb:53-81ruby
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 idGREATEST() 집계를 수행한다. 20개 레코드에 대해서도 이 전체 쿼리가 한 번에 실행된다.

4단계: N+1 Statistics 쿼리 (직렬화 단계)

app/models/concerns/statisticable/sitetrack.rb:9-17ruby
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_statisticstatistics.group(:name, :phase).maximum(:created_at)을 호출하고, error_statistics.count는 별도 COUNT 쿼리를 실행한다. 20건의 sitetrack에 대해 최소 40회의 추가 DB 쿼리가 발생한다.

Log Evidence#

Datadog 검색 쿼리:

text
service:cupixworks-api "SitetracksController" @duration:>500

해당 요청의 실제 로그 항목:

json
{
  "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 기준):

text
| 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-227permission_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#index p95/p99 latency 알림 추가 (임계값: 800ms)
  • Permission_joins 실행 시간 측정을 위한 custom instrumentation 추가
text
service:cupixworks-api "SitetracksController" @duration:>1000

Risk Assessment#

  • Risk level: medium
  • 예상 복잡도: standard — Admin 전용 permission skip은 기존 skip_join 메커니즘을 활용하면 비교적 안전하게 적용 가능. N+1 해소는 eager loading 추가로 해결 가능하나 메모리 사용량 증가에 주의 필요.