출처: https://alexkates.dev/server-side-rendering-ssr-with-bun-and-react
많은 분들이 기다려온 자바스크립트의 맥가이버, 번(Bun)이 드디어 1.0 버전으로 출시되며 판도를 바꾸고 있습니다. 처음 들으신 분들을 위해 설명하자면, 번은 빠른 속도를 위해 설계된 올인원 자바스크립트 런타임 및 툴킷입니다. 번들러, 테스트 러너, 네이티브 타입스크립트 및 JSX 지원뿐만 아니라 Node.js 호환 패키지 매니저까지 포함되어 있습니다.
이 가이드에서는 번 1.0의 잠재력을 최대한 활용하기 위해 번 1.0의 세계로 뛰어들어보려 합니다. 다음 내용을 다룰 예정입니다.
이 가이드에서 사용된 모든 코드는 아래 링크에서 확인하실 수 있습니다.
https://github.com/alexkates/ssr-bun-react
다른 리포지토리를 건드리지 않고도 현재 설치된 노드와 함께 번을 설치할 수 있습니다.
# 번 설치하기
curl -fsSL https://bun.sh/install | bash
다음으로 새로운 번 프로젝트를 세팅해 보겠습니다.
# 프로젝트 셋업
mkdir bun-httpserver
cd bun-httpserver
bun init
다음 스크린샷에서 볼 수 있듯이 bun init
을 사용하면 프로젝트의 기본 구조를 만들 수 있습니다. yarn, npm 또는 pnpm lock 파일을 대신하는 새로운 bun.lockb
파일이 생긴 것을 확인할 수 있습니다. 또한 index.ts
와 tsconfig.json
이 기본적으로 생성되므로 추가 설정 없이 타입스크립트를 지원합니다.
믿기 어렵겠지만, 첫 번째 번 서버를 생성하는 것은 매우 간단합니다. 코드 몇 줄만 입력하는 것 만으로 바로 시작할 수 있습니다.
const server = Bun.serve({
port: 3000,
fetch(req) {
return new Response(`Bun!`);
},
});
console.log(`Listening on http://localhost:${server.port} ...`);
각 줄의 코드를 조금 더 자세히 살펴보겠습니다.
const server = Bun.serve({ ... });
: 이 코느는 Bun.serve()
를 사용해 서버를 초기화하고 3000
번 포트에서 수신 대기하도록 설정합니다.port: 3000,
: 서버가 포트 3000
에서 수신 대기하도록 지정합니다.fetch(req) { ... }
: 들어오는 모든 HTTP 요청을 처리하는 함수를 정의합니다. 요청이 들어오면 "Bun!"이라는 텍스트가 포함된 새 HTTP 응답을 반환합니다.return new Response(Bun!);
: "Bun!" 텍스트로 새 HTTP 응답 객체를 생성합니다.console.log(
Listening on http://localhost:${server.port} ...);
: 서버가 수신 대기 중임을 나타내는 메시지를 콘솔에 기록합니다. 템플릿 리터럴을 사용하여 포트 번호를 동적으로 삽입합니다.이제 전체 프로젝트가 다음 스크린샷과 같이 되었을 것입니다.
이제부터가 진짜 재미있는 부분입니다. 리액트와 번으로 서버 사이드 렌더링(SSR)을 구현해 볼 것입니다. 이 섹션에서는 서버 사이드 렌더링(줄여서 SSR이라고도 합니다)의 복잡한 부분을 리액트와 번을 사용해 자세히 살펴보겠습니다.
yarn에 익숙하다면 여기에서도 편안함을 느낄 수 있을 것입니다. 번에서 패키지를 추가하려면 add
명령을 사용하기만 하면 됩니다. dev 모드에 종속적인 패키지를 추가하고 싶으시다면 -d
플래그를 추가하면 됩니다.
bun add react react-dom
bun add @types/react-dom -d
다음으로 기존 index.ts
서버 파일을 index.tsx
로 전환하겠습니다. 이렇게 하면 JSX 요소를 직접 반환할 수 있습니다.
mv index.ts index.tsx
변경된 index.tsx
파일에서는 react-dom/server
의 renderToReadableStream
을 사용해 Pokemon
컴포넌트를 렌더링하고 있습니다. 그 후 이 스트림을 Response
객체로 감싸고 콘텐츠 타입을 "text/html"로 설정합니다.
import { renderToReadableStream } from "react-dom/server";
import Pokemon from "./components/Pokemon";
Bun.serve({
async fetch(request) {
const stream = await renderToReadableStream(<Pokemon />);
return new Response(stream, {
headers: { "Content-Type": "text/html" },
});
},
});
console.log("Listening ...");
자, 더 많은 일이 벌어지고 있습니다. 한번 살펴보겠습니다.
import { renderToReadableStream } from "react-dom/server";
: 리액트 서버 사이드 렌더링을 위해 react-dom/server
패키지에서 renderToReadableStream
함수를 가져옵니다.import Pokemon from "./components/Pokemon";
: 상대 경로에서 Pokemon
이라는 이름의 리액트 컴포넌트를 가져옵니다.Bun.serve({ ... });
: Bun.serve()
: 메소드를 사용해서 HTTP 서버를 설정합니다. 여기에는 들어오는 HTTP 요청을 처리하기 위한 비동기 fetch
함수가 포함되어 있습니다.async fetch(request) { ... }
: 서버로 들어오는 HTTP 요청에 대해 트리거되는 비동기 함수입니다.const stream = await renderToReadableStream(<Pokemon />);
: 비동기적으로 Pokemon
리액트 컴포넌트를 읽을 수 있는 스트림으로 렌더링합니다.return new Response(stream, { ... });
: 읽을 수 있는 스트림이 포함된 새 HTTP 응답 객체를 반환하고 "Content-Type" 헤더를 "text/html"로 설정합니다.console.log("Listening ...");
: 서버가 들어오는 요청을 수신 대기 중임을 나타내는 메시지를 콘솔에 출력합니다.마지막으로 간단한 리액트 컴포넌트를 만들어 보겠습니다. 이 컴포넌트는 서버 사이드 렌더링(SSR)되어 클라이언트로 바로 스트리밍됩니다.
import React from "react";
type PokemonProps = {
name?: string;
};
function Pokemon() {
return <div>Bun Forrest, Bun!</div>;
}
export default Pokemon;
다음이 흥미로운 부분입니다. 번 서버를 실행하여 모든 것이 함께 작동하는 것을 확인해 보겠습니다!
bun index.tsx
http://localhost:3000 로 이동하면 SSR Pokemon 컴포넌트가 표시되는 것을 확인할 수 있습니다!
좀 더 어려운 것을 시도해 볼 준비가 되셨나요? 이 섹션에서는 /pokemon
과 /pokemon/[pokemonName]
두 가지 라우트를 만들어 보겠습니다.
/pokemon
으로 이동하면 Pokemon API에 페치 요청이 발생하여 클릭 가능한 앵커 태그 목록으로 렌더링됩니다./pokemon/[pokemonName]
으로 이동하고 특정 포켓몬을 페칭하여 서버 사이드 렌더링(SSR)한 다음 클라이언트로 다시 스트리밍한 페이지를 보게 됩니다.이 업데이트 버전에서는 index.tsx
가 상당한 작업을 하고 있습니다. 이제 동적 라우팅이 포함되어 Pokemon API에서 가져온 포켓몬 리스트를 표시하거나 URL에 따라 특정 포켓몬을 표시할 수 있습니다. 리스트든 개별 포켓몬이든 컴포넌트는 서버 사이드에서 렌더링된 후 클라이언트로 다시 스트리밍됩니다.
import { PokemonResponse } from "./types/PokemonResponse";
import { PokemonsResponse } from "./types/PokemonsResponse";
import { renderToReadableStream } from "react-dom/server";
import Pokemon from "./components/Pokemon";
import PokemonList from "./components/PokemonList";
Bun.serve({
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/pokemon") {
const response = await fetch("https://pokeapi.co/api/v2/pokemon");
const { results } = (await response.json()) as PokemonsResponse;
const stream = await renderToReadableStream(
<PokemonList pokemon={results} />
);
return new Response(stream, {
headers: { "Content-Type": "text/html" },
});
}
const pokemonNameRegex = /^\/pokemon\/([a-zA-Z0-9_-]+)$/;
const match = url.pathname.match(pokemonNameRegex);
if (match) {
const pokemonName = match[1];
const response = await fetch(
`https://pokeapi.co/api/v2/pokemon/${pokemonName}`
);
if (response.status === 404) {
return new Response("Not Found", { status: 404 });
}
const {
height,
name,
weight,
sprites: { front_default },
} = (await response.json()) as PokemonResponse;
const stream = await renderToReadableStream(
<Pokemon
name={name}
height={height}
weight={weight}
img={front_default}
/>
);
return new Response(stream, {
headers: { "Content-Type": "text/html" },
});
}
return new Response("Not Found", { status: 404 });
},
});
console.log("Listening ...");
많은 일이 벌어지고 있습니다. 흥미로운 부분을 더 자세히 살펴보겠습니다.
Bun.serve()
메서드는 HTTP 서버를 세팅하고 들어오는 비동기 fetch
함수로 모든 HTTP 트래픽의 진입점 역할을 수행합니다./pokemon
인 경우 서버는 외부 API에서 포켓몬 목록을 가져와 PokemonList
React 컴포넌트를 HTML로 렌더링합니다. 그런 다음 이 HTML은 클라이언트로 다시 전송됩니다./pokemon/pikachu
)을 지정하는 URL 경로를 확인합니다. 이러한 경로가 감지되면 서버는 특정 포켓몬에 대한 세부 정보를 가져와서 Pokemon
리액트 컴포넌트를 사용하여 렌더링합니다.renderToReadableStream
함수는 리액트 컴포넌트를 읽을 수 있는 스트림으로 변환 후 HTML 응답으로 반환합니다.이 컴포넌트는 포켓몬의 목록을 가져와 클릭 가능한 리스트 아이템으로 변환합니다. 각 리스트 아이템은 클릭 시 사용자를 /pokemon/[name]
으로 라우팅하여 개별 포켓몬 세부 정보를 렌더링하는 앵커 태그입니다.
import React from "react";
function PokemonList({
pokemon,
}: {
pokemon: { name: string; url: string }[];
}) {
return (
<ul>
{pokemon.map(({ name }) => (
<li key={name}>
<a href={`/pokemon/${name}`}>{name}</a>
</li>
))}
</ul>
);
}
export default PokemonList;
Pokemon 컴포넌트는 개별 포켓몬의 키, 몸무게, 이름, 이미지 URL을 가져와서 하나의 포켓몬을 표시하고 싶은대로 나타내는 역할을 담당합니다.
import React from "react";
function Pokemon({
height,
weight,
name,
img,
}: {
height: number;
weight: number;
name: string;
img: string;
}) {
return (
<div>
<h1>{name}</h1>
<img src={img} alt={name} />
<p>Height: {height}</p>
<p>Weight: {weight}</p>
</div>
);
}
export default Pokemon;
이제 서버를 재시작해야 합니다. 이번에는 핫 모듈 리로딩(HMR)을 위해 --watch
플래그를 추가해 보겠습니다. 좋은 소식은 번이 HMR을 지원하기 때문에 이제 nodemon
과는 작별 인사할 수 있다는 것입니다.
bun --watch index.tsx
첫 번째 스크린샷은 /pokemon
으로 이동했을 때 어떤 일이 일어나는지 보여줍니다. 보시다시피 포켓몬 목록이 표시되며, 각 포켓몬은 클릭 가능한 링크입니다. 이 모든 것은 클릭 가능한 이름을 가져와 표시하는 PokemonList
컴포넌트 덕분입니다.
두 번째 스크린샷은 /pokemon/charmander
로 이동합니다. 이번에는 Pokemon
컴포넌트가 중심이 되어 파이리(Charmander)의 키, 몸무게, 이미지가 표시됩니다. 물론 모든 것은 서버 사이드에서 렌더링된 결과입니다.
지금까지 이 글을 따라 코딩을 같이 해왔다면 바로 스스로에게 박수를 보내주세요! 여러분은 방금 아래의 일들을 해냈습니다.
작성하신거 보고 따라해봤는데 재밌네요!