이미지 처리 작업은 다운로드, 변환, 디렉터리에 결과 쓰기 순서로 진행됩니다. 그런데 다운로드가 실패했는데도 변환 작업이 스케줄링되거나, 사용자가 취소를 눌렀는데도 파일 쓰기가 계속되는 경우가 있습니다. 대개 큐 자체의 문제가 아니라 ‘의존 작업의 완료’, ‘비즈니스 로직의 성공’, ‘취소 요청’을 같은 의미로 취급해서 생기는 현상입니다. 이 글에서는 클라우드 Mac에서 독립 실행형 Swift 프로그램으로 이런 경계를 재현한 뒤, 검증 코드를 iOS 테스트 타깃으로 옮깁니다. 네트워크나 서명 자격 증명은 필요하지 않습니다.
먼저 큐가 책임지지 않는 범위를 정하기
OperationQueue의 의존성은 선행 작업이 완료 상태에 도달했는지만 확인합니다. 다운로드 실패나 변환 실패 같은 비즈니스 결과까지 이해하지는 않습니다. cancel() 역시 취소 플래그를 설정할 뿐, 실행 중인 클로저를 강제로 중단하지 않습니다.
각 비즈니스 단계의 최종 상태를 성공, 실패, 건너뜀, 취소로 구분하세요. 의존하는 작업이 실패했다면 건너뜀으로 기록하고, 이미 실행을 시작한 단계는 취소 요청을 받은 뒤 안전한 경계에서 종료하도록 합니다. ‘실행하지 않음’과 ‘실행했지만 실패함’을 하나의 불리언 값으로 표현하지 마세요.
isFinished는 작업의 생명주기가 끝났다는 뜻이지, 비즈니스 로직이 성공했다는 뜻이 아닙니다.isCancelled는 취소 요청을 받았다는 뜻이지, 실제 처리가 멈췄다는 뜻이 아닙니다.
이 예제는 동기 방식의 BlockOperation만 사용합니다. 클로저가 비동기 요청을 시작한 직후 반환하면 큐는 클로저 실행이 끝났다고 판단합니다. 이 경우 여기서 사용하는 의존성만으로는 요청의 콜백을 기다릴 수 없습니다. 비동기 작업은 생명주기 상태를 별도로 올바르게 구현해야 합니다.
명시적인 결과로 실패 후 후속 실행 막기
아래 내용을 QueueContract.swift로 저장하세요. 결과를 읽고 쓸 때 잠금을 사용하므로 나중에 병렬로 실행할 단계를 추가하더라도 데이터 경쟁을 방지할 수 있습니다. 이 예제는 우연히 정해지는 작업 스케줄링 순서에 의존하지 않습니다.
import Foundation
enum Outcome: Equatable {
case pending
case succeeded
case failed
case skipped
}
final class State: @unchecked Sendable {
private let lock = NSLock()
private var values: [String: Outcome] = [:]
func set(_ key: String, _ value: Outcome) {
lock.lock()
defer { lock.unlock() }
values[key] = value
}
func get(_ key: String) -> Outcome {
lock.lock()
defer { lock.unlock() }
return values[key] ?? .pending
}
}
let state = State()
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 2
let download = BlockOperation {
state.set("download", .failed)
}
let convert = BlockOperation {
guard state.get("download") == .succeeded else {
state.set("convert", .skipped)
return
}
state.set("convert", .succeeded)
}
convert.addDependency(download)
queue.addOperations([convert, download], waitUntilFinished: true)
precondition(state.get("download") == .failed)
precondition(state.get("convert") == .skipped)
precondition(download.isFinished && convert.isFinished)
터미널에서 컴파일하고 실행합니다.
xcrun swiftc QueueContract.swift -o QueueContract
./QueueContract
종료 코드가 0이면 검증을 통과한 것이므로 성공 메시지를 따로 출력할 필요는 없습니다. 다운로드 결과를 성공으로 바꾼 경우에는 변환 역시 성공했는지 확인하도록 단언문을 변경해 성공 경로와 실패 경로를 모두 검증하세요. @unchecked Sendable은 개발자가 스레드 안전성을 직접 보장한다는 선언일 뿐입니다. 모든 공유 상태 접근이 잠금을 거치고 내부 딕셔너리를 외부에 노출하지 않아야 이 보장이 성립합니다.
실행 전 취소와 실행 중 취소를 나눠 테스트하기
먼저 아직 실행하지 않은 작업의 취소를 검증합니다. 다음 코드를 같은 파일 끝에 추가한 뒤 다시 컴파일하세요. 별도의 큐를 사용하므로 앞선 테스트 케이스에 영향을 주지 않습니다.
let heldQueue = OperationQueue()
heldQueue.isSuspended = true
let cancelledState = State()
let waiting = BlockOperation {
cancelledState.set("body", .succeeded)
}
heldQueue.addOperation(waiting)
waiting.cancel()
heldQueue.isSuspended = false
heldQueue.waitUntilAllOperationsAreFinished()
precondition(waiting.isCancelled)
precondition(waiting.isFinished)
precondition(cancelledState.get("body") == .pending)
큐를 일시 정지하면 실행 전에 취소가 이루어짐을 보장할 수 있어 밀리초 단위의 대기 시간을 짐작할 필요가 없습니다. 단언문은 취소 플래그, 최종 완료 여부, 클로저가 실행되지 않았다는 사실을 함께 확인합니다. 어느 하나라도 빠지면 검증의 사각지대가 생깁니다.
실행 중인 작업은 별도 케이스로 검증해야 합니다. 작업이 처리 루프에 진입하면 ‘시작됨’ 신호를 보내고, 테스트는 그 신호를 받은 뒤에만 취소를 요청한 다음 다음 반복을 진행하도록 허용합니다. 매 반복마다 데이터를 읽기 전과 결과를 커밋하기 전에 isCancelled를 확인하세요. 신호를 기다릴 때는 타임아웃을 두고, 시간이 초과되면 테스트를 실패 처리해야 합니다. 무한정 대기하게 두어서는 안 됩니다.
취소 요청 시점까지 스케줄링되지 않은 클로저는 비즈니스 최종 상태를 대신 기록해 주지 않습니다. 따라서 오케스트레이션 계층에서 취소 상태를 기록해야 합니다. 한 번도 실행되지 않은 클로저에 정리 작업을 맡기지 마세요.
결과를 쓸 때 커밋 경계 지키기
협력적 취소는 취소 여부를 확인하는 지점에서만 적용됩니다. 한 번의 변환에 오랜 시간이 걸린다면 취소가 반영될 때까지 적어도 현재 변환 단계가 끝날 만큼의 시간이 걸릴 수 있습니다. 빈 루프에서 플래그를 자주 확인하기보다는 오래 걸리는 처리를 적절한 배치로 나누는 편이 더 효과적입니다.
파일은 먼저 해당 작업 전용 임시 경로에 쓴 뒤, 핸들을 닫고 결과를 커밋하세요. 실패하거나 취소되면 해당 작업의 임시 파일만 정리하고 공유 디렉터리는 삭제하지 않습니다. 커밋에 성공한 뒤 취소 요청을 받았다면 최종 상태는 ‘성공’으로 유지해야 합니다. 이를 나중에 ‘취소’로 바꾸면 호출 측에서 결과물이 없다고 잘못 판단할 수 있습니다.
취소와 커밋은 동시에 일어날 수 있습니다. 비즈니스 요구사항상 둘 중 하나만 이루어져야 한다면 취소 결정과 커밋 결정을 동일한 잠금이나 직렬 상태 머신으로 처리하세요. 쓰기 직전에 플래그를 한 번 확인하는 것만으로는 그 이후에 발생하는 경쟁 상태를 없앨 수 없습니다.
회귀 테스트에서 최종 상태를 빠짐없이 확인하기
예제의 검증 코드를 XCTest로 옮길 때는 precondition을 테스트 단언문으로 바꾸세요. 클라우드 Mac의 명령줄 작업에서 큐가 끝날 때까지 기다리는 것은 괜찮습니다. 하지만 앱의 메인 스레드에서는 동기적으로 기다리지 마세요. 특히 큐의 작업이 다시 메인 스레드의 콜백을 기다리는 구조라면 더욱 피해야 합니다.
매 테스트 실행에서 최소한 선행 작업 성공, 선행 작업 실패, 실행 전 취소, 실행 중 취소, 커밋 후 취소를 검증하세요. 각 케이스마다 비즈니스 최종 상태, 결과물의 존재 여부, 완료 알림 횟수를 함께 확인해야 합니다. 완료 알림은 정확히 한 번만 보내야 하며, 콜백이 오지 않거나 중복 호출되면 모두 실패로 처리해야 합니다.
제어 가능한 신호로 실행 순서를 확정하고, sleep으로 이른바 안정적인 타이밍을 만들려 하지 마세요. 이렇게 하면 반복해서 검증할 수 있는 계약이 남습니다. 의존 작업이 실패하면 잘못된 커밋이 발생하지 않고, 취소 후에는 작업이 종료될 수 있으며, 종료된 뒤에만 정리 작업을 수행해야 한다는 계약입니다. 이 조건을 지켜야 동시성을 높일 때도 신뢰할 수 있는 기반을 갖출 수 있습니다.
자주 묻는 질문
선행 Operation이 실패하면 의존 작업도 자동으로 건너뛰나요?
아닙니다. 의존성은 실행 순서만 정합니다. 스레드 안전한 결과 상태로 실패를 전달하고 후속 작업이 해당 상태를 확인해 명시적으로 건너뛰어야 합니다.
cancelAllOperations 호출 직후 작업 파일을 삭제해도 되나요?
안 됩니다. 실행 중인 작업이 파일을 계속 사용할 수 있습니다. 취소를 요청하고 메인 스레드 밖에서 작업 종료를 기다린 다음 정리해야 합니다.