Java 채팅 프로그램 서버 구현 — 커맨드 패턴 리팩토링

2편에서 채팅 서버의 기능을 완성했다. 그런데 CommandManagerV2execute()메서드를 다시 보면 마음이 불편하다. if-else가 5개 들어서 있고, 새 명령어가 생길 때마다 이 덩어리 안에 코드를 끼워 넣어야 한다. 이번에는 커맨드 패턴(Command Pattern)을 도입해 이 문제를 깔끔하게 해결한다

문제 정의

// CommandManagerV2 - 새 기능이 추가될수록 이 메서드가 계속 커진다
@Override
public void execute(String totalMessage, Session session) throws IOException {
    if (totalMessage.startsWith("/join")) {
        // 파싱 + 로직
    } else if (totalMessage.startsWith("/message")) {
        // 파싱 + 로직
    } else if (totalMessage.startsWith("/change")) {
        // 파싱 + 로직
    } else if (totalMessage.startsWith("/users")) {
        // 로직
    } else if (totalMessage.startsWith("/exit")) {
        // 로직
    } else {
        // 예외 처리
    }
}

문제는 두 가지다. 첫째, 서로 다른 5가지 책임이 하나의 메서드에 뭉쳐 있다. 둘째, 기능을 추가하거나 수정하려면 이 메서드를 직접 열어야 한다. OCP를 정면으로 위반하는 구조이다

해법: 명령어 하나하나를 독립적인 클래스로 분리한다

Command 인터페이스

public interface Command {
    void execute(String[] args, Session session) throws IOException;
}

CommandManagerexecute(String totalMessage, Session session)와 비교해 인터페이스가 달라졌다. totalMessage 대신 이미 파싱된 String[] args를 받는다. 파싱 작업을 각 구현체에서 반복하지 않고, 호출하는 쪽(CommandManagerV3)에서 한 번만 처리하기 위해서다

명령어 구현체 5개

각 명령어가 하나의 클래스가 된다. SessionManager를 필요로 하는 클래스는 생성자 주입으로 받는다

JoinCommand – 입장

public class JoinCommand implements Command {
    private final SessionManager sessionManager;

    public JoinCommand(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String[] args, Session session) {
        String username = args[1];
        session.setUsername(username);
        sessionManager.sendAll(username + "님이 입장했습니다.");
    }
}

MessageCommand – 메시지 전파

public class MessageCommand implements Command {
    private final SessionManager sessionManager;

    public MessageCommand(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String[] args, Session session) {
        String message = args[1];
        sessionManager.sendAll("[" + session.getUsername() + "] " + message);
    }
}

ChangeCommand – 이름 변경

public class ChangeCommand implements Command {
    private final SessionManager sessionManager;

    public ChangeCommand(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String[] args, Session session) {
        String changeName = args[1];
        sessionManager.sendAll(session.getUsername() + "님이 " + changeName + "로 이름을 변경했습니다.");
        session.setUsername(changeName);
    }
}

UsersCommand – 전체 사용자 목록

public class UsersCommand implements Command {
    private final SessionManager sessionManager;

    public UsersCommand(SessionManager sessionManager) {
        this.sessionManager = sessionManager;
    }

    @Override
    public void execute(String[] args, Session session) throws IOException {
        List<String> usernames = sessionManager.getAllUsername();
        StringBuilder sb = new StringBuilder();
        sb.append("전체 접속자: ").append(usernames.size()).append("\n");
        for (String username : usernames) {
            sb.append(" - ").append(username).append("\n");
        }
        session.send(sb.toString());
    }
}

ExitCommand – 종료

public class ExitCommand implements Command {
    @Override
    public void execute(String[] args, Session session) throws IOException {
        throw new IOException("exit");
    }
}

ExitCommandSessionManager가 필요 없다. 그냥 IOException을 던지면 Session.run()의 finally 블록이 뒷정리를 해준다

final 키워드에 대해: 필드에 final을 붙이면 생성자에서 반드시 초기화해야 한다는 컴파일 타임 강제가 생긴다. sessionManager를 실수로 생성자에서 빠뜨리면 NullPointerException이 런타임에 터지는 게 아니라, 컴파일 자체가 안 된다. 이 차이가 버그를 조기에 잡아준다

CommandManagerV3 – 조립하는 곳

public class CommandManagerV3 implements CommandManager {

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

    public CommandManagerV3(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.get(key);
        if (command == null) {
            session.send("처리할 수 없는 명령어입니다: " + totalMessage);
            return;
        }
        command.execute(args, session);
    }
}

동작 흐름

클라이언트: "/change|seon2"
    → split("\\|") → ["/change", "seon2"]
    → key = "/change"
    → commands.get("/change") → ChangeCommand 인스턴스
    → ChangeCommand.execute(["/change", "seon2"], session)

execute() 내부는 단 4줄이다. 파싱 → 키 추출 → 명령어 조회 → 실행. if-else는 단 하나, null 체크만 남았다

다형성의 핵심
  • commands의 value 타입은 Command 인터페이스다. execute()는 구체적인 구현체가 무엇인지 전혀 모른다. 그냥 command.execute(args, session)을 호출할 뿐이고, 오버라이드된 메서드가 동적으로 선택되어 실행된다

동시성과 HashMap – 왜 문제가 없는가

  • 여러 클라이언트가 동시에 접속하므로 여러 스레드가 execute()를 동시에 호출한다. commandsHashMap인데 스레드 안전하지 않다고 알려져 있다. 그렇다면 문제가 되지 않을까?
  • 결론: 문제없다. 이유는 단순하다
  • commands에 데이터를 쓰는 것은 서버 시작 시 메인 스레드가 생성자에서 딱 한 번만 한다. 이후 여러 스레드가 하는 것은 읽기(get) 뿐이다. 멀티스레드 환경에서 동시성 문제가 발생하는 원인은 쓰기 작업 때문이다. 여러 스레드가 동시에 읽기만 한다면 HashMap은 안전하다
  • 만약 런타임 중에 명령어를 동적으로 추가/제거해야 한다면 그때는 ConcurrentHashMap을 써야 한다. 이 구조에서는 그럴 필요가 없다

커맨드 패턴 정리

  • 커맨드 패턴은 요청을 객체로 캡슐화하는 패턴이다. 호출하는 쪽(CommandManagerV3)과 실행하는 쪽(JoinCommand, MessageCommand 등)을 분리한다
  • 장점은 확장성이다. 새 명령어 추가 시 기존 코드 변경 없이 클래스 하나만 추가하면 된다. OCP를 자연스럽게 따르게 된다. 기능별로 클래스가 분리되어 있으므로 유지보수 범위도 명확해진다
  • 단점도 있다. 명령어가 단 2~3개인 단순한 상황에서 이 패턴을 도입하면 클래스 수만 늘어나고 오히려 복잡해진다. 기능의 수와 확장 가능성을 고려해 선택해야 한다. 모든 설계에는 트레이드 오프가 있다

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