본문 바로가기

IT

JWT의 AT와 RT, 도대체 뭐가 다른 건가요? 액세스 토큰과 리프레시 토큰 비교

728x90
반응형
728x170

로그인 기능을 만들다 보면 반드시 한 번은 마주치는 두 글자가 있습니다. AT와 RT. 풀어 쓰면 Access Token(액세스 토큰)과 Refresh Token(리프레시 토큰)입니다. 개발자들끼리는 "AT 만료됐네요", "RT로 재발급 받으세요" 하고 아무렇지 않게 말하지만, 처음 듣는 사람 입장에서는 무슨 암호 대화 같습니다.

 

이 글에서는 비개발자도 끝까지 읽을 수 있도록 비유부터 시작해서, 개발자가 실제로 구현할 때 필요한 디테일까지 차근차근 정리해 보겠습니다. 커피 한 잔 준비하시고 편하게 읽어주세요.

먼저 JWT가 뭔지부터

JWT는 JSON Web Token의 약자이고, 발음은 보통 "제이더블유티" 또는 "조트(jot)"라고 합니다. 표준 문서는 RFC 7519입니다.

한 줄로 요약하면 이렇습니다.

 

서버가 "이 사람 신원 확인했음"이라고 도장을 찍어서 사용자에게 발급해 주는 디지털 출입증.

놀이공원 자유이용권 비유

놀이공원에 들어갈 때를 떠올려 보세요.

  1. 매표소에서 신분증을 보여주고 표를 삽니다. (아이디와 비밀번호로 로그인)
  2. 직원이 손목에 팔찌를 채워줍니다. (토큰 발급)
  3. 이후 놀이기구를 탈 때마다 팔찌만 보여주면 됩니다. (API 요청마다 토큰 제출)

핵심은 3번입니다. 놀이기구를 탈 때마다 매표소에 다시 가서 신분증을 보여주지 않습니다. 팔찌 하나로 끝납니다. 서버 입장에서도 마찬가지로, 요청이 올 때마다 데이터베이스를 뒤져서 "이 사람 누구더라" 확인하지 않고 토큰만 검증하면 됩니다. 이게 JWT의 가장 큰 장점입니다.

JWT의 생김새

JWT는 점(.)으로 구분된 세 덩어리로 되어 있습니다.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0IiwibmFtZSI6Ikhvbmciw...
└──────── 헤더 ────────┘ └────────── 페이로드 ──────────┘ └─ 서명 ─┘

각 부분의 역할은 다음과 같습니다.

1번째 Header(헤더) 서명에 사용한 알고리즘, 토큰 타입
2번째 Payload(페이로드) 사용자 식별자, 만료 시각, 권한 등의 클레임
3번째 Signature(서명) 앞의 두 덩어리가 위조되지 않았음을 증명하는 값

 

디코딩하면 이런 JSON이 나옵니다.

// 헤더
{
  "alg": "HS256",
  "typ": "JWT"
}

// 페이로드
{
  "sub": "user-1024",
  "iss": "https://auth.example.com",
  "iat": 1769500000,
  "exp": 1769501800,
  "jti": "8f3c1b9e-...",
  "role": "USER"
}

 

여기서 자주 쓰는 표준 클레임 몇 가지만 알아두면 충분합니다.

  • sub: 토큰의 주체, 보통 사용자 식별자
  • iss: 토큰 발급자
  • exp: 만료 시각 (Unix timestamp)
  • iat: 발급 시각
  • jti: 토큰 고유 ID (중복 사용 탐지에 유용)

중요한 오해 하나: JWT는 암호화가 아닙니다

이건 정말 많은 분들이 착각하는 부분입니다. JWT의 페이로드는 Base64URL로 인코딩되어 있을 뿐, 암호화된 것이 아닙니다. 그러니까 누구든지 토큰 문자열만 있으면 내용을 그대로 읽을 수 있습니다.

