Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Deno 팀에서 만든 웹 프레임워크 Fresh

Fresh 프레임워크Deno 팀에서 만든 JavaScript/TypeScript 웹 프레임워크다. Deno에서는 엣지 컴퓨팅을 위해 Deno Deploy를 만들고 있으므로 엣지 컴퓨팅의 경험을 높이기 위해 만든 걸로 보이지만 꼭 Deno Deploy에서만 사용할 수 있는 것은 아니다. 5월부터 만들기 시작했으니 아직 6개월도 안 된 초기 프로젝트라고 할 수 있다. 아직 관련 정보가 많지 않기 때문에 공식 문서를 참고해서 정리했다.

Deno 홈페이지

아직 Fresh가 맘에 든다기보다는 Deno를 쓰면서 프레임워크를 찾다가 Deno 팀에서 만든 프레임워크라서 사용해 보는 중이다. 아직 초기이고 문서도 공식 문서밖에 없어서 좀 다르게 해보는 건 어려워서 시키는 것만 해보려고 하고 있다. Deno Company의 엣지 컴퓨팅 서비스인 Deno Deploy에도 최적화되어 있는데 이 글에서는 다루지 않는다.

Fresh 시작하기

Fresh도 처음 프로젝트를 시작할 수 있는 보일러플레이트 코드를 제공해 주는데 이는 Deno로 실행할 수 있다.(Deno 1.27.0를 사용했다.) Deno의 특징과 연결되어 있어서 Deno도 어느 정도 알 필요가 있다.

deno run -A -r https://fresh.deno.dev <Project Name> 명령어로 프로젝트를 생성할 수 있는데 -A는 모든 권한을 허용하는 옵션이고 -r은 캐시가 있더라고 다시 로드하는 옵션인데 https://fresh.deno.dev의 패키지가 갱신될 수 있으니 이 옵션을 준 것이다.

$ deno run -A -r https://fresh.deno.dev fresh-example

  Fresh: the next-gen web framework.

Let's set up your new Fresh project.

Fresh has built in support for styling using Tailwind CSS. Do you want to use this? [y/N] N
Do you use VS Code? [y/N] N
The manifest has been generated for 3 routes and 1 islands.

Project initialized!

Enter your project directory using cd fresh-example.
Run deno task start to start the project. CTRL-C to stop.

Stuck? Join our Discord https://discord.gg/deno

Happy hacking! 

프로젝트 설정을 위해 몇 가지를 묻는데 나중에 설명하겠지만 Fresh는 Tailwind CSS를 적극적으로 지원한다. 이를 같이 설정할 것인지 묻는데 여기서는 일단 사용하지 않음을 선택했고 난 VS Code도 쓰지 않으니 이 역시 선택하지 않았다. 그러면 3개의 라우트와 1개의 아일랜드로 프로젝트가 초기화되었다고 안내가 나온다.

위에서 deno run을 실행할 때 Fresh의 홈페이지인 https://fresh.deno.dev를 지정한 게 의아할 수 있다. 이 주소를 curl로 확인해 보면 307 Temporary Redirect로 Deno 모듈인 https://deno.land/x/fresh@1.1.2/init.ts로 리다이렉트를 시키는 것을 알 수 있다. 프로젝트를 초기화할 때 대표 도메인을 그대로 쓰게 하면서 최신 버전으로 리다이렉트하게 하는 목적이 있는데 좀 숨겨진 느낌이라 아주 좋은 방법은 모르겠다.

$ curl -i https://fresh.deno.dev
HTTP/2 307
location: https://deno.land/x/fresh@1.1.2/init.ts
content-type: text/plain;charset=UTF-8
vary: Accept-Encoding
date: Thu, 03 Nov 2022 16:45:13 GMT
content-length: 54
server: deno/asia-northeast3-b

Redirecting to https://deno.land/x/fresh@1.1.2/init.ts

브라우저에서는 웹사이트가 떴는데 아래처럼 accept 헤더를 지정하면 200 OK로 웹사이트가 반환되는 것을 볼 수 있다.

$ curl -i -H 'accept: text/html' https://fresh.deno.dev
HTTP/2 200
content-type: text/html; charset=utf-8
vary: Accept-Encoding
date: Thu, 03 Nov 2022 16:45:44 GMT
content-length: 49218
server: deno/asia-northeast3-b

