블로그 썸네일 만들기 사이트

썸네일 에디터는 블로그, 유튜브, SNS용 이미지를 브라우저에서 바로 만들 수 있게 만든 온라인 편집기다. 처음에는 단순 노동을 좀 더 편하고 효율적으로 할 수 없을까?로 시작했고,“이미지 올리고 문구 넣고 저장만 되게 하자” 라고 생각했지만, 실제로는 Canvas 렌더링, 한글 입력 끊김, 텍스트 위치 조절, 스타일 저장, 모바일 UX, AdSense, SEO, Cloudflare Pages 배포 오류까지 하나씩 해결해야 했다.

이 글은 바이브코딩으로 썸네일 에디터를 만들면서 실제로 겪었던 기능 구현, UI 개편, 오류 해결, 배포 삽질까지 잔수정이 아주 많았던… 내용을 기록해 보겠다!

1. 내가 바로 사용할 썸네일 도구를 만들자!

만들고 싶었던 의도는 아주 단순했다. 블로그 글을 쓸 때마다 썸네일과 첨부할 사진을 만들기 위해 포토샵이나 미리캔버스를 켜는 과정이 번거로웠고, 내가 자주 쓰는 썸네일 스타일과 워터마크 넣기 등을 웹에서 바로 뽑아내고 싶었다.

각 블로그마다 통일성 있는 컨셉을 유지하고,
최대한 효율적으로 + 빠르게 이미지 자료를 저장하는 게 목표!

초기 기획 기능

  • 이미지 업로드
  • 1080×1080 썸네일 제작
  • 1920×1080 이미지 제작
  • 윗줄·아랫줄 텍스트 입력
  • 텍스트 크기, 색상, 테두리 조절
  • 프레임 색상과 두께 조절
  • 그라데이션 배경 적용
  • 워터마크 표시
  • 이미지 출처 표시
  • PNG/JPG 다운로드

초기 프롬프트는 대략 이런 방향이었다.

Next.js로 온라인 썸네일 에디터를 만들어줘.

기능:
- 이미지 업로드
- Canvas에 이미지와 텍스트 합성
- 1080x1080 썸네일 저장
- 1920x1080 이미지 저장
- 텍스트 색상, 크기, 테두리 수정
- 프레임 색상과 두께 수정
- 워터마크 추가
- PNG/JPG 다운로드

디자인:
- 어두운 배경
- 초록색 브랜드 느낌
- 버튼은 깔끔하게
- 모바일에서도 사용 가능하게

처음에는 AI가 코드를 짜주면 금방 끝날 줄 알았다. 하지만 막상 만들고 보니 “기능이 있다”와 “쓸 만하다”는 완전히 달랐다.

처음엔 진짜 금방 끝날 줄 알았다. 근데 노멀한 썸네일 하나 나오게 하려고 글자 위치, 테두리, 프레임, 모바일 화면을 계속 만지다 보니 정말 손이 많이 가는 편집기 만드는 일이 되어버렸다.

2. 프로젝트 구조는 Next.js + Canvas로 잡았다

프로젝트는 Next.js 기반으로 만들었다. 화면은 app/page.tsx에서 처리하고, 전역 레이아웃과 메타데이터는 app/layout.tsx, 전체 디자인은 app/globals.css에서 관리했다.

파일역할
app/page.tsx썸네일 에디터의 핵심 기능, Canvas 렌더링, 입력 UI, 다운로드 처리
app/layout.tsx메타데이터, 검색엔진 인증 태그, AdSense 스크립트 삽입
app/globals.css전체 레이아웃, 좌측 패널, 미리보기 영역, 모바일 반응형 디자인
global.d.tswindow.adsbygoogle 타입 선언
public/fontsCanvas에 사용할 폰트 파일 저장
public로고, favicon, 정적 파일 저장

핵심은 Canvas였다. 사용자가 업로드한 이미지를 Canvas에 그리고, 그 위에 프레임, 그라데이션, 텍스트, 워터마크를 순서대로 합성한 뒤 이미지로 저장하는 구조다.

const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");

ctx.clearRect(0, 0, spec.w, spec.h);

// 1. 프레임 배경
// 2. 업로드 이미지
// 3. 그라데이션
// 4. 워터마크
// 5. 텍스트
// 6. 출처

이 순서가 중요했다. 텍스트를 먼저 그리면 이미지에 덮이고, 그라데이션을 나중에 너무 진하게 덮으면 글씨가 뭉개졌다. 썸네일 결과물은 결국 레이어 순서 싸움이었다.

3. 이미지 업로드와 cover 배치부터 구현했다

