ID: key_26_22_05_25_P1_1_5 Created date: 5월 25 2026 월요일

연관 문서


개요

next-auth v5를 활용한 로그인·회원가입 페이지를 구현한다.
서버 Auth API(1-2_서버-Auth-API)와 연동하여 세션을 관리한다.

  • 예상 소요: 2~3일
  • 선행 조건: 1-2_서버-Auth-API, P6_CLAUDE-md-UI 완료
  • 완료 기준: 브라우저에서 로그인 성공 후 /home 리다이렉트 동작 확인

현재 web-plate 구현 현황

기존 코드 충돌 분석

아래 충돌 항목을 반드시 확인하고 작업 지시서대로 수정할 것

항목작업 지시서현재 web-plate 상태충돌 여부
next-auth 버전v5v5 beta.30 설치됨✅ 일치
auth 설정 파일src/auth.tssrc/auth.ts 존재✅ 일치 (수정 필요)
[...nextauth]/route.tshandlers 재export이미 존재✅ 유지
Credentials authorizeCIRA 백엔드 연동hardcoded admin/admin⚠️ 수정 필요
로그인 필드emailusername 사용 중⚠️ 수정 필요
회원가입 개념이메일/비번 신규 가입Google OAuth 후 프로필 등록⚠️ 목적 충돌 → 해결 방안 참조
세션 토큰accessToken (CIRA JWT)idToken (Google OAuth)⚠️ 확장 필요
API 클라이언트axios 인터셉터fetch + /api/proxy/ 패턴 사용 중⚠️ proxy 방식 유지 + 토큰 주입 추가
리다이렉트 경로/projects/todo/home⚠️ /home 으로 통일
미들웨어신규 구현이미 구현 완료✅ 경로 추가만 필요
폼 유효성react-hook-form + zod미설치⚠️ Server Action 방식으로 대체
Google OAuth미포함이미 구성됨✅ 유지

구현 화면 목록

경로설명현황
/login로그인 페이지기존 파일 수정
/cira/registerCIRA 이메일 회원가입신규 생성 (기존 /register와 분리)

회원가입 경로 분리 이유
기존 /register는 Google OAuth 후 프로필 등록 용도로 다른 서비스에서 사용 중.
CIRA 전용 이메일 회원가입은 /cira/register로 분리하여 충돌 방지.


Claude Code 작업 수행서

목표

기존 src/auth.ts의 Credentials provider를 CIRA 백엔드 API와 연동하고,
로그인 폼 및 CIRA 전용 회원가입 페이지를 구현한다.
기존 Google OAuth 흐름과 proxy API 패턴은 그대로 유지한다.


작업 지시

web-plate 프로젝트의 인증 기능을 CIRA 백엔드와 연동하도록 수정해줘.
기존 Google OAuth 설정 및 /api/proxy/ 패턴은 변경하지 않는다.

[프로젝트 경로]
- C:\workspace\tsh\boilerplate\fe\web-plate

[현재 프로젝트 스택 확인]
- Next.js 16.1.3, React 19
- next-auth v5 (beta.30) — src/auth.ts 중앙 설정
- API 패턴: fetch + /api/proxy/[...path]/route.ts
- 스타일: Tailwind CSS
- 상태관리: zustand (설치됨, 미사용)
- react-hook-form, zod: 미설치 → Server Action 방식으로 대체

---

[수행 작업 1] src/auth.ts — Credentials authorize 수정

기존 hardcoded 로직을 CIRA 백엔드 호출로 교체.
Google 설정은 변경하지 않는다.

변경 전:
  async authorize(credentials) {
    if (credentials?.username === "admin" && credentials?.password === "admin") {
      return { id: "1", name: "Admin", email: "admin@tsh.com" };
    }
    return null;
  }

변경 후:
  credentials: {
    email: { label: "Email" },
    password: { label: "Password", type: "password" },
  },
  async authorize(credentials) {
    try {
      const res = await fetch(`${process.env.BACKEND_URL}/api/v1/auth/login`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          email: credentials?.email,
          password: credentials?.password,
        }),
      });
      if (!res.ok) return null;
      const data = await res.json();
      // 백엔드 응답: { success: true, data: { accessToken, user: { id, email, name } } }
      return {
        id: data.data.user.id,
        email: data.data.user.email,
        name: data.data.user.name,
        accessToken: data.data.accessToken,
      };
    } catch {
      return null;
    }
  }

callbacks 수정 (idToken 유지 + accessToken 추가):
  async jwt({ token, account, user }) {
    if (account) {
      token.idToken = account.id_token;  // Google OAuth — 기존 유지
    }
    if (user) {
      token.accessToken = (user as any).accessToken;  // CIRA JWT
    }
    return token;
  },
  async session({ session, token }) {
    session.idToken = token.idToken as string;         // 기존 유지
    session.accessToken = token.accessToken as string; // CIRA JWT 추가
    return session;
  },

---

[수행 작업 2] src/next-auth.d.ts — 타입 확장

기존 idToken에 accessToken 추가:

