← iOS 개발자 CS 로드맵 12 / 35

12 · CPU와 성능

연산 vs 대기, 메모리 계층, 알고리즘 복잡도, 빌드 최적화, 배터리·발열 — CPU 관점에서 성능을 읽는 다섯 질문
진행률
0 / 0 완료

같은 코드도 무엇에 시간을 쓰느냐(연산 vs 대기), 데이터가 어디에 있느냐(레지스터~스토리지), 어떻게 빌드했느냐에 따라 체감 성능이 수십 배까지 갈린다. 이 장은 CPU 관점에서 성능·배터리·발열이 어떻게 연결되는지 원리부터 iOS 실무까지 잇는다.

Q1. CPU-bound와 I/O-bound 작업은 무엇이 다른가?

🔑 30초 답변

작업의 완료 시간을 무엇이 지배하느냐가 기준이다. CPU-bound는 계산 자체가 병목이라 CPU가 쉴 틈 없이 돌고, 코어를 빨리·많이 줄수록 빨라진다. I/O-bound는 디스크·네트워크 응답을 기다리는 시간이 병목이라 그동안 CPU는 놀고, 빠른 CPU는 거의 도움이 안 되며 대기를 겹치는(동시성) 전략이 답이다. 판별은 간단하다 — CPU 시간이 벽시계 시간과 비슷하면 CPU-bound, 훨씬 작으면 I/O-bound이다.

CS 원리

둘의 차이는 실행 중 스레드의 상태로 드러난다. CPU-bound 스레드는 대부분 RUNNING 상태로 코어를 점유하다 스케줄러에 의해 선점(preempt)된다. I/O-bound 스레드는 read()·recv() 같은 시스템 콜에서 BLOCKED가 되어 코어를 반납하고, I/O가 끝나면 깨어난다. 그래서 병렬화 전략이 갈린다. CPU-bound는 코어 수만큼 병렬(parallelism)로 나눌 때 이득이 코어 수에서 포화되고, I/O-bound는 대기를 겹치는 동시성(concurrency)이면 충분해 코어 수보다 훨씬 많은 요청을 한 스레드로도 소화할 수 있다.

한 작업의 시간축 → CPU-bound 연산 (코어 거의 100% 점유) I/O-bound 대기 (I/O 응답 · 코어 유휴) ■ 채움 = 연산(코어 사용) ▢ 점선 = 대기(디스크·네트워크)
CPU-bound는 코어를 계속 점유하고, I/O-bound는 대기 구간에 코어를 반납한다. 이 대기를 여러 작업이 겹쳐 쓰는 게 동시성이다.

iOS에서는

Darwin/XNU 스케줄러에서 I/O로 블록된 스레드는 디스케줄되고, CPU-bound 스레드는 타임 퀀텀을 다 쓰면 선점된다. 프레임워크 층에서는 전략이 더 명확히 갈린다. GCD의 전역 큐에 동기 I/O를 잔뜩 던지면, 블록된 워커가 늘어날 때마다 GCD가 새 스레드를 만들어 thread explosion(스레드 폭발)과 최악의 경우 데드락으로 이어진다. 반대로 CPU-bound 병렬 계산은 DispatchQueue.concurrentPerform(iterations:)로 코어 수에 맞춰 나누는 게 정석이다.

Swift Concurrency의 협력적 스레드 풀(cooperative thread pool)은 코어 수만큼만 스레드를 둔다(WWDC21 Swift concurrency: Behind the scenes). 그래서 이 풀 위에서 동기 I/O로 스레드를 블록하거나, 오래 걸리는 CPU 계산을 await 지점 없이 붙잡고 있으면 전진 보장(forward progress)이 깨져 다른 태스크가 굶는다. I/O는 URLSession의 async API처럼 논블로킹으로 쓰고, 긴 계산은 잘게 쪼개 await Task.yield()로 양보하거나 별도로 오프로딩해야 한다. 그리고 둘 중 무엇이든 메인 스레드에서 하면 프레임 드랍(hitch)이 난다.

동기 I/O를 계속 던지면 async로 대기를 겹치면 스레드1 블록 — 응답 대기 스레드2 스레드3 스레드4 스레드5 대기 하나가 스레드 하나를 통째로 붙잡는다 → thread explosion (스레드 폭발) 요청1 요청2 요청3 요청4 요청5 대기가 겹쳐도 스레드 수는 코어 수만큼 → 논블로킹 async/await
왼쪽은 대기 하나에 스레드 하나가 묶여 계속 늘어나고, 오른쪽은 같은 대기를 겹쳐 처리해 스레드 수가 늘지 않는다(■ 연산, ▢ 점선 = 대기).

실험 · 도구

가장 확실한 판별법은 벽시계 시간(wall-clock)과 CPU 시간을 함께 재는 것이다. 둘이 비슷하면 CPU가 내내 일한 것(CPU-bound), CPU 시간이 훨씬 작으면 그 차이만큼 기다린 것(I/O-bound)이다.

벽시계 vs CPU 시간으로 분류하기 (Swift)
import Foundation

func classify(_ label: String, _ work: () -> Void) {
    let wallStart = ContinuousClock.now
    let cpuStart  = clock()                       // 프로세서가 실제로 돈 시간(근사)
    work()
    let wall = wallStart.duration(to: .now)
    let cpu  = Double(clock() - cpuStart) / Double(CLOCKS_PER_SEC)
    print("\(label): wall=\(wall), cpu≈\(cpu)s")
    // cpu ≈ wall  →  CPU-bound (계산이 병목)
    // cpu << wall →  I/O-bound (대부분 대기)
}

도구로는 Instruments의 Time Profiler가 CPU-bound의 핫 함수를 보여준다. I/O-bound는 Time Profiler상 CPU가 낮게 나오므로, System Trace의 Thread State로 스레드가 언제 Blocked였는지 보거나 os_signpost로 대기 구간을 직접 계측하는 편이 낫다.

프로젝트 적용

CPU-bound: 백그라운드 QoS(.userInitiated/.utility)로 옮기고, 병렬화 가능한 반복은 concurrentPerform. 스레드를 코어 수보다 많이 만들지 말 것 — 오버서브스크립션은 컨텍스트 스위치 낭비만 늘린다.

CPU-bound는 코어 수만큼만 병렬화
import Foundation

// CPU-bound는 코어 수에서 이득이 포화된다. 그 이상은 컨텍스트 스위칭 낭비다.

let coreCount = ProcessInfo.processInfo.activeProcessorCount

// ✅ ① concurrentPerform — 런타임이 코어에 맞춰 분배한다
func renderTiles(_ tiles: [Tile], into output: UnsafeMutableBufferPointer<Pixel>) {
    DispatchQueue.concurrentPerform(iterations: tiles.count) { i in
        output[i] = render(tiles[i])
    }
}

// ✅ ② TaskGroup을 쓸 때는 '청크 개수'를 코어에 맞춘다
func processImages(_ images: [RawImage]) async -> [Processed] {
    let chunkSize = max(1, (images.count + coreCount - 1) / coreCount)

    var chunks: [ArraySlice<RawImage>] = []
    var i = images.startIndex
    while i < images.endIndex {
        let j = images.index(i, offsetBy: chunkSize, limitedBy: images.endIndex)
                ?? images.endIndex
        chunks.append(images[i..<j]); i = j
    }

    return await withTaskGroup(of: [Processed].self) { group in
        for chunk in chunks {
            group.addTask { chunk.map(process) }      // Task 하나 = 청크 하나
        }
        var out: [Processed] = []
        for await part in group { out += part }
        return out
    }
}

// ✅ ③ QoS로 코어 종류까지 유도한다
func background() {
    DispatchQueue.global(qos: .utility).async { }     // E 코어로 갈 수 있다
    DispatchQueue.global(qos: .userInitiated).async { } // P 코어 우선
}

// ❌ 작업마다 Task 하나 — CPU 작업이면 스위칭만 늘어난다
// for image in images { Task { _ = process(image) } }

struct Tile {}; struct Pixel {}; struct RawImage {}; struct Processed {}
func render(_ t: Tile) -> Pixel { Pixel() }
func process(_ r: RawImage) -> Processed { Processed() }

I/O-bound: 동기 API로 스레드를 붙잡지 말고 async/await·논블로킹으로 대기를 겹칠 것.

I/O-bound는 대기를 겹친다
import Foundation

// I/O는 CPU가 노는 시간이 대부분이다. 스레드를 늘려도 대기 시간은 그대로다.
// 필요한 건 '대기를 겹치는 것'이고, 그건 async로 충분하다.

// ❌ 동기 API로 스레드를 붙잡는다
func downloadAllBad(_ urls: [URL]) {
    for url in urls {
        DispatchQueue.global().async {
            _ = try? Data(contentsOf: url)      // 스레드 하나가 대기로 죽는다
        }
    }
}

// ✅ async로 왕복을 겹친다 — 스레드는 몇 개 안 쓴다
func downloadAll(_ urls: [URL]) async throws -> [Data] {
    try await withThrowingTaskGroup(of: (Int, Data).self) { group in
        for (i, url) in urls.enumerated() {
            group.addTask { (i, try await URLSession.shared.data(from: url).0) }
        }
        var out = [Data?](repeating: nil, count: urls.count)
        for try await (i, d) in group { out[i] = d }
        return out.compactMap { $0 }
    }
}

// ✅ 동시 실행 수는 제한한다 — 무제한은 서버·메모리에 부담이다
func downloadLimited(_ urls: [URL], limit: Int = 6) async throws -> [Data] {
    var results: [Data] = []
    var iterator = urls.makeIterator()

    try await withThrowingTaskGroup(of: Data.self) { group in
        // 처음 limit개만 시작
        for _ in 0..<limit {
            guard let url = iterator.next() else { break }
            group.addTask { try await URLSession.shared.data(from: url).0 }
        }
        // 하나 끝날 때마다 하나 추가 — 항상 limit개가 진행 중
        while let data = try await group.next() {
            results.append(data)
            if let url = iterator.next() {
                group.addTask { try await URLSession.shared.data(from: url).0 }
            }
        }
    }
    return results
}

하지 말 것: 협력적 스레드 풀이나 GCD 전역 큐에서 동기 블로킹 I/O 대량 투입(스레드 폭발), 메인 스레드에서의 무거운 연산.

하지 말 것 — 블로킹 대량 투입 · 메인 연산
import Foundation

// ❌ ① 동기 블로킹을 전역 큐에 대량 투입 → thread explosion
func explosion(_ urls: [URL]) {
    for url in urls {
        DispatchQueue.global().async { _ = try? Data(contentsOf: url) }
    }
    // 워커 스레드가 한계까지 늘어나고, 그 뒤 작업은 굶는다
}

// 스레드 수를 실제로 관찰해 보면 바로 보인다
func threadCount() -> Int {
    var count: mach_msg_type_number_t = 0
    var list: thread_act_array_t?
    guard task_threads(mach_task_self_, &list, &count) == KERN_SUCCESS else { return 0 }
    if let list {
        vm_deallocate(mach_task_self_, vm_address_t(UInt(bitPattern: list)),
                      vm_size_t(Int(count) * MemoryLayout<thread_t>.size))
    }
    return Int(count)
}

