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#
- 2026-05-26T03:42:18Z — TasksController#index 요청 처리 시작, 1012ms 소요 (serialization 642ms)
- 2026-05-26T03:51:31Z — 동일 패턴 반복 발생, 1085ms 소요 (serialization 759ms)
- 2026-05-26T05:00:00Z — error-sweeper 감지 및 클러스터 생성
Error Log#
{
"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:
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:
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:
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):
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에서 확인한 실제 요청 로그:
service:cupixworks-api resource_name:"Api::V1::TasksController#index" env:production @http.status_code:200
핵심 로그 (2026-05-26T03:42:18.981Z):
{
"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):
{
"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로 정상 응답함을 확인:
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 시간 비율 모니터링:
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가 비례하여 악화될 구조적 문제이므로 선제적 개선이 권장됨.