소켓 기초 — TCP 통신의 출발점

소켓은 두 프로그램이 네트워크 너머로 바이트를 주고받는 양 끝점입니다. TCP 소켓의 개념, Socket 클래스로 연결을 맺고 입력·출력 스트림으로 데이터를 주고받는 흐름, 그리고 자바 네트워킹의 토대가 되는 핵심 개념을 정리합니다.

· 9 min read · PALDYN Team

지난 글에서 데이터베이스에 접근하는 여러 도구를 한 바퀴 돌아봤습니다. 데이터베이스도 결국은 네트워크 너머의 또 다른 프로그램이었습니다. 이제 시야를 한 단계 넓혀, 자바 프로그램이 네트워크를 통해 다른 프로그램과 직접 통신 하는 가장 밑바닥의 방식인 소켓을 살펴봅니다. HTTP도, gRPC도, 데이터베이스 드라이버도 그 아래를 파보면 결국 이 소켓 위에서 동작합니다. 네트워킹을 제대로 이해하는 출발점이 바로 여기입니다.

소켓이란 무엇인가

소켓(socket)은 두 프로그램이 데이터를 주고받기 위한 양 끝점(endpoint) 입니다. 전화 통화에 비유하면, 전화기 두 대가 회선으로 연결되어 양쪽이 말하고 들을 수 있는 상태와 같습니다. 한쪽에서 말한 소리가 회선을 타고 반대쪽 수화기로 들리듯, 한 소켓에 쓴 바이트가 네트워크를 타고 반대쪽 소켓에서 읽힙니다.

소켓을 식별하는 주소는 IP 주소 + 포트 번호 의 조합입니다. IP 주소가 “어느 컴퓨터인가”를 가리킨다면, 포트 번호는 “그 컴퓨터의 어느 프로그램인가”를 가리킵니다. 한 대의 서버에서 웹 서버(80), 데이터베이스(5432), SSH(22)가 동시에 동작할 수 있는 것은 포트로 구분되기 때문입니다.

TCP 소켓 — 두 프로그램을 잇는 양방향 통로

위 그림에서 보듯 연결은 비대칭으로 시작됩니다. 한쪽은 능동적으로 연결을 요청 하고(클라이언트), 다른 쪽은 연결을 기다리다 수락 합니다(서버). 하지만 일단 연결이 맺어지면 양쪽은 대등합니다. 둘 다 읽을 수 있고 둘 다 쓸 수 있는 전이중(full-duplex) 통로가 만들어집니다.

TCP와 UDP — 두 가지 전송 방식

소켓이 사용하는 전송 계층 프로토콜에는 크게 두 가지가 있습니다. TCP 는 연결을 먼저 맺고(연결 지향), 보낸 순서대로, 빠짐없이 도착하는 것을 보장합니다. 중간에 패킷이 유실되면 재전송하고, 순서가 뒤바뀌면 다시 정렬합니다. 신뢰성이 중요한 대부분의 통신 — HTTP, 데이터베이스, 파일 전송 — 이 TCP를 씁니다.

UDP 는 연결 없이 패킷을 그냥 던집니다(비연결). 빠르지만 도착도, 순서도 보장하지 않습니다. 약간의 손실을 감수하더라도 지연이 적어야 하는 실시간 스트리밍이나 게임 같은 곳에서 쓰입니다. 자바에서 TCP는 Socket/ServerSocket, UDP는 DatagramSocket이 담당합니다. 이 글에서는 가장 널리 쓰이는 TCP 소켓을 중심으로 다룹니다.

Socket 클래스로 연결 맺기

자바에서 클라이언트가 서버에 연결하는 일은 놀랍도록 간단합니다. java.net.Socket의 생성자에 호스트와 포트를 넘기면, 그 호출이 끝나는 시점에 이미 연결이 맺어져 있습니다.

import java.net.Socket;

// 생성자가 반환되면 연결이 완료된 상태
Socket socket = new Socket("example.com", 80);

System.out.println("연결됨: " + socket.isConnected());
System.out.println("원격 주소: " + socket.getRemoteSocketAddress());

socket.close();

