08.1 파일 업로드 폼은 뭐가 다를까요?
지금까지 다룬 폼은 글자를 받았어요. 파일 업로드는 결이 조금 달라요. 사용자 컴퓨터에 있는 파일 자체를 서버로 옮기는 일이거든요. 사진, PDF, 동영상처럼요. 시작점은 input type=file이에요.
상황: 프로필 사진을 여러 장 올리는 칸을 만들어요.
<form>
<input type="file" id="photo" accept="image/*" multiple>
</form>
속성 두 개가 핵심이에요. accept(어셉트, 어떤 종류를 받을지 힌트)에 image/*를 주면 파일 선택창이 이미지 위주로 걸러 보여줘요. multiple(멀티플)을 붙이면 한 번에 여러 개를 고를 수 있고요. 안 붙이면 한 개만 선택돼요.
08.2 accept만 걸면 이미지만 들어올까요?
여기서 초보가 크게 착각해요. accept="image/*"를 걸었으니 이미지만 들어오겠지, 하고요. 아니에요. accept는 선택창을 정리해주는 힌트일 뿐 강제가 아니에요. 사용자가 선택창에서 모든 파일 보기로 바꾸면 .exe든 .zip이든 얼마든지 고를 수 있어요.
06편에서 배운 그 원리가 여기서도 똑같이 적용돼요. 브라우저가 걸어둔 제한은 언제든 우회된다. 그러니 파일도 자바스크립트에서 한 번, 서버에서 또 한 번 반드시 다시 검사해야 해요. accept만 믿고 넘어가면 엉뚱한 파일이 그대로 올라와요.
08.3 파일을 서버로 어떻게 보내나요?
글자는 JSON(제이슨, 데이터 표현 형식)으로 보냈지만, 파일은 FormData(폼데이터, 파일까지 담아 보내는 전송 객체)에 담아요. 선택된 파일들은 input.files에 들어 있어요.
const input = document.getElementById('photo');
const fd = new FormData();
for (const file of input.files) {
fd.append('photos', file); // 같은 이름으로 여러 개 담기
}
fetch('/api/upload', { method: 'POST', body: fd });
동작을 보면 input.files를 돌면서 고른 파일을 전부 fd.append로 담고, 그걸 fetch의 body에 그대로 넘겨요. 한 가지 팁이 있어요. FormData를 보낼 땐 Content-Type 헤더를 직접 넣지 마세요. 브라우저가 파일 경계를 표시하는 값을 알아서 붙여주는데, 우리가 손대면 그게 깨져서 서버가 파일을 못 읽어요. body에 FormData만 넘기고 헤더는 건드리지 않는 게 정답이에요.
08.4 용량과 타입은 어떻게 걸러요?
보내기 전에 두 가지를 걸러요. 용량과 진짜 종류예요. 용량은 쉬워요. file.size가 바이트(byte, 파일 크기 단위) 단위로 크기를 알려주니 기준과 비교하면 돼요. 너무 큰 파일을 미리 걸러 헛된 업로드를 막는 거죠.
종류가 까다로워요. file.type이 알려주는 MIME 타입(마임 타입, 파일 종류 표시)은 위조가 가능하거든요. 헤더 값이라 얼마든지 바꿔 붙일 수 있어요. 그래서 진짜로 확인하려면 매직 바이트(magic bytes, 파일 맨 앞에 박힌 종류 표식)를 봐요. 예를 들어 PNG는 첫 바이트가 항상 89 50 4E 47로 시작해요.
const MAX = 10 * 1024 * 1024; // 10MB
async function isRealImage(file) {
const buf = new Uint8Array(await file.slice(0, 8).arrayBuffer());
const isPNG = [0x89, 0x50, 0x4E, 0x47].every((b, i) => buf[i] === b);
const isJPEG = buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF;
return isPNG || isJPEG;
}
for (const file of input.files) {
if (file.size > MAX) { showError('10MB 이하만 올릴 수 있어요'); return; }
if (!(await isRealImage(file))) { showError('이미지 파일만 올릴 수 있어요'); return; }
}
동작을 보면 file.slice(0, 8)로 파일 맨 앞 8바이트만 잘라 읽어서, 그 값이 PNG나 JPEG의 실제 표식과 맞는지 봐요. 확장자를 .png로 바꿔 붙인 가짜 파일은 이 표식이 안 맞아서 걸러져요. 용량은 file.size로 먼저 치고요. 이름이 아니라 내용으로 판단하는 게 핵심이에요.
08.5 업로드 진행률 바는 왜 fetch로 안 되나요?
큰 파일을 올릴 땐 진행률 바(progress bar, 얼마나 올라갔는지 보여주는 막대)가 있어야 사용자가 안 답답해요. 그런데 여기서 fetch가 발목을 잡아요. fetch는 업로드 진행 상황을 알려주는 기능이 없어요. 그래서 진행률만큼은 옛날 도구인 XMLHttpRequest(XHR, 예전부터 쓰던 서버 요청 객체)를 써요.
function upload(file) {
const bar = document.getElementById('bar');
const xhr = new XMLHttpRequest();
const fd = new FormData();
fd.append('file', file);
bar.hidden = false;
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) bar.value = (e.loaded / e.total) * 100;
};
xhr.onload = () => { bar.hidden = true; };
xhr.open('POST', '/api/upload');
xhr.send(fd);
}
동작을 보면 xhr.upload.onprogress가 업로드가 진행되는 동안 계속 불려요. e.loaded(지금까지 보낸 양)를 e.total(전체 양)로 나누면 몇 퍼센트인지 나오죠. 그 값을 진행률 바에 넣으면 막대가 차오르고, 다 올라가면 onload에서 막대를 숨겨요. 이게 파일 하나에 fetch 대신 XHR을 쓰는 유일한 이유예요.
08.6 오늘 배운 파일 업로드, 이렇게 정리돼요
파일 폼은 챙길 게 조금 많았어요. 순서대로 짚을게요. 첫째, input type=file에 accept로 종류 힌트를, multiple로 다중 선택을 줘요. 둘째, accept는 힌트일 뿐이라 자바스크립트와 서버에서 반드시 다시 검사해요. 셋째, 파일은 FormData에 담아 보내고, Content-Type 헤더는 직접 넣지 않아요.
넷째, file.size로 용량을, 매직 바이트로 진짜 종류를 걸러요. 이름이 아니라 내용으로요. 다섯째, 진행률 바가 필요하면 fetch 대신 XHR을 써요.
마지막으로 제일 중요한 걸 다시 강조할게요. 클라이언트 검사는 편의일 뿐, 진짜 방어는 서버예요. 서버에서도 매직 바이트를 다시 확인하고, 올라온 파일은 실행 권한이 없는 경로에 저장해야 해요. .php를 .png로 위장해 올린 파일이 서버에서 실행되면 그건 사고니까요. 파일 하나 받는 일도 결국 신뢰할 수 없는 입력을 다루는 일이라는 걸 잊지 마세요.