백엔드에서 API를 아직 만들지 않았다면 MSW를 사용하자! - 회원가입
MSW
Mock Service Worker
서비스 워커를 사용해 네트워크 호출을 가로채는 API 모킹(mocking)라이브러리
백엔드 API인 척하면서 프론트엔드의 요청에 가짜 데이터를 응답한다.
요청에 대한 응답을 직접 MSW로 만들어 백엔드에서 API를 주기 전에 어떤 형식의 데이터가 들어올 것인지 미리 코드를 작성할 수 있다.
따라서 백엔드 개발자와 미리 회의를 통해 데이터의 정확한 형식을 맞춰 놓는 것이 좋은 방법이다.
- MSW 세팅하기
MSW 초기 세팅
npx msw init public/ --save
public폴더에 msw를 세팅한다. 강의에서는 위 코드로 실행하는데 나는 오류가 발생해서 뒤의 --save를 제외하고 입력했다.
MSW 설치
npm install msw@latest --save-dev
public 폴더에 mockServiceWorker 파일이 생성되었다!
- 요청 핸들러 작성하기
응답을 해주는 핸들러 코드를 작성한다.
모킹 관련 코드는 mocks 디렉토리에서 관리하는 것이 일반적이므로 src폴더 바로 아래 mocks 폴더를 생성한다.
- brower.ts - 클라이언트에서 API요청 모킹을 처리해주는 파일
- handlers.ts - 실제 API 모킹 코드
- http.ts - 서버에서 API요청 모킹을 처리해주는 파일
Next.js는 서버와 클라이언트 모두에서 돌아가기 때문에 서버 사이드 렌더링 시에도 mockServiceWorker가 돌아가야 한다.
아직까지 서버 쪽에서 mockServiceWorker를 자연스럽게 돌리는 방식이 나오지 않았기 때문에 임시로 노드 서버를 활용한다.
// src/mocks/http.ts
import { createMiddleware } from "@mswjs/http-middleware";
import express from "express";
import cors from "cors";
import { handlers } from "./handlers";
const app = express();
const port = 9090;
app.use(
cors({
origin: "http://localhost:3000",
optionsSuccessStatus: 200,
credentials: true,
})
);
app.use(express.json());
app.use(createMiddleware(...handlers));
app.listen(port, () => console.log(`Mock server is running on port: ${port}`));
handler.ts에 핸들러 함수를 작성한다.
handler 함수가 실제 api대신 응답을 보내주는 역할을 한다.
// src/mocks/handler.ts
export const handlers = [
http.post("/api/login", () => {
console.log("로그인");
return HttpResponse.json(User[1], {
// http headers로 쿠키 설정
headers: {
"Set-Cookie": "connect.sid=msw-cookie;HttpOnly;Path=/",
},
});
}),
http.post("/api/logout", () => {
console.log("로그아웃");
return new HttpResponse(null, {
headers: {
"Set-Cookie": "connect.sid=;HttpOnly;Path=/;Max-Age=0",
},
});
}),
http.post("/api/users", async ({ request }) => {
console.log("회원가입");
// return HttpResponse.text(JSON.stringify("user_exists"), {
// status: 403,
// });
return HttpResponse.text(JSON.stringify("ok"), {
headers: {
"Set-Cookie": "connect.sid=msw-cookie;HttpOnly;Path=/;",
},
});
}),
];
- MSW 백엔드 서버 실행하기
package.json에 명령어를 추가한다.
"scripts": {
...
"mock": "npx tsx watch ./src/mocks/http.ts"
},
msw 서버를 실행하는 명령어를 입력한다.
npm run mock
Next 앱에서 MSW를 언제 적용할지 판단할 수 있도록 컴포넌트를 생성한다.
app 폴더 아래 폴더에 공통적으로 적용이 되야하기 때문에 app 폴더 바로 아래에 _component 폴더를 생성한다.
MSWComponent는 클라이언트 컴포넌트로 전체 레이아웃 안에 들어간다.
// /app/_component/MSWComponent.tsx
"use client";
import { useEffect } from "react";
export const MSWComponent = () => {
useEffect(() => {
// 브라우저에서만 돌아가도록 제한
if (typeof window !== "undefined") {
if (process.env.NEXT_PUBLIC_API_MOCKING === "enabled") {
require("@/mocks/browser");
}
}
}, []);
return null;
};
// /app/layout.tsx
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body className={inter.className}>
<MSWComponent />
{children}
</body>
</html>
);
}
.env 파일에 환경변수 NEXT_PUBLIC_API_MOCKING을 추가한다.
// .env
NEXT_PUBLIC_API_MOCKING=enabled
NEXT_PUBLIC_API_MOCKING이 enable인 동안에는 MSW가 동작을 한다.
➷ 환경 변수 네이밍
NEXT_PUBLIC이 앞에 붙으면 -> 브라우저에서 접근할 수 있는 환경변수
API_MOCKING이 앞에 붙으면 -> 브라우저에서 접근할 수 없는 환경변수
Server Action을 사용해 로그인 구현하기
기존 SignUpModal 컴포넌트를 서버 컴포넌트로 바꿔준다.
클라이언트 컴포넌트에서 서버 컴포넌트로 바꾸면서 기존의 useState 훅은 제거한다.
그렇다면 input 값이 들어왔는지 어떻게 확인할까?
⇨ 각 input에 required 속성을 추가한다.
또 formData에서 .get()메소드로 접근할 수 있도록 각각 name 속성을 추가한다.
<form action={submit}>
<div className={style.modalBody}>
<div className={style.inputDiv}>
<label className={style.inputLabel} htmlFor="id">
아이디
</label>
<input
id="id"
name="id"
className={style.input}
type="text"
placeholder=""
required
/>
</div>
<div className={style.inputDiv}>
<label className={style.inputLabel} htmlFor="name">
닉네임
</label>
<input
id="name"
name="name"
className={style.input}
type="text"
placeholder=""
required
/>
</div>
<div className={style.inputDiv}>
<label className={style.inputLabel} htmlFor="password">
비밀번호
</label>
<input
id="password"
name="password"
className={style.input}
type="password"
placeholder=""
required
/>
</div>
<div className={style.inputDiv}>
<label className={style.inputLabel} htmlFor="image">
프로필
</label>
<input
id="image"
name="image"
className={style.input}
type="file"
accept="image/*"
required
/>
</div>
</div>
<div className={style.modalFooter}>
<button
type="submit"
className={style.actionButton}
>
가입하기
</button>
</div>
</form>
form을 제출하는 동작인 server action을 action 속성에 넘겨준다.
- server action 작성하기
formData를 받아 제출 동작을 수행할 서버 액션 함수를 만든다.
서버 액션 함수를 사용할 때는 함수 상단에 "use server"를 작성한다.
서버 코드는 브라우저에서 노출이 되지 않기 때문에 key나 secret값을 안전하게 사용할 수 있다.
const submit = async(formData:FormData) => {
"use server";
};
"use server"를 선언한 프론트엔드 서버로 백엔드 서버로 별도의 요청을 한 번 더 보낸다.
http.post("/api/users", async ({ request }) => {
console.log("회원가입");
return HttpResponse.text(JSON.stringify("ok"), {
headers: {
"Set-Cookie": "connect.sid=msw-cookie;HttpOnly;Path=/;",
},
});
}),
handler에 작성한 서버 주소로 요청을 보내면 된다.
const submit = (formData:FormData) => {
"use server";
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/users`,
{
method: "post",
body: formData,
credentials: "include",
}
);
};
// .env
NEXT_PUBLIC_BASE_URL=http://localhost:9090
URL 주소는 .env 파일에서 관리한다.
현재는 가짜 서버를 사용하기 때문에 localhost:9090을 넣어준다.
쿠키를 전달하기 위해 Credentials: "include"를 헤더에 담아 넘긴다.
쿠키가 필요한 이유?
로그인 상태인 경우 회원가입을 불가능하게 하기 위해 쿠키로 로그인 유무를 판단한다.
회원가입이 완료되면 로그인이 된 상태로 홈 페이지로 리다이렉트한다.
여기서 redirect는 try-catch문 내부에서 사용할 수 없기 때문에 밖으로 빼준다.
const submit = (formData:FormData) => {
"use server";
let shouldRedirect = false;
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/users`,
{
method: "post",
body: formData,
credentials: "include",
}
);
console.log(response.status);
console.log(await response.json());
shouldRedirect = true;
} catch (error) {
console.error(error);
return { message: null };
}
if (shouldRedirect) {
redirect("/home");
} // home페이지로 이동
// redirect는 try/catch문 안에서 사용할 수 없음
};
- 에러 메세지 표시하기
input 항목이 비어있거나 이미 존재하는 아이디인 경우 에러 메세지를 표시한다.
handler에 응답 코드를 추가한다.
http.post("/api/users", async ({ request }) => {
console.log("회원가입");
return HttpResponse.text(JSON.stringify("user_exists"), {
status: 403,
});
//return HttpResponse.text(JSON.stringify("ok"), {
//headers: {
// "Set-Cookie": "connect.sid=msw-cookie;HttpOnly;Path=/;",
// },
//});
}),
submit 함수에 입력창 검증 코드와 403 응답을 받았을 때 오류 메세지를 리턴하는 코드를 추가한다.
const submit = (formData:FormData) => {
"use server";
// formData 검증
if (!formData.get("id") || !(formData.get("id") as string)?.trim()) {
return { message: "no_id" };
}
if (!formData.get("name") || !(formData.get("name") as string)?.trim()) {
return { message: "no_name" };
}
if (
!formData.get("password") ||
!(formData.get("password") as string)?.trim()
) {
return { message: "no_password" };
}
if (!formData.get("image")) {
return { message: "no_image" };
}
let shouldRedirect = false;
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/users`,
{
method: "post",
body: formData,
credentials: "include",
}
);
console.log(response.status);
// 403응답을 받은 경우
if (response.status === 403) {
return { message: "user_exists" };
}
console.log(await response.json());
shouldRedirect = true;
} catch (error) {
console.error(error);
return { message: null };
}
if (shouldRedirect) {
redirect("/home");
} // home페이지로 이동
// redirect는 try/catch문 안에서 사용할 수 없음
};
에러 메세지를 화면에 띄우기 위해서는 클라이언트 컴포넌트에서 보여줘야 한다.
따라서 SignUpModal 컴포넌트를 다시 클라이언트 컴포넌트로 만들어준다.
서버 액션은 클라이언트와 서버 컴포넌트 모두에서 사용할 수 있지만 여기서는 submit 서버 액션 함수를 다른 파일로 분리해서 사용하겠다.
private한 lib 폴더를 생성하고 그 안에 signup.ts 파일을 만들어 코드를 넣어준다.
- lib -> 외부 함수들을 모아 관리하는 폴더
// /_lib/signup.ts
"use server";
import { redirect } from "next/navigation";
export default async (prevState: any, formData: FormData) => {
...
};
"use server"는 최상단으로 끌어와도 괜찮다.
SignUpModal 컴포넌트에서 이 함수를 받아와 사용한다.
import onSubmit from "../_lib/signup";
export default function SignupModal() {
const submit = onSubmit;
return (
<>
<div className={style.modalBackground}>
...
<form action={submit}>
...
);
}
에러 메세지를 표시하기 위해 React-Dom에서 useFormState와 useFormStatus 훅을 가져와 사용한다.
useFormState
form action의 결과에 기반하여 상태를 업데이트할 수 있게 하는 훅.
const [state, formAction] = useFormState(fn, initialState, permalink?);
- fn : form이 제출될 때 호출할 함수. 초기 인자로 form의 이전 state를 받음
- initialState : state의 초기값. 처음 호출된 이후에는 무시
- permalink(옵셔널) : form이 수정하는 고유 페이지 URL을 포함하는 문자열
- state : 액션에 의해 반환된 값. 첫 렌더링에서는 initialState
- formAction : form 내의 버튼 컴포넌트의 formAction 속성으로 전달할 수 있는 새로운 액션
여기서 onSubmit 함수는 추가로 초기값을 매개변수로 받아야하기 때문에 수정한다.
export default async (revState: { message: string | null } | undefined,formData: FormData) => {
...
};
useFormStatus
마지막 form 제출의 status 정보를 제공하는 훅.
const { pending, data, method, action } = useFormStatus();
- pending : 제출중이면 true, 아니면 false
- data : form이 제출하는 데이터를 포함하는 FormData 인터페이스를 구현한 객체. 활성화된 제출이 없거나 <form> 부모가 없으면 null
- method : HTTP 메소드 중 어떤 것으로 제출되는지 알려줌. 문자열 형식의 get 또는 post
- action : 부모 <form>의 action 속성에 전달된 함수에 대한 참조. action 속성에 URI 값이 제공되었거나 지정되지 않았으면 null
여기서는 제출 상태에 따라 버튼을 비활성화하기 위해 pending을 받아와 사용했다.
받아오는 에러에 따라 다른 메세지를 띄우기 위해 showMessage 함수를 생성하고 state로 받아온 메세지를 전달한다.
import { useFormState, useFormStatus } from "react-dom";
function showMessage(messasge: string | null | undefined) {
if (messasge === "no_id") {
return "아이디를 입력하세요.";
}
if (messasge === "no_name") {
return "닉네임을 입력하세요.";
}
if (messasge === "no_password") {
return "비밀번호를 입력하세요.";
}
if (messasge === "no_image") {
return "이미지를 업로드하세요.";
}
if (messasge === "user_exists") {
return "이미 사용 중인 아이디입니다.";
}
return "";
}
export default function SignupModal() {
const [state, formAction] = useFormState(onSubmit, { message: null });
const { pending } = useFormStatus();
return (
<>
<div className={style.modalBackground}>
...
<form action={formAction}>
...
<button
type="submit"
className={style.actionButton}
// 회원가입 중 일때는 버튼 비활성화
disabled={pending}
>
가입하기
</button>
<div className={style.error}>{showMessage(state?.message)}</div>
</div>
</form>
</div>
</div>
</>
);
}