Swift 함수와 클로저 | 함수 정의, 클로저, 고차 함수

이 글의 핵심

Swift에서 함수와 클로저는 모두 일급 값이라 변수에 담고 인자나 반환값으로 넘길 수 있고, UIKit 콜백과 비동기 핸들러가 이 성질에 기대고 있습니다. 클로저가 self를 강하게 붙잡아 생기는 순환 참조를 캡처 리스트로 끊는 방법, 탈출 클로저가 왜 따로 표시되는지, 고차 함수 체이닝의 성능 고려까지 다룹니다.

시리즈 안내

#03 | 📋 전체 목차 | 이전: #02 변수와 타입 · 다음: #04 클래스


들어가며

Swift에서 함수(function)는 이름과 호출 시그니처가 고정된 실행 단위이고, 클로저(closure)는 주변 문맥을 캡처할 수 있는 코드 블록입니다. 둘 다 일급 값(first-class)이라 변수에 담을 수 있고 인자나 반환값으로 주고받을 수 있습니다. UIKit 콜백, 비동기 핸들러, map 같은 컬렉션 연산이 모두 이 성질 위에 서 있습니다.

이 글은 Swift 시리즈의 세 번째 글로, 선언과 호출 규칙에서 시작해 클로저의 수명과 캡처, @escaping과 @autoclosure, 메서드·서브스크립트·연산자, 고차 함수와 성능 순서로 설명합니다. 코드 예시는 Swift 5 이상을 기준으로 합니다.

#02 변수와 타입에서 다룬 값 타입과 참조 타입, Optional, 기본 제어 흐름은 알고 있다고 가정합니다. C나 Java 계열에서 넘어왔다면 Swift의 외부 인자 레이블과 guard/if let 패턴이 클로저와 함께 쓰인다는 점을 알아 두면 UIKit·SwiftUI 샘플 코드의 호출부가 훨씬 잘 읽힐 것입니다. async/actor와 구조적 동시성은 이 글에서 다루지 않고 async 글에서 이어갑니다.

용어를 정리하면, 본문에서 “함수”는 func로 선언한 이름 있는 호출 단위를, “클로저”는 문맥을 캡처할 수 있는 코드 블록 전반(익명 클로저 표현식, 중첩 함수 포함)을 가리킵니다. Apple 문서도 같은 모델을 사용합니다.


함수 기초: 선언, 매개변수, 반환값

func 키워드로 정의하고, 매개변수 목록 뒤에 ->와 반환 타입을 씁니다. 반환값이 없으면 반환 타입을 생략하거나 Void로 적습니다. Void는 빈 튜플 ()의 타입 별칭이므로 () -> Void와 () -> ()는 같은 함수 타입이며, 관례상 앞쪽 표기를 더 많이 씁니다.

@discardableResult는 반환값을 쓰지 않았을 때 나오는 컴파일러 경고를 끄는 속성입니다. 체이닝용 플루언트 API에서 주로 쓰는데, 남용하면 정말 확인해야 할 반환값을 무시하는 실수까지 가려 버립니다.

@inlinable은 라이브러리 저자가 모듈 경계를 넘는 최적화를 허용할 때 쓰는 속성이고, @_transparent나 @_optimize(speed)처럼 밑줄로 시작하는 속성은 공개 지원 대상이 아닌 내부용입니다. 일반 앱 코드라면 이런 속성보다 알고리즘과 I/O를 줄이는 쪽이 효과가 훨씬 큽니다(뒤의 성능 절 참고).

func logLine(_ text: String) {
    print("[LOG] \(text)")
}

func maxValue(_ a: Int, _ b: Int) -> Int {
    if a >= b { return a }
    return b
}

// 본문이 단일 식이면 return 생략 가능(단, 반환이 있을 때)
func minValue(_ a: Int, _ b: Int) -> Int { a < b ? a : b }

매개변수는 기본적으로 상수입니다. 본문에서 값을 바꿔야 하면 지역 변수에 복사해서 쓰거나, 호출한 쪽 변수까지 바꿔야 할 때는 inout을 사용합니다(뒤 절).

