PNG 줄바꿈 손상, CRLF 일괄 정리 스크립트가 그림 46장을 망가뜨린 날

2026.10.16 · 밥

10월 4일 저녁, TEXT WAR 작업 폴더에서 그림 46장이 한꺼번에 안 열리게 됐다. 아바타 착용 그림 견본이랑 기준점 그림, 며칠 전에 찍어 둔 전쟁 화면 캡처들이었다. 지운 사람은 없다. 파일은 그대로 있는데 안 열린다.

범인은 줄바꿈 정리 스크립트. AI 에이전트가 텍스트 파일의 CRLF 를 LF 로 맞추는 스크립트를 돌렸는데, 대상에 git 미추적 파일까지 넣었다. 그 안에 PNG 가 섞여 있었고, 그림 속 바이트 0D 0A 가 0A 로 바뀌면서 PNG 줄바꿈 손상이 났다. 텍스트 파일 27개도 줄바꿈이 바뀌었다. 다행히 46장은 청크 CRC 로 다시 살렸고, 그 뒤로 일괄 변환은 내가 고친 파일 이름을 적은 목록에만 한다.

일부러 똑같이 망가뜨린 그림도 CRC 로 원본 그대로 돌아왔다
일부러 똑같이 망가뜨린 그림도 CRC 로 원본 그대로 돌아왔다

줄바꿈만 바꿨는데 PNG 가 왜 깨질까

윈도우 텍스트 파일은 줄 끝에 CR LF 두 바이트(0D 0A)를 쓰고, 리눅스 쪽은 LF 하나(0A)를 쓴다. 저장소 글 파일을 LF 로 통일하려고 파일 내용에서 0D 0A 를 찾아 0A 로 바꾸는 건 흔한 정리다. LF 로 맞추기로 한 텍스트 파일이라면 필요한 일이고. 그래도 대상 파일은 골라야 한다.

근데 PNG 는? 압축된 바이트 덩어리다. 0D 0A 라는 두 바이트가 아무 자리에나 우연히 나온다. 거기서 0D 하나를 빼면 파일이 한 바이트 짧아지고, 그 뒤 내용이 전부 한 칸씩 당겨진다.

게다가 PNG 맨 앞 8바이트 서명 안에 0D 0A 가 들어 있다. W3C PNG 명세 5.2에 서명이 89 50 4E 47 0D 0A 1A 0A 로 나와 있다. 정리 스크립트가 한 번 지나가면 서명부터 틀린 파일이 되는 거고. 13.1 오류 처리에는 텍스트 모드로 옮기다 값이 13 이나 10 인 바이트가 더해지거나 빠지거나 바뀌는 걸 전송 오류의 예로 들고, 서명 8바이트를 다 검사하라고 적혀 있다. 이번엔 전송이 아니라 줄바꿈 치환이었지만 PNG 바이트가 빠진 건 같은 모양이었다.

git 미추적 파일은 되돌릴 데가 없다

git 에 이전 내용이 남아 있었다면 거기서 되돌리면 됐을 거다. 문제는 망가진 PNG 가 전부 미추적 파일이었다는 거다. 다른 에이전트가 만든 견본 그림이랑 캡처라서 아직 git 이 추적하지 않는 상태였다. git 이 모르는 파일은 git 으로 되돌릴 수 없다.

정리 스크립트 원문은 남아 있지 않다. 그 세션이 남긴 기록에는 미추적 파일 목록을 통째로 대상으로 삼았다는 설명만 있다. 내가 고친 파일만 골라야 했는데 범위를 넓게 잡은 게 실수였다.

견본 그림은 다시 뽑으면 되긴 한다. 시간이 들 뿐. 캡처는 얘기가 다르다. 그날 화면이라 다시 찍을 수가 없다.

청크 CRC 로 PNG 되살리기

다행히 PNG 는 스스로 검사할 거리를 갖고 있다. 5.3 청크 구조를 보면 서명 뒤는 청크가 이어지는데, 청크마다 길이 4바이트, 종류 4바이트, 데이터, 그리고 CRC 4바이트가 붙는다. CRC 는 종류와 데이터로 계산한 값이다.

그러니까 거꾸로 풀 수 있다. 바뀐 게 CRLF 를 LF 로 치환한 것뿐이라면, 빠진 0D 는 지금 남은 0A 앞 어딘가에 있었다. 후보 자리에 0D 를 다시 넣어 보면서 청크 길이와 CRC 가 맞아떨어지는 조합을 찾는 방식이다. 길이 필드나 CRC 자리에서도 0D 가 빠질 수 있어서 그쪽도 후보를 만들어 검사했다. 그날 도구는 청크 데이터에서 다시 넣을 0D 를 3개까지 찾고, 실패한 파일은 5개까지 늘려 다시 돌리게 짜여 있었다.