// ❌ ② 협력 풀에서 블로킹 — 워커가 코어 수만큼뿐이라 더 치명적이다
func starvation() async {
    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<100 {
            group.addTask {
                Thread.sleep(forTimeInterval: 1)    // 💥 워커를 통째로 막는다
            }
        }
    }
}

// ❌ ③ 메인 스레드에서의 무거운 연산
@MainActor
func heavyOnMain(_ data: [Double]) -> Double {
    data.reduce(0) { $0 + sqrt($1) }    // 100만 개면 프레임을 놓친다
}

// ✅ 각각의 올바른 형태
func fixed(_ urls: [URL]) async throws {
    // ① async I/O
    _ = try await withThrowingTaskGroup(of: Data.self) { group -> [Data] in
        for url in urls { group.addTask { try await URLSession.shared.data(from: url).0 } }
        var out: [Data] = []; for try await d in group { out.append(d) }; return out
    }

    // ② 지연은 Task.sleep
    try await Task.sleep(for: .seconds(1))

    // ③ 무거운 연산은 메인 밖
    _ = await Task.detached(priority: .userInitiated) {   // ⚠️ detached도 같은 협력 풀에서 돈다 —
        // 액터 컨텍스트만 끊을 뿐이라, 길게 도는 계산은 중간에 await Task.yield()로 양보하거나
        // concurrentPerform으로 넘겨야 풀을 잠식하지 않는다
        (0..<1_000_000).reduce(0.0) { $0 + Double($1).squareRoot() }
    }.value
}
⚠️ 흔한 오해

"스레드를 더 많이 만들면 항상 빨라진다"는 틀리다. CPU-bound는 코어 수에서 이득이 포화되고 그 이상은 오히려 스위칭 비용으로 느려진다. I/O-bound가 많은 동시 요청으로 빨라지는 것도 요청당 스레드 때문이 아니라 대기를 겹치기 때문이며, 이는 동기 스레드가 아니라 논블로킹/async로 달성해야 한다.

🧒 쉽게 이해하기

라면 끓이는 장면을 떠올려 보자. 물이 끓기를 기다리는 3분 동안 요리사는 사실 아무것도 안 하고 서 있다 — 이게 I/O-bound다. 반대로 양파 스무 개를 써는 3분은 손을 한 순간도 못 쉰다 — 이게 CPU-bound다. 여기서 사람들이 제일 많이 걸려 넘어지는 지점이 있다. 요리사를 두 명 더 부르면 양파는 정말 빨리 썰리지만, 물이 끓는 속도는 1초도 안 빨라진다. 기다림은 사람 수로 줄이는 게 아니라 겹쳐서 줄인다 — 물 올려놓고 그 사이에 파를 썰면 된다.

GCD 전역 큐에 동기 I/O를 잔뜩 던지는 건 "냄비마다 요리사를 한 명씩 붙여 물 끓는 걸 지켜보게 하는" 짓이다. 주방에 사람만 가득 차고(thread explosion, 스레드가 계속 늘어나는 현상) 정작 라면은 더 빨리 안 나온다. 비유가 깨지는 곳도 하나 있다 — 진짜 CPU는 멍하니 서 있지 않고, 기다리는 스레드를 아예 재워 둔 뒤 코어를 다른 스레드에게 넘겨 준다.

꼬리 질문

한 작업이 "다운로드 후 디코딩"처럼 I/O와 CPU가 섞여 있으면, 파이프라인을 어떻게 설계해야 처리량이 최대가 되나?

다운로드(I/O)와 디코딩(CPU)을 서로 다른 자원 풀에 놓고 겹치게 하는 게 핵심이다. 다운로드는 논블로킹 async로 여러 개를 동시에 진행시켜 네트워크 대기를 겹치고, 디코딩은 코어 수만큼만 병렬로 도는 별도 스테이지로 흘려보낸다. 그래야 한쪽이 일하는 동안 다른 쪽이 쉬지 않아 파이프라인이 가장 느린 스테이지의 처리량에 수렴한다.

iOS에서는 AsyncStream이나 TaskGroup으로 다운로드 결과를 흘리되, 디코딩 동시 실행 수는 세마포어나 maxConcurrentOperationCount로 코어 수 근처에 묶어 두는 게 정석이다. 무한정 겹치면 메모리에 미처리 버퍼가 쌓여(백프레셔 부재) 오히려 터진다.

쉽게 말하면 빨래방을 떠올려 보자. 세탁기(다운로드)와 건조기(디코딩)는 서로 다른 기계라, 첫 빨래가 건조되는 동안 두 번째 빨래를 세탁기에 넣으면 둘 다 안 논다. 전체 속도는 결국 제일 느린 기계가 정한다. 단, 세탁 끝난 옷을 건조기 앞에 무한정 쌓아 두면 바닥이 옷더미로 터지니, 대기 줄 길이에 상한을 둬야 한다 — 그게 백프레셔다.
코어가 8개인데 CPU-bound 스레드를 16개 만들면 총 처리 시간과 지연시간은 각각 어떻게 되나?

총 작업량이 고정된 CPU-bound라면 전체 완료 시간(makespan)은 거의 나아지지 않는다. 코어가 8개뿐이라 실제로 동시에 도는 건 8개고, 나머지 8개는 대기하다 번갈아 스케줄될 뿐이라 유효 병렬도는 이미 8에서 포화됐기 때문이다. 오히려 컨텍스트 스위치와 캐시 오염 비용이 붙어 살짝 느려질 수 있다.

반면 개별 작업의 지연시간(latency)은 나빠진다. 16개가 8코어를 시분할하면 각 작업이 선점당하며 끝까지 도는 데 더 오래 걸리기 때문이다. 그래서 CPU-bound는 코어 수만큼만 나누는 concurrentPerform이 맞다.

쉽게 말하면 계산대가 8개인 마트에 손님 16명이 들어온 상황이다. 계산대를 더 만든 게 아니니 마지막 손님이 나가는 시각은 거의 그대로다. 그런데 8명씩 번갈아 조금씩 처리하면 내가 계산을 끝내는 시각은 뒤로 밀린다. 손님을 바꿀 때마다 물건을 치우고 다시 올리는 잔손질(컨텍스트 스위치·캐시 오염)까지 붙어서 오히려 살짝 손해다.
동기 네트워크 호출 100개를 GCD 전역 concurrent 큐에 던지면 무슨 일이 벌어지고, 왜 데드락까지 갈 수 있나?

전역 concurrent 큐에 블로킹 작업을 쌓으면, GCD는 앞선 워커가 recv() 등에서 블록될 때마다 큐를 진행시키려고 새 스레드를 스폰한다. 이게 thread explosion이고, 스레드 하나하나가 커널 자원과 스택 메모리를 잡아먹어 수십~수백 개까지 늘 수 있다. GCD의 스레드 풀 상한(대략 64개대)에 닿으면 더는 스레드를 못 만든다.

데드락은 이때 이미 풀을 다 쓴 블록된 작업이, 완료되려면 같은 풀에서 스케줄돼야 할 다른 작업(예: 콜백)을 기다릴 때 발생한다. 풀에 빈 스레드가 없어 그 의존 작업이 영영 못 돌기 때문이다. 그래서 동기 I/O 대량 투입 대신 논블로킹 async를 써야 한다.

쉽게 말하면 전화 100통을 걸어야 하는데, 상대가 받을 때까지 직원 한 명이 수화기를 붙잡고 멍하니 서 있는 방식이다. 사람이 모자라니 계속 새로 뽑다가(thread explosion) 회사 정원(GCD 풀 상한, 대략 64명)이 꽉 찬다. 데드락은 여기서 한 걸음 더 간다 — 통화를 끝내려면 "받아 줄 직원"이 한 명 더 필요한데 정원이 이미 다 찼으면, 아무도 끝낼 수 없고 아무도 자리를 비우지 않는다.

Q2. Register, CPU Cache, RAM, Storage의 비용은 어떻게 다른가?

🔑 30초 답변

메모리는 피라미드형 계층이다. 위로 갈수록 빠르고 작고 비싸며(레지스터), 아래로 갈수록 느리고 크고 싸다(스토리지). 대략적인 접근 지연은 레지스터 1ns 미만, 캐시 수 ns, RAM 수십~100ns, SSD 수십~수백 µs로 단계마다 자릿수가 바뀐다. 특히 L1 캐시와 RAM은 약 100배 차이라, 실무 성능은 알고리즘보다 데이터가 캐시에 얼마나 잘 들어오느냐(지역성)가 좌우하는 경우가 많다.

CS 원리

캐시가 작동하는 이유는 프로그램의 지역성(locality)이다. 방금 쓴 데이터를 곧 또 쓰고(시간 지역성), 이웃한 주소를 연달아 쓰는(공간 지역성) 경향이 있어서, 자주 쓰는 것만 빠른 곳에 두면 평균 접근 시간이 크게 줄어든다. 캐시는 바이트 단위가 아니라 캐시라인이라는 고정 블록 단위로 메모리를 끌어온다(x86은 보통 64바이트, Apple Silicon은 128바이트). 물리적으로 캐시는 빠르지만 비싼 SRAM, RAM은 값싼 DRAM이라 용량/속도/가격이 서로 맞물려 있다.

빠름·비쌈 → 느림·쌈 레지스터 ≈ 0.3 ns · 수 KB CPU 캐시 (L1–L3) ≈ 1–20 ns · 수십 KB~수십 MB 메인 메모리 (RAM/DRAM) ≈ 50–100 ns · 수 GB 스토리지 (SSD/NAND) ≈ 수십~수백 µs · 수백 GB~TB
바 길이는 대략적인 용량, 오른쪽은 대략적인 지연이다(자릿수 감각용, Latency Numbers 기준). 한 단계 내려갈 때마다 지연이 약 10~100배씩 커진다.

iOS에서는

Apple Silicon은 통합 메모리 아키텍처(Unified Memory)라 CPU·GPU·Neural Engine이 같은 물리 DRAM 풀을 공유한다. 그래서 전통적인 CPU↔GPU 간 복사 비용이 줄어드는 대신, 이 공유 대역폭을 아끼는 게 중요해진다. 참고로 Apple Silicon의 캐시라인은 128바이트라, 이웃 데이터를 한 번에 더 많이 끌어오는 만큼 false sharing 같은 문제도 그 단위로 생긴다.

Swift에서 지역성은 값 타입 vs 참조 타입 선택으로 직결된다. struct 배열은 원소들이 메모리에 연속으로 놓여(캐시 친화적) 순회 시 캐시라인을 알뜰히 재사용한다. 반면 class 인스턴스 배열은 사실상 힙에 흩어진 객체들을 가리키는 포인터 배열이라, 순회할 때마다 포인터를 따라 여기저기 점프하는 포인터 추적(pointer chasing)이 일어나 캐시 미스가 잦고 ARC의 retain/release 트래픽까지 겹친다(WWDC16 Understanding Swift Performance). 성능이 중요한 뜨거운 경로에서 데이터 레이아웃을 값 타입 연속 저장으로 두는 것이 정석인 이유다.

