두 스레드로 만드는 채팅 클라이언트
콘솔 입력을 기다리는 동안에도 서버 메시지를 받도록 보내기와 받기를 두 스레드로 나누고, 입력을 명령 프레임으로 바꾸는 방법과 연결이 끝날 때의 정리까지 구현합니다.
채팅 클라이언트는 두 가지를 동시에 기다려야 합니다. 하나는 사용자가 키보드로 입력할 문장이고, 다른 하나는 서버가 전해 줄 다른 참가자의 메시지입니다. TCP 첫 연결과 다중 클라이언트 서버의 에코 클라이언트는 문자열 하나를 보내고 응답 하나를 받으면 할 일이 끝났기 때문에 이런 고민이 필요 없었습니다. 채팅은 다릅니다. 내가 아무것도 입력하지 않는 동안에도 다른 사람의 메시지가 도착하고, 서버가 공지를 연달아 보낼 수도 있습니다.
이 장에서는 콘솔에서 동작하는 채팅 프로그램을 만듭니다. 클라이언트와 서버는 앞 장처럼 DataOutputStream.writeUTF로 문자열을 보내고 DataInputStream.readUTF로 받습니다. writeUTF는 문자열 앞에 바이트 길이를 붙여 보내므로, 받는 쪽은 그 길이만큼 읽어 문자열 하나를 정확히 복원할 수 있습니다. 이렇게 길이 정보로 구분되는 메시지 한 덩어리를 프레임(frame)이라고 부릅니다. 이 글에서는 클라이언트를 만들고, 다음 글에서 여러 클라이언트에게 메시지를 전달하는 서버를 만듭니다.
한 스레드로 번갈아 기다리면 생기는 일
가장 먼저 떠오르는 구조는 반복문 안에서 콘솔 한 줄을 읽어 서버로 보내고, 서버 메시지 하나를 받아 출력하는 것입니다. 아래 코드는 컴파일은 되지만 채팅 클라이언트로는 쓸 수 없습니다. 구조의 문제만 보여 주는 반례라서 main은 run을 호출하지 않고 안내 문장만 출력합니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.util.Scanner;
public final class AlternatingChatClient {
static void run(
Scanner console,
DataInputStream input,
DataOutputStream output) throws Exception {
while (true) {
String typed = console.nextLine();
output.writeUTF(typed);
String received = input.readUTF();
System.out.println(received);
}
}
public static void main(String[] args) {
System.out.println("compile-only blocking counterexample");
}
}Scanner.nextLine()과 DataInputStream.readUTF()는 둘 다 블로킹(blocking) 호출입니다. 기다리는 일이 일어날 때까지 반환하지 않고, 호출한 스레드를 그 자리에 멈춰 둔다는 뜻입니다. nextLine()은 사용자가 엔터를 눌러야 반환하고, readUTF()는 서버가 보낸 프레임 하나가 끝까지 도착해야 반환합니다. 그래서 한 스레드가 둘을 번갈아 호출하면, 한쪽을 기다리는 동안 다른 쪽에서 일어난 일은 처리하지 못합니다.
서버가 공지 두 개를 연달아 보냈는데 사용자는 아직 아무것도 입력하지 않은 상황을 따라가 보겠습니다.
| 순서 | 일어난 일 | 클라이언트 스레드의 위치 | 화면에 보이는 것 |
|---|---|---|---|
| 1 | 서버가 공지 1을 보낸다 | nextLine()에서 대기 | 없음 |
| 2 | 서버가 공지 2를 보낸다 | nextLine()에서 대기 | 없음 |
| 3 | 사용자가 hello를 입력한다 | 보낸 뒤 readUTF()가 공지 1을 받는다 | 공지 1 |
| 4 | 사용자가 다음 문장을 고민한다 | 다시 nextLine()에서 대기 | 공지 1 (공지 2는 아직 안 보임) |
도착한 공지는 사라지지 않고 운영체제의 수신 버퍼에 쌓여 있습니다. 하지만 화면에는 사용자가 한 줄을 입력할 때마다 하나씩만 나타납니다. 이 코드는 '내가 한 번 말해야 상대의 말을 한 번 들을 수 있다'는, 채팅에는 없는 규칙을 몰래 만들어 버린 셈입니다.
호출 순서를 뒤집어 readUTF()를 먼저 불러도 해결되지 않습니다. 이번에는 서버가 보낼 메시지가 생기기 전까지 사용자가 아무것도 보낼 수 없습니다. 모든 참가자가 이런 클라이언트를 쓴다면 모두가 상대의 첫 메시지를 기다리기만 하므로 대화가 영원히 시작되지 않습니다.
블로킹 호출이 들어 있는 코드를 읽을 때는 호출마다 '무엇이 이 호출을 깨우는가'를 물어보면 문제가 잘 보입니다. nextLine()을 깨우는 것은 사용자의 엔터이고, readUTF()를 깨우는 것은 서버의 데이터나 연결 종료입니다. 이 두 사건은 서로 상관없이 아무 때나 일어나므로, 각각을 기다리는 스레드도 따로 있어야 합니다.
받는 스레드와 보내는 스레드로 나누기
해결 방법은 기다리는 대상마다 스레드를 하나씩 두는 것입니다. 받는 스레드는 readUTF()로 서버 메시지를 기다렸다가 화면에 출력하는 일만 하고, 보내는 스레드는 nextLine()으로 사용자 입력을 기다렸다가 서버로 보내는 일만 합니다. 한쪽이 멈춰 있어도 다른 쪽은 자기 일을 계속할 수 있습니다.
두 스레드는 같은 연결을 쓰지만 같은 스트림을 함께 쓰지는 않습니다. DataInputStream은 받는 스레드만 읽고, DataOutputStream은 보내는 스레드만 씁니다. writeUTF 한 번은 길이와 본문을 이어 써서 프레임 하나를 만드는데, 여러 스레드가 한 스트림에 동시에 쓰면 프레임이 서로 섞여 깨질 수 있습니다. 실제로 DataOutputStream의 Javadoc은 이 클래스가 여러 스레드의 동시 사용에 안전하지 않으니 그렇게 쓰려면 동기화를 하라고 적고 있습니다. 스트림마다 사용하는 스레드를 하나로 정해 두면 잠금 없이도 이 문제가 생기지 않습니다.
아래 클라이언트에서는 main 스레드가 받는 일을 맡고, 새로 만든 sender 스레드가 보내는 일을 맡습니다. 사용자가 /로 시작하는 줄을 입력하면 명령으로 보고 그대로 보내고, 그 밖의 줄은 앞에 /message|를 붙여 일반 메시지로 보냅니다. /exit를 입력하면 그 명령을 보낸 뒤 입력을 그만 받습니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.Socket;
import java.util.Scanner;
public final class ChatClient {
public static void main(String[] args) throws IOException {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 12345;
try (Socket socket = new Socket("127.0.0.1", port)) {
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream());
Thread sender = new Thread(() -> sendLines(output), "sender");
sender.setDaemon(true); // 콘솔에서 기다리는 중이어도 프로그램 종료를 막지 않는다
sender.start();
receiveFrames(input); // main 스레드는 서버가 연결을 닫을 때까지 받는다
}
}
static void receiveFrames(DataInputStream input) {
try {
while (true) {
System.out.println(input.readUTF());
}
} catch (EOFException e) {
System.out.println("[client] 서버가 연결을 닫았습니다");
} catch (IOException e) {
System.out.println("[client] 연결 오류: " + e.getMessage());
}
}
static void sendLines(DataOutputStream output) {
Scanner console = new Scanner(System.in);
try {
while (console.hasNextLine()) {
String line = console.nextLine();
if (line.isBlank()) {
continue;
}
output.writeUTF(toFrame(line));
if (line.equals("/exit")) {
return;
}
}
output.writeUTF("/exit"); // 입력 끝(Ctrl+D, 윈도우는 Ctrl+Z)도 퇴장으로 처리한다
} catch (IOException e) {
// 연결이 이미 끊겼다. 끝내는 일은 main 스레드가 맡는다.
}
}
static String toFrame(String line) {
return line.startsWith("/") ? line : "/message|" + line;
}
}sender.setDaemon(true)가 이 설계의 핵심입니다. 데몬 스레드는 JVM이 종료를 기다려 주지 않는 스레드입니다. 보내는 스레드는 대부분의 시간을 hasNextLine()에서 사용자 입력을 기다리며 보내는데, 자바에는 이 대기를 확실하게 깨울 방법이 없습니다. 이유는 뒤의 '더 깊이'에서 다룹니다. 데몬으로 만들어 두면 이 스레드가 콘솔에서 기다리는 중이어도 main 스레드가 끝날 때 프로그램 전체가 함께 끝납니다.
그래서 프로그램이 끝나는 순서는 언제나 같습니다. 서버가 연결을 닫으면 readUTF()가 EOFException을 던지고, receiveFrames가 안내 문장을 출력한 뒤 반환합니다. 이어서 try-with-resources가 소켓을 닫고 main이 끝나며, 남은 스레드가 데몬뿐이므로 JVM이 종료합니다. 소켓을 닫으면 소켓에서 얻은 입력·출력 스트림도 함께 닫히므로, 그 스트림을 감싼 DataInputStream과 DataOutputStream은 따로 닫지 않았습니다.
sendLines의 나머지 부분도 짚어 보겠습니다. 빈 줄은 보내지 않고 건너뜁니다. 사용자가 /exit 대신 입력 끝(Linux·macOS는 Ctrl+D, 윈도우는 Ctrl+Z 다음 엔터)을 보내면 hasNextLine()이 false를 반환하는데, 이때도 서버에 /exit를 보내 퇴장을 알립니다. 쓰는 도중 IOException이 나면 연결이 이미 끊긴 것이므로 아무것도 하지 않고 끝냅니다. 연결이 끊긴 사실은 main 스레드의 readUTF()에도 드러나고, 끝내는 일은 그쪽이 맡기 때문입니다. 줄을 프레임으로 바꾸는 규칙은 toFrame 메서드로 따로 떼어 두었는데, 이 규칙은 뒤에서 명령 코덱으로 발전시킵니다.
시험용 서버로 실행해 보기
클라이언트만으로는 실행해 볼 수 없으므로 시험용 서버를 하나 만들겠습니다. 이 서버는 접속한 클라이언트 하나에게 1초 간격으로 공지 세 개를 먼저 보내고, 그다음부터는 받은 프레임 앞에 [echo]를 붙여 돌려줍니다. /exit를 받으면 연결을 닫고 끝납니다. 여러 클라이언트를 받는 진짜 채팅 서버는 다음 글에서 만듭니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
public final class NoticeServer {
public static void main(String[] args) throws Exception {
int port = args.length > 0 ? Integer.parseInt(args[0]) : 12345;
try (ServerSocket server = new ServerSocket(port)) {
System.out.println("[server] " + port + "번 포트에서 접속을 기다립니다");
try (Socket socket = server.accept()) {
var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream());
// 클라이언트가 아무것도 보내지 않아도 서버가 먼저 보낸다.
for (int i = 1; i <= 3; i++) {
output.writeUTF("[notice] 공지 " + i);
Thread.sleep(1000);
}
// 그다음부터는 받은 프레임을 그대로 돌려준다.
while (true) {
String frame = input.readUTF();
if (frame.equals("/exit")) {
break;
}
output.writeUTF("[echo] " + frame);
}
}
}
}
}이 서버는 일부러 단순하게 만들었습니다. 스레드가 하나뿐이라 공지를 다 보낸 다음에야 클라이언트의 프레임을 읽기 시작하지만, 그동안 클라이언트가 보낸 프레임은 서버 쪽 수신 버퍼에 쌓여 있다가 순서대로 읽힙니다. 지금 확인하려는 것은 사용자가 아무것도 입력하지 않은 동안에도 공지가 클라이언트 화면에 나타나는지입니다.
두 파일을 같은 폴더에 두고 컴파일한 뒤, 터미널 두 개를 열어 서버를 먼저 실행하고 클라이언트를 실행합니다. 두 프로그램 모두 포트 번호를 인수로 받고, 인수가 없으면 12345번을 씁니다. 다른 프로그램이 12345번을 쓰고 있어서 서버가 BindException으로 끝나면, 두 프로그램에 같은 번호를 인수로 주면 됩니다.
# 터미널 1
javac NoticeServer.java ChatClient.java
java NoticeServer
# 터미널 2
java ChatClient클라이언트 터미널에서 공지 세 개가 모두 나올 때까지 기다렸다가 hello, 빈 줄, /exit를 차례로 입력해 보세요. Linux나 macOS에서는 직접 치는 대신 (sleep 4; echo hello; echo; echo /exit) | java ChatClient처럼 정해진 시간에 입력을 넘겨도 됩니다. 이렇게 실행했을 때 클라이언트가 출력한 내용은 다음과 같습니다. 직접 입력하면 입력한 줄도 그 사이에 함께 보입니다.
[notice] 공지 1
[notice] 공지 2
[notice] 공지 3
[echo] /message|hello
[client] 서버가 연결을 닫았습니다처음 세 줄은 사용자가 아직 아무것도 입력하지 않은 동안 1초 간격으로 나타납니다. 받는 스레드가 입력과 상관없이 readUTF()에서 기다리다가 프레임이 오는 대로 출력하기 때문입니다. 앞의 반례였다면 이때 화면은 비어 있었을 것입니다. 네 번째 줄은 hello가 toFrame을 거쳐 /message|hello로 바뀌어 서버에 갔다가 돌아온 것이고, 빈 줄은 보내지 않았으므로 아무 응답도 없습니다. /exit를 받은 서버가 연결을 닫자 readUTF()가 EOFException을 던졌고, 클라이언트는 마지막 줄을 출력한 뒤 끝났습니다. 서버 터미널에는 [server] 12345번 포트에서 접속을 기다립니다 한 줄만 남고, 서버도 연결 하나를 처리한 뒤 끝납니다.
서버를 띄우지 않고 클라이언트부터 실행하면 연결할 상대가 없으므로 new Socket이 java.net.ConnectException: Connection refused를 던지며 프로그램이 끝납니다.
입력한 줄을 명령 프레임으로 바꾸기
지금 클라이언트의 변환 규칙은 toFrame 한 줄이 전부입니다. 그런데 이 장의 채팅 프로토콜에는 입장(/join|이름), 메시지(/message|내용), 이름 바꾸기(/change|이름), 사용자 목록(/users), 퇴장(/exit)이 있습니다. 화면 코드 여기저기서 "/join|" + name 같은 문자열을 직접 이어 붙이기 시작하면, 형식을 바꾸거나 잘못 만든 문자열을 찾기가 어려워집니다. 그래서 명령의 종류는 타입으로 표현하고, 타입과 문자열 사이의 변환은 한곳에 모읍니다. 이렇게 변환을 맡는 코드를 코덱(codec)이라고 부릅니다. 코덱은 명령 객체를 프레임 문자열로 바꾸는 encode와, 받은 프레임을 다시 명령 객체로 바꾸는 decode를 함께 가집니다.
import java.util.Objects;
public final class ChatCommandCodec {
sealed interface Command
permits Join, Message, ChangeName, ListUsers, Exit {
}
record Join(String name) implements Command {
Join {
requireText(name, "name");
}
}
record Message(String text) implements Command {
Message {
requireText(text, "message");
}
}
record ChangeName(String name) implements Command {
ChangeName {
requireText(name, "name");
}
}
record ListUsers() implements Command {
}
record Exit() implements Command {
}
static String encode(Command command) {
return switch (command) {
case Join join -> "/join|" + join.name();
case Message message -> "/message|" + message.text();
case ChangeName change -> "/change|" + change.name();
case ListUsers ignored -> "/users";
case Exit ignored -> "/exit";
};
}
static Command decode(String frame) {
Objects.requireNonNull(frame, "frame");
String[] parts = frame.split("\\|", 2);
return switch (parts[0]) {
case "/join" -> new Join(argument(parts, frame));
case "/message" -> new Message(argument(parts, frame));
case "/change" -> new ChangeName(argument(parts, frame));
case "/users" -> new ListUsers();
case "/exit" -> new Exit();
default -> throw new IllegalArgumentException("unknown frame: " + frame);
};
}
private static String argument(String[] parts, String frame) {
if (parts.length != 2) {
throw new IllegalArgumentException("argument missing: " + frame);
}
return parts[1];
}
private static void requireText(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
}
public static void main(String[] args) {
Command command = decode("/message|hello|team");
System.out.println(command);
System.out.println(encode(new Join("Mina")));
System.out.println(encode(command));
}
}Command는 봉인된 인터페이스(sealed interface)입니다. permits 뒤에 적은 다섯 레코드만 이 인터페이스를 구현할 수 있으므로, 컴파일러는 Command의 종류가 정확히 다섯 가지라는 것을 압니다. 그래서 encode의 switch는 다섯 경우를 모두 다루기만 하면 default 없이도 컴파일되고, 나중에 명령을 하나 추가하면서 encode에 그 경우를 빠뜨리면 컴파일 오류로 알려 줍니다. case Join join ->처럼 타입을 검사하면서 변수에 담는 문법은 switch의 패턴 매칭이고, Java 21부터 정식 기능입니다. 두 문법은 sealed 계층과 상태 분기와 패턴 매칭과 데이터 분기에서 자세히 다룹니다.
Join, Message, ChangeName의 컴팩트 생성자(compact constructor)는 이름이나 메시지가 비어 있거나 공백뿐이면 IllegalArgumentException을 던집니다. 잘못된 명령은 아예 객체로 만들어지지 않으므로, 명령 객체를 받는 쪽은 같은 검사를 되풀이하지 않아도 됩니다.
decode는 프레임을 split("\\|", 2)로 나눕니다. split의 인수는 정규 표현식이고, |는 정규 표현식에서 '또는'을 뜻하는 기호입니다. 그래서 글자 그대로의 |로 나누려면 \\|라고 써야 합니다. 두 번째 인수 2는 결과를 최대 두 조각으로 만들라는 뜻이라서, 첫 번째 |에서만 나누고 나머지는 뒤에 |가 더 있어도 두 번째 조각에 그대로 남깁니다. 이 인수를 빼면 "/message|hello|team"이 /message, hello, team 세 조각으로 나뉘어 메시지 본문이 hello만 남습니다.
실행 결과:
Message[text=hello|team]
/join|Mina
/message|hello|team첫 줄은 decode가 첫 구분자에서만 나눈 덕분에 본문 hello|team이 온전히 Message에 담겼다는 것을 보여 줍니다. Message[text=hello|team]이라는 형식은 레코드가 자동으로 만들어 주는 toString()의 출력입니다. 둘째 줄은 Join 객체를 프레임으로 바꾼 결과이고, 셋째 줄은 방금 해독한 명령을 다시 프레임으로 바꾸면 원래 문자열과 같아진다는 것을 보여 줍니다.
이 코덱은 형식만 다룬다는 점도 알아 두어야 합니다. '입장하기 전에 메시지를 보낼 수 있는가' 같은 규칙은 세션의 상태를 아는 쪽에서 판단해야 하며, 채팅 명령 파싱과 상태 검사에서 다룹니다. 문자열 해석과 상태 규칙을 한 메서드에 섞으면 형식이 틀린 입력과 순서가 틀린 입력이 같은 예외로 나와서 원인을 가려내기 어렵습니다.
검사도 일부러 최소한만 넣었습니다. 빈 인수와 알 수 없는 명령은 거절하지만, 이름 안의 |나 제어 문자, 길이 상한은 검사하지 않습니다. /users|extra나 /exit|extra처럼 필요 없는 인수가 붙어 있어도 조용히 무시합니다. 실제 서비스라면 적어도 길이 상한은 코덱 입구에서 검사해야, 아주 큰 입력이 서버 안쪽까지 들어오지 않습니다.
확인 문제
ChatCommandCodec으로 /message| hello|team 을 decode한 뒤 다시 encode하면 앞뒤 공백과 두 번째 |는 어떻게 될까요? /users|extra와 /join|은 각각 받아들여질까요? 실행하기 전에 먼저 예측해 보세요.
확인 문제 해설
첫 번째 |에서만 나누므로 메시지 본문은 앞뒤 공백까지 포함한 " hello|team "이고, 다시 encode한 프레임도 입력과 똑같습니다. requireText는 공백뿐인지만 검사하고 앞뒤 공백을 지우지는 않기 때문입니다. /users|extra는 인수를 읽지 않고 ListUsers를 돌려줍니다. 반면 /join|은 split이 뒤쪽의 빈 조각까지 남기므로 인수가 빈 문자열이 되고, Join의 생성자가 name must not be blank라는 메시지와 함께 IllegalArgumentException을 던집니다. limit에 양수를 주면 마지막 조각에 나머지 입력이 모두 남고 뒤쪽의 빈 조각도 버리지 않는다는 규칙은 String.split의 Javadoc에 나와 있습니다. 비교해 보면, limit 없이 "/join|".split("\\|")를 호출하면 뒤쪽의 빈 조각이 버려져 /join 한 조각만 남습니다.
더 깊이: writeUTF 프레임의 실제 바이트와 한계
writeUTF("hi")가 실제로 쓰는 바이트는 00 02 68 69 네 개입니다. 앞의 두 바이트는 뒤따르는 바이트 수 2를 writeShort와 같은 방식, 즉 높은 자리 바이트부터 쓴 것이고, 뒤의 두 바이트가 h와 i입니다. readUTF는 먼저 두 바이트를 읽어 길이를 알아낸 다음 정확히 그만큼 더 읽습니다. 그래서 TCP가 데이터를 어떻게 나누어 전달하든 프레임 하나를 그대로 복원합니다.
이 형식에는 한계가 두 가지 있습니다. 첫째, 길이를 2바이트에 담으므로 인코딩된 본문은 65535바이트를 넘을 수 없고, 넘으면 writeUTF가 UTFDataFormatException을 던집니다. 한글 음절은 3바이트씩 차지하므로 프레임 하나에 담을 수 있는 한글은 21845자까지입니다. 둘째, 본문은 표준 UTF-8이 아니라 자바의 수정된 UTF-8(modified UTF-8)로 인코딩됩니다. 대부분의 문자는 표준 UTF-8과 같은 바이트가 되지만, 문자 코드 0과 이모지 같은 보충 문자는 다르게 인코딩됩니다.
| 문자열 | writeUTF가 쓰는 바이트 수 | 표준 UTF-8 바이트 수 |
|---|---|---|
hello | 7 (길이 2 + 본문 5) | 5 |
안녕 | 8 (길이 2 + 본문 6) | 6 |
😀 | 8 (길이 2 + 본문 6) | 4 |
| 문자 코드 0 하나 | 4 (길이 2 + 본문 2) | 1 |
이모지 하나는 자바 문자열에서 char 두 개(서로게이트 쌍)로 표현되는데, 수정된 UTF-8은 이 둘을 각각 3바이트로 인코딩합니다. 다른 언어의 표준 UTF-8 디코더는 이런 바이트를 올바른 문자로 읽지 못할 수 있습니다. 이 장처럼 양쪽이 모두 자바라면 writeUTF가 간편하지만, 다른 언어로 만든 프로그램과 통신해야 한다면 표준 UTF-8 바이트 수를 직접 앞에 붙이거나 줄바꿈으로 메시지를 구분하는 형식을 정해야 합니다. 길이 제한과 인코딩 방식은 DataOutputStream.writeUTF의 Javadoc에서 확인할 수 있습니다.
연결이 끝나는 경우를 한곳으로 모으기
연결은 여러 방식으로 끝납니다. 사용자가 /exit를 입력할 수도 있고, 서버가 먼저 연결을 닫거나 네트워크 오류가 날 수도 있습니다. 이때 두 스레드가 각자 소켓을 닫거나 서로를 멈추려고 하면, 누가 무엇을 언제 닫았는지 따지기가 금세 복잡해집니다. 그래서 이 클라이언트는 끝내는 일을 받는 스레드인 main 한곳에 모았습니다. 어떤 방식으로 끝나든 연결이 끊긴 사실은 결국 readUTF()의 예외로 드러나기 때문입니다.
| 끝나는 계기 | 보내는 스레드(sender) | 받는 스레드(main) |
|---|---|---|
사용자가 /exit 입력 | /exit를 보내고 끝남 | 서버가 연결을 닫으면 EOFException으로 반복 종료 |
| 콘솔 입력 끝(Ctrl+D) | /exit를 대신 보내고 끝남 | 위와 같음 |
| 서버가 먼저 연결을 닫음 | 콘솔에서 기다리던 중, 데몬이므로 JVM과 함께 끝남 | EOFException으로 반복 종료 |
| 연결이 비정상으로 끊김 | 콘솔에서 기다리던 중이거나, 쓰다가 IOException을 받고 끝남 | IOException으로 반복 종료 |
네 경우 모두 main이 receiveFrames를 빠져나오면 try-with-resources가 소켓을 한 번 닫고 프로그램이 끝납니다. 보내는 스레드는 아무것도 닫지 않습니다. 닫는 곳이 한 군데뿐이니 같은 소켓을 두 번 닫거나, 한 스레드가 아직 쓰는 스트림을 다른 스레드가 닫는 일을 따로 걱정하지 않아도 됩니다.
사용자가 /exit를 입력했을 때 클라이언트가 바로 소켓을 닫지 않고 서버가 닫기를 기다린다는 점도 눈여겨보세요. 서버는 /exit를 읽은 뒤 연결을 닫으므로, 그 전에 서버가 보낸 메시지는 모두 클라이언트에 도착합니다. 클라이언트가 먼저 닫아 버리면 그 사이에 오던 메시지를 잃고, 이미 닫힌 소켓으로 데이터가 계속 들어오면 클라이언트 쪽 운영체제가 연결을 강제로 끊는 RST를 보낼 수도 있습니다. FIN과 RST의 차이는 TCP 연결 종료: EOF, 반쪽 닫기, RST에서 다뤘습니다.
더 깊이: 콘솔 입력은 왜 깨우기 어려운가
블로킹된 스레드를 깨우는 방법은 무엇을 기다리느냐에 따라 다릅니다. 소켓은 비교적 쉽습니다. Socket.close()의 Javadoc은 그 소켓에서 I/O 작업 중 블로킹된 스레드가 SocketException을 받는다고 명시합니다. 그래서 다른 스레드에서 소켓을 닫으면 readUTF()에서 기다리던 스레드를 확실히 깨울 수 있습니다.
콘솔 입력에는 그런 방법이 없습니다. System.in.close()가 기다리던 읽기를 깨운다는 보장은 Javadoc 어디에도 없습니다. 게다가 System.in은 프로세스 전체가 함께 쓰는 자원이라서, 닫으면 같은 프로그램의 다른 코드도 더는 입력을 받을 수 없습니다. 이 글을 쓰면서 Linux와 JDK 21에서 시험해 보니, nextLine()에서 기다리는 스레드는 다른 스레드가 System.in.close()를 호출해도, 그 스레드에 interrupt()를 호출해도 2초 뒤까지 RUNNABLE 상태로 계속 기다렸습니다. 입력이 파이프일 때와 터미널일 때 모두 그랬습니다. 운영체제나 실행 환경에 따라 결과가 다를 수 있으니, 어느 쪽이든 이런 동작에 기대어 설계해서는 안 됩니다.
그래서 이 클라이언트는 콘솔 스레드를 깨우려 하지 않고, 데몬으로 만들어 프로그램이 끝날 때 함께 사라지게 했습니다. 이 방법은 연결이 끝나면 프로그램도 끝나는 콘솔 도구이기 때문에 쓸 수 있습니다. GUI 클라이언트처럼 연결이 끊긴 뒤에도 프로그램이 계속 살아 있어야 한다면, 종료를 맡는 객체를 하나 두고 그 객체가 단 한 번만 소켓을 닫고 입력 쪽에 그만 받으라는 신호를 보내게 만듭니다. GUI에서는 입력이 이벤트로 들어오므로 기다리는 스레드를 깨울 필요 없이 입력 처리를 멈추기만 하면 됩니다. 아래 연습 문제 3이 이런 종료 객체를 만드는 문제입니다.
연습 문제
AlternatingChatClient가 서버에 연결되어 있고, 서버가 공지 세 개를 보낸 뒤 사용자가 두 줄을 입력했습니다. 화면에 나온 공지는 몇 개이고, 나머지 공지는 어디에 있을까요?ChatClient에서sender.setDaemon(true)줄을 지우고, 사용자가 아무것도 입력하지 않은 상태에서 서버를 강제로 끄면 클라이언트는 어떻게 될까요?- 다음 조건을 만족하는 종료 객체
ClientLifetime을 작성하세요.close(reason)은 여러 스레드에서 동시에 호출될 수 있습니다.- 닫힘 상태로 바꾸는 CAS에 먼저 성공한 호출의 이유만 남깁니다.
- 등록된 취소 작업은 각각 정확히 한 번 실행합니다.
- 취소 작업 하나가
RuntimeException을 던져도 나머지 작업은 실행합니다.
힌트
- 표의 3번처럼, 한 줄을 입력할 때마다
readUTF()가 한 번 불립니다. - 데몬이 아닌 스레드가 하나라도 살아 있으면 JVM은 끝나지 않습니다. 그 스레드가 지금 어디서 기다리고 있는지 생각해 보세요.
- 닫힘 여부와 이유를 두 변수에 따로 두면, 둘을 한 번에 바꿀 수 없습니다. 둘을 담은 불변 객체 하나를
AtomicReference로 바꿔 끼워 보세요.
해설
-
두 개입니다. 사용자가 한 줄을 입력할 때마다
readUTF()가 프레임을 하나씩 꺼내므로 공지 1과 공지 2만 출력됩니다. 공지 3은 사라지지 않고 클라이언트 운영체제의 소켓 수신 버퍼에 남아 있다가, 사용자가 세 번째 줄을 입력해야 비로소 화면에 나옵니다. -
클라이언트는
[client] 서버가 연결을 닫았습니다를 출력하고도 끝나지 않습니다.main스레드는 끝났지만 데몬이 아닌sender스레드가 콘솔 입력을 기다리며 살아 있기 때문입니다. 공백이 아닌 줄을 입력하면 그제야 닫힌 소켓에 쓰려다IOException이 나서sender가 끝나고, 그때 JVM도 종료합니다. 입력 끝(Ctrl+D)을 보내도/exit를 쓰다가 같은 이유로 끝납니다. 클라이언트를 실행하고 2.5초 뒤에 시험용 서버를 강제 종료한 다음 8초째에hello를 넘기는 실험에서, 안내 문장은 2.5초 무렵 나왔지만 프로세스는 8초가 지나서야 끝났습니다. 데몬으로 둔 원래 코드는 같은 실험에서 서버가 꺼진 직후 바로 끝났습니다. -
닫힘 여부와 이유를 따로 관리하면 두 값이 엇갈릴 수 있습니다. 예를 들어 한 스레드가 닫힘 플래그를 바꾼 직후에는 다른 스레드가 '닫혔는데 이유는 아직 없는' 상태를 볼 수 있습니다. 불변
State하나를AtomicReference로 통째로 바꾸면 상태 전환과 이유 저장이 한 번의 원자 연산으로 묶입니다. 취소 작업이 던진RuntimeException은 목록에 모아 CAS에 성공한 호출자에게 돌려줍니다.
한 가지 구현
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
public final class ClientLifetimeSolution {
enum Reason {
USER_EXIT,
REMOTE_EOF,
READ_FAILURE,
UI_CLOSED
}
record State(boolean closed, Reason reason) {
static State open() {
return new State(false, null);
}
}
private static final State OPEN = State.open();
private final AtomicReference<State> state =
new AtomicReference<>(OPEN);
private final List<Runnable> cancelActions;
ClientLifetimeSolution(List<Runnable> cancelActions) {
this.cancelActions = List.copyOf(cancelActions);
}
List<RuntimeException> close(Reason reason) {
State closed = new State(true, reason);
if (!state.compareAndSet(OPEN, closed)) {
return List.of();
}
List<RuntimeException> failures = new ArrayList<>();
for (Runnable action : cancelActions) {
try {
action.run();
} catch (RuntimeException error) {
failures.add(error);
}
}
return List.copyOf(failures);
}
State snapshot() {
return state.get();
}
public static void main(String[] args) {
List<String> events = new ArrayList<>();
var lifetime = new ClientLifetimeSolution(List.of(
() -> events.add("socket closed"),
() -> events.add("input cancelled")));
System.out.println(lifetime.close(Reason.REMOTE_EOF));
System.out.println(lifetime.close(Reason.USER_EXIT));
System.out.println(lifetime.snapshot());
System.out.println(events);
}
}실행 결과:
[]
[]
State[closed=true, reason=REMOTE_EOF]
[socket closed, input cancelled]main은 두 호출을 차례로 실행합니다. 첫 줄의 []는 REMOTE_EOF 호출이 CAS에 성공해 취소 작업 두 개를 모두 실행했고 실패가 하나도 없었다는 뜻입니다. 둘째 줄의 []는 겉모습은 같지만 뜻이 다릅니다. USER_EXIT 호출은 CAS에 실패해 아무 작업도 실행하지 않고 곧바로 List.of()를 돌려준 것입니다. 셋째 줄은 첫 이유인 REMOTE_EOF가 그대로 남았다는 것을, 넷째 줄은 각 취소 작업이 정확히 한 번씩 실행됐다는 것을 보여 줍니다.
OPEN을 상수 하나로 만들어 둔 데는 이유가 있습니다. AtomicReference.compareAndSet은 기댓값을 equals가 아니라 ==로, 즉 같은 객체인지로 비교합니다. compareAndSet(State.open(), closed)처럼 내용이 같은 새 객체를 기댓값으로 넘기면, 레코드끼리 equals로는 같아도 다른 객체이므로 CAS는 언제나 실패합니다.
닫힘 상태 게시와 취소 작업 완료의 구별
CAS로 종료 상태와 이유를 먼저 공개한 다음 action들을 실행해 실패를 모읍니다. 중복 호출의 즉시 반환·RuntimeException 이후 계속·Error 중단을 완료 상태와 구분할 수 있습니다.
ClientLifetimeSolution은 CAS로 상태를 먼저 게시한 뒤 성공한 호출 하나만 취소 작업을 순회합니다.
수명 종료 · 상태 공개와 작업 완료
닫힘 상태 게시와 취소 작업 완료의 구별
닫힘 상태와 이유는 CAS 성공 시 먼저 게시됩니다. 다른 close 호출은 취소 작업이 끝나기 전에 빈 목록을 반환할 수 있습니다.
CAS 성공
- 상태와 반환
- closed=true와 이유를 먼저 저장
- 취소 작업
- 등록 순서대로 각 작업 시도
CAS 실패
- 상태와 반환
- 빈 실패 목록을 즉시 반환
- 취소 작업
- 실행하지 않음 · 승리자 완료도 기다리지 않음
RuntimeException 발생
- 상태와 반환
- 승리자의 실패 목록에 수집
- 취소 작업
- 다음 작업도 시도
Error 발생
- 상태와 반환
- 이 구현은 잡지 않고 전파
- 취소 작업
- 뒤 작업을 실행한다는 보장 없음
정상 main은 REMOTE_EOF 다음 USER_EXIT을 순차 호출합니다. 여러 스레드 경쟁이나 실제 콘솔·소켓 해제를 실행하는 예제는 아닙니다.
이 예의 조건
- 자원 해제 성공은 별도입니다. Error는 잡지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | CAS 성공이 닫힘 상태와 첫 이유를 원자적으로 먼저 게시한다. 실패 호출은 빈 목록을 즉시 반환하며 승리자의 완료를 기다리지 않는다. 성공 호출은 등록 순서로 각 작업을 시도한다. 정상 반환과 RuntimeException 수집 모두 다음 작업 검사로 돌아가며 작업이 없을 때 실패 목록을 반환한다. Error는 잡지 않고 전파하며 뒤 작업을 보장하지 않는다. 실선은 제어 흐름이고 해제의 실제 성공/실제 소켓 검증을 뜻하지 않는다. |
| 구분 | 내용 |
|---|---|
| 설명 | CAS 성공이 닫힘 상태와 첫 이유를 원자적으로 먼저 게시한다. 실패 호출은 빈 목록을 즉시 반환하며 승리자의 완료를 기다리지 않는다. 성공 호출은 등록 순서로 각 작업을 시도한다. 정상 반환과 RuntimeException 수집 모두 다음 작업 검사로 돌아가며 작업이 없을 때 실패 목록을 반환한다. Error는 잡지 않고 전파하며 뒤 작업을 보장하지 않는다. 실선은 제어 흐름이고 해제의 실제 성공/실제 소켓 검증을 뜻하지 않는다. |
| 전체 읽기 정보 | 닫힘 상태 게시와 취소 작업 완료의 구별 닫힘 상태 게시와 취소 작업 완료의 구별 ClientLifetimeSolution은 CAS로 상태를 먼저 게시한 뒤 성공한 호출 하나만 취소 작업을 순회합니다. 수명 종료 · 상태 공개와 작업 완료 닫힘 상태 게시와 취소 작업 완료의 구별 닫힘 상태와 이유는 CAS 성공 시 먼저 게시됩니다. 다른 close 호출은 취소 작업이 끝나기 전에 빈 목록을 반환할 수 있습니다. CAS 성공 상태와 반환 closed=true와 이유를 먼저 저장 취소 작업 등록 순서대로 각 작업 시도 CAS 실패 상태와 반환 빈 실패 목록을 즉시 반환 취소 작업 실행하지 않음 · 승리자 완료도 기다리지 않음 RuntimeException 발생 상태와 반환 승리자의 실패 목록에 수집 취소 작업 다음 작업도 시도 Error 발생 상태와 반환 이 구현은 잡지 않고 전파 취소 작업 뒤 작업을 실행한다는 보장 없음 정상 main은 REMOTE_EOF 다음 USER_EXIT을 순차 호출합니다. 여러 스레드 경쟁이나 실제 콘솔·소켓 해제를 실행하는 예제는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 닫힘 게시 CAS 승리자가 닫힘 상태와 첫 이유를 게시합니다. |
|
|
| 콜백 순회 등록 순서로 action.run을 시도합니다. |
|
|
| 끝 또는 Error 정상 순회 끝은 실패 목록을 반환합니다. |
|
|
닫힘 상태 게시와 취소 작업 완료의 구별
CAS로 종료 상태와 이유를 먼저 공개한 다음 action들을 실행해 실패를 모읍니다. 중복 호출의 즉시 반환·RuntimeException 이후 계속·Error 중단을 완료 상태와 구분할 수 있습니다.
ClientLifetimeSolution은 CAS로 상태를 먼저 게시한 뒤 성공한 호출 하나만 취소 작업을 순회합니다.
수명 종료 · 상태 공개와 작업 완료
닫힘 상태 게시와 취소 작업 완료의 구별
닫힘 상태와 이유는 CAS 성공 시 먼저 게시됩니다. 다른 close 호출은 취소 작업이 끝나기 전에 빈 목록을 반환할 수 있습니다.
CAS 성공
- 상태와 반환
- closed=true와 이유를 먼저 저장
- 취소 작업
- 등록 순서대로 각 작업 시도
CAS 실패
- 상태와 반환
- 빈 실패 목록을 즉시 반환
- 취소 작업
- 실행하지 않음 · 승리자 완료도 기다리지 않음
RuntimeException 발생
- 상태와 반환
- 승리자의 실패 목록에 수집
- 취소 작업
- 다음 작업도 시도
Error 발생
- 상태와 반환
- 이 구현은 잡지 않고 전파
- 취소 작업
- 뒤 작업을 실행한다는 보장 없음
정상 main은 REMOTE_EOF 다음 USER_EXIT을 순차 호출합니다. 여러 스레드 경쟁이나 실제 콘솔·소켓 해제를 실행하는 예제는 아닙니다.
이 예의 조건
- 자원 해제 성공은 별도입니다. Error는 잡지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | CAS 성공이 닫힘 상태와 첫 이유를 원자적으로 먼저 게시한다. 실패 호출은 빈 목록을 즉시 반환하며 승리자의 완료를 기다리지 않는다. 성공 호출은 등록 순서로 각 작업을 시도한다. 정상 반환과 RuntimeException 수집 모두 다음 작업 검사로 돌아가며 작업이 없을 때 실패 목록을 반환한다. Error는 잡지 않고 전파하며 뒤 작업을 보장하지 않는다. 실선은 제어 흐름이고 해제의 실제 성공/실제 소켓 검증을 뜻하지 않는다. |
| 구분 | 내용 |
|---|---|
| 설명 | CAS 성공이 닫힘 상태와 첫 이유를 원자적으로 먼저 게시한다. 실패 호출은 빈 목록을 즉시 반환하며 승리자의 완료를 기다리지 않는다. 성공 호출은 등록 순서로 각 작업을 시도한다. 정상 반환과 RuntimeException 수집 모두 다음 작업 검사로 돌아가며 작업이 없을 때 실패 목록을 반환한다. Error는 잡지 않고 전파하며 뒤 작업을 보장하지 않는다. 실선은 제어 흐름이고 해제의 실제 성공/실제 소켓 검증을 뜻하지 않는다. |
| 전체 읽기 정보 | 닫힘 상태 게시와 취소 작업 완료의 구별 닫힘 상태 게시와 취소 작업 완료의 구별 ClientLifetimeSolution은 CAS로 상태를 먼저 게시한 뒤 성공한 호출 하나만 취소 작업을 순회합니다. 수명 종료 · 상태 공개와 작업 완료 닫힘 상태 게시와 취소 작업 완료의 구별 닫힘 상태와 이유는 CAS 성공 시 먼저 게시됩니다. 다른 close 호출은 취소 작업이 끝나기 전에 빈 목록을 반환할 수 있습니다. CAS 성공 상태와 반환 closed=true와 이유를 먼저 저장 취소 작업 등록 순서대로 각 작업 시도 CAS 실패 상태와 반환 빈 실패 목록을 즉시 반환 취소 작업 실행하지 않음 · 승리자 완료도 기다리지 않음 RuntimeException 발생 상태와 반환 승리자의 실패 목록에 수집 취소 작업 다음 작업도 시도 Error 발생 상태와 반환 이 구현은 잡지 않고 전파 취소 작업 뒤 작업을 실행한다는 보장 없음 정상 main은 REMOTE_EOF 다음 USER_EXIT을 순차 호출합니다. 여러 스레드 경쟁이나 실제 콘솔·소켓 해제를 실행하는 예제는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 닫힘 게시 CAS 승리자가 닫힘 상태와 첫 이유를 게시합니다. |
|
|
| 콜백 순회 등록 순서로 action.run을 시도합니다. |
|
|
| 끝 또는 Error 정상 순회 끝은 실패 목록을 반환합니다. |
|
|
위 단계에서 보듯 닫힘 상태와 이유는 CAS가 성공하는 순간 다른 스레드에 먼저 보이고, 취소 작업은 그다음에 실행됩니다. 따라서 CAS에 진 호출이 빈 목록을 돌려받았다거나 snapshot().closed()가 true라는 사실만으로 취소 작업이 끝났다고 판단할 수는 없습니다. 이 구현은 Error도 잡지 않으므로, 취소 작업에서 Error가 나면 그대로 전파되고 뒤에 남은 작업은 실행되지 않을 수 있습니다. 취소 작업이 정상 반환했다고 해서 자원이 실제로 해제됐다는 보장도 없습니다. 여기서는 소켓 대신 기록만 남기는 Runnable을 썼기 때문입니다.
예외가 나는 경로도 확인해 볼 수 있습니다. 첫 취소 작업이 socket attempted를 기록한 뒤 IllegalStateException을 던지고, 둘째 작업이 input cancelled를 기록하게 바꿔 실행하면 REMOTE_EOF 호출은 실패 하나가 든 목록을, USER_EXIT 호출은 빈 목록을 돌려줍니다. 기록에는 두 항목이 모두 남고 이유는 여전히 REMOTE_EOF입니다. 더 나아가 스레드 열 개가 서로 다른 이유로 동시에 close를 호출하게 만들고, 기록이 두 줄만 남는지와 남은 이유가 호출자 가운데 하나인지 검사해 보세요.
정리
- 블로킹 호출은 '무엇이 이 호출을 깨우는가'로 읽습니다. 콘솔 입력과 서버 수신은 서로 다른 사건이 깨우므로 한 스레드가 번갈아 기다리면 안 되고, 스레드를 따로 두어야 합니다.
- 스트림마다 쓰는 스레드를 하나로 정합니다.
DataInputStream은 받는 스레드만,DataOutputStream은 보내는 스레드만 씁니다.DataOutputStream은 여러 스레드의 동시 사용에 안전하지 않습니다. - 끝내는 일은 받는 쪽 한곳에서 합니다. 연결이 어떻게 끊기든
readUTF()의 예외로 드러나기 때문입니다. 확실히 깨울 수 없는 콘솔 스레드는 데몬으로 만들어 프로그램 종료를 막지 않게 합니다. - 명령과 문자열 사이의 변환은 코덱 한곳에 모읍니다. 봉인된 인터페이스와
switch를 함께 쓰면 빠뜨린 명령을 컴파일러가 찾아 주고,split("\\|", 2)는 첫 구분자에서만 나눕니다. writeUTF프레임은 2바이트 길이와 수정된 UTF-8 본문으로 이루어지므로, 본문은 65535바이트까지이고 양쪽이 모두 자바일 때 쓰기 좋습니다.
다음 글
채팅 서버의 세션 관리와 브로드캐스트에서는 이 클라이언트가 접속할 진짜 서버를 만듭니다. 서버는 접속한 연결을 세션 목록으로 관리하고, 한 사람이 보낸 메시지를 모든 참가자에게 전달합니다. 여러 스레드가 같은 목록을 고치고 같은 연결에 쓰는 상황에서 잠금을 어디까지 잡아야 하는지가 핵심입니다.