Api::V1::ReviewsController#index (avg 32943ms, max 32943ms)
RCA: Api::V1::ReviewsController#index latency (avg 32943ms, max 32943ms)
Overview#
What Happened#
2026-07-01 07:43 KST에 production us-west-2의 cupixworks-api에서 Api::V1::ReviewsController#index 요청 1건이 32.9초 동안 처리되었다. 5xx 에러는 아니지만 사용자 체감 응답 지연 수준이며, 같은 시각대(±10분)에 다른 latency 클러스터(CapturesController#resource_upload_url, ElementTracesController#refresh, ElementsController#index)가 함께 발생하여 status-board가 cupixworks-api service degraded 인시던트(2026-06-30-svc-cupixworks-api--unknown-1)로 묶었다. 요청은 admin team 소속 내부 사용자(jin.lee@cupix.com, user_id=16414)가 일으켰고, 권한 캐시가 admin 사용자에 한해 동작하지 않는 구조 때문에 권한 lookup이 매 호출마다 DB로 흘렀다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | — (no exception — slow request) |
| resource_name | Api::V1::ReviewsController#index |
| avg_duration_ms | 32943 |
| max_duration_ms | 32943 |
| top_frame | app/repositories/review_repository.rb:525 (current_user.readable_facility_ids) |
| sample_trace_id | 1381676408555254902 |
| env | production, region us-west-2, tenant cupix |
| user_id | 16414 (jin.lee@cupix.com — admin team) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| Internal admin (cupix) | 1 (this cluster) | Admin user UI 응답 32.9s — 작업 흐름 차단 |
| cupixworks-api 전반 | 4 추가 latency cluster | 동일 시간대 다른 endpoint도 함께 느려졌으나 동일 admin 사용자 부하로 추정 (uncertain — needs verification) |
Timeline#
- 2026-07-01 07:35 KST — status-board가
cupixworks-api service degraded인시던트(2026-06-30-svc-cupixworks-api--unknown-1) 오픈 (첫 latency cluster3ce7061c…) - 2026-07-01 07:42:55 KST 부터 — user 16414의
directly_permitted_items/readable_facility_idscache "Flushing" 로그가 2초 간격으로 계속 출력 (cache miss 반복) - 2026-07-01 07:43:08 KST —
Api::V1::ReviewsController#index호출 시작 (cluster 본건first_seen) - 2026-07-01 07:43:43 KST — 32.9초 후
[200] GET /api/v1/reviews응답 - 2026-07-01 07:46:51 KST — 인시던트의 마지막 관련 cluster (
0c05ab76…) 발생
Error Log#
{
"resource_name": "Api::V1::ReviewsController#index",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 32943,
"max_ms": 32943,
"sample_trace_id": "1381676408555254902"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-07-01 07:43 KST
- 최근 발생: 2026-07-01 07:43 KST
- 연관 인시던트:
2026-06-30-svc-cupixworks-api--unknown-1(cupixworks-api service degraded — 5 clusters)
Root Cause Summary#
AccessibleEntities::Cache#cached_permission_timestamp 가 admin team 사용자에 대해 매 호출마다 SecureRandom.hex(10)을 반환한다. 이 timestamp는 _directly_permitted_item_ids, _readable_facility_ids 등 모든 권한 cache key 의 t: 필드에 들어가므로, admin 사용자에 한해 cache key가 매번 달라져 캐시가 사실상 비활성화된다. Api::V1::ReviewsController#index → ReviewRepository#_search 는 한 요청에서 current_user.readable_facility_ids 와 current_user.directly_accessible_review_ids 를 호출하고, 각 호출이 모두 DB에서 전체 권한 테이블을 join/pluck하는 무거운 SQL로 떨어진다. admin 사용자는 facility/review 가시 범위가 전 테넌트라서 결과 집합이 거대하고, 이어지는 Elasticsearch ::Review.search 도 매우 큰 terms 조건을 받게 되어 단일 요청이 32.9s까지 늘어났다.
Technical Analysis#
Code Path#
- Entry:
app/controllers/api/v1/reviews_controller.rb:14-22—indexaction - Repository search:
app/repositories/review_repository.rb:489-554—_search - Permission lookup #1 (facility):
app/repositories/review_repository.rb:525 - Permission lookup #2 (review):
app/repositories/review_repository.rb:530 - Cache key generator (failure point):
app/models/concerns/accessible_entities/cache.rb:7 - Cache fetch wrappers:
app/models/concerns/accessible_entities/cache.rb:19-33,cache.rb:50-63
def index
review_query_option = Cupix::QueryOption::Review.new(get_query_option, params)
reviews = repository_instance.search(review_query_option)
render_api Renderable.new({
search_result: reviews,
is_collection: true,
serializer_option: @serializer_option
})
end
self.query_option.query[:bool][:must] += [
{
bool: {
should: [
{
terms: {
"facility.id": self.current_user.readable_facility_ids # ← cache miss every call for admin
}
},
{
terms: {
id: self.current_user.directly_accessible_review_ids # ← cache miss every call for admin
}
}
]
}
}
]
# ...
response = ::Review.search(
self.query_option.serializable_hash
).paginate(
per_page: self.query_option.per_page,
page: self.query_option.page
)
included do
def cached_permission_timestamp
return SecureRandom.hex(10) if admin_team? # ← root cause: cache key randomized for admin users
Rails.cache.fetch({ cached_permission: { user_id: id } }, expires_in: DEFAULT_PERMISSION_CACHE_EXPIRES_IN) do
DateTime.now.to_i
end
end
# ...
end
def _directly_permitted_item_ids(model, visibility = Cyclable.visibility[:UNTRASHED], min_permission: 1, max_permission: MAX_PERMISSION)
Rails.cache.fetch({
cached_permission: {
user_id: id,
model: model.try(:name),
min_permission: min_permission,
max_permission: max_permission,
t: cached_permission_timestamp # ← random for admin → key never collides → block always runs
}
}, expires_in: DEFAULT_PERMISSION_CACHE_EXPIRES_IN) do
Cupix::Logger.info("Flushing directly_permitted_items on user #{id}, model: #{model}, min_permission: #{min_permission}}", ...)
directly_permitted_items(model, visibility, min_permission: min_permission, max_permission: max_permission).pluck(:id).uniq
end
end
기대 동작: Rails.cache.fetch 가 같은 cache key 에 대해 결과를 재사용하여, 같은 사용자/모델 조합의 권한 lookup 이 짧은 시간 내 반복 호출되면 단 한 번만 DB로 떨어진다.
실제 동작: admin team 사용자의 cached_permission_timestamp 가 매번 새 random hex 를 반환하므로 cache key 의 t: 가 매번 달라져, Rails.cache.fetch 의 inner block 이 매 호출마다 실행된다. 같은 한 요청 내에서도 권한 lookup 이 여러 번 일어나는 컨트롤러(ReviewsController#index, ElementsController#index 등) 는 매번 전체 권한 join SQL 을 다시 실행한다.
Log Evidence#
Datadog query:
service:cupixworks-api "user 16414"
시간 범위 2026-06-30T22:30:00Z ~ 2026-06-30T22:50:00Z. 같은 user_id(16414, admin team jin.lee@cupix.com) 에 대해 cache miss 로그(Flushing …) 가 짧은 간격으로 반복:
2026-07-01 07:43:43 info [200] GET /api/v1/reviews (Api::V1::ReviewsController#index)
2026-07-01 07:43:39 info Flushing directly_permitted_items on user 16414, model: Review, min_permission: 1
2026-07-01 07:43:11 info Flushing directly_permitted_items on user 16414, model: Facility, min_permission: 2
2026-07-01 07:43:11 info Flushing readable_facility_ids on user 16414
2026-07-01 07:43:13 info Flushing readable_facility_ids on user 16414
2026-07-01 07:43:13 info Flushing directly_permitted_items on user 16414, model: Facility, min_permission: 2
2026-07-01 07:43:17 info Flushing directly_permitted_items on user 16414, model: Record, min_permission: 1
2026-07-01 07:43:19 info Flushing directly_permitted_items on user 16414, model: Facility, min_permission: 1
2026-07-01 07:43:35 info Flushing readable_facility_ids on user 16414
2026-07-01 07:43:35 info Flushing directly_permitted_items on user 16414, model: Facility, min_permission: 2
2026-07-01 07:43:45 info Flushing readable_facility_ids on user 16414
2026-07-01 07:43:45 info Flushing directly_permitted_items on user 16414, model: Facility, min_permission: 2
2026-07-01 07:43:53 info Flushing directly_permitted_items on user 16414, model: Review, min_permission: 1
같은 (user_id=16414, model=Facility, min_permission=2) 조합에서 Flushing readable_facility_ids 가 07:43:11, 07:43:13, 07:43:35, 07:43:45 에 반복 출력 — 캐시가 동작했다면 첫 호출만 출력되어야 한다. user/model 키가 동일한데 매번 inner block 이 실행됨이 곧 root cause 의 직접 증거다.
Datadog query for trace:
service:cupixworks-api trace_id:1381676408555254902
trace 의 끝 응답 로그 (32.9s 후 200 OK):
2026-07-01 07:43:43 info [200] GET /api/v1/reviews (Api::V1::ReviewsController#index)
2026-07-01 07:43:39 info Flushing directly_permitted_items on user 16414, model: Review, min_permission: 1
2026-07-01 07:43:11 info Flushing directly_permitted_items on user 16414, model: Facility, min_permission: 2
2026-07-01 07:43:11 info Flushing readable_facility_ids on user 16414
같은 trace 안에서 _readable_facility_ids (line 525 경로) 와 _directly_permitted_item_ids(Review, ...) (line 530 경로) 가 모두 cache miss 로 흘러 두 번의 무거운 권한 join SQL 이 발생한 것이 확인된다.
같은 시간대 다른 latency cluster (인시던트 동반 발생):
| Cluster ID | Resource | avg_ms |
|---|---|---|
3ce7061c… |
Api::V1::CapturesController#resource_upload_url |
10496 |
8a18b22e… |
Api::V1::ElementTracesController#refresh |
27714 |
6633f842… |
Api::V1::ElementsController#index |
26130 |
3580e20c… (this) |
Api::V1::ReviewsController#index |
32943 |
모두 권한 lookup 을 거치는 list/refresh 류 endpoint 라는 공통점이 있다 (ElementsController#index 등도 동일 cache 경로 사용 — uncertain, separate RCA에서 검증 필요).
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | admin 사용자의 권한 캐시 무력화(SecureRandom.hex(10) per call) 로 모든 권한 lookup 이 DB hit → 32s 응답 |
cache.rb:7 의 return SecureRandom.hex(10) if admin_team?, 같은 user/model 조합의 Flushing … 로그가 동일 trace 시간 창에 반복 (07:43:11, 07:43:13, 07:43:35, 07:43:45), user 16414가 @cupix.com admin 도메인 |
같은 trace 의 직접 DB latency breakdown(APM span)을 첨부하지 못함 (uncertain — needs verification via APM trace UI) | Confirmed |
| H2 | Elasticsearch (::Review.search) 측 cluster 장애로 응답이 지연 |
review_repository.rb:546 가 ES 호출, 32s 는 ES timeout 와 비슷한 규모 |
같은 시간대 service:cupixworks-api status:error 검색에서 ES timeout/error 로그가 1건(RecordRepository 10s timeout)뿐이고 Review 관련 ES error 없음. Flushing 로그 패턴이 권한 cache 경로에서 직접 시간을 소비하는 모습을 보임 |
Rejected |
| H3 | 단일 노드 / 단일 Sidekiq worker 장애로 인한 일시적 부하 | 같은 incident 에 4개 endpoint cluster 동시 발생 | 4개 cluster 모두 동일 사용자(uncertain) 의 권한 lookup 이 무거운 endpoint 들이며, dep:* (외부 의존성) 인시던트가 아니라 svc:cupixworks-api::unknown scope. 시스템 metric (CPU/mem) 이상 신호 없음 |
Inconclusive |
| H4 | Datadog APM의 단순 측정 노이즈 | 1건만 발생, occurrence_count=1 | trace_id 의 응답 로그(07:43:43) 와 첫 권한 cache miss(07:43:11) 사이 32s 격차가 실제 측정됨 | Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 파일:
app/models/concerns/accessible_entities/cache.rb:7 - 변경 방향: admin team 사용자에 대해 cache key 를 randomize 하지 않도록 한다. admin 의 권한이 변경될 때 명시적으로 invalidation 하는 경로(
flush_cached_permission/delete_cached_permission_timestamp가 이미 존재) 를 사용하면 일반 사용자와 동일한 캐시 동작을 가질 수 있다. randomize 가 도입된 의도(예: admin 의 권한이 너무 자주 바뀌어 stale cache 위험) 가 있다면, 그 의도를 만족하는 다른 메커니즘(예: admin 그룹 권한 변경 시 명시적 cache bust hook)으로 대체해야 한다. 변경 전에 git blame 으로 도입 commit 의 PR/이슈를 확인하여 의도 검증 필요 (uncertain — needs verification). - 단기 완화책 (코드 변경이 위험할 경우): admin 사용자에 한해서
directly_permitted_items자체를 query 단순화 (admin 은 모든 facility 접근 가능 → DB 조회 대신Facility.untrashed.pluck(:id)같은 단순 path 또는terms조건 자체를 생략) 하도록ReviewRepository#_search분기 추가.
단기 개선 (1주 이내)#
app/repositories/review_repository.rb:519-536: admin/super_admin 사용자의 경우terms: { "facility.id": [...] }terms: { id: [...] }조건을 통째로 생략하고 ES 측에서도match_all같이 가볍게 처리하도록 분기. 거대한 ID 배열을 ES에 보내는 것 자체가 ES side cost 도 키운다._directly_permitted_item_ids등 cache wrapper 가 같은 process / 같은 request 안에서 동일 키를 여러 번 부르는 경우, in-memory memoization 한 layer 추가 (@_permission_memo). admin 사용자에서도 한 요청 내에선 안정적인 응답시간 확보.
장기 개선 (재발 방지)#
- 권한 lookup 의 비용을 APM custom span 으로 측정하여 단일 요청에서 동일 사용자/모델 조합 cache miss 가 N회 이상이면 warn 발생. 회귀를 metric 으로 감지.
- admin team 사용자의 권한 모델을 “전체 접근”으로 short-circuit 하는 명시적 경로를 모델 레벨에 두고, 각 repository 가 이 경로를 사용하도록 통일. 컨트롤러/repository 마다 분기를 분산시키지 않는다.
Monitoring#
권한 cache miss 가 admin 사용자에서 폭발하는지 확인하는 dashboard timeseries widget. 모두 count aggregator 사용.
권한 cache miss 빈도:
logs("service:cupixworks-api @class:User @function:_directly_permitted_item_ids").index("*").rollup("count", "60").by("@user.id")
ReviewsController#index p95 latency:
trace.rails.request.duration{service:cupixworks-api,resource_name:Api::V1::ReviewsController#index}.rollup(avg, 60).as_count()
cupixworks-api 전반 latency p95:
trace.rails.request.duration{service:cupixworks-api}.rollup(percentile, 60, 95)
(Datadog metric name 은 실제 환경 metric 명세 확인 필요 — trace.rails.request.duration 가 우리 환경 metric 명과 일치하는지 검증 후 dashboard 에 반영 — uncertain.)
알림 가이드:
- 단일 admin user_id 에서
_directly_permitted_item_idscache miss 가 30s 안에 10회 이상 발생하면 warn. Api::V1::ReviewsController#indexp95 가 5s 초과 시 page.
Risk Assessment#
- Risk level: medium — admin 사용자만 영향을 받지만 내부 운영/CS 흐름을 차단할 수 있고, 동시에 다른 endpoint(ElementsController, ElementTracesController, CapturesController)도 같은 root cause로 함께 느려진다.
- 예상 복잡도: standard — 한 줄(
cache.rb:7) 변경이지만 admin 권한 변경 시 cache invalidation 경로가 적절히 동작하는지 검증이 필요. 도입 의도(PR/commit)를 먼저 확인 후 변경 권장.