4편에서는 헤더 적용 흐름에서 컬럼명 정리 규칙을 ExcelService로 옮기고, 헤더 적용 성공 후 화면 갱신을 _apply_header_data()로 묶었다. 그 결과 apply_selected_header()는 “선택한 헤더로 읽기 → 컬럼명 정리 → 화면에 적용”이라는 흐름을 보여주는 메서드가 되었다.
이번에는 그 다음 단계인 build_mapping_ui()를 살펴봤다.
헤더가 적용되면 프로그램은 원본 데이터의 컬럼을 Darwin Core 필드에 연결할 수 있는 매핑 UI를 만든다. 각 필드마다 설명을 보여주고, 원본 컬럼을 선택할 콤보박스를 만들고, 일부 필드는 직접 입력값도 받을 수 있게 한다. 여기에 자동 매핑과 기존 입력값 복원까지 더해지면서 build_mapping_ui()는 단순한 화면 생성 메서드보다 더 많은 일을 하게 되었다.
매핑 UI는 언제 다시 만들어질까
처음에는 헤더 적용 직후에만 매핑 UI가 만들어진다고 생각하기 쉽다. 하지만 실제로는 여러 상황에서 같은 UI를 다시 만든다.
- 헤더를 적용했을 때
- 사용자가 선택 필드를 추가했을 때
- 자동 매핑 설정을 저장했을 때
- 자동 매핑 설정을 기본값으로 초기화했을 때
이 중 헤더 적용은 새로운 데이터를 기준으로 매핑 UI를 처음 만드는 상황에 가깝다. 반면 필드 추가나 자동 매핑 설정 변경은 이미 사용자가 선택해둔 값이 있을 수 있다.
예를 들어 사용자가 scientificName에 학명 컬럼을 선택하고, countryCode에 KR을 직접 입력했다고 해보자. 이 상태에서 선택 필드를 하나 추가했는데 모든 콤보박스와 직접 입력값이 초기화된다면 사용자는 같은 작업을 다시 해야 한다.
따라서 매핑 UI를 다시 만드는 작업에는 두 가지 흐름이 필요하다.
- 현재 선택값을 잠시 저장한다.
- UI를 새로 만든 뒤 저장해둔 값을 복원한다.
기존 선택값을 스냅샷으로 저장하기
먼저 현재 매핑 UI의 상태를 읽어오는 메서드를 만들었다.
def _snapshot_mapping_state(self) -> tuple[dict, dict]:
combo_state = {}
manual_state = {}
for key, combo in self.combo_boxes.items():
try:
combo_state[key] = combo.currentText()
except RuntimeError:
continue
for key, widget in self.manual_inputs.items():
try:
manual_state[key] = widget.text()
except RuntimeError:
continue
return combo_state, manual_state
콤보박스 선택값과 직접 입력값은 서로 성격이 다르기 때문에 두 딕셔너리로 나눠 저장했다. combo_boxes는 Darwin Core 필드 키를 기준으로 QComboBox를 들고 있고, manual_inputs는 직접 입력을 허용하는 필드의 QLineEdit을 들고 있다.
여기서 try와 RuntimeError 처리가 조금 낯설 수 있다. Qt 위젯은 이미 삭제된 뒤에 접근하면 런타임 오류가 날 수 있다. 매핑 UI는 자주 지웠다가 다시 만들기 때문에, 오래된 위젯 참조가 남아 있더라도 스냅샷 과정이 전체 흐름을 깨지 않도록 방어했다.
이 메서드의 장점은 반환값이 단순하다는 점이다.
(
{"scientificName": "학명", "decimalLatitude": "위도"},
{"countryCode": "KR"},
)
이런 형태라면 UI 위젯을 몰라도 “어떤 필드에 어떤 값이 선택되어 있었는지”만 다룰 수 있다.
새 UI에 선택값 복원하기
저장한 값은 UI를 다시 만든 뒤 _restore_mapping_state()에서 복원한다.
def _restore_mapping_state(self, combo_state: dict, manual_state: dict):
for key, value in combo_state.items():
combo = self.combo_boxes.get(key)
if combo and value:
index = combo.findText(value)
if index >= 0:
combo.setCurrentIndex(index)
else:
combo.setCurrentText(value)
for key, value in manual_state.items():
line_edit = self.manual_inputs.get(key)
if line_edit is not None:
line_edit.setText(value)
복원할 때는 현재 새로 만들어진 위젯 목록을 기준으로 값을 넣는다. 이전 위젯을 재사용하지 않는다. 이 차이가 중요하다.
clear_mapping_ui()는 기존 위젯을 삭제하고, build_mapping_ui()는 현재 활성 필드 목록과 원본 컬럼 목록을 기준으로 새 위젯을 만든다. 따라서 상태 복원은 “예전 위젯을 살려두기”가 아니라 “예전 값만 새 위젯에 다시 넣기”가 되어야 한다.
콤보박스에서는 먼저 findText()로 같은 항목이 있는지 찾는다. 원본 컬럼 목록에 여전히 같은 이름이 있으면 해당 항목을 선택한다. 항목이 없다면 setCurrentText()로 텍스트를 넣는다. 이렇게 하면 필드 추가처럼 컬럼 목록이 그대로인 경우에는 자연스럽게 선택이 복원되고, 상황이 조금 달라져도 가능한 범위에서 값이 유지된다.
필드 추가 흐름에서 상태 보존하기
이제 선택 필드를 추가할 때는 다음 순서로 동작한다.
self.active_field_keys.append(selected_field["key"])
combo_state, manual_state = self._snapshot_mapping_state()
self.build_mapping_ui(self.source_df.columns.tolist(), combo_state, manual_state)
self.btn_coordinate_preview.setEnabled(False)
self.btn_export.setEnabled(False)
새 필드를 active_field_keys에 추가한 뒤, 현재 매핑 상태를 저장하고, 매핑 UI를 다시 만든다. 새로 추가된 필드는 빈 상태로 나타나고, 기존 필드의 선택값과 직접 입력값은 복원된다.
여기서 결과 확인 이후의 버튼은 다시 비활성화한다. 매핑 구성이 바뀌었으므로 기존 결과 미리보기와 저장 가능 상태는 더 이상 최신 상태라고 볼 수 없기 때문이다. 사용자가 다시 “결과 확인”을 눌러 새 매핑 기준으로 결과를 생성해야 한다.
자동 매핑은 UI 밖에서 결정하기
build_mapping_ui() 안에서 모든 필드는 기본적으로 콤보박스를 만든다. 다만 일반 필드는 원본 컬럼 중 알맞은 후보를 자동으로 선택한다.
matched_col = auto_match_column(field["key"], source_columns)
if matched_col:
combo.setCurrentText(matched_col)
combo.setProperty("autoMatched", True)
여기서 QComboBox를 만들고 색상 표시를 위한 속성을 지정하는 일은 UI 책임이다. 하지만 “이 Darwin Core 필드에는 어떤 원본 컬럼이 어울리는가”를 판단하는 일은 화면과 무관하다.
그래서 자동 매핑 판단은 MappingService 쪽 함수인 auto_match_column()에 맡겼다.
def auto_match_column(dwc_key: str, source_columns: list[str]) -> str | None:
rules = load_auto_mapping_rules()
candidates = rules.get(dwc_key, [])
for col in source_columns:
col_norm = normalize(col)
for keyword in candidates:
if normalize(keyword) in col_norm:
return col
return fuzzy_match(dwc_key, source_columns, rules)
자동 매핑은 먼저 설정된 키워드가 원본 컬럼명에 포함되는지 확인한다. 정확히 포함되는 후보가 없으면 유사도 기반 매칭을 한 번 더 시도한다.
이 함수는 QComboBox를 알지 못한다. Darwin Core 필드 키와 원본 컬럼 목록을 받아서, 매칭되는 컬럼명 또는 None을 반환할 뿐이다. 덕분에 자동 매핑 규칙은 UI 없이도 따로 읽고 검증할 수 있는 형태가 되었다.
자동 매핑 설정을 바꾼 뒤 다시 만들기
자동 매핑 설정 탭에서 키워드를 저장하거나 기본값으로 초기화하면, 이미 헤더가 적용된 상태에서는 매핑 UI를 다시 만든다.
if self.source_df is not None:
self.build_mapping_ui(self.source_df.columns.tolist())
이 흐름은 필드 추가와 조금 다르다. 자동 매핑 설정을 바꾼 직후에는 새 규칙을 기준으로 추천값을 다시 적용하는 것이 목적이므로 기존 선택값을 무조건 복원하지 않는다. 사용자가 의도적으로 규칙을 바꿨다면 새 자동 매핑 결과를 확인하고 싶은 상황일 가능성이 높기 때문이다.
반대로 필드 추가는 매핑 규칙을 바꾼 것이 아니라 화면에 표시할 필드만 늘린 것이다. 그래서 기존 선택값을 보존하는 쪽이 자연스럽다.
같은 build_mapping_ui()를 호출하더라도 호출하는 상황에 따라 상태 복원 여부가 달라진다. 이 차이를 인자로 표현했다.
def build_mapping_ui(
self,
source_columns: list[str],
combo_state: dict | None = None,
manual_state: dict | None = None,
):
...
if combo_state or manual_state:
self._restore_mapping_state(combo_state or {}, manual_state or {})
상태를 넘기면 복원하고, 넘기지 않으면 새 자동 매핑 결과를 그대로 사용한다.
아직 build_mapping_ui()에 남아 있는 것
이번 리팩토링으로 build_mapping_ui()의 모든 복잡도가 사라진 것은 아니다. 여전히 이 메서드는 필드 종류에 따라 서로 다른 위젯 구성을 만든다.
basisOfRecord는 정해진 값 목록을 보여준다.collectionCode는 원본 컬럼 선택과 직접 입력을 함께 제공한다.countryCode는 ISO 국가 코드 목록과 직접 입력을 함께 제공한다.- 그 외 필드는 원본 컬럼 목록과 자동 매핑 결과를 사용한다.
이 분기는 화면 구성과 강하게 연결되어 있다. 어떤 필드는 콤보박스 하나면 충분하고, 어떤 필드는 콤보박스와 입력창이 함께 필요하다. 따라서 이 코드를 무리하게 서비스로 옮기면 서비스가 QComboBox, QLineEdit, 레이아웃까지 알아야 한다.
대신 이번에는 UI 밖으로 나갈 수 있는 것만 분리했다.
- 자동 매핑 후보를 결정하는 규칙
- 기존 선택값을 단순한 딕셔너리로 저장하는 과정
- 새 위젯에 저장값을 복원하는 과정
위젯을 만드는 일은 아직 MainWindow에 남아 있지만, 사용자의 입력값을 보존해야 하는 흐름은 이전보다 분명해졌다.
이번 리팩토링에서 배운 점
이번에는 다음 기준으로 코드를 나눴다.
- 위젯 객체 자체가 아니라 값만 저장하면 UI를 다시 만들기 쉬워진다.
- UI를 재생성할 때는 “기존 위젯 유지”보다 “기존 값 복원”이 안전하다.
- 같은 메서드를 다시 호출하더라도, 호출 의도에 따라 상태 복원 여부를 다르게 둘 수 있다.
- 자동 매핑처럼 입력값으로 결과를 결정하는 규칙은 서비스 쪽에 둘 수 있다.
- 버튼 상태는 매핑 결과가 최신인지 아닌지를 기준으로 함께 갱신해야 한다.
3편과 4편에서는 파일 로드와 헤더 적용의 성공 상태를 메서드로 묶었다. 이번에는 매핑 UI를 다시 만드는 과정에서 사용자의 선택 상태를 잃지 않도록 스냅샷과 복원 흐름을 분리했다.
리팩토링을 하다 보면 “큰 메서드를 작게 나누자”는 말이 너무 막연하게 느껴질 때가 있다. 이번 작업에서는 그 기준을 조금 더 구체적으로 잡을 수 있었다. 위젯을 만드는 코드는 화면에 남기고, 화면을 다시 만들기 전에 보존해야 하는 값은 화면 밖에서도 이해할 수 있는 단순한 자료구조로 꺼낸다.
다음 리팩토링에서는 매핑된 결과를 만드는 흐름을 살펴볼 수 있다. preview_mapped_result()는 필수값 검증, 고정값 처리, 데이터 변환, 결과 테이블 표시, 빈칸 요약, 버튼 상태 변경까지 한 번에 처리한다. 이제 결과 생성 흐름에서도 데이터 변환 규칙과 화면 반영을 더 또렷하게 나눌 수 있는지 확인해볼 차례다.
'Data Engineering & Automation > GBIF Darwin Core' 카테고리의 다른 글
| PySide6 리팩토링 4편 : 헤더 적용과 컬럼명 정리 분리하기 (0) | 2026.06.04 |
|---|---|
| PySide6 리팩토링 3편: Excel 파일 읽기 흐름을 단계별로 나누기 (0) | 2026.06.04 |
| PySide6 리팩토링 2편: Excel 파일 읽기 흐름 분리하기 (0) | 2026.05.28 |
댓글