터미널 프롬프트는 보통 장식처럼 보인다.

$ , % , , → ~ 같은 것들. 개발자의 취향이 살짝 묻어나는 작은 꼬리표. 색을 입히고, 현재 디렉터리를 보여주고, Git 브랜치를 얹으면 기분이 좋아지는 그런 것.

그런데 어느 날 이 작은 장식이 감사 로그의 운명을 결정했다.

사건: 명령은 실행됐는데 기록이 없다

증상은 단순했다.

서버 터미널에서 명령은 잘 실행된다. 화면에도 결과가 나온다. 그런데 Command Audit에는 기록이 없다.

이런 문제는 처음 보면 자꾸 큰 범인을 찾게 된다.

  • 특정 shell을 지원하지 않는 건가?
  • audit 저장 로직이 고장난 건가?
  • WebSocket 세션이 끊긴 건가?
  • 서버 로그가 늦게 들어오는 건가?

하지만 이번 사건의 범인은 훨씬 작았다.

프롬프트 끝 문자열이었다.

Command Audit은 언제 “명령이 끝났다”고 믿을까

터미널은 생각보다 애매한 공간이다.

사용자가 echo hello를 입력했다. 서버는 결과를 보냈다. 그런데 시스템 입장에서는 “이 명령이 끝났다”는 신호가 별도로 오지 않을 수 있다.

그래서 많은 터미널 자동화는 이런 식으로 판단한다.

“출력 끝에 다시 프롬프트가 나타났네? 그럼 명령이 끝난 거구나.”

예를 들면 출력 끝이 이런 모양이면:

hello
$

$ 같은 프롬프트 꼬리가 다시 보였으니 명령 완료로 볼 수 있다.

이번에도 구조는 비슷했다. 기본적으로 $ , # , % , > 같은 흔한 프롬프트 꼬리를 보고 명령 완료를 판단한다. 그리고 custom prompt를 쓰는 환경을 위해 Prompt Separators라는 추가 꼬리표 목록을 둘 수 있다.

여기까지는 꽤 합리적이다.

문제는 이 비교가 “느낌 비교”가 아니라 “바이트 끝이 정확히 같냐”는 비교라는 점이다.

→ ~ → ~는 다르다. 마지막 공백 하나가 다르다.

그리고 화면에는 똑같이 보여도 raw output 뒤에 색상 reset 코드가 붙어 있으면 또 다르다.

실험실 만들기: 프롬프트 네 마리를 잡아보자

이번에는 추측으로 끝내지 않고 작은 실험실을 만들었다.

로컬 SSH target을 띄우고, 프롬프트를 바꿔가며 같은 명령을 실행했다. 그런 다음 shell output과 Command Audit row 생성 여부를 비교했다.

실험은 네 가지였다.

첫째, 기본 zsh 스타일 suffix인 % .

Prompt Separators를 비워도 audit이 생성됐다. 즉 “zsh라서 안 된다”는 설명은 맞지 않았다. % 는 기본 delimiter에 포함되어 있기 때문이다.

둘째, custom prompt → ~ .

Prompt Separators가 비어 있으면 명령 결과는 화면에 나오지만 audit은 생성되지 않았다. 반대로 Prompt Separators에 → ~ 를 정확히 넣으면 audit이 생성됐다.

셋째, 화면상으로는 → ~ 처럼 보이지만 뒤에 ANSI color reset/control sequence가 붙은 prompt.

Prompt Separators에 → ~ 를 넣어도 audit이 생성되지 않았다. 화면은 같아 보여도 raw tail은 다르기 때문이다.

넷째, trailing space가 저장 과정에서 사라지는지 확인했다.

gRPC와 DB read-back을 확인해보니 → ~ 의 마지막 공백은 살아 있었다. 이번 실험에서는 저장/전달 경로가 아니라 matching 방식이 핵심이었다.

가장 작은 공백이 만든 큰 차이

이번 디버깅에서 재미있는 점은, 실패와 성공의 차이가 아주 작았다는 것이다.

→ ~ 를 넣으면 된다.

그런데 → ~는 안 된다.

~만 넣어도 안 될 수 있다.

화면에 → ~ 가 보이더라도 뒤에 보이지 않는 control byte가 붙으면 안 될 수 있다.

이런 종류의 버그는 사람 눈에는 거의 농담처럼 보인다. “아니, 저게 그렇게 중요해?” 싶다.

하지만 컴퓨터는 농담을 모른다. EndsWith(bytes)는 꽤 성실하고, 꽤 무정하다.

이 문제를 고친다면 어디를 봐야 할까

단기적으로는 운영 가이드가 필요하다.

custom prompt를 쓰는 환경에서는 실제 prompt 끝 문자열을 정확히 등록해야 한다. 특히 마지막 공백과 특수 기호를 포함해야 한다.

하지만 제품적으로 더 좋아지려면 다음이 필요하다.

  1. ANSI/OSC/control sequence를 제거하거나 normalize한 뒤 prompt suffix를 비교한다.
  2. 현재 chunk 하나만 보지 말고, 안전하게 누적한 output tail을 기준으로 판단한다.
  3. Prompt Separators UI에서 공백이나 보이지 않는 문자를 사용자가 인지할 수 있게 보여준다.
  4. matching 실패 시 진단 로그를 남긴다. 예를 들어 raw tail bytes, 기본 delimiter, 추가 separator, match result 같은 정보다.
  5. 이번처럼 % , custom suffix, ANSI prompt, trailing space read-back을 회귀 테스트로 유지한다.

고객에게는 어떻게 말해야 할까

이런 이슈에서 가장 위험한 답변은 너무 빨리 단정하는 것이다.

“zsh는 미지원입니다.”

이 말은 간단하지만 틀릴 수 있다. 기본 % suffix는 잘 감지됐다.

~만 등록하세요.”

이것도 위험하다. 실제 prompt가 → ~ 라면 ~는 끝 문자열이 아닐 수 있고, 마지막 공백도 빠져 있다.

더 안전한 답변은 이렇다.

“Command Audit은 명령 출력이 끝났는지 판단하기 위해 prompt suffix를 비교합니다. 기본 delimiter가 아닌 custom prompt를 사용한다면, 실제 prompt 끝 문자열을 trailing space까지 포함해 Prompt Separators에 등록해야 합니다. 등록 후에도 누락된다면 화면에 보이지 않는 ANSI/color/control sequence나 출력 chunking 영향을 추가로 확인해야 합니다.”

조금 길지만, 훨씬 덜 위험하다.

배운 점

터미널 프롬프트는 장식이 아니다.

자동화와 감사 로그의 세계에서는 프롬프트가 프로토콜처럼 행동한다. 사람이 보기엔 예쁜 기호지만, 시스템에겐 “명령이 끝났다”는 신호일 수 있다.

그래서 디버깅할 때는 화면에 보이는 문자열만 보면 부족하다.

  • 화면에 보이는 prompt
  • 실제 raw bytes
  • 마지막 공백
  • ANSI/control sequence
  • 저장된 separator
  • command completion을 판단하는 코드의 기준

이 여섯 가지를 같이 봐야 한다.

작은 공백 하나가 로그를 만들기도 하고, 사라지게도 한다.

프롬프트는 장식이 아니었다. 작고 조용한 프로토콜이었다.