이미지를 업로드하면 FileReader로 브라우저에서 읽고, HTMLImageElement로 만든 뒤 Canvas에 그렸다. 서버로 이미지를 보내지 않고 브라우저 안에서만 처리하는 방식이라 개인정보 측면에서도 부담이 적었다.

function onUpload(file: File | null) {
  if (!file) return;

  const reader = new FileReader();

  reader.onload = () => {
    const src = String(reader.result || "");
    setUploadedSrc(src);

    const img = new window.Image();

    img.onload = () => {
      imgRef.current = img;
      setPanX(0);
      setPanY(0);
      setZoom(1.0);
      setIsImageReady(true);
    };

    img.src = src;
  };

  reader.readAsDataURL(file);
}

문제는 이미지를 그냥 그리면 비율이 깨진다는 점이었다. 그래서 CSS의 object-fit: cover처럼 Canvas에서도 cover 방식으로 이미지를 잘라 넣는 함수를 만들었다.

function drawImageCover(
  ctx: CanvasRenderingContext2D,
  img: HTMLImageElement,
  dx: number,
  dy: number,
  dWidth: number,
  dHeight: number,
  panX: number,
  panY: number,
  zoom: number
) {
  const iw = img.naturalWidth;
  const ih = img.naturalHeight;
  if (!iw || !ih) return;

  const scale = Math.max(dWidth / iw, dHeight / ih) * zoom;
  const sw = dWidth / scale;
  const sh = dHeight / scale;

  const maxSX = Math.max(0, iw - sw);
  const maxSY = Math.max(0, ih - sh);

  const sx = clamp(maxSX / 2 + panX * (maxSX / 2), 0, maxSX);
  const sy = clamp(maxSY / 2 + panY * (maxSY / 2), 0, maxSY);

  ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dWidth, dHeight);
}

이 함수 덕분에 사용자는 이미지 좌우 이동, 상하 이동, 확대 조절을 할 수 있게 됐다. 썸네일에서 얼굴이나 핵심 오브젝트를 중앙에 맞추는 데 꼭 필요한 기능이었다.

현존하는 에디터들에 비해서는 투박하지만, 내가 블로그나 인스타 등등 sns 썸네일로 사용하기엔 충분한 기능이다.

4. 텍스트 입력에서 한글 조합 문제가 생겼다

텍스트 입력 기능을 만들면서 생각보다 큰 문제가 있었다. 한글을 입력할 때 글자가 조합되는 중간 단계마다 Canvas가 다시 렌더링되면서 입력이 끊기는 느낌이 났다.

예를 들어 “전세집”을 입력하는 중에 ㄱ, 저, 전, 전ㅅ 같은 중간 상태가 계속 Canvas에 반영되면서 사용감이 좋지 않았다. 그래서 실제 input 값과 Canvas에 그릴 값을 분리했다.

const isComposingRef = useRef(false);

const [line1, setLine1] = useState("전세집 어디");
const [renderLine1, setRenderLine1] = useState(line1);

입력 중에는 line1만 바꾸고, 조합이 끝난 뒤에 renderLine1을 업데이트하는 방식으로 수정했다.

<input
  value={line1}
  onCompositionStart={() => {
    isComposingRef.current = true;
  }}
  onCompositionEnd={(e) => {
    isComposingRef.current = false;
    setRenderLine1(e.currentTarget.value);
  }}
  onChange={(e) => {
    const v = e.target.value;
    setLine1(v);

    if (!isComposingRef.current) {
      setRenderLine1(v);
    }
  }}
/>

이렇게 바꾸니 한글 입력이 훨씬 자연스러워졌다. 썸네일 에디터처럼 텍스트 입력이 핵심인 도구에서는 이런 작은 입력감 차이가 꽤 크게 느껴졌다. 버벅일 땐 정말 사이트가 무겁게 느껴졌다.

진짜 별거 아닌 줄 알았는데 한글 입력이 버벅이면 바로 서비스가 허접해 보인다. 바로 사이트 이탈 각ㅋ 한국어 서비스 만들 때 IME 조합 처리는 생각보다 중요했다.

5. 텍스트 스타일 조절 기능을 계속 추가했다

처음에는 텍스트 크기와 색상만 바꾸면 충분할 줄 알았다. 그런데 썸네일은 글자 테두리, 줄 간격, 위치, 정렬, 폰트 굵기에 따라 완전히 다른 이미지가 된다.

