티스토리 뷰
현대 웹의 필수 요소, JWT 인증을 파헤치다
안녕하세요! 빠르게 변화하는 웹 세상에서 사용자 인증(Authentication)은 서비스의 신뢰성과 보안을 결정짓는 핵심 요소입니다. 특히 모바일 앱, SPA(Single Page Application), 마이크로서비스 아키텍처 등 현대적인 개발 환경에서는 기존의 인증 방식으로는 해결하기 어려운 여러 난관에 부딪히곤 했습니다. 이러한 배경 속에서 강력한 대안으로 떠오른 것이 바로 JWT(JSON Web Token) 인증 방식입니다.
"JWT? 그게 뭔데?"부터 "JWT 동작 원리와 JWT 구현 예시는?" 그리고 "어떻게 JWT 보안 취약점을 관리하고 안전하게 사용할 수 있을까?"까지, 이 가이드는 JWT 토큰의 모든 것을 다룹니다. 비전공자도 이해하기 쉬운 눈높이 설명부터 시작하여, 실제 개발자가 알아야 할 Refresh Token 전략, 그리고 JWT와 세션 인증 비교까지 깊이 있게 파고들 예정입니다.
이 JWT 인증 완벽 가이드를 통해 여러분은 토큰 기반 인증의 세계를 마스터하고, 여러분의 서비스에 JWT를 성공적으로 적용할 수 있는 통찰을 얻으실 수 있을 것입니다. 지금부터 JSON Web Token의 모든 궁금증을 함께 해소해 볼까요?
1. JWT란 무엇인가요? (핵심 개념과 등장 배경)
먼저, JWT 토큰이라는 전문 용어에 대한 부담감을 덜어내고 일상적인 비유를 통해 쉽게 접근해 보겠습니다.
1.1. 왜 JWT가 필요할까요? 세션 인증의 한계
웹 서비스에서 사용자를 인증하는 과정은 마치 클럽 입구에서 신분증을 확인하는 것과 같습니다. 사용자가 "나 로그인했어!"라고 주장할 때, 서버는 "정말 네가 맞는지" 확인해야 합니다. 이 확인 과정이 바로 인증(Authentication)입니다.
과거에는 서버가 사용자를 인증하기 위해 '세션(Session)'이라는 방식을 주로 사용했습니다. 사용자가 로그인하면 서버는 그 사용자를 위한 고유한 세션 정보를 저장하고, 사용자에게는 이 세션에 접근할 수 있는 '열쇠(세션 ID)'를 발급해줍니다. 이후 사용자가 어떤 요청을 할 때마다 이 열쇠를 서버에 제출하면, 서버는 자신의 저장소에서 사용자 정보를 찾아 "아, 이 사람은 로그인된 사람이 맞구나!" 하고 확인합니다.
하지만 이런 세션 방식은 현대 웹 환경에서 다음과 같은 한계에 부딪혔습니다.
- 서버 부담: 사용자가 많아질수록 서버는 더 많은 세션 정보를 저장해야 했고, 이는 서버의 메모리나 저장 공간에 큰 부담이 되었습니다.
- 확장성 문제: 서버를 여러 대 사용하게 되면, 특정 서버에 저장된 세션 정보를 다른 서버가 알지 못해 문제가 발생했습니다. (예: 1번 서버에서 로그인했는데, 다음 요청이 2번 서버로 가면 로그아웃으로 인식)
- 모바일/API 친화성 부족: 웹 브라우저가 아닌 모바일 앱이나 다른 서비스에서 API를 호출할 때는 세션 방식이 불편하거나 비효율적이었습니다.
JWT는 이러한 세션 기반 인증의 한계를 극복하기 위해 등장한 토큰 기반 인증의 한 종류입니다. JWT 토큰은 사용자 식별 정보와 인증 상태에 대한 '클레임(Claim)'을 토큰 자체에 담아 클라이언트(브라우저, 앱 등)에게 넘겨주는 방식입니다.
1.2. JWT: 서명된 디지털 신분증의 원리
JWT는 말 그대로 "JSON 형식으로 된 웹 토큰"입니다. 여기서 '토큰'은 마치 여러분의 '디지털 신분증' 또는 '서명된 자기소개서'와 같습니다. 이 신분증 안에는 여러분이 누구인지, 언제까지 유효한지 등의 정보가 담겨 있습니다.
가장 중요한 차이점은, 이 디지털 신분증(JWT)을 서버가 따로 저장할 필요가 없다는 점입니다. 사용자가 로그인에 성공하면, 서버는 사용자 정보를 담은 JWT 토큰을 생성하여 사용자에게 발급합니다. 사용자는 이 토큰을 받아서 잘 보관하고 있다가, 앞으로 서버에 요청을 보낼 때마다 이 토큰을 함께 보냅니다.
서버는 요청과 함께 전달된 토큰을 받으면, 그 토큰의 시그니처가 유효한지, 만료되지 않았는지 등 토큰의 유효성을 확인합니다. 토큰 안에 담긴 정보를 통해 "이 사람은 누구이고, 로그인 상태가 맞구나!"라고 판단하고 요청을 처리해 줍니다. 서버는 이 과정에서 어떠한 사용자 인증 정보도 자체적으로 저장할 필요가 없습니다. 이를 무상태성(Stateless)이라고 부르며, JWT 인증의 핵심 개념 중 하나입니다.
간단히 요약하자면:
- 기존 방식 (세션): 서버가 사용자 정보를 '기억'하고, 사용자에게 '열쇠'를 줌.
- JWT 방식: 서버는 사용자 정보를 '기억'하지 않고, 사용자에게 '서명된 신분증(JWT)'을 줌. 사용자는 이 신분증을 보여주기만 하면 됨.
이러한 특징 덕분에 JWT는 현대 웹 환경에서 매우 강력하고 유연한 인증 수단으로 자리매김하고 있습니다. 이제 다음 섹션에서는 왜 이 JWT 토큰이 그렇게 유용하게 사용되는지, 그 장점과 활용 사례에 대해 더 자세히 알아보겠습니다.
2. 왜 JWT를 사용해야 할까요? (주요 장점과 활용 사례)
JWT 인증 방식이 기존의 세션 방식의 한계를 극복하기 위해 등장했다는 것은 이제 이해하셨을 겁니다. 그렇다면 구체적으로 어떤 JWT 장점들이 현대 웹 서비스 개발자들의 마음을 사로잡았을까요? 그리고 이 JWT 토큰은 실제로 어떤 곳에 활용될까요?
2.1. JWT의 주요 장점
JWT가 제공하는 여러 이점 중 핵심적인 것들은 다음과 같습니다.
- 1. 무상태성 (Statelessness): 서버가 상태를 기억할 필요가 없다!
- 가장 큰
JWT 장점이자 핵심 특징입니다. 세션 기반 인증에서는 서버가 사용자 로그인 상태(세션)를 메모리나 데이터베이스에 저장해야 했습니다. 반면 JWT는 서버가 사용자의 상태를 전혀 저장하지 않습니다. 클라이언트가JWT 토큰을 매 요청마다 함께 보내면, 서버는 그 토큰의 유효성만을 검증하고, 토큰 내부의 정보(Payload)를 통해 사용자를 식별합니다. - 결과: 서버의 부담이 줄어들고, 서버 확장이 매우 용이해집니다. 어떤 서버로 요청이 들어오든 동일한 토큰 검증 로직만 수행하면 되기 때문입니다.
- 가장 큰
- 2. 확장성 (Scalability): 수평 확장이 너무나 쉬워진다!
- 무상태성과 연결되는 장점입니다. 트래픽이 증가하여 여러 대의 서버를 운영해야 할 때(수평 확장), 세션 방식은 모든 서버가 세션 정보를 공유해야 하는 복잡한 문제를 안고 있습니다. (세션 클러스터링, 공유 저장소 등)
- 하지만 JWT는 각 서버가 독립적으로 토큰을 검증할 수 있으므로, 서버를 단순히 늘리는 것만으로도 서비스의 확장성을 쉽게 확보할 수 있습니다. 이는 클라우드 환경이나 마이크로서비스 아키텍처에 특히 유리합니다.
- 3. 모바일 친화성 및 API 친화성: 모든 클라이언트에서 유연하게!
- 웹 브라우저뿐만 아니라 iOS/Android 앱, 데스크톱 애플리케이션 등 다양한 클라이언트에서 백엔드 API를 호출할 때
JWT 토큰은 매우 효율적인 인증 수단이 됩니다. 토큰은 HTTP 헤더에 쉽게 포함되어 전송될 수 있으며, 플랫폼이나 언어에 구애받지 않습니다. - 세션 쿠키와 달리, JWT는 쿠키 기반의 CORS(Cross-Origin Resource Sharing) 관련 제약으로부터 비교적 자유롭고, 여러 도메인 간의 인증 처리에도 더 유연하게 대처할 수 있습니다.
- 웹 브라우저뿐만 아니라 iOS/Android 앱, 데스크톱 애플리케이션 등 다양한 클라이언트에서 백엔드 API를 호출할 때
- 4. 보안 (Security): 서명으로 위변조 방지!
- JWT는 토큰이 발행될 때 서버의 비밀 키(Secret Key)로 '서명(Signature)'됩니다. 이 서명 덕분에 클라이언트가 토큰을 임의로 변경하거나 위조하더라도 서버는 이를 즉시 감지하고 해당 토큰을 거부할 수 있습니다.
- 물론,
JWT 보안 취약점도 존재하며 이에 대한 관리가 중요하지만, 기본적으로 토큰의 무결성을 보장하는 강력한 메커니즘을 가지고 있습니다.
- 5. 정보 포함 능력 (Self-contained): 필요한 정보를 스스로!
JWT 토큰자체에 필요한 최소한의 사용자 정보(예: 사용자 ID, 권한 정보)를 담을 수 있습니다. 서버는 토큰을 검증하는 것만으로 이 정보를 바로 얻을 수 있으며, 추가적인 데이터베이스 조회가 필요 없을 수 있습니다. 이는 API 호출 성능 향상에 기여합니다.
2.2. JWT의 활용 사례
이러한 JWT 장점 덕분에 JWT 토큰은 현대 웹 및 모바일 애플리케이션의 다양한 곳에서 핵심적인 역할을 수행하고 있습니다.
- API 기반 인증 및 인가 (Authentication & Authorization):
- 가장 기본적인 활용처입니다. 백엔드 API 서버를 구축할 때, 클라이언트(웹 브라우저, 모바일 앱)가 API를 호출하기 전에 JWT를 사용하여 인증하고, 특정 리소스에 대한 접근 권한(Authorization)을 부여합니다.
- 싱글 사인 온 (Single Sign-On, SSO): 한 번의 로그인으로 여러 서비스 이용!
- 여러 개의 관련 서비스가 있는 경우, 사용자가 한 서비스에서 로그인하면 다른 서비스에서도 별도의 로그인 없이 바로 이용할 수 있도록 하는 시스템을 SSO라고 합니다. JWT는 여러 서비스 간에 사용자 인증 정보를 안전하고 효율적으로 공유하는 데 적합합니다.
- 마이크로서비스 아키텍처 (Microservices Architecture): 분산 환경에서의 인증!
- 애플리케이션이 수많은 작은 서비스들로 나누어져 독립적으로 운영되는 마이크로서비스 환경에서, JWT는 각 서비스가 상태를 공유하지 않고 독립적으로 토큰을 검증할 수 있게 하여 인증 처리를 간소화합니다.
- 타사 애플리케이션 권한 부여:
- 페이스북이나 구글 계정으로 다른 웹사이트에 로그인하는 것과 같이, 특정 서비스가 제3의 애플리케이션에 사용자 데이터 접근 권한을 부여할 때 JWT를 활용할 수 있습니다. (주로 OAuth 2.0과 함께 사용됩니다.)
결론적으로, JWT 인증은 서버의 부담을 줄이고 서비스의 확장성을 높이며, 다양한 클라이언트 환경에서 유연하게 작동해야 하는 현대적인 애플리케이션 개발에 있어 매우 강력하고 효과적인 선택지입니다. 다음 섹션에서는 이 강력한 JWT 토큰이 어떤 구조를 가지고 있으며, 어떻게 동작하는지 더 깊이 파고들어 보겠습니다.
3. JWT의 구조와 동작 원리: 헤더, 페이로드, 시그니처 분석
이제 JWT 토큰이 무엇이고 왜 유용한지 이해하셨으니, 이 토큰이 실제로 어떻게 생겼는지 그리고 JWT 동작 원리에 대해 자세히 살펴보겠습니다. JWT는 세 부분으로 나뉘며, 각 부분은 점(.)으로 구분되어 있습니다.
Header.Payload.Signature
각 부분은 Base64Url 방식으로 인코딩(encoding)된 문자열입니다. Base64Url 인코딩은 데이터를 URL 안전한 문자열로 변환하는 방식으로, 암호화(encryption)와는 다릅니다. 이는 데이터를 숨기는 것이 아니라, 안전하게 전송할 수 있는 형태로 만드는 것이 목적입니다.
3.1. 1단계: 헤더 (Header)
헤더는 토큰의 종류와 서명(Signature)을 생성하는 데 사용될 암호화 알고리즘 정보를 담고 있습니다. 일반적으로 다음과 같은 JSON 형태로 구성됩니다.
{
"alg": "HS256",
"typ": "JWT"
}
alg(algorithm): 어떤 암호화 알고리즘으로 토큰을 서명할 것인지를 나타냅니다. 'HS256'은 HMAC SHA256을 의미하며, 많이 사용되는 알고리즘 중 하나입니다.typ(type): 토큰의 타입을 나타냅니다. 여기서는 "JWT"라는 값을 가집니다.
이 JSON 객체가 Base64Url로 인코딩되어 JWT의 첫 번째 부분이 됩니다.
예시: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
3.2. 2단계: 페이로드 (Payload)
페이로드(Payload)는 토큰에 담을 정보, 즉 '클레임(Claim)'들을 담고 있는 부분입니다. 클레임은 사용자 정보나 권한 등 토큰 사용에 필요한 다양한 정보를 JSON 형태로 표현합니다. 페이로드는 크게 세 가지 종류의 클레임으로 나뉩니다.
- 등록된 클레임 (Registered Claims): JWT 자체에서 미리 정의해 둔 클레임들입니다. (예:
iss- 발행자,exp- 만료 시간,sub- 주체 등) - 공개 클레임 (Public Claims): JWT를 사용하는 사람들이 임의로 정의할 수 있는 클레임들입니다. (충돌 방지를 위한 특정 형식 권장)
- 비공개 클레임 (Private Claims): 서버와 클라이언트 간에 협의하여 사용하는 클레임들입니다. (예:
userId,username,role) 주의: 이 클레임들은 Base64Url 인코딩될 뿐, 암호화되지 않으므로 민감한 정보는 담지 않는 것이 중요합니다.
일반적인 페이로드 예시는 다음과 같습니다.
{
"sub": "1234567890",
"name": "Jane Doe",
"admin": true,
"iat": 1516239022,
"exp": 1516242622
}
이 JSON 객체 또한 Base64Url로 인코딩되어 JWT의 두 번째 부분이 됩니다.
예시: eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MjQyNjIyfQ
3.3. 3단계: 시그니처 (Signature)
시그니처는 JWT의 가장 중요한 부분 중 하나로, 토큰의 무결성(Integrity)을 보장하고 위변조 여부를 확인하는 데 사용됩니다. 시그니처는 다음과 같은 과정을 통해 생성됩니다.
- Base64Url로 인코딩된 헤더와 페이로드 문자열을 가져옵니다.
- 이 두 문자열을 점(.)으로 연결합니다.
- 헤더에 명시된 암호화 알고리즘(
alg)과 서버만 알고 있는 비밀 키(Secret Key)를 사용하여, 점으로 연결된 문자열을 해싱(Hashing)합니다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
이렇게 생성된 해시 값이 바로 시그니처입니다. 시그니처는 토큰의 세 번째 부분이 됩니다.
예시: S9c3kZ9rX4f_A8bQ7yT6xV5wU4s2p1o0N_mB-lKjIhGfEdC (가상 시그니처)
3.4. JWT의 동작 원리
이 세 부분이 조합되면 최종 JWT 토큰 문자열이 됩니다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTE2MjQyNjIyfQ.S9c3kZ9rX4f_A8bQ7yT6xV5wU4s2p1o0N_mB-lKjIhGfEdC
이제 이 토큰이 어떻게 사용되는지 JWT 동작 원리를 단계별로 살펴보겠습니다.
- 로그인 요청: 사용자가 아이디와 비밀번호를 입력하여 서버에 로그인 요청을 보냅니다.
- 인증 및 토큰 발급:
- 서버는 사용자 정보를 확인하여 인증합니다.
- 인증에 성공하면, 서버는 사용자 ID, 권한 등 필요한 정보를 담은 페이로드와 암호화 알고리즘 정보가 담긴 헤더를 조합하여
JWT 토큰을 생성합니다. - 이때, 서버만 아는 비밀 키를 사용하여 헤더와 페이로드를 기반으로 시그니처를 생성하고, 이를 토큰에 추가합니다.
- 완성된 JWT를 클라이언트에게 응답으로 보냅니다.
- 토큰 저장: 클라이언트(웹 브라우저, 모바일 앱)는 서버로부터 받은 JWT를 안전한 곳에 저장합니다. (예: Local Storage, Session Storage, HTTP-Only 쿠키, 모바일 앱 보안 저장소)
- 자원 요청: 클라이언트는 이후 보호된 자원(Protected Resource)에 접근해야 할 때마다, 저장해 둔
JWT 토큰을 HTTP 요청의Authorization헤더에Bearer타입으로 함께 보냅니다.Authorization: Bearer <JWT_TOKEN_STRING>
- 토큰 검증:
- 서버는 클라이언트로부터 토큰을 받으면, 먼저 토큰의 시그니처를 검증합니다.
- 시그니처가 유효하다면, 서버는 토큰의 페이로드에서
exp(만료 시간) 및iss(발행자),aud(수신자) 등 등록된 클레임들을 확인하여 토큰이 유효한지 검사합니다. - 이 모든 검증이 통과하면, 서버는 페이로드의 정보를 바탕으로 사용자를 식별하고 요청된 자원에 대한 접근 권한(인가)을 확인한 후, 요청을 처리하고 응답을 보냅니다.
이처럼 JWT 토큰은 그 자체로 사용자 정보를 포함하고 있으며, 서버는 별도로 상태를 저장하지 않고도 토큰의 유효성만을 검증함으로써 사용자를 인증할 수 있습니다. 이는 서버 자원의 효율적인 사용과 확장성을 높이는 데 크게 기여합니다. 다음 섹션에서는 이러한 JWT 인증 플로우를 실제 코드를 통해 살펴보겠습니다.
4. 실전! JWT 인증 플로우와 구현 예시 (Python)
앞서 JWT 동작 원리를 이론적으로 살펴보았습니다. 이제 실제 JWT 구현 예시를 통해 사용자가 로그인하고 JWT 토큰을 발급받아 보호된 자원에 접근하는 전체적인 JWT 인증 플로우를 Python 코드로 구현해보겠습니다. 또한, 토큰의 만료와 관련된 Refresh Token의 개념도 함께 다룹니다.
4.1. JWT 인증 플로우 개요
전체적인 JWT 인증 흐름은 다음과 같습니다.
- 사용자 로그인 (Client → Server): 클라이언트가 사용자 ID와 비밀번호를 서버에 전송합니다.
- 인증 및 JWT 발급 (Server): 서버가 사용자 정보를 확인하고, 인증에 성공하면 Access Token(단기 유효)과 Refresh Token(장기 유효)을 생성하여 클라이언트에 응답합니다.
- 토큰 저장 (Client): 클라이언트는 발급받은 두 토큰을 안전하게 저장합니다.
- API 요청 (Client → Server): 클라이언트는 Access Token을
Authorization헤더에 담아 보호된 API에 요청합니다. - Access Token 검증 및 자원 제공 (Server): 서버는 Access Token의 유효성을 검증하고, 유효하면 요청을 처리 후 응답합니다.
- Access Token 만료 시 갱신 (Client → Server): Access Token이 만료되면, 클라이언트는 Refresh Token을 사용하여 새로운 Access Token을 발급받습니다.
- Refresh Token 검증 및 Access Token 재발급 (Server): 서버는 Refresh Token의 유효성을 검증하고, 유효하면 새로운 Access Token을 발급해 클라이언트에 응답합니다.
4.2. Python으로 JWT 구현 예시
Python에서는 PyJWT 라이브러리를 사용하여 JWT를 쉽게 다룰 수 있습니다. 먼저 라이브러리를 설치합니다.
pip install PyJWT Flask
다음은 JWT 생성 및 검증, 그리고 Refresh Token 로직을 포함한 간단한 예시 코드입니다. 이 코드는 Flask 웹 프레임워크와 함께 사용하는 것을 가정했지만, 핵심 로직은 프레임워크와 독립적으로 동작합니다.
import jwt
import datetime
from datetime import timedelta
from functools import wraps
from flask import Flask, request, jsonify, make_response # Flask 예시를 위해 import
app = Flask(__name__)
# --- JWT 설정 ---
# 실제 환경에서는 훨씬 길고 복잡한 비밀 키를 사용해야 합니다.
# 이 키는 절대로 외부에 노출되어서는 안 됩니다!
SECRET_KEY = "your_very_secret_key_that_no_one_should_know_at_least_32_bytes_long" # 강력한 비밀 키 사용 권장
ALGORITHM = "HS256"
# --- 유저 데이터베이스 (간단한 예시) ---
users = {
"testuser": {"password": "testpassword", "roles": ["user"]},
"adminuser": {"password": "adminpassword", "roles": ["admin", "user"]}
}
# Refresh Token 저장소 (실제로는 데이터베이스에 저장)
# { refresh_token_jti: { user_id: "...", expiration: "..." } }
refresh_tokens_db = {}
# --- JWT 관련 함수 ---
def generate_tokens(user_id, roles):
"""
Access Token과 Refresh Token을 생성합니다.
Access Token은 짧은 유효 기간을, Refresh Token은 긴 유효 기간을 가집니다.
"""
# Access Token 생성 (예: 15분 유효)
access_payload = {
"sub": user_id,
"roles": roles,
"iat": datetime.datetime.utcnow(),
"exp": datetime.datetime.utcnow() + timedelta(minutes=15)
}
access_token = jwt.encode(access_payload, SECRET_KEY, algorithm=ALGORITHM)
# Refresh Token 생성 (예: 7일 유효)
# jti(JWT ID)를 추가하여 Refresh Token 관리를 용이하게 합니다.
refresh_jti = jwt.uuid()
refresh_payload = {
"sub": user_id,
"iat": datetime.datetime.utcnow(),
"exp": datetime.datetime.utcnow() + timedelta(days=7),
"jti": refresh_jti
}
refresh_token = jwt.encode(refresh_payload, SECRET_KEY, algorithm=ALGORITHM)
# Refresh Token을 서버 DB에 저장 (실제로는 DB에 저장해야 함)
refresh_tokens_db[refresh_jti] = {
"user_id": user_id,
"expiration": refresh_payload["exp"],
"token_string": refresh_token # 실제 토큰 문자열도 저장 (필요 시)
}
return access_token, refresh_token
def decode_token(token, verify_expiration=True):
"""
JWT를 디코딩하고 유효성을 검증합니다.
verify_expiration=False로 설정하여 만료된 Refresh Token의 payload를 읽을 수 있도록 합니다.
"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM], options={"verify_exp": verify_expiration})
return payload
except jwt.ExpiredSignatureError:
return {"error": "Token has expired"}
except jwt.InvalidTokenError:
return {"error": "Invalid token"}
# --- 데코레이터: 인증이 필요한 라우트 보호 ---
def token_required(f):
@wraps(f)
def decorated(*args, **kwargs):
token = None
if 'Authorization' in request.headers:
# 'Bearer <token>' 형식에서 <token> 부분 추출
auth_header = request.headers['Authorization']
if auth_header.startswith('Bearer '):
token = auth_header.split(" ")[1]
if not token:
return jsonify({"message": "Access Token is missing!"}), 401
try:
current_user_payload = decode_token(token)
if "error" in current_user_payload:
return jsonify({"message": current_user_payload["error"]}), 401
except Exception as e:
return jsonify({"message": "Access Token is invalid!", "detail": str(e)}), 401
# 요청 처리 함수에 사용자 페이로드 전달
return f(current_user_payload, *args, **kwargs)
return decorated
# --- Flask 라우트 정의 ---
@app.route('/login', methods=['POST'])
def login():
auth = request.json
if not auth or not auth.get('username') or not auth.get('password'):
return make_response('Could not verify', 401, {'WWW-Authenticate': 'Basic realm="Login required!"'})
username = auth.get('username')
password = auth.get('password')
user_info = users.get(username)
if not user_info or user_info['password'] != password:
return make_response('Could not verify', 401, {'WWW-Authenticate': 'Basic realm="Login required!"'})
access_token, refresh_token = generate_tokens(username, user_info['roles'])
return jsonify({
"message": "Login successful!",
"access_token": access_token,
"refresh_token": refresh_token
})
@app.route('/protected', methods=['GET'])
@token_required
def protected_route(current_user_payload):
"""
Access Token이 있어야 접근 가능한 보호된 라우트
"""
return jsonify({
"message": "This is a protected resource!",
"user_id": current_user_payload["sub"],
"user_roles": current_user_payload["roles"]
})
@app.route('/refresh', methods=['POST'])
def refresh_token_route():
"""
Access Token 만료 시 Refresh Token으로 새로운 Access Token을 발급받는 라우트
"""
refresh_token = request.json.get('refresh_token')
if not refresh_token:
return jsonify({"message": "Refresh token is missing!"}), 401
# Refresh Token 자체의 유효성 검증 (만료 여부 등)
# 이때 만료된 토큰의 jti를 읽기 위해 verify_expiration=False로 디코딩을 시도
payload = decode_token(refresh_token, verify_expiration=False)
if "error" in payload:
# Refresh Token이 아예 유효하지 않거나 (서명 오류)
# 만료되었을 경우, DB에서도 삭제하고 재로그인 요청
if payload.get("jti") and payload["jti"] in refresh_tokens_db:
del refresh_tokens_db[payload["jti"]]
return jsonify({"message": f"Refresh token {payload['error']}. Please log in again."}), 401
refresh_jti = payload.get('jti')
if not refresh_jti:
return jsonify({"message": "Refresh token has no JTI."}), 401
# 1. Refresh Token이 우리 DB에 저장된 유효한 토큰인지 확인
stored_token_info = refresh_tokens_db.get(refresh_jti)
if not stored_token_info or stored_token_info["token_string"] != refresh_token:
# DB에 없거나, 토큰 문자열이 일치하지 않으면 (탈취 시도 가능성)
return jsonify({"message": "Invalid or unknown refresh token"}), 401
# Refresh Token이 DB에는 있지만 이미 만료되었는지 다시 한번 확인 (decode_token에서 verify_expiration=False 했으므로)
if datetime.datetime.utcnow() > stored_token_info["expiration"]:
del refresh_tokens_db[refresh_jti] # 만료된 토큰 삭제
return jsonify({"message": "Refresh token has expired. Please log in again."}), 401
user_id = payload['sub']
# Refresh Token이 유효하고 DB에도 있으면 새로운 Access Token 발급
user_roles = users.get(user_id, {}).get('roles', [])
new_access_token, new_refresh_token = generate_tokens(user_id, user_roles)
# 기존 Refresh Token은 사용 후 삭제 (Refresh Token Rotation)
if refresh_jti in refresh_tokens_db:
del refresh_tokens_db[refresh_jti]
return jsonify({
"message": "Access Token refreshed successfully!",
"access_token": new_access_token,
"refresh_token": new_refresh_token
})
if __name__ == '__main__':
app.run(debug=True, port=5000)
4.3. 코드 해설 및 Refresh Token의 중요성
SECRET_KEY: JWT의 시그니처를 생성하고 검증하는 데 사용되는 절대 노출되어서는 안 되는 비밀 키입니다. 실제 환경에서는 환경 변수나 보안 저장소에서 불러와야 하며, 무작위로 생성된 매우 긴 문자열이어야 합니다 (최소 32바이트 이상 권장).generate_tokens함수:access_token과refresh_token두 가지 토큰을 생성합니다.- Access Token: 비교적 짧은 유효 기간(예: 15분)을 가집니다. API 요청 시 사용되며, 탈취되더라도 위험 부담이 적습니다.
- Refresh Token: Access Token보다 훨씬 긴 유효 기간(예: 7일, 30일)을 가집니다. Access Token이 만료되었을 때, 이를 사용하여 새로운 Access Token을 발급받는 데 사용됩니다.
refresh_tokens_db에 저장되어 관리됩니다. Refresh Token Rotation을 위해jti(JWT ID)를 포함하고 서버 DB에 저장 관리합니다.
decode_token함수: 토큰을 디코딩하고 유효성(서명, 만료 시간)을 검증합니다.jwt.ExpiredSignatureError는 토큰이 만료되었을 때 발생합니다.verify_expiration옵션을 통해 만료된 Refresh Token의jti를 읽을 수 있도록 합니다.token_required데코레이터: 특정 라우트에 적용하여, 해당 라우트에 접근하기 전에Authorization헤더의Access Token을 검증하도록 합니다./login라우트: 사용자 로그인 처리 후 Access Token과 Refresh Token을 발급합니다./protected라우트:token_required데코레이터가 적용되어 있어, 유효한 Access Token이 없으면 접근이 거부됩니다./refresh라우트:- 클라이언트가 만료된 Access Token 대신
Refresh Token을 보내어 새로운 Access Token을 요청하는 엔드포인트입니다. - 서버는 받은 Refresh Token이 유효한지(DB에 존재하는지, 만료되지 않았는지 등) 확인합니다.
- 유효하다면, 새로운 Access Token과 Refresh Token을 발급하여 클라이언트에게 보내줍니다.
- Refresh Token Rotation (재사용 방지): 새로운 Access Token을 발급할 때마다 기존 Refresh Token은 무효화하고 새로운 Refresh Token을 발급하여 클라이언트에 전달하는 방식입니다. 만약 탈취된 Refresh Token이 재사용된다면, 서버는 이미 삭제된 토큰임을 인지하고 해당 사용자의 모든 토큰을 무효화(revoked)하여 세션을 강제 종료시킬 수 있습니다.
- 클라이언트가 만료된 Access Token 대신
이 코드를 통해 JWT 인증의 기본적인 흐름과 Refresh Token을 활용한 세션 관리 방법을 이해할 수 있습니다. 하지만 이 예시는 학습용이며, 실제 프로덕션 환경에서는 더 많은 보안 고려사항과 예외 처리, 그리고 Refresh Token 관리 전략이 필요합니다. 다음 섹션에서는 바로 이러한 JWT 보안 취약점과 안전한 사용을 위한 팁에 대해 자세히 알아보겠습니다.
5. JWT 보안 취약점과 안전한 사용을 위한 팁
JWT 인증은 강력하고 유연한 방식이지만, 완벽하지는 않습니다. 잘못 사용하면 심각한 JWT 보안 취약점으로 이어질 수 있으므로, 개발자는 이러한 위험을 이해하고 적절한 방어 전략을 적용해야 합니다. 이 섹션에서는 주요 보안 위협과 이를 방지하기 위한 실질적인 팁을 제시합니다.
5.1. 주요 JWT 보안 취약점
- 토큰 탈취 (Token Theft): XSS, CSRF 공격 위험
- JWT는 주로 클라이언트(브라우저의 로컬 스토리지, 세션 스토리지 등)에 저장됩니다. 만약 웹사이트에 XSS(Cross-Site Scripting) 취약점이 있다면, 공격자는 악성 스크립트를 주입하여 사용자의
JWT 토큰을 탈취할 수 있습니다. Authorization헤더에 JWT를 담는 방식은 쿠키에 담는 방식보다 CSRF(Cross-Site Request Forgery) 공격으로부터는 상대적으로 안전합니다. 하지만 HTTP-Only 쿠키 사용 시 여전히 CSRF에 취약할 수 있습니다.
- JWT는 주로 클라이언트(브라우저의 로컬 스토리지, 세션 스토리지 등)에 저장됩니다. 만약 웹사이트에 XSS(Cross-Site Scripting) 취약점이 있다면, 공격자는 악성 스크립트를 주입하여 사용자의
- 토큰 위변조 및 무단 접근:
SECRET_KEY가 노출되면, 공격자는 쉽게 위조된JWT 토큰을 생성할 수 있습니다. 비밀 키가 약하거나 관리 부실로 노출되면,JWT 인증시스템 전체가 무력화될 수 있습니다.- 또한,
alg(알고리즘)를none으로 설정하고 서명 없이 토큰을 발행하는 경우(alg: none취약점)는 심각한 보안 문제를 야기합니다.
- 만료 시간 관리 부실:
exp(만료 시간)이 너무 길면, 토큰이 탈취되었을 때 공격자가 해당 토큰으로 더 오랜 시간 악용할 수 있습니다.- 만료 시간을 너무 짧게 하면, 사용자 경험이 저하될 수 있으며
Refresh Token과 같은 추가적인 메커니즘이 복잡해질 수 있습니다.
- 페이로드에 민감 정보 포함:
- JWT의 페이로드 부분은 Base64Url 인코딩될 뿐, 암호화되지 않습니다. 즉, 누구나 디코딩하여 내용을 볼 수 있습니다. 따라서 주민등록번호, 비밀번호, 결제 정보 등과 같은 민감한 개인 정보를 페이로드에 직접 담아서는 절대 안 됩니다.
- 토큰 무효화(Invalidation)의 어려움:
JWT 토큰은 발행되면 만료될 때까지 유효합니다. 서버는 토큰을 저장하지 않기 때문에, 특정 토큰을 중간에 강제로 만료시키는 것이 어렵습니다. (예: 사용자가 비밀번호를 변경하거나 관리자가 악성 사용자를 차단했을 때)
5.2. 안전한 JWT 사용을 위한 팁
위에서 언급된 JWT 보안 취약점들을 방지하고 안전하게 JWT 토큰을 사용하기 위한 몇 가지 핵심 팁입니다.
- 강력한
SECRET_KEY사용 및 안전한 관리:SECRET_KEY는 무작위로 생성된 충분히 길고 복잡한 문자열이어야 합니다 (최소 32바이트 이상 권장).- 이 키는 환경 변수, 키 관리 서비스(KMS) 또는 다른 보안 저장소에 저장하고, 코드베이스에 하드코딩하지 마십시오. 정기적인 키 교체 정책을 고려하십시오.
- HTTPS (SSL/TLS) 사용 필수:
- 모든 통신은 반드시 HTTPS를 통해 이루어져야 합니다. HTTP로 통신하면
JWT 토큰이 네트워크 상에서 평문으로 노출되어 탈취될 수 있습니다.
- 모든 통신은 반드시 HTTPS를 통해 이루어져야 합니다. HTTP로 통신하면
- Access Token의 짧은 유효 기간 설정:
- Access Token은 짧은 만료 시간(예: 5분~30분)을 가지도록 설정하여 탈취 시 악용될 수 있는 시간을 최소화합니다.
Refresh Token활용 및 안전한 관리:- 짧은 Access Token의 불편함을 해소하기 위해
Refresh Token을 사용합니다. - Refresh Token은 Access Token보다 훨씬 긴 유효 기간을 가지지만, 훨씬 더 안전하게 관리되어야 합니다.
- Refresh Token은 HTTP-Only 쿠키에 저장하는 것을 강력히 권장합니다. HTTP-Only 쿠키는 JavaScript로 접근할 수 없어 XSS 공격으로부터 보호됩니다.
- Refresh Token은 서버 측 데이터베이스에 저장하여 관리하고, 사용 시마다 서버에서 유효성을 검증해야 합니다.
- Refresh Token Rotation (재사용 방지): 새로운 Access Token을 발급할 때마다 기존 Refresh Token을 무효화하고 새로운 Refresh Token을 발급하여 클라이언트에 전달하는 방식을 사용합니다. 이는 탈취된 Refresh Token의 재사용을 막고 피해를 최소화합니다.
- 짧은 Access Token의 불편함을 해소하기 위해
- 페이로드에 민감 정보 절대 포함 금지:
JWT 토큰의 페이로드는 Base64Url로 인코딩되어 누구나 디코딩할 수 있다는 사실을 항상 기억하십시오. 사용자 ID, 권한 정보 등 비민감 정보를 제외한 어떠한 민감 정보도 직접 저장해서는 안 됩니다.
alg: none취약점 방지:- 서버가
JWT 토큰을 검증할 때, 토큰 헤더의alg값에 관계없이 항상 정해진 강력한 알고리즘(예: HS256, RS256 등)만을 사용하도록 강제해야 합니다.alg: none과 같은 비보안적인 알고리즘을 절대 허용하지 않도록 라이브러리 설정을 확인하고 엄격하게 제한하십시오.
- 서버가
- 토큰 무효화를 위한 블랙리스트/화이트리스트:
- 특정 상황(비밀번호 변경, 로그아웃, 관리자 강제 종료)에서
JWT 토큰을 즉시 무효화해야 할 경우를 대비하여 블랙리스트(Blacklist) 또는 화이트리스트(Whitelist) 메커니즘을 고려할 수 있습니다. 이는JWT 인증의 무상태성 원칙을 일부 희생하는 것이지만, 특정 보안 요구사항을 충족시키기 위해 고려될 수 있습니다.
- 특정 상황(비밀번호 변경, 로그아웃, 관리자 강제 종료)에서
JWT 토큰은 현대 웹 환경에 최적화된 강력한 인증 방식이지만, 보안은 언제나 최우선으로 고려되어야 합니다. 위에 제시된 팁들을 잘 적용하여 안전하고 견고한 JWT 인증 시스템을 구축하시길 바랍니다. 마지막 섹션에서는 JWT와 전통적인 세션 인증 방식을 비교하여, 어떤 상황에 어떤 방식을 선택하는 것이 더 효과적인지 의사 결정에 도움을 드리겠습니다.
6. JWT vs 세션 인증: 어떤 상황에 무엇을 선택해야 할까?
이제 JWT 토큰에 대한 깊은 이해를 바탕으로, 전통적인 세션(Session) 기반 인증 방식과 JWT 인증 방식을 비교하고 각각의 JWT 장점 단점을 분석하여, 여러분의 프로젝트에 어떤 인증 방식이 더 적합할지 결정하는 데 도움을 드리고자 합니다. 이 세션 JWT 비교는 두 기술의 근본적인 차이점과 실무적인 적용 시 고려사항을 중점적으로 다룹니다.
6.1. 세션 기반 인증의 특징
- 동작 방식: 사용자가 로그인하면 서버는 세션 ID를 생성하고 사용자 정보를 저장한 뒤, 클라이언트에게 세션 ID를 쿠키 형태로 발급합니다. 클라이언트는 모든 요청 시 이 쿠키를 서버로 보내며, 서버는 저장된 정보로 사용자를 인증합니다.
- 상태 저장 (Stateful): 서버가 사용자 상태를 '기억'하고 있어야 합니다. (세션 저장소 필요)
- 보안: 세션 ID 자체는 무의미하며, 실제 사용자 정보는 서버에 저장됩니다. CSRF 공격에 취약할 수 있으나(CSRF 토큰 등으로 방어), 세션 ID 탈취 시 세션 하이재킹 위험이 있습니다.
- 확장성: 여러 대의 서버를 사용할 경우, 모든 서버가 세션 정보를 공유해야 하므로 확장이 복잡해집니다.
- 모바일/API 친화성: 웹 브라우저 기반에 최적화되어 있어, 모바일 앱이나 다른 API 클라이언트에서는 사용이 번거로울 수 있습니다.
- 토큰 무효화: 서버가 세션 정보를 직접 관리하므로, 특정 세션을 언제든지 강제로 만료시키거나 무효화하기가 용이합니다.
6.2. JWT 기반 인증의 특징
- 동작 방식: 사용자가 로그인하면 서버는 사용자 정보가 담긴
JWT 토큰을 생성하여 클라이언트에게 발급합니다. 클라이언트는 토큰을 저장하고, 모든 요청 시Authorization헤더에 토큰을 함께 보냅니다. 서버는 토큰의 시그니처와 만료 시간을 검증하여 유효한지 확인하고 토큰 내부 정보로 사용자를 식별합니다. - 무상태 (Stateless): 서버는 사용자 상태를 '기억'할 필요가 없습니다. (세션 저장소 불필요)
- 보안: 토큰 자체가 서명되어 위변조를 방지합니다. 로컬 스토리지 저장 시 XSS 공격에 취약하며, HTTP-Only 쿠키에 저장하는 것이 권장됩니다.
SECRET_KEY노출 시 심각한 보안 문제가 발생합니다. - 확장성: 무상태성 덕분에 서버를 수평 확장하기 매우 용이하며, 분산 환경(마이크로서비스)에 적합합니다.
- 모바일/API 친화성: HTTP 헤더를 통해 토큰을 주고받으므로, 다양한 클라이언트 환경 및 API 기반 통신에 매우 유연하게 대응할 수 있습니다. CORS 관련 제약으로부터 비교적 자유롭습니다.
- 토큰 무효화: 기본적으로 토큰은 만료 시간까지 유효하며, 중간에 강제로 무효화하기 어렵습니다. 이를 위해 블랙리스트/화이트리스트 또는 Refresh Token Rotation과 같은 추가적인 메커니즘이 필요합니다.
6.3. 세션 JWT 비교 및 선택 가이드
| 특징 | 세션 기반 인증 | JWT 기반 인증 |
|---|---|---|
| 상태 관리 | Stateful (서버가 사용자 상태 저장) | Stateless (서버가 사용자 상태 저장 안 함) |
| 확장성 | 복잡한 세션 공유 메커니즘 필요 (어려움) | 서버 수평 확장 용이 (쉬움) |
| 서버 부하 | 세션 데이터 저장 및 관리로 인한 부하 존재 | 토큰 검증만으로, 서버 부하 적음 |
| 데이터베이스 | 세션 저장소 (메모리, DB, Redis 등) 필요 | 일반적으로 불필요 (Refresh Token 관리는 예외) |
| 클라이언트 | 웹 브라우저 (쿠키 기반)에 최적화 | 웹, 모바일 앱, 다양한 API 클라이언트에 모두 적합 |
| 보안 (탈취) | 세션 ID 탈취 시 세션 하이재킹 위험 | JWT 토큰 탈취 시 공격자가 사용자 행세 가능 (단기 Access Token 권장) |
| 보안 (위변조) | 서버 데이터베이스에 의존, 위변조 어려움 | SECRET_KEY 노출 시 위변조 가능 (강력한 키 필수) |
| 토큰 무효화 | 언제든지 강제 무효화 가능 (용이) | 기본적으로 어려움 (블랙리스트, Refresh Token Rotation 필요) |
| 개발 복잡성 | 비교적 간단 (프레임워크가 대부분 처리) | Refresh Token, 블랙리스트 등 추가 고려사항 존재 (복잡성 증가) |
어떤 상황에 무엇을 선택해야 할까요?
- 세션 기반 인증이 적합한 경우:
- 전통적인 웹 애플리케이션: 서버 측 렌더링(SSR) 위주의 웹 애플리케이션이나, 단일 서버/모놀리식 아키텍처에 적합합니다.
- 간단한 프로젝트: 복잡한 분산 환경이나 모바일 앱 연동이 많지 않은 소규모 프로젝트에서는 세션이 더 간단할 수 있습니다.
- 강력한 실시간 토큰 무효화가 필요한 경우: 사용자가 로그아웃하거나 비밀번호를 변경했을 때 즉시 세션을 무효화해야 하는 요구사항이 강하다면 세션이 더 직관적일 수 있습니다.
- JWT 기반 인증이 적합한 경우:
- SPA(Single Page Application) 및 모바일 앱 백엔드: 클라이언트가 웹 브라우저 외에 다양한 형태일 때 API 기반 인증에 매우 효율적입니다.
- 마이크로서비스 아키텍처: 여러 독립적인 서비스들이 유기적으로 연동될 때, 각 서비스가 상태를 공유하지 않고 독립적으로 인증을 처리할 수 있어 시스템의 복잡도를 줄입니다.
- 분산 환경 및 확장성 요구: 트래픽 증가에 대비하여 서버를 수평 확장해야 하는 서비스에 매우 적합합니다.
- CORS 문제 관리 용이: 여러 도메인 간의 인증 처리 시, 세션 쿠키 방식보다 JWT가 CORS 관련 제약으로부터 비교적 자유로워 더 유연하게 대응할 수 있습니다.
- 싱글 사인 온 (SSO): 여러 서비스 간에 사용자 인증 정보를 공유하는 시스템을 구축할 때 효율적입니다.
6.4. 결론: 프로젝트의 요구사항에 맞춰 선택
궁극적으로 JWT 인증과 세션 기반 인증 중 어느 것이 더 우월하다고 단정할 수는 없습니다. 중요한 것은 여러분의 프로젝트가 가진 특정 요구사항과 제약 조건을 면밀히 분석하고, 그에 가장 적합한 방식을 선택하는 것입니다.
- 만약 확장성, 분산 환경, 모바일 및 다양한 클라이언트 지원이 최우선이라면
JWT 토큰이 강력한 선택지가 될 것입니다. - 반면, 단일 서버의 전통적인 웹 서비스, 간편한 토큰 무효화, 그리고 개발 복잡성을 최소화하고 싶다면 세션 기반 인증도 여전히 유효하고 강력한 대안이 될 수 있습니다.
어떤 방식을 선택하든, 보안은 항상 최우선으로 고려되어야 합니다. 선택한 인증 방식의 JWT 보안 취약점이나 세션 취약점을 명확히 이해하고, 적절한 방어 메커니즘을 구축하는 것이 가장 중요합니다.
마무리하며: JWT, 현대 웹 개발의 필수 도구를 마스터하다
지금까지 JWT 인증에 대한 심도 깊은 가이드를 통해 JWT 토큰의 기본 개념부터 JWT 동작 원리, JWT 장점 단점, JWT 구현 예시, JWT 보안 취약점, 그리고 세션 JWT 비교까지 폭넓게 살펴보았습니다. JSON Web Token은 더 이상 선택이 아닌, 현대적인 웹 및 모바일 애플리케이션 개발에서 필수적인 토큰 기반 인증 메커니즘으로 자리 잡았습니다.
이 가이드를 통해 비전공자분들은 JWT가 왜 등장했고 어떻게 동작하는지에 대한 큰 그림을 얻으셨기를 바라며, 개발자분들은 실제 JWT 구현 예시와 Refresh Token 전략, 그리고 JWT 보안 취약점을 통한 안전한 사용법까지 익히는 데 도움이 되셨기를 바랍니다. JWT는 그 유연성과 확장성으로 인해 앞으로도 계속해서 중요한 인증 방식으로 활용될 것입니다.
여러분 스스로 이 지식을 바탕으로 안전하고 견고한 JWT 인증 시스템을 설계하고 구현하며, 현대 웹 개발의 핵심 역량을 강화하시길 응원합니다! 궁금한 점이나 추가적으로 다루었으면 하는 내용이 있다면 언제든지 댓글로 남겨주세요. 감사합니다!
'DEV' 카테고리의 다른 글
| 자바 성능 튜닝 완벽 가이드: 느린 코드를 고속화하는 비법 (초보부터 전문가까지) (0) | 2026.01.24 |
|---|---|
| 엘라스틱 스택 기반의 로그 분석: 비전공자부터 전문가까지, 핵심 가이드 (0) | 2026.01.24 |
| 데이터베이스 샤딩: 대규모 데이터 처리의 한계를 넘어서는 확장성 해법 완벽 가이드 (0) | 2026.01.24 |
| 자바스크립트 비동기 완벽 가이드: Callback, Promise, Async/Await로 마스터하기 (0) | 2026.01.24 |
| 개발자를 위한 대용량 트래픽: 안정적인 서비스 구축 핵심 전략 (1) | 2026.01.24 |
- Total
- Today
- Yesterday
- 성능최적화
- Rag
- 개발생산성
- ElasticSearch
- 시스템아키텍처
- 데이터베이스
- 프롬프트엔지니어링
- llm최적화
- 마이크로서비스
- 직구
- 배민
- 코드생성AI
- 자바AI개발
- 해외
- AI기술
- LLM
- 인공지능
- 업무자동화
- Oracle
- 미래ai
- 오픈소스DB
- 서비스안정화
- spring프레임워크
- Java
- 웹개발
- 로드밸런싱
- springai
- 개발자가이드
- AI
- 펄
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
