import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking // ★ Linux 판정기: 이 임포트 없이는 URLSession이 컴파일조차 안 됨
#endif
// ═══════════════════════════════════════════════════════════════
// 쿠팡이츠 코딩면접 · 종합 레퍼런스 (탄창)
//
// 사용법: 실전에서 이 파일을 "재현"하는 게 아니다.
// 문제가 요구하는 최소만 만들고, 면접관이 요구를 추가할 때
// 해당 MARK 부품을 꺼낸다.
//
// 지도:
// ① ImageURL — URL 검증 (타입이 곧 검증)
// ② LRURecency — O(1) 최근성 장부 (측정 기록 포함)
// ②B LFURecency — O(1) 빈도 장부. <EvictionPolicy>로 ②와 교체 가능
// ③B NSCacheImageCache — ★기본 캐시★ 12줄. 안내문 "실습 추천"이 지목한 것
// ③ ImageCache — 자체 LRU actor. ★팔로업★ "LRU"·순서 증명·복잡도 요구 시
// (③B→③ 갈아타는 규칙은 아래 '캐시 선택 규칙' 참조)
// ④ ImageLoader — 중복 합류 + loadAll(동시 N 제한)
// ⑤ ImageCellController — 셀 재사용 방어 2겹
// ⑤B UIKit 매핑 — "UITableView로 직접" 대응 (#if UIKit)
// ⑤C ImageDecoder — 지연 디코딩 대응 (다운샘플링) "스크롤이 끊겨요"
// ⑥ MVVM — CellImageState + AsyncStream 바인딩
// ⑦ SearchDebouncer — 검색 자동완성 (마지막이 진짜)
// ⑦B Throttler — 버튼·셀 연타 (첫 것이 진짜) + UIKit 예시
// ⑧ RetryingFetcher — 재시도+백오프 (401/5xx 분리)
// ⑨ withTimeout — 작업 vs 시계 경주
// ⑩ staleWhileRevalidate — 캐시 즉시 + 신선한 값 나중
// ⑪ Paginator — 무한 스크롤 중복 방지
// ⑫ URLSessionFetcher — 실네트워크 (판정기엔 네트워크 없음 주의)
// ⑬ 테스트 더블 — FakeFetcher / FlakyFetcher / Log / Counter
// ⑭ 데모 main — 10개 시연
//
// ───────────────────────────────────────────────────────────────
// 데이터 흐름 — 면접 시작 3분에 화이트보드/주석으로 그릴 그림
// ───────────────────────────────────────────────────────────────
//
// [VC] urls: [ImageURL] ← ① 검증된 URL만 존재
// │ loader = ImagePipeline.shared ← 앱 레벨 1개 (VC마다 X)
// │
// ├─▶ [Cell] ──▶ [ImageCellController] ⑤ ← 셀마다 1개
// │ │ 방어① task.cancel()
// │ │ 방어② 시작 전 isCancelled → 요청 자체를 안 보냄
// │ │ 방어③ currentURL == url → 늦은 응답 폐기
// │ ▼
// └────────────▶ [ImageLoader] ④ (actor)
// │
// ├─1▶ cache.value(url) 있으면 즉시 반환 O(1)
// ├─2▶ inFlight[url] 진행 중이면 합류 ← 중복 다운로드 0
// └─3▶ fetcher.fetch(url) 없을 때만 네트워크
// │
// ▼ 완료 시점에 기록 (합류자 전원이 이후 캐시 히트)
// [ImageCache] ③ (actor)
// │ storage[URL: Data] + LRURecency ②
// │ 상한 2개: countLimit / costLimit
// └─ 넘치면 popOldest() 축출
//
// 소유 계층 (누가 무엇을 들고 있나 — 잘못 두면 캐시가 쪼개진다)
// 앱 : ImageLoader + ImageCache ← 화면 전환해도 유지
// 셀 : ImageCellController ← 신원표(currentURL) + task는 개인 소지품
// 시스템: URLSession.shared ← 커넥션 풀만. 메모리 캐시·합류는 안 해줌
//
// 핵심 문장 3개
// "취소는 부탁이고, 신원 확인은 검문입니다" → ⑤ 방어②③
// "싼 것부터: 캐시 → 합류 → 네트워크" → ④ load 3줄
// "조회도 '사용'이다" → ② LRU의 정의
//
// ───────────────────────────────────────────────────────────────
// 의존성 다이어그램 — 화살표 = "~에 의존한다"
// [대괄호] = 구체 타입 / <꺾쇠> = 프로토콜(경계) / (괄호) = 갈아끼울 수 있는 구현
// 규칙: 구체 타입은 프로토콜에만 의존한다. 화살표는 항상 추상을 향한다 (DIP).
// ───────────────────────────────────────────────────────────────
//
// [ViewController]
// │ 주입: ImagePipeline.shared (테스트 시 스텁 주입 가능)
// ▼
// [ImageCellController] ⑤ ───────▶ <ImageLoading> ④
// │ ▲
// │ render: (Data?) -> Void │ 구현
// │ (클로저 = 프로토콜 없는 경계) │
// ▼ [ImageLoader]
// (UIImageView 갱신) │
// (RenderRecorder — 테스트) ├──────▶ <ImageDataFetching> ⑬
// │ ▲
// │ │ 구현
// │ (URLSessionFetcher) ⑫ ← 실네트워크
// │ (FakeFetcher) ⑬ ← 지연·실패 주입
// │ (FlakyFetcher) ⑬ ← N회 실패 후 성공
// │ (RetryingFetcher) ⑧ ← 데코레이터: 감싸서 재시도
// │
// └──────▶ <ImageCaching> ③
// ▲
// │ 구현
// (ImageCache) ③ ← LRU 직접 제어
// (NSCacheImageCache) ③B ← 메모리 압박 자동 대응
// │
// ▼ 내부 부품 (교체 대상 아님)
// [LRURecency] ②
//
// ① [ImageURL] — 모든 층이 값으로 사용. 의존이 아니라 '어휘'
//
// 읽는 법
// · 화살표가 <꺾쇠>로 들어가면 = 그 층은 구현을 모른다 = 갈아끼울 수 있다
// · (괄호) 안의 것들은 서로 형제 = 언제든 교체 가능 (데모 [5]가 실증)
// · [LRURecency]에 화살표가 없는 이유: ImageCache의 private 내부라 외부에 안 보임
//
// 왜 이렇게 나눴나 (면접 답변)
// "판정기엔 네트워크가 없습니다. <ImageDataFetching>이 없으면 테스트 자체가 불가능하고,
// 그래서 이 경계는 취향이 아니라 강제입니다. <ImageCaching>은 NSCache로 갈아끼울 수
// 있게 하는 자리고, 실제로 로더 코드 한 줄 안 바꾸고 교체됩니다."
//
// 경계가 '없는' 곳과 그 이유
// · ImageCellController — 프로토콜 없음. 소비자가 없고, render 클로저가 이미 경계
// · LRURecency — 프로토콜 없음. 구현이 하나뿐 (두 번째가 생기면 그때)
//
// ───────────────────────────────────────────────────────────────
// 요구사항 → 꺼낼 부품 (면접관이 요구를 추가할 때 이 표를 본다)
// ───────────────────────────────────────────────────────────────
// "같은 이미지를 두 번 받지 마세요" → ④ inFlight 합류
// "스크롤 시 잘못된 이미지가 보입니다" → ⑤ 방어 2겹
// "화면 밖 다운로드는 취소하세요" → ⑤ prepareForReuse
// "메모리 캐시를 관리하세요"(기본) → ③B NSCacheImageCache ★기본 선택★
// "최대 N개만 캐시하세요" → ③B countLimit / totalCostLimit로 충분
// "가장 오래 안 쓴 것부터 버리세요" → ③ + ② 자체 LRU (O(1)) ─ 여기서만 갈아탄다
// "많이 쓴 것을 지키세요" → ③ + ②B LFU (policy 인자만 교체)
// "축출 순서를 테스트로 증명하세요" → ③ + ② (NSCache는 순서 보장이 없어 불가)
// "URL이 잘못됐을 수 있습니다" → ① ImageURL (생성 자체가 불가능)
// "동시 3개까지만 받으세요" → ④ loadAll 슬라이딩 윈도우
// "로딩/실패를 화면에 구분해 보여주세요" → ⑥ CellImageState enum
// "검색어 입력마다 요청하지 마세요" → ⑦ SearchDebouncer (마지막 것)
// "버튼/셀을 연타하면 두 번 실행돼요" → ⑦B Throttler (첫 것 즉시, 창 동안 무시)
// "실패하면 재시도하세요" → ⑧ RetryingFetcher (401은 1회, 5xx는 3회)
// "응답이 너무 늦으면 포기하세요" → ⑨ withTimeout
// "캐시 먼저 보여주고 갱신하세요" → ⑩ staleWhileRevalidate
// "무한 스크롤인데 중복 로드됩니다" → ⑪ Paginator (isLoading 가드)
// "UITableView/CollectionView로 붙이세요" → ⑤B (로더는 앱 레벨, 셀은 컨트롤러 소유)
// "빠르게 스크롤하면 끊깁니다" → ⑤C 다운샘플링 (render 클로저만 교체)
//
// ───────────────────────────────────────────────────────────────
// ★ 캐시 선택 규칙 — 리크루터 안내문 근거로 확정 ★
// ───────────────────────────────────────────────────────────────
// 안내문은 목록을 둘로 나눠 썼다:
// "개념적으로 도움됩니다" → LRU Cache, Concurrency, Task Scheduling (배경 지식)
// "실습도 추천드립니다" → NSCache 기반 이미지 캐싱 구현, ... (손으로 할 과제)
// 시나리오 표현도 "메모리 캐시 관리"이지 "LRU 캐시 구현"이 아니다.
//
// ⇒ 기본은 ③B NSCache. 자체 LRU(③+②)는 '요구가 오면 꺼내는 팔로업'이다.
//
// 실전 이유(더 중요): 캐시는 시나리오 4개 중 하나일 뿐이다.
// 비동기 다운로드 · 셀 재사용 · 캐시 · MVVM 바인딩
// 여기서 이중 연결 리스트를 손으로 짜면 15~20분이 사라지고, 점수 비중이 큰
// 동시성·셀 재사용·바인딩을 못 끝낸다. NSCache는 12줄이다.
//
// 자체 LRU로 갈아타는 신호는 정확히 셋 — 이 셋이 없으면 NSCache를 유지한다:
// 1) "LRU"라는 단어가 요구에 직접 등장
// 2) 축출 '순서'나 '시점'을 테스트로 증명하라는 요구
// 3) 복잡도(O(1)) 논증을 명시적으로 요구
//
// 갈아탈 때 말할 문장:
// "지금까지는 NSCache로 갑니다 — 메모리 압박 자동 대응을 공짜로 얻으니까요.
// 다만 축출 순서는 문서화된 보장이 없어서, LRU가 요구사항이면 증명할 수가
// 없습니다. 그 요구가 있다면 여기서 자체 구현으로 교체하겠습니다 —
// <ImageCaching> 경계라 로더는 한 줄도 안 바뀝니다."
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════// MARK: - ① ImageURL — 잘못된 URL은 '생성 자체가 불가능'한 타입
// ═══════════════════════════════════════════════════════════════
// 의도: 검증을 여기저기 흩뿌리지 않고 입구에서 타입으로 좁힌다.
// 이 타입 아래의 모든 코드는 "검증된 URL"만 받으므로 검증 코드 0줄.
// 면접 멘트: "파싱은 검증이 아닙니다 — 최신 파서는 쓰레기 문자열도
// URL로 만들어버려서, scheme·host 가드가 진짜 검증입니다."
//
// ── ImageURL은 어디까지 여행하는가 — 타입 전파의 선 (옆문 완전 차단판) ──
// 결정: type-driven을 선언한 코드베이스이므로, 로딩 서브시스템의 '모든 공개 API'가
// ImageURL을 받는다 — 요청(Cell·ViewModel·load·loadAll)뿐 아니라
// 경계 프로토콜(ImageDataFetching·ImageCaching)까지. 검증 안 된 URL은
// 어느 문으로도 '타입상' 진입 불가. loader/cache/fetcher 어디에도
// URL(string:)로 만든 값을 몰래 넣을 수 없다(컴파일 에러).
//
// 그럼 플랫폼 상호운용(URLSession은 URL, NSCache는 NSURL)은?
// → '벗는 지점'을 로더가 아니라 '각 구현체 내부'로 옮긴다. 플랫폼 API를
// 실제로 호출하는 그 줄 바로 앞에서 url.value로 한 번 벗는다.
// (URLSessionFetcher: data(from: url.value) / NSCacheImageCache: url.value as NSURL)
// → 데모 [5]의 백엔드 무수정 교체는 그대로 유지된다.
//
// 트레이드오프(정직하게):
// · 얻음: 옆문 0개 — 검증 우회가 타입 시스템 차원에서 불가능.
// · 냄: ImageCaching이 이미지 전용으로 좁아진다(범용 URL 캐시로 재사용 불가).
// 이 파일은 '이미지 로딩 서브시스템'이 목적이라 이 좁힘을 수용한다.
// · 다이얼: 범용 캐시가 필요하면 프로토콜은 URL로 두고 로더에서 벗는
// 이전 방식으로 되돌린다 — boundary.swift가 그 판단축을 코드로 보여준다.
//
// 면접 한 줄: "검증 타입은 서브시스템 전체를 여행하고,
// 플랫폼 API를 실제로 호출하는 구현체 안에서만 벗깁니다."
struct ImageURL: Hashable, Sendable {
let value: URL
/// 복잡도: O(문자열 길이) — 파싱 1회, 생성 시점에만 지불
init?(_ string: String) {
guard
let url = URL(string: string), // 파싱 (검증 아님 — 관대함)
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https", // 이미지 소스는 웹만 (file:, javascript: 차단)
let host = url.host, !host.isEmpty // 목적지 없는 URL 차단 ("https://")
else { return nil }
self.value = url
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ② LRURecency — "최근에 누가 쓰였나"만 아는 O(1) 장부
// ═══════════════════════════════════════════════════════════════
// 역사(면접 증거): v1은 배열 removeAll 방식 O(n)이었다.
// 측정: recordUse ×6,000 = 0.538초 (-O 빌드)
// v2(현재): 딕셔너리+이중연결리스트 O(1) → 같은 부하 0.0079초. 68배.
// 면접 멘트: "감이 아니라 측정으로 교체했습니다. 행동 테스트가 있어서
// 내부를 통째로 갈아도 안전했고요."
//
// 복잡도 요약:
// recordUse O(1) — 딕셔너리 조회 + 리스트 양끝 조작
// popOldest O(1)
// 공간 O(n) — 노드 + 딕셔너리 엔트리
//
// 왜 class인가: 노드 연결 구조는 본질이 참조. struct 겉옷을 입히면
// 복사본끼리 노드를 공유하는 '가짜 값 타입'이 된다 (조용히 깨짐).
// 왜 비-Sendable인가: 일부러다. actor(캐시) 밖으로 반환하려는 코드는
// 컴파일이 안 된다 — "이 장부는 캐시를 못 벗어난다"를 컴파일러가 강제.
// ── 축출 정책 경계 (LRU ↔ LFU 교체 지점) ──
// 왜 이제야 프로토콜을 만드나: 이 파일의 원칙은 "소비자가 생길 때 추상화한다"였고
// LRURecency는 오랫동안 구현이 하나뿐이라 프로토콜이 없었다. LFURecency가 생긴
// 지금이 바로 그 시점 — 갈아끼울 대상이 실제로 둘이 됐다.
//
// 요구사항은 딱 두 개다. ImageCache가 쓰는 것만 넣는다(인터페이스 분리):
// · recordUse — 삽입·조회·갱신이 전부 여기로 수렴
// · popVictim — "다음에 누구를 버릴 것인가"
// count·remove 같은 건 소비자가 없으니 넣지 않는다.
protocol EvictionPolicy: AnyObject {
func recordUse(_ url: URL)
func popVictim() -> URL?
}
final class LRURecency: EvictionPolicy {
private final class Node {
let url: URL
weak var prev: Node? // ★ 약한 참조 — 이중연결리스트의 상호 강참조(릭) 차단.
// 강참조는 head→tail 한 방향만. (프로덕션에서 25건 잡은
// retain cycle과 같은 계열이라 설계 단계에서 차단)
var next: Node?
init(_ url: URL) { self.url = url }
}
private var nodes: [URL: Node] = [:] // url → 노드 위치, O(1) 탐색
private var head: Node? // 최고령 (축출 후보)
private var tail: Node? // 최근
/// 삽입·조회·갱신 — '사용'은 전부 이 하나로 수렴. 케이스 분기가 없으니 버그 자리도 없다.
/// 복잡도: O(1)
func recordUse(_ url: URL) {
if let node = nodes[url] {
unlink(node) // 옛 자리에서 떼어
appendAsNewest(node) // 맨 뒤(최근)로 = 승격
} else {
let node = Node(url)
nodes[url] = node
appendAsNewest(node)
}
}
/// 최고령을 꺼내며 제거. 비면 nil → 축출 while문이 자연 종료.
/// 복잡도: O(1)
func popOldest() -> URL? {
guard let oldest = head else { return nil }
unlink(oldest)
nodes[oldest.url] = nil
return oldest.url
}
/// 프로토콜 이름으로 이어주는 1줄. popOldest는 LRU 고유 어휘라 그대로 남긴다.
func popVictim() -> URL? { popOldest() }
// 포인터 수술은 이 두 함수로 봉인 — 실수 가능 지대의 최소화
private func unlink(_ node: Node) {
let prev = node.prev
let next = node.next
prev?.next = next
next?.prev = prev
if head === node { head = next } // 양끝 경계 — 이거 빼먹으면 유령 head
if tail === node { tail = prev }
node.prev = nil
node.next = nil
}
private func appendAsNewest(_ node: Node) {
if let last = tail {
last.next = node
node.prev = last
tail = node
} else { // 첫 노드 — 양끝이 자기 자신
head = node
tail = node
}
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ②B LFURecency — "많이 쓴 것"을 지킨다 (LRU와 교체 가능)
// ═══════════════════════════════════════════════════════════════
// LRU vs LFU — 무엇을 "가치 있다"고 보는가:
// LRU = 최근성. "방금 본 건 곧 또 볼 것이다" → 되돌아보는 스크롤에 강함
// LFU = 빈도. "여러 번 본 건 앞으로도 볼 것이다" → 인기 편중이 뚜렷할 때 강함
//
// 실측(같은 입력, 정책만 교체):
// a를 옛날에 5번 쓰고 방치 / b,c를 최근 1번씩 / d 신규 → 하나를 버려야 할 때
// LRU가 버림: a (5번 썼어도 가장 오래 안 썼으니)
// LFU가 버림: b (freq 1 중 가장 오래된 것. a는 freq 5라 안전)
//
// 자료구조 — get/put 모두 O(1):
// nodes[url] → Node(freq 보유)
// buckets[freq] → 같은 빈도끼리의 이중 연결 리스트 (head=오래된, tail=최근)
// minFreq → 현재 최저 빈도. 축출은 buckets[minFreq].head 하나 꺼내면 끝
// ★ 동률 타이브레이크가 LRU다 — 같은 빈도면 그중 가장 오래된 것을 버린다.
// (버킷 안이 이중 연결 리스트인 이유가 이것)
//
// ⚠️ LFU의 고유 함정 — 물어보면 이걸 답한다:
// 캐시 오염(cache pollution). 어제 100번 조회된 이미지가 오늘 아무도 안 봐도
// 횟수가 높아 안 나간다. 반대로 방금 들어온 신선한 항목은 freq 1이라 바로 쫓겨난다.
// → 실무 LFU는 에이징(횟수 주기적 반감)이나 윈도우를 붙인다 (W-TinyLFU 계열).
// → 이미지 썸네일은 "방금 본 걸 되돌아보는" 패턴이라 대개 LRU가 낫다.
// LFU가 이기는 건 인기 편중이 강하고 오래 지속되는 워크로드.
final class LFURecency: EvictionPolicy {
private final class Node {
let url: URL
var freq = 1
weak var prev: Node? // LRU와 같은 이유 — 상호 강참조(릭) 차단
var next: Node?
init(_ url: URL) { self.url = url }
}
/// 같은 빈도끼리 묶은 줄. head = 그 빈도 중 가장 오래된 것 = 먼저 버릴 것
private final class Bucket { var head: Node?; var tail: Node? }
private var nodes: [URL: Node] = [:]
private var buckets: [Int: Bucket] = [:]
private var minFreq = 0
/// 삽입·조회·갱신 전부 여기로 — LRU와 같은 수렴 구조. 복잡도 O(1)
func recordUse(_ url: URL) {
if let node = nodes[url] {
let old = node.freq
unlink(node, from: old)
node.freq = old + 1
append(node, to: node.freq)
// 최저 빈도 줄이 비었으면 한 칸 위가 새 최저 — 스캔 없이 O(1)로 갱신된다
if minFreq == old, buckets[old]?.head == nil { minFreq = old + 1 }
} else {
let node = Node(url)
nodes[url] = node
append(node, to: 1)
minFreq = 1 // 신입은 언제나 최저
}
}
/// 최저 빈도 줄의 가장 오래된 것을 꺼낸다. 복잡도 O(1) (아래 예외 주석 참고)
func popVictim() -> URL? {
guard !nodes.isEmpty else { return nil }
// 연속 축출로 최저 줄이 비면 다음 줄을 찾는다. removeAll처럼 계속 뽑을 때만
// 발생하고, 그때는 어차피 전체를 비우는 중이라 총비용이 O(n)으로 유지된다.
if buckets[minFreq]?.head == nil { advanceMinFreq() }
guard let bucket = buckets[minFreq], let victim = bucket.head else { return nil }
unlink(victim, from: minFreq)
nodes[victim.url] = nil
return victim.url
}
private func advanceMinFreq() {
guard let lowest = buckets.filter({ $0.value.head != nil }).keys.min() else { return }
minFreq = lowest
}
// 포인터 수술은 이 둘로 봉인 — LRU와 같은 원칙
private func append(_ node: Node, to freq: Int) {
let bucket = buckets[freq] ?? { let b = Bucket(); buckets[freq] = b; return b }()
if let last = bucket.tail { last.next = node; node.prev = last } else { bucket.head = node }
bucket.tail = node
}
private func unlink(_ node: Node, from freq: Int) {
guard let bucket = buckets[freq] else { return }
let prev = node.prev, next = node.next
prev?.next = next
next?.prev = prev
if bucket.head === node { bucket.head = next } // 양끝 경계 — 빼먹으면 유령 head
if bucket.tail === node { bucket.tail = prev }
node.prev = nil
node.next = nil
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - 경계 프로토콜 — 의존은 전부 추상으로 (POP·DIP)
// ═══════════════════════════════════════════════════════════════
// 의도: ① 테스트 심(판정기엔 네트워크가 없다 → 주입은 선택이 아니라 강제)
// ② 데코레이터 끼움 자리 (재시도·관측·2단 캐시를 코어 무수정으로)
// ③ NSCache ↔ 자체 캐시 교체 지점 (아래 ③B가 실증)
//
// ── 왜 some이 아니라 any로 받는가 ──
// 쉬운 구분: some = "컴파일 타임에 '하나'를 골라 고정"(불투명 타입, 제네릭 설탕)
// any = "런타임에 무엇이든 담는 상자"(실존 타입, witness table 간접 호출)
//
// 여기서 any인 이유 3가지:
// 1) 목적이 '저장 + 교체'다. some으로 저장하려면 소유 타입 전체가 제네릭이 돼야 한다:
// actor ImageLoader<F: ImageDataFetching, C: ImageCaching>
// 그러면 ImageLoader<Fake, ImageCache>와 ImageLoader<Fake, NSCacheImageCache>는
// 서로 '다른 타입' — 데모 [5]처럼 백엔드만 갈아끼우는 것이 타입 수준에서 막힌다.
// 2) 제네릭 전염 방지. 로더가 제네릭이 되면 셀 컨트롤러·뷰모델·VC까지 타입
// 파라미터를 끌고 다닌다 — 경계의 목적(결합 차단)과 정반대.
// 3) 비용이 문제인 지점이 아니다. any의 간접 호출은 ns 단위 — 옆에서 ms 단위
// 네트워크가 도는 경계에선 소음이다. (핫루프 내부라면 얘기가 다르고,
// 그때가 some/제네릭의 자리다. LRURecency 내부가 딕셔너리 직접 접근인 이유.)
//
// 면접 한 줄: "some은 하나로 고정, any는 교체 가능한 상자 —
// 저장하고 갈아끼우는 의존성이라 any가 도구였습니다."
protocol ImageDataFetching: Sendable {
func fetch(_ url: ImageURL) async throws -> Data
}
protocol ImageCaching: Sendable {
func value(for url: ImageURL) async -> Data?
func insert(_ data: Data, for url: ImageURL) async
}
/// 비우기를 ImageCaching에 넣지 않고 쪼갠 이유 (인터페이스 분리 원칙):
/// · 소비자가 다르다 — 로더는 removeAll을 한 번도 부르지 않는다.
/// 쓰는 쪽은 메모리 경고(⑬)와 로그아웃뿐이다.
/// · ImageCaching에 넣으면 '비울 수 없는 캐시'가 conform할 수 없게 된다
/// (예: 앱 번들에 박아둔 읽기 전용 프리로드 캐시).
/// · 로더의 의존성이 자기가 안 쓰는 능력을 선언하게 되는 것도 거짓말이다.
/// AnyObject를 붙인 이유: 관찰자가 [weak cache]로 잡으려면 클래스 바운드여야 한다.
/// (actor도 참조 타입이라 문제없이 conform한다)
protocol CacheClearing: AnyObject, Sendable {
func removeAll() async
}
protocol ImageLoading: Sendable {
func load(_ url: ImageURL) async throws -> Data // 요청 API는 검증된 타입만 — 옆문 차단
/// 배치 로드. 기본 구현이 아래 extension에 있어 구현체는 만들지 않아도 된다.
/// ⚠️ 여기 '선언'까지 해야 동적 디스패치된다 — extension에만 두면 정적 디스패치라
/// any ImageLoading으로 부를 때 구현체의 오버라이드가 무시된다. (Swift의 고전 함정)
func loadAll(_ urls: [ImageURL], maxConcurrent: Int) async -> [Data?]
}
// ═══════════════════════════════════════════════════════════════// MARK: - ③ ImageCache — actor 캐시 (개수+바이트 이중 상한, LRU)
// ═══════════════════════════════════════════════════════════════
// 왜 actor: storage·totalCost를 여러 Task가 동시 접근 — 락은 잊으면 레이스지만
// actor는 격리 위반이 컴파일 에러다.
// 왜 자체 구현(NSCache 대신): NSCache는 LRU '순서'와 축출 '시점'을 제어할 수 없다.
// 요구사항이 LRU면 직접 짜는 게 맞다. (아니면 ③B로 충분 — 싸울 지점 아님)
//
// 복잡도 요약:
// value O(1) 평균 — 딕셔너리 해시 + recordUse O(1)
// insert O(1) 상각 — evict는 초과분 개수만큼만 (총량 기준 상각 O(1))
// 공간 O(n × 평균 데이터 크기)
actor ImageCache: ImageCaching, CacheClearing {
private var storage: [URL: Data] = [:]
private let policy: any EvictionPolicy // class지만 actor가 격리를 담당
private var totalCost = 0 // 바이트 합을 들고 다님 (매번 reduce하면 O(n))
private let countLimit: Int
private let costLimit: Int
/// costLimit이 왜 필요한가: 개수만 제한하면 대형 이미지 100장에 메모리가 터진다.
///
/// 기본값 50 * 1024 * 1024 (= 50MB)의 근거:
/// · 표기: 52_428_800 같은 매직넘버 대신 "바이트→KB→MB" 단위가 읽히게 곱셈으로.
/// · 자릿수: 셀 크기로 다운샘플링된 썸네일은 장당 대략 100~500KB.
/// 50MB ≈ 그런 이미지 100~500장 = 리스트 몇 화면 분량의 워킹셋.
/// · countLimit(100)과 맞물림: 100장 × ~500KB ≈ 50MB — 두 상한이 같은 가정을 공유.
/// · 시스템 감각: 이미지 캐시가 수백 MB로 커지면 메모리 압박(jetsam) 위험 —
/// '체감 효과는 크고 압박은 낮은' 수십 MB 자릿수를 택했다.
/// · 정직한 한 줄: 측정으로 조정할 '근거 있는 시작값'이지 진리가 아니다.
/// 프로덕션이라면 기기 메모리 비례(예: physicalMemory / 8)를 호출부에서
/// 계산해 '주입' — 기본값은 결정적·단순하게, 정책은 주입으로.
/// policy가 `sending`인 이유: 장부는 스레드 안전하지 않은 class다.
/// `sending`은 "소유권을 넘긴다"는 선언 — 호출부는 넘긴 뒤 더 못 만지고,
/// 이 actor가 유일한 소유자가 된다. Sendable로 거짓 서명하지 않고 안전을 얻는 방법.
init(countLimit: Int = 100, costLimit: Int = 50 * 1024 * 1024,
policy: sending any EvictionPolicy = LRURecency()) {
self.countLimit = countLimit
self.costLimit = costLimit
self.policy = policy
}
/// 조회도 '사용'이다 — 이게 LRU의 정의. (없으면 FIFO가 되어 자주 보는 이미지가 쫓겨남)
/// 공개 API는 ImageURL, 내부 저장은 URL 키 — 벗는 지점은 이 한 줄(url.value).
func value(for url: ImageURL) -> Data? {
guard let data = storage[url.value] else { return nil }
policy.recordUse(url.value)
return data
}
/// insert가 정책 문장으로 읽히게 3단계로 분해: 저장 → 사용 기록 → 넘치면 비움
func insert(_ data: Data, for url: ImageURL) {
store(data, for: url.value)
policy.recordUse(url.value) // 신규·갱신 구분 불필요 — 같은 '사용'
evictWhileOverLimit()
}
/// 메모리 압박 시 전량 비움 — NSCache가 공짜로 해주는 걸 여기선 명시적으로
func removeAll() {
storage.removeAll()
totalCost = 0
while policy.popVictim() != nil {} // 장부도 비운다 (안 비우면 유령 키가 남음)
}
private func store(_ data: Data, for url: URL) { // 내부 헬퍼는 URL 키 (LRURecency와 통일)
totalCost -= storage[url]?.count ?? 0 // ★ 갱신 환불 — 없으면 비용이 부풀어 과잉 축출
storage[url] = data
totalCost += data.count
}
private var isOverLimit: Bool {
storage.count > countLimit || totalCost > costLimit
}
private func evictWhileOverLimit() {
while isOverLimit, let oldest = policy.popVictim() {
totalCost -= storage.removeValue(forKey: oldest)?.count ?? 0
}
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ③B NSCacheImageCache — "NSCache 기반으로 하세요" 대응
// ═══════════════════════════════════════════════════════════════
// ┌─ NSCache는 어떻게 동작하나 ─────────────────────────────────────┐
// │ 한 줄 요약: 딕셔너리처럼 쓰지만, "언제든 사라질 수 있는" 딕셔너리. │
// └──────────────────────────────────────────────────────────────┘
//
// 1) 두 개의 상한 — countLimit(개수) / totalCostLimit(비용 합)
// · setObject(_:forKey:cost:)의 cost를 우리가 정한다. 이미지는 바이트 수가 자연스럽다.
// · ⚠️ cost를 안 넘기면 0으로 취급 → totalCostLimit이 사실상 무력화된다 (흔한 버그)
// · ⚠️ 문서상 이 상한들은 "엄격한 제한이 아니라 힌트"다. 잠깐 넘길 수 있다.
//
// 2) 자동 축출 — NSCache의 진짜 가치
// · 시스템 메모리 압박(memory pressure)이 오면 OS 신호를 받아 스스로 비운다.
// · 우리 자체 구현은 이걸 못 한다 — didReceiveMemoryWarning을 직접 받아
// removeAll을 호출하는 코드를 우리가 써야 한다.
// · "OS와 협력해 강제 종료(jetsam)에 도달하지 않게 한다"가 핵심 이점.
// (jetsam 자체는 감지 불가 — 통보 없이 죽는다. 경고 단계에서 예방하는 것)
//
// 3) 스레드 안전 — 문서가 보장. 락 없이 여러 스레드에서 동시 접근 가능.
// (그래서 아래 클래스는 actor가 아니어도 되고, @unchecked Sendable을 붙일 수 있다)
//
// 4) 키/값 제약 — 둘 다 클래스여야 한다 (Objective-C 유산)
// · URL(struct) → NSURL, Data(struct) → NSData 로 감싼다.
// · ⚠️ 키는 hash/isEqual로 비교된다. NSURL은 이걸 제대로 구현하므로 안전.
// · ⚠️ 감싸는 방식이 성능을 가른다 — 둘 다 컴파일되지만 비용이 다르다:
// insert: data as NSData (브리지, 버퍼 공유) ← 이걸 쓴다
// NSData(data: data) (바이트 복사)
// 실측 300KB×2000회 → 0.1ms vs 40.7ms = 441배. 왕복 값 일치는 확인됨.
// 조회: Data(referencing:) (지연 참조) ← 반대 방향도 복사 피함
//
// ┌─ 트레이드오프 ────────────────────────────────────────────────┐
// │ NSCache가 유리 │ 자체 구현이 유리 │
// ├──────────────────────────────────┼──────────────────────────┤
// │ 메모리 압박 자동 대응 (jetsam 방지) │ 축출 '순서'를 LRU로 보장 │
// │ 스레드 안전 공짜 │ 축출 '시점'을 내가 통제 │
// │ 코드 5줄 │ 어떤 키가 왜 빠졌는지 관찰 가능 │
// │ 검증된 시스템 구현 │ 테스트로 정책 검증 가능 │
// │ │ 히트율·통계 계측 삽입 가능 │
// └──────────────────────────────────┴──────────────────────────┘
//
// NSCache의 결정적 한계: 축출 정책이 문서화되어 있지 않다.
// "LRU처럼 동작하는 경향"은 있지만 보장이 아니다 → 요구사항이 "LRU"라면
// NSCache로는 그 요구를 '증명'할 수 없다. 테스트도 못 쓴다(언제 빠질지 모르니).
// 그래서 면접 문제가 "LRU 캐시를 구현하라"면 NSCache는 답이 될 수 없다.
//
// ┌─ 실측 (macOS 26.1 / Swift 6, 이 저장소에서 직접 측정) ─────────────┐
// │ countLimit 100에 200개 삽입 → 정확히 100개 생존 (초과분 즉시 축출) │
// │ costLimit 1000B에 300B×10 삽입 → 3개 생존 (3×300=900 ≤ 1000) │
// │ 키 비교 → isEqual 기반. 값이 같은 별개 │
// │ NSURL 인스턴스로도 히트 │
// │ 축출 순서 (한도 10, 0~9 채운 뒤 │
// │ 0번을 50회 조회 → 10~14 삽입) → 생존 [0,6,7,8,9,10,11,12,13,14] │
// │ 0번이 살아남음 = LRU처럼 동작 │
// └──────────────────────────────────────────────────────────────────┘
// ⚠️ 이 숫자들을 '보장'으로 말하면 안 된다. 문서는 상한을 "힌트"라고 하고
// 축출 정책은 아예 명시하지 않는다. 즉 측정된 건 "이 OS·이 버전의 구현"이지
// API 계약이 아니다. iOS·다음 버전에서 달라져도 Apple은 약속을 어긴 게 아니다.
// 면접에서 쓸 형태: "측정해보면 LRU처럼 동작하지만, 문서화된 보장이 아니라
// 요구사항이 LRU면 의존할 수 없습니다."
//
// 실무 절충: 2단 구성이 흔하다.
// 메모리 = NSCache(압박 대응 공짜) + 디스크 = 자체 구현(정책 통제).
// 또는 자체 LRU를 쓰되 memoryWarning 알림을 구독해 removeAll을 호출.
//
// 면접 멘트:
// "요구가 '캐시'면 NSCache가 낫습니다 — 메모리 압박 자동 대응을 공짜로 얻으니까요.
// 요구가 'LRU'면 직접 짭니다. NSCache는 축출 순서를 보장하지 않아서
// 그 요구를 증명할 수도, 테스트할 수도 없습니다."
//
// 구현 메모: @unchecked Sendable 근거 — 우리 쪽 가변 상태가 없고, NSCache 자체가 스레드 안전.
final class NSCacheImageCache: ImageCaching, CacheClearing, @unchecked Sendable {
private let cache = NSCache<NSURL, NSData>()
/// 기본값 50MB의 근거는 ③ ImageCache의 init 주석과 동일 (단위 가독 표기 + 워킹셋 자릿수)
init(countLimit: Int = 100, totalCostLimit: Int = 50 * 1024 * 1024) {
cache.countLimit = countLimit
cache.totalCostLimit = totalCostLimit
}
// ImageURL을 받되, NSCache 어휘(NSURL)로 벗는 지점이 바로 이 안 — 상호운용은 그대로.
func value(for url: ImageURL) -> Data? {
cache.object(forKey: url.value as NSURL).map { Data(referencing: $0) }
}
func insert(_ data: Data, for url: ImageURL) {
// cost를 넘겨야 totalCostLimit이 의미를 가진다 (안 넘기면 cost 0으로 취급)
// `data as NSData`(브리지) vs `NSData(data:)`(복사) — 실측 300KB×2000회:
// NSData(data:) 40.7ms / as NSData 0.1ms → 441배. 스크롤 중 삽입 경로라 중요하다.
cache.setObject(data as NSData, forKey: url.value as NSURL, cost: data.count)
}
/// ③ ImageCache와 시그니처를 맞춰 둔다 — 둘을 바꿔 끼울 때 호출부가 안 바뀐다.
/// 메모리 경고용으로는 거의 불필요하다(NSCache가 알아서 비운다).
/// 실제 용도는 사용자 행동 — "캐시 지우기" 버튼, 로그아웃 시 계정 데이터 격리.
func removeAll() { cache.removeAllObjects() }
}
// ── 자체 캐시의 유일한 약점 보완: 메모리 압박 대응 ──
// NSCache가 공짜로 주는 걸 자체 구현에선 이렇게 직접 받는다.
// 면접 멘트: "NSCache 대비 잃는 건 자동 축출 하나인데, 알림 구독 5줄로 메웁니다."
#if canImport(UIKit)
import UIKit
// ── ⚠️ 용어 정확히: '메모리 경고 대응'이지 'OOM 감지'가 아니다 ──
// 메모리 경고 : 압박이 생기는 중. 앱이 알림을 받고 비울 기회가 있다 ← 여기 대응
// OOM(jetsam): 이미 한계 초과. OS가 통보 없이 프로세스를 즉시 죽인다
// → deinit도, applicationWillTerminate도 안 불린다.
// → 크래시 리포트에도 안 남는다 (크래시가 아니라 강제 종료)
// 즉 이 타입은 OOM을 '감지'하는 게 아니라 '예방'한다 — 경고 단계에서 비워
// 한계에 도달하지 않게 만드는 것.
//
// OOM은 사후 추정만 가능: 다음 실행 때 "크래시 아님 + 정상종료 아님 + 포그라운드였음"
// 이면 OOM 의심 (Crashlytics 방식). 실제 집계는 MetricKit의 MXAppExitMetric —
// cumulativeMemoryResourceLimitExitCount 가 OOM 종료 횟수를 준다.
//
/// 메모리 경고 구독을 '소유'하는 타입.
/// 왜 토큰을 들고 있나: addObserver(forName:)는 토큰을 반환하는데 버리면
/// ① 해제할 방법이 없고 ② 두 번 호출하면 중복 등록되어 removeAll이 두 번 돈다.
/// deinit에서 제거해야 NotificationCenter에 죽은 등록이 안 남는다.
final class MemoryWarningObserver {
private var token: NSObjectProtocol?
/// 구체 타입이 아니라 <CacheClearing>을 받는다 —
/// ImageCache로 받아두면 캐시 백엔드를 NSCache로 바꾸는 순간 여기서 컴파일이 깨진다.
/// (실제로 깨졌던 자리다: "cannot convert 'NSCacheImageCache' to 'ImageCache'")
init(cache: any CacheClearing) {
token = NotificationCenter.default.addObserver(
forName: UIApplication.didReceiveMemoryWarningNotification,
object: nil, queue: nil
) { [weak cache] _ in
Task { await cache?.removeAll() } // actor 격리를 지키며 진입
}
}
deinit {
if let token { NotificationCenter.default.removeObserver(token) }
}
}
#endif
// ═══════════════════════════════════════════════════════════════// MARK: - ④ ImageLoader — 싼 것부터: 캐시 → 진행 중 합류 → 네트워크
// ═══════════════════════════════════════════════════════════════
// 복잡도: load는 캐시 히트 O(1) / inFlight 조회 O(1) / 미스 시 네트워크 1회
//
// inFlight = "지금 하늘에 떠 있는 다운로드" 장부.
// Task를 값으로 들고 있으면 여러 호출자가 같은 결과를 await할 수 있다.
actor ImageLoader: ImageLoading {
private let fetcher: any ImageDataFetching
private let cache: any ImageCaching // 추상에 의존 → ③이든 ③B든 무수정 교체
private var inFlight: [ImageURL: Task<Data, Error>] = [:]
/// ⚠️ cache 기본값의 의미: '생략하면 이 로더 전용 새 캐시가 생긴다'
/// · 테스트/데모에는 이게 맞다 — 케이스마다 격리된 캐시라 서로 오염되지 않는다
/// · 프로덕션에서 생략하면 캐시가 로더 수만큼 쪼개진다 (같은 이미지를 여러 번 받음)
/// → 앱 경로는 반드시 ImagePipeline.shared를 쓴다. 직접 만들지 않는다.
/// 트레이드오프: 기본값은 편의를 주지만 '조용한 분리'를 숨긴다.
/// 더 엄격하게 가려면 기본값을 없애 캐시를 항상 명시하게 강제할 수 있다.
init(fetcher: any ImageDataFetching, cache: any ImageCaching = ImageCache()) {
self.fetcher = fetcher
self.cache = cache
}
func load(_ url: ImageURL) async throws -> Data {
if let cached = await cache.value(for: url) { return cached }
if let running = inFlight[url] { return try await running.value } // ★ 중복 합류
return try await startFetch(url)
}
/// 왜 비구조적 Task인가: 호출자(셀)가 취소돼도 다운로드는 완주시킨다.
/// ① 같은 URL을 기다리는 다른 셀이 결과를 받아야 하고
/// ② 완주하면 캐시에 남아 스크롤 복귀 시 공짜.
/// (모든 대기자가 떠나면 진짜 취소하는 건 refcount 방식 — Kingfisher가 그렇게 함)
///
/// 로더는 ImageURL을 그대로 아래로 흘린다 — URL로 벗는 건 각 구현체 몫
/// (URLSessionFetcher / NSCacheImageCache 내부). 로더엔 .value가 없다.
private func startFetch(_ url: ImageURL) async throws -> Data {
let task = Task<Data, Error> { [fetcher, cache] in // actor self 캡처 회피 (Swift 6)
let data = try await fetcher.fetch(url)
await cache.insert(data, for: url) // 완료 '시점'에 기록 → 전원 히트 가능
return data
}
inFlight[url] = task
defer { inFlight[url] = nil } // ★ 성공이든 throw든 장부 청소 → 실패 후 재시도 가능
return try await task.value
}
}
// ── Task Scheduling: 동시 최대 maxConcurrent개 배치 ──
// 왜 구현체가 아니라 '프로토콜 확장'인가:
// ① 호출부가 any ImageLoading으로 들고 있으므로, 구현체에만 있으면 못 부른다.
// ② 기본 구현이 load()만 쓰므로 모든 구현체가 공짜로 얻는다 (스텁 로더도 배치 가능).
// ③ 새 구현체는 load 하나만 만들면 된다 — 필수 요구사항은 최소로.
// ④ 더 나은 배치 전략이 있는 구현체는 자기 버전으로 오버라이드할 수 있다.
// 복잡도: 작업 O(n) / wall-clock ≈ ⌈n/k⌉ × 평균응답 / 공간 O(n)+동시 O(k)
// 왜 세마포어가 아니라 슬라이딩 윈도우인가: "하나 착륙해야 하나 이륙"이라
// 동시 수 초과가 '구조적으로' 불가능 — 카운터 해제 실수 자체가 없음.
extension ImageLoading {
func loadAll(_ urls: [ImageURL], maxConcurrent: Int = 3) async -> [Data?] {
var results = [Data?](repeating: nil, count: urls.count) // 완료순≠입력순 → 인덱스로 제자리
await withTaskGroup(of: (Int, Data?).self) { group in
var iterator = urls.enumerated().makeIterator()
for _ in 0..<min(maxConcurrent, urls.count) { // 처음 k개만 이륙
guard let (i, url) = iterator.next() else { break }
group.addTask { (i, try? await self.load(url)) } // try?: 한 장 실패가 배치를 못 죽임
}
while let (i, data) = await group.next() { // 하나 착륙 →
results[i] = data
if let (j, url) = iterator.next() { // → 하나 이륙
group.addTask { (j, try? await self.load(url)) }
}
}
}
return results
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑤ ImageCellController — 셀 재사용 방어 2겹
// ═══════════════════════════════════════════════════════════════
// ┌─ 타입 선택 3문답 (실제 면접 질문 순서) ────────────────────────┐
// │ Q1 왜 actor가 아닌가 │
// │ · 상태(currentURL·task)를 만지는 건 메인 스레드뿐 — 보호할 경합이 없다 │
// │ · 결정적 이유: 셀 제공 지점이 '동기'다 — cellForRowAt이든 │
// │ Diffable의 셀 제공 클로저든 await를 쓸 수 없다 │
// │ actor면 Task로 감싸야 하고 → 셀 구성이 한 프레임 늦어 깜빡임 + │
// │ 취소 타이밍이 어긋나 재사용 방어가 무력화된다 │
// │ Q2 왜 @MainActor인가 │
// │ · render가 UIKit을 만진다 → 메인 강제 │
// │ · await에서 돌아올 때 컴파일러가 자동으로 메인 복귀시킨다 │
// │ ("DispatchQueue.main.async를 깜빡할 수가 없다") │
// │ Q3 render만 @MainActor면 안 되나 │
// │ · UI는 지켜지지만 정작 방어의 핵심인 currentURL이 무방비 │
// │ · guard 확인 → await render 사이에 '틈'이 생겨 그 사이 재사용되면 │
// │ 검문을 통과한 뒤 주인이 바뀐다. 클래스 전체 격리면 그 틈이 없다 │
// └────────────────────────────────────────────────────────────┘
//
// ── 방어 3겹이 각각 막는 것 (하나라도 빠지면 뚫리는 시나리오) ──
// ① task?.cancel() 진행 중 다운로드 중단 (대역폭 낭비 방지)
// ② 시작 전 isCancelled 이미 취소면 '요청 자체를 안 보냄'
// ③ currentURL == url ★ 취소가 못 막는 '이미 도착한 응답' 폐기
//
// ③이 왜 필수인가 — 취소는 '협조적'이라 await에서 멈춰 있을 때만 관측된다:
// · 캐시 히트로 즉시 반환 → 멈춘 적이 없어 취소를 못 봄
// · 마지막 await 이후 취소 도착 → 이미 통과
// · try?가 CancellationError를 삼킴 → 흐름이 계속 진행
// ②③은 타이밍 의존, ③의 신원 비교만 '결정적'이다.
//
// 실패 시나리오 (③ 없을 때):
// 0.0s 김밥천국 로드 시작 (2초 소요)
// 0.5s 스크롤 → 이 셀이 버거킹 담당으로 재사용, 버거킹 로드 시작
// 0.8s 버거킹 도착 → 표시 ✅
// 2.0s 김밥천국 도착 → 버거킹 위에 덮어씀 ❌ ← ③이 막는 지점
//
// ── render를 클로저로 받는 이유 ──
// · UIKit 없이 컴파일·테스트 가능 (판정기가 Linux일 수 있다)
// · 실전: { imageView.image = UIImage(data:) } / 테스트: { frames.append($0) }
// · 프로토콜을 안 만든 이유 = 소비자가 이 클로저 하나뿐 (⑤B가 유일한 사용처)
//
// ── 트레이드오프 ──
// · Data? 하나로 placeholder/성공/실패를 뭉갠다 → 로딩 스피너·재시도 버튼 불가
// 해소: ⑥의 CellImageState enum으로 교체 (요구되면)
// · deinit에서 취소하지 않는다 → @MainActor의 deinit은 nonisolated라
// 격리 프로퍼티(task)에 접근 불가. prepareForReuse를 계약으로 삼음
//
// 면접 멘트: "취소는 부탁이고, 신원 확인은 검문입니다 —
// 취소는 협조적이라 '이미 도착한 응답'은 못 막습니다."
@MainActor
final class ImageCellController {
private let loader: any ImageLoading
private let render: (Data?) -> Void // UIKit이면 { imageView.image = ... } 주입 (⑤B 참고)
private var currentURL: ImageURL? // "지금 이 셀의 주인" 신원표
private var task: Task<Void, Never>?
init(loader: any ImageLoading, render: @escaping (Data?) -> Void) {
self.loader = loader
self.render = render
}
func configure(with url: ImageURL) {
task?.cancel() // 방어① 이전 로드 취소
currentURL = url
render(nil) // 이전 이미지 잔상 즉시 제거 (재사용 깜빡임 방지)
task = Task { [weak self] in
// 방어② 시작 전 취소 확인 — 이미 취소면 네트워크 요청 자체를 안 보냄
guard let self, !Task.isCancelled else { return }
let data = try? await self.loader.load(url) // ImageURL 그대로 — 벗기는 건 로더의 일
// 방어③ 신원 확인 — 늦게 도착한 응답이 새 주인의 화면을 덮지 못하게
guard !Task.isCancelled, self.currentURL == url else { return }
self.render(data)
}
}
func prepareForReuse() {
task?.cancel()
task = nil
currentURL = nil
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑤B UIKit 매핑 — "UITableView로 직접 하세요" 대응
// ═══════════════════════════════════════════════════════════════
// 같은 부품을 UIKit 생명주기에 꽂는 법. macOS/Linux 컴파일 시엔 자동 제외.
// ★ 최대 함정: 로더를 셀마다 만들면 캐시가 셀 수만큼 쪼개진다 → VC가 1개 소유.
#if canImport(UIKit)
import UIKit
final class ImageCell: UITableViewCell {
static let reuseID = "ImageCell"
private var controller: ImageCellController?
/// 셀 최초 구성 시 1회만 — 컨트롤러가 render 클로저로 imageView에 연결됨
func bind(loader: any ImageLoading) {
guard controller == nil else { return }
controller = ImageCellController(loader: loader) { [weak self] data in
self?.imageView?.image = data.flatMap(UIImage.init(data:))
self?.setNeedsLayout()
}
}
func show(_ url: ImageURL) {
controller?.configure(with: url)
}
override func prepareForReuse() {
super.prepareForReuse()
controller?.prepareForReuse() // 진행 중 로드 취소 + 신원표 제거
imageView?.image = nil
}
}
// ── 로더는 '앱 레벨' 단일 인스턴스 ──
// 왜 VC 소유가 아닌가: VC마다 만들면 화면 전환 때마다 캐시가 새로 생겨
// 같은 이미지를 다시 받는다. 캐시·in-flight 장부는 앱 전체에서 하나여야 의미가 있다.
// 왜 URLSession 싱글톤으로 부족한가: URLSession이 공유하는 건 커넥션 풀과
// HTTP 캐시(서버 헤더 의존, 디스크 왕복·디코딩 매번)다. 우리가 필요한
// '디코딩된 메모리 캐시'와 '동시 중복 요청 합류'는 URLSession이 해주지 않는다.
// (같은 URL을 동시에 10번 요청하면 URLSession은 10번 다 보낸다)
// 왜 직접 참조하지 않고 기본 인자로 주입하는가: 테스트에서 가짜를 꽂기 위해.
enum ImagePipeline {
/// 캐시를 따로 노출하는 이유: 메모리 경고 구독이 캐시에 직접 붙어야 하고(⑬),
/// 로그아웃 같은 시점에 removeAll()로 비울 수 있어야 한다.
///
/// ★ 기본은 ③B NSCache — '캐시 선택 규칙' 참조. LRU 요구가 오면 이 한 줄만 바꾼다:
/// static let sharedCache = ImageCache() // 자체 LRU (③+②)
/// 로더도 관찰자도 안 바뀐다 — 로더는 <ImageCaching>에, 관찰자는
/// <CacheClearing>에 의존하지 구체 타입에 의존하지 않기 때문. (교체 타입체크 검증됨)
static let sharedCache = NSCacheImageCache()
/// ★ 앱 코드는 반드시 이걸 쓴다. ImageLoader를 직접 만들지 말 것 —
/// cache 인자를 빼먹으면 기본값으로 '새 캐시'가 조용히 생겨 캐시가 쪼개진다.
/// (테스트/데모는 반대로 격리가 필요하므로 직접 만드는 게 맞다)
static let shared: any ImageLoading =
ImageLoader(fetcher: URLSessionFetcher(), cache: sharedCache)
}
final class FeedTableViewController: UITableViewController {
private let loader: any ImageLoading
private let urls: [ImageURL]
init(urls: [ImageURL], loader: any ImageLoading = ImagePipeline.shared) {
self.urls = urls
self.loader = loader // 기본값은 공유, 테스트는 주입
super.init(style: .plain)
}
required init?(coder: NSCoder) { fatalError("스토리보드 미사용") }
override func viewDidLoad() {
super.viewDidLoad()
title = "맛집"
tableView.register(ImageCell.self, forCellReuseIdentifier: ImageCell.reuseID)
tableView.rowHeight = 88
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
urls.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: ImageCell.reuseID,
for: indexPath) as? ImageCell else {
preconditionFailure("셀 등록 불일치: \(ImageCell.reuseID)") // 구성 오류는 메시지로 즉사
}
cell.bind(loader: loader) // 최초 1회만 유효 (guard)
cell.show(urls[indexPath.row]) // 재사용 방어는 컨트롤러가 담당
return cell
}
}
// ── CollectionView 버전 — 그리드로 나올 때 (썸네일 격자가 더 흔하다) ──
// TableView와 다른 점은 셋뿐: 레이아웃 지정 / 커스텀 뷰 계층 / dequeue API.
// 재사용 방어·로더 소유 규칙은 완전히 동일하다.
final class ThumbnailCell: UICollectionViewCell {
static let reuseID = "ThumbnailCell"
// 기본 imageView에 의존하지 않고 직접 만든다 — 실서비스 수준의 레이아웃
private let thumbnail: UIImageView = {
let v = UIImageView()
v.contentMode = .scaleAspectFill
v.clipsToBounds = true // 넘치는 부분 잘라냄
v.backgroundColor = .secondarySystemFill // placeholder 배경 = 회색 박스
v.layer.cornerRadius = 8
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
private var controller: ImageCellController?
override init(frame: CGRect) {
super.init(frame: frame)
contentView.addSubview(thumbnail)
NSLayoutConstraint.activate([
thumbnail.topAnchor.constraint(equalTo: contentView.topAnchor),
thumbnail.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
thumbnail.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
thumbnail.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}
required init?(coder: NSCoder) { fatalError("코드 기반 셀") }
func bind(loader: any ImageLoading) {
guard controller == nil else { return } // 최초 1회만 — 셀은 재사용되므로
controller = ImageCellController(loader: loader) { [weak self] data in
self?.thumbnail.image = data.flatMap(UIImage.init(data:))
}
}
func show(_ url: ImageURL) { controller?.configure(with: url) }
override func prepareForReuse() {
super.prepareForReuse()
controller?.prepareForReuse() // 진행 중 로드 취소 + 신원표 제거
thumbnail.image = nil // 잔상 즉시 제거
}
}
final class ThumbnailGridViewController: UIViewController {
// Diffable DataSource: 인덱스가 아니라 '아이템 식별자'로 다룬다.
//
// ── 구식과 무엇이 다른가 ──
// 구식: numberOfItems + cellForItemAt 을 '직접 오버라이드'
// → cell.show(urls[indexPath.item]) ← 배열 인덱스 접근 = OOB 위험
// Diffable: 둘 다 구현하지 않는다. 셀 제공 클로저가 대신하고,
// 그 클로저는 indexPath와 '아이템(url)'을 함께 받는다
// → cell.show(url) ← 배열 접근 자체가 없음
//
// 이득 ① index out of range 크래시가 '구조적으로' 불가능
// (효성님이 프로덕션에서 잡은 OOB 27건과 같은 계열을 설계로 차단)
// ② 갱신 시 diff를 UIKit이 계산 — reloadData 없이 애니메이션까지
// ③ 스냅샷이 곧 진실. "지금 화면에 뭐가 있나"가 한 값으로 표현됨
// ④ 데이터가 바뀌면 apply(urls) 한 번만 다시 부르면 끝
//
// ── 대가 ──
// · 아이템 타입이 Hashable이어야 한다 (ImageURL이 그래서 Hashable)
// · 식별자가 '값'이라 같은 URL이 두 번 들어가면 중복으로 인식 못 함
// → 실무에선 보통 고유 ID를 식별자로 쓴다 (여기선 URL이 유일하다는 가정)
private enum Section { case main } // 섹션이 하나여도 타입으로 명시
private typealias DataSource = UICollectionViewDiffableDataSource<Section, ImageURL>
private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, ImageURL>
private let loader: any ImageLoading // 앱 레벨 공유 (ImagePipeline.shared)
private let urls: [ImageURL]
private var collectionView: UICollectionView!
private var dataSource: DataSource!
init(urls: [ImageURL], loader: any ImageLoading = ImagePipeline.shared) {
self.urls = urls
self.loader = loader
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("스토리보드 미사용") }
override func viewDidLoad() {
super.viewDidLoad()
title = "썸네일"
view.backgroundColor = .systemBackground
setUpCollectionView()
setUpDataSource()
apply(urls)
}
// 3열 그리드 — Compositional Layout이면 화면 폭 계산이 필요 없다(비율로 지정)
private func setUpCollectionView() {
let item = NSCollectionLayoutItem(layoutSize: .init(
widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0)))
item.contentInsets = .init(top: 4, leading: 4, bottom: 4, trailing: 4)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: .init(widthDimension: .fractionalWidth(1.0),
heightDimension: .fractionalWidth(1.0 / 3.0)), // 정사각 3열
repeatingSubitem: item, count: 3)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = .init(top: 8, leading: 8, bottom: 8, trailing: 8)
collectionView = UICollectionView(frame: .zero,
collectionViewLayout: UICollectionViewCompositionalLayout(section: section))
collectionView.backgroundColor = .systemBackground
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func setUpDataSource() {
// CellRegistration: register/dequeue 문자열 ID가 사라져 오타 가능성이 없어진다
let registration = UICollectionView.CellRegistration<ThumbnailCell, ImageURL> {
[loader] cell, _, url in
cell.bind(loader: loader) // 최초 1회만 유효 (셀 내부 guard)
cell.show(url) // 재사용 방어는 ImageCellController가 담당
}
dataSource = DataSource(collectionView: collectionView) { cv, indexPath, url in
cv.dequeueConfiguredReusableCell(using: registration, for: indexPath, item: url)
}
}
/// 스냅샷 적용 — 데이터가 바뀌면 이 함수만 다시 부르면 된다
private func apply(_ urls: [ImageURL], animated: Bool = false) {
var snapshot = Snapshot()
snapshot.appendSections([.main])
snapshot.appendItems(urls, toSection: .main)
dataSource.apply(snapshot, animatingDifferences: animated)
}
}
// ── 앱 진입점 — 실제로 화면에 뜨는 완성본 ──
// 여기까지 있어야 "돌아가는 앱"이다. 데이터 주입 → VC 생성 → 윈도우에 올림.
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
/// ★ 읽지 않는 프로퍼티지만 지우면 안 된다 — 이 참조가 곧 '수명 앵커'다.
/// MemoryWarningObserver는 deinit에서 구독을 해제하므로, 아무도 안 붙잡으면
/// 생성 직후 해제되어 메모리 경고 대응이 조용히 죽는다.
/// (Combine의 .store(in:&cancellables), RxSwift의 DisposeBag과 같은 역할)
/// ⚠️ 컴파일러는 "안 쓰는 프로퍼티"라고 경고하지 않는다 → 정리하다 지우기 쉬운 자리
private var memoryObserver: MemoryWarningObserver?
func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
memoryObserver = MemoryWarningObserver(cache: ImagePipeline.sharedCache)
let urls = (1...30).compactMap { ImageURL("https://picsum.photos/id/\($0)/300/300") }
let grid = ThumbnailGridViewController(urls: urls) // 또는 FeedTableViewController(urls:)
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UINavigationController(rootViewController: grid)
window.makeKeyAndVisible()
self.window = window
}
}
#endif
// ═══════════════════════════════════════════════════════════════// MARK: - ⑤C 지연 디코딩 대응 — "스크롤이 끊겨요" (깜빡임 원인 ⑥)
// ═══════════════════════════════════════════════════════════════
// 문제: UIImage(data:)는 디코딩을 '안 한다'. 껍데기만 만들고 실제 픽셀 변환은
// 화면에 그려지는 순간 = 메인 스레드에서 일어난다.
// 한 프레임이 16.7ms(60fps)인데 큰 이미지 디코딩이 그걸 넘으면 바로 끊긴다.
//
// 두 단계로 답한다:
// [A] 다운샘플링 — 표시 크기로 줄이면서 디코딩까지 끝낸다. 3000px 원본을
// 300pt 셀에 넣으면 픽셀의 90% 이상이 버려진다 → 디코딩·메모리 동시 절감.
// 캐시에 저장되는 바이트도 원본이 아니라 '표시 크기'가 되어 상한 계산이 정확해진다.
// [B] iOS 15+ 표준 API — 다운샘플링이 과할 때의 간단판.
//
// ★ 붙이는 자리: render 클로저 하나만 바꾸면 된다 — ⑤ 컨트롤러는 0줄 수정.
// (경계를 클로저로 둔 값어치가 여기서 나온다)
//
// 사용법 (⑤B의 ImageCell.bind에서 render 자리만 교체):
// controller = ImageCellController(
// loader: loader,
// render: decodingRender(into: iv, targetSize: CGSize(width: 60, height: 60)))
//
// 트레이드오프 / 남은 한계:
// · 다운샘플링은 CPU를 쓴다 — 아주 작은 썸네일엔 오히려 손해
// · ⚠️ 표시 크기를 '명시'해야 한다. bounds에만 의존하면 레이아웃 전 호출에서
// 0이 되고, 그러면 다운샘플링이 조용히 무력화된다 (아래 주석의 실측 참조)
// · 같은 원본을 여러 크기로 쓰면 크기별 캐시 키가 필요해진다
// · ⚠️ 디코딩에는 동시 제한이 없다 — 화면에 10셀이면 디코딩 10개가 동시에 뜬다.
// 네트워크는 loadAll의 슬라이딩 윈도우로 막았지만 디코딩 층은 안 막혀 있다.
// 요구가 오면 같은 패턴(창 N개)을 디코딩에도 적용한다.
#if canImport(UIKit)
import ImageIO
enum ImageDecoder {
/// 표시 크기로 줄이면서 디코딩까지 끝낸다.
static func downsample(_ data: Data, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let maxPixel = max(pointSize.width, pointSize.height) * scale
let srcOpts = [kCGImageSourceShouldCache: false] as CFDictionary
guard let src = CGImageSourceCreateWithData(data as CFData, srcOpts) else { return nil }
let opts = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true, // EXIF 회전 반영
kCGImageSourceShouldCacheImmediately: true, // ★ 여기서 디코딩 강제 = 메인이 안 함
kCGImageSourceThumbnailMaxPixelSize: maxPixel
] as [CFString: Any] as CFDictionary
guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opts) else { return nil }
return UIImage(cgImage: cg, scale: scale, orientation: .up)
}
/// [B] 간단판 — 크기를 안 줄이고 디코딩만 미리 끝낸다 (iOS 15+)
@available(iOS 15.0, *)
static func prepared(_ data: Data) async -> UIImage? {
guard let img = UIImage(data: data) else { return nil }
return await img.byPreparingForDisplay()
}
}
/// ⑤ 컨트롤러의 render 자리에 이걸 주입하면 끝 — 컨트롤러는 0줄 수정.
///
/// ⚠️ 디코딩도 비동기라 '방어 2겹'이 여기서도 필요하다.
/// 안 하면 파이프라인 층에서 막은 버그를 디코딩 층에서 다시 연다:
/// A 디코딩 중 → 셀이 B로 재사용 → A 디코딩 완료 → B 자리에 A가 뜬다.
/// 그래서 이전 디코딩을 취소하고(①), 늦게 끝난 건 폐기한다(②).
/// ⚠️ targetSize를 '명시'로 받는 이유 — 실측으로 잡은 함정:
/// cellForRowAt은 레이아웃 '전에' 불린다 → 그 시점 bounds.size가 .zero일 수 있다.
/// maxPixelSize가 0이면 CGImageSource는 에러도 nil도 아니고 **원본 크기를 그대로**
/// 돌려준다(64×64 원본 → 64×64 확인). 즉 다운샘플링이 조용히 무력화된다 —
/// 끊김을 잡으려고 넣은 코드가 아무 일도 안 하는 최악의 실패 방식.
/// 그래서 크기를 호출부가 정하고, bounds는 '보조'로만 쓴다.
@MainActor
func decodingRender(into imageView: UIImageView,
targetSize: CGSize) -> @MainActor (Data?) -> Void {
var decodeTask: Task<Void, Never>? // 클로저가 소유하는 상태
return { data in
decodeTask?.cancel() // ① 이전 디코딩 취소 (⑤의 task.cancel과 같은 근육)
guard let data else { imageView.image = nil; return }
// 레이아웃이 끝났으면 실제 크기가 더 정확하고, 아니면 명시값으로 간다
let laid = imageView.bounds.size
let size = (laid.width > 0 && laid.height > 0) ? laid : targetSize
let scale = imageView.traitCollection.displayScale
decodeTask = Task { // MainActor 상속 — detached면 취소를 못 건다
let img = await Task.detached(priority: .userInitiated) {
ImageDecoder.downsample(data, to: size, scale: scale) // CPU 작업만 메인 밖으로
}.value
guard !Task.isCancelled else { return } // ② 늦게 끝난 디코딩 폐기
imageView.image = img
}
}
}
#endif
// ═══════════════════════════════════════════════════════════════// MARK: - ⑥ MVVM — 상태를 타입으로, 바인딩은 AsyncStream으로
// ═══════════════════════════════════════════════════════════════
// Rx 대응표: Observable→AsyncStream / onNext→yield / bind→for await / DisposeBag→Task 취소
// 면접 멘트: "개념은 Rx와 같고, 런타임 보증(actor 격리·구조적 취소)이 더 강해진 것뿐입니다."
//
// ── ⑤ 셀 컨트롤러와 무엇이 다른가 (둘은 '대안'이지 계층이 아니다) ──
// ⑤ 컨트롤러: 로드 완료 → render(data) '직접 호출' — 값 하나만 전달
// ⑥ VM : 로드 완료 → states.yield(.loaded) — 상태를 '방송'
// 같은 층(셀 단위)이다. VM에 prepareForReuse가 있는 게 그 증거.
// → 요구사항이 "이미지만 표시"면 ⑤, "로딩/실패를 화면에서 구분"이면 ⑥.
//
// ── 왜 enum인가: Data?로는 표현할 수 없는 것 ──
// render(nil) ← 이게 '로딩 중'인가 '실패'인가? 구분 불가
// 상태를 타입으로 만들면 화면이 분기할 수 있다:
// .loading → 스피너 / .failed → 재시도 버튼 / .loaded → 이미지
// 그리고 '동시에 두 상태일 수 없음'을 타입이 보장한다
// (isLoading + isFailed 두 Bool이면 둘 다 true인 모순 상태가 표현 가능해진다)
//
// ── catch is CancellationError를 '따로' 잡는 이유 ★ ──
// 취소는 실패가 아니다. 스크롤로 지나간 셀을 .failed로 표시하면
// 화면에 재시도 버튼이 우수수 뜬다. 사용자가 떠난 것과 서버가 죽은 것은 다르다.
//
// ── bufferingNewest(1)를 고른 이유 ──
// UI는 '최신 상태'만 의미가 있다. 구독자가 잠깐 느려서 loading→loaded가 쌓여도
// 과거 상태를 순서대로 재생할 이유가 없다. 무제한 버퍼면 메모리만 먹는다.
//
// ── state 프로퍼티를 따로 두는 이유 ──
// AsyncStream은 '구독 이후'의 값만 준다. 늦게 붙은 뷰(셀 재사용 후 재구독)는
// 현재 상태를 모른다 → 현재값을 별도로 들고 즉시 반영할 수 있게 한다.
// (Rx의 BehaviorSubject가 하던 역할을 수동으로)
//
// ── emit()으로 묶은 이유 ──
// state 갱신과 yield가 항상 함께 일어나야 한다. 따로 쓰면 한쪽만 하는 버그가 난다.
// 변경 지점을 하나로 봉인 = 실수 가능 지대의 최소화.
//
// ── deinit의 continuation.finish() ──
// 이게 없으면 구독 루프(for await)가 영원히 기다린다 = Task 누수.
// 스트림을 끝내야 루프가 깨어나 종료된다.
//
// ── 트레이드오프 ──
// · AsyncStream은 단일 구독 전제 — 여러 뷰가 동시에 구독하면 값이 나뉜다
// (멀티캐스트가 필요하면 각자 스트림을 만들거나 Combine/Observation 사용)
// · 코드량이 ⑤보다 2배 — 상태 구분이 필요 없으면 과한 도구
// · SwiftUI라면 @Observable이 더 자연스럽다 (iOS 17+)
/// 화면 상태 — placeholder/성공/실패가 한 타입에서 구분됨 (Data? 뭉개짐 해소)
enum CellImageState: Equatable, Sendable {
case idle
case loading(ImageURL)
case loaded(ImageURL, Data)
case failed(ImageURL)
}
@MainActor
final class ImageViewModel {
private let loader: any ImageLoading
/// 뷰가 구독하는 채널. UI는 '최신' 상태만 의미 → newest(1) 버퍼
let states: AsyncStream<CellImageState>
private let continuation: AsyncStream<CellImageState>.Continuation
private(set) var state: CellImageState = .idle // 늦게 붙은 구독자용 현재값
private var currentURL: ImageURL?
private var task: Task<Void, Never>?
init(loader: any ImageLoading) {
self.loader = loader
let (stream, cont) = AsyncStream.makeStream(
of: CellImageState.self,
bufferingPolicy: .bufferingNewest(1)
)
self.states = stream
self.continuation = cont
}
deinit { continuation.finish() } // 구독 루프(for await)를 깨워서 종료 — Task 릭 방지
func show(_ url: ImageURL) {
task?.cancel()
currentURL = url
emit(.loading(url))
task = Task { [weak self] in
guard let self, !Task.isCancelled else { return }
do {
let data = try await self.loader.load(url)
guard !Task.isCancelled, self.currentURL == url else { return }
self.emit(.loaded(url, data))
} catch is CancellationError {
// ★ 취소는 실패가 아니다 — 스크롤로 지나간 셀이 .failed로 오염되지 않게
} catch {
guard self.currentURL == url else { return }
self.emit(.failed(url))
}
}
}
func prepareForReuse() {
task?.cancel()
task = nil
currentURL = nil
emit(.idle)
}
/// 상태 변경과 방송이 항상 동시 — 둘이 어긋날 수 없게 한 곳으로
private func emit(_ s: CellImageState) {
state = s
continuation.yield(s)
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑦ SearchDebouncer — 검색 자동완성
// ═══════════════════════════════════════════════════════════════
// 본질: "입력마다 이전 예약을 취소" — 셀의 task?.cancel()과 같은 근육.
// 결과 반영까지 요구하면: 응답 도착 시 "최신 질의인가" 신원 확인 (방어③과 동일).
// 복잡도: O(1) / 공간 O(1)
@MainActor
final class SearchDebouncer {
private let delay: Duration
private let action: @MainActor (String) -> Void
private var pending: Task<Void, Never>?
init(delay: Duration = .milliseconds(300),
action: @escaping @MainActor (String) -> Void) {
self.delay = delay
self.action = action
}
func input(_ text: String) {
pending?.cancel() // 디바운스의 전부
pending = Task { [weak self, delay] in // self 강캡처 금지 + delay는 값으로 캡처
try? await Task.sleep(for: delay) // 취소되면 여기서 던지고
guard let self, !Task.isCancelled else { return } // 조용히 끝
self.action(text)
}
}
/// 화면이 사라질 때 예약 취소 (선택) — deinit은 @MainActor 격리라 여기서 못 만짐
func cancel() { pending?.cancel(); pending = nil }
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑦B Throttler — "버튼·셀 탭이 두 번 먹혀요"
// ═══════════════════════════════════════════════════════════════
// ⑦ 디바운스와 형제처럼 보이지만 목적이 정반대다. 이 표가 전부:
//
// ┌──────────────┬───────────────┬──────────────┬───────────────┬────────┐
// │ │ 언제 실행 │ 무엇을 버리나 │ 전형적 자리 │ 타이머 │
// ├──────────────┼───────────────┼──────────────┼───────────────┼────────┤
// │ Debounce ⑦ │ 조용해진 '뒤' │ 중간 것 전부 │ 검색어 입력 │ 필요 │
// │ Throttle ⑦B │ 첫 것 '즉시' │ 창 안의 나머지│ 버튼·셀 연타 │ 불필요 │
// └──────────────┴───────────────┴──────────────┴───────────────┴────────┘
//
// 한 줄 판별: **Debounce는 "마지막이 진짜", Throttle은 "첫 번째가 진짜".**
// 검색은 마지막 글자가 사용자의 의도 → 중간 것을 버린다.
// 버튼은 첫 탭이 의도, 나머지는 손가락 사고 → 뒤엣것을 버린다.
//
// ⚠️ 버튼에 디바운스를 달면 안 되는 이유: 누르고 0.5초 뒤에 반응한다.
// 사용자는 "앱이 렉 걸렸다"고 느끼고 더 누른다 — 문제를 악화시킨다.
//
// ⚠️ 이 구현의 한계 — 물어보면 이렇게 답한다:
// 시간이 기준이라 '작업이 창보다 길면' 뚫린다.
// 실측: 500ms 작업 + 300ms 창 → 350ms 시점에 두 번째 탭이 통과해 중복 실행.
// 대응 ① interval을 예상 작업 시간보다 크게 잡는다 (대부분 이걸로 충분)
// ② 화면 전환·결제처럼 소요 시간을 예측할 수 없으면 '시간'이 아니라
// '진행 중인가'를 기준으로 막아야 한다 — ④ inFlight와 같은 근육이다.
// 요구가 오면 그때 확장한다. (지금 넣으면 오버엔지니어링)
//
// 복잡도: 판단 O(1) / 공간 O(1). Task 할당조차 없다 — 시각 비교 한 번이 전부.
/// 시간 창 기반 연타 방어 (leading edge — 첫 것을 살린다).
///
/// @MainActor인 이유: 버튼 탭·셀 선택은 정의상 메인에서 온다.
/// 메인 격리가 곧 상호배제라 락도 actor도 불필요 — 실행자 hop 0회, 할당 0회.
@MainActor
final class Throttler {
private let interval: Duration
/// ContinuousClock을 쓰는 이유: Date는 사용자의 시간 변경·NTP 동기화로
/// '뒤로' 점프할 수 있다 → 창이 영원히 안 열리거나 항상 열린다.
/// ContinuousClock은 단조 증가가 보장된다.
private let clock = ContinuousClock()
private var lastFired: ContinuousClock.Instant?
init(interval: Duration = .milliseconds(500)) { self.interval = interval }
/// 통과시켰으면 true, 삼켰으면 false.
/// Bool을 돌려주는 이유: 동작을 테스트로 증명할 수 있고,
/// 호출부가 "무시됨"을 계측·로깅할 수 있다. 평소엔 @discardableResult로 조용히.
@discardableResult
func perform(_ action: () -> Void) -> Bool {
let now = clock.now
if let last = lastFired, now - last < interval { return false }
lastFired = now
action()
return true
}
/// 화면 재진입 등에서 창을 즉시 연다.
func reset() { lastFired = nil }
}
// ── UIKit 붙이는 자리 ──
// ┌─ "버튼/셀 extension으로 하면 안 되나요?" — 답이 갈린다 ────────────┐
// │ UIControl(버튼) → 된다. 그리고 그게 더 낫다. │
// │ UITableViewCell → 안 된다. 셀은 재사용되므로 게이트가 콘텐츠가 │
// │ 아니라 '화면 조각'에 붙어버린다. │
// └──────────────────────────────────────────────────────────────────┘
//
// Swift extension은 저장 프로퍼티를 못 넣는다. 그런데 아래 UIControl 확장은
// 저장 프로퍼티가 필요 없다 — 게이트를 UIAction 클로저가 캡처하고,
// UIControl이 그 action을 retain하므로 게이트도 함께 산다.
// (정 프로퍼티처럼 쓰고 싶으면 objc 연관 객체를 쓰지만, Swift 6에선 키가
// 전역 가변이라 nonisolated(unsafe)가 붙는다 — 이득 대비 비용이 크다)
//
// ── 셀에 붙이면 안 되는 이유 (실측) ──
// 재사용 풀 2개 / row 0 탭 직후 row 1, row 2를 연타하면:
// 셀 소유 → row0 열림 · row1 열림 · row2 차단 ← 무엇이 갈랐나?
// "어느 셀 인스턴스를 재사용했나"뿐. 스크롤 위치에 따라 결과가 바뀐다.
// VC 소유 → row0 열림 · row1 차단 · row2 차단 ← 규칙이 하나: 0.5초에 한 번
// 셀은 '콘텐츠'가 아니라 '화면 조각'이다. 콘텐츠에 관한 상태를 얹으면
// ⑤에서 막았던 그 버그(재사용된 셀이 남의 상태를 물려받음)와 같은 종류가 된다.
// 게다가 셀은 선택을 처리하지도 않는다 — didSelectRowAt은 VC에 있다.
#if canImport(UIKit)
extension UIControl {
/// 게이트를 클로저가 소유 → 저장 프로퍼티도, 연관 객체도 필요 없다.
func addAction(throttledBy interval: Duration,
for event: UIControl.Event = .touchUpInside,
_ handler: @escaping @MainActor () -> Void) {
let gate = Throttler(interval: interval)
addAction(UIAction { _ in gate.perform(handler) }, for: event)
}
}
@MainActor
final class TapGateExample: UITableViewController {
/// 화면 전환은 애니메이션이 끝나는 시간을 덮도록 창을 넉넉히. VC가 소유한다.
private let selectTap = Throttler(interval: .milliseconds(800))
func setUp(likeButton: UIButton) {
likeButton.addAction(throttledBy: .milliseconds(500)) { /* 좋아요 토글 */ }
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
// 삼켰으면 선택 하이라이트를 되돌린다 —
// 안 그러면 셀이 눌린 채 멈춰서 사용자에겐 "먹통"으로 보인다.
// (perform이 Bool을 돌려주는 값어치가 여기서 나온다)
guard selectTap.perform({ /* 상세 화면 push */ }) else {
tableView.deselectRow(at: indexPath, animated: true)
return
}
}
}
#endif
// ═══════════════════════════════════════════════════════════════// MARK: - ⑧ RetryingFetcher — 재시도 + 지수 백오프 (데코레이터)
// ═══════════════════════════════════════════════════════════════
// ── 핵심: 재시도는 '무엇을' 재시도하느냐가 전부 ──
// 401(인증 만료) → 재시도 무의미. 100번 해도 100번 실패 → 즉시 전파, 토큰 갱신이 답
// 5xx(서버 일시) → 재시도 가치 있음. 잠깐 뒤엔 될 수 있다
// 취소 → 재시도 대상 아님 (사용자가 떠난 것)
// 그래서 정책을 타입에 박지 않고 isRetryable 클로저로 '주입'한다.
//
// ── 왜 데코레이터인가 ──
// RetryingFetcher(wrapped: URLSessionFetcher()) ← 감싸기만 하면 끝
// · 로더·캐시·셀 코드 0줄 수정 (의존성 다이어그램의 <ImageDataFetching> 자리)
// · 중첩 가능: Retrying(Instrumented(URLSession()))
// · 끄고 싶으면 감싸지 않으면 그만 — 조건문이 코드에 안 남는다
//
// ── 왜 struct인가 ──
// 상태가 없다. attempt는 지역변수라 호출마다 새로 시작.
// "동시성 도구는 상태를 따라간다" — 보호할 상태가 없으면 actor도 락도 불필요.
//
// ── 지수 백오프: 왜 고정 간격이 아닌가 ──
// 서버가 과부하로 5xx를 뱉는 상황에서 100ms마다 재시도하면 불을 기름으로 끄는 격.
// 20ms → 40ms → 80ms 로 물러서며 서버에 회복 시간을 준다.
//
// ── 빠진 것(정직하게): 지터(jitter) ──
// 지금은 모든 클라이언트가 '똑같은' 간격으로 물러선다 → 장애 복구 순간
// 전 기기가 동시에 재시도해 서버를 다시 눕히는 thundering herd.
// 실서비스: baseDelay * 2^n * Double.random(in: 0.5...1.5) 로 시점을 흩는다.
// 여기 안 넣은 이유: 데모 재현성(랜덤이면 출력이 매번 달라짐).
//
// ── 트레이드오프 ──
// · 재시도는 지연을 늘린다 — 3회 × 백오프면 사용자는 그만큼 더 기다린다
// → ⑨ withTimeout과 조합해 상한을 씌우는 게 실전 조합
// · 멱등하지 않은 요청(POST 결제)에 붙이면 중복 실행 위험. 이미지 GET이라 안전.
struct RetryPolicy: Sendable {
var maxAttempts = 3
var baseDelay: Duration = .milliseconds(20)
var isRetryable: @Sendable (Error) -> Bool = { _ in true }
}
struct RetryingFetcher: ImageDataFetching {
let wrapped: any ImageDataFetching // 어떤 fetcher든 감쌈 — 중첩 가능
let policy: RetryPolicy
func fetch(_ url: ImageURL) async throws -> Data {
var attempt = 1
while true { // 종료는 return/throw로만 — 도달불가 마커가 필요 없는 구조
do { return try await wrapped.fetch(url) }
catch {
guard !(error is CancellationError), // 취소는 재시도 대상이 아니다
policy.isRetryable(error), // 401류는 즉시 전파
attempt < policy.maxAttempts
else { throw error }
// 지수 백오프: 20ms → 40ms → 80ms …
try await Task.sleep(for: policy.baseDelay * (1 << (attempt - 1)))
attempt += 1
}
}
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑨ withTimeout — 작업 vs 시계의 경주
// ═══════════════════════════════════════════════════════════════
// ── 아이디어: 둘을 동시에 출발시키고 먼저 들어온 쪽을 채택 ──
// 그룹에 태스크 2개: [실제 작업] vs [limit만큼 자고 에러 던지기]
// group.next()는 '먼저 끝난 것'을 준다 → 그게 곧 승자
//
// 작업이 이기면 → 결과 반환
// 시계가 이기면 → TimeoutError가 throw되어 밖으로 나감
//
// ── cancelAll()이 없으면 생기는 일 ──
// 진 쪽이 계속 돈다. 200ms 작업에 50ms 타임아웃이면 그 작업은
// 에러를 던진 뒤에도 150ms 더 살아서 네트워크·CPU를 먹는다 = 좀비.
// withThrowingTaskGroup은 스코프를 벗어날 때 자식을 정리하지만,
// '명시적으로' 끊어야 의도가 코드에 드러나고 즉시 해제된다.
//
// ── guard let winner = ... else { preconditionFailure } 인 이유 ──
// group.next()가 nil을 주는 건 '그룹이 비었을 때'뿐. 우리는 방금 2개를 넣었다.
// 즉 nil은 논리적으로 불가능 → force unwrap(!) 대신 메시지 있는 즉사로 표현.
// (프로그래머 실수는 조용히 크래시하지 말고 이유를 남긴다)
//
// ── 트레이드오프 ──
// · 타임아웃은 '포기'다 — 느린 네트워크 사용자를 버리는 정책이 될 수 있다
// 이미지라면 괜찮지만, 결제 요청에 짧은 타임아웃은 위험
// · Task.sleep의 정밀도는 OS 스케줄러에 달림 — ms 단위 정확도를 기대하면 안 됨
// · 재시도(⑧)와 조합할 때 순서 주의:
// withTimeout(전체) { retrying.fetch() } ← 전체 상한 (권장)
// retrying { withTimeout(개별) { fetch() } } ← 시도마다 상한, 총 시간은 3배
struct TimeoutError: Error {}
func withTimeout<T: Sendable>(
_ limit: Duration,
_ work: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await work() }
group.addTask {
try await Task.sleep(for: limit)
throw TimeoutError() // 시계가 먼저 끝나면 이 에러가 이김
}
guard let winner = try await group.next() else { // 먼저 끝난 쪽
preconditionFailure("도달 불가: 그룹에 태스크를 2개 넣었다") // nil은 빈 그룹뿐
}
group.cancelAll() // ★ 진 쪽 정리 — 없으면 좀비 Task
return winner
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑩ staleWhileRevalidate — 캐시 즉시, 신선한 값 나중
// ═══════════════════════════════════════════════════════════════
// ── 무엇을 파는 패턴인가: '체감 속도' ──
// 일반 로딩: [스피너 2초] → 내용
// SWR: [옛 내용 즉시] → (2초 뒤 조용히 갱신)
// 사용자는 0초에 뭔가를 본다. 실제 데이터가 늦게 와도 '빠르다'고 느낀다.
// → 홈 피드·프로필처럼 "약간 낡아도 되는" 화면에 맞다.
//
// ── AsyncThrowingStream을 쓰는 이유 ──
// 한 번의 호출이 값을 '두 번' 내보내야 한다. 반환값 하나로는 표현 불가.
// for try await로 받으면 호출부가 자연스럽게 두 번 갱신된다.
//
// for try await data in stream {
// imageView.image = UIImage(data: data) // 1) 옛것 2) 새것
// }
//
// ── onTermination이 핵심 ──
// 구독자가 화면을 떠나면(Task 취소) 스트림이 종료되고, 그때 네트워크도 끊는다.
// 이게 없으면 아무도 안 보는 갱신이 계속 돌아 대역폭을 먹는다.
//
// ── 정책 결정: 갱신 실패를 어떻게 다룰까 (요구사항 질문거리) ──
// 여기 선택: 캐시를 이미 보여줬으면 조용히 종료 (화면 유지)
// 근거 — 사용자에겐 이미 볼 게 있다. 에러 배너를 띄우면 오히려 방해.
// 반대 선택: 실패를 노출 (finish(throwing:))
// 근거 — 데이터 신선도가 중요한 화면(잔액·주문 상태)이라면 낡은 값이 위험.
// → 면접에선 "어느 쪽인지 여쭤봐도 될까요?"가 정답에 가깝다.
//
// ── 트레이드오프 ──
// · 화면이 두 번 바뀐다 → 깜빡임으로 보일 수 있음 (같은 값이면 yield 생략 필요)
// · 캐시가 없을 땐 이점이 0 — 첫 방문 사용자는 그냥 로딩을 본다
// · "얼마나 낡은 것까지 허용하나"는 별개 문제 → TTL과 조합해야 완성
func staleWhileRevalidate(
cached: Data?,
fresh: @escaping @Sendable () async throws -> Data
) -> AsyncThrowingStream<Data, Error> {
AsyncThrowingStream { continuation in
if let cached { continuation.yield(cached) } // 1) 있던 것 즉시 — 체감 속도
let task = Task {
do {
continuation.yield(try await fresh()) // 2) 신선한 것
continuation.finish()
} catch {
cached != nil ? continuation.finish()
: continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() } // ★ 구독자가 떠나면 네트워크도 취소
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑪ Paginator — 무한 스크롤 중복 로드 방지
// ═══════════════════════════════════════════════════════════════
// ── 문제: 스크롤 연타 ──
// 사용자가 바닥에서 빠르게 스크롤하면 loadNext()가 0.1초 안에 5번 불린다.
// 가드가 없으면 → 같은 페이지를 5번 요청 + 목록에 중복 5벌.
//
// ── 핵심: 왜 이게 actor 안에서 '원자적'인가 ──
// guard hasMore, !isLoading else { return [] } ┐ 이 두 줄 사이엔
// isLoading = true ┘ await가 없다
//
// actor는 await를 만나야 다른 호출에게 순서를 넘긴다(재진입 지점).
// check와 set이 '같은 동기 구간'에 있으므로 그 사이에 끼어들 수 없다.
// → 락도 세마포어도 필요 없다. 이게 로더 inFlight와 완전히 같은 원리.
//
// ⚠️ 만약 이렇게 썼다면 깨진다:
// guard !isLoading else { return [] }
// let x = await something() ← 여기서 다른 호출이 들어와 guard 통과
// isLoading = true ← 둘 다 통과해버림
//
// ── defer { isLoading = false } 인 이유 ──
// fetchPage가 throw해도 잠금이 풀린다. 없으면 한 번 실패한 순간
// isLoading이 true로 굳어 '영원히 더 못 불러오는' 화면이 된다.
//
// ── 빈 배열을 반환하는 설계 ──
// 중복 호출을 에러로 던지지 않고 []로 흡수 → 호출부(스크롤 델리게이트)가
// "지금 로딩 중인가?"를 신경 쓸 필요가 없다. 그냥 부르면 된다.
//
// ── 트레이드오프 ──
// · 실패해도 page가 안 올라가므로 다음 호출이 같은 페이지를 재시도한다 (의도)
// · 새로고침(reset) 기능이 없다 — 필요하면 page=0, hasMore=true로 되돌리는 메서드 추가
// · 커서 기반 API라면 page: Int 대신 nextCursor: String?으로 바뀐다
actor Paginator<Item: Sendable> {
private let fetchPage: @Sendable (Int) async throws -> ([Item], Bool)
private var page = 0
private var hasMore = true
private var isLoading = false
init(fetchPage: @escaping @Sendable (Int) async throws -> ([Item], Bool)) {
self.fetchPage = fetchPage
}
/// 연타·동시 호출은 빈 배열로 흡수 — 호출부가 신경 쓸 게 없다
func loadNext() async throws -> [Item] {
guard hasMore, !isLoading else { return [] }
isLoading = true
defer { isLoading = false } // throw여도 잠금 해제
let (items, more) = try await fetchPage(page)
page += 1
hasMore = more
return items
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑫ URLSessionFetcher — 실네트워크 (참고용)
// ═══════════════════════════════════════════════════════════════
// 판정기엔 인터넷이 없다 → 데모/검증은 전부 FakeFetcher로.
// 이 타입은 "실제로는 이렇게 연결된다"를 보여주는 참고 + UIKit 예제에서 사용.
enum ImageLoadError: Error {
case badStatus(Int)
}
struct URLSessionFetcher: ImageDataFetching {
func fetch(_ url: ImageURL) async throws -> Data {
let (data, response) = try await URLSession.shared.data(from: url.value) // 벗는 지점
if let http = response as? HTTPURLResponse,
!(200..<300).contains(http.statusCode) {
throw ImageLoadError.badStatus(http.statusCode)
}
return data
}
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑬ 테스트 더블 — 판정기(무네트워크)에서의 눈과 손
// ═══════════════════════════════════════════════════════════════
// ── 왜 이게 '있어야만' 하는가 ──
// HackerRank 판정기엔 인터넷이 없다. 진짜 URLSession을 쓰면 아무것도 증명 못 한다.
// → <ImageDataFetching> 프로토콜은 취향이 아니라 '강제'였다는 근거가 여기.
//
// ── 좋은 가짜의 3가지 조건 (면접에서 설명 가능해야 함) ──
// ① 세어야 한다 calls / peak — "네트워크가 정말 1번만 나갔나"를 증명
// ② 느려야 한다 delays — 지연이 없으면 경쟁 상태(재사용 버그)가 재현 안 됨
// ③ 거짓말하면 안 된다 취소·실패를 진짜처럼 전파
//
// ── ③이 가장 중요하다: try? 하나가 테스트를 무의미하게 만든다 ──
// if let d = delays[url] { try? await Task.sleep(...) } ❌ 취소를 삼킴
// if let d = delays[url] { try await Task.sleep(...) } ✅ CancellationError 전파
//
// try?를 쓰면 '취소된 요청이 오히려 더 빨리 성공하는' 가짜가 된다.
// 그런 가짜로 테스트를 통과시키면 프로덕션에서만 터진다.
//
// ── 왜 actor인가 ──
// 동시 10회 호출에서 calls += 1이 유실되면 안 된다. class + Int면 레이스로
// 숫자가 틀리고, 테스트가 '가끔 통과/가끔 실패'(flaky)해진다.
// → actor로 직렬화. 락 코드 0줄.
//
// ── peak(최대 동시치)가 왜 필요한가 ──
// "동시 3개 제한"은 결과만 봐선 증명이 안 된다(20개 다 받아지니까).
// 진입 시 current += 1, 이탈 시 defer로 감소, 최고치를 기록 → peak == 3이 곧 증거.
// defer를 쓰는 이유: throw로 빠져나가도 카운터가 새지 않게.
//
// ── 데이터를 URL 문자열 바이트로 만드는 이유 ──
// return Data(url.value.absoluteString.utf8)
// URL마다 길이가 달라진다 → "화면에 누가 그려졌나"를 바이트 수로 식별 가능.
// (데모 [3]에서 slow 24B vs fast 18B로 stale 방어를 증명하는 방식)
//
// ── 트레이드오프 ──
// · 가짜는 진짜의 근사일 뿐 — HTTP 상태코드, 리다이렉트, 부분 전송은 재현 못 함
// · 지연을 sleep으로 흉내내므로 테스트가 실제 시간을 쓴다 (판정기 2초 제한 주의:
// 실전에선 150ms→30ms처럼 상수를 줄인다)
/// 지연·실패 주입 + 호출/동시 계측. actor라 락 없이 정확 (동시 10회에서도).
actor FakeFetcher: ImageDataFetching {
struct StubError: Error {}
private let delays: [URL: UInt64]
private let failures: Set<URL>
private let defaultDelay: UInt64
private(set) var calls = 0
private var current = 0 // 지금 떠 있는 요청 수
private(set) var peak = 0 // 역대 최고 동시치 — "동시 제한"의 증거
init(delays: [URL: UInt64] = [:], failures: Set<URL> = [], defaultDelay: UInt64 = 0) {
self.delays = delays
self.failures = failures
self.defaultDelay = defaultDelay
}
func fetch(_ url: ImageURL) async throws -> Data {
calls += 1
current += 1
peak = max(peak, current)
defer { current -= 1 } // throw여도 감소 보장
let d = delays[url.value] ?? defaultDelay
if d > 0 { try await Task.sleep(nanoseconds: d) } // try? 금지 — 취소도 진짜처럼 던진다
if failures.contains(url.value) { throw StubError() }
return Data(url.value.absoluteString.utf8) // URL마다 다른 바이트 → "누가 그려졌나" 식별
}
}
/// N번 실패 후 성공 / 또는 영구 실패(401류) — 재시도 데모용
actor FlakyFetcher: ImageDataFetching {
struct ServerError: Error {} // 재시도 가치 있음 (5xx류)
struct AuthError: Error {} // 재시도 무의미 (401류)
private var failuresLeft: Int
private let terminal: Bool
private(set) var attempts = 0
init(failFirst: Int = 0, terminalFailure: Bool = false) {
self.failuresLeft = failFirst
self.terminal = terminalFailure
}
func fetch(_ url: ImageURL) async throws -> Data {
attempts += 1
if terminal { throw AuthError() }
if failuresLeft > 0 { failuresLeft -= 1; throw ServerError() }
return Data(url.value.absoluteString.utf8)
}
}
@MainActor
final class Log { // 캡처 var 대신 — 격리가 명확한 기록기
private(set) var lines: [String] = []
func add(_ s: String) { lines.append(s) }
}
actor Counter {
private(set) var n = 0
func tick() { n += 1 }
}
// ═══════════════════════════════════════════════════════════════// MARK: - ⑭ 데모 — 제출/설명 전 마지막 3분: 핵심 동작을 print로 시연
// ═══════════════════════════════════════════════════════════════
@main
enum Demo { // case 없는 enum — 인스턴스가 무의미한 네임스페이스
/// 데모 픽스처 전용 — 컴파일 타임 상수 리터럴만 넣는다는 계약.
/// 조용한 `!` 대신: 실패하면 '어떤 문자열이' 문제인지 메시지와 함께 즉시 멈춘다.
static func fixture(_ s: String) -> ImageURL {
guard let url = ImageURL(s) else {
preconditionFailure("잘못된 데모 픽스처: \(s)")
}
return url
}
@MainActor
static func main() async {
print("── 데모 ──")
// [1] 같은 URL 동시 10회 → 네트워크 1회 (in-flight 합류)
do {
let a = fixture("https://e.com/a.png")
let fetcher = FakeFetcher(delays: [a.value: 40_000_000])
let loader = ImageLoader(fetcher: fetcher)
await withTaskGroup(of: Void.self) { group in
for _ in 0..<10 { group.addTask { _ = try? await loader.load(a) } }
}
print("[1] 동시 10회 → 네트워크 호출:", await fetcher.calls, "(기대 1)")
}
// [2] 20장 배치, 동시 3개 제한 — peak가 증거
do {
let urls = (0..<20).map { fixture("https://e.com/img\($0).png") }
let fetcher = FakeFetcher(defaultDelay: 20_000_000)
// ★ any ImageLoading으로 받아서 호출 — 프로토콜에 선언돼 있어야 컴파일된다
// (VC가 실제로 들고 있는 형태와 동일)
let loader: any ImageLoading = ImageLoader(fetcher: fetcher)
let results = await loader.loadAll(urls, maxConcurrent: 3)
print("[2] 배치 20장 → 수신:", results.compactMap { $0 }.count,
"/ peak 동시:", await fetcher.peak, "(기대 3)")
}
// [3] 셀 재사용 — 늦게 온 slow는 폐기, 마지막 렌더는 fast(18B)
do {
let slow = fixture("https://e.com/slow-large") // 24B, 80ms
let fast = fixture("https://e.com/fast") // 18B, 10ms
let fetcher = FakeFetcher(delays: [slow.value: 80_000_000,
fast.value: 10_000_000])
let loader = ImageLoader(fetcher: fetcher)
let cell = ImageCellController(loader: loader) { data in
print(" render:", data.map { "\($0.count)B" } ?? "placeholder")
}
print("[3] 셀 재사용 (slow → fast 즉시 교체):")
cell.configure(with: slow)
cell.configure(with: fast)
try? await Task.sleep(nanoseconds: 150_000_000)
print(" (마지막 render가 18B면 stale 방어 성공, slow 24B는 미출력)")
}
// [4] MVVM 바인딩 — AsyncStream이 Rx의 자리를 대체
do {
let fast = fixture("https://e.com/fast")
let boom = fixture("https://e.com/boom")
let fetcher = FakeFetcher(delays: [fast.value: 10_000_000,
boom.value: 10_000_000],
failures: [boom.value])
let vm = ImageViewModel(loader: ImageLoader(fetcher: fetcher))
print("[4] MVVM 바인딩 (AsyncStream):")
let binding = Task { // 뷰의 자리 — for await가 곧 bind
for await s in vm.states { print(" state:", describe(s)) }
}
await Task.yield() // 구독이 붙을 틈
vm.show(fast)
try? await Task.sleep(nanoseconds: 60_000_000)
vm.show(boom) // 실패는 .failed — placeholder와 구분됨
try? await Task.sleep(nanoseconds: 60_000_000)
binding.cancel() // DisposeBag의 자리
}
// [5] NSCache 교체 — 로더 무수정으로 백엔드만 바꿈 (DIP 실증)
do {
let a = fixture("https://e.com/a.png")
let fetcher = FakeFetcher()
let loader = ImageLoader(fetcher: fetcher, cache: NSCacheImageCache())
_ = try? await loader.load(a)
_ = try? await loader.load(a) // 두 번째는 NSCache 히트
print("[5] NSCache 백엔드 → 네트워크 호출:", await fetcher.calls, "(기대 1)")
}
// [6] 디바운스: 3연타 → 마지막 것 1회만
do {
let log = Log()
let d = SearchDebouncer(delay: .milliseconds(50)) { log.add($0) }
d.input("김")
try? await Task.sleep(for: .milliseconds(15))
d.input("김치")
try? await Task.sleep(for: .milliseconds(15))
d.input("김치찌개")
try? await Task.sleep(for: .milliseconds(120))
print("[6] 디바운스 — 실행:", log.lines, "(기대 [김치찌개])")
}
// [7] 재시도: 5xx류 2회 실패 후 성공 / 401류는 즉시 전파
do {
let u = fixture("https://e.com/a") // 이제 fetch도 ImageURL을 받는다 (옆문 차단)
let flaky = FlakyFetcher(failFirst: 2)
_ = try? await RetryingFetcher(wrapped: flaky, policy: RetryPolicy()).fetch(u)
let auth = FlakyFetcher(terminalFailure: true)
_ = try? await RetryingFetcher(
wrapped: auth,
policy: RetryPolicy(isRetryable: { !($0 is FlakyFetcher.AuthError) })
).fetch(u)
print("[7] 재시도 — 5xx류:", await flaky.attempts, "회(기대3) / 401류:",
await auth.attempts, "회(기대1)")
}
// [8] 타임아웃: 200ms 작업 vs 50ms 제한
do {
let result: String
do {
_ = try await withTimeout(.milliseconds(50)) { () -> Int in
try await Task.sleep(for: .milliseconds(200))
return 42
}
result = "성공(?)"
} catch is TimeoutError { result = "TimeoutError (기대대로)" }
catch { result = "다른 에러: \(error)" }
print("[8] 타임아웃 —", result)
}
// [9] SWR: old 즉시 → new 나중
do {
var got: [String] = []
let stream = staleWhileRevalidate(cached: Data("old".utf8)) {
try await Task.sleep(for: .milliseconds(30))
return Data("new".utf8)
}
do { for try await d in stream { got.append(String(decoding: d, as: UTF8.self)) } }
catch {}
print("[9] SWR — 방출 순서:", got, "(기대 [old, new])")
}
// [10] 페이지네이션: 동시 2회 → 1회만 로드, 끝까지 중복 없음
do {
let calls = Counter()
let paginator = Paginator<Int> { page in
await calls.tick()
try? await Task.sleep(for: .milliseconds(20))
return ([page * 2, page * 2 + 1], page < 2) // 3페이지 × 2개
}
async let a = paginator.loadNext() // 동시 진입
async let b = paginator.loadNext()
var all = ((try? await a) ?? []) + ((try? await b) ?? []) // 하나는 [], 하나는 [0,1]
while true {
let next = (try? await paginator.loadNext()) ?? []
if next.isEmpty { break }
all += next
}
print("[10] 페이지네이션 — 아이템:", all.count, "(기대6) / fetch 호출:",
await calls.n, "회(기대3)")
}
// [11] 수명 앵커 — '읽지 않는 프로퍼티'가 왜 필요한가 (MemoryWarningObserver와 같은 원리)
do {
final class Subscription { // deinit에서 정리하는 타입 (옵저버의 축소판)
let onGone: () -> Void
init(onGone: @escaping () -> Void) { self.onGone = onGone }
deinit { onGone() }
}
var aliveWithoutHolding = true
var aliveWithHolding = true
do { _ = Subscription { aliveWithoutHolding = false } } // 안 붙잡음 → 즉시 해제
let held = Subscription { aliveWithHolding = false } // 붙잡음 → 유지
print("[11] 수명 앵커 — 미보유:", aliveWithoutHolding ? "살아있음" : "즉시 해제됨(기대)",
"/ 보유:", aliveWithHolding ? "살아있음(기대)" : "해제됨")
_ = held // 스코프 끝까지 유지
}
// [12] 연타 방어 — Throttle은 '첫 것 즉시' (⑦ Debounce의 '마지막 것 나중'과 정반대)
do {
let t = Throttler(interval: .milliseconds(300))
var fired: [Int] = []
for i in 1...10 { t.perform { fired.append(i) } }
print("[12] Throttle 연타 10회 → 실행:", fired, "(기대 [1] — 첫 탭이 산다)")
try? await Task.sleep(for: .milliseconds(350))
t.perform { fired.append(99) }
print(" 창(300ms) 지난 뒤 재탭 →", fired, "(기대 [1, 99] — 창이 다시 열림)")
}
// [13] 축출 정책 교체 — 같은 접근 패턴, policy 인자만 바꾸면 다른 답
do {
let a = fixture("https://cdn.e.com/a.png"), b = fixture("https://cdn.e.com/b.png")
let c = fixture("https://cdn.e.com/c.png"), d = fixture("https://cdn.e.com/d.png")
/// a를 많이 쓰지만 '오래전에' 쓰고, b·c를 나중에 넣은 뒤 d로 한도를 넘긴다
func survivors(policy: sending any EvictionPolicy) async -> [String] {
let cache = ImageCache(countLimit: 3, costLimit: .max, policy: policy)
await cache.insert(Data([1]), for: a)
for _ in 0..<4 { _ = await cache.value(for: a) } // a: 총 5회 사용 (freq↑)
await cache.insert(Data([1]), for: b) // 이후 a는 '가장 오래' 안 쓴 것이 됨
await cache.insert(Data([1]), for: c)
await cache.insert(Data([1]), for: d) // 한도 초과 → 하나 축출
var live: [String] = []
for u in [a, b, c, d] where await cache.value(for: u) != nil {
live.append(u.value.lastPathComponent)
}
return live
}
let lru = await survivors(policy: LRURecency())
let lfu = await survivors(policy: LFURecency())
print("[13] 축출 정책 교체 — 같은 입력, 다른 결정")
print(" LRU 생존:", lru, "(a 탈락 — 5번 썼어도 가장 오래 안 씀)")
print(" LFU 생존:", lfu, "(b 탈락 — a는 freq 5라 안전)")
}
print("완료")
}
static func describe(_ s: CellImageState) -> String {
switch s {
case .idle: "idle"
case .loading(let u): "loading(\(u.value.lastPathComponent))"
case .loaded(let u, let d): "loaded(\(u.value.lastPathComponent), \(d.count)B)"
case .failed(let u): "failed(\(u.value.lastPathComponent))"
}
}
}
import Foundation
// ═══════════════════════════════════════════════════════════════
// 변형 문제 5종 — 전부 exam.swift 부품의 재조합
// 실행: swiftc -O -swift-version 6 -strict-concurrency=complete -parse-as-library variants.swift -o v && ./v
// ═══════════════════════════════════════════════════════════════
// ── 공용 최소 경계 (self-contained용) ──
protocol Fetching: Sendable {
func fetch(_ url: URL) async throws -> Data
}
/// N번 실패 후 성공하는 가짜 — 재시도 데모용
actor FlakyFetcher: Fetching {
struct ServerError: Error {} // 재시도 가치 있음 (5xx류)
struct AuthError: Error {} // 재시도 무의미 (401류)
private var failuresLeft: Int
private let terminal: Bool // true면 AuthError만 던짐
private(set) var attempts = 0
init(failFirst: Int = 0, terminalFailure: Bool = false) {
self.failuresLeft = failFirst
self.terminal = terminalFailure
}
func fetch(_ url: URL) async throws -> Data {
attempts += 1
if terminal { throw AuthError() }
if failuresLeft > 0 { failuresLeft -= 1; throw ServerError() }
return Data(url.absoluteString.utf8)
}
}
@MainActor
final class Log { // 캡처 var 대신 — 격리 명확
private(set) var lines: [String] = []
func add(_ s: String) { lines.append(s) }
}
// ═══════════════════════════════════════════════════════════════// [1] 디바운스 — 검색 자동완성
// 본질: "입력마다 이전 타이머를 취소" — 셀의 task?.cancel()과 같은 근육
// ═══════════════════════════════════════════════════════════════
@MainActor
final class SearchDebouncer {
private let delay: Duration
private let action: @MainActor (String) -> Void
private var pending: Task<Void, Never>?
init(delay: Duration = .milliseconds(300),
action: @escaping @MainActor (String) -> Void) {
self.delay = delay
self.action = action
}
func input(_ text: String) {
pending?.cancel() // 디바운스의 전부: 이전 예약 취소
pending = Task {
try? await Task.sleep(for: delay) // 취소되면 sleep이 던지고
guard !Task.isCancelled else { return } // 여기서 조용히 끝
action(text)
}
}
}
// 결과 반영까지 물으면: 응답 도착 시 "최신 질의인가" 신원확인 — 셀 방어 ③과 동일.
// ═══════════════════════════════════════════════════════════════// [2] 재시도 + 지수 백오프 — fetcher 데코레이터
// 실무 스토리 접속: "401은 재시도 금지, 5xx만 재시도" — isRetryable로 표현
// ═══════════════════════════════════════════════════════════════
struct RetryPolicy: Sendable {
var maxAttempts = 3
var baseDelay: Duration = .milliseconds(20)
var isRetryable: @Sendable (Error) -> Bool = { _ in true }
}
struct RetryingFetcher: Fetching { // 상태 없음 — actor 불필요, struct면 충분
let wrapped: any Fetching
let policy: RetryPolicy
func fetch(_ url: URL) async throws -> Data {
for attempt in 1... {
do { return try await wrapped.fetch(url) }
catch {
guard !(error is CancellationError), // 취소는 재시도 대상이 아니다
policy.isRetryable(error), // 401류는 즉시 전파
attempt < policy.maxAttempts
else { throw error }
// 지수 백오프: 20ms → 40ms → 80ms … (실서비스는 지터 추가 — 재시도 동기화 방지)
try await Task.sleep(for: policy.baseDelay * (1 << (attempt - 1)))
}
}
fatalError("unreachable")
}
}
// ═══════════════════════════════════════════════════════════════// [3] 타임아웃 — 작업 vs 시계의 경주 (TaskGroup)
// ═══════════════════════════════════════════════════════════════
struct TimeoutError: Error {}
func withTimeout<T: Sendable>(
_ limit: Duration,
_ work: @escaping @Sendable () async throws -> T
) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await work() }
group.addTask {
try await Task.sleep(for: limit)
throw TimeoutError() // 시계가 먼저 끝나면 이 에러가 이김
}
let winner = try await group.next()! // 먼저 끝난 쪽
group.cancelAll() // 진 쪽 정리 — 구조적 취소
return winner
}
}
// ═══════════════════════════════════════════════════════════════// [4] Stale-While-Revalidate — 캐시 즉시, 신선한 값 나중 (최대 2회 방출)
// ═══════════════════════════════════════════════════════════════
func staleWhileRevalidate(
cached: Data?,
fresh: @escaping @Sendable () async throws -> Data
) -> AsyncThrowingStream<Data, Error> {
AsyncThrowingStream { continuation in
if let cached { continuation.yield(cached) } // 1) 있던 것 즉시 — 체감 속도
let task = Task {
do {
continuation.yield(try await fresh()) // 2) 신선한 것
continuation.finish()
} catch {
// 정책: 캐시를 이미 보여줬으면 갱신 실패는 조용히 종료 (화면 유지)
cached != nil ? continuation.finish()
: continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() } // 구독자가 떠나면 네트워크도 취소
}
}
// ═══════════════════════════════════════════════════════════════// [5] 페이지네이션 — 스크롤 연타에도 중복 로드 없음
// 본질: check&set이 await 앞에 — 로더 inFlight와 같은 원리
// ═══════════════════════════════════════════════════════════════
actor Paginator<Item: Sendable> {
private let fetchPage: @Sendable (Int) async throws -> ([Item], Bool)
private var page = 0
private var hasMore = true
private var isLoading = false
init(fetchPage: @escaping @Sendable (Int) async throws -> ([Item], Bool)) {
self.fetchPage = fetchPage
}
/// 연타·동시 호출은 빈 배열로 흡수 — 호출부가 신경 쓸 게 없다
func loadNext() async throws -> [Item] {
guard hasMore, !isLoading else { return [] } // await 전 동기 구간 = 원자적
isLoading = true
defer { isLoading = false } // throw여도 잠금 해제
let (items, more) = try await fetchPage(page)
page += 1
hasMore = more
return items
}
}
actor Counter {
private(set) var n = 0
func tick() { n += 1 }
}
// ═══════════════════════════════════════════════════════════════
// 데모
// ═══════════════════════════════════════════════════════════════
@main
enum Demo { // case 없는 enum — 인스턴스가 무의미한 네임스페이스
@MainActor
static func main() async {
print("── 변형 5종 데모 ──")
// [1] 디바운스: 3연타 → 마지막 것 1회만
do {
let log = Log()
let d = SearchDebouncer(delay: .milliseconds(50)) { log.add($0) }
d.input("김")
try? await Task.sleep(for: .milliseconds(15))
d.input("김치")
try? await Task.sleep(for: .milliseconds(15))
d.input("김치찌개")
try? await Task.sleep(for: .milliseconds(120))
print("[1] 디바운스 — 실행:", log.lines, "(기대 [김치찌개])")
}
// [2] 재시도: 5xx류 2회 실패 후 성공 / 401류는 즉시 전파
do {
let u = URL(string: "https://e.com/a")!
let flaky = FlakyFetcher(failFirst: 2)
let retrying = RetryingFetcher(wrapped: flaky, policy: RetryPolicy())
_ = try? await retrying.fetch(u)
let auth = FlakyFetcher(terminalFailure: true)
let retryingAuth = RetryingFetcher(
wrapped: auth,
policy: RetryPolicy(isRetryable: { !($0 is FlakyFetcher.AuthError) })
)
_ = try? await retryingAuth.fetch(u)
print("[2] 재시도 — 5xx류:", await flaky.attempts, "회(기대3) / 401류:",
await auth.attempts, "회(기대1)")
}
// [3] 타임아웃: 200ms 작업 vs 50ms 제한
do {
let result: String
do {
_ = try await withTimeout(.milliseconds(50)) { () -> Int in
try await Task.sleep(for: .milliseconds(200))
return 42
}
result = "성공(?)"
} catch is TimeoutError {
result = "TimeoutError (기대대로)"
} catch {
result = "다른 에러: \(error)"
}
print("[3] 타임아웃 —", result)
}
// [4] SWR: old 즉시 → new 나중
do {
var got: [String] = []
let stream = staleWhileRevalidate(cached: Data("old".utf8)) {
try await Task.sleep(for: .milliseconds(30))
return Data("new".utf8)
}
do { for try await d in stream { got.append(String(decoding: d, as: UTF8.self)) } }
catch {}
print("[4] SWR — 방출 순서:", got, "(기대 [old, new])")
}
// [5] 페이지네이션: 동시 2회 → 1회만 로드, 끝까지 중복 없음
do {
let calls = Counter()
let paginator = Paginator<Int> { page in
await calls.tick()
try? await Task.sleep(for: .milliseconds(20))
return ([page * 2, page * 2 + 1], page < 2) // 3페이지 × 2개
}
async let a = paginator.loadNext() // 동시 진입
async let b = paginator.loadNext()
let (ra, rb) = (try! await a, try! await b)
var all = ra + rb // 하나는 [], 하나는 [0,1]
while true {
let next = try! await paginator.loadNext()
if next.isEmpty { break }
all += next
}
print("[5] 페이지네이션 — 아이템:", all.count, "(기대6) / fetch 호출:",
await calls.n, "회(기대3)")
}
print("완료")
}
}
검색과 일치하는 섹션이 없습니다.