grep이 조용히 거짓말을 했다: 제어문자 한 바이트가 파일을 통째로 숨긴 사건

코드를 고치려고 특정 함수를 grep으로 찾았는데, 분명 있는 함수가 "없다"고 나왔습니다. 에러도, 경고도 없이 그냥 빈 결과. 하마터면 "이 함수는 삭제됐구나" 하고 잘못된 전제 위에서 작업을 이어갈 뻔했습니다. 범인은 소스 파일 안에 숨어 있던 제어문자 단 한 바이트였습니다. 이 글은 검색 도구가 침묵으로 거짓말을 할 때, 그걸 어떻게 알아채고 어떻게 고쳤는지에 대한 기록입니다.

1. 발단: 있는데 없다고 나온다

자바스크립트 유틸리티 파일에서 함수 하나를 수정하려고 이름으로 grep을 걸었습니다. 결과는 빈 화면. 오타를 의심하고 함수명 일부만 다시 검색해도 빈 화면. 그런데 에디터로 파일을 직접 열면 그 함수는 분명히 거기 있었습니다.

검색 도구가 "못 찾았다"가 아니라 "아무것도 없다"고 말하는 상황. 이건 검색어의 문제가 아니라 검색 대상 자체가 시야에서 사라진 신호였습니다.

2. 진단: grep은 당신이 아는 그 grep이 아닐 수 있다

현대의 많은 개발 환경에서 grep은 알게 모르게 더 빠른 대체 도구(ripgrep, ugrep 등)로 라우팅되는 경우가 많습니다. 이런 도구들은 편의를 위해 기본값 몇 개를 바꿔 놓습니다. 대표적인 두 가지가 문제였습니다.

  • 바이너리 파일 자동 무시 (전통 grep의 -I 옵션이 기본 ON): 바이너리로 판정된 파일은 검색에서 통째로 제외됩니다.
  • gitignore 존중: .gitignore에 걸린 파일도 조용히 건너뜁니다.

여기서 핵심은 "바이너리로 판정된"의 기준입니다. 이 도구들은 파일 앞부분에 NUL(0x00) 같은 제어문자가 하나라도 있으면 그 파일 전체를 바이너리로 간주합니다. 텍스트 파일 99.99%가 멀쩡한 소스여도, 어딘가에 제어문자 1바이트가 섞이는 순간 파일은 "바이너리"가 되어 검색에서 사라집니다 — 에러 한 줄 없이.

교차 확인은 간단합니다. 시스템의 진짜 grep을 절대 경로로 직접 부르는 겁니다.

/bin/grep "함수이름" 그파일.js
# → "Binary file 그파일.js matches"  ← 범인 확정

진짜 grep은 매치를 찾긴 했지만 "바이너리 파일이라 내용은 안 보여준다"고 답합니다. 대체 도구는 이 상황에서 아예 파일을 건너뛰어 침묵했던 것이고요.

3. 범인 색출: 제어문자는 어디서 왔나

파일을 바이트 단위로 열어 제어문자의 위치를 찾았습니다.

python3 -c "d=open('그파일.js','rb').read(); \
print([(i,b) for i,b in enumerate(d) if b<9 or 13<b<32][:5])"

범인은 경로 정리용 정규식이었습니다. 파일 이름에서 쓸 수 없는 문자를 걸러내는 흔한 패턴인데, 이런 모양이었어야 합니다.

// 의도한 코드 — 제어문자를 "이스케이프 표기"로 적음
const bad = /[<>:"|?*\x00-\x1f]/g;

그런데 실제 파일에는 \x00이라는 글자 대신, 진짜 NUL 바이트가 저장돼 있었습니다. 정규식이 동작하는 데는 아무 문제가 없었습니다 — NUL 바이트든 \x00 이스케이프든 정규식 엔진에는 같은 의미니까요. 그래서 코드는 완벽히 작동했고, 아무도 이상을 눈치채지 못한 채 그 파일만 검색에서 조용히 빠지고 있었습니다. 편집 과정에서 이스케이프가 실제 바이트로 변환돼 저장된 것이 원인이었습니다.

4. 수리: 바이트를 글자로 되돌린다

고치는 건 단순합니다. 실제 제어문자 바이트를 \x00 같은 이스케이프 문자열로 교체하는 것. 정규식의 의미는 완전히 동일하고, 이제 파일은 순수 텍스트가 되어 검색에 다시 나타납니다.

교체 후 확인:

python3 -c "d=open('그파일.js','rb').read(); \
print('제어문자:', [b for b in d if b<9 or 13<b<32])"
# → 제어문자: []   ← 깨끗

5. 교훈

  • 도구의 침묵을 신뢰하지 마라. "결과 없음"과 "검색이 그 파일을 못 봄"은 완전히 다른 사실인데, 화면에는 똑같이 빈 줄로 보입니다. 있어야 할 게 안 나오면 도구의 정직성부터 의심해야 합니다.
  • 당신의 grep이 진짜 grep인지 확인하라. 많은 환경에서 grep은 별칭이나 함수로 다른 도구를 가리킵니다. 결정적인 순간엔 /bin/grep처럼 절대 경로로 원본을 불러 교차 검증하세요.
  • 제어문자를 코드에 넣을 땐 항상 이스케이프 표기로. 정규식 문자 클래스에 특수 바이트를 넣어야 한다면 반드시 \x00 같은 표기를 쓰고, 진짜 바이트가 들어가지 않았는지 커밋 전에 확인하는 습관을 들이세요. 컴파일도 되고 동작도 하기 때문에 테스트로는 절대 안 걸립니다.
  • 침묵하는 버그가 가장 비쌉니다. 크래시는 스스로를 신고하지만, 이런 버그는 당신이 "삭제됐네"라고 오판하고 엉뚱한 코드를 새로 짜기 시작할 때까지 아무 말도 하지 않습니다.

FAQ

Q. 왜 도구가 바이너리 파일을 그냥 건너뛰나요? 경고라도 주면 안 되나요?

성능 때문입니다. 코드베이스 전체를 훑을 때 이미지·압축파일 같은 진짜 바이너리를 매번 경고하면 출력이 노이즈로 뒤덮입니다. 그래서 조용히 건너뛰는 게 기본값이 됐고, 텍스트 파일이 바이너리로 오판되는 드문 경우에 그 침묵이 함정이 됩니다.

Q. 제어문자가 어쩌다 소스에 들어가나요?

정규식·이스케이프 시퀀스를 다루는 코드에서 편집 도구나 복사·붙여넣기 과정에 이스케이프 표기가 실제 바이트로 해석돼 저장되는 경우가 대표적입니다. 터미널 제어 시퀀스(ANSI 이스케이프)를 다루는 코드도 같은 위험이 있습니다.

Q. 이걸 예방하려면 커밋 전에 뭘 확인해야 하나요?

소스 트리에 탭·개행을 제외한 C0 제어문자가 있는지 스캔하는 검사를 두면 됩니다. 진짜 grep이나 파이썬 한 줄로 제어문자 바이트를 찾아 CI나 커밋 훅에서 걸러내면, 이 부류의 침묵 버그를 사전에 차단할 수 있습니다.