본문 바로가기
인공지능(AI)

프론트와 백엔드의 완벽한 밀당: JWT 인증 시스템 설계와 실무 보안 가이드

by triz-hong 2026. 3. 14.

프론트와 백엔드의 완벽한 밀당: JWT 인증 시스템 설계와 실무 보안 가이드

백엔드 개발을 공부하다 보면 누구나 한 번쯤 '통곡의 벽'을 만나게 됩니다. 게시판 CRUD(생성/조회/수정/삭제)까지는 어찌어찌 재미있게 만들었는데, '로그인(인증/인가)' 시스템을 붙이는 순간부터 머리가 하얘지기 시작하죠. 앞선 글에서 HTTP 프로토콜은 치명적인 '기억상실증(Stateless)'을 앓고 있다고 말씀드렸습니다. 유저가 로그인을 한 번 하면, 그다음 페이지로 넘어갈 때도 서버가 "아, 아까 로그인한 그 유저구나!" 하고 알아차리게 만들어야만 서비스가 굴러갑니다.

이 기억상실증을 우아하게 해결해 주는 현대 웹 아키텍처의 마법의 통행증, JWT(JSON Web Token)에 대해 아주 딥(Deep)하게 파헤쳐 보려고 합니다. 단순한 개념 설명을 넘어서, 실제 IT 기업의 백엔드 엔지니어들이 밤잠을 설치며 고민하는 토큰 탈취 방어 로직(XSS, CSRF)Redis를 활용한 고도화 전략까지, 실무자의 시선으로 낱낱이 해부해 보겠습니다.

1. 세션(Session)의 한계와 JWT의 등장 배경 (호텔 프론트 vs 마스터키)

과거의 웹사이트들은 대부분 '세션(Session)' 방식을 사용했습니다. 이 방식은 비유하자면 '호텔 프론트의 숙박 장부'와 같습니다. 유저가 로그인하면 서버는 자신의 메모리(장부)에 "1번 유저 홍길동, 현재 접속 중"이라고 적어둡니다. 그리고 유저에겐 '세션 ID'라는 번호표만 쥐여주죠.

유저가 100명일 땐 완벽했습니다. 그런데 서비스가 대박이 나서 동시 접속자가 10만 명이 되고, 서버 컴퓨터를 1대에서 10대로 늘리는 스케일 아웃(Scale-out)을 하게 되면 대참사가 벌어집니다. 1번 서버 장부에 이름을 적고 로그인한 유저가 새로고침을 해서 2번 서버로 접속이 넘어가면, 2번 서버는 "장부에 네 이름이 없는데? 다시 로그인해!"라고 튕겨냅니다. 이 장부를 10대의 서버가 실시간으로 공유하게 만들려면 인프라가 끔찍하게 복잡해집니다.

반면 JWT는 '스스로 증명하는 디지털 스마트 키'입니다. 서버가 장부에 유저의 정보를 기억할 필요가 전혀 없습니다(Stateless). 유저가 직접 자신의 아이디와 권한, 그리고 서버의 도장이 찍힌 토큰을 들고 다닙니다. 서버는 그저 유저가 내민 토큰의 도장이 위조되지 않았는지만 수학적으로 검증하면 끝납니다. 서버를 100대로 늘리든 1000대로 늘리든, 마이크로서비스(MSA) 환경이든 아무런 병목 현상 없이 완벽하게 작동하는 엄청난 확장성을 가지게 된 것이죠.

2. JWT의 3단 구조 해부: 해커가 마음대로 조작할 수 없는 이유

