TIL 06-11 Vue와 DRF 결합하기 3 - Customize User

김덕협·2026년 6월 11일

TIL

목록 보기
24/41

Vue with DRF 3 - "Customize User"

DRF에 권한 설정을 추가하자 게시글 조회가 401 Unauthorized 오류와 함께 막혔다.
인증 과정에서 프론트엔드(Vue)의 역할을 이해하고, 이 오류를 해결해 완전한 인증 시스템을 구축한다.

인증의 큰 흐름은 다음과 같다.

  1. Vue에서 로그인을 요청하여 DRF로부터 토큰을 받는다.
  2. 이 토큰을 Pinia에 저장한 뒤, 인증이 필요한 모든 요청에 토큰을 포함하여 보낸다.

0. 인증 With Vue

사전 준비

  • DB 초기화
    • db.sqlite3 삭제
  • Migration 과정 재 진행
  • 관리자 계정 생성 후, 게시글 1개 이상 작성
    • 기존 fixtures 데이터는 user 정보가 없으므로 사용 불가능

시작하기 전에

  • 정상 작동하던 게시글 전체 조회가 작동하지 않음 → 401 status code 확인
  • 게시글 조회 요청 시 인증에 필요한 수단(token)을 보내지 않고 있으므로 게시글 조회가 불가능해진 것
Failed to load resource: the server responded with a status of 401 (Unauthorized)

오늘의 목표

  • 회원가입/로그인 과정에서 token을 발급받아 store에 저장
  • 인증이 필요한 요청마다 token을 함께 보내는 과정 진행
  • 401 오류가 해결되고 게시글이 정상적으로 출력되는 모습 확인

1. 회원가입 (Sign Up)

(1/9) SignUpView route 관련 코드 주석 해제

// router/index.js

import SignUpView from '@/views/SignUpView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    // ...
    {
      path: '/signup',
      name: 'SignUpView',
      component: SignUpView
    }
  ]
})
export default router
<!-- App.vue -->

<template>
  <header>
    <nav>
      <RouterLink :to="{ name:'ArticleView' }">Articles</RouterLink>
      <RouterLink :to="{ name: 'SignUpView'}">SignUpPage</RouterLink>
    </nav>
  </header>
  <RouterView />
</template>

(3/9) 회원가입 form 작성

<!-- views/SignUpView.vue -->

<template>
  <div>
    <h1>Sign Up Page</h1>
    <form @submit.prevent="signUp">
      <label for="username">username: </label>
      <input type="text" id="username" v-model.trim="username" /> <br>

      <label for="password1">password: </label>
      <input type="password" id="password1" v-model.trim="password1" /> <br>

      <label for="password2">password confirmation: </label>
      <input type="password" id="password2" v-model.trim="password2" /> <br>

      <input type="submit" value="signup">
    </form>
  </div>
</template>

(4/9) 사용자 입력 데이터와 바인딩 될 반응형 변수 작성

// views/SignUpView.vue

import { ref } from 'vue'

const username = ref(null)
const password1 = ref(null)
const password2 = ref(null)

(5/9) SignUpView 컴포넌트 출력 확인

  • http://localhost:5173/signup 접속하여 폼 렌더링 확인

(6/9) store에 signUp 함수 뼈대 작성

signUp 함수가 해야 할 일

  1. 사용자 입력 데이터를 받아
  2. 서버로 회원가입 요청을 보냄
// stores/accounts.js

export const useAccountStore = defineStore('account', () => {
  const signUp = function () {
    // ...
  }
  return { signUp }
}, { persist: true })

(7/9) 컴포넌트에서 사용자 입력 데이터를 저장 후 store의 signUp 호출

payload는 전달해야 하는 데이터로 자주 쓰는 변수명

// views/SignUpView.vue
import { useAccountStore } from '@/stores/accounts'

const accountStore = useAccountStore()

