본문 바로가기

FrontEnd

[React][Remix][tutorial]React-Remix를 이용하여 개발자 블로그 만들기.

반응형

공식 블로그 튜토리얼을 의역한 내용이다.
해당 튜토리얼을 따라 react-remix 프레임워크와 마크다운을 활용해 개발자 블로그를 만들어본다.

 

Remix | Developer Blog

Quickstart We're going to be short on words and quick on code in this quickstart. If you're looking to see what Remix is all about in 15 minutes, this is it. 💿 Hey I'm Derrick the Remix Compact Disc 👋 Whenever you're supposed to do something you'll s

remix.run

완성된 프로젝트는 여기서 볼 수 있다.

추가로 튜토리얼과 다르게 admin 경로가 안먹어서 auth로 변경하였다.

 

썸네일!

개발 환경 설정

다 귀찮으므로 코드샌드박스 템플릿을 사용한다. 무료니 써보자

project

공식문서는 tsx로 되어있는데 내가 사용한 탬플릿은 jsx 기반이다.

처음 만들면 root.jsx가 다음과 같이 되어있다.
신기한게 index.html이 없다는 점이다.
웹 기본을 알면 대충 무슨 역할을 하는 녀석들인지 이해가 간다.
서스펜스에 대한 기본지식과 네스티드 라우팅에 대한 지식이 없으면 일단 무지성으로 따라하자.
내용물을 추가하고 싶으면 App 함수 jsx 부분에 추가한다.

import {
  Meta,
  Links,
  Scripts,
  useLoaderData,
  LiveReload,
  useCatch
} from "remix";
import { Outlet } from "react-router-dom";

import stylesUrl from "./styles/global.css";
import { Link } from "remix";
export function links() {
  return [{ rel: "stylesheet", href: stylesUrl }];
}

export function loader() {
  return { date: new Date() };
}

function Document({ children, title }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <link rel="icon" href="/favicon.png" type="image/png" />
        {title ? <title>{title}</title> : null}
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <Scripts />
        {process.env.NODE_ENV === "development" && <LiveReload />}
      </body>
    </html>
  );
}

export default function App() {
  let data = useLoaderData();

  return (
    <Document>
      <Outlet />
      <footer>
        <p>This page was rendered at {data.date.toLocaleString()}</p>
      </footer>
    </Document>
  );
}

export function CatchBoundary() {
  let caught = useCatch();

  switch (caught.status) {
    case 401:
    case 404:
      return (
        <Document title={`${caught.status} ${caught.statusText}`}>
          <h1>
            {caught.status} {caught.statusText}
          </h1>
        </Document>
      );

    default:
      throw new Error(
        `Unexpected caught response with status: ${caught.status}`
      );
  }
}

export function ErrorBoundary({ error }) {
  console.error(error);

  return (
    <Document title="Uh-oh!">
      <h1>App Error</h1>
      <pre>{error.message}</pre>
      <p>
        Replace this UI with what you want users to see when your app throws
        uncaught errors.
      </p>
    </Document>
  );
}

라우터 만들기

주의 : 나처럼 코드샌드박스로 개발하면 리믹스는 핫 리로드를 안해준다.(원래 안해주는게 맞는것 같다.) 코드 수정 후 브라우저 새로고침을 해야한다.
💿 app/root.jsx

// 대충 임포트한다.
import { Link } from "remix";
// 대충 App 안에 추가해본다.
<Link to="/posts">Posts</Link>

Posts를 누르면 404 not found가 나온다.

터미널은 아래 조그만 + 를 누르면 나온다.

terminal

터미널에 친다. (윈도우는 touch. 대신 .>하면 된다고 한다.)

mkdir app/routes/posts
touch app/routes/posts/index.jsx

💿app/routes/posts/index.jsx

export default function Posts() {
  return (
    <div>
      <h1>Posts</h1>
    </div>
  );
}

이제는 Posts가 나온다.


데이터 로딩

본문에 길게 이래저래 써져있는데 내가 이해한 바로 한줄로 정리하면
Remix는 라우터가 컴포넌트(들)와 데이터 제공의 책임을 동시에 진다. 즉 컴포넌트(들) + 비동기훅이다.
라우터가 레이아웃의 일부분, 즉 컴포넌트보다는 크거나 같고 페이지보다 작거나 같은 느낌이다.
그래서 nested routing이 중요한 것이다.

