ANSI/VT Escape Sequence — 표준 출력으로 커서를 움직이고 화면을 그리는 법
TUI 앱이 픽셀을 직접 그리는 대신 stdout으로 문자와 제어 시퀀스를 보내고, 터미널 에뮬레이터가 이를 해석해 화면 상태를 바꾸는 원리를 정리한다.
앞에서는 터미널 입력이 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를 어떻게 해석했는지 보여주는 대표 자료