목차
NIO TCP blocking channel 소켓 서버와 클라이언트
ServerSocketChannel은 연결을 기다리고, 연결을 수락하면 SocketChannel을 얻는다. 클라이언트는 SocketChannel로 서버 주소에 연결한다. 원래 예제의 read()와 write()에는 ByteBuffer 인자가 필요하며, 한 번의 호출이 메시지 전체를 읽거나 쓴다고 가정하면 안 된다.
sequenceDiagram
participant C as 클라이언트
participant S as 서버
S->>S: bind(127.0.0.1:9190), accept 대기
C->>S: connect
C->>S: 길이 4바이트 + UTF-8 본문
S->>S: 정해진 길이만큼 반복 read
S-->>C: 길이 4바이트 + 응답 본문
TCP는 바이트 스트림이다. read() 한 번에 문자열 하나가 정확히 도착한다는 경계가 없다. 아래 예제는 본문 앞에 4바이트 길이를 붙여 한 메시지의 범위를 정한다. 설명을 단순하게 하기 위해 서버는 클라이언트 한 명의 요청 한 건만 처리하고 종료한다.
1. 공통 메시지 읽기·쓰기 (FrameIO.java)
import java.io.EOFException;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.nio.charset.StandardCharsets;
public final class FrameIO {
private static final int MAX_BYTES = 64 * 1024;
private FrameIO() { }
public static void writeFrame(SocketChannel channel, String message) throws IOException {
byte[] body = message.getBytes(StandardCharsets.UTF_8);
if (body.length > MAX_BYTES) throw new IOException("메시지가 너무 깁니다");
ByteBuffer frame = ByteBuffer.allocate(4 + body.length);
frame.putInt(body.length);
frame.put(body);
frame.flip(); // 쓰기 모드에서 읽기 모드로 전환
while (frame.hasRemaining()) channel.write(frame);
}
public static String readFrame(SocketChannel channel) throws IOException {
ByteBuffer header = ByteBuffer.allocate(4);
readFully(channel, header);
header.flip();
int length = header.getInt();
if (length < 0 || length > MAX_BYTES) {
throw new IOException("허용되지 않은 메시지 길이: " + length);
}
ByteBuffer body = ByteBuffer.allocate(length);
readFully(channel, body);
body.flip();
return StandardCharsets.UTF_8.decode(body).toString();
}
private static void readFully(SocketChannel channel, ByteBuffer buffer)
throws IOException {
while (buffer.hasRemaining()) {
if (channel.read(buffer) == -1) {
throw new EOFException("메시지 도중 연결이 닫혔습니다");
}
}
}
}
flip() 전에는 버퍼에 데이터를 채우고, 이후에는 버퍼에서 읽는다. write()는 남은 바이트가 없어질 때까지 호출하고, read()는 정해진 길이를 모두 받거나 연결 종료(-1)를 확인할 때까지 호출한다. 길이를 검사하지 않고 그대로 메모리를 할당하면 큰 숫자 하나로 서버 메모리를 소진시킬 수 있다. Java ByteBuffer 문서와 SocketChannel 문서에 버퍼와 입출력 동작이 설명돼 있다.
2. 서버 (EchoServer.java)
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
public class EchoServer {
public static void main(String[] args) throws IOException {
try (ServerSocketChannel server = ServerSocketChannel.open()) {
server.configureBlocking(true);
server.bind(new InetSocketAddress("127.0.0.1", 9190));
System.out.println("연결 기다리기");
try (SocketChannel client = server.accept()) {
String request = FrameIO.readFrame(client);
System.out.println("받은 메시지: " + request);
FrameIO.writeFrame(client, "서버 응답: " + request);
}
}
}
}
configureBlocking(true)에서는 연결이 올 때까지 accept()가 기다린다. try-with-resources가 정상 종료와 예외 발생 시 채널을 닫는다. 원래 코드에서는 newCachedThreadPool()을 만들고 사용하지 않았으며, sock.isOpen()이 참이면 첫 연결 뒤 서버를 닫았다. 위 코드는 한 번만 처리한다는 동작을 명시한다. 여러 연결을 처리하려면 반복 수락과 각 연결의 작업 관리가 필요하다. Java ServerSocketChannel 문서를 참고한다.
3. 클라이언트 (EchoClient.java)
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;
public class EchoClient {
public static void main(String[] args) throws IOException {
try (SocketChannel channel = SocketChannel.open()) {
channel.configureBlocking(true);
channel.connect(new InetSocketAddress("127.0.0.1", 9190));
FrameIO.writeFrame(channel, "hello world");
System.out.println(FrameIO.readFrame(channel));
}
}
}
원래 클라이언트의 new InetSocketAddress(9190)은 로컬 와일드카드 주소 형태다. 원격 서버에 연결하려면 위처럼 호스트와 포트를 함께 지정한다. 시험할 때는 같은 디렉터리에 세 파일을 저장하고 javac FrameIO.java EchoServer.java EchoClient.java로 컴파일한다. 터미널 하나에서 java EchoServer를 실행한 뒤 다른 터미널에서 java EchoClient를 실행한다. 실제 서비스에는 연결 제한 시간, 오류 처리, 여러 클라이언트 관리가 추가로 필요하다.