본문 바로가기

(개인Project)_개발/PLC-PC 연결

[Program][보족] PyInstaller EXE가 콘솔 없이 에러 메시지도 없이 꺼진다 : 초기화 로그로 디버깅

반응형

이런 경험 있으신가요?

PyInstaller로 빌드한 EXE를 실행했는데 아무 일도 일어나지 않습니다.

창이 뜨지 않아요.

오류 메시지도 없어요. 그냥 꺼집니다.

console=True로 다시 빌드해서 실행하면 그제야 이런 오류가 보입니다.

AttributeError: 'NoneType' object has no attribute 'isatty'

"뭐가 None이라는 거지? isatty는 또 뭐야?"

두 번째 문제도 있었습니다.

이번엔 오류 메시지는 나오는데 뭔가 이상합니다.

ModuleNotFoundError: No module named 'main'

분명히 같은 폴더에 main.py가 있는데 없다고 합니다.

세 번째. uvicorn을 별도 스레드에서 돌렸는데 PLC 연결이 간헐적으로 끊깁니다.

개발 환경에서는 안 생기던 문제예요.

네 번째. Python으로 bat 파일을 자동 생성하는 기능을 만들었는데, cmd.exe에서 실행하면 이런 오류가 납니다.

'o' is not recognized as an internal or external command.

"@echo off"의 '@'가 아니라 'o'가 인식 안 된다는 게 도무지 이해가 안 됩니다.

PLCLink Phase 5에서 EXE를 만드는 과정에 이 네 가지가 순서대로 기다리고 있었습니다.

하나씩 풀어볼게요.


용어 먼저 짚고 넘어갈게요

PyInstaller란

Python 스크립트와 모든 패키지를 하나의 실행 파일로 묶어주는 도구입니다.

받는 사람은 Python을 설치하지 않아도 돼요.

도시락 비유로 설명하면, 재료와 조리 도구까지 전부 담아두는 것과 같습니다.

받는 사람은 그냥 먹기만 하면 됩니다.

 

frozen 모드란

PyInstaller가 만든 EXE가 실행 중인 상태입니다.

이때 sys.frozen이 True가 돼요.

Python 파일들이 실제 파일 시스템 경로가 아닌 sys._MEIPASS라는 임시 폴더 안에 압축 해제되어 있습니다.

일반 Python 환경과 경로가 달라서 import 방식도 달라져요.

 

console=False란

EXE 실행 시 검은 터미널 창이 뜨지 않게 하는 옵션입니다.

배포용 앱에서 자연스러운 모습을 위해 씁니다.

그런데 이게 예상치 못한 문제의 원인이 됩니다.

터미널이 없으면 sys.stdout이 None이 되거든요.

 

CRLF와 LF란

텍스트 파일에서 줄을 바꾸는 방식입니다.

Windows는 \r\n 두 글자(CRLF), Linux/Mac은 \n 한 글자(LF)를 씁니다.

Windows의 cmd.exe는 CRLF만 제대로 인식합니다.

Python에서 파일을 쓸 때 기본값은 LF예요.

이 차이 하나가 "is not recognized" 오류를 만듭니다.


첫 번째 문제 : console=False가 서버를 죽였다

EXE를 console=False로 빌드했습니다.

더블클릭하면 창이 뜨고 브라우저로 접속할 수 있어야 했는데, EXE가 실행되자마자 조용히 꺼집니다.

처음엔 아무것도 몰랐어요.

로그도 없고, 창도 없고, 오류 메시지도 없으니까요.

console=True로 다시 빌드해보니 그제야 오류가 보였습니다.

AttributeError: 'NoneType' object has no attribute 'isatty'

범인은 uvicorn이었습니다.

uvicorn은 로깅 설정을 할 때 내부적으로 이 코드를 실행합니다.

# uvicorn 내부 코드
if sys.stdout.isatty():
    ...

console=False 환경에서는 터미널이 없으니 sys.stdout이 None입니다.

None.isatty()를 호출하니 AttributeError가 나는 거예요.