struct 배열 — 값이 나란히 붙어 있다 값0 값1 값2 값3 값4 값5 캐시라인 한 줄에 함께 올라옴 이웃 값이 같이 딸려오니 다음 접근은 거의 공짜 class 배열 — 주소만 나란하고, 진짜 값은 흩어져 있다 주소0 주소1 주소2 주소3 주소4 주소5 객체1 객체3 객체5 객체4 객체0 객체2 힙 여기저기 값마다 다른 곳으로 점프 = pointer chasing → 접근마다 새 캐시라인
같은 6개라도 struct 배열은 한 덩어리로 올라오고, class 배열은 주소를 따라 매번 다른 곳으로 뛰어야 한다.

실험 · 도구

지역성의 위력은 같은 데이터를 순서만 바꿔 훑어 보면 드러난다. 연속 버퍼를 행 우선(이웃 접근)열 우선(큰 스트라이드 점프)으로 각각 더해 보면, 계산량은 똑같은데도 열 우선이 몇 배 느리다 — 매 접근이 새 캐시라인을 건드리기 때문이다.

공간 지역성 실험: 행 우선 vs 열 우선 (Swift)
let n = 4_000
var a = [Int](repeating: 1, count: n * n)   // 하나의 연속 버퍼

// 행 우선: 이웃 원소를 차례로 → 캐시라인 재사용 (빠름)
var s = 0
for i in 0..<n { for j in 0..<n { s &+= a[i*n + j] } }

// 열 우선: 매번 n칸씩 건너뜀 → 접근마다 새 캐시라인 (느림)
for j in 0..<n { for i in 0..<n { s &+= a[i*n + j] } }

iOS 디바이스에는 Instruments의 CPU Counters 템플릿은 macOS뿐 아니라 연결된 iOS 실기기에서도 PMU 하드웨어 카운터(cache miss 등)를 노출한다. 다만 이벤트 이름·종류가 칩 세대마다 달라 절대 수치를 그대로 믿기보다 같은 기기에서의 A/B 비교로 읽는 편이 안전하고, 그 밖에는 Time Profiler로 병목 위치를 잡거나 위처럼 A/B 벤치마크로 상대 비용을 확인하는 게 현실적이다.

프로젝트 적용

뜨거운 루프의 데이터는 값 타입 연속 저장(Array·ContiguousArray)으로. 링크드 리스트류 포인터 추적 구조는 성능 경로에서 피한다.

뜨거운 루프의 데이터는 연속 저장으로
import Foundation

// 포인터를 따라가는 구조는 매 접근이 새 캐시라인을 건드린다.
// 연속 메모리 + 순차 접근이 프리페처가 가장 잘 예측하는 형태다.

// ❌ 링크드 리스트 — 노드가 힙 여기저기에 흩어져 있다
final class Node {
    var value: Double = 0
    var next: Node?
}
func sumList(_ head: Node?) -> Double {
    var total = 0.0, node = head
    while let n = node { total += n.value; node = n.next }   // 매번 캐시 미스
    return total
}

// ✅ 연속 배열 — 한 캐시라인에 Double 여러 개가 함께 온다
//    (hw.cachelinesize 기준 Apple Silicon 128B → 16개, x86 64B → 8개)
func sumArray(_ values: ContiguousArray<Double>) -> Double {
    var total = 0.0
    for v in values { total += v }
    return total
}

// ✅ 경계 검사까지 빼고 싶으면
func sumFast(_ values: ContiguousArray<Double>) -> Double {
    values.withUnsafeBufferPointer { buf in
        var total = 0.0
        for i in buf.indices { total += buf[i] }
        return total
    }
}

// 차이를 직접 재 본다 — 보통 자릿수가 다르다
func benchmark(n: Int = 1_000_000) {
    var array = ContiguousArray<Double>(repeating: 1, count: n)
    var head: Node? = nil
    for _ in 0..<n { let node = Node(); node.next = head; head = node }

    let clock = ContinuousClock()
    print("배열   \(clock.measure { _ = sumArray(array) })")
    print("리스트 \(clock.measure { _ = sumList(head) })")
    _ = array
}

// ⚠️ 클래스 배열([Node])도 마찬가지다 — 배열은 연속이지만
//    각 요소는 힙 포인터라 실제 데이터는 흩어져 있다.

워킹셋을 작게 유지해 캐시에 들어오게 한다. 필요하면 SoA(Struct of Arrays)로 재배치.

워킹셋을 줄이려면 SoA로 재배치
import Foundation

// 일부 필드만 훑는데 AoS로 두면, 쓰지 않는 필드까지 캐시라인에 딸려 온다.

// AoS(Array of Structs) — 한 개체를 통째로 다룰 때 유리
struct ParticleAoS {
    var x, y: Float          // 위치
    var vx, vy: Float        // 속도
    var color: UInt32        // 색
    var lifetime: Float      // 수명
}
// 24바이트 × 100만 = 24MB. 위치만 읽어도 전부 캐시에 올라온다.

// SoA(Struct of Arrays) — 일부 필드만 훑을 때 유리
struct ParticlesSoA {
    var x = ContiguousArray<Float>()
    var y = ContiguousArray<Float>()
    var vx = ContiguousArray<Float>()
    var vy = ContiguousArray<Float>()
    var color = ContiguousArray<UInt32>()
    var lifetime = ContiguousArray<Float>()
}

// ✅ 위치만 그린다 — vx·vy·color·lifetime은 아예 읽지 않는다
func draw(_ p: ParticlesSoA) {
    for i in p.x.indices { plot(p.x[i], p.y[i]) }    // 워킹셋 8MB
}

// ✅ 물리 갱신 — 위치와 속도만
func integrate(_ p: inout ParticlesSoA, dt: Float) {
    p.x.withUnsafeMutableBufferPointer { xs in
        p.vx.withUnsafeBufferPointer { vxs in
            for i in xs.indices { xs[i] += vxs[i] * dt }
        }
    }
}

// ⚠️ SoA는 코드가 장황해지고, 한 개체를 통째로 다루는 연산은 오히려 느려진다.
//    "이 루프가 실제 병목이다"가 확인된 뒤에만 바꾼다.

func plot(_ x: Float, _ y: Float) {}

차가운(cold) 경로까지 과하게 최적화하지 말 것 — 지역성 최적화는 프로파일러가 지목한 핫스팟에만.

지역성 최적화는 핫스팟에만
import Foundation

// 캐시 최적화는 코드를 읽기 어렵게 만든다. 그 대가를 치를 가치가 있는 곳은
// 프로파일러가 지목한 소수의 함수뿐이다.

// ✅ 순서 ① 먼저 측정한다 — 추측으로 시작하지 않는다
//    Instruments → Time Profiler → 실제 사용 시나리오 녹화
//    "가장 큰 덩어리"부터 본다. 절대 밀리초가 아니라 상대 비중이 기준이다.

// ✅ 순서 ② 알고리즘·자료구조를 먼저 본다 (효과가 훨씬 크다)
//    O(n²) → O(n log n)이 캐시 최적화보다 백 배 낫다

// ✅ 순서 ③ 그다음에야 지역성
func optimizedHotLoop(_ values: inout ContiguousArray<Float>) {
    values.withUnsafeMutableBufferPointer { buf in
        for i in buf.indices { buf[i] *= 0.5 }
    }
}

// ❌ 차가운 경로까지 이렇게 비트는 것 — 읽기만 어려워지고 이득이 없다
func coldPath(_ settings: [String: String]) -> String {
    settings["theme"] ?? "light"        // 앱 시작에 한 번 도는 코드
    // 여기에 SoA·unsafe 포인터를 쓸 이유가 전혀 없다
}

// ✅ 최적화한 코드에는 '왜'를 남긴다
/// ⚡️ 핫스팟: 프레임마다 100만 회 호출된다 (Time Profiler 기준 전체의 34%).
/// 경계 검사 제거로 약 2.1배 개선. 일반 for-in으로 되돌리지 말 것.
/// 측정: 2026-07-27, iPhone 15 Pro, Release
func criticalPath(_ buf: UnsafeMutableBufferPointer<Float>) {
    for i in buf.indices { buf[i] *= 0.5 }
}

// ✅ 개선 효과를 회귀 테스트로 고정한다
func test_핫루프_성능() {
    var values = ContiguousArray<Float>(repeating: 1, count: 1_000_000)
    let elapsed = ContinuousClock().measure { optimizedHotLoop(&values) }
    // ⚠️ assert는 -O에서 조건 평가 자체가 사라진다 — Release로 돌리는 성능 회귀 검사는
    //    XCTAssert(또는 precondition)로 써야 한다.
    XCTAssertLessThan(elapsed, .milliseconds(10), "핫루프 성능 회귀: \(elapsed)")
}
⚠️ 흔한 오해

"요즘 RAM은 빠르니 캐시나 지역성은 신경 안 써도 된다"는 오해다. L1과 RAM은 약 100배 차이라 캐시 미스가 잦으면 실제 성능을 지배한다. 또 "레지스터·캐시는 개발자가 직접 지정한다"도 틀리다 — 배치·교체는 대체로 컴파일러와 하드웨어가 관리하고, 개발자는 데이터 레이아웃과 접근 패턴으로 간접적으로만 영향을 준다.

🧒 쉽게 이해하기

공부하는 책상을 생각해 보자. 손에 쥔 펜은 꺼낼 필요도 없고(레지스터), 책상 위 책은 팔만 뻗으면 되고(캐시), 방 책장은 일어나서 걸어가야 하고(RAM), 지하 창고는 사서한테 부탁하고 한참 기다려야 한다(스토리지). 중요한 건 이 간격이 "조금 더 오래"가 아니라 단계마다 자릿수가 통째로 바뀐다는 점이다. 게다가 창고 사서는 책 한 권만 달라고 해도 그 책이 꽂힌 칸을 통째로 들고 온다 — 이게 캐시라인이다(Apple Silicon은 128바이트 묶음).

그래서 다음에 볼 책이 바로 옆 칸에 있으면 이미 책상 위에 있어서 공짜고, 매번 딴 층 딴 칸을 찍으면 매번 창고까지 다시 다녀와야 한다. struct 배열은 책들이 번호순으로 딱 붙어 꽂혀 있는 상태고, class 배열은 "3층 17번 칸"이라고 적힌 쪽지만 순서대로 있고 실제 책은 도서관 전체에 흩어져 있는 상태다. 쪽지를 따라 계속 뛰어다니는 게 pointer chasing이고, 여기에 ARC(참조 개수를 세는 자동 메모리 관리)의 retain/release까지 매번 붙어 격차가 더 벌어진다.

꼬리 질문

캐시를 그냥 무한히 크게 만들면 되지 않나? 왜 그렇게 못 하나?

캐시는 빠른 대신 비싼 SRAM이라 물리적·경제적 제약이 크다. 셀 하나에 트랜지스터가 여러 개 들어가 DRAM보다 면적을 훨씬 잡아먹고, 다이 위에서 CPU 코어와 자리를 다투므로 무작정 키우면 칩 값과 전력이 폭증한다.

더 근본적으로 캐시는 크면 느려진다. 용량이 커질수록 주소를 조회하는 태그 비교와 신호 전파 경로가 길어져 접근 지연이 늘기 때문이다. 그래서 실제 설계는 무한대 한 덩어리 대신 L1(작고 빠름)~L3(크고 느림)의 계층으로 나눠, 자주 쓰는 것만 가까이 두는 절충을 택한다.

