ES /docs

RecordsController#index permission_joins — inefficient multi-layer JOIN query

RCA: Api::V1::RecordsController#index Latency (avg 1690ms, max 2628ms)

Overview#

What Happened#

2026-05-26 03:28~06:06 UTC 사이에 cupixworks-api 서비스의 Api::V1::RecordsController#index 엔드포인트에서 평균 1690ms, 최대 2628ms의 응답 지연이 us-west-2, ap-southeast-2, eu-central-1 리전에서 19건 감지되었다. DB 쿼리 시간이 전체 응답의 58%를 차지하며, 결과 건수와 무관하게 1100-1500ms의 고정 DB 오버헤드가 관찰된다.

Quick Facts#

Field Value
resource_name Api::V1::RecordsController#index
top_frame app/repositories/record_repository.rb:143-355
env production (us-west-2, ap-southeast-2, eu-central-1)

Affected Teams#

Team / Domain Error Count Impact
pclconstruction 3 456건 record 조회 시 2200ms+ 지연
gad 2 219건 record 조회 시 2630ms 지연
smacon 2 20건 record 조회에도 2200ms+ 지연
ellisdon 1 535건 record 조회 시 2230ms 지연
varcomac 1 20건 record 조회에도 1900ms 지연
okland 2 6-8건 record 조회에도 1800ms 지연

