LogoSEO Jing
  • All Posts
  • SEO Jing
  • okayJing
  • KD Team
  • CLAB Coreteam
  • Study

Contact Me

© 2026 SEOJing. All rights reserved.

ReactDOMUX모바일DevLog

블로그 글을 PPT로 만들기 — DOM 클로닝 기반 프레젠테이션 모드

2026년 3월 22일·17분 읽기

아이디어: 블로그 글을 슬라이드로 보여주기

이전 글에서 코드 블록을 모바일 가로 모드로 보여주는 전체화면 뷰어를 만들었다. CSS transform: rotate(90deg)로 화면을 돌리고, 롱프레스로 종료하는 패턴이었다. 이번에는 같은 기법을 글 전체에 적용해봤다. 블로그 글을 PPT처럼 슬라이드로 넘기면서 읽는 프레젠테이션 모드다. 이 모드를 통해서 스터디를 위한 별도의 자료 없이, 블로그만으로 진행할 수 있도록 하고 싶었다.

요구사항은 이랬다.
  • MDX 파일을 수정하지 않는다. 기존 글이 그대로 슬라이드가 되어야 한다.
  • 모바일은 가로 회전 전체화면, PC는 일반 전체화면.
  • 스크롤 없음. PPT처럼 한 슬라이드에 내용이 넘치면 자동으로 다음 슬라이드로 분할된다.
  • 화면 왼쪽/오른쪽을 탭해서 이전/다음으로 넘긴다.

가장 먼저 떠오른 방법은 MDX에 {/* @slide */} 같은 마커를 넣는 것이었다. 하지만 이러면 모든 기존 글을 수정해야 하고, 마커가 일반 렌더링에 영향을 줄 수 있다. 렌더된 DOM을 읽어서 자동으로 분할하는 방식이 더 낫다고 판단했다.


1차 분할: h2 기준으로 챕터 나누기

블로그 글의 구조는 대부분 h2(SubTitle 컴포넌트)로 섹션을 구분한다. 이 h2를 기준으로 DOM 자식들을 그룹핑하면 자연스럽게 챕터가 된다. hr(---)도 명시적 구분자로 사용한다.

tsx
const chapters: Element[][] = [];
let currentChapter   

Post Q&A

오케이징에게 물어보기

블로그 글을 PPT로 만들기 — DOM 클로닝 기반 프레젠테이션 모드 전체를 기준으로 질문과 피드백을 받아요.답을 본 뒤에는 이 내용을 댓글로 달아서 서징에게도 물어볼 수 있어요. 작성자가 직접 볼 수 있어요!

0/500

포스트 목록

/SEOJing
파일 15개, 폴더 1개
Cloudflare Workers에서 fs 모듈이 안 되는 이유와 해결법대표 이미지 자동화 실험 — 검색과 Codex 생성이 같은 경로로 붙었다본문 이미지를 나중에 넣는 게 아니라 — SEOJing 글쓰기 파이프라인에 시각 판단을 넣기모바일 웹에서 가로 모드를 강제하는 5가지 방법 — iOS Safari에서도 동작하는 코드 뷰어 만들기블로그 글을 PPT로 만들기 — DOM 클로닝 기반 프레젠테이션 모드100vh가 100%가 아닌 이유 — 모바일 뷰포트 단위 완전 정리Context로 퀴즈 컴포넌트를 만들다 막혀서 React.Children을 공부하게 된 이야기대표 이미지를 글마다 다시 붙이는 방식 — 사진 검색에서 리소그래프 배경까지글 위에 영상을 붙인다는 것 — SEOJing 요약 쇼츠 파이프라인localStorage 읽기에서 하이드레이션 에러가 터지는 이유 useSyncExternalStore로 해결useEffect cleanup과 의존성 배열 — 실전 버그 사례로 이해하는 생애주기vinext + GitHub Actions로 Cloudflare Workers 배포하기vinext 오픈소스 기여기: 한국어 slug가 RSC에서 이슈를 일으킨 이유RSC 환경에서 WebAssembly가 차단되는 이유 — Shiki에서 rehype-prism-plus로vinext는 왜 빠를까? — SSR, Vite, Edge, 그리고 Web Vitals까지

