클라우드 Mac CI 심볼릭 링크 감사 실전

클라우드 Mac CI 심볼릭 링크 감사 실전

개발자 Mac에서는 같은 프로젝트가 정상적으로 아카이브되는데 클라우드 Mac CI에서는 리소스가 없다는 오류가 발생한다면, 가장 놓치기 쉬운 원인 중 하나가 심볼릭 링크다. 저장소에서 평범한 파일처럼 보이는 항목이 실제로는 로컬 디렉터리나 커밋되지 않은 대상, 또는 작업 공간 외부 위치를 가리킬 수 있다. Xcode가 리소스를 복사하거나 스크립트를 실행하고 의존성을 패키징하는 시점에야 해당 파일에 접근하면, 문제는 빌드 후반부에 가서야 드러난다. 이때 로그에는 흔히 일반적인 “파일을 찾을 수 없음” 오류로만 표시된다.

허용할 링크부터 정의하기

검사 스크립트부터 작성하지 말고 먼저 정책을 명확히 정해야 한다. 지속적 빌드에 적합한 기본 규칙은 보통 세 가지다. 링크는 상대 경로를 사용해야 하고, 경로를 해석한 대상이 실제로 존재해야 하며, 그 대상이 현재 작업 공간 안에 있어야 한다. 일부 도구가 만드는 내부 링크는 예외로 둘 수 있지만, 예외는 완전한 상대 경로 단위로 정확하게 지정해야 한다.

심볼릭 링크 게이트의 목적은 링크 자체를 없애는 것이 아니라, 특정 컴퓨터의 디렉터리 구조에 대한 암묵적인 의존성을 제거하는 데 있다.

예를 들어 Config/current.json -> release.json은 저장소를 체크아웃할 때도 안정적으로 유지된다. 반면 SDK/current -> /Users/dev/SDK는 로컬 컴퓨터의 상태를 파이프라인으로 끌어들인다. ../Shared/file 역시 문자열만 보고 판단해서는 안 된다. 대상이 여전히 저장소 내부에 있을 수도 있지만 CI가 할당한 작업 디렉터리를 벗어날 수도 있으므로, 경로를 해석하고 정규화한 뒤 경계를 비교해야 한다.

검사 항목 허용 차단
링크 형식 저장소 내부 상대 경로 절대 경로
대상 상태 대상이 존재하고 읽을 수 있음 끊어진 링크 또는 순환 링크
경로 경계 해석한 경로가 작업 공간 내부에 있음 작업 공간 외부를 가리킴
예외 규칙 완전한 상대 경로 광범위한 디렉터리 접두사

Git 인덱스에서 링크 여부 확인하기

파일 확장자가 아닌 모드 확인하기

Git은 심볼릭 링크를 120000 모드로 기록하고 링크 대상은 파일 내용으로 저장한다. 먼저 인덱스에서 링크를 나열하면 일반 텍스트 파일이나 빌드 후 생성된 링크가 섞이는 일을 피할 수 있다.

git ls-files -s | awk '$1 == "120000" { print $4 }'
git config --show-origin --get core.symlinks || true
git status --porcelain=v1

macOS에서는 체크아웃된 객체가 실제 링크여야 한다. 저장소 상태에서 링크가 갑자기 일반 파일로 바뀐 것으로 나타난다면, 먼저 패키징이나 압축 해제, 동기화 단계가 파일 유형을 변경했는지 확인한다. 또한 깨끗하게 체크아웃한 뒤 게이트에서 git diff --exit-code를 한 번 실행해, 초기화 스크립트가 인덱스에 기록된 링크 문자열을 몰래 수정하지 않았는지 확인해야 한다.

추적되지 않는 대상 찾기

링크 자체가 커밋되어 있다고 해서 대상까지 커밋된 것은 아니다. Git이 추적하는 각 링크에 대해 대상이 존재하는지 확인하고, 저장소 내부 대상은 git ls-files --error-unmatch로 버전 관리 대상인지 판별해야 한다. 대상이 원래 빌드 과정에서 생성되는 파일이라면 검사를 생성 단계 이후로 옮기되, 생성 전에는 일시적으로 없어도 되는 대상을 완전한 경로로 정확히 지정한 목록을 유지해야 한다.

경계 검사 스크립트로 끊어진 링크와 경로 이탈 차단하기

아래 스크립트는 지정한 디렉터리를 재귀적으로 검사한다. 절대 경로 링크, 해석할 수 없는 대상, 경로를 해석했을 때 루트 디렉터리 밖에 있는 대상을 거부한다. 스크립트를 ci/check_symlinks.py로 저장하고 의존성을 설치한 뒤, xcodebuild를 호출하기 전에 실행한다.

import os
import sys
from pathlib import Path

root = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
failures = []

for path in root.rglob("*"):
    if not path.is_symlink():
        continue

    raw = os.readlink(path)
    relative = path.relative_to(root)

    if os.path.isabs(raw):
        failures.append((relative, "absolute", raw))
        continue

    try:
        target = path.resolve(strict=True)
    except (FileNotFoundError, RuntimeError, OSError) as error:
        failures.append((relative, "broken", str(error)))
        continue

    try:
        target.relative_to(root)
    except ValueError:
        failures.append((relative, "outside", str(target)))

