ES /docs

Api::V1::TrashesController#index (avg 2323ms, max 2789ms)

RCA: Api::V1::TrashesController#index Latency (avg 2323ms, max 2789ms)

Overview#

What Happened#

2026-05-26 04:34~04:50 UTC 사이에 cupixworks-api의 TrashesController#index 엔드포인트가 평균 2323ms, 최대 2789ms의 응답 시간을 기록했다. 총 5건의 요청이 모두 HTTP 200을 반환했지만, 정상 응답 시간(~900ms)의 2.5배를 초과했다. 영향을 받은 사용자는 단일 팀(hec-test-dangjin)의 peter.nam@cupix.com 계정이다.

Quick Facts#

Field Value
resource_name Api::V1::TrashesController#index
top_frame app/repositories/trash_repository.rb:195
env production, us-west-2
avg_duration 2323ms
max_duration 2789ms

Timeline#

  1. 2026-05-26T04:34:35Z — 최초 지연 요청 발생 (2787ms, host: ip-10-1-80-134)
  2. 2026-05-26T04:34:46Z — 두 번째 요청 (2294ms, host: ip-10-1-144-228)
  3. 2026-05-26T04:46:46Z — 세 번째 요청 (2154ms, host: ip-10-1-80-134)
  4. 2026-05-26T04:50:34Z — 마지막 지연 요청 (1916ms, host: ip-10-1-144-228)
  5. 2026-05-26T13:25:00Z — 동일 엔드포인트 응답 시간 900ms로 정상화 확인

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::TrashesController#index",
  "service": "cupixworks-api",
  "occurrences": 5,
  "avg_ms": 2323,
  "max_ms": 2789,
  "sample_trace_id": "950733167875203257"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 5
  • 최초 발생: 2026-05-26T04:34:35.294Z
  • 최근 발생: 2026-05-26T04:50:31.527Z
  • 영향 범위: 단일 사용자(peter.nam@cupix.com), 단일 팀(hec-test-dangjin, team_id: 1222). 기능 장애가 아닌 UX 지연(Trash 목록 로딩 2-3초).

Root Cause Summary#

TrashRepository#search가 9개 Elasticsearch 인덱스(Workspace, Facility, Bim, Level, Record, Capture, Pointcloud, Review, AnnotationLayer)에 대해 동시에 복잡한 bool query를 실행하면서 발생하는 구조적 성능 문제이다. Datadog 로그에서 DB time이 일관되게 ~1000ms로 기록되었으나, 이는 4회의 directly_permitted_item_ids SQL 쿼리(권한 ID 조회)에 소요된 시간이다. 나머지 1000-1800ms는 Elasticsearch multi-index 검색에 소요되었으며, 이는 Datadog의 db metric에 포함되지 않는다. 결과가 13건임에도 불구하고 9개 인덱스를 모두 스캔하는 쿼리 구조가 근본 원인이다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/trashes_controller.rb:11index action
  • Permission query: app/repositories/trash_repository.rb:13-16 — 4회의 directly_permitted_item_ids 호출
  • ES query build: app/repositories/trash_repository.rb:18-193 — 9개 모델별 bool query 구성
  • ES execution: app/repositories/trash_repository.rb:195-200 — multi-index search + pagination
  • Serialization: app/serializers/trash_serializer.rb:1-78 — FastJsonapi serializer (ES _source 직접 사용)

1단계: 권한 ID 조회 (~1000ms)

app/repositories/trash_repository.rb:13-16ruby
readable_team_ids = @current_user.directly_permitted_item_ids(::Team, Trash.visibility[:ALL], min_permission: 2) & [@current_team.id]
readable_workspace_ids = @current_user.directly_permitted_item_ids(::Workspace, Trash.visibility[:ALL], min_permission: 2)
readable_facility_ids = @current_user.directly_permitted_item_ids(::Facility, Trash.visibility[:ALL], min_permission: 2)
readable_record_ids = @current_user.directly_permitted_item_ids(::Record, Trash.visibility[:ALL], min_permission: 2)

이 4개의 호출은 각각 DB permissions 테이블을 JOIN하여 사용자가 접근 가능한 엔티티 ID를 조회한다. PERMISSION_CACHE_ENABLED가 true인 경우 Rails.cache를 사용하지만, 캐시 키에 visibility 파라미터가 포함되지 않아 캐시 miss 시 전체 visibility scope로 쿼리가 실행된다.

app/models/concerns/accessible_entities/directly_permitted_items.rb:51-56ruby
def directly_permitted_item_ids(model, visibility = Cyclable.visibility[:UNTRASHED], min_permission: 1, max_permission: MAX_PERMISSION)
  if PERMISSION_CACHE_ENABLED
    _directly_permitted_item_ids(model, visibility = Cyclable.visibility[:UNTRASHED], min_permission: min_permission, max_permission: max_permission)
  else
    directly_permitted_items(model, visibility, min_permission: min_permission, max_permission: max_permission).pluck(:id).uniq
  end
end

2단계: Elasticsearch multi-index query (~1000-1800ms)

app/repositories/trash_repository.rb:195-200ruby
response = Elasticsearch::Model.search(
  query_option.serializable_hash, [::Workspace, ::Facility, ::Bim, ::Level, ::Record, ::Capture, ::Pointcloud, ::Review, ::AnnotationLayer]
).paginate(
  per_page: query_option.per_page,
  page: query_option.page
)

9개 인덱스에 대해 단일 search request를 보내며, 각 인덱스별로 bool query에 should + must + must_not 조건이 조합된 복잡한 쿼리를 실행한다. 결과가 1~3건이더라도 모든 인덱스의 scoring 과정을 거쳐야 하므로 응답 시간이 길어진다.

