본문 바로가기

커맨드 패턴으로 채팅 명령 분리하기

채팅 명령마다 실행 객체를 만들어 맵에 등록하고, 알 수 없는 명령을 기본 객체로 처리하며, 완성한 명령표를 여러 세션이 함께 쓰는 소켓 서버에 연결하는 방법을 배웁니다.

채팅 명령 파싱과 상태 검사에서 만든 dispatch는 모든 명령을 switch 하나에서 처리합니다. 명령이 다섯 개일 때는 읽기 쉽지만, 도움말 /help나 운영자용 공지 /announce를 추가할 때마다 이 중앙의 메서드를 열어 고쳐야 합니다. 명령마다 필요한 서비스, 권한, 인수 규칙이 달라질수록 한 메서드가 알아야 할 것이 늘어나고, /join을 고치다가 옆에 있는 /message의 분기를 건드리는 실수도 쉬워집니다.

커맨드 패턴(command pattern)은 '무엇을 실행할지'를 객체로 만들어 두는 방법입니다. 명령 이름마다 실행 객체를 하나씩 만들어 맵에 등록해 두면, 라우터(router)는 프레임에서 명령 이름을 꺼내 맵에서 객체를 찾아 실행하기만 합니다. 이렇게 명령 이름과 실행 객체를 묶어 둔 표를 명령 레지스트리(registry)라고 부릅니다. 새 명령은 객체 하나를 만들어 등록하면 되고, 라우터 코드는 바뀌지 않습니다. 물론 명령 수만큼 클래스나 람다가 늘고, 처리 흐름을 따라가려면 등록 코드와 실행 객체를 오가며 읽어야 한다는 대가도 있습니다.

이 글에서는 레지스트리를 만들면서 세 가지를 따집니다. 맵에 없는 명령을 어떻게 처리할지, 명령표를 언제 완성해서 여러 세션 스레드에 공유할지, 명령 객체가 어떤 상태를 가져도 되는지입니다. 마지막에는 완성한 라우터를 실제 소켓 서버에 연결해 두 클라이언트가 대화하는 과정을 실행해 봅니다.

맵에서 찾은 명령이 null일 때

명령 이름으로 실행 객체를 찾는 가장 단순한 방법은 HashMap에 넣어 두고 get으로 꺼내는 것입니다. 아래 라우터는 /echo 하나를 등록한 뒤, 정상 프레임과 오타가 난 프레임을 차례로 처리합니다.

bad/NullableCommandRouter.java
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);
        String argument = parts.length == 2 ? parts[1] : "";
        return commands.get(parts[0]).execute(argument);
    }

    public static void main(String[] args) {
        var router = new NullableCommandRouter();
        router.register("/echo", argument -> "echo=" + argument);

        System.out.println(router.route("/echo|hi"));
        System.out.println(router.route("/ecoh|hi"));
    }
}

두 번째 호출의 /ecoh는 /echo의 오타라서 등록된 키가 아닙니다. 이 호출을 감싸는 try가 없으므로, 여기서 예외가 나면 그 예외는 main 밖으로 나가 프로그램을 끝냅니다.

echo=hi
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "NullableCommandRouter$Command.execute(String)" because the return value of "java.util.Map.get(Object)" is null
	at NullableCommandRouter.route(NullableCommandRouter.java:18)
	at NullableCommandRouter.main(NullableCommandRouter.java:26)

Map.get은 키가 없으면 null을 돌려줍니다. 오타가 난 /ecoh는 등록되지 않은 키라서 get이 null을 돌려줬고, 그 null에 execute를 호출하는 순간 NullPointerException이 났습니다. JDK 15부터 기본으로 켜진 자세한 NPE 메시지 덕분에 'Map.get의 반환값이 null이라서 execute를 호출할 수 없다'는 원인이 그대로 나옵니다. 서버였다면 사용자의 오타 하나가 그 세션 스레드를 끝내 버렸을 것입니다.

route에 if (command == null) 검사를 넣으면 이 예외는 막을 수 있습니다. 하지만 명령표를 조회하는 곳이 route 하나로 끝나지 않는다는 것이 문제입니다. 도움말을 만드는 코드, 권한을 확인하는 코드처럼 조회하는 곳이 늘어날 때마다 같은 검사를 기억해서 넣어야 하고, 한 곳만 빠뜨려도 같은 예외가 납니다.

이 라우터에는 문제가 하나 더 있습니다. register를 언제든 호출할 수 있으므로, 어떤 스레드가 명령을 등록하는 동안 세션 스레드들이 조회하면 동기화되지 않은 HashMap을 동시에 읽고 쓰게 됩니다. 이때는 조회가 엉뚱한 결과를 내거나 맵 내부 구조가 깨질 수 있습니다.

기본 명령 객체로 null 없애기

Map.getOrDefault(key, defaultValue)는 키가 없을 때 null 대신 두 번째 인수를 돌려줍니다. 여기에 '지원하지 않는 명령'이라고 답하는 기본 명령 객체를 넘기면 조회 결과는 언제나 실행할 수 있는 Command가 되고, 호출하는 쪽에서 null을 검사할 일이 사라집니다. 이렇게 '없음'을 null 대신 정해진 기본 동작을 하는 객체로 나타내는 방법을 널 객체 패턴(null object pattern)이라고 합니다.

널 객체는 흔히 아무 일도 하지 않는 객체로 만들지만, 채팅 서버의 기본 명령이 조용히 아무것도 하지 않으면 곤란합니다. 사용자는 명령이 왜 무시됐는지 알 수 없고, 서버 로그에도 흔적이 남지 않기 때문입니다. 그래서 기본 명령은 성공처럼 보이는 결과 대신 Invalid("unsupported command")처럼 실패를 분명히 드러내는 결과를 돌려줍니다. 또 getOrDefault가 없애 주는 것은 '명령이 없을 때'의 분기뿐입니다. 인수가 맞는지, 지금 상태에서 허용되는지는 여전히 각 명령이 직접 검사해야 합니다.

명령 객체와 Context로 라우터 만들기