💿app/routes/posts/index.jsx 업데이트

import { useLoaderData } from "remix";
// loader는 백엔드 API 역할을 한다.
export const loader = () => {
  return [
    {
      slug: "my-first-post",
      title: "My First Post"
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You"
    }
  ];
};

export default function Posts() {
  const posts = useLoaderData();
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.slug}>
            <Link to={post.slug}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

컴포넌트가 자체 API 라우터를 갖고 있다.


작은 리팩터링

touch app/post.js

💿 app/post.js

export function getPosts() {
  const posts = [
    {
      slug: "my-first-post",
      title: "My First Post"
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You"
    }
  ];
  return posts;
}

💿app/routes/posts/index.jsx 업데이트

import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";
// import type { Post } from "~/post"; we don't use typescript!
// loader는 백엔드 API 역할을 한다.
export const loader = () => {
    return getPosts();
};

export default function Posts() {
  const posts = useLoaderData();
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.slug}>
            <Link to={post.slug}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

데이터소스에서 데이터 가져오기

사실 리믹스 공부를 다시 시작한게 이 탬플릿으로 함수형 프로그래밍 치트시트를 만드려고...
어쨌든 위의 useLoaderData API를 이용하면 파일 시스템 뿐만 아니라 DB에서도 데이터를 가져올 수 있다. 말 그대로 서버이기 때문이다. (prisma를 공부해보고 싶다.)

💿 "posts/폴더를 App 폴더 옆에 만든다.

mkdir posts
touch posts/my-first-post.md
touch posts/90s-mixtape.md

💿 posts/my-first-post.md

---
title: My First Post
---

# This is my first post

Isn't it great?

💿 posts/90s-mixtape.md

---
title: 90s Mixtape
---

# 90s Mixtape

