๐ SSR์ ์ด์ฉํ ์ธ์ฆ/์ธ๊ฐ ๋์
์ด๋ฒ์ ํ๋ก์ ํธ๋ฅผ ๊ณ ๋ฏผํ๋ฉด์ ํญ์ ๋ต๋ตํ๋ ๋ถ๋ถ์ด์๋ ํ์ด์ง redirection๊ณผ ์ธ์ฆ ๊ณผ์ ์ ๋ํด ๋ ๊น์ด ๊ณต๋ถํ๋ค. CSR๋ก ์ฒ๋ฆฌํ๋ ์ธ์ฆ ๋ฐฉ์์ SSR๋ก ์์ ํ๊ธฐ๊น์ง ๊ณผ์ ์ ์ ๋ฆฌํด ๋ณด๋ ค ํ๋ค.
๐ Firebase์ API๋ฅผ ์ด์ฉํ User ์ํ ๊ด๋ฆฌ
๊ธฐ์กด Authentication์ firebase Auth๋ฅผ ์ด์ฉํด ๋ฐ์์จ user์ ๋ณด์ token์ localstorage์ ์ ์ฅํด์ ํ์ธํ๋ค. localstorage์ token์ ์ ์ฅํ๋ ๋ฐฉ์์ ๋ณด์์ ์ทจ์ฝํ๋ค๋ ์๊ธฐ๋ฅผ ๋ง์ด ๋ค์ด, ์๋ก์ด ๋ฐฉ๋ฒ์ ๊ณ ๋ฏผํ๊ณ  ์์๋ค. ๊ณ ๋ฏผ๊ณผ์ ์์ firebase์ API๋ก onAuthStateChanged๊ฐ ์๋ค๋ ๊ฒ์ ์๊ฒ ๋์๊ณ , API๋ฅผ ์ด์ฉํ๋ค๋ฉด ๋ณ๋์ ํ ํฐ๊ณผ ์ฟ ํค๋ฅผ ์ง์  ์ ์ฅํ์ง ์์๋ ๋  ๊ฒ์ด๋ ์์์ด ๋์ด ์ ์ฉํ๋ค.
//AuthService.ts
export class AuthServiceImpl implements AuthService {
  googleProvider: GoogleAuthProvider;
  githubProvider: GithubAuthProvider;
  auth: Auth;
  constructor(private app: FirebaseApp) {
    this.googleProvider = new GoogleAuthProvider();
    this.githubProvider = new GithubAuthProvider();
    this.auth = getAuth(this.app);
  }
    ...
  onUserStateChanged(callback: Dispatch<SetStateAction<User | null>>) {
    return onAuthStateChanged(this.auth, callback);
  }
}
//AuthContext.tsx
type InitialValue = {
  authService: AuthService;
  user: User | null;
};
const AuthContext = createContext<InitialValue | null>(null);
export const AuthProvider = ({ children, authService }: AuthProviderProps) => {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => {
    authService.onUserStateChanged((user) => {
      setUser(user);
    });
  }, []);
  return (
    <AuthContext.Provider value={{ user, authService }}>
      {children}
    </AuthContext.Provider>
  );
};
export const useAuthService = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('Not under AuthProvider');
  }
  return context;
};์ด๋ ๊ฒ ์์ ํ๊ณ API๋ฅผ ์ด์ฉํด login์ํ๋ฅผ ํ์ธํด์ ํ์ด์ง ์ด๋์ ํ๋ค ๋ณด๋ ๋ค๋ฅธ ๋ฌธ์ ๊ฐ ์๊ฒผ๋ค. ๋๋๋ง์ ํ๊ณ API๋ก user์ํ๋ฅผ ๋ฐ๊พธ๊ธฐ ์ , ์ด๊ธฐ ๊ฐ์ด null๋ก ๋์ด์์ด API ํธ์ถ์ user๊ฐ ์๋ ์ํ๋ก ํธ์ถ๋์ด ์๋ฌ๊ฐ ๋ํ๋ฌ๋ค. ์ฐ์ useJobs ํ ๋ด๋ถ์ useEffect๋ก user์ํ๊ฐ ๋ฌ๋ผ์ง๋ฉด refetchํด ๋ฐ์์ฌ ์ ์๊ฒ ํด๊ฒฐํ๋ค.
export const useSpecificJobs = () => {
  const { user } = useAuthService();
  const { query } = useRouter();
  const { id } = query;
  const dbService = useDBService();
  const getFilteredJobs = useQuery(
    [JOBS_KEY],
    () => {
      if (!user) {
        return {};
      }
      return dbService.getJobs(user);
    },
    {
      select: (data: ModifiedJobsType) => {
        if (!data) {
          return [];
        }
        return Object.values(data).filter((item) => item.id !== id);
      },
      onError: (error) => {
        console.log(error);
      },
    }
  );
  useEffect(() => {
    getFilteredJobs.refetch();
  }, [user]);
	...
};์ฑ์ฉ๊ณต๊ณ ๋ค์ ๋ฐ์์ค๋ ๊ณผ์ ์ด ๋์ด๋๊ฒ ๋์ด ๊ฐ์ API๋ฅผ ๋๋ฒ ํธ์ถํ๋ ๊ณผ์ ์์ ์๊ฐ์ด ๋ ์ค๋ ๊ฑธ๋ฆฌ๊ฒ ๋์๋ค.
[ํธ์ถ ๊ณผ์ ]
- ๋ฉ์ธ ํ์ด์ง ๋ ๋๋ง
 - APIํธ์ถ
 - User์ํ ์ ๋ฐ์ดํธ
 - API ์ฌํธ์ถ
 
