서버 주소가 바뀌었거나 담당자 이름이 바뀌어서, 폴더 안 텍스트 파일 수백 개에서 같은 단어를 전부 고쳐야 할 때가 있습니다. 하나씩 열어 바꾸면 파일당 30초만 잡아도 500개면 4시간이 넘습니다. 이번 글에서는 파일 내용 일괄 치환을 파이썬으로 처리하되, 저장하기 전에 무엇이 어떻게 바뀌는지 먼저 보여 주는 미리보기까지 넣은 스크립트를 직접 만들어 돌려 봤습니다.
먼저 검색하면 흔히 나오는 3줄짜리 방식으로 500개 파일을 바꿔 봤습니다. 결과는 350개를 바꾼 뒤 UnicodeDecodeError로 멈춤이었습니다. 나머지 150개는 그대로라 폴더가 반만 바뀐 상태로 남았고, 바뀐 350개 중 150개는 내용과 상관없이 모든 줄바꿈이 달라져 있었습니다. 이 문제들을 막은 완성본은 같은 500개를 0.50초에 처리했고, 원본과 바이트 단위로 대조해 500개 모두 기대한 결과와 일치했습니다.
실험 환경과 테스트 파일
Windows 11, Python 3.12.10에서 외부 패키지 없이 실행했습니다. 이 PC의 기본 인코딩(locale.getpreferredencoding())은 cp949입니다. 실제 업무 폴더처럼 섞인 상황을 만들려고 docs 폴더 아래 10개 하위 폴더에 텍스트 파일 500개(총 801,541바이트)를 만들었습니다.
| 파일 종류 | 개수 | 비고 |
|---|---|---|
| UTF-8, 줄바꿈 LF | 230 | 맥·리눅스·git에서 온 파일에 흔함 |
| UTF-8, 줄바꿈 CRLF | 250 | 윈도우 메모장 기본 |
| UTF-8 BOM | 10 | 엑셀·일부 편집기 저장 방식 |
| cp949(EUC-KR 계열) | 10 | 오래된 한글 문서 |
각 파일에는 “서버 주소는 old-api.example.com 입니다.”, “category 값은 그대로 둡니다.”, “cat 명령으로 로그를 봅니다.” 같은 문장이 20~60줄씩 무작위로 들어 있습니다.
흔한 3줄짜리 코드가 남긴 것
for path in Path("docs").rglob("*.txt"):
text = path.read_text(encoding="utf-8")
path.write_text(text.replace("old-api.example.com", "new-api.example.com"), encoding="utf-8")
인코딩도 utf-8로 지정했으니 문제없어 보였지만, 실제로 돌려 보니 이렇게 끝났습니다.
$ python naive.py
...
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xbc in position 0: invalid start byte
(원본과 비교)
바뀐 파일 350 / 그중 원래 LF였던 파일 150
note_001.txt 2238 -> 2296 바이트, CRLF 0 -> 58
- 반만 바뀐 폴더: 파일을 하나씩 읽고 바로 쓰기 때문에, part07의 cp949 파일에서 예외가 나는 순간 앞의 350개는 이미 바뀌어 있었습니다. 다시 돌리려면 어디까지 됐는지부터 따져야 합니다.
- 줄바꿈 변경: 원래 LF였던 150개 파일이 전부 CRLF로 바뀌었습니다. note_001.txt는 58줄이라 정확히 58바이트가 늘었습니다. git으로 관리하는 폴더라면 한 단어 바꿨는데 파일 전체가 변경된 것으로 보입니다.
줄바꿈 문제만 따로 떼어 300줄짜리 LF 파일로 재 보면 이렇습니다. 처음에는 제 측정 코드가 f-string 안에서 \r\n을 글자 그대로 세는 바람에 “CRLF 0 → 0″으로 나왔는데, 크기가 300바이트 늘어난 게 이상해서 다시 고쳐 쟀습니다.
== read_text/write_text(encoding='utf-8') 로 LF 파일 치환
크기 2600 → 2900바이트, CRLF 개수 0 → 300
바뀐 줄 수(줄바꿈까지 비교): 300 / 300
newline='': 크기 2600바이트, CRLF 0개
read_text()는 읽을 때 줄바꿈을 \n으로 통일하고, write_text()는 윈도우에서 \n을 \r\n으로 바꿔 씁니다. 파일을 열 때 newline=""을 주면 이 변환이 일어나지 않아 크기가 2,600바이트 그대로였습니다. 참고로 인코딩을 아예 빼고 read_text()만 쓰면 이 PC에서는 cp949로 읽으려다 UTF-8 파일의 첫 바이트에서 바로 UnicodeDecodeError가 났습니다.
미리보기 포함 완성본
아래가 최종 스크립트입니다. 기본 동작은 미리보기이고, --apply를 붙였을 때만 저장합니다. 위에서 본 문제는 각각 이렇게 막았습니다.
- 인코딩: UTF-8(BOM 포함)로 먼저 읽고, 실패하면 cp949로 읽은 뒤 읽은 인코딩 그대로 저장합니다. 둘 다 실패하면 건너뛰고 목록으로 알려 줍니다.
- 줄바꿈: 쓸 때
newline=""을 줍니다. 읽은 텍스트는read_bytes().decode()라 원래 줄바꿈이 살아 있습니다. - 중간 실패: 임시 파일에 먼저 쓰고
os.replace()로 바꿔치기해서, 쓰는 도중 멈춰도 원본이 반쯤 잘린 채 남지 않습니다. - 부분 일치:
--word를 주면 앞뒤가 영문·숫자·밑줄이 아닌 곳만 바꿉니다.
"""여러 텍스트 파일의 단어를 한 번에 바꾸기 (기본은 미리보기, --apply 일 때만 저장)
사용 예:
python bulk_replace.py docs old-api.example.com new-api.example.com
python bulk_replace.py docs cat dog --word --apply
"""
import argparse
import os
import re
import sys
import tempfile
from pathlib import Path
sys.stdout.reconfigure(encoding="utf-8")
def read_text(path):
"""UTF-8(BOM 포함) → cp949 순서로 읽고, 원래 인코딩을 함께 돌려준다."""
raw = path.read_bytes()
for enc in ("utf-8-sig", "cp949"):
try:
text = raw.decode(enc)
except UnicodeDecodeError:
continue
if enc == "utf-8-sig" and not raw.startswith(b"\xef\xbb\xbf"):
enc = "utf-8"
return text, enc
return None, None
def write_text(path, text, enc):
"""임시 파일에 먼저 쓴 뒤 바꿔치기: 도중에 죽어도 원본이 반쯤 잘리지 않는다."""
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
with os.fdopen(fd, "w", encoding=enc, newline="") as f: # newline="" → 줄바꿈 그대로
f.write(text)
os.replace(tmp, path)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("folder")
ap.add_argument("old")
ap.add_argument("new")
ap.add_argument("--glob", default="*.txt")
ap.add_argument("--word", action="store_true", help="단어 단위로만 바꾸기")
ap.add_argument("--apply", action="store_true", help="실제로 저장")
ap.add_argument("--show", type=int, default=5, help="미리보기로 보여 줄 줄 종류 수")
args = ap.parse_args()
pat = re.escape(args.old)
if args.word:
pat = rf"(?<![\w]){pat}(?![\w])"
rx = re.compile(pat)
files = changed = hits = 0
skipped, samples = [], {}
for path in sorted(Path(args.folder).rglob(args.glob)):
files += 1
text, enc = read_text(path)
if text is None:
skipped.append(path)
continue
new_text, n = rx.subn(args.new, text)
if n == 0:
continue
changed += 1
hits += n
for no, line in enumerate(text.splitlines(), 1):
if rx.search(line):
if line not in samples: # 같은 줄은 한 번만, 처음 나온 위치와 함께
samples[line] = [f"{path}:{no} ({enc})", 0]
samples[line][1] += 1
if args.apply:
write_text(path, new_text, enc)
for line, (where, cnt) in sorted(samples.items(), key=lambda x: -x[1][1])[: args.show]:
print(f"{cnt}줄 | 예: {where}")
print(f" - {line}")
print(f" + {rx.sub(args.new, line)}")
mode = "저장 완료" if args.apply else "미리보기(저장 안 함, --apply로 저장)"
print(f"[{mode}] 검사 {files}개 / 바뀔 파일 {changed}개 / 치환 {hits}곳 / 서로 다른 줄 {len(samples)}종")
for p in skipped:
print(" 읽지 못해 건너뜀:", p)
if __name__ == "__main__":
main()
실행 결과: 미리보기 0.17초, 저장 0.50초
먼저 옵션 없이 실행하면 저장하지 않고 바뀔 줄을 종류별로 보여 줍니다. 미리보기 직후 원본과 비교해 보니 바뀐 파일은 0개였습니다.
$ python bulk_replace.py docs old-api.example.com new-api.example.com
2572줄 | 예: docs\part00\note_000.txt:1 (utf-8)
- 배포 전 old-api.example.com 응답을 확인하세요.
+ 배포 전 new-api.example.com 응답을 확인하세요.
2443줄 | 예: docs\part00\note_000.txt:2 (utf-8)
- 서버 주소는 old-api.example.com 입니다.
+ 서버 주소는 new-api.example.com 입니다.
[미리보기(저장 안 함, --apply로 저장)] 검사 500개 / 바뀔 파일 500개 / 치환 5015곳 / 서로 다른 줄 2종
내용을 확인하고 --apply로 저장한 뒤, 따로 만든 검증 스크립트로 원본 복사본과 바이트 단위로 대조했습니다. 같은 명령을 한 번 더 돌려 두 번 바뀌지 않는지도 확인했습니다.
$ python bulk_replace.py docs old-api.example.com new-api.example.com --apply --show 0
[저장 완료] 검사 500개 / 바뀔 파일 500개 / 치환 5015곳 / 서로 다른 줄 2종
$ python verify.py old-api.example.com new-api.example.com
바뀐 파일 500개 / 기대와 다른 파일 0개 / 남은 임시파일 0개
$ python bulk_replace.py docs old-api.example.com new-api.example.com --apply --show 0
[저장 완료] 검사 500개 / 바뀔 파일 0개 / 치환 0곳 / 서로 다른 줄 0종
500개 모두 기대와 일치했습니다. LF·CRLF·BOM·cp949 파일 모두 줄바꿈과 인코딩이 원래대로였고, 임시 파일도 남지 않았습니다. 시간은 파이썬 실행 시간을 포함해 미리보기 0.17초, 저장 0.50초였습니다. 저장이 세 배 정도 걸리는 건 파일마다 임시 파일을 만들고 바꿔치기하는 비용입니다.
미리보기가 문제를 놓친 이유: 처음 N줄만 보여 줬다
처음 만든 버전의 미리보기는 “처음 나온 3줄”을 보여 주는 방식이었습니다. 이 버전으로 cat을 dog로 바꾸는 미리보기를 돌렸을 때 화면에는 아무 문제가 없어 보였습니다.
$ python bulk_replace.py docs cat dog --show 2 (1차 버전)
docs\part00\note_000.txt:4 (utf-8)
- cat 명령으로 로그를 봅니다.
+ dog 명령으로 로그를 봅니다.
docs\part00\note_000.txt:5 (utf-8)
- cat 명령으로 로그를 봅니다.
+ dog 명령으로 로그를 봅니다.
[미리보기(저장 안 함, --apply로 저장)] 검사 500개 / 바뀔 파일 500개 / 치환 5057곳
그런데 치환 수 5,057곳이 어딘가 많았습니다. --word를 붙이면 2,437곳이었으니 절반 넘게가 다른 곳이었다는 뜻입니다. 그래서 미리보기를 서로 다른 변경 줄을 종류별로 묶어 많이 나온 순서대로 보여 주도록 바꿨고, 그러자 숨어 있던 문제가 맨 위에 나왔습니다.
$ python bulk_replace.py docs cat dog (수정 후)
2620줄 | 예: docs\part00\note_000.txt:6 (utf-8)
- category 값은 그대로 둡니다.
+ dogegory 값은 그대로 둡니다.
2437줄 | 예: docs\part00\note_000.txt:4 (utf-8)
- cat 명령으로 로그를 봅니다.
+ dog 명령으로 로그를 봅니다.
[미리보기(저장 안 함, --apply로 저장)] 검사 500개 / 바뀔 파일 500개 / 치환 5057곳 / 서로 다른 줄 2종
$ python bulk_replace.py docs cat dog --word
2437줄 | 예: docs\part00\note_000.txt:4 (utf-8)
- cat 명령으로 로그를 봅니다.
+ dog 명령으로 로그를 봅니다.
[미리보기(저장 안 함, --apply로 저장)] 검사 500개 / 바뀔 파일 487개 / 치환 2437곳 / 서로 다른 줄 1종
“category”가 “dogegory”로 바뀌는 줄이 2,620줄이었습니다. 앞의 몇 줄만 보여 주는 미리보기는 첫 파일 앞부분에 정상 줄이 몰려 있으면 이런 문제를 그대로 통과시킵니다. 미리보기에서는 개수보다 어떤 종류의 줄이 바뀌는지를 봐야 합니다.
한글 단어와 여러 단어 바꾸기에서 주의할 점
영어 단어에는 --word가 잘 맞지만, 한글에서는 기대와 다르게 동작했습니다. 한글 글자도 정규식에서 단어 문자(\w)로 취급되기 때문에, “서버” 뒤에 조사가 붙은 “서버를”은 단어 경계가 없다고 봅니다.
str.replace : server 주소 확인. server를 재시작하면 반영됩니다. 웹server 로그.
\b서버\b : server 주소 확인. 서버를 재시작하면 반영됩니다. 웹서버 로그.
실제로 500개 파일에서 “서버”를 --word로 바꾸면 2,443곳만 잡혔고, 옵션 없이 바꾸면 “서버를 재시작하면…” 2,498줄까지 포함해 4,941곳이었습니다. 반대로 옵션 없이 바꾸면 “웹서버” 같은 합성어까지 바뀝니다. 한글 단어는 --word 없이 미리보기를 돌리고, 바뀌는 줄 종류를 눈으로 확인해 판단하는 편이 안전합니다.
dev와 prod를 서로 맞바꾸는 것처럼 여러 단어를 한꺼번에 바꿀 때는 차례대로 replace를 부르면 안 됩니다. 먼저 dev를 prod로 바꾸고 다음에 prod를 dev로 바꾸면, 방금 바꾼 것까지 되돌아가 둘 다 dev가 됐습니다. 정규식 하나로 묶어 한 번에 바꾸면 의도대로 나왔습니다.
cfg = "env=dev\nbackup=prod\n"
# 차례로 바꾸기 → 'env=dev\nbackup=dev\n' (둘 다 dev가 됨)
seq = cfg
for old, new in [("dev", "prod"), ("prod", "dev")]:
seq = seq.replace(old, new)
print(repr(seq))
# 한 번에 바꾸기 → 'env=prod\nbackup=dev\n'
table = {"dev": "prod", "prod": "dev"}
rx = re.compile("|".join(map(re.escape, table)))
print(rx.sub(lambda m: table[m.group(0)], cfg))
정리: 문제별 대응표
| 문제 | 실측에서 본 현상 | 대응 |
|---|---|---|
| 인코딩이 섞인 폴더 | 350개 처리 후 cp949 파일에서 중단 | UTF-8 → cp949 순 판별, 같은 인코딩으로 저장 |
| 줄바꿈 변환 | LF 파일 150개가 CRLF로 바뀜 | newline=”” 로 쓰기 |
| 부분 문자열 | category → dogegory 2,620줄 | –word, 미리보기 줄 종류 확인 |
| 처음 N줄 미리보기 | 정상 줄만 보여 문제를 숨김 | 변경 줄 종류별·빈도순 표시 |
| 한글 + \b | “서버를” 2,498줄을 못 잡음 | –word 없이 미리보기로 판단 |
| 차례로 replace | dev↔prod 맞바꾸기 실패 | 정규식 하나로 한 번에 치환 |
마무리
가장 작은 첫걸음은 바꾸려는 폴더를 통째로 복사해 두고, 위 스크립트를 --apply 없이 한 번 돌려 보는 것입니다. 바뀔 줄 종류가 예상과 같은지만 확인해도 dogegory 같은 사고는 대부분 막을 수 있습니다. 확인이 끝나면 같은 명령에 --apply만 붙이면 되고, 한 번 더 돌렸을 때 “바뀔 파일 0개”가 나오면 깔끔하게 끝난 것입니다.
다음 글에서는 바꾸기 전후 두 텍스트 파일의 차이를 파이썬 difflib로 HTML 비교 화면으로 만들어 보겠습니다.