
이 파이썬 터미널 로딩 스피너 예제는 터미널 프로그램이 멈춘 것처럼 보일 때 가장 가볍게 UX를 보강하는 방법입니다. 특히 이번 수정에서는 실행 결과를 가짜처럼 꾸미지 않고, 실제 stdout 동작 기준으로 설명을 다시 잡았습니다.
이번 글에서는 frames, carriage return, flush, interval, 그리고 clean stop까지 한 줄짜리 스피너 안에 들어 있는 핵심을 차근차근 정리해보겠습니다. 작은 예제지만 CLI UX 감각을 익히기에는 꽤 좋습니다.
먼저, 이 예제의 결과를 정확하게 보는 방법
스피너 글에서 가장 자주 틀리는 부분은 실행 결과입니다. 스피너는 여러 줄을 찍는 프로그램이 아니라, 같은 한 줄을 계속 덮어쓰는 프로그램이기 때문입니다.
그래서 이 글에서는 결과를 두 가지로 나눠서 보여줍니다. 하나는 사람 눈에 보이는 화면이고, 다른 하나는 프로그램이 실제로 내보낸 stdout 캡처입니다. 이 둘을 분리해서 봐야 동작을 오해하지 않습니다.
1) 사용자가 보는 화면
사람 눈에는 같은 줄이 이렇게 바뀌는 것처럼 보입니다. 아래 네 줄은 읽기 편하게 나눠 적은 것이고, 실제로는 한 줄이 계속 갱신됩니다.
[|] 서버에 연결하는 중…
[/] 서버에 연결하는 중…
[-] 서버에 연결하는 중…
[✔] 서버에 연결하는 중… 완료
즉, 이것은 로그가 차곡차곡 쌓이는 출력이 아니라, 같은 줄 하나가 프레임만 바뀌며 움직이는 모습을 읽기 쉽게 펼쳐 적은 것입니다.
2) 프로그램이 실제로 내보낸 stdout 캡처
아래 값은 같은 예제를 subprocess.run(..., capture_output=True)로 받아서 제어 문자가 보이도록 확인한 실제 출력입니다.
stdout 캡처
'\r[|] 서버에 연결하는 중…\r[/] 서버에 연결하는 중…\r[-] 서버에 연결하는 중…\r[\] 서버에 연결하는 중…\r[✔] 서버에 연결하는 중… 완료\n'
- \r: 커서를 줄 맨 앞으로 돌려서 같은 위치를 다시 그린다
- 마지막 \n: 완료 후 줄바꿈해서 다음 로그가 붙지 않게 정리한다
이 캡처를 보면 왜 실제 터미널에서는 한 줄이 움직이는 것처럼 보이는지 훨씬 명확해집니다. 스피너의 본질은 새 줄 출력이 아니라 한 줄 갱신입니다.
파이썬 터미널 로딩 스피너 전체 코드
예제는 일부러 작게 유지했습니다. 입문자가 읽어도 구조가 바로 보이도록, 스피너 클래스 하나에 필요한 것만 담았습니다.
import itertools
import sys
import threading
import time
class Spinner:
def __init__(self, message="로딩 중...", interval=0.1):
self.message = message
self.interval = interval
self.frames = ["|", "/", "-", "\\"]
self._stop_event = threading.Event()
self._thread = None
def start(self):
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._spin, daemon=True)
self._thread.start()
def _spin(self):
for frame in itertools.cycle(self.frames):
if self._stop_event.is_set():
break
sys.stdout.write(f"\r[{frame}] {self.message}")
sys.stdout.flush()
time.sleep(self.interval)
def stop(self, final_message="완료"):
self._stop_event.set()
if self._thread:
self._thread.join()
sys.stdout.write(f"\r[✔] {self.message} {final_message}\n")
sys.stdout.flush()
if __name__ == "__main__":
spinner = Spinner("서버에 연결하는 중...", interval=0.12)
print("작업을 시작합니다.\n")
spinner.start()
try:
time.sleep(3)
finally:
spinner.stop()이 예제는 Python 공식 문서의 sys.stdout와 time.monotonic() 설명을 바탕으로, 터미널에서 무리 없이 동작하는 가장 기본적인 패턴만 뽑아낸 형태입니다.
먼저 구조를 눈에 익히자
이 코드에서 핵심 역할은 다섯 가지입니다. frames, \r, flush(), interval, stop(). 이 다섯 개만 이해하면 스피너의 본질은 거의 끝입니다.
- frames: 어떤 모양이 순환할지 정한다
- \r: 같은 줄을 다시 그리게 만든다
- flush(): 버퍼에 쌓지 말고 바로 보여 달라고 요청한다
- interval: 너무 빠르지도 느리지도 않게 리듬을 만든다
- stop(): 마지막 줄을 깔끔하게 정리한다
프레임은 문자열 배열이면 충분하다
스피너의 회전감은 거창한 애니메이션이 아니라, 작은 문자열 배열에서 나옵니다.
self.frames = ["|", "/", "-", "\\"]이 배열을 계속 순환시키면 우리가 익숙하게 보는 회전 느낌이 납니다. 처음부터 유니코드 점자 프레임을 쓰고 싶을 수도 있지만, 시작은 ASCII가 가장 편합니다. 터미널 환경 차이를 덜 타고 글꼴 깨짐 걱정도 적기 때문입니다.
- 터미널 환경 차이를 덜 탄다
- 글꼴 깨짐 걱정이 적다
- 예제 설명이 더 단순해진다
carriage return이 진짜 핵심이다
스피너를 스피너답게 만드는 핵심은 \r입니다. 역사적으로는 carriage return이고, 실무 감각으로는 커서를 현재 줄 맨 앞으로 되돌리는 문자라고 이해하면 충분합니다.
sys.stdout.write(f"\r[{frame}] {self.message}")
sys.stdout.flush()이 한 줄이 없으면 스피너는 회전이 아니라 로그 누적이 됩니다. 새 줄이 계속 늘어나기만 해서, 사용자는 움직임보다 지저분함을 먼저 느끼게 됩니다.
[|] 서버에 연결하는 중...
[/] 서버에 연결하는 중...
[-] 서버에 연결하는 중...
[\] 서버에 연결하는 중...즉, \r이 있어야만 같은 줄 위에서 프레임이 바뀌는 스피너다운 동작이 만들어집니다.
flush를 빼면 왜 답답해질 수 있을까
write()만 호출하고 flush()를 하지 않으면, 환경에 따라 출력이 버퍼에 잠깐 머물 수 있습니다. 내부적으로는 프레임이 바뀌고 있어도 사용자 눈에는 한동안 안 보일 수 있다는 뜻입니다.
sys.stdout.flush()같은 줄 갱신 UX는 결국 지금 바뀐 내용을 바로 보여 주는가가 핵심입니다. 그래서 스피너에서는 write()와 flush()를 같이 두는 편이 안전합니다.
interval은 속도보다 리듬에 가깝다
스피너는 빨라 보인다고 좋은 것이 아닙니다. 너무 빠르면 조급하고, 너무 느리면 멈춘 것처럼 보입니다.
time.sleep(self.interval)- 0.08초 ~ 0.15초: 대체로 자연스럽다
- 0.3초 이상: 약간 굼뜨게 느껴질 수 있다
- 0.01초 수준: 눈도 피곤하고 이득도 작다
이 글의 예제는 단순 데모라서 time.sleep()만으로 충분합니다. 다만 경과 시간을 별도로 계산해야 한다면 time.monotonic()처럼 단조 증가 시계를 쓰는 편이 더 안전합니다.
stop 처리가 깔끔해야 진짜 실전용이다
사실 시작보다 더 중요한 것은 종료입니다. 스피너를 멈추지 않거나 줄바꿈 없이 끝내면, 다음 로그가 같은 줄에 붙어서 화면이 금방 지저분해집니다.
def stop(self, final_message="완료"):
self._stop_event.set()
if self._thread:
self._thread.join()
sys.stdout.write(f"\r[✔] {self.message} {final_message}\n")
sys.stdout.flush()- 멈추라는 신호를 보낸다
- 스레드가 끝날 때까지 기다린다
- 완료 문장으로 현재 줄을 정리한다
- 마지막에 줄바꿈을 넣는다
이 마지막 줄바꿈 하나가 별것 아닌 것 같아도, CLI 인상을 꽤 많이 바꿉니다. 작업 종료 후 다음 로그가 보기 좋게 이어지느냐가 실제 사용감에 큰 차이를 만듭니다.
try/finally를 같이 두는 이유
실전 코드에서는 작업 중간에 예외가 나거나 사용자가 중단할 수 있습니다. 그럴 때도 화면이 망가지지 않게 하려면 stop()이 최대한 빠지지 않아야 합니다.
spinner.start()
try:
time.sleep(3)
finally:
spinner.stop()성공했을 때만 예쁘게 끝나는 프로그램보다, 중간에 틀어져도 정리되는 프로그램이 실제로 더 신뢰할 만합니다. 이런 부분이 바로 작은 UX를 실전 감각으로 바꾸는 지점입니다.
프로그램 흐름을 정돈하는 감각이 궁금하다면 파이썬 pass, continue, break 차이 글도 같이 보면 좋습니다. 흐름을 제어하는 생각법이 은근히 이어집니다.
이런 작업에서 특히 체감이 좋다
로딩 스피너는 아무 데나 붙일 필요는 없습니다. 하지만 기다림이 먼저 보이는 작업에서는 꽤 효율적인 UX입니다.
- API 응답을 몇 초 기다릴 때
- 파일 다운로드나 압축 해제가 잠깐 걸릴 때
- 설치 스크립트가 멈춘 것처럼 보일 때
- 짧은 배치 작업이 진행 중임을 알려주고 싶을 때
반대로 이미 퍼센트 기반 진행률이 있고 작업이 거의 즉시 끝난다면, 굳이 스피너가 필요 없을 수도 있습니다. 즉, 스피너는 정보를 많이 주는 도구라기보다 아무 신호도 없는 공백을 줄여 주는 도구에 가깝습니다.
비슷한 감성의 콘솔 예제가 더 보고 싶다면 파이썬 택시 미터기 만들기 글도 같이 보면 좋습니다. 한 줄 갱신이 주는 재미와 UX 포인트가 잘 이어집니다.
왜 이렇게 작은 UX가 꽤 크게 느껴질까
사용자는 내부 구현을 몰라도 괜찮습니다. 대신 지금 프로그램이 멈춘 것인지, 일하는 중인지는 알고 싶어 합니다.
스피너는 바로 그 불안을 가장 싸게 줄여 주는 장치입니다. 화려할 필요도 없습니다. 한 줄이 자연스럽게 움직이고, 끝날 때 깔끔하게 정리되면 이미 충분히 좋은 UX입니다.
정리
파이썬 터미널 로딩 스피너는 작지만 배울 것이 많은 예제입니다. frames로 움직임을 만들고, \r로 같은 줄을 다시 그리고, flush()로 바로 보여 주고, interval로 리듬을 조절하고, 마지막에는 stop()으로 화면을 정리하면 됩니다.
구현 비용은 작습니다. 그런데 사용자는 생각보다 빨리 차이를 느낍니다. CLI 프로그램이 너무 무뚝뚝하게 느껴진다면, 이런 작은 한 줄부터 붙여 보는 것이 가장 좋은 시작일 수 있습니다.