[파이썬 100강] 94강. difflib로 텍스트 변경점 비교 자동화하기
문서 버전 비교, 설정 파일 변경 검토, 코드 리뷰 보조처럼 “어디가 어떻게 바뀌었는지”를 자동으로 보여줘야 할 때가 많습니다. 이때 파이썬 표준 라이브러리 difflib를 잘 쓰면 외부 도구 없이도 꽤 강력한 변경점 분석 파이프라인을 만들 수 있습니다. 오늘은 이론 설명을 길게 끌기보다, 실무에서 바로 재사용 가능한 패턴 중심으로 정리합니다.
핵심 개념
difflib는 두 텍스트(문자열, 줄 목록)를 비교해서 “같은 부분/다른 부분”을 찾아 사람이 읽기 좋은 형태로 출력하는 도구입니다. 중요한 건 단순히 diff를 뽑는 것보다, 목적에 맞는 비교 형식을 고르는 것입니다. 예를 들어 리뷰용이면 unified diff가 좋고, “비슷한 후보 찾기”면 get_close_matches()가 더 적합합니다.
또 하나의 핵심은 difflib가 “문자열 비교 엔진”이라는 점입니다. 즉, 입력 전처리(공백 통일, 줄바꿈 정규화, 민감정보 마스킹)를 어떻게 하느냐에 따라 결과 품질이 크게 달라집니다. 같은 문서라도 줄 끝 공백 때문에 변경이 과장되어 보일 수 있고, 반대로 정규화를 잘하면 실제 의미 있는 변경만 도드라지게 만들 수 있습니다.
마지막으로 운영 관점에서는 diff 결과를 “출력으로 끝내지 않고” 후속 액션에 연결해야 합니다. 예를 들어 변경 라인 수가 임계치를 넘으면 알림을 보내고, 특정 키워드(예: password, token)가 새로 추가되면 배포 전 차단하는 식입니다. difflib는 그 자체로 완성품이 아니라, 품질 게이트의 핵심 부품이라고 보는 편이 실무적으로 맞습니다.
기본 사용
예제 1) unified_diff로 가장 표준적인 변경점 보기
>>> import difflib
>>> old = """version=1
... timeout=3
... feature_x=false
... """.splitlines(keepends=True)
>>> new = """version=2
... timeout=5
... feature_x=true
... """.splitlines(keepends=True)
>>> diff = list(difflib.unified_diff(old, new, fromfile="before.ini", tofile="after.ini"))
>>> print("".join(diff))
--- before.ini
+++ after.ini
@@ -1,3 +1,3 @@
-version=1
-timeout=3
-feature_x=false
+version=2
+timeout=5
+feature_x=true
해설:
splitlines(keepends=True)를 쓰면 줄바꿈 정보가 유지되어 diff 출력이 안정적입니다.fromfile,tofile를 넣으면 리포트/리뷰에서 맥락이 좋아집니다.- 코드 리뷰나 배포 검토에서는 unified diff가 가장 익숙하고 전달력도 좋습니다.
예제 2) ndiff로 라인 내부 변경까지 자세히 추적
>>> a = ["name=gunwoo\n", "region=ap-northeast-2\n", "retry=3\n"]
>>> b = ["name=geonwoo\n", "region=ap-northeast-2\n", "retry=5\n"]
>>> lines = list(difflib.ndiff(a, b))
>>> [line for line in lines if line.startswith(("- ", "+ "))]
['- name=gunwoo\n', '+ name=geonwoo\n', '- retry=3\n', '+ retry=5\n']
해설:
ndiff는-,+,?기호로 차이를 더 세밀하게 보여줍니다.- 사람이 수동 검토할 때는 좋지만, 기계 파싱에는
unified_diff가 더 다루기 쉽습니다.
예제 3) HtmlDiff로 브라우저에서 읽기 쉬운 비교 보고서 만들기
>>> old_lines = ["title=Daily Report\n", "enabled=true\n", "workers=2\n"]
>>> new_lines = ["title=Daily Report V2\n", "enabled=true\n", "workers=4\n"]
>>> html = difflib.HtmlDiff().make_file(old_lines, new_lines, fromdesc="old", todesc="new")
>>> html.startswith("\n<!DOCTYPE html") or "<table class=\"diff\"" in html
True
>>> "workers=2" in html and "workers=4" in html
True
해설:
- QA/기획과 공유할 때 텍스트 diff보다 HTML이 훨씬 이해가 빠릅니다.
- 결과를 파일로 저장해 아티팩트(예: CI 결과물)로 남기면 회귀 분석에 유용합니다.
예제 4) get_close_matches로 오타 교정 후보 추천
>>> commands = ["deploy", "rollback", "status", "restart", "logs"]
>>> difflib.get_close_matches("restat", commands, n=3, cutoff=0.5)
['restart', 'status']
>>> difflib.get_close_matches("deply", commands, n=1, cutoff=0.6)
['deploy']
해설:
- CLI에서 잘못 입력한 명령어에 대해 “혹시 이걸 의도했나요?”를 구현할 수 있습니다.
cutoff를 너무 낮추면 엉뚱한 후보가 나와 UX가 나빠지니 도메인별 튜닝이 필요합니다.
자주 하는 실수
실수 1) 줄바꿈 정보를 버려서 diff가 어색하게 나옴
>>> import difflib
>>> old = "a\nb\nc".splitlines() # keepends=False (기본값)
>>> new = "a\nb\nd".splitlines()
>>> print("".join(difflib.unified_diff(old, new)))
---
+++
@@ -1,3 +1,3 @@
c+d
원인:
- 줄바꿈(
\n)이 빠진 라인 목록을 그대로 넣으면 출력이 붙어 보이거나 가독성이 크게 떨어집니다.
해결:
>>> old = "a\nb\nc\n".splitlines(keepends=True)
>>> new = "a\nb\nd\n".splitlines(keepends=True)
>>> print("".join(difflib.unified_diff(old, new)))
---
+++
@@ -1,3 +1,3 @@
a
b
-c
+d
해결 포인트:
- 파일 비교 시에는
read_text().splitlines(keepends=True)패턴을 고정하세요. - “왜 그럴까요?”에 대한 답은 단순합니다. diff 출력 포맷이 라인 단위 문자열과 줄바꿈을 기대하기 때문입니다.
실수 2) 공백/정렬 차이까지 모두 변경으로 취급해 노이즈 폭증
>>> left = ["name = gunwoo\n", "timeout = 5\n"]
>>> right = ["name=gunwoo\n", "timeout=5\n"]
>>> list(difflib.unified_diff(left, right))[:6]
['--- \n', '+++ \n', '@@ -1,2 +1,2 @@\n', '-name = gunwoo\n', '-timeout = 5\n', '+name=gunwoo\n']
원인:
- 의미는 같은데 포맷만 다른 텍스트를 원본 그대로 비교했습니다.
해결:
>>> def normalize_kv(lines):
... out = []
... for line in lines:
... if "=" in line:
... k, v = line.split("=", 1)
... out.append(f"{k.strip()}={v.strip()}\n")
... else:
... out.append(line.strip() + "\n")
... return out
...
>>> norm_left = normalize_kv(left)
>>> norm_right = normalize_kv(right)
>>> list(difflib.unified_diff(norm_left, norm_right))
[]
해결 포인트:
- “그럼 어떻게 막을까요?”에 대한 정답은 비교 전 정규화입니다.
- 공백, 줄끝, 정렬, 대소문자 규칙을 도메인에 맞게 통일한 뒤 diff를 생성하세요.
실수 3) 큰 파일을 한 번에 처리해 메모리와 시간 낭비
- 증상: 수 MB~수십 MB 텍스트를 리스트로 통째로 올렸다가 CI가 느려짐
- 원인: 변경점이 필요한 파일과 불필요한 파일을 구분하지 않음
- 해결: 파일 크기/확장자 필터, 샘플링, chunk 단위 처리 전략을 먼저 두고
difflib를 적용
실무에서는 “모든 파일 완전 비교”보다 “중요 파일 우선 비교 + 임계치 초과 시 상세 분석” 방식이 훨씬 효율적입니다.
실무 패턴
difflib를 운영에서 안정적으로 쓰려면 아래 4단계를 추천합니다.
- 입력 검증 규칙
- 파일 존재 여부, 인코딩(UTF-8), 최대 크기 제한을 먼저 확인합니다.
- 비교 대상이 바이너리인지 텍스트인지 판별하고, 텍스트가 아니면 우회 처리합니다.
- 전처리/정규화
- 줄바꿈 통일(
\r\n→\n), trailing whitespace 제거, 필요 시 대소문자 정규화. - 민감정보(
token=...)는 마스킹한 복사본으로만 diff를 생성합니다.
- diff 생성 및 요약
- 사람 검토용:
unified_diff혹은HtmlDiff. - 자동 판단용: 추가/삭제 라인 수를 집계해 임계치 기반 경고 생성.
- 예: 삭제 라인 200줄 이상이면 “대규모 변경”으로 분류.
- 로그/알림/재현성
- 어떤 파일을 어떤 옵션으로 비교했는지 메타데이터를 남깁니다.
- CI 아티팩트에 raw diff + 요약 JSON을 같이 저장하면 사후 분석이 쉬워집니다.
아래는 “요약 정보 뽑기” 미니 패턴입니다.
>>> def summarize_unified_diff(diff_lines):
... added = sum(1 for ln in diff_lines if ln.startswith("+") and not ln.startswith("+++"))
... removed = sum(1 for ln in diff_lines if ln.startswith("-") and not ln.startswith("---"))
... return {"added": added, "removed": removed, "changed": added + removed}
...
>>> d = list(difflib.unified_diff(["a\n", "b\n"], ["a\n", "c\n", "d\n"]))
>>> summarize_unified_diff(d)
{'added': 2, 'removed': 1, 'changed': 3}
이 요약값은 대시보드, 슬랙 알림, 배포 차단 룰에 그대로 연결할 수 있습니다. 결국 실무에서 중요한 건 “diff를 잘 보여주는 것”을 넘어 “변경 위험을 빠르게 판단하게 만드는 것”입니다.
오늘의 결론
한 줄 요약: difflib는 단순 비교 도구가 아니라, 변경 품질을 통제하는 자동화 파이프라인의 핵심 부품이다.
기억할 것:
- 목적에 맞는 형식(
unified_diff,ndiff,HtmlDiff,get_close_matches)을 고른다. - 비교 전 정규화가 결과 품질을 좌우한다.
- diff 결과를 요약 지표/알림/차단 룰과 연결해야 실무 가치가 커진다.
연습문제
- 두 설정 파일(
before.toml,after.toml)을 읽어 unified diff를 문자열로 반환하는build_config_diff(path_a, path_b)함수를 작성하세요. 줄바꿈 문제를 피하기 위한 처리도 포함하세요. - diff 라인 목록에서 추가/삭제/총 변경 수를 집계하는
summarize_unified_diff()를 직접 구현하고, 변경 수가 20줄을 넘으면 경고 문구를 반환하도록 확장하세요. - 명령어 목록이 있을 때 사용자가 오타를 입력하면
get_close_matches()로 상위 2개 후보를 제시하는suggest_command(input_cmd, commands)를 작성하세요.
이전 강의 정답
93강은 graphlib.TopologicalSorter로 작업 의존성을 안전하게 순서화하는 내용이었습니다.
- 의존성 그래프에서 실행 순서 계산
>>> from graphlib import TopologicalSorter
>>> graph = {
... "test": {"build"},
... "deploy": {"test"},
... "build": {"lint"},
... "lint": set(),
... }
>>> tuple(TopologicalSorter(graph).static_order())
('lint', 'build', 'test', 'deploy')
- 여러 시작 노드를 가진 그래프 처리
>>> graph = {
... "package": {"unit-test", "integration-test"},
... "unit-test": {"install"},
... "integration-test": {"install"},
... "install": set(),
... }
>>> order = tuple(TopologicalSorter(graph).static_order())
>>> order[0], order[-1]
('install', 'package')
- 순환 의존성 검출
>>> from graphlib import TopologicalSorter, CycleError
>>> cyclic = {"A": {"B"}, "B": {"A"}}
>>> try:
... tuple(TopologicalSorter(cyclic).static_order())
... except CycleError as e:
... "cycle" in str(e).lower()
True
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈: 표준 라이브러리
difflib - 재현 절차:
- REPL에서 예제 문자열/라인 목록을 준비
unified_diff,ndiff,HtmlDiff,get_close_matches를 각각 실행- 정규화 전/후 결과를 비교해 노이즈 감소 효과 확인
- 변경 요약 함수를 붙여 자동 판단 로직까지 점검
- 검증 체크리스트:
pycon코드블록이 실제 실행 가능한 형태인가?- 실수 예제에서 원인/해결이 분리되어 설명되는가?
- 결과를 운영 경고/리포트로 연결할 수 있는 구조인가?