저희 서비스 앱에서 이미지를 저장할 때 11초 정도 걸린다는 보고가 있었습니다. 서버가 이미지를 직접 받아 S3에 올리는 구조를 클라이언트가 S3에 직접 업로드하는 구조로 전환한 과정을 담았습니다. 같은 구조를 고민 중인 분들께 도움이 되었으면 합니다.
1. 기존 구조 및 문제점
1.1 기존 구조

Flutter (클라이언트) → 이미지 업로드는 이미지 1장당 API를 1번 호출하는 구조이며 이미지 3장을 순차로 처리했습니다. 1장씩 요청을 보내고 응답을 받은 뒤 다음 장을 보내는 구조였습니다.
- img1 요청 → 응답 대기 → img2 요청 → 응답 대기 → img3 요청 → 응답 대기
Spring(서버) → Flutter에서 multipart/form-data 형태로 요청을 보내면 Spring Boot 서버가 받아서 처리합니다. 서버 내부 처리 순서는 다음과 같습니다.
- 검증 - 이미지 확장자, 파일 크기(10MB 이하), 스토리지 용량(5GB 이하) 확인
- 이미지 처리 - 파일을 bytes로 읽고 width / height 추출
- S3 업로드 - objectKey 생성 후 S3에 파일 저장
- presigned URL 생성 - S3 GET presigned URL 발급 (7일짜리)
- DB 저장 - objectKey, presignedUrl 등 메타데이터 저장
- 응답 - presignedUrl 포함한 첨부파일 정보 반환
1.2 측정 설계

어디서 시간을 쓰는지 모르는 상태에서는 어떤 최적화도 추측에 불과합니다. 그래서 코드 수정 전에 구간을 나눠 측정하는 것을 먼저 진행했습니다.
- Flutter → 서버 == 네트워크 구간(Flutter 책임)
- 서버 내부 → 파일 수신 / S3 업로드 / presign 생성(Spring 책임)
이후 네트워크 왕복 구간을 더 정밀하게 보기 위해 Flutter에서 콜백으로 두 구간을 추가로 분리했습니다.
- uploadMs: Flutter가 서버로 파일을 전송하는 시간
- waitMs: 파일 전송 완료 후 서버 응답을 기다리는 시간
네트워크 왕복 = uploadMs + waitMs
구간을 나눴으면 다음은 조건을 고정을 진행하였습니다.
측정 조건
- 이미지 크기: 1.4MB, 1.7MB, 1.8MB (갤러리 원본 PNG 기준)
- 네트워크: 모바일 5G
- 서버: LightSail 2GB / S3 ap-northeast-2
- 장수: 3장 동시
- 반복 횟수: 100회 (p99를 의미 있게 보기 위해)
- 웜업: 본 측정 전 더미 요청 5회 (TCP 핸드셰이크 · TLS 협상 비용 제외)
패킷 분석
- 서버에서 tcpdump로 Flutter → 서버 구간 패킷 캡처
- Wireshark IO Graph로 구간별 전송 속도 분석해 upload 시간 변동 원인 확인
1.2 순차 - 이미지 저장 3장 병목 측정 결과
Flutter 측정 결과
| 항목 | p50 | p95 | p99 | max |
| batch total | 11,209ms | 24,328ms | 25,311ms | 61,434ms |
서버 측정 결과
| 항목 | p50 | p95 | p99 |
| s3Ms | 231ms | 310ms | 356ms |
| presignMs | 2ms | 5ms | 6ms |
| receiveMs | 321ms | 393ms | 731ms |
패킷 분석 결과