JWT를 뜯어보면 aaaaa.bbbbb.ccccc 처럼 점(.) 두 개를 기준으로 나뉜 세 개의 알파벳 덩어리입니다. 겉보기엔 그저 난수 같지만, 이를 Base64Url로 디코딩해보면 아주 과학적인 3단 구조를 가지고 있습니다.

  • Header (머리): 토큰의 타입(JWT)과 서명을 만들 때 어떤 암호화 해시 알고리즘(주로 HMAC SHA256)을 썼는지 적혀 있습니다.
  • Payload (몸통): 이곳이 핵심입니다. 유저의 ID, 이름, 권한(Role), 토큰 발급 시간(iat), 만료 시간(exp) 등 실제로 클라이언트와 서버가 주고받을 데이터(Claim)가 JSON 형태로 들어 있습니다.
    [실무 주의보🚨] 여기서 수많은 주니어 개발자들이 실수합니다. Payload는 암호화된 것이 아니라 단순히 '인코딩(번역)'된 것입니다. 누구나 디코딩 사이트에 넣으면 안의 내용을 훤히 들여다볼 수 있습니다! 따라서 이곳에 비밀번호나 주민번호 같은 민감한 개인정보는 절대, 네버 넣으면 안 됩니다.
  • Signature (서명): Payload가 훤히 보이는데 해커가 내 권한을 '일반 유저'에서 '최고 관리자(Admin)'로 조작하면 어떡하냐고요? 그걸 막아주는 보안의 꽃이 바로 서명입니다. 서버는 머리와 몸통의 데이터를 합친 뒤, 서버 깊숙한 곳에 숨겨둔 '비밀 키(Secret Key)'를 소금 치듯 뿌려 해싱(Hashing)하여 이 서명을 만듭니다. 만약 해커가 Payload를 단 한 글자라도 조작한다면, 서버가 가진 비밀 키로 검증했을 때 서명값이 완전히 다르게 나오므로 즉각 401 Unauthorized 에러를 뱉고 튕겨냅니다.

3. Access Token과 Refresh Token: 보안과 UX의 두 마리 토끼 잡기

JWT는 완벽해 보이지만 아주 치명적인 단점이 하나 있습니다. 서버가 유저의 상태를 기억하지 않기 때문에, 한 번 발급한 토큰을 서버 마음대로 빼앗거나 강제로 만료시킬 수 없다는 겁니다. 만약 해커가 PC방에서 내 JWT를 훔쳐 갔다면? 토큰 수명이 끝날 때까지 내 계정은 속수무책으로 털리게 됩니다.

그래서 현업에서는 보안 리스크를 줄이기 위해 토큰을 두 개의 역할로 쪼개서 발급합니다.

  • Access Token (출입증): 프론트엔드가 API를 찌를 때마다 Authorization: Bearer [토큰] 헤더에 담아 보내는 진짜 출입증입니다. 해커에게 털려도 피해를 최소화하기 위해 수명을 15분~30분 정도로 아주 짧게 잡습니다.
  • Refresh Token (재발급 교환권): 수명이 2주~1달 정도로 긴 보관용 교환권입니다. 30분이 지나 Access Token이 만료되어 401 에러가 터지면, 프론트엔드는 유저 몰래(Silent Refresh) 조용히 백엔드에 이 교환권을 내밀며 "새 Access Token으로 바꿔주세요"라고 요청합니다. 덕분에 유저는 30분마다 다시 로그인 창을 마주할 필요 없이 매끄러운 사용자 경험(UX)을 누릴 수 있습니다.

4. 창과 방패의 대결: 토큰은 대체 어디에 저장해야 안전할까?

그럼 프론트엔드(React, Vue 등) 개발자는 백엔드가 준 이 귀중한 토큰들을 브라우저의 어디에 보관해야 할까요? 여기서 실무 기술 면접의 단골 질문인 XSSCSRF 해킹 공격에 대한 이해가 등장합니다.

만약 개발하기 편하다고 브라우저의 로컬 스토리지(Local Storage)에 토큰을 저장하면 어떨까요? 이곳은 자바스크립트로 너무나 쉽게 꺼내 쓸 수 있습니다. 만약 게시판에 해커가 악성 스크립트를 숨겨놓고 유저가 그 글을 읽게 만드는 XSS(Cross-Site Scripting) 공격을 당하면, 해커의 코드 한 줄에 로컬 스토리지의 토큰이 통째로 털리게 됩니다.

반대로, 브라우저의 쿠키(Cookie)에 저장하면 XSS는 막을 수 있지만 CSRF(Cross-Site Request Forgery) 공격에 취약해집니다. 유저가 해커가 보낸 피싱 이메일 링크를 무심코 클릭했을 때, 브라우저가 유저도 모르게 쿠키에 담긴 토큰을 싣고 해커가 원하는 행동(예: 송금하기, 비밀번호 변경)을 서버에 요청해 버리는 무서운 해킹입니다.

