AI 자동화/AI 자동화 블로그

웹앱 대신 데스크톱 앱으로 만든 이유 — 파이썬 Tkinter를 고른 기준

끄적인다 2026. 9. 12. 00:38
반응형

지난 글인 블로그 자동화 프로그램, 어디까지 자동으로 할지 경계 정하기에서 우리는 인간의 개입을 허용하는 하이브리드 워크플로우의 필요성과 상태 관리 설계에 대해 다루었습니다. 완전 자동화의 기술적 한계를 인정하고 중간 검수 단계를 도입하기로 결정하면서, 자연스럽게 이를 제어할 사용자 인터페이스(UI)가 필요해졌습니다. 그렇다면 이 하이브리드 제어부와 상태 관리 화면을 구현하기 위해, 왜 대세인 웹 기반 앱(Web App) 대신 구시대의 유물처럼 여겨지는 데스크톱 GUI 라이브러리인 Tkinter를 선택했을까요?

이 글에서는 1인 개발자로서 한정된 자원과 시간 속에서 내린 아키텍처 선택의 기준과, 실제 로컬 환경에서 Selenium과 GUI를 충돌 없이 결합하기 위해 고군분투했던 기술적 구현 과정을 상세히 기록합니다.


웹앱(Web App)과 데스크톱 앱(Desktop App)의 현실적인 갈림길

처음 시스템을 기획할 때는 React나 Vue를 프론트엔드로 쓰고, FastAPI나 Django를 백엔드로 두는 세련된 웹앱 형태를 고민했습니다. 웹 브라우저만 있으면 언제 어디서나 접속할 수 있고, UI 컴포넌트 라이브러리가 풍부하여 보기 좋은 화면을 빠르게 만들 수 있기 때문입니다. 하지만 우리 시스템의 핵심 동작 메커니즘을 대입해 보니, 웹앱 구조는 심각한 오버헤드와 기술적 장벽을 가지고 있었습니다.

로컬 크롬 프로필 및 Selenium 제어의 물리적 한계

우리가 구축한 하이브리드 자동화 시스템은 Selenium 크롬 프로필 재사용을 위한 드라이버 설정 예시에서 다룬 것처럼, 로컬 Windows 환경에 설치된 크롬 브라우저의 사용자 프로필 데이터를 직접 로드하여 로그인 세션을 유지합니다.

만약 이를 웹앱으로 구현한다면 구조는 다음과 같이 복잡해집니다.

  1. 사용자가 웹 브라우저(클라이언트)에서 '발행 시작' 버튼을 클릭합니다.
  2. 웹 API 서버로 HTTP 요청이 전송됩니다.
  3. 서버는 백그라운드 태스크(예: Celery)나 서브프로세스(Subprocess)를 실행하여 로컬 시스템의 Selenium 드라이버를 구동합니다.
  4. 이 과정에서 웹 서버 프로세스와 Selenium 드라이버 프로세스 간의 통신 세션을 유지하고 제어해야 합니다.

이 방식은 단일 로컬 PC에서 혼자 사용하는 도구임에도 불구하고 클라이언트-서버 구조를 강제하며, 포트 충돌, CORS 설정, 로컬 파일 시스템 권한 문제 등 개발 생산성을 갉아먹는 수많은 예외 상황을 만들어냅니다. 반면, 데스크톱 앱은 GUI 프로세스와 Selenium 제어 코드가 동일한 파이썬 런타임 및 메모리 공간을 공유하므로, 별도의 네트워크 통신 레이어 없이 직접적인 객체 참조와 함수 호출로 모든 제어가 가능합니다.

아키텍처 비교 분석

웹앱과 데스크톱 앱의 개발 및 운영 비용을 객체화하여 비교하면 다음과 같습니다.

비교 항목 웹앱 (FastAPI + React) 데스크톱 앱 (Python Tkinter)
개발 환경 복잡도 프론트엔드/백엔드 이원화, 빌드 도구 필요 단일 파이썬 스크립트로 실행 가능
로컬 자원 접근성 브라우저 샌드박스로 인해 로컬 파일/프로필 접근 제한적 로컬 파일 시스템 및 크롬 프로필 직접 접근 가능
프로세스 제어 비동기 큐(Celery 등) 및 프로세스 간 통신(IPC) 필수 멀티스레딩(Threading)만으로 직관적 제어 가능
의존성 및 용량 Node.js, 가상환경, 웹 서버 등 무거운 의존성 파이썬 기본 표준 라이브러리 활용 (추가 설치 무)
UI 미려함 CSS, Tailwind, UI 라이브러리로 매우 뛰어남 기본 스타일은 투박함 (테마 커스텀 필요)

