ElementTracesController#index N+1 Redis lookups — missing eager loads
RCA: ElementTracesController#index Latency (avg 1282ms, max 1934ms)
Overview#
What Happened#
2026-05-26 03:21~06:09 UTC 동안 Api::V1::ElementTracesController#index 엔드포인트에서 평균 1282ms, 최대 1934ms의 응답 지연이 4개 리전(us-west-2, ap-southeast-1, ap-northeast-1, ap-southeast-2)에 걸쳐 46건 발생했다. DB 처리 시간은 평균 14ms(전체의 1.5%)에 불과하며, 나머지 98.5%의 지연은 application layer에서 발생한다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::ElementTracesController#index |
| top_frame | app/repositories/base_repository.rb:70-112 |
| env | production (us-west-2, ap-southeast-1, ap-northeast-1, ap-southeast-2) |
Timeline#
- 2026-05-26T03:21:20Z — 최초 slow request 감지 (ap-southeast-2)
- 2026-05-26T06:09:01Z — 마지막 slow request 기록
- 2026-05-26 — RCA 분석 완료
Error Log#
{
"resource_name": "Api::V1::ElementTracesController#index",
"service": "cupixworks-api",
"occurrences": 46,
"avg_ms": 1282,
"max_ms": 1934,
"sample_trace_id": "2420665675104782183"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 46
- 최초 발생: 2026-05-26T03:21:20.106Z
- 최근 발생: 2026-05-26T06:09:01.263Z
Root Cause Summary#
ElementTracesController#index의 latency는 serialization 단계에서 각 ElementTrace 레코드의 association을 Redis cache로 개별 조회하는 fetch_cache 패턴에 기인한다. per_page=300과 14개 필드 조합 시 레코드당 최대 9회의 Redis roundtrip이 발생하여 최대 2700회의 cache lookup이 수행된다. default_joins는 :element, :task만 eager loading하므로 나머지 7개 association(category, phase, workarea, vendor, status, estimated_status, sitetrack)은 모두 Redis fetch_cache fallback을 거친다. Cache miss 시에는 개별 DB query(find_by_id)까지 실행되어 latency가 급증한다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/element_traces_controller.rb:11 - ES search:
app/repositories/element_trace_repository.rb:198-307 - Permission/Joins:
app/repositories/base_repository.rb:70-81 - Serialization:
app/serializers/element_trace_serializer.rb:22-53 - Cache fetch:
app/models/application_record.rb:84-94 - Cache implementation:
app/models/concerns/cachable.rb:58-70
- Controller가
repository_instance.search(query_option)을 호출한다:
def index
element_trace_query_option = Cupix::QueryOption::ElementTrace.new(get_query_option, params)
element_traces = repository_instance.search(element_trace_query_option)
render_api Renderable.new({
search_result: element_traces,
is_collection: true,
serializer_option: @serializer_option
})
end
_search가 Elasticsearch에 쿼리를 실행하고,default_joins가:element, :task만 eager load한다:
def self.default_joins(record)
record.includes(:element, :task).select('element_traces.*')
end
_skip_join?이ElementTrace에 대해true를 반환하여 permission SQL join은 skip된다:
def _skip_join?
return false if search_public_accessed?
[::Pano, ::Element, ::ElementTrace, ::Bookmark].include?(self.class.current_class) ||
search_own_model?
end
ApplicationRecord에서belongs_to선언 시 자동으로_#{name}메서드가 정의된다. 이 메서드가fetch_cache를 호출하여 Redis에서 관련 모델의 serialized JSON을 조회한다:
class << self
def belongs_to(name, scope = nil, **options)
super
define_method "_#{name}" do |*args, **kwargs, &block|
if options[:polymorphic]
fetch_cache(send("#{name}_type"), send("#{name}_id")).merge({
type: send("#{name}_type")
}) rescue nil
else
model_name = options[:class_name] || name
model_id = options[:foreign_key] || "#{name}_id"
fetch_cache(model_name.to_s.classify, send(model_id))
end
end
end
end
fetch_cache는 Redis에서 cache key를 조회하고, miss 시find_by_id+serialized_json을 실행한다:
def fetch_cache(model_name = self.class.name, model_id = self.id)
return nil if model_id.blank?
Rails.cache.fetch(cache_key(model_name, model_id), skip_nil: true, expires_in: self.class.cache_expires_in) do
if self.respond_to?("serialized_#{model_name.underscore}_json".to_sym)
self.send("serialized_#{model_name.underscore}_json")
else
record = model_name.constantize.find_by_id(model_id)
record.serialized_json if record.respond_to?(:serialized_json)
end
end
end
- Serializer에서 14개 필드 요청 시
_record,_category,_phase,_workarea,_vendor,_status,_estimated_status,_sitetrack등이 각각 독립적인 Redis roundtrip을 발생시킨다:
attribute :record, &:_record
attribute :deviation, &:_deviation
attribute :sitetrack, &:_sitetrack
attribute :element do |element_trace, params|
if params[:element_eager_load]
element = element_trace.element # eager loaded - no extra query
# ...
end
end
attribute :task, &:_task
attribute :category, &:_category
attribute :phase, &:_phase
attribute :workarea, &:_workarea
attribute :vendor, &:_vendor
attribute :status, &:_status
attribute :estimated_status, &:_estimated_status
Log Evidence#
Datadog 검색 쿼리:
service:cupixworks-api "ElementTracesController" "index" @duration:>500
핵심 로그 패턴 (100건의 slow request에서 추출):
{
"resource_name": "Api::V1::ElementTracesController#index",
"duration_ms": 1548,
"db_time_ms": 11,
"view_time_ms": 0.05,
"region": "ap-southeast-2",
"params": {
"fields": ["id","record","element","task","category","workarea","phase","vendor","status","estimated_status","processing_result","created_at","updated_at","sitetrack"],
"per_page": 300,
"page": 1,
"facility_key": "5io96"
}
}
대조 로그 (빠른 요청):
{
"resource_name": "Api::V1::ElementTracesController#index",
"duration_ms": 42,
"params": {
"fields": ["id","record","element","phase","task"],
"per_page": 300
}
}
핵심 수치 비교:
| 조건 | 평균 응답 시간 | DB 시간 | App 시간 |
|---|---|---|---|
| 14 fields, per_page=300 | 894ms | 14ms (1.5%) | 881ms (98.5%) |
| 5 fields (id,record,element,phase,task) | 42ms | ~10ms | ~30ms |
또한 total_entries=0인 요청도 최대 1377ms가 소요된 사례가 있으며, 이는 ES 쿼리 자체의 복잡도(nested bool with cycle_state, trashed_at, purged_at 필터)로 인한 지연도 병존함을 시사한다.
리전별 분포:
ap-southeast-2: 48건, avg 950ms, max 1548ms
ap-southeast-1: 14건, avg 1187ms, max 1802ms
us-west-2: 38건, avg 715ms, max 1526ms
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Redis cache lookup 병목 (per-record × per-field cache roundtrip) | 14-field 요청만 slow (avg 894ms), 5-field 요청은 fast (42ms). DB time은 14ms로 일정. ApplicationRecord:84에서 _#{name} 메서드가 각 association마다 fetch_cache 호출. default_joins는 :element, :task만 포함. |
total_entries=0 요청도 1377ms 소요 (serialization 대상 없음) | Confirmed |
| H2 | Elasticsearch 쿼리 복잡도로 인한 ES 자체 지연 | total_entries=0인 요청도 1377ms 소요. Base query option이 cycle_state_filter에서 nested bool 4단계 쿼리 생성 (base.rb:204-276). |
대부분의 latency는 result count와 무관하게 14-field 여부에 따라 결정됨. ES 단독 지연이면 5-field도 느려야 함. | Inconclusive |
| H3 | Permission JOIN SQL 병목 | permission_joins에 8개 LEFT JOIN subquery 존재 (element_trace_repository.rb:70-162) |
_skip_join?가 ElementTrace에 대해 true 반환 (base_repository.rb:397), permission join이 실제로 skip됨 |
Rejected |
| H4 | N+1 DB query (eager loading 부족) | default_joins가 :element, :task만 include. category, phase, workarea 등은 미포함. |
DB time이 14ms로 일정하게 낮음. _#{name} 메서드가 Redis cache를 먼저 시도하므로 cache hit 시 DB query 미발생. |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
app/repositories/element_trace_repository.rb:36-37—default_joins에 자주 요청되는 association을 추가하여 batch eager loading 처리:- 현재:
includes(:element, :task) - 개선 방향:
includes(:element, :task, :category, :phase, :workarea, :vendor, :status, :estimated_status, :sitetrack, :record)추가 후, serializer에서 eager loaded association을 직접 사용하도록 변경
- 현재:
app/serializers/element_trace_serializer.rb—_record,_category등 cache-based accessor 대신 eager loaded association을 직접 참조하도록 serializer 로직을 변경하는 것이 근본 해결
단기 개선 (1주 이내)#
- Batch cache fetch 도입: 현재 레코드별 개별
Rails.cache.fetch를Rails.cache.read_multi로 교체하여 Redis roundtrip을 N×M에서 1회로 줄이는 방식 검토 - Serializer 분기 최적화:
element_eager_load: true패턴을category,phase,workarea등에도 확장하여 eager loaded 객체를 직접 serialization
장기 개선 (재발 방지)#
ApplicationRecord.belongs_to의_#{name}자동 생성 패턴을 재검토. 컬렉션 API에서 per-record cache fetch는 구조적으로 latency를 유발하므로, 컬렉션 조회 시에는 eager loading 기반 serialization을 기본으로 사용하는 아키텍처 전환 필요- Elasticsearch query의
cycle_state_filter복잡도 감소 (nested bool 단순화) 검토
Monitoring#
- 추가 메트릭:
ElementTracesController#indexp95 duration에 대한 monitor 설정 - Datadog 쿼리:
service:cupixworks-api resource_name:"Api::V1::ElementTracesController#index" @duration:>1000
- Redis cache hit rate 모니터링:
Cache::Category::*,Cache::Phase::*,Cache::Workarea::*key 패턴의 hit/miss ratio 추적
Risk Assessment#
- Risk level: medium
- 예상 복잡도: standard — eager loading 추가는 기존 패턴과 일관되나, serializer 변경은 다른 endpoint에도 영향을 줄 수 있어 regression 테스트 필요