⭐ [Java] TCP/IP Socket Server-Client 채팅 프로그램

devdo·2024년 12월 28일

Java

목록 보기
61/63
post-thumbnail

Java 소켓 프로그래밍 개발

1. 개요

Java의 Socket API를 사용한 네트워크 프로그래밍에 대한 학습하겠습니다. 기본적인 에코 서버/클라이언트 및 멀티스레드 에코 서버 구현을 통해 Java의 Thread, Socket, 네트워크 Stream의 개념과 사용법을 설명합니다.

[Java 파일 ➡️ OS 커널 ➡️ 소켓(Socket) ➡️ 스트림(Stream) ➡️ TCP/IP 레일]로 이어지는 과정에서 인과관계를 반드시 파악해야 한다!

그래서 다음과 같은 내용을 꼭 숙지하자!

소켓 이하의 영토는 커널 공간이다:

백엔드 개발자가 자바 코드로 new Socket()이나 ServerSocket을 선언하고 가상 메모리에 상주시키는 순간, JVM은 OS의 시스템 콜(System Call)을 때려 커널 영토에 파일 디스크립터(FD) 형태의 소켓 고유 주소록을 개설한다.

즉, 소켓 이하의 모든 네트워크 가공 및 패킷 가동 영역은 전부 유저 공간이 아닌 '커널 공간(Kernel Space)'에서 처리된다.

스트림(Stream)과 바이트의 수송:

자바의 InputStream과 OutputStream은 커널 공간에 파여 있는 '송수신 버퍼(Socket Buffer)'로 데이터를 실어 나르는 가상의 파이프라인이다.

우리가 스트림에 텍스트나 데이터를 쓰면, OS 커널은 이를 바이트 덩어리로 잘게 쪼개어 파일 시스템 블록처럼 정렬된 순서대로 하부 네트워크 레이어로 수송을 시작한다.

멀티스레드 채팅 서버의 파산과 비동기 가속:

유저가 들어올 때마다 new Thread()를 무지성으로 할당하는 전통적인 멀티스레드 소켓 채팅 서버는 대규모 트래픽 앞에서 컨텍스트 스위칭(Context Switching) 연산 폭주로 기절한다.

그렇기 때문에 현대 백엔드 개발자들은 효율적인 코드 최적화를 넘어 싱글 스레드가 수천 개의 소켓 무작위 요청을 감시하는 자바 NIO 기반의 비동기 Non-blocking (Selector/Epoll) 아키텍처까지 생각에 도달하게 된다.

2. Java 소켓 프로그래밍 기본 개념

2.1 소켓(Socket)

소켓은 네트워크 통신의 엔드포인트로, 두 프로그램이 네트워크를 통해 데이터를 주고받을 수 있게 합니다.

  • ServerSocket: 서버 측에서 클라이언트의 연결 요청을 기다리는 소켓
  • Socket: 실제 데이터 통신에 사용되는 소켓(클라이언트와 서버 양쪽에서 사용)

2.2 스트림(Stream)

스트림은 데이터의 입출력을 다루는 Java의 1차 선형구조의 기본 메커니즘입니다.

자바는 파일, 네트워크, 콘솔 등 데이터 소스가 다르더라도 일관되게 byte 단위 입출력을 처리할 수 있도록 최상위 추상 클래스인 InputStream과 OutputStream을 제공합니다.

  • InputStream: 데이터를 읽기 위한 스트림
  • OutputStream: 데이터를 쓰기 위한 스트림

2.3 스레드(Thread)

스레드는 프로그램 내에서 동시에 실행될 수 있는 작은 실행 단위입니다. 다중 클라이언트 처리가 필요한 서버에서 반드시 필요합니다.

3. 구현 예제 분석

3.1 기본 에코 서버 (EchoServer.java)

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): 클라이언트에게 데이터 전송

💥한계점: 하나의 클라이언트만 처리 가능. 첫 클라이언트의 연결이, 처리가 끝날 때까지 다른 클라이언트는 대기해야 합니다.


3.2 에코 클라이언트 (EchoClient.java)

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()가 안전하게 수행

3.3 멀티스레드 에코 서버 (MultiThreadEchoServer.java)

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() 메서드 실행
  • 다중 클라이언트 동시 처리 가능

4. 주요 기술 상세 설명

4.1 Java Socket API

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(): 소켓 닫기

4.2 Java Stream API

InputStream:

  • 주요 메서드:
    • read(byte[] b): 바이트 배열로 데이터 읽기
    • read(byte[] b, int off, int len): 지정된 오프셋과 길이로 데이터 읽기
    • 반환값 -1은 더 이상 읽을 데이터가 없음을 의미