const signUp = function () {
  const payload = {
    username: username.value,
    password1: password1.value,
    password2: password2.value,
  }
  accountStore.signUp(payload)
}
<!-- views/SignUpView.vue -->
<template>
  <div>
    <h1>Sign Up Page</h1>
    <form @submit.prevent="signUp">
      <label for="username">username: </label>
      <!-- ... -->
    </form>
  </div>
</template>

(8/9) store의 signUp 함수에 실제 회원가입 요청 작성

// stores/accounts.js

const signUp = function (payload) {
  const username = payload.username
  const password1 = payload.password1
  const password2 = payload.password2
  // const { username, password1, password2 } = payload

  axios({
    method: 'post',
    url: `${API_URL}/accounts/signup/`,
    data: {
      username, password1, password2
    }
  })
    .then(res => {
      console.log('회원가입이 완료되었습니다.')
    })
    .catch(err => console.log(err))
}

(9/9) 회원가입 테스트 후 Django DB 확인

  • 회원가입 요청 후 콘솔에 회원가입이 완료되었습니다. 출력 확인
  • SELECT * FROM accounts_user LIMIT 100 으로 가입된 user 데이터 확인

2. 로그인 (Log In)

(1/9) LogInView route 관련 코드 주석 해제

// router/index.js

import LogInView from '@/views/LogInView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    // ...,
    {
      path: '/login',
      name: 'LogInView',
      component: LogInView
    }
  ]
})
<!-- App.vue -->

<template>
  <header>
    <nav>
      <RouterLink :to="{ name:'ArticleView' }">Articles</RouterLink>
      <RouterLink :to="{ name:'SignUpView' }">SignUpPage</RouterLink>
      <RouterLink :to="{ name:'LogInView' }">LogInPage</RouterLink>
    </nav>
  </header>
  <RouterView />
</template>

(3/9) 로그인 form 작성

<!-- views/LoginView.vue -->

<template>
  <div>
    <h1>LogIn Page</h1>
    <form>
      <label for="username">username: </label> <br>
      <input type="text" id="username" v-model.trim="username" /> <br>

      <label for="password">password: </label> <br>
      <input type="password" id="password" v-model.trim="password" /> <br>

      <input type="submit" value="LogIn"/>
    </form>
  </div>
</template>

(4/9) 사용자 입력 데이터와 바인딩 될 반응형 변수 작성

// views/LoginView.vue

import { ref } from 'vue';

const username = ref(null)
const password = ref(null)

(5/9) LogInView 컴포넌트 출력 확인

  • http://localhost:5173/login 접속하여 폼 렌더링 확인

(6/9) store에 logIn 함수 뼈대 작성

logIn 함수가 해야 할 일

  1. 사용자 입력 데이터를 받아
  2. 서버로 로그인 요청 및 응답 받은 토큰 저장
// stores/accounts.js

export const useAccountStore = defineStore('account', () => {
  const logIn = function () {
    // ...
  }
  return { signUp, logIn }
}, { persist: true })

(7/9) 컴포넌트에서 사용자 입력 데이터를 저장 후 store의 logIn 호출

// views/LoginView.vue
import { useAccountStore } from '@/stores/accounts.js'

const accountStore = useAccountStore()

const logIn = function () {
  const payload = {
    username: username.value,
    password: password.value
  }
  accountStore.logIn(payload)
}
<!-- views/LoginView.vue -->
<template>
  <div>
    <h1>LogIn Page</h1>
    <form @submit.prevent="logIn">
      <!-- ... -->
    </form>
  </div>
</template>

(8/9) store의 logIn 함수에 실제 로그인 요청 작성

// stores/accounts.js

const logIn = function (payload) {
  const username = payload.username
  const password = payload.password

  axios({
    method: 'post',
    url: `${API_URL}/accounts/login/`,
    data: {
      username, password
    }
  })
    .then(res => {
      console.log('로그인이 완료되었습니다.')
      console.log(res.data)
    })
    .catch(err => console.log(err))
}

