[새싹 iOS] 22주차_WebSocket

임승섭·2023년 12월 16일
1

새싹 iOS

목록 보기
38/45

WebSocket

  • 클라이언트와 서버 간 연결을 종료하기 전까지 연결된 통신 계속 유지
  • 연결 시작 단계, 연결 유지 단계, 연결 종료 단계로 상태 구분
  • 양방향 통신(full-duplex), 실시간(Real-Time) 네트워킹
    • HTTP와 달리 요청이 없더라도 서버 -> 클라이언트 데이터 송신 가능
    • 원하는 시점에 서로 데이터 주고받기 가능

WebSocket 구현

Preview

  • WebSocketManager 싱글톤 패턴
  • URLSessionWebSocketTask, URLSessionWebSocketDelegate
  • UPBit WebSocket API 활용
  • SwiftUI, Combine

Model

// 응답 데이터 모델
struct OrderBookWS: Decodable {
  let timestamp: Int
  let totalAskSize, totalBidSize: Double
  let orderbookUnits: [OrderbookUnit]

  enum CodingKeys: String, CodingKey { /*...*/ }
}

struct OrderbookUnit: Codable {
  let askPrice, bidPrice, askSize, bidSize: Double

  enum CodingKeys: String, CodingKey { /*...*/ }
}


// 뷰 데이터 모델
struct OrderBookItem: Hashable, Identifiable {
  let id = UUID()
  let price: Double
  let size: Double
}

struct Market: Codable, Hashable {
  let id = UUID()
  let market: String
  let koreanName: String
  let englishName: String
    
  enum CodingKeys: String, CodingKey { /*...*/ }
}

class WebSocketManager: NSObject

싱글톤 + 필요한 프로퍼티 선언

static let shared = WebSocketManager()

private override init() {
    super.init()
}

private var timer: Timer?    // 5초에 한 번씩 Ping 보내기 위한 타이머
private var webSocket: URLSessionWebSocketTask?
private var isOpen = false   // 소켓 연결 상태
var orderBookSbj = PassthroughSubject<OrderBookWS, Never>()
// RxSwift PublishSubject   -> Combine PassthroughSubject
// Rx는 데이터 타입만 설정       -> Combine은 에러 타입도 설정

1. open

  • 소켓 연결 -> delegate의 didOpen으로 연결 확인
  • URLSession - default - webSocketTask 활용
  • iOS 13부터 webSocketTaks 등장
    func openWebSocket() {
        if let url = URL(string: "wss://api.upbit.com/websocket/v1") {
            let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
            // -> URLSessionWebSocketDelegate 프로토콜 채택
            webSocket = session.webSocketTask(with: url)
            webSocket?.resume()
            ping()
        }
    }

2. close

  • 소켓 해제 -> delegate의 didClose로 해제 확인

  • 다양한 CloseCode가 존재하는데 주로 .goingAwaymessageTooBig 사용

  • VM, VC와 따로 동작하는 timer 관리 중요 (Keyword : run loop)

    func closeWebSocket() {
        webSocket?.cancel(with: .goingAway, reason: nil)
        webSocket = nil
    
        timer?.invalidate()
        timer = nil
    
        isOpen = false  
    }

3. send

  • 소켓 통신을 통해 받고 싶은 데이터를 요청 포맷에 맞춰서 요청

    func send(_ codes: String) {
        let requestStr = """
        [{"ticket":"test"},{"type":"orderbook","codes":["\(codes)"]}]
        """
    
        webSocket?.send(.string(requestStr), completionHandler: { error  in
            if let error { print("send Error : \(error.localizedDescription)") }
        })
    }

