비밀번호는 어떻게 저장되고, 로그인은 어떻게 유지될까
로그인 폼에 이메일과 비밀번호를 넣고 버튼을 누른다. 잠시 뒤 내 이름이 박힌 화면이 뜨고, 그 뒤로는 페이지를 옮겨 다녀도 계속 "나"로 인식된다. 너무 당연해서 그냥 넘어가지만, 이 짧은 흐름 안에는 백엔드 인증의 거의 모든 핵심이 들어 있다.
이 글은 두 개의 질문을 따라간다. 첫째, 회원가입 때 내 비밀번호는 서버에 어떻게 저장되는가 — 그리고 왜 "서버도 내 비밀번호를 모른다"가 정상인가. 둘째, 로그인 한 번으로 그 뒤의 요청들이 어떻게 계속 나로 인식되는가 — HTTP는 분명 요청마다 남남인데 말이다. 이 둘을 이해하면 "비밀번호 찾기는 왜 재설정만 되고 원래 비번을 안 알려주지?", "로그인은 분명 했는데 왜 자꾸 풀리지?", "토큰을 어디에 저장해야 안전하지?" 같은 질문들이 한 줄기로 꿰어진다.
이메일 + 비밀번호로 직접 가입·로그인하는 전통적인 방식을 기준으로 한다. 소셜 로그인(OAuth)이나 매직 링크 같은 건 다른 글의 몫이다. 여기선 "비밀번호를 어떻게 안전하게 다루고, 로그인 상태를 어떻게 이어가는가"에 집중한다.
1. 회원가입: 비밀번호를 그대로 저장하지 않는다
가장 먼저 분명히 할 것. 제대로 만든 서비스는 당신의 비밀번호를 저장하지 않는다. 비밀번호로부터 계산된 해시(hash) 를 저장할 뿐이다. 그래서 비밀번호를 잊으면 "원래 비번 알려주기"가 아니라 "새 비번으로 재설정"만 가능한 것이다 — 서버도 원래 값을 모르기 때문이다.
왜 그럴까? 만약 비밀번호를 그대로(평문으로) DB에 넣어 두면, DB가 한 번 유출되는 순간 모든 사용자의 비밀번호가 통째로 새어 나간다. 게다가 사람들은 같은 비밀번호를 여러 사이트에 돌려 쓰기 때문에, 한 곳의 유출이 다른 모든 서비스의 계정 탈취로 번진다. 그래서 유출되더라도 원래 비밀번호를 복원할 수 없도록 단방향 함수인 해시로 바꿔 저장한다.
- 1해싱 — 되돌릴 수 없게 변환
비밀번호를 단방향 함수로 고정 길이 지문으로 바꾼다.
해시는 한 방향으로만 계산된다. 같은 입력은 항상 같은 출력을 내지만, 출력에서 입력을 거꾸로 계산할 수는 없다. 그래서 서버는 저장된 해시로부터 원래 비밀번호를 알아낼 방법이 없다.
- 2솔트(salt) — 같은 비번도 다르게
비밀번호마다 무작위 값을 섞어 해싱한다.
솔트가 없으면 같은 비밀번호는 항상 같은 해시가 된다. 그러면 공격자가 미리 계산해 둔 해시 대조표(레인보우 테이블)로 한 방에 뚫린다. 사용자마다 다른 무작위 솔트를 섞으면, 같은
"1234"라도 사람마다 완전히 다른 해시가 나와 이 공격이 무력화된다. 솔트는 해시와 함께 저장해도 안전하다. - 3느린 해시 — 일부러 비싸게
bcrypt · argon2 같은 전용 알고리즘으로 무차별 대입을 늦춘다.
SHA-256같은 일반 해시는 너무 빨라서 공격자가 초당 수억 번 시도할 수 있다. 그래서 비밀번호엔 일부러 느리게 설계된bcrypt,argon2,scrypt를 쓴다. 반복 횟수(cost)를 올리면 한 번 해싱에 수십~수백 ms가 걸리게 만들어, 정상 로그인엔 지장 없지만 대량 추측 공격은 비현실적으로 느려진다.
평문은 메모리에서만 잠깐, DB엔 해시만
users 테이블에 email + hash 저장
해싱·솔팅·cost를 한 번에 처리해 주는 비밀번호 전용 알고리즘. 결과 문자열
$2b$12$... 안에 알고리즘·cost·솔트·해시가 전부 들어 있어, 검증할
때 별도로 솔트를 챙길 필요가 없다. 숫자 12 가 cost(반복 강도)이고,
하드웨어가 빨라지면 이 값을 올려 난이도를 따라 올린다.
정리하면 저장 단계의 목표는 하나다. DB가 통째로 유출되어도 비밀번호 자체는 지켜지게 하는 것. 해시로 되돌릴 수 없게 만들고, 솔트로 대조표 공격을 막고, 느린 알고리즘으로 추측을 비싸게 한다. 이 세 겹이 모여야 "유출은 됐지만 비밀번호는 안전하다"가 성립한다.
2. 로그인: 저장된 해시와 대조
로그인은 회원가입의 거울상이다. 서버는 저장해 둔 비밀번호를 "꺼내서 비교"할 수 없다 — 평문을 안 갖고 있으니까. 대신 들어온 비밀번호를 같은 방식으로 다시 해싱해서, 저장된 해시와 일치하는지 본다.
- 1사용자 조회
이메일로 계정을 찾아 저장된 해시를 가져온다.
이메일이 없으면 여기서 실패다. 단, 에러 메시지는 "이메일이 없습니다"가 아니라 "이메일 또는 비밀번호가 틀렸습니다"처럼 어느 쪽이 틀렸는지 숨기는 게 보안상 낫다. 공격자가 어떤 이메일이 가입돼 있는지 알아내지 못하게 하기 위해서다.
- 2같은 방식으로 재해싱·비교
입력 비밀번호를 저장된 솔트로 다시 해싱해 대조한다.
bcrypt.compare(입력비번, 저장된해시)한 줄이 이걸 다 한다. 저장된 해시 문자열 안의 솔트·cost를 꺼내 입력을 같은 조건으로 해싱한 뒤, 타이밍 공격까지 막는 방식으로 두 해시를 비교한다. 일치하면 통과. - 3통과하면 자격증명 발급
이 시점부터 '로그인된 사용자'를 어떻게 기억할지가 시작된다.
여기까지가 인증(authentication) — "네가 너임을 증명"하는 단계다. 다음 문제는 이걸 어떻게 다음 요청들로 이어가느냐이다. 바로 다음 절의 주제다.
"이메일이 존재하지 않습니다" vs "비밀번호가 틀렸습니다"를 구분해 알려주면, 공격자는 그 차이로 가입된 이메일 목록을 수집할 수 있다(계정 열거 공격). 그래서 로그인 실패는 보통 한 가지 메시지로 통일 한다. 사용자 편의와 보안이 부딪히는 대표적인 지점이다.
3. 핵심 문제: HTTP는 요청마다 남남이다
로그인에 성공했다. 그런데 여기서 근본적인 문제가 생긴다. HTTP는
무상태(stateless) 다. 즉 서버는 기본적으로 직전 요청을 기억하지 못한다. 방금
로그인한 그 사람이, 1초 뒤 GET /my-orders 를 보낸 그 사람과 같은
사람인지를 — HTTP 자체로는 알 길이 없다. 매 요청이 서로 모르는 남남이다.
그래서 인증의 진짜 어려움은 비밀번호 검증이 아니라 "한 번 증명한 신원을 다음 요청들로 어떻게 이어 붙이느냐" 에 있다. 방법은 크게 두 갈래로 나뉜다. 서버가 기억하는 세션 방식과, 증명서를 클라이언트에게 들려 보내는 토큰 (JWT) 방식이다.
- ·로그인 시 서버가 세션을 만들어 저장소(메모리·Redis·DB)에 보관
- ·클라이언트엔 의미 없는 세션 ID만 쿠키로 전달
- ·요청마다 ID로 서버 저장소를 조회해 '누구인지' 확인
- ·로그아웃 = 서버에서 세션 삭제 → 즉시 무효화 쉬움
- ·상태를 서버가 들고 있어 서버 확장 시 저장소 공유 필요
- ·로그인 시 서버가 서명된 토큰을 발급해 클라이언트에 전달
- ·토큰 안에 사용자 ID·만료시각 등이 담겨 있음 (서명으로 위조 방지)
- ·요청마다 토큰의 서명만 검증 → 저장소 조회 없이 무상태
- ·서버가 가볍고 확장 쉬움 (공유 저장소 불필요)
- ·발급된 토큰은 만료 전까지 강제 무효화가 어려움
핵심 트레이드오프는 "상태를 누가 들고 있느냐" 다. 세션은 서버가 들고 있어 무효화(로그아웃·강제 로그아웃)가 쉽지만 저장소를 관리해야 하고, JWT는 클라이언트가 들고 있어 서버가 가볍지만 한 번 발급한 걸 만료 전에 취소하기가 까다롭다. 다음 두 절에서 각각을 따라가 보자.
4. 세션 방식: 서버가 신원을 기억한다
세션의 발상은 단순하다. 서버가 로그인한 사람마다 "보관함" 을 하나씩 만들고, 그 보관함의 열쇠 번호(세션 ID)만 클라이언트에게 쿠키로 쥐여 준다. 이후 요청에 이 번호가 따라오면, 서버는 번호로 보관함을 열어 "아, 42번 사용자구나"를 알아낸다. 민감한 정보는 전부 서버에 있고, 클라이언트는 의미 없는 번호표만 들고 다닌다.
세션 저장소에 sid→user42 기록, 쿠키엔 ID만
브라우저가 쿠키 자동 첨부
sid로 저장소 조회 → user42 확인 후 응답
- 1세션 생성·저장
로그인 성공 시 무작위 세션 ID를 만들고 서버 저장소에 매핑한다.
저장소는 보통
Redis처럼 빠른 인메모리 저장소를 쓴다. 매 요청마다 조회되므로 속도가 중요하고, 서버가 여러 대로 늘어나도 모두가 같은 세션을 보게 하려면 이 저장소를 공유해야 하기 때문이다. - 2쿠키로 세션 ID 전달
Set-Cookie 헤더로 ID를 내려보내면 브라우저가 보관한다.
이후 브라우저는 같은 도메인 요청에 이 쿠키를 자동으로 실어 보낸다. 프론트엔드 코드가 토큰을 직접 챙기지 않아도 되는 이유다. 단, 그래서 CSRF 같은 다른 공격 대비가 필요해진다(아래 6절).
- 3요청마다 저장소 조회
들어온 세션 ID로 보관함을 열어 사용자를 식별한다.
ID가 저장소에 없거나(만료·삭제됨) 위조되면 인증 실패다. 이 "매번 조회"가 세션의 비용이자 강점이다 — 한 번 더 묻기 때문에 언제든 보관함을 없애 즉시 로그아웃시킬 수 있다.
5. JWT 방식: 위조 불가능한 증명서를 들려 보낸다
JWT(JSON Web Token)는 반대 발상이다. 서버가 보관함을 두지 않는 대신, "이 사람은 user42이고 이 시각까지 유효함" 이라고 적은 증명서를 만들어 서버의 비밀키로 서명해 클라이언트에게 통째로 준다. 이후 클라이언트가 이 증명서를 들고 오면, 서버는 저장소를 뒤질 것도 없이 서명만 확인해 진짜인지 판별한다.
- 1구조 — 점 두 개로 나뉜 세 조각
header.payload.signature 형태의 문자열이다.
header는 알고리즘 정보,payload는 사용자 ID·만료시각(exp) 같은 내용,signature는 앞 두 조각을 비밀키로 서명한 값이다. 앞 두 조각은 암호화가 아니라 단순 인코딩(base64)이라 누구나 열어 볼 수 있다 — 비밀을 담는 곳이 아니다. - 2서명 — 내용물이 아니라 봉인
payload를 바꾸면 서명이 깨져 위조가 들통난다.
누군가 payload의
user42를user1(관리자)로 바꿔치기하면, 그 내용으로 다시 서명할 비밀키가 없으므로 서명 검증에서 탈락한다. 그래서 JWT는 내용을 숨기는 게 아니라 변조를 막는 도구다. - 3검증 — 저장소 조회 없이
서버는 서명과 만료만 확인하면 끝, DB를 안 본다.
이게 무상태(stateless)의 의미다. 서버가 토큰을 기억하지 않아도 되니 저장소가 필요 없고, 서버를 여러 대로 늘려도 각자 비밀키만 있으면 독립적으로 검증한다. 대신 한 번 발급한 토큰은 만료될 때까지 유효해서, 중간에 강제로 끊기가 어렵다.
서명된 JWT 발급, 서버는 따로 저장 안 함
클라이언트가 토큰을 직접 첨부
서명·exp만 검증 → 통과
JWT는 서버가 기억하지 않으니, 유출된 토큰을 만료 전에 막을 방법이 마땅찮다. 그래서 현실에선 수명이 짧은 access token(몇 분~몇십 분)과, 그걸 갱신해 주는 refresh token(길고, 서버가 폐기 목록으로 관리) 을 함께 쓴다. access는 무상태로 빠르게 검증하고, 통제가 필요한 refresh만 서버가 들고 있는 식으로 두 방식의 장점을 섞는다.
Authorization 헤더에 토큰을 실을 때 Bearer <토큰> 형식을
쓴다. "이 토큰을 소지한(bearer) 자에게 권한을 준다"는 뜻이라,
토큰 자체가 곧 출입증이다. 그래서 토큰이 새면 그걸 주운 사람이 곧 나처럼 행세할
수 있어, 보관과 만료 관리가 중요하다.
6. 자격증명을 어디에 둘까: 쿠키 보안 옵션
세션 ID든 JWT든, 결국 클라이언트 어딘가에 자격증명을 보관해야 한다. 이걸
어디에 어떻게 두느냐가 보안의 큰 갈림길이다. 가장 안전한 기본값은 쿠키에
담되, 보안 옵션을 거는 것이다 — 자바스크립트로 직접 만지는 저장소
(localStorage)는 스크립트 공격(XSS)에 통째로 노출되기 때문이다.
- ·HttpOnly: JS에서 못 읽음 → XSS로 토큰 탈취 차단
- ·Secure: HTTPS에서만 전송 → 중간 탈취 차단
- ·SameSite: 다른 사이트발 요청엔 제한 → CSRF 완화
- ·브라우저가 자동 첨부 → 프론트가 직접 관리 불필요
- ·대신 CSRF 대비(SameSite/토큰)가 추가로 필요
- ·JS로 자유롭게 읽고 씀 → 다루기 편함
- ·그러나 XSS 한 방이면 통째로 탈취됨 (HttpOnly 불가)
- ·요청마다 코드가 직접 Authorization 헤더에 첨부
- ·쿠키 자동 전송이 없어 CSRF에선 상대적으로 자유
- ·민감한 장기 토큰 보관처로는 권장되지 않음
- 1HttpOnly — 스크립트로부터 숨기기
이 쿠키는 document.cookie로 읽을 수 없게 한다.
사이트에 악성 스크립트가 주입돼도(XSS),
HttpOnly쿠키는 자바스크립트가 읽을 수 없어 토큰을 빼갈 수 없다. 인증 쿠키엔 거의 항상 켜야 하는 옵션이다. - 2Secure — 평문 전송 금지
HTTPS 연결에서만 이 쿠키를 보낸다.
Secure가 켜져 있으면 암호화되지 않은http요청엔 쿠키가 실리지 않아, 네트워크 중간에서 가로채이는 걸 막는다. - 3SameSite — 다른 사이트발 요청 제한
내 사이트가 시작한 요청에만 쿠키를 붙인다.
SameSite=Lax/Strict면, 다른 사이트(악성 페이지)가 사용자의 쿠키를 몰래 실어 내 API로 요청을 보내는 CSRF 를 상당 부분 막는다. 쿠키가 자동 전송된다는 편리함의 뒷면을 메우는 장치다.
XSS 는 내 페이지에 악성 스크립트가 끼어들어 토큰을 훔치거나
행동을 가로채는 공격 — HttpOnly 와 입력 이스케이프로 막는다.
CSRF 는 다른 사이트가 사용자의 쿠키를 이용해 내 API로 의도치
않은 요청을 보내게 하는 공격 — SameSite 와 CSRF 토큰으로 막는다.
쿠키 인증을 쓴다면 이 둘을 각각 대비해야 한다.
7. 로그아웃: 방식에 따라 갈리는 뒷정리
마지막으로 로그아웃. "그냥 토큰 지우면 되는 거 아냐?" 싶지만, 여기서 세션과 JWT의 차이가 다시 한 번 선명하게 드러난다.
- ·서버 저장소에서 해당 세션을 삭제
- ·그 순간 세션 ID는 곧장 무효 → 즉시 차단
- ·관리자가 특정 사용자를 강제 로그아웃하기도 쉬움
- ·'서버가 기억한다'는 성질의 직접적 이득
- ·클라이언트에서 토큰 삭제 = 그쪽만 안 보내는 것
- ·이미 발급된 토큰은 만료 전까진 여전히 유효
- ·즉시 무효화하려면 별도 폐기(블랙)리스트가 필요
- ·그래서 access는 짧게, refresh로 갱신을 통제
JWT에서 "클라이언트의 토큰만 지우는" 로그아웃이 대부분의 경우엔 충분하다 — 그 기기에서 더는 토큰을 안 보내니까. 하지만 토큰이 이미 유출된 상황이라면 그것만으론 부족하다. 유출본은 여전히 만료 전까지 살아 있기 때문이다. 그래서 "강제 로그아웃·계정 정지가 즉시 먹혀야 하는" 서비스일수록 순수 JWT보다 세션, 혹은 짧은 access + 서버가 통제하는 refresh 조합으로 기운다. 로그아웃 하나만 봐도 "상태를 누가 들고 있느냐"는 3절의 트레이드오프가 그대로 되돌아온다.
한 장 요약
- 1회원가입
비밀번호는 솔트 + 느린 해시(bcrypt/argon2)로 변환해 저장. 평문은 안 남긴다.
- 2로그인
입력 비번을 같은 방식으로 재해싱해 저장된 해시와 대조. 실패 메시지는 모호하게.
- 3상태 문제
HTTP는 무상태 → 한 번 증명한 신원을 다음 요청으로 이어 붙여야 한다.
- 4세션
서버가 보관함을 들고 세션 ID만 쿠키로 전달. 무효화가 쉽지만 저장소 관리 필요.
- 5JWT
서명된 증명서를 클라이언트가 소지. 무상태로 가볍지만 강제 폐기가 어렵다.
- 6보관
쿠키 + HttpOnly·Secure·SameSite가 기본값. XSS와 CSRF를 각각 대비.
- 7로그아웃
세션은 서버에서 삭제하면 즉시 끝. JWT는 만료/refresh로 통제.