이번 포스터에서는 TCP/IP 기반의 소켓 프로그래밍에 대해 정리하고자 합니다. TCP/IP 기반이라는 말에서 알 수 있듯이 소켓은 TCP/IP 기반 통신뿐만 아니라, 블루투스 통신, 직렬 통신에서도 사용됩니다. TCP/IP, 블루투스는 단일 프로토콜이 아닌 해당 통신에 사용되는 모든 프로토콜의 집합을 의미합니다. ‘TCP/IP 소켓 프로그래밍’이라는 책으로 학습하였습니다. 아래 이미지는 TCP 송수신이 이루어지는 과정을 시각적으로 보여주고 있습니다. 이 이미지는 ‘널널한 개발자’라는 유튜브 영상을 보고 작성한 것입니다. 소켓 통신과 네트워크에 대해 시각적으로 파악하는 데 큰 도움이 되었습니다.

socket-tcp


Socket


소켓을 데이터 타입, 통신 종단점, 네트워크 프로그래밍 인터페이스 세 관점에서 보면 조금 더 구체적으로 인식할 수 있습니다.

  • 데이터 타입

    소켓은 커널 오브젝트이고 운영체제에 의해 생성, 종료, 관리되어집니다. 그래서, 소켓을 생성하고 반환하는 값은 해당 커널 오브젝트를 가리키는 정수입니다.(정확한 것은 아니지만, 내부적으로 테이블 인덱스를 사용하는 것 같습니다.) 윈도우 프로그래밍에 핸들, 리눅스 프로그래밍에서는 파일 디스크립터와 유사한 개념입니다.

  • 통신 종단점

    소켓은 응용 프로그램 관점(통신이 되는 대상)에서 통신 종단점, 통신의 출발점과 도착점으로 볼 수 있습니다.

  • 네트워크 프로그래밍 인터페이스

    TCP/IP 프로토콜 관점에서 응용 계층과 전송 계층 사이에 위치하는 인터페이스입니다. 소켓 인터페이스를 사용하여 TCP나 UDP 프로토콜에 접근할 수 있고, 필요시 전송 계층을 건너뛰고 인터넷 프로토콜을 직접 사용할 수도 있습니다.


소켓 구조


  • 소켓 주소 구조체

    소켓 통신을 위해서는 소켓 생성뿐만 아니라 통신을 할 두 단말 간의 주소 정보를 소켓에 알려주어야 합니다. 이를 위해 소켓 주소 구조체를 생성하고 IP 주소, 포트 번호, IP 체계를 설정하여 생성된 소켓과 바인드합니다. 그리고, 호스트와 서버 각각의 주소 정보(IP, 포트)가 소켓과 바인드되어 있어야만 실제 통신이 가능합니다. bind(), accept(), connect(), sendto() 함수들은 TCP/IP 소켓 프로그래밍에서 이러한 역할을 내부적으로 혹은 명시적으로 수행합니다. 예를 들어, sendto() 함수는 UDP 프로토콜 프로그래밍에서 사용되는데, 서버의 주소 구조체와 소켓을 매개 변수로 넣어주면 함수 내부에서 서버 주소 정보뿐만 아니라 호스트 주소 정보도 결정합니다.

  • 바이트 정렬

    바이트 정렬은 메모리에 데이터를 저장할 때 바이트의 배치 순서를 나타내는 용어입니다. 빅 엔디엔 방식과 리틀 엔디엔 방식이 있으며, 네트워크 통신에서는 주로 빅엔디엔 방식을 이용합니다. 흔히, 빅엔디엔 정렬을 네트워크 정렬이라고 부릅니다. 숫자와 네트워크 정렬, 문자열을 네트워크 정렬로 변환시켜 주는 함수들이 있으며 hton, ntoh 함수는 전자 inet_pton, inet_ntop는 후자의 역할을 합니다. 주소 구조체에 포트 번호와 IP 주소를 설정할 때, 주로 사용합니다.


