[Swift 입문] 29편 — TCP 소켓과 네트워크 통신

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: [28편] SQLite와 데이터베이스

네트워크 통신의 기초 — TCP/IP

앱이 인터넷을 통해 서버와 대화하거나, 같은 컴퓨터 안에서 두 프로세스가 대화할 때 사용하는 가장 기본적인 메커니즘이 소켓(Socket)입니다.

소켓은 두 프로그램이 데이터를 주고받기 위해 여는 “연결 창구”입니다. 편지함에 비유하면, 소켓은 주소가 붙은 우편함이고, 데이터는 그 안에 넣는 편지입니다.

TCP(Transmission Control Protocol)는 가장 널리 쓰이는 소켓 유형입니다.

  • 신뢰성: 데이터가 반드시 도착하고, 순서도 보장됩니다
  • 스트림 기반: 바이트 흐름으로 통신합니다
  • 연결 지향: 통신 전 연결(핸드셰이크)이 필요합니다

URLSession — HTTP 통신

대부분의 앱은 REST API를 통해 서버와 통신합니다. Swift에서는 URLSession이 이를 담당합니다.

import Foundation

// GET 요청
func fetchPosts() async throws -> [Post] {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
    let (data, response) = try await URLSession.shared.data(from: url)

    // HTTP 상태 코드 확인
    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {
        throw URLError(.badServerResponse)
    }

    return try JSONDecoder().decode([Post].self, from: data)
}

// POST 요청
func createPost(title: String, body: String) async throws -> Post {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!

    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    let payload = ["title": title, "body": body, "userId": 1] as [String: Any]
    request.httpBody = try JSONSerialization.data(withJSONObject: payload)

    let (data, _) = try await URLSession.shared.data(for: request)
    return try JSONDecoder().decode(Post.self, from: data)
}

Network 프레임워크 — TCP 소켓 직접 사용

HTTP가 아닌 커스텀 프로토콜이나 낮은 레벨의 TCP 통신이 필요할 때는 Apple의 Network 프레임워크를 사용합니다.

TCP 클라이언트

import Network

class TCPClient {
    private var connection: NWConnection?
    private let queue = DispatchQueue(label: "tcp.client")

    func connect(host: String, port: UInt16) {
        let endpoint = NWEndpoint.hostPort(
            host: NWEndpoint.Host(host),
            port: NWEndpoint.Port(rawValue: port)!
        )

        connection = NWConnection(to: endpoint, using: .tcp)

        connection?.stateUpdateHandler = { state in
            switch state {
            case .ready:
                print("연결됨")
                self.receive()
            case .failed(let error):
                print("연결 실패: \(error)")
            case .cancelled:
                print("연결 취소")
            default:
                break
            }
        }

        connection?.start(queue: queue)
    }

    // 데이터 송신
    func send(_ text: String) {
        guard let data = text.data(using: .utf8) else { return }

        connection?.send(content: data, completion: .contentProcessed { error in
            if let error = error {
                print("전송 실패: \(error)")
            }
        })
    }

    // 데이터 수신 (반복 호출)
    private func receive() {
        connection?.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, isComplete, error in
            if let data = data, !data.isEmpty {
                let text = String(data: data, encoding: .utf8) ?? "(바이너리)"
                print("수신: \(text)")
            }

            if isComplete {
                print("연결 종료")
            } else if error == nil {
                self.receive()  // 다음 데이터 대기
            }
        }
    }

    func disconnect() {
        connection?.cancel()
    }
}

TCP 서버

class TCPServer {
    private var listener: NWListener?
    private let queue = DispatchQueue(label: "tcp.server")
    private var connections: [NWConnection] = []

    func start(port: UInt16) throws {
        let parameters = NWParameters.tcp
        listener = try NWListener(using: parameters,
                                   on: NWEndpoint.Port(rawValue: port)!)

        listener?.stateUpdateHandler = { state in
            switch state {
            case .ready:
                print("서버 시작됨 — 포트 \(port)")
            case .failed(let error):
                print("서버 오류: \(error)")
            default:
                break
            }
        }

        // 새 클라이언트 연결 수락
        listener?.newConnectionHandler = { [weak self] connection in
            print("새 클라이언트 연결")
            self?.connections.append(connection)
            self?.handleConnection(connection)
        }

        listener?.start(queue: queue)
    }

