배포판들이 기본 coreutils를 Rust로 작성된 uutils 구현으로 바꾸는 흐름이 이어지고 있다. 목표는 드롭인 호환이고, 대부분의 일상적인 사용에서는 차이를 느끼기 어렵다.
문제가 생기는 곳은 정해져 있다. 출력 문자열을 파싱하는 스크립트, 로케일에 의존하는 처리, 그리고 문서화되지 않은 동작에 기대는 코드다. 이 세 가지만 점검하면 전환 리스크의 대부분이 사라진다.
나는 배포 스크립트에서 ls -l 출력을 awk로 잘라 쓰는 코드를 발견하고 한숨을 쉰 적이 있다. 10년 전에 누가 짰는지 모르는 코드였고, 잘 돌아갔다. 그런 코드는 구현이 바뀔 때 조용히 틀린 값을 만든다. 에러도 안 난다. 그게 최악이다.
어디가 깨지나
| 영역 | 위험도 | 이유 |
|---|---|---|
| 출력 문자열 파싱 | 높음 | 메시지 문구·정렬·구분자가 구현마다 다를 수 있다 |
| 에러 메시지 매칭 | 높음 | 문구가 다르면 조건문이 통째로 무력화 |
| 로케일 의존 출력 | 중간 | 날짜·숫자 형식이 환경에 따라 달라진다 |
| 드물게 쓰는 플래그 | 중간 | 구현 범위 차이가 남아 있을 수 있다 |
| 종료 코드 | 낮음 | 대체로 호환되지만 엣지 케이스 확인 필요 |
| 성능 특성 | 낮음 | 대량 파일 처리에서 차이가 날 수 있다 |
색출 — 파싱 코드 찾기
# coreutils 출력을 파싱하는 패턴 찾기
grep -rnE '\b(ls|df|du|stat|date|wc|find)\b[^|]*\|[[:space:]]*(awk|cut|sed|grep)' \
--include='*.sh' --include='*.bash' --include='Makefile' .
# 에러 메시지에 의존하는 코드
grep -rnE '(No such file|Permission denied|cannot access)' --include='*.sh' .
# 파일 크기·권한을 텍스트로 파싱하는 곳
grep -rn "ls -l" --include='*.sh' .
# 더 나은 대안이 있는 경우가 대부분이다:
# 파일 크기 → stat -c %s file (또는 언어 API)
# 수정 시각 → stat -c %Y file
# 파일 개수 → find ... -printf '.' | wc -c
# 권한 비트 → stat -c %a file
stat의 포맷 지정자를 쓰면 출력이 기계 친화적이라 구현 간 차이에 훨씬 강하다. ls 출력을 자르는 코드는 거의 언제나 stat이나 find -printf로 대체할 수 있다.
검증 절차
# 두 구현의 출력을 비교하는 간단한 하네스
compare() {
local desc="$1"; shift
local a b
a=$(LC_ALL=C "$@" 2>&1; echo "exit=$?")
b=$(LC_ALL=C docker run --rm -v "$PWD:/w" -w /w <new-image> "$@" 2>&1; echo "exit=$?")
if [ "$a" != "$b" ]; then
echo "=== 차이: $desc"
diff <(echo "$a") <(echo "$b") | head -20
fi
}
compare "ls 상세" ls -l testdir
compare "df 헤더" df -h /
compare "없는 파일" cat /nonexistent
compare "권한 없음" cat /root/.bashrc
compare "stat 포맷" stat -c '%n %s %a' testdir/file
로케일을 고정하라
로케일에 따라 날짜 형식, 숫자 구분자, 정렬 순서, 에러 메시지 언어가 달라진다. 스크립트에서는 예외 없이 고정하는 것이 정석이다. 이건 구현 교체와 무관하게 이미 해야 할 일이다.
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C # 모든 출력·정렬을 예측 가능하게
export TZ=UTC # 시각 처리도 고정
# 정렬 순서가 로케일에 따라 달라지는 대표 사례
# LC_ALL=en_US.UTF-8 에서는 대소문자 혼합 정렬이 다르게 나온다
ls | sort # LC_ALL=C 고정 시 바이트 순서로 안정
안전한 대체 패턴 모음
| 하려는 일 | 취약한 방법 | 안정적인 방법 |
|---|---|---|
| 파일 크기 | ls -l | awk '{print $5}' | stat -c %s file |
| 수정 시각 | ls -l 파싱 | stat -c %Y file |
| 파일 존재 확인 | ls file 2>/dev/null | [ -e file ] |
| 파일 개수 | ls | wc -l | find . -maxdepth 1 -type f -printf . | wc -c |
| 디스크 여유 | df -h 파싱 | df -P --output=avail /path | tail -1 |
| 빈 디렉터리 확인 | ls -A dir | wc -l | find dir -mindepth 1 -maxdepth 1 -print -quit |
오른쪽 방식들은 구현이 바뀌어도, 파일 이름에 공백이나 개행이 들어 있어도 잘 동작한다. 후자는 구현 교체보다 훨씬 자주 마주치는 버그 원인이다.
전환 판단
- 메모리 안전성 관련 취약점 클래스 감소
- 배포판 기본값이 그쪽으로 이동 중
- 새 환경에서는 어차피 기본이 된다
- 기능적 이득이 사용자에게 보이지 않는다
- 검증 비용이 조직 규모에 비례
- LTS 환경이라면 시간이 있다
요약하면 이렇다. 지금 해야 할 일은 전환이 아니라 검증이다. 그리고 그 검증 과정에서 나오는 수정 사항은 대부분 원래부터 고쳤어야 할 것들이다.
자주 묻는 질문
GNU coreutils에서 uutils로 바꾸면 무엇이 달라지나요?
일상적인 사용에서는 거의 차이가 없습니다. 드롭인 호환을 목표로 하며 차이가 발견되면 버그로 취급됩니다. 변화의 목적은 기능이 아니라 메모리 안전성 관련 취약점 클래스를 줄이는 것입니다.
어떤 스크립트가 깨질 가능성이 높나요?
명령 출력 문자열을 awk나 cut으로 파싱하는 스크립트, 에러 메시지 문구로 분기하는 코드, 로케일 의존 출력에 기대는 처리입니다. 종료 코드나 기본 플래그에 의존하는 코드는 대체로 안전합니다.
어떻게 검증하면 되나요?
새 구현이 기본인 배포판 컨테이너를 하나 띄우고 기존 CI·배포·백업 스크립트를 그대로 돌려 보세요. 로케일을 LC_ALL=C로 고정한 상태에서 두 구현의 출력과 종료 코드를 비교하면 차이를 빠르게 찾을 수 있습니다.
ls 출력을 파싱하는 코드는 어떻게 고치나요?
stat의 포맷 지정자나 find의 printf를 사용하세요. 파일 크기는 stat -c %s, 수정 시각은 stat -c %Y로 얻을 수 있으며, 출력이 기계 친화적이라 구현 차이와 파일 이름의 특수문자 양쪽에 강합니다.
지금 서버를 바꿔야 하나요?
급하지 않습니다. 단기 지원 릴리스에서 먼저 기본값이 바뀌는 경우가 많으므로, CI 러너나 테스트 환경을 새 구현으로 돌려 보며 다음 LTS 전환에 대비하는 편이 비용 대비 효과가 큽니다.
로케일 고정이 왜 중요한가요?
날짜·숫자 형식, 정렬 순서, 에러 메시지 언어가 로케일에 따라 달라지기 때문입니다. 스크립트에서 LC_ALL=C와 TZ를 고정하면 환경 차이로 인한 오작동이 크게 줄고, 구현 교체 시에도 비교가 쉬워집니다.

댓글 0