Devin.KR

Swift · 기본

Swift로 만드는 첫 앱 로직

프로토콜과 확장 - 약속으로 설계하기

protocol 정의와 채택, Equatable·Comparable·CustomStringConvertible, extension 으로 기능 더하기, 프로토콜 기본 구현, some 키워드 맛보기

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서는 열거형으로 습관의 상태를 정해진 경우의 수로 나타냈다. 이 장에서는 서로 다른 타입이 같은 방식으로 쓰이게 하는 도구를 다룬다. 프로토콜(protocol)은 "이 타입은 이런 이름과 모양의 기능을 갖추겠다"는 약속을 적어 둔 목록이다. 확장(extension)은 이미 만들어진 타입에 기능을 덧붙이는 문법이다. 둘을 함께 쓰면 습관 기록 앱의 물 마시기, 운동, 독서처럼 성격이 조금씩 다른 항목을 하나의 틀로 다룰 수 있다.

  • protocol 로 약속을 정의하고 struct 가 그 약속을 채택하게 만든다.
  • 표준 프로토콜 Equatable, Comparable, CustomStringConvertible 을 채택해 비교, 정렬, 출력 방식을 정한다.
  • extension 으로 기존 타입에 계산 프로퍼티와 메서드를 더한다.
  • 프로토콜 확장으로 기본 구현을 제공하고, 타입이 그것을 덮어쓰게 한다.
  • some 키워드로 "구체 타입은 숨기고 약속만 드러내는" 반환과 인자를 맛본다.

문제 상황

습관 기록 앱에 물 마시기와 운동을 넣었고, 이번에 독서를 추가한다고 하자. 물 마시기는 "하루 8잔"처럼 횟수가 목표이고, 독서는 "하루 30분"처럼 시간이 목표다. 둘 다 이름, 하루 목표, 단위를 갖고 "오늘 얼마나 했는가"를 글로 보여 주어야 한다는 점은 같다.

타입마다 따로 만들면 곧 문제가 생긴다. 진행 상황을 출력하는 함수를 물 마시기용, 독서용으로 두 벌 만들게 된다. 습관 목록을 이름순으로 정렬하려면 매번 비교 기준을 풀어서 써야 한다. 두 습관이 같은 것인지 확인하는 코드도 필드를 하나씩 비교하며 늘어난다. print 로 습관을 찍어 보면 사람이 읽기 어려운 기본 출력이 나온다.

필요한 것은 "습관이라면 이것을 할 수 있어야 한다"는 공통 약속이다. 약속을 한 곳에 적어 두면 함수는 개별 타입이 아니라 약속만 보고 일할 수 있고, 새 습관 타입이 늘어도 기존 함수를 고치지 않아도 된다.

프로토콜: 약속을 적고 지키기

정의와 채택

프로토콜은 어떤 프로퍼티와 메서드가 있어야 하는지만 적고, 동작 내용은 적지 않는다. 습관이 갖춰야 할 모양은 다음과 같다.

protocol Trackable {
    var name: String { get }
    var dailyGoal: Int { get }
    var unit: String { get }
    func progressText(done: Int) -> String
}

{ get } 은 "읽을 수 있어야 한다"는 뜻이다. 이 약속을 지키는 쪽은 let 저장 프로퍼티로 채워도 되고, 계산 프로퍼티로 채워도 된다. 타입 이름 뒤에 콜론과 프로토콜 이름을 적으면 그 약속을 채택(adopt)한 것이고, 요구된 항목을 하나라도 빠뜨리면 컴파일 오류가 난다.

아래 그림은 약속 하나를 두 타입이 각자의 방식으로 지키는 구조를 보여 준다.

Trackable 약속 하나를 Habit 과 TimedHabit 두 타입이 각자 채택한다.

프로토콜 이름을 타입 자리에 쓸 수도 있다. 서로 다른 타입을 한 배열에 담을 때는 [any Trackable] 처럼 쓴다. any 는 "이 약속을 지키는 어떤 타입이든"이라는 표시다. 배열 안에서 각 원소의 실제 타입은 달라도, 꺼내 쓸 때는 약속에 적힌 기능만 부를 수 있다.

표준 프로토콜 세 가지

Swift 가 기본으로 제공하는 프로토콜 중 일상적으로 가장 자주 채택하는 것이 세 가지다.

자주 채택하는 표준 프로토콜과 얻는 것
프로토콜요구하는 것얻는 것비고
Equatable== 연산==, != 비교모든 저장 프로퍼티가 Equatable 이면 자동 생성
Comparable< 연산<, >, <=, >=, sorted(), max(), min()Equatable 을 포함한다
CustomStringConvertibledescription 프로퍼티print 와 문자열 보간의 출력 모양문자열을 직접 돌려준다

