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#
- 2026-05-27 06:20~06:33 — review
2y44ie에 대해 연속적인 field update 요청 20건+ 발생 (annotations 7309~7328) - 2026-05-27 06:28:00 — trace ID
4087585538206037190에서 1164ms 레이턴시 기록 - 2026-05-27 16:29 — RCA 분석 시점
Error Log#
{
"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(updateaction) - 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 — 권한 조회 (주요 병목)
def set_field
@model = repository_instance.show(params[:id])
end
repository_instance.show는 BaseRepository.show를 호출하고, 이때 FieldRepository.permission_joins가 실행된다:
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 권한을 한 번에 확인한다:
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
belongs_to :fieldable, optional: true, polymorphic: true, touch: true
save! 호출 시 fieldable(Annotation)의 updated_at도 갱신된다. 이로 인해 annotation에도 연쇄적으로 after_commit 콜백이 발생할 수 있다.
3. after_commit — DataWareHouse CDC
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를 확인:
Datadog query: service:cupixworks-api "FieldsController#update" from:2026-05-27T06:20:00Z to:2026-05-27T06:35:00Z
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):
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 응답을 정상 반환:
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::PartialJson의 after_commit이 perform_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_fieldbefore_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#updatep95 레이턴시에 대한 알림 설정 (threshold: 1000ms)- Datadog APM 쿼리:
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쿼리의 구조적 복잡도는 데이터 증가 시 악화될 수 있어 장기적 모니터링 필요.