Utility |
---|
NodeJS | |
---|---|
NPM |
Netlify |
---|
Style & UI Library |
---|
Remix | |
---|---|
server state | |
Drag & Drop | |
Forms |
가벼움과 단숨함을 이유로 아래와 같은 세팅을 선택했습니다.
풀스택으로 전반적인 웹 흐름을 이해해보고자 하는 욕구도 있었지만,
기존 목표는 풀스택이 아닌 SSR이었기 때문에,
레퍼런스가 있고 비교적 심플하고 간단한 SQLite + Prisma 조합을 선택하였습니다.
DMBS | |
---|---|
ORM |
.
├── README.md
├── app
│ ├── components <-- 공통 컴포넌트를 관리합니다.
│ │ └── Layout.tsx
│ ├── entry.client.tsx <-- 브라우저 번들의 진입점입니다. entry.server.tsx에서 생성된 마크업을 ReHydrate하는데 사용됩니다.
│ ├── entry.server.tsx <-- SSR이 이루어지는 진입점입니다. 기본적인 마크업, HTTP Response를 생성하는데 사용됩니다.
│ ├── models <-- db model을 관리합니다.
│ │ ├── todo.server.ts
│ │ └── user.server.ts
│ ├── pages <-- 각 라우터 별 필요한 구성요소들을 관리합니다. 해당 요소들은 해당 라우터에서만 사용됩니다.
│ │ ├── auth
│ │ │ ├── AuthModal.tsx
│ │ │ ├── controller <-- 비즈니스 로직을 관리합니다. 순수 함수, 커스텀 훅, contextAPI를 각각 utils, hooks, context 폴더에 넣어 관리합니다.
│ │ │ │ ├── context
│ │ │ │ │ └── AuthUXProvider.tsx
│ │ │ │ └── utils
│ │ │ │ └── authUtils.ts
│ │ │ ├── errors <-- 라우터의 에러를 핸들링 할 컴포넌트를 관리합니다
│ │ │ │ └── AuthErrorContainer.tsx
│ │ │ └── styles <-- 라우터의 스타일을 관리합니다.
│ │ │ └── authStyles.tsx
│ │ ├── errors <-- 글로벌 에러 모듈을 관리합니다.
│ │ │ ├── ErrorHandler.tsx
│ │ │ └── styles
│ │ │ └── errorHandlerStyles.tsx
│ │ ├── home
│ │ │ ├── HomContent.tsx
│ │ │ ├── components
│ │ │ ├── errors
│ │ │ │ └── DefaultErrorContainer.tsx
│ │ │ └── styles
│ │ │ └── homeContentStyles.tsx
│ │ ├── todo
│ │ │ ├── TodoDetail.tsx
│ │ │ ├── components
│ │ │ │ ├── DefaultDescriptionContainer.tsx
│ │ │ │ └── UpdateDescriptionContainer.tsx
│ │ │ ├── controller
│ │ │ │ ├── context
│ │ │ │ │ └── TodoModalProvider.tsx
│ │ │ │ ├── hook
│ │ │ │ │ └── useDropList.tsx
│ │ │ │ └── utils
│ │ │ │ └── handleArray.ts
│ │ │ ├── errors
│ │ │ │ └── TodoErrorContainer.tsx
│ │ │ └── styles
│ │ │ └── todoDetailStyles.tsx
│ │ └── todos
│ │ ├── TodoCard.tsx
│ │ ├── TodoList.tsx
│ │ ├── TodoProgress.tsx
│ │ ├── errors
│ │ │ └── ListErrorContainer.tsx
│ │ └── styles
│ │ ├── todoCardStyles.tsx
│ │ └── todoProgressStyles.tsx
│ ├── root.tsx
│ ├── routes <— 라우터의 집합소입니다. 해당 라우터의 action / loader / Error등 서버상태를 관리하며 뼈대의 역할을 합니다.
│ │ ├── auth
│ │ │ ├── login.tsx
│ │ │ └── register.tsx
│ │ ├── auth.tsx
│ │ ├── delete.tsx
│ │ ├── index.tsx
│ │ ├── logout.tsx
│ │ ├── todos
│ │ │ └── $id.tsx
│ │ └── todos.tsx
│ ├── styles <— 글로벌 스타일을 관리합니다
│ │ ├── commonStyles.ts
│ │ └── reset.css
│ ├── types <— 글로벌 타입을 관리합니다
│ │ └── commontypes.ts
│ └── utils <— 서버측을 관리합니다. session, db, action handler등을 관리합니다.
│ ├── actionHandler.server.ts
│ ├── db.server.ts
│ └── session.server.ts
├── emotion.d.ts
├── jsconfig.json
├── netlify.toml
├── package-lock.json
├── package.json
├── prisma
│ ├── dev.db
│ ├── migrations
│ │ ├── 20220922174416_
│ │ │ └── migration.sql
│ │ ├── 20220926185647_
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ ├── schema.prisma
│ └── seed.ts <— 개발용 초기 목 데이터를 관리합니다.
├── remix.config.js
├── remix.env.d.ts
├── server.js
└── tsconfig.json
우리는 서버를 빠르게 만들 수는 있지만 유저의 네트워크를 제어할 순 없습니다.
리믹스가 선택한 방법은 네트워크를 통해 보내는 양을 줄이는 것 입니다.
모든 request response는 서버에서 이루어지며,
client에는 필요한 데이터만 보낼 수 있습니다.
클라이언트에서는 useActionData , useLoaderData API로
결과값을 넘겨받기만 합니다.
심플합니다.
loader
export const loader = async ({ params }: LoaderArgs) => {
const { id } = params;
if (!id) {
throw new Response('User Not Found', {
status: 404,
statusText: `User Not Found`,
});
}
const todo = await getSingleTodo(id);
if (!todo) {
throw new Response('Todo Not Found', {
status: 404,
statusText: `Todo Not Found`,
});
}
return json<TodoLoaderData>({ ...todo });
};
action
export const action: ActionFunction = async ({ request }: ActionArgs) => {
const formData = await request.formData();
const { _action, _id, ...values } = Object.fromEntries(formData);
invariant(typeof _action === 'string', `${_action} must be a string`);
invariant(typeof _id === 'string', `${_id} must be a string`);
if (_action === 'update') {
await updateSingleTodo({ _id, ...values });
return redirect(`/todos/${_id}`);
}
if (_action === 'delete') {
await deleteTodo({ _id });
return redirect('/todos');
}
if (_action === 'categoryUpdate') {
await updateSingleTodo({ _id, ...values });
return redirect(`/todos/${_id}`);
}
return;
};
route
export default function $idRoute() {
const { ...data } = useLoaderData() as TodoLoaderData;
return <TodoDetail todo={data} />;
}
prisma를 통해 건내줄 데이터를
쉽고 간단하게 가공합니다.
Remix에서 server.ts와 같이 파일 명에 server가 붙는다면
해당 파일은 서버에서만 실행됩니다.
반대로 client.ts도 마찬가지입니다.
import { db } from '~/utils/db.server';
interface userParams {
username: string;
passwordHash: string;
}
export async function createUser({ username, passwordHash }: userParams) {
return db.user.create({
data: { username, passwordHash },
});
}
export async function checkUser(username: string) {
return db.user.findFirst({
where: { username },
});
}
export async function findUser(username: string) {
return db.user.findUnique({
where: { username },
});
}
export async function findUserWithId(userId: string) {
return db.user.findUnique({
where: { id: userId },
select: { id: true, username: true },
});
}
useTransition, useFetcher, useCatch, useActionData, useLoaderData 등
remix의 서버 상태관리 API들은 정말 쉽고 간단하게
server state를 관리해줍니다.
export default function LoginRoute() {
const actionData = useActionData() as AuthBadRequestResponse;
const { classes } = authStyles();
const { wrapper, label, input, errorInput, errorMessage, button } = classes;
const { setActionData } = useAuthUX();
useEffect(() => {
if (!actionData) return;
setActionData(actionData);
}, [setActionData, actionData]);
return (
<Form method="post">
<Box className={wrapper}>
<Space h={20} />
<Input.Label htmlFor="username-input" mt={4} className={label}>
<Input
id="username-input"
name="username"
type="text"
defaultValue={actionData?.fields?.username}
aria-label="username"
aria-invalid={Boolean(actionData?.fieldErrors?.username) || Boolean(actionData?.formError)}
aria-errormessage={(actionData?.fieldErrors?.username && 'username-error') || (actionData?.formError && 'User does not exist.')}
placeholder="username"
className={input}
//@ts-ignore
styles={{ input: (actionData?.fieldErrors?.username || actionData?.formError) && errorInput }}
/>
<Input.Error className={errorMessage}>{actionData?.fieldErrors?.username || actionData?.formError} </Input.Error>
</Input.Label>
<Input.Label htmlFor="password-input" mt={4} className={label}>
<Input
id="password-input"
name="password"
type="password"
defaultValue={actionData?.fields?.password}
aria-label="password"
aria-invalid={Boolean(actionData?.fieldErrors?.password) || Boolean(actionData?.formError)}
aria-errormessage={(actionData?.fieldErrors?.password && 'password-error') || (actionData?.formError && 'User does not exist.')}
placeholder="password"
className={input}
//@ts-ignore
styles={{ input: (actionData?.fieldErrors?.password || actionData?.formError) && errorInput }}
/>
<Input.Error className={errorMessage}>{actionData?.fieldErrors?.password || actionData?.formError} </Input.Error>
</Input.Label>
<Button type="submit" variant="gradient" className={button} mt={8}>
Log in
</Button>
<Space h={60} />
</Box>
</Form>
);
}
React의 ErrorBoundary와 같이
정말 쉽고 간단하게
Remix도 에러를 가둘 수 있습니다.
더욱더 나아가
예상치 못한 에러 (500 status, server error)
예상 가능한 에러 (400 status, client error)
모두 나누어 독립적으로 관리가 가능합니다.
이는 remix의 중첩 라우팅과 어우러져 강력한 nesting 기능을 갖춥니다.
export default function $idRoute() {
const { ...data } = useLoaderData() as TodoLoaderData;
return <TodoDetail todo={data} />;
}
export function CatchBoundary() {
const caught = useCatch();
const params = useParams();
const error = { ...caught, ...{ name: caught.data.name || '', message: `Todo id : ${params.id} does not exist.` } };
if (caught.status === 404) {
return <TodoErrorContainer error={error} />;
}
throw new Error(`Unhandled error: ${caught.status}`);
}
export function ErrorBoundary({ error }: { error: CaughtError }) {
return <TodoErrorContainer error={error} />;
}
Remix는 Full Stack Framework이기 때문에
혼자서도 간단한 로그인 인증, 인가도 구현할 수 있습니다.
import { createCookieSessionStorage, redirect } from '@remix-run/node';
import { findUser, findUserWithId, createUser } from '~/models/user.server';
import bcrypt from 'bcryptjs';
import invariant from 'tiny-invariant';
import { User } from '@prisma/client';
interface authParameter {
username: string;
password: string;
}
invariant(process.env.SESSION_SECRET, 'SESSION_SECRET must be set');
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: 'Remix_TodoList_session',
/**
* Safari localhost에서는 `secure: true`가 동작하는 않는 이슈가 있음
* https://web.dev/when-to-use-local-https/
**/
secure: process.env.NODE_ENV === 'production',
secrets: [process.env.SESSION_SECRET],
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 30,
httpOnly: true,
},
});
const USER_SESSION_KEY = 'userId';
function getUserSession(request: Request) {
const cookie = request.headers.get('Cookie');
return sessionStorage.getSession(cookie);
}
export async function login({ username, password }: authParameter) {
// find User
const user = await findUser(username);
if (!user) return;
// password validation
const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);
if (!isCorrectPassword) return;
return { id: user.id, username };
}
export async function logout(request: Request) {
const session = await getUserSession(request);
return redirect('/', {
headers: {
'Set-Cookie': await sessionStorage.destroySession(session),
},
});
}
export async function register({ username, password }: authParameter) {
const passwordHash = await bcrypt.hash(password, 10);
const user = await createUser({ username, passwordHash });
const { id } = user;
return { id, username };
}
export async function getUserId(request: Request): Promise<User['id'] | undefined> {
const session = await getUserSession(request);
const userId = session.get(USER_SESSION_KEY);
if (!userId || typeof userId !== 'string') return;
return userId;
}
export async function getUser(request: Request) {
const userId = await getUserId(request);
if (typeof userId !== 'string') return;
try {
const user = await findUserWithId(userId);
return user;
} catch {
throw logout(request);
}
}
export async function requireUserId(request: Request, redirectTo = new URL(request.url).pathname) {
const userId = await getUserId(request);
if (!userId || typeof userId !== 'string') {
const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
throw redirect(`/auth/login?${searchParams}`);
}
return userId;
}
export async function createUserSession(userId: string, redirectTo: string) {
const session = await sessionStorage.getSession();
session.set(USER_SESSION_KEY, userId);
return redirect(redirectTo, {
headers: {
'Set-Cookie': await sessionStorage.commitSession(session),
},
});
}