쉽게 말하면 책상을 방만 하게 키운다고 공부가 빨라지지 않는다. 책상 끝에 놓인 책은 결국 일어나서 걸어가야 하고, 어디 뒀는지 찾는 시간도 늘어난다. 캐시도 똑같아서 키울수록 "안에 있나?"를 확인하는 데 더 오래 걸린다. 게다가 캐시용 SRAM은 값비싼 땅이라 칩 위에서 코어와 자리를 다툰다. 그래서 하나를 무한히 키우는 대신 작은 책상·큰 책상·창고로 층을 나눠 쓴다.
false sharing이 무엇이고, 멀티코어에서 성능을 어떻게 갉아먹나?

서로 다른 코어가 논리적으로는 별개인 변수를 각자 수정하는데, 그 변수들이 우연히 같은 캐시라인에 얹혀 있을 때 생기는 문제다. 캐시 일관성 프로토콜(MESI 등)은 라인 단위로 작동하므로, 한 코어가 자기 변수를 쓰면 그 라인 전체가 dirty가 되고 다른 코어가 가진 사본이 무효화된다.

그러면 실제로는 공유하지 않는데도 코어들이 같은 라인을 서로 뺏고 되가져오는 캐시라인 핑퐁이 벌어져, 매 접근이 캐시 미스급 비용을 낸다. 병렬화했는데 오히려 느려지는 전형적 원인이다. 해법은 코어별 데이터를 캐시라인 크기(Apple Silicon은 128바이트)로 패딩/정렬해 다른 라인에 떨어뜨리는 것이다.

쉽게 말하면 공책 한 장을 둘이 나눠 쓰는데, 규칙상 종이는 한 장 통째로만 주고받을 수 있는 상황이다. 나는 왼쪽 칸, 너는 오른쪽 칸에 전혀 다른 내용을 적는데도, 내가 한 글자 쓸 때마다 네 손에서 종이를 뺏어 와야 하고 너도 그렇다. 적는 내용은 겹치는 게 하나도 없는데 종이만 왔다 갔다 하다 둘 다 느려진다. 해결은 칸을 더 잘 나누는 게 아니라 아예 다른 장에 적는 것이다.
Swift에서 같은 개수의 struct 배열과 class 배열을 순회할 때 속도 차이가 나는 이유를 캐시와 ARC 관점에서 설명한다면?

캐시 측면에서, struct 배열은 값들이 버퍼에 연속으로 놓여 순회 시 한 캐시라인이 여러 원소를 담아 재사용된다. class 배열은 힙에 흩어진 객체를 가리키는 포인터 배열이라, 원소마다 포인터를 따라 예측 불가능한 주소로 점프하는 pointer chasing이 일어나 캐시 미스가 잦다.

ARC 측면에서, 참조 타입은 배열을 훑으며 원소를 만질 때마다 컴파일러가 retain/release를 삽입할 수 있고, 이 카운트 연산은 원자적(atomic)이라 비용이 더 크다. struct는 (참조 타입 필드를 품지 않는 한) 이런 트래픽이 아예 없다. 두 효과가 겹쳐 뜨거운 루프에서 값 타입이 몇 배 빠른 경우가 흔하다.

쉽게 말하면 struct 배열은 1번부터 6번까지 나란히 붙어 있는 사물함이라, 하나 열면 옆칸 물건까지 딸려 나온다. class 배열은 사물함마다 "실물은 3층 창고 17번"이라고 적힌 쪽지만 들어 있는 꼴이라, 물건 하나 보려면 매번 창고까지 뛰어야 한다. 거기다 물건 만질 때마다 대출·반납 도장까지 찍어야 한다(ARC의 retain/release) — 그 도장은 여러 사람이 동시에 찍어도 안 꼬이게 만든 거라(atomic) 한 번 찍는 값이 싸지 않다.

Q3. 알고리즘 복잡도와 실제 실행 시간은 어떻게 함께 판단하는가?

🔑 30초 답변

Big-O는 입력이 커질 때의 증가율만 말하고 상수 항과 하드웨어 효과는 버린다. 즉 "얼마나 잘 확장되는가"이지 "지금 얼마나 빠른가"가 아니다. 그래서 같은 O라도 상수 차이로 몇 배가 나고, 작은 n에서는 O(n²)이 O(n log n)을 이기기도 한다. 실무 판단은 둘을 겹쳐서 한다 — Big-O로 예상 n 범위에 맞는 후보를 고르고, 실제 상수(캐시·분기예측·할당·ARC 등)는 측정으로 확인한다.

CS 원리

Big-O는 n→∞에서 상수 배수와 저차항을 무시한 점근적 상한이다. 그래서 상수 인자가 큰 O(1)이 상수가 작은 O(n)보다 작은 입력에서 느릴 수 있다. 정렬 라이브러리가 작은 구간에서 삽입 정렬로 전환하는 것도 같은 이유다 — 점근적으로 나쁜 O(n²) 삽입 정렬이 작은 n에서는 상수가 작아 퀵/머지 정렬보다 빠르다. 상수 인자는 메모리 접근 패턴(캐시), 분기 예측, 명령어 수, 숨은 비용(할당·해싱·복사)에서 나온다.

입력 크기 n → 실행 시간 교차점 n* O(n log n) O(n²) 작은 n: O(n²) 승 큰 n: O(n log n) 승
상수가 작은 O(n²)은 교차점 n*까지 O(n log n)보다 빠르다. 내 데이터의 n이 n*의 어느 쪽인지가 실제 선택을 결정한다.

iOS에서는

Swift 컬렉션의 상수 인자는 꽤 다르다. Array.contains는 O(n)이지만 연속 메모리라 상수가 작고, Set.contains는 평균 O(1)이지만 해싱 비용과 잠재적 캐시 미스로 상수가 크다. 그래서 원소가 수십 개 이하로 작으면 배열 선형 탐색이 Set보다 빠른 구간이 실제로 존재한다. 또 Swift에는 숨은 O(n)이 많다 — Array는 copy-on-write라 공유 중인 배열을 수정하면 O(n) 복사가 튀고, removeFirst()는 뒤 원소를 전부 당겨 O(n)이다. 이런 게 루프 안에 들어가면 겉보기 O(n) 코드가 조용히 O(n²)이 된다(Array 문서).

겉보기 O(n)이 실제로는 O(n²)이 되는 경우 (Swift)
var queue = Array(0..<100_000)
while !queue.isEmpty {
    let x = queue.removeFirst()   // 앞을 지우면 뒤 전부를 앞으로 당김 → 매번 O(n)
    _ = x                          // 루프 n번 × O(n) = 전체 O(n²)
}

// 개선: 인덱스로 순회하거나 swift-collections의 Deque(앞 제거 O(1)) 사용
removeFirst() 한 번 = 뒤 원소를 전부 한 칸씩 당기는 일 1회차 a b c d e f 뒤 5칸 이동 2회차 b c d e f 뒤 4칸 이동 3회차 c d e f 뒤 3칸 이동 한 줄짜리 호출이지만, n번 반복 × 매번 O(n) 이동 = 전체 O(n²)
색칠된 칸이 지워질 원소. 앞을 지울 때마다 뒤 전부가 한 칸씩 당겨지므로, 겉보기 O(n) 루프가 조용히 O(n²)이 된다.

실험 · 도구

판단의 마지막은 항상 측정이다. n을 여러 값으로 바꿔가며 시간을 재서 교차점이 실제로 어디인지 확인한다. ContinuousClock으로 구간을 재거나 XCTest의 measure { }로 반복 측정하고, 상수 인자가 어디서 나오는지는 Time Profiler로 본다.

n을 바꿔가며 상수 인자를 실측 (Swift)
func bench(_ n: Int, _ body: () -> Void) -> Duration {
    let start = ContinuousClock.now
    body()
    return start.duration(to: .now)
}
for n in [8, 64, 512, 4096] {
    print(n, bench(n) { /* 후보 알고리즘 */ })
}   // 작은 n과 큰 n에서 승자가 뒤바뀌는지 관찰

프로젝트 적용

내 n의 현실적 범위를 먼저 안다. 아주 작은 컬렉션은 단순한 알고리즘이 대개 더 빠르고 읽기 쉽다.

내 n의 현실적 범위를 먼저 확인
import Foundation

// 점근 복잡도는 n이 클 때의 이야기다. 내 n이 20이면 그 논의는 무의미하다.

enum Scale {
    case screen         // 20~100      화면에 보이는 행
    case page           // 100~1,000   한 페이지
    case session        // 1만~10만    누적 스크롤
    case archive        // 100만+      전체 동기화
}

// ✅ n에 따라 구현을 다르게 고른다
struct Lookup {
    private let items: [Item]
    private let index: [String: Item]?