OutputStream:

  • 주요 메서드:
    • write(byte[] b): 바이트 배열 전체 쓰기
    • write(byte[] b, int off, int len): 지정된 오프셋과 길이로 데이터 쓰기
    • flush(): 버퍼링된 출력 바이트 강제 쓰기

4.3 Java Thread API

Thread 클래스:

  • 생성 방법:
    1. Thread 클래스 상속: class MyThread extends Thread
    2. Runnable 인터페이스 구현: class MyRunnable implements Runnable
  • 주요 메서드:
    • start(): 스레드 시작
    • run(): 스레드 실행 시 실행될 코드 (오버라이드 필요)
    • join(): 스레드가 종료될 때까지 대기

멀티스레드 서버의 이점:

  • 동시에 여러 클라이언트 처리 가능
  • 클라이언트 처리 시간이 길어도 다른 클라이언트 연결 차단되지 않음
  • 서버 리소스를 효율적으로 사용 가능

5. 구현 시 주의사항

5.1 리소스 관리

예제 코드에서는 다음과 같은 리소스 관리 패턴을 사용했습니다:

  • 소켓과 스트림 사용 후 명시적 close()
  • finally 블록에서 리소스 해제 (MultiThreadEchoServer)

개선점: 현대 Java에서는 try-with-resources 구문을 사용하여 더 안전하게 리소스를 관리할 수 있습니다.

5.2 예외 처리

예제 코드에서는 다음과 같은 예외 처리 패턴을 보여줍니다:

  • IOException을 메인 메서드에서 throw
  • MultiThreadEchoServer에서는 catch 블록으로 예외 처리

개선점: 더 상세한 예외 처리와 로깅이 필요합니다.

5.3 스레드 관리

현재 구현에서는 클라이언트마다 새 스레드를 생성합니다. 이는 간단하지만 클라이언트가 많아지면 비효율적일 수 있습니다.

개선점: ThreadPool(ExecutorService)을 사용하여 스레드 수를 제한하고 효율적으로 관리할 수 있습니다.

6. 확장 및 개선 방향

6.1 채팅 기능 확장

현재 1:1 에코 서버에서 다음과 같이 확장할 수 있습니다:

  • 클라이언트 간 메시지 브로드캐스팅
  • 사용자 이름 등록 및 관리
  • 대화방(채팅룸) 개념 도입

6.2 성능 개선

  • NIO(Non-blocking I/O) 사용: java.nio 패키지의 Channel과 Selector 활용
  • 스레드 풀 도입: java.util.concurrent.ExecutorService 사용

6.3 보안 강화

  • SSL/TLS를 통한 암호화 통신: javax.net.ssl 패키지 활용
  • 사용자 인증 및 권한 관리 추가

7. 결론

Java 소켓 프로그래밍의 기본 개념인 Socket, Stream, Thread를 소개하고, 실제 구현 예제를 통해 그 사용법을 보여주었습니다.

제공된 예제 코드는 기본적인 에코 서버/클라이언트와 멀티스레드 서버를 구현하며, 실제 채팅 애플리케이션으로 확장하기 위한 기반을 제공합니다.

Java의 네트워크 프로그래밍은 강력하고 유연하며, 이러한 기본 개념을 이해함으로써 더 복잡한 네트워크 애플리케이션을 개발할 수 있는 토대를 마련할 수 있습니다.

8. 개선 ⭐

멀티스레드 에코 서버의 데이터 송수신과 예외 처리를 개선하도록 하겠습니다. 주요 개선사항은 다음과 같습니다:

  1. 버퍼 크기를 상수로 정의
  2. try-with-resources 사용하여 자원 관리 개선
  3. 데이터 송수신 시 버퍼 플러시 추가
  4. 더 자세한 예외 처리와 로깅
  5. 스레드 이름 지정으로 디버깅 용이성 향상
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 ...
}

주요 개선사항 설명

  1. BUFFER_SIZE를 상수로 정의하여 버퍼 크기를 쉽게 관리할 수 있게 함
  2. try-with-resources를 사용하여 스트림 자원을 자동으로 해제
  3. flush()를 추가하여 데이터가 즉시 전송되도록 보장
  4. 스레드 이름을 지정하여 로깅 시 어떤 클라이언트의 메시지인지 식별 가능
  5. 소켓 종료 로직을 별도 메서드로 분리하여 코드 가독성 향상
  6. 더 자세한 로그 메시지 추가로 디버깅 용이성 향상

이러한 개선사항들은 서버의 안정성과 유지보수성을 높여줄 것입니다.


참고

profile
자바 스프링 백엔드 개발자입니다. 배운 것을 기록합니다.

0개의 댓글