Vue + FastAPI + SQLite 통합 실습
실제 CRUD 사원관리 웹 프로그램을 완성합니다.
이번 장에서는 지금까지 배운 Vue, Axios, FastAPI, Service, Repository, SQLite를 실제 하나의 프로그램으로 연결합니다.
| 폴더 | 담당 |
|---|---|
| backend | Python / FastAPI / SQLite |
| frontend | Vue / Axios / Vue Router |
backend 폴더에 employee.db를 만듭니다.
CREATE TABLE employee (
employee_id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
department TEXT
);
INSERT INTO employee
(name, phone, department)
VALUES
('홍길동', '010-1111-1111', '개발팀');
INSERT INTO employee
(name, phone, department)
VALUES
('김철수', '010-2222-2222', '영업팀');
INSERT INTO employee
(name, phone, department)
VALUES
('이영희', '010-3333-3333', '관리팀');
| employee_id | name | phone | department |
|---|---|---|---|
| 1 | 홍길동 | 010-1111-1111 | 개발팀 |
| 2 | 김철수 | 010-2222-2222 | 영업팀 |
| 3 | 이영희 | 010-3333-3333 | 관리팀 |
import sqlite3
def get_connection():
conn = sqlite3.connect(
"employee.db"
)
conn.row_factory = sqlite3.Row
return conn
Repository는 데이터베이스를 사용할 때
get_connection() 함수를 호출합니다.
from pydantic import BaseModel
class EmployeeCreate(BaseModel):
name: str
phone: str = ""
department: str = ""
class EmployeeUpdate(BaseModel):
name: str
phone: str = ""
department: str = ""
{
"name": "홍길동",
"phone": "010-1111-1111",
"department": "개발팀"
}
Repository는 SQLite를 직접 처리합니다.
from database import get_connection
def find_all():
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT
employee_id,
name,
phone,
department
FROM employee
ORDER BY employee_id
"""
)
rows = cursor.fetchall()
conn.close()
return [
dict(row)
for row in rows
]
def find_by_id(
employee_id
):
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT
employee_id,
name,
phone,
department
FROM employee
WHERE employee_id = ?
""",
(
employee_id,
)
)
row = cursor.fetchone()
conn.close()
if row is None:
return None
return dict(row)
def insert(
name,
phone,
department
):
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"""
INSERT INTO employee
(
name,
phone,
department
)
VALUES (?, ?, ?)
""",
(
name,
phone,
department
)
)
conn.commit()
employee_id = (
cursor.lastrowid
)
conn.close()
return employee_id
def update(
employee_id,
name,
phone,
department
):
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"""
UPDATE employee
SET
name = ?,
phone = ?,
department = ?
WHERE employee_id = ?
""",
(
name,
phone,
department,
employee_id
)
)
conn.commit()
count = cursor.rowcount
conn.close()
return count
cursor.rowcount를 사용하면 실제로 수정된 행이 있는지 확인할 수 있습니다.
def delete(
employee_id
):
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"""
DELETE FROM employee
WHERE employee_id = ?
""",
(
employee_id,
)
)
conn.commit()
count = cursor.rowcount
conn.close()
return count
from repositories import (
employee_repository
)
def validate_name(
name
):
if name.strip() == "":
return False
return True
def validate_phone(
phone
):
if phone == "":
return True
if not phone.startswith(
"010-"
):
return False
return True
Service에서는 DB SQL 자체보다 이름과 전화번호 검증 같은 업무 규칙을 담당합니다.
def get_all_employees():
return (
employee_repository
.find_all()
)
def get_employee(
employee_id
):
return (
employee_repository
.find_by_id(
employee_id
)
)
def create_employee(
employee
):
if not validate_name(
employee.name
):
raise ValueError(
"이름을 입력하세요."
)
if not validate_phone(
employee.phone
):
raise ValueError(
"전화번호 형식이 올바르지 않습니다."
)
name = (
employee.name.strip()
)
employee_id = (
employee_repository
.insert(
name,
employee.phone,
employee.department
)
)
return employee_id
def update_employee(
employee_id,
employee
):
if not validate_name(
employee.name
):
raise ValueError(
"이름을 입력하세요."
)
if not validate_phone(
employee.phone
):
raise ValueError(
"전화번호 형식이 올바르지 않습니다."
)
return (
employee_repository
.update(
employee_id,
employee.name.strip(),
employee.phone,
employee.department
)
)
def delete_employee(
employee_id
):
return (
employee_repository
.delete(
employee_id
)
)
from fastapi import (
APIRouter,
HTTPException
)
from models.employee import (
EmployeeCreate,
EmployeeUpdate
)
from services import (
employee_service
)
router = APIRouter(
prefix="/employees"
)
prefix="/employees"를 지정하면
이 Router에 작성하는 모든 API 주소 앞에 /employees가 자동으로 붙습니다.
@router.get("")
def get_employees():
return (
employee_service
.get_all_employees()
)
@router.get(
"/{employee_id}"
)
def get_employee(
employee_id: int
):
employee = (
employee_service
.get_employee(
employee_id
)
)
if employee is None:
raise HTTPException(
status_code=404,
detail=
"사원을 찾을 수 없습니다."
)
return employee
@router.post("")
def create_employee(
employee: EmployeeCreate
):
try:
employee_id = (
employee_service
.create_employee(
employee
)
)
return {
"employee_id":
employee_id,
"message":
"등록되었습니다."
}
except ValueError as e:
raise HTTPException(
status_code=400,
detail=str(e)
)
@router.put(
"/{employee_id}"
)
def update_employee(
employee_id: int,
employee: EmployeeUpdate
):
try:
count = (
employee_service
.update_employee(
employee_id,
employee
)
)
if count == 0:
raise HTTPException(
status_code=404,
detail=
"사원을 찾을 수 없습니다."
)
return {
"message":
"수정되었습니다."
}
except ValueError as e:
raise HTTPException(
status_code=400,
detail=str(e)
)
@router.delete(
"/{employee_id}"
)
def delete_employee(
employee_id: int
):
count = (
employee_service
.delete_employee(
employee_id
)
)
if count == 0:
raise HTTPException(
status_code=404,
detail=
"사원을 찾을 수 없습니다."
)
return {
"message":
"삭제되었습니다."
}
/employees
/employees/{id}
/employees
/employees/{id}
/employees/{id}
from fastapi import FastAPI
from fastapi.middleware.cors import (
CORSMiddleware
)
from routers.employee_router import (
router as employee_router
)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173"
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
app.include_router(
employee_router
)
backend 폴더에서 실행합니다.
uvicorn main:app --reload
Swagger 문서 화면에서 다음 API를 테스트합니다.
GET /employees GET /employees/1 POST /employees PUT /employees/1 DELETE /employees/1
npm create vite@latest frontend
cd frontend npm install npm install vue-router axios
import axios from 'axios'
const api = axios.create({
baseURL:
'http://localhost:8000'
})
export default api
axios.get( 'http://localhost:8000/employees' )
api.get( '/employees' )
http://localhost:8000을 반복해서 작성하지 않아도 됩니다.
import api from './axios'
export function getEmployees() {
return api.get(
'/employees'
)
}
export function getEmployee(id) {
return api.get(
'/employees/' + id
)
}
export function createEmployee(
employee
) {
return api.post(
'/employees',
employee
)
}
export function updateEmployee(
id,
employee
) {
return api.put(
'/employees/' + id,
employee
)
}
export function deleteEmployee(
id
) {
return api.delete(
'/employees/' + id
)
}
Vue 화면에서는 Axios를 직접 호출하지 않고
employeeApi.js에 정의한 함수들을 사용합니다.
import {
createRouter,
createWebHistory
} from 'vue-router'
import EmployeeListView
from '../views/EmployeeListView.vue'
import EmployeeCreateView
from '../views/EmployeeCreateView.vue'
import EmployeeDetailView
from '../views/EmployeeDetailView.vue'
import EmployeeEditView
from '../views/EmployeeEditView.vue'
const routes = [
{
path: '/',
redirect: '/employees'
},
{
path: '/employees',
component:
EmployeeListView
},
{
path: '/employees/new',
component:
EmployeeCreateView
},
{
path: '/employees/:id',
component:
EmployeeDetailView
},
{
path: '/employees/:id/edit',
component:
EmployeeEditView
}
]
const router = createRouter({
history:
createWebHistory(),
routes
})
export default router
import {
createApp
} from 'vue'
import App
from './App.vue'
import router
from './router'
createApp(App)
.use(router)
.mount('#app')
<template>
<div>
<h1>
사원관리 프로그램
</h1>
<nav>
<router-link
to="/employees"
>
사원목록
</router-link>
|
<router-link
to="/employees/new"
>
사원등록
</router-link>
</nav>
<hr>
<router-view />
</div>
</template>
<script setup>
import {
ref,
onMounted
} from 'vue'
import {
getEmployees,
deleteEmployee
} from '../api/employeeApi'
const employees = ref([])
const loading = ref(false)
async function loadEmployees() {
loading.value = true
try {
const response =
await getEmployees()
employees.value =
response.data
} catch (error) {
alert(
'목록 조회 중 오류가 발생했습니다.'
)
} finally {
loading.value = false
}
}
async function removeEmployee(
id
) {
const result = confirm(
'삭제하시겠습니까?'
)
if (!result) {
return
}
try {
await deleteEmployee(id)
await loadEmployees()
} catch (error) {
alert(
'삭제 중 오류가 발생했습니다.'
)
}
}
onMounted(
loadEmployees
)
</script>
<template>
<div>
<h2>
사원 목록
</h2>
<p v-if="loading">
조회 중입니다.
</p>
<table
v-else
border="1"
>
<thead>
<tr>
<th>번호</th>
<th>이름</th>
<th>전화번호</th>
<th>부서</th>
<th>관리</th>
</tr>
</thead>
<tbody>
<tr
v-for="emp in employees"
:key="emp.employee_id"
>
<td>
{{ emp.employee_id }}
</td>
<td>
<router-link
:to="
'/employees/'
+ emp.employee_id
"
>
{{ emp.name }}
</router-link>
</td>
<td>
{{ emp.phone }}
</td>
<td>
{{ emp.department }}
</td>
<td>
<router-link
:to="
'/employees/'
+ emp.employee_id
+ '/edit'
"
>
수정
</router-link>
<button
@click="
removeEmployee(
emp.employee_id
)
"
>
삭제
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
onMounted( loadEmployees )
const response = await getEmployees()
<script setup>
import {
reactive
} from 'vue'
import {
useRouter
} from 'vue-router'
import {
createEmployee
} from '../api/employeeApi'
const router = useRouter()
const employee = reactive({
name: '',
phone: '',
department:
'개발팀'
})
async function save() {
try {
await createEmployee(
employee
)
alert(
'등록되었습니다.'
)
router.push(
'/employees'
)
} catch (error) {
if (
error.response
&&
error.response.data
) {
alert(
error.response
.data.detail
)
} else {
alert(
'등록 중 오류가 발생했습니다.'
)
}
}
}
</script>
<template>
<div>
<h2>
사원 등록
</h2>
<p>
이름
<input
v-model="employee.name"
>
</p>
<p>
전화번호
<input
v-model="employee.phone"
>
</p>
<p>
부서
<select
v-model="
employee.department
"
>
<option value="개발팀">
개발팀
</option>
<option value="영업팀">
영업팀
</option>
<option value="관리팀">
관리팀
</option>
</select>
</p>
<button @click="save">
등록
</button>
</div>
</template>
사용자가 이름, 전화번호, 부서를 입력하면
v-model을 통해 employee 객체에 저장됩니다.
await createEmployee( employee )
<script setup>
import {
ref,
onMounted
} from 'vue'
import {
useRoute
} from 'vue-router'
import {
getEmployee
} from '../api/employeeApi'
const route = useRoute()
const employee = ref(null)
async function loadEmployee() {
try {
const response =
await getEmployee(
route.params.id
)
employee.value =
response.data
} catch (error) {
alert(
'사원 조회 중 오류가 발생했습니다.'
)
}
}
onMounted(
loadEmployee
)
</script>
<template>
<div>
<h2>
사원 상세정보
</h2>
<div v-if="employee">
<p>
번호 :
{{ employee.employee_id }}
</p>
<p>
이름 :
{{ employee.name }}
</p>
<p>
전화번호 :
{{ employee.phone }}
</p>
<p>
부서 :
{{ employee.department }}
</p>
<router-link
:to="
'/employees/'
+ employee.employee_id
+ '/edit'
"
>
수정
</router-link>
</div>
</div>
</template>
/employees/3
{
path:
'/employees/:id'
}
const route = useRoute() route.params.id
getEmployee( route.params.id )
<script setup>
import {
reactive,
onMounted
} from 'vue'
import {
useRoute,
useRouter
} from 'vue-router'
import {
getEmployee,
updateEmployee
} from '../api/employeeApi'
const route = useRoute()
const router = useRouter()
const employee = reactive({
name: '',
phone: '',
department: ''
})
async function loadEmployee() {
const response =
await getEmployee(
route.params.id
)
employee.name =
response.data.name
employee.phone =
response.data.phone
employee.department =
response.data.department
}
async function save() {
try {
await updateEmployee(
route.params.id,
employee
)
alert(
'수정되었습니다.'
)
router.push(
'/employees'
)
} catch (error) {
if (
error.response
&&
error.response.data
) {
alert(
error.response
.data.detail
)
}
}
}
onMounted(
loadEmployee
)
</script>
<template>
<div>
<h2>
사원 수정
</h2>
<p>
이름
<input
v-model="employee.name"
>
</p>
<p>
전화번호
<input
v-model="employee.phone"
>
</p>
<p>
부서
<select
v-model="
employee.department
"
>
<option value="개발팀">
개발팀
</option>
<option value="영업팀">
영업팀
</option>
<option value="관리팀">
관리팀
</option>
</select>
</p>
<button @click="save">
수정
</button>
</div>
</template>
<button
@click="
removeEmployee(
emp.employee_id
)
"
>
삭제
</button>
async function removeEmployee(
id
) {
const result = confirm(
'삭제하시겠습니까?'
)
if (!result) {
return
}
await deleteEmployee(id)
await loadEmployees()
}
npm run dev
| 프로그램 | 주소 |
|---|---|
| Vue | http://localhost:5173 |
| FastAPI | http://localhost:8000 |
cd backend uvicorn main:app --reload
cd frontend npm run dev
def load_employees():
rows = (
repository.find_all()
)
for row in rows:
tree.insert(
"",
"end",
values=row
)
async function loadEmployees() {
const response =
await getEmployees()
employees.value =
response.data
}
| Tkinter | Vue 웹 프로그램 |
|---|---|
| 화면 → Python 함수 직접 호출 → DB | 화면 → HTTP → FastAPI → Python 함수 → DB |
기존 프로그램에 다음과 같은 파일이 있었다고 가정합니다.
화면과 독립적으로 작성된 employee_repository.py와
employee_service.py는 FastAPI 프로젝트에서도 상당 부분 그대로 활용할 수 있습니다.
사원관리 프로그램을 완성했다면 같은 구조로 회원관리, 상품관리, 게시판 등을 만들 수 있습니다.
ALTER TABLE employee ADD COLUMN position TEXT;
다음 부분도 함께 수정해야 합니다.
<select
v-model="employee.position"
>
<option value="사원">
사원
</option>
<option value="대리">
대리
</option>
<option value="과장">
과장
</option>
<option value="차장">
차장
</option>
<option value="부장">
부장
</option>
</select>
- 전체 애플리케이션의 기본 구조는 Vue → Axios → FastAPI → Service → Repository → SQLite입니다.
- Vue는 화면 표시와 사용자 입력을 담당합니다.
- Axios는 Vue와 FastAPI 사이의 HTTP 통신을 담당합니다.
- FastAPI Router는 GET, POST, PUT, DELETE 요청을 받습니다.
- Service는 이름·전화번호 검증과 같은 업무 규칙을 담당합니다.
- Repository는 SQLite의 SELECT, INSERT, UPDATE, DELETE를 담당합니다.
-
목록 화면은
onMounted()에서 GET API를 호출하고 결과를employees.value에 저장합니다. -
등록 화면은
v-model로 입력값을 객체에 저장하고 POST 요청으로 서버에 전달합니다. -
상세 화면은
route.params.id를 이용하여 URL의 사원번호를 가져옵니다. - 수정 화면은 기존 정보를 GET으로 조회한 뒤 변경된 데이터를 PUT 요청으로 전달합니다.
- 삭제 후 다시 GET 요청을 실행하면 최신 목록으로 화면을 갱신할 수 있습니다.
- 기존 Tkinter 프로그램과 웹 프로그램의 가장 큰 차이는 화면과 Python 코드 사이에 HTTP와 FastAPI가 추가된다는 것입니다.
- 기존 Service와 Repository가 화면과 독립적으로 작성되어 있다면 FastAPI 프로젝트에서도 상당 부분 재사용할 수 있습니다.
- 회원관리, 상품관리, 게시판도 같은 View → API → Router → Service → Repository → Database 구조로 확장할 수 있습니다.
- 새로운 필드를 하나 추가할 때도 DB → Repository → Service → API → Vue 전체 흐름을 함께 수정해야 합니다.