사람과 AI는 리눅스 터미널을 공유할 수 있을까? — 1편


[연재 1 / 3] · 사람과 AI는 리눅스 터미널을 공유할 수 있을까?

화면에는 프롬프트 하나만 보인다. 그러나 그 아래에는 서로 다른 프로그램과 커널 기능이 층층이 놓여 있다. AI와 무엇을 공유할지 정하려면, 먼저 그 층을 나눠 보아야 한다.

  • 리눅스 터미널을 AI와 함께 쓰면 좋지 않을까?
  • 입력 한 줄을 Bash로도, AI로도 보낼 수 있다면?
  • AI가 지금 터미널의 맥락을 보고 내 의도를 알아챈다면?

Agent CLI를 쓰다보면 불편한 점이 있다. 잘 아는 명령은 Bash에 입력하고, 조금 복잡한 일은 옆의 에이전트에게 부탁한다. 둘 다 글자로 대화하는 창인데도 맥락은 따로 흐른다. 그래서 자연스럽게 이런 생각이 든다. “두 창을 하나로 합치면 되지 않을까?”


그냥 검은 창 아닌가요

AI와 협업을 위해 어떤 입력은 터미널로, 어떤 입력은 에이전트에게 보낼지 정하기만 하면 되지 않을까? 짧고 정확한 명령은 Bash로 보내고, 자연어 문장은 AI로 보내면 될 듯하다. 에이전트가 화면의 마지막 출력까지 읽는다면 맥락 문제도 쉽게 해결될 것 같다.

그런데 터미널 안쪽을 들여다보면 낯선 이름이 잇달아 등장한다. TTY, PTY, line discipline, controlling terminal, foreground process group, Readline, ... . 게다가 터미널에는 Bash 프롬프트만 있는 것이 아니다. Vim을 떠올려보자. Bash 프롬프트와 달리 전체 화면을 사용하고, 키 입력도 다르게 사용된다. 터미널을 이해하려면 이런 화면 동작을 다루는 층까지 살펴봐야 한다.

이 것들은 우연히 덧붙은 부품이 아니다. 수십 년 동안 사람과 프로그램이 같은 통로를 나누어 쓰면서 자연스럽게 생긴 요소들이다. 평소에는 각 역할이 잘 맞물리므로 사용자 눈에 띄지 않는다. AI를 그 사이에 넣으려는 순간에야 그 복잡성이 드러난다.

이 글의 질문

“터미널을 공유한다”는 말에서 정확히 무엇을 공유하려는가? 화면일까, PTY일까, Bash의 상태일까, 작업 맥락일까, 아니면 명령을 실행할 권한일까?


펜팔에서 필담으로

터미널이 무엇인지 궁금해지면 곧바로, 왜 터미널을 터미널이라고 부르는지 궁금해진다. 꽤 오랜 역사를 거슬러 올라가는 이야기이지만, 의외로 2026년의 리눅스를 이해하는데 꽤 중요하다.

최초의 인터페이스

현대의 키보드, 마우스 + 모니터가 존재하기 이전에도 컴퓨터와의 상호작용에 사용되는 장치가 있었다, 물론. 교과서에서 들어본 최초의 컴퓨터는 카드에 구멍을 뚫어 정보를 입력했다. OMR 카드와 비슷한 것으로 코드를 표현 했다. (이진수를 작성했던 것이 아니다) 이 시절에 컴퓨터와의 상호작용은 작업을 제출하면 답이 오기를 기다리는, 마치 펜팔과 비슷한 것이었다.

eighty

출처: https://www.computerhistory.org/revolution/punched-cards/2/211/67

숫자, 글자, 특수문자를 각각 선택할 수 있다. 한 장에 단어 하나 정도 분량의 정보가 들어가므로 거대한 뭉치를 쌓아야 하나의 프로그램을 입력할 수 있었다.

sixty

출처 : https://kr.pinterest.com/pin/589971619983895139/

ASCII 표준이 만들어지기도 훨씬 이전이지만 이미 FORTRAN같은 고급 언어가 존재했다. OMR로 FORTRAN 코딩이라니. (이렇게 불편하게 코딩해도 실질적인 생산성 향상이 있었을까? 지금 AI 도입을 둘러싼 생산성 향상 논쟁 비슷한 것이 그 당시에도 있지 않았을까)

Teleprinter의 도입

Teleprinter는 컴퓨터가 존재하기 이전에, 전신망 통신을 위해 사용되던 장치이다. Teleprinter는 타자기인데, 내 쪽에서 키를 입력하면 상대방 종이에 글자가 찍히는 타자기이다. 1950년대 후반부터 이 장치가 컴퓨터와의 상호작용에 쓰이게 되면서 이제 컴퓨터와 사람의 상호작용은 수업시간에 단짝과 나누던 필담처럼, 실시간으로 일어나는 interactive session이 되었다.

▶ Video. 1970년대 Teleprinter로 ChatGPT와 대화하기

💡 한쪽에서 키를 누르면 반대편으로 전기 신호가 전송되어야 하는데, 이 당시 글자는 5bit로 표현되었다. 아마 영문 알파벳 26글자를 집어넣고 뒤늦게 ‘어? 특수문자 넣을 자리가 없네?’가 된 게 아닐까.

