G-1: 프론트엔드 인증 ① — 진짜 로그인을 만듭니다
목차 54
안녕하세요, 홍순구 튜터입니다.
시작하기 전에 우리 앱으로 실험을 하나 해봤어요. 브라우저 개발자 도구를 열어서 쿠키 하나를 손으로 만들었습니다. 이름은 me, 값은 nobody. 그런 사람은 우리 앱에 없어요.
그 상태로 재훈이의 프로필을 열어봤습니다.
요청 GET /jaehoon (쿠키: me=nobody)
응답 200
머리말 @nobody
들어가졌습니다. 그리고 앱은 저를 @nobody라고 부르고 있어요.
여기서 멈추지 않았습니다. 그 상태로 민지의 프로필에 가면 팔로우 버튼이 그대로 있어요. 눌러봤더니 서버까지 요청이 갔습니다. 우리 앱은 제가 누구인지 물어본 적이 한 번도 없어요.
지난 시간 마지막에 이렇게 말씀드렸죠. D-8에서 세운 문지기는 쿠키가 있는지만 봤다고요. 그 쿠키가 진짜인지, 누구 것인지는 안 봤다고요.
오늘 그 자리를 채웁니다.
💡 오늘 수업의 핵심 — "서버만 아는 열쇠로 서명한 쿠키라야 신원입니다"
🎯 학습 목표
- 지금 우리 로그인이 왜 로그인이 아닌지를 직접 위조해보고 확인합니다.
- 인증 서버를 앱 안에 세우고, 비밀번호를 진짜로 확인하게 만듭니다.
- 서명된 세션 쿠키가 위조를 어떻게 막는지 눈으로 확인합니다.
Step 1: "버튼 하나로 남이 됩니다"
지금 우리 머리말에는 버튼이 두 개 있습니다. "재훈으로 보기"와 "민지로 보기" 요.
D-7에서 이 버튼을 만들 때 제가 가짜라고 말씀드렸어요. 눌러보면 무슨 일이 벌어지는지 다시 봅시다.
// apps/web-next/app/actions/session.ts (지금 상태)
export async function signIn(formData: FormData) {
const username = formData.get('username');
if (typeof username !== 'string' || username === '') {
return;
}
(await cookies()).set('me', username, {
httpOnly: true,
path: '/',
maxAge: 60 * 60 * 24,
});
}
버튼이 보낸 이름을 그대로 쿠키에 적습니다. 확인하는 줄이 한 줄도 없어요.
문지기는 무엇을 보고 있나
D-8에서 만든 문지기를 다시 읽어봅시다.
// apps/web-next/proxy.ts (지금 상태)
if (request.cookies.get('me') === undefined) {
return NextResponse.redirect(new URL('/', request.url));
}
undefined인지만 봅니다. 값이 무엇인지는 안 봐요. 그래서 이렇게 됩니다.
쿠키 없음 → 307 (막힌다)
me=jaehoon → 200
me=minji → 200
me=nobody → 200 없는 사람인데 통과
me= → 200 빈 값도 통과
마지막 두 줄이 중요합니다. nobody라는 사람은 우리 데이터 어디에도 없어요. 빈 문자열은 아예 이름도 아니고요. 그런데 둘 다 들어와집니다.
화면은 그 값을 그대로 믿습니다
머리말은 쿠키에 적힌 글자를 그대로 이름으로 씁니다.
me=jaehoon → @jaehoon
me=minji → @minji
me=nobody → @nobody
그리고 행동까지 됩니다
여기가 진짜 문제예요. 위조한 쿠키로 민지의 프로필을 열면 팔로우 폼이 통째로 옵니다. 그 폼을 그대로 제출해봤더니, 백엔드 로그에 이렇게 찍혔습니다.
[api-stub] POST /api/users/minji/follow
요청이 서버까지 갔어요. 우리 앱은 이 요청을 막을 근거를 하나도 갖고 있지 않습니다.
다만 여기서 한 번 걸립니다. 우리 연습용 백엔드는 요청에 실려온 이름이 계정 목록에 있는지를 봐요.
X-Actor: nobody → 401
X-Actor: seungwoo → 401 게시물은 있지만 로그인 계정이 아니다
X-Actor: jaehoon → 200 팔로워 8500 → 8501
세 번째 줄을 보세요. 실존하는 계정 이름으로 위조하면 그대로 통과합니다. 백엔드가 한 것은 "그 이름이 목록에 있나"를 확인한 것뿐이에요. 비밀번호도 서명도 안 봤습니다.
즉 지금 우리 앱과 백엔드 어디에도 신원을 확인하는 곳이 없습니다.
무엇이 없는 걸까요
두 가지가 없습니다.
없는 것 1 비밀번호
본인만 아는 것을 물어본 적이 없다
없는 것 2 서명
쿠키에 적힌 값을 우리가 발급했다는 증거가 없다
오늘 이 둘을 채웁니다. Step 3이 첫 번째, Step 4가 두 번째예요.
💡 한 줄 정리
지금 우리 로그인은 이름을 적어내면 그 사람이 되는 방식이고, 문지기는 그 종이가 있는지만 봅니다.
🙋 학생 질문 — "httpOnly 쿠키면 자바스크립트로 못 만든다고 하셨는데, 어떻게 위조하나요?"
맞습니다. httpOnly가 붙은 쿠키는 페이지의 자바스크립트가 읽지도 쓰지도 못해요. D-8에서 그 이야기를 했었는데, 그건 페이지 안에서 도는 스크립트에 대한 제약이에요. 브라우저 개발자 도구의 Application 탭에서는 손으로 만들 수 있고, curl 같은 도구로 요청을 직접 보내면 헤더에 원하는 쿠키를 그냥 실어 보낼 수도 있어서, D-8에서 제가 이렇게 정리했습니다. 지금 문지기는 "장난 삼아 주소를 쳐보는 사람"은 막지만 "작정한 사람"은 못 막는다고요.
httpOnly는 다른 문제를 막습니다. 남이 심어놓은 스크립트가 쿠키를 훔쳐가는 것을요. 위조를 막는 것은 서명이고, 그건 오늘 Step 4에서 붙입니다. 둘은 서로 다른 일을 하고, 둘 다 필요해요.
Step 2: "인증 서버를 앱 안에 세웁니다"
여기서 도구를 하나 들이는데, 그 전에 이름 이야기를 먼저 해야 해요.
검색하면 다른 이름이 나올 겁니다
"Next.js 인증"으로 검색하면 대부분 NextAuth라는 이름이 나옵니다. 나중에 Auth.js로 이름을 바꿨고, 자료가 굉장히 많아요.
그 프로젝트는 지금 Better Auth라는 프로젝트의 일부가 됐습니다. 공식 문서 맨 위에 그렇게 적혀 있고, 새 프로젝트를 시작한다면 Better Auth를 쓰라고 안내하고 있어요. NextAuth 쪽은 보안 문제만 고쳐주는 상태로 남아서 우리는 Better Auth를 쓰는데, 검색으로 만날 예제의 절반 이상은 아직 NextAuth로 되어 있을 거예요. 그 이름이 보이면 옛 자료라고 생각하시면 됩니다.
이런 일이 처음은 아니죠. C-1에서 라우터 패키지 이름이 바뀐 것을, D-2에서 주소 관련 훅을 가져오는 곳이 바뀐 것을 이미 겪었습니다. 프론트엔드에서는 검색 결과가 옛것인지 아닌지를 가려내는 것 자체가 일입니다.
설정 파일 하나
npm install better-auth --workspace apps/web-next
// apps/web-next/lib/auth.ts
import { betterAuth } from 'better-auth';
import { memoryAdapter } from 'better-auth/adapters/memory';
// 사람과 세션을 담아둘 곳. 지금은 서버 메모리라 서버를 끄면 사라진다.
// 표(배열)를 미리 만들어 둬야 한다 — 빈 객체를 주면 "Model user not found" 로 죽는다.
const db = { user: [], session: [], account: [], verification: [] };
export const auth = betterAuth({
database: memoryAdapter(db),
// 아이디와 비밀번호로 로그인하겠다는 선언.
emailAndPassword: { enabled: true },
});
사람과 세션을 어딘가에는 담아야 합니다. 진짜 서비스라면 데이터베이스를 연결해요. 우리는 강의라서 서버 메모리에 담습니다. 서버를 끄면 다 사라지고요.
주석에 적어둔 것을 한 번 겪어보시면 좋습니다. const db = {}로 두면 빌드도 통과하고 서버도 뜨는데, 로그인을 시도하는 순간 500이 나요. 서버 로그에 이렇게 찍힙니다.
[MemoryAdapter] Model user not found in the DB []
담을 칸을 안 만들어놓은 겁니다.
주소를 여는 파일
// apps/web-next/app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
// 주소 하나가 아니라 /api/auth 아래 전부를 이 파일이 받는다.
export const { GET, POST } = toNextJsHandler(auth);
폴더 이름이 [...all]입니다. D-1에서 [username]을 배웠죠. 대괄호 하나는 칸 하나였는데, 점 세 개가 붙으면 그 아래 전부를 받습니다.
빌드해보면 라우트 표에 한 줄이 늘어요.
ƒ /api/auth/[...all]
나머지 줄은 그대로입니다. D-5·D-6에서 정한 캐시 설정과 부딪히지 않아요.
한 줄로 여러 주소가 생깁니다
이 파일 하나가 무엇을 열었는지 직접 두드려봤습니다.
GET /api/auth/ok → 200 {"ok":true}
GET /api/auth/get-session → 200 null
POST /api/auth/sign-up/email → 400 (본문이 비어서)
POST /api/auth/sign-in/email → 400
POST /api/auth/sign-out → 200
우리가 만든 것은 파일 하나인데 주소는 여러 개가 생겼습니다.
한 가지 짚고 갈 것이 있어요. POST로만 여는 주소에 GET을 보내면 404가 옵니다.
GET /api/auth/sign-up/email → 404
D-7에서 우리가 손으로 만든 Route Handler는 같은 상황에서 405를 줬어요. 그때 우리가 POST만 내보냈기 때문이었죠. 여기는 라이브러리가 정한 방식이라 404입니다. 없는 주소로 취급하느냐, 있는데 방법이 틀렸다고 알려주느냐는 만든 쪽이 정합니다.
💡 한 줄 정리
파일 두 개로 인증 서버가 앱 안에 생겼고, [...all] 덕분에 주소 하나가 여러 엔드포인트를 받습니다.
🙋 학생 질문 — "인증 서버를 왜 우리 앱 안에 두나요? 백엔드가 하면 되는 거 아닌가요?"
좋은 질문이고, 실제로 둘 다 있는 방식입니다.
우리 인스타그램 클론에는 Spring Boot 백엔드가 있어요. C-6에서 그 백엔드에 로그인해서 토큰을 받아왔던 것 기억하시죠. "백엔드가 인증을 한다"는 이미 하고 있는 방식인 셈이지만, 지금 무대인 Next.js 앱에는 서버가 하나 더 있어요. 바로 화면을 그리는 서버인데, D-4부터 계속 우리가 써온 곳이고 D-7에서는 액션까지 받았습니다.
문제는 이 서버도 "지금 보고 있는 사람이 누구인지"를 알아야 한다는 거예요. 프로필을 그릴 때도, 액션을 받을 때도요. 그걸 매번 백엔드에 물어보는 방식도 있고, 이 서버가 세션을 직접 들고 있는 방식도 있습니다.
오늘은 후자로 갑니다. 화면을 그리는 서버가 세션의 주인이 되는 방식이라, 백엔드의 토큰과는 어떻게 화해할지가 G-3의 마지막 주제로 남습니다.
Step 3: "비밀번호를 진짜로 묻습니다"
여기서 없던 것 첫 번째를 채웁니다.
우리 앱은 이메일로 사람을 안 부릅니다
Better Auth의 기본은 이메일과 비밀번호인데, 우리 인스타그램 클론은 @jaehoon처럼 아이디로 사람을 부르고 주소도 /jaehoon이라서 아이디 로그인을 얹어줄 것을 하나 붙입니다.
// apps/web-next/lib/auth.ts
export const auth = betterAuth({
database: memoryAdapter(db),
// 비밀번호로 로그인하겠다는 선언.
emailAndPassword: { enabled: true },
// 우리 앱은 이메일이 아니라 @jaehoon 같은 아이디로 사람을 부른다.
plugins: [username()],
});
이 한 줄이 sign-in/username이라는 주소를 새로 엽니다.
계정을 심어둡니다
저장소가 메모리라서 서버를 켤 때마다 비어 있어요. 실습을 하려면 계정이 있어야 하니 둘을 심어둡니다.
// apps/web-next/lib/auth.ts
// 강의용 씨앗 계정. 저장소가 메모리라 서버를 켤 때마다 새로 심는다.
// 진짜 서비스라면 가입 화면이 이 일을 한다.
const DEMO_USERS = [
{ username: 'jaehoon', name: '재훈', password: 'hunter22!' },
{ username: 'minji', name: '민지', password: 'hunter22!' },
];
진짜 서비스라면 가입 화면이 이 일을 합니다. 우리는 가입 화면을 만들지 않으니 서버가 켜질 때 대신 심어요.
그런데 여기에 함정이 하나 있었습니다
심는 것은 시간이 걸리는 일입니다. 서버를 켜자마자 로그인을 시도하면 아직 안 심겼을 수 있어요. 실제로 그랬습니다.
기동 직후 첫 로그인 → 401
두 번째부터 → 200
서버 로그에는 User not found가 한 번 찍히고요. 두 번째부터 되기 때문에 눈치채기가 어렵습니다. 새로고침 한 번 하면 되기 때문에, 주소를 받는 파일이 심는 것을 기다렸다가 넘기게 했습니다.
// apps/web-next/app/api/auth/[...all]/route.ts
const handlers = toNextJsHandler(auth);
// 씨앗 계정을 심는 동안 로그인 요청이 먼저 도착하면 "그런 사람 없다" 가 된다.
// 서버를 켠 직후 딱 한 번 벌어지는 일이라 눈치채기 어렵다. 심는 것을 기다렸다 넘긴다.
export async function GET(request: Request) {
await demoUsersReady;
return handlers.GET(request);
}
export async function POST(request: Request) {
await demoUsersReady;
return handlers.POST(request);
}
Step 2에서 한 줄이었던 파일이 늘어났죠. 대신 기동 직후 첫 요청부터 200이 나옵니다.
이제 비밀번호를 확인합니다
{"username":"jaehoon","password":"hunter22!"} → 200
{"username":"jaehoon","password":"wrong"} → 401
{"username":"nobody","password":"hunter22!"} → 401
두 번째와 세 번째를 보세요. 응답이 완전히 같습니다.
{"message":"Invalid username or password","code":"INVALID_USERNAME_OR_PASSWORD"}
비밀번호가 틀린 건지, 그런 아이디가 없는 건지 안 알려줘요. 일부러 그렇게 만든 겁니다.
만약 "그런 아이디는 없습니다"라고 알려주면, 공격자는 아이디를 하나씩 넣어보면서 어떤 아이디가 존재하는지를 알아낼 수 있어요. 그것만으로도 이미 정보가 새는 겁니다.
Step 1에서 우리 연습용 백엔드는 X-Actor: nobody에 "로그인이 필요합니다"라고 답했고, 없는 프로필에는 "그런 사람이 없습니다"라고 404를 줬죠. 그건 프로필 조회라 공개 정보지만, 로그인 창에서는 이야기가 다릅니다.
⚠️ 한글 아이디는 기본값으로 막힙니다
우리 학생들이 반드시 밟는 곳이라 미리 말씀드릴게요.
aa → USERNAME_TOO_SHORT
jae_hoon → 통과 (밑줄은 된다)
jae hoon → INVALID_USERNAME (공백)
재훈 → USERNAME_TOO_SHORT
재훈이 → INVALID_USERNAME
마지막 두 줄이 눈에 띄죠. 재훈은 두 글자라 길이에서 먼저 걸리고, 재훈이는 세 글자인데도 막힙니다. 기본 검사가 영문자·숫자·밑줄만 받기 때문이에요.
한글 아이디를 받고 싶으면 검사 규칙을 직접 주면 되는데, 그 전에 한 번 생각해볼 것이 있어요. 아이디가 주소에 들어가는 앱이라면(/재훈이) 주소 인코딩까지 함께 따라옵니다.
비밀번호도 검사합니다. 1234는 PASSWORD_TOO_SHORT로 거절돼요.
💡 한 줄 정리
아이디로 로그인하는 주소가 열렸고, 비밀번호가 틀렸을 때와 아이디가 없을 때가 같은 답으로 돌아옵니다.
🙋 학생 질문 — "비밀번호를 그대로 저장하는 건 아니겠죠?"
아닙니다. 저장되는 것은 비밀번호가 아니라 비밀번호를 한 방향으로 뭉갠 값이에요.
한 방향이라는 게 중요합니다. 비밀번호에서 그 값을 만들 수는 있는데, 그 값에서 비밀번호로는 되돌릴 수 없어요. 로그인할 때는 들어온 비밀번호를 똑같이 뭉개서 저장된 값과 비교하기 때문에, 서비스가 "비밀번호를 잊으셨나요"에서 원래 비밀번호를 알려주지 않고 새로 정하게 하는 겁니다. 서버도 모르거든요. 만약 어떤 서비스가 가입할 때 쓴 비밀번호를 그대로 메일로 보내준다면, 그건 그대로 저장하고 있다는 뜻입니다.
우리 코드에는 이 부분이 한 줄도 없어요. emailAndPassword: { enabled: true }라고 선언한 것이 전부입니다. 직접 만들면 틀리기 쉬운 부분이라, 라이브러리를 쓰는 이유 중 하나이기도 합니다.
Step 4: "쿠키에 서명이 붙습니다"
없던 것 두 번째입니다. 오늘의 중심이에요.
로그인이 성공하면 응답에 쿠키가 실려옵니다. 그 쿠키를 뜯어봅시다.
set-cookie: better-auth.session_token=X7jr4eHKzjwGl72zsY5VLIkcksGcrBX7.t42blc8wvTOV8yS80mSbcUtl14pSNlT8z0MLy6LxVi4%3D;
Max-Age=604800; Path=/; HttpOnly; SameSite=Lax
길지만 구조는 단순합니다. 가운데 점을 기준으로 두 조각이에요.
X7jr4eHKzjwGl72zsY5VLIkcksGcrBX7 . t42blc8wvTOV8yS80mSbcUtl14pSNlT8z0MLy6LxVi4%3D
└────────── token 32 ──────────┘ . └─────────────── signature 46 ───────────────┘
앞쪽 32자가 세션을 가리키는 번호표입니다. 뒤쪽 46자가 오늘의 주인공이에요. 서버만 아는 열쇠로 앞쪽을 계산해서 만든 값입니다.
끝의 %3D는 =를 주소에 실을 수 있게 바꿔 적은 것이라 서명의 일부입니다.
왜 이게 위조를 막을까요
D-8의 쿠키와 비교해보면 분명해집니다.
D-8 me=jaehoon
누구나 적을 수 있다. 우리가 발급했다는 증거가 없다.
오늘 better-auth.session_token=<토큰>.<서명>
서명은 서버만 아는 열쇠가 있어야 만들 수 있다.
값을 고치면 서명이 안 맞고, 서명을 맞추려면 열쇠가 필요합니다.
직접 위조해봤습니다
말로만 하면 안 되니 실제로 해봤어요. 다섯 가지를 보냈습니다. 첫 줄은 대조군입니다. 진짜 쿠키가 통과하는지부터 봐야 나머지 결과가 의미를 가져요.
진짜 (대조군) → 세션이 돌아온다
쓰레기 값 → null
서명을 떼어냄 → null
토큰은 진짜 + 서명만 위조 → null
서명은 진짜 + 토큰만 바꿈 → null
세 번째부터 다섯 번째가 핵심입니다. 절반이 진짜여도 안 통해요. 토큰과 서명은 짝이라서 둘이 같이 맞아야 합니다.
쿠키에 붙은 나머지 표시들
HttpOnly 페이지의 자바스크립트가 못 읽는다
SameSite=Lax 다른 사이트에서 우리 앱으로 보내는 요청에는 잘 안 딸려간다
Path=/ 앱 전체 주소에서 보낸다
Max-Age=604800 7일 (60×60×24×7)
Max-Age를 보세요. 7일입니다. 새로고침해도 로그인이 안 풀리는 이유가 여기 있어요. 브라우저를 껐다 켜도 7일 동안은 이 쿠키가 남아 있습니다.
SameSite=Lax는 다른 사이트가 우리 앱에 요청을 보내도록 유도했을 때 쿠키가 딸려가는 것을 줄여줍니다. 이 이야기는 커리큘럼의 보안 시간에서 제대로 다뤄요.
💡 한 줄 정리
세션 쿠키는 번호표와 서명의 짝이고, 서버만 아는 열쇠가 있어야 그 짝을 만들 수 있습니다.
🙋 학생 질문 — "서명이 있으면 쿠키 값만 보고 누구인지 알 수 있는 건가요?"
우리가 쓰는 방식에서는 아닙니다. 앞쪽 토큰은 그냥 번호표라서 이름이 안 들어 있어요. 서버가 그 번호로 저장소를 뒤져서 누구인지 찾습니다.
이름과 권한을 쿠키 안에 직접 넣고 서명하면 서버가 저장소를 안 뒤져도 되는 방식도 있어요. 대신 그 안에 든 정보는 브라우저가 다 볼 수 있고, 한 번 발급하면 만료 전까지 취소하기가 어려워요.
번호표 방식은 반대입니다. 저장소를 봐야 하니 조회가 한 번 더 들지만, 세션 하나를 지우면 즉시 로그아웃시킬 수 있어요.
둘 중 무엇을 고를지는 G-3에서 나란히 놓고 정합니다. 지금은 우리가 번호표 방식을 쓰고 있다는 것만 기억해두세요.
Step 5: "기본 시크릿의 값"
서명을 만들려면 열쇠가 있어야 한다고 했죠. 그 열쇠를 우리가 준 적이 없습니다. 그런데 지금까지 잘 돌아갔어요. 왜일까요.
라이브러리가 기본값을 갖고 있기 때문입니다. 그 값을 찾아봤어요.
better-auth-secret-12345678901234567890
이게 전부인데, 이 문자열은 여러분이 설치한 패키지 안에 그대로 들어 있어서 누구나 아는 값입니다.
누구나 아는 열쇠로 서명하면 어떻게 될까요. 누구나 서명을 만들 수 있습니다. Step 4에서 막았던 위조가 다시 뚫려요.
라이브러리는 막으려고 합니다
라이브러리 코드를 열어보면 이렇게 되어 있습니다.
if (isDefaultSecret && isProduction)
throw new BetterAuthError("You are using the default secret. ...")
기본 열쇠를 쓰면서 프로덕션이면 던집니다. 개발 중에는 안 던져요. 그래서 지금까지 우리가 못 느꼈던 겁니다.
그런데 빌드는 통과합니다
프로덕션으로 빌드해봤습니다.
next build → 종료 코드 0
통과했어요. 그리고 배포한 다음 열어보면 이렇게 됩니다.
GET / → 200 화면이 다 뜬다
GET /explore → 200
GET /jaehoon → 200
GET /api/auth/ok → 500
POST /api/auth/sign-in/... → 500 로그인만 죽는다
모든 화면이 멀쩡한데 로그인만 안 됩니다. 빌드가 통과했으니 배포까지 갔고, 사용자가 로그인을 눌러야 드러나요. D-6에서 겪었던 "통과했는데 틀린 것"과 같은 종류입니다.
⚠️ 우리가 쓴 한 줄이 경고를 삼켰습니다
여기서 재미있는 것을 발견했어요. 라이브러리는 사실 빌드할 때도 알려주려고 합니다. 그런데 우리 빌드 출력에는 그 메시지가 없었어요.
Step 3에서 계정을 심는 코드를 다시 보세요.
// apps/web-next/lib/auth.ts
auth.api.signUpEmail({ /* ... */ }).catch(() => undefined)
마지막의 .catch(() => undefined)요. 이미 있는 계정을 또 심으려 할 때 나는 오류를 무시하려고 붙인 건데, 이 한 줄이 열쇠 경고까지 같이 삼켰어요.
확인해봤습니다.
.catch 있음 → 빌드 출력에 BetterAuthError 0건
.catch 없음 → 빌드 출력에 BetterAuthError 1건
두 경우 다 빌드 종료 코드는 0입니다. 삼키지 않았어도 빌드는 통과했겠지만, 화면에 한 번은 보였을 기회를 우리가 없앤 셈입니다.
넓게 잡은 catch가 무엇을 삼키는지는 이렇게 나중에야 드러납니다.
열쇠를 줍니다
openssl rand -base64 32
이 명령이 아무나 못 맞출 값을 만들어줍니다. 그 값을 .env.local에 넣어요.
BETTER_AUTH_SECRET=여기에-방금-만든-값
BETTER_AUTH_URL=http://localhost:3000
두 번째 줄도 함께 넣습니다. 이게 없으면 서버가 시작할 때 자기 주소를 모른다고 경고를 내요.
이제 다시 재보면 이렇습니다.
GET /api/auth/ok → 200
POST /api/auth/sign-in/... → 200
서버 로그의 오류 → 0건
🌟 이 열쇠는 브라우저로 안 갑니다
D-10에서 환경 변수 이야기를 했었죠. 이름 앞에 NEXT_PUBLIC_이 붙으면 브라우저가 받아가는 파일에 그 값이 그대로 박힌다고요.
BETTER_AUTH_SECRET에는 그게 안 붙어 있어서 서버에서만 읽혀요. 여기에 NEXT_PUBLIC_을 붙이면 서명 열쇠를 전 세계에 공개하는 셈이 되고, .env.local 파일 자체도 저장소에 안 올라갑니다. .gitignore에 이미 들어 있어요. 대신 무엇이 필요한지는 알려줘야 하니 예시 파일을 하나 남겨둡니다.
// apps/web-next/.env.example
# 세션 쿠키에 붙는 서명을 만드는 열쇠. 서버만 알아야 한다.
# 이 파일을 .env.local 로 복사한 뒤 아래 값을 직접 만든 것으로 바꾼다.
# openssl rand -base64 32
BETTER_AUTH_SECRET=여기에-직접-만든-값을-넣으세요
값은 안 들어 있고 이름만 들어 있습니다. 새로 온 사람이 무엇을 채워야 하는지 알 수 있고, 열쇠는 새지 않아요.
💡 한 줄 정리
서명 열쇠의 기본값은 누구나 아는 문자열이고, 그것을 안 바꾸면 빌드는 통과하지만 배포된 앱에서 로그인만 죽습니다.
🙋 학생 질문 — "그럼 열쇠를 바꾸면 이미 로그인한 사람들은 어떻게 되나요?"
전부 로그아웃됩니다.
생각해보면 당연해요. 기존 쿠키에 붙은 서명은 옛 열쇠로 만든 것이고, 서버는 이제 새 열쇠로 검사하니까 안 맞습니다. Step 4의 "서명만 위조"와 서버 입장에서는 똑같은 상황이라서, 운영 중인 서비스에서 열쇠를 바꾸는 것은 전체 로그아웃을 각오하고 하는 일입니다. 보통은 열쇠가 샜을 때 일부러 그렇게 해요. 샌 열쇠로 만들어진 세션을 전부 무효로 만들어야 하니까요.
한 가지 더요. 열쇠를 환경 변수로 두면 서버가 여러 대일 때 전부 같은 값을 줘야 합니다. 한 대에서 로그인했는데 다른 대가 다른 열쇠를 갖고 있으면, 요청이 어느 서버로 가느냐에 따라 로그인이 됐다 안 됐다 해요. C-10에서 실시간 이야기를 하면서 만났던 그 문제와 결이 비슷합니다.
Step 6: "화면을 진짜 로그인 폼으로"
마지막으로 화면을 바꿉니다.
폼을 만듭니다
두 개의 버튼을 아이디와 비밀번호 칸으로 바꿉니다.
// apps/web-next/app/components/SignInFields.tsx
'use client';
const initial: SignInState = { message: null };
export function SignInFields() {
const [state, formAction] = useActionState(signIn, initial);
return (
<form action={formAction} className="ml-2 flex items-center gap-2">
<input name="username" aria-label="아이디" placeholder="아이디" autoComplete="username" />
<input name="password" type="password" aria-label="비밀번호" autoComplete="current-password" />
<SubmitButton pendingLabel="확인 중…">로그인</SubmitButton>
{state.message !== null && (
<p aria-live="polite" className="text-black/60">
{state.message}
</p>
)}
</form>
);
}
D-7에서 배운 useActionState와 D-8의 SubmitButton을 그대로 씁니다. 새로 배우는 것은 없어요.
받는 쪽은 이렇습니다.
// apps/web-next/app/actions/session.ts
export async function signIn(previous: SignInState, formData: FormData): Promise<SignInState> {
const username = formData.get('username');
const password = formData.get('password');
if (typeof username !== 'string' || typeof password !== 'string') {
return { message: '아이디와 비밀번호를 입력해주세요' };
}
try {
// 성공하면 서명된 세션 쿠키가 응답에 실린다.
await auth.api.signInUsername({ body: { username, password } });
} catch (error) {
// 아이디가 없을 때와 비밀번호가 틀릴 때가 같은 답이다.
// 어느 쪽인지 알려주면 "이 아이디는 있다" 는 것을 알려주는 셈이 된다.
if (error instanceof APIError) {
return { message: '아이디 또는 비밀번호가 올바르지 않아요' };
}
throw error;
}
return { message: null };
}
비밀번호는 이 함수 안에서만 다뤄집니다. 몸통이 서버에서만 도니까요.
액션이 심은 쿠키가 응답에 실리려면 설정에 한 줄이 더 필요합니다.
// apps/web-next/lib/auth.ts
plugins: [
// 우리 앱은 이메일이 아니라 @jaehoon 같은 아이디로 사람을 부른다.
username(),
// 액션이 심은 쿠키를 응답에 실어준다. 반드시 마지막에 둔다.
nextCookies(),
],
🌟 이 폼은 자바스크립트 없이도 제출됩니다
렌더된 결과를 보면 평범한 HTML 폼이에요.
<form action="" encType="multipart/form-data" method="POST">
그런데 같은 화면의 좋아요 버튼은 이렇게 되어 있습니다.
<form action="javascript:throw new Error('React form unexpectedly submitted.')">
D-8에서 봤던 그 모습이죠. 낙관적 업데이트를 붙이려고 액션을 클라이언트 함수로 감쌌기 때문에 자바스크립트 전용이 된 겁니다.
한 화면에 두 형태가 나란히 있어요. 서버 함수를 useActionState에 그대로 건네면 평범한 폼으로 남고, 클라이언트 함수로 감싸면 자바스크립트가 있어야 동작합니다. 로그인은 화면이 뜨자마자 누를 수 있어야 하니 앞쪽이 맞습니다.
화면이 세션을 읽습니다
// apps/web-next/app/components/SignInForm.tsx
// 지시어가 없다. 서버 컴포넌트다 — 세션을 서버에서 직접 확인한다.
export async function SignInForm() {
const session = await auth.api.getSession({ headers: await headers() });
if (session !== null) {
return (
<form action={signOut} className="ml-2 flex items-center gap-2">
<span className="text-black/60">@{session.user.username}</span>
<button type="submit">나가기</button>
</form>
);
}
return <SignInFields />;
}
⚠️ 여기서 이상한 일이 벌어졌습니다
이 상태로 로그인을 해봤어요. 응답은 200이고 쿠키도 왔습니다. 그런데 머리말은 여전히 로그인 폼이었어요.
주소로 직접 물어보면 세션이 있다고 나옵니다.
GET /api/auth/get-session → 세션이 돌아온다
화면의 SignInForm 안에서 → null
같은 요청, 같은 쿠키인데 답이 다릅니다. 쿠키가 화면 쪽까지 잘 도착하는지부터 찍어봤어요. 잘 도착하고 있어서, 저장소를 의심했습니다. 설정 파일에 표식을 하나 심어서, 이 파일이 몇 번 만들어지는지 찍어봤어요.
lib/auth.ts 인스턴스 ebra71
lib/auth.ts 인스턴스 aykips
lib/auth.ts 인스턴스 3yujz3
세 개입니다. Next는 이 파일을 한 번만 읽지 않아요. 화면이 쓰는 것과 주소가 쓰는 것이 따로 만들어집니다. 우리 저장소는 파일 안의 변수라서, 만들어질 때마다 빈 저장소가 새로 하나씩 생긴 거예요.
주소 쪽에서 로그인해서 만든 세션은 주소 쪽 저장소에 있고, 화면 쪽 저장소는 그것을 모릅니다.
저장소를 한 곳에 매답니다
// apps/web-next/lib/auth.ts
// globalThis 에 매다는 이유: Next 는 이 파일을 한 번만 읽지 않는다.
// 화면이 쓰는 것과 주소가 쓰는 것이 따로 만들어져서, 그냥 두면 저장소가 둘이 된다.
// 그러면 로그인은 됐는데 화면은 모르는 상태가 된다.
const store = globalThis as typeof globalThis & { __authDb?: AuthDb };
type AuthDb = { user: unknown[]; session: unknown[]; account: unknown[]; verification: unknown[] };
const db = (store.__authDb ??= { user: [], session: [], account: [], verification: [] });
globalThis는 어디서 접근하든 같은 하나입니다. 여기에 매달아두면 파일이 몇 번 만들어져도 저장소는 하나예요. ??=는 이미 있으면 그대로 쓰고 없을 때만 만듭니다.
되돌려서도 확인했습니다. 이 줄을 빼고 프로덕션으로 빌드해서 띄우면 머리말이 다시 로그인 폼으로 돌아가요. 개발 서버만의 문제가 아닙니다.
신원을 얻는 함수도 바꿉니다
D-8에서 신원을 얻는 곳을 한 곳으로 모아뒀죠. 그 안쪽만 바꿉니다.
// apps/web-next/lib/session.ts
export async function currentUser(): Promise<string | null> {
const session = await auth.api.getSession({ headers: await headers() });
return session?.user.username ?? null;
}
app/actions/follow.ts와 app/actions/like.ts는 한 글자도 안 고쳤습니다. D-8에서 이렇게 될 거라고 말씀드렸던 것이 그대로 됐어요.
문지기도 한 줄만 바꿉니다.
// apps/web-next/proxy.ts
// 세션 쿠키가 있는지만 본다 — 진짜인지는 화면과 액션이 각자 확인한다.
if (getSessionCookie(request) === null) {
return NextResponse.redirect(new URL('/', request.url));
}
여기서 세션이 진짜인지까지 확인하지 않는 이유는 다음 시간에 제대로 다룹니다. D-8에서 우리가 속도 때문에 내렸던 결정과 이어져요.
오늘의 결과
전부 바꾼 다음 다시 재봤습니다.
로그아웃 상태 머리말 아이디·비밀번호 칸과 로그인 버튼
로그인 뒤 머리말 @jaehoon 과 나가기
/jaehoon (세션 있음) → 200
/zzzzz (세션 있음) → 404
me=jaehoon → 307
me=nobody → 307
(쿠키 없음) → 307
아래 세 줄이 오늘의 답입니다. 오프닝에서 통과했던 위조가 전부 막혔어요. /zzzzz가 여전히 404인 것도 함께 봐주세요. D-7에서 첫 바이트 405ms 를 내주고 되찾았던 그 404입니다. 인증을 갈아 끼우면서도 안 잃었어요.
팔로우도 확인했습니다. 새 세션으로 민지를 팔로우하니 팔로워가 8500에서 8501로 올라갔습니다. 액션 코드는 안 고쳤는데도요.
💡 한 줄 정리
로그인 폼과 세션이 이어졌고, 신원을 얻는 함수 하나를 바꾸자 액션들이 저절로 따라왔습니다.
🙋 학생 질문 — "저장소를 globalThis에 매다는 게 좀 이상한데, 원래 이렇게 하나요?"
좋은 감각입니다. 전역에 무언가를 매다는 것은 보통 피하는 방식인데, 이 상황은 좀 특별합니다. 문제가 "우리 코드가 전역을 쓰고 싶어서"가 아니라 "모듈이 여러 번 만들어지는데 그 안에 상태를 들고 있어서" 거든요. 데이터베이스 연결을 다루는 라이브러리들도 Next에서 같은 문제를 만나고, 공식 안내가 똑같이 globalThis를 씁니다.
더 중요한 것은 이게 메모리에 담기 때문에 생긴 문제라는 점이에요. 진짜 데이터베이스를 쓰면 저장소가 앱 바깥에 있으니 모듈이 몇 번 만들어지든 상관이 없습니다. 우리가 강의라서 메모리를 골랐고, 그 대가로 이 줄이 필요해진 거라서 실무 코드에서 이 줄을 보면 이렇게 읽으시면 됩니다. "여기 앱 메모리에 상태를 들고 있구나" 라고요.
마무리
오늘은 쿠키 하나를 손으로 만들어서 남이 되어보는 것으로 시작했고, 그게 왜 됐는지 무엇을 채워야 안 되는지를 하나씩 붙였어요.
Step 1 위조가 그냥 된다 무엇이 없는지 확인
Step 2 인증 서버를 앱 안에 세운다
Step 3 비밀번호를 진짜로 묻는다 없던 것 첫 번째
Step 4 쿠키에 서명이 붙는다 없던 것 두 번째
Step 5 그 서명의 열쇠를 우리 것으로
Step 6 화면과 신원을 세션에 잇는다
오늘 배운 핵심 세 가지
💡 하나 — 신원은 값이 아니라 증거입니다. me=jaehoon은 누구나 적을 수 있는 글자였고, 서명된 세션 쿠키는 서버만 아는 열쇠가 있어야 만들 수 있는 짝입니다. 절반이 진짜여도 안 통한다는 것을 네 가지 위조로 확인했어요.
💡 둘 — 기본값은 비밀이 아닙니다. 서명 열쇠의 기본값은 패키지 안에 그대로 들어 있는 공개된 문자열이에요. 빌드는 통과하고 화면도 다 뜨는데 로그인만 죽는 모습으로 드러납니다.
💡 셋 — 신원을 얻는 곳이 하나여서 갈아 끼울 수 있었습니다. D-8에서 currentUser()로 모아둔 덕분에 오늘 그 안쪽만 바꿨고, 액션 두 개는 손대지 않았어요.
다음 시간 예고
오늘 로그인은 만들었는데, 아직 이야기가 끝나지 않은 것이 몇 개 남아 있습니다.
문지기부터요. 오늘 우리는 문지기에게 "세션 쿠키가 있는지"만 보게 했어요. Step 4에서 그 쿠키가 진짜인지 아닌지를 그렇게 열심히 확인해놓고, 정작 문지기는 존재만 봅니다. 이상하게 들리시죠. 여기에는 이유가 있고, 그 이유가 D-8에서 우리가 속도 때문에 내렸던 결정과 정확히 이어집니다. 로그인이 막아 세운 사람을 어디로 보낼지도 남았어요. 지금은 /minji를 열려던 사람도 로그인 후에 홈에 남습니다. 원래 가려던 곳으로 보내주는 것이 맞겠죠.
여기까지가 다음 시간입니다. 문 앞에서 누구를 들여보내고 어디로 보낼 것인가요.
그다음 시간에는 신원을 들고 다니는 이야기로 넘어갑니다. Step 4의 🙋 에서 잠깐 미뤄뒀던 것이 거기서 나와요. 세션을 번호표로 들 것이냐, 쿠키 안에 정보를 담을 것이냐요. 둘을 나란히 놓고 고릅니다. 가장 큰 것은 마지막에 남았는데, 우리 앱에는 지금 신원이 두 개 있습니다. 오늘 만든 세션이 하나고, C-6에서 백엔드로부터 받아오던 토큰이 다른 하나예요. 화면을 그리는 서버는 오늘 만든 것을 알고, 인스타그램 데이터를 들고 있는 백엔드는 그것을 모릅니다.
그 둘을 화해시키는 것으로 인증 이야기를 닫습니다.
과제
[구현] 가입 화면 만들기
Step 3에서 계정 둘을 코드로 심으면서 "진짜 서비스라면 가입 화면이 이 일을 한다" 고 했죠. 그 화면을 만들어봅시다.
/signup 주소를 새로 만들고, 아이디·비밀번호를 받아 계정을 만드는 폼을 놓으세요. 계정을 만드는 일은 auth.api.signUpEmail이 합니다. 로그인 액션과 모양이 거의 같아요.
만들고 나면 Step 3에서 표로만 봤던 규칙들이 화면에서 그대로 드러납니다. 두 글자 아이디, 한글 아이디, 짧은 비밀번호를 각각 넣어보고 무엇이 어떻게 거절되는지 확인하고, 하나 더 살펴보세요. /signup은 로그인 안 한 사람이 가야 하는 주소인데, 페이지를 만들자마자 열어보면 아마 화면이 안 나올 거예요.
로그아웃 상태로 한 번, 로그인 상태로 한 번 열어보세요. 두 경우의 응답이 서로 다릅니다. 각각 왜 그렇게 나오는지를 proxy.ts를 읽고 설명해보세요. 로그인 상태에서 나오는 쪽은 D-7에서 우리가 공들여 만든 것이 엉뚱하게 동작한 결과입니다.
고치는 것까지 해보시면 더 좋은데, matcher를 건드릴지 함수 안에서 거를지는 선택이에요. D-8에서 문지기의 검사 순서를 정할 때 했던 이야기를 떠올려보세요.
[구현] 로그인 실패 횟수를 세어 잠그기
같은 아이디로 비밀번호를 여러 번 틀리면 잠시 잠기게 만들어보세요. Better Auth가 이 기능을 갖고 있진 않으니, signIn 액션 안에서 직접 세면 됩니다.
세는 값을 어디에 둘지가 이 과제의 핵심입니다. 컴포넌트 안에 두면 안 되는 이유부터 생각해보세요.
그다음이 재미있는 부분이에요. Step 6에서 우리는 저장소를 파일 안 변수에 뒀다가 그게 여러 개가 되는 일을 겪었는데, 이 횟수도 그렇게 될지 한번 생각해보세요.
추측하지 말고 확인해보세요. 파일 안 변수로 먼저 만들고, 그 변수가 몇 번 만들어지는지 표식을 심어서 찍어보면 됩니다. Step 6에서 제가 했던 방법 그대로요. 결과가 Step 6과 같은지 다른지 보고, 다르다면 무엇이 그 차이를 만드는지 설명해보세요.
잠긴 동안의 메시지도 정해야 해요. "5번 틀려서 잠겼습니다"라고 알려주는 것과 그냥 "아이디 또는 비밀번호가 올바르지 않아요"를 유지하는 것 중 어느 쪽이 나을까요. Step 3에서 같은 답을 준 이유를 떠올려보세요.
[탐구] 기기 두 대로 로그인한 뒤 한쪽만 나가보기
같은 계정으로 두 번 로그인해보세요. 브라우저 두 개를 써도 되고, 요청 도구로 쿠키를 따로 받아도 됩니다.
먼저 두 쪽이 받은 쿠키를 비교해보세요. 같은가요, 다른가요. 그다음 한쪽에서만 로그아웃하고, 두 쪽이 각각 어떻게 되는지 확인하세요.
여기서 두 가지를 알 수 있습니다. 하나는 세션이 계정 단위가 아니라 무엇 단위로 만들어지는가이고, 다른 하나는 로그아웃이 브라우저에서 쿠키를 지우는 일인지 서버에서 무언가를 지우는 일인지입니다.
⚠️ 요청 도구로 로그아웃을 직접 불러보면 아마 막힐 거예요. 그때 돌아오는 상태 코드를 보고, 무엇을 확인해서 막은 것인지 찾아보세요. 브라우저에서는 안 막히는데 도구에서만 막히는 이유가 답입니다.
[탐구] 열쇠를 바꾸는 실험이 왜 아무것도 증명하지 못하는지 밝히기
로그인한 상태에서 .env.local의 BETTER_AUTH_SECRET을 다른 값으로 바꾸고 서버를 다시 띄우면, 예상대로 로그아웃됩니다. "역시 서명이 안 맞아서 그렇구나" 하고 넘어가기 쉬운 결과지만, 이 실험은 아무것도 증명하지 못합니다.
확인해보세요. 열쇠를 그대로 둔 채 서버만 다시 띄워도 결과가 같습니다. 왜 그런지 설명하고, 이 실험에서 한 번에 두 가지가 바뀌었다는 것을 짚은 뒤, 열쇠 때문이라는 것을 진짜로 보이려면 무엇이 달라져야 할지 설계해보세요. 우리 지금 설정으로 가능한 일인지도 함께 판단해보시고요.
생각해볼 주제
1. 왜 "아이디가 없습니다"라고 알려주면 안 될까요
Step 3에서 비밀번호가 틀렸을 때와 아이디가 없을 때가 같은 메시지로 돌아온다고 했습니다. 사용자 입장에서는 불친절해 보여요. 오타를 냈는지 비밀번호를 잊었는지 알려주면 훨씬 편할 텐데, 우리 앱의 다른 곳을 보면 이미 아이디의 존재 여부를 알려주고 있습니다. /jaehoon은 200이고 /zzzzz는 404 니까요. 프로필 주소만 봐도 그 사람이 있는지 알 수 있어요.
그렇다면 로그인 창에서만 감추는 것이 의미가 있을까요. 이 앱에서는 어떻게 정하는 것이 맞을까요.
2. 강의용 씨앗 계정은 실무에서 어떻게 바뀌어야 할까요
우리는 서버가 켜질 때 계정 두 개를 코드로 심었습니다. 비밀번호도 코드 안에 그대로 적혀 있고요.
실무에서 이런 코드가 배포본에 들어가면 어떤 일이 벌어질지, 그리고 초기 관리자 계정처럼 처음 한 번은 누군가 만들어야 하는 계정은 실제로 어떻게 만들지 생각해보세요.
Step 5에서 열쇠를 환경 변수로 뺐던 것과 같은 방법을 쓸 수 있을지, 아니면 다른 방법이 필요할지 생각해보세요.
3. 신원이 두 개인 앱을 어떻게 정리할까요
다음 시간 예고에서 말씀드린 것을 미리 생각해봅시다.
우리 앱에는 지금 신원이 두 개 있습니다. Next 서버가 아는 세션과, 백엔드가 발급하던 토큰이요. C-6에서 만든 SPA는 후자를 쓰고, 오늘 만든 Next 앱은 전자를 씁니다.
두 개를 하나로 만드는 방법이 여럿 있습니다. Next 서버가 세션을 확인한 뒤 백엔드에게 대신 물어봐 주는 방법도 있고, 백엔드를 신원의 주인으로 두고 Next 서버는 그냥 옮겨주기만 하는 방법도 있어요.
각각 무엇을 얻고 무엇을 잃는지, 그리고 브라우저가 백엔드를 직접 부르는 화면(우리 SPA)이 함께 있을 때는 어느 쪽이 나을지 생각해보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 가입 화면 만들기 (25점)
채점 포인트
| 항목 | 배점 | 확인할 것 |
|---|---|---|
| 가입 액션과 폼을 만들었는가 | 8 | auth.api.signUpEmail을 서버 액션에서 부르고, 오류를 화면에 되돌린다 |
| 거절 규칙 셋을 화면에서 확인했는가 | 6 | 짧은 아이디 · 한글 아이디 · 짧은 비밀번호를 각각 넣어보고 결과를 적었다 |
/signup이 두 경우 모두 안 열린다는 것을 봤는가 |
6 | 로그아웃 307 · 로그인 404 를 각각 확인했다 |
404 의 원인을 proxy.ts로 설명했는가 |
5 | 문지기가 주소를 사람 이름으로 보고 백엔드에 물었다는 것까지 |
풀이 예시
액션은 로그인과 모양이 거의 같습니다.
// apps/web-next/app/actions/signup.ts
'use server';
import { APIError } from 'better-auth/api';
import { auth } from '@/lib/auth';
export type SignUpState = { message: string | null };
export async function signUp(previous: SignUpState, formData: FormData): Promise<SignUpState> {
const username = formData.get('username');
const password = formData.get('password');
if (typeof username !== 'string' || typeof password !== 'string') {
return { message: '아이디와 비밀번호를 입력해주세요' };
}
try {
await auth.api.signUpEmail({
body: {
email: `${username}@example.com`,
password,
name: username,
username,
},
});
} catch (error) {
// 가입은 로그인과 달리 사유를 알려주는 편이 낫다.
// 이미 쓰는 아이디인지, 규칙에 안 맞는지를 모르면 고칠 수가 없다.
if (error instanceof APIError) {
return { message: error.body?.message ?? '가입하지 못했어요' };
}
throw error;
}
return { message: null };
}
화면에서 확인되는 거절 규칙은 이렇습니다.
아이디 aa → Username is too short
아이디 재훈이 → Username is invalid (한글은 기본 검사가 안 받는다)
비밀번호 1234 → Password too short
재훈처럼 두 글자 한글을 넣으면 too short가 먼저 나옵니다. 길이 검사가 먼저 돌기 때문이에요. 세 글자 이상으로 만들어야 비로소 invalid가 나옵니다.
두 경우가 다르게 막힙니다
/signup 로그아웃 상태 → 307 홈으로 되돌려보낸다
/signup 로그인 상태 → 404 없는 주소라고 한다
첫 줄은 예상하기 쉽습니다. 문지기가 세션 쿠키가 없으면 홈으로 보내니까요. 가입하려는 사람은 당연히 세션이 없죠.
두 번째 줄이 진짜 발견입니다. 로그인했는데 왜 404 일까요.
proxy.ts를 다시 읽어보면 답이 나옵니다.
const username = request.nextUrl.pathname.slice(1);
const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`);
if (response.status === 404) {
return NextResponse.rewrite(new URL('/_not-found', request.url), { status: 404 });
}
문지기는 주소 앞의 빗금을 떼고 남은 것을 사람 이름으로 취급합니다. /signup이 들어오면 signup이라는 사람이 있는지 백엔드에 물어보고, 그런 사람이 없으니 404로 바꿔버리는 겁니다.
D-7에서 첫 바이트 405ms 를 내주고 되찾았던 404 회복이, 프로필이 아닌 주소를 만나자 엉뚱하게 동작한 거예요.
고치는 두 가지 방법
// 방법 A — matcher 에서 아예 제외한다
export const config = {
matcher: ['/((?!api|_next|favicon.ico|explore|signup).+)'],
};
// 방법 B — 함수 안에서 거른다
const PUBLIC_PATHS = ['/signup'];
if (PUBLIC_PATHS.includes(request.nextUrl.pathname)) {
return NextResponse.next();
}
둘 다 답이지만, 성질은 서로 달라요.
방법 A는 문지기가 아예 안 돌아서 가장 쌉니다. 대신 주소가 늘어날 때마다 정규식이 길어지고, 그 정규식은 읽기가 점점 어려워져요.
방법 B는 문지기가 한 번 돌긴 하지만 목록이 눈에 보입니다. D-8에서 우리가 비-GET 요청을 함수 안에서 걸렀던 것과 같은 선택이에요. 그때도 matcher로는 방법을 못 가려서 코드에서 걸렀죠.
자주 나오는 실수
가입에 성공한 다음 아무 일도 안 일어나서 당황하는 경우가 많습니다. signUpEmail은 계정을 만들고 세션까지 만들어주지만, 화면을 옮겨주지는 않아요. 옮기려면 redirect('/')를 직접 불러야 하고, 라이브러리가 주는 메시지는 영어입니다. 실제로 돌려보면 이렇게 나와요.
{"message":"Username is already taken. Please try another."}
한국어 화면에 이 문장이 그대로 뜨면 곤란하죠. 코드를 보고 우리말로 갈아주거나, 최소한 자주 나오는 것들만이라도 골라서 바꿔줘야 합니다. E-6에서 들여온 컴포넌트의 "Close"를 "닫기"로 고쳤던 것과 같은 이야기예요.
또 하나는 이메일입니다. 우리 앱에는 이메일 칸이 없어서 아이디로 만들어 붙였는데(${username}@example.com), 이러면 아이디가 같으면 이메일도 같아집니다. 실무라면 이메일을 따로 받거나, 이메일 대신 아이디를 열쇠로 쓰도록 설정을 바꿔야 해요.
💡 튜터의 한마디
이 과제의 진짜 수확은 가입 폼이 아니라 404 쪽입니다.
D-7에서 우리는 404 회복을 만들면서 첫 바이트 2ms 를 405ms 로 내줬어요. 꽤 비싼 값을 치렀고, 그만한 값어치가 있다고 판단했습니다. 그런데 그 코드는 "빗금 뒤에 오는 것은 사람 이름이다"라는 가정 위에 서 있었어요.
그 가정은 그때 참이었습니다. 프로필 말고는 그 아래 주소가 없었으니까요. 오늘 /signup을 만드는 순간 거짓이 됐고, 아무도 안 알려줬습니다. 빌드도 통과하고 타입도 통과해요.
앱이 자라면 예전 결정의 전제가 조용히 무너집니다. 그때 그 결정이 틀렸던 게 아니라 세상이 바뀐 것이라서, 새 주소를 하나 만들 때마다 "이 주소가 기존 규칙 어디에 걸리나"를 한 번씩 물어보는 습관이 필요합니다.
🎯 [과제 2 예시답안] 로그인 실패 횟수를 세어 잠그기 (25점)
채점 포인트
| 항목 | 배점 | 확인할 것 |
|---|---|---|
| 세는 값을 컴포넌트 밖에 뒀는가 | 6 | 렌더마다 초기화되면 안 된다는 것을 이해했다 |
| 아이디별로 셌는가 | 5 | 한 사람이 틀렸다고 다른 사람이 잠기면 안 된다 |
| 표식을 심어 직접 확인했는가 | 8 | 추측이 아니라 찍어본 결과로 판단했다 |
| Step 6과 결과가 다른 이유를 설명했는가 | 6 | 누가 그 파일을 가져다 쓰는지가 갈랐다는 것 |
풀이 예시
// apps/web-next/app/actions/session.ts
const MAX_TRIES = 5;
const LOCK_MS = 60_000;
type Attempt = { count: number; lockedUntil: number };
const attempts = new Map<string, Attempt>();
function recordFailure(username: string) {
const now = Date.now();
const found = attempts.get(username) ?? { count: 0, lockedUntil: 0 };
const next = { count: found.count + 1, lockedUntil: found.lockedUntil };
if (next.count >= MAX_TRIES) {
next.lockedUntil = now + LOCK_MS;
next.count = 0;
}
attempts.set(username, next);
}
function isLocked(username: string) {
const found = attempts.get(username);
return found !== undefined && found.lockedUntil > Date.now();
}
signIn 안에서는 잠겼는지 먼저 보고, 실패하면 세고, 성공하면 지웁니다.
if (isLocked(username)) {
return { message: '아이디 또는 비밀번호가 올바르지 않아요' };
}
try {
await auth.api.signInUsername({ body: { username, password } });
} catch (error) {
if (error instanceof APIError) {
recordFailure(username);
return { message: '아이디 또는 비밀번호가 올바르지 않아요' };
}
throw error;
}
attempts.delete(username);
표식을 심어 확인한 결과
이 과제의 핵심은 "파일 안 변수가 몇 개가 되는가" 였습니다. Step 6에서 저장소가 세 개가 됐으니 여기도 그럴 것 같죠. 찍어봤습니다.
액션 모듈 umrq5t · 이 모듈에서 센 횟수 1
액션 모듈 umrq5t · 이 모듈에서 센 횟수 2
액션 모듈 umrq5t · 이 모듈에서 센 횟수 3
액션 모듈 umrq5t · 이 모듈에서 센 횟수 4
하나입니다. 표식이 내내 같고 횟수도 제대로 쌓여요. Step 6과 결과가 다릅니다.
왜 다를까요
무엇이 그 파일을 가져다 쓰느냐가 갈랐습니다.
lib/auth.ts 주소를 받는 파일이 쓴다 → 갈린다
화면 컴포넌트도 쓴다
actions/session.ts 폼에서 부르는 액션이다 → 안 갈린다
한쪽에서만 쓰인다
lib/auth.ts는 app/api/auth/[...all]/route.ts와 SignInForm 양쪽이 가져다 씁니다. 이 둘은 서로 다른 묶음으로 만들어져서, 각자 자기 몫의 파일을 갖게 돼요.
액션 파일은 그렇지 않아서, 파일 안 변수로도 셈이 맞았어요.
자주 나오는 실수
가장 흔한 것은 아이디로 안 나누고 하나로 세는 것인데, 그러면 남이 다섯 번 틀리는 것만으로 내 계정이 잠겨서 남을 못 들어오게 만드는 방법이 되어버립니다.
그다음은 잠긴 동안 다른 메시지를 주는 겁니다. "5번 틀려서 1분간 잠겼습니다"라고 알려주면 친절하긴 한데, 그 메시지는 그 아이디가 존재한다는 것을 알려줍니다. 없는 아이디로는 그 메시지가 안 나올 테니까요. Step 3에서 같은 답을 준 이유가 여기서도 그대로 적용됩니다.
💡 튜터의 한마디
"Step 6에서 그랬으니 여기도 그럴 것이다"는 아주 자연스러운 추론이지만, 이번엔 틀렸어요.
제가 이 과제를 처음 쓸 때 힌트에 "여기서도 똑같이 적용됩니다"라고 적었다가, 실제로 찍어보고 지웠습니다. 저도 그렇게 믿고 있었거든요.
바로 앞에서 배운 것일수록 다음 상황에 그대로 갖다 붙이기 쉽습니다. 그게 학습이기도 하고 함정이기도 해요. 갈리는 조건이 무엇이었는지를 같이 기억해두면(여기서는 "누가 그 파일을 쓰는가") 다음번엔 조건부터 확인하게 됩니다.
표식 하나 심고 네 번 눌러보는 데 2분이면 됩니다. 그 2분이 틀린 확신 하나를 막아줘요.
🎯 [과제 3 예시답안] 기기 두 대로 로그인한 뒤 한쪽만 나가보기 (25점)
채점 포인트
| 항목 | 배점 | 확인할 것 |
|---|---|---|
| 두 쿠키가 다르다는 것을 확인했는가 | 5 | 세션이 계정이 아니라 로그인 한 번 단위로 생긴다 |
| 한쪽만 죽는 것을 확인했는가 | 7 | 로그아웃한 쪽만 null, 다른 쪽은 살아 있다 |
| 로그아웃이 서버 쪽 일이라는 것을 설명했는가 | 7 | 쿠키를 지우는 것만으로는 설명이 안 된다 |
| 도구에서 막히는 이유를 찾았는가 | 6 | 403 이 나오고 Origin을 확인한 결과라는 것 |
풀이 예시
같은 계정으로 두 번 로그인해서 쿠키를 각각 받아둡니다.
기기 A 쿠키 yZuwXbrGdM97QSYMydy29BCT...
기기 B 쿠키 emKbgTiIyjwCOP0Jr20GEej1...
→ 다르다
세션은 계정 단위가 아니라 로그인 한 번 단위로 만들어져서, 휴대폰과 노트북에서 같은 계정으로 로그인해도 서로 다른 세션을 갖게 돼요.
한쪽에서만 로그아웃한 결과입니다.
로그아웃 전 A 살아있음 · B 살아있음
A 로 로그아웃
로그아웃 후 A null · B 살아있음
로그아웃은 어느 쪽 일일까요
브라우저에서 쿠키를 지우는 일이라면, 우리가 쿠키 값을 손에 들고 있으니 그 값으로 다시 물어보면 통해야 합니다. 그런데 A는 null이 나와요.
서버가 그 세션을 지웠기 때문입니다. 쿠키에 적힌 번호표가 가리키던 것이 없어졌으니, 번호표를 아무리 들고 있어도 소용이 없어요.
Step 4의 🙋 에서 이야기했던 번호표 방식의 성질이 여기서 드러납니다. 서버가 목록을 들고 있으니 그중 하나를 지우면 그 세션만 즉시 끊을 수 있어요. "다른 기기에서 로그아웃" 같은 기능이 이렇게 만들어집니다.
⚠️ 도구로 부르면 막힙니다
요청 도구로 로그아웃을 불러보면 이렇게 됩니다.
POST /api/auth/sign-out → 403
POST /api/auth/sign-out (Origin: 우리 주소) → 200
POST /api/auth/sign-out (Origin: 남의 주소) → 403
Origin을 확인하고 있었습니다. 이 값은 요청을 보낸 쪽이 어느 사이트인지를 브라우저가 붙여주는 것이고, 페이지의 자바스크립트가 마음대로 못 바꿔요.
왜 이걸 확인할까요. 남의 사이트가 우리 앱으로 요청을 보내도록 유도할 수 있기 때문입니다. 그 요청에도 우리 쿠키는 딸려갈 수 있어요. 그때 Origin이 남의 주소로 찍히니 거기서 걸러내는 겁니다.
브라우저에서 우리 화면의 버튼을 누를 때는 Origin이 우리 주소라 안 막힙니다. 도구로 부를 때만 걸린 거예요.
자주 나오는 실수
로그아웃을 부를 때 본문 없이 Content-Type: application/json만 붙이면 이런 답이 옵니다.
{"message":"Invalid JSON in request body","code":"BAD_REQUEST"} 400
400을 보고 "로그아웃이 안 되는구나"로 넘어가면 안 됩니다. 아예 시작도 안 한 거예요. 본문에 {}라도 실어 보내야 합니다.
이 실수는 확인 순서를 지키면 걸러집니다. 로그아웃 전에 두 쪽이 다 살아 있는지부터 보고, 로그아웃 후에 다시 보는 거예요. 그렇게 하면 "아무것도 안 변했네"라는 것이 바로 눈에 띕니다.
💡 튜터의 한마디
이 과제에는 제가 실제로 밟은 함정이 그대로 들어 있습니다.
처음 이 실험을 할 때 저는 로그아웃을 부르고 나서 A와 B를 확인했어요. 둘 다 살아 있어서 "로그아웃이 서버 세션을 안 지우나 보다"라고 생각할 뻔했는데, 로그아웃 응답을 안 봤던 겁니다. 봤더니 400 이었어요. 그다음엔 403 이었고요. 두 번 다 로그아웃은 시작도 안 했던 거예요.
무언가 안 바뀌었을 때, 그 일이 실제로 일어나긴 했는지부터 확인하세요. 결과만 보고 원인을 지어내면 없는 성질을 발견하게 됩니다. 이 과목에서 계속 이야기한 "대조군부터"와 같은 이야기예요.
🎯 [과제 4 예시답안] 열쇠를 바꾸는 실험이 왜 아무것도 증명하지 못하는지 밝히기 (25점)
채점 포인트
| 항목 | 배점 | 확인할 것 |
|---|---|---|
| 열쇠를 그대로 두고도 재현했는가 | 8 | 같은 열쇠로 재시작해도 결과가 같다는 것을 직접 봤다 |
| 한 번에 두 가지가 바뀌었다는 것을 짚었는가 | 8 | 열쇠와 저장소가 함께 사라졌다 |
| 격리 조건을 설계했는가 | 5 | 저장소가 프로세스보다 오래 살아야 한다 |
| 지금 설정으로 되는지 판단했는가 | 4 | 메모리 저장소로는 불가능하다는 결론 |
풀이 예시
먼저 예상대로의 결과입니다.
로그인 → 세션 살아있음
.env.local 의 열쇠를 바꾸고 서버 재시작
같은 쿠키로 물어보면 → null
여기서 멈추면 "서명이 안 맞아서 거절됐다"로 읽힙니다. 그런데 열쇠를 안 바꾸고 서버만 다시 띄워봤습니다.
로그인 → 세션 살아있음
열쇠 그대로 두고 서버만 재시작
같은 쿠키로 물어보면 → null
결과가 같습니다.
한 번에 두 가지가 바뀌었습니다
서버를 다시 띄우면 두 가지가 동시에 벌어집니다.
바뀐 것 1 서명 열쇠가 달라진다 (우리가 의도한 것)
바뀐 것 2 메모리 저장소가 비워진다 (재시작이라 딸려온 것)
두 번째가 문제예요. 우리 저장소는 서버 프로세스의 메모리에 있으니, 프로세스가 죽으면 세션 목록이 통째로 사라집니다. 그러면 서명이 맞든 안 맞든 그 번호표가 가리킬 것이 없어요.
이 실험에서 null이 나온 이유가 둘 중 어느 것인지는 우리가 알 수 없습니다. 둘 다 바뀌었으니 어느 쪽 탓인지 가릴 수가 없어요.
격리하려면 무엇이 필요할까요
한 가지만 바꾸고 나머지를 붙들어둬야 합니다.
필요한 것 세션 목록이 서버 재시작을 견디고 살아남을 것
그러면 재시작 후에도 그 세션이 목록에 그대로 있으니
null 이 나온다면 원인은 서명뿐이다
진짜 데이터베이스를 쓰면 됩니다. 저장소가 앱 바깥에 있으니 프로세스가 죽어도 목록이 남아요. 그 상태에서 열쇠만 바꾸면 원인이 하나로 좁혀집니다.
우리 지금 설정으로는 불가능합니다. 메모리에 담기로 한 순간 이 실험은 설계가 안 돼요.
그럼 서명이 검사된다는 것은 어떻게 알까요
이미 Step 4에서 확인했습니다. 서버를 재시작하지 않고, 저장소를 그대로 둔 채, 쿠키만 손대는 실험이었어요.
토큰 진짜 + 서명만 위조 → null
서명 진짜 + 토큰만 바꿈 → null
같은 서버, 같은 저장소, 같은 열쇠입니다. 바뀐 것은 쿠키 하나뿐이라서, 이 실험은 원인을 가릴 수 있습니다.
자주 나오는 실수
"열쇠를 바꿨더니 로그아웃됐다"를 근거로 삼는 것이 가장 흔합니다. 결과가 예상과 맞으면 확인을 멈추게 되거든요.
예상과 다른 결과가 나왔을 때는 다들 원인을 파는데, 예상대로 나오면 그냥 넘어갑니다. 그런데 예상대로 나온 결과도 엉뚱한 이유로 그럴 수 있어요.
💡 튜터의 한마디
이 과제는 원래 다른 문제였습니다. "열쇠를 바꾸면 무슨 일이 생기는지 확인하고, 저장소가 비워져서인지 다른 이유인지 구분해보세요" 였는데, 답안을 쓰려고 직접 해보니 구분할 수가 없었습니다. 제가 낸 문제에 답이 없었던 것이라, 문제를 바꿨습니다.
실험을 설계할 때 가장 자주 하는 실수가 이겁니다. 한 번에 두 가지를 바꿔놓고 결과를 하나의 원인으로 읽는 것이요. 우리는 이번 시간에도 비슷한 것을 겪었어요. 시크릿을 줬는데도 500이 나와서 한참 헤맸는데, 알고 보니 옛 서버가 안 죽어서 새 서버가 못 뜬 상태였습니다. 코드가 아니라 재는 환경이 틀렸던 거예요.
바꾼 것이 정말 하나인지를 실험 전에 세어보세요. 둘이면 그 실험은 아직 실험이 아닙니다.
🤔 [생각해볼 주제 1] 왜 "아이디가 없습니다"라고 알려주면 안 될까요
문제 상황 요약
로그인 창에서는 아이디가 없는 경우와 비밀번호가 틀린 경우를 같은 메시지로 답합니다. 아이디의 존재 여부를 숨기기 위해서예요.
그런데 같은 앱의 프로필 주소는 그것을 그대로 알려줍니다. /jaehoon은 200이고 /zzzzz는 404 니까요. 한쪽에서 감추는 것을 다른 쪽에서 알려주고 있는 셈입니다.
튜터의 가이드 및 해설
먼저 사실 관계를 정리해봅시다. 우리 앱에서 아이디의 존재는 이미 공개 정보입니다. 인스타그램도 그렇죠. 아이디로 사람을 찾는 것이 서비스의 기능이니까요. 그렇다면 로그인 창에서 감추는 것은 의미가 없을지, 여기서 한 걸음 더 들어가야 합니다.
감추려는 대상이 서비스마다 다릅니다.
인스타그램 같은 서비스에서 아이디는 공개 이름이에요. 감출 이유가 별로 없습니다. 반면 회사 내부 시스템이나 병원 예약 서비스라면 이야기가 달라져요. "이 이메일로 가입된 계정이 있다"는 사실 자체가 정보입니다. 어떤 서비스에서는 그 사람이 그 서비스를 쓴다는 것만으로도 민감할 수 있어서, 판단 기준은 이렇게 잡습니다.
물어볼 것 이 서비스에서 "그 계정이 있다" 는 사실이
그 자체로 알려지면 안 되는 정보인가
인스타 클론 아니다 — 프로필이 이미 공개다
회사 내부 그렇다 — 재직 여부가 새어나간다
우리 앱이라면 로그인 창에서 사유를 나눠 알려줘도 큰 문제는 아닙니다. 다만 저는 그래도 같은 답을 유지하는 쪽을 권해요. 이유는 다릅니다.
아이디가 존재한다는 것과, 그 아이디로 로그인을 시도했을 때 비밀번호만 틀렸다는 것은 다른 정보이기 때문입니다. 후자는 "이 계정은 살아 있고 비밀번호만 맞히면 된다"를 알려줍니다. 공격자에게는 목록을 좁히는 데 쓸모가 있어요.
그리고 이런 세부 설정은 나중에 바꾸기가 어렵습니다. 지금 나눠 알려주다가 나중에 합치면 사용자가 불편해졌다고 느껴요. 처음부터 합쳐두면 그런 일이 없습니다.
대신 사용자 불편은 다른 방법으로 줄일 수 있어요. 비밀번호 찾기를 눈에 잘 띄게 두거나, 아이디 입력 칸에 규칙을 미리 보여주는 방식이요. 정보를 흘리지 않으면서 도와주는 방법은 따로 있습니다.
🎯 면접관을 홀리는 핵심 멘트
"로그인 실패 메시지를 합치는 이유는 계정 존재 여부를 감추기 위해서인데, 저희 서비스는 프로필이 공개라 그 전제가 이미 깨져 있었습니다. 그래서 근거를 바꿔서 판단했어요. 감출 것은 계정의 존재가 아니라 '이 계정은 비밀번호만 맞히면 된다'는 사실이라고요. 그 판단이면 프로필이 공개인 것과 모순되지 않고, 사용자 불편은 비밀번호 찾기 동선을 개선해서 따로 풀었습니다."
🤔 [생각해볼 주제 2] 강의용 씨앗 계정은 실무에서 어떻게 바뀌어야 할까요
문제 상황 요약
우리는 서버가 켜질 때 계정 두 개를 코드로 심었습니다. 비밀번호 hunter22!가 소스에 그대로 적혀 있어요. 실무 코드에 이런 것이 들어가면 안 된다는 것은 알겠는데, 그럼 초기 관리자 계정 같은 것은 실제로 어떻게 만들까요.
튜터의 가이드 및 해설
먼저 우리 코드가 배포됐을 때 무슨 일이 벌어지는지 봅시다.
심어지는 것 jaehoon / hunter22!
minji / hunter22!
누가 아는가 저장소를 볼 수 있는 사람 전부
그 코드가 오픈소스면 전 세계
비밀번호가 알려진 계정이 서비스에 항상 존재하게 됩니다. 게다가 서버를 켤 때마다 다시 심어지니, 관리자가 그 계정을 지워도 재시작하면 되살아나요.
실무에서 쓰는 방법은 크게 셋입니다.
첫째, 환경 변수로 뺍니다. Step 5에서 열쇠에 했던 것과 같아요. 초기 비밀번호를 배포할 때 주입하는 방식입니다. 코드에는 안 남지만 그 값이 배포 설정에는 남으므로, 첫 로그인 때 비밀번호를 반드시 바꾸게 강제하는 것과 짝을 이뤄야 해요.
둘째, 사람이 한 번 실행하는 명령으로 만듭니다. 앱이 켜질 때가 아니라, 배포한 사람이 손으로 한 번 돌리는 방식이에요. 그때 만들어진 비밀번호를 화면에 한 번 보여주고 저장하지 않습니다. 재시작해도 안 되살아나고, 계정이 몇 개 있는지도 명확해요.
셋째, 아예 안 만듭니다. 첫 사용자가 가입하면 그 사람을 관리자로 삼는 방식입니다. 앱을 세우자마자 가입하면 되니 알려진 비밀번호가 존재하는 순간이 없어요. 대신 그 창을 남이 먼저 밟으면 안 되니, 설치 직후에만 열리는 창으로 좁혀둡니다.
우리 강의 코드는 첫 번째와 두 번째의 성질을 일부러 안 쓴 겁니다. 실습이 매번 같은 상태에서 시작해야 하고 메모리 저장소라 재시작하면 어차피 비다 보니, 이 코드에 표시를 남기는 것이 중요합니다. 주석에 "강의용"이라고 적어두는 것만으로는 부족해요. 배포 환경에서는 아예 안 돌게 조건을 걸어두는 편이 낫습니다.
// 배포된 곳에서는 심지 않는다.
const shouldSeed = process.env.NODE_ENV !== 'production';
한 줄이지만 이 줄이 있으면, 나중에 누가 이 코드를 그대로 들고 가도 배포본에는 안 따라갑니다.
🎯 면접관을 홀리는 핵심 멘트
"초기 계정을 코드로 심으면 두 가지가 동시에 문제가 됩니다. 비밀번호가 저장소에 남는 것과, 재시작할 때마다 되살아나서 관리자가 지워도 안 지워진다는 것이요. 그래서 저는 초기 계정을 앱 부팅이 아니라 배포자가 한 번 실행하는 명령으로 옮기고, 만들어진 비밀번호는 그 자리에서 한 번만 보여주고 저장하지 않는 쪽을 씁니다. 되살아나지 않는 게 핵심이에요."
🤔 [생각해볼 주제 3] 신원이 두 개인 앱을 어떻게 정리할까요
문제 상황 요약
우리 앱에는 지금 신원이 두 개 있습니다. 오늘 만든 Next 서버의 세션과, C-6에서 백엔드로부터 받아오던 토큰이요.
화면을 그리는 서버는 세션을 알고, 인스타그램 데이터를 들고 있는 백엔드는 그것을 모릅니다. 백엔드는 아직도 X-Actor 헤더에 적힌 이름을 그대로 믿고 있어요.
튜터의 가이드 및 해설
이 문제는 G-3의 주제지만, 미리 갈래를 잡아두면 훨씬 잘 들립니다.
큰 방향이 둘입니다.
방향 A — 앞 서버가 대신 물어봐 준다. 브라우저는 Next 서버하고만 이야기합니다. Next 서버가 세션을 확인하고, 백엔드에는 자기가 신원을 증명해서 요청을 보내요. 브라우저는 백엔드 토큰을 아예 못 봅니다.
방향 B — 백엔드를 신원의 주인으로 둔다. 로그인도 백엔드가 처리하고, Next 서버는 받은 토큰을 보관했다가 요청에 실어주기만 합니다.
각각 무엇을 얻고 잃을까요.
방향 A 얻는다 토큰이 브라우저에 안 간다
앞 서버가 요청을 다 거치니 한 곳에서 막을 수 있다
잃는다 앞 서버가 반드시 살아 있어야 한다
백엔드를 직접 부르던 화면(우리 SPA)은 이 길을 못 쓴다
방향 B 얻는다 신원이 한 곳에만 있다
SPA 와 Next 앱이 같은 방식을 쓴다
잃는다 앞 서버가 토큰을 들고 있어야 하고 갱신도 챙겨야 한다
백엔드 로그인 규약에 앞 서버가 묶인다
여기서 우리 상황의 특수한 점이 하나 있습니다. 우리에게는 SPA도 함께 있어요. C-6에서 만든 그 앱은 브라우저가 백엔드를 직접 부릅니다.
앱이 하나라면 방향 A가 깔끔합니다. 토큰이 브라우저로 안 가는 것만으로도 걱정거리가 여럿 줄지만, 백엔드를 직접 부르는 앱이 함께 있으면 백엔드는 어차피 자기 인증을 갖고 있어야 해서 신원이 두 벌 유지되는 것은 마찬가지이므로, 저라면 이렇게 물어보겠습니다. "SPA를 계속 유지할 것인가."
계속 간다면 백엔드 인증을 정본으로 두고 Next 쪽을 거기에 맞추는 편이 낫습니다. Next 앱으로 옮겨가는 중이라면 방향 A로 가면서 SPA를 정리하는 것이 맞고요.
기술을 고르는 문제처럼 보이지만 실은 앞으로 어느 앱을 남길 것인가를 정하는 문제입니다.
🎯 면접관을 홀리는 핵심 멘트
"인증을 어디에 둘지는 보안 문제로 보이지만, 저희 경우엔 어느 클라이언트를 남길 것인가가 먼저였습니다. 브라우저가 백엔드를 직접 부르는 앱이 함께 살아 있으면 백엔드는 어차피 자기 인증을 유지해야 해서, 앞 서버가 대신 물어봐 주는 구조로 가도 신원이 두 벌 남거든요. 그래서 구조를 고르기 전에 클라이언트 로드맵을 먼저 확정하고, 거기에 맞춰 정본을 하나 정했습니다."