[파이썬 100강] 94강. difflib로 텍스트 변경점 비교 자동화하기

[파이썬 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단계를 추천합니다.

  1. 입력 검증 규칙
  • 파일 존재 여부, 인코딩(UTF-8), 최대 크기 제한을 먼저 확인합니다.
  • 비교 대상이 바이너리인지 텍스트인지 판별하고, 텍스트가 아니면 우회 처리합니다.
  1. 전처리/정규화
  • 줄바꿈 통일(\r\n\n), trailing whitespace 제거, 필요 시 대소문자 정규화.
  • 민감정보(token=...)는 마스킹한 복사본으로만 diff를 생성합니다.
  1. diff 생성 및 요약
  • 사람 검토용: unified_diff 혹은 HtmlDiff.
  • 자동 판단용: 추가/삭제 라인 수를 집계해 임계치 기반 경고 생성.
  • 예: 삭제 라인 200줄 이상이면 “대규모 변경”으로 분류.
  1. 로그/알림/재현성
  • 어떤 파일을 어떤 옵션으로 비교했는지 메타데이터를 남깁니다.
  • 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 결과를 요약 지표/알림/차단 룰과 연결해야 실무 가치가 커진다.

연습문제

  1. 두 설정 파일(before.toml, after.toml)을 읽어 unified diff를 문자열로 반환하는 build_config_diff(path_a, path_b) 함수를 작성하세요. 줄바꿈 문제를 피하기 위한 처리도 포함하세요.
  2. diff 라인 목록에서 추가/삭제/총 변경 수를 집계하는 summarize_unified_diff()를 직접 구현하고, 변경 수가 20줄을 넘으면 경고 문구를 반환하도록 확장하세요.
  3. 명령어 목록이 있을 때 사용자가 오타를 입력하면 get_close_matches()로 상위 2개 후보를 제시하는 suggest_command(input_cmd, commands)를 작성하세요.

이전 강의 정답

93강은 graphlib.TopologicalSorter로 작업 의존성을 안전하게 순서화하는 내용이었습니다.

  1. 의존성 그래프에서 실행 순서 계산
>>> from graphlib import TopologicalSorter
>>> graph = {
...     "test": {"build"},
...     "deploy": {"test"},
...     "build": {"lint"},
...     "lint": set(),
... }
>>> tuple(TopologicalSorter(graph).static_order())
('lint', 'build', 'test', 'deploy')
  1. 여러 시작 노드를 가진 그래프 처리
>>> 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')
  1. 순환 의존성 검출
>>> 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

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 사용 모듈: 표준 라이브러리 difflib
  • 재현 절차:
    1. REPL에서 예제 문자열/라인 목록을 준비
    2. unified_diff, ndiff, HtmlDiff, get_close_matches를 각각 실행
    3. 정규화 전/후 결과를 비교해 노이즈 감소 효과 확인
    4. 변경 요약 함수를 붙여 자동 판단 로직까지 점검
  • 검증 체크리스트:
    • pycon 코드블록이 실제 실행 가능한 형태인가?
    • 실수 예제에서 원인/해결이 분리되어 설명되는가?
    • 결과를 운영 경고/리포트로 연결할 수 있는 구조인가?