Search

[Swift] Protocol(1)

The Swift Programming Language Documents를 참고해서 정리했습니다.
contents

Protocol

Swift에서 Protocol은 중요한 기능 중 하나입니다. Protocol이 없었으면 불편한 점이 많았을 거예요.
Delegate Pattern도 구현할 수 없고, 추상화도 불가능했을 겁니다. 리팩토링도 쉽지 않았겠죠.
그렇다면, Protocol이란 뭘까?
Define requirements that conforming types must implement. conform type이 구현해야 하는 요구 사항 정의
Protocol은 Protocol를 채택하는 타입이 가져야 하는 요구 사항을 정의하고 있습니다. 그래서 우리는 흔히 Protocol를 틀에 비유합니다.
쿠키 커터가 Protocol이라고 한다면, Protocol로 찍어서 만든 쿠키는 모두 같은 모양을 가지지만 그 위에 올라가는 토핑이 뭐냐에 따라서 다른 맛을 내게 됩니다. 어떤 쿠키는 초콜릿을 한가득 올린 쿠키 일 거고, 어떤 쿠키는 아몬드 토핑을 가득 올린 쿠키일 거고, 어떤 쿠키는 딸기잼을 뿌린 쿠키일 테니깐요. 쿠키가 어떤 맛을 내는가는 만드는 이가 어떤 걸 토핑하느냐에 달렸습니다. 틀은 그냥 쿠키의 모양만 찍어줄 뿐이지요.
쿠키 커터처럼 Protocol도 해당 타입이 가져야 하는 모양만 만들어 줄 뿐입니다.
해당 타입이 가져야 하는 특정 작업, 기능에 적합한 메소드, 속성, 기타 요구 사항의 청사진(틀)을 정의합니다.
그리고 Protocol를 채택한 Class, Struct, enum에서 요구 사항에 대한 실제 구현을 제공합니다. 물론, 프로토콜을 준수한 타입에서 요구 사항을 일부 구현할 수도 있고, 추가 기능을 구현할 수 있도록 프로토콜을 확장할 수도 있습니다. 일부 구현(Optional) 및 확장(Extension)에 대해서는 글을 계속 전개해가면서 알아보도록 합시다.
그 다음 단계로 넘어가기 전에 계속해서 언급될 두 단어를 알려드리고자 합니다.
채택(adopt)
작품, 의견, 제도 따위를 골라서 다루거나 뽑아 씀.
말그대로 클래스, 구조체, 열거형에서 프로토콜을 뽑아서 사용하는 겁니다. 콜론(:) 뒤에 프로토콜을 적어뒀다면 클래스, 구조체, 열거형은 해당 프로토콜을 채택했다고 볼 수 있습니다. 예를 들어서, struct Dice: TextRepresentable 이라는 코드를 본다면, 우리는 Dice 구조체가 TextRepresentable이라는 프로토콜을 채택했다고 생각할 수 있습니다.
준수(conform)
전례나 규칙, 명령 따위를 그대로 좇아서 지킴.
클래스, 구조체, 열거형이 프로토콜의 요구 사항을 좇아서 지키는 겁니다. 프로토콜을 채택하고 있는 클래스, 구조체, 열거형은 곧 프로토콜을 준수하고 있는 타입이 됩니다. 위의 예시로 보면, Dice 구조체는 TextRepresentable를 준수하고 있는 타입인 겁니다.
채택(adopt), 준수(conform)는 프로토콜 관련해서 자주 쓰이는 단어이기 때문에 기억해두시면 진행되는 내용을 이해하기에 편하실 겁니다. 프로토콜은 타입에 의해서 채택 당하고, 해당 타입은 프로토콜을 준수한다. 기억해두세요!

Syntax

