챌린지 2-3 · 6 Topics

🧩 재사용 가능한 코드 짜기

비슷한 코드를 계속 복붙하고 있다

Protocol Extension Generic throws · try · catch Result 타입 enum 연관 값
시작 전에 던져볼 질문
01
Protocol과 추상 클래스(Abstract Class)는 어떻게 다른가?
검색 → Swift protocol vs abstract class differences
02
Generic을 쓰면 실제로 코드가 얼마나 줄어드는가?
검색 → Swift generics benefits reusable type safe code
03
throws/Result 중 어느 쪽이 더 나은가? 사용 상황이 다른가?
검색 → Swift throws vs Result type when to use
Topic 01

Protocol — 타입의 약속

구현을 강제하는 인터페이스 — 어떤 타입이든 이 요구사항을 반드시 구현해야 한다
Swift
// Protocol 선언 — "이걸 구현하면 Drawable이다"
protocol Drawable {
    func draw() -> String
}

protocol Describable {
    var description: String { get }
}

// 여러 Protocol 동시 채택
struct Circle: Drawable, Describable {
    let radius: Double

    func draw() -> String { "◯ r=\(radius)" }
    var description: String { "Circle(radius: \(radius))" }
}

struct Rectangle: Drawable, Describable {
    let width: Double
    let height: Double

    func draw() -> String { "▭ \(width)×\(height)" }
    var description: String { "Rect(\(width)×\(height))" }
}

// Protocol 타입으로 다형성 활용
let shapes: [any Drawable] = [
    Circle(radius: 5),
    Rectangle(width: 10, height: 3),
    Circle(radius: 2)
]

// 구체적 타입 몰라도 draw() 호출 가능
shapes.forEach { print($0.draw()) }
// ◯ r=5.0
// ▭ 10.0×3.0
// ◯ r=2.0

// Protocol with default implementation (Extension)
protocol Shape: Drawable {
    var area: Double { get }
    func describe() -> String
}

extension Shape {
    // 기본 구현 — 채택 타입이 override 안 해도 동작
    func describe() -> String {
        "이 도형의 넓이는 \(area)입니다."
    }
}
생각해보기
  • protocol Shape: Drawable의 의미는? (프로토콜이 다른 프로토콜을 상속)
  • any Drawablesome Drawable의 차이는?
  • Protocol에 프로퍼티 요구사항을 쓸 때 { get }{ get set }의 차이는?
Protocol = 계약서
채택한 타입은 반드시 요구사항을 구현해야 한다. 컴파일 타임에 검증되므로 런타임 오류 없음
Protocol 다형성
서로 다른 타입을 동일한 Protocol 타입으로 묶어 배열에 담고 같은 메서드 호출 가능
Protocol + Extension
Extension에서 기본 구현을 제공하면 채택 타입이 필요한 경우에만 override. 코드 중복 제거
Topic 02

Extension — 기존 타입에 기능 추가

소스 코드 없이도 String, Int, Array 같은 기존 타입에 새 기능을 붙일 수 있다
Swift
// String Extension
extension String {
    var isEmail: Bool {
        contains("@") && contains(".")
    }

    var trimmed: String {
        trimmingCharacters(in: .whitespaces)
    }

    func repeated(_ times: Int) -> String {
        String(repeating: self, count: times)
    }
}

"hello@example.com".isEmail   // true
"  hello  ".trimmed            // "hello"
"ha".repeated(3)               // "hahaha"

// Int Extension
extension Int {
    var isEven: Bool { self % 2 == 0 }
    var isOdd: Bool  { !isEven }

    // 범위 내 클램프
    func clamped(to range: ClosedRange) -> Int {
        min(max(self, range.lowerBound), range.upperBound)
    }
}

7.isOdd                    // true
150.clamped(to: 0...100)   // 100

// Array Extension
extension Array where Element: Comparable {
    var sortedUnique: [Element] {
        Array(Set(self)).sorted()
    }
}

[3, 1, 2, 1, 3].sortedUnique  // [1, 2, 3]

// Collection Extension — 안전한 subscript
extension Collection {
    subscript(safe index: Index) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}

