AI가 쓴 글 점검 스크립트 — 문장 길이·반복 표현을 숫자로 재기 (실측)

AI가 쓴 글은 한 편씩 읽으면 멀쩡해 보입니다. 어색함은 여러 편을 이어 읽을 때 드러나는데, 그걸 눈으로 찾기는 어렵습니다. 그래서 AI 글 반복 표현 점검 스크립트를 파이썬 표준 라이브러리만으로 만들어, 이 블로그 원고 76편(문장 5,681개)을 재 봤습니다. 이 블로그는 Claude로 원고를 쓰기 때문에 제 글이 곧 실험 대상입니다. 결과는 예상과 달랐습니다. 제가 미리 고른 상투어 15개 중 8개는 76편에서 0번 나왔고, 대신 “가장 작은 첫걸음은”이라는 구절이 76편 중 38편에 들어 있었습니다. 최근 20편은 전부입니다. 한 글 안의 끝말이 15문장 연속 같은 곳도 찾았습니다. 환경은 윈도우 11, Python 3.12.10이고, 전체 분석은 프로세스 시작부터 끝까지 0.23초였습니다.

첫 시도 — 마침표로 자르면 문장이 아니다

측정은 문장을 세는 데서 시작합니다. 원고에서 본문을 뽑아 가장 단순하게 마침표로 잘랐습니다.

text = open(sys.argv[1], encoding="utf-8").read()
sentences = [s.strip() for s in text.split(".") if s.strip()]
# 코드 블록까지 포함한 본문
문장 수 370 / 평균 길이 24.4 / 가장 긴 문장 186
가장 짧은 5개: 'f', 'p', 'os', '12', 'py'

# 코드 블록을 뺀 본문
문장 수 118 / 평균 길이 33.6 / 가장 긴 문장 100
가장 짧은 5개: '12', 'os', '10입니다', '052~0', '속도는 0'

두 가지가 동시에 망가졌습니다. 코드 블록이 섞이면 f, os가 문장으로 세어지고, 오류 메시지와 설명이 붙어서 186자짜리 “문장”이 생깁니다. 코드를 뺀 뒤에도 Python 3.12.10입니다는 12와 10입니다로, 0.052~0.063초는 052~0으로 쪼개졌습니다. 기술 글은 숫자와 파일 이름에 점이 많아서 이 방식이 특히 안 맞습니다.

고친 방법은 둘입니다. 원고를 뽑을 때 코드 블록과 표를 지우고 인라인 코드는 CODE라는 자리표시자로 바꿨습니다. 그리고 문장 끝은 “마침표 뒤에 공백이 올 때”만 인정했습니다. 경계 사례 7개를 넣어 보니 3.12.10, Node.js, 따옴표 속 마침표는 잘 넘어갔습니다. 다만 약 3. 5초나 표 1. 결과처럼 점 뒤에 공백이 있으면 여전히 잘립니다. 한국어 형태소 분석 없이 쓰는 대신 받아들인 오차입니다.

점검 스크립트 완성본

아래가 최종본입니다. 설치할 것은 없고, 일반 텍스트나 마크다운 파일을 넣으면 됩니다. 마크다운의 제목과 표 줄, 코드 블록은 알아서 뺍니다.

"""글 점검 스크립트: 문장 길이, 끝말 반복, 상투 표현을 숫자로 잰다.

사용: python textcheck.py 글.txt
      python textcheck.py 글.md --strict   기준을 넘으면 종료 코드 1
"""
import re
import statistics
import sys
from collections import Counter

LONG = 60   # 이 글자 수를 넘으면 긴 문장
STOCK = ["수 있습니다", "것이 좋습니다", "중요합니다", "필요합니다", "살펴보겠습니다",
         "알아보겠습니다", "다양한", "효율적", "효과적", "간단합니다", "쉽게", "핵심",
         "또한", "따라서", "결론적으로"]
LIMITS = {"긴 문장 비율(%)": 27.0, "최대 끝말 연속": 7, "상투 표현(1000자당)": 2.0}


