파이썬 터미널 눈내리기 만들기: ANSI escape code와 프레임 루프로 배우는 콘솔 애니메이션

파이썬 터미널 눈내리기 애니메이션을 설명하는 대표 이미지
ANSI escape code와 프레임 루프로 배우는 작은 파이썬 터미널 눈내리기 예제

파이썬 터미널 눈내리기 예제는 그냥 귀여운 장난감으로 끝나지 않습니다. ANSI escape code, 프레임 루프, 좌표 갱신, 화면 다시 그리기 구조를 가장 작게 체험하기 좋은 콘솔 애니메이션 예제이기 때문입니다.

이번 글에서는 파이썬으로 작은 눈내리기 애니메이션을 직접 만들면서 터미널에서 같은 화면을 계속 갱신하는 방법을 차근차근 정리하겠습니다. 예제는 작지만, 실행해보면 왜 이런 구조가 재밌고 또 실용적인지 바로 감이 옵니다.


먼저 결과부터 보기

이 글의 목표는 복잡한 게임 엔진이 아닙니다. 점이나 별표가 아래로 천천히 떨어지고, 바닥에 닿으면 다시 위에서 나타나는 아주 작은 애니메이션입니다.

  *        .       *
       .        *
 *             .
         *
------------------------
Ctrl+C 로 종료

중요한 점은 이것이 여러 화면을 쌓아두는 출력이 아니라, 같은 영역을 계속 다시 그리는 출력이라는 점입니다. 이 감각이 잡히면 스피너, 진행 표시, 텍스트 게임 같은 다른 콘솔 예제로도 자연스럽게 이어집니다.


파이썬 터미널 눈내리기 전체 코드

먼저 전체 코드를 보고, 뒤에서 구조를 나눠서 설명하겠습니다. 일부러 단일 파일로 유지해서 바로 실행해보기 쉽게 만들었습니다.

import random
import shutil
import sys
import time


SNOW_CHARS = ["*", ".", "+"]


def move_cursor_home() -> None:
    sys.stdout.write("[H")


def clear_screen() -> None:
    sys.stdout.write("[2J")


def hide_cursor() -> None:
    sys.stdout.write("[?25l")


def show_cursor() -> None:
    sys.stdout.write("[?25h")


def make_snowflakes(count: int, width: int, height: int) -> list[dict]:
    flakes = []
    for _ in range(count):
        flakes.append(
            {
                "x": random.randint(0, width - 1),
                "y": random.randint(0, height - 1),
                "char": random.choice(SNOW_CHARS),
                "speed": random.choice([1, 1, 1, 2]),
            }
        )
    return flakes


def update_snowflakes(flakes: list[dict], width: int, height: int) -> None:
    for flake in flakes:
        flake["y"] += flake["speed"]

        drift = random.choice([-1, 0, 0, 1])
        flake["x"] = max(0, min(width - 1, flake["x"] + drift))

        if flake["y"] >= height:
            flake["y"] = 0
            flake["x"] = random.randint(0, width - 1)
            flake["char"] = random.choice(SNOW_CHARS)
            flake["speed"] = random.choice([1, 1, 1, 2])


def render_frame(flakes: list[dict], width: int, height: int) -> str:
    rows = [[" "] * width for _ in range(height)]

    for flake in flakes:
        x = flake["x"]
        y = flake["y"]
        if 0 <= x < width and 0 <= y < height:
            rows[y][x] = flake["char"]

    lines = ["".join(row) for row in rows]
    lines.append("-" * width)
    lines.append("Ctrl+C 로 종료")
    return "
".join(lines)