    private func handleConnection(_ connection: NWConnection) {
        connection.stateUpdateHandler = { state in
            if case .ready = state {
                self.receiveLoop(connection)
            }
        }
        connection.start(queue: queue)
    }

    private func receiveLoop(_ connection: NWConnection) {
        connection.receive(minimumIncompleteLength: 1, maximumLength: 4096) { data, _, isComplete, error in
            if let data = data, let text = String(data: data, encoding: .utf8) {
                print("클라이언트로부터 수신: \(text)")

                // 에코 응답
                connection.send(content: data, completion: .idempotent)
            }

            if !isComplete && error == nil {
                self.receiveLoop(connection)
            }
        }
    }

    func stop() {
        listener?.cancel()
        connections.forEach { $0.cancel() }
    }
}

TCP 스트림의 핵심 문제 — 메시지 경계

TCP는 바이트 스트림입니다. “1000바이트 메시지를 보냈다”고 해서 받는 쪽이 한 번에 1000바이트를 받는다는 보장이 없습니다. 200바이트씩 5번에 나눠서 올 수도 있고, 다른 메시지와 합쳐서 올 수도 있습니다.

이 문제를 해결하는 방법이 메시지 프레이밍(Framing)입니다. 다음 편에서 자세히 다룹니다.

// 잘못된 방식 — TCP는 경계를 보장하지 않음
connection.send(content: messageData, ...)  // 보낸 쪽
connection.receive(...) { data in
    // 받는 쪽: data가 전체 메시지라는 보장이 없음!
}

// 올바른 방식 — 길이 접두사로 경계 표시 (다음 편에서 상세히)
var frame = Data()
var length = UInt32(messageData.count).bigEndian
frame.append(Data(bytes: &length, count: 4))  // 앞 4바이트: 길이
frame.append(messageData)                      // 나머지: 실제 데이터

async/await와 Network 프레임워크

// Swift Concurrency와 함께 사용하는 래퍼
actor TCPClientActor {
    private var connection: NWConnection?
    private let queue = DispatchQueue(label: "tcp.actor")

    func connect(host: String, port: UInt16) async throws {
        let endpoint = NWEndpoint.hostPort(
            host: NWEndpoint.Host(host),
            port: NWEndpoint.Port(rawValue: port)!
        )
        let conn = NWConnection(to: endpoint, using: .tcp)

        // continuation으로 콜백 → async 변환
        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            conn.stateUpdateHandler = { state in
                switch state {
                case .ready:
                    continuation.resume()
                case .failed(let error):
                    continuation.resume(throwing: error)
                default:
                    break
                }
            }
            conn.start(queue: self.queue)
        }

        self.connection = conn
    }

    func send(_ data: Data) async throws {
        guard let connection else { throw URLError(.notConnectedToInternet) }

        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
            connection.send(content: data, completion: .contentProcessed { error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume()
                }
            })
        }
    }
}

다른 언어와 비교

언어 TCP 소켓 API
Swift (macOS) Network 프레임워크 (NWConnection)
Python socket 모듈 (BSD 소켓 래핑)
Node.js net 모듈
Java/Kotlin java.net.Socket

Python 예시와 비교:

# Python TCP 클라이언트
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("localhost", 8080))
s.send(b"Hello")
data = s.recv(1024)
s.close()

Swift의 Network 프레임워크는 Python의 socket보다 추상화 수준이 높고, TLS/연결 상태 관리가 더 편리하게 통합되어 있습니다.


핵심 요약

  • TCP: 신뢰성 있는 바이트 스트림 프로토콜. 연결 후 양방향 통신
  • URLSession: HTTP/HTTPS 통신에 적합한 고수준 API
  • Network 프레임워크: TCP/UDP 소켓 직접 제어. NWConnection, NWListener
  • TCP는 메시지 경계를 보장하지 않음 — 프레이밍이 필요
  • withCheckedThrowingContinuation으로 콜백 기반 API를 async/await로 변환

다음 편에서는 같은 컴퓨터 내 프로세스 간 통신에 더 적합한 Unix Domain Socket과 메시지 경계 문제를 해결하는 길이-접두사 프레이밍을 배웁니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다