엑셀 기반 위탁업체 파일 자동 분류 프로그램을 만들면서 UX문제를 발견했다.
기능 자체는 단순했다.
1. 사용자가 엑셀 파일을 선택한다.
2. 엑셀의 업체 정보와 파일 경로를 읽는다.
3. 업체 차수에 따라 폴더를 만든다.
4. 원본 파일을 복사하고 규칙에 맞게 이름을 바꾼다.
5. 결과 폴더를 zip으로 압축한다.
6. 임시 작업 폴더를 삭제한다.
문제는 작업량이 조금만 늘어나도 Windows에서 프로그램이 “응답 없음” 상태로 보인다는 점이었다.
파일 복사와 압축은 실제로 계속 진행 중이었지만, 사용자 입장에서는 프로그램이 멈춘 것처럼 보였다. 비개발자가 사용하는 GUI 도구에서는 치명적인 UX 문제였다.
문제상황 정의
처음에는 “파일 복사가 느린가?”라고 생각했다.
하지만 실제 문제는 처리 속도 자체가 아니었다.
문제는 사용자가 작업이 진행 중이라는 신호를 받지 못한다는 것이었다.
즉, 해결해야 할 문제는 이거였다.
오래 걸리는 작업 중에도 GUI가 계속 살아있어야 한다.
조금 더 구체적으로는 다음 상태가 유지되어야 했다.
오래 걸리는 작업 중에도 GUI가 계속 살아있어야 한다.
조금 더 구체적으로는 다음 상태가 유지되어야 했다.
- 창이 멈춘 것처럼 보이지 않아야 한다.
- 로그가 계속 갱신되어야 한다.
- 현재 어느 단계인지 보여야 한다.
- zip 생성처럼 오래 걸리는 단계도 별도로 표시되어야 한다.
- 사용자가 중복 클릭하지 않도록 버튼 상태가 바뀌어야 한다.
원인은 GUI Main Thread 점유였다.
tkinter는 메인 스레드에서 이벤트 루프를 돌린다.
이 이벤트 루프가 버튼 클릭, 화면 갱신, 창 이동, 텍스트 출력 같은 GUI 작업을 처리한다.
그런데 기존 구조에서는 이 메인 스레드에서 엑셀 로딩, 파일 복사, zip 압축까지 함께 처리하고 있었다.
def start_processing(self):
# 오래 걸리는 작업
load_excel()
copy_files()
create_zip()
이런 작업이 오래 걸리면 tkinter는 화면을 다시 그릴 수 없다.
그래서 프로그램이 실제로 죽은 건 아니지만, 운영체제는 응답이 없다고 판단한다.
원인은 단순했다.
무거운 I/O 작업이 GUI 이벤트 루프를 막고 있었다.
작업을 백그라운드 스레드로 분리했다(1차 해결)
먼저 파일 처리 작업을 tkinter 메인 스레드에서 분리했다.
작업 시작 버튼을 누르면 실제 처리는 별도 스레드에서 실행된다.
self._processing_thread = threading.Thread(
target=self._run_processing,
args=(excel_path,),
daemon=True
)
self._processing_thread.start()
이렇게 하면 메인 스레드는 계속 GUI 이벤트를 처리할 수 있다.
- 창이 다시 그려진다.
- 프로그레스바가 움직인다.
- 상태 텍스트가 바뀐다.
- 로그 창이 갱신된다.
하지만 여기서 끝나지 않았다.
로그 출력도 병목(2차 병목)
tkinter 위젯은 스레드 안전하지 않다.
그래서 백그라운드 스레드에서 직접 로그 창이나 상태 라벨을 수정하면 안 된다.
대신 백그라운드 스레드는 큐에 메시지를 넣고, 메인 스레드가 큐를 읽어서 GUI를 갱신하게 했다.
self._log_queue.put(("log", level, timestamp, message))
self._log_queue.put(("status", status_text))
self._log_queue.put(("progress", percent))
구조는 좋아졌지만, 새로운 병목이 생겼다.
파일이 많아지면 로그 메시지도 많아진다.
메인 스레드가 큐에 쌓인 로그를 한 번에 전부 화면에 그리면, 다시 GUI가 버벅인다.
즉, 병목이 이동했다.
처음에는 파일 처리 작업이 문제였고, 그다음에는 로그 렌더링이 문제였다.
큐를 조금씩만 소비하도록 변경(최종 해결)
큐를 한 번에 모두 비우지 않도록 제한했다.
processed = 0
while processed < 80:
item = self._log_queue.get_nowait()
...
processed += 1
한 번의 GUI 갱신 주기에서 최대 80개까지만 처리한다.
이렇게 하면 로그가 많이 쌓여도 메인 스레드가 너무 오래 점유되지 않는다.
GUI는 짧게 일하고, 다시 이벤트 루프로 돌아간다.
상태값과 진행률도 최적화했다.
상태 메시지는 모든 중간값을 보여줄 필요가 없다.
사용자는 가장 최신 상태만 알면 된다.
그래서 status, progress 메시지는 큐에서 읽는 동안 최신값만 저장하고, 마지막에 한 번만 반영했다.
latest_status = None
latest_progress = None
결과적으로 GUI 업데이트 비용을 줄이면서도 사용자는 충분한 피드백을 받을 수 있게 됐다.
진행 상태를 명확하게 나눔
기존에는 작업 중이라는 정도만 알 수 있었다.
수정 후에는 단계별로 상태를 나눴다.
- 엑셀 로딩 중
- 작업 디렉토리 생성 중
- N번째 행 처리 중
- zip 파일 생성 중
- 완료
진행률도 대략적인 단계 기반으로 보여줬다.
self.update_progress(5 + ((index + 1) / total_rows) * 80)
정확한 초 단위 예측보다 중요한 것은 사용자가 “멈춘 게 아니다”라고 느끼는 것이었다.
그래서 진행률은 다음 기준으로 설계했다.
- 엑셀 로딩: 움직이는 상태 표시
- 디렉토리 생성: 5%
- 행 처리: 5~85%
- zip 생성: 90%
- 완료: 100%
왜 다른 방법을 선택하지 않았는지?
root.update()를 중간중간 호출하는 방법
가장 빠르게 떠올릴 수 있는 방법이다.
하지만 선택하지 않았다.
이 방식은 비즈니스 로직 중간에 GUI 갱신 코드를 계속 넣어야 한다.
파일 복사 로직이 tkinter에 강하게 의존하게 되고, 나중에 유지보수하기 어려워진다.
또한 이벤트 재진입 문제가 생길 수 있다.
빠른 임시방편은 될 수 있지만, 구조적인 해결책은 아니라고 판단했다.
작업을 after()로 잘게 쪼개는 방법
tkinter의 after()를 이용해서 파일 하나 처리하고, 다음 파일은 다시 예약하는 방식도 가능하다.
하지만 이 프로그램에는 맞지 않았다.
처리 흐름이 엑셀 행, 업체 차수, 파일 종류, zip 생성까지 이어져 있다.
이걸 전부 작은 콜백으로 쪼개면 코드 흐름이 지나치게 복잡해진다.
이번 작업은 CPU 계산보다 파일 I/O가 중심이었기 때문에, 백그라운드 스레드가 더 단순하고 적절했다.
multiprocessing 사용
프로세스를 분리하는 방법도 고려할 수 있다.
하지만 이 문제는 CPU를 많이 쓰는 작업이 아니라 파일 읽기/쓰기와 압축이 중심이었다.
multiprocessing을 쓰면 처리 구조, 메시지 전달, PyInstaller 패키징 복잡도가 늘어난다.
현재 문제에는 스레드와 큐로 충분했다.
결과
수정 전에는 작업이 정상 진행 중이어도 사용자는 멈췄다고 느꼈다.
수정 후에는 작업 중에도 GUI가 계속 반응한다.
변화는 다음과 같다.
| 항목 | 개선 전 | 개선 후 |
| 창 응답성 | 작업 중 멈춘 것처럼 보임 | 작업 중에도 계속 반응 |
| 로그 출력 | 한꺼번에 밀려서 출력 | 조금씩 지속적으로 출력 |
| 진행 상태 | 알기 어려움 | 현재 단계와 행 번호 표시 |
| zip 생성 단계 | 멈춘 것처럼 보임 | zip 파일 생성 중... 표시 |
| 사용자 조작 | 중복 클릭 가능 | 처리 중 버튼 비활성화 |
| 완료 안내 | 결과 폴더 중심 | zip경로와 임시 폴더 삭제 여부 표시 |
정량 측정은 아직 하지 않았다.
다만 이번 개선의 목표는 처리 시간을 줄이는 것이 아니라 체감 응답성을 개선하는 것이었다.
추후에는 다음 지표를 추가하면 더 명확하게 개선 효과를 보여줄 수 있다.
- 전체 처리 시간
- zip 생성 시간
- 로그 큐 최대 적체량
- GUI tick당 처리 시간
- “응답 없음” 재현 여부
최종 정리
이번 문제는 단순한 속도 문제가 아니었다.
사용자가 보는 화면이 멈추면, 내부 작업이 정상이어도 실패한 경험이 된다.
해결 과정은 다음 순서로 진행했다.
- “파일 복사가 느리다”가 아니라 “GUI가 살아있어야 한다”로 문제를 재정의했다.
- GUI 메인 스레드를 막는 I/O 작업을 백그라운드 스레드로 분리했다.
- 스레드 간 GUI 업데이트는 큐로 전달했다.
- 로그 렌더링도 병목이 될 수 있어 큐 소비량을 제한했다.
- 상태와 진행률은 최신값 중심으로 반영했다.
결과적으로 프로그램의 처리 방식은 크게 바꾸지 않으면서, 사용자가 느끼는 안정성은 크게 개선됐다.
백엔드 관점에서 보면 이 작업은 GUI 개선이라기보다 이벤트 루프 병목을 분리하고, 메시지 처리량을 제어한 작업에 가깝다.
앞으로도 오래 걸리는 작업을 빠르게 만드는 것도 중요하지만, 사용자가 기다릴 수 있는 상태를 만드는 것도 제품 품질의 일부라고 생각하면서 개발할 것이다.
'지식 나눔' 카테고리의 다른 글
| 하네스 엔지니어링과 비용에 대한 고찰 (2) | 2026.05.01 |
|---|---|
| Cloudflare Worker 기반 GitHub → Discord 알림 시스템 구축 (0) | 2025.11.19 |
| [Vue] 오픈소스 이슈 제보 후기 (3) | 2025.07.24 |
| [Spring Security 6 + AWS] HTTP ERROR 403 해결 (Order Annotation와 securityMatcher 사용) (1) | 2025.01.25 |
| [Github Actions & Docker] unable to authenticate, attempted methods - 해결 (0) | 2025.01.20 |