    init(_ items: [Item]) {
        self.items = items
        // 작은 n에서는 인덱스 구축 비용이 조회 이득보다 크다
        self.index = items.count >= 64
            ? Dictionary(items.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
            : nil
    }

    func find(_ id: String) -> Item? {
        if let index { return index[id] }
        return items.first { $0.id == id }     // 선형 스캔이 더 빠른 구간
    }
}

// ✅ 상한을 문서로 못 박아 두면 최적화 범위가 명확해진다
/// 이 목록의 상한: 서버가 페이지당 100개, 최대 50페이지 → n ≤ 5,000
/// 이 범위에서는 O(n) 선형 처리로 충분하며, 인덱스는 불필요하다.
struct FeedList { var items: [Item] = [] }

// ✅ 실제 분포를 로그로 수집해 가정을 검증한다
func recordSize(_ n: Int) {
    // Analytics.record("list_size", value: n)
    // p50/p95/p99를 보고 "우리 n"을 숫자로 확정한다
}

struct Item { let id: String }

도달하지도 않을 n을 위한 점근 최적화에 시간을 쓰지 말 것.

도달하지 않을 n을 위한 최적화 금지
import Foundation

// "언젠가 100만 개가 될 수도 있으니까"로 시작한 최적화는
// 대부분 도달하지 않는 미래를 위해 지금의 가독성을 태운다.

// ❌ 20개짜리 설정 목록에 B-tree 인덱스를 직접 구현
final class OverEngineeredSettings {
    private var btree: BTreeNode?          // 💥 설정 20개에 트리
    func value(for key: String) -> String? { nil }
}

// ✅ 그냥 딕셔너리 — 읽기 쉽고 충분히 빠르다
struct Settings {
    private var storage: [String: String] = [:]
    func value(for key: String) -> String? { storage[key] }
}

// ✅ 판단 기준: "지금 n에서 측정했을 때 문제인가?"
func shouldOptimize(currentN: Int, measuredMs: Double, budgetMs: Double) -> Bool {
    measuredMs > budgetMs        // 측정값이 예산을 넘을 때만
}

// ✅ 미래 대비는 '최적화'가 아니라 '경계'로 한다
//    지금은 단순하게 두되, 나중에 바꿀 수 있게 인터페이스를 분리해 둔다
protocol ItemLookup {
    func find(_ id: String) -> Item?
}

struct LinearLookup: ItemLookup {          // 지금
    let items: [Item]
    func find(_ id: String) -> Item? { items.first { $0.id == id } }
}

struct IndexedLookup: ItemLookup {         // n이 커지면 이걸로 교체
    let index: [String: Item]
    func find(_ id: String) -> Item? { index[id] }
}
// 교체 지점이 한 곳이므로, 필요해질 때 바꾸면 된다

final class BTreeNode {}
struct Item { let id: String }

루프 안의 숨은 O(n)(COW 복사, removeFirst, 문자열 이어붙이기)을 경계하고, 크기를 아는 배열은 reserveCapacity로 재할당을 없앤다.

루프 안의 숨은 O(n) 경계하기
import Foundation

// 겉보기엔 O(n) 루프인데 안에 O(n) 연산이 숨어 O(n²)가 되는 패턴들.

// ❌ ① 문자열 이어붙이기
func joinBad(_ parts: [String]) -> String {
    var out = ""
    for p in parts { out += p }            // Swift String은 지수적으로 늘어 분할상환 O(n)이다
                                           // (O(n²)가 아니다 — joined()와의 차이는 상수 인자)
    return out
}
func joinGood(_ parts: [String]) -> String {
    parts.joined()                          // 전체 크기를 먼저 계산해 한 번 할당
}

// ❌ ② removeFirst
func drainBad(_ queue: inout [Job]) {
    while !queue.isEmpty { run(queue.removeFirst()) }    // 매번 전체 이동
}
func drainGood(_ queue: [Job]) {
    for job in queue { run(job) }
}

// ❌ ③ contains로 중복 검사
func dedupBad(_ items: [Item]) -> [Item] {
    var out: [Item] = []
    for i in items where !out.contains(where: { $0.id == i.id }) { out.append(i) }
    return out
}
func dedupGood(_ items: [Item]) -> [Item] {
    var seen = Set<String>(minimumCapacity: items.count)
    return items.filter { seen.insert($0.id).inserted }
}

// ❌ ④ COW 복사 — 루프 안에서 공유 상태인 배열을 변경
func cowTrap(_ source: [Double]) -> [Double] {
    var working = source                    // 공유 시작
    for i in working.indices { working[i] *= 2 }   // 첫 쓰기에서 O(n) 복사가 '한 번' 튄다
                                                   // (반복되지 않으므로 O(n²)는 아니다)
    return working
}
func cowAvoided(_ source: inout [Double]) {
    for i in source.indices { source[i] *= 2 }     // 유일 소유 → 복사 없음
}

// ✅ ⑤ 크기를 알면 예약
func build(_ n: Int) -> [Int] {
    var out: [Int] = []
    out.reserveCapacity(n)
    for i in 0..<n { out.append(i) }
    return out
}

struct Job {}; struct Item { let id: String }
func run(_ j: Job) {}
⚠️ 흔한 오해

"O(1)은 언제나 O(n)보다 빠르다"와 "Big-O를 보면 성능을 안다"는 둘 다 틀리다. O(1)이라도 상수(해싱·할당)가 크면 작은 입력에서 작은 O(n)에 진다. 그리고 Big-O는 증가율이지 측정된 시간이 아니다 — 같은 O(n log n) 두 구현이 상수 때문에 3배 차이 나는 건 흔한 일이라, 결국 재 봐야 안다.

🧒 쉽게 이해하기

편의점이 200m 앞이면 자전거가 자동차보다 빨리 도착한다. 차는 주차장까지 걸어가고, 시동 걸고, 다 가서 다시 댈 자리를 찾는 고정 비용이 붙기 때문이다. 그런데 목적지가 20km면 결과가 완전히 뒤집힌다. Big-O가 알려 주는 건 딱 여기까지다 — "거리가 멀어질수록 시간이 얼마나 가파르게 늘어나는가". 지금 이 거리에서 뭐가 빠른지는 안 알려 준다. 시동 거는 시간(상수 인자)을 계산에서 아예 빼 버리기 때문이다.

그래서 실무에선 두 개를 겹쳐 본다. 내 데이터 n이 교차점 왼쪽이면 단순한 쪽이 이기고, 오른쪽이면 점근적으로 좋은 쪽이 이긴다. 교차점이 정확히 어디냐는 캐시 미스, 메모리 할당, ARC 같은 게 뒤엉켜 정해지므로 머리로 못 맞히고 재 봐야 안다. 참고로 removeFirst() 같은 건 자전거인 줄 알았는데 매번 주차장을 왕복하는 경우라, 이런 숨은 비용을 루프 안에 두지 않는 게 알고리즘 고르기보다 먼저다.

꼬리 질문

입력 n이 항상 10 이하로 보장된다면, 굳이 O(n log n) 대신 O(n²)을 써도 되는 근거는 무엇인가?

Big-O는 n→∞의 증가율일 뿐이고, n이 10에 묶여 있으면 그 점근적 우열은 발동할 기회가 없다. n=10에서 n²=100 대 n log n≈33 정도라 절대 연산 수 차이가 작고, 여기에 상수 인자가 승부를 뒤집는다 — O(n²) 삽입 정렬은 분기·할당이 거의 없고 연속 메모리를 순차 접근해 상수가 매우 작은 반면, O(n log n) 정렬은 재귀·파티션·추가 버퍼 같은 오버헤드가 붙는다.

실제로 대부분의 표준 정렬 구현이 작은 구간에서 삽입 정렬로 전환하는 것도 같은 이유다. 다만 "10 이하 보장"이 정말 불변인지, 코너케이스로 커질 여지가 없는지는 확인해야 한다.

쉽게 말하면 카드 열 장을 순서대로 놓는 일은 그냥 손으로 한 장씩 끼워 넣는 게 제일 빠르다. 좋은 정렬 알고리즘은 "반으로 나누고, 임시로 놓을 자리를 만들고, 다시 합치고" 하는 준비 동작이 붙는데, 카드가 열 장뿐이면 준비하다가 끝날 일이다. 진짜 조심할 건 알고리즘이 아니라 "10 이하"라는 약속이다 — 언젠가 1만 개가 들어오는 순간 이 선택은 그대로 폭탄이 된다.
같은 O(n log n) 정렬 A가 B보다 3배 느리다면, 상수 인자의 원인 후보를 캐시·분기·할당 관점에서 셋 이상 들 수 있나?

캐시 관점: A가 데이터를 큰 스트라이드로 흩어 접근하거나 추가 버퍼를 오가면 캐시 미스가 잦은 반면, B가 in-place로 연속 접근하면 라인 재사용이 좋아 빠르다. 분기 관점: 비교·분기 예측이 자주 빗나가는 패턴(예측 불가능한 파티션 경계)이면 파이프라인 스톨이 쌓인다. 할당 관점: A가 매 병합마다 임시 배열을 malloc하거나 클로저/박싱을 유발하면 그 비용이 상수에 얹힌다.

추가로 원소 비교 함수의 무게(값 비교 vs 참조 타입 비교 + ARC), 그리고 Swift라면 제네릭이 특수화됐는지(witness table 동적 디스패치 여부)도 3배쯤은 쉽게 만든다.

쉽게 말하면 똑같이 "상자 1000개 옮기기"라도, 쌓인 순서대로 한 아름씩 나르는 사람과 창고 이 끝 저 끝을 뛰며 하나씩 집어 오는 사람은 걸음 수가 몇 배 차이 난다. Big-O는 "상자가 늘면 시간이 얼마나 가파르게 늘까"만 세고, 걸은 거리(캐시 미스), 상자를 매번 새로 접는 시간(할당), 엉뚱한 데 갔다 되돌아오는 시간(분기 예측 실패)은 아예 안 센다. 3배는 전부 여기서 나온다.
Swift에서 배열 앞에 원소를 자주 넣고 빼야 한다면 어떤 자료구조로 바꿔 O(n²)을 O(n)으로 낮추겠나?

Array는 앞 삽입/삭제 때 뒤 원소를 전부 밀거나 당겨 매 연산이 O(n)이라, 루프에서 반복하면 전체가 O(n²)이 된다. swift-collections의 Deque가 정석이다 — 원형 버퍼 기반이라 양끝 삽입·삭제가 상각 O(1)이라 전체가 O(n)으로 떨어진다.

외부 의존이 부담이면, 소비만 앞에서 한다면 읽기 인덱스(head)를 전진시키며 실제 제거를 미루거나, 순서가 무관하면 배열을 뒤집어 뒤에서 removeLast()(O(1))로 처리하는 우회도 O(n)을 만든다.

쉽게 말하면 줄 맨 앞 사람이 빠질 때마다 뒤에 선 사람 전부가 한 발씩 걸어 나오는 게 Array다. Deque는 사람은 그대로 두고 "여기가 이제 맨 앞"이라는 표지판만 한 칸 옮긴다. 그래서 줄이 100명이든 10만 명이든 한 번 빠지는 비용이 똑같다. 라이브러리를 못 쓰는 상황이면 그 표지판을 직접 만들면 된다 — 실제로 지우지 말고 "지금 읽을 위치" 인덱스만 앞으로 밀면 된다.

Q4. Debug build와 Release build의 성능 차이는 왜 큰가?

🔑 30초 답변

Debug는 디버깅 편의를 위해 최적화를 끄고(-Onone), Release는 최적화를 켠다(-O). 차이의 핵심은 Swift 특유의 비용이다 — 최적화가 켜지면 함수 인라인, 불필요한 ARC retain/release 제거, 제네릭 특수화(specialization), 전모듈 최적화(WMO)가 일어나 계산 중심 코드가 수십 배까지 빨라질 수 있다. 그래서 Debug에서 잰 성능 숫자는 신뢰할 수 없고, 벤치마크·프로파일링은 반드시 Release로 해야 한다.

CS 원리

컴파일러 최적화는 관찰 가능한 동작을 보존하면서 더 빠른 기계어로 바꾸는 변환이다. Debug 빌드는 이런 변환을 거의 끈다 — 소스와 기계어를 가깝게 유지해야 브레이크포인트가 정확한 줄에 걸리고 변수 값이 그대로 보이기 때문이다. Release 빌드는 인라인, 죽은 코드 제거, 루프 언롤링, 레지스터 할당, 상수 폴딩, 벡터화 같은 변환을 켠다. C 세계의 -O0 vs -O2 구도와 같다.

iOS에서는

Swift의 최적화 레벨은 빌드 설정 SWIFT_OPTIMIZATION_LEVEL로, Debug는 -Onone, Release는 -O(속도) 또는 -Osize(크기)이다. Swift에서 Debug가 유독 느린 이유는 C보다 Swift에 최적화로 없앨 수 있는 오버헤드가 훨씬 많기 때문이다.

Swift 오버헤드Debug (-Onone)Release (-O)
함수 인라인거의 안 함(작은 게터도 실제 호출)적극 인라인
ARC retain/release중복까지 그대로 방출불필요한 것 대거 제거
제네릭witness table 동적 디스패치(boxing)구체 타입으로 특수화
전모듈 최적화(WMO)파일 단위 컴파일모듈 전체를 함께 최적화
assert(_:)런타임에 실행컴파일 시 제거(비용 0)

전모듈 최적화(WMO)는 파일 경계를 넘어 인라인과 제네릭 특수화를 가능하게 해 런타임을 크게 끌어올리지만, 컴파일러가 모듈 전체를 한 번에 봐야 해서 빌드는 느려진다. 제네릭 특수화는 func f<T>(...)를 실제로 쓰인 구체 타입별 버전으로 복제해 동적 디스패치와 boxing을 없애는 것으로, ARC 제거와 함께 Swift에서 가장 큰 이득원이다(Swift OptimizationTips).

Debug (-Onone) total(xs) 호출 witness table 조회 — 어느 구현? T의 + 구현을 간접 호출 원소를 만질 때마다 이 경로를 다시 탄다 인라인·ARC 정리 없음 → 느림 Release (-O + WMO) total([Int]) 호출 Int 전용 복사본으로 특수화 덧셈을 그 자리에 인라인 조회할 표도, 박스도 없다 불필요한 retain/release까지 제거
소스는 한 줄도 안 바뀌었는데 컴파일 결과가 달라진다. Debug는 매번 "어느 구현이지?"를 되묻고, Release는 그 질문 자체를 없앤다.
최적화 유무로 갈리는 대표 코드 (Swift)
// Debug(-Onone): 이 제네릭은 특수화되지 않고 witness table로 동적 디스패치
func total<T: Numeric>(_ xs: [T]) -> T { xs.reduce(.zero, +) }
// Release(-O + WMO): Int 등 구체 타입으로 특수화 + 인라인 + ARC 제거 → 수십 배↑

assert(index >= 0)        // Release(-O)에서는 사라짐 → 런타임 비용 0
precondition(index >= 0)  // Release에서도 유지됨 (-Ounchecked에서만 제거)

실험 · 도구

같은 계산 루프를 Debug와 Release로 각각 실행해 시간을 비교하면 차이가 바로 드러난다. 핵심 규칙은 성능 측정은 절대 Debug로 하지 않는다이다. Instruments도 최적화된(Release 또는 -O 프로파일링) 빌드에 붙여야 현실적인 숫자가 나온다. Xcode의 Scheme에서 Run/Test 구성을 Release로 바꾸거나, 벤치마크 타깃에 -O를 켜서 재세요.

프로젝트 적용

벤치마크·프로파일링은 Release(또는 최적화 켠 구성)로만. Debug 체감 성능으로 판단 금지.

벤치마크·프로파일링은 Release로만
import Foundation

// Debug(-Onone)에서는 ARC 중복·제네릭 비특수화·인라인 없음이 겹쳐
// 실제와 수십 배까지 차이 난다. Debug 숫자로 판단하면 엉뚱한 곳을 고치게 된다.

// ✅ 커맨드라인
//    xcodebuild -scheme MyApp -configuration Release build
//    swift run -c release
//    swift test -c release

// ✅ Instruments도 Release 구성으로 (Scheme → Profile → Build Configuration)

// ✅ 코드로 최적화 여부를 확인해 실수를 막는다
func assertOptimizedBuild() {
    #if DEBUG
    assertionFailure("Debug 빌드에서 벤치마크를 돌리고 있습니다")
    #endif
}

// ✅ 벤치마크 함수는 최적화로 사라지지 않게 결과를 소비한다
@inline(never)
func blackHole<T>(_ value: T) {
    withExtendedLifetime(value) {}
}

func benchmark(_ label: String, iterations: Int = 1000, _ body: () -> Void) {
    assertOptimizedBuild()

    // 워밍업 — 첫 실행의 캐시·분기예측 콜드 비용을 제외한다
    for _ in 0..<10 { body() }

    let clock = ContinuousClock()
    let elapsed = clock.measure {
        for _ in 0..<iterations { body() }
    }
    print("\(label): \(elapsed / iterations) per iteration")
}

// ⚠️ 최적화가 코드를 통째로 지울 수 있다
func wrongBenchmark() {
    benchmark("sqrt") {
        _ = (0..<1000).map { Double($0).squareRoot() }   // 결과를 안 쓰면 삭제될 수 있다
    }
}
func rightBenchmark() {
    benchmark("sqrt") {
        let result = (0..<1000).map { Double($0).squareRoot() }
        blackHole(result)                                 // ✅ 소비한다
    }
}

// ✅ XCTest의 measure도 Release 구성에서 돌려야 의미가 있다

불변식 검사는 성격에 맞게 — assert는 개발 중에만 필요한 검사(Release에서 제거), precondition은 Release에서도 반드시 지켜야 할 검사.

assert와 precondition을 성격에 맞게
import Foundation

// assert      : 개발 중에만 필요한 검사. Release(-O)에서 제거된다.
// precondition: Release에서도 반드시 지켜야 할 계약. 남는다.
// fatalError  : 도달하면 안 되는 지점. 항상 남는다.

struct RingBuffer<T> {
    private var storage: [T?]
    private var head = 0

