← 블로그로 돌아가기
Day 9

유튜브 댓글 생성기 만들기 — 아바타 3번 갈아엎고 CSS 버그에 1시간 날린 썰

2026년 4월 5일 PM 11:50

devlogreacthtml2canvascontentEditable

왜 또 생성기인가

Day 3에 만든 카카오톡 대화 생성기의 YouTube판입니다.

콘텐츠 제작자가 블로그나 영상에 "이런 댓글 달렸어요" 같은 예시를 넣을 때, 또는 리뷰 글에 실제 댓글 레이아웃을 보여주고 싶을 때 쓰는 도구입니다. 기존 카톡 생성기에서 잘 통했던 패턴 — useReducer 하나로 상태 통합 + 좌측 설정 패널 + 우측 프리뷰 + html2canvas-pro로 PNG 내보내기 — 을 그대로 가져와서 시작했습니다.

유튜브 댓글 생성기 결과물


아바타를 3번 갈아엎었다

이게 오늘 시간을 가장 많이 먹은 부분입니다.

v1: 이모지 아바타 (😀 🙂 🎬 …)

처음에는 카톡 생성기처럼 이모지 셀렉터를 넣었습니다. 결과는 "가짜처럼 보이는 가짜". YouTube는 모든 아바타가 실제 사진이라 이모지가 등장하는 순간 위화감이 폭발합니다.

v2: 이니셜 아바타

Gmail/GitHub 스타일로 이름 첫 글자 + 컬러 배경. 한글은 첫 글자 그대로, 영문은 대문자. 훨씬 자연스럽긴 한데, 유튜브를 오래 쓴 사람이라면 "진짜 같은데 좀 허전하네" 수준입니다.

v3: 이미지 업로드

결국 실제 이미지를 업로드할 수 있게 했습니다. 관건은 state 비대화 방지 — data URL을 그대로 저장하면 댓글 10개만 돼도 수 MB가 될 수 있습니다. 그래서 업로드 시 canvas로 256px까지 축소하고 JPEG 0.85로 재인코딩:

async function fileToDataUrl(file: File, maxSize = 256): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => {
      const img = new Image();
      img.onload = () => {
        // 긴 변을 maxSize에 맞춰 비율 유지 축소
        const canvas = document.createElement("canvas");
        // ...
        resolve(canvas.toDataURL("image/jpeg", 0.85));
      };
      img.src = reader.result as string;
    };
    reader.readAsDataURL(file);
  });
}

최종 렌더 우선순위: redacted(모자이크) > image > initial. 이 순서 때문에 나중에 버그 하나가 더 터집니다 (뒤에서).


프사 모자이크를 "진짜처럼" 만들기

댓글 생성기에서 은근히 중요한 기능입니다. SNS 스크린샷을 보면 다른 사람 프사는 거의 항상 모자이크나 블러로 가려져 있습니다. "이 컴포넌트로 실제 캡처처럼 만들 수 있어야 한다"가 기준이었습니다.

솔직히 단색 원이나 CSS filter: blur()도 고려했는데, 전자는 너무 밋밋하고 후자는 html2canvas로 export하면 결과가 예측 불가능합니다. 그래서 SVG 5×5 <rect> 그리드 + 원형 클립으로 직접 그렸습니다.

포인트는 같은 이름은 항상 같은 패턴이 나와야 한다는 것. 렌더할 때마다 색이 바뀌면 장난감처럼 보입니다. 이름을 시드로 FNV-1a 해시를 돌려서 결정적으로 색을 뽑습니다:

function mosaicColors(seed: string, count: number): string[] {
  let h = 2166136261;
  for (let i = 0; i < seed.length; i++) {
    h ^= seed.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  // xorshift로 count만큼 팔레트에서 결정적으로 뽑기
}

shapeRendering="crispEdges"로 픽셀 경계를 흐리지 않게 유지하면, 진짜 모자이크 처리된 이미지 같은 느낌이 납니다.


In-place 편집 모드

처음엔 모든 수정이 왼쪽 설정 패널의 폼에서 일어났습니다. 그런데 막상 써보니 불편했습니다. "이 댓글의 좋아요 숫자만 바꾸고 싶다"고 하면 리스트에서 해당 항목을 찾아 드롭다운을 고르고… 단계가 너무 많습니다.

그래서 프리뷰 위에 프리뷰 / 편집 탭을 붙이고, 편집 모드에서는 작성자명·시간·본문·좋아요 수를 프리뷰 위에서 직접 클릭해서 수정할 수 있게 했습니다.

전체 레이아웃 + 편집 모드

구현은 contentEditable + 자체 래퍼 컴포넌트 EditableText. React에서 contentEditable을 쓸 때 가장 골치 아픈 건 외부 value가 바뀔 때마다 textContent를 덮어쓰면 커서가 맨 앞으로 튀는 현상입니다. 해결은 의외로 단순했습니다. 포커스가 있으면 DOM에 손 대지 않기:

useEffect(() => {
  const el = ref.current;
  if (!el || document.activeElement === el) return; // 포커스 중엔 건드리지 않음
  if (el.textContent !== value) el.textContent = value;
}, [value]);

편집 모드에만 나오는 플래그 토글 툴바, 삭제 버튼, 편집/프리뷰 탭에는 전부 data-html2canvas-ignore="true"를 달아뒀습니다. 편집 중에 그대로 이미지 저장을 눌러도 export된 PNG에는 툴바가 안 찍힙니다.


편집 UI의 이모지를 전부 걷어냈다

초반엔 플래그 버튼을 📌 ✓ 👑 ❤ 🔒 🗑 이렇게 이모지로 만들었습니다. 하나씩 보면 직관적인데, 6개가 한 줄로 모이니 의미 해독이 오히려 느려졌습니다. Day 8 유튜브 다운로더 리디자인 때 얻은 교훈이 정확히 다시 나왔습니다:

이모지가 많을수록 AI가 만든 티가 나고, 가독성도 떨어진다.

결국 전부 한글 2~4자 라벨로 교체했습니다: 고정 / 인증 / 주인 / 하트 / 프사모자이크 / 삭제 / 프사제거. 더 긴 설명이 필요한 건 title 속성으로 툴팁을 달아뒀습니다 (예: 고정 → "상단 고정 댓글").

편집 모드 플래그 툴바


그 와중에 찾은 CSS 버그 (진짜 어이없는 것)

편집 모드를 붙이고 나서 화면을 보는데, 답글의 "채널주인 ❤" 하트 오버레이가 아바타 옆이 아니라 아바타에서 한참 떨어진 이상한 위치에 그려졌습니다.

원인을 찾는 데 한 시간 정도 날렸습니다. 결론부터 말하면 이겁니다:

해결은 한 줄:

<div
  className="relative shrink-0 self-start"
  style={{ width: size, height: size }}
>

래퍼에 명시적 width/height를 주고 self-start로 flex stretch를 차단하니 하트가 제자리로 돌아왔습니다.

교훈: absolute positioning의 기준 박스가 "내가 시각적으로 생각하는 것"이 아닐 수 있다. flex container 안의 relative wrapper는 거의 항상 stretch되기 때문에, 크기를 명시하거나 align-self를 걸어주지 않으면 나중에 이런 식으로 배신당합니다.


데이터 싱크 함정

또 하나 재밌었던 버그. 영상 설정의 채널 소유자 이름을 "홍선생"으로 바꿨는데, 고정 댓글의 @채널주인이 그대로였습니다.

원인은 단순했습니다. creatorName(설정)과 comment.author(데이터)가 완전히 분리된 채 각자 렌더되고 있었습니다. 고정 배너의 "고정됨 by XX"는 creatorName을 보고, 댓글 본체의 @XXcomment.author를 봤던 것.

해결은 reducer의 UPDATE_SETTINGS 케이스에서 creatorName 변경을 감지하면 isCreator: true인 모든 댓글·답글의 author까지 함께 업데이트하도록 cascade 처리했습니다:

case "UPDATE_SETTINGS": {
  const next = { ...state, ...action.payload };
  if (action.payload.creatorName !== undefined &&
      action.payload.creatorName !== state.creatorName) {
    const newName = action.payload.creatorName;
    next.comments = state.comments.map((c) => ({
      ...c,
      author: c.isCreator ? newName : c.author,
      replies: c.replies.map((r) => ({
        ...r,
        author: r.isCreator ? newName : r.author,
      })),
    }));
  }
  return next;
}

교훈: 단일 소스가 없으면 반드시 싱크 버그가 난다. 두 곳에서 같은 의미의 값을 쓰면, 언젠가 한 곳만 업데이트하는 코드가 끼어들기 마련이고, 그게 사용자 손에서 터집니다.


카톡 생성기로 역수입

유튜브 댓글 생성기에서 오늘 하루 다듬은 패턴들이 그대로 카톡 대화 생성기로 역수입됐습니다. Day 3에 만들어둔 버전은 이모지 아바타 + 좌측 패널 폼 편집만 되는 수준이었는데, 저녁에 같은 원리로 한 번에 업그레이드했습니다.

카톡 생성기에 반영된 것들:

두 생성기가 이제 같은 DX를 공유합니다. 유튜브 쪽에서 버그나 UX 개선이 나오면 카톡 쪽에도 바로 적용할 수 있게 됐고, 반대도 마찬가지입니다.


회고

9일차 교훈: 이모지 많이 쓴 UI는 예쁘지 않다. 그리고 absolute 쓸 때는 부모 크기를 항상 의심하자.

직접 써보기: /youtube-fakecomment