diff --git a/apps/blog/next.config.mjs b/apps/blog/next.config.mjs
index 6cdcdb3..6caa20c 100644
--- a/apps/blog/next.config.mjs
+++ b/apps/blog/next.config.mjs
@@ -33,6 +33,10 @@ const workspaceRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx'],
+ images: {
+ // AVIF를 우선 협상해 지원 브라우저에서 전송량을 더 줄이고, 미지원 브라우저는 WebP로 폴백한다.
+ formats: ['image/avif', 'image/webp'],
+ },
// 모노레포에서 워크스페이스 루트 자동 추론으로 인한 경고를 막기 위해 Turbopack 루트를 워크스페이스 루트로 고정한다
turbopack: {
root: workspaceRoot,
diff --git a/apps/blog/public/create-chart.gif b/apps/blog/public/create-chart.gif
deleted file mode 100644
index 95e3e94..0000000
Binary files a/apps/blog/public/create-chart.gif and /dev/null differ
diff --git a/apps/blog/public/create-chart.mp4 b/apps/blog/public/create-chart.mp4
new file mode 100644
index 0000000..ba1b403
Binary files /dev/null and b/apps/blog/public/create-chart.mp4 differ
diff --git a/apps/blog/public/sse-exam.gif b/apps/blog/public/sse-exam.gif
deleted file mode 100644
index 9345fc9..0000000
Binary files a/apps/blog/public/sse-exam.gif and /dev/null differ
diff --git a/apps/blog/public/sse-exam.mp4 b/apps/blog/public/sse-exam.mp4
new file mode 100644
index 0000000..3d48be7
Binary files /dev/null and b/apps/blog/public/sse-exam.mp4 differ
diff --git a/apps/blog/public/update-chart.gif b/apps/blog/public/update-chart.gif
deleted file mode 100644
index c912d59..0000000
Binary files a/apps/blog/public/update-chart.gif and /dev/null differ
diff --git a/apps/blog/public/update-chart.mp4 b/apps/blog/public/update-chart.mp4
new file mode 100644
index 0000000..e7d0ae4
Binary files /dev/null and b/apps/blog/public/update-chart.mp4 differ
diff --git a/apps/blog/src/app/(main)/posts/ai/ai-tool/page.mdx b/apps/blog/src/app/(main)/posts/ai/ai-tool/page.mdx
index a509685..fa2de07 100644
--- a/apps/blog/src/app/(main)/posts/ai/ai-tool/page.mdx
+++ b/apps/blog/src/app/(main)/posts/ai/ai-tool/page.mdx
@@ -1,4 +1,5 @@
import { frontmatter } from '@libs/frontmatter';
+import { PostVideo } from '@components/post-detail/post-video';
export const metadata = frontmatter({
title: 'LLM Tool Calling으로 AI가 내 애플리케이션의 함수를 직접 호출하게 하기',
@@ -642,7 +643,12 @@ const handleSend = async (content: string) => {
앞서 만든 서버액션인 requestAi를 호출하는 것을 볼 수 있는데요,
이 때 결과인 result에서 functionCall에 따라 해당하는 함수를 호출하는 것을 볼 수 있습니다. 함수를 호출하는데 까지 성공했다면, 아래와 같이 사용자 채팅메시지를 통해 차트를 생성하는 기능까지 완성입니다.
-
+
> Gemini API는 사용량이 워낙 많아서 간헐적으로 실패할 수 있습니다. 또, 저희는 Free Tier라서 사용량 초과 문제도 발생할 수 있습니다.
> 그러니 오류가 발생하는 경우, VS Code의 터미널에서 어떤 오류 메시지가 발생하는지 확인해주셔야 합니다.
@@ -872,7 +878,12 @@ export async function requestAi(
이제 채팅패널에서 차트의 색을 바꾸거나 그리드를 없애달라고 요청하면 마법같이 반영되는 것을 확인할 수 있습니다.
-
+
## 마치며
diff --git a/apps/blog/src/app/(main)/posts/ai/sse-exam/page.mdx b/apps/blog/src/app/(main)/posts/ai/sse-exam/page.mdx
index 0ae219f..3743fcc 100644
--- a/apps/blog/src/app/(main)/posts/ai/sse-exam/page.mdx
+++ b/apps/blog/src/app/(main)/posts/ai/sse-exam/page.mdx
@@ -1,4 +1,5 @@
import { frontmatter } from '@libs/frontmatter';
+import { PostVideo } from '@components/post-detail/post-video';
export const metadata = frontmatter({
title: 'ChatGPT처럼 AI 채팅 메시지 스트리밍하기(Server-Sent Events)',
@@ -283,7 +284,12 @@ const onClick = async () => {
잘 보시면 reader.read()에서 await가 걸려있습니다. 해당 메서드가 비동기라, 서버에서 청크를 보낼 때까지 이벤트 루프의 다른 태스크를 처리합니다.
따라서 해당 코드로 인해 화면이 블로킹 될 일은 없습니다.
-
+
### 개발자 도구에서 Network 요청 확인해보기.
diff --git a/apps/blog/src/components/post-detail/post-image.test.tsx b/apps/blog/src/components/post-detail/post-image.test.tsx
new file mode 100644
index 0000000..53de94c
--- /dev/null
+++ b/apps/blog/src/components/post-detail/post-image.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from '@testing-library/react';
+import { PostImage } from './post-image';
+
+describe('PostImage', () => {
+ it('기본은 lazy 로딩이다(priority 미지정 시 loading="lazy")', () => {
+ render();
+
+ const img = screen.getByRole('img', { name: '샘플 이미지' });
+ // priority 남용 시 하단 이미지까지 즉시 preload되어 LCP가 늦어지므로 기본은 lazy여야 한다.
+ expect(img).toHaveAttribute('loading', 'lazy');
+ // 실제 표시폭 힌트(sizes)를 내려 과대 크기 다운로드를 막는다.
+ expect(img).toHaveAttribute('sizes', '(max-width: 1024px) 100vw, 944px');
+ });
+
+ it('priority를 옵트인하면 lazy 로딩이 해제된다(즉시 로딩)', () => {
+ render();
+
+ const img = screen.getByRole('img', { name: '첫 화면 이미지' });
+ // 첫 화면(LCP) 이미지에만 명시적으로 우선순위를 부여할 수 있어야 한다.
+ // (jsdom에서는 fetchpriority가 attribute로 반영되지 않으므로 loading 부재로 검증한다.)
+ expect(img).not.toHaveAttribute('loading');
+ });
+});
diff --git a/apps/blog/src/components/post-detail/post-image.tsx b/apps/blog/src/components/post-detail/post-image.tsx
index 4a885ff..43d2790 100644
--- a/apps/blog/src/components/post-detail/post-image.tsx
+++ b/apps/blog/src/components/post-detail/post-image.tsx
@@ -6,9 +6,13 @@ export interface PostImageProps {
src: string;
// 마크다운 `` 문법으로 들어오는 이미지는 blur를 전달하지 않으므로 선택값으로 둔다.
blur?: PlaceholderValue;
+ // 기본은 lazy 로딩이다. 첫 화면(LCP)에 걸리는 이미지에만 명시적으로 옵트인한다.
+ // 마크다운 문법 이미지는 위치 정보가 없으므로 무조건 priority를 걸면 하단 이미지까지
+ // 즉시 preload되어 대역폭 경쟁으로 오히려 LCP가 늦어진다.
+ priority?: boolean;
}
-export function PostImage({ src, alt, blur }: PostImageProps) {
+export function PostImage({ src, alt, blur, priority }: PostImageProps) {
return (
"{alt}"
diff --git a/apps/blog/src/components/post-detail/post-video.test.tsx b/apps/blog/src/components/post-detail/post-video.test.tsx
new file mode 100644
index 0000000..3ce2dcf
--- /dev/null
+++ b/apps/blog/src/components/post-detail/post-video.test.tsx
@@ -0,0 +1,107 @@
+import { act, render, screen } from '@testing-library/react';
+import { PostVideo } from './post-video';
+
+// jsdom에는 matchMedia/IntersectionObserver가 없으므로 테스트에서 직접 모킹한다.
+// 각 테스트가 reduced-motion 여부와 뷰포트 진입을 제어할 수 있게 상태를 밖으로 꺼내둔다.
+let prefersReducedMotion = false;
+// 컴포넌트가 등록한 IntersectionObserver 콜백. 테스트에서 뷰포트 진입을 흉내낼 때 호출한다.
+let intersectionCallback: IntersectionObserverCallback | undefined;
+
+beforeEach(() => {
+ prefersReducedMotion = false;
+ intersectionCallback = undefined;
+
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: query === '(prefers-reduced-motion: reduce)' && prefersReducedMotion,
+ media: query,
+ }));
+
+ window.IntersectionObserver = jest
+ .fn()
+ .mockImplementation((callback: IntersectionObserverCallback) => {
+ intersectionCallback = callback;
+ return { observe: jest.fn(), disconnect: jest.fn(), unobserve: jest.fn() };
+ });
+});
+
+// 컴포넌트가 등록한 콜백에 "뷰포트 진입" 엔트리를 전달한다.
+function enterViewport() {
+ if (!intersectionCallback) {
+ throw new Error('IntersectionObserver가 등록되지 않았다');
+ }
+ act(() => {
+ if (intersectionCallback) {
+ intersectionCallback(
+ [{ isIntersecting: true } as IntersectionObserverEntry],
+ {} as IntersectionObserver,
+ );
+ }
+ });
+}
+
+describe('PostVideo', () => {
+ it('GIF와 동일한 경험을 위해 loop/muted/playsInline 영상으로 렌더된다', () => {
+ const { container } = render(
+ ,
+ );
+
+ const video = container.querySelector('video');
+
+ // video 엘리먼트가 실제로 렌더되어야 한다(이미지 최적화 파이프라인을 우회).
+ expect(video).not.toBeNull();
+
+ if (!video) {
+ return;
+ }
+
+ expect(video).toHaveAttribute('src', '/sse-exam.mp4');
+ // GIF처럼 무한 반복 재생되어야 한다(자동재생 정책상 muted 필수).
+ expect(video).toHaveAttribute('loop');
+ expect(video.muted).toBe(true);
+ expect(video).toHaveAttribute('playsinline');
+ // autoPlay 속성이 있으면 페이지 로드 즉시 다운로드가 시작되므로 쓰지 않는다(뷰포트 진입 시 play()).
+ expect(video).not.toHaveAttribute('autoplay');
+ // 초기 로드에서는 메타데이터만 받아 하단 영상의 대역폭 경쟁을 막는다.
+ expect(video).toHaveAttribute('preload', 'metadata');
+ // WCAG 2.2.2: 무한 반복 영상의 일시정지 수단이자 자동재생 차단 환경의 재생 수단.
+ expect(video).toHaveAttribute('controls');
+ // 레이아웃 시프트 방지를 위해 원본 픽셀 크기를 예약한다.
+ expect(video).toHaveAttribute('width', '642');
+ expect(video).toHaveAttribute('height', '416');
+ // 스크린리더가 영상 내용을 알 수 있도록 캡션 텍스트를 aria-label로 제공한다.
+ expect(video).toHaveAttribute('aria-label', 'SSE 예제 화면');
+ });
+
+ it('뷰포트에 들어오면 재생을 시작한다', () => {
+ const playMock = jest.fn().mockResolvedValue(undefined);
+ jest.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(playMock);
+
+ render();
+
+ // 뷰포트 진입 전에는 재생하지 않는다(초기 대역폭 절약).
+ expect(playMock).not.toHaveBeenCalled();
+
+ enterViewport();
+
+ expect(playMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('prefers-reduced-motion 설정 시 자동재생하지 않는다', () => {
+ prefersReducedMotion = true;
+ const playMock = jest.fn().mockResolvedValue(undefined);
+ jest.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(playMock);
+
+ render();
+
+ // 동작 줄이기 설정 시 뷰포트 관찰 자체를 시작하지 않아야 한다(controls로만 재생).
+ expect(intersectionCallback).toBeUndefined();
+ expect(playMock).not.toHaveBeenCalled();
+ });
+
+ it('캡션 텍스트를 본문에 함께 노출한다', () => {
+ render();
+
+ // PostImage와 동일한 캡션 표기(따옴표 감싸기)를 유지한다.
+ expect(screen.getByText('"차트 생성 화면"')).toBeInTheDocument();
+ });
+});
diff --git a/apps/blog/src/components/post-detail/post-video.tsx b/apps/blog/src/components/post-detail/post-video.tsx
new file mode 100644
index 0000000..b60985d
--- /dev/null
+++ b/apps/blog/src/components/post-detail/post-video.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+
+export interface PostVideoProps {
+ src: string;
+ // 영상이 보여주는 내용을 설명하는 텍스트. 캡션과 aria-label로 함께 사용한다.
+ caption: string;
+ // 레이아웃 시프트(CLS)를 막기 위해 원본 영상의 픽셀 크기를 받아 종횡비를 예약한다.
+ width: number;
+ height: number;
+}
+
+/**
+ * 본문에서 애니메이션 GIF를 대체하는 영상 컴포넌트.
+ *
+ * muted + loop + playsInline 조합으로 GIF와 동일한 시청 경험을 유지하면서,
+ * H.264(mp4) 인코딩으로 GIF 대비 전송량을 크게 줄인다.
+ * next/image의 최적화 파이프라인(애니메이션 이미지에 비효율적)을 거치지 않는다.
+ *
+ * autoPlay 속성 대신 뷰포트 진입 시 play()를 호출한다.
+ * - 본문 하단 영상이 첫 화면 렌더 전부터 다운로드되어 대역폭을 경쟁하는 문제(#97)를 막고,
+ * - prefers-reduced-motion 설정 시 자동재생을 건너뛴다.
+ * controls를 제공하므로 자동재생이 차단된 환경에서도 직접 재생할 수 있고,
+ * 무한 반복 영상에 일시정지 수단이 생긴다(WCAG 2.2.2).
+ */
+export function PostVideo({ src, caption, width, height }: PostVideoProps) {
+ // 뷰포트 관찰과 play() 호출을 위해 video 엘리먼트 참조를 잡아둔다.
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) {
+ return;
+ }
+
+ // 동작 줄이기 설정 시 자동재생을 하지 않는다. 사용자는 controls로 직접 재생할 수 있다.
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ return;
+ }
+
+ // 뷰포트에 처음 들어올 때 한 번만 재생을 시작한다(그 이후 정지/재생은 사용자 몫).
+ const observer = new IntersectionObserver((entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ // 자동재생이 차단된 환경이면 조용히 실패시키고 controls로 재생하게 둔다.
+ video.play().catch(() => {});
+ observer.disconnect();
+ }
+ });
+
+ observer.observe(video);
+
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {/* 음성 트랙이 없는 화면 녹화 영상이므로 캡션 트랙 대신 aria-label로 내용을 설명한다. */}
+
+
+ "{caption}"
+
+
+ );
+}