글만 쓰던 에디터에 이미지 한 장을 넣게 만드는 순간, 갑자기 할 일이 확 늘어나요. 파일을 고르게 하고, 화면에 미리 보여주고, 서버에 올리고, 올라간 주소로 바꿔 끼우고요. 저도 이거 처음 붙일 때 base64로 대충 때웠다가 본문이 수백 KB로 부풀어서 저장이 막힌 적이 있어요. 오늘은 에디터에 이미지를 제대로 넣는 흐름을, 파일 선택부터 서버 업로드와 자리표시(placeholder)까지 상황별로 하나씩 풀어볼게요.

19.1 에디터에 이미지는 어떻게 넣죠?

가장 기본은 파일 선택 창을 띄우는 거예요. 툴바의 이미지 버튼을 누르면 숨겨둔 input type="file"을 대신 눌러줘요. 사용자가 사진을 고르면 그 파일을 미리보기로 먼저 보여주고요. 파일 내용을 화면에 띄우려면 FileReader(파일 읽기 도구)를 써요.


const file = input.files[0];
const reader = new FileReader();
reader.onload = (e) => {
insertImage(e.target.result);
};
reader.readAsDataURL(file);


readAsDataURL은 파일을 data URL(파일 내용을 글자로 바꾼 긴 문자열)로 읽어줘요. 읽기가 끝나면 onload가 불리고, e.target.result에 그 문자열이 담겨 있죠. 이걸 img 태그의 src에 넣으면 서버에 올리기도 전에 사진이 화면에 뜨는 거예요. 여기까지만 하면 미리보기는 완성이에요.

19.2 근데 이 data URL을 그대로 저장하면 안 되나요?

여기서 딱 멈추고 싶은 유혹이 커요. 이미 화면에 잘 뜨니까요. 그런데 이 긴 문자열을 본문에 그대로 저장하면 문제가 터져요. data URL은 사진 한 장이 글자 수만 개에서 수십만 개가 돼요. 사진 세 장만 넣어도 본문이 메가바이트 단위로 부풀죠.


그러면 저장 요청이 용량 한도에 걸려 실패하거나, 목록에서 글 하나 불러오는 데도 한참 걸려요. 저는 이걸 몰라서 저장 버튼만 누르면 에러가 나던 시절이 있었어요. 그래서 미리보기는 data URL로 잠깐 보여주되, 진짜 저장은 파일을 서버에 올리고 그 주소만 본문에 남기는 게 정석이에요. 본문에는 결국 이런 짧은 태그만 들어가야 해요.


<img src="https://cdn.example.com/img/a1b2.webp">

19.3 파일을 서버로는 어떻게 올려요?

파일을 서버로 보낼 땐 FormData(폼 데이터 묶음)에 담아요. JSON으로는 파일 같은 바이너리(그림, 영상 같은 날데이터)를 곱게 못 실어요. FormData는 파일을 통째로 담아 보낼 수 있게 만들어진 그릇이에요.


const fd = new FormData();
fd.append("image", file);
const res = await fetch("/api/upload", {
method: "POST",
body: fd,
});
const { url } = await res.json();


fd.append("image", file)로 파일을 묶고, fetch에 그대로 실어 보내요. 여기서 주의할 점 하나. FormData를 보낼 때는 Content-Type 헤더를 직접 적지 마세요. 브라우저가 파일 경계 표시까지 알아서 붙여주는데, 우리가 손대면 그게 깨져서 서버가 파일을 못 읽어요. 서버는 파일을 저장하고 접근 가능한 주소를 돌려주면 되고요. 우리는 그 url을 받아 쓰는 거예요.

19.4 업로드되는 동안 화면은 어떻게 하죠?

사진이 크면 업로드가 몇 초 걸려요. 그동안 화면이 멈춰 있으면 사용자는 먹통인 줄 알죠. 그래서 자리표시(placeholder)를 먼저 꽂아둬요. 흐릿한 미리보기나 로딩 표시를 임시로 넣어두고, 업로드가 끝나면 진짜 주소로 바꿔치기하는 방식이에요.


