
8편의 소켓과 7편의 스레드를 합치면 채팅이 됩니다.
8편에서 두 프로그램을 연결했습니다. 다만 서버가 인사말 한 줄을 보내고 끝이었죠. 진짜 채팅은 그렇지 않습니다. 양쪽이 아무 때나, 계속 말을 주고받아야 합니다. 오늘은 지금까지 배운 것을 합쳐 그 구조를 만들어 봅니다.
한 줄씩 주고받으면 되지 않나요?
처음에는 이렇게 생각하기 쉽습니다. 받고, 보내고, 다시 받고, 보내고. 그런데 이 방식으로는 채팅이 안 됩니다.
이유는 8편에서 본 블로킹 때문입니다. readUTF()는 상대가 뭔가 보낼 때까지 그 자리에서 멈춥니다. 그러니 받기를 기다리는 동안에는 내가 말을 걸 수 없습니다. 반대로 내가 입력하는 동안에는 상대 메시지를 받지 못하고요.
결국 "네가 말하면 내가 듣고, 내가 말하면 네가 듣는" 무전기가 됩니다. 한쪽이 말하지 않으면 다른 쪽은 영원히 기다리는 상태가 되죠. 우리가 아는 채팅과는 다릅니다.
역할을 둘로 나눕니다
해법은 7편에서 배운 것입니다. 받는 일과 보내는 일을 각각 다른 스레드에 맡기면 됩니다.
받기 스레드는 오로지 듣기만 합니다. readUTF()에서 멈춰 있어도 상관없습니다. 그동안 보내기 스레드가 따로 돌면서 키보드 입력을 받아 보내면 되니까요. 서로 방해하지 않습니다.
쉽게 — 전화 통화와 같습니다. 내 귀는 계속 듣고 있고, 내 입은 언제든 말할 수 있죠. 귀와 입이 각각 따로 움직이기 때문에 대화가 됩니다. 스레드를 둘로 나눈다는 건 프로그램에 귀와 입을 따로 달아 주는 일입니다.

