Study/인터넷 & 네트워크 기초

소켓의 동작 원리

sowon02 2025. 11. 10. 13:11

네트워크 애플리케이션은 운영체제가 제공하는 소켓(Socket)을 통해 네트워크와 통신한다.

소켓은 쉽게 말해, 프로세스가 네트워크를 통해 데이터를 주고받기 위한 통신용 창구라고 할 수 있다.

 

내부적으로는 운영체제 커널의 TCP/IP 스택 위에서 동작하며,

데이터를 실제로 보내고 받는 건 이 커널 레벨의 소켓이 담당한다.

우리가 작성하는 코드는 결국 "커널에 통신을 부탁하는 코드"다. 
그래서 send()나 recv() 같은 함수를 호출하면, 실제 전송은 커널 내부에서 비동기적으로 진행된다.

 

오늘은 이 소켓이 어떻게 만들어지고, 연결되고, 데이터가 오가는지 

그리고 실제 서버 개발자가 알아야 할 부분까지 정리해보려 한다.


소켓 통신의 흐름 - 서버와 클라이언트 

소켓의 생성과 흐름

 

소켓 통신은 기본적으로 서버(Server)클라이언트(Client) 구조로 이루어진다.
서버는 요청을 기다리고, 클라이언트는 연결을 시도한다.

서버는 다음과 같은 순서로 동작한다.

socket() → bind() → listen() → accept() → read()/write() → close()
  1. socket() : 소켓을 만든다. 운영체제에게 “통신할 준비하자~” 하는 요청이다.
  2. bind() : IP 주소와 포트를 소켓에 연결한다. 즉, “이 주소로 들어오는 요청을 받을게 ! ” 선언하는 과정이다.
  3. listen() : 연결 요청을 받을 준비를 한다. 이제 서버는 클라이언트를 기다리는 상태다.
  4. accept() : 클라이언트가 연결을 시도하면, 그 연결을 받아들이고 새로운 소켓을 만들어 준다.
    (이때 accept()가 반환하는 소켓이 실제로 데이터 주고받는 소켓이다.)
  5. read()/write() : 데이터를 주고받는다.
  6. close() : 연결을 종료한다.

클라이언트는 더 간단하다. 

socket() → connect() → read()/write() → close()
  1. socket() : 소켓 생성
  2. connect() : 서버의 IP와 포트로 연결 요청 (TCP의 3-way handshake 과정이 여기서 이루어진다)
  3. read()/write() : 데이터 송수신
  4. close() : 연결 종료

정리하면, 서버는 “문을 열고 기다리는 역할”, 클라이언트는 “그 문을 두드리는 역할”이다.


TCP 연결의 원리

TCP는 신뢰성 있는 연결 지향형 프로토콜이다.
클라이언트와 서버는 데이터를 주고받기 전에 반드시 연결을 맺는다.

 

연결은 3-way handshake 과정으로 이루어진다.

  1. 클라이언트 → 서버 : SYN (연결 요청)
  2. 서버 → 클라이언트 : SYN + ACK (요청 수락)
  3. 클라이언트 → 서버 : ACK (최종 확인)
    3-way handshake 과정

이 세 단계를 거치면 연결이 성립되고, 이제 두 프로세스는 데이터를 안전하게 주고받을 수 있다.
TCP는 이 과정에서 패킷 순서, 손실, 재전송, 혼잡 제어 등을 모두 책임진다.

연결 종료는 4단계로 이뤄진다.

  1. 한쪽이 FIN을 보내 종료를 요청하면,
  2. 상대는 ACK로 응답하고,
  3. 반대 방향도 같은 절차를 거쳐 완전히 닫는다.

TCP 연결 종료 과정 (4-way handshake)

 

마지막으로 연결을 닫은 쪽은 TIME_WAIT 상태를 잠시 유지하는데,
이건 늦게 도착한 패킷을 처리하기 위해서다.
그래서 서버를 재시작할 때 Address already in use 에러가 날 수 있고, 이를 방지하려면 SO_REUSEADDR 옵션을 설정한다.


