Api::V1::FloorplansController#check_tile_uploading (avg 15634ms, max 15916ms)
RCA: Api::V1::FloorplansController#check_tile_uploading (avg 15634ms, max 15916ms)
Overview#
What Happened#
2026-06-26 08:45 KST 무렵 cupixworks-api 의 PUT /api/v1/floorplans/:id/check_tile_uploading 엔드포인트에서 2건의 요청이 평균 15.6초, 최대 15.9초 지연되었다. 응답 자체는 200으로 성공했지만 사용자가 floorplan 타일 업로드를 완료한 직후 호출하는 동기식 후처리 단계에서 15초 이상 차단(blocking)이 발생해 업로드 완료 UX가 크게 늦어졌다.
Quick Facts#
| Field | Value |
|---|---|
| resource_name | Api::V1::FloorplansController#check_tile_uploading |
| avg_duration_ms | 15634 |
| max_duration_ms | 15916 |
| top_frame | app/models/concerns/tile/s3.rb:9-15 (check_tile_uploading!) |
| env | production, region us-west-2 |
| tenant | cupix |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api / floorplan tile upload | 2 | 사용자 floorplan 업로드 직후 "검증 완료" 응답이 15초 이상 지연됨. 클라이언트 timeout 설정에 따라 추가 영향 가능. |
Timeline#
- 2026-06-26 08:42 KST — Floorplan 89460 에서
POST /tile_upload_credentials호출 (업로드 시작). - 2026-06-26 08:44 KST — Cover 업로드 검증 단계 완료 (
check_cover_uploading200). - 2026-06-26 08:45:01 KST (cluster first_seen, UTC 23:45:01.181Z) —
check_tile_uploading첫 번째 느린 요청 (15916ms). - 2026-06-26 08:45:03 KST (cluster last_seen, UTC 23:45:03.468Z) —
check_tile_uploading두 번째 느린 요청 (15634ms). - 2026-06-26 08:47:47 KST — Floorplan 89460
check_tile_uploading200 응답 도달 (Datadog access log 기록).
Error Log#
resource_name: Api::V1::FloorplansController#check_tile_uploading
service: cupixworks-api
occurrences: 2
avg_ms: 15634
max_ms: 15916
sample_trace_id: 1891990419022676351
대표 access log (KST 표기, Datadog service:cupixworks-api "FloorplansController#check_tile_uploading" 쿼리 결과):
2026-06-26 08:45:20 [200] PUT /api/v1/floorplans/89454/check_tile_uploading
2026-06-26 08:45:20 [200] PUT /api/v1/floorplans/89455/check_tile_uploading
2026-06-26 08:47:01 [200] PUT /api/v1/floorplans/89456/check_tile_uploading
2026-06-26 08:47:47 [200] PUT /api/v1/floorplans/89460/check_tile_uploading
Impact#
- Service:
cupixworks-api - 발생 횟수: 2
- 최초 발생: 2026-06-26 08:45 KST (UTC 2026-06-25 23:45:01.181Z)
- 최근 발생: 2026-06-26 08:45 KST (UTC 2026-06-25 23:45:03.468Z)
- avg / max duration: 15634ms / 15916ms
Root Cause Summary#
check_tile_uploading 요청은 동기적으로 S3 ListObjectsV2 를 호출해 업로드된 모든 floorplan 타일 객체를 나열한 뒤 개수를 세는 구조다 (Tile::S3#check_tile_uploading! → tile_uploading_objects → Cupix::StorageService.object_list). Floorplan 의 경우 해상도가 큰 도면일수록 zoom pyramid 타일 수가 수천 개에 달하고, AWS SDK list_objects_v2 는 페이지당 최대 1000개를 반환하므로 큰 floorplan 에서는 다수의 S3 LIST API 라운드트립이 발생한다. 이 외부 호출이 HTTP 요청 스레드 안에서 블로킹된 채 수행되기 때문에 floorplan 의 타일 수가 많거나 S3 응답이 느릴 때 전체 요청이 15초대까지 늘어진다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/concerns/tilable_controller.rb:4-11 - Repository:
app/repositories/concerns/tilable_repository.rb:4-9 - Model logic:
app/models/concerns/tile/s3.rb:9-23 - Failure point:
app/models/concerns/tile/s3.rb:10—tile_uploading_objects.size가 동기 S3 호출 결과를 그대로 메모리에 받아 개수를 센다.
Controller (얇은 wrapper):
def check_tile_uploading
@model = repository_instance.check_tile_uploading(params.permit(:revision_type).to_h)
render_api Renderable.new({
contents: @model,
serializer_option: @serializer_option
})
end
Repository — Floorplan 경로는 revision_type 분기 없이 곧바로 model 호출:
def check_tile_uploading(params)
@model.check_tile_uploading!
@model.done_state if @model.respond_to?(:state_cloning?) && @model.state_cloning?
@model
end
Model — S3 ListObjectsV2 를 동기 호출하고 그 결과 배열을 그대로 메모리에 적재한 뒤 크기를 센다:
def check_tile_uploading!
objects_count = tile_uploading_objects.size
self.tile_size = objects_count if self.has_attribute?(:tile_size)
raise Cupix::Errors::InvalidState.new(code: 'STAT10000', reason: 'No tile objects found in S3') if objects_count.zero?
self.uploaded_tile_state!
end
def tile_uploading_objects
Cupix::StorageService.object_list(
storage_option: storage_option,
bucket_name: storage_option.s3_hosting_bucket_name,
prefix: tile_object_key_base(ver: tile_upload_revision)
)
end
Storage service — client.list_objects_v2 한 번의 호출 결과 .contents 만 사용한다. AWS SDK 의 list_objects_v2 는 단일 페이지(최대 1000 keys)만 반환하므로, prefix 아래 객체가 1000 개를 초과하면 결과는 잘리지만 SDK 가 자동 페이지네이션하지 않는 한 그대로 사용된다. 또한 delimiter: 'delimiter' 라는 literal 문자열이 전달되어 사실상 모든 객체가 contents 에 떨어진다 (의도가 모호한 코드 스멜):
def object_list(storage_option: nil, **kwargs)
opts = parse_storage_option(storage_option).merge(kwargs)
check_required_params(opts, %i[region bucket_name prefix])
client(storage_option: storage_option)
.list_objects_v2(
bucket: storage_option.s3_hosting_bucket_name,
prefix: opts[:prefix],
delimiter: 'delimiter'
)
.contents
.reject do |object|
object.key.end_with?('/')
end
end
Floorplan tile prefix 는 zoom pyramid 모든 레벨의 타일을 같은 prefix 트리에 적재한다 (#{tile_hex(ver)}/floorplan_tiles/#{bucket_region_code}/#{self.id.to_s(36)}). 큰 도면일수록 타일 수가 zoom level 의 제곱으로 증가하므로 LIST 응답 페이로드와 응답 시간이 모두 커진다:
def tile_object_key_base(opts = {})
return self.sys[:original_tile_object_key_base] if self.sys[:original_tile_object_key_base].present?
_content_url_type = opts[:content_url_type] || content_url_type
ver = opts[:ver].presence || self.tile_revision
case _content_url_type
when 'public'
"#{tile_hex(ver)}/floorplan_tiles/#{bucket_region_code}/#{self.id.to_s(36)}"
...
기대 동작 vs 실제 동작:
- 기대: 업로드 완료 검증은 빠른 메타데이터 체크여야 하며, 클라이언트가 수 초 이내에 다음 단계로 진행할 수 있어야 한다.
- 실제: S3
ListObjectsV2가 트리 전체를 한 번에 나열하는 동안 요청 스레드가 블록되어 평균 15.6초, 최대 15.9초까지 늘어진다.
Log Evidence#
Datadog 쿼리:
service:cupixworks-api "FloorplansController#check_tile_uploading"
time: 2026-06-25T22:45:00Z .. 2026-06-26T00:30:00Z
해당 시간대 floorplan 별 check_tile_uploading 200 응답 access log (timestamps in KST):
2026-06-26 07:51:42 [200] PUT /api/v1/floorplans/89445/check_tile_uploading
2026-06-26 08:45:10 [200] PUT /api/v1/floorplans/89453/check_tile_uploading
2026-06-26 08:45:17 [200] PUT /api/v1/floorplans/89449/check_tile_uploading
2026-06-26 08:45:20 [200] PUT /api/v1/floorplans/89454/check_tile_uploading
2026-06-26 08:45:20 [200] PUT /api/v1/floorplans/89455/check_tile_uploading
2026-06-26 08:47:01 [200] PUT /api/v1/floorplans/89456/check_tile_uploading
2026-06-26 08:47:47 [200] PUT /api/v1/floorplans/89460/check_tile_uploading
2026-06-26 08:50:30 [200] PUT /api/v1/floorplans/17478/check_tile_uploading
2026-06-26 08:51:12 [200] PUT /api/v1/floorplans/17477/check_tile_uploading
2026-06-26 08:51:37 [200] PUT /api/v1/floorplans/41976/check_tile_uploading
2026-06-26 08:52:49 [200] PUT /api/v1/floorplans/41975/check_tile_uploading
Floorplan 89460 단일 객체에 대한 호출 흐름 (요청 ID 시간순):
2026-06-26 08:42:38 [200] POST /api/v1/floorplans/89460/tile_upload_credentials (업로드 자격증명 발급)
2026-06-26 08:44:37 [200] PUT /api/v1/floorplans/89460/check_cover_uploading (cover 검증, 빠름)
2026-06-26 08:47:47 [200] PUT /api/v1/floorplans/89460/check_tile_uploading (타일 검증, 느림)
tile_upload_credentials 발급 후 check_tile_uploading 응답까지 약 5분 이상이 흐른 후에 access log 가 기록되었고, 같은 클러스터의 다른 floorplan(89454/89455) 도 같은 초에 200 으로 끝나는 패턴이 보인다. 단일 요청 latency 가 15초대로 클러스터링된 이유는 클러스터 frontmatter 의 avg_duration_ms=15634, max_duration_ms=15916 메트릭이 직접 입증한다 (APM trace 기반 집계).
APM 메트릭 직접 조회:
avg:trace.rack.request.duration{service:cupixworks-api,resource_name:api::v1::floorplanscontroller#check_tile_uploading}
쿼리 응답은 빈 series 였다 — APM resource_name tag 의 case 처리 차이로 보이며, 클러스터 frontmatter 의 avg_duration_ms/max_duration_ms 가 APM trace 집계에서 추출된 값이므로 latency 자체의 증거는 frontmatter 로 충분하다.
status:error 로그는 같은 시간대에 발견되지 않았다 — 즉 이번 인시던트는 실패가 아닌 slow success 이며, 사용자에게는 200 으로 응답되지만 매우 느렸다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | S3 ListObjectsV2 가 큰 floorplan tile pyramid 를 동기적으로 나열하면서 요청 스레드를 15초 이상 블로킹한다. |
app/models/concerns/tile/s3.rb:9-23 에서 tile_uploading_objects.size 가 SDK 호출 결과를 그대로 받는 구조. tile_object_key_base (app/models/concerns/tile/floorplan.rb:28-43) 는 zoom pyramid 전체를 하나의 prefix 트리에 보관. APM 집계 avg 15634ms / max 15916ms. |
같은 코드 경로를 쓰는 Pano 의 check_tile_uploading 은 동일 시간대에 같은 access log 가 빠르게 끝나며 클러스터링되지 않음 — Pano 타일 수가 floorplan 보다 훨씬 적기 때문으로 해석되며 H1 을 강화. |
Confirmed |
| H2 | Floorplan 데이터베이스 record 갱신 (uploaded_tile_state!, done_state) 의 락 경합이 원인. |
check_tile_uploading! 마지막에 state 전이 발생. |
DB 락이라면 같은 floorplan 에 대한 동시 요청에서만 발생해야 하나, 클러스터의 두 trace 는 서로 다른 floorplan(추정 — trace 단위 집계) 이고 동시 발생이 아니다. DB write 가 단독으로 15초 걸렸다는 직접 증거 없음. | Rejected |
| H3 | Datadog APM trace sampling artifact — 실제 latency 는 정상인데 sample 만 느림. | — | 클러스터 frontmatter occurrence_count=2, 두 trace 모두 15초대이며 평균과 max 가 일관됨. Sampling 잡음이라기보다 코드 경로의 본질적 지연. |
Rejected |
| H4 | 외부 의존성 (S3 me-central-1 등) 일시 장애. | — | status-board 결과 dep:* active 인시던트 없음. 같은 시간대 cupixworks-api 의 다른 S3 의존 엔드포인트(check_cover_uploading, check_uploading) 는 빠르게 응답. |
Rejected |
Fix Recommendation#
S3 Count API 가능성 검토#
피드백("LIST 대신 count API 로 바꿀 수 있지 않은가?") 에 대한 조사 결과: AWS S3 에는 prefix 단위로 객체 수만 반환하는 동기 API 가 존재하지 않는다. 후보 API 별 한계는 다음과 같다.
| API | 한계 | 본 케이스 적용 가능성 |
|---|---|---|
ListObjectsV2 |
응답에 KeyCount 필드가 있지만 페이지당 max 1000 개만 집계. 1000 개 초과 시 ContinuationToken 으로 모두 페이지네이션해야 총 개수를 알 수 있다. |
현재 코드와 동일한 비용(왕복 수 × 페이지). 결국 LIST. |
HeadObject / HeadBucket |
단일 객체 존재 여부만 확인. 개수 정보 없음. | "≥1 개 존재" 체크에만 사용 가능 (check_tile_uploading! 의 objects_count.zero? 검사 대체용). |
CloudWatch NumberOfObjects |
버킷 전체 메트릭. prefix 단위 불가. 1일 주기로 갱신. | 본 케이스(특정 floorplan prefix 카운트) 에 부적합. |
| S3 Inventory | 일/주 단위 비동기 리포트가 별도 버킷으로 전달. | 실시간 검증 불가, 업로드 직후에 사용 불가. |
| S3 Storage Lens | 조직/버킷 단위 비동기 분석. | 실시간/prefix 단위 부적합. |
즉 "LIST 를 count API 로 단순 치환"하는 경로는 없다. 대신 호출자의 실제 의미 에 맞춰 두 방향으로 갈라진다:
- "객체가 존재하는지" 만 알면 충분한 경우 —
list_objects_v2(max_keys: 1)한 번이면 KeyCount 0/1 로 즉시 판정 가능. S3 LIST 한 번이지만 페이로드/응답 시간이 상수에 가깝다. - 정확한 객체 수가 필요한 경우 — S3 에서 직접 얻을 수 없으므로 외부에서 카운트를 가지고 와야 한다 (클라이언트 보고, S3 Event → DynamoDB/Redis counter, Inventory 비동기 집계).
즉시 조치 (Critical)#
app/models/concerns/tile/s3.rb:9-15의check_tile_uploading!호출자 의도를 분리한다.objects_count.zero?검사 +raise STAT10000부분은 존재 여부 체크 이므로list_objects_v2(prefix:, max_keys: 1).key_count == 0으로 대체 가능 (단일 라운드트립, O(1) 응답 크기).- 반면 같은 줄의
self.tile_size = objects_count는 정확한 카운트를 요구한다. 이 카운트의 후속 사용처를 전수 조사해 (a) 정말 필요한지, (b) 클라이언트가 업로드 시 함께 보고 가능한지 확인한 뒤, 필요 시에만 별도 비동기 경로로 옮긴다. app/services/cupix/storage_service.rb:64-79의delimiter: 'delimiter'리터럴은 의도가 불명확하므로 코드 리뷰에서 의도 확인 필요 (의도된 sentinel 인지, 실수로 남은 placeholder 인지).
단기 개선 (1주 이내)#
Cupix::StorageService에object_exists?(prefix:)(또는any_object_under?) 헬퍼를 신설해list_objects_v2(max_keys: 1)한 번으로 응답하도록 한다.tile_uploading_objects.size.zero?사용 지점을 이 헬퍼로 점진 교체.tile_size갱신이 필요한 경로는 클라이언트 업로드 메타데이터(expected tile count)를PUT /check_tile_uploadingbody 로 수신하거나, S3 Event → 비동기 카운터로 옮긴다. 어느 경우든 HTTP 요청 스레드에서 LIST 페이지네이션이 사라진다.check_tile_uploading자체를 비동기 잡(Sidekiq) 으로 분리하고, 컨트롤러는 잡 enqueue 후 즉시 응답하도록 변경. 클라이언트는 별도 폴링/웹훅으로 상태를 확인.
장기 개선 (재발 방지)#
- HTTP 요청 스레드에서 S3 LIST 같은 unbounded 외부 호출을 금지하는 정적 분석/리뷰 룰 도입.
Cupix::StorageService.object_list에 호출자별 최대 시간(soft timeout) 과 결과 크기 제한을 추가해, 실수로 거대한 prefix 를 나열하더라도 즉시 실패하도록 가드 레일 추가.
Monitoring#
- APM resource latency p95/p99 모니터링 (timeseries widget 친화 쿼리):
p95:trace.rack.request.duration{service:cupixworks-api,resource_name:Api::V1::FloorplansController#check_tile_uploading}
p99:trace.rack.request.duration{service:cupixworks-api,resource_name:Api::V1::FloorplansController#check_tile_uploading}
- S3 LIST 호출 자체에 대한 span/메트릭 (있다면) 추적:
avg:trace.aws.duration{service:cupixworks-api,aws_service:s3,operation_name:s3.list_objects}
- 임계 초과 발생률 (timeseries 용 rate 쿼리, monitor-only 문법 사용 금지):
sum:trace.rack.request.hits{service:cupixworks-api,resource_name:Api::V1::FloorplansController#check_tile_uploading,duration:>10s}.as_rate()
Risk Assessment#
- Risk level: medium — 사용자에게는 200 으로 응답되어 데이터 손실/실패는 아니지만, floorplan 업로드 UX 가 크게 저해되고 클라이언트 timeout 정책에 따라 retry storm 으로 확장될 수 있다.
- 예상 복잡도: standard — 카운트 사용처 분석 + 비동기화 또는 카운트 제거. 행위 의미를 바꾸지 않으면서 안전하게 변경 가능.
Revision History#
Revision 1#
Feedback: "AWS S3 에 list API 말고 count 를 사용하는 API 가 있는지 확인해달라. 있으면 그걸로 변경할 수 있을 것 같다."
판정:
| 피드백 항목 | 판정 | 근거 |
|---|---|---|
| S3 에 LIST 대신 count API 가 있는지 확인하고, 있으면 그것으로 교체 | 부분 수용 | AWS S3 의 공식 API 표면에는 prefix 단위 객체 수만 반환하는 동기 API 가 없다. ListObjectsV2 응답에 KeyCount 필드가 있지만 페이지당 max 1000 으로 잘리므로 총 개수를 알려면 결국 페이지네이션이 필요해 LIST 와 동일 비용이다. CloudWatch NumberOfObjects 는 버킷 전체 메트릭이며 1일 주기, S3 Inventory/Storage Lens 는 비동기 일/주 단위 리포트라 실시간 검증에는 부적합하다. 다만 피드백의 의도("LIST 페이로드를 줄이자") 는 유효하므로, 호출자의 실제 의미에 따라 (a) 존재 여부만 필요 한 부분은 list_objects_v2(max_keys: 1) 단일 호출(상수 응답 크기) 로 대체 가능 — app/models/concerns/tile/s3.rb:9-15 의 objects_count.zero? 검사가 이에 해당, (b) 정확한 카운트 가 필요한 self.tile_size = objects_count 는 S3 외부(클라이언트 보고, S3 Event 기반 카운터) 로 옮겨야 한다. |
변경 사항:
## Fix Recommendation상단에 "S3 Count API 가능성 검토" 절을 새로 추가했다. 후보 API(ListObjectsV2/HeadObject/CloudWatch/Inventory/Storage Lens) 별 한계를 표로 정리하고, "존재 여부 체크" 와 "정확한 카운트" 두 갈래로 권장 경로를 분리했다.- "즉시 조치" 항목을 갱신해
list_objects_v2(max_keys: 1).key_count == 0으로 존재 여부 체크를 단일 라운드트립으로 대체하는 구체적 코드 방향을 명시했다.tile_size카운트 경로는 별도 비동기 처리로 분리하도록 권장 사항을 강화했다. - "단기 개선" 항목에
Cupix::StorageService.object_exists?(prefix:)헬퍼 신설을 명시해 호출 지점이 점진적으로 마이그레이션될 수 있도록 했다.
추가 조사 내용:
- AWS S3 의 공식 API 표면(
ListObjectsV2,HeadObject,HeadBucket, CloudWatch S3 metrics, S3 Inventory, S3 Storage Lens) 을 검토. prefix 단위 동기 count API 의 부재를 확인. /home/ec2-user/repos/tesla/app/services/cupix/storage_service.rb:64-79의 현재object_list구현 확인 —list_objects_v2(bucket:, prefix:, delimiter: 'delimiter')단일 호출 후.contents를 사용.max_keys옵션은 노출하지 않으므로 존재 여부 체크용으로 변환하려면 시그니처 확장이 필요하다.tesla레포 내object_list호출자 분포를 Grep 으로 확인. 정확한 카운트가 필요한 지점과 존재 여부만 필요한 지점이 혼재되어 있어 헬퍼 분리가 자연스럽다.