PySide6 리팩토링 2편: Excel 파일 읽기 흐름 분리하기
1편에서는 프로젝트 전체 구조를 살펴보면서 MainWindow가 너무 많은 책임을 갖고 있다는 점을 확인했다.
화면을 구성하고 버튼 이벤트를 연결하는 역할뿐 아니라, 파일을 읽고, 데이터를 변환하고, 좌표를 검증하고, 결과를 저장하는 흐름까지 MainWindow 안에 많이 모여 있었다.
그래서 이번 편에서는 그중에서도 가장 경계가 분명한 기능부터 다시 읽어보려고 한다.
바로 Excel/CSV 파일 읽기와 결과 Excel 저장이다.
이 기능은 현재 app/services/excel\_service.py의 ExcelService가 담당하고 있다.
ExcelService는 어떤 역할을 하는가
ExcelService를 한 문장으로 정리하면 이렇다.
Excel 또는 CSV 파일을 pandas DataFrame으로 읽고, 변환된 결과 DataFrame을 Excel 파일로 저장하는 서비스 클래스
즉, MainWindow가 직접 pd.read\_excel, pd.read\_csv, pd.ExcelWriter 같은 pandas 코드를 자세히 알지 않아도 되도록 중간에서 파일 처리를 담당한다.
현재 구조는 단순하다.
from pathlib import Path
import pandas as pd
class ExcelService:
Path는 파일 확장자를 확인할 때 사용하고, pandas는 Excel/CSV 읽기와 저장에 사용한다.
처음 프로젝트를 만들 때는 “파일을 불러와서 화면에 보여주기”가 목표였기 때문에, UI 코드 안에서 바로 파일을 읽어도 크게 문제처럼 느껴지지 않았다. 하지만 기능이 늘어나면서 파일 처리 로직을 ExcelService로 분리해둔 것은 좋은 선택이었다.
클래스 상수로 정리된 파일 처리 기준
ExcelService에는 파일 처리와 관련된 기준값들이 클래스 상수로 정의되어 있다.
EXCEL\_EXTENSIONS = {".xlsx", ".xls"}
CSV\_EXTENSIONS = {".csv"}
CSV\_ENCODINGS = ("utf-8-sig", "cp949", "euc-kr", "utf-8")
TEXT\_FORMAT\_COLUMNS = {
"eventDate",
"dateIdentified",
"decimalLatitude",
"decimalLongitude",
}
처음에는 단순히 Excel 파일만 읽으면 된다고 생각할 수 있다. 하지만 실제 데이터를 다루다 보면 CSV 파일도 자주 만나게 된다. 특히 한국어가 포함된 CSV는 인코딩 문제가 자주 발생한다.
그래서 CSV_ENCODINGS에는 여러 인코딩이 순서대로 들어가 있다.
CSV_ENCODINGS = ("utf-8-sig", "cp949", "euc-kr", "utf-8")
이 부분은 작지만 실무적인 코드다.
CSV 파일을 무조건 utf-8로만 읽으면 한글이 깨지거나 파일 읽기에 실패할 수 있다.
그래서 여러 인코딩을 순서대로 시도하도록 만든 것이다.
또 하나 눈에 띄는 부분은 TEXT_FORMAT_COLUMNS다.
TEXT\_FORMAT\_COLUMNS = {
"eventDate",
"dateIdentified",
"decimalLatitude",
"decimalLongitude",
}
이 컬럼들은 Excel로 저장할 때 자동 변환되면 곤란한 값들이다.
GBIF쪽에서는 이 값이 데이터로 업로드시 날짜 형식이나 숫자 형식이면 제대로 인식하지 못하기 때문에 숫자나 날짜이지만 무조건 텍스트 형식으로 되어야하는 컬럼 값들을 지정해 두었다.
엑셀 읽기 로직 흐름 정리
먼저 Excel/CSV 파일을 읽는 흐름부터 정리해봤다. 처음 코드를 볼 때는 load_excel()이라는 함수 하나가 “엑셀을 불러오는 함수”처럼 보였다. 하지만 안쪽을 자세히 보면 여러 책임이 섞여 있었다.
현재 MainWindow.load_excel() 안에서 일어나는 일은 다음과 같다.
| 순서 | 현재 처리 | 현재 위치 | 책임 성격 |
|---|---|---|---|
| 1 | 파일 선택 다이얼로그 열기 | MainWindow.load_excel() |
UI |
| 2 | 마지막으로 열었던 폴더 기억하기 | MainWindow |
UI 상태 |
| 3 | 지원하는 파일인지 확인하기 | ExcelService.get_sheet_names() 내부 |
파일 처리 |
| 4 | Excel 시트 목록 가져오기 | ExcelService.get_sheet_names() |
파일 처리 |
| 5 | 시트 선택 다이얼로그 열기 | MainWindow.load_excel() |
UI |
| 6 | 헤더 없이 원본 데이터 읽기 | ExcelService.read_excel_raw() |
파일 처리 |
| 7 | 현재 파일 경로, 시트명, DataFrame 상태 저장 | MainWindow.load_excel() |
화면 상태 |
| 8 | 파일명/시트명/헤더 상태 라벨 갱신 | MainWindow.load_excel() |
UI |
| 9 | 원본 미리보기 테이블 표시 | MainWindow.show_preview_raw() |
UI |
| 10 | 기존 매핑 UI와 결과 테이블 초기화 | MainWindow.load_excel() |
UI 상태 |
| 11 | 버튼 활성화/비활성화 | MainWindow.load_excel() |
UI 상태 |
| 12 | 오류 발생 시 메시지 박스 표시 | MainWindow.load_excel() |
UI |
이 표를 보면 load_excel()이 실제로는 파일만 읽는 함수가 아니라는 걸 알 수 있다.
파일을 선택하고, 시트를 고르고, 데이터를 읽고, 화면 상태를 초기화하고, 버튼 상태까지 바꾸고 있는 상태
그래서 리팩토링 방향은 load_excel() 전체를 서비스로 옮기는 것이 아니라 역할에 따라 나누어야 한다.
리팩토링 후 책임을 나누면 다음과 같이 볼 수 있다.
| 처리 | 남을 위치 | 이유 |
|---|---|---|
| 파일 선택 다이얼로그 | MainWindow |
사용자의 입력을 받는 UI 책임 |
| 마지막 폴더 저장 | MainWindow 또는 설정 helper |
화면/앱 상태 관리 |
| 확장자 검증 | ExcelService |
파일 처리 규칙 |
| 시트 목록 조회 | ExcelService |
파일 내부 구조 읽기 |
| 시트 선택 다이얼로그 | MainWindow |
사용자 선택 UI |
| 헤더 없는 원본 읽기 | ExcelService |
DataFrame 생성 |
| 현재 파일/시트 상태 저장 | MainWindow |
화면 상태 |
| 라벨 갱신 | MainWindow |
UI 표시 |
| 미리보기 테이블 표시 | MainWindow 또는 별도 Table helper |
UI 표시 |
| 매핑 UI 초기화 | MainWindow |
화면 상태 |
| 버튼 상태 변경 | MainWindow |
UI 상태 |
| 예외 메시지 표시 | MainWindow |
사용자 피드백 |
여기서 중요한 건 파일 처리 로직과 UI 흐름을 구분하는 것이다.
현재 구조를 흐름으로 그리면 이렇게 볼 수 있다.
MainWindow.load\_excel()
├─ QFileDialog로 파일 선택
├─ ExcelService.get\_sheet\_names(file\_path)
│ ├─ 확장자 검증
│ ├─ CSV면 \["CSV"\] 반환
│ └─ Excel이면 pd.ExcelFile로 시트 목록 반환
├─ QInputDialog로 시트 선택
├─ ExcelService.read\_excel\_raw(file\_path, sheet\_name)
│ ├─ 확장자 검증
│ ├─ CSV면 pd.read\_csv(header=None)
│ └─ Excel이면 pd.read\_excel(header=None)
├─ MainWindow 상태값 초기화
├─ 라벨 갱신
├─ show\_preview\_raw()
├─ 매핑 UI/결과 테이블 초기화
└─ 버튼 상태 변경
리팩토링 후 목표 흐름은
MainWindow.load\_excel()
├─ 파일 선택
├─ 시트 선택 요청
├─ ExcelService로 원본 DataFrame 읽기
├─ MainWindow 상태 초기화
└─ 화면 갱신
ExcelService
├─ 지원 파일 확장자 검증
├─ CSV 인코딩 처리
├─ 시트 목록 조회
├─ 헤더 없는 원본 읽기
└─ 헤더 적용 DataFrame 읽기
현재도 파일 자체를 읽는 코드는 이미 ExcelService에 있다.
하지만 MainWindow.load_excel()은 여전히 너무 많은 UI 상태 변경을 한 번에 처리하고 있다.
그래서 이 단계에서는 두 가지를 목표로 잡았다.
ExcelService가 맡고 있는 파일 처리 책임을 명확히 이해한다.MainWindow.load_excel()안의 UI 상태 초기화 코드를 작은 메서드로 나눠 읽기 쉽게 만든다.
지원하는 파일형식 검증하기
QFileDialog를 사용하여 filePath 를 가지고 excel_service.get_sheet_names 로 요청되면 거기서 파일을 열기에 앞서 파일 확장자 검증이 시작된다.
@staticmethod
def _validate_supported_path(file_path: str) -> None:
path = Path(file_path)
suffix = path.suffix.lower()
if suffix not in ExcelService.EXCEL_EXTENSIONS | ExcelService.CSV_EXTENSIONS:
raise ValueError("지원하지 않는 파일 형식입니다. .xlsx, .xls, .csv 파일만 가능합니다.")
이 메서드는 현재 앱에서 중요한 검증 역할을 한다.
지원하지 않는 확장자가 들어오면 ValueError를 발생시킨다.
여기서 중요한 점은 ExcelService가 직접 메세지 박스를 띄우지 않고 main_window에서 에외를 잡아서 사용자에게 메세지 박스로 던져준다.
CSV 파일인지 확인하기
excel_service.get_sheet_names에서 검증 이후 is_csv_path 메서드를 통해 이 파일이 csv인지, excel 파일인지 한번 더 확인하는 분기점을 갖는다.
이때 csv이면 return csv로 되고, excel 이면 pandas로 읽어온 sheet_name이 list로 나오게 된다.
@staticmethod
def get_sheet_names(file_path: str) -> list[str]:
ExcelService._validate_supported_path(file_path)
if ExcelService.is_csv_path(file_path):
return ["CSV"]
excel_file = pd.ExcelFile(file_path)
return excel_file.sheet_names
CSV 인코딩을 순서대로 시도하기
이 메서드는 CSV_ENCODINGS에 정의된 인코딩을 하나씩 시도한다.
예를 들어 utf-8-sig로 읽기에 실패하면 cp949로 다시 시도하고, 그래도 실패하면 euc-kr, utf-8 순서로 다시 시도한다.
Excel과 CSV를 같은 흐름으로 다루기
@staticmethod
def read_excel_raw(file_path: str, sheet_name=0) -> pd.DataFrame:
ExcelService._validate_supported_path(file_path)
if ExcelService.is_csv_path(file_path):
return ExcelService._read_csv(file_path, header=None)
excel 의 경우는 pandas를 사용하여 data를 read 해오고, csv의 경우 read 해온다.
@staticmethod
def _read_csv(file_path: str, **kwargs) -> pd.DataFrame:
last_error = None
for encoding in ExcelService.CSV_ENCODINGS:
try:
return pd.read_csv(file_path, encoding=encoding, **kwargs)
except UnicodeDecodeError as error:
last_error = error
if last_error is not None:
raise last_error
return pd.read_csv(file_path, **kwargs)
이걸 main 에서 raw_data로 받는데,
기존 코드
self.raw_df = ExcelService.read_excel_raw(file_path, sheet_name=selected_sheet)
self.current_file_path = file_path
self.current_sheet_name = selected_sheet
self.source_df = None
self.result_df = None
self.selected_header_row = None
이부분에서 리팩토링이 필요한가?? => 코드를 분리하여 메서드로 깔끔하게 정리 필요
def load_excel(self):
'''
raw_df = ExcelService.read_excel_raw(file_path, sheet_name=selected_sheet)
self._set_loaded_file_state(file_path,selected_sheet,raw_df)
''''
# 파일을 새로 불러올 때마다 관련 상태를 초기화하는 메서드
def _set_loaded_file_state(self, file_path, sheet_name, raw_df):
self.raw_df = raw_df
self.current_file_path = file_path
self.current_sheet_name = sheet_name
self.source_df = None
self.result_df = None
self.selected_header_row = None
self._reset_active_fields()
메서드로 분리하여 주석처리함
load_excel 파일에서 UI 초기화 메서드 _update_loaded_file_labels , _set_file_loaded_buttons_enabled 버튼 상태 초기화 메서드 까지 분리하여 깔끔하게 정리하였다.
처음에는 load_excel() 전체를 ExcelService로 옮겨야 하나 생각했다. 하지만 다시 역할을 나눠보니 그렇지 않았다. 파일 선택 다이얼로그, 시트 선택 다이얼로그, 라벨 갱신, 버튼 상태 변경은 명확히 UI 책임이었다. 반면 확장자 검증, CSV 인코딩 처리, 시트 목록 조회, DataFrame 생성은 파일 처리 책임이므로 ExcelService에 두는 것이 맞았다.
따라서 이번 리팩토링의 방향은 load_excel()을 없애는 것이 아니라, load_excel()이 전체 흐름을 읽기 쉽게 조율하도록 만드는 것이다. 실제 파일 읽기는 ExcelService가 담당하고, MainWindow는 사용자 입력과 화면 상태 갱신에 집중하도록 나눈다.
'Data Engineering & Automation > GBIF Darwin Core' 카테고리의 다른 글
| PySide6 리팩토링 4편 : 헤더 적용과 컬럼명 정리 분리하기 (0) | 2026.06.04 |
|---|---|
| PySide6 리팩토링 3편: Excel 파일 읽기 흐름을 단계별로 나누기 (0) | 2026.06.04 |
| PySide6 리팩토링 1편: 프로젝트 구조 다시 읽기 (0) | 2026.05.27 |
댓글