Remix로 쉽게 하는 리액트 서버사이드 렌더링

Minjun Kim·2022년 5월 22일
56
post-thumbnail

이 포스트는 책 『리액트를 다루는 기술』 에 수록될 예정입니다.
소스 코드는 여기에서 확인할 수 있습니다.

서버 사이드 렌더링은 UI를 서버에서 렌더링하는 것을 의미합니다. 앞에서 만든 리액트 프로젝트는 기본적으로 클라이언트 사이드 렌더링만을 하고 있습니다. 클라이언트 사이드 렌더링은 UI 렌더링을 브라우저에서 모두 처리하는 것이죠. 즉, 자바스크립트를 불러온다음에 실행이 되어야 우리가 만든 화면이 사용자에게 보여집니다.

CRA로 만든 프로젝트의 개발 서버를 실행한 다음, 크롬 개발자 도구의 Network탭을 열고
http://localhost:3000/ 페이지의 요청에 대한 응답을 보시면 다음과 같이 root 엘리먼트가 비어 있는 것을 확인할 수 있습니다.

즉, 이 페이지는 처음에 빈 페이지라는 뜻이죠. Preview 탭을 누르면 자바스크립트 없이 HTML 결과물을 확인할 수 있는데, “You need to enable JavaScript to run this app.” 라는 문구만 나타납니다. 리액트 프로젝트는 서버 사이드 렌더링을 하지 않으면 이렇게 자바스크립트가 실행되어야 비로소 개발자가 의도한 UI가 사용자에게 보여집니다.

반면, 서버 사이드 렌더링을 하게 된다면 서버 측에서 리액트 컴포넌트의 초기 렌더링을 해주며, 렌더링 결과를 HTML 응답에 넣어서 자바스크립트를 실행하기 전에도 사용자에게 UI를 보여줄 수 있습니다.

1 서버 사이드 렌더링을 하는 이유

서버 사이드 렌더링을 하는 이유는 크게 3가지가 있습니다. 첫 번째는 사용자 경험 개선입니다. 앞서 언급했듯이 클라이언트 사이드 렌더링만 하게 된다면 자바스크립트를 불러오고 실행이 될 때 까지 사용자는 비어있는 화면을 보게 됩니다. 서버 사이드 렌더링을 한다면 자바스크립트를 실행하기 이전에도 페이지의 UI를 볼 수 있기 때문에 사용자의 대기 시간이 줄어들게 됩니다. 쉽게 말하면 페이지의 내용을 사용자에게 더 일찍 보여줄 수 있다는 의미지요.

물론, 자바스크립트를 불러와서 실행할 때 까지 웹 서비스의 인터랙션 기능 (예를 들어서 버튼을 클릭하는 것)은 정상적으로 작동하진 않지만 대부분의 경우엔 사용자 인터랙션이 시작되기 전에 자바스크립트 로딩이 끝나기 때문에 걱정 할 필요가 없습니다.

두 번째는 검색 엔진 최적화입니다. 서버 사이드 렌더링을 하면 구글, 네이버, 다음 등의 검색 엔진이 우리가 만든 웹 애플리케이션의 페이지를 제대로 읽어갈 수 있습니다. 구글 검색 엔진 크롤링 봇의 경우엔 페이지를 수집하면서 자바스크립트를 실행해주기 때문에 서버 사이드 렌더링을 꼭 하지 않아도 페이지를 제대로 긁어갈 수 있다고 합니다. 그럼에도 불구하고, 서버 사이드 렌더링을 해주는게 검색 엔진 최적화에 더욱 좋습니다. 그 이유는 구글 크롤링 봇이 페이지의 모든 데이터가 로딩될 때 까지 기다리지 않을 가능성이 있기 때문에 의도한 데이터가 제대로 수집되지 않을 수 있기 때문입니다. 추가적으로, 서버 사이드 렌더링을 하여 사용자에게 페이지를 더욱 일찍 보여준다면, 사용자의 페이지 이탈률을 개선시킬 수 있는 가능성이 있는데요, 페이지 이탈률은 구글 검색 결과 랭킹에 영향을 주기 때문에, 만약 여러분이 만든 페이지가 검색 결과에 잘 나타나길 바란다면 서버 사이드 렌더링을 하는 것이 좋습니다.

세 번째는 정적 페이지 및 캐싱입니다. 자주 변하지 않는 정적 페이지를 서버 사이드 렌더링을 할 경우 Redis 같은 캐시 시스템을 직접 구축해서 사용하거나, Cloudfront 또는 Cloudflare 같은 CDN에 서버 사이드 렌더링 결과물을 캐싱해서 사용한다면 응답 시간을 아주 효과적으로 단축시킬 수 있습니다.