(9/9) 응답 객체 안에 Token 확인

  • 로그인 후 응답 객체 안에 Django가 발급한 Token이 함께 온 것을 확인
    { key: 'c99fc78062f7843fba363e94956439c5765fc708' }
  • 이제 이 Token을 store에 저장하여 인증이 필요한 요청마다 함께 보내면 된다.

3. 요청과 토큰

토큰 저장 로직 (1/3) 반응형 변수 token 선언 및 토큰 저장

// stores/accounts.js

export const useAccountStore = defineStore('account', () => {

  const token = ref(null)

  const logIn = function (payload) {
    // ...
    .then(res => {
      token.value = res.data.key
    })
    .catch(err => console.log(err))
  }
  return { signUp, logIn, token }
}, { persist: true })

토큰 저장 로직 (2/3) store에 저장된 토큰 확인

  • 다시 로그인 요청 후 Pinia(Vue devtools)에서 state의 token이 저장된 것 확인

토큰 저장 로직 (3/3) 로그인 성공 후 자동으로 메인 페이지로 이동

// stores/accounts.js

import { useRouter } from 'vue-router'

export const useAccountStore = defineStore('account', () => {
  const router = useRouter()

  const logIn = function (payload) {
    // ...
    .then(res => {
      token.value = res.data.key
      router.push({ name: 'ArticleView' })
    })
    .catch(err => console.log(err))
  }
})

토큰이 필요한 요청

  1. 게시글 전체 목록 조회 시
  2. 게시글 작성 시

4. 인증 여부 확인 (요청에 토큰 담기)

게시글 전체 목록 조회 with token

  • 게시글 전체 목록 조회 요청 함수(getArticles)에 token 추가 후 조회
// stores/articles.js

import { useAccountStore } from './accounts'

export const useArticleStore = defineStore('article', () => {
  const accountStore = useAccountStore()

  const getArticles = function () {
    axios({
      method: 'get',
      url: `${API_URL}/api/v1/articles/`,
      headers: {
        'Authorization': `Token ${accountStore.token}`
      }
    })
    // ...
  }
})

게시글 생성 with token

  • 게시글 생성 요청 함수(createArticle)에 token 추가 후 생성
// views/CreateView.vue

import { useAccountStore } from '@/stores/accounts'

const accountStore = useAccountStore()

const createArticle = function () {
  axios({
    method: 'post',
    url: `${store.API_URL}/api/v1/articles/`,
    data: {
      // ...
    },
    headers: {
      'Authorization': `Token ${accountStore.token}`
    }
  })
}

→ 401 오류가 해결되고 게시글이 정상적으로 출력되는 것을 확인할 수 있다.


5. 사용자의 인증(로그인) 여부에 따른 추가 기능 구현

  1. 인증되지 않은 사용자 → 메인 페이지 접근 제한
  2. 인증된 사용자 → 회원가입 및 로그인 페이지에 접근 제한

인증 상태 여부를 나타낼 속성 값 지정

  • token 소유 여부에 따라 로그인 상태를 나타낼 isLogin 변수 작성
  • computed를 활용해 token 값이 변경될 때만 이 상태를 다시 계산하도록 함
    • computed: 원본 데이터가 바뀔 때만, 값을 알아서 다시 계산하는 ref

// stores/accounts.js

export const useAccountStore = defineStore('account', () => {
  // ...
  const isLogin = computed(() => {
    return token.value ? true : false
  })
  // ...
  return { signUp, logIn, token, isLogin }
}, { persist: true })

1. 인증되지 않은 사용자는 메인 페이지 접근 제한

  • 전역 네비게이션 가드 beforeEach를 활용하여, 인증되지 않은 사용자가 보호된 페이지(예: 메인 페이지)로 이동하려 할 때 로그인 페이지로 리다이렉트
    • beforeEach: 모든 페이지 이동 직전에 수행

// router/index.js

import { useAccountStore } from '@/stores/accounts'

const router = createRouter({ /* ... */ })