다음 예제는 이 생각을 모두 담은 라우터입니다. 구성 요소를 먼저 살펴보겠습니다.

  • Command는 메서드가 execute 하나뿐인 함수형 인터페이스여서 람다로 구현할 수 있습니다. 인수 문자열과 Context를 받아 Result를 돌려줍니다.
  • Context는 명령을 실행할 때 필요한 세션 정보만 모은 인터페이스입니다. 세션 ID, 현재 이름 읽기와 바꾸기, 접속자 목록이 전부이고 소켓이나 스트림은 들어 있지 않습니다.
  • Result는 실행 결과입니다. 보낸 사람에게만 답하는 Reply, 모두에게 알리는 Publish, 응답한 뒤 세션을 끝내는 EndSession, 잘못된 요청을 알리는 Invalid가 있으며 앞 글의 Decision과 같은 역할을 합니다.
  • 생성자는 받은 맵을 Map.copyOf로 복사해 수정할 수 없는 명령표를 만들고, 맵에 없는 키에 쓸 기본 명령도 준비합니다. standard()는 /join, /message, /users, /exit 네 명령을 등록한 라우터를 만듭니다.

명령이 소켓에 직접 쓰지 않고 결과 값만 돌려주게 한 이유는 테스트와 재사용 때문입니다. 명령이 출력 스트림에 직접 쓴다면 명령 하나를 시험하는 데도 실제 연결이 필요하지만, 결과 값만 돌려준다면 작은 가짜 Context 하나로 충분합니다. 결과를 받아 실제로 전송하는 일은 바깥의 네트워크 코드가 맡는데, 이 글의 마지막 절에서 바로 그 코드를 만듭니다.

src/ImmutableCommandRouter.java
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));
        }
    }
}

main은 Context를 구현한 TestContext로 실제 연결 없이 프레임 다섯 개를 차례로 처리합니다. TestContext는 main 안에 선언한 지역 클래스(local class)라서 이 메서드 안에서만 쓰입니다.

실행 결과:

Invalid[reason=join first]
Publish[text=Mina joined]
Publish[text=[Mina] hello]
Invalid[reason=unsupported command]
EndSession[text=bye]

첫 줄은 입장하기 전에 보낸 메시지라서 /message 명령이 직접 join first로 거절했습니다. 둘째 줄에서 /join 명령이 Context에 이름을 저장했기 때문에 셋째 줄의 메시지에는 [Mina]가 붙었습니다. 넷째 줄의 /unknown은 맵에 없는 키라서 getOrDefault가 기본 명령을 돌려줬고, 기본 명령이 unsupported command를 만들었습니다. NullableCommandRouter였다면 여기서 NullPointerException이 났을 것입니다.

마지막 줄의 EndSession은 '세션을 끝내겠다'는 결과일 뿐입니다. 아래 그림의 마지막 단계에서 보듯 Context의 이름은 Mina 그대로 남고, 연결을 닫고 목록을 정리하는 일은 이 결과를 받은 바깥 코드가 합니다.

명령 결과와 문맥 변경을 나란히 읽기

같은 Context가 가입 전 message, join, message, unknown, exit 프레임을 차례로 처리합니다. 사용자 이름과 Publish/Invalid/EndSession 결과를 추적하되 exit 반환 뒤 실제 정리를 추가하지 않습니다.

명령 결과와 문맥 변경을 나란히 읽기

ImmutableCommandRouter main의 입력 순서에서 결과 객체와 현재 username의 변화를 비교합니다.

불변 명령 맵 · 결과와 문맥의 경계

명령 결과와 문맥 변경을 나란히 읽기

/join은 username을 저장하지만 /exit는 EndSession 값만 반환합니다. 종료 결과를 받은 뒤 정리할 책임은 라우터 바깥에 있습니다.

가입 명령만 문맥 이름을 만든다

가입 명령만 문맥 이름을 만든다가입 전 message는 Invalid(join first)다. join Mina가 문맥을 만들고 이후 이름은 유지된다.Context.username=nullearly 메시지Invalidjoin MinaPublishusernameMina 저장
가입 명령만 문맥 이름을 만든다가입 전 message는 Invalid(join first)다. join Mina가 문맥을 만들고 이후 이름은 유지된다.Context.username=nullearly메시지Invalidjoin MinaPublishusernameMina 저장

가입 전 message는 Invalid(join first)다. join Mina가 문맥을 만들고 이후 이름은 유지된다.

가입 뒤 결과 반환은 외부 정리와 다르다

가입 뒤 결과 반환은 외부 정리와 다르다Mina 문맥에서 hello는 Publish, unknown은 Invalid, exit는 EndSession을 반환한다. 세 결과 모두 문맥의 Mina를 유지하며 실제 정리는 라우터 바깥 책임이다.Mina 문맥명령별 독립 결과messagePublishunknownInvalidexit → EndSession(bye)Mina 유지 · 정리는 외부
가입 뒤 결과 반환은 외부 정리와 다르다Mina 문맥에서 hello는 Publish, unknown은 Invalid, exit는 EndSession을 반환한다. 세 결과 모두 문맥의 Mina를 유지하며 실제 정리는 라우터 바깥 책임이다.Mina 문맥명령별 독립 결과messagePublishunknownInvalidexit → EndSession(bye)Mina 유지 · 정리는 외부

Mina 문맥에서 hello는 Publish, unknown은 Invalid, exit는 EndSession을 반환한다. 세 결과 모두 문맥의 Mina를 유지하며 실제 정리는 라우터 바깥 책임이다.

상태별 상세 해설
  1. /message|early

    결과
    Invalid(join first)
    호출 뒤 username
    null
  2. /join|Mina

    결과
    Publish(Mina joined)
    호출 뒤 username
    Mina
  3. /message|hello

    결과
    Publish([Mina] hello)
    호출 뒤 username
    Mina
  4. /unknown|x

    결과
    Invalid(unsupported command)
    호출 뒤 username
    Mina
  5. /exit

    결과
    EndSession(bye)
    호출 뒤 username
    Mina 그대로

EndSession은 종료 의사를 나타내는 결과입니다. 이 예제의 라우터는 username 초기화, 목록 제거, 소켓 닫기를 수행하지 않습니다.

이 예의 조건

  • EndSession 반환은 실제 소켓 종료나 회원 제거를 수행하지 않는다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보명령 결과와 문맥 변경을 나란히 읽기 명령 결과와 문맥 변경을 나란히 읽기 ImmutableCommandRouter main의 입력 순서에서 결과 객체와 현재 username의 변화를 비교합니다. 불변 명령 맵 · 결과와 문맥의 경계 명령 결과와 문맥 변경을 나란히 읽기 /join은 username을 저장하지만 /exit는 EndSession 값만 반환합니다. 종료 결과를 받은 뒤 정리할 책임은 라우터 바깥에 있습니다. /message|early 결과 Invalid(join first) 호출 뒤 username null /join|Mina 결과 Publish(Mina joined) 호출 뒤 username Mina /message|hello 결과 Publish([Mina] hello) 호출 뒤 username Mina /unknown|x 결과 Invalid(unsupported command) 호출 뒤 username Mina /exit 결과 EndSession(bye) 호출 뒤 username Mina 그대로 EndSession은 종료 의사를 나타내는 결과입니다. 이 예제의 라우터는 username 초기화, 목록 제거, 소켓 닫기를 수행하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
