lgvv98

[TCA] Effect #6 (WebSocket) 본문

apple/TCA

[TCA] Effect #6 (WebSocket)

lgvv 2023. 10. 8. 15:01

[TCA] Effect #6 (WebSocket)

목차

- 이번에 할 실습에 대한 설명
   - 웹소켓 개념에 대해서 설명은 생략
   - 나의 이전 작업에서의 웹소켓과 연관지어 생각해보기

- TCA WebScoket 예제

# 이번에 할 실습에 대한 설명 

URLsession의 웹소켓용 API를 위한 간단한 Wrapper를 만들어 소켓 서버에 접속하여 테스트한 후 메시지를 전송 후 소켓 서버는 즉시 메시지를 클라이언트에게 회신.


 - 나의 이전 작업에서의 웹소켓과 연관지어 생각해보기

내가 개발을 처음 공부할 당시에는 웹소켓을 이렇게 많이 다루리라고 생각하지 못했는데, 메인으로 사용하고 있음.
 처음 이론이 아닌 웹소켓을 접하게 된 것은 Kuring프로젝트에서 검색파트를 구현하면서 처음 알게 되었는데, 당시에는 StarScream을 활용하여 개발했었음.
 그 이후에는 인턴을 하면서 RxStarScream가 RxSwift 4.x(당시 최신 6.5) 를 사용하고 있었기에 사내용으로 직접 변경하는 작업을 하면서 RxSwift Extension을 키워드를 중점으로 웹소켓을 살펴봄.
 그리고 지금의 회사에서는 채팅서버와 함께 작업을 하면서 소켓 통신을 이용하는데, 이번에는 사내 프로토콜을 따르고 있기에 웹소켓에 대해서 웹소켓의 프로토콜을 중점으로 살펴보고 있음. 웹 소켓에 대해서 따로 공부하다가 결국 Node.js를 학습을 시작하게 되었음.

 이번에는 TCA에서의 웹 소켓 사용법 예시인데, 이번에는 웹소켓을 디펜던시를 활용한 부분에 있어서 구조적으로 어떻게 설계하는게 더 나을지를 중점적으로 살펴봄.

 

그럼에도 불구하고 아직도 웹소켓 이론에 대해서는 잘 모르겠는 기분이 들어서 추후에 해당 부분은 더 딥하게 정리해보자!


 

# TCA WebScoket 예제

// MARK: - Feature domain

struct WebSocket: Reducer {
    struct State: Equatable {
        @PresentationState var alert: AlertState<Action.Alert>?
        var connectivityState = ConnectivityState.disconnected
        var messageToSend = ""
        var receivedMessages: [String] = []
        
        enum ConnectivityState: String {
            case connected
            case connecting
            case disconnected
        }
    }
    
    enum Action: Equatable {
        case alert(PresentationAction<Alert>)
        case connectButtonTapped
        case messageToSendChanged(String)
        case receivedSocketMessage(TaskResult<WebSocketClient.Message>)
        case sendButtonTapped
        case sendResponse(didSucceed: Bool)
        case webSocket(WebSocketClient.Action)
        
        enum Alert: Equatable {}
    }
    
    @Dependency(\.continuousClock) var clock
    @Dependency(\.webSocket) var webSocket // Ribs처럼 View가 없는 친구들은 `@Dependency`로 만들어서 처리하면 좋겠다!
    
    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .alert:
                return .none
                