서버가 시작조차 못 하고 죽는 거였습니다.

원인을 알고 나면 해결은 간단합니다.

uvicorn에게 "로깅 설정 하지 마"라고 하면 됩니다.

config = uvicorn.Config(
    app,
    host="0.0.0.0",
    port=8765,
    log_config=None,   # 이 한 줄로 해결
)
server = uvicorn.Server(config)

log_config=None을 주면 uvicorn이 로깅 초기화를 건너뜁니다.

sys.stdout.isatty()를 호출하지 않아요.

PLCLink는 어차피 별도 로그 시스템을 쓰기 때문에 uvicorn 기본 로깅이 필요 없었습니다.


두 번째 문제 : Windows 비메인 스레드에서 asyncio가 불안정했다

첫 번째 문제를 해결하고 나니 EXE가 실행됐습니다.

그런데 운영하다 보면 PLC TCP 연결이 간헐적으로 끊깁니다.

개발 환경에서 수백 번 테스트할 때는 안 생기던 현상이에요.

PLCLink는 tkinter가 메인 스레드를 점유해야 해서 uvicorn을 daemon 스레드에서 실행합니다.

문제는 Windows의 기본 asyncio 이벤트 루프인 ProactorEventLoop가 비메인 스레드에서 불안정하다는 것이었어요.

기차 비유로 설명하면 이렇습니다.

ProactorEventLoop는 빠른 고속철도인데, 비메인 스레드에서 타려면 환승이 필요합니다.

그런데 이 환승 시스템이 불안정해서 가끔 연결이 끊기는 거예요.

SelectorEventLoop는 속도가 조금 느리지만 어떤 스레드에서 타든 안정적으로 동작합니다.

def _run_server():
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(
            asyncio.WindowsSelectorEventLoopPolicy()
        )
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    loop.run_until_complete(server.serve())

WindowsSelectorEventLoopPolicy로 바꾸고 나서 간헐적 연결 끊김이 사라졌습니다.


세 번째 문제 : main.py가 있는데 없다고 한다

두 번째도 해결했습니다. 그런데 이번엔 다른 오류가 납니다.

ModuleNotFoundError: No module named 'main'

PLCLink EXE의 진입점은 launcher.py입니다. 런처 창을 띄우고 나서 서버 스레드에서 from main import app으로 FastAPI 앱을 가져옵니다.

같은 폴더에 main.py가 있으니까 당연히 됩니다.

개발 환경에서는요.

frozen 환경에서는 다릅니다.

파일들이 sys._MEIPASS라는 임시 폴더에 압축 해제돼요.

launcher.py 기준의 상대 경로로 main.py를 찾으면 없습니다.

Python이 그 경로를 모르는 거예요.

마치 도서관 창고에 책은 있는데 도서관 목록에 등록이 안 된 것처럼요.

책은 창고에 있지만 사서는 "없는 책"이라고 합니다.

창고 경로를 목록에 직접 추가해줘야 해요.

import sys
from pathlib import Path

if getattr(sys, "frozen", False):
    # frozen 상태일 때만 _MEIPASS 경로 추가
    sys.path.insert(0, str(Path(sys._MEIPASS)))

from main import app  # 이제 찾을 수 있음

getattr(sys, "frozen", False)로 frozen 상태인지 먼저 확인합니다.

일반 Python 환경에서는 sys.frozen이 없으니 False가 돼서 이 코드가 실행되지 않아요.

EXE로 실행될 때만 경로를 추가합니다.


네 번째 문제 : bat 파일이 한 글자씩 어긋났다

세 가지가 해결됐습니다.

이제 PLCLink는 정상적으로 실행됩니다.

그런데 자동 시작 설정용 bat 파일을 생성하는 기능을 테스트하다가 이상한 오류를 만났습니다.

'o' is not recognized as an internal or external command.

@echo off로 시작하는 bat 파일인데 '@'가 아닌 'o'가 인식이 안 된다는 거예요.

처음에는 무슨 말인지 이해가 안 됐어요.

원인은 줄 끝 문자였습니다.

