[에러 해결] [Tkinter] Toplevel 창 삭제 후에도 메모리에 남아있는 원인과 해결 방법

안녕하세요! 구글 SEO와 사용자 경험(UX) 최적화에 정통한 기술 블로그 전문 에디터입니다. 오늘은 파이썬(Python)의 대표적인 GUI 라이브러리인 Tkinter에서 Toplevel 창을 생성하고 삭제했을 때, 해당 창이 메모리에서 완전히 제거되지 않고 남아있는 것처럼 보이는 문제에 대해 심층 분석하고, 명확한 해결 방법을 제시해 드리겠습니다.

이 문제는 Tkinter로 복잡한 GUI 애플리케이션, 특히 모달(Modal) 다이얼로그나 설정 창을 구현할 때 흔히 발생하며, 애플리케이션의 성능 저하와 메모리 누수로 이어질 수 있습니다. 개발자분들이 이 문제를 빠르고 정확하게 해결하고, 더 나아가 향후 유사한 문제를 예방할 수 있도록 상세히 설명해 드리겠습니다.

1. 에러 발생 상황

문제는 Tkinter의 Toplevel 위젯을 생성하고 닫았음에도 불구하고, 애플리케이션의 메모리 사용량이 계속 증가하거나 새로운 Toplevel 창이 생성될 때 이전 창의 이름이 계속해서 증가하는 현상으로 나타납니다. 즉, destroy() 메서드를 호출했음에도 불구하고, Tkinter의 내부 시스템이나 Python의 메모리 상에 잔존하는 부분이 있다는 것을 시사합니다.

제보된 개발자의 상황을 예시 코드를 통해 살펴보겠습니다. 해당 코드는 노트패드 프로그램의 폰트 설정 창을 Toplevel로 구현한 시나리오입니다.

from tkinter import Tk, Misc, Toplevel
from tkinter.ttk import Button

class FontSettingsWindow(object):
    root: Misc | None = None

    def __init__(self) -> None:
        self.top = Toplevel(FontSettingsWindow.root)
        self.top.protocol('WM_DELETE_WINDOW', self._close_protocol)
        self.top.title('Pawpad - Font Settings')
        self.top.resizable(width=False, height=False)
        self.top.grab_set() # 모달 다이얼로그를 위한 grab 설정

        print(self.top)

# Lots of stuff would go between here

    def _close_protocol(self):
        for child in self.top.winfo_children():
            child.destroy()
            del(child) # 이 부분은 불필요합니다.

        self.top.destroy()

    def show(self):      
        self.top.wm_deiconify()
        self.top.wait_window() # Toplevel 창이 파괴될 때까지 대기

        return ("Returned Value")

if __name__ == '__main__':
    root = Tk()
    root.title('Test')
    root.geometry('150x100')

    def show_window():
        tl = FontSettingsWindow
        tl.root = root
        value = tl().show() # 새 FontSettingsWindow 인스턴스 생성 및 호출
        print(value)
        del tl # 클래스 참조를 삭제 (인스턴스에는 영향 없음)

    Button(text='Click Me', command=show_window).pack(anchor='center')

    root.mainloop()

위 코드를 실행하고 ‘Click Me’ 버튼을 누른 후, Toplevel 창을 두 번 닫았다가 다시 열면 다음과 같은 로그가 출력됩니다.

.!toplevel
Returned Value
.!toplevel2
Returned Value

.!toplevel 다음 .!toplevel2가 출력되는 것은 새로운 Toplevel 위젯이 생성될 때마다 Tkinter 내부적으로 고유한 이름 카운터가 증가한다는 것을 의미합니다. 이는 이전 위젯이 완전히 정리되지 않았거나, 적어도 그 이름이 다시 재활용되지 않고 있음을 시사합니다. 또한, 작업 관리자에서 애플리케이션의 메모리 사용량이 창을 닫고 다시 열 때마다 미세하게 증가하는 현상도 확인되었습니다.

사용된 환경 정보:

  • Python 버전: 3.15.2
  • Tkinter 버전: 9.0
  • Windows 버전: 11

2. 명확한 발생 원인

