iOS 면접 준비

iOS 면접 질문

자주 묻는 iOS 면접 질문 20개. iOS 개발은 Swift, SwiftUI, UIKit 앱을 만들고, 수명 주기, 동시성, 네트워킹, 저장소, 성능, 테스트, App Store 릴리스 과제를 해결하는 데 초점을 맞춘 수요가 높은 분야입니다. 이 질문들은 여러 난이도를 다루며, 면접 트레이너에서 큰 소리로 답변하는 연습을 할 수 있습니다.

iOS AI 면접 시작하기신용카드가 필요하지 않습니다. 무료 세션 1회 제공.
영어 기술 면접 연습비원어민이 기술 면접 통과를 연습할 수 있는 모드입니다.

초급 질문

1전형적인 iOS MVC(Model-View-Controller) 아키텍처에서 UIViewController는 어떤 책임을 가져야 하나요?

전통적인 iOS MVC(Model-View-Controller)에서 UIViewController는 View 컴포넌트와 Model 데이터 사이를 중재하는 Controller 역할을 합니다. 주요 책임으로는 뷰 수명 주기 관리(예: viewDidLoad, viewWillAppear), UI 요소 설정 및 업데이트, 직접적인 사용자 인터랙션 처리(버튼 탭, 델리게이트, 타깃-액션 등), 화면 전환 또는 프레젠테이션 조율 등이 있습니다. 뷰 컨트롤러가 비대해지는 현상(Massive View Controller)을 방지하려면 로우 레벨 네트워크 통신, 영속성 처리, 복잡한 비즈니스 로직과 같은 비-UI 관련 책임은 전담 서비스나 모델 객체로 위임해야 합니다.

class UserProfileViewController: UIViewController {
    private let userService: UserServiceProtocol
    private let profileView = UserProfileView()
    