3단계: Serialization (< 1ms)

app/serializers/trash_serializer.rb:9-11ruby
attribute :id do |item|
  item['_source']['id']
end

Serializer는 ES _source에서 직접 필드를 추출하므로 추가 DB 쿼리 없이 빠르게 완료된다. Datadog 로그에서 serialization time이 0-1ms로 확인되었다.

Log Evidence#

Datadog 쿼리:

text
service:cupixworks-api resource_name:"Api::V1::TrashesController#index" env:production @duration:>500ms

5건의 요청 로그에서 추출한 핵심 패턴:

json
{
  "timestamp": "2026-05-26T04:34:38.263Z",
  "duration_ms": 2787.56,
  "db_ms": 997.39,
  "view_ms": 0.1,
  "serialization_ms": 0,
  "total_entries": 2,
  "user_id": 751,
  "team_id": 1222,
  "host": "ip-10-1-80-134",
  "params": "page=1, per_page=25, fields: has_child, counts_info, parents_info..."
}
json
{
  "timestamp": "2026-05-26T04:50:34.697Z",
  "duration_ms": 1916.18,
  "db_ms": 1007.20,
  "view_ms": 0.1,
  "serialization_ms": 0,
  "total_entries": 1,
  "user_id": 751,
  "team_id": 1222,
  "host": "ip-10-1-144-228"
}

핵심 관찰:

  • DB time은 모든 요청에서 1000ms로 일정 (total_entries 13건과 무관)
  • 총 duration에서 DB+view+serialization을 제외한 ~1000-1800ms가 Elasticsearch 쿼리에 소요
  • 3개의 서로 다른 app server에서 발생하여 단일 인스턴스 문제 배제
  • 동일 세션(125655eaa08ba463d926be0c14ef4b8a354b3202)에서 반복 호출

APM metric 확인:

text
trace.rack.request.duration (resource: Api::V1::TrashesController#index)
- 04:30 UTC: avg 2.54s
- 04:45 UTC: avg 2.16s
- 04:50 UTC: avg 2.18s
- 13:25 UTC: avg 0.90s (정상화)

오후에 정상화된 것은 permission cache가 warm된 이후 DB query 시간이 단축되었기 때문으로 판단된다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Permission ID 조회(4x SQL)로 인한 DB 병목 DB time 일관 ~1000ms; 4회의 directly_permitted_item_ids 호출이 permissions 테이블 JOIN 수행; 오후 정상화(캐시 warm) 캐시 활성 상태에서도 첫 요청은 DB 직접 조회 필요; 단독으로 전체 2300ms를 설명하지 못함 Confirmed (부분)
H2 9개 인덱스 동시 Elasticsearch 검색으로 인한 쿼리 지연 total_duration - db - view - serialization = 1000-1800ms 미설명 시간; 결과 1~3건임에도 9개 인덱스 full scan; multi-index bool query 복잡도 높음 ES 전용 slow log 미확인 (debug 레벨 로그 미수집) Confirmed (부분)
H3 N+1 쿼리 또는 serialization 병목 has_child, counts_info, parents_info 필드 요청됨 serialization time 0-1ms; TrashSerializer는 ES _source에서 직접 읽으므로 추가 DB 쿼리 없음 Rejected
H4 특정 앱 서버 또는 인스턴스 문제 3개 서버에서 동일 지연 발생 (ip-10-1-80-134, ip-10-1-144-228, ip-10-1-19-190) Rejected
H5 대량 데이터 반환으로 인한 메모리/직렬화 부하 total_entries가 1~3건으로 매우 적음; per_page=25 설정 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/repositories/trash_repository.rb:13-16: 4회의 directly_permitted_item_ids 호출을 병렬화하거나, 이미 캐시된 directly_readable_*_ids 메서드를 활용하여 중복 쿼리를 제거해야 한다. 특히 Trash.visibility[:ALL]로 호출하지만 실제로는 UNTRASHED scope로 실행되는 불일치를 수정해야 한다.

단기 개선 (1주 이내)#

  • Elasticsearch query 최적화: 9개 인덱스를 한번에 검색하는 대신, 사용자에게 실제 trash 항목이 있는 인덱스만 선택적으로 검색하는 2-phase 접근을 고려한다. 먼저 각 인덱스의 count를 확인하고, 결과가 있는 인덱스만 full query를 실행한다.
  • Permission cache key에 visibility 포함: _directly_permitted_item_ids 캐시 키에 visibility 파라미터를 추가하여 ALL vs UNTRASHED 요청이 올바르게 분리 캐시되도록 한다.

장기 개선 (재발 방지)#

  • Trash 목록 전용 denormalized 인덱스를 Elasticsearch에 생성하여 9개 인덱스 동시 검색을 단일 인덱스 검색으로 대체한다.
  • Permission ID 조회 결과를 request-scoped 캐시(RequestStore)에 저장하여 동일 요청 내에서 중복 DB 호출을 방지한다.

Monitoring#

  • Datadog APM에서 TrashesController#index p95 latency alert 추가 (threshold: 1500ms)
text
avg(last_5m):trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::trashescontroller#index,env:production} > 1.5
  • Elasticsearch slow query 로그를 info 레벨로 활성화하여 multi-index 검색 소요 시간을 가시화한다.

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard
  • 기능 장애가 아닌 UX 지연 문제로, 사용자가 Trash 목록을 자주 조회하지 않는 한 비즈니스 영향은 제한적이다. 단, 데이터가 많은 팀에서는 지연이 더 악화될 수 있다.