쿠팡이츠 코딩면접 · 개념 정리

Swift 6 · 11그룹 54개념 · 코드 레퍼런스의 "왜" 코드 →
이 페이지의 용도 — 코드 레퍼런스(exam.swift)가 무엇을이라면, 여기는 다. 면접에서 코드는 화면에 있지만 설명은 입에서 나와야 한다. 각 개념은 필요한가 → 함정어디서 틀리나 → 멘트어떻게 말하나 순으로 정리했다. 실측이 붙은 건 이 저장소에서 직접 측정한 숫자다.

ASwift 동시성actor를 썼다고 안전해지지 않는다 — 이 그룹의 A2가 이 면접의 최대 분기점

A1actor — 한 번에 한 명만 들어가는 방

가변 상태를 여러 곳에서 동시에 건드릴 때, 컴파일러가 강제하는 상호배제.

딕셔너리 같은 가변 상태를 두 스레드가 동시에 쓰면 자료구조가 깨진다. 락을 손으로 걸면 빼먹거나 데드락이 난다. actor는 그 규율을 타입 수준에서 강제한다 — 밖에서는 await 없이 접근할 수조차 없다.

actor ImageLoader {
  private var inFlight: [ImageURL: Task<Data, Error>] = [:]   // 보호 대상
}

함정actor를 남발하면 성능을 깎는다. 이미 스레드 안전한 것(NSCache)을 감싸면 동시 읽기가 가능하던 걸 한 줄로 세운다. 보호할 내 가변 상태가 없으면 actor가 아니다.

멘트"inFlight 딕셔너리가 여러 요청에서 동시에 읽고 쓰이니 actor로 직렬화했습니다. 반대로 NSCache는 문서상 스레드 안전이라 actor로 감싸면 이득 없이 직렬화 비용만 붙습니다."

A2actor 재진입 — await에서 문이 열린다 ★

액터는 await를 만나면 그 자리에서 다른 요청을 들여보낸다. 액터를 썼다고 자동으로 안전하지 않다.

액터가 await 동안 문을 잠그고 있으면 그 시간만큼 모두가 멈춘다(사실상 데드락 위험). 그래서 Swift는 중단 지점에서 액터를 놓아준다. 대가는 "검사한 값이 재개 후에도 유효하다는 보장이 사라진다"는 것.

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)   // 안에서 inFlight[url] = task  ┘ 등록
}                                    // 검사~등록 사이에 await가 없다 = 원자적

실측검사와 등록 사이에 await Task.sleep하나만 끼워 넣자 동시 10회 요청의 네트워크 호출이 1회 → 10회로 터졌다. 중복 제거를 지킨 건 액터가 아니라 그 구간에 await를 안 쓴 것이다.

멘트"액터라도 await 지점에서는 재진입이 일어납니다. 그래서 inFlight 확인부터 등록까지는 await 없이 붙여 뒀습니다. 이 구간에 await가 하나라도 들어가면 중복 제거가 깨집니다."

이 한 마디가 "액터 써봤다"와 "액터를 이해했다"를 가른다. 같은 불변식이 E2 스로틀·E6 페이지네이션에서도 반복된다.

A3@MainActor — UI는 정의상 메인에서 온다

메인 스레드 격리. UI 접근 보장 + 덤으로 상호배제까지 공짜.

UIKit은 메인 전용이다. @MainActor를 붙이면 컴파일러가 메인 밖에서의 접근을 막는다. 그리고 "한 번에 하나"가 자동으로 성립하므로, 버튼 탭 상태 같은 건 락도 actor도 필요 없다.

함정@MainActor 타입의 deinitnonisolated다 — 거기서 격리된 프로퍼티를 못 만진다. 그래서 취소는 prepareForReuse() 같은 명시적 지점에서 한다.

멘트"셀 컨트롤러는 UIImageView를 만지고 탭 상태를 들고 있어서 @MainActor입니다. 메인 격리가 곧 상호배제라 별도 락이 없습니다."

A4Sendable / @unchecked Sendable

"격리 경계를 넘어가도 안전한 값"이라는 컴파일러용 표시.

Swift 6는 격리 경계를 넘는 모든 값이 안전함을 증명하라고 요구한다. actor·값 타입·불변 final class는 자동으로 만족한다.

함정NSCache는 Sendable이 아니다. 문서에는 "스레드 안전"이라 써 있지만 그건 사람이 읽는 글이지 컴파일러가 읽는 표시가 아니다. 실제 에러:

error: stored property 'cache' of 'Sendable'-conforming class
       has non-Sendable type 'NSCache<NSURL, NSData>'

@unchecked는 에러를 지우는 마법이 아니라 "내가 책임지고 보증한다"는 서명이다. 근거 2개를 댈 수 있어야 붙인다: ① NSCache 자체가 스레드 안전 ② 우리 쪽에 가변 상태가 없다(let 하나뿐). 계측용 var hitCount를 하나라도 추가하면 보증이 깨지고 actor로 바꿔야 한다.

멘트"근거가 둘입니다. NSCache의 스레드 안전 보장, 그리고 우리 쪽에 가변 상태가 없다는 것. 카운터라도 추가하면 이 보증은 깨집니다."

A5sending 파라미터 — Task { }가 거절당할 때

Task { }의 클로저는 다른 실행 문맥으로 보내지는 값이라, 격리가 없으면 컴파일러가 안전을 증명하지 못한다.
error: Passing closure as a 'sending' parameter risks causing data races

클로저가 캡처한 것들이 원래 문맥과 새 Task 양쪽에서 동시에 쓰일 수 있기 때문. 해법: 타입에 @MainActor(또는 actor 격리)를 준다 — 그러면 "이 클로저는 메인에서만 돈다"가 증명된다.

함정실제로 겪은 에러다. 클래스에서 @MainActor를 빼먹었을 때 정확히 이 메시지가 났다. 메시지만 보면 원인이 안 보이는데, 범인은 빠진 격리다.

A6취소는 협조적이다 — 부탁이지 명령이 아니다

task.cancel()은 플래그를 켤 뿐, 실행을 강제로 멈추지 않는다.

강제 중단은 자원 정리 없이 죽는 것이라 위험하다. 그래서 Swift는 "멈춰달라고 표시"만 하고, 멈추는 건 코드가 직접 Task.isCancelled를 보거나 취소를 던지는 API(Task.sleep, URLSession.data)를 써야 한다.

함정취소해도 이미 도착한 응답은 못 막는다. 그래서 셀 재사용 방어는 취소 하나로 안 되고 신원 확인이 반드시 따라온다(D3).