def run_animation() -> None:
    terminal = shutil.get_terminal_size((60, 20))
    width = terminal.columns
    height = max(8, terminal.lines - 3)
    flake_count = max(12, width // 3)

    flakes = make_snowflakes(flake_count, width, height)

    clear_screen()
    hide_cursor()

    try:
        while True:
            move_cursor_home()
            update_snowflakes(flakes, width, height)
            frame = render_frame(flakes, width, height)
            sys.stdout.write(frame)
            sys.stdout.flush()
            time.sleep(0.12)
    except KeyboardInterrupt:
        pass
    finally:
        show_cursor()
        sys.stdout.write("
눈내리기를 종료합니다.
")
        sys.stdout.flush()


if __name__ == "__main__":
    run_animation()

출력 제어와 시간 함수의 기본 의미는 Python의 sys.stdout 문서, time.sleep 문서, 그리고 ANSI/VT 계열 제어 시퀀스 설명은 Microsoft의 Console Virtual Terminal Sequences 문서를 참고했습니다.


구조는 네 단계다

이 예제는 아래 순서로 반복됩니다. 눈송이 목록을 만들고, 각 눈송이 좌표를 갱신하고, 현재 좌표 기준으로 화면 문자열을 만든 뒤, 같은 위치에 다시 출력합니다. 이 네 단계가 곧 프레임 루프입니다.

  1. 눈송이 목록을 만든다
  2. 각 눈송이 좌표를 한 칸씩 갱신한다
  3. 현재 좌표를 기준으로 화면 문자열을 만든다
  4. 같은 위치에 다시 출력한다

게임처럼 들릴 수 있지만 사실은 아주 단순한 반복문입니다. 현재 상태를 갱신하고 다시 그리는 반복 구조만 이해하면 콘솔 애니메이션의 핵심은 거의 잡힌 셈입니다.

작은 애니메이션 직관은 의외로 단순합니다. 한 프레임에서는 아주 조금만 바꾸고, 그 작은 차이를 빠르게 이어 붙이면 사람 눈에는 움직임처럼 보입니다. 눈송이의 y 값이 1씩 내려가고 x 값이 가끔 흔들리기만 해도, 우리는 이미 “눈이 내린다”라고 느끼게 됩니다.


ANSI escape code 이해하기

이 글에서 쓰는 ANSI escape code는 화면 지우기, 커서 홈 이동, 커서 숨기기, 커서 다시 보이기 정도로만 제한했습니다. ANSI 전체를 외울 필요는 없고, 출력 스트림에 특수한 제어 시퀀스를 써서 터미널 동작을 바꾼다고 이해하면 충분합니다.

"\x1b[2J"    # 화면 지우기
"\x1b[H"     # 커서를 좌상단으로 이동
"\x1b[?25l"  # 커서 숨기기
"\x1b[?25h"  # 커서 다시 보이기

중요한 것은 아래 두 가지입니다. 시작할 때는 화면을 한 번 비우고, 루프 안에서는 매번 커서만 위로 보내서 같은 영역을 다시 그립니다. 즉, 화면을 새 로그처럼 쌓지 않고 덮어쓰는 방식으로 움직임을 만듭니다.


지우기와 이동은 다르다

처음 보면 둘 다 비슷해 보이지만 역할이 다릅니다. clear_screen()은 내용을 지우고, move_cursor_home()은 지우지 않은 채 커서만 맨 위로 돌립니다.

def clear_screen() -> None:
    sys.stdout.write("\x1b[2J")


def move_cursor_home() -> None:
    sys.stdout.write("\x1b[H")

이 예제에서는 시작할 때 한 번만 clear_screen()을 쓰고, 루프 안에서는 move_cursor_home()만 반복합니다. 이렇게 해야 매 프레임이 독립된 새 출력처럼 늘어나지 않고, 같은 위치에서 자연스럽게 갱신됩니다.


눈송이는 좌표 몇 개면 된다

눈송이 하나는 사실 별것 없습니다. 열 위치 x, 행 위치 y, 화면에 찍을 문자, 그리고 내려오는 속도 정도만 있으면 됩니다.

{
    "x": 12,
    "y": 3,
    "char": "*",
    "speed": 1,
}

이런 딕셔너리 여러 개를 리스트에 넣으면 눈송이 무리가 됩니다. 일부 눈송이만 조금 더 빠르게 내려오게 두면 화면이 덜 딱딱해지고, 과하지 않은 랜덤이 오히려 더 자연스럽게 느껴집니다.

def make_snowflakes(count: int, width: int, height: int) -> list[dict]:
    flakes = []
    for _ in range(count):
        flakes.append(
            {
                "x": random.randint(0, width - 1),
                "y": random.randint(0, height - 1),
                "char": random.choice(SNOW_CHARS),
                "speed": random.choice([1, 1, 1, 2]),
            }
        )
    return flakes

좌표를 조금씩 바꾼다

애니메이션이란 결국 상태 변화입니다. 이 글의 상태 변화는 눈송이 좌표를 조금씩 바꾸는 것입니다.

def update_snowflakes(flakes: list[dict], width: int, height: int) -> None:
    for flake in flakes:
        flake["y"] += flake["speed"]

        drift = random.choice([-1, 0, 0, 1])
        flake["x"] = max(0, min(width - 1, flake["x"] + drift))

        if flake["y"] >= height:
            flake["y"] = 0
            flake["x"] = random.randint(0, width - 1)
  • y를 늘리면 아래로 떨어진다
  • x를 가끔만 바꾸면 살짝 흔들리는 느낌이 난다
  • 바닥에 닿으면 위로 다시 보내서 루프를 이어간다

이 구조는 눈내리기 말고도 별똥별, 비, 버블, 숫자 폭포 같은 예제에 거의 그대로 재활용할 수 있습니다.


먼저 화면을 만든다

눈송이를 바로 문자열에 꽂아 넣으려고 하면 인덱스가 금방 헷갈립니다. 그래서 먼저 빈 화면을 2차원 배열로 만들고, 그 위에 좌표를 찍는 편이 훨씬 편합니다.

def render_frame(flakes: list[dict], width: int, height: int) -> str:
    rows = [[" "] * width for _ in range(height)]

    for flake in flakes:
        x = flake["x"]
        y = flake["y"]
        if 0 <= x < width and 0 <= y < height:
            rows[y][x] = flake["char"]

    lines = ["".join(row) for row in rows]
    return "\n".join(lines)
  • 현재 프레임 상태를 머릿속으로 그리기 쉽다
  • 디버깅이 쉽다
  • 다른 문자나 장애물을 추가하기도 쉽다

이 정도 콘솔 예제에서는 성능보다 구조가 더 중요합니다. 그래서 읽기 쉬운 2차원 배열 방식이 오히려 잘 맞습니다.


프레임 루프가 움직임을 만든다

터미널 애니메이션은 보통 이런 루프로 돌아갑니다. 먼저 상태를 갱신하고, 그 다음 프레임 문자열을 만들고, 마지막에 출력한 뒤 짧게 쉽니다.

while True:
    move_cursor_home()
    update_snowflakes(flakes, width, height)
    frame = render_frame(flakes, width, height)
    sys.stdout.write(frame)
    sys.stdout.flush()
    time.sleep(0.12)

너무 빠르면 화면이 바쁘고, 너무 느리면 멈춘 것처럼 느껴집니다. 보통 이런 작은 애니메이션은 0.08 ~ 0.15초 정도가 무난합니다.

반복문 흐름 제어 감각이 아직 헷갈린다면 파이썬 pass, continue, break 차이 글도 같이 보면 좋습니다. 반복 구조를 읽는 감각이 이어집니다.

여기서 중요한 것은 매 프레임이 독립된 그림이라는 점입니다. 이전 화면을 붙잡고 있는 것이 아니라, 현재 상태를 기준으로 화면 전체를 다시 계산해서 덮어쓴다고 생각하면 구조가 훨씬 잘 보입니다. 그래서 콘솔 애니메이션은 그래픽 라이브러리가 없어도 충분히 시작할 수 있습니다.


터미널 크기 맞추기

폭과 높이를 하드코딩하면 예제는 돌아가도 금방 어색해집니다. 좁은 터미널에서는 줄이 꺾이고, 높은 터미널에서는 화면이 지나치게 비어 보일 수 있습니다.

terminal = shutil.get_terminal_size((60, 20))
width = terminal.columns
height = max(8, terminal.lines - 3)
  • 전체 높이에서 안내 문구 2~3줄 정도는 빼준다
  • 너무 작은 터미널에서도 최소 높이는 보장한다
  • fallback 값을 같이 두어 환경 차이를 줄인다

비슷한 콘솔 출력 감각은 파이썬 택시 미터기 만들기 글에서도 이어집니다. 한 줄 갱신과 화면 폭 대응을 같이 보는 데 도움이 됩니다.


자주 하는 실수들

  • flush()를 빼서 화면이 늦게 갱신된다
  • 커서를 숨기고 다시 보여주지 않는다
  • 화면 높이보다 큰 좌표를 그대로 써서 인덱스가 틀어진다
  • 매 프레임마다 새 줄을 계속 찍어서 로그처럼 쌓인다
  • 이모지 너비를 믿고 배치했다가 정렬이 틀어진다

특히 커서 복구는 꼭 챙기는 편이 좋습니다. 작은 예제에서도 종료 처리가 깔끔해야 다음 터미널 작업이 편합니다.

finally:
    show_cursor()
    sys.stdout.write("\n눈내리기를 종료합니다.\n")
    sys.stdout.flush()

커서를 숨겼다면 finally에서 다시 보여주는 습관이 중요합니다. 보기 좋은 애니메이션보다 터미널을 망치지 않는 종료가 먼저입니다.


환경 차이도 있다

ANSI escape code는 요즘 터미널에서는 대체로 잘 동작하지만, 모든 환경이 완전히 똑같지는 않습니다. 오래된 콘솔 환경, 출력 리다이렉션, Windows 설정이나 셸 조합에 따라 차이가 날 수 있습니다.

그래서 이 글의 예제는 색상에 크게 기대지 않고, 커서 이동과 간단한 화면 제어만 쓰는 보수적인 형태로 유지했습니다. 환경 호환성을 더 챙기고 싶다면 먼저 단색 문자 애니메이션으로 시작하는 편이 안전합니다.


확장 아이디어

  • 눈송이 속도를 더 다양하게 만들기
  • 바닥에 쌓이는 눈 표현 추가하기
  • 색상을 넣어 가까운 눈과 먼 눈을 구분하기
  • 바람 세기를 바꿔 좌우 흔들림을 더 크게 만들기
  • 특정 시간 뒤 자동 종료하기

다만 처음부터 너무 많은 효과를 넣으면 구조를 이해하기 어려워집니다. 처음 한 번은 지금 코드처럼 작고 읽기 쉬운 버전으로 끝내는 것이 좋습니다.

비슷한 결의 콘솔 장난감을 더 보고 싶다면 파이썬 터미널 로딩 스피너 만들기 글도 함께 보면 좋습니다. 같은 출력 제어인데 한 줄 갱신과 화면 전체 갱신의 차이가 잘 이어집니다.


정리

파이썬 터미널 눈내리기 예제는 작지만 꽤 좋은 연습 문제입니다. ANSI escape code로 커서를 움직이고, 프레임 루프로 상태를 반복 갱신하고, 눈송이 좌표를 조금씩 바꾸고, 다시 그리는 구조를 한 번에 익힐 수 있기 때문입니다.

결국 핵심은 거창하지 않습니다. 상태를 만들고, 상태를 갱신하고, 화면을 다시 그리고, 너무 빠르지 않게 반복하면 됩니다. 이 네 가지만 잡히면 콘솔 애니메이션의 절반은 이미 이해한 것입니다.

함께보면 좋은 글