Teleprinter가 컴퓨터의 통신 장치로 사용되던 시점에 Teletype이라는 제품이 큰 인기를 끌게 된다. 이 제품명이 터미널 장치의 대명사가 되었고, 그래서 리눅스의 터미널 인터페이스 이름이 /dev/tty가 되었다.

모니터와 키보드

컴퓨터와 필담을 나누는 일이 꼭 낭만적이지 만은 않았다. Teleprinter는 크고, 느리고 시끄러운 장치였고, 종이를 많이 사용하는 문제가 있었다. 한번 쓴 글자를 지울 수도 없었다.

글자 수정

1973년 Unix 매뉴얼에서는 입력 문자열 수정을 위해 아래 방법을 사용한다

  • #: 직전에 입력한 문자 하나를 논리적으로 삭제
  • @: 현재 입력 중인 줄 전체를 취소

입력한 글자가 버퍼에 쌓이는 게 아니라 사용자 눈앞에 물리적으로 출력 되어있을 때 필요한 동작은 ‘방금 입력 값 지우기’ 혹은 ‘이번 줄 전체 지우기’가 되는 것이다. 1973년은 teleprinter에서 CRT 모니터로 터미널의 형태가 진화한 이후지만, teleprinter에서 유래된 전통이 여전히 유지된 것이다.

Backspace

글자를 ‘#’ 표시로 지웠다면, backspace는 무엇을 했을까?

Teleprinter에서 backspace는 단순히 출력 위치를 한 칸 앞으로 옮기는 역할을 했다. 만약 밑줄이나 취소선을 긋고 싶다면 한 칸에 두 가지 기호를 겹쳐야 하므로 출력 위치를 전으로 옮기는 기능이 필요하다.

eighty

출처 : https://ko.wikipedia.org/wiki/VT100

글자를 입력 받고, 결과를 화면에 보여 주는 장치일 뿐, CPU 같은 게 들어있진 않다. 브라운관이라는 기계 장치가 들어가서 부피가 큰 것이다 (라고 설명해줘야 하는 시대가 되었다니ㅠ) 이런식으로 모니터와 키보드가 통합된 장치를 ‘콘솔’이라고 불렀다.

1970년대 비디오 장치가 보급되면서 프린터 대신 CRT 화면을 출력 장치로 사용하게 된다. 이제 종이에 일회성으로 흔적을 남기는 대신 커서를 옮기거나 글자를 고치고 화면을 다시 그릴 수 있게 되었다. UX는 크게 향상되었지만 컴퓨터와 터미널의 관계는 크게 바뀌지 않았다. 논리적으로는 teleprinter 시절과 동일하다. 컴퓨터는 문자와 제어 코드를 보낸다. 터미널은 그 바이트열을 해석해 화면 상태를 바꾼다. 사용자가 키를 누르면 터미널은 그 키를 나타내는 문자나 제어 시퀀스를 컴퓨터로 보낸다.


현대 터미널의 층

이제 다시 익숙한 현대의 Linux 데스크톱으로 돌아오자. Ghostty, Kitty, GNOME Terminal 같은 창을 열면 물리적인 단말은 보이지 않는다. 대신 옛 단말이 맡던 일을 여러 프로그램과 커널 기능이 나누어 처리한다.

가장 바깥에는 terminal emulator가 있다. 이 프로그램은 Wayland나 X11을 거쳐 키 이벤트를 받고, 그 입력을 터미널 프로토콜의 바이트열로 바꾼다. 반대 방향에서는 프로그램이 보낸 문자와 VT/ANSI 제어 sequence를 해석해 커서와 화면 격자를 갱신하고, 글자를 픽셀로 그린다. shell 문법을 해석하거나 pipeline을 만드는 일은 맡지 않는다.

full

터미널 에뮬레이터는 데스크톱의 키 이벤트를 처음 받는 사용자 공간 프로그램이다. 그러나 “이 키는 Readline으로, 저 키는 Vim으로 보내자”고 판단하지는 않는다. 키 이벤트를 터미널 프로토콜에 맞는 바이트열로 인코딩해 PTY master에 쓴다. 커널이 터미널 설정에 따라 처리한 뒤, 일반 입력으로 남은 바이트를 읽는 프로그램이 그 의미를 정한다. Ctrl-C처럼 커널이 신호로 바꾸는 입력은 아래에서 따로 살펴본다.

TTY는 공통 인터페이스이고, PTY는 그 인터페이스를 구현하는 한 방식이다

TTY라는 이름은 텔레타이프에서 왔다. 그러나 현재 Linux에서 TTY는 특정한 물리 장치라기보다, 프로그램이 터미널을 다룰 때 사용하는 공통 인터페이스다. 이 인터페이스에는 바이트 입출력만 있는 것이 아니다. 입력을 줄 단위로 모을지, 입력 문자를 echo할지, Ctrl-C를 신호로 바꿀지, 어느 프로세스 그룹이 터미널을 읽을 수 있는지가 함께 정의된다. 이 글에서는 이 동작 묶음을 TTY semantics라고 부르겠다.

