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#
- 2026-05-26T03:28:00Z — 최초 감지 (us-west-2, pclconstruction team, 2797ms)
- 2026-05-26T05:44:02Z — 동시간대 ActiveRecord::Deadlocked 발생 (EditingsController#update)
- 2026-05-26T06:06:15Z — 마지막 감지
Error Log#
{
"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#index의 permission_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
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.search는 BaseRepository#search를 호출하여 Elasticsearch 검색 후 ActiveRecord로 로드하고, default_joins와 permission_joins를 적용한다.
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-355—permission_joins메서드
이 메서드는 14개의 LEFT JOIN 서브쿼리를 생성한다. 각 서브쿼리는 *_permissions 테이블과 grouped_users 테이블을 조인하여 사용자의 그룹 기반 권한을 계산한다:
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로 최대 권한을 계산:
").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에서 사용한 쿼리:
service:cupixworks-api @controller:"Api::V1::RecordsController" @action:index @duration:>1500 @environment:production
핵심 패턴 — 결과 건수와 무관한 고정 DB 시간:
[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임을 확인.
리전별 패턴:
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 발생:
service:cupixworks-api @environment:production ("ActiveRecord::Deadlocked")
[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-355—permission_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#indexDB 시간 P95 모니터링 알림 추가:
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 모니터링:
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 검증 필수.