동시 업로드, “왜 자꾸 실패하는 거지?”
여러 증빙 파일을 한 번에 올리던 물류 확인 화면에서 일부 파일만 실패하던 문제를, 한 번에 하나씩 업로드하고 성공한 파일은 재시도 때 다시 보내지 않도록 고친 실무 삽질기

Promise.allSettled로 4개의 인수증을 한 번에 올리던 화면이 있었습니다. 평소엔 멀쩡한데, 가끔 S3/트랜잭션 쪽에서 한두 건씩 미끄러졌고 운영팀에는 "다시 눌러도 되나요?"라는 질문이 계속 들어왔습니다. 결국 한 번에 하나씩 올리는 헬퍼, 성공한 logisticsId를 기억하는uploadedLogisticsIds: Set<number>, 그리고 모달 리렌더에 상태가 날아가지 않게 막아준wasOpenRef까지 손보게 됐습니다. 이번 글은 그 삽질기입니다. 핵심은 단순합니다. "이미 올라간 건 다시 건드리지 않고, 실패한 것만 다시 시도하게 만들자."
안녕하세요. BAS KOREA IT팀에서 프론트엔드를 맡고 있는 INSIK입니다.
여러 파일을 한 번에 업로드하는 화면, 다들 괜찮으신가요? Promise.all이나 Promise.allSettled는 손에 익으면 너무 편합니다. 그러다 보니 서비스가 어느 정도 오래 굴러가다 보면, 어딘가에 "일단 4개 동시에 던지자" 같은 코드가 자연스럽게 자리 잡고 있더라고요. 저희도 그랬습니다.
먼저 배경을 조금 풀어보겠습니다. 저희가 만들고 있는 "Flow MATE"는 무역 업무를 처리하는 B2B 서비스입니다. 쉽게 말하면, 고객이 물건을 문의하고, 견적을 받고, 주문하고, 실제로 물건을 보내고, 마지막으로 돈을 정산하기까지의 과정을 한 화면 흐름 안에서 관리하는 도구입니다. 여기서 Logistics(물류)는 "주문한 물건이 실제로 출하되고 배송되는 단계"라고 보면 됩니다. 그리고 Invoice(정산)는 그 배송 결과를 바탕으로 비용을 청구하거나 회계 처리하는 단계입니다.
이런 업무에서는 파일 하나가 꽤 큰 의미를 가집니다. 예를 들어 물건이 실제로 배송됐다는 증빙 파일이 빠지면, 담당자는 "정말 배송이 끝난 게 맞나?", "이 건을 정산으로 넘겨도 되나?"를 확인할 수 없습니다. 그래서 각 단계의 Confirm 버튼은 단순히 모달을 닫는 버튼이 아니라, "이 자료는 확인됐고, 이제 다음 업무 단계로 넘겨도 된다"는 체크포인트에 가깝습니다. 사용자 입장에서는 방금 올린 파일이 확실히 저장됐는지, 실패했다면 어디까지 성공했는지를 바로 알아야 합니다.
오늘 이야기할 화면은 이 물류 단계의 마지막 확인 창인 "Confirm Logistics" 모달입니다. 상황을 더 일반적으로 바꿔 말하면, "배송이 끝났으니 이제 이 건을 정산 대상으로 넘겨도 되는지 확인하는 화면"입니다. 이때 사용자는 인수확인서, 즉 물건을 받은 쪽에서 "네, 받았습니다"라고 남긴 PDF나 이미지 파일을 첨부합니다. 이 파일이 있어야 나중에 회계팀이나 운영팀이 배송 완료 사실을 근거로 정산을 진행할 수 있습니다.
문제는 실제 업무가 항상 한 주문에 파일 하나처럼 깔끔하지 않다는 점이었습니다. 한 번의 선적에 여러 물류 문서가 같이 묶이기도 하고, 사용자는 그 여러 건을 한 번에 확정하고 싶어합니다. 그래서 모달도 "현재 보고 있는 문서 하나만 확정"이 아니라, "현재 문서에 관련 문서 몇 개를 더 묶어서 한 번에 확정"할 수 있게 만들어져 있었습니다. 화면 입장에서는 결국 여러 개의 증빙 파일을 한 번에 받아서 서버에 올려야 하는 구조였습니다.
이 글은 결국 그 화면에서 생긴 업로드 문제를 고친 기록입니다. 처음에는 여러 파일을 동시에 올렸고, 그중 일부만 실패하는 일이 반복됐습니다. 그래서 업로드를 한 번에 하나씩 처리하도록 바꾸고, 이미 성공한 파일은 재시도 때 다시 보내지 않게 만들고, 모달이 다시 렌더돼도 "어떤 파일이 이미 올라갔는지" 기억하도록 고친 과정을 정리해보려고 합니다. 중간중간 "아, 이거 왜 또 깨지지?" 했던 지점까지 최대한 그대로 남겨봤습니다.
이런 분께 도움이 될 수 있습니다
여러 파일을 한꺼번에 업로드하는 화면에서 부분 실패를 겪고 있는 분
Promise.all과Promise.allSettled를 어디까지 믿어도 될지 고민해본 분모달이 열린 상태에서 부모 리렌더 때문에 내부 상태가 초기화되는 문제를 겪어본 분
1. 발단 — 4건 중 2건만 올라가는데, 다시 눌러도 되나요?
운영팀 슬랙에 비슷한 문의가 주 1~2회씩 올라오기 시작했습니다.

