2편에서 채팅 서버의 기능을 완성했다. 그런데 CommandManagerV2의 execute()메서드를 다시 보면 마음이 불편하다. 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;
}
CommandManager의 execute(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");
}
}
ExitCommand는 SessionManager가 필요 없다. 그냥 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()를 동시에 호출한다.commands는HashMap인데 스레드 안전하지 않다고 알려져 있다. 그렇다면 문제가 되지 않을까? - 결론: 문제없다. 이유는 단순하다
commands에 데이터를 쓰는 것은 서버 시작 시 메인 스레드가 생성자에서 딱 한 번만 한다. 이후 여러 스레드가 하는 것은 읽기(get) 뿐이다. 멀티스레드 환경에서 동시성 문제가 발생하는 원인은 쓰기 작업 때문이다. 여러 스레드가 동시에 읽기만 한다면HashMap은 안전하다- 만약 런타임 중에 명령어를 동적으로 추가/제거해야 한다면 그때는
ConcurrentHashMap을 써야 한다. 이 구조에서는 그럴 필요가 없다
커맨드 패턴 정리
- 커맨드 패턴은 요청을 객체로 캡슐화하는 패턴이다. 호출하는 쪽(
CommandManagerV3)과 실행하는 쪽(JoinCommand,MessageCommand등)을 분리한다 - 장점은 확장성이다. 새 명령어 추가 시 기존 코드 변경 없이 클래스 하나만 추가하면 된다. OCP를 자연스럽게 따르게 된다. 기능별로 클래스가 분리되어 있으므로 유지보수 범위도 명확해진다
- 단점도 있다. 명령어가 단 2~3개인 단순한 상황에서 이 패턴을 도입하면 클래스 수만 늘어나고 오히려 복잡해진다. 기능의 수와 확장 가능성을 고려해 선택해야 한다. 모든 설계에는 트레이드 오프가 있다