ES /docs

Api::V1::AnnotationLayersController#associate_form_designs (avg 1327ms, max 1763ms)

RCA: AnnotationLayersController#associate_form_designs Latency

Overview#

What Happened#

2026-05-27 04:33~05:10 UTC 사이에 cupixworks-api 서비스의 Api::V1::AnnotationLayersController#associate_form_designs 엔드포인트에서 평균 1327ms, 최대 1763ms(APM 기준)의 응답 지연이 7회 발생했다. 로그에서 확인된 실제 최대 duration은 8712ms(annotation_layer 5786)이며, DB 시간이 대부분을 차지한다.

Quick Facts#

Field Value
resource_name Api::V1::AnnotationLayersController#associate_form_designs
top_frame app/models/concerns/associatable/annotation_layer.rb:12-18
env production, ap-southeast-2
deploy production-ap-southeast-2-20260527t0503z0-650f3601-cupixworks

Affected Teams#

Team / Domain Error Count Impact
endeavourgroup (team_id: 180) 7 Annotation layer 일괄 form design 연결 작업 시 사용자 체감 지연 (1~8초)

Timeline#

  1. 2026-05-27T04:33:58Z — 최초 slow trace 감지 (APM, >500ms threshold)
  2. 2026-05-27T05:07:43Z — 최악 케이스: annotation_layer 5786, duration 8712ms / db 4744ms
  3. 2026-05-27T05:10:15Z — 마지막 slow trace (annotation_layer 5788, duration 1101ms)
  4. 2026-05-27 — RCA 수행

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::AnnotationLayersController#associate_form_designs",
  "service": "cupixworks-api",
  "occurrences": 7,
  "avg_ms": 1327,
  "max_ms": 1763,
  "sample_trace_id": "3591269114273289778"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 7
  • 최초 발생: 2026-05-27T04:33:58.451Z
  • 최근 발생: 2026-05-27T05:10:15.239Z

Root Cause Summary#

한 사용자(Emily Holmes, team: endeavourgroup)가 약 20개의 annotation layer에 대해 순차적으로 associate_form_designs를 호출하는 일괄 작업을 수행했다. 이 엔드포인트의 코드 경로에는 두 가지 성능 문제가 존재한다: (1) find_by(associated_resourcable: form_designs)가 ActiveRecord 배열을 전달받을 때 개별 쿼리를 생성하며, (2) form_designs.each { create! } 루프에서 매 레코드마다 INSERT + uniqueness validation 쿼리 + after_commit :write_cache 콜백이 실행된다. associations 테이블에 uniqueness validation 대상인 (associatable_id, associatable_type, associated_resourcable_id, associated_resourcable_type) 복합 인덱스가 없어 validation 시 테이블 스캔이 발생하며, 이것이 DB 시간의 대부분을 차지한다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/concerns/form_design_associatable_controller.rb:10
  • before_action set_annotation_layerrepository_instance.show(params[:id]) (annotation layer 로드)
  • associate_form_designsrepository_instance.associate_form_designs(form_design_ids)
  • Repository concern: app/repositories/concerns/form_design_associatable_repository.rb:4-10
  • Model concern (failure point): app/models/concerns/associatable/annotation_layer.rb:11-18
  • 응답 렌더: showrender_api with AnnotationLayerSerializer

1단계: Repository에서 FormDesign 조회

app/repositories/concerns/form_design_associatable_repository.rb:4-9ruby
def associate_form_designs(form_design_ids)
  raise Cupix::Errors::PermissionDenied.new(code: 'PERM10000', reason: 'Permission denied') unless self.model.updatable_by?(self.current_user)

  form_designs = ::FormDesign.where(team: self.current_team).where(id: form_design_ids)

  self.model.associate_form_designs(form_designs, current_user: self.current_user)
end

이 단계는 정상적이다. FormDesign.where(id: [...]) 는 단일 쿼리.

2단계: 중복 체크 (N+1 문제)

app/models/concerns/associatable/annotation_layer.rb:11-14ruby
def associate_form_designs(form_designs, current_user: nil)
  associations = self.associations.find_by(associated_resourcable: form_designs)

  raise Cupix::Errors::InvalidState.new(code: 'STAT40000', reason: 'Annotation Layer already associated with form_design') unless associations.blank?

find_by에 ActiveRecord relation을 전달하면 첫 번째 매칭 레코드만 반환하지만, 내부적으로 polymorphic association에 대한 조건절 생성 시 비효율적 쿼리가 발생한다.

3단계: 개별 INSERT 루프 (핵심 병목)

app/models/concerns/associatable/annotation_layer.rb:16-18ruby
  form_designs.each do |form_design|
    self.associations.create!(associated_resourcable: form_design, user: current_user)
  end
end

create! 호출마다:

  1. before_validation 콜백으로 user_id, team_id 설정 (추가 쿼리 가능)
  2. Uniqueness validation — SELECT 쿼리로 (associatable_id, associatable_type, associated_resourcable_id, associated_resourcable_type) 조합 확인
  3. INSERT 실행
  4. after_commit :write_cache (Cachable concern) — Redis에 캐시 기록 + association_cache_keys 생성

4단계: Uniqueness validation에 복합 인덱스 부재

app/models/association.rb:19-21ruby
validates :associatable_id, uniqueness: {
  scope: %i[associatable_type associated_resourcable_id associated_resourcable_type]
}

