명령은 분명히 실행됐다.

화면에도 결과가 보인다. echo hello를 치면 hello가 나온다. 사용자는 “됐네”라고 생각한다.

그런데 감사 로그에는 없다.

이런 순간 엔지니어의 머릿속에는 여러 범인이 뛰어다닌다.

  • 저장 로직이 고장났나?
  • 세션이 중간에 끊겼나?
  • zsh를 지원하지 않는 건가?
  • 권한 문제인가?
  • 로그 조회 화면이 늦게 반영되나?

하지만 이번 사건의 범인은 아주 작았다.

프롬프트 끝의 %.
그리고 그 뒤에 있거나 없을 수 있는 공백 하나.
더 나아가 눈에 보이지 않는 zsh/ZLE 제어문자였다.

Command Audit은 어떻게 “명령이 끝났다”고 알까

터미널은 생각보다 친절하지 않다.

우리가 ls를 입력하면 서버는 결과를 주지만, 보통 “이제 명령이 끝났습니다”라는 예쁜 JSON 이벤트를 따로 보내주지 않는다.

그래서 터미널 중계 시스템은 자주 이런 방식으로 판단한다.

명령 출력이 나왔다.
그리고 다음 프롬프트가 다시 보였다.
아, 이전 명령이 끝났구나.

예를 들어 이런 화면을 보자.

$ echo hello
hello
$

마지막 $는 그냥 장식처럼 보이지만, 시스템 입장에서는 중요한 신호다.

“사용자가 다시 입력할 수 있는 상태가 됐다.”

즉 프롬프트는 단순한 꾸밈이 아니라, 명령 완료를 추정하는 작은 신호등이다.

그런데 zsh 프롬프트는 조금 장난꾸러기다

고객 환경의 zsh 프롬프트가 이런 형태였다고 해보자.

PROMPT='[%n@%m]%~%#'

처음 보면 외계어 같다.

하지만 zsh에게는 익숙한 약속이다.

  • %n: 사용자 이름
  • %m: 호스트 이름
  • %~: 현재 디렉터리
  • %#: 일반 사용자는 %, root는 #

그래서 실제 화면에는 대략 이렇게 보인다.

[alice@server]~%

여기서 중요한 점은 마지막이다.

끝 문자: %
끝 공백: 없음

반면 많은 시스템은 zsh의 기본 프롬프트를 이런 식으로 기대한다.

% + 공백

%% 는 다르다.

사람 눈에는 “거의 같은데?” 싶지만, 컴퓨터는 냉정하다.

실제 끝:       %
기대하는 끝:   %공백
결과:          다름

그래서 명령은 실행됐고 결과도 보이지만, 감사 로그 시스템은 아직 이렇게 생각할 수 있다.

흠... 다음 프롬프트가 아직 안 온 것 같은데?
명령이 끝났다고 저장하면 안 되겠다.

~를 separator로 넣으면 되지 않을까?

여기서 흔한 착각이 나온다.

프롬프트가 이렇게 보이니까:

[alice@server]~%

“그럼 Prompt Separators에 ~를 넣으면 되겠네?”라고 생각하기 쉽다.

하지만 여기에는 작은 함정이 있다.

시스템이 보는 것은 “포함되어 있느냐”가 아니라 “끝나느냐”일 수 있다.

[alice@server]~%
              ↑ ↑
              ~ 마지막 아님
                % 마지막

~는 중간에 있는 문자다.
마지막 문자는 %다.

그래서 ~를 등록해도 이런 비교가 된다.

이 프롬프트는 ~로 끝나나?  아니오.

이건 마치 주소가 서울시 강남구 테헤란로 123인데, 끝 주소를 물어보는 칸에 강남구만 넣은 것과 비슷하다.
틀린 정보는 아니지만, 끝 주소는 아니다.

더 얄미운 친구: 보이지 않는 제어문자

여기서 한 단계 더 재미있는 일이 생긴다.

프롬프트 끝에 공백을 추가하면 해결될 것처럼 보인다.

PROMPT='[%n@%m]%~%# '

이제 화면에는 이렇게 보인다.

[alice@server]~% 

좋다. % + 공백이다.

그런데 zsh interactive shell에서는 ZLE, 즉 Zsh Line Editor가 prompt 주변에 보이지 않는 터미널 제어문자를 붙일 수 있다.

