ES /docs

Api::V1::FieldsController#update (avg 1164ms, max 1164ms)

RCA: Api::V1::FieldsController#update Latency (1164ms)

Overview#

What Happened#

2026-05-27 06:28 UTC에 ap-southeast-2 리전의 cupixworks-api 서비스에서 Api::V1::FieldsController#update 엔드포인트가 1164ms의 응답 시간을 기록했다. 이는 해당 엔드포인트의 p50 응답 시간(260ms) 대비 약 4.5배 느린 수치이다. 기능 에러(HTTP 5xx)는 발생하지 않았으며 정상 200 응답을 반환했다.

Quick Facts#

Field Value
resource_name Api::V1::FieldsController#update
top_frame app/controllers/api/v1/fields_controller.rb:26
env production, ap-southeast-2
avg_duration 1164ms (이 trace), 332ms (2시간 평균)
p50_duration 260ms

Timeline#

  1. 2026-05-27 06:20~06:33 — review 2y44ie에 대해 연속적인 field update 요청 20건+ 발생 (annotations 7309~7328)
  2. 2026-05-27 06:28:00 — trace ID 4087585538206037190에서 1164ms 레이턴시 기록
  3. 2026-05-27 16:29 — RCA 분석 시점

Error Log#

Datadog Logs

json
{
  "resource_name": "Api::V1::FieldsController#update",
  "service": "cupixworks-api",
  "occurrences": 1,
  "avg_ms": 1164,
  "max_ms": 1164,
  "sample_trace_id": "4087585538206037190"
}

Impact#

  • Service: cupixworks-api
  • 발생 횟수: 1
  • 최초 발생: 2026-05-27T06:28:00.692Z
  • 최근 발생: 2026-05-27T06:28:00.692Z
  • 사용자 영향: 응답 지연만 발생, 기능 장애 없음 (200 응답 정상 반환)

Root Cause Summary#

FieldsController#update의 레이턴시는 before_action :set_field에서 수행되는 FieldRepository.permission_joins 쿼리의 복잡도에 기인한다. 이 쿼리는 15개의 LEFT JOIN을 통해 review, annotation, annotation_layer, facility, workspace, team 레벨의 사용자/그룹 권한을 모두 확인한다. ap-southeast-2 리전에서 동일 review(2y44ie)에 대한 연속적인 field update 요청이 집중되면서, 각 요청마다 이 복잡한 권한 쿼리가 반복 실행되어 DB 부하가 누적되었다. 추가로, belongs_to :fieldable, touch: true 설정이 parent annotation의 updated_at을 갱신하고, after_commit 콜백에서 DataWareHouse::PartialJson이 파일 I/O를 수행하며, Eventable::Callbacks가 이벤트를 생성하는 등의 연쇄 작업이 응답 시간을 늘린다.

Technical Analysis#

Code Path#

  • Entry point: app/controllers/api/v1/fields_controller.rb:25 (update action)
  • before_action set_field: app/controllers/api/v1/fields_controller.rb:37-38
  • Permission query: app/repositories/field_repository.rb:34-306 (permission_joins)
  • Repository update: app/repositories/field_repository.rb:12-24
  • Base permission check: app/repositories/base_repository.rb:131-132
  • Model save: app/repositories/field_repository.rb:18 (@model.save!)
  • Touch parent: app/models/field.rb:17 (belongs_to :fieldable, touch: true)
  • After-commit CDC: app/models/concerns/data_ware_house/partial_json.rb:7 (save_partial_json_to_file_as_updated)
  • After-commit event: app/models/concerns/eventable/callbacks.rb:34-35

1. before_action set_field — 권한 조회 (주요 병목)

app/controllers/api/v1/fields_controller.rb:37-38ruby
def set_field
  @model = repository_instance.show(params[:id])
end

repository_instance.showBaseRepository.show를 호출하고, 이때 FieldRepository.permission_joins가 실행된다:

app/repositories/base_repository.rb:333-342ruby
query =
  if skip_permission || current_user == ::User.unauthorized_user
    where(attrs)
  elsif current_class == ::Review || (review_id || capture_id).present?
    permission_joins(default_joins(current_class), current_user, review_id: review_id || -1, capture_id: capture_id || -1).where(attrs)
  elsif current_user.present?
    permission_joins(default_joins(current_class), current_user).where(attrs)
  else
    raise Cupix::Errors::System.new(code: 'SYS30000', reason: 'current_user or review is required on Repository')
  end

