Foundation의 장수명 OperationQueue 하나를 사용하고 계산을 제출하기 전에 maxConcurrentOperationCount을 설정하세요. 버튼을 누를 때마다 요청을 하나씩 받는 Mac 유틸리티에서는 동시에 실행되는 작업 수를 제한하면서 이후 요청은 대기열에서 기다리게 할 수 있습니다.
이 튜토리얼에서는 해당 데스크톱 작업을 위한 계산 엔진과 동작을 확인하는 독립 실행형 프로그램을 만듭니다. 각 요청에는 합성 정수가 포함되며, 모든 결과는 메모리에 유지됩니다. 계산은 작업 내에서 동기식으로 실행되므로 작업의 수명은 실제 작업이 진행되는 동안 유지됩니다.
개별 요청이 들어올 때 적합한 동시성 도구는 무엇인가요?
OperationQueue는 작업을 개별적으로 받아들이며, 블록 기반 addOperation(_:) 메서드는 제출된 각 클로저를 작업으로 감쌉니다. 요청이 이어지는 동안 유지되는 객체에 대기열을 보관한 다음, 들어오는 항목마다 클로저 하나를 제출하세요. Apple의 addOperation(_:) 문서에서 이 제출 메서드를 설명합니다.
maxConcurrentOperationCount를 2과 같은 양수 제한으로 설정하세요. Apple은 이 속성을 해당 대기열의 작업 중 동시에 실행될 수 있는 최대 개수로 정의합니다. 제한은 해당 대기열에만 적용됩니다. 요청마다 별도의 대기열을 만들면 공유 제한의 효과가 사라집니다. 나중에 제한을 낮추더라도 이미 실행 중인 작업은 계속 실행됩니다. Apple의 동시 실행 제한 문서에서 이러한 경계를 설명합니다.
Swift 작업 그룹의 수명은 다릅니다. 작업 그룹은 withTaskGroup 범위 안에 속하고, 이를 만든 작업 외부에서는 사용할 수 없으며, 반환하기 전에 자식 작업이 끝나기를 기다립니다. 따라서 서로 무관한 이후 버튼 입력을 위해 작업 그룹을 속성으로 저장하는 것은 지원되지 않습니다. 이 동기식 계산 엔진에는 보관된 작업 대기열이 필요한 수명과 명시적인 동시 실행 설정을 제공합니다. Swift 표준 라이브러리의 작업 그룹 문서에서 이러한 제약을 설명합니다.
| 작업 구성 | 적합한 방식 |
|---|---|
| 독립적인 동기식 계산이 개별적으로 들어옴 | OperationQueue 하나를 보관하고 계산마다 작업 하나를 제출합니다. |
| 상위 비동기 작업이 한정된 자식 작업 모음을 관리함 | 상위 작업의 범위 안에서 작업 그룹을 사용합니다. 숫자로 제한해야 한다면 작업 수용을 명시적으로 제어합니다. |
| 작업 결과가 데스크톱 인터페이스를 갱신해야 함 | 계산을 별도로 수행한 뒤 MainActor에 격리된 코드로 결과를 전달합니다. |
마지막 행은 결과 전달에 관한 내용입니다. Swift 언어 가이드는 메인 액터를 인터페이스 상태를 다루는 곳으로 설명하며, 명시적으로 메인 액터에 격리된 클로저의 예를 보여 줍니다. 비용이 큰 산술 연산은 결과 전달 단계 밖에서 수행하세요. The Swift Programming Language: 동시성에서 메인 액터 격리를 설명합니다.
계산과 완료 규약 정의하기
이 예제는 1부터 1_000_000까지의 정수 식별자와 반복 횟수를 받습니다. 반복 횟수가 n이면 1부터 n까지 모든 정수의 제곱을 더합니다. 식별자와 UInt64 결과를 함께 반환합니다. 식별자는 출력을 표시하며 산술 연산에는 영향을 주지 않습니다.
이 프로그램을 위해 선택한 예시 제한입니다. 입력이 허용된 최댓값일 때 결과는 333333833333500000이며, UInt64에 들어갑니다. 제출 전에 유효성을 검사하므로 잘못된 요청은 즉시 오류를 던지고 대기열에 작업을 만들지 않습니다. 입력 제한은 반복문의 범위가 유효하도록 하는 역할도 합니다.
허용된 각 요청은 작업이 끝날 때 저장된 결과 하나를 생성합니다. 결과는 배열에 누적되므로 식별자가 반복되어도 이전 결과를 덮어쓰지 않습니다. 예제에서는 읽기 쉽게 서로 다른 오름차순 식별자를 사용하고 식별자 기준으로 정렬해 출력합니다. 이 출력 순서는 실행 순서를 나타내지 않습니다.
이 규약은 대기열을 계속 사용할 수 있고 작업이 완료될 때까지 실행된다고 가정합니다. 이 예제에서는 취소를 제공하지 않습니다. 나중에 취소를 추가하려면 완료되지 않은 요청에 대한 별도의 결과 상태가 필요합니다. Apple 문서에 따르면 취소는 중지 요청이며, 실행 중인 작업이 이에 응답해야 하기 때문입니다. Apple의 OperationQueue 문서에서 작업 수명과 취소를 설명합니다.
공유 제한을 적용한 완전한 프로그램 만들기
Swift 6.2 이상 컴파일러와 Xcode 또는 명령줄 도구를 통해 제공되는 macOS SDK가 설치된 Mac을 사용하세요. 다음 전체 프로그램을 main.swift으로 저장합니다. 아래 명령은 기본 격리로 nonisolated를 명시적으로 선택해 계산 도우미가 앱 타깃의 격리 설정과 독립적으로 유지되도록 합니다. Swift는 이 컴파일러 설정을 SE-0466: 기본 액터 격리 추론 제어에서 설명합니다.
결과 로그는 NSLock을 사용해 배열과 활성 작업 카운터를 모두 보호합니다. @unchecked Sendable 적합성 선언은 동기화 책임을 구현에 둡니다. 즉, 변경 가능한 상태에 접근할 때마다 같은 잠금을 사용합니다. Apple은 이 책임을 Sendable에서, 임계 구역 보호를 NSLock에서 설명합니다.
import Foundation
enum InputError: Error {
case invalidIterations
}
struct Calculation: Sendable {
let id: Int
let iterations: Int
}
func sumOfSquares(_ job: Calculation) -> UInt64 {
var sum: UInt64 = 0
for integer in 1...job.iterations {
let value = UInt64(integer)
sum += value * value
}
return sum
}
final class ExecutionLog: @unchecked Sendable {
private let lock = NSLock()
private var active = 0
private var peak = 0
private var rows: [(id: Int, value: UInt64)] = []
func begin() {
lock.lock()
defer { lock.unlock() }
active += 1
peak = max(peak, active)
}
func finish(id: Int, value: UInt64) {
lock.lock()
defer { lock.unlock() }
rows.append((id: id, value: value))
active -= 1
}
func snapshot() -> (
rows: [(id: Int, value: UInt64)], peak: Int
) {
lock.lock()
defer { lock.unlock() }
return (rows, peak)
}
}
final class CalculationPool {
private let queue = OperationQueue()
private let log = ExecutionLog()
init(limit: Int) {
precondition(limit > 0)
queue.name = "Synthetic calculations"
queue.maxConcurrentOperationCount = limit
}
func submit(id: Int, iterations: Int) throws {
guard (1...1_000_000).contains(iterations) else {
throw InputError.invalidIterations
}
let job = Calculation(id: id, iterations: iterations)
queue.addOperation { [log] in
log.begin()
let value = sumOfSquares(job)
log.finish(id: job.id, value: value)
}
}
// Call only after the producer has stopped submitting work.
// This blocking method is for the command-line demonstration.
func finishForDemo() -> (
rows: [(id: Int, value: UInt64)], peak: Int
) {
queue.waitUntilAllOperationsAreFinished()
return log.snapshot()
}
}
@main
struct Demo {
static func main() throws {
let limit = 2
let pool = CalculationPool(limit: limit)
try pool.submit(id: 1, iterations: 100_000)
try pool.submit(id: 2, iterations: 200_000)
try pool.submit(id: 3, iterations: 300_000)
let report = pool.finishForDemo()
let rows = report.rows.sorted { $0.id < $1.id }
precondition(rows.map { $0.id } == [1, 2, 3])
precondition(rows.map { $0.value } == [
UInt64(333338333350000),
UInt64(2666686666700000),
UInt64(9000045000050000)
])
precondition(report.peak >= 1 && report.peak <= limit)
for row in rows {
print("Job \(row.id): \(row.value)")
}
print("Observed peak: \(report.peak)")
}
}
main.swift가 있는 디렉터리에서 컴파일하고 실행하세요.
swiftc -swift-version 6 -default-isolation nonisolated \
-parse-as-library main.swift -o calculation-demo
./calculation-demo
결과 줄 세 개는 다음과 같아야 합니다.
Job 1: 333338333350000
Job 2: 2666686666700000
Job 3: 9000045000050000
마지막 줄에는 관측된 최대 동시 실행 수가 1 또는 2으로 표시됩니다. 제한은 중첩 실행을 허용하지만 이를 보장하지는 않습니다. 예상 합계는 정수 산술과 공식 n * (n + 1) * (2 * n + 1) / 6를 사용해 별도로 확인했습니다. Swift 실행 파일이 설치되어 있지 않아 조사 환경에서는 Swift 프로그램을 검토만 했고 컴파일하지 못했습니다.
이 제한이 실제 계산에도 적용되는 이유는 무엇인가요?
각 작업은 활성 작업 카운터를 시작하고, 계산 전체를 동기식으로 수행하고, 결과를 기록한 뒤 반환합니다. 해당 클로저에서 별도의 비동기 계산을 시작하지 않습니다. 따라서 실행 중인 작업 수를 제한하면 이 계산의 동시 실행 수도 제한됩니다.
프로그램을 수정할 때도 이 관계를 유지하세요. 작업이 별도의 Task만 생성하고 즉시 반환하면, 해당 작업은 완료된 것으로 간주되지만 그 Task는 계속 계산할 수 있습니다. 그러면 작업 제한은 짧은 실행 요청 클로저에만 적용됩니다. 이는 문서에 설명된 작업 수명과 블록 제출 API에서 비롯됩니다. Apple의 OperationQueue 개요와 addOperation(_:)에서 관련 내용을 정의합니다.
로그의 잠금은 짧은 기록 처리 구간만 보호합니다. sumOfSquares(_:)을 실행하는 동안 잠금을 유지하면 계산 접근이 직렬화되어 의도한 중첩 실행이 불가능해집니다. 각 잠금 획득과 해제는 하나의 동기식 메서드 안에서 이루어지며, 이는 NSLock을 잠근 스레드에서 잠금을 해제해야 한다는 Apple의 요구사항에 부합합니다. Apple의 NSLock 문서에서 이 요구사항을 설명합니다.
수정한 프로그램을 확인하려면 limit를 1로 설정해 같은 요청 세 개를 실행한 다음 2로 되돌리세요. 두 실행 모두 세 가지 결과를 정확히 보존해야 합니다. 직렬 실행에서는 최대 동시 실행 수가 1으로 표시되어야 합니다. 두 번째 실행은 2을 넘지 않아야 합니다. 이러한 확인을 통해 특정 스케줄링 순서를 가정하지 않고 결과 보존과 관측된 계산 제한을 점검할 수 있습니다.
인터페이스를 막지 않고 들어오는 데스크톱 동작에 연결하기
데스크톱 계산 기능을 소유하는 구성 요소에 CalculationPool 하나를 보관하세요. 동작이 들어올 때마다 submit(id:iterations:)을 한 번 호출합니다. 동작 처리기 안에서 풀을 새로 만들지 마세요. 인스턴스마다 독립된 대기열과 제한이 생기기 때문입니다.
명령줄 프로세스가 종료되기 전에 결과를 수집할 수 있도록 예제에서는 마지막 제출이 끝난 뒤에만 finishForDemo()을 호출합니다. 이 메서드는 호출 스레드를 차단합니다. 데스크톱 앱에서는 버튼 처리기나 메인 액터에서 이 메서드를 호출하지 말고 결과별 전달 방식을 사용하세요. Apple은 waitUntilAllOperationsAreFinished()의 차단 동작을 명시적으로 문서화합니다.
인터페이스에 통합하려면 제출 API를 MainActor에 격리되고 @Sendable로 표시된 결과 콜백을 받도록 확장하세요. 작업 안에서 결과를 계산한 뒤, 명시적인 @MainActor 클로저가 있는 Task를 예약해 해당 콜백을 호출합니다. 변경 불가능한 결과 값과 콜백을 캡처하세요. 해당 클로저 안에서는 표시 업데이트만 수행하고 산술 연산은 작업 안에 둡니다. Swift는 메인 액터 클로저 구문과 전송 가능한 클로저 캡처를 모두 문서화합니다.
이 인터페이스 방식에는 완료에 대한 추가 정의가 필요합니다. 계산 완료와 결과 표시는 서로 다른 이벤트입니다. 명령줄 프로그램은 계산과 동기식 결과 저장을 기다립니다. 별도로 예약한 인터페이스 콜백은 그 이후에 실행될 수 있습니다.
제출이 계산 속도보다 빠르면 어떻게 되나요?
동시 실행 설정은 실행 중인 작업 수를 제한하지만, 추가 제출 작업은 대기열에 남을 수 있습니다. 또한 예제의 완료 결과 배열은 계속 커질 수 있습니다. 따라서 이 예제는 계산의 동시 실행 수만 제한하며 전체 메모리 사용량이나 대기열 크기는 제한하지 않습니다. Apple 문서에 따르면 대기열은 작업이 끝날 때까지 작업을 보유합니다. OperationQueue 개요에서 보유 동작을 설명합니다.
데스크톱 기능에서 지속적인 요청 폭주가 발생할 수 있다면 확장 전에 수용 정책을 정하세요. 미완료 요청 수를 고정해 초과 요청을 거부한다고 알리거나, 추가 제출을 일시 중지하거나, 해당 동작이 명시적으로 의도된 경우 대기 중인 합성 미리보기를 최신 요청으로 교체할 수 있습니다. 수용 건수와 완료 업데이트는 하나의 동기화된 소유자에서 조정하세요. 실행 중인 작업 수 제한만으로는 이러한 정책을 구현할 수 없습니다.
개별 동기식 요청을 위한 핵심 설계는 간단합니다. 대기열 하나를 보관하고, 제한을 한 번 설정하고, 각 계산을 해당 작업 안에서 수행하며, 공유 결과를 동기화합니다. 데스크톱 동작에 연결할 때도 이러한 경계를 지키고, 요청 유입 양상에 따라 필요한 경우에만 명시적인 대기열 정책을 추가하세요.
출처
- OperationQueue — Apple.
- maxConcurrentOperationCount — Apple.
- addOperation(_:) — Apple.
- waitUntilAllOperationsAreFinished() — Apple.
- NSLock — Apple.
- Sendable — Apple.
- 동시성 — The Swift Programming Language — Swift.org.
- TaskGroup.swift — Swift 표준 라이브러리 소스 코드 및 문서 — Swift.org.
- SE-0466: 기본 액터 격리 추론 제어 — Swift.org.