iOS 핸드북

post-thumbnail

Ruchit Shah의 May 2025 iOS Interview Handbook
Advanced Swift Interview Topics for iOS Developers 을 한국어로 번역후 MD로 변환한 내용입니다!
원문링크

목차

  1. 보안 및 암호화
  2. 클로저 (Closures)
  3. 메모리 관리 (ARC)
  4. SOLID 원칙
  5. SwiftUI
  6. Property Wrappers
  7. 동시성 (Concurrency)
  8. Copy-on-Write
  9. GCD와 Operation Queue
  10. 기본 개념
  11. 네트워킹
  12. 데이터 지속성
  13. 테스팅
  14. 코드 리뷰 및 최적화

보안 및 암호화

민감한 사용자 데이터를 안전하게 저장하는 방법

설명:

  • 인증 토큰, 비밀번호, 생체 인증 정보와 같은 민감한 데이터는 암호화하여 저장해야 합니다
  • Apple은 이를 위해 Keychain을 제공합니다
  • 파일이나 큰 데이터의 경우 .completeFileProtection과 함께 File Protection API 사용

예제 (KeychainAccess 라이브러리 사용):

import KeychainAccess

let keychain = Keychain(service: "com.yourapp.bundleid")

do {
    try keychain.set("my-secret-token", key: "userToken")
    let token = try keychain.get("userToken")
    print("Retrieved token: \(token ?? "nil")")
} catch {
    print("Keychain error: \(error)")
}

Keychain이란 무엇이며 iOS에서 어떻게 사용되나요?

설명:

  • Keychain은 Apple의 암호화된 데이터베이스로 자격 증명, 토큰, 인증서 및 기타 비밀 정보를 안전하게 저장합니다
  • 데이터는 지속적으로 유지되며 앱 샌드박스에 격리되어 있습니다 (명시적으로 공유하지 않는 한)

예제 (네이티브 API 사용):

import Security

func saveToKeychain(value: String, forKey key: String) {
    let data = Data(value.utf8)
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecValueData as String: data
    ]
    SecItemAdd(query as CFDictionary, nil)
}

func getFromKeychain(forKey key: String) -> String? {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: key,
        kSecReturnData as String: true,
        kSecMatchLimit as String: kSecMatchLimitOne
    ]
    var item: CFTypeRef?
    let status = SecItemCopyMatching(query as CFDictionary, &item)
    
    guard status == errSecSuccess, let data = item as? Data else {
        return nil
    }
    return String(data: data, encoding: .utf8)
}

대칭 암호화와 비대칭 암호화의 차이

대칭 암호화 (예: AES):

  • 암호화와 복호화에 동일한 키 사용
  • 빠르며 대용량 데이터 암호화에 적합

비대칭 암호화 (예: RSA, ECC):

  • 공개 키로 암호화, 개인 키로 복호화
  • 통신에 안전하지만 대칭 암호화보다 느림
특징대칭 (AES)비대칭 (RSA)
하나 (공유)두 개 (공개/개인)
속도빠름느림
사용 사례대량 암호화키 교환, 디지털 서명

서버로 전송되는 데이터 암호화 구현 방법

설명:
항상 HTTPS(TLS) 사용. 추가 암호화 레이어가 필요한 경우:
1. AES로 페이로드 암호화
2. 서버의 공개 RSA 키로 AES 키 암호화
3. 서버가 개인 키로 AES 키 복호화 후 페이로드 복호화

예제 (CryptoKit을 사용한 AES):

import CryptoKit

func encryptWithAES(message: String, key: SymmetricKey) -> Data {
    let data = Data(message.utf8)
    let sealedBox = try! AES.GCM.seal(data, using: key)
    return sealedBox.combined!
}

func decryptWithAES(ciphertext: Data, key: SymmetricKey) -> String? {
    let sealedBox = try! AES.GCM.SealedBox(combined: ciphertext)
    let decryptedData = try! AES.GCM.open(sealedBox, using: key)
    return String(data: decryptedData, encoding: .utf8)
}

// 사용 예
let key = SymmetricKey(size: .bits256)
let encrypted = encryptWithAES(message: "Hello, server!", key: key)
let decrypted = decryptWithAES(ciphertext: encrypted, key: key)
print(decrypted ?? "decryption failed")

iOS에서 암호화 작업에 사용하는 프레임워크

CryptoKit (iOS 13+, 현대적인 Swift API)

  • AES-GCM 암호화
  • SHA 해싱
  • Curve25519 키 교환
  • HMAC, 서명

Security 프레임워크

  • Keychain, 인증서 관리
  • RSA/EC

CommonCrypto

  • 하위 수준 요구 사항을 위한 C 기반 API

예제 (CryptoKit으로 SHA256 해싱):

import CryptoKit

let input = "password123"
let inputData = Data(input.utf8)
let hash = SHA256.hash(data: inputData)
let hashString = hash.compactMap { String(format: "%02x", $0) }.joined()

print("SHA256 Hash: \(hashString)")

보안 데이터 데모 프로젝트

프로젝트 구조:

SecureDataDemo/
├── SecureStorage.swift      ← Keychain 함수
├── EncryptionManager.swift  ← AES 암호화/복호화
└── ViewController.swift     ← UI + 통합

SecureStorage.swift:

import Foundation
import Security

class SecureStorage {
    static func save(value: String, forKey key: String) {
        let data = Data(value.utf8)
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data
        ]
        
        SecItemDelete(query as CFDictionary) // 기존 항목 제거
        SecItemAdd(query as CFDictionary, nil)
    }
    
    static func load(forKey key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        
        guard status == errSecSuccess, let data = item as? Data else {
            return nil
        }
        return String(data: data, encoding: .utf8)
    }
}

EncryptionManager.swift:

import Foundation
import CryptoKit

class EncryptionManager {
    static let shared = EncryptionManager()
    private let symmetricKey = SymmetricKey(size: .bits256)
    
    func encrypt(message: String) -> Data? {
        let data = Data(message.utf8)
        guard let sealedBox = try? AES.GCM.seal(data, using: symmetricKey) else {
            return nil
        }
        return sealedBox.combined
    }
    
    func decrypt(ciphertext: Data) -> String? {
        guard let sealedBox = try? AES.GCM.SealedBox(combined: ciphertext),
              let decryptedData = try? AES.GCM.open(sealedBox, using: symmetricKey) else {
            return nil
        }
        return String(data: decryptedData, encoding: .utf8)
    }
}

ViewController.swift:

import UIKit

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        simulateSecureDataFlow()
    }
    
    func simulateSecureDataFlow() {
        let token = "super_secure_token_9876"
        
        // Keychain에 저장
        SecureStorage.save(value: token, forKey: "authToken")
        
        if let storedToken = SecureStorage.load(forKey: "authToken") {
            print("✅ Token from Keychain: \(storedToken)")
            
            // API로 전송하기 전 토큰 암호화
            if let encrypted = EncryptionManager.shared.encrypt(message: storedToken) {
                print("🔐 Encrypted Data: \(encrypted.base64EncodedString())")
                
                // 서버에서 받은 데이터라고 가정
                let serverReceived = encrypted
                
                // 서버 측에서 복호화
                if let decrypted = EncryptionManager.shared.decrypt(ciphertext: serverReceived) {
                    print("🔓 Decrypted on server: \(decrypted)")
                }
            }
        }
    }
}

요구사항:

  • iOS 13+
  • Swift 5+
  • CryptoKit (내장)
  • KeychainAccess (선택사항)

클로저 (Closures)

Closure란 무엇인가?

정의:

  • 클로저는 코드에서 전달하고 사용할 수 있는 독립적인 기능 블록입니다
  • 함수와 유사하지만 정의된 컨텍스트의 변수와 상수에 대한 참조를 캡처하고 저장할 수 있습니다

클로저의 특징:

  • 변수에 저장 가능
  • 매개변수로 전달 가능
  • 값 반환 가능
  • 외부 값 캡처 가능

문법 예제:

let greet = { (name: String) -> String in
    return "Hello, \(name)!"
}

print(greet("Ruchit")) // 출력: Hello, Ruchit!

실제 사용 예제:

let names = ["Zara", "Ruchit", "Amit", "Bhavik"]
let sorted = names.sorted(by: { $0 < $1 })
print(sorted)

Trailing Closure란?

정의:

  • 함수 호출의 괄호 밖에 작성된 클로저
  • 클로저가 마지막 인자일 때 특히 유용합니다

