7 분 소요



[Next.js에서 NextAuth를 사용한 인증 구현하기 Repo↗️]



1. NextAuth 개요

1.1 NextAuth 란?

[NextAuth 공식 문서↗️]

NextAuth.js는 Next.js App에 인증 기능을 쉽게 추가할 수 있는 라이브러리이다.

  • 백엔드로부터 로그인 이후 Authentication등의 정보를 받아와야 한다.
  • 그리고 access token, refresh token등 정보를 별도 공간에 저장하고 관리해야 한다.
  • 지금까지 access token을 localStorage에 단순하게 저장 하였지만, 이는 보안 문제가 발생할 수 있다.


1.2 NextAuth의 장점

간편한 설정

설정이 간단하여 복잡한 인증 로직 없이도 빠르게 인증 기능을 구현할 수 있다.

다양한 Authentication Providers 지원

GitHub, Google, Facebook, Twitter 등 다양한 인증 제공자를 사용할 수 있다.

세션 관리

NextAuth는 사용자 세션을 자동으로 관리하여, 세션 상태를 쉽게 확인하고 갱신할 수 있다.

보안

CSRF 공격 방지, JWT(JSON Web Token) 지원, 사용자 비밀번호 암호화 등 보안 관련 기능을 제공한다.



2. NextAuth 설치 및 세팅

2.1 설치

yarn add next-auth


2.2 라우팅 설정

app 폴더 안에 page.tsx를 생성하여 라우팅을 설정하자.

  • (🔒 auth): 인증이 필요한 페이지
  • (🔓 nonAuth): 인증이 필요하지 않은 페이지
  • api/auth/[…nextauth]: NextAuth API 라우트
📂 app
│  ├── 📄 favicon.ico
│  ├── 🎨 globals.css
│  ├── 📄 layout.tsx
│  ├── 📄 page.tsx
│  ├── 📄 provider.tsx
│
├─ 📂 (🔒 auth)
│  └─ 📂 todo
│      ├── 📄 layout.tsx
│      │
│      ├─ 📂 my
│      │   └── 📄 page.tsx
│      │
│      └─ 📂 new
│          └── 📄 page.tsx
│
├─ 📂 (🔓 nonAuth)
│  ├─ 📂 home
│  │   └── 📄 page.tsx
│  │
│  ├─ 📂 signin
│  │   └── 📄 page.tsx
│  │
│  └─ 📂 signup
│      └── 📄 page.tsx
│
├─ 📂 api
│  └─ 📂 auth
│      └─ 📂 [...nextauth]
│          └── 📄 route.ts
│
├─ 📂 components
│  └─ 📂 layouts
│      ├── 📄 Header.tsx
│      │
│      └─ 📂 common
│          └── 📄 SignInOutButton.tsx
│
└─ 📂 fonts
    ├── 🎶 GeistMonoVF.woff
    └── 🎶 GeistVF.woff


2.3 provider 세팅

인증 세션을 관리하기 위해 SessionProvider를 설정해야 한다.

  • Next.js에서는 useSession 훅을 통해 클라이언트에서 세션 정보를 확인할 수 있다. 이를 전역적으로 사용하기 위해 SessionProvider로 감싸 전역적으로 session을 확인할 수 있게한다.
  • provider은 client 컴포넌트인데 root layout은 클라이언트 컴포넌트가 안 되므로 root layout에 provider을 use client로 만들어 주입해야하는 것이다.
// app/providers.tsx
"use client";

import { SessionProvider } from "next-auth/react";

const Provider = ({ children }: { children: React.ReactNode }) => {
  return <SessionProvider>{children}</SessionProvider>;
};

export default Provider;
  • SessionProvider: 애플리케이션의 모든 컴포넌트가 인증 세션에 접근할 수 있도록 한다.


2.4 RootLayout 세팅

Next.js 애플리케이션의 레이아웃을 설정하는 app/layout.tsx 파일이다.

위에서 생성한 Providers로 감싸주어 모든 자식 컴포넌트에서 인증 상태에 접근할 수 있도록 하자.

// app/layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ko">
      <body className={inter.className}>
        <Providers>
          <Header />
          {children}
        </Providers>
      </body>
    </html>
  );
}


2.5 next.config.mjs 세팅

Next.js의 리디렉션 설정을 포함하는 파일이다.

  • 아래와 같이 설정하면 루트 경로( / )에 접근 시 /home으로 리디렉션된다.
  • 리디렉션은 브라우저 캐시에 저장될 수 있다. 따라서 리디렉션 설정을 변경한 후에는 브라우저의 캐시를 지워야 한다.
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  async redirects() {
    return [
      {
        source: "/",
        destination: "/home",
        permanent: true,
      },
    ];
  },
};

export default nextConfig;


2.6 Header.tsx 설정

