← 뉴스레터

2026-07-12

붙으려다 실패한 뒤, 처음부터 세션을 소유했다

원하는 장면은 단순했다. PowerShell 창에 AI 터미널이 떠 있다. 내가 키보드로 질문하면 그 화면에서 답한다. 밖에서는 텔레그램으로 같은 세션에 질문한다. 돌아오면 PowerShell 화면에 텔레그램 대화까지 이어져 있어야 한다. 기존 리눅스 환경에서는 이미 되던 일이었다. 그런데 Windows에서 codexclaude를 먼저 실행한 뒤 브릿지를 켜자, 둘은 같은 화면에 있으면서도 서로 다른 세션처럼 움직였다.

처음에는 연결 코드가 하나 빠진 줄 알았다. 실행 중인 프로세스를 찾고, 그 프로세스의 입력으로 문장을 넣으면 될 것 같았다. 터미널 창 제목도 알고, 프로세스 ID도 찾을 수 있고, AI가 남기는 대화 파일도 찾을 수 있었다.

하지만 “보인다”와 “입력할 수 있다”는 다른 문제였다.

Windows에서 이미 다른 터미널이 소유한 대화형 프로그램에 나중에 안전하게 붙는 일은, 생각보다 훨씬 어려웠다. 결국 해결책은 붙는 법을 더 잘 찾는 것이 아니었다. 붙으려는 생각을 버리고, 브릿지가 세션을 처음부터 소유하게 만드는 것이었다.

리눅스에서는 왜 쉬웠나

기존 브릿지는 tmux를 이용했다. tmux 안에서 AI 터미널을 띄우면 로컬 키보드도 같은 화면을 보고, 브릿지도 지정된 pane에 문장을 넣을 수 있다. 화면을 캡처하고, 키를 보내고, 세션이 살아 있는지 확인하고, 필요하면 다시 붙는 인터페이스가 이미 있다.

중요한 건 tmux가 단순한 창 꾸미기 도구가 아니라는 점이다. 누가 터미널을 소유하고, 어디로 입력이 들어가고, 어떤 화면을 읽을지 정해주는 중간층이다. 사람과 자동화가 같은 세션을 다룰 수 있는 공식적인 접점이 된다.

Windows PowerShell에서 AI 명령을 직접 실행하면 그 중간층이 없다. Windows Terminal이 화면을 보여주지만, 바깥 프로그램이 나중에 와서 “저 탭의 저 프로세스 입력으로 이 문장을 안전하게 넣어줘”라고 요청하는 범용 인터페이스는 제공하지 않는다.

창은 보인다. 프로세스도 보인다. 하지만 입력 스트림의 소유권은 이미 그 터미널과 프로세스 사이에 묶여 있다.

이 차이를 이해하기 전에는 계속 엉뚱한 곳을 찾게 된다. 프로세스 목록을 뒤지고, 창 핸들을 찾고, 터미널 명령줄 옵션을 읽는다. 화면에 떠 있으니 어딘가 입구가 있을 거라고 믿는다.

입구가 없는 게 아니라, 내가 원하는 종류의 늦은 입구가 없었다.

가짜 해법은 대부분 포커스를 믿었다

가장 쉬운 우회는 창을 앞으로 가져오고 클립보드 붙여넣기 키를 흉내 내는 것이다. 데모에서는 되지만 무인 자동화에는 위험하다. 알림 하나가 포커스를 가져가면 문장이 다른 앱에 입력되고, 창 제목이나 탭 순서가 바뀌면 엉뚱한 대상이 선택될 수 있다.

Windows Terminal 설정을 매번 다시 써서 입력 명령을 만드는 방법도 같은 문제가 있었다. 터미널의 sendInput은 설정에 묶인 동작이지, 실행 중인 특정 탭을 골라 매번 다른 긴 문장을 보내는 범용 원격 입력 API가 아니었다.

프로세스 핸들을 직접 다루는 방식은 권한과 수명주기, 버전 차이까지 떠안아야 했다. 무엇보다 모든 우회가 같은 질문에 답하지 못했다.

“지금 이 문장이 정확히 어느 세션에 들어간다는 걸 어떻게 증명할 것인가?”

늦게 붙지 말고, 태어날 때부터 같이 만들기

Windows에는 ConPTY라는 의사 콘솔 인터페이스가 있다. 프로그램이 가상 콘솔을 만들고, 그 안에서 자식 프로세스를 실행하며, 입력과 출력 파이프를 직접 관리할 수 있게 해준다.

여기서 핵심은 attach가 아니라 create다.

호스트가 ConPTY를 만든다. 그 안에서 AI 터미널을 자식으로 실행한다. 출력은 호스트의 stdout으로 보내 Windows Terminal이 그대로 그린다. 로컬 키보드 입력은 호스트가 받아 ConPTY 입력으로 넣는다. 텔레그램 브릿지의 입력도 인증된 로컬 통신을 통해 같은 호스트에 들어온다.