let arr = [1, 2, 3]
arr[safe: 5]  // nil (크래시 없음)
생각해보기
  • Extension으로 저장 프로퍼티(stored property)를 추가할 수 없는 이유는?
  • where Element: Comparable처럼 조건부 Extension은 어떤 상황에서 유용한가?
  • Apple의 Foundation 타입(String, Array)에 Extension을 추가할 때 주의할 점은?
Extension의 한계
저장 프로퍼티 추가 불가. 연산 프로퍼티(computed), 메서드, 이니셜라이저, 프로토콜 채택만 가능
Conditional Extension
where 절로 특정 타입 조건을 만족할 때만 Extension이 활성화. 타입 안전성 유지
코드 구조화 도구
같은 타입을 기능별로 여러 Extension으로 나누어 가독성 향상. // MARK: - Protocol과 함께 활용
Topic 03

Generic — 타입에 독립적인 코드

같은 로직을 Int용, String용, Double용으로 따로 쓰지 않아도 된다
Swift
// Generic 스택 자료구조
struct Stack {
    private var storage: [Element] = []

    mutating func push(_ element: Element) {
        storage.append(element)
    }

    mutating func pop() -> Element? {
        storage.popLast()
    }

    var top: Element? { storage.last }
    var isEmpty: Bool { storage.isEmpty }
    var count: Int { storage.count }
}

var intStack = Stack()
intStack.push(1); intStack.push(2); intStack.push(3)
intStack.pop()  // 3

var strStack = Stack()
strStack.push("Swift")
strStack.push("Generic")

// Generic 함수
func swapValues(_ a: inout T, _ b: inout T) {
    let temp = a
    a = b
    b = temp
}

// Generic + where (타입 제약)
func findMin(_ array: [T]) -> T? {
    guard !array.isEmpty else { return nil }
    return array.min()
}

func findMax(_ array: [T]) -> T? {
    array.max()
}

findMin([3, 1, 4, 1, 5])  // Optional(1)
findMin(["banana", "apple", "cherry"])  // Optional("apple")

// 여러 타입 제약
func process(
    _ items: [T]
) -> [T] {
    Array(Set(items)).sorted()
}

// Generic typealias
typealias Pair = (T, T)
let point: Pair = (3.0, 4.0)
생각해보기
  • T: Comparable이 없으면 < 연산자를 쓸 수 없는 이유는?
  • Swift의 Array, Optional도 Generic 타입이다. 내부 구현은 어떻게 생겼을까?
  • some View는 opaque type이다. Generic의 T와 어떻게 다른가?
타입 파라미터 <T>
실제 타입의 자리 표시자. 호출 시점에 컴파일러가 타입을 결정. 타입 안전성과 재사용성 동시 확보
Type Constraint
T: Protocol 또는 T: Class로 T가 가져야 할 능력을 명시. 제약된 메서드/연산자 사용 가능
where 절
복잡한 타입 관계를 표현. where T == U.Element처럼 연관 타입 관계도 표현 가능
Topic 04

throws · try · catch — 에러 처리

예외 상황을 타입으로 표현하고 호출자가 명시적으로 처리하게 강제한다
Swift
// 커스텀 에러 타입
enum AppError: LocalizedError {
    case invalidInput(String)
    case notFound(id: Int)
    case permissionDenied
    case unknown(Error)

    var errorDescription: String? {
        switch self {
        case .invalidInput(let msg):
            return "잘못된 입력: \(msg)"
        case .notFound(let id):
            return "ID \(id)를 찾을 수 없습니다"
        case .permissionDenied:
            return "권한이 없습니다"
        case .unknown(let e):
            return "알 수 없는 오류: \(e)"
        }
    }
}

// throws 함수
func validateAge(_ age: Int) throws -> String {
    guard age >= 0 else {
        throw AppError.invalidInput("나이는 0 이상이어야 합니다")
    }
    guard age <= 150 else {
        throw AppError.invalidInput("유효하지 않은 나이입니다")
    }
    return age >= 18 ? "성인" : "미성년자"
}

// do-catch 에러 처리
do {
    let result = try validateAge(-5)
    print(result)
} catch AppError.invalidInput(let msg) {
    print("입력 오류:", msg)   // 입력 오류: 나이는 0 이상이어야 합니다
} catch AppError.permissionDenied {
    print("권한 없음")
} catch {
    print("기타 오류:", error)
}

