Vue(프론트엔드)와 DRF(백엔드)를 분리된 서버로 두고 요청·응답을 주고받을 때 마주치는 CORS Policy 문제와 그 해결 과정을 정리한다.
이번 글은 1번, 그중에서도 두 서버를 연결할 때 가장 먼저 만나게 되는 CORS 에 대한 내용이다.
django-pjt 제공 → 주석을 해제하며 진행vue-pjt 제공 → 코드를 직접 작성하며 진행# articles/models.py
class Article(models.Model):
# user = models.ForeignKey(
# settings.AUTH_USER_MODEL, on_delete=models.CASCADE
# )
title = models.CharField(max_length=100)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# accounts/models.py
class User(AbstractUser):
pass
user필드는 인증 시스템을 다룰 때 사용하므로 지금은 주석 처리된 상태로 둔다.
# my_api/urls.py
urlpatterns = [
path('admin/', admin.site.urls),
path('api/v1/', include('articles.urls')),
# path('accounts/', include('dj_rest_auth.urls')),
# path('accounts/signup/', include('dj_rest_auth.registration.urls')),
]
# articles/urls.py
urlpatterns = [
path('articles/', views.article_list),
path('articles/<int:article_pk>/', views.article_detail),
]
# articles/serializers.py
class ArticleListSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = ('id', 'title', 'content')
class ArticleSerializer(serializers.ModelSerializer):
class Meta:
model = Article
fields = '__all__'
# read_only_fields = ('user',)
ArticleListSerializer: 목록 조회용 (일부 필드만)ArticleSerializer: 단일 조회·생성용 (전체 필드)# articles/views.py
from rest_framework.response import Response
from rest_framework.decorators import api_view
from rest_framework import status
# permission Decorators
# from rest_framework.decorators import permission_classes
# from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404, get_list_or_404
from .serializers import ArticleListSerializer, ArticleSerializer
from .models import Article
@api_view(['GET', 'POST'])
# @permission_classes([IsAuthenticated])
def article_list(request):
if request.method == 'GET':
articles = get_list_or_404(Article)
serializer = ArticleListSerializer(articles, many=True)
return Response(serializer.data)
elif request.method == 'POST':
serializer = ArticleSerializer(data=request.data)
if serializer.is_valid(raise_exception=True):
serializer.save()
# serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@api_view(['GET'])
def article_detail(request, article_pk):
article = get_object_or_404(Article, pk=article_pk)
if request.method == 'GET':
serializer = ArticleSerializer(article)
print(serializer.data)
return Response(serializer.data)
# my_api/settings.py
INSTALLED_APPS = [
'articles',
'accounts',
'rest_framework',
# 'rest_framework.authtoken',
# 'dj_rest_auth',
# 'corsheaders',
# 'django.contrib.sites',
# 'allauth',
# 'allauth.account',
# 'allauth.socialaccount',
# 'dj_rest_auth.registration',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
# REST_FRAMEWORK = {
# # Authentication
# 'DEFAULT_AUTHENTICATION_CLASSES': [
# 'rest_framework.authentication.TokenAuthentication',
# ],
# # permission
# 'DEFAULT_PERMISSION_CLASSES': [
# 'rest_framework.permissions.AllowAny',
# ],
# }
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
# 'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
# 'allauth.account.middleware.AccountMiddleware',
]
# CORS_ALLOWED_ORIGINS = [
# 'http://127.0.0.1:5173',
# 'http://localhost:5173',
# ]
corsheaders관련 코드는 아직 주석 상태. CORS 에러를 직접 마주한 뒤에 해제할 예정.
articles/fixtures/articles.json 에 테스트용 게시글 데이터가 들어있다.
# 가상환경 생성 및 활성화
$ python -m venv venv
$ source venv/Scripts/activate
# 패키지 설치
$ pip install -r requirements.txt
# Migration
$ python manage.py makemigrations
$ python manage.py migrate
# Fixtures 데이터 로드
$ python manage.py loaddata articles.json
# 서버 실행
$ python manage.py runserver
서버 실행 후 http://127.0.0.1:8000/api/v1/articles/ 로 전체 게시글 조회가 정상 동작하는지 확인한다. (Postman으로도 200 OK 확인)
vue-pjt 제공pinia-plugin-persistedstate 설치 및 등록되어 있음App
├── ArticleView ── ArticleList ── ArticleListItem
├── DetailView
├── CreateView
├── SignUpView
└── LogInView (각 View는 Router로 연결)
vue-project/
├── src/
│ ├── components/
│ │ ├── ArticleList.vue
│ │ └── ArticleListItem.vue
│ ├── router/
│ ├── stores/
│ ├── views/
│ │ ├── ArticleView.vue
│ │ ├── CreateView.vue
│ │ ├── DetailView.vue
│ │ ├── LogInView.vue
│ │ └── SignUpView.vue
│ ├── App.vue
│ └── main.js
└── ...
<!-- App.vue -->
<template>
<header>
<nav>
</nav>
</header>
<RouterView />
</template>
<script setup>
import { RouterView } from 'vue-router'
</script>
<style scoped>
</style>
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
// import ArticleView from '@/views/ArticleView.vue'
// import DetailView from '@/views/DetailView.vue'
// import CreateView from '@/views/CreateView.vue'
// import SignUpView from '@/views/SignUpView.vue'
// import LogInView from '@/views/LogInView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
// {
// path: '/',
// name: 'ArticleView',
// component: ArticleView
// },
…
// store/articles.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useArticleStore = defineStore('article', () => {
return { }
}, { persist: true })
// src/main.js
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
app.use(router)
app.mount('#app')
$ npm install
$ npm run dev
DRF와 연결하기 전에, 임시 데이터로 화면에 게시글 목록이 출력되는 흐름부터 만든다.
// router/index.js
import ArticleView from '@/views/ArticleView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'ArticleView',
component: ArticleView
},
]
})
<!-- App.vue -->
<template>
<header>
<nav>
<RouterLink :to="{ name:'ArticleView' }">Articles</RouterLink>
</nav>
</header>
<RouterView />
</template>
<script setup>
import { RouterView, RouterLink } from 'vue-router'
</script>
<!-- views/ArticleView.vue -->
<template>
<div>
<h1>Article Page</h1>
<ArticleList />
</div>
</template>
<script setup>
import ArticleList from '@/components/ArticleList.vue'
</script>
// store/articles.js
export const useArticleStore = defineStore('article', () => {
const articles = ref([
{ id: 1, title: 'Article 1', content: 'Content of article 1' },
{ id: 2, title: 'Article 2', content: 'Content of article 2' }
])
return { articles }
}, { persist: true })
articles 데이터를 참조v-for로 하위 컴포넌트(ArticleListItem)에 article 단일 객체를 props로 전달<!-- components/ArticleList.vue -->
<template>
<div>
<h3>Article List</h3>
<ArticleListItem
v-for="article in store.articles"
:key="article.id"
:article="article"
/>
</div>
</template>
<script setup>
import { useArticleStore } from '@/stores/articles'
import ArticleListItem from '@/components/ArticleListItem.vue'
const store = useArticleStore()
</script>
<!-- components/ArticleListItem.vue -->
<template>
<div>
<h5>{{ article.id }}</h5>
<p>{{ article.title }}</p>
<p>{{ article.content }}</p>
<hr>
</div>
</template>
<script setup>
defineProps({
article: Object
})
</script>
여기까지 하면 메인 페이지에서 임시 데이터 기반 게시글 목록이 출력된다.
이제 임시 데이터를 DRF 서버에 실제 요청해서 받아온 데이터로 대체한다.
# Vue 서버 종료 → 설치 → 서버 재실행
$ npm install axios
Axios: Promise 기반의 HTTP 클라이언트 라이브러리
Promise: JavaScript에서 비동기 작업의 결과를 나타내는 객체
// store/articles.js
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import axios from 'axios'
export const useArticleStore = defineStore('article', () => {
const articles = ref([])
const API_URL = 'http://127.0.0.1:8000'
}, { persist: true })
// store/articles.js
export const useArticleStore = defineStore('article', () => {
const articles = ref([])
const API_URL = 'http://127.0.0.1:8000'
const getArticles = function () {
axios({
method: 'get',
url: `${API_URL}/api/v1/articles/`
})
.then(res => {
console.log(res)
console.log(res.data)
})
.catch(err => console.log(err))
}
return { articles, API_URL, getArticles }
}, { persist: true })
getArticles가 실행되도록 한다.<!-- views/ArticleView.vue -->
<script setup>
import { onMounted } from 'vue'
import { useArticleStore } from '@/stores/articles'
import { RouterLink } from 'vue-router'
import ArticleList from '@/components/ArticleList.vue'
const store = useArticleStore()
onMounted(() => {
store.getArticles()
})
</script>
Vue와 DRF 서버를 모두 실행한 후 응답 데이터를 확인하면 다음과 같은 에러가 발생한다.
Access to XMLHttpRequest at 'http://127.0.0.1:8000/api/v1/articles/'
from origin 'http://localhost:5173' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
AxiosError {message: 'Network Error', code: 'ERR_NETWORK', ...}
[..] "GET /api/v1/articles/ HTTP/1.1" 200 216
[..] "GET /api/v1/articles/ HTTP/1.1" 200 216
localhost:5173 에서 127.0.0.1:8000/api/v1/articles/ 로의 XMLHttpRequest 접근이 CORS policy에 의해 차단되었기 때문.동일 출처 정책(SOP)은 '같은 출처에서만 리소스를 자유롭게 공유할 수 있다'는 웹 브라우저의 가장 기본적인 보안 규칙이다. 한 출처에서 실행된 스크립트가 다른 출처의 데이터를 마음대로 읽어오지 못하도록 막아, 악의적인 사이트가 개인 정보를 탈취하는 것을 방지한다.
URL의 Protocol(Scheme), Host, Port 를 모두 포함하여 "출처"라고 부른다.
Scheme/Protocol Host Port Path
http:// localhost :3000 /posts/3
http://localhost:3000/articles/3/ 기준 비교 예시
| URL | 결과 | 이유 |
|---|---|---|
http://localhost:3000/articles/ | ✅ 성공 | Path만 다름 |
http://localhost:3000/comments/3/ | ✅ 성공 | Path만 다름 |
https://localhost:3000/articles/3/ | ❌ 실패 | Protocol 다름 |
http://localhost:80/articles/3/ | ❌ 실패 | Port 다름 |
http://yahuua:3000/articles/3/ | ❌ 실패 | Host 다름 |
CORS는 다른 출처의 자원 공유를 허용하기 위해 서버가 발급하는 '허가증'과 같은 정책이다. 서버는 자신의 응답에 "이 출처에서 온 요청은 내 데이터를 읽어가도 좋아"라고 브라우저에게 알려줌으로써, 동일 출처 정책(SOP)을 안전하게 우회하고 서로 다른 서버 간의 통신을 가능하게 만든다.
Access-Control-Allow-Origin: 도메인A 가 포함되면, 이제 도메인 A에서의 요청은 서버의 자원에 접근할 수 있음Browser(도메인 A) ──── 요청 ────▶ Server(도메인 B)
◀─── 응답 + "HTTP Response Header"
(Access-Control-Allow-Origin)
Django에서는 django-cors-headers 라이브러리를 활용한다. (손쉽게 응답 객체에 CORS header를 추가해주는 라이브러리)
# requirements.txt로 인해 사전에 설치되어 있음
$ pip install django-cors-headers
settings.py 관련 코드 주석 해제 및 허용할 Vue 프로젝트 Domain 등록
# settings.py
INSTALLED_APPS = [
…
'corsheaders',
…
]
MIDDLEWARE = [
…
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
…
]
# settings.py
CORS_ALLOWED_ORIGINS = [
'http://127.0.0.1:5173',
'http://localhost:5173',
]
⚠️
CorsMiddleware는 가능한 한 위쪽(특히CommonMiddleware보다 위)에 위치시키는 것이 권장된다.
설정 후 메인 페이지에서 DRF 응답 데이터를 다시 확인하면 정상적으로 게시글 데이터가 출력된다.
개발자 도구 → Network → Fetch/XHR 에서 응답 객체의 Response Headers를 보면:
Access-Control-Allow-Origin: http://localhost:5173
헤더가 포함된 것을 확인할 수 있다. → CORS 문제 해결 완료 ✅
CORS 문제를 해결했으니, 실제 CRUD 동작을 구현한다.
getArticles의 응답 데이터를 store의 articles에 저장한다.
// store/articles.js
const getArticles = function () {
axios({
method: 'get',
url: `${API_URL}/api/v1/articles/`
})
.then(res => {
articles.value = res.data
})
.catch(err => console.log(err))
}
id, title, content)pinia-plugin-persistedstate에 의해 브라우저 Local Storage에 저장됨(1) DetailView route 주석 해제
// router/index.js
import DetailView from '@/views/DetailView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'ArticleView',
component: ArticleView
},
{
path: '/articles/:id',
name: 'DetailView',
component: DetailView
},
…
]
})
(2) ArticleListItem에 DetailView로 가는 RouterLink 작성
<!-- components/ArticleListItem.vue -->
<template>
<div>
<h5>{{ article.id }}</h5>
<p>{{ article.title }}</p>
<p>{{ article.content }}</p>
<RouterLink :to="{ name: 'DetailView', params: { id: article.id } }">
[DETAIL]
</RouterLink>
<hr>
</div>
</template>
<script setup>
import { RouterLink } from 'vue-router'
…
</script>
(3) DetailView 마운트 시 단일 게시글 조회 AJAX 요청
// views/DetailView.vue (script 부분)
import axios from 'axios'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useArticleStore } from '@/stores/articles'
const store = useArticleStore()
const route = useRoute()
onMounted(() => {
axios({
method: 'get',
url: `${store.API_URL}/api/v1/articles/${route.params.id}/`,
})
.then((res) => {
console.log(res.data)
})
.catch(err => console.log(err))
})
route.params.id로 URL의 동적 파라미터(:id)를 가져온다.
(4) 응답 데이터 저장 후 출력
<!-- views/DetailView.vue -->
<template>
<div>
<h1>Detail</h1>
<div v-if="article">
<p>글 번호 : {{ article.id }}</p>
<p>제목 : {{ article.title }}</p>
<p>내용 : {{ article.content }}</p>
<p>작성시간 : {{ article.created_at }}</p>
<p>수정시간 : {{ article.updated_at }}</p>
</div>
</div>
</template>
<script setup>
import axios from 'axios'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useArticleStore } from '@/stores/articles'
const store = useArticleStore()
const route = useRoute()
const article = ref(null)
onMounted(() => {
axios({
method: 'get',
url: `${store.API_URL}/api/v1/articles/${route.params.id}/`,
})
.then((res) => {
article.value = res.data
})
.catch(err => console.log(err))
})
</script>
응답 데이터가 도착하기 전에는
article이null이므로,v-if="article"로 데이터가 있을 때만 렌더링한다.
localhost:5173/articles/1 접속 → 단일 게시글 정보(글 번호/제목/내용/작성·수정 시간)가 정상 출력되면 완료.
(1) CreateView route 주석 해제
// router/index.js
import CreateView from '@/views/CreateView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
…,
{
path: '/create',
name: 'CreateView',
component: CreateView
}
]
})
(2) ArticleView에 CreateView로 가는 RouterLink 작성
<!-- views/ArticleView.vue -->
<template>
<div>
<h1>Article Page</h1>
<RouterLink :to="{ name:'CreateView' }">
[CREATE]
</RouterLink>
<ArticleList />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useArticleStore } from '@/stores/articles'
import { RouterLink } from 'vue-router'
import ArticleList from '@/components/ArticleList.vue'
</script>
(3) v-model로 양방향 바인딩 (.trim으로 공백 제거)
<!-- views/CreateView.vue -->
<template>
<div>
<h1>게시글 작성</h1>
<form @submit.prevent="createArticle">
<label for="title">제목 : </label>
<input type="text" id="title" v-model.trim="title"><br>
<label for="content">내용 : </label>
<textarea id="content" v-model.trim="content"></textarea><br>
<input type="submit">
</form>
</div>
</template>
<script setup>
import { ref } from 'vue'
const title = ref(null)
const content = ref(null)
</script>
Vue 개발자 도구로 입력값이
title,contentref에 양방향 바인딩되는지 확인 가능.
(4) createArticle 함수 작성
ArticleView 컴포넌트로 이동시킴<!-- views/CreateView.vue (script) -->
<script setup>
import axios from 'axios'
import { ref } from 'vue'
import { useArticleStore } from '@/stores/articles'
import { useRouter } from 'vue-router'
const store = useArticleStore()
const router = useRouter()
const title = ref(null)
const content = ref(null)
const createArticle = function () {
axios({
method: 'post',
url: `${store.API_URL}/api/v1/articles/`,
data: {
title: title.value,
content: content.value,
},
})
.then(() => {
router.push({ name: 'ArticleView' })
})
.catch(err => console.log(err))
}
</script>
(5) submit 이벤트 처리
submit 이벤트가 발생하면 createArticle 함수를 호출v-on의 .prevent 수식어를 사용해 submit 이벤트의 기본 동작(새로고침) 취소<form @submit.prevent="createArticle">
게시글 생성 후 DB(articles_article 테이블)에 새 레코드가 추가된 것을 확인하면 완료.
| 개념 | 핵심 내용 |
|---|---|
| SOP | 동일 출처 정책. 같은 출처(Protocol+Host+Port)에서만 리소스 공유 허용 (브라우저 보안 규칙) |
| Origin | URL의 Protocol + Host + Port. 세 가지가 모두 같아야 동일 출처 |
| CORS | 서로 다른 출처 간 리소스 공유를 허용하는 메커니즘. 서버가 응답 헤더로 허가를 내줌 |
| 해결책 | 서버 응답에 Access-Control-Allow-Origin 헤더 포함 → Django는 django-cors-headers 사용 |
기억할 흐름
5173)에서 DRF(8000)로 요청 → 출처가 다름django-cors-headers로 허용 출처를 등록하고 CORS Header를 응답에 추가결론: CORS 문제는 프론트가 아니라 서버에서 해결한다.