튜플 반환은 작은 값 몇 개를 한 번에 돌려줄 때 유용하고, 멤버에 이름을 붙이면 호출 측 가독성이 좋아집니다. 묶음이 커지거나 여러 곳에서 쓰이면 struct로 승격하는 편이 타입으로서 의미가 분명합니다.

실패할 수 있는 연산(문자열을 Int로 파싱, 네트워크 요청)은 Optional, Result, throws 중 하나로 오류 경로를 분리하세요. (값, Bool) 튜플처럼 성공 여부를 따로 돌려주는 방식은 enum 연관 값이나 Result로 바꾸는 편이 유지보수에 유리한 경우가 많습니다.

func quotRem(_ a: Int, _ b: Int) -> (q: Int, r: Int) {
    (a / b, a % b)
}
let r = quotRem(7, 3)
print(r.q, r.r) // 2 1

함수 호출: Argument labels, parameter names

Swift는 외부 매개변수 이름(인자 레이블)과 내부 매개변수 이름을 분리할 수 있습니다. 호출하는 쪽에서 읽히는 문장은 외부 이름이 결정합니다. 외부 레이블을 없애려면 그 자리에 _를 씁니다.

// 외부: to, from  / 내부: name, sender
func greetPerson(to name: String, from sender: String) {
    print("\(sender) → \(name)")
}
greetPerson(to: "팀", from: "나")

// 외부 레이블 없이 호출
func addIntegers(_ a: Int, _ b: Int) -> Int { a + b }
_ = addIntegers(2, 3)

to, in, at 같은 전치사를 레이블로 쓰면 호출부가 문장처럼 읽힙니다. 다만 첫 인자에도 레이블을 붙일지 _로 생략할지는 팀 컨벤션으로 통일하는 것이 좋습니다.

Swift API Design Guidelines의 요지는 불필요한 단어를 빼고, 첫 인자가 메서드 베이스 이름과 이어서 읽히게 하라는 것입니다. insert(_:at:) 같은 형태가 자주 보이는 이유입니다. public/open API는 이름 자체가 문서 역할을 하므로, 레이블을 억지로 줄이기보다 호출문을 몇 번 소리 내어 읽어 보고 정하는 편이 빠릅니다.

Swift는 기본 매개변수 값으로 여러 개의 오버로드를 대신할 수 있어서 C++식으로 foo(), foo(int)를 나열할 일이 적습니다. 공개 라이브러리에서 소스 호환을 유지하려면 새 매개변수를 기본값과 함께 뒤쪽에 추가하는 방식이 일반적입니다. 단, 기본값이 있는 매개변수를 추가해도 함수 시그니처는 바뀌므로 ABI 안정성이 필요한 라이브러리에서는 별도 오버로드를 남겨 두기도 합니다.


기본 매개변수 값

매개변수 뒤에 = 기본값을 두면 호출에서 생략한 인자는 기본값으로 채워집니다. 기본값이 있는 매개변수는 목록 뒤쪽에 두는 것이 호출 측에서 자연스럽습니다.

func connect(host: String, port: Int = 443, useTLS: Bool = true) {
    print("\(host):\(port) TLS=\(useTLS)")
}
connect(host: "api.example.com")
connect(host: "dev.local", port: 8080, useTLS: false)

기본값 식은 함수를 정의할 때 한 번 계산되는 것이 아니라, 인자를 생략하고 호출할 때마다 평가됩니다. 그래서 Date() 같은 식을 기본값으로 두면 호출마다 다른 값이 들어가고, Python처럼 가변 기본값이 호출 사이에 공유되는 함정은 없습니다.

기본값을 쓰면 호출부만 보고는 인자를 일부러 생략한 것인지 그냥 잊은 것인지 구분되지 않습니다. 명시성을 중시하는 팀은 기본값이 있어도 중요한 인자는 레이블과 함께 적는 스타일을 택하기도 합니다.

func buildQuery(_ path: String, useCache: Bool = true, timeout: Double = 30) { }
buildQuery("/v1")                         // 전부 기본
buildQuery("/v2", useCache: false)        // 타임아웃은 기본

가변 매개변수 (Variadic parameters)

