소켓 기초 — TCP 통신의 출발점
소켓은 두 프로그램이 네트워크 너머로 바이트를 주고받는 양 끝점입니다. TCP 소켓의 개념, Socket 클래스로 연결을 맺고 입력·출력 스트림으로 데이터를 주고받는 흐름, 그리고 자바 네트워킹의 토대가 되는 핵심 개념을 정리합니다.
지난 글에서 데이터베이스에 접근하는 여러 도구를 한 바퀴 돌아봤습니다. 데이터베이스도 결국은 네트워크 너머의 또 다른 프로그램이었습니다. 이제 시야를 한 단계 넓혀, 자바 프로그램이 네트워크를 통해 다른 프로그램과 직접 통신 하는 가장 밑바닥의 방식인 소켓을 살펴봅니다. HTTP도, gRPC도, 데이터베이스 드라이버도 그 아래를 파보면 결국 이 소켓 위에서 동작합니다. 네트워킹을 제대로 이해하는 출발점이 바로 여기입니다.
소켓이란 무엇인가
소켓(socket)은 두 프로그램이 데이터를 주고받기 위한 양 끝점(endpoint) 입니다. 전화 통화에 비유하면, 전화기 두 대가 회선으로 연결되어 양쪽이 말하고 들을 수 있는 상태와 같습니다. 한쪽에서 말한 소리가 회선을 타고 반대쪽 수화기로 들리듯, 한 소켓에 쓴 바이트가 네트워크를 타고 반대쪽 소켓에서 읽힙니다.
소켓을 식별하는 주소는 IP 주소 + 포트 번호 의 조합입니다. IP 주소가 “어느 컴퓨터인가”를 가리킨다면, 포트 번호는 “그 컴퓨터의 어느 프로그램인가”를 가리킵니다. 한 대의 서버에서 웹 서버(80), 데이터베이스(5432), SSH(22)가 동시에 동작할 수 있는 것은 포트로 구분되기 때문입니다.
위 그림에서 보듯 연결은 비대칭으로 시작됩니다. 한쪽은 능동적으로 연결을 요청 하고(클라이언트), 다른 쪽은 연결을 기다리다 수락 합니다(서버). 하지만 일단 연결이 맺어지면 양쪽은 대등합니다. 둘 다 읽을 수 있고 둘 다 쓸 수 있는 전이중(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()은 상대에게 보낼 바이트를 쓰는 출력 스트림을 돌려줍니다.
여기서 핵심은 소켓이 바이트 스트림을 다룬다는 점입니다. 우리가 이미 익혔던 자바 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 — 자바로 서버를 여는 법
읽어주셔서 감사합니다. 😊