프로토콜❗️: 객체의 설계도

DEVJUN·2022년 7월 4일
0

Swift 문법

목록 보기
14/14
post-thumbnail

  프로토콜은 클래스나 구조체가 어떤 기준을 만족하거나 또는 특수한 목적을 달성하기 위해 구현해야 하는 메소드와 프로퍼티의 목록으로 다른 객체지향 언어에서 사용되는 인터페이스와 비슷한 개념이다.

  iOS는 특정 컨트롤에서 발생하는 각종 이벤트를 효율적으로 관리하기 위해 대리자(delegate)를 지정하여 이벤트 처리를 위임하고, 실제로 이벤트가 발생하면 위임된 대리자가 콜벡 메소드를 호출해주는 델리게이트 패턴(Delegate Pattern)을 많이 사용하는데, 이 패턴을 구현하기 위해 이용되는 것이 프로토콜이다.

  프로토콜은 구체적인 내용이 없는 프로퍼티나 메소드의 단순한 선언 형태로 구성되며, 구체적인 내용은 이 프로토콜을 이용하는 객체에서 담당한다. 어떤 내용을 정의하는지는 프로토콜에서 관심을 갖지 않는다. 이는 나중에 정리할 MVC와 MVVM패턴에서 다시 이야기하려고 한다.

  이때 프로토콜에 선언된 프로퍼티나 메소드의 형식을 프로토콜의 '명세'라고 부르고, 이 명세에 맞추어 실질적인 내용을 작성하는 것을 '프로토콜의 구현(Implement)한다'라고 한다. 특정 객체가 프로토콜을 구현했다면 컴파일러는 기본적으로 프로토콜에 선언된 기능과 프로퍼티가 모두 작성된 것으로 간주한다.

  클래스와 프로토콜의 차이라고 한다면 클래스에서 상속은 대부분 기능을 계속 물려받고 추가적인 것들을 확장하는 것이므로 부모 자식 관계가 성립하는 밀접한 객체들이어야 하지만 프로토콜에는 그와 같은 것이 없고 자동차와 전혀 상관없는 것들에 프로토콜을 갖다 붙일 수 있다.

	protocol <프로토콜명> {
    	<구현해야 할 프로퍼티 명세>
         		.
                .
        <구현해야 할 메소드 명세 1>
        		.
                .
    }

  프로토콜을 구현한다는 것은 프로토콜에 선언된 명세에 따라 실질적으로 프로퍼티에 값을 할당하여 선언하거나 메소드이 내용을 작성하는 것을 의미한다. 스위프트에서 프로토콜을 구현할 수 있는 구현체들은 구조체, 클래스, 열거형, 익스텐션등이 있다.


1-1 프로토콜 프로퍼티

	protocol SomeProperyProtocol {
    	var name: String { get set }
        var description: String { get }
    }

  프로토콜에 선언되는 프로퍼티에는 초기값을 할당할 수 없다. 또한 이름, 변수/상수 구분, 타입, 읽기 전용인지 읽고 쓰기가 가능한지에 대해서만 정의할 뿐이다.

  인스턴스 프로퍼티 두 개를 SomePropertyProtocol 프로토콜에 선언하고 있다. 프로토콜의 명세에는 초기값을 할당하는 대신 읽기 전용/읽고 쓰기에 대한 여부를 get과 set 키워드로 표시해야 한다. 익기 전용으로 설정할 때는 get만, 읽고 쓰기가 모두 가능한 프로퍼티를 선언할 때는 get과 set 모두를 작성한다.

	struct RubyMember: SomePropertyProtocol {
    	var name = "홍길동"
        var description: String {
        	return "Name : \(self.name)"	
        }
    }

  위에서 정의한 SomePropertyProtocol 프로토콜을 구현한 구조체이다. 프로토콜에서 get set으로 선언된 name 프로퍼티는 저장프로퍼티로, 읽기 전용으로 선언된 description 프로퍼티는 연산 프로퍼티로 구현하고 있는 것을 볼 수 있다. 프로토콜에서 선언된 프로퍼티 중 일부를 누락하면 오류가 발생하지만 프로토콜과 상관없이 필요한 프로퍼티를 구현체에서 더 추가할 수 있다.


