반응형

https://jayeshkawli.ghost.io/ 사이트의 강좌 번역본입니다.

원문 : https://jayeshkawli.ghost.io/using-websockets-on-ios-using/amp/

WebSockets on iOS using URLSessionWebSocketTask

iOS는 WebSocket에 대한 지원을 iOS 13부터 시작했으며 WWDC19에서 발표했습니다. 그 전에는, 개발자들이 Starscream이나 SwiftWebsocket과 같은 타사(third-party) 라이브러리에 의존해야했습니다. 이제는 기본적으로 지원하며, 타사 라이브러리를 사용하는 오버헤드 없이 매우 쉽게 추가할 수 있습니다.

참고 : 타사 라이브러리를 사용해서 iOS에서 WebSockets 사용하기

iOS에서 WebSocket를 이해하기 위해서, 단계별로 진행하고 설정하는 각 부분이 어떻게 동작하는지 이해할 것입니다.

iOS API

iOS 는 iOS 13에서 도입한 URLSessionWebSocketTask을 사용해서 웹 소켓을 사용할 수 있습니다. 이 API에 대해서 Apple을 이용하면,

URLSessionWebSocketTask는 URLSessionTask의 하위클래스로 구성되며, WebSocket 프레임의 형태로 TCP와 TLS 형태로 메시지 지향의 전송 프로토콜을 제공합니다. RFC 6455에 정의된 WebSocket Protocol을 따릅니다.

웹 소켓의 종단점(Web Sockets Endpoint)

테스트 하기 위해서, 값을 등록하고 시간이 지남에 따라 클라이언트를 주기적으로 업데이트 하는 기능을 제공하는 것으로 알려진 WebSocket의 종단점(endpoint)을 사용할 것입니다. 이 API는 BUX에서 제공하지만 WebSocket을 지원을 제공하는 모든 API를 사용할 수 있습니다.

wss://rtf.beta.getbux.com/subscriptions/me

이는 무료로 사용가능한 API가 아닙니다. 이 종단점(endpoint)에 보내기 요청을 하기 위해선 인증 토큰이 필요합니다. 가능하다면, 로컬 WebSocket서버나 이미 사용가능한 WebSocket API를 사용하는것이 좋습니다.

웹 소켓 열기(Opening a WebSocket)

WebSocket에서 메시지를 보내거나 받는 것을 시작하기 위해서, 우선 웹 소켓 연결을 열어야 합니다. URLSessionWebSocketTask API를 다음과 같이 사용할 것입니다.

func openWebSocket() {
    let urlString = "wss://rtf.beta.getbux.com/subscriptions/me"
    if let url = URL(string: urlString) {
        var request = URLRequest(url: url)
        let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
        let webSocket = session.webSocketTask(with: request)
        webSocket?.resume()
    }
}

URLSession 객체를 생성하고 현재 클래스를 URLSessionWebSocketDelegate 프로토콜을 준수하는 URLSession의 델리게이터(delegate)로 설정합니다. 이 델리게이터를 설정하는 것은 선택사항 이지만 분석을 기록하거나 디버그 정보를 분석할 수 있도록 소켓 열기나 닫을때 콜백을 받는데 사용할 수 있습니다.

현재 클래스 SocketNetworkService에 해당 델리게이터를 구현합시다.

extension SocketNetworkService: URLSessionWebSocketDelegate {
    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
        print("Web socket opened")
        isOpened = true
    }

    
    func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
        print("Web socket closed")
        isOpened = false
    }
}

웹 소켓을 열고난 직후에 메시지 수신을 시작하지만, 이를 수신하기 위해서, URLSessionWebSocketTask 인스턴스에서 receive API를 사용해서 설정해야 합니다.

메시지 보내기(Sending a message)

URLSessionWebSocketTask 인스턴스에서 send API을 사용해서 소켓 종단점(endpoint)에 메시지를 보낼수 있습니다. URLSessionWebSocketTask.Message 타입의 객체를 허용하고 send 작업으로 인해 오류가 발생했는지 여부를 나타내는 옵셔널 Error 객체와 완료 핸들러를 제공합니다.

URLSessionWebSocketTask.Message는 String과 Data 형태의 enum이며, String 또는 Binary 데이터 포멧중 하나로 메시지를 보내기 위해 send API를 사용할 수 있습니다.

String

webSocket.send(URLSessionWebSocketTask.Message.string("Hello")) { [weak self] error in
    if let error = error {
        print("Failed with Error \(error.localizedDescription)")
    } else {
        // no-op
    }
}
webSocket.send(URLSessionWebSocketTask.Message.data("Hello".data(using: .utf8)!)) { [weak self] error in
    if let error = error {
        print("Failed with Error \(error.localizedDescription)")
    } else {
        self?.closeSocket()
    }
}

메시지 수신하기(Receiving messages)

WebSocke에서 메시지를 수신하기 위해서, receive API를 사용할 것입니다. 어떻게 설정하는지 봅시다.

public func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)

Receive 메소드는 실패 또는 성공을 나타낼수 있는 Result 객체를 통해 객체를 반환하는 completionHandler를 통해서 입력 값을 제공합니다.

var request = URLRequest(url: URL("wss://rtf.beta.getbux.com/subscriptions/me")!)
let webSocket = URLSession.shared.webSocketTask(with: request)
webSocket.resume()

webSocket.receive(completionHandler: { result in       
    switch result {
    case .failure(let error):
        print(error.localizedDescription)
    case .success(let message):
        switch message {
        case .string(let messageString):
            print(messageString)
        case .data(let data):
            print(data.description)
        default:
            print("Unknown type received from WebSocket")
        }
    }
})