멘트"취소는 부탁, 신원 확인은 검문입니다. 부탁이 늦게 도착할 수 있으니 검문이 필요합니다."

A7defer로 장부 청소 — 실패에도 반드시

스코프를 어떻게 빠져나가든 실행된다. 성공·throw·취소 전부.
private func startFetch(_ url: ImageURL) async throws -> Data {
  let task = Task<Data, Error> { … }
  inFlight[url] = task
  defer { inFlight[url] = nil }     // 넣은 자리 바로 밑 = 짝이 보인다
  return try await task.value
}

defer가 없으면 실패한 Task가 장부에 시체로 남는다. 이후 모든 요청이 그 실패를 영원히 되돌려받는다.

실측1회차 실패 → 2회차가 새 요청으로 나가 성공(네트워크 2회). defer를 지우면 2회차가 실패를 재사용한다.

함정선언 위치는 동작에 영향이 없지만(스코프 이탈 시 실행) 등록 아래에 둬야 읽는 사람에게 짝이 보인다.

A8static let — 지연 + 딱 한 번 + Sendable 강제

싱글톤의 재료. 잠금을 직접 걸 필요가 없다.

실측100개 Task가 동시에 접근해도 인스턴스는 1개. 그리고 처음 읽는 순간까지 생성조차 안 된다(앱 시작 시간을 안 먹는다).

Swift 6는 전역 공유 값이 Sendable임을 강제한다 — 즉 전역 싱글톤의 안전성을 컴파일러가 검사해준다.

B타입 설계 · 프로토콜"실서비스에 써도 될 수준"을 요구한 안내문에 직접 대응하는 그룹

B1struct / class / actor / enum 고르는 기준

고르는 것기준이 코드의 예
struct값이다. 복사돼도 의미가 안 변한다ImageURL — 같은 URL이면 같은 것
class정체성이 있다. 참조가 공유돼야 한다LRURecency.Node — 이웃이 그 노드를 가리켜야
actor가변 상태를 여러 곳에서 동시에 만진다ImageLoader — inFlight 보호
enum(케이스 없음)인스턴스가 될 이유가 없는 이름표ImagePipeline — 배선 모음

함정"동시성 = actor"가 아니다. @MainActor 클래스가 맞는 자리가 있다(UI 계열). 이미 스레드 안전한 걸 감싸는 actor는 손해다.

B2실패 가능 init — "파싱은 검증이 아니다"

잘못된 값을 조심해서 안 만드는 것이 아니라 만들 수 없게 하는 것.
struct ImageURL: Hashable, Sendable {
  let value: URL
  init?(_ string: String) {
    guard let url = URL(string: string),                 // ← 이것만으론 검증 아님
          let scheme = url.scheme?.lowercased(),
          scheme == "http" || scheme == "https",          // ftp·file 차단
          let host = url.host, !host.isEmpty
    else { return nil }
    self.value = url
  }
}

함정URL(string:)"이건 URL 아님" 같은 문자열도 통과시킨다. 파싱 성공 ≠ 안전한 URL. scheme·host 가드가 진짜 검증이다.

이 타입 아래의 모든 코드에 검증 코드가 0줄이 된다. 검증을 여기저기 흩뿌리는 대신 입구 한 곳으로 좁혔다.

멘트"URL(string:)은 거의 아무거나 통과시켜서 검증이 아닙니다. scheme과 host를 가드로 확인하고, 통과 못 하면 타입 자체가 생성되지 않게 했습니다."

B3프로토콜 = 테스트 심 + 교체 지점 (POP · DIP)

"무엇을 쓰는가"만 선언하고 "누가 해주는가"는 모른다.
protocol ImageDataFetching: Sendable { func fetch(_ url: ImageURL) async throws -> Data }
protocol ImageCaching: Sendable { /* value / insert */ }
protocol ImageLoading: Sendable { /* load / loadAll */ }

실측로더 코드를 한 글자도 안 고치고 가짜 네트워크(CountingFetcher)를 꽂아 중복 제거를 검증했고, 캐시 백엔드를 NSCache ↔ 자체 LRU로 양방향 교체해 타입체크를 통과시켰다.

멘트"세 경계는 테스트 심이자 데코레이터를 끼울 자리입니다. 재시도나 계측을 넣을 때 코어를 0줄 수정합니다."

B4인터페이스 분리 — CacheClearing을 왜 쪼갰나

메뉴판을 손님별로 나눈다. 커피만 마시는 손님에게 스테이크 조리법을 외우게 하지 않는다.

함정원래 MemoryWarningObserver(cache: ImageCache)구체 타입이었다. 캐시를 NSCache로 바꾸자 실제로 깨졌다:

error: cannot convert value of type 'NSCacheImageCache'
       to expected argument type 'ImageCache'

로더는 프로토콜에 의존해 안 깨졌지만 관찰자가 경계 밖으로 새어 있었다. 해법:

protocol CacheClearing: AnyObject, Sendable { func removeAll() async }

소비자가 다르다 — 로더는 removeAll을 한 번도 안 부른다. ② ImageCaching에 넣으면 비울 수 없는 캐시(번들에 박은 읽기 전용)가 conform 못 한다. ③ AnyObject를 붙인 건 관찰자가 [weak cache]로 잡으려면 클래스 바운드여야 하기 때문(actor도 참조 타입이라 OK).

멘트"교체 가능하다고 주장만 하지 않고 실제로 바꿔봤더니 관찰자에서 깨졌습니다. 그래서 CacheClearing으로 분리했고, 지금은 양방향 교체가 타입체크를 통과합니다."

B5some vs any

some Pany P
결정 시점컴파일 타임에 하나로 고정런타임에 갈아끼울 수 있는 상자
비용직접 호출(빠름)간접 호출(약간 느림)
전염성저장하면 타입 전체가 제네릭이 됨없음

멘트"저장하고 갈아끼우는 의존성이라 any가 도구입니다. some으로 저장하면 타입 전체가 제네릭이 되어 호출부까지 전염됩니다. 간접 호출 비용은 네트워크 옆에선 소음이고요."

B6프로토콜 확장 기본 구현의 디스패치 함정

확장에만 쓰고 프로토콜 본문에 선언하지 않으면, 구현체가 재정의해도 안 불린다.
protocol ImageLoading: Sendable {
  func load(_ url: ImageURL) async throws -> Data
  func loadAll(_ urls: [ImageURL], maxConcurrent: Int) async -> [Data?]  // ★ 선언 필수
}
extension ImageLoading { /* 기본 구현 — load만 써서 만든다 */ }

프로토콜 본문에 있으면 동적 디스패치(구현체 것이 불림), 확장에만 있으면 정적 디스패치(선언된 타입 것이 불림). any ImageLoading으로 받아 부르는 순간 차이가 드러난다.