[현재 업계에서 가장 권장되는 실무 아키텍처]
수명이 짧고 탈취당해도 타격이 적은 Access Token은 자바스크립트의 로컬 변수(메모리)에 잠시 띄워두고, 수명이 길고 치명적인 Refresh Token은 오직 서버와 브라우저만 통신할 수 있고 자바스크립트로는 절대 탈취할 수 없도록 백엔드에서 HttpOnly, Secure, SameSite=Strict 옵션을 떡칠한 쿠키(Cookie)에 구워버리는 방식입니다. 이렇게 하면 프론트엔드와 백엔드가 협력하여 두 가지 공격의 리스크를 가장 완벽하게 틀어막을 수 있습니다.

5. 백엔드 시니어의 비기: Redis와 RTR (Refresh Token Rotation)

이쯤 되면 "아니, 쿠키에 꽁꽁 숨긴 Refresh Token마저 노트북 자체를 해킹당해 털리면 어떡하나요?"라는 의문이 들어야 합니다. 맞습니다. 그래서 CTO급 시니어 개발자들은 RTR(Refresh Token Rotation)Redis(인메모리 DB) 아키텍처를 서버에 추가로 도입합니다.

RTR 기법은 유저가 Refresh Token을 들고 와서 새 Access Token으로 교환해 달라고 할 때, Access Token만 새로 주는 게 아니라 Refresh Token마저 1회용으로 간주하고 완전히 새로운 것으로 발급해 주는 방식입니다. 그리고 이전에 쓰인 토큰은 Redis 데이터베이스의 블랙리스트에 넣어 폐기시켜 버립니다.


# 백엔드(FastAPI 등)의 토큰 재사용 탐지(Token Reuse Detection) 논리 구조

def reissue_tokens(old_refresh_token):
    # 1. Redis에서 이 토큰이 이미 사용된 적 있는 '폐기된 토큰'인지 확인
    if redis.is_blacklisted(old_refresh_token):
        # 🚨 비상 상황! 누군가(해커)가 이미 쓴 교환권을 또 들고 왔다!
        # 정상적인 유저의 토큰이 해커에게 복제되어 탈취당했다는 명백한 증거.
        redis.delete_all_tokens_for_user(user_id) # 해당 유저의 모든 기기 로그아웃 처리
        raise HTTPException(401, "보안 위협이 감지되어 모든 세션이 만료되었습니다. 다시 로그인하세요.")

    # 2. 정상적인 교환이라면, 이전 토큰은 블랙리스트에 올리고 새 토큰 세트를 발급
    redis.add_to_blacklist(old_refresh_token)
    new_access = create_access_token()
    new_refresh = create_refresh_token()
    
    return new_access, new_refresh

'토큰 재사용 탐지(Token Reuse Detection)' 로직이 들어가면, 해커가 유저의 토큰을 훔쳐서 사용하는 순간(혹은 반대로 유저가 다시 로그인하는 순간) 시스템이 이상을 감지하고 해당 계정의 모든 권한을 즉시 차단해 버립니다. 비로소 JWT의 한계를 완벽히 극복하는 안전한 서버가 완성되는 것이죠.

6. 마무리

그저 로그인 화면 하나 구현하는 것뿐인데도 백엔드에서는 이렇게 수많은 보안 이슈(XSS, CSRF)와 아키텍처 고민(RTR, Redis)이 치열하게 돌아가고 있습니다. 프론트엔드와 백엔드가 쿠키와 헤더를 주고받으며 안전하게 통신하는 이 과정을 완벽히 이해한다면, 여러분은 이미 단순한 코더를 넘어 비즈니스를 든든하게 받쳐주는 믿음직한 백엔드 엔지니어로 훌쩍 성장한 것입니다.

기본적인 자체 로그인 로직을 마스터했으니, 다음 글에서는 "카카오로 로그인하기", "구글로 로그인하기" 버튼의 뼈대가 되는 OAuth 2.0 프로토콜과 소셜 로그인 백엔드 로직 설계법을 시원하게 파헤쳐 보겠습니다.