이 문제의 핵심은 Tkinter 위젯의 생명 주기 관리와 Python 객체의 가비지 컬렉션(Garbage Collection)이 예상대로 동작하지 않는 부분에 있습니다. 주요 원인은 다음과 같습니다.

  1. grab_set()의 잔여 리소스 문제:
    self.top.grab_set()은 Toplevel 창을 모달(Modal) 다이얼로그로 만들어, 사용자가 해당 창에서만 상호작용하도록 강제합니다. Toplevel.destroy() 메서드는 일반적으로 grab을 해제하는 역할도 하지만, 특정 상황이나 Tkinter 버전에서는 grab 상태가 완전히 해제되지 않거나, 내부적인 참조가 남아있을 수 있습니다. 이로 인해 Tkinter의 C-수준(C-level) 구현에서 해당 위젯에 대한 참조를 계속 유지하여 완전한 자원 해제를 방해할 수 있습니다.
  2. Python 객체와 Tkinter 위젯의 분리된 생명 주기:
    self.top.destroy()는 Tkinter의 C-확장 모듈을 통해 실제 화면에 그려진 위젯을 파괴하는 명령입니다. 하지만 Python 객체인 self.top 자체는 여전히 존재하며, 이를 감싸는 FontSettingsWindow 인스턴스도 마찬가지입니다. Python의 가비지 컬렉터가 FontSettingsWindow 인스턴스를 수거하기 위해서는 해당 객체에 대한 모든 참조가 사라져야 합니다. 만약 Tkinter의 내부 시스템이 파괴된 위젯에 대한 어떤 형태로든 참조를 유지한다면, Python 객체 역시 가비지 컬렉션되지 않아 메모리 누수로 이어질 수 있습니다.
  3. 불필요한 del(child):
    _close_protocol 메서드 내의 del(child) 구문은 아무런 효과가 없습니다. child.destroy()가 Tkinter 위젯을 파괴하는 역할을 수행하며, del 키워드는 단순히 Python 변수(참조)를 삭제하는 것이지, 객체 자체를 메모리에서 해제하는 것이 아닙니다. 이 부분은 코드의 오해를 불러일으킬 뿐입니다.
  4. FontSettingsWindow.root 클래스 속성 사용:
    비록 직접적인 메모리 누수의 원인은 아니지만, root 위젯을 클래스 속성으로 공유하는 방식은 객체 지향적인 설계에서 바람직하지 않습니다. 각 FontSettingsWindow 인스턴스가 생성될 때 부모 위젯을 인스턴스 속성으로 받는 것이 더 견고한 디자인입니다.

결론적으로, Toplevel 창이 완전히 사라지지 않는 것처럼 보이는 현상은 Tkinter의 내부적인 자원 관리와 Python의 가비지 컬렉션 사이의 미묘한 불일치에서 발생하며, 특히 모달 grab과 관련된 잔여 참조가 주된 원인으로 추정됩니다.

3. 해결 방법 및 코드 예시

이 문제를 해결하기 위해서는 Toplevel 위젯이 파괴될 때, Tkinter의 내부적인 grab 상태를 명확히 해제하고, Python 객체에 대한 참조도 확실히 제거하여 가비지 컬렉션이 원활하게 이루어지도록 해야 합니다.

핵심 해결책:

  1. grab_release() 명시적 호출: Toplevel 위젯을 destroy()하기 전에 self.top.grab_release()를 명시적으로 호출하여 모달 grab 상태를 해제합니다.
  2. 참조 명시적 제거: self.top.destroy() 호출 후, self.top = None을 설정하여 Python 객체가 파괴된 Tkinter 위젯에 대한 참조를 명확히 제거하도록 돕습니다. 이는 Python 가비지 컬렉터가 FontSettingsWindow 인스턴스를 더 쉽게 수거할 수 있게 합니다.
  3. 불필요한 코드 제거: del(child)와 같이 불필요하고 오해를 줄 수 있는 코드를 제거합니다.
  4. 부모 위젯 전달 방식 개선 (선택 사항): root 위젯을 클래스 속성으로 공유하는 대신, FontSettingsWindow 인스턴스 생성 시점에 파라미터로 전달하도록 개선합니다.

아래는 위 해결책이 적용된 수정된 코드입니다.

from tkinter import Tk, Misc, Toplevel
from tkinter.ttk import Button