두 입력은 하나의 순서 큐를 거쳐 같은 AI 프로세스로 간다.

구조를 글로 그리면 이렇다.

로컬 키보드 ───────────┐
                      ├─> 한 개의 입력 큐 ─> ConPTY ─> AI 터미널
텔레그램 브릿지 ───────┘                         │
                                                ├─> Windows Terminal 화면
                                                └─> 대화 기록 파일

이 방식에서는 어느 세션에 붙을지 추측할 필요가 없다. 호스트가 만든 자식이 곧 대상이다. 화면에 보이는 프로세스와 텔레그램이 입력하는 프로세스가 태어날 때부터 같다.

처음부터 소유하면 확인해야 할 질문이 바뀐다.

“어느 기존 세션이 내 것인가?”가 아니라 “내가 만든 자식이 아직 같은 세대인가?”가 된다.

후자가 훨씬 검증하기 쉽다.

키보드와 텔레그램은 한 줄로 서야 했다

같은 프로세스를 소유하는 것만으로 끝나지 않았다. 로컬 키보드와 텔레그램이 동시에 입력할 수 있기 때문이다.

사람이 “로그 확인해줘”를 치는 동안 텔레그램에서 “테스트 돌려줘”가 들어오면, 두 문장의 바이트가 섞여서는 안 된다. 한 메시지의 앞부분, 다른 메시지 전체, 다시 첫 메시지 뒷부분이 이어지면 AI는 전혀 다른 명령을 받는다.

그래서 입력 큐는 문장 전체를 하나의 프레임으로 다뤘다. 어느 출처에서 왔든 프레임 단위로 순번을 받고, writer 스레드 하나가 순서대로 쓴다. 동시 입력 다섯 개를 보내도 순서는 달라질 수 있지만 각 문장의 몸통은 섞이지 않아야 했다.

이건 데이터베이스의 거대한 트랜잭션이 아니다. 작은 원자성이다. 최소한 한 사람이 보낸 한 문장은 쪼개지지 않아야 한다.

멀티라인 붙여넣기도 같은 원칙을 썼다. 터미널은 여러 줄을 평범한 키 입력으로 받으면 첫 줄에서 제출해버릴 수 있다. bracketed paste 시작과 끝 표식을 감싸 “이 구간은 하나의 붙여넣기”라고 알려주고, 그 뒤에 제출 키를 별도 프레임으로 보냈다.

여기서도 실제 결함이 하나 나왔다. 붙여넣기 본문과 Enter를 같은 프레임에 넣으면, 어떤 환경에서는 Enter가 붙여넣기 중간에 삼켜졌다. 화면에는 질문이 적혔지만 제출되지 않았다.

수리는 화려하지 않았다. 붙여넣기 프레임을 먼저 쓰고, 아주 짧은 지연 뒤 제출 프레임을 따로 썼다. 테스트는 두 프레임의 순서와 지연을 고정했다.

자동화에서 “한 번에 보내면 더 원자적일 것”이라는 직감이 항상 맞지는 않는다. 상대 프로토콜이 붙여넣기와 제출을 서로 다른 사건으로 이해한다면, 경계를 살려 보내야 한다.

세션 파일을 가장 최신이라는 이유로 고르지 않았다

AI 터미널은 대화 내용을 파일로 남긴다. 브릿지는 그 파일을 읽어 최종답변을 텔레그램으로 보낸다. 문제는 같은 컴퓨터에 다른 AI 세션이 여러 개 있을 수 있다는 점이다.

가장 최근에 수정된 파일을 고르는 방식은 간단하지만 위험하다. 호스트가 시작되는 순간 다른 터미널에서 누군가 질문을 보내면 그 파일이 더 최신이 된다. 브릿지가 그쪽을 잡으면 입력은 A 세션에 들어가고 답변은 B 세션에서 읽는 엇갈림이 생긴다.

새 구조에서는 호스트가 자식을 시작한 뒤 첫 입력이 들어온 시각을 기록했다. 그 시각 이후 새로 생성된 대화 파일만 후보가 된다. 후보가 하나면 그 파일을 현재 호스트 세대에 묶는다. 둘 이상이면 임의로 최신 것을 고르지 않고 중단한다.

처음에는 “둘 중 더 최신인 걸 고르면 되지 않나”라는 유혹이 있었다. 하지만 최신은 소유권의 증거가 아니다. 단지 마지막으로 움직였다는 뜻이다.

모호할 때 멈추는 쪽을 골랐다. 한 번의 수동 확인 비용보다, 다른 사람의 세션에 입력하거나 다른 세션의 답을 배달하는 비용이 훨씬 크기 때문이다.

세션 바인딩은 편의 기능이 아니라 안전 경계였다.

재시작한 호스트는 같은 호스트가 아니다