Habit 은 세 프로퍼티가 모두 String 과 Int 라서 Equatable 만 선언하면 비교 코드를 쓰지 않아도 된다. Comparable 은 "무엇을 기준으로 앞선다고 볼 것인가"를 사람이 정해야 하므로 < 만 직접 구현한다. 그러면 나머지 비교 연산과 sorted() 가 따라온다.

extension Habit: Comparable {
    static func < (lhs: Habit, rhs: Habit) -> Bool {
        (lhs.name, lhs.dailyGoal, lhs.unit) < (rhs.name, rhs.dailyGoal, rhs.unit)
    }
}

괄호로 묶은 값 여러 개를 튜플(tuple)이라고 한다. 튜플끼리의 < 는 왼쪽 값부터 비교하다가 처음으로 다른 값에서 결과를 정한다. 이름이 같으면 목표를, 목표도 같으면 단위를 본다. 이렇게 하면 == 가 모든 필드를 비교하는 것과 어긋나지 않는다.

static func 는 값마다 있는 메서드가 아니라 타입 자체에 속한 함수다. 연산자 구현은 항상 이 형태로 쓴다. lhs 는 왼쪽(left-hand side), rhs 는 오른쪽(right-hand side) 피연산자를 뜻한다.

확장: 만들어진 타입에 기능 더하기

extension 의 두 가지 쓰임

extension 은 타입을 선언한 곳을 열지 않고도 기능을 더한다. 위에서 Comparable 채택을 extension 에 적은 것이 첫 번째 쓰임이다. 프로토콜 하나당 extension 하나로 나누면 "이 타입이 무엇을 약속했는가"가 눈에 잘 들어온다. 두 번째 쓰임은 새 메서드나 계산 프로퍼티를 더하는 것이다. 저장 프로퍼티는 extension 에 더할 수 없다는 점은 기억해 둔다.

Swift 가 기본으로 주는 타입에도 쓸 수 있다. 날마다 달성 여부를 [true, false, ...] 로 쌓아 둔 Bool 배열이 있다면, 오늘부터 거꾸로 세어 연속 달성일을 돌려주는 프로퍼티를 배열에 붙일 수 있다.

extension Array where Element == Bool {
    var currentStreak: Int {
        var count = 0
        for achieved in reversed() {
            if achieved { count += 1 } else { break }
        }
        return count
    }
}

where Element == Bool 은 "원소가 Bool 인 배열에만 적용한다"는 조건이다. Int 배열에서는 currentStreak 를 쓸 수 없다. 조건을 두면 엉뚱한 곳에 기능이 노출되지 않는다.

프로토콜 기본 구현

프로토콜 자체를 extension 으로 확장하면 채택하는 모든 타입이 공짜로 기능을 얻는다. 아래 코드에서 progressText 는 프로토콜이 요구하는 항목이면서 동시에 기본 구현도 갖는다. 별도로 구현하지 않은 타입은 기본 구현을 쓰고, 직접 구현한 타입은 자기 것을 쓴다.

extension Trackable {
    func progressText(done: Int) -> String {
        "\(name) \(done)/\(dailyGoal)\(unit)"
    }

    func isAchieved(done: Int) -> Bool {
        done >= dailyGoal
    }
}

기본 구현은 프로토콜이 요구하는 name, dailyGoal, unit 만 사용한다. 구체적인 타입이 무엇인지 모르는 상태에서 쓸 수 있는 재료는 약속에 적힌 것뿐이기 때문이다.

progressText 를 부르면 타입이 직접 구현한 것이 있으면 그것을, 없으면 extension 의 기본 구현을 실행한다.

프로토콜 확장에 쓴 이름이 프로토콜 요구 항목에도 적혀 있느냐에 따라 동작이 달라지는 경우가 있다. 이는 아래 "실무에서 자주 틀리는 것"에서 다시 다룬다.

some 키워드 맛보기

함수의 인자나 반환 타입에 some Trackable 을 쓰면 "Trackable 을 채택한 어떤 하나의 구체 타입"이라는 뜻이 된다. 호출하는 쪽은 구체 타입을 알 수 없지만, 컴파일러는 알고 있다. any 와의 차이를 표로 정리한다.

some 과 any 의 차이
구분some Trackableany Trackable
의미정해진 구체 타입 하나채택한 타입 아무거나
한 함수의 반환 경로모두 같은 타입이어야 한다서로 달라도 된다
배열에 섞어 담기할 수 없다할 수 있다