예를 들면 raw output은 이런 식일 수 있다.

[alice@server]~% \x1b[K\x1b[?1h\x1b=\x1b[?2004h

사람 눈에는 여전히 이렇게 보인다.

[alice@server]~% 

하지만 실제 byte의 끝은 % 가 아니라 \x1b[?2004h 같은 제어 시퀀스다.

비유하면 이렇다.

우리가 편지 봉투에 “끝”이라고 썼는데, 우체국 자동 판독기는 그 뒤에 붙은 투명 스티커까지 읽고 있는 상황이다.

사람은 “끝이라고 쓰여 있잖아”라고 말하지만, 기계는 “아직 끝이 아닌데?”라고 말한다.

그래서 고객 환경에서는 무엇을 확인해야 할까

이런 문제는 말로만 설명하면 계속 헷갈린다.

그래서 좋은 디버깅은 “화면에 보이는 것”과 “실제 shell이 출력하는 것”을 나눠서 확인하는 것이다.

아래 명령들은 고객 환경에서 zsh 프롬프트 문제를 빠르게 확인하기 위한 작은 탐정 도구다.

1단계: 지금 정말 zsh인가?

echo "SHELL=$SHELL"
ps -p $$ -o pid=,comm=,args=
zsh --version 2>/dev/null || echo "zsh command not found"

이 스크립트는 “지금 내가 어떤 shell 안에 있는지” 확인한다.

쉽게 말하면 현장 신분증 검사다.

  • echo "SHELL=$SHELL": 로그인 shell 설정을 보여준다.
  • ps -p $$ ...: 현재 실행 중인 shell process를 보여준다.
  • zsh --version: zsh가 설치되어 있고 어떤 버전인지 보여준다.

왜 필요할까?

사용자는 zsh라고 생각하지만, 실제 QueryPie SAC 접속 세션에서는 bash나 sh가 떠 있을 수 있다. 반대로 로그인 shell은 bash인데 .profile에서 zsh로 바꿔 실행하는 환경도 있다.

그래서 먼저 “진짜 현장에 있는 shell”부터 확인해야 한다.

2단계: PROMPT 원문 보기

printf 'PROMPT=<%s>\n' "$PROMPT"
printf 'PS1=<%s>\n' "$PS1"

이건 레시피를 보는 단계다.

PROMPT는 화면에 보이는 최종 문자열이 아니라 zsh에게 주는 레시피다.

예를 들어:

PROMPT=<[%n@%m]%~%#>

이 값 자체가 화면에 그대로 찍히는 것은 아니다.
zsh가 %n, %m, %~, %#를 해석해서 실제 프롬프트를 만든다.

즉 이 단계에서는 “요리 전 재료표”를 확인한다.

3단계: 실제 렌더링된 프롬프트 보기

printf 'BEGIN<'
print -Pn -- "$PROMPT"
printf '>END\n'

이 명령은 zsh에게 “PROMPT 레시피를 실제 화면 문자열로 만들어줘”라고 요청한다.

BEGIN<>END를 붙이는 이유는 경계를 눈으로 보기 위해서다.

예를 들어 결과가 이렇게 나오면:

BEGIN<[alice@server]~%>END

프롬프트 끝은 %다.
공백이 없다.

반대로 이렇게 나오면:

BEGIN<[alice@server]~% >END

% 뒤에 공백이 있다.

이 차이가 감사 로그 생성 여부를 가를 수 있다.

4단계: 끝 공백을 hex로 확인하기

print -Pn -- "$PROMPT" | od -An -tx1 -c

이건 사람 눈 대신 현미경으로 보는 단계다.

공백은 눈으로 놓치기 쉽다.
그래서 byte 값으로 본다.

  • %는 hex로 25
  • 공백은 hex로 20

공백 없는 prompt는 끝이 대략 이렇게 보인다.

25
 %

공백 있는 prompt는 끝이 이렇게 보인다.

25  20
 %

여기서 20이 있느냐 없느냐가 핵심이다.

이 명령은 특히 유용하다.
채팅이나 문서에 붙여넣으면 trailing space가 사라질 수 있기 때문이다.
사람 눈으로는 보이지 않는 것을 od가 대신 확인해준다.

5단계: ~가 정말 끝 문자인지 확인하기

rendered_prompt="$(print -Pn -- "$PROMPT")"
printf 'rendered_prompt=<%s>\n' "$rendered_prompt"

case "$rendered_prompt" in
  *~) echo 'ends_with_tilde=yes' ;;
  *)  echo 'ends_with_tilde=no' ;;
esac

case "$rendered_prompt" in
  *%) echo 'ends_with_percent=yes' ;;
  *)  echo 'ends_with_percent=no' ;;