Type... 문법은 0개 이상의 인자를 받으며, 본문에서는 [Type] 배열로 다룹니다. 가변 매개변수 뒤에 다른 매개변수를 둘 수는 있지만, 어느 인자가 어디에 속하는지 구분할 수 있도록 뒤따르는 매개변수에는 인자 레이블이 있어야 합니다. Swift 5.4부터는 한 함수에 가변 매개변수를 여러 개 둘 수도 있습니다.

func average(_ values: Double...) -> Double {
    guard !values.isEmpty else { return 0 }
    return values.reduce(0, +) / Double(values.count)
}
_ = average(1, 2, 3, 4)

한 가지 불편한 점은 이미 가진 배열을 가변 매개변수에 그대로 펼쳐 넘길 방법이 없다는 것입니다. 호출하는 쪽이 대부분 배열을 들고 있다면 처음부터 [Double]을 받는 편이 단순합니다.


inout 매개변수: 참조에 가까운 복사 제어

inout은 호출한 쪽 변수를 함수 안에서 바꾸고 그 결과를 되돌려 쓰는 매개변수입니다. 의미론적으로는 copy-in copy-out으로, 호출 시 값을 복사해 들어가고 함수가 반환될 때 원래 변수에 다시 씁니다. 컴파일러는 최적화로 주소를 직접 넘기기도 하지만, 코드는 그 동작에 기대지 말아야 합니다. 상수나 리터럴은 넘길 수 없고, 호출부에서 &를 붙입니다.

func swapInts(_ a: inout Int, _ b: inout Int) {
    (a, b) = (b, a)
}
var x = 1, y = 2
swapInts(&x, &y)

같은 변수를 두 inout 인자에 동시에 넘기면(swapInts(&x, &x)) 메모리 배타적 접근 규칙 위반으로 컴파일 오류가 납니다. 표준 라이브러리의 swap도 같은 이유로 swapAt을 따로 제공합니다.

inout을 남용하면 “이 함수가 값을 바꾼다”는 부수 효과가 여기저기 퍼져 코드를 읽기 어려워집니다. 스왑이나 부분 갱신 같은 작은 연산에만 쓰고, 도메인 로직에서는 mutating 메서드나 새 값을 반환하는 형태로 통일하는 것이 일반적입니다.


함수 타입: 일급 객체로서의 함수

함수 값의 타입은 (Int, Int) -> Int처럼 씁니다. Void가 ()의 별칭이므로 () -> Void는 “인자 없이 아무것도 반환하지 않는 클로저” 타입으로 자주 쓰입니다.

typealias BinaryIntOp = (Int, Int) -> Int
let mul: BinaryIntOp = { $0 * $1 }
_ = mul(3, 4)

// 인스턴스 메서드를 “함수 값”으로 넘기기 (KeyPath / 메서드 참조)
struct Point { var x: Double, y: Double }
let points = [Point(x: 1, y: 2), Point(x: 3, y: 4)]
let xs = points.map(\.x)
_ = xs

마지막 예제의 \.x는 KeyPath입니다. Swift 5.2(SE-0249)부터는 (Root) -> Value 함수가 필요한 자리에 KeyPath 리터럴을 그대로 넘길 수 있어서 map { $0.x } 대신 map(\.x)라고 쓸 수 있습니다.


함수를 매개변수로: 고차 함수 (Higher-order)

다른 함수를 인자로 받는 함수를 고차 함수라고 합니다. 배열의 sort(by:), map, filter가 대표적이고, 직접 만드는 API에서는 콜백이나 검사용 조건(predicate)을 받을 때 자주 씁니다.

func withLogging<T>(_ work: () throws -> T) rethrows -> T {
    print("start")
    defer { print("end") }
    return try work()
}

rethrows는 인자로 받은 클로저가 에러를 던질 때에만 이 함수도 에러를 던진다는 뜻입니다. 그래서 던지지 않는 클로저를 넘기면 호출부에 try가 필요 없습니다. 표준 라이브러리의 map, filter, sorted(by:)가 모두 이렇게 선언되어 있습니다.


함수를 반환: 팩토리·전략 패턴