그래서 텍스트 관련 옵션을 계속 늘렸다.

  • 윗줄 글자 크기
  • 아랫줄 글자 크기
  • 채움 색상
  • 테두리 색상
  • 테두리 두께
  • 폰트 굵기
  • 윗줄/아랫줄 간격
  • 좌측, 중앙, 우측 정렬
  • 가로 위치
  • 상하 위치
  • 윗줄/아랫줄 개별 표시 여부

텍스트를 그리는 함수는 따로 분리했다.

function drawTextAt(
  ctx: CanvasRenderingContext2D,
  x: number,
  ts: TextStyle,
  fontFamily: string,
  align: "left" | "center" | "right"
) {
  ctx.save();

  ctx.textAlign = align;
  ctx.textBaseline = "alphabetic";
  ctx.lineJoin = "round";
  ctx.miterLimit = 2;

  ctx.font = `${ts.fontWeight} ${ts.size}px ${fontFamily}, system-ui, -apple-system`;
  ctx.strokeStyle = ts.stroke;
  ctx.lineWidth = ts.strokeWidth;

  ctx.strokeText(ts.text, x, ts.y);

  ctx.fillStyle = ts.fill;
  ctx.fillText(ts.text, x, ts.y);

  ctx.restore();
}

테두리를 먼저 그리고 채움 글자를 나중에 그리는 구조다. 이렇게 해야 썸네일에서 많이 쓰는 굵은 외곽선 텍스트 느낌이 나온다.

6. 폰트도 여러 개 추가했다

처음에는 Paperlogy 하나만 사용했다. 하지만 썸네일 스타일은 폰트에 따라 분위기가 많이 달라지기 때문에 여러 폰트를 추가했다. 그리고 좀 더 다양한 옵션을 제공하되, 아주 노멀하게 사용하게끔 세팅했다. 그냥 어느 콘텐츠에 올려도 괜찮은 정도로.

  • Paperlogy
  • Pretendard
  • GmarketSans
  • Chosun Centennial
  • BM 도현
  • BM 을지로
  • BM 한나
  • BM 주아
  • BM 기랑해랑
  • BM 연성

Canvas에서 로컬 폰트를 쓰려면 FontFace로 직접 로드해야 했다.

async function loadCanvasFont(
  family: string,
  url: string,
  weight: number
) {
  const font = new FontFace(
    family,
    `url(${url})`,
    { weight: String(weight) }
  );

  await font.load();
  document.fonts.add(font);
}

폰트를 public/fonts 폴더에 넣고, 앱이 시작될 때 필요한 폰트를 로드했다.

useEffect(() => {
  loadCanvasFont("Paperlogy", "/fonts/Paperlogy-9Black.ttf", 900);
  loadCanvasFont("Pretendard", "/fonts/Pretendard-Bold.otf", 700);
  loadCanvasFont("GmarketSans", "/fonts/GmarketSansTTFBold.ttf", 700);
  loadCanvasFont("BMDOHYEON", "/fonts/BMDOHYEON_ttf.ttf", 900);
}, []);

이 부분은 단순히 CSS 폰트 적용과 달랐다. 화면에 보이는 폰트가 아니라 Canvas에 직접 그려지는 폰트이기 때문에, 폰트 로딩이 끝나지 않으면 다운로드 이미지에서 기본 폰트로 나올 수 있었다.

7. 스타일 프리셋 저장 기능에서 구조를 바꿨다

스타일 옵션 1, 스타일 옵션 2를 만들고 각각 다른 디자인을 저장할 수 있게 하고 싶었다. 처음에는 localStorage에 하나의 키로 저장했다.

const LS_KEY = "thumbmaker_style_v1";

문제는 스타일 옵션 1을 저장한 뒤 스타일 옵션 2를 저장하면, 기존 값이 덮어써진다는 점이었다. 즉, 스타일별 저장이 아니라 전체 저장 하나만 존재하는 구조였다.

그래서 저장 키를 스타일 옵션별로 분리했다.

const LS_KEY_BASE = "thumbmaker_style_v1";
const styleKey = (presetKey: StylePreset) => `${LS_KEY_BASE}:${presetKey}`;

저장할 때는 현재 선택된 스타일 옵션 이름을 붙여 저장했다.

function saveStyleToLocal() {
  try {
    localStorage.setItem(
      styleKey(stylePreset),
      JSON.stringify(getStyleSnapshot())
    );

    alert(`✅ ${stylePreset} 스타일을 저장했어요!`);
  } catch {
    alert("저장에 실패했어요. 브라우저 저장공간이나 권한을 확인해 주세요.");
  }
}

불러올 때도 현재 스타일 옵션에 해당하는 키를 읽게 했다.

