TIL 06-12 - Vue를 활용한 SPA 기초

김덕협·2026년 6월 12일

TIL

목록 보기
25/43

JWT 인증부터 위치 기반 지도 검색까지

Vue로 SPA를 만들면서 JWT 인증, refresh token, Geolocation API, iframe, jsdiff까지 다뤘다. 인증 흐름을 직접 코드로 따라가며 정리했다.


1. JWT (JSON Web Token)

JWT란?

  • 유저가 스스로 누군지 증명하는 디지털 출입증
  • 서버가 유저에게 발급해주는 긴 문자열
  • 해당 문자열 안에는 유저의 정보가 암호화되어 들어 있음

JWT 구조

JWT는 .(dot)으로 구분하며, 3부분으로 나뉘어 있다.

xxxxx.yyyyy.zzzzz
Header.Payload.Signature
구성역할설명
Header봉투어떤 알고리즘으로 암호화했는지 적혀 있음
Payload내용물실제 유저 정보가 들어 있음. 누구나 열어볼 수 있어 민감한 정보를 넣으면 안 됨
Signature위조 방지 도장정보가 조작되지 않았음을 증명. 서버가 가진 키로 위조 유무를 확인할 수 있음

💡 Payload는 단순 인코딩(Base64)이라 디코딩하면 누구나 내용을 볼 수 있다. 비밀번호 같은 민감 정보는 절대 담지 않는다.

JWT 동작 흐름

1. Client → Server : 로그인 요청
2. Server : 사용자 정보 검증 후 JWT 토큰 발급
3. Client : JWT를 브라우저나 앱에 저장
4. Client → Server : 발급받은 JWT를 header에 담아 API 요청
5. Server : 별도의 DB 조회 없이 JWT에 포함된 서명을 검증 후 응답

JWT 특징

장점

  • 서버 부담이 적음 : DB에 저장하여 누가 로그인 중인지 기억할 필요 없음 (Stateless)
  • 확장성이 좋음 : 서버를 여러 대 늘려도(Scale-out) 토큰만 있으면 어떤 서버에서든 인증 가능
  • 모바일 친화적 : 웹뿐만 아니라 앱에서도 쓰기 편함

단점

  • 키를 잃어버렸을 경우 대응하기 어려움
  • 실수로 payload에 개인정보가 있을 시 누구든 확인할 수 있음

2. JWT 실습

Token 방식 vs. JWT 방식

구분Token 방식JWT 방식
정보 위치키 자체에는 정보 없음, 서버가 정보 보유키에 정보가 들어있어 서버가 따로 확인 불필요
인증 방식DB 저장 정보를 확인하여 사용자 인증서버가 토큰을 해석해서 사용자 인증
키 유출 대응번거롭지만 서버에서 Disable 처리하여 대응 가능간단하지만 유출 시 대응하기 힘듦
사용처로그인 관리가 엄격해야 하는 경우 (은행)대규모 트래픽 처리가 필요한 곳 (SNS, 쇼핑몰)

사용 패키지

  • DRF 공식 문서 기준 djangorestframework-simplejwt 사용
  • dj-rest-auth의 USE_JWT=True 설정 시 JWT 토큰을 발급 (해당 설정은 djangorestframework-simplejwt 설치를 요구함)

simple-jwt 설정

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_JWTTrue로 설정한다.

REST_AUTH = {
    'REGISTER_SERIALIZER': 'accounts.serializers.CustomRegisterSerializer',
    'USE_JWT': True,
}

Vue에서 Login 설정 변경

stores/accounts.jslogIn 함수를 수정한다.

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))
}

JWT 응답 확인

  • Login 완료 후 console 창에서 응답을 확인하면 access, refresh, user 등이 함께 전달된다.
  • 전달된 access token을 복사하여 jwt.io 에서 내부를 확인할 수 있다.
  • settings.py 상단에 있는 SECRET_KEY를 붙여 넣으면 서명 인증(Signature Verification)됨을 확인할 수 있다.

게시글 조회·생성 axios 수정

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))
}

3. refresh token

refresh token이란?

로그인을 다시 하지 않아도 Access Token을 새로 받을 수 있게 하는 장기 열쇠 같은 것

refresh token의 필요성

  • Access Token은 짧게 쓰는 출입증 역할을 한다.
    • 유출되었을 때 대응하기 힘들기 때문에 유효시간을 짧게 설정
    • 유효기간이 짧으면 금방 만료되고 로그인이 필요해짐
    • 잦은 로그인은 매우 불편함
  • refresh token은 이런 access token을 재발급 받을 수 있게 하는 용도로 쓰인다.
    • 유효시간은 access token보다 길게 설정
    • 외부에 노출되지 않게 잘 보관해야 함

access token 재발급 흐름