B7추상화를 만들지 않는 기준

타입 보장은 그걸 소비하는 층까지만. 소비자가 없으면 의식(ceremony)만 남는다.

프로토콜을 안 만든 것들과 이유:

  • ImageCellController — 소비자가 없고 render 클로저가 이미 경계다
  • LRURecency — 구현이 하나뿐. 두 번째가 생기면 그때 만든다
  • Throttler와 시간 기반 게이트 — 시그니처가 달라 억지로 묶으면 언래핑 의식만 남는다

멘트"추상화는 갈아끼울 소비자가 생길 때 만듭니다. 지금 넣으면 재사용도 못 하고 테스트마다 생성 의식만 늘어납니다."

B8"위에서 ImageURL 만들었는데 왜 아래는 URL이죠?" — 양방향 답

판단 축은 둘이다. ① 안쪽에 보장을 소비하는 동작이 있는가 ② 우회 위험이 큰 코드베이스인가.

현재 채택안: 요청이 다니는 공개 API(load·loadAll·configure)는 ImageURL로 받고, 플랫폼 접점(URLSession·NSCache 키)에서만 url.value로 벗는다. 벗는 곳이 한 구역이다.

상황판단이유
인프라가 Hashable 키만 필요URL로 벗는다아바타·설정 JSON도 같은 캐시를 재사용 가능
인프라가 host보장을 실제로 씀
(예: CDN 샤딩)
ImageURL 전파비-Optional host가 "host가 nil이면?" 분기를 소멸시킴
보장을 안 쓰는 층에 강요❌ 반례재사용 불가 + 테스트마다 생성 의식 + 동작은 1비트도 안 변함

멘트"판단 축은 둘입니다. 안쪽에 보장을 소비하는 동작이 있으면 전파하고, host 같은 증명을 값으로 실어 나릅니다. 우회 위험이 큰 코드베이스면 소비가 없어도 출입 통제 목적으로 전파합니다. 이 코드는 둘 다 아니라 경계 한 곳으로 좁혔고, 반대로 전파하는 설계도 정당합니다."

함정보장을 안 쓰는 층에 타입을 강요하면 의식(ceremony)만 남는다 — 어차피 NSCache·URLSession과 만나는 순간 .value로 벗어야 한다. B7과 같은 원칙.

C캐시안내문 "실습 추천"이 NSCache를 지목했다 — 기본은 NSCache, 자체 LRU는 팔로업

C0★ 캐시 선택 규칙 — 무엇부터 꺼낼 것인가

안내문은 목록을 둘로 나눠 썼다:

목록내용
"개념적으로 도움됩니다"LRU Cache, Concurrency, Task Scheduling배경 지식
"실습도 추천드립니다"NSCache 기반 이미지 캐싱 구현, async/await 이미지 로딩, 셀 재사용 최적화손으로 할 과제

시나리오 표현도 "메모리 캐시 관리"이지 "LRU 캐시 구현"이 아니다. ⇒ 기본은 NSCache.

더 실전적인 이유: 캐시는 시나리오 4개 중 하나다(비동기 다운로드 · 셀 재사용 · 캐시 · MVVM). 여기서 이중 연결 리스트를 손으로 짜면 15~20분이 사라지고 점수 비중이 큰 나머지를 못 끝낸다. NSCache는 12줄이다.

자체 LRU로 갈아타는 신호는 정확히 셋 — 없으면 NSCache를 유지한다:

  1. "LRU"라는 단어가 요구에 직접 등장
  2. 축출 순서·시점을 테스트로 증명하라는 요구
  3. O(1) 복잡도 논증을 명시적으로 요구

멘트"지금은 NSCache로 갑니다 — 메모리 압박 자동 대응을 공짜로 얻으니까요. 다만 축출 순서는 문서화된 보장이 없어서 LRU가 요구사항이면 증명할 수 없습니다. 그 요구가 있다면 여기서 자체 구현으로 교체하겠습니다 — ImageCaching 경계라 로더는 한 줄도 안 바뀝니다."

C1NSCache 동작 원리 + 실측

딕셔너리처럼 쓰지만, 언제든 사라질 수 있는 딕셔너리.
final class NSCacheImageCache: ImageCaching, CacheClearing, @unchecked Sendable {
  private let cache = NSCache<NSURL, NSData>()
  init(countLimit: Int = 100, totalCostLimit: Int = 50 * 1024 * 1024) {
    cache.countLimit = countLimit
    cache.totalCostLimit = totalCostLimit
  }
  func value(for url: ImageURL) -> Data? {
    cache.object(forKey: url.value as NSURL).map { Data(referencing: $0) }
  }
  func insert(_ data: Data, for url: ImageURL) {
    cache.setObject(data as NSData, forKey: url.value as NSURL, cost: data.count)
  }
  func removeAll() { cache.removeAllObjects() }
}

실측(macOS 26.1 / Swift 6, 직접 측정)

실험결과
countLimit 100에 200개 삽입정확히 100개 생존
costLimit 1000B에 300B×10 삽입3개 생존 (3×300=900 ≤ 1000)
값만 같은 별개 NSURL로 조회히트 → 포인터가 아니라 isEqual 비교
한도 10, 0~9 채우고 0번만 50회 조회 후 10~14 삽입생존 [0, 6,7,8,9, 10~14]0번이 살아남음

함정이 숫자를 "보장"이라고 말하면 감점이다. 문서는 상한을 "엄격한 제한이 아니라 힌트"라 하고, 축출 순서아예 언급하지 않는다. 측정된 건 "이 OS·이 버전의 구현"이지 API 계약이 아니다.

멘트"측정해보면 LRU처럼 동작하지만 문서화된 보장이 아니라서, 요구사항이 LRU면 의존할 수 없습니다."

읽을 때 헷갈리는 곳프로토콜은 async인데 위 구현은 동기다. Swift는 동기 함수가 async 요구사항을 만족하도록 허용한다. 프로토콜을 async로 둔 건 디스크 캐시 같은 느린 구현을 허용하기 위해서고, 메모리 구현은 즉시 반환하므로 실제로 중단되지 않는다. async로 선언해도 동일하게 동작한다 — 프로토콜을 통해 부르면 차이가 0이다.

C2cost:를 빼먹으면 바이트 상한이 죽는다

안 넘기면 0으로 취급totalCostLimit이 영원히 발동하지 않는다.

100장을 넣어도 비용 합이 0이라 50MB 한도가 안 걸리고 개수 제한만 남는다. 실무에서 자주 나는 조용한 버그. 이미지는 data.count(바이트 수)가 가장 자연스러운 비용 단위다.