    init(userService: UserServiceProtocol) {
        self.userService = userService
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
    
    override func loadView() {
        view = profileView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        profileView.editButton.addTarget(self, action: #selector(didTapEdit), for: .touchUpInside)
        fetchProfile()
    }
    
    private func fetchProfile() {
        userService.fetchCurrentUser { [weak self] result in
            DispatchQueue.main.async {
                if case .success(let user) = result {
                    self?.profileView.configure(with: user)
                }
            }
        }
    }
    
    @objc private func didTapEdit() {
        // Handle user action or trigger navigation
    }
}
AI 코치와 함께 이 질문에 답해 보세요

2iOS 앱 출시 과정에서 프로비저닝 프로필(provisioning profile)의 역할은 무엇인가요?

iOS 배포에서 프로비저닝 프로필은 Apple의 보안 요구사항을 앱 바이너리 및 대상 기기와 연결하는 암호학적으로 서명된 패키지 역할을 합니다. 주된 역할은 해당 애플리케이션이 특정 배포 규칙에 따라 실행되도록 승인되었음을 iOS 운영체제와 App Store Connect에 전달하는 것입니다. 프로비저닝 프로필은 다음과 같은 몇 가지 핵심 요소를 하나로 묶습니다. - 앱 ID(Bundle Identifier) - 앱을 빌드한 주체를 증명하는 공인 서명 인증서 - 앱에 부여된 권한 및 기능(푸시 알림, Sign in with Apple, iCloud 등 엔타이틀먼트) - Development나 Ad-Hoc 같은 비-App Store 프로필의 경우 실행이 허용된 기기 UDID 목록 App Store 출시용 프로필의 경우, Apple이 App Store를 통해 모든 일반 사용자 기기로의 배포를 허용하므로 개별 기기 UDID는 포함되지 않습니다.

Provisioning Profile (.mobileprovision)
├── App ID / Bundle Identifier (e.g., com.example.app)
├── Certificates (Public key / Distribution Certificate)
├── Entitlements (e.g., Push Notifications, Associated Domains)
└── Device UDIDs (Present for Development/Ad-Hoc; omitted for App Store distribution)
AI 코치와 함께 이 질문에 답해 보세요

3iOS 앱이 백그라운드에서 작업을 실행한다는 것은 무엇을 의미하며, 시스템은 해당 작업에 어떤 기본적인 제한을 두나요?

iOS에서 백그라운드 작업을 실행한다는 것은 앱이 화면에서 활성 상태가 아니거나 사용자에게 보이지 않을 때 코드를 실행하는 것을 의미합니다. 사용자가 앱을 벗어나면 앱은 활성 포그라운드 상태에서 백그라운드로 전환되며, 얼마 지나지 않아 실행이 일시 중지되는 일시 정지(suspended) 상태로 진입합니다. 이때 메모리는 RAM에 유지됩니다. 배터리 수명, 시스템 응답성, 기기 리소스를 보존하기 위해 iOS는 백그라운드 실행을 엄격히 제어합니다. 시스템은 백그라운드 상태가 된 앱이 코드를 실행할 수 있는 시간을 제한하며(특정 백그라운드 모드나 예약된 작업을 사용하지 않는 한 대개 약 30초 수준의 짧고 유한한 실행 시간만 허용됨), CPU 및 네트워크 우선순위를 제한하고, 기기에 메모리 부족 현상이 발생하면 일시 정지된 앱을 강제 종료할 수 있습니다.

import UIKit

NotificationCenter.default.addObserver(
    forName: UIApplication.didEnterBackgroundNotification,
    object: nil,
    queue: .main
) { _ in
    print("App entered background. Code execution will pause soon unless extended.")
}
AI 코치와 함께 이 질문에 답해 보세요

4Combine의 Publisher란 무엇이며, 전형적인 iOS 데이터 흐름에서 Subscriber와 어떻게 다른가요?

Combine에서 Publisher는 시간에 따라 일련의 값 스트림을 방출하며, 완료 이벤트(성공적으로 완료되거나 에러와 함께 실패)로 종료될 수 있습니다. Publisher는 두 가지 연관 타입(associated types)인 `Output`(생성하는 데이터의 타입)과 `Failure`(방출할 수 있는 에러의 타입)를 선언합니다. Subscriber는 Publisher로부터 값과 생명주기 이벤트를 전달받습니다. 전형적인 iOS 데이터 흐름에서 Publisher는 비동기 이벤트(예: 네트워크 응답, 알림, 사용자 입력)의 소스 또는 생산자 역할을 하는 반면, Subscriber는 방출된 값에 반응하고, 에러를 처리하며, 완료에 대응(예: UI 상태 업데이트, 데이터베이스 쓰기)하는 소비자 역할을 합니다. Subscriber는 Publisher에 연결되어 구독 토큰(subscription token)을 받고, 요소에 대한 수요(demand)를 요청합니다.

import Combine

// Publisher: emits a sequence of integers
let numberPublisher = [1, 2, 3].publisher

// Subscriber: consumes the emitted integers
let cancellable = numberPublisher.sink(
    receiveCompletion: { completion in
        switch completion {
        case .finished:
            print("Finished")
        case .failure(let error):
            print("Error: \(error)")
        }
    },
    receiveValue: { value in
        print("Received: \(value)")
    }
)
AI 코치와 함께 이 질문에 답해 보세요

5Swift에서 async/await의 의미는 무엇이며, iOS 앱에서 비동기 API를 호출할 때 이를 어떻게 활용하나요?

`async/await`는 중첩 클로저나 완료 핸들러(completion handler)를 사용하는 대신 비동기 코드를 선형적이고 가독성 높은 순차적 방식으로 작성할 수 있도록 제공하는 Swift의 내장 문법입니다. 함수를 `async`로 지정하면 컴파일러에게 네트워크 요청이나 파일 I/O와 같이 오래 걸리는 작업이 완료되기를 기다리는 동안 해당 함수의 실행을 일시 중단(suspend)할 수 있음을 알립니다. `await` 키워드는 중단 지점(suspension point)을 나타냅니다. 즉, 현재 함수의 실행이 일시 정지되어 기반 스레드가 다른 작업을 수행할 수 있도록 양보하며, 대기 중이던 결과나 오류가 준비되면 실행을 재개합니다. iOS 앱에서 비동기 API를 호출하려면 다른 `async` 함수 내부나 `Task` 블록과 같은 비동기 컨텍스트 내에서 `async` 메서드 앞에 `await`(함수가 오류를 던질 수 있는 경우 `try await`)를 붙여 호출합니다.

func fetchUserData(from url: URL) async throws -> User {
    let (data, response) = try await URLSession.shared.data(from: url)
    
    guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
        throw URLError(.badServerResponse)
    }
    
    return try JSONDecoder().decode(User.self, from: data)
}
AI 코치와 함께 이 질문에 답해 보세요

6iOS 앱에서 UserDefaults는 언제 사용하며, 어떤 종류의 데이터는 저장하지 않아야 하나요?

UserDefaults는 앱 실행 간에 유지되어야 하는 작고 가벼운 사용자 환경설정, 옵션 값, 플래그를 저장하도록 설계되었습니다(예: 테마 설정, 볼륨 크기, `hasSeenOnboarding` 불리언 플래그 등). String, Int, Double, Bool, Date, Data, Array, Dictionary와 같은 프로퍼티 리스트(property list) 타입을 지원합니다. 다음과 같은 데이터는 저장해서는 안 됩니다. 1. 민감한 정보(사용자 비밀번호, 개인 키, 인증 토큰 등): UserDefaults는 데이터를 암호화되지 않은 일반 텍스트 plist 파일로 저장하기 때문입니다. 2. 대용량 데이터 세트, 문서, 미디어 파일(이미지, 오디오, 대용량 JSON 페이로드 등): 데이터에 접근할 때 plist 전체가 메모리에 로드되므로, 메모리 오버헤드가 커지고 앱 실행 속도가 느려지기 때문입니다.

// Saving simple preference values
UserDefaults.standard.set(true, forKey: "hasCompletedOnboarding")
UserDefaults.standard.set("dark", forKey: "preferredTheme")

// Reading values back
let hasCompletedOnboarding = UserDefaults.standard.bool(forKey: "hasCompletedOnboarding")
let theme = UserDefaults.standard.string(forKey: "preferredTheme") ?? "system"
AI 코치와 함께 이 질문에 답해 보세요

7Swift에서 ARC (Automatic Reference Counting)란 무엇이며, iOS 앱에서 클래스 인스턴스의 수명을 어떻게 관리하나요?

ARC (Automatic Reference Counting)는 참조 타입인 클래스 인스턴스의 수명을 추적하고 관리하는 Swift의 컴파일 타임 메모리 관리 메커니즘입니다. 클래스의 새 인스턴스가 생성되어 강한 참조에 할당될 때마다 ARC는 해당 인스턴스의 내부 참조 카운트를 추적합니다. 인스턴스에 대한 강한 참조가 하나라도 존재하는 한 ARC는 인스턴스를 메모리에 유지합니다. 모든 강한 참조가 해제되어 참조 카운트가 0으로 떨어지면 ARC는 즉시 인스턴스를 할당 해제하여 메모리를 확보하며, 할당 해제 직전에 인스턴스의 `deinit` 메서드를 자동으로 호출합니다. Java나 .NET 환경의 런타임 가비지 컬렉션과 달리 ARC는 주기적인 순회 정리(sweep) 작업을 실행하지 않으며, Swift 컴파일러가 컴파일 시점에 바이너리에 적절한 retain 및 release 호출을 삽입합니다.

class User {
    let name: String
    init(name: String) {
        self.name = name
        print("\(name) initialized")
    }
    deinit {
        print("\(name) deallocated")
    }
}

var ref1: User? = User(name: "Alice") // count = 1
var ref2: User? = ref1               // count = 2

ref1 = nil                           // count = 1
ref2 = nil                           // count = 0 -> deinit called
AI 코치와 함께 이 질문에 답해 보세요

8iOS 앱에서 URLSession을 사용하여 간단한 HTTP (Hypertext Transfer Protocol) GET 요청을 어떻게 수행하나요?

iOS 앱에서 간단한 HTTP GET 요청을 수행하려면 URL 객체를 생성하여 URLSession.shared와 같은 URLSession 인스턴스에 전달합니다. 요청 실행은 데이터 태스크 완료 핸들러(URLSession.shared.dataTask(with: url) { data, response, error in ... })를 사용하거나 최신 Swift 동시성(let (data, response) = try await URLSession.shared.data(from: url))을 사용할 수 있습니다. 기존 데이터 태스크 방식을 사용할 때는 태스크를 시작하기 위해 .resume()을 호출해야 하며, 전송 에러 처리, HTTPURLResponse 상태 코드 확인, 그리고 모든 UI 업데이트가 메인 스레드로 디스패치되도록 보장해야 합니다.

guard let url = URL(string: "https://api.example.com/items") else { return }

let task = URLSession.shared.dataTask(with: url) { data, response, error in
    if let error = error {
        print("Network error: \(error.localizedDescription)")
        return
    }
    
    guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else {
        print("Server error or invalid status code")
        return
    }
    
    if let data = data {
        DispatchQueue.main.async {
            // Update UI on main thread
        }
    }
}
task.resume()
AI 코치와 함께 이 질문에 답해 보세요

9Xcode의 Instruments란 무엇이며, iOS 앱의 간단한 성능 문제를 조사할 때 이를 어떻게 활용할 수 있나요?

Instruments는 Xcode에 번들로 포함된 Apple의 성능 프로파일링 및 분석 도구입니다. 개발자는 이를 통해 런타임 동작, CPU (Central Processing Unit) 사용량, 메모리 할당, 메모리 누수, UI (User Interface) 렌더링 성능을 모니터링할 수 있습니다. UI 버벅임이나 높은 CPU 사용량 같은 간단한 성능 문제를 조사하려면 Xcode에서 Instruments를 실행(Product -> Profile 메뉴)하고, 적절한 템플릿(CPU 병목 조사용 Time Profiler 또는 메모리 문제 조사용 Allocations/Leaks 등)을 선택한 뒤 실제 기기에서 문제가 발생하는 사용자 흐름을 재현하면서 추적 데이터를 기록합니다. 기록 후에는 호출 트리(call tree)와 가장 비중이 큰 스택 추적(heaviest stack trace)을 분석하여 지연이나 과도한 리소스 소모를 일으키는 정확한 메서드를 찾아내므로, 코드를 수정하기 전에 병목 지점을 정량적으로 측정하고 파악할 수 있습니다.

1. In Xcode, select Product -> Profile (Builds with Release optimizations).
2. Select the 'Time Profiler' template.
3. Click Record and perform the sluggish action in the app.
4. Stop recording.
5. Inspect the Call Tree tab:
   - Enable 'Separate by Thread' and 'Hide System Libraries'.
   - Trace the heaviest call stack on the Main Thread to identify the blocking function.
AI 코치와 함께 이 질문에 답해 보세요

10APNs(Apple Push Notification service)란 무엇이며, iOS 앱에 푸시 알림을 전달할 때 어떤 역할을 하나요?

APNs(Apple Push Notification service)는 백엔드 서버(제공자 서버)에서 Apple 기기로 푸시 알림을 안전하게 라우팅하는 Apple의 클라우드 기반 중계 서비스입니다. 모바일 기기가 배터리를 소모하거나 과도한 네트워크 대역폭을 쓰지 않고 개별 앱 백엔드마다 지속적인 소켓 연결을 유지하는 것은 불가능하기 때문에, Apple은 각 기기와 APNs 간에 단 하나의 저전력 영구 연결(persistent connection)만을 유지합니다. 앱이 원격 알림을 등록하면 APNs는 해당 기기의 특정 앱 설치를 식별하는 고유하고 불투명한(opaque) 기기 토큰(device token)을 생성합니다. 앱은 이 토큰을 백엔드 제공자 서버로 전송합니다. 제공자가 사용자에게 알림을 보내고자 할 때는 페이로드를 구성한 뒤 기기 토큰과 함께 HTTP/2를 통해 APNs로 전송합니다. 그러면 APNs는 해당 기기의 활성 연결을 찾아 푸시 알림 페이로드를 iOS로 직접 전달하고, iOS는 설정에 따라 알림을 표시하거나 앱을 깨웁니다.

[Provider Server] ---> (HTTP/2 Request + Device Token + Payload) ---> [APNs]
                                                                           |
                                                               (Persistent APNs Connection)
                                                                           v
                                                                    [iOS Device / App]
AI 코치와 함께 이 질문에 답해 보세요

중급 질문

11UI 업데이트, 유효성 검사, 네트워크 통신, 화면 이동(내비게이션)을 모두 처리하는 거대한 UIViewController를 어떻게 리팩터링하겠습니까?

거대한 뷰 컨트롤러(Massive View Controller, MVC) 리팩터링은 리스크를 줄이기 위해 점진적으로 진행해야 하며, 서로 다른 책임을 전용 계층으로 분리해야 합니다. 1. 네트워크 및 데이터 분리: API 호출, 데이터 영속화, JSON 파싱을 뷰 컨트롤러에서 추출하여 프로토콜 추상화가 적용된 전용 Service 또는 Repository 클래스로 이전합니다. 2. 프레젠테이션 상태 및 유효성 검사 분리: ViewModel(또는 Presenter)을 도입합니다. 입력 유효성 검사, 문자열 포맷팅, UI 상태 관리를 ViewModel로 이전하여 해당 로직을 독립적으로 단위 테스트할 수 있도록 만듭니다. 3. 화면 이동 로직 분리: Coordinator 패턴(또는 Router / Flow Controller)을 적용하여 push/present 호출 및 화면 이동 흐름 로직을 뷰 컨트롤러에서 제거합니다. 4. 뷰 컨트롤러 경량화 유지: UI 레이아웃 구성, 서브뷰 설정, 수명주기 훅, UI 컨트롤과 ViewModel 간 바인딩만 뷰 컨트롤러에 남깁니다. 5. 테스트를 동반한 점진적 실행: 리팩터링 과정에서 새로 추출된 서비스와 ViewModel에 대한 단위 테스트를 작성하여 기존 동작이 변경되지 않음을 보장합니다.

// 1. Extracted Navigation
protocol LoginCoordinatorProtocol: AnyObject {
    func showHomeScreen()
}

// 2. Extracted Business Logic & State
final class LoginViewModel {
    private let authService: AuthServiceProtocol
    private weak var coordinator: LoginCoordinatorProtocol?
    
