TCP 첫 연결과 다중 클라이언트 서버
localhost 단일 에코 서버를 직접 실행한 뒤 IP·포트·DNS, Socket·ServerSocket의 블로킹 지점과 다중 클라이언트 구조로 확장합니다.
네트워크에서 클라이언트는 서비스를 요청하는 역할이고 서버는 요청을 받아 결과를 제공하는 역할입니다. 이 이름은 장비의 고정 신분이 아닙니다. 웹 서버가 데이터베이스에 질의할 때 웹 서버는 데이터베이스의 클라이언트가 됩니다. 한 프로세스도 관계마다 두 역할을 오갈 수 있으므로 “누가 먼저 요청했는가”를 기준으로 읽습니다.
원격 메서드 호출처럼 보여도 네트워크 경계에는 실패와 지연, 부분 전달, 재시도가 있습니다. 같은 JVM의 객체 호출은 반환 또는 예외가 즉시 관찰되지만 네트워크에서는 상대가 처리 중인지, 응답이 유실됐는지, 연결이 끊겼는지 한쪽만 보고 확정하기 어렵습니다. 프로토콜은 메시지 구분과 시간 제한, 중복 처리 정책을 따로 가져야 합니다.
localhost에서 한 번 주고받기
복잡한 서버 구조를 보기 전에 한 클라이언트가 한 메시지를 보내고 같은 메시지를 돌려받는 에코 서버를 실행합니다.
서버는 ServerSocket으로 연결을 기다리고, 클라이언트는 Socket으로 서버의 주소와 포트에 연결합니다.
연결이 성립하면 양쪽의 Socket이 바이트를 주고받는 통로가 됩니다.
먼저 서버를 실행합니다.
accept()는 클라이언트가 연결할 때까지 기다리고, 연결되면 통신 전용 Socket을 반환합니다.
이 예제는 메시지 하나만 처리하고 종료합니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public final class EchoServer {
public static void main(String[] args) throws Exception {
try (ServerSocket server = new ServerSocket(5000)) {
System.out.println("서버 대기: 5000");
try (Socket socket = server.accept();
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
String message = input.readUTF();
System.out.println("받음: " + message);
output.writeUTF(message);
output.flush();
}
}
}
}다른 터미널에서 클라이언트를 실행합니다.
localhost는 현재 컴퓨터를 뜻하고 5000은 방금 서버가 연 포트입니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;
public final class EchoClient {
public static void main(String[] args) throws Exception {
try (Socket socket = new Socket("localhost", 5000);
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
output.writeUTF("hello");
output.flush();
System.out.println("응답: " + input.readUTF());
}
}
}javac --release 25 -d out src/EchoServer.java src/EchoClient.java
# 터미널 1
java -cp out EchoServer
# 터미널 2
java -cp out EchoClient서버를 먼저 실행해야 하는 이유는 클라이언트가 연결할 대상이 준비되어 있어야 하기 때문입니다.
writeUTF와 readUTF는 자바 학습용으로 문자열 길이와 내용을 함께 주고받습니다.
두 메서드의 호출 순서가 양쪽에서 맞지 않으면 서로 읽기만 기다릴 수 있습니다.
이 정상 왕복을 기준으로 주소, 메시지 경계, 여러 클라이언트 처리 문제를 하나씩 추가합니다.
IP와 포트의 역할
IP 패킷에는 출발지와 목적지 주소가 들어가고 라우터가 목적지 네트워크 쪽으로 전달합니다. IP는 최선형 전달이므로 도착·순서·중복 제거를 보장하지 않습니다. TCP는 IP 위에서 연결을 만들고 순서 있는 신뢰 바이트 스트림, 재전송, 흐름 제어를 제공합니다. UDP는 연결 상태와 신뢰 처리를 줄여 작은 데이터그램을 전달하며 손실과 순서가 필요하면 애플리케이션이 다룹니다.
한 컴퓨터에는 여러 서버 프로그램이 동시에 실행되므로 IP만으로는 목적 프로세스를 고를 수 없습니다. 포트 번호가 로컬 통신 끝점을 구분합니다. 서버는 알려진 포트에 bind하고 클라이언트는 대개 운영체제가 고른 임시 포트를 사용합니다. TCP 연결은 출발 IP·포트와 목적 IP·포트의 조합으로 식별되므로 같은 서버 포트가 여러 클라이언트를 동시에 받을 수 있습니다.
DNS는 사람이 기억하는 호스트 이름을 IP 주소 집합으로 해석합니다. 조회 결과는 hosts 파일, 로컬 캐시, 재귀 DNS 해석기를 거칠 수 있고 TTL 동안 캐시됩니다. 하나의 이름이 IPv4와 IPv6 또는 여러 서버 주소를 반환할 수 있으므로 첫 주소가 영원히 같은 장비라고 가정하지 않습니다.
import java.net.InetAddress;
import java.util.Arrays;
public final class LocalAddressInspector {
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
InetAddress[] localhost = InetAddress.getAllByName("localhost");
System.out.println("loopback=" + loopback.getHostAddress());
System.out.println("isLoopback=" + loopback.isLoopbackAddress());
System.out.println("localhost=" + Arrays.stream(localhost)
.map(InetAddress::getHostAddress).toList());
}
}localhost와 루프백 주소는 패킷이 외부 네트워크 인터페이스로 나가지 않고 현재 컴퓨터로 돌아오게 합니다.
통합 예제에서 외부 DNS와 방화벽에 의존하지 않도록 루프백을 사용합니다.
실제 배포에서는 서버가 어느 인터페이스에 bind하는지에 따라 로컬 전용인지 외부 접근 가능인지 달라집니다.
TCP의 바이트 흐름
TCP 연결은 3-way handshake 뒤 양방향 바이트 스트림을 제공합니다.
한 번의 write가 상대의 한 번 read와 대응한다는 보장은 없습니다.
100바이트 쓰기가 여러 read로 나뉘거나 여러 write가 한 read에서 합쳐질 수 있습니다.
길이 접두사, 구분자, 고정 크기 중 하나로 애플리케이션 메시지 구분을 정의해야 합니다.
DataOutputStream.writeUTF와 DataInputStream.readUTF는 길이가 포함된 자바 전용 학습 프레임을 간단히 만듭니다.
다른 언어와 통신할 공개 프로토콜에는 수정 UTF와 길이 제한을 문서화하거나 일반 UTF-8 길이 프레임을 직접 정의합니다.
JSON이라고 해도 TCP가 JSON 끝을 알지는 못하므로 줄 구분 또는 Content-Length 같은 경계 표시가 필요합니다.
accept·read 역할 분리
ServerSocket.accept()는 새 연결이 들어올 때까지 블로킹하고, 연결된 Socket의 read는 데이터가 올 때까지 별도로 블로킹합니다.
한 스레드가 첫 클라이언트의 read 루프에 들어가면 accept로 돌아가지 못해 다음 클라이언트 처리가 멈춥니다.
운영체제 연결 대기열에 연결이 잠시 머물 수 있어 두 번째 클라이언트의 connect는 성공한 것처럼 보이지만 애플리케이션 응답은 없습니다.
import java.io.DataInputStream;
import java.net.ServerSocket;
import java.net.Socket;
public final class SingleThreadedBlockingServer {
static void serve(ServerSocket server) throws Exception {
while (!server.isClosed()) {
try (Socket socket = server.accept();
var input = new DataInputStream(socket.getInputStream())) {
while (!"exit".equals(input.readUTF())) {
// 첫 연결이 입력을 기다리는 동안 다음 accept는 실행되지 않는다.
}
}
}
}
public static void main(String[] args) throws Exception {
try (var server = new ServerSocket(0)) {
System.out.println("port=" + server.getLocalPort());
// serve(server)는 블로킹 서버의 구조를 보여 주므로 여기서는 호출하지 않는다.
}
}
}단일 클라이언트 도구라면 의도일 수 있지만 다중 사용자 서버의 기본 구조로는 부족합니다.
accept 전용 루프는 연결을 받아 세션 작업에 넘기고 곧바로 다음 accept로 돌아갑니다.
세션 작업은 자신의 Socket만 소유하며 읽기·응답·닫기를 담당합니다.
단일 프로세스의 소켓 왕복 재현
고정 포트는 다른 프로그램과 충돌할 수 있으므로 예제 서버를 0번 포트로 열어 운영체제가 빈 포트를 선택하게 합니다.
서버가 준비된 뒤 실제 포트를 클라이언트에 전달합니다.
서버 실패가 보이지 않게 사라지지 않도록 Future로 결과를 회수합니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.Executors;
public final class EchoSocketRoundTrip {
public static void main(String[] args) throws Exception {
try (var server = new ServerSocket(0, 10, InetAddress.getLoopbackAddress());
var executor = Executors.newSingleThreadExecutor()) {
var serverResult = executor.submit(() -> {
try (Socket accepted = server.accept();
var input = new DataInputStream(accepted.getInputStream());
var output = new DataOutputStream(accepted.getOutputStream())) {
String request = input.readUTF();
output.writeUTF(request + " World!");
output.flush();
return request;
}
});
try (Socket client = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
var input = new DataInputStream(client.getInputStream());
var output = new DataOutputStream(client.getOutputStream())) {
output.writeUTF("Hello");
output.flush();
System.out.println(input.readUTF());
}
System.out.println("serverReceived=" + serverResult.get());
}
}
}클라이언트의 OutputStream은 서버 Socket의 InputStream으로 이어지고 반대 방향도 동일합니다.
ServerSocket은 연결 요청을 수락하는 리스너이며 실제 데이터는 accept()가 반환한 별도 Socket으로 주고받습니다.
리스너를 닫아도 이미 수락한 세션 Socket을 따로 닫지 않으면 계속 살아 있을 수 있습니다.
연결 대기열과 accept 역할 구분
TCP handshake가 끝난 연결 정보는 서버 애플리케이션이 accept할 때까지 운영체제 큐에 대기할 수 있습니다.
연결 대기열은 무한 대기열이 아니며 운영체제 설정과 구현에 따라 실효 크기가 달라집니다.
애플리케이션이 accept를 늦게 호출하면 새 연결이 시간 제한 또는 거부될 수 있습니다.
accept가 반환한 시점에는 연결별 로컬·원격 주소가 정해진 Socket이 생깁니다.
그 뒤 수신 바이트는 운영체제 TCP 버퍼에 도착하고 애플리케이션 read가 소비합니다.
네트워크 카드에 패킷이 도착했다는 사실과 자바 세션이 요청을 처리했다는 사실은 다른 단계입니다.
큐 대기와 세션 처리 지연을 구분해 관측합니다.
연결별 작업과 스레드 상한
전통적인 플랫폼 스레드는 연결당 블로킹 코드를 이해하기 쉽지만 연결 수만큼 스택과 스케줄링 비용이 늘어납니다.
유한 풀을 쓰면 동시 처리 상한이 생기는 대신 세션이 오래 read에서 대기하면 풀 전체가 점유될 수 있습니다.
Java의 가상 스레드는 많은 블로킹 세션을 간단한 동기 코드로 표현하는 선택지지만 데이터베이스와 외부 API의 동시성 상한은 별도로 제한해야 합니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
public final class MultiClientEchoServer {
static void session(Socket socket) {
try (socket;
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
String request = input.readUTF();
output.writeUTF(request.toUpperCase());
} catch (Exception error) {
System.err.println("session failed: " + error.getClass().getSimpleName());
}
}
public static void main(String[] args) throws Exception {
int clients = 3;
try (var server = new ServerSocket(0, clients, InetAddress.getLoopbackAddress());
var sessions = Executors.newVirtualThreadPerTaskExecutor()) {
Thread acceptor = Thread.ofPlatform().start(() -> {
try {
for (int index = 0; index < clients; index++) {
Socket socket = server.accept();
sessions.submit(() -> session(socket));
}
} catch (Exception error) {
throw new RuntimeException(error);
}
});
List<String> replies = new ArrayList<>();
for (int index = 1; index <= clients; index++) {
try (var socket = new Socket(InetAddress.getLoopbackAddress(), server.getLocalPort());
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
output.writeUTF("client-" + index);
replies.add(input.readUTF());
}
}
acceptor.join();
System.out.println(replies);
}
}
}세션 함수가 Socket을 소유하고 try-with-resources로 닫습니다.
accept 루프가 만든 Socket을 작업 제출 전에 예외로 잃지 않도록 제출 실패 경로도 운영 코드에서는 처리해야 합니다.
서버 종료 시 리스너를 닫아 accept 블로킹을 깨우고 활성 세션을 등록해 함께 종료합니다.
try-with-resources와 예외 보존
네트워크 세션은 Socket, 입력, 출력처럼 서로 관련된 여러 자원을 갖습니다.
정상 경로 끝에 close를 나열하면 read 또는 업무 코드 예외에서 실행되지 않습니다.
finally에서 첫 close가 실패하면 뒤 자원이 닫히지 않고 핵심 업무 예외가 close 예외로 가려질 수 있습니다.
try-with-resources는 선언의 역순으로 모든 close를 시도합니다.
본문에서 이미 예외가 났다면 그것을 주 예외로 던지고 close 실패는 getSuppressed()에 보관합니다.
본문이 성공하고 close만 실패하면 close 예외가 주 예외가 됩니다.
진단 로그는 주 예외와 억제됨 목록을 함께 기록해야 합니다.
네트워크 서버 판단표
| 경계 | 필수 규칙 | 검증 방법 |
|---|---|---|
| 주소 해석 | 호스트와 IP 실패 구분 | 루프백·잘못된 이름 |
| 연결 | connect 시간 제한 | 미사용 포트·무응답 주소 |
| 메시지 | 길이 또는 구분자 | 분할·병합 입력 |
| 동시성 | accept와 세션 분리 | 클라이언트 여러 개 |
| 자원 | 소유자와 역순 close | read 중 종료 |
| 과부하 | 동시 세션 상한 | 장기 연결 부하 |
연습 문제
두 AutoCloseable 자원을 선언하고 본문과 두 close에서 모두 예외를 발생시키세요.
어떤 예외가 주 예외가 되는지와 close 호출 순서, 억제됨 배열 순서를 출력합니다.
정답과 해설
public final class SuppressedCloseOrderSolution {
static final class Resource implements AutoCloseable {
private final String name;
Resource(String name) { this.name = name; }
void use() { System.out.println("use " + name); }
@Override public void close() throws Exception {
System.out.println("close " + name);
throw new Exception("close-" + name);
}
}
public static void main(String[] args) {
try (var first = new Resource("first");
var second = new Resource("second")) {
first.use();
second.use();
throw new IllegalStateException("body");
} catch (Exception primary) {
System.out.println("primary=" + primary.getMessage());
for (Throwable suppressed : primary.getSuppressed()) {
System.out.println("suppressed=" + suppressed.getMessage());
}
}
}
}두 번째 자원이 먼저 닫히고 첫 번째가 나중에 닫힙니다.
body가 주 예외로 유지되며 close-second와 close-first가 억제됨으로 붙습니다.
네트워크 장애 로그에서 억제됨을 버리지 않으면 업무 실패와 자원 정리 실패를 동시에 추적할 수 있습니다.
다중 클라이언트 서버의 통합 결론
자바 네트워크 프로그램은 Socket 문법만 배우면 끝나지 않습니다. DNS가 주소를 찾고 IP와 TCP가 바이트 경로를 만들며 포트가 프로세스를 구분하고, ServerSocket과 세션 Socket이 연결 수락과 데이터 처리를 나눕니다. 메시지 프레임, 블로킹 지점, 동시성 상한, 시간 제한, 자원 종료를 한 모델로 확인해야 여러 클라이언트가 와도 오래 살아 있는 서버가 됩니다.