1-2 프로토콜 메소드

	protocol SomeMethodProtocol {
    	func execute(cmd: String)
        func showPort(p: Int) -> String
    }

  프로토콜 SomeMethodProtocol은 두 개의 인스턴스 메소드를 포함하고 있다. 프로토콜에서 메소드를 정의하는 방식은 클래스나 구조체에서 메소드를 정의할 때 사용하는 형태와 동일하다. 프로토콜의 메소드가 클래스의 메소드와 다른 점은 메소드의 선언 뒤에 중괄호 블록이 없다는 점이다. 즉 실제 실행할 내용을 작성할 수 없다는 뜻이다. 메소드의 실질적인 내용은 구현체 즉 프로토콜을 구현하는 구조체나 클래스, 열거형, 혹은 익스텐션의 역할이다.

	struct RubyService: SomeMethodProtocol {
    	func execute(cmd: String) {
        	if cmd == "start" {
            	print("실행")
            }
        }
    	func showPort(p: Int) -> String {
        	return "Port : \(p)"
        }
    }

  위에서 정의한 SomeMethodProtocol 프로토콜을 구현하고 있는 구조체이다. 프로토콜에서 정의된 메소드는 구현체에서도 매개변수명까지 완전히 일치해야한다.

	protocol NewSomeMethodProtocol {
    	mutating func execute(cmd command: String, desc: String)
        func showPort(p: Int, memo desc: String) -> String
    }
    
    struct RubyService: NewSomeMethodProtocol {
    	func execute(cmd comm: String, desc d: String) {
        	if command == "start" {
            	print("\(d) 실행")
            }
        }
    	func showPort(p: Int, memo description: String) -> String {
        	return "Port : \(p), Memo : \(description)"
        }
    }

  위 코드는 외부 매개변수명이 포함된 프로토콜 메소드와 이를 구현한 구조체 예시이다. 외부 매개변수명은 프로토콜을 그대로 따라야 하지만 내부 매개변수명은 임의로 바꾸어 사용해도 된다.

  위 구문에서 exeute(cmd: desc: ) 메소드의 경우 첫 번째 매개변수의 외부 매개변수명은 프로토콜서와 구현한 구조체에서 모두 cmd 이지만 내부 매개변수는 각각 command, comm으로 다르게 정의되어 있다. 이렇게 내부 매개변수의 경우 프로토콜을 그대로 따르지 않고 필요한 대로 변형하여 사용하는 것이 가능하다.


