Java 채팅 프로그램 서버 구현 — Null Object 패턴

3편에서 커맨드 패턴을 도입해 명령어마다 독립적인 클래스를 만들었다. 그런데 CommandManagerV3execute()를 보면 아직 한 가지 거슬리는 부분이 남아 있다

Command command = commands.get(key);
if (command == null) {
    session.send("처리할 수 없는 명령어입니다: " + totalMessage);
    return;
}
command.execute(args, session);

명령어를 찾고, null이면 처리하고, 아니면 실행한다. 로직 자체는 맞지만 null 체크가 흐름을 끊는다. 이상적인 코드는 이렇다

Command command = commands.get(key);
command.execute(args, session);

명령어를 찾고, 바로 실행한다. 이 두 줄을 위한 Null Object 패턴을 적용한다

DefaultCommand — null을 객체로

public class DefaultCommand implements Command {
    @Override
    public void execute(String[] args, Session session) throws IOException {
        session.send("처리할 수 없는 명령어입니다: " + Arrays.toString(args));
    }
}

null인 상황, 즉 “등록되지 않은 명령어가 들어왔을 때 어떻게 할 것인가”를 if문으로 처리하지 않고 객체의 책임으로 만든 것이다. 이것이 Null Object 패턴의 핵심이다

CommandManagerV4

public class CommandManagerV4 implements CommandManager {

    private static final String DELIMITER = "\\|";
    private final Map<String, Command> commands = new HashMap<>();
    private final Command defaultCommand = new DefaultCommand();

    public CommandManagerV4(SessionManager sessionManager) {
        commands.put("/join",    new JoinCommand(sessionManager));
        commands.put("/message", new MessageCommand(sessionManager));
        commands.put("/change",  new ChangeCommand(sessionManager));
        commands.put("/users",   new UsersCommand(sessionManager));
        commands.put("/exit",    new ExitCommand());
    }

    @Override
    public void execute(String totalMessage, Session session) throws IOException {
        String[] args = totalMessage.split(DELIMITER);
        String key = args[0];

        Command command = commands.getOrDefault(key, defaultCommand);
        command.execute(args, session);
    }
}

V3와 비교해 execute() 내부에서 바뀐 것은 딱 한 줄이다

// V3
Command command = commands.get(key);
if (command == null) {
    session.send("처리할 수 없는 명령어입니다: " + totalMessage);
    return;
}
command.execute(args, session);

// V4
Command command = commands.getOrDefault(key, defaultCommand);
command.execute(args, session);

Map.getOrDefault(key, defaultValue)는 키에 해당하는 값이 있으면 그것을, 없으면 defaultValue를 반환한다. 이 메서드 덕분에 호출하는 쪽은 항상 유효한 Command 객체를 받게 되고, null을 신경 쓸 필요가 없어진다

동작 흐름

클라이언트: "/hello"  (등록되지 않은 명령어)
    → split("\\|") → ["/hello"]
    → key = "/hello"
    → commands.getOrDefault("/hello", defaultCommand)
    → Map에 없음 → defaultCommand 반환
    → DefaultCommand.execute(["/hello"], session)
    → "처리할 수 없는 명령어입니다: [/hello]" 전송

Null Object 패턴 정리

  • Null Object 패턴은 null 대신 사용할 수 있는 특별한 객체를 만들어 null로 인해 발생할 수 있는 문제를 방지하는 패턴이다
  • 이 패턴이 없다면 null을 반환받은 쪽, 즉 호출하는 코드(클라이언트 코드)가 null 체크를 직접 해야 한다. 이 체크가 코드 곳곳에 퍼지면 유지보수가 어려워진다. Null Object 패턴은 이 책일음 객체 자체에 넘긴다
  • 정리하면 이 패턴은 불필요한 조건문을 줄이고, 예외 케이스를 기본 동작으로 정의해 코드를 더 선언적으로 만든다

출처 – 김영한 님의 강의 중 김영한의 실전 자바 – 고급 2편, I/O, 네트워크, 리플렉션