관리 메뉴

개발공부기록

네트워크 - 채팅 프로그램, 채팅 프로그램(설계, 클라이언트, 서버) 본문

자바 로드맵 강의/고급 2 - 입출력, 네트워크, 리플렉션

네트워크 - 채팅 프로그램, 채팅 프로그램(설계, 클라이언트, 서버)

소소한나구리 2025. 3. 1. 17:12
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급2편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


채팅 프로그램 - 설계

설계

요구 사항

서버에 접속한 사용자는 모두 대화할 수 있어야 하며 아래의 채팅 명령어가 있어야 함

입장

  • /join|{name}: 처음 채팅 서버에 접속할 때 사용자의 이름을 입력해야 함

메시지

  • /message|{내용}: 모든 사용자에게 메시지를 전달

이름 변경

  • /change|{name}: 사용자의 이름을 변경

전체 사용자

  • /users: 채팅 서버에 접속한 전체 사용자 목록을 출력

종료

  • /exit: 채팅 서버의 접속을 종료

클라이언트 설계

기존에 작성한 네트워크 프로그램과 기본 뼈대는 비슷하지만 어느 정도의 설계가 필요함

채팅은 실시간으로 대화를 주고받아야 하는데 기존의 네트워크 클라이언트 프로그램은 사용자의 콘솔 입력이 있을 때까지 무한정 대기하는 문제가 있음

 

기존 클라이언트 프로그램의 문제

System.out.print("전송 문자: ");
String toSend = scanner.nextLine(); // 블로킹

// 서버에게 문자 보내기
output.writeUTF(toSend);
log("client -> server: " + toSend);

// 서버로부터 문자 받기
String received = input.readUTF(); // 블로킹
  • 스레드가 사용자의 콘솔 입력을 대기하기 때문에 실시간으로 다른 사용자가 보낸 메시지를 콘솔에 출력할 수 없음
  • 뿐만 아니라 서버로부터 메시지를 받는 코드도 블로킹이 발생함

따라서 사용자의 콘솔 입력과 서버로부터 메시지를 받는 부분을 별도의 스레드로 분리해야함!

  • 서버에서 메시지를 수신하는 것은 ReadHandler 스레드로, 서버에 메시지를 송신하는 것은 WriteHandler 스레드를 사용하여 채팅 프로그램을 설계

 

서버 설계

채팅 프로그램의 핵심은 한 명이 이야기하면 그 이야기를 모두가 볼 수 있어야 하기 때문에 하나의 클라이언트가 보낸 메시지를 서버가 받은 다음에 서버에서 모든 클라이언트에게 메시지를 다시 전송해야 함

 

이렇게 하려면 서버에서 모든 세션을 관리해야 모든 세션에 메시지를 전달할 수 있음

기존에 네트워크 프로그램을 만들 때 세션을 관리하는 세션 매니저를 만들어 두었으므로 기존 구조를 잘 활용하면 채팅 서버를 쉽게 구축할 수 있음

 

채팅 프로그램 서버 구조

  • 클라이언트 1이 서버로 메시지를 보내면 서버는 SessionManager를 통해 연결된 모든 세션에 채팅 메시지를 전달함
  • 각각의 세션은 자신의 클라이언트에게 채팅 메시지를 전송하게 되고 모든 클라이언트가 서버로부터 채팅 메시지를 전송 받음

채팅 프로그램 - 클라이언트

클라이언트

클라이언트는 다음 두 기능을 별도의 스레드에서 실행해야 함

  • 콘솔의 입력을 받아서 서버로 전송함
  • 서버로부터 오는 메시지를 콘솔에 출력함

ReadHandler

package chat.client;

public class ReadHandler implements Runnable {

    private final DataInputStream input;
    private final Client client;
    public boolean closed = false;

    public ReadHandler(DataInputStream input, Client client) {
        this.input = input;
        this.client = client;
    }

    @Override
    public void run() {
        try {
            while (true) {
                String received = input.readUTF();
                System.out.println(received);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            client.close();
        }
    }

    public synchronized void close() {
        if (closed) {
            return;
        }
        // 종료 로직 필요시 작성
        closed = true;
        log("readHandler 종료");
    }
}
  • 서버의 메시지를 반복해서 받고 콘솔에 출력하는 단순한 기능을 제공하는 ReadHandler 클래스
  • Runnable 인터페이스를 구현하고 별도의 스레드에서 실행함
  • 클라이언트 종료 시 ReadHandler도 종료될 수 있도록 close()메서드를 생성하고 중복 종료를 막기 위해 동기화 코드와 closed 플래그를 사용하였음
  • 예제 코드는 단순하기 때문에 중요한 종료 로직이 없지만 실무에서 필요하다면 종료 시 동작해야 할 로직을 입력하면 됨
  • IOException 예외가 발생하면 애플리케이션 레벨에선 해결할 수 있는 방법이 없으므로 client.close()를 통해 클라이언트를 종료하고 전체 자원을 정리함

WriteHandler

package chat.client;

public class WriteHandler implements Runnable {

