[태그:] Windows 터미널

  • 터미널에 한글이 물음표로 나올 때 — 인코딩 문제 한 번에 잡기

    터미널에 한글이 물음표로 나올 때 — 인코딩 문제 한 번에 잡기

    터미널을 켜고 명령을 하나 실행했는데, 한글이 있어야 할 자리에 물음표만 줄지어 나온다. 처음 보면 글자가 부서졌다고 생각하게 된다. 그런데 물음표가 보일 때 볼 곳은 글자가 아니라 통로다. 그 글자를 담을 수 없는 통로로 글자를 보낸 것이 가능한 원인 중 하나다. 이번 사례에서는 전달 경로를 바꾸자 결과가 달라졌다. 그리고 다 고쳤는지 확인하는 방법은 눈이 아니라 대조다. 층별로 개수를 세는 검사는 뒤에서 자세히 설명하는데, 그것은 경보이지 판정이 아니다.

    이 글은 우리가 여러 대의 에이전트를 돌려 글을 만드는 작업에서 실제로 겪은 세 가지 기록에 기대고 있다. 하나는 한글이 전멸한 경로와 보존된 경로를 같은 자리에서 갈라 본 관측이고, 하나는 원인을 적어 둔 다음에도 같은 결함을 다시 밟은 기록이고, 하나는 글자 하나 때문에 도구가 통째로 멈춘 사고다. 각 항목마다 우리가 직접 측정한 것일반적인 설명을 문장 단위로 갈라 표시했다. 우리 관측은 우리가 본 환경의 사실이고, 모든 컴퓨터에서 똑같이 일어난다는 주장이 아니다.

    먼저 말을 몇 개 풀어 둔다. 인코딩은 글자를 숫자로 바꾸는 약속이다. 컴퓨터는 글자를 모르고 숫자만 다루므로, 어떤 숫자가 어떤 글자인지 약속을 정해 두고 그 약속으로 저장하고 읽는다. UTF-8은 세계의 글자를 폭넓게 담는 약속이고, cp949는 한국어 윈도우에서 오래 쓰인 약속인데 담을 수 있는 글자의 범위가 UTF-8보다 좁다. 파이프는 한 프로그램의 출력을 다른 프로그램의 입력으로 곧바로 잇는 통로를 말한다. 글리프는 화면이나 그림에 실제로 그려진 글자 모양을 말한다.

    같은 한글을 두 경로로 보냈을 때의 이번 관측 결과를 정리한 도식. 명령에 한글을 박아 파이프로 넘긴 경로에서는 머리글의 한글이 전멸했고, UTF-8 파일에 두고 프로그램이 직접 읽은 교정본에서는 한글이 보존됐다. 관측 1회이며 경로 간 우열을 측정한 것이 아니라는 한정을 함께 적었다. 보이지 않는 손상인 제어문자 항목과, 같은 함정을 세 번 밟은 경위, 그리고 층별로 세는 다섯 축을 담았다. 그 다섯 축은 판정이 아니라 경보라는 것, 0이 손상 부재의 증명이 아니라는 것, 문자가 있어도 손상 확정은 아니라는 것, 정상적인 의문부호를 손상으로 센 오탐이 우리 기록에 있다는 것, 판정은 언제나 기대 원문과의 대조로 내린다는 것을 그림 안에 함께 적었다.
    손상된 글자를 그리지 않고 통로를 그렸다. 이번 관측 1회의 결과이고 경로 간 우열을 측정한 것이 아니다. 다섯 축은 전부 경보이고 판정은 대조다 — 2026-09-08 우리 실측.

    물음표가 나온 자리

    1. 한글이 있어야 할 자리에 물음표만 있다

    증상. 명령을 실행했는데 결과에 한글이 보이지 않고 물음표만 나온다. 줄의 구조는 멀쩡하고 한글이 있던 자리만 물음표다. 우리가 본 사례의 모양은 이렇게 된다.

    
    === ??? ????? ===
    > ?? ?? ???
    

    위 블록은 어떤 특정 기록을 그대로 옮긴 것이 아니라 모양을 보인 것이다. 우리가 관측한 사례에서는 한글이 물음표로 바뀐 자리에 한글이 남지 않았고, 그래서 그 줄에 한글이 없었다. 다만 모든 손상이 이 모양이라는 뜻은 아니다. 일부 글자만 바뀌어 한글과 물음표가 섞여 남는 경우가 있을 수 있고, 우리는 그런 부분 손상을 측정하지 않았다.

    왜 그런가 (일반 설명). 글자를 저장하거나 내보낼 때는 어떤 약속으로 담을지 정해야 한다. 그 약속이 담을 수 없는 글자를 만나면 무슨 일이 일어나는지는 오류 처리 방식이 결정한다. 오류 처리 방식에 따라 중단되거나, 생략되거나, 다른 글자로 대체될 수 있고, 물음표 대체는 그중 하나다. 파이썬을 예로 들면 기본값은 중단이어서 대체하지 않고 오류를 내며 멈추는데, 이 글 4번 항목의 사고가 바로 그 경우다. 처리 방식의 종류는 파이썬 공식 문서의 오류 처리 방식(error handlers) 절에 정리돼 있다. 이것은 문서로 확인한 일반 설명이고, 우리가 직접 측정한 것은 아래 2번과 3번의 관측이다.

    여기서 두 가지가 따라온다. 첫째, 물음표는 되돌릴 수 없다. 원래 글자가 무엇이었는지의 정보가 그 자리에서 사라졌기 때문이다. 물음표를 한글로 되돌리는 도구를 찾는 것은 방향이 틀렸고, 원본을 다시 만들어야 한다. 둘째, 볼 곳은 글자가 아니라 통로다. 같은 글자를 어느 통로로 보내느냐에 따라 결과가 갈리는 것을 우리가 관측했다. 다만 우리는 그 통로의 어떤 설정이 원인이었는지를 코드로 짚어 확정하지는 못했으니, 통로 하나로 원인을 단정하지는 않는다.

    확인 방법. 영문과 숫자는 멀쩡한데 한글만 물음표라면 이 축을 의심한다. 좁은 약속도 영문과 숫자는 담을 수 있어서, 문제가 생기는 자리는 그 약속에 없는 글자 쪽이기 때문이다. 이것도 일반적인 설명이고, 의심의 근거이지 확정은 아니다. 이 축인지 가리는 더 나은 방법은 같은 글자를 서로 다른 통로로 보내 결과를 비교하는 것이고, 우리가 실제로 그렇게 갈라 본 기록이 다음 두 항목이다.

    2. 명령에 한글을 직접 박아 넘겼더니 한글이 전멸했다

    이 항목은 우리가 직접 측정한 것이다.

    증상. 프로그램에 한글이 든 문장을 넘겨 처리하게 했는데, 결과물의 한글이 전부 물음표로 나온다.

    우리가 측정한 것. 우리는 PowerShell의 here-string 안에 한글을 적어 두고, 그것을 파이프로 python3 -에 넘겨 처리하게 했다. 그렇게 만든 보고서의 머리글에서 한글이 전부 물음표가 됐다. 넘긴 통로는 명령줄과 표준 입력이었다.

    범위를 밝혀 둔다. 이것은 우리가 관측한 그 세션의 사실이다. PowerShell이 한글을 못 다룬다는 뜻이 아니고, 모든 환경에서 같은 결과가 난다는 주장도 아니다. 우리 환경의 어떤 조합에서 이 결과가 나왔다는 것까지가 우리가 아는 범위다. 원인이 되는 설정을 우리가 코드로 짚어 확인한 것은 아니다.

    초보용으로 옮기면 이렇다. 이 세션에서는 파이프로 전달한 머리글이 손상됐고, UTF-8 파일을 프로그램이 직접 읽은 교정본은 보존됐다. 그래서 우리는 한글을 파일에 두고 프로그램이 UTF-8로 읽게 하는 방식을 쓴다. 어느 경로가 더 잘 깨지는지를 우리가 잰 것은 아니다. 두 경로를 각각 한 번씩 관측했을 뿐이고, 그것으로 순위를 매기면 방금 밝힌 범위 제한을 스스로 깨는 셈이 된다.

    처방. 한글을 명령에 박아 넣지 말고 파일에 둔다. 그리고 프로그램이 그 파일을 UTF-8로 읽게 한다. 우리가 실제로 그렇게 바꿔 결과가 달라진 기록이 다음 항목이다.

    확인 방법. 결과물에서 한글이 사라졌는지 눈으로 훑는 것만으로는 부족하니 물음표의 개수를 함께 센다. 다만 개수는 경보일 뿐이고 그것만으로 손상 여부를 확정할 수 없다. 왜 그런지는 7번 항목에서 다룬다.

    3. 같은 한글을 UTF-8 파일에 두고 읽게 하니 그대로 보존됐다

    이 항목도 우리가 직접 측정한 것이다. 2번과 같은 세션에서 반대쪽 경로를 갈라 본 것이라 두 항목을 나란히 놓고 읽어야 한다.

    우리가 측정한 것. 같은 세션에서, 프로그램이 encoding="utf-8"로 파일을 읽어 만든 본문은 한글이 온전했다. 이어서 머리글도 UTF-8 파일로 만들어 두고 프로그램이 그 파일을 직접 읽어 인자 배열로 전달하게 바꿨더니, 교정본에서 한글이 보존됐다.

    정리하면 같은 세션에서 같은 성격의 글자가 두 통로로 갈라졌고 결과가 반대로 나왔다. 한쪽은 전멸이고 한쪽은 보존이다. 이 대비가 이 글에서 가장 값진 대목이라고 본다. 원인을 확정하지 못한 상태에서도 무엇을 바꾸면 되는지가 나오기 때문이다.

    처방. 한글은 파일에 두고, 프로그램이 그 파일을 UTF-8로 읽게 한다. 파이썬이라면 파일을 열 때 약속을 명시한다.

    
    open(path, encoding="utf-8")
    

    약속을 적지 않으면 프로그램은 그 컴퓨터의 기본 약속을 쓴다. 그 기본값이 무엇인지는 환경에 따라 다르고, 그래서 어제 되던 것이 다른 컴퓨터에서 안 되는 일이 생긴다. 약속을 문장 안에 적어 두면 적어도 그 컴퓨터의 기본값에 좌우되는 부분은 사라진다.

    이 글도 그 규칙으로 만들었다. 위 도식의 문구는 스크립트 안에 한글로 적어 넣지 않았다. 문구를 UTF-8 파일에 따로 두고, 그림을 그리는 프로그램이 그 파일을 UTF-8로 읽어 그렸다. 이 글의 2번과 6번이 바로 그 경로에서 나온 사고이므로, 이 글을 만드는 경로가 같은 사고를 밟으면 안 된다고 봤다.

    확인 방법. 같은 문장을 두 경로로 보내 결과를 비교한다. 한쪽만 깨지면 깨진 쪽이 통로 문제다. 양쪽 다 깨지면 더 앞단인 원본 파일도 함께 확인한다. 두 경로가 공유하는 뒷단에서 같은 원인이 작용했을 수도 있으니 원본 확인은 순서의 문제이지 결론이 아니다. 이 비교가 유용한 이유는 기대하는 원문을 우리가 알고 있기 때문이다. 원문을 아는 상태의 대조라야 판정이 되고, 이 점은 7번 항목에서 다시 다룬다.

    4. 물음표도 없이 도구가 그냥 멈췄다

    이 항목도 우리가 직접 측정한 것이다.

    증상. 물음표가 나오는 것이 아니라 프로그램이 오류를 내고 중단된다. 화면에는 이런 줄이 남는다.

    
    UnicodeEncodeError: cp949 codec can't encode character u2014
    

    우리가 측정한 것. 한국어 윈도우 콘솔에서 우리 진단 도구가 위 오류로 중단됐고, 원인이 된 글자는 줄표(em dash) 한 글자였다. 이 사건은 이 시리즈의 다른 글에서 이미 1차 자료로 다뤘으니 상세한 경위와 처방은 그쪽을 보기 바란다 — Claude Code를 윈도우에 설치한 뒤 처음 실행할 때 나는 오류들.

    초보용으로 옮기면 이렇다. 물음표만 문제가 아니다. 아예 죽는 경우도 있다. 그리고 그때는 프로그램이 고장났다고 읽히기 쉽다. 물음표는 결과물이 나오기는 하니 글자 문제로 보이는데, 중단은 아무 결과도 나오지 않기 때문이다. 둘은 같은 축의 두 얼굴이다. 담을 수 없는 글자를 만났을 때 중단할지, 생략할지, 다른 글자로 대체할지는 그 프로그램의 오류 처리 방식이 정한다.

    확인 방법. 오류 메시지에 codec이나 encode라는 낱말이 있으면 이 축을 먼저 의심한다. 낱말만으로 원인이 정해지는 것은 아니니, 메시지 전문과 어느 단계에서 멈췄는지를 함께 본다. 이 축이 맞다면 프로그램의 기능이 고장난 것이 아니라 글자를 내보내는 자리에서 막힌 것이므로, 볼 곳은 기능이 아니라 내보내는 통로다.

    5. 파일은 정상으로 보이는데 프로그램만 이상하게 동작했다

    이 항목도 우리가 직접 측정한 것이다. 그리고 이 축은 눈으로 알아차리기 어렵다.

    증상. 파일을 열어 눈으로 보면 아무 문제가 없다. 물음표도 없고 깨진 글자도 없다. 그런데 그 파일을 쓰는 프로그램만 이상하게 동작한다.

    제어문자가 무엇인가. 제어문자는 화면에 그려질 모양이 없는 글자다. 줄바꿈이나 탭처럼 자리를 옮기는 데 쓰이는 것도 있고, 옛 장치를 조작하기 위해 만들어진 것도 있다. 모양이 없으니 파일을 눈으로 봐도 있는지 없는지 알 수 없다. 이것이 일반 설명이다.

    우리가 측정한 것. 우리 운영 기록을 쓰는 중에, 문자열이 프로그램의 손을 거치는 과정에서 0x080x01 두 제어문자가 그 파일에 박혔다. 우리 기록에는 그 파일이 21,665바이트에서 21,667바이트로 바뀐 것이 남아 있다. 우리가 그것을 발견해 고쳤다.

    일반적인 문법 사실은 따로 적는다. 문자열 안의 역슬래시로 시작하는 조합을 프로그램이 하나의 제어문자로 바꿔 해석하는 문법이 여럿 있다. 이것은 널리 알려진 일반 사실이고, 위 문단의 관측과는 다른 층의 이야기다. 우리가 코드로 확인한 것은 위 문단의 관측이며, 어떤 문법이 그 변환을 일으켰는지를 우리가 코드로 짚어 확인한 것은 아니다.

    왜 눈으로 잡기 어려운가. 이유는 짧다. 제어문자는 눈으로 알아차리기 어렵다. 물음표는 보이지만 제어문자는 보이지 않는다. 물음표는 화면에 나오는 순간 누구나 이상하다고 느낀다. 제어문자는 파일을 열어 봐도 정상으로 보이므로, 사람은 파일이 아니라 프로그램을 의심하며 엉뚱한 곳을 고치게 된다.

    독자에게도 같은 위험이 있다. 웹 문서에서 복사해 붙여넣기를 하거나, 여러 도구를 거쳐 파일을 만들거나, 터미널에서 특수한 조합을 그대로 옮겨 쓰는 과정에서 모양 없는 글자가 섞여 들어올 수 있다.

    처방. 파일이 정상으로 보이는데 프로그램만 이상하게 동작하면 이 축을 의심하라. 그리고 눈으로 찾지 말고 바이트를 세라. 모양이 없는 글자는 눈으로 못 찾지만, 개수로는 정확히 잡힌다.

    확인 방법. 파일에서 줄바꿈·탭·복귀를 뺀 제어문자의 개수를 센다. 다만 이 축도 다른 축과 같은 성격이다. 예상하지 않은 문자가 있으면 경보이고, 판정은 원문·파일 형식과 대조해서 내린다. 제어문자가 원문에 원래 들어 있을 수도 있기 때문이다. 그리고 0은 손상이 없다는 뜻이 아니라 검사한 범위에서 나오지 않았다는 뜻이다. 우리는 이 사고 뒤에 그 계수를 검사에 정식으로 추가했다. 그 이유는 7번 항목에 적었다.

    6. 원인을 적어 뒀는데 다음 작업에서 같은 결함을 밟았다

    이 항목도 우리가 직접 측정한 것이다. 다만 여기에는 우리가 기록으로 아는 것우리가 지금 눈으로 본 것이 섞여 있어서, 그 둘을 갈라 적는다. 이 글의 주제가 바로 그 구분이기도 하다.

    우리 기록에 남아 있는 것. 앞선 글을 만들 때 쓴 스크립트를 그 다음 글에서 복사해 두 줄만 고쳐 썼다. 그런데 그 고치는 작업이 이 글 2번 항목의 경로를 그대로 지났고, 그 결과 소스 파일의 두 줄에 물음표가 연달아 박혔다. 그 아래로는 아무것도 막지 않았다. 물음표는 로컬 문서로, 원격에 올라간 글로, 그 글에 붙은 이미지의 대체 텍스트와 캡션으로 충실히 전파됐다.

    우리가 지금 눈으로 본 것. 이 글을 쓰면서 그 소스 파일을 다시 열어 그 두 줄을 확인했다. 지금 그 줄에는 물음표가 없다. 이미 고친 뒤의 파일을 보고 있는 것으로 읽는 것이 자연스럽지만, 사고 당시의 파일 상태를 우리가 이 자리에서 눈으로 확인한 것은 아니다. 그러니 위 문단은 기록에 근거한 서술이고, 이 문단이 우리가 지금 확인한 것이다. 둘을 섞어 적으면 확인하지 않은 것을 확인한 것처럼 쓰게 된다.

    축을 가리기 위해 반대쪽도 측정했다. 당시 이 문제를 그림 렌더나 전각 문자 쪽 문제로 보는 귀속이 있었다. 그래서 반대쪽을 쟀다. 같은 건의 이미지는 정상 렌더였다. 한글이 온전했고 문장부호도 공통 기준선에 맞게 그려져 있었다. 그림에 그려진 조각들의 기록도 정상이었고, 다른 글의 대체 텍스트와 캡션도 온전한 한글이었다. 즉 렌더에서 깨진 것은 하나도 없었다. 그 귀속은 이 측정으로 반증됐다. 깨진 자리는 그림을 그리는 단계가 아니라 소스에 글자를 넣는 단계였다.

    여기서 얻은 교훈이 이 글의 핵심 문장이다. 원인을 기록하는 것과 다음 작업이 그것을 안 밟는 것 사이에는 한 단이 더 있다. 그 단을 메우는 것은 경고문이 아니라 기계가 세는 검사다. 사람은 자기가 적어 둔 경고를 읽고도 그 위를 걸어간다. 읽는 것과 막는 것은 다른 일이다.

    같은 함정을 우리는 세 번 밟았다. 우리 기록에 남은 형태를 순서대로 적으면 이렇다. 첫째, 원인을 적어 둔 다음 작업에서 다시 밟았다. 이 항목의 사고다. 둘째, 그 함정을 경고하는 줄을 쓰다가 같은 경로로 5번 항목의 제어문자를 파일에 박았다. 셋째, 그 규칙을 적은 문장 자체에서 다시 밟았다. 이 글의 사실을 정리해 둔 우리 내부 문서에는 대체 문자를 0개로 유지하라는 규칙이 적혀 있는데, 그 규칙을 적은 문장 안에 대체 문자 한 글자가 박혀 있었다. 이 글을 쓰기 위해 그 문서를 기계로 검사하다가 찾아 고쳤다.

    뒤로 갈수록 강한 형태다. 마지막 것은 규칙이 자기 자신에게 적용되지 않았다는 뜻이므로, 경고문은 자기 자신을 지키지 못한다는 것을 이보다 짧게 보일 방법이 없다. 셋 모두 같은 통로에서 나왔다. 글자를 명령이나 소스에 직접 박아 넣는 경로다.

    확인 방법. 경고를 적었다고 해서 그 결함이 막힌 것이 아니다. 막혔는지 확인하려면 산출물을 기계로 세어야 한다. 다만 그 세는 검사에도 한계가 있고, 그 한계를 모르면 이번에는 검사를 경고문처럼 믿게 된다. 그래서 다음 항목은 검사와 그 한계를 함께 적는다.

    7. 층별로 세는 검사는 경보다 — 0은 손상이 없다는 증명이 아니다

    여기서부터가 이 글이 독자에게 주려는 도구다. 우리가 실제로 쓰는 검사이고, 요점은 층을 나눠 각각 센다는 것이다. 한 층만 보면 다른 층에 남은 손상을 놓친다. 그리고 이 절의 후반부는 그 검사가 무엇을 못 잡는지를 적는다. 그 부분이 검사 자체보다 중요하다.

    우리 검사가 실제로 낸 출력은 이렇다. 먼저 네 층이 모두 통과한 경우다.

    
    === 성공기준 4-b: 물음표 0 (네 층 각각) ===
      [PASS] ① 본문(원격 raw) 물음표 0개
      [PASS] ② alt 물음표 0개
      [PASS] ③ caption 물음표 0개
      [PASS] ④ PNG 안에 그려진 글자 물음표 0개
    

    본문만 보고 끝내면 안 되는 이유가 여기 있다. 이미지의 대체 텍스트와 캡션은 본문과 다른 자리에 저장되고, 그림 안에 그려진 글자는 아예 이미지 안에 들어 있다. 6번 항목에서 물음표가 본문과 이미지의 대체 텍스트, 캡션 세 곳으로 나란히 전파된 것을 우리가 기록으로 갖고 있다. 그때 그림에 그려진 글자 자체는 온전했다. 그래서 층을 나눈다.

    다음은 같은 검사의 다른 실행이다. 여기서 1개가 남았고, 그것이 왜 통과인지가 중요하다.

    
    === 물음표 네 층 각각 ===
      ① 본문(원격 raw)      물음표 1개
      ② alt                물음표 0개  [PASS]
      ③ caption            물음표 0개  [PASS]
      ④ PNG 안 그려진 글자   물음표 0개  [PASS] (조각 352개 대조)
      손상 지표: U+FFFD 0개 · 한글 인접 물음표 0개
    

    본문에 남은 물음표 1개는 손상이 아니었다. 우리가 인용한 영문 대화상자 문장 안에 있던 진짜 물음표였다. 영어 의문문의 끝에 물음표가 있는 것은 정상이다. 개수만 세는 검사는 그 정상적인 물음표를 손상으로 보고 실패를 냈다.

    그래서 판정 기준을 총 개수에서 손상 지표 두 개로 바꿨다. 하나는 U+FFFD가 0개인지이고, 다른 하나는 한글에 붙어 있는 물음표가 0개인지다. U+FFFD는 프로그램이 여기 있던 글자를 알아보지 못했다고 표시할 때 쓰는 대체 문자다. 이것도 있으면 경보이지 손상 확정은 아니다 — 인용문이나 의도적인 표기로 원문에 원래 들어 있을 수 있다. 한글 바로 옆의 물음표도 마찬가지로, 한글이 있던 자리가 바뀐 흔적일 수 있다는 신호다. 반대로 영문 문장 끝의 물음표는 한글에 붙어 있지 않으므로 걸리지 않는다.

    여기까지가 우리가 쓰는 검사다. 이제 그 검사가 무엇을 못 잡는지를 적는다.

    이 지표에는 한계가 둘 있다

    첫째, 못 잡는 경우가 있다. 이 글의 1번 항목이 설명한 대로, 한글이 물음표로 바뀌면 그 자리에 한글이 남지 않는다. 그러면 한글에 붙어 있는 물음표도 없어진다. 즉 손상이 심할수록, 한글이 전멸에 가까울수록 이 지표는 오히려 0이 된다. U+FFFD도 마찬가지로 0일 수 있다. 글자가 대체 문자가 아니라 물음표로 바뀌는 경로에서는 U+FFFD가 아예 나오지 않기 때문이다. 두 지표가 모두 0인데 손상일 수 있다 — 한글 전체가 물음표로 바뀌어도 두 지표는 0일 수 있다.

    둘째, 잘못 잡는 경우가 있다. 정상적인 한국어 의문문의 물음표도 한글에 붙어 있다. 우리 기록에 실제로 그 오탐이 있었다. 지난 글의 그림 기록을 다시 검사했을 때 물음표 1개가 걸려 실패로 나왔는데, 실물을 열어 보니 그 그림 제목에 쓰인 한국어 의문문의 정상적인 물음표였다. 틀린 것은 그림이 아니라 우리 계수 규칙이었다.

    정리하면 이 지표는 못 잡기도 하고 잘못 잡기도 한다. 그러니 정직한 사용법은 이렇다.

    층별 계수는 경보다. 걸리면 의심하고 들여다본다. 그러나 안 걸리는 것은 무죄 증명이 아니다. 검사가 조용하다고 해서 손상이 없다는 뜻이 아니라, 이 검사로는 아무것도 못 봤다는 뜻일 뿐이다.

    손상이 없다는 판정은 기대 원문과 각 층의 텍스트를 대조해야만 성립한다. 무엇이 있어야 하는지를 알아야 무엇이 없어졌는지 안다. 그래서 원문을 모르면 판정할 수 없다. 이것이 이 문제의 본질적인 난점이고, 편한 지표로 덮을 수 있는 것이 아니다. 3번 항목의 비교가 판정이 될 수 있었던 이유도 여기에 있다. 우리는 그때 기대하는 원문을 알고 있었다.

    실용적으로 옮기면 이렇게 된다. 중요한 글자를 어딘가로 보낼 때는 보내기 전에 원문을 따로 남겨 두라. 그러면 나중에 대조할 수 있다. 예를 들면 보내기 전에 정상적인 한글 문장을 UTF-8 파일로 저장해 두고, 그 파일을 다시 열어 글자가 그대로인지 확인한 뒤, 보낸 결과와 그 파일을 비교하는 식이다. 이 절차는 우리가 측정해 본 것이 아니라 위 관측에서 나온 권고다. 원문 없이 결과물만 놓고 손상 여부를 확정하려는 시도는, 지표가 아무리 정교해도 한계를 벗어나지 못한다.

    우리가 실제로 쓰는 다섯 축

    위 한계를 알고 쓰는 전제로, 지금 우리가 세는 축은 다섯 개다.

    
    1. 코드 블록 밖 물음표          0
    2. U+FFFD (대체 문자)           0
    3. 한글에 붙은 물음표           0
    4. 도식 3층 (alt/캡션/글리프)    0
    5. 제어문자 (개행/탭/복귀 제외)   0
    

    다섯 축 모두에 같은 규칙이 적용된다. 예상하지 않은 문자가 있으면 경보이고, 원문·파일 형식과 대조해서 판정한다. 그리고 0은 손상이 없다는 뜻이 아니라 검사한 범위에서 나오지 않았다는 뜻이다. 어느 축도 다른 축보다 높은 계급을 갖지 않는다.

    이 가운데 1번은 우리 집필 형식 규칙이지 손상을 가리는 방법이 아니다. 우리는 이 글에 손상 예시를 실어야 하는데 그 예시에 물음표가 들어가므로, 예시를 코드 블록 안에만 두기로 정하고 그 규칙을 지켰는지를 검사한 것이다. 독자의 파일에는 해당하지 않는 규칙이니 판별법으로 가져가지 말기 바란다.

    5번을 넣은 이유는 5번 항목의 사고다. 박힌 0x080x01은 물음표도 아니고 대체 문자도 아니며 한글 옆에 있지도 않았다. 즉 물음표 네 축을 전부 통과했을 것이다. 검사가 조용한데 파일은 손상돼 있는 상태였고, 그것이 축을 하나 늘린 이유다.

    예외 목록은 만들지 않았다. 정상적인 물음표가 걸릴 때 그것을 예외로 등록해 개수를 면제하는 방법이 있다. 그렇게 하면 검사에 구멍이 생기고, 그 구멍이 다음번에 진짜 손상을 통과시킬 수 있다. 우리는 대신 콘텐츠의 형식을 바꿨다. 손상 예시를 코드 블록 안에 두고 그 블록을 영문·기호 전용으로 썼으며, 도식에는 리터럴 물음표를 넣지 않고 통로를 그렸다. 다만 이것은 우리 검사가 조용해지도록 우리 쪽 형식을 맞춘 것이지, 그렇게 해서 손상이 없어졌다는 뜻은 아니다.

    확인 방법. 자기 글이나 산출물을 점검할 때 다음 순서로 본다. 첫째, U+FFFD를 찾는다. 걸리면 의심한다. 둘째, 한글 바로 옆의 물음표를 찾는다. 셋째, 본문 말고 이미지의 대체 텍스트와 캡션도 따로 본다. 넷째, 줄바꿈·탭·복귀를 뺀 제어문자의 개수를 센다. 그리고 다섯째, 가능하면 원문과 대조한다. 앞의 넷은 전부 경보이고, 판정은 다섯째에서 나온다.

    정리 — 확인할 것 다섯 가지

    첫째, 물음표가 보일 때 볼 곳은 글자가 아니라 통로다. 담을 수 없는 통로로 글자를 보낸 것이 가능한 원인 중 하나다. 이번 사례에서는 전달 경로를 바꾸자 결과가 달라졌다. 이미 물음표가 된 글자는 되돌릴 수 없으니 원본을 다시 만든다.

    둘째, 한글을 명령에 직접 박아 넘기는 대신 파일에 두고 프로그램이 UTF-8로 읽게 한다. 우리 관측에서 전멸과 보존을 가른 것이 이 차이였다. 경로 간 우열을 잰 것은 아니지만, 파일을 열 때 약속을 문장 안에 적어 두면 그 컴퓨터의 기본값에 좌우되지 않는다는 것은 분명하다.

    셋째, 물음표가 안 나오고 프로그램이 그냥 멈추는 것도 같은 축일 수 있다. 오류 메시지에 codec이나 encode가 있으면 기능보다 글자를 내보내는 통로를 먼저 본다. 낱말만으로 원인이 확정되지는 않는다.

    넷째, 파일은 정상으로 보이는데 프로그램만 이상하게 동작하면 제어문자를 의심한다. 제어문자는 눈으로 알아차리기 어렵다. 물음표는 보이지만 제어문자는 보이지 않는다. 눈으로 찾지 말고 줄바꿈·탭·복귀를 뺀 개수를 센다. 이 축도 경보이지 판정은 아니다.

    다섯째, 다 고쳤는지는 층을 나눠 세되 그 결과를 무죄 증명으로 읽지 않는다. 본문, 이미지의 대체 텍스트, 캡션, 그림에 그려진 글자를 각각 보고, 걸리면 의심한다. 그리고 손상이 없다고 말하려면 기대하는 원문과 대조한다. 한 줄로 줄이면 이렇다. 세는 것은 경보이고, 판정은 대조다.

    마지막으로 하나만 덧붙인다. 우리는 이 원인을 적어 두고도 같은 함정을 세 번 밟았고, 그중 하나는 그 함정을 경고하는 줄을 쓰다가, 다른 하나는 그 규칙을 적은 문장 자체에서 밟았다. 경고문을 잘 쓰는 것으로는 막히지 않는다. 막히는 것은 기계가 세는 검사를 산출물에 걸어 두는 쪽이다. 그리고 그 검사도 무엇을 못 잡는지 함께 적어 두어야 한다. 그러지 않으면 이번에는 검사가 경고문 자리를 대신 차지한다.

    우리가 잰 것과 일반 설명

    우리가 직접 측정한 것 (2026-09-08 및 그 앞선 작업 기록)

    • PowerShell의 here-string에 한글을 적어 파이프로 python3 -에 넘겼을 때 보고서 머리글의 한글이 전부 물음표가 된 것. 우리가 관측한 그 세션의 사실이며, 원인이 되는 설정을 코드로 짚어 확인한 것은 아니다
    • 같은 세션에서 프로그램이 encoding="utf-8"로 파일을 읽어 만든 본문의 한글이 온전했던 것, 그리고 머리글을 UTF-8 파일로 두고 직접 읽어 인자 배열로 전달한 교정본에서 한글이 보존된 것
    • 한국어 윈도우 콘솔에서 진단 도구가 UnicodeEncodeError: cp949 codec can't encode character u2014로 중단됐고 원인 글자가 줄표 한 글자였던 것. 상세는 이 시리즈의 다른 글에 있다
    • 앞선 글의 스크립트를 복사해 두 줄을 고치는 작업에서 소스 파일의 두 줄에 물음표가 연달아 박히고, 그것이 로컬 문서와 원격 글, 이미지의 대체 텍스트와 캡션으로 전파된 것. 이것은 우리 기록에 근거한 서술이다. 지금 그 파일의 그 두 줄에는 물음표가 없고, 사고 당시의 상태를 이 글을 쓰는 자리에서 눈으로 확인한 것은 아니다
    • 같은 건의 이미지가 정상 렌더였고 그림 조각 기록과 다른 글의 대체 텍스트·캡션도 온전한 한글이었던 것. 그림 렌더나 전각 문자 쪽 귀속은 이 측정으로 반증됐다
    • 우리 운영 기록을 쓰는 중에 0x080x01 두 제어문자가 파일에 박혔고 그 파일이 21,665바이트에서 21,667바이트로 바뀐 것, 그리고 그것을 발견해 고친 것
    • 이 글의 사실을 정리한 우리 내부 문서에서 대체 문자를 0개로 유지하라는 규칙을 적은 문장 안에 대체 문자 한 글자가 박혀 있던 것. 기계 검사로 찾아 고쳤다
    • 물음표 네 층 검사의 실제 출력 두 건, 그리고 본문에 남은 물음표 1개가 인용한 영문 대화상자의 진짜 물음표였던 것
    • 지난 글의 그림 기록을 다시 검사했을 때 걸린 물음표 1개가 그림 제목의 한국어 의문문 물음표였던 것. 틀린 것은 그림이 아니라 계수 규칙이었다

    이 글에서 일반 설명으로만 쓴 것 (우리가 코드로 확인한 것이 아니다)

    • 인코딩·UTF-8·cp949·파이프·글리프·제어문자의 개념 설명
    • 담을 수 없는 글자를 만났을 때 중단·생략·대체 중 무엇이 일어나는지는 오류 처리 방식이 정하고 물음표 대체는 그중 하나라는 것. 처리 방식의 종류는 파이썬 공식 문서의 오류 처리 방식(error handlers) 절에 있다
    • 좁은 약속도 영문·숫자는 담을 수 있어 문제가 생기는 자리는 그 약속에 없는 글자 쪽이라는 일반적인 설명
    • 문자열 안의 역슬래시 조합을 프로그램이 하나의 제어문자로 바꿔 해석하는 문법이 여럿 있다는 일반 사실. 우리 관측에 어떤 문법이 작용했는지를 코드로 짚어 확인한 것은 아니다

    우리가 측정하지 않아 일반화하지 않은 것

    • 경로 간 우열. 우리는 두 경로를 각각 한 번씩 관측했을 뿐이고, 어느 쪽이 더 자주 깨지는지를 재지 않았다
    • 물음표 손상과 중단 사고의 빈도 비교. 어느 쪽이 더 흔한지를 세지 않았다
    • 부분 손상의 모양. 우리가 본 사례는 한글이 남지 않은 형태였고, 일부 글자만 바뀌어 한글과 물음표가 섞이는 경우를 측정하지 않았다
    • 단일 원인. 통로를 바꾸면 결과가 달라진다는 것은 관측했지만, 그 통로의 어떤 설정이 원인인지는 코드로 확정하지 않았다

    이 글에 싣지 않은 것

    인터넷에 널리 돌아다니는 일반 처방이 몇 가지 있다. 콘솔의 코드페이지를 바꾸는 방법 같은 것들이다. 우리가 그 방법들을 직접 실행해 결과를 측정한 기록이 없어서 이 글에는 싣지 않았다. 우리가 실제로 실행해 결과를 본 처방만 위에 적었다.