사용자의 인증 상태에 따라 로그인 또는 로그아웃 버튼을 표시하는 Header.jsx 컴포넌트를 생성하자.

// app/components/layouts/Header.tsx
import Link from "next/link";
import SignInOutButton from "../common/SignInOutButton";

const Header = () => {
  return (
    <header className="flex justify-between p-4 border">
      <section>
        <Link href="/home">Logo</Link>
      </section>
      <nav className="flex gap-8">
        <Link href="/home">Home</Link>
        <Link href="/todo/my">My Todo</Link>
        <Link href="/todo/new">New Todo</Link>
      </nav>
      <section>
        <SignInOutButton />
      </section>
    </header>
  );
};

export default Header;


SignInOutButton.tsx

  • SignInOutButton.tsx는 인증 상태에 따라 로그인,회원가입 또는 로그아웃 버튼을 표시한다.
// app/components/common/SignInOutButton.tsx
"use client";

import { signIn, signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";

const SignInOutButton = () => {
  const session = useSession();
  const router = useRouter();
  const isSignIn = session.status === "authenticated";

  return isSignIn ? (
    <>
      <button onClick={() => signOut()}>로그아웃</button>
    </>
  ) : (
    <>
      <button onClick={() => signIn()} className="mr-4">
        로그인
      </button>
      <button onClick={() => router.push("/signup")}>회원가입</button>
    </>
  );
};

export default SignInOutButton;


2.7 TodoLayout 세팅

인증된 사용자만 접근할 수 있는 레이아웃을 설정한다.

shouldRender 값이 true일 때만 children이 렌더링되므로, 자식 요소들이 화면에 나타나는 조건이 TodoLayout 내부의 상태에 따라 결정된다.

// app/(auth)/todo/layout.tsx
"use client";

import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation"; // 주의! navigation에서 가져와야 함
import { useEffect, useState } from "react";

const TodoLayout = ({ children }: { children: React.ReactNode }) => {
  const sesstion = useSession();
  const router = useRouter();
  const [shouldRender, setShouldRender] = useState(false);

  useEffect(() => {
    if (sesstion.status != "authenticated") {
      // 로그인 상태가 아니라면
      router.replace("/signin");
    } else {
      // 로그인이 되어있다면
      setShouldRender(true);
    }
  }, []);

  return <div>{shouldRender && children}</div>;
};

export default TodoLayout;
  • useEffect: 사용자의 인증 상태를 확인하고, 인증되지 않은 경우 로그인 페이지로 리디렉션한다.



3. NextAuth 로그인 구현

3.1 NextAuth handler 설정

NextAuth.js를 설정하기 위해, Next.js의 API 라우트를 생성해야 한다.

app/api/auth/[...nextauth]/route.ts에 파일을 생성하여, 다음과 같이 NextAuth 핸들러를 설정하자. (⚠️ 경로 똑같이 지정할 것)

// app/api>auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      id: "email-password-credential", // 꼭 설정 할 것 (목차 3.3과 연결)
      name: "Credentials",
      type: "credentials",
      // credentials안에 있는 요소들은 목차 3.3의 input과 연결
      credentials: {
        email: {
          label: "이메일",
          type: "email",
          placeholder: "이메일을 입력해주세요.",
        },
        password: {
          label: "비밀번호",
          type: "password",
          placeholder: "비밀번호를 입력해주세요.",
        },
      },
      async authorize(credentials, req) {
        // 로그인 요청을 처리하는 부분
        const res = await fetch("http://localhost:8080/login", {
          method: "POST",
          body: JSON.stringify(credentials),
          headers: {
            "Content-Type": "application/json",
          },
        });

        const user = await res.json();

        if (res.ok && user) {
          return user; // 인증 성공 시 사용자 정보 반환
        }
        return null; // 인증 실패 시 null 반환
      },
    }),
  ],
  pages: {
    signIn: "/signin", // 사용자 정의 로그인 페이지 (커스터마이징)
  },
  secret: "secret",
});

export { handler as GET, handler as POST };
  • CredentialsProvider: 사용자가 입력한 아이디와 비밀번호로 인증을 처리한다.
  • authorize(): 사용자로부터 받은 자격 증명을 기반으로 API에 요청을 보내고, 인증이 성공하면 사용자 정보를 반환한다.


3.2 Home.tsx에서 로그인 여부 확인

사용자가 로그인했는지 확인하는 페이지를 만든다.

  • useSession이 반환하는 값은 객체로, 세션 상태와 데이터를 확인할 수 있다.
    • loading: 세션 정보가 아직 로드되지 않은 상태
    • authenticated: 세션이 인증된 상태
    • unauthenticated: 인증되지 않은 상태
// app/(non-auth)/home/page.tsx
"use client";

import { useSession } from "next-auth/react";