            case .connectButtonTapped:
                // 커넥트 버튼 눌렀을 때
                switch state.connectivityState {
                case .connected, .connecting: // 연결중이라면
                    state.connectivityState = .disconnected // 상태 변경 -> 연결 종료
                    return .cancel(id: WebSocketClient.ID()) // Task cancel
                    
                case .disconnected: // 연결중이 아니라면
                    state.connectivityState = .connecting // 상태를 연결중으로 변경
                    
                    return .run { send in
                        let actions = await self.webSocket
                            .open(WebSocketClient.ID(), URL(string: "wss://echo.websocket.events")!, []) // 웹소켓 오픈
                        
                        await withThrowingTaskGroup(of: Void.self) { group in // Task를 group에 등록하여 처리하는 방법
                            for await action in actions {
                                //  주의: !'Effect.{task,run}'안에서 작업 로컬 종속성 디펜던시의 mutation으로 인해 'wait send'를 'group.addTask' 외부에서 호출할 수 없다.
                                //  'Effect.{task,run}'의 범주 내에서는 명시적인 작업 로컬 돌연변이(및 이 'addTask'?)를 제거할 수 있을까?
                                
                                group.addTask { await send(.webSocket(action)) } // 웹소켓 액션을 처리할 그룹
                                
                                switch action {
                                case .didOpen: // 열려 있다면
                                    
                                    group.addTask {
                                        // 웹소켓 핑퐁을 처리할 그룹
                                        while !Task.isCancelled { // 태스크가 캔슨되지 않은 동안에
                                            try await self.clock.sleep(for: .seconds(10))
                                            try? await self.webSocket.sendPing(WebSocketClient.ID())
                                        }
                                    }
                                    
                                    group.addTask {
                                        // 웹소켓 데이터 이벤트를 받는 부분을 처리할 그룹
                                        for await result in try await self.webSocket.receive(WebSocketClient.ID()) {
                                            await send(.receivedSocketMessage(result))
                                        }
                                    }
                                case .didClose:
                                    return
                                }
                            }
                        }
                    }
                    .cancellable(id: WebSocketClient.ID()) // 언제든 캔슬 가능하도록 등록
                }
                
            case let .messageToSendChanged(message):
                state.messageToSend = message
                return .none
                
            case let .receivedSocketMessage(.success(message)):
                if case let .string(string) = message {
                    state.receivedMessages.append(string)
                }
                return .none
                
            case .receivedSocketMessage(.failure):
                return .none
                
            case .sendButtonTapped:
                
//                // ✅ 정상작동 코드
                let messageToSend = state.messageToSend
                state.messageToSend = ""
                return .run { send in
                    try await self.webSocket.send(WebSocketClient.ID(), .string(messageToSend))
                    await send(.sendResponse(didSucceed: true))
                } catch: { _, send in
                    await send(.sendResponse(didSucceed: false))
                }
                .cancellable(id: WebSocketClient.ID())
                
//                // 🚨 아쉬운 동작성 코드
//                state.messageToSend = ""
//                return .run { [messageToSend = state.messageToSend] send in
//                    try await self.webSocket.send(WebSocketClient.ID(), .string(messageToSend))
//                    await send(.sendResponse(didSucceed: true)) // 응답을 받은 후 데이터필드를 비워야 하는 문제
//                } catch: { _, send in
//                    await send(.sendResponse(didSucceed: false))
//                }
//                .cancellable(id: WebSocketClient.ID())
                
            case .sendResponse(didSucceed: false):
                state.alert = AlertState {
                    TextState("Could not send socket message. Connect to the server first, and try again.")
                }
                return .none
                
            case .sendResponse(didSucceed: true):
                return .none
                
            case .webSocket(.didClose):
                state.connectivityState = .disconnected // 닫혀있을 경우 여기서 컨트롤!
                return .cancel(id: WebSocketClient.ID())
                
            case .webSocket(.didOpen):
                state.connectivityState = .connected
                state.receivedMessages.removeAll()
                return .none
            }
        }
        .ifLet(\.$alert, action: /Action.alert)
    }
}

// MARK: - Feature view

struct WebSocketView: View {
    let store: StoreOf<WebSocket>
    