함수를 반환하는 방식은 설정에 따라 다르게 동작하는 연산을 미리 만들어 두고 싶을 때 씁니다.

func makeAdder(_ base: Int) -> (Int) -> Int {
    { x in base + x }
}
let add5 = makeAdder(5)
_ = add5(3) // 8

반환된 클로저는 base를 캡처합니다. 캡처한 값이 언제까지 살아 있는지, 순환 참조가 어떻게 생기는지는 뒤의 “클로저 캡처” 절에서 다룹니다.


중첩 함수, 클로저 맛보기

func는 다른 func 안에 정의할 수 있습니다. 중첩 함수는 바깥 스코프의 let/var를 읽고, var라면 값을 바꿀 수도 있습니다. 한 함수에서만 쓰는 헬퍼를 사용하는 곳 바로 옆에 두어 응집도를 높일 때 유용합니다.

func runBatch(count: Int) {
    var seen = 0
    func step() {
        seen += 1
        if seen < count { step() }
    }
    step()
}

클로저 표현식은 { 매개변수 in 본문 } 형태로, 이름 없는 함수라고 생각하면 됩니다. 문법은 다음 절에서 자세히 정리합니다.

let add: (Int, Int) -> Int = { a, b in a + b }

클로저: 문법과 Trailing closure

Swift에서 전역 함수, 중첩 함수, 익명 클로저 표현식은 모두 같은 클로저 가족입니다. 익명 형태를 완전히 적으면 다음과 같습니다.

let nums = [1, 2, 3, 4]
let squares = nums.map({ (n: Int) -> Int in n * n })

타입 추론이 가능하면 (Int) -> Int 부분을 생략할 수 있고, 인자 이름 대신 $0, $1 같은 축약 이름을 쓸 수 있습니다.

후행 클로저(trailing closure)는 마지막 인자가 클로저일 때 괄호 밖으로 빼서 쓰는 문법입니다. 클로저가 유일한 인자라면 ()도 생략할 수 있습니다.

_ = [1, 2, 3].map { $0 * 2 }

UIView.animate(withDuration: 0.2) {
    // 애니메이션
} completion: { _ in
    // 완료
}

위 예제처럼 첫 후행 클로저 뒤에 completion: 레이블을 붙여 클로저를 이어 쓰는 것이 다중 후행 클로저 문법(Swift 5.3, SE-0279)이며, SwiftUI에서 특히 많이 씁니다.

축약은 보통 타입 생략, 단일 식의 return 생략, $0 사용 순으로 진행됩니다. 다만 너무 줄이면 $0이 값인지 인덱스인지 헷갈리는 코드가 되므로, 클로저가 두세 줄을 넘거나 의미가 중요하면 인자에 이름을 붙이는 편이 낫습니다.

KeyPath, 메서드 값, throws / rethrows 클로저

\.member 형태의 KeyPath는 프로퍼티에 대한 읽기 전용 참조이며 map, 정렬 기준 등에 잘 맞습니다. 쓰기까지 필요하면 WritableKeyPath(값 타입)나 ReferenceWritableKeyPath(참조 타입)를 사용하고, value[keyPath: kp] = ...처럼 서브스크립트로 값을 씁니다.

struct User: Equatable { let name: String; let score: Int }
let users = [User(name: "A", score: 10), User(name: "B", score: 20)]
let top = users.max(by: { $0.score < $1.score })   // “점수 비교”
let byName = users.sorted { $0.name < $1.name }
_ = (top, byName)

map, filter, compactMap은 rethrows로 선언되어 있어서 에러를 던지는 클로저를 그대로 넘길 수 있습니다. 예를 들어 try datas.map { try decoder.decode(User.self, from: $0) }처럼 쓰면 되고, 첫 에러가 나는 순간 전체 호출이 에러를 던집니다. 실패한 항목만 건너뛰고 싶다면 compactMap { try? ... }를 씁니다. 반면 forEach도 rethrows이지만, 루프 중간에 break나 continue가 필요하면 for-in으로 쓰는 편이 자연스럽습니다.