예제 (일반 vs Trailing):

// Trailing closure 없이
UIView.animate(withDuration: 0.3, animations: {
    view.alpha = 0
})

// Trailing closure 사용
UIView.animate(withDuration: 0.3) {
    view.alpha = 0
}

클로저가 값을 캡처하는 방법

정의:

  • 클로저는 주변 컨텍스트의 상수와 변수를 캡처합니다
  • 변수가 범위를 벗어나더라도 클로저는 이를 유지합니다

예제:

func makeCounter() -> () -> Int {
    var count = 0
    return {
        count += 1
        return count
    }
}

let counter = makeCounter()
print(counter()) // 1
print(counter()) // 2

Retain Cycle과 회피 방법

정의:

  • 두 객체(클래스와 클로저)가 서로를 강하게 참조할 때 발생
  • 메모리 누수를 일으킵니다

문제 예제:

class ViewModel {
    var message = "Hi"
    var handler: (() -> Void)?
    
    func setup() {
        handler = {
            print(self.message) // ❌ 강한 참조 사이클 발생
        }
    }
}

해결 방법: [weak self] 또는 [unowned self] 사용

func setup() {
    handler = { [weak self] in
        print(self?.message ?? "")
    }
}

차이점:

  • weak: self를 옵셔널로 만들고 강한 참조 방지
  • unowned: self가 절대 nil이 되지 않을 것이 확실할 때 사용 (권장하지 않음)

요약 표:

개념설명예제
Closure재사용 가능한 코드 블록{ name in "Hello, \(name)" }
Trailing Closure함수 괄호 밖에 전달된 클로저someFunction { ... }
값 캡처클로저가 컨텍스트 변수를 기억카운터에서 count 캡처
Retain Cycle클로저와 클래스 간 강한 참조로 인한 메모리 누수클로저 내부의 self
회피 방법[weak self] 또는 [unowned self] 사용handler = { [weak self] in ... }

메모리 관리 (ARC)

ARC의 작동 원리

정의:

  • ARC (Automatic Reference Counting)는 Swift가 클래스 인스턴스의 메모리 사용을 추적하고 관리하는 시스템입니다
  • 필요한 동안 객체를 메모리에 유지하고, 참조가 없을 때 자동으로 해제합니다

작동 방식:

  • 변수, 상수 또는 프로퍼티에 클래스 인스턴스를 할당할 때마다 참조 카운트가 증가합니다
  • 참조가 제거되면 카운트가 감소합니다
  • 카운트가 0이 되면 메모리가 자동으로 해제됩니다

예제:

class Person {
    var name: String
    
    init(name: String) {
        self.name = name
        print("\(name) is initialized")
    }
    
    deinit {
        print("\(name) is deallocated")
    }
}

var person1: Person? = Person(name: "Ruchit")
person1 = nil // 참조 카운트가 0으로 떨어져 인스턴스가 해제됨

Strong, Weak, Unowned 참조의 차이

참조 타입객체 유지?옵셔널?사용 사례
strong✅ Yes❌ No기본값, 메모리 유지
weak❌ No✅ YesRetain cycle 방지, 객체가 nil이 될 수 있을 때
unowned❌ No❌ NoRetain cycle 방지, 객체가 항상 존재할 것이 보장될 때

Strong 예제 (기본값):

class Dog {
    var name: String
    init(name: String) { self.name = name }
}

class Owner {
    var dog: Dog // 기본적으로 강한 참조
}

Weak 예제:

class Dog {
    var name: String
    init(name: String) { self.name = name }
}

class Owner {
    weak var dog: Dog? // 참조 카운트를 증가시키지 않음
}

ARC가 순환 참조를 처리하는 방법

Retain Cycle (순환 참조):

  • 두 클래스 인스턴스가 서로를 강하게 참조할 때 발생
  • 어느 것도 해제되지 않아 메모리 누수 발생

클래스 간 Retain Cycle:

class A {
    var b: B?
}

class B {
    var a: A?
}

var objA: A? = A()
var objB: B? = B()

objA?.b = objB
objB?.a = objA

objA = nil
objB = nil // ❌ 둘 다 해제되지 않음 — 메모리 누수!

해결 방법 (weak 또는 unowned 사용):

class B {
    weak var a: A? // Retain cycle 방지
}

클로저 Retain Cycle 예제:

class ViewModel {
    var name = "Ruchit"
    var printName: (() -> Void)?
    
    func setup() {
        printName = {
            print(self.name) // ❌ self가 강하게 캡처됨 → retain cycle
        }
    }
}

[weak self]로 해결:

func setup() {
    printName = { [weak self] in
        print(self?.name ?? "")
    }
}

weak 대신 unowned를 사용하는 경우

사용 시점weak 사용unowned 사용
객체가 nil이 될 수 있음✅ Yes❌ No (해제되면 크래시)
객체가 항상 존재함이 보장됨❌ Optional더 안전, 옵셔널 언래핑 불필요

실제 예제: 클로저에서 unowned 사용

class ViewController {
    var title = "Main"
    
    func doSomething() {
        performAction {
            print(self.title) // 주의하지 않으면 Retain cycle
        }
    }
    
    func performAction(_ completion: @escaping () -> Void) {
        completion()
    }
}

// 개선된 방법:
performAction { [unowned self] in
    print(title) // self가 유지되지 않으며 옵셔널이 아님
}

⚠️ 주의: self가 실행 시점에 100% 존재할 것이 확실할 때만 unowned를 사용하세요. 해제될 가능성이 있다면 weak를 사용하세요.

요약:

용어설명
ARC강한 참조를 추적하여 자동으로 메모리 관리
strong기본 참조, 객체를 유지함
weak유지하지 않으며, 옵셔널 값과 함께 retain cycle 방지
unowned유지하지 않으며, 옵셔널이 아니고 객체가 항상 존재한다고 가정
Retain Cycle두 객체가 서로 강하게 참조 (또는 클로저가 self 캡처)

SOLID 원칙

SOLID란 무엇인가?

SOLID는 소프트웨어를 더 유지보수 가능하고, 확장 가능하며, 테스트 가능하게 만드는 5가지 설계 원칙의 약어입니다. Robert C. Martin (Uncle Bob)이 소개했습니다.

원칙목적키워드
S - Single Responsibility클래스당 하나의 역할단일 책임
O - Open/Closed확장 가능, 수정 불가개방-폐쇄
L - Liskov Substitution하위 타입이 상위 타입처럼 동작리스코프 치환
I - Interface Segregation작고 역할별 프로토콜 선호인터페이스 분리
D - Dependency Inversion추상화에 의존, 구체적 타입 아님의존성 역전

1. 단일 책임 원칙 (SRP)

정의:
클래스는 변경해야 할 이유가 하나만 있어야 합니다. 즉, 한 가지 일만 해야 합니다.

나쁜 예 - ViewController:

class UserProfileViewController: UIViewController {
    func fetchUserData() { /* API 로직 */ }
    func parseJSON() { /* JSON 파싱 */ }
    func updateUI() { /* UI 로직 */ }
}

❌ 이 ViewController는 네트워킹, 파싱, UI를 처리 = 너무 많은 책임

SRP를 적용한 리팩토링:

class UserService {
    func fetchUser(completion: @escaping (User) -> Void) { 
        /* API 호출 */ 
    }
}

class UserProfileViewModel {
    var user: User?
    let service = UserService()
}

class UserProfileViewController: UIViewController {
    var viewModel = UserProfileViewModel()
    func updateUI() { /* UI 렌더링만 */ }
}

✅ 각 클래스가 하나의 책임을 처리: 네트워킹, 데이터 보유, UI

2. 개방-폐쇄 원칙 (OCP)

정의:
소프트웨어 엔티티(클래스, 함수)는 확장에는 열려 있어야 하지만 수정에는 닫혀 있어야 합니다.

Strategy 패턴 사용 예제:

protocol PaymentMethod {
    func pay(amount: Double)
}

class CreditCardPayment: PaymentMethod {
    func pay(amount: Double) { 
        print("Paid with credit card") 
    }
}

class PayPalPayment: PaymentMethod {
    func pay(amount: Double) { 
        print("Paid with PayPal") 
    }
}

class PaymentProcessor {
    var method: PaymentMethod
    
    init(method: PaymentMethod) {
        self.method = method
    }
    