TCP 통신


  • TCP 서버 - 클라이언트 동작 순서는 다음과 같습니다. (코드 참고-https://github.com/Jaeun-Choi98/network-programming-practice/tree/main/Windows/Chapter04)

    1. 서버는 소켓을 생성(socket)하고 listen 상태로 바꿔 클라이언트가 접속하기를 기다립니다.
    2. 클라이언트가 서버에 connect하여 데이터를 보냅니다(send).
    3. 서버는 클라이언트 접속을 수용(accept)하고, 클라이언트가 보낸 데이터를 받아(recv) 처리합니다. 이때, accept가 완료되면 새로운 소켓을 생성하여 클라이언트와 통신합니다. 추가적으로 accept() 함수는 서버를 대기 상태로 만들며 이때 서버의 CPU 사용률이 0%가 됩니다.
    4. 데이터를 주고받은 후, 한 쪽에서 접속을 끊으면 (closesocket) 다른 한 쪽도 소켓을 닫습니다. 이때, recv() 함수는 0을 리턴합니다. (UDP의 경우 recvfrom() 함수를 사용하는데, 0을 리턴해도 정상 종료가 아닌, 데이터의 최소 크기를 0으로 설정했다는 뜻입니다. 이는 UDP가 비연결성을 가지기 때문입니다.)
  • 데이터 송/수신

    TCP 프로토콜을 사용해서 데이터를 송/수신하는 함수로는 send(), recv()함수가 있습니다. 여기서 소켓이 블로킹 소켓과 넌블로킹 소켓이 있으며, 어느 소켓인지에 따라 송/수신 함수의 데이터 전송량이 달라질 수 있습니다.

    • 블로킹 소켓

      블로킹 소켓은 소켓 함수 호출 시 조건을 만족하지 않으면 함수가 리턴하지 않고 스레드 실행이 정지합니다.

      • send()

        send() 함수는 소켓 송신 버퍼의 여유 공간이 len보다 작을 경우 send함수를 호출한 프로세스는 대기 상태가 됩니다. 그게 아니라면, 응용 프로그램이 송신 버퍼에 데이터를 복사하고 복사한 데이터를 리턴합니다. 이 경우 send() 함수의 리턴값은 len과 같습니다.

      • recv()

        수신 버퍼에 데이터가 1바이트 이상 도착하고, 응용 프로그램 버퍼에 데이터를 복사할 때까지 대기 상태가 됩니다. 복사한 데이터가 있다면 복사한 데이터를 리턴합니다. 이때, 리턴값은 1~len 입니다.

    • 넌블로킹 소켓

      넌블로킹 소켓은 소켓 함수 호출 시 조건을 만족하지 않더라도 함수가 리턴하므로 스레드가 중단 없이 다음 코드를 수행합니다. 이때, 조건을 만족하지 않으면 함수 리턴 값은 WSAEWOULDBLOCK 오류 값을 반환합니다.

      • send()

        소켓 송신 버퍼의 여유 공간만큼 데이터를 복사한 후, 실제 복사한 바이트 수를 리턴합니다. 이 경우 send() 함수의 리턴값은 1~len과 같습니다.

      • recv()

        수신 버퍼에 데이터가 도착하지 않더라도 리턴합니다. 이때, 리턴값은 1~len 입니다.

  • 데이터 처리

    TCP는 스트림 소켓으로 보낸 데이터의 경계를 구분하지 않는다는 특징이 있습니다. 예를 들어, 클라이언트에서 100, 200 바이트를 차례로 보내면 서버 소켓 수신 버퍼에 300 바이트가 도착하고 응용 프로그램 수준에서 최대 1~len 만큼 복사가 일어납니다. 그래서, 응용 프로그램 수준에서 데이터 경계를 구분하기 위한 추가 작업(상호 약속)을 해야합니다.

    • 고정 길이 데이터 송/수신

      수신할 때, MAS_WAITALL 옵션을 사용하고, 송신할 때는 고정 길이만큼 데이터를 가공해서 보냅니다.

    • 가변 길이 데이터 송/수신

      1바이트씩 읽어 EOR을 만날 때까지 수신합니다.

    • 고정 길이 + 가변 길이

      recv() 함수에 MSG_WAITALL 옵션을 주어 첫 번째는 가변 길이에 대한 길이 정보를 얻고, 두 번째는 가변 길이만큼 데이터를 수신합니다.

    • EOR로 특별한 데이터 패턴 대신 연결 종료

      recv() 함수를 읽어 0(연결 종료)을 만날 때까지 수신합니다.


UDP 통신


UDP 프로토콜은 비연결형, 신뢰성 없는 데이터 전송(데이터 재전송x)이고, 데이터 경계를 구분하는 데이터그램 소켓이라 데이터를 처리할 필요가 없습니다. 그래서, UDP를 이용하는 응용 프로그램에서 신뢰성 있는 데이터 전송을 하기 위해서는 데이터 재전송과 데이터 순서 유지라는 두 가지 기능을 구현해야합니다. 이러한 부분이 UDP 기반 프로그래밍의 힘든 점이라 생각합니다. UDP는 TCP와 달리 멀티 스레드를 사용하지 않고도 한 소켓으로 여러 클라이언트를 처리할 수 있습니다.

  • UDP 서버-클라이언트 동작 순서는 다음과 같습니다. (코드 참고- https://github.com/Jaeun-Choi98/network-programming-practice/tree/main/Windows/Chapter08)

    1. 서버는 소켓을 생성하고 클라이언트가 데이터를 보낼 때까지 대기합니다.
    2. 클라이언트는 연결 설정 없이 서버와 데이터를 전송합니다.
    3. 서버와 클라이언트는 각각 작업을 마치면 closesocket()함수로 소켓을 닫습니다.
  • sendto()

    sendto() 함수의 경우, 리턴 조건은 send() 함수와 같아 넌블로킹/블로킹 소켓을 사용할 때 리턴 값은 같습니다. 이때, 독립적인 UDP 패킷(데이터그램)으로 만들어져 전송합니다.

  • recvfrom()

    recvfrom() 함수의 경우, 리턴 조건은 UDP 패킷이 완전히 도착해서 수신 버퍼의 데이터를 응용 프로그램 버퍼에 복사 했을 때입니다. 이때, UDP 패킷 데이터를 한 번에 하나만 읽을 수 있습니다. 블로킹/넌블로킹 소켓일 때, 리턴값은 1~len과 같습니다.


다중 처리 문제와 교착 상태


위에서 설명한 TCP 서버의 경우 동시에 두 개 이상의 클라이언트를 처리할 수 없습니다. 그리고 TCP서버와 UDP 서버 모두 클라이언트의 함수 호출 순서가 맞지 않는 경우 교착 상태가 발생할 수 있습니다. 이러한 문제를 해결하는 방법은 다음과 같습니다.

  • 다중 처리 문제

    • 서버가 각 클라이언트와 연결하여 통신하는 시간을 짧게 줄입니다. 클라이언트 수가 많아지거나 대용량 데이터를 전송하는 응용 프로그램을 구현하는 데는 적합하지 않습니다.

    • 클라이언트마다 스레드를 생성합니다.멀티 스레드는 여러 작업이 동시에 처리되는 것처럼 보이지만, 실제로는 CPU가 여러 스레드를 짧은 시간 간격으로 번갈아 수행합니다. 따라서 늘린다고 성능이 향상되지는 않습니다. 또한, 코드 실행(CPU 명령 처리)과 입출력 작업을 병행할 수 없습니다.

    • 소켓 입출력 모델을 사용합니다. 교착 상태와 다중 처리 문제를 해결할 수 있고, 성능과 시스템 자원 효율성이 좋습니다.

  • 교착 상태

    • 소켓의 타임 옵션을 적용합니다.
    • 넌블로킹 소켓을 사용합니다.
    • 소켓 입출력 모델을 사용합니다.
  • 스레드 동기화

    멀티 스레드를 이용하는 프로그램에서 두 개 이상의 스레드가 공유 데이터에 접근하는 경우, 모순성이 생길 수 있습니다. 따라서 공유 데이터에 접근하고자 할 때, 동기화 작업이 필요합니다. 여러 동기화 방법이 있지만, 이 포스터에서는 임계 영역 방법과 이벤트 방법에 대해 정리하고자 합니다.

    • 임계 영역

      개별 프로세스의 사용자 메모리 영역에 존재하는 단순한 구조체입니다. 그래서, 다른 프로세스가 접근할 수 없고 한 프로세스에 속한 스레드 간의 동기화에만 사용 가능합니다.

      1. 임계 영역을 전역 변수로 생성합니다.
      2. 임계 영역을 호출하여 초기화합니다.
      3. 공유 자원을 접근하기 전에 Enter*() 함수를 호출합니다. 이때, 다른 스레드에서 접근하고 있다면 대기 상태가 됩니다.
      4. 공유 자원 접근이 끝나면, Leave*() 함수를 호출합니다. 이때, 대기 중인 다른 스레드가 있다면 하나만 선택되어 깨어납니다.
      5. 모든 스레드가 종료되면, 임계 영역을 삭제합니다.
    • 이벤트

      동기화 객체를 사용하여, 사건 발생을 다른 스레드에 알리는 동기화 기법입니다.

      1. 이벤트 객체를 프로세스 순서 방식에 따라 신호/비신호 상태로 생성합니다.
      2. 한 스레드가 작업을 진행하고, 나머지 스레드는 이벤트에 대해 Wait*() 함수를 호출하여 이벤트가 신호 상태가 될 때까지 대기합니다.
      3. 스레드가 작업을 완료하면 이벤트를 신호 상태로 바꾼다. (Auto-Reset일 경우 자동적으로 비신호 상태로 바뀝니다.)
      4. 대기 중인 스레드 중 한 개 또는 전부 깨어납니다. 2~4를 반복하면서 작업이 끝나면, 이벤트 객체를 제거합니다.


소켓 옵션


소켓 옵션은 처리 주체에 따라 크게 두 종류로 구분할 수 있습니다. setsockopt() 함수와 getsockopt() 함수를 사용하여 옵션을 설정하거나 옵션의 값을 얻어 옵니다.

  • 소켓 코드가 처리하는 옵션

    대표적으로 SOL_SOCKET 레벨 옵션이 있고, 옵션 종류로는 브로드캐스트, KEEPLIVE, LINGER, *TIMEO, REUSEADDR 등이 있습니다. 이중 REUSEADDR 옵션은 설정하는 것이 기본적으로 좋습니다. TCP 서버 종료 후 재실행 시 bind() 함수에서 오류가 발생하는 일을 방지하고, 여러 IP주소를 보유한 호스트에서 서버를 IP 주소별로 따로 운용할 수 있도록 합니다. 운영 체제에 따라 다를 수 있지만 코드 이식성을 고려하여 서버 작성 시 기본적으로 작성하는 것이 좋습니다.

  • 프로토콜 구현 코드가 처리하는 옵션

    IPPROTO_IP, IPPROTO_IP6, IPPROTO_TCP 레벨 옵션이 있습니다. 주로 멀티 캐스트를 이용하고자 할때 IP, IP6 레벨 옵션을 사용하고, TCP 레벨 옵션은 TCP 내부 연결 방식을 설정하여 TCP 성능을 제어할 수 있습니다. 대표적으로 TCP_NODELAY 옵션은 Nagle 알고리즘을 중지시켜 반응 속도를 높일 수 있습니다. 하지만, 네트워크 트래픽이 증가할 수 있습니다.


소켓 입출력 모델


소켓 입출력 모델은 다수의 소켓을 관리하고 소켓에 대한 입출력을 처리하는 방식을 말합니다. 소켓 프로그래밍에서 다수의 클라이언트를 효율적으로 처리하는 서버를 만들기 위해서는 소켓 입출력 모델을 이용하는 것이 효과적입니다.

동기/비동기 입출력, 비동기 통지에 대해 먼저 정리하고, Select, WSAAsyncSelect, Completion Port 모델에 대해 정리하고자 합니다.

  • 동기/비동기 입출력

    동기 입출력 함수는 TCP, UDP 서버에서 사용한 전형적인 송/수신 함수입니다. 입출력 함수를 호출하고 입출력 작업을 끝낸 후 함수가 리턴할 때까지 해당 프로세스는 대기합니다. 다중 처리에서 동기화 작업을 할 때와 유사합니다. 비동기 입출력은 입출력 함수를 호출하고 입출력 작업이 끝나기 전에 함수 호출과 동시에 리턴합니다. 여기서 중요한 점은 동기 입출력 함수를 쓴다고 해서 다중 처리가 불가능하지는 않는다는 것입니다. 하지만, 동기 입출력 함수를 사용한다면 CPU 명령 처리와 입출력 작업을 병행 처리하는 것은 불가능합니다.

  • 비동기 통지

    비동기 통지는 운영체제가 특정 사건 발생을 프로세스에 알리는 기능으로, 이는 프로세스가 관심을 가지는 사건에 따라 달라집니다. 입출력 작업의 완료 또는 입출력 함수 호출 가능 상태 등이 해당될 수 있습니다. 이러한 비동기 통지는 약속된 API 함수를 호출하여 가능합니다. 이에 따라 동기 입출력 함수를 사용하더라도, 약속된 API를 활용하여 다중 처리를 효과적으로 수행할 수 있습니다. 소켓 입출력 모델은 동기/비동기 입출력 함수와 함께 비동기 통지(약속된 API)를 이용하여 구현됩니다.

  • Select 모델

    Select 모델을 사용하면 소켓 함수 호출이 성공할 수 있는 시점을 미리 알 수 있습니다. 동작 방식은 다음과 같습니다. (코드 참고 - https://github.com/Jaeun-Choi98/network-programming-practice/blob/main/Windows/Chapter11/SelectTCPServer.cpp)

    1. 세 개의 소켓 셋(읽기, 쓰기, 예외)을 생성합니다.
    2. 호출할 함수에 따라 소켓 셋에 소켓을 담습니다. ex) accept(), recv() 읽기 셋, send() 쓰기 셋
    3. select() 함수를 호출하고 타임아웃이 NULL이면 조건을 만족하는 소켓이 하나라도 있을 때까지 리턴하지 않습니다.
    4. select() 함수가 리턴하면 소켓 셋에 함수 호출이 가능한 소켓만 남기고 모두 제거합니다.
    5. 호출 가능한 소켓에 대해 동기 입출력 함수를 호출 및 리턴하고 나머지 작업을 진행합니다.
    6. 2~5를 반복합니다.
  • WSAAsyncSelect 모델

    GUI 메시지 구동 구조를 활용하는 방식으로, WSAAsyncSelect() 함수를 사용하면 소켓과 관련된 네트워크 이벤트를 윈도우 메시지로 받을 수 있습니다. 이 과정에서 생성된 소켓을 리스트에 저장하고, 이벤트가 발생하면 해당 소켓을 리스트에서 찾아 함수를 호출하고 결과를 반환한 후, 나머지 작업을 진행합니다. (코드 참고-https://github.com/Jaeun-Choi98/network-programming-practice/blob/main/Windows/Chapter11/WSAAsyncSelectTCPServer.cpp)

  • Completion Port 모델

    Completion Port 모델은 입출력 완료 포트라는 운영 체제가 제공하는 구조를 사용하는 방법입니다. 입출력 완료 포트는 비동기 입출력 결과와 이 결과를 처리할 스레드에 관한 정보를 담고 있는 구조입니다. 동작 방식은 다음과 같습니다. 비동기 입출력 함수인 WSAsend() 함수와 WSArecv() 함수를 사용합니다. (코드 참고-https://github.com/Jaeun-Choi98/network-programming-practice/blob/main/Windows/Chapter11/CompletionPortTCPServer.cpp, Completion Port 모델을 이용한 프로젝트-https://github.com/Jaeun-Choi98/network-project)

    1. 입출력 완료 포트를 생성합니다.
    2. 작업자 스레드를 생성합니다. 이때, 모든 작업자 스레드는 GetQueuedCompletonStatus() 함수를 호출하여 대기 상태가 됩니다.
    3. 소켓을 생성하고 입출력 완료 포트에 연결시킵니다.
    4. 비동기 입출력 함수를 호출합니다. 이때, 입출력 작업이 완료되면 운영체제는 입출력 완료 포트에 결과를 저장합니다.
    5. 운영체제는 대기중인 작업자 스레드를 하나 선택해 깨웁니다. 깨어난 작업자 스레드는 비동기 입출력 결과를 처리합니다. 이후, 비동기 입출력 함수를 호출하여 4~5를 반복할 수 있습니다.