53-B, S019, S054 # Day18 미션(챌린지 GPT 서버) ## 체크리스트 - [x] TCP 서버 동작 및 Network Framework 학습 - [x] Telnet, 소켓 옵션 학습 - [x] TCP 에코 서버 설계하기 - [x] SocketIO 구현하기 - [x] Client 객체를 구현하기 - [x] 여러개의 Client 연결이 가능하도록 구현하기 - [x] Client 그룹 짓기 - [x] Summary 구현하기 - [x] Chat 구현하기 - [x] Broadcast 구현하기 - [x] Direct 구현하기 - [x] !history 구현하기 - [x] Server Log 구현하기 - [ ] 디테일 추가하기 - [ ] 응답 실패 - [ ] checkin S001~S256 범위 체크, 벗어난 경우 재입력 - [ ] checkin 중복 불가능 - [ ] checkout 활동시간 출력 - [ ] checkout TCP 연결 해제 시에도 적용 ## 문제 해결 과정 ### 미션 1 - TCPSocketIO 구현 ```swift final class TCPSocketIO { private var listener: NWListener? init() { do { let parameters = NWParameters.tcp parameters.allowLocalEndpointReuse = true guard let nwPort = NWEndpoint.Port(rawValue: 2024) else { return } listener = try NWListener(using: parameters, on: nwPort) //.. 중략 /// telnet 명령어를 통해 새로운 Client가 들어오면 호출되는 클로저 listener?.newConnectionHandler = { [weak self] newConnection in self?.handleConnection(newConnection) } listener?.start(queue: .global()) } } /// Client에게 요청을 받고, echo 응답을 주는 메서드 private func handleConnection(_ connection: NWConnection) { connection.start(queue: .global()) // 요청을 클라이언트로부터 receive connection.receive(minimumIncompleteLength: 4, maximumLength: 1024) { data, context, isComplete, error in //.. 중략 if let data = data, !data.isEmpty { guard let message = String(data: data, encoding: .utf8) else { return connection.cancel() } print("Received message: \(message)") /// 응답 다시 클라이언트에게 send connection.send( content: data, completion: .contentProcessed { sendError in if let sendError = sendError { print("Send error: \(sendError)") } } ) } /// 다시 입력을 받기 위해 재귀로 호출 self.handleConnection(connection) } } /// 프로세스가 종료 되었을 때, listener 해제 deinit { listener?.cancel() } } ``` ### 미션 2 - 설계 <img width="1000" src="https://gist.github.com/user-attachments/assets/60d62588-aecb-4a61-a537-d795a7fcf0f0"> - 결과 캡쳐 <img width="1500" src="https://gist.github.com/user-attachments/assets/a0b084a1-e568-4dcc-81f9-7b99a739151b"> - 구현 내용 ```swift class Client { let id: String // 캠퍼 아이디 let connection: NWConnection let checkInDate: Date let session: Int let group: Int // 그룹 번호 var inputCommandList: [String] //.. init() 중략 } // 입력받을 수 있는 명령어를 Case로 분류한 Enum Type enum Command: String { case checkin case checkout case summary case chat case finish case broadcast case direct case clap case history = "!history" } // summary 명령어 입력받은 날을 Case로 분류한 Enum Type enum Summary: String { case day1, day2, day3, day4, day6, day7, day8, day9, day11, day13, day16, day18 var keyword: String { switch self { case .day1: "Xcode, Swift" case .day2: "Linux, System" case .day3: "XML, JSON" case .day4: "Heap, Stack" case .day6: "Object, Class" case .day7: "File Path, UnitTest" case .day8: "Immutable, Closure" case .day9: "Event, Publisher" case .day11: "Async, EventLoop" case .day13: "Git, Objects" case .day16: "HTTP, SQL" case .day18: "Network, Server" } } } ``` - TCPSocketIO 기능 확장 ```swift final class TCPSocketIO { private var listener: NWListener? private var clients = [Client]() private var groupChatCount: [Int: (client: Client, count: Int)] = [:] private var claps = 0 init() {...} /// Client에게 요청을 받고, 명령어에 따라 처리해주는 메서드 private func handleConnection(_ connection: NWConnection) { connection.start(queue: .global()) /// 최대길이 1024, 최소 길이 4로 지정 connection.receive(minimumIncompleteLength: 4, maximumLength: 1024) { data, context, isComplete, error in //.. 중략 if let data = data, !data.isEmpty { guard let message = String(data: data, encoding: .utf8) else { return connection.cancel() } let components = message .replacingOccurrences(of: "\r\n", with: "") .components(separatedBy: .whitespaces) /// 입력받은 명령어에 따라 Case를 나눠 실행 /// - components: 입력받은 명령어 /// - connection: 요청을 보낸 Client의 connection if let command = Command(rawValue: components[0]) { switch command { case .checkin: self.checkin(components: components, connection: connection) case .checkout: self.checkout(connection: connection) case .summary: self.summary(components: components, connection: connection) case .chat: self.chat(components: components, connection: connection) case .finish: self.finish(components: components, connection: connection) case .broadcast: self.broadcast(components: components, connection: connection) case .direct: self.direct(components: components, connection: connection) case .clap: self.clap(connection: connection) case .history: self.history(connection: connection) } } } self.handleConnection(connection) } } /// 요청한 Client의 connection을 clients배열에 추가합니다. func checkin(components: [String], connection: NWConnection) { /// group, session을 clients배열을 필터링 하며 판별 var group = 1 var session = 1 while true { let count = clients.filter { $0.group == group }.count if count < 4 { session = count + 1; break } group += 1 } let client = Client( id: components[1], connection: connection, checkInDate: .now, session: session, group: group, inputCommandList: [components.joined(separator: " ")] ) clients.append(client) print(">> checkin \(client.id) (success) from \(connection.endpoint) => session#\(client.session), group#\(client.group)") } /// 요청한 Client의 connection을 clients배열에서 삭제한다. func checkout(connection: NWConnection) { guard let index = clients.firstIndex(where: { $0.connection === connection }) else { return } let removedClient = clients.remove(at: index) let clients = clients.filter { $0.group == removedClient.group } /// connection을 끊어줌 connection.cancel() sendMessageToClients(clients, "< broadcast from server, \"\(removedClient.id)님이 퇴장하셨습니다.\"\n") print(">> checkout from session#\(removedClient.session)(\(removedClient.id)) - disconnected") } /// 요청한 Client의 connection에만 Summary Enum으로 구분하여 맞는 keyword를 출력해준다. func summary(components: [String], connection: NWConnection) { guard let client = clients.first(where: { $0.connection === connection }) else { return } client.inputCommandList.append(components.joined(separator: " ")) guard let summary = Summary(rawValue: components[1]) else { return } sendMessageToClients([client], "< keywords are \"\(summary.keyword)\"\n") print(">> summary from session#\(client.session)(\(client.id)) : \(summary.rawValue) => \"\(summary.keyword)\"") } /// 입력한 maxCount의 값 만큼 groupChatCount Dictionary에 client와 maxCount추가한다. func chat(components: [String], connection: NWConnection) { guard let client = clients.first(where: { $0.connection === connection }) else { return } let clients = clients.filter { $0.group == client.group } guard let count = Int(components[1].components(separatedBy: "=")[1]) else { return } groupChatCount[client.group] = (client, count) client.inputCommandList.append(components.joined(separator: " ")) sendMessageToClients(clients, "< broadcast from server, \"채팅이 시작되었습니다\"\n") print(">> chat from session#\(client.session)(\(client.id))") } /// finish명령어 입력 시 groupChatCount Dictionary를 nil로 바꿔준다. func finish(components: [String], connection: NWConnection) { guard let client = clients.first(where: { $0.connection === connection }) else { return } client.inputCommandList.append(components.joined(separator: " ")) if groupChatCount[client.group]?.client === client { groupChatCount[client.group] = nil let clients = clients.filter { $0.group == client.group } sendMessageToClients(clients, "< broadcast from server, \"채팅이 종료되었습니다\"\n") print(">> finish from session#\(client.session)(\(client.id))") } } /// chat 명령어를 통해 maxCount 입력 후 `broadcast` 명령어 수행 가능 /// `broadcast` 호출 시 `groupChatCount` 1 씩 감소 0이 되면, 채팅 종료 func broadcast(components: [String], connection: NWConnection) { guard let client = clients.first(where: { $0.connection === connection }) else { return } client.inputCommandList.append(components.joined(separator: " ")) if groupChatCount[client.group]?.count ?? 0 > 0 { groupChatCount[client.group]?.count -= 1 let clients = clients.filter { $0.group == client.group } let message = components[1...].joined(separator: " ") sendMessageToClients(clients, "< broadcast from \(client.id), \(message)\n") if groupChatCount[client.group]?.count == 0 { sendMessageToClients(clients, "< broadcast from server, \"채팅이 종료되었습니다\"\n") } print(">> broadcast to session#\(client.session)(\(client.id)) => text=\(message)") } } /// 그룹 상관없이 특정 클라이언트에게 메시지 전송 func direct(components: [String], connection: NWConnection) { guard let client = clients.first(where: { $0.connection === connection }) else { return } let targetClient = clients.filter { $0.id == components[2] } let message = components[3...].joined(separator: " ") client.inputCommandList.append(components.joined(separator: " ")) sendMessageToClients(targetClient, "< direct from \(client.id), \(message)\n") print(">> direct to session#\(client.session)(\(client.id)) => text=\(message)") } /// Client가 서버에게 clap 명령어를 입력하면 서버는 `claps`의 카운트를 올려 해당 Client에게 메시지를 보낸다. func clap(connection: NWConnection) { guard let client = clients.first(where: { $0.connection === connection }) else { return } claps += 1 sendMessageToClients([client], "< clap count is \(claps)\n") print(">> clap from session#\(client.session)(\(client.id)) => \(claps)") } func history(connection: NWConnection) { guard let client = clients.first(where: { $0.connection === connection }) else { return } /// forEach를 통해 `inputCommandList` 배열을 순회하여 클라이언트가 입력했던 명령어를 해당 클라이언트에게 보내준다. /// `enumerated()` 메서드를 통해 인덱스 추출 client.inputCommandList .enumerated() .forEach { (index, command) in sendMessageToClients([client], "> \(index + 1)\t\(command)\n") } } /// 클라이언트에게 메시지를 전송하는 메서드 /// - Parameters: /// - clients: 메시지를 전송 받을 클라이언트 배열 /// - message: 전송 받을 메시지 func sendMessageToClients(_ clients: [Client], _ message: String) { let data = Data(message.utf8) for client in clients { client.connection.send( content: data, completion: .contentProcessed { sendError in if let sendError = sendError { print("Send error: \(sendError)") } } ) } } deinit {...} } ``` ## 학습 메모 - [TCP / UDP](https://nordvpn.com/ko/blog/tcp-udp-comparison/) - [Network](https://developer.apple.com/documentation/network) - [NWListener](https://developer.apple.com/documentation/network/nwlistener) - [NWConnection](https://developer.apple.com/documentation/network/nwconnection)