ES /docs

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#

  1. 2026-05-31 18:02:59 KST — Quote application begins (Team 323)
  2. 2026-05-31 18:02:59 KST — Deactivate existing products, add team products, update billing_info
  3. 2026-05-31 18:02:59 KST — Activate billing state → after_active_billing_state 콜백 시작
  4. 2026-05-31 18:02:59~18:03:01 KST — 50+ Workspace billing_state 개별 리셋 (none → none 전환)
  5. 2026-05-31 18:03:03 KST — Quote state changed paid → applied
  6. 2026-05-31 18:03:03 KST — Quote application finished

Error Log#

Datadog Logs

text
{
  "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 활성화:

app/models/quote.rb:126-128ruby
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를 순회:

app/models/concerns/billable/team.rb:13-16ruby
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 발생:

app/models/concerns/resetable/billable.rb:19-27ruby
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 재계산:

app/models/concerns/statable/billable.rb:75-79ruby
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! 호출:

app/models/concerns/statable/billable.rb:86-93ruby
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 계산:

app/models/concerns/license_type.rb:8-35ruby
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_typeteam.update_license_type! (DB query 2-3개 + conditional save) = 최소 100-150 DB 쿼리가 단일 요청에서 실행됨.

Log Evidence#

Datadog 검색 쿼리:

text
service:cupixworks-api "[Workspace] billing_state" from:2026-05-31T09:02:00Z to:2026-05-31T09:04:00Z

50개 이상의 Workspace billing_state 전환 로그 확인 (전부 none → none 무의미한 전환):

text
[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 실행 타임라인:

text
[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 변경 로그 (불필요한 리셋 후 재설정):

text
[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을 제거.
app/models/concerns/billable/team.rb:15
- 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 패턴 추적:
text
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에 대해 적용하면 지연이 더 심해질 수 있음.