    func processPayment(amount: Double) {
        method.pay(amount: amount)
    }
}

✅ 기존 로직을 수정하지 않고 새 결제 방식(예: ApplePay) 추가 가능

3. 리스코프 치환 원칙 (LSP)

정의:
하위 클래스의 객체는 동작을 깨뜨리지 않고 상위 클래스의 객체로 대체 가능해야 합니다.

위반 예제:

class Bird {
    func fly() {}
}

class Penguin: Bird {
    override func fly() {
        // 펭귄은 날 수 없음 — 잘못된 동작
    }
}

❌ Penguin은 Bird처럼 동작할 수 없습니다. 계약을 위반합니다.

리팩토링:

protocol Bird { }

protocol FlyingBird: Bird {
    func fly()
}

class Eagle: FlyingBird {
    func fly() { print("Eagle flies") }
}

class Penguin: Bird { }

✅ 이제 FlyingBird만 fly()를 준수하여 LSP를 존중합니다

4. 인터페이스 분리 원칙 (ISP)

정의:
하나의 크고 범용적인 프로토콜보다 여러 개의 작고 구체적인 프로토콜을 선호합니다.

나쁜 예제:

protocol Vehicle {
    func drive()
    func fly()
}

class Car: Vehicle {
    func drive() {}
    func fly() {} // ❌ Car와 관련 없음
}

더 나은 설계:

protocol Drivable {
    func drive()
}

protocol Flyable {
    func fly()
}

class Car: Drivable {
    func drive() {}
}

✅ 클래스는 필요한 것만 구현

5. 의존성 역전 원칙 (DIP)

정의:
고수준 모듈은 저수준 모듈에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 합니다.

강하게 결합된 코드:

class UserManager {
    let db = FirebaseManager() // 하드코딩된 의존성
}

추상화를 통한 분리:

protocol DatabaseManager {
    func save(data: String)
}

class FirebaseManager: DatabaseManager {
    func save(data: String) { /* Firebase 코드 */ }
}

class UserManager {
    let db: DatabaseManager
    
    init(db: DatabaseManager) {
        self.db = db
    }
    
    func saveUser() {
        db.save(data: "User")
    }
}

✅ 이제 테스트나 런타임 시 어떤 DatabaseManager든 주입 가능 (예: CoreData, REST)

iOS에서 SOLID 적용

원칙iOS에서 사용처
SRPViewController에서 네트워킹, 파싱, UI 분리
OCP프로토콜/확장으로 기능 추가 (예: 결제, 테마)
LSP재사용 가능한 컴포넌트를 위한 프로토콜 지향 프로그래밍
ISP모듈식 프로토콜 설계 (예: UITableViewDataSource, Delegate)
DIP서비스, 코디네이터, 매니저를 위한 프로토콜 기반 아키텍처

전체 SOLID 원칙 예제 코드

// MARK: - 1. Single Responsibility Principle (SRP)
class UserService {
    func fetchUser(completion: @escaping (String) -> Void) {
        completion("Ruchit")
    }
}

class UserProfileViewModel {
    var userName: String?
    let service = UserService()
    
    func loadUser() {
        service.fetchUser { [weak self] name in
            self?.userName = name
        }
    }
}

class UserProfileViewController {
    let viewModel = UserProfileViewModel()
    
    func displayUser() {
        viewModel.loadUser()
        print("User: \(viewModel.userName ?? "")")
    }
}

// MARK: - 2. Open/Closed Principle (OCP)
protocol PaymentMethod {
    func pay(amount: Double)
}

class CreditCardPayment: PaymentMethod {
    func pay(amount: Double) {
        print("Paid \(amount) with Credit Card")
    }
}

class PayPalPayment: PaymentMethod {
    func pay(amount: Double) {
        print("Paid \(amount) with PayPal")
    }
}

class PaymentProcessor {
    var method: PaymentMethod
    
    init(method: PaymentMethod) {
        self.method = method
    }
    
    func process(amount: Double) {
        method.pay(amount: amount)
    }
}

// MARK: - 3. Liskov Substitution Principle (LSP)
protocol Bird {}

protocol FlyingBird: Bird {
    func fly()
}

class Eagle: FlyingBird {
    func fly() {
        print("Eagle soars high")
    }
}

class Penguin: Bird {} // 날 수 없으므로 fly() 미구현

// MARK: - 4. Interface Segregation Principle (ISP)
protocol Drivable {
    func drive()
}

protocol Flyable {
    func fly()
}

class Car: Drivable {
    func drive() {
        print("Driving a car")
    }
}

class Plane: Flyable {
    func fly() {
        print("Flying a plane")
    }
}

// MARK: - 5. Dependency Inversion Principle (DIP)
protocol DatabaseManager {
    func save(data: String)
}

class FirebaseManager: DatabaseManager {
    func save(data: String) {
        print("Saved to Firebase: \(data)")
    }
}

class UserManager {
    let db: DatabaseManager
    
    init(db: DatabaseManager) {
        self.db = db
    }
    
    func saveUser(name: String) {
        db.save(data: name)
    }
}

SwiftUI

SwiftUI 데이터 흐름: @State, @Binding, @ObservedObject 등

SwiftUI에서는 property wrapper를 사용하여 뷰 간 데이터를 전달하고 상태를 관리합니다.

@State

뷰 내에서 로컬 상태를 저장하는 데 사용됩니다. 불린 토글이나 사용자 입력처럼 단순하고 변경 가능한 데이터에 주로 사용됩니다.

struct ContentView: View {
    @State private var isOn = false
    
    var body: some View {
        Toggle("Switch", isOn: $isOn)
    }
}
  • isOn은 로컬 상태 변수이고, $isOn은 해당 상태에 대한 바인딩입니다

@Binding

다른 뷰의 상태에 대한 양방향 연결을 생성합니다. 일반적으로 부모 뷰에서 자식 뷰로 전달되며, 자식 뷰가 부모 뷰의 상태를 수정할 수 있게 합니다.

struct ParentView: View {
    @State private var isToggled = false
    
    var body: some View {
        ChildView(isOn: $isToggled)
    }
}

struct ChildView: View {
    @Binding var isOn: Bool
    
    var body: some View {
        Toggle("Child Switch", isOn: $isOn)
    }
}

@ObservedObject

ObservableObject 프로토콜을 준수하는 외부 상태 객체의 변경사항을 관찰하는 데 사용됩니다. 여러 뷰에서 데이터를 공유하고 데이터가 변경될 때 자동으로 업데이트하는 데 유용합니다.

class UserData: ObservableObject {
    @Published var name: String = "John Doe"
}

struct ProfileView: View {
    @ObservedObject var userData: UserData
    
    var body: some View {
        Text(userData.name)
    }
}

@EnvironmentObject

전역적으로 공유되는 데이터에 사용됩니다. 뷰 계층 구조의 최상위에서 설정되며, 계층 구조의 모든 자식 뷰에서 접근할 수 있습니다.

class ThemeData: ObservableObject {
    @Published var themeColor: Color = .blue
}

struct ContentView: View {
    @EnvironmentObject var themeData: ThemeData
    
    var body: some View {
        Text("The theme color is \(themeData.themeColor.description)")
            .foregroundColor(themeData.themeColor)
    }
}

@EnvironmentObject 주입:

@main
struct MyApp: App {
    @StateObject var themeData = ThemeData()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(themeData)
        }
    }
}

SwiftUI에서 조건부 렌더링 처리

SwiftUI에서는 뷰의 body에서 if, else, switch 문을 직접 사용하여 조건부 렌더링을 처리합니다.

struct ContentView: View {
    @State private var isLoggedIn = false
    
    var body: some View {
        VStack {
            if isLoggedIn {
                Text("Welcome back!")
            } else {
                Text("Please log in.")
            }
            
            Button(action: {
                isLoggedIn.toggle()
            }) {
                Text(isLoggedIn ? "Log Out" : "Log In")
            }
        }
    }
}

뷰 업데이트: SwiftUI vs UIKit

SwiftUI:

  • 선언적 뷰 계층 구조
  • 상태가 변경되면 뷰가 자동으로 업데이트됨
  • 데이터 모델 변경에 반응하여 뷰를 경량화하고 자동 업데이트
  • @State 또는 @ObservedObject가 변경되면 SwiftUI가 자동으로 UI를 업데이트