# 페이로드 부분만 잘라서 디코딩해 보기
echo 'eyJzdWIiOiJ1c2VyLTEwMjQiLCJyb2xlIjoiVVNFUiJ9' | base64 -d
# {"sub":"user-1024","role":"USER"}

 

그래서 JWT 페이로드에는 주민등록번호, 전화번호, 비밀번호 같은 민감 정보를 절대 넣으면 안 됩니다. 서명이 보장하는 것은 "내용의 기밀성"이 아니라 "내용이 변조되지 않았다는 사실"입니다. 굳이 내용까지 감추고 싶다면 JWE(JSON Web Encryption)라는 별도의 규격을 써야 합니다.

액세스 토큰(AT)이란

액세스 토큰은 실제로 일을 하는 토큰입니다. API를 호출할 때마다 HTTP 헤더에 실려 서버로 전송됩니다.

GET /api/v1/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

 

이 방식을 Bearer 토큰이라고 부르는데, Bearer는 "소지자"라는 뜻입니다. 이름 그대로, 가지고 있는 사람이 곧 주인입니다. 지하철 종이 승차권과 비슷합니다. 승차권을 주웠으면 주운 사람이 탈 수 있습니다. 이 성질이 액세스 토큰의 편리함이자 동시에 위험의 근원입니다.

액세스 토큰의 특징

  • 수명이 짧습니다. 보통 5분에서 30분, 길어야 1시간 정도로 설정합니다.
  • 서버에 상태를 저장하지 않습니다. 서명만 검증하면 되므로 DB 조회가 필요 없습니다.
  • 그래서 만료 전에 강제로 무효화하기가 어렵습니다.

마지막 항목이 핵심입니다. 서버가 토큰 목록을 들고 있지 않으니, 한번 발급된 액세스 토큰은 exp에 적힌 시각이 될 때까지 유효합니다. 탈퇴한 회원의 토큰이든, 해킹당해 유출된 토큰이든 마찬가지입니다. 이 위험을 줄이는 유일하게 깔끔한 방법이 바로 "수명을 짧게 만드는 것"입니다.

리프레시 토큰(RT)이란

그런데 액세스 토큰 수명이 30분이면, 사용자는 30분마다 다시 로그인해야 할까요? 그렇게 만들면 앱 리뷰 별점이 남아나지 않을 겁니다.

그래서 등장하는 것이 리프레시 토큰입니다.

 

리프레시 토큰의 역할은 딱 하나입니다.

 

액세스 토큰이 만료되었을 때, 새 액세스 토큰을 받아오는 것.

 

리프레시 토큰으로는 주문 조회도, 결제도, 게시글 작성도 할 수 없습니다. 오직 인증 서버의 토큰 재발급 엔드포인트에만 사용됩니다. 놀이공원 비유로 돌아가면, 손목 팔찌가 하루짜리라면 리프레시 토큰은 매표소에 보관된 "연간 회원 확인증" 같은 겁니다. 이걸 들고 가면 신분증 없이도 새 팔찌를 받을 수 있습니다.

재발급 요청은 이렇게 생겼습니다

OAuth 2.0(RFC 6749) 표준을 따르면 다음과 같은 형태가 됩니다.

curl -X POST https://auth.example.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=v1.MrkT9s2...(생략)"

 

응답은 대략 이렇습니다.

{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 1800,
  "refresh_token": "v1.QpZ8x0f...(새로 회전된 값)"
}

알아두면 좋은 사실: 리프레시 토큰이 꼭 JWT일 필요는 없습니다

의외로 많이들 모르는 부분입니다. OAuth 2.0 표준에서 리프레시 토큰은 그냥 "불투명한(opaque) 문자열"이어도 됩니다. 오히려 실무에서는 무작위 문자열로 발급하고 서버 DB나 Redis에 저장하는 방식이 더 흔합니다.

 

