같은 리소스 처리 작업이 로컬에서는 예상대로 실행되는데, 클라우드 Mac의 파이프라인으로 옮기면 가끔 리소스보다 인덱스가 먼저 생성될 수 있습니다. 반드시 머신의 문제인 것은 아닙니다. 작업 순서가 딕셔너리 순회에 달려 있거나, “큐에 등록됨”을 “선행 작업이 완료됨”으로 잘못 간주했다면 프로세스를 새로 실행하는 것만으로도 오류가 드러날 수 있습니다. 아래에서는 시뮬레이터를 먼저 실행할 필요 없이 독립 실행형 Swift 스크립트로 의존 그래프를 검증합니다.
간선 방향과 실패 처리 규칙부터 정의하기
순서를 직접 지정하는 방식은 짧고 고정된 흐름에 적합합니다. 반면 작업이 추가·삭제되거나 여러 단계가 같은 선행 조건을 공유한다면 의존 그래프가 적합합니다. 원래 모든 작업이 선형으로 실행되는 구조라면 그래프를 도입해도 유지보수 부담만 늘어납니다.
이 글에서는 입력을 “작업 이름 → 선행 작업 집합”으로 표현합니다. 예를 들어 index가 assets에 의존한다면 출력에서는 assets가 먼저 나와야 합니다. 작업 이름은 표시 언어에 따라 바뀌지 않는 고정 식별자로 사용하며, 서로 의존하지 않는 작업은 식별자의 사전순으로 선택합니다.
입력에 대해서는 세 가지 규칙도 정해야 합니다. 중복 간선은 하나로 합치고, 존재하지 않는 의존 대상은 즉시 실패 처리하며, 자기 자신에 대한 의존은 순환으로 간주합니다. 빈 그래프는 유효하며 빈 배열을 반환합니다. 알 수 없는 노드를 암묵적으로 생성해서는 안 됩니다. 그러면 이름을 잘못 입력해도 정상 입력으로 통과하게 됩니다.
정렬은 의존 관계를 유효한 순서로 나열할 수 있는지만 확인합니다. 작업이 완료되었다는 뜻도, 산출물이 올바르다는 뜻도 아닙니다. 실행 상태와 그래프 구조는 별도로 검증해야 합니다.
실행 가능한 결정적 정렬기 작성하기
노드의 작업 디렉터리에 아래 내용을 graph-check.swift로 저장합니다. Swift 표준 라이브러리만 사용하는 코드이므로 현재 선택된 툴체인으로 실행하면 됩니다. 별도 패키지를 설치할 필요는 없습니다. 알고리즘은 각 작업에 아직 충족되지 않은 의존성이 몇 개 남았는지 관리하고, 매 단계에서 준비된 작업 중 이름이 사전순으로 가장 앞선 작업을 선택합니다.
enum GraphError: Error, Equatable {
case unknown(task: String, dependency: String)
case blocked([String])
}
func ordered(_ graph: [String: [String]]) throws -> [String] {
let names = graph.keys.sorted()
let known = Set(names)
var remaining: [String: Int] = [:]
var successors: [String: [String]] = [:]
for task in names {
let dependencies = Set(graph[task]!)
for dependency in dependencies.sorted() {
guard known.contains(dependency) else {
throw GraphError.unknown(
task: task, dependency: dependency
)
}
successors[dependency, default: []].append(task)
}
remaining[task] = dependencies.count
}
var ready = names.filter { remaining[$0] == 0 }
var result: [String] = []
while !ready.isEmpty {
let task = ready.removeFirst()
result.append(task)
for next in successors[task, default: []] {
remaining[next]! -= 1
if remaining[next] == 0 {
ready.append(next)
}
}
ready.sort()
}
guard result.count == names.count else {
throw GraphError.blocked(
names.filter { remaining[$0]! > 0 }
)
}
return result
}
func check() throws {
let graph = [
"assets": ["fetch", "fetch"],
"fetch": [],
"index": ["assets"],
"lint": []
]
let result = try ordered(graph)
precondition(result == ["fetch", "assets", "index", "lint"])
let position = Dictionary(
uniqueKeysWithValues: result.enumerated().map {
($0.element, $0.offset)
}
)
precondition(result.count == graph.count)
precondition(Set(result) == Set(graph.keys))
for (task, dependencies) in graph {
for dependency in dependencies {
precondition(position[dependency]! < position[task]!)
}
}
let empty = try ordered([:])
precondition(empty.isEmpty)
do {
_ = try ordered(["export": ["missing"]])
preconditionFailure("Unknown dependency was accepted")
} catch let error as GraphError {
precondition(error == .unknown(
task: "export", dependency: "missing"
))
}
do {
_ = try ordered([
"a": ["b"], "b": ["a"], "report": ["b"]
])
preconditionFailure("Cycle was accepted")
} catch let error as GraphError {
precondition(error == .blocked(["a", "b", "report"]))
}
}
try check()
xcrun swift graph-check.swift
검증을 통과하면 아무것도 출력되지 않으며, 실패하면 프로세스가 0이 아닌 종료 코드를 반환합니다. 전제 조건 검사를 비활성화하는 최적화 모드로 이 검증 스크립트를 실행해서는 안 됩니다. 프로젝트에 통합한 뒤에는 같은 검증문을 정식 테스트 타깃으로 옮겨 애플리케이션 빌드 설정이 검사 동작에 영향을 주지 않도록 할 수 있습니다.
속성 검증으로 예제의 사각지대 메우기
결과를 고정해 비교하면 순서가 달라지는 문제를 잡을 수 있지만, 잘못된 구현의 결과까지 정답으로 굳혀 버릴 수도 있습니다. 그래서 코드에서는 출력 개수, 노드 집합, 각 간선의 선후 관계도 함께 검증합니다. 이 세 가지 검사는 누락, 중복, 의존 순서 역전을 방지합니다.
다음으로 다이아몬드 형태의 그래프를 추가합니다. 두 작업이 같은 루트 작업에 의존하고, 마지막 작업은 그 두 작업에 의존하는 구조입니다. 루트는 한 번만 출력되어야 하며, 두 갈래가 합쳐지는 작업은 반드시 마지막에 나와야 합니다. 이어서 서로 독립적인 여러 부분 그래프, 단일 노드, 자기 의존, 모든 노드가 순환에 포함된 입력도 추가합니다.
같은 그래프를 딕셔너리에 서로 다른 순서로 삽입하고 각 의존성 배열의 순서도 뒤집어 보세요. 결과는 완전히 같아야 합니다. 여기서 결정성은 반복 실행했을 때 “계속 같아 보이는” 데서 나오는 것이 아니라 명시적인 정렬에서 나옵니다. 우연히 한 번 올바른 결과를 낸 딕셔너리 순회를 인터페이스의 보장으로 받아들여서는 안 됩니다.
차단된 작업 집합을 순환 구성원으로 잘못 보고하지 않기
예제의 a와 b는 서로 의존합니다. report는 b에 의존할 뿐이지만 이 역시 실행 가능한 상태가 되지 못합니다. 따라서 오류 이름은 남은 모든 작업이 순환에 속한다고 단정하는 대신 blocked로 지정했습니다.
문제 분석을 위해 남겨야 할 증거
실패 보고서에는 원본 그래프, 사전순으로 정렬한 차단 작업 집합, 커밋 식별자, 툴체인 버전만 남기면 됩니다. UI에서 순환에 속한 노드만 정확히 빨간색으로 표시해야 한다면 강한 연결 요소 분석을 추가하세요. 진입 차수가 여전히 0보다 크다는 이유만으로 “순환 경로”를 그려서는 안 됩니다.
순환이 있다면 일부 독립 작업의 순서가 이미 정해졌더라도 전체 실행 계획은 실패해야 합니다. 제품에서 부분 실행을 명시적으로 지원하지 않는 한, 앞부분의 결과를 완전한 계획처럼 실행기에 넘겨서는 안 됩니다.
파이프라인에 통합할 때 스케줄링 경계 지키기
클라우드 Mac에서 그래프 검증을 먼저 실행한 다음 시간이 오래 걸리는 빌드 단계로 넘어가세요. 0이 아닌 종료 코드를 그대로 전달해, 실행기가 멈춘 뒤에야 문제를 발견하는 대신 수 분이 걸리는 작업이 시작되기 전에 의존성 설정 오류가 드러나게 해야 합니다.
이 구현은 매 단계마다 준비된 작업 배열을 다시 정렬하므로 규모가 제한적인 애플리케이션 작업 그래프에 적합합니다. 노드 수가 늘어나면 최소 힙으로 교체할 수 있지만, 같은 사전순 선택 규칙을 유지하고 기존 테스트를 재사용해 결과를 비교해야 합니다.
실제로 병렬 실행할 때는 선행 작업이 성공적으로 완료된 뒤에만 후속 작업의 미완료 의존성 수를 줄여야 합니다. 실패, 취소, 재시도에는 별도의 상태가 필요하며, 위상 정렬 결과의 인덱스로 추론해서는 안 됩니다. 최종 검증은 잘못된 입력의 원인을 설명할 수 있는지, 유효한 그래프의 순서가 항상 일정한지, 실행기가 선행 조건을 충족하기 전에 후속 작업을 풀어 주지 않는지를 모두 확인해야 합니다. 그래야 다른 빌드 노드로 옮겨도 우연한 실행 순서가 릴리스 과정에 끼어들지 않습니다.
자주 묻는 질문
정렬 후 남은 노드는 모두 순환 구성원인가요?
아닙니다. 순환에 의존하는 후속 작업도 남을 수 있습니다. 실제 순환 구성원을 찾으려면 강한 연결 요소 분석을 추가해야 합니다.
안정적인 정렬은 병렬 실행을 막나요?
아닙니다. 출력 순서만 고정합니다. 병렬 스케줄러는 선행 작업이 실제로 끝난 뒤 후속 작업을 준비 집합에 넣어야 합니다.