ํด๊ฒฐ๋ฐฉ๋ฒ์ผ๋ก user๋ฅผ ๋จผ์  ๋ฐ๊ณ API์ฝ์ ํ๋ ๋ฐฉ๋ฒ๊ณผ, auth๋ฅผ server-side๋ก ๋จผ์  ๋ฐ์์ค๋ ๋ฐฉ๋ฒ ๋๊ฐ์ง ๋ฐฉ๋ฒ์ด ๋ ์ฌ๋๋ค. ๊ทธ์ค ๋จผ์  ํ์ฌ ์งํํ๊ณ ์๋ CSR์์ ํด๊ฒฐ๋ฐฉ๋ฒ์ ์ฐพ์๋ณด์๋ค.
๐ป CSR์์์ ํด๊ฒฐ๋ฒ: Protected Route ๋์
CSR์์ ํด๊ฒฐํ๊ธฐ ์ํด์๋ fetch๋ก user๋ฅผ ๋ฐ์์จ ํ์ api๋ฅผ ํธ์ถํ ์ ์๊ฒ ํด์ผ ํ๋ค. ๊ฒ์ํด์ ์๋กญ๊ฒ ์๊ฒ ๋ ๊ฒ์ Protected Route๋ผ๋ HOC๋ฅผ ์ด์ฉํ๋ ๋ฐฉ๋ฒ์ด์๋ค.
1) ๋ก๊ทธ์ธ flow ์์ ํ๊ธฐ
๋จผ์  ๊ฐ์ฅ ์๊ธํ ๋ฌธ์ ๋ ๋ก๊ทธ์ธ ํ์ ๋ฉ์ธ ํ์ด์ง ์ด๋์์ User๊ฐ ์๋ ์ํ๋ก API๊ฐ ํธ์ถ๋๋ค๋ ์ ์ด์๋ค. ์ด๊ฒ์ ๋ง๊ธฐ ์ํด์๋ ๋จผ์  user์ํ๋ฅผ ๊ด๋ฆฌํด ์ค ์ ์๋ component๊ฐ ํ์ํ๊ณ , AuthStateChanged๋ผ๋ ์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค์ด ์ปดํฌ๋ํธ๊ฐ ๋ง์ดํธ๋๋ฉด ๋จผ์  User๋ฅผ ๋ฐ์์ค๊ณ , ์ดํ์ ๋ฉ์ธ ํ์ด์ง๊ฐ ๋ ๋๋ง ๋ ์ ์๊ฒ ํ๋ค.
// AuthStateChanged.tsx
import React, { useEffect, useState } from 'react';
import { useAuthService } from '../context/AuthContext';
export default function AuthStateChanged({
  children,
}: {
  children: React.ReactNode;
}) {
  const { authService, setUser } = useAuthService();
  const [loading, setLoading] = useState(true);
  useEffect(() => {
    authService.onUserStateChanged((user) => {
      setUser(user);
      setLoading(false);
    });
  }, []);
  if (loading) {
    return <h1>๋ก๋ฉ์ค</>;
  }
  return <>{children}</>;
}
// _app.tsx
function MyApp({ Component, pageProps }: AppProps) {
    ...
  return (
    <>
      <QueryClientProvider client={queryClient}>
        <DBProvider dbService={dbService}>
          <AuthProvider authService={authService}>
            <AuthStateChanged>
              <ThemeProvider theme={theme}>
                <GlobalStyle />
                <Component {...pageProps} />
              </ThemeProvider>
            </AuthStateChanged>
          </AuthProvider>
        </DBProvider>
      </QueryClientProvider>
    </>
  );
}
export default MyApp;User๊ฐ ์์ด์ผ๋ง ๋ค์ ์ปดํฌ๋ํธ๋ค๋ก ๋์ด๊ฐ ๋์ํ๊ธฐ ๋๋ฌธ์ ์ด์  ๊ฐ์ฅ ํฐ ๋ฌธ์ ์๋ API๋ฅผ ์ ์์ ์ผ๋ก ํ๋ฒ๋ง ํธ์ถํ๊ฒ ํด๊ฒฐํ ์ ์์๋ค.
ํ์ง๋ง ์ฌ์ ํ ์ด์ง ๋ฌธ์ ๊ฐ ๋จ์์์๋ ๊ฒ์ AuthStateChanged์์ User๋ฅผ ๋ฐ์์ค๋ ๋์ ํ๋ฉด์ ๋ก๋ฉ์ ๋ณด์ฌ์ค์ผ ํ๋ค๋ ์ ์ด์๋ค. ๋ก๋ฉ์ ์ ๋ณด์ฌ์ฃผ๊ธฐ ์ํด loading์ด true์ผ ๋ <></> ๋ฆฌ์กํธ fragment๋ก ๋ฐํํ๋ค ํด๋ ์ฌ์ ํ ๋ถ๋ฌ์ค๋ ๋์์ ๋น ํ์ด์ง๊ฐ ๋ณด์๋ค.
[AuthStateChanged ์ปดํฌ๋ํธ๋ฅผ ์ถ๊ฐํ ํ ์๋ก๊ณ ์นจํ ๋ชจ์ต]