결국 1인 개발자로서 가장 중요하게 보아야 할 지표는 '동작하는 프로토타입을 얼마나 빠르게 안정적으로 뽑아낼 수 있는가'였습니다. 화려한 웹 UI를 만들기 위해 아까운 개발 시간을 낭비하는 것보다, 로컬 자원을 100% 활용할 수 있는 데스크톱 앱이 본질에 부합하는 선택이었습니다.


파이썬 GUI 라이브러리 비교: 왜 PyQt가 아닌 Tkinter인가?

데스크톱 앱으로 방향을 정한 뒤, 파이썬 생태계에 존재하는 대표적인 GUI 라이브러리들을 테이블 위에 올려두고 저울질했습니다. 주요 후보는 Tkinter, PyQt(또는 PySide), CustomTkinter, Flet이었습니다.

후보군 분석과 탈락 이유

  1. PyQt / PySide
  2. 장점: 매우 강력하고, Qt Designer를 통해 드래그 앤 드롭으로 UI를 그릴 수 있으며, 현대적이고 미려한 UI 제작이 가능합니다.
  3. 단점: 라이브러리 자체가 매우 무겁고 배포 시 용량이 수백 MB로 커집니다. 무엇보다 PyQt는 GPL 라이선스를 따르므로 향후 코드 공개나 상용화 시 라이선스 제약이 까다롭습니다. (LGPL을 따르는 PySide가 대안이 될 수 있지만 설정이 번거롭습니다.) 1인 개발자가 내부 통제용 도구로 쓰기에는 다소 과한(Overkill) 스펙이었습니다.
  4. CustomTkinter
  5. 장점: Tkinter의 안정성을 유지하면서 다크 모드와 세련된 위젯 스타일을 기본 제공합니다.
  6. 단점: 외부 라이브러리이므로 별도 설치가 필요하며, 간혹 특정 OS 환경이나 해상도 배율 설정에 따라 위젯이 찌그러지거나 텍스트가 깨지는 렌더링 버그가 보고되었습니다.
  7. Flet
  8. 장점: Flutter 기반으로 동작하여 매우 아름다운 UI를 파이썬 코드로만 작성할 수 있습니다.
  9. 단점: 아키텍처가 독특하여 학습 곡선이 존재하고, 백그라운드에서 Flutter 엔진이 돌기 때문에 메모리 점유율이 높으며 Selenium과의 스레드 동기화 검증 사례가 부족했습니다.

Tkinter를 최종 선택한 세 가지 기준

결국 제가 선택한 것은 파이썬 표준 라이브러리에 내장된 Tkinter였습니다. 투박한 외관이라는 명확한 단점이 있음에도 불구하고, 아래의 세 가지 기준이 단점을 완전히 상쇄했습니다.

  • 표준 라이브러리의 압도적 안정성: 외부 패키지 의존성이 전혀 없습니다. pip install 없이 파이썬만 설치되어 있다면 즉시 구동되므로, 개발 못 해도 블로그 자동화 할 수 있을까에서 언급한 실행 환경의 파편화 문제를 원천 차단합니다.
  • 가벼움과 빠른 실행 속도: C 언어로 작성된 Tcl/Tk 래퍼인 Tkinter는 실행 속도가 빠르고 메모리를 거의 먹지 않습니다. Selenium과 대형 언어 모델(LLM) API 통신만으로도 시스템 자원을 꽤 소모하기 때문에, 제어부 GUI는 최대한 가벼워야 했습니다.
  • 충분한 레퍼런스와 단순함: 수십 년간 검증된 라이브러리인 만큼, 스레드 충돌이나 윈도우 이벤트 루프 관련 트러블슈팅 자료가 인터넷에 널리 퍼져 있습니다. 직관적인 레이아웃 매니저(pack, grid) 덕분에 단 하루 만에 제어 화면을 설계할 수 있었습니다.

Tkinter와 멀티스레딩 — Selenium 제어의 핵심

Tkinter를 사용하여 Selenium 자동화 프로그램을 만들 때 반드시 넘어야 하는 기술적 장벽이 있습니다. 바로 이벤트 루프(Event Loop)의 병목 현상입니다.

