바이브 코더를 위한 기초 지식 · Season 1 · 3강
언어: 한국어 · English
‘로그인됨’은 화면에 이름이 뜬다는 뜻이 아니라, 서버가 요청을 받을 때 유효한 로그인 증명을 확인하고 사용자와 연결할 수 있다는 뜻이에요. Cookie는 브라우저가 값을 보관하고 전송하는 방법, session은 여러 요청 사이에 로그인 상태를 이어가는 방식, token은 그 상태나 권한을 증명하거나 가리키는 값이에요.
이번 질문은 ‘로그인 버튼을 눌렀는데 왜 새로고침하거나 다른 브라우저에서는 풀릴까요?’예요. 지난 2강의 frontend/backend/database 구분을 이번에는 ‘누가 로그인 여부를 판정하나?’에 적용해 볼게요.
1. 로그인 화면, 인증, session, cookie, token은 무엇이 다를까요?
- Authentication(인증): 비밀번호, passkey, 신원 제공자 등을 통해 사용자가 누구인지 확인하는 절차예요. 버튼 모양만으로 신원을 확인하지는 못해요.
- Session: 서로 다른 HTTP 요청에 걸쳐 로그인 상태를 이어가는 논리적 관계예요. 서버 저장소에서 ID를 조회하거나 별도로 검증 가능한 token을 사용하는 설계도 있어요.
- Cookie: 서버가 지정한 값을 브라우저가 보관하고 조건에 맞는 요청에 함께 보내는 HTTP 메커니즘이에요. Cookie가 존재한다고 로그인된 것은 아니에요.
- Token: 서버가 검증하는 자격 증명이나 참조값이에요. 무작위 session ID도 token의 일종이고 JWT는 여러 표현 형식 중 하나일 뿐 필수는 아니에요.
- Authorization(인가): 인증된 사용자가 그 일을 할 수 있는지를 판단해요. 로그인했다고 관리자 권한이 생기는 것은 아니며, 10강에서 따로 다룹니다.
판단 규칙: 로그인 표시 대신, 보호된 요청을 서버가 승인하는지(200), 자격 증명이 없을 때 거절하는지(401), 로그아웃한 뒤에도 거절하는지를 확인해요. 아래 실습에서의 status 코드는 학습을 위한 명확한 예시이고 실제 서비스는 정책에 따라 다르게 설계할 수 있어요.
2. 머릿속 구조도: 브라우저는 증거를 옮기고, 서버가 판정해요
- 브라우저 → 서버: 로그인 요청이 도착해요. 실제 서비스에서는 먼저 신원을 검증해야 해요.
- 서버: 확인된 사용자에게 무작위 session ID를 만들고, ID와 사용자·만료시간의 관계를 기록할 수 있어요.
- 서버 → 브라우저:
Set-Cookie응답으로 ID를 알려줘요. 브라우저는 속성에 맞게 보관해요. - 다음 요청: 브라우저가 조건에 맞는
Cookie를 보내요. 서버는 ID가 존재하는지, 만료됐는지, 폐기됐는지를 다시 확인해요. - 서버 → 화면: 유효하면 데이터를 주고 아니면 거절해요. 화면의 상태는 그 결과를 따라야 해요.
| 시점 | 브라우저 | 서버 |
|---|---|---|
| 로그인 직후 | Cookie 보관 | ID와 사용자 연결 |
| 새로고침 | Cookie 재전송 | 세션 재검증 |
| 로그아웃 | Cookie 제거 지시 수신 | 세션 폐기 |
| 만료 뒤 | Cookie가 남을 수 있음 | 요청 거절 |
이것은 서버 저장형 session의 구조도예요. 모든 인증 시스템이 같은 내부 구조를 쓰는 것은 아닙니다. OWASP session 관리 지침은 무작위 ID, 서버 검증, 만료와 폐기를 별개 문제로 다뤄요.
3. 작은 연속 실습: 서버가 결정하는 ‘로그인됨’
아래는 합성 사용자 demo 한 명의 session 흐름만 보여주는 무료 로컬 예제예요. ‘연습용 세션 만들기’를 누르면 누구에게나 session이 생겨요. 따라서 실제 신원 인증도, 상용 로그인도 아니며 그대로 배포하면 안 됩니다. 유료 API·신용카드·계정·DB·진짜 비밀번호는 필요하지 않아요.
- 무료 Node.js가 설치됐는지 터미널에서
node -v로 확인해요. 없다면 공식 사이트에서 설치해요. - 전체 코드를
session-demo.js로 저장해요. 파일명이.txt로 끝나지 않는지 확인하세요. - 터미널에서 그 파일이 들어 있는 폴더로 이동해
node session-demo.js를 실행해요. 서버가 실행 중인 동안 터미널을 닫지 마세요. - 브라우저 주소창에
127.0.0.1:3000/?lang=ko를 입력해요. 내 컴퓨터 안의 서버 주소예요. 열리지 않는다면 터미널 오류나 포트 3000 충돌을 확인하세요. /me링크에서 실패를 확인한 다음 뒤로 가서 ‘연습용 세션 만들기’를 눌러요. 새로고침해도 session이 유효한 동안 상태가 이어지는지 보세요.
// Local teaching demo only; this is NOT a real authentication system.
const http = require('node:http');
const { randomBytes } = require('node:crypto');
const sessions = new Map();
const lifeMs = 2 * 60 * 1000;
const words = {
ko: ['세션 실습', '연습용 세션 만들기', '로그아웃', '서버가 유효한 세션을 찾았어요.', '유효한 세션이 없어요.'],
en: ['Session lab', 'Create demo session', 'Log out', 'Server found a valid session.', 'No valid session found.']
};
function sessionId(req) {
const pair = (req.headers.cookie || '').split('; ').find(v => v.startsWith('sid='));
return pair ? pair.slice(4) : '';
}
http.createServer((req, res) => {
const [path, query = ''] = req.url.split('?');
const params = new URLSearchParams(query);
const lang = params.get('lang') === 'en' ? 'en' : 'ko';
const w = words[lang];
const id = sessionId(req);
const record = sessions.get(id);
const active = !!record && record.until > Date.now();
if (record && !active) sessions.delete(id);
const headers = { 'Cache-Control': 'no-store' };
if (req.method === 'POST' && path === '/login') {
const newId = randomBytes(24).toString('hex');
sessions.set(newId, { userId: 'demo', until: Date.now() + lifeMs });
res.writeHead(303, { ...headers, Location: '/?lang=' + lang,
'Set-Cookie': `sid=${newId}; HttpOnly; SameSite=Lax; Path=/` });
return res.end();
}
if (req.method === 'POST' && path === '/logout') {
sessions.delete(id);
res.writeHead(303, { ...headers, Location: '/?lang=' + lang,
'Set-Cookie': 'sid=; Max-Age=0; HttpOnly; SameSite=Lax; Path=/' });
return res.end();
}
if (req.method === 'GET' && path === '/me') {
res.writeHead(active ? 200 : 401, { ...headers,
'Content-Type': 'application/json; charset=utf-8' });
return res.end(JSON.stringify(active ? { userId: record.userId } : { error: 'not_authenticated' }));
}
if (req.method === 'GET' && path === '/') {
res.writeHead(200, { ...headers, 'Content-Type': 'text/html; charset=utf-8' });
return res.end(`<!doctype html><html lang="${lang}"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${w[0]}</title><main style="font:1rem/1.6 system-ui;max-width:34rem;margin:3rem auto;padding:0 1rem">
<h1>${w[0]}</h1><p role="status">${active ? w[3] : w[4]}</p>
<form method="post" action="/login?lang=${lang}"><button>${w[1]}</button></form>
<form method="post" action="/logout?lang=${lang}"><button>${w[2]}</button></form>
<p><a href="/me">/me (JSON)</a></p></main></html>`);
}
res.writeHead(404, headers);
res.end('Not found');
}).listen(3000, '127.0.0.1', () => console.log('Open 127.0.0.1:3000/?lang=ko or ?lang=en'));
핵심: 서버 메모리의 sessions Map에 sid → demo 사용자와 만료시간을 저장하고, randomBytes(24)로 무작위 ID를 만들어요. /me는 유효하지 않은 ID에 401을 반환해요. HttpOnly는 브라우저 JavaScript가 cookie를 읽지 못하게 하지만 XSS를 없애지는 않아요. SameSite=Lax만으로 CSRF 공격 방어가 완성되는 것도 아니에요.
이 예제는 req.url에서 경로와 query string만 분리해 사용하며 외부 서버로 요청을 보내지 않아요. Cookie에는 사용자의 비밀번호 대신 임의의 ID가 들어 있어요. 종료할 때는 터미널에서 Ctrl+C를 눌러요.
4. 정상·실패·예외를 어떻게 구분할까요?
| 행동 | 예상 결과 | 의미 |
|---|---|---|
세션 생성 전 /me | 401 | 유효한 ID 없음 |
생성 뒤 /me | 200, demo | 서버 ID 확인 |
| 2분 안에 새로고침 | 로그인 상태 유지 | Cookie 재전송 |
시크릿 창의 /me | 401 | 별도 cookie 저장공간 |
2분 뒤 /me | 401 | 서버의 유효기간 끝 |
로그아웃 뒤 /me | 401 | 서버 기록 삭제 |
| 없는 주소로 이동 | 404 | 경로 자체가 없음 |
이 예제의 cookie에는 Max-Age가 없지만, 서버의 session은 생성 2분 뒤 만료돼요. 브라우저가 cookie를 보관하는 시간과 서버가 로그인 상태를 인정하는 시간은 서로 다른 시계예요. 서버를 재시작하면 Map이 비어, cookie가 남았어도 이전 ID는 통하지 않아요.
5. 실제로 망가지는 다섯 가지 방식
- ① 화면 변수만 바꿈:
isLoggedIn = true로 배지만 바뀌고/me는401이라면 인증을 구현한 게 아니에요. - ② Cookie가 요청에 실리지 않음: domain·path·HTTPS 규칙, 다른 origin 요청의
credentials, 서버 CORS 설정을 살펴야 해요.credentials: 'include'도 SameSite 정책을 무시하지는 못해요. - ③ Cookie가 있어도 서버가 거절함: 만료, 로그아웃, 서버 재시작, 저장소 장애를 구분해야 해요. Cookie 존재와 유효한 session은 달라요.
- ④ UI에서만 로그아웃함: 버튼이나 문구만 바꾸지 말고 서버가 기존 session을 폐기했는지, 이전 ID 재사용을 거절하는지 확인해요.
- ⑤ Token을 쉽게 읽히는 곳에 저장함: 스크립트 주입 때 bearer token을 빼앗길 위험을 키울 수 있어요.
HttpOnly, 운영 환경의Secure, CSRF 대응, 서버 검증을 함께 판단해야 해요.
이 실습은 로컬 HTTP라 Secure를 생략했고, 사용자 인증·비밀번호 검증·CSRF 보호·rate limiting·지속 저장소도 없어요. 상용 서비스에는 HTTPS와 검증된 인증 솔루션을 사용하세요. Cookie의 정확한 동작은 MDN Set-Cookie, CSRF 방어의 한계는 OWASP CSRF 지침에서 확인할 수 있어요.
6. AI에게 어떻게 요청해야 할까요?
나쁜 요청: “로그인 버튼 만들고 누르면 프로필 보이게 해줘.”
좋은 요청: “현재 기술 스택과 인증 수단을 먼저 확인해 줘. 자체 인증을 즉흥적으로 만들지 말고 검증된 라이브러리·서비스의 선택 이유를 설명해 줘. 서버가 누구인지 확인하는 경로, cookie/session/token 설계, 새로고침·만료·로그아웃·시크릿 창 동작, 인증 누락 시 401과 권한 부족의 분리, HTTPS cookie 속성, CSRF 대응을 명세해 줘. UI만 바뀐 구현은 완료로 보지 말고 재현 절차를 제시해 줘. 실제 계정·비밀값은 쓰지 마.”
검수할 때는 ‘서버가 로그인 상태를 판정하는 코드는 어디인가요? Cookie가 없어도 통과하나요? 로그아웃한 ID를 다시 쓰면 어떻게 되나요?’라고 물어보세요. 화면이 그럴듯한지보다 훨씬 강한 확인이에요.
7. 되돌리기와 복구
- 실습 종료:
Ctrl+C로 로컬 서버를 멈춰요. 이 예제는 DB나 파일에 session을 기록하지 않아요. - 초기 상태 복구: 서버를 다시 켜면 메모리 Map이 초기화돼요. 새 session을 만들면 다시 시작할 수 있어요.
- 로그인 표시가 틀리다면: cookie부터 무작정 지우지 말고
/me결과를 보세요. 서버는401인데 UI가 로그인이라면 UI와 서버 상태 동기화가 문제예요. - 실제 배포 사고라면: 변경을 안전하게 되돌리되 이미 발급한 session의 무효화 범위, 재로그인 영향, 보안 대응 필요성을 따로 점검해야 해요.
8. 언제 이 방법을 쓰지 말아야 할까요?
계정별 보호 데이터가 없는 정적 포트폴리오는 로그인이 필요하지 않을 수 있어요. 실제 회원가입, 계정 복구, OAuth, passkey가 필요한 제품이라면 이 실습 코드를 확장해 자체 인증 시스템으로 만들지 마세요. 이미 검증된 인증 제공자·프레임워크와 별도의 권한 설계를 사용해야 해요. 이 코드는 오직 개념 확인을 위한 것이에요.
9. 복습 질문과 짧은 해설
- Q. Cookie가 남으면 로그인인가요? 아니에요. 서버가 그 ID의 유효성과 만료·폐기 여부를 검사해야 해요.
- Q.
HttpOnly면 보안이 끝인가요? 아니에요. JavaScript 접근만 제한하므로 HTTPS, CSRF 방어 등이 필요해요. - Q. 시크릿 창에서
401이면 버그인가요? 아니에요. 별도 cookie 공간이므로 정상이에요. - Q. 로그인했다면 관리자 화면도 열 수 있나요? 아니에요. 인증과 인가는 별개의 판단이에요.
10. 이전 수업과 다음 수업
1강 · HTML/CSS/JS에서 원본과 화면을, 2강 · Frontend/Backend/DB에서 데이터의 주인을 구분했어요. 이제 서버의 로그인 판정을 알게 됐어요. 수요일 클리닉 · 새로고침 후 데이터 유실과 클리닉 · 배포 후 새로고침 404는 겉모습은 비슷하지만 원인은 다른 문제예요. 다음 4강에서는 API / JSON / request-response와 대기·실패·재시도를 배워요.