@Sendable은 다른 Task나 actor로 넘어가는 클로저에 붙이는 표시입니다. 컴파일러는 이 표시가 있으면 클로저가 가변 상태를 안전하지 않게 캡처하는지 검사합니다. UI 앱이라면 Swift async/await 글과 함께 읽는 것이 좋습니다.


클로저 캡처: Capture list, weak / unowned

실제 앱을 붙이면서 이런 일이 있었습니다. UIViewController의 네트워크 요청 completion 안에서 self를 그냥 잡고 있었더니, 화면은 내려갔는데 인스턴스가 생각보다 오래 살아 있었습니다. Instruments의 Allocations를 보고 나서야 클로저가 self를 강하게 붙잡고 있다는 걸 알았습니다. 그 뒤로 비동기·UI 코드에서 [weak self]와 guard let self를 습관처럼 쓰게 되었습니다.

클로저는 만들어질 때 주변의 값과 참조를 캡처합니다. 클래스 인스턴스를 캡처하면 강한 참조가 하나 늘어나고, 그 인스턴스가 다시 클로저를 프로퍼티로 들고 있으면 순환 참조(retain cycle)가 생깁니다. 이를 끊는 도구가 [weak self], [unowned self] 같은 캡처 목록입니다.

final class Work {
    var onDone: (() -> Void)?
    func start() {
        onDone = { [weak self] in
            self?.finished()
        }
    }
    func finished() { }
}
  • weak: 참조 대상이 해제되면 nil이 되므로 self?처럼 옵셔널로 다룹니다. UI 이벤트나 네트워크 완료 핸들러에서는 기본 선택지로 안전합니다.
  • unowned: 참조 대상이 클로저보다 항상 오래 산다는 것이 확실할 때만 씁니다. 전제가 틀려 해제된 객체에 접근하면 런타임 크래시가 납니다.

캡처 목록에서는 [weak a = self, unowned b = other]처럼 새 이름을 붙일 수도 있습니다. 옵셔널이 된 self는 guard let self = self else { return }으로 바인딩하는데, Swift 5.7부터는 guard let self else { return }으로 줄여 쓸 수 있습니다.

값 타입을 캡처 목록에 적으면([count]) 클로저를 만드는 시점의 값이 복사됩니다. 반면 캡처 목록 없이 지역 var를 참조하면 클로저는 변수 자체를 캡처하므로, 나중에 호출했을 때 그 사이에 바뀐 값이 보입니다. “왜 이 값이 찍히지?” 싶을 때 가장 먼저 확인할 부분입니다.


@escaping: 이스케이핑 클로저

@escaping은 클로저가 함수가 반환된 뒤에도 살아남을 수 있음을 컴파일러에 알리는 속성입니다. DispatchQueue.async, URLSession의 완료 핸들러처럼 “나중에 호출하는” API가 모두 여기에 해당합니다. 클로저 매개변수는 기본이 non-escaping이라 함수 실행 중에만 쓰이고, 그래서 컴파일러가 더 적극적으로 최적화할 수 있습니다.

클래스 안에서 escaping 클로저가 프로퍼티나 메서드를 쓸 때는 self.를 명시해야 합니다. 이는 팀 스타일이 아니라 컴파일러 규칙으로, 캡처가 일어난다는 사실을 코드에 드러내려는 의도입니다. Swift 5.3(SE-0269)부터는 캡처 목록에 [self]를 적거나 self가 값 타입일 때 이 표기를 생략할 수 있습니다.

func later(_ work: @escaping () -> Void) {
    DispatchQueue.main.async {
        work()
    }
}

@escaping 클로저를 프로퍼티에 저장하거나 백그라운드 작업에 넘기면 그 수명이 호출이 끝난 뒤까지 늘어납니다. 그래서 취소 수단이나 weak 캡처를 함께 설계해야 합니다.


@autoclosure: 자동으로 클로저로 감싸기

@autoclosure는 호출부에 적은 인자 식을 즉시 평가하지 않고 클로저로 감싸서 넘깁니다. 표준 라이브러리의 assert, precondition, 그리고 &&, ||, ?? 연산자의 오른쪽 피연산자가 이 방식으로 구현되어 있어서, 필요할 때만 식이 평가되는 단락 평가가 가능합니다. 클로저를 저장해야 한다면 @autoclosure @escaping처럼 함께 표시합니다.