소켓을 resume하고난 후에, receive 메소드로 콜백을 설정합니다. 결과는 성공와 실패 2가지 경우를 나타냅니다. 실패하는 경우에 메시지를 기록하고 계속진행합니다. 성공하는 경우에, 3가지 경우가 있습니다.

  1. String
  2. Binary object (Data)
  3. Default type

데이터를 반환하고자하는 포멧에 따라 이러한 결과를 추가적으로 처리할 수 있습니다.

수신 메소드에 대한 주의사항!!!(Caveat for Receive Method!!!)

Apple에서 제공되는 receive 메소드는 콜백에서 하나의 메세지를 수신하고나서 자동으로 추가적인 메시지 수신으로 부터 등록해제하는 것이 특이(quirky)합니다. 해결하려면, 메시지를 수신하고나서 receive 콜백을 항상 다시 등록해줘야 합니다. 이렇게 우회하는 것이 불필요하게 들리지만, 이것이 현재 이 API가 동작하는 방법입니다.

이를 위해 위의 코드를 함수로 감싸고, 메시지를 받고나서 다시 호출해 줍시다.

func receiveMessage() {

    if !isOpened {
        openWebSocket()
    }

    webSocket.receive(completionHandler: { [weak self] result in
        
        switch result {
        case .failure(let error):
            print(error.localizedDescription)
        case .success(let message):
            switch message {
            case .string(let messageString):
                print(messageString)
            case .data(let data):
                print(data.description)
            default:
                print("Unknown type received from WebSocket")
            }
        }
        self?.receiveMessage()
    })
}

func openWebSocket() {
    let urlString = "wss://rtf.beta.getbux.com/subscriptions/me"
    if let url = URL(string: urlString) {
        var request = URLRequest(url: url)
        let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
        let webSocket = session.webSocketTask(with: request)
        webSocket?.resume()
        isOpened = true
    }
}

메시지를 한번만 수신하려는 경우가 아니면 receive 메소드를 한 번만 호출하지 마세요. 다음번에 소켓으로 값이 필요할때 다시 설정해줘야 합니다. 몇가지 이유로 수신 메소드가 실패하는 경우에, 사용자 케이스를 멈추고 연결을 종료할 수 있습니다.

연결 유지하기(Kepping connection alive)

웹 소켓은 오랜 시간동안 유휴상태(idle)이면 연결을 종료합니다. 무기한으로 열어둬야 하는 경우에, 주기적으로 ping을 보내서 활성상태를 유지할 수 있습니다.

let timer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] timer in
    self?.webSocket?.sendPing(pongReceiveHandler: { error in
        if let error = error {
            print("Failed with Error \(error.localizedDescription)")
        } else {
            // no-op
        }
    })
}
timer.fire()

WebSocket 종료하기(closing a WebSocket)

웹 소켓을 오랫동안 열어두거나 필요하지 않을때 열어두는 경우에 필요한 리소스를 모두 사용하고 배터리 수명과 네트워크 데이터가 추가적으로 소모될 수 있습니다. 사용하지 않는 경우에, 특히 사용자가 웹 소켓 데이터가 필요한 화면에서 벗어나서 탐색할때 안전하게 종료할 수 있습니다.

소켓 연결을 종료하기 위해서 URLSessionWebSocketTask인스턴스에서 cancel API를 사용할 것입니다.

func closeSocket() {
    webSocket.cancel(with: .goingAway, reason: nil)
    webSocket = nil
    isOpened = false
}

마지막 생각들(Final Thoughts)

이 API에 대해서 어떻게 생각하나요? 흠. 우선 Apple은 몇년동안 다른 언어에서 제공되었을때, iOS에서 웹 소켓 API를 발표하는데 매우 늦었습니다. 지연됐음에도 불구하고, 원하는 만큼 인상적이지 않습니다.

둘째, 기존의 URLSession API를 확장해서 웹 소켓 지원을 확장하려 했는지 이해가 안됩니다. 이 API는 몇년간 HTTP 요청에 주로 사용되었고 개발자들에게 직관적입니다. 이러한 2가지를 혼합하는 경우에 특히 WebSocket이 일반 HTTP 요청과는 완전히 다르게 동작해서 매우 혼란스럽습니다. Apple이 서로의 발가락을 밟지 않고 두가지 표준을 병렬로 개발할 수 있었기에 Apple에게 유익했을것이라 생각합니다.

셋째, 소켓이 메시지를 받은 후에도 매법 receive 메소스를 호출하는 것은 불필요하고 쓸모가 없습니다. 아직 측정하지 않은 다른 성능은 말할 것도 없습니다. Starscrean과 SwiftWebSocket와 같은 다른 소켓 라이브러리는 이러한 콜백을 개발자가 한번만 설정하고 연결이 열려있는 한 계속해서 메시지를 수신할 수 있습니다.

이런 우려를 제쳐두고, iOS에서 이 API를 사용하는 것이 좋습니다. 희망하건데, Apple이 장기적으로 유지관리해서 여기에서의 불편함을 해소해줬으면 합니다. 다음 번에 웹 소켓을 구현해야 하는 경우에, 특별한 이유가 없는 한 타사 라이브러리에 의존성을 줄여야 하므로 Apple을 첫번째로 선택할 것입니다.

참조 :
WWDC2019 - Advances in Networking, Part 1
Swift에서 URLSessionWebSocketTask 사용하는 방법

반응형
Posted by 까칠코더

댓글을 달아 주세요