이유는 간단합니다. 리프레시 토큰은 어차피 서버에서 관리해야 하기 때문입니다. 강제 로그아웃, 기기별 세션 관리, 탈취 탐지 같은 기능은 전부 "서버가 이 토큰을 알고 있어야" 가능합니다. 이미 저장할 거라면 굳이 JWT로 만들어 자체 정보를 담을 이유가 없습니다.

AT vs RT 한눈에 비교

비교 항목  액세스 토큰(AT)  리프레시 토큰(RT)
목적 보호된 리소스(API) 접근 새 액세스 토큰 재발급
사용처 모든 API 서버 인증 서버의 토큰 엔드포인트 하나
전송 빈도 요청마다 매번 액세스 토큰 만료 시에만 (드물게)
일반적 수명 5분 ~ 1시간 며칠 ~ 몇 주
저장 형식 대부분 JWT JWT 또는 불투명 문자열
서버 저장 여부 보통 저장하지 않음 (Stateless) 대부분 저장 (Stateful)
즉시 무효화 어려움 쉬움 (서버에서 삭제)
유출 시 피해 만료 전까지 API 오남용 사실상 계정 장기 탈취
권장 저장 위치 클라이언트 메모리 HttpOnly, Secure 쿠키

 

가장 중요한 줄을 하나만 고르라면 "전송 빈도"입니다. 자주 돌아다니는 것은 유출 위험이 크니 수명을 짧게, 거의 안 돌아다니는 것은 오래 살려도 상대적으로 안전하게. 두 토큰을 나눈 설계 철학이 전부 이 한 줄에 들어 있습니다.

왜 굳이 둘로 나눴을까

토큰을 하나만 쓰면 안 될까요? 이 질문에 대한 답은 결국 트레이드오프입니다.

  • 토큰 수명을 길게 하면: 사용자는 편하지만, 유출됐을 때 오래 악용됩니다.
  • 토큰 수명을 짧게 하면: 안전하지만, 사용자가 계속 로그인해야 합니다.

둘 다 만족시킬 수 없어 보이지만, "자주 쓰는 것"과 "가끔 쓰는 것"을 분리하면 얘기가 달라집니다. 자주 노출되는 액세스 토큰은 짧게, 거의 노출되지 않는 리프레시 토큰은 길게 가져가는 것이죠. 보안과 편의성 사이에서 찾아낸 꽤 영리한 절충안입니다.

실제 동작 흐름

전체 흐름을 순서대로 보면 이렇습니다.

  1. 사용자가 아이디와 비밀번호로 로그인합니다.
  2. 서버가 액세스 토큰(30분)과 리프레시 토큰(14일)을 함께 발급합니다.
  3. 클라이언트는 API 요청마다 Authorization 헤더에 액세스 토큰을 담아 보냅니다.
  4. 30분이 지나 서버가 401 Unauthorized를 응답합니다.
  5. 클라이언트는 리프레시 토큰으로 재발급을 요청합니다.
  6. 서버가 리프레시 토큰의 유효성을 확인하고 새 액세스 토큰을 내려줍니다.
  7. 클라이언트는 실패했던 요청을 새 토큰으로 재시도합니다. 사용자는 아무것도 눈치채지 못합니다.
  8. 14일이 지나 리프레시 토큰마저 만료되면, 그때 비로소 다시 로그인 화면이 뜹니다.

프론트엔드에서는 보통 HTTP 인터셉터로 4번부터 7번까지를 자동화합니다. 이때 반드시 챙겨야 할 것이 동시 요청 처리입니다. API 다섯 개가 동시에 401을 받으면 재발급 요청도 다섯 번 나가버립니다. 리프레시 토큰 로테이션까지 걸려 있다면 여기서 대참사가 벌어집니다. 재발급 요청은 하나만 보내고 나머지는 큐에 대기시켰다가 함께 재시도하도록 구현해야 합니다.

토큰을 어디에 저장해야 하나

실무에서 논쟁이 가장 뜨거운 지점입니다. 정답은 상황에 따라 다르지만, 웹 애플리케이션 기준으로 일반적인 권장안은 다음과 같습니다.