2) Protected Route
๋ ๋ฒ์งธ๋ก ๋ก๊ทธ์ธํ์ง ์๊ณ ๋ฉ์ธ ํ์ด์ง์ ์ ์ํ๋ ๊ฒฝ์ฐ์ ๋ก๊ทธ์ธ ํ์๋ ๋ก๊ทธ์ธ ํ์ด์ง์ ์ ์ํ๋ ค๋ ๊ฒฝ์ฐ๋ฅผ ๋ง๊ธฐ ์ํ ๋ฆฌ๋ค์ด๋ ์  ๋ก์ง์ด ํ์ํ๋ค. ์ด๊ฒ์ ์ํด์ AuthStateChanged ์ปดํฌ๋ํธ์ ์ ์ฌํ๊ฒ ์กฐ๊ฑด์ ๋ฐ๋ผ ๋ ๋๋ง์ ํด์ค ์ ์๋ ProtectedRoutes ์ปดํฌ๋ํธ๋ฅผ ์ถ๊ฐํ๋ค. Protected Route๋ ํ์ํ ํ์ด์ง์ ๋ง๊ฒ ์ฌ์ฉ๋์ด์ผ ํ๊ธฐ ๋๋ฌธ์ type์ ์ ํ ๋ Generic์ ์ด์ฉํด ์ ํด ์ค ์ ์์๋ค.
//ProtectRoute.tsx
import { useRouter } from "next/router"
import React, { ComponentType } from "react"
import { useAuthService } from "../context/AuthContext"
export function withPublic<T>(Component: ComponentType<T>) {
  return function WithPublic(props: T) {
    const auth = useAuthService()
    const router = useRouter()
    if (auth.user) {
      router.replace("/")
      return <></>
    }
    return <Component auth={auth} {...props} />
  }
}
export function withProtected<T>(Component: React.ComponentType<T>) {
  return function WithProtected(props: T) {
    const auth = useAuthService()
    const router = useRouter()
    if (!auth.user) {
      router.replace("/login")
      return <></>
    }
    return <Component auth={auth} {...props} />
  }
}
// pages/login.tsx
function Login() {
  // ...
}
export default withPublic(Login)
// Pages/register.tsx
function Register() {
  // ...
}
export default withPublic(Register)
// pages/index.tsx
function Home() {
  // ...
}
export default withProtected(Home)
// pages/job/[id].tsx
function Index() {
  // ...
}
export default withProtected(Index)Protected Route๋ฅผ ์ด์ฉํด ๋ก๊ทธ์ธ์ ํ ํ์ home์์ login์ผ๋ก ์ด๋ํ๊ฑฐ๋ ๋ก๊ทธ์ธ์ ํ์ง ์๊ณ login์์ home์ผ๋ก์ ์ด๋์ ๋ง์ ์ ์์๋ค. ํ์ง๋ง ์ฌ์ ํ ์์ ๋ฌธ์ ๊ฐ ๋์๋ ํ์ด์ง ์ด๋์๊ฐ๋์ ๋ก๋ฉ์ ๋ณด์ฌ์ค์ผ ํ๋ ๋ฌธ์ ๊ฐ ์์๋ค.
Protected Routes๋ฅผ ์๊ธฐ ์ ์ ํด๊ฒฐ๋ฐฉ๋ฒ์ผ๋ก ๋ ์ฌ๋ ธ๋ ์๋ฒ์ฌ์ด๋์์ auth๋ฅผ ๋ฏธ๋ฆฌ ๋ฐ์์ ๋ ๋๋ง ์ ์ ์ฒดํฌํ ํ์, ํด๋น ํ์ด์ง์์ auth๋ฅผ ๋ฐ๋ก ๋ฐ์๋ณผ ์ ์๋ค๋ฉด ์ข ๋ ๋ก์ง๋ ๊ฐ๋จํด์ง๊ณ , next๋ฅผ ์ ํ์ฉํ๋ ๋ฐฉ๋ฒ์ด ๋์ง ์์๊น๋ผ๋ ์๊ฐ์ด ๋ค์ด ๋ค์ ์ฐพ์๋ณด๊ธฐ ์์ํ๋ค.
๐พ Next-auth๋ฅผ ์ด์ฉํ SSR๋ก ์ ํ
SSR์ ์ด์ฉํ๊ธฐ ์ํ ๋ฐฉ๋ฒ์ ์ฐพ๊ธฐ์ํด Next JS ๊ณต์ ์ฌ์ดํธ๋ฅผ ์ฐพ์๋ณด๋, ์ ์๋ ๋ฐฉ๋ฒ์ผ๋ก with-iron-session๊ณผ next-auth๋ผ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ด์ฉํ๋ ๋ฐฉ๋ฒ์ด์๋ค. ๊ทธ์ค์์ ๊ธฐ์กด ์ง์ํ๋ OAuth๋ฅผ ์ด์ฉํ ๋ก๊ทธ์ธ ๋ฐฉ์์ ๊ทธ๋๋ก ์ฌ์ฉํ  ์ ์๋ next-auth๋ฅผ ์ ํํ๋ค.
Next-auth๋ Next js์์ ์ฝ๊ฒ ์ธ์ฆ๊ณผ์ ์ ๊ตฌํํด ์ค ์ ์๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ก, API Routes๋ฅผ ์ด์ฉํด ์๋ฒ๋จ์์ session๊ณผ ํ ํฐ์ ๋ง๋ค๊ณ ํ์ธํด client์์ ํ์ํ๋ ํ์ธ๋ก์ง์ ์ค์ฌ์ค ์ ์๋ค. Next Auth๋ฅผ ๊ณต๋ถํ๋ฉด์ ๊ต์ฅํ ๋ค์ํ Provider๋ค์ OAuth๋ก ์ ๊ณตํด์ค๋ค๋ ์ ์ด ๋๋ผ์ ๋ค. ํนํ, ์นด์นด์คํก๊ณผ ๋ค์ด๋ฒ๋ ์ง์ํด์ค๋ค๋ ์ ์ด ์ ๊ธฐํ๊ณ , ๋ค์ํ DB์๋ ์ฐ๋ํด์ ์ฌ์ฉํ ์ ์์ด ์ข์๋ค.
SSR์ Next-auth๋ก ์ ์ฉํ ์ ์๋ค๋ ์ฌ์ค์ ์๊ฒ ๋ ํ์, ๋ผ์ด๋ธ๋ฌ๋ฆฌ๋ฅผ ์ ์ฌ์ฉํ๊ธฐ ์ํด ์ธ์ฆ, ์ธ๊ฐ ๊ณผ์ ๊ณผ ์์ ์ฌ์ฉํ๋ ๋ฐฉ์์ธ local storage์ ๋๋ ๋ฐฉ์์ด ์ ์ ์ข์์ง์ ๋ํด ์ดํดํ๊ณ , Next-auth๊ฐ ํด๊ฒฐ์ฑ ์ด ๋ ์ ์๋์ง ๊ณ ๋ฏผํ๋ค.
๐ ํ ํฐ์ localStorage์ ๋๋ฉด ์๋๋ ์ด์
local storage๋ ๋ธ๋ผ์ฐ์ ๊ฐ ๋ซํ๋ ์ ์ง๋๊ธฐ ๋๋ฌธ์ ๋ก๊ทธ์ธ์ ์ ์งํ  ์ ์๋ ๊ฐํธํ ๋ฐฉ๋ฒ์ผ๋ก ์ฌ์ฉํด์๋ค. ํ์ง๋ง ์ด๋ฌํ ๋ฐฉ์์ XSS (Cross-Site Scripting) ๊ณต๊ฒฉ์ ์ทจ์ฝํ ๋จ์ ์ ๊ฐ์ง๋ค.
XSS (Cross-site scripting)
XSS ๊ณต๊ฒฉ์ ์น์ฌ์ดํธ์ ์ ์ฑ ์คํฌ๋ฆฝํธ๋ฅผ ์ฃผ์ ํ๋ ๋ฐฉ์์ผ๋ก ์ฃผ๋ก ํ์ด์ง์ input์ด๋ form์ ์ด์ฉํด ๊ณต๊ฒฉํ๋ ๋ฐฉ์์ด๋ค. ์ฃผ์ ํด๋์ ์ฌ์ดํธ์ ์ฌ์ฉ์๊ฐ ๋ก๊ทธ์ธํ๊ฒ ๋ ๊ฒฝ์ฐ์ ์ฌ์ฉ์์ ํ ํฐ, ์ฟ ํค ๋ฑ์ ์ ๋ณด๋ฅผ ๋นผ๋ผ ์ ์๊ฒ ๋๋ค. ๊ทธ๋ ๊ธฐ ๋๋ฌธ์ ์๋ฐ์คํฌ๋ฆฝํธ๋ก ์ ๊ทผํ ์ ์๋ localstorage๋ ๋ณ๋์ ์ต์ ์ด ์๋ ์ฟ ํค์ ํ ํฐ์ ์ ์ฅํ๊ฒ ๋ ๊ฒฝ์ฐ์๋ XSS ๊ณต๊ฒฉ์ ์ทจ์ฝํ๊ฒ ๋๋ค.
์ด๊ฒ์ ๋ง๊ธฐ ์ํด์๋ in-memory์ ์ ์ฅํ๋ ๋ฐฉ์๊ณผ http only ์ฟ ํค๋ฅผ ์ด์ฉํ๋ ๋ฐฉ์์ด ์๋ค. in-memory์ ์ ์ฅํ  ๊ฒฝ์ฐ์๋ ๊ด๋ฆฌ๋ ์ฝ์ง๋ง ๋ก๊ทธ์ธ์ ์ ์งํ  ์๊ฐ ์๋ ๋จ์ ์ด ์์ด, ๋ณดํต http only ์ฟ ํค๋ก ์๋ฒ์์ ํ ํฐ์ ๋ณด๋ด์ฃผ๋ฉด ๋ธ๋ผ์ฐ์ ์ ์ ์ฅํ๊ณ  ์๋์ผ๋ก ์์ฒญ์ ๋ด์์ ๋ณด๋ด๋ ๋ฐฉ์์ ์ ์ฉํ  ์ ์๋ค. ํ์ง๋ง http only ์ฟ ํค๋ก ํ ํฐ์ ์ ์ฅํ๋ ๋ฐฉ๋ฒ๋ CSRF ๊ณต๊ฒฉ์ ์ฝํ ๋จ์ ์ด ์กด์ฌํ๋ค.
CSRF(Cross-site Request Forgery)
CSRF๊ณต๊ฒฉ์ ์ฌ์ฉ์๊ฐ ์ํ์ง ์์ action์ ํ๊ฒํ๋ ํดํน ๋ฐฉ๋ฒ์ผ๋ก, ๋ก๊ทธ์ธํ ์ฌ์ฉ์์ ์ ๋ณด๋ฅผ ์ด์ฉํด ์ฌ์ฉ์ ๋ชฐ๋ ๋ธ๋ผ์ฐ์ ์ ์ ์ฅ๋์ด์๋ ์ฟ ํค๋ฅผ ์ด์ฉํด ์์ฒญ์ ๋ณด๋ด๋ ๋ฐฉ์์ด๋ค. http only๋ก ๋ ์ฟ ํค์ด๊ธฐ ๋๋ฌธ์ ํดํน๋ฒ์ด ์ฟ ํค๋ฅผ ์ง์  ๋นผ์ฌ ์๋ ์์ง๋ง, ๋์  ์์ ์ด ์ํ๋ ์์ฒญ์ ์ฌ์ฉ์์ ํ ํฐ์ ์ด์ฉํด ์ฌ์ฉํ  ์ ์๋ค. ๊ทธ๋ ๊ธฐ http only ์ฟ ํค๋ง ์ฌ์ฉํ๋ ๊ฒ์ด ์๋๋ผ ์ถ๊ฐ์ ์ธ ์ธ์ฆ๋ฐฉ์์ด ํ์ํ๋ฐ, ์ด๋ ์ฌ์ฉํ  ์ ์๋ ๋ฐฉ๋ฒ์ด CSRF ํ ํฐ์ด๋ค.
CSRF ํ ํฐ์ ์๋ฒ์์ ๋ฐ๊ธํ๋ ์ธ์ฆ์ฉ ํ ํฐ์ผ๋ก, ํด๋ผ์ด์ธํธ๋ ๋ฐ์ ํ ํฐ์ in-memory์ ์ ์ฅํด ๋๊ณ ์ดํ ์์ฒญ์ ํ ํฐ์ ํจ๊ป ๋ณด๋ธ๋ค. ๋ง์ฝ์ ํดํน๋ฒ์ด CSRF๊ณต๊ฒฉ์ ํ ํ์ ์ฌ์ฉ์์ ์ฟ ํค๋ฅผ ์ด์ฉํด ์์ฒญ์ ๋ณด๋ด๋๋ผ๋, ์ฌ์ฉ์๊ฐ ์ง์ ๋ณด๋ด๋ ๊ฒ์ด ์๋๋ผ๋ฉด ํ ํฐ์ด ์๊ธฐ ๋๋ฌธ์ ์๋ฒ๊ฐ ํ์ธํ ์ ์๊ฒํด ๊ณต๊ฒฉ์ ๋ง์ ์ ์๋ค.
๊ทธ๋ฌ๋ฉด Next-auth๋ฅผ ์ด์ฉํ ์ธ์ฆ๋ฐฉ์์ ์ด๋ป๊ฒ ์ด๋ฃจ์ด์ง๊ณ ์์๊น?
Next-auth์ ๋ณด์ ๋ฐฉ์๊ณผ ์ ์ฉ
Next-auth๋ OAuth์ JWT๋ฅผ ์ง์ํ๊ณ , ๋ฌด์๋ณด๋ค ๊ณ ๋ฏผํ๋ ๋ณด์ ๋ฌธ์ ๋ฅผ ํ๋ฒ์ ํด๊ฒฐํด ์ค ์ ์์๋ค. CSRFํ ํฐ์ POST ์์ฒญ์ ์ ์ฉํ๊ณ , ์ธ์ฆ์ ์ํ ํ ํฐ๊ณผ ์ธ์
๋ค์ server-readable-only cookie๋ก ์๋์ผ๋ก ๊ด๋ฆฌํด์ค๋ค. ๋๊ฐ์ง ๋๋ถ์ ์์ ์์๋ณด์๋ XSS๊ณต๊ฒฉ๊ณผ CSRF ๊ณต๊ฒฉ์ ๋ฐฉ์ดํ๋ฉด์ ์ธ์ฆ์ ํธํ๊ฒ ํ  ์ ์๋ค๋ ๊ฒ์ ํ์ธํ๋ค.
Next auth์ ๋ํด ๊ณต๋ถํ๋ฉด์ ๊ธฐ๋ณธ์ ์ผ๋ก password๋ฅผ ์ฌ์ฉํ์ง ์๋ ๋ฐฉํฅ์ ์งํฅํ๋ค๋ ๊ฒ์ ์๊ฒ ๋์๋ค. ๋น๋ฐ๋ฒํธ๋ ๋ณดํต ํ๋์ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฌ ๊ณณ์ ์ฌ์ฉ๋๊ธฐ ๋๋ฌธ์ ๋ณด์์ ๋ฏผ๊ฐํ ์ฌํญ์ด๋ผ๊ณ ์๊ฐํด, ๊ธฐ์กด ํ์๊ฐ์ -๋ก๊ทธ์ธ ๋ก์ง ๋์ ์ "์ด๋ฉ์ผ ์ธ์ฆ"์ผ๋ก ๊ต์ฒดํ๊ณ , OAuth๋ก "๊ตฌ๊ธ"๊ณผ "Github"๋ฅผ ์ด์ฉํ ์ด ์ธ๊ฐ์ง ๋ฐฉ์์ ์ธ์ฆ๊ณผ์ ์ ์งํํ๊ธฐ๋ก ํ๋ค.
OAuth๋ฅผ ์ด์ฉํ  ๋๋ ๊ณ ๋ คํด์ผํ  ์ ์, ๋ค๋ฅธ ํ๋ซํผ์ด์ง๋ง ๊ฐ์ email์ ์ฌ์ฉํ๋ ๊ฒฝ์ฐ์ด๋ค. ๊ณ ๋ฏผ์ ํด๋ณด์์ ๋ ๋ค๋ฅธ ํ๋ซํผ์ด์ง๋ง ๊ฐ์ email๋ก ์ ์ํ์ ๋ ๊ฐ์ ์ฑ์ฉ๊ณต๊ณ ๋ค์ ๋ณด์ฌ์ค๋ ๊ด์ฐฎ์ ๊ฒ ๊ฐ์ ์ด๋ถ๋ถ์ provider ์ต์
์ allowDangerousEmailAccountLinking์ ์ถ๊ฐํด์ ์ฒ๋ฆฌํ๋ค.
์ฌ์ฉ ๋ฐฉ๋ฒ
Next-auth๋ฅผ ์ฌ์ฉํ๊ธฐ ์ํด์๋ ์ฐ์  pages/api/auth/[...nextauth].ts๋ฅผ ๋ง๋ค์ด์ผํ๋ค. ๋ด๊ฐ ์ฌ์ฉํ [...nextauth].tsํ์ผ ๋ด์ฉ์ ์๋์ ๊ฐ๊ณ , ์ฝ๋์ ์ดํด๋ฅผ ๋๊ธฐ ์ํด์ ๊ณตํต ๋ถ๋ถ์ ์ค๋ช
ํ๊ณ  ๊ฐ๊ฐ์ provider๋ค์ ์ฐ๊ฒฐ์ ์ ๋ฆฌํด๋ณด๋ ค ํ๋ค.
import GoogleProvider from "next-auth/providers/google"
import GithubProvider from "next-auth/providers/github"
import EmailProvider from "next-auth/providers/email"
import NextAuth from "next-auth"
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import prisma from "../../../prisma/prisma"
import nodemailer from "nodemailer"
import { html, text } from "../../../src/utils/emailFormat"
export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_ID || "",
      clientSecret: process.env.GOOGLE_SECRET || "",
      allowDangerousEmailAccountLinking: true,
    }),
    GithubProvider({
      clientId: process.env.GITHUB_ID || "",
      clientSecret: process.env.GITHUB_SECRET || "",
      allowDangerousEmailAccountLinking: true,
    }),
    EmailProvider({
      server: process.env.EMAIL_SERVER,
      from: process.env.EMAIL_FROM,
      async sendVerificationRequest({
        identifier: email,
        url,
        provider: { server, from },
      }) {
        const { host } = new URL(url)
        const transport = nodemailer.createTransport(server)
        await transport.sendMail({
          to: email,
          from,
          subject: `Sign in to ${host}`,
          text: text({ url, host }),
          html: html({ url, host, email }),
        })
      },
    }),
  ],
  session: {
    strategy: "jwt",
  },
  adapter: PrismaAdapter(prisma),
  pages: {
    signIn: "/login",
  },
  secret: process.env.JWT_SECRET,
  debug: true,
  callbacks: {
    async session({ session, token, user }) {
      session.user.id = user.id
      return session
    },
  },
})๊ณตํต ๋ถ๋ถ
- secret: ๋ฐฐํฌ ๋, JWT๋ฅผ ๋ง๋๋๋ฐ ์ฌ์ฉ๋  secret์ผ๋ก ํฐ๋ฏธ๋์ 
openssl rand -base64 32์ ์ ๋ ฅํด ๋ง๋ค์ด์ง ํค๋ฅผ env์ ์ถ๊ฐํด ์ฐ๊ฒฐํ๋ค. - debug: ์ฐ๊ฒฐ๋ provider์ ํ ํฐ ๋ด์ฉ์ด๋ ์๋ฌ๋ค์ ๋ณผ ์ ์๋ ์ต์ ์ผ๋ก ํฐ๋ฏธ๋๋ก ๋ด์ฉ๋ค์ ํ์ธํ ์ ์๋ค.
 - callbacks: ์ธ์ฆ๊ณผ์ ์์ ํ์ํ ๋ด์ฉ๋ค์ ์ปค์คํ