// try? — 실패 시 nil 반환 (에러 무시)
let result1 = try? validateAge(25)    // Optional("성인")
let result2 = try? validateAge(-1)    // nil

// try! — 실패 시 크래시 (확실할 때만 사용)
let result3 = try! validateAge(30)    // "성인"

// 에러를 위로 전파 (rethrows)
func transform(
    _ value: T,
    using block: (T) throws -> T
) rethrows -> T {
    try block(value)
}
생각해보기
  • try?를 쓰면 에러 정보가 사라진다. 어떤 상황에서 이 트레이드오프를 감수할 수 있는가?
  • rethrows는 무엇이고 왜 필요한가?
  • Swift의 에러 처리와 Objective-C의 NSError 방식은 어떻게 다른가?
throws = 명시적 에러 선언
함수가 에러를 던질 수 있음을 시그니처에 명시. 호출자가 반드시 처리해야 하므로 누락 방지
try / try? / try!
try: do-catch로 처리 필수 | try?: Optional 변환 | try!: 강제 (실패 시 크래시)
LocalizedError
errorDescription 구현으로 사용자 친화적 메시지 제공. Alert에서 바로 사용 가능
Topic 05

Result<Success, Failure> — 에러를 값으로 표현

성공 또는 실패를 하나의 타입으로 표현 — 비동기 컨텍스트나 콜백에서 특히 유용하다
Swift
// Result 타입 기본 사용
func divide(_ a: Double, by b: Double) -> Result {
    guard b != 0 else {
        return .failure(.invalidInput("0으로 나눌 수 없습니다"))
    }
    return .success(a / b)
}

// switch로 처리
let result = divide(10, by: 2)
switch result {
case .success(let value):
    print("결과:", value)  // 결과: 5.0
case .failure(let error):
    print("오류:", error.errorDescription ?? "")
}

// map / flatMap 체이닝
let doubled = divide(10, by: 2)
    .map { $0 * 2 }           // success → 값 변환
    // .mapError { ... }       // failure → 에러 변환

// get()으로 throws로 변환
do {
    let value = try divide(10, by: 0).get()
    print(value)
} catch {
    print(error)  // 0으로 나눌 수 없습니다
}

// Result vs throws — 언제 무엇을?
// throws: 동기 함수, 즉시 처리 흐름에서
// Result: 콜백 전달, 나중에 처리, 값으로 저장할 때

// Result를 저장하고 나중에 처리
struct CachedResult {
    var value: Result?

    func unwrap() throws -> T {
        guard let result = value else {
            throw AppError.notFound(id: 0)
        }
        return try result.get()
    }
}
생각해보기
  • Result.get()은 내부적으로 어떻게 구현되어 있는가?
  • Result<T, Never>는 어떤 의미인가? Never는 무엇인가?
  • async/await 환경에서 Result 타입을 반환하는 것이 적절한 경우는?
Result = 에러 포함 반환값
성공(.success)과 실패(.failure)를 하나의 enum으로 표현. 콜백 패턴에서 에러 처리 구조화에 유용
map / flatMap
성공 케이스의 값을 변환. 실패면 그대로 전파. Optional의 map과 같은 개념
throws와의 상호 변환
result.get()으로 throws로, Result { try fn() }로 throws 함수를 Result로 변환
Topic 06

enum 연관 값 — 데이터를 품은 상태

각 케이스가 서로 다른 타입의 데이터를 가질 수 있다 — Swift enum의 가장 강력한 기능
Swift
// 네트워크 상태 — 각 케이스에 다른 데이터
enum NetworkState {
    case idle
    case loading(progress: Double)
    case success(data: Data, statusCode: Int)
    case failure(error: Error, retryCount: Int)
}

// switch로 패턴 매칭 + 값 추출
func handleState(_ state: NetworkState) {
    switch state {
    case .idle:
        print("대기 중")
    case .loading(let progress):
        print("로딩 중: \(Int(progress * 100))%")
    case .success(let data, let code):
        print("성공 (\(code)): \(data.count) bytes")
    case .failure(let error, let retry):
        print("실패 (재시도 \(retry)회): \(error)")
    }
}

