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.9 | 42.4 | 47.5 | 54.0 | – |
| 긴 문장 비율(%) | 2.3 | 17.0 | 26.8 | 34.5 | 27 초과 |
| 최대 끝말 연속 | 1 | 3 | 7 | 15 | 7 초과 |
| 과거형 비율(%) | 0.0 | 3.5 | 42.7 | 60.3 | – |
| 상투 표현(1000자당) | 0.0 | 1.0 | 1.9 | 3.1 | 2 초과 |
바꾼 뒤에는 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,814 | 3,563 |
| 평균 문장 길이(자) | 40.0 | 46.0 |
| 긴 문장 비율(%) | 13.8 | 23.0 |
| 최대 끝말 연속 | 3 | 5 |
| 상위 끝말 비율(%) | 24.2 | 51.0 |
| 과거형 비율(%) | 2.5 | 37.8 |
| 상투 표현(1000자당) | 1.1 | 0.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으로 나눠 돌렸을 때 시간이 실제로 얼마나 줄어드는지입니다.