Java의 Socket API를 사용한 네트워크 프로그래밍에 대한 학습하겠습니다. 기본적인 에코 서버/클라이언트 및 멀티스레드 에코 서버 구현을 통해 Java의 Thread, Socket, 네트워크 Stream의 개념과 사용법을 설명합니다.
[Java 파일 ➡️ OS 커널 ➡️ 소켓(Socket) ➡️ 스트림(Stream) ➡️ TCP/IP 레일]로 이어지는 과정에서 인과관계를 반드시 파악해야 한다!
그래서 다음과 같은 내용을 꼭 숙지하자!
백엔드 개발자가 자바 코드로 new Socket()이나 ServerSocket을 선언하고 가상 메모리에 상주시키는 순간, JVM은 OS의 시스템 콜(System Call)을 때려 커널 영토에 파일 디스크립터(FD) 형태의 소켓 고유 주소록을 개설한다.
즉, 소켓 이하의 모든 네트워크 가공 및 패킷 가동 영역은 전부 유저 공간이 아닌 '커널 공간(Kernel Space)'에서 처리된다.
자바의 InputStream과 OutputStream은 커널 공간에 파여 있는 '송수신 버퍼(Socket Buffer)'로 데이터를 실어 나르는 가상의 파이프라인이다.
우리가 스트림에 텍스트나 데이터를 쓰면, OS 커널은 이를 바이트 덩어리로 잘게 쪼개어 파일 시스템 블록처럼 정렬된 순서대로 하부 네트워크 레이어로 수송을 시작한다.
유저가 들어올 때마다 new Thread()를 무지성으로 할당하는 전통적인 멀티스레드 소켓 채팅 서버는 대규모 트래픽 앞에서 컨텍스트 스위칭(Context Switching) 연산 폭주로 기절한다.
그렇기 때문에 현대 백엔드 개발자들은 효율적인 코드 최적화를 넘어 싱글 스레드가 수천 개의 소켓 무작위 요청을 감시하는 자바 NIO 기반의 비동기 Non-blocking (Selector/Epoll) 아키텍처까지 생각에 도달하게 된다.
소켓은 네트워크 통신의 엔드포인트로, 두 프로그램이 네트워크를 통해 데이터를 주고받을 수 있게 합니다.
스트림은 데이터의 입출력을 다루는 Java의 1차 선형구조의 기본 메커니즘입니다.
자바는 파일, 네트워크, 콘솔 등 데이터 소스가 다르더라도 일관되게 byte 단위 입출력을 처리할 수 있도록 최상위 추상 클래스인 InputStream과 OutputStream을 제공합니다.
스레드는 프로그램 내에서 동시에 실행될 수 있는 작은 실행 단위입니다. 다중 클라이언트 처리가 필요한 서버에서 반드시 필요합니다.
public class EchoServer {
public static void main(String[] args) throws IOException {
// 서버 소켓을 포트 9999에서 생성
ServerSocket serverSocket = new ServerSocket(9999);
// 무한 루프로 클라이언트 연결을 대기
while (true) {
// 클라이언트 연결 대기
Socket socket = serverSocket.accept();
// 클라이언트와의 입출력 스트림 생성
InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream();
byte[] buf = new byte[1024]; // 데이터 버퍼
int count = 0;
// 클라이언트로부터 데이터를 읽고 다시 전송
while ((count = inputStream.read(buf)) != -1) {
outputStream.write(buf, 0, count);
System.out.write(buf, 0 , count);
}
// 연결 종료 및 소켓 닫기
outputStream.close();
socket.close();
}
}
}
핵심 개념:
ServerSocket(9999): 9999 포트에서 서버 소켓 생성serverSocket.accept(): 클라이언트 연결을 기다리는 블로킹 메서드socket.getInputStream()/getOutputStream(): 클라이언트와 데이터를 주고받을 스트림 생성inputStream.read(buf): 클라이언트로부터 데이터 읽기outputStream.write(buf, 0, count): 클라이언트에게 데이터 전송💥한계점: 하나의 클라이언트만 처리 가능. 첫 클라이언트의 연결이, 처리가 끝날 때까지 다른 클라이언트는 대기해야 합니다.
public class EchoClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket();
// 서버에 연결
socket.connect(new InetSocketAddress("localhost", 9999));
// 서버와의 입출력 스트림 생성
OutputStream outputStream = socket.getOutputStream();
InputStream inputStream = socket.getInputStream();
byte[] buf = new byte[1024]; // 데이터 버퍼
int count = 0;
// 표준 입력에서 데이터를 읽어 서버로 전송하고 응답 받기
while ((count = System.in.read(buf)) != -1) {
outputStream.write(buf, 0, count);
count = inputStream.read(buf);
System.out.write(buf, 0, count);
}
// 연결 종료 및 소켓 닫기
outputStream.close();
socket.close();
}
}
핵심 개념:
new Socket(): 소켓 객체 생성socket.connect(new InetSocketAddress("localhost", 9999)): 지정된 주소와 포트로 서버에 연결System.in.read(buf): 표준 입력(키보드)에서 데이터 읽기inputStream.read(buf): 서버로부터 응답 데이터 읽기outputStream.close(): 데이터 유실을 막기 위해 반드시 가장 마지막에 연결된 보조 스트림의 close()를 호출해야 연쇄적으로 내부 자원 정리와 flush()가 안전하게 수행public class MultiThreadEchoServer extends Thread {
private Socket socket = null;
// 생성자: 클라이언트 소켓을 받아 초기화
public MultiThreadEchoServer(Socket socket) {
this.socket = socket;
}
// 스레드가 실행되는 메서드
public void run() {
try {
InputStream fromClient = socket.getInputStream();
OutputStream toClient = socket.getOutputStream();
byte[] buf = new byte[1024];
int count = 0;
while ((count = fromClient.read(buf)) != -1) {
toClient.write(buf, 0, count);
System.out.write(buf, 0, count);
}
} catch (IOException e) {
System.out.println(socket + ": 연결종료 (" + e + ")");
} finally {
if (socket != null) {
try {
socket.close();
socket = null;
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
public static void main(String[] args) throws NumberFormatException, IOException {
ServerSocket serverSocket = new ServerSocket(5001);
while (true) {
Socket client = serverSocket.accept();
MultiThreadEchoServer myServer = new MultiThreadEchoServer(client);
myServer.start();
}
}
}
핵심 개념:
extends Thread: Thread 클래스 상속으로 멀티스레딩 구현myServer.start(): 스레드 시작, run() 메서드 실행ServerSocket 클래스:
ServerSocket serverSocket = new ServerSocket(포트번호)Socket clientSocket = serverSocket.accept()accept(): 클라이언트 연결 수락(블로킹)close(): 서버 소켓 닫기Socket 클래스:
Socket socket = new Socket() 또는 new Socket(호스트, 포트)socket.connect(new InetSocketAddress(호스트, 포트))getInputStream(): 소켓에서 데이터를 읽기 위한 스트림 getOutputStream(): 소켓으로 데이터를 쓰기 위한 스트림close(): 소켓 닫기InputStream:
read(byte[] b): 바이트 배열로 데이터 읽기read(byte[] b, int off, int len): 지정된 오프셋과 길이로 데이터 읽기-1은 더 이상 읽을 데이터가 없음을 의미OutputStream:
write(byte[] b): 바이트 배열 전체 쓰기write(byte[] b, int off, int len): 지정된 오프셋과 길이로 데이터 쓰기flush(): 버퍼링된 출력 바이트 강제 쓰기Thread 클래스:
class MyThread extends Threadclass MyRunnable implements Runnablestart(): 스레드 시작run(): 스레드 실행 시 실행될 코드 (오버라이드 필요)join(): 스레드가 종료될 때까지 대기멀티스레드 서버의 이점:
예제 코드에서는 다음과 같은 리소스 관리 패턴을 사용했습니다:
close()finally 블록에서 리소스 해제 (MultiThreadEchoServer)개선점: 현대 Java에서는 try-with-resources 구문을 사용하여 더 안전하게 리소스를 관리할 수 있습니다.
예제 코드에서는 다음과 같은 예외 처리 패턴을 보여줍니다:
IOException을 메인 메서드에서 throw개선점: 더 상세한 예외 처리와 로깅이 필요합니다.
현재 구현에서는 클라이언트마다 새 스레드를 생성합니다. 이는 간단하지만 클라이언트가 많아지면 비효율적일 수 있습니다.
개선점: ThreadPool(ExecutorService)을 사용하여 스레드 수를 제한하고 효율적으로 관리할 수 있습니다.
현재 1:1 에코 서버에서 다음과 같이 확장할 수 있습니다:
java.nio 패키지의 Channel과 Selector 활용java.util.concurrent.ExecutorService 사용javax.net.ssl 패키지 활용Java 소켓 프로그래밍의 기본 개념인 Socket, Stream, Thread를 소개하고, 실제 구현 예제를 통해 그 사용법을 보여주었습니다.
제공된 예제 코드는 기본적인 에코 서버/클라이언트와 멀티스레드 서버를 구현하며, 실제 채팅 애플리케이션으로 확장하기 위한 기반을 제공합니다.
Java의 네트워크 프로그래밍은 강력하고 유연하며, 이러한 기본 개념을 이해함으로써 더 복잡한 네트워크 애플리케이션을 개발할 수 있는 토대를 마련할 수 있습니다.
멀티스레드 에코 서버의 데이터 송수신과 예외 처리를 개선하도록 하겠습니다. 주요 개선사항은 다음과 같습니다:
public class MultiThreadEchoServer extends Thread {
private static final int BUFFER_SIZE = 1024;
private final Socket socket;
public MultiThreadEchoServer(Socket socket) {
this.socket = socket;
setName("Client-" + socket.getInetAddress().getHostAddress()); // 스레드 이름 지정
}
public void run() {
System.out.println("[" + getName() + "] 클라이언트 연결됨: " + socket);
try (InputStream fromClient = socket.getInputStream();
OutputStream toClient = socket.getOutputStream()) {
byte[] buf = new byte[BUFFER_SIZE];
int count;
while ((count = fromClient.read(buf)) != -1) {
toClient.write(buf, 0, count);
toClient.flush(); // 버퍼 즉시 전송
// 서버 로그에 수신된 데이터 출력
System.out.printf("[%s] 수신된 데이터 크기: %d bytes%n", getName(), count);
System.out.write(buf, 0, count);
System.out.flush();
}
} catch (IOException e) {
System.out.printf("[%s] 연결 오류: %s%n", getName(), e.getMessage());
} finally {
closeSocket();
}
}
private void closeSocket() {
if (socket != null && !socket.isClosed()) {
try {
socket.close();
System.out.printf("[%s] 소켓 정상 종료%n", getName());
} catch (IOException e) {
System.out.printf("[%s] 소켓 종료 중 오류 발생: %s%n", getName(), e.getMessage());
}
}
}
// ... existing code ...
}
BUFFER_SIZE를 상수로 정의하여 버퍼 크기를 쉽게 관리할 수 있게 함- try-with-resources를 사용하여 스트림 자원을 자동으로 해제
flush()를 추가하여 데이터가 즉시 전송되도록 보장- 스레드 이름을 지정하여 로깅 시 어떤 클라이언트의 메시지인지 식별 가능
- 소켓 종료 로직을 별도 메서드로 분리하여 코드 가독성 향상
- 더 자세한 로그 메시지 추가로 디버깅 용이성 향상
이러한 개선사항들은 서버의 안정성과 유지보수성을 높여줄 것입니다.