가입 전 메시지

첫 message를 거절합니다.

  • username: null
  • 결과: Invalid
  • 가입 상태가 없어 게시를 허용하지 않는다.
가입

join Mina가 상태를 만듭니다.

  • username: Mina
  • 결과: Publish
  • 정상 가입이 Context의 사용자 이름을 설정한다.
가입 후 메시지

hello를 게시합니다.

  • username: Mina
  • 결과: Publish hello
  • 가입 상태를 보존하여 다음 명령에 사용한다.
알 수 없는 명령

unknown을 거절합니다.

  • username: Mina 유지
  • 결과: Invalid
  • 명령 오류가 가입 상태를 지우지 않는다.
종료 요청 반환

exit는 bye를 담은 EndSession을 반환합니다.

  • username: Mina 유지
  • 결과: EndSession bye
  • 실제 cleanup: 호출자 책임
  • 라우터 반환과 외부 정리는 서로 다른 단계다.

명령 결과와 문맥 변경을 나란히 읽기

같은 Context가 가입 전 message, join, message, unknown, exit 프레임을 차례로 처리합니다. 사용자 이름과 Publish/Invalid/EndSession 결과를 추적하되 exit 반환 뒤 실제 정리를 추가하지 않습니다.

명령 결과와 문맥 변경을 나란히 읽기

ImmutableCommandRouter main의 입력 순서에서 결과 객체와 현재 username의 변화를 비교합니다.

불변 명령 맵 · 결과와 문맥의 경계

명령 결과와 문맥 변경을 나란히 읽기

/join은 username을 저장하지만 /exit는 EndSession 값만 반환합니다. 종료 결과를 받은 뒤 정리할 책임은 라우터 바깥에 있습니다.

가입 명령만 문맥 이름을 만든다

가입 명령만 문맥 이름을 만든다가입 전 message는 Invalid(join first)다. join Mina가 문맥을 만들고 이후 이름은 유지된다.Context.username=nullearly 메시지Invalidjoin MinaPublishusernameMina 저장
가입 명령만 문맥 이름을 만든다가입 전 message는 Invalid(join first)다. join Mina가 문맥을 만들고 이후 이름은 유지된다.Context.username=nullearly메시지Invalidjoin MinaPublishusernameMina 저장

가입 전 message는 Invalid(join first)다. join Mina가 문맥을 만들고 이후 이름은 유지된다.

가입 뒤 결과 반환은 외부 정리와 다르다

가입 뒤 결과 반환은 외부 정리와 다르다Mina 문맥에서 hello는 Publish, unknown은 Invalid, exit는 EndSession을 반환한다. 세 결과 모두 문맥의 Mina를 유지하며 실제 정리는 라우터 바깥 책임이다.Mina 문맥명령별 독립 결과messagePublishunknownInvalidexit → EndSession(bye)Mina 유지 · 정리는 외부
가입 뒤 결과 반환은 외부 정리와 다르다Mina 문맥에서 hello는 Publish, unknown은 Invalid, exit는 EndSession을 반환한다. 세 결과 모두 문맥의 Mina를 유지하며 실제 정리는 라우터 바깥 책임이다.Mina 문맥명령별 독립 결과messagePublishunknownInvalidexit → EndSession(bye)Mina 유지 · 정리는 외부

Mina 문맥에서 hello는 Publish, unknown은 Invalid, exit는 EndSession을 반환한다. 세 결과 모두 문맥의 Mina를 유지하며 실제 정리는 라우터 바깥 책임이다.

상태별 상세 해설
  1. /message|early

    결과
    Invalid(join first)
    호출 뒤 username
    null
  2. /join|Mina

    결과
    Publish(Mina joined)
    호출 뒤 username
    Mina
  3. /message|hello

    결과
    Publish([Mina] hello)
    호출 뒤 username
    Mina
  4. /unknown|x

    결과
    Invalid(unsupported command)
    호출 뒤 username
    Mina
  5. /exit

    결과
    EndSession(bye)
    호출 뒤 username
    Mina 그대로

EndSession은 종료 의사를 나타내는 결과입니다. 이 예제의 라우터는 username 초기화, 목록 제거, 소켓 닫기를 수행하지 않습니다.

이 예의 조건

  • EndSession 반환은 실제 소켓 종료나 회원 제거를 수행하지 않는다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
설명
전체 읽기 정보명령 결과와 문맥 변경을 나란히 읽기 명령 결과와 문맥 변경을 나란히 읽기 ImmutableCommandRouter main의 입력 순서에서 결과 객체와 현재 username의 변화를 비교합니다. 불변 명령 맵 · 결과와 문맥의 경계 명령 결과와 문맥 변경을 나란히 읽기 /join은 username을 저장하지만 /exit는 EndSession 값만 반환합니다. 종료 결과를 받은 뒤 정리할 책임은 라우터 바깥에 있습니다. /message|early 결과 Invalid(join first) 호출 뒤 username null /join|Mina 결과 Publish(Mina joined) 호출 뒤 username Mina /message|hello 결과 Publish([Mina] hello) 호출 뒤 username Mina /unknown|x 결과 Invalid(unsupported command) 호출 뒤 username Mina /exit 결과 EndSession(bye) 호출 뒤 username Mina 그대로 EndSession은 종료 의사를 나타내는 결과입니다. 이 예제의 라우터는 username 초기화, 목록 제거, 소켓 닫기를 수행하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
가입 전 메시지

첫 message를 거절합니다.

  • username: null
  • 결과: Invalid
  • 가입 상태가 없어 게시를 허용하지 않는다.
가입

join Mina가 상태를 만듭니다.

  • username: Mina
  • 결과: Publish
  • 정상 가입이 Context의 사용자 이름을 설정한다.
가입 후 메시지

hello를 게시합니다.

  • username: Mina
  • 결과: Publish hello
  • 가입 상태를 보존하여 다음 명령에 사용한다.
알 수 없는 명령

unknown을 거절합니다.

  • username: Mina 유지
  • 결과: Invalid
  • 명령 오류가 가입 상태를 지우지 않는다.
종료 요청 반환

exit는 bye를 담은 EndSession을 반환합니다.

  • username: Mina 유지
  • 결과: EndSession bye
  • 실제 cleanup: 호출자 책임
  • 라우터 반환과 외부 정리는 서로 다른 단계다.

