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에 따라 해당하는 함수를 호출하는 것을 볼 수 있습니다. 함수를 호출하는데 까지 성공했다면, 아래와 같이 사용자 채팅메시지를 통해 차트를 생성하는 기능까지 완성입니다. -![사용자 채팅메시지를 기반으로 차트 생성](/create-chart.gif) + > Gemini API는 사용량이 워낙 많아서 간헐적으로 실패할 수 있습니다. 또, 저희는 Free Tier라서 사용량 초과 문제도 발생할 수 있습니다. > 그러니 오류가 발생하는 경우, VS Code의 터미널에서 어떤 오류 메시지가 발생하는지 확인해주셔야 합니다. @@ -872,7 +878,12 @@ export async function requestAi( 이제 채팅패널에서 차트의 색을 바꾸거나 그리드를 없애달라고 요청하면 마법같이 반영되는 것을 확인할 수 있습니다. -![채팅메시지를 통해 차트를 수정하는 화면](/update-chart.gif) + ## 마치며 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가 걸려있습니다. 해당 메서드가 비동기라, 서버에서 청크를 보낼 때까지 이벤트 루프의 다른 태스크를 처리합니다. 따라서 해당 코드로 인해 화면이 블로킹 될 일은 없습니다. -![SSE 방식으로 메시지를 조각 단위로 받고 이를 출력하는 화면](/sse-exam.gif) + ### 개발자 도구에서 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; // 마크다운 `![alt](src)` 문법으로 들어오는 이미지는 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로 내용을 설명한다. */} + + ); +}