ES /docs

Api::V1::TasksController#index (avg 1016ms, max 1016ms)

RCA: Api::V1::TasksController#index Latency (1016ms)

Overview#

What Happened#

2026-05-26 03:42 UTC에 ap-southeast-2 리전의 cupixworks-api 서비스에서 Api::V1::TasksController#index 엔드포인트가 1016ms 응답 시간을 기록했다. 정상 응답 시간 대비 약 3배 느린 응답으로, serialization 단계에서 전체 시간의 63%를 소비했다.

Quick Facts#

Field Value
resource_name Api::V1::TasksController#index
top_frame app/controllers/api/v1/tasks_controller.rb:15
runtime Ruby on Rails
deploy production-ap-southeast-2-20260526t0034z0-3e770a15-cupixworks
env production, ap-southeast-2

Timeline#

  1. 2026-05-26T03:42:18Z — TasksController#index 요청 처리 시작, 1012ms 소요 (serialization 642ms)
  2. 2026-05-26T03:51:31Z — 동일 패턴 반복 발생, 1085ms 소요 (serialization 759ms)
  3. 2026-05-26T05:00:00Z — error-sweeper 감지 및 클러스터 생성

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::TasksController#index",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 1016,
  "max_ms": 1016,
  "sample_trace_id": "102654981646681083"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-05-26T03:42:16.511Z
  • 최근 발생: 2026-05-26T03:42:16.511Z
  • 영향 범위: ap-southeast-2 리전, built 팀 (team_id=16), 340개 task 레코드를 가진 facility

Root Cause Summary#

TasksController#index의 latency는 serialization 단계에서 300개 task 레코드의 연관 객체를 개별 cache lookup으로 처리하는 구조에 기인한다. TaskSerializer가 각 task마다 8개의 연관 객체(_user, _team, _workspace, _facility, _workarea, _category, _vendor, _phase)를 fetch_cache() 메서드로 조회하며, cache miss 시 개별 DB 쿼리가 발생한다. 300개 레코드 × 8개 연관 = 최대 2,400회의 cache lookup이 수행되어 serialization에만 642ms가 소요되었다. DB 쿼리 자체는 38ms로 빠르지만, serialization이 전체 응답 시간의 63%를 차지했다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/tasks_controller.rb:15
  • Elasticsearch 검색: BaseRepository#search_search(query_option) (base_repository.rb:70-71)
  • Permission JOIN: TaskRepository.permission_joins — 11개 LEFT JOIN (task_repository.rb:40-68)
  • Serialization: TaskSerializer — 8개 연관 속성 각각 fetch_cache() 호출 (task_serializer.rb:7-24)

1. Controller entry point:

app/controllers/api/v1/tasks_controller.rb:15-24ruby
def index
  task_query_option = Cupix::QueryOption::Task.new(get_query_option, params)
  tasks = repository_instance.search(task_query_option)

  render_api Renderable.new({
    search_result: tasks,
    is_collection: true,
    serializer_option: @serializer_option
  })
end

render_api에서 TaskSerializer를 통해 300개 레코드의 직렬화가 시작된다.

2. BaseRepository#search — Elasticsearch + Permission JOIN:

app/repositories/base_repository.rb:70-112ruby
def search(query_option = nil)
  _search(query_option)

  begin
    if self.review.present?
      contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, review_id: self.review.id, skip_join: _skip_join?)
    else
      contents = self.class.permission_joins(self.class.default_joins(self.response.records), self.current_user, skip_join: _skip_join?)
    end
  rescue Elasticsearch::Transport::Transport::Errors::BadRequest => e
    # ...error handling
  end

  SearchResult.new({
    contents: contents.records,
    aggregations: self.response.aggregations,
    pagination: { total_entries: self.response.total_entries, ... }
  })
end

Elasticsearch에서 반환된 레코드 ID 목록에 대해 11개 LEFT JOIN으로 permission을 계산한다. per_page=300일 때 이 JOIN이 300개 행에 적용된다.

3. TaskSerializer — N+1 패턴의 serialization:

app/serializers/task_serializer.rb:7-24ruby
attribute :user, &:_user
attribute :team do |task|
  task._team.as_json(only: %i[id name domain])
end
attribute :workspace do |task|
  task._workspace.as_json(only: %i[id name])
end
attribute :facility do |task|
  task._facility.as_json(only: %i[id name key])
end
attribute :workarea, &:_workarea
attribute :category do |task|
  task._category.as_json(only: %i[id name weight category_type])
end
attribute :vendor, &:_vendor
attribute :phase do |task|
  task._phase.as_json(only: %i[id name color_code texture])
end

_* 메서드는 ApplicationRecord에서 동적 정의된 fetch_cache() 호출로, Redis cache에서 조회하거나 miss 시 Model.find_by_id()로 DB 쿼리를 발행한다. 300개 task × 8개 연관 = 2,400회 cache lookup이 serialization 병목의 핵심이다.

4. Permission JOIN 구조 (11개 LEFT JOIN):