    var body: some View {
        WithViewStore(self.store, observe: { $0 }) { viewStore in
            Form {
                Section {
                    AboutView(readMe: readMe)
                }
                
                Section {
                    VStack(alignment: .leading) {
                        Button(
                            // 삼항 연산자를 이런 방식으로도 처리할 수 있구나!
                            viewStore.connectivityState == .connected
                            ? "Disconnect"
                            : viewStore.connectivityState == .disconnected
                            ? "Connect"
                            : "Connecting..."
                        ) {
                            viewStore.send(.connectButtonTapped)
                        }
                        .buttonStyle(.bordered)
                        .tint(viewStore.connectivityState == .connected ? .red : .green)
                        
                        HStack {
                            TextField(
                                "Type message here",
                                text: viewStore.binding(
                                    get: \.messageToSend, send: WebSocket.Action.messageToSendChanged)
                            )
                            .textFieldStyle(.roundedBorder)
                            
                            Button("Send") {
                                viewStore.send(.sendButtonTapped)
                            }
                            .buttonStyle(.borderless)
                        }
                    }
                }
                
                Section {
                    Text("Status: \(viewStore.connectivityState.rawValue)")
                        .foregroundStyle(.secondary)
                    Text(viewStore.receivedMessages.reversed().joined(separator: "\n"))
                } header: {
                    Text("Received messages")
                }
            }
            .alert(store: self.store.scope(state: \.$alert, action: { .alert($0) }))
            .navigationTitle("Web Socket")
        }
    }
}

// MARK: - WebSocketClient

struct WebSocketClient {
    // NOTE: - Ribs처럼 View가 없어도 디펜던시 형태로 제공할건데 Action을 분리하는게 상당히 매력적이다!
    
    struct ID: Hashable, @unchecked Sendable {
        let rawValue: AnyHashable
        
        init<RawValue: Hashable & Sendable>(_ rawValue: RawValue) {
            self.rawValue = rawValue
        }
        
        init() {
            struct RawValue: Hashable, Sendable {}
            self.rawValue = RawValue()
        }
    }
    
    enum Action: Equatable {
        case didOpen(protocol: String?)
        case didClose(code: URLSessionWebSocketTask.CloseCode, reason: Data?)
    }
    
    enum Message: Equatable {
        struct Unknown: Error {}
        
        case data(Data)
        case string(String)
        
        init(_ message: URLSessionWebSocketTask.Message) throws {
            switch message {
            case let .data(data): self = .data(data)
            case let .string(string): self = .string(string)
            @unknown default: throw Unknown()
            }
        }
    }
    
    var open: @Sendable (ID, URL, [String]) async -> AsyncStream<Action>
    var receive: @Sendable (ID) async throws -> AsyncStream<TaskResult<Message>>
    var send: @Sendable (ID, URLSessionWebSocketTask.Message) async throws -> Void
    var sendPing: @Sendable (ID) async throws -> Void
}