func when(_ cond: Bool, _ body: @autoclosure () -> String) {
    if cond { print(body()) }
}
when(2 > 1, "ok") // "ok" 는 cond가 true일 때만 평가

남용하면 호출부만 보고는 값이 바로 계산되는지 나중에 계산되는지 알 수 없어서 혼란스럽습니다. 로그나 진단처럼 비용이 큰 메시지를 조건부로 만드는 경우가 아니라면, 일반 API에서는 () -> String을 명시적으로 받는 편이 낫습니다.


메서드: Instance methods, type methods

인스턴스 메서드는 self에 바인딩됩니다. struct나 enum의 메서드가 저장 프로퍼티를 바꾸려면 mutating 키워드가 필요합니다. 클래스는 참조 타입이라 mutating이 없습니다. 타입 메서드는 static 또는 class로 선언하며, class로 선언하면 서브클래스에서 오버라이드할 수 있습니다.

struct Counter {
    private var n = 0
    mutating func inc() { n += 1 }
    var value: Int { n }
}
enum Math {
    static func hypot(_ a: Double, _ b: Double) -> Double {
        (a * a + b * b).squareRoot()
    }
}

Math처럼 케이스 없는 enum에 static 함수를 모으면 인스턴스를 만들 수 없는 네임스페이스로 쓸 수 있습니다. 프로퍼티 래퍼, @objc 셀렉터, @MainActor는 동시성·UI를 다루는 글에서 이어갑니다.


Subscripts: 서브스크립트

subscript를 정의하면 타입에 [] 문법을 붙일 수 있습니다. 인자를 여러 개 받을 수 있고, 읽기 전용이나 읽기-쓰기로 만들 수 있으며, Swift 5.5부터는 읽기 전용 서브스크립트의 get에 throws나 async를 붙일 수 있습니다.

struct Box<T> {
    private var store: [String: T] = [:]
    subscript(key: String) -> T? {
        get { store[key] }
        set { store[key] = newValue }
    }
}
var b = Box<String>()
b["a"] = "1"
_ = b["a"]

Range를 받는 서브스크립트는 문자열이나 버퍼 뷰에서 흔합니다. 인덱스 경계를 벗어났을 때 어떻게 동작하는지는 문서에 밝히거나 precondition으로 강제하세요.

에러를 던지는 서브스크립트는 I/O나 디코딩 래퍼에 쓸 수는 있지만, 가독성 면에서는 func load()처럼 이름 있는 메서드가 더 잘 읽히는 경우가 많습니다. try box["key"]만 보고는 무엇이 실패할 수 있는지 짐작하기 어렵기 때문입니다.

// 레이블이 붙은 서브스크립트 예시
struct Guarded {
    subscript(safe i: Int) -> Int? {
        get { nil }
    }
}
let g = Guarded()
_ = g[safe: 0]

인자 수나 타입이 다른 서브스크립트를 여러 개 두는 것은 C++의 operator[] 오버로드와 비슷합니다. 다만 종류가 늘어날수록 API 표면이 넓어지니, 그럴 때는 이름 있는 메서드로 바꾸는 것도 고려하세요.


연산자 오버로딩: Custom operators

커스텀 연산자는 prefix, infix, postfix 중 하나로 operator를 선언하고, 중위 연산자라면 precedencegroup으로 우선순위를 정한 뒤 함수로 구현합니다. ==나 +는 Equatable, AdditiveArithmetic 같은 프로토콜을 준수해 구현하는 편이 Swift다운 방식이고, 커스텀 중위 연산자는 기하·행렬 계산이나 도메인 DSL처럼 한정된 맥락에서 씁니다.

infix operator ⊕: AdditionPrecedence

struct Vec2 { let x: Double, y: Double }
func ⊕(lhs: Vec2, rhs: Vec2) -> Vec2 {
    Vec2(x: lhs.x + rhs.x, y: lhs.y + rhs.y)
}