router.beforeEach((to, from) => {
  const accountStore = useAccountStore()

  if (to.name === 'ArticleView' && !accountStore.isLogin) {
    window.alert('로그인이 필요합니다.')
    return { name: 'LogInView' }
  }
})
  • 확인: 브라우저 local storage에서 token을 삭제 후 메인 페이지 접속 시도 → 로그인 페이지로 리다이렉트
    • local storage: 브라우저를 껐다 켜도, 데이터가 사라지지 않는 저장 공간

2. 인증된 사용자는 회원가입과 로그인 페이지에 접근 제한

  • 다른 주소에서 회원가입 또는 로그인 페이지로 이동 시, 이미 인증된 사용자라면 메인 페이지로 이동시키기
// router/index.js

router.beforeEach((to, from) => {
  const accountStore = useAccountStore()

  if (to.name === 'ArticleView' && !accountStore.isLogin) {
    window.alert('로그인이 필요합니다.')
    return { name: 'LogInView' }
  }

  if ((to.name === 'SignUpView' || to.name === 'LogInView') && (accountStore.isLogin)) {
    window.alert('이미 로그인 되어 있습니다.')
    return { name: 'ArticleView' }
  }
})
  • 확인: 로그인 후 회원가입/로그인 페이지 접속 시도 → 이미 로그인 되어 있습니다. alert 후 메인으로 이동

6. User Customize

dj-rest-auth를 활용한 회원가입 시 User Model을 Customize 해보기

사전 준비

  • DB 초기화: db.sqlite3 삭제

1. User Model에 필드 추가 (1/2)

  • 사용자의 나이 정보를 저장할 PositiveIntegerField 추가
# accounts/models.py

from django.db import models
from django.contrib.auth.models import AbstractUser

# Create your models here.
class User(AbstractUser):
    age = models.PositiveIntegerField(null=True, blank=True)

1. User Model에 필드 추가 (2/2)

  • makemigrations, migrate 작업 진행
$ python manage.py makemigrations
Migrations for 'accounts':
  accounts\migrations\0002_user_age.py
    - Add field age to user
  • DB의 accounts_user 테이블에 age integer unsigned 필드 추가 확인

2. Vue 회원 가입 기능 수정 (1/2)

  • age 입력을 위한 input과 변수 및 payload data 수정
    • payload data: 헤더, 메타데이터를 제외한 전송의 목적이 되는 데이터

<!-- views/SignUpView.vue -->

<template>
  <div>
    <h1>Sign Up Page</h1>
    <form @submit.prevent="signUp">
      <!-- ... -->
      <label for="age">age: </label>
      <input type="number" id="age" v-model.trim="age" /> <br>

      <input type="submit" value="signup">
    </form>
  </div>
</template>
// views/SignUpView.vue

<script setup>
const age = ref(0)

const signUp = function () {
  const payload = {
    // ...
    age: age.value
  }
  accountStore.signUp(payload)
}
</script>

2. Vue 회원 가입 기능 수정 (2/2)

  • signUp 함수에 age 정보 추가
// store/accounts.js
const signUp = function (payload) {
  // ...
  const age = payload.age
  // const { username, password1, password2, age } = payload

  axios({
    method: 'post',
    url: `${API_URL}/accounts/signup/`,
    data: {
      username: username,
      password1: password1,
      password2: password2,
      age: age,
    }
  })
  // ...
}

회원 가입 성공? (1/2)

  • 회원가입 요청 후, 응답 결과 확인 → 콘솔에 회원가입 완료되었습니다. 출력

회원 가입 성공? (2/2)

  • 요청 보낸 유저 명으로 잘 생성 되었으나, age 정보는 누락되었음 (age 컬럼이 NULL)

7. Customize RegisterSerializer

RegisterSerializer

  • dj-rest-authRegisterSerializer의 field 정보 확인
  • username, email, password1, password2 필드만 정의 되어 있음 (그래서 age가 누락됨)
