관리 메뉴

개발공부기록

네트워크 - 프로그램, 문제 - 직접 채팅 프로그램 만들기1 본문

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

네트워크 - 프로그램, 문제 - 직접 채팅 프로그램 만들기1

소소한나구리 2025. 2. 28. 17:23
728x90

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


** 문제 구현하기

  • 다음 강의에서 진행하는 네트워크 - 채팅 프로그램 만들기에서 동일한 기능을 미리 구현해보는 학습
  • 최대한 직접 구현해보고 문제를 인터넷 검색과 AI 툴을 활용하여 문제를 해결하기
  • 이후 강의를 수강 후 강의 버전으로 다시 구현하면서 글을 작성하기

문제 - 채팅 프로그램 만들기

요구 사항

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

 

입장

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

메시지

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

이름 변경

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

전체 사용자

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

종료

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

1차 구현

ChatSessionManager

  • 강의에서 배운 내용 그대로 여러 클라이언트가 접속하기 때문에 접속 클라이언트와 서버가 통신을 하기 위한 Socket들을 관리하는 세션 매니저가 필요할 것 같아 생성하였음
  • list에 세션을 저장하고 삭제하는 메서드와 list를 조회하는 메서드, 그리고 세션들을 모두 닫고 list를 완전히 삭제하는 메서드를 가지고 있음
public class ChatSessionManager {
    List<ChatSession> sessionList = new ArrayList<>();

    public synchronized void addSession(ChatSession session) {
        sessionList.add(session);
    }

    public synchronized void removeSession(ChatSession session) {
        sessionList.remove(session);
    }

    public synchronized void allCloseSession() {
        for (ChatSession session : sessionList) {
            session.close();
        }
        sessionList.clear();
    }

    public synchronized List<ChatSession> findAllSession() {
        return sessionList;
    }
}

 

ChatClient

  • 채팅 서버에 접속하는 클라이언트 코드
  • 처음에는 채팅 서버에서 사용하는 모든 기능을 if문으로 감싸서 진행했으나 제대로 동작하지 않았고 코드가 너무 지저분해졌음
  • 여러 번 시도 끝에 어차피 명령어로 입력받은 값을 서버로 보내고 서버에서 응답 받는 대로 값을 출력해주면 될 것 같다고 생각이 들었음
  • 그래서 기존에 작성한 if문을 모두 제거하고 명령어를 체크하는 commandCheck() 메서드를 만들어 입력값을 검증하고, 검증이 통과된 명령들은 모두 서버로 전송시켜서 응답받은 값을 출력하도록 변경하였음
  • 덕분에 클라이언트 코드는 매우 깔끔해졌음
public class ChatClient {

    public static void main(String[] args) throws IOException {

        try (Socket socket = new Socket("localhost", 12345);
             DataInputStream input = new DataInputStream(socket.getInputStream());
             DataOutputStream output = new DataOutputStream(socket.getOutputStream())) {

            Scanner scanner = new Scanner(System.in);

            System.out.println("== 채팅방 메뉴 출력 ==");
            System.out.println("/join|사용자명 : 채팅방 접속");
            System.out.println("/message|내용 입력 : 메시지 전송");
            System.out.println("/change|사용자명 : 이름 변경");
            System.out.println("/users : 사용자 목록 출력");
            System.out.println("/exit : 종료");
            while (true) {
                String sendMessage = scanner.nextLine();

                if (commandCheck(sendMessage)) {
                    System.out.println("잘못 입력 하셨습니다. 알맞은 명령어를 입력해 주세요.");
                    continue;
                }

                if (sendMessage.equals("/exit")) {
                    System.out.println("채팅을 종료 합니다.");
                    break;
                }

                output.writeUTF(sendMessage);
                log(input.readUTF());

            }
        } catch (IOException e) {
            log(e);
        }
    }

    private static boolean commandCheck(String sendMessage) {
        return !(sendMessage.contains("/join|") || sendMessage.contains("/message|") || sendMessage.contains("/change|") ||
                sendMessage.contains("/users") || sendMessage.contains("/exit"));
    }

}

 