Timeline#

  1. 2026-05-26T03:28:00Z — 최초 감지 (us-west-2, pclconstruction team, 2797ms)
  2. 2026-05-26T05:44:02Z — 동시간대 ActiveRecord::Deadlocked 발생 (EditingsController#update)
  3. 2026-05-26T06:06:15Z — 마지막 감지

Error Log#

Datadog Logs

text
{
  "resource_name": "Api::V1::RecordsController#index",
  "service": "cupixworks-api",
  "occurrences": 19,
  "avg_ms": 1690,
  "max_ms": 2628,
  "sample_trace_id": "712329936997739650"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 19
  • 최초 발생: 2026-05-26T03:28:00.786Z
  • 최근 발생: 2026-05-26T06:06:15.585Z

Root Cause Summary#

RecordsController#indexpermission_joins 메서드가 14개의 LEFT JOIN과 다수의 서브쿼리를 포함한 대규모 SQL을 매 요청마다 실행하여 1100-1500ms의 고정 DB 오버헤드를 발생시킨다. 이 쿼리는 Elasticsearch에서 반환된 record ID 목록에 대해 record/facility/workspace/team/review 5개 계층의 permission 테이블을 조인하고, grouped_users 테이블과의 조인으로 그룹 기반 권한을 계산한다. 결과 건수(6건~535건)와 무관하게 서브쿼리들이 전체 permission 테이블을 스캔하므로 일정한 지연이 발생한다. Retool 자동화 폴링이 부하를 가중시키고, us-west-2에서 동시간대 deadlock이 발생하여 DB 경합이 추가적으로 영향을 미쳤다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/records_controller.rb:10-19
  • Query option 생성 → Repository search → Permission SQL → Serialization
app/controllers/api/v1/records_controller.rb:10-19ruby
def index
  record_query_option = Cupix::QueryOption::Record.new(get_query_option, params)
  records = repository_instance.search(record_query_option)

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

repository_instance.searchBaseRepository#search를 호출하여 Elasticsearch 검색 후 ActiveRecord로 로드하고, default_joinspermission_joins를 적용한다.

app/repositories/record_repository.rb:131-141ruby
def self.default_joins(record)
  record.includes(:storage).joins(:facility, :workspace, :team).select("
    records.*,
    workspaces.name AS workspace_name,
    facilities.name AS facility_name,
    facilities.key AS facility_key,
    teams.qa_preference AS team_qa_preference,
    facilities.qa_preference AS facility_qa_preference,
    facilities.cycle_state AS applied_cycle_state
  ")
end
  • Failure point: app/repositories/record_repository.rb:143-355permission_joins 메서드

이 메서드는 14개의 LEFT JOIN 서브쿼리를 생성한다. 각 서브쿼리는 *_permissions 테이블과 grouped_users 테이블을 조인하여 사용자의 그룹 기반 권한을 계산한다:

app/repositories/record_repository.rb:220-228ruby
LEFT JOIN (
  SELECT record_id, MAX(permission) AS permission
  FROM record_permissions
  JOIN grouped_users
    ON grouped_users.group_id = record_permissions.accessor_id
    AND grouped_users.user_id = #{sanitized_user_id}
  WHERE record_permissions.accessor_type = 'Group'
  GROUP BY record_id
) AS record_group_permissions
  ON record_group_permissions.record_id = records.id

이 패턴이 record, facility, workspace, team, review 각 레벨에서 user/group/system_group 조합으로 14회 반복된다. 최종 WHERE 절에서 GREATEST/IFNULL로 최대 권한을 계산:

app/repositories/record_repository.rb:320-354ruby
").group('id').where("
  (
    GREATEST(
      IFNULL(facility_user_permissions.permission, 0),
      IFNULL(facility_group_permissions.permission, 0)
    ) = 1
    AND
      GREATEST(
        IFNULL(record_user_permissions.permission, 0),
        IFNULL(record_group_permissions.permission, 0)
      ) > 0
  )
  OR
    GREATEST(
      IFNULL(review_user_permissions.permission, 0),
      IFNULL(review_group_permissions.permission, 0),
      IFNULL(review_public_permissions.permission, 0)
    ) > 0
  OR
    GREATEST(...14 permission columns...) > 1
")

기대 동작: permission 체크가 이미 Elasticsearch 단계에서 readable_facility_ids로 필터링되었으므로, SQL 레벨의 permission_joins는 빠르게 실행되어야 한다.

실제 동작: 서브쿼리들이 record_id 필터 없이 전체 permission 테이블을 스캔하여 (특히 record_group_permissions, facility_group_permissions), 결과 건수와 무관하게 1100-1500ms가 소요된다.

Log Evidence#

Datadog에서 사용한 쿼리:

text
service:cupixworks-api @controller:"Api::V1::RecordsController" @action:index @duration:>1500 @environment:production

핵심 패턴 — 결과 건수와 무관한 고정 DB 시간:

text
[2026-05-26T03:39:06Z] team=cana facility=e8uxfx records=45 duration=2626ms db_time=1132ms
[2026-05-26T05:31:59Z] team=smacon facility=z7dkrn records=20 duration=2757ms db_time=1137ms
[2026-05-26T05:41:02Z] team=varcomac facility=z0g66x records=20 duration=2743ms db_time=1251ms
[2026-05-26T03:28:01Z] team=pclconstruction facility=umphr6 records=456 duration=2797ms db_time=1479ms

20건 조회(smacon)와 456건 조회(pclconstruction)의 DB 시간이 동일 범위(1100-1500ms)에 분포 — 서브쿼리의 full table scan이 bottleneck임을 확인.

리전별 패턴:

text
us-west-2: avg_duration=1794ms, avg_db_time=938ms (98% of >1500ms requests)
ap-southeast-2: avg_duration=787ms, avg_db_time=35ms (cross-region latency)
eu-central-1: avg_duration=748ms, avg_db_time=224ms (cross-region latency)

동시간대 deadlock 발생:

text
service:cupixworks-api @environment:production ("ActiveRecord::Deadlocked")
text
[2026-05-26T05:44:02Z] ActiveRecord::Deadlocked on Api::V1::EditingsController#update — DB contention

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 permission_joins의 14개 LEFT JOIN 서브쿼리가 full table scan으로 고정 DB 오버헤드 발생 DB 시간 1100-1500ms가 결과 건수(6~535건)와 무관하게 일정; us-west-2에서 98% 집중 Confirmed
H2 N+1 쿼리로 인한 지연 (serialization 단계) Redis cache 기반 association 접근; thumbnail_urls AWS signing 가능성 includes(:storage) 적용됨; 결과 건수 증가와 지연 증가 상관 없음; view_time 비율 낮음 Rejected
H3 Elasticsearch 검색 자체가 느림 readable_facility_ids / directly_accessible_record_ids terms 필터가 클 수 있음 DB 시간이 전체의 58%이며, ES 시간은 로그에서 낮게 확인됨 Rejected
H4 DB connection pool 고갈 동시간대 Retool 자동화 폴링이 높은 부하 유발 connection pool timeout/exhaustion 로그 없음 Rejected
H5 DB contention (deadlock 영향) 05:44:02Z에 EditingsController에서 ActiveRecord::Deadlocked 발생 deadlock은 다른 controller이고, latency는 03:28부터 지속 — deadlock은 결과가 아닌 증상 Inconclusive

Fix Recommendation#

즉시 조치 (Critical)#

  • app/repositories/record_repository.rb:143-355permission_joins에서 서브쿼리에 record ID 범위를 조건으로 추가. 현재 서브쿼리들(record_group_permissions, facility_group_permissions 등)이 WHERE 절에 record_id IN (...) 조건 없이 전체 테이블을 스캔하므로, Elasticsearch에서 반환된 record ID 목록을 서브쿼리 내 WHERE에 주입하여 스캔 범위를 제한해야 한다.
  • grouped_users 테이블의 (user_id, group_id) 복합 인덱스 존재 여부 확인 및 추가.

단기 개선 (1주 이내)#

  • Permission 계산 결과를 Redis에 캐싱하여 동일 사용자의 반복 요청에 대해 SQL permission_joins를 스킵하는 방안 검토. 캐시 키: user_id + record_ids hash, TTL 5분.
  • Retool 자동화 폴링의 request rate limiting 적용 검토 (동일 사용자가 수초 간격으로 반복 호출).

장기 개선 (재발 방지)#

  • Permission 아키텍처를 materialized view 또는 별도 permission cache 테이블로 전환하여 매 요청 시 14개 LEFT JOIN을 실행하지 않도록 구조 변경.
  • Elasticsearch 단계에서 이미 permission 필터링이 완료되었으므로, SQL 레벨의 permission_joins를 선택적으로 스킵할 수 있는 옵션 도입 (ES 결과가 이미 권한 필터링된 경우).

Monitoring#

  • RecordsController#index DB 시간 P95 모니터링 알림 추가:
text
avg:trace.active_record.query.duration{service:cupixworks-api,resource_name:Api::V1::RecordsController#index} > 1000
  • permission_joins 실행 시간을 ActiveSupport::Notifications로 계측하여 별도 custom metric 전송.
  • Retool 사용자의 request rate 모니터링:
text
count:trace.rack.request{service:cupixworks-api,@http.useragent:*retool*} by {@usr.id}.rollup(count, 60) > 30

Risk Assessment#

  • Risk level: medium
  • 예상 복잡도: standard — 서브쿼리에 ID 조건 추가는 비교적 안전하나, permission 로직 변경이므로 QA 검증 필수.