포스트

ANSI/VT Escape Sequence — 표준 출력으로 커서를 움직이고 화면을 그리는 법

TUI 앱이 픽셀을 직접 그리는 대신 stdout으로 문자와 제어 시퀀스를 보내고, 터미널 에뮬레이터가 이를 해석해 화면 상태를 바꾸는 원리를 정리한다.

ANSI/VT Escape Sequence — 표준 출력으로 커서를 움직이고 화면을 그리는 법

앞에서는 터미널 입력이 PTY와 termios를 거쳐 애플리케이션에 들어오는 방향을 봤다.

이번에는 반대 방향이다.

애플리케이션은 터미널 화면을 어떻게 바꾸는가?

핵심은 한 문장이다.

애플리케이션이 제어용 바이트를 출력하고, 터미널 에뮬레이터가 그것을 화면 제어 명령으로 해석한다.

전체 흐름은 다음과 같다.

1
2
3
4
5
6
7
8
9
애플리케이션
  ↓ stdout으로 문자 + 제어 시퀀스 출력
PTY
  ↓
터미널 에뮬레이터
  ├─ 일반 문자는 화면에 표시
  └─ 제어 시퀀스는 명령으로 해석
  ↓
화면

즉 TUI 앱이 화면의 픽셀을 직접 그리는 것이 아니다.

애플리케이션은 문자와 화면 제어 명령이 섞인 바이트 스트림을 보내고, 실제 화면을 바꾸는 주체는 터미널 에뮬레이터다.

왜 이런 제어 문법이 필요한가

터미널과 컴퓨터가 문자만 주고받는다면 A, B, C 같은 바이트를 화면에 표시하면 끝난다.

하지만 화면을 제대로 제어하려면 문자 출력 말고도 이런 요청이 필요하다.

1
2
3
4
커서를 5행 10열로 옮겨라
화면을 지워라
글자 색을 바꿔라
대체 화면으로 전환하라

문제는 이 명령도 결국 같은 바이트 스트림을 통해 전달해야 한다는 것이다.

그래서 일반 문자와 제어 명령을 구분할 수 있는 약속이 필요해졌다.

1
2
3
4
5
일반 바이트
→ 화면에 문자로 표시

약속된 제어 바이트 조합
→ 문자로 표시하지 않고 명령으로 해석

ANSI/ECMA 계열 표준은 이런 제어 기능과 제어 시퀀스의 표현 방식을 정의했고, DEC의 VT 계열 터미널은 이런 규칙을 실제 터미널 동작으로 구현하며 큰 영향을 줬다.

따라서 처음에는 ANSI/VT를 다음처럼 이해하면 충분하다.

ANSI/VT = 애플리케이션이 터미널 에뮬레이터에게 화면 제어 의도를 전달하기 위한 명령 문법 계열

문자와 제어 명령은 같은 출력 스트림에 있다

가장 단순한 출력은 다음과 같다.

1
printf 'hello\n'

이 바이트는 일반 문자이므로 터미널 에뮬레이터가 화면에 그대로 표시한다.

반면 다음 출력에는 제어 시퀀스가 섞여 있다.

1
printf '\e[31mRED\e[0m\n'

터미널 에뮬레이터는 일부 바이트를 문자로 표시하고, 일부는 명령으로 해석한다.

1
2
일반 문자      → 화면에 표시
제어 시퀀스    → 화면 상태 변경

제어 시퀀스로 할 수 있는 대표적인 일은 다음과 같다.

1
2
3
4
5
커서 이동
화면 지우기
색상·문자 속성 변경
대체 화면 전환
커서 표시/숨김

이 정도가 ANSI/VT 계열 제어를 이해하는 핵심이다.

제어 시퀀스는 터미널 상태를 바꾼다

터미널 에뮬레이터는 단순히 받은 문자를 찍는 프린터가 아니다.

다음과 같은 상태를 가지고 있다.

1
2
3
4
5
커서 위치
현재 문자 속성
화면 내용
스크롤 영역
화면 모드

제어 시퀀스는 이런 상태를 변경한다.

1
2
3
4
5
6
7
애플리케이션 출력
  ↓
터미널 에뮬레이터가 해석
  ↓
터미널 상태 변경
  ↓