handleState(.loading(progress: 0.75))
// 로딩 중: 75%

// if case let — 단일 케이스 추출
let state = NetworkState.success(
    data: Data(), statusCode: 200
)
if case .success(_, let code) = state, code == 200 {
    print("요청 성공!")
}

// 실전 예시 — 앱 알림 타입
enum AppNotification {
    case message(from: String, body: String)
    case friendRequest(userId: Int, name: String)
    case systemAlert(title: String, priority: Int)
    case promotion(discount: Double, expiry: Date)
}

func notificationTitle(_ n: AppNotification) -> String {
    switch n {
    case .message(let from, _):      return "\(from)님의 메시지"
    case .friendRequest(_, let name): return "\(name)님이 친구 요청"
    case .systemAlert(let title, _): return title
    case .promotion(let d, _):       return "\(Int(d*100))% 할인!"
    }
}
생각해보기
  • 연관 값이 있는 enum과 struct의 차이는? 어떤 상황에서 enum을 선택하는가?
  • _로 연관 값을 무시하는 것과 값을 바인딩하는 것의 문법 차이는?
  • Swift의 Optional<T>는 사실 enum이다. .none.some(T)로 구성된다. 이 관점에서 Optional의 동작을 설명해보라
Associated Values
각 케이스마다 다른 타입과 개수의 값을 가질 수 있다. 상태 + 데이터를 하나의 타입으로 표현
패턴 매칭
switch, if case, guard case로 케이스와 데이터를 동시에 추출. 중첩 패턴도 가능
exhaustive switch
모든 케이스를 처리하지 않으면 컴파일 오류. 새 케이스 추가 시 처리 누락을 컴파일러가 잡아줌
Protocol
protocol Drawable
func draw() → String
Circle
Rect
shapes.forEach {
  $0.draw() // ✓
Extension
extension String {
  var isEmail: Bool
  var trimmed: String
}
"hello@x.com"
.isEmail → true
[3,1,2,1]
.sortedUnique → [1,2,3]
Generic
struct Stack<T> {
  mutating func push(_ e: T)
  mutating func pop() → T?
}
Stack<Int>
1 2 3
Stack<String>
A B
throws / catch
do {
  let r = try validate(-5)
} catch AppError
  .invalidInput(msg) {
  print(msg)
}
AppError.invalidInput
나이는 0 이상이어야 합니다
Result 타입
.success(5.0)
divide(10, by: 2)
.failure(invalidInput)
divide(10, by: 0)
enum 연관 값
.idle
.loading(75%)
.success(data, 200)
.failure(err, retry:2)
Homework

챌린지 2-3을 마쳤다면

필수
Protocol로 도형 계산기 만들기
Shape 프로토콜(area, perimeter, draw)을 정의하고 Circle, Rectangle, Triangle을 구현. [any Shape] 배열에 담아 전체 넓이 합계 출력
필수
Generic Stack + throws 에러 처리
Generic Stack을 만들고 빈 스택에서 pop 시 StackError.empty를 throw. do-catch로 SwiftUI View에서 에러 알림 표시
도전
Result 기반 폼 검증 시스템
이메일·비밀번호·이름 각 필드를 검증하는 함수를 Result<String, ValidationError>로 구현. 여러 검증 결과를 결합해 종합 에러 리스트를 View에 표시
완료 체크리스트
Protocol을 선언하고 여러 타입에 채택할 수 있다
Extension으로 기존 타입에 유용한 기능을 추가할 수 있다
Generic 함수/타입을 작성하고 타입 제약을 걸 수 있다
throws/do-catch와 Result 타입을 상황에 맞게 선택할 수 있다
enum 연관 값으로 다양한 상태+데이터를 하나의 타입으로 표현할 수 있다
← 이전 챌린지 2-2 인터넷에서 데이터 가져오기
코멘트
후원하기
콘텐츠가 도움이 됐다면
커피 한 잔의 후원을 부탁드려요 :)
카카오페이 QR
이현호
카카오페이
카카오톡 QR
카카오톡 오픈채팅 💬
학습 질문 · 코드 리뷰 · 링크로 참여