function loadStyleFromLocal(presetKey?: StylePreset) {
  const key = presetKey ?? stylePreset;

  const raw = localStorage.getItem(styleKey(key));
  if (!raw) return false;

  const s = JSON.parse(raw);

  setT1Size(s.t1Size);
  setT2Size(s.t2Size);
  setFrameColor(s.frameColor);
  setFrameThickness(s.frameThickness);

  return true;
}

이렇게 바꾼 뒤 스타일 옵션 1과 옵션 2를 각각 따로 저장하고 불러올 수 있게 됐다.

이거 진짜 헷갈렸다. 분명 저장했다고 했는데 스타일2 누르면 사라지고, 다시 스타일1 누르면 또 안 맞고… 알고 보니 저장소를 하나만 쓰고 있었던 것. 결국 키를 나눠야 했다.

아직 리스크를 감당할 깜량이 안 돼서일까.. 유저의 정보를 받는 도전은 최대한으로 미루고 싶었다. 그래서 로컬 저장으로 진행했고, 대신 다계정 사용을 위해서 다른 컴퓨터에서 열지 않는 이상 본인이 사용하는 컴퓨터에서는 세팅해둔 스타일을 저장하고 꺼내올 수 있도록!

8. AdSense를 넣으면서 TypeScript 경고가 생겼다

서버비를 위해 애드센스 광고도 넣었다. layout.tsx에는 애드센스 스크립트를 넣고, page.tsx에는 수동 광고 영역을 넣었다.

<Script
  async
  src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-xxxxxxxxxxxxxxxx"
  crossOrigin="anonymous"
  strategy="afterInteractive"
/>

문제는 page.tsx에서 다음 코드를 넣었을 때 TypeScript 경고가 생긴 것이다.

(window.adsbygoogle = window.adsbygoogle || []).push({});

브라우저에서는 동작할 수 있지만, TypeScript 입장에서는 window에 adsbygoogle이라는 속성이 있다는 것을 알 수 없다. 그래서 global.d.ts 파일을 만들었다.

// global.d.ts
export {};

declare global {
  interface Window {
    adsbygoogle?: unknown[];
  }
}

처음에는 any[]로 선언했더니 ESLint에서 any 경고가 떴다. 그래서 unknown[]로 바꿔 경고를 줄였다.

또 기존 코드에 // @ts-ignore를 넣었더니 이 주석 자체에도 경고가 생겼다. 결국 타입 선언을 제대로 추가하는 방식이 가장 깔끔했다.

9. 광고 클릭 보호 로직도 넣었다

수동 광고 영역을 넣으면서 같은 사용자가 반복 클릭하는 상황을 막고 싶었다. 완벽한 서버 기반 방어는 아니지만, 브라우저 localStorage를 이용해 기본적인 차단 로직을 넣었다.

useEffect(() => {
  const adZone = document.getElementById("ads-zone");
  if (!adZone) return;

  const LIMIT = 2;
  const BLOCK_DAYS = 90;

  const onClick = () => {
    const now = Date.now();
    const count = Number(localStorage.getItem("ad_click_count") || 0) + 1;

    localStorage.setItem("ad_click_count", String(count));

    if (count >= LIMIT) {
      const until = now + BLOCK_DAYS * 24 * 60 * 60 * 1000;
      localStorage.setItem("hide_ads_until", String(until));
      location.reload();
    }
  };

  adZone.addEventListener("click", onClick);

  return () => adZone.removeEventListener("click", onClick);
}, []);

이 방식은 같은 브라우저 기준으로만 작동한다. IP 단위나 계정 단위로 완벽하게 막는 것은 아니다. 하지만 무료 도구형 웹앱 초기 단계에서는 최소한의 방어 장치로는 의미가 있었다.

광고는 넣고 싶은데, 광고 때문에 서비스가 지저분해지는 건 싫고, 무효 클릭도 무섭고… 수익화는 진짜 생각보다 신경 쓸 게 많았다. 하늘에서 돈이 뚝 떨어지면 좋겠다.

10. 처음 UI는 너무 못생겼다

기능이 어느 정도 붙고 나서 화면을 봤는데, 솔직히 너무 못생겼다. 버튼 크기도 제각각이고, 미리보기 화면도 어정쩡하고, 설정 영역은 아래로 길게 늘어져 있었다.

특히 처음 구조는 이런 문제가 있었다.

  • 상단 버튼 배열이 불규칙함
  • 미리보기와 편집 영역의 균형이 안 맞음
  • 모바일에서 버튼이 너무 큼
  • 설정 섹션이 아래에 있어서 수정 후 미리보기를 확인하기 불편함
  • 전체적으로 서비스보다는 임시 테스트 페이지처럼 보임