    init(capacity: Int) {
        // ✅ 호출자 계약 위반 — Release에서도 잡아야 한다
        precondition(capacity > 0, "capacity는 양수여야 합니다")
        storage = Array(repeating: nil, count: capacity)
    }

    mutating func append(_ value: T) {
        // ✅ 내부 불변식 — 개발 중에만 확인하면 충분하다
        assert(head < storage.count, "내부 인덱스 오류")
        storage[head] = value
        head = (head + 1) % storage.count
    }

    func element(at index: Int) -> T? {
        // ✅ 외부 입력이면 precondition (또는 Optional 반환)
        precondition(index >= 0 && index < storage.count, "인덱스 범위 초과")
        return storage[index]
    }
}

// ⚠️ assert의 인자는 @autoclosure — Release에서 아예 실행되지 않는다
var cursor = 0
func advanceAndCheck() -> Bool { cursor += 1; return cursor < 10 }

// ❌ Debug와 Release의 동작이 갈린다
func bad() {
    assert(advanceAndCheck())      // Release에서는 cursor가 증가하지 않는다
}

// ✅ 부수효과는 밖으로 꺼낸다
func good() {
    let ok = advanceAndCheck()
    assert(ok)
}

// ✅ 도달 불가 지점
func handle(_ state: State) -> String {
    switch state {
    case .idle: return "대기"
    case .running: return "실행"
    }
}
enum State { case idle, running }

-Ounchecked는 배열 경계·정수 오버플로 검사까지 제거하므로, 안전을 증명하지 못하면 켜지 말 것.

-Ounchecked는 증명 없이 켜지 않기
import Foundation

// -Ounchecked는 배열 경계 검사와 정수 오버플로 검사까지 제거한다.
// 그 검사가 잡아 주던 버그가 이제는 메모리 손상으로 나타난다.

// -O 에서는 이렇게 동작한다
func withChecks() {
    let array = [1, 2, 3]
    let index = 5
    // _ = array[index]        // 💥 크래시 (Fatal error: Index out of range)
    //                          //    → 명확한 오류로 즉시 멈춘다

    var value = Int.max
    // value += 1              // 💥 크래시 (Swift runtime failure: arithmetic overflow)
    _ = (array, index, value)
}

// -Ounchecked 에서는
//   array[5]  → 정의되지 않은 메모리를 읽는다. 크래시할 수도, 쓰레기 값을 줄 수도 있다.
//   Int.max+1 → 조용히 음수로 감긴다. 계산 결과가 틀리는데 아무도 모른다.

// ✅ 켜야 한다면 그 범위를 최소화하고, 안전을 코드로 증명한다
func safeSum(_ values: [Int]) -> Int {
    // 오버플로가 불가능함을 타입으로 보장한다
    values.reduce(0) { partial, v in
        let (sum, overflow) = partial.addingReportingOverflow(v)
        return overflow ? Int.max : sum      // 명시적으로 처리
    }
}

// ✅ 경계 검사를 빼고 싶으면 -Ounchecked가 아니라 이걸 쓴다
func fastLoop(_ values: [Double]) -> Double {
    values.withUnsafeBufferPointer { buf in
        var total = 0.0
        for i in buf.indices { total += buf[i] }   // 이미 경계 검사가 없다
        return total
    }
}
// 범위를 함수 하나로 좁힐 수 있고, 나머지 코드의 안전성은 유지된다

// ⚠️ 판단: -Ounchecked로 얻는 이득을 '측정'했는가?
//    대부분의 앱에서 차이는 미미하고, 잃는 것(진단 가능성)은 크다.
⚠️ 흔한 오해

"Debug에서 느리면 Release에서도 비슷하게 느리다"는 틀리다. Swift에서 그 격차는 대부분 ARC·제네릭 오버헤드에서 오고 -O가 이를 없애므로, Debug 숫자는 성능 판단에 무의미하다. 또 "Release는 Debug에서 로그만 뺀 것"이라는 생각도 틀리다 — 최적화·WMO·assert 제거가 얽힌 근본적으로 다른 컴파일이라, Release에서만 재현되는 버그(초기화 안 된 값·데이터 레이스·미정의 동작)도 생길 수 있다.

🧒 쉽게 이해하기

가구를 두 가지 방식으로 조립한다고 해 보자. Debug 빌드는 설명서를 한 단계도 건너뛰지 않고 순서대로 그대로 따라 하는 쪽이다. 느리지만 어디서 틀렸는지 짚기 쉽다 — 브레이크포인트가 정확히 그 줄에 걸리는 이유가 이거다. Release 빌드는 숙련공이 "이 세 단계는 한 번에 해도 되고, 저 확인 단계는 어차피 통과할 테니 빼자"고 판단해서 만든 쪽이다. 나온 가구는 똑같은데 훨씬 빠르다.

Swift는 유독 뺄 게 많은 언어라 이 격차가 크다. 최적화를 켜면 자잘한 함수 호출이 그 자리에 녹아들고(인라인), 필요 없는 retain/release가 지워지고, 제네릭이 실제로 쓰인 타입 전용 버전으로 복제된다. 여기서 진짜 조심할 건 assert다 — Release에서는 그 확인 단계가 통째로 사라지므로, "확인하는 김에" 그 안에서 딴 일까지 시켜 놨다면 그 일도 같이 증발한다. 그래서 Debug에서 잰 속도로 성능을 말하는 건, 설명서 보며 천천히 조립한 시간으로 공장 생산 속도를 추정하는 것과 같다.

꼬리 질문

assert(mutateAndCheck())처럼 조건식 안에 상태를 바꾸는 부수효과가 있는 함수를 넣으면 Debug와 Release(-O)에서 동작이 어떻게 갈리며, 왜 이게 재현이 어려운 위험한 버그가 되나?

assert(_:)의 인자는 @autoclosure () -> Bool이라 조건식 전체가 지연 클로저로 감싸진다. Debug(-Onone)에서는 이 클로저가 호출돼 mutateAndCheck()의 부수효과(상태 변경)까지 실제로 일어나지만, Release(-O)에서는 조건 평가 자체가 통째로 제거돼 그 함수가 아예 호출되지 않는다. 그 결과 부수효과가 Debug에만 존재하고, 그 상태 변경에 은근히 기대던 뒤쪽 코드가 두 빌드에서 다르게 동작한다.

위험한 건 개발·테스트를 대개 Debug로 하니 멀쩡히 통과하다가, 최적화된 프로덕션(Release) 빌드에서만 로직이 어긋난다는 점이다. 정작 버그가 나타나는 그 빌드는 최적화·인라인 때문에 브레이크포인트와 변수 관찰이 어려워 재현·추적이 까다롭다. 그래서 assert·precondition의 조건식은 반드시 부수효과 없는 순수 판정이어야 하고, 상태를 바꾸는 호출은 조건식 밖에서 먼저 실행한 뒤 그 결과값만 검사해야 한다.

쉽게 말하면 소풍 가기 전에 "가방 다 챙겼나?" 확인하면서 그 김에 도시락도 슬쩍 집어넣는 습관을 들였다고 해 보자. 연습(Debug)에서는 늘 도시락이 들어 있으니 아무도 문제를 못 느낀다. 그런데 실전(Release)에서는 그 확인 절차가 통째로 잘려 나가고, 도시락도 같이 사라진다. 확인은 확인만 시키고, 무언가를 바꾸는 일은 확인 밖에서 먼저 해 둬야 하는 이유다.
Debug에선 멀쩡한데 Release에서만 크래시가 난다면, 원인 후보를 최적화·타이밍·미정의 동작 관점에서 무엇부터 의심하겠나?

먼저 미정의 동작(UB)을 의심한다 — 초기화되지 않은 값, 이미 해제된 메모리 접근, 잘못된 unsafeBitCast/포인터 사용은 Debug에선 우연히 0이 채워져 넘어가다 최적화가 가정을 바꾸면 터진다. 최적화 자체가 버그를 만들기보다, 원래 있던 UB를 드러내는 경우가 대부분이다.

다음으로 타이밍/동시성 — -O로 코드가 빨라지고 재배치되면 숨어 있던 데이터 레이스나 초기화 순서 의존이 표면화된다. 마지막으로 assert로만 막던 조건이 Release에서 사라져 통과해 버리는 경우도 본다. TSan/ASan을 최적화 빌드에 붙여 좁혀 가는 게 실전 순서다.

쉽게 말하면 나사 하나가 원래부터 덜 조여져 있던 자전거를 생각해 보자. 살살 타면 아무 일 없다가 속도를 올리는 순간 바퀴가 빠진다. 그렇다고 "빨리 달린 게 원인"이라 말하지는 않는다. Release 크래시도 대개 이렇다 — 최적화가 버그를 만든 게 아니라, 원래 있던 UB(undefined behavior, 언어가 결과를 보장해 주지 않는 코드)를 드러낸 것이다. 그러니 범인은 최적화가 아니라 초기화 안 한 값, 이미 해제된 메모리, 숨어 있던 데이터 레이스 쪽에서 먼저 찾는다.
WMO를 켜면 왜 빌드는 느려지고 런타임은 빨라지는가? 증분 빌드 관점의 트레이드오프는?

전모듈 최적화(WMO)는 컴파일러가 모듈의 모든 소스를 한꺼번에 보게 해서, 파일 경계를 넘는 인라인과 제네릭 특수화, 불필요한 ARC 제거를 가능하게 한다. 그래서 런타임이 빨라지는 대신, 컴파일 단위가 파일 하나가 아니라 모듈 전체라 한 번의 컴파일이 무거워져 빌드가 느리다.

증분 빌드 관점의 손해가 크다 — 파일 하나만 고쳐도 모듈 전체를 함께 봐야 하므로 재컴파일 범위가 넓어져 반복 개발 사이클이 길어진다. 그래서 보통 Debug는 WMO를 끄고 파일 단위 증분 빌드로 빠르게 돌리고, Release에서만 WMO를 켜 런타임 성능을 취하는 절충을 쓴다.

쉽게 말하면 소설을 번역할 때, 한 챕터씩 따로 맡기면 빨리 나오지만 앞뒤 말투가 어긋난다. 한 사람이 처음부터 끝까지 다 읽고 번역하면 결과물은 훨씬 매끄럽다 — 이게 WMO다. 대신 한 문장만 고쳐도 다시 전체를 훑어야 하니 손보는 데 오래 걸린다. 그래서 원고를 계속 고치는 동안(Debug)에는 챕터별로 빨리 돌리고, 출간본(Release)을 뽑을 때만 통독한다.

Q5. CPU 사용량은 배터리와 발열에 어떻게 연결되는가?

🔑 30초 답변

CMOS 회로의 동적 소비 전력은 대략 P ∝ C·V²·f(정전용량·전압²·주파수)이다. CPU를 세게 돌리면 주파수·전압이 올라(DVFS, Dynamic Voltage and Frequency Scaling — 부하에 따라 전압·주파수를 조절하는 기법) 전력이 급증하고, 그 전력은 배터리 소모이자 곧 발열이다. 소비 에너지는 전력×시간이라 "얼마나 세게"와 "얼마나 오래"가 모두 중요하고, 열이 쌓여 온도가 임계에 닿으면 스로틀링(주파수↓)으로 성능이 깎인다. 그래서 CPU 사용량은 배터리·발열·지속 성능이 한 줄로 엮인 문제다.

CS 원리

CMOS에서 전력은 스위칭할 때 커패시턴스를 충·방전하며 소모되는 동적 전력과, 가만히 있어도 새는 누설 전력(static/leakage)으로 나뉜다. 동적 전력이 전압의 제곱에 비례한다는 점이 핵심이다(CMOS 전력 모델) — 주파수를 올리려면 전압도 올려야 하므로 성능을 조금 더 짜낼 때 전력은 훨씬 가파르게 뛴다. 소모한 전력은 에너지 보존에 따라 사실상 전부 이 되고, 열 발생이 방출을 넘어서면 온도가 오른다. 여기서 흔한 오해가 갈린다 — 짧고 굵게 끝내고 깊은 idle로 들어가는 "race to idle"이, 낮은 전력으로 오래 끄는 것보다 총 에너지가 적을 수 있다(idle 전력이 매우 낮기 때문). 다만 항상은 아니고 작업 성격에 달렸다.

CPU 부하 ↑ DVFS: 주파수 f · 전압 V ↑ 소비 전력 P ∝ C · V² · f 배터리 소모 ↑ 에너지 = P × 시간 발열 ↑ 전력 → 열 온도 임계 → 스로틀링 f·V ↓ → 성능 저하 (thermalState) 성능 되먹임
부하 → 전력(V²에 민감) → 배터리·발열 → 스로틀링으로 성능이 되돌아 깎이는 폐루프. 그래서 "무조건 빨리"가 항상 최선은 아니다.

iOS에서는

Apple Silicon은 성능(P) 코어와 효율(E) 코어를 함께 두고 DVFS로 주파수·전압을 조절한다. 스케줄러는 낮은 QoS·백그라운드 작업을 훨씬 전력이 낮은 E 코어에 배치하므로, 작업의 QoS를 올바로 지정하는 것만으로 에너지가 크게 달라진다(Energy Efficiency Guide for iOS Apps). 발열이 쌓이면 시스템이 ProcessInfo.thermalState.nominal에서 .fair.serious.critical로 올리고 CPU/GPU를 스로틀링한다. 앱은 이 상태를 구독해 프레임 레이트나 백그라운드 작업량을 줄여야 한다(thermalState 문서). 또 잦은 타이머 웨이크업은 SoC가 깊은 idle로 못 들어가게 막아 배터리를 갉아먹으므로, 웨이크업을 모아 처리(coalescing)하는 게 중요하다.

같은 60초 · 실제 연산 시간은 둘 다 거의 0 1초 타이머 깨움이 잦아 깊은 잠에 못 든다 — 깰 때마다 고정 비용이 붙는다 60초 타이머 깊은 잠 (deep idle) — 전력 거의 0 깨움 1번 — 나머지 시간은 계속 깊은 잠
세로 막대 하나가 웨이크업 한 번. 연산량(CPU %)은 거의 같아도, 칩이 깊은 잠에 들 수 있느냐에서 배터리가 갈린다.

실험 · 도구

개발 중에는 Xcode 디버그 내비게이터의 Energy ImpactCPU 게이지로 대략을 보고, 필드 데이터는 MetricKitMXCPUMetric(누적 CPU 시간·명령어 수)으로 CPU 부하를 수집한다 — MetricKit에는 전력 소비 지표 자체가 없으므로, 실제 배터리 소모는 Xcode Organizer의 Battery Usage 리포트로 본다. 열 상태 변화는 thermalStateDidChangeNotification으로 관찰하고, macOS라면 powermetrics로 CPU·GPU·ANE 등 SoC 서브시스템별 추정 전력과 P/E 클러스터별 주파수·유휴 잔류 시간을 볼 수 있다(전력 값은 추정치라 절대 비교용은 아니다). 관찰 지표는 CPU%뿐 아니라 어느 코어(P/E)에서 얼마나 오래 도는지와 온도·스로틀 여부를 함께 봐야 한다.

QoS로 코어를 고르고, 열 상태에 반응하기 (Swift)
// 백그라운드 대량 작업은 낮은 QoS → 스케줄러가 효율(E) 코어에 배치 → 전력↓
Task(priority: .utility) { await reindexPhotos() }

// 열 상태에 따라 작업량을 조절
NotificationCenter.default.addObserver(
    forName: ProcessInfo.thermalStateDidChangeNotification,
    object: nil, queue: .main
) { _ in
    switch ProcessInfo.processInfo.thermalState {
    case .serious, .critical: renderer.targetFPS = 30   // 프레임·품질 낮추기
    default:                  renderer.targetFPS = 60
    }
}

프로젝트 적용

작업 성격에 맞는 QoS 지정 — 백그라운드/비대화형은 .utility·.background로 E 코어 유도.

QoS로 코어 배치 유도
import Foundation

// Apple Silicon은 성능(P) 코어와 효율(E) 코어를 함께 둔다.
// QoS는 "어느 쪽에 배치할지"의 힌트가 된다. E 코어는 전력이 훨씬 낮다.

// ✅ 사용자가 지금 기다린다 → P 코어 우선
func loadCurrentScreen() {
    Task(priority: .userInitiated) { await fetchAndRender() }
}

// ✅ 곧 필요하지만 지금 기다리진 않는다 → E 코어로 유도
func prefetchNextPage() {
    Task(priority: .utility) { await prefetch() }
}

// ✅ 사용자가 전혀 모르는 뒷정리 → 가장 낮게
let cleanupQueue = DispatchQueue(label: "com.app.cleanup", qos: .background)

func scheduleCleanup() {
    cleanupQueue.async { pruneOldCache() }
}

// ✅ 큐를 만들 때 QoS를 명시한다 — 기본값은 .default라 애매하다
let syncQueue = DispatchQueue(label: "com.app.sync", qos: .utility)

// ❌ 전부 높게 올리면 우선순위가 의미를 잃고 전력만 더 쓴다
// let everything = DispatchQueue(label: "x", qos: .userInteractive)

// ⚠️ QoS 역전 주의: 높은 QoS가 낮은 QoS의 결과를 기다리면
//    시스템이 낮은 쪽을 승격해 주지만, 세마포어로 막아 둔 경우 등
//    승격이 통하지 않는 경로가 있다. 구조 자체를 피하는 게 낫다.

// ✅ 실제 코어 배치를 확인하려면 Instruments의 CPU Counters / System Trace에서
//    스레드가 어느 코어 클러스터에서 도는지 볼 수 있다

func fetchAndRender() async {}
func prefetch() async {}
func pruneOldCache() {}

busy-wait·타이트 폴링 루프 금지(코어를 100%로 붙잡아 전력·발열 폭증). 폴링 대신 이벤트/푸시, 타이머는 coalescing.

busy-wait·폴링 금지
import Foundation

// 타이트 루프는 코어를 100%로 붙잡는다. 전력·발열이 급증하고
// 다른 작업까지 밀린다.

// ❌ ① busy-wait
func waitBad(until condition: () -> Bool) {
    while !condition() { }              // 💥 코어 하나를 통째로 태운다
}

// ❌ ② 짧은 간격 폴링
func pollBad() {
    Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { _ in
        checkServerForUpdates()          // 초당 20번 — 배터리가 녹는다
    }
}

// ✅ ① 이벤트 기반으로 바꾼다
actor Signal {
    private var waiters: [CheckedContinuation<Void, Never>] = []

    func wait() async {
        await withCheckedContinuation { waiters.append($0) }   // 잠든다
    }
    func fire() {
        let current = waiters; waiters = []
        current.forEach { $0.resume() }
    }
}

// ✅ ② 서버 푸시로 (폴링 자체를 없앤다)
//    APNs silent push → application(_:didReceiveRemoteNotification:)

// ✅ ③ 폴링이 불가피하면 간격을 늘리고 leeway를 준다
func pollReasonably() {
    let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
    timer.schedule(deadline: .now() + 60,
                   repeating: 60,
                   leeway: .seconds(15))   // ✅ 시스템이 다른 깨우기와 묶는다
    timer.setEventHandler { checkServerForUpdates() }
    timer.resume()
}

// ✅ ④ 지수적으로 간격을 늘린다 (아무 일도 없으면 덜 자주 확인)
func adaptivePoll(idleRounds: Int) -> Duration {
    .seconds(min(pow(2.0, Double(idleRounds)) * 30, 900))   // 30초 → 최대 15분
}

func checkServerForUpdates() {}
import UIKit

thermalStateProcessInfo.isLowPowerModeEnabled에 반응해 프레임 레이트·백그라운드 처리량을 낮춘다.

thermalState·저전력 모드에 반응
import Foundation
import UIKit

// 열이 쌓이면 시스템이 주파수를 낮춘다(스로틀링). 그 전에 스스로 부하를 줄이면
// 사용자 체감 성능이 오히려 좋아진다.

@MainActor
final class PerformanceGovernor {
    private var observers: [NSObjectProtocol] = []