    private static final String DELIMITER = "|";    // 구분하기 위한 상수

    private final DataOutputStream output;
    private final Client client;

    private boolean closed = false;

    public WriteHandler(DataOutputStream output, Client client) {
        this.output = output;
        this.client = client;
    }

    @Override
    public void run() {
        Scanner scanner = new Scanner(System.in);

        try {
            String username = inputUsername(scanner);
            output.writeUTF("/join" + DELIMITER + username);

            while (true) {
                String toSend = scanner.nextLine();
                if (toSend.isEmpty()) {
                    continue;
                }

                if (toSend.equals("/exit")) {
                    output.writeUTF(toSend);
                    break;
                }

                // "/"로 시작하면 명령어, 나머지는 일반 메시지
                if (toSend.startsWith("/")) {
                    output.writeUTF(toSend);
                } else {
                    output.writeUTF("/message" + DELIMITER + toSend);
                }
            }

        } catch (IOException | NoSuchElementException e) {
            log(e);
        } finally {
            client.close();
        }
    }

    private String inputUsername(Scanner scanner) {
        System.out.println("이름을 입력하세요.");
        String username;
        do {
            username = scanner.nextLine();
        } while (username.isEmpty());
        return username;
    }

    public synchronized void close() {
        if (closed) {
            return;
        }

        try {
            System.in.close();  // Scanner 입력 중지, 사용자의 입력을 닫음
        } catch (IOException e) {
            log(e);
        }

        closed = true;
        log("writeHandler 종료");
    }
}
  • 콘솔 입력을 받아서 서버로 메시지를 전송하며 Runnable로 구현하여 별도의 스레드로 동작함
  • 처음 시작하면 inputUsername() 메서드를 통해 사용자의 이름을 입력 받음
  • 채팅 서버에 처음 접속하면 /join|{name}을 전송하여 이 메시지를 통해 입장했다는 정보와 사용자의 이름을 서버에 전달함
  • 메시지는 다음과 같이 설계 되었으며 사용자가 동작 키워드를 직접 입력하는 것이 아니라 메시지를 입력하면 프로그램이 키워드와 구분자를 만들어서 문장을 완성하고 서버에 전달함
    • 입장: /join|{name}
    • 메시지: /message|{내용}
    • 종료: /exit
  • 콘솔 입력 시 /로 시작하면 /join, /exit와 같이 특정 명령어를 수행한다고 가정하고 /를 입력하지 않으면 일반 메시지로 보고 /message에 내용을 추가해서 서버에 전달함
    • 여기에서는 startsWith("/") 메서드를 활용하여 /로 시작하는지 아닌지를 구분하였음
  • IOException 예외가 발생하면 client.close()를 통해 클라이언트를 종료하고 자원을 정리함

close()를 호출하면 System.in.close()를 통해 사용자의 콘솔 입력을 닫음

이렇게 하면 Scanner를 통한 콘솔 입력인 scanner.nextLine() 코드에서 대기하는 스레드에 NoSuchElementException 예외가 발생하면서 대기 상태에서 빠져나올 수 있음

서버가 연결을 끊은 경우에 클라이언트의 자원이 정리되는데 이때 유용하게 사용됨

  • 윈도우의 경우 바로 사용자 콘솔 입력이 닫히지 않아 사용자가 아무 내용을 한번 입력 해주어야 NoSuchElementException이 발생함

Client

package chat.client;

public class Client {

    private final String host;
    private final int port;

    private Socket socket;
    private DataInputStream input;
    private DataOutputStream output;

    private ReadHandler readHandler;
    private WriteHandler writeHandler;
    private boolean closed = false;