extension WebSocketClient: DependencyKey {
    static var liveValue: Self {
        return Self(
            open: { await WebSocketActor.shared.open(id: $0, url: $1, protocols: $2) },
            receive: { try await WebSocketActor.shared.receive(id: $0) },
            send: { try await WebSocketActor.shared.send(id: $0, message: $1) },
            sendPing: { try await WebSocketActor.shared.sendPing(id: $0) }
        )
        
        final actor WebSocketActor: GlobalActor { // Actor를 GlobalActor로 지정
            final class Delegate: NSObject, URLSessionWebSocketDelegate {
                var continuation: AsyncStream<Action>.Continuation?
                
                func urlSession(
                    _: URLSession,
                    webSocketTask _: URLSessionWebSocketTask,
                    didOpenWithProtocol protocol: String?
                ) {
                    self.continuation?.yield(.didOpen(protocol: `protocol`))
                }
                
                func urlSession(
                    _: URLSession,
                    webSocketTask _: URLSessionWebSocketTask,
                    didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
                    reason: Data?
                ) {
                    self.continuation?.yield(.didClose(code: closeCode, reason: reason))
                    self.continuation?.finish()
                }
            }
            
            typealias Dependencies = (socket: URLSessionWebSocketTask, delegate: Delegate)
            
            static let shared = WebSocketActor()
            
            var dependencies: [ID: Dependencies] = [:]
            
            func open(id: ID, url: URL, protocols: [String]) -> AsyncStream<Action> {
                let delegate = Delegate()
                let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
                let socket = session.webSocketTask(with: url, protocols: protocols)
                defer { socket.resume() }
                var continuation: AsyncStream<Action>.Continuation!
                let stream = AsyncStream<Action> {
                    $0.onTermination = { _ in
                        socket.cancel()
                        Task { await self.removeDependencies(id: id) }
                    }
                    continuation = $0
                }
                delegate.continuation = continuation
                self.dependencies[id] = (socket, delegate)
                return stream
            }
            
            func close(
                id: ID, with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?
            ) async throws {
                defer { self.dependencies[id] = nil }
                try self.socket(id: id).cancel(with: closeCode, reason: reason)
            }
            
            func receive(id: ID) throws -> AsyncStream<TaskResult<Message>> {
                let socket = try self.socket(id: id)
                return AsyncStream { continuation in
                    let task = Task {
                        while !Task.isCancelled {
                            continuation.yield(await TaskResult { try await Message(socket.receive()) })
                        }
                        continuation.finish()
                    }
                    continuation.onTermination = { _ in task.cancel() }
                }
            }
            
            func send(id: ID, message: URLSessionWebSocketTask.Message) async throws {
                try await self.socket(id: id).send(message)
            }
            
            func sendPing(id: ID) async throws {
                let socket = try self.socket(id: id)
                return try await withCheckedThrowingContinuation { continuation in
                    socket.sendPing { error in
                        if let error = error {
                            continuation.resume(throwing: error)
                        } else {
                            continuation.resume()
                        }
                    }
                }
            }
            
            private func socket(id: ID) throws -> URLSessionWebSocketTask {
                guard let dependencies = self.dependencies[id]?.socket else {
                    struct Closed: Error {}
                    throw Closed()
                }
                return dependencies
            }
            
            private func removeDependencies(id: ID) {
                self.dependencies[id] = nil
            }
        }
    }
    
    static let testValue = Self(
        open: unimplemented("\(Self.self).open", placeholder: AsyncStream.never),
        receive: unimplemented("\(Self.self).receive"),
        send: unimplemented("\(Self.self).send"),
        sendPing: unimplemented("\(Self.self).sendPing")
    )
}

extension DependencyValues {
    var webSocket: WebSocketClient {
        get { self[WebSocketClient.self] }
        set { self[WebSocketClient.self] = newValue }
    }
}

// MARK: - SwiftUI previews

struct WebSocketView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            WebSocketView(
                store: Store(initialState: WebSocket.State(receivedMessages: ["Hi"])) {
                    WebSocket()
                }
            )
        }
    }
}

 

이번 예제는 많은 생각거리들을 주었음.

1. DispatchGroup이 아닌 TaskGroup에 대한 아이디어

2. RIBs처럼 View가 존재하지 않는 상태에서는 TCA에서는 Dependency로 해결하는것

3. TCA에서 Effect.run에서 내부로 변수를 끌고 들어갈 경우, 변경할 수 없어서 외부 변수를 직접 선언해서 들어야 한다는 것
4. GlobalActor로 지정하여 사용.
5. 웹소켓에 대한 기본적인 이론 및 이해

등을 간단히나마 학습할 수 있었음.

이 모든 것들을 deep dive하게끔 정리하고 싶으나, 이번 학습 계획에서 더 집중하는건 TCA를 자체이므로 별도의 포스팅을 하면서 해당 부분을 복기하도록 하자.

 

 

'apple > TCA' 카테고리의 다른 글

[TCA] HigherOrderReducers #1 (Recursion)  (0) 2023.10.11
[TCA] Navigation (화면전환 총 정리)  (0) 2023.10.09
[TCA] Effect #5 (Timers)  (0) 2023.10.07
[TCA] Effect #4 (Refreshable)  (0) 2023.10.07
[TCA] Effect #3 (LongLiving)  (0) 2023.10.07
Comments