1. 문제점 파악
이미지 업로드 속도가 느리다.
Gloddy 프로젝트를 운영하면서 구글폼에 여러 문의가 들어왔는데, 그 중 가장 많았던 게 바로 '이미지 업로드가 너무 느려요'였다. 사용자가 직접 문의할 정도면 심각한 거라고 생각했고, 이건 뭐가 됐든 고쳐야겠다고 마음먹었다.
현재 상황이 얼마나 심각할까?

일단 개선 전후를 비교하려면 현재 속도부터 측정해야 했다. 사용자가 이미지를 선택한 순간부터 화면에 실제로 나타나기까지 총 3.6초가 걸렸다.
내 환경은 빠른 인터넷에 최신 휴대폰이었는데도 이 정도였다. 더 열악한 환경에서는 얼마나 답답할까 싶었다. 😢
2. 원인 파악
기존 이미지 업로드 프로세스를 분석해보니 이렇게 동작하고 있었다.
- 클라이언트 → 백엔드 서버로 이미지 파일 전송
- 백엔드 서버 → AWS S3로 이미지 업로드
- AWS S3에서 이미지 URL 생성 후 백엔드 서버로 반환
- 백엔드 서버 → 클라이언트로 URL 전달
- 클라이언트에서
<img />태그의src에 URL 설정하여 이미지 표시
그림으로 표현하면 다음과 같습니다.
문제는 명확했다. 이미지가 클라이언트 → 백엔드 → S3를 거쳐 다시 돌아오는 여정이 너무 길었던 것이다. 특히 모바일 환경에서 큰 이미지를 업로드할 때는 이 과정이 훨씬 더 오래 걸릴 수밖에 없었다.
3. 해결 방법 강구
다른 방법은 없을까?
이미지를 브라우저에 표시하는 방법을 찾다가 base64 인코딩을 떠올렸다. base64로 변환하면 별도의 네트워크 요청 없이 바로 이미지를 표시할 수 있고, 변환 자체도 빠르게 처리된다.
그래서 이런 전략을 세웠다. 사용자에게는 base64로 변환한 이미지를 먼저 보여주고, 백그라운드에서는 서버에 이미지를 업로드해서 실제 URL을 받아놓는 것이다. 사용자가 폼을 작성하는 동안 서버 업로드를 처리하고, 최종 제출 시점에 받아둔 URL을 함께 보내면 된다.
1. 이미지를 base64로 변환을 하고, 서버에 이미지 파일 전송한다
사용자가 이미지를 선택하면 두 가지 작업을 동시에 처리한다.
input.onchange = (event) => {
const { files } = event.target as HTMLInputElement;
if (!files) return;
const reader = new FileReader();
reader.onload = () => {
// base64로 변환된 이미지를 즉시 화면에 표시
previewImageField && previewImageField.onChange(reader.result);
};
reader.readAsDataURL(files[0]);
// 동시에 서버에 이미지 업로드 시작
mutate(
{ fileList: Array.from(files) },
{
onSuccess: (data) => {
handleFileChange(data.fileUrlList);
},
}
);
};
여기서 핵심은 FileReader API다. Web API에서 기본으로 제공하는 이 API를 사용하면 파일을 비동기적으로 읽어서 base64 같은 다양한 형식으로 변환할 수 있다. 사용자 컴퓨터의 파일을 브라우저에서 안전하게 읽을 수 있도록 설계되어 있어서, 별도의 라이브러리 없이도 충분히 활용할 수 있다.
readAsDataURL 메서드를 호출하면 파일을 읽어서 base64로 인코딩된 데이터 URL로 변환해준다. 이렇게 변환된 데이터는 onload 콜백에서 받아서 바로 화면에 표시할 수 있다.
동시에 react-query의 useMutation을 사용해서 백엔드 서버에 파일을 전송한다. 이 두 작업이 병렬로 진행되기 때문에 사용자는 업로드 완료를 기다리지 않고 바로 이미지를 확인할 수 있다.
2. 변환된 이미지를 즉시 표시하기
base64로 변환이 완료되면 이미지를 바로 렌더링한다.
if (previewImage) {
return <Image src={previewImage} alt="group_image" className="object-cover" fill />;
}
처음에는 서버에서 URL을 받은 후에 이미지를 교체해야 하나 고민했다. 하지만 사용자 입장에서는 이미 화면에서 이미지를 보고 있는데 굳이 깜빡이면서 다시 로드할 필요가 없다고 판단했다. base64 이미지와 S3 URL 이미지는 어차피 똑같은 이미지니까 말이다.
4. 개선 후
이미지가 거의 즉시 업로드되어 시간 측정이 힘들었지만, 대략 0.2초 정도 걸렸다.
5. 그래서 얼마나 바뀌었을까?
수치로 성능 개선 측정하기
이미지 업로드를 base64형태로 변환하여, 이 base64를 Image태그의 src에 넣어 사용자에게 미리 보여준다. 이 base64형태로 변환하여 사용자에게 보여주기까지 시간이 얼마나 걸릴까?
1. performance.now() vs Date.now()
Date.now()는 1970년 1월 1일(유닉스 시대)부터 현재까지의 시간을 밀리초로 반환한다. 로그에 타임스탬프를 남기거나 날짜/시간 관련 작업에 주로 쓰인다. 하지만 정밀도가 낮아서 세밀한 성능 측정에는 적합하지 않다.
반면 performance.now()는 페이지가 로드된 시점부터의 시간을 밀리초 단위로 반환하되, 소수점 이하까지 포함한다. 마이크로초 단위의 정밀도를 제공하기 때문에 코드 실행 시간, 페이지 로딩 시간, 애니메이션 프레임 측정 등 성능 분석에 훨씬 적합하다.
당연히 성능 측정이 목적이었기 때문에 performance.now()를 선택했다.
2. performance.now()로 시간 측정하기
const timeBeforeUpload = performance.now();
reader.onload = () => {
previewImageField.onChange(reader.result);
const timeAfterUpload = performance.now();
console.log(timeAfterUpload - timeBeforeUpload);
};
reader.readAsDataURL(files[0]);
❓ FileReader는 비동기로 실행되지 않나요?
맞습니다. FileReader는 공식문서에서처럼 비동기적으로 실행이 됩니다.
![]()
아래 코드에서도 확인해볼 수 있습니다.
const file = new File(["Hello, World!"], "test.txt", { type: "text/plain" }); console.log('시작'); const reader = new FileReader(); reader.onload = (event) => { console.log('파일 로드 완료'); console.log('파일 내용:', event.target.result); }; reader.readAsText(file); console.log('끝');![]()
시작 > 끝 > undefined(이 콘솔 실행문의 결과) > 파일 로드 완료 > 파일 내용~ 순서이다.
하지만, 위 문에서는 reader.onload 내부가 결국 시작 후에 비동기적으로 종료되므로 동기적으로 실행하는 것과 무관한 결과를 보인다.
관련 내용 : Javascript에서 비동기 처리 방법 (Callback Queue, Event Loop, Promise, async-await, Libuv)
-
결과
5밀리초라고 한다.
이 시간이 정말 사용자에게 이미지가 보여질 때까지의 시간일까?
이미지를 base64로 변환하여, 이를 사용자에게 이미지로 보여주는 과정은 다음과 같다.
-
이미지를 base64로 변환한다.
- 이 시간이 위 이미지와 같이 5밀리초이다.
-
변환한 base64문자열을 Image태그의 src에 설정한다.
실제로 네트워크 탭을 확인해보니
react-dom.development.js에서 base64 데이터를 처리하는 과정이 보였다.
Next.js가 이미지를 최적화하고, React DOM이 이를 브라우저에 전달하고, 브라우저가 최종적으로 렌더링하는 전체 과정이 필요했던 것이다.
// 소스 > react-dom.development.js case 'src': case 'href': { if (value == null || typeof value === 'function' || typeof value === 'symbol' || typeof value === 'boolean') { domElement.removeAttribute(key); break; } // `setAttribute` with objects becomes only `[object]` in IE8/9, // ('' + value) makes it output the correct toString()-value. { checkAttributeStringCoercion(value, key); } var sanitizedValue = sanitizeURL('' + value); domElement.setAttribute(key, sanitizedValue); break; }
결론적으로, 위에서 내가 측정한 5ms라는 수치는 base64로 변환하는데까지 걸린 시간이지, 사용자에게는 아직 보여주지 않은 상태라는 것이다.
사용자가 이미지를 업로드하고, 브라우저에 이미지가 띄어지는 진짜 시간은 어떻게 구할까?
-
사용자가 이미지를 선택한 시점에 측정을 시작한다.
const timeBeforeUpload = performance.now(); reader.onload = () => { previewImageField && previewImageField.onChange(reader.result); // .. }; -
이미지가 브라우저에 로드가 완료되면 한 번 더 측정한다.
<Image // .. onLoad={() => console.log('performanceBeforeUpload' + performance.now())} />onLoad 이벤트는 리소스와 그것에 의존하는 모든 리소스의 로딩이 완료되면 발생한다. 이미지가 완전히 디코딩되고 화면에 나타날 준비가 된 시점이다.
다시 측정해보자
base64로 변환 후 이미지를 사용자에게 보여줄 경우
5083 - 5056 = 27ms이다.
백엔드 서버에서 전달받은 S3 Url을 사용자에게 보여줄 경우
7213 - 6744 = 469ms이다.
442밀리초, 469 / 27 = 약 17배 빨라진 셈이다.
442밀리초도 나름 작은 수치이지만, 성능 테스트를 한 기기가 PC이고 안정적인 인터넷 환경이여서 업로드 속도가 빠른 것도 한 몫을 한 것이라 생각한다.
일반적인 모바일 기기에서는 어떻게 테스트를 할까?
Mac -iOS를 연결하여 Safari로 디버깅을 하려는데, iOS에서 접속할 수 있는 url은 localhost가 아닌, 172.30.x.x의 내가 현재 접속한 인터넷의 ip주소이다. 이 ip주소는 백엔드 서버에서 origin site에 추가해주지 않았기에 이 ip주소로는 접속할 수 없다.
- 백엔드 서버에 모든 ip를 origin ste 풀어달라고 요청한다.
- 휴대폰에서의 테스트는 포기한다.
두 가지의 방법이 있다. 테스트를 포기하기에는 너무 이르고, CORS가 보안을 위한 정책이라면, 개발 서버만 허용해주는 방법도 있지 않을까?
우선, 이에 대한 요청을 백엔드에 요청했다.
빠른 3G환경에서의 테스트
개발자 모드 > 네트워크에서 빠른 3G환경이 그나마 모바일 기기와 유사하다고 생각하여 모바일 기기에서 측정해보고자 한다.
-
base64 : 33ms
-
백엔드서버로부터 받은 url : 1286ms
빠른 3G환경에서는 38배 가까운 속도 차이를 보인다.
결론
이번 개선 작업을 통해 몇 가지 중요한 걸 배웠다.
먼저 성능 개선은 측정에서 시작된다는 것이다. performance.now()를 활용해서 정확한 수치를 확보하니 개선 전후를 명확하게 비교할 수 있었다. 체감만으로는 알기 어려운 정확한 개선 폭을 확인할 수 있었고, 이는 나중에 비슷한 문제를 해결할 때도 큰 도움이 될 것 같다.
두 번째는 사용자 경험을 생각한 최적화의 중요성이다. 서버에 이미지를 저장하는 것도 중요하지만, 사용자가 실제로 이미지를 빨리 보는 게 더 중요했다. 백그라운드 업로드를 활용해서 두 가지를 모두 해결할 수 있었다.
마지막으로 측정 방법 자체도 정확해야 한다는 걸 깨달았다. 처음에는 단순히 변환 시간만 측정했지만, 실제로는 렌더링 완료까지의 전체 시간을 측정해야 의미있는 지표를 얻을 수 있었다. 올바른 측정 지점을 선택하는 것도 성능 최적화의 중요한 부분이다.
네트워크 환경이 열악할수록 이런 최적화의 효과는 더 크게 나타난다. 빠른 환경에서는 17배 빨라졌지만, 느린 3G 환경에서는 38배나 빨라졌다. 모든 사용자 환경을 고려한 최적화가 얼마나 중요한지 실감할 수 있었다.