app/repositories/task_repository.rb:74-104ruby
record.joins("
  LEFT JOIN (
    SELECT reviews.id AS review_id, 2 AS permission
    FROM reviews
    where reviews.public_access_enabled_at IS NOT NULL
      AND reviews.id = #{sanitized_review_id}
  ) AS review_public_permissions
    ON review_public_permissions.review_id = #{sanitized_review_id}

  LEFT JOIN (
    SELECT review_id, permission
    FROM review_permissions
    WHERE review_permissions.accessor_id = #{sanitized_user_id}
      AND review_permissions.accessor_type = 'User'
      ...
  ) AS review_user_permissions
    ON review_user_permissions.review_id = #{sanitized_review_id}
  ...
")

DB 시간은 38ms로 빠르나, 11개 LEFT JOIN + GROUP BY + MAX/GREATEST 연산은 데이터 증가 시 비선형적으로 느려질 수 있다.

Log Evidence#

Datadog에서 확인한 실제 요청 로그:

text
service:cupixworks-api resource_name:"Api::V1::TasksController#index" env:production @http.status_code:200

핵심 로그 (2026-05-26T03:42:18.981Z):

json
{
  "timestamp": "2026-05-26T03:42:18.981Z",
  "host": "ip-10-1-145-251.ap-southeast-2.compute.internal",
  "duration_ms": 1012.13,
  "db_time_ms": 37.91,
  "serialization_time_ms": 642,
  "total_entries": 340,
  "current_page": 1,
  "per_page": 300,
  "team_domain": "built",
  "facility_key": "5io96",
  "user_id": 4037,
  "http_status": 200
}

9분 후 동일 패턴 반복 (2026-05-26T03:51:31.470Z):

json
{
  "timestamp": "2026-05-26T03:51:31.470Z",
  "duration_ms": 1084.92,
  "db_time_ms": 113.58,
  "serialization_time_ms": 759,
  "total_entries": 340,
  "current_page": 1,
  "per_page": 300
}

Page 2 요청 (40개 레코드)은 60-85ms로 정상 응답함을 확인:

text
page=2 records=40 duration_ms=62 serialization_ms=25

시간대 통계 (03:00-04:00 UTC, 34건):

  • 평균: 274ms, 중앙값: 319ms, P90: 480ms, 최대: 1085ms
  • Serialization이 전체 시간의 평균 58% 차지

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Serialization N+1 cache lookup 병목 — 300개 task × 8개 연관 객체의 개별 fetch_cache() 호출이 누적 latency 유발 serialization_time_ms=642 (63%), page 2(40건)는 25ms만 소요, 레코드 수와 선형 비례 DB time 38ms로 cache hit率이 높을 수 있음 Confirmed
H2 DB permission JOIN 쿼리 자체가 느림 — 11개 LEFT JOIN이 대량 데이터에서 성능 저하 두 번째 요청에서 db_time=113ms로 상승 첫 번째 요청 db_time=38ms, 전체 시간의 3.7%에 불과 Rejected
H3 GC pause 또는 Ruby 프로세스 경합 — 단일 요청에서 비정상적 지연 발생 1012ms 중 DB(38ms)+serialization(642ms)=680ms, 나머지 332ms가 미설명 반복 발생하며 레코드 수에 비례하므로 일시적 GC보다 구조적 문제 Inconclusive

Fix Recommendation#

즉시 조치 (Critical)#

  • app/serializers/task_serializer.rb:7-24 — 현재 8개 연관 객체를 개별 fetch_cache()로 조회하는 구조를 batch preload 방식으로 변경. serialization 시작 전에 해당 페이지의 모든 task에 대해 필요한 연관 ID를 수집하고 한 번에 cache multi-get 또는 DB batch query를 수행.
  • per_page 기본값을 300에서 100 이하로 줄여 즉각적인 latency 감소 효과를 얻을 수 있음.

단기 개선 (1주 이내)#

  • BaseRepository#search (base_repository.rb:70)에서 Elasticsearch 결과를 받은 후, includes(:user, :facility, :workspace, :team, :workarea, :category, :phase) 등 eager loading을 추가하여 N+1을 구조적으로 제거.
  • TaskSerializer_* 동적 메서드 대신 preloaded association을 직접 참조하는 방식으로 변경 검토.

장기 개선 (재발 방지)#

  • Permission JOIN 로직을 materialized view 또는 별도 permission cache layer로 분리하여, 페이지네이션 규모와 무관하게 일정한 성능 보장.
  • Serialization 성능 모니터링을 위한 custom metric 추가 (per-request serialization_time_ms threshold alert).
  • 대량 데이터를 가진 팀(340+ tasks)에 대한 pagination 전략 재검토 (cursor-based pagination 등).

Monitoring#

  • avg(trace.rack.request.duration){service:cupixworks-api, resource_name:Api::V1::TasksController#index} > 800ms 임계값 알림 추가
  • Serialization 시간 비율 모니터링:
text
service:cupixworks-api resource_name:"Api::V1::TasksController#index" @duration:>800
  • built 팀 (facility_key: 5io96) task 수 증가 추이 모니터링 — task 수 증가 시 latency 비례 악화 예상

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard
  • 현재 1건 발생이며 HTTP 200 정상 응답. 사용자 체감 지연은 있으나 기능 장애는 아님. 다만 built 팀의 task 수가 증가하면 latency가 비례하여 악화될 구조적 문제이므로 선제적 개선이 권장됨.