4. receive

  • 필요한 순간에 서버에서 데이터를 받는다

    func receive() {
        if isOpen { // 소켓이 열렸을 때만 데이터 수신이 가능하도록 한다
            webSocket?.receive(completionHandler: { [weak self] result  in
                switch result {
                case .success(let success):
                    print("receive Success : \(success)")
    
                    switch success {
                    case .data(let data):
                        print("success - data : \(data)")
    
                        do {
                            let decodedData = try JSONDecoder().decode(OrderBookWS.self, from: data)
                            // RxSwift .onNext -> Combine .send
                            self?.orderBookSbj.send(decodedData)
                        } catch {
                            print("decodingError : \(error.localizedDescription)")
                        }
    
                    case .string(let string):
                        print("success - string : \(string)")
    
                    @unknown default:
                        fatalError()
                    }
    
                case .failure(let failure):
                    print("receive Fail : \(failure.localizedDescription)")
                    self?.closeWebSocket()  // 소켓 데이터가 제대로 오지 않기 때문에, 닫아준다
                }
    
                // recursive
                self?.receive()
            })
    
        }
    }

5. ping

  • 서버에 의해 연결이 끊어지지 않도록 주기적으로 ping을 보낸다
    (120초 Idle Timeout)

  • 선언해둔 timer 활용

    private func ping() {
        self.timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true, block: { [weak self] _ in
    
            self?.webSocket?.sendPing(pongReceiveHandler: { error  in
                if let error {
                    print("Ping Error : \(error.localizedDescription)")
                } else {
                    print("Ping Success") 
                }
            })
        })
    }

extension WebSocketManager: URLSessionWebSocketDelegate

  • URLSessionWebSocketDelegate를 타고타고 올라가보면,
    최종적으로 NSObjectProtocol 을 채택하고 있다.
  • 즉, 원래대로라면 WebSocketManager 에서 NSObjectProtocol의 필수 프로퍼티와 메서드를 구현해야 한다.
  • 이러한 번거로움을 줄이기 위해 이미 해당 내용을 구현하고 있는 NSObjectWebSocketManger가 상속받도록 한다
  • 소켓이 연결되었을 때와 해제되었을 때 실행되는 메서드

    extension WebSocketManager: URLSessionWebSocketDelegate {
    
        func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) {
            print(#function)
            print("WebSocket OPEN")
    
            isOpen = true
    
            receive()	// 데이터 수신 시작
        }
    
        func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
            print(#function)
            print("WebSocket CLOSE")
    
            isOpen = false
        }
    }

class SocketTestViewModel: ObservableObject

필요한 프로퍼티 선언

var marketData: Market	// 어떤 코인인지

@Published var askOrderBook: [OrderBookItem] = []	// 매도 정보
@Published var bidOrderBook: [OrderBookItem] = []	// 매수 정보

// RxSwift disposeBag -> Combine cancellable
private var cancellable = Set<AnyCancellable>()

init

  • 소켓 연결. 즉, VM 인스턴스를 만들면 바로 소켓 통신이 시작된다

    init(market: Market) {
        self.marketData = market
    
        WebSocketManager.shared.openWebSocket()
    
        WebSocketManager.shared.send(marketData.market)
    
        // Rx subscribe        -> Combine sink
        // Rx Scheduler(.main) -> Combine receive
        // Rx Dispose          -> Combine AnyCancellable
        WebSocketManager.shared.orderBookSbj
            .receive(on: DispatchQueue.main)
            .sink { [weak self] order in
                guard let self else { return }
    
                self.askOrderBook = order.orderbookUnits
                    .map { .init(price: $0.askPrice, size: $0.askSize)}
                    .sorted { $0.price > $1.price }
    
                self.bidOrderBook = order.orderbookUnits
                    .map { .init(price: $0.bidPrice, size: $0.bidSize)}
                    .sorted { $0.price > $1.price }
            }
            .store(in: &cancellable)
    }

deinit

  • VM 인스턴스가 해제되면 소켓 연결 중단
  • 만약 메모리 누수가 발생해서 deinit 함수가 실행되지 않으면 소켓은 영원히 해제되지 않는다. 주의하기
    deinit {
        WebSocketManager.shared.closeWebSocket()
    }	

WebSocket 결과

1개의 댓글

comment-user-thumbnail
2023년 12월 30일

잘 보고 갑니당 :)

답글 달기