Protocol vs. Class

Junyoung Lee·2022년 10월 13일
post-thumbnail

Popcorn?

POP는 Protocol Oriented Programming의 약자로, WWDC 2015에서 소개된 개념입니다. OOP에서 Class를 활용하는 것처럼 Swift에서는 Protocol을 활용하자는 것이 핵심입니다.

그러면 ProtocolClass가 어떤 차이점이 있는지 알아봐야겠죠?


Class의 장점

WWDC 2015에서는 Class의 장점을 아래와 같이 설명합니다.

  • Encapsulation
    • 데이터의 구조와 역할, 기능을 하나의 집합으로 만드는 것
  • Access Control
    • 특정 데이터와 코드의 형태를 외부로부터 숨길 수 있음
  • Abstraction
    • 객체의 공통적인 속성과 기능을 추출하여 사용할 수 있음
  • Namespace
    • 프로젝트의 규모가 커졌을 때, 변수명 등이 충돌하는 것을 방지할 수 있음
  • Expressive Syntax
    • 한 눈에 이해할 수 있을만큼 쉽게 코드를 작성할 수 있음
  • Extensibility
    • 원하는 기능을 언제든지 기존 코드에 추가할 수 있음

OOP 언어(Java, Kotlin 등)을 해보신 분들이라면, 위 내용이 무엇을 의미하는지 바로 이해가 되실 겁니다. 캡슐화, 상속, 추상화 등과 관련된 내용들이죠.

여기까지만 보면, Class가 꽤나 괜찮아 보입니다.


레퍼런스 타입의 문제점

class BankAccount {
    private var balance: Int
    
    init(balance: Int) {
        self.balance = balance
    }
    
    func deposit(amount: Int) {
        balance += amount
    }
    
    func withDraw(amount: Int) {
        balance -= amount
    }
    
    func printBalance() {
        print("USD \(balance)")
    }
}

위 코드는 은행 계좌를 Class로 표현한 예시입니다. 계좌 잔고를 표현하기 위해 Int 타입의 프로퍼티 balance를 가지고 있고, 입금과 출금을 위한 메소드도 있습니다. 그럼 이 클래스를 가지고 코드를 한번 작성해보겠습니다.

let _basicAccount = BankAccount(balance: 1000) // 기본 은행 계좌
let userA = _basicAccount // A 사용자에게 기본 은행 계좌 할당
let userB = _basicAccount // B 사용자에게 기본 은행 계좌 할당

userA.printBalance() // USD 1000
userA.deposit(amount: 10)
userB.printBalance() // ???

우선 기본 은행 계좌 _basicAccount를 선언해주고, 이를 각각 userAuserB에 할당해주었습니다. 그 다음 코드를 한번 살펴보겠습니다.

  • userA.printBalance()
    • userA의 계좌 잔고 출력 → USD 1000
  • userA.deposit(amount: 10)
    • userA의 잔고에 10 달러 입금 → userA의 잔고가 1010 달러로 변경됨
  • userB.printBalance()
    • userB의 계좌 잔고 출력 → USD 1000?
    • 실제 출력 결과는 USD 1010이 나옴

userB의 계좌 잔고 출력 결과가 예상과 다른 것을 알 수 있습니다. 한번도 userB의 잔고를 변경한 적이 없는데, 어떻게 된 것일까요?

정답은 Class가 레퍼런스 타입이기 때문입니다. 데이터 타입은 크게 값 타입과 레퍼런스 타입으로 나눌 수 있습니다. 두 타입 간에는 여러 차이점이 있지만, 복사 과정의 차이점으로 쉽게 구분할 수 있습니다.

값 타입은 깊은 복사(Deep Copy), 즉 별도의 메모리 공간에 똑같은 값이 복사됩니다. 레퍼런스 타입은 얕은 복사(Shallow Copy), 즉 원본 데이터는 그대로 있고 해당 데이터의 주소만 복사해서 가지고 있게 됩니다. 따라서 레퍼런스 타입은 복사를 하더라도 여러 변수가 똑같은 공간을 가리키고 있게 되는 것이죠.

따라서 우리는 userA의 잔고만 변경했지만, userB와 같은 데이터를 참조하고 있기 때문에 위와 같은 결과가 나온 것입니다. 그러면 이 문제를 어떻게 해결할 수 있을까요?


Class → Struct

Class는 레퍼런스 타입인 반면, Struct는 값 타입입니다. 그러면 위의 BankAccountStruct로 바꿔봅시다!

struct BankAccount {
    private var balance: Int
    
    init(balance: Int) { ... }
    
    mutating func deposit(amount: Int) { ... }
    
    mutating func withDraw(amount: Int) { ... }
    
    func printBalance() { ...  }
}

다른 부분은 거의 비슷하지만, mutating 키워드가 새로 추가되었습니다. 앞서 말했듯이, Struct는 값 타입입니다. 그리고 Swift에서 값 타입은 메소드가 프로퍼티의 값을 수정할 수 없습니다. 따라서 수정을 원하는 경우에는 이렇게 mutating 키워드를 붙여줘야 하는 것이죠.

그 이후 코드도 한번 살펴봅시다.

let _basicAccount = BankAccount(balance: 1000)
var userA = _basicAccount
let userB = _basicAccount

userA.printBalance()
userA.deposit(amount: 10)
userB.printBalance()