UIKit:

  • 명령형 접근 방식
  • 모델이 변경될 때 UI를 명시적으로 업데이트해야 함
  • setNeedsLayout() 또는 reloadData() 호출 필요
  • 예: UITableView의 경우 tableView.reloadData() 호출

Custom ViewModifier 만들기

ViewModifier는 스타일링이나 변환을 재사용 가능한 방식으로 뷰에 적용하는 데 사용됩니다.

struct RedBorderModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .border(Color.red, width: 5)
            .padding()
    }
}

extension View {
    func redBorder() -> some View {
        self.modifier(RedBorderModifier())
    }
}

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
            .redBorder()
    }
}

Property Wrappers

Property Wrapper란 무엇인가?

정의:
Property wrapper는 프로퍼티의 동작을 캡슐화하는 제네릭 구조체 또는 클래스입니다. 프로퍼티 선언을 깔끔하고 재사용 가능하게 유지하면서 프로퍼티 접근(getter와 setter) 주변에 로직을 추가하는 방법을 제공합니다.

@ 기호를 사용하여 정의되며 모든 프로퍼티에 적용할 수 있습니다.

기본 예제:

@propertyWrapper
struct Uppercase {
    private var value: String
    
    init(wrappedValue: String) {
        self.value = wrappedValue
    }
    
    var wrappedValue: String {
        get { value }
        set { value = newValue.uppercased() }
    }
}

struct User {
    @Uppercase var name: String
}

let user = User(name: "john doe")
print(user.name) // 출력: "JOHN DOE"

@State 또는 @AppStorage는 내부적으로 어떻게 작동하나요?

둘 다 SwiftUI에서 제공하는 데이터 바인딩, 지속성 및 UI 업데이트를 처리하는 특수 property wrapper입니다.

@State

뷰의 변경 가능한 상태를 관리합니다. 상태가 변경되면 SwiftUI가 뷰를 다시 렌더링합니다.

내부 동작:

  • 뷰 자체만 접근할 수 있는 private, mutable 프로퍼티에 값을 저장
  • 값이 변경되면 SwiftUI가 이 상태를 사용하는 뷰의 재렌더링을 트리거
  • 메모리에 저장되며 앱 실행 간 유지되지 않음
struct ContentView: View {
    @State private var counter = 0
    
    var body: some View {
        VStack {
            Text("Counter: \(counter)")
            Button("Increment") {
                counter += 1
            }
        }
    }
}

@AppStorage

UserDefaults 시스템에서 데이터를 저장하고 검색하는 방법을 제공하며 자동 동기화됩니다.

내부 동작:

  • UserDefaults를 래핑하고 UserDefaults의 값에 프로퍼티를 바인딩
  • 프로퍼티에 대한 변경사항이 자동으로 UserDefaults에 저장됨
  • 값이 변경되면 뷰가 재렌더링됨
struct ContentView: View {
    @AppStorage("userName") private var userName: String = "Guest"
    
    var body: some View {
        Text("Hello, \(userName)!")
    }
}

Custom Property Wrapper 만들기

사용 사례: 이메일 형식 검증

@propertyWrapper
struct ValidEmail {
    private var email: String
    
    init(wrappedValue: String) {
        self.email = wrappedValue
    }
    
    var wrappedValue: String {
        get { email }
        set {
            // 간단한 이메일 검증
            if newValue.contains("@") {
                email = newValue
            } else {
                email = "Invalid email"
            }
        }
    }
}

struct User {
    @ValidEmail var email: String
}

let user = User(email: "john.doe@example.com")
print(user.email) // 출력: "john.doe@example.com"

Property Wrapper와 Codable

Property wrapper는 Codable과 함께 작동할 수 있지만 신중하게 관리해야 합니다. Codable은 프로퍼티가 인코딩 및 디코딩되어야 하므로 wrapper가 인코딩 및 디코딩 프로세스와 어떻게 상호작용하는지 정의해야 합니다.

Custom Codable Property Wrapper 예제:

@propertyWrapper
struct CodableString: Codable {
    var value: String
    
    init(wrappedValue: String) {
        self.value = wrappedValue
    }
    
    var wrappedValue: String {
        get { value }
        set { value = newValue }
    }
}

struct User: Codable {
    @CodableString var name: String
}

let user = User(name: "John Doe")

// 인코딩
let encoder = JSONEncoder()
if let encoded = try? encoder.encode(user) {
    print(String(data: encoded, encoding: .utf8)!) // 출력: {"name":"John Doe"}
}

// 디코딩
let decoder = JSONDecoder()
if let decoded = try? decoder.decode(User.self, from: encoded) {
    print(decoded.name) // 출력: "John Doe"
}

동시성 (Concurrency)

async/await를 사용한 비동기 코드 처리

Swift에서 async/await는 비동기 코드를 더 읽기 쉽고 구조화되게 만드는 언어 기능입니다. 비동기 코드를 동기 코드처럼 보이게 작성할 수 있습니다.

  • async: 함수나 클로저를 비동기로 표시합니다. 즉시 완료되지 않는 작업을 수행함을 나타냅니다
  • await: 비동기 작업이 완료될 때까지 현재 함수의 실행을 일시 중지합니다

예제:

import Foundation

// 네트워크 요청을 시뮬레이션하는 async 함수
func fetchData() async -> String {
    // 네트워크 지연 시뮬레이션
    await Task.sleep(2 * 1_000_000_000) // 2초
    return "Data fetched"
}

// async 함수 호출
func loadData() async {
    let data = await fetchData()
    print(data)
}

Task {
    await loadData()
}

Structured Concurrency란?

정의:
Structured concurrency는 동시 작업의 실행을 구조화된 방식으로 조직하는 모델입니다. 작업에 명확한 범위와 수명이 부여되고 실행이 명시적으로 관리됩니다.

Swift 5.5는 TaskTaskGroup을 통해 structured concurrency를 도입했습니다. 이 모델은 작업이 잘 정의된 범위 내에서 생성되도록 보장하여 수명 주기를 관리하고 의도하지 않은 작업 누수와 같은 문제를 방지합니다.

예제:

func fetchMultipleData() async {
    async let data1 = fetchData()
    async let data2 = fetchData()
    
    // 둘 다 동시에 대기
    let results = await [data1, data2]
    print(results)
}

Completion Handler 기반 API를 async/await로 변환

기존 API (Completion Handler):

func fetchDataWithCompletion(completion: @escaping (String?, Error?) -> Void) {
    // 네트워크 요청 시뮬레이션
    DispatchQueue.global().async {
        // 지연 시뮬레이션
        sleep(2)
        completion("Data fetched", nil)
    }
}

async/await로 변환:

func fetchData() async throws -> String {
    return try await withCheckedThrowingContinuation { continuation in
        fetchDataWithCompletion { data, error in
            if let error = error {
                continuation.resume(throwing: error)
            } else if let data = data {
                continuation.resume(returning: data)
            }
        }
    }
}

withCheckedThrowingContinuation 함수가 completion handler 기반 API를 async/await로 연결합니다.

Task, DetachedTask, TaskGroup의 차이

타입설명사용 사례
Task현재 컨텍스트에 연결된 비동기 작업 단위. 생성된 범위가 끝나면 자동으로 취소됨범위의 수명 주기를 따라야 하는 작업
DetachedTask현재 컨텍스트나 범위에 연결되지 않은 비동기 작업. 독립적으로 실행되며 자동 취소되지 않음자동으로 취소되지 않아야 하는 장기 실행 작업
TaskGroup여러 비동기 작업을 동시에 실행하고 모두 완료될 때까지 기다리는 방법여러 동시 작업 관리

Task 예제:

Task {
    let result = await fetchData()
    print(result)
}

DetachedTask 예제:

DetachedTask {
    let result = await fetchData()
    print(result)
}

TaskGroup 예제:

func fetchMultipleData() async {
    await withTaskGroup(of: String.self) { group in
        group.async {
            return await fetchData()
        }
        group.async {
            return await fetchData()
        }
        
        for await result in group {
            print(result)
        }
    }
}

Copy-on-Write

Copy-on-Write란 무엇이며 메모리 사용을 어떻게 최적화하나요?

정의:
Copy-on-Write (COW)는 실제로 수정될 때까지 불필요한 데이터 복사를 피하는 메모리 최적화 기법입니다. Swift에서 Array, String, Dictionary 같은 데이터 구조는 COW를 구현합니다.