이 장에서는 맛보기만 한다. 인자 자리의 some 은 "아무 습관이나 받는다"는 뜻으로 읽으면 충분하고, 반환 자리의 some 은 "무슨 타입인지는 숨기되 항상 같은 타입을 돌려준다"로 읽으면 된다. 더 깊은 쓰임은 제네릭을 다루는 장에서 나온다.

공식 설명은 Swift 공식 문서의 프로토콜 항목에서 확인할 수 있다.

완성 코드

아래 코드를 main.swift 하나에 저장한다.

// 습관 기록 앱: 프로토콜과 확장

// 1. 프로토콜 정의: 습관이 갖춰야 할 약속
protocol Trackable {
    var name: String { get }
    var dailyGoal: Int { get }
    var unit: String { get }
    func progressText(done: Int) -> String
}

// 2. 프로토콜 확장: 기본 구현과 덧붙인 기능
extension Trackable {
    func progressText(done: Int) -> String {
        "\(name) \(done)/\(dailyGoal)\(unit)"
    }

    func isAchieved(done: Int) -> Bool {
        done >= dailyGoal
    }
}

// 3. 채택: 횟수가 목표인 습관
struct Habit: Trackable, Equatable {
    let name: String
    let dailyGoal: Int
    let unit: String
}

// 3. 채택: 시간(분)이 목표인 습관, 진행 문구는 직접 구현
struct TimedHabit: Trackable {
    let name: String
    let minutes: Int

    var dailyGoal: Int { minutes }
    var unit: String { "분" }

    func progressText(done: Int) -> String {
        "\(name) \(done)분 / 목표 \(minutes)분"
    }
}

// 4. 표준 프로토콜: 정렬 기준
extension Habit: Comparable {
    static func < (lhs: Habit, rhs: Habit) -> Bool {
        (lhs.name, lhs.dailyGoal, lhs.unit) < (rhs.name, rhs.dailyGoal, rhs.unit)
    }
}

// 4. 표준 프로토콜: 출력 모양
extension Habit: CustomStringConvertible {
    var description: String {
        "\(name)(하루 \(dailyGoal)\(unit))"
    }
}

// 5. 기존 타입 확장: 연속 달성일
extension Array where Element == Bool {
    var currentStreak: Int {
        var count = 0
        for achieved in reversed() {
            if achieved { count += 1 } else { break }
        }
        return count
    }
}

// 6. some: 구체 타입은 숨기고 약속만 드러낸다
func makeDefaultHabit() -> some Trackable {
    Habit(name: "물 마시기", dailyGoal: 8, unit: "잔")
}

func percentText(_ item: some Trackable, done: Int) -> String {
    guard item.dailyGoal > 0 else {
        return "\(item.name): 목표 없음"
    }
    let percent = min(done * 100 / item.dailyGoal, 100)
    return "\(item.name): \(percent)%"
}

// 실행
let water = Habit(name: "물 마시기", dailyGoal: 8, unit: "잔")
let workout = Habit(name: "운동", dailyGoal: 1, unit: "회")
let stretch = Habit(name: "스트레칭", dailyGoal: 1, unit: "회")
let reading = TimedHabit(name: "독서", minutes: 30)
let sameWater = Habit(name: "물 마시기", dailyGoal: 8, unit: "잔")

print("같은 습관인가:", water == sameWater)
print("다른 습관인가:", water != workout)
print("물 < 운동:", water < workout)
print(water)
print("오늘의 습관 -> \(workout)")

let ordered = [workout, water, stretch].sorted()
print("정렬:", ordered.map { $0.description }.joined(separator: ", "))

let habits: [any Trackable] = [water, workout, reading]
let doneToday = [6, 1, 45]
for (habit, done) in zip(habits, doneToday) {
    let state = habit.isAchieved(done: done) ? "달성" : "진행 중"
    print(habit.progressText(done: done), "-", state)
}

let starter = makeDefaultHabit()
print(percentText(starter, done: 3))
print(percentText(reading, done: 45))

let week = [true, true, false, true, true, true, true]
print("연속 달성일: \(week.currentStreak)일")

