Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/blog/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Binary file removed apps/blog/public/create-chart.gif
Binary file not shown.
Binary file added apps/blog/public/create-chart.mp4
Binary file not shown.
Binary file removed apps/blog/public/sse-exam.gif
Binary file not shown.
Binary file added apps/blog/public/sse-exam.mp4
Binary file not shown.
Binary file removed apps/blog/public/update-chart.gif
Binary file not shown.
Binary file added apps/blog/public/update-chart.mp4
Binary file not shown.
15 changes: 13 additions & 2 deletions apps/blog/src/app/(main)/posts/ai/ai-tool/page.mdx
Original file line number Diff line number Diff line change
@@ -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가 내 애플리케이션의 함수를 직접 호출하게 하기',
Expand Down Expand Up @@ -642,7 +643,12 @@ const handleSend = async (content: string) => {
앞서 만든 서버액션인 requestAi를 호출하는 것을 볼 수 있는데요,
이 때 결과인 result에서 functionCall에 따라 해당하는 함수를 호출하는 것을 볼 수 있습니다. 함수를 호출하는데 까지 성공했다면, 아래와 같이 사용자 채팅메시지를 통해 차트를 생성하는 기능까지 완성입니다.

![사용자 채팅메시지를 기반으로 차트 생성](/create-chart.gif)
<PostVideo
src="/create-chart.mp4"
caption="사용자 채팅메시지를 기반으로 차트 생성"
width={1340}
height={616}
/>

> Gemini API는 사용량이 워낙 많아서 간헐적으로 실패할 수 있습니다. 또, 저희는 Free Tier라서 사용량 초과 문제도 발생할 수 있습니다.
> 그러니 오류가 발생하는 경우, VS Code의 터미널에서 어떤 오류 메시지가 발생하는지 확인해주셔야 합니다.
Expand Down Expand Up @@ -872,7 +878,12 @@ export async function requestAi(

이제 채팅패널에서 차트의 색을 바꾸거나 그리드를 없애달라고 요청하면 마법같이 반영되는 것을 확인할 수 있습니다.

![채팅메시지를 통해 차트를 수정하는 화면](/update-chart.gif)
<PostVideo
src="/update-chart.mp4"
caption="채팅메시지를 통해 차트를 수정하는 화면"
width={640}
height={364}
/>

## 마치며

Expand Down
8 changes: 7 additions & 1 deletion apps/blog/src/app/(main)/posts/ai/sse-exam/page.mdx
Original file line number Diff line number Diff line change
@@ -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)',
Expand Down Expand Up @@ -283,7 +284,12 @@ const onClick = async () => {
잘 보시면 reader.read()에서 await가 걸려있습니다. 해당 메서드가 비동기라, 서버에서 청크를 보낼 때까지 이벤트 루프의 다른 태스크를 처리합니다.
따라서 해당 코드로 인해 화면이 블로킹 될 일은 없습니다.

![SSE 방식으로 메시지를 조각 단위로 받고 이를 출력하는 화면](/sse-exam.gif)
<PostVideo
src="/sse-exam.mp4"
caption="SSE 방식으로 메시지를 조각 단위로 받고 이를 출력하는 화면"
width={642}
height={416}
/>

### 개발자 도구에서 Network 요청 확인해보기.

Expand Down
23 changes: 23 additions & 0 deletions apps/blog/src/components/post-detail/post-image.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { render, screen } from '@testing-library/react';
import { PostImage } from './post-image';

describe('PostImage', () => {
it('기본은 lazy 로딩이다(priority 미지정 시 loading="lazy")', () => {
render(<PostImage src="/sample.png" alt="샘플 이미지" />);

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(<PostImage src="/sample.png" alt="첫 화면 이미지" priority />);

const img = screen.getByRole('img', { name: '첫 화면 이미지' });
// 첫 화면(LCP) 이미지에만 명시적으로 우선순위를 부여할 수 있어야 한다.
// (jsdom에서는 fetchpriority가 attribute로 반영되지 않으므로 loading 부재로 검증한다.)
expect(img).not.toHaveAttribute('loading');
});
});
10 changes: 8 additions & 2 deletions apps/blog/src/components/post-detail/post-image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className="block text-center">
<Image
Expand All @@ -20,7 +24,9 @@ export function PostImage({ src, alt, blur }: PostImageProps) {
height={400}
// 고정 width/height 대비 표시폭이 달라져도 종횡비 경고가 나지 않도록 CSS로 비율을 유지한다.
style={{ width: '100%', height: 'auto' }}
priority
// 본문 컨테이너(max-w-[1024px] + 좌우 패딩) 기준 실제 표시폭을 알려 과대 다운로드를 막는다.
sizes="(max-width: 1024px) 100vw, 944px"
priority={priority}
/>
<span className="block text-gray-600 text-center leading-[28px] italic py-5 break-words">
&quot;{alt}&quot;
Expand Down
107 changes: 107 additions & 0 deletions apps/blog/src/components/post-detail/post-video.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<PostVideo src="/sse-exam.mp4" caption="SSE 예제 화면" width={642} height={416} />,
);

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(<PostVideo src="/sse-exam.mp4" caption="SSE 예제 화면" width={642} height={416} />);

// 뷰포트 진입 전에는 재생하지 않는다(초기 대역폭 절약).
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(<PostVideo src="/sse-exam.mp4" caption="SSE 예제 화면" width={642} height={416} />);

// 동작 줄이기 설정 시 뷰포트 관찰 자체를 시작하지 않아야 한다(controls로만 재생).
expect(intersectionCallback).toBeUndefined();
expect(playMock).not.toHaveBeenCalled();
});

it('캡션 텍스트를 본문에 함께 노출한다', () => {
render(<PostVideo src="/create-chart.mp4" caption="차트 생성 화면" width={1340} height={616} />);

// PostImage와 동일한 캡션 표기(따옴표 감싸기)를 유지한다.
expect(screen.getByText('"차트 생성 화면"')).toBeInTheDocument();
});
});
81 changes: 81 additions & 0 deletions apps/blog/src/components/post-detail/post-video.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLVideoElement>(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 (
<span className="block text-center">
{/* 음성 트랙이 없는 화면 녹화 영상이므로 캡션 트랙 대신 aria-label로 내용을 설명한다. */}
<video
ref={videoRef}
className="rounded-md mx-auto shadow-xl"
src={src}
width={width}
height={height}
// PostImage와 동일하게 표시폭이 달라져도 종횡비를 유지한다.
style={{ width: '100%', height: 'auto' }}
// 초기 로드에서는 첫 프레임 표시에 필요한 메타데이터만 받고, 본편은 재생 시점에 받는다.
preload="metadata"
// 무한 반복 자동재생 영상의 일시정지 수단이자, 자동재생 차단 환경의 재생 수단.
controls
loop
muted
playsInline
aria-label={caption}
/>
<span className="block text-gray-600 text-center leading-[28px] italic py-5 break-words">
&quot;{caption}&quot;
</span>
</span>
);
}
Loading