3장 p50은 11,209ms였습니다. 순차 처리 구조라 이미지 1장씩 왕복이 3번 누적된 결과였습니다.
- p99는 25,311ms였고 max는 61,434ms였습니다.
- max가 p99보다 크게 튄 건 94회차에서 Flutter → 서버 파일 전송 시간(uploadMs)이 극단적으로 느렸기 때문입니다.
- 서버 측 지표는 안정적이었기 때문에 모바일 5G 네트워크 순간 단절에 가까운 변동이 원인이라고 생각했습니다.
- Wireshark에서도 750~1,000초 구간에서 TCP 에러가 집중적으로 발생했기 때문입니다.
1.3 해결 방향
3장 기준 시간 분해 (p50)
| 구간 | 시간 | 비율 |
| upload 합계 (Flutter → 서버) | 5,647ms | 50% |
| wait 합계 (서버 응답 대기) | 5,252ms | 47% |
| s3Ms 합계 | 693ms | 6% |
| batch total | 11,209ms | 100% |
Flutter → 서버(upload)와 서버 응답 대기(wait)가 전체의 94%를 차지했습니다. 서버 처리(s3Ms)는 6%에 불과했습니다. 서버 코드를 아무리 최적화해도 개선 상한이 6%라는 뜻이었습니다. 원인은 구조에 있었습니다. 3장이 순차로 처리되면서 이 비용이 3번 누적됩니다. 서버 처리를 줄이는 것보다 왕복 횟수 자체를 줄이는 방향으로 접근해야 했습니다.
2. 개선 1 - 이미지 저장을 병렬로 처리
Flutter에서 3장을 동시에 전송하는 병렬 처리로 전환했습니다. 서버를 최적화하는 것보다 왕복이 3번 누적되는 구조 자체를 바꾸는 것이 효과적이라고 판단했습니다. 원인은 단순했습니다. Flutter에서 이미지를 1장씩 순차로 보내고 응답을 기다린 뒤 다음 장을 보내는 구조였기 때문입니다.
- 기존 순차(await): (upload + wait) + (upload + wait) + (upload + wait)
- 병렬(Future.wait): max(upload + wait, upload + wait, upload + wait)
병렬 구조에서는 3장이 동시에 전송되므로 가장 오래 걸리는 1장만큼만 기다리면 됩니다.
2.1 병렬 - 이미지 3장 (100회) 측정 결과
Flutter 측정 결과
| 항목 | p50 | p95 | p99 | max |
| batch total | 6,522ms | 13,105ms | 14,722ms | 15,357ms |
서버 측정 결과
| 항목 | p50 | p95 | p99 | max |
| s3Ms | 239ms | 410ms | 479ms | 565ms |
| presignMs | 2ms | 6ms | 10ms | 16ms |
| receiveMs | 342ms | 763ms | 955ms | 1,058ms |
패킷 분석 결과