PTY는 이 TTY semantics를 사용자 공간 프로그램 사이에 제공하도록 커널이 만든 한 쌍의 가상 장치다. 한쪽을 master, 다른 쪽을 slave라고 부른다. 터미널 에뮬레이터, sshd, tmux 같은 프로그램이 master 쪽 파일 디스크립터를 연다. 보통 별도의 리다이렉션이 없다면 Bash나 Vim의 표준 입력·출력·오류(stdin, stdout, stderr)는 slave 장치인 /dev/pts/N에 연결된다. [5]

직렬 터미널이나 Linux 가상 콘솔과 마찬가지로, PTY의 slave는 Bash나 Vim에 TTY 인터페이스를 제공한다. 여기서 비교하는 대상은 master가 아니라 다른 종류의 터미널 장치다. 물리적인 단말이 없어도 프로그램은 slave를 통해 터미널 입력을 읽고 출력을 쓰며, termios로 입력 모드나 echo 같은 속성을 조회하고 바꿀 수 있다. 즉, PTY는 TTY와 경쟁하는 별개의 인터페이스가 아니라, 프로그램에 TTY 동작을 제공하는 구현 방식 중 하나다. [5] [6]

master와 slave는 그 연결의 서로 다른 역할을 가리킨다. master는 터미널 호스트가 입력을 넣고 프로그램 출력을 읽는 쪽이고, slave는 Bash나 Vim이 자신의 터미널로 사용하는 쪽이다. master에 쓴 바이트는 slave 쪽 입력이 되고, slave에 쓴 바이트는 master에서 읽을 수 있는 출력이 된다. 다만 이것을 “master는 TTY가 아니다”라는 뜻으로 받아들이면 안 된다. Linux에서는 master도 TTY 계층에서 다룬다. 여기서 중요한 차이는 TTY 계층에 속하는지 여부가 아니라, 두 끝점의 용도와 입출력 방향이다. [5] [13]

데스크톱에서 보통 Ctrl+Alt+F3을 누르면 GUI 창이 아니라 Linux 가상 콘솔로 전환된다. 어느 F키가 가상 콘솔이나 그래픽 세션에 배정되는지는 배포판과 설정에 따라 다를 수 있다. 그 화면의 /dev/tty3과 GUI 터미널 창의 /dev/pts/7은 사용자에게 비슷한 Bash 프롬프트를 보여 주지만, 장치의 반대편에 연결된 구현은 다르다.

full

/dev/tty3과 /dev/pts/7은 모두 프로그램에 TTY semantics를 제공한다. 달라지는 것은 장치의 반대편이다. 전자는 커널의 가상 콘솔에 연결된다. 후자는 PTY master를 소유한 사용자 공간 프로그램에 연결된다. [9]

방향을 헷갈리기 쉽다. Bash / Vim → PTY slave → PTY master → 터미널 에뮬레이터 → 픽셀은 출력 경로다. 입력은 반대로 흐른다. GUI 터미널에서는 터미널 에뮬레이터가 키 이벤트를 받은 뒤 바이트열로 인코딩해 PTY master에 쓴다. 커널은 터미널 설정에 따라 그 입력을 처리하고, 일반 입력으로 남은 바이트를 slave에서 읽을 수 있도록 한다. 특수 문자 중 일부는 바이트 대신 신호를 발생시킨다.

직접 확인해 보기

가상 콘솔에서는 tty가 대개 /dev/tty3처럼 나오고 $TERM은 linux다. GUI 터미널에서는 /dev/pts/7 같은 장치 경로와 xterm-256color 같은 $TERM 값을 볼 수 있다. tty가 보여 주는 것은 현재 연결된 커널 장치다. $TERM이 나타내는 것은 프로그램이 그 터미널에서 사용할 수 있다고 기대하는 기능 집합이다.

Readline은 shell 문법을 해석하는 파서가 아니라, 입력 중인 한 줄을 관리하는 편집기다

프롬프트에서 git status를 입력할 때 Bash가 g, i, t를 받을 때마다 shell 문법을 구문 분석하는 것은 아니다. Bash 쪽에서 보면, 프롬프트를 기다리는 동안 PTY slave로 들어온 바이트를 Bash의 shell 파서보다 먼저 받아들이는 주체가 보통 GNU Readline이다. Readline은 대화형 Bash가 불러 쓰는 줄 편집기(line editor)다. 현재 입력 중인 한 줄을 내부 버퍼에 보관하고, 화면의 커서와 함께 관리한다.

평범한 문자는 입력 버퍼에 넣는다. Backspace가 오면 버퍼의 문자를 지운 뒤 화면을 다시 그린다. 방향키의 바이트열이 오면 문자를 추가하지 않고 커서 위치를 바꾼다. Ctrl-R은 히스토리 검색을 시작하고 Tab은 자동 완성을 요청한다. 이 단계에서 Readline이 해석하는 것은 shell 문법이 아니라 줄 편집 명령이다. [10]

처음 접한다면 Readline을 **“Bash 파서 앞에 놓인 한 줄 편집기”**라고 이해하면 된다. 터미널에서 들어온 바이트를 사용자가 고칠 수 있는 한 줄의 문자열로 정리한다. 문법보다 먼저 동작하지만, shell 문법을 분석하지는 않는다. 그래서 Readline을 “첫 파서”라고 부르면 Bash 파서와 역할이 뒤섞인다.

full

