Protected Handlers
다음과 같은 상황을 생각해 봅시다. 로그인도 안한 사용자가 GET /api/users/me을 보낸다고 해 봅시다. 그런데 이는 인가가 되지 않은 사용자 입니다. 당연히 오류가 나겠죠. 이 api에서는 req.session.user가 있어야 prisma client에서 이에 해당하는 user를 찾을 수 있기 대문입니다.
이에 대한 해결방안은 많습니다. 당연히 me.txt에 하나하나씩 req.session.user가 있는지 확인하면 됩니다. 하지만 모든 페이지에서, 이를 하기란 매우 귀찮기 짝이 없을 수 없습니다. 저희는 그래서 withHandler에서 이를 1차로 막고 가자는 생각을 해 볼 수 이씃ㅂ니다.
withHandler에서 isPrivate값을 받고, 이가 true이라면 req.session.user가 있어야만 그 다음 단계로 넘어가게 말이죠, 만약 private가 true인데 req.session.user가 없다면 { ok: false, error: "~~" }를 client에 보내버리면 되겠죠
그리고 isPrivate의 default값을 true로 하는 것이 좋습니다. 그 이유는. 대부분의 api들은 login되어야지만 사용할 수 있게끔 설계되어야 하기 때문입니다. 예를 들어 봅시다. confirm, enter만이 login이 되지 않는 상황에서도 api요청을 보낼 수 있기 때문입니다. 따라서 이러한 값들만 isPrivate: false로 인자로 주면 된다고 생각하면 됩니다. 아래 코드를 봅시다.
/libs/server/withHandler.ts
import type {
NextApiRequest,
NextApiResponse,
NextApiHandler,
} from "next/types";
type MethodType = "GET" | "POST" | "DELETE";
export interface ResponseType {
ok: boolean;
[key: string]: any;
}
interface ConfigType {
method: MethodType;
handler: NextApiHandler<ResponseType>;
isPrivate?: boolean;
}
type HandlerType = {
(config: ConfigType): NextApiHandler;
};
const withHandler: HandlerType = ({
method,
handler,
isPrivate = true,
}) => {
return async function (req, res): Promise<any> {
if (req.method !== method) {
res.status(405).end();
}
if (isPrivate && !req.session.user) {
return res
.status(401)
.json({ ok: false, error: "Plz log in." });
}
try {
await handler(req, res);
} catch (error) {
console.error(error);
if (error instanceof Error) {
res.status(500).json({ error: error.message });
} else {
res.status(500).json({ error: "Internal Server Error" });
}
}
};
};
export default withHandler;
/pages/api/users/enter.tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import twilio from "twilio";
import mail from "@sendgrid/mail";
import { withApiSession } from "@libs/server/withSession";
mail.setApiKey(process.env.SENDGRID_API_KEY!);
const twilioClient = twilio(
process.env.TWILIO_SID,
process.env.TWILIO_TOKEN
);
interface reqDataType {
email?: string;
phone?: string;
}
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse<ResponseType>
) => {
const { email, phone }: reqDataType = req.body;
const user = phone ? { phone } : email ? { email } : null;
if (!user) return res.status(400).json({ ok: false });
const payload = Math.floor(10000 + Math.random() * 90000) + "";
const token = await client.token.create({
data: {
payload,
user: {
connectOrCreate: {
where: {
...user,
},
create: {
name: "Anonymous",
...user,
},
},
},
},
});
// if (phone) {
// const message = await twilioClient.messages.create({
// messagingServiceSid: process.env.TWILIO_MSID,
// // 원래라면 phone으로 보내야 하지만 -> dev process에서는 그냥 내 폰으로
// to: process.env.MY_PHONE!,
// body: `Your login token is ${payload}`,
// });
// console.log(message);
// } else if (email) {
// const email = await mail.send({
// from: "heart20021010@gmail.com",
// to: "dhdbswl021010@naver.com",
// subject: "Your Carrot Market Verification Email",
// text: `Your token is ${payload}`,
// html: `<strong>Your token is ${payload}</strong>`,
// });
// console.log(email);
// }
res.status(200).json({ ok: true });
};
export default withApiSession(
withHandler({
method: "POST",
handler,
isPrivate: false,
})
);
/pages/api/users/confirm.tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const { token } = req.body;
const foundToken = await client.token.findUnique({
where: {
payload: token,
},
include: {
user: true,
},
});
if (!foundToken) return res.status(404).end() as any;
req.session.user = {
id: foundToken.userId,
};
await req.session.save();
await client.token.deleteMany({
where: {
userId: foundToken.userId,
},
});
res.json({
ok: true,
});
};
export default withApiSession(
withHandler({
method: "POST",
handler,
isPrivate: false,
})
);
/pages/api/users/me.tsx
import client from "@libs/client/client";
import withHandler, {
ResponseType,
} from "@libs/server/withHandler";
// prettier-ignore
import type { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
import { withApiSession } from "@libs/server/withSession";
const handler: NextApiHandler = async (
req: NextApiRequest,
res: NextApiResponse
) => {
const profile = await client.user.findUnique({
where: {
id: req.session.user?.id,
},
});
res.json({
ok: true,
profile,
});
};
export default withApiSession(
withHandler({
method: "GET",
handler,
})
);
useUser Hook
저희는 로그인이 되고 confirm api가 { ok: true }를 리턴하면 이전에 작성했던 Home 페이지로 리다이렉션 되도록 하였습니다. 그리고 저희는 이 Home 프론트 페이지에서 지금 로그인된 사용자의 정보를 확인하고 싶을 겁니다. 이를 위해서 저희는 간단한 useUser Hook을 작성해 보도록 하겠습니다.
/libs/client/useUser.tsx
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
export default function useUser() {
const [user, setUser] = useState();
const router = useRouter();
useEffect(() => {
fetch("/api/users/me")
.then((response) => response.json())
.then((data) => {
if (!data.ok) {
return router.replace("/enter");
}
setUser(data.profile);
});
}, [router]);
return user;
}
간단합니다. 그냥 GET /api/users/me 를 fetch api로 호출해서 data의 ok가 true라면 user의 정보를 받고 return하면 됩니다. 만약 login이 되어 있지 않은 상태라면 /enter (로그인)페이지로 redirection시켜주면 됩니다. 여기서 replace를 사용했는데, 짜피 뒤로가봤자 다시 로그인이 되지 않는한 redirection되기 때문에 그냥 아예 바꿔버렸습니다.
/pages/index.tsx
import type { NextPage } from "next";
import Layout from "@components/layout";
import Item from "@components/item";
import FloatingButton from "@components/FloatingButton";
import useUser from "@libs/client/useUser";
import Head from "next/head";
const Home: NextPage = () => {
const user = useUser();
console.log(user);
return (
<Layout title="홈" hasTabBar>
<Head>
<title>Home</title>
</Head>
<div className="p flex flex-col space-y-5 py-2">
{[...Array(11)].map((_, i) => (
<Item
key={i}
id={i + 1}
title="new iPhone 14"
price={95}
comments={1}
hearts={1}
/>
))}
<FloatingButton href="/items/upload">
<svg
className="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</FloatingButton>
</div>
</Layout>
);
};
export default Home;
그럼 다음과 같이 useUser Hook을 사용하고 user정보를 출력하면 처음에는 undefined이였다가 로그인 중인 사용자 정보가 session정보를 바탕으로 잘 console에 찍히는 것을 확인해 볼 수 있습니다.
하지만 저희 개발자는 매번 useUser Hook을 사용해서 사용자를 받아 오는 것은 매우 비효율적인 일이라고 생각할 겁니다. 따라서 이러한 값을 캐싱해서 잠깐 다른 페이지에 갔다오더라도 다시 훅을 실행해서 user값을 받아오는 비효율적인 일을 하지 않아야 겠다고 생각해야 합니다. 저희는 이러한 일을 방지하기 위해 이 값을 캐싱하는 방법을 떠올려야 합니다. 이를 위해서 저희는 SWR을 사용해 보도록 하겠습니다.
SWR
swr는 진짜로 섹시한 아이입니다. "SWR"은 먼저 캐시(스태일)로부터 데이터를 반환한 후, fetch 요청(재검증)을 하고, 최종적으로 최산화된 데이터를 가져오는 전략입니다. swr을 사용하면 컴포넌트는 지속적이며 자동으로 데이터 업데이트 스트림을 받게 됩니다. 그리고 UI는 항상 빠르고 반응적입니다.
import useSWR from 'swr'
function Profile() {
const { data, error } = useSWR('/api/user', fetcher)
if (error) return <div>failed to load</div>
if (!data) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
이와 같은 예시에서, useSWR hook은 key문자열과, fetcher함수를 받습니다. key는 데이터의 고유한 식별자이며(일반적으로 API URL) fetcher로 전달될 것입니다. fetcher는 데이터를 반환하는 어떠한 비동기 함수도 될 수 있습니다. 네이티브 fetch또는 Axios와 같은 도구를 사용할 수도 있습니다.
hook은 두 개의 값을 반환합니다. 요청의 상태에 기반한 data와 error데이터 입니다.
/libs/client/useUser.tsx
import { useRouter } from "next/router";
import { useState, useEffect } from "react";
import useSWR from "swr";
const fetcher = (url: string) =>
fetch(url).then((response) => response.json());
export default function useUser() {
const { data, error } = useSWR("/api/users/me", fetcher);
// return router.replace("/enter");
return data;
}
다음과 같이 useSWR 훅을 사용해서 코드를 확기적으로 줄일 수 있습니다.
이렇게 하면 하나의 훅만으로 전역상태를 관리해 질 수 있게 됩니다. 또한 전의 상태를 재사용해서 로딩상태를 보지 않고 이전 데이터를 볼 수 있게 할 수 있습니다. 하지만 SWR는 매우 똑똑하기 때문에, 아무도 모르게 API요청을 보내 데이터 내용이 바뀌었는지 체크를 할겁니다. 만약 바뀌었다면 데이터를 업데이트 해주는 겁니다.
우선 페이지에 접속하면 SWR이 데이터를 불러오고, API응답이 도착하면 그 데이터를 캐시에 저장할겁니다. 다른페이지로 갔다가 다시 돌아오면 SWR이 전에 받은 데이터를 보여줄거고, 아무도 모르게 API에 새로운 데이터가 있는지 확인할겁니다. 그래서 항상 최신 데이터를 확인할 수 있고, 페이지를 이동할 때마다 로딩 표시르 ㄹ볼 필요가 없는겁니다.
그리고 SWRConfig을 사용해서 전역적으로 fetcher를 관리해 보도록 하겠습니다.
/pages/_app.tsx
import "../styles/globals.css";
import type { AppProps } from "next/app";
import useSWR, { SWRConfig } from "swr";
function MyApp({ Component, pageProps }: AppProps) {
return (
<SWRConfig
value={{
refreshInterval: 3000,
fetcher: (url: string) =>
fetch(url).then((response) => response.json()),
}}
>
<div className="mx-auto w-full max-w-xl">
<Component {...pageProps} />
</div>
</SWRConfig>
);
}
export default MyApp;
/lib/client/useUser.tsx
import { useRouter } from "next/router";
import { useEffect } from "react";
import useSWR from "swr";
export default function useUser() {
const { data, error } = useSWR("/api/users/me");
const router = useRouter();
useEffect(() => {
if (data && !data.ok) {
router.replace("/enter");
}
}, [data, router]);
return { user: data?.profile, isLoading: !data && !error };
}
'Web > CloneCoding' 카테고리의 다른 글
[Carrot Market] #11 - PRODUCT - FINISH (0) | 2022.05.18 |
---|---|
[Carrot Market] #11 PRODUCTS - START (0) | 2022.05.17 |
[Carrot Market] #9 - Authentication - 2 (0) | 2022.05.12 |
[Carrot Market] #9 - Authentication - 1 (0) | 2022.05.12 |
[Carrot Market] #8 REFACTORING Form (0) | 2022.05.04 |