Tkinter는 단일 스레드로 동작하며, root.mainloop()가 실행되는 동안 화면을 계속해서 다시 그리고 사용자의 클릭이나 키보드 입력을 대기합니다. 만약 사용자가 '작업 시작' 버튼을 눌렀을 때, Tkinter가 동작하는 메인 스레드에서 Selenium 드라이버를 켜고 구글 검색을 하거나 포스팅 글을 작성하는 무거운 작업을 실행하면 어떻게 될까요?

화면은 즉시 멈추고, 윈도우 창 제목 표시줄에는 '응답 없음'이라는 경고가 뜨며 프로그램이 강제 종료될 위험에 처합니다. 이를 해결하기 위해서는 무조건 멀티스레딩(Multi-threading) 구조를 도입해야 합니다.

스레드 간 안전한 통신을 위한 큐(Queue) 구조 설계

단순히 threading.Thread를 사용해 백그라운드에서 Selenium을 돌리는 것만으로는 부족합니다. 백그라운드 스레드에서 작업의 진행 상황(예: "현재 3번째 글 작성 중...", "이미지 업로드 완료")을 Tkinter 화면의 텍스트 창이나 프로그레스 바에 직접 업데이트하려고 시도하면, Tkinter의 스레드 비안전성(Thread-unsafe) 특성 때문에 프로그램이 예기치 않게 크래시(Crash)를 일으킵니다.

이 문제를 해결하기 위해, 백그라운드 스레드는 오직 스레드 안전 큐(Queue)에 메시지를 던지기만 하고, Tkinter 메인 스레드가 주기적으로 이 큐를 검사하여 UI를 갱신하는 폴링(Polling) 구조를 설계해야 합니다.

Tkinter + Selenium 멀티스레딩 표준 뼈대 코드

아래 코드는 실제 시스템에 적용한 스레드 안전 큐 기반의 제어부 표준 프레임워크입니다. 불필요한 비즈니스 로직은 걷어내고, 핵심 스레드 통신과 GUI 갱신 메커니즘만 명확하게 남겨두었습니다.

# Tkinter GUI와 백그라운드 스레드 간의 안전한 통신을 구현한 프레임워크 코드
import tkinter as tk
from tkinter import ttk
from tkinter import scrolledtext
import threading
import queue
import time

class AutomationController:
    def __init__(self, root):
        self.root = root
        self.root.title("AI 블로그 자동화 제어기 (Tkinter)")
        self.root.geometry("600x400")

        # 스레드 간 통신을 위한 스레드 안전 큐 생성
        self.log_queue = queue.Queue()

        # UI 컴포넌트 초기화
        self._create_widgets()

        # 백그라운드 스레드 제어 플래그
        self.is_running = False

        # GUI 갱신을 위한 폴링 루프 시작 (100ms 간격)
        self.root.after(100, self._poll_queue)

    def _create_widgets(self):
        # 상단 제어 버튼 영역
        btn_frame = ttk.Frame(self.root, padding=10)
        btn_frame.pack(fill=tk.X)

        self.start_btn = ttk.Button(btn_frame, text="자동화 시작", command=self._start_task)
        self.start_btn.pack(side=tk.LEFT, padx=5)

        self.stop_btn = ttk.Button(btn_frame, text="강제 종료", command=self._stop_task, state=tk.DISABLED)
        self.stop_btn.pack(side=tk.LEFT, padx=5)

        # 하단 로그 출력 영역
        log_frame = ttk.LabelFrame(self.root, text="작업 진행 로그", padding=10)
        log_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)

        self.log_area = scrolledtext.ScrolledText(log_frame, wrap=tk.WORD, height=15)
        self.log_area.pack(fill=tk.BOTH, expand=True)

    def _start_task(self):
        """자동화 작업을 백그라운드 스레드에서 실행"""
        self.is_running = True
        self.start_btn.config(state=tk.DISABLED)
        self.stop_btn.config(state=tk.NORMAL)

        # 백그라운드 워커 스레드 생성 및 시작
        self.worker_thread = threading.Thread(target=self._run_selenium_workflow, daemon=True)
        self.worker_thread.start()

    def _stop_task(self):
        """작업 종료 신호 전달"""
        self.is_running = False
        self.log_queue.put("[SYSTEM] 종료 요청 중... 현재 작업이 끝난 후 안전하게 정지합니다.")

    def _run_selenium_workflow(self):
        """실제 Selenium 및 API 작업이 수행되는 백그라운드 루프 (예시)"""
        try:
            self.log_queue.put("[INFO] Selenium 드라이버 초기화 중...")
            time.sleep(1.5)  # 실제 환경에서는 webdriver_manager 구동 및 크롬 프로필 로드 시간

            steps = ["티스토리 로그인 세션 확인", "Gemini API 활용 본문 초안 생성", "이미지 자동 수집 및 업로드", "포스팅 임시저장 및 검수 대기"]

            for step in steps:
                if not self.is_running:
                    break
                self.log_queue.put(f"[PROCESS] {step} 시작...")
                time.sleep(2.0)  # 각 단계별 작업 지연 시간 시뮬레이션
                self.log_queue.put(f"[SUCCESS] {step} 완료.")

            self.log_queue.put("[SYSTEM] 모든 자동화 프로세스가 완료되었습니다.")
        except Exception as e:
            self.log_queue.put(f"[ERROR] 예외 발생: {str(e)}")
        finally:
            self.is_running = False
            # 버튼 상태 복구를 위해 특별 메시지 전송
            self.log_queue.put("WORKER_FINISHED")

    def _poll_queue(self):
        """메인 스레드에서 주기적으로 큐를 확인하여 UI를 업데이트"""
        try:
            while True:
                # 대기 없이 즉시 큐에서 메시지 인출
                message = self.log_queue.get_nowait()

                if message == "WORKER_FINISHED":
                    self.start_btn.config(state=tk.NORMAL)
                    self.stop_btn.config(state=tk.DISABLED)
                else:
                    self.log_area.insert(tk.END, message + "\n")
                    self.log_area.see(tk.END)  # 스크롤을 항상 최하단으로 유지

                self.log_queue.task_done()
        except queue.Empty:
            # 큐가 비어있으면 예외가 발생하므로 루프를 빠져나감
            pass
        finally:
            # 100ms 후에 다시 이 함수를 호출하여 지속적인 모니터링 유지
            self.root.after(100, self._poll_queue)