저장 위치  XSS에 안전한가  CSRF에 안전한가  비고
localStorage 취약 안전 JS로 접근 가능해 스크립트 탈취에 노출
sessionStorage 취약 안전 탭 종료 시 사라짐
JS 변수(메모리) 상대적으로 안전 안전 새로고침하면 사라짐
HttpOnly 쿠키 안전 취약 SameSite 속성으로 보완 필요

 

그래서 흔히 쓰이는 조합이 이렇습니다.

  • 액세스 토큰: 자바스크립트 메모리에 보관. 새로고침하면 날아가지만, 어차피 리프레시 토큰으로 곧바로 재발급받으면 됩니다.
  • 리프레시 토큰: HttpOnly, Secure, SameSite 속성을 붙인 쿠키에 저장. 자바스크립트가 아예 접근할 수 없으니 XSS로 훔쳐가기 어렵습니다.
Set-Cookie: refresh_token=v1.MrkT9s2...; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh; Max-Age=1209600

 

Path를 재발급 엔드포인트로 좁혀두면 리프레시 토큰이 다른 요청에 딸려 나가는 일도 막을 수 있습니다. 모바일 앱이라면 iOS의 Keychain, 안드로이드의 EncryptedSharedPreferences 같은 보안 저장소를 사용합니다.

리프레시 토큰 로테이션과 재사용 탐지

리프레시 토큰은 오래 살기 때문에, 유출되면 피해가 큽니다. 이를 막기 위한 표준적인 기법이 리프레시 토큰 로테이션(Refresh Token Rotation)입니다.

 

동작 방식은 단순합니다. 재발급을 해줄 때마다 리프레시 토큰도 새것으로 바꿔주고, 기존 것은 즉시 폐기합니다. 즉 리프레시 토큰은 일회용이 됩니다.

 

여기서 진짜 묘수는 그다음입니다. 이미 폐기된 리프레시 토큰이 다시 들어오면, 이건 명백한 이상 신호입니다.

  • 공격자가 훔친 토큰을 먼저 썼다면, 나중에 정상 사용자의 토큰이 거부됩니다.
  • 정상 사용자가 먼저 썼다면, 나중에 공격자의 토큰이 거부됩니다.

어느 쪽이든 서버는 "누군가 토큰을 복제해서 쓰고 있다"는 사실을 알아챌 수 있습니다. 이때는 해당 토큰 하나만 막는 게 아니라, 같은 로그인에서 파생된 토큰 계보(token family) 전체를 무효화하고 사용자에게 재로그인을 요구하는 것이 정석입니다. 이를 자동 재사용 탐지(automatic reuse detection)라고 부릅니다.

 

IETF가 2025년 1월에 발표한 RFC 9700(OAuth 2.0 보안 모범 사례)은 퍼블릭 클라이언트의 리프레시 토큰에 대해 발신자 제한(sender-constrained) 방식을 적용하거나 리프레시 토큰 로테이션을 사용할 것을 요구하고 있습니다.

 

액세스 토큰에 대해서도 mTLS(RFC 8705)나 DPoP(RFC 9449) 같은 발신자 제한 메커니즘을 사용해 탈취된 토큰의 오남용을 막도록 권고합니다.

실무에서 자주 겪는 문제들

로그아웃을 눌렀는데 왜 아직 접근이 되나요

앞에서 말씀드린 그 문제입니다. 액세스 토큰은 서버가 기억하지 않으므로, 로그아웃 처리는 사실상 "리프레시 토큰 삭제 + 클라이언트에서 액세스 토큰 폐기"입니다. 이미 발급된 액세스 토큰은 만료될 때까지 유효합니다.

 

정말 즉시 차단이 필요하다면 블랙리스트(차단 목록)를 Redis에 두고 jti 값을 대조하는 방법이 있습니다. 다만 이 순간 JWT의 무상태(stateless) 장점은 사라집니다. "액세스 토큰 수명을 충분히 짧게 유지한다"가 대부분의 서비스에서 더 합리적인 선택입니다.

