채팅 명령과 상태 전이
입장·메시지·이름 변경·사용자 목록·종료 명령을 문법 분석과 상태 실행으로 나누고 잘못된 입력이 다른 세션이나 서버 루프를 무너뜨리지 않게 만듭니다.
채팅 서버가 연결을 받고 브로드캐스트까지 수행하면 다음 복잡성은 명령 해석에서 생깁니다.
/join|Mina, /message|hello, /change|Nari, /users, /exit은 형태도 다르고 허용되는 세션 상태도 다릅니다.
문자열 startsWith 조건을 한 메서드에 계속 추가하면 /messageBackup 같은 잘못된 키도 메시지로 오인되고, 인수 누락은 배열 인덱스 예외가 되어 연결 전체를 끊습니다.
명령 처리는 세 층으로 나누어 읽는 편이 안전합니다. 프레임 파서는 바이트에서 복원된 문자열이 문법에 맞는지 판단합니다. 상태 검증기는 현재 세션이 입장 전인지, 이름을 가진 활성 상태인지 확인합니다. 실행기는 검증된 명령으로 이름 저장소와 세션 레지스트리를 변경합니다. 각 층이 다른 오류 타입을 반환하면 사용자는 입력을 고칠 수 있고 서버는 프로그래밍 오류만 별도로 경보할 수 있습니다.
취약한 명령 파싱
다음 구현은 기능이 적을 때는 간단해 보입니다.
하지만 /join에 구분자와 이름이 없으면 parts[1]에서 실패하고, /message-old|x도 startsWith("/message")를 통과합니다.
IOException을 /exit 제어 흐름으로 사용하는 방식까지 더해지면 정상 퇴장과 실제 네트워크 장애가 같은 경로로 기록됩니다.
import java.io.IOException;
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) {
System.out.println("compile-only parser counterexample");
}
}프로토콜 키는 접두사가 아니라 첫 구분자 앞의 정확한 토큰으로 비교합니다.
인수 개수와 공백, 길이 제한도 파서에서 확인합니다.
단, “입장 전에 /message를 보냈다”는 문법 오류가 아닙니다.
프레임은 올바르지만 현재 상태에서 허용되지 않는 명령이므로 실행 전 상태 검사 결과로 반환합니다.
이 차이를 지키면 문법 통계와 사용 흐름 통계를 따로 볼 수 있습니다.
제어 흐름을 예외로 나타내는 것도 신중해야 합니다.
/exit은 정상 명령이므로 Exit 값이나 CLOSE_AFTER_REPLY 결과로 표현할 수 있습니다.
반면 소켓 쓰기 실패는 예상하지 않은 전송 오류입니다.
둘을 같은 catch(IOException)으로 처리하면 정상 퇴장이 오류 로그를 오염시키고 장애율을 높입니다.
명령 결과와 세션 결정
아래 예제는 파싱과 실행을 분리합니다.
parse는 성공 명령 또는 SyntaxError를 반환하고, dispatch는 세션 상태를 고려해 Reply, Broadcast, Close, Rejected 중 하나를 만듭니다.
네트워크 코드는 이 결과를 보고 현재 세션에만 응답할지 전체 전송할지, 응답 뒤 닫을지를 선택합니다.
import java.util.ArrayList;
import java.util.List;
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,
java.util.function.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|Mina",
"/message|hello",
"/users",
"/exit")) {
ParseResult parsed = parse(frame);
Decision decision = switch (parsed) {
case Parsed value -> dispatch(value.command(), state, users);
case SyntaxError error -> new Rejected(error.detail());
};
System.out.println(frame + " -> " + decision);
}
}
}이 예제는 Java 25의 패턴 switch와 가드 조건을 사용해 상태별 분기를 드러냅니다.
실무에서는 가변 List<String> 대신 별도 사용자 디렉터리가 이름 유일성과 동시성을 책임져야 합니다.
여기서 확인할 것은 동일 프레임이라도 세션 상태에 따라 결정이 달라지고, 그 차이가 문자열 예외가 아니라 타입에 남는다는 점입니다.
파서 결과에 원본 전체 메시지를 무조건 넣지 않는 것도 중요합니다. 메시지 본문에 개인정보나 인증 토큰이 포함될 수 있으므로 오류 로그에는 명령 키, 길이, 세션 ID 같은 제한된 정보만 기록합니다. 사용자가 고칠 수 있는 오류는 현재 연결에 응답하고, 반복 위반 횟수가 임계값을 넘을 때만 세션 종료를 고려합니다.
이름 변경의 원자성
/change|Nari는 단순히 세션 필드 하나를 바꾸는 명령이 아닙니다.
서버가 이름 중복을 허용하지 않는다면 기존 이름 해제와 새 이름 예약이 원자적으로 이루어져야 합니다.
두 세션이 동시에 같은 이름을 선택할 때 하나만 성공해야 하고, 실패한 세션의 기존 이름은 유지되어야 합니다.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class UniqueUsernameDirectory {
enum RenameResult {
RENAMED,
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.RENAMED;
}
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());
}
}메서드 전체를 동기화한 이유는 containsValue 확인과 put이 하나의 불변식을 지키기 때문입니다.
동시성 컬렉션으로 바꾸더라도 세션→이름과 이름→세션 두 인덱스를 별개 연산으로 갱신하면 중간 상태가 노출될 수 있습니다.
참가자 규모가 커지면 양방향 맵과 하나의 명시적 락을 사용해 이름 조회 비용을 줄일 수 있습니다.
사용자 목록은 내부 맵 뷰를 반환하지 않고 스냅샷으로 제공합니다.
목록을 만드는 시점과 브로드캐스트 시점 사이에 사용자가 나갈 수 있으므로 /users 응답은 요청 순간 근처의 관찰값이지 영구 사실이 아닙니다.
이 약한 일관성을 API 설명에 적으면 클라이언트가 목록 버전이나 실시간 동기화를 과도하게 기대하지 않습니다.
오류 응답과 연결 정책
모든 잘못된 프레임에서 연결을 즉시 끊으면 입력 오타 하나가 세션 손실로 이어집니다.
반대로 제한 없이 오류 응답만 보내면 공격자가 큰 프레임과 빠른 잘못된 명령으로 CPU와 로그를 소비할 수 있습니다.
오류 종류, 최근 위반 횟수, 프레임 크기를 바탕으로 REPLY_AND_KEEP, REPLY_AND_CLOSE, CLOSE_SILENTLY를 고릅니다.
| 사건 | 현재 세션 응답 | 상태 변경 | 권장 연결 처리 |
|---|---|---|---|
| 알 수 없는 짧은 명령 | 지원 목록 안내 | 없음 | 유지 |
| 입장 전 메시지 | 먼저 입장하라는 오류 | 없음 | 유지 |
| 중복 이름 선택 | 다른 이름 요청 | 기존 이름 유지 | 유지 |
| 길이 상한 초과 | 최소 오류 또는 무응답 | 위반 카운트 증가 | 종료 가능 |
| 내부 실행기 예외 | 일반 서버 오류 | 세션 격리 | 안전하게 종료 |
파서가 던지는 예외를 최상위 세션 루프에서 모두 IOException으로 바꾸지 않습니다.
예상 가능한 입력 실패는 값으로 처리하고, 실제 버그나 불변식 위반은 오류 ID와 함께 기록한 뒤 해당 세션만 닫습니다.
서버 accept 루프까지 예외가 올라가 새 연결 수락이 중단되지 않도록 경계를 둡니다.
연습 문제
세션마다 최근 프로토콜 위반 점수를 보관하는 FrameGate를 작성합니다.
알 수 없는 명령은 1점, 인수 누락은 2점, 256자를 넘는 프레임은 5점입니다.
누적 5점 이상이면 CLOSE, 그 전에는 KEEP을 반환합니다.
유효한 명령은 점수를 1 낮추되 0 아래로 내려가지 않습니다.
상태와 판정을 함께 반환하는 풀이
점수 변경과 판정을 한 동기화 메서드에서 수행하면 두 스레드가 같은 세션을 잘못 처리하더라도 임계값을 놓치지 않습니다. 실제 서버에서는 세션 입력을 한 작업자만 읽게 만드는 것이 우선이며, 이 클래스는 정책을 독립 검증하기 위한 모델입니다.
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));
}
}
}종료 판정 이후에도 record가 호출될 수 있는 모델이므로, 확장 과제에서는 closed 상태를 추가하고 첫 CLOSE 뒤에는 점수를 바꾸지 않게 만듭니다.
테스트는 정확히 임계값에 도달한 경우와 유효 명령으로 점수가 감소하는 경우를 따로 포함합니다.
명령 계층 검증
문법 파서에는 키 정확 일치, 인수 존재, 길이 상한 테스트가 필요합니다. 상태 실행기에는 입장 전 메시지, 중복 입장, 이름 충돌, 정상 변경, 퇴장 전환을 넣습니다. 여러 세션이 같은 이름을 동시에 요청하는 실험에서는 성공이 하나뿐이어야 하며 실패 세션의 이전 상태가 보존되어야 합니다.
이렇게 명령의 의미가 타입으로 정리되면 거대한 조건문을 각 명령 객체로 옮길 준비가 끝납니다.
다음 문서에서는 명령 키와 구현체를 불변 레지스트리에 연결하고, 알 수 없는 키를 null 검사 대신 기본 명령 객체로 처리하는 커맨드 패턴을 완성합니다.