쿠팡이츠 코딩면접 · 코드 레퍼런스

Swift 6 · 아이패드용
개념 → ← 허브
📑 섹션 목차 (탭하여 열기)
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("완료")
  }
}

검색과 일치하는 섹션이 없습니다.