반응형
Swift에서 Lazy 컬렉션
Swift에서 컬렉션 연산은 기본적으로 즉시 평가(Eager) 입니다. map, filter 등을 체인으로 연결하면 단계마다 중간 배열이 생성되어 메모리·시간 비용이 쌓일 수 있습니다. 이때 .lazy를 사용하면 지연 평가(Lazy Evaluation) 로 동작하여 중간 배열을 만들지 않고 요소가 필요할 때만 계산합니다.
1. 핵심 요약
- .lazy는 Sequence/Collection 위에 게으른 뷰를 제공합니다.
- lazy.map / lazy.filter / lazy.compactMap 등은 중간 배열을 만들지 않음.
- first(where:), contains(where:) 와 함께 쓰면 조기 종료 + 무중간 메모리.
- 최종적으로 배열이 필요하다면 Array(...) 또는 .map { $0 } 같은 종단 연산으로 materialize.
2. 언제 쓰나?
- 대용량 데이터에서 map → filter → map ... 체인이 길 때
- contains(where:), first(where:) 처럼 일부만 검사하면 되는 로직
- 비용이 큰 변환(예: 정규식·JSON 파싱·이미지 처리)의 불필요한 계산 회피
- SwiftUI·Combine 환경에서 지연된 데이터 변환이 필요할 때
3. 기본 문법과 타입
let numbers = Array(0..<10)
let eager = numbers.map { $0 * 2 } // 즉시 평가, 중간 배열 생성
let lazyMapped = numbers.lazy.map { $0 * 2 } // LazyMapSequence<Int>
let resultArray = Array(lazyMapped) // 여기서 비로소 materialize
numbers.lazy.map { ... }의 반환 타입은 LazyMapSequence 로, 요소를 접근할 때마다 변환이 수행됩니다.
4. 성능 관점: 중간 배열 제거
let users = (0..<1_000_000).map { "user-\($0)" }
// Eager (비추천: 중간 배열 2번 생성)
let e1 = users
.map { $0.uppercased() } // 큰 중간 배열 #1
.filter { $0.hasPrefix("USER-9") } // 큰 중간 배열 #2
.first // 첫 요소만 필요→낭비
// Lazy (추천: 중간 배열 생성 없음, 첫 매칭에서 종료)
let e2 = users.lazy
.map { $0.uppercased() }
.filter { $0.hasPrefix("USER-9") }
.first() // LazySequence의 확장 메서드
위 lazy 버전은 첫 매칭에서 즉시 종료하며, 중간 배열이 전혀 생성되지 않습니다.
5. 종단 연산(Terminal Operation)
지연 평가를 끝내고 실제 컬렉션으로 만들려면 아래 같이 materialize 합니다.
let out = Array(numbers.lazy.map { $0 * $0 }) // 여기서만 배열 할당 발생
다만 정말 배열이 필요할 때만 materialize 하세요. 그렇지 않다면 지연 상태 유지가 성능상 유리합니다.
6. Lazy + 조기 종료(best combo)
let big = 1...10_000_000
// Lazy + first(where:) = 첫 true에서 즉시 종료, 중간 메모리 0
if let n = big.lazy.first(where: { $0 % 777_777 == 0 }) {
print("hit:", n)
}
// Lazy + contains(where:) = 존재 여부만 빠르게
let hasSquare = big.lazy.contains { x in
let r = Int(Double(x).squareRoot())
return r * r == x
}
7. map / filter / compactMap / reduce와의 관계
- map, filter, compactMap은 lazy에서 지연 변환으로 동작합니다.
- reduce는 lazy라도 모든 요소를 소비하는 종단 연산이므로 중간 배열은 없지만 순회는 끝까지 합니다.swift let s = (1...10_000_000).lazy .filter { $0.isMultiple(of: 2) } .reduce(0, +) // 전체 순회 필요 (그래도 중간 배열 없음)
8. Lazy에서 주의할 점
- 기저 컬렉션 변경에 민감: lazy 뷰는 기저 데이터에 대한 뷰입니다. 기저를 변경하면 지연 결과도 달라집니다.
- 다중 순회 비용: LazySequence는 필요할 때마다 계산합니다. 같은 lazy를 여러 번 순회하면 중복 계산이 발생할 수 있습니다. 재사용하려면 materialize.
- 부수 효과 금지: lazy 변환 클로저에서 외부 상태를 변경하는 부수 효과는 피하세요. 평가 시점이 지연되어 예측이 어려워집니다.
- 측정은 릴리즈 빌드에서: 성능 평가는 반드시 Release + 최적화로 확인.
9. 실전 패턴
9.1 Lazy + 파일 스트림(의사 코드)
// 의사 코드: 한 줄씩 읽히는 시퀀스를 lazy 변환한다고 가정
let lines: AnySequence<String> = FileLineSequence(url: logURL).eraseToAnySequence()
let firstError = lines.lazy
.compactMap { $0.range(of: "ERROR:") != nil ? $0 : nil }
.first
9.2 Lazy + 대용량 JSON 배열 변환
struct User: Decodable { let id: Int; let name: String }
let raw: [Data] = loadLargeJsonArray() // 수만 개 Data 조각
// 중간 배열 없이 필요한 때만 디코딩
let stream = raw.lazy.compactMap { try? JSONDecoder().decode(User.self, from: $0) }
if let firstAdmin = stream.first(where: { $0.name == "admin" }) {
print(firstAdmin)
}
9.3 Lazy + 중첩 컬렉션 평탄화
let matrix = [[1,2,3],[4,5,6],[7,8,9]]
let evens = matrix.lazy
.joined() // LazyFlattenSequence
.filter { $0 % 2 == 0 }
.map { $0 * 10 }
.prefix(3) // 3개만 필요
print(Array(evens)) // [20, 40, 60]
9.4 Lazy + 문자열 처리
let text = " Hello, Swift Lazy! "
let tokens = text.split(separator: " ").lazy
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
print(Array(tokens)) // ["Hello,", "Swift", "Lazy!"]
9.5 Lazy + 이미지 파이프라인(의사 코드)
// 의사: 썸네일 URL 시퀀스를 lazy 로 내려받고 디코딩
let thumbs: [URL] = loadManyThumbURLs()
let decoded = thumbs.lazy
.compactMap { try? Data(contentsOf: $0) }
.compactMap { UIImage(data: $0) } // iOS
.prefix(20) // 필요한 개수만
let first20 = Array(decoded)
10. eager vs lazy 비교 테이블
| 구분 | Eager 체인 | Lazy 체인 |
| 중간 배열 | 단계별 생성 | 생성 안 함 |
| 메모리 사용 | 커짐 | 최소 |
| 조기 종료 | 어려움 | 쉬움 (first, contains) |
| 계산 시점 | 즉시 | 접근 시 |
| 코드 복잡도 | 동일 | 동일(선두에 .lazy만 추가) |
11. 벤치마크 스켈레톤
import Foundation
let data = Array(0..<2_000_000)
func measure(_ name: String, _ block: () -> Void) {
let t1 = CFAbsoluteTimeGetCurrent()
block()
let t2 = CFAbsoluteTimeGetCurrent()
print(name, ":", t2 - t1, "sec")
}
measure("eager-first") {
_ = data.map { $0 * 2 }.filter { $0 % 3 == 0 }.first
}
measure("lazy-first") {
_ = data.lazy.map { $0 * 2 }.filter { $0 % 3 == 0 }.first()
}
measure("eager-materialize") {
_ = data.map { $0 * 2 }.filter { $0 % 3 == 0 }
}
measure("lazy-materialize") {
_ = Array(data.lazy.map { $0 * 2 }.filter { $0 % 3 == 0 })
}
12. 참조 링크
- Sequence / Collection 개요: https://developer.apple.com/documentation/swift/sequence
- LazySequenceProtocol: https://developer.apple.com/documentation/swift/lazysequenceprotocol
- LazyMapSequence: https://developer.apple.com/documentation/swift/lazymapsequence
- LazyFilterSequence: https://developer.apple.com/documentation/swift/lazyfiltersequence
- Flattening(joined): https://developer.apple.com/documentation/swift/sequence/3018376-joined
- first(where:): https://developer.apple.com/documentation/swift/sequence/first(where:)
- contains(where:): https://developer.apple.com/documentation/swift/sequence/contains(where:)
13. 결론
- .lazy는 중간 배열 제거와 지연 평가로 성능과 메모리를 동시에 잡는 강력한 도구입니다.
- 조기 종료가 가능한 종단 연산(first, contains, prefix, prefix(while:))와 결합하면 효과가 극대화됩니다.
- 부수 효과 없는 순수 변환을 작성하고, 정말 필요할 때만 materialize 하세요.
반응형
'Dev Study > Swift' 카테고리의 다른 글
| Swift에서 append(contentsOf:) vs 반복 append (0) | 2025.11.11 |
|---|---|
| Swift에서 reduce(into:) vs reduce (0) | 2025.11.11 |
| Swift에서 compactMap vs map + filter (0) | 2025.11.11 |
| Swift에서 map vs forEach (0) | 2025.11.11 |
| Swift에서 contains(where:) vs filter().count > 0 (0) | 2025.11.10 |
| Swift에서 for-in vs 인덱스 기반 반복(0..<array.count) (0) | 2025.11.10 |
| Swift에서 count > 0 대신 isEmpty를 사용하는 이유 (0) | 2025.11.10 |
| Swift 6.1 기능 정리 (0) | 2025.11.10 |

