Java 채팅 프로그램 — 설계와 클라이언트 구현

지금까지 학습한 소켓 통신, 멀티스레드, 자원 정리를 모두 활용해 실제 채팅 프로그램을 만들어보자. 단순한 에코 서버를 넘어, 여러 사용자가 실시간으로 대화를 주고받는 프로그램이다

요구사항

서버에 접속한 모든 사용자는 서로 대화할 수 있어야 한다. 지원하는 명령어는 다음과 같다

명령어형식설명
입장/join|{name}채팅 서버 접속 시 사용자 이름 등록
메시지/message|{내용}모든 사용자에게 메시지 전달
이름 변경/change|{name}사용자 이름 변경
전체 사용자/users현재 접속자 목록 출력
종료/exit채팅 서버 접속 종료

설계

클라이언트 설계 – 블로킹 문제

기존 클라이언크 코드를 단일 스레드로 동작했다.

String toSend = scanner.nextLine(); // 블로킹 1: 사용자 입력 대기
output.writeUTF(toSend);

String received = input.readUTF(); // 블로킹 2: 서버 메시지 대기

이 구조는 채팅에 사용할 수 없다. 사용자가 키보드 입력을 하기 전까지 스레드가 블로킹되어, 그 사이에 다른 사용자가 보낸 메시지를 받을 수 없기 때문이다. 두 블로킹 지점을 하나의 스레드로 처리하는 것은 구조적으로 불가능하다

해결책은 두 블로킹 작업을 별도의 스레드로 분리하는 것이다

  • ReadHandler – 서버에서 오는 메시지를 수신해 콘솔에 출력
  • WriteHandler – 콘솔 입력을 받아 서버로 전송

서버 설계

채팅의 핵심은 한 사람이 보낸 메시지를 모두가 받아야 한다는 것이다. 클라이언트1이 “Hello”를 전송하면 서버는 SessionManager를 통해 연결된 모든 세션에 “Hello”를 전달한다. 각 세션은 자신의 클라이언트에게 메시지를 전송한다. 앞서 구현한 SessionManager를 그대로 활용할 수 있다

클라이언트 구현

ReadHandler – 서버 메시지 수신

public class ReadHandler implements Runnable {
    private final DataInputStream input;
    private final Client client;
    public boolean closed = false;

    public ReadHandler(DataInputStream input, Client client) {
        this.input = input;
        this.client = client;
    }

    @Override
    public void run() {
        try {
            while (true) {
                String received = input.readUTF(); // 블로킹
                System.out.println(received);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            client.close(); // 예외 발생 시 전체 자원 정리
        }
    }

    public synchronized void close() {
        if (closed) return;
        // 필요한 종료 로직 작성
        closed = true;
        log("readHandler 종료");
    }
}

서버에서 메시지가 오면 콘솔에 출력하는 단순한 역할이다. IOException이 발생하면 서버와의 연결이 끊어진 것으로 판단하고 client.close()로 전체 자원을 정리한다

WriteHandler – 콘솔 입력 송신

public class WriteHandler implements Runnable {
    private static final String DELIMITER = "|";
    private final DataOutputStream output;
    private final Client client;
    private boolean closed = false;

    public WriteHandler(DataOutputStream output, Client client) {
        this.output = output;
        this.client = client;
    }

    @Override
    public void run() {
        Scanner scanner = new Scanner(System.in);
        try {
            // 1. 사용자 이름 입력 후 서버에 입장 메시지 전송
            String username = inputUsername(scanner);
            output.writeUTF("/join" + DELIMITER + username);

            while (true) {
                String toSend = scanner.nextLine(); // 블로킹
                if (toSend.isEmpty()) continue;

                if (toSend.equals("/exit")) {
                    output.writeUTF(toSend);
                    break;
                }

                // '/'로 시작하면 명령어, 나머지는 일반 채팅 메시지
                if (toSend.startsWith("/")) {
                    output.writeUTF(toSend);
                } else {
                    output.writeUTF("/message" + DELIMITER + toSend);
                }
            }
        } catch (IOException | NoSuchElementException e) {
            log(e);
        } finally {
            client.close();
        }
    }

    private static String inputUsername(Scanner scanner) {
        System.out.println("이름을 입력하세요.");
        String username;
        do {
            username = scanner.nextLine();
        } while (username.isEmpty());
        return username;
    }

    public synchronized void close() {
        if (closed) return;
        try {
            System.in.close(); // 콘솔 입력 차단 → NoSuchElementException 발생
        } catch (IOException e) {
            log(e);
        }
        closed = true;
        log("writeHandler 종료");
    }
}

System.in.close()가 필요한 이유

WriteHandlerscanner.nextLine()에서 사용자 입력을 기다리며 블로킹된다. 서버가 연결을 끊어 자원을 정리해야 하는 상황에서, 이 스레드는 사용자가 키를 누르기 전까지 빠져나올 수 없다. System.in.close()로 표준 입력을 닫으면 scanner.nextLine()에서 NoSuchElementException이 발생하며 블로킹 상태에서 즉시 탈출할 수 있다

Client – 전체 자원 관리

public class Client {
    private final String host;
    private final int port;

    private Socket socket;
    private DataInputStream input;
    private DataOutputStream output;
    private ReadHandler readHandler;
    private WriteHandler writeHandler;
    private boolean closed = false;

    public Client(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public void start() throws IOException {
        log("클라이언트 시작");
        socket = new Socket(host, port);
        input = new DataInputStream(socket.getInputStream());
        output = new DataOutputStream(socket.getOutputStream());

        readHandler = new ReadHandler(input, this);
        writeHandler = new WriteHandler(output, this);

        Thread readThread = new Thread(readHandler, "readHandler");
        Thread writeThread = new Thread(writeHandler, "writeHandler");
        readThread.start();
        writeThread.start();
    }

    public synchronized void close() {
        if (closed) return;
        writeHandler.close();
        readHandler.close();
        closeAll(socket, input, output);
        closed = true;
        log("연결 종료: " + socket);
    }
}

Client는 소켓, 스트림, 두 핸들러를 모두 소유하고 생명주기를 관리하는 중심 클래스다. close()synchronized를 적용한 이유는 ReadHandlerWriteHandler 각자가 예외 발생 시 client.close()를 동시에 호출할 수 있기 때문이다. synchronizedclosed 플래그를 함께 사용해 자원 정리가 정확히 한 번만 실행되도록 보장한다

ClientMain

public class ClientMain {
    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        Client client = new Client("localhost", PORT);
        client.start();
    }
}

클래스 구조 정리

ClientMain
    └── Client (소켓, 스트림, 자원 생명주기 관리)
            ├── ReadHandler (readHandler 스레드) — 서버 → 콘솔 출력
            └── WriteHandler (writeHandler 스레드) — 콘솔 → 서버 전송

ReadHandlerWriteHandler 중 어느 한 쪽에서 예외가 발생하면 client.close()가 호출된다. close()는 나머지 핸들러와 소켓까지 모두 정리하며, synchronized + closed 플래그 덕분에 중복 실행은 발생하지 않는다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션