1-3 프로토콜에서의 mutating, static 사용

  스위프트에서는 구조체 내의 메소드가 프로퍼티를 변경하는 경우, 메소드 앞에 반드시 mutating 키워드를 붙여 이 메소드가 프로퍼티 값을 수정하는 메소드임을 표시하도록 강제하고 있다. 이때 그 메소드가 만약 프로토콜에서 선언된 메소드라면 mutating 키워드를 붙이기 위해서는 반드시 프로토콜에 mutating 키워드가 추가되어 있어야 한다.

  클래스와 같은 참조 타입은 mutating키워드를 붙이지 않아도 메소드 내에서 마음대로 프로퍼티를 수정할 수 있지만, 구조체나 열거형으 포로토콜의 메소드에 mutating 키워드가 추가되어 있지 않을 경우 프로퍼티의 값을 변경할 수 없다. 이런 면에서, 프로토콜은 자신을 구현하는 구조체가 마음대로 프로퍼티를 수정하지 못하도록 통제할 수 있는 권한을 가지고 있다.

  프로토콜에서 메소드 선언에 mutating 키워드가 붙지 않는 다는 것은 다음의 두 가지 경우이다.

  • 구조체나 열거형 등 값 타입의 객체에서 내부 프로퍼티의 값을 변경하기를 원치 않을 때
  • 주로 클래스를 대상으로 간주하고 작성된 프로토콜일 때
	protocol MService {
    	mutating func execute(cmd: String)
        func showPort(p: Int) -> String
    }
	
    struct RubyMService: MService {
    	var paramCommand: String?
        
        mutating func execute(cmd: String) {
        	self.paramCommand = cmd
            if cmd == "start" {
            	print("실행")
            }
        }
        
        func showPort(p: Int) -> String {
        	return " \(p), now command : \(self.paramCommand!)"
        }
    }
    
    
    struct RubyMService2: MService {
    	var paramCommand: String?
        
        func execute(cmd: String) {
            if cmd == "start" {
            	print("실행")
            }
        }
        
        func showPort(p: Int) -> String {
        	return " \(p), now command : \(self.paramCommand!)"
        }
    } 

  위 코드에서 RubyMService2구조체는 프로토콜 메소드에 기재된 mutating 키워드를 사용하지 않았지만 MService프로토콜을 문제 없이 구현하고 있다. 구조체에서 mutating 키워드를 붙이지 않은 것은 실제로 구현된 execute(cmd:) 메소드 내에서 프로퍼티를 변경하지 않기 때문에 프로토콜 쪽에서 mutating 키워드가 추가되어 있다 할지라도 실제 구현하는 쪽에서 프로퍼티의 변경이 없다면 굳이 붙이지 않고 생략해도 오류는 발생하지 않는다.

  클래스의 경우는 조금 다르다. 클래스는 참조 타입!의 객체이므로 메소드 내부에서 프로퍼티를 수정하더라도 mutating 키워드를 붙일 필요가 없다. mutating 키워드가 붙어있는 프로토콜 메소드를 구현할 때도 클래스에서는 프로퍼티의 수정 여부와 관계없이 mutating 키워드를 사용하지 않는다.

  타입 메소드나 타입 프로퍼티도 프로토콜에 정의할 수 있다. 프로토콜의 각 선언 앞에 static 키워드를 붙이면 된다. 클래스에서 타입 메소드를 선언할 때 사용할 수 있는 또 다른 키워드인 class는 프로토콜에서 사용할 수 없다. 이유는 프로토콜은 구조체나 열거형, 그리고 클래스에 모두 사용할 수 있는 형식으로 정의되어야 하기 때문이다.


