ES /docs

QuoteFactory sequential Salesforce API calls — N+1 latency

RCA: Api::V1::Admin::QuotesController#create Latency (avg 1372ms)

Overview#

What Happened#

2026-05-26 09:32~11:31 UTC 사이에 cupixworks-api 서비스의 Api::V1::Admin::QuotesController#create 엔드포인트가 3회 호출되었으며, 평균 1372ms (최대 1484ms)의 응답 지연이 발생했다. 모든 요청은 HTTP 200으로 성공했으나 SLA 기준 500ms를 초과하는 latency를 보였다.

Quick Facts#

Field Value
resource_name Api::V1::Admin::QuotesController#create
top_frame app/factories/quote_factory.rb:53-58
env production, eu-central-1
avg_duration 1372ms
max_duration 1484ms

Timeline#

  1. 2026-05-26T09:32:04Z — 첫 번째 create 요청 (1482ms, DB: 114ms, User-Agent: Prism/CS/1.1.6)
  2. 2026-05-26T11:29:23Z — 두 번째 create 요청 (1396ms, DB: 56ms, User-Agent: Retool/2.0)
  3. 2026-05-26T11:31:47Z — 세 번째 create 요청 (1232ms, DB: 43ms, User-Agent: Retool/2.0)
  4. 2026-05-26T11:31:45Z — 클러스터 감지 (last_seen)

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::Admin::QuotesController#create",
  "service": "cupixworks-api",
  "occurrences": 3,
  "avg_ms": 1372,
  "max_ms": 1484,
  "sample_trace_id": "4746949182855030506"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 3
  • 최초 발생: 2026-05-26T09:32:01.151Z
  • 최근 발생: 2026-05-26T11:31:45.389Z
  • 영향 범위: Admin quote 생성 기능 — 내부 운영팀(darren.murphy@cupix.com)이 Retool/Prism 도구를 통해 사용하는 엔드포인트로, 고객 대면 서비스에 직접적 영향은 없으나 운영 효율이 저하됨.

Root Cause Summary#

QuoteFactory#create!prepare_line_items 메서드가 각 line item마다 동기 HTTP 호출로 Salesforce API를 호출(Restforce select)하며, 이 외부 API 호출이 요청당 400500ms의 latency를 유발한다. DB 시간은 42114ms에 불과하지만, Salesforce 라운드트립이 총 응답 시간의 대부분(11001370ms)을 차지하고 있다. Restforce 클라이언트는 매 요청마다 새로 인스턴스화되어 연결 재사용이 없고, line item이 복수인 경우 순차적으로 API를 호출하여 latency가 선형 증가한다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/admin/quotes_controller.rb:8-11
app/controllers/api/v1/admin/quotes_controller.rb:8-11ruby
def create
  @model = factory_instance.create!(params)
  super
end
  • Factory create flow: app/factories/quote_factory.rb:20-45
app/factories/quote_factory.rb:20-45ruby
def create!(params = {})
  self.model = ::Quote.new(user: self.current_user)
  self.model.team = TeamRepository.new(current_user: self.current_user).show(params[:team_id]) if params[:team_id].present?

  set_params(params)
  set_meta(params)
  set_billing_model(params)

  line_items = prepare_line_items(params) if params[:line_items].present?
  self.model.contract_months = (line_items.first.billing_expires_at.to_date - line_items.first.billing_started_at.to_date).to_i / 30

  self.model.validate!
# ...
else
  self.model = super
  create_line_items(line_items) if line_items.present?
  self.model.ready_state!
  self.model
end
  • Failure point (latency bottleneck): app/factories/quote_factory.rb:49-58
app/factories/quote_factory.rb:49-58ruby
def prepare_line_items(permitted_params = {})
  line_items = permitted_params[:line_items].map do |line_item_request|
    check_line_item_required_parameter(line_item_request)

    opportunity_client = Cupix::Salesforce::Client::Opportunity.new  # 매번 새 인스턴스 생성
    opportunity_info = opportunity_client.select_custom_field!(
      opportunity_client.class::DEFAULT_FIELDS,
      opportunity_client.class::DEFAULT_WHERE_FIELD,
      line_item_request[:sf_opportunity_id]
    )

각 line item 반복마다 Cupix::Salesforce::Client::Opportunity.new가 호출되어 Restforce 클라이언트를 새로 초기화하고, select_custom_field!가 Salesforce REST API로 동기 HTTP 요청을 수행한다.

  • Salesforce client: app/services/cupix/salesforce/client/base.rb:6-11
app/services/cupix/salesforce/client/base.rb:6-11ruby
def initialize
  @client = Restforce.new(refresh_token: $SALESFORCE[:refresh_token],
                          instance_url: $SALESFORCE[:instance_url],
                          client_id: $SALESFORCE[:client_id],
                          client_secret: $SALESFORCE[:client_secret],
                          api_version: $SALESFORCE[:api_version])
end

매 인스턴스 생성 시 Restforce 연결이 새로 설정되며, OAuth refresh token flow가 포함될 수 있어 추가 latency가 발생한다.

  • Secondary latency: line item 저장 후 callback: app/models/line_item.rb:25,33-35