화면 갱신

예를 들어 커서를 다른 위치로 옮긴 뒤 문자를 출력하면, 다음 문자는 그 위치에 나타난다.

대표적인 예 세 가지

1. 화면 지우기

1
printf '\e[2J\e[H'

화면을 지우고 커서를 홈 위치로 옮기는 형태다.

2. 커서 이동

1
printf '\e[5;10HHELLO'

커서를 특정 위치로 이동한 뒤 HELLO를 출력한다.

3. 색상 변경

1
printf '\e[31mRED\e[0m\n'

문자 색상을 바꿨다가 다시 원래 속성으로 복구한다.

이 세 예에서 중요한 것은 명령 문법을 외우는 것이 아니다.

같은 stdout 바이트 스트림 안에서 일부는 문자이고, 일부는 터미널 상태를 바꾸는 명령이라는 것

이 핵심이다.

대체 화면 — Neovim을 나가면 셸 화면이 돌아오는 이유

Neovim, less, htop 같은 전체 화면 프로그램을 종료하면 이전 셸 화면이 다시 나타나는 경우가 많다.

이때 흔히 사용하는 것이 대체 화면(Alternate Screen)이다.

1
2
3
터미널 에뮬레이터
├─ 기본 화면 버퍼      ← 평소 셸
└─ 대체 화면 버퍼      ← 전체 화면 TUI에서 잠깐 사용

앱은 시작할 때 제어 시퀀스를 출력해 대체 화면 버퍼로 전환하고, 종료할 때 다시 기본 화면 버퍼로 돌아온다.

1
2
3
4
5
6
7
8
9
10
11
셸 사용 중
  ↓
앱이 대체 화면 전환 제어 시퀀스 출력
  ↓
터미널 에뮬레이터가 대체 화면 버퍼로 전환
  ↓
Neovim 같은 전체 화면 UI 표시
  ↓
앱 종료 시 복귀 제어 시퀀스 출력
  ↓
기본 화면 버퍼 복원

즉 대체 화면은 앱이 별도의 GUI 창을 만드는 것이 아니라, 터미널 에뮬레이터가 관리하는 별도 화면 버퍼로 전환해 사용하는 것이다.

커서를 숨기고 다시 표시하는 것도 같은 종류의 상태 변경이다.

즉 이런 동작도 모두:

1
2
3
앱이 제어 시퀀스 출력
→ 터미널 에뮬레이터가 해석
→ 터미널 상태 변경

이라는 같은 구조로 이해할 수 있다.

여기까지가 핵심

처음 읽을 때는 다음만 기억하면 충분하다.

1
2
3
4
5
애플리케이션
→ 문자 + 제어 시퀀스 출력
→ PTY
→ 터미널 에뮬레이터가 해석
→ 화면 상태 변경

그리고 역할도 이렇게 나뉜다.

1
2
3
4
5
애플리케이션
= 무엇을 보여줄지 결정하고 바이트를 출력

터미널 에뮬레이터
= 그 바이트를 문자 또는 제어 명령으로 해석해 실제 화면을 갱신

아래는 이 핵심을 이해한 뒤 보면 되는 세부 내용이다.

ANSI/VT 문법 보기 — ESC와 CSI

여기부터는 위에서 말한 제어 시퀀스가 실제 바이트로 어떤 형태를 가지는지 보는 부분이다.

즉 새 개념이 아니라, 앞에서 본 ANSI/VT 명령 문법의 구체적인 표현이다.

1
2
3
4
5
ANSI/VT
= 터미널 제어 명령 규칙 전체

ESC / CSI
= 그 규칙 안에서 실제 명령을 표현하는 대표 형식

많은 터미널 제어 시퀀스는 ESC 바이트로 시작한다.

ASCII에서 ESC는 0x1b이며 셸에서는 흔히 다음처럼 표현된다.

1
2
3
\033
\e
\x1b

현대 터미널에서 자주 보는 형태는 다음과 같다.