<!DOCTYPE html><html lang="en"><head><meta charSet="UTF-8" />
...중간 생략...
</html>

다시 Fresh 프로젝트로 돌아와서 생성된 폴더에 들어가서 deno task start를 실행하면 서버를 띄울 수 있다.

$ cd fresh-example

$ deno task start
Task start deno run -A --watch=static/,routes/ dev.ts
Watcher Process started.
The manifest has been generated for 3 routes and 1 islands.
Listening on http://localhost:8000/

Fresh 보일러프로젝트 웹사이트

예제 프로젝트를 볼 수 있고 +/- 버튼을 누르면 옆의 숫자도 바뀌는 걸 볼 수 있다.

Fresh 프로젝트 살펴보기

Fresh 프레임워크는 유연한 프레임워크라기 보다는 opinionated한 프레임워크라서 페이지 로딩 시간을 최소한으로 줄이고 클라이언트에서 수행되는 작업도 최대한 줄이려는 목표를 가지고 있다. 그래서 다른 프레임워크와는 좀 다른 특징이 있다.

  • Deno를 사용하므로 TypeScript을 쓰더라도 별도의 빌드 과정은 없다.
  • 요즘 프론트엔드 추세와는 달리 기본적으로 서버측 렌더링하는 구조다.
  • 아일랜드 아키텍처를 이용해서 선택적인 클라이언트 하이드레이션을 할 수 있다.
  • React와 같은 API를 쓰지만 가벼운 Preact를 사용한다.

위 예제에서 생성된 디렉터리 구조는 다음과 같다.

├── README.md
├── components
│   └── Button.tsx
├── deno.json
├── dev.ts
├── fresh.gen.ts
├── import_map.json
├── islands
│   └── Counter.tsx
├── main.ts
├── routes
│   ├── [name].tsx
│   ├── api
│   │   └── joke.ts
│   └── index.tsx
└── static
    ├── favicon.ico
    └── logo.svg
  • dev.ts: 개발용으로 사용하는 시작 파일이다. 개발 중에는 이 파일로 서버를 띄우는데 앞에서 실행한 deno task startdeno run -A --watch=static/,routes/ dev.ts를 실행한다.(deno.json 파일에서 볼 수 있다.) 파일명은 꼭 dev.ts일 필요는 없지만, 관례상 dev.ts를 사용한다.
  • main.ts: 프로젝트의 시작 파일로 배포 시 이 파일을 실행해서 서버를 띄운다.. 이 파일명도 꼭 main.ts일 필요는 없지만, 관례상 main.ts를 사용한다. 앞의 dev.ts 파일로 내부적으로는 main.ts를 바라보고 있다.
  • fresh.gen.ts: 뒤에서 더 설명하겠지만 라우팅과 아일랜드 정보를 담고 있는 manifest 파일이다. dev.ts로 서버를 띄울 때 routes, islands 디렉터리를 기반으로 자동 생성되므로 직접 수정할 일은 없다.(다른 말로 말하면 이 두 디렉터리의 내용을 수정하면 dev.ts를 이용해서 업데이트해 주어야 한다.)
  • routes/: 라우팅을 담고 있는 디렉터리로 파일시스템 기반의 라우팅을 사용하므로 파일 구조가 URL 경로가 된다. 이 디렉터리 내의 코드는 클라이언트에 전송되지 않는다.
  • islands/: 뒤에서 설명하겠지만 Fresh는 아일랜드 아키텍처를 사용하는데 이 아일랜드 컴포넌트는 이 디렉터리에 들어있다. 아일랜드 컴포넌트는 클라이언트와 서버 모두에서 실행될 수 있기 때문에 클라이언트에서 인터렉티브한 기능을 구현할 때 사용한다.
  • static/: 이 디렉터리의 파일은 정적 에셋으로 제공되고 routes/와 겹치더라도 static/이 우선된다. 파일 확장자 기반으로 Content-Type 헤더가 할당되고 ETag도 자동으로 추가한다.
  • components/: islands/에서 사용하는 컴포넌트가 담긴 디렉터리다.

라우팅