여기도 크게 달라진 건 없습니다. userAlet이 아니라 var로 바뀐 것만 빼고요. let은 상수를 저장하기 위한 키워드입니다. 따라서 한 번 값이 할당 된 이후에는 절대 새로운 값이 할당될 수 없죠.

하지만 userAmutating 메소드인 deposit(amount: Int)를 호출하여 프로퍼티 값을 수정합니다. 따라서 선언 이후에 값이 바뀌기 때문에, var로 선언해 주어야 합니다.


아마 여기까지 읽으신 분들은 이런 생각이 드실겁니다.

지금 이 사람이 하고 싶은 말이 뭐지? ClassStruct의 차이점인가?

Protocol의 유용함을 설명하기 위해 조금 먼 길을 돌아온 것 같네요. 그래도 ClassStruct의 차이점이 중요한 포인트이니, 너그럽게 이해해주시면 감사하겠습니다.


Protocol과 Struct의 조합

Class는 다른 Class를 상속하여 만들 수 있는 반면, Struct는 그렇지 못합니다. 따라서 Class에 비해 확장성이나 구조화가 어려울 수 있죠. 이 간극을 깔끔하게 메꿔주는 것이 바로 Protocol입니다. 아래 예시를 한번 보시죠.

protocol BankAccount {
    var balance: Int { get set }
    
    mutating func deposit(amount: Int)
    mutating func withDraw(amount: Int)
    func printBalance()
}

앞서 보여드린 BankAccountProtocol로 정의한 코드입니다. 하나씩 설명을 드려볼게요.

var balance: Int { get set }

위 코드는 Int 타입의 balance라는 프로퍼티가 존재하고, 추후에 Protocol을 확장하여 사용할 때 balance의 값을 할당하고 변경할 수도 있다는 뜻입니다.

mutating func deposit(amount: Int)
mutating func withDraw(amount: Int)
func printBalance()

위는 Protocol이 가질 메소드를 정의한 것입니다. 메소드의 바디가 없는데, 이는 Protocol을 채택하는 Struct/Class에서 작성할 수 있습니다.

Protocol을 보다보면 생각나는 것이 하나 있습니다. 바로 Java 계열 언어의 Interface이죠. 정말 비슷하지 않나요? 물론 차이점도 있습니다만, 그건 추후 다른 포스트에서 다루겠습니다.

자, 이제 Protocol의 정의가 끝났으니 실제로 사용해봅시다.

struct MyBankAccount: BankAccount {
    var balance: Int
    
    init() {
        self.balance = 0
    }
    mutating func deposit(amount: Int) {
        self.balance += amount
    }
    
    mutating func withDraw(amount: Int) {
        self.balance += amount
    }
    
    func printBalance() {
        print("USD \(balance)")
    }
}

이전과 크게 다르지 않죠? 여기서 가장 좋은 점은, : BankAccount 처럼 Protocol을 상속하는 순간 XCode가 필요한 내용을 자동으로 적어준다는 것입니다. 정말 편해요.

하지만 여전히 문제점이 있습니다. 아래 경우를 한번 볼까요?

struct BankOfAmericaAccount: BankAccount {
	...
    func printBalance() {
        print("USD \(balance)")
    }
}

struct WellsFargoAccount: BankAccount {
	...
    func printBalance() {
        print("USD \(balance)")
    }
}

이처럼 여러 Struct에서 똑같은 Protocol을 채택하고, 똑같은 메소드를 구현할 때가 있습니다. 만약 매번 똑같은 코드를 적어야 한다면 복사+붙여넣기를 기계적으로 반복해야 합니다.


마법과 같은 Extension

WWDC 2015에서 Dave Abrahams는 POP를 설명하면서 Extensions == magic이라는 표현을 썼습니다. 마치 마법과도 같다는 것이죠. 도대체 얼마나 편하길래 그럴까요? 직접 살펴봅시다.

앞선 예시와 같이 똑같은 메소드를 Protocol을 채택하는 모든 Struct/Class에서 사용할 예정이라고 할 때, 우리는 Extension을 사용할 수 있습니다. 아래 예시를 보시죠.

extension BankAccount {
    func printBalance() {
        print("USD \(balance)")
    }
}

Extension을 통해 Protocol의 메소드의 바디를 미리 정의할 수 있습니다. 이러면 더 이상 똑같은 코드를 Protocol을 채택할 때마다 적지 않아도 되죠. 이렇게 편할 수가 없습니다. 그런데 이것도 어디서 많이 본 것 같지 않나요? 저는 Java의 추상 클래스가 많이 생각 났습니다.

이처럼 우리는 Extension을 사용해서 코드 재사용률을 높이고, 확장성도 확보할 수 있습니다.


용도에 맞게 쓰자!

모든 물건은 제작자의 의도가 있고, 가능한 그 의도를 파악하고 올바르게 사용하는 것이 중요하다고 생각합니다. 저는 프로그래밍도 마찬가지일거라고 생각합니다.

Swift를 만든 사람들이 그 특성에 맞게 POP를 설계해 주었으니, 그것을 이해하고 사용하면 분명히 큰 도움이 될 것입니다. 다음 포스트에서는 제가 iOS 프로젝트를 하면서 POP를 어떻게 활용했는지 설명해 드리겠습니다.

profile
여행과 피자를 좋아하는 iOS 개발자입니다. 피자에는 파인애플이 들어가지 않습니다.

0개의 댓글