    init(authService: AuthServiceProtocol, coordinator: LoginCoordinatorProtocol) {
        self.authService = authService
        self.coordinator = coordinator
    }
    
    func login(email: String, password: String) {
        guard email.contains("@") else { return }
        authService.login(email: email, password: password) { [weak self] result in
            if case .success = result { self?.coordinator?.showHomeScreen() }
        }
    }
}

// 3. Lean UIViewController: Only handles UI and bindings
final class LoginViewController: UIViewController {
    private let viewModel: LoginViewModel
    init(viewModel: LoginViewModel) { self.viewModel = viewModel; super.init(nibName: nil, bundle: nil) }
    required init?(coder: NSCoder) { fatalError() }
}
AI 코치와 함께 이 질문에 답해 보세요

12코드 서명이나 프로비저닝 오류로 인해 발생하는 iOS 아카이브 업로드 실패 문제를 어떻게 해결하시겠습니까?

서명 또는 프로비저닝 문제로 인한 iOS 아카이브 업로드 실패를 해결할 때는 배포 인증서, 프로비저닝 프로파일, 타깃 권한(entitlements)이라는 세 가지 핵심 영역을 점검해야 합니다. 1. 진단 로그 확인: Xcode Organizer의 배포 로그, 상세 업로드 유효성 검사 오류 또는 CI 콘솔 로그(`altool`/`notarytool`)를 검토하여 정확한 거부 원인을 파악합니다. 2. 인증서 및 신원 검증: 유효한 Apple Distribution 인증서가 일치하는 개인 키와 함께 키체인에 존재하는지, 만료되었거나 취소되지 않았는지, Apple WWDR 중간 인증서가 누락되지 않았는지 확인합니다. 3. 프로비저닝 프로파일 일치 여부: 내보내기(export)에 사용된 프로파일이 정확한 번들 ID(Bundle Identifier)와 일치하는 App Store 배포 프로파일인지 확인합니다. 푸시 알림이나 연관 도메인(Associated Domains) 같은 기능을 사용하는 경우, 호환되지 않는 와일드카드 대신 명시적 App ID가 구성되어 있는지 확인합니다. 4. 타깃 권한(Entitlements) 정합성: 타깃의 `.entitlements` 파일과 Apple Developer Portal의 App ID에 활성화된 기능 간의 불일치를 확인합니다. 로컬 권한 파일에 포털 프로파일에 등록되지 않은 권한이 포함되어 있으면 업로드가 실패합니다. 5. 서명 설정 점검: 자동 서명을 사용하는 경우 Xcode 설정에서 선택된 팀(Team)과 Apple ID 계정 자격 증명을 확인합니다. 수동 서명(또는 CI 내보내기 옵션)을 사용하는 경우 `ExportOptions.plist`가 각 번들 ID를 올바른 배포 인증서 및 프로파일에 매핑하고 있는지 확인합니다.

# Decode the provisioning profile embedded in the archive
security cms -D -i MyApp.xcarchive/Products/Applications/MyApp.app/embedded.mobileprovision > profile.plist

# Read entitlements embedded in the profile
/usr/libexec/PlistBuddy -c "Print :Entitlements" profile.plist

# Inspect signature and entitlements on the built binary
codesign -d --entitlements :- MyApp.xcarchive/Products/Applications/MyApp.app
AI 코치와 함께 이 질문에 답해 보세요

13프로덕션 환경의 백그라운드 동기화 기능을 구현할 때 BGAppRefreshTask, BGProcessingTask, 백그라운드 URLSession 중 어떤 기준으로 선택하시겠습니까?

적절한 백그라운드 API를 선택하는 것은 작업 소요 시간, 전력/네트워크 조건 필요 여부, 그리고 네트워크 페이로드의 특성에 따라 달라집니다. 1. **BGAppRefreshTask**: 대략 15~30초 정도 소요되는 짧고 가벼운 콘텐츠 업데이트(예: 뉴스 피드, 소셜 타임라인, 사용자 대시보드 새로고침)에 가장 적합합니다. 시스템은 사용자의 사용 패턴을 기반으로 작업을 스케줄링하여, 사용자가 앱을 주로 여는 시간 직전에 최신 콘텐츠가 준비되도록 합니다. 2. **BGProcessingTask**: 데이터 인덱싱, 데이터베이스 정리/마이그레이션, 머신러닝 모델 학습, 대용량 데이터 동기화와 같이 오래 걸리고 급하지 않은 무거운 작업에 적합합니다. 수 분 동안 실행될 수 있으며, 기기가 충전 중(`requiresExternalPower = true`)이거나 Wi-Fi/네트워크에 연결된 상태(`requiresNetworkConnectivity = true`)를 요구할 수 있어 주로 심야에 실행됩니다. 3. **백그라운드 URLSession (`URLSessionConfiguration.background`)**: 앱이 OS에 의해 일시 중단(suspend)되거나 종료(terminate)되더라도 프로세스 외부에서 계속되어야 하는 대용량 파일 전송(사진/동영상 업로드 또는 대용량 에셋 번들 다운로드) 시 올바른 선택입니다. 앱 코드를 메모리에 유지하는 대신 네트워크 전송 작업을 `nsurlsessiond` 데몬에 위임하여 처리합니다.

import BackgroundTasks

func scheduleDatabaseMaintenance() {
    let request = BGProcessingTaskRequest(identifier: "com.app.db_cleanup")
    request.requiresNetworkConnectivity = false
    request.requiresExternalPower = true // Run overnight on charger
    request.earliestBeginDate = Date(timeIntervalSinceNow: 6 * 3600)
    
    try? BGTaskScheduler.shared.submit(request)
}

func scheduleTimelineRefresh() {
    let request = BGAppRefreshTaskRequest(identifier: "com.app.feed_refresh")
    request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
    
    try? BGTaskScheduler.shared.submit(request)
}
AI 코치와 함께 이 질문에 답해 보세요

14입력 디바운싱, 중복 무시, 네트워크 요청 수행, UI의 안전한 업데이트를 처리하는 검색 텍스트 필드용 Combine 파이프라인을 어떻게 구축하겠습니까?

Combine에서 안전한 검색 파이프라인을 구축하는 방법은 다음과 같습니다: 1. **디바운스 및 중복 제거(Debounce & Deduplicate):** 검색 쿼리 게시자(예: `$queryText`)를 받아 타이핑 중단을 대기하도록 `.debounce(for: .milliseconds(300), scheduler: RunLoop.main)`을 적용하고, 변경되지 않은 쿼리 문자열을 무시하도록 `.removeDuplicates()`를 적용합니다. 2. **네트워크 요청 및 취소(Network Request & Cancellation):** 각 쿼리를 네트워크 요청 게시자에 매핑하고 `.switchToLatest()`를 사용하여 평탄화합니다. 이렇게 하면 새 검색 쿼리가 도착했을 때 진행 중인 요청이 자동으로 취소되어 경쟁 상태(race condition)와 지연된 오래된 결과(stale results) 노출을 방지합니다. 3. **오류 격리(Isolate Errors):** 내부 게시자 안에서 네트워크 오류를 포착하여(예: `.catch { _ in Just([]) }`) 네트워크 실패가 외부 검색 텍스트 스트림 자체를 종료시키지 않도록 합니다. 4. **메인 스레드 전달(Main Thread Delivery):** 상태를 업데이트하거나 UI 프로퍼티에 할당하기 전에 `.receive(on: DispatchQueue.main)`을 적용합니다.

import Combine
import Foundation

class SearchViewModel: ObservableObject {
    @Published var query: String = ""
    @Published var searchResults: [String] = []
    private var cancellables = Set<AnyCancellable>()
    