2. 서버 사이드 렌더링의 단점

첫 번째로, 서버 사이드 렌더링은 원래 브라우저가 해야 할 초기 렌더링을 서버가 대신 수행해주는 것이기 때문에 서버 리소스 부하 관리가 필요합니다. 갑자기 수많은 사용자가 동시에 웹 페이지에 접속하면 서버에 과부하가 발생할 수 있기 때문에 이에 대한 대비가 되어야 합니다. 이는 서버 자동 스케일링, 로드 밸런싱, 캐싱, 그리고 서버리스 기술 등을 통해 해결할 수 있는 문제입니다.

두 번째로, 리액트에서 자체적으로 서버 사이드 렌더링 기능을 제공하긴 하지만, 실제로 이를 도입하려면 상당히 복잡합니다. 특히, 코드 스플리팅, 데이터 불러오기, 정적 파일 관리, 빌드 환경 설정 및 배포 여러가지 문제들이 얽혀서 프로젝트의 복잡도가 굉장히 높아지고, 입문자가 접근하기엔 상당히 어렵습니다.

다행히도, 복잡한 작업을 이를 간단하게 만들어주는 프레임워크들이 존재합니다. 기존에는 Next.js 라는 프레임워크가 서버 사이드 렌더링을 쉽게 해주는 유일한 프레임워크였고, 2021년에 Remix라는 프레임워크가 오픈소스로 공개되어 많은 관심을 받고 있습니다. 이 글에서는 Remix를 사용하여 서버 사이드 렌더링을 하는 방법을 알아보겠습니다. Remix를 선택한 이유는 개발사가 리액트 라우터를 만든 곳과 동일한 곳이기 때문에 React Router와 호환이 아주 잘 되기 때문입니다. Link, Outlet, useLocation, useParams 등 리액트 라우터에서 사용하던 API를 그대로 사용할 수 있어서 새롭게 배워야 할 지식들이 많지 않습니다.

3. 새 Remix 프로젝트 만들기

Remix는 리액트 기반의 웹 프레임워크입니다. 웹 애플리케이션을 만들 때 필요한 다양한 기능을 내장하여 개발자로 하여금 기능 구현에 더 집중할 수 있게 해주며 안정적이고 최적화된 서비스를 제공 할 수 있도록 도움을 줍니다.

새 Remix 프로젝트를 만들어봅시다.

터미널에서 다음과 같이 명령어를 입력해보세요.

$ npx create-remix hello-remix
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix if you're unsure; it's easy to
change deployment targets. Remix App Server
? Do you want me to run `npm install`? No
? TypeScript or JavaScript? JavaScript

$ cd hello-remix
$ yarn
$ yarn dev

CLI에서 프로젝트를 만들 때 어떠한 옵션으로 만들 지 물어보는데 위와 같은 설정을 따라하시고, 프로젝트 경로로 이동하여 yarn 명령어를 입력해서 의존 모듈을 설치해주었습니다. (npm 대신 yarn 을 사용하기 위해 도중에 npm install 을 하지 않도록 하였습니다.) Remix 프로젝트의 경우엔, 개발 서버를 시작 할 때 yarn dev 명령어를 사용합니다.

명령어 실행 후, http://localhost:3000 를 브라우저 주소창에 입력하여 들어가보세요. 다음과 같이 페이지가 잘 나타났나요?

4. 디렉터리 기반 라우팅

우리가 리액트 라우터를 사용할 때 라우트를 구성할 때에는, 컴포넌트 선언 방식으로 설정을 했었던 반면, Remix의 경우에는 컴포넌트의 디렉터리에 기반하여 라우트가 설정이 됩니다. 방금 만들었던 프로젝트를 VS Code를 열어보세요.

app/routes/index.jsx 파일을 열어보시면 Index 라는 컴포넌트가 있습니다. 이 컴포넌트가 바로 우리가 만든 프로젝트의 홈 페이지입니다. 확장자를 jsx 로 작성하는건 JSX를 사용하는 파일과 일반 자바스크립트 코드를 구분하기 위한 컨벤션입니다. 과거에는 리액트 컴포넌트를 꼭 jsx 확장자로 작성해야 했을 때가 있었으나, 지금은 js 로 작성해도 무방합니다. 우리는 앞으로 새 컴포넌트를 작성할 때 이 프로젝트의 기존 컨벤션에 맞춰 jsx 확장자를 사용하도록 하겠습니다.

4.1 새 라우트 만들기

한번 새로운 라우트를 만들어볼까요? app/routes 경로에 about.jsx 파일을 생성해보세요. 라우트를 위한 파일을 만들때는 이렇게 컴포넌트임에도 불구하고 소문자로 작성하는 점 참고해 주세요.

