tmux와 SSH까지 연결한 터미널 전체 스택
Ghostty 같은 터미널 에뮬레이터에서 로컬 PTY, tmux client/server와 Pane PTY, SSH 전송, 원격 PTY, TERM, SIGWINCH가 어떻게 이어지는지 전체 경로를 한 번에 정리한다.
지금까지 터미널 스택을 조각별로 봤다.
1
2
3
4
5
6
7
TTY / PTY
termios
ANSI / VT
terminfo
curses
TUI 렌더러
Neovim
마지막으로 이 조각들을 실제 개발 환경 하나에 연결해보자.
예시는:
1
2
3
4
5
6
7
Ghostty
↓
tmux
↓
SSH
↓
원격 Neovim
이다.
겉보기에는 터미널 창 하나지만 내부에는 여러 개의 PTY와 터미널 에뮬레이션 계층이 겹친다.
이 글의 핵심 질문은 두 가지다.
1
2
1. tmux는 왜 프로그램을 끊지 않고 터미널을 분리·재접속할 수 있는가?
2. SSH는 왜 원격 프로그램을 로컬 터미널에서 자연스럽게 사용할 수 있는가?
둘의 공통점은 프로그램에게 Terminal 인터페이스를 다시 제공하거나 그 입출력을 다른 곳으로 전달한다는 것이다.
1. tmux 없이 로컬 셸만 실행
가장 단순한 구조부터 시작한다.
Ghostty 같은 터미널 에뮬레이터를 열면 대략:
1
2
3
4
5
6
7
Ghostty
│
│ PTY master
↕
PTY slave
│
zsh
구조가 만들어진다.
여기서 ↕는 호출 관계가 아니라 연결과 양방향 데이터 흐름을 뜻한다.
터미널 에뮬레이터는 master 쪽을 잡고 셸은 slave 쪽을 자신의 터미널처럼 사용한다.
셸의:
1
2
3
stdin
stdout
stderr
가 그 PTY slave에 연결된다.
사용자가 타이핑하면:
1
2
3
4
5
6
7
8
9
키보드
↓
Ghostty
↓
PTY master
↓
터미널 드라이버 / termios
↓
zsh
출력은 반대로 올라간다.
1
2
3
4
5
6
7
8
9
zsh stdout
↓
PTY slave
↓
PTY master
↓
Ghostty 파서
↓
화면
2. 그 안에서 Neovim을 실행
1
nvim
을 실행해도 새 PTY가 반드시 생기는 것은 아니다.
셸이 Foreground Process Group을 Neovim 쪽으로 넘기고 같은 제어 터미널(Controlling Terminal)을 사용한다.
개념적으로:
1
2
3
4
5
Ghostty
↕
PTY
↕
Neovim
이 된다.
Neovim은 터미널을 raw/noncanonical 계열로 설정하고 내장 TUI가 화면 제어 Sequence를 출력한다.
종료하면 터미널 상태를 복구하고 Foreground 제어가 셸로 돌아간다.
3. tmux를 실행하면 왜 PTY가 하나 더 생기는가
이제:
1
tmux
를 실행한다.
multiplex는 여러 신호·흐름을 하나의 매체나 연결 위에서 나누어 다루는 뜻이다. 그래서 Terminal Multiplexer는 하나의 바깥 Terminal 연결 위에서 여러 Terminal session과 Pane을 관리하는 프로그램이라는 이름이다.
tmux는 단순히 화면을 나누는 UI가 아니다.
공식 tmux 구조에서 server는 session/window/pane을 관리하고, 각 Pane에는 별도의 Pseudoterminal이 있다.
큰 그림은:
1
2
3
4
5
6
7
8
9
10
11
Ghostty
↕
바깥 PTY
↕
tmux client
↕ socket
tmux server
↕
Pane PTY
↕
zsh / Neovim
이다.
여기서 PTY가 두 층이다.
바깥쪽에서는 tmux client가 Terminal 프로그램처럼 보이고, 안쪽에서는 tmux server가 Pane 프로그램에게 다시 Terminal 환경을 제공한다.
4. tmux client와 server를 분리해서 보기
사용자가 보는 화면 쪽은 tmux client다.
client는 현재 바깥 터미널과 붙는다.
1
2
3
4
5
Ghostty
↕
PTY
↕
tmux client
하지만 session 상태와 Pane 안의 프로그램을 실제로 관리하는 것은 백그라운드의 tmux server다.
client와 server는 Unix socket으로 통신한다.
1
2
3
tmux client
↕ socket
tmux server
그래서 client를 detach해도 server와 Pane 안의 프로그램은 살아 있을 수 있다.
1
2
3
4
Client 종료
X
Server ── Pane PTY ── Process
계속 실행
이게 tmux session persistence의 핵심이다.
사용자가 붙었다 떨어지는 바깥 Terminal 연결과 실제 작업 프로그램이 붙어 있는 Pane PTY를 분리했기 때문에 가능한 구조다.
5. 각 Pane은 하나의 Pseudoterminal이다
창을 세 개 Pane으로 나누면 단순한 화면 직사각형 세 개가 아니다.
각 Pane 안에는 별도 PTY가 있다.
1
2
3
4
5
tmux server
/ | \
PTY PTY PTY
↓ ↓ ↓
zsh nvim btop
각 프로그램 입장에서는 자신이 그냥 정상적인 터미널 하나에 연결되어 있다고 느낀다.
Neovim은:
나는 tmux Pane 안에 있다
라는 특별한 API를 사용할 필요가 없다.
그냥 PTY와 $TERM을 보고 터미널 애플리케이션처럼 행동한다.
이게 Unix 터미널 추상화의 힘이다.
6. tmux가 중간 터미널 에뮬레이터처럼 행동한다
Pane 안의 프로그램이 출력한다.
1
2
3
4
5
6
7
Neovim
↓
Escape Sequence
↓
Pane PTY
↓
tmux server
그런데 이 Sequence가 무조건 바깥 Ghostty로 그대로 전달되는 것은 아니다.
tmux가 Pane의 터미널 출력을 해석해 자신의 화면 모델(Screen Model)을 갱신하고, 현재 client 터미널에 맞게 다시 그린다.
1
2
3
4
5
6
7
8
9
안쪽 앱
↓
안쪽 터미널 Protocol
↓
tmux 화면 모델
↓
바깥 터미널 Protocol
↓
Ghostty
그래서 tmux 자체가 중간 터미널 에뮬레이터/멀티플렉서 계층처럼 보인다.
7. 그래서 tmux 안에서는 TERM이 달라진다
바깥 터미널에서:
1
echo $TERM
한 값과 tmux 안의 값이 다를 수 있다.
예를 들면:
1
2
바깥: xterm-256color 계열
안쪽: tmux-256color
이유는 Pane 안 앱이 직접 Ghostty와 대화하는 게 아니기 때문이다.
1
2
3
4
5
6
7
8
9
앱
↓
"tmux가 제공하는 터미널 Capability"
↓
tmux
↓
"실제 바깥 터미널 Capability"
↓
Ghostty
즉 안쪽 $TERM은 tmux라는 가상 터미널의 계약을 표현한다.
8. 대체 화면도 tmux가 중간에서 처리한다
Neovim이 대체 화면(Alternate Screen)으로 들어가면:
1
2
3
4
5
Neovim
↓ smcup 계열
Pane PTY
↓
tmux
tmux가 이 상태를 관리한 뒤 자신의 client 화면에 반영한다.
tmux의 alternate-screen 옵션이 이런 동작과 연결된다.
즉 앞에서 배운 terminfo Capability가 실제 멀티플렉서 안에서도 그대로 중요하다.
9. 터미널 크기 변경 — 창 크기를 바꾸면 어떻게 Neovim까지 전달되나
Ghostty 창을 늘린다고 하자.
가장 바깥 터미널 크기가 바뀐다.
1
2
3
4
5
6
7
Ghostty 크기 변경
↓
바깥 PTY 크기 변경
↓
SIGWINCH / 크기 변경 이벤트
↓
tmux client/server
tmux는 새로운 client 크기를 기준으로 Pane 레이아웃을 다시 계산한다.
1
2
3
4
5
6
전체 120x40
↓
tmux 레이아웃
↓
Pane A 60x40
Pane B 60x40
그다음 각 Pane PTY의 크기를 갱신한다.
1
2
3
4
5
6
7
Pane PTY 크기 변경
↓
Foreground Process에 SIGWINCH
↓
Neovim
↓
UI 레이아웃 재계산
그래서 마우스로 터미널 창을 늘리면 Neovim 화면이 거의 즉시 다시 배치된다.
하나의 크기 변경이 여러 계층을 타고 전파되는 것이다.
10. 이제 SSH를 넣어보자 — 실행과 Terminal 연결은 다르다
여기서 가장 먼저 구분해야 할 것이 있다.
프로그램을 실행하는 것 자체에는 Terminal이 필요하지 않다.
Terminal은 프로세스를 실행시키는 엔진이 아니라, 프로세스가 사용자와 상호작용할 때 사용할 수 있는 입출력 환경 중 하나다.
그래서 SSH에는 크게 두 그림이 있다.
10.1 원격 명령만 실행하는 경우
1
ssh server ls
이 경우 원격 ls는 PTY 없이도 실행할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
로컬 Terminal Emulator
↓
로컬 PTY
↓
ssh client
↓ 암호화된 네트워크
ssh server
↓ 프로세스 실행
ls
↓ stdout/stderr
SSH channel
↓
ssh client
↓
로컬 Terminal Emulator
원격 sshd가 프로세스를 실행하고 ls의 stdout/stderr를 SSH channel에 연결하면 된다.
즉 원격 ls가 Terminal에 붙어 있지 않아도 결과는 네트워크를 통해 로컬 화면까지 전달될 수 있다.
1
2
3
프로그램 실행
≠
Terminal에 연결되어 실행
10.2 대화형 Terminal이 필요한 경우
반대로:
1
ssh server
처럼 원격 Shell을 대화형으로 사용하거나 vim, top 같은 프로그램을 사용할 때는 Terminal 기능이 필요하다.
PTY를 할당한 대화형 SSH 세션을 단순화하면:
1
2
3
4
5
6
7
8
9
10
11
로컬 터미널 에뮬레이터
↕
로컬 PTY
↕
ssh client
↕ 암호화된 네트워크
ssh server
↕
원격 PTY
↕
원격 셸
핵심은 원격에서도 PTY가 하나 생긴다는 것이다.
원격 셸이나 Neovim은 네트워크 socket을 직접 터미널로 사용하는 것이 아니라 원격 PTY의 slave 쪽을 터미널로 본다.
10.3 ssh -t는 무엇인가
원격 명령 실행에서도 PTY를 명시적으로 요청할 수 있다.
1
ssh -t server top
-t는 원격 측에 pseudo-terminal allocation을 요청하는 옵션이다.
비교하면:
1
2
3
4
5
6
7
8
9
10
11
ssh server ls
→ 명령 실행
→ 원격 PTY 없어도 됨
ssh server
→ 대화형 원격 Shell
→ 일반적으로 원격 PTY 사용
ssh -t server top
→ 원격 top 실행
→ top이 사용할 PTY를 명시적으로 요청
따라서 로컬 Terminal에서 SSH 명령을 입력했다는 사실과 원격 프로그램이 Terminal에 연결되어 있다는 사실은 별개다.
11. SSH에서 키 입력은 어떻게 흐르는가
이제 PTY를 할당한 대화형 세션을 보자.
사용자가 로컬에서 j를 누른다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
키보드
↓
로컬 터미널 에뮬레이터
↓
로컬 PTY
↓
ssh client
↓ 암호화
네트워크
↓
sshd
↓
원격 PTY
↓
원격 Neovim
원격 Neovim이 출력한 Escape Sequence는 반대로 돌아온다.
1
2
3
4
5
6
7
8
9
10
11
12
13
원격 Neovim
↓
원격 PTY
↓
sshd
↓ 암호화된 네트워크
ssh client
↓
로컬 PTY
↓
로컬 터미널 에뮬레이터
↓
화면
그래서 실제 렌더링은 로컬 터미널 에뮬레이터가 하지만 애플리케이션은 원격에서 실행될 수 있다.
12. 그래서 SSH는 픽셀을 전송하지 않는다
일반적인 터미널 SSH 세션은 원격 데스크톱처럼 framebuffer 픽셀을 보내는 구조가 아니다.
기본적으로 터미널 입출력 바이트 스트림을 운반한다.
1
2
3
4
5
6
원격 앱 출력
"ESC[31mHELLO"
↓ 네트워크
로컬 터미널 에뮬레이터
↓
실제 색상/글리프 렌더링
이 때문에 네트워크 대역폭이 작아도 복잡한 TUI를 원격에서 사용할 수 있다.
13. TERM도 원격에 필요하다
원격 프로그램은 실제 로컬 터미널 에뮬레이터가 무엇을 지원하는지 알아야 한다.
그래서 대화형 SSH 세션에서는 터미널 종류 정보가 원격 환경과 연결된다.
1
2
3
4
5
로컬 TERM
↓ SSH 터미널 요청
원격 TERM
↓
원격 terminfo 조회
원격 서버의 terminfo에 해당 Entry가 없으면:
1
unknown terminal type
문제가 생길 수 있다.
즉 최신 터미널 에뮬레이터를 쓰면서 오래된 서버에 접속할 때 발생하는 호환성 문제도 이 스택으로 설명된다.
14. 터미널 크기 변경도 SSH를 건너간다
로컬 터미널 창 크기가 바뀌면 SSH client는 원격 측에 새로운 터미널 크기를 알려준다.
1
2
3
4
5
6
7
8
9
10
11
로컬 크기 변경
↓
ssh client
↓ 네트워크 window-change 요청
sshd
↓
원격 PTY 크기 갱신
↓
SIGWINCH
↓
원격 Foreground Process
그래서 원격 Neovim도 로컬 창 크기에 맞춰 다시 그려진다.
15. 로컬 tmux 안에서 SSH하면
이제 실제 자주 쓰는 구조다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Ghostty
↕
바깥 PTY
↕
tmux client
↕
tmux server
↕
Pane PTY
↕
ssh client
↕ 네트워크
sshd
↕
원격 PTY
↕
원격 Neovim
PTY만 세어도 여러 개다.
하지만 각 프로그램은 바로 옆 계층만 알면 된다.
1
2
3
4
5
6
7
8
tmux Pane의 ssh client
→ 자신의 제어 터미널만 알면 됨
원격 Neovim
→ 원격 PTY와 TERM만 알면 됨
Ghostty
→ 최종 바이트 스트림을 그리면 됨
복잡한 전체 구조를 모든 프로그램이 알 필요가 없다.
16. 원격 서버에서도 tmux를 다시 실행하면
1
2
3
4
5
6
7
8
9
로컬 Ghostty
↓
로컬 tmux
↓
SSH
↓
원격 tmux
↓
원격 Neovim
도 가능하다.
구조는 더 깊어진다.
1
2
3
4
5
6
7
8
9
터미널 에뮬레이터
↕ PTY
로컬 tmux
↕ PTY
SSH 전송
↕ 원격 PTY
원격 tmux
↕ Pane PTY
Neovim
그래도 Unix PTY 추상화가 반복될 뿐이다.
같은 인터페이스를 계층마다 다시 제공할 수 있기 때문에 터미널 중첩이 가능하다.
17. Ctrl-C도 전체 스택을 타고 간다
Ctrl-C를 생각해보자.
로컬 Neovim이 아니라 원격 Foreground Process를 끊는 상황이라면 키 바이트가 SSH를 건너 원격 PTY에 들어간다.
원격 터미널 드라이버의 ISIG가 활성화된 상태라면 그곳에서:
1
2
3
4
5
6
7
0x03
↓
VINTR
↓
SIGINT
↓
원격 Foreground Process Group
으로 바뀐다.
즉 Signal이 네트워크를 그대로 “SIGINT packet”으로 이동한다고 단순화하면 안 된다.
터미널 입력 바이트와 원격 PTY의 터미널 의미론이 중요한 역할을 한다.
물론 SSH Protocol 자체에도 Signal 전달 기능 등 별도 메커니즘이 있지만 일반 대화형 터미널에서 제어 키를 이해하는 기본 그림은 PTY 의미론과 함께 보는 것이 좋다.
18. 지금까지 배운 모든 층
최종 지도를 만들면:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌──────────────────────────────────┐
│ 터미널 에뮬레이터 │
│ Ghostty / Kitty / WezTerm │
├──────────────────────────────────┤
│ PTY │
├──────────────────────────────────┤
│ 터미널 멀티플렉서 / 전송 │
│ tmux / SSH │
├──────────────────────────────────┤
│ PTY │
├──────────────────────────────────┤
│ 터미널 드라이버 │
│ termios / raw mode / Signal │
├──────────────────────────────────┤
│ 터미널 Protocol │
│ ANSI / VT Escape Sequence │
├──────────────────────────────────┤
│ Capability Database │
│ terminfo / TERM │
├──────────────────────────────────┤
│ UI 추상화 │
│ curses / TUI 프레임워크 │
├──────────────────────────────────┤
│ 애플리케이션 │
│ Neovim / fzf / lazygit / ... │
└──────────────────────────────────┘
정확히는 이 층들이 한 방향으로만 일렬 연결되는 것은 아니고 입력/출력 경로에 따라 역할이 교차하지만, 어떤 추상화가 어느 문제를 해결하는지를 보는 지도론 유용하다.
터미널 스택의 핵심
처음에는 TUI 앱이 화면에 박스를 어떻게 그리는지가 궁금했다.
바닥까지 내려오니 핵심은 의외로 단순하다.
1
2
3
4
5
입력:
터미널 → 바이트 스트림 → 앱
출력:
앱 → 바이트 스트림 → 터미널
그 사이에 Unix가 수십 년 동안 쌓은 계층들이 있다.
1
2
3
4
5
6
7
8
PTY
termios
Signal
Escape Sequence
terminfo
화면 추상화
렌더러
컴포넌트 프레임워크
그리고 tmux와 SSH는 이 터미널 인터페이스를 중간에서 다시 제공하거나 전송하기 때문에 기존 TUI 앱을 거의 그대로 중첩하고 원격으로 보낼 수 있다.
다만 역할은 다르다.
1
2
3
4
5
6
tmux
→ 안쪽 프로그램에게 PTY를 다시 제공하고 여러 session을 multiplex
SSH
→ 원격 실행과 입출력을 네트워크로 전달
→ 대화형 Terminal이 필요할 때 원격 PTY도 제공
이게 터미널 생태계가 오래 살아남은 가장 재미있는 구조 중 하나다.
참고
- tmux manual / Getting Started — server/client 구조, 각 Pane의 Pseudoterminal
- POSIX terminal interface / PTY semantics
- SSH interactive terminal PTY allocation과 window size 전달