실측costLimit 1000B에 300B를 10개 넣어 3개만 생존한 것이 cost:가 실제로 전달됐다는 증명이다. 안 넘겼으면 10개가 다 살아남는다.

C3NSData 브리지 vs 복사 — 441배

data as NSData(껍데기만 씌움) vs NSData(data:)(바이트를 통째로 베낌).

실측300KB 썸네일 × 2000회

방식시간
NSData(data: data) — 복사40.7 ms
data as NSData — 브리지0.1 ms
441배 / 왕복 값 일치 확인됨

Data는 값 타입이고 캐시에 들어간 뒤 안 바뀌니 베낄 이유가 없다. insert는 스크롤 중 계속 불린다 — 셀 하나 지날 때마다 300KB 복사가 붙으면 프레임을 갉아먹는다.

조회 방향의 Data(referencing:)도 같은 이유(지연 참조)다. 양방향 모두 복사를 피한다.

C4LRU = 딕셔너리 + 이중 연결 리스트 → O(1)

딕셔너리는 "어디 있나"를, 리스트는 "누가 오래됐나"를 안다. 둘을 합쳐야 O(1).
final class LRURecency {
  private final class Node { let url: URL; weak var prev: Node?; var next: Node? }
  func recordUse(_ url: URL)   // 삽입·조회·갱신이 전부 여기로 수렴
  func popOldest() -> URL?
}

실측배열 O(n) 버전 0.538s → dict+DLL O(1) 버전 0.0079s = 68배.

함정"조회도 사용이다." value(for:)에서 recordUse를 안 부르면 자주 읽는 항목이 오래된 것으로 취급돼 먼저 쫓겨난다. 이 한 줄을 깨는 변이 테스트로 검증했다.

C4bLFU + EvictionPolicy 경계 — 정책을 갈아끼운다

LRU와 LFU는 무엇을 "가치 있다"고 보는가가 다르다.
믿는 것강한 곳
LRU"방금 본 건 곧 또 볼 것이다" — 최근성되돌아보는 스크롤
LFU"여러 번 본 건 앞으로도 볼 것이다" — 빈도인기 편중이 뚜렷하고 오래갈 때

경계이 파일의 원칙은 "소비자가 생길 때 추상화한다"(B7)였고, 그래서 LRURecency는 오랫동안 프로토콜이 없었다. 두 번째 구현이 생긴 지금이 정확히 그 시점이다. ImageCache가 쓰는 것만 넣는다(B4 인터페이스 분리):

protocol EvictionPolicy: AnyObject {
  func recordUse(_ url: URL)      // 삽입·조회·갱신이 전부 여기로 수렴
  func popVictim() -> URL?        // "다음에 누구를 버릴 것인가"
}
// LRURecency는 1줄만 추가 — popOldest는 LRU 고유 어휘라 그대로 남긴다
func popVictim() -> URL? { popOldest() }

LFU 자료구조get/put 모두 O(1). 스캔이 없다.

nodes[url]     → Node (freq 보유)
buckets[freq]  → 같은 빈도끼리의 이중 연결 리스트 (head=오래된, tail=최근)
minFreq        → 현재 최저 빈도

축출 = buckets[minFreq].head 하나 꺼내면 끝

★ 동률 타이브레이크가 LRU다. 같은 빈도가 여럿이면 그중 가장 오래된 것을 버린다 — 버킷 안이 이중 연결 리스트인 이유가 이것. 실측x,y,z 전부 freq 1 → 희생자 x(가장 먼저 들어옴).

주입은 sending장부는 스레드 안전하지 않은 class다. actor 안으로 넣으려면 격리 경계를 넘는데 — Sendable로 표시하면 거짓말이고, @unchecked는 근거를 못 댄다. sending"소유권을 넘긴다"는 선언이라, 호출부는 넘긴 뒤 더 못 만지고 actor가 유일한 소유자가 된다. 거짓 서명 없이 안전을 얻는 방법이고, 컴파일러가 "넘긴 뒤 또 쓰는지"를 검사해준다.

init(countLimit: Int = 100, costLimit: Int = 50 * 1024 * 1024,
     policy: sending any EvictionPolicy = LRURecency())

실측같은 입력, policy 인자만 교체 (한도 3):
a 삽입 후 5번 사용 → b 삽입 → c 삽입 → d 삽입(초과)

LRU 생존: [b, c, d]   ← a 탈락. 5번 썼어도 가장 오래 안 썼으니
LFU 생존: [a, c, d]   ← b 탈락. a는 freq 5라 안전

ImageCache·ImageLoader·셀은 한 글자도 안 바뀐다.

LFU 고유 함정캐시 오염(cache pollution). 어제 100번 조회된 이미지가 오늘 아무도 안 봐도 횟수가 높아서 안 나간다. 반대로 방금 들어온 신선한 항목은 freq 1이라 바로 쫓겨난다 — 과거의 인기가 자리를 눌러앉는다. 그래서 실무 LFU는 에이징(횟수 주기적 반감)이나 윈도우를 붙인다(W-TinyLFU 계열).

멘트"이미지 썸네일은 '방금 본 걸 되돌아보는' 접근 패턴이라 대개 LRU가 낫습니다. LFU는 캐시 오염 때문에 에이징 없이는 위험하고요. 그래도 정책은 EvictionPolicy 경계로 분리해서, 워크로드를 측정한 뒤 인자 하나로 바꿀 수 있게 뒀습니다."

"둘 다 만들었다"보다 "왜 기본이 LRU인지"를 말하는 것이 점수다.

C5weak var prev — 이중 연결 리스트의 누수 차단

A→B(next 강함) + B→A(prev 강함) = 서로 붙잡아 둘 다 영원히 안 죽는다.

Swift는 참조 세는 방식으로 메모리를 정리한다. 두 노드가 서로를 강하게 잡으면 카운트가 0이 되지 않아 리스트 전체가 누수된다. 한쪽 방향(prev)만 weak로 만들면 고리가 끊긴다.

함정next를 weak로 하면 안 된다 — 리스트를 앞에서 붙잡는 유일한 강한 사슬이 사라져 노드들이 즉시 사라진다. 진행 방향은 강하게, 되돌아가는 방향은 약하게.

C6이중 상한 + 갱신 환불 회계

개수(countLimit)와 바이트(costLimit) 둘 다 걸어야 한다.

개수만 걸면 4K 이미지 100장에 메모리가 터지고, 바이트만 걸면 아주 작은 이미지가 무한정 쌓여 딕셔너리 자체가 커진다.

