본문 바로가기

Frontend Dev Log

파일 업로드, 간단한 기능 뒤 원리가 뭘까?

당연하게 사용되는 파일 업로드 기능. 드래그 앤 드롭 한 번이면 끝나는 간단한 동작 같지만, 그 내부에는 생각보다 재미있는 원리들이 숨어있다. 최근 스토리지 시스템을 만들어 보는 개인 프로젝트를 진행하며 알게 된 원리인데 이번 글에서 간단하게 한 번 풀어보고자 한다.

파일 업로드, 간단한 기능 뒤 원리가 뭘까?

 

"파일"이란 건 뭘까?

파일은 결국 숫자의 나열이다. 우리가 흔히 말하는 이진 데이터로 이루어진 것이 바로 파일이다.

https://kr.freepik.com/premium-vector/file-extension-download-icons-collection_26558683.htm

  • 텍스트 파일: "hi~" → [236, 149, 136, 235, 133, 149, 237, 149, 152, 236, 132, 184, 236, 154, 148]
  • 이미지 파일: 각 픽셀의 RGB 값들 → [255, 0, 0, 128, 255, 200, ...]
  • 영상 파일: 여러 이미지 + 오디오 데이터의 조합

이처럼 모든 파일이 결국 0과 1로 이루어진 숫자의 연속이라는 걸 알 수 있다.

 

파일의 실제 모습

브라우저에서 파일을 읽으면 실제로 이렇게 보인다.

return uploadFile({ file, ownerId, accountId, path }).then(
	async (uploadFile) => {
		if (uploadFile) {
			
            // 파일의 이진 데이터 확인하기
            try {
            	// 1. ArrayBuffer로 파일 읽기
            	const buffer = await file.arrayBuffer();
            	const bytes = new Uint8Array(buffer);

            	console.log('=== 파일 이진 데이터 분석 ===');
            	console.log('파일명:', file.name);
            	console.log('파일 크기:', file.size, 'bytes');
            	console.log('파일 타입:', file.type);

            	// 2. 처음 20바이트 확인 (파일 시그니처)
            	const firstBytes = bytes.slice(0, 20);
            	console.log('처음 20바이트 (10진수):', Array.from(firstBytes));
            	console.log('처음 20바이트 (16진수):',
            	
                Array.from(firstBytes).map
                	(b => b.toString(16).padStart(2, '0')).join(' ')
            	);

            	// 3. 파일 시그니처로 실제 파일 타입 확인
            	const signature = Array.from(firstBytes.slice(0, 4))
            		.map(b => b.toString(16).padStart(2, '0'))
            			.join('')
            				.toUpperCase();

            	const fileSignatures = {
            		'FFD8FF': 'JPEG 이미지',
            		'89504E47': 'PNG 이미지',
            		'474946': 'GIF 이미지',
            		'504B0304': 'ZIP/Office 문서',
            		'25504446': 'PDF 문서',
            		'52494646': 'RIFF (WAV/AVI)',
            		'49443': 'MP3 파일'
            	};

            	const detectedType = Object.entries(fileSignatures)
            		.find(([sig]) => signature.startsWith(sig))?.[1] || '알 수 없는 타입';

            	console.log('파일 시그니처:', signature);
           		console.log('감지된 파일 타입:', detectedType);

            	// 4. 전체 버퍼 정보
            	console.log('전체 ArrayBuffer:', buffer);
            	console.log('Uint8Array 길이:', bytes.length);

            	// 5. 메모리 사용량 체크 (대략적)
            	const memoryMB = (bytes.length / 1024 / 1024).toFixed(2);
            	console.log('메모리 사용량:', memoryMB, 'MB');

            	console.log('=== 분석 완료 ===');

            	} 
                
                catch (error) {
                    console.error('이진 데이터 읽기 실패:', error);
                }

            	// console.log('uploaded file:', uploadFile);
            	setFiles((prevFiles) =>
            		prevFiles.filter((f) => f.name !== file.name)
            	);
		}
	}
)

<개인 프로젝트에서 따온 파일 업로드 코드>