1-4 프로토콜과 초기화 메소드

  프로토콜에서는 초기화 메소드도 정의할 수 있다. 실행블록을 작성하지 않고, 단순히 이름과 매개변수명, 그리고 매개변수의 타입만 작성하면 된다.

	protocol SomeInitProtocol {
    	init()
        init(cmd: String)
    }

  초기화 메소드가 포함된 프로토콜을 구현할 때 주의할 점이 있다. 외부 매개변수명까지는 완전히 일치해야 한다. 다음으로 구조체는 모든 포로퍼티의 초기값을 한 번에 설정할 수 이쓴 멤버와이즈 메소드가 기본으로 제공되지만, 만약 프로토콜에 멤버와이즈 메소드가 선언되었다면 프로토콜에 선언된 초기화 메소드는 기본 제공 여부와 상관없이 모두 직접 구현해 주어야 한다. 마지막으로, 클래스에서 초기화 메소드를 구현할 때에는 반드시 required 키워드를 붙여야 한다. 이러한 조건들을 토대로 예시코드를 구현하면 아래와 같다.

	struct SInit: SomeInitProtocol {
    	var cmd: String
        
        init() {
        	self.cmd = "start"
        }
    	
        init(cmd: String) {
        	self.cmd = cmd
        }
    }
    
    class CInit: SomeInitProtocol {
    	var cmd: String
        
        required init() {
        	self.cmd = "start"
        }
    
    	required init(cmd: String ) {
        	self.cmd \ cmd
        }
    }
    

  클래스는 상속과 프로토콜 구현이 동시에 가능한 객체이다. 즉 부모 클래스로부터 초기화 메소드, 메소드와 프로퍼티 등을 상속받으면서 동시에 프로토콜에 정의된 초기화 메소드, 프로퍼티나 메소드를 구현할 수 있다는 뜻이다. 이때 부모 클래스로부터 물려받은 초기화 구문과 프로토콜로부터 구현해야 하는 초기화 메소드가 충돌하는 경우가 종종 생긴다.

  상속을 통해 초기화 메소드를 물려받았다 할지라도 구현해야 할 프로토콜 명세에 동일한 초기화 메소드가 선언되어 있다면 이를 다시 구현해야 한다. 부모 클래스의 관점에서 볼 때 상속받은 초기화 메소드를 오버라이드하는 셈이다. 이때에는 초기화 메소드에 required 키워드와 override 키워드를 모두 붙여주어야 한다.

	// init() 메소드를 가지는 프로토콜
    protocol Init {
    	init()
    }
    
    //init() 메소드를 가지는 부모 클래스
    class Parent {
    	init() {}
    }
    
    // 부모클래스의 init() + 프로토콜의 init()
    class Child: Parent, Init {
    	override required init(){
        }
    }
    

  위 코드에서 Child 클래스는 Parent 클래스의 init() 메소드와 Init 프로토콜의 init() 메소드를 동시에 상속받고 있다. override와 required의 순서는 상관이 없다. 또한 일반 메소드나 연산 프로퍼티에 required 키워드는 붙이지 않는다.

  클래스는 상속의 개념이 있기 때문에 프로토콜 구현을 선언할 때 주의해야 한다. 프로토콜을 구현할 클래스가 다른 클래스로부터 상속된 것이라면 먼저 상속에 대한 선언부터 해야 한다.


2-1 타입으로서의 프로토콜

  프로토콜은 때로는 타입으로서 중요한 역할을 하기도 한다.

  • 상수나 변수, 그리고 프로퍼티의 타입으로 사용할 수 있음
  • 함수, 메소드 또는 초기화 구문에서 매개변수 타입이나 반환 타입으로 프로토콜을 사용할 수 있음
  • 배열이나 사전, 혹은 다른 컨테이너의 타입으로 사용할 수 있음
	protocol A {
    	func doA()
    }
    
    protocol B {
    	func doB()
    }
    
    class Impl: A, B {
    	func doA {
        }
    	
        func doB {
        }
        
        func desc() -> String {
        	return "Class instance method"
        }
    }
    
    var ipl: A & B \ Impl()
    ipl.doA()
    ipl.doB()

  ipl 변수의 타입으로 사용된 A & B는 A프로토콜과 B프로토콜 모두를 포함하는 객체 타입이다 이 타입으로 정의된 변수는 두 프로토콜을 모두 구현한 객체만 할당받을 수 있다. ipl은 클래스 Impl의 인스턴스를 할당 받았지만, 클래스에서 정의된 메소드 desc()는 사용할 수 없다. 대신 프로토콜 A와 B에서 정의된 doA(), doB()는 사용할 수 있다.