ChatServer

  • 서버 소켓을 만들고 서버를 정상 종료 시 셧다운 훅을 통해 ServerSocket과 다른 모든 자원을 종료시키기 위해 static 내부 클래스로 ShutdownHook 클래스를 만들었음
  • 강의에서 구현한 그대로 다시 구현해보았고, 셧다운 훅이 발생하면 serverSocket과 sessionManager의 allCloseSession() 메서드를 호출하여 모든 세션의 자원을 종료시킴
  • 이미 동작하고 있는 스레드를 정리할 시간을 주기 위해 1초간 대기한 후 프로그램을 종료함
static class ShutdownHook implements Runnable {

    private ServerSocket serverSocket;
    private ChatSessionManager sessionManager;

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

    @Override
    public void run() {
        log("셧다운 훅 실행");
        try {
            serverSocket.close();
            sessionManager.allCloseSession();
            Thread.sleep(1000);
        } catch (Exception e) {
            log("셧다운 중 에러 발생:" + e);
        }
    }
}

 

  • ServerSocket.accept()로 Socket을 통해서 클라이언트와 소통할 수 있는 ChatSession을 생성하고 멀티스레드로 동작하기 위해 new Thread()로 thread를 생성하여 동작함
  • static 내부 클래스로 만든 셧다운도 생성하여 Runtime.getRuntime().addShutdownHook() 메서드에 스레드를 생성하여 셧다운을 등록시켜 서버가 종료되면 해당 스레드가 동작하여 서버가 종료되고 셧다운 훅이 동작하여 자원을 정리함
public class ChatServer {
    public static void main(String[] args) throws IOException {
        log("서버 시작");
        ChatSessionManager sessionManager = new ChatSessionManager();
        ServerSocket serverSocket = new ServerSocket(12345);
        log("서버 소켓 시작 - 리스닝 포트: " + 12345);

        ShutdownHook shutdownHook = new ShutdownHook(serverSocket, sessionManager);
        Runtime.getRuntime().addShutdownHook(new Thread(shutdownHook, "shutdown"));

        try {
            while (true) {
                Socket socket = serverSocket.accept();
                ChatSession chatSession = new ChatSession(socket, sessionManager);
                Thread thread = new Thread(chatSession);
                thread.start();
            }

        } catch (IOException e) {
            log(e);
        }
    }

 

ChatSession

  • Socket을 통해 실제 클라이언트와 데이터를 주고받는 클래스로 Runnable을 구현하여 멀티스레드로 동작할 수 있도록 작성하였음
  • 생성자를 통해서 Socket과 SessionManager를 주입받고, 본인의 참조를 세션 매니저에 등록하여 세션 매니저의 세션 보관소에서 세션이 관리될 수 있도록 하였음
public class ChatSession implements Runnable {
    private Socket socket;
    private DataInputStream input;
    private DataOutputStream output;
    private ChatSessionManager sessionManager;

    private boolean closed = false;
    private String userName;

    public ChatSession(Socket socket, ChatSessionManager sessionManager) throws IOException {
        this.socket = socket;
        this.input = new DataInputStream(socket.getInputStream());
        this.output = new DataOutputStream(socket.getOutputStream());
        this.sessionManager = sessionManager;
        sessionManager.addSession(this);
    }

 