FieldRepository.permission_joins는 15개의 LEFT JOIN 서브쿼리를 생성하여 review, annotation, annotation_layer, facility, workspace, team 권한을 한 번에 확인한다:

app/repositories/field_repository.rb:91-259ruby
record.joins("
  LEFT JOIN (
    SELECT reviews.id AS review_id, 2 AS permission
    FROM reviews
    where reviews.public_access_enabled_at IS NOT NULL
      AND reviews.id = #{sanitized_review_id}
  ) AS review_public_permissions
    ON review_public_permissions.review_id = #{sanitized_review_id}

  LEFT JOIN (
    SELECT review_id, permission
    FROM review_permissions
    WHERE review_permissions.accessor_id = #{sanitized_user_id}
      ...
  ) AS review_user_permissions
    ...
  -- ... 13 more LEFT JOINs ...
").group('id').select(_select).where("...")

2. Model save + touch parent

app/models/field.rb:17ruby
belongs_to :fieldable, optional: true, polymorphic: true, touch: true

save! 호출 시 fieldable(Annotation)의 updated_at도 갱신된다. 이로 인해 annotation에도 연쇄적으로 after_commit 콜백이 발생할 수 있다.

3. after_commit — DataWareHouse CDC

app/models/concerns/data_ware_house/partial_json.rb:6-8ruby
after_commit :save_partial_json_to_file_as_created, on: :create
after_commit :save_partial_json_to_file_as_updated, on: :update, if: :not_new_record?
after_commit :save_partial_json_to_file_as_destroyed, on: :destroy

업데이트 후 SavePartialJsonToFileWorker.perform_async를 호출하여 비동기 워커를 enqueue한다. 워커 enqueue 자체는 빠르지만, $FORWARD_DATA_CHANGES가 true일 때만 실행된다.

Log Evidence#

Datadog 로그에서 동일 시간대에 review 2y44ie에 대한 연속적 field update를 확인:

text
Datadog query: service:cupixworks-api "FieldsController#update" from:2026-05-27T06:20:00Z to:2026-05-27T06:35:00Z
text
2026-05-27 15:28:02 [200] PUT /api/v1/reviews/2y44ie/annotations/7320/fields/61963 (Api::V1::FieldsController#update)
2026-05-27 15:29:00 [200] PUT /api/v1/reviews/2y44ie/annotations/7321/fields/61964 (Api::V1::FieldsController#update)
2026-05-27 15:29:49 [200] PUT /api/v1/reviews/2y44ie/annotations/7322/fields/61965 (Api::V1::FieldsController#update)
2026-05-27 15:31:33 [200] PUT /api/v1/reviews/2y44ie/annotations/7323/fields/61966 (Api::V1::FieldsController#update)
2026-05-27 15:31:51 [200] PUT /api/v1/reviews/2y44ie/annotations/7324/fields/61967 (Api::V1::FieldsController#update)
2026-05-27 15:32:06 [200] PUT /api/v1/reviews/2y44ie/annotations/7325/fields/61968 (Api::V1::FieldsController#update)
2026-05-27 15:32:25 [200] PUT /api/v1/reviews/2y44ie/annotations/7326/fields/61969 (Api::V1::FieldsController#update)
2026-05-27 15:32:41 [200] PUT /api/v1/reviews/2y44ie/annotations/7327/fields/61970 (Api::V1::FieldsController#update)
2026-05-27 15:32:58 [200] PUT /api/v1/reviews/2y44ie/annotations/7328/fields/61971 (Api::V1::FieldsController#update)

APM 메트릭 분석 (최근 2시간, ap-southeast-2):

text
Datadog metric query: avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::fieldscontroller_update,region:ap-southeast-2}

Total data points: 90
Top 5 slowest:
  05:46:00 UTC: 1124ms
  05:47:00 UTC: 849ms
  06:25:00 UTC: 798ms
  06:28:30 UTC: 774ms
  06:28:00 UTC: 771ms
Average: 332ms
Median (p50): 260ms

에러 로그 없음 — 모든 요청이 200 응답을 정상 반환:

text
Datadog query: service:cupixworks-api "FieldsController" status:error from:2026-05-27T05:28:00Z to:2026-05-27T07:28:00Z
Result: 0 logs

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 permission_joins의 15-LEFT-JOIN 쿼리가 주요 병목 - 쿼리 복잡도: 15개 LEFT JOIN + GROUP BY + GREATEST 연산. set_field before_action에서 매 요청마다 실행됨. APM에서 p50=260ms인데 해당 쿼리는 단일 field 조회에 전체 팀/워크스페이스/시설 권한을 확인 - 평균 332ms로 대부분의 요청은 500ms 미만에 완료됨 Confirmed
H2 동일 review에 대한 연속 요청으로 인한 DB 커넥션 경합/캐시 미스 - 같은 시간대에 review 2y44ie에 대해 20건+ 연속 요청. 피크(1124ms)가 연속 요청 시작 시점에 집중 - 연속 요청이더라도 각각 다른 annotation/field를 대상으로 하므로 lock 경합은 아님 Confirmed
H3 touch: true로 인한 parent annotation 업데이트 cascade - belongs_to :fieldable, touch: true가 annotation의 updated_at 갱신 → DB write 추가. 연속 요청 시 같은 annotation에 대한 write 경합 가능 - 각 요청이 다른 annotation을 대상으로 하므로 row lock 경합은 제한적. touch 자체는 단일 UPDATE로 빠름 Contributing
H4 after_commit CDC 파일 I/O가 응답 시간에 포함 - DataWareHouse::PartialJsonafter_commitperform_async를 호출 - after_commit은 트랜잭션 완료 후 실행되므로 응답 시간에 직접 포함되지 않음. 워커 enqueue도 Redis 호출 한 번으로 빠름 Rejected
H5 ap-southeast-2 리전의 DB 인스턴스 성능 이슈 (크로스-리전 레이턴시) - 해당 리전에서만 발생. 전체 메트릭과 AU 리전 메트릭이 거의 동일 (동일 트래픽 소스) - 다른 리전의 동시 비교 데이터 부족. 단, AU 리전 DB가 별도 인스턴스라면 상대적으로 작은 인스턴스일 가능성 있음 Inconclusive

Fix Recommendation#

즉시 조치 (Critical)#

  • 이 케이스는 단발성 레이턴시 스파이크(1건)이며, 기능 에러를 유발하지 않았다. 즉시 조치가 필요한 수준은 아님.
  • 단, FieldRepository.permission_joins (app/repositories/field_repository.rb:34-306)의 쿼리 복잡도가 근본적으로 높으므로 모니터링 강화 권장.

단기 개선 (1주 이내)#

  • set_field before_action에서 show 호출 시 permission_joins를 단순화할 수 있는지 검토. Field는 이미 annotation에 속하므로, annotation 레벨에서 권한을 확인한 후 field를 직접 조회하는 방식으로 쿼리 단계를 줄일 수 있다.
  • FieldRepository.permission_joins에서 review_id가 없을 때 불필요한 review 관련 JOIN을 스킵하는 조건부 로직 추가 검토.

장기 개선 (재발 방지)#

  • 권한 확인 결과를 요청 단위로 캐싱하는 메커니즘 도입 (같은 사용자가 같은 review 내에서 연속으로 field를 업데이트할 때 권한 재확인 불필요).
  • permission_joins 패턴을 materialized view 또는 별도 권한 테이블로 사전 계산하여 JOIN 수를 줄이는 방안 검토.
  • 연속 field update를 batch API로 묶을 수 있는 엔드포인트 제공 검토 (N번 개별 호출 → 1번 bulk 호출).

Monitoring#

  • ap-southeast-2 리전의 FieldsController#update p95 레이턴시에 대한 알림 설정 (threshold: 1000ms)
  • Datadog APM 쿼리:
text
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::fieldscontroller_update,region:ap-southeast-2} > 1
  • 동일 review에 대한 연속 field update 빈도를 추적하여 batch API 필요성 판단에 활용

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: standard
  • 근거: 단발성 레이턴시 스파이크(1건)이며 사용자에게 에러가 노출되지 않음. 다만 permission_joins 쿼리의 구조적 복잡도는 데이터 증가 시 악화될 수 있어 장기적 모니터링 필요.