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)한 것이고, 요구된 항목을 하나라도 빠뜨리면 컴파일 오류가 난다.
아래 그림은 약속 하나를 두 타입이 각자의 방식으로 지키는 구조를 보여 준다.
프로토콜 이름을 타입 자리에 쓸 수도 있다. 서로 다른 타입을 한 배열에 담을 때는 [any Trackable] 처럼 쓴다. any 는 "이 약속을 지키는 어떤 타입이든"이라는 표시다. 배열 안에서 각 원소의 실제 타입은 달라도, 꺼내 쓸 때는 약속에 적힌 기능만 부를 수 있다.
표준 프로토콜 세 가지
Swift 가 기본으로 제공하는 프로토콜 중 일상적으로 가장 자주 채택하는 것이 세 가지다.
| 프로토콜 | 요구하는 것 | 얻는 것 | 비고 |
|---|---|---|---|
| Equatable | == 연산 | ==, != 비교 | 모든 저장 프로퍼티가 Equatable 이면 자동 생성 |
| Comparable | < 연산 | <, >, <=, >=, sorted(), max(), min() | Equatable 을 포함한다 |
| CustomStringConvertible | description 프로퍼티 | 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 만 사용한다. 구체적인 타입이 무엇인지 모르는 상태에서 쓸 수 있는 재료는 약속에 적힌 것뿐이기 때문이다.
프로토콜 확장에 쓴 이름이 프로토콜 요구 항목에도 적혀 있느냐에 따라 동작이 달라지는 경우가 있다. 이는 아래 "실무에서 자주 틀리는 것"에서 다시 다룬다.
some 키워드 맛보기
함수의 인자나 반환 타입에 some Trackable 을 쓰면 "Trackable 을 채택한 어떤 하나의 구체 타입"이라는 뜻이 된다. 호출하는 쪽은 구체 타입을 알 수 없지만, 컴파일러는 알고 있다. any 와의 차이를 표로 정리한다.
| 구분 | some Trackable | any 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] |
연습 문제
- TimedHabit 이 CustomStringConvertible 을 채택하게 해서
print(reading)이 "독서(하루 30분)"를 출력하게 하라. 어디에 코드를 적는 것이 좋은가. - Trackable 을 채택한 모든 타입이 쓸 수 있는
remaining(done:)을 추가하라. 목표까지 남은 양을 돌려주되 음수가 되면 0 으로 한다.water.remaining(done: 6)의 결과는 무엇인가. - Bool 배열에서 true 의 개수를 돌려주는
achievedCount를 배열 확장으로 만들고,[true, false, true].achievedCount의 결과를 쓰라. - 다음 함수가 컴파일되지 않는 이유를 설명하고 고쳐라.
func choose(useWater: Bool) -> some Trackable { if useWater { return Habit(name: "물 마시기", dailyGoal: 8, unit: "잔") } return TimedHabit(name: "독서", minutes: 30) }
정답과 해설
- 프로토콜 하나당 extension 하나로 나누는 관례에 따라 아래처럼 적는다.
description 은 문자열을 돌려주는 읽기 전용 프로퍼티이므로 계산 프로퍼티로 만들면 된다.extension TimedHabit: CustomStringConvertible { var description: String { "\(name)(하루 \(minutes)분)" } } - 프로토콜 확장에 추가하면 Habit 과 TimedHabit 모두 얻는다.
water 의 목표는 8, 한 양은 6 이므로 결과는 2 다. 목표를 넘겨 하면 8 - 10 = -2 가 아니라 0 이 나온다.extension Trackable { func remaining(done: Int) -> Int { max(dailyGoal - done, 0) } } - 배열 확장에 계산 프로퍼티를 더한다.
filter 는 조건이 true 인 원소만 남기므로 [true, false, true] 에서는 true 두 개가 남고 결과는 2 다.extension Array where Element == Bool { var achievedCount: Int { filter { $0 }.count } } - some Trackable 은 구체 타입이 하나여야 하는데, 두 return 이 Habit 과 TimedHabit 으로 다르다. 반환 타입을
any Trackable로 바꾸면 컴파일된다. 구체 타입이 항상 하나라면 some 을 유지해도 된다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.