예전 윈도우 프로그램이 저장한 CSV나 메모장 파일은 CP949(ANSI)로, 요즘 도구가 만든 파일은 UTF-8로 저장돼 한 폴더 안에 인코딩이 섞이는 일이 흔합니다. 그래서 파일 인코딩 일괄 변환 스크립트를 파이썬 표준 라이브러리만으로 만들어 이 노트북(윈도우 11, 파이썬 3.12)에서 실제로 돌려봤습니다. 먼저 인코딩 자동 감지 라이브러리(charset-normalizer)에 맡겨 봤는데, CP949로 저장한 11바이트짜리 “안녕하세요”를 big5(중국어 번체)로 판정했고, 그대로 변환하면 “寰喟ж撮蹂”라는 엉뚱한 한자가 오류 없이 저장됩니다. 이 글에서는 그 대신 쓴 판정 방법, 미리보기·백업·재검증이 들어간 최종 스크립트, 2,000개 파일을 2.1초에 변환한 결과, 그리고 직접 겪은 실수(백업 폴더까지 변환 대상에 잡힌 일)를 순서대로 정리합니다.
테스트 폴더: 인코딩이 섞인 9개 파일
현실에서 만나는 경우를 흉내 내려고 테스트 폴더를 직접 만들었습니다. 같은 한글 CSV를 UTF-8, UTF-8 BOM, CP949로 각각 저장하고, 윈도우 파워셸 5.1에서 >로 리다이렉트했을 때와 같은 형태인 UTF-16(BOM 포함) 파일, 영문만 있는 파일, 한 파일 안에 UTF-8 줄과 CP949 줄이 섞인 파일, PNG 이미지 하나를 넣었습니다.
| 파일 | 인코딩 | 크기 |
|---|---|---|
| utf8.csv | UTF-8 | 104바이트 |
| utf8_bom.csv | UTF-8 + BOM | 107바이트 |
| cp949.csv | CP949 | 79바이트 |
| cp949_short.txt | CP949 (“안녕하세요”) | 11바이트 |
| utf16_ps.txt | UTF-16 LE + BOM | 110바이트 |
| ascii.txt | 영문만 | 23바이트 |
| sub/memo_cp949.md | CP949 | 64바이트 |
| sub/mixed.txt | UTF-8 줄 + CP949 줄 | 33바이트 |
| sub/image.png | 바이너리 | 272바이트 |
먼저 인코딩을 지정하지 않고 open()으로 하나씩 읽어 봤습니다.
"""인코딩을 지정하지 않고 읽어 보기: python read_default.py samples"""
import os
import sys
root = sys.argv[1]
for dirpath, _, names in os.walk(root):
for name in names:
path = os.path.join(dirpath, name)
try:
with open(path) as f:
text = f.read()
print(f'OK {os.path.relpath(path, root):22} {text[:12]!r}')
except UnicodeDecodeError as e:
print(f'오류 {os.path.relpath(path, root):22} {e}')
$ python read_default.py samples
OK ascii.txt 'hello world\n'
OK cp949.csv '월간 정산 보고서\n이름'
OK cp949_short.txt '안녕하세요\n'
오류 utf16_ps.txt 'cp949' codec can't decode byte 0xff in position 0: illegal multibyte sequence
오류 utf8.csv 'cp949' codec can't decode byte 0xec in position 0: illegal multibyte sequence
오류 utf8_bom.csv 'cp949' codec can't decode byte 0x84 in position 8: illegal multibyte sequence
오류 sub\image.png 'cp949' codec can't decode byte 0x80 in position 144: illegal multibyte sequence
OK sub\memo_cp949.md '# 회의 메모\n\n- 다'
오류 sub\mixed.txt 'cp949' codec can't decode byte 0xab in position 2: illegal multibyte sequence
한국어 윈도우에서 파이썬 3.12의 open() 기본 인코딩은 cp949입니다(locale.getpreferredencoding()로 확인). 그래서 CP949 파일만 읽히고 UTF-8·UTF-16 파일은 첫 바이트부터 UnicodeDecodeError가 납니다. 반대로 맥이나 리눅스, 또는 UTF-8 모드로 켠 파이썬에서는 CP949 파일 쪽이 오류가 납니다. 어느 쪽이든 인코딩이 섞인 폴더를 한 코드로 처리하려면 먼저 하나로 맞추는 게 편합니다.
자동 감지 라이브러리에 맡기면 생기는 일
인코딩을 모를 때 가장 먼저 떠오르는 방법은 자동 감지입니다. 이 노트북에 이미 설치돼 있던 charset-normalizer 3.4.9로 같은 폴더를 감지해 봤습니다(chardet은 설치돼 있지 않아 비교하지 않았습니다).
$ python detect_lib.py samples
ascii.txt ascii
cp949.csv cp949
cp949_short.txt big5
utf16_ps.txt utf_16
utf8.csv utf_8
utf8_bom.csv utf_8
sub\memo_cp949.md cp949
sub\mixed.txt shift_jis_2004
$ python lib_convert_demo.py samples
cp949_short.txt: 감지 big5 -> '寰喟ж撮蹂\n'
cp949로 읽으면 -> '안녕하세요\n'
sub/mixed.txt: 감지 shift_jis_2004 -> '瑫ォ 琦⁉捩 UTF-8\nオムツー チルタコ CP949\n'
긴 파일은 잘 맞혔지만 두 개가 틀렸습니다. 11바이트짜리 CP949 “안녕하세요”는 big5로, UTF-8과 CP949가 섞인 파일은 shift_jis_2004(일본어)로 판정했습니다. 감지 결과대로 디코딩하면 위처럼 전혀 다른 글자가 되는데, 오류가 나지 않기 때문에 변환 스크립트는 “성공”으로 끝나고 파일만 조용히 망가집니다. 또 BOM이 있는 UTF-8도 그냥 utf_8로 나와서 BOM 여부는 따로 확인해야 했습니다.
자동 감지는 수십 개 인코딩 중에서 통계적으로 가장 그럴듯한 것을 고르기 때문에, 글자 수가 적으면 후보끼리 구분할 근거가 부족한 것으로 보입니다. 다루는 파일이 한글 위주라면 후보를 처음부터 UTF-8과 CP949 두 개로 좁히는 편이 훨씬 안전하다고 판단했습니다.
판정 순서: BOM → 바이너리 → UTF-8 → CP949
최종 스크립트의 판정은 다음 순서입니다. 핵심은 decode()를 엄격 모드(errors 지정 없음)로 시도해서 한 바이트라도 안 맞으면 실패로 보는 것입니다.
- 파일 앞에 BOM이 있으면 그걸 믿습니다.
EF BB BF는 UTF-8 BOM,FF FE/FE FF는 UTF-16입니다. - BOM 없이 NUL 바이트(
\x00)가 있으면 텍스트가 아니라고 보고 건너뜁니다. - UTF-8로 엄격 디코딩이 되면 UTF-8입니다(영문만 있으면 ascii로 따로 셉니다). UTF-8은 바이트 규칙이 까다로워서 CP949 한글이 우연히 통과하기는 어렵습니다.
- 그다음 CP949로 엄격 디코딩이 되면 CP949입니다.
- 둘 다 실패하면
unknown으로 표시하고 건드리지 않습니다. 사람이 직접 봐야 하는 파일입니다.
UTF-8을 CP949보다 먼저 검사하는 순서가 중요합니다. 테스트 폴더의 UTF-8 파일은 CP949로 디코딩하면 첫 바이트에서 오류가 났지만, 그렇다고 모든 UTF-8 바이트열이 CP949에서 반드시 실패한다는 보장은 없으니 규칙이 더 엄격한 UTF-8을 먼저 확인하는 편이 안전합니다.
최종 스크립트: to_utf8.py
표준 라이브러리만 씁니다. 기본은 미리보기라서 아무것도 바꾸지 않고 무엇을 바꿀지만 보여줍니다. --apply를 줘야 실제로 변환하며, 원본은 대상 폴더 옆의 폴더_backup에 같은 구조로 복사합니다. 변환한 파일은 다시 읽어 원래 글자와 같은지 확인하고, 다르면 백업에서 되돌립니다.
"""폴더 안 텍스트 파일을 UTF-8로 일괄 변환합니다.
사용법:
python to_utf8.py 폴더 미리보기(아무것도 바꾸지 않음)
python to_utf8.py 폴더 --apply 실제 변환, 원본은 '폴더_backup'에 복사
옵션: --ext .txt .csv (대상 확장자), --bom (BOM 붙여 저장)
"""
import argparse
import os
import shutil
import sys
import time
DEFAULT_EXT = ['.txt', '.csv', '.md', '.py', '.js', '.json', '.html', '.css', '.xml', '.ini', '.log', '.ps1']
SKIP_DIRS = {'.git', 'node_modules', '__pycache__', '.venv', 'venv'}
BOM_EXT = {'.ps1'} # 윈도우 파워셸 5.1은 BOM 없는 UTF-8을 CP949로 읽으므로 BOM 유지
def detect(data):
if data.startswith(b'\xef\xbb\xbf'):
return 'utf-8-sig'
if data.startswith((b'\xff\xfe', b'\xfe\xff')):
return 'utf-16'
if b'\x00' in data:
return 'binary'
try:
data.decode('utf-8')
return 'ascii' if data.isascii() else 'utf-8'
except UnicodeDecodeError:
pass
try:
data.decode('cp949')
return 'cp949'
except UnicodeDecodeError:
return 'unknown'
def main():
ap = argparse.ArgumentParser()
ap.add_argument('root')
ap.add_argument('--apply', action='store_true')
ap.add_argument('--ext', nargs='+', default=DEFAULT_EXT)
ap.add_argument('--bom', action='store_true')
args = ap.parse_args()
root = os.path.abspath(args.root)
backup = root.rstrip('\\/') + '_backup'
exts = {e.lower() for e in args.ext}
count = {}
changed = failed = 0
t = time.perf_counter()
for dirpath, dirnames, names in os.walk(root):
# 이전 실행이 만든 '_backup' 폴더까지 다시 변환하지 않도록 제외
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.endswith('_backup')]
for name in sorted(names):
ext = os.path.splitext(name)[1].lower()
if ext not in exts:
continue
path = os.path.join(dirpath, name)
rel = os.path.relpath(path, root)
with open(path, 'rb') as f:
data = f.read()
enc = detect(data)
count[enc] = count.get(enc, 0) + 1
target = 'utf-8-sig' if (args.bom or ext in BOM_EXT) else 'utf-8'
if enc == 'ascii' or enc == target:
continue
if enc in ('binary', 'unknown'):
print(f'건너뜀 {rel} ({enc}: 직접 확인 필요)')
continue
text = data.decode(enc)
new = text.encode(target)
print(f'{"변환" if args.apply else "변환 예정"} {rel} {enc} -> {target}')
if not args.apply:
continue
dst = os.path.join(backup, rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(path, dst)
with open(path, 'wb') as f:
f.write(new)
with open(path, 'rb') as f: # 다시 읽어 내용이 그대로인지 확인
ok = f.read().decode('utf-8-sig') == text
if ok:
changed += 1
else:
failed += 1
shutil.copy2(dst, path)
print(f' 검증 실패, 원본으로 되돌림: {rel}')
summary = ', '.join(f'{k} {v}' for k, v in sorted(count.items()))
print(f'\n검사 {sum(count.values())}개 ({summary})')
if args.apply:
print(f'변환 {changed}개, 검증 실패 {failed}개, 백업 폴더: {backup}')
print(f'{time.perf_counter() - t:.2f}초')
if __name__ == '__main__':
sys.exit(main())
파일을 텍스트 모드가 아니라 바이트로 읽고 바이트로 씁니다. 텍스트 모드로 쓰면 윈도우에서 줄바꿈이 바뀔 수 있는데, 바이트로 다루면 \r\n이든 \n이든 원래 그대로 남습니다. .ps1을 BOM 유지 대상으로 둔 이유는 아래 주의사항에서 설명합니다.
실행 결과: 미리보기 → 적용 → 검증
$ python to_utf8.py samples
변환 예정 cp949.csv cp949 -> utf-8
변환 예정 cp949_short.txt cp949 -> utf-8
변환 예정 utf16_ps.txt utf-16 -> utf-8
변환 예정 utf8_bom.csv utf-8-sig -> utf-8
변환 예정 sub\memo_cp949.md cp949 -> utf-8
건너뜀 sub\mixed.txt (unknown: 직접 확인 필요)
검사 8개 (ascii 1, cp949 3, unknown 1, utf-16 1, utf-8 1, utf-8-sig 1)
0.00초
이미지는 확장자 목록에 없어 처음부터 검사 대상이 아니고(8개), 섞인 파일은 unknown으로 건너뜁니다. 확인 후 --apply로 실제 변환하고, 원본(백업)과 변환본을 하나씩 비교했습니다.
$ python to_utf8.py samples --apply
(변환 목록은 위와 같음)
변환 5개, 검증 실패 0개, 백업 폴더: ...\encoding\samples_backup
0.01초
$ python check_bytes.py samples samples_backup
cp949.csv 79 -> 104바이트 CRLF 4 -> 4 첫 바이트 ec9b94 내용 같음
cp949_short.txt 11 -> 16바이트 CRLF 0 -> 0 첫 바이트 ec9588 내용 같음
utf16_ps.txt 110 -> 104바이트 CRLF 4 -> 4 첫 바이트 ec9b94 내용 같음
utf8_bom.csv 107 -> 104바이트 CRLF 4 -> 4 첫 바이트 ec9b94 내용 같음
sub\memo_cp949.md 64 -> 86바이트 CRLF 0 -> 0 첫 바이트 2320ed 내용 같음
$ python to_utf8.py samples
건너뜀 sub\mixed.txt (unknown: 직접 확인 필요)
검사 8개 (ascii 1, unknown 1, utf-8 6)
다섯 개 모두 글자 내용이 같고, CRLF 줄바꿈 개수(4개)도 그대로입니다. 첫 바이트가 ec9b94(“월”의 UTF-8)로 바뀌어 BOM 없는 UTF-8이 됐습니다. CP949는 한글 한 글자가 2바이트, UTF-8은 3바이트라 79바이트 파일이 104바이트로 늘었습니다. 한 번 더 돌리면 바꿀 게 없다고 나오니, 실수로 두 번 실행해도 문제가 없습니다.
그런데 변환 직후 처음의 read_default.py를 다시 돌려 보니 이번엔 반대 결과가 나왔습니다.
$ python read_default.py samples (변환 후 다시 실행)
OK ascii.txt 'hello world\n'
오류 cp949.csv 'cp949' codec can't decode byte 0xec in position 0: illegal multibyte sequence
오류 cp949_short.txt 'cp949' codec can't decode byte 0xec in position 0: illegal multibyte sequence
오류 sub\memo_cp949.md 'cp949' codec can't decode byte 0xed in position 2: illegal multibyte sequence
(나머지도 전부 오류)
변환 전에는 잘 읽히던 CP949 파일들이 이제 오류입니다. 파일은 UTF-8이 됐는데 읽는 코드는 여전히 기본값(cp949)으로 읽기 때문입니다. 인코딩을 바꿨다면 그 파일을 읽는 코드에도 open(path, encoding='utf-8')을 꼭 넣어야 합니다. 파일만 바꾸고 끝내면 문제가 다른 곳으로 옮겨갈 뿐입니다.
한 파일에 두 인코딩이 섞였을 때
건너뛴 mixed.txt는 UTF-8로 저장된 파일에 다른 프로그램이 CP949로 한 줄을 덧붙인 경우를 흉내 낸 것입니다. 로그나 여러 사람이 손댄 CSV에서 생길 수 있는 형태입니다. 파일 전체로는 어느 쪽으로도 디코딩되지 않으니, 줄 단위로 UTF-8을 먼저 시도하고 실패한 줄만 CP949로 읽었습니다.
"""한 파일 안에 UTF-8 줄과 CP949 줄이 섞였을 때 줄 단위로 고치기: python fix_mixed.py 파일"""
import shutil
import sys
path = sys.argv[1]
with open(path, 'rb') as f:
lines = f.read().splitlines(keepends=True)
out, fixed = [], 0
for i, line in enumerate(lines, 1):
try:
out.append(line.decode('utf-8'))
except UnicodeDecodeError:
out.append(line.decode('cp949')) # 여기서도 실패하면 오류로 멈추게 둠
fixed += 1
print(f'{i}번째 줄 CP949 -> UTF-8: {out[-1].rstrip()}')
shutil.copy2(path, path + '.bak')
with open(path, 'wb') as f:
f.write(''.join(out).encode('utf-8'))
print(f'{len(lines)}줄 중 {fixed}줄 변환, 원본은 {path}.bak')
$ python fix_mixed.py samples/sub/mixed.txt
2번째 줄 CP949 -> UTF-8: 둘째 줄은 CP949
2줄 중 1줄 변환, 원본은 samples/sub/mixed.txt.bak
$ python to_utf8.py samples --ext .txt
검사 4개 (ascii 1, utf-8 3)
두 번째 줄만 CP949로 판정돼 변환됐고, 다시 검사하니 unknown이 사라졌습니다. 다만 이 방법은 “줄마다 둘 중 하나”라는 가정이 맞을 때만 씁니다. CP949로도 안 읽히는 줄이 있으면 일부러 오류로 멈추게 뒀습니다.
2,000개 파일로 속도와 정확도 확인
한글 CSV 2,000개(약 18MB)를 CP949·UTF-8·UTF-8 BOM으로 번갈아 만들어 돌렸습니다.
$ python make_bulk.py bulk 2000
2000개 파일, 18,235,074바이트
$ python to_utf8.py bulk
검사 2000개 (cp949 667, utf-8 667, utf-8-sig 666)
0.38초
$ python to_utf8.py bulk --apply
검사 2000개 (cp949 667, utf-8 667, utf-8-sig 666)
변환 1333개, 검증 실패 0개, 백업 폴더: ...\encoding\bulk_backup
2.10초
$ python verify_bulk.py bulk bulk_backup
비교 1333개: 내용 같음 1333, 다름 0, 줄바꿈 개수 다름 0
미리보기는 0.38초, 백업·변환·재검증까지 하는 적용은 2.10초였습니다. 변환한 1,333개를 백업과 전부 비교해 내용과 줄바꿈 개수가 하나도 다르지 않은 것을 확인했습니다. 이 정도 규모라면 미리보기를 먼저 돌려 목록을 훑어보는 데 드는 시간이 거의 없습니다.
직접 겪은 실수: 백업 폴더까지 변환 대상에
테스트 폴더를 지우기 전에, 이 블로그 저장소 전체에 미리보기를 돌려 봤습니다. 그랬더니 바로 앞에서 만든 백업 폴더가 목록에 잔뜩 나왔습니다.
$ python to_utf8.py ../../../.. (저장소 루트 전체, 미리보기)
변환 예정 drafts\evidence\run\encoding\bulk_backup\00\data_0000.csv cp949 -> utf-8
변환 예정 drafts\evidence\run\encoding\bulk_backup\00\data_0002.csv utf-8-sig -> utf-8
(bulk_backup 1,333개, samples_backup 5개 …)
변환 예정 logs\publish-2026-08-14.log utf-8-sig -> utf-8
(…)
검사 3673개 (ascii 114, cp949 670, utf-16 1, utf-8 2165, utf-8-sig 723)
0.77초
원본을 보관하려고 만든 bulk_backup과 samples_backup이 상위 폴더 기준으로는 그냥 하위 폴더라서, CP949 원본 670개가 “변환 예정”으로 잡힌 것입니다. 미리보기였으니 망정이지 --apply였다면 백업까지 UTF-8로 바뀌어 되돌릴 원본이 사라졌을 겁니다. 그래서 스크립트에 이름이 _backup으로 끝나는 폴더를 건너뛰는 줄을 넣었고, 위 최종 코드는 그 수정이 반영된 버전입니다.
$ python to_utf8.py ../../../.. (_backup 폴더 제외 후)
변환 예정 logs\publish-2026-08-14.log utf-8-sig -> utf-8
변환 예정 logs\publish-2026-08-15.log utf-8-sig -> utf-8
(… logs\publish-*.log 52개)
검사 2336개 (ascii 114, utf-8 2166, utf-8-sig 56)
0.41초
$ python to_utf8.py ../../../.. --ext .ps1
검사 4개 (utf-8-sig 4)
수정 후 저장소의 텍스트 파일 2,336개 중 CP949는 0개였고, 바꿀 대상은 자동 발행 로그 52개(전부 UTF-8 BOM)뿐이었습니다. 실제 운영 로그라서 미리보기까지만 하고 적용하지는 않았습니다.
주의사항: BOM은 지우면 안 되는 경우가 있다
스크립트는 기본적으로 BOM을 떼고 저장합니다. 대부분의 개발 도구와 파이썬은 BOM 없는 UTF-8을 표준으로 다루고, BOM이 있으면 첫 줄 앞에 보이지 않는 글자(\ufeff)가 붙어 비교나 파싱이 어긋나기 때문입니다. 실제로 전날 글의 로그 검색 스크립트를 만들 때, 이 블로그 로그 파일 맨 앞의 BOM 때문에 첫 줄을 시각 줄로 인식하지 못해 결과가 2바이트 어긋난 적이 있습니다.
반대로 BOM을 지우면 안 되는 파일도 있습니다. 이 블로그 자동화에서 2026-08-14에, BOM 없는 UTF-8로 저장된 .ps1 스크립트를 윈도우 파워셸 5.1이 CP949로 읽어 한글 경로와 프롬프트가 전부 깨졌습니다. 그런데 에러 없이 exit 0으로 끝나 작업 스케줄러에는 “성공”으로 기록됐고, 그날 발행이 통째로 빠졌습니다. 그래서 스크립트는 .ps1은 BOM을 붙인 채로 두고(저장소의 .ps1 4개는 모두 BOM이 있어 그대로 통과), 다른 파일도 BOM이 필요하면 --bom을 주도록 했습니다.
- 반드시 미리보기부터 돌려 목록을 확인하세요. 특히 상위 폴더에서 돌릴 때는 백업·가상환경·node_modules 같은 폴더가 섞이지 않았는지 봅니다.
- unknown은 억지로 바꾸지 마세요.
errors='ignore'나'replace'로 강제 디코딩하면 깨진 글자가 그대로 굳습니다. - 변환 후에는 읽는 코드에
encoding='utf-8'을 넣으세요. 안 그러면 한국어 윈도우에서 반대로 오류가 납니다. - 실행 중인 프로그램이 계속 쓰는 파일(오늘 자 로그 등)은 변환해도 다음 기록부터 다시 원래 인코딩으로 쌓입니다. 쓰는 쪽 설정을 먼저 바꾸는 게 근본 해결입니다.
정리
| 항목 | 실측 결과 |
|---|---|
| 인코딩 미지정 open() | 한국어 윈도우에선 cp949 → UTF-8·UTF-16 파일 오류 |
| 자동 감지(charset-normalizer) | 짧은 CP949를 big5로, 섞인 파일을 shift_jis로 오판 |
| UTF-8 → CP949 엄격 판정 | 검사한 8개 모두 맞게 분류, 섞인 파일은 unknown으로 건너뜀 |
| 2,000개 CSV(18MB) | 미리보기 0.38초, 변환·검증 2.10초, 1,333개 내용 일치 |
| 저장소 전체 미리보기 | 2,336개 중 CP949 0개, BOM 로그 52개, .ps1 4개는 BOM 유지 |
가장 작은 첫걸음은 위 to_utf8.py를 저장하고, 인코딩이 의심되는 폴더에 python to_utf8.py 폴더를 옵션 없이 한 번 돌려 보는 것입니다. 아무것도 바뀌지 않으니 부담 없이 현황부터 볼 수 있고, 목록이 납득되면 그때 --apply를 붙이면 됩니다.
다음 글에서는 웹페이지의 표를 엑셀 파일로 바로 저장하는 방법을 다루겠습니다.