# iOS 시니어 면접 2026: 아키텍처와 디자인 패턴 질문 > MVVM, VIPER, Clean Architecture, 디자인 패턴 핵심 질문으로 iOS 시니어 면접을 준비합니다. Swift 코드 예시가 포함된 완벽 가이드입니다. - Published: 2026-03-02 - Updated: 2026-04-29 - Author: SharpSkill - Tags: ios, swift, architecture, mvvm, viper, interview, design-patterns - Reading time: 12 min --- iOS 시니어 면접에서는 아키텍처와 디자인 패턴이 큰 비중을 차지합니다. Swift 문법을 넘어, 면접관은 유지보수성과 테스트 용이성, 확장성을 갖춘 애플리케이션을 설계하는 능력을 평가합니다. 본 가이드는 MVVM, VIPER, Clean Architecture, 그리고 필수 패턴에 관한 빈출 질문을 다루며, 자세한 답변과 면접에 바로 활용할 수 있는 코드 예시를 함께 제공합니다. > **면접관이 진정으로 평가하는 것** > > 시니어 면접에서는 기술적인 답보다 그 이유를 설명하는 것이 더 중요합니다. **왜** 특정 아키텍처가 주어진 맥락에 적합한지를 항상 설명해야 하며, **어떻게** 구현하는지에 그쳐서는 안 됩니다. ## iOS 아키텍처 이해하기: 전반적 개요 구체적인 질문에 들어가기 전에, iOS 아키텍처 지형을 이해하는 것이 필수적입니다. 각 패턴은 서로 다른 문제를 해결하고 서로 다른 맥락에 적합합니다. ### MVC: Apple의 전통적 패턴 MVC(Model-View-Controller)는 여전히 Apple의 기본 패턴이지만, 복잡한 애플리케이션에서는 "Massive View Controllers" 문제가 발생합니다. ```swift // UserViewController.swift // Typical MVC example showing its limitations class UserViewController: UIViewController { // The ViewController accumulates too many responsibilities private var users: [User] = [] override func viewDidLoad() { super.viewDidLoad() // View logic setupUI() // Business logic fetchUsers() // Navigation logic setupNavigationBar() } private func fetchUsers() { // Networking in the VC: anti-pattern URLSession.shared.dataTask(with: URL(string: "api/users")!) { data, _, _ in // JSON parsing here too... }.resume() } } ``` 이 패턴은 ViewController가 500줄을 넘는 순간 문제가 되며, 단위 테스트가 사실상 불가능해집니다. ## 질문 1: MVVM과 Swift에서의 구현을 설명해 주세요 MVVM(Model-View-ViewModel)은 프레젠테이션 로직을 ViewModel로 분리해 테스트를 쉽게 하고 ViewController의 크기를 줄여 줍니다. > **면접의 핵심 포인트** > > MVVM은 `@Observable`과 네이티브 데이터 바인딩 덕분에 SwiftUI에서 빛을 발합니다. UIKit에서는 바인딩 메커니즘(Combine, 클로저)이 필요합니다. ### Combine을 활용한 MVVM 구현 ```swift // UserViewModel.swift // ViewModel separated from any UIKit dependency import Combine @MainActor final class UserViewModel: ObservableObject { // Published states for binding @Published private(set) var users: [User] = [] @Published private(set) var isLoading = false @Published private(set) var errorMessage: String? // Injected dependency for testability private let userRepository: UserRepositoryProtocol private var cancellables = Set() init(userRepository: UserRepositoryProtocol = UserRepository()) { self.userRepository = userRepository } func loadUsers() { isLoading = true errorMessage = nil userRepository.fetchUsers() .receive(on: DispatchQueue.main) .sink { [weak self] completion in self?.isLoading = false if case .failure(let error) = completion { self?.errorMessage = error.localizedDescription } } receiveValue: { [weak self] users in self?.users = users } .store(in: &cancellables) } } ``` ### Combine 바인딩을 사용하는 View ```swift // UserListView.swift // SwiftUI View consuming the ViewModel import SwiftUI struct UserListView: View { // StateObject for lifecycle management @StateObject private var viewModel = UserViewModel() var body: some View { Group { if viewModel.isLoading { ProgressView("Loading...") } else if let error = viewModel.errorMessage { ErrorView(message: error, retry: viewModel.loadUsers) } else { List(viewModel.users) { user in UserRowView(user: user) } } } .onAppear { viewModel.loadUsers() } } } ``` ViewModel은 UIKit이나 SwiftUI를 알지 못하기 때문에 단위 테스트로 완전히 검증할 수 있습니다. ## 질문 2: 언제 MVVM 대신 VIPER를 선택해야 하나요? VIPER(View-Interactor-Presenter-Entity-Router)는 책임의 엄격한 분리와 고급 내비게이션을 요구하는 복잡한 애플리케이션에 적합합니다. ### VIPER의 전체 구조 ```swift // UserListProtocols.swift // Contract definitions between VIPER components protocol UserListViewProtocol: AnyObject { var presenter: UserListPresenterProtocol? { get set } func showUsers(_ users: [UserViewModel]) func showError(_ message: String) func showLoading() } protocol UserListPresenterProtocol: AnyObject { var view: UserListViewProtocol? { get set } var interactor: UserListInteractorInputProtocol? { get set } var router: UserListRouterProtocol? { get set } func viewDidLoad() func didSelectUser(_ user: UserViewModel) } protocol UserListInteractorInputProtocol: AnyObject { var presenter: UserListInteractorOutputProtocol? { get set } func fetchUsers() } protocol UserListInteractorOutputProtocol: AnyObject { func didFetchUsers(_ users: [User]) func didFailWithError(_ error: Error) } protocol UserListRouterProtocol: AnyObject { func navigateToUserDetail(with userId: String) } ``` ### Presenter가 로직을 조율 ```swift // UserListPresenter.swift // The Presenter bridges View and Interactor final class UserListPresenter: UserListPresenterProtocol { weak var view: UserListViewProtocol? var interactor: UserListInteractorInputProtocol? var router: UserListRouterProtocol? func viewDidLoad() { view?.showLoading() interactor?.fetchUsers() } func didSelectUser(_ user: UserViewModel) { router?.navigateToUserDetail(with: user.id) } } // Extension for Interactor callbacks extension UserListPresenter: UserListInteractorOutputProtocol { func didFetchUsers(_ users: [User]) { // Model -> ViewModel transformation let viewModels = users.map { UserViewModel(from: $0) } view?.showUsers(viewModels) } func didFailWithError(_ error: Error) { view?.showError(error.localizedDescription) } } ``` > **면접 시 주의사항** > > VIPER는 상당한 보일러플레이트를 동반합니다. 도입의 정당성은 팀 규모(같은 모듈에 여러 명의 개발자) 또는 비즈니스 도메인의 복잡성으로 설명해야 합니다. ## 질문 3: iOS에서 Clean Architecture는 어떻게 구현하나요? Clean Architecture는 코드를 동심원 형태로 정리하고, 비즈니스 규칙을 중심에 두며 프레임워크에 종속되지 않도록 합니다. ### 계층 구조 ```swift // Domain/Entities/User.swift // Pure business entity, no framework dependencies struct User: Identifiable, Equatable { let id: String let email: String let fullName: String let subscriptionLevel: SubscriptionLevel enum SubscriptionLevel: String { case free, premium, enterprise } } // Domain/UseCases/GetUsersUseCase.swift // Use Case encapsulating a business rule protocol GetUsersUseCaseProtocol { func execute() async throws -> [User] } final class GetUsersUseCase: GetUsersUseCaseProtocol { // Dependency on abstraction, not implementation private let repository: UserRepositoryProtocol init(repository: UserRepositoryProtocol) { self.repository = repository } func execute() async throws -> [User] { let users = try await repository.fetchAll() // Business rule: sort by subscription level return users.sorted { $0.subscriptionLevel.rawValue > $1.subscriptionLevel.rawValue } } } ``` ### Repository 패턴을 사용하는 데이터 계층 ```swift // Data/Repositories/UserRepository.swift // Concrete repository implementation final class UserRepository: UserRepositoryProtocol { private let remoteDataSource: UserRemoteDataSourceProtocol private let localDataSource: UserLocalDataSourceProtocol init( remoteDataSource: UserRemoteDataSourceProtocol = UserRemoteDataSource(), localDataSource: UserLocalDataSourceProtocol = UserLocalDataSource() ) { self.remoteDataSource = remoteDataSource self.localDataSource = localDataSource } func fetchAll() async throws -> [User] { do { // Cache-first strategy with fallback let remoteUsers = try await remoteDataSource.fetchUsers() await localDataSource.save(remoteUsers) return remoteUsers } catch { // Fallback to local cache return try await localDataSource.fetchUsers() } } } ``` 이 구성은 각 계층을 독립적으로 테스트할 수 있게 하고, 도메인을 건드리지 않고 구현체(예: CoreData에서 SwiftData로 마이그레이션)를 교체할 수 있게 해 줍니다. ## 질문 4: 일상에서 어떤 디자인 패턴을 사용하나요? 면접관은 이론적 암기가 아니라 패턴의 실제 숙련도를 기대합니다. iOS에서 가장 자주 등장하는 것들을 소개합니다. ### Property Wrapper로 구현하는 의존성 주입 ```swift // DependencyInjection/Container.swift // Simple and effective injection container final class DIContainer { static let shared = DIContainer() private var factories: [String: () -> Any] = [:] func register(_ type: T.Type, factory: @escaping () -> T) { let key = String(describing: type) factories[key] = factory } func resolve(_ type: T.Type) -> T { let key = String(describing: type) guard let factory = factories[key], let instance = factory() as? T else { fatalError("No registration for \(key)") } return instance } } // Property Wrapper for elegant injection @propertyWrapper struct Injected { private var value: T init() { self.value = DIContainer.shared.resolve(T.self) } var wrappedValue: T { get { value } mutating set { value = newValue } } } // Usage in a ViewModel final class PaymentViewModel { @Injected private var paymentService: PaymentServiceProtocol @Injected private var analyticsService: AnalyticsServiceProtocol func processPayment(_ amount: Decimal) async throws { analyticsService.track(.paymentInitiated(amount: amount)) try await paymentService.charge(amount) } } ``` ### 내비게이션을 위한 Coordinator 패턴 ```swift // Coordinators/AppCoordinator.swift // Coordinator managing navigation flow protocol Coordinator: AnyObject { var childCoordinators: [Coordinator] { get set } var navigationController: UINavigationController { get } func start() } final class AppCoordinator: Coordinator { var childCoordinators: [Coordinator] = [] let navigationController: UINavigationController private let window: UIWindow init(window: UIWindow) { self.window = window self.navigationController = UINavigationController() } func start() { window.rootViewController = navigationController window.makeKeyAndVisible() // Flow decision based on state if AuthManager.shared.isAuthenticated { showMainFlow() } else { showAuthFlow() } } private func showAuthFlow() { let authCoordinator = AuthCoordinator(navigationController: navigationController) authCoordinator.delegate = self childCoordinators.append(authCoordinator) authCoordinator.start() } private func showMainFlow() { let mainCoordinator = MainCoordinator(navigationController: navigationController) childCoordinators.append(mainCoordinator) mainCoordinator.start() } } ``` 이 패턴은 내비게이션 로직을 ViewController 밖으로 옮겨, 더 가볍고 재사용성이 높은 ViewController를 만들어 줍니다. ## 질문 5: 모듈 간 통신은 어떻게 처리하나요? 모듈 간 통신은 대형 애플리케이션에서 결정적 요소입니다. 원하는 결합도에 따라 여러 접근 방식이 존재합니다. ### 프로토콜 기반 통신 ```swift // Modules/Shared/ModuleProtocols.swift // Public contracts exposed by each module protocol PaymentModuleProtocol { func startPaymentFlow(for productId: String, completion: @escaping (Result) -> Void) } protocol UserModuleProtocol { func getCurrentUser() -> User? func updateProfile(_ profile: ProfileUpdate) async throws } // Modules/Payment/PaymentModule.swift // Internal module implementation final class PaymentModule: PaymentModuleProtocol { static let shared: PaymentModuleProtocol = PaymentModule() private let paymentService: PaymentService private init() { self.paymentService = PaymentService() } func startPaymentFlow(for productId: String, completion: @escaping (Result) -> Void) { // Module-internal logic paymentService.process(productId: productId, completion: completion) } } ``` ### Combine을 활용한 이벤트 기반 통신 ```swift // EventBus/AppEventBus.swift // Decoupled event bus for async communication enum AppEvent { case userDidLogin(User) case userDidLogout case purchaseCompleted(Receipt) case subscriptionChanged(SubscriptionLevel) } final class AppEventBus { static let shared = AppEventBus() // Private Subject, public Publisher private let eventSubject = PassthroughSubject() var events: AnyPublisher { eventSubject.eraseToAnyPublisher() } func send(_ event: AppEvent) { eventSubject.send(event) } } // Listening in any module final class AnalyticsModule { private var cancellables = Set() init() { AppEventBus.shared.events .sink { [weak self] event in self?.handleEvent(event) } .store(in: &cancellables) } private func handleEvent(_ event: AppEvent) { switch event { case .purchaseCompleted(let receipt): trackPurchase(receipt) case .userDidLogin(let user): identifyUser(user) default: break } } } ``` > **면접 팁** > > 강결합(프로토콜)과 약결합(이벤트) 사이의 선택은 맥락에 달려 있다는 점을 언급해 주세요. 이벤트는 전역 알림에, 프로토콜은 직접 상호작용에 적합합니다. ## 질문 6: 모듈러 아키텍처에서 테스트는 어떻게 구성하나요? 테스트 가능성은 시니어 직무에서 핵심 기준입니다. 좋은 아키텍처는 모든 수준의 테스트를 용이하게 만듭니다. ### ViewModel의 단위 테스트 ```swift // Tests/UserViewModelTests.swift // Unit tests with injected mocks import XCTest @testable import MyApp final class UserViewModelTests: XCTestCase { private var sut: UserViewModel! private var mockRepository: MockUserRepository! override func setUp() { super.setUp() mockRepository = MockUserRepository() sut = UserViewModel(userRepository: mockRepository) } override func tearDown() { sut = nil mockRepository = nil super.tearDown() } func test_loadUsers_success_updatesUsersArray() async { // Given let expectedUsers = [User.mock(), User.mock()] mockRepository.stubbedUsers = expectedUsers // When await sut.loadUsers() // Then XCTAssertEqual(sut.users.count, 2) XCTAssertFalse(sut.isLoading) XCTAssertNil(sut.errorMessage) } func test_loadUsers_failure_setsErrorMessage() async { // Given mockRepository.stubbedError = NetworkError.noConnection // When await sut.loadUsers() // Then XCTAssertTrue(sut.users.isEmpty) XCTAssertNotNil(sut.errorMessage) } } // Mocks/MockUserRepository.swift final class MockUserRepository: UserRepositoryProtocol { var stubbedUsers: [User] = [] var stubbedError: Error? var fetchUsersCalled = false func fetchUsers() -> AnyPublisher<[User], Error> { fetchUsersCalled = true if let error = stubbedError { return Fail(error: error).eraseToAnyPublisher() } return Just(stubbedUsers) .setFailureType(to: Error.self) .eraseToAnyPublisher() } } ``` ### Use Case 통합 테스트 ```swift // Tests/GetUsersUseCaseTests.swift // Integration test verifying business logic final class GetUsersUseCaseTests: XCTestCase { func test_execute_sortsUsersBySubscriptionLevel() async throws { // Given let freeUser = User(id: "1", email: "free@test.com", fullName: "Free", subscriptionLevel: .free) let premiumUser = User(id: "2", email: "premium@test.com", fullName: "Premium", subscriptionLevel: .premium) let mockRepo = MockUserRepository() mockRepo.stubbedUsers = [freeUser, premiumUser] let sut = GetUsersUseCase(repository: mockRepo) // When let result = try await sut.execute() // Then - Premium should be first XCTAssertEqual(result.first?.subscriptionLevel, .premium) XCTAssertEqual(result.last?.subscriptionLevel, .free) } } ``` ## 질문 7: 복잡한 UI 상태는 어떻게 다루나요? 상태 관리는 시니어 애플리케이션에서 매우 중요합니다. 구조적 접근은 버그를 예방하고 디버깅을 단순화합니다. ### Enum을 활용한 상태 머신 ```swift // ViewModels/CheckoutViewModel.swift // State machine for payment flow enum CheckoutState: Equatable { case idle case loadingCart case cartLoaded(CartSummary) case processingPayment case paymentSucceeded(Receipt) case paymentFailed(PaymentError) var isLoading: Bool { switch self { case .loadingCart, .processingPayment: return true default: return false } } } @MainActor final class CheckoutViewModel: ObservableObject { @Published private(set) var state: CheckoutState = .idle private let cartService: CartServiceProtocol private let paymentService: PaymentServiceProtocol init(cartService: CartServiceProtocol, paymentService: PaymentServiceProtocol) { self.cartService = cartService self.paymentService = paymentService } func loadCart() async { state = .loadingCart do { let summary = try await cartService.getSummary() state = .cartLoaded(summary) } catch { state = .paymentFailed(.cartLoadFailed) } } func confirmPayment() async { guard case .cartLoaded(let summary) = state else { return } state = .processingPayment do { let receipt = try await paymentService.charge(summary.total) state = .paymentSucceeded(receipt) } catch let error as PaymentError { state = .paymentFailed(error) } catch { state = .paymentFailed(.unknown) } } } ``` 이런 접근 방식은 일관성 없는 상태(예: 오류가 표시된 채 `isLoading = true`)를 원천적으로 방지합니다. ## 결론 iOS 시니어 면접은 맥락에 부합하는 아키텍처를 선택하고 그 근거를 설명할 수 있는 능력을 평가합니다. 핵심 정리는 다음과 같습니다. **iOS 시니어 아키텍처 체크리스트:** ✅ SwiftUI 또는 UIKit + Combine을 활용한 중간 규모 앱에는 MVVM ✅ 대규모 팀과 복잡한 비즈니스 도메인에는 VIPER ✅ 프레임워크 비종속성을 위한 Clean Architecture ✅ 테스트 가능성을 위한 체계적인 의존성 주입 ✅ 내비게이션을 분리하기 위한 Coordinator 패턴 ✅ 복잡한 흐름을 위한 상태 머신 ✅ 단위, 통합, UI 등 모든 수준의 테스트 **면접에서 보여 줘야 할 점:** - 트레이드오프에 대한 이해(단순한 MVVM vs 구조화된 VIPER) - 실제 프로젝트 사례를 동반한 실무 경험 - 맥락(팀 규모, 복잡성)에 따라 아키텍처를 조정하는 능력 - 아키텍처 품질 기준으로서 테스트에 대한 숙련도 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ko/blog/ios/ios-senior-interview-architecture-design-patterns