같은 섹션의 대표 이미지

39 posts · latest first
본문 이미지를 나중에 넣는 게 아니라 — SEOJing 글쓰기 파이프라인에 시각 판단을 넣기 글의 대표 이미지
SEO Jing26. 06. 22.

본문 이미지를 나중에 넣는 게 아니라 — SEOJing 글쓰기.

SEOJing에서 새 글을 쓸 때 대표 이미지와 본문 이미지를 빼먹지 않도록, 블로그 맵과 글쓰기 파이프라인 안에 시각 판단 단계를 넣은 과정을 정리했다.

26. 06. 22.SEOJing
:
Element
[
]
=
[
]
;
for (const child of Array.from(article.children)) {
const tag = child.tagName.toLowerCase();
// 위젯(toolbar, nav 등)은 건너뛴다
if (child.classList.contains("sticky") || tag === "nav") continue;
// h2를 만나면 이전 챕터를 확정하고 새 챕터 시작
if (tag === "h2" && currentChapter.length > 0) {
chapters.push(currentChapter);
currentChapter = [];
}
// hr은 구분자 역할만 하고 슬라이드에 포함하지 않는다
if (tag === "hr") {
if (currentChapter.length > 0) chapters.push(currentChapter);
currentChapter = [];
continue;
}
currentChapter.push(child);
}

이 시점에서 각 챕터는 "h2 제목 + 그 아래 콘텐츠"의 배열이다. 짧은 챕터는 하나의 슬라이드가 되고, 긴 챕터는 2차 분할로 넘어간다.


2차 분할: 화면 높이 기반 자동 페이지네이션

PPT처럼 스크롤 없이 보여주려면, 각 챕터를 화면 높이에 맞춰 잘라야 한다. 핵심은 각 블록 요소의 렌더링 높이를 측정해서, 누적 높이가 슬라이드 가용 높이를 초과하면 새 슬라이드를 시작하는 것이다.

측정에는 숨겨진 컨테이너를 사용한다.
tsx
const measurer = document.createElement("div");
measurer.style.cssText = `
  position: fixed; top: -9999px; left: -9999px;
  width: ${slideWidth}px;
  visibility: hidden; pointer-events: none;
`;
document.body.appendChild(measurer);

