본문 바로가기

iOS/3rd Party Library

iOS에서의 웹소켓 통신: socketcluster-client-swift 사용법과 분석

 


 

iOS에서 웹소켓 통신을 구현하는 방법 중 하나인 socketcluster-client-swift 라이브러리의 사용법과 소스코드를 분석해보겠습니다.

이를 통해 기존보다 안정적이고 현대적인 오픈소스구현을 목표로 삼아보겠습니다.

 

개요

Socket.IO와 마찬가지로 SocketCluster는 실시간 애플리케이션에 널리 사용되는 프레임워크입니다.

이 때, iOS 개발을 위해 지원되는 라이브러리가 socketcluster-client-swift 입니다.

 

라이브러리 링크: socketcluster-client-swift

 

주요 기능
  1. 설치 및 사용이 간편
  2. 원격 이벤트의 송수신 지원
  3. Pub/Sub 패턴 지원
  4. JWT 인증 지원

 

요구사항
  • iOS ≥ 8.0, macOS ≥ 10.10, watchOS ≥ 2.0, tvOS ≥ 9.0

확실히 업데이트가 4년전 이후로 없어서 플랫폼 지원버전이 매우 낮습니다.

또한 CocoaPods, SwiftPM 모두 지원합니다.

 

사용방법

인스턴스 생성
var client = ScClient(url: "http://localhost:8000/socketcluster/")

주의사항은 socketcluster의 end-point URL에는 항상 socketcluster가 포함되어야 합니다.

 

기본 리스너 등록
import Foundation
import ScClient

var client = ScClient(url: "http://localhost:8000/socketcluster/")

var onConnect = {
    (client :ScClient) in
    print("서버에 연결되었습다.")
}

var onDisconnect = {
    (client :ScClient, error : Error?) in
    print("서버 연결이 해제 되었습니다. Error: \(error?.localizedDescription)")
}

var onAuthentication = {
    (client :ScClient, isAuthenticated : Bool?) in
    print("인증 여부", isAuthenticated)
    startCode(client : client)
}

var onSetAuthentication = {
    (client : ScClient, token : String?) in
    print("Token: \(token)")
}

client.setBasicListener(onConnect: onConnect, onConnectError: nil, onDisconnect: onDisconnect)
client.setAuthenticationListener(onSetAuthentication: onSetAuthentication, onAuthentication: onAuthentication)

client.connect()

while(true) {
    RunLoop.current.run(until: Date())
    usleep(10)
}

func startCode(client scclient.Client) {
}

README.md에 작성된 예제를 보면 다양한 클로저 함수를 통해 각각의 상황별로 처리하는 것을 확인할 수 있습니다.

  • setBasicListener(): 연결상태에 따른 클로저를 처리합니다.
  • setAuthenticationListener() : 인증 관련 클로저를 처리합니다.
  • connect() : 서버에 연결을 시작합니다.
  • isConnected() : 서버 연결상태를 Bool 자료형으로 확인합니다.

 

이벤트 처리방법

 

client.emit(eventName: "eventname", data: message as AnyObject)

client.emitAck(eventName: "chat", data: "Sample message" as AnyObject, ack: { (eventName: String, error: AnyObject?, data: AnyObject?) in
    print("Received data for eventName", eventName, "error:", error, "data:", data)
})

client.on(eventName: "yell", ack: { (eventName: String, data: AnyObject?) in
    print("Received data for eventName", eventName, "data:", data)
})

client.onAck(eventName: "yell", ack: { (eventName: String, data: AnyObject?, ack: (AnyObject?, AnyObject?) -> Void) in
    print("Received data for eventName", eventName, "data:", data)
    ack("Error message" as AnyObject, "Data message" as AnyObject)
})
  • emit() : 이벤트를 보낼 수 있고, data는 AnyObject 자료형으로 변환해서 전달합니다.
  • emitAck() : 기존 emit() 함수에서 이벤트전송여부를 확인하기 위한 ack(acknowledgement)를 함께 보냅니다.
  • on() : 이벤트를 수신하기 위해 사용합니다.
  • onAck() : 기존 on() 함수에서 서버에게 이벤트 수신여부를 함께 보내기 위해 사용합니다.

 