app/routes/about.jsx

export default function About() {
  return <div>! 리믹스! 하이~</div>;
}

위 코드에서는 함수 컴포넌트를 선언함과 동시에 바로 default로 내보내주었습니다. 이 프로젝트에서 기존에 하던 방식을 그대로 유지한 것 뿐이며, 꼭 이렇게 할 필요는 없습니다. 화살표 함수로 선언하고 파일의 하단에서 export default About 이런 식으로 내보내주어도 괜찮습니다. 파일 이름은 소문자이지만, 컴포넌트 이름은 대문자로 시작하도록 이름을 지어주세요. 다 작성 하셨으면 브라우저에서 http://localhost:3000/about 경로를 입력하여 들어가보세요

위와 같이 우리가 만든 컴포넌트가 화면에 잘 나타났나요?

페이지를 이동시키는 링크를 만들 때에는 리액트 라우터와 동일하게 Link 컴포넌트를 사용합니다. Index 라우트를 다음과 같이 변경해보세요.

app/routes/index.jsx

import { Link } from '@remix-run/react';

export default function Index() {
  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <h1>Welcome to Remix</h1>
      <ul>
        <li>
          <Link to="/about">about</Link>
        </li>
      </ul>
    </div>
  );
}

기존의 외부 링크들은 모두 지우고, 우리가 만든 About 라우트로 이동하는 링크를 만들어주었습니다. 리액트 라우터와 다르게 react-router-dom 이 아닌 @remix-run/react 에서 Link 를 불러온다는 점 주의해주세요. 참고로 Remix에서 react-router-dom을 의존하고 있기 때문에, 해당 라이브러리가 이미 설치가 되어 있으며 react-router-dom 에서 Link를 불러와도 정상적으로 작동하기는 합니다.

코드를 작성 후, 브라우저에서 개발자 도구의 Network 탭을 열고 새로고침 해보세요.


맨 위의 http://localhost:3000/ 에 대한 요청에 대한 응답의 Preview를 보면, 위 스크린샷과 같이 비어있는 페이지가 아니라 컴포넌트의 내용이 보여집니다. Response 쪽의 코드를 보아도, body 가 채워져 있는 것을 볼 수 있죠 (코드가 minify 되어있기 때문에 읽기 어려울 수 있는데 좌측 하단의 {} 아이콘을 누르면 코드가 정리됩니다).

그 다음, 우리가 방금 만들었던 about 링크를 눌러보세요.

Link 컴포넌트를 사용하여 About 라우트로 이동을 할 때에는 서버 사이드 렌더링을 다시 하는 것이 아니라, 코드 스플리팅된 About 컴포넌트를 불러오고, 클라이언트 사이드 렌더링을 하고 있는것을 확인 할 수 있습니다.

리믹스를 사용하면 리액트에서 제공하는 lazy 함수를 사용하지 않고도 자동으로 라우트 단위의 코드 스플리팅이 적용됩니다.

4.2 URL 파라미터

리액트 라우터에서 URL 파라미터를 다룰 때에는 Route 컴포넌트의 path 에 "/articles/:id" 이런 식으로 문자열 기반의 파라미터 설정을 했습니다.

Remix를 사용할 때에는 라우트 파일 또는 디렉터리 이름에 $id 와 같은 방식으로 파라미터 설정을 합니다. 한번 예시를 확인해볼까요?

먼저, routes 디렉터리에 articles 라는 경로를 만들고, 그 안에 $id.jsx 파일을 다음과 같이 만들어보세요.

app/routes/articles/$id.jsx

import { useParams } from '@remix-run/react';

export default function Article() {
  const params = useParams();
  return <div>게시글 ID: {params.id}</div>;
}

URL 파라미터를 조회할 땐, 리액트 라우터를 사용할 떄 처럼 useParams Hook을 사용합니다. 위와 같이 컴포넌트를 작성 후 브라우저의 주소창에 http://localhost:3000/articles/1 을 입력하고 결과를 확인해보세요.

4.3 Outlet

우리가 리액트 라우터를 사용할 때, 공통된 레이아웃을 가진 라우트들을 구성할 때 Outlet 을 활용하여 라우트 설정을 할 수 있었지요?

Remix에서도 이 기능을 사용할 수 있는데, 사용법이 조금 다릅니다. 이번에도 역시나, 디렉터리 기반으로 작동합니다.

routes 경로에 articles.jsx 라는 파일을 만들어서 다음과 같이 코드를 입력해보세요.

app/routes/articles.jsx

import { Link, Outlet } from '@remix-run/react';

