배포 스크립트의 current 링크 바꾸기, ln -sfn 과 mv -T 는 어디서 갈리나
환경 GNU coreutils 8.32 · BusyBox 1.30.1 · Ubuntu 22.04 · macOS 26 실측 · 2026-10-12 확인
새 릴리스를 releases/ 아래에 풀고, current 링크만 새 폴더로 돌리는 배포 스크립트가 있다. 링크를 바꾸는 줄은 ln -sf 한 줄이다. 배포 로그는 매번 성공인데 어느 날 보니 서비스는 여전히 지난주 코드로 돌고 있고, 지난주 릴리스 폴더 안에 처음 보는 링크 파일이 하나 생겨 있다.
두 방식은 결국 같은 일을 하려는 것이다
releases/ 에 릴리스를 날짜별로 쌓아 두고 current 라는 심볼릭 링크 하나로 지금 쓰는 쪽을 가리키는 구조는 흔하다. 웹 서버 설정과 서비스 유닛은 current 만 바라본다. 배포는 새 폴더를 만들고 링크를 옮기는 것으로 끝나고, 되돌리기도 링크를 다시 옮기면 된다.
링크를 옮기는 방법은 크게 둘이다. 기존 링크 자리에 ln -sfn 으로 새 링크를 덮어쓰는 방법, 그리고 옆에 임시 링크를 만들어 두고 mv -T 로 current 자리에 밀어 넣는 방법이다. 결과는 같아 보이지만 옵션 하나 빠졌을 때 망가지는 모양, 그리고 바뀌는 순간에 틈이 있느냐 없느냐가 다르다.
먼저 나란히 놓고 본다.
ln -sfn new current ln -s new tmp && mv -T tmp current
--------------------------- --------------------------- ----------------------------------
한 줄로 끝나나 그렇다 두 줄 (임시 링크 이름이 필요하다)
옵션이 빠지면 -n 이 빠지면 옛 폴더 안에 -T 가 빠지면 tmp 가 옛 폴더 안으로
링크가 새로 생긴다 옮겨 간다
바꾸는 순간 current 가 비나 GNU 8.32: 비지 않는다 비지 않는다 (rename 한 번)
BusyBox: 잠깐 빈다
macOS 기본 도구 -n(-h) 있음, 동작은 unlink -T 없음, 대신 mv -h
Alpine 같은 BusyBox 이미지 -n 있음, unlink 후 생성 1.30.1 기준 -T 없음같은 일을 하는 두 줄, 갈리는 자리는 옵션과 교체 순간이다
결과는 같아 보여도, 옵션 하나가 빠졌을 때와 바뀌는 그 순간에 둘은 다르게 움직인다.
-n 이 빠진 ln 은 링크를 바꾸지 않고 폴더 안에 하나를 더 만든다
도입부의 사고가 이것이다. current 가 디렉터리를 가리키는 링크면 ln 은 current 를 디렉터리로 본다. 마지막 인자가 디렉터리면 그 안에 링크를 만드는 게 ln 의 기본 형식이라, ln -sf new current 는 current 를 고치지 않고 옛 릴리스 폴더 안에 new 라는 링크를 하나 더 만든다. 에러는 없다. 종료 코드도 0 이다.
-n(--no-dereference)은 이 해석을 막는다. 링크 이름이 디렉터리를 가리키는 링크여도 보통 파일처럼 다룬다. 그래서 current 자체를 지우고 새로 만든다. -T 를 줘도 같은 효과가 난다. 마지막 인자를 언제나 링크 이름으로만 보라는 뜻이다.
Ubuntu 22.04 서버에서 직접 돌려 보면 차이가 그대로 드러난다. -n 을 뺀 쪽은 current 가 r1 에 그대로 남고, r1 안에 r2 를 가리키는 링크가 생긴다. 상대 경로로 만든 링크라 그 안에서 보면 자기 자신을 가리키는 깨진 링크다.
mkdir r1 r2 && ln -s r1 current
ln -sf r2 current # -n 없음
ls -l current r1
# current -> r1 <- 그대로다
# r1/r2 -> r2 <- 옛 폴더 안에 링크가 하나 생겼다
ln -sfn r2 current # -n 있음
ls -l current
# current -> r2-n 하나로 '링크를 바꾸는 일'과 '폴더 안에 링크를 만드는 일'이 갈린다
mv 도 -T 가 없으면 같은 함정에 빠진다
mv 쪽이 안전하다고 해서 옵션을 생략해도 된다는 얘기는 아니다. current 가 디렉터리를 가리키는 링크면 mv tmp current 는 tmp 를 current 가 가리키는 폴더 안으로 옮긴다. 이것도 조용히 성공한다. 서버에서 재 보면 current 는 r1 그대로고 r1/tmp 가 새로 생긴다.
-T(--no-target-directory)를 주면 mv 는 마지막 인자를 목적지 폴더로 보지 않고 바꿀 이름으로 본다. 그러면 tmp 가 current 라는 이름으로 바뀌고, 원래 있던 current 링크는 그 자리에서 덮인다.
macOS 의 mv 에는 -T 가 없다. 대신 -h 가 있다. 대상이 디렉터리를 가리키는 링크면 따라가지 말고 그 이름으로 바꾸라는 옵션이라 같은 자리를 맡는다. BusyBox 1.30.1 의 mv 는 -f·-i·-n 만 받는다. -T 를 주면 옵션 오류로 끝나고 링크는 그대로다. Alpine 기반 이미지 안에서 같은 배포 스크립트를 돌린다면 이 점부터 확인해 둔다.
ln -s r2 tmp
mv tmp current # -T 없음
ls -l current r1
# current -> r1
# r1/tmp -> r2 <- 폴더 안으로 들어가 버렸다
ln -s r2 tmp
mv -T tmp current # GNU coreutils
mv -h tmp current # macOS
ls -l current
# current -> r2mv 도 '이름 바꾸기'로 읽게 하려면 -T(macOS 는 -h)가 있어야 한다
바뀌는 그 순간에 current 가 비느냐는 구현마다 다르다
mv -T 가 안전한 이유는 결국 rename 시스템 콜 하나로 끝나기 때문이다. rename 은 새 이름이 이미 있으면 그 자리를 원자적으로 바꾼다. 다른 프로세스가 그 이름을 열려고 할 때 파일이 없는 순간을 만나지 않는다. 대상이 심볼릭 링크면 링크 자체가 덮인다. strace 로 보면 GNU mv -T 는 먼저 RENAME_NOREPLACE 로 renameat2 를 시도했다가 EEXIST 를 받고 rename 으로 덮는다.
ln -sfn 은 구현에 따라 다르다. -f 의 정의는 '있는 대상을 지운다'뿐이라 지우고 새로 만드는 방식도 정의에 맞는다. BusyBox 1.30.1 이 그렇게 한다. unlink 로 current 를 지우고 symlink 로 다시 만든다. 그 사이 아주 짧은 순간 current 가 없다. 요청이 많은 서버라면 그 틈에 404 나 '파일 없음'이 섞일 수 있다.
GNU coreutils 8.32 의 ln -sfn 은 그렇지 않았다. 먼저 current 이름으로 만들어 보다가 EEXIST 를 받으면, 무작위 임시 이름으로 링크를 만들고 renameat 으로 current 를 덮는다. mv -T 와 같은 결과다. macOS 의 ln 은 매뉴얼부터 '이미 있으면 unlink 한다'고 적고 있다. 결국 ln -sfn 이 틈 없이 바뀌는지는 어느 ln 이 돌고 있느냐에 달렸다.
# GNU coreutils 8.32
strace -e trace=symlinkat,unlinkat,renameat ln -sfn r2 current
# symlinkat("r2", AT_FDCWD, "current") = -1 EEXIST
# symlinkat("r2", AT_FDCWD, "Cu3Hq3eb") = 0
# renameat(AT_FDCWD, "Cu3Hq3eb", AT_FDCWD, "current") = 0
# BusyBox 1.30.1
strace -e trace=symlink,unlink busybox ln -sfn r2 current
# unlink("current") = 0 <- 여기서부터
# symlink("r2", "current") = 0 <- 여기까지 current 가 없다같은 ln -sfn 이 GNU 에서는 rename 으로, BusyBox 에서는 지우고 다시 만드는 식으로 돈다
mv -T 는 어디서든 rename 한 번이다. ln -sfn 은 돌고 있는 ln 이 무엇이냐에 따라 틈이 생기기도 한다.
그래서 배포 스크립트에는 이렇게 적는다
GNU coreutils 가 깔린 리눅스 서버에서 손으로 링크를 돌릴 때는 ln -sfn 한 줄이면 충분하다. 짧고, 옵션만 지키면 결과도 같다.
스크립트가 여러 환경을 돈다면 임시 링크를 만들고 mv -T 로 넣는 쪽을 권한다. 리눅스 배포판과 BusyBox, 오래된 coreutils 를 가리지 않고 rename 한 번으로 바뀐다. 임시 링크는 current 와 같은 폴더에 만든다. rename 은 마운트 지점을 넘지 못한다. 넘어가는 순간 mv 는 복사 후 삭제로 바뀌고, 원자성도 사라진다.
macOS 와 리눅스를 같이 지원해야 하면 mv -T 와 mv -h 를 운영체제로 갈라 쓰거나, 리눅스 쪽만 지원한다고 정하는 편이 낫다. 옵션 없이 mv 만 쓰는 타협은 하지 않는다. 앞에서 본 것처럼 그건 조용히 틀린다.
어느 쪽이든 마지막 줄은 같다. 링크가 실제로 새 폴더를 가리키는지 readlink 로 읽고, 다르면 실패로 끝낸다. 도입부의 배포 로그가 매번 성공이었던 건 이 확인이 없어서다.
#!/usr/bin/env bash
set -euo pipefail
REL="releases/$(date +%Y%m%d%H%M%S)"
# ... 새 릴리스를 $REL 에 푼다 ...
# current 와 같은 폴더에 임시 링크를 만든다
ln -sfn "$REL" current.tmp
mv -T current.tmp current # macOS 라면 mv -h
# 실제로 바뀌었는지 읽어서 확인한다
if [ "$(readlink current)" != "$REL" ]; then
echo "current 가 $REL 를 가리키지 않는다" >&2
exit 1
fi임시 링크 + mv -T, 그리고 readlink 로 결과를 읽는 줄까지가 한 세트다
조치 후 확인할 것
- 배포 스크립트의 링크 교체 줄에 ln 이면 -n, mv 면 -T(macOS 는 -h)가 붙어 있는지 본다.
- 옛 릴리스 폴더 안에 이름 모를 링크(new·tmp·current.tmp 같은 것)가 생겨 있지 않은지 find releases -maxdepth 2 -type l 로 훑는다.
- 컨테이너 안에서 돈다면 ln --version 이나 busybox 로 어느 구현인지 확인하고, BusyBox 면 mv -T 가 먹히는지 직접 한 번 돌려 본다.
- 임시 링크가 current 와 같은 폴더(같은 파일 시스템)에 만들어지는지 확인한다.
- 교체 직후 readlink current 를 읽어 새 릴리스 경로와 맞지 않으면 실패로 끝나게 한다.
링크 하나 바꾸는 줄은 배포 스크립트에서 가장 짧고, 그래서 가장 덜 들여다본다. 옵션이 하나 빠져도 에러 없이 끝나니 로그로는 알 수가 없다. 링크를 바꾼 다음 줄에서 readlink 로 한 번 읽어 보는 것, 그 한 줄이 이 부류의 사고를 거의 다 막는다.