⊕ 같은 기호는 검색하기도, 키보드로 입력하기도 어렵습니다. 그래서 많은 팀이 스타일 가이드에서 커스텀 연산자를 제한하고 plus 같은 이름 있는 메서드를 쓰게 합니다.


실전 패턴: map, filter, reduce, 체이닝

Sequence/Collection의 고차 연산은 코드의 의도를 짧게 드러내고, 한 단계씩 위에서 아래로 읽힌다는 장점이 있습니다.

let numbers = [1, 2, 3, 4, 5, 6]

let v = numbers
    .filter { $0 % 2 == 0 }   // 짝수
    .map { $0 * $0 }         // 제곱
    .reduce(0, +)            // 합
// 2*2 + 4*4 + 6*6 = 4+16+36 = 56

compactMap은 변환 결과에서 nil을 걸러내고, flatMap은 중첩된 시퀀스를 평탄화합니다. 예전에는 옵셔널을 걸러내는 용도로도 flatMap을 썼지만 Swift 4.1에서 그 오버로드가 deprecated되어 compactMap으로 바뀌었습니다.

체이닝은 단계마다 중간 배열을 만듭니다. 큰 배열이라면 lazy로 중간 배열 생성을 피하거나, reduce(into:) 또는 일반 for 루프 하나로 합치는 방법을 검토합니다(다음 절).

let strings = ["1", "2", "x", "3"]
let ints: [Int] = strings.compactMap { Int($0) } // [1, 2, 3]

reduce(into:)·집계·질의, 정렬

reduce(0, +)는 익숙하지만, 딕셔너리나 배열에 값을 모으는 경우에는 reduce(into:_:)가 낫습니다. reduce(_:_:)는 단계마다 누적값을 새로 반환해야 해서 컬렉션이 매번 복사될 수 있는 반면, reduce(into:)는 누적값을 inout으로 받아 제자리에서 수정합니다. “모든 요소가 조건을 만족하는가”, “하나라도 만족하는가”는 allSatisfy와 contains(where:)가 의도를 더 잘 드러냅니다.

let words = ["a", "c", "a", "b"]
let freq = words.reduce(into: [String: Int]()) { dict, w in
    dict[w, default: 0] += 1
}
_ = freq // ["a": 2, "c": 1, "b": 1]

let allPositive = [1, 2, 3].allSatisfy { $0 > 0 }
let hasZero = (1...5).contains(where: { $0 == 0 })
_ = (allPositive, hasZero)

정렬은 제자리에서 정렬하는 sort와 새 배열을 반환하는 sorted로 나뉘고, 요소가 Comparable을 준수하면 인자 없이 sorted()로 충분합니다. 여러 기준으로 정렬할 때는 sorted { ($0.lname, $0.fname) < ($1.lname, $1.fname) }처럼 튜플 비교를 쓰는 패턴이 흔합니다.

조건에 맞는 첫 요소나 마지막 요소는 first(where:), last(where:), 인덱스는 firstIndex(where:)로 찾습니다. 모두 O(n) 선형 탐색이므로 큰 배열에서 반복해서 찾는다면 딕셔너리 같은 인덱스를 먼저 만들어 두는 편이 낫습니다.


성능: 인라인, 최적화 메모

  • @inlinable: 라이브러리의 공개 API에 붙이면 클라이언트 모듈이 함수 본문을 볼 수 있어 제네릭 특수화와 인라인이 가능해집니다. 대신 본문이 사실상 공개 ABI의 일부가 되므로, 나중에 본문을 바꿔도 이미 인라인된 클라이언트 코드에는 반영되지 않는다는 점을 감안해야 합니다.
  • COW(copy-on-write): Array를 다른 변수에 대입해도 즉시 복사되지 않고 버퍼를 공유합니다. 공유 중인 버퍼에 쓰기가 일어나는 순간 복사가 발생합니다. map, filter는 항상 새 배열을 만듭니다.
  • 클로저 할당: non-escaping 클로저는 보통 힙 할당 없이 처리되지만, escaping 클로저가 값을 캡처하면 캡처 컨텍스트를 위한 힙 할당이 생길 수 있습니다. 핫 루프에서 의심된다면 일반 for 루프와 비교해 측정하세요.
  • lazy: 체이닝의 중간 배열을 만들지 않지만, 요소에 접근할 때마다 변환 클로저가 다시 실행될 수 있습니다. 그래서 부수 효과가 있는 변환에는 쓰지 않는 것이 좋습니다.
