6. Data 조회

1. 목표
더보기
- PySide6 폼에서 SELECT 문으로 users 데이터를 조회한 뒤, QTableWidget에 행 단위로 표시한다.
- QAbstractItemView 열거형을 사용해서 행 단위 선택, 읽기 전용 테이블을 설정하는 방법을 익힌다.
- 오류 발생 시 QMessageBox와 QLabel, QFile + QTextStream으로 사용자 메시지와 로그를 남기는 흐름을 이해한다.
2. 전체 로직
더보기
1단계: 연결 테스트
2단계: 데이터베이스 생성
3단계: users 테이블 생성
import sys
from PySide6.QtWidgets import (
QApplication,
QWidget,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QFormLayout,
QMessageBox,
QGroupBox,
QTableWidget,
QTableWidgetItem,
QHeaderView,
QAbstractItemView,
)
from PySide6.QtCore import QFile, QIODevice, QTextStream, Qt
import mysql.connector as mc
class MySqlDemoWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("PySide6 MySQL 사용자 관리 데모")
self.resize(720, 620)
self.host_edit = QLineEdit()
self.user_edit = QLineEdit()
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.Password)
self.db_edit = QLineEdit()
self.host_edit.setText("localhost")
self.user_edit.setText("root")
conn_form = QFormLayout()
conn_form.addRow("호스트", self.host_edit)
conn_form.addRow("사용자", self.user_edit)
conn_form.addRow("비밀번호", self.password_edit)
conn_form.addRow("데이터베이스 이름", self.db_edit)
self.btn_connect = QPushButton("연결 테스트")
self.btn_create_db = QPushButton("데이터베이스 생성")
self.btn_create_table = QPushButton("users 테이블 생성")
conn_button_layout = QVBoxLayout()
conn_button_layout.addWidget(self.btn_connect)
conn_button_layout.addWidget(self.btn_create_db)
conn_button_layout.addWidget(self.btn_create_table)
conn_group_layout = QVBoxLayout()
conn_group_layout.addLayout(conn_form)
conn_group_layout.addLayout(conn_button_layout)
conn_group = QGroupBox("1~3단계: 연결·DB·테이블 생성")
conn_group.setLayout(conn_group_layout)
self.username_edit = QLineEdit()
self.password_user_edit = QLineEdit()
self.password_user_edit.setEchoMode(QLineEdit.Password)
insert_form = QFormLayout()
insert_form.addRow("사용자 이름", self.username_edit)
insert_form.addRow("사용자 비밀번호", self.password_user_edit)
self.btn_insert_user = QPushButton("users 테이블에 사용자 추가")
insert_group_layout = QVBoxLayout()
insert_group_layout.addLayout(insert_form)
insert_group_layout.addWidget(self.btn_insert_user)
insert_group = QGroupBox("4단계: PySide6 폼에서 데이터 입력 후 MySQL 저장")
insert_group.setLayout(insert_group_layout)
self.table_users = QTableWidget()
self.table_users.setColumnCount(3)
self.table_users.setHorizontalHeaderLabels(["id", "username", "password"])
self.table_users.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table_users.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows
)
self.table_users.setEditTriggers(
QAbstractItemView.EditTrigger.NoEditTriggers
)
self.btn_load_users = QPushButton("users 테이블 전체 조회")
table_group_layout = QVBoxLayout()
table_group_layout.addWidget(self.btn_load_users)
table_group_layout.addWidget(self.table_users)
table_group = QGroupBox("5단계: MySQL 데이터 조회 및 QTableWidget에 표시")
table_group.setLayout(table_group_layout)
self.result_label = QLabel("결과 메시지가 여기에 표시됩니다.")
self.result_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
main_layout = QVBoxLayout(self)
main_layout.addWidget(conn_group)
main_layout.addWidget(insert_group)
main_layout.addWidget(table_group)
main_layout.addWidget(self.result_label)
self.btn_connect.clicked.connect(self.connect_to_server_or_db)
self.btn_create_db.clicked.connect(self.create_database)
self.btn_create_table.clicked.connect(self.create_users_table)
self.btn_insert_user.clicked.connect(self.insert_user)
self.btn_load_users.clicked.connect(self.load_users)
def connect_to_server_or_db(self):
host = self.host_edit.text().strip()
user = self.user_edit.text().strip()
password = self.password_edit.text()
db_name = self.db_edit.text().strip()
if not host or not user:
QMessageBox.warning(self, "입력 오류", "호스트와 사용자 이름은 반드시 입력해야 합니다.")
return
conn = None
try:
if db_name:
conn = mc.connect(
host=host,
user=user,
password=password,
database=db_name,
)
else:
conn = mc.connect(
host=host,
user=user,
password=password,
)
if conn.is_connected():
if db_name:
msg = f"MySQL 서버에 연결되었습니다.\n사용 중인 데이터베이스: {db_name}"
else:
msg = "MySQL 서버에 연결되었습니다.\n데이터베이스 이름은 아직 지정되지 않았습니다."
self.result_label.setText(msg)
QMessageBox.information(self, "연결 성공", msg)
except mc.Error as e:
self.handle_mysql_error(e, "연결 중 오류가 발생했습니다.", "연결 오류")
finally:
try:
if conn and conn.is_connected():
conn.close()
except Exception:
pass
def create_database(self):
host = self.host_edit.text().strip()
user = self.user_edit.text().strip()
password = self.password_edit.text()
db_name = self.db_edit.text().strip()
if not host or not user or not db_name:
QMessageBox.warning(self, "입력 오류", "호스트, 사용자, 데이터베이스 이름을 모두 입력해야 합니다.")
return
conn = None
try:
conn = mc.connect(
host=host,
user=user,
password=password,
)
cursor = conn.cursor()
cursor.execute(f"CREATE DATABASE {db_name}")
conn.commit()
msg = f"{db_name} 데이터베이스가 생성되었습니다."
self.result_label.setText(msg)
QMessageBox.information(self, "데이터베이스 생성 완료", msg)
self.write_log_with_qt(f"데이터베이스 생성: {db_name}")
except mc.Error as e:
self.handle_mysql_error(e, "데이터베이스 생성에 실패했습니다.", "데이터베이스 생성 오류")
finally:
try:
if conn and conn.is_connected():
conn.close()
except Exception:
pass
def create_users_table(self):
host = self.host_edit.text().strip()
user = self.user_edit.text().strip()
password = self.password_edit.text()
db_name = self.db_edit.text().strip()
if not host or not user or not db_name:
QMessageBox.warning(self, "입력 오류", "테이블을 생성할 데이터베이스 이름까지 모두 입력해야 합니다.")
return
create_table_sql = """
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(150) NOT NULL,
password VARCHAR(150) NOT NULL
)
"""
conn = None
try:
conn = mc.connect(
host=host,
user=user,
password=password,
database=db_name,
)
cursor = conn.cursor()
cursor.execute(create_table_sql)
conn.commit()
msg = f"{db_name} 데이터베이스에 users 테이블이 생성되었습니다."
self.result_label.setText(msg)
QMessageBox.information(self, "테이블 생성 완료", msg)
self.write_log_with_qt(f"테이블 생성: {db_name}.users")
except mc.Error as e:
self.handle_mysql_error(e, "테이블 생성 중 오류가 발생했습니다.", "테이블 생성 오류")
finally:
try:
if conn and conn.is_connected():
conn.close()
except Exception:
pass
def insert_user(self):
host = self.host_edit.text().strip()
user = self.user_edit.text().strip()
password = self.password_edit.text()
db_name = self.db_edit.text().strip()
username = self.username_edit.text().strip()
user_password = self.password_user_edit.text().strip()
if not host or not user or not db_name:
QMessageBox.warning(self, "입력 오류", "먼저 접속 정보와 데이터베이스 이름을 모두 입력해야 합니다.")
return
if not username or not user_password:
QMessageBox.warning(self, "입력 오류", "사용자 이름과 비밀번호를 모두 입력해야 합니다.")
return
conn = None
try:
conn = mc.connect(
host=host,
user=user,
password=password,
database=db_name,
)
cursor = conn.cursor()
query = "INSERT INTO users (username, password) VALUES (%s, %s)"
values = (username, user_password)
cursor.execute(query, values)
conn.commit()
msg = f"사용자 {username} 정보가 저장되었습니다."
self.result_label.setText(msg)
QMessageBox.information(self, "저장 완료", msg)
self.write_log_with_qt(f"사용자 추가 성공: {db_name}.users, username={username}")
self.username_edit.clear()
self.password_user_edit.clear()
self.username_edit.setFocus()
except mc.Error as e:
self.handle_mysql_error(e, "데이터 저장 중 오류가 발생했습니다.", "저장 오류", username=username)
finally:
try:
if conn and conn.is_connected():
conn.close()
except Exception:
pass
def load_users(self):
host = self.host_edit.text().strip()
user = self.user_edit.text().strip()
password = self.password_edit.text()
db_name = self.db_edit.text().strip()
if not host or not user or not db_name:
QMessageBox.warning(self, "입력 오류", "먼저 접속 정보와 데이터베이스 이름을 모두 입력해야 합니다.")
return
conn = None
try:
conn = mc.connect(
host=host,
user=user,
password=password,
database=db_name,
)
cursor = conn.cursor()
query = "SELECT id, username, password FROM users ORDER BY id ASC"
cursor.execute(query)
rows = cursor.fetchall()
self.table_users.setRowCount(len(rows))
for row_index, row_data in enumerate(rows):
id_value, username_value, password_value = row_data
item_id = QTableWidgetItem(str(id_value))
item_username = QTableWidgetItem(username_value)
item_password = QTableWidgetItem(password_value)
item_id.setTextAlignment(Qt.AlignCenter)
item_username.setTextAlignment(Qt.AlignCenter)
item_password.setTextAlignment(Qt.AlignCenter)
self.table_users.setItem(row_index, 0, item_id)
self.table_users.setItem(row_index, 1, item_username)
self.table_users.setItem(row_index, 2, item_password)
msg = f"총 {len(rows)}개의 사용자 데이터를 조회했습니다."
self.result_label.setText(msg)
self.write_log_with_qt(f"사용자 조회: {db_name}.users, 행 수={len(rows)}")
except mc.Error as e:
self.handle_mysql_error(e, "데이터 조회 중 오류가 발생했습니다.", "조회 오류")
finally:
try:
if conn and conn.is_connected():
conn.close()
except Exception:
pass
def handle_mysql_error(self, error, ui_message, title, username=None):
self.result_label.setText(ui_message)
if hasattr(error, "errno") and error.errno == 1045:
detail = (
"접속이 거부되었습니다.\n\n"
"가능한 원인\n"
"1. 사용자 이름이 잘못되었습니다.\n"
"2. 비밀번호가 잘못되었거나 비밀번호가 필요한데 비워 둔 상태입니다.\n"
"3. 현재 MySQL 서버 설정에서 root 계정 대신 별도 계정 사용이 요구될 수 있습니다.\n\n"
"해결 방법\n"
"1. MySQL 클라이언트에서 직접 로그인해 사용자와 비밀번호를 확인합니다.\n"
"2. 이 창의 사용자와 비밀번호 입력란에 동일한 정보를 입력합니다.\n"
"3. 필요하다면 애플리케이션 전용 계정을 새로 생성하고 권한을 부여합니다."
)
QMessageBox.critical(
self,
title,
f"{ui_message}\n\n오류 코드: 1045\n오류 내용: {error}\n\n{detail}",
)
else:
QMessageBox.critical(
self,
title,
f"{ui_message}\n\n오류 내용: {error}",
)
if username is not None:
self.write_log_with_qt(f"사용자 추가 실패: {username}, 오류: {error}")
else:
self.write_log_with_qt(f"MySQL 작업 실패, 오류: {error}")
def write_log_with_qt(self, message):
file = QFile("db_log_all_steps.txt")
if not file.open(QIODevice.Append | QIODevice.Text):
return
stream = QTextStream(file)
stream << message << "\n"
file.close()
# main.py
import sys
from PySide6.QtWidgets import QApplication
from mainwindow import DbConnectWindow
if __name__ == "__main__":
app = QApplication(sys.argv)
w = DbConnectWindow()
w.show()
sys.exit(app.exec())
3. 필요한 import + 주석 설명
더보기
from PySide6.QtWidgets import (
QApplication,
QWidget,
QLabel,
QLineEdit,
QPushButton,
QVBoxLayout,
QFormLayout,
QMessageBox,
QGroupBox,
QTableWidget, # 데이터베이스에서 조회한 레코드를 행·열 형태로 보여 주는 테이블 위젯
QTableWidgetItem, # QTableWidget의 한 셀을 표현하는 아이템 클래스
QHeaderView, # 테이블 헤더(컬럼 헤더)의 크기 조정, 모양을 제어하기 위한 클래스
QAbstractItemView, # 테이블과 같은 뷰의 선택 방식, 편집 트리거 등을 제어하는 기반 클래스
)
from PySide6.QtCore import QFile, QIODevice, QTextStream, Qt
- QTableWidget
MySQL에서 가져온 결과 집합을 메모리로 읽어와서 행과 열로 보여 줄 때 사용한다. - QTableWidgetItem
한 셀에 들어갈 텍스트를 표현하고, 정렬 방식을 지정할 수 있다. - QHeaderView
setSectionResizeMode(QHeaderView.Stretch)로 컬럼 폭을 자동으로 창 크기에 맞게 늘리도록 설정한다. - QAbstractItemView
선택 행위와 편집 행위를 통제하는 열거형을 제공한다.
SelectionBehavior.SelectRows와 EditTrigger.NoEditTriggers를 사용해서
행 단위 선택, 편집 금지를 설정한다.
4. GUI 구현부 소스코드와 주석
더보기
class MySqlDemoWindow(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("PySide6 MySQL 사용자 관리 데모")
self.resize(720, 620)
# 1) MySQL 접속 정보 입력 위젯들
self.host_edit = QLineEdit()
self.user_edit = QLineEdit()
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.Password) # 비밀번호는 입력 시 가려서 표시
self.db_edit = QLineEdit()
# 자주 사용하는 기본값 세팅
self.host_edit.setText("localhost")
self.user_edit.setText("root")
# 폼 레이아웃에 접속 정보 배치
conn_form = QFormLayout()
conn_form.addRow("호스트", self.host_edit)
conn_form.addRow("사용자", self.user_edit)
conn_form.addRow("비밀번호", self.password_edit)
conn_form.addRow("데이터베이스 이름", self.db_edit)
# 2) 연결 / DB 생성 / 테이블 생성 버튼들
self.btn_connect = QPushButton("연결 테스트")
self.btn_create_db = QPushButton("데이터베이스 생성")
self.btn_create_table = QPushButton("users 테이블 생성")
conn_button_layout = QVBoxLayout()
conn_button_layout.addWidget(self.btn_connect)
conn_button_layout.addWidget(self.btn_create_db)
conn_button_layout.addWidget(self.btn_create_table)
# 접속 정보 + 버튼들을 하나의 그룹으로 묶기
conn_group_layout = QVBoxLayout()
conn_group_layout.addLayout(conn_form)
conn_group_layout.addLayout(conn_button_layout)
conn_group = QGroupBox("1~3단계: 연결·DB·테이블 생성")
conn_group.setLayout(conn_group_layout)
# 3) 사용자 입력 폼 (INSERT용)
self.username_edit = QLineEdit()
self.password_user_edit = QLineEdit()
self.password_user_edit.setEchoMode(QLineEdit.Password)
insert_form = QFormLayout()
insert_form.addRow("사용자 이름", self.username_edit)
insert_form.addRow("사용자 비밀번호", self.password_user_edit)
self.btn_insert_user = QPushButton("users 테이블에 사용자 추가")
insert_group_layout = QVBoxLayout()
insert_group_layout.addLayout(insert_form)
insert_group_layout.addWidget(self.btn_insert_user)
insert_group = QGroupBox("4단계: PySide6 폼에서 데이터 입력 후 MySQL 저장")
insert_group.setLayout(insert_group_layout)
# 4) 사용자 목록을 보여 줄 QTableWidget과 조회 버튼
self.table_users = QTableWidget()
self.table_users.setColumnCount(3) # id, username, password 세 컬럼
self.table_users.setHorizontalHeaderLabels(["id", "username", "password"])
# 헤더 크기를 창 크기에 맞게 고르게 늘리기
self.table_users.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
# 행 단위 선택 설정 (한 셀만 아니라, 전체 행 단위로 선택되도록)
self.table_users.setSelectionBehavior(
QAbstractItemView.SelectionBehavior.SelectRows
)
# 테이블 안에서 직접 수정하지 못하게 설정
self.table_users.setEditTriggers(
QAbstractItemView.EditTrigger.NoEditTriggers
)
self.btn_load_users = QPushButton("users 테이블 전체 조회")
table_group_layout = QVBoxLayout()
table_group_layout.addWidget(self.btn_load_users)
table_group_layout.addWidget(self.table_users)
table_group = QGroupBox("5단계: MySQL 데이터 조회 및 QTableWidget에 표시")
table_group.setLayout(table_group_layout)
# 5) 결과 메시지 표시용 라벨
self.result_label = QLabel("결과 메시지가 여기에 표시됩니다.")
self.result_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
# 6) 전체 레이아웃 구성
main_layout = QVBoxLayout(self)
main_layout.addWidget(conn_group)
main_layout.addWidget(insert_group)
main_layout.addWidget(table_group)
main_layout.addWidget(self.result_label)
- 첫 번째 그룹박스: 접속 정보 입력, 연결 테스트, DB 생성, 테이블 생성
- 두 번째 그룹박스: 사용자 입력 폼, INSERT 기능
- 세 번째 그룹박스: 사용자 조회 버튼, QTableWidget에 리스트 표시
- 가장 아래 라벨: 현재 상태나 결과 메시지를 한 줄로 보여 주는 영역
5. 시그널과 슬롯 연결 부분
더보기
# 5) SELECT: users 테이블 전체 조회 후 QTableWidget에 표시
self.btn_load_users.clicked.connect(self.load_users)
동일한 패턴
6. <Data 조회> 주요 구조
더보기
6.1 <Data 조회> 핵심 요약
# 1. 연결
conn = mc.connect(서버_및_DB_접속_정보)
# 2. 확인
if conn.is_connected():
성공_처리()
# 3. SQL 실행 + 결과 조회
cursor = conn.cursor()
cursor.execute(SELECT_SQL)
rows = cursor.fetchall()
# 4. 결과 사용 (UI/테이블에 표시 등)
결과_표시(rows)
# 5. 종료
conn.close()
SELECT는 INSERT/UPDATE/DELETE와 달리 commit()이 필요하지 않다.
6.2 <Data 조회> 흐름
import mysql.connector as mc
def users_select_flow():
# 1. DB 서버 및 데이터베이스 접속 정보 준비
host = "localhost"
user = "root"
password = "password"
database = "test_db"
# 2. SELECT SQL 준비
select_sql = "SELECT id, username, password FROM users ORDER BY id ASC"
# 3. 지정된 데이터베이스에 연결
conn = mc.connect(
host=host,
user=user,
password=password,
database=database
)
# 4. 서버와의 연결 상태 확인
if conn.is_connected():
print("DB 연결 성공")
# 5. SQL 실행을 위한 커서 생성
cursor = conn.cursor()
# 6. SELECT SQL 실행
cursor.execute(select_sql)
# 7. 조회 결과 가져오기 (행 리스트)
rows = cursor.fetchall()
# 8. 조회 결과 사용 (여기서는 출력, 실제 GUI에서는 테이블에 표시)
for row in rows:
print(row)
# 9. 모든 DB 작업이 끝난 후 연결 종료
conn.close()
# users 테이블 조회 흐름 실행
users_select_flow()
- DB 접속 정보 준비
- SELECT SQL 작성
- 데이터베이스까지 포함하여 연결
- 연결 상태 확인
- 커서 생성
- SELECT 실행
- fetchall()로 결과 수집
- 결과 표시(출력/테이블/리스트 반영)
- 연결 종료
7. 기능 구현 소스코드
더보기
사용자 조회 및 QTableWidget에 표시
def load_users(self):
host = self.host_edit.text().strip()
user = self.user_edit.text().strip()
password = self.password_edit.text()
db_name = self.db_edit.text().strip()
# 조회에도 접속 정보와 DB명이 필요
if not host or not user or not db_name:
QMessageBox.warning(self, "입력 오류", "먼저 접속 정보와 데이터베이스 이름을 모두 입력해야 합니다.")
return
conn = None
try:
conn = mc.connect(
host=host,
user=user,
password=password,
database=db_name,
)
cursor = conn.cursor()
# 1) SELECT SQL 문 기본 구조
query = "SELECT id, username, password FROM users ORDER BY id ASC"
cursor.execute(query)
# 2) 전체 결과를 한 번에 가져오기
rows = cursor.fetchall()
# 3) 테이블 위젯의 행 수를 결과 행 수에 맞게 설정
self.table_users.setRowCount(len(rows))
# 4) 각 행을 순회하면서 QTableWidgetItem을 만들어 넣기
for row_index, row_data in enumerate(rows):
id_value, username_value, password_value = row_data
item_id = QTableWidgetItem(str(id_value))
item_username = QTableWidgetItem(username_value)
item_password = QTableWidgetItem(password_value)
# 각 셀의 텍스트를 가운데 정렬
item_id.setTextAlignment(Qt.AlignCenter)
item_username.setTextAlignment(Qt.AlignCenter)
item_password.setTextAlignment(Qt.AlignCenter)
# 테이블의 [행, 열] 위치에 아이템 배치
self.table_users.setItem(row_index, 0, item_id)
self.table_users.setItem(row_index, 1, item_username)
self.table_users.setItem(row_index, 2, item_password)
msg = f"총 {len(rows)}개의 사용자 데이터를 조회했습니다."
self.result_label.setText(msg)
self.write_log_with_qt(f"사용자 조회: {db_name}.users, 행 수={len(rows)}")
except mc.Error as e:
self.handle_mysql_error(e, "데이터 조회 중 오류가 발생했습니다.", "조회 오류")
finally:
try:
if conn and conn.is_connected():
conn.close()
except Exception:
pass
- SELECT SQL 기본 구조: SELECT id, username, password FROM users ORDER BY id ASC
- cursor.fetchall()로 전체 결과를 가져온 뒤, setRowCount로 테이블 행 수를 맞춘다.
- 각 행을 반복하며 QTableWidgetItem을 생성하고, 셀에 배치한다.
- QHeaderView.Stretch와 함께 사용하면, 데이터가 늘어나도 자동으로 보기 좋게 칼럼이 늘어난다.
8. 실행 테스트
더보기
QTableWidget으로 조회
- 상단 접속 정보와 DB 이름이 올바르게 설정되어 있는지 다시 확인
- users 테이블 전체 조회 버튼 클릭
- QTableWidget에 id, username, password가 행 단위로 표시되는지 확인
로그 파일 확인
- 실행 디렉터리에 생성된 db_log_all_steps.txt 파일을 열어
데이터베이스 생성, 테이블 생성, 사용자 추가, 조회 정보가 기록되어 있는지 확인
8.학습 주요 포인트
더보기
QTableWidget과 QTableWidgetItem을 사용해 MySQL의 레코드를 GUI 테이블에 표시하는 기본 패턴을 익힐 수 있다.
- setColumnCount, setHorizontalHeaderLabels로 컬럼 정의
- setRowCount와 setItem으로 행 데이터를 채우는 방식
- QHeaderView.Stretch로 컬럼 폭 자동 조절
- QAbstractItemView.SelectionBehavior.SelectRows로 행 단위 선택
- QAbstractItemView.EditTrigger.NoEditTriggers로 테이블을 읽기 전용 형태로 사용
MySQL 쿼리 작성 흐름
- CREATE DATABASE, CREATE TABLE, INSERT, SELECT를 순차적으로 실습하면서 전체 데이터 흐름을 체감할 수 있다.
SQL Injection 방지를 위해 문자열 덧붙이기 대신 파라미터 바인딩 방식
INSERT INTO users (username, password) VALUES (%s, %s) + cursor.execute(query, values) 패턴을 사용했다.
오류 처리 공통 함수 handle_mysql_error를 두어
- UI 메시지
- 상세 에러 안내
- 로그 기록
세 가지를 한 번에 관리하도록 구성했다.
단계별 완성 파일