채널을 통한 Pub-Sub 구현
client.subscribe(channelName: "yell")

client.subscribeAck(channelName: "yell", ack : {
    (channelName : String, error : AnyObject?, data : AnyObject?) in
    if (error is NSNull) {
        print("Successfully subscribed to channel ", channelName)
    } else {
        print("Got error while subscribing ", error)
    }
})

client.unsubscribe(channelName: "yell")

client.unsubscribeAck(channelName: "yell", ack : {
    (channelName : String, error : AnyObject?, data : AnyObject?) in
    if (error is NSNull) {
        print("Successfully unsubscribed to channel ", channelName)
    } else {
        print("Got error while unsubscribing ", error)
    }
})
  • subscribe() : 채널명을 통해 구독을 시작합니다.
  • subscribeAck() : 기존 subscribe() 함수에서 구독여부를 확인하기 위한 ack(acknowledgement)를 함께 보냅니다.
  • unsubscribe() : 채널명을 통해 해당 채널구독을 취소합니다.
  • unsubscribeAck() : 기존 unsubscribe() 함수에서 구독취소여부를 확인하기 위한 ack(acknowledgement)를 함께 보냅니다.
client.publish(channelName: "yell", data: "I am sending data to yell" as AnyObject)

client.publishAck(channelName: "yell", data: "I am sending data to yell" as AnyObject, ack : {
    (channelName : String, error : AnyObject?, data : AnyObject?) in
    if (error is NSNull) {
        print("Successfully published to channel ", channelName)
    }else {
        print("Got error while publishing ", error)
    }
})

client.onChannel(channelName: "yell", ack: {
        (channelName : String , data : AnyObject?) in
        print ("Got data for channel", channelName, " object data is ", data)
})
  • publish() : 채널에 이벤트를 전달하기 위해 사용합니다.
  • publishAck() : 기존 publish() 함수에서 서버에게 이벤트 전송여부를 함께 보내기 위해 사용합니다.
  • onChannel() : 채널에서 전송한 이벤트를 전달받기 위해 사용합니다.

 

라이브러리 분석

소스코드 트리구조

 

socketcluster-client-swift의 Sources 폴더의 파일 구조는 다음과 같습니다.

.
├── Main
│   └── main.swift
└── ScClient
    ├── Emitter
    │   └── Listener.swift
    ├── Models
    │   └── Event.swift
    ├── Parser
    │   └── Parser.swift
    ├── Utils
    │   ├── AtomicInteger.swift
    │   ├── ClientUtils.swift
    │   └── Miscellaneous.swift
    └── client.swift

7 directories, 8 files

main.swift는 socketcluster-client-swift를 어떻게 사용해야 하는지 사용방법 예시를 작성한 파일입니다. 실제 구현코드는 ScClient 폴더만 확인하면 됩니다.

 


먼저, 연결 및 통신을 담당하는 ScClient가 정의된 client.swift를 살펴보겠습니다.

// client.swift
import Starscream
import Foundation

public class ScClient: Listener, WebSocketDelegate {
  ...

해당 객체는 Listener를 상속받고, Starscream 라이브러리에서 사용하는 WebSocketDelegate를 채택 중입니다.

그럼, ScClient를 상속하고있는 Listener를 살펴보겠습니다.

// Listener.swift

import Foundation

public class Listener {

    var emitAckListener: [Int : (String, (String, AnyObject?, AnyObject? ) -> Void )]
    var onListener: [String : (String, AnyObject?) -> Void]
    var onAckListener: [String: (String, AnyObject?, (AnyObject?, AnyObject?) -> Void ) -> Void]

    func putEmitAck { ... }
    func handleEmitAck { ... }

    func putOnListener { ... }
    func handleOnListener { ... }

    func putOnAckListener { ... }
    func handleOnAckListener { ... }

    func hasEventAck { ... }
    
}

전체적으로 emitAckListener, onListener, onAckListener 3개의 딕셔너리 변수가 있습니다.

해당 변수들의 딕셔너리 값은 클로저 변수를 가집니다.