    public Client(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public void start() throws IOException {
        log("클라이언트 시작");
        socket = new Socket(host, port);
        input = new DataInputStream(socket.getInputStream());
        output = new DataOutputStream(socket.getOutputStream());

        readHandler = new ReadHandler(input, this);
        writeHandler = new WriteHandler(output, this);

        Thread readThread = new Thread(readHandler, "readHandler");
        Thread writeThread = new Thread(writeHandler, "writeHandler");

        readThread.start();
        writeThread.start();
    }

    public synchronized void close() {
        if (closed) {
            return;
        }
        writeHandler.close();
        readHandler.close();
        closeAll(socket, input, output);    // 네트워크 프로그램에서 만들었던 자원을 닫는 유틸
        closed = true;
        log("연결 종료: " + socket);

    }
}
  • 클라이언트 전반을 관리하는 클래스로 start()메서드로 클라이언트를 시작함
  • Socket, ReadHandler, WriteHandler, DataInputStream, DataOutputStream 모두 생성하고 관리하며 각 스레드를 생성하여 실행함
  • close() 메서드를 통해 클라이언트에서 생성한 모든 자원을 정리하는 기능도 제공함
    • 이전 강의에서 만들었던 closeAll() 유틸 메서드를 사용하여 소켓과 데이터 인풋, 아웃풋 스트림의 자원을 종료함

ClientMain

package chat.client;

public class ClientMain {
    private static final int PORT = 12345;

    public static void main(String[] args) throws IOException {
        Client client = new Client("localhost", PORT);
        client.start();
    }
}
  • 클라이언트를 시작하는 클래스로 host와 port 정보를 인자로하여 클라이언트를 생성하고 start()메서드를 호출하여 채팅프로그램을 시작함

채팅 프로그램 - 서버

서버1

Session

package chat.server;

public class Session implements Runnable {

    private final Socket socket;
    private final DataInputStream input;
    private final DataOutputStream output;
    private final CommandManager commandManager;
    private final SessionManager sessionManager;

    private boolean closed = false;
    private String username;

    public Session(Socket socket, CommandManager commandManager, SessionManager sessionManager) throws IOException {
        this.socket = socket;
        this.input = new DataInputStream(socket.getInputStream());
        this.output = new DataOutputStream(socket.getOutputStream());
        this.commandManager = commandManager;
        this.sessionManager = sessionManager;
        sessionManager.add(this);
    }

    @Override
    public void run() {
        try {
            while (true) {
                String received = input.readUTF();
                log("client -> server: " + received);

                commandManager.execute(received, this);
            }
        } catch (IOException e) {
            log(e);
        } finally {
            sessionManager.remove(this);
            sessionManager.sendAll(username + "님이 퇴장했습니다.");
            close();
        }

    }

    public void send(String message) throws IOException {
        log("server -> client: " + message);
        output.writeUTF(message);
    }

    public synchronized void close() {
        if (closed) {
            return;
        }

        closeAll(socket, input, output);
        closed = true;
        log("연결 종료: " + socket);
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }
}
  • CommandManager는 명령어를 처리하는 기능을 제공함
  • Session의 생성 시점에 sessionManager에 Session을 등록함
  • username을 통해 클라이언트의 이름을 등록할 수 있음, 사용자의 이름을 사용하는 기능은 뒤에서 추가할 예정이므로 현재 상태에서는 null로 동작함

run()

  • 클라이언트로부터 메시지를 전송받은 메시지를 commandManager.execute()를 사용해서 실행함
  • 예외가 발생하면 세션 매니저에서 세션을 제거하고 나머지 클라이언트에게 퇴장 소식을 알리고 close() 메서드를 통해 자원을 정리함

send(String message)

  • 해당 세션의 클라이언트에게 메시지를 보냄

SessionManager

package chat.server;

public class SessionManager {

    private List<Session> sessions = new ArrayList<>();

    public synchronized void add(Session session) {
        sessions.add(session);
    }

    public synchronized void remove(Session session) {
        sessions.remove(session);
    }

    public synchronized void closeAll() {
        for (Session session : sessions) {
            session.close();
        }
        sessions.clear();
    }

    public synchronized void sendAll(String message) {
        for (Session session : sessions) {
            try {
                session.send(message);
            } catch (IOException e) {
                log(e);
            }
        }
    }

    public synchronized List<String> getAllUsername() {
        List<String> usernames = new ArrayList<>();
        for (Session session : sessions) {
            if (session.getUsername() != null) {
                usernames.add(session.getUsername());
            }
        }
        return usernames;
    }
}
  • 세션을 관리하는 클래스
  • closeAll(): 모든 세션을 종료하고 세션 관리자에서 제거함
  • sendAll(): 각 세션의 send() 메서드를 호출하여 모든 세션에 메시지를 전달함
  • getAllUsername(): 모든 세션에 등록된 사용자의 이름을 반환함, 모든 사용자의 목록을 출력할 때 사용됨

CommandManager

package chat.server;

public interface CommandManager {
    void execute(String totalMessage, Session session) throws IOException;
}
  • 클라이언트에게 전달받은 메시지를 처리하는 인터페이스로 향후 구현체를 점진적으로 변경하기 위해 인터페이스를 사용함
  • 클라이언트에게 전달받은 메시지인 totalMessage와 session을 인자로 받는 execute 추상 메서드를 가지고 있음

CommandManagerV1

package chat.server;

public class CommandManagerV1 implements CommandManager {