함정같은 키를 갱신할 때 이전 비용을 빼야 한다.

totalCost -= storage[url]?.count ?? 0   // ★ 환불. 없으면 비용이 부풀어 과잉 축출

이 줄을 지우는 변이 테스트로 "체크에 이빨이 있는지" 확인했다.

C7메모리 경고 vs OOM(jetsam) — 감지가 아니라 예방

메모리 경고OOM (jetsam)
상태압박이 생기는 중이미 한계 초과
알림didReceiveMemoryWarning 받음없음 — 통보 없이 죽는다
deinit정상안 불림
크래시 리포트해당 없음안 남음 (크래시가 아니라 강제 종료)

그래서 MemoryWarningObserver는 OOM을 감지하는 게 아니라 예방한다 — 경고 단계에서 비워 한계에 도달하지 않게 한다. NSCache는 이걸 공짜로 해준다(OS 신호를 받아 스스로 비움). 자체 구현이 NSCache 대비 잃는 건 이것 하나이고, 알림 구독 5줄로 메운다.

OOM은 사후 추정만 가능: "크래시 아님 + 정상종료 아님 + 포그라운드였음"이면 의심(Crashlytics 방식), 실제 집계는 MetricKit MXAppExitMetric.cumulativeMemoryResourceLimitExitCount.

D이미지 파이프라인안내문 시나리오 1·2번 직결 — "비동기 다운로드"와 "셀 재사용 깜빡임 방지"

D1싼 것부터 — 캐시 → 진행 중 합류 → 네트워크

func load(_ url: ImageURL) async throws -> Data {
  if let cached = await cache.value(for: url) { return cached }        // ① 0원
  if let running = inFlight[url] { return try await running.value }    // ② 무임승차
  return try await startFetch(url)                                  // ③ 실제 비용
}

순서가 곧 비용 순서다. 세 줄로 정책이 드러나므로 설명하기 쉬운 코드이기도 하다.

D2in-flight 중복 합류 ★

같은 URL 요청이 이미 날아가고 있으면, 새로 쏘지 않고 그 결과에 합류한다.

실측같은 URL을 동시에 10번 요청 → 네트워크 1회. 완료 후 재요청 → 여전히 1회(캐시 히트).

스크롤이 빠르면 같은 썸네일 요청이 겹쳐 들어온다. 합류가 없으면 네트워크·메모리·배터리를 셀 수만큼 낭비한다.

함정성립 조건은 검사와 등록 사이에 await가 없는 것(A2). 그리고 defer로 장부를 비워야 실패 후 재시도가 된다(A7).

D3셀 재사용 방어 3겹

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)
    guard !Task.isCancelled, self.currentURL == url else { return }  // ③ 신원 확인
    self.render(data)
  }
}
막는 것못 막는 것
cancel()아직 안 나간/도는 요청이미 도착한 응답
isCancelled 사전 체크취소됐는데 시작하려는 작업시작 후 도착한 늦은 응답
currentURL == url늦게 도착한 남의 이미지

취소는 부탁이고(A6) 신원 확인은 검문이다. 부탁이 늦어 응답이 이미 도착했을 수 있으므로 검문이 반드시 필요하다.

함정currentURL = url= nil로 잘못 쓰면 이미지가 영영 안 나온다(③에서 항상 탈락). 등록(configure)과 해제(prepareForReuse)를 헷갈리기 쉬운 자리다.

D4슬라이딩 윈도우 — 동시 N개 제한

"하나가 착륙해야 하나가 이륙한다." 첫 N개를 띄우고, 끝날 때마다 하나씩 채운다.
extension ImageLoading {
  func loadAll(_ urls: [ImageURL], maxConcurrent: Int = 3) async -> [Data?] {
    // TaskGroup: 첫 N개 addTask → group.next()로 하나 회수할 때마다 addTask 하나
  }
}

실측5개를 창 2로 → 성공 5개, 동시 최대 2.

전부 한꺼번에 띄우면 대역폭을 나눠 먹어 모든 이미지가 다 같이 늦게 뜬다. 제한하면 위에서부터 순서대로 빨리 채워진다. 서버 rate limit 대응도 된다.

D5★ 깜빡임 원인 12가지 — 3층으로 묶어 말한다

"셀 재사용 깜빡임 방지"는 시나리오 2번이다. 12개를 나열하지 말고 층으로 답한다.
원인우리 상태
파이프라인① 이전 행 이미지 잔상render(nil) + prepareForReuse
늦게 도착한 응답이 덮어씀currentURL == url 신원 확인
③ 취소 안 함 → 응답 줄줄이 도착task?.cancel()
⑧ 프리페칭 없음 → 항상 빈칸 먼저🔶 탄창loadAll이 그대로 꽂힘
캐시⑤ 캐시 히트인데 빈 프레임이 한 번 지나감❌ 말로만 (3줄이면 즉석 수정)
메모리 경고로 캐시 소실 → 재다운로드🔶 자리만 (2단 캐시 = 메모리+디스크)
렌더링메인에서 지연 디코딩⑤C 다운샘플링 (render 클로저만 교체)
⑩ 오프스크린 렌더링 (cornerRadius+masksToBounds)❌ 말로만
⑪ 즉시 교체(크로스페이드 없음)❌ 말로만
레이아웃·
데이터소스
reloadData 남발 / 페이드 애니메이션✅ Diffable DataSource
⑨ 이미지 도착으로 셀 높이 점프🔶 부분 (ThumbnailCell 고정 크기 뷰)
⑫ 셀 타입·reuseID 혼용preconditionFailure로 즉사

핵심 셋 취소는 이미 도착한 응답을 못 막는다 → 신원 확인 필수. render(nil)은 즉시, render(data)는 아무리 빨라도 다음 런루프 → 캐시에 있어도 빈칸이 한 번 보인다. NSCache의 자동 축출은 OOM을 막아주지만, 스크롤 중 축출되면 방금 본 이미지도 다시 받는다 — 장점이 곧 깜빡임 원인이다.

함정디코딩 층에도 재사용 방어가 다시 필요하다. A를 디코딩하는 중 셀이 B로 재사용되면, 늦게 끝난 A가 B 자리에 그려진다 — 파이프라인 층에서 막은 버그를 렌더링 층에서 다시 여는 것. ⑤C는 이전 디코딩을 취소하고 늦게 끝난 건 폐기한다. 실측그리고 bounds.size.zeromaxPixelSize=0이 되는데, CGImageSource는 에러도 nil도 아니고 원본 크기를 그대로 반환한다(64×64 → 64×64 확인) — 다운샘플링이 조용히 무력화된다. 그래서 목표 크기를 명시로 받는다.

