
이런 경험 있으신가요?
pystray로 트레이 아이콘을 만들었습니다.
메뉴를 클릭하면 tkinter 창이 열리게 하려고 이렇게 했어요.
def on_show_window(_icon, _item):
root.deiconify() # 창 보이기
그런데 메뉴를 눌러도 창이 안 뜹니다.
Windows에서는 가끔 크래시가 나기도 해요.
tkinter와 FastAPI를 같이 실행해야 해서 구조를 어떻게 잡을지도 막막했습니다.
uvicorn을 먼저 실행하면 tkinter를 어디서 실행하고, pystray는 어디에 들어가는 건지.

PLCLink Phase 5에서 이 세 가지를 동시에 돌리는 구조를 잡으면서, 처음엔 구조를 잘못 잡아서 크래시를 만났습니다.
결론부터 말하면 핵심은 두 가지예요.
tkinter는 반드시 메인 스레드, 그리고 다른 스레드에서 tkinter를 건드릴 때는 root.after(0, ...)를 씁니다.
용어 먼저 짚고 넘어갈게요

pystray란
Python으로 시스템 트레이 아이콘을 만드는 라이브러리입니다.
Windows 우측 하단에 나타나는 작은 아이콘이에요.
우클릭 메뉴, 더블클릭 동작을 설정할 수 있습니다.
pystray.Icon.run()을 호출하면 그 스레드를 블로킹합니다.
tkinter란
Python 내장 GUI 라이브러리입니다.
창, 버튼, 텍스트 같은 화면 요소를 만들 때 씁니다.
특이한 제약이 있어요.
반드시 메인 스레드에서 실행해야 합니다.
Windows에서 비메인 스레드에서 tkinter를 쓰면 크래시가 납니다.
스레드(Thread)란
프로그램 안에서 동시에 여러 작업을 실행하는 방법입니다.
식당 주방 비유로 설명하면, 주방장 혼자 순서대로 요리하는 게 아니라 여러 조리사가 각자 다른 요리를 동시에 하는 것과 같아요. daemon=True로 설정하면 메인이 종료될 때 자동으로 같이 꺼집니다.
root.after(0, ...)란
tkinter에서 다른 스레드가 메인 스레드(tkinter 루프)에게 일을 맡기는 방법입니다.
"메인 스레드야, 다음 루프 돌 때 이 함수 실행해줘"라고 예약하는 것과 같아요.
0은 즉시 예약이에요.
처음 구조를 잘못 잡았다

처음에 이렇게 시도했습니다.
uvicorn이 서버니까 메인 스레드에서 실행하고, tkinter와 pystray를 별도 스레드로 빼는 구조였어요.
# 처음 시도 (잘못된 구조)
def main():
threading.Thread(target=run_tkinter, daemon=True).start()
threading.Thread(target=run_tray, daemon=True).start()
loop.run_until_complete(server.serve()) # 메인 스레드에 uvicorn
실행하자마자 크래시가 났습니다.
tkinter를 비메인 스레드에서 실행했기 때문이에요.
"아, tkinter가 메인 스레드여야 하는구나"를 그때 알았습니다.
왜 tkinter는 메인 스레드여야 하나
tkinter는 내부적으로 Tcl/Tk라는 GUI 엔진을 씁니다.
이 엔진은 처음 초기화된 스레드에서만 GUI 이벤트를 처리할 수 있도록 설계되어 있어요.
주방 비유로 이해하면 이렇습니다.
Tcl/Tk 엔진은 "주방 자체"입니다.
처음 자리를 잡은 조리사(메인 스레드)만 이 주방을 쓸 수 있어요.
다른 조리사(보조 스레드)가 직접 주방 장비를 쓰려고 하면 충돌이 납니다.
그래서 pystray 콜백(보조 스레드)에서 직접 root.deiconify()를 호출하면 크래시가 나요.
보조 조리사가 메인 조리사 몰래 주방 장비를 쓰려 한 거니까요.
보조 조리사가 주방에서 뭔가를 하고 싶으면 메인 조리사에게 "이것 좀 해줘"라고 전달해야 합니다.
이게 root.after(0, ...) 패턴이에요.
올바른 스레드 구조

구조를 이렇게 잡았습니다.
def main():
root = tk.Tk()
win = StatusWindow(root)
# uvicorn과 pystray를 daemon 스레드로 분리
threading.Thread(target=_run_server, daemon=True).start()
threading.Thread(target=_run_tray, args=(root, win), daemon=True).start()
# 메인 스레드는 tkinter가 점유
root.mainloop()
메인 스레드(tkinter): tkinter.mainloop()가 메인 스레드를 점유합니다.
uvicorn 스레드: FastAPI 서버를 실행합니다. asyncio 루프가 이 스레드 안에서 돌아요.
pystray 스레드: 시스템 트레이 아이콘을 관리합니다. 클릭 이벤트가 여기서 발생해요.
daemon=True 덕분에 tkinter 창을 닫으면 uvicorn과 pystray 스레드도 자동으로 같이 꺼집니다. 별도 종료 코드가 필요 없어요.
root.after(0, ...) : 핵심 패턴