Enter를 누르면 Readline은 현재 편집한 줄을 Bash에 돌려준다. 그때부터 Bash가 인용 부호, 변수 확장, 리다이렉션, 파이프라인 같은 shell 문법을 구문 분석한다. 문장이 아직 끝나지 않았다면 Bash는 다음 줄을 다시 요청할 수 있다. 외부 명령이 필요하면 프로세스를 시작한다. Bash가 아닌 shell은 다른 줄 편집기를 쓸 수도 있다. 예를 들어 zsh는 ZLE를 사용한다. 따라서 Readline은 “터미널 자체의 기능”이 아니라, 대화형 Bash가 프롬프트를 구현할 때 흔히 사용하는 사용자 공간 라이브러리다.

구성요소 맡는 일 맡지 않는 일
터미널 에뮬레이터 데스크톱의 키 이벤트를 바이트열로 바꾸고, 프로그램 출력을 해석해 화면에 렌더링한다. shell 문법이나 키의 최종 의미를 판단하지 않는다.
PTY master를 소유한 터미널 호스트와 slave에 연결된 전경 프로그램 사이에 가상 터미널 연결을 만든다. 글자를 픽셀로 그리거나 shell 문법을 해석하지 않는다.
커널 TTY 계층 입력 버퍼와 모드, echo, 특수 문자 처리를 제공하고, 전경/백그라운드 읽기 규칙을 적용하며 터미널 신호를 생성한다. shell 문법이나 Vim의 편집 명령을 해석하지 않는다. 신호의 기본 동작 실행은 커널의 신호 처리 기능이 맡는다.
Bash Readline이 넘긴 입력을 shell 문법으로 구문 분석하고 프로세스, 파이프라인, job을 관리한다. 데스크톱의 키 이벤트를 직접 받거나 명령줄 커서를 그리지 않는다.
Readline Bash 프롬프트에서 입력 버퍼, 커서, 히스토리와 자동 완성을 관리한다. shell 문법을 구문 분석하거나 완성된 명령을 실행하지 않는다.
터미널 구성요소의 역할

Ctrl-C 하나에도 운영체제가 끼어 있다

이 층들이 실제로 무엇을 하는지 알려면 키 하나를 따라가 보는 것이 좋다. GUI 데스크톱에서는 물리 키보드의 이벤트가 Wayland나 X11을 거쳐 터미널 에뮬레이터에 도착한다. 터미널 에뮬레이터는 이 키 이벤트를 터미널에서 통용되는 바이트열로 인코딩해 PTY master에 쓴다.