멘트"깜빡임은 층이 세 개입니다. 파이프라인 층 — 잔상·늦은 응답·취소 누락은 취소와 신원 확인 2겹으로 막았습니다. 캐시 층 — 히트인데도 빈 프레임이 지나가는 것, 메모리 경고로 캐시가 비워져 다시 받는 것. 렌더링 층 — 메인에서 일어나는 지연 디코딩, 오프스크린 렌더링. 지금은 파이프라인 층과 디코딩을 막았고, 나머지는 어디를 고칠지 명확합니다."

E시간 축 정책안내문 "Task Scheduling" 개념에 해당 — 언제 하고, 언제 버리고, 언제 포기하나

E1디바운스 — "마지막이 진짜"

입력이 조용해질 때까지 기다렸다가 마지막 것만 실행한다.

사과사과주를 칠 때 마지막 글자가 사용자의 의도다. 중간 것은 지나가는 과정일 뿐이다. 구현의 전부는 이전 예약 취소 한 줄(pending?.cancel()) — 셀의 task?.cancel()과 같은 근육이다.

실측10연타 직후 [](아무것도 안 함) → 200ms 뒤 [10].

함정[weak self]를 빼면 Task가 디바운서를 붙잡아 화면이 안 사라진다.

E2스로틀 — "첫 번째가 진짜"

첫 것을 즉시 실행하고, 창(interval) 동안 나머지를 버린다.
@MainActor final class Throttler {
  private let interval: Duration
  private let clock = ContinuousClock()
  private var lastFired: ContinuousClock.Instant?
  @discardableResult
  func perform(_ action: () -> Void) -> Bool {
    let now = clock.now
    if let last = lastFired, now - last < interval { return false }
    lastFired = now; action(); return true
  }
}

실측10연타 → [1](첫 것만), 첫 실행까지 0.001ms(지연 없음). 창이 지나면 다시 열림.

언제 실행무엇을 버리나전형적 자리타이머
디바운스조용해진 중간 것 전부검색어 입력필요
스로틀첫 것 즉시창 안의 나머지버튼·셀 연타불필요

함정 ①버튼에 디바운스를 달면 안 된다. 누르고 0.5초 뒤에 반응하면 사용자는 "렉 걸렸다"고 느끼고 더 누른다 — 문제를 악화시킨다.

함정 ②Date 대신 ContinuousClock을 쓴다. Date는 시스템 시간 변경·NTP 동기화로 뒤로 점프할 수 있어 창이 영원히 안 열리거나 항상 열린다. ContinuousClock은 단조 증가가 보장된다.

함정 ③시간 기준이라 작업이 창보다 길면 뚫린다. 실측: 500ms 작업 + 300ms 창 → 350ms 시점에 두 번째가 통과. 대응은 창을 넉넉히 잡거나, 시간이 아니라 "진행 중인가"로 막는 것(= D2와 같은 근육).

Bool을 돌려주는 이유: 셀에서 삼켰을 때 deselectRow로 하이라이트를 되돌려야 한다. 안 그러면 눌린 채 멈춰 "먹통"으로 보인다.

E2bextension으로 붙일 수 있나 — 버튼은 O, 셀은 X

UIControl은 된다. Swift extension은 저장 프로퍼티를 못 넣지만, 게이트를 클로저가 캡처하게 하면 필요가 없다 — UIControl이 action을 retain하므로 수명이 자동으로 맞는다.

extension UIControl {
  func addAction(throttledBy interval: Duration, _ handler: @escaping @MainActor () -> Void) {
    let gate = Throttler(interval: interval)
    addAction(UIAction { _ in gate.perform(handler) }, for: .touchUpInside)
  }
}

셀은 안 된다셀은 재사용되므로 게이트가 콘텐츠가 아니라 화면 조각에 붙는다.

실측재사용 풀 2개에서 row 0 탭 직후 row 1·2 연타:

셀 소유 → row0 열림 / row1 열림 / row2 차단   ← 무엇이 갈랐나?
                                         "어느 셀 인스턴스를 재사용했나"뿐
VC 소유 → row0 열림 / row1 차단 / row2 차단   ← 규칙이 하나: 0.5초에 한 번

사용자에겐 스크롤 위치에 따라 결과가 바뀌는 재현 불가 버그로 보인다. D3에서 막은 "재사용된 셀이 남의 상태를 물려받는" 버그와 같은 종류다. 게다가 셀은 선택을 처리하지도 않는다(didSelectRowAt은 VC에 있다).

E3타임아웃 — 작업 vs 시계의 경주

작업과 타이머를 같이 띄우고, 먼저 끝난 쪽을 채택한다.
group.cancelAll()   // ★ 진 쪽 정리 — 없으면 좀비 Task가 남는다

함정① 타임아웃은 '포기'다 — 느린 네트워크 사용자를 버리는 정책이 될 수 있다. ② Task.sleep 정밀도는 OS 스케줄러에 달려 ms 단위 정확도를 기대하면 안 된다. ③ 재시도와의 순서: 시도마다 타임아웃(권장)인지, 전체에 하나인지 결정해야 한다.

E4재시도 + 지수 백오프 (데코레이터)

실패를 무조건 다시 하지 않는다 — 재시도할 가치가 있는 실패만.

401은 1회, 5xx는 3회. 인증 실패는 다시 해도 똑같이 실패한다(서버만 때린다). 일시적 서버 오류는 잠시 뒤 성공할 수 있다.

데코레이터의 값어치: ImageDataFetching 자리에 끼우므로 로더·캐시·셀 코드를 0줄 고친다. 중첩도 된다 — Retrying(Instrumented(URLSession())). 끄고 싶으면 감싸지 않으면 그만이라 조건문이 코드에 안 남는다.

함정① 재시도는 지연을 늘린다 — 3회 × 백오프면 사용자는 그만큼 더 기다린다. ② 멱등하지 않은 요청(POST 결제)에 붙이면 중복 실행 위험. 이미지 GET이라 안전하다.

E5SWR (stale-while-revalidate)

캐시된 옛 값을 즉시 보여주고, 새 값이 오면 나중에 갱신한다.

함정① 화면이 두 번 바뀌어 깜빡임으로 보일 수 있다(같은 값이면 yield 생략 필요). ② 캐시가 없으면 이점이 0 — 첫 방문자는 그냥 로딩을 본다. ③ "얼마나 낡은 것까지 허용하나"는 별개 문제 → TTL과 조합해야 완성된다.

AsyncStreamonTermination에서 task.cancel()을 걸어 구독자가 떠나면 네트워크도 취소한다.

E6페이지네이션 — isLoading 가드