def clean(text):
    """코드 블록은 지우고 인라인 코드는 CODE로 바꾼다."""
    text = re.sub(r"^(```|~~~).*?^\1\s*$", "", text, flags=re.M | re.S)
    return re.sub(r"`[^`\n]+`", "CODE", text)


def split_sentences(text):
    """(문장, 목록 여부)를 돌려준다. 뒤에 공백이 오는 . ! ? 에서만 자른다."""
    out = []
    for line in text.splitlines():
        line = line.strip()
        is_list = bool(re.match(r"([-*•]|\d+\.)\s", line))
        if is_list:
            line = line.split(None, 1)[1] if " " in line else ""
        if line.startswith(("#", "|")):
            continue
        for s in re.split(r"(?<=[.!?])\s+", line):
            if len(s) >= 2:
                out.append((s, is_list))
    return out


def ending(sentence, n=3):
    """문장 끝 n 글자. 끝의 괄호 설명과 문장부호, 따옴표는 뺀다."""
    sentence = re.sub(r"\s*\([^()]*\)[\s.!?]*$", "", sentence)
    return re.sub(r"[\s.!?:)\]\"']+$", "", sentence)[-n:]


def is_past(sentence):
    """~었습니다 꼴인지: '습니다' 앞 글자의 받침이 ㅆ이고 '있'이 아닌 경우."""
    tail = ending(sentence, 4)
    if len(tail) < 4 or not tail.endswith("습니다") or tail[0] == "있":
        return False
    code = ord(tail[0]) - 0xAC00
    return 0 <= code < 11172 and code % 28 == 20


def max_run(pairs):
    """같은 끝말이 이어진 최대 길이. 목록 항목은 나란히 쓰는 게 자연스러워 끊는다."""
    best, run, prev = 0, 0, None
    for sentence, is_list in pairs:
        if is_list:
            run, prev = 0, None
            continue
        e = ending(sentence)
        run = run + 1 if e == prev else 1
        prev = e
        best = max(best, run)
    return best


def analyze(text):
    pairs = split_sentences(clean(text))
    if not pairs:
        raise SystemExit("문장을 찾지 못했습니다")
    sentences = [s for s, _ in pairs]
    lengths = [len(s) for s in sentences]
    chars = sum(lengths)
    ends = Counter(ending(s) for s in sentences)
    stock = {w: text.count(w) for w in STOCK if w in text}
    return {
        "글자 수": chars,
        "문장 수": len(sentences),
        "평균 길이": round(statistics.mean(lengths), 1),
        "표준편차": round(statistics.pstdev(lengths), 1),
        "긴 문장 비율(%)": round(sum(n > LONG for n in lengths) / len(lengths) * 100, 1),
        "최대 끝말 연속": max_run(pairs),
        "상위 끝말 비율(%)": round(ends.most_common(1)[0][1] / len(sentences) * 100, 1),
        "과거형 비율(%)": round(sum(map(is_past, sentences)) / len(sentences) * 100, 1),
        "상투 표현(1000자당)": round(sum(stock.values()) / chars * 1000, 1),
        "_ends": ends,
        "_stock": stock,
        "_sentences": sentences,
        "_pairs": pairs,
    }


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    r = analyze(open(sys.argv[1], encoding="utf-8").read())
    for key, value in r.items():
        if not key.startswith("_"):
            print(f"{key:<14} {value}")
    print("끝말 상위:", r["_ends"].most_common(4))
    print("상투 표현:", sorted(r["_stock"].items(), key=lambda x: -x[1])[:5])
    print("가장 긴 문장:", max(r["_sentences"], key=len)[:50], "...")
    over = [key for key, limit in LIMITS.items() if r[key] > limit]
    print("기준 초과:", over or "없음")
    if "--strict" in sys.argv and over:
        sys.exit(1)


if __name__ == "__main__":
    main()

이 블로그 글 중 끝말 연속이 가장 길었던 글을 넣은 결과입니다.

