Published on

bash에서 sed가 파일 망칠 때 안전 치환 7가지

Authors

서버에서 설정 파일, 소스 코드, 로그 포맷을 한 번에 바꾸려다 sed -i 한 방에 파일을 망가뜨린 경험은 흔합니다. 문제는 sed 자체가 위험하다기보다, 인플레이스 편집(-i)이 OS와 파일시스템, 권한, 인코딩, 심볼릭 링크, 동시성에 따라 예상 밖의 결과를 만들기 쉽다는 점입니다.

이 글은 bash 환경에서 “치환은 해야 하는데 파일은 절대 망치면 안 된다”는 전제하에, 안전도를 높이는 7가지 치환 패턴을 정리합니다. 각 방법은 상황별로 장단점이 다르니, 팀의 운영 환경과 리스크 허용치에 맞춰 선택하면 됩니다.

참고로 이런 종류의 사고는 재현이 어렵고, 장애 원인 추적 비용이 큽니다. 운영에서 원인 추적을 체계화하는 관점은 Linux OOM Killer 원인추적 - dmesg·cgroup·로그 글의 접근과도 결이 비슷합니다.

sed가 파일을 망치는 대표 시나리오

먼저 “왜 망가지는지”를 짧게 정리합니다.

  • GNU sed와 BSD sed 차이: macOS 기본 sed(BSD)는 -i 옵션 동작이 다릅니다. sed -i '' 같은 형태가 필요하고, 실수로 백업 확장자를 잘못 주면 의도치 않은 파일이 생기거나 실패합니다.
  • 부분 쓰기와 중단: 인플레이스 편집 중 프로세스가 죽거나 디스크가 꽉 차면, 파일이 반쯤만 써진 상태로 남을 수 있습니다.
  • 심볼릭 링크/권한/소유권 변화: 구현에 따라 임시 파일을 만들고 교체하는 과정에서 권한, 소유권, ACL, xattr이 달라질 수 있습니다.
  • 개행/인코딩 이슈: CRLF, UTF-8 BOM, 마지막 줄 개행 유무 등이 바뀌어 diff가 커지거나 도구가 오동작합니다.
  • 치환식 변수 확장 문제: 쉘에서 특수문자가 확장되어 정규식이 망가지거나, 백슬래시가 소실됩니다.

이제부터는 위 리스크를 줄이는 “안전 치환 7가지”를 제시합니다.

1) 인플레이스 대신 임시 파일 + 원자적 교체 mv

가장 기본이면서도 효과가 큽니다. 핵심은 sed 결과를 새 파일에 쓰고, 검증 후 원자적으로 교체하는 것입니다.

set -euo pipefail

file="app.conf"
tmp="${file}.tmp.$$"

# 1) 새 파일 생성
sed 's/OLD_VALUE/NEW_VALUE/g' "$file" > "$tmp"

# 2) 권한/소유권을 최대한 유지하고 싶다면 원본 메타를 복사
# (환경에 따라 필요)
# chmod --reference="$file" "$tmp" 2>/dev/null || true
# chown --reference="$file" "$tmp" 2>/dev/null || true

# 3) 교체(같은 파일시스템 내 mv는 보통 원자적)
mv "$tmp" "$file"

장점

  • sed가 실패해도 원본 파일이 그대로 남습니다.
  • 중간에 죽어도 원본 파일이 반쯤 써지는 일을 줄입니다.

주의

  • mv가 원자적이려면 대체로 같은 파일시스템이어야 합니다. 임시 파일은 원본과 같은 디렉터리에 만드는 편이 안전합니다.

2) mktemptrap으로 임시 파일 누수 방지

운영 서버에서 반복 실행되는 스크립트는 임시 파일이 쌓여 디스크를 압박할 수 있습니다. mktemptrap을 조합해 정리 루틴을 강제합니다.

set -euo pipefail

file="app.conf"
tmp="$(mktemp "${file}.XXXXXX")"