"Confirm 누르면 4건 중 2건만 인수증이 올라가요. 다시 눌러도 되나요?"
처음에는 대수롭지 않게 넘겼습니다. "사용자 네트워크가 잠깐 끊겼나?" 정도로 생각했습니다. 그런데 다른 선사, 다른 시간대에서도 같은 문의가 반복되니 슬슬 찜찜해졌습니다. 그래서 코드를 다시 열어봤습니다. 처음 배포했던 버전은 대략 이렇게 생겼습니다.
// src/features/logistics/components/logisticsList/detailModal/LogisticsConfirmModal.tsx (BEFORE)
// ❌ Before — 4건을 한꺼번에 던져서 S3가 충돌
const uploadTargets = getUploadTargets(selectedDocs, receiptFileByLogisticsId);
if (uploadTargets.length > 0) {
const results = await Promise.allSettled(
uploadTargets.map((t) => {
if (t.orderId === undefined) {
return Promise.reject(new Error(`orderId 없음: ${t.docNumber}`));
}
return uploadReceiptAttachment(t.orderId, t.file);
}),
);
const failedDocNumbers = collectFailedDocNumbers(results, uploadTargets);
if (failedDocNumbers.length > 0) {
customMessage.error(`인수증 업로드 실패: ${failedDocNumbers.join(", ")}`);
return;
}
}
겉으로 보면 크게 이상해 보이지 않습니다. Promise.allSettled로 4건을 동시에 보내고, 실패한 게 있으면 confirm을 막습니다. 그런데 찬찬히 보니 마음에 걸리는 부분이 세 가지였습니다.
첫째, "동시에 보내면 빠르니까 좋다"는 전제였습니다. 둘째, 부분 실패 후 사용자가 다시 누르면 이미 성공한 파일까지 다시 올라간다는 점이었습니다. 셋째, 실패했는데도 throw가 아니라 return으로 빠지면서 뒤쪽 성공 처리 흐름이 계속 탈 수 있는 구조였습니다. 세 번째는 따로 길게 다룰 수 있는 주제라, 이번 글에서는 앞의 두 가지, 즉 동시에 보내는 방식과 재시도 때 중복 업로드를 막는 방식에 집중하겠습니다.
같은 logisticsId에 다시 PUT을 보내면 백엔드는 그걸 새 업로드로 받습니다. 그러니까 운영팀이 사용자에게 "다시 눌러보세요"라고 안내하는 순간, 같은 인수증이 두 번 등록될 수 있는 길을 열어두고 있었던 겁니다.
PR을 올리기 전에, 머릿속에서 정리가 안 되는 부분이 있어 AI 에이전트에게 한 번 물어봤습니다.
나: 4건 동시 업로드에서 자꾸 한두 건씩 깨지는데, 일단 사용자한테 다시 눌러도 된다고 안내만 붙이면 되지 않을까?
AI: 지금 구조에서는 성공한 건과 실패한 건이 한 confirm 안에 섞여 있습니다. 사용자가 다시 누르면 이미 성공했던 logisticsId에도 PUT이 다시 나가요. 그러면 같은 인수증이 두 번 등록될 수 있습니다.
나: 음… 그럼 백엔드에서 중복 요청을 막는 키 같은 걸로 처리할 수도 있지 않나?
AI: 가능은 합니다. 다만 그 작업은 다음 스프린트로 잡혀 있고, 운영 안정화는 이번 주 안에 필요해 보입니다. 그리고 프론트 호출부도 지금은 "이미 성공한 건이 있다"는 사실을 전혀 모릅니다. 백엔드가 바뀌어도 프론트는 여전히 같은 파일을 다시 만들어 보낼 거예요.
나: 그렇네. 그러면 단순히 에러 메시지만 고칠 일이 아니라, 호출부가 성공/실패를 기억해야 한다는 얘기네.
AI: 맞습니다. 그리고 하나 더 보자면, 4건을 거의 같은 millisecond에 던지는 게 S3 쪽 트랜잭션 충돌의 직접 원인으로 보입니다. 이번에 "성공한 건은 다시 안 보낸다"와 "한 번에 한 건씩만 보낸다"를 같이 적용하면 두 문제를 한 번에 줄일 수 있습니다.
그 대화에서 방향이 확 잡혔습니다. 이번에 고칠 건 Promise.allSettled 한 줄이 아니라, 호출부가 "이미 처리한 것"을 기억하도록 구조를 바꾸는 일이었습니다. 이게 이번 개선의 시작점이었습니다.
2. 원인 — 병렬이 빠른 게 정답일 줄 알았는데
처음에는 솔직히 백엔드나 인프라 쪽 문제라고 생각했습니다. "S3 PUT 몇 개 동시에 들어왔다고 깨진다고? 그건 서버에서 잡아줘야 하는 거 아닌가?" 하는 마음이 있었습니다. 그런데 백엔드 동료와 로그를 같이 보니 상황이 조금 다르게 보였습니다. 4개의 PUT이 거의 같은 순간에 들어오면서 같은 row를 잠그려는 트랜잭션이 서로 부딪혔고, 그중 일부가 rejected로 떨어지고 있었습니다.
백엔드 팀에서는 이미 트랜잭션 단위를 더 작게 쪼개는 작업을 진행하고 있었습니다. 다만 그건 한두 스프린트 정도 더 필요한 일이었고, 운영팀은 다음 주까지 이 화면을 안정화해달라고 요청한 상황이었습니다. 그래서 제가 꺼낸 카드는 조금 민망하지만 이런 쪽이었습니다.
Promise.all이 익숙하다고 너무 아무 생각 없이 가져다 썼던 것 같습니다.
그리고 프론트에서도 같이 부담을 줄이자고 제안했습니다. 결론은 단순했습니다. 동시성을 1로 줄이자. 즉, 한 번에 하나씩만 올리자. 그때 머릿속에 남은 문장이 있습니다. "동시성을 줄이는 건 패배가 아니다. 외부 자원의 일관성이 걸린 상황에서는 일부러 한 줄로 세우는 게 더 안전한 선택일 수 있다."
p-limit이나 bottleneck 같은 라이브러리도 잠깐 검토했습니다. 하지만 최대 4건짜리 흐름에 새 의존성을 추가하는 건 조금 무거워 보였습니다. 그래서 이번에는 직접 작은 헬퍼를 만들고, 나중에 다른 배치 화면까지 확장할 일이 생기면 그때 라이브러리 도입을 다시 보기로 했습니다.
3. 방향 잡기 — "호출부는 두 가지만 약속한다"
방향을 잡고 나니 고민은 오히려 단순해졌습니다. 호출부가 지킬 약속을 먼저 정하면, 구현은 그 약속을 코드로 옮기는 일이 됩니다. 저희가 정한 약속은 딱 두 가지였습니다.
외부 자원에는 한 번에 한 건씩만 요청한다.
한 번 성공한 건은 같은 모달 안에서 다시 올리지 않는다. 사용자가 재시도해도 이미 끝난 항목은 건드리지 않는다.
여기에 사용자 관점의 약속도 하나 더했습니다. "성공한 건은 사라지지 않았다"는 걸 화면에서 바로 알 수 있게 하자. 토스트 메시지로 한 번, 행 단위 배지로 한 번 보여주기로 했습니다.
이 약속을 책임 단위로 나눠보면 이렇게 정리됩니다.
| 계층 | 책임 | 위치 |
|---|---|---|
| 업로드 헬퍼 | 한 번에 하나씩 호출, 실패가 있어도 끝까지 시도 | uploadReceiptsSequentially |
| 호출부(모달) | 성공한 logisticsId를 기억하고, 재시도 때 제외 | uploadedLogisticsIds: Set<number> |
| UI 행 컴포넌트 | 끝난 행 표시, 파일 변경 차단 | disabled={isUploaded} |
| 모달 라이프사이클 | 열리는 순간에만 상태 초기화 | wasOpenRef |
이 표를 그려놓고 나니 구현할 조각들이 꽤 선명해졌습니다. 이제 하나씩 바꾸면 됐습니다.
4. 1단계 — Promise.allSettled의 순차 버전 만들기
코드를 바로 고치기 전에 테스트부터 만들었습니다. 헬퍼 하나에 6개 케이스를 걸어두고, 그중 가장 중요한 케이스는 "진짜로 순서대로 호출되는지"를 확인하는 테스트였습니다. 이 테스트가 있어야 나중에 누군가 "그냥 다시 Promise.all 쓰면 안 돼요?"라고 물었을 때 코드가 먼저 대답할 수 있습니다.
// src/features/logistics/components/logisticsList/detailModal/confirmReceiptUploadUtils.ts
/**
* 인수증 업로드 대상을 순차적으로 호출한다.
* 실패가 있어도 중단하지 않고 끝까지 시도하며, 반환 형태는 Promise.allSettled와 같다.
*/
export async function uploadReceiptsSequentially(
targets: UploadTarget[],
uploader: (orderId: number, file: File) => Promise<unknown>,
): Promise<PromiseSettledResult<unknown>[]> {
const results: PromiseSettledResult<unknown>[] = [];
for (const target of targets) {
if (target.orderId === undefined) {
results.push({
status: "rejected",
reason: new Error(`orderId 없음: ${target.docNumber}`),
});
continue;
}
try {
const value = await uploader(target.orderId, target.file);
results.push({ status: "fulfilled", value });
} catch (reason) {
results.push({ status: "rejected", reason });
}
}
return results;
}
이 헬퍼를 만들면서 신경 쓴 지점은 세 가지였습니다.
첫 번째는 반환 타입을 PromiseSettledResult<unknown>[] 그대로 맞춘 것입니다. 기존 호출부에는 collectFailedDocNumbers(results, targets)처럼 결과 배열과 대상 배열을 인덱스로 맞춰보는 로직이 있었습니다. 반환 형태를 유지하면 그 로직을 거의 건드리지 않고 넘어갈 수 있습니다. 이런 작업을 할 때마다 느끼지만, 인터페이스를 유지하면 마이그레이션 난이도가 확 내려갑니다.
두 번째는 실패해도 바로 멈추지 않고 continue로 다음 파일을 시도하는 것입니다. 4건 중 2건이 실패하더라도 나머지 2건을 올려두는 편이 사용자에게는 훨씬 낫습니다. 그래야 "실패한 것만 다시 해보세요"라는 흐름도 만들 수 있습니다.
세 번째는 orderId === undefined인 경우에도 결과 배열에 rejected 항목을 넣는 것입니다. 실제 업로드 호출은 하지 않지만, 배열 인덱스는 유지해야 합니다. 여기서 한 칸이라도 밀리면 다른 문서 번호가 실패 메시지에 찍힐 수 있습니다. 이런 버그는 화면상으로는 그럴듯해 보이기 때문에 더 무섭습니다.
가장 중요한 테스트는 이렇게 생겼습니다.
// src/features/logistics/components/logisticsList/detailModal/confirmReceiptUploadUtils.test.ts
it("순차적으로 호출한다 (병렬이 아님)", async () => {
const callOrder: number[] = [];
const uploader = vi.fn(async (orderId: number) => {
callOrder.push(orderId);
await new Promise((resolve) => setTimeout(resolve, 10));
callOrder.push(orderId * 10);
return { id: orderId };
});
const targets: UploadTarget[] = [
{ logisticsId: 1, docNumber: "D-1", orderId: 100, isCurrent: false, file: makeFile() },
{ logisticsId: 2, docNumber: "D-2", orderId: 101, isCurrent: false, file: makeFile() },
];
await uploadReceiptsSequentially(targets, uploader);
// 병렬이면 [100, 101, 1000, 1010] 같은 끼어든 순서가 나옴.
// 직렬이면 [100, 1000, 101, 1010]만 나와야 한다.
expect(callOrder).toEqual([100, 1000, 101, 1010]);
});
callOrder가 [100, 1000, 101, 1010]이어야 한다는 검증 한 줄이 이 변경의 안전벨트입니다. 누군가 나중에 슬쩍 Promise.all을 되돌려놓으면, 이 테스트가 바로 빨갛게 떠야 합니다.
5. 2단계 — "이미 올라간 건 또 올리지 마세요" 상태 보존
헬퍼는 이제 한 번에 하나씩만 호출합니다. 하지만 아직 부족합니다. "성공한 건은 다시 올리지 않는다"는 약속은 헬퍼가 아니라 호출부가 지켜야 합니다. 그래서 모달 컴포넌트 안에 uploadedLogisticsIds: Set<number> 상태를 추가했습니다. 이 모달이 열려 있는 동안 성공한 logisticsId를 계속 모아두는 역할입니다.
// src/features/logistics/components/logisticsList/detailModal/LogisticsConfirmModal.tsx
// 👍 After — 한 건씩, 끝까지
// 인수증 사전 업로드: S3 트랜잭션 충돌 회피를 위해 순차 호출
const allTargets = getUploadTargets(selectedDocs, receiptFileByLogisticsId);
const pendingTargets = allTargets.filter((t) => !uploadedLogisticsIds.has(t.logisticsId));
if (pendingTargets.length > 0) {
const results = await uploadReceiptsSequentially(pendingTargets, uploadReceiptAttachment);
const newlyUploadedIds = pendingTargets.reduce<number[]>((acc, target, index) => {
if (results[index]?.status === "fulfilled") acc.push(target.logisticsId);
return acc;
}, []);
if (newlyUploadedIds.length > 0) {
setUploadedLogisticsIds((prev) => {
const next = new Set(prev);
for (const id of newlyUploadedIds) next.add(id);
return next;
});
}
const failedDocNumbers = collectFailedDocNumbers(results, pendingTargets);
if (failedDocNumbers.length > 0) {
customMessage.error(
`인수증 업로드 실패: ${failedDocNumbers.join(", ")} (성공 ${newlyUploadedIds.length}건은 유지됩니다. 다시 Confirm을 눌러 재시도하세요.)`,
);
throw new Error("인수증 업로드 실패");
}
}
여기서 중요한 흐름은 네 가지입니다.
첫째, pendingTargets = allTargets.filter(t => !uploadedLogisticsIds.has(...))입니다. 사용자가 다시 Confirm을 눌렀을 때, 이미 성공한 항목은 업로드 대상에서 빠집니다. 호출 자체가 만들어지지 않으니 같은 logisticsId로 다시 PUT을 보낼 일도 없습니다.
둘째, fulfilled 결과만 골라 setUploadedLogisticsIds에 누적합니다. 성공했다는 사실을 모달 상태에 남겨두고, 다음 재시도 때 그 상태를 기준으로 건너뜁니다.
셋째, 실패가 있으면 throw합니다. 그냥 return으로 빠지면 뒤쪽 성공 처리 흐름이 계속 이어질 수 있습니다. 이 부분이 앞에서 잠깐 말한 "실패했는데도 onSuccess가 도는" 문제까지 같이 막아줬습니다.
넷째, 에러 메시지 끝에 이 문장을 붙였습니다.
인수증 업로드 실패: 인수증A, 인수증B (성공 2건은 유지됩니다. 다시 Confirm을 눌러 재시도하세요.)
개인적으로는 이 한 줄이 사용자에게 가장 크게 체감된 변경이었다고 생각합니다. "다시 눌러도 되나요?"라는 문의가 사라진 이유는 코드가 안전해진 것만은 아니었습니다. 화면이 "방금 성공한 건 그대로 남아 있고, 다시 눌러도 그건 다시 안 건드립니다"라고 말해주기 시작했기 때문입니다. 시스템이 안전하게 동작해도 사용자가 그걸 모르면, 사용자 입장에서는 여전히 불안한 화면입니다.
6. 3단계 — UI에서 "업로드됨" 잠금 표시
토스트 메시지만으로는 부족했습니다. 토스트는 금방 사라지고, 사용자는 다시 4행짜리 리스트를 보면서 "그래서 뭐가 성공했고 뭐가 실패한 거지?"라고 생각하게 됩니다. 그래서 행 단위로 "업로드됨" 배지를 넣었습니다.
// src/features/logistics/components/logisticsList/detailModal/ConfirmReceiptUploadList.tsx
selectedDocs.map((doc) => {
const selectedFile = receiptFileByLogisticsId.get(doc.logisticsId);
const isUploaded = uploadedLogisticsIds.has(doc.logisticsId);
return (
<RowItem key={doc.logisticsId}>
<DocNumber>{doc.docNumber}</DocNumber>
<FileName>{selectedFile ? selectedFile.name : "파일 미선택"}</FileName>
{isUploaded && (
<UploadedBadge data-testid={`uploaded-${doc.logisticsId}`}>
<CheckCircleOutlined /> 업로드됨
</UploadedBadge>
)}
<Upload
accept={ALLOWED_EXTENSIONS}
showUploadList={false}
disabled={isUploaded}
beforeUpload={(file) => {
if (validateFile(file)) onFileSelect(doc.logisticsId, file);
return false;
}}
>
<Button size="small" icon={<UploadOutlined />} disabled={isUploaded}>
{selectedFile ? "Change" : "Upload"}
</Button>
</Upload>
</RowItem>
);
});
핵심은 disabled={isUploaded} 두 줄입니다. 이미 성공한 행은 Change 버튼도 막고, 파일 선택 자체도 막습니다. "끝난 건 건드리지 않는다"는 약속을 로직뿐 아니라 UI에서도 지키게 만든 겁니다.
이 배지는 단순한 장식이 아닙니다. 에러 메시지를 믿게 해주는 근거입니다. 토스트에 "성공 N건은 유지됩니다"라고 써 있어도 화면에 아무 표시가 없으면 사용자는 불안합니다. 반대로 행에 "업로드됨"이 찍혀 있고 버튼이 잠겨 있으면, 사용자는 "아, 이건 진짜 끝난 거구나"라고 받아들이기 훨씬 쉽습니다.
7. 복병 — 모달이 다시 렌더되면서 상태가 날아간다