    func start() {
        observers.append(NotificationCenter.default.addObserver(
            forName: ProcessInfo.thermalStateDidChangeNotification,
            object: nil, queue: .main) { [weak self] _ in self?.apply() })

        observers.append(NotificationCenter.default.addObserver(
            forName: .NSProcessInfoPowerStateDidChange,
            object: nil, queue: .main) { [weak self] _ in self?.apply() })

        apply()
    }

    private func apply() {
        let info = ProcessInfo.processInfo

        if info.isLowPowerModeEnabled {
            setQuality(.minimum)
            return
        }

        switch info.thermalState {
        case .nominal:  setQuality(.maximum)
        case .fair:     setQuality(.high)
        case .serious:  setQuality(.reduced)
        case .critical: setQuality(.minimum)
        @unknown default: setQuality(.high)
        }
    }

    private func setQuality(_ level: Quality) {
        switch level {
        case .maximum:
            displayLink?.preferredFrameRateRange =
                CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120)
            ImagePipeline.shared.resumePrefetch()
            VideoPlayer.shared.preferredResolution = .p1080

        case .high:
            displayLink?.preferredFrameRateRange =
                CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)

        case .reduced:
            displayLink?.preferredFrameRateRange =
                CAFrameRateRange(minimum: 20, maximum: 30, preferred: 30)
            ImagePipeline.shared.pausePrefetch()
            VideoPlayer.shared.preferredResolution = .p720