Protocol이 쿠키 커터라는 걸 알았다면, 근본부터 알아가 봅시다.
Protocol을 어떻게 작성하는가?
Protocol은 Class, Struct, enum과 유사한 방법으로 정의합니다.
protocol SomeProtocol { var someProperty: String { get set } func someMethod() -> Int }
Swift
복사
그리고 Protocol를 채택하는 클래스, 구조체, 열거형은 채택을 명시합니다.
struct SomeStruct: SomeProtocol { var someProperty: String func someMethod() -> Int { // ... } }
Swift
복사
타입들은 Protocol를 여러 개 채택할 수 있기 때문에 이런 경우에는 쉼표(,)를 사용해서 나열하면 됩니다.
클래스에 경우에는 상속받는 Super 클래스가 있을 수 있는데, 그 경우에는 Super 클래스를 맨 앞에 적은 후 그 뒤에 쉼표를 사용해서 프로토콜을 나열하면 됩니다.
프로토콜에 method, property 가 보통 정의되는데, 어떤 식으로 정의되는지 알아봅시다.

Property

instance property, type property가 프로토콜에서 정의됩니다.
프로토콜은 해당 Property가 Stored인지 Computed인지 지정하지 않고 속성의 이름과 타입만 지정합니다.
속성은 gettable 일 수도 있고, gettable & settable 일 수도 있는데 이 부분은 프로토콜에서 지정해줘야 합니다.
gettable한 프로퍼티
{ get } 를 뒤에 입력
모든 종류의 프로퍼티로 충족 가능
gettable & settable한 프로퍼티
{ get set } 를 뒤에 입력
constant stored property나 read-only computed property로는 충족 불가
추가적으로 프로퍼티는 항상 var 키워드를 변수 속성 앞에 선언해줘야 합니다.
프로토콜에서 Type Property를 정의한다면 항상 static 키워드를 사용해서 프로퍼티 앞에 붙여주어야 합니다. 다른데서 class, static 키워드 붙여서 사용하는 것과 동일하게 사용하면 됩니다.
protocol AnotherProtocol { static var anotherProperty: Int { get set } }
Swift
복사
Instance Property를 정의한다면 Type Property에서 static 키워드만 제거해주면 됩니다.
protocol AnotherProtocol { var anotherProperty: Int { get set } }
Swift
복사
“Type Property가 뭔가요?” 라고 물어보신다면, Property Document를 읽어보시라 추천드리겠습니다. 후에 Property 관련해서도 정리 글을 써보겠습니다. 저도 프로퍼티 관련 공부가 필요하기에..

Method

프로퍼티와 동일하게 instance method, type method가 정의됩니다.
Protocol에서는 메서드 본문을 작성하지 않고, {}도 쓰지 않습니다. 또한, 매개 변수는 쓸 수 있지만, 기본 값을 지정할 순 없습니다. 메서드 본문은 Protocol를 채택한 타입에서 구현해주면 되기 때문입니다.
Type Method를 정의한다면 Type Property와 동일하게 static 키워드를 사용해서 메서드 앞에 붙여주면 됩니다.
protocol AnotherProtocol { static func anotherMethod() -> Int }
Swift
복사
Instance Method를 정의한다면 Type Method에서 Static 키워드만 제거해주면 됩니다.
protocol AnotherProtocol { func anotherMethod() -> Int }
Swift
복사
메서드에는 프로퍼티와는 다르게 Prefix로 mutating 키워드가 붙어 있는 메서드가 존재합니다.
해당 키워드가 붙어 있는 메서드는 메서드가 속한 instance를 수정한다는 걸 뜻합니다.
메서드가 프로퍼티를 수정할 수 있는 reference type(Class)의 메서드라면 mutating 키워드를 따로 붙일 필요가 없지만, value type(Struct, enum)의 메서드에 mutating 키워드가 붙여 있으면 메서드가 속한 instance와 instance의 모든 property를 수정할 수 있다는 걸 뜻합니다.
protocol Toggable { mutating func toggle() } enum OnOffSwitch: Toggable { case on, off mutating func toggle() { switch self { case .on: self = .off case .off: self = .on } } }
Swift
복사
Toggable 프로토콜이 가진 toggle 메서드는 mutating 키워드를 붙이고 있기 때문에 toggle 메서드를 구현할 때에 값을 변경할 수 있는 겁니다.

initializer

Protocol에는 특정 이니셜라이저를 작성할 수도 있습니다. 일반 이니셜라이저와 동일하게 작성하면 되지만 메서드와 동일하게 본문은 작성하지 않아도 됩니다.
protocol SomeProtocol { init(someParameter: Int) }
Swift
복사
프로토콜을 준수하는 클래스에서 해당 이니셜라이저를 구현할 때, 이니셜라이저는 designated, convenience 이니셜라이저로 구현될 수 있습니다. 두 경우 모두 이니셜라이저 구현 시에 required 키워드로 표시해주어야 합니다. 키워드를 사용하게 되면 클래스의 하위 클래스에도 해당 이니셜라이저를 제공할 수 있습니다.
class SomeClass: SomeProtocol { required init(someParameter: Int) { // ... } } class SomeSubClass: SomeClass { required init(someParameter: Int) { // ... } }
Swift
복사
만약, 하위 클래스가 Super 클래스의 designated 이니셜라이저를 재정의하면서 프로토콜의 이니셜라이저도 구현하는 경우 이니셜라이저에 requiredoverride 를 모두 사용하여 표시할 수 있습니다.
protocol SomeProtocol { init() } class SomeClass { init() { } } class SomeSubClass: SomeClass, SomeProtocol { required override init() { } }
Swift
복사
만약, 클래스에 final 키워드가 표시되어 있다면 이니셜라이저에 required 를 표시할 필요가 없습니다. final 클래스는 상속을 막기 때문에 하위 클래스를 만들 수 없기 때문입니다.
final class SomeClass: SomeProtocol { init(someParameter: Int) { print(someParameter) } }
Swift
복사

Create a game using the protocol

지금부터 게임을 하나 만들어 볼게요. 우리가 만들 게임은 주사위를 굴려서 진행하는 보드 게임인데, 어떤 게임인지는 진행하면서 서서히 알게 되실 겁니다.
먼저, 가장 기본이 되는 주사위 기능을 만들어 볼게요.

Random Number Generator

우리가 굴리는 주사위는 규칙적이지 않습니다. 어떤 숫자가 나올 지 모르죠. 랜덤합니다.
그렇기 때문에, 주사위 굴리기 기능을 만들기 위해서는 랜덤한 숫자를 내보내는 기능부터 필요합니다. RandomNumber를 생성할 수 있는 생성기말이죠. 생성기를 만들기 위해서 먼저 프로토콜을 하나 만들어 볼게요.
이름은 RandomNumberGenerator 라고 할게요. 해당 프로토콜은 random이라는 메서드를 가지고 있습니다. random은 Double 값을 반환하지만 어떻게 구현될 지는 어떤 Generator를 통해서 만들어지느냐에 따라 다르겠지요?
protocol RandomNumberGenerator { func random() -> Double }
Swift
복사
LinearCongruentialGenerator가 RandomNumberGenerator를 채택합니다. 클래스 내부에 random 메서드를 구현해뒀습니다. random 메서드를 통해서 랜덤한 숫자를 받게 되겠네요.
final class LinearCongruentialGenerator: RandomNumberGenerator { private var lastRandom: Double = 42.0 private let m: Double = 139968.0 private let a: Double = 3877.0 private let c: Double = 29573.0 func random() -> Double { lastRandom = ((lastRandom * a + c) .truncatingRemainder(dividingBy: m)) return lastRandom / m } }
Swift
복사

Dice

랜덤 숫자 생성기를 만들었으니 이제 주사위를 만들 준비가 다 되었군요. 주사위 클래스를 만들어 봅시다.
Dice 클래스에는 주사위 모든 면의 수를 나타내는 sides 프로퍼티와 랜덤 넘버를 생성하는 generator 프로퍼티가 있습니다. generator는 RandomNumberGenerator 를 타입으로 가지고 있습니다. Protocol은 타입으로도 사용할 수 있기 때문이죠.
class Dice { private let sides: Int private let generator: RandomNumberGenerator init(sides: Int, generator: RandomNumberGenerator) { self.sides = sides self.generator = generator } func roll() -> Int { return Int(self.generator.random() * Double(self.sides)) + 1 } }
Swift
복사
클래스 이니셜라이저에서 sides와 generator를 받아서 사용하게 됩니다.
generator는 RandomNumberGenerator 를 타입으로 가지고 있기 때문에 RandomNumberGenerator 를 채택하는 모든 타입 인스턴스를 generator로 설정할 수 있습니다.
물론 그렇기 때문에 RandomNumberGenerator 프로토콜이 가지고 있는 메서드, 프로퍼티만 사용할 수 있고, 채택하는 타입의 메서드, 프로퍼티를 사용하려면 Down Casting를 해야 합니다.
Dice 내부에 roll 메서드가 있습니다. 해당 메서드를 사용해서 주사위를 굴려서 랜덤한 값을 내보내는 기능을 구현할 겁니다.
일단, 주사위까지 만들었으니 주사위를 굴려서 랜덤 값을 내보내는 기능이 잘 되는지 확인해볼까요?
let generator = LinearCongruentialGenerator() let dice = Dice(sides: 6, generator: generator) for _ in 0...5 { print("주사위를 굴렸더니, \"\(dice.roll())\" 가 나왔습니다.") }
Swift
복사
출력이 잘 되는군요. 숫자가 랜덤하지만 규칙적이게 나오긴 합니다. 하지만 큰 문제는 없으니 넘어가시죠.

DiceGame

랜덤한 값을 내보내는 주사위까지 다 만들었으니, 이제 주사위 게임을 만들어볼까 합니다.
주사위 게임을 프로토콜로 만들면 아마 주사위 게임을 기본 틀로 가지는 대부분의 게임은 주사위 게임 프로토콜을 채택해서 주사위 기능을 사용할 수 있을 겁니다.
DiceGame 프로토콜을 만들어 보겠습니다. DiceGame은 Dice 타입을 가진 속성을 하나 가집니다. 그리고 게임을 진행하는 play 메서드를 만들 거예요.
protocol DiceGame { var dice: Dice { get } func play() }
Swift
복사
그리고 DiceGame 진행 사항을 추적하기 위해서 DiceGameDelegate 프로토콜을 만들겁니다.
Delegate Pattern해당 클래스, 구조체의 책임을 다른 타입의 인스턴스로 위임할 수 있도록 하는 design pattern 입니다. 위임된 책임을 프로토콜로 캡슐화하여 구현하는 것이죠. 해당 프로토콜을 채택하는 타입은 위임된 기능을 제공하도록 보장합니다.
protocol DiceGameDelegate: AnyObject { func gameDidStart(_ game: DiceGame) func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int) func gameDidEnd(_ game: DiceGame) }
Swift
복사
AnyObject는 해당 프로토콜이 class-only Protocol이라는 걸 표현합니다. DiceGameDelegate 프로토콜은 Class 타입만 사용할 수 있고, 그렇기 때문에 Class에서 DiceGameDelegate 타입의 인스턴스를 선언할 때에 Strong Reference Cycle 방지를 위해서 weak 로 선언해주어야 합니다.

Create a game

주사위를 사용하는 여러 게임이 있지만 우리가 만들어 볼 게임은 Snakes and Ladders 게임입니다.
먼저, 아까 만든 DiceGame를 SnakesAndLadders 클래스가 채택하도록 합니다. 그러면, 이런 모양이 되겠죠.
final class SnakesAndLadders: DiceGame { var dice: Dice = Dice(sides: 6, generator: LinearCongruentialGenerator()) func play() { } }
Swift
복사
게임에서 사용하는 주사위는 이전에 만든 것과 동일한 6개의 면을 가진 주사위로 만들고 아까 만든 랜덤 넘버 생성기를 통해서 랜덤 값이 나오도록 했습니다.
주사위 게임을 진행할 주사위를 마련이 되었네요. 그러면 게임을 진행할 보드 판을 만들어 볼까요?
100칸짜리 보드를 만들고 보드 내부에 사다리와 뱀을 설치했습니다. 양수가 사다리, 음수가 뱀이라고 생각하시면 됩니다.
let finalSquare: Int = 99 var gameBoard: [Int] init() { self.gameBoard = Array(repeating: 0, count: self.finalSquare + 1) self.gameBoard[03] = +08; self.gameBoard[06] = +11; self.gameBoard[09] = +09 self.gameBoard[10] = +02; self.gameBoard[14] = -10; self.gameBoard[19] = -11 self.gameBoard[22] = -02; self.gameBoard[24] = -08; self.gameBoard[30] = +05 self.gameBoard[40] = -10; self.gameBoard[44] = +09; self.gameBoard[49] = +12 self.gameBoard[59] = -04; self.gameBoard[62] = +03; self.gameBoard[65] = +19 self.gameBoard[75] = +07; self.gameBoard[77] = +10; self.gameBoard[82] = -03 self.gameBoard[88] = +05; self.gameBoard[89] = +03; self.gameBoard[90] = -10 self.gameBoard[92] = +04; self.gameBoard[95] = -06; }
Swift
복사
게임 진행을 위해서 진행 방식을 선언해둔 DiceGameDelegate를 인스턴스로 넣어줍니다. 아까 말했듯 강한 참조를 피하기 위해서 weak 로 선언해줍니다.
필수적인 속성은 아니기 때문에 optional로 선언해줍니다. nil인 경우에는 delegate 메서드가 호출되지 않을겁니다.
weak var delegate: DiceGameDelegate?
Swift
복사
이제 play 메서드 내부 로직을 짜봅시다.
SnakesAndLadders 게임은 마지막 칸에 딱 맞춰서 도착할 때까지 게임을 계속 진행합니다.
func play() { var square: Int = 0 // 처음 칸을 0으로 초기화 self.delegate?.gameDidStart(self) // 게임 시작 gameLoop: while square != self.finalSquare { let diceRoll = self.dice.roll() // 주사위 굴리기 self.delegate?.game(self, didStartNewTurnWithDiceRoll: diceRoll) switch square + diceRoll { case self.finalSquare: // 마지막 칸 도착 break gameLoop case let newSquare where newSquare > finalSquare: // 마지막 칸에 딱 맞춰서 도착하지 않음 continue gameLoop default: // 진행 중 square += diceRoll square += self.gameBoard[square] } } self.delegate?.gameDidEnd(self) // 게임 끝 }
Swift
복사
이제 게임을 진행할 수 있겠군요. 하지만, 아직 DiceGameDelegate를 채택하여 세 가지 방법을 구현해줄 Class가 없군요. 한 번 만들어 보겠습니다.
DiceGameTracker에 전체 턴 수를 카운트 해주는 numberOfTurns를 만들어 줍니다. 그리고 DiceGameDelegate 내부에 있는 메서드들을 구현해줍니다.
final class DiceGameTracker: DiceGameDelegate { var numberOfTurns: Int = 0 func gameDidStart(_ game: DiceGame) { self.numberOfTurns = 0 if game is SnakesAndLadders { print("🐍 Snakes and Ladders 🪜 게임을 시작합니다.") } print("🎲 \(game.dice.sides)면을 가진 주사위를 사용해서 게임을 진행합니다.") } func game(_ game: DiceGame, didStartNewTurnWithDiceRoll diceRoll: Int) { self.numberOfTurns += 1 print("🎲 \"\(diceRoll)\"") } func gameDidEnd(_ game: DiceGame) { print("=========== 게임이 끝났습니다. ===========\n☑️ turns: \(numberOfTurns)") } }
Swift
복사
gameDidStart 는 게임이 처음 시작될 때 턴 수를 0으로 초기화해주고 게임 시작을 알리는 글을 출력합니다. 이 때 DiceGame 타입으로 들어온 gameSnakesAndLadders 타입이라면 어떤 게임을 진행하는지 알려줍니다.
game 메서드는 굴려진 주사위 값을 출력하고 턴 수를 증가시켜줍니다. gameDidEnd 메서드는 게임이 끝났음을 알려줍니다.
그러면 게임 진행이 잘 되는지 확인해볼까요?
잘 됩니다. ♪(´ε`*)
하지만, 아직 주사위만 냅다 굴리는 게임 같지 Snakes and Ladders 게임 같진 않네요. 출력되는 문장을 좀 더 깔끔하게 정리해보겠습니다.

TextRepresentable

먼저 TextRepresentable이라는 프로토콜을 만들어서 매번 게임 타이틀이 프린트 되도록 해보겠습니다.
protocol TextRepresentable { var textualDescription: String { get } }
Swift
복사
TextRepresentable 프로토콜은 textualDescription 속성을 가집니다. 이제 해당 프로토콜을 채택하는 SnakesAndLadders 클래스를 만들어 봅시다.
extension SnakesAndLadders: TextRepresentable { var textualDescription: String { return "🐍 Snake And Ladders 🪜" } }
Swift
복사
매 게임마다 타이틀이 출력될겁니다.
하지만, 타이틀이 써있다고 해서 게임 같진 않네요. 현재 위치를 보드판에서 보여주면 좋을 것 같아요.
PrettyTextRepresentable 프로토콜을 만들어서 보드판을 출력해보겠습니다. 해당 프로토콜은 TextRepresentable 프로토콜을 상속받습니다.
protocol PrettyTextRepresentable: TextRepresentable { var prettyTextualDescription: String { get } }
Swift
복사
이제 PrettyTextRepresentable를 SnakesAndLadders 클래스에서 구현해볼게요.
extension SnakesAndLadders: PrettyTextRepresentable { var prettyTextualDescription: String { var board: String = "\(textualDescription)\n=================" self.gameBoard.enumerated().forEach { index, square in if index % 10 == 0 { board += "\n" } if self.currentSquare == index { board += "⬇️ " } else if index == 0 { board += "🔜 " } else if index == self.finalSquare { board += "🔚 " } else { switch square { case let ladder where ladder > 0: board += "▲ " case let snake where snake < 0: board += "▼ " default: board += "○ " } } } board += "\n=================\n" return board } }
Swift
복사
이렇게 하면, 보드판이 출력됩니다. 현재 위치와 어디에 사다리가 있고 뱀이 있는지 확인할 수 있습니다.
Snakes and Ladders 게임을 완성했습니다. ٩( ᐛ )و
게임을 직접 해보고 싶으신 분들은 해당 링크 깃헙 레포를 클론해서 Protocol 폴더에 있는 SnakesAndLadders playground로 플레이해보실 수 있습니다.

To be continued…

Protocol 1탄에서는 기본적인 부분들을 다뤘습니다.
프로토콜이 뭔가?
프로토콜은 어떤 모양을 가졌는가?
프로토콜을 사용해서 Snakes and Ladders 게임을 만들어 보자!
이어지는 포스팅에서는 추가적인 내용들을 알아보겠습니다.
그럼 다음 포스팅에서 만나요.☆⸜(⑉˙ᗜ˙⑉)⸝♡

참고 자료