  1. emitAckListener는 ack를 함께보내는 함수들에서 활용되는 변수입니다.
  • emitAck(), subscribeAck(), unsubscribeAck(), publishAck()

2. onListener는 이벤트수신을 위한 함수들에서 활용되는 변수입니다.

  • onChannel(), on()

3. onAckListener는 이벤트수신에 ack를 함께 보내는 onAck 함수에서 활용되는 변수입니다.

객체의 함수들을 살펴보면 전체적으로 함수명이 put~, handle~ 구조를 가지고 있습니다.

간단히 코드를 확인해보면,

  • put~ 으로 시작하는 함수들은 위의 Listener 변수들과 매칭된 딕셔너리에 값을 추가하는 함수들입니다.
  • handle~ 로 시작하는 함수들은 위의 Listener 변수들과 매칭된 딕셔너리의 키를 통해 값으로 저장된 클로저를 호출해주는 함수들입니다.

즉, Listener 객체는 ack 및 이벤트 수신 처리를 담당하는 객체입니다.


다시 ScClient로 돌아가서 변수들을 확인해보겠습니다.

// client.swift
public class ScClient : Listener, WebSocketDelegate {

    var authToken : String?
    var url : String?
    var socket : WebSocket
    var counter : AtomicInteger
    
    var onConnect : ((ScClient)-> Void)?
    var onConnectError : ((ScClient, Error?)-> Void)?
    var onDisconnect : ((ScClient, Error?)-> Void)?
    var onSetAuthentication : ((ScClient, String?)-> Void)?
    var onAuthentication : ((ScClient, Bool?)-> Void)?
    
    ...

Starscream 라이브러리를 사용 중이여서 WebSocket 객체를 사용하고, WebSocketDelegate를 채택 중입니다. 핵심 로직은 socket 변수가 담당할 것으로 보여집니다.

url 변수는 전달받은 url을 통해 연결을 시도할 것이고, authToken도 토큰을 저장할 변수일 것입니다.

on~으로 선언된 변수들은 연결상태, 인증상태에 따라 처리하는 클로저들이 있습니다.

counter변수는 AtomicInteger인데 간단히 알아보겠습니다.

// AtomicInteger.swift
import Foundation

public final class AtomicInteger {
    
    private let lock = DispatchSemaphore(value: 1)
    private var _value: Int
    
    public init(value initialValue: Int = 0) {
        _value = initialValue
    }
    
    public var value: Int {
        get {
            lock.wait()
            defer { lock.signal() }
            return _value
        }
        set {
            lock.wait()
            defer { lock.signal() }
            _value = newValue
        }
    }
    
    public func decrementAndGet() -> Int {
        lock.wait()
        defer { lock.signal() }
        _value -= 1
        return _value
    }
    
    public func incrementAndGet() -> Int {
        lock.wait()
        defer { lock.signal() }
        _value += 1
        return _value
    }

}

 

코드를 살펴보면, 데이터경쟁방지를 위해 DispatchSemaphore를 사용하여 동시접근을 제한하여 정수를 읽거나 저장하고 1씩 증감, 감소 시킬 수 있는 객체입니다.


 

다시 ScClient로 돌아가서 변수들을 확인해보겠습니다.

// client.swift
public class ScClient : Listener, WebSocketDelegate {

  public func websocketDidConnect(socket: WebSocketClient) {
        counter.value = 0
        self.sendHandShake()
        onConnect?(self)
    }
    
  public func websocketDidDisconnect(socket: WebSocketClient, error: Error?) {
      onDisconnect?(self, error)
  }
  