class RegisterSerializer(serializers.Serializer):
    username = serializers.CharField(
        max_length=get_username_max_length(),
        min_length=allauth_account_settings.USERNAME_MIN_LENGTH,
        required=allauth_account_settings.USERNAME_REQUIRED,
    )
    email = serializers.EmailField(required=allauth_account_settings.EMAIL_REQUIRED)
    password1 = serializers.CharField(write_only=True)
    password2 = serializers.CharField(write_only=True)

회원 가입 기능 수정 (1/5) - CustomRegisterSerializer 정의

  • CustomRegisterSerializer에 정의 (RegisterSerializer 상속)
  • age 필드 추가 (필요하다면, first_name 등 기본 제공 필드 추가 가능)
# accounts/serializers.py

from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers

class CustomRegisterSerializer(RegisterSerializer):
    age = serializers.IntegerField()

회원 가입 기능 수정 (2/5) - get_cleaned_data 구조 확인

  • 유효성 검사를 위한 get_cleaned_data 함수 구조 확인
  • 입력 받은 데이터의 유효성 검사 결과를 객체 형태로 반환 해야 함
  • 새롭게 추가한 필드도 추가하여 동일하게 동작할 수 있도록 수정
def get_cleaned_data(self):
    return {
        'username': self.validated_data.get('username', ''),
        'password1': self.validated_data.get('password1', ''),
        'email': self.validated_data.get('email', ''),
    }

회원 가입 기능 수정 (3/5) - get_cleaned_data 오버라이딩

  • super()를 사용하여, 기존 필드에 대한 유효성 검사 실행 후,
  • 추가 필드에 대해 동일한 작업을 진행한 결과를 반영하여 반환
# accounts/serializers.py

from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers

class CustomRegisterSerializer(RegisterSerializer):
    age = serializers.IntegerField()

    def get_cleaned_data(self):
        data = super().get_cleaned_data()
        data['age'] = self.validated_data.get('age')
        return data

회원 가입 기능 수정 (4/5) - save 함수 오버라이딩

  • save 함수도 유효성 검사 함수와 같은 작업을 진행
# 원본 save 함수 구조 참고
def save(self, request):
    adapter = get_adapter()
    user = adapter.new_user(request)
    self.cleaned_data = self.get_cleaned_data()
    user = adapter.save_user(request, user, self, commit=False)
    if "password1" in self.cleaned_data:
        try:
            adapter.clean_password(self.cleaned_data['password1'], user=user)
        except DjangoValidationError as exc:
            raise serializers.ValidationError(
                detail=serializers.as_serializer_error(exc)
            )
    user.save()
    self.custom_signup(request, user)
    setup_user_email(request, user, [])
    return user
# accounts/serializers.py

from dj_rest_auth.registration.serializers import RegisterSerializer
from rest_framework import serializers

class CustomRegisterSerializer(RegisterSerializer):
    # ...
    def save(self, request):
        user = super().save(request)
        user.age = self.validated_data.get('age')
        user.save()
        return user

회원 가입 기능 수정 (5/5) - settings.py 설정

  • dj-rest-authCustomRegisterSerializer를 사용하도록 settings.py에 설정
  • REST_AUTH_REGISTER_SERIALIZERS 주석 해제
# settings.py

...

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

회원 가입 요청 및 결과 확인

  • 이전과 달리 정상적으로 age 정보가 기입된 것을 확인 (예: age = 17)

※ 참고

로그아웃 구현 (1/2) - store에 logOut 작성

// stores/accounts.js

import { useRouter } from 'vue-router'

export const useAccountStore = defineStore('account', () => {
  const router = useRouter()

  const logOut = function () {
    axios({
      method: 'post',
      url: `${API_URL}/accounts/logout/`
    })
      .then((res) => {
        token.value = null
        router.push({ name: 'LoginView' })
      })
      .catch((err) => console.log(err))
  }
  return { signUp, logIn, token, isLogin, logOut }
})

로그아웃 구현 (2/2) - App 컴포넌트에 로그아웃 form 요소 작성