declare module "next-auth" {
  interface Session {
    idToken?: string;
    accessToken?: string;  // CIRA JWT 추가
    user: {
      id?: string;
    } & DefaultSession["user"];
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    idToken?: string;
    accessToken?: string;  // CIRA JWT 추가
  }
}

---

[수행 작업 3] src/app/(auth)/login/page.tsx — 기존 파일 수정

변경 사항:
1. Server Action의 signIn 필드: username → email
2. 로그인 성공 redirectTo: "/todo" → "/home"
3. 입력 필드 placeholder 및 name 속성 수정
4. 이메일 입력 type="text" → type="email"
5. "CIRA 회원가입" 링크 추가 → /cira/register

최종 폼 구조:
  <form action={async (formData) => {
    "use server";
    await signIn("credentials", {
      email: formData.get("email"),
      password: formData.get("password"),
      redirectTo: "/home",
    });
  }}>
    <input type="email" name="email" placeholder="이메일" ... />
    <input type="password" name="password" placeholder="비밀번호" ... />
    <button type="submit">로그인</button>
  </form>
  <a href="/cira/register">CIRA 계정 회원가입</a>

---

[수행 작업 4] src/app/(auth)/cira/register/page.tsx — 신규 생성

기존 /register는 Google OAuth 후 프로필 등록 용도이므로 수정하지 않는다.
CIRA 전용 이메일/비밀번호 가입 페이지를 /cira/register 경로로 신규 생성.

"use server" Server Action으로 구현:
  async function registerAction(formData: FormData) {
    "use server";
    const name = formData.get("name") as string;
    const email = formData.get("email") as string;
    const password = formData.get("password") as string;
    const confirm = formData.get("confirm") as string;

    // 서버 사이드 기본 검증
    if (password !== confirm) {
      // redirect with error param
      redirect("/cira/register?error=password_mismatch");
    }

    const res = await fetch(`${process.env.BACKEND_URL}/api/v1/auth/register`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, email, password }),
    });

    if (!res.ok) {
      redirect("/cira/register?error=register_failed");
    }
    redirect("/login?registered=true");
  }

폼 필드: 이름, 이메일(type=email), 비밀번호(type=password), 비밀번호 확인
에러 표시: URL searchParams의 error 값을 읽어 인라인 메시지 표시

---

[수행 작업 5] src/middleware.ts — CIRA 경로 추가

기존 미들웨어는 수정하지 않고 matcher에 CIRA 경로만 추가:

export const config = {
  matcher: [
    "/((?!api/auth|_next/static|_next/image|favicon.ico).*)",
  ],
};
// 기존 auth 미들웨어 로직 그대로 유지
// /cira/register는 인증 없이 접근 가능하도록 isRegisterPage 조건에 추가:
const isRegisterPage = pathname === "/register" || pathname === "/cira/register";

---

[수행 작업 6] src/app/api/proxy/[...path]/route.ts — Authorization 헤더 주입

기존 proxy 패턴을 유지하되 CIRA accessToken을 서버에서 주입.
auth() 함수로 서버 사이드 세션 조회 후 헤더 추가:

import { auth } from "@/auth";

// proxyHandler 내부 api.fetcher 호출 전:
const session = await auth();
const authHeader = session?.accessToken
  ? { Authorization: `Bearer ${session.accessToken}` }
  : {};

const result = await api.fetcher(targetPath, {
  method,
  headers: authHeader,   // Authorization 헤더 추가
  body: body ? JSON.stringify(body) : undefined,
});

---

[수행 작업 7] .env.local 확인
BACKEND_URL 이 CIRA 서버를 가리키도록 확인/수정:
  BACKEND_URL=http://localhost:8080  (로컬 CIRA 서버)

---

[검증]
1. 이메일/비밀번호로 로그인 → /home 리다이렉트 확인
2. session.accessToken 값이 존재하는지 확인 (서버 컴포넌트에서 auth() 호출)
3. /api/proxy/ 경로 호출 시 Authorization 헤더가 전달되는지 서버 로그 확인
4. 미인증 상태로 /home 접근 시 /login 리다이렉트 확인
5. /cira/register 에서 회원가입 후 /login?registered=true 이동 확인

[변경하지 않는 것]
- Google OAuth 설정 (src/auth.ts의 Google provider)
- 기존 /register 페이지 (Google OAuth 후 프로필 등록 흐름)
- /api/proxy/ 기본 구조
- middleware.ts 핵심 로직
- 기존 설치된 패키지 목록 (react-hook-form, zod 미설치 유지)

완료 기준

항목기준
이메일 로그인CIRA 백엔드 인증 성공 → /home 리다이렉트
Google 로그인기존 동작 유지 (회귀 없음)
CIRA 회원가입/cira/register → 가입 → /login 이동
accessTokensession.accessToken 값 존재 확인
proxy 인증 헤더/api/proxy/ 호출 시 Bearer 헤더 포함
미인증 접근/home, /cira/* 접근 시 /login 리다이렉트

후행 작업