Api::V1::Admin::QuotesController#apply (avg 4023ms, max 4023ms)
RCA: QuotesController#apply Latency (4023ms)
Overview#
What Happened#
2026-05-31 18:02:59 KST에 Api::V1::Admin::QuotesController#apply 엔드포인트가 4023ms의 응답 시간을 기록했다. Quote VC96VIAR7O를 Team 323에 적용하는 과정에서, after_active_billing_state 콜백이 Team 소속 50개 이상의 Workspace에 대해 개별적으로 reset_billing!을 호출하면서 cascading state transition이 발생했다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::Admin::QuotesController#apply |
| avg_duration | 4023ms |
| top_frame | app/models/quote.rb:78 (apply!) |
| env | production, us-west-2 |
| quote_number | VC96VIAR7O |
| billable | Team 323 |
Timeline#
- 2026-05-31 18:02:59 KST — Quote application begins (Team 323)
- 2026-05-31 18:02:59 KST — Deactivate existing products, add team products, update billing_info
- 2026-05-31 18:02:59 KST — Activate billing state →
after_active_billing_state콜백 시작 - 2026-05-31 18:02:59~18:03:01 KST — 50+ Workspace billing_state 개별 리셋 (none → none 전환)
- 2026-05-31 18:03:03 KST — Quote state changed paid → applied
- 2026-05-31 18:03:03 KST — Quote application finished
Error Log#
{
"resource_name": "Api::V1::Admin::QuotesController#apply",
"service": "cupixworks-api",
"occurrences": 1,
"avg_ms": 4023,
"max_ms": 4023,
"sample_trace_id": "2685172725604127766"
}
Impact#
- Service:
cupixworks-api - 발생 횟수: 1
- 최초 발생: 2026-05-31 18:02 KST
- 최근 발생: 2026-05-31 18:02 KST
- 사용자 영향: Admin이 Quote를 적용하는 동안 4초 대기. 요청은 성공(200)하였으며 데이터 손실은 없음. 빈도가 낮은 관리자 전용 작업.
Root Cause Summary#
Team에 Quote를 적용하면 active_billing_state! 이벤트가 발생하고, after_active_billing_state 콜백에서 workspaces.each(&:reset_billing!)을 호출한다. Team 323에 50개 이상의 Workspace가 존재하며, 각 Workspace의 reset_billing!은 state machine reset 이벤트를 발생시킨다. 이 state transition마다 after_transition 콜백이 update_team_license_type을 호출하여 Team의 license_type을 다시 계산한다. 결과적으로 50+ 개의 Workspace × (state transition + DB query + team.save) = 수백 회의 불필요한 DB 연산이 단일 HTTP 요청 내에서 동기적으로 실행되어 4초의 지연이 발생했다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/admin/quotes_controller.rb:37 - Repository:
app/repositories/admin/quote_repository.rb:45 - Main logic:
app/models/quote.rb:78(apply!) - Billing activation:
app/models/quote.rb:126(active_billing_state!) - Cascade trigger:
app/models/concerns/billable/team.rb:15 - Per-workspace reset:
app/models/concerns/resetable/billable.rb:19 - State transition callback:
app/models/concerns/statable/billable.rb:75-79 - License recalculation:
app/models/concerns/license_type.rb:29
1. Quote#apply! 내에서 billing state 활성화:
if _billing_started_at > Date.current
billable.scheduled_billing_state!
else
billable.active_billing_state!
end
_billing_started_at이 2026-05-31이고 요청 시점도 2026-05-31이므로 active_billing_state!가 실행된다.
2. Team의 after_active_billing_state 콜백에서 모든 Workspace를 순회:
after_active_billing_state do
self.reset_trial_state!
workspaces.each(&:reset_billing!)
end
Team 323에 50+ Workspace가 존재하므로 각각에 대해 reset_billing! 호출.
3. 각 Workspace의 reset_billing!이 state machine event 발생:
def reset_billing!
reset_billable_properties
reset_applied_products
reset_billing_state # ← state machine reset event 발생
reset_trial_state
reset_lock_state
flush_all_on_billable
billing_state
end
4. State machine의 after_transition 콜백이 매번 Team license_type 재계산:
after_transition from: any, to: any do |billable, transition|
Cupix::Logger.info("[#{billable.class.name}] billing_state has transitioned from #{transition.from} to #{transition.to} on #{billable.id}")
billable.clear_billing_state_cache
billable.update_team_license_type
end
5. update_team_license_type → Workspace에서 team.update_license_type! 호출:
def update_team_license_type
case self.class.name
when 'Team'
update_license_type!
else
team.update_license_type! # ← Workspace에서 Team의 license_type 재계산
end
end
6. update_license_type!이 DB 쿼리로 license_type 계산:
def set_license_type
self.license_type =
if billing_state_active?
'subscription'
elsif workspaces.in_billing.exists? # ← DB query
'project_license'
elsif trial_state_active?
'subscription_trial'
elsif workspaces.in_trial.exists? # ← DB query
'project_license_trial'
else
nil
end
# ...
end
def update_license_type!
set_license_type
self.save if license_type_changed? # ← conditional DB write
self.license_type
end
결론: 50+ Workspace × reset_billing! → 각 Workspace의 billing_state reset 이벤트 → update_team_license_type → team.update_license_type! (DB query 2-3개 + conditional save) = 최소 100-150 DB 쿼리가 단일 요청에서 실행됨.
Log Evidence#
Datadog 검색 쿼리:
service:cupixworks-api "[Workspace] billing_state" from:2026-05-31T09:02:00Z to:2026-05-31T09:04:00Z
50개 이상의 Workspace billing_state 전환 로그 확인 (전부 none → none 무의미한 전환):
[2026-05-31 18:02:59] [Workspace] billing_state has transitioned from none to none on 2871
[2026-05-31 18:02:59] [Workspace] billing_state has transitioned from none to none on 2902
[2026-05-31 18:02:59] [Workspace] billing_state has transitioned from none to none on 2920
[2026-05-31 18:02:59] [Workspace] billing_state has transitioned from none to none on 2926
...
[2026-05-31 18:03:01] [Workspace] billing_state has transitioned from none to none on 3495
[2026-05-31 18:03:01] [Workspace] billing_state has transitioned from none to none on 3496
[2026-05-31 18:03:01] [Workspace] billing_state has transitioned from none to none on 3978
Quote 실행 타임라인:
[2026-05-31 18:02:59] [Quote][VC96VIAR7O] Quote application begins Team 323
[2026-05-31 18:02:59] [Quote][VC96VIAR7O] Deactivate existing products
[2026-05-31 18:02:59] [Quote][VC96VIAR7O] Add team products
[2026-05-31 18:02:59] [Quote][VC96VIAR7O] Update billing_info to Team 323
[2026-05-31 18:02:59] [Quote][VC96VIAR7O] Activate billing state: 2026-05-31 to 2027-06-03 for 12 months
[2026-05-31 18:03:03] [Quote][VC96VIAR7O] Quote application finished
license_type 변경 로그 (불필요한 리셋 후 재설정):
[2026-05-31 18:02:59] [Team][323] license_type has been changed from subscription to
[2026-05-31 18:02:59] [Team][323] license_type has been changed from to subscription
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Workspace cascading reset이 N+1 문제로 latency 발생 | 50+ Workspace 각각에 billing_state transition 로그 확인, 18:02:59~18:03:01 약 2초간 지속. 각 transition마다 update_team_license_type 호출 (statable/billable.rb:78) |
— | Confirmed |
| H2 | 외부 API 호출 또는 네트워크 지연이 원인 | — | apply! 코드에 외부 API 호출 없음. 모든 로그가 DB 작업 관련. worker 로그에 Team 323 관련 항목 없음 | Rejected |
| H3 | DB slow query (단일 무거운 쿼리)가 원인 | — | 로그 타임라인이 2초간 고르게 분포. 단일 쿼리 지연이면 한 시점에 집중될 것. 다수의 개별 transition 로그가 순차 실행 증거 | Rejected |
| H4 | reset_applied_products의 N+1 (applied_products.each(&:inactive_state))가 원인 | reset_billing!에서 호출됨 (resetable/billable.rb:53). Workspace별로도 applied_products가 있을 수 있음 | Workspace가 billing_state: none이면 applied_products도 없을 가능성 높음. 주요 지연은 state transition 콜백의 반복 쿼리 | Inconclusive |
Fix Recommendation#
즉시 조치 (Critical)#
- 파일:
app/models/concerns/billable/team.rb:15 - 방향:
workspaces.each(&:reset_billing!)호출 시, 이미billing_state: :none인 Workspace는 skip하도록 조건 추가.none → none전환은 완전히 무의미하므로 불필요한 state transition을 제거.
- workspaces.each(&:reset_billing!)+ workspaces.where.not(billing_state: :none).each(&:reset_billing!)단기 개선 (1주 이내)#
after_transition콜백의update_team_license_type(statable/billable.rb:78)에서, Workspace의 billing_state 변경 시 매번 Team의 license_type을 재계산하는 것이 아니라, batch 처리 후 마지막에 한 번만 계산하도록 개선.reset_billing!메서드에 guard clause 추가: 현재 state가 이미none이면 early return.
장기 개선 (재발 방지)#
- Workspace 수가 많은 Team에 대해
reset_billing!을 background job으로 전환 (Sidekiq worker). - State machine 콜백에서
update_team_license_type을 immediate 실행 대신 dirty flag 방식으로 변경하여, 트랜잭션 종료 시 한 번만 실행되도록 아키텍처 개선.
Monitoring#
- APM trace duration 모니터:
resource_name:"Api::V1::Admin::QuotesController#apply"평균 응답시간 2초 초과 시 알림 - Workspace 수가 많은 Team에 대한 Quote apply 패턴 추적:
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::admin::quotescontroller_apply} > 2000
Risk Assessment#
- Risk level: low
- 예상 복잡도: standard
- 근거: 관리자 전용 엔드포인트이며 일반 사용자에게 노출되지 않음. 요청은 성공적으로 완료됨 (200 OK). 빈도가 매우 낮음 (1건). 단, Workspace가 더 많은 Team에 대해 적용하면 지연이 더 심해질 수 있음.