3-1 델리게이션

  프로토콜 타입으로 선언된 값을 사용한다는 것은, 여기에 할당된 객체가 구체적으로 어떤 기능을 갖추고 있는지는 상관없다는 뜻이기도 하다.

  코코아 터치 프레임워크에서는 이러한 프로토콜 타입의 특성을 이용하여 델리게이션이라는 기능을 구현한다.

  델리게이션(Delegation)은 델리게이트 패턴과 연관되는 아주 중요한 개념으로, 특정기능을 다른 객체에 위임하고, 그에 따라 필요한 시점에서 메소드의 호출만 받는 패턴이다.

  예를 들어 자동차, 오토바이, 기차 등에서 공통으로 사용되는 연료펌프를 전문으로 만드는 회사가 있는데, 이 회사에서 만들어낸 연료펌프는 특별히 신경 쓰지 않아도 알아서 잘 굴러가다가 연료가 부족해지면 이를 연료펌프가 장착된 시스템에 알려주는 기능을 가지고 있다고 해보자! 알림을 받은 자동차, 자전거, 기차 등의 시스템은 각자의 방법으로 모자란 연료를 보충하게 된다. 또 연료 보충 과정에서 충분히 연료가 채워지면 이를 시스템에 알려 연료 보충을 중단하도록 알려주기도 한다. 이때ㅔ 연료가 부족해지는 시점이나 연료가 가득 차는 시점을 연료펌프가 장착된 시스템이 점검하는 것이 아니라 연료펌프에 위임해두고, 연료펌프가 알아서 스스로 알려주도록 하는 것이 바로 델리게이션이라고 할 수 있다.

	protocol FuelPumpDelegate {
    	func lackFuel()
        func fullFuel()
    }
    
    class FuelPump {
    	var maxGage: Double = 100.0
        var delegate: FuelPumpDelegate? =nil
    
    	var fuelGage: Double {
        	didSet {
            	if oldValue < 10 {
                	//연료가 부족해지면 델리게이트의 lackFuel 메소드 호출.
                    self.delegate?.lackFuel()
                } else if oldValue == self.maxGage {
                	// 연료가 가득차면 델리게이트의 fullFuel 메소드를 호출한다.
                    self.delegate?.fullFuel()
                }
            }
        }
        
        init(fuelGage: Double = 0) {
        	self.fuelGage = fuelGage
        }
        
        //연료펌프를 가동.
        func startPump() {
        	while(true) {
            	if (self.fuelGage > 0) {
                	self.jetFuel()
                }else {
                	break
                }
            }
        }
        
        //연료가 엔진에 분사. 분사할 때마다 연료 게이지의 눈금은 내려감.
        func jetFuel() {
        	self.fuelGage -= 1
        }
    }    
    

  위에서 FuelPump클래스는 FuelPumpDelegate 프로토콜을 구현한 객체의 정보를 delegate 프로퍼티에 저장해 두었다가 필요한 시점에 프로토콜의 메소드를 호출하는 대상으로 사용한다. 또한 didSet키워드로 fuelGage에 대한 프로퍼티 옵저버를 통해 연료 눈금이 10 미만으로 떨어지면 델리게이트 프로퍼티에 저장된 객체에 lackFuel() 메소드를, 연료가 가득차면 fullFuel()메소드를 각각 호출한다. 이때 dlelegate 프로퍼티에 저장되는 객체는 FuelPumpDelegate 프로토콜 타입으로 선언된다. delegate 프로퍼티는 선언된 타입으로 인해, 실제 그 객체가 어떤 타입이든 관계없이 FuelPumpDelegate 프로토콜에 정의된 lackFuel()과 fullFuel() 메소드만을 사용할 수 있다.

	class Car: FuelPumpDelegate {
    	var fuelPump = FuelPump(fuelGage: 100)
        
        init() {
        	self.fuelPump.delegate = self
        }
        
        // fuelPump가 호출하는 메소드
        func lackFuel() {
        	// 연료 보충
        }
        
        // fuelPump가 호출하는 메소드
        func fullFuel {
        	// 연료 보충 중단
        }
        
        // 자동차에 시동을 검
        func start() {
        	fuelPump.startPump()
        }
    }

  Car클래스는 fuelPump라는 프로퍼티에 앞에서 작성한 연료펌프 클래스의 인스턴스를 할당한다. 초기화 구문으로 클래스가 만들어질 때 연료펌프에 연료를 100으로 채우고, 연료펌프의 델리게이트 프로퍼티를 자신으로 설정한다. 이제 Car 클래스를 누군가 인스턴스로 생성하여 start 메소드를 호출하면 연료펌프 역시 작동되면서 연료가 부족해지는 시점이 오면 delegate 객체를 대상으로 lackFuel 메소드를 호출한다. delegate 프로퍼티에는 Car 인스턴스가 할당되어 있으므로 Car 클래스에서 작성한 lackFuel()메소드가 실행된다.

  프로토콜을 사용하여 델리게이션을 구현하는 것은, 클래스가 단일 상속만을 지원하기 때문이다. 하나의 부모 클래스를 상속받고 나면 더는 다른 클래스를 상속받을 수 없으므로 구현 개수에 제한이 없는 프로토콜을 이용하여 델리게이션을 구현한 것이다.