그림 1. 서버와 클라이언트가 거울처럼 같은 구조를 갖습니다.
받기 스레드
듣는 쪽부터 만듭니다. 하는 일은 단순합니다. 계속 읽고, 읽은 것을 화면에 뿌립니다.
class RecvThread implements Runnable {
private DataInputStream din;
RecvThread(DataInputStream din) { this.din = din; }
@Override
public void run() {
while (true) {
try {
String recv = din.readUTF(); // 올 때까지 대기
if (recv.equals("q")) { // 종료 약속
System.out.println("상대가 나갔습니다.");
break;
}
System.out.println("[상대] " + recv);
} catch (EOFException e) {
System.out.println("연결이 끊어졌습니다.");
break;
} catch (Exception e) {
break;
}
}
}
}
while (true)로 계속 도는 것이 핵심입니다. 한 번 받고 끝나는 게 아니라 대화가 끝날 때까지 계속 듣습니다.
보내기 스레드
말하는 쪽도 같은 모양입니다. 키보드에서 한 줄 읽어 그대로 보냅니다.
class SendThread implements Runnable {
private DataOutputStream dout;
SendThread(DataOutputStream dout) { this.dout = dout; }
@Override
public void run() {
Scanner sc = new Scanner(System.in);
while (true) {
String msg = sc.nextLine(); // 입력 대기
try {
dout.writeUTF(msg);
dout.flush();
if (msg.equals("q")) break; // 나도 종료
} catch (Exception e) {
break;
}
}
}
}
여기서도 nextLine()이 입력을 기다리며 멈춥니다. 하지만 받기 스레드가 따로 돌고 있으니, 내가 입력하는 중에도 상대 메시지는 계속 화면에 뜹니다.
둘을 붙여 실행합니다
서버 쪽 조립입니다. 8편에서 만든 연결에 스레드 둘을 얹으면 끝입니다.
ServerSocket server = new ServerSocket(9000);
System.out.println("접속을 기다립니다");
Socket client = server.accept();
System.out.println("접속: " + client.getInetAddress());
DataInputStream din = new DataInputStream(client.getInputStream());
DataOutputStream dout = new DataOutputStream(client.getOutputStream());
Thread t1 = new Thread(new RecvThread(din)); // 귀
Thread t2 = new Thread(new SendThread(dout)); // 입
t1.start();
t2.start();
t1.join(); // 둘 다 끝날 때까지
t2.join();
client.close();
server.close();
System.out.println("종료합니다");
클라이언트 쪽은 첫 줄만 다릅니다. new ServerSocket(9000)과 accept() 대신 new Socket("localhost", 9000)으로 접속하고, 나머지 스레드 조립은 완전히 똑같습니다. 서버든 클라이언트든 연결만 되고 나면 대등한 관계라 그렇습니다.
끝낼 때를 정해 둡니다
채팅에서 의외로 손이 많이 가는 부분이 종료입니다. 두 가지를 대비해야 합니다.
첫째, 사용자가 스스로 나갈 때. 위 코드는 q를 입력하면 끝나도록 약속을 정해 뒀습니다. 이런 약속을 프로토콜이라고 부릅니다. 거창한 말 같지만 "이 글자가 오면 이렇게 하자"는 규칙일 뿐입니다.
둘째, 상대가 갑자기 사라질 때. 상대 프로그램이 강제 종료되면 readUTF()는 더 읽을 게 없어 EOFException을 던집니다. 이걸 잡지 않으면 오류 메시지가 쏟아지죠. 1편과 2편에서 배운 예외 처리가 여기서 쓰입니다. 잡아서 "연결이 끊어졌습니다"라고 알리고 반복문을 빠져나오면 깔끔합니다.
왜 한 스레드로는 안 되는지 직접 보기
실제 채팅은 프로그램 두 개를 각각 실행해야 해서 브라우저에서는 돌려볼 수 없습니다. 대신 한 스레드일 때와 두 스레드일 때가 어떻게 다른지는 확인할 수 있습니다. 아래를 돌려 보고 출력 순서를 눈여겨보세요.
public class Main {
// 받기 흉내: 0.3초마다 메시지가 도착한다고 가정
static void receiving() {
for (int i = 1; i <= 3; i++) {
try { Thread.sleep(300); } catch (Exception e) { }
System.out.println("[상대] 메시지 " + i);
}
}
// 보내기 흉내: 0.3초마다 내가 한 줄 친다고 가정
static void sending() {
for (int i = 1; i <= 3; i++) {
try { Thread.sleep(300); } catch (Exception e) { }
System.out.println("[나] 답장 " + i);
}
}
public static void main(String[] args) throws InterruptedException {
System.out.println("=== 한 스레드 (받기 끝나야 보내기) ===");
receiving();
sending();
System.out.println();
System.out.println("=== 두 스레드 (동시에) ===");
Thread t1 = new Thread(new Runnable() {
public void run() { receiving(); }
});
Thread t2 = new Thread(new Runnable() {
public void run() { sending(); }
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}📱 코드 실행(실습)은 태블릿·PC에서 이용할 수 있어요. 모바일에서는 위 코드를 눈으로 따라가며 확인하세요.
앞쪽은 상대 메시지 3개가 다 끝난 뒤에야 내 답장이 나옵니다. 뒤쪽은 둘이 섞여 나오죠. 이 차이가 곧 무전기와 채팅의 차이입니다.
단골 함정
join을 빼먹는 것. 스레드를 시작해 놓고 바로 close()를 부르면, 아직 대화 중인데 통로가 닫혀 버립니다. 두 스레드가 끝난 뒤에 닫아야 합니다.
flush를 빼먹는 것. 6편과 8편에서 본 그대로입니다. 보냈다고 생각했는데 버퍼에 고여 있으면 상대는 아무것도 못 받고 계속 기다립니다. 채팅이 먹통이 되는 흔한 원인입니다.
서버를 나중에 켜는 것. 8편에서 본 순서 그대로, 받는 쪽이 먼저 켜져 있어야 합니다.
한 장 정리
| 항목 | 내용 |
|---|---|
| 왜 스레드 둘 | 받기가 멈춰 있어도 보내기가 따로 돌아야 대화가 됨 |
| 받기 스레드 | while로 계속 readUTF(), 받은 것 출력 |
| 보내기 스레드 | while로 계속 입력받아 writeUTF() 후 flush() |
| 서버·클라이언트 | 연결 방법만 다르고 스레드 구조는 동일 |
| 종료 약속 | 특정 글자가 오면 종료 — 이런 규칙이 프로토콜 |
| 끊김 대비 | EOFException을 잡아 안내 후 반복문 종료 |
이것만 기억하면 됩니다. 채팅은 소켓(8편)으로 통로를 만들고, 스레드(7편)로 귀와 입을 따로 다는 것입니다. 한 스레드로는 받는 동안 보낼 수 없어 무전기가 되고, 둘로 나누면 비로소 대화가 됩니다.
미니 퀴즈
개념을 제대로 잡았는지 직접 풀어 봅니다. 정답은 눌러야 나옵니다.
미니 퀴즈
포켓코딩(JAVA3) 9편 · 예제 출처: 강사 개인 GitHub 저장소. 다음 편: 데이터베이스 연결 — JDBC로 자바와 DB 잇기.