  • 쓰레드를 실행하면 실제 동작하는 run()메서드를 구현
  • 클라이언트에서 명령을 서버로 전송하면 명령에 따라 동작할 수 있도록 각 코드를 작성하였음
  • 명령이 "/join|사용자명" 과 같이 "|" 구분자로 명령와 메시지(유저)로 나뉘기 때문에 split()메서드로 구분을 하였는데, split()메서드는 인자로 정규식을 받기 때문에 정규식에서 메타 문자로 인지되는 "|"를 그냥 사용할 수 없어서 "\\|"로 처럼 이스케이프로 감싸야 됨
  • /join과 /change는 세션에 사용자명을 저장하여 각 세션과 연결된 클라이언트의 유저 정보를 보관하도록 코드를 작성하였음
  • /users는 세션 매니저의 저장소인 list를 그대로 가져와서 요소만큼 순회하여 각 세션의 userName 필드를 출력하여 서버에 전체 접속한 user 정보를 확인할 수 있음
  • exit로 클라이언트에 요청이 반복문을 탈출하여 세션 매니저에서 해당 세션정보를 삭제하고 자원을 정리하도록 만들어둔 close() 메서드를 호출하여 각 스트림과 socket을 순서대로 자원을 정리하고 서버를 종료시킴
  • 문제발생!
    • /message로 서버에 메시지를 전송할 수 있는 기능이지만 정상적으로 동작하지 않음
    • 현재 이 코드는 클라이언트가 서버에 전송한 메시지를 서버에서 모든 클라이언트로 뿌려줘야하는데 메시지를 보낸 클라이언트에게만 메시지가 반환되고 다른 클라이언트에는 메시지가 보내지지 않는 문제가 있음
    • 세션 매니저에서 세션 정보가 모두 있기 때문에 세션 매니저의 저장소를 가져와서 순회하여 모든 소켓의 DataOutputStream.writeUTF()메서드를 통해 메시지를 전송하도록 코드를 작성하면 될 것이라고 생각하였으나 해결하지 못하였음
@Override
public void run() {
    try {
        while (true) {
            String readMessage = input.readUTF();
            log("클라이언드 -> 서버: " + readMessage);

            if (readMessage.contains("/join|")) {
                String[] splitMessage = readMessage.split("\\|");    // |로 스플릿하려면 \\| 이렇게 해야함(정규식에서 '|'이 메타 문자이기 때문임)
                userName = splitMessage[1];
                String joinSuccess = userName + "유저가 채팅방 서버 접속이 완료 되었습니다.";
                log(joinSuccess + " user 접속 완료");
                output.writeUTF(joinSuccess);
            }

            if (readMessage.contains("/change|")) {
                String[] splitMessage = readMessage.split("\\|");
                userName = splitMessage[0];
                String changeNameSuccess = "사용자 명이 변경 되었습니다. " + userName + " -> " + splitMessage[1];
                output.writeUTF(changeNameSuccess);
            }

            if (readMessage.contains("/message|")) {
                String[] splitMessage = readMessage.split("\\|");
                List<ChatSession> findSession = sessionManager.findAllSession();
                findSession.forEach(session -> {
                    String allUserMessage = userName + "님의 메시지:" + splitMessage[1];
                    try {
                        session.output.writeUTF(allUserMessage);
                    } catch (IOException e) {
                        log(e);
                    }
                });
            }

           if (readMessage.equals("/users")) {
                List<ChatSession> findSession = sessionManager.findAllSession();
                String receiveMessage = "서버 접속 사용자 출력: ";
                for (int i = 0; i < findSession.size(); i++) {

                    if (i == findSession.size() - 1) {
                        receiveMessage += findSession.get(i).userName;
                        break;
                    }
                    receiveMessage += findSession.get(i).userName + ", ";

                }
                output.writeUTF(receiveMessage);
            }
            
            if (readMessage.equals("/exit")) {
                close();
                break;
            }

        }
    } catch (IOException e) {
        log(e);
    } finally {
        sessionManager.removeSession(this);
        close();
    }
}


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

    if (input != null) {
        try {
            input.close();
        } catch (IOException e) {
            log(e);
        }

    }
    if (output != null) {
        try {
            output.close();
        } catch (IOException e) {
            log(e);
        }

    }
    if (socket != null) {
        try {
            socket.close();
        } catch (IOException e) {
            log(e);
        }
    }
    closed = true;
    log("연결 종료: " + socket);
}

문제 해결

문제점 정리

  • 다른 기능은 모두 정상 동작하지만, 메시지 전송기능이 모든 클라이언트에 메시지가 응답하지 않고있음

문제 해결 방안

