자바 네트워크 프로그래밍 — Socket, ServerSocket, 비동기 채널
HTTP 프레임워크 아래에는 결국 Socket이 있습니다. 네트워크 프로그래밍의 기본을 알면 프레임워크가 왜 그렇게 동작하는지 이해할 수 있습니다.
이게 뭔가요?
자바의 네트워크 프로그래밍은 java.net.Socket(클라이언트)과 java.net.ServerSocket(서버)으로 TCP 통신을 구현하고, java.nio.channels로 논블로킹/비동기 통신을 구현하는 것입니다.
왜 필요한가요?
- HTTP 서버(Tomcat, Netty)의 내부 동작 이해
- 커스텀 프로토콜 서버 구현
- 채팅, 게임 서버 같은 실시간 통신
- 네트워크 관련 장애 디버깅
블로킹 서버 (ServerSocket)
가장 기본적인 TCP 서버입니다.
public class SimpleServer {
public static void main(String[] args) throws Exception {
try (ServerSocket server = new ServerSocket(8080)) {
System.out.println("서버 시작: 포트 8080");
while (true) {
// accept()는 클라이언트 연결까지 블로킹
Socket client = server.accept();
// 클라이언트마다 새 스레드 (비효율적)
new Thread(() -> handleClient(client)).start();
}
}
}
static void handleClient(Socket client) {
try (client;
var in = new BufferedReader(
new InputStreamReader(client.getInputStream()));
var out = new PrintWriter(
client.getOutputStream(), true)) {
String message;
while ((message = in.readLine()) != null) {
out.println("에코: " + message);
}
} catch (IOException e) {
log.error("클라이언트 처리 에러", e);
}
}
}
문제점
클라이언트 1000개가 접속하면 스레드 1000개가 필요합니다. 스레드 하나당 약 1MB 스택 메모리를 사용하므로 확장성에 한계가 있습니다.
블로킹 클라이언트 (Socket)
try (Socket socket = new Socket("localhost", 8080);
var out = new PrintWriter(socket.getOutputStream(), true);
var in = new BufferedReader(
new InputStreamReader(socket.getInputStream()))) {
out.println("안녕하세요");
String response = in.readLine(); // 응답 대기 (블로킹)
System.out.println("서버 응답: " + response);
}
스레드 풀 서버
스레드를 무한정 만드는 대신 스레드 풀을 사용합니다.
ExecutorService pool = Executors.newFixedThreadPool(200);
try (ServerSocket server = new ServerSocket(8080)) {
while (true) {
Socket client = server.accept();
pool.submit(() -> handleClient(client)); // 스레드 풀에 작업 제출
}
}
200개의 동시 연결만 처리할 수 있습니다. 201번째 클라이언트는 큐에서 대기합니다.
NIO: 논블로킹 서버 (Selector)
하나의 스레드로 여러 연결을 처리합니다.
public class NioServer {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false); // 논블로킹 모드
// 새 연결 이벤트를 Selector에 등록
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 이벤트 발생까지 대기
Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
while (keys.hasNext()) {
SelectionKey key = keys.next();
keys.remove();
if (key.isAcceptable()) {
// 새 클라이언트 연결
SocketChannel client = serverChannel.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
}
if (key.isReadable()) {
// 데이터 읽기 가능
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int read = client.read(buffer);
if (read == -1) {
client.close();
} else {
buffer.flip();
client.write(buffer); // 에코
}
}
}
}
}
}
Selector의 핵심
Selector (1개 스레드)
├── Channel A (읽기 가능?)
├── Channel B (쓰기 가능?)
├── Channel C (연결 요청?)
└── ...
select() → "이벤트가 있는 채널만" 반환
→ 하나의 스레드로 수천 연결 처리 가능
이것이 Netty, Tomcat NIO, Nginx의 기본 동작 원리입니다.
소켓 옵션
Socket socket = new Socket();
socket.setSoTimeout(5000); // 읽기 타임아웃 5초
socket.setKeepAlive(true); // TCP Keep-Alive
socket.setTcpNoDelay(true); // Nagle 알고리즘 비활성화 (저지연)
socket.setReceiveBufferSize(8192); // 수신 버퍼 크기
socket.connect(
new InetSocketAddress("server", 8080),
3000); // 연결 타임아웃 3초
HttpClient (Java 11+)
저수준 Socket 대신 HTTP 통신에는 HttpClient를 사용합니다.
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.example.com/users"))
.header("Accept", "application/json")
.GET()
.build();
// 동기
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
// 비동기
client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(System.out::println);
자주 헷갈리는 포인트
- 블로킹 I/O vs NIO: 블로킹은 코드가 간단하지만 확장성이 낮습니다. NIO는 복잡하지만 하나의 스레드로 수천 연결을 처리합니다. 대부분의 경우 Netty 같은 프레임워크를 쓰는 것이 좋습니다.
- SO_TIMEOUT: 소켓 타임아웃을 설정하지 않으면
read()가 영원히 블로킹될 수 있습니다. 반드시 설정하세요. - TCP_NODELAY: Nagle 알고리즘은 작은 패킷을 모아서 보냅니다. 실시간 통신에서는 비활성화(true)하는 것이 좋습니다.
- ByteBuffer flip():
write모드에서read모드로 전환할 때 반드시flip()을 호출해야 합니다. 빠뜨리면 빈 데이터를 읽습니다.
정리
| 방식 | 스레드 모델 | 동시 연결 | 복잡도 |
|---|---|---|---|
| ServerSocket | 스레드/연결 | 수백 | 낮음 |
| 스레드 풀 | 고정 스레드 풀 | 수백 | 낮음 |
| NIO Selector | 1 스레드 : N 연결 | 수만 | 높음 |
| Netty | 이벤트 루프 | 수만 | 중간 (프레임워크) |