let big = Array(1...1_000_000)
_ = big.lazy.filter { $0 % 2 == 0 }.map { $0 * 2 }

ContiguousArray나 withUnsafeBufferPointer는 오디오 처리처럼 성능이 결정적인 특수한 상황에서만 고려하고, 일반 UI·네트워크 코드에서는 가독성을 우선합니다.

앱 코드만 작성하는 팀이라면 @inlinable을 쓸 일이 거의 없습니다. 같은 모듈 안에서는 컴파일러가 알아서 인라인하고, Whole Module Optimization을 켜면 파일 경계도 넘어 최적화하기 때문입니다.

// 개념 예시(실제는 모듈·가시성과 함께 설계)
@inlinable
public func clamp(_ v: Int, _ lo: Int, _ hi: Int) -> Int {
    min(max(v, lo), hi)
}

성능을 의심한다면 Instruments의 Time Profiler와 Allocations로 해당 지점만 측정하세요. 최적화 전후를 같은 입력으로 비교하는 것이 핵심입니다.


실전에서 배운 것들

레이블은 결국 호출부가 잘 읽히는지가 기준입니다. to:, for: 덕분에 호출이 문장처럼 읽히면 좋고, 반대로 모든 인자를 _로 만들어 버리면 나중에 “이 두 번째 인자가 뭐였지?” 하고 선언부를 다시 열게 됩니다. inout은 짧은 스왑이나 부분 갱신에만 쓰고, 도메인 전체로 암시적인 변경이 퍼지지 않게 하는 편이 나중에 덜 고생합니다.

map과 filter를 이어 붙이는 것도 두세 단계까지는 의도가 잘 보이지만, 열 단계가 넘어가면 중간 결과를 확인하려고 브레이크포인트를 걸기가 어려워집니다. 그럴 때는 중간 결과를 이름 있는 상수로 끊어 두면 디버깅이 쉬워집니다. 문자열을 다룰 때 다른 언어 습관대로 Int 인덱스를 쓰려 하면 컴파일이 되지 않는데, Swift의 String.Index는 유니코드 문자 경계를 지키기 위한 설계라서 firstIndex(of:)나 prefix(_:) 같은 API를 쓰는 쪽이 결국 편합니다.

Xcode가 self.를 붙이라거나 @escaping을 추가하라는 Fix-it을 제안할 때는 그냥 받아들이기 전에 그 클로저가 누구보다 오래 사는지 한 번 따져 보세요. 순환 참조는 대부분 그 순간에 만들어집니다.


같이 보면 좋은 글

자주 묻는 질문 (FAQ)

Q. map에서 인덱스가 필요하다면?

A. array.enumerated().map { (i, v) in ... }처럼 enumerated()를 거치면 됩니다. 다만 enumerated()의 i는 0부터 세는 순번이라 ArraySlice처럼 시작 인덱스가 0이 아닌 컬렉션에서는 실제 인덱스와 다릅니다. 그럴 때는 zip(array.indices, array)를 쓰세요.

Q. trailing closure는 언제 피하는 편이 좋을까?

A. 클로저 인자가 여러 개이고 역할이 대칭적이지 않을 때입니다. 첫 번째 클로저에는 레이블이 붙지 않기 때문에 어느 블록이 무슨 역할인지 읽기 어려울 수 있습니다. 그런 경우에는 괄호 안에 레이블과 함께 넘기는 편이 명확합니다.

Q. unowned는 언제 써도 안전한가?

A. 참조 대상이 클로저보다 항상 오래 산다는 것이 구조적으로 보장될 때만 안전합니다. 예를 들어 자식 객체가 자신을 소유한 부모를 가리키는 경우입니다. 조금이라도 의심된다면 weak를 쓰고 guard let self로 조기 반환하세요.