Java 채팅 프로그램 서버 구현 — 뼈대 설계와 핵심 구조

네트워크 소켓 프로그래밍의 기초를 익혔다면, 이제 실전 프로젝트를 통해 그 지식을 단단히 굳혀볼 차례다. Java로 멀티클라이언트를 단계적으로 구현해본다. 먼저 핵심 뼈대 구조를 완성하는 데 집중한다

전체 구조 한눈에 보기

[클라이언트1] ──socket──┐
[클라이언트2] ──socket──┼──▶ [Session1]  ┐
[클라이언트3] ──socket──┘    [Session2]  ├──▶ [SessionManager] ──▶ sendAll()
                          [Session3]  ┘             ▲
                                                    │
                              [CommandManager] ─────┘

클라이언트가 메시지를 보내면 해당 Session이 수신하고, CommandManager에게 처리를 위임한다. CommandManager는 SessionManager를 통해 연결된 모든 클라이언트에게 메시지를 전파한다. 각 역할이 명확하게 분리된 구조다

핵심 클래스 설계

Session – 클라이언트 연결의 최소 단위

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;
        this.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 synchronized void close() {
        if (closed) return;
        closeAll(socket, input, output);
        closed = true;
        log("연결 종료: " + socket);
    }

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

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
}

설계 포인트

  • Runnable을 구현해 독립 스레드에서 실행된다. 클라이언트 한 명당 스레드 하나가 할당된다
  • 생성자에서 sessionManager.add(this)를 호출해, 세션이 생성되는 즉시 관리 대상에 포함된다
  • close()closed플래그를 두어 중복 호출을 방지한다. 소켓 자원은 정확히 한 번만 해제되어야 한다
  • 메시지 수신(input.readUTF())과 처리 (commandManager.execute())를 분리했다. Session은 받는 것에만 집중하고, 어떻게 처리할 것인가는 CommandManager에 위임한다
  • finally 블록에서 퇴장 알림과 자원 정리를 보장한다. 예외가 발생하든 정상 종료든 반드시 실행된다

SessionManager – 전체 세션을 일괄 관리

public class SessionManager {

    private final 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;
    }
}

설계 포인트

  • 모든 메서드에 synchronized가 붙어 있다. 클라이언트마다 별도의 스레드가 실행되므로 동시성 문제가 반드시 발생한다. 여러 스레드가 sessions 리스트에 동시에 접근하면 ConcurrentModificationException 등의 문제가 생긴다
  • sendAll()에서 예외를 개별적으로 처리한다. 특정 클라이언트 연결이 끊겼더라도 나머지 클라이언트에게는 메시지가 전달되어야 하기 때문이다
  • getAllUsername()은 현재는 직접 쓰이지 않지만, 이후 /users 명령어를 구현 시 사용된다. 미리 설계해두면 나중에 손댈 코드가 줄어든다

CommandManager – 명령어 처리의 추상화

public interface CommandManager {
    void execute(String totalMessage, Session session) throws IOException;
}

단순해 보이지만, 이 인터페이스가 있고 없고의 차이는 크다. 이것이 바로 OCP(개방-폐쇄 원칙)의 실천이다

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);
    }
}

V1은 딱 두 가지만 한다

  • /exit가 오면 IOException을 던져 세션 종료를 유도한다
  • 그 외의 메시지는 모든 클라이언트에게 전파한다

/exitIOException을 사용하는 방식이 어색하게 느껴질 수 있다. 이는 의도된 제어 흐름으로, Session의 run() 루프가 IOException을 잡아 finally 블록에서 자원을 정리하도록 설계되어 있기 때문이다. 추후 더 정교한 방식으로 개선한다

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();
            }
        }
    }
}

설계 포인트

  • start()addShutdownHook()running()으로 분리했다. 메서드 이름만 읽어도 실행 순서와 역할이 파악된다. 이것이 좋은 코드 문서화이다
  • ShutdownHook은 JVM이 종료될 때 (Ctrl+C 등) 호출된다. 모든 세션을 닫고, 서버 소켓을 닫은 뒤 1초 대기해 자원 정리 시간을 확보한다
  • serverSocket.accept()는 블로킹 호출이다. 클라이언트가 접속하는 순간 반환되고, 즉시 새 SessionThread를 생성해 독립적으로 실행시킨다

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);
        Server server = new Server(PORT, commandManager, sessionManager);
        server.start();
    }
}

CommandManager를 인터페이스 타입으로 선언한 덕분에, V2로 바꿀 때 이 한 줄만 수정하면 된다. Server 내부는 전혀 건드릴 필요가 없다

실행 결과 확인

서버 로그를 보면 구조가 더 선명하게 보인다

[ main] 서버 시작: class chat.server.CommandManagerV1
[ main] 서버 소켓 시작 - 리스닝 포트: 12345
[ Thread-0] client -> server: /join|nate
[ Thread-0] server -> client: /join|nate
[ Thread-1] client -> server: /join|seon
[ Thread-1] server -> client: /join|seon
[ Thread-1] server -> client: /join|seon   ← nate에게도 전파됨
[ Thread-0] client -> server: /message|hi seon
[ Thread-0] server -> client: /message|hi seon  ← nate
[ Thread-0] server -> client: /message|hi seon  ← seon
[ Thread-0] client -> server: /exit
[ Thread-0] java.io.IOException: exit
[ Thread-0] server -> client: null님이 퇴장했습니다.  ← username 미설정
[ Thread-0] 연결 종료: Socket[...]

null님이 퇴장했습니다.가 보인다. 아직 username을 세팅하는 로직이 없기 때문이다. V2에서 구현하면서 해결한다

정리

클래스역할
Session개별 클라이언트 연결 관리, 메시지 수신
SessionManager전체 세션 목록 관리, 일괄 메시지 전파
CommandManager명령어 처리 추상화(인터페이스)
CommandManagerV1최소 구현 – 전파 및 종료
Server연결 수락, 세션 생성, 생명주기 관리

가장 중요한 설계 결정은 CommandManager를 인터페이스로 뽑은 것이다. 덕분에 서버 로직을 건드리지 않고도 명령어 처리 방식을 자유롭게 교체할 수 있다

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