여기서 쉬운 길로 가면 안 된다. 모든 0A 앞에 0D 를 넣어 버리면 원래부터 0A 하나였던 자리까지 바뀐다. 손상본은 그대로 보관하고 사본에서 후보를 검사해야 한다.

되살린 파일을 제자리에 넣기 전에 확인도 몇 겹으로 했다. 망가진 파일은 따로 백업해 두고, 되살린 파일을 다시 LF 로 바꾸면 망가진 파일과 똑같아야 하고, 그림이 실제로 열려야 하고, 지금 폴더에 있는 파일이 백업한 손상본과 같을 때만 덮어썼다. 그사이 누가 그 파일을 고쳤을 수도 있으니까. 인계 기록에는 46장을 전부 되살려 제자리에 넣었다고 남았다. 다만 원본 사본이 없었으니 원본과 직접 비교한 건 아니다. CRC 와 열림 확인까지가 그날 할 수 있는 검증이었다.

텍스트 파일 27개는 반대로 원래 CRLF 로 되돌렸다. 이것도 바꾸기 전에 백업부터 떴다.

이 PC 에서 PNG 손상을 다시 재현해 봤다

같은 CRLF 치환을 이 PC 에서 재현한 결과도 확인했다. 블로그에 이미 올린 전쟁 결과 그림 한 장을 메모리로 읽어서, 0D 0A 를 0A 로 바꾸고, Node 로 청크 CRC 를 맞춰 되살리는 스크립트다. 원본 파일은 읽기만 했다.

node docs/sources/scripts/png_crlf_demo.mjs images/ep14-war-result-01.png

31,952바이트짜리 그림에 0D 0A 가 3곳 있었다. 1곳은 서명이고 2곳은 그림 데이터 안이다. 정리하고 나니 3바이트 줄어서 31,949바이트. 서명 첫 8바이트가 89504e470d0a1a0a 에서 89504e470a1a0a00 으로 바뀌었다. 블로그 빌드에 쓰는 sharp 로 열어 보니 지원하지 않는 형식이라며 못 열었다.

서명만 고쳐 놓고 청크를 읽어 보면 앞쪽 IHDR, pHYs, 첫 IDAT 는 CRC 가 맞고 두 번째 IDAT 에서 틀린다. 거기부터 청크 경계가 어긋나서 뒤는 읽을 수도 없다.

되살리기는 0D 3개를 다시 넣고 6ms 에 끝났다. 결과를 원본과 바이트 단위로 비교했더니 같았다. 하나도 안 틀리고 돌아온 거다. 여기서 0D 3개와 6개는 서명까지 포함한 파일 전체에 넣은 수라서 위에 쓴 청크 데이터 탐색 상한과는 다른 얘기다.

근데 두 번째로 해 본 110,915바이트 아바타 그림은 0D 6개를 넣는 데 약 160초가 걸렸다. 결과는 역시 원본과 같았다. 큰 청크 안에 넣을 자리가 여러 개면 경우의 수가 확 늘어난다. 그림이 크거나 빠진 바이트가 많으면 이 방법도 오래 걸리고, 답이 여러 개 나와서 하나로 못 고를 수도 있다. 원본에 0D 만 있고 뒤에 0A 가 없던 자리는 애초에 안 바뀌니 상관없다.

일괄 변환은 고친 파일 목록에만

사고 뒤에 규칙을 세 개 정했다.

일괄 변환은 이번에 내가 고친 파일을 이름으로 적은 목록에만 한다. git diff 결과나 미추적 목록을 통째로 넣는 건 금지했고. 그리고 png, jpg, webp, pdf, zip 같은 바이너리는 목록에 있어도 늘 뺀다. 마지막으로 바꾸기 전에 대상 목록을 먼저 출력해서 눈으로 보고 돌린다.

하나 더. 윈도우에서 파이썬 Path.write_text 를 기본 옵션으로 쓰면 문자열 속 LF 가 CRLF 로 바뀐다. 기존 파일의 줄바꿈을 지키려고 바이트로 읽고 바이트로 쓰는 규칙도 넣었다. AI 에이전트한테 일을 맡길 때도 이 규칙을 지침에 넣어 뒀다.

비슷하게 AI 에이전트가 범위를 넓게 잡아서 난 사고는 PID 로 프로세스 끄기 글에도 적었다. 윈도우 줄바꿈이랑 인코딩 설정은 처음 깔 때 점검표에 있다.

다음엔 Claude Code 의 스킬, MCP, 프로젝트 지침이 각각 어디에 들어가는지 정리해 보려고 한다.