얼마 전부터 블로그 관리자 에디터로 Monaco Editor를 쓰기 시작했습니다. VS Code의 핵심, 코드를 쓰라고 태어난 에디터인데, 그걸 블로그 쓰는 데 가져다 쓰니 스스로도 좀 하드코어하다 싶죠. 다만 이 글의 주인공은 Monaco 자체가 아니라, 「그 안에 이미지 한 장을 넣는」다는 시시해 보이는 행위입니다. 그 작은 요구 하나가 결국 Base64, 백그라운드 업로드, NAS 마운트, 이미지 월 재작성까지—구덩이를 한 줄 통째로 끄집어냈습니다.

Monaco는 이미지를 안 먹고, 링크만 먹는다#

Monaco는 WYSIWYG 에디터와 완전히 다른 세계에 삽니다. Word 같은 에디터처럼 이미지를 끌어 놓는다고 바로 보이지 않아요—그 세계에서 이미지는 그저 한 줄의 Markdown 링크입니다. 즉 이미지를 넣으려면, 먼저 이미지를 「쓸 수 있는 URL」로 바꿔야 하죠. 그때 떠오른 길은 둘뿐이었습니다: 이미지를 로 곧장 임베드하거나, 어딘가에 업로드해 URL을 만들거나.

첫 직감은 Base64였습니다. 이미지가 그대로 긴 문자열이 되어 Markdown에 박히고, 텍스트와 이미지가 한 몸이 된다. 글을 이리저리 옮겨도 이미지가 안 빠진다—편해 보이죠:

markdown
![설명](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA... 뒤에 수십만 글자 더)

그런데 실제로 해 보니 깊은 구덩이였습니다. 평범한 이미지도 Base64로 바꾸면 문자열이 수십만 글자로 부풀고, Monaco는 이런 초장문 단일 행 텍스트를 만나는 순간 렌더링이 버벅여 인생을 의심하게 됩니다. 게다가 이 거대한 텍스트를 그대로 데이터베이스에 쑤셔 넣으면 쿼리 성능은 떨어지고 용량은 폭증하죠. 이 길은 명백히 막다른 길입니다.

그래서 업계 표준으로 돌아옵니다: 서버에 업로드해 URL을 만들고 Markdown으로 참조하기. 글 텍스트는 가볍게, DB엔 텍스트만, 이미지는 파일 시스템·NAS·CDN이 제공—로딩도 빠르고 캐시 걸기도 좋습니다:

markdown
![설명](https://koimsurai.com/uploads/xxx.webp)

TIP

Base64는 「가장 유혹적인 한 줄짜리 해법」입니다—텍스트와 이미지가 한 몸, 의존성 제로, 복붙 즉시 사용. 하지만 비용을 안 보이는 곳에 숨깁니다: 에디터의 렌더링 성능, DB의 용량과 쿼리. 많은 「간단한」 해법은 그저 청구서를 미뤄 다른 데 적어 둘 뿐이죠.

「업로드」라는 행위를, 글쓰기 경험에서 지우기#

방향은 정해졌습니다. 그런데 손으로 업로드하고, 링크 복사하고, 에디터에 붙여 넣는 이 흐름은 글쓰기의 연속성을 너무 깹니다. 이상은: 붙여 넣든 끌어 놓든, 이미지가 알아서 업로드되고 링크가 생성돼 커서 위치에 꽂히며, 그 과정이 「투명」해지는 것. 로직 자체는 꽤 직관적이고 네 단계로 나뉩니다:

  • 이벤트 감시: Monaco의 를 건다.
  • 이미지 가로채기: 클립보드나 드래그 내용에 가 있는지 확인—순수 텍스트면 통과, 이미지면 기본 동작을 가로챈다.
  • 백그라운드 업로드: 그 이미지를 백엔드 POST /api/upload로 넘기면, 백엔드가 파일을 쓰고 URL을 돌려준다.
  • 커서에 삽입: 프런트가 URL을 받아 로 커서 위치에 ![alt](url)을 삽입한다.

코드로 쓰면 대략 이렇습니다(당시의 설계 초안):

ts
// 붙여넣을 때 이미지를 가로채, 뒤에서 업로드, 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: `![](${url})`,
  }]);
});

이 업로드 라인은 나중에 진짜로 만들었습니다: 백엔드가 이미지를 받으면 자동으로 압축하고 WebP로 변환해 NAS에 저장하고, /uploads/2026/02/<타임스탬프>-<난수>.webp 형태의 URL을 돌려줍니다—날짜별 폴더, 타임스탬프 파일명. 이후로 스크린샷 한 장 붙이는 건 거의 즉시고, 글쓰기 흐름은 한 번도 끊기지 않습니다.

그런데 파일이 몰래 시스템 디스크에 남지 않나?#

업로드는 해결됐는데, 여기서 잠깐 멈춰 아주 자연스러운 의문이 떠올랐습니다: 내 server는 시스템 디스크 위에서 도는데, 결국 NAS에 저장한다 해도 이미지가 Node.js 백엔드를 지날 때 web 쪽(시스템 드라이브 SSD)에 한 부 남는 거 아닌가?

답은 「아니다」이고, 열쇠는 입니다. NAS 디스크에 전용 디렉터리를 만들고, 그걸 컨테이너에 마운트합니다:

yaml
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로 마운트 상태를 확인합니다:

text
파일 시스템      크기  사용  가용 사용% 마운트 위치
/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/로 시작하는 요청을 디스크 위 이미지로 바로 대응시킵니다(이건 당시의 구상):

nginx
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)이 커서 위치에 자동 삽입되게 하기. 이제 손으로 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 시스템 디스크에 욱여넣는 것도 무리였고요.

그래서 다시 쓰는 설계를 그렸습니다(역시 당시의 구상):

  1. image-processor.ts를 NAS 백그라운드 서비스로—사진이 NAS에 올라오는 순간, 뒤에서 고화질 이미지·썸네일·thumbHash를 자동 생성. 다만 출력 경로를 nginx 프록시 URL로 바꾼다.
  2. GET /api/gallery/photos 하나 세우기—원래 PhotosManifestData완전히 같은 구조의 JSON 배열을 반환.
  3. 프런트는 무통 전환PhotoGallery.tsx는 데이터 소스 한 줄만 바꾸면 됩니다:
프런트에서 바꾸는 건 이 한 줄뿐+11
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. 하지만 그것들은 다 같은 하나에 봉사합니다—「이미지 한 장 놓기」라는 동작을, 쓰는 사람에게 자연스럽게, 나아가 무감각하게 만드는 것. 그리고 이 여정에서 가장 재미있던 건 가장 소박한 교훈이었습니다: 얼핏 난해해 보이는 문제(왜 파일이 시스템 디스크로 갔지?)의 답은, 대개 「먼저 그게 실제로 어떻게 도는지 제대로 이해하기」의 다음 한 걸음에 숨어 있다는 것. 마운트 포인트는 순간이동 문—그게 정말 연결됐는지 먼저 확인하기만 하면 됩니다.