스크롤 이벤트가 여러 번 와도 같은 페이지를 두 번 안 부른다.

성립 조건이 A2와 같다 — await 앞의 동기 구간이 원자적이므로 "확인하고 플래그 세우기"가 끊기지 않는다.

함정가드를 await 뒤로 옮기면 깨진다. 그리고 실패해도 page를 안 올리므로 다음 호출이 같은 페이지를 재시도한다(의도된 동작).

FUIKit 연결"UITableView로 직접 붙여보세요" 요구 대응

F1★ 로더 소유권 — 셀이 아니라 앱 레벨

최대 함정: 로더를 셀마다 만들면 캐시가 셀 수만큼 쪼개진다.
enum ImagePipeline {
  static let sharedCache = NSCacheImageCache()
  static let shared: any ImageLoading = ImageLoader(fetcher: URLSessionFetcher(), cache: sharedCache)
}
// 호출부는 기본값으로 받는다 — 앱은 편하고, 테스트는 가짜를 주입한다
init(urls: [ImageURL], loader: any ImageLoading = ImagePipeline.shared)

캐시와 in-flight 장부는 앱 전체에서 하나여야 의미가 있다. 화면마다 로더를 만들면 같은 이미지를 화면 수만큼 다시 받는다.

함정cache: 인자를 빼먹고 ImageLoader(fetcher:)만 쓰면 기본값으로 새 캐시가 조용히 생겨 캐시가 쪼개진다. 반대로 테스트·데모는 격리가 필요하므로 직접 만드는 게 맞다.

F2UIKit 매핑 3줄

개념UIKit 자리
render 클로저{ imageView.image = UIImage(data: $0) }
configure(with:)cellForRowAt에서 호출
취소prepareForReuse() override

Diffable DataSource를 써도 cellForRowAt에 해당하는 자리는 있다CellRegistration의 handler가 그 역할이다. 이름만 다르고 "셀에 데이터를 꽂는 지점"은 동일하다.

F3수명 앵커 — 읽지 않는 프로퍼티를 지우면 안 되는 이유

값을 쓰려고 들고 있는 게 아니라, 살아있게 하려고 들고 있다.
private var memoryObserver: MemoryWarningObserver?   // 읽는 곳이 없다. 그래도 필요하다

실측안 붙잡으면 즉시 해제, 붙잡으면 살아있음. 해제되면 deinit이 옵저버를 제거해 메모리 경고를 못 받게 된다.

NotificationCenter.addObserver토큰을 버리면 제거할 방법이 없고 중복 등록도 막지 못한다. 그래서 토큰을 소유하는 타입을 만들고 deinit에서 제거한다.

GMVVM · 상태안내문 시나리오 4번 — "MVVM 구조로 상태 바인딩"

G1상태를 enum으로 — Data?로는 부족하다

enum CellImageState { case idle, loading(ImageURL), loaded(ImageURL, Data), failed(ImageURL) }

Data? 하나로는 placeholder·성공·실패가 전부 nil로 뭉개져 로딩 스피너도 재시도 버튼도 못 만든다. 상태를 타입으로 나누면 화면이 표현할 수 있는 경우가 컴파일러에게 드러난다.

함정코드량이 단순 버전의 2배다. 상태 구분이 필요 없으면 과한 도구다.

G2AsyncStream 바인딩 — Rx를 아는 사람을 위한 지도

RxSwiftSwift Concurrency
ObservableAsyncStream
onNext(v)continuation.yield(v)
bind(to:) / subscribefor await v in stream
DisposeBag구독 Taskcancel()

함정AsyncStream단일 구독 전제다. 여러 뷰가 동시에 구독하면 값이 나뉜다. SwiftUI라면 @Observable(iOS 17+)이 더 자연스럽다.

G3취소는 실패가 아니다

catch is CancellationError { return }   // .failed로 만들지 않는다

스크롤로 지나간 셀은 취소된 것이지 실패한 게 아니다. 구분하지 않으면 화면이 에러 아이콘·재시도 버튼으로 오염된다. catch 순서에서 CancellationError를 먼저 잡아야 한다.

H배선 · 싱글톤"무엇을 만드는가"와 "앱에서 어떻게 연결하는가"의 분리

H1왜 class가 아니라 케이스 없는 enum인가

실측ClassicPipeline()통과해버린다(private init()을 깜빡했으므로). EnumPipeline()은:

error: 'EnumPipeline' cannot be constructed because it has no accessible initializers

private init() {}사람이 기억해서 써야 하는 방어벽이다. 케이스 없는 enum은 문법이 막는다. B2 ImageURL과 같은 사고방식 — 규율을 타입으로 바꾼 것.

그리고 ImagePipeline 자신은 값이 아니라 이름표다. 하나뿐인 건 그 안의 캐시와 로더다.

H2Composition Root — 부품과 배선의 분리

ImageLoader / URLSessionFetcher / NSCacheImageCache   = 부품 (몇 개든 만들 수 있음)
                    ↓ 조립
ImagePipeline                                   = 배선도 (앱에서는 이렇게 연결한다)

ImageLoader.shared처럼 타입 안에 넣으면 "이 타입 = 앱의 그 하나"라는 인상이 생겨 부품으로서의 성격이 흐려진다. 조립 지식을 한 곳에 모으면 캐시 백엔드 교체가 한 줄이다.

함정전역 접근점은 숨은 의존성 위험이 있다. 그래서 호출부는 생성자 기본값으로 받는다 — 의존성이 시그니처에 드러나 숨겨지지 않고, 테스트는 가짜를 넘겨 전역을 우회한다.

I복잡도 · 실측 총괄평가 요소 "시간/공간 복잡도"에 숫자로 답하기

I1연산별 복잡도

연산시간근거
캐시 조회 / 삽입O(1)딕셔너리 + 연결 리스트 갱신
LRU 축출(popOldest)O(1)꼬리 노드 직접 제거
in-flight 조회O(1)딕셔너리
load 캐시 히트O(1)네트워크 0회
loadAllO(n), 동시 ≤ N슬라이딩 윈도우
스로틀 판단O(1) / 할당 0시각 비교 한 번
공간O(캐시 항목 수) — countLimit·costLimit로 상한이 걸림

I2측정한 숫자 모음 (전부 이 저장소에서 직접 측정)

무엇결과의미
LRU 배열 O(n) → dict+DLL O(1)0.538s → 0.0079s = 68배자료구조 선택의 값어치
NSData(data:)as NSData40.7ms → 0.1ms = 441배브리지 vs 복사 (300KB×2000)
중복 합류 (동시 10회)네트워크 1회in-flight 장부의 값어치
합류 구간에 await 삽입1회 → 10회불변식의 증거 (변이 테스트)
스로틀 첫 실행 지연0.001msleading edge 증명
static let 동시 100 접근인스턴스 1개lazy + once 보장

