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)