cleanup() {
  rm -f "$tmp"
}
trap cleanup EXIT INT TERM

sed 's/OLD_VALUE/NEW_VALUE/g' "$file" > "$tmp"

# 간단 검증 예시: 치환 후 금지 문자열이 남아 있지 않은지
if grep -q 'OLD_VALUE' "$tmp"; then
  echo "replacement incomplete" >&2
  exit 1
fi

mv "$tmp" "$file"

이 패턴은 “중간 실패 시 원본 보존”뿐 아니라 “실패 흔적 최소화”에도 좋습니다.

3) sed -i를 써야 한다면, 백업 확장자를 강제

그럼에도 -i가 꼭 필요한 경우가 있습니다. 파일이 너무 커서 임시 파일 방식이 부담이거나, 특정 툴 체인에서 인플레이스가 전제일 때입니다.

이때는 최소한 백업 파일을 남기도록 강제합니다.

# GNU sed (리눅스)
sed -i.bak 's/OLD_VALUE/NEW_VALUE/g' app.conf

# BSD sed (macOS)
# sed -i '.bak' 's/OLD_VALUE/NEW_VALUE/g' app.conf

팁: GNU/BSD 차이를 흡수하는 함수

sedi() {
  # 사용법: sedi 's/a/b/' file
  local expr="$1"
  local file="$2"

  if sed --version >/dev/null 2>&1; then
    # GNU sed
    sed -i.bak "$expr" "$file"
  else
    # BSD sed
    sed -i '.bak' "$expr" "$file"
  fi
}

sedi 's/OLD_VALUE/NEW_VALUE/g' app.conf

백업이 남으면 롤백이 쉬워지고, CI에서도 “치환 전후 diff”를 비교하기가 편해집니다.

4) 치환 전후를 diff로 검증하고 조건부 반영

안전한 변경의 핵심은 “바꿨다”가 아니라 “의도대로만 바뀌었다”입니다. 임시 파일 패턴에 diff를 끼워 넣어 검증을 자동화합니다.

set -euo pipefail

file="app.conf"
tmp="$(mktemp "${file}.XXXXXX")"
trap 'rm -f "$tmp"' EXIT

sed 's/OLD_VALUE/NEW_VALUE/g' "$file" > "$tmp"

# 변경이 아예 없으면 교체하지 않음
if diff -q "$file" "$tmp" >/dev/null; then
  echo "no changes"
  exit 0
fi

# 변경량이 너무 크면 중단(예: 200줄 초과 변경 금지)
changed_lines="$(diff -u "$file" "$tmp" | grep -E '^[+-]' | wc -l | tr -d ' ')"
if [ "$changed_lines" -gt 200 ]; then
  echo "too many changes: ${changed_lines}" >&2
  exit 1
fi

mv "$tmp" "$file"

이 방식은 배포 자동화에서 특히 유용합니다. 캐시나 환경 차이로 예상보다 큰 변경이 발생하는 문제는 GitHub Actions 캐시 미스 원인 7가지와 해결 같은 글에서 다루는 “환경 비결정성”과 같은 결로 관리해야 합니다.

5) 치환 대상이 확실할 때만 실행: 앵커와 컨텍스트 사용

sed 's/foo/bar/g'는 너무 강력합니다. 의도치 않은 곳까지 바뀌는 순간 파일이 “망가진 것처럼” 보입니다. 안전도를 올리려면 범위를 좁히는 조건을 붙입니다.

특정 섹션/라인에서만 치환

# 예: 설정 파일에서 'server {' 블록 내부에서만 치환하고 싶을 때(간단 예)
sed '/server[[:space:]]*{/,/}/ s/OLD_VALUE/NEW_VALUE/g' nginx.conf

라인 시작 앵커로 오탐 줄이기

# 예: KEY= 형태의 라인만 변경
sed 's/^MY_KEY=.*/MY_KEY=NEW_VALUE/' app.env

주의

