G-2: 프론트엔드 인증 ② — 문 앞에서
목차 37
안녕하세요, 홍순구 튜터입니다.
지난 시간에 우리는 쿠키에 서명을 붙였습니다. 서버만 아는 열쇠로 서명하고, 위조한 쿠키 세 가지를 전부 거절하는 것까지 눈으로 봤는데, 이상하게도 우리 문지기는 그 서명을 안 봐요.
// apps/web-next/proxy.ts (지난 시간에 만든 상태)
if (getSessionCookie(request) === null) {
return NextResponse.redirect(new URL('/', request.url));
}
쿠키가 있는지만 봅니다. 진짜인지는 안 물어봐요.
서명을 그렇게 공들여 붙여놓고 정작 문을 지키는 쪽은 그걸 안 씁니다. 실수처럼 보이시죠. 저도 처음엔 그랬는데, 이건 실수가 아니라 선택이에요. 그 선택은 D-8에서 우리가 속도 때문에 내렸던 결정과 정확히 같은 이유에서 나왔습니다.
오늘은 그 이유부터 확인하고, 문 앞에서 벌어지는 일들을 하나씩 정리합니다.
💡 오늘 수업의 핵심 — "문지기는 빠르게 걸러내고, 진짜 확인은 들어간 다음에 합니다"
🎯 학습 목표
- 문지기가 왜 쿠키의 존재만 보는지를 직접 재보고, 그 대가로 무엇이 남는지 확인합니다.
- 보호할 주소와 열어둘 주소를 나누고, 새 주소를 만들 때 무엇이 기본값인지 정합니다.
- 막아 세운 사람을 원래 가려던 곳으로 되돌려보내고, 그 주소를 믿어도 되는지 판단합니다.
Step 1: "문지기는 낙관적으로 믿습니다"
말로 하는 대신 실험을 해봅시다. 쿠키 값을 아무 글자로나 채워서 재훈이의 프로필을 열어보겠습니다.
세 가지 경우를 나란히 놓고 재봤어요. 진짜 쿠키, 서명만 망가뜨린 쿠키, 그리고 아무 의미 없는 글자 하나요.
쿠키 값 문지기 화면이 아는 신원
─────────────────────────────────────────────────────
진짜 세션 쿠키 200 통과 재훈
토큰은 맞고 서명만 위조 200 통과 없음
sdfsdfsdf 200 통과 없음
(쿠키 없음) 307 차단 —
맨 아래만 막혔습니다. 위의 셋은 전부 들어가졌어요.
sdfsdfsdf는 우리가 만든 적 없는 값입니다. 서명은커녕 형식도 안 맞아요. 그런데 문지기는 통과시킵니다.
문지기는 무엇을 보고 있었나
getSessionCookie라는 이름이 오해를 부르는데, 이 함수는 세션을 가져오지 않습니다. 쿠키가 붙어 있는지만 확인해요.
지난 시간에 만든 서명 검사는 저장소를 뒤져서 그 토큰이 실제로 있는지 대조하는 일인데, 문지기가 도는 곳은 요청이 막 도착한 순간이라 여기서 저장소를 뒤지면 모든 요청이 그만큼 느려집니다. 이미지 하나, 스타일 파일 하나까지 전부요.
Better Auth 공식 문서도 이 함수를 그렇게 소개하는데, 사용자를 "낙관적으로(optimistically) 되돌려보내기 위한" 용도라면서 바로 다음 문장에서 못을 박아요. 이 검사만 믿고 보안을 맡기는 것은 위험하며, 보호가 필요한 화면과 동작에서는 반드시 서버에서 세션을 다시 확인하라고요.
낙관적이라는 말이 정확합니다. 문지기는 "쿠키를 들고 있으니 아마 로그인한 사람이겠지" 하고 일단 들여보내요.
그럼 위조한 사람이 다 뚫는 걸까요
여기서 한 겹 더 재봤습니다. 위조한 쿠키를 들고 팔로우 버튼을 눌러본 거예요.
위조 쿠키로 팔로우 제출 → 백엔드가 받은 요청 0건
진짜 쿠키로 팔로우 제출 → POST /api/users/minji/follow
막혔지만, 막은 것은 문지기가 아니에요.
// 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;
}
지난 시간에 액션들이 신원을 오직 이 함수로만 얻게 모아뒀죠. 이 함수는 진짜로 확인합니다. 위조 쿠키를 들고 오면 null을 돌려주고, 액션은 거기서 멈춰요.
정리하면 우리 앱에는 검사가 두 겹입니다.
요청 도착
│
├─ 문지기: 쿠키 있나? ── 없으면 여기서 끝
│ (싸다 · 낙관적 · 위조를 못 걸러낸다)
│
└─ 화면과 액션: 이 세션이 진짜 누구 것인가?
(비싸다 · 정확하다 · 여기가 진짜 방어선)
그래도 남은 구멍이 하나 있습니다
액션은 막혔는데 화면 내용은 그대로 보입니다. 위조 쿠키로 연 민지 프로필에는 게시물도, 팔로워 수도, 팔로우 버튼도 다 있었는데, 머리말에만 로그인 폼이 떠 있어요. 앱이 "당신은 로그인 안 했습니다"라고 말하면서 프로필은 보여주는 상태예요.
이게 문제인지 아닌지는 그 화면이 무엇을 담고 있느냐에 달렸는데, 우리 인스타그램 클론의 프로필은 어차피 공개 정보에 가깝지만 결제 내역이나 남의 쪽지함이었다면 이야기가 완전히 달라져요.
정리하면 문지기는 대부분의 사람을 싸게 걸러내는 그물이고, 진짜로 지켜야 하는 것은 그 화면을 그리는 서버 컴포넌트가 직접 확인해야 한다는 겁니다.
🙋 학생 질문 — "튜터님, 그러면 문지기를 아예 없애도 되는 것 아닌가요? 어차피 진짜 검사는 안쪽에서 하잖아요."
없애면 두 가지를 잃습니다.
하나는 속도예요. 로그인 안 한 사람이 프로필 주소를 열면, 문지기가 없을 때는 서버가 백엔드에 사람을 조회하고 게시물을 가져오고 화면을 그리기 시작한 다음에야 "아, 로그인이 필요하네"를 알게 됩니다. 문지기는 그 전에 끝내요.
다른 하나는 일관성입니다. 보호할 주소가 열 개, 스무 개로 늘어나면 각 화면마다 검사를 넣어야 하는데, 새 화면을 만들면서 그 줄을 빼먹기 쉬워요. 문지기는 기본값을 「보호」로 만들어 줍니다. 빼먹으면 열리는 게 아니라 빼먹으면 막히죠.
그러니까 둘은 경쟁 관계가 아닙니다. 문지기는 싸고 넓게, 안쪽 검사는 정확하게. 역할이 다릅니다.
💡 한 줄 정리
문지기는 쿠키의 존재만 보고 낙관적으로 통과시킵니다. 위조를 걸러내는 것은 문지기가 아니라 화면과 액션이 각자 하는 확인이에요.
Step 2: "무엇을 지키고 무엇을 열어둘까"
지금 우리 앱에는 가입 화면이 없습니다. 계정 두 개를 서버가 켜질 때 코드로 심어두고 있어요. 진짜 서비스라면 가입 화면이 그 일을 해야겠죠.
만들어봅시다.
// apps/web-next/app/actions/signup.ts
'use server';
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: reasonInKorean(error.body?.code) };
}
throw error;
}
return { message: null };
}
로그인 액션과 모양이 거의 같습니다. 다른 점은 실패 사유를 숨기지 않고 알려준다는 것이에요. 로그인 창에서는 아이디가 없는지 비밀번호가 틀렸는지를 일부러 감췄지만, 가입은 아직 아무의 계정도 아니라서 감출 게 없습니다.
화면은 app/signup/page.tsx에 두면 폴더 이름이 그대로 주소가 되죠.
만들자마자 안 열립니다
로그아웃 상태로 한 번, 로그인 상태로 한 번, /signup을 열어볼게요.
로그아웃 상태 → 307 location: /
로그인 상태 → 404
둘 다 화면이 안 나오는데, 막힌 이유가 서로 다릅니다.
로그아웃 쪽은 짐작이 가시죠. 문지기가 "로그인 안 했으니 홈으로" 하고 되돌려보낸 겁니다. 가입하러 온 사람한테 로그인을 요구하고 있어요.
로그인 쪽의 404는 좀 더 고약합니다. 그 순간 백엔드가 받은 요청을 보면 범인이 나옵니다.
[api-stub] GET /api/users/signup
문지기가 백엔드에게 "signup이라는 사람 있나요?"를 물어본 거예요. 당연히 없으니 404가 돌아왔고, 문지기는 D-7에서 우리가 공들여 만든 404 화면을 띄웠습니다.
문지기 코드를 다시 보면 이유가 보입니다.
// apps/web-next/proxy.ts (아직 안 고친 상태)
const username = request.nextUrl.pathname.slice(1);
const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}`);
주소에서 맨 앞 빗금을 떼고 남은 것을 사람 이름으로 취급합니다. /jaehoon이면 jaehoon, /signup이면 signup 이죠. 문지기는 이 주소가 프로필인지 가입 화면인지 구분할 방법이 없어요.
D-7에서 만든 404 회복이 새로 만든 주소를 통째로 잡아먹은 겁니다.
어디서 고칠 것인가
고칠 곳이 두 군데입니다.
하나는 matcher예요. 문지기가 아예 안 깨어나게 주소를 빼는 방법입니다.
matcher: ['/((?!api|_next|favicon.ico|explore).+)'],
여기에 signup을 더하면 됩니다. 실제로 explore가 그렇게 들어가 있죠.
다른 하나는 함수 안에서 거르는 방법입니다. 문지기는 깨어나되 공개 주소면 그냥 통과시키는 거예요.
우리는 두 번째를 고릅니다. 이유는 두 가지 일을 갈라놓기 위해서예요.
matcher → "이 요청 때문에 문지기를 깨울 가치가 있나" (성능 결정)
함수 안 목록 → "이 사람을 들여보내도 되나" (정책 결정)
정규식 안에 정책을 적기 시작하면, 나중에 "이 주소는 왜 공개였더라"를 물었을 때 답할 데가 없습니다. 함수 안이면 그 옆에 이유를 적어둘 수 있어요.
// apps/web-next/proxy.ts
// 로그인 안 한 사람이 와야 하는 주소. 여기 없는 주소는 전부 보호된다.
// 새 주소를 만들면 기본이 "보호" 라서, 공개할 것만 이 목록에 적는다.
const PUBLIC_PATHS = ['/signup'];
export async function proxy(request: NextRequest) {
if (request.method !== 'GET') {
return NextResponse.next();
}
const { pathname } = request.nextUrl;
// 공개 주소는 여기서 끝난다. 아래 두 검사를 둘 다 건너뛴다 —
// 로그인을 요구하지도 않고, 사람 이름으로 착각해 404 를 얹지도 않는다.
if (PUBLIC_PATHS.includes(pathname)) {
return NextResponse.next();
}
if (getSessionCookie(request) === null) {
return NextResponse.redirect(new URL('/', request.url));
}
const username = pathname.slice(1);
// ... 아래는 그대로
}
한 줄이 두 겹을 다 고칩니다. 로그인 요구도 건너뛰고, 사람 이름 조회도 건너뛰어요.
고치기 전 고친 뒤
로그아웃 /signup → 307 로그아웃 /signup → 200
로그인 /signup → 404 로그인 /signup → 200
(보호는 그대로)
로그아웃 /minji → 307 로그아웃 /minji → 307
로그인 /zzzzz → 404 로그인 /zzzzz → 404
목록을 쓰는 방향이 중요합니다
여기서 짚고 갈 게 하나 있어요. 우리는 공개할 것을 적었습니다. 보호할 것을 적지 않았어요.
방향을 뒤집어서 PROTECTED_PATHS로 만들면 어떻게 될까요. 새 화면을 만들 때마다 그 목록에 넣어야 하고, 빼먹으면 아무나 볼 수 있는 상태로 배포됩니다. 그리고 빼먹었다는 것을 알려주는 사람이 없어요. 화면은 잘 뜨니까요.
지금 방향이면 반대입니다. 새 화면을 만들고 목록에 안 넣으면 로그인해야 보이는 상태가 돼요. 실수의 결과가 "너무 많이 막힘" 이지 "너무 많이 열림"이 아닙니다.
둘 다 실수를 하지만, 한쪽 실수는 사용자가 바로 알려주고 다른 쪽 실수는 아무도 안 알려줍니다.
🙋 학생 질문 — "튜터님, 로그인한 사람이 가입 화면을 보는 건 이상하지 않나요?"
이상합니다. 이미 계정이 있는 사람한테 가입 폼을 보여줄 이유는 없어요.
실무에서는 보통 로그인한 사람이 /signup이나 /login에 오면 홈으로 되돌려보냅니다. 지금 우리 문지기에 넣는다면 이런 모양이 되겠죠. 공개 주소인지 확인한 다음, 쿠키가 있으면 홈으로 보내는 겁니다.
다만 오늘은 안 넣었습니다. 이유가 있어요. 그 되돌려보내기를 넣으려면 "쿠키가 있다"를 근거로 판단해야 하는데, Step 1에서 봤듯이 그 쿠키는 위조된 것일 수도 있습니다. 위조 쿠키를 들고 온 사람이 가입 화면에 못 들어가는 상황이 되는 거예요.
큰 문제는 아니지만, 낙관적인 검사로 무엇을 결정해도 되고 무엇을 결정하면 안 되는지를 생각해볼 만한 예입니다. 과제에서 직접 다뤄보세요.
💡 한 줄 정리
공개할 주소를 목록으로 적고 나머지는 전부 보호합니다. 반대 방향으로 적으면 빼먹었을 때 조용히 열린 채로 배포돼요.
Step 3: "원래 가려던 곳으로 되돌려보냅니다"
지금 문지기에게 막힌 사람은 홈으로 갑니다. 로그인을 하고 나면 어떻게 될까요.
홈에 그대로 있습니다.
민지 프로필을 보려고 링크를 눌렀는데, 로그인하라고 해서 로그인했더니 피드가 떠 있고, 자기가 원래 뭘 보려고 했는지는 다시 찾아가야 하는 거예요.
왜 이렇게 되는지 응답을 보면 바로 보입니다.
GET /minji (로그아웃)
→ 307
location: /
location에 /밖에 없어요. 어디로 가려고 했는지가 응답 어디에도 안 남습니다. 로그인 액션이 나중에 그걸 알아낼 방법이 없어요.
목적지를 주소에 실어 보냅니다
문지기가 되돌려보낼 때 목적지를 함께 적어주면 됩니다.
// apps/web-next/proxy.ts
if (getSessionCookie(request) === null) {
// 어디로 가려고 했는지를 주소에 실어 보낸다.
// 이 값을 안 남기면 로그인한 뒤에 되돌려 보낼 곳을 아무도 모른다.
const signInUrl = new URL('/', request.url);
signInUrl.searchParams.set('next', pathname);
return NextResponse.redirect(signInUrl);
}
이제 응답이 이렇게 바뀝니다.
GET /minji (로그아웃)
→ 307
location: /?next=%2Fminji
%2F는 빗금(/)이 주소 안에 안전하게 들어가도록 바뀐 모양입니다. searchParams.set이 알아서 해줘요. 우리가 직접 문자열을 이어 붙였다면 이걸 빼먹기 쉬웠을 겁니다.
폼이 그 값을 들고 갑니다
로그인 폼은 브라우저에서 도니까 지금 주소에 뭐가 붙어 있는지 읽을 수 있습니다.
// apps/web-next/app/components/SignInFields.tsx
export function SignInFields() {
const [state, formAction] = useActionState(signIn, initial);
// 문지기가 주소에 실어 보낸 값. 막힌 적이 없으면 없다.
const next = useSearchParams().get('next');
return (
<form action={formAction} className="ml-2 flex items-center gap-2">
{next !== null && <input type="hidden" name="next" value={next} />}
{/* 아이디·비밀번호 칸은 그대로 */}
</form>
);
}
숨은 입력 하나를 더했습니다. 화면에는 안 보이지만 폼을 제출하면 다른 값들과 함께 서버로 갑니다.
여기서 폼의 성질이 하나 드러나요. 지난 시간에 이 폼이 자바스크립트 없이도 제출되는 평범한 HTML 폼이라고 했죠. 숨은 입력도 똑같이 평범한 HTML입니다. 실제로 화면 소스를 열어보면 이렇게 들어가 있어요.
<input type="hidden" name="next" value="/minji"/>
액션이 그리로 보냅니다
// 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');
const next = formData.get('next');
// ... 아이디·비밀번호 확인은 그대로
// 막혀서 온 사람이면 원래 가려던 곳으로 보낸다.
// redirect 는 값을 돌려주는 대신 던진다 — 이 아래는 실행되지 않는다.
if (typeof next === 'string' && next !== '') {
redirect(next as Route);
}
return { message: null };
}
redirect는 조금 특이합니다. 값을 돌려주는 대신 던져요. 그래서 그 아래 return은 실행되지 않습니다. 처음 보면 어색한데, 덕분에 "여기서 이 함수는 끝"이라는 게 분명해집니다.
as Route라고 적은 부분은 D-2에서 켜둔 주소 타입 검사 때문입니다. 그 검사는 우리 앱에 진짜 있는 주소 목록과 대조하는데, 이 값은 요청이 와야 정해져서 대조할 수가 없어요. 그래서 "이건 주소가 맞다" 고 우리가 알려주는 겁니다.
⚠️ 방금 이 문장이 조금 찜찜하셨다면 정확한 감각입니다. 우리는 아직 이 값을 확인한 적이 없어요. 그런데 주소가 맞다고 단언하고 있습니다. 다음 Step이 이 자리를 다룹니다.
왕복을 확인합니다
로그아웃 상태로 민지 프로필에 가서, 되돌려보내진 화면에서 로그인해봤습니다.
GET /minji → 307 location: /?next=%2Fminji
POST /?next=%2Fminji → 303 location: /minji
set-cookie: better-auth.session_token=...
로그인이 되면서 동시에 원래 가려던 곳으로 갑니다. 응답 하나에 세션 쿠키와 목적지가 함께 실려 있어요.
🙋 학생 질문 — "튜터님, 로그인에 실패하면 그 값은 사라지나요?"
안 사라집니다.
next를 어디서 읽는지 보면 이유가 나와요. 폼은 그 값을 주소에서 읽습니다. 비밀번호를 틀리면 액션이 오류 메시지를 돌려주고 화면이 다시 그려지는데, 주소는 여전히 /?next=%2Fminji예요. 그러니 숨은 입력도 그대로 다시 만들어집니다.
만약 이 값을 화면 상태로 들고 있었다면 이야기가 달랐을 겁니다. 폼이 다시 그려지는 과정에서 날아갈 수도 있었죠. 주소에 담아둔 값은 새로고침을 해도, 실패를 몇 번 해도 남아 있습니다.
C-2에서 모달을 주소에 담았던 것과 같은 이야기예요. 주소에 담으면 살아남습니다.
💡 한 줄 정리
막을 때 목적지를 주소에 실어 보내면, 로그인 액션이 그것을 받아 되돌려보낼 수 있습니다.
Step 4: "그 주소를 믿어도 될까요"
Step 3을 끝내고 나면 우리 액션은 이런 상태입니다.
if (typeof next === 'string' && next !== '') {
redirect(next as Route);
}
글자가 있기만 하면 받은 주소로 그냥 보내는데, 이제 이런 상황을 한번 생각해볼게요. 누군가 여러분에게 링크를 보냅니다.
http://localhost:3000/?next=https://evil.example.com
우리 앱 주소로 시작하니까 안심하고 누르시겠죠. 로그인 화면이 뜹니다. 진짜 우리 앱이 맞아요. 아이디와 비밀번호를 넣고 로그인 버튼을 누릅니다.
그다음에 어디로 갈까요.
재봤습니다
next=/minji → 303 location: /minji
next=https://evil.example.com → 303 location: https://evil.example.com
next=//evil.example.com → 303 location: //evil.example.com
next=/\/evil.example.com → 303 location: /\/evil.example.com
아래 셋은 전부 우리 앱 밖으로 나갑니다.
이게 왜 위험하냐면, 사용자가 방금 진짜 우리 앱에서 로그인을 마친 직후이기 때문입니다. 그 상태로 우리 앱과 똑같이 생긴 화면에 도착하면, 거기서 "세션이 만료됐습니다. 다시 로그인해주세요"라고 해도 의심하기 어려워요.
이런 결함을 열린 리다이렉트라고 부릅니다. 우리 앱이 사용자를 아무 데나 보내주는 통로가 되는 거예요.
뻔한 고침이 뚫립니다
가장 먼저 떠오르는 방법은 "빗금으로 시작하는 것만 허용" 일 겁니다. 우리 앱 안의 주소는 전부 /로 시작하니까요.
if (next.startsWith('/')) { /* 안전하다고 보고 보낸다 */ }
그런데 이걸로 막히는 건 셋 중 하나뿐입니다.
입력 startsWith("/") 실제로 가는 곳
────────────────────────────────────────────────────────────
/minji 통과 우리 앱
https://evil.example.com 거절 —
//evil.example.com 통과 다른 사이트
/\/evil.example.com 통과 다른 사이트
//evil.example.com은 빗금으로 시작하지만, 브라우저는 빗금 두 개로 시작하는 주소를 "지금 쓰는 방식(http 나 https)을 그대로 쓰되 다른 사이트로 가라"는 뜻으로 읽어요. 우리 눈에는 경로처럼 보이지만 브라우저에게는 다른 사이트 주소입니다.
그럼 빗금 두 개를 막으면 될까요.
if (next.startsWith('/') && !next.startsWith('//')) { /* ... */ }
마지막 줄이 남습니다. /\/evil.example.com은 빗금·역슬래시·빗금으로 시작해요. //로 시작하지 않으니 이 검사를 통과하는데, 브라우저는 역슬래시를 빗금과 같게 취급합니다. 결국 //evil.example.com과 같은 뜻이 돼요.
이런 식으로 계속 갈 수 있습니다. 막을 모양을 하나씩 추가하는 방식은 다음 모양이 나오면 또 뚫려요.
방향을 바꿉니다
문제의 뿌리는 글자 모양을 보고 브라우저의 해석을 짐작하려 한 것입니다. 짐작하지 말고 물어보면 됩니다.
주소를 푸는 일은 브라우저와 서버가 똑같은 규칙으로 하고 있어요. URL이 그 규칙을 그대로 씁니다. 그러니 실제로 풀어본 다음, 결과가 우리 앱 안인지 보면 됩니다.
// apps/web-next/app/actions/session.ts
// 그래서 아무도 안 쓰는 이름에 붙여 풀어본 뒤, 그 이름 그대로면 우리 앱 안이라고 본다.
// 바깥으로 나가는 주소는 붙이는 순간 다른 이름이 되어 걸린다.
const INTERNAL_BASE = 'http://internal.invalid';
function insideOurApp(next: FormDataEntryValue | null): string | null {
if (typeof next !== 'string' || next === '') {
return null;
}
try {
const resolved = new URL(next, INTERNAL_BASE);
if (resolved.origin !== INTERNAL_BASE) {
return null;
}
return resolved.pathname + resolved.search;
} catch {
return null;
}
}
읽는 방법은 이렇습니다. new URL(주소, 기준)은 "이 기준 주소에서 저 주소를 눌렀다면 어디로 가는가"를 계산해줍니다. 기준을 아무도 안 쓰는 이름으로 두면, 결과가 그 이름 그대로일 때만 "안 나갔다"는 뜻이 돼요.
바깥으로 나가는 주소는 전부 다른 이름으로 바뀌어서 걸립니다.
입력 풀어본 결과 판정
──────────────────────────────────────────────────────────────────
/minji internal.invalid/minji 통과
/explore?tag=여행 internal.invalid/explore?tag=… 통과
https://evil.example.com evil.example.com 거절
//evil.example.com evil.example.com 거절
/\/evil.example.com evil.example.com 거절
javascript:alert(1) (주소가 아님) 거절
덤으로 하나 더 얻었습니다. 통과한 주소는 풀린 모양으로 돌려받아요. 우리가 받은 글자를 그대로 쓰는 게 아니라, 규칙에 따라 정리된 경로를 씁니다.
액션에서는 이렇게 쓰면 됩니다.
const destination = insideOurApp(next);
if (destination !== null) {
// 타입 검사는 "진짜 있는 주소" 목록과 대조하는데, 이 값은 요청 때 정해져서 대조할 수가 없다.
// 우리가 바로 위에서 확인했다는 것을 타입은 모르므로 여기서만 단언한다.
redirect(destination as Route);
}
Step 3에서 찜찜했던 as Route가 이제 근거를 갖습니다. 확인을 한 다음에 단언하는 것과, 확인 없이 단언하는 것은 글자는 같아도 전혀 다른 코드예요.
다시 재봅니다
next=/minji → 303 location: /minji
next=/explore → 303 location: /explore
next=https://evil.example.com → 200 (되돌려보내지 않음 · 앱에 남는다)
next=//evil.example.com → 200
next=/\/evil.example.com → 200
next=javascript:alert(1) → 200
거절당한 경우에도 로그인은 되는데, 되돌려보내지 않고 그 자리에 남아요. 사용자 입장에서는 "로그인은 됐는데 어디로도 안 갔다"이고, 그게 다른 사이트로 끌려가는 것보다 낫습니다.
⚠️ 타입 검사는 이걸 안 막아줍니다
여기서 오해하기 쉬운 게 하나 있어요. D-2에서 주소 타입 검사를 켜뒀으니 이상한 주소는 타입이 걸러줄 거라고 기대하기 쉽습니다.
확인해봤습니다. 네 줄을 적어놓고 타입 검사를 돌려봤어요.
redirect('/minji'); // 진짜 있는 주소
redirect('https://evil.example.com'); // 바깥 사이트
redirect('javascript:alert(1)'); // 주소가 아닌 것
redirect(runtimeValue); // 확인 안 한 값
걸린 것은 맨 아래 한 줄뿐이었습니다. 바깥 사이트 주소도, javascript:로 시작하는 것도 그냥 통과해요.
생성된 타입을 열어보면 이유가 나옵니다. 허용 목록 안에 이런 항목이 있어요.
type WithProtocol = `${string}:${string}`
가운데 콜론이 있으면 뭐든 주소로 봅니다. 바깥 링크를 걸 수 있어야 하니 당연한 설계예요.
그러니까 타입 검사가 막은 것은 위험한 주소가 아니라 우리가 확인 안 한 값입니다. 그 둘은 전혀 다릅니다. 도구가 뭔가를 막아줬을 때, 그게 정확히 무엇을 막은 것인지 확인하는 습관이 필요해요.
🙋 학생 질문 — "튜터님, 아예 목적지를 주소에 안 싣고 서버가 기억하면 안 되나요?"
가능하고, 실제로 그렇게 하는 서비스도 있습니다. 문지기가 막을 때 "이 사람은 /minji로 가려 했다"를 서버에 적어두고, 로그인이 끝나면 꺼내 쓰는 방식이에요.
이러면 사용자가 값을 건드릴 수 없으니 오늘 본 문제가 통째로 사라집니다.
대신 얻는 것만큼 잃습니다. 서버가 사람마다 뭔가를 기억해야 하고, 그건 서버가 여러 대일 때 곧바로 문제가 돼요. 막은 서버와 로그인을 처리한 서버가 다르면 기억이 없어서, 그 기억을 또 어딘가 공유되는 곳에 둬야 하죠.
주소에 싣는 방식은 서버가 아무것도 기억하지 않아도 됩니다. 대신 사용자가 건드릴 수 있으니 받을 때 확인해야 해요. 오늘 우리가 한 게 그겁니다.
무엇을 서버가 들고 있고 무엇을 요청에 실어 보낼 것인가는 인증 전반에서 계속 나오는 선택입니다. 다음 시간에 이 질문을 세션 자체에 대해 다시 던지게 될 거예요.
💡 한 줄 정리
밖에서 들어온 주소는 글자 모양으로 검사하면 뚫립니다. 실제로 풀어본 다음 우리 앱 안인지 확인해야 해요.
마무리
오늘은 문 앞에서 벌어지는 일들을 정리했습니다. 코드는 많이 안 늘었는데, 결정은 여러 개 했어요.
오늘 배운 핵심 세 가지
💡 하나. 문지기는 낙관적입니다. 쿠키가 있는지만 보고 통과시켜요. 위조를 걸러내지 못하고, 그건 결함이 아니라 속도를 위해 고른 성질입니다. 진짜 확인은 화면과 액션이 각자 합니다.
💡 둘. 공개할 것을 적고 나머지는 막습니다. 반대 방향으로 적으면 새 화면을 만들다 빼먹었을 때 조용히 열린 채로 배포돼요. 실수가 났을 때 어느 쪽으로 기우는지가 목록의 방향을 정합니다.
💡 셋. 밖에서 온 값은 풀어보고 판단합니다. 글자 모양으로 검사하면 //도 역슬래시도 빠져나가요. 브라우저가 어떻게 읽을지는 브라우저와 같은 규칙으로 풀어봐야 압니다.
다음 시간 예고
오늘 우리는 신원을 문 앞에서 어떻게 쓸지를 정했습니다. 다음 시간에는 그 신원을 들고 다니는 이야기로 넘어가요.
먼저 세션 자체를 다시 봅니다. 지금 우리 쿠키에는 서른두 글자짜리 토큰 하나만 들어 있어요. 번호표 같은 거죠. 서버는 그 번호를 받을 때마다 저장소에서 누구 것인지 찾아봅니다.
번호표 대신 내용을 쿠키에 통째로 담는 방법도 있는데, 그러면 서버가 저장소를 안 찾아도 되는 대신 잃는 게 생깁니다. 무엇을 잃는지 직접 재볼 거예요. 서버가 세션을 지웠는데도 한동안 통과하는 상황을 만들어보고, 오늘 잠깐 나왔던 질문 하나도 다시 다룹니다. 남의 집 열쇠로 우리 집에 들어오는 방법, 그러니까 구글이나 GitHub 계정으로 로그인하는 그 방식이요.
마지막으로 가장 큰 것이 남았습니다. 우리 앱에는 지금 신원이 두 개예요. 오늘 문지기가 확인한 세션이 하나고, C-6에서 백엔드로부터 받아오던 토큰이 다른 하나입니다. 액션이 백엔드에 팔로우를 보낼 때 무엇을 실어 보내고 있는지, 그리고 백엔드가 그걸 어떻게 믿고 있는지를 열어봅니다.
그 둘을 화해시키는 것으로 인증 이야기를 닫습니다.
과제
[구현] 로그인한 사람은 가입 화면에 못 들어가게 하기
Step 2의 🙋 에서 미뤄뒀던 것을 직접 해봅시다.
지금은 로그인한 사람도 /signup을 볼 수 있습니다. 이미 계정이 있는 사람에게 가입 폼을 보여줄 이유는 없으니, 홈으로 되돌려보내세요.
문지기의 공개 주소 검사 부분을 손보되, 어느 검사보다 먼저 하고 어느 검사보다 나중에 할지를 정해야 해요. 지금 순서대로면 공개 주소는 아무것도 안 보고 통과하는데, 그 안에서 쿠키를 봐야 하니까요.
만들고 나면 네 경우를 전부 확인하세요. 로그아웃으로 /signup, 로그인으로 /signup, 로그아웃으로 /minji, 로그인으로 /minji요.
하나 더 생각해볼 것이 있는데, 이 되돌려보내기는 쿠키가 있다는 것만 근거로 삼는다는 점이에요. Step 1에서 그 근거가 위조될 수 있다는 걸 봤죠. 위조 쿠키를 들고 온 사람은 가입 화면에 못 들어가게 됩니다. 이게 받아들일 만한 결과인지, 아니면 고쳐야 하는 것인지 판단하고 이유를 적어보세요.
[구현] 가입 실패 사유를 한국어로 옮기기
signUp 액션은 실패 사유를 한국어로 바꿔서 돌려줍니다. 그 대응표를 직접 만들어보세요.
function reasonInKorean(code: string | undefined): string {
switch (code) {
// 여기를 채웁니다
default:
return '가입하지 못했어요';
}
}
⚠️ 이름을 짐작해서 적으면 안 됩니다. 실제로 어떤 값이 오는지 확인하고 적으세요. 짧은 아이디, 한글 아이디, 이미 있는 아이디, 짧은 비밀번호를 각각 넣어보고 돌아오는 값을 직접 보시면 됩니다.
이 과제의 핵심은 여기예요. 틀린 이름을 적어도 아무 일도 안 일어납니다. 오류도 안 나고 경고도 없어요. 그냥 맨 아래 기본 문구가 나옵니다. 화면은 멀쩡해 보이고, 사용자만 왜 가입이 안 되는지 모르게 되죠.
네 가지를 다 맞춘 다음, 일부러 하나를 틀리게 바꿔서 무슨 일이 벌어지는지도 확인해보세요.
[탐구] 문지기를 잠깐 꺼보고 무엇이 달라지는지 재기
Step 1의 🙋 에서 문지기를 없애면 두 가지를 잃는다고 했습니다. 직접 확인해봅시다.
문지기의 쿠키 검사를 잠깐 주석 처리하고, 로그아웃 상태로 민지의 프로필을 열어보세요. 두 가지를 재시면 됩니다.
첫째, 첫 응답이 오기까지 걸리는 시간이요. 문지기가 있을 때와 없을 때를 비교하세요.
둘째, 그동안 백엔드가 받은 요청이요. 요청 기록을 켜두고 세어보시면 됩니다.
재고 나면 반드시 되돌려놓고, 이 실험에서 문지기가 막아준 것이 정확히 무엇인지 한 문장으로 적어보세요. 화면이 안 보이게 한 것인지, 다른 무언가인지요.
[탐구] 검사를 통과했는데 화면이 아닌 곳
Step 4의 검사는 우리 앱 밖으로 나가는 주소를 막지만, 우리 앱 안이면 전부 괜찮을까요.
이런 값들을 넣고 로그인해보세요.
/api/auth/sign-out
/zzzzz
/minji?a=1&a=2
우리 앱 안의 주소가 맞아서 셋 다 검사를 통과하지만, 되돌려보내진 다음에 보게 되는 화면은 셋이 다릅니다. 각각 어떤 응답이 오는지 확인하세요.
확인하고 나면 두 가지를 생각해보시면 됩니다.
첫째, 우리 검사는 "우리 앱 안인가"만 봅니다. "사람이 볼 화면인가"는 안 봐요. 첫 번째 값이 그 차이를 드러냅니다. 이게 지금 우리 앱에서 문제가 되나요? 어떤 앱이었다면 문제가 됐을까요.
둘째, 세 번째 값에서 물음표 뒤가 어떻게 됐는지 보고, insideOurApp이 무엇을 돌려주는지 다시 읽어보시면 왜 그렇게 됐는지 알 수 있습니다. 이게 의도한 동작인지, 아니면 물음표 뒤는 떼는 게 나은지 판단해보세요.
생각해볼 주제
1. 낙관적인 검사로 무엇까지 결정해도 될까요
오늘 문지기는 쿠키의 존재만 보고 사람을 들여보냈습니다. 위조된 쿠키도 통과했죠.
우리 앱에서는 그게 괜찮았습니다. 프로필 화면은 크게 민감하지 않고, 진짜 위험한 동작은 액션이 따로 막고 있으니까요.
만약 이 앱에 쪽지함이 있어서 남의 쪽지 목록이 위조 쿠키로 열린다면, 그건 완전히 다른 이야기가 됩니다.
낙관적인 검사에 기대도 되는 것과 안 되는 것을 가르는 기준은 무엇일까요. 그 기준을 팀에서 어떻게 공유하면 새 화면을 만드는 사람이 실수하지 않을까요.
2. 문지기가 하는 일이 늘어나면 어디까지 늘려도 될까요
오늘 우리 문지기는 세 가지 일을 합니다. 공개 주소인지 보고, 쿠키가 있는지 보고, 백엔드에 사람이 있는지 물어봅니다. 마지막 것은 D-7에서 404 화면을 되찾으려고 넣은 거였죠.
문지기는 모든 요청이 지나가는 곳입니다. 여기에 일을 하나 더 넣으면 그 비용이 앱 전체에 붙어요.
앞으로 "권한이 있는 사람만" 같은 요구가 생기면 그 검사도 여기에 넣고 싶어질 겁니다. 어디까지가 문지기의 일이고, 어디부터는 화면이 직접 해야 할까요. 그 선을 무엇으로 그으시겠어요.
3. 사용자가 보낸 값을 확인하는 자리는 어디여야 할까요
오늘 next 값은 이런 경로로 흘렀습니다. 문지기가 주소에 적고, 브라우저가 폼에 담고, 액션이 받아서 씁니다.
우리는 액션에서 확인했지만, 문지기가 적을 때 확인할 수도 있었고 폼이 담을 때 확인할 수도 있었습니다.
액션에서 확인하기로 한 것은 왜 맞는 선택일까요. 앞의 두 자리에서 확인하면 무엇이 부족한지 생각해보세요.
이 질문을 조금 더 넓혀서, 사용자가 보낸 값을 여러 곳에서 나눠 확인하면 어떤 문제가 생길지도 생각해보세요. 우리 앱의 다른 값들도 같은 기준으로 보면 지금 확인하는 자리가 맞는지 점검해보세요.
✅ 예시 답안정답 보기
과제와 생각해볼 주제의 예시답안입니다. 먼저 직접 해보시고 펼쳐서 비교해보세요.
배점은 과제 네 개가 각각 25점씩, 합해서 100점입니다.
과제 예시답안
🎯 [과제 1 예시답안] 로그인한 사람은 가입 화면에 못 들어가게 하기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 검사를 넣은 위치 | 8 | 공개 주소 갈래 안쪽에 넣었는가. 바깥에 넣으면 보호 주소까지 영향을 받는다 |
| 네 경우 확인 | 7 | 로그아웃·로그인 × /signup·/minji를 전부 재봤는가 |
| 되돌려보내는 방식 | 5 | 홈으로 보냈는가. 그리고 그때 next를 안 붙였는가 |
| 위조 쿠키 판단 | 5 | 낙관적 검사를 근거로 삼는 것의 한계를 설명했는가 |
풀이 예시
공개 주소인지 확인하는 갈래 안쪽에 쿠키 검사를 하나 더 둡니다.
// apps/web-next/proxy.ts
if (PUBLIC_PATHS.includes(pathname)) {
// 이미 계정이 있는 사람에게 가입 폼을 보여줄 이유는 없다.
if (getSessionCookie(request) !== null) {
return NextResponse.redirect(new URL('/', request.url));
}
return NextResponse.next();
}
위치가 이 과제의 핵심인데, 이 검사를 공개 주소 갈래 바깥에 두면 로그인한 사람이 프로필을 여는 것까지 홈으로 튕겨나가요. 조건이 뒤집힌 검사라 그렇습니다.
네 경우를 재보면 이렇게 나옵니다.
로그아웃 /signup → 200 가입하러 온 사람은 들어간다
로그인 /signup → 307 → / 이미 계정이 있으니 홈으로
로그아웃 /minji → 307 → /?next=%2Fminji
로그인 /minji → 200
세 번째 줄만 next가 붙어 있는 것도 확인하세요. 가입 화면에서 되돌려보낼 때는 목적지를 안 남깁니다. 로그인한 뒤에 가입 화면으로 다시 보낼 이유가 없으니까요.
자주 나오는 실수
getSessionCookie(request) === null을 그대로 복사해 오는 경우가 많은데, 여기서는 조건이 반대라서 쿠키가 있을 때 되돌려보내야 해요. 이 되돌려보내기에 next를 붙이면 이상해지는데, 로그인 후에 가입 화면으로 가게 되기 때문입니다.
위조 쿠키 이야기
이 검사는 쿠키가 있다는 것만 보는데, Step 1에서 봤듯 그 쿠키는 아무 글자여도 통과해서 위조 쿠키를 들고 온 사람도 가입 화면에 못 들어갑니다. 로그인도 안 된 상태인데 가입도 막히는 거예요.
받아들일 만한 결과라고 봅니다. 이유는 틀렸을 때 잃는 것이 작기 때문이에요. 그 사람은 쿠키를 지우면 바로 들어갈 수 있고, 우리가 잘못 막아서 새는 정보도 없습니다.
여기서 규칙 하나를 뽑을 수 있어요. 낙관적인 검사는 편의를 위한 결정에 쓰고, 무언가를 지키는 결정에는 쓰지 않습니다. 이 되돌려보내기는 "이미 로그인했으니 굳이 가입 화면을 보여주지 말자"는 편의예요.
💡 튜터의 한마디
검사를 넣을 위치를 고르는 게 이 과제의 전부입니다. 같은 두 줄이라도 어느 갈래 안에 있느냐로 앱 전체의 동작이 달라져요. 조건문을 하나 더할 때는 "이게 어느 갈래 안에 있는가"를 먼저 확인하는 습관을 들이세요.
🎯 [과제 2 예시답안] 가입 실패 사유를 한국어로 옮기기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 네 가지 이름을 실제로 확인 | 10 | 짐작이 아니라 돌아온 값을 보고 적었는가 |
| 대응표 구현 | 7 | 네 경우가 각각 다른 문장으로 나오는가 |
| 기본 문구 유지 | 3 | 모르는 값이 와도 화면이 안 깨지는가 |
| 틀렸을 때 관찰 | 5 | 일부러 틀리게 바꿔보고 무슨 일이 벌어지는지 확인했는가 |
풀이 예시
먼저 실제로 어떤 값이 오는지 확인합니다. 네 가지를 각각 넣어보면 이렇게 나와요.
두 글자 아이디 USERNAME_TOO_SHORT
한글 아이디 INVALID_USERNAME
이미 있는 아이디 USERNAME_IS_ALREADY_TAKEN
짧은 비밀번호 PASSWORD_TOO_SHORT
이걸 보고 적습니다.
// apps/web-next/app/actions/signup.ts
// 이름은 라이브러리가 정한다. 짐작해서 적으면 아무 일도 안 일어난 것처럼
// 맨 아래로 떨어지므로, 실제로 돌려주는 값을 보고 적는다.
function reasonInKorean(code: string | undefined): string {
switch (code) {
case 'USERNAME_IS_ALREADY_TAKEN':
return '이미 쓰고 있는 아이디예요';
case 'USERNAME_TOO_SHORT':
return '아이디는 세 글자 이상이어야 해요';
case 'INVALID_USERNAME':
return '아이디에는 영문과 숫자만 쓸 수 있어요';
case 'PASSWORD_TOO_SHORT':
return '비밀번호는 여덟 글자 이상이어야 해요';
default:
return '가입하지 못했어요';
}
}
자주 나오는 실수
이름을 짐작해서 적는 것이 가장 흔한데, 그게 이 과제가 노린 부분이에요.
예를 들어 USERNAME_IS_TOO_SHORT라고 적었다고 해봅시다. 사이에 IS가 하나 더 들어갔죠. 그러면 무슨 일이 벌어질까요.
아무 일도 안 벌어집니다. 타입 검사도 통과하고, 린트도 조용하고, 화면도 잘 뜹니다. 두 글자 아이디로 가입을 시도하면 "가입하지 못했어요"가 나오는데, 그럴듯해서 아무도 안 알아채요.
사용자만 왜 가입이 안 되는지 모르게 됩니다. 아이디가 짧아서인지, 비밀번호가 문제인지, 서버가 죽었는지 알 수가 없어요.
switch의 default는 편리하지만 이런 성질이 있습니다. 모르는 값이 와도 조용히 넘어가요. 그게 장점인 자리도 있고 함정인 자리도 있습니다. 여기서는 함정 쪽이었어요.
한국어 문장을 쓸 때도 조심할 게 있습니다. 라이브러리가 주는 원문은 영어예요. 그대로 화면에 내보내면 한국어 화면에 영어 문장이 하나 섞입니다. 우리가 옮기는 이유가 그겁니다.
💡 튜터의 한마디
"틀려도 안 터지는 코드"가 가장 위험합니다. 터지면 고치게 되지만, 조용히 기본값으로 떨어지면 몇 달을 그대로 갑니다. 바깥에서 온 값을 이름으로 분기할 때는 실제로 오는 값을 한 번은 눈으로 보고 적으세요.
🎯 [과제 3 예시답안] 문지기를 잠깐 꺼보고 무엇이 달라지는지 재기
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 시간 측정 | 7 | 첫 응답까지의 시간을 양쪽에서 쟀는가 |
| 백엔드 요청 수 측정 | 8 | 요청 기록을 켜고 실제로 세었는가 |
| 되돌려놓기 | 2 | 실험 후 원래대로 복구했는가 |
| 무엇이 막혔는지 서술 | 8 | 상태 코드가 아니라 화면 내용이 달라졌다는 것을 짚었는가 |
풀이 예시
문지기의 쿠키 검사를 주석 처리하고, 로그아웃 상태로 민지 프로필을 세 번씩 열어 재봤습니다.
문지기 있음 문지기 없음
────────────────────────────────────────────────────
상태 코드 307 200
첫 바이트까지 0.003 ~ 0.05초 0.43 ~ 0.47초
전체 0.003 ~ 0.05초 약 1.67초
백엔드 요청 (3회분) 0건 13건
재고 나서 알게 되는 것
시간 차이보다 마지막 줄과 첫 줄이 중요합니다.
백엔드 요청이 0건에서 13건이 됐어요. 한 번 열 때마다 네다섯 건씩입니다. 문지기가 있을 때는 로그인 안 한 사람의 요청이 백엔드까지 아예 안 갑니다. 사람이 많아지면 이 차이가 그대로 서버 비용이 돼요.
상태 코드도 307에서 200으로 바뀌었는데, 이게 답을 적을 때 핵심이에요.
문지기가 막고 있던 것은 화면 내용 자체입니다. 문지기를 끄면 로그인 안 한 사람에게 민지의 게시물과 팔로워 수가 그대로 보여요. 우리 앱에서 프로필은 이 문지기 하나로만 지켜지고 있습니다. 그 화면을 그리는 서버 컴포넌트는 "이 사람이 로그인했나"를 따로 확인하지 않아요.
여기서 Step 1과 이어집니다. 문지기는 쿠키가 없는 사람만 막습니다. 아무 값이나 쿠키에 넣은 사람은 통과해요. 그러니 정확히 말하면 이렇습니다.
프로필 화면은 "쿠키를 하나라도 들고 온 사람"에게 열려 있다.
프로필이 민감하지 않아서 우리 앱에서는 괜찮지만, 이 문장을 그대로 쪽지함에 대입하면 곧바로 사고가 됩니다.
자주 나오는 실수
시간만 재고 끝내는 경우가 많은데, 사실 시간 차이는 이 실험에서 가장 안 중요한 부분이고 개발 환경이라 널뛰기도 해요. 되돌려놓는 것을 잊으면 다음 실습이 전부 이상해지니, 실험용으로 주석 처리한 줄은 잰 직후에 바로 복구하세요.
💡 튜터의 한마디
무언가를 껐을 때 무엇이 달라지는지를 재는 것은 그 코드의 값을 아는 가장 확실한 방법인데, 잴 때 하나만 보면 안 돼요. 시간, 요청 수, 상태 코드, 화면 내용을 같이 봐야 "이 코드가 정확히 무엇을 하고 있었나"가 나옵니다.
🎯 [과제 4 예시답안] 검사를 통과했는데 화면이 아닌 곳
채점 포인트
| 항목 | 배점 | 확인 내용 |
|---|---|---|
| 셋 다 통과한다는 것 확인 | 5 | 우리 앱 안이므로 검사에 안 걸린다는 것을 짚었는가 |
| 세 응답 확인 | 8 | 되돌려보내진 뒤의 응답을 각각 재봤는가 |
| 첫 번째 값 판단 | 7 | 화면이 아닌 주소로도 보낼 수 있다는 것과 그 영향을 설명했는가 |
| 물음표 뒤 설명 | 5 | pathname + search를 돌려주기 때문이라는 것을 찾았는가 |
풀이 예시
셋 다 우리 앱 안의 주소라서 검사를 통과합니다. 되돌려보내지는 것까지는 똑같아요.
next=/api/auth/sign-out → 303 location: /api/auth/sign-out
next=/zzzzz → 303 location: /zzzzz
next=/minji?a=1&a=2 → 303 location: /minji?a=1&a=2
그런데 그 주소를 실제로 열면 셋이 갈립니다.
/api/auth/sign-out → 404 세션은 그대로 살아 있다
/zzzzz → 404 없는 사람
/minji?a=1&a=2 → 200 물음표 뒤가 그대로 남아 있다
첫 번째 값 — 화면이 아닌 곳
/api/auth/sign-out은 사람이 볼 화면이 아니라 인증 서버가 쓰는 주소입니다. 우리 검사는 이걸 걸러내지 않아요. "우리 앱 안인가"만 보고 "사람이 볼 화면인가"는 안 보기 때문입니다.
열어보면 404가 나오는데, 로그아웃은 되지 않아요. 그 주소는 폼 제출 같은 방식으로만 받게 되어 있어서, 주소창으로 여는 방식에는 응답할 것이 없기 때문에 지금 우리 앱에서는 문제가 안 됩니다. 로그인 직후에 404 화면을 보는 게 전부예요. 당황스럽긴 해도 잃는 것은 없습니다.
문제가 되는 앱은 따로 있습니다. 주소를 여는 것만으로 무언가가 바뀌는 곳이 있다면요. 예전 방식으로 만든 서비스 중에는 /logout이나 /items/3/delete 같은 주소를 열기만 해도 동작하는 것들이 있었어요. 그런 앱에서 이 구멍은 실제 피해로 이어집니다.
여기서 규칙이 하나 나옵니다. 주소를 여는 것(GET)은 무언가를 바꾸면 안 됩니다. 이 규칙을 지키는 앱에서는 오늘 같은 구멍이 생겨도 피해가 없어요. 우리 앱이 그래서 안전했던 겁니다. 검사가 촘촘해서가 아니라 GET이 아무것도 안 바꾸기 때문이에요.
굳이 더 좁히고 싶다면 되돌려보낼 주소가 /api로 시작하면 거절하는 방법이 있습니다. 다만 그건 목록을 하나 더 관리하는 일이고, Step 4에서 봤듯 목록으로 막는 방식은 빠뜨리기 쉬워요.
세 번째 값 — 물음표 뒤
insideOurApp이 무엇을 돌려주는지 다시 보면 답이 있습니다.
return resolved.pathname + resolved.search;
경로와 물음표 뒤를 함께 돌려주기 때문에 ?a=1&a=2가 살아남았어요.
이게 의도한 동작입니다. 물음표 뒤를 떼면 잃는 게 생기거든요. 예를 들어 /explore?tag=여행을 보려다 막힌 사람은 로그인 후에 태그가 풀린 탐색 화면으로 가게 됩니다. 원래 가려던 곳이 아니죠.
물음표 뒤도 사용자가 넣은 값이지만, 그 값을 읽는 쪽이 각자 확인하는 게 맞습니다. 되돌려보내기가 그것까지 책임지려 하면 화면마다 다른 규칙을 여기에 적어야 해요.
자주 나오는 실수
303만 보고 "셋 다 똑같이 동작한다" 고 적는 경우가 있습니다. 되돌려보내는 것까지는 똑같지만 그다음 화면이 다릅니다. 되돌려보내진 주소를 실제로 한 번 더 열어봐야 차이가 보여요.
💡 튜터의 한마디
검사를 만들 때는 그 검사가 무엇을 안 보는지를 같이 적어두면 좋습니다. 우리 검사는 "밖으로 나가는가"만 봐요. "화면인가", "권한이 있는가"는 안 봅니다. 안 보는 것을 알고 있으면, 나중에 그게 문제가 되는 상황이 왔을 때 어디를 고쳐야 하는지 바로 압니다.
생각해볼 주제 예시답안
🤔 [생각해볼 주제 1] 낙관적인 검사로 무엇까지 결정해도 될까요
문제 상황 요약
문지기는 쿠키의 존재만 보고 사람을 들여보냅니다. 위조된 쿠키도 통과해요. 우리 앱에서는 괜찮았지만, 쪽지함 같은 화면이었다면 완전히 다른 이야기가 됩니다.
튜터의 가이드 및 해설
기준은 틀렸을 때 무엇을 잃는가 하나로 잡으면 깔끔합니다.
낙관적인 검사는 두 방향으로 틀릴 수 있어요.
잘못 막는다 → 들어와도 되는 사람이 못 들어온다
잘못 들인다 → 들어오면 안 되는 사람이 들어온다
앞쪽으로 틀리면 사용자가 알려주지만, 뒤쪽으로 틀리면 아무도 안 알려주기 때문에 뒤쪽으로 틀렸을 때 잃는 것의 크기가 기준이 됩니다.
과제 1의 되돌려보내기를 다시 보면, 잘못 들여도 잃는 게 없습니다. 가입 화면을 한 번 더 보는 게 전부예요. 그러니 낙관적인 검사로 충분합니다.
프로필 화면은 애매한 자리인데, 우리 앱에서는 괜찮지만 비공개 계정 기능이 생기는 순간 안 괜찮아지고, 그 기능을 만드는 사람이 문지기를 떠올릴 거라는 보장도 없습니다.
쪽지함이라면 명확합니다. 낙관적인 검사에 맡기면 안 돼요.
팀에서 공유하는 방법이 사실 더 어려운 문제입니다. "잘 판단하세요"는 지침이 아니니까요. 두 가지가 실제로 도움이 됩니다.
첫째, 기본값을 안전한 쪽으로 두는 것입니다. 오늘 우리가 공개 목록을 쓴 이유가 그거예요. 새 화면은 아무것도 안 해도 일단 막힙니다.
둘째, 진짜 검사를 한 곳에 모아두고 그것을 쓰게 하는 것입니다. 우리 앱에는 currentUser()가 그 자리에 있어요. 새 화면을 만드는 사람이 "이 화면은 누가 보나"를 생각하게 되는 지점이 그 함수를 부를 때입니다.
셋째로 하나 더 붙이자면, 문지기를 믿으면 안 된다는 것을 문지기 옆에 적어두는 것입니다. 코드를 읽는 사람이 그 파일을 열었을 때 오해하지 않게요.
🎯 면접관을 홀리는 핵심 멘트
"미들웨어의 세션 검사는 성능 최적화지 인증이 아니라고 봅니다. 저희는 쿠키 존재 여부만 보고 낙관적으로 리다이렉트하되, 실제 보호는 각 서버 컴포넌트와 서버 액션에서 세션을 재확인하는 방식으로 이중화했습니다. 기준은 단순합니다. 잘못 들였을 때 잃는 것이 있으면 낙관적 검사에 맡기지 않습니다."
🤔 [생각해볼 주제 2] 문지기가 하는 일이 늘어나면 어디까지 늘려도 될까요
문제 상황 요약
우리 문지기는 지금 세 가지 일을 합니다. 공개 주소 확인, 쿠키 확인, 그리고 백엔드에 사람이 있는지 조회요. 마지막 것은 404 화면을 되찾으려고 넣었습니다.
문지기는 모든 요청이 지나가는 곳이라, 일이 하나 늘면 그 비용이 앱 전체에 붙습니다.
튜터의 가이드 및 해설
선을 긋는 기준으로 세 가지를 제안합니다.
첫째, 네트워크를 타는가. 앞의 두 검사는 요청 안에 든 것만 봅니다. 아주 쌉니다. 세 번째는 백엔드를 부르죠. 실제로 재보면 로그인한 사람의 프로필 요청이 400밀리초쯤 느려집니다. D-7에서 이 비용을 알고도 넣은 이유는 404 상태 코드를 되찾는 값이 그만큼 컸기 때문이에요. 알고 내는 것과 모르고 내는 것은 다릅니다.
둘째, 모든 요청에 필요한가. 문지기는 골라서 깨울 수 있습니다. matcher가 그 일을 해요. 특정 주소에만 필요한 검사라면 문지기 전체에 넣는 대신 그 화면에서 하는 게 맞습니다.
셋째, 여기서만 할 수 있는 일인가. 이게 가장 결정적입니다. 문지기에는 아직 아무것도 안 그려진 순간이라는 고유한 성질이 있어서, 상태 코드를 정할 수 있고 화면을 그리는 비용을 아예 안 낼 수 있습니다. D-7의 404 회복이 여기 있어야 하는 이유가 그거예요. 화면 안에서는 못 하는 일이니까요.
반대로 화면 안에서도 할 수 있는 일은 화면에서 하는 게 낫습니다. 권한 검사가 대표적이에요. "이 게시물의 작성자만 수정할 수 있다" 같은 규칙은 그 게시물을 이미 가져온 화면에서 판단하는 게 자연스럽습니다. 문지기가 하려면 게시물을 한 번 더 조회해야 해요.
정리하면 이렇습니다.
문지기에 둔다 → 화면을 그리기 전에 끝내야 하는 것
모든 요청에 해당하는 것
여기서만 할 수 있는 것 (상태 코드 · 되돌려보내기)
화면에 둔다 → 이미 가져온 데이터로 판단할 수 있는 것
화면마다 규칙이 다른 것
🎯 면접관을 홀리는 핵심 멘트
"미들웨어에 로직을 넣을지는 '여기서만 할 수 있는 일인가'로 판단합니다. 상태 코드 결정이나 렌더링 전 차단은 미들웨어의 고유 영역이지만, 리소스 단위 권한 검사처럼 이미 가져온 데이터로 판단 가능한 것은 해당 화면에 두는 게 맞다고 봅니다. 모든 요청이 지나가는 지점이라 비용이 전역으로 붙기 때문입니다."
🤔 [생각해볼 주제 3] 사용자가 보낸 값을 확인하는 자리는 어디여야 할까요
문제 상황 요약
next 값은 문지기가 주소에 적고, 브라우저가 폼에 담고, 액션이 받아서 씁니다. 우리는 액션에서 확인했어요. 앞의 두 자리에서 할 수도 있었습니다.
튜터의 가이드 및 해설
원칙은 쓰기 직전에 확인한다입니다. 이유를 자리별로 보면 분명해져요.
문지기가 적을 때 확인하는 방법. 문지기가 적는 값은 원래 안전합니다. 자기가 방금 본 주소를 그대로 적으니까요. 문제는 공격자가 보내는 링크가 문지기를 안 거친다는 겁니다. ?next=를 직접 붙인 주소로 곧장 들어오면 문지기는 그 값을 만든 적도 본 적도 없어요. 여기서 확인해봐야 소용이 없습니다.
폼이 담을 때 확인하는 방법. 폼은 브라우저에서 돕니다. 브라우저에서 하는 검사는 건너뛸 수 있어요. 폼을 안 쓰고 직접 요청을 만들어 보내면 그 검사는 존재하지 않는 것과 같습니다. Step 3에서 이 폼이 평범한 HTML 폼이라 도구로도 제출된다고 했죠. 그 성질이 여기서는 반대로 작용합니다.
액션에서 확인하는 방법. 액션은 서버에서 돕니다. 그리고 값을 실제로 쓰는 곳이에요. 어떤 경로로 들어온 값이든 여기를 지나야 하기 때문에, 액션이 맞습니다. 앞의 두 자리는 "안 거칠 수 있는 자리"예요.
여러 곳에서 나눠 확인하면 생기는 문제도 짚어봅시다.
가장 큰 문제는 각자 상대를 믿게 되는 것입니다. 폼이 "문지기가 확인했겠지" 하고, 액션이 "폼이 확인했겠지" 하면 아무도 안 하게 돼요. 그리고 이 상태는 겉으로 안 드러납니다. 평소에는 잘 동작하니까요.
두 번째는 규칙이 갈리는 것입니다. 세 곳의 검사가 처음에는 같아도 한 곳만 고쳐지면 그때부터 다른 규칙이 됩니다. 어느 것이 진짜인지 아무도 몰라요.
그래서 확인은 한 곳에서, 쓰기 직전에 하고, 그 함수를 다른 데서도 쓰고 싶으면 함수를 부르게 합니다. 검사를 복사하지 않아요.
이 기준으로 앱을 훑어보면 점검할 곳이 보입니다. 액션이 받는 아이디와 비밀번호, 팔로우 대상 이름, 좋아요 대상 번호요. 그중 신원은 인자로 받지 않는다는 규칙을 우리는 이미 세워뒀습니다. 액션이 신원을 오직 currentUser()로만 얻게 한 것이 그거예요. 인자로 온 값은 무엇이든 사용자가 고칠 수 있다고 보는 겁니다.
🎯 면접관을 홀리는 핵심 멘트
"검증은 값을 사용하는 지점에서 합니다. 리다이렉트 대상을 예로 들면, 미들웨어에서 검증해도 공격자는 미들웨어를 거치지 않고 쿼리 파라미터를 직접 붙일 수 있고, 클라이언트 검증은 요청을 직접 만들면 우회됩니다. 서버 액션에서 검증한 이유는 어떤 경로로 들어온 값이든 반드시 그 지점을 지나기 때문입니다."