프로세스가 죽었다 다시 뜨면 운영체제 경로나 파일 이름이 같을 수 있다. 브릿지가 예전 연결 정보를 들고 있다가 새 호스트에 그대로 붙는다면, 과거 큐에 남은 문장이 새 AI 세션으로 흘러갈 수 있다.

그래서 호스트가 뜰 때마다 새로운 generation 값을 만들었다. 브릿지가 보내는 모든 요청에는 그 세대 값이 들어간다. 호스트가 재시작되면 세대가 바뀌고, 옛 브릿지의 요청은 거부된다.

로컬 통신에도 임의 capability 값을 붙였다. 같은 사용자 컴퓨터 안의 파이프라고 해서 누구나 입력할 수 있게 두지 않았다. 설명 파일은 현재 사용자만 읽을 수 있게 하고, 로그에는 capability나 질문 본문을 남기지 않았다.

세대와 capability는 비슷해 보이지만 역할이 다르다.

  • capability는 “이 요청을 보낼 권한이 있는가”를 확인한다.
  • generation은 “이 요청이 지금 살아 있는 바로 그 호스트를 위한 것인가”를 확인한다.

권한이 맞아도 세대가 오래됐으면 거부한다. 세대가 맞아도 capability가 틀리면 거부한다. 이 둘을 나눠두면 재시작과 무관한 입력 주체를 각각 막을 수 있다.

기능을 줄이고 경계를 더 많이 시험했다

ConPTY의 raw 출력은 현재 화면 자체가 아니어서 승인 버튼이나 /status 같은 화면 파싱은 뒤로 미뤘다. P0는 텍스트 입력, 유니코드 붙여넣기, 최종답변 미러, 잘못된 세션으로 가지 않는 데 집중했다.

동시 입력이 섞이지 않는지, 붙여넣기와 Enter가 분리되는지, 세대나 capability가 틀리면 거부하는지, 오래된 세션을 무시하고 후보가 둘이면 중단하는지 시험했다.

정상 경로가 한 번 맞으면 데모는 된다. 실패 경로가 다른 세션을 건드리지 않아야 제품이 된다.

사용법도 소유권 모델을 따라야 했다

구현이 끝난 뒤에도 한 가지 혼동이 반복됐다. 사용자가 PowerShell을 열고 먼저 codex를 실행한 다음 브릿지를 켰다. 텔레그램 답은 오지만 PowerShell 화면에는 같은 대화가 보이지 않았다.

그 동작은 버그가 아니라 소유권 모델의 결과였다. 직접 실행한 AI는 사용자의 터미널이 소유한 별도 프로세스다. 브릿지 호스트가 만든 자식이 아니다. 새 구조는 임의의 기존 세션에 늦게 붙지 않기로 했기 때문에, 직접 실행한 세션과 동기화되지 않는 게 맞다.

같은 화면을 쓰려면 codex를 직접 실행하는 대신 ConPTY 호스트를 먼저 실행해야 한다. 호스트가 AI를 띄우고, 그 화면에 로컬 키보드와 텔레그램이 함께 들어간다.

아키텍처가 바뀌면 사용 순서도 바뀐다. 코드만 맞고 설명서가 옛 습관을 유도하면 사용자는 정상 동작을 고장으로 경험한다. “기존 세션에 붙지 않는다”는 제한은 내부 구현 메모가 아니라 첫 실행 안내의 첫 문장이 되어야 했다.

배운 것

처음에는 이미 열린 PowerShell AI 세션에 붙고 싶었다. 그게 자연스러운 사용자 경험처럼 보였다. 하지만 안전하게 붙을 수 있는 공식 접점이 없는데도 그 경험을 억지로 만들면, 포커스와 추측과 우연에 기대게 된다.

해결은 더 영리한 attach가 아니었다. 소유권을 시작점으로 옮기는 것이었다.

호스트가 가상 콘솔을 만들고 자식을 띄운다. 키보드와 텔레그램을 한 큐에 세운다. 첫 입력 이후 생긴 정확한 대화 파일만 묶는다. 세대가 바뀌면 옛 요청을 거부한다. 모호하면 최신을 고르지 않고 멈춘다.

이 원리는 터미널 밖에서도 통한다. 나중에 남의 수명주기에 끼어들어 제어하려 하면 상태를 추측해야 한다. 처음부터 프로세스, 작업, 세션의 수명주기를 소유하면 입력과 출력의 관계를 증명할 수 있다.

물론 모든 걸 직접 소유할 필요는 없다. 이미 tmux나 데이터베이스처럼 안전한 공유 접점이 있다면 그걸 쓰면 된다. 중요한 건 접점이 없는 곳에서 “보이니까 붙을 수 있다”고 착각하지 않는 것이다.

우리는 붙는 데 실패한 뒤에야 제대로 시작하는 법을 찾았다.


안전하게 늦게 붙을 수 없다면, 처음부터 같은 수명주기 안에서 시작해야 한다.

— 강대종 / @ssamssae