new Socket("example.com", 80) 한 줄 안에서 DNS 조회로 도메인을 IP로 바꾸고, TCP 3-way 핸드셰이크를 수행해 연결을 확립합니다. 이 모든 복잡한 과정이 생성자 호출 하나로 감춰져 있습니다. 연결을 다 쓰고 나면 반드시 close()로 닫아 운영체제 자원을 돌려줘야 합니다.

입력·출력 스트림으로 데이터 주고받기

연결된 소켓은 그 자체로는 통로일 뿐, 실제 데이터는 소켓에 붙은 스트림 을 통해 흐릅니다. getInputStream()은 상대가 보낸 바이트를 읽는 입력 스트림을, getOutputStream()은 상대에게 보낼 바이트를 쓰는 출력 스트림을 돌려줍니다.

Socket 하나에 입력·출력 스트림이 함께 붙는다

여기서 핵심은 소켓이 바이트 스트림을 다룬다는 점입니다. 우리가 이미 익혔던 자바 I/O — InputStream, OutputStream, 그리고 그 위의 BufferedReader, PrintWriter — 가 그대로 쓰입니다. 네트워크라고 해서 특별한 I/O API를 새로 배울 필요가 없습니다. 파일을 읽고 쓰던 방식 그대로, 대상만 네트워크 연결로 바뀔 뿐입니다.

다음은 서버에 한 줄을 보내고 한 줄을 응답으로 받는 최소한의 클라이언트입니다.

try (Socket socket = new Socket("example.com", 7)) {  // echo 포트
    var out = new PrintWriter(socket.getOutputStream(), true);
    var in  = new BufferedReader(
                  new InputStreamReader(socket.getInputStream()));

    out.println("안녕, 서버");          // 한 줄 전송
    String reply = in.readLine();       // 한 줄 수신
    System.out.println("응답: " + reply);
}

try-with-resources로 소켓을 감쌌기 때문에 블록을 벗어날 때 소켓이 자동으로 닫힙니다. 네트워크 코드는 예외와 자원 누수가 잦은 영역이므로, 소켓·스트림을 try-with-resources로 다루는 것이 거의 필수 관행입니다.

블로킹이라는 본질

소켓 I/O를 처음 다룰 때 가장 자주 부딪히는 개념이 블로킹(blocking) 입니다. in.readLine()을 호출했는데 상대가 아직 아무것도 보내지 않았다면, 이 호출은 데이터가 도착할 때까지 그 자리에서 멈춰 기다립니다. 스레드가 그 줄에 묶여 다른 일을 하지 못하는 것입니다.

이 블로킹 특성은 단순한 클라이언트에서는 문제가 되지 않지만, 수천 개의 연결을 동시에 다뤄야 하는 서버에서는 큰 고민거리가 됩니다. 연결마다 스레드를 하나씩 묶어두면 스레드가 금방 고갈되기 때문입니다. 이 문제를 풀기 위한 NIO의 논블로킹 채널, 그리고 최근의 가상 스레드는 앞선 글들에서 이미 다뤘던 주제와 맞닿아 있습니다. 지금은 “기본 소켓 I/O는 블로킹이다”라는 사실만 기억해 두면 충분합니다.

정리

소켓은 두 프로그램이 네트워크 너머로 바이트를 주고받는 양 끝점이며, IP 주소와 포트 번호의 조합으로 식별됩니다. 신뢰성 있는 통신에는 TCP를, 빠른 비연결 전송에는 UDP를 쓰고, 자바에서 TCP 클라이언트는 Socket 클래스 한 줄로 연결을 맺습니다. 실제 데이터는 소켓에 붙은 입력·출력 스트림으로 흐르며, 우리가 알던 자바 I/O가 그대로 적용됩니다. 기본 소켓 I/O는 블로킹이라는 점만 유의하면 됩니다. 이번 글에서는 연결을 거는 클라이언트 쪽을 봤으니, 다음 글에서는 연결을 기다리고 수락하는 서버 쪽, 즉 ServerSocket을 살펴봅니다.


지난 글: MyBatis — SQL을 분리해 관리하는 SQL 매퍼

다음 글: ServerSocket — 자바로 서버를 여는 법


읽어주셔서 감사합니다. 😊