그래서 레이아웃을 완전히 다시 잡았다. 에디터 서비스처럼 좌측에는 설정 패널, 우측에는 미리보기 화면을 두는 구조로 바꿨다.

블로그 썸네일 만들기 사이트
영역역할
상단 바로고, 저장 형식, 이미지 추출 버튼
좌측 패널프리셋, 이미지 업로드, 텍스트, 프레임, 워터마크, 이미지 조절
우측 화면Canvas 미리보기 고정 표시
하단사용법, FAQ, 푸터

이 구조로 바꾸니 편집하면서 결과물을 바로 확인할 수 있었다. 썸네일 에디터는 결국 “수정 → 확인 → 다시 수정”을 반복하는 도구라서, 미리보기가 항상 보여야 했다.

11. 좌측 패널이 삐져나가는 문제를 해결했다

좌측 패널로 옮긴 뒤에도 문제가 있었다. 텍스트/프레임 수정 섹션에서 윗줄과 아랫줄 설정을 2열로 배치했더니, 패널 너비보다 내용이 커져 옆으로 삐져나갔다.

처음에는 미리보기 크기 문제인 줄 알았지만, 실제 원인은 패널 내부 grid와 input 너비였다. 그래서 좌측 패널 안에서는 내부 요소가 무조건 패널 너비를 넘지 않게 CSS를 정리했다.

.tm-sidebar {
  overflow-y: auto;
  overflow-x: hidden;
}

.tm-sidebar * {
  max-width: 100%;
  box-sizing: border-box;
}

.tm-sidebar input,
.tm-sidebar select,
.tm-sidebar button {
  min-width: 0;
  max-width: 100%;
}

또 텍스트 수정 영역은 데스크탑에서도 2열이 아니라, 패널 안에서 자연스럽게 줄바꿈되도록 조정했다.

이때 진짜 답답했다. 웹에서는 괜찮은 듯 보이다가 창 줄이면 아랫줄 설정이 옆으로 튀어나오고, 모바일에서는 거의 세로로 찢어진 것처럼 보였다. 결국 문제는 “예쁜 디자인”보다 “기본의 역할을 하는 CSS”였다.

12. 미리보기 화면은 고정되어야 했다

처음에는 좌측 패널을 스크롤하면 미리보기 확인을 위해 다시 위로 올라가야 했다. 이건 편집 도구로서 너무 불편했다. 바꾸기 이전과 비교해서 크게 편하지도 않았고.. 그래서 데스크탑에서는 전체 화면을 고정 레이아웃으로 만들고, 좌측 패널만 스크롤되게 했다.

.tm-workspace {
  display: grid;
  grid-template-columns: 380px minmax(0, 1fr);
  height: calc(100vh - 88px);
  overflow: hidden;
}

.tm-sidebar {
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
}

.tm-canvas-area {
  height: 100%;
  overflow: hidden;
}

.tm-preview-stage {
  height: calc(100vh - 160px);
  display: flex;
  align-items: center;
  justify-content: center;
}

이렇게 하니 좌측 설정을 위아래로 움직여도 우측 미리보기는 계속 고정되어 보였다. 실제 편집기처럼 사용할 수 있게 된 순간이었다.

13. 모바일은 데스크탑 구조를 그대로 줄이면 안 됐다

모바일에서도 좌측 패널 + 우측 미리보기 구조를 유지하려고 하니 화면이 완전히 망가졌다. 작은 화면에서 좌우 레이아웃을 유지하면 설정 패널은 너무 좁아지고, 미리보기는 제대로 보이지 않는다.

그래서 모바일에서는 구조를 바꿔야 했다.

  • 상단: 로고와 저장 버튼
  • 그 아래: 미리보기 화면
  • 하단: 편집 설정 패널
  • 설정 항목은 1열로 배치
  • Canvas는 화면 너비에 맞게 축소
@media (max-width: 768px) {
  .tm-workspace {
    display: block;
    height: auto;
    overflow: visible;
  }

  .tm-sidebar {
    width: 100%;
    height: auto;
    overflow: visible;
  }

  .tm-canvas-area {
    width: 100%;
    padding: 16px;
  }

  .tm-canvas {
    width: 100% !important;
    max-width: 520px !important;
    height: auto !important;
    max-height: 52vh !important;
  }
}

모바일에서는 데스크탑처럼 “미리보기를 항상 우측에 고정”하는 것이 불가능하다. 대신 미리보기를 위에 잘 보이게 두고, 편집 패널을 아래로 내려 손가락으로 조작하기 쉽게 만드는 쪽이 더 현실적이었다.

