G-3: 프론트엔드 인증 ③ — 신원을 어떻게 들고 다닐까
목차 55
안녕하세요, 홍순구 튜터입니다. 오늘이 이 과목의 마지막 시간입니다.
첫 시간에 우리가 쓴 코드는 이거 한 줄이었어요.
// apps/web-spa/src/types/instagram.ts (A-1 · 45일 전)
export interface Post {
id: number;
imageUrl: string;
}
게시물이 무엇인지 타입으로 적어본 게 전부였고, 화면도 서버도 없었어요.
지금은 아이디와 비밀번호로 로그인이 되고, 문 앞에서 사람을 걸러내고, 서버가 화면을 미리 그려서 보내고, 컨테이너에 담아 내보낼 수 있는 앱이 됐습니다.
그런데 아직 안 닫힌 것이 하나 남아 있는데, 지난 시간 마지막에 말씀드린 대로 우리 앱에는 신원이 두 개입니다. 문지기가 확인하는 세션이 하나고, 백엔드가 믿는 무언가가 다른 하나예요.
오늘 그 둘을 화해시키면서 인증 이야기를 닫는데, 가는 길에 두 가지를 더 봐요. 세션을 어떻게 들고 다닐지, 그리고 남의 집 열쇠로 우리 집에 들어오는 방법을요.
💡 오늘 수업의 핵심 — "신원은 만드는 것보다 들고 다니는 게 어렵습니다"
🎯 학습 목표
- 세션을 번호표로 들지 내용을 베껴 들지 직접 재보고, 무엇을 잃는지 확인한 뒤 정합니다.
- 소셜 로그인이 실제로 무엇을 주고받는지 열어보고, 그 안의 검사를 일부러 깨뜨려봅니다.
- 우리 서버가 아는 신원과 백엔드가 믿는 신원을 맞추고, 뒷문 하나를 닫습니다.
Step 1: "번호표 하나에 무엇이 들어 있나"
지난 시간에 우리가 만든 쿠키를 다시 열어봅시다.
better-auth.session_token
└─────── 토큰 32 ───────┘ . └───────── 서명 46 ─────────┘
앞의 32자가 번호표인데, 세션 자체는 서버 저장소에 있고 쿠키에는 그것을 가리키는 번호만 들어 있어요.
그래서 서버는 요청을 받을 때마다 이 번호를 들고 저장소를 찾아보면서 "이 번호 누구 거지?" 하고 묻습니다.
식당 옷 보관소를 떠올리시면 됩니다. 번호표를 받아 들고 있다가 나갈 때 그걸 내밀면 직원이 옷걸이를 찾아 옷을 꺼내주는데, 번호표 자체에는 옷에 대한 정보가 하나도 없어요.
번호표 대신 내용을 통째로 들 수도 있습니다
Better Auth에는 세션 내용을 쿠키에 베껴 담는 설정이 있어서 켜보겠습니다.
// apps/web-next/lib/auth.ts
export const auth = betterAuth({
database: memoryAdapter(db),
// 비밀번호로 로그인하겠다는 선언.
emailAndPassword: { enabled: true },
// 세션 내용을 쿠키에 베껴 담는다. 그러면 서버가 저장소를 안 찾아도 누구인지 안다.
// maxAge 를 60 초로 짧게 준 것은 강의에서 유효기간이 지나는 순간을 직접 보려는 것이다.
// (기본값은 300 초)
session: { cookieCache: { enabled: true, maxAge: 60 } },
plugins: [
한 줄뿐인데, 다시 로그인하고 쿠키를 세어보면 이렇게 달라져요.
켜기 전 쿠키 1개
better-auth.session_token 79자
켠 뒤 쿠키 2개
better-auth.session_token 79자 (그대로)
better-auth.session_data 955자 (통째로 새로 생김)
955자짜리가 새로 생겼는데, 쿠키 안에 답이 들어 있으니까 이제 서버는 저장소를 안 찾아도 돼요.
그 955자 안에 무엇이 있을까요
base64url이라는 방식으로 적혀 있어서 눈으로는 못 읽는데, 풀어보면 이렇게 나와요.
{"session":{"session":{"expiresAt":"2026-08-27T11:12:29.384Z",
"token":"GWW7WkFjQ5AyJAzhxSNPQzO6vWI4zHAh",
"userId":"mDMpdglQ8DKgpLnNQQk66xuWKPLtO3s8"},
"user":{"name":"재훈","email":"jaehoon@example.com",
"username":"jaehoon", ... },
"version":"1"},
"expiresAt":1787223705079,
"signature":"3DsZk5Ll1NaFdua_tfSz..."}
이름이 있고, 이메일이 있고, 아이디가 있습니다.
여기서 오해하기 쉬운 지점이 하나 있어요. 맨 끝에 signature가 붙어 있으니 안전하다고 생각하기 쉽지만, 서명은 「고쳤는지」를 잡는 것이지 「못 읽게」 하는 게 아니에요.
봉투에 도장을 찍은 것과 같아서, 도장이 있으면 누가 뜯어고쳤는지는 알 수 있지만 봉투가 투명하면 안의 내용은 그냥 보입니다.
⚠️ 그러니까 이 방식을 쓸 거라면 쿠키에 담아도 되는 것만 담아야 합니다. 이메일이 보이는 게 우리 앱에선 큰일이 아닐 수 있지만, 여기에 전화번호나 권한 목록이 들어가 있었다면 이야기가 완전히 달라집니다.
💡 한 줄 정리
세션을 번호표로 들면 서버가 매번 저장소를 찾고, 내용을 베껴 들면 안 찾아도 되는 대신 그 내용은 서명만 붙은 채로 브라우저에 그대로 놓입니다.
🙋 학생 질문 — "튜터님, 955자면 큰가요? 매 요청마다 저 쿠키가 다 따라가는 거잖아요"
정확히 보셨습니다. 쿠키는 그 주소로 가는 모든 요청에 자동으로 붙어서, 이미지 하나 받을 때도 따라가요.
955자는 대략 1킬로바이트쯤 되니까, 요청 하나당 1킬로바이트가 늘어난다고 보시면 됩니다. 페이지 하나 여는 데 요청이 수십 건이면 그만큼 곱해지고요.
브라우저마다 쿠키 크기 상한도 있는데, 보통 하나에 4킬로바이트예요. 세션에 담는 정보가 늘어나면 어느 순간 그 선에 닿습니다.
그래서 이 선택은 "저장소 조회를 아끼는 대신 네트워크로 매번 실어 나른다"는 맞바꿈이라서, 저장소 조회가 진짜 병목인지 먼저 재보고 정할 일이에요. C-8에서 배운 그 순서인데, 재보고 나서 쓰는 것이지 미리 쓰는 게 아닙니다.
Step 2: "서버가 잊어도 돌아옵니다"
이제 대가를 재보는데, 방법은 이래요.
로그인을 하고 서버를 껐다 켭니다. 우리 저장소는 서버 메모리에 있어서 서버를 끄면 세션도 계정도 통째로 사라지는데, 그다음 같은 쿠키를 들고 다시 들어가 봅니다.
T0 로그인 · 저장소에 세션이 있고 캐시 쿠키도 갓 발급됨
T+11s 서버 재시작 완료 — 저장소는 비었다
서버 입장에서 그 사람은 이제 존재하지 않습니다. 그런데요.
T+20s get-session 세션있음 /minji 200
T+45s get-session 세션있음 /minji 200
T+65s get-session null /minji 307
T+80s get-session null /minji 307
45초 지점까지도 멀쩡히 들어가집니다.
돌아온 세션이 진짜 그 세션인지 확인해봤어요.
되살아난 쿠키가 말하는 사람 userId mDMpdglQ8DKgpLnNQQk66xuWKPLtO3s8
새 서버의 진짜 재훈 userId x8cDlyubXHXVYzrAhE9rFEvrQ8M5Uos7
다른 사람인데, 서버에 없는 사람으로 통과한 거예요.
이게 왜 중요한가
서버 재시작은 실습을 위해 쓴 방법일 뿐이고, 진짜로 무서운 건 이거예요.
세션을 지워도 안 지워집니다.
로그아웃 버튼을 눌렀다고 해봅시다. 서버는 저장소에서 그 세션을 지우지만, 쿠키에 내용이 베껴져 있으면 그 쿠키를 들고 있는 사람은 유효기간이 끝날 때까지 계속 들어옵니다. 서버는 저장소를 안 찾아보니까요.
계정을 정지시켜도 마찬가지고, 비밀번호를 바꿔서 다른 기기를 다 내보내려 해도 마찬가지입니다.
번호표만 든다 나가기를 누른 순간 끝난다
매 요청 저장소를 본다
내용을 베껴 든다 나가기를 눌러도 유효기간 동안은 통과한다
저장소를 안 봐도 된다
이걸 부르는 이름이 있어요. 즉시 무효화를 잃는다고 합니다.
유효기간이 지나면 스스로 정리됩니다
위 표에서 65초 지점을 보시면 /minji가 307로 막혔죠. 이때 무슨 일이 있었는지 응답 헤더를 잡아봤습니다.
set-cookie: better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
set-cookie: better-auth.session_data=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
set-cookie: better-auth.dont_remember=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
Max-Age=0은 "이 쿠키 지워라"라는 뜻이라서, 서버가 세 개를 한꺼번에 걷어갔어요.
캐시가 만료돼서 저장소를 찾아봤는데 없더라, 그럼 이 사람 세션은 끝난 거다, 쿠키도 정리하자 — 이렇게 동작한 것이고, 그다음 요청부터는 쿠키가 없으니 문지기가 막고요.
⚠️ 여기서 제가 한 번 틀릴 뻔했는데, 처음에는 이 실험을 응답의 쿠키 지시를 무시하는 방식으로 쟀어요. 그랬더니 /minji가 계속 200으로 나와서 "신원은 사라졌는데 문지기는 통과시킨다"는 결론이 나올 뻔했죠. 브라우저는 서버가 지우라고 하면 지우는데, 재는 도구가 그 말을 안 들었을 뿐이에요.
💡 한 줄 정리
내용을 베껴 들면 서버가 그 세션을 잊어버려도 유효기간 동안은 통과하는데, 잃는 것은 편의가 아니라 즉시 무효화입니다.
🙋 학생 질문 — "그럼 유효기간을 아주 짧게, 5초쯤 주면 되는 거 아닌가요?"
좋은 생각이고 실제로 그렇게 쓰지만, 짧게 줄수록 이 설정을 켠 이유가 사라져요.
5초로 주면 5초마다 저장소를 다시 찾아봅니다. 원래 목적이 "저장소를 안 찾는 것" 이었는데, 5초에 한 번은 찾게 되는 거죠. 아낀 양이 얼마나 되는지는 그 5초 안에 요청이 몇 건 들어오느냐에 달렸습니다.
그래서 이건 "얼마나 오래 틀린 답을 해도 괜찮은가"를 정하는 문제입니다. 로그아웃하고 5초 동안 들어가지는 게 괜찮은 앱이라면 5초를 주면 되고, 1초도 안 되는 앱이라면 이 설정 자체를 안 쓰는 겁니다.
숫자를 고르는 게 아니라 얼마를 감수할지를 고르는 거예요.
Step 3: "가릴 수는 있는데, 그게 문제가 아니었습니다"
Step 1에서 이메일이 그대로 보였는데, 그것부터 해결해봅시다.
Better Auth는 이 쿠키를 어떤 형식으로 적을지 고를 수 있게 해뒀습니다. 설치된 소스에서 옵션을 직접 확인해보면 이렇게 적혀 있어요.
strategy?: "compact" | "jwt" | "jwe" 기본값 "compact"
compact base64url + HMAC-SHA256 서명 (지금 우리가 본 것)
jwt JWT 형식 + HMAC 서명 (서명만, 암호화 없음)
jwe JWE 암호화 (A256CBC-HS512)
앞의 둘은 서명만 해서 형식이 다를 뿐 내용은 똑같이 보이고, 세 번째만 다릅니다.
jwe로 바꿔서 다시 로그인해봤습니다.
session_data 955자 → 1,124자
마침표로 나뉜 조각 5개 (116 / 0 / 22 / 939 / 43)
머리 조각만 풀린다:
{"alg":"dir","enc":"A256CBC-HS512","kid":"Pmjxv..."}
이메일이 보이나 안 보인다
「재훈」이 보이나 안 보인다
가려졌습니다. 어떤 방식으로 잠갔는지만 알려주고 내용은 안 보여주는데, 앱도 그대로 돌아갑니다.
그런데 진짜 문제는 그게 아니었습니다
Step 2에서 잃은 것을 다시 떠올려보면 즉시 무효화였습니다.
암호화는 그걸 하나도 안 고쳐줘서, 남이 못 읽게 됐을 뿐 서버가 세션을 지워도 유효기간 동안 통과하는 것은 똑같습니다.
혹시 이걸 고쳐주는 설정이 있나 찾아봤더니 refreshCache라는 게 있습니다. 문서에는 만료 전에 자동으로 갱신해준다고 적혀 있는데, 이렇게 덧붙어 있습니다.
WITHOUT querying the database
저장소를 안 보고 갱신한다는 뜻이라서, 켜고 재봤습니다.
refreshCache: true · 저장소를 비운 서버에 20초마다 두드리기
T+20s 세션있음 200
T+40s 세션있음 200
T+60s 세션있음 200
T+80s null 307
계속 두드리는 동안 스스로 기간을 늘려주는 셈이라 즉시 무효화에서 더 멀어지는 쪽이고, 대가를 줄이는 옵션이 아니라 키우는 옵션이었습니다.
그래서 우리는 켜지 않습니다
셋을 나란히 놓고 정했습니다.
켬 (maxAge 60) 끔 (기본값)
로그인 직후 쿠키 2개 (+955자) 1개
저장소 비운 직후 세션있음 · 200 null · 307
유효기간 지난 뒤 null · 307 -
우리 앱에는 나가기 버튼이 있어서 누르면 그 순간 끝나야 하는데, 그게 저장소 조회를 아끼는 것보다 중요합니다.
그리고 솔직히 말씀드리면 우리 앱은 저장소 조회가 병목이라는 증거가 없는데, 재보지도 않고 아끼려 드는 건 C-8에서 하지 말자고 했던 그 순서입니다.
// apps/web-next/lib/auth.ts
export const auth = betterAuth({
database: memoryAdapter(db),
// 비밀번호로 로그인하겠다는 선언.
emailAndPassword: { enabled: true },
// session.cookieCache 는 켜지 않는다.
// 켜보고 잰 결과, 저장소를 비운 서버에서도 유효기간 동안은 통과했다.
// 우리 앱에는 나가기 버튼이 있고, 나가는 순간 끝나는 쪽이 더 중요하다.
plugins: [
코드는 원래대로 돌아갔지만, 우리는 이제 왜 안 켜는지를 아는데 그게 이번 Step의 결과물입니다.
💡 한 줄 정리
암호화는 내용을 가려줄 뿐 즉시 무효화를 되돌려주지 않습니다. 이 설정을 켤지는 "로그아웃이 몇 초 늦어도 되는가"로 정합니다.
🙋 학생 질문 — "그럼 이 설정은 대체 언제 쓰나요? 안 쓸 거면 왜 있는 걸까요"
저장소를 찾는 비용이 진짜로 클 때 씁니다.
우리 저장소는 서버 메모리라 찾는 데 거의 시간이 안 걸리지만, 실무에서는 세션이 다른 기계에 있는 데이터베이스나 캐시 서버에 있는 경우가 많아서 요청 하나마다 네트워크를 한 번 더 타는 셈이죠.
요청이 초당 수천 건씩 들어오는 서비스라면 그 한 번이 쌓여서 큰 비용이 됩니다. 그럴 때 "로그아웃이 최대 30초 늦어도 된다" 고 정하고 켜는 거예요.
그리고 로그아웃이 늦으면 안 되는 자리만 따로 막는 방법도 있습니다. 돈이 오가는 화면이나 설정 변경 같은 곳에서는 캐시를 믿지 말고 저장소를 직접 보게 하는 거죠. 전부 아니면 전무로 정할 일이 아닙니다.
Step 4: "남의 집 열쇠로 우리 집에"
지난 시간 마지막에 던져둔 질문으로 넘어가는데, 구글이나 GitHub 계정으로 로그인하는 그 방식이요.
먼저 이름을 정리하고 가겠습니다. 이걸 OAuth라고 부르는데, 우리가 남의 집 비밀번호를 대신 받아 확인하는 게 아니에요. 그건 오히려 최악이라서, 대신 이렇게 합니다.
사용자 ──① 우리 앱에서 "GitHub 으로 계속하기" 를 누른다
│
├─② 우리가 GitHub 주소를 만들어 보낸다
│
├─③ GitHub 화면에서 로그인하고 "허락" 을 누른다
│
├─④ GitHub 이 우리에게 되돌려보낸다 (코드 한 장을 들려서)
│
└─⑤ 우리가 그 코드를 GitHub 에 내밀고 "이 사람 누구야" 를 묻는다
비밀번호는 ③에서 GitHub 화면에만 입력되고, 우리 앱은 그걸 구경도 못 해요. 우리가 받는 건 ④의 코드 한 장뿐입니다.
진짜 열쇠가 없어도 붙일 수 있습니다
끝까지 가려면 GitHub에 앱을 등록해서 열쇠 한 쌍을 받아야 하는데, 등록 없이도 여기까지는 볼 수 있어요. 가짜 값을 넣고 붙여봅시다.
// apps/web-next/lib/auth.ts
// 남의 집 열쇠로 우리 집에 들어오는 길.
// 진짜 열쇠는 GitHub 에 앱을 등록해야 나온다 — 없으면 강의용 가짜 값으로 둔다.
// 가짜 값이어도 「어디로 보낼지」까지는 진짜로 만들어진다.
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID ?? 'demo-client-id',
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? 'demo-secret',
},
},
그리고 보내주는 함수를 하나 만듭니다.
// apps/web-next/app/actions/session.ts
/**
* 남의 집 열쇠로 들어오기. 우리가 하는 일은 「어디로 보낼지」를 정하는 데까지다.
*
* signInSocial 은 로그인을 시키지 않는다 — GitHub 으로 가는 주소를 만들어 줄 뿐이다.
* 그 주소로 보내면 그다음은 GitHub 의 화면이고, 확인이 끝나면 우리 콜백 주소로 돌아온다.
*/
export async function signInWithGitHub() {
const { url } = await auth.api.signInSocial({
body: { provider: 'github', callbackURL: '/' },
});
if (url === undefined) {
return;
}
// 우리 앱 밖의 주소라 Route 목록에 없다. 만든 쪽이 GitHub 이므로 여기서만 단언한다.
redirect(url as Route);
}
머리말에 버튼을 답니다.
// apps/web-next/app/components/SignInFields.tsx
{/* 아이디·비밀번호 폼과 목적지가 달라서 폼을 따로 둔다. 폼 하나에 보낼 곳은 하나다. */}
<form action={signInWithGitHub}>
<button type="submit" className="rounded border border-black/15 px-2 py-1">
GitHub 으로 계속하기
</button>
</form>
폼을 따로 둔 이유가 있는데, 폼 하나는 보낼 곳이 하나이기 때문입니다. 아이디·비밀번호는 signIn으로 가야 하고 이 버튼은 signInWithGitHub으로 가야 하니까 나눠야 해요.
눌러보면 어디로 갈까요
버튼을 누르면 앱이 이렇게 답합니다.
HTTP/1.1 303 See Other
Location: https://github.com/login/oauth/authorize
?response_type=code
&client_id=demo-client-id
&state=<32자>
&scope=read%3Auser+user%3Aemail
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fcallback%2Fgithub
&code_challenge_method=S256
&code_challenge=<43자>
가짜 열쇠인데도 주소는 진짜로 만들어졌고, 물음표 뒤에 일곱 가지가 실려 있어요.
그중 redirect_uri를 보시면 반가운 게 있을 겁니다.
http://localhost:3000/api/auth/callback/github
└──────────────────────┘
G-1 에서 만든 그 라우트
G-1에서 app/api/auth/[...all]이라는, 주소를 통째로 받아내는 폴더를 만들었죠. 그때는 로그인과 로그아웃만 그리로 갔는데, 오늘 하나가 더 늘어서 GitHub이 사람을 되돌려보낼 곳이 바로 거기예요.
그 주소로 실제로 가보면
HTTP/2 302
location: https://github.com/login?client_id=demo-client-id&return_to=...
GitHub이 자기 로그인 화면으로 보내는데, 여기부터는 GitHub의 영역이에요.
우리 몫은 주소를 만드는 데까지입니다. 그 뒤는 남의 집에서 벌어지는 일이고, 끝까지 가려면 진짜 앱 등록이 필요해서 강의는 여기서 정직하게 멈춥니다.
💡 한 줄 정리
소셜 로그인에서 우리가 하는 일은 「어디로 보낼지」를 정하고 「돌아온 사람을 받는 것」입니다. 비밀번호는 우리 앱을 지나가지 않습니다.
🙋 학생 질문 — "가짜 열쇠인데도 주소가 만들어지는 게 좀 이상한데, 검사는 언제 하나요?"
client_id는 비밀이 아니라서 그렇습니다. 주소에 그대로 실려 나가는 걸 보셨듯이 브라우저 주소창에도 보이고 누구나 읽을 수 있어요.
비밀인 쪽은 clientSecret인데, 그건 주소에 안 실립니다. 나중에 우리 서버가 GitHub에 코드를 내밀 때 서버 대 서버로만 씁니다.
그래서 순서가 이래요. 주소를 만드는 데는 공개된 값만 있으면 되니까 아무나 만들 수 있고, 검사는 GitHub이 그 client_id를 자기 목록에서 찾아볼 때 시작돼요. 우리 demo-client-id는 목록에 없으니 그때 걸립니다.
다음 Step에서 그 「걸리는 순간」을 직접 만들어볼게요.
Step 5: "그 주소를 뜯어봅니다"
주소에 실린 일곱 가지 중에 세 개를 짚고 갑니다.
scope read:user user:email 무엇까지 볼 것인지
state 32자 무작위 글자 돌아온 사람이 우리가 보낸 사람인지
code_challenge 43자 + method=S256 코드를 가로채도 못 쓰게
scope는 쉽습니다. 우리가 GitHub에 요구하는 범위이자 프로필과 이메일만 보겠다는 뜻인데, 여기에 저장소 쓰기 권한 같은 걸 넣으면 사용자 승인 화면에 그게 그대로 뜨고 대부분 그 자리에서 그만둡니다. 필요한 것만 적는 게 기술적으로도 유리해요.
나머지 둘이 오늘의 주인공입니다.
state — 돌아온 사람이 우리가 보낸 사람인가
버튼을 눌렀을 때 우리 서버가 쿠키를 하나 심습니다.
better-auth.state (81~83자)
주소에 실어 보낸 state와 같은 값이라, 나중에 GitHub이 사람을 되돌려보낼 때 그 값을 같이 보내줍니다. 우리는 주소에 실려 온 값과 우리가 심어둔 쿠키를 대조해요.
왜 이런 걸 할까요. 공격자가 자기 계정으로 로그인을 시작해놓고 그 되돌아오는 주소를 남에게 눌리게 만들면, 피해자는 자기도 모르게 공격자의 계정으로 로그인된 상태가 되는데, 그 상태에서 사진을 올리면 공격자 계정에 올라갑니다.
state는 그걸 막는데, 되돌아온 값이 이 브라우저가 시작한 것인지 확인하니까요.
직접 깨뜨려봅시다
말로만 하면 와닿지 않으니 콜백 주소를 손으로 만들어서 넣어봤습니다. 네 가지를요.
콜백에 들고 간 것 돌아온 오류
────────────────────────────────────────────────
state 없음 state_not_found
state 틀림 state_mismatch
state 맞음 · 쿠키 없음 state_mismatch
state 맞음 · 쿠키 있음 invalid_code
세 번째 줄을 보시면 주소의 state는 맞는데 쿠키가 없으면 막히는데, 주소만 베껴서는 못 통과한다는 뜻이에요. 둘이 짝이 맞아야 합니다.
네 번째 줄에서는 오류 이름이 바뀌었는데, state 검사를 통과해서 다음 단계로 넘어갔고 거기서 가짜 코드가 걸린 겁니다.
한 번 더 재봤어요.
같은 state 를 두 번 쓰면
1회 invalid_code
2회 state_mismatch
한 번 쓰면 사라지는데, 같은 값을 다시 못 쓰게 해둔 거예요.
PKCE — 코드를 가로채도 못 쓰게
code_challenge는 이런 겁니다. 우리가 무작위 값을 하나 만들어서 몰래 보관하고 그걸 해시로 뭉갠 값만 주소에 실어 보내는데, 나중에 코드를 교환할 때 원래 값을 같이 내밀어요.
시작할 때 우리만 아는 값 V 를 만든다
해시(V) 만 주소에 실어 보낸다 ← 가로채도 V 를 못 구한다
교환할 때 코드 + V 를 함께 내민다
GitHub 이 해시(V) 와 대조한다
method=S256이 그 해시 방식입니다.
이게 왜 필요한가 하면, ④에서 돌아오는 코드는 주소에 실려 있어서 새어 나갈 수 있거든요. 그 코드만 손에 넣은 사람은 V를 모르니까 교환을 못 합니다.
원래는 휴대폰 앱처럼 비밀을 안전하게 못 숨기는 환경을 위해 만들어진 방식인데, 지금은 웹에서도 기본으로 켭니다. 우리가 아무것도 안 했는데도 주소에 이미 들어 있었죠.
여기서 멈춥니다
invalid_code를 넘어가려면 GitHub에 진짜 앱을 등록해야 합니다. 등록하면 열쇠 한 쌍이 나오고, 그걸 환경 변수에 넣으면 이 흐름이 끝까지 돕니다.
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
.env.example에 자리를 만들어뒀어요. 계정이 있으신 분은 직접 등록해서 끝까지 가보셔도 좋습니다.
다만 강의에서는 여기까지만 합니다. 모두가 GitHub 계정을 만들어야 진도가 나가는 구조는 좋지 않기도 하고, 오늘 배울 것은 이미 다 나왔어요. 무엇을 주고받는지, 무엇이 검사되는지, 어디서 막히는지를 전부 눈으로 봤습니다.
💡 한 줄 정리
state는 돌아온 사람이 우리가 보낸 사람인지 보고, PKCE는 코드가 새어도 못 쓰게 합니다. 둘 다 우리가 안 적었는데 이미 들어 있었습니다.
🙋 학생 질문 — "우리가 안 적었는데 알아서 들어간다면, 안 켜는 방법도 있나요?"
옛날에는 state도 PKCE도 선택이었어서, 안 쓰는 구현이 많았고 그만큼 사고도 많았어요.
지금은 이런 라이브러리들이 기본으로 켜고 끄는 방법을 잘 안 열어주는데, 안 쓸 이유가 없기 때문이에요.
이건 우리가 이 과목에서 여러 번 만난 패턴입니다. 예전에는 직접 챙겨야 했던 것이 도구 안으로 들어가서, 이제는 안 하는 쪽이 더 어려워진 자리요. 자동 메모이제이션도 그랬고, 사진 크기를 미리 잡아두는 것도 그랬죠.
다만 방향은 짚고 가셔야 합니다. 기본값이 좋아졌다는 것이지 없어도 된다는 게 아니에요. 검색해서 나온 옛날 코드에는 이런 게 빠져 있을 수 있고, 그걸 그대로 베끼면 그 시절의 구멍도 같이 옵니다.
Step 6: "우리가 보내는 것과 백엔드가 믿는 것"
이제 오늘의 본론인데, 우리 앱에 신원이 두 개라고 했던 그 이야기요.
먼저 우리 액션이 백엔드에 무엇을 실어 보내는지 열어봅시다. 두 줄이면 됩니다.
// apps/web-next/app/actions/follow.ts (지난 시간까지의 상태)
const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}/follow`, {
method: 'POST',
headers: { 'X-Actor': me },
});
X-Actor라는 헤더에 이름을 적어 보내는데, me는 currentUser()가 세션에서 읽어낸 진짜 신원이라 우리 쪽에서는 아무 문제가 없습니다.
문제는 받는 쪽입니다.
백엔드는 그 이름을 어떻게 확인할까요
확인 안 합니다. 확인할 방법이 없어요.
백엔드 입장에서 X-Actor: jaehoon은 그냥 글자라서, 그게 우리 서버가 세션을 읽고 붙인 값인지 아니면 아무나 손으로 적어 보낸 값인지 구분할 수가 없어요.
직접 해보는데, 우리 앱을 거치지 않고 백엔드에 바로 쏴보는 겁니다.
X-Actor 에 적어 보낸 이름 결과
───────────────────────────────────
jaehoon → @minji 200
minji → @jaehoon 200
seungwoo → @minji 401
nobody → @minji 401
(헤더 없음) → @minji 401
위의 두 줄이 통과했습니다. 비밀번호도 없고 토큰도 없고 서명도 없어요. 목록에 있는 이름 한 줄이면 그 사람이 됩니다.
세 번째 줄이 흥미로운데, seungwoo는 게시물이 있는 사람인데도 401이에요. 로그인 계정이 아니기 때문에, 백엔드가 하는 검사는 "이 이름이 계정 목록에 있나" 딱 하나입니다.
진짜로 데이터가 움직입니다
이게 말장난이 아니라는 걸 보여드릴게요. 민지 이름으로 태양이를 팔로우해봤는데, 민지는 오늘 로그인한 적이 없고 우리 서버를 거치지도 않았어요.
태양 팔로워 0 → 1
움직였습니다.
어디서 본 구조죠
G-1 첫 Step을 떠올려보시면, 그때 우리 앱에는 me라는 쿠키가 있었고 거기에 아무 이름이나 적으면 그 사람이 됐습니다. "재훈으로 보기" 버튼이 그냥 쿠키를 심는 버튼이었죠.
우리는 그걸 고쳐서, 서명을 붙였고 위조 세 가지를 전부 거절하는 것까지 확인했어요.
앞문 서명된 세션 쿠키 위조가 안 통한다 (G-1 에서 고침)
뒷문 X-Actor 에 적힌 이름 아무나 적으면 된다 ← 그대로 남아 있다
같은 구조가 뒷문에 그대로 남아 있었습니다. 앞문에 자물쇠를 달고 뒷문은 열어둔 셈이에요.
💡 한 줄 정리
우리 서버는 세션으로 신원을 확인하지만, 백엔드는 헤더에 적힌 이름을 그냥 믿습니다. 앞문에 붙인 서명이 뒷문에는 없습니다.
🙋 학생 질문 — "백엔드가 그냥 인터넷에 열려 있는 게 문제 아닌가요? 우리 서버만 부를 수 있게 하면 되잖아요"
정확한 지적이고, 실무에서 실제로 그렇게 하는데, 백엔드를 바깥에 노출하지 않고 내부 망에서만 부를 수 있게 두면 아무나 X-Actor를 쏘지 못합니다.
다만 그것만으로는 부족한 이유가 둘 있어요.
하나는 안쪽도 안전하지 않다는 겁니다. 우리 서버 말고도 그 백엔드를 부를 수 있는 것이 사내에 여럿이면 그중 하나만 문제가 생겨도 전부 남이 될 수 있어서, 요즘은 "안쪽이니까 믿는다"를 안 하는 쪽으로 갑니다.
다른 하나는 누가 했는지 남지 않는다는 겁니다. 백엔드가 신원을 자기 손으로 확인하지 않으면, 나중에 문제가 생겼을 때 그 요청이 진짜 그 사람 것이었는지 증명할 방법이 없어요.
그래서 망을 닫는 것과 신원을 확인하는 것은 둘 다 하는데, 오늘은 뒤쪽을 하는 거예요.
Step 7: "서명된 것으로 바꿨더니 반쪽만"
고쳐봅시다. 이름 대신 백엔드가 발급한 출입증을 보내는 거예요.
우리 백엔드에는 원래 그런 게 있었는데, C-6에서 SPA가 쓰던 그 토큰이요. 로그인하면 출입증을 주고, 그걸 Authorization 헤더에 담아 보내면 백엔드가 자기가 발급한 것인지 대조합니다.
그때와 다른 점이 하나 있어요. 이번엔 토큰이 브라우저로 안 갑니다.
C-6 (SPA) 브라우저가 토큰을 들고 있다 localStorage
오늘 (Next) 서버가 토큰을 들고 있다 브라우저는 구경도 못 한다
C-6에서 이 이야기를 하다가 미뤄뒀던 것 기억하시나요. 토큰을 localStorage에 두면 그 사이트에서 도는 자바스크립트가 다 읽을 수 있는데, 더 안전한 방법이 있지만 그건 서버가 받쳐줘야 쓸 수 있다고 했죠.
지금 우리에겐 서버가 있습니다.
출입증을 들고 있을 자리를 만듭니다
// apps/web-next/lib/backend-token.ts
const tokens = (store.__backendTokens ??= new Map<string, string>());
/** 이 사람 몫의 출입증. 없으면 백엔드에 받아온다. */
export async function backendTokenFor(username: string): Promise<string | null> {
const held = tokens.get(username);
if (held !== undefined) {
return held;
}
return issueFor(username);
}
globalThis에 매다는 이유는 G-1에서 겪은 그대로인데, 그냥 두면 이 파일을 읽는 곳마다 저장소가 따로 생겨요.
액션은 헤더만 바꿉니다.
// apps/web-next/app/actions/follow.ts
// 이름 대신 백엔드가 발급한 출입증을 보낸다.
const token = await backendTokenFor(me);
if (token === null) {
return { ...previous, message: '백엔드에 신원을 확인받지 못했어요' };
}
const response = await fetch(`${API_BASE}/users/${encodeURIComponent(username)}/follow`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
좋아요도 똑같이 바꿨습니다. 이제 눌러보죠.
팔로우가 안 됩니다
폼 제출 HTTP 200
민지 팔로워 8500 → 8500 안 움직였다
요청은 갔고, 백엔드 기록에도 남아 있어요.
[api-stub] POST /api/auth/login
[api-stub] POST /api/users/minji/follow
출입증도 제대로 받아왔고 팔로우 요청도 도착했는데 아무 일이 없어서, 같은 출입증을 백엔드에 직접 내밀어봤어요.
팔로우 /users/minji/follow {"success":false, "message":"로그인이 필요합니다"}
좋아요 /posts/2/like {"success":true, "data":{ ... }}
좋아요는 되는데 팔로우는 안 됩니다.
왜 갈릴까요
백엔드 코드를 열어보면 두 라우트가 신원을 서로 다르게 가리고 있었어요.
좋아요 resolveActor(req.headers) 출입증도 받고 이름도 받는다
팔로우 req.headers['x-actor'] 이름만 받는다
한쪽은 두 방식을 다 받아주는 함수를 쓰고 다른 쪽은 헤더를 직접 읽어서, 같은 앱의 두 버튼인데 규칙이 달랐던 거예요.
여기서 중요한 건 이 차이가 프런트에서 안 보였다는 겁니다. 방식을 바꾸기 전에는 둘 다 200이었는데, 우리가 이름을 보내고 있었으니까 둘 다 통했던 거죠. 방식을 바꾸는 순간 하나만 깨졌습니다.
⚠️ 이런 어긋남은 조용해서, 타입 검사도 통과하고 빌드도 통과하고 화면도 멀쩡해요. 눌러보고 숫자가 안 바뀌는 걸 봐야 압니다.
💡 한 줄 정리
같은 앱의 두 버튼이 백엔드에서는 서로 다른 규칙으로 확인되고 있었습니다. 보내는 방식을 바꾸기 전까지 그 차이는 안 보였습니다.
🙋 학생 질문 — "그럼 팔로우만 예전 방식으로 되돌리면 둘 다 도는 거 아닌가요?"
돌긴 도는데, 그건 뒷문을 그대로 두는 선택이에요.
X-Actor를 계속 쓰는 한 아무나 이름을 적어 보낼 수 있고, Step 6에서 태양이 팔로워가 실제로 움직이는 걸 봤죠.
그리고 이런 식으로 "되는 쪽에 맞춘다"를 반복하면, 나중에 새 기능을 붙이는 사람이 어느 쪽 규칙을 따라야 할지 모르게 됩니다. 코드베이스에 규칙이 두 개 살아 있는 상태가 되는 거예요.
다음 Step에서 반대 방향으로 가는데, 팔로우를 되돌리는 게 아니라 팔로우도 좋아요와 같은 함수를 쓰게 만들면 규칙이 하나가 되고 그 김에 뒷문도 닫힙니다.
Step 8: "같은 규칙으로 맞춥니다"
여기서 한 가지 인정하고 갈 게 있어요.
이건 프런트만으로는 못 고칩니다.
C-6에서 비슷한 말을 했었죠. 토큰을 더 안전한 곳에 두려면 서버가 그렇게 만들어져 있어야 하고 프런트만으로는 못 정한다고요. 오늘 그 문장이 다시 나오는데, 백엔드가 같은 규칙을 갖고 있지 않으면 우리가 아무리 잘 보내도 소용이 없어요.
그래서 백엔드를 고치는데, 두 군데예요.
하나 — 팔로우도 같은 함수를 쓰게
// apps/api-stub/server.mjs
// 좋아요와 같은 함수로 가린다. 라우트마다 규칙이 다르면 프런트에서는 그 차이가 안 보인다.
const auth = resolveActor(req.headers);
if (auth.status === 'missing') return fail(res, 401, '로그인이 필요합니다');
if (auth.status === 'invalid') return fail(res, 401, '토큰이 유효하지 않습니다');
if (auth.status === 'expired') return fail(res, 401, '액세스 토큰이 만료되었습니다');
const actor = auth.username;
둘 — 뒷문을 닫는다
resolveActor는 출입증이 없으면 X-Actor를 봐주고 있었는데, 그 갈래를 걷어냅니다.
// apps/api-stub/server.mjs
// 누가 보냈는지를 가린다. 우리가 발급한 출입증만 받는다.
//
// 예전에는 출입증이 없으면 X-Actor 헤더에 적힌 이름을 그대로 믿었다.
// 목록에 있는 이름 한 줄이면 누구든 그 사람이 될 수 있었다는 뜻이다 — 뒷문이었다.
// 이제는 우리가 발급하고 우리가 확인할 수 있는 것만 신원으로 친다.
function resolveActor(headers) {
return verifyAccessToken(headers.authorization);
}
재봤습니다.
팔로우 좋아요
X-Actor: jaehoon 401 401 "로그인이 필요합니다"
Bearer <진짜 출입증> 200 200
Bearer nope 401 401 "토큰이 유효하지 않습니다"
첫 줄이 닫혔고, 두 번째 줄에서 두 라우트가 같아졌습니다.
그런데 새 문제가 생깁니다
우리 백엔드가 주는 출입증은 세 번 쓰면 만료됩니다. C-6에서 401 갱신을 눈으로 보려고 일부러 짧게 만들어둔 규칙이에요.
지금 우리 코드는 받아온 출입증을 그냥 들고만 있어서, 네 번째 팔로우부터는 401이 오고 화면에 "액세스 토큰이 만료되었습니다"가 뜰 거예요.
C-6에서 이 문제를 어떻게 풀었는지 기억하시나요. Axios 인터셉터가 401을 가로채서 갱신용 표를 내밀고 새 출입증을 받은 다음, 원래 요청을 다시 보냈죠.
같은 일을 서버에서 합니다.
// apps/web-next/lib/backend-token.ts
/**
* 이 사람 몫의 출입증을 붙여 백엔드를 부른다.
*
* 백엔드 출입증은 몇 번 쓰면 만료된다. 만료되면(401) 새로 받아 딱 한 번 다시 시도한다.
* 한 번으로 제한하는 것이 중요하다 — 새로 받은 것도 거절당하는 상황에서 계속 다시
* 시도하면 그대로 무한 반복이 된다.
*/
export async function fetchAsUser(username: string, path: string, init?: RequestInit) {
const held = tokens.get(username) ?? (await issueFor(username));
if (held === null) {
return null;
}
const first = await send(path, held.accessToken, init);
if (first.status !== 401) {
return first;
}
// 만료됐다. 우리 서버는 세션으로 이 사람을 이미 확인했으니 새 출입증을 받아올 자격이 있다.
const renewed = await renew(username, held);
if (renewed === null) {
return first;
}
return send(path, renewed.accessToken, init);
}
⚠️ 주석에 적힌 「딱 한 번」이 중요합니다. C-6에서 재시도 표시를 빼고 돌렸다가 무한 반복에 빠져서 프로세스가 통째로 죽었던 것 기억하시죠. 여기도 똑같아요. 갱신한 것도 거절당하는 상황이면 영원히 돕니다.
액션은 한 줄로 줄어듭니다.
// apps/web-next/app/actions/follow.ts
// 이름 대신 백엔드가 발급한 출입증을 붙여 보낸다. 만료됐으면 알아서 새로 받는다.
const response = await fetchAsUser(me, `/users/${encodeURIComponent(username)}/follow`, {
method: 'POST',
});
다섯 번 눌러봅니다
출입증은 세 번 쓰면 만료됩니다. 다섯 번 누르면 중간에 한 번 갈아 끼워야 해요.
팔로우 횟수 팔로워 수 토큰 발급·갱신 요청
──────────────────────────────────────────────
1회 8501 → 8500 1건 (첫 발급)
2회 8500 → 8501 0건
3회 8501 → 8500 0건
4회 8500 → 8501 1건 (갱신)
5회 8501 → 8500 0건
다섯 번 다 성공했습니다. 4회째에 갱신이 한 번 일어났는데도 화면에는 아무 일도 안 일어나서, 사용자는 출입증이 갈렸는지도 모릅니다.
C-6에서 인터셉터가 해주던 일을 이제 서버가 하는데, 그 출입증은 브라우저로 한 글자도 안 나가요.
이제 신원이 하나입니다
사용자 ──> 우리 서버 세션 쿠키로 확인 (서명됨)
│
└──> 백엔드 출입증으로 확인 (백엔드가 발급)
두 구간 모두 「받는 쪽이 자기 손으로 확인할 수 있는 것」만 신원으로 친다
지난 시간에 "신원이 두 개"라고 했던 건, 두 구간이 서로 다른 종류의 근거를 쓰고 있었다는 뜻이었습니다. 한쪽은 서명된 것, 다른 쪽은 그냥 적힌 이름이요.
지금은 둘 다 확인 가능한 것을 써서, 이름은 어디에도 신원으로 쓰이지 않아요.
💡 한 줄 정리
신원을 맞추려면 보내는 쪽과 받는 쪽이 같은 규칙을 가져야 합니다. 프런트만 고쳐서는 안 되는 자리가 있습니다.
🙋 학생 질문 — "우리 서버가 백엔드에 로그인할 때 비밀번호를 안 보내는데, 백엔드는 왜 그냥 출입증을 주나요?"
날카로운 질문이고, 지금 우리 연습용 백엔드가 정확히 그렇게 되어 있습니다. 이름만 보내면 출입증을 주고, 비밀번호를 안 봅니다.
그러니까 엄밀히 말하면 뒷문을 완전히 닫은 게 아니라 한 칸 옮긴 겁니다. 아무나 팔로우를 쏠 수는 없게 됐지만 아무나 출입증을 받아갈 수는 있어요.
진짜 서비스에서는 이 구간을 이렇게 막습니다. 우리 서버와 백엔드만 아는 비밀을 하나 두고 출입증을 요청할 때 그걸 함께 내밀면 그 비밀을 모르는 쪽은 출입증을 못 받는데, 사용자 한 명 한 명이 아니라 서버가 서버에게 자기를 증명하는 겁니다.
이 구간을 정면으로 다루는 것은 백엔드 쪽 과목의 몫이라 오늘은 여기까지 하지만, 「어디가 아직 열려 있는지」는 알고 넘어가는 게 좋아요. 모르고 지나가는 것과 알고 미루는 것은 다르니까요.
마무리
오늘은 신원을 들고 다니는 세 가지 방법을 봤습니다. 쿠키에 어떻게 담을지, 남의 집 열쇠를 어떻게 빌릴지, 그리고 그 신원을 백엔드에 어떻게 전할지요.
오늘 배운 핵심 세 가지
💡 하나. 세션을 베껴 들면 즉시 무효화를 잃습니다. 저장소를 안 찾아도 되는 대신 나가기를 눌러도 유효기간 동안은 통과하는데, 암호화를 얹어도 그건 안 고쳐집니다. 가려지는 것과 무효화되는 것은 다른 문제예요.
💡 둘. 소셜 로그인에서 우리 몫은 주소를 만들고 돌아온 사람을 받는 것입니다. 비밀번호는 우리 앱을 지나가지 않아요. 대신 state와 PKCE라는 검사가 자동으로 붙는데, 우리가 안 적었는데도 들어 있었습니다.
💡 셋. 신원을 맞추려면 보내는 쪽과 받는 쪽이 같은 규칙을 가져야 합니다. 우리가 아무리 잘 보내도 받는 쪽이 이름을 그냥 믿으면 소용이 없어요. 프런트만으로 못 정하는 자리가 있습니다.
45일을 돌아봅니다
오늘로 코어 과정이 끝났습니다. 어디서 출발해서 어디까지 왔는지 한 번 훑고 가죠.
A 타입 한 줄 게시물이 무엇인지 적는다
B 첫 컴포넌트 상태가 바뀌면 화면이 따라온다
E 디자인 유틸리티 → 토큰 → 변형 → 어두운 화면 → 디자인 시스템
C 진짜 앱 주소 · 전역 상태 · 서버 상태 · 성능 · 실시간
D 서버로 서버 컴포넌트 · 캐싱 · 서버 액션 · 최적화 · 컨테이너
F 지키기 커버리지 · 첫 판 · 사용자 관점 · 서버 없이 재현
G 신원 로그인 · 문 앞 · 들고 다니기
45일 동안 인스타그램 클론 하나가 이 길을 따라 자랐습니다. 정적인 카드 한 장에서 시작해서, 로그인이 되고 테스트가 지키고 컨테이너에 담기는 앱이 됐어요.
그런데 제가 여러분께 정말 남기고 싶은 건 기술 목록이 아닙니다. 이 과목 내내 반복해서 나온 태도 네 가지예요.
먼저 재고 나서 정합니다. 자동 메모이제이션이 정말 일하는지, 캐시가 정말 빠른지, 사진이 정말 밀리는지를 매번 재봤는데, 여러 번 예상이 틀렸습니다. 안 줄어드는 게 정답인 적도 있었고 고칠 게 없는 게 답인 적도 있었는데, 오늘 세션 캐시도 재보고 안 켰습니다.
통과했다고 맞는 게 아닙니다. 타입 검사도 통과하고 빌드도 통과하고 화면도 멀쩡한데 틀린 자리를 여러 번 만났어요. 오늘도 그랬는데, 팔로우가 안 되는데 아무도 안 알려줬습니다.
재는 방법이 답을 바꿉니다. 숨은 탭에서 잰 값이 거짓이었던 적이 있고, 도구가 실행 환경을 보고 다른 출력을 낸 적도 있어요. 오늘은 응답의 쿠키 지시를 무시하는 도구 때문에 정반대 결론이 나올 뻔했는데, 이상한 숫자가 나오면 코드보다 재는 쪽을 먼저 의심하세요.
기본값이 좋아진 것과 몰라도 되는 것은 다릅니다. 컴파일러가 메모이제이션을 해주고, 라이브러리가 state와 PKCE를 자동으로 붙여줘요. 편해진 건 맞지만, 왜 그게 필요한지 모르면 그게 없는 옛날 코드를 베꼈을 때 무엇이 빠졌는지도 모릅니다.
여기서 더 가고 싶다면
코어에서 다루지 않은 갈래가 넷 있습니다. 커리큘럼에서 골라 들으실 수 있어요.
보안을 더 깊이. 오늘 인증을 했지만 보안은 인증만이 아닌데, 남이 심은 스크립트가 우리 페이지에서 도는 문제, 남의 사이트가 우리 앱에 요청을 대신 보내는 문제, 그리고 브라우저에게 "이 출처의 스크립트만 실행해라"라고 미리 알려두는 방법이 있어요. 오늘 마지막 질문에서 "뒷문이 한 칸 옮겨갔다" 고 했던 것도 여기서 이어집니다.
테스트를 더 넓게. F에서 우리는 브라우저를 흉내 낸 환경에서 판을 짰습니다. 진짜 브라우저를 띄워서 사람이 하는 것과 똑같이 클릭하고 확인하는 방법이 따로 있고, 컴포넌트를 하나씩 떼어서 카탈로그로 만들어 그 카탈로그가 곧 문서이자 테스트가 되게 하는 방법도 있습니다.
전역 상태의 다른 갈래. C에서 우리는 가벼운 것을 골랐는데, 회사에 들어가면 다른 것을 만날 가능성이 높아요. 특히 오래된 프로젝트일수록 그런데, 같은 기능을 세 가지 도구로 나란히 만들어보면서 무엇이 다른지 보는 시간이 있습니다.
운영으로 넘어가기. 배포한 다음이 진짜 시작이죠. 사용자 화면에서 터진 오류를 우리가 어떻게 알게 되는지, 실제 사용자가 체감하는 속도를 어떻게 재는지, 여러 나라 말을 어떻게 지원하는지, 그리고 팀이 여럿일 때 앱을 어떻게 쪼개는지를 다룹니다.
45일 동안 고생 많으셨습니다.
처음에 타입 한 줄을 적을 때는 이게 어디로 가는지 안 보이셨을 텐데, 지금 돌아보면 그 한 줄이 오늘 이 앱의 첫 벽돌이었습니다.
앞으로 만드실 앱은 인스타그램 클론이 아니겠지만, 오늘까지 밟아온 순서는 그대로 쓰이실 겁니다. 재보고, 정하고, 왜 그렇게 정했는지 적어두는 것이요.
감사합니다.
과제
[구현] 나가기를 눌러도 안 나가지는 상황 만들기
Step 2에서 우리는 서버를 껐다 켜는 방법으로 「저장소가 비었는데 통과하는」 상황을 만들었는데, 그건 실습을 위한 방법이었고 실무에서 진짜 문제가 되는 건 로그아웃이에요.
직접 만들어봅시다. 순서는 이렇습니다.
- 로그인한 뒤 쿠키를 담아둔 파일을 복사본으로 하나 더 떠둡니다.
- 원본으로 나가기를 누릅니다.
- 복사본으로 세션을 물어봅니다.
복사본은 로그아웃 응답을 못 받았으니 쿠키를 그대로 들고 있는데, 남이 훔쳐간 쿠키와 같은 상황입니다.
이 실험을 cookieCache를 끈 상태와 켠 상태(maxAge: 60)로 각각 해보고, 복사본이 무엇을 돌려받는지 비교하세요. 켠 상태에서는 60초를 기다린 뒤에도 한 번 더 재보시고요.
⚠️ 나가기를 도구로 부를 때 두 가지에 걸립니다. 본문 없이 보내면 415가 오고, 어디서 보냈는지를 안 밝히면 403이 와요. 실제 화면에서는 브라우저가 알아서 붙여주는 것들입니다. 무엇을 붙여야 하는지 응답을 보고 알아내세요.
다 재고 나면 한 문장으로 적어보세요. 이 두 상태의 차이를 사용자에게 설명한다면 뭐라고 하시겠어요?
[구현] 출입증을 사람마다 안 나누면 무슨 일이 생기는지
lib/backend-token.ts는 출입증을 사람 이름을 열쇠로 삼아 보관하는데, 그걸 일부러 깨뜨려보세요. 열쇠를 고정된 글자 하나로 바꾸면 모두가 한 칸을 같이 쓰게 되는 거죠.
그다음 이렇게 해보세요.
- 재훈으로 로그인해서 아무나 팔로우합니다. (이때 그 칸에 재훈의 출입증이 들어갑니다)
- 민지로 로그인해서 재훈을 팔로우해봅니다.
민지가 재훈을 팔로우하면 재훈의 팔로워 수가 움직여야 정상이니, 움직이는지 확인하세요.
안 움직인다면 왜 그런지 백엔드 입장에서 설명해보세요. 백엔드는 그 요청을 누가 보낸 것으로 봤고, 그 조합이 왜 거절당했을까요?
⚠️ 이 실험은 반드시 되돌려놓고, 되돌린 뒤에 정상 동작을 한 번 더 확인하시고요.
[탐구] 뒷문은 정말 닫혔을까
Step 8에서 X-Actor를 걷어냈습니다. 이제 이름 한 줄로는 아무것도 못 해요.
그런데 출입증을 받는 쪽은 어떨까요. 우리 서버가 백엔드에 POST /api/auth/login을 보낼 때 무엇을 함께 보내는지 코드에서 확인하고 같은 요청을 여러분이 직접 백엔드에 보내보세요.
- 출입증이 나오나요?
- 나온다면 그걸로 팔로우가 되나요?
- 계정 목록에 없는 이름으로도 되나요?
확인하고 나서 판단해보세요. 뒷문이 닫힌 걸까요, 아니면 한 칸 옮겨간 걸까요?
그리고 한 칸 옮겨간 것이라면, 그 칸을 막으려면 무엇이 더 있어야 할지 적어보세요. 힌트는 Step 8 마지막 질문에 있습니다.
[탐구] 요구하는 범위를 넓히면
Step 5에서 scope가 read:user user:email 이었는데, 프로필과 이메일만 보겠다는 뜻이었습니다.
socialProviders.github 설정에 scope를 직접 지정할 수 있는데, 저장소 접근 권한을 뜻하는 repo를 넣고 주소를 다시 받아보세요.
주소의 scope가 어떻게 바뀌는지 보시면 예상과 다를 겁니다. 우리가 적은 것으로 바뀔까요, 아니면 다른 일이 일어날까요? 직접 확인하고 무엇이 벌어졌는지 적으세요.
그다음 이걸 생각해보세요. 이 값이 실제로 무슨 일을 하는지는 GitHub 승인 화면에 나타나는데, 사진 공유 앱이 승인 화면에서 저장소 접근을 요구한다면 사용자는 어떻게 반응하고 그게 우리에게 어떤 결과로 돌아올까요?
⚠️ 실험이 끝나면 원래대로 되돌리세요.
생각해볼 주제
1. 즉시 무효화가 필요한 곳과 아닌 곳을 어떻게 가를까요
오늘 우리는 세션 캐시를 안 켜기로 정했는데, 나가기를 누른 순간 끝나야 한다는 이유였죠.
그런데 이건 앱 전체에 대한 결정이라서, 화면마다 다를 수도 있어요. 피드를 보는 것과 계정 설정을 바꾸는 것은 무게가 다르니까요.
전부 아니면 전무가 아닌 방식으로 정한다면 무엇을 기준으로 선을 긋고, 그 기준을 팀에서 공유하려면 어디에 어떻게 적어둬야 새 화면을 만드는 사람이 자기 화면이 어느 쪽인지 스스로 판단할 수 있을까요?
2. 우리 서버가 백엔드에게 자기를 어떻게 증명해야 할까요
Step 8 마지막에 뒷문이 한 칸 옮겨갔다고 했습니다. 아무나 출입증을 받아갈 수 있으니까요.
이걸 막으려면 우리 서버와 백엔드만 아는 무언가가 있어야 하는데, 그 무언가를 어떻게 관리할지가 또 문제예요. 코드에 적어두면 저장소에 올라가고, 환경 변수에 두면 서버가 여러 대일 때 전부에 넣어줘야 하고, 한 번 새면 전부 바꿔야 합니다.
여러분이라면 이 비밀을 어디에 두고, 그게 샜다는 걸 알았을 때 무엇부터 하시겠어요?
3. 45일 동안 배운 것 중 무엇이 가장 오래 갈까요
이 과목에서 다룬 도구들은 앞으로 바뀔 겁니다. 실제로 이 과목을 만드는 동안에도 몇 개는 이름이 바뀌었고, 하나는 통째로 다른 것으로 갈아탔어요.
그런데도 남을 것이 있다면 무엇일까요.
여러분이 생각하기에 도구가 바뀌어도 그대로 쓸 수 있는 것 세 가지를 골라, 왜 그것이 오래 갈 거라고 생각하는지 이유를 적어보세요. 면접에서 "무엇을 할 줄 아세요" 대신 "어떻게 일하세요"를 물었을 때 꺼낼 답이 여기서 나옵니다.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 나가기를 눌러도 안 나가지는 상황 만들기
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 복사본을 나가기 전에 떠뒀는가 | 5 | 나간 뒤에 복사하면 이미 지워진 쿠키를 복사하게 된다 |
끈 상태에서 복사본이 null을 받는 것을 확인했는가 |
6 | 즉시 무효화가 도는 증거 |
| 켠 상태에서 복사본이 세션을 받는 것을 확인했는가 | 6 | 유효기간 안에는 통과한다 |
60초 뒤 다시 재서 null로 바뀌는 것까지 봤는가 |
4 | 영원히 사는 게 아니라 기간 동안만 |
| 나가기 요청이 415·403 으로 막힌 이유를 알아냈는가 | 4 | 응답을 보고 무엇이 빠졌는지 찾았는가 |
| 합계 | 25 |
풀이 예시
먼저 나가기 요청부터인데, 그냥 보내면 막힙니다.
본문 없음 415
본문 {} · 어디서 왔는지 안 밝힘 403
본문 {} · 출처를 밝힘 200
415는 "이 형식은 못 받는다"는 뜻이라 본문과 형식 표시가 필요하다는 신호이고, 403은 출처 검사에 걸린 겁니다. 브라우저는 이 둘을 알아서 붙여주기 때문에 화면에서는 안 보이는 문제예요.
실험 결과입니다.
끈 상태(기본) 켠 상태(maxAge 60)
나간 직후 · 원본 null null
나간 직후 · 복사본 null 세션있음 ← 갈리는 곳
60초 뒤 · 복사본 - null
끈 상태에서는 복사본도 같이 죽습니다. 세션이 저장소에서 지워졌고 서버는 요청마다 저장소를 보니까, 쿠키를 훔쳐가도 소용이 없어요.
켠 상태에서는 복사본이 살아 있습니다. 서버는 그 쿠키 안의 내용을 보고 판단하는데, 그 내용은 저장소와 아무 상관이 없거든요.
사용자에게 설명한다면 이렇게 됩니다.
끈 상태 — "나가기를 누르면 그 순간부터 아무도 그 계정에 못 들어갑니다." 켠 상태 — "나가기를 누르면 이 기기에서는 바로 나가집니다. 다만 누가 이미 쿠키를 가져갔다면 최대 1분 동안은 들어갈 수 있어요."
두 번째 문장을 사용자에게 할 수 있는지가 이 설정을 켤지 말지의 기준입니다.
자주 나오는 실수
같은 항아리로 재기. 나가기를 누른 그 파일로 다시 물어보면 쿠키가 지워졌으니 당연히 null이고, 복사본을 미리 떠두는 게 이 실험의 전부예요.
켠 상태에서 60초를 안 기다리기. "복사본이 계속 통과한다"로 끝내면 절반만 본 것인데, 영원히 사는 게 아니라 정해둔 기간 동안만 살아요. 그 기간이 곧 우리가 감수하기로 한 시간입니다.
💡 튜터의 한마디
이 실험이 서버 재시작보다 나은 이유는 실제로 일어나는 일이기 때문입니다. 서버가 재시작되는 건 배포할 때뿐이지만, 쿠키가 새어 나가는 건 언제든 일어날 수 있어요.
그리고 여기서 한 가지 더 보셨으면 하는데, 끈 상태에서도 복사본으로 프로필 화면 자체는 열립니다. 문지기는 쿠키가 있는지만 보니까 화면은 로그아웃 상태로 그려지고 팔로우 같은 동작은 막히는데, 지난 시간에 "문지기는 낙관적"이라고 했던 게 이 자리예요. 막는 층이 하나가 아니라는 것이 이 구조의 요점입니다.
🎯 [과제 2 예시답안] 출입증을 사람마다 안 나누면 무슨 일이 생기는지
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 열쇠를 고정 값으로 바꿔 실제로 깨뜨렸는가 | 5 | 보관하는 곳과 꺼내는 곳 둘 다 바꿔야 한다 |
| 재훈이 먼저 눌러 그 칸을 채우는 순서를 지켰는가 | 5 | 순서가 틀리면 증상이 안 나온다 |
| 민지의 팔로우가 안 통하는 것을 수치로 확인했는가 | 6 | 팔로워 수가 안 움직인다 |
| 백엔드가 그 요청을 누구 것으로 봤는지 설명했는가 | 6 | 재훈이 재훈을 팔로우한 것으로 본다 |
| 되돌린 뒤 정상 동작을 다시 확인했는가 | 3 | |
| 합계 | 25 |
풀이 예시
열쇠를 고정하면 모두가 한 칸을 같이 씁니다.
// 일부러 깨뜨린 상태 — 확인이 끝나면 반드시 되돌린다
const held = tokens.get('everyone');
...
tokens.set('everyone', held);
⚠️ 꺼내는 곳만 바꾸면 증상이 안 나옵니다. 보관하는 곳이 여전히 이름별로 담고 있으면 'everyone' 칸은 늘 비어 있어서 매번 새로 받아오게 되고, 오히려 정상처럼 보입니다.
측정 결과입니다.
[정상] 민지가 재훈을 팔로우 → 재훈 팔로워 1241 → 1240 움직임
[깨뜨림] 민지가 재훈을 팔로우 → 재훈 팔로워 1240 → 1240 안 움직임
백엔드 입장에서 무슨 일이 있었는지 보면 이렇습니다.
우리 서버가 보낸 것 Authorization: Bearer <재훈의 출입증>
백엔드가 읽은 것 "이 요청을 보낸 사람은 재훈이다"
요청 내용 "재훈을 팔로우해달라"
백엔드의 판단 재훈이 재훈을 팔로우한다 → 거절
자기 자신은 팔로우할 수 없다는 규칙에 걸린 것이라, 민지가 눌렀는데 재훈이 누른 것으로 처리됐어요.
자주 나오는 실수
순서를 안 지키기. 민지가 먼저 누르면 'everyone' 칸에 민지의 출입증이 들어가서, 그러면 민지가 재훈을 팔로우하는 게 정상적으로 되어 증상이 안 나와요. 재훈이 먼저 그 칸을 채워야 합니다.
팔로워 수의 방향만 보고 판단하기. 팔로우는 누를 때마다 붙었다 떨어졌다 해서, 숫자가 줄어드는 것도 정상이에요. 봐야 할 것은 방향이 아니라 움직였는지입니다.
💡 튜터의 한마디
이 버그가 무서운 이유는 어떤 도구도 안 잡아준다는 겁니다. 타입도 통과하고 빌드도 통과하고 오류도 안 납니다. 우리가 만든 증상은 "안 된다" 였지만, 조건이 조금만 달랐으면 남의 이름으로 성공하는 쪽이 될 수도 있었어요. 민지가 재훈 말고 다른 사람을 팔로우했다면 그냥 200이 나오고, 기록에는 재훈이 한 것으로 남았을 겁니다.
C-4에서 상태를 어디에 담을지 정할 때 "누구의 것인가"를 물었던 것 기억하시나요. 그 질문은 상태 관리에만 해당하는 게 아니었습니다. 사람마다 달라야 하는 값을 한 칸에 담으면, 그 순간부터 신원이 섞입니다.
🎯 [과제 3 예시답안] 뒷문은 정말 닫혔을까
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
| 우리 서버가 무엇을 보내는지 코드에서 확인했는가 | 5 | 이름 하나뿐이다 |
| 같은 요청을 직접 보내 출입증을 받아냈는가 | 6 | |
| 그 출입증으로 실제 동작이 되는 것까지 확인했는가 | 6 | 받는 데서 그치면 절반 |
| 계정 목록에 없는 이름은 막히는 것을 확인했는가 | 4 | 완전히 열려 있는 건 아니다 |
| "닫혔다" 가 아니라 "옮겨갔다" 로 판단했는가 | 4 | |
| 합계 | 25 |
풀이 예시
코드를 먼저 봅니다.
// lib/backend-token.ts 의 issueFor
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
보내는 것은 이름 하나뿐이고, 비밀번호도 서명도 없어요.
그러면 누구나 같은 요청을 보낼 수 있습니다.
{"username":"minji"} → {"success":true,"data":{"accessToken":"access-6", ... }}
그 출입증으로 팔로우 → 200
{"username":"nobody"} → 아이디 또는 비밀번호가 올바르지 않습니다
{"username":"seungwoo"} → 아이디 또는 비밀번호가 올바르지 않습니다
출입증이 그냥 나오고, 그걸로 팔로우도 됩니다.
그래서 답은 한 칸 옮겨갔다입니다.
고치기 전 이름 한 줄로 팔로우가 됐다
고친 뒤 이름 한 줄로 출입증을 받고, 그 출입증으로 팔로우가 된다
한 단계가 늘었을 뿐 통과하는 것은 같지만, 완전히 무의미한 건 아닙니다. 계정 목록에 없는 이름은 막히고, 출입증은 몇 번 쓰면 만료되고, 누가 언제 받아갔는지 백엔드에 기록이 남으니까요.
그 칸을 막으려면
Step 8 마지막 질문에 답이 있었습니다. 우리 서버와 백엔드만 아는 비밀이 하나 필요해요.
지금 우리 서버 ──{username}──> 백엔드 누구든 보낼 수 있다
막으려면 우리 서버 ──{username + 우리만 아는 증표}──> 백엔드
이러면 그 증표를 모르는 쪽은 출입증을 못 받는데, 사용자 한 명 한 명이 아니라 서버가 서버에게 자기를 밝히는 층이에요.
💡 튜터의 한마디
이 과제의 진짜 목적은 "아직 열려 있다"를 발견하는 게 아니라 끝까지 따라가 보는 습관입니다.
뒷문을 닫았다고 선언하고 넘어가기 쉬워요. 팔로우가 401이 됐다고 해도, 한 칸만 더 가보면 아직 열려 있습니다. 보안에서 "막았다"는 대개 "이 경로를 막았다"는 뜻이고, 다른 경로가 있는지는 따로 확인해야 하는 일이에요.
그리고 이건 부끄러운 상태가 아니라, 어디까지 막았고 어디가 아직 열려 있는지 알고 있는 것이 중요해요. 모르고 지나가는 것과 알고 미루는 것은 완전히 다릅니다.
🎯 [과제 4 예시답안] 요구하는 범위를 넓히면
채점 포인트
| 항목 | 배점 | 확인 |
|---|---|---|
scope를 지정하고 주소를 다시 받았는가 |
5 | |
| 교체가 아니라 더해진다는 것을 발견했는가 | 8 | 이 과제의 핵심 |
| 사용자 승인 화면 관점에서 결과를 설명했는가 | 7 | |
| 실험 후 되돌렸는가 | 5 | |
| 합계 | 25 |
풀이 예시
// 실험용 — 확인이 끝나면 되돌린다
github: {
clientId: process.env.GITHUB_CLIENT_ID ?? 'demo-client-id',
clientSecret: process.env.GITHUB_CLIENT_SECRET ?? 'demo-secret',
scope: ['repo'],
},
주소를 다시 받아보면 이렇게 나옵니다.
지정 전 scope = read:user user:email
지정 후 scope = read:user user:email repo ← 더해졌다
되돌린 뒤 scope = read:user user:email
교체가 아니라 더해지는데, repo만 남을 거라고 예상하셨다면 빗나간 거예요.
이건 설계 의도가 있는 동작입니다. 라이브러리가 로그인에 꼭 필요한 최소 범위를 이미 알고 있는데, 사용자를 식별하려면 프로필과 이메일이 있어야 하니까요. 우리가 scope를 지정한다고 그것을 빼버리면 로그인 자체가 망가지기 때문에, 우리 것을 더해주는 방식으로 되어 있어요.
사용자 승인 화면에서는
이 값은 그대로 GitHub 승인 화면에 나타나는데, 사진 공유 앱이 이런 화면을 띄운다고 해보죠.
이 앱이 요청하는 권한
· 프로필 정보 읽기
· 이메일 주소 읽기
· 저장소 전체 접근 ← ?
세 번째 줄에서 대부분 멈추는데, 사진 올리는 앱이 왜 내 코드를 보려고 하지, 하고요.
그 결과가 우리에게 돌아오는 방식은 이렇습니다. 가입 전환율이 떨어지고, 그중 일부는 우리 앱을 의심하게 되는데, 한 번 생긴 의심은 되돌리기 어렵습니다.
여기서 배울 것은 이거예요. 필요한 것만 요구하는 것은 예의가 아니라 이득이라서, 보안 원칙으로 배우는 「최소 권한」이 사용자 화면에서는 전환율 문제로 나타나요. 두 가지가 같은 방향을 가리키는 드문 자리입니다.
자주 나오는 실수
되돌리는 걸 잊기. 실험용 값을 그대로 두고 다음 작업에 들어가면, 나중에 승인 화면을 볼 때 왜 저게 있는지 모르게 됩니다. 실험은 되돌리는 것까지가 한 판이에요.
💡 튜터의 한마디
scope는 오늘 다룬 것 중에 사용자가 유일하게 직접 보는 값입니다.
state도 PKCE도 사용자는 평생 모르고 지나가요. 잘 동작하면 아무 일도 안 일어나지만, scope는 승인 화면에 글자로 뜹니다. 우리가 코드에 적은 것이 사용자 눈에 그대로 보이는 거예요.
그래서 이 값은 기술 결정이면서 동시에 제품 결정입니다. 나중에 기능을 추가할 때 "혹시 몰라서 미리 넓게 받아두자"는 유혹이 있을 텐데, 그게 승인 화면에서 어떻게 보일지 한 번 그려보고 정하시면 좋겠습니다.
🤔 [생각해볼 주제 1] 즉시 무효화가 필요한 곳과 아닌 곳을 어떻게 가를까요
문제 상황 요약
세션 캐시를 켜면 빨라지고 끄면 안전하지만, 이 결정을 앱 전체에 한 번만 내리는 것은 거칠어요. 피드를 보는 것과 계정 설정을 바꾸는 것은 무게가 다르니까요.
튜터의 가이드 및 해설
선을 긋는 기준은 "이 동작이 취소 불가능한가"입니다.
되돌릴 수 있다 피드 보기 · 프로필 열기 · 검색
→ 캐시를 믿어도 된다
되돌리기 어렵다 비밀번호 변경 · 결제 · 탈퇴 · 권한 부여 · 글 삭제
→ 저장소를 직접 본다
시간을 기준으로 삼으면 안 됩니다. "30초 정도면 괜찮겠지"는 사고가 났을 때 아무 근거가 못 되는데, 30초 동안 결제가 몇 건 일어날 수 있는지는 아무도 모르니까요.
구현 관점에서는 기본값을 어느 쪽에 둘지가 진짜 결정인데, 지난 시간에 공개 주소 목록을 만들 때와 같은 이야기예요. 새 화면을 만드는 사람이 아무것도 안 했을 때 어느 쪽으로 기우는지가, 몇 달 뒤 이 앱의 안전을 결정합니다.
그래서 기본은 저장소를 보게 두고 빠르게 갈 화면만 따로 표시하는 쪽을 권하는데, 반대로 하면 새 결제 화면을 만든 사람이 표시를 빼먹었을 때 조용히 열린 채로 배포돼요.
팀에 공유하는 방법은 문서보다 코드가 나은데, 문서는 안 읽히거든요. 저장소를 직접 보는 함수와 캐시를 믿는 함수를 이름으로 갈라두면, 새 화면을 만드는 사람이 둘 중 하나를 고르면서 자연스럽게 그 질문을 만나게 됩니다.
가장 좋은 규칙은 안 지키기가 더 어려운 규칙이에요.
🎯 면접관을 홀리는 핵심 멘트
"세션 캐시를 켤지는 성능 문제가 아니라 '얼마나 오래 틀린 답을 해도 되는가'를 정하는 문제라고 봅니다. 그래서 저는 시간이 아니라 동작의 되돌릴 수 있음을 기준으로 나눕니다. 되돌릴 수 없는 동작은 매번 저장소를 확인하고요. 그리고 기본값은 안전한 쪽에 둡니다. 새 화면을 만드는 사람이 아무것도 안 했을 때 어디로 기우는지가, 문서에 뭐라고 적혀 있는지보다 중요하니까요."
🤔 [생각해볼 주제 2] 우리 서버가 백엔드에게 자기를 어떻게 증명해야 할까요
문제 상황 요약
뒷문을 한 칸 옮겼을 뿐 아직 열려 있습니다. 우리 서버와 백엔드만 아는 비밀이 필요한데, 그 비밀을 어디에 두고 어떻게 관리할지가 새로운 문제가 됩니다.
튜터의 가이드 및 해설
어디에 둘 것인가부터 정리하면 선택지가 셋입니다.
코드에 적는다 저장소에 올라간다. 나간 사람도 계속 볼 수 있다. ✗
환경 변수에 둔다 지금 우리가 세션 열쇠에 쓰는 방식. 실용적이다. △
비밀 보관소에 맡긴다 받아가는 것도 기록되고, 바꿀 때 한 곳만 고친다. ○
가운데가 현실적인 출발점입니다. 우리도 G-1에서 세션 열쇠를 그렇게 뒀지만, 서버가 늘어나면 넣어주는 곳도 늘어나고 한 번 새면 전부 찾아다니며 바꿔야 해요.
세 번째는 그 문제를 옮긴 것인데, 서버는 시작할 때 보관소에서 받아오고 우리는 보관소 한 곳만 관리해요. 대신 보관소에 접근할 자격을 또 관리해야 하니 문제가 사라지는 게 아니라 한 겹 정리되는 겁니다.
샜다는 걸 알았을 때는 순서가 중요합니다.
1 새 비밀을 만들어 함께 받아들이게 한다 (둘 다 통하는 상태를 잠깐 만든다)
2 서버들을 하나씩 새 비밀로 바꾼다 (서비스가 안 멈춘다)
3 옛 비밀을 못 쓰게 막는다
4 그 비밀로 무엇이 있었는지 기록을 훑는다
핵심은 1번을 먼저 하는 것인데, 옛것을 먼저 막으면 그 순간 서비스가 멈춰요. 급한 마음에 순서를 뒤집기 쉬운 자리입니다.
그리고 이 순서가 가능하려면 평소에 두 개를 동시에 받아들일 수 있게 만들어둬야 하는데, 사고가 난 다음에 그 구조를 만들 시간은 없어요. 바꿔본 적 없는 비밀은 필요할 때 못 바꿉니다.
🎯 면접관을 홀리는 핵심 멘트
"비밀을 어디에 두느냐보다 중요한 건 바꿀 수 있게 만들어뒀느냐라고 생각합니다. 새 비밀과 옛 비밀을 동시에 받아들이는 구간이 없으면, 교체하는 순간 서비스가 멈추니까요. 그래서 저는 평소에 한 번은 실제로 바꿔봅니다. 바꿔본 적 없는 비밀은 사고가 났을 때도 못 바꿉니다."
🤔 [생각해볼 주제 3] 45일 동안 배운 것 중 무엇이 가장 오래 갈까요
문제 상황 요약
이 과목에서 다룬 도구는 바뀌는데, 과목을 만드는 동안에도 몇 개는 이름이 바뀌었고 하나는 통째로 다른 것으로 갈아탔어요. 그렇다면 무엇이 남을까요.
튜터의 가이드 및 해설
정답이 있는 질문은 아니지만, 제가 고른다면 이 셋입니다.
하나. 재보고 정하는 순서.
우리는 자동 메모이제이션이 정말 일하는지 셌고, 캐시가 정말 빠른지 쟀고, 사진이 정말 밀리는지 봤는데, 여러 번 예상이 틀렸어요. 안 줄어드는 게 정답인 적도 있었고, 고칠 게 없는 게 답인 적도 있었죠.
도구는 바뀌어도 이 순서는 안 바뀝니다. 새 도구를 만나도 똑같이 하면 되는데, 이건 면접에서 가장 티가 나는 부분이기도 해서, "빠르다고 들었습니다"와 "재봤더니 이렇더라"는 완전히 다르게 들려요.
둘. 경계가 어디에 있는지 묻는 습관.
서버와 클라이언트 사이, 캐시 안과 밖, 우리 앱과 남의 집, 앞문과 뒷문 — 이 과목의 어려운 부분은 대부분 경계였습니다.
새 기술을 만났을 때 "이건 어디서 도는가", "이 값은 어디를 건너가는가", "이 검사는 누가 하는가"를 먼저 묻는 습관이 붙으면, 처음 보는 도구도 빨리 파악하게 됩니다.
셋. 결정을 이유와 함께 남기는 것.
오늘 우리가 cookieCache를 안 켠 것을 코드에 이렇게 적었습니다.
// session.cookieCache 는 켜지 않는다.
// 켜보고 잰 결과, 저장소를 비운 서버에서도 유효기간 동안은 통과했다.
// 우리 앱에는 나가기 버튼이 있고, 나가는 순간 끝나는 쪽이 더 중요하다.
여섯 달 뒤에 이 줄을 보는 사람은 그게 모르고 안 켠 건지 알고 안 켠 건지를 알 수 있는데, 그 차이가 크죠. 모르고 안 켠 거면 켜보고 싶어지고, 알고 안 켠 거면 같은 실험을 반복하지 않아도 되니까요.
코드는 무엇을 하는지 말해주지만 왜 그렇게 정했는지는 안 말해주는데, 그건 우리가 적어야 해요.
🎯 면접관을 홀리는 핵심 멘트
"기술 스택은 계속 바뀌더라고요. 제가 배운 과정 중에도 쓰던 라이브러리가 유지보수 모드로 바뀌어서 다른 것으로 갈아탄 적이 있습니다. 그래서 저는 도구보다 순서를 익히려고 했어요. 재보고 정하고, 왜 그렇게 정했는지 코드에 남기는 순서요. 그렇게 해두면 6개월 뒤에 그 결정을 다시 볼 때 같은 실험을 반복하지 않아도 되고, 팀에 새로 온 사람도 그 줄만 읽고 판단할 수 있습니다."