본문 바로가기
Data Engineering & Automation/GBIF Darwin Core

PySide6 리팩토링 2편: Excel 파일 읽기 흐름 분리하기

by JINJINC 2026. 5. 28.
728x90
반응형

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 상태 변경을 한 번에 처리하고 있다.

그래서 이 단계에서는 두 가지를 목표로 잡았다.

  1. ExcelService가 맡고 있는 파일 처리 책임을 명확히 이해한다.
  2. 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는 사용자 입력과 화면 상태 갱신에 집중하도록 나눈다.

728x90
반응형

댓글