그래도 이 썸네일 에디터 사이트는 웹에서 사용하는 것이 훨씬 더 편하다. 모바일은 아무래도 세로로 배치하다 보니 수정하는 옵션과 미리보기를 한 눈에 보는 게 힘들다. 스크롤을 올렸다 내렸다 반복해야 하는 것을 어떻게 해결해야 모바일에서도 편할지… 사진 편집 어플들을 조금 더 많이 공부해 봐야겠다.

너무 레퍼런스 공부를 안 하고 즉흥적으로 만든 탓도 있다ㅜ
그냥 당장 이 귀찮은 노가다성 업무를 해결하고 싶었기에..

14. 디자인은 초록 그라데이션으로 다시 잡았다

기능과 레이아웃은 어느 정도 정리됐지만, 디자인이 여전히 마음에 들지 않았다. 너무 어둡고 딱딱했고, 버튼도 그냥 기본 UI처럼 보였다. 그래서 원래 브랜드 느낌인 초록색은 유지하되, 더 고급스러운 그라데이션과 글래스 느낌을 넣기로 했다.

최종 디자인 방향은 다음과 같았다.

  • 딥그린 배경
  • 은은한 radial gradient
  • 반투명 패널
  • 글래스 느낌의 사이드바
  • 입체감 있는 버튼
  • 미리보기 카드 강조
  • 너무 튀지 않는 고급스러운 초록 계열
:root {
  --bg: #07140d;
  --panel: rgba(10, 28, 18, 0.72);
  --panel2: rgba(255, 255, 255, 0.06);
  --line: rgba(255, 255, 255, 0.14);
  --text: #f4fff8;
  --muted: rgba(244, 255, 248, 0.62);
  --green: #123d26;
  --orange: #ffa322;
  --primary: #18b45b;
}