이 라우터는 앞 글의 parse보다 문법 검사가 느슨합니다. 프레임 전체 길이나 null 프레임을 검사하지 않고, 인수의 공백을 trim하지 않습니다. /users|처럼 빈 인수를 붙인 형식도 argument.isEmpty()가 참이라서 받아들입니다. 실제 서버라면 길이 상한이나 명령 이름 형식처럼 모든 명령에 공통인 검사는 라우터에 들어오기 전에 한 번만 하고, 명령별 인수 규칙은 각 명령이 맡는 식으로 나눕니다. 어느 쪽 규칙을 따르든 클라이언트와 서버가 같은 규칙을 쓰도록 프로토콜 설명에 적어 두어야 합니다.

여러 세션이 라우터 하나를 함께 쓸 때

서버에는 라우터가 하나만 있고, 모든 세션 스레드가 그 라우터를 동시에 씁니다. 그런데도 잠금이 필요 없는 이유는 두 가지입니다. 첫째, 명령표는 생성자에서 Map.copyOf로 만든 수정할 수 없는 맵이라서 생성 뒤에는 누구도 쓰지 않습니다. 이 맵에 put 같은 수정 메서드를 호출하면 UnsupportedOperationException이 납니다. 여러 스레드가 읽기만 하는 데이터에서는 경쟁이 일어날 여지가 없습니다.

둘째, 이 맵을 가리키는 필드가 final이라서, 생성자가 끝난 뒤 라우터를 받은 스레드는 완성된 맵을 보게 됩니다. 둘째 이유는 아래 '더 깊이'에서 자세히 설명합니다.

하지만 수정할 수 없는 것은 맵이라는 그릇뿐이고, 그 안에 든 명령 객체까지 불변이 되는 것은 아닙니다. 명령 객체가 요청마다 바뀌는 값을 필드에 저장하면, 그 필드는 모든 세션이 함께 쓰는 공유 변수가 됩니다. 다음은 마지막으로 말한 사람을 필드에 기억해 두는 잘못된 명령입니다.

final class LastSpeakerCommand implements Command {
    private String lastSpeaker; // 모든 세션이 함께 쓰는 필드

    @Override
    public Result execute(String argument, Context context) {
        lastSpeaker = context.username();
        String text = argument.strip();
        return new Publish("[" + lastSpeaker + "] " + text);
    }
}

Mina와 Jin의 세션 스레드가 이 명령을 거의 동시에 실행하면 다음 순서가 가능합니다.

순서Mina의 세션 스레드Jin의 세션 스레드lastSpeaker
1lastSpeaker에 Mina 저장Mina
2lastSpeaker에 Jin 저장Jin
3lastSpeaker를 읽어 결과를 만든다Jin

그 결과 Mina가 보낸 메시지가 [Jin]이라는 이름을 달고 모두에게 전송됩니다. 그래서 요청마다 달라지는 값은 필드가 아니라 execute의 매개변수와 지역 변수로만 다룹니다. 명령에 필요한 서비스, 예를 들어 이름 디렉터리는 명령을 만들 때 넣어 주되 그 서비스 자체가 스레드에 안전해야 합니다. 실행 횟수 같은 통계를 꼭 필드에 모아야 한다면 AtomicLong 같은 원자 변수를 씁니다.

더 깊이: 잠금 없이 읽어도 되는 이유와 final 필드

다른 스레드가 만든 객체를 읽을 때는 '그 스레드가 쓴 값이 내 스레드에 보이는가'라는 가시성(visibility) 문제가 있습니다. 자바 메모리 모델은 final 필드에 특별한 규칙을 둡니다. 생성자가 끝나기 전에 this가 밖으로 새어 나가지 않았다면, 그 객체의 참조를 얻은 스레드는 final 필드의 값과 그 필드를 통해 닿는 객체들을 적어도 생성자가 끝난 시점의 상태로 보게 됩니다(JLS 17.5). ImmutableCommandRouter의 commands와 defaultCommand는 final이고 생성자 안에서 this를 다른 곳에 넘기지 않으므로 이 조건을 만족합니다. 여기에 생성 뒤에는 아무도 맵에 쓰지 않는다는 점이 더해져, 세션 스레드들은 잠금 없이 맵을 읽을 수 있습니다.

필드가 final이 아니라면 라우터를 다른 스레드에 넘기는 방법까지 따져야 합니다. 스레드를 시작하기 전에 넘기거나 volatile 변수 또는 잠금을 거쳐 넘기면 안전하지만, 그런 장치 없이 넘기면 받는 쪽이 덜 만들어진 상태를 볼 수 있습니다. 공유할 객체의 필드를 final로 선언하는 습관이 동시성 코드에서 특히 중요한 이유입니다. 불변 객체의 공유는 불변 객체와 안전한 공유에서, 수정할 수 없는 컬렉션은 불변 값과 컬렉션 스냅샷에서 더 다룹니다.

더 깊이: 실행 중에 명령을 추가해야 한다면

플러그인처럼 서버가 실행되는 도중에 명령을 설치해야 한다면, 맵을 고치는 대신 맵 전체를 새것으로 바꿔 끼웁니다. 아래처럼 AtomicReference에 수정할 수 없는 맵을 담아 두고, 설치할 때마다 복사본을 만들어 교체합니다.

private final AtomicReference<Map<String, Command>> commands =
        new AtomicReference<>(Map.of());

void install(String key, Command command) {
    commands.updateAndGet(current -> {
        Map<String, Command> next = new HashMap<>(current);
        next.put(key, command);
        return Map.copyOf(next);
    });
}

조회하는 쪽은 commands.get()으로 그 순간의 맵을 꺼내 쓰므로, 교체 전의 완성된 맵이나 교체 후의 완성된 맵 가운데 하나를 보게 되고 중간 상태는 보지 않습니다. updateAndGet은 다른 스레드와 경쟁해 교체에 실패하면 함수를 다시 실행하므로, 넘기는 함수에는 부수 효과가 없어야 합니다. 이 글의 서버처럼 명령이 시작할 때 정해진다면 이런 장치 없이 고정된 명령표를 쓰는 것이 가장 단순합니다.

등록할 때 실수를 잡아내는 빌더

Map.put은 같은 키를 다시 넣으면 이전 값을 조용히 덮어씁니다. 여러 모듈이 명령을 등록하는 서버에서 두 모듈이 모두 /users를 등록하면, 서버는 문제없이 시작되지만 어느 쪽 명령이 실행될지는 등록 순서에 따라 정해집니다. 이런 실수는 나중에 '어제와 동작이 다르다'는 형태로 늦게 드러납니다. 아래 빌더(builder)는 명령을 추가할 때마다 키 형식, null, 중복을 검사하고, build()를 호출한 뒤에는 더 이상 추가하지 못하게 막습니다. 덕분에 구성 실수를 서버가 시작하는 순간에 찾아낼 수 있습니다.

