Vue로 SPA를 만들면서 JWT 인증, refresh token, Geolocation API, iframe, jsdiff까지 다뤘다. 인증 흐름을 직접 코드로 따라가며 정리했다.
JWT는 .(dot)으로 구분하며, 3부분으로 나뉘어 있다.
xxxxx.yyyyy.zzzzz
Header.Payload.Signature
| 구성 | 역할 | 설명 |
|---|---|---|
| Header | 봉투 | 어떤 알고리즘으로 암호화했는지 적혀 있음 |
| Payload | 내용물 | 실제 유저 정보가 들어 있음. 누구나 열어볼 수 있어 민감한 정보를 넣으면 안 됨 |
| Signature | 위조 방지 도장 | 정보가 조작되지 않았음을 증명. 서버가 가진 키로 위조 유무를 확인할 수 있음 |
💡 Payload는 단순 인코딩(Base64)이라 디코딩하면 누구나 내용을 볼 수 있다. 비밀번호 같은 민감 정보는 절대 담지 않는다.
1. Client → Server : 로그인 요청
2. Server : 사용자 정보 검증 후 JWT 토큰 발급
3. Client : JWT를 브라우저나 앱에 저장
4. Client → Server : 발급받은 JWT를 header에 담아 API 요청
5. Server : 별도의 DB 조회 없이 JWT에 포함된 서명을 검증 후 응답
장점
단점
| 구분 | Token 방식 | JWT 방식 |
|---|---|---|
| 정보 위치 | 키 자체에는 정보 없음, 서버가 정보 보유 | 키에 정보가 들어있어 서버가 따로 확인 불필요 |
| 인증 방식 | DB 저장 정보를 확인하여 사용자 인증 | 서버가 토큰을 해석해서 사용자 인증 |
| 키 유출 대응 | 번거롭지만 서버에서 Disable 처리하여 대응 가능 | 간단하지만 유출 시 대응하기 힘듦 |
| 사용처 | 로그인 관리가 엄격해야 하는 경우 (은행) | 대규모 트래픽 처리가 필요한 곳 (SNS, 쇼핑몰) |
djangorestframework-simplejwt 사용USE_JWT=True 설정 시 JWT 토큰을 발급 (해당 설정은 djangorestframework-simplejwt 설치를 요구함)1) 패키지 설치
pip install djangorestframework-simplejwt
2) settings.py — 인증 클래스 변경
기존 TokenAuthentication을 주석 처리 후 JWTAuthentication으로 변경한다.
REST_FRAMEWORK = {
# Authentication
'DEFAULT_AUTHENTICATION_CLASSES': [
# 'rest_framework.authentication.TokenAuthentication',
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
# permission
...
}
3) settings.py — INSTALLED_APPS 등록
INSTALLED_APPS = [
'articles',
...
'allauth.socialaccount',
'dj_rest_auth.registration',
'rest_framework_simplejwt',
...
]
4) settings.py — USE_JWT 활성화
dj-rest-auth의 login url을 그대로 활용하기 위해 USE_JWT를 True로 설정한다.
REST_AUTH = {
'REGISTER_SERIALIZER': 'accounts.serializers.CustomRegisterSerializer',
'USE_JWT': True,
}
stores/accounts.js의 logIn 함수를 수정한다.
const logIn = function ({ username, password }) {
axios({
method: 'post',
url: `${API_URL}/accounts/login/`,
data: {
username, password
},
})
.then(res => {
console.log('로그인이 완료되었습니다.')
console.log(res.data)
token.value = res.data.access // access token 저장
user.value = res.data.user // 같이 전달되는 로그인 user 정보 저장
router.push({ name: 'ArticleView' })
})
.catch(err => console.log(err))
}
access, refresh, user 등이 함께 전달된다.settings.py 상단에 있는 SECRET_KEY를 붙여 넣으면 서명 인증(Signature Verification)됨을 확인할 수 있다.Authorization 설정에서 Token이 아닌 Bearer로 변경해야 한다.
stores/articles.js — 게시글 조회
const getArticles = function () {
axios({
method: 'get',
url: `${API_URL}/api/v1/articles/`,
headers: {
// 'Authorization': `Token ${accountStore.token}`
'Authorization': `Bearer ${accountStore.token}`
}
})
.then(res => {
// console.log(res.data)
articles.value = res.data
})
.catch(err => console.log(err))
}
views/CreateView.vue — 게시글 생성
const createArticle = function () {
axios({
method: 'post',
url: `${store.API_URL}/api/v1/articles/`,
data: {
title: title.value,
content: content.value
},
headers: {
// 'Authorization': `Token ${accountStore.token}`
'Authorization': `Bearer ${accountStore.token}`
}
})
.then(res => {
router.push({ name: 'ArticleView' })
})
.catch(err => console.log(err))
}
로그인을 다시 하지 않아도 Access Token을 새로 받을 수 있게 하는 장기 열쇠 같은 것
1. 기본 access token을 활용해서 서버에 요청한다.
2. 여기서 access token이 만료되면 401 에러가 발생한다.
3. 이때 refresh token을 활용해 access token을 재발급 받는다.
├─ 재발급 성공 → 발급받은 access token으로 다시 서버에 요청
└─ 재발급 실패 → refresh token이 만료되어 다시 로그인 해야 함
현재 로그인을 진행하면 refresh token이 빈 값으로 전달되고 있다.
JWT_AUTH_HTTPONLY 설정 기본값이 True로 되어 있기 때문HTTPONLY 설정이 되어 있는 경우 refresh token이 발급되지 않음False로 변경하면 refresh token이 발급됨REST_AUTH = {
'REGISTER_SERIALIZER': 'accounts.serializers.CustomRegisterSerializer',
'USE_JWT': True,
'JWT_AUTH_HTTPONLY': False, # refresh token을 받기 위한 준비 (기본이 True)
}
로그인 시 발급 받은 refresh token을 저장한다 (return도 같이 등록).
const logIn = function ({ username, password }) {
axios({
method: 'post',
url: `${API_URL}/accounts/login/`,
data: {
username, password
},
})
.then(res => {
console.log('로그인이 완료되었습니다.')
console.log(res.data)
token.value = res.data.access // token 저장
user.value = res.data.user // 같이 전달되는 로그인 user 정보 저장
refresh.value = res.data.refresh // refresh token 저장
router.push({ name: 'ArticleView' })
})
.catch(err => console.log(err))
}
현재 access token은 5분, refresh token은 1일로 만료 기한이 설정되어 있다.
access token의 적정 만료 기한
refresh token의 적정 만료 기한
실습 테스트를 위해 임시로 access token(1분) / refresh token(2분)으로 설정한다. (실제 프로젝트에서는 권장 시간으로 설정 필요)
from datetime import timedelta
SIMPLE_JWT = {
# 테스트를 위해 임시로 access: 1분 / refresh: 2분 설정
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=1),
'REFRESH_TOKEN_LIFETIME': timedelta(minutes=2),
}
stores/accounts.js에서 refreshAccessToken 함수를 정의한다.
const refreshAccessToken = function () {
return axios({
method: 'post',
url: `${API_URL}/accounts/token/refresh/`,
data: {
refresh: refresh.value,
}
})
.then(res => {
// console.log(res)
token.value = res.data.access // access 토큰 갱신
return true
})
.catch(err => {
console.log(err)
return false
})
}
catch 메서드가 실행됨refreshAccessToken 함수를 실행하여 access token을 재발급stores/articles.js의 getArticles 메서드 수정:
const getArticles = function () {
axios({
// ...
})
// ...
.catch(err => {
console.log(err)
if (err.response?.status === 401) {
console.log('Access Token 재발급 진행!')
// access token 재발급은 비동기 요청 (promise 객체)
accountStore.refreshAccessToken()
.then(ok => {
// 재발급에 실패한 경우 종료
if (!ok) {
window.alert('다시 로그인이 필요합니다.')
accountStore.logOut() // 기존에 저장된 token을 제거하기 위함
router.push({ name: 'LogInView' })
return
}
// 재발급에 성공한 경우 목록 재요청 진행
axios({
method: 'get',
url: `${API_URL}/api/v1/articles/`,
headers: {
'Authorization': `Bearer ${accountStore.token}`
},
})
.then(res => {
articles.value = res.data
})
})
}
})
}
CreateView도 동일한 로직으로 수정 — 401 발생 시 refreshAccessToken()을 호출하고, 성공 시 게시글 생성 요청을 재요청한다.
const createArticle = function () {
axios({
// ...
})
// ...
.catch(err => {
console.log(err)
if (err.response?.status === 401) {
console.log('Access Token 재발급 진행!')
// access token 재발급은 비동기 요청
accountStore.refreshAccessToken()
.then(ok => {
// 재발급에 실패한 경우 종료
if (!ok) {
window.alert('다시 로그인이 필요합니다.')
accountStore.logOut() // 기존에 저장된 token을 제거하기 위함
router.push({ name: 'LogInView' })
return
}
// 재발급에 성공한 경우 재요청 진행
axios({
method: 'post',
url: `${API_URL}/api/v1/articles/`,
headers: {
'Authorization': `Bearer ${accountStore.token}`
},
data: {
title: title.value,
content: content.value
},
})
.then(res => {
router.push({ name: 'ArticleView' })
})
})
}
})
}
사용자의 현재 위치 정보를 브라우저를 통해 간편하게 얻을 수 있는 인터페이스
→ 지도, 내비게이션, 위치 기반 서비스 등 구현
navigator.geolocation.getCurrentPosition(
SuccessCallBack,
ErrorCallBack,
Options …
)
| 구성 | 설명 |
|---|---|
navigator | 사용자 에이전트의 상태와 신원 정보 |
navigator.geolocation | 장치의 위치 정보에 접근할 수 있는 객체 |
getCurrentPosition() | 사용자의 현재 위치를 불러오는 메서드 |
SuccessCallBack | 위치 정보를 성공적으로 가져왔을 때 실행되는 콜백 |
SuccessCallBack 예시:
function (pos) {
console.log(pos) // GeolocationPosition
// 위도 경도
const { latitude, longitude } = pos.coords
}
<script>
navigator.geolocation.getCurrentPosition(
function (pos) {
const { latitude, longitude } = pos.coords
console.log(`위도: ${latitude}, 경도: ${longitude}`)
// 위도: 37.4800384, 경도: 127.008768
}
)
</script>
loadLocation 함수 정의onMounted 활용<script setup>
import { ref } from 'vue'
const lat = ref(null)
const lng = ref(null)
const error = ref('')
const loadLocation = () => {
navigator.geolocation.getCurrentPosition(
(pos) => {
lat.value = pos.coords.latitude
lng.value = pos.coords.longitude
},
(err) => error.value = err.message
)
}
</script>
<template>
<div>
<button @click="loadLocation">내 위치 가져오기</button>
<p v-if="lat && lng">Lat: {{ lat }}, Lng: {{ lng }}</p>
<p v-if="error">{{ error }}</p>
</div>
</template>
'Inline Frame'의 줄임말
HTML 문서 안에 또 다른 HTML 문서를 삽입하는 태그
외부 페이지를 현재 페이지에 임베드(embedded)할 때 사용
iframe 기본 문법
| 속성 | 설명 |
|---|---|
src | 삽입할 문서의 URL |
width, height | iframe의 크기 |
allowfullscreen | 전체화면 전환 허용 여부 |
loading | "lazy" 설정 시 화면에 보여야 할 때 최초 로딩 |
iframe 활용 예시
https://www.youtube.com/embed/<video_id>https://www.google.com/maps?output=embed&q=<keyword>https://calendar.google.com/calendar/embed?src=<ics> 등<iframe
src="https://www.google.com/maps?output=embed"
width="600"
height="400"
allowfullscreen
loading="lazy"
></iframe>
Vue 코드 — 위도·경도로 지도 표시
위도, 경도 정보를 토대로 mapUrl을 작성한다.
<template>
<iframe :src="mapUrl"></iframe>
</template>
<script setup>
// iframe 지도 업데이트
const updateMap = () => {
mapUrl.value =
`https://maps.google.com/maps` +
`?ll=${lat.value},${lng.value}` +
`&z=14` +
`&output=embed`
}
</script>
사용자 입력 값을 query로 활용:
<template>
<input v-model="keyword" @input="updateMap" />
...
</template>
<script setup>
const keyword = ref('')
// iframe 지도 업데이트
const updateMap = () => {
mapUrl.value =
...
`&q=${keyword.value.trim()}`
}
</script>
🤔 생각 포인트
diff 활용JavaScript로 텍스트 구분을 구현한 것
이전 텍스트와 새 텍스트를 받아서 두 텍스트의 차이를 구분
패키지 설치
package.json에 작성된 의존성 패키지 목록을 기반으로 최초 npm install 과정에서 설치 완료$ npm install diff
// package.json
{
...
"dependencies": {
"diff": "^7.0.0",
...
},
...
}
diff 메서드 종류
| 메서드 | 설명 |
|---|---|
Diff.diffChars(oldStr, newStr[, options]) | 두 텍스트를 비교하여 각 문자를 토큰으로 취급 |
Diff.diffWords(oldStr, newStr[, options]) | 두 텍스트를 비교하여 각 단어와 구두점을 토큰으로 처리 |
Diff.diffWordsWithSpace(oldStr, newStr[, options]) | 두 텍스트를 비교하여 각 단어, 구두점, 줄 바꿈 또는 공백을 토큰으로 처리 |
diffChars 코드 예시
diffChars를 diff에서 import<script setup>
import { diffChars } from 'diff'
const oldStr = '변경 전'
const newStr = '변경 후 추가'
const changes = diffChars(oldStr, newStr)
</script>
반환 구조:
{ "count": 3, "added": false, "removed": false, "value": "변경 " }
{ "count": 1, "added": false, "removed": true, "value": "전" }
{ "count": 4, "added": true, "removed": false, "value": "후 추가" }
diffChars 코드 활용 1 — added / removed 값에 따라 다른 스타일 부여
<span
v-for="(change, index) in changes"
:key="index"
:class="{ add: change.added, removed: change.removed }"
>
{{ change.value }}
</span>
diffChars 코드 활용 2 — "변경 사항"이 발생한 총 문자의 수를 count
// diffChars 활용 2
const diffCount = changes
// 추가 되었거나 제거된 문자열만 filter
.filter(function (part) {
return part.added || part.removed
})
// 총 변경 문자 수를 count
.reduce(function (sum, part) {
return sum + part.count
}, 0)
// diff로 키워드 변화 판단 및 지도 갱신 시도
const tryUpdateMap = () => {
const changes = diffChars(prevKeyword.value, keyword.value)
const diffCount = changes
// 추가 되었거나 제거된 문자열만 filter
.filter(function (char) {
return char.added || char.removed
})
// 총 변경 문자 수를 count
.reduce(function (sum, char) {
return sum + char.count
}, 0)
const threshold = 2 // 최소 두 글자 이상 바뀌어야 지도 업데이트
if (diffCount >= threshold) {
updateMap()
} else {
error.value = '키워드가 크게 바뀌지 않았습니다.'
}
}
// iframe 지도 업데이트
const updateMap = () => {
// 키워드가 비어있으면 현재 위치로 설정
const query = keyword.value.trim()
? keyword.value
: `${lat.value},${lng.value}`
mapUrl.value =
`https://maps.google.com/maps` +
`?q=${query}` +
`&ll=${lat.value},${lng.value}` +
`&z=14` +
`&output=embed`
prevKeyword.value = keyword.value
error.value = ''
}
Header.Payload.Signature 구조를 가진다. Payload는 누구나 열어볼 수 있으니 민감 정보 금지.djangorestframework-simplejwt를 사용하고, dj-rest-auth의 USE_JWT: True로 JWT 로그인을 활성화한다. 요청 헤더는 Token이 아닌 Bearer.JWT_AUTH_HTTPONLY: False로 발급받고, 401 발생 시 재발급 → 실패하면 로그아웃 로직을 구현한다.