줄별 해설

  • protocol Trackable: 이름, 하루 목표, 단위, 진행 문구 함수를 요구한다. 함수는 본문 없이 모양만 적는다.
  • extension Trackable: progressText 의 기본 구현과, 요구 항목에는 없는 isAchieved 를 더한다. 이 안에서는 약속에 적힌 name, dailyGoal, unit 만 쓴다.
  • struct Habit: Trackable, Equatable: 요구 프로퍼티 세 개를 let 으로 채웠고 progressText 는 쓰지 않았으므로 기본 구현을 얻는다. Equatable 은 선언만으로 == 가 자동 생성된다.
  • struct TimedHabit: dailyGoal 과 unit 을 계산 프로퍼티로 채웠다. 약속이 요구하는 것은 읽을 수 있는 값이지 저장 방식이 아니기 때문에 가능하다. progressText 는 직접 구현해 기본 구현을 덮어쓴다.
  • extension Habit: Comparable: < 하나만 구현한다. 튜플 비교로 이름, 목표, 단위 순서의 기준을 만든다.
  • extension Habit: CustomStringConvertible: description 이 print 와 문자열 보간에 쓰인다.
  • extension Array where Element == Bool: reversed() 로 뒤에서부터 훑으며 true 가 이어지는 동안 세고, false 를 만나면 break 로 멈춘다.
  • makeDefaultHabit: 반환 타입이 some Trackable 이므로 호출한 쪽은 Habit 인지 알 수 없지만 Trackable 의 기능은 모두 쓸 수 있다.
  • percentText: 인자 타입이 some Trackable 이라서 Habit 과 TimedHabit 을 모두 받는다. guard 로 목표가 0 인 경우를 먼저 걸러 0 으로 나누는 일을 막고, min 으로 100 을 넘지 않게 한다. 정수 나눗셈은 소수점 아래를 버리므로 3 × 100 ÷ 8 은 37 이 된다.
  • water == sameWater: 세 필드가 모두 같으므로 true 다. print 에 쉼표로 값을 나열하면 사이에 공백이 들어간다.
  • [workout, water, stretch].sorted(): < 기준으로 정렬한다. 한글은 글자 코드 순서로 비교되어 "물", "스", "운" 순이 된다.
  • [any Trackable]: Habit 과 TimedHabit 을 한 배열에 담는다. zip 은 두 배열을 짝지어 순회하게 해 준다. 독서의 progressText 는 덮어쓴 구현이 실행된다.
  • week.currentStreak: 뒤에서부터 true 가 4 개 이어지고 그 앞은 false 이므로 4 다.

실행 결과

$ swiftc main.swift -o app
$ ./app
같은 습관인가: true
다른 습관인가: true
물 < 운동: true
물 마시기(하루 8잔)
오늘의 습관 -> 운동(하루 1회)
정렬: 물 마시기(하루 8잔), 스트레칭(하루 1회), 운동(하루 1회)
물 마시기 6/8잔 - 진행 중
운동 1/1회 - 달성
독서 45분 / 목표 30분 - 달성
물 마시기: 37%
독서: 100%
연속 달성일: 4일

실무에서 자주 틀리는 것

비교 기준이 Equatable 과 어긋난다

Comparable 의 < 를 이름만으로 구현하면, 이름이 같고 목표가 다른 두 습관은 == 로는 다르다고 나오는데 < 로는 어느 쪽도 앞서지 않는다.

// 틀린 코드
extension Habit: Comparable {
    static func < (lhs: Habit, rhs: Habit) -> Bool {
        lhs.name < rhs.name
    }
}

"물 마시기 8잔"과 "물 마시기 10잔"은 서로 다르면서 순서도 정해지지 않아, 정렬 결과가 입력 순서에 따라 달라질 수 있다. 같은 값이면 == 이고, 다르면 어느 한쪽이 앞서도록 기준을 이어 붙인다.

// 고친 코드
extension Habit: Comparable {
    static func < (lhs: Habit, rhs: Habit) -> Bool {
        (lhs.name, lhs.dailyGoal, lhs.unit) < (rhs.name, rhs.dailyGoal, rhs.unit)
    }
}

요구 항목에 없는 메서드를 덮어썼다고 믿는다

프로토콜 확장에만 있는 메서드를 타입이 같은 이름으로 다시 만들면, 프로토콜 타입으로 부를 때는 확장의 것이 실행된다.

// 틀린 코드
protocol Reminder {
    var title: String { get }
}

extension Reminder {
    func message() -> String { "오늘 할 일: \(title)" }
}

struct WaterReminder: Reminder {
    let title: String
    func message() -> String { "물 한 잔 마실 시간이다" }
}

let r: any Reminder = WaterReminder(title: "물")
print(r.message())   // 오늘 할 일: 물

의도는 "물 한 잔 마실 시간이다"였지만 기본 구현이 출력된다. message 가 약속에 적혀 있지 않아서 any Reminder 로 부를 때 타입의 구현을 찾지 않는다. 덮어쓰게 하고 싶은 메서드는 프로토콜 안에도 적는다.