src/CommandRegistryBuilder.java
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(key, "key");
        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());
        }
    }
}

main은 명령 두 개를 등록해 맵을 만든 뒤, 그 맵에서 /echo를 꺼내 실행하고 키 목록을 찍습니다. 마지막에는 이미 build()를 부른 빌더에 /late를 더해 봅니다. 한 번 실행한 결과는 다음과 같습니다.

echo=hello
[/echo, /health]
registry already built

첫 줄은 등록한 /echo 명령을 꺼내 실행한 결과이고, 셋째 줄은 build() 뒤에 /late를 추가하려다 ensureMutable에서 막힌 결과입니다. 둘째 줄의 순서는 실행할 때마다 달라질 수 있어서, 같은 프로그램을 여러 번 실행하면 [/health, /echo]가 나오기도 합니다. Map.copyOf가 돌려주는 수정할 수 없는 맵은 Map의 공식 문서가 밝히듯 순회 순서가 정해져 있지 않고 바뀔 수 있습니다. 현재 JDK 구현은 JVM이 시작될 때 정해지는 값으로 순서를 섞기 때문에, 입력이 순서를 지키는 LinkedHashMap이었어도 결과 순서는 실행마다 다를 수 있습니다. 도움말 화면처럼 순서가 필요하다면 build() 안에서 List.copyOf(entries.keySet())로 순서를 담은 목록을 따로 만들어 둡니다.

add의 각 검사도 살펴보겠습니다. putIfAbsent는 키가 없을 때만 값을 넣고, 이미 있으면 기존 값을 돌려줍니다. 그래서 반환값이 null이 아니면 중복 등록이므로 예외를 던집니다.

키 형식은 정규식 /[a-z][a-z0-9-]{1,30}으로 검사하는데, 슬래시 다음에 소문자 하나가 오고 그 뒤에 소문자, 숫자, 하이픈이 1개 이상 30개 이하로 이어져야 한다는 뜻입니다. String.matches는 문자열 전체가 정규식과 맞아야 참을 돌려주므로 /q 같은 한 글자 명령과 /Users 같은 대문자 명령은 invalid command key로 거절됩니다. 한 글자 명령을 허용하려면 {1,30}을 {0,30}으로 바꾸면 됩니다. 어느 쪽이든 이 규칙은 임의로 정할 것이 아니라 프로토콜 설명과 맞춰야 합니다.

build()는 비어 있는지 검사하기 전에 built를 먼저 true로 바꿉니다. 그래서 명령 없이 build()를 호출해 실패하면 그 빌더에는 더 이상 명령을 추가할 수 없습니다. 실패한 빌더를 고쳐 쓰지 말고 새로 만들라는 선택입니다. 빌더는 서버를 시작하는 스레드 하나에서만 쓴다고 가정하므로 동기화하지 않았습니다.

커맨드 패턴이 맞는 경우와 아닌 경우

커맨드 패턴을 쓸지는 명령 개수만 보고 정하지 않습니다. 명령마다 필요한 권한, 검증, 서비스가 다르고 명령을 따로 테스트하거나 따로 추가해야 한다면 나누는 쪽이 유리합니다. 반대로 모든 명령이 한 줄짜리 비슷한 동작이고 앞으로 바뀔 일도 적다면 앞 글의 작은 switch가 더 읽기 쉽습니다. sealed와 switch를 쓰면 처리하지 않은 명령을 컴파일러가 찾아 준다는 장점도 있습니다.

바뀌는 요구중앙 switch명령 레지스트리더 나은 쪽
새 명령 하나 추가parse와 dispatch, 그 테스트를 함께 고친다실행 객체를 만들어 등록한다레지스트리
모든 명령에 권한 검사 적용분기마다 같은 검사를 반복하기 쉽다데코레이터로 감싸 한 번에 적용한다레지스트리
명령 두 개로 고정코드가 짧다타입과 등록 코드가 늘어난다switch
실행 중에 플러그인으로 명령 추가분기를 실행 중에 늘릴 수 없다명령표 교체 방식을 정해야 한다따로 설계해야 한다

표의 데코레이터(decorator)는 같은 인터페이스를 구현하면서 다른 객체를 감싸, 호출 앞뒤에 기능을 덧붙이는 객체입니다. 권한을 검사한 뒤에만 원래 명령을 호출하는 데코레이터를 만들어 두면, 명령 코드는 한 줄도 고치지 않고 어떤 명령이든 보호할 수 있습니다. 연습 문제에서 직접 만들어 봅니다.

패턴을 도입한 뒤에는 라우터가 명령별 사정을 모르는 상태로 남아 있는지 확인합니다. 라우터에 /join만을 위한 if가 다시 생기기 시작하면 나눈 의미가 사라집니다. 반대로 프레임 길이 제한, 세션 ID 로그, 실행 시간 측정처럼 모든 명령에 공통인 일은 라우터의 입구나 데코레이터에서 한 번만 처리하는 것이 맞습니다.

라우터를 실제 소켓 서버에 연결하기

지금까지 명령은 결과 값만 돌려줬고, 결과를 실제로 전송하는 일은 바깥 코드가 맡는다고만 했습니다. 이제 그 바깥 코드를 만들어, ImmutableCommandRouter를 한 줄도 고치지 않고 소켓 서버에 연결해 보겠습니다. 뼈대는 채팅 서버의 세션 관리와 브로드캐스트의 ChatServer와 같습니다. 세션마다 스레드를 하나씩 두고, 두 스레드로 만드는 채팅 클라이언트에서처럼 writeUTF와 readUTF로 프레임을 주고받습니다. 달라진 점은 프레임을 직접 비교하던 자리에 라우터가 들어가고, 세션 객체가 Context를 구현해 라우터에 자기 자신을 넘긴다는 것입니다.

main은 한 프로세스 안에서 서버와 테스트용 클라이언트 둘(Mina와 Jin)을 함께 실행합니다. 서버 소켓을 포트 0으로 열면 운영체제가 비어 있는 포트를 골라 주므로 다른 프로그램과 충돌하지 않고, 루프백 주소(보통 127.0.0.1)에만 묶었으므로 다른 컴퓨터에서는 접속할 수 없습니다. 클라이언트마다 받은 프레임을 큐에 쌓는 읽기 스레드가 있고, main은 프레임을 보낸 뒤 expect로 받기로 한 개수만큼 큐에서 꺼내 출력합니다. 출력은 모두 main 스레드가 정해진 순서로 하므로, 서버 스레드들이 어떤 순서로 실행되든 출력 순서는 바뀌지 않습니다.