    init() {
        $query
            .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
            .removeDuplicates()
            .map { query -> AnyPublisher<[String], Never> in
                guard !query.trimmingCharacters(in: .whitespaces).isEmpty else {
                    return Just([]).eraseToAnyPublisher()
                }
                return self.performSearch(query)
                    .catch { _ in Just([]) }
                    .eraseToAnyPublisher()
            }
            .switchToLatest()
            .receive(on: DispatchQueue.main)
            .assign(to: &$searchResults)
    }
    
    private func performSearch(_ term: String) -> AnyPublisher<[String], Error> {
        let url = URL(string: "https://api.example.com/search?q=\(term)")!
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: [String].self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}
AI 코치와 함께 이 질문에 답해 보세요

15사용자가 다른 화면으로 이동한 후 UI (User Interface)가 업데이트되지 않도록 Swift의 async/await와 Task 취소를 활용하여 네트워크에서 데이터를 로드하는 화면을 어떻게 구현하시겠습니까?

네트워크 로딩 로직을 서비스나 뷰 모델의 `async` 함수로 구현하고, 화면의 수명 주기와 연결된 `Task`에서 이를 실행하도록 하겠습니다. UIKit의 경우 보통 뷰 컨트롤러나 뷰 모델에 `var loadTask: Task<Void, Never>?`와 같은 프로퍼티를 두고, 화면을 로드해야 할 때 시작한 뒤 원하는 수명 주기에 맞춰 `viewWillDisappear`, `deinit` 또는 새 요청을 시작하기 직전에 취소합니다. SwiftUI에서는 뷰가 사라지거나 식별자(id)가 변경될 때 프레임워크가 자동으로 작업을 취소해주므로 가급적 `.task` 또는 `.task(id:)`를 사용하겠습니다. Task 내부에서는 `try await`로 비동기 네트워크 API를 호출하고, 실제 오류와 취소 예외를 구분하여 처리하며, 여러 단계의 await나 연산 과정이 있는 경우 결과를 반영하기 전에 취소 여부를 확인합니다. UI 상태 변경은 뷰 모델에 `@MainActor`를 적용하거나 `await MainActor.run`을 사용하여 반드시 메인 액터에서 실행되도록 해야 합니다. 오래된 데이터가 UI에 반영되는 것을 막기 위해 이전 작업을 취소하고, 화면 수명과 연계된 작업에 분리된(detached) 태스크나 전역 태스크 사용을 지양하며, 필요에 따라 결과를 반영하기 전에 요청 ID나 현재 아이템 ID를 비교하겠습니다.

@MainActor
final class UsersViewController: UIViewController {
    private var loadTask: Task<Void, Never>?
    private let service: UserService