        case .minimum:
            displayLink?.preferredFrameRateRange =
                CAFrameRateRange(minimum: 10, maximum: 20, preferred: 15)
            ImagePipeline.shared.pausePrefetch()
            BackgroundSync.shared.suspend()
        }
    }

    private enum Quality { case maximum, high, reduced, minimum }
    private var displayLink: CADisplayLink?
}

enum ImagePipeline { static let shared = ImagePipelineImpl() }
struct ImagePipelineImpl { func resumePrefetch() {}; func pausePrefetch() {} }
enum VideoPlayer { static var shared = VideoPlayerImpl() }
struct VideoPlayerImpl { var preferredResolution: Resolution = .p1080
    enum Resolution { case p720, p1080 } }
enum BackgroundSync { static let shared = BackgroundSyncImpl() }
struct BackgroundSyncImpl { func suspend() {} }
⚠️ 흔한 오해

"CPU 사용률(%)만 낮추면 배터리가 좋아진다"는 지나친 단순화다. 에너지는 전력×시간이고, 어느 코어(P vs E)에서, 어떤 주파수·전압으로, 얼마나 자주 깨어 도는지가 함께 작용한다 — E 코어에서 짧게 끝나는 100% 버스트가, SoC를 계속 얕게 깨워 두는 20% 잔업보다 나을 수 있다. "발열은 성능과 무관"도 틀리다 — 지속 발열은 스로틀링을 부르고, 스로틀링은 곧 성능 저하다.

🧒 쉽게 이해하기

걷다가 전력질주로 바꾸면 속도는 두 배쯤 되는데 숨은 네 배쯤 가빠진다. CPU도 똑같다. 주파수를 올리려면 전압을 같이 올려야 하고, 전력은 전압의 제곱으로 뛴다(P ∝ C·V²·f). 그렇게 쓴 전기는 어디로 사라지지 않고 거의 전부 열이 된다 — 뛰면 몸이 뜨거워지는 것과 같은 이치다. 그리고 몸이 너무 뜨거워지면 저절로 속도가 떨어지듯, 칩도 온도가 임계에 닿으면 스스로 주파수를 낮춘다(스로틀링). 그래서 "무조건 세게 돌리기"가 항상 이기는 게 아니다.

배터리 쪽에서 더 헷갈리는 건 이다. 칩은 할 일이 없으면 깊은 잠에 드는데, 깨울 때마다 클럭·전압을 올리고 캐시를 데우는 고정 비용이 붙는다. 1초마다 툭툭 건드리면 CPU 사용률은 0%처럼 보여도 칩은 하루 종일 선잠만 자게 되고, 이쪽이 배터리를 훨씬 많이 먹는다. 그래서 "CPU %만 낮추면 된다"가 아니라 어느 코어에서, 얼마나 세게, 얼마나 자주 깨서 도는지를 같이 봐야 한다.

꼬리 질문

같은 계산을 P 코어에서 빨리 끝내는 것과 E 코어에서 천천히 하는 것, 배터리 관점에서 무엇이 유리한지 어떤 조건이 가르나?

가르는 건 작업이 지연에 민감한지, 그리고 끝난 뒤 얼마나 깊은 idle로 들어갈 수 있는지이다. 전력이 전압²에 비례하므로 P 코어의 고주파·고전압은 순간 전력이 훨씬 비싸다. 하지만 빨리 끝내고 SoC 전체가 깊은 저전력 상태로 들어가면(race to idle) 총 에너지(전력×시간)가 오히려 적을 수 있다.

반대로 E 코어에서 낮은 전압으로 오래 끄는 편이 유리한 경우는, 그 작업이 백그라운드라 지연이 상관없고 어차피 다른 이유로 SoC가 깨어 있을 때이다. 결국 "idle로 얼마나 깊이·오래 들어갈 수 있나"와 QoS(지연 요구)가 판단 기준이고, 실측으로 확인해야 한다.

쉽게 말하면 세탁기 강력 코스와 절약 코스 중 뭐가 전기를 덜 먹느냐는 질문과 같다. 강력 코스는 도는 동안 전기를 훨씬 많이 먹지만 빨리 끝나고 플러그를 뽑을 수 있다. 절약 코스는 약하게 돌지만 그동안 기계 전체가 계속 켜져 있어야 한다. 그래서 승부는 "끝나고 얼마나 깊이 잠들 수 있느냐"에서 갈린다 — 어차피 다른 일 때문에 기계가 켜져 있을 시간이면, 그 틈에 약하게 도는 쪽이 이긴다.
thermalState.serious로 올라가면 게임·영상 앱은 구체적으로 무엇을 줄여야 하나?

가장 효과 큰 건 프레임 레이트 하향(60→30fps)과 렌더 해상도 축소이다. GPU/CPU 부하가 프레임 수와 픽셀 수에 거의 비례하므로 여기서 전력이 크게 빠진다. 이어 셰이더 품질·후처리(블룸, 그림자, 안티에일리어싱) 다운그레이드, 물리·파티클 단순화, 화면 밖 연산 스킵을 적용한다.

영상 앱이면 하드웨어 디코더를 최대한 쓰고(소프트웨어 디코딩은 CPU를 태움), 불필요한 색보정·업스케일을 끄며 배경 프리페치·업로드 같은 부가 작업을 미룬다. 핵심은 스로틀링에 끌려가 프레임이 요동치기 전에 앱이 먼저 자발적으로 부하를 낮춰 체감 안정성을 지키는 것이다.

쉽게 말하면 오르막에서 숨이 차면 목적지를 바꾸는 게 아니라 보폭부터 줄인다. 게임에서 보폭에 해당하는 게 초당 그림 수와 해상도다 — 전기를 제일 많이 먹는 곳이라 여기부터 줄여야 효과가 크다. 더 중요한 건 타이밍이다. 시스템이 강제로 깎기 시작하면 프레임이 60에서 20으로 널뛰어 오히려 더 티가 난다. 내가 먼저 30으로 내려놓으면 느려도 일정하게 느려서 사람 눈에는 훨씬 낫다.
1초마다 깨는 타이머와 60초마다 한 번 깨는 타이머의 배터리 차이가 사용률(%) 차이보다 클 수 있는 이유는?

CPU 사용률만 보면 두 타이머의 실제 연산 시간은 미미해 둘 다 거의 0%로 보인다. 하지만 배터리를 결정하는 건 연산량이 아니라 SoC를 얼마나 자주 깨우느냐이다. 유휴 상태의 칩은 깊은 저전력 슬립에 있는데, 매 웨이크업마다 클럭·전압을 올리고 캐시를 데우는 전이 비용(고정 오버헤드)이 붙는다.

1초 타이머는 이 비싼 전이를 분당 60번 강제해 SoC가 깊은 idle에 들어갈 틈을 주지 않는 반면, 60초 타이머는 대부분의 시간을 슬립으로 보낸다. 그래서 사용률(%) 차이는 거의 없어도 배터리 차이는 수십 배가 날 수 있고, 이 때문에 타이머 coalescing과 이벤트 기반 설계가 중요하다.

쉽게 말하면 자는 사람을 1분마다 툭 쳐서 "괜찮아?" 묻고 바로 다시 재우기를 밤새 반복해 보라. 실제로 말을 건 시간은 다 합쳐도 1분이 안 되지만, 그 사람은 하룻밤 내내 깊은 잠을 단 한 번도 못 잔다. CPU 사용률(%)은 "말을 건 시간"만 세는 지표라 0%에 가깝게 보이고, 배터리는 "깊이 못 잔 값"을 대신 치른다. 그래서 알림을 모아서 한 번에 묻는 게(coalescing) 핵심이다.

출처 · 참고자료