esac

case "$rendered_prompt" in
  *"% ") echo 'ends_with_percent_space=yes' ;;
  *)     echo 'ends_with_percent_space=no' ;;
esac

이 스크립트는 프롬프트의 마지막 문자를 퀴즈처럼 물어본다.

고객 케이스라면 보통 이런 결과가 나온다.

ends_with_tilde=no
ends_with_percent=yes
ends_with_percent_space=no

즉 결론은 이렇다.

~는 프롬프트 안에 있지만, 끝 문자는 아니다.
끝 문자는 %다.
그리고 % 뒤에 공백은 없다.

이 결과가 나오면 Prompt Separators에 ~만 넣는 설정은 효과가 없을 가능성이 높다.

6단계: zsh/ZLE 제어문자 가능성 확인

typeset -p zle_bracketed_paste 2>/dev/null || echo "zle_bracketed_paste parameter not found"
bindkey 2>/dev/null | grep -i bracketed || echo "no bracketed paste bindkey found"

이 단계는 “보이지 않는 조연”을 찾는 과정이다.

zsh의 ZLE는 bracketed paste 같은 기능을 위해 터미널 제어 시퀀스를 사용할 수 있다.

예를 들어 이런 값이 보일 수 있다.

zle_bracketed_paste=( $'\C-[[?2004h' $'\C-[[?2004l' )
"^[[200~" bracketed-paste

이 값이 있다고 해서 무조건 문제가 생긴다는 뜻은 아니다.

하지만 “프롬프트 뒤에 보이지 않는 제어문자가 붙을 수 있는 환경”이라는 중요한 힌트가 된다.

공백을 추가했는데도 감사 로그가 여전히 안 남는다면, 이쪽을 의심해야 한다.

7단계: 임시 우회 테스트

export PROMPT='[%n@%m]%~%# '

printf 'BEGIN<'
print -Pn -- "$PROMPT"
printf '>END\n'

print -Pn -- "$PROMPT" | od -An -tx1 -c

이 명령은 현재 세션에서만 prompt 끝에 공백을 추가한다.

그 다음 테스트 명령을 실행한다.

echo PROMPT_SPACE_TEST_$(date +%Y%m%d_%H%M%S)

그리고 확인한다.

  • 화면에는 output이 보이는가?
  • Session Log에는 남는가?
  • Command Audit에도 남는가?

만약 이 상태에서 Command Audit이 남으면, 원인은 “공백 없는 prompt suffix”였을 가능성이 크다.

반대로 여전히 안 남으면, zsh/ZLE control sequence나 raw output matching 문제가 함께 있을 수 있다.

이 스크립트들을 한 번에 실행하려면

고객에게 너무 많은 명령을 하나씩 요청하기 어렵다면 아래처럼 최소 진단 묶음으로 전달할 수 있다.

echo "=== shell ==="
echo "SHELL=$SHELL"
ps -p $$ -o pid=,comm=,args=
zsh --version 2>/dev/null || true

echo
echo "=== prompt template ==="
printf 'PROMPT=<%s>\n' "$PROMPT"
printf 'PS1=<%s>\n' "$PS1"

echo
echo "=== rendered prompt ==="
printf 'BEGIN<'
print -Pn -- "$PROMPT"
printf '>END\n'

echo
echo "=== rendered prompt hex ==="
print -Pn -- "$PROMPT" | od -An -tx1 -c

echo
echo "=== suffix check ==="
rendered_prompt="$(print -Pn -- "$PROMPT")"
case "$rendered_prompt" in *~) echo 'ends_with_tilde=yes' ;; *) echo 'ends_with_tilde=no' ;; esac
case "$rendered_prompt" in *%) echo 'ends_with_percent=yes' ;; *) echo 'ends_with_percent=no' ;; esac
case "$rendered_prompt" in *"% ") echo 'ends_with_percent_space=yes' ;; *) echo 'ends_with_percent_space=no' ;; esac

