|
| 1 | +--- |
| 2 | +title: "프론트엔드에서 원자성 보장하기 (feat. 대용량 데이터 배치 업로드)" |
| 3 | +createdAt: "2026-03-07" |
| 4 | +category: "React" |
| 5 | +description: 413 Payload 에러 해결을 위한 이미지 압축, API 청크 분할, 부분 성공 복구 로직을 도입하며 프론트엔드에서 All or Nothing을 구현한 경험기 |
| 6 | +comment: true |
| 7 | +head: |
| 8 | + - - meta |
| 9 | + - name: keywords |
| 10 | + content: "React, Next.js, API Batching, Image Compression, 413 Payload Error" |
| 11 | +--- |
| 12 | + |
| 13 | +인턴으로 재직 중인 누비랩에서는 선생님이 여러 명의 원아를 한 번에 등록할 수 있는 **원아 일괄 등록 기능**을 운영하고 있습니다. <br/> |
| 14 | +3월인 신학기 시즌에는 다수의 원아를 한 번에 등록하는 경우가 많았고, 어느 날 CX 채널로 다음과 같은 문의가 들어왔습니다. |
| 15 | + |
| 16 | +> "원아 등록이 안돼요. 등록 버튼을 눌러도 아무 반응이 없어요." |
| 17 | +
|
| 18 | +문제를 재현해보니 실제로 요청이 서버에 도달하기도 전에 **413 Payload Too Large** 에러가 발생하고 있었습니다. |
| 19 | + |
| 20 | + |
| 21 | + |
| 22 | +기존 API는 모든 데이터를 **단일 multipart 요청**으로 전송하고 있었고, <br/> |
| 23 | +여러 장의 원아 사진이 한 번에 업로드되면서 **API Gateway의 Payload 제한을 초과**하고 있었습니다. |
| 24 | + |
| 25 | +이 문제를 해결하기 위해 다음과 같은 구조를 도입했습니다. |
| 26 | + |
| 27 | +> 1. 이미지 압축 <br/> |
| 28 | +> 2. API 요청 청크 분할 <br/> |
| 29 | +> 3. 부분 성공 복구 로직 <br/> |
| 30 | +> 4. Next.js 전환 지연 처리 <br/> |
| 31 | +
|
| 32 | +이를 통해 **프론트엔드에서 사실상 "All or Nothing"에 가까운 업로드 구조**를 구현했습니다. |
| 33 | + |
| 34 | +## 기존 업로드 구조의 한계 |
| 35 | + |
| 36 | +기존 원아 등록 API 는 여러명의 데이터를 하나으 Multipart 요청으로 전송하는 구조였습니다. |
| 37 | + |
| 38 | +```bash |
| 39 | +POST /batch-upload |
| 40 | +Content-Type: multipart/form-data |
| 41 | + |
| 42 | +files[] |
| 43 | +metadata[] |
| 44 | +``` |
| 45 | + |
| 46 | +선생님이 여러 원아 정보를 입력한 뒤 "등록" 버튼을 누르면,<br> |
| 47 | +각 원아의 프로필 사진과 메타데이터가 하나의 FormData 로 묶여 서버로 전송됩니다. |
| 48 | + |
| 49 | +원아 프로필 사진은 평균 3~7MB 정도의 크기를 가지고 있었고, 15명의 원아를 한번에 등록한다고 가정했을 때 <br/> |
| 50 | +`5MB x 15 = 75MB` 의 데이터가 하나의 multipart 요청에 담기면서 API Gateway 의 요청 크기 제한을 초과했습니다. |
| 51 | + |
| 52 | +## 해결 전략 설계 |
| 53 | + |
| 54 | +문제의 핵심은 **단일 Multipart 요청에 모든 데이터를 담고 있다는 점** 이었습니다. <br/> |
| 55 | +따라서 해결방법을 두 가지로 나눌 수 있었습니다. |
| 56 | + |
| 57 | +| 전략 | 설명 | 장점 | 단점 | |
| 58 | +| ------------------------------- | ------------------------------------------------------ | ------------------------------------------------------ | -------------------------------- | |
| 59 | +| **A. 이미지 압축** | 클라이언트에서 이미지를 압축하여 페이로드 크기를 줄임 | - 요청 크기 감소<br/> - 구현 비교적 간단 | 압축하더라도 대량 업로드 시 한계 | |
| 60 | +| **B. 요청 청킹 (Batch Upload)** | 여러 원아 데이터를 여러 API 요청으로 나누어 전송 | - Payload 제한 문제 해결<br/>- 실패 복구 가능 | 요청 관리 로직이 복잡해짐 | |
| 61 | +| **C. S3 Presigned URL** | 이미지를 S3에 직접 업로드 후, 메타데이터만 서버로 전송 | - Payload 제한 완전 우회<br/>- 대용량 파일 처리에 유리 | 백엔드 인프라 변경 필요 | |
| 62 | + |
| 63 | +여기서 **C. S3 Presigned URL** 방식도 충분히 유효한 대안이었습니다. |
| 64 | +Presigned URL을 발급받아 이미지를 S3에 직접 업로드하고, 서버에는 S3 키와 메타데이터만 전송하면 API Gateway의 Payload 제한을 근본적으로 우회할 수 있기 때문입니다. |
| 65 | + |
| 66 | +하지만 이 방식을 채택하지 않은 이유는 다음과 같습니다. |
| 67 | + |
| 68 | +> 1. **백엔드 변경이 필요했습니다.** Presigned URL 발급 엔드포인트 추가, S3 버킷 설정, IAM 권한 구성, CORS 설정 등 인프라 레벨의 작업이 수반됩니다. 당시 백엔드 팀의 스프린트 일정상 즉각적인 대응이 어려웠고, 프론트엔드 변경만으로 빠르게 해결할 수 있는 방법이 필요했습니다. <br/> |
| 69 | +> 2. **업로드-등록 간 정합성 문제가 생깁니다.** S3 업로드는 성공했지만 이후 등록 API가 실패하면, S3에 고아 파일(orphaned file)이 남게 됩니다. 이를 정리하기 위한 별도의 클린업 로직이나 TTL 정책이 추가로 필요합니다.<br/> |
| 70 | +> 3. **기존 API를 그대로 활용할 수 있었습니다.** 청크 분할 방식은 기존 배치 등록 API의 인터페이스를 변경하지 않고, 프론트엔드에서 요청을 나눠 보내는 것만으로 문제를 해결할 수 있었습니다. |
| 71 | +
|
| 72 | +결과적으로 **기존 API 호환성을 유지하면서 프론트엔드만으로 빠르게 대응할 수 있는 A + B 조합**을 선택했습니다. |
| 73 | + |
| 74 | +<br/> |
| 75 | + |
| 76 | +## 구현 과정 |
| 77 | + |
| 78 | +### 1️⃣ 이미지 압축으로 요청 크기 줄이기 |
| 79 | + |
| 80 | +가장 먼저 시도한 방법은 **클라이언트에서 이미지를 압축하여 요청 Payload 크기를 줄이는 것**이었습니다. |
| 81 | + |
| 82 | +이를 위해 `browser-image-compression` 라이브러리를 사용했습니다. |
| 83 | +이 라이브러리는 브라우저에서 Canvas를 이용해 이미지를 압축하고, 원하는 크기나 해상도로 변환할 수 있습니다. |
| 84 | + |
| 85 | +```ts |
| 86 | +import imageCompression from "browser-image-compression"; |
| 87 | + |
| 88 | +const options = { |
| 89 | + maxSizeMB: 1, |
| 90 | + maxWidthOrHeight: 1024, |
| 91 | + useWebWorker: true, |
| 92 | +}; |
| 93 | + |
| 94 | +const compressedFile = await imageCompression(file, options); |
| 95 | +``` |
| 96 | + |
| 97 | +이미지 압축을 적용한 결과, 15명의 원아를 등록할 때의 Payload 크기가 평균 75MB에서 **약 15MB 수준으로 감소**했습니다. |
| 98 | +이 정도면 API Gateway의 Payload 제한을 충분히 통과할 수 있는 수준이었습니다. |
| 99 | + |
| 100 | +하지만 이 방법에는 한계가 있었습니다. |
| 101 | +만약 선생님이 **30명 이상의 원아를 한 번에 등록**하려고 한다면, |
| 102 | +이미지를 압축하더라도 Payload 크기가 다시 제한을 초과할 가능성이 있었습니다. |
| 103 | + |
| 104 | +따라서 이미지 압축만으로는 근본적인 해결책이 될 수 없었습니다. |
| 105 | + |
| 106 | +<br/> |
| 107 | + |
| 108 | +### 2️⃣ 요청청킹 + Batch 업로드 |
| 109 | + |
| 110 | +이미지 압축만으로는 한계가 있었기 때문에, 근본적으로 요청 자체를 여러 개로 나누는 방식을 도입했습니다. |
| 111 | + |
| 112 | +```ts |
| 113 | +const CHUNK_SIZE = 5; |
| 114 | + |
| 115 | +function splitChunks<T>(array: T[], size: number): T[][] { |
| 116 | + const result = []; |
| 117 | + for (let i = 0; i < array.length; i += size) { |
| 118 | + result.push(array.slice(i, i + size)); |
| 119 | + } |
| 120 | + return result; |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +이렇게 하면 `1~5명`, `6~10명`, `11~15명` 총 3개의 요청으로 나누어 서버에 전송할 수 있습니다. |
| 125 | + |
| 126 | +```ts |
| 127 | +const chunks = splitChunks(students, CHUNK_SIZE); |
| 128 | + |
| 129 | +await Promise.all(chunks.map((chunk) => api.post("/batch-upload", chunk))); |
| 130 | +``` |
| 131 | + |
| 132 | +<br/> |
| 133 | + |
| 134 | +### ⚠️ 그런데.. 원자성(Atomicity)이 깨진다 |
| 135 | + |
| 136 | +하지만 여기서 새로운 문제가 발생합니다. <br> |
| 137 | + |
| 138 | +> 중간에 일부 요청만 실패하면 어떻게 될까? |
| 139 | +
|
| 140 | +예를들어, |
| 141 | + |
| 142 | +- 1~5명: 성공 |
| 143 | +- 6~10명: 성공 |
| 144 | +- 11~15명: 실패 |
| 145 | + |
| 146 | +이런 상황이 발생하면, 서버에는 이미 10명의 원아 데이터만 등록된 상태가 됩니다. <br> |
| 147 | +이는 흔히 말하는 원자성(Atomicity)을 깨는 구조입니다. |
| 148 | + |
| 149 | +#### 1. 실패시 전체 롤백 (All or Nothing) |
| 150 | + |
| 151 | +- 성공했던 요청까지 모두 삭제 |
| 152 | +- DB 상태를 완전히 원래대로 복구 |
| 153 | + |
| 154 | +<center> |
| 155 | +<img src="./img/rollback-failed.png" alt="rollback-failed" width="500"/> |
| 156 | +</center> |
| 157 | + |
| 158 | +하지만 이 방식에는 치명적인 문제가 있었습니다. |
| 159 | + |
| 160 | +> 롤백 요청도 실패하면 어떡하지? |
| 161 | +
|
| 162 | +#### 2. 부분 성공 허용 + 재시도 유도 |
| 163 | + |
| 164 | +그래서 방향을 바꿨습니다. |
| 165 | + |
| 166 | +> 완벽한 원자성 대신, UX 레벨에서 원자성을 만들자 |
| 167 | +
|
| 168 | +핵심 전략은 다음과 같습니다 |
| 169 | + |
| 170 | +- 성공한 데이터는 그대로 둔다 |
| 171 | +- 실패한 데이터만 사용자에게 다시 보여준다 |
| 172 | +- 사용자는 남은 것만 재시도한다 |
| 173 | + |
| 174 | +<br/> |
| 175 | + |
| 176 | +### 3️⃣ 부분성공 복구 로직 (프론트엔드에서 원자성 만들기) |
| 177 | + |
| 178 | +이를 위해 `PartialSuccessError` 라는 개념을 도입했습니다 |
| 179 | + |
| 180 | +```ts |
| 181 | +export class PartialSuccessError extends Error { |
| 182 | + readonly successIndices: number[]; |
| 183 | + |
| 184 | + constructor(successIndices: number[]) { |
| 185 | + super("일부 업로드에 실패했습니다."); |
| 186 | + this.name = "PartialSuccessError"; |
| 187 | + this.successIndices = successIndices; |
| 188 | + } |
| 189 | +} |
| 190 | +``` |
| 191 | + |
| 192 | +청크 요청 결과를 모아서, 일부라도 실패했다면 `PartialSuccessError`를 던집니다. |
| 193 | + |
| 194 | +<details> |
| 195 | +<summary> |
| 196 | +🙋♂️ `Promise.all` vs `Promise.allSettled` vs `Promise.any` vs `Promise.race` 뭐가 다른가요? |
| 197 | +</summary> |
| 198 | + |
| 199 | +| 메서드 | 성공 조건 | 실패 조건 | 반환값 | |
| 200 | +| -------------------- | ----------------------- | ------------------------- | ---------------------- | |
| 201 | +| `Promise.all` | 모두 성공 | 하나라도 실패 | 성공값 배열 | |
| 202 | +| `Promise.allSettled` | 그냥 전부 끝나면 됨 | 거의 없음(항상 fulfilled) | 상태 객체 배열 | |
| 203 | +| `Promise.race` | 가장 먼저 성공하면 성공 | 가장 먼저 실패하면 실패 | 가장 먼저 끝난 값/에러 | |
| 204 | +| `Promise.any` | 하나라도 성공 | 전부 실패 | 가장 먼저 성공한 값 | |
| 205 | + |
| 206 | +</details> |
| 207 | + |
| 208 | +```ts |
| 209 | +const results = await Promise.allSettled(requests); |
| 210 | + |
| 211 | +const successIndices = results |
| 212 | + .map((result, index) => (result.status === "fulfilled" ? index : null)) |
| 213 | + .filter((index) => index !== null); |
| 214 | + |
| 215 | +if (successIndices.length !== chunks.length) { |
| 216 | + throw new PartialSuccessError(successIndices); |
| 217 | +} |
| 218 | +``` |
| 219 | + |
| 220 | +이후, ReactHookForm 에서 이 에러를 잡아서 성공한 항목은 폼에서 제거하고, 실패한 항목만 그대로 남깁니다 |
| 221 | + |
| 222 | +```ts |
| 223 | +onError: (error) => { |
| 224 | + if (error instanceof PartialSuccessError) { |
| 225 | + removeSuccessItems(error.successIndices); |
| 226 | + } |
| 227 | +}; |
| 228 | +``` |
| 229 | + |
| 230 | +결과적으로 사용자 입장에서는 이미 성공한 원아들은 화면에서 사라지고, 실패한 원아들만 남아서 재시도할 수 있게 됩니다. |
| 231 | + |
| 232 | +<center> |
| 233 | +<img src="./img/partial-success.png" alt="부분 성공 로직" width="500"/> |
| 234 | +</center> |
| 235 | + |
| 236 | +<br/> |
| 237 | + |
| 238 | +## 💡 그래서, 이게 왜 “프론트엔드에서 원자성 보장”일까? |
| 239 | + |
| 240 | +엄밀히 말하면, 이 방식은 데이터베이스 레벨의 원자성(Atomicity)을 보장하지는 않습니다. <br/> |
| 241 | +이미 일부 요청이 성공하면, 시스템 내부 상태는 중간 상태를 가지게 됩니다. |
| 242 | + |
| 243 | +하지만 중요한 건 사용자 경험(User Experience) 입니다. |
| 244 | + |
| 245 | +- 성공한 데이터는 유지되고 |
| 246 | +- 실패한 데이터만 남아 재시도할 수 있으며 |
| 247 | +- 사용자는 전체 작업을 다시 하지 않아도 됩니다 |
| 248 | + |
| 249 | +즉, 사용자 입장에서는 다음과 같이 느껴집니다. |
| 250 | + |
| 251 | +> "한 번에 다 처리되거나, 실패한 것만 다시 하면 된다" |
| 252 | +
|
| 253 | +결과적으로 시스템이 아닌 UX 레벨에서 “All or Nothing”에 가까운 경험을 제공하게 됩니다. |
| 254 | + |
| 255 | +<br/> |
| 256 | + |
| 257 | +## 🧠 Atomicity vs Idempotency vs UX |
| 258 | + |
| 259 | +이번 경험을 통해 한 가지 중요한 관점을 얻을 수 있었습니다. |
| 260 | + |
| 261 | +| 개념 | 설명 | 이 문제에서의 역할 | |
| 262 | +| ----------- | -------------------------- | ---------------------------------- | |
| 263 | +| Atomicity | 모두 성공하거나 모두 실패 | 이상적이지만 구현 비용/리스크 높음 | |
| 264 | +| Idempotency | 여러 번 실행해도 결과 동일 | 재시도 안정성 확보 | |
| 265 | +| UX Recovery | 사용자 경험 기반 복구 | 실제 해결 전략 | |
| 266 | + |
| 267 | +이 문제는 결국 |
| 268 | + |
| 269 | +> "시스템적으로 완벽한 원자성을 만들 것인가?" |
| 270 | +> vs |
| 271 | +> "사용자 경험으로 문제를 해결할 것인가?" |
| 272 | +
|
| 273 | +의 선택이었고, 저는 후자를 선택했습니다. |
| 274 | + |
| 275 | +<br/> |
| 276 | + |
| 277 | +## 🚀 확장 관점에서의 한계와 개선 방향 |
| 278 | + |
| 279 | +이번 방식에도 몇 가지 고려해야 할 지점이 있습니다. |
| 280 | + |
| 281 | +1. Promise.all 기반 병렬 요청은 요청 수가 많아지면 브라우저 connection limit에 걸릴 수 있음 |
| 282 | +2. 향후 100명 이상 업로드 시 동시성 제한 (p-limit) 적용 필요 |
| 283 | +3. 서버 단에서도 batch 처리 API 또는 presigned URL 구조로 확장 가능 |
| 284 | + |
| 285 | +<br/> |
| 286 | + |
| 287 | +## ✍️ 마치며 |
| 288 | + |
| 289 | +이번 문제는 단순히 413 Payload Too Large 에러를 해결하는 것을 넘어, |
| 290 | + |
| 291 | +> 네트워크 제약, API 설계 한계, 데이터 정합성, 사용자 경험 |
| 292 | +
|
| 293 | +이 네 가지를 동시에 고려해야 하는 문제였습니다. |
| 294 | + |
| 295 | +특히 인상 깊었던 점은, |
| 296 | + |
| 297 | +> 완벽한 시스템적 정합성을 보장할 수 없다면, 사용자 경험을 통해 그에 준하는 결과를 만들어낼 수 있다는 것 |
| 298 | +
|
| 299 | +이었습니다. |
| 300 | + |
| 301 | +프론트엔드는 단순히 데이터를 전달하는 계층이 아니라, |
| 302 | +실패를 다루고, 복구하고, 사용자 경험을 설계하는 영역이라는 것을 다시 한 번 느낄 수 있었습니다. |
0 commit comments