← iOS 기술면접 — 모던 스택 5종 5 / 5

05 · 라이브코딩 · 설계 토론

상태 모델링, 검색·이미지 캐시 단골 과제의 채점 포인트, 설계 토론 뼈대
진행률
0 / 0 완료
이 장에서 답할 수 있게 되는 질문
  1. 로딩·성공·실패·빈 상태를 어떻게 모델링해야 합니까?
  2. 검색 화면에서 이전 요청 취소를 왜, 어떻게 처리합니까?
  3. 같은 이미지를 동시에 10곳에서 요청하면 어떻게 됩니까?
  4. 화면 하나를 처음부터 설계한다면 어떤 순서로 말하겠습니까?

상태 모델링 — 여기서 절반이 갈린다 🔥 핵심

라이브코딩에서 가장 먼저 보는 건 화려한 알고리즘이 아니라 상태를 어떻게 표현하는가입니다. Bool과 옵셔널을 늘어놓으면 실제로는 일어날 수 없는 조합이 타입상 표현 가능해집니다.

❌ Bool 3개 = 8가지 조합 loading F · items [] · err nil loading T · items [] · err nil loading F · items ✓ · err nil loading F · items [] · err ✓ loading T · items ✓ · err ✓ loading T · err ✓ items ✓ · err ✓ ← 불가능한 조합인데 타입은 허용 ✅ enum = 유효한 상태만 idle loading loaded([Item]) empty failed(Error) 한 번에 정확히 하나만 참 switch가 누락을 잡아줌
enum으로 바꾸면 불가능한 상태가 컴파일되지 않습니다. 그리고 뷰에서 switch를 쓰면 새 케이스를 추가할 때 처리 누락을 컴파일러가 잡아줍니다.
// ❌ 8가지 조합 중 4가지가 말이 안 됨
struct State {
  var isLoading = false
  var items: [Item] = []
  var error: Error?
}

// ✅ 유효한 상태만 표현 가능
enum ViewState {
  case idle
  case loading
  case loaded([Item])
  case empty
  case failed(Error)
}
🔑 핵심

이 원칙에는 이름이 있습니다 — "불가능한 상태를 표현 불가능하게 만든다 (make illegal states unrepresentable)". 라이브코딩 중에 이 말을 쓰면서 리팩토링하면, 단방향 아키텍처를 도구가 아니라 사상으로 이해했다는 신호가 됩니다.

단골 과제 ① — 검색 화면 🔥 핵심

"입력하면 검색되는 화면을 만들어보세요"는 가장 흔한 과제입니다. 쉬워 보이지만 채점 포인트가 네 개 숨어 있습니다.

❌ 타이핑마다 요청 — 응답 순서가 뒤바뀐다 입력 "강" "강남" "강남역" 응답 "강남" "강남역" "강" 늦게 도착 → 화면엔 "강"의 결과가 남는다 ✅ debounce + 이전 요청 취소 마지막 입력 하나만 요청 · 이전 것은 취소 300ms 대기
가장 중요한 채점 포인트는 "오래된 응답이 최신 결과를 덮어쓰지 않는가"입니다. debounce만 걸고 취소를 빼먹으면 이 버그가 그대로 남습니다.
// TCA — .cancellable(id:) 하나로 "이전 요청 취소"가 해결된다
case let .searchQueryChanged(query):
  state.query = query
  guard !query.isEmpty else {
    state.results = []
    return .cancel(id: CancelID.search)
  }
  return .run { send in
    try await clock.sleep(for: .milliseconds(300))        // debounce
    await send(.searchResponse(Result { try await client.search(query) }))
  }
  .cancellable(id: CancelID.search, cancelInFlight: true) // ← 핵심
채점 포인트빠뜨리면
debounce글자마다 요청 → 서버 부하·요금
이전 요청 취소늦은 응답이 최신 결과를 덮어씀
빈 쿼리 처리전체 목록을 불필요하게 요청
상태 4종 표현로딩·빈 결과·에러가 구분 안 됨
📝 노트

cancelInFlight: true가 "같은 id의 진행 중인 작업을 취소하고 새로 시작"을 의미합니다. Rx의 flatMapLatest, Combine의 switchToLatest와 같은 역할입니다. 세 프레임워크에서 같은 개념이 다른 이름으로 나온다는 걸 짚으면 좋은 인상을 줍니다.

단골 과제 ② — 이미지 캐시 🔥 핵심

"이미지 캐시를 만들어보세요"에서 진짜로 보는 건 메모리·디스크 2단 구조가 아니라 같은 URL이 동시에 여러 번 요청될 때의 처리입니다.