// 고친 코드
protocol Reminder {
    var title: String { get }
    func message() -> String
}

CustomStringConvertible 을 채택하지 않고 description 만 만든다

// 틀린 코드
struct Habit {
    let name: String
    var description: String { "습관: \(name)" }
}
print(Habit(name: "운동"))   // Habit(name: "운동")

프로퍼티 이름이 description 이라는 것만으로는 print 가 알아보지 못한다. 프로토콜을 채택해야 약속이 성립한다.

// 고친 코드
struct Habit: CustomStringConvertible {
    let name: String
    var description: String { "습관: \(name)" }
}
print(Habit(name: "운동"))   // 습관: 운동

some 함수에서 서로 다른 타입을 돌려준다

// 틀린 코드
func pick(timed: Bool) -> some Trackable {
    if timed {
        return TimedHabit(name: "독서", minutes: 30)
    } else {
        return Habit(name: "운동", dailyGoal: 1, unit: "회")
    }
}

some 은 구체 타입이 하나라는 뜻이므로, 반환 경로마다 타입이 다르면 컴파일 오류가 난다. 경우에 따라 다른 타입을 돌려줘야 한다면 any 를 쓴다.

// 고친 코드
func pick(timed: Bool) -> any Trackable {
    if timed {
        return TimedHabit(name: "독서", minutes: 30)
    } else {
        return Habit(name: "운동", dailyGoal: 1, unit: "회")
    }
}

한눈에 보기

이 장의 문법과 쓰임 요약
문법하는 일예
protocol갖춰야 할 항목을 약속으로 적는다protocol Trackable { ... }
채택타입 이름 뒤에 프로토콜을 적는다struct Habit: Trackable
Equatable== 비교를 얻는다water == sameWater
Comparable< 하나로 정렬과 비교를 얻는다[a, b].sorted()
CustomStringConvertible출력 모양을 정한다var description: String
extension기존 타입에 기능을 더한다extension Array where Element == Bool
프로토콜 기본 구현채택한 모든 타입에 공통 동작을 준다extension Trackable { ... }
some / any구체 타입 하나 / 채택한 아무 타입-> some Trackable, [any Trackable]

연습 문제

  1. TimedHabit 이 CustomStringConvertible 을 채택하게 해서 print(reading) 이 "독서(하루 30분)"를 출력하게 하라. 어디에 코드를 적는 것이 좋은가.
  2. Trackable 을 채택한 모든 타입이 쓸 수 있는 remaining(done:) 을 추가하라. 목표까지 남은 양을 돌려주되 음수가 되면 0 으로 한다. water.remaining(done: 6) 의 결과는 무엇인가.
  3. Bool 배열에서 true 의 개수를 돌려주는 achievedCount 를 배열 확장으로 만들고, [true, false, true].achievedCount 의 결과를 쓰라.
  4. 다음 함수가 컴파일되지 않는 이유를 설명하고 고쳐라.
    func choose(useWater: Bool) -> some Trackable {
        if useWater { return Habit(name: "물 마시기", dailyGoal: 8, unit: "잔") }
        return TimedHabit(name: "독서", minutes: 30)
    }

정답과 해설

  1. 프로토콜 하나당 extension 하나로 나누는 관례에 따라 아래처럼 적는다.
    extension TimedHabit: CustomStringConvertible {
        var description: String {
            "\(name)(하루 \(minutes)분)"
        }
    }
    description 은 문자열을 돌려주는 읽기 전용 프로퍼티이므로 계산 프로퍼티로 만들면 된다.
  2. 프로토콜 확장에 추가하면 Habit 과 TimedHabit 모두 얻는다.
    extension Trackable {
        func remaining(done: Int) -> Int {
            max(dailyGoal - done, 0)
        }
    }
    water 의 목표는 8, 한 양은 6 이므로 결과는 2 다. 목표를 넘겨 하면 8 - 10 = -2 가 아니라 0 이 나온다.
  3. 배열 확장에 계산 프로퍼티를 더한다.
    extension Array where Element == Bool {
        var achievedCount: Int {
            filter { $0 }.count
        }
    }
    filter 는 조건이 true 인 원소만 남기므로 [true, false, true] 에서는 true 두 개가 남고 결과는 2 다.
  4. some Trackable 은 구체 타입이 하나여야 하는데, 두 return 이 Habit 과 TimedHabit 으로 다르다. 반환 타입을 any Trackable 로 바꾸면 컴파일된다. 구체 타입이 항상 하나라면 some 을 유지해도 된다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.