본문으로 건너뛰기
wooncloud

macOS의 sort·uniq은 UTF-8 로케일에서 한글을 전부 같은 값으로 본다

macOS의 sort와 uniq 명령어가 UTF-8 환경에서 한글 등 비ASCII 문자를 동일하게 취급해 데이터를 유실하는 문제를 설명하고 LC_ALL=C를 통한 바이트 단위 비교 해결책과 검산의 중요성을 제안합니다

·8 min read· views·

서로 다른 두 물체를 같은 무게로 재는 저울

git 로그에서 작성자별 커밋 수를 세는, 어디서나 쓰는 한 줄짜리 집계를 돌렸다.

git log --pretty=format:'%an' | sort | uniq -c | sort -rn

결과는 195 홍길동 한 줄. 그런데 같은 데이터에 grep -c 를 걸면 다른 이름이 16건, 4건, 3건 분명히 나온다. 파이프 하나를 사이에 두고 사실이 뒤집힌 셈이다.

에러는 없었다. 종료 코드 0, 그럴듯한 표 한 장. 그대로 리포트에 실릴 뻔했다.

재현

범인은 uniq 이고, 조건은 로케일이다. macOS 기본값인 LANG=en_US.UTF-8 환경에서 세 글자를 넣어 보면 바로 드러난다.

printf '가\n나\n가\n다\n' > k.txt
 
LC_ALL=en_US.UTF-8 uniq -c k.txt
#    4 가          ← 서로 다른 세 글자가 한 값으로 합쳐졌다
 
LC_ALL=C uniq -c k.txt
#    2 가
#    1 나
#    1 다

정렬을 거치지 않아도 재현된다. uniq인접한 줄이 같은지만 보는데, 그 "같은지" 판정 자체가 무너진 것이다. sort -u 도 마찬가지로 하나만 남긴다.

LC_ALL=en_US.UTF-8 sort -u k.txt
# 가

왜 이런 일이

문자열 비교가 로케일의 collation(정렬 대조) 규칙을 타기 때문이다. POSIX 로케일 모델에서 sort·uniq 같은 도구는 바이트를 직접 비교하지 않고, 로케일이 정의한 대조 가중치(collation weight)로 비교한다. 두 문자열의 가중치가 같으면 "같은 값"이다.

macOS 가 쓰는 BSD 계열 도구 + 시스템 로케일 데이터에서는 en_US.UTF-8 의 대조 테이블이 한글 같은 비ASCII 문자를 제대로 다루지 못한다. 가중치를 뽑지 못하면 전부 동률이 되고, 동률이면 uniq 입장에서는 중복이다. 그래서 서로 다른 글자가 한 줄로 합쳐진다.

두 가지가 이 문제를 특히 고약하게 만든다.

  • 리눅스에서는 재현되지 않는다. GNU coreutils 는 같은 로케일에서 정상 동작하므로, CI(리눅스)에서 검증한 집계 스크립트가 로컬 맥에서만 조용히 틀린 숫자를 뱉는다. 반대 방향도 마찬가지다.
  • 한글만의 문제가 아니다. 일본어·중국어·키릴 문자·이모지 등 비ASCII 데이터 전반에 해당한다. 태그 집계, 카테고리 카운트, 로그의 유저 에이전트 통계 어디서나 터질 수 있다.

해결: 비교 단계의 로케일을 C 로 고정한다

LC_ALL=C 는 대조를 바이트 단위 비교로 떨어뜨린다. 사전순 한글 정렬은 포기하게 되지만, 동일성 판정은 정확해진다. 집계에서 필요한 건 후자다.

git log --pretty=format:'%an' | LC_ALL=C sort | LC_ALL=C uniq -c | LC_ALL=C sort -rn

주의할 점은 파이프의 모든 단계에 붙여야 한다는 것이다. sortC 로 두고 uniq 를 놔두면, 정렬은 제대로 되어도 병합 단계에서 그대로 뭉개진다. 거꾸로도 마찬가지다 — sort 가 로케일 규칙으로 "동률"이라 판단한 줄들을 임의 순서로 흩어 놓으면, C 로케일 uniq 이 인접 비교를 해도 이미 늦다.

스크립트 전체에 걸겠다면 맨 위에서 한 번 export 하는 편이 안전하다.

#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C   # 집계 정확도 우선

사람이 읽을 순서가 필요하면

역할을 나눈다. 집계는 C 로 정확하게 끝내고, 표시 순서는 마지막에 숫자 기준(sort -rn)으로 잡는다. 대부분의 리포트는 사전순이 아니라 빈도순이라 이걸로 충분하다.

사전순 한글 정렬 자체가 목적이라면 LC_ALL=C 가 답이 아니다. GNU coreutils 를 설치해 쓰는 편이 낫다.

brew install coreutils
gsort k.txt | guniq -c    # GNU 구현은 UTF-8 로케일에서 정상 동작

교차 검증을 습관으로

이 사례에서 오류를 잡아낸 건 grep -c '<이름>' 한 줄이었다. 파이프라인 결과를 다른 경로로 한 번 더 확인하는 습관이 없었다면 틀린 숫자를 그대로 보고했을 것이다.

집계 스크립트에 붙일 만한 최소한의 검산은 이런 것들이다.

# 1) 카운트 총합이 원본 줄 수와 일치하는가
LC_ALL=C sort f.txt | LC_ALL=C uniq -c | awk '{s+=$1} END {print "sum:", s}'
wc -l < f.txt
 
# 2) 고유값 개수가 두 방법에서 같은가
LC_ALL=C sort -u f.txt | wc -l
awk '!seen[$0]++' f.txt | wc -l    # awk 의 해시는 로케일 대조를 타지 않는다

awk 의 연관배열 키 비교는 바이트 기준이라 이 문제에서 자유롭다. 비ASCII 가 섞인 데이터를 셸로 집계할 때 sort | uniq -c 대신 awk 로 세는 것도 방법이다.

awk '{c[$0]++} END {for (k in c) print c[k], k}' f.txt | sort -rn

정리

  • macOS 의 sort·uniqen_US.UTF-8 에서 비ASCII 문자열을 동일하게 취급해 조용히 병합한다.
  • 에러도, 경고도, 종료 코드 이상도 없다. 결과 숫자만 틀린다.
  • 집계 파이프라인은 모든 단계에 LC_ALL=C 를 걸어 바이트 비교로 고정한다.
  • 사전순 정렬이 필요하면 GNU coreutils(gsort), 로케일 영향을 아예 피하려면 awk 해시 카운팅.
  • 비ASCII 데이터 집계 결과는 다른 방법으로 한 번 검산한다.

참고