DB 스키마에는 (associatable_type, associatable_id) 인덱스만 있고, validation에 필요한 4-column 복합 인덱스가 없다:

db/schema.rb:596-599ruby
t.index ["associatable_type", "associatable_id"], name: "index_associations_on_associatable_type_and_associatable_id"
t.index ["associated_resourcable_type", "associated_resourcable_id"], name: "index_associations_on_associated_resourcable"
t.index ["team_id"], name: "index_associations_on_team_id"
t.index ["user_id"], name: "index_associations_on_user_id"

기대 동작: uniqueness check가 인덱스를 타서 <1ms에 완료되어야 함. 실제 동작: 복합 인덱스 부재로 partial index scan 또는 table scan 발생, 테이블 크기에 비례하여 느려짐.

Log Evidence#

사용한 Datadog 쿼리:

text
service:cupixworks-api "AnnotationLayersController" "associate_form_designs"
Time range: 2026-05-27T03:30:00Z to 2026-05-27T06:00:00Z

핵심 로그 항목 (raw 형태):

json
{
  "timestamp": "2026-05-27T05:07:43.589Z",
  "action": "associate_form_designs",
  "controller": "Api::V1::AnnotationLayersController",
  "duration": 8712.62,
  "db": 4744.56,
  "view": 0.09,
  "serialization": { "duration": 0 },
  "params": { "id": "5786" },
  "user": { "id": 8185, "email": "emily.holmes@edg.com.au" },
  "team": { "domain": "endeavourgroup", "id": 180 },
  "http": { "status_code": 200, "method": "PUT" },
  "host": { "name": "ip-10-1-18-50.ap-southeast-2.compute.internal" }
}
json
{
  "timestamp": "2026-05-27T05:04:55.943Z",
  "duration": 2442.79,
  "db": 1858.91,
  "params": { "id": "5784" },
  "host": { "name": "ip-10-1-145-251.ap-southeast-2.compute.internal" }
}
json
{
  "timestamp": "2026-05-27T05:09:01.760Z",
  "duration": 1439.28,
  "db": 837.13,
  "params": { "id": "5787" }
}

패턴 분석:

  • 모든 요청이 동일 사용자/세션: session_id 4d784f073142b4eb9fc3465849b99dd1c6874e33
  • 순차적 annotation_layer ID (5769~5788): 일괄 연결 작업
  • DB 시간이 전체 duration의 50-75% 차지
  • view/serialization 시간은 0ms 수준 — 렌더링은 병목 아님
  • 두 호스트에 분산 (ip-10-1-18-50, ip-10-1-145-251) — 로드밸런서 뒤에서 처리

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 N+1 쿼리 + 복합 인덱스 부재로 uniqueness validation이 느림 db 시간이 duration의 50-75% 차지 (585~4744ms). associations 테이블에 4-column 복합 인덱스 없음 (schema.rb:596-599). 코드에서 form_designs.each { create! } 루프 확인 (annotation_layer.rb:16-18) Confirmed
H2 AP-southeast-2 리전의 DB 인스턴스 자체가 느려서 발생한 일시적 문제 annotation_layer 5786에서 8712ms/4744ms로 비정상적 spike 존재 다른 요청들도 consistently 500ms+ DB 시간 보임 — 일시적이 아닌 구조적 문제. 같은 시간대 다른 엔드포인트는 정상 Rejected
H3 많은 수의 form_design을 한 번에 연결하여 루프 횟수가 많음 단일 세션에서 20개+ annotation layer를 순차 호출 각 요청의 params에 form_design_ids 개수 미확인 (로그에 미포함). 하지만 DB 시간 패턴으로 보아 다수의 form_design일 가능성 높음 Inconclusive

Fix Recommendation#

즉시 조치 (Critical)#

  • db/migrate/associations 테이블에 복합 인덱스 추가: add_index :associations, [:associatable_type, :associatable_id, :associated_resourcable_type, :associated_resourcable_id], unique: true, name: 'index_associations_uniqueness'
  • 이 인덱스가 uniqueness validation의 SELECT 쿼리를 O(1) 수준으로 만듦

단기 개선 (1주 이내)#

  • app/models/concerns/associatable/annotation_layer.rb:16-18each { create! } 루프를 insert_all 또는 import로 교체하여 batch INSERT 사용
  • app/models/concerns/associatable/annotation_layer.rb:12find_bywhere(...).exists?로 변경하여 중복 체크를 단일 쿼리로 수행
  • Cachable after_commit :write_cache가 batch insert에서도 작동하는지 확인 필요 (write_cache가 개별 호출되면 추가 최적화 대상)

장기 개선 (재발 방지)#

  • Batch API 도입: 여러 annotation layer에 동일 form_design을 연결하는 경우 단일 API call로 처리 가능하도록 bulk endpoint 제공
  • Associatable concern 전체에 대한 N+1 감사 — disassociate_form_designs도 동일 패턴 가능

Monitoring#

  • APM 모니터 추가: resource_name:"Api::V1::AnnotationLayersController#associate_form_designs" @duration:>1000ms
  • 인덱스 추가 후 DB 시간 추이 확인:
text
avg:trace.rails.request.duration{resource_name:api::v1::annotationlayerscontroller#associate_form_designs,env:production} by {region}

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard (인덱스 마이그레이션은 간단하나, 대형 테이블이면 online DDL 고려 필요)