echo
echo "=== zle hints ==="
typeset -p zle_bracketed_paste 2>/dev/null || true
bindkey 2>/dev/null | grep -i bracketed || true

이 묶음은 세 가지를 빠르게 알려준다.

  1. 실제 shell이 무엇인지
  2. prompt가 무엇으로 끝나는지
  3. zsh/ZLE 제어문자 가능성이 있는지

고객에게는 어떻게 설명하면 좋을까

고객에게 너무 내부 구현처럼 말하면 어렵다.

대신 이렇게 설명하면 좋다.

이번 현상은 zsh 자체가 미지원이라기보다는, Command Audit이 명령 종료 시점을 prompt 끝 문자열로 판단하는 방식과 현재 prompt 형태가 맞지 않아 발생할 수 있는 케이스입니다.

현재 PROMPT가 `[%n@%m]%~%#`이면 실제 화면에서는 `[user@host]~%`처럼 공백 없는 `%`로 끝납니다. 반면 기본 종료 구분자는 `% `처럼 `%` 뒤 공백까지 포함한 형태입니다.

따라서 명령은 정상 실행되고 화면에도 결과가 보이지만, 시스템이 “명령이 끝났다”고 판단하지 못해 Command Audit 저장이 누락될 수 있습니다.

우선 `PROMPT='[%n@%m]%~%# '`처럼 마지막 공백을 추가해 확인 부탁드립니다. 다만 zsh/ZLE 환경에서는 prompt 뒤에 보이지 않는 control sequence가 붙을 수 있어, 공백 추가만으로 해결되지 않는 경우 제품 측 감지 로직 개선이 필요할 수 있습니다.

핵심은 “zsh가 안 된다”가 아니다.

정확히는 이렇다.

프롬프트를 기준으로 명령 종료를 판단하는 구조에서,
실제 prompt suffix와 시스템이 기대하는 suffix가 다르면 감사 로그가 누락될 수 있다.

제품적으로는 어떻게 좋아져야 할까

운영 우회는 prompt 끝 공백 추가다.

하지만 제품이 더 튼튼해지려면 단순히 %를 delimiter에 추가하면 안 된다.

왜냐하면 명령 결과가 이렇게 끝날 수도 있기 때문이다.

echo "progress 100%"

출력:

progress 100%

여기서 %만 보고 “명령이 끝났다”고 판단하면 오탐이 된다.

더 안전한 방향은 다음과 같다.

  1. 기존 $ , # , % , > 같은 빠른 경로는 유지한다.
  2. 최근 output tail을 작게 누적해서 chunk 분리 영향을 줄인다.
  3. 감지 전용으로 ANSI/OSC/control sequence를 제거한다.
  4. 마지막 visible line만 prompt 후보로 본다.
  5. 이전에 관측한 prompt fingerprint와 비교한다.
  6. 100% 같은 일반 출력은 prompt로 오인하지 않도록 테스트한다.

즉 “기호 하나 더 추가”가 아니라 “프롬프트를 프롬프트답게 판단하는 detector”가 필요하다.

배운 점

이번 사건의 교훈은 귀엽지만 강력하다.

프롬프트는 장식이 아니다.

사람에게는 예쁜 꼬리표지만, 시스템에게는 명령 완료를 알리는 신호일 수 있다.

그래서 터미널 관련 문제를 디버깅할 때는 항상 두 세계를 나눠 봐야 한다.

  • 사람이 보는 화면
  • shell이 만든 prompt template
  • 실제 렌더링된 prompt
  • byte 단위 raw output
  • 보이지 않는 control sequence
  • 시스템이 비교하는 suffix 규칙

이 여섯 가지를 분리하면, “명령은 실행됐는데 로그가 없다” 같은 이상한 사건도 꽤 차분하게 풀린다.

작은 % 하나.
공백 하나.
보이지 않는 escape sequence 하나.

터미널 세계에서는 이런 작은 것들이 꽤 큰 사건을 만든다.

그리고 좋은 디버깅은 언제나 이렇게 시작한다.

“화면에 보이는 것 말고, 실제 bytes는 어떻게 생겼지?”