app/models/line_item.rb:25,33-35ruby
after_create :sync_salesforce_opportunity

def sync_salesforce_opportunity
  LineItemOpportunitySyncWorker.perform_async(self.id)
end

이 callback은 Sidekiq으로 비동기 작업을 enqueue하므로 상대적으로 가벼우나, 복수 line item 저장 시 각각 enqueue 오버헤드가 추가된다.

Log Evidence#

Datadog에서 검색한 3개의 create 요청 로그:

text
service:cupixworks-api resource_name:"Api::V1::Admin::QuotesController#create" env:production @duration:>500ms
json
{
  "timestamp": "2026-05-26T09:32:04.439Z",
  "action": "create",
  "controller": "Api::V1::Admin::QuotesController",
  "duration_ms": 1482.17,
  "db_ms": 114.19,
  "view_ms": 0.44,
  "user_agent": "Prism/CS/1.1.6",
  "user": "darren.murphy@cupix.com",
  "status": 200
}
json
{
  "timestamp": "2026-05-26T11:29:23.782Z",
  "action": "create",
  "controller": "Api::V1::Admin::QuotesController",
  "duration_ms": 1395.90,
  "db_ms": 55.82,
  "view_ms": 0.07,
  "user_agent": "Retool/2.0",
  "user": "darren.murphy@cupix.com",
  "status": 200
}
json
{
  "timestamp": "2026-05-26T11:31:47.839Z",
  "action": "create",
  "controller": "Api::V1::Admin::QuotesController",
  "duration_ms": 1232.06,
  "db_ms": 42.77,
  "view_ms": 0.19,
  "user_agent": "Retool/2.0",
  "user": "darren.murphy@cupix.com",
  "status": 200
}

핵심 관찰:

  • 총 duration에서 DB time을 빼면 1118~1368ms가 application-level 처리 시간이다.
  • View rendering은 0.07~0.44ms로 무시할 수준이다.
  • 따라서 DB도 view도 아닌 **외부 HTTP 호출(Salesforce API)**이 대부분의 시간을 차지한다.
  • 에러/경고 로그는 해당 시간대에 없었으며, 모두 정상 200 응답이었다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Salesforce API 동기 호출이 주요 latency 원인 duration(12321482ms) - db_time(42114ms) - view_time(<1ms) = 1118~1368ms 미설명 시간. 코드에서 prepare_line_itemsopportunity_client.select_custom_field!가 line item마다 동기 HTTP 호출 수행 (quote_factory.rb:53-58). Restforce 클라이언트 매번 재생성 (base.rb:6-11). Confirmed
H2 N+1 DB 쿼리 (Product lookup per line item) quote_factory.rb:87에서 각 line item마다 Product 조회 DB time이 42~114ms로 상대적으로 낮아 주요 원인은 아님 Rejected (contributing factor)
H3 직렬화(serialization) 또는 view rendering 지연 view_ms가 0.07~0.44ms로 무시할 수준 Rejected
H4 DB 연결 풀 고갈 또는 lock contention 첫 번째 요청의 DB time이 114ms로 약간 높음 나머지 요청은 42~56ms로 정상적이며, 에러/타임아웃 로그 없음 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/factories/quote_factory.rb:53 — Salesforce 클라이언트를 반복문 밖에서 한 번만 인스턴스화하여 연결 재사용
  • app/factories/quote_factory.rb:49-91 — 동일한 opportunity ID가 여러 line item에 걸쳐 있을 경우 중복 호출 방지 (memoization)

단기 개선 (1주 이내)#

  • prepare_line_items 메서드에서 Salesforce 호출을 batch로 변환: 모든 sf_opportunity_id를 수집한 후 한 번의 SOQL 쿼리로 조회 (Restforce의 query 메서드 활용)
  • Product lookup을 loop 밖에서 batch로 수행: Product.where(id: ids).or(Product.where(code: codes)) 형태로 한 번에 조회

장기 개선 (재발 방지)#

  • Salesforce API 호출에 대한 timeout 설정 (현재 Restforce 기본값 사용 중이며, 응답이 느릴 경우 무한 대기 가능)
  • Quote 생성 프로세스를 비동기 패턴으로 전환: 즉시 quote를 draft 상태로 저장 후 Salesforce 검증을 background worker에서 수행하고 완료 시 ready 상태로 전환
  • Salesforce 응답에 대한 캐싱 레이어 추가 (동일 Opportunity가 반복 조회되는 경우)

Monitoring#

  • APM에서 QuotesController#create p95 latency 추적 및 500ms 초과 시 알림 설정
  • Salesforce API 호출 duration을 별도 span으로 계측하여 외부 의존성 모니터링
text
service:cupixworks-api resource_name:"Api::V1::Admin::QuotesController#create" @duration:>500ms

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard
  • 내부 운영 도구(Retool/Prism)에서만 사용되는 Admin 엔드포인트로, 고객 대면 서비스 영향 없음. 그러나 Salesforce API 장애 시 quote 생성이 완전히 차단될 수 있는 구조적 위험이 존재함.