class FontSettingsWindow(object):
    # root를 더 이상 클래스 속성으로 사용하지 않습니다.
    # root: Misc | None = None # 주석 처리 또는 제거

    def __init__(self, parent_root: Misc) -> None: # __init__에서 부모 위젯을 받습니다.
        self.top = Toplevel(parent_root) # Toplevel 생성 시 전달받은 부모 위젯 사용
        self.top.protocol('WM_DELETE_WINDOW', self._close_protocol)
        self.top.title('Pawpad - Font Settings')
        self.top.resizable(width=False, height=False)
        self.top.grab_set()

        print(self.top)

    def _close_protocol(self):
        # 모든 자식 위젯 파괴
        for child in self.top.winfo_children():
            child.destroy()
        
        # grab_release()를 destroy() 전에 명시적으로 호출
        if self.top.grab_current() == str(self.top): # 현재 grab이 이 Toplevel에 의해 설정되었는지 확인
            self.top.grab_release()
            
        self.top.destroy() # Toplevel 위젯 파괴
        self.top = None # Python 객체에서 Toplevel 참조를 명시적으로 제거

    def show(self):      
        self.top.wm_deiconify()
        self.top.wait_window()

        return ("Returned Value")

if __name__ == '__main__':
    root = Tk()
    root.title('Test')
    root.geometry('150x100')

    def show_window():
        # FontSettingsWindow 인스턴스를 생성할 때 root를 전달
        font_settings_instance = FontSettingsWindow(root) 
        value = font_settings_instance.show()
        print(value)
        # font_settings_instance 변수는 show() 메서드가 완료된 후 스코프를 벗어나므로 
        # 자동으로 가비지 컬렉션 대상이 됩니다. 
        # 명시적인 del은 필요하지 않습니다.
        # del tl # 이 부분은 이제 필요 없습니다.

    Button(text='Click Me', command=show_window).pack(anchor='center')

    root.mainloop()

수정된 코드를 실행하면, ‘Click Me’ 버튼을 누르고 Toplevel 창을 닫고 다시 열었을 때 메모리 사용량이 더 이상 계속 증가하지 않으며, .!toplevel 이름 카운터 문제도 해결될 것입니다. (이름 카운터는 Tkinter 내부 동작이므로 카운터 자체가 초기화되지는 않을 수 있으나, 메모리 누수는 해결됩니다.)

4. 향후 예방을 위한 팁

Tkinter에서 이와 유사한 메모리 관련 문제를 예방하기 위한 몇 가지 팁을 드립니다.

  1. 명시적인 자원 해제 습관화:
    destroy() 메서드는 Tkinter 위젯을 파괴하는 데 필수적입니다. 특히 Toplevel 창이나 동적으로 생성되는 위젯의 경우, 더 이상 필요 없을 때 반드시 호출하도록 습관화해야 합니다. grab_set()을 사용했다면, grab_release()도 명시적으로 호출하는 것이 안전합니다.
  2. Python 객체 참조 관리:
    Tkinter 위젯 객체가 파괴된 후에는, 해당 위젯을 참조하고 있던 Python 변수를 None으로 설정하는 것이 좋습니다. 이는 파이썬의 가비지 컬렉터가 더 이상 참조되지 않는 객체를 효과적으로 수거하는 데 도움이 됩니다.
  3. 메모리 사용량 모니터링:
    복잡한 GUI 애플리케이션을 개발할 때는 작업 관리자나 Python의 tracemalloc 모듈 등을 사용하여 애플리케이션의 메모리 사용량을 주기적으로 모니터링하는 것이 중요합니다. 예상치 못한 메모리 증가가 발생하면, 즉시 원인을 파악하고 해결해야 합니다.
  4. 객체 지향 설계 원칙 준수:
    부모 위젯 참조와 같은 중요한 정보는 클래스 속성보다는 생성자(__init__)를 통해 인스턴스 속성으로 전달하는 것이 더 안전하고 객체 지향적인 방식입니다. 이는 각 객체가 독립적으로 동작하고 자원을 관리하는 데 도움이 됩니다.
  5. Tkinter 이벤트 루프 이해:
    root.mainloop()는 Tkinter의 이벤트 루프를 시작하며, 이 루프가 실행되는 동안 위젯의 이벤트 처리가 이루어집니다. wait_window()와 같은 메서드는 이 이벤트 루프를 잠시 중단하고 특정 위젯의 파괴를 기다리는 방식으로 작동합니다. 이러한 흐름을 이해하는 것은 Tkinter 애플리케이션의 동작을 예측하고 디버깅하는 데 큰 도움이 됩니다.

Tkinter는 강력하고 유연한 GUI 라이브러리이지만, 그만큼 자원 관리와 객체 생명 주기에 대한 깊은 이해가 필요합니다. 오늘 다룬 내용을 통해 여러분의 Tkinter 애플리케이션이 더욱 안정적이고 효율적으로 작동하기를 바랍니다. 궁금한 점이 있다면 언제든지 댓글로 문의해 주세요!

댓글 남기기