    private final SessionManager sessionManager;

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

    @Override
    public void execute(String totalMessage, Session session) throws IOException {
        if (totalMessage.startsWith("/exit")) {
            throw new IOException("exit");
        }
        sessionManager.sendAll(totalMessage);
    }
}
  • 클라이언트에게 일반적인 메시지를 전달 받으면 모든 클라이언트에게 같은 메시지를 처리하기 위해 sessionManager.sendAll(totalMessage) 메서드를 호출함
  • /exit가 호출되면 IOException을 던지고 세션이 해당 예외를 잡아서 세션을 종료함
  • CommandManagerV1은 최소한의 메시지 전달 기능만 구현했고 복잡한 나머지 기능들은 버전을 증가시키면서 추가할 예정

Server

package chat.server;

public class Server {
    private final int port;
    private final CommandManager commandManager;
    private final SessionManager sessionManager;

    private ServerSocket serverSocket;

    public Server(int port, CommandManager commandManager, SessionManager sessionManager) {
        this.port = port;
        this.commandManager = commandManager;
        this.sessionManager = sessionManager;
    }

    public void start() throws IOException {
        log("서버 시작: " + commandManager.getClass());
        serverSocket = new ServerSocket(port);
        log("서버 소켓 시작 - 리스닝 포트: " + port);

        addShutdownHook();
        running();
    }

    private void addShutdownHook() {
        ShutdownHook target = new ShutdownHook(serverSocket, sessionManager);
        Runtime.getRuntime().addShutdownHook(new Thread(target, "shutdown"));
    }

    private void running() {
        try {
            while (true) {
                Socket socket = serverSocket.accept();  // 블로킹
                log("소켓 연결: " + socket);

                Session session = new Session(socket, commandManager, sessionManager);
                Thread thread = new Thread(session);
                thread.start();
            }
        } catch (IOException e) {
            log("서버 소켓 종료: " + e);
        }
    }

    static class ShutdownHook implements Runnable {

        private final ServerSocket serverSocket;
        private final SessionManager sessionManager;

        public ShutdownHook(ServerSocket serverSocket, SessionManager sessionManager) {
            this.serverSocket = serverSocket;
            this.sessionManager = sessionManager;
        }

        @Override
        public void run() {
            log("shutdownHook 실행");

            try {
                sessionManager.closeAll();
                serverSocket.close();

                Thread.sleep(1000);
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println("e = " + e);
            }
        }
    }
}
  • 앞서 구현해 보았던 네트워크 프로그램 V6버전과 코드구조가 거의 동일함
  • addShutdownHook(): 내부 클래스로 정의한 셧다운 훅을 등록하는 메서드
  • running(): 클라이언트의 연결을 처리하고 세션을 생성하는 동작 메서드
  • 각 코드에 주석을 달아서 구분해도 되지만 기능을 메서드로 추출하여 메서드이름으로 구분지으면 코드의 가독성과 유지보수성이 더욱 좋아짐

ServerMain

package chat.server;

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);
        Server server = new Server(PORT, commandManager, sessionManager);
        server.start();
    }
}
  • Server는 생성자로 SessionManager와 CommandManager가 필요함
  • 여기서 CommandManager의 구현체를 점진적으로 변경하여 적용할 예정임
  • 실행해보면 아직 기능이 부족하고, 사용자 이름을 적용하지 않아 null로 출력되지만 가장 중요한 핵심 기능인 채팅기능이 정상적으로 동작되어 각 클라이언트에게 메시지가 전송되는 것을 확인할 수 있음

프로그램 실행 결과 - 서버