1
ESC [ 매개변수 명령문자

이를 CSI(Control Sequence Introducer) 계열이라고 부른다.

예를 들어:

1
2
3
ESC [ 2 J        → 화면 지우기 계열
ESC [ 10 ; 20 H  → 커서 위치 이동
ESC [ 31 m       → 문자 표시 속성 변경

처음에는 각 명령문자를 외울 필요가 없다.

앱이 이 문법에 맞는 바이트 시퀀스를 출력하고, 터미널 에뮬레이터가 이를 명령으로 해석한다는 관계가 중요하다.

심화 — 입력 쪽에서도 시퀀스가 쓰인다

같은 터미널/VT 계열 시퀀스 관습이 입력 방향에서도 쓰일 수 있다. 다만 방향과 의미가 다르다.

1
2
3
4
5
애플리케이션 → 터미널 에뮬레이터
= 화면 제어 시퀀스

터미널 에뮬레이터 → 애플리케이션
= 특수키 입력을 표현하는 시퀀스

터미널 에뮬레이터는 방향키 같은 특수키를 애플리케이션에 보낼 때 여러 바이트의 시퀀스를 사용할 수 있다.

예를 들어 위쪽 방향키는 흔히 다음과 비슷한 형태다.

1
ESC [ A

즉 출력에서는 앱이 시퀀스를 보내 터미널의 상태를 바꾸고, 입력에서는 터미널 에뮬레이터가 시퀀스를 보내 어떤 특수키가 눌렸는지 앱에 표현한다.

앞의 termios 문서에서 raw mode가 키 이벤트 자체를 만들어주는 것이 아니라, 애플리케이션이 바이트 스트림을 직접 해석할 수 있게 해주는 기반이라고 한 이유가 여기서 연결된다.

심화 — ANSI와 VT라는 이름

ANSI와 VT는 출발점이 다르다.

1
2
3
4
5
6
7
8
ANSI
= American National Standards Institute
= 미국의 표준화 기관 이름
= 여기서는 ANSI 계열 터미널 제어 규격 쪽을 가리킴

VT
= Video Terminal
= DEC의 VT100, VT220 같은 실제 터미널 제품군 이름

즉 간단히 보면:

1
2
3
4
5
ANSI
= 표준 규격 쪽 이름

VT
= 실제 터미널 제품/계열 쪽 이름

DEC의 VT 계열 터미널은 ANSI 계열 제어 규칙을 널리 구현했고, 이후 터미널 에뮬레이터들이 VT 호환 동작을 많이 따라갔다.

그래서 오늘날에는 다음 표현이 함께 쓰인다.

1
2
3
ANSI escape code
VT sequence
terminal control sequence

처음 학습할 때는 역사적 차이보다 터미널 에뮬레이터가 해석하는 화면 제어용 바이트 시퀀스라는 공통 역할을 먼저 잡는 편이 중요하다.

다음 단계 — 터미널마다 제어 방식이 다르면?

여기까지는 애플리케이션이 제어 시퀀스를 알고 있다고 가정했다.

하지만 실제로는 터미널마다 지원하는 기능이나 제어 시퀀스가 다를 수 있다.

그러면 다음 질문이 생긴다.

현재 터미널에서 어떤 기능을 어떤 시퀀스로 호출해야 하는지 애플리케이션은 어떻게 알까?

여기서 TERM, termcap, terminfo, tput이 등장한다.

1
2
3
4
5
6
7
애플리케이션
  ↓
TERM / terminfo
  ↓
현재 터미널에 맞는 제어 시퀀스
  ↓
터미널 에뮬레이터

지금까지 연결

입력과 출력을 함께 놓으면 다음처럼 볼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
입력
키보드
  ↓
터미널 에뮬레이터
  ↓
PTY
  ↓
termios 규칙
  ↓
애플리케이션
  ↓
문자 + ANSI/VT 제어 시퀀스 출력
  ↓
PTY
  ↓
터미널 에뮬레이터
  ↓
화면
출력

핵심은 다음 한 문장으로 다시 정리할 수 있다.

터미널 앱은 입력 바이트를 받고, 화면을 바꾸기 위한 문자와 제어 시퀀스를 다시 출력한다.

참고

  • ECMA-48 — coded character data 안에 포함되는 control function과 control sequence 표현 규칙
  • DEC VT100/VT 계열 문서 — 실제 터미널이 escape/control sequence를 어떻게 해석했는지 보여주는 대표 자료
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.