Sequence, Collection, lazy, 슬라이스, 정렬 — 이 다섯은 컬렉션을 다루는 코드가 실제로 몇 번 도는지, 메모리를 얼마나 오래 붙잡는지, 정렬 결과를 얼마나 믿어도 되는지를 결정하는 계약(contract)이다. 프로토콜 하나하나가 실제로 파는 건 메서드 목록이 아니라 "이 연산이 O(1)인지 O(n)인지"에 대한 약속이고, lazy와 슬라이스는 그 약속을 언제 지키고 언제 미루느냐의 문제다. 이 챕터는 그 계약을 어겼을 때 Swift가 정말로 크래시를 내는지, 아니면 조용히 틀린 결과를 주는지까지 전부 직접 실행해서 확인한다.
Q1. Sequence / Collection / BidirectionalCollection / RandomAccessCollection은 각각 무엇을 추가로 보장하는가?
이 네 프로토콜은 메서드 목록이 아니라 복잡도 계약의 계층이다. Sequence는 for-in은 되지만 여러 번 순회해도 처음부터 다시 돈다는 보장이 없다(단일 패스, 심하면 소비형일 수 있다). Collection은 다회 순회(멀티패스)를 보장하고 인덱스로 저장해둔 위치를 나중에 다시 찾아갈 수 있으며, subscript·startIndex·endIndex가 O(1)이길 기대한다(못 지키면 문서화해야 하는 계약이다). BidirectionalCollection은 여기에 역방향 이동을 더한다. RandomAccessCollection은 인덱스 간 거리 계산과 오프셋 이동 자체가 O(1)이라는 가장 강한 보장을 추가한다 — count가 O(1)이 되는 것도 이 계층의 부산물이다. 실측으로 확인하면 500만 개짜리 Array.count는 약 3마이크로초, 같은 크기의 String.count는 약 4.6밀리초로 1,500배 가까이 차이 난다.
원리
Apple 공식 Sequence 문서는 이 계층의 첫 계약을 이렇게 정의한다 — Sequence는 "준수 타입이 순회로 인해 파괴적으로 소비되는지 여부에 대해 아무런 요구도 하지 않는다". 즉 두 번째 for-in이 처음부터 다시 돈다는 보장을 언어가 해주지 않는다. 다회 순회가 필요하면 Collection을 채택하라고 문서가 직접 명시한다. Collection 문서는 한 걸음 더 나아가 "Collection을 준수하는 타입은 startIndex·endIndex 프로퍼티와 subscript를 O(1) 연산으로 제공할 것으로 기대된다"고 못 박는다 — 이걸 어기면(예: 링크드 리스트 기반 컬렉션) 그 사실을 반드시 문서화해야 한다. BidirectionalCollection과 RandomAccessCollection은 각각 "역방향으로도 O(1) 이동이 되는가", "인덱스 사이의 거리·오프셋 계산 자체가 O(1)인가"를 추가로 보장하는 상위 계층이다.
내부 동작
이 계약들이 실제로 갈리는 지점은 count다. Collection의 기본 구현은 count를 distance(from: startIndex, to: endIndex)로 계산하는데, RandomAccessCollection이 아니면 이 distance 자체가 인덱스를 하나씩 옮기며 세야 하는 O(n) 연산이다. Array는 RandomAccessCollection이라 내부에 저장된 카운트 필드를 그대로 읽어 O(1)이지만, String의 Character 뷰는 BidirectionalCollection일 뿐이라 그래핌 클러스터 경계를 처음부터 다시 계산해야 해서 O(n)이다. 재미있는 예외가 Dictionary·Set이다 — 이 둘은 실측해보면 BidirectionalCollection조차 아니고 딱 Collection만 채택하는데도 count는 O(1)이다. 이유는 "RandomAccessCollection이라서"가 아니라, 해시테이블이 살아있는 엔트리 수를 별도 필드로 항상 들고 있어서 count 자체를 그 필드를 읽는 것으로 직접 오버라이드했기 때문이다. 즉 "count가 O(1)인가"는 프로토콜 계층만으로 100% 결정되는 게 아니라, 그 타입이 자체적으로 얼마나 값싼 count를 제공하도록 구현했는지에도 달려 있다.
| 프로토콜 | 추가로 보장하는 것 | 대표 연산 복잡도 | 예시 타입 |
|---|---|---|---|
Sequence | 순회 가능(for-in). 다회 순회·비파괴 순회는 보장 안 함 | makeIterator() O(1) 기대, 그 외는 문서화 없으면 O(n) 가정 | 커스텀 스트림(예: 네트워크 페이지네이터), AnyIterator 래퍼 |
Collection | 다회 순회(멀티패스) 보장 + 인덱스를 저장해 나중에 재접근 가능 | subscript·startIndex·endIndex O(1) 기대(위반 시 문서화 의무), count는 기본적으로 O(n) | Dictionary, Set(단 count는 자체 구현으로 O(1) — 예외) |
BidirectionalCollection | 역방향 이동 추가 | index(before:) O(1) 기대 | String의 Character 뷰 |
RandomAccessCollection | 인덱스 간 거리·오프셋 계산 자체가 O(1) | distance(from:to:)·index(_:offsetBy:)·count 모두 O(1) | Array, ContiguousArray, ArraySlice, Range<Int> |
복잡도 실측: Swift 6.2.1(arm64-apple-macosx26)에서 300만~500만 원소 기준, Array.count ≈ 3.1마이크로초, String.count(500만 문자) ≈ 4.58밀리초. Array.distance(from:to:) ≈ 5.0마이크로초, String.distance(from:to:)(300만 문자) ≈ 2.06밀리초. Array.index(_:offsetBy:)는 측정 불가할 만큼 빠른 반면 String.index(_:offsetBy:)는 ≈ 2.18밀리초. Dictionary(300만 엔트리)의 count는 ≈ 6.9마이크로초로 여전히 O(1)이다.
실험 · 도구
아래 두 스니펫을 swift 파일명.swift로 그대로 실행하면 위 표의 수치를 직접 재현할 수 있다. 두 번째 스니펫은 Sequence가 다회 순회를 보장하지 않는다는 걸 소비형(destructive) 타입으로 직접 증명한다.
import Foundation
let bigArr = Array(0..<5_000_000)
let bigStr = String(repeating: "a", count: 5_000_000)
var t = Date()
_ = bigArr.count
print("Array.count:", Date().timeIntervalSince(t)) // 실측 ≈ 0.0000031초
t = Date()
_ = bigStr.count
print("String.count:", Date().timeIntervalSince(t)) // 실측 ≈ 0.00458초 — 약 1,500배 차이
// Dictionary는 BidirectionalCollection도 아니지만 count는 여전히 O(1)
var d = [Int: Int](minimumCapacity: 3_000_000)
for i in 0..<3_000_000 { d[i] = i }
t = Date()
_ = d.count
print("Dictionary.count:", Date().timeIntervalSince(t)) // 실측 ≈ 0.0000069초// makeIterator()의 기본 구현은 "내가 곧 Iterator"일 때 self를 그대로 돌려준다.
// class는 참조 타입이라 매번 '같은 인스턴스'가 나가고, 그 인스턴스의 내부 상태(remaining)는
// 첫 순회에서 이미 다 소비돼버린다 — 이게 "소비형(destructive) Sequence"다.
final class NetworkPageStream: IteratorProtocol, Sequence {
var remaining: [Int]
init(_ values: [Int]) { remaining = values }
func next() -> Int? {
guard !remaining.isEmpty else { return nil }
return remaining.removeFirst()
}
}
let stream = NetworkPageStream([1, 2, 3])
for x in stream { print(x) } // 1 2 3
for x in stream { print(x) } // 아무것도 안 찍힘 — 이미 다 소비됨
// ✅ 다회 순회가 필요하다면 Sequence가 아니라 Collection을 채택해야 한다.프로젝트 적용
① String을 다루는 코드에서 count를 반복 호출(매 프레임, 매 셀 레이아웃 등)하고 있다면 그 자체가 O(n) 누적 비용이다. 결과를 캐시하거나, 비어있는지만 알면 되는 경우 O(1)인 isEmpty로 바꿔라.
struct MessageRow {
let text: String
// ❌ 셀이 다시 그려질 때마다 O(n)으로 그래핌 경계를 다시 계산한다
var isTooLongBad: Bool { text.count > 200 }
// ✅ "비어있는지"만 필요하면 O(1)인 isEmpty로
var isEmptyGood: Bool { text.isEmpty }
// ✅ 반복 조회가 필요하면 계산 시점에 한 번만 캐시해서 들고 있는다
let cachedCount: Int
init(text: String) {
self.text = text
self.cachedCount = text.count // 여기서 딱 한 번 O(n)
}
}② 커스텀 타입을 Sequence로만 만들어두고 여러 번 순회할 걸 기대하는 코드는 함정이다. 다회 순회가 필요하면 처음부터 Collection을 채택하라.
// ✅ Collection을 채택하면 인덱스 기반 재접근·멀티패스가 '계약으로' 보장된다
struct RecentEvents: Collection {
private var buffer: [String]
var startIndex: Int { buffer.startIndex }
var endIndex: Int { buffer.endIndex }
func index(after i: Int) -> Int { buffer.index(after: i) }
subscript(position: Int) -> String { buffer[position] }
}
// 이렇게 만들면 이 타입을 두 번, 세 번 순회해도 항상 같은 결과가 나온다는 걸
// 호출부가 컴파일러 수준의 계약으로 믿을 수 있다."count가 O(1)이려면 RandomAccessCollection이어야 한다"는 대체로 맞지만 100% 진실은 아니다 — Dictionary·Set이 반례다. 반대로 "Sequence를 채택했으니 두 번째 for-in도 항상 안전하다"는 확정적으로 틀렸다. 위 NetworkPageStream 예시가 그 증거다.
두루마리, 페이지 책, 목차 있는 책 세 가지로 생각해보자. Sequence는 두루마리다 — 앞에서부터 펼치며 읽을 순 있지만, 다 읽은 두루마리를 되감아 준다는 보장이 없다. Collection은 페이지 번호가 매겨진 책이다 — 아무 페이지나 다시 펼 수 있고, 몇 번을 다시 읽어도 내용이 그대로다. RandomAccessCollection은 목차에 각 장이 몇 쪽인지까지 적혀 있는 책이다 — "300쪽에서 500쪽까지 몇 쪽 차이인지"를 세어보지 않고 바로 뺄셈으로 안다. 비유가 깨지는 곳: 진짜 책은 페이지 번호가 있어야 목차도 의미가 있는데, Dictionary는 페이지 번호(오프셋) 자체가 없는데도 "총 몇 쪽인지"(count)만은 표지에 이미 적혀 있어서 바로 안다 — 이 챕터에서 본 예외적인 지름길이다.
꼬리 질문
Collection이 아니라 Sequence만 채택한 타입을 두 번 순회하면 실제로 어떤 코드에서 자주 사고가 나는가?
makeIterator()가 그 상태를 복사하지 않고 공유하는 경우(특히 class 기반)에 자주 일어난다. 커스텀 파서, 네트워크 페이지네이터, 로그 스트림 같은 걸 Sequence로 감쌀 때 makeIterator()를 매번 새로 만들지 않고 self를 그대로 반환하면 두 번째 for-in부터 빈 순회가 된다. 이런 타입을 여러 곳에서 재사용(한 번은 개수를 세는 용도로, 한 번은 실제 처리 용도로)하면 두 번째 소비 지점이 조용히 비어버려 디버깅이 오래 걸린다.MutableCollection·RangeReplaceableCollection은 이 계층에서 어디에 낀 사촌인가?
Array가 BidirectionalCollection이면서 동시에 RandomAccessCollection이라는 사실이 알고리즘 구현에는 실제로 왜 중요한가?
where 절에 RandomAccessCollection 제약을 걸어두고, "인덱스 오프셋 이동이 O(1)"이라는 전제 위에서 전체 복잡도를 설계한다. 이 전제 없이 BidirectionalCollection 수준(역방향 이동만 O(1))에서 같은 알고리즘을 돌리면, 피벗이나 중간 지점을 찾는 매 단계가 O(n)이 되어 전체 복잡도가 눈에 띄게 나빠질 수 있다. 그래서 stdlib은 이런 알고리즘을 RandomAccessCollection에만 열어주는 경우가 있다.Q2. lazy 시퀀스는 언제 도움이 되고 언제 배신하는가? — 부수효과와 다중 순회
lazy는 map/filter 같은 변환을 즉시 계산하지 않고, 실제로 값을 꺼내 쓰는 순간(순회·subscript 접근)까지 미루는 뷰다. 도움이 되는 순간은 뚜렷하다 — .lazy.filter{}.first{}처럼 일부만 필요한 체이닝에서 중간 배열을 만들지 않고 조건을 만족하는 즉시 멈춘다. 실측으로 100만 개짜리 범위에 lazy를 걸고 first를 찾으면 map 클로저가 딱 11번만 실행되는 반면, eager 버전은 100만 번을 전부 계산한 다음에야 first를 고른다. 배신은 두 갈래다 — (1) lazy는 결과를 캐시하지 않아서 같은 lazy 시퀀스를 두 번 순회하면 클로저가 두 번 다 처음부터 실행된다(부수효과가 있으면 그대로 두 배가 된다). (2) 체이닝·짧은 회로의 이득이 없는 단발성 연산에서는 lazy의 래퍼 구조 자체가 얹는 오버헤드가 이득보다 커서 오히려 더 느릴 수 있다(디버그 빌드에서 실측 2배 이상).
원리
Apple 공식 문서는 sequence.lazy가 LazySequence를 돌려주고, 그 위에 건 map/filter 연산이 즉시 실행되지 않고 지연된다고 정의한다. 이 지연은 "나중에 한 번 계산해서 캐시해준다"는 뜻이 아니다 — lazy 연산은 결과를 어디에도 저장하지 않고, 매번 꺼내 쓰는 시점에 원본과 변환 함수를 다시 조합해 값을 만들어낸다. 이 설계가 "짧은 회로"와 "부수효과 재실행"이라는 두 얼굴을 동시에 만드는 근본 원인이다.
내부 동작
.lazy.map(f)는 값을 즉시 배열에 담는 대신 (base, f) 쌍만 들고 있는 LazyMapSequence 구조체를 만든다. 여기에 filter를 이어 걸면 다시 그 구조체 전체를 감싸는 LazyFilterSequence<LazyMapSequence<...>>가 되는 식으로, 체이닝할수록 제네릭 타입이 점점 깊게 중첩된다. 이 중첩 구조체는 순회(next() 호출이나 subscript 접근) 시점에야 f를 실제로 실행하고, first(where:)처럼 앞에서부터 하나씩 요청하는 소비자는 조건을 만족하는 원소를 찾는 즉시 나머지 순회를 중단시킨다 — 그래서 eager라면 무조건 다 계산했을 나머지 999,989개의 계산이 통째로 생략된다. 반대로 sorted()나 count처럼 전체를 다 봐야 답이 나오는 연산은 lazy 체인 뒤에 붙여도 결국 배열로 즉시(eager) 확정된다 — 실측으로 base.lazy.map{...}.sorted()의 런타임 타입은 여전히 Array<Int>다. lazy는 "가능하면 미룬다"는 태도일 뿐, 결과를 확정해야 하는 연산 앞에서는 결국 다 계산한다.
실험 · 도구
아래 두 스니펫을 그대로 실행하면 "캐시 없음"과 "짧은 회로"를 각각 직접 눈으로 확인할 수 있다.
var callCount = 0
let base = [1, 2, 3, 4, 5]
let lazySeq = base.lazy.map { (x: Int) -> Int in
callCount += 1
return x * 2
}
_ = Array(lazySeq) // 첫 번째 순회
print(callCount) // 실측: 5
_ = Array(lazySeq) // 같은 lazySeq를 다시 순회 — 재계산!
print(callCount) // 실측: 10 (5가 아니라)var evalCountLazy = 0
let lazyResult = (1...1_000_000).lazy.map { (n: Int) -> Int in
evalCountLazy += 1
return n * 2
}.first { $0 > 20 }
print(evalCountLazy) // 실측: 11
var evalCountEager = 0
let eagerResult = (1...1_000_000).map { (n: Int) -> Int in
evalCountEager += 1
return n * 2
}.first { $0 > 20 }
print(evalCountEager) // 실측: 1,000,000 — first(where:) 전에 map이 이미 다 끝나 있다프로젝트 적용
① 큰 컬렉션에서 조건을 만족하는 첫 원소만 필요하면 .lazy를 걸어 중간 배열 생성 자체를 없애라.
struct User { let id: Int; let email: String }
let users: [User] = /* 수십만 건 */ []
// ❌ filter가 조건에 맞는 전부를 배열로 만든 다음에야 first를 고른다
func findByEmailBad(_ target: String) -> User? {
users.filter { $0.email == target }.first
}
// ✅ lazy면 email이 일치하는 첫 원소를 찾는 즉시 나머지를 보지 않는다
func findByEmailGood(_ target: String) -> User? {
users.lazy.filter { $0.email == target }.first
}② 부수효과가 있는 클로저(로깅, 카운터 증가, 네트워크 호출 등)를 lazy 체인에 넣지 마라. 여러 번 쓸 결과라면 즉시 Array(...)로 확정해 캐시하라.
// ❌ 이 lazy 시퀀스를 두 번 쓰면(한 번은 개수 세기, 한 번은 실제 출력) 로그도 두 번 남는다
let riskyLazy = rawEvents.lazy.map { event -> String in
Logger.log("processing \(event)") // 부수효과!
return event.description
}
// ✅ 여러 번 쓸 거라면 즉시 확정해서 캐시한다 — 계산은 딱 한 번만
let processedEvents = Array(rawEvents.lazy.map { event -> String in
Logger.log("processing \(event)")
return event.description
})체이닝·짧은 회로의 이득이 없는 단발성 연산이라면 lazy를 붙이지 않는 게 낫다 — 20개짜리 작은 배열에 map 하나만 걸고 바로 Array로 확정하는 걸 100만 번 반복했을 때, Debug 빌드(-Onone) 실측에서 eager가 lazy보다 2배 이상 빨랐다(0.94초 vs 2.11초). 반대로 최적화 빌드(-O)에서는 컴파일러가 래퍼를 인라인해 순위가 뒤집혔다(0.074초 vs 0.032초) — "무조건 lazy"도 "무조건 eager"도 아니라, 실제 접근 패턴(체이닝 여부·짧은 회로 여부·빌드 설정)에 따라 실측하고 고르는 게 맞다.
"lazy는 결과를 한 번 계산해서 캐시해준다"는 틀렸다 — 매번 재계산한다는 걸 위에서 실측으로 증명했다. "lazy를 쓰면 무조건 더 빠르다"도 틀렸다 — 짧은 회로 이득이 없는 단발성 연산에서는 Debug 빌드 기준 오히려 2배 이상 느려질 수 있다.
주문이 들어오면 즉시 다 만들어서 쌓아두는 뷔페(eager)와, 손님이 앞에 왔을 때만 그 자리에서 만들어주는 즉석 요리 코너(lazy)를 생각해보라. 즉석 코너는 손님이 "매운 거 딱 하나만" 원하면 첫 매운 요리가 나오는 순간 나머지 재료는 손도 안 댄다 — 이게 짧은 회로다. 그런데 이 즉석 코너는 레시피를 기억해두지 않는다. 같은 주문을 또 넣으면 처음부터 다시 요리한다. 비유가 깨지는 곳: 진짜 즉석 코너는 재료 손질에 실제로 시간이 걸리지만, 코드의 lazy 래퍼는 "준비 과정 자체"에 작은 비용이 있어서 손님이 한 명뿐이고 한 접시만 시키면 오히려 미리 다 차려둔 뷔페보다 늦게 나올 수도 있다.
꼬리 질문
lazy 시퀀스를 저장 프로퍼티로 오래 들고 있으면 무슨 문제가 생기나?
.lazy.filter{...}로 감싸 뷰모델 프로퍼티에 저장해두면, 실제로 쓰는 건 필터링된 일부뿐인데도 원본 전체가 메모리에서 풀리지 않는다 — 뒤에 나올 Substring/ArraySlice의 메모리 함정과 본질적으로 같은 문제다.sorted()나 count처럼 전체를 다 봐야 하는 연산을 lazy 체인 뒤에 붙이면 어떻게 되나?
base.lazy.map{...}.sorted()의 런타임 타입은 LazySequence가 아니라 그냥 Array<Int>다. 정렬은 원소 전체를 비교해야 하는 연산이라 lazy를 아무리 앞에 붙여도 그 시점에 무조건 전체를 순회해 배열로 확정한다. lazy는 "가능한 만큼만 미루는" 태도이지, 확정이 필요한 연산 앞에서 거부권을 행사하지는 않는다.왜 Debug 빌드(-Onone)에서 유독 lazy 오버헤드가 두드러지나?
Q3. Substring / ArraySlice를 오래 들고 있으면 왜 위험한가?
Substring과 ArraySlice는 "필요한 부분만 복사해서 가볍다"는 인상과 정반대로, 원본 스토리지 전체에 대한 참조를 그대로 들고 있다. 6천만 개짜리 "파일 레코드" 배열을 파싱해서 1개짜리 슬라이스만 남겨도, 그 슬라이스가 살아있는 한 원본 480MB 버퍼 전체가 메모리에서 풀리지 않는다 — 실측으로 원본 지역 변수가 스코프를 벗어나도 프로세스 메모리(RSS)는 480MB대에 그대로 머물렀다. 또한 슬라이스의 인덱스는 0부터 시작한다는 보장이 없다 — 원본 배열에서의 오프셋을 그대로 물려받으므로 slice[0]은 "Index out of bounds" 크래시로 이어질 수 있다(실측으로 재현).
원리
Apple 공식 Substring 문서는 이 설계를 명시적으로 경고한다 — "서브스트링은 원본 문자열의 스토리지를 공유한다... 서브스트링은 보이는 부분만이 아니라 원본 스토리지 전체에 대한 참조를 들고 있다. 이는 더 이상 접근할 수 없는 문자열 데이터의 수명을 연장시켜, 마치 메모리 누수처럼 보이게 만들 수 있다." 그래서 문서는 서브스트링을 짧은 시간만 쓰고, 오래 보관할 값은 반드시 String으로 변환하라고 권한다. TSPL도 같은 원칙을 "문자열에 대해 잠깐 동작을 수행하는 동안만 서브스트링을 쓰고, 오래 저장하려면 String 인스턴스로 변환하라"고 못박는다. ArraySlice도 동일한 설계를 공유한다 — 배열의 일부만 보여주는 창(window)일 뿐, 뒤에 있는 버퍼는 원본과 통째로 같다.
내부 동작
이 공유는 스토리지 클래스(참조 타입)를 그대로 넘겨받는 방식으로 구현된다. Array·ArraySlice·String·Substring 모두 실제 원소를 담은 힙 버퍼는 클래스 인스턴스이고, 슬라이스는 이 버퍼에 대한 참조 + startIndex/endIndex(또는 바이트 범위)만 따로 들고 있다. 그래서 array[2...4]로 만든 ArraySlice의 startIndex는 0이 아니라 그 슬라이스가 시작하는 원본 기준 오프셋(이 경우 2)이다 — 슬라이스마다 시작 인덱스가 다 다를 수 있다는 뜻이고, slice[0]처럼 정수 리터럴을 그대로 서브스크립트에 넣으면 그 인덱스가 범위 밖이라 런타임 트랩이 난다. 실측으로 확인하면 String과 그 suffix(6) 결과인 Substring은 withContiguousStorageIfAvailable로 얻은 데이터 포인터가 정확히 1,000,000바이트(="TARGET"이 시작하는 위치) 차이만 나고, 그 외에는 완전히 같은 버퍼를 가리킨다 — 별도로 복사된 데이터가 아니라는 직접적인 증거다.
실험 · 도구
두 방식으로 직접 증명할 수 있다. 하나는 포인터 오프셋으로 "같은 버퍼인가"를 확인하는 것이고, 다른 하나는 실제 프로세스 메모리(RSS)를 mach_task_basic_info로 읽어 버퍼가 정말 안 풀리는지 확인하는 것이다.
let longString = String(repeating: "x", count: 1_000_000) + "TARGET"
let sub = longString.suffix(6) // Substring "TARGET"
let baseP = longString.utf8.withContiguousStorageIfAvailable { Int(bitPattern: $0.baseAddress) } ?? 0
let subP = sub.utf8.withContiguousStorageIfAvailable { Int(bitPattern: $0.baseAddress) } ?? 0
print(subP - baseP) // 실측: 1,000,000 — "TARGET"이 시작하는 정확한 바이트 오프셋.
// 새로 복사된 데이터라면 이 차이는 아무 의미가 없어야 한다.import Darwin
func residentMB() -> Double {
var info = mach_task_basic_info()
var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size / MemoryLayout<natural_t>.size)
let kerr = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
}
}
guard kerr == KERN_SUCCESS else { return -1 }
return Double(info.resident_size) / 1024 / 1024
}
func parseBigFile() -> ArraySlice<Int> {
let wholeFile = Array(0..<60_000_000) // "파일에서 읽은 6천만 개 레코드"라고 하자 (≈480MB)
return wholeFile[0..<1] // 첫 레코드 하나만 뽑아 반환
}
print(residentMB()) // 파싱 전 ≈ 5.7MB
let firstRecord = parseBigFile()
print(residentMB()) // 파싱 후, 레코드 1개만 들고 있는데도 ≈ 463.5MB — 480MB가 그대로!더 확실하게 대조하려면, 같은 크기의 배열을 슬라이스 없이 버린 다음 새로 하나 더 만들어보면 된다. 슬라이스가 없으면 이전 버퍼가 재사용돼 메모리 증가가 없지만(463MB → 463MB), 슬라이스로 붙잡은 채 새로 하나 더 만들면 이전 버퍼를 내줄 수 없어 새 할당이 온전히 더해져 거의 두 배(921MB)로 뛴다 — 직접 실행해 확인할 수 있다.
프로젝트 적용
① 파싱·검색 결과로 얻은 Substring/ArraySlice를 함수 밖으로 오래 들고 나가야 한다면, 반환 직전에 String(sub)/Array(slice)로 명시적으로 복사해서 원본 버퍼에서 완전히 떼어내라.
// ❌ 파싱 결과를 Substring 그대로 저장 — 원본 전체 버퍼가 계속 메모리에 남는다
struct ParsedHeaderBad {
let title: Substring // longString 전체 버퍼를 계속 붙든다
}
// ✅ String으로 명시 변환 — 새 작은 버퍼로 복사되어 원본과 완전히 분리된다
struct ParsedHeaderGood {
let title: String
init(from sub: Substring) {
title = String(sub) // 여기서 딱 한 번, 필요한 만큼만 복사
}
}② 슬라이스를 정수 리터럴로 직접 인덱싱하지 마라. 항상 startIndex·indices·first를 기준으로 접근하라.
let array = [10, 20, 30, 40, 50]
let slice = array[2...4] // ArraySlice, startIndex는 0이 아니라 2
// ❌ slice[0] — "Fatal error: Index out of bounds"로 크래시(실측: Swift/SliceBuffer.swift:307)
// print(slice[0])
// ✅ 안전한 접근
print(slice[slice.startIndex]) // 30
print(slice.first ?? -1) // 30"Substring/ArraySlice는 필요한 부분만 복사해서 가볍다"는 정확히 반대다 — 원본 전체 버퍼를 참조해서 오히려 큰 메모리를 붙잡아둘 수 있다. "슬라이스의 인덱스도 항상 0부터 시작한다"도 틀렸다 — 원본에서의 오프셋을 그대로 물려받는다.
도서관에서 책 한 권을 통째로 빌려와서 그중 한 문단에 포스트잇을 붙였다고 하자. 포스트잇(Substring/ArraySlice)은 그 문단만 표시하는 것처럼 보이지만, 사실은 책 전체를 계속 빌린 상태로 만들어버린다 — 포스트잇을 떼어내기 전까진 책 전체를 반납할 수 없다. 비유가 깨지는 곳: 진짜 도서관 책은 포스트잇 위치를 "몇 쪽부터"라고 남에게 새로 알려줘야 하지만, 슬라이스는 그 자체가 "원래 몇 번째였는지"를 그대로 기억하고 있어서 "0번째"라고 부르면 엉뚱한 곳을 가리키거나 크래시가 난다.
꼬리 질문
왜 Swift는 이렇게 위험해 보이는 설계(전체 버퍼 공유)를 택했나 — 그냥 항상 복사하면 안 됐나?
String.Index는 정수가 아닌데도 비슷한 함정이 있는가?
Instruments 없이 슬라이스가 버퍼를 붙잡고 있는지 코드로 확인할 방법이 있는가?
withUnsafeBufferPointer(Array 계열)나 withContiguousStorageIfAvailable(String/Substring)로 얻은 baseAddress를 비교하는 것이다. 두 주소가 오프셋만큼만 차이 나고 나머지가 겹치면 같은 버퍼를 공유한다는 뜻이다. 더 실전적인 신호로는, 같은 크기의 배열을 하나 더 만들었을 때 이전 버퍼가 재사용되는지(메모리 증가폭이 그대로인지) 대 재사용이 안 되는지(거의 두 배로 뛰는지)를 비교하는 방법도 있다 — 슬라이스가 살아있으면 이전 버퍼를 내줄 수 없어 새 할당이 온전히 추가된다.Q4. Swift의 sort()는 안정 정렬인가? — SE-0372와 그 역사
그렇다 — 현재(Swift 5.8 이후) Swift의 정렬은 공식적으로 안정 정렬임이 보증된다. 다만 이 사실은 "처음부터 쭉 그랬다"가 아니라 뒤늦게 문서로 승격된 역사를 갖고 있다. Swift 5 이전에는 introsort 계열(불안정)을 썼고 문서도 "정렬 알고리즘은 안정성을 보장하지 않는다"고 명시했었다. Swift 5부터 내부 구현이 timsort 계열의 안정 정렬로 바뀌었지만, 문서는 한동안 예전 문구를 그대로 유지해 "실제로는 안정인데 공식적으로는 보장 안 함"이라는 혼란기가 있었다. 이 간극은 SE-0372가 Swift 5.8에서 문서를 "안정 정렬을 보장한다"로 갱신하면서 닫혔다. 면접에서 "Swift sort는 불안정하다"고 확정적으로 말하면 최신 사실과 어긋난다.
원리
안정 정렬(stable sort)의 정의는 언어와 무관한 일반 개념이다 — 정렬 키가 같은 두 원소는, 정렬 후에도 정렬 전의 상대적 순서를 유지해야 한다. 이 성질은 다단계 정렬(먼저 이름으로, 그다음 나이로)을 구현할 때 중요하다 — 안정 정렬이면 "나이로 정렬했을 때 나이가 같은 사람들끼리는 직전에 이름으로 정렬해둔 순서가 그대로 유지된다"는 걸 믿고 두 번의 sort 호출만으로 다단계 정렬을 구현할 수 있다. 이 성질을 stdlib이 API 계약으로 보증하느냐 마느냐는 별개 문제다 — 내부 구현이 우연히 안정적으로 동작하는 것과, 그 동작을 앞으로도 유지하겠다고 공식 약속하는 것은 다르다.
내부 동작
실제 타임라인은 이렇다. Swift 5 이전 stdlib은 introsort(quicksort + heapsort 혼합, 불안정) 계열을 썼고 문서도 그렇게 명시했다. swiftlang/swift PR #19717을 기점으로 Swift 5의 내부 구현이 timsort 계열(안정 정렬)로 교체됐다. 문제는 이 시점이 ABI 안정성이 확정되기 전이었다는 점이다 — 알고리즘을 다시 바꿀 여지를 남겨두려고 문서는 한동안 옛 "보장 안 함" 문구를 그대로 뒀다. 그래서 2019년 무렵 Swift Forums의 "Is sort stable in Swift 5?" 스레드에서 실제로 이 간극에 대한 혼란이 논의됐다. SE-0372는 "이미 모든 ABI-stable Swift 런타임이 안정 정렬을 제공하고 있으니, 이제 이걸 공식 계약으로 문서화하자"는 제안이었고, Swift 5.8에서 채택되며 문서가 "정렬 알고리즘은 안정임이 보장된다"로 바뀌었다. 이후 알고리즘 구현이 또 바뀌더라도 안정성만은 유지해야 하는, 되돌릴 수 없는 계약이 된 것이다.
실험 · 도구
다단계 정렬을 직접 돌려보면 안정성을 눈으로 확인할 수 있다. 이름으로 먼저 정렬한 뒤 나이로 다시 정렬했을 때, 나이가 같은 사람들 사이에서 방금 정해둔 이름 순서가 유지되는지가 관건이다.
struct Person { let name: String; let age: Int }
var people = [
Person(name: "Bob", age: 30),
Person(name: "Alice", age: 30),
Person(name: "Carol", age: 25),
]
people.sort { $0.name < $1.name } // Alice, Bob, Carol
people.sort { $0.age < $1.age } // 나이만 기준으로 다시 정렬
print(people.map { $0.name })
// 실측: ["Carol", "Alice", "Bob"]
// Carol(25)이 맨 앞은 당연하고, 나이가 같은 Alice·Bob 사이에서는
// 직전 이름 정렬 순서(Alice → Bob)가 그대로 유지됐다 — 안정 정렬의 증거프로젝트 적용
① 다단계 정렬(정렬 키가 여러 개)을 구현할 때, 안정 정렬을 믿고 우선순위가 낮은 키부터 먼저 정렬한 다음 우선순위가 높은 키로 다시 정렬하는 패턴을 안전하게 써도 된다(Swift 5.8+).
struct Order { let customerName: String; let priority: Int; let placedAt: Date }
// 1) 먼저 가장 낮은 우선순위 기준(접수 시각)으로 정렬
orders.sort { $0.placedAt < $1.placedAt }
// 2) 그다음 진짜 기준(priority)으로 정렬 — priority가 같은 주문끼리는
// 안정 정렬 덕분에 1단계에서 정해둔 접수 시각 순서가 그대로 유지된다
orders.sort { $0.priority > $1.priority }
// 결과: priority 내림차순, 같은 priority 안에서는 접수 시각 오름차순② 다만 이 보증은 Swift 5.8(SE-0372) 이후에만 공식적이다. "정말 확실히" 동률 순서를 통제하고 싶다면, 정렬 키에 원래 인덱스를 명시적으로 포함시켜 안정성을 언어 계약에만 기대지 말고 직접 보장하라.
let indexed = orders.enumerated().map { (index: $0.offset, order: $0.element) }
let sortedOrders = indexed.sorted {
if $0.order.priority != $1.order.priority {
return $0.order.priority > $1.order.priority
}
return $0.index < $1.index // 동률이면 원래 순서를 명시적으로 유지
}.map { $0.order }"Swift의 sort()는 불안정하다"는 Swift 5.8(SE-0372) 이후로는 틀린 말이다. 다만 "Swift는 항상 안정 정렬이었다"도 부정확하다 — Swift 5부터 내부적으로는 안정적이었지만 공식 보증은 5.8부터다.
학생들을 먼저 이름 가나다순으로 줄 세운 다음, 다시 반 번호 순으로 줄을 세운다고 하자. 안정 정렬이면 같은 반 학생들끼리는 방금 세워둔 이름 순서가 그대로 유지된 채로 반 번호만 기준으로 재배치된다. 안정적이지 않다면 같은 반 안에서 이름 순서가 뒤죽박죽 섞여버릴 수 있다. 비유가 깨지는 곳: 이 "안정성 지킴"이 처음부터 약속된 규칙이었던 건 아니다 — 한동안은 "선생님 마음대로 섞어도 된다"는 규정이 남아있는 채로 실제로는 안 섞고 있었을 뿐이었고, 나중에야 그 규정 자체를 "앞으로도 절대 안 섞는다"로 고쳐 썼다.
꼬리 질문
SE-0372가 통과되기 전에는 정말 정렬 알고리즘이 바뀔 수도 있었나, 아니면 그냥 형식적 문구였나?
안정 정렬이 아닌 정렬(unstable sort)을 Swift에서 쓰고 싶다면 어떻게 하나?
sort()·sorted()·sort(by:) 전부 안정 정렬 계약을 따른다. 불안정 정렬이 주는 이점(대체로 약간 더 빠르거나 메모리 지역성이 좋을 수 있음)을 굳이 취하고 싶다면, 원소에 별도 인덱스를 붙이지 않는 커스텀 알고리즘을 직접 구현해야 한다 — 하지만 실무에서 이 트레이드오프가 유의미한 경우는 드물다.왜 quicksort·heapsort 계열(introsort)은 대체로 불안정하고, timsort는 안정적인가?
Q5. sorted(by:)의 strict weak ordering을 어기면 정확히 무슨 일이 일어나는가?
sorted(by:)·sort(by:)에 넘기는 predicate는 strict weak ordering(엄격한 약한 순서)이어야 한다 — 자기 자신보다 작다고 답하면 안 되고(비반사성), "A가 B보다 작고 B가 C보다 작으면 A는 C보다 작다"는 전이성이 성립해야 하며, 같은 두 원소를 비교하면 항상 같은 답을 내야 한다(일관성). 어기면 문서상 정의되지 않은 동작이다. 그런데 실제로 Swift 6.2.1에서 >= 오용, NaN이 섞인 Double 배열, 순환(가위바위보) 비교자, 완전히 무작위인 predicate까지 네 가지 전형적인 위반을 직접 돌려봐도 단 한 번도 크래시가 나지 않았다. 전부 "정렬 완료"라고 조용히 끝나면서, 실제로는 오름차순이 깨진(인접 원소끼리 순서가 뒤집힌) 배열을 돌려줬다. 진짜 위험은 크래시가 아니라 눈치채기 어려운 조용한 데이터 오염이다.
원리
strict weak ordering은 정렬 알고리즘 이론에서 "비교 함수가 최소한 이 정도는 지켜야 정렬이 의미를 가진다"고 요구하는 표준 조건이다. 세 가지 축으로 나뉜다. (1) 비반사성(irreflexivity) — 어떤 원소도 자기 자신보다 작다고 판정되면 안 된다. (2) 비교 가능 관계의 전이성 — A가 B보다 작고 B가 C보다 작으면 A도 C보다 작아야 한다. (3) 일관성 — 같은 두 값을 몇 번을 비교해도 항상 같은 답이 나와야 한다(비교 대상이 정렬 도중 바뀌면 이 조건이 깨진다). Swift의 sorted(by:)도 predicate가 이 조건을 만족해야 한다고 요구하며, 어기면 정의되지 않은 동작이라고 문서화돼 있다.
내부 동작
정렬 알고리즘(timsort 계열)은 이 세 조건이 성립한다는 전제 위에서 비교 횟수를 줄이고 병합 순서를 최적화한다. 조건이 깨지면 알고리즘이 논리적으로 불가능한 상태를 마주칠 수 있지만, 그렇다고 항상 배열 경계를 벗어나는 접근으로 이어지는 건 아니다 — 병합 기반 정렬은 "일단 비교 결과를 그대로 믿고 순서를 정하는" 방식이라, 결과가 논리적으로 말이 안 되더라도(오름차순이 깨져도) 배열 경계를 벗어나지 않고 그냥 끝까지 진행해버릴 수 있다. 실측으로는 >=, NaN, 비전이적 비교자, 무작위 비교자 네 가지를 각각 수만~수백만 원소 규모로 돌려도 전부 예외 없이 "정렬 완료"로 끝났고, 대신 결과 배열이 실제로는 정렬돼 있지 않았다.
| 위반 패턴 | 어긴 성질 | 실측 결과 (Swift 6.2.1, arm64) |
|---|---|---|
{ $0 >= $1 } | 비반사성 위반 | 크래시 없이 완료 — 우연히 일관된 내림차순 결과(8개~200만 개 모두 재현) |
NaN 포함 [Double]에 기본 < | 비교불가 관계 처리 | 크래시 없이 완료 — 그러나 오름차순이 아님(인접 위반 4곳 실측) |
| 순환(가위바위보) 비교자 | 전이성 위반 | 크래시 없이 완료 — 사실상 무작위에 가까운 순서(20만 개 실측) |
| 비교 도중 값이 바뀜 | 일관성 위반 | 크래시 없이 완료 — 부분적으로 뒤섞인 순서 |
위 네 케이스 모두 Swift 6.2.1(swiftlang-6.2.1.4.8, arm64-apple-macosx26.0)에서 직접 실행해 확인했다. "strict weak ordering을 어기면 stdlib이 바로 트랩한다"는 통념과 달리, 릴리즈 빌드에서는 크래시 없이 조용히 잘못된 결과를 돌려줬다.
실험 · 도구
아래 두 스니펫을 그대로 실행하면 크래시 없이 조용히 틀린 결과가 나오는 걸 직접 볼 수 있다.
var nums = (0..<2_000_000).map { _ in Int.random(in: 0..<10) }
nums.sort { $0 >= $1 } // a >= a는 항상 true — 비반사성 위반
print(nums.prefix(5)) // 실측: 크래시 없음, 그냥 내림차순으로 정렬됨var nums: [Double] = [5, 3, 8, 1, 9, Double.nan, 2, Double.nan, 7]
nums.sort() // Comparable의 < 를 그대로 사용. NaN과의 비교는 항상 false
print(nums)
// 실측 결과: [1.0, 3.0, 5.0, 8.0, 9.0, nan, 2.0, nan, 7.0]
// 2.0과 7.0이 9.0보다 뒤에 와 있다 — 명백히 오름차순이 아니다
var brokenAdjacent = 0
for i in 1..<nums.count where !(nums[i - 1] <= nums[i]) { brokenAdjacent += 1 }
print(brokenAdjacent) // 실측: 4 — 인접 원소 사이 오름차순이 깨진 지점이 4군데프로젝트 적용
① predicate에 <=/>=를 습관적으로 쓰지 마라. 정렬에는 반드시 엄격 부등호(<, >)를 써라.
// ❌ 흔한 실수 — "크거나 같으면"이라는 말을 그대로 코드로 옮김
items.sort { $0.priority >= $1.priority }
// ✅ 내림차순이 목적이면 엄격 부등호의 방향만 뒤집는다
items.sort { $0.priority > $1.priority }② Double/Float 배열에 NaN이 섞일 수 있다면, 정렬 전에 걸러내거나 NaN을 어디에 둘지 predicate에 명시적으로 규칙을 세워라.
// ❌ NaN이 섞이면 결과가 오름차순이라는 보장이 사라진다
values.sort()
// ✅ NaN을 먼저 걸러내고, 별도로 어디에 둘지 스스로 정한다
let (nans, finite) = values.reduce(into: ([Double](), [Double]())) { acc, v in
if v.isNaN { acc.0.append(v) } else { acc.1.append(v) }
}
let sortedValues = finite.sorted() + nans // NaN은 맨 뒤로 몰아둔다는 규칙을 직접 명시"predicate가 strict weak ordering을 어기면 Swift가 바로 크래시로 알려준다"는 실측과 다르다 — 최소한 릴리즈 stdlib에서는 네 가지 대표 위반 전부 크래시 없이 조용히 잘못된 결과를 냈다. "결과가 그럴듯해 보이면 정렬 predicate가 안전하다는 뜻이다"도 위험한 결론이다 — >=는 우연히 그럴듯한(내림차순) 결과를 냈을 뿐, 여전히 계약을 어긴 코드다.
줄자로 키를 잴 때 눈금이 어디서든 항상 같은 방식으로 읽혀야 줄을 세울 수 있다. 그런데 어떤 날은 줄자를 잘못 읽어서(가위바위보 비교자처럼) "A가 B보다 크고, B가 C보다 크고, C가 A보다 크다"는 앞뒤가 안 맞는 결과를 낸다면, 세 명을 어떻게 줄 세워도 항상 누군가는 이상한 자리에 서게 된다. Swift는 이런 상황에서 "줄자가 고장났다"고 알람을 울려주지 않는다 — 그냥 어떻게든 줄을 세우고 끝내버린다. 비유가 깨지는 곳: 실제 잘못된 줄자라면 눈으로 봐도 이상하다는 걸 금방 알아채지만, 코드의 정렬 결과는 얼핏 보면 "그럴듯하게 섞인 순서"처럼 보여서 문제를 알아채기가 훨씬 어렵다.
꼬리 질문
크래시가 안 난다면, 이 버그를 실무에서는 어떻게 알아챌 수 있나?
zip(arr, arr.dropFirst()) 같은 방식으로 검증하는 어서션을 테스트에 넣어두는 것이다 — 위 NaN 예시에서 쓴 "인접 위반 개수 세기"가 그 검증 코드 자체다.NaN을 Comparable 준수 커스텀 타입에서 아예 못 만들게 막을 수 있나?
isNaN으로 필터링하는 방어 코드를 정렬 함수 경계에 두는 게 실무적으로 더 현실적이다.