for item in failures:
    print("\t".join(map(str, item)))

sys.exit(1 if failures else 0)

다음과 같이 실행한다.

python3 ci/check_symlinks.py "$PWD"

경로를 단순한 문자열 접두사로 비교해서는 안 된다. /tmp/job-10은 문자열상 /tmp/job-1로 시작하지만 그 하위 디렉터리는 아니다. 경로를 정규화한 뒤 계층 구조를 기준으로 비교해야 이런 오판을 피할 수 있다.

빌드 전후로 한 번씩 검사하기

소스 코드 검사만으로는 저장소에 이미 존재하는 문제만 찾을 수 있다. 의존성 관리자와 코드 생성기, 사용자 정의 Build Phase가 새 링크를 만들 수도 있다. 따라서 검사 지점을 두 곳에 두는 것이 좋다.

빌드 전에 작업 공간 검사하기

의존성 해석과 코드 생성이 끝나면 전체 작업 공간을 검사한다. 단, 도구 내부 구현에만 사용되고 결과물에 포함되지 않는 것이 명확한 캐시 디렉터리는 제외할 수 있다. 제외 규칙 역시 저장소에 고정해야 하며, runner의 임시 환경 변수로 임의로 범위를 넓힐 수 없게 해야 한다. 그런 다음 링크 목록을 기록해 빌드 중 새로 추가된 항목을 비교할 수 있도록 한다.

find "$PWD" -type l -print | LC_ALL=C sort > "$TMPDIR/symlinks-before.txt"
python3 ci/check_symlinks.py "$PWD"

빌드 후 결과물 검사하기

아카이브가 완료되면 내보낸 .app 또는 압축을 해제한 결과물의 루트 디렉터리에서 같은 스크립트를 다시 실행한다. 정상 링크로 인정받으려면 “대상이 여전히 결과물 내부에 있음”과 “경로가 허용 목록에 포함됨”을 모두 충족해야 한다. 서드파티 프레임워크에 실제로 내부 링크가 포함되어 있다면 Frameworks 디렉터리 전체를 허용하지 말고 구체적인 경로만 허용해야 한다.

이어서 빌드 전후의 링크 목록을 비교한다. 스크립트 단계에서 추가된 링크라면 명확한 생성 명령과 대응되어야 한다. 출처를 설명할 수 없는 새 항목은 우선 배포를 차단한 뒤 빌드 로그에서 생성 주체를 찾아야 한다.

일반적인 오탐을 처리하고 게이트로 정착시키기

가장 흔한 오탐은 임시 디렉터리와 의존성 캐시, 아카이브 내부 구조에서 발생한다. 먼저 검사 루트 범위를 줄이고, 그다음 정확한 예외를 추가하며, 디렉터리 제외는 마지막 수단으로 고려해야 한다. 특정 의존성이 링크를 만든다는 이유로 의존성 트리 전체를 건너뛰면 실제로 끊어진 링크까지 함께 숨겨진다.

게이트가 실패하면 최소한 링크의 상대 경로와 실패 유형, 해석된 대상을 출력해야 하며 검사 스크립트의 종료 코드를 CI에 그대로 전달해야 한다. 수정 후에는 초기화 스크립트가 이미 실행된 작업 공간에서 검사만 다시 수행하지 말고, 완전히 새로 체크아웃한 환경에서 검증해야 한다. 최종 검사 항목은 다음 여섯 가지로 정리할 수 있다. Git 모드가 올바른지, 대상이 버전 관리되는지, 링크가 상대 경로인지, 해석한 경로가 경계를 벗어나지 않는지, 빌드 중 추가된 링크의 출처를 설명할 수 있는지, 아카이브 예외가 정확한 경로와 일치하는지 확인한다. 이 여섯 가지를 충족하면 심볼릭 링크는 간헐적인 환경 문제가 아니라 감사 가능한 엔지니어링 계약이 된다.

자주 묻는 질문

로컬에서 동작하는 링크가 클라우드 Mac CI에서 끊기는 이유는 무엇인가요?

로컬 절대 경로를 참조하거나 대상 파일이 Git에 없거나 체크아웃 위치를 가정하기 때문입니다. 저장된 링크 문자열과 해석된 대상 경로를 함께 검사해야 합니다.

저장소의 모든 심볼릭 링크를 금지해야 하나요?

그럴 필요는 없습니다. 저장소 내부에서 끝나는 안정적인 상대 링크는 허용할 수 있습니다. 끊어진 링크, 절대 링크와 작업 공간 외부 참조만 기본적으로 거부합니다.

아카이브 안의 정상 링크는 어떻게 예외 처리하나요?

해석된 대상이 아카이브 루트 안에 있는지 먼저 확인하고 정확한 상대 경로만 허용 목록에 넣습니다. 파일명이나 넓은 접두어 기준 허용은 피해야 합니다.

다음 작업 실행

워크로드에 맞는 클라우드 Mac 선택

칩, 메모리, 저장 공간, 대여 기간 및 리전을 확인한 후 Apple Silicon 독점 물리 서버를 주문하세요.

모델 선택 및 주문