function measure(node: Node): number {
  measurer.innerHTML = "";
  measurer.appendChildnode

요소를 클론해서 측정 컨테이너에 넣고, getBoundingClientRect().height로 실제 렌더링 높이를 가져온다. 누적 높이가 가용 높이를 넘으면 현재 슬라이드를 확정하고 새 슬라이드를 시작한다.

tsx
for (const element of chapter) {
  const cloned = element.cloneNode(true) as HTMLElement;
  const elementHeight = measure(cloned);

  if (
    currentHeight + elementHeight > availableHeight &&
    currentSlide.children.length > 0
  ) {
    slides.push(currentSlide);
    currentSlide = document.createElement("div");
    currentHeight = 0;
  }

  currentSlide.cloned

여기까지는 단순하다. 문제는 모바일에서 레이아웃이 깨져보였다.


버그: 모바일에서 슬라이드에 콘텐츠가 너무 많이 들어간다

PC에서는 잘 동작했는데, 모바일에서는 한 슬라이드에 콘텐츠가 넘쳐흘렀다. 분명히 가용 높이를 계산해서 자르고 있는데, 왜 넘치는 걸까?

디버그 로그를 찍어보니 원인이 두 가지였다.

원인 1: 측정 컨테이너의 너비가 실제 슬라이드 너비와 다르다

처음에 측정 컨테이너의 너비를 width: 64rem; max-width: 100vw로 고정했다. 64rem은 약 1024px이다. 하지만 모바일에서 CSS 회전을 하면 슬라이드의 실제 너비는 100vh — 대략 700~850px 정도다.

너비가 넓으면 텍스트가 덜 줄바꿈되고, 높이가 더 낮게 측정된다.

실제로는 좁은 슬라이드 안에서 텍스트가 더 많이 줄바꿈되어 높이가 커지는데, 측정할 때는 넓은 컨테이너에서 측정하니 높이가 작게 나온다. 그래서 "아직 공간이 남았다"고 판단해서 콘텐츠를 계속 넣는 것이다.

tsx
// 수정 전: 고정 너비
measurer.style.width = "64rem";

// 수정 후: 실제 슬라이드 너비와 동일
const slideW = isMobile
  ? window.innerHeight - 64 // 회전 시 vh가 너비, 좌우 패딩 제외
  : Math.min(window.innerWidth - 64, 896);

measurer.style.width = `${slideW}px`;

원인 2: 리스트(ul/ol)가 통째로 하나의 블록

너비를 맞춰도 여전히 넘치는 슬라이드가 있었다. 로그를 보니 이런 상황이었다.

[Slide Element] { tag: "UL", elementHeight: 384, availableHeight: 280 }
[Slide Element] { tag: "OL", elementHeight: 732, availableHeight: 280 }

ul의 높이가 384px인데 가용 높이는 280px이다. 분명히 넘치지만, 분할 로직에는 이런 조건이 있다.

tsx
if (currentHeight + elementHeight > availableHeight
    && currentSlide.children.length > 0) {

currentSlide.children.length > 0 — 현재 슬라이드에 이미 내용이 있을 때만 새 슬라이드를 시작한다. 빈 슬라이드에 첫 번째 요소로 들어가면 아무리 커도 분할되지 않는다. 그리고 ul 안에 li가 10개 있어도, 분할 단위는 ul 전체이기 때문에 리스트 내부를 자를 수 없다.


해결: 리스트를 li 단위로 분할하기

ul이나 ol이 가용 높이를 초과하면, 자식 li들을 하나씩 측정해서 슬라이드별로 나눈다. 분할된 li들은 원본과 동일한 태그(ul/ol)와 클래스로 감싸서 스타일이 유지되도록 한다.

tsx
const SPLITTABLE_TAGS = new Set(["UL", "OL"]);

// 리스트가 가용 높이를 초과하면 li 단위로 분할
if (elementHeight > availableHeight && SPLITTABLE_TAGS.has(element.tagName)) {
  // 현재 슬라이드에 내용이 있으면 먼저 확정
  if (currentSlide.children.length > 0) {
    slides.push(currentSlide);
    [currentSlide, currentHeight] = [document.createElement("div"), 0];
핵심은 두 가지다.
  • li 단위 측정: 리스트 전체가 아닌 각 항목의 높이를 개별 측정한다.
  • 래퍼 유지: 분할된 li들을 ul/ol로 감싸서 원본의 클래스(list-disc, space-y-2 등)를 그대로 적용한다. 그래야 불릿/번호 스타일이 유지된다.

인터랙션: 탭과 롱프레스의 공존

프레젠테이션 모드에서는 탭으로 슬라이드를 넘기고, 롱프레스로 종료해야 한다. 코드 뷰어에서는 롱프레스만 있었지만, 이번에는 두 제스처를 구분해야 한다.

tsx
const LONG_PRESS_MS = 1500;
const pressStartTime = useRef(0);

const handlePointerDown = () => {
  pressStartTime.current = Date.now();
  timerRef.current = setTimeout(onClose, LONG_PRESS_MS);
};

const handlePointerUp = (side: "left" | "right") => {
  clearTimeout(timerRef.current);
  const elapsed =   pressStartTime

onPointerDown에서 타이머를 시작하고, onPointerUp에서 경과 시간을 확인한다. 1.5초 미만이면 탭으로 판단해서 슬라이드를 넘기고, 1.5초 이상이면 타이머 콜백이 이미 onClose()를 호출한 상태다.

PC에서는 롱프레스 대신 오른쪽 하단의 X 버튼과 ESC 키로 종료한다. 마우스로 1.5초 꾹 누르는 건 자연스럽지 않기 때문이다. 모바일/PC 분기는 window.matchMedia("(max-width: 768px)")로 판단한다.


모바일 가로 회전: 코드 뷰어와 동일한 패턴

이전 글에서 다뤘던 CSS transform 패턴을 그대로 재사용한다. 모바일에서는 width: 100vh, height: 100vw, rotate(90deg)로 가로 모드를 시뮬레이션하고, PC에서는 단순히 fixed inset-0으로 전체화면을 만든다.

tsx
const containerStyle = isMobile
  ? {
      width: "100vh",
      height: "100vw",
      top: "50%",
      left: "50%",
      transform: "translate(-50%, -50%) rotate(90deg)",
    }
  : { width: "100%", height: "100%" };

슬라이드 콘텐츠는 createPortal로 document.body에 직접 렌더링한다. 기존 페이지 레이아웃의 overflow나 z-index에 영향받지 않기 위해서다.


전체 흐름 정리

프레젠테이션 모드의 전체 흐름을 정리하면 이렇다.
  1. 사용자가 ArticleToolbar의 프레젠테이션 버튼을 클릭한다.
  2. toolbarRef.current.closest("section")으로 글의 DOM 루트를 찾는다.
  3. DOM 자식들을 h2/hr 기준으로 챕터를 나눈다. (1차 분할)
  4. 각 챕터의 블록 요소를 숨겨진 측정 컨테이너에서 높이를 측정한다.
  5. 누적 높이가 슬라이드 가용 높이를 넘으면 새 슬라이드를 시작한다. (2차 분할)
  6. ul/ol이 가용 높이를 넘으면 li 단위로 더 잘게 분할한다.
  7. createPortal로 전체화면 오버레이를 렌더링한다.
  8. 모바일은 CSS 회전, PC는 일반 전체화면.
  9. 탭으로 좌우 이동, 롱프레스(모바일)/X 버튼(PC)/ESC로 종료.

반응형 채움 비율: 화면 크기에 따른 밀도 조절

내 개발 환경은 QHD(2560×1440) 모니터다. 이 해상도에서는 슬라이드가 잘 보였는데, 개발자 도구에서 FHD(1920×1080) 크기로 줄여보니 슬라이드에 콘텐츠가 넘쳐흘렀다. 가용 높이가 줄었는데 채움 비율은 그대로이니, 더 좁은 공간에 같은 양의 콘텐츠를 밀어 넣고 있었다. 계산식을 보면 바로 이해된다.

available = (viewH - padding - bottomBar) * ratio

QHD:  (1440 - 96 - 48) × 0.85 = 1101px  → 넉넉, 문제 없음
FHD:  (1080 - 96 - 48) × 0.85 =  796px  → 여전히 높아서 콘텐츠가 많이 들어감
      하지만 실제 렌더링 영역은 py-12 + overflow-hidden으로 더 좁음

QHD에서 85%는 적절한 여백을 남기지만, FHD에서 85%는 거의 꽉 찬 상태다. 화면이 작아질수록 패딩과 하단 바가 차지하는 비중이 커지는데, 고정 비율은 이를 반영하지 못한다. 결국 작은 화면일수록 슬라이드가 더 빡빡해지는 문제가 생긴다.

처음 시도한 해결책은 고정 최대 높이 캡(720px)이었다. 어떤 화면이든 슬라이드 콘텐츠 높이가 720px을 넘지 않도록 제한하는 것이다. FHD에서도 잘 동작했지만, 이 프레젠테이션 모드를 강의실 메인 TV나 빔프로젝터에서도 쓸 계획이었다. 고정 캡이면 큰 화면에서 슬라이드가 위아래로 비어 보이고, 작은 노트북에서는 여전히 빡빡해진다. 고정값은 특정 환경에 맞추면 다른 환경에서 깨진다.

최종 해결책은 화면 높이 구간별로 채움 비율을 다르게 적용하는 것이다.

tsx
function getFillRatio(viewH: number): number {
  if (viewH <= 600) return 0.92; // 작은 화면: 최대한 활용
  if (viewH <= 900) return 0.82; // 일반 노트북
  if (viewH <= 1200) return 0.72; // 데스크탑/빔프로젝터
  return 0.65; // 대형 모니터/TV
}

const available = (viewH - padding - bottomBar) * getFillRatio(viewH);

작은 화면에서는 공간이 부족하니 92%까지 채우고, 큰 화면에서는 65%만 채워서 슬라이드가 답답해 보이지 않도록 한다. 어떤 환경에서든 적절한 밀도가 유지된다.


핵심 교훈

DOM 높이 측정은 측정 환경이 실제 렌더링 환경과 다르면 틀린다. 특히 너비가 다르면 텍스트 줄바꿈이 달라지고, 높이 계산이 완전히 어긋난다. "측정만 하면 되겠지"라고 생각했지만, 측정 컨테이너의 너비를 실제 슬라이드 너비에 맞추는 것이 핵심이었다.

그리고 분할 단위를 잘 선택해야 한다. ul 전체를 하나의 블록으로 취급하면 10개의 li가 하나의 슬라이드에 몰리게 된다. "이 요소가 너무 크면 더 잘게 자를 수 있는가?"를 항상 고려해야 한다. 이번에는 리스트의 li만 처리했지만, 같은 패턴으로 긴 테이블의 tr이나 정의 목록의 dt/dd도 분할할 수 있다.

마지막으로, 고정 상수는 특정 환경에서만 동작한다. 내 모니터에서 잘 보이는 값이 강의실 TV에서는 콘텐츠가 넘치고, 작은 노트북에서는 비어 보인다. 화면 크기에 따라 동적으로 조절되는 반응형 접근이 필요하다.

SEO Jing26. 06. 22.

글 위에 영상을 붙인다는 것 — SEOJing 요약.

SEOJing 글을 소셜용 영상으로 따로 소비시키는 게 아니라, 포스트 상단 요약과 블로그 유입 장치로 연결하기 위해 summaryVideo frontmatter와 Supertonic3 기반 요약 쇼츠 파이프라인을 붙인 과정을 정리했다.

26. 06. 22.SEOJing
대표 이미지 자동화 실험 — 검색과 Codex 생성이 같은 경로로 붙었다 글의 리소그래프 스타일 대표 이미지 배경
SEO Jing26. 06. 21.

대표 이미지 자동화 실험 — 검색과 Codex 생성이 같은.

SEOJing 블로그에 대표 이미지를 자동으로 붙이는 실험을 실제로 돌려봤다. 검색 기반 cover 삽입과 Codex CLI 기반 정적 SVG 생성이 같은 frontmatter 경로로 연결됐다.

26. 06. 21.SEOJing
대표 이미지를 글마다 다시 붙이는 방식 글의 리소그래프 스타일 대표 이미지 배경
SEO Jing26. 06. 21.

대표 이미지를 글마다 다시 붙이는 방식 — 사진 검색에서.

SEOJing 포스트 목록을 파일 탐색기처럼만 두지 않고, 최신 글부터 실제 사진 기반 리소그래프 배경을 붙이는 실험을 정리합니다. 이미지는 배경만 만들고, 제목과 아이콘은 블로그 UI가 맡는 쪽으로 방향을 바꿨습니다.

26. 06. 21.SEOJing
SEO Jing26. 04. 01.

Day 12 - 테스트 커버리지 개선, 모바일 프레젠테이션 버그 2건.

SEO Jing 개발 열두째 날. code-block 테스트 14개 추가로 커버리지 대폭 개선, 모바일 프레젠테이션에서 FullscreenView 방향 전환 문제와 스크롤 멈춤 버그 수정.

26. 04. 01.SEOJing
SEO Jing26. 04. 01.

useEffect cleanup과 의존성 배열 — 실전 버그.

useEffect 의존성 배열에 불필요한 값이 포함되면 cleanup과 재실행이 뒤엉켜 DOM 상태가 꼬일 수 있다. 프레젠테이션 모드에서 발생한 모바일 스크롤 고착 버그를 통해 원인과 해결 패턴을 정리한다.

26. 04. 01.SEOJing
SEO Jing26. 03. 25.

Day 11 - 프레젠테이션 확대 기능,.

SEO Jing 개발 열한째 날. PC 프레젠테이션 확대/축소 컨트롤 추가, 모바일 orientation 판단 로직 개선, FullscreenView를 독립 컴포넌트로 분리 및 PC 대응.

26. 03. 25.SEOJing
SEO Jing26. 03. 25.

vinext 오픈소스 기여기: 한국어 slug가 RSC에서.

한국어 MDX 블로그를 만들다 vinext 프레임워크의 ByteString 버그를 발견하고, 이슈를 작성하고, PR을 올리기까지의 과정

26. 03. 25.SEOJing
SEO Jing26. 03. 25.

vinext는 왜 빠를까? — SSR, Vite, Edge,.

vinext가 빠른 이유를 이해하기 위해, SSR부터 Hydration, 빌드 도구, Edge Runtime, Web Vitals, RSC, CDN 캐싱, ISR, PPR까지 웹 렌더링 성능의 전체 그림을 정리한다

26. 03. 25.SEOJing
SEO Jing26. 03. 24.

100vh가 100%가 아닌 이유 — 모바일 뷰포트 단위 완전 정리.

모바일 Safari에서 100vh가 화면을 넘치는 이유, vh/svh/lvh/dvh의 차이, JavaScript에서 실제 뷰포트를 구하는 방법, 그리고 전체화면 UI를 만들 때 알아야 할 CSS zoom과 모바일 판정 패턴까지 정리한다.

26. 03. 24.SEOJing
SEO Jing26. 03. 23.

Day 10 - 프레젠테이션 모드 안정화.

SEO Jing 개발 열째 날. 프레젠테이션 모드의 모바일 UX 문제들을 전면 수정. 퀴즈·코드블록·이미지·포스트목록 처리 개선, 롱프레스 UX 및 하단 바 레이아웃 안정화. 모바일 뷰포트·리스트 분할 문제 수정, 채움 비율 보수적으로 조정, 포스트 탐색기 자연 정렬 적용.

26. 03. 23.SEOJing
SEO Jing26. 03. 22.

Day 9 - 프레젠테이션 모드, 코드블럭 개선, 테스팅.

SEO Jing 개발 아홉째 날. 프레젠테이션 기능 추가, 퀴즈 구조 변경, 모바일 반응형, 코드블럭 사용성, 테스팅 도입.

26. 03. 22.SEOJing
SEO Jing26. 03. 22.

모바일 웹에서 가로 모드를 강제하는 5가지 방법 — iOS.

모바일 웹에서 코드 블록을 가로로 넓게 보여주고 싶었다. screen.orientation.lock()은 iOS에서 안 되고, PWA manifest는 브라우저에서 무시된다. 결국 CSS transform으로 가짜 회전을 만들었고, 그 과정에서 엄지 접근성까지 고민하게 됐다.

26. 03. 22.SEOJing
SEO Jing26. 03. 22.

블로그 글을 PPT로 만들기 — DOM 클로닝 기반.

MDX 파일을 수정하지 않고, 렌더된 DOM을 h2 기준으로 자르고 화면 높이에 맞춰 자동 페이지네이션하는 프레젠테이션 모드를 만들었다. 리스트 높이 측정이 왜 틀리는지 디버깅한 과정과, ul/ol을 li 단위로 분할하는 해결책을 정리한다.

26. 03. 22.SEOJing
SEO Jing26. 03. 21.

Day 8 - 아티클 퀴즈와 스터디 자료.

SEO Jing 개발 여덟째 날. 아티클 퀴즈 디자인시스템 구현과 스터디 대면 자료 작성.

26. 03. 21.SEOJing
SEO Jing26. 03. 21.

Context로 퀴즈 컴포넌트를 만들다 막혀서.

MDX 블로그에 퀴즈 컴포넌트를 만들면서, Context 기반 Compound Component로 시작했다가 index 문제에 막혀 React.Children API를 채택하게 된 과정을 정리한다.

26. 03. 21.SEOJing
SEO Jing26. 03. 20.

Day 7 - Front Matter CMS와 관련 게시물.

SEO Jing 개발 일곱째 날. Front Matter CMS 설치와 관련 게시물 이동 탐색기 구현.

26. 03. 20.SEOJing
SEO Jing26. 03. 18.

Day 6 - 스터디 자료 작성과 데스크탑 비율 수정.

SEO Jing 개발 여섯째 날. 데스크탑 비율 수정과 씨랩 스터디 사전 진단 자료 작성.

26. 03. 18.SEOJing
SEO Jing26. 03. 17.

Day 5 - shiki 제거, MDX 모듈화, 그리고.

SEO Jing 개발 다섯째 날. shiki를 rehype-prism-plus로 교체하고, gray-matter 직접 구현, MDX 모듈화, 페이지 내 검색, 테이블 디자인시스템까지.

26. 03. 17.SEOJing
SEO Jing26. 03. 16.

Day 4 - 배포와 CI/CD.

SEO Jing 개발 넷째 날. lint, codecov, Cloudflare 배포, fs 런타임 이슈.

26. 03. 16.SEOJing
SEO Jing26. 03. 16.

Cloudflare Workers에서 fs 모듈이 안 되는 이유와.

배포 후 블로그 포스트가 404를 반환하던 문제부터, gray-matter eval 차단, next-mdx-remote eval 차단까지 — 세 겹으로 터진 이슈를 하나씩 해결한 기록

26. 03. 16.SEOJing
SEO Jing26. 03. 16.

localStorage 읽기에서 하이드레이션 에러가 터지는 이유.

localStorage를 읽는 컴포넌트에서 하이드레이션 불일치가 발생하는 원인과, useState+useEffect가 아닌 useSyncExternalStore가 정답인 이유를 정리한다.

26. 03. 16.SEOJing
SEO Jing26. 03. 16.

vinext + GitHub Actions로.

vinext 프로젝트를 GitHub Actions로 Cloudflare Workers에 자동 배포하는 방법과 실제 겪은 트러블슈팅 기록

26. 03. 16.SEOJing
SEO Jing26. 03. 16.

RSC 환경에서 WebAssembly가 차단되는 이유 —.

코드 하이라이팅에 Shiki를 쓰면 왜 RSC에서 WebAssembly.instantiate() 에러가 터지는지, 그리고 빌드 타임 하이라이팅으로 어떻게 해결했는지 정리한다.

26. 03. 16.SEOJing
SEO Jing26. 03. 15.

엄청난 피드백.

CLI 코드 리뷰에서 받은 피드백과 전체 코드 수정 계획을 정리했다.

26. 03. 15.SEOJing
SEO Jing26. 03. 15.

생각보다 어려웠던 댓글, 완독 로컬스토리지.

localStorage만으로 글 읽기 추적, 스크롤 진행률, 댓글 감지를 구현한 과정을 정리했다.

26. 03. 15.SEOJing
SEO Jing26. 03. 15.

MDX 관련 이슈 노트.

블로그 디테일 페이지에서 MDX를 렌더링하기 위해 검토한 라이브러리들과 최종 선택 과정.

26. 03. 15.SEOJing
SEO Jing26. 03. 15.

Day 3 - MDX 이슈, 반응형, 다크모드.

SEO Jing 개발 셋째 날. MDX 라이브러리 이슈, 반응형, 코드블럭, 댓글, 다크모드, 코드 리뷰.

26. 03. 15.SEOJing
SEO Jing26. 03. 14.

디자인 시스템을 구축할 때 주의할 점.

디자인 시스템 구현 시 파일 구조, 디자인 토큰, 유의 사항을 정리했다.

26. 03. 14.SEOJing
SEO Jing26. 03. 14.

폰트는 왜 메인 페이지에서만 적용이 안되고 있었을까?.

Tailwind v4 환경에서 폰트가 메인 페이지에서만 적용되지 않던 원인과 Hydration Mismatch 이슈를 정리했다.

26. 03. 14.SEOJing
SEO Jing26. 03. 14.

MDX DOM 트리 파싱하기.

MDX 파일의 경로 탐색 로직과 콘텐츠 트리 생성 과정을 정리했다.

26. 03. 14.SEOJing
SEO Jing26. 03. 14.

결국 Node.js 까지 와버렸다.

MDX 파일 구조를 JSON으로 변환하기 위해 Node.js의 fs 모듈을 배워봤다.

26. 03. 14.SEOJing
SEO Jing26. 03. 14.

Day 2 - 블로그 스켈레톤과 MDX 파싱.

SEO Jing 개발 둘째 날. 디자인 시스템 확장, 블로그 스켈레톤, 폰트 이슈 해결.

26. 03. 14.SEOJing
SEO Jing26. 03. 13.

전체적인 플로우.

SEO Jing 프로젝트의 기술 스택 선정과 전체적인 개발 플로우 정리.

26. 03. 13.SEOJing
SEO Jing26. 03. 13.

Storybook으로 디자인 시스템 테스팅하기.

Storybook의 사용법과 디자인 시스템 개발에서의 장점을 정리했다.

26. 03. 13.SEOJing
SEO Jing26. 03. 13.

MDX가 뭘까?.

MDX의 개념과 블로그에서 활용하는 이유를 정리했다.

26. 03. 13.SEOJing
SEO Jing26. 03. 13.

Day 1 - 디자인 컨셉과 디자인 시스템.

SEO Jing 개발 첫째 날. 디자인 컨셉 설정과 디자인 시스템 구축을 시작했다.

26. 03. 13.SEOJing
SEO Jing26. 03. 13.

기술 블로그를 직접 제작하게 된 이유.

SEO Jing을 개발하게 된 이유입니다.

26. 03. 13.SEOJing
SEO Jing26. 03. 13.

왜 자꾸 프로젝트가 중단되는지.

프로젝트가 중단되는 이유에 대한 자기 회고입니다.

26. 03. 13.SEOJing
(
.
cloneNode
(
true
)
)
;
return measurer.firstElementChild!.getBoundingClientRect().height;
}
appendChild
(
)
;
currentHeight += elementHeight;
}
}
for (const li of Array.from(cloned.children)) {
const liHeight = measure(li);
if (
currentHeight + liHeight > availableHeight &&
currentSlide.children.length > 0
) {
slides.push(currentSlide);
[currentSlide, currentHeight] = [document.createElement("div"), 0];
}
// li를 감싸는 동일 태그(ul/ol) 컨테이너에 넣기
let wrapper = currentSlide.querySelector(
`:scope > ${element.tagName.toLowerCase()}:last-child`,
);
if (!wrapper) {
wrapper = document.createElement(element.tagName.toLowerCase());
wrapper.className = cloned.className; // 원본 스타일 복사
currentSlide.appendChild(wrapper);
}
wrapper.appendChild(li.cloneNode(true));
currentHeight += liHeight;
}
continue;
}
Date
.
now
(
)
-
.
current
;
if (elapsed < LONG_PRESS_MS) {
// 짧은 탭 → 슬라이드 이동
side === "left" ? goToPrev() : goToNext();
}
// 1.5초 이상이면 타이머가 이미 onClose를 호출했으므로 아무것도 안 한다
};