새로운 라우팅을 추가하려면 routes/ 디렉터리 아래 파일을 추가해야 한다. Next.js를 사용해봤다면 익숙할 것이다. 생성된 프로젝트에 생성된 라우팅이 있지만 이해를 돕기 위해 routes/about.tsx 파일을 추가해보자.

// routes/about.tsx
export default function AboutPage() {
  return (
    <main>
      <h1>About</h1>
      <p>This is the about page.</p>
    </main>
  );
}

HTML 페이지를 렌더링 하려면 export default로 JSX 컴포넌트를 익스포트하면 된다. 다시 말하지만, Fresh는 Preact를 사용한다.

여기서는 routes/about.tsx을 추가했으니 /about 경로로 위 페이지가 서빙된다. 파일시스템으로 라우팅할 때 몇 가지 패턴이 있다.

  • 파일 확장자는 무시되고 index 파일은 해당 경로로 취급된다.
  • [, ]로 감싼 파일은 패스 파라미터로 사용되어 동적인 이름과 매칭된다.
  • 경로 마지막에 [...path].ts 같은 파일이 있으면 와일드카드로 처리되어서 하위 경로 모두와 매칭된다.
$ deno task start
Task start deno run -A --watch=static/,routes/ dev.ts
Watcher Process started.
The manifest has been generated for 4 routes and 1 islands.
Listening on http://localhost:8000/

다시 서버를 시작하면 아까와 달리 4 routes(아까는 3개였다)라고 나오는걸 볼 수 있고 fresh.gen.ts 파일에도 ./routes/about.tsx에 한 라우팅이 자동으로 추가된 것을 볼 수 있다. http://localhost:8000/about에 접속하면 작성한 페이지를 볼 수 있다.

About 페이지

좀 더 동적인 페이지를 위해 보일러플레이트 코드에 생성된 routes/[name].tsx 파일을 보자. [name].tsx 패턴을 사용했으므로 /Outsider, /Wayland 같은 경로와 매핑되고 이 경로는 name 파라미터로 들어온다. 이 코드를 보면 PageProps을 이용해서 props.params.name으로 패스 파라미터를 받은 걸 알 수 있다.

// routes/[name].tsx
import { PageProps } from "$fresh/server.ts";
export default function Greet(props: PageProps) {
  return <div>Hello {props.params.name}</div>;
}

http://localhost:8000/outsider로 접속하면 아래 화면을 볼 수 있다.

outsider 경로로 접속한 페이지

간단한 라우팅을 살펴봤는데 라우팅은 실제로는 핸들러와 페이지 컴포넌트로 이루어져 있고 지금까지 본 것은 페이지 컴포넌트다.

핸들로는 Request => ResponseRequest => Promise<Response> 형식의 함수로 해당 경로로 요청이 들어오면 호출된다. 응답 객체는 직접 생성할 수도 있고 페이지 컴포넌트가 렌더링 되면서 생성될 수도 있는데 앞에 봤듯이 커스텀 핸들러를 정의하지 않았다면 페이지 컴포넌트를 렌더링하는 기본 핸들러가 사용된 것이다.

커스텀 핸들러를 정의하려면 handler라는 이름으로 익스포트 해야 한다. routes/[name].tsx에 커스텀 핸들러를 추가해보자.

// routes/[name].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
export const handler: Handlers = {
  async GET(req, ctx) {
    const resp = await ctx.render();
    resp.headers.set("X-Custom-Header", "Hello");
    return resp;
  }
};
export default function Greet(props: PageProps) {
  return <div>Hello {props.params.name}</div>;
}

이 페이지에 접근하면 아까와 동일하게 페이지는 표시되지만 응답 헤더에 X-Custom-Header가 추가된 것을 볼 수 있다. handler에서 GET 요청에 대한 처리를 추가했고 ctx.render()는 아래 Greet 페이지 컴포넌트를 렌더링하는 부분이다. 렌더링한 응답에 헤더를 추가하고 반환했다.

HTTP 응답헤더에 커스텀 헤더가 추가된 화면

핸들러의 사용 방법을 알기 위해 API로 데이터를 가져와서 렌더링하도록 이 페이지를 수정해 보자. 페이지 컴포넌트의 렌더링은 동기로 수행되기 때문에 데이터를 가져오려면 핸들러에서 비동기로 데이터를 가져온 뒤에 ctx.render()에 인자로 전달해 주어야 한다. routes/[name].tsx 페이지를 GitHub의 사용자 정보를 가져와서 보여주도록 수정해 보자.