const HomePage = () => {
  const session = useSession();

  console.log("====================================");
  console.log("session", session);
  console.log("user", session.data?.user?.name);
  console.log("====================================");
  return <div>HomePage</div>;
};

export default HomePage;

  • useSession: 현재 세션의 상태를 확인한다.


3.3 로그인 기능 구현

기본적으로 제공하는 NextAuth 로그인 페이지를 커스터마이징을 할 수 있다.

// src/app/(non-auth)/signin/page.tsx
"use client";

import { signIn, useSession } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";

const SignInPage = () => {
  const router = useRouter();
  const pathname = usePathname();
  const session = useSession();

  const [email, setEmail] = useState("");
  const [password1, setPassword1] = useState("");
  const [shouldRender, setShouldRender] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (session.status === "authenticated") {
      router.replace("/"); // 인증된 경우 홈으로 리디렉션
    } else {
      setShouldRender(true);
    }
  }, [router, session, pathname]);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    // 위에서(목차 3.1) 설정한 id와 연결
    const response = await signIn("email-password-credential", {
      email,
      password1,
      redirect: false,
    });

    if (response?.error) {
      setError("로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.");
    }
  };

  return (
    <div>
      {shouldRender && (
        <form onSubmit={handleSubmit}>
          <div>
            <label htmlFor="email">이메일</label>
            <input
              type="email"
              id="email"
              name="email"
              value={email}
              autoComplete="username"
              required
              onChange={(e) => {
                setEmail(e.target.value);
              }}
            />
          </div>
          <div>
            <label htmlFor="password">비밀번호</label>
            <input
              type="password"
              id="password"
              name="password"
              value={password1}
              autoComplete="current-password"
              required
              onChange={(e) => {
                setPassword1(e.target.value);
              }}
            />
          </div>
          {error && <div className="text-red-500">{error}</div>}
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full"
            type="submit"
          >
            로그인하기
          </button>
        </form>
      )}
    </div>
  );
};

export default SignInPage;
  • signIn(): 입력된 자격 증명을 사용하여 인증을 시도한다.
  • 로그인 성공 시: 사용자 세션 상태가 변경되며, 로그인 상태에 따라 다른 컴포넌트를 렌더링한다.



4. 회원가입 기능 구현

NextAuth는 기본적으로 인증과 관련된 기능(로그인, 세션 관리, 소셜 로그인 등)을 지원하지만, 회원가입 기능은 기본적으로 포함되지 않는다.

그래서 회원가입 기능은 NextAuth가 아니라 별도의 백엔드 API를 사용해 처리하였다.

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";

const SignupPage = () => {
  const router = useRouter();
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [password1, setPassword1] = useState("");
  const [password2, setPassword2] = useState("");
  const [nickname, setNickname] = useState("");
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setError(null);

    try {
      const res = await fetch("http://localhost:8080/members/new", {
        method: "POST",
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          name,
          nickname,
          email,
          password1,
          password2,
        }), // API가 요구하는 필드 형식으로 전송
      });

      // 응답이 400번대일 경우 텍스트로 처리
      if (!res.ok) {
        const errorText = await res.text(); // 응답을 텍스트로 처리
        setError(errorText || "회원가입에 실패했습니다.");
        return;
      }
    } catch (error) {
      console.error("네트워크 오류 발생:", error);
      setError("서버와의 연결에 문제가 발생했습니다.");
    }

    // 회원가입 성공 시 로그인 페이지로 리디렉션
    router.push("/signin");
    alert("회원가입이 완료되었습니다!");
  };

  return (
    <div>
      <h1>회원가입</h1>
      <form onSubmit={handleSubmit}>
        <label htmlFor="name">이름</label>
        <input
          id="name"
          type="text"
          name="name"
          value={name}
          onChange={(e) => setName(e.target.value)}
        />

        <label htmlFor="email">이메일</label>
        <input
          id="email"
          type="email"
          name="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
        />

        <label htmlFor="password1">비밀번호</label>
        <input
          id="password1"
          type="password"
          name="password1"
          value={password1}
          onChange={(e) => setPassword1(e.target.value)}
        />

        <label htmlFor="password2">비밀번호 확인</label>
        <input
          id="password2"
          type="password"
          name="password2"
          value={password2}
          onChange={(e) => setPassword2(e.target.value)}
        />

        <label htmlFor="nickname">닉네임</label>
        <input
          id="nickname"
          type="text"
          name="nickname"
          value={nickname}
          onChange={(e) => setNickname(e.target.value)}
        />

        {error && <p style={{ color: "red" }}>{error}</p>}

        <button
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full"
          type="submit"
        >
          회원가입
        </button>
      </form>
    </div>
  );
};

export default SignupPage;


카테고리:

업데이트:

댓글남기기