Python 프로그램 웹 전환 프로젝트
Vue + FastAPI 기반 웹 프로그램으로 단계적으로 전환합니다.
이번 장에서는 기존 Python/Tkinter 사원관리 프로그램을 Vue + FastAPI 웹 프로그램으로 전환합니다.
기존 사원관리 프로그램은 다음 기능을 가지고 있다고 가정합니다.
웹에서는 기능별로 화면을 나누는 것이 편리합니다.
| URL | 화면 |
|---|---|
| /employees | 사원 목록 |
| /employees/new | 사원 등록 |
| /employees/1 | 사원 상세 |
| /employees/1/edit | 사원 수정 |
import tkinter as tk
from tkinter import ttk
window = tk.Tk()
window.title(
"사원관리 프로그램"
)
tk.Label(
window,
text="이름"
).grid(
row=0,
column=0
)
entry_name = tk.Entry(
window
)
entry_name.grid(
row=0,
column=1
)
tk.Label(
window,
text="전화번호"
).grid(
row=1,
column=0
)
entry_phone = tk.Entry(
window
)
entry_phone.grid(
row=1,
column=1
)
combo_department = ttk.Combobox(
window,
values=(
"개발팀",
"영업팀",
"관리팀"
)
)
combo_department.grid(
row=2,
column=1
)
tree = ttk.Treeview(
window,
columns=(
"id",
"name",
"phone",
"department"
),
show="headings"
)
tree.grid(
row=5,
column=0,
columnspan=2
)
window.mainloop()
이 화면 역할은 Vue가 담당합니다.
entry_name.get() tree.insert(...) messagebox.showinfo(...)
def validate_name(name):
return (
name.strip() != ""
)
def find_all():
cursor.execute(
"""
SELECT *
FROM employee
"""
)
import sqlite3
DB_NAME = "employee.db"
def get_connection():
conn = sqlite3.connect(
DB_NAME
)
conn.row_factory = (
sqlite3.Row
)
return conn
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 search_by_name(
keyword
):
conn = get_connection()
cursor = conn.cursor()
cursor.execute(
"""
SELECT
employee_id,
name,
phone,
department
FROM employee
WHERE name LIKE ?
ORDER BY employee_id
""",
(
"%" + keyword + "%",
)
)
rows = cursor.fetchall()
conn.close()
return [
dict(row)
for row in rows
]
@router.get(
"/search"
)
def search_employees(
keyword: str = ""
):
return (
employee_service
.search_employees(
keyword
)
)
def search_employees(
keyword
):
keyword = keyword.strip()
if keyword == "":
return (
employee_repository
.find_all()
)
return (
employee_repository
.search_by_name(
keyword
)
)
/employees/search?keyword=홍길동
여기서 keyword는 Query Parameter입니다.
def search_employees(
keyword: str = ""
):
...
export function searchEmployees(
keyword
) {
return api.get(
'/employees/search',
{
params: {
keyword: keyword
}
}
)
}
<script setup>
import {
ref,
onMounted
} from 'vue'
import {
getEmployees,
searchEmployees,
deleteEmployee
} from '../api/employeeApi'
const employees = ref([])
const keyword = ref('')
async function loadEmployees() {
const response =
await getEmployees()
employees.value =
response.data
}
async function search() {
const response =
await searchEmployees(
keyword.value
)
employees.value =
response.data
}
async function resetSearch() {
keyword.value = ''
await loadEmployees()
}
async function removeEmployee(
id
) {
if (
!confirm(
'삭제하시겠습니까?'
)
) {
return
}
await deleteEmployee(id)
await loadEmployees()
}
onMounted(
loadEmployees
)
</script>
<template>
<div>
<h2>
사원 목록
</h2>
<div>
<input
v-model="keyword"
placeholder="이름 검색"
>
<button
@click="search"
>
검색
</button>
<button
@click="resetSearch"
>
전체
</button>
</div>
<br>
<router-link
to="/employees/new"
>
사원 등록
</router-link>
<br><br>
<table 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>
def search():
keyword = (
entry_search.get()
)
employees = (
repository
.search_by_name(
keyword
)
)
clear_tree()
for emp in employees:
tree.insert(
"",
"end",
values=emp
)
async function search() {
const response =
await searchEmployees(
keyword.value
)
employees.value =
response.data
}
| Tkinter | Vue |
|---|---|
| Repository 직접 호출 | API 호출 |
| Treeview 직접 변경 | employees 데이터 변경 |
| 화면을 코드로 수정 | 데이터 변경 후 화면 자동 갱신 |
등록과 수정 화면에는 이름, 전화번호, 부서라는 동일한 입력 항목이 있습니다. 따라서 공통 컴포넌트로 분리할 수 있습니다.
<script setup>
const props = defineProps({
employee: Object,
buttonText: {
type: String,
default: '저장'
}
})
const emit = defineEmits([
'save'
])
function save() {
emit('save')
}
</script>
<template>
<div>
<p>
이름
<input
v-model="
props.employee.name
"
>
</p>
<p>
전화번호
<input
v-model="
props.employee.phone
"
>
</p>
<p>
부서
<select
v-model="
props.employee.department
"
>
<option value="개발팀">
개발팀
</option>
<option value="영업팀">
영업팀
</option>
<option value="관리팀">
관리팀
</option>
</select>
</p>
<button @click="save">
{{ buttonText }}
</button>
</div>
</template>
<script setup>
import {
reactive
} from 'vue'
import {
useRouter
} from 'vue-router'
import EmployeeForm
from '../components/EmployeeForm.vue'
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) {
alert(
error.response?.data?.detail
||
'등록 중 오류가 발생했습니다.'
)
}
}
</script>
<template>
<h2>
사원 등록
</h2>
<EmployeeForm
:employee="employee"
button-text="등록"
@save="save"
/>
</template>
<script setup>
import {
reactive,
onMounted
} from 'vue'
import {
useRoute,
useRouter
} from 'vue-router'
import EmployeeForm
from '../components/EmployeeForm.vue'
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
)
Object.assign(
employee,
response.data
)
}
async function save() {
try {
await updateEmployee(
route.params.id,
employee
)
alert(
'수정되었습니다.'
)
router.push(
'/employees'
)
} catch (error) {
alert(
error.response?.data?.detail
||
'수정 중 오류가 발생했습니다.'
)
}
}
onMounted(
loadEmployee
)
</script>
<template>
<h2>
사원 수정
</h2>
<EmployeeForm
:employee="employee"
button-text="수정"
@save="save"
/>
</template>
EmployeeForm 컴포넌트를 사용하게 됩니다.
menu = tk.Menu(
window
)
employee_menu = tk.Menu(
menu,
tearoff=0
)
employee_menu.add_command(
label="사원목록"
)
employee_menu.add_command(
label="사원등록"
)
menu.add_cascade(
label="사원관리",
menu=employee_menu
)
window.config(
menu=menu
)
<nav>
<router-link
to="/employees"
>
사원목록
</router-link>
|
<router-link
to="/employees/new"
>
사원등록
</router-link>
</nav>
| Tkinter | Vue |
|---|---|
| EmployeeListFrame | EmployeeListView.vue |
| EmployeeCreateFrame | EmployeeCreateView.vue |
| EmployeeEditFrame | EmployeeEditView.vue |
웹에서는 Frame을 앞으로 가져오는 대신 Vue Router가 화면 이동을 담당합니다.
messagebox.showinfo(
"확인",
"등록되었습니다."
)
alert( '등록되었습니다.' )
result = (
messagebox.askyesno(
"확인",
"삭제하시겠습니까?"
)
)
const result = confirm( '삭제하시겠습니까?' )
alert()와 confirm() 대신 별도의 Vue 모달 컴포넌트를 사용할 수도 있습니다.
from ui import (
EmployeeWindow
)
if __name__ == "__main__":
app = EmployeeWindow()
app.run()
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
)
uvicorn main:app --reload
기존 Python에서는 if __name__ == "__main__": 부분이
프로그램 실행의 시작점 역할을 했습니다.
Vue에서는 main.js가 시작점입니다.
import {
createApp
} from 'vue'
import App
from './App.vue'
import router
from './router'
createApp(App)
.use(router)
.mount('#app')
CREATE TABLE employee (
employee_id INTEGER
PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
department TEXT
);
기존 DB 테이블 구조가 웹 프로그램에서 필요한 구조와 같다면 데이터베이스를 새로 만들 필요가 없습니다.
웹 프로그램으로 변경한다고 해서 기존 데이터를 다시 입력할 필요는 없습니다. FastAPI Repository가 기존 DB 파일에 접근하도록 만들면 됩니다.
from pathlib import Path
import sqlite3
BASE_DIR = (
Path(__file__)
.resolve()
.parent
)
DB_PATH = (
BASE_DIR
/ "employee.db"
)
def get_connection():
conn = sqlite3.connect(
DB_PATH
)
conn.row_factory = (
sqlite3.Row
)
return conn
database.py가 있는 폴더를 기준으로 DB 경로를 찾기 쉬워집니다.
VITE_API_URL=http://localhost:8000
import axios from 'axios'
const api = axios.create({
baseURL:
import.meta.env
.VITE_API_URL
})
export default api
VITE_API_URL=https://api.example.com
환경에 따라 API 주소만 변경할 수 있습니다.
const loading = ref(false)
async function loadEmployees() {
loading.value = true
try {
const response =
await getEmployees()
employees.value =
response.data
} finally {
loading.value = false
}
}
<p v-if="loading"> 데이터를 불러오는 중입니다. </p>
<tr
v-if="
employees.length === 0
"
>
<td colspan="5">
등록된 사원이 없습니다.
</td>
</tr>
목록이 있으면 기존처럼 v-for를 사용합니다.
<tr v-for="emp in employees" :key="emp.employee_id" > ... </tr>
async function save() {
if (
employee.name
.trim() === ''
) {
alert(
'이름을 입력하세요.'
)
return
}
await createEmployee(
employee
)
}
사용자가 잘못 입력한 경우 서버 요청을 보내기 전에 바로 알려줄 수 있습니다.
def validate_name(name):
return (
name.strip() != ""
)
<router-link to="/employees" > 목록 </router-link>
<router-link
:to="
'/employees/'
+ employee.employee_id
+ '/edit'
"
>
수정
</router-link>
웹에서는 화면 이동을 URL 중심으로 구성할 수 있습니다.
async function remove() {
if (
!confirm(
'삭제하시겠습니까?'
)
) {
return
}
await deleteEmployee(
route.params.id
)
alert(
'삭제되었습니다.'
)
router.push(
'/employees'
)
}
목록 화면이 정상적으로 출력되는 것을 확인한 뒤 다음 기능으로 넘어갑니다.
등록이 완료되면 목록 화면으로 이동합니다.
import os
files = os.listdir(
"data"
)
Vue에서 서버 파일을 직접 읽지 않고 FastAPI API를 만듭니다.
@app.get("/files")
def get_files():
return os.listdir(
"data"
)
const response =
await api.get(
'/files'
)
def search_data():
# 외부 API 호출
return result
| 기존 Tkinter 프로그램 | 웹 프로그램 |
|---|---|
| Python이 화면까지 담당 | Vue가 화면 담당 |
| Python 함수 직접 호출 | FastAPI를 HTTP로 호출 |
| Python이 업무 로직 처리 | FastAPI / Python이 업무 로직 처리 |
| Python이 DB 접근 | FastAPI 서버의 Repository가 DB 접근 |
| 화면과 서버가 한 프로그램 | 프론트엔드와 백엔드 역할 분리 |
| Tkinter 코드 | 웹에서 대체 |
|---|---|
| import tkinter | Vue / HTML / CSS |
| Tk() | Vue 애플리케이션 |
| Label | label, p, span 등 |
| Entry | <input> |
| Button | <button> |
| Frame | <div>, Vue 컴포넌트 |
| Treeview | <table>, v-for |
| pack() | CSS 레이아웃 |
| grid() | CSS Grid |
| place() | CSS 위치 지정 |
| mainloop() | 브라우저와 Vue |
| messagebox | alert(), confirm() 또는 웹 UI |
| 기존 Python 코드 | 웹 전환 후 |
|---|---|
| SQL | 대부분 유지 |
| sqlite3 DB 처리 | 서버에서 유지 |
| 계산 함수 | 유지 |
| 데이터 검증 함수 | 유지 |
| 파일 처리 | 유지 |
| Excel 처리 | 유지 |
| 데이터 변환 | 유지 |
| 외부 API 호출 | 유지 |
| Python 라이브러리 | 대부분 유지 |
| 업무 규칙 | 유지 |
services, repositories, models가
이미 화면에서 분리되어 있다면 웹 전환 시 상당 부분 그대로 활용할 수 있습니다.
CREATE TABLE member (
member_id INTEGER
PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
address TEXT
);
| Method | URL | 기능 |
|---|---|---|
| GET | /members | 회원 목록 |
| GET | /members/{id} | 회원 상세 |
| GET | /members/search?keyword=홍 | 회원 검색 |
| POST | /members | 회원 등록 |
| PUT | /members/{id} | 회원 수정 |
| DELETE | /members/{id} | 회원 삭제 |
화면 주소는 /member 또는 /members 형태로 구성할 수 있습니다.
| 기존 코드 판단 | 웹 전환 방법 |
|---|---|
| 화면 코드인가? | Vue로 변경 |
| Python 업무 코드인가? | Service에서 재사용 |
| DB 코드인가? | Repository에서 재사용 |
| 외부에서 호출해야 하는가? | FastAPI Router 추가 |
- Python/Tkinter 프로그램을 웹으로 전환할 때 전체 프로그램을 처음부터 다시 만들 필요는 없습니다.
- 가장 먼저 기존 코드를 UI · 업무 로직 · DB 코드로 구분합니다.
- Tkinter의 Entry, Button, Treeview, Frame, messagebox 같은 화면 종속 코드는 Vue로 변경합니다.
- 검증, 계산, 데이터 가공 같은 기존 Python 업무 로직은 Service에서 재사용할 수 있습니다.
- SQL과 sqlite3 기반 데이터 접근 코드는 Repository에서 재사용할 수 있습니다.
- Vue는 Repository를 직접 호출하지 않고 Axios → FastAPI Router를 통해 서버에 요청합니다.
-
이름 검색은 Query Parameter를 이용해
/employees/search?keyword=홍길동형태로 구현할 수 있습니다. - 등록과 수정 화면의 입력 항목이 같다면 EmployeeForm.vue와 같은 공통 컴포넌트로 분리할 수 있습니다.
- Tkinter 메뉴와 Frame 화면 전환은 Vue Router와 router-link로 변경합니다.
- 기존 SQLite 테이블 구조가 그대로 사용 가능하면 DB와 기존 데이터를 새로 만들지 않고 유지할 수 있습니다.
-
DB 경로는
Path(__file__).resolve().parent와 같이 파일 기준으로 관리하면 실행 위치에 따른 문제를 줄일 수 있습니다. -
API 주소는
.env와VITE_API_URL로 분리할 수 있습니다. - Vue의 검증은 사용자 편의를 위한 1차 검증, FastAPI의 검증은 데이터 보호를 위한 2차 검증으로 사용할 수 있습니다.
- 큰 프로그램은 조회 → 등록 → 수정 → 삭제 → 검색 순서처럼 기능별로 하나씩 전환하는 것이 좋습니다.
- 파일 처리, Excel 처리, 이미지 처리, 외부 API 호출 등 기존 Python 기능도 FastAPI 서버 뒤에서 계속 사용할 수 있습니다.
- 최종 구조는 Browser → Vue → Axios → FastAPI Router → Service → Repository → SQLite 로 이해할 수 있습니다.
- 가장 중요한 기준은 화면은 Vue, 업무 로직은 Service, DB는 Repository, 외부 호출 지점은 FastAPI Router 로 역할을 나누는 것입니다.