여기서 끝났으면 참 좋았을 텐데, QA에서 다시 막혔습니다.
QA: "재시도가 안 돼요. 다시 누르면 위에서 이미 성공한 행까지 또 올라가요."
직접 재현해보고 꽤 당황했습니다. uploadedLogisticsIds에 ID를 분명히 넣었는데, 어느 순간 Set이 비어 있었습니다. 아니, 내가 뭘 잘못 본 건가? 싶어서 한참을 봤습니다. 그런데 모달은 닫힌 적이 없었습니다. 그냥 부모 컴포넌트가 한 번 리렌더됐을 뿐이었습니다.
원인은 익숙하지만 또 당한 그 패턴이었습니다.
// ❌ Before — formValues 객체 참조가 바뀌면 매번 리셋됨!
useEffect(() => {
if (isOpen) {
const currentDoc: ConfirmDoc = {
logisticsId,
docNumber: formValues?.documentNumber ?? String(logisticsId),
orderId: formValues?.orderId,
isCurrent: true,
};
setSelectedDocs([currentDoc]);
setReceiptFileByLogisticsId(new Map());
setUploadedLogisticsIds(new Set());
setDocNumberInput("");
}
}, [isOpen, logisticsId, formValues]);
부모가 리렌더될 때 formValues가 새 객체 참조로 내려왔습니다. 그러자 useEffect는 의존성이 바뀌었다고 판단하고 다시 실행됐습니다. 그 안에서는 setSelectedDocs([currentDoc]), setUploadedLogisticsIds(new Set())가 그대로 호출되고 있었고요. 결과적으로 모달은 계속 열려 있는데, 안쪽 상태만 싹 초기화됐습니다. 사용자 입장에서는 "방금 업로드됐다고 봤는데 왜 다시 올라가지?"가 되는 겁니다.
해결은 두 가지를 같이 적용했습니다.
// 👍 After — 열리는 순간에만 초기화 + 의존성을 실제 쓰는 필드로 좁힘
const wasOpenRef = useRef(false);
useEffect(() => {
const shouldInitialize = isOpen && !wasOpenRef.current;
wasOpenRef.current = isOpen;
if (!shouldInitialize) return;
const currentDoc: ConfirmDoc = {
logisticsId,
docNumber: formValues?.documentNumber ?? String(logisticsId),
orderId: formValues?.orderId,
isCurrent: true,
};
setSelectedDocs([currentDoc]);
setReceiptFileByLogisticsId(new Map());
setUploadedLogisticsIds(new Set());
setDocNumberInput("");
}, [isOpen, logisticsId, formValues?.documentNumber, formValues?.orderId]);
하나는 wasOpenRef로 이전 isOpen 값을 기억해두고, false에서 true로 바뀌는 순간에만 초기화하는 것입니다. 다른 하나는 의존성 배열에서 formValues 객체 전체를 빼고, 실제로 읽는 formValues?.documentNumber와 formValues?.orderId만 넣는 것입니다. 둘 다 필요했습니다. 한쪽만 고쳤다면 다른 케이스에서 또 상태가 날아갔을 겁니다.
리액트의 의존성 배열은 "받은 값 전부"가 아니라 "진짜로 읽는 값"을 적는 곳이라는 걸 또 한 번 배웠습니다.
이 교훈은 주석으로만 남기지 않고 회귀 테스트로 박아두었습니다.
// src/features/logistics/components/logisticsList/detailModal/LogisticsConfirmModal.test.tsx (발췌)
it("모달이 열린 상태에서 formValues 참조가 바뀌어도 업로드 완료 상태를 유지한다", async () => {
const { rerender } = renderModal();
fireEvent.click(screen.getByText("select-file-1"));
clickConfirm();
expect(await screen.findByTestId("uploaded-1")).toBeTruthy();
// 부모가 리렌더 → formValues 새 참조 객체 전달
rerender(
<QueryClientProvider client={queryClient}>
<LogisticsConfirmModal
{...defaultProps}
formValues={makeLogistics({ documentNumber: "LOG-001" })}
/>
</QueryClientProvider>,
);
await waitFor(() => {
// 새 참조에도 불구하고 "업로드됨" 상태는 살아있어야 한다
expect(screen.getByTestId("uploaded-1")).toBeTruthy();
});
});
테스트는 모달을 열고 한 건을 업로드한 뒤, 부모가 리렌더되면서 formValues를 새 객체로 다시 내려주는 상황을 재현합니다. 그 뒤에도 uploaded-1이 화면에 남아 있어야 합니다. 다음에 누군가 의존성 배열을 다시 객체 통째로 바꾸면, 이 테스트가 바로 잡아줄 겁니다.
8. 도입 후 — 숫자로 확인된 효과
배포 후 한 달 정도 지켜본 결과는 이랬습니다.
지표 | Before | After --- | --- | --- 4건 동시 업로드 시 부분 실패 | 주 1~2회 | 배포 이후 0건 "다시 눌러도 되나요?" 운영 문의 | 주기적으로 발생 | 멈춤 재시도 시 성공 건 재업로드 | 발생 | 차단됨 4건 평균 업로드 시간 | ~3.0초 | ~4.2초
업로드 시간은 약 1.2초 늘었습니다. 숫자만 보면 손해처럼 보일 수 있습니다. 하지만 이 업로드는 원래 사용자가 다른 입력을 이어가던 흐름 안에서 비동기로 진행됐고, 운영팀에서도 체감 속도에 대한 불만은 거의 없었습니다. 반대로 부분 실패가 관찰 기간 동안 0건으로 떨어진 것, 그리고 반복되던 "다시 눌러도 되나요?" 문의가 멈춘 것은 확실히 체감됐습니다.
무엇보다 좋았던 건 사용자와 운영팀 모두 "다시 눌러도 괜찮다"고 확신할 수 있게 된 것입니다. 운영팀은 더 이상 같은 질문을 개발팀에 전달하지 않았고, 저희도 매번 "네, 괜찮습니다"라고 설명하지 않아도 됐습니다.
회귀 테스트는 두 군데로 나눠두었습니다. confirmReceiptUploadUtils.test.ts의 헬퍼 6개 케이스는 "이 함수가 한 번에 하나씩 호출한다"를 보장합니다. LogisticsConfirmModal.test.tsx의 통합 테스트 2개는 "리렌더돼도 업로드 완료 상태가 유지된다"와 "닫고 다시 열면 상태가 초기화된다"를 같이 확인합니다.
9. 정리 — 핵심만 다시