멘트"배열 버전을 먼저 만들고 측정했더니 0.538초였습니다. dict + 이중 연결 리스트로 바꾸니 8ms, 68배 차이가 났습니다."추측이 아니라 측정을 말하는 것이 평가 항목 "디버깅 및 테스트"에 직결된다.

J면접 진행코드가 아니라 점수가 걸린 부분 — 리크루터가 "전략 설명 필수"라고 명시했다

J1★ 오프닝 리추얼 — 첫 60초

① 요약 (15초) — 문제를 내 말로 되돌려주기
"정리하면, 스크롤되는 리스트에서 각 셀이 서로 다른 이미지를 비동기로 받아오고, 같은 이미지는 두 번 받지 않으며, 메모리를 일정 한도로 유지해야 한다 — 맞을까요?"

② 질문 (20초) — 2개. 나머지는 가정으로 선언하고 진행한다.

③ 전략 선언 (25초)구멍 뚫린 틀로. 암기 문장은 동문서답 폭탄이다.
"핵심 난점은 [문제에서 찾은 것] → 그래서 [그 난점을 푸는 구조] → 최소 동작 먼저, [확장 지점]은 표시만 하겠습니다."

구조는 종이가 아니라 에디터 맨 위 주석으로 (화상이면 종이는 안 보인다):
// 구조: Loader → Cache → CellController // 순서: 1)최소 2)취소 3)LRU(요구 시)

코딩 중 30초마다 한 마디. 침묵이 가장 위험하다.

함정지문이 영어다. ①(소리 내어 요약)이 존재하는 이유가 이것이다 — 오해를 60초 만에 잡는다.

J2질문 은행 — 2~3개 골라 쓰기

  • 캐시 상한은 개수 기준인가요, 바이트 기준인가요?
  • 네트워크는 실제 URLSession인가요, fake로 대체해도 되나요?
  • 화면 이탈 시 취소인가요, 완주인가요?
  • 동시 중복 요청을 합쳐도 될까요?
  • 재시도 요구가 있나요?
  • UIKit 없이 로직만 봐도 될까요?
  • "이 세션은 몇 분인가요?"

J3중계 패턴 · 대전략

중계 문장 틀: "지금 ~하는 이유는" / "A vs B인데 B로, 왜냐면" / "TODO 표시만 하고 진행하겠습니다" / 방향 전환 시 "접근을 바꾸겠습니다, ~때문에"

막히면 물어라. 면접관이 인간 문서다 — "data(from:) 맞나요?" 시그니처를 못 외운 건 감점이 아니지만 멈춰 있는 건 감점이다. print 디버깅도 당당하게.

⚔️ 대전략: 준비물은 재생 목록이 아니라 탄창이다.
"난이도는 LeetCode Mid, 실전에서는 그 이상 체감" = 면접관이 요구사항을 계속 추가하는 구조. → 시작은 문제가 요구하는 최소로. 확장 지점은 입으로만 예고한다: "지금은 NSCache로 갑니다 — LRU 요구가 오면 여기가 교체 지점입니다." 팔로업이 오면 그때 부품을 꺼낸다. 처음부터 풀 아키텍처를 깔면 = 오버엔지니어링 + "외워왔네" + 시간 폭발.

K검증 · 판정기 대응평가 요소 "디버깅 및 테스트" 직결 — HackerRank엔 XCTest가 없다

K1테스트 프레임워크 없이 증명하기

HackerRank 판정기에는 XCTest가 없다고 가정한다. print가 판정기다.
enum T {                                  // 10줄짜리 하네스
  nonisolated(unsafe) static var pass = 0, fail = 0   // Swift 6 전역 통과 열쇠
  static func eq<V: Equatable>(_ a: V, _ b: V, _ m: String) { … }
}

"돌아간다"와 "돌아간다는 걸 보였다"는 다르다. 기대값을 같이 출력하면 면접관이 눈으로 채점할 수 있다:

[1] 동시 10회 요청 → 네트워크 1회  (기대 1)     ← 기대값을 나란히

멘트"테스트 프레임워크가 없어서 print로 기대값과 실제값을 나란히 출력했습니다. 중복 제거가 1회인지, 동시 실행이 창을 안 넘는지 눈으로 확인할 수 있습니다."

K2테스트 더블 — 거짓말하지 않는 가짜

가짜 fetcher는 지연·실패·취소를 진짜처럼 흉내내야 한다.

취소를 안 던지는 가짜를 쓰면 취소 경로가 테스트에서 항상 통과한다 — 실제로는 깨져 있는데도. actor로 만들면 호출 횟수 카운터에 락이 필요 없다.

데코레이터로 계측: InstrumentedFetcherImageDataFetching 자리에 끼우면 코어 0줄 수정으로 관측이 붙는다. 그리고 캐시 히트는 관측에 안 잡힌다 — 이게 층위가 제대로 나뉘었다는 증거다.

함정가짜는 진짜의 근사일 뿐이다 — HTTP 상태코드·리다이렉트·부분 전송은 재현 못 한다. 그리고 지연을 sleep으로 흉내내므로 테스트가 실제 시간을 쓴다(판정기 시간 제한 주의).

K3변이 테스트 — "이 체크에 이빨이 있나"

일부러 코드를 망가뜨려 검증이 실제로 잡아내는지 확인한다.
망가뜨린 것결과증명한 것
합류 구간에 await 삽입네트워크 1회 → 10회원자성이 중복 제거의 근거
갱신 환불 줄 제거과잉 축출 발생비용 회계가 실제로 필요
recordUse 제거자주 쓴 항목이 먼저 축출"조회도 사용이다"

멘트"체크가 통과하는 것만으로는 부족해서, 일부러 깨뜨려 봤습니다. await를 한 줄 끼우니 중복 제거가 1회에서 10회로 무너졌고, 그게 이 불변식이 실제로 동작한다는 증거입니다."

K4판정기에 네트워크가 없을 수 있다

함정실제 URLSession을 쓰는 코드는 판정기에서 타임아웃으로 실패할 수 있다. 그래서 데모는 전부 가짜 fetcher로 돌아간다.

판정기가 Linux일 수도 있다 — 그 경우 import FoundationNetworking이 없으면 URLSession컴파일조차 안 된다:

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

같은 이유로 UIKit 코드는 전부 #if canImport(UIKit) 안에 둔다 — UIKit 없이도 로직이 컴파일·실행되어야 한다.

검색 결과가 없습니다.