더보기
/*
12:17:54.645 [     main] 서버 시작: class chat.server.CommandManagerV1
12:17:54.650 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
12:17:56.388 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=53901,localport=12345]
12:19:00.624 [ Thread-0] client -> server: /join|han
12:19:00.625 [ Thread-0] server -> client: /join|han
12:19:03.532 [ Thread-0] client -> server: /message|hihi
12:19:03.533 [ Thread-0] server -> client: /message|hihi
12:19:12.381 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=53913,localport=12345]
12:19:22.022 [ Thread-1] client -> server: /join|jeon
12:19:22.022 [ Thread-1] server -> client: /join|jeon
12:19:22.022 [ Thread-1] server -> client: /join|jeon
12:19:27.810 [ Thread-1] client -> server: /message|안녕하세요
12:19:27.811 [ Thread-1] server -> client: /message|안녕하세요
12:19:27.811 [ Thread-1] server -> client: /message|안녕하세요
12:19:38.175 [ Thread-0] client -> server: /message|네 안녕하세요
12:19:38.175 [ Thread-0] server -> client: /message|네 안녕하세요
12:19:38.176 [ Thread-0] server -> client: /message|네 안녕하세요
12:20:14.041 [ Thread-0] client -> server: /exit
12:20:14.041 [ Thread-0] java.io.IOException: exit
12:20:14.042 [ Thread-0] server -> client: null님이 퇴장했습니다.
12:20:14.046 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=53901,localport=12345]
12:20:17.871 [ Thread-1] client -> server: /exit
12:20:17.872 [ Thread-1] java.io.IOException: exit
12:20:17.872 [ Thread-1] 연결 종료: Socket[addr=/127.0.0.1,port=53913,localport=12345]
12:22:56.484 [ shutdown] shutdownHook 실행
12:22:56.486 [     main] 서버 소켓 종료: java.net.SocketException: Socket closed
*/
  • CommandManagerV1이 동작하는 로그를 확인할 수 있으며 클라이언트에서 동작하는 모든 정보가 출력되고 있음
  • 서버에서 모든 클라이언트에게 메시지를 모두 전송하고 있음
  • 각 클라이언트가 종료되면 각 소켓의 연결이 종료되었다는 로그를 확인할 수 있으며 서버가 종료되면 shutdownHook이 실행되어 자원을 닫고 서버를 종료하는 로그도 확인할 수 있음

프로그램 실행 결과 - 클라이언트1

더보기
/*
12:17:56.382 [     main] 클라이언트 시작
이름을 입력하세요.
han
/join|han
hihi
/message|hihi
/join|jeon
/message|안녕하세요
네 안녕하세요
/message|네 안녕하세요
/exit
12:20:14.041 [writeHandler] writeHandler 종료
12:20:14.041 [writeHandler] readHandler 종료
12:20:14.047 [writeHandler] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=53901]
12:20:14.048 [readHandler] java.net.SocketException: Socket closed
*/

 

  • 해당 클라이언트에서 보낸 메시지를 다른 클라이언트에서 확인할 수 있고 다른 클라이언트에서 보낸 메시지를 해당 클라이언트에서 확인할 수 있음

프로그램 실행 결과 - 클라이언트2

더보기
/*
12:19:12.376 [     main] 클라이언트 시작
이름을 입력하세요.
jeon
/join|jeon
안녕하세요
/message|안녕하세요
/message|네 안녕하세요
null님이 퇴장했습니다.
/exit
12:20:17.871 [writeHandler] writeHandler 종료
12:20:17.872 [writeHandler] readHandler 종료
12:20:17.872 [readHandler] java.io.EOFException
12:20:17.876 [writeHandler] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=53913]
*/
  • 클라이언트가 종료하면 퇴장했다는 알림이 전송되는 것을 확인할 수 있으며 지금은 이름을 등록하고 있지 않아 null로 표시되고 있음
  • 종료시  정상적으로 모든 자원이 종료되는 것을 확인할 수 있음

서버2

CommandManagerV2

채팅 프로그램의 모든 기능을 구현하기 위해서 CommandManager를 V2 버전으로 작성

package chat.server;

public class CommandManagerV2 implements CommandManager {

    // |를 구분자로 split 하려면 \\|로 해야 문자열로 사용됨
    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|{name}

  • 메시지를 |(파이프)를 구분자로 기준을 나누어 이름을 나누고 session.setUsername(username)을 사용하여 세션에 이름을 등록함
  • 그 후 sessionManager.sendAll() 메서드를 활용하여 모든 클라이언트에게 입장을 알림

메시지 - /message|{내용}

  • 모든 클라이언트에게 메시지를 전달함
  • 메시지를 누가 전달했는지 설명하기 위해 '[사용자이름] 메시지'의 형식으로 전달함

이름 변경 - /change|{name}

  • 사용자의 이름을 변경하고 채팅 서버의 모든 사용자에게 사용자가 변경된 것을 알려줌

전체 사용자 - /users

  • 채팅 서버에 접속한 전체 사용자 목록을 출력함
  • 문자열을 수정할 때는 불변 객체인 String을 수정하기보다 StringBuilder()로 변환하여 수정한 뒤 반환하는 것이 성능상 좋음
  • 싱글 스레드에서 동작하므로 StringBuilder()를 사용하였으며 멀티스레드 환경에서 공유 자원으로 사용된다면 동기화가 되어있는 StringBuffer()를 사용하면 됨

종료 - /exit