TCP와 UDP의 차이

두 프로토콜 모두 전송 계층에서 동작하지만,

TCP는 연결을 맺고 신뢰성을 보장하는 대신 속도가 느리고, UDP는 연결 없이 빠르지만 데이터 손실이 발생할 수 있다.

구분 TCP UDP
연결 방식 연결형 (3-way handshake) 비연결형
신뢰성 순서, 손실 복구 보장 보장하지 않음
데이터 단위 바이트 스트림 (프레이밍 필요) 데이터그램 (메시지 단위 유지)
속도 느림 빠름
대표 사용 예 HTTP, 파일 전송, DB 연결 스트리밍, DNS, 게임, 음성통화

TCP는 데이터를 연속적인 바이트 스트림으로 다루기 때문에, “어디서부터 어디까지가 한 메시지인지”를 프로그래머가 직접 구분해야 한다. 이를 프레이밍(Framing) 이라고 한다.

예를 들어, 메시지 길이 + 본문 형태로 보내거나, '\n' 같은 구분자를 사용하는 방식이 있다.

 

UDP는 반대로, 한 번 보낸 메시지가 한 덩어리로 유지된다.
하지만 도착을 보장하지 않기 때문에, 실시간성이 필요한 게임이나 스트리밍에 주로 사용된다.


커널 버퍼와 전송 과정

데이터는 단순히 프로그램 사이를 "직접" 오가는 게 아니다.

실제로는 이렇게 흐른다.

유저 공간 → 커널 소켓 버퍼 → 네트워크 카드 → 인터넷

 

send() 함수를 호출하면, 우리가 보낸 데이터는 커널의 송신 버퍼에 저장되고,

커널이 네트워크 스택을 통해 실제 전송을 처리한다.

 

이때 버퍼가 가득 차면,

 

  • 블로킹 모드에서는 send() 함수가 멈춘다.
  • 논블로킹 모드에서는 EAGAIN 에러를 반환한다.

TCP에는 작은 데이터를 효율적으로 묶어 보내는 *Nagle 알고리즘이 있다.

하지만 채팅처럼 지연이 민감한 프로그램에서는 TCP_NODELAY 옵션으로 이 기능을 끈다고한다.

 

*Nagle 알고리즘

: 네트워크를 통해 전송되는 패킷의 수를 줄여 TCP/IP 네트워크의 효율성을 향상시키는 방법/네트워크 효율성은 높아지지만 ACK를 기다리는 시간 때문에 데이터 전송에 지연이 발생하는 단점이 있음

 


블로킹과 논블로킹 / 멀티플렉싱

소켓은 두 가지 방식으로 동작할 수 있다.

 

  • 블로킹 소켓: 데이터가 도착할 때까지 기다린다. 구현은 단순하지만, 하나의 연결만 처리할 수 있다.
  • 논블로킹 소켓: 즉시 반환하며, “지금은 읽을 게 없다면” EAGAIN 에러를 낸다.

논블로킹을 쓰려면 "지금 어떤 소켓이 준비됐는지"를 감시해야 한다. 이 역할을 하는게 바로 I/O 멀티 플렉싱이다. 

I/O멀티 플렉싱이란, 하나의 스레드가 여러 소켓을 동시에 감시하기 위해 사용하는 기술이다.

대표적인 방식으로는 :

  • select() : 오래된 방식, 파일 디스크립터 개수 제한이 있다.
  • poll() : 제한은 없지만 매번 전체를 순회해야 한다.
  • epoll(리눅스), kqueue(BSD/macOS) : 이벤트 기반으로, 수천 개 이상의 연결을 효율적으로 처리할 수 있다.

이 구조를 잘 이해하면,
Nginx, Redis, Node.js 같은 고성능 서버의 기본 원리를 이해할 수 있다.

 


데이터 송수신의 세부 동작