순차 vs 병렬 비교
| 항목 | 순차 | 병렬 | 변화 |
| p50 | 11,209ms | 6,522ms | -42% |
| p95 | 24,328ms | 13,105ms | -46% |
| p99 | 25,311ms | 14,722ms | -42% |
병렬 처리로 p50이 42% 줄었습니다. 3장이 동시에 전송되면서 누적 구조가 사라진 효과입니다. Wireshark에서 400초 구간에서 패킷이 7kpkts까지 급증했고 72~84회차 구간에서 Flutter → 서버(upload)이 10,000ms 이상으로 튀었습니다. TCP 에러도 이 구간에서 집중됐는데 모바일 5G 네트워크 변동이 원인이었습니다.
그런데 전체 수치가 줄어드는 동안 서버 측 지표는 반대 방향으로 움직이고 있었습니다.
| 항목 | 순차 p99 | 병렬 p99 | 변화 |
| receiveMs(파일 수신) | 731ms | 955ms | +31% |
| s3Ms(S3 업로드) | 356ms | 479ms | +35% |
p50은 줄었지만 서버가 파일을 수신하는 시간(receiveMs)과 S3에 업로드하는 시간(s3Ms)의 p99가 오히려 늘었습니다.
2.2 병렬 처리 3장일 때 CPU와 메모리 분석
3장이 동시에 서버에 도달할 때 서버 내부 어딘가에서 경합이 생기는 것으로 판단해 병렬 요청 중 LightSail의 CPU와 메모리를 측정했습니다.
다음 명령어를 사용하여 3장 병렬처리 전 CPU와 메모리는 다음과 같습니다.
free -m && top -bn5 -d2 | grep java
| 항목 | 수치 |
| 전체 RAM | 1,910MB |
| 사용 중 | 926MB |
| 여유 | 109MB |
| JVM 프로세스 메모리 | 652MB (RAM의 33.4%) |
| CPU | 0.5% |
다음은 3장 병렬 처리 일 때 CPU와 메모리 입니다. 다음과 같은 명령어를 사용해서 분석했습니다.
while true; do
echo "=== $(date) ===" >> /tmp/monitor.log
free -m >> /tmp/monitor.log
top -bn5 -d2 | grep java >> /tmp/monitor.log
sleep 1
done
| 항목 | 대기 상태 | 피크 | 증가 |
| 전체 메모리 | 969MB | 1,152MB | +183MB (+19%) |
| JVM 메모리 | 646MB | 836MB | +190MB |
| CPU | 0.5% | 200% | +200%p |
위를 보고 다음과 같이 판단하였습니다.
- CPU가 200%까지 튑니다. → 3장 병렬 요청이 들어오는 순간 CPU가 최대 200%까지 상승했습니다. LightSail 인스턴스가 vCPU 1개 기반인데 스레드가 동시에 돌면서 집중된 결과라고 생각했습니다.
- 메모리는 정상 동작합니다. → JVM 메모리가 836MB까지 올라갔다가 573MB로 급감하는 패턴이 반복됐습니다. GC가 정상적으로 메모리를 회수하고 있었기 때문에 메모리 부족이 원인은 아니었습니다.
→ CPU 경합으로 인해 각 요청의 처리가 지연된 것으로 판단했습니다. 그런데 CPU가 200%까지 올라가는 정확한 원인을 파악할 필요가 있었습니다. 서버 내부에서 어느 구간이 CPU를 많이 소비하는지 알아야 제대로 된 개선이 가능했기 때문입니다.
2.3 CPU의 병목 찾기
병렬 3장일 때의 CPU 병목을 찾기 위해 다음과 같은 리눅스 서버의 시스템 리소스를 실시간으로 모니터링하는 명령어인를 사용하여 컨텍스트 스위칭(cs), CPU 유저 사용률(us), CPU 유휴 상태(id)를 측정했습니다.
vmstat 1 >> /tmp/vmstat.log
결과 요약
| 항목 | 유휴 상태 | 병렬 요청 중 | 변화 |
| CPU(us) | 0~1% | 최대 96% | +95%p |
| 실행 대기(r) | 0 | 최대 8 | +8 |
vmstat로 CPU 유저 사용률(us)과 실행 대기 프로세스(r)를 1초 간격으로 측정했습니다. 유휴 상태에서는 CPU 사용률이 0~1%였지만 3장 병렬 요청이 들어오는 순간 최대 96%까지 상승했고 실행 대기 프로세스도 최대 8까지 늘었습니다.
스레드 단위로 더 정밀하게 보기 위해 다음과 같은 명령어로 Java 프로세스 내부 스레드별 컨텍스트 스위칭을 측정했습니다.
pidstat -w -t
| 스레드 | cswch/s | 의미 |
| VM Periodic Tas | 19.5/s | JVM 내부 주기 작업 |
| G1 Refine#0 | 7.5/s | G1 GC 카드 테이블 정제 |
| Monitor Deflati | 6.0/s | synchronized 모니터 관리 |
| http-nio-8080-exec | 3.0/s | 실제 요청 처리 스레드 |
실제 요청을 처리하는 http-nio-8080-exec 스레드의 비자발적 컨텍스트 스위칭(nvcswch)이 0에 가까웠습니다. CPU 경합이 심하면 nvcswch가 높게 나와야 하는데 그렇지 않았습니다. 즉 CPU 경합이 서버가 파일을 수신하는 시간(receiveMs) 증가의 직접적인 원인이 아닐 가능성이 높았습니다.
서버가 파일을 수신하는 시간(receiveMs)이 높아지는 더 정확히 파악하기 위해 내부를 다음과 같이 쪼개서 다시 측정을 했습니다.
- HTTP 수신과 Image byte[] 읽기를 시간 분리 진행
- bytesReadMs → Spring이 multipart로 받은 파일을 버퍼에서 byte[]로 복사하는 시간 == 원래 있었음
- imageIoMs → ImageIO.read()로 JPEG를 BufferedImage로 디코딩하는 시간
| 구간 | p50 | p95 | p99 | 평균 비율 |
| bytesReadMs | 2ms | 8ms | 10ms | 0.6% |
| imageIoMs | 368ms | 764ms | 1,087ms | 99.4% |
| receiveMs | 369ms | 774ms | 1,098ms | 100% |
receiveMs의 99.4%가 ImageIO.read()의 JPEG 디코딩이었습니다. bytesReadMs는 평균 2.6ms로 무시할 수 있는 수준이었고 CPU를 많이 쓰는 것도 JPEG 디코딩 자체가 CPU 집약적인 작업이었기 때문이었습니다. 3장이 병렬로 들어오면 3개 스레드가 동시에 JPEG 디코딩을 하면서 CPU가 96%까지 올라간 것이었습니다.
이 시점에서 새로운 질문이 생겼습니다. 코드를 확인해보니 이미지의 width/height를 추출해 DB에 저장하고 Flutter 응답에 포함시키기 위해서였습니다. 처음에 Flutter에서 이미지 리사이즈 시 필요할 것으로 예상해 추가한 코드였습니다. 그런데 Flutter에서 해당 값을 실제로 사용하지 않는다는 것을 확인했습니다. 팀 내 의사소통 부재로 Flutter에서 한 번도 사용하지 않은 값을 위해 receiveMs의 99.4%를 차지하는 연산이 남아있었던 겁니다. ImageIO.read()를 제거하면 receiveMs가 대폭 줄어들 것으로 예상했습니다.
2.4 이미지 디코딩(ImageIO) 삭제 후 병렬 3장 재측정
Flutter 측정 결과
| 항목 | p50 | p95 | p99 | max |
| batch total | 2,875ms | 3,186ms | 3,206ms | 4,157ms |
ImageIO 제거 전후 비교
| 항목 | 순차 (기준) 병렬 | (ImageIO 있음) 병렬 | (ImageIO 제거) | 변화 |
| 3장 p50 | 11,209ms | 6,522ms | 2,875ms | 순차 대비 74% 단축 |
| 3장 p99 | 25,311ms | 14,722ms | 4,157ms | 순차 대비 84% 단축 |
| 파일 수신 p50 | 321ms | 342ms | 2ms | 99% 감소 |
파일 수신 시간이 342ms에서 2ms로 99% 줄었습니다. 3장 p50이 11,209ms → 2,875ms로 74% 단축됐고 p99도 25,311ms → 4,157ms로 84% 줄었습니다.
| 항목 | ImageIO 있음 | ImageIO 제거 | 변화 |
| CPU 최대 | 96% | 57% | -39%p |
| CPU 평균 | 62% | 48% | -14%p |
| 실행 대기 최대(r) | 8 | 3 | -5 |
하지만 ImageIO 제거 후 vmstat로 다시 측정해보니 3장 병렬 요청 시 CPU가 여전히 최대 57%까지 올라갔습니다.
| 항목 | 1명 3장 병렬 | 5명 3장 병렬 | 변화 |
| CPU 평균 | 48% | 66% | +18%p |
| CPU 최대 | 57% | 97% | +40%p |
| 실행 대기 최대(r) | 3 | 7 | +4 |
지금은 사용자가 1명인 상태입니다. 사용자 1명이 3장을 병렬 업로드할 때 CPU가 이미 최대 57%까지 올라갑니다. 동시 사용자가 늘어나면 CPU 과부하가 더 심해질 것으로 예상했고 실제로 k6로 동시 사용자 5명이 3장씩 병렬 업로드하는 시나리오를 테스트해봤습니다. 1명일 때 최대 57%였던 CPU가 5명에서는 최대 97%까지 올라갔습니다. 파일이 Flutter → 서버 → S3로 두 번 전송되는 구조 자체가 한계였습니다. 서버는 파일을 받아서 그대로 S3에 넘기는 중계 역할만 하면서 CPU를 소비하고 있었습니다.
3. 개선 2 - Flutter에서의 S3 직접 업로드
S3에 이미지 업로드 기능 아키텍처 비교 (서버에 직접 전송, presigned URL 활용)
잘못된 내용이나 의견 있다면 편하게 말씀해주세요.🙏🏻 사이드 프로젝트를 하며 유저 프로필이나 음식점 리뷰 사진 등 이미지 업로드 기능이 필요했습니다. 처음엔 이미지를 업로드 기능을
kukim.tistory.com
위 블로그를 참조하여 서버는 인증, 검증, Presigned PUT URL 발급만 담당하고 파일 전송에서 완전히 빠집니다. Flutter가 서버로부터 URL을 발급받아 S3에 직접 업로드하는 구조입니다. 구조는 다음과 같습니다.