  public func websocketDidReceiveMessage(socket: WebSocketClient, text: String) {
      if (text == "") {
          socket.write(string: "")
      } else {
          if let messageObject = JSONConverter.deserializeString(message: text) {
              if let (data, rid, cid, eventName, error) = Parser.getMessageDetails(myMessage: messageObject) {
                  
                  let parseResult = Parser.parse(rid: rid, cid: cid, event: eventName)
                  
                  switch parseResult {
                      
                  case .isAuthenticated:
                      let isAuthenticated = ClientUtils.getIsAuthenticated(message: messageObject)
                      onAuthentication?(self, isAuthenticated)
                  case .publish:
                      if let channel = Model.getChannelObject(data: data as AnyObject) {
                          handleOnListener(eventName: channel.channel, data: channel.data as AnyObject)
                      }
                  case .removeToken:
                      self.authToken = nil
                  case .setToken:
                      authToken = ClientUtils.getAuthToken(message: messageObject)
                      self.onSetAuthentication?(self, authToken)
                  case .ackReceive:
                      
                      handleEmitAck(id: rid!, error: error as AnyObject, data: data as AnyObject)
                  case .event:
                      if hasEventAck(eventName: eventName!) {
                          handleOnAckListener(eventName: eventName!, data: data as AnyObject, ack: self.ack(cid: cid!))
                      } else {
                          handleOnListener(eventName: eventName!, data: data as AnyObject)
                      }
                  }
              }
          }
      }
  }
  
  public func websocketDidReceiveData(socket: WebSocketClient, data: Data) {
      print("Received data: \(data.count)")
  }
  
  private func sendHandShake() {
      let handshake = Model.getHandshakeObject(authToken: self.authToken, messageId: counter.incrementAndGet())
      socket.write(string: handshake.toJSONString()!)
  }

ScClient를 보면 다른 함수들은 코드들이 간단합니다. 실제로는 Starscream 라이브러리의 코드를 활용하여 소켓 연결, 해제, 전송, 수신등을 전부 처리하고 있습니다.

그러므로, WebSocketDelegate의 함수들을 확인해보겠습니다.

 

WebSocketDelegate의 함수

 

websocketDidConnect() : 해당 함수는 소켓이 연결될때 호출되는 함수입니다.

  1. counter변수값을 초기화값인 0으로 설정합니다.
  2. sendHandShake() 함수에서 전달하는 이벤트모델을 생성해주는 Model 클래스가 존재하고 JSONString 자료형으로 서버에 전송합니다. → 즉, HandShake 처리를 합니다.
  3. onConnect 클로저를 호출합니다. → 이로써 setBasicListener() 에서 처리할 수 있습니다.

websocketDidDisconnect() : 해당 함수는 소켓연결이 해제될때 호출되는 함수입니다.

  • onDisconnect 클로저를 호출합니다. → 이로써 setBasicListener() 에서 처리할 수 있습니다.

websocketDidReceiveMessage(socket: WebSocketClient, text: String) : 서버에서 전달한 데이터를 수신할 때 호출됩니다.

  1. JSON 형식의 String을 Dictionary로 반환합니다.
  2. 반환된 Dictionary의 값들에 따라 조건을 나눈 후 Listener 객체에서 살펴본 handle~ 함수들을 통해 클로저를 호출합니다. → 이로써 이벤트 수신 및 ack 처리등을 처리합니다.

결론

socketcluster-client-swift 라이브러리를 분석해본 결과 느낀점을 정리해보겠습니다.

장점
  1. 이벤트를 전달, 수신할 때 이벤트이름들(#handshake, #publish, #subscribe 등등)이 미리 선언되어 있어 서버와 정해져있는 경우 편리하게 사용할 수 있습니다.
  2. Starscream 라이브러리에 추가적으로 유틸리티함수들을 제공합니다.
  3. jwt 인증이 편리합니다.
단점
  1. Starscream 라이브러리의 의존성이 매우 높다고 생각했습니다.
  2. 라이브러리 업데이트가 미흡하다. (마지막 업데이트는 4년전)
  3. 해당 라이브러리를 관심도가 적어 리소스가 거의 없었습니다. (Github 저장소의 star 수도 약 20개 정도)

서버에서 socketcluster를 사용하면 클라이언트와 동일하게 socketcluster를 사용해야 socketcluster에서 지원하는 기능들을 온전히 사용할 수 있습니다.

socket.io도 마찬가지로 동일하게 사용해야 기능들을 원활하게 사용할 수 있습니다.

기존에는 그렇기 때문에 당연히 사용해야 한다고 생각했지만, socketcluster 라이브러리를 분석한 결과 다시 재구성하여 third-party 라이브러리의 의존도를 줄이고 first-party 프레임워크를 사용하여 더 안정적인 라이브러리를 만들 수 있을 것으로 생각해서 추후 오픈소스를 제작해봐야겠습니다.