From 8472cec9cf4794228b5d9945b3dc89ce65be7be6 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Mon, 3 Nov 2025 15:43:49 +0900 Subject: [PATCH 01/14] =?UTF-8?q?test:=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Remote/WebSocket/WebSocketClient.swift | 21 +++- .../iCo/Core/Util/Async+BroadCaster.swift | 2 + .../Data/API/Upbit/UpbitTickerService.swift | 4 +- .../Socket/WebSocket+ConnectTest.swift | 119 ++++++++++++++++++ 4 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 AIProject/iCoTests/Socket/WebSocket+ConnectTest.swift diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index 3721d207..5031ac4c 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -1,7 +1,26 @@ import Foundation import AsyncAlgorithms -public final class WebSocketClient: NSObject { +public protocol WebSocketProvider { + var stateBroadCaster: AsyncStreamBroadcaster { get } + var incomingChannel: AsyncChannel { get } + + /// 웹소켓 세션을 연결하고 작업을 생성합니다. + func connect() async + + /// 명시적으로 현재 WebSocket 연결을 정상적으로 종료합니다. + /// + /// 이 메서드는 서버와의 WebSocket 연결을 `normalClosure` 코드로 닫습니다. + func disconnect() async + + /// 텍스트 형태의 메시지를 WebSocket 서버로 전송합니다. + func send(text: String) async throws + + /// 바이너리(Data) 형태의 메시지를 WebSocket 서버로 전송합니다. + func send(data: Data) async throws +} + +public final class WebSocketClient: NSObject, WebSocketProvider { /// 소켓 상태 채널 private var stateStream: AsyncStream /// WebSocket의 상태 변화를 여러 Consumer에게 동시에 전달하는 브로드캐스터 diff --git a/AIProject/iCo/Core/Util/Async+BroadCaster.swift b/AIProject/iCo/Core/Util/Async+BroadCaster.swift index 7aa22a09..0e991766 100644 --- a/AIProject/iCo/Core/Util/Async+BroadCaster.swift +++ b/AIProject/iCo/Core/Util/Async+BroadCaster.swift @@ -20,6 +20,8 @@ public class AsyncStreamBroadcaster { /// 구독할 continuation 값들 private var continuations: [UUID: AsyncStream.Continuation] = [:] + public init() {} + /// 구독 메서드로 stream을 반환 /// - Returns: stream 반환 public func stream() -> AsyncStream { diff --git a/AIProject/iCo/Data/API/Upbit/UpbitTickerService.swift b/AIProject/iCo/Data/API/Upbit/UpbitTickerService.swift index 2bcc2abe..7d5f16af 100644 --- a/AIProject/iCo/Data/API/Upbit/UpbitTickerService.swift +++ b/AIProject/iCo/Data/API/Upbit/UpbitTickerService.swift @@ -9,12 +9,12 @@ import Foundation /// 업비트 실시간 코인 시세 웹소켓 서비스 final class UpbitTickerService: RealTimeTickerProvider { - private let client: WebSocketClient + private let client: WebSocketProvider /// 소켓 상태 stream private var stateStreamTask: Task? - init(client: WebSocketClient = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!)) { + init(client: WebSocketProvider = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!)) { self.client = client } diff --git a/AIProject/iCoTests/Socket/WebSocket+ConnectTest.swift b/AIProject/iCoTests/Socket/WebSocket+ConnectTest.swift new file mode 100644 index 00000000..d3ed03e4 --- /dev/null +++ b/AIProject/iCoTests/Socket/WebSocket+ConnectTest.swift @@ -0,0 +1,119 @@ +// +// WebSocket+ConnectTest.swift +// iCoTests +// +// Created by 강대훈 on 11/3/25. +// + +import XCTest +import AsyncAlgorithms +@testable import iCo + +public final class MockWebSocketClient: WebSocketProvider { + private var stateStream: AsyncStream + private(set) var connectCallCount: Int = 0 + private(set) var reconnectCallCount: Int = 0 + + public var stateBroadCaster: AsyncStreamBroadcaster + public var incomingChannel: AsyncChannel + + private var stateTask: Task? + + init() { + stateBroadCaster = .init() + stateStream = stateBroadCaster.stream() + incomingChannel = AsyncChannel() + + observeState() + } + + public func connect() async { + await stateBroadCaster.send(.connecting) + try? await Task.sleep(for: .milliseconds(100)) + await stateBroadCaster.send(.connected) + connectCallCount += 1 + } + + public func disconnect() async { + await stateBroadCaster.send(.closed) + } + + public func disconnectWithError() async { + // disconnectWithError -> 0.1초 -> reconnect send -> reconnect 함수 호출 -> ~~초 기다렸다가 -> connect + try? await Task.sleep(for: .milliseconds(100)) + await stateBroadCaster.send(.reconnecting(nextAttempsIn: .milliseconds(200))) + } + + public func send(text: String) async throws { + + } + + public func send(data: Data) async throws { + + } + + private func observeState() { + stateTask = Task { + for await state in stateStream { + switch state { + case .connecting: + continue + case .connected: + receive() + checkingAlive() + case .failed, .closed: + release() + case .reconnecting: + await reconnect() + } + } + } + } + + private func receive() { + + } + + private func checkingAlive() { + + } + + private func release() { + + } + + private func reconnect() async { + reconnectCallCount += 1 + try? await Task.sleep(for: .milliseconds(100)) + await connect() + } +} + +final class WebSocket_ConnectTest: XCTestCase { + var sut: RealTimeTickerProvider! + var socket: MockWebSocketClient! + + override func setUpWithError() throws { + socket = MockWebSocketClient() + sut = UpbitTickerService(client: socket!) + } + + override func tearDownWithError() throws { + socket = nil + sut = nil + } + + func test_커넥트_연결됐을때() async { + await sut.connect() + XCTAssertEqual(1, socket.connectCallCount) + } + + func test_연결끊김시에_자동으로재연결되는지() async { + await sut.connect() + XCTAssertEqual(1, socket.connectCallCount) + await socket.disconnectWithError() + try? await Task.sleep(for: .seconds(0.5)) + XCTAssertEqual(2, socket.connectCallCount) + XCTAssertEqual(1, socket.reconnectCallCount) + } +} From 8ea04d3c1c1a906013cce7f267c569335997529b Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 12 Nov 2025 21:21:13 +0900 Subject: [PATCH 02/14] =?UTF-8?q?refactor:=20=EC=B6=94=EC=83=81=ED=99=94?= =?UTF-8?q?=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resource/MockAsyncStreamBroadCaster.swift | 17 +++ .../Socket/Resource/MockURLSession.swift | 8 ++ .../Socket/Resource/MockWebSocketClient.swift | 3 + .../Socket/Resource/MockWebSocketTask.swift | 60 +++++++++ .../Socket/Tests/WebSocketTests.swift | 34 +++++ .../Socket/WebSocket+ConnectTest.swift | 119 ------------------ AIProject/iCoTests/Socket/WebSocketTest.swift | 71 ----------- .../iCoTests/Socket/WebSocketTests.swift | 35 ++++++ 8 files changed, 157 insertions(+), 190 deletions(-) create mode 100644 AIProject/iCoTests/Socket/Resource/MockAsyncStreamBroadCaster.swift create mode 100644 AIProject/iCoTests/Socket/Resource/MockURLSession.swift create mode 100644 AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift create mode 100644 AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift create mode 100644 AIProject/iCoTests/Socket/Tests/WebSocketTests.swift delete mode 100644 AIProject/iCoTests/Socket/WebSocket+ConnectTest.swift delete mode 100644 AIProject/iCoTests/Socket/WebSocketTest.swift create mode 100644 AIProject/iCoTests/Socket/WebSocketTests.swift diff --git a/AIProject/iCoTests/Socket/Resource/MockAsyncStreamBroadCaster.swift b/AIProject/iCoTests/Socket/Resource/MockAsyncStreamBroadCaster.swift new file mode 100644 index 00000000..259d42f9 --- /dev/null +++ b/AIProject/iCoTests/Socket/Resource/MockAsyncStreamBroadCaster.swift @@ -0,0 +1,17 @@ +// +// StateBroadCasterSpy.swift +// iCo +// +// Created by 강대훈 on 11/12/25. +// + +@testable import iCo + +final class MockAsyncStreamBroadCaster: AsyncStreamBroadcaster { + var log: [Element] = [] + + override func send(_ element: Element) async { + log.append(element) + await super.send(element) + } +} diff --git a/AIProject/iCoTests/Socket/Resource/MockURLSession.swift b/AIProject/iCoTests/Socket/Resource/MockURLSession.swift new file mode 100644 index 00000000..02d019a3 --- /dev/null +++ b/AIProject/iCoTests/Socket/Resource/MockURLSession.swift @@ -0,0 +1,8 @@ +// +// MockURLSession.swift +// iCoTests +// +// Created by 강대훈 on 11/12/25. +// + +import Foundation diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift new file mode 100644 index 00000000..14f8e449 --- /dev/null +++ b/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift @@ -0,0 +1,3 @@ +final class MockWebSocketClient: WebSocketClient { + +} \ No newline at end of file diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift new file mode 100644 index 00000000..e0198ec4 --- /dev/null +++ b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift @@ -0,0 +1,60 @@ +final class MockWebSocketTask: WebSocketType { + var delegate: URLSessionTaskDelegate? + private var taskState: URLSessionTask.State = .completed + + var state: URLSessionTask.State { + taskState + } + + private var throwError: Bool + + var resumeCallCount: Int = 0 + var cancelCallCount: Int = 0 + var sendCallCount: Int = 0 + var sendPingCallCount: Int = 0 + var receiveCallCount: Int = 0 + + init(throwError: Bool = false) { + self.throwError = throwError + } + + func resume() { + taskState = .suspended + resumeCallCount += 1 + taskState = .running + // Delegate 호출이 있어야 함. + } + + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { + cancelCallCount += 1 + // Delegate 호출이 있어야 함. + } + + func send(_ message: URLSessionWebSocketTask.Message) async throws { + if throwError { + throw NSError( + domain: NSURLErrorDomain, + code: -1009, + userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."] + ) + } + + sendCallCount += 1 + } + + func cancel() { + cancelCallCount += 1 + // Delegate 호출이 있어야 함. + } + + func sendPing(pongReceiveHandler: @escaping ((any Error)?) -> Void) { + sendPingCallCount += 1 + // 아직 모르겠음. + } + + func receive() async throws -> URLSessionWebSocketTask.Message { + // 어떤 경우에 에러를 던지는지 생각해봐야 함. + receiveCallCount += 1 + return .string("데이터 잘 받음.") + } +} \ No newline at end of file diff --git a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift new file mode 100644 index 00000000..ce73acc7 --- /dev/null +++ b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift @@ -0,0 +1,34 @@ +// +// WebSocketTests.swift +// iCoTests +// +// Created by 강대훈 on 11/12/25. +// + +import XCTest + +final class WebSocketTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } +} diff --git a/AIProject/iCoTests/Socket/WebSocket+ConnectTest.swift b/AIProject/iCoTests/Socket/WebSocket+ConnectTest.swift deleted file mode 100644 index d3ed03e4..00000000 --- a/AIProject/iCoTests/Socket/WebSocket+ConnectTest.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// WebSocket+ConnectTest.swift -// iCoTests -// -// Created by 강대훈 on 11/3/25. -// - -import XCTest -import AsyncAlgorithms -@testable import iCo - -public final class MockWebSocketClient: WebSocketProvider { - private var stateStream: AsyncStream - private(set) var connectCallCount: Int = 0 - private(set) var reconnectCallCount: Int = 0 - - public var stateBroadCaster: AsyncStreamBroadcaster - public var incomingChannel: AsyncChannel - - private var stateTask: Task? - - init() { - stateBroadCaster = .init() - stateStream = stateBroadCaster.stream() - incomingChannel = AsyncChannel() - - observeState() - } - - public func connect() async { - await stateBroadCaster.send(.connecting) - try? await Task.sleep(for: .milliseconds(100)) - await stateBroadCaster.send(.connected) - connectCallCount += 1 - } - - public func disconnect() async { - await stateBroadCaster.send(.closed) - } - - public func disconnectWithError() async { - // disconnectWithError -> 0.1초 -> reconnect send -> reconnect 함수 호출 -> ~~초 기다렸다가 -> connect - try? await Task.sleep(for: .milliseconds(100)) - await stateBroadCaster.send(.reconnecting(nextAttempsIn: .milliseconds(200))) - } - - public func send(text: String) async throws { - - } - - public func send(data: Data) async throws { - - } - - private func observeState() { - stateTask = Task { - for await state in stateStream { - switch state { - case .connecting: - continue - case .connected: - receive() - checkingAlive() - case .failed, .closed: - release() - case .reconnecting: - await reconnect() - } - } - } - } - - private func receive() { - - } - - private func checkingAlive() { - - } - - private func release() { - - } - - private func reconnect() async { - reconnectCallCount += 1 - try? await Task.sleep(for: .milliseconds(100)) - await connect() - } -} - -final class WebSocket_ConnectTest: XCTestCase { - var sut: RealTimeTickerProvider! - var socket: MockWebSocketClient! - - override func setUpWithError() throws { - socket = MockWebSocketClient() - sut = UpbitTickerService(client: socket!) - } - - override func tearDownWithError() throws { - socket = nil - sut = nil - } - - func test_커넥트_연결됐을때() async { - await sut.connect() - XCTAssertEqual(1, socket.connectCallCount) - } - - func test_연결끊김시에_자동으로재연결되는지() async { - await sut.connect() - XCTAssertEqual(1, socket.connectCallCount) - await socket.disconnectWithError() - try? await Task.sleep(for: .seconds(0.5)) - XCTAssertEqual(2, socket.connectCallCount) - XCTAssertEqual(1, socket.reconnectCallCount) - } -} diff --git a/AIProject/iCoTests/Socket/WebSocketTest.swift b/AIProject/iCoTests/Socket/WebSocketTest.swift deleted file mode 100644 index 005f60af..00000000 --- a/AIProject/iCoTests/Socket/WebSocketTest.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// WebSocketTest.swift -// iCoTests -// -// Created by kangho on 10/25/25. -// - -import XCTest -import iCo - -final class WebSocketTest: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() async throws { - let sut = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!) - await sut.connect() - try await sut.send(text: "[{ticket:test},{type:ticker,codes:[KRW-BTC]}]") - } - - func testReconnenctWhenAbnormalClose() async throws { - let sut = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!) - - await sut.connect() - - try await Task.sleep(for: .seconds(2)) - sut.cancel(with: .abnormalClosure) - try await Task.sleep(for: .seconds(2)) - // connecting -> connected -> abnormal close -> handleDisconnect -> reconnect - // .... -> abnormal close -> didCompletWithError -> reconnect - } - - func testReceiveData() async throws { - let sut = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!) - let requestFormat = "[{ticket:test},{type:ticker,codes:[KRW-BTC]}]" - - await sut.connect() - try await sut.send(text: requestFormat) - try await Task.sleep(for: .seconds(3)) - - sut.cancel() - - try await Task.sleep(for: .seconds(3)) - try await sut.send(text: requestFormat) - try await Task.sleep(for: .seconds(10)) - } - - func testUserClose() async throws { - let sut = WebSocketClient(url: URL(string: "wss://api.upbit.com/websocket/v1")!) - await sut.connect() - try await Task.sleep(for: .seconds(2)) - await sut.disconnect() - try await Task.sleep(for: .seconds(2)) - await sut.connect() - try await Task.sleep(for: .seconds(10)) - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } -} - diff --git a/AIProject/iCoTests/Socket/WebSocketTests.swift b/AIProject/iCoTests/Socket/WebSocketTests.swift new file mode 100644 index 00000000..23bfd75e --- /dev/null +++ b/AIProject/iCoTests/Socket/WebSocketTests.swift @@ -0,0 +1,35 @@ +// +// WebSocketTests.swift +// iCoTests +// +// Created by 강대훈 on 11/12/25. +// + +import XCTest + +final class WebSocketTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + +} From 77edd44584a4a3bf3d1684fd17e53b85f552af7b Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 12 Nov 2025 21:21:41 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20WebSocketClient=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=EC=9D=B4=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Remote/WebSocket/WebSocketClient.swift | 86 +++++++++---------- 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index 5031ac4c..b27974cc 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -20,29 +20,58 @@ public protocol WebSocketProvider { func send(data: Data) async throws } -public final class WebSocketClient: NSObject, WebSocketProvider { +public protocol URLSessionType { + func makeWebSocketTask(with url: URL) -> WebSocketType +} + +public protocol WebSocketType { + var delegate: (any URLSessionTaskDelegate)? { get set } + var state: URLSessionTask.State { get } + + func resume() + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) + func send(_ message: URLSessionWebSocketTask.Message) async throws + func cancel() + func sendPing(pongReceiveHandler: @escaping ((any Error)?) -> Void) + func receive() async throws -> URLSessionWebSocketTask.Message +} + +extension URLSession: URLSessionType { + public func makeWebSocketTask(with url: URL) -> any WebSocketType { + return webSocketTask(with: url) + } +} + +extension URLSessionWebSocketTask: WebSocketType {} + +public class WebSocketClient: NSObject, WebSocketProvider { /// 소켓 상태 채널 private var stateStream: AsyncStream /// WebSocket의 상태 변화를 여러 Consumer에게 동시에 전달하는 브로드캐스터 - public var stateBroadCaster: AsyncStreamBroadcaster = .init() + public var stateBroadCaster: AsyncStreamBroadcaster /// 메세지 채널 public var incomingChannel: AsyncChannel - private let url: URL - private let session: URLSession - private var task: URLSessionWebSocketTask? + private(set) var url: URL + private(set) var session: URLSessionType + private(set) var task: WebSocketType? - private var stateTask: Task? - private var receiveTask: Task? + private(set) var stateTask: Task? + private(set) var receiveTask: Task? /// 핑 전송 task - private var healthCheck: Task? + private(set) var healthCheck: Task? private var pingInterval: Duration = .seconds(30) private var pingTimeout: Duration = .seconds(10) - public init(url: URL, session: URLSession = .shared) { + public init( + url: URL, + session: URLSessionType = URLSession.shared, + stateBroadCaster: AsyncStreamBroadcaster = .init() + ) { self.url = url self.session = session + self.stateBroadCaster = stateBroadCaster stateStream = stateBroadCaster.stream() incomingChannel = AsyncChannel() @@ -54,12 +83,9 @@ public final class WebSocketClient: NSObject, WebSocketProvider { /// 웹소켓 세션을 연결하고 작업을 생성합니다. public func connect() async { await stateBroadCaster.send(.connecting) - self.task = session.webSocketTask(with: url) + self.task = session.makeWebSocketTask(with: url) task?.delegate = self task?.resume() - - // 핑 응답은 연결 후에 오기 때문에 connected 시점을 캐치할 수 있음 - try? await performWithTimeout(sendPing, at: pingTimeout) } /// 명시적으로 현재 WebSocket 연결을 정상적으로 종료합니다. @@ -88,24 +114,6 @@ public final class WebSocketClient: NSObject, WebSocketProvider { } } -// MARK: - Test용 메소드 -// TODO: Deprecated 예정입니다. -extension WebSocketClient { - public func sendState(with state: WebSocket.State) async { - await stateBroadCaster.send(state) - } - - public func cancel(with code: URLSessionWebSocketTask.CloseCode) { - task?.cancel(with: code, reason: nil) - task = nil - } - - public func cancel() { - task?.cancel() - task = nil - } -} - // MARK: - Private extension WebSocketClient { /// 서버로 Ping 프레임을 전송하여 연결 상태를 확인합니다. @@ -148,19 +156,13 @@ extension WebSocketClient { } } - // FIXME: 개선이 필요한지 한 번 더 생각해보기 /// 서버로부터 WebSocket 메시지를 지속적으로 수신합니다. private func receive() { - receiveTask?.cancel() - receiveTask = Task { - do { - guard let task else { return } + while true { + guard let task else { throw NetworkError.taskCancelled } let message = try await task.receive() await incomingChannel.send(message) - receive() - } catch { - print("종료되어 더 이상 웹소켓 데이터를 받지 않습니다.") } } } @@ -204,15 +206,11 @@ extension WebSocketClient { /// WebSocket 클라이언트의 모든 비동기 작업과 연결을 종료하고 리소스를 정리합니다. private func release() { + receiveTask?.cancel() receiveTask = nil healthCheck?.cancel() healthCheck = nil - - if task?.state == .running { - task?.cancel(with: .goingAway, reason: nil) - } - task = nil } } From da8c68ef8e2e62856add2bdeb3245717e8f62ff5 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 12 Nov 2025 21:22:30 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=EB=A6=AC=EC=8A=A4=20Mock=EA=B0=9D=EC=B2=B4=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Socket/Resource/MockURLSession.swift | 13 ++ .../Socket/Resource/MockWebSocketClient.swift | 17 ++- .../Socket/Resource/MockWebSocketTask.swift | 61 +++++++-- .../Socket/Tests/WebSocketTests.swift | 119 +++++++++++++++--- .../iCoTests/Socket/WebSocketTests.swift | 35 ------ 5 files changed, 177 insertions(+), 68 deletions(-) delete mode 100644 AIProject/iCoTests/Socket/WebSocketTests.swift diff --git a/AIProject/iCoTests/Socket/Resource/MockURLSession.swift b/AIProject/iCoTests/Socket/Resource/MockURLSession.swift index 02d019a3..41920c41 100644 --- a/AIProject/iCoTests/Socket/Resource/MockURLSession.swift +++ b/AIProject/iCoTests/Socket/Resource/MockURLSession.swift @@ -6,3 +6,16 @@ // import Foundation +@testable import iCo + +final class MockURLSession: URLSessionType { + let task: MockWebSocketTask + + init(task: MockWebSocketTask) { + self.task = task + } + + func makeWebSocketTask(with url: URL) -> WebSocketType { + return task + } +} diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift index 14f8e449..543c0db2 100644 --- a/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift +++ b/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift @@ -1,3 +1,18 @@ +// +// MockWebSocketClient.swift +// iCo +// +// Created by 강대훈 on 11/12/25. +// + +@testable import iCo + final class MockWebSocketClient: WebSocketClient { + func disconnectWithCloseCode() async { // 의도적인 에러 + task?.cancel(with: .internalServerError, reason: nil) + } -} \ No newline at end of file + func disconnectWithoutCloseCode() async { + task?.cancel() + } +} diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift index e0198ec4..a6449944 100644 --- a/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift +++ b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift @@ -1,9 +1,25 @@ +// +// MockWebSocketTask.swift +// iCo +// +// Created by 강대훈 on 11/12/25. +// + +import Foundation +@testable import iCo + final class MockWebSocketTask: WebSocketType { var delegate: URLSessionTaskDelegate? + + private var closed: Bool = false private var taskState: URLSessionTask.State = .completed + private var fakeSession: URLSession = URLSession(configuration: .ephemeral) + private var fakeTask: URLSessionWebSocketTask { + fakeSession.webSocketTask(with: URL(string: "wss://")!) + } var state: URLSessionTask.State { - taskState + return taskState } private var throwError: Bool @@ -19,15 +35,32 @@ final class MockWebSocketTask: WebSocketType { } func resume() { - taskState = .suspended resumeCallCount += 1 - taskState = .running - // Delegate 호출이 있어야 함. + + if let delegate = delegate as? URLSessionWebSocketDelegate { + delegate.urlSession?(fakeSession, webSocketTask: fakeTask, didOpenWithProtocol: nil) + taskState = .running + } } func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { cancelCallCount += 1 - // Delegate 호출이 있어야 함. + taskState = .canceling + closed = true + + if let delegate = delegate as? URLSessionWebSocketDelegate { + delegate.urlSession?(fakeSession, webSocketTask: fakeTask, didCloseWith: closeCode, reason: nil) + delegate.urlSession?(fakeSession, task: fakeTask, didCompleteWithError: nil) + } + } + + func cancel() { + cancelCallCount += 1 + taskState = .canceling + closed = true + + let error = URLError(.notConnectedToInternet) + delegate?.urlSession?(fakeSession, task: fakeTask, didCompleteWithError: error) } func send(_ message: URLSessionWebSocketTask.Message) async throws { @@ -42,19 +75,21 @@ final class MockWebSocketTask: WebSocketType { sendCallCount += 1 } - func cancel() { - cancelCallCount += 1 - // Delegate 호출이 있어야 함. - } - func sendPing(pongReceiveHandler: @escaping ((any Error)?) -> Void) { sendPingCallCount += 1 // 아직 모르겠음. } func receive() async throws -> URLSessionWebSocketTask.Message { - // 어떤 경우에 에러를 던지는지 생각해봐야 함. + if closed { // 작업이 종료되었을 때 에러 던져야 함. + throw NSError( + domain: NSURLErrorDomain, + code: URLError.cancelled.rawValue, + userInfo: nil + ) + } + receiveCallCount += 1 - return .string("데이터 잘 받음.") + return .string("데이터 잘 받았습니다.") } -} \ No newline at end of file +} diff --git a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift index ce73acc7..fab927c9 100644 --- a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift +++ b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift @@ -6,29 +6,110 @@ // import XCTest +@testable import iCo final class WebSocketTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + let url: URL = URL(string: "wss://")! + var broadCaster: MockAsyncStreamBroadCaster! + var sut: MockWebSocketClient! + var task: MockWebSocketTask! + var urlSession: MockURLSession! + + override func setUp() async throws { + task = MockWebSocketTask() + broadCaster = MockAsyncStreamBroadCaster() + urlSession = MockURLSession(task: task) + sut = MockWebSocketClient(url: url, session: urlSession, stateBroadCaster: broadCaster) } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + + override func tearDown() async throws { + urlSession = nil + sut = nil + task = nil + broadCaster = nil } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + + func testInit() { + XCTAssertNil(sut.task) + XCTAssertNil(sut.receiveTask) + XCTAssertNil(sut.healthCheck) + XCTAssertNotNil(sut.stateTask) } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } + + func testConnect() async { + // arrange + let expectedLog: [WebSocket.State] = [.connecting, .connected] + + // act + await sut.connect() + try? await Task.sleep(for: .seconds(0.1)) // WSS HandShake 대기 + + // assert + XCTAssertEqual(expectedLog, broadCaster.log) + XCTAssertNotNil(sut.task) + XCTAssertEqual(sut.task?.state, .running) + XCTAssertIdentical(sut.task?.delegate, sut) + XCTAssertEqual(1, task.resumeCallCount) + } + + func testDisconnect() async { + // arrange + let expectedLog: [WebSocket.State] = [.connecting, .connected, .closed] + + // act + await sut.connect() + try? await Task.sleep(for: .seconds(0.2)) + await sut.disconnect() + try? await Task.sleep(for: .seconds(0.2)) + + // assert + XCTAssertEqual(expectedLog, broadCaster.log) + XCTAssertNil(sut.healthCheck) + XCTAssertNil(sut.receiveTask) + XCTAssertNil(sut.task) + } + + func testReconnect_CloseCode를받았을때_재연결하는지() async { + // arrange + let expectedLog: [WebSocket.State] = [ + .connecting, + .connected, + .reconnecting(nextAttempsIn: .seconds(2)), + .connecting, + .connected + ] + + // act + await sut.connect() + await sut.disconnectWithCloseCode() + try? await Task.sleep(for: .seconds(3)) + + // assert + XCTAssertEqual(expectedLog, broadCaster.log) + XCTAssertNotNil(sut.task) + XCTAssertNotNil(sut.stateTask) + XCTAssertNotNil(sut.receiveTask) + XCTAssertNotNil(sut.healthCheck) + } + + func testReconnect_CloseCode를받지못했을때_재연결하는지() async { + // arrange + let expectedLog: [WebSocket.State] = [ + .connecting, + .connected, + .reconnecting(nextAttempsIn: .seconds(2)), + .connecting, + .connected + ] + + await sut.connect() + await sut.disconnectWithoutCloseCode() + try? await Task.sleep(for: .seconds(3)) + + XCTAssertEqual(expectedLog, broadCaster.log) + XCTAssertNotNil(sut.task) + XCTAssertNotNil(sut.stateTask) + XCTAssertNotNil(sut.receiveTask) + XCTAssertNotNil(sut.healthCheck) } } diff --git a/AIProject/iCoTests/Socket/WebSocketTests.swift b/AIProject/iCoTests/Socket/WebSocketTests.swift deleted file mode 100644 index 23bfd75e..00000000 --- a/AIProject/iCoTests/Socket/WebSocketTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// WebSocketTests.swift -// iCoTests -// -// Created by 강대훈 on 11/12/25. -// - -import XCTest - -final class WebSocketTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} From 7559c04ebdda2622ea33642fa50561aae4b138e4 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Wed, 12 Nov 2025 21:22:59 +0900 Subject: [PATCH 05/14] =?UTF-8?q?refactor:=20WebSocket.State=20Equatable?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift b/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift index 3e70a2e1..d80ce796 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift @@ -51,6 +51,8 @@ extension WebSocket.State: Equatable { return true case (.connected, .connected): return true + case (.closed, .closed): + return true case (.reconnecting(let lhsDelay), .reconnecting(let rhsDelay)): return lhsDelay == rhsDelay default: From a3539b112b55078da90d6fe661798158baa9470f Mon Sep 17 00:00:00 2001 From: kangho Date: Wed, 12 Nov 2025 23:09:11 +0900 Subject: [PATCH 06/14] test: send, ping test case cover --- .../Remote/WebSocket/WebSocketClient.swift | 55 ++++++----- .../Socket/Resource/MockWebSocketClient.swift | 11 --- .../Socket/Resource/MockWebSocketTask.swift | 24 ++++- .../Socket/Tests/WebSocketTests.swift | 97 +++++++++++++++++-- 4 files changed, 144 insertions(+), 43 deletions(-) diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index b27974cc..8afd6c29 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -97,12 +97,14 @@ public class WebSocketClient: NSObject, WebSocketProvider { /// 텍스트 형태의 메시지를 WebSocket 서버로 전송합니다. public func send(text: String) async throws { - try await task?.send(.string(text)) + guard let task else { throw NetworkError.networkError(URLError(.notConnectedToInternet)) } + try await task.send(.string(text)) } /// 바이너리(Data) 형태의 메시지를 WebSocket 서버로 전송합니다. public func send(data: Data) async throws { - try await task?.send(.data(data)) + guard let task else { throw NetworkError.networkError(URLError(.notConnectedToInternet)) } + try await task.send(.data(data)) } deinit { @@ -116,27 +118,11 @@ public class WebSocketClient: NSObject, WebSocketProvider { // MARK: - Private extension WebSocketClient { - /// 서버로 Ping 프레임을 전송하여 연결 상태를 확인합니다. - private func sendPing() async throws { - return try await withCheckedThrowingContinuation { continuation in - task?.sendPing { error in - Task { - if let error { - debugPrint("Ping Failed: \(error)") - continuation.resume(throwing: error) - return - } - - continuation.resume() - } - } - } - } /// WebSocket의 상태 변화를 관찰하고 각 상태에 맞는 동작을 수행합니다. private func observeState() { stateTask = Task { - for await state in stateStream { + for await state in stateStream.removeDuplicates() { switch state { case .connecting: debugPrint("Connecting") @@ -174,13 +160,30 @@ extension WebSocketClient { healthCheck = Task { do { while true { - try await Task.sleep(until: .now + pingInterval) try await performWithTimeout(sendPing, at: .seconds(10)) + try await Task.sleep(until: .now + pingInterval) } } catch is CancellationError { debugPrint("작업이 취소되었습니다.") } catch { - await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) + await requestReconnect() + } + } + } + + /// 서버로 Ping 프레임을 전송하여 연결 상태를 확인합니다. + private func sendPing() async throws { + return try await withCheckedThrowingContinuation { continuation in + task?.sendPing { error in + Task { + if let error { + debugPrint("Ping Failed: \(error)") + continuation.resume(throwing: error) + return + } + + continuation.resume() + } } } } @@ -190,16 +193,20 @@ extension WebSocketClient { if userClose { await stateBroadCaster.send(.closed) } else { - await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) + await requestReconnect() } } + private func requestReconnect() async { + await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) + } + /// WebSocket 재연결을 시도합니다. private func reconnect() async { guard task?.state != .running else { return } - + try? await Task.sleep(for: .seconds(2)) await connect() } @@ -230,7 +237,7 @@ extension WebSocketClient: URLSessionWebSocketDelegate { // 1. 네트워크 닫힘, 2. 에러로 종료, 3. 정상적으로 완료 public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { if let _ = error { - Task { await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) } + Task { await requestReconnect() } } } } diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift index 543c0db2..830137cb 100644 --- a/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift +++ b/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift @@ -5,14 +5,3 @@ // Created by 강대훈 on 11/12/25. // -@testable import iCo - -final class MockWebSocketClient: WebSocketClient { - func disconnectWithCloseCode() async { // 의도적인 에러 - task?.cancel(with: .internalServerError, reason: nil) - } - - func disconnectWithoutCloseCode() async { - task?.cancel() - } -} diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift index a6449944..86bf80c3 100644 --- a/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift +++ b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift @@ -24,6 +24,8 @@ final class MockWebSocketTask: WebSocketType { private var throwError: Bool + var messages: [URLSessionWebSocketTask.Message] = [] + var resumeCallCount: Int = 0 var cancelCallCount: Int = 0 var sendCallCount: Int = 0 @@ -72,12 +74,22 @@ final class MockWebSocketTask: WebSocketType { ) } + messages.append(message) sendCallCount += 1 } func sendPing(pongReceiveHandler: @escaping ((any Error)?) -> Void) { - sendPingCallCount += 1 - // 아직 모르겠음. + if throwError || state != .running { + pongReceiveHandler(NSError( + domain: NSURLErrorDomain, + code: -1009, + userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."] + )) + return + } else { + pongReceiveHandler(nil) + sendPingCallCount += 1 + } } func receive() async throws -> URLSessionWebSocketTask.Message { @@ -92,4 +104,12 @@ final class MockWebSocketTask: WebSocketType { receiveCallCount += 1 return .string("데이터 잘 받았습니다.") } + + func disconnect(with code: URLSessionWebSocketTask.CloseCode?) { + if let code { + self.cancel(with: code, reason: nil) + } else { + self.cancel() + } + } } diff --git a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift index fab927c9..3389e6b1 100644 --- a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift +++ b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift @@ -11,7 +11,7 @@ import XCTest final class WebSocketTests: XCTestCase { let url: URL = URL(string: "wss://")! var broadCaster: MockAsyncStreamBroadCaster! - var sut: MockWebSocketClient! + var sut: WebSocketClient! var task: MockWebSocketTask! var urlSession: MockURLSession! @@ -19,7 +19,7 @@ final class WebSocketTests: XCTestCase { task = MockWebSocketTask() broadCaster = MockAsyncStreamBroadCaster() urlSession = MockURLSession(task: task) - sut = MockWebSocketClient(url: url, session: urlSession, stateBroadCaster: broadCaster) + sut = WebSocketClient(url: url, session: urlSession, stateBroadCaster: broadCaster) } override func tearDown() async throws { @@ -81,8 +81,8 @@ final class WebSocketTests: XCTestCase { // act await sut.connect() - await sut.disconnectWithCloseCode() - try? await Task.sleep(for: .seconds(3)) + task.disconnect(with: .internalServerError) + try? await Task.sleep(for: .seconds(4)) // assert XCTAssertEqual(expectedLog, broadCaster.log) @@ -103,8 +103,8 @@ final class WebSocketTests: XCTestCase { ] await sut.connect() - await sut.disconnectWithoutCloseCode() - try? await Task.sleep(for: .seconds(3)) + task.disconnect(with: .internalServerError) + try? await Task.sleep(for: .seconds(4)) XCTAssertEqual(expectedLog, broadCaster.log) XCTAssertNotNil(sut.task) @@ -112,4 +112,89 @@ final class WebSocketTests: XCTestCase { XCTAssertNotNil(sut.receiveTask) XCTAssertNotNil(sut.healthCheck) } + + func testSend_Failed_notConnected() async throws { + let value: ()? = try? await sut.send(text: "Hello") + XCTAssertNil(value) + } + + func testSend_Success() async throws { + let values = ["Hello", "Swift", "Test"] + + await sut.connect() + + try await sut.send(text: values[0]) + try await sut.send(text: values[1]) + try await sut.send(text: values[2]) + + if case .string(let text) = task.messages[1] { + XCTAssertEqual(text, values[1]) + } else { + XCTFail("not found") + } + + XCTAssertEqual(task.sendCallCount, 3) + XCTAssertEqual(task.messages.count, 3) + } + + func testSend_dataSucess() async throws { + await sut.connect() + + try await sut.send(data: Data("Hello".utf8)) + + if case .data(let encoded) = task.messages[0] { + XCTAssertEqual("Hello", String(data: encoded, encoding: .utf8)) + } else { + XCTFail("not found") + } + + XCTAssertEqual(task.sendCallCount, 1) + } + + func testPing_Failed_notConnected() async throws { + let exp = expectation(description: "Wait for request") + + makeSUTError() + task.sendPing { error in + XCTAssertNotNil(error) + exp.fulfill() + } + + await fulfillment(of: [exp], timeout: 0.3) + } + + func testPing_Failed_pingTimeout() async throws { + let exp = expectation(description: "Wait for request") + + makeSUTError() + await sut.connect() + task.sendPing { error in + XCTAssertNotNil(error) + exp.fulfill() + } + + await fulfillment(of: [exp], timeout: 0.3) + } + + func testPing_Success() async throws { + let exp = expectation(description: "Wait for request") + + await sut.connect() + + task.sendPing { error in + XCTAssertNil(error) + exp.fulfill() + } + + await fulfillment(of: [exp], timeout: 0.3) + } +} + +extension WebSocketTests { + private func makeSUTError() { + task = MockWebSocketTask(throwError: true) + broadCaster = MockAsyncStreamBroadCaster() + urlSession = MockURLSession(task: task) + sut = WebSocketClient(url: url, session: urlSession, stateBroadCaster: broadCaster) + } } From ee7b83dc03ec4274c4c2322e1e97f3d93b83f48e Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Fri, 14 Nov 2025 16:30:26 +0900 Subject: [PATCH 07/14] =?UTF-8?q?refactor:=20sendPing=20=EC=9E=AC=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=ED=95=84=EC=9A=94=ED=95=9C=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Remote/WebSocket/WebSocketClient.swift | 48 ++++++++++++------- .../Socket/Resource/MockWebSocketClient.swift | 7 --- 2 files changed, 32 insertions(+), 23 deletions(-) delete mode 100644 AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index 8afd6c29..e0e08b16 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -118,7 +118,6 @@ public class WebSocketClient: NSObject, WebSocketProvider { // MARK: - Private extension WebSocketClient { - /// WebSocket의 상태 변화를 관찰하고 각 상태에 맞는 동작을 수행합니다. private func observeState() { stateTask = Task { @@ -155,19 +154,44 @@ extension WebSocketClient { /// 주기적으로 Ping을 전송하여 WebSocket 연결 상태를 점검합니다. private func checkingAlive() { - healthCheck?.cancel() - healthCheck = Task { do { while true { - try await performWithTimeout(sendPing, at: .seconds(10)) + try await performWithTimeout(sendPing, at: pingTimeout) try await Task.sleep(until: .now + pingInterval) } } catch is CancellationError { debugPrint("작업이 취소되었습니다.") } catch { - await requestReconnect() + if handlePingError(error) { + if task?.state == .running { + task?.cancel() + } + } + } + } + } + + /// sendPing(:) 으로부터 받은 에러를 핸들링하는 메소드입니다. + /// - Parameter error: 에러를 전달받습니다. + /// - Returns: 재연결해야 한다면 true를 반환합니다. + private func handlePingError(_ error: Error) -> Bool { + if let urlError = error as? URLError { + switch urlError.code { // URLError (네트워크 단절) + case .notConnectedToInternet, .networkConnectionLost: + return true + default: + return false } + } else if let posixError = error as? POSIXError { + switch posixError.code { // POSIXError (소켓이 죽음) + case .EPIPE, .ECONNRESET: + return true + default: + return false + } + } else { // 소켓이 정상상태가 아님. + return true } } @@ -193,27 +217,19 @@ extension WebSocketClient { if userClose { await stateBroadCaster.send(.closed) } else { - await requestReconnect() + await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) } } - private func requestReconnect() async { - await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) - } - /// WebSocket 재연결을 시도합니다. private func reconnect() async { - guard task?.state != .running else { - return - } - + if task?.state == .running { return } try? await Task.sleep(for: .seconds(2)) await connect() } /// WebSocket 클라이언트의 모든 비동기 작업과 연결을 종료하고 리소스를 정리합니다. private func release() { - receiveTask?.cancel() receiveTask = nil healthCheck?.cancel() @@ -237,7 +253,7 @@ extension WebSocketClient: URLSessionWebSocketDelegate { // 1. 네트워크 닫힘, 2. 에러로 종료, 3. 정상적으로 완료 public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { if let _ = error { - Task { await requestReconnect() } + Task { await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) } } } } diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift deleted file mode 100644 index 830137cb..00000000 --- a/AIProject/iCoTests/Socket/Resource/MockWebSocketClient.swift +++ /dev/null @@ -1,7 +0,0 @@ -// -// MockWebSocketClient.swift -// iCo -// -// Created by 강대훈 on 11/12/25. -// - From 19f677e49ee0e8761d90e9d2e9745bbd86808fa3 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Fri, 14 Nov 2025 16:30:59 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=20MockWebSocketTask=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=83=81=ED=83=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Socket/Resource/MockWebSocketTask.swift | 18 ++++++++++-------- .../iCoTests/Socket/Tests/WebSocketTests.swift | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift index 86bf80c3..97f78f32 100644 --- a/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift +++ b/AIProject/iCoTests/Socket/Resource/MockWebSocketTask.swift @@ -40,26 +40,28 @@ final class MockWebSocketTask: WebSocketType { resumeCallCount += 1 if let delegate = delegate as? URLSessionWebSocketDelegate { - delegate.urlSession?(fakeSession, webSocketTask: fakeTask, didOpenWithProtocol: nil) taskState = .running + delegate.urlSession?(fakeSession, webSocketTask: fakeTask, didOpenWithProtocol: nil) } } func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { cancelCallCount += 1 - taskState = .canceling closed = true + taskState = .canceling if let delegate = delegate as? URLSessionWebSocketDelegate { delegate.urlSession?(fakeSession, webSocketTask: fakeTask, didCloseWith: closeCode, reason: nil) delegate.urlSession?(fakeSession, task: fakeTask, didCompleteWithError: nil) } + + taskState = .completed } func cancel() { cancelCallCount += 1 - taskState = .canceling closed = true + taskState = .completed let error = URLError(.notConnectedToInternet) delegate?.urlSession?(fakeSession, task: fakeTask, didCompleteWithError: error) @@ -67,10 +69,11 @@ final class MockWebSocketTask: WebSocketType { func send(_ message: URLSessionWebSocketTask.Message) async throws { if throwError { + taskState = .completed throw NSError( domain: NSURLErrorDomain, code: -1009, - userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."] + userInfo: [NSLocalizedDescriptionKey: "인터넷 에러 코드 -1009"] ) } @@ -83,9 +86,8 @@ final class MockWebSocketTask: WebSocketType { pongReceiveHandler(NSError( domain: NSURLErrorDomain, code: -1009, - userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."] + userInfo: [NSLocalizedDescriptionKey: "인터넷 에러 코드 -1009"] )) - return } else { pongReceiveHandler(nil) sendPingCallCount += 1 @@ -93,7 +95,7 @@ final class MockWebSocketTask: WebSocketType { } func receive() async throws -> URLSessionWebSocketTask.Message { - if closed { // 작업이 종료되었을 때 에러 던져야 함. + if closed { throw NSError( domain: NSURLErrorDomain, code: URLError.cancelled.rawValue, @@ -105,7 +107,7 @@ final class MockWebSocketTask: WebSocketType { return .string("데이터 잘 받았습니다.") } - func disconnect(with code: URLSessionWebSocketTask.CloseCode?) { + func disconnect(with code: URLSessionWebSocketTask.CloseCode? = nil) { if let code { self.cancel(with: code, reason: nil) } else { diff --git a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift index 3389e6b1..8ff71b81 100644 --- a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift +++ b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift @@ -103,7 +103,7 @@ final class WebSocketTests: XCTestCase { ] await sut.connect() - task.disconnect(with: .internalServerError) + task.disconnect() try? await Task.sleep(for: .seconds(4)) XCTAssertEqual(expectedLog, broadCaster.log) From c9223635dcea280c4af8a8cce29fc5a78a7805f1 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Fri, 14 Nov 2025 18:01:20 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20WebSocket=20=EC=9E=AC=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EB=B0=B1=EC=98=A4=ED=94=84=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Remote/WebSocket/WebSocketClient.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index e0e08b16..27ae817a 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -63,6 +63,7 @@ public class WebSocketClient: NSObject, WebSocketProvider { private(set) var healthCheck: Task? private var pingInterval: Duration = .seconds(30) private var pingTimeout: Duration = .seconds(10) + private var attempts: Int = 0 public init( url: URL, @@ -221,10 +222,21 @@ extension WebSocketClient { } } + + /// WebSocket 재연결시에 백오프를 적용합니다. + /// - Returns: 백오프하는 시간을 Int 타입으로 반환합니다. + private func backoff() -> Int { + attempts += 1 + let base = min(pow(2.0, Double(attempts)) * 100.0, 10000) + let jitter = Double.random(in: 0.5...1.0) + + return Int(base * jitter) + } + /// WebSocket 재연결을 시도합니다. private func reconnect() async { - if task?.state == .running { return } - try? await Task.sleep(for: .seconds(2)) + if task?.state == .running || attempts > 10 { return } + try? await Task.sleep(for: .milliseconds(backoff())) await connect() } From 4b7258c6075e54014b9b897dba320c44f789a567 Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Fri, 14 Nov 2025 19:19:45 +0900 Subject: [PATCH 10/14] =?UTF-8?q?refactor:=20WebSocket.State=20reconnectab?= =?UTF-8?q?le=20=EC=97=B0=EA=B4=80=EA=B0=92=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift | 6 +++--- AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift | 8 ++++++-- AIProject/iCoTests/Socket/Tests/WebSocketTests.swift | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift b/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift index d80ce796..c9e3c78d 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift @@ -21,7 +21,7 @@ public enum WebSocket { case connecting, connected case failed case closed - case reconnecting(nextAttempsIn: Duration) + case reconnecting } public enum Failure: Error { @@ -53,8 +53,8 @@ extension WebSocket.State: Equatable { return true case (.closed, .closed): return true - case (.reconnecting(let lhsDelay), .reconnecting(let rhsDelay)): - return lhsDelay == rhsDelay + case (.reconnecting, .reconnecting): + return true default: return false } diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index 27ae817a..fab6eaae 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -129,6 +129,7 @@ extension WebSocketClient { continue case .connected: debugPrint("Connected") + clearAttempts() receive() checkingAlive() case .failed, .closed: @@ -218,10 +219,13 @@ extension WebSocketClient { if userClose { await stateBroadCaster.send(.closed) } else { - await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) + await stateBroadCaster.send(.reconnecting) } } + private func clearAttempts() { + attempts = 0 + } /// WebSocket 재연결시에 백오프를 적용합니다. /// - Returns: 백오프하는 시간을 Int 타입으로 반환합니다. @@ -265,7 +269,7 @@ extension WebSocketClient: URLSessionWebSocketDelegate { // 1. 네트워크 닫힘, 2. 에러로 종료, 3. 정상적으로 완료 public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { if let _ = error { - Task { await stateBroadCaster.send(.reconnecting(nextAttempsIn: .seconds(2))) } + Task { await stateBroadCaster.send(.reconnecting) } } } } diff --git a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift index 8ff71b81..0df6839d 100644 --- a/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift +++ b/AIProject/iCoTests/Socket/Tests/WebSocketTests.swift @@ -74,7 +74,7 @@ final class WebSocketTests: XCTestCase { let expectedLog: [WebSocket.State] = [ .connecting, .connected, - .reconnecting(nextAttempsIn: .seconds(2)), + .reconnecting, .connecting, .connected ] @@ -97,7 +97,7 @@ final class WebSocketTests: XCTestCase { let expectedLog: [WebSocket.State] = [ .connecting, .connected, - .reconnecting(nextAttempsIn: .seconds(2)), + .reconnecting, .connecting, .connected ] From ce1f589027d55a66252fac56037edb702e53811f Mon Sep 17 00:00:00 2001 From: kanghun1121 Date: Fri, 14 Nov 2025 19:20:55 +0900 Subject: [PATCH 11/14] =?UTF-8?q?fix:=20deinit=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index fab6eaae..83a3a7b1 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -107,14 +107,6 @@ public class WebSocketClient: NSObject, WebSocketProvider { guard let task else { throw NetworkError.networkError(URLError(.notConnectedToInternet)) } try await task.send(.data(data)) } - - deinit { - debugPrint(String(describing: Self.self), #function) - task?.cancel() - task = nil - stateBroadCaster.finish() - incomingChannel.finish() - } } // MARK: - Private From 8c2a2efcaf514e488d53c44f1938f387e4088b61 Mon Sep 17 00:00:00 2001 From: kangho Date: Fri, 14 Nov 2025 20:55:14 +0900 Subject: [PATCH 12/14] fix: protocol swift6 sendable conform warning --- AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index 83a3a7b1..0f8b750b 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -32,7 +32,7 @@ public protocol WebSocketType { func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) func send(_ message: URLSessionWebSocketTask.Message) async throws func cancel() - func sendPing(pongReceiveHandler: @escaping ((any Error)?) -> Void) + func sendPing(pongReceiveHandler: @escaping @Sendable((any Error)?) -> Void) func receive() async throws -> URLSessionWebSocketTask.Message } From 20b03242867d7b6ecb5e1c846c9c06dfdc5f8cbe Mon Sep 17 00:00:00 2001 From: kangho Date: Fri, 14 Nov 2025 21:23:45 +0900 Subject: [PATCH 13/14] remove: Network dependency, unused protocol. move util to WebSocket Module --- .../Core/Remote/WebSocket/SocketEngine.swift | 62 --------------- .../WebSocket}/Util/Async+BroadCaster.swift | 0 .../WebSocket}/Util/Async+Timeout.swift | 0 .../Remote/WebSocket/WebSocketClient.swift | 51 +----------- .../Remote/WebSocket/WebSocketProvider.swift | 77 +++++++++++++++++++ 5 files changed, 81 insertions(+), 109 deletions(-) delete mode 100644 AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift rename AIProject/iCo/Core/{ => Remote/WebSocket}/Util/Async+BroadCaster.swift (100%) rename AIProject/iCo/Core/{ => Remote/WebSocket}/Util/Async+Timeout.swift (100%) create mode 100644 AIProject/iCo/Core/Remote/WebSocket/WebSocketProvider.swift diff --git a/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift b/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift deleted file mode 100644 index c9e3c78d..00000000 --- a/AIProject/iCo/Core/Remote/WebSocket/SocketEngine.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// SocketEngine.swift -// AIProject -// -// Created by kangho lee on 8/17/25. -// - -import Foundation -import AsyncAlgorithms - -public protocol SocketEngine { - var stateChannel: AsyncChannel { get set } - var incomingChannel: AsyncChannel> { get set } - func connect() async - func send(_ data: Data) async throws - func close() async -} - -public enum WebSocket { - public enum State: Sendable { - case connecting, connected - case failed - case closed - case reconnecting - } - - public enum Failure: Error { - - /// 네트워크 끊김, timeout - case retryable(underlying: Error?) - - /// 인증/정책/프로토콜 위반 - case nonRetryable(underlying: Error?) - case closed(code: URLSessionWebSocketTask.CloseCode, reason: Data?) - } - - public enum MessageFailure: Error { - case frameCorrupted - case failed(Error) - } - - public enum RetryFailure: Error { - case exceedAttemps - } -} - -extension WebSocket.State: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.connecting, .connecting): - return true - case (.connected, .connected): - return true - case (.closed, .closed): - return true - case (.reconnecting, .reconnecting): - return true - default: - return false - } - } -} diff --git a/AIProject/iCo/Core/Util/Async+BroadCaster.swift b/AIProject/iCo/Core/Remote/WebSocket/Util/Async+BroadCaster.swift similarity index 100% rename from AIProject/iCo/Core/Util/Async+BroadCaster.swift rename to AIProject/iCo/Core/Remote/WebSocket/Util/Async+BroadCaster.swift diff --git a/AIProject/iCo/Core/Util/Async+Timeout.swift b/AIProject/iCo/Core/Remote/WebSocket/Util/Async+Timeout.swift similarity index 100% rename from AIProject/iCo/Core/Util/Async+Timeout.swift rename to AIProject/iCo/Core/Remote/WebSocket/Util/Async+Timeout.swift diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift index 0f8b750b..6fda2a08 100644 --- a/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketClient.swift @@ -1,49 +1,6 @@ import Foundation import AsyncAlgorithms -public protocol WebSocketProvider { - var stateBroadCaster: AsyncStreamBroadcaster { get } - var incomingChannel: AsyncChannel { get } - - /// 웹소켓 세션을 연결하고 작업을 생성합니다. - func connect() async - - /// 명시적으로 현재 WebSocket 연결을 정상적으로 종료합니다. - /// - /// 이 메서드는 서버와의 WebSocket 연결을 `normalClosure` 코드로 닫습니다. - func disconnect() async - - /// 텍스트 형태의 메시지를 WebSocket 서버로 전송합니다. - func send(text: String) async throws - - /// 바이너리(Data) 형태의 메시지를 WebSocket 서버로 전송합니다. - func send(data: Data) async throws -} - -public protocol URLSessionType { - func makeWebSocketTask(with url: URL) -> WebSocketType -} - -public protocol WebSocketType { - var delegate: (any URLSessionTaskDelegate)? { get set } - var state: URLSessionTask.State { get } - - func resume() - func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) - func send(_ message: URLSessionWebSocketTask.Message) async throws - func cancel() - func sendPing(pongReceiveHandler: @escaping @Sendable((any Error)?) -> Void) - func receive() async throws -> URLSessionWebSocketTask.Message -} - -extension URLSession: URLSessionType { - public func makeWebSocketTask(with url: URL) -> any WebSocketType { - return webSocketTask(with: url) - } -} - -extension URLSessionWebSocketTask: WebSocketType {} - public class WebSocketClient: NSObject, WebSocketProvider { /// 소켓 상태 채널 private var stateStream: AsyncStream @@ -98,13 +55,13 @@ public class WebSocketClient: NSObject, WebSocketProvider { /// 텍스트 형태의 메시지를 WebSocket 서버로 전송합니다. public func send(text: String) async throws { - guard let task else { throw NetworkError.networkError(URLError(.notConnectedToInternet)) } + guard let task else { throw URLError(.notConnectedToInternet) } try await task.send(.string(text)) } /// 바이너리(Data) 형태의 메시지를 WebSocket 서버로 전송합니다. public func send(data: Data) async throws { - guard let task else { throw NetworkError.networkError(URLError(.notConnectedToInternet)) } + guard let task else { throw URLError(.notConnectedToInternet) } try await task.send(.data(data)) } } @@ -124,7 +81,7 @@ extension WebSocketClient { clearAttempts() receive() checkingAlive() - case .failed, .closed: + case .closed: debugPrint("Closed") release() case .reconnecting: @@ -139,7 +96,7 @@ extension WebSocketClient { private func receive() { receiveTask = Task { while true { - guard let task else { throw NetworkError.taskCancelled } + guard let task else { throw CancellationError() } let message = try await task.receive() await incomingChannel.send(message) } diff --git a/AIProject/iCo/Core/Remote/WebSocket/WebSocketProvider.swift b/AIProject/iCo/Core/Remote/WebSocket/WebSocketProvider.swift new file mode 100644 index 00000000..0e8feb7d --- /dev/null +++ b/AIProject/iCo/Core/Remote/WebSocket/WebSocketProvider.swift @@ -0,0 +1,77 @@ +// +// SocketEngine.swift +// AIProject +// +// Created by kangho lee on 8/17/25. +// + +import Foundation +import AsyncAlgorithms + +public protocol WebSocketProvider { + var stateBroadCaster: AsyncStreamBroadcaster { get } + var incomingChannel: AsyncChannel { get } + + /// 웹소켓 세션을 연결하고 작업을 생성합니다. + func connect() async + + /// 명시적으로 현재 WebSocket 연결을 정상적으로 종료합니다. + /// + /// 이 메서드는 서버와의 WebSocket 연결을 `normalClosure` 코드로 닫습니다. + func disconnect() async + + /// 텍스트 형태의 메시지를 WebSocket 서버로 전송합니다. + func send(text: String) async throws + + /// 바이너리(Data) 형태의 메시지를 WebSocket 서버로 전송합니다. + func send(data: Data) async throws +} + +public protocol URLSessionType { + func makeWebSocketTask(with url: URL) -> WebSocketType +} + +extension URLSession: URLSessionType { + public func makeWebSocketTask(with url: URL) -> any WebSocketType { + return webSocketTask(with: url) + } +} + +public protocol WebSocketType { + var delegate: (any URLSessionTaskDelegate)? { get set } + var state: URLSessionTask.State { get } + + func resume() + func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) + func send(_ message: URLSessionWebSocketTask.Message) async throws + func cancel() + func sendPing(pongReceiveHandler: @escaping @Sendable((any Error)?) -> Void) + func receive() async throws -> URLSessionWebSocketTask.Message +} + +extension URLSessionWebSocketTask: WebSocketType {} + +public enum WebSocket { + public enum State: Sendable { + case connecting, connected + case closed + case reconnecting + } +} + +extension WebSocket.State: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.connecting, .connecting): + return true + case (.connected, .connected): + return true + case (.closed, .closed): + return true + case (.reconnecting, .reconnecting): + return true + default: + return false + } + } +} From ca1657972f92a648f7d94140ea25064ae2e1f900 Mon Sep 17 00:00:00 2001 From: kangho Date: Fri, 14 Nov 2025 21:36:30 +0900 Subject: [PATCH 14/14] fix: #684 websocket scenePhase issue --- AIProject/iCo/Features/Market/CoinList/CoinListView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AIProject/iCo/Features/Market/CoinList/CoinListView.swift b/AIProject/iCo/Features/Market/CoinList/CoinListView.swift index 678a84c1..68e29ed8 100644 --- a/AIProject/iCo/Features/Market/CoinList/CoinListView.swift +++ b/AIProject/iCo/Features/Market/CoinList/CoinListView.swift @@ -125,9 +125,9 @@ extension CoinListView { print(#function, phase) switch phase { case .background: - await store.disconnect() - case .inactive: break + case .inactive: + await store.disconnect() case .active: await store.connect() @unknown default: