JWT (JSON Web Token) 인증의 필요성
최근 웹 애플리케이션은 단일 서버 구성보다는 여러 서비스가 유기적으로 연결된 분산 환경에서 운영되는 경우가 많습니다. 이러한 환경에서 사용자 인증을 처리할 때 세션(Session) 기반 방식은 여러 제약을 가집니다. 예를 들어, 여러 서버에 걸쳐 사용자의 로그인 상태를 유지하려면 복잡한 세션 공유 메커니즘이 필요하거나, 각 서버에 요청이 전달될 때마다 사용자 정보를 다시 확인해야 하는 오버헤드가 발생할 수 있습니다.
JWT는 이러한 문제를 해결하기 위한 효과적인 대안으로 주목받고 있습니다. JWT는 서버가 사용자 인증 성공 시 고유한 토큰을 발행하고, 이 토큰을 클라이언트(브라우저 등)가 저장했다가 다음 요청 시 서버에 함께 전송하는 방식입니다. 서버는 토큰의 유효성을 검증하여 사용자를 식별하며, 이 과정에서 별도의 세션 저장 공간을 유지할 필요가 없어 무상태(stateless) 인증을 구현할 수 있습니다.
JWT를 사용하면 다음과 같은 이점을 얻을 수 있습니다:
- 무상태성(Stateless): 서버가 클라이언트의 상태를 저장하지 않아도 되므로, 서버 확장성(Scalability)이 향상됩니다.
- 분산 환경 지원: 여러 서버나 마이크로서비스 간에 사용자 인증 정보를 공유하기 용이합니다.
- 보안: 토큰은 서명되어 위변조 여부를 확인할 수 있으며, HTTPS와 함께 사용 시 안전하게 전송됩니다.
- 모바일 친화적: 모바일 애플리케이션에서도 쉽게 적용할 수 있습니다.
Flask 백엔드 구현
Flask 애플리케이션에서 JWT를 사용하여 인증 시스템을 구축하는 방법을 살펴보겠습니다. 먼저 필요한 라이브러리를 설치합니다.
pip install pyjwt Flask
다음은 사용자 로그인 및 토큰 발행, 그리고 발행된 토큰을 검증하는 예시 코드입니다.
from flask import Flask, request, jsonify, current_app
import jwt
from datetime import datetime, timedelta, timezone
import functools
app = Flask(__name__)
# 실제 운영 환경에서는 환경 변수 등을 통해 관리해야 합니다.
app.config['SECRET_KEY'] = 'my_secret_key_for_jwt_signing'
# 로그인 엔드포인트: 사용자 인증 후 JWT 토큰 발행
@app.route("/api/authenticate", methods=["POST"])
def authenticate_user():
if request.method == 'POST':
credentials = request.json
username = credentials.get('username')
password = credentials.get('password')
# 실제로는 데이터베이스에서 사용자 정보를 조회하여 검증합니다.
if username == 'testuser' and password == 'password123':
# 토큰 페이로드 구성: 사용자 식별 정보, 만료 시간 등
payload = {
'user_id': username,
'exp': datetime.now(timezone.utc) + timedelta(minutes=30) # 토큰 30분 만료
}
# JWT 토큰 인코딩 (SECRET_KEY로 서명)
access_token = jwt.encode(payload, current_app.config['SECRET_KEY'], algorithm="HS256")
return jsonify({'message': '인증 성공', 'access_token': access_token})
else:
return jsonify({'message': '유효하지 않은 사용자명 또는 비밀번호입니다.'}), 401
# JWT 토큰이 필요한 엔드포인트에 적용할 데코레이터
def jwt_required(f):
@functools.wraps(f)
def decorated_function(*args, **kwargs):
# 요청 헤더에서 'Authorization: Bearer <token>' 형식으로 토큰 추출
auth_header = request.headers.get('Authorization')
if not auth_header:
return jsonify({'message': '인증 토큰이 없습니다.'}), 401
try:
token_prefix, actual_token = auth_header.split(' ', 1)
if token_prefix.lower() != 'bearer':
raise ValueError("유효하지 않은 토큰 형식입니다. 'Bearer'로 시작해야 합니다.")
# JWT 토큰 디코딩 및 검증 (SECRET_KEY로 서명 검증, 만료 시간 확인)
decoded_payload = jwt.decode(actual_token, current_app.config['SECRET_KEY'], algorithms=["HS256"])
# 요청 객체에 사용자 정보를 추가하여 다음 함수에서 사용 가능하도록 함
request.user_id = decoded_payload['user_id']
except jwt.ExpiredSignatureError:
return jsonify({'message': '토큰이 만료되었습니다. 다시 로그인해주세요.'}), 401
except jwt.InvalidTokenError:
return jsonify({'message': '유효하지 않은 토큰입니다.'}), 401
except ValueError as e:
return jsonify({'message': str(e)}), 400
except Exception as e:
return jsonify({'message': f'토큰 처리 중 오류 발생: {e}'}), 500
return f(*args, **kwargs)
return decorated_function
# 보호된 리소스 엔드포인트 (JWT 토큰이 있어야 접근 가능)
@app.route('/api/protected_resource', methods=['GET'])
@jwt_required
def get_protected_resource():
# request.user_id를 통해 인증된 사용자 정보에 접근
return jsonify({'message': f'{request.user_id}님, 보호된 리소스에 접근했습니다!'})
if __name__ == '__main__':
app.run(debug=True)
Vue.js 프론트엔드 구현
Vue.js 클라이언트에서 백엔드와 통신하고 JWT 토큰을 관리하는 방법을 알아보겠습니다. 토큰 저장을 위해 vue-cookies 라이브러리를 사용하고, HTTP 요청을 위해 axios를 사용하는 것이 일반적입니다. 먼저 필요한 라이브러리를 설치합니다.
npm install vue-cookies axios
main.js 파일에 vue-cookies와 axios를 전역적으로 설정합니다.
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import VueCookies from 'vue-cookies'
import axios from 'axios'
const app = createApp(App)
// Vue 인스턴스에 쿠키 플러그인 등록
app.use(VueCookies)
// axios를 전역 프로토타입으로 등록하여 컴포넌트에서 this.$http로 접근 가능하게 함
app.config.globalProperties.$http = axios;
// API 기본 URL 설정 (백엔드 서버 주소)
app.config.globalProperties.$http.defaults.baseURL = 'http://127.0.0.1:5000'; // Flask 서버 주소
app.mount('#app')
로그인 컴포넌트 예시 (예: LoginComponent.vue)에서 사용자 인증 정보를 전송하고 토큰을 쿠키에 저장합니다.
<!-- LoginComponent.vue -->
<template>
<div>
<h2>로그인</h2>
<input type="text" v-model="username" placeholder="사용자명" /><br />
<input type="password" v-model="password" placeholder="비밀번호" /><br />
<button @click="performLogin">로그인</button>
<p v-if="loginMessage">{{ loginMessage }}</p>
</div>
</template>
<script>
export default {
data() {
return {
username: '',
password: '',
loginMessage: ''
};
},
methods: {
async performLogin() {
try {
const response = await this.$http.post('/api/authenticate', {
username: this.username,
password: this.password
});
const accessToken = response.data.access_token;
// 토큰을 'jwt_access_token'이라는 이름으로 30분간 유효한 쿠키에 저장
this.$cookies.set('jwt_access_token', accessToken, '30m');
this.loginMessage = response.data.message + ' 토큰이 저장되었습니다.';
console.log('로그인 성공:', response.data);
// 로그인 성공 후 다른 페이지로 리다이렉트
// this.$router.push('/dashboard');
} catch (error) {
this.loginMessage = error.response ? error.response.data.message : '로그인 요청 실패';
console.error('로그인 실패:', error);
}
}
}
};
</script>
보호된 리소스에 접근하는 컴포넌트 예시 (예: ProtectedResource.vue)에서는 저장된 토큰을 HTTP 요청 헤더에 포함시켜 전송합니다.
<!-- ProtectedResource.vue -->
<template>
<div>
<h2>보호된 리소스</h2>
<button @click="fetchProtectedData">보호된 데이터 가져오기</button>
<p v-if="protectedData">서버 응답: {{ protectedData }}</p>
<p v-if="errorMessage" style="color: red;">{{ errorMessage }}</p>
</div>
</template>
<script>
export default {
data() {
return {
protectedData: null,
errorMessage: ''
};
},
methods: {
async fetchProtectedData() {
const token = this.$cookies.get('jwt_access_token');
if (!token) {
this.errorMessage = '인증 토큰이 없습니다. 로그인해주세요.';
return;
}
try {
const response = await this.$http.get('/api/protected_resource', {
headers: {
'Authorization': `Bearer ${token}` // 'Bearer ' 접두사를 붙여 토큰 전송
}
});
this.protectedData = response.data.message;
this.errorMessage = '';
console.log('보호된 데이터 로드 성공:', response.data);
} catch (error) {
this.protectedData = null;
this.errorMessage = error.response
? error.response.data.message
: '데이터 로드 실패: 서버 연결 문제';
console.error('보호된 데이터 로드 실패:', error);
// 토큰 만료 등 인증 실패 시 로그인 페이지로 리다이렉트 처리
// if (error.response && (error.response.status === 401 || error.response.status === 403)) {
// this.$router.push('/login');
// }
}
}
}
};
</script>