❌ 결과만 캐싱 — 셋 다 캐시 미스를 본다 셀 1 셀 2 셀 3 캐시 미스 다운로드 1 다운로드 2 다운로드 3 같은 이미지를 3번 받는다 ✅ 진행 중인 Task를 캐싱 셀 1 셀 2 셀 3 .loading(Task) 이미 진행 중 1회 뒤늦은 요청은 편승한다 왜 이런 일이 생기나 — actor 재진입(reentrancy) actor여도 await에서 중단되는 동안 다른 호출이 들어온다 데이터 레이스는 없지만 중복 작업은 막아주지 않는다
이 과제가 좋은 이유는 actor를 이해했는지가 자연스럽게 드러나기 때문입니다. "actor니까 안전하다"고만 답하면 중복 다운로드를 놓칩니다.
actor ImageCache {
  private enum Entry {
    case loading(Task<UIImage, Error>)
    case ready(UIImage)
  }
  private var cache: [URL: Entry] = [:]

  func image(for url: URL) async throws -> UIImage {
    if let entry = cache[url] {
      switch entry {
      case .ready(let img):    return img
      case .loading(let task): return try await task.value   // 남의 작업에 편승
      }
    }
    let task = Task { try await download(url) }
    cache[url] = .loading(task)
    do {
      let img = try await task.value
      cache[url] = .ready(img)
      return img
    } catch {
      cache[url] = nil          // 실패는 캐시하지 않는다
      throw error
    }
  }
}
⚠️ 주의

실패를 캐시에 남기면 일시적인 네트워크 오류가 영구적인 실패로 굳습니다. catch에서 항목을 지우는 이 한 줄을 빼먹는 경우가 아주 많습니다.

이미지 메모리 — 파일 크기가 아니다

캐시 용량을 계산할 때 파일 크기로 세면 크게 어긋납니다. 화면에 올라간 이미지가 쓰는 메모리는 디코딩된 비트맵 크기입니다.

JPEG 파일 약 300 KB 디코딩 비트맵 2000×2000 16 MB 가로 × 세로 × 4바이트 (RGBA 각 1바이트) 해법 — Downsampling: 표시할 크기에 맞춰 디코딩한다
100×100 셀에 2000×2000 이미지를 그대로 넣으면 필요량의 400배를 씁니다. 리스트에서 메모리가 폭증하는 대표 원인입니다.
let options: [CFString: Any] = [
  kCGImageSourceCreateThumbnailFromImageAlways: true,
  kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,   // 표시 크기 × scale
  kCGImageSourceShouldCacheImmediately: true           // 미리 디코딩 → 스크롤 hitch 방지
]
📝 노트

kCGImageSourceShouldCacheImmediately가 중요한 이유 — 기본적으로 디코딩은 화면에 그리는 순간 지연 수행됩니다. 그게 메인 스레드에서 일어나면 스크롤이 끊깁니다. 미리 백그라운드에서 디코딩해두면 부드러워집니다.

설계 토론 뼈대 🧩 롱테일

"이 화면을 처음부터 설계한다면?"에는 말하는 순서가 곧 점수입니다. 코드부터 시작하면 방향을 잃고, 아래 순서대로 가면 빠뜨리는 게 없습니다.

View — 상태를 그린다 Reducer — 상태 전이 · 순수 UseCase — 도메인 규칙 Repository — 프로토콜 (테스트에서 교체) Remote (API) Local (캐시·DB) 여기가 테스트 핵심 여기서 목킹
레이어를 그릴 때 "어디를 테스트하고 어디를 목킹하는지"를 같이 말하면, 구조를 외운 게 아니라 이유를 안다는 인상을 줍니다.
순서말할 것빠뜨리기 쉬운 것
상태 모델링 (enum)빈 결과와 초기 상태 구분
레이어 분리각 층을 어떻게 테스트할지
모듈 경계 · Interface 의존화면 이동을 Factory로 주입
비동기 정책취소 · 중복 요청 합치기
캐싱 정책데이터 종류마다 유효기간이 다름
관측 · 실패 처리실패했을 때 사용자가 뭘 보나
🔑 핵심

⑤에서 "데이터 종류마다 캐시 정책이 다르다"를 짚으면 실무 감각이 드러납니다 — 목록은 짧게, 상세 정보는 길게, 실시간성이 필요한 값은 캐시하지 않습니다. 하나의 정책으로 전부 덮으려 하면 반드시 어딘가가 깨집니다.

최종 점검 — 라이브코딩 중 소리 내어 말할 것

타이밍말할 문장
시작 직후"먼저 상태부터 정의하겠습니다. 불가능한 조합이 안 생기게 enum으로 가겠습니다."
비동기 붙일 때"이 요청은 화면을 나가면 취소돼야 해서 취소 가능하게 만들겠습니다."
막혔을 때"지금 A와 B 중 고민 중인데, A로 가겠습니다. 이유는 …입니다."
시간이 부족할 때"여기는 실제로는 ○○ 처리가 더 필요한데, 지금은 뼈대부터 세우겠습니다."
📝 노트

라이브코딩은 완성도보다 사고 과정을 봅니다. 침묵하며 코딩하면 평가할 근거가 없습니다. 막혔을 때 고민 중인 선택지를 소리 내어 말하는 것이 가장 안전한 전략입니다.

출처 · 참고자료