길게 돌아왔으니, 마지막으로 핵심만 다시 적어보겠습니다.
외부 자원에 무작정 동시에 들이치지 말자. S3나 DB처럼 공통 자원이 뒤에 있는 경우, 병렬 처리가 오히려 문제를 키울 수 있습니다. 필요하면
await로 한 줄씩 세우는 게 더 안전합니다.부분 실패는 곧 데이터 유실이 아니다. 다만 화면이 그걸 말해줘야 한다. "성공 N건은 유지됩니다"라는 문구와 행 단위 "업로드됨" 표시가 있어야 사용자가 안심하고 재시도할 수 있습니다.
재시도할 때는 이미 성공한 건을 다시 보내지 말자. 성공한 항목을 ID 단위
Set으로 기억해두고, 모달을 명시적으로 닫을 때만 비우면 됩니다.useEffect의존성에는 진짜로 읽는 값을 넣자. 객체 통째를 넣으면 부모 리렌더 한 번에 자식 상태가 초기화될 수 있습니다.
마무리 — 속도가 항상 정답은 아니었습니다
예전에 전역 에러 핸들링 글에서 "에러는 사용자와의 마지막 대화"라고 쓴 적이 있습니다. 이번에는 하나를 더 배웠습니다. 성공도 사용자에게 보여줘야 한다는 겁니다. 무역 운영처럼 한 단계의 누락이 다음 단계로 그대로 번지는 도메인에서는, 부분 성공도 "이건 끝났고, 이건 아직 남았습니다"라고 화면이 분명히 말해줘야 사용자가 다음 행동을 안심하고 선택할 수 있었습니다.
속도가 항상 정답은 아니었습니다. 인수증 4장이 그걸 알려줬습니다. 외부 자원을 상대할 때는 가끔 한 번에 많이 던지는 것보다, 한 건씩 차근차근 처리하는 쪽이 결과적으로 더 빠른 길이 됩니다. 처음에는 작은 모달 하나의 문제였지만, 지금은 Flow MATE 안에서 "여러 건을 한 번에 처리하는 화면"을 설계할 때 기본적으로 떠올리는 패턴이 됐습니다. 앞으로는 Order 일괄 확정, Invoice 일괄 발행 같은 다른 배치 화면에도 이 "성공한 건은 기억하고, 실패한 것만 다시 시도하는" 흐름을 확장해볼 생각입니다.
긴 글 읽어주셔서 감사합니다. 여러분의 화면에서도 "다시 눌러도 되나요?"라는 질문이 하나씩 줄어들길 바랍니다.
BAS KOREA IT챕터 프론트엔드, INSIK 드림.
Tags: React TypeScript File Upload Concurrency TDD Frontend 웹개발