평범한 문자 a라면 대개 0x61이라는 바이트가 간다. 왼쪽 방향키는 사정이 다르다. ASCII에는 “왼쪽 방향키”를 뜻하는 단일 코드가 없다. Unicode에는 왼쪽 화살표 글자 ←가 있지만, 그 글자를 입력하는 것과 물리적인 왼쪽 방향키를 누르는 것은 다른 사건이다. 터미널은 방향키를 보통 ESC [ D 같은 여러 바이트의 escape sequence로 표현한다.

여기서 터미널 에뮬레이터가 “Bash 프롬프트니까 Readline으로 보내자”, “지금은 Vim이니까 Vim으로 보내자”고 판단하는 것은 아니다. 터미널 에뮬레이터가 PTY master에 쓴 바이트는 커널의 TTY 계층에서 처리된다. 그중 일반 입력으로 남은 바이트는 slave 쪽 입력 버퍼에 놓이고, 프로그램이 read()로 읽어 간다. Bash가 전경에서 프롬프트를 기다리는 동안에는 Bash가 사용하는 Readline이 이 바이트열을 줄 편집 명령으로 해석한다. Vim이 전경에서 입력을 읽는 동안에는 Vim이 같은 바이트열을 커서 이동 등의 명령으로 해석한다. [13]

그렇다면 누가 읽을 수 있는지는 어떻게 정할까? 대화형 Bash의 job control에서는 Bash가 tcsetpgrp()로 어느 프로세스 그룹이 터미널의 전경인지 커널에 설정한다. 커널은 이를 바탕으로 읽기 접근을 제어한다. 자신의 제어 터미널을 읽으려는 백그라운드 작업에는 보통 SIGTTIN이 보내지며, 그 기본 동작은 해당 작업을 일시 정지시키는 것이다. 일반 입력을 전경 그룹의 모든 프로세스에 복사해 주는 것은 아니다. 실제로 터미널을 읽는 프로세스가 입력을 소비한다. 반면 아래에서 볼 SIGINT는 전경 그룹 전체를 대상으로 하는 신호다. [14] [15]

따라서 Vim에도 하드웨어 키 이벤트가 “그대로” 전달되는 것은 아니다. 터미널 에뮬레이터는 언제나 데스크톱의 키 이벤트를 터미널 프로토콜의 바이트열로 바꾼다. 다만 Vim 같은 프로그램이 application cursor mode나 확장 키보드 프로토콜을 켜면 같은 방향키를 나타내는 정확한 바이트열이 달라질 수 있다. 인코딩은 터미널 에뮬레이터가 맡고, 최종 의미는 전경 프로그램이 정한다.

커널은 바이트를 배달하는 프로그램인가?

커널(kernel)은 운영체제의 핵심으로, 프로세스 실행, 메모리, 장치 접근 등을 관리한다. Bash와 Vim 같은 사용자 공간 프로그램은 파일을 읽거나 프로세스를 만들 때 **시스템 호출(system call)**로 커널에 작업을 요청한다. 앞에서 본 TTY 계층은 그 커널 안에서 터미널을 담당하는 부분이지, 커널의 역할 전부가 아니다.

TTY 계층 안의 line discipline은 설정에 따라 입력을 가공한다. canonical mode에서는 보통 Enter로 한 줄이 완성될 때까지 입력을 모으고, echo가 켜져 있으면 입력 문자를 출력 쪽으로 되돌려 보낸다. 특수 문자에 대한 신호 생성도 맡는다. 다만 “이 방향키는 Vim의 커서 이동이다” 같은 응용프로그램 고유의 의미까지 해석하지는 않는다. 커널은 터미널의 공통 규칙을 적용하고, Readline이나 Vim은 자신이 읽은 입력의 구체적인 의미를 정한다. [6] [13]

Ctrl-C에서는 커널의 역할이 더 선명해진다. 다음 명령을 실행했다고 하자.

$ sleep 100
^C
$

터미널 에뮬레이터는 Ctrl-C를 보통 0x03으로 바꾸어 보낸다. 커널의 TTY 계층은 신호 생성 설정인 ISIG가 켜져 있고, 이 바이트가 현재 VINTR에 지정된 값과 일치하는지 확인한다. 이 조건에 맞으면 바이트를 평범한 입력으로 넘기지 않고, 현재 터미널의 전경 프로세스 그룹에 SIGINT를 발생시킨다. 전경 작업이 여러 프로세스로 이루어진 경우에도 신호는 그 그룹 전체를 대상으로 한다. Bash의 파이프라인이 한 예다. [5] [6]

full

일반적인 설정의 sleep 100 실행을 그린 것이다. Bash가 키를 보고 sleep을 종료하는 것이 아니다. 커널이 터미널 입력에서 신호를 생성하고, 신호의 기본 동작으로 sleep을 종료한다. Bash는 작업 종료를 확인한 뒤 자신의 프로세스 그룹을 다시 전경으로 설정하고 프롬프트를 표시한다. 다른 프로그램은 신호 처리 방식에 따라 종료하지 않을 수도 있다.

따라서 프로세스를 실제로 종료시키는 것도 커널의 역할이 맞다. 다만 “SIGINT를 보낸다”와 “무조건 즉시 종료한다”는 같은 말이 아니다. SIGINT의 운영체제 기본 동작은 종료이며, 프로세스가 이 기본 처리 방식을 유지하면 커널이 종료를 수행한다. 프로그램은 대신 신호 처리 함수(signal handler)를 등록하거나 이 신호를 무시하도록 설정할 수 있고, 처리를 차단해 전달을 늦출 수도 있다. 이때는 커널이 그 설정에 따라 처리한다. 신호 전달은 표준 입력에서 바이트를 읽는 것과 별개의 경로이므로, sleep처럼 터미널 입력을 읽지 않는 프로그램도 Ctrl-C의 영향을 받는다. [15]

Python이 좋은 예다. 일반적인 Python 실행 환경은 SIGINT를 받으면 KeyboardInterrupt 예외가 발생하도록 자체 처리기를 설치한다. 이 경우 커널이 Python 프로세스를 곧바로 없애는 것이 아니라, Python 런타임이 신호에 반응해 예외를 발생시킨다. 스크립트는 그 예외를 처리해 정리 작업을 하거나 계속 실행할 수 있고, 처리하지 않으면 보통 실행이 끝난다. 신호의 생성·전달과 운영체제의 기본 동작은 커널이, 등록한 처리기 안의 동작은 프로그램이 맡는다. [16]

Bash의 job control은 shell 혼자 만드는 기능이 아니다. 커널의 TTY 계층과 Bash가 역할을 나누어 구현한다. [7]

물론 터미널의 입력 처리 설정 자체를 바꿀 수도 있다. 프로그램이 ISIG를 끄면 커널은 0x03을 SIGINT로 바꾸지 않고 일반 입력 바이트로 남길 수 있다. 줄 단위 입력을 끄는 것(ICANON)과 신호 생성을 끄는 것(ISIG)은 별개의 설정이므로, noncanonical mode라고 해서 반드시 신호 생성까지 꺼지는 것은 아니다. Vim, 게임, debugger처럼 입력을 세밀하게 다루는 프로그램은 필요한 터미널 mode를 직접 설정한다. 그래서 프로그램이 종료할 때 원래 설정으로 되돌리지 못하면 입력 글자가 보이지 않거나 줄바꿈이 이상해지는 현상을 볼 수 있다. [6]


명령의 입출력만 연결해서는 모자란다

AI 에이전트가 명령 하나를 실행하고 결과를 받아오는 일은 어렵지 않다. 새 프로세스를 만들고 표준 입력과 표준 출력을 파이프로 연결하면 된다. 테스트 실행, 파일 검색, 컴파일처럼 입력을 거의 받지 않는 작업에는 이 방법이 오히려 단순하고 믿을 만하다.

하지만 파이프는 터미널이 아니다. 바이트는 옮겨도 터미널의 행과 열, 입력 모드, echo, 전경 프로세스 그룹 같은 상태는 만들지 않는다. 프로그램은 isatty(fd)를 호출해 해당 파일 디스크립터가 터미널에 연결되어 있는지 확인할 수 있다. 표준 입력이 터미널이라고 해서 표준 출력까지 터미널인 것은 아니다. 예를 들어 ls | cat에서 ls의 표준 출력은 파이프다. 프로그램은 이런 차이에 맞춰 출력 형식이나 출력 시점을 달리할 수 있다. [20]

ls는 대화형으로 변하는 것이 아니라, 출력 형식을 고른다

ls가 “색상과 열 배치를 바꾼다”는 것은 Vim처럼 키 입력을 기다리며 화면을 계속 갱신한다는 뜻이 아니다. 출력 대상이 터미널인지에 따라, 이번에 내보낼 목록의 형식을 고른다는 뜻이다. GNU ls는 별도의 형식 옵션이 없을 때 표준 출력이 터미널이면 여러 열로 배치하고, 파이프나 파일이면 기본적으로 한 줄에 항목 하나씩 출력한다. 터미널에서는 사람이 훑어보기 좋은 배치를, 파이프에서는 줄 단위로 받기 쉬운 배치를 택하는 셈이다. [17]

색상에는 조건이 하나 더 있다. GNU ls 자체의 기본 설정은 색상 출력이 꺼져 있으며, --color=auto가 지정되어야 터미널 여부에 따라 색상 코드를 넣거나 생략한다. 평소 ls만 쳐도 색이 보인다면 shell의 alias 등이 이 옵션을 붙이고 있을 수 있다. 아래의 command ls는 Bash에서 같은 이름의 alias나 함수를 우회해 비교하기 위한 표기다. -C, -1, --color=always, --color=never 같은 명시적 옵션으로 자동 선택을 바꿀 수도 있다. [17]

# 일반적인 터미널에서 실행하며, 표시할 항목이 여러 개 있다고 하자.
command ls --color=auto         # stdout이 터미널: 여러 열, 설정에 따른 색상
command ls --color=auto | cat   # stdout이 파이프: 한 줄에 하나, 색상 코드 생략

두 번째 명령의 최종 결과도 사람 눈에는 같은 터미널 창에 나타난다. 하지만 ls가 확인하는 것은 최종적으로 누가 볼 것인지가 아니라, 자신의 표준 출력이 무엇에 연결되어 있는지다. cat이 색을 지우거나 열을 풀어 쓴 것이 아니라, ls가 처음부터 다른 형식으로 출력한 것이다.

Python의 버퍼링은 ‘만든 출력을 언제 내보낼까’의 문제다

Python의 print()가 호출됐다고 해서 그 순간마다 커널에 출력 바이트를 넘기는 것은 아니다. 런타임은 작은 출력을 사용자 공간의 메모리 버퍼에 잠시 모았다가 한꺼번에 쓸 수 있다. 이것이 출력 버퍼링이다. 작은 쓰기를 묶으면 시스템 호출을 반복하는 부담을 줄일 수 있지만, 사람이 기다리는 출력은 늦게 보일 수 있다.

일반적인 Linux의 CPython 3 기본 표준 출력에서는, stdout이 터미널이면 **줄 버퍼링(line buffering)**을 사용한다. 보통 개행이 쓰일 때 버퍼를 비워 출력하므로, 기본 print()로 만든 한 줄은 곧바로 보인다. 반면 stdout이 파이프나 파일이면 **블록 버퍼링(block buffering)**을 사용한다. 개행이 들어왔다고 매번 내보내지는 않고, 버퍼가 차거나 명시적으로 비우거나 정상 종료하면서 정리할 때 모아 둔 출력을 내보낸다. 코드나 실행 옵션이 버퍼링을 바꾸지 않았다는 전제다. [18]

아래 내용을 ticks.py로 저장해 보자. 반복할 때마다 한 줄을 출력하고 1초씩 쉰다.

import time

for i in range(5):
    print(f"step {i}")
    time.sleep(1)
python3 ticks.py         # 터미널: 대체로 한 줄씩 1초 간격으로 보인다.
python3 ticks.py | cat   # 파이프: 약 5초 뒤, 종료할 때 다섯 줄이 모여 보인다.

두 번째 경우에도 Python은 1초마다 print()를 실행한다. 단지 출력이 아직 Python 프로세스 안의 버퍼에 머물러 있을 뿐이다. 이 예제에서는 cat이 일부러 기다리는 것도, 커널이 파이프 입력을 한 줄로 완성하려고 기다리는 것도 아니다. 앞에서 설명한 TTY의 canonical mode는 커널의 입력 처리이고, 여기서의 버퍼링은 Python 런타임의 출력 처리다. 서로 다른 층, 다른 방향의 버퍼다.

파이프에서도 출력을 바로 내보내려면 print(f"step {i}", flush=True)로 해당 출력을 강제로 비우거나, 출력 뒤에 sys.stdout.flush()를 호출할 수 있다. 프로그램 전체의 기본 표준 출력·오류 버퍼링을 끄려면 python3 -u ticks.py | cat로 실행하거나 PYTHONUNBUFFERED=1을 설정한다. 터미널에서도 print(..., end="")처럼 개행 없이 출력하면 바로 나타나지 않을 수 있으므로, 그때도 flush=True가 유용하다. [19]

Python의 ‘대화형’과 버퍼링을 구별할 때 주의할 점

이 설명에서 “터미널에 연결되어 있다”는 것은 >>> 프롬프트가 나오는 Python 대화형 모드(REPL)로 실행 중이라는 뜻이 아니다. python3 ticks.py처럼 입력을 전혀 받지 않는 스크립트도 표준 출력이 터미널이면 줄 버퍼링을 사용할 수 있다. 반대로 사람이 직접 실행한 프로그램이라도 표준 출력을 파일로 돌리면 출력 버퍼링은 달라질 수 있다. [18]

또한 stdout과 stderr를 구분해야 한다. Python 3.9 이후 기본 stderr는 터미널과 파이프·파일 모두에서 줄 버퍼링이다. 따라서 정상 출력과 오류 메시지의 표시 시점이 서로 다를 수 있다. Jupyter나 IDE처럼 표준 스트림을 자체 객체로 바꾸는 환경에는 별도 규칙이 적용될 수 있다. [18]

flush=True나 -u는 Python 쪽 출력 버퍼링을 제어한다. 커널의 파이프 버퍼를 없애거나, 뒤에 연결된 프로그램의 버퍼링까지 끄거나, 화면 렌더링 시점을 보장하는 옵션은 아니다. -u는 표준 입력의 버퍼링에도 영향을 주지 않는다. [19]

에이전트가 실행한 Python 작업의 로그가 한동안 안 보이다가 한꺼번에 나타난다면, 이런 출력 버퍼링이 원인일 수 있다. 로그를 빨리 받으려는 목적만으로 PTY가 꼭 필요한 것은 아니다. 파이프를 유지하면서 자식 프로그램의 flush나 실행 옵션을 조절할 수 있다. 반면 터미널의 입력 모드, 크기 변경, job control까지 필요한 경우에는 아래와 같은 PTY의 차이가 중요해진다.

full

출력 문자열만 필요한 명령이라면 파이프가 더 낫다. 사용자가 직접 조작하는 프로그램이라면 PTY가 필요하다. 둘 중 하나가 언제나 우월한 것이 아니라, 필요한 terminal semantics가 다르다.

Vim은 단순한 줄 목록을 출력하지 않는다. 커서를 10번째 행으로 옮기고, 화면 일부를 지우고, 색상을 바꾸라는 제어 시퀀스를 내보낸다. 이 출력을 문자열로만 저장하면 Vim의 현재 화면을 알 수 없다. 제어 시퀀스를 해석해 커서와 화면 셀의 상태를 유지하는 터미널 에뮬레이션이 필요하다.

AI 통합에서 생기는 첫 번째 경계

에이전트가 명령을 실행하고 결과를 읽는 것과, 에이전트 또는 사용자가 대화형 터미널 프로그램을 직접 조작하는 것은 같은 문제가 아니다.


tmux를 보면 터미널을 겹쳐 놓을 수 있다는 사실이 보인다

하나의 터미널 창 안에 여러 shell을 넣는 일은 오래전부터 가능했다. tmux가 대표적이다. 바깥쪽에서 보면 tmux는 터미널 에뮬레이터 안에서 실행되는 평범한 프로그램이다. 키 입력을 받고 pane 경계와 상태 표시줄을 출력한다.

안쪽의 Bash나 Vim에서 보면 사정이 바뀐다. tmux 서버는 pane마다 PTY를 하나씩 만든다. 각 프로그램은 자신의 PTY slave에 연결된다. tmux 서버는 master 쪽에서 출력 제어 시퀀스를 읽고 pane별 화면 상태를 기억한다. [8]

full

클라이언트를 떼어 내도 서버가 PTY master를 닫지 않으므로 안쪽 프로세스는 계속 실행된다. 나중에 다른 클라이언트가 붙으면 서버가 기억한 화면과 이후 출력을 다시 보여 준다.

tmux는 터미널을 단순한 창으로 보지 않는다. 서버가 소유하는 지속적인 세션으로 본다. 이 점은 사람과 AI가 터미널을 함께 쓰는 문제에도 중요한 힌트를 준다. 눈앞의 창을 공유하는 것보다, 창 뒤에 있는 PTY와 프로세스의 수명을 누가 관리할지가 더 중요할 수 있다.


‘터미널을 공유한다’는 말에는 다섯 가지 뜻이 섞여 있다

여기까지 오면 처음의 질문을 조금 더 정확히 나눌 수 있다. 사람과 AI가 터미널을 공유한다는 말은 적어도 다섯 단계로 해석할 수 있다. 단계가 올라갈수록 필요한 기술보다 조정과 안전의 문제가 더 커진다.

full

같은 PTY에 쓸 수 있다고 해서 함께 일할 수 있는 것은 아니다

PTY master를 가진 프로그램은 입력 바이트를 보낼 수 있다. 기술적으로는 에이전트가 사용자의 shell에 문자를 주입할 수도 있다. 그러나 shell이 지금 프롬프트를 기다리는지, Vim이 raw mode로 입력을 받는지, sudo가 비밀번호를 기다리는지 알지 못한다면 안전하지 않다.

사용자가 명령을 반쯤 입력한 상태일 수도 있다. 에이전트가 그 뒤에 문자를 이어 쓰면 전혀 다른 명령이 만들어진다. 사용자가 Ctrl-C를 누를 때 중단해야 할 대상이 실행 중인 서버인지, 에이전트의 추론인지도 정해야 한다.

화면을 함께 보는 일, 키를 함께 보내는 일, Bash 상태를 함께 쓰는 일, 권한을 나누는 일은 서로 다른 문제다. “터미널 공유”라는 한마디로 묶으면 설계의 핵심이 흐려진다.


결론 : 터미널은 글자가 오가는 창보다 크다

사용자에게는 하나의 창만 보이지만, 그 안에는 서로 다른 층이 있다. 터미널 에뮬레이터는 데스크톱의 키 이벤트를 터미널 바이트열로 인코딩하고, 프로그램의 출력을 픽셀로 바꾼다. PTY는 사용자 공간의 터미널 호스트와 전경 프로그램 사이에 가상 터미널 연결을 만든다. 커널의 TTY 계층은 입력 모드, 특수 문자, 전경 프로세스 그룹과 신호를 관리한다.

Bash 프롬프트에서는 Readline이 현재 입력 중인 한 줄을 편집한다. Enter를 누른 뒤에야 Bash가 완성된 문자열을 shell 문법으로 구문 분석하고 프로세스와 파이프라인을 조직한다. Linux 가상 콘솔의 /dev/tty3과 GUI 터미널의 /dev/pts/7은 연결 대상이 다르지만, 프로그램에는 공통된 TTY semantics를 제공한다.

그래서 “AI와 터미널을 공유한다”는 말은 하나의 기능을 뜻하지 않는다. 화면을 함께 보는 일, 같은 PTY에 접근하는 일, 살아 있는 Bash 상태를 공유하는 일, 작업 맥락을 함께 아는 일, 실행 권한을 나누는 일은 서로 다른 문제다. 무엇을 공유할지 먼저 고르지 않으면, 입력창 하나를 합치는 설계가 전체 문제를 해결해 줄 것처럼 보이기 쉽다.

이 글의 결론은 단순하다. 터미널은 텍스트 창이 아니라, 사람과 프로세스 사이에 놓인 오래된 호환성 인터페이스이자 제어 경계다. Agent CLI가 이 경계 위에서 어떤 구조를 택하는지는 다음 글에서 따로 살펴본다.


참고 자료

[1]

IBM, The IBM punched card. 천공카드의 역사와 데이터 처리에서의 사용.

[2]

IBM, Fortran. FORTRAN의 개발과 1957년 상용 공개.

[3]

University of Queensland Physics Museum, Teletype ASR-33 Model 3320 6WA. Model 33의 ASCII 지원과 초기 미니컴퓨터에서의 사용.

[4]

U.S. National Bureau of Standards, Code for Information Interchange. ASCII 제어 문자와 문자 교환 표준.

[5]

Linux man-pages, pty(7). PTY master/slave와 slave 쪽의 고전적 터미널 인터페이스.

[6]

Linux man-pages, termios(3). canonical/noncanonical mode, 특수 문자와 터미널 속성.

[7]

GNU Bash Reference Manual, Job Control Basics. 프로세스 그룹과 Bash job control.

[8]

OpenBSD Manual Pages, tmux(1). 서버·클라이언트 구조와 pane별 pseudoterminal.

[9]

Linux man-pages, console_codes(4), 그리고 Linux Kernel Documentation, Console Drivers. Linux 가상 콘솔과 터미널 연결 구조.

[10]

GNU Bash Reference Manual, Command Line Editing. Bash가 Readline을 이용해 대화형 명령줄을 편집하는 방식.

[11]

International Telecommunication Union, Recommendation S.1: International Telegraph Alphabet No. 2. 5-unit teleprinter code와 LETTERS/FIGURES shift.

[12]

Unix Third Edition Manual, Unix Programmer's Manual, Third Edition. 초기 Unix terminal 입력에서 erase 문자 #과 kill 문자 @.

[13]

Linux Kernel Documentation, TTY, N_TTY, TTY Driver and TTY Operations. TTY 계층과 line discipline, PTY의 연결 구조, 입력 버퍼 및 읽기 처리.

[14]

Linux man-pages, tcsetpgrp(3), credentials(7). 터미널의 전경 프로세스 그룹 설정과 제어 터미널의 읽기 접근 규칙.

[15]

Linux man-pages, signal(7). 신호의 생성·전달, 기본 동작, 처리기, 무시와 차단. SIGINT의 기본 동작은 종료, SIGTTIN은 일시 정지.

[16]

Python Documentation, signal — General rules. Python의 SIGINT 처리와 KeyboardInterrupt 예외.

[17]

GNU Coreutils, ls(1), General output formatting. 출력 대상에 따른 열 배치, 색상 옵션 및 명시적 형식 지정.

[18]

Python Documentation, sys.stdin / sys.stdout / sys.stderr. 기본 표준 스트림의 줄·블록 버퍼링, Python 3.9 이후 stderr의 동작과 대체 스트림에 대한 주의.

[19]

Python Documentation, print(), -u / PYTHONUNBUFFERED. 출력 flush, 표준 출력·오류의 버퍼링 제어 및 표준 입력에는 적용되지 않는다는 구분.

[20]

Linux man-pages, isatty(3). 지정한 파일 디스크립터가 터미널을 가리키는지 검사하는 함수.

cc
members

최호승

Hoseung CHOI

Research Engineer

github   github