if __name__ == "__main__":
    root = tk.Tk()
    app = AutomationController(root)
    root.mainloop()

실제로 구현하며 마주한 장단점과 한계

이 뼈대 코드를 바탕으로 실제 포스팅 수집, 본문 생성, 이미지 매칭, Selenium 발행 단계를 하나씩 얹어가며 시스템을 완성했습니다. 직접 Tkinter 기반의 데스크톱 앱을 만들어 운영해 보니, 장점과 단점이 아주 명확하게 드러났습니다.

확실했던 장점

  1. 상태 공유의 단순함: GUI 클래스 인스턴스 내부에 현재 작업 중인 포스팅의 메타데이터(제목, 카테고리, 상태값 등)를 변수로 들고 있을 수 있습니다. 웹앱이었다면 Redis 세션이나 데이터베이스를 거쳐야 했을 상태 관리가, 단순한 파이썬 딕셔너리(self.current_post = {}) 하나로 해결되었습니다.
  2. 배포 및 무설치 구동: 이 프로그램은 파이썬 파일 하나만 실행하면 즉시 GUI 창이 뜹니다. 가상환경 세팅 외에 추가적인 데이터베이스 서버 구축이나 웹 서버 데몬 구동이 필요 없어 로컬 자원을 극도로 아낄 수 있었습니다.
  3. 디버깅의 직관성: 개발 중 오류가 발생하면, 웹 브라우저의 개발자 도구 콘솔과 웹 서버 로그를 번갈아 볼 필요 없이 파이썬 터미널 창 하나에 모든 트레이스백(Traceback)이 출력되어 원인 파악이 매우 빨랐습니다.

뼈아픈 단점과 극복 방안

  1. 투박하고 낡은 UI 비주얼: 기본 Tkinter 위젯은 현대적인 윈도우 11 환경에서 이질감이 듭니다. 이를 극복하기 위해 ttk.Style을 사용하여 폰트를 맑은 고딕으로 통일하고, 윈도우 시스템 배경색에 맞춘 플랫 스타일 테마를 적용하여 시각적 불편함을 최소화했습니다.
  2. 비동기 코드와의 호환성 문제: Gemini나 Claude API 호출 시 asyncio 기반의 비동기 호출을 섞어 쓰려 하면, Tkinter의 이벤트 루프와 asyncio 루프가 충돌을 일으켰습니다. 결국 GUI 앱 내부에서는 비동기 라이브러리를 배제하고, 모든 API 요청과 Selenium 제어를 동기식(Synchronous) 코드로 통일하여 백그라운드 스레드에서 순차 실행하는 방식으로 단순화했습니다.

실제로 해보며 알게 된 것과 대처법

