지금까지 학습한 소켓 통신, 멀티스레드, 자원 정리를 모두 활용해 실제 채팅 프로그램을 만들어보자. 단순한 에코 서버를 넘어, 여러 사용자가 실시간으로 대화를 주고받는 프로그램이다
요구사항
서버에 접속한 모든 사용자는 서로 대화할 수 있어야 한다. 지원하는 명령어는 다음과 같다
| 명령어 | 형식 | 설명 |
| 입장 | /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()가 필요한 이유
WriteHandler는 scanner.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를 적용한 이유는 ReadHandler와 WriteHandler 각자가 예외 발생 시 client.close()를 동시에 호출할 수 있기 때문이다. synchronized와 closed 플래그를 함께 사용해 자원 정리가 정확히 한 번만 실행되도록 보장한다
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 스레드) — 콘솔 → 서버 전송
ReadHandler나 WriteHandler 중 어느 한 쪽에서 예외가 발생하면 client.close()가 호출된다. close()는 나머지 핸들러와 소켓까지 모두 정리하며, synchronized + closed 플래그 덕분에 중복 실행은 발생하지 않는다