  • 기존에도 있던 기능인 채팅 서버의 접속을 종료하는 기능

** 주의

  • 이름을 변경하는 /change 기능을 사용할 때 별도의 예외처리를 하지 않았으므로 꼭 지정한 |(파이프)를 사용해야 함
  • 현재 구현한 기능에서는 다른 구분자나 공백을 사용하면 파싱에서 예외가 발생하면서 연결이 종료되므로 이를 방지하고 싶다면 추가적인 예외 코드를 작성하면 됨

ServerMain

  • 새롭게 개발한 CommandManagerV2를 사용하도록 수정하고 실행해보면 모든 기능들이 정상적으로 동작하는 걸 확인할 수 있음 

실행 결과 - 클라이언트

더보기
/* 클라이언트 1 실행 결과
15:47:35.646 [     main] 클라이언트 시작
이름을 입력하세요.
사람123
사람123님이 입장했습니다.
동물123님이 입장했습니다.
[동물123] 안녕하세요!
헐; 동물이 말을하네;;
[사람123] 헐; 동물이 말을하네;;
동물123님이 사람333로 이름을 변경하였습니다.
[사람333] 이제 사람입니다 ^^
동물이 사람흉내를... 
[사람123] 동물이 사람흉내를... 
/users
전체 접속자 : 2
 - 사람333
 - 사람123

도망가야겟다 
[사람123] 도망가야겟다 
/exit
15:48:32.601 [writeHandler] writeHandler 종료
15:48:32.602 [writeHandler] readHandler 종료
15:48:32.608 [writeHandler] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=54596]
15:48:32.609 [readHandler] java.net.SocketException: Socket closed
*/

/* 클라이언트 2 실행 결과
15:47:34.302 [     main] 클라이언트 시작
이름을 입력하세요.
사람123님이 입장했습니다.
동물123
동물123님이 입장했습니다.
안녕하세요!
[동물123] 안녕하세요!
[사람123] 헐; 동물이 말을하네;;
/change|사람333
동물123님이 사람333로 이름을 변경하였습니다.
이제 사람입니다 ^^
[사람333] 이제 사람입니다 ^^
[사람123] 동물이 사람흉내를... 
[사람123] 도망가야겟다 
사람123님이 퇴장했습니다.
....
[사람333] ....
/exit
15:48:38.510 [writeHandler] writeHandler 종료
15:48:38.510 [writeHandler] readHandler 종료
15:48:38.511 [readHandler] java.io.EOFException
15:48:38.515 [writeHandler] 연결 종료: Socket[addr=localhost/127.0.0.1,port=12345,localport=54589]
*/

 

실행결과 - 서버

더보기
/*
15:47:32.275 [     main] 서버 시작: class chat.server.CommandManagerV2
15:47:32.280 [     main] 서버 소켓 시작 - 리스닝 포트: 12345
15:47:34.308 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=54589,localport=12345]
15:47:35.652 [     main] 소켓 연결: Socket[addr=/127.0.0.1,port=54596,localport=12345]
15:47:49.685 [ Thread-1] client -> server: /join|사람123
15:47:49.686 [ Thread-1] server -> client: 사람123님이 입장했습니다.
15:47:49.686 [ Thread-1] server -> client: 사람123님이 입장했습니다.
15:47:53.232 [ Thread-0] client -> server: /join|동물123
15:47:53.232 [ Thread-0] server -> client: 동물123님이 입장했습니다.
15:47:53.232 [ Thread-0] server -> client: 동물123님이 입장했습니다.
15:47:55.163 [ Thread-0] client -> server: /message|안녕하세요!
15:47:55.164 [ Thread-0] server -> client: [동물123] 안녕하세요!
15:47:55.164 [ Thread-0] server -> client: [동물123] 안녕하세요!
15:48:02.607 [ Thread-1] client -> server: /message|헐; 동물이 말을하네;;
15:48:02.607 [ Thread-1] server -> client: [사람123] 헐; 동물이 말을하네;;
15:48:02.607 [ Thread-1] server -> client: [사람123] 헐; 동물이 말을하네;;
15:48:12.546 [ Thread-0] client -> server: /change|사람333
15:48:12.546 [ Thread-0] server -> client: 동물123님이 사람333로 이름을 변경하였습니다.
15:48:12.547 [ Thread-0] server -> client: 동물123님이 사람333로 이름을 변경하였습니다.
15:48:16.126 [ Thread-0] client -> server: /message|이제 사람입니다 ^^
15:48:16.126 [ Thread-0] server -> client: [사람333] 이제 사람입니다 ^^
15:48:16.126 [ Thread-0] server -> client: [사람333] 이제 사람입니다 ^^
15:48:26.946 [ Thread-1] client -> server: /message|동물이 사람흉내를... 
15:48:26.946 [ Thread-1] server -> client: [사람123] 동물이 사람흉내를... 
15:48:26.947 [ Thread-1] server -> client: [사람123] 동물이 사람흉내를... 
15:48:28.294 [ Thread-1] client -> server: /users
15:48:28.295 [ Thread-1] server -> client: 전체 접속자 : 2
 - 사람333
 - 사람123

15:48:30.670 [ Thread-1] client -> server: /message|도망가야겟다 
15:48:30.671 [ Thread-1] server -> client: [사람123] 도망가야겟다 
15:48:30.671 [ Thread-1] server -> client: [사람123] 도망가야겟다 
15:48:32.601 [ Thread-1] client -> server: /exit
15:48:32.602 [ Thread-1] java.io.IOException: exit
15:48:32.602 [ Thread-1] server -> client: 사람123님이 퇴장했습니다.
15:48:32.607 [ Thread-1] 연결 종료: Socket[addr=/127.0.0.1,port=54596,localport=12345]
15:48:36.706 [ Thread-0] client -> server: /message|....
15:48:36.706 [ Thread-0] server -> client: [사람333] ....
15:48:38.510 [ Thread-0] client -> server: /exit
15:48:38.510 [ Thread-0] java.io.IOException: exit
15:48:38.511 [ Thread-0] 연결 종료: Socket[addr=/127.0.0.1,port=54589,localport=12345]
15:50:23.028 [ shutdown] shutdownHook 실행
15:50:23.029 [     main] 서버 소켓 종료: java.net.SocketException: Socket closed
*/

 

 

문제