pystray 콜백은 pystray 스레드에서 실행됩니다.
이 안에서 직접 tkinter 메서드를 호출하면 크래시가 납니다.
# 잘못된 방법
def on_toggle(_icon, _item):
win.toggle() # 크래시! pystray 스레드에서 tkinter 직접 호출
root.deiconify() # 크래시!
root.after(0, ...)로 메인 스레드에 위임해야 합니다.
# 올바른 방법
def on_toggle(_icon, _item):
root.after(0, win.toggle) # 메인 스레드(tkinter)에 위임
def on_quit(_icon, _item):
_icon.stop() # pystray 종료는 이 스레드에서 OK
root.after(0, root.quit) # tkinter 종료는 메인 스레드로
root.after(0, win.toggle)은 "tkinter 메인 루프야, 다음 기회에 win.toggle()을 실행해줘"라고 예약합니다.
실제 실행은 메인 스레드가 합니다.
두 글자 차이가 크래시와 정상 동작을 가릅니다.
pystray 세부 설정

트레이 메뉴 구성과 주의사항입니다.
def _run_tray(root, win):
icon_img = Image.open("icon.png")
menu = pystray.Menu(
pystray.MenuItem(
"상태 창 표시", on_toggle,
default=True # Windows 더블클릭 시 실행되는 항목
),
pystray.MenuItem("브라우저 열기", on_open_browser),
pystray.MenuItem("종료", on_quit),
)
icon = pystray.Icon("PLCLink", icon_img, "PLCLink", menu)
icon.run() # 이 스레드를 블로킹
default=True를 설정하지 않으면 트레이 아이콘을 왼쪽으로 더블클릭해도 아무 일이 일어나지 않습니다.
사용자 입장에서 답답한 경험이 돼요.
아이콘 이미지가 없어도 PIL로 코드에서 직접 그릴 수 있습니다.
from PIL import Image, ImageDraw
img = Image.new("RGB", (64, 64), color=(15, 15, 28))
draw = ImageDraw.Draw(img)
draw.ellipse([16, 16, 48, 48], fill=(77, 122, 255))
EXE 배포 시 PNG 파일 의존성을 없앨 수 있어요.
세 가지 원칙으로 정리

세 가지만 기억하면 됩니다.
첫째, tkinter는 메인 스레드.
tkinter.mainloop()를 main() 에서 직접 실행하고, uvicorn과 pystray는 daemon 스레드로 분리합니다.
둘째, 다른 스레드에서 tkinter 조작 = root.after(0, ...). pystray 콜백이든, uvicorn 콜백이든 tkinter를 건드려야 할 때는 항상 root.after로 메인 루프에 위임합니다. 직접 호출은 크래시입니다.
셋째, daemon=True. 메인 스레드(tkinter)가 종료되면 나머지 스레드도 자동으로 꺼집니다. 직접 종료 처리를 만들지 않아도 돼요.
마치며
pystray + tkinter + asyncio 조합은 세 라이브러리가 각자 다른 스레드 요구사항을 가지고 있어서 처음 구조를 잡기가 어렵습니다.
저도 처음에 uvicorn을 메인 스레드에 놓고 tkinter를 별도 스레드로 뺐다가 크래시를 만났어요.
"tkinter는 메인 스레드"라는 원칙을 모르면 여기서 막힙니다.
구조를 한 번 제대로 잡으면 이후에 기능을 추가하는 건 어렵지 않아요.
트레이 메뉴 항목을 하나 더 넣거나, 상태 창에 내용을 추가하는 것은 전체 구조를 건드리지 않아도 됩니다.
'(개인Project)_개발 > PLC-PC 연결' 카테고리의 다른 글
| [Program][보족] 같은 파일에 있어도 다른 함수면 스코프가 다르다 : React 컴포넌트 기초 (1) | 2026.06.14 |
|---|---|
| [Program][보족] 현장 운영자가 볼 로그와 개발자가 볼 로그를 나눈 이유 (1) | 2026.06.13 |
| [Program][보족] PyInstaller EXE가 콘솔 없이 에러 메시지도 없이 꺼진다 : 초기화 로그로 디버깅 (0) | 2026.06.11 |
| [Program][Phase5] "Python 설치해야 해요?" 라는 질문이 없어지는 날 : PLCLink 데스크탑 앱 제작기 (1) | 2026.06.10 |
| [Program][보족] Python OPC-UA 클라이언트 구현 삽질기 : browse path, NodeId, VariantType (0) | 2026.06.08 |