구조 설계
- Flutter가 서버에 Presigned PUT URL 발급을 요청합니다. 이때 3장 분량의 URL을 한 번에 요청합니다.
- 서버는 파일을 전혀 받지 않고 S3 Presigned PUT URL 3개를 발급해 Flutter에 반환합니다.
- Flutter는 발급받은 URL로 3장을 S3에 병렬로 직접 PUT 업로드합니다. 서버를 거치지 않습니다.
- S3 업로드가 완료되면 Flutter가 서버에 완료 신고와 함께 메타데이터(파일명, 경로 등)를 전송합니다.
- 서버는 메타데이터를 DB에 저장하고 Flutter에 응답합니다.
기존 구조와의 핵심 차이는 파일이 서버를 거치지 않는다는 점입니다. 서버는 인증, 검증, URL 발급, 메타데이터 저장만 담당하고, 실제 파일 전송은 Flutter ↔ S3 사이에서만 일어납니다.
3.1 S3 직접 업로드 - 이미지 3장 (100회) 측정 결과
Flutter 측정 결과
| 항목 | 순차(ImageIO 있음) | 병렬(ImageIO 있음) | 병렬+ImageIO 제거 | S3 직접 업로드 | 변화 |
| p50 | 11,209ms | 6,522ms | 2,875ms | 2,718ms | 순차 대비 -76% |
| p95 | 24,328ms | 13,105ms | 3,197ms | 3,072ms | 순차 대비 -87% |
| p99 | 25,311ms | 14,722ms | 4,157ms | 4,249ms | 순차 대비 -83% |
p50과 p95는 소폭 개선됐고 p99는 거의 동일했습니다. 지연 시간 자체는 크게 줄지 않았지만 서버 관점에서는 전혀 다른 결과가 나왔습니다.
서버 CPU 측정 결과 (vmstat)
| 항목 | 병렬+ImageIO 제거 | S3 직접 업로드 | 변화 |
| CPU 최대(us) | 57% | 5~10% | -47%p |
| CPU 평균(us) | 48% | 1~4% | -44%p |
| 실행 대기 최대(r) | 3 | 0 | -3 |
100회 측정 전체에서 CPU가 10%를 넘은 구간이 단 3회에 불과했습니다. 서버가 파일 전송에서 완전히 빠지면서 CPU 부하가 사실상 사라졌습니다. 앞서 5명 동시 업로드 시 CPU가 97%까지 올라갔던 구조적 한계가 해소됐습니다.
4. 개선 3 - 사용자가 업로드 완료를 기다려야 하는가
S3 직접 업로드로 전환하면서 Flutter가 presigned URL 요청, S3 업로드, 메타데이터 전송까지 전체 흐름을 직접 오케스트레이션하는 구조가 됐습니다. 이 구조를 만들면서 자연스럽게 질문이 생겼습니다.
- "Flutter가 업로드를 직접 제어하고 있는데, 사용자가 완료를 기다려야 할 이유가 있는가?"
저장 버튼을 누른 뒤 업로드가 끝날 때까지 화면을 막는 것은 사용자 입장에서 불필요한 대기입니다. 모바일 UX에서 사용자의 행동과 화면 반응 사이의 지연은 앱이 느리다는 체감으로 직결됩니다. 업로드 성공 여부와 관계없이 콘텐츠는 이미 로컬에 있고, 실패하더라도 나중에 재시도할 수 있기 때문에 사용자를 업로드 완료 시점까지 붙잡아둘 이유가 없었습니다. 그래서 저장 버튼 클릭 시 UI를 즉시 반영하고 S3 업로드는 백그라운드 비동기로 처리하는 구조로 전환했습니다. 사용자는 업로드가 완료되지 않은 이미지를 흰색 배경으로 미리 볼 수 있고, 업로드가 완료되면 실제 이미지로 교체됩니다.
4.1 앱 종료 시 업로드가 유실
그런데 백그라운드로 전환하고 나서 새로운 문제가 생겼습니다. 앱을 종료하면 업로드 중이던 항목들이 유실되는 것이었습니다. 백그라운드 비동기로 처리하는 구조에서는 앱이 살아있는 동안만 업로드 상태가 메모리에 유지되기 때문입니다.
이 문제는 SQLite로 해결했습니다. 업로드 전에 이미지 바이너리와 폴더, 메모 같은 메타데이터를 SQLite에 저장해두고, 앱 재시작 시 미완료 항목을 감지해 presigned URL을 새로 발급받아 재시도하는 방식입니다. 이미지를 여러 장 선택한 경우에도 작업 단위와 이미지 바이너리를 분리해서 저장하기 때문에 앱이 종료되더라도 어떤 이미지가 아직 올라가지 않았는지 정확하게 추적할 수 있습니다. presigned URL은 만료 시간이 있지만 재발급은 서버에 요청만 하면 되기 때문에 추가 비용이 크지 않았습니다. 이 구조에서 단점도 있습니다. 업로드 실패 시 사용자에게 즉시 알리지 않기 때문에, 실패가 오래 지속되면 사용자는 이미지가 올라간 줄 알고 있을 수 있습니다. 현재는 업로드 상태를 UI에 표시해 완료 전 항목을 구분할 수 있도록 처리했습니다.
정상 흐름 (12단계)