export default function Articles() {
  return (
    <div>
      <Outlet />
      <hr />
      <ul>
        <li>
          <Link to="/articles/1">게시글 1</Link>
        </li>
        <li>
          <Link to="/articles/1">게시글 2</Link>
        </li>
        <li>
          <Link to="/articles/1">게시글 3</Link>
        </li>
      </ul>
    </div>
  );
}

이렇게 라우트 컴포넌트에서 Outlet 컴포넌트를 렌더링하면, 해당 경로의 하위 라우트들이 Outlet 컴포넌트가 사용한 자리에 나타나게 됩니다. 다음과 같이 말이죠.

주의하실 점은, Articles 라우트가 위치한 곳이 app/routes/articles/index.jsx 가 아니라 해당 디렉터리의 상위 디렉터리에 같은 이름을 가진 파일인 articles.jsx를 생성했다는 것 입니다. 만약 articles/index.jsx 파일을 만들게 된다면 Outlet이 정상적으로 작동하지 않고 Articles와 Article 라우트가 독립적으로 작동하게 됩니다.

또 다른 예시를 들어보겠습니다. 만약 다음과 같은 Outlet이 적용된 라우트를 구성한다고 가정해봅시다.

  • /movies/1
  • /movies/1/reviews
  • /movies/1/actors
  • /movies/1/related

위 라우트들이 모두 같은 레이아웃을 가지고 있다고 가정하고, /moives/1 경로에 들어왔을땐 영화의 상세 정보를 보여준다고 가정을 해보겠습니다.

이러한 상황엔 다음과 같이 라우트 파일을 만들어주어야 합니다.

app
└── movies
    ├── $id
    │   ├── actors.jsx
    │   ├── index.jsx
    │   ├── related.jsx
    │   └── reviews.jsx
    └── $id.jsx

그리고, app/movies/$id.jsx 에서 공통된 레이아웃과 함께 Outlet 컴포넌트를 사용헤야 정상적으로 작동합니다

5. 데이터 불러오기