Python에서 텍스트 파일을 쓸 때 기본값은 LF(\n)입니다.

그런데 Windows의 cmd.exe는 CRLF(\r\n)로 저장된 bat 파일만 제대로 인식합니다.

LF로 저장되면 cmd.exe가 줄 끝을 제대로 나누지 못합니다.

@echo off와 다음 줄 내용이 이어붙여져서 @echo offo 같은 형태로 읽혀요.

그래서 '@'가 아닌 'o'가 인식 안 된다는 오류가 나는 겁니다.

문장 사이에 마침표 없이 이어쓴 것처럼 읽히는 거예요.

해결 방법은 bat 파일을 쓸 때 명시적으로 CRLF로 변환하고, 바이너리 모드로 저장하는 것입니다.

def write_bat_file(path: Path, content: str):
    raw = content.encode("utf-8")
    # 기존 줄 끝 정규화 후 CRLF로 통일
    raw = raw.replace(b"\r\n", b"\n")
    raw = raw.replace(b"\r", b"\n")
    raw = raw.replace(b"\n", b"\r\n")

    with open(path, "wb") as f:   # "wb": 바이너리 모드 필수
        f.write(raw)

open(path, "wb") 바이너리 모드로 써야 합니다.

텍스트 모드 "w"로 쓰면 Python이 또 LF로 바꿔버립니다.


보너스 : console=False 환경에서 디버깅하는 방법

네 가지 문제를 겪으면서 가장 답답했던 건 첫 번째 문제였어요.

EXE가 꺼져도 아무 말이 없으니까요.

console=False 환경에서 디버깅하는 방법은 초기화 단계부터 파일에 로그를 남기는 것입니다.

화면이 없어도 파일은 남으니까요.

import time
from pathlib import Path

_BASE_DIR = (
    Path(sys._MEIPASS) if getattr(sys, "frozen", False)
    else Path(__file__).parent
)

def _dbg(msg: str):
    dbg_path = _BASE_DIR / "data" / "debug.txt"
    dbg_path.parent.mkdir(parents=True, exist_ok=True)
    with open(dbg_path, "a", encoding="utf-8") as f:
        f.write(f"{time.strftime('%H:%M:%S')}  {msg}\n")

_dbg("launcher.py 시작")
_dbg("sys.path에 MEIPASS 추가")
from main import app
_dbg("main import 성공")

EXE가 꺼진 후 data/debug.txt를 열면 어느 단계까지 실행됐는지 알 수 있어요.

마지막 줄이 어디인지를 보면 어디서 죽었는지 알 수 있습니다.

문제를 찾은 후에는 _dbg 호출을 지우면 됩니다.


정리

문제 증상 해결
console=False + isatty EXE 실행 즉시 종료, 오류 없음 uvicorn.Config(log_config=None)
비메인 스레드 asyncio 간헐적 TCP 불안정 WindowsSelectorEventLoopPolicy
frozen import 실패 ModuleNotFoundError: main sys.path에 _MEIPASS 추가
bat 파일 CRLF 'o' is not recognized open("wb") + 명시적 CRLF 변환

console=False EXE가 아무 말 없이 꺼진다면, console=True로 먼저 빌드해서 오류를 확인하세요. 그래도 안 보이면 파일 디버그 로그가 유일한 방법입니다.


마치며

PyInstaller로 FastAPI를 패키징하면 일반 Python 환경에서 숨어있던 문제들이 한꺼번에 튀어나옵니다.

그중 가장 당황스러운 게 console=False에서 EXE가 말 없이 꺼지는 현상입니다.

오류를 볼 수가 없으니 원인을 찾는 데 시간이 걸려요.

저도 처음엔 uvicorn 코드를 의심하지 않고 launcher 코드부터 뒤졌습니다.

개발 환경과 EXE 환경의 차이를 이해하고 나면, 각 문제의 원인과 해결이 생각보다 단순하다는 걸 알게 됩니다.

문제가 네 개라서 많아 보이지만 각각은 독립적이에요.

하나씩 확인하면서 잡아가면 됩니다.

반응형