body {
  background:
    radial-gradient(circle at 22% 0%, rgba(84, 145, 98, 0.42), transparent 34%),
    radial-gradient(circle at 78% 12%, rgba(255, 163, 34, 0.16), transparent 28%),
    linear-gradient(145deg, #06110b 0%, #0d2416 42%, #07140d 100%);
}

디자인을 바꾸고 나니 단순한 테스트 페이지 느낌은 줄었고, 조금 더 실제 서비스 같은 분위기가 생겼다.

사실 디자인이 제일 어렵다. 기능은 되면 끝인데, 예쁜 건 끝이 없다. 초록색 하나도 촌스러울 수 있고 고급스러울 수 있어서 계속 만지게 된다. 지금도 마음에 20% 정도만 든다…ㅜ

15. SEO를 위해 H1, 메타데이터, 사용법, FAQ를 넣었다

처음에는 로고 이미지만 크게 보여줬다. 그런데 검색엔진 입장에서는 이 페이지가 어떤 페이지인지 이해하기 어렵다. 그래서 실제 화면에는 보이지 않지만 검색엔진이 읽을 수 있는 H1을 추가했다.

<h1 className="sr-only">
  유튜브·블로그 썸네일 만들기 무료 에디터
</h1>

layout.tsx에는 메타데이터도 넣었다.

export const metadata: Metadata = {
  title: "썸네일 에디터 | SNS·블로그 썸네일 만들기 무료 사이트",
  description:
    "유튜브, 블로그, SNS용 컨텐츠 썸네일을 무료로 제작할 수 있는 온라인 썸네일 편집기입니다. 이미지 업로드 후 텍스트·워터마크·프레임을 간편하게 편집하고 바로 저장해보세요.",
};

그리고 페이지 하단에는 사용법 섹션과 FAQ를 추가했다. 도구형 페이지는 기능만 있으면 본문 콘텐츠가 부족해질 수 있기 때문이다.

  • 썸네일 에디터 사용법
  • 이미지 업로드 방법
  • 텍스트 입력 및 수정 방법
  • 프레임과 스타일 선택 방법
  • 이미지 위치와 확대 조절 방법
  • PNG/JPG 저장 방법
  • FAQ 섹션

FAQ 구조화 데이터도 추가했다.

const faqSchema = {
  "@context": "https://schema.org",
  "@type": "FAQPage",
  mainEntity: [
    {
      "@type": "Question",
      name: "썸네일 에디터는 무료인가요?",
      acceptedAnswer: {
        "@type": "Answer",
        text: "네, 회원가입 없이 무료로 사용할 수 있습니다."
      }
    }
  ]
};

다만 네이버는 FAQ 구조화 데이터 노출 정책이 계속 바뀌기 때문에, 구조화 데이터에만 의존하지 않고 본문에 실제 FAQ 섹션을 같이 넣는 방향으로 정리했다.

16. GitHub push에서 충돌이 났다

몇 달간 실제 내 업무에 이용하면서 불편했던 점들을 기록하다, 마음을 다잡고 디자인을 고치며 재배포하려고 했는데, GitHub push부터 막혔다.

이미 너무 바쁜데 사이트 업데이트까지 하려니 스트레스가 최고조였다..
근데 또 맘처럼 배포도 안 돼.. 바로 포기 각이었는데..
그래도 어째? 해야지…
git push

! [rejected] master -> master (fetch first)
error: failed to push some refs

원인은 원격 GitHub 저장소에 로컬에 없는 커밋이 있었기 때문이다. GitHub에서 파일을 직접 수정한 상태였고, 로컬에도 수정본이 있었기 때문에 서로 충돌이 생겼다.

처음에는 pull rebase를 시도했다.

git pull --rebase origin master

그런데 app/globals.css와 app/layout.tsx에서 충돌이 발생했다.

both modified: app/globals.css
both modified: app/layout.tsx

원격 저장소의 수정본을 우선 적용하기 위해 다음 명령어를 사용했다.

git checkout --theirs app/globals.css app/layout.tsx
git add app/globals.css app/layout.tsx

이후 rebase를 계속하려고 했는데, vi 에디터 문제로 또 막혔다.

error: there was a problem with the editor 'vi'

그래서 에디터를 열지 않고 rebase를 진행하도록 처리했다.

git -c core.editor=true rebase --continue

그 결과 rebase가 완료되고, 다시 push할 수 있었다.

git push origin master
Git은 진짜 아직도 무섭다. 특히 rebase 중에 HEAD 분리됨 이런 말 나오면 그냥 뭔가 큰일 난 것 같다. 그래도 이번에 충돌 해결 흐름을 한 번 겪으면서 조금은 감이 왔다.

17. Cloudflare Pages 배포도 한 번에 안 됐다

GitHub push는 성공했지만, Cloudflare Pages 배포에서 또 실패했다. 로그를 보니 build 단계에서 dependency 오류가 발생했다.

npm error ERESOLVE unable to resolve dependency tree
npm error Found: next@16.1.6
npm error Could not resolve dependency:
peer next ">=14.3.0 && <=15.5.2" from @cloudflare/next-on-pages

원인은 Next.js 16과 @cloudflare/next-on-pages의 호환성 문제였다. Cloudflare Pages 빌드 명령어가 다음처럼 되어 있었다.

npx @cloudflare/next-on-pages@1

하지만 현재 프로젝트는 서버 기능이 필요한 앱이 아니라 정적 export로 충분한 구조였다. 그래서 Next.js를 정적 파일로 빌드한 뒤 Cloudflare Pages에 올리는 방식으로 바꿨다.

next.config.ts에는 정적 export 설정을 넣었다.

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
  images: {
    unoptimized: true,
  },
};

export default nextConfig;

Cloudflare Pages 설정도 바꿨다.

항목변경 전변경 후
빌드 명령어npx @cloudflare/next-on-pages@1npm run build
빌드 출력 디렉터리.vercel/output/staticout
배포 방식next-on-pagesNext.js static export

이렇게 바꾼 뒤 배포가 성공했다.

배포 성공했을 때 진짜 안도했다. 코드가 문제가 아니라 배포 설정 하나 때문에 계속 실패하는 게 제일 멘탈이 털린다. 아직 너무 모르는 게 많아서 쫄보가 되는 바이브코더…

18. 이번 프로젝트에서 얻은 가장 큰 인사이트

이번 썸네일 에디터를 만들면서 느낀 건, AI가 코드를 만들어주는 것과 실제 서비스를 완성하는 것은 전혀 다르다는 점이다. 기능 하나를 만드는 것은 빠르지만, 사용자가 편하게 쓰는 수준으로 다듬는 데 훨씬 많은 시간이 들어갔다.

이 사용성에 대해서는 인간이 직접 사용해보고 느끼는 점을 고려해야 하니, ai에게 모두 맡기기가 힘들었다. 난 역시 반자동 바이브코더인 것 같다..

특히 다음 부분이 중요했다.

  • Canvas는 레이어 순서가 중요하다.
  • 한글 입력은 IME 조합 처리가 필요하다.
  • 텍스트 스타일 저장은 localStorage 키 설계가 중요하다.
  • 도구형 웹앱은 미리보기 위치가 UX를 좌우한다.
  • 모바일은 데스크탑 구조를 줄이는 방식으로 해결되지 않는다.
  • AdSense는 TypeScript 타입과 렌더링 시점을 신경 써야 한다.
  • Cloudflare Pages는 Next.js 버전과 빌드 방식을 반드시 확인해야 한다.
  • GitHub에서 직접 수정한 파일과 로컬 수정본이 충돌할 수 있다.

