채팅 명령 레지스트리
채팅 명령을 독립 객체로 분리하고 불변 맵에 게시하며 알 수 없는 명령을 기본 객체로 처리해 확장성과 동시 읽기 안전성을 확보합니다.
명령 파싱과 상태 검증을 나누어도 하나의 switch가 모든 기능 구현을 호출한다면 새 명령을 추가할 때 중앙 코드를 수정해야 합니다.
커맨드 패턴은 명령 키를 실행 객체와 연결해 호출자와 수행자를 분리합니다.
/join 로직을 고칠 때 입장 명령 클래스만 보면 되고, 도움말이나 관리자 공지 기능도 기존 구현의 조건 순서를 건드리지 않고 등록할 수 있습니다.
클래스 수가 늘어난다는 비용은 분명합니다. 단순 명령 두 개뿐인 프로그램에 무조건 패턴을 적용할 이유는 없습니다. 하지만 채팅처럼 명령별 의존성, 권한, 인수 규칙, 테스트가 달라지는 경우에는 분리 이점이 커집니다. 중요한 설계 포인트는 인터페이스 자체보다 레지스트리 생성 시점과 알 수 없는 키의 처리, 명령 객체가 보유하는 상태의 범위입니다.
null 분기 라우터
다음 코드는 명령을 Map으로 찾지만 호출자가 매번 null을 검사해야 합니다.
어느 경로가 검사를 빠뜨리면 알 수 없는 사용자 입력이 NullPointerException으로 바뀝니다.
또한 가변 Map을 생성 뒤에도 외부에 노출하면 다른 스레드가 조회하는 동안 등록 내용이 변할 수 있습니다.
import java.util.HashMap;
import java.util.Map;
public final class NullableCommandRouter {
interface Command {
String execute(String argument);
}
private final Map<String, Command> commands = new HashMap<>();
void register(String key, Command command) {
commands.put(key, command);
}
String route(String frame) {
String[] parts = frame.split("\\|", 2);
Command command = commands.get(parts[0]);
if (command == null) {
return "unknown";
}
String argument = parts.length == 2 ? parts[1] : "";
return command.execute(argument);
}
public static void main(String[] args) {
System.out.println("compile-only nullable router counterexample");
}
}기본 명령 객체를 두면 조회 결과가 항상 Command라는 계약을 만들 수 있습니다.
getOrDefault(key, unknownCommand)는 라우터의 분기를 줄이지만 입력 검증까지 없애 주지는 않습니다.
각 명령은 필요한 인수 개수와 세션 상태를 검사하고 타입이 있는 실행 결과를 반환해야 합니다.
레지스트리는 서버 구성 단계에서 완성한 뒤 Map.copyOf로 게시합니다.
생성 이후 쓰기가 없다면 여러 세션 스레드가 별도 잠금 없이 조회할 수 있습니다.
실행 중 동적 명령 설치가 정말 필요하다면 불변 Map 전체를 AtomicReference로 교체하거나 명확한 동시성 컬렉션 정책을 사용합니다.
학습용 서버에서는 정적 구성이 오류 가능성과 운영 복잡도를 줄입니다.
명령 객체의 책임
명령 구현체가 세션의 소켓 스트림에 직접 쓰면 테스트가 네트워크에 묶입니다.
대신 CommandContext에는 세션 ID와 현재 이름, 사용자 디렉터리처럼 명령 수행에 필요한 최소 포트만 제공합니다.
명령은 Reply, Publish, EndSession, Invalid 결과를 반환하고 외부 어댑터가 실제 전송을 담당합니다.
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class ImmutableCommandRouter {
sealed interface Result permits Reply, Publish, EndSession, Invalid {
}
record Reply(String text) implements Result {
}
record Publish(String text) implements Result {
}
record EndSession(String text) implements Result {
}
record Invalid(String reason) implements Result {
}
interface Context {
String sessionId();
String username();
void username(String value);
List<String> users();
}
@FunctionalInterface
interface Command {
Result execute(String argument, Context context);
}
private final Map<String, Command> commands;
private final Command defaultCommand;
ImmutableCommandRouter(Map<String, Command> commands) {
this.commands = Map.copyOf(commands);
this.defaultCommand = (argument, context) ->
new Invalid("unsupported command");
}
Result route(String frame, Context context) {
String[] parts = frame.split("\\|", 2);
String key = parts[0];
String argument = parts.length == 2 ? parts[1] : "";
Command command = commands.getOrDefault(key, defaultCommand);
return command.execute(argument, context);
}
static ImmutableCommandRouter standard() {
Map<String, Command> commands = new LinkedHashMap<>();
commands.put("/join", (argument, context) -> {
if (argument.isBlank()) {
return new Invalid("name required");
}
if (context.username() != null) {
return new Invalid("already joined");
}
context.username(argument);
return new Publish(argument + " joined");
});
commands.put("/message", (argument, context) -> {
if (context.username() == null) {
return new Invalid("join first");
}
if (argument.isBlank()) {
return new Invalid("message required");
}
return new Publish("[" + context.username() + "] " + argument);
});
commands.put("/users", (argument, context) ->
argument.isEmpty()
? new Reply("users=" + context.users())
: new Invalid("users takes no argument"));
commands.put("/exit", (argument, context) ->
argument.isEmpty()
? new EndSession("bye")
: new Invalid("exit takes no argument"));
return new ImmutableCommandRouter(commands);
}
public static void main(String[] args) {
final class TestContext implements Context {
private String username;
@Override
public String sessionId() {
return "s-1";
}
@Override
public String username() {
return username;
}
@Override
public void username(String value) {
username = value;
}
@Override
public List<String> users() {
return username == null ? List.of() : List.of(username);
}
}
var router = standard();
var context = new TestContext();
for (String frame : List.of(
"/message|early",
"/join|Mina",
"/message|hello",
"/unknown|x",
"/exit")) {
System.out.println(router.route(frame, context));
}
}
}이 구현에서 명령 객체는 요청별 가변 상태를 필드에 저장하지 않습니다. 라우터 인스턴스 하나가 모든 세션에서 공유되므로 람다가 최근 사용자나 인수 값을 캡처해 필드로 유지하면 세션 간 데이터가 섞입니다. 명령 구현체에 필요한 의존성은 불변 서비스로 주입하고, 요청 상태는 매 호출의 문맥과 인수로 전달합니다.
defaultCommand는 null 객체 패턴의 한 형태입니다.
알 수 없는 명령이 정상 명령인 것처럼 성공 결과를 반환해서는 안 되며, 명시적인 Invalid 결과를 만들어 관측 가능성을 보존합니다.
조건문을 없애는 목적보다 “라우팅 결과는 항상 실행 가능한 객체”라는 불변식을 세우는 것이 중요합니다.
명령 등록 검증
Map.put은 같은 키를 다시 넣으면 이전 명령을 조용히 덮어씁니다.
대규모 구성에서 /users가 다른 모듈에 의해 교체되면 서버는 시작에는 성공하지만 런타임 행동이 달라집니다.
빌더가 키 문법, null, 중복을 검사하고 build() 이후 변경을 금지하면 구성 오류를 서버 시작 시점에 찾을 수 있습니다.
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public final class CommandRegistryBuilder {
@FunctionalInterface
interface Command {
String execute(String argument);
}
private final Map<String, Command> entries = new LinkedHashMap<>();
private boolean built;
CommandRegistryBuilder add(String key, Command command) {
ensureMutable();
Objects.requireNonNull(command, "command");
if (!key.matches("/[a-z][a-z0-9-]{1,30}")) {
throw new IllegalArgumentException("invalid command key: " + key);
}
if (entries.putIfAbsent(key, command) != null) {
throw new IllegalArgumentException("duplicate command key: " + key);
}
return this;
}
Map<String, Command> build() {
ensureMutable();
built = true;
if (entries.isEmpty()) {
throw new IllegalStateException("at least one command is required");
}
return Map.copyOf(entries);
}
private void ensureMutable() {
if (built) {
throw new IllegalStateException("registry already built");
}
}
public static void main(String[] args) {
var builder = new CommandRegistryBuilder();
Map<String, Command> commands = builder
.add("/echo", argument -> "echo=" + argument)
.add("/health", argument -> "ok")
.build();
System.out.println(commands.get("/echo").execute("hello"));
System.out.println(commands.keySet());
try {
builder.add("/late", argument -> argument);
} catch (IllegalStateException error) {
System.out.println(error.getMessage());
}
}
}정규식은 예제의 명령 키 규약을 문서화합니다.
한 글자 명령을 허용하려면 {1,30}을 {0,30}으로 조정해야 합니다.
규칙은 임의로 강제하지 말고 프로토콜 사양과 일치시킵니다.
명령 목록을 도움말에 표시할 계획이라면 등록 순서를 유지하는 별도 메타데이터와 사용자용 설명을 함께 저장합니다.
불변 Map은 컨테이너 쓰기를 막지만 값인 Command 내부 상태까지 불변으로 만들지는 않습니다.
각 명령 클래스가 가변 카운터를 가진다면 원자 연산이나 외부 지표 수집기가 필요합니다.
구성 불변성과 구현체의 스레드 안전성을 별개 항목으로 검토해야 합니다.
명령 패턴의 비용
커맨드 패턴을 채택할지는 명령 개수만으로 결정하지 않습니다.
기능마다 다른 권한·검증·서비스 의존성이 있고 독립 배포나 테스트가 필요하면 분리가 유리합니다.
반대로 모든 명령이 한 줄짜리 동일 동작이며 변화 가능성이 낮다면 작은 switch가 더 읽기 쉬울 수 있습니다.
| 변화 요구 | 중앙 조건문 | 명령 레지스트리 | 판정 |
|---|---|---|---|
| 새 명령 하나 추가 | 분기와 테스트 파일 수정 | 구현체 등록 | 레지스트리 우세 |
| 공통 인증 적용 | 모든 분기에 반복 가능 | 데코레이터로 감쌈 | 레지스트리 우세 |
| 명령 두 개가 고정 | 코드가 짧음 | 타입 수 증가 | 조건문 가능 |
| 런타임 플러그인 | 동적 분기 필요 | 게시 정책 필요 | 별도 아키텍처 |
패턴 도입 뒤에는 라우터가 명령별 세부 내용을 알지 않는지 확인합니다.
라우터에 /join만을 위한 예외 분기가 다시 생기면 책임 분리가 무너집니다.
반대로 공통 프레임 크기, 세션 ID 로깅, 실행 시간 계측은 데코레이터나 라우터 경계에서 한 번 적용하는 것이 맞습니다.
연습 문제
운영자만 /announce를 실행할 수 있도록 기존 Command를 수정하지 않는 AuthorizedCommand를 구현해 봅니다.
권한이 없으면 대상 명령을 호출하지 않고 DENIED를 반환하며, 허용되면 원래 결과를 그대로 돌려줍니다.
호출 횟수까지 검증하는 풀이
데코레이터도 같은 함수형 인터페이스를 구현합니다.
권한 판정 함수를 생성자에서 받아 정책을 교체할 수 있게 하고, null 결과가 나오지 않도록 생성 시 의존성을 검증합니다.
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Predicate;
public final class AuthorizedCommandSolution {
record Context(String user, String role) {
}
sealed interface Result permits Accepted, Denied {
}
record Accepted(String message) implements Result {
}
record Denied(String reason) implements Result {
}
@FunctionalInterface
interface Command {
Result execute(String argument, Context context);
}
static final class AuthorizedCommand implements Command {
private final Predicate<Context> permission;
private final Command target;
AuthorizedCommand(Predicate<Context> permission, Command target) {
this.permission = Objects.requireNonNull(permission, "permission");
this.target = Objects.requireNonNull(target, "target");
}
@Override
public Result execute(String argument, Context context) {
if (!permission.test(context)) {
return new Denied("role=" + context.role());
}
return target.execute(argument, context);
}
}
public static void main(String[] args) {
AtomicInteger calls = new AtomicInteger();
Command announce = (argument, context) -> {
calls.incrementAndGet();
return new Accepted("[notice] " + argument);
};
Command protectedCommand = new AuthorizedCommand(
context -> context.role().equals("ADMIN"),
announce);
System.out.println(protectedCommand.execute(
"maintenance",
new Context("Mina", "MEMBER")));
System.out.println(protectedCommand.execute(
"maintenance",
new Context("Root", "ADMIN")));
System.out.println("targetCalls=" + calls.get());
}
}출력의 targetCalls=1이 권한 거부 경로에서 실제 명령이 실행되지 않았다는 증거입니다.
확장 과제로 실행 시간 계측 데코레이터를 바깥에 둘 때와 권한 데코레이터 안쪽에 둘 때 어떤 요청 시간이 측정되는지 비교합니다.
순서에 따라 거부 요청 포함 여부가 달라집니다.
완성 기준
레지스트리 빌드에서 중복 키가 즉시 실패하고, 게시된 Map이 더는 변경되지 않으며, 알 수 없는 명령도 null 없이 Invalid 결과를 만들어야 합니다.
동일 라우터를 여러 세션이 동시에 사용해도 명령 객체의 요청 상태가 섞이지 않는지 확인합니다.
각 구현체 테스트는 실제 소켓 대신 작은 문맥 대역으로 결과와 상태 전이를 검증합니다.
이 장에서 완성한 구조는 연결 수명, 명령 문법, 실행 객체, 브로드캐스트를 서로 다른 경계로 나눕니다. 다음 장의 HTTP 서버도 같은 TCP 스트림 위에 요청 문법과 라우팅을 세웁니다. 다만 HTTP는 시작 줄, 헤더, 본문 길이, 응답 상태처럼 더 엄격한 메시지 계약을 가지므로 채팅의 단순 구분자 파서를 그대로 재사용해서는 안 됩니다.