1편에서 뼈대를 완성했다. 클라이언트끼리 메시지를 주고 받는 핵심 흐름은 동작하지만, 사용자 이름도 없고 명령어 체계도 없었다. 2편에서는 실제로 쓸 수 있는 채팅 서버를 완성한다. 목표는 단순하다. CommandManager 인터페이스의 구현체만 새로 작성하면 된다. 서버 코드는 손대지 않는다. 인터페이스로 추상화해둔 덕분이다
구현할 명령어 스펙
| 명령어 | 형식 | 동작 |
| 입장 | /join|{name} | 사용자 이름 등록, 전체 입장 알림 |
| 메시지 | /message|{내용} | 전체 사용자에게 메시지 전파 |
| 이름 변경 | /change|{name} | 이름 변경, 전체 변경 알림 |
| 전체 사용자 | /users | 요청자에게만 접속자 목록 전송 |
| 종료 | /exit | 세션 종료 |
CommandManagerV2 구현
public class CommandManagerV2 implements CommandManager {
private static final String DELIMITER = "\\|"; // 정규식 이스케이프 필수
private final SessionManager sessionManager;
public CommandManagerV2(SessionManager sessionManager) {
this.sessionManager = sessionManager;
}
@Override
public void execute(String totalMessage, Session session) throws IOException {
if (totalMessage.startsWith("/join")) {
String[] split = totalMessage.split(DELIMITER);
String username = split[1];
session.setUsername(username);
sessionManager.sendAll(username + "님이 입장했습니다.");
} else if (totalMessage.startsWith("/message")) {
String[] split = totalMessage.split(DELIMITER);
String message = split[1];
sessionManager.sendAll("[" + session.getUsername() + "] " + message);
} else if (totalMessage.startsWith("/change")) {
String[] split = totalMessage.split(DELIMITER);
String changeName = split[1];
sessionManager.sendAll(session.getUsername() + "님이 " + changeName + "로 이름을 변경했습니다.");
session.setUsername(changeName);
} else if (totalMessage.startsWith("/users")) {
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()); // 요청자에게만 전송
} else if (totalMessage.startsWith("/exit")) {
throw new IOException("exit");
} else {
session.send("처리할 수 없는 명령어입니다: " + totalMessage);
}
}
}
각 명령어 설계 포인트
- /join: 세션에 사용자 이름을 등록하고 전체에 알린다. 이 시점부터 해당 세션은
username을 갖게 된다. 이전에 퇴장 메시지가null님이 퇴장했습니다.로 출력되던 문제가 여기서 해결된다 - /message:
[hyeok] hello kim형식으로 발신자 이름을 붙여 전파한다. 이 한 줄이 채팅의 핵심이다 - /change: 이름 변경 알림을 먼저 전파하고 (
session.getUsername()호출 시점이 중요) , 그 다음에 이름을 바꾼다. 순서가 바뀌면 알림에 새 이름이 노출된다 - /users:
sessionManager.sendAll()이 아니라session.send()를 사용한다. 전체 공지가 아니라 요청한 사람에게만 목록을 돌려주는 것이다 - 파이프(
|)이스케이프:String.split()은 정규식을 인자로 받는다.|는 정규식에서 OR 연산자로 해석되기 때문에\\|로 이스케이프해야 한다. 이를 빠뜨리면 모든 문자가 구분자로 처리되어 파싱이 엉망이 된다
ServerMain 수정
public class ServerMain {
private static final int PORT = 12345;
public static void main(String[] args) throws IOException {
SessionManager sessionManager = new SessionManager();
// CommandManager commandManager = new CommandManagerV1(sessionManager);
CommandManager commandManager = new CommandManagerV2(sessionManager); // V2로 교체
Server server = new Server(PORT, commandManager, sessionManager);
server.start();
}
}
딱 한 줄만 바꿨다. Server도 Session도 SessionManager도 건드린 곳이 없다. 인터페이스로 추상화해둔 효과다
실행 결과 확인
[ main] 서버 시작: class chat.server.CommandManagerV2
[ Thread-0] client -> server: /join|hyeok
[ Thread-0] server -> client: hyeok님이 입장했습니다.
[ Thread-1] client -> server: /join|kim
[ Thread-1] server -> client: kim님이 입장했습니다.
[ Thread-1] server -> client: kim님이 입장했습니다. ← hyeok에게도 전파
[ Thread-0] client -> server: /message|hello kim
[ Thread-0] server -> client: [hyeok] hello kim
[ Thread-0] server -> client: [hyeok] hello kim
[ Thread-0] client -> server: /change|hyeok2
[ Thread-0] server -> client: hyeok님이 hyeok2로 이름을 변경했습니다.
[ Thread-0] client -> server: /users
[ Thread-0] server -> client: 전체 접속자: 2
- hyeok2
- nate
[ Thread-0] client -> server: /exit
[ Thread-0] server -> client: hyeok2님이 퇴장했습니다.
[ Thread-0] 연결 종료: Socket[...]
1편에서 보이던 null님이 퇴장했습니다.가 사라졌다. /join 명령어로 이름이 등록되기 때문이다
주의 – /change 사용 시 공백 금지
/change|seon2 ← 정상 /change seon2 ← 파싱 실패 → 연결 종료
현재 구현은 split(DELIMITER)로 파이프 기준으로만 파싱한다. 공백으로 입력하면 split[1]에 접근할 수 없어 ArrayIndexOutOfBoundsException이 발생하고, 이것이 IOException으로 이어져 세션이 종료된다. 실제 서비스라면 입력 유효성 검증을 클라이언트 측에서 처리해야 한다
이제 기능은 완성됐다. 그런데 코드가 마음에 안 든다
기능은 모두 동작한다. 하지만 CommandManagerV2의 execute() 메서드를 다시 보면 if-else 블록이 늘어서 있다. 새 명령어가 추가될 때마다 이 메서드 안에 else if 하나가 더 붙는다
문제는 두 가지다
첫째, 단일 책임 원칙 위반이다. 입장, 메시지, 이름 변경, 사용자 목록, 종료라는 서로 다른 5가지 기능이 하나의 메서드 안에 뭉쳐 있다
둘째, OCP 위반이다. 기능을 추가하려면 기존 코드 (execute() 메서드)를 수정해야 한다