  • 채팅 프로그램에서 앞으로 새로운 기능이 계속 추가 될 수 있는데 CommandManagerV2의 코드를 보면 각 기능을 if-else 문으로 구분하여 가독성이 좋지 않음
  • 그리고 기능을 정상적으로 동작하기 위해 메시지를 파싱하는 코드도 중복으로 여러 곳에서 사용되고 있음
  • 이렇게 개발이 되면 기능이 많아질 경우 가독성 뿐만 아니라 유지보수하기에도 어려워짐
  • Command 패턴을 활용하면 이런 문제를 해결하면서 새로운 기능이 추가되어도 기존 코드에 영향을 최소화 할 수 있음

서버3

Command

package chat.command;

public interface Command {
    void execute(String[] args, Session session) throws IOException;
}
  • 각각의 명령어를 하나의 Command(명령)로 보고 인터페이스와 구현체로 분리하기 위해 Command 인터페이스를 생성
  • 각 명령어 하나를 처리하는 목적으로 만들었으며, 이제 프로그램의 기능들을 Command를 상속받아서 구현하면 됨

JoinCommand

package chat.command;

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

package chat.command;

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

package chat.command;

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

package chat.command;

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

package chat.command;

public class ExitCommand implements Command {

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

CommandManagerV3

package chat.server;

public class CommandManagerV3 implements CommandManager {

    // |를 구분자로 split 하려면 \\|로 해야 문자열로 사용됨
    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);
    }
}

Map<String, Command> commands

  • 명령어를 Map에 보관하여 명령어 자체를 Key를 사용하고 각 Key에 해당하는 Command 구현체를 저장해둠

execute()

  • 구분자를 활용하여 분리한 key값을 활용하여 commands.get(key)로 key에 해당하는 명령을 꺼내서 명령을 실행함
  • 만약 /join이라는 메시지가 들어왔다면 JoinCommand의 인스턴스가 반환되고 다형성을 활용하여 구현체의 execute()메서드를 호출함
  • 만약 찾을 수 없다면 처리할 수 없는 명령어이므로 처리할 수 없다는 메시지를 전달함

Servermain

  • 새로 만든 CommandManagerV3를 실행해보면 기능은 V2버전과 동일하게 정상적으로 실행됨
  • 그러나 V2의 코드와 비교해보면 설계가 완전히 달라졌는데, 각각의 명령을 인터페이스를 구현하는 별도의 구현체들로 구현하고 다형성을 활용하여 명령들을 사용하는 코드클래스로 분리하여 클래스별 기능이 명확해지고 가독성도 매우 좋아졌음
  • 새로운 기능을 추가해도 Command 인터페이스만 구현한다면 매우 간단한 수정만으로 기능을 추가할 수 있음
  • 이러한 방식을 디자인 패턴 중 커맨드 패턴이라고 하며 설명은 아래에서 설명함

