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#
- 2026-05-26T09:32:04Z — 첫 번째 create 요청 (1482ms, DB: 114ms, User-Agent: Prism/CS/1.1.6)
- 2026-05-26T11:29:23Z — 두 번째 create 요청 (1396ms, DB: 56ms, User-Agent: Retool/2.0)
- 2026-05-26T11:31:47Z — 세 번째 create 요청 (1232ms, DB: 43ms, User-Agent: Retool/2.0)
- 2026-05-26T11:31:45Z — 클러스터 감지 (last_seen)
Error Log#
{
"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
def create
@model = factory_instance.create!(params)
super
end
- Factory create flow:
app/factories/quote_factory.rb:20-45
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
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
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
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 요청 로그:
service:cupixworks-api resource_name:"Api::V1::Admin::QuotesController#create" env:production @duration:>500ms
{
"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
}
{
"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
}
{
"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(1232prepare_line_items 내 opportunity_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#createp95 latency 추적 및 500ms 초과 시 알림 설정 - Salesforce API 호출 duration을 별도 span으로 계측하여 외부 의존성 모니터링
service:cupixworks-api resource_name:"Api::V1::Admin::QuotesController#create" @duration:>500ms
Risk Assessment#
- Risk level: low
- 예상 복잡도: standard
- 내부 운영 도구(Retool/Prism)에서만 사용되는 Admin 엔드포인트로, 고객 대면 서비스 영향 없음. 그러나 Salesforce API 장애 시 quote 생성이 완전히 차단될 수 있는 구조적 위험이 존재함.