const img = insertImage(previewUrl);
img.classList.add("uploading");
const { url } = await uploadFile(file);
img.src = url;
img.classList.remove("uploading");


먼저 미리보기 주소로 img를 하나 넣고 uploading 표시를 붙여요. CSS로 이 상태일 때 반투명하게 만들면 "올리는 중"이 눈에 보이죠. 업로드가 끝나 진짜 url이 오면 img.src만 갈아 끼워요. 태그를 지웠다 다시 넣는 게 아니라 주소만 교체하니 커서 위치도 안 튀고 깔끔해요. 만약 업로드가 실패하면 그 자리표시 이미지를 지우고 "올리기 실패"를 알려줘야 해요. 안 그러면 깨진 사진이 본문에 남거든요.

19.5 드래그나 붙여넣기로도 넣고 싶어요

요즘 사용자는 파일 선택 창보다 드래그해서 툭 떨어뜨리거나 복사해서 붙여넣기를 더 자주 해요. 둘 다 잡아줄 수 있어요. 드롭은 drop 이벤트에서 파일을 꺼내요.


editor.addEventListener("drop", (e) => {
e.preventDefault();
const files = e.dataTransfer.files;
for (const file of files) handleImage(file);
});


e.dataTransfer.files에 끌어다 놓은 파일들이 들어 있어요. e.preventDefault()를 안 하면 브라우저가 그 사진으로 페이지를 통째로 이동해버리니 꼭 막아야 해요. 붙여넣기는 paste 이벤트에서 잡아요.


editor.addEventListener("paste", (e) => {
for (const item of e.clipboardData.items) {
if (item.type.startsWith("image/")) {
handleImage(item.getAsFile());
}
}
});


붙여넣은 게 이미지면 clipboardData.items 안에 그림 조각이 들어 있어요. type이 image로 시작하는 걸 골라 getAsFile()로 파일을 꺼내면, 파일 선택으로 고른 것과 똑같이 업로드 흐름에 태울 수 있어요. 통로만 셋일 뿐 뒤 처리는 하나로 합치는 게 핵심이에요.

19.6 올리기 전에 뭘 검사해야 하나요?

아무 파일이나 다 받으면 사고가 나요. 사용자가 실수로 100MB짜리 영상을 떨어뜨릴 수도, 이미지가 아닌 파일을 밀어 넣을 수도 있죠. 그래서 업로드 전에 타입과 용량을 걸러요.


if (!file.type.startsWith("image/")) {
alert("이미지만 올릴 수 있어요");
return;
}
if (file.size > 10 * 1024 * 1024) {
alert("10MB 이하만 가능해요");
return;
}


file.type으로 이미지인지, file.size로 용량이 넘치지 않는지 보는 거예요. 단, 이 화면 검사만 믿으면 안 돼요. 화면 검사는 사용자 편의용 1차 안내일 뿐, 마음먹으면 서버로 직접 요청을 쏘아 우회할 수 있거든요. 그래서 서버에서도 똑같이 타입과 용량을 다시 검사해야 진짜로 안전해요. 화면에서 한 번(친절하게), 서버에서 한 번(진짜로)이 원칙이에요.

19.7 오늘 정리

에디터 이미지는 미리보기와 저장을 분리하는 게 핵심이었어요. 화면엔 FileReader의 data URL로 잠깐 보여주되, 저장은 FormData로 서버에 올려 받은 짧은 주소만 본문에 남겼죠. data URL을 그대로 저장하면 본문이 폭증하니까요. 업로드가 오래 걸릴 땐 자리표시를 먼저 꽂고 끝나면 src만 교체했고, 실패하면 자리표시를 걷어냈어요. 파일은 선택 창뿐 아니라 droppaste로도 받되 뒤 처리는 하나로 모았고요. 마지막으로 타입과 용량은 화면과 서버 양쪽에서 걸러야 안전했어요. 이 흐름 하나면 웬만한 에디터 이미지는 다 붙일 수 있어요.