스레드는 ChatServer처럼 모두 Thread.ofVirtual()로 만든 가상 스레드입니다. 가상 스레드는 언제나 데몬(daemon) 스레드, 즉 JVM이 끝날 때 기다려 주지 않는 스레드라서, readUTF에서 멈춰 있는 스레드가 남아 있어도 main이 끝나면 프로그램이 종료됩니다.

이 프로그램은 앞의 ImmutableCommandRouter.java를 그대로 사용합니다. 두 파일을 같은 폴더에 두고 다음처럼 컴파일하고 실행합니다.

javac ImmutableCommandRouter.java ChatServerDemo.java
java ChatServerDemo
src/ChatServerDemo.java
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public final class ChatServerDemo {
    private final ImmutableCommandRouter router = ImmutableCommandRouter.standard();
    private final List<Session> sessions = new CopyOnWriteArrayList<>();
    private final AtomicInteger nextId = new AtomicInteger();

    final class Session implements ImmutableCommandRouter.Context, Runnable {
        private final String id;
        private final Socket socket;
        private final DataInputStream in;
        private final DataOutputStream out;
        private volatile String username;

        Session(String id, Socket socket) throws IOException {
            this.id = id;
            this.socket = socket;
            this.in = new DataInputStream(socket.getInputStream());
            this.out = new DataOutputStream(socket.getOutputStream());
        }

        @Override
        public String sessionId() {
            return id;
        }

        @Override
        public String username() {
            return username;
        }

        @Override
        public void username(String value) {
            username = value;
        }

        @Override
        public List<String> users() {
            return sessions.stream()
                    .map(Session::username)
                    .filter(Objects::nonNull)
                    .toList();
        }

        @Override
        public void run() {
            try (socket) {
                while (true) {
                    var result = router.route(in.readUTF(), this);
                    switch (result) {
                        case ImmutableCommandRouter.Reply reply ->
                                send(reply.text());
                        case ImmutableCommandRouter.Publish publish ->
                                broadcast(publish.text());
                        case ImmutableCommandRouter.Invalid invalid ->
                                send("error: " + invalid.reason());
                        case ImmutableCommandRouter.EndSession end -> {
                            send(end.text());
                            return;
                        }
                    }
                }
            } catch (EOFException clientClosed) {
                // 클라이언트가 /exit 없이 연결을 닫았다
            } catch (IOException networkError) {
                // 읽기나 쓰기 중에 연결이 끊겼다
            } catch (RuntimeException bug) {
                System.err.println(id + " failed: " + bug);
            } finally {
                sessions.remove(this);
                if (username != null) {
                    broadcast(username + " left");
                }
            }
        }

        synchronized void send(String text) throws IOException {
            out.writeUTF(text);
            out.flush();
        }
    }

    void broadcast(String text) {
        List<Session> targets = sessions.stream()
                .filter(session -> session.username() != null)
                .toList();
        for (Session target : targets) {
            try {
                target.send(text);
            } catch (IOException ignored) {
                // 그 세션의 읽기 루프가 곧 실패하고 스스로 정리한다
            }
        }
    }

    void acceptLoop(ServerSocket server) {
        while (!server.isClosed()) {
            try {
                Socket socket = server.accept();
                try {
                    var session = new Session("s-" + nextId.incrementAndGet(), socket);
                    sessions.add(session);
                    Thread.ofVirtual().start(session);
                } catch (IOException | RuntimeException error) {
                    socket.close();
                }
            } catch (IOException error) {
                // 서버 소켓이 닫히면 accept가 예외로 끝나고 반복을 빠져나간다
            }
        }
    }

    static final class TestClient implements AutoCloseable {
        private final String name;
        private final Socket socket;
        private final DataOutputStream out;
        private final BlockingQueue<String> inbox = new LinkedBlockingQueue<>();

        TestClient(String name, int port) throws IOException {
            this.name = name;
            this.socket = new Socket(InetAddress.getLoopbackAddress(), port);
            this.out = new DataOutputStream(socket.getOutputStream());
            var in = new DataInputStream(socket.getInputStream());
            Thread.ofVirtual().start(() -> {
                try {
                    while (true) {
                        inbox.add(in.readUTF());
                    }
                } catch (EOFException serverClosed) {
                    inbox.add("(connection closed by server)");
                } catch (IOException closedByTest) {
                    // 테스트가 끝나 소켓을 닫았다
                }
            });
        }

        void send(String frame) throws IOException {
            System.out.println(name + " -> " + frame);
            out.writeUTF(frame);
            out.flush();
        }

        void expect(int count) throws InterruptedException {
            for (int i = 0; i < count; i++) {
                String line = inbox.poll(5, TimeUnit.SECONDS);
                System.out.println(name + " <- " + (line == null ? "(timeout)" : line));
            }
        }

        @Override
        public void close() throws IOException {
            socket.close();
        }
    }

    public static void main(String[] args) throws Exception {
        var demo = new ChatServerDemo();
        try (var server = new ServerSocket(0, 50, InetAddress.getLoopbackAddress())) {
            Thread.ofVirtual().start(() -> demo.acceptLoop(server));
            int port = server.getLocalPort();
            String host = server.getInetAddress().getHostAddress();
            System.out.println("listening on " + host + ":" + port);

            try (var mina = new TestClient("Mina", port);
                 var jin = new TestClient("Jin", port)) {
                mina.send("/join|Mina");
                mina.expect(1);
                jin.send("/message|hi");
                jin.expect(1);
                jin.send("/join|Jin");
                mina.expect(1);
                jin.expect(1);
                mina.send("/message|hello");
                mina.expect(1);
                jin.expect(1);
                jin.send("/users");
                jin.expect(1);
                jin.send("/oops");
                jin.expect(1);
                jin.send("/exit");
                jin.expect(2);
                mina.expect(1);
            }
        }
    }
}

한 번 실행한 결과는 다음과 같습니다. ->는 클라이언트가 보낸 프레임이고, <-는 클라이언트가 받은 프레임입니다.