작동 방식:

  • 복사 시: 시스템이 실제로 데이터를 복사하지 않고 동일한 데이터에 대한 참조만 공유합니다
  • 수정 시: 참조 중 하나가 데이터를 수정하려고 할 때만 데이터가 복사되어 변경사항이 해당 참조에만 격리됩니다

Array에서 Copy-on-Write 시연:

var array1 = [1, 2, 3]
var array2 = array1 // 아직 복사 안 됨, array1과 array2가 같은 메모리 공유

array2.append(4) // array2를 수정하므로 복사가 트리거됨

print(array1) // [1, 2, 3]
print(array2) // [1, 2, 3, 4]

Swift는 Array, String, Dictionary를 복사할 때 메모리를 어떻게 관리하나요?

  • Arrays: COW 사용. 배열을 변수에 할당하거나 함수에 전달할 때 Swift는 즉시 배열을 복사하지 않습니다. 새 배열이 수정될 때만 Swift가 데이터의 복사본을 만듭니다
  • Strings: COW 사용. 문자열을 다른 변수에 할당하거나 함수에 전달할 때 실제 복사는 발생하지 않습니다. 원본 문자열이나 새 문자열이 수정되면 해당 변수에 대해 새 문자열 복사본이 생성됩니다
  • Dictionaries: COW 사용. 딕셔너리가 복사되면 수정될 때까지 원본과 복사본 간에 기본 저장소가 공유됩니다

Custom Struct에서 Copy-on-Write 동작 시연

class DataStorage {
    var data: [Int]
    
    init(data: [Int]) {
        self.data = data
    }
}

struct CopyOnWriteStruct {
    private var storage: DataStorage
    
    init(data: [Int]) {
        self.storage = DataStorage(data: data)
    }
    
    // 수정 시에만 고유한 복사본 보장
    private mutating func copyIfNeeded() {
        if !isKnownUniquelyReferenced(&storage) {
            storage = DataStorage(data: storage.data)
        }
    }
    
    var data: [Int] {
        get { storage.data }
        set {
            copyIfNeeded()
            storage.data = newValue
        }
    }
}

var struct1 = CopyOnWriteStruct(data: [1, 2, 3])
var struct2 = struct1 // 아직 복사 안 됨, struct1과 struct2가 같은 데이터 공유

struct2.data.append(4) // struct2가 수정될 때 복사 트리거

print(struct1.data) // [1, 2, 3]
print(struct2.data) // [1, 2, 3, 4]

설명:

  • DataStorage는 실제 데이터를 보유하는 클래스입니다
  • copyIfNeeded() 메서드는 구조체가 고유하게 참조되지 않을 때만 데이터 복사본을 만듭니다
  • data 프로퍼티는 데이터를 읽고 쓰며, 필요할 때만(즉, 변경이 발생할 때) 데이터가 복사되도록 보장합니다

GCD와 Operation Queue

DispatchQueue.main.async와 .sync의 차이

DispatchQueue.main.async:

  • 메인 큐에서 코드 블록을 비동기적으로 실행
  • 코드가 미래의 어느 시점에 실행되지만 호출 스레드로 제어가 즉시 반환됨
  • 논블로킹이므로 메인 스레드가 다른 작업을 계속 실행할 수 있음
DispatchQueue.main.async {
    print("This is async on the main queue")
}
// 제어가 즉시 반환됨

DispatchQueue.main.sync:

  • 메인 큐에서 코드 블록을 동기적으로 실행
  • 작업이 완료될 때까지 호출 스레드가 차단됨
  • 메인 스레드에서 메인 큐에 .sync를 호출하면 데드락 발생 (자신이 완료되기를 기다리며 차단됨)
DispatchQueue.main.sync {
    print("This is sync on the main queue")
}
// 작업이 완료된 후 제어가 반환됨

핵심 차이:

  • .async: 논블로킹, 현재 스레드를 차단하지 않고 미래에 코드를 실행
  • .sync: 블로킹, 호출자에게 반환되기 전에 코드가 완료될 때까지 대기

Serial Queue vs Concurrent Queue

Serial Queue:

  • 추가된 순서대로 한 번에 하나의 작업을 처리
  • 작업이 서로 의존하거나 경쟁 조건을 피하기 위해 순차적으로 실행해야 할 때 유용
let serialQueue = DispatchQueue(label: "com.example.serialQueue")

serialQueue.async {
    print("Task 1")
}
serialQueue.async {
    print("Task 2")
}
// "Task 1"이 완료된 후 "Task 2"가 시작됨

Concurrent Queue:

  • 여러 작업을 동시에 실행 가능
  • 서로 의존하지 않고 병렬로 수행할 수 있는 작업에 유용
let concurrentQueue = DispatchQueue(label: "com.example.concurrentQueue", 
                                    attributes: .concurrent)

concurrentQueue.async {
    print("Task 1")
}
concurrentQueue.async {
    print("Task 2")
}
// "Task 1"과 "Task 2"가 동시에 실행될 수 있음

GCD를 사용할 때 데드락을 피하는 방법

데드락은 두 스레드가 서로 완료되기를 기다릴 때 발생하여 무한 대기 상태가 됩니다.

데드락의 일반적인 원인:

  • 동일한 큐 내에서 해당 큐에 .sync 호출

데드락 피하기:

  • 메인 스레드에서 DispatchQueue.main.sync 호출하지 않기
  • 이미 작업을 실행 중인 큐에서 .sync 호출하지 않기

데드락 예제:

DispatchQueue.main.sync {
    DispatchQueue.main.sync {
        // ❌ 데드락: 메인 스레드가 자신을 기다리며 차단됨
    }
}

OperationQueue

OperationQueue란?
더 구조화된 방식으로 작업(태스크)을 관리하고 조직할 수 있는 GCD의 상위 수준 추상화입니다.

OperationQueue의 GCD 대비 장점

기능설명
작업 관리우선순위, 의존성, 취소를 포함한 더 나은 작업 관리
실행 제어maxConcurrentOperationCount 프로퍼티로 최대 동시 작업 수 제어
취소작업을 취소할 수 있음 (GCD는 불가능)
의존성작업 간 의존성 정의 가능 (작업 A가 완료된 후 작업 B 시작)

예제:

let operationQueue = OperationQueue()

let op1 = BlockOperation {
    print("Task 1")
}

let op2 = BlockOperation {
    print("Task 2")
}

op2.addDependency(op1) // op1 완료 후 op2 시작
operationQueue.addOperations([op1, op2], waitUntilFinished: false)

Operation을 실행 중에 취소하는 방법

cancel() 메서드를 호출하여 작업을 취소할 수 있습니다. 그러나 효과를 보려면 작업의 실행 코드 내에서 정기적으로 취소를 확인하고 취소되면 중지해야 합니다.

let operationQueue = OperationQueue()

let op = BlockOperation {
    for i in 0..<10 {
        if op.isCancelled {
            print("Operation was cancelled")
            return
        }
        print(i)
    }
}

operationQueue.addOperation(op)

DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
    op.cancel() // 2초 후 작업 취소
}

Operation 간 의존성 관리

addDependency() 메서드를 사용하여 작업 간 의존성을 정의할 수 있습니다.

let operationQueue = OperationQueue()

let op1 = BlockOperation {
    print("Task 1")
}

let op2 = BlockOperation {
    print("Task 2")
}

let op3 = BlockOperation {
    print("Task 3")
}

op2.addDependency(op1) // op1 완료 후 op2 실행
op3.addDependency(op2) // op2 완료 후 op3 실행

operationQueue.addOperations([op1, op2, op3], waitUntilFinished: false)

실행 순서: op1 → op2 → op3

요약:

  • GCD: 간단한 방법(async와 sync)으로 동시성을 관리하지만 동기화, 데드락, 작업 조직의 수동 처리 필요
  • OperationQueue: 취소, 의존성, 우선순위를 포함한 동시 작업 관리를 위한 상위 수준 추상화 제공
  • Serial Queue: 순차 실행에 사용
  • Concurrent Queue: 병렬 실행 허용
  • 데드락 방지: 메인 큐나 현재 실행 중인 큐에서 sync 사용 피하기

기본 개념

Class, Struct, Enum의 차이

Class (클래스):

  • 참조 타입: 새 변수에 할당하거나 함수에 전달할 때 복사본이 아닌 같은 인스턴스에 대한 참조를 전달
  • 다른 클래스에서 상속 가능
  • 메모리 관리를 위한 초기화 해제(deinit) 지원