코드 실행 화면 + 실제 파일 모습(https://ko.wikipedia.org/wiki/%EC%9D%B4%EC%A7%84_%ED%8C%8C%EC%9D%BC)

이 숫자들이 바로 파일의 "내용물"인 것이다.

 

그렇다면 파일 업로드란?

파일 업로드는 이런 과정을 거친다.

파일 업로드 과정 도식화

  1. 내 컴퓨터의 숫자들을 읽어서
  2. 메모리라는 임시 공간에 복사하고
  3. 네트워크를 통해 서버로 전송해서
  4. 서버 저장공간에 똑같이 복사하는 것

여기서 중요한 점은 원본 파일은 그대로 남아있고, 복사본이 서버에 생기는 것이다.

 

왜 메모리를 거쳐야 할까?

메모리의 역할은 '임시 작업 공간'이라고 볼 수 있다. 파일을 바로 네트워크로 보낼 수는 없기 때문에 중간에 메모리라는 작업 공간이 필요한 것이다.

파일 업로드 시 메모리 구조

내 노트북 파일 → 메모리 (RAM) → 네트워크 → 서버 저장소

파일은 저장소에 잠자고 있는 상태라고 생각하면 편하다. CPU가 이 파일로 네트워크로 전송을 하든 뭘 하든 작업을 하려면 반드시 메모리로 깨워서 가져와야 한다. 

 

비유로 더 상세하게 봐보도록 하자. 음...

창고에 있는 박스를 다른 곳으로 보내려면, 먼저 작업대로 가져와서 포장을 해야한다. 마찬가지로 파일을 네트워크로 보내려면 메모리로 가져와서 HTTP 형태로 포장하는 작업이 필요하다.

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Content-Type: image/jpeg

[실제 이진 데이터가 여기에...]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

<파일 데이터 HTTP 포장 예시 from chatGPT>

 

왜 저장소에서 바로 네트워크로 보낼 수 없을까?

  1. 저장소는 느리고, 메모리는 빠르기 때문이다.
  2. 저장소는 순차적 접근이 가능하지만 메모리는 랜덤 접근이 가능하다.
  3. CPU가 메모리에만 직접 소통할 수 있게 설계되어 있다.

크게 이 세 가지 이유 때문에 반드시 메모리를 거치는 게 좋지만 여기서 문제가 생긴다.

 

만약 2GB의 영상 파일을 업로드한다면?

2GB를 통째로 메모리에 올리면 그만큼 메모리 공간이 할당되어 다른 프로그램들이 느려지게 된다. 용량이 더 크다면 브라우저가 뻗을 수도 있을 것이다. 

 

이 문제에 해결책은 조금씩 나눠서 처리하는 '스트리밍'이라는 방식이다. 파일을 작은 조각인 청크단위로 나눠서 처리하게 된다.

[ 2GB 영상 파일 ]

[ 1MB 조각1 ] → 메모리 → 네트워크 → 서버
[ 1MB 조각2 ] → 메모리 → 네트워크 → 서버
[ 1MB 조각3 ] → 메모리 → 네트워크 → 서버
...
[ 2048개 조각 ] 완료!

이렇게 하면

  • 메모리는 항상 1MB만 사용할 수 있고
  • 다른 프로그램에 영향이 거의 없고
  • 업로드 진행률도 실시간으로 파악이 가능하고
  • 중간에 에러가 나도 전체를 다시 시작할 필요가 없게된다.

스트리밍 방식을 쓰냐 안 쓰냐는 사용자 경험에 중대한 영향을 미치기 때문에 이런 똑똑한 해결책이 필요하다. 이 덕분에 우리가 몇 GB짜리 영상도 문제없이 유튜브에 올릴 수 있고 구글 드라이브에 큰 파일 업로드도 가능해지는 것이다.

 

끝내는 말

이번 개인 프로젝트를 진행하면서 파일 업로드에 자세한 과정에 대해 학습해보았는데, 파일 업로드라는 평범한 기능 하나에도 이렇게 많은 원리들이 숨어있다는 게 신기했다. 겉으로는 단순해 보이는 드래그 앤 드롭 하나가 실제로는 이진 데이터 읽기, 메모리 관리, 네트워크 전송, 서버 저장이라는 과정을 거치고 있었던 것이다.

 

특히 스트리밍이라는 해결책이 없었다면 우리는 아직도 몇 MB짜리만 겨우 업로드/다운로드하며 "왤캐 느리냐"라고 투털거리고 있지 않았을까 한다. 항상 느끼는 거지만 기술의 발전이란 결국 이런 작은 문제들을 하나씩 해결해나가는 과정인 거 같다.

 

다음번에 파일 업로드 진행률 바가 조금씩 올라가는 걸 보면 '내 파일이 1MB씩 올라가고 있구나' 생각할 거 같다 ㅋㅋㅋ 당연하게 여겨왔던 기능들 뒤에 숨어있는 원리를 이해하면, 개발할 때도 더 깊이있는 사고가 가능하지 않을까?