** 참고 - 동시성과 읽기

  • 여러 스레드가 commands = new HashMap<>()을 동시에 접근해서 조회하지만 commands는 데이터 초기화 이후에는 데이터를 전혀 변경하지 않기 때문에 여러 스레드가 동시에 값을 조회해도 문제가 발생하지 않음
  • 만약 commands의 데이터를 중간에 변경할 수 있게 하려면 동시성 문제를 고민해야 함

서버4

이전의 CommandManagerV3의 버전에서는 null을 체크하고 처리해야 하는 부분이 조금 지저분하게 보임

만약 명령어가 항상 존재한다면 명령어를 찾고 바로 실행하는 깔끔한 코드를 작성할 수 있을 것임

 

이 문제를 해결하는 방법은 의외로 간단한데 null인 상황을 처리할 객체를 만들면 됨

 

DefaultCommand

package chat.command;

public class DefaultCommand implements Command {
    @Override
    public void execute(String[] args, Session session) throws IOException {
        session.send("처리할 수 없는 명령어 입니다: " + Arrays.toString(args));
    }
}
  • command가 null일때 동작하는 코드를 DefaultCommand로 별도의 명령으로 구현하였음

CommandManagerV4

package chat.server;

public class CommandManagerV4 implements CommandManager {

    private static final String DELIMITER = "\\|";
    private final Map<String, Command> commands = new HashMap<>();
    private final DefaultCommand 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];

        // NullObject Pattern
        Command command = commands.getOrDefault(key, defaultCommand);
        command.execute(args, session);
    }
}
  • Map에는 getOrDefault(key, defaultObject)라는 메서드가 존재하는데, 만약 key를 사용해서 객체를 찾을 수 있다면 찾고 찾을 수 없다면 옆에 있는 defaultObject를 반환함
  • 이 기능을 사용하면 null을 받지 않고 항상 Command 객체를 받아서 처리할 수 있으며, 예제에서는 key를 찾을 수 없다면 방금 만들어둔 DefaultCommand를 사용함

ServerMain

  • CommandManagerV4를 사용하도록 실행해보면 기존과 동일하게 코드가 정상적으로 실행됨

Null Object Pattern

  • Null을 객체(Object)처럼 처리하는 방법을 Null Object Pattern이라고 함
  • 이 디자인 패턴은 null 대신 사용할 수 있는 특별한 객체를 만들어 null로 인해 발생할 수 있는 예외 상황을 방지하고 코드의 간결성을 높이는데 목적이 있음
  • Null Object Pattern은 null 값 대신 특정 동작을 하는 객체를 반환하게 되어 클라이언트 코드에서 null 체크를 할 필요가 없어지므로 코드에서 불필요한 조건문을 줄이고 객체의 기본 동작을 정의하는데 유용함

Command Pattern

  • 지금까지 작성한 Command 인터페이스와 그 구현체들이 바로 커맨드 패턴을 사용한 것임
  • 디자인 패턴 중 하나인 커맨드 패턴은 요청을 독립적인 객체로 변환해서 처리함
  • 특징
    • 분리: 작업을 호출하는 객체와 작업을 수행하는 객체를 분리함
    • 확장성: 기존 코드를 변경하지 않고 새로운 명령을 추가할 수 있음
  • 장점
    • 새로운 커맨드를 추가하고 싶다면 새로운 Command의 구현체만 만들면 되고 기존 코드를 대부분 변경할 필요가 없어 새로운 커맨드를 쉽게 추가할 수 있음
    • 작업을 호출하는 객체와 작업을 수행하는 객체가 분리되어 있어 if문 없이도 각 작업들을 호출할 수 있어 가독성이 좋아지고 각 명령을 등록만 해주면 명령을 호출하는 부분을 한개로 모아서 처리할 수 있음(모든 명령의 실행 메서드가 동일함)
    • 각각의 기능이 명확하게 분리가 되어 개발자가 기능을 수정해야할 때 수정해야하는 클래스가 아주 명확해짐
  • 단점
    • 복잡성 증가: 간단한 작업을 수행하는 경우에도 Command 인터페이스와 구현체들, Command 객체를 호출하고 관리하는 클래스 등 여러 클래스를 생성해야 하기 때문에 코드의 복잡성이 증가함
    • 모든 설계에는 트레이드 오프가 있기 때문에 단순한 if문 몇 개로 해결할 수 있는 문제에 복잡한 커맨드 패턴을 도입하는 것은 좋은 설계가 아님
    • 기능이 어느정도 있고 각각의 기능이 명확하게 나누어지며 향후 기능의 확장까지 고려해야 한다면 커맨드 패턴이 좋은 대안이 될 수 있음
728x90