    override func viewDidLoad() {
        super.viewDidLoad()
        loadUsers()
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        loadTask?.cancel()
    }

    private func loadUsers() {
        loadTask?.cancel()
        loadingView.isHidden = false

        loadTask = Task { [weak self] in
            do {
                let users = try await self?.service.fetchUsers() ?? []
                try Task.checkCancellation()

                guard let self else { return }
                self.loadingView.isHidden = true
                self.tableModel = users
                self.tableView.reloadData()
            } catch is CancellationError {
                // User navigated away or a new load replaced this one; do not update UI.
            } catch {
                guard let self else { return }
                self.loadingView.isHidden = true
                self.showError(error)
            }
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

16다양한 유형의 앱 데이터를 다룰 때 UserDefaults, Keychain, 파일 시스템, SQLite, Core Data 중 어떤 기준으로 선택하시겠습니까?

적절한 iOS 저장소 메커니즘을 선택하는 기준은 데이터의 민감도, 크기, 관계형 복잡도, 쿼리 요구사항, 백업 수명주기에 따라 달라집니다: 1. **Keychain**: 민감한 데이터(인증 토큰, 비밀번호, 암호화 키, 생체 인증 시크릿)에 적합합니다. 하드웨어 기반 암호화를 제공하며 앱 재설치 후에도 유지되고, 샌드박스 환경이 침해되더라도 안전성을 유지합니다. 2. **UserDefaults**: 가볍고 민감하지 않은 환경설정 및 플래그(예: `hasCompletedOnboarding`, 테마 설정)에 적합합니다. 속성 리스트(plist) 형태로 메모리에 전체가 로드되므로 대용량 데이터 세트, 미디어, 민감한 토큰 저장에는 사용하지 않아야 합니다. 3. **파일 시스템(Documents / Caches / Application Support)**: 독립된 파일 및 대용량 바이너리 블롭(다운로드된 이미지, 오디오, PDF)에 적합합니다. `Documents`는 사용자에게 노출되며 iCloud에 백업됩니다. `Caches`는 필요 시 삭제 후 다시 다운로드 가능한 콘텐츠에 사용되며, `Application Support`는 사용자에게 직접 노출되지 않는 영구 파일에 사용됩니다. 4. **SQLite**: 객체 그래프의 오버헤드 없이 빠른 인덱스 쿼리, 대량 데이터 작업, 크로스 플랫폼 데이터베이스 공유가 필요한 구조화된 테이블 형태의 데이터에 적합합니다. 5. **Core Data / SwiftData**: 관계, 변경 사항 추적, 폴팅(faulting), 실행 취소 관리 및 UIKit(`NSFetchedResultsController`)이나 SwiftUI(`@Query`)와의 직접적인 연동이 필요한 복잡한 객체 그래프에 적합합니다.

- Auth tokens / API keys          -> Keychain (Hardware-backed encrypted store)
- App settings / UI flags         -> UserDefaults (Lightweight key-value, non-sensitive)
- Downloaded videos / PDFs        -> File System (Documents / Caches directory)
- 50k items with complex queries   -> SQLite / Core Data / SwiftData (Indexed, relationship-aware)
AI 코치와 함께 이 질문에 답해 보세요

17프로덕션 환경의 UIKit 화면에서 뷰 모델의 콜백을 처리할 때, 클로저 핸들러에서 self를 weak, unowned, strong 중 어떤 방식으로 캡처할지 어떻게 결정하시겠습니까?

뷰 컨트롤러가 콜백을 통해 뷰 모델과 상호작용하는 UIKit 아키텍처에서는 소유권과 수명 주기에 따라 캡처 전략을 결정합니다. 1. `[weak self]`: 저장되거나 탈출(escaping)하는 클로저(예: 뷰 모델 이벤트 콜백이나 비동기 네트워크/데이터 완료 핸들러)에 적용하는 표준적이고 가장 안전한 접근 방식입니다. 뷰 컨트롤러가 뷰 모델을 소유하므로, 뷰 모델에 저장된 콜백에서 `self`를 강하게 캡처하면 강한 참조 순환(`VC -> VM -> closure -> VC`)이 발생합니다. `[weak self]`는 `self`를 옵셔널(`UIViewController?`)로 변환하여 `self`가 깔끔하게 메모리에서 해제될 수 있도록 지원하며 메모리 누수를 방지합니다. 2. `[unowned self]`: 클로저가 실행될 때 `self`가 절대 nil이 되지 않는다고 가정합니다. UI 화면에서는 극도로 주의해서 사용해야 합니다. 뷰 컨트롤러가 닫히고(dismiss) 메모리에서 해제된 이후에 비동기 작업이나 콜백이 완료되면, `unowned self`에 접근할 때 치명적인 런타임 크래시가 발생합니다. 이러한 이유로 프로덕션 UIKit UI 콜백에서는 일반적으로 `unowned`보다 `[weak self]`를 권장합니다. 3. 강한 캡처(Strong capture, 기본값, 캡처 목록 없음): 클로저가 비탈출(non-escaping) 클로저이거나(예: `map`/`filter` 같은 표준 컬렉션 연산), 클로저의 수명이 매우 짧고 `self`로 이어지는 소유권 순환을 만들지 않는 경우에 적합합니다.

final class ProfileViewController: UIViewController {
    private let viewModel: ProfileViewModel

    init(viewModel: ProfileViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        
        // VM stores the callback -> capture weak self to break retain cycle
        viewModel.onDataUpdated = { [weak self] state in
            guard let self else { return }
            self.updateUI(with: state)
        }
    }

    private func updateUI(with state: ProfileState) { /* Update views */ }
}
AI 코치와 함께 이 질문에 답해 보세요

고급 질문

18수백 개의 화면, 수많은 거대 뷰 컨트롤러(Massive View Controller), 느린 릴리스 주기를 가진 성숙한 iOS 앱에 합류하게 되었습니다. 기능 출시를 중단하지 않고 점진적인 아키텍처 개선 전략을 어떻게 수립하시겠습니까?

효과적인 점진적 마이그레이션 전략은 전체 재작성(rewrite)을 지양하고, 스트랭글러 피그 패턴(Strangler Fig pattern)에 따라 리팩터링을 진행 중인 기능 개발과 직접 결합하는 것입니다. 먼저 합의된 목표 아키텍처 청사진(예: 의존성 주입 및 모듈형 코디네이터를 갖춘 MVVM 또는 VIPER)을 수립하고, 기존 레거시 코드를 수정하기 전에 테스트 기준선(특성 테스트, 스냅샷 테스트, 단위 테스트)을 마련합니다. 우선순위는 기능 변경 빈도와 리스크를 기준으로 설정해야 합니다. 즉, 안정적인 레거시 코드보다는 팀이 활발히 수정 중이거나 버그 발생률이 높은 화면을 먼저 리팩터링합니다. Massive View Controller를 리팩터링할 때는 비즈니스 로직과 프레젠테이션 로직을 전용 ViewModel 또는 Presenter로 분리하고, 프로토콜 기반 어댑터나 코디네이터를 도입하여 레거시 UI와 신규 코드를 격리합니다. 마지막으로 아키텍처 거버넌스를 통해 개발 속도와 품질을 유지합니다. 모범 사례가 되는 표준 참조 구현(golden sample)을 제공하고, CI 린터나 PR(Pull Request) 가이드라인을 통해 경계를 강제하며, 지속적인 기술 부채 해결 역량(예: 15~20%의 리소스 할당 또는 관련 기능 작업과의 리팩터링 병행)을 확보하면서 빌드 시간, 크래시율, 테스트 커버리지 등의 지표를 지속적으로 추적합니다.

protocol ProfileDisplaying: AnyObject {
    func updateProfile(name: String, avatarUrl: URL?)
}

extension LegacyProfileViewController: ProfileDisplaying {
    func updateProfile(name: String, avatarUrl: URL?) {
        self.nameLabel.text = name
    }
}

final class ProfilePresenter {
    private weak var view: ProfileDisplaying?
    private let userUseCase: FetchUserUseCaseProtocol
    
    init(view: ProfileDisplaying, userUseCase: FetchUserUseCaseProtocol) {
        self.view = view
        self.userUseCase = userUseCase
    }
    
    func onViewLoaded() async {
        guard let user = try? await userUseCase.execute() else { return }
        await MainActor.run {
            view?.updateProfile(name: user.name, avatarUrl: user.avatarURL)
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요

19데이터베이스 마이그레이션과 백엔드 API(Application Programming Interface) 변경이 수반되는 대규모 트래픽의 iOS 앱을 배포할 때, 사용자 영향을 최소화하기 위한 롤아웃 계획을 어떻게 설계하겠습니까?

데이터베이스 마이그레이션과 백엔드 API 변경이 포함된 대규모 트래픽 iOS 앱의 롤아웃 계획을 설계할 때는 iOS 클라이언트의 제약 조건(비동기적 사용자 업데이트 및 클라이언트 측 바이너리 강제 롤백 불가) 때문에 배포와 기능 활성화를 분리해야 합니다. 1. 백엔드 호환성: 확장-축소(expand-and-contract) 전략을 구현합니다. 레거시 클라이언트와 업데이트된 클라이언트 버전이 호환성 문제 없이 동시에 작동할 수 있도록 API v1과 함께 API v2를 병행 배포합니다. 2. 복원력 있는 데이터베이스 마이그레이션: 로컬 스키마 마이그레이션(예: SQLite, Core Data, SwiftData)이 멱등적이고 비파괴적이어야 하며, 메인 스레드를 블로킹하거나 앱 실행 시 충돌 루프(crash loop)를 유발하지 않고 여러 버전을 건너뛰는 업그레이드(예: N-3에서 N으로 업그레이드)를 안전하게 처리하도록 합니다. 3. 동적 게이팅: 서버 기반 피처 플래그/원격 구성을 통해 새 클라이언트 기능을 숨겨둔 채(dark launch) 배포합니다. 초기 배포 단계에서는 피처 플래그를 비활성화 상태로 유지합니다. 4. 단계적 롤아웃 및 모니터링: App Store 단계적 출시(Phased Release, 7일간의 단계적 배포)를 통해 앱을 배포합니다. 원격 측정 지표(telemetry), 크래시율, 데이터베이스 마이그레이션 성공 지표, API 오류율을 지속적으로 모니터링합니다. 이상 징후가 발생하면 긴급 바이너리 롤백을 할 필요 없이 App Store Connect에서 단계적 출시를 일시 중지하고 피처 플래그를 끌 수 있습니다.

Phase 1 (Backend Expand): Deploy API v2 supporting both new and legacy payloads.
Phase 2 (Client Distribution & Migration): Release client v2.0 via App Store Phased Release (1% -> 100%). DB migrates safely on first launch. Feature flag remains OFF.
Phase 3 (Validation & Feature Enablement): Monitor crash rates & migration success. Incrementally ramp Remote Config flag (10% -> 50% -> 100%).
Phase 4 (Backend Contract): Once legacy app adoption drops below deprecation threshold, sunset API v1.
AI 코치와 함께 이 질문에 답해 보세요

20편집 내용이 결국 모든 기기 간에 동기화되어야 하지만 iOS가 백그라운드 작업을 지연시키거나 취소할 수 있는 메모 앱의 오프라인 우선(offline-first) 동기화를 설계하고 있습니다. 어떤 백그라운드 실행 아키텍처를 선택하시겠습니까?

오프라인 우선 동기화 아키텍처에서는 로컬 데이터베이스(SQLite, Core Data, SwiftData 등)를 UI 상태의 즉각적인 단일 진실 공급원(single source of truth)으로 취급하고, 데이터 변경 사항(mutation)은 디스크의 영속 아웃박스(outbox) 큐에 추가하여 프로세스가 종료되더라도 미전송 편집 내용이 유지되도록 합니다. 기회주의적이고 비결정적인 iOS의 백그라운드 실행 특성을 관리하기 위해 백그라운드 작업은 다음과 같은 다중 계층으로 구성합니다. 1. 백그라운드 전환 시 아웃박스 비우기(flush): 진행 중이거나 빠르게 처리할 수 있는 대기 변경 사항을 완료하기 위해 `UIApplication.shared.beginBackgroundTask(expirationHandler:)`를 통해 시작합니다. 2. 예약된 주기적 동기화: `BGTaskScheduler`에 등록하여 가벼운 메타데이터/델타 동기화에는 `BGAppRefreshTask`를, 무거운 동기화 작업에는 `BGProcessingTask`를 사용합니다. 3. 대용량 애셋 동기화: 파일 업로드/다운로드를 위해 백그라운드 `URLSessionConfiguration`을 통해 별도 프로세스(out-of-process)로 처리합니다. 4. 서버 트리거 동기화: 무음 푸시 알림(`content-available: 1`)을 통한 기회주의적 깨우기를 활용합니다. 백그라운드 작업은 언제든 중단되거나 일정이 재조정될 수 있으므로, 데이터 중복 없이 안전하게 재시도할 수 있도록 클라이언트에서 생성한 멱등성 키(예: UUID)를 사용해야 합니다. 작업의 `expirationHandler`가 호출되면 진행 중인 작업을 즉시 취소하고, 커밋되지 않은 작업을 다시 대기(pending) 상태로 표시한 뒤 `setTaskCompleted(success: false)`를 호출해야 합니다. 최종 일관성(eventual consistency)은 CRDT, 버전 벡터(version vector) 또는 삭제 표시(tombstone)를 포함한 최종 쓰기 승리(last-write-wins)와 같은 충돌 해결 메커니즘을 통해 유지되며, 이를 통해 원격 변경 내역이 아직 커밋되지 않은 로컬 아웃박스 변경 사항을 덮어쓰지 않도록 보장합니다.

import BackgroundTasks
import Foundation

final class NoteSyncCoordinator {
    static let shared = NoteSyncCoordinator()
    private let syncTaskID = "com.notesapp.sync.refresh"

    func registerSyncTask() {
        BGTaskScheduler.shared.register(forTaskWithIdentifier: syncTaskID, using: nil) { task in
            guard let refreshTask = task as? BGAppRefreshTask else { return }
            self.handleAppRefresh(task: refreshTask)
        }
    }

    func scheduleNextSync() {
        let request = BGAppRefreshTaskRequest(identifier: syncTaskID)
        request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
        try? BGTaskScheduler.shared.submit(request)
    }

    private func handleAppRefresh(task: BGAppRefreshTask) {
        scheduleNextSync()
        let syncTask = Task {
            do {
                try await SyncEngine.shared.flushPersistentOutboxAndFetchDeltas()
                task.setTaskCompleted(success: true)
            } catch {
                task.setTaskCompleted(success: false)
            }
        }

        task.expirationHandler = {
            syncTask.cancel()
        }
    }
}
AI 코치와 함께 이 질문에 답해 보세요