로그인과 로그아웃
Auth.js 서버 액션과 클라이언트 API로 로그인·로그아웃 UI를 구현하고 세션 경계를 구분합니다.
인증 설정을 마쳤다면 사용자가 로그인과 로그아웃을 시작할 수 있는 화면을 만듭니다. Auth.js에서는 서버 액션과 클라이언트 API라는 두 가지 방식을 쓸 수 있습니다. 이 글은 앞 글의 Auth.js v5와 Next.js App Router 설정을 이어서 씁니다. 서버 액션은 @/auth에서, 클라이언트 이벤트는 next-auth/react에서 가져온 함수를 호출하며, 두 모듈의 같은 이름을 섞어 쓰지 않습니다. 기본으로는 서버 액션을 고르고, 브라우저 상태에 따라 곧바로 바뀌는 UI가 필요할 때만 클라이언트 API를 씁니다.
서버 액션으로 로그인하기
src/auth.ts에서 내보낸 signIn을 서버 액션에서 호출합니다. 아래 코드에서 <form>의 action에 넘긴 함수는 맨 앞의 'use server' 때문에 브라우저가 아니라 서버에서 실행됩니다. 그래서 이 컴포넌트에는 'use client'가 없고, 버튼을 누르면 폼 제출이 서버 액션 호출로 바뀝니다. signIn의 첫 인자 'github'는 앞 글에서 등록한 공급자의 이름입니다.
import { signIn } from '@/auth';
export function SignInButton() {
return (
<form
action={async () => {
'use server';
await signIn('github', { redirectTo: '/account' });
}}
>
<button type="submit">GitHub로 로그인</button>
</form>
);
}버튼을 누르면 서버 액션이 GitHub 인증 흐름을 시작하고, 인증에 성공하면 redirectTo에 지정한 /account로 이동합니다. 버튼을 누른 시점에는 GitHub 인증을 시작했을 뿐이므로, 사용자가 동의를 취소하거나 공급자에서 오류가 나면 로그인에 성공했다고 가정하고 다음 작업을 실행해서는 안 됩니다.
기본 redirect 콜백은 상대 경로와 같은 출처(origin)의 URL로만 이동하도록 제한합니다. 이 예제는 고정된 내부 경로를 씁니다. 목적지를 입력으로 받거나 콜백을 바꿀 때도, 허용할 내부 경로를 명시적으로 제한합니다.
서버 액션으로 로그아웃하기
로그아웃도 중앙 인증 모듈의 signOut을 호출합니다. 구조는 로그인 버튼과 같아서, 폼의 action에 넘긴 서버 액션 안에서 signOut을 기다립니다. 다른 점은 공급자 이름이 필요 없다는 것과, redirectTo로 로그아웃 뒤에 돌아갈 경로를 홈(/)으로 정한다는 것입니다.
import { signOut } from '@/auth';
export function SignOutButton() {
return (
<form
action={async () => {
'use server';
await signOut({ redirectTo: '/' });
}}
>
<button type="submit">로그아웃</button>
</form>
);
}signOut()은 현재 브라우저의 세션 쿠키를 지우고 지정한 경로로 이동합니다. 앞 글의 JWT 전략에서는 이 동작이 이미 복사된 토큰까지 서버에서 한꺼번에 폐기하지는 않습니다.
앱 세션과 공급자 세션은 별개입니다. 이 앱에서 로그아웃해도 GitHub 웹사이트의 로그인 상태까지 끝나지는 않으므로, 다시 GitHub 로그인을 시작하면 비밀번호 입력 화면 없이 돌아올 수 있습니다. 이것만으로 앱의 로그아웃이 실패했다고 판단하지 말고, 앱의 보호된 요청이 새 세션 없이 거부되는지 확인합니다. Auth.js 로그인·로그아웃 안내는 공급자 세션과 앱 세션의 범위를 구분해 설명합니다. 브라우저 저장소의 값을 직접 지우는 식으로 세션을 흉내 내지 않습니다.
세션에 따라 버튼 바꾸기
서버 컴포넌트에서 세션을 읽으면, 브라우저가 따로 세션을 조회하기 전에 알맞은 버튼을 서버 렌더링 결과에 넣을 수 있습니다. 다만 서버의 세션 조회 자체에도 대기 시간이 있을 수 있습니다.
import { auth } from '@/auth';
import { SignInButton } from './sign-in-button';
import { SignOutButton } from './sign-out-button';
export async function AuthMenu() {
const session = await auth();
if (!session?.user) {
return <SignInButton />;
}
return (
<div>
<span>{session.user.name ?? session.user.email}</span>
<SignOutButton />
</div>
);
}사용자 이름이 null이나 undefined이면 이메일을 씁니다. 두 필드가 모두 없거나 빈 문자열인 경우까지 표시 이름을 보장하는 코드는 아닙니다. 예를 들어 이름이 빈 문자열이고 이메일이 [email protected]이어도, name ?? email의 결과는 빈 문자열입니다. 빈 문자열은 null이나 undefined가 아니기 때문입니다. 빈 이름도 바꾸어 보여 주려는 화면 정책이라면 별도의 빈 값 검사가 필요합니다. 표시 문구를 보완한다고 실제 서버 권한이 바뀌지는 않습니다.
클라이언트 API 사용하기
모달 안에서 로그인 버튼을 제어하거나 세션 갱신 상태를 곧바로 보여 줘야 한다면 클라이언트 API가 필요할 수 있습니다. useSession()을 쓰는 컴포넌트는 클라이언트 SessionProvider 아래에 있어야 합니다. 아직 Provider가 없다면 다음 래퍼를 만들고, 서버 layout.tsx에서 로그인 상태를 표시할 영역을 이 컴포넌트로 감쌉니다. 서버 레이아웃 전체를 'use client'로 바꿀 필요는 없습니다.
'use client';
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
export function AuthSessionProvider({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}서버에서 auth()만 호출하는 컴포넌트에는 이 Provider가 필요하지 않습니다. Provider는 브라우저의 표시 상태를 함께 쓰게 할 뿐, 이후의 API 요청을 자동으로 허가하는 장치가 아닙니다. 클라이언트에서 쓸 수 있는 범위는 Auth.js React API를 따릅니다.
'use client';
import { signIn, signOut, useSession } from 'next-auth/react';
export function ClientAuthButton() {
const { data: session, status } = useSession();
if (status === 'loading') {
return <button disabled>확인 중</button>;
}
if (session?.user) {
return <button onClick={() => signOut({ redirectTo: '/' })}>로그아웃</button>;
}
return <button onClick={() => signIn('github', { redirectTo: '/account' })}>로그인</button>;
}이 방식은 상위 트리에 SessionProvider가 있어야 합니다. 클라이언트의 세션 상태는 화면을 표현하기 위한 값이므로, 데이터 저장이나 관리자 기능 같은 보안 판단은 서버에서 다시 검사해야 합니다.
로그인 API의 실행 위치와 세션 UI를 구분합니다
AuthButton이 세션 loading 동안 버튼을 막고, 세션 해석 뒤 로그인 또는 로그아웃 UI를 선택합니다. 같은 버튼의 초기 상태와 해석 완료 상태를 따라가면 과거 세션 값으로 잘못 표시하는 이유를 이해할 수 있습니다.
NEXT · 10.2 · 로그인과 로그아웃
로그인 API의 실행 위치와 세션 UI를 구분합니다
같은 로그인·로그아웃 기능도 import와 실행 위치가 다릅니다. 두 방식을 동시에 거치는 순서가 아니라, 교재의 서버 버튼과 ClientAuthButton이라는 두 구현을 비교합니다.
서버 액션 · @/auth
- form 제출 → 서버 액션
- SignInButton은 use server 액션에서 signIn("github")를 호출합니다. 인증 완료 후 /account로 이동합니다.
- 로그아웃 → 현재 앱 세션 종료
- SignOutButton은 signOut을 호출하고 /로 이동합니다. JWT 전략에서 복사된 모든 토큰을 일괄 폐기하는 구현은 아닙니다.
- AuthMenu → 서버의 세션 분기
- auth()의 session.user가 없으면 로그인 버튼, 있으면 사용자 표시와 로그아웃 버튼을 렌더링합니다.
클라이언트 API · next-auth/react
- 전제 · SessionProvider
- 상위 트리에서 세션을 제공하고 useSession()으로 data와 status를 읽습니다.
- status === "loading"
- 확인 중 버튼을 비활성화합니다. 이 상태를 로그아웃 상태로 간주하지 않습니다.
- 로딩 종료 · session.user 유무
- 있으면 signOut, 없으면 signIn을 onClick에서 호출합니다. 이동 목적지는 서버 버튼과 같습니다.
세션 상태와 버튼 숨김은 UI 표현입니다. 데이터 변경 권한은 서버에서 다시 검사합니다. OAuth 오류·동의 취소는 성공 이동과 다른 경로이며, 커스텀 /login 페이지는 별도로 구현해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 ClientAuthButton의 조건문 순서다. status가 loading이면 이전 session.user 값과 무관하게 disabled 확인 중 버튼에서 끝난다. 로딩이 끝난 경우에만 session.user를 판정하여 로그아웃 또는 로그인 버튼을 반환한다. 화살표는 버튼을 선택하는 제어 흐름이고 signIn/signOut은 각 버튼을 클릭할 때 브라우저의 next-auth/react API로 호출된다. 버튼 출력은 데이터 변경 권한 검사가 아니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 세션 확인 중 status가 loading이면 예전 session.user와 무관하게 확인 중 버튼을 반환합니다. | status: loading · 버튼: disabled 확인 중 · session.user: 이 분기에서 무시 | 세션 해석 전 클릭과 이전 사용자 표시를 막습니다. |
| 세션 해석 완료 loading이 끝나야 session.user를 판단합니다. | status: loading 아님 · 판정: session.user 유무 | 사용자 유무가 UI 선택 입력이 됩니다. |
| 로그인 또는 로그아웃 UI user 유무에 맞는 버튼이 선택되고 클릭 때 signIn 또는 signOut을 호출합니다. | user 있음: 로그아웃 · user 없음: 로그인 · 권한 검사: 버튼 출력과 별도 | 세션 결과가 실제 클릭 가능한 UI로 반영됩니다. |
로그인 API의 실행 위치와 세션 UI를 구분합니다
AuthButton이 세션 loading 동안 버튼을 막고, 세션 해석 뒤 로그인 또는 로그아웃 UI를 선택합니다. 같은 버튼의 초기 상태와 해석 완료 상태를 따라가면 과거 세션 값으로 잘못 표시하는 이유를 이해할 수 있습니다.
NEXT · 10.2 · 로그인과 로그아웃
로그인 API의 실행 위치와 세션 UI를 구분합니다
같은 로그인·로그아웃 기능도 import와 실행 위치가 다릅니다. 두 방식을 동시에 거치는 순서가 아니라, 교재의 서버 버튼과 ClientAuthButton이라는 두 구현을 비교합니다.
서버 액션 · @/auth
- form 제출 → 서버 액션
- SignInButton은 use server 액션에서 signIn("github")를 호출합니다. 인증 완료 후 /account로 이동합니다.
- 로그아웃 → 현재 앱 세션 종료
- SignOutButton은 signOut을 호출하고 /로 이동합니다. JWT 전략에서 복사된 모든 토큰을 일괄 폐기하는 구현은 아닙니다.
- AuthMenu → 서버의 세션 분기
- auth()의 session.user가 없으면 로그인 버튼, 있으면 사용자 표시와 로그아웃 버튼을 렌더링합니다.
클라이언트 API · next-auth/react
- 전제 · SessionProvider
- 상위 트리에서 세션을 제공하고 useSession()으로 data와 status를 읽습니다.
- status === "loading"
- 확인 중 버튼을 비활성화합니다. 이 상태를 로그아웃 상태로 간주하지 않습니다.
- 로딩 종료 · session.user 유무
- 있으면 signOut, 없으면 signIn을 onClick에서 호출합니다. 이동 목적지는 서버 버튼과 같습니다.
세션 상태와 버튼 숨김은 UI 표현입니다. 데이터 변경 권한은 서버에서 다시 검사합니다. OAuth 오류·동의 취소는 성공 이동과 다른 경로이며, 커스텀 /login 페이지는 별도로 구현해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 원 ClientAuthButton의 조건문 순서다. status가 loading이면 이전 session.user 값과 무관하게 disabled 확인 중 버튼에서 끝난다. 로딩이 끝난 경우에만 session.user를 판정하여 로그아웃 또는 로그인 버튼을 반환한다. 화살표는 버튼을 선택하는 제어 흐름이고 signIn/signOut은 각 버튼을 클릭할 때 브라우저의 next-auth/react API로 호출된다. 버튼 출력은 데이터 변경 권한 검사가 아니다.
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 세션 확인 중 status가 loading이면 예전 session.user와 무관하게 확인 중 버튼을 반환합니다. | status: loading · 버튼: disabled 확인 중 · session.user: 이 분기에서 무시 | 세션 해석 전 클릭과 이전 사용자 표시를 막습니다. |
| 세션 해석 완료 loading이 끝나야 session.user를 판단합니다. | status: loading 아님 · 판정: session.user 유무 | 사용자 유무가 UI 선택 입력이 됩니다. |
| 로그인 또는 로그아웃 UI user 유무에 맞는 버튼이 선택되고 클릭 때 signIn 또는 signOut을 호출합니다. | user 있음: 로그아웃 · user 없음: 로그인 · 권한 검사: 버튼 출력과 별도 | 세션 결과가 실제 클릭 가능한 UI로 반영됩니다. |
위 그림은 같은 로그인, 로그아웃 기능이라도 import와 실행 위치가 다르다는 점을 보여 줍니다. 두 방식을 차례로 거치는 순서가 아니라, 서버 버튼과 ClientAuthButton이라는 두 구현을 비교한 것입니다. 위쪽의 흐름은 ClientAuthButton의 조건문 순서입니다. status가 loading이면 이전 session.user 값과 관계없이 비활성화된 "확인 중" 버튼에서 끝나고, 로딩이 끝난 경우에만 session.user를 판단해 로그아웃이나 로그인 버튼을 돌려줍니다. 그림의 단계도 세션 확인 중, 세션 해석 완료, 로그인이나 로그아웃 UI 선택의 순서입니다.
아래쪽 비교에서 서버 액션 쪽은 @/auth를 씁니다. SignInButton은 서버 액션에서 signIn("github")를 호출해 인증을 마친 뒤 /account로 이동하고, SignOutButton은 signOut을 호출해 현재 앱 세션을 끝내고 /로 이동하며, AuthMenu는 auth()의 session.user가 없으면 로그인 버튼을, 있으면 사용자 표시와 로그아웃 버튼을 렌더링합니다. 클라이언트 API 쪽은 next-auth/react를 쓰며, 상위 트리의 SessionProvider를 전제로 useSession()이 data와 status를 읽고 onClick에서 signIn이나 signOut을 부릅니다. 그림의 설명처럼 세션 상태와 버튼 숨김은 UI 표현일 뿐이고, 데이터 변경 권한은 서버에서 다시 검사합니다.
로그인 실패 다루기
OAuth 공급자가 오류를 돌려주거나 사용자가 동의를 취소할 수 있습니다. /login 페이지를 직접 만들었다면 설정에 그 경로를 지정합니다. 다음은 설정에 추가할 부분이며, 기존 NextAuth와 GitHub import, 필요한 세션과 콜백 설정은 그대로 둡니다. 경로를 설정한다고 페이지 파일이 만들어지지는 않습니다.
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
pages: {
signIn: '/login',
error: '/login',
},
});오류 화면에는 공급자의 원문 오류나 비밀 정보를 그대로 보여 주지 않습니다. 사용자에게는 다시 시도할 방법을 안내하고, 자세한 원인은 서버 로그에서 확인합니다.
리다이렉트와 오류도 구분합니다. 서버의 signIn()과 signOut()은 기본 설정에서 페이지 이동으로 흐름을 끝내므로, 성공한 뒤 언제나 다음 줄로 돌아오는 일반 함수처럼 쓰지 않습니다. 나중에 서버 액션에 try/catch를 넣는다면 인증 실패인 AuthError만 처리하고 그 밖의 예외는 다시 던집니다. Next.js의 리다이렉트도 예외로 제어 흐름을 전달하므로, 모든 예외를 "로그인 실패"로 삼키면 정상적인 이동이 막힐 수 있습니다. Auth.js 서버 API의 오류 처리 예시와 Next.js redirect 규칙을 함께 확인하세요.
화면에서 버튼을 숨겼다는 사실만으로 권한이 보호되지는 않습니다. 다음 글에서는 서버 쪽에서 auth()로 세션을 검사해 실제 라우트를 보호합니다.
정리
- 로그인과 로그아웃은 기본적으로
@/auth의signIn,signOut을 서버 액션에서 호출하며, 클라이언트 API는next-auth/react에서 가져옵니다. signOut()은 현재 브라우저의 앱 세션 쿠키를 지울 뿐, GitHub 같은 공급자의 세션이나 이미 복사된 JWT까지 끝내지는 않습니다.- 서버 컴포넌트에서
auth()로 세션을 읽으면 서버 렌더링 결과에 알맞은 버튼을 넣을 수 있습니다. useSession()은 상위의SessionProvider가 필요하며,loading상태를 먼저 처리한 뒤 사용자 유무를 판단합니다.- 서버의
signIn()과signOut()은 리다이렉트로 흐름을 끝내므로,try/catch에서는AuthError만 처리하고 다른 예외는 다시 던집니다.
연습 문제
1. 로딩 분기와 API의 위치
ClientAuthButton에 이전 session.user 값이 남아 있지만 status는 loading이라고 가정합니다. 어떤 버튼을 먼저 돌려줄까요? 로딩이 끝난 뒤 사용자 값이 없다면 어떤 API를 어느 모듈에서 호출할까요? 조건문의 순서만 따라 판단해 보세요.
해설 보기
첫 번째 조건에서 비활성화된 "확인 중" 버튼을 돌려주므로, 이전 사용자 값에 따른 로그아웃 버튼까지 내려가지 않습니다. 로딩이 끝난 뒤 사용자가 없으면, 클릭할 때 next-auth/react의 signIn을 호출합니다. 서버 폼에서 쓰는 @/auth의 signIn과 기능 이름은 같아도 import와 실행 위치가 다릅니다.
2. 앱 세션과 GitHub 세션
앞 글의 JWT 전략과 이 글의 기본 리다이렉트 설정을 쓴다고 가정합니다. 서버의 거부 목록이나 전역 토큰 폐기 기능은 추가하지 않았습니다. 앱에서 signOut()이 끝난 뒤에도 GitHub 웹사이트는 여전히 로그인 상태입니다. 이것은 모순일까요? 앱의 보호된 API에 세션 없이 보낸 요청은 어디에서 거부해야 할까요?
해설 보기
앱과 GitHub의 세션은 별개이므로 모순이 아닙니다. 앱의 API는 요청마다 서버에서 auth()로 확인한 세션을 검사해야 하며, 로그인 화면이 보였는지나 GitHub 웹사이트의 상태로 허가하지 않습니다.
3. 남아 있는 JWT 사본
같은 조건에서, 과거에 발급된 JWT의 유효한 사본이 만료 전까지 남아 있습니다. 현재 브라우저의 쿠키를 지운 것만으로 이 사본까지 곧바로 무효화되었다고 결론 내릴 수 있을까요?
해설 보기
결론 내릴 수 없습니다. 쿠키 삭제는 현재 브라우저가 그 쿠키를 보내지 않게 하는 동작일 뿐입니다. 사본을 만료 전에 무효화해야 한다면 만료 정책 외에 서버의 거부 목록이나 세션 버전 검사 같은 별도의 설계가 필요합니다. Auth.js 세션 전략도 JWT를 일찍 폐기하기 어려운 점을 설명합니다.
4. 버튼 상태와 서버 권한
ClientAuthButton이 status === 'loading'인 동안에는 어떤 버튼을 보여 줄까요? 서버 요청의 인증과 권한도 이 버튼의 상태로 결정할까요?
해설 보기
비활성화된 "확인 중" 버튼을 보여 주고, 사용자를 확인한 뒤 로그인이나 로그아웃 버튼을 고릅니다. 이 분기는 UI 처리일 뿐이며, 서버 요청은 서버가 확인한 신원과 그 요청의 권한을 따로 검사합니다.
5. 모든 예외를 무시한 signIn
서버 액션에서 signIn() 호출을 try/catch로 감싸 모든 예외를 무시한 뒤 "성공" 메시지를 출력했습니다. 이렇게 하면 왜 정상 이동과 인증 실패를 잘못 구분할 수 있을까요?
해설 보기
정상 리다이렉트가 전달하는 제어 흐름까지 잡아서 없앨 수 있고, 실제 실패도 성공처럼 보이게 만들 수 있기 때문입니다. 처리할 인증 오류만 구분하고 다른 예외는 다시 던지며, 무조건 이어지는 성공 메시지에 기대지 않습니다.
다음 글 보호된 라우트에서는 서버에서 세션을 검사해 로그인한 사용자만 페이지와 API에 접근하게 하는 방법을 알아봅니다.