alg가 none인 토큰

과거 여러 JWT 라이브러리에서 문제가 됐던 취약점입니다. 헤더의 alg를 none으로 바꾸고 서명을 지운 토큰을 보냈더니 검증을 통과해 버리는 사례들이 있었습니다. RFC 8725(JWT 모범 사례)는 검증 시 알고리즘을 서버가 명시적으로 지정하고, 토큰 헤더에 적힌 alg 값을 그대로 신뢰하지 말라고 권고합니다.

// 좋지 않은 예: 토큰이 주장하는 알고리즘을 그대로 따름
Jwts.parser().build().parseSignedClaims(token);

// 권장: 허용 알고리즘과 키를 서버가 고정
Jwts.parser()
    .verifyWith(secretKey)
    .sig().add(Jwts.SIG.HS256).and()
    .requireIssuer("https://auth.example.com")
    .build()
    .parseSignedClaims(token);

서버 시계가 안 맞아서 토큰이 만료됐다고 나옵니다

exp와 iat은 절대 시각이므로 서버 간 시계가 어긋나면 멀쩡한 토큰이 거부됩니다. NTP 동기화는 기본이고, 검증 시 몇 초 정도의 clock skew(허용 오차)를 두는 것이 안전합니다.

토큰 만료 시간은 보통 얼마로 잡아야 할까

정해진 정답은 없고, 서비스 성격에 따라 다릅니다. 참고할 만한 일반적인 기준은 다음과 같습니다.

 

서비스 유형  액세스 토큰  리프레시 토큰
금융, 의료 등 고보안 5 ~ 15분 수 시간 ~ 1일
일반 웹 서비스 15 ~ 60분 7 ~ 14일
모바일 앱 30 ~ 60분 30일 이상 (로테이션 필수)

 

모바일 앱의 리프레시 토큰이 유독 긴 이유는 사용자 경험 때문입니다. 30일마다 로그인시키는 앱과 매주 로그인시키는 앱 중에 어느 쪽이 살아남을지는 자명하니까요. 대신 수명이 긴 만큼 로테이션과 기기 바인딩 같은 방어 수단을 반드시 함께 갖춰야 합니다.

정리

길었으니 핵심만 다시 짚어보겠습니다.

  • JWT는 서버가 서명해서 발급하는 디지털 출입증이고, 내용은 암호화되지 않으니 민감 정보를 담으면 안 됩니다.
  • 액세스 토큰은 API를 호출할 때 매번 쓰는 짧은 수명의 토큰입니다.
  • 리프레시 토큰은 액세스 토큰을 재발급받는 용도로만 쓰는 긴 수명의 토큰입니다.
  • 둘을 나눈 이유는 보안과 사용자 편의성 사이의 절충입니다.
  • 액세스 토큰은 메모리에, 리프레시 토큰은 HttpOnly 쿠키나 보안 저장소에 두는 것이 일반적입니다.
  • 리프레시 토큰에는 로테이션과 재사용 탐지를 적용하는 것이 현재의 표준 권고입니다.

비개발자 분들은 딱 하나만 기억하셔도 충분합니다. 액세스 토큰은 매일 쓰는 사원증, 리프레시 토큰은 사원증을 잃어버렸을 때 새로 발급받게 해주는 인사팀 등록 정보. 사원증은 자주 들고 다니니 잃어버릴 위험이 크고, 그래서 유효기간을 짧게 둡니다. 인사팀 정보는 금고 안에 있으니 오래 둬도 괜찮고요.

 

그리고 개발자 분들은 이것 하나만 더 기억해 주세요. 토큰 재발급 로직에서 동시 요청 처리를 빼먹으면, 배포 다음 날 아침에 로그아웃 문의가 쏟아집니다. 경험담입니다.

참고 자료

 

728x90
반응형
그리드형