  • AI 툴을 통해 현재 발생하는 문제는 서버의 코드가 아니라 클라이언트가 싱글 스레드로 동작하고 있기 때문이라는 코멘트를 얻었음
  • 클라이언트에서 서버의 응답을 수신받는 input.readUTF() 코드를 별도의 스레드로 동작하게 만들면 서버에서 응답받는 즉시 글을 출력하기 때문에 모든 클라이언트에서 수신 받을 수 있다고 함
  • 즉, 문제는 서버에서 오는 메시지를 클라이언트가 계속 응답 받아야 하는데 단일 스레드에서 동작하다보니 scanner와 블로킹 메서드들에 걸려서 서버의 메시지를 불러오지 못하는 문제였음

문제 해결 적용

  • 클라이언트 코드에서 아래처럼 별도의 스레드를 만들어서 서버의 데이터를 받아오니 정상적으로 모든 클라이언트에서 정상적으로 메시지가 출력되는 것을 확인하였음
// 서버에서 응답받는 메시지는 별도 스레드에서 동작
Thread thread = new Thread(() -> {
    while (true) {
        try {
            log(input.readUTF());
        } catch (IOException e) {
            log(e);
            break;
        }
    }
});
thread.start();

 

  • 성공적으로 메시지를 실시간으로 받도록 하였으나 기존 세션 코드에서는 메시지를 보낸 클라이언트에게도 서버에의 메시지가 전송되어 내가 보낸 메시지가 다시나에게 전송이 되었음
  • 그래서 다른 클라이언트에게만 메시지가 전송되도록 session의 userName 필드와 현재 클라이언트의 userName 필드를 비교하여 같지 않을 때만 메시지를 응답하도록 수정하였음
if (readMessage.contains("/message|")) {
        String[] splitMessage = readMessage.split("\\|");
        List<ChatSession> findSession = sessionManager.findAllSession();
        findSession.forEach(session -> {

            if (!session.userName.equals(this.userName)) {
                String allUserMessage = userName + "님의 메시지: " + splitMessage[1];
                try {
                    session.output.writeUTF(allUserMessage);
                } catch (IOException e) {
                    log(e);
                }
            }
        });
    }

클라이언트 - 출력 결과

  • 클라이언트를 3개 실행하여 메시지를 전송해보면 메시지를 보낸 클라이언트는 그대로 내가 입력한 메시지만 보이고, 다른 클라이언트에서는 메시지가 정상적으로 출력되고 있는 것을 확인할 수 있음

 

서버 - 출력 결과

  • 서버에서도 모든 메시지가 잘 동작하는 것을 확인할 수 있음

 

직접 채팅 프로그램 만들기 1탄 종료 회고

  • 배운 내용을 토대로 실제로 구현해보았는데 처음에는 너무 막막해서 기존의 코드들을 보면서 어떻게 코드를 구현할지 살펴보니 무슨 역할을 했었는지 기억이 조금씩 떠올랐음
  • 채팅 프로그램 구현에 필요하다고 생각되는 V6 버전의 코드를 보면서 하나씩 작성해 보다 보니 기본적인 골격이 이미 V6에 모두 갖추어져 있었다는 것을 깨달았음
  • 그리고 처음에는 클라이언트에서 기능을 구현하려고 시도했지만 다시 생각해보니 기능 동작은 서버에서 이루어져야하고 현재는 클라이언트가 특별한 동적 기능이 없기 때문에 중복 구현할 필요가 없다고 생각이 들었음
  • 그래서 이후 모든 로직은 클라이언트와 Socket으로 소통하는 세션클래스를 통해 채팅 서버의 기능을 구현하도록 수정하였고 발생한 문제도 검색과 AI툴을 통해서 문제를 생각보다 빠르게 발견하여 처음 구현한 만큼 아직 많이 부족한 채팅 프로그램이지만 요구사항의 기능이 정상 동작하는 채팅 프로그램을 만들었다는 것에 의의를 두고 있음
  • 당연히 처음 작성해본 채팅 프로그램이기 때문에 내가 모르는 문제가 있을 수 있고 여러가지 부족한 예외 처리가 존재하지만 이부분은 이 후 채팅 프로그램 강의와 나머지 고급2편 강의를 모두 수강 후 한번 더 직접 구현해보는 시간을 가지면 더 익숙해질 것이라고 생각이 됨
  • 이후 2탄으로 더 발전된 채팅 프로그램 개발기를 작성하겠음
728x90