class Dog {
    var name: String
    init(name: String) {
        self.name = name
    }
}

let dog1 = Dog(name: "Buddy")
let dog2 = dog1
dog2.name = "Max"

print(dog1.name) // 출력: Max (dog1과 dog2가 같은 객체 참조)

Struct (구조체):

  • 값 타입: 새 변수에 할당하거나 함수에 전달할 때 인스턴스의 복사본 생성
  • 상속이나 초기화 해제 지원 안 함
  • init, ==(동등 비교) 등 자동 합성
struct Dog {
    var name: String
}

var dog1 = Dog(name: "Buddy")
var dog2 = dog1
dog2.name = "Max"

print(dog1.name) // 출력: Buddy (dog1과 dog2는 독립적)

Enum (열거형):

  • 값 타입: 관련 값들의 공통 타입을 정의
  • 케이스와 값을 연관시킬 수 있음
  • 상태 관리와 제어 흐름에 자주 사용됨
enum DogBreed {
    case bulldog, poodle, labrador
}

var myDogBreed = DogBreed.bulldog
myDogBreed = .poodle

ARC (Automatic Reference Counting)

정의:
Swift가 메모리의 객체에 대한 참조를 추적하는 데 사용하는 메모리 관리 시스템입니다. ARC는 더 이상 사용되지 않는 객체의 메모리가 해제되도록 보장하여 메모리 누수를 방지합니다.

작동 방식:

  • 객체가 변수에 할당될 때마다 해당 객체에 대한 참조가 생성됨
  • ARC는 객체에 대한 참조 수를 카운트함
  • 객체의 참조 카운트가 0에 도달하면(더 이상 강한 참조가 없음) ARC가 자동으로 메모리를 해제함
class Car {
    var brand: String
    
    init(brand: String) {
        self.brand = brand
    }
    
    deinit {
        print("\(brand) is being deallocated")
    }
}

var car1: Car? = Car(brand: "Tesla")
var car2 = car1 // 참조 카운트 = 2

car1 = nil // 참조 카운트 = 1
car2 = nil // 참조 카운트 = 0, "Tesla is being deallocated" 출력

Strong, Weak, Unowned 참조

Strong 참조:

  • 기본적으로 모든 객체 참조는 strong입니다
  • 객체의 참조 카운트를 증가시킴
  • 강한 참조가 있는 한 객체는 해제되지 않음
var dog = Dog(name: "Buddy")
// 'dog'는 Dog 인스턴스에 대한 강한 참조

Weak 참조:

  • 객체를 유지하지 않으므로 참조 카운트를 증가시키지 않음
  • 객체가 해제되면 weak 참조는 자동으로 nil이 됨
  • 순환 참조가 있을 수 있을 때 사용 (예: 부모-자식 관계)
class Owner {
    var name: String
    init(name: String) {
        self.name = name
    }
}

class Dog {
    var owner: Owner?
}

var owner1: Owner? = Owner(name: "John")
var dog1 = Dog()
dog1.owner = owner1

owner1 = nil // owner1이 해제되고 dog1.owner는 nil이 됨 (weak 참조)

Unowned 참조:

  • weak 참조와 유사하지만 참조된 객체가 참조의 수명 동안 절대 해제되지 않을 것으로 가정
  • 강한 참조 사이클이 있지만 참조된 객체가 unowned 참조보다 먼저 해제되지 않을 것이 확실할 때 사용
class Teacher {
    var name: String
    var student: Student?
    
    init(name: String) {
        self.name = name
    }
}

class Student {
    var name: String
    var teacher: Teacher?
    
    init(name: String) {
        self.name = name
    }
}

var teacher = Teacher(name: "Mr. Smith")
var student = Student(name: "Alice")

teacher.student = student
student.teacher = teacher // 강한 참조 사이클을 피하기 위한 unowned 참조

값 타입 vs 참조 타입

값 타입:

  • 각 인스턴스가 데이터의 고유한 복사본을 유지
  • 다른 변수에 할당하거나 함수에 전달할 때 원본 객체의 복사본이 생성됨
  • struct, enum, 기본 타입(Int, Double, Array, Dictionary 등)

특징:

  • 할당 시 복사됨
  • 한 인스턴스의 변경이 다른 인스턴스에 영향을 주지 않음
struct Point {
    var x: Int
    var y: Int
}

var point1 = Point(x: 1, y: 2)
var point2 = point1
point2.x = 10

print(point1.x) // 출력: 1
print(point2.x) // 출력: 10

참조 타입:

  • 인스턴스가 같은 기본 데이터에 대한 참조를 공유
  • 다른 변수에 할당하거나 함수에 전달할 때 두 변수가 같은 객체를 가리킴
  • class, closure

특징:

  • 복사본이 아닌 공유 참조
  • 한 참조의 변경이 객체에 대한 모든 참조에 영향을 줌
class Person {
    var name: String
    init(name: String) {
        self.name = name
    }
}

var person1 = Person(name: "John")
var person2 = person1
person2.name = "Steve"

print(person1.name) // 출력: Steve (두 변수가 같은 인스턴스 참조)

네트워킹

Swift에서 네트워크 호출하는 방법

현대 Swift(Swift 5.5+)에서는 URLSessionasync/await를 사용하는 것이 선호됩니다. 이전에는 클로저나 Alamofire 같은 서드파티 라이브러리를 사용했습니다.

async/await 사용 예제 (Swift 5.5+):

struct Post: Codable {
    let id: Int
    let title: String
}

func fetchPosts() async throws -> [Post] {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
    let (data, _) = try await URLSession.shared.data(from: url)
    return try JSONDecoder().decode([Post].self, from: data)
}

Completion Handler 사용 (이전 방식):

func fetchPosts(completion: @escaping ([Post]?) -> Void) {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
    
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data = data else {
            completion(nil)
            return
        }
        let posts = try? JSONDecoder().decode([Post].self, from: data)
        completion(posts)
    }.resume()
}

Codable로 디코딩 에러 처리하는 방법

Swift의 Codable 디코딩은 여러 이유로 실패할 수 있습니다(키 누락, 타입 불일치, 잘못된 형식 등). 이를 처리하려면 do-catch로 디코딩을 감싸고 선택적으로 에러를 검사합니다.

do {
    let decoded = try JSONDecoder().decode([Post].self, from: data)
    return decoded
} catch let DecodingError.keyNotFound(key, context) {
    print("Missing key: \(key), context: \(context)")
} catch let DecodingError.typeMismatch(type, context) {
    print("Type mismatch: \(type), context: \(context)")
} catch {
    print("Decoding failed: \(error.localizedDescription)")
}

팁: 자세한 에러를 로그하려면 debugDescription 또는 localizedDescription 사용

Alamofire 같은 서드파티 라이브러리 사용 여부

Alamofire를 사용하는 이유:

  • 요청 체이닝을 위한 더 깔끔한 문법
  • 멀티파트 업로드, 백그라운드 세션 내장 지원
  • SSL 피닝, 재시도 정책, 네트워크 도달 가능성 처리
  • 요청/응답 검증을 위한 더 나은 추상화

때때로 피하는 이유:

  • 외부 의존성 및 앱 크기 증가
  • 네이티브 URLSession + async/await가 이제 대부분의 요구사항 커버
  • URLSession 동작에 대한 세밀한 제어

실무 경험:

  • 소/중규모 앱 → async/await를 사용한 네이티브 URLSession
  • 대규모 앱 또는 팀 → 더 나은 네트워크 추상화 레이어를 위해 Alamofire 또는 Moya와 결합

데이터 지속성

사용한 지속성 방법

iOS에는 여러 일반적인 지속성 방법이 있으며 각각 다른 사용 사례에 적합합니다:

1. UserDefaults

  • 용도: 경량 데이터 저장 (간단한 키-값 쌍)
  • 적합 대상: 설정, 플래그 같은 작은 설정
// 데이터 저장
UserDefaults.standard.set("dark", forKey: "theme")

// 데이터 검색
let theme = UserDefaults.standard.string(forKey: "theme")

2. CoreData

  • 용도: 관계를 가진 복잡한 데이터 모델 관리
  • 적합 대상: 엔티티와 속성이 많은 더 크고 구조화된 데이터
  • 쿼리, 필터링, 정렬 기능 제공
// 엔티티 생성 및 저장
let context = persistentContainer.viewContext
let newEntity = Entity(context: context)
newEntity.attribute = "value"