// routes/[name].tsx
import { Handlers, PageProps } from "$fresh/server.ts";
interface User {
  login: string;
  name: string;
  avatar_url: string;
}
export const handler: Handlers<User | null> = {
  async GET(req, ctx) {
    const { name } = ctx.params;
    const resp = await fetch(`https://api.github.com/users/${name}`);
    if (resp.status === 404) {
      return ctx.render(null);
    }
    const user: User = await resp.json();
    return ctx.render(user);
  }
};
export default function Greet({ data }: PageProps<User | null>) {
  if (!data) {
    return <h1>User not found</h1>
  }
  return (<div>
    <img src={data.avatar_url} width={64} height={64} />
    <h1>{data.name}</h1>
    <p>{data.login}</p>
  </div>);
}

경로의 name 파라미터의 값으로 https://api.github.com/users/${name}에서 사용자 정보를 가져온 뒤 ctx.render(user)에 전달해 주었다. 이렇게 전달한 값은 PagePropsdata로 가져올 수 있다.

GitHub에서 정보를 불러온 화면

http://localhost:8000/outsideris로 접근하면 GitHub 사용자 정보가 잘 표시된 것을 볼 수 있다.

아일랜드 아키텍처

지금까지 본 페이지는 클라이언트에 자바스크립트가 전혀 없고 모두 서버에서 렌더링해서 내려주었다. 요즘은 프론트엔드가 클라이언트 측 렌더링(CSR)하고 있는데 Fresh를 서버 측 렌더링(SSR)을 더 중점적으로 선택하고 있는 게 다른 프레임워크와는 다른 점이다. 하지만 클라이언트에도 상호작용을 위해서 자바스크립트가 필요한데 Fresh는 아일랜드 아키텍처라는 것을 채택했다. 페이지는 서버에서 렌더링해서 내려주지만, 상호작용이 필요한 부분을 섬처럼 별도 취급해서 이 부분에는 JavaScript를 내려주는 형태라고 생각할 수 있다. 그래서 앞에서 islands라는 폴더가 있었다.

클라이언트에서도 렌더링 되는 "아일랜드 컴포넌트"는 islands/ 디렉터리에 있어야 하고 파스칼 케이스(예: ConvertUnit)나 케밥 케이스(예: convert-unit)로 정의해야 한다.

예시로 만들어진 프로젝트에도 버튼으로 카운터를 조정하는 islands/Counter.tsx가 있었다.

import { useState } from "preact/hooks";
import { Button } from "../components/Button.tsx";
interface CounterProps {
  start: number;
}
export default function Counter(props: CounterProps) {
  const [count, setCount] = useState(props.start);
  return (
    <div>
      <p>{count}</p>
      <Button onClick={() => setCount(count - 1)}>-1</Button>
      <Button onClick={() => setCount(count + 1)}>+1</Button>
    </div>
  );
}

일반적으로 볼 수 있는 React 컴포넌트다.(물론 여기서는 preact다.)

// routes/index.tsx
import Counter from "../islands/Counter.tsx";

export default function Home() {
  return (
    <>
      {/*생략*/}
      <Counter start={3} />
    </>
  );
}

사용할 때도 아일랜드 컴포넌트를 불러와서 사용하면 되고 이 아일랜드 컴포넌트는 클라이언트에서 다시 하이드레이션 된다. 대신 서버에서 렌더링 되는 페이지에서 아일랜드 컴포넌트를 사용하는 것이기 때문에 컴포넌트에 전달되는 props는 반드시 JSON으로 직렬화할 수 있어야 한다. 위에서는 start로 숫자를 넘겨서 상관없지만 Date 객체를 넘긴다고 하더라도 문자열이 되고 함수나 커스텀 클래스 등은 전달할 수 없다. children도 직렬화할 수 없는 VNodes이므로 아일랜드 컴포넌트에 전달할 수 없다.

이제 이 아일랜드 컴포넌트로 버튼을 누를때 마다 숫자가 바뀌는 것을 확인할 수 있다.

2022/11/05 20:39 2022/11/05 20:39