Tkinter와 Selenium을 결합하는 과정에서 예상치 못한 두 가지 큰 삽질이 있었고, 이를 해결하며 배운 실전 팁을 공유합니다.

1. 창을 닫아도 백그라운드 스레드가 죽지 않는 좀비 프로세스 문제

상황: 사용자가 Tkinter GUI 창의 우측 상단 X 버튼을 눌러 프로그램을 종료했는데, 작업 관리자를 켜보니 크롬 브라우저(Selenium)와 파이썬 프로세스가 백그라운드에 그대로 남아 메모리를 갉아먹고 있었습니다.

원인: Tkinter의 메인 루프는 종료되었지만, 백그라운드에서 실행 중이던 threading.Thread가 무한 대기 상태이거나 Selenium의 driver.quit()가 호출되지 않아 프로세스가 완전히 종료되지 않은 것이었습니다.

해결책: 스레드를 생성할 때 반드시 daemon=True 옵션을 주어 메인 프로세스 종료 시 함께 강제 종료되도록 설정해야 합니다. 또한, Tkinter 윈도우가 닫히는 프로토콜 이벤트(WM_DELETE_WINDOW)를 가로채어, 열려 있는 Selenium 브라우저를 안전하게 닫고 종료하는 윈도우 종료 핸들러를 추가했습니다.

# 안전한 앱 종료를 위한 프로토콜 핸들러 등록 예시
def on_closing():
    if messagebox.askokcancel("종료", "프로그램을 종료하시겠습니까? 진행 중인 작업이 중단됩니다."):
        # 백그라운드 스레드 플래그 정지
        app.is_running = False
        # Selenium 드라이버가 열려 있다면 명시적으로 닫기
        if hasattr(app, 'driver') and app.driver:
            try:
                app.driver.quit()
            except:
                pass
        root.destroy()

root.protocol("WM_DELETE_WINDOW", on_closing)

2. GUI 로그 창의 메모리 누수 및 오버플로우

상황: 수백 개의 글을 수집하고 처리하는 장기 자동화 작업을 돌려놓았더니, 프로그램이 몇 시간 뒤 심각하게 느려지며 멈추는 현상이 일어났습니다.

원인: ScrolledText 위젯에 실시간 로그를 계속 누적하여 추가(insert)하다 보니, 텍스트 버퍼의 크기가 무한정 커져 Tkinter의 렌더링 엔진이 과부하를 일으킨 것이었습니다.

해결책: 로그 출력 함수에 최대 줄 수 제한 로직을 추가했습니다. 로그가 1,000줄을 넘어가면 가장 오래된 상단 로그를 자동으로 지워버리는 큐 버퍼 제한 방식을 적용하여 메모리 사용량을 일정하게 유지했습니다.

# 로그 영역의 버퍼 크기를 제한하는 안전한 쓰기 함수
def safe_log_insert(log_area, message, max_lines=1000):
    log_area.insert(tk.END, message + "\n")

    # 현재 줄 수 계산
    current_lines = int(log_area.index('end-1c').split('.')[0])
    if current_lines > max_lines:
        # 초과한 만큼 상단에서 제거
        log_area.delete('1.0', f'{current_lines - max_lines}.0')

    log_area.see(tk.END)

마무리

1인 개발자로서 AI 블로그 자동화 시스템의 제어부로 파이썬 Tkinter를 선택한 것은, 기술적 화려함보다 실용성과 개발 속도를 최우선으로 둔 결과였습니다. 로컬 크롬 프로필에 직접 접근해야 하는 Selenium의 특성상, 단일 메모리 공간에서 스레드 통신만으로 동작을 제어할 수 있는 데스크톱 앱 구조는 탁월한 선택이었음이 개발 과정에서 증명되었습니다. 비록 UI는 다소 투박할지언정, 시스템 자원을 거의 먹지 않고 단 한 번의 크래시 없이 묵묵히 제 역할을 다해주는 든든한 제어부를 얻게 되었습니다.

이제 제어부 UI라는 단단한 그릇이 준비되었으니, 그 안에 담길 데이터와 설정을 정교하게 다듬을 차례입니다. 다음 글에서는 코드의 유지보수성을 극대화하기 위해, 프로그램의 각종 환경 설정값과 수집 대상 블로그 주제 목록을 파이썬 코드 내부가 아닌 외부 파일로 분리하여 관리하는 아키텍처 구조를 설계하고 구현하는 과정을 다루겠습니다.

반응형