zoe

"주문이 안 돼요" 한 건에서 시작된 사고

카드 주문 마지막 화면에서 등록이 안 된다는 CS 한 건이 들어왔다. 센트리 로그를 따라가 보니 마지막 등록 시점에 S3 PUT 이 실패하고 있었다. 사용자 입장에서는 step2 까지 다 채워두고 마지막에 막혀서, 다시 step1 으로 돌아가 이미지를 바꿔 끼워야 했다.

첫 의심은 "이미지 용량 사전 검증을 빠뜨린 건가" 였다. 그게 맞기는 했다. 다만 한 발 떨어져서 보면, 이 사고는 단순한 검증 누락의 문제만은 아니었다. 마지막에 큰 PUT 을 한 번 더 보내야 동작하는 패턴, 우리가 화면 대부분에서 쓰고 있던 지연 업로드의 구조가 만들어낸 사고이기도 했다.

우리가 쓰는 업로드 방식

회사 백엔드는 이미지 업로드를 presigned URL 패턴으로 처리한다. S3 에 직접 PUT 권한이 있는 임시 서명 URL 을 백엔드가 발급해주고, 프론트는 그 URL 로 S3 에 파일을 올린 다음, 마지막에 비즈니스 API 에는 keyName 만 넘긴다.

흐름은 세 단계로 나뉜다.

[1] 과 [3] 은 백엔드를 한 번씩 다녀오고, [2] 가 실제로 파일을 S3 에 올리는 단계다. 즉시 / 지연의 차이는 결국 [2] 의 PUT 을 언제 보낼 것인가의 차이로 좁혀진다. 파일을 고른 순간에 보내면 즉시, 마지막 등록 클릭에 묶어 보내면 지연.

지연 업로드라는 직관

당시 우리 프론트는 두 가지 방식이 섞여 있었다.

  • 즉시 업로드: 파일 선택 시점에 바로 S3 로 PUT
  • 지연 업로드: 파일 선택은 메모리에만 두고, 최종 등록 클릭 시점에 PUT 한 뒤 비즈니스 API 한 번에 묶어 보냄

대부분의 화면은 지연 쪽이었다. 처음 그 결정을 내렸을 때의 이유는 단순했다. 사용자가 이미지만 올려두고 등록 화면에서 이탈하면, S3 에는 어디에도 연결되지 않는 고아 이미지가 남게 된다. 그게 아깝다고 생각했다.

지금 돌아보면 그 판단에는 근거가 없었다. "줄어들지 않나" 라는 직관 한 줄로 내린 결정이었다.

비용을 한 번 계산해보다

S3 Standard 의 ap-northeast-2 리전 기준, 처음 50TB/월 구간의 단가는 GB 당 $0.025 다 (AWS S3 Pricing, 2026-05 확인). 1MB 짜리 이미지가 매일 1000 개씩 고아로 남는다고 가정해도 한 달치는 30GB 남짓, 비용으로 환산하면 약 $0.75 / 월 이다.

거기에 백엔드에는 이미 미참조 객체를 일정 기간 뒤 자동으로 정리하는 cleanup 이 돌고 있었다. Lifecycle Rule 까지 더하면 실질 비용은 0 에 가까웠다.

처음 직관이 지키려고 했던 비용은, 자릿수로 보면 처음부터 지킬 만한 금액이 아니었다.

빅테크는 어떻게 하고 있나

근거가 사라지고 나니, 같은 상황에서 다른 곳들은 어떻게 풀고 있는지가 궁금해졌다. 메시지·콘텐츠·폼·KYC 가릴 것 없이 거의 다 "파일 선택 즉시 PUT" 이었다.

케이스PUT 시점사례
메시지/댓글 첨부파일 선택 즉시GitHub, Slack, Discord, Notion
게시물 콘텐츠파일 선택 즉시Notion, Linear
KYC / 폼의 트랜잭션 일부파일 선택 즉시 (file_id 로 분리)Stripe Identity
작업 첨부파일 선택 즉시Asana, Jira
단일 step 단순 폼submit 시 multipart 한 번Greenhouse, Lever

Uploadcare 의 File uploader UX best practices 글에는 이런 표현이 있다.

Uploadcare starts uploading at the moment when you select the file rather than when you submit, which improves user experience. The traditional 'grandpa pattern' involves file upload happening when the form is submitted.

submit 시점에 묶어 보내는 패턴은 업계에서 grandpa pattern 이라 불릴 정도의 구식 취급이었고, 모던 UX 의 권장은 파일 선택 즉시 PUT 이었다.

단일 step 단순 폼이 submit 에 한 번에 보내는 건 "지연" 이 아니라 "단순함" 에 가깝다. 멀티 step 에서 메모리에 File 객체를 들고 있다가 마지막에 PUT 하는 우리의 지연 패턴과는 결이 다르다.

카드 주문 사고가 사실 알려준 것

여러 사례를 보고 나서야 우리가 겪은 사고의 결이 다시 보였다. 마지막에 큰 PUT 을 모아 보내는 패턴은, 폼이 길어질수록 "마지막 한 번의 실패" 가 가진 비용이 같이 커진다. 사용자가 step2 까지 다 채운 시간을 통째로 되감아야 하는 상황이 만들어진다.

물론 step 순서를 바꿔 이미지 업로드를 뒤로 미루는 길도 있긴 했다. 다만 우리 카드 주문은 수량 선택과 이미지 업로드 → 배송지 입력 순서로 흘렀고, 배송지까지 끝나야 주문이 완료되는 구조라 이 순서가 흐름상 가장 자연스러웠다. 결국 풀어야 할 건 step 의 순서가 아니라 PUT 의 시점이었다.

지연을 유지할 이유는 점점 약해졌다. 비용은 자릿수로 안 아까웠고, 운영(cleanup) 으로 풀리는 문제를 코드 패턴으로 풀고 있던 셈이었고, 카드 주문에서 사용자에게 떠넘기던 부담은 그 선택이 만든 구조였다.

이미지 업로드 방식을 파일 선택 즉시 S3 PUT 으로 통일하기로 결정했다.

통일 이후

같은 화면에서 같은 사고는 다시 나지 않았다. 카드 주문에서 step2 까지 채운 뒤 마지막에 막히는 경험이 사라졌다.

화면마다 즉시/지연으로 섞여 있던 패턴이 하나로 정리되면서, "올라간 줄 알았는데 실제로는 안 올라간" 구간도 함께 사라졌다. 사용자가 파일을 고른 시점과 그 파일이 실제로 서버에 도착한 시점이 일치하게 됐다는 뜻이다.

옮기는 김에 업로드 중 / 업로드 완료 상태를 화면에 함께 표시했다. 파일이 지금 어디까지 가 있는지를 사용자가 직접 볼 수 있게 됐다.

운영 비용 쪽은 변화가 없었다. 우려했던 비용 부담이 시작부터 우려가 아니었다는 게, 통일 이후에 비로소 함께 드러났다.

직관에서 시작했던 결정

처음의 결정은 "줄어들지 않나" 라는 한 줄로 시작했다. 근거를 찾기 전에 직관이 먼저 들어섰고, 그 직관 위에 코드를 한 줄씩 얹었다.

비용을 한 번이라도 계산했다면 시작점이 달랐을 것이다. 그리고 "사용자가 올라갔다고 믿는 시점" 과 "실제로 서버에 도착한 시점" 사이의 간격을 한 번이라도 사용자 입장에서 보았다면, 지연이라는 단어는 처음부터 다르게 들렸을 것이다.

직관 자체가 틀리지는 않는다. 다만 직관은 의사결정의 시작점이지, 끝점이 아니다.