이번에는 Remix 프로젝트에서 데이터를 불러오는 방법에 대해서 알아보겠습니다. 데이터 로딩 연습을 하기 위해 우리는 JSONPlaceholder (https://jsonplaceholder.typicode.com) 에서 제공하는 가짜 데이터 기반의 API를 사용하겠습니다.

사용할 API는 다음과 같습니다.

# 모든 사용자 정보 불러오기
GET https://jsonplaceholder.typicode.com/users

# 특정 사용자 정보 불러오기 (:id 는 1 ~ 10 사이 숫자)
GET https://jsonplaceholder.typicode.com/users/:id

5.1 데이터 요청 함수 준비하기

axios 웹 클라이언트를 사용하여 데이터 요청을 해보도록 하겠습니다.

우선, axios 라이브러리를 설치하세요.

$ yarn add axios

그 다음, app 디렉터리에 lib 디렉터리를 만들고 그 안에 api.js 파일을 만들어서 다음과 같이 코드를 작성해보세요.

app/lib/api.js

import axios from 'axios';

export async function getUsers() {
  const response = await axios.get(
    'https://jsonplaceholder.typicode.com/users'
  );
  return response.data;
}

export async function getUser(id) {
  const response = await axios.get(
    `https://jsonplaceholder.typicode.com/users/${id}`
  );
  return response.data;
}

이제 데이터를 불러오는 함수는 다 만들었습니다. 참고로 이 경로에 API 함수들을 정리하는 것은 의무가 아니며, 코드를 잘 정리해서 사용하기 위해 이렇게 작성하는 것입니다.

5.2 loader와 useLoaderData

Remix 프로젝트에서 데이터를 불러올 때는 loaderuseLoaderData Hook을 사용합니다. 우선, 데이터를 불러올 라우트인 Users 라우트를 다음과 같이 생성해보세요.

app/routes/users/index.jsx

export default function Users() {
  return (
    <div>
      <h1>Users</h1>
      <ul>
        <li>User</li>
        <li>User</li>
      </ul>
    </div>
  );
}

지금은 그냥 User 라는 텍스트만을 보여주고 있는데요, 이 컴포넌트에서 데이터를 불러오는 예시를 한번 살펴봅시다.

app/routes/users/index.jsx

import { json } from '@remix-run/node';
import { Link, useLoaderData } from '@remix-run/react';
import { getUsers } from '../../lib/api';

export const loader = async () => {
  const data = await getUsers();
  return json(data);
};

export default function Users() {
  const users = useLoaderData();

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <Link to={`/users/${user.id}`}>{user.username}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

라우트에서 데이터를 불러와야 할 땐, 라우트 파일에서 loader 라는 함수를 선언하여 내보내주어야 하며, 이 함수에서 불러오고 싶은 데이터를 요청 후 응답받은 데이터를 json 함수로 감싸서 반환을 해주어야 합니다. 여기서 json 함수의 역할은 응답의 Content-Type을 application/json으로 설정해주는 것 입니다.

그리고, 이 함수에서 반환한 데이터는 라우트 컴포넌트에서 useLoaderData Hook을 통하여 받아와서 사용할 수 있습니다. 이 라우트가 화면에 나타나는 시점에는 데이터가 이미 로딩 되어있는 것이 보장되어 있기 때문에, 로딩 상태에 대한 처리를 별도로 할 필요가 없습니다.

브라우저에서 http://localhost:3000/users 경로에 들어가보세요. 다음과 같이 사용자 목록이 잘 나타났나요?

이 페이지에서 개발자 도구의 네트워크 탭을 보고 페이지의 응답 결과를 보면 페이지 진입 시점부터 데이터에 기반하여 렌더링이 잘 되어있고, HTML 코드의 하단부의 스크립트를 보면 브라우저의 글로벌 변수 window.__remixContext 에 객체를 할당하는 코드가 있고 해당 객체에 routeData라는 필드에 우리가 요청한 사용자 정보의 응답 데이터가 들어있는 것을 확인할 수 있습니다.

라우트에서 useLoaderData를 호출하면 위 routerData를 참조하여 알맞는 데이터를 찾아서 컴포넌트에서 사용할 수 있게 해주는 것입니다.

이번에는 각 사용자의 정보를 보여주는 라우트를 만들어봅시다. User 라우트를 다음과 같이 작성해보세요.

app/routes/users/$id.jsx

import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { getUser } from '../../lib/api';

export const loader = async ({ params }) => {
  const { id } = params;
  const user = await getUser(id);
  return json(user);
};

export default function User() {
  const user = useLoaderData();

  return (
    <div>
      <h2>{user.username}</h2>
      <code style={{ whiteSpace: 'pre' }}>{JSON.stringify(user, null, 2)}</code>
    </div>
  );
}

데이터를 요청할 때 URL 파라미터를 참조해야 하는 경우에는 loader 함수의 파라미터에서 params 값을 사용하면 됩니다.

이 컴포넌트에서는 JSON 데이터를 문자열로 형태로 바로 화면에 나타내도록 만들어주었습니다.

브라우저에서 http://localhost:3000/users/1 경로에 들어가서 정보가 잘 나타나는지 확인해보세요.

자, 이번에는 http://localhost:3000/users 에 들어가서 개발자 도구를 열고, 링크를 클릭해서 User 라우트로 이동을 해보세요.

Remix 프로젝트에서 데이터 불러오기가 필요한 라우트로 클라이언트 라우팅을 하게 될 때에는 리액트 라우터를 사용할 때와 조금 다르게 작동합니다.

리액트 라우터에서는 주소 변경 -> 라우트 컴포넌트 마운트 -> 필요한 데이터 로딩 -> 데이터 렌더링 순으로 작동하는 반면, Remix 를 사용할 때에는 주소 변경 -> 필요한 데이터 로딩 -> 준비된 데이터와 함께 컴포넌트 마운트 순으로 작동합니다.

즉, Remix에서는 다른 라우트로 이동할 때 주소가 바뀌어도 데이터 불러오기가 끝날 때 까지 이전 라우트의 UI가 화면에 나타나게 됩니다.

그리고, 클라이언트 라우팅을 하면서 데이터를 새로 요청하긴 하지만 이 때 loader 함수 로직이 브라우저에서 직접 호출되는게 아니라, loader 함수는 서버에서만 실행되며, 그 응답의 결과만 브라우저에서 받아서 사용합니다. 위 스크린샷을 보면 라우트 이동을 하면서 Remix에서 내부적으로/users/1?_data=routes%2Fusers%2F%24id 경로에 요청을 한 것을 볼 수 있습니다.

Remix 서버에서는 이러한 요청을 받으면 해당 라우트의 loader를 실행하고 그 결과를 응답합니다.

loader에서 사용한 로직은 무조건 서버 측에서만 호출되며, 추후 프로젝트를 빌드 할 때 클라이언트측 번들링 결과에 포함되지 않습니다. 여기에 입력되는 코드는 사용자에게 절대 노출되지 않기 때문에 필요에 따라 이 함수 내에서 데이터베이스에 직접 접근을 하거나 민감한 알고리즘을 다뤄도 안전합니다.

6. 데이터 쓰기

이번에는 Remix 프로젝트에서 데이터를 쓰기를 해야 하는 상황엔 어떻게 해야하는지 알아봅시다. 일반 리액트 애플리케이션처럼, 데이터 쓰기 API를 직접 호출하는 방식으로 구현해도 상관은 없지만, Remix에서 권장하는 방식은 Form 기반의 데이터 쓰기입니다.

6.1 json server 사용하기

우선, 우리가 이전 예시에서 사용했던 JSONPlaceholder에서 제공하는 가짜 API는 데이터 읽기만 작동하므로, 가짜 API 서버를 직접 열어보도록 하겠습니다. 우리는 이 과정에서 json-server 라는 도구를 사용하겠습니다. 이 도구는 지금처럼 학습을 하거나, 프로토타입을 만들 때 서버 개발 없이 CRUD (Create, Read, Update, Delete) 기능을 갖춘 REST API를 쉽게 구축할 수 있게 해줍니다. 참고로, JSONPlaceholder 에서도 json-server를 사용하고 있답니다.

프로젝트 디렉터리에 data.json 파일을 생성하고 다음과 같이 JSON을 입력하세요.

data.json

{
  "stories": [
    {
      "id": 1,
      "title": "첫 번째 이야기",
      "body": "Remix..."
    },
    {
      "id": 2,
      "title": "두 번째 이야기",
      "body": "Data Loading...!"
    },
    {
      "id": 3,
      "title": "세 번째 이야기",
      "body": "Data Write!!!"
    }
  ]
}

그리고 터미널에서 다음 명령어를 사용하면 json-server가 시작됩니다.

$ npx json-server ./data.json --port 4000 -d 500
  \{^_^}/ hi!

  Loading ./data.json
  Done

  Resources
  http://localhost:4000/stories

  Home
  http://localhost:4000

  Type s + enter at any time to create a snapshot of the database

위 명령어는 data.json 파일을 기반으로 REST API 서버를 포트 4000으로 시작하고, API 딜레이를 500ms로 설정합니다.

6.2 API 함수 작성하기

lib/api.js

(...)

export async function getStories() {
  const response = await axios.get('http://localhost:4000/stories');
  return response.data;
}

export async function getStory(id) {
  const response = await axios.get(`http://localhost:4000/stories/${id}`);
  return response.data;
}

export async function createStory({ title, body }) {
  const response = await axios.post('http://localhost:4000/stories', {
    title,
    body,
  });
  return response.data;
}

6.3 이야기 불러오기 기능 구현

Stories 라우트를 만들고 데이터 불러오기 기능부터 완성해봅시다.
app/routes/stories/index.jsx

import { json } from '@remix-run/node';
import { Link, useLoaderData } from '@remix-run/react';
import { getStories } from '../../lib/api';

export const loader = async () => {
  const stories = await getStories();
  return json(stories);
};

export default function Stories() {
  const stories = useLoaderData();

  return (
    <div>
      <h1>Stories</h1>
      <ul>
        {stories.map((story) => (
          <li key={story.id}>
            <Link to={`/stories/${story.id}`}>{story.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

각 항목을 누르면 /stories/:id 주소로 이동하도록 만들었는데요, 하나의 이야기만 불러오는 라우트는 나중에 만들어주도록 하겠습니다.

6.4 action과 Form

만약 라우트에서 데이터 쓰기를 하고 싶다면, action 이라는 함수를 만들어서 내보내주어야 합니다. 이 함수에서는 request 파라미터를 통하여 브라우저에서 요청한 데이터를 Form Data 형식으로 조회할 수 있습니다. 이 함수 내부에서 우리가 원하는 데이터 쓰기 함수를 요청하면 됩니다. 이 함수는 loader 처럼 추후 프로젝트를 빌드 할 때 클라이언트측 번들링 결과에 포함되지 않습니다.

그리고, 이 함수를 브라우저 단에서 이용하기 위해서는 Remix에서 제공하는 Form 컴포넌트를 사용해야됩니다. 다음 코드처럼 파일을 수정해보세요.

app/routes/stories/idnex.jsx

import { json } from '@remix-run/node';
import { Link, useLoaderData, Form, useTransition } from '@remix-run/react';
import { createStory, getStories } from '../../lib/api';

export const loader = async () => {
  const stories = await getStories();
  return json(stories);
};

export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');
  const story = await createStory({ title, body });
  return story;
}

export default function Stories() {
  const stories = useLoaderData();
  const transition = useTransition();

  return (
    <div>
      <h1>Stories</h1>
      <ul>
        {stories.map((story) => (
          <li key={story.id}>
            <Link to={`/stories/${story.id}`}>{story.title}</Link>
          </li>
        ))}
      </ul>
      <Form method="post">
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: 4,
            width: 320,
          }}
        >
          <input type="text" name="title" placeholder="제목을 입력하세요..." />
          <textarea name="body" placeholder="이야기를 입력하세요..." />
          <button type="submit">
            {transition.state === 'submitting' ? '등록 중...' : '등록'}
          </button>
        </div>
      </Form>
    </div>
  );
}

브라우저에서 Form을 통해 등록이 이뤄지면, 데이터가 Remix 서버로 전달되고, Remix 서버에서 action 함수를 호출한 다음에 그 결과를 브라우저에게 응답합니다. 지금의 경우엔 응답한 결과를 따로 사용하진 않고 있지만, 만약 응답된 데이터를 사용하고 싶으면 useActionData 라는 Hook을 사용하면 됩니다.

그리고, Form을 통해 데이터가 등록되면 useLoaderData 를 통해서 불러왔던 데이터가 자동으로 최신화 됩니다.

useTransition 이라는 Hook은 Form의 상태를 조회할 때 사용합니다. transition.state 는 총 3가지 값이 있습니다. 아무것도 하지 않은 기본 상태일 때에는 "idle”, 데이터 쓰기 요청이 진행중일 때에는 "submitting", 데이터 쓰기 후 useLoaderData 의 데이터를 불러오고 있을 때에는 "loading" 값을 나타냅니다.

이제 각 입력 칸에 글을 입력하고 등록을 눌러보세요. 새로운 데이터가 잘 등록 되나요? 등록 후 페이지의 데이터가 제대로 최신화가 되었는지도 확인해보세요.

현재 데이터를 등록 후 Form의 내용이 그대로 유지가 되고 있는데요, 내용을 비우는 방법을 알아보겠습니다. 만약 onSubmit 에서 폼을 비워버리면, 데이터를 서버에 요청하기도 전에 값이 사라져버립니다. 값을 제대로 비우려면, useTransition 을 통하여 요청 상태를 조회할 때 state 값이 "submitting"이 되는 시점에 폼을 비우시면 됩니다.

폼을 비울 때에는, Form에 ref를 달고, useEffect 를 사용해서 state 값이 "submitting"으로 변하는 시점을 감지하여 form DOM의 reset 함수를 호출해주면 됩니다.

app/routes/stories/index.jsx

현재 데이터를 등록 후 Form의 내용이 그대로 유지가 되고 있는데요, 내용을 비우는 방법을 알아보겠습니다. 만약 onSubmit 에서 폼을 비워버리면, 데이터를 서버에 요청하기도 전에 값이 사라져버립니다. 값을 제대로 비우려면, useTransition 을 통하여 요청 상태를 조회할 때 state 값이 "submitting"이 되는 시점에 폼을 비우시면 됩니다.

폼을 비울 때에는, Form에 ref를 달고, useEffect 를 사용해서 state 값이 "submitting"으로 변하는 시점을 감지하여 form DOM의 reset 함수를 호출해주면 됩니다.

app/routes/stories/index.jsx

import { json } from '@remix-run/node';
import { Form, Link, useLoaderData, useTransition } from '@remix-run/react';
import { useEffect, useRef } from 'react';
import { createStory, getStories } from '../../lib/api';

(...)

export default function Stories() {
  const stories = useLoaderData();
  const transition = useTransition();

  const ref = useRef();
  useEffect(() => {
    if (transition.state === 'submitting') {
      // ?. 는 Optional Chaining 연산자로서, ref.current 값이 유효할 때에만 reset 함수를 호출해줍니다.
      ref.current?.reset();
    }
  }, [transition.state]);

  return (
    <div>
      <h1>Stories</h1>
      <ul>
        {stories.map((story) => (
          <li key={story.id}>
            <Link to={`/stories/${story.id}`}>{story.title}</Link>
          </li>
        ))}
      </ul>
      <Form method="post" ref={ref}>
         (...)
      </Form>
    </div>
  );
}

6.5 요청 후 redirect 하기

이번에는 데이터 쓰기 요청이 끝나고 나서 원하는 주소로 이동하는 기능을 구현해보겠습니다. 우선, 하나의 이야기만 불러오는 Story 라우트를 작성해봅시다.

app/routes/stories/$id.jsx

import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { getStory } from '../../lib/api';

export const loader = async ({ params }) => {
  const { id } = params;
  const data = await getStory(id);
  return json(data);
};

export default function Story() {
  const story = useLoaderData();
  return (
    <div>
      <h1>{story.title}</h1>
      <p>{story.body}</p>
    </div>
  );
}

이렇게 라우트를 다 작성하셨으면, 기존에 Stories 라우트에서 보여지던 아무 링크를 누르거나, http://localhost:3000/stories/1 페이지를 열어서 이야기가 잘 불러와졌는지 확인해보세요.

데이터가 잘 불러와졌나요?

그러면, 이제 Stories 라우트 파일을 다시 열어서 action 함수 부분을 다음과 같이 수정해보세요.

app/routes/stories/index.jsx

import { json, redirect } from '@remix-run/node';
import { Form, Link, useLoaderData, useTransition } from '@remix-run/react';
import { useEffect, useRef } from 'react';
import { createStory, getStories } from '../../lib/api';

export const loader = async () => {
  const stories = await getStories();
  return json(stories);
};

export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');
  const story = await createStory({ title, body });
  return redirect(`/stories/${story.id}`);
}

(...)

redirect 함수를 사용하면, 데이터 쓰기 처리가 끝난 후 우리가 원하는 페이지로 바로 이동하도록 만들 수 있습니다. 브라우저에서 Stories 라우트에 들어가서 새 이야기를 작성해보세요. 데이터 쓰기 요청이 끝나고 우리가 방금 작성한 이야기가 보여지는 페이지로 잘 이동이 되었나요?

7. 배포

Remix로 만든 프로젝트를 배포하는 방법은 다양합니다. 우리가 새 프로젝트를 만들 때 다음과 같은 옵션을 CLI에서 물어봤었는데요:

? Where do you want to deploy? Choose Remix if you're unsure; it's easy to change deployment targets.
❯ Remix App Server
  Express Server
  Architect (AWS Lambda)
  Fly.io
  Netlify
  Vercel
Cloudflare Pages
Cloudflare Workers 

선택했던 방법에 따라 배포 방법이 README.md 에 적혀있습니다.

우리는 가장 기본 방식인Remix App Server 방식으로 했었기 때문에, 다음 명령어로 배포를 하면 된다고 합니다.

$ yarn build
$ yarn start

(README.md 에서는 npm 명령어로 안내하고 있지만 이 글에서는 yarn 으로 변경했습니다.)

만약 프로덕션에서 이 옵션을 선택한다면, 정적 파일들도 같은 서버에서 제공되는데요, 만약 정적 파일들을 Amazon S3 같은 외부 저장소에 올리고 싶다면 파일들을 업로드 후 remix.config.js 에서 publicPath 값을 변경하시면 됩니다.

Remix App Server 옵션으로 개발을 하시면 추후 배포를 할 때 저장소부터 서버를 실행할 인스턴스, 오토 스케일링, 로드 밸런싱, 자동 배포 등을 직접 설정해야 하지만, Vercel, Netlify, Cloudflare 의 서비스를 사용하면 방금 언급한 것들을 더욱 편하게 해주기 때문에 해당 서비스들에 대해서 알아보고 선택해서 사용하시는 것을 권장드립니다.

8. 정리

이 장에서는 서버 사이드 렌더링이 왜 필요한지 알아보았고, Remix 프레임워크의 기본적인 사용방법을 배웠습니다. 책에서는 Remix의 주요 기능들에 대해서만 다뤘고, 책에서 다룬 내용 외에도 다른 유익한 기능들이 많이 탑재되어 있으니 다음 링크에서 더 이 프레임워크에 대하여 더 알아보는 것을 권장 드립니다.

https://remix.run/docs/en/v1

profile
CEO @ Chaf Inc. 사용자들이 좋아하는 프로덕트를 만듭니다.

8개의 댓글

comment-user-thumbnail
2022년 5월 22일

잘 보았습니다.

답글 달기
comment-user-thumbnail
2022년 5월 24일

좋은 글 늘 감사합니다~!

app/routes/articles.jsx 파일 작성 중 Link의 경로가 전부 1로 되어 있습니다~ 소소한 오타

1개의 답글
comment-user-thumbnail
2022년 5월 28일

감사합니다❤❤

답글 달기
comment-user-thumbnail
2022년 8월 24일

잘보고 갑니다!
6.4절에 app/routes/stories/idnex.jsx
여기도 소소한 파일명 index.jsx 오타가 있네요!

답글 달기
comment-user-thumbnail
2023년 5월 27일

안녕하세요 책 보고 공부하다 Remix를 알려주셔서 공부하고 있습니다
4.2 URL 파라미터 부분에서 똑같이 코드를 따라 작성했는데
계속 404만 뜨는데 이유를 모르겠네요 ㅠㅠ

1개의 답글
comment-user-thumbnail
2023년 11월 29일

글 잘 읽었습니다 !!

Remix가 Next.js보다 더 좋은 장점이 있는 지 궁금합니다.

답글 달기