<!-- App.vue -->

<form @submit.prevent="logOut">
  <input type="submit" value="Logout">
</form>
// App.vue

import { useAccountStore } from '@/stores/accounts'
const accountStore = useAccountStore()
const logOut = function () {
  accountStore.logOut()
}

기타 기능 구현 - 회원가입 성공 후 자동으로 로그인까지 진행하기

// stores/accounts.js

const signUp = function (payload) {
  // ...
  .then(res => {
    const password = password1

    logIn({ username, password })
  })
  .catch(err => console.log(err))
}

Django Signals

  • "이벤트 알림 시스템"
  • 애플리케이션 내에서 특정 이벤트가 발생할 때, 다른 부분에 신호를 보내어 이벤트가 발생했음을 알릴 수 있음
  • 주로 모델의 데이터 변경 또는 저장, 삭제와 같은 작업에 반응하여 추가적인 로직을 실행하고자 할 때 사용
    • 예) 사용자가 새로운 게시글을 작성할 때마다 특정 작업(예: 이메일 알림 보내기)을 수행하려는 경우
# articles/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Article

# Article 모델의 post_save 신호를 이 함수가 받도록 설정
@receiver(post_save, sender=Article)
def article_post_save(sender, instance, created, **kwargs):
    # 'created'는 객체가 새로 생성되었을 때 True
    if created:
        print(f"알림: 새로운 게시글이 작성되었습니다! (제목: {instance.title})")

환경 변수 (environment variable)

  • 애플리케이션의 설정이나 동작을 제어하기 위해 사용되는 변수
  • 개발, 테스트 및 프로덕션 환경에서 다르게 설정되어야 하는 설정 값이나 민감한 정보(ex. API key)를 포함
  • 환경 변수를 사용하여 애플리케이션의 설정을 관리하면, 다양한 환경에서 일관된 동작을 유지하면서 필요에 따라 변수를 쉽게 변경할 수 있음
  • 보안적인 이슈를 피하고, 애플리케이션을 다양한 환경에 대응하기 쉽게 만들어 줌

Vite에서 환경 변수를 사용하는 법

  • .env.local 파일 생성 및 API 변수 작성
  • 주의사항
    • 변수명은 반드시 VITE_ 접두어를 작성해야 함
    • 변수명과 값 사이에 공백이 없어야 함
# .env.local
VITE_TMDB_API_KEY=ey1hf1wnfdk202f...
const API_KEY = import.meta.env.VITE_TMDB_API_KEY

Vue 참고자료

Awesome Vue.js

Vuetify

설치한 라이브러리 정리

  • pinia-plugin-persistedstate
    • Pinia의 상태를 브라우저의 localStorage에 자동으로 저장해주는 플러그인
  • axios
    • Vue가 Django 서버와 데이터를 주고받기 위해 사용하는 HTTP 통신 라이브러리
  • djangorestframework
    • Django로 REST API를 구축하기 위한 핵심 프레임워크
  • django-cors-headers
    • CORS(교차 출처 리소스 공유) 헤더를 처리해주는 라이브러리
  • dj-rest-auth
    • Django의 기본 인증 시스템을 기반으로 로그인, 로그아웃, 비밀번호 변경 등의 기능을 API 엔드포인트로 제공
  • 'dj-rest-auth[with-social]'
    • 기본 dj-rest-auth 기능에 더해 API를 구현하는 데 필요한 django-allauth 라이브러리를 함께 설치

한 줄 정리

프론트엔드(Vue)는 로그인 시 발급받은 토큰을 store에 저장하고, 인증이 필요한 요청마다 헤더에 토큰을 담아 보낸다. User 모델을 커스터마이징할 때는 모델 필드 추가 → migration → RegisterSerializer 상속 및 get_cleaned_data / save 오버라이딩 → settings.pyREST_AUTH 설정까지 함께 맞춰줘야 추가 필드가 누락 없이 저장된다.

profile
뭘봐

0개의 댓글