listening on 127.0.0.1:43993
Mina -> /join|Mina
Mina <- Mina joined
Jin -> /message|hi
Jin <- error: join first
Jin -> /join|Jin
Mina <- Jin joined
Jin <- Jin joined
Mina -> /message|hello
Mina <- [Mina] hello
Jin <- [Mina] hello
Jin -> /users
Jin <- users=[Mina, Jin]
Jin -> /oops
Jin <- error: unsupported command
Jin -> /exit
Jin <- bye
Jin <- (connection closed by server)
Mina <- Jin left

첫 줄의 포트 번호는 운영체제가 고르므로 실행할 때마다 다릅니다. 나머지 줄은 매번 같은 순서로 나옵니다. 클라이언트가 기다리던 응답을 받은 뒤에야 다음 프레임을 보내고, 출력을 main 스레드 한 곳에서만 하기 때문입니다. 컴퓨터가 몹시 느려 5초 안에 응답이 오지 않으면 그 자리에 (timeout)이 찍힙니다.

프레임마다 라우터가 돌려준 결과와 서버가 한 일을 정리하면 다음과 같습니다.

보낸 프레임라우터 결과서버가 한 일받은 클라이언트
Mina의 /join|MinaPublish입장한 세션 모두에게 전송Mina
Jin의 /message|hiInvalid보낸 세션에만 error:를 붙여 응답Jin
Jin의 /join|JinPublish입장한 세션 모두에게 전송Mina, Jin
Mina의 /message|helloPublish입장한 세션 모두에게 전송Mina, Jin
Jin의 /usersReply보낸 세션에만 응답Jin
Jin의 /oopsInvalid보낸 세션에만 error:를 붙여 응답Jin
Jin의 /exitEndSessionbye를 보내고 반복 종료, 소켓 닫기, 목록에서 제거, 퇴장 알림Jin은 bye, Mina는 Jin left

결과를 실제 동작으로 바꾸는 곳은 run 안의 switch (result) 하나입니다. 네 가지 결과가 '나에게 보내기', '모두에게 보내기', '오류로 답하기', '답하고 끝내기'라는 네 가지 전송 동작에 대응합니다. Result가 sealed이므로 결과 종류를 새로 추가하면 컴파일러가 바로 이 switch를 가리키며 처리를 요구합니다. 또 Jin은 Mina가 입장하기 전에 이미 연결되어 있었지만 Mina joined를 받지 않았습니다. broadcast가 이름이 있는 세션, 즉 입장한 세션에만 보내기 때문입니다.

/exit의 흐름을 따라가면 EndSession이 '종료 의도'일 뿐이라는 말의 뜻이 분명해집니다. 서버는 bye를 보낸 뒤 return으로 반복을 빠져나오고, 그러면 try-with-resources가 소켓을 닫고 finally가 세션을 목록에서 지운 다음 남은 사람들에게 Jin left를 알립니다. 서버가 소켓을 닫았으므로 Jin의 읽기 스레드는 readUTF에서 EOFException을 받아 (connection closed by server)를 남겼습니다.

채팅 명령 파싱과 상태 검사에서 정한 예외 처리 순서도 여기에 그대로 들어 있습니다. 클라이언트가 그냥 연결을 끊으면 EOFException, 네트워크 오류는 IOException, 서버 버그는 RuntimeException으로 잡히고, 어느 경우든 finally가 정리합니다. acceptLoop는 연결 하나를 준비하다 실패하면 그 소켓만 닫고 다음 연결을 계속 받으며, 서버 소켓이 닫혔을 때만 반복을 끝냅니다.

여러 스레드가 만나는 지점은 ChatServer와 같은 방법으로 다뤘습니다. 한 세션에는 자기 스레드가 보내는 응답과 다른 세션 스레드가 보내는 브로드캐스트가 동시에 도착할 수 있는데, DataOutputStream의 공식 문서는 이 클래스가 여러 스레드의 동시 사용에 안전하지 않으므로 동기화해서 쓰라고 밝힙니다. 그래서 send를 synchronized로 만들어 세션마다 한 번에 한 스레드만 쓰게 했습니다.

username이 volatile인 것은 /users를 처리하거나 브로드캐스트 대상을 고르는 스레드가 다른 세션의 이름을 읽기 때문입니다. 세션 목록은 SessionDirectory.java까지 함께 컴파일하지 않아도 되도록 ChatServer가 쓰던 SessionDirectory 대신 CopyOnWriteArrayList로 만들었습니다. 이 목록은 순회가 그 순간의 복사본 위에서 이뤄지므로 순회 도중 누가 들어오거나 나가도 안전합니다. 대신 수정할 때마다 배열 전체를 복사하므로, 브로드캐스트는 잦고 입장과 퇴장은 드문 채팅 서버에 알맞습니다.

broadcast가 받을 세션 목록을 먼저 만들어 두고 보내는 데도 이유가 있습니다. 반복문을 돌면서 세션마다 그 자리에서 이름을 확인하면, 브로드캐스트가 끝나기 전에 입장한 세션도 대상에 끼어듭니다. 예를 들어 Mina의 스레드가 Mina joined를 Mina에게 보낸 직후, 반복문을 다 돌기 전에 Jin이 입장하면 Jin은 자기 입장 알림 Jin joined를 받은 뒤에 Mina joined를 받을 수 있습니다. 실제로 broadcast를 그렇게 바꿔 이 프로그램을 여러 번 실행하면 가끔 이 순서가 나옵니다.

받을 세션을 사건이 일어난 순간에 한 번만 정하면 이런 역전은 사라집니다. 다만 두 사람이 동시에 보낸 메시지가 받는 사람마다 다른 순서로 도착하는 일까지 막지는 못하며, 모두에게 같은 순서로 전달하려면 메시지를 한 줄로 세워 차례로 내보내는 장치가 따로 필요합니다.

이 서버는 이 장의 조각을 맞춰 보기 위한 최소한의 구성이라 빠진 것도 있습니다. 브로드캐스트가 세션에 하나씩 차례로 쓰므로 느린 수신자 한 명이 나머지 전송을 늦출 수 있는데, 해결 방법은 채팅 서버의 세션 관리와 브로드캐스트에서 느린 수신자를 다룰 때 살펴봤습니다. 이름 중복 검사(UniqueUsernameDirectory)와 프레임 길이 제한도 연결하지 않았고, 라우터의 느슨한 문법 검사도 그대로입니다.