정규식이 복잡해질수록 테스트가 중요합니다. 가능하면 샘플 파일을 고정해 두고 스크립트를 여러 번 돌려도 결과가 안정적인지 확인하세요.

6) 쉘 변수/특수문자 안전하게 넣기: 구분자 변경과 이스케이프

치환 문자열에 /, &, \, 개행 등이 섞이면 sed가 예상과 다르게 동작합니다. 안전하게 하려면 다음 두 가지를 같이 씁니다.

  • 구분자를 / 대신 | 같은 것으로 바꾸기
  • 치환 문자열을 sed용으로 이스케이프하기
escape_sed_repl() {
  # sed replacement에서 의미 있는 문자: '&'와 '\\'
  # 필요에 따라 구분자 '|'도 이스케이프
  printf '%s' "$1" | sed -e 's/[\\&|]/\\\\&/g'
}

old='http://old.example.com/a/b'
new='http://new.example.com/a/b?x=1&y=2'

new_esc="$(escape_sed_repl "$new")"

sed "s|$old|$new_esc|g" input.txt

이 패턴을 쓰면 URL, JSON 조각, 쿼리스트링처럼 특수문자가 많은 값도 비교적 안전하게 다룰 수 있습니다.

7) 대량 변경은 “리허설 모드”를 만들고, 실패 시 롤백 가능하게

운영에서 가장 위험한 건 “스크립트 한 번에 수백 파일 변경”입니다. 안전 장치를 코드로 박아두면 실수를 크게 줄일 수 있습니다.

--dry-run 옵션 구현 예시

set -euo pipefail

DRY_RUN=0
if [ "${1:-}" = "--dry-run" ]; then
  DRY_RUN=1
  shift
fi

expr='s/OLD_VALUE/NEW_VALUE/g'
file="${1:?file required}"

tmp="$(mktemp "${file}.XXXXXX")"
trap 'rm -f "$tmp"' EXIT

sed "$expr" "$file" > "$tmp"

if diff -u "$file" "$tmp"; then
  echo "no changes"
  exit 0
fi

if [ "$DRY_RUN" -eq 1 ]; then
  echo "dry-run: not applying"
  exit 0
fi

# 적용 전 백업(원자적 교체와 별개로, 사람이 복구하기 쉬운 백업)
cp -p "$file" "${file}.bak.$(date +%Y%m%d%H%M%S)"

mv "$tmp" "$file"

운영 팁

  • 배포 파이프라인에서는 기본값을 --dry-run으로 두고, 승인된 단계에서만 실제 적용하게 만드는 편이 안전합니다.
  • 변경 작업 자체를 “재시도 가능한 작업”으로 모델링하는 것도 중요합니다. 실패 시 재시도, 백오프, 중복 실행 안전성 같은 관점은 OpenAI 429·insufficient_quota 재시도와 백오프 설계에서 다루는 패턴을 파일 변경 작업에도 응용할 수 있습니다.

체크리스트: 어떤 방법을 언제 쓰나

  • 단일 파일, 중요도 높음: 임시 파일 + mvdiff 검증을 붙이기
  • macOS와 Linux 혼용: sedi 래퍼로 -i 차이를 흡수하거나, 아예 -i를 피하기
  • 치환 문자열이 복잡: 구분자 변경 + 치환 문자열 이스케이프
  • 대량 변경/운영 반영: --dry-run, 변경량 제한, 백업 파일 보관 정책

마무리

sed는 강력하지만, 인플레이스 치환은 “편한 만큼 위험”합니다. 안전 치환의 본질은 도구가 아니라 절차입니다. 새 파일에 생성하고, 검증하고, 원자적으로 교체하고, 롤백 가능하게 만들면 sed는 오히려 가장 예측 가능한 자동화 도구가 됩니다.

위 7가지를 스크립트 템플릿으로 만들어 두면, 다음에 급하게 운영 파일을 바꿔야 할 때도 사고 확률을 크게 낮출 수 있습니다.