4-1 프로토콜의 활용

  클래스나 구조체, 열거형 등의 특정 객체에서 프로토콜을 구현해야 할 경우, 객체 자체의 코드를 수정하여 직접 구현할 수도 있지만 이를 대신하여 익스텐션에서 프로토콜을 구현할 수도 있다.

	class Man {
    	var name: String?
    	
        init(name: String = "홍길동") {
        	self.name = name
        }
    }
    
    protocol Job {
    	func dowork()
    }
    
    extension Man: Job {
    	func doWork() {
        	print("\(self.name!)님이 일을 한다.")
        }
    }
    
    let man = Man(name: "개발자")
    man.doWork()

  원래 Man 클래스는 Job프로토콜을 구현하지 않기 때문에 doWork() 메소드를 사용할 수 없지만 extension을 통해 기존 클래스를 구현하지 않고도 Job 프로토콜을 구현할 수 있다.

4-2 optional

  프로토콜을 구현할 때는 기본적으로 프로토콜의 명세에 포함된 모든 프로퍼티와 메소드, 그리고 초기화 구문을 표현해야 한다. 하지만 구현하는 객체에 따라 특별히 필요하지 않은 프로퍼티나 메소드, 초기화 구문이 있을 수 있다. 이런 메소드까지 전부 구현한다면 상당히 번거로워진다.

  이런 상황을 방지하기 위해 선택적 요청(Optional Requirement)라고 불리는 문법이 있다. 프로토콜을 정의할 때 optional 키워드를 사용하여 프로퍼티나 메소드, 초기화 구문 앞에 표시한다. 이 키워드가 붙은 요소들은 프로토콜을 구현할 때 반드시 구현하지 않아도 된다는 것을 의미한다.

  프로토콜에서 optional 키워드를 사용하려면 프로토콜 앞에 @objc를 붙여야 한다. @objc는 파운데이션 프레임워크에 정의된 어노테이션의 일종으로, 이 어노테이션이 붙은 코드나 객체를 오브젝티브-C 코드에서도 참조할 수 있도록 노출됨을 의미한다. 또한 @objc가 붙은 프로토콜은 구조체나 열거형에서 구현할 수 없고 오직 클래스만 구현할 수 있다.

	import Foundation
    
    @objc
    protocol MsgDelegate {
    	@objc optional func onReceive(new:Int)
    }
    
    class MsgCenter {
    	var delegate: MsgDelegate?
        var newMsg: Int = 0
    	
        func msgCheck() {
        	if newMsg > 0 {
            	self.delegate?.onReceive?(new: self.newMsg)
                self.newMsg = 0
            }
        }
    }

  위 코드에서 optional 키워드가 붙은 메소드를 호출할 때는 옵셔널 체인처럼 사용하면 된다. 다만 이때는 메소드의 결과값이 옵셔널이 아니라 메소드 자체가 옵셔널이므로 메소드와 괄호 사이에 ?연산자를 작성해야 한다.

  실제로 코코아 터치 프레임워크에서는 프로토콜마다 정의해야 할 메소드가 상당히 많다. 그래서 해당 프로토콜에서 반드시 필요한 메소드들만을 제외하고 나머지는 optional 키워드로 선언되어 선택적으로 구현할 수 있도록 제공된다.

profile
🧑🏻‍💻iOS

0개의 댓글