do {
    try context.save()
} catch {
    print("Failed to save context: \(error)")
}

// 엔티티 가져오기
let request: NSFetchRequest<Entity> = Entity.fetchRequest()
do {
    let entities = try context.fetch(request)
} catch {
    print("Failed to fetch entities: \(error)")
}

3. Realm

  • 용도: 간단한 설정으로 빠른 로컬 데이터베이스
  • 적합 대상: 빠른 로컬 데이터베이스와 크로스 플랫폼 호환성이 필요한 모바일 앱
  • 관계형 및 플랫 데이터 모델 모두 지원
import RealmSwift

class Cocktail: Object {
    @objc dynamic var name = ""
    @objc dynamic var isFavorite = false
}

let realm = try! Realm()

// 즐겨찾기 칵테일 저장
let cocktail = Cocktail()
cocktail.name = "Margarita"
cocktail.isFavorite = true

try! realm.write {
    realm.add(cocktail)
}

// 모든 즐겨찾기 칵테일 가져오기
let favoriteCocktails = realm.objects(Cocktail.self).filter("isFavorite == true")

4. SQLite

  • 용도: 크고 구조화된 데이터를 위한 관계형 데이터베이스
  • 적합 대상: 직접 SQL 쿼리가 필요하거나 CoreData나 Realm 같은 상위 수준 라이브러리를 선호하지 않을 때

앱에서 즐겨찾기 칵테일을 모델링하고 저장하는 방법

칵테일의 이름, 재료, 즐겨찾기 여부 같은 속성을 저장하는 앱을 만든다고 가정해봅시다.

CoreData 예제:

1. CoreData 모델 정의:

  • name: String
  • ingredients: String (또는 별도 엔티티로 저장하려면 관계 사용)
  • isFavorite: Boolean
import CoreData

func addCocktail(name: String, ingredients: String, isFavorite: Bool) {
    let context = persistentContainer.viewContext
    let newCocktail = Cocktail(context: context)
    
    newCocktail.name = name
    newCocktail.ingredients = ingredients
    newCocktail.isFavorite = isFavorite
    
    do {
        try context.save()
    } catch {
        print("Failed to save cocktail: \(error)")
    }
}

func fetchFavoriteCocktails() -> [Cocktail] {
    let context = persistentContainer.viewContext
    let fetchRequest: NSFetchRequest<Cocktail> = Cocktail.fetchRequest()
    fetchRequest.predicate = NSPredicate(format: "isFavorite == true")
    
    do {
        let favoriteCocktails = try context.fetch(fetchRequest)
        return favoriteCocktails
    } catch {
        print("Failed to fetch favorite cocktails: \(error)")
        return []
    }
}

Realm 예제:

import RealmSwift

class Cocktail: Object {
    @objc dynamic var name = ""
    @objc dynamic var ingredients = ""
    @objc dynamic var isFavorite = false
}

func addCocktailToRealm(name: String, ingredients: String, isFavorite: Bool) {
    let cocktail = Cocktail()
    cocktail.name = name
    cocktail.ingredients = ingredients
    cocktail.isFavorite = isFavorite
    
    let realm = try! Realm()
    try! realm.write {
        realm.add(cocktail)
    }
}

func fetchFavoriteCocktailsFromRealm() -> Results<Cocktail> {
    let realm = try! Realm()
    return realm.objects(Cocktail.self).filter("isFavorite == true")
}

테스팅

Swift에서 단위 테스트 작성하는 방법

Swift에서 단위 테스트는 Xcode에 통합된 XCTest 프레임워크를 사용하여 수행됩니다. XCTest를 통해 코드의 정확성을 검증하는 테스트를 작성하여 각 컴포넌트가 예상대로 작동하는지 확인할 수 있습니다.

기본 단위 테스트 예제:

테스트할 코드:

class Calculator {
    func add(_ a: Int, _ b: Int) -> Int {
        return a + b
    }
}

테스트 작성:

import XCTest
@testable import YourApp

class CalculatorTests: XCTestCase {
    var calculator: Calculator!
    
    override func setUp() {
        super.setUp()
        calculator = Calculator()
    }
    
    override func tearDown() {
        calculator = nil
        super.tearDown()
    }
    
    func testAdd() {
        let result = calculator.add(2, 3)
        XCTAssertEqual(result, 5, "Add function should return the sum of two numbers")
    }
}

주요 요소:

  • setUp(): 각 테스트 메서드 전에 리소스 설정
  • tearDown(): 각 테스트 후 리소스 정리
  • XCTAssertEqual: 두 값이 같은지 확인하는 내장 assertion

테스트 실행:
Xcode에서 Command + U를 누르거나 Test Navigator에서 실행

UI 테스팅에 사용하는 도구

iOS에서 UI 테스팅은 주로 XCTest 프레임워크의 일부인 XCUITest를 사용합니다. 사용자 상호작용을 시뮬레이션하고 UI가 예상대로 동작하는지 검증하는 데 도움을 줍니다.

UI 테스트 예제:

버튼 탭이 레이블 텍스트를 변경하는지 확인하는 간단한 UI 테스트:

import XCTest

class MyAppUITests: XCTestCase {
    func testButtonTapChangesLabelText() {
        let app = XCUIApplication()
        app.launch()
        
        let button = app.buttons["MyButton"]
        let label = app.staticTexts["MyLabel"]
        
        // 초기 레이블 텍스트 확인
        XCTAssertEqual(label.label, "Initial Text")
        
        // 버튼 탭
        button.tap()
        
        // 버튼 탭 후 레이블 텍스트 변경 확인
        XCTAssertEqual(label.label, "Text After Tap")
    }
}

주요 요소:

  • XCUIApplication: 테스트 중인 앱을 나타냄
  • app.buttons["MyButton"]: accessibility identifier로 버튼 접근
  • button.tap(): 버튼 탭 동작 시뮬레이션
  • XCTAssertEqual: 탭 후 레이블 텍스트 검증

Accessibility Identifier:
UI 테스트 중 쉽게 식별할 수 있도록 UI 요소에 항상 accessibility identifier 설정:

button.accessibilityIdentifier = "MyButton"
label.accessibilityIdentifier = "MyLabel"

비동기 네트워크 호출 테스트 방법

비동기 네트워크 호출 테스트는 네트워크 응답을 시뮬레이션하고 expectation을 사용하여 호출이 완료될 때까지 기다려야 합니다.

네트워크 호출 예제 (async/await 사용):

class NetworkService {
    func fetchData() async throws -> String {
        let url = URL(string: "https://api.example.com/data")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return String(data: data, encoding: .utf8) ?? ""
    }
}

비동기 네트워크 호출 단위 테스트:

import XCTest
@testable import YourApp

class NetworkServiceTests: XCTestCase {
    var networkService: NetworkService!
    
    override func setUp() {
        super.setUp()
        networkService = NetworkService()
    }
    
    override func tearDown() {
        networkService = nil
        super.tearDown()
    }
    
    func testFetchData() async {
        // Expectation 생성
        let expectation = XCTestExpectation(description: "Async network call should complete successfully")
        
        do {
            let result = try await networkService.fetchData()
            XCTAssertEqual(result, "Expected Result")
            expectation.fulfill() // 비동기 호출 완료 시 expectation 충족
        } catch {
            XCTFail("Network call failed with error: \(error)")
        }
        
        // timeout 내에 expectation이 충족될 때까지 대기 (비동기 테스트에 중요)
        await fulfillment(of: [expectation], timeout: 5.0)
    }
}

주요 요소:

  • XCTestExpectation: 비동기 코드를 위한 expectation 생성
  • expectation.fulfill(): expectation을 완료로 표시
  • await fulfillment(of:timeout:): 주어진 timeout 내에 expectation이 충족될 때까지 대기

네트워크 호출 모킹:
단위 테스트의 경우 실제 API 호출을 피하고 OHHTTPStubs 같은 라이브러리를 사용하여 네트워크 응답을 모킹할 수 있습니다.


코드 리뷰 및 최적화

면접이나 과제에서 제공될 수 있는 코드 예제와 함께 리팩토링하거나 문제를 찾는 작업:

예제 1: 루프에서 중복된 로직

문제가 있는 코드:

func calculateSquareOfEvenNumbers(numbers: [Int]) -> [Int] {
    var result = [Int]()
    for number in numbers {
        if number % 2 == 0 {
            result.append(number * number)
        }
    }
    return result
}