TCP는 "한 번에 전송된 데이터가 한 번에 읽힌다"는 보장이 없다.

예를들어 4KB를 보냈다고 해도, 수신 측에서는 2KB + 2KB로 나눠서 읽을 수 있다.

이걸 부분 읽기(Partial Read)라고 한다.

 

반대로, 여러 번 write()한 데이터가 한 번의 read()로 합쳐질 수도 있는데, 이건 스티키 패킷(Sticky Packet) 현상이다.

이런 이유로 프레이밍 규칙이 반드시 필요하다.

 

shutdown()을 이용하면 송신만 닫거나, 수신만 닫는 half-close도 가능하다.

예를들어 HTTP 1.0에서 서버가 응답 후 송신을 닫는 구조가 이런 방식이다. 

 

예시코드 - TCP 에코 서버 

# TCP 에코 서버
import socket

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)  # TIME_WAIT 대비
srv.bind(("0.0.0.0", 9000))
srv.listen(128)

while True:
    conn, addr = srv.accept()
    data = conn.recv(4096)
    if not data:
        conn.close()
        continue
    conn.sendall(data)  # 받은 데이터를 그대로 되돌려줌
    conn.close()

 

이 코드는 TCP 에코 서버다.
SO_REUSEADDR 옵션은 서버를 재시작할 때 “Address already in use” 오류를 피하기 위해 설정한다.
사실 recv()는 한 번에 데이터를 다 읽어오지 못하는 경우도 있어서 반복문으로 돌려야 하지만,
아직 그 부분은 완전히 감을 못 잡았다..... 그냥 지금은 구조만 이해해보자는 느낌으로 적어봤다.

 

클라이언트 예시 - Java

// TCP 클라이언트 
import java.io.*;
import java.net.*;

public class EchoClient {
  public static void main(String[] args) throws Exception {
    try (Socket s = new Socket("127.0.0.1", 9000)) {
      s.setTcpNoDelay(true); // 작은 데이터 지연 최소화
      OutputStream out = s.getOutputStream();
      InputStream in = s.getInputStream();

      out.write("hello\n".getBytes());
      out.flush();

      byte[] buf = new byte[4096];
      int n = in.read(buf);
      System.out.println(new String(buf, 0, n));
    }
  }
}

 

 

여 기서 setTcpNoDelay(true) 는 Nagle 알고리즘을 끄는 설정이다. 작은 메시지를 보낼 때 딜레이 없이 즉시 전송되도록 만든다.


운영 환경에서 고려할 점

  • 작은 패킷을 자주 보낸다면 Nagle 알고리즘 비활성화 또는 배칭(batch) 설계를 고려해야 한다.
  • 대용량 데이터를 보낼 때는 송신 버퍼가 꽉 차면 백프레셔(backpressure) 처리가 필요하다.
  • TCP 연결은 한 번 끊어지면 재사용되지 않으므로, 재시도 정책타임아웃 설정이 중요하다.
  • 대규모 서버에서는 epoll 기반 비동기 구조와 함께 커널 파라미터(somaxconn, rmem_max, wmem_max)를 조정한다.
  • EC2나 클라우드 환경에서는 반드시 보안 그룹 인바운드 규칙에 포트를 허용해야 외부 접속이 가능하다.

정리 및 요약

소켓은 단순히 send() 와 recv() 함수가 아니다.
프로세스가 운영체제의 네트워크 스택을 거쳐 실제로 데이터를 주고받는 표준 인터페이스다.


TCP는 신뢰성과 순서를 보장하지만, 그만큼 개발자가 알아야 할 디테일(버퍼, 프레이밍, 부분 읽기)이 많다.

소켓을 이해하면 네트워크의 동작 원리가 눈에 들어오고, 그 위에서 만들어진 WebSocket 같은 기술이 훨씬 쉽게 보인다.

이제 소켓의 원리를 어느 정도 이해했으니,
다음 글에서는 웹 환경에서 이 소켓을 확장한 WebSocket 구조를 정리해보려 한다.