$ python textcheck.py 2026-10-22-bulk-rename.txt
글자 수           3409
문장 수           71
평균 길이          48.0
표준편차           20.8
긴 문장 비율(%)     28.2
최대 끝말 연속       15
상위 끝말 비율(%)    53.5
과거형 비율(%)      36.6
상투 표현(1000자당)  1.2
끝말 상위: [('습니다', 38), ('입니다', 7), ('합니다', 5), ('씁니다', 3)]
상투 표현: [('수 있습니다', 3), ('핵심', 1)]
기준 초과: ['긴 문장 비율(%)', '최대 끝말 연속']

중간에 고친 곳이 두 군데입니다. 하나는 끝났습니다(실측).의 끝말이 (실측으로 나오던 버그로, 끝의 괄호 설명을 지우게 했습니다. 괄호로 끝난 문장이 원고 전체에 36개라 과거형 비율 최댓값이 58.6%에서 60.3%로 움직였습니다. 다른 하나는 목록입니다. 항목끼리 끝말을 맞추는 건 자연스러운 일이니 연속 계산에서 뺐더니, markdown-table-csv 글의 11연속이 상위 5위 밖으로 내려갔습니다.

처음 정한 기준은 76편 중 60편을 걸렸다

어림으로 정한 기준값은 쓸모가 없었습니다. “긴 문장 12% 초과, 끝말 6연속 초과, 상투 표현 1000자당 8회 초과”로 돌리면 76편 중 60편이 걸립니다. 긴 문장(60자 초과) 비율은 중앙값이 이미 17.0%였기 때문입니다. 반대로 상투 표현 기준은 최댓값이 3.1이라 한 편도 걸리지 않았습니다. 둘 다 아무것도 알려 주지 않는 기준입니다.

그래서 기준을 내 글 전체 분포의 상위 10% 근처로 다시 잡았습니다.

지표최소중앙값상위 10%최대새 기준
평균 길이(자)30.942.447.554.0–
긴 문장 비율(%)2.317.026.834.527 초과
최대 끝말 연속137157 초과
과거형 비율(%)0.03.542.760.3–
상투 표현(1000자당)0.01.01.93.12 초과

바꾼 뒤에는 14편이 걸렸고, 긴 문장 7편, 끝말 연속 5편, 상투 표현 5편이었습니다(두 지표에 함께 걸린 글이 있습니다). 이 정도면 며칠에 나눠 다시 읽어 볼 수 있는 분량입니다. 다른 사람의 글이나 다른 문체에 이 숫자를 그대로 쓰면 안 됩니다. 기준은 각자의 글로 다시 뽑아야 합니다.

상투어 목록은 빗나갔고, 반복은 글 사이에 있었다

“AI 글이면 이런 말을 많이 쓰겠지” 하고 만든 15개 목록의 성적은 나빴습니다. 또한, 따라서, 결론적으로, 다양한, 효과적, 살펴보겠습니다, 알아보겠습니다, 것이 좋습니다 8개는 76편 전체에서 한 번도 나오지 않았습니다. 가장 많은 수 있습니다도 1000자당 0.59회입니다.

글 한 편 안에서 3어절 구절을 세어 봐도 소득이 없었습니다. “글 안에서 3회 이상”인 구절은 76편을 통틀어 단 1종, 그나마 3.49초가 0.016초가 됐습니다.라는 그 글의 주제 수치입니다.

그래서 방향을 바꿔 몇 편의 글에 등장하는지를 세어 봅니다. 한 글에 몰린 구절은 주제어이고, 여러 글에 퍼진 구절은 습관이라는 생각입니다. 글 안에서 같은 구절을 여러 번 써도 1편으로만 셉니다.

"""폴더 안 글들을 비교해 여러 글에 반복되는 구절을 찾는다.

사용: python repeat_phrases.py 폴더 [어절 수=3] [최소 글 수=5]
"""
import re
import sys
from collections import Counter
from pathlib import Path

folder = Path(sys.argv[1])
n = int(sys.argv[2]) if len(sys.argv) > 2 else 3
min_docs = int(sys.argv[3]) if len(sys.argv) > 3 else 5
sys.stdout.reconfigure(encoding="utf-8")

docs = Counter()
for path in sorted(folder.glob("*.txt")):
    text = path.read_text(encoding="utf-8")
    seen = set()
    for sentence in re.split(r"(?<=[.!?])\s+|\n", text):
        words = re.sub(r"[.,!?:;()\"'“”]", " ", sentence).split()
        for i in range(len(words) - n + 1):
            phrase = " ".join(words[i:i + n])
            if "CODE" not in phrase:
                seen.add(phrase)
    docs.update(seen)

total = len(list(folder.glob("*.txt")))
hits = [(c, p) for p, c in docs.items() if c >= min_docs]
print(f"글 {total}편, {n}어절 구절 {len(docs):,}종, {min_docs}편 이상 {len(hits)}종")
for c, p in sorted(hits, reverse=True)[:15]:
    print(f"{c:>3}편 ({c / total * 100:.0f}%)  {p}")
$ python repeat_phrases.py corpus
글 76편, 3어절 구절 41,995종, 5편 이상 36종
 38편 (50%)  가장 작은 첫걸음은
 20편 (26%)  윈도우 11 Python
 17편 (22%)  작은 첫걸음은 이렇습니다
 14편 (18%)  할 수 있는
 11편 (14%)  쓸 수 있는
 10편 (13%)  한 번 더

$ python repeat_phrases.py corpus 2 20
 76편 (100%)  다음 글에서는
 65편 (86%)  수 있습니다
 48편 (63%)  이 글에서는
 21편 (28%)  경우가 많습니다
 20편 (26%)  편이 안전합니다

절반의 글에 같은 구절이 있었습니다. 자리표시자 처리 때문에 잘못 센 건 아닌지 원고 파일 원문을 직접 검색해도 똑같이 38편이었고, 월별로는 8월 15편 중 5편, 9월 29편 중 4편, 10월 26편 중 24편, 11월 4편 중 4편으로 최근에 몰려 있습니다. 최근 20편은 모두 들어 있고, 그중 17편은 “가장 작은 첫걸음은 이렇습니다”로 문장까지 같습니다.

그 표현의 출처는 지시문이었습니다. 원고 작성 기준 문서의 글 뼈대에 “마무리: 가장 작은 첫걸음 제안 + 다음 글 예고”라고 적혀 있습니다. 같은 문서에 다음 글을 예고하라는 규칙이 있으니 다음 글에서는이 76편 전부에 나온 것도 설명이 됩니다. 다만 8월과 9월에는 44편 중 9편에만 나왔으므로, 언제부터 왜 늘었는지는 이 숫자만으로 알 수 없습니다.

끝말 15연속 — 실측 글의 부작용

이 블로그의 작성 방식은 10월 7일에 바뀌었습니다. 코드를 실제로 돌리고 그 기록으로 쓰는 방식입니다. 그렇다면 문체도 달라졌을까요? 날짜가 파일 이름에 있는 74편을 전후로 나눠 중앙값을 비교했습니다.

지표10/7 이전 45편10/7 이후 29편
글자 수2,8143,563
평균 문장 길이(자)40.046.0
긴 문장 비율(%)13.823.0
최대 끝말 연속35
상위 끝말 비율(%)24.251.0
과거형 비율(%)2.537.8
상투 표현(1000자당)1.10.6

상투 표현이 줄어든 대신 끝말은 더 단조로워졌습니다. 겪은 일을 쓰다 보니 “~했습니다”가 늘어 과거형이 2.5%에서 37.8%가 됐고, 습니다로 끝나는 문장도 절반을 넘습니다. 끝을 네 글자로 나누면 차이가 더 잘 보입니다. 이전 1위는 있습니다(6.7%)이지만 이후에는 었습니다(8.2%)와 했습니다(5.6%)가 위에 있습니다.

15연속이 나온 곳은 일괄 이름 변경 글입니다. 읽어 보면 “돌려 봤습니다, 맞았습니다, 출력했습니다, 돌려 봤습니다, 걸러졌습니다…”가 이어지는 실험 일지입니다. 지표가 틀린 게 아니라 정말 단조로웠던 것이죠. 그래서 이 15문장을 복사본에서 고쳐 다시 쟀습니다. 사실은 그대로 두고 문장을 합치거나, 현재형이나 질문으로 바꾸는 정도입니다. 공개된 글은 건드리지 않았습니다.

                 고치기 전   고친 뒤
글자 수            747        745
문장 수             15         15
최대 끝말 연속      15          4
상위 끝말 비율(%)  100.0       73.3
과거형 비율(%)      86.7       60.0

문장 몇 개를 손본 것만으로 15가 4로 내려갔고, 글자 수는 거의 그대로입니다. 이 글의 초안도 같은 검사를 거쳤습니다. 결과는 끝말 11연속, 습니다 60.4%로 기준 초과였습니다. 실측을 옮겨 적는 글이라 같은 버릇이 그대로 나온 셈입니다. 그 구간을 고친 뒤에는 5연속, 49.0%로 내려갔습니다.

이 숫자로 할 수 없는 일

  • AI 판별기가 아닙니다. 이번 측정에는 사람이 쓴 비교 글이 없습니다. “이 값이면 AI가 쓴 글”이라고 말할 근거가 없고, 제 글이 어제의 제 글과 얼마나 다른지만 보여 줍니다.
  • 작은 차이는 의미가 없습니다. 한 글을 앞뒤 절반으로 나눠 재면 긴 문장 비율이 중앙값 5.6%포인트, 많게는 26.5%포인트 달랐습니다. 평균 문장 길이는 중앙값 4.5자 차이가 났습니다. 글 하나의 17%와 19%를 비교하는 건 소용없습니다.
  • 전후 비교의 원인은 하나가 아닙니다. 10월 7일 전후로 주제와 분량도 달라졌기 때문에, 위 표의 변화를 전부 문체 탓으로 볼 수 없습니다.
  • 숫자를 맞추려고 글을 고치면 안 됩니다. 고칠 위치를 찾는 데만 쓰고, 고친 뒤 사실이 바뀌지 않았는지는 사람이 다시 읽어야 합니다.

주의할 점

  • 코드 블록을 먼저 지우세요. 안 지우면 평균 길이가 24.4자처럼 그럴듯한 값으로 나와서 틀린 줄도 모릅니다.
  • 윈도우에서는 파일을 읽을 때 encoding="utf-8"을 꼭 적으세요. 표준 출력도 파이프로 받는다면 sys.stdout.reconfigure(encoding="utf-8")이 있어야 한글이 깨지지 않습니다.
  • 문장부호를 구절에서 빼세요. 안 빼면 됐습니다.와 됐습니다,가 다른 구절이 됩니다. 빼는 자리는 빈칸으로 두어야 합니다. 그냥 지웠더니 노트북(윈도우가 노트북윈도우로 붙었습니다.
  • 빈 파일은 평균을 낼 수 없습니다. 처음엔 StatisticsError로 멈춰서, 문장이 없으면 메시지를 내고 끝나도록 했습니다.
  • 기준값은 글 분량에 따라 다시 뽑으세요. 이 글의 표는 44~113문장짜리 글로 만든 값입니다.

마무리

글 한 편의 품질은 사람이 읽어야 알 수 있었습니다. 숫자가 찾아 준 것은 여러 편을 겹쳐 놓아야 보이는 습관이었습니다.

오늘 해 볼 일은 하나면 충분합니다. AI로 쓴 글을 10편 이상 텍스트 파일로 모아 repeat_phrases.py를 돌려 보세요. 절반 가까운 글에 나오는 구절이 있다면 AI에게 준 지시문에서 그 표현부터 찾아보면 됩니다. 이 글의 마지막 두 문단에는 그 두 구절을 쓰지 않았습니다.

이어지는 글의 주제는 반복 작업을 multiprocessing으로 나눠 돌렸을 때 시간이 실제로 얼마나 줄어드는지입니다.

댓글 남기기