🔑 핵심 요약
✦ AI·GEN「Monaco는 링크만 받고 이미지를 직접은 못 붙인다」는 작은 요구에서 출발해, 이미지 처리의 구덩이를 한 줄 통째로 끄집어낸 기록. Base64가 왜 에디터를 버벅이게 하고 DB까지 부풀리는지, 붙여넣는 순간 NAS로 자동 업로드하는 설계, 그리고 가장 놓치기 쉬운 지뢰—Docker bind mount는 사실 「순간이동 문」이라, HDD가 마운트 안 돼 있으면 파일이 조용히 시스템 디스크로 떨어진다. 정적 이미지 월을 NAS 동적 API로 다시 쓰는 구상과, 포토월 리디자인 때의 「UI 먼저, 데이터는 나중에」 트레이드오프까지 담았다.
얼마 전부터 블로그 관리자 에디터로 Monaco Editor를 쓰기 시작했습니다. VS Code의 핵심, 코드를 쓰라고 태어난 에디터인데, 그걸 블로그 쓰는 데 가져다 쓰니 스스로도 좀 하드코어하다 싶죠. 다만 이 글의 주인공은 Monaco 자체가 아니라, 「그 안에 이미지 한 장을 넣는」다는 시시해 보이는 행위입니다. 그 작은 요구 하나가 결국 Base64, 백그라운드 업로드, NAS 마운트, 이미지 월 재작성까지—구덩이를 한 줄 통째로 끄집어냈습니다.
Monaco는 이미지를 안 먹고, 링크만 먹는다#
Monaco는 WYSIWYG 에디터와 완전히 다른 세계에 삽니다. Word 같은 에디터처럼 이미지를 끌어 놓는다고 바로 보이지 않아요—그 세계에서 이미지는 그저 한 줄의 Markdown 링크입니다. 즉 이미지를 넣으려면, 먼저 이미지를 「쓸 수 있는 URL」로 바꿔야 하죠. 그때 떠오른 길은 둘뿐이었습니다: 이미지를 로 곧장 임베드하거나, 어딘가에 업로드해 URL을 만들거나.
첫 직감은 Base64였습니다. 이미지가 그대로 긴 문자열이 되어 Markdown에 박히고, 텍스트와 이미지가 한 몸이 된다. 글을 이리저리 옮겨도 이미지가 안 빠진다—편해 보이죠:
그런데 실제로 해 보니 깊은 구덩이였습니다. 평범한 이미지도 Base64로 바꾸면 문자열이 수십만 글자로 부풀고, Monaco는 이런 초장문 단일 행 텍스트를 만나는 순간 렌더링이 버벅여 인생을 의심하게 됩니다. 게다가 이 거대한 텍스트를 그대로 데이터베이스에 쑤셔 넣으면 쿼리 성능은 떨어지고 용량은 폭증하죠. 이 길은 명백히 막다른 길입니다.
그래서 업계 표준으로 돌아옵니다: 서버에 업로드해 URL을 만들고 Markdown으로 참조하기. 글 텍스트는 가볍게, DB엔 텍스트만, 이미지는 파일 시스템·NAS·CDN이 제공—로딩도 빠르고 캐시 걸기도 좋습니다:
TIP
Base64는 「가장 유혹적인 한 줄짜리 해법」입니다—텍스트와 이미지가 한 몸, 의존성 제로, 복붙 즉시 사용. 하지만 비용을 안 보이는 곳에 숨깁니다: 에디터의 렌더링 성능, DB의 용량과 쿼리. 많은 「간단한」 해법은 그저 청구서를 미뤄 다른 데 적어 둘 뿐이죠.
「업로드」라는 행위를, 글쓰기 경험에서 지우기#
방향은 정해졌습니다. 그런데 손으로 업로드하고, 링크 복사하고, 에디터에 붙여 넣는 이 흐름은 글쓰기의 연속성을 너무 깹니다. 이상은: 붙여 넣든 끌어 놓든, 이미지가 알아서 업로드되고 링크가 생성돼 커서 위치에 꽂히며, 그 과정이 「투명」해지는 것. 로직 자체는 꽤 직관적이고 네 단계로 나뉩니다:
- 이벤트 감시: Monaco의 를 건다.
- 이미지 가로채기: 클립보드나 드래그 내용에 가 있는지 확인—순수 텍스트면 통과, 이미지면 기본 동작을 가로챈다.
- 백그라운드 업로드: 그 이미지를 백엔드
POST /api/upload로 넘기면, 백엔드가 파일을 쓰고 URL을 돌려준다. - 커서에 삽입: 프런트가 URL을 받아 로 커서 위치에
을 삽입한다.
코드로 쓰면 대략 이렇습니다(당시의 설계 초안):
// 붙여넣을 때 이미지를 가로채, 뒤에서 업로드, URL을 받아 커서에 삽입
editor.onDidPaste(async () => {
const file = getImageFromClipboard(); // 클립보드에 File Object가 있나?
if (!file) return; // 순수 텍스트면 통과
const url = await uploadToNAS(file); // POST /api/upload → 이미지 URL 반환
editor.executeEdits('paste-image', [{ // 커서 위치에 markdown 삽입
range: editor.getSelection(),
text: ``,
}]);
});이 업로드 라인은 나중에 진짜로 만들었습니다: 백엔드가 이미지를 받으면 자동으로 압축하고 WebP로 변환해 NAS에 저장하고, /uploads/2026/02/<타임스탬프>-<난수>.webp 형태의 URL을 돌려줍니다—날짜별 폴더, 타임스탬프 파일명. 이후로 스크린샷 한 장 붙이는 건 거의 즉시고, 글쓰기 흐름은 한 번도 끊기지 않습니다.
그런데 파일이 몰래 시스템 디스크에 남지 않나?#
업로드는 해결됐는데, 여기서 잠깐 멈춰 아주 자연스러운 의문이 떠올랐습니다: 내 server는 시스템 디스크 위에서 도는데, 결국 NAS에 저장한다 해도 이미지가 Node.js 백엔드를 지날 때 web 쪽(시스템 드라이브 SSD)에 한 부 남는 거 아닌가?
답은 「아니다」이고, 열쇠는 입니다. NAS 디스크에 전용 디렉터리를 만들고, 그걸 컨테이너에 마운트합니다:
services:
blog-backend:
volumes:
# 왼쪽은 진짜 HDD 경로, 오른쪽은 컨테이너 안 경로
- /mnt/hdd16tb_01/blog_data/images:/app/public/uploads이러면 Node.js는 이미지를 /app/public/uploads에 쓰기만 하고, 파일은 이 매핑을 관통해 곧장 NAS 섹터에 떨어집니다. 는 본질적으로 「순간이동 문」입니다: /mnt/hdd16tb_01 아래에 쓴 것은 시스템 디스크를 1KB도 소비하지 않아요—시스템 드라이브는 전 과정에서 그저 「지나가는 통로」일 뿐입니다. (반대로 Docker가 named volume을 만들게 하면 파일은 /var/lib/docker/volumes/ 밑에 눌러앉아, 그게 시스템 디스크를 잡아먹습니다.)
다만 이 순간이동 문에는 딱 하나의 실패 모드가 있습니다: 그 HDD가 사실 마운트에 성공하지 않았다면, Linux는 /mnt/hdd16tb_01을 그냥 빈 폴더로 취급하고, 거기 쓴 파일은 조용히 시스템 디스크로 떨어집니다—에러도 없고, 알아채지도 못해요, 어느 날 시스템 디스크가 영문 없이 꽉 찰 때까지. 그래서 매번 먼저 df -h로 마운트 상태를 확인합니다:
파일 시스템 크기 사용 가용 사용% 마운트 위치
/dev/nvme0n1p2 1.9T 255G 1.5T 15% /
/dev/sdb1 15T 28K 15T 1% /mnt/hdd16tb_02
/dev/sda1 15T 22G 15T 1% /mnt/hdd16tb_01/mnt/hdd16tb_01, /mnt/hdd16tb_02가 그 두 개의 15T HDD에 대응한다(/로 접히지 않았다)는 걸 보고서야 마음 놓고 이미지를 써넣습니다. 마지막으로 nginx가 /uploads/로 시작하는 요청을 디스크 위 이미지로 바로 대응시킵니다(이건 당시의 구상):
location /uploads/ {
alias /mnt/hdd16tb_01/blog_data/images/;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}WARNING
「파일을 NAS에 저장한다」에서 가장 음험한 실패는 에러가 아니라 에러가 안 나는 것입니다. 마운트가 빠졌거나 경로를 틀렸어도, 시스템은 얌전히 파일을 시스템 디스크에 쓰고, 다 정상으로 보이다가, 시스템 디스크가 터지는 그 순간에야 드러납니다. 마운트 포인트에 기대는 저장이라면, df -h로 마운트를 확인하는 습관을 근육 기억으로 만들 가치가 있습니다.
폰으로 찍은 사진, 어떻게 인용하나?#
스크린샷 업로드는 정리됐는데, 또 다른 상황이 있습니다: 밖에서 폰으로 사진을 찍어 NAS에 보낸 뒤, 그걸 글에서 어떻게 인용할까? 여기서 저는 두 방법을 저울질했습니다:
방법 1, nginx 정적 프록시. NAS의 어떤 사진 폴더를 nginx로 공개 URL로 노출해, 던져 넣은 이미지를 전부 https://koimsurai.com/nas-images/파일명.jpg로 접근하게 하기. 장점은 개발이 거의 제로—사진이 NAS에 들어가는 순간 참조 가능; 단점도 뻔한데, 파일명을 직접 외우거나 NAS에서 복사해야 하고, 사진이 많아지면 찾기가 괴로워 글쓰기 경험이 끊깁니다.
방법 2, 갤러리 피커(당시 만들고 싶던 상위 버전). NAS가 GET /api/photos를 제공해, 어떤 폴더 아래 모든 이미지의 파일명과 URL JSON을 돌려주고; Monaco 편집 영역 위에 「NAS에서 이미지 삽입」 버튼을 두어, 누르면 썸네일 격자 Modal이 뜨고, 하나 클릭하면 이 커서 위치에 자동 삽입되게 하기. 이제 손으로 URL 복사하지 않고, 시각적으로 고릅니다.
도구는 단순하지만 경험은 딴판. 방법 1은 임시방편에 맞고, 방법 2가 제 머릿속 최종 형태—다만 그 시점엔 아직 「구상」 칸에 머물러 있었습니다.
기존 이미지 월, 갈아엎어야 하나?#
이 줄기를 따라, 제 기존 이미지 월을 봤습니다. 당시엔 아주 전형적인 「정적 빌드」형이었죠: 프런트가 manifestLoader.ts로 하드코딩된 /photos-manifest.json을 읽어, 각 이미지의 경로·썸네일·그리고 플레이스홀더를 나열; 만에 하나 manifest조차 못 찾으면 Vite의 import.meta.glob으로 강등해, 프런트 프로젝트 assets/Portfolio/에 하드코딩된 이미지를 억지로 읽었습니다. image-processor.ts도 써 뒀는데, 자동으로 압축·변환하고 Blurhash용 thumbHash를 계산하지만, 출력 경로가 /generated/ 같은 웹 정적 폴더에 하드코딩돼 있었죠.
가장 큰 아픔은: 사진 한 장 추가하는 절차가 지나치게 번거롭다는 것. 이미지를 한 장 늘릴 때마다 파일을 프로젝트 정적 디렉터리에 넣고, 스크립트를 돌려 photos-manifest.json을 갱신하고, 그리고 사이트 전체를 재배포해야 했습니다. 고양이 사진 한 장 더 놓는 데도요. 사진 작업 자체도 용량을 먹어, 1.9T 시스템 디스크에 욱여넣는 것도 무리였고요.
그래서 다시 쓰는 설계를 그렸습니다(역시 당시의 구상):
image-processor.ts를 NAS 백그라운드 서비스로—사진이 NAS에 올라오는 순간, 뒤에서 고화질 이미지·썸네일·thumbHash를 자동 생성. 다만 출력 경로를 nginx 프록시 URL로 바꾼다.GET /api/gallery/photos하나 세우기—원래PhotosManifestData와 완전히 같은 구조의 JSON 배열을 반환.- 프런트는 무통 전환—
PhotoGallery.tsx는 데이터 소스 한 줄만 바꾸면 됩니다:
const response = await fetch('/photos-manifest.json');
const response = await fetch('https://koimsurai.com/api/gallery/photos');반환 JSON 구조가 동일하니, 프런트의 , Blurhash 플레이스홀더, hover 인터랙션 컴포넌트는 한 줄도 안 바꿔도 됩니다—데이터 소스가 「Web 프로젝트 로컬」에서 「NAS 동적 API」로 옮겨질 뿐. 갈아엎으면 사진 추가에 재배포가 필요 없어, NAS에 던져 넣으면 알아서 나타납니다.
UI 먼저, 데이터는 나중에#
이미지 월의 비주얼은 오히려 먼저 다시 만들었습니다. 구버전은 카드 간격이 너무 넓고, 「사진 작품 모음」 큰 제목 카드를 한 장 눌러 놔서, 비주얼을 뽐내는 갤러리라기보다 「폴더 관리 화면」처럼 보였죠. Innei의 Afilmory를 참고해, 핵심은 사진을 유일한 주인공으로 만드는 것: 큰 사각형을 버리고 등고 자적응 그리드로, 제목은 가운데로 모아 조이고, hover 때 옆 사진을 어둡게 가라앉혀 지금 이 한 장만 떠오르게. 다시 만드니 이렇습니다:
그리고, 예쁜 월을 다 만들고 나서야 한 가지가 떠올랐습니다: 내 사진엔 애초에 tag가 안 붙어 있다. 위에 늘어선 「전체 / 고양이 / 풍경」 필터 버튼은 아무것도 못 거릅니다.
한 줄이, 벽 하나를 끄집어낸다#
돌아보면 이 여정은 「Monaco는 링크만 먹는다」에서 시작해, Base64의 함정, 순간이동 문과 df -h, 그리고 이미지 월 재작성까지 끄집어냈습니다. 이미 돌아가고 있는 업로드 라인을 그림으로 그리면 대략 이렇습니다:
기술 세부 하나하나는 따로 보면 고립돼 보입니다: Monaco의 이벤트, Base64 인코딩, 한 줄의 bind mount, 하나의 df -h. 하지만 그것들은 다 같은 하나에 봉사합니다—「이미지 한 장 놓기」라는 동작을, 쓰는 사람에게 자연스럽게, 나아가 무감각하게 만드는 것. 그리고 이 여정에서 가장 재미있던 건 가장 소박한 교훈이었습니다: 얼핏 난해해 보이는 문제(왜 파일이 시스템 디스크로 갔지?)의 답은, 대개 「먼저 그게 실제로 어떻게 도는지 제대로 이해하기」의 다음 한 걸음에 숨어 있다는 것. 마운트 포인트는 순간이동 문—그게 정말 연결됐는지 먼저 확인하기만 하면 됩니다.
아직 댓글이 없어요
✨ 첫 댓글을 남겨보세요