결국 썸네일 에디터는 “이미지에 글자 넣는 간단한 도구”가 아니라, 편집 UX와 배포 안정성까지 챙겨야 하는 하나의 웹 서비스였다.

19. 현재 완성된 기능

  • 이미지 업로드
  • 업로드 초기화
  • 1080×1080 썸네일 제작
  • 1920×1080 이미지 제작
  • 1080×1350 인스타 4:5 구조
  • 1080×1920 쇼츠 9:16 구조
  • 윗줄·아랫줄 텍스트 입력
  • 텍스트 전체 표시 ON/OFF
  • 윗줄·아랫줄 개별 표시 ON/OFF
  • 폰트 선택
  • 글자 크기 조절
  • 글자 색상 조절
  • 테두리 색상 조절
  • 테두리 두께 조절
  • 텍스트 정렬
  • 텍스트 위치 조절
  • 프레임 색상 조절
  • 프레임 두께 조절
  • 그라데이션 색상 조절
  • 그라데이션 알파 조절
  • 워터마크 표시
  • 중앙 워터마크 표시
  • 이미지 출처 표시
  • 이미지 좌우·상하·확대 조절
  • PNG 저장
  • JPG 저장
  • JPG 품질 조절
  • 스타일 옵션별 저장
  • AdSense 수동 광고
  • 사용법 섹션
  • FAQ 섹션
  • Cloudflare Pages 배포

20. 앞으로 개선하고 싶은 부분

아직 완성형이라고 보기는 어렵다. 현재는 썸네일을 빠르게 만드는 MVP에 가깝고, 앞으로는 더 편집기다운 기능을 추가하고 싶다.

  • 텍스트 드래그 이동 기능
  • 이미지 드래그 이동 기능
  • 텍스트 박스 회전 기능
  • 도형 추가 기능
  • 스티커 추가 기능
  • 템플릿 프리셋 추가
  • 사용자 커스텀 템플릿 저장
  • 폰트 미리보기
  • 모바일 전용 하단 편집바
  • 광고 클릭 보호 고도화
  • SEO용 랜딩 콘텐츠 보강
  • 썸네일 예시 갤러리 추가

특히 모바일 편집 경험은 더 개선하고 싶다. 데스크탑에서는 어느 정도 편집기처럼 쓸 수 있지만, 모바일에서는 여전히 미리보기와 조작 패널 사이의 균형이 어렵다.

PC에서는 이제 꽤 쓸 만한데, 모바일은 아직 “진짜 편하다”까지는 아닌 느낌이다. 언젠가는 미리캔버스처럼 손가락으로 바로 움직이고 조절하는 느낌까지 가보고 싶다.

이번 에디터를 만들면서 또 다른 비슷한 서비스를 기획하게 됐는데, 그 개발에 이번 인사이트를 많이 녹여서 업그레이드 된 에디터를 가져와 보겠다!

21. 마무리

처음에는 단순히 내가 블로그 썸네일을 빨리 만들기 위한 도구를 만들고 싶었다. 그런데 만들다 보니 Canvas, 폰트, 한글 입력, UI, 모바일 반응형, SEO, 광고, Git 충돌, Cloudflare 배포까지 모두 겪게 됐다.

이렇게까지 정성드릴 생각은 없었는데.. 꽤나 열심히 만들었음.

항상 AI와 함께 개발하면 시작은 빠르다. 하지만 실제로 쓸 수 있는 서비스가 되려면 결국 사람이 계속 보고, 눌러보고, 깨지는 부분을 찾아서 고쳐야 한다. 이번 썸네일 에디터는 그 과정을 그대로 보여준 프로젝트였다.

그럼에도 여전히 부족한… 아직 개선할 부분은 많지만, 이제는 이미지 업로드부터 텍스트 편집, 스타일 저장, 다운로드, 배포까지 한 사이클이 완성됐다. 앞으로는 더 예쁜 템플릿과 더 편한 모바일 UX를 붙여서 진짜 자주 쓰는 썸네일 제작 도구로 발전시켜볼 예정이다.

많관부!

이 게시물이 얼마나 유용했습니까?

별을 눌러주세요!

평균 평점 5 / 5. 투표수: 1103

이 게시물은 구독자의 반응이 좋은 글입니다. 함께 평가해 보세요.