ํ๋ ๋ถ๋ถ์ผ๋ก API์ ํ์ํ id๋ฅผ ๊ฐ์ ๊ธฐ๋ณธ์ ์ผ๋ก 
session/user์ ๋ด๊ฒจ์์ง ์์ ์ถ๊ฐํด์ฃผ์๋ค. - pages: custom ๋ก๊ทธ์ธ ํ์ด์ง๋ฅผ ์ฐ๊ฒฐํ url์ ์ ๋ฌํด ์ค ์ ์๋ค.
 - session: database์ ์ฐ๊ฒฐํ๊ฒ ๋๋ฉด ์ฌ์ฉ์ ์ ๋ณด๊ฐ ์ธ์ ์ผ๋ก ๊ด๋ฆฌ๋๊ธฐ ๋๋ฌธ์ jwt๋ก ๋ณ๊ฒฝํ๋ค. database๋ฅผ ์ฐ๊ฒฐํ์ง ์์ผ๋ฉด ๊ธฐ๋ณธ์ ์ผ๋ก jwt๊ฐ ๋๋ค.
 
callbacks์์ ์ถ๊ฐํ  ๋ถ๋ถ์ ํ์
๋ ์ถ๊ฐํด์ค์ผํ๊ธฐ ๋๋ฌธ์ next-auth.d.ts์์ ์ ์ํด์ฃผ์๋ค.
// types/next-auth.d.ts
import { DefaultUser } from "next-auth"
declare module "next-auth" {
  interface Session {
    user: DefaultUser & {
      id: string
    }
  }
}Adapter
์ฌ์ฉ์์ ๋ณด๋ค์ ์ ์ฅํ DB๋ฅผ ์ฐ๊ฒฐํ๋ ๋ถ๋ถ์ผ๋ก, next-auth๋ ๋ค์ํ DB๋ฅผ ์ง์ํ๋ค. ๊ทธ์ค์์ ๊ธฐ์กด์ ์ฌ์ฉํ๋ firebase ๋์ ์ prisma๋ฅผ ์ด์ฉํด ์ฐ๊ฒฐํ๋ค. Firebase๋ฅผ ์ฌ์ฉํ๋ คํ์ง๋ง ํ์ฌ next-auth๊ฐ version 4๋ก ์ ๋ฐ์ดํธ ํ๋ฉด์ ์ง์ํ์ง ์๋ ๊ฒ์ ํ์ธํ๋ค.
๊ทธ๋์ ์๋ก์ด Database๋ฅผ ์ฐพ๋ค๊ฐ prisma๋ฅผ ์ฌ์ฉํ๋ค. Prisma๋ ORM์ผ๋ก SQL DB์ ํจ๊ป ์ฌ์ฉ๋์ง๋ง ํ์ฌ MongoDB๊น์ง ์ง์ํด์ค, ๋ฐ์ดํฐ์ schema๋ฅผ ๊ธฐ์
ํด์ ์์ ํ๊ฒ ๊ด๋ฆฌํ  ์ ์๊ณ , ๋ค์ํ database๋ฅผ ์ง์ํ๋ ์ฅ์ ๋ค๋ก ์ ํํ๋ค.
Prisma๋ฅผ ์ด์ฉํ DB ์ฐ๊ฒฐ
prisma๋ฅผ ์ด์ฉํด MongoDB๋ฅผ ์ฐ๊ฒฐํ๊ธฐ ์ํด์ next-auth์ ๊ณต์ ํํ์ด์ง๋ฅผ ์ฐธ๊ณ ํ๋ฉด ๋์ง๋ง ์ฃผ์ํ ๋ถ๋ถ์ด ์์๋ค.
๊ณต์ ํํ์ด์ง์ schema์ VerificationToken ๋ถ๋ถ์ id๊ฐ ์๊ธฐ ๋๋ฌธ์ ์๋ฌ๊ฐ ๋ฐ์ํ๋๋ฐ, ์ด๊ฒ์ ํด๊ฒฐํ๊ธฐ ์ํด์๋ ๋ค์๊ณผ ๊ฐ์ด ์ถ๊ฐํด ํด๊ฒฐํ ์ ์์๋ค.
// ...
model VerificationToken {
  id         String   @id @default(auto()) @map("_id") @db.ObjectId
  identifier String
  token      String   @unique
  expires    DateTime @map("expiresAt")
  @@unique([identifier, token])
  @@map("verification_tokens")
}OAuth
OAuth๋ก ์ฌ์ฉํ Google๊ณผ Github์ ์ฐ๊ฒฐํ๊ธฐ ์ํด์๋ ๊ฐ๊ฐ ID์ Secret์ ์ ๋ฌํด์ฃผ์ด์ผํ๋ค. ์ด๋ ๋ง์ด ์๋ฌ๊ฐ ๋ฌ๋ ๋ถ๋ถ์ ์ธ์ฆ์ด ๋๋๊ณ  ๋์๊ฐ callback์ ์ค์ ํด์ฃผ๋ ๋ถ๋ถ์ด์๋ค. ๋ฐฐํฌ๋ฅผ ํ๋ฉด์ ์๋ฌ๊ฐ ๋ง์ด ๋ ๋ถ๋ถ์ด๋ผ ๋ฏธ๋ฆฌ ๋ก์ปฌ๊ณผ ๋ฐฐํฌ์ฉ ๋๊ฐ์ OAuth ์ธ์ฆ ์ ๋ณด๋ฅผ ๋ง๋ค๊ณ , ๊ฐ๊ฐ ๊ตฌ๋ถ๋ key์ secret์ผ๋ก .env.local๊ณผ .env.production์ผ๋ก ํด๋์๋ค๋ฉด ํท๊ฐ๋ฆฌ์ง ์์์ํ
๋ฐ๋ผ๋ ์์ฌ์์ด ๋ ๋ค.
Google์ ๊ฒฝ์ฐ๋ GCP(Google Cloud Platform)์ ๋ค์ด๊ฐ ์ฌ์ฉ์ ์ธ์ฆ์ ๋ณด- OAuth Client ID๋ฅผ ๋ง๋ค๋ฉด ๋๋๋ฐ ์ด๋ ์ฃผ์ํ ์ ์ url์ฃผ์๋ฅผ ์ ์ ์ด์ฃผ์ด์ผํ๋ค.
- ๊ฐ๋ฐ: url=
http://localhost:3000, ์น์ธ๋ ๋ฆฌ๋๋ ์  URI=http://localhost:3000/api/auth/callback/google - ๋ฐฐํฌ: url= 
http://moejob.shop, ์น์ธ๋ ๋ฆฌ๋๋ ์  URI=http://moejob.shop/api/auth/callback/google 
Github
Github์ Github ๋ณธ์ธ๊ณ์ ์ settings-์ผ์ชฝ ๋ฉ๋ด ๊ฐ์ฅํ๋จ์ developer settings-OAuth Apps๋ฅผ ์ด์ฉํด ์ถ๊ฐํ๋ฉด ๋๋ค. ์ด๋๋ url์ฃผ์๋ฅผ ์ฃผ์ํด์ ์ ์ด์ผํ๋ค. ํนํ Github์ ํ๋ฒ ์ธ์ฆ๋ ๊ณ์ ์ url์ ๋ฐ๊พธ์ด๋ ์ ์ ์ฉ์ด ์๋ ์๋กญ๊ฒ ๋ฐฐํฌ์ฉ ๊ณ์ ์ ๋ง๋ค์ด์ ์ธ์ฆํ๋ค.
- ๊ฐ๋ฐ: Homepage URL=
http://localhost:3000, Authorization callback URL=http://localhost:3000/api/auth/callback/github - ๋ฐฐํฌ: Homepage URL=
http://moejob.shop, Authorization callback URL=http://moejob.shop/api/auth/callback/github 
์ด๋ฉ์ผ์ ์ฐ๊ฒฐํ๋ ๋ถ๋ถ์ด ๊ฐ์ฅ ํ๋ค์๋ ๋ถ๋ถ์ค ํ๋๋ค. Next-auth์ ๊ฒ์์ ๋๋ถ๋ถ์ sendGrid์ SMTP๋ฅผ ์ด์ฉํด ์ด๋ฉ์ผ์ ๋ณด๋ผ ์ ์๊ฒ ์๊ฐํ์ง๋ง, ๊ฐ์
์ ํด๋ ๊ณ์ ์ด pending์ํ๋ก ๋จ์์์ด์ Gmail์ ์ด์ฉํ ๋ฐฉ๋ฒ์ผ๋ก ๋ฐ๊พธ์๋ค.
์ฐ์  next-auth์์ฒด์ nodemailer๊ฐ ์๊ธฐ ๋๋ฌธ์ nodemailer๋ฅผ ์ค์นํ ํ์ ์ฐ๊ฒฐํด์ฃผ์๋ค. Gmailํ์ด์ง์์ ์ค์ ์ ๋ค์ด๊ฐ IMAP์ ์ฌ์ฉ์ผ๋ก ์์ ํ๊ณ , google๊ณ์ ์ ์ฑ๋น๋ฐ๋ฒํธ๋ฅผ ์ถ๊ฐํด ํ๊ฒฝ๋ณ์๋ก ์ ๋ฌํ๋ค.
EMAIL_SERVER =smtp://<email>:<app password>@smtp.gmail.com:587EMAIL_FROM=moejob@gmail.com
์ด๋ ๊ฒ ์ค์ ๋ง ํ๋ฉด ๊ธฐ๋ณธ ์ด๋ฉ์ผ ํฌ๋งท์ผ๋ก ๋์ค๊ธฐ ๋๋ฌธ์ ์ถ๊ฐ์ ์ธ ํฌ๋งท์ ์ํด EmailProvider option์ผ๋ก sendVerificationRequest๋ก ํ์ํ ๋ด์ฉ๋ค์ ์ ๋ฌ ํ ์ ์๋ค.
_app.tsx
๊ฐ๊ฐ์ Provider๋ฅผ ์ฐ๊ฒฐํ ํ์ ์ปดํฌ๋ํธ์ ์ ์ฉํ๊ธฐ ์ํด์๋ next-auth๊ฐ ์ ๊ณตํ๋ SessionProvider์ ๊ฐ์ธ์ฃผ๋ฉด ์ปดํฌ๋ํธ ๋ด๋ถ์์ useSession hook์ ์ด์ฉํด session์ ๋ณด๋ฅผ ์ ์ญ ์ํ๋ก ์ฌ์ฉํ  ์ ์๋ค. ๋๋ถ์ ๊ธฐ์กด์ CSR์์ ์ฌ์ฉํ๊ณ  ์๋ AuthService๋ฅผ ์ด์ฉํด user๋ฅผ ๋ฐ๋ก ๊ด๋ฆฌํ์ง ์์๋ ๋์๋ค.
function MyApp({ Component, pageProps }: AppProps) {
  const dbService = new DBServiceImpl(firebaseApp)
  return (
    <>
      <QueryClientProvider client={queryClient}>
        <DBProvider dbService={dbService}>
          <SessionProvider basePath={process.env.NEXTAUTH_URL}>
            ...
          </SessionProvider>
        </DBProvider>
      </QueryClientProvider>
    </>
  )
}Proteceted Route
getServerSideProps๋ก session์ ๋ฐ์์ ๋ก๊ทธ์ธ ๋ ์ฌ์ฉ์์ธ์ง ๋จผ์  ์ฒดํฌํด ์ฃผ๋ ๋ฐฉ์์ ํตํด ํ์ฌ ๋ก๊ทธ์ธ๋ ์ ์ ์ธ์ง ๋จผ์  ํ์ธํ ์ ์์๋ค. ๋จผ์  ์๋ฒ์์ ์ฌ์ฉ์์ ๋ณด๋ฅผ ์ด์ฉํด ๋ฆฌ๋ค์ด๋ ์  ์ํค๊ธฐ ๋๋ฌธ์ ํ์ด์ง๋ฅผ ๋๋๋ง์ ํ์ง ์๊ณ , ๋ฆฌ๋ค์ด๋ ์ ์ด ๊ฐ๋ฅํ๊ฒ ๋์๋ค.
export default function User({
  session,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  return (
    <MainLayout>
      <JobSection session={session} />
    </MainLayout>
  )
}
export const getServerSideProps = async (context: NextPageContext) => {
  const session = await getSession(context)
  if (!session) {
    return {
      redirect: {
        destination: "/login",
      },
    }
  }
  return {
    props: { session },
  }
}Custom Login
Next auth๋ฅผ ์ด์ฉํ๊ฒ ๋๋ฉด ๊ธฐ๋ณธ์ ์ผ๋ก ์ ๊ณตํด ์ฃผ๋ login ํ์ด์ง๊ฐ ์์ง๋ง ์ด๋ฏธ ๋ง๋ค์ด๋ ํ์ด์ง๊ฐ ์์๊ธฐ ๋๋ฌธ์ custom ํ์ด์ง์ ์ฐ๊ฒฐํ๋ค. custom ํ์ด์ง์์ provider๋ฅผ ์ด์ฉํ๊ธฐ ์ํด์๋ ์๋ฒ๋ก ํด๋น provider์ ๋ณด๋ฅผ ์ ๋ฌํด ์ฃผ์ด์ผ ํ๋ค.
const Login = ({
  providers,
}: InferGetServerSidePropsType<typeof getServerSideProps>) => {
  return (
    <>
      <SEO title="๋ก๊ทธ์ธ" />
      <AuthLayout providers={providers} />;
    </>
  )
}
export default Login
export const getServerSideProps = async ({ req }: NextPageContext) => {
  const session = await getSession({ req })
  if (session) {
    return {
      props: {},
      redirect: {
        destination: "/",
      },
    }
  }
  return {
    props: {
      providers: await getProviders(),
    },
  }
}๐์ฑ๋ฅ ๋น๊ต
ํฌ๋กฌ์ ๊ฐ๋ฐ์๋๊ตฌ๋ฅผ ์ด์ฉํด ์๋ฃ (ํ์ด์ง๋ฅผ ๋ชจ๋ ๋ถ๋ฌ์ค๋๋ฐ ๊น์ง ๊ฑธ๋ฆฌ๋ ์๊ฐ)๋ฅผ ๊ธฐ์ค์ผ๋ก CSR์ ์ด์ฉํด ์ธ์ฆ์ ํ์๋ ์๊ฐ๊ณผ SSR์ ์ด์ฉํด ์ธ์ฆ์ ํ์ ๋ ์๊ฐ์ ๋น๊ตํด๋ณด์๋ค.
์ ์ฌ์ง๊ณผ ๊ฐ์ด CSR์ ์ด 1.52s, SSR์ 955ms๋ก SSR์ด CSR์ ๋นํด 38%์ ๋ ๋ก๋ฉ์๋๋ฅผ ๊ฐ์ ๋๊ฒ์ ์ ์ ์์๋ค.
๋ง์น๋ฉฐ
์ด๋ ๊ฒ ์๋ฒ์ฌ์ด๋ ๋ ๋๋ง์ ์ด์ฉํด์ ์ธ์ฆ์ ์ ์ฉํ๋ฉด์ ์ ์ฒด์ ์ผ๋ก ์ปดํฌ๋ํธ๋ค์ด ๊ฐ๋จํด์ก๊ณ , ๋นํ์ด์ง๋ฅผ ๋ณด์ฌ์ฃผ์ง ์๊ณ ํ์ด์ง ์ด๋์ด ์ด๋ฃจ์ด์ ธ ์ฑ๋ฅ ๊ฐ์ ๋ ์ด๋ฃจ์ด์ง ์ ์ด ๋๋ฌด ์ข์๋ค. ์ดํ์๋ ํ์ด์ง๋ง๋ค ๊ถํ์ ์ ํ๊ณ ํ์ด์ง ๊ฐ์ ์ ํ ํ์ SEO์ HTTPS๋ฅผ ์ ์ฉํ๋ ์์ ์ ํ ์์ ์ด๋ค.
[์ฐธ์กฐ]