func calculateSquareOfOddNumbers(numbers: [Int]) -> [Int] {
    var result = [Int]()
    for number in numbers {
        if number % 2 != 0 {
            result.append(number * number)
        }
    }
    return result
}

문제: 중복된 루프 로직

리팩토링:

func calculateSquare(numbers: [Int], condition: (Int) -> Bool) -> [Int] {
    return numbers.filter(condition).map { $0 * $0 }
}

let evenSquares = calculateSquare(numbers: [1,2,3,4]) { $0 % 2 == 0 }
let oddSquares = calculateSquare(numbers: [1,2,3,4]) { $0 % 2 != 0 }

예제 2: 비효율적인 문자열 연결

문제가 있는 코드:

func concatenateStrings(strings: [String]) -> String {
    var result = ""
    for string in strings {
        result += string
    }
    return result
}

문제: 루프에서 비효율적인 문자열 연결

리팩토링:

func concatenateStrings(strings: [String]) -> String {
    return strings.joined()
}

예제 3: 불필요한 중첩 루프

문제가 있는 코드:

func checkForDuplicates(numbers: [Int]) -> Bool {
    for i in 0..<numbers.count {
        for j in i+1..<numbers.count {
            if numbers[i] == numbers[j] {
                return true
            }
        }
    }
    return false
}

문제: 중복 확인을 위한 중첩 루프는 피할 수 있음

리팩토링:

func checkForDuplicates(numbers: [Int]) -> Bool {
    return Set(numbers).count != numbers.count
}

예제 4: 최적화되지 않은 배열 검색

문제가 있는 코드:

func findMaxNumber(numbers: [Int]) -> Int? {
    var maxNumber: Int? = nil
    for number in numbers {
        if maxNumber == nil || number > maxNumber! {
            maxNumber = number
        }
    }
    return maxNumber
}

문제: 최대값을 위해 수동으로 반복

리팩토링:

func findMaxNumber(numbers: [Int]) -> Int? {
    return numbers.max()
}

예제 5: 중복된 조건 확인

문제가 있는 코드:

func isAdult(age: Int) -> Bool {
    if age >= 18 {
        return true
    } else {
        return false
    }
}

문제: 중복된 코드

리팩토링:

func isAdult(age: Int) -> Bool {
    return age >= 18
}

예제 6: 일관성 없는 반환 타입

문제가 있는 코드:

func getDescription(age: Int) -> String {
    if age >= 18 {
        return "Adult"
    } else {
        return 18  // ❌ 다른 타입 반환
    }
}

문제: 다른 타입(String과 Int) 반환

리팩토링:

func getDescription(age: Int) -> String {
    return age >= 18 ? "Adult" : "Minor"
}

예제 7: 비효율적인 딕셔너리 검색

문제가 있는 코드:

func findName(forId id: Int, in dictionary: [Int: String]) -> String? {
    for key in dictionary.keys {
        if key == id {
            return dictionary[key]
        }
    }
    return nil
}

문제: 비효율적인 딕셔너리 검색

리팩토링:

func findName(forId id: Int, in dictionary: [Int: String]) -> String? {
    return dictionary[id]
}

예제 8: 잘못된 옵셔널 언래핑

문제가 있는 코드:

func divideNumbers(_ a: Int, _ b: Int) -> Int? {
    if b == 0 {
        return nil
    }
    return a / b
}

let result = divideNumbers(10, 2)!  // ❌ 강제 언래핑

문제: 강제 언래핑으로 크래시 가능성

리팩토링:

if let result = divideNumbers(10, 2) {
    print("Result: \(result)")
} else {
    print("Cannot divide by zero")
}

예제 9: 과도하게 복잡한 정렬

문제가 있는 코드:

func sortNumbers(numbers: [Int]) -> [Int] {
    var sortedNumbers = numbers
    for i in 0..<numbers.count {
        for j in 0..<numbers.count-i-1 {
            if sortedNumbers[j] > sortedNumbers[j+1] {
                let temp = sortedNumbers[j]
                sortedNumbers[j] = sortedNumbers[j+1]
                sortedNumbers[j+1] = temp
            }
        }
    }
    return sortedNumbers
}

문제: sorted()가 있을 때 버블 정렬 사용

리팩토링:

func sortNumbers(numbers: [Int]) -> [Int] {
    return numbers.sorted()
}

예제 10: 불필요한 배열 인덱스 접근

문제가 있는 코드:

func getFirstElement(numbers: [Int]) -> Int? {
    if numbers.count > 0 {
        return numbers[0]
    } else {
        return nil
    }
}

문제: 불필요한 배열 길이 확인

리팩토링:

func getFirstElement(numbers: [Int]) -> Int? {
    return numbers.first
}

예제 11: 수동 JSON 파싱

문제가 있는 코드:

func parseUserData(data: Data) -> User? {
    let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
    if let jsonDict = json {
        if let name = jsonDict["name"] as? String, 
           let age = jsonDict["age"] as? Int {
            return User(name: name, age: age)
        }
    }
    return nil
}

문제: 수동 파싱은 오류가 발생하기 쉬움

리팩토링:

struct User: Codable {
    let name: String
    let age: Int
}

func parseUserData(data: Data) -> User? {
    return try? JSONDecoder().decode(User.self, from: data)
}

예제 12: 비효율적인 필터링

문제가 있는 코드:

func filterEvenNumbers(numbers: [Int]) -> [Int] {
    var result = [Int]()
    for number in numbers {
        if number % 2 == 0 {
            result.append(number)
        }
    }
    return result
}

문제: 더 나은 성능을 위해 filter 메서드 사용 가능

리팩토링:

func filterEvenNumbers(numbers: [Int]) -> [Int] {
    return numbers.filter { $0 % 2 == 0 }
}

예제 13: 중복된 문자열 매칭

문제가 있는 코드:

func hasSubstring(text: String, substring: String) -> Bool {
    if text.contains(substring) {
        return true
    } else {
        return false
    }
}

문제: 중복된 조건 확인

리팩토링:

func hasSubstring(text: String, substring: String) -> Bool {
    return text.contains(substring)
}

예제 14: 불필요한 타입 캐스팅

문제가 있는 코드:

func addNumbers(a: Any, b: Any) -> Int? {
    if let num1 = a as? Int, let num2 = b as? Int {
        return num1 + num2
    }
    return nil
}

문제: 올바른 함수 매개변수로 타입 캐스팅을 피할 수 있음

리팩토링:

func addNumbers(a: Int, b: Int) -> Int {
    return a + b
}

예제 15: 너무 많은 중첩 삼항 연산자

문제가 있는 코드:

func checkAge(age: Int) -> String {
    return age < 18 ? "Minor" : age < 21 ? "Young Adult" : "Adult"
}

문제: 너무 많은 중첩 삼항 연산자는 가독성 저하

리팩토링:

func checkAge(age: Int) -> String {
    if age < 18 {
        return "Minor"
    } else if age < 21 {
        return "Young Adult"
    } else {
        return "Adult"
    }
}

마무리

이 핸드북은 iOS 개발자 면접을 위한 핵심 주제들을 다룹니다:

보안 및 암호화 - Keychain, AES, RSA
클로저 - Trailing closures, 값 캡처, Retain cycles
메모리 관리 - ARC, Strong/Weak/Unowned
SOLID 원칙 - 깨끗한 아키텍처를 위한 5가지 원칙
SwiftUI - @State, @Binding, @ObservedObject
Property Wrappers - Custom wrappers, Codable
동시성 - async/await, Structured concurrency
Copy-on-Write - 메모리 최적화
GCD & OperationQueue - 동시성 관리
기본 개념 - Class vs Struct vs Enum
네트워킹 - URLSession, Codable, Alamofire
데이터 지속성 - UserDefaults, CoreData, Realm
테스팅 - XCTest, UI Testing, 비동기 테스트
코드 최적화 - 일반적인 안티패턴과 리팩토링

학습 팁:
1. 각 주제를 실제로 코딩해보세요
2. 프로젝트에서 실제 사용 사례를 찾아보세요
3. 면접 전에 핵심 개념을 복습하세요
4. 예제 코드를 자신의 프로젝트에 적용해보세요

행운을 빕니다! 🍀


이 핸드북은 2025년 5월 기준 iOS 면접 주제를 포함합니다.

0개의 댓글