1. 기본 access token을 활용해서 서버에 요청한다.
2. 여기서 access token이 만료되면 401 에러가 발생한다.
3. 이때 refresh token을 활용해 access token을 재발급 받는다.
   ├─ 재발급 성공 → 발급받은 access token으로 다시 서버에 요청
   └─ 재발급 실패 → refresh token이 만료되어 다시 로그인 해야 함

refresh token 발급 받기

현재 로그인을 진행하면 refresh token이 빈 값으로 전달되고 있다.

  • 이는 dj-rest-auth의 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))
}

token 만료 기한 설정

현재 access token은 5분, refresh token은 1일로 만료 기한이 설정되어 있다.

access token의 적정 만료 기한

  • 짧을수록 보안에 유리함
  • 다만 너무 짧으면 갱신 요청이 많아지게 됨
  • 대부분의 서비스는 10~15분으로 설정
    • 보안에 민감한 경우 5~10분으로 설정 (금융권)

refresh token의 적정 만료 기한

  • 일반적으로 1~2주 또는 30일을 주로 사용
  • 보안이 안전한 곳에서는 30~90일까지도 사용함
    • 보안에 민감한 경우 1~14일로 설정

실습 테스트를 위해 임시로 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),
}

refresh token을 활용한 access token 갱신

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
    })
}

Article 목록 요청 시 access token 재발급

  • 요청에 실패한 경우 catch 메서드가 실행됨
  • 요청 실패의 원인이 401 (Unauthorized)인 경우
    • refreshAccessToken 함수를 실행하여 access token을 재발급
      • 재발급 성공 시 → Article 목록을 다시 요청하여 목록을 받아 옴
      • 재발급 실패 시 → refresh token이 만료됨을 의미 → 모든 인증 정보를 삭제한 후 로그인 페이지로 이동

stores/articles.jsgetArticles 메서드 수정:

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' })
              })
          })
      }
    })
}

4. 위치 기반 지도 검색 기능 구현

Geolocation API

사용자의 현재 위치 정보를 브라우저를 통해 간편하게 얻을 수 있는 인터페이스
→ 지도, 내비게이션, 위치 기반 서비스 등 구현

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>

Vue 코드 — 위치 가져오기

  • 위도, 경도, 에러를 저장할 변수 선언
  • 함수 실행 시 사용자의 현재 위치를 불러올 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>

iframe

'Inline Frame'의 줄임말
HTML 문서 안에 또 다른 HTML 문서를 삽입하는 태그
외부 페이지를 현재 페이지에 임베드(embedded)할 때 사용

iframe 기본 문법

속성설명
src삽입할 문서의 URL
width, heightiframe의 크기
allowfullscreen전체화면 전환 허용 여부
loading"lazy" 설정 시 화면에 보여야 할 때 최초 로딩

iframe 활용 예시

  • YouTube 영상 : https://www.youtube.com/embed/<video_id>
  • Google Maps : https://www.google.com/maps?output=embed&q=<keyword>
  • Google Calendar : 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>

🤔 생각 포인트

  • 현재 구현된 기능 상, 사용자가 값을 입력할 때마다 지도가 업데이트 됨
  • 별도의 API Key 없이 구현된 상태에서는 큰 문제는 없음
    • 빈번한 reloading으로 인한 UX 감소는 있으나 큰 이슈라고 보기는 힘듦
  • 그러나, API를 연동하여 사용해야 한다면? (ex. 유튜브 API)
    • 매 입력마다 유료 API에 요청 → 과금 발생
  • 입력 값을 감시하고, 특정 조건을 만족할 때마다 갱신하면 어떨까?
    • Watch의 경우, 변경되었다는 사실만을 알 수 있음 → 얼마나 변경되었는지 알기 위해 diff 활용

jsdiff

JavaScript로 텍스트 구분을 구현한 것
이전 텍스트와 새 텍스트를 받아서 두 텍스트의 차이를 구분

패키지 설치

  • Npm을 통해 쉽게 설치 가능
  • 제공된 skeleton 코드에는 이미 설치되어 있음
  • 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 코드 활용 1added / 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 = ''
}

✅ 오늘의 정리

  • JWT는 서버가 상태를 저장하지 않는(Stateless) 인증 방식으로, Header.Payload.Signature 구조를 가진다. Payload는 누구나 열어볼 수 있으니 민감 정보 금지.
  • DRF에서는 djangorestframework-simplejwt를 사용하고, dj-rest-auth의 USE_JWT: True로 JWT 로그인을 활성화한다. 요청 헤더는 Token이 아닌 Bearer.
  • refresh token은 짧게 만료되는 access token을 다시 받기 위한 장기 열쇠. JWT_AUTH_HTTPONLY: False로 발급받고, 401 발생 시 재발급 → 실패하면 로그아웃 로직을 구현한다.
  • Geolocation API로 현재 위치를, iframe으로 지도를 임베드하고, jsdiff로 입력 변화량을 측정해 불필요한 갱신(과금)을 방지할 수 있다.
profile
뭘봐

0개의 댓글