Overview
Next.js와 Firebase를 활용하여 웹앱 서비스를 구축하면서, 백오피스(Admin) 영역은 관리자만 접근 가능하도록 인증이 필요한 영역으로 분리하여 구현하기로 하였습니다.
위 환경에서 일반 사용자에게 노출되는 User Route와 관리자 전용 Admin Route를 명확히 구분하여 설계하였으며, 각 서비스의 책임과 접근 권한을 분리하기 위해 RootLayout 또한 분리하여 구현하였습니다.
Admin 서비스의 특성상 반드시 SSR(Server-Side Rendering)이 필요한 구조는 아니었지만, 향후 인증 로직, 보안 정책, 레이아웃 및 미들웨어 확장 가능성을 고려하여, 초기 단계부터 User 영역과 Admin 영역을 구조적으로 분리된 RootLayout 으로 구성하였습니다.
Repository 구조 선택 배경
본 프로젝트는 Monolithic Repository 구조를 선택하여 개발을 진행하였습니다.
그 이유는 다음과 같습니다.
- 프로젝트 초기 단계에서 레포지토리를 분리하여 멀티 레포 방식으로 관리할 경우, 설정 및 운영에 필요한 시간적 비용이 상대적으로 크다고 판단하였습니다.
- 개발 인원이 2명으로 제한되어 있어, 멀티 레포 또는 복잡한 모노레포(pnpm-workspace)를 도입하는 것보다, 단일 레포지토리 내에서 빠른 커뮤니케이션과 개발 속도를 유지하는 것이 더 효율적이라 판단하였습니다.
인증 요청 흐름에 대한 일반적인 구조
일반적으로 Next.js를 활용한 웹 서비스에서는 ‘로그인’과 같은 인증 요청이 아래와 같은 레이어 구조를 통해 요청과 응답을 주고받습니다.
Next.js Client ↔️ BFF(e.g. Route APIs / Adding Express Server) ↔️ Backend Framework(e.g. Spring) ↔️ DB
Problem
초기에는 Firebase라는 BaaS를 사용하고 있었기 때문에, 다층적인 레이어 구조를 별도로 설계하거나 Nest.js와 같은 Backend Framework를 도입할 필요성을 느끼지 못했습니다.
그러나 서비스가 확장되면서 현재는 Next.js 클라이언트, Next.js API Routes를 활용한 BFF, Firebase로 구성된 총 3개의 레이어를 사용하고 있으며, 이 구조가 보안 측면에서 충분한지에 대한 고민이 생기게 되었습니다.
특히 인증과 권한 제어가 필요한 영역이 증가하면서, 기존 기술 스택을 유지하면서도 Next.js와 Firebase 환경에서 레이어를 이중화하여 보안을 강화할 수 있는 구조는 없을지를 중심으로 아키텍처를 재검토하게 되었습니다.
아래와 같은 옵션들이 있었습니다.
- Next Auth 활용
- Express를 Next.js Server에 적용 후, passport 모듈 활용
이미 Firebase의 인증을 도와주는 Authentication Solution이 있었기에, Admin Route 보호 전략으로서 BFF 역할을 하는 Next.js의 Route API와 Firebase의 Authentication, Next.js에 추상화된 Cookie 방식를 활용하여 로그인, 로그아웃, 비밀번호 재설정 등과 같은 기능을 구현해보고자 하였습니다.
How to design Authentication Flow in this App?
Admin Route에서 일반적인 Authentication 과정을 다음과 같이 도식화 하였습니다.
Resolution
본 프로젝트에서 적용한 로그인은 Firebase 1차 인증 + Next.js(BFF) 2차 세션 검증으로 구성됩니다.
즉, Firebase로 사용자를 먼저 인증한 뒤, Admin 영역 접근을 위한 별도의 세션 쿠키(SESSION)를 Next.js Route Handler에서 발급/검증하여 인증 레이어를 이중화했습니다.
1. 로그인 Flow
1) 클라이언트에서 1차 인증 (Firebase Auth)
- 사용자가 로그인 폼에서 이메일/비밀번호를 제출하면
login()이 호출됩니다. login()은 먼저 Firebase Authentication의signInWithEmailAndPassword(auth, email, password)를 수행합니다.- 이 단계에서 이메일/비밀번호의 유효성을 Firebase가 검증합니다.
// src/app/(admin)/_components/auth/LoginForm.jsx
export default function LoginForm() {
const router = useRouter();
const { userData, setUserData } = useUserStore();
const notificationActions = useNotificationActions();
const [isLoginLoading, setIsLoginLoading] = useState();
const {
register,
handleSubmit,
formState: { errors },
reset,
setFocus,
} = useForm({
mode: "onChange",
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
shouldFocusError: true,
});
const onSubmit = async (data) => {
setIsLoginLoading(true);
try {
const userData = await login(data);
} catch (error) {
setFocus("email");
reset();
console.error(error);
} finally {
setIsLoginLoading(false);
}
};
return (
<div>
<Form onSubmit={handleSubmit(onSubmit)} />
</div>
);
}
2) Firebase 인증 성공 후 2차 세션 생성 (Next.js Route Handler / BFF)
- 1차 인증이 성공하면, 클라이언트는 바로 Next.js Route Handler(
/admin/login/api)로 POST 요청을 보냅니다. - 요청 시
credentials: 'include'로 설정하여, 쿠키 기반 세션을 사용합니다. - Route Handler는 다음과 같이 동작합니다.
cookies()에서 기존SESSION쿠키가 있는지 확인합니다.- 없다면
uuidv4()로 예측 불가능한sessionId를 새로 생성합니다. { sessionId, userId }정보를encrypt()로 암호화하여 세션 토큰을 생성합니다.- 생성된 토큰을
HttpOnly + Secure(+ Strict SameSite)옵션으로SESSION쿠키에 저장합니다.- 만료: 1일 (
maxAge: 60 * 60 * 24)
- 만료: 1일 (
- 캐시를 방지하기 위해
no-store헤더를 추가합니다.
이 단계에서 클라이언트는 세션 토큰의 내용을 직접 읽을 수 없고(
HttpOnly), Admin 영역 접근 여부 판단은 서버가 쿠키를 기반으로 수행할 수 있게 됩니다.
// src/lib/firebase/auth.js
const login = async ({ email, password }) => {
try {
// firebase로 1차 로그인
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
// Firebase로부터 ID 토큰을 받아옵니다 (like accessToken)
const idToken = await userCredential.user.getIdToken();
const response = await fetch("/admin/login/api", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${idToken}`, // 액세스 토큰을 헤더에 추가
},
body: JSON.stringify({
email,
password,
}),
credentials: "include",
});
if (response.error === 500) {
throw new Error(response?.error?.message);
}
if (response.ok) {
const data = await response.json();
const userDocRef = doc(db, COLLECTION_NAME, email);
const userSnapShot = await getDoc(userDocRef);
const userData = userSnapShot.data();
if (userData) {
return { ...userData, email };
} else {
throw new Error(data.message);
}
}
} catch (error) {
console.error(error);
throw new Error(error);
}
};
// src/app/(admin)/admin/login/api/route.js
export async function POST(request) {
try {
const { email, password } = await request.json();
const sessionId = cookies().get(SESSION) ?? uuidv4(); // cookies() 정보에 담긴 idToken(accessToken) 이 없을 경우 uuid를 활용하여 방어한다.
// next server response
const response = NextResponse.json({
message: "Credentials are generated",
email,
});
const session = await encrypt({ sessionId, userId: email.split("@")[0] });
console.log("encrypt session", session);
response.cookies.set(SESSION, session, {
httpOnly: true,
secure: process.env.NODE_ENV === "production", // Only set secure flag in production
maxAge: 60 * 60 * 24 * 1, // Cookie expires in 1 day
sameSite: "strict", // Adjust based on your needs (lax, strict, none)
path: "/", // Cookie is valid for the whole site
});
} catch (error) {
console.error(error);
return NextResponse.json(
{ message: "A Server error occurred", error: error.message },
{ status: 500 }
);
}
}
3) 관리자 권한 확인 (Firestore)
- Next.js 세션 쿠키가 정상적으로 설정되면, 클라이언트는
Firestore에서 관리자 문서를 조회합니다. doc(db, COLLECTION_NAME, email)문서가 존재하면 관리자 데이터(nickname등)를 확보하고 로그인 성공으로 처리합니다.- 문서가 없다면 “관리자 아님”으로 간주하고 에러를 발생시켜 로그인 실패 처리합니다.
// src/lib/firebase/auth.js
const login = async ({ email, password }) => {
try {
// 1. firebase 로그인
...
// 2. fetch API Route API 에서 검증
...
// 3. firestore 검증
if (response.ok) {
const data = await response.json();
const userDocRef = doc(db, COLLECTION_NAME, email);
const userSnapShot = await getDoc(userDocRef);
const userData = userSnapShot.data();
if (userData) {
return { ...userData, email };
} else {
throw new Error(data.message);
}
}
} catch (error) {
console.error(error);
throw new Error(error);
}
};
4) 성공/실패 UX 처리
- 성공
- 전역 상태에 사용자 데이터를 저장하고 성공 Toast를 노출합니다.
router.replace(route.DASHBOARD)로 이동 후router.refresh()로 화면을 갱신합니다.
- 실패
- 실패 Toast를 노출하고 입력 폼을 reset한 뒤 이메일 필드에 focus 합니다.
try {
// 1, 2, 3 과정 이후
// const userData = await login(data);
if (userData) {
shouldRedirect = true;
setUserData(userData);
notificationActions?.dispatchNotification({
status: "success",
message: "성공적으로 로그인하였습니다",
});
}
} catch (error) {
notificationActions?.dispatchNotification({
status: "error",
message: "이메일과 비밀번호를 다시 확인해 주세요",
});
setFocus("email");
reset();
console.error(error);
} finally {
setIsLoginLoading(false);
}
2. Admin Route 접근 및 새로고침 대응
세부적으로, 성공적으로 로그인한 후에도 새로고침 혹은 다른 Secure 된 페이지로 접근하는 경우는 다음과 같은 접근으로 해결하였습니다.
이 구조에서 Admin Route 의 지속 인증 확인은 다음 조합으로 유지됩니다.
- 1차 인증 : Firebase는 사용자가 유효하게 로그인했는지 확인
- 2차 인증 : Next.js는 Admin Route에 접근 가능한 세션 쿠키가 있는지(2차 게이트) 확인
따라서, 페이지 새로고침이나 /admin/* 진입 시에는:
- 서버(Middleware 또는 Route Handler) 가
SESSION쿠키 존재/유효성을 기준으로 접근을 제어- pathname이
/admin으로 시작하거나/admin/login이 아닌 경우, cookie에 담긴 session-cookie 정보로 인증 요청을 보낸다.- cookie Validation을 통해 담긴 데이터가 유효기간이 지나지 않은 경우
- 인증 허가로 특정 페이지로 리다이렉트 시킨다.
- cookie에 담긴 데이터가 유효기간이 지난 경우, 401(unauthorized) 에러 코드로 응답을 보낸다.
- Header에 유효기간이 지난 accessToken 대신 refreshToken을 넣어 재요청을 보낸다.
- refreshToken으로 사용자 권한을 확인한 서버는 응답 Header에 accessToken을 담아 응답한다.
- accessToken을 받은 후, 성공적으로 로그인 했음을 확인하고, 특정 페이지로 Redirect 시킨다.
- cookie Validation을 통해 담긴 데이터가 유효기간이 지나지 않은 경우
- pathname이
- Admin Layout 컴포넌트에서 Firebase Auth 상태(
onAuthStateChanged)와 함께 2중으로 조건을 만족하는 경우에만 Admin 페이지를 허용하는 방식으로 확장 가능합니다.
// src/middleware.js
export function middleware(request) {
const { pathname } = request.nextUrl;
if (pathname.startsWith("/admin") && !pathname.startsWith("/admin/login")) {
const credentials = request.cookies.get(SESSION);
console.log("credentials", credentials);
if (!credentials && !pathname.startsWith("/admin/resetpassword")) {
console.log("no credentials");
return NextResponse.redirect(new URL("/admin/login", request.url));
}
return NextResponse.next();
}
}
Conclusion
사실, NextAuth 혹은 Express.js의 Passport 모듈을 사용한 것보다는 완벽한 솔루션을 아닐 수 있습니다.
Express 서버를 Next.js 프레임워크 내에 구축하여, passport 모듈을 활용해볼까도 고민하였었지만, Firebase의 Auth의 보안적인 강력함을 활용해보기로 하였습니다.
따라서, Firebase의 Auth에서 반환하는 token을 session cookie와 적절히 잘 활용하여 충분히 보안이 유지되는 어플리케이션을 구축하는 경험을 할 수 있었습니다.
코드에는 은탄환이 없고, 완벽한 어플리케이션도 없기 때문에, 유지보수를 하는 과정에서 취약점이 발견된다면, 언제든지 취약점을 해결하기 위한 코드로 언제든지 리팩토링 해보려 합니다.
이 프로젝트를 통해 프론트엔드 엔지니어로서 인증 처리에 한츰 익숙해질 수 있었습니다.