정리

  • 커맨드 패턴은 명령 이름마다 실행 객체를 만들어 레지스트리에 등록하고, 라우터는 찾아서 실행하기만 합니다. 새 명령을 추가할 때 라우터를 고치지 않아도 됩니다.
  • Map.get이 돌려주는 null을 조회하는 곳마다 검사하는 대신, getOrDefault와 기본 명령 객체로 조회 결과가 항상 실행 가능하게 만듭니다. 기본 명령은 실패를 분명히 드러내는 결과를 돌려줍니다.
  • 명령표는 시작할 때 완성해 Map.copyOf로 수정할 수 없게 만들고 final 필드에 담습니다. 그러면 여러 세션 스레드가 잠금 없이 함께 읽을 수 있습니다.
  • 수정할 수 없는 맵도 그 안의 명령 객체까지 불변으로 만들지는 않습니다. 요청마다 달라지는 값은 명령의 필드가 아니라 매개변수와 지역 변수로 다룹니다.
  • 빌더로 키 형식과 중복을 등록 시점에 검사하면 구성 실수가 서버 시작 때 드러납니다. Map.copyOf 결과의 순회 순서는 정해져 있지 않습니다.
  • 명령은 결과 값만 돌려주고, 전송과 연결 종료와 정리는 세션 루프의 switch와 finally가 맡습니다.

연습 문제

라우터를 고치지 않고 /change 추가하기

ImmutableCommandRouter.standard()에 이름을 바꾸는 /change 명령을 추가하세요. 입장 전이면 Invalid("join first"), 새 이름이 비어 있으면 Invalid("name required")를 돌려주고, 성공하면 이름을 바꾼 뒤 Publish("Mina changed to Nari") 같은 결과를 돌려줍니다. 추가하는 동안 route 메서드를 고쳐야 했는지도 확인하세요.

풀이 보기

standard() 안에서 다른 명령을 등록하는 줄 사이에 다음 코드를 넣습니다.

commands.put("/change", (argument, context) -> {
    if (context.username() == null) {
        return new Invalid("join first");
    }
    if (argument.isBlank()) {
        return new Invalid("name required");
    }
    String old = context.username();
    context.username(argument);
    return new Publish(old + " changed to " + argument);
});

main의 프레임 목록을 /change|Nari, /join|Mina, /change|, /change|Nari, /message|hello로 바꿔 실행하면 다음과 같습니다.

Invalid[reason=join first]
Publish[text=Mina joined]
Invalid[reason=name required]
Publish[text=Mina changed to Nari]
Publish[text=[Nari] hello]

입장 전의 /change는 거절되고, 빈 이름도 거절됩니다. 이름을 바꾼 뒤의 메시지에는 새 이름 [Nari]가 붙습니다. route는 한 줄도 고치지 않았습니다. 명령표에 항목 하나를 더했을 뿐인데 라우터가 새 명령을 찾아 실행하는 것이 커맨드 패턴의 핵심입니다. 실제 서버라면 이 명령에 UniqueUsernameDirectory 같은 이름 디렉터리를 넣어 주고, 이름 중복 검사와 교체를 디렉터리의 rename 한 번으로 처리하게 합니다.

권한을 검사하는 데코레이터 만들기

운영자만 /announce를 실행할 수 있도록, 기존 Command를 고치지 않고 감싸기만 하는 AuthorizedCommand를 구현하세요. 권한이 없으면 대상 명령을 호출하지 않고 거부 결과를 돌려주며, 권한이 있으면 대상 명령의 결과를 그대로 돌려줍니다. 대상 명령이 실제로 몇 번 호출됐는지도 확인하세요.

풀이 보기

데코레이터도 같은 Command 인터페이스를 구현합니다. 권한 판정 함수를 생성자에서 받아 정책을 바꿔 끼울 수 있게 하고, 판정 함수와 대상 명령이 null이 아닌지는 생성할 때 확인합니다. 다만 이 확인은 대상 명령이 null 결과를 돌려주지 않는다는 뜻까지는 아닙니다.

solution/AuthorizedCommandSolution.java
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());
    }
}

실행 결과:

Denied[reason=role=MEMBER]
Accepted[message=[notice] maintenance]
targetCalls=1

MEMBER의 요청은 권한 검사에서 막혀 대상 명령을 아예 호출하지 않았고, ADMIN의 요청만 대상 명령까지 갔습니다. 그래서 대상 호출 횟수는 1입니다. 판정 함수나 대상 명령이 예외를 던지면 데코레이터는 잡지 않고 그대로 전달합니다.

한 가지 더 기억할 점이 있습니다. 이 예제의 role은 이미 주어진 값이지만, 실제 서버에서는 사용자가 보낸 문자열을 그대로 역할로 믿으면 안 됩니다. 역할은 서버가 인증을 거쳐 직접 정한 값이어야 하고, 데코레이터는 그렇게 준비된 Context를 검사하는 일만 합니다.

데코레이터를 감싸는 순서

명령을 실행할 때마다 횟수를 하나씩 세는 계측 데코레이터가 있습니다. 같은 대상 명령에 MEMBER, MEMBER, ADMIN 순서로 세 요청을 보낼 때, 계측 데코레이터를 권한 데코레이터 바깥에 둔 경우와 안쪽에 둔 경우 각각 몇 번을 세나요? 두 경우의 대상 명령 호출 횟수도 구하세요. 판정 함수와 대상 명령은 예외를 던지지 않는다고 가정합니다.

해설 보기

계측을 바깥에 두면 세 요청이 모두 계측을 먼저 지나므로 3번을 셉니다. 안쪽에 두면 권한 검사를 통과한 ADMIN 요청만 계측까지 오므로 1번입니다. 대상 명령은 어느 경우든 ADMIN 요청에서만 호출되므로 1번입니다.

어느 쪽이 맞는지는 무엇을 재고 싶은지에 달려 있습니다. 거부된 요청까지 포함해 들어온 요청 전체를 보려면 바깥에, 허용되어 실제로 실행된 기능만 보려면 안쪽에 둡니다. 이 문제는 호출 경로의 횟수를 센 것이고, 실행 시간을 측정한다면 같은 원리로 바깥 계측에는 권한 검사 시간까지 포함됩니다.

다음 글

이 장에서는 연결을 받고, 명령 프레임을 해석하고, 명령을 실행 객체로 나누고, 결과를 모두에게 전달하는 채팅 서버의 조각을 하나씩 만들었습니다. 다음 장의 HTTP/1.1 요청과 응답 메시지의 구조에서는 같은 TCP 연결 위에 HTTP라는 표준 프로토콜을 올립니다. HTTP에도 요청을 해석해 알맞은 처리기로 보내는 라우팅이 있지만, 시작 줄, 헤더, 본문 길이, 응답 상태 코드처럼 메시지 구조가 훨씬 엄격합니다. 그래서 채팅의 단순한 구분자 파서를 그대로 쓸 수 없고, 메시지 구조부터 정확히 읽어야 합니다.