- I wish (Skee-Lo)
- This Is How We Do It (Montell Jordan)
- Everlong (Foo Fighters)
- Ms. Jackson (Outkast)
- Interstate Love Song (Stone Temple Pilots)
- Killing Me Softly With His Song (Fugees, Ms. Lauryn Hill)
- Just a Friend (Biz Markie)
- The Man Who Sold The World (Nirvana)
- Semi-Charmed Life (Third Eye Blind)
- ...Baby One More Time (Britney Spears)
- Better Man (Pearl Jam)
- It's All Coming Back to Me Now (Céline Dion)
- This Kiss (Faith Hill)
- Fly Away (Lenny Kravits)
- Scar Tissue (Red Hot Chili Peppers)
- Santa Monica (Everclear)
- C'mon N' Ride it (Quad City DJ's)

💿 app/post.js를 업데이트한다.

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
import invariant from "tiny-invariant";

const postsPath = path.join(__dirname, "..", "posts");

function isValidPostAttributes(
  attributes
) {
  return attributes?.title;
}

export async function getPosts() {
  const dir = await fs.readdir(postsPath);
  return Promise.all(
    dir.map(async filename => {
      const file = await fs.readFile(
        path.join(postsPath, filename)
      );
      const { attributes } = parseFrontMatter(
        file.toString()
      );
      // 포맷이 잘못되면 에러 페이지와 메세지를 보여줌
      invariant(
        isValidPostAttributes(attributes),
        `${filename} has bad meta data!`
      );
      return {
        slug: filename.replace(/\.md$/, ""),
        title: attributes.title
      };
    })
  );
}

참고 : 원문에서는 타입스크립트를 사용하기 때문에 md 파일 내용물의 type-safety를 체크한다.
진작에 타입스크립트로 세팅하고 시작했어야 했지만 일단 넘어간다.

export type Post = {
  slug: string;
  title: string;
};
export type PostMarkdownAttributes = {
  title: string;
};
function isValidPostAttributes(
  attributes: any
): attributes is PostMarkdownAttributes {
  return attributes?.title;
}

다이나믹 라우팅 변수

주소창에 해당 페이지들을 요청하고 싶다. 어떻게 하지?

/posts/my-first-post
/posts/90s-mixtape

url 내의 동적 세그먼트를 사용한다.

touch app/routes/posts/\$slug.jsx

💿 마크다운 파서를 설치한다.`

npm add marked
# if using typescript
npm add @types/marked

💿 app/routes/posts/\$slug.sx

import { useLoaderData } from "remix";
import { getPost } from "~/post";
import invariant from "tiny-invariant";

// params.slug 통해 파라미터의 url 접근
export const loader = async ({ params }) => {
  invariant(params.slug, "expected params.slug");
  return getPost(params.slug);
};

export default function PostSlug() {
  const post = useLoaderData();
  return <div dangerouslySetInnerHTML={{ __html: post.html }} />;
}

💿 app/post.js를 업데이트한다.

import path from "path";
import fs from "fs/promises";
import parseFrontMatter from "front-matter";
import invariant from "tiny-invariant";
import { marked } from "marked";

const postsPath = path.join(__dirname, "..", "posts");

function isValidPostAttributes(attributes) {
  return attributes?.title;
}

 // new!!
export async function getPost(slug) {
  const filepath = path.join(postsPath, slug + ".md");
  const file = await fs.readFile(filepath);
  const { attributes, body } = parseFrontMatter(file.toString());
  invariant(
    isValidPostAttributes(attributes),
    `Post ${filepath} is missing attributes`
  );
  const html = marked(body);
  return { slug, html, title: attributes.title };
}

export async function getPosts() {
  const dir = await fs.readdir(postsPath);
  return Promise.all(
    dir.map(async (filename) => {
      const file = await fs.readFile(path.join(postsPath, filename));
      const { attributes } = parseFrontMatter(file.toString());
      invariant(
        isValidPostAttributes(attributes),
        `${filename} has bad meta data!`
      );
      return {
        slug: filename.replace(/\.md$/, ""),
        title: attributes.title
      };
    })
  );
}

게시물 만들기

💿 admin route 만들기

touch app/routes/admin.jsx

💿 app/routes/admin.jsx

import { Link, useLoaderData } from "remix";
import { getPosts } from "~/post";

export const loader = () => {
  return getPosts();
};
// 이렇게 하면 Remix가 렌더링된 모든 경로 링크를 병합하여 문서 상단의 <Links/> 요소에 렌더링할 수 있습니다.
export const links = () => {
  return [{ rel: "stylesheet", href: adminStyles }];
};
export default function Admin() {
  const posts = useLoaderData();
  return (
    <div className="admin">
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map((post) => (
            <li key={post.slug}>
              <Link to={`/posts/${post.slug}`}>{post.title}</Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>...</main>
    </div>
  );
}

💿 admin css 적용

mkdir app/styles
touch app/styles/admin.css

💿 app/styles/admin.css

.admin {
  display: flex;
}

.admin > nav {
  padding-right: 2rem;
}

.admin > main {
  flex: 1;
  border-left: solid 1px #ccc;
  padding-left: 2rem;
}

em {
  color: red;
}

Index 라우트

네스티드 라우팅 구현

💿 터미널에 입력

mkdir app/routes/admin
touch app/routes/admin/index.jsx

💿 app/routes/admin/index.jsx

import { Link } from "remix";

export default function AdminIndex() {
  return (
    <p>
      <Link to="new">Create a New Post</Link>
    </p>
  );
}

네스티드 라우팅의 핵심은 <Outlet/>을 통해 루트 컴포넌트에서 자식 컴포넌트들의 레이아웃을 제어한다는 것이다.
URL이 상위 경로의 경로와 일치하면 index가 outlet 내부에서 렌더링된다는 점을 알아두자.
💿 app/routes/admin.jsx
main 안을 살펴보자.

import { Link, useLoaderData, Outlet } from "remix";
import { getPosts } from "~/post";
import adminStyles from "~/styles/admin.css";

export const loader = () => {
  return getPosts();
};
// 이렇게 하면 Remix가 렌더링된 모든 경로 링크를 병합하여 문서 상단의 <Links/> 요소에 렌더링할 수 있습니다.
export const links = () => {
  return [{ rel: "stylesheet", href: adminStyles }];
};
export default function Admin() {
  const posts = useLoaderData();
  return (
    <div className="admin">
      <nav>
        <h1>Admin</h1>
        <ul>
          {posts.map((post) => (
            <li key={post.slug}>
              <Link to={`/posts/${post.slug}`}>{post.title}</Link>
            </li>
          ))}
        </ul>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

💿 터미널에 입력

touch app/routes/admin/new.jsx

💿 app/routes/admin/new.jsx

export default function NewPost() {
  return <h2>New Post</h2>;
}

액션

마지막이다. 이제 새로운 라우트에 새 포스트를 추가해보자.
요청의 폼데이터에서 데이터를 꺼내와 처리하는게 다다.
또 재미있는건 해당 기능은 자바스크립트를 꺼도 작동한다.
💿 app/routes/admin/new.jsx 업데이트

import {
  useTransition,
  useActionData,
  Form,
  redirect
} from "remix";
import { createPost } from "~/post";
import invariant from "tiny-invariant";

export const action = async ({ request }) => {

  // pending UI를 사용하기 위해 추가함. 너무 빠르면 안보여줌
  await new Promise(res => setTimeout(res, 1000));

  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");
  const errors = {};
  if (!title) errors.title = true;
  if (!slug) errors.slug = true;
  if (!markdown) errors.markdown = true;


  invariant(typeof title === "string");
  invariant(typeof slug === "string");
  invariant(typeof markdown === "string");
// 리디렉션을 반환하지 않고 실제로 오류를 반환합니다. 
// 이러한 오류는 useActionData를 통해 컴포넌트에서 사용 가능합니다. 
// useLoaderData와 비슷하지만 POST 이후에 가져옵니다.
  if (Object.keys(errors).length) {
    return errors;
  }
  await createPost({ title, slug, markdown });

  return redirect("/admin");
};

export default function NewPost() {
  // form action 이후에 가져옴.
  const errors = useActionData();
  // loading이 길어질 경우 해당 상태를 이용
  const transition = useTransition();
  return (
    <Form method="post">
      <p>
        <label>
          Post Title:{" "}
          {errors?.title ? (
            <em>Title is required</em>
          ) : null}
          <input type="text" name="title" />
        </label>
      </p>
      <p>
        <label>
          Post Slug:{" "}
          {errors?.slug ? <em>Slug is required</em> : null}
          <input type="text" name="slug" />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown:</label>{" "}
        {errors?.markdown ? (
          <em>Markdown is required</em>
        ) : null}
        <br />
        <textarea rows={20} name="markdown" />
      </p>
      <p>
         <button type="submit">
          {transition.submission
            ? "Creating..."
            : "Create Post"}
        </button>
      </p>
    </Form>
  );
}

💿 app/post.js 업데이트

// 맨 아래에 추가...
export async function createPost(post) {
  const md = `---\ntitle: ${post.title}\n---\n\n${post.markdown}`;
  await fs.writeFile(
    path.join(postsPath, post.slug + ".md"),
    md
  );
  return getPost(post.slug);
}

과제

edit 기능 추가하기

 

마치며

서버사이드 프레임워크를 사용하여

자바스크립트로 브라우저 호환 기능을 개발하며 (인터랙션)

리액트로 함수형 스타일로 적은 코드로 컴포넌트를 개발하며

상태관리 등 프론트엔드의 가장 복잡한 부분들을 상당히 없애버렸다.

(내부적으로 API서버와 이미지 서버와의 SWR을 유지한다고 하긴 한다. 해당 내용에 대한 글이 있던데 나중에 번역해봐야 할듯.)

장비의 연산보다 개발자의 시간이 비싼 엔터프라이즈와 스타트업에서는 정말 써볼만한 프레임워크가 아닌가 생각이 든다.

코드는 짧고 적을수록 좋다. 프레임워크가 너무 많은 것을 추상화하는게 아니냐?라 질문할 수도 있는데,

프레임워크 내부는 원리를 한번 이해하고 넘어가면 되고,,, 반복되는 부분을 줄이고 내 코드베이스를 줄일수 있다는 것은 관리비용이 줄어 좋다고 생각한다.

대신 개발자는 W3C 표준 등 사용자에게 더 의미있는 내용에 집중할 수 있다.

반응형