- 이미지를 메모리에 올린 직후 SQLite에 복사 저장합니다. 업로드가 완료되면 12에서 메모리와 SQLite 양쪽에서 이미지를 제거하고 목록을 갱신합니다.
재처리 흐름 (앱 재시작 시)

앱이 종료됐다가 재시작되면 이렇게 동작합니다.
- 1 SQLite에서 미완료 업로드 데이터 읽기
- 2 휴대폰 메모리에 이미지 올림
- 3 ~ 9 백그라운드에서 presign 새로 발급 → S3 업로드 → confirm
- 10 완료 후 메모리와 SQLite에서 이미지 제거 + 목록 갱신
presign을 앱 재시작 시 새로 발급받기 때문에 URL 만료 문제가 없습니다. 사용자는 앱을 다시 열기만 하면 중단됐던 업로드가 자동으로 재개됩니다. 또한, 두 가지 안전성도 확보했습니다.
- DB 저장 보장입니다. 저장 버튼을 누르면 DB 저장이 완료된 후에 화면이 닫히기 때문에 DB에 저장되지 않은 채 화면이 전환되는 경우는 없습니다.
- 트랜잭션 보장입니다. DB 저장은 SQLite 트랜잭션으로 묶여 있어 all-or-nothing으로 동작합니다. 트랜잭션 도중 앱이 종료되면 SQLite가 자동으로 롤백합니다. 즉 "DB에 일부만 저장된 상태"는 발생하지 않습니다. DB 저장이 완료됐으면 재시작 시 재시도하고 저장 전에 종료됐으면 재시도 대상이 없습니다.
4.1 업로드 완료 기다리지 않는 구조 전환 - 이미지 3장 (100회) 측정 결과
Flutter 측정 결과
| 항목 | p50 | p95 | p99 | max |
| presign (URL 발급) | 49ms | 70ms | 244ms | 251ms |
| img(S3, 백그라운드) | 2,720~2,750ms | - | - | - |
| confirm (완료 신고) | 115ms | 159ms | 221ms | 355ms |
| total | 3,099ms | 4,275ms | 5,259ms | 6,101ms |
S3 업로드는 백그라운드에서 처리되기 때문에 사용자가 실제로 기다리는 구간은 presign과 confirm뿐입니다.
사용자 체감 여부
| 구간 | p50 | 사용자 체감 여부 |
| presign (URL 발급) | 49ms | 기다림 |
| img(S3) | 2,720ms | 기다리지 않음 |
| confirm (완료 신고) | 115ms | 기다리지 않음 |
| 체감 합계 | 164ms |
전체 비교 (3장 기준)
| 방식 | 3장 체감 p50 | 단축 |
| 순차(ImageIO 있음) | 11,209ms | - |
| 병렬+ImageIO 제거 | 2,875ms | -74% |
| S3 직접 업로드 | 2,718ms | -76% |
| 백그라운드 업로드 | 164ms | -99% |
출발점(11,209ms)에서 최종 사용자 체감 시간(164ms)까지 99% 단축됐습니다. S3 업로드가 백그라운드로 빠지면서 사용자가 기다리는 시간이 presign과 confirm만 남았습니다.
5. 최종 개선 요약
전체 시도 요약
| 시도 | 3장 p50 | 3장 p99 | 서버 부하 |
| 순차 (기준) | 11,209ms | 25,311ms | 있음 |
| 개선 1 Flutter 병렬 처리 | 6,522ms | 14,722ms | 있음 |
| 개선 1-1 병렬 + ImageIO 제거 | 2,875ms | 4,157ms | 있음 (감소) |
| 개선 2 S3 직접 업로드 | 2,718ms | 4,249ms | 없음 |
| 개선 3 낙관적 업데이트 | 3,099ms (백그라운드) | - | 없음 |
사용자 체감 시간 비교
| 방식 | 3장 체감 p50 | 단축 |
| 순차 (기준) | 11,209ms | - |
| 병렬 + ImageIO 제거 | 2,875ms | -74% |
| S3 직접 업로드 | 2,718ms | -76% |
| 백그라운드 업로드 | 164ms | -99% |
출발점(11,209ms)에서 최종 사용자 체감 시간(164ms)까지 99% 단축됐습니다. 단순히 업로드를 빠르게 만드는 것을 넘어 사용자가 업로드 완료를 기다리지 않아도 되는 구조로 전환한 것이 가장 큰 변화였습니다.
6. 회고
- 측정 프로토콜을 먼저 만들었다
코드를 수정하기 전에 측정 환경을 먼저 설계했습니다. 웜업 횟수, 측정 반복 수, 조건 고정 방식을 정해두지 않으면 개선 전후 비교 자체가 의미 없어지기 때문입니다. 처음 20회 측정에서 웜업 없이 진행했다가 S3 콜드 스타트로 인해 1회차 값이 튀는 것을 발견했고 그때서야 웜업 5회 + 본 측정 100회로 프로토콜을 다시 설계했습니다. 측정 설계 오류를 먼저 발견한 것이 이후 모든 측정의 신뢰도를 높이는 데 중요했습니다.
- 해결할 수 없는 부분을 인정하는 것도 엔지니어링이다
S3 콜드 스타트를 없애려고 커넥션 유지 설정을 검토했지만 AWS 공식 문서에도 S3 서버 측 커넥션 종료 시점이 명시되어 있지 않았습니다. 클라이언트 설정만으로는 근본적으로 해결할 수 없다고 판단했고 해결 대신 측정 설계로 영향을 제거하는 방향을 선택했습니다. 모든 문제를 코드로 해결하려 하지 않고 제어할 수 없는 영역을 인정하는 것도 현실적인 판단이라는 것을 배웠습니다.
- 질문을 바꾸면 해법이 달라진다
S3 직접 업로드 후에도 p99가 크게 개선되지 않았습니다. 계속 업로드 속도를 줄이려 했다면 막혔을 겁니다. "업로드가 완료될 때까지 사용자를 기다리게 해야 하는가"로 질문을 바꾸자 낙관적 업데이트라는 방향이 열렸습니다. 문제를 푸는 것보다 문제를 다시 정의하는 것이 더 큰 변화를 만들 수 있다는 것을 이번 작업에서 가장 크게 느꼈습니다.
'프로젝트 > toIT' 카테고리의 다른 글
| 통합검색 성능 개선기 (0) | 2026.07.02 |
|---|---|
| Spring의 요청 처리 순서 (0) | 2026.07.02 |
| OSIV (0) | 2026.07.02 |
| 톰캣 (0) | 2026.07.02 |
| 이미지 조회 시 S3 원본 직접 전송 구조를 Lambda 리사이즈 및 CloudFront 캐싱 구조로 전환 (0) | 2026.06.12 |