채팅 명령 파싱과 상태 검사
채팅 명령을 문법 검사, 입장 상태 검사, 실행의 세 단계로 나눠 처리하고, 이름 변경과 잘못된 입력을 서버가 안전하게 다루는 방법을 배웁니다.
채팅 서버의 세션 관리와 브로드캐스트에서 만든 ChatServer는 여러 연결을 받아 두고 한 사람의 메시지를 모두에게 보낼 수 있습니다. 다만 명령은 /message|와 /exit 두 가지만 알아듣고, 참가자도 s1 같은 세션 ID로 표시했습니다. 이 글에서는 사용자 이름과 나머지 명령을 더하면서, 클라이언트가 보낸 프레임(frame), 즉 writeUTF로 한 번에 보낸 문자열 하나가 무슨 뜻인지 해석하는 부분을 제대로 설계합니다.
이 장의 채팅 프로토콜에는 명령이 다섯 가지 있습니다. /join|Mina로 입장하고, /message|hello로 메시지를 보내고, /change|Nari로 이름을 바꾸고, /users로 접속자 목록을 보고, /exit로 나갑니다. 명령 이름과 인수 사이는 |로 구분하며, /users와 /exit는 인수를 받지 않습니다.
명령마다 모양만 다른 것이 아니라 허용되는 때도 다릅니다. 아직 입장하지 않은 사람이 /message를 보내면 누구의 말로 표시해야 할지 알 수 없으니 거절해야 하고, 이미 입장한 사람이 /join을 다시 보내는 것도 막아야 합니다. 이 글에서는 먼저 이런 규칙을 조건문 하나에 몰아넣으면 무엇이 잘못되는지 확인합니다. 그다음 명령 처리를 문법 검사, 상태 검사, 실행의 세 단계로 나누고, 이름이 겹치지 않게 바꾸는 방법과 잘못된 입력에 서버가 어떻게 반응할지 정하는 방법을 차례로 다룹니다. 예제는 소켓 없이 메서드를 직접 호출해 확인하고, 이렇게 만든 명령 처리를 실제 서버에 연결하는 일은 다음 글의 마지막 절에서 합니다.
startsWith로 명령을 고르면 생기는 문제
명령이 몇 개 없을 때 가장 먼저 떠오르는 방법은 프레임이 어떤 문자열로 시작하는지 보고 분기하는 것입니다. 앞 글의 ChatServer도 프레임이 /message|로 시작하는지 보고 분기했고, 명령이 두 개일 때는 그것으로 충분했습니다. 아래 execute는 같은 방식으로 명령을 늘려 가다 흔히 만들게 되는 코드입니다. /join이면 이름을 정하고, /message면 모두에게 보내고, /exit면 연결을 끝내려고 IOException을 던집니다. main은 이 방식이 잘못 처리하는 프레임 네 개를 차례로 넣고, 세션에 어떤 호출이 일어났는지 출력합니다.
import java.io.IOException;
import java.util.List;
public final class ConditionalCommandManager {
interface Session {
void setName(String name);
void sendAll(String message);
}
static void execute(String frame, Session session) throws IOException {
if (frame.startsWith("/join")) {
String[] parts = frame.split("\\|");
session.setName(parts[1]);
} else if (frame.startsWith("/message")) {
String[] parts = frame.split("\\|");
session.sendAll(parts[1]);
} else if (frame.startsWith("/exit")) {
throw new IOException("exit");
}
}
public static void main(String[] args) {
Session session = new Session() {
@Override
public void setName(String name) {
System.out.println(" setName(" + name + ")");
}
@Override
public void sendAll(String message) {
System.out.println(" sendAll(" + message + ")");
}
};
for (String frame : List.of(
"/message-old|x",
"/message|hello|team",
"/join",
"/exit")) {
System.out.println(frame);
try {
execute(frame, session);
} catch (IOException | RuntimeException error) {
System.out.println(" " + error);
}
}
}
}main은 프레임을 먼저 찍고, 익명 Session은 호출된 메서드를 두 칸 들여 써서 찍습니다. 그래서 들여 쓰지 않은 줄은 넣은 프레임이고, 그 아래 들여 쓴 줄은 그 프레임이 일으킨 호출이나 예외입니다.
/message-old|x
sendAll(x)
/message|hello|team
sendAll(hello)
/join
java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
/exit
java.io.IOException: exit네 프레임 모두 의도와 다르게 처리됐습니다. 첫 번째 /message-old|x는 프로토콜에 없는 명령인데도 startsWith("/message")를 통과해 모두에게 전송됐습니다. 접두사로 비교하면 /messageBackup이나 /joiner처럼 정상 명령 이름으로 시작하는 문자열을 전부 그 명령으로 오해합니다. 두 번째 줄에서는 메시지 본문 hello|team이 hello로 잘렸습니다. split("\\|")는 구분자가 나올 때마다 문자열을 나누므로, 본문에 |가 들어 있으면 그 뒤가 parts[2]로 떨어져 나가 버립니다.
세 번째 /join에는 인수가 없어서 split 결과가 원소 하나짜리 배열인데, 코드가 parts[1]을 읽다가 ArrayIndexOutOfBoundsException이 났습니다. /join|처럼 구분자만 있고 이름이 비어 있어도 결과는 같습니다. limit 인수 없이 split을 부르면 끝에 생긴 빈 문자열을 버리기 때문입니다. 이 main은 예외를 잡아서 출력했지만, 실제 세션 루프가 이 예외를 잡지 않으면 사용자의 입력 실수 하나가 그 연결 전체를 끊습니다.
마지막 /exit는 사용자가 정상적으로 나가는 경우인데 IOException으로 표현됐습니다. 이렇게 하면 세션 루프는 이 예외와 소켓이 실제로 끊어져서 생긴 IOException을 구별할 수 없습니다. 그 결과 사용자가 평범하게 나갈 때마다 네트워크 오류 로그가 쌓이고, 장애가 얼마나 자주 나는지 보여 주는 통계도 부풀려집니다.
명령 처리를 세 단계로 나누기
앞의 네 가지 문제는 성격이 서로 다릅니다. /message-old|x와 /join은 프레임 모양 자체가 틀린 문법 오류이고, /message|hello|team은 올바른 프레임을 파서가 잘못 자른 경우입니다. 반면 입장하기 전에 보낸 /message|hi는 모양은 올바르지만 지금 상태에서 허용되지 않는 요청이고, /exit는 아예 오류가 아니라 정상 명령입니다. 성격이 다른 일을 한 메서드의 조건문에서 함께 처리했기 때문에 결과도 뒤섞였습니다. 그래서 명령 처리를 다음 세 단계로 나눕니다.
| 단계 | 확인하는 것 | 걸리는 예 | 걸렸을 때의 대응 |
|---|---|---|---|
| 파싱(문법 검사) | 명령 이름이 정확히 일치하는지, 인수가 필요한 명령에 인수가 있는지, 길이가 상한 안인지 | /message-old|x, /join, /users|x | 올바른 형식을 알려 준다 |
| 상태 검사 | 현재 세션 상태에서 허용되는 명령인지 | 입장 전의 /message|hi, 입장 후의 /join|Jin | 먼저 해야 할 일을 알려 준다 |
| 실행 | 이름 저장소와 사용자 목록을 바꾸고, 누구에게 무엇을 보낼지 정한다 | 정상 입력이라면 실패하지 않아야 한다 | 서버 버그이므로 기록하고 그 세션만 닫는다 |
단계를 나누는 이유는 오류마다 대응이 달라야 하기 때문입니다. 문법 오류와 상태 오류는 사용자가 입력을 고치면 해결되므로, 그 연결에 안내 메시지를 보내고 연결은 그대로 둡니다. 실행 단계에서 예외가 난다면 사용자의 잘못이 아니라 서버 코드의 버그입니다. 이때는 사용자에게 일반적인 오류만 알리고, 원인은 서버 로그에서 따로 추적합니다. 오류 종류별로 통계를 따로 내면 형식을 자주 틀리는 클라이언트와 사용 순서를 헷갈리는 사용자를 구별해서 볼 수도 있습니다.
/exit는 예외가 아니라 값으로 표현합니다. 퇴장은 예상된 정상 흐름이므로 '응답을 보낸 뒤 연결을 닫으라'는 결과 값을 돌려주면 충분합니다. 뒤의 예제에서는 이 값이 Close입니다. IOException은 소켓 쓰기 실패처럼 정말로 예상하지 못한 전송 오류에만 남겨 둡니다.
파싱 결과와 처리 결정을 타입으로 나타내기
아래 예제는 앞의 단계를 두 메서드로 나눕니다. parse는 프레임 문자열을 받아 문법만 검사하고, 성공하면 명령 객체를 담은 Parsed를, 실패하면 이유를 담은 SyntaxError를 돌려줍니다. dispatch는 파싱된 명령과 세션 상태를 받아 상태 검사와 실행을 하고, 결정(decision) 하나를 돌려줍니다. 결정은 네 종류로, Reply는 보낸 사람에게만 답하고, Broadcast는 모두에게 보내고, Close는 응답한 뒤 연결을 닫고, Rejected는 상태 때문에 거절한다는 뜻입니다. 실제 네트워크 코드는 이 결정만 보고 무엇을 어디로 보낼지 고르면 됩니다.
명령과 결과는 모두 레코드(record)로 선언하고, 같은 종류끼리는 sealed 인터페이스로 묶었습니다. sealed interface Command permits Join, Message, Change, Users, Exit는 Command를 구현할 수 있는 타입을 이 다섯 개로 제한합니다. 그래서 switch가 다섯 경우를 모두 다루면 default가 없어도 컴파일러가 빠진 경우가 없다고 판단합니다.
case Join join when state.joined()처럼 case 뒤에 타입과 변수 이름을 쓰는 것을 패턴 매칭이라 하고, when 뒤의 조건을 가드(guard)라고 합니다. 값이 Join이고 가드 조건까지 참일 때만 그 case가 선택됩니다. 레코드는 Java 16, sealed는 Java 17, switch의 패턴 매칭과 가드는 Java 21에서 정식 기능이 되었으므로 이 트랙의 기준인 JDK 25에서 그대로 쓸 수 있습니다. 자세한 규칙은 sealed 계층과 상태 분기와 패턴 매칭과 데이터 분기에서 다룹니다.
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
public final class TypedChatDispatcher {
sealed interface Command permits Join, Message, Change, Users, Exit {
}
record Join(String name) implements Command {
}
record Message(String text) implements Command {
}
record Change(String name) implements Command {
}
record Users() implements Command {
}
record Exit() implements Command {
}
sealed interface ParseResult permits Parsed, SyntaxError {
}
record Parsed(Command command) implements ParseResult {
}
record SyntaxError(String detail) implements ParseResult {
}
sealed interface Decision permits Reply, Broadcast, Close, Rejected {
}
record Reply(String text) implements Decision {
}
record Broadcast(String text) implements Decision {
}
record Close(String text) implements Decision {
}
record Rejected(String reason) implements Decision {
}
static final class SessionState {
private String name;
boolean joined() {
return name != null;
}
String name() {
return name;
}
void join(String newName) {
name = newName;
}
}
static ParseResult parse(String frame) {
if (frame == null || frame.isBlank()) {
return new SyntaxError("empty frame");
}
if (frame.length() > 256) {
return new SyntaxError("frame too long");
}
String[] parts = frame.split("\\|", 2);
String key = parts[0];
String argument = parts.length == 2 ? parts[1].trim() : null;
return switch (key) {
case "/join" -> argumentRequired(argument, Join::new);
case "/message" -> argumentRequired(argument, Message::new);
case "/change" -> argumentRequired(argument, Change::new);
case "/users" -> noArgument(argument, new Users());
case "/exit" -> noArgument(argument, new Exit());
default -> new SyntaxError("unknown command: " + key);
};
}
static Decision dispatch(Command command, SessionState state,
List<String> users) {
return switch (command) {
case Join join when state.joined() ->
new Rejected("already joined");
case Join join -> {
state.join(join.name());
users.add(join.name());
yield new Broadcast(join.name() + " joined");
}
case Message message when !state.joined() ->
new Rejected("join first");
case Message message ->
new Broadcast("[" + state.name() + "] " + message.text());
case Change change when !state.joined() ->
new Rejected("join first");
case Change change -> {
String old = state.name();
users.remove(old);
users.add(change.name());
state.join(change.name());
yield new Broadcast(old + " changed to " + change.name());
}
case Users ignored -> new Reply("users=" + users);
case Exit ignored -> new Close("bye");
};
}
private static ParseResult argumentRequired(
String argument, Function<String, Command> factory) {
if (argument == null || argument.isBlank()) {
return new SyntaxError("argument required");
}
return new Parsed(factory.apply(argument));
}
private static ParseResult noArgument(String argument, Command command) {
return argument == null
? new Parsed(command)
: new SyntaxError("argument not allowed");
}
public static void main(String[] args) {
var state = new SessionState();
List<String> users = new ArrayList<>();
for (String frame : List.of(
"/message|early",
"/join",
"/join|Mina",
"/messageBackup|hi",
"/message| hi|team",
"/change|Nari",
"/users|",
"/users",
"/exit")) {
switch (parse(frame)) {
case SyntaxError error ->
System.out.println(frame + " -> " + error);
case Parsed parsed ->
System.out.println(frame + " -> "
+ dispatch(parsed.command(), state, users));
}
}
}
}main은 프레임 아홉 개를 차례로 처리합니다. 문법 오류는 SyntaxError를 그대로 출력하고, 파싱에 성공한 프레임은 dispatch가 내린 결정을 출력하므로 어느 단계에서 걸렸는지가 출력의 타입 이름에 드러납니다.
실행 결과:
/message|early -> Rejected[reason=join first]
/join -> SyntaxError[detail=argument required]
/join|Mina -> Broadcast[text=Mina joined]
/messageBackup|hi -> SyntaxError[detail=unknown command: /messageBackup]
/message| hi|team -> Broadcast[text=[Mina] hi|team]
/change|Nari -> Broadcast[text=Mina changed to Nari]
/users| -> SyntaxError[detail=argument not allowed]
/users -> Reply[text=users=[Nari]]
/exit -> Close[text=bye]출력을 세션 상태와 함께 따라가면 다음과 같습니다.
| 프레임 | 결정한 단계 | 결과 | 처리 뒤 이름 | 처리 뒤 users |
|---|---|---|---|---|
/message|early | 상태 검사 | Rejected | 없음 | [] |
/join | 파싱 | SyntaxError | 없음 | [] |
/join|Mina | 실행 | Broadcast | Mina | [Mina] |
/messageBackup|hi | 파싱 | SyntaxError | Mina | [Mina] |
/message| hi|team | 실행 | Broadcast | Mina | [Mina] |
/change|Nari | 실행 | Broadcast | Nari | [Nari] |
/users| | 파싱 | SyntaxError | Nari | [Nari] |
/users | 실행 | Reply | Nari | [Nari] |
/exit | 실행 | Close | Nari | [Nari] |
첫 줄의 /message|early는 문법으로는 올바르기 때문에 파싱을 통과했고, dispatch의 상태 검사에서 Rejected가 됐습니다. 반면 둘째 줄의 /join은 인수가 없어 파싱 단계에서 SyntaxError로 끝났고, 세션 상태는 아예 살펴보지 않았습니다. 앞의 조건문 코드에서 ArrayIndexOutOfBoundsException을 일으켰던 입력이 이제는 이유가 적힌 값으로 돌아옵니다. 넷째 줄의 /messageBackup|hi도 switch가 문자열 전체를 /message와 비교하므로 알 수 없는 명령으로 걸러졌습니다. 이 비교는 대소문자도 구별합니다.
다섯째 줄에서는 본문 안의 |가 그대로 남았습니다. split("\\|", 2)의 두 번째 인수 2는 결과를 최대 두 조각으로 만들라는 뜻이어서, 첫 구분자에서 한 번만 자르고 나머지는 전부 두 번째 조각에 둡니다. 정규식에서 |는 '또는'을 뜻하는 특수 문자라서 \\|로 이스케이프했다는 점도 기억해 두세요. 인수 앞의 공백은 trim()이 지웠습니다.
일곱째 줄의 /users|도 눈여겨볼 만합니다. limit을 양수로 준 split은 끝의 빈 문자열을 버리지 않으므로 결과가 ["/users", ""]입니다. 그래서 인수가 빈 문자열로 '있는' 것으로 판단되어 argument not allowed가 나옵니다. 두 스레드로 만드는 채팅 클라이언트의 ChatCommandCodec은 이 프레임을 /users로 받아들이므로, 두 파서를 함께 쓰려면 어느 쪽 규칙을 따를지 먼저 정해야 합니다.
마지막 줄의 /exit는 Close를 돌려줄 뿐 이름과 목록은 그대로 둡니다. 응답을 보내고, 연결을 닫고, 목록에서 이름을 지우는 일은 이 결정을 받은 바깥 코드가 합니다.
dispatch에서는 case의 순서에도 의미가 있습니다. 가드가 있는 case Join join when state.joined()가 가드 없는 case Join join보다 먼저 와야 합니다. 순서를 바꾸면 가드 없는 쪽이 모든 Join을 먼저 가져가서 가드 있는 case에는 절대 도달하지 못하는데, 컴파일러가 이것을 알아채고 다음 오류를 냅니다.
TypedChatDispatcher.java:94: error: this case label is dominated by a preceding case label
case Join join when state.joined() ->
^
1 errorsealed는 반대 방향의 실수도 잡아 줍니다. 예를 들어 permits 목록에 Help 레코드를 추가하고 dispatch에 그 case를 넣지 않으면 the switch expression does not cover all possible input values라는 컴파일 오류가 납니다. 새 명령을 추가할 때 처리를 빠뜨린 곳을 컴파일러가 찾아 주는 셈입니다. 다만 명령이 늘 때마다 parse와 dispatch라는 중앙의 두 메서드를 고쳐야 한다는 점은 그대로이고, 이 문제는 다음 글에서 다룹니다.
입장 전과 후에 다섯 명령이 각각 어떤 결정으로 이어지는지 그림으로 정리하면 다음과 같습니다.
TypedChatDispatcher의 상태는 이름 존재 여부이며 Users와 Exit는 입장 상태와 무관하게 허용됩니다.
명령 디스패치 · 입장 전과 입장 후
입장 상태에 따른 명령 결정
Join·Message·Change는 입장 상태에 따라 결정이 달라집니다. Users와 Exit는 두 상태 모두 결과를 반환합니다.
입장 전 · name=null
- Join
- 이름 저장 · 목록 추가 · Broadcast
- Message
- Rejected(join first)
- Change
- Rejected(join first)
- Users
- 사용자 목록 Reply
- Exit
- Close(bye) 반환
입장 후
- Join
- Rejected(already joined)
- Message
- 이름을 붙인 Broadcast
- Change
- 목록과 이름 교체 · Broadcast
- Users
- 사용자 목록 Reply
- Exit
- Close(bye) 반환
Close 값만으로 이름·목록이 바뀌거나 소켓이 닫히지 않습니다. 이름 유일성 검사는 뒤의 별도 디렉터리가 담당합니다.
그림에서 보듯 /users와 /exit는 입장 전에도 허용됩니다. 그리고 이 예제의 users는 평범한 ArrayList라서 두 가지가 부족합니다. 여러 세션 스레드가 동시에 고치면 안전하지 않고, 두 세션이 똑같이 Mina로 입장하는 것도 막지 못합니다. 이름이 겹치지 않게 관리하는 일은 다음 절의 별도 디렉터리가 맡습니다.
더 깊이: 256은 무엇을 센 길이인가
parse의 길이 검사 frame.length() > 256에서 length()가 세는 것은 화면에 보이는 글자 수도, UTF-8 바이트 수도 아닙니다. String.length의 공식 문서가 설명하듯 문자열에 든 UTF-16 코드 단위(code unit)의 개수입니다. 한글과 영문자는 한 글자가 한 단위지만, 😀 같은 이모지는 보충 문자(supplementary character)라서 두 단위를 차지합니다. 그래서 /message| 아홉 단위 뒤에 😀를 123개 붙이면 9+246=255로 통과하고, 124개를 붙이면 257이 되어 frame too long이 됩니다. 통과한 123개짜리 프레임도 화면에서는 132글자로 보이고, UTF-8로 인코딩하면 501바이트입니다.
따라서 프로토콜 설명에는 상한이 무엇을 센 것인지 함께 적어야 합니다. 네트워크로 오가는 크기를 제한하려면 UTF-8 바이트 수를, 사용자에게 보이는 글자 수를 제한하려면 codePointCount로 센 코드 포인트 수를 기준으로 삼습니다. 이 예제의 256은 UTF-16 코드 단위 기준이라는 점을 밝혀 두면, 다른 언어로 만든 클라이언트도 같은 기준으로 검사할 수 있습니다.
이름 변경은 검사와 교체를 한 번에 해야 한다
서버가 같은 이름을 두 사람이 쓰지 못하게 한다면 /change|Jin은 세션 필드 하나를 바꾸는 일로 끝나지 않습니다. 먼저 다른 세션이 Jin을 쓰고 있지 않은지 확인하고, 비어 있을 때만 이 세션의 이름을 바꿔야 합니다. 이렇게 무언가를 확인한 뒤 그 결과에 따라 행동하는 코드를 검사 후 행동(check-then-act)이라고 부릅니다. 여러 스레드가 이런 코드를 동시에 실행하면 확인과 행동 사이에 다른 스레드가 끼어들 수 있습니다. 세션 s2와 s3가 거의 동시에 /change|Jin을 보냈을 때 잠금 없이 처리하면 다음 순서가 가능합니다.
| 순서 | s2를 처리하는 스레드 | s3를 처리하는 스레드 | 이름 저장소 |
|---|---|---|---|
| 1 | Jin을 쓰는 다른 세션 확인 → 없음 | s1=Mina, s2=Nari, s3=Bora | |
| 2 | Jin을 쓰는 다른 세션 확인 → 없음 | 변화 없음 | |
| 3 | s2의 이름을 Jin으로 저장 | s1=Mina, s2=Jin, s3=Bora | |
| 4 | s3의 이름을 Jin으로 저장 | s1=Mina, s2=Jin, s3=Jin |
두 스레드 모두 상대가 저장하기 전에 확인했기 때문에 둘 다 Jin이 비어 있다고 보았고, 결국 이름이 겹쳤습니다. 이것을 막으려면 확인과 저장을 다른 스레드가 끼어들 수 없는 한 덩어리로 실행해야 합니다. 지켜야 할 규칙이 하나 더 있는데, 변경에 실패하면 이전 이름이 그대로 남아 있어야 한다는 것입니다. 옛 이름을 먼저 지운 뒤 새 이름을 확인하면, 새 이름이 이미 쓰이고 있을 때 그 세션은 이름을 잃어버립니다.
아래 UniqueUsernameDirectory는 세션 ID를 이름에 대응시키는 맵 하나를 두고, 이 맵을 다루는 모든 메서드를 synchronized로 선언합니다. rename은 다른 세션이 새 이름을 쓰는지 확인한 다음, 비어 있을 때만 put으로 매핑을 바꿉니다.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class UniqueUsernameDirectory {
enum RenameResult {
RENAMED,
ALREADY_JOINED,
NOT_JOINED,
NAME_TAKEN,
INVALID_NAME
}
private final Map<String, String> nameBySession = new LinkedHashMap<>();
synchronized RenameResult join(String sessionId, String name) {
if (!valid(name)) {
return RenameResult.INVALID_NAME;
}
if (nameBySession.containsKey(sessionId)) {
return RenameResult.ALREADY_JOINED;
}
if (nameBySession.containsValue(name)) {
return RenameResult.NAME_TAKEN;
}
nameBySession.put(sessionId, name);
return RenameResult.RENAMED;
}
synchronized RenameResult rename(String sessionId, String nextName) {
String current = nameBySession.get(sessionId);
if (current == null) {
return RenameResult.NOT_JOINED;
}
if (!valid(nextName)) {
return RenameResult.INVALID_NAME;
}
boolean occupied = nameBySession.entrySet().stream()
.anyMatch(entry -> !entry.getKey().equals(sessionId)
&& entry.getValue().equals(nextName));
if (occupied) {
return RenameResult.NAME_TAKEN;
}
nameBySession.put(sessionId, nextName);
return RenameResult.RENAMED;
}
synchronized void leave(String sessionId) {
nameBySession.remove(sessionId);
}
synchronized List<String> names() {
return List.copyOf(nameBySession.values());
}
private static boolean valid(String name) {
return name != null
&& !name.isBlank()
&& name.length() <= 20
&& name.chars().noneMatch(Character::isISOControl);
}
public static void main(String[] args) {
var directory = new UniqueUsernameDirectory();
System.out.println(directory.join("s1", "Mina"));
System.out.println(directory.join("s2", "Nari"));
System.out.println(directory.rename("s2", "Mina"));
System.out.println(directory.rename("s2", "Jin"));
System.out.println(directory.names());
}
}main은 s2의 이름을 이미 쓰이는 Mina로 바꾸려 한 뒤, 비어 있는 Jin으로 다시 바꿉니다. 마지막 줄의 names()는 맵의 값을 복사해 돌려주는데, LinkedHashMap은 이미 있는 키에 put해도 그 키의 순서를 바꾸지 않으므로 이름은 입장한 순서대로 나옵니다.
RENAMED
RENAMED
NAME_TAKEN
RENAMED
[Mina, Jin]처음 두 줄은 s1과 s2가 각각 Mina와 Nari로 입장한 결과입니다. 이 예제는 입장과 이름 변경에 같은 결과 타입을 쓰므로, RENAMED는 '요청한 이름이 이 세션에 적용되었다'는 뜻으로 읽으면 됩니다. 이미 입장한 세션이 join을 다시 부르면 아무것도 바꾸지 않고 ALREADY_JOINED를 돌려줍니다.
셋째 줄에서는 s2가 Mina로 바꾸려 했지만 s1이 그 이름을 쓰고 있어서 NAME_TAKEN이 나왔습니다. 매핑은 확인을 통과한 뒤에만 바뀌므로 s2는 여전히 Nari입니다. 넷째 줄에서 Jin으로 바꾸는 것은 성공했고, 마지막 목록에서 Jin이 두 번째 자리에 있는 것은 LinkedHashMap이 이미 있는 키에 put하면 원래 순서를 유지하기 때문입니다.
아래 단계별 그림은 이 네 호출 동안 저장소가 어떻게 바뀌는지 보여 줍니다. main은 호출을 하나씩 차례로 실행하므로 앞의 표 같은 경쟁 상황을 재현하지는 않는다는 점에 주의하세요.
이름 충돌에서는 기존 이름을 남긴다
Mina·Nari 가입 후 중복 이름 Mina로 변경이 거절되어 Nari를 보존하고 Jin으로 변경이 성공합니다. 같은 저장소의 네 연속 상태를 직접 추적할 수 있습니다.
UniqueUsernameDirectory main의 순차 호출에서 이름 점유 실패와 성공 뒤 목록을 비교합니다.
이름 디렉터리 · 충돌 시 상태 보존
이름 충돌에서는 기존 이름을 남긴다
s2가 Mina로 변경하려 하면 NAME_TAKEN을 반환하고 Nari를 유지합니다. 이후 Jin으로 바꾸면 목록에 새 이름이 반영됩니다.
새 이름의 점유 검사 뒤에만 매핑을 교체한다
순차 main의 s1=Mina·s2=Nari 상태에서 Mina 변경은 NAME_TAKEN으로 Nari를 유지하고 Jin 변경 성공만 s2를 교체한다. 동시에 실행한 관측값은 아니다.
상태별 상세 해설
join(s1, Mina)
- 반환
- RENAMED
- 호출 뒤 이름 목록
- [Mina]
join(s2, Nari)
- 반환
- RENAMED
- 호출 뒤 이름 목록
- [Mina, Nari]
rename(s2, Mina)
- 반환
- NAME_TAKEN
- 호출 뒤 이름 목록
- [Mina, Nari] 그대로
rename(s2, Jin)
- 반환
- RENAMED
- 호출 뒤 이름 목록
- [Mina, Jin]
원문 main의 순차 경로를 표시했습니다. 동시 이름 경쟁을 실제 실행한 관측값은 아닙니다.
이 예의 조건
- 동일 저장소에서 원 main의 순서로 요청한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 이름 충돌에서는 기존 이름을 남긴다 이름 충돌에서는 기존 이름을 남긴다 UniqueUsernameDirectory main의 순차 호출에서 이름 점유 실패와 성공 뒤 목록을 비교합니다. 이름 디렉터리 · 충돌 시 상태 보존 이름 충돌에서는 기존 이름을 남긴다 s2가 Mina로 변경하려 하면 NAME_TAKEN을 반환하고 Nari를 유지합니다. 이후 Jin으로 바꾸면 목록에 새 이름이 반영됩니다. join(s1, Mina) 반환 RENAMED 호출 뒤 이름 목록 [Mina] join(s2, Nari) 반환 RENAMED 호출 뒤 이름 목록 [Mina, Nari] rename(s2, Mina) 반환 NAME_TAKEN 호출 뒤 이름 목록 [Mina, Nari] 그대로 rename(s2, Jin) 반환 RENAMED 호출 뒤 이름 목록 [Mina, Jin] 원문 main의 순차 경로를 표시했습니다. 동시 이름 경쟁을 실제 실행한 관측값은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 가입 s1이 Mina라는 이름을 확보합니다. |
|
|
| 둘째 가입 s2가 Nari로 가입합니다. |
|
|
| 중복 변경 거절 s2가 Mina로 바꾸려 하지만 기존 이름을 보존합니다. |
|
|
| 유효 변경 성공 s2의 이름을 Jin으로 확정합니다. |
|
|
이름 충돌에서는 기존 이름을 남긴다
Mina·Nari 가입 후 중복 이름 Mina로 변경이 거절되어 Nari를 보존하고 Jin으로 변경이 성공합니다. 같은 저장소의 네 연속 상태를 직접 추적할 수 있습니다.
UniqueUsernameDirectory main의 순차 호출에서 이름 점유 실패와 성공 뒤 목록을 비교합니다.
이름 디렉터리 · 충돌 시 상태 보존
이름 충돌에서는 기존 이름을 남긴다
s2가 Mina로 변경하려 하면 NAME_TAKEN을 반환하고 Nari를 유지합니다. 이후 Jin으로 바꾸면 목록에 새 이름이 반영됩니다.
새 이름의 점유 검사 뒤에만 매핑을 교체한다
순차 main의 s1=Mina·s2=Nari 상태에서 Mina 변경은 NAME_TAKEN으로 Nari를 유지하고 Jin 변경 성공만 s2를 교체한다. 동시에 실행한 관측값은 아니다.
상태별 상세 해설
join(s1, Mina)
- 반환
- RENAMED
- 호출 뒤 이름 목록
- [Mina]
join(s2, Nari)
- 반환
- RENAMED
- 호출 뒤 이름 목록
- [Mina, Nari]
rename(s2, Mina)
- 반환
- NAME_TAKEN
- 호출 뒤 이름 목록
- [Mina, Nari] 그대로
rename(s2, Jin)
- 반환
- RENAMED
- 호출 뒤 이름 목록
- [Mina, Jin]
원문 main의 순차 경로를 표시했습니다. 동시 이름 경쟁을 실제 실행한 관측값은 아닙니다.
이 예의 조건
- 동일 저장소에서 원 main의 순서로 요청한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 이름 충돌에서는 기존 이름을 남긴다 이름 충돌에서는 기존 이름을 남긴다 UniqueUsernameDirectory main의 순차 호출에서 이름 점유 실패와 성공 뒤 목록을 비교합니다. 이름 디렉터리 · 충돌 시 상태 보존 이름 충돌에서는 기존 이름을 남긴다 s2가 Mina로 변경하려 하면 NAME_TAKEN을 반환하고 Nari를 유지합니다. 이후 Jin으로 바꾸면 목록에 새 이름이 반영됩니다. join(s1, Mina) 반환 RENAMED 호출 뒤 이름 목록 [Mina] join(s2, Nari) 반환 RENAMED 호출 뒤 이름 목록 [Mina, Nari] rename(s2, Mina) 반환 NAME_TAKEN 호출 뒤 이름 목록 [Mina, Nari] 그대로 rename(s2, Jin) 반환 RENAMED 호출 뒤 이름 목록 [Mina, Jin] 원문 main의 순차 경로를 표시했습니다. 동시 이름 경쟁을 실제 실행한 관측값은 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 가입 s1이 Mina라는 이름을 확보합니다. |
|
|
| 둘째 가입 s2가 Nari로 가입합니다. |
|
|
| 중복 변경 거절 s2가 Mina로 바꾸려 하지만 기존 이름을 보존합니다. |
|
|
| 유효 변경 성공 s2의 이름을 Jin으로 확정합니다. |
|
|
메서드 전체를 synchronized로 만든 것은 '이름은 세션마다 하나이고 서로 겹치지 않는다'는 규칙이 확인과 put 두 동작에 걸쳐 있기 때문입니다. 한 스레드가 rename을 실행하는 동안 같은 객체의 다른 synchronized 메서드는 기다려야 하므로, 앞의 표에서 2번 단계가 1번과 3번 사이에 끼어들 수 없습니다. join의 containsValue와 rename의 스트림 검사는 모든 항목을 훑으므로 참가자 수에 비례하는 시간이 들지만, 채팅방 하나의 인원이라면 문제가 되지 않습니다. 인원이 아주 많아지면 이름에서 세션으로 가는 맵을 하나 더 두고, 두 맵을 같은 잠금 안에서 함께 고치면 됩니다.
valid는 이름 규칙을 한곳에 모았습니다. 비어 있거나 공백뿐인 이름, 길이가 20을 넘는 이름, 줄바꿈 같은 제어 문자가 든 이름을 거절합니다. 제어 문자를 막는 이유는 이 이름이 다른 사용자의 화면과 서버 로그에 그대로 찍히기 때문입니다. 이름에 줄바꿈이 들어 있으면 시스템 공지처럼 보이는 가짜 줄을 만들어 낼 수 있습니다.
names()가 내부 맵의 values()를 그대로 돌려주지 않고 List.copyOf로 복사하는 데도 이유가 있습니다. values()는 맵을 들여다보는 뷰(view)라서, 받은 쪽이 잠금 없이 순회하는 동안 다른 스레드가 맵을 바꾸면 ConcurrentModificationException이 나거나 어긋난 내용을 읽을 수 있습니다. 게다가 뷰의 remove를 부르면 디렉터리의 잠금을 거치지 않고 항목이 지워집니다.
복사본은 잠금 안에서 만들어지므로 그 순간의 목록을 정확히 담습니다. 다만 바로 다음 순간에 누군가 나갈 수 있으므로, /users 응답은 '요청을 처리한 순간의 목록'일 뿐입니다. 프로토콜 설명에 이 점을 적어 두면 클라이언트가 이 목록을 실시간 상태로 오해하지 않습니다.
더 깊이: ConcurrentHashMap 두 개로 바꾸면 잠금이 필요 없을까
ConcurrentHashMap은 키 하나에 대한 연산을 원자적으로 처리합니다. 이름을 세션에 대응시키는 맵을 두고 putIfAbsent(nextName, sessionId)가 null을 돌려줄 때만 성공으로 보면, 두 세션이 같은 이름을 동시에 요청해도 한쪽만 성공합니다.
그러나 이름 변경은 키 하나로 끝나지 않습니다. 새 이름을 예약하고, 옛 이름을 지우고, 세션에서 이름으로 가는 맵도 고쳐야 합니다. 세 연산이 각각은 원자적이어도 그 사이에서는 다른 스레드가 중간 상태를 볼 수 있습니다. 예를 들어 새 이름을 예약한 뒤 옛 이름을 지우기 전에 다른 세션의 /users가 처리되면, 같은 사람이 두 이름으로 나타납니다.
이런 중간 상태가 프로토콜에서 허용되는지 먼저 정하지 않은 채 동시성 컬렉션으로 바꾸면, 잠금은 사라져도 드물게만 나타나는 버그가 생깁니다. 채팅방 하나 규모에서는 잠금 하나로 전체를 감싸는 쪽이 단순하고 정확합니다. 키 단위 원자 연산은 ConcurrentHashMap과 동시성 컬렉션에서 더 자세히 다룹니다.
잘못된 입력에 어떻게 반응할까
잘못된 프레임마다 연결을 바로 끊으면 입력 실수 하나로 대화가 끊깁니다. 반대로 어떤 경우에도 오류 응답만 보내고 연결을 유지하면, 악의적인 클라이언트가 큰 프레임과 잘못된 명령을 빠르게 쏟아 내 서버의 CPU와 로그 저장 공간을 써 버릴 수 있습니다. 그래서 오류의 종류, 최근 위반 횟수, 프레임 크기를 보고 대응을 고릅니다. 대응은 응답하고 연결을 유지하기(REPLY_AND_KEEP), 응답한 뒤 닫기(REPLY_AND_CLOSE), 응답 없이 닫기(CLOSE_SILENTLY) 정도로 나눌 수 있습니다.
| 상황 | 그 세션에 보낼 응답 | 상태 변화 | 연결 처리 |
|---|---|---|---|
| 알 수 없는 짧은 명령 | 지원하는 명령 목록 안내 | 없음 | 유지 |
| 입장 전 메시지 | 먼저 입장하라는 안내 | 없음 | 유지 |
| 이미 쓰이는 이름 선택 | 다른 이름을 고르라는 안내 | 기존 이름 유지 | 유지 |
| 길이 상한 초과 | 짧은 오류 또는 응답 없음 | 위반 점수 증가 | 반복되면 종료 |
| 실행 중 예상하지 못한 예외 | 일반적인 서버 오류 | 그 세션만 정리 | 종료 |
위의 세 줄은 정상 사용자도 흔히 겪는 실수이므로 안내를 보내고 연결을 유지합니다. 길이 상한 초과는 실수보다는 고장 난 클라이언트나 공격일 가능성이 크므로, 자세한 응답에 자원을 쓰지 않고 위반 점수를 쌓다가 일정 수준을 넘으면 연결을 닫습니다. 이 점수 방식은 연습 문제에서 직접 구현합니다. 마지막 줄은 서버 버그이므로 사용자에게 내부 사정을 알리지 않고, 그 세션만 정리해서 다른 세션까지 영향이 번지지 않게 합니다.
예외 처리도 이 구분을 따릅니다. 파싱 실패나 상태 위반처럼 예상할 수 있는 입력 문제는 앞의 예제처럼 값으로 돌려주고, 세션 루프의 맨 바깥에서는 예상하지 못한 예외만 잡습니다. 이때 모든 예외를 IOException으로 바꿔 한 경로로 보내지 않습니다. 잡은 예외는 오류 ID와 함께 기록하고 그 세션만 닫습니다.
세션마다 스레드를 따로 쓴다면 세션에서 난 예외가 다른 스레드의 accept 루프로 넘어가지는 않습니다. 하지만 아무도 잡지 않은 예외는 세션 스레드를 그대로 끝내면서 소켓 닫기와 세션 목록에서의 제거를 건너뛸 수 있으므로, 정리 코드는 finally에 둡니다. 첫 프레임을 accept 스레드에서 읽는 식으로 명령을 그 스레드에서 처리한다면, 예외가 accept 루프까지 올라가 새 연결 수락이 멈추지 않도록 그 안에서 잡아야 합니다. 이 구조를 실제 소켓 서버에 적용한 코드는 다음 글의 마지막 절에서 실행해 봅니다.
로그에 무엇을 남길지도 정해 둡니다. 메시지 본문에는 개인정보나 인증 토큰이 들어 있을 수 있으므로, 오류 로그에는 프레임 전체 대신 명령 이름, 프레임 길이, 세션 ID처럼 꼭 필요한 정보만 남깁니다. 이 예제의 unknown command: 메시지는 구분자 앞부분을 그대로 담는데, 구분자가 없는 프레임이라면 그 앞부분이 곧 프레임 전체라는 점도 주의해야 합니다. 보낸 사람에게 돌려주는 응답으로는 괜찮지만, 그대로 로그에 쓰면 안 됩니다.
테스트로 확인할 것
명령 처리 코드는 단계마다 따로 테스트합니다. 파서 테스트에는 명령 이름의 정확한 일치(/messageBackup, /Join), 인수가 필요한 명령과 받지 않는 명령의 구분(/join, /join|, /users|), 길이 상한의 바로 안쪽과 바깥쪽(256과 257 코드 단위)을 넣습니다. 상태 처리 테스트에는 입장 전 메시지, 두 번 입장, 이름 충돌, 정상적인 이름 변경, 퇴장을 넣습니다.
이름 디렉터리에는 동시성 테스트가 따로 필요합니다. 여러 스레드를 동시에 출발시켜 모두 같은 새 이름을 요청하게 한 뒤, RENAMED가 정확히 한 번만 나오는지와 실패한 세션들이 이전 이름을 그대로 가지고 있는지 확인합니다. 경쟁 상황은 매번 나타나지 않으므로 이런 테스트는 여러 번 반복해서 실행합니다.
정리
- 명령을
startsWith로 고르면 비슷한 이름의 잘못된 명령을 받아들이고, 인수가 없을 때 배열 인덱스 예외로 연결이 끊깁니다. 명령 이름은 첫 구분자 앞의 문자열 전체와 정확히 비교합니다. - 명령 처리는 파싱(문법 검사), 상태 검사, 실행으로 나눕니다. 문법 오류와 상태 오류는 값으로 돌려주어 사용자에게 안내하고, 실행 중 예외는 버그로 보고 그 세션만 정리합니다.
/exit같은 정상 흐름은 예외가 아니라Close같은 결과 값으로 나타냅니다.sealed인터페이스와 패턴 매칭switch를 함께 쓰면 모든 명령 종류를 처리했는지 컴파일러가 확인합니다.- 이름 중복 검사와 이름 교체는 하나의 잠금 안에서 함께 실행하고, 실패하면 이전 이름을 그대로 둡니다.
연습 문제
위반 점수로 연결 처리 정하기
세션마다 최근 프로토콜 위반 점수를 관리하는 FrameViolationBudget을 작성하세요. 알 수 없는 명령은 1점, 인수 누락은 2점, 256 코드 단위를 넘는 프레임은 5점을 더합니다. 유효한 명령은 점수를 1 낮추되 0 아래로 내려가지 않습니다. 기록할 때마다 현재 점수와 판정을 함께 돌려주며, 판정은 누적 점수가 5 이상이면 CLOSE, 그 전에는 KEEP입니다.
풀이 보기
점수를 바꾸는 일과 판정을 내리는 일을 하나의 synchronized 메서드에서 처리합니다. 둘을 따로 하면 두 스레드가 같은 세션의 위반을 동시에 기록할 때, 한쪽이 점수를 올리는 사이에 다른 쪽이 판정을 내려 임계값을 놓칠 수 있습니다. 실제 서버라면 한 세션의 입력은 한 스레드만 읽게 만드는 것이 먼저이고, 이 클래스는 정책만 따로 떼어 시험해 보기 위한 모델입니다.
import java.util.List;
public final class FrameViolationBudgetSolution {
enum Violation {
VALID(0),
UNKNOWN_COMMAND(1),
ARGUMENT_MISSING(2),
FRAME_TOO_LARGE(5);
private final int points;
Violation(int points) {
this.points = points;
}
}
enum Action {
KEEP,
CLOSE
}
record Decision(int points, Action action) {
}
private int points;
synchronized Decision record(Violation violation) {
if (violation == Violation.VALID) {
points = Math.max(0, points - 1);
} else {
points += violation.points;
}
Action action = points >= 5 ? Action.CLOSE : Action.KEEP;
return new Decision(points, action);
}
public static void main(String[] args) {
var budget = new FrameViolationBudgetSolution();
List<Violation> events = List.of(
Violation.UNKNOWN_COMMAND,
Violation.ARGUMENT_MISSING,
Violation.VALID,
Violation.ARGUMENT_MISSING,
Violation.UNKNOWN_COMMAND);
for (Violation event : events) {
System.out.println(event + " -> " + budget.record(event));
}
}
}실행 결과:
UNKNOWN_COMMAND -> Decision[points=1, action=KEEP]
ARGUMENT_MISSING -> Decision[points=3, action=KEEP]
VALID -> Decision[points=2, action=KEEP]
ARGUMENT_MISSING -> Decision[points=4, action=KEEP]
UNKNOWN_COMMAND -> Decision[points=5, action=CLOSE]점수는 1과 3으로 오르다가 유효한 명령에서 2로 내려가고, 다시 4를 거쳐 마지막에 정확히 5가 되면서 CLOSE가 나옵니다. 테스트를 만든다면 이처럼 임계값에 정확히 도달하는 경우와, 유효한 명령으로 점수가 내려가는 경우를 따로 넣습니다.
닫힌 뒤에도 점수가 바뀔까
풀이의 FrameViolationBudgetSolution을 새로 만들어 ARGUMENT_MISSING, ARGUMENT_MISSING, VALID, ARGUMENT_MISSING, VALID를 차례로 기록하면 각 점수와 판정은 무엇인가요? 마지막 VALID에서 나온 판정이 무엇을 뜻하는지도 설명하세요.
해설 보기
점수와 판정은 2와 KEEP, 4와 KEEP, 3과 KEEP, 5와 CLOSE, 4와 KEEP 순서입니다. 인수 누락 두 번으로 4가 되고, 유효한 명령으로 3이 되었다가, 다시 인수 누락으로 5가 되어 CLOSE가 나옵니다.
그런데 이 클래스는 닫힘 상태를 기억하지 않으므로 마지막 VALID도 점수를 4로 낮추고 KEEP을 돌려줍니다. 이 KEEP은 이미 닫힌 연결을 다시 연다는 뜻이 아니라, 모델이 닫힌 뒤의 입력을 구별하지 못한다는 뜻일 뿐입니다. 실제 세션 루프는 첫 CLOSE를 받으면 더 읽지 않고 연결을 닫아야 합니다. 모델에서도 이것을 나타내려면 closed 필드를 두어, 첫 CLOSE 뒤에는 점수를 바꾸지 않고 계속 CLOSE를 돌려주게 만듭니다.
parse 결과 예측하기
TypedChatDispatcher.parse에 다음 네 프레임을 차례로 넣으면 각각 무엇이 나오는지 예측하세요. 첫 번째는 /join| 뒤에 공백만 두 칸 붙인 프레임입니다.
List.of("/join| ", "/join|Mina|Jin", "/exit|now", "/Join|Mina")해설 보기
SyntaxError[detail=argument required]
Parsed[command=Join[name=Mina|Jin]]
SyntaxError[detail=argument not allowed]
SyntaxError[detail=unknown command: /Join]첫 번째 프레임은 구분자가 있으므로 인수가 공백 두 칸으로 존재하지만, trim()하면 빈 문자열이 되어 argument required입니다. 두 번째는 limit 2 때문에 첫 구분자에서만 잘리므로 이름이 Mina|Jin이 됩니다. 문법 검사는 통과하므로, 이름에 |를 쓰지 못하게 하려면 valid 같은 이름 규칙에 따로 추가해야 합니다. 세 번째의 /exit는 인수를 받지 않는 명령이라 now가 붙으면 문법 오류이고, 네 번째는 switch의 문자열 비교가 대소문자를 구별하므로 /Join을 알 수 없는 명령으로 봅니다.
다음 글
커맨드 패턴으로 채팅 명령 분리하기에서는 dispatch의 큰 switch를 명령마다 하나씩인 실행 객체로 나눕니다. 명령 이름과 실행 객체를 맵에 등록해 두고, 알 수 없는 명령은 null 검사 대신 기본 명령 객체로 처리합니다. 마지막에는 그 구조를 실제 소켓 서버에 연결해 두 클라이언트가 대화하는 모습을 실행해 봅니다.