최원종의 개발 블로그

(소켓 - 6) 1:N 실시간 채팅 (브로드캐스트) 본문

Java/JAVA 유용한 클래스

(소켓 - 6) 1:N 실시간 채팅 (브로드캐스트)

chl6698 2026. 3. 27. 16:26

1:1에서 1:N으로

1:1 채팅 (소켓-5):
    클라이언트 A  ──────────  서버

1:N 채팅 (소켓-6):
  클라이언트 A  ──┐
  클라이언트 B  ──┼──  서버  → 모든 클라이언트에게 전달
  클라이언트 C  ──┘

 

채팅방처럼 한 명이 말하면 모두에게 전해지는 방식을 브로드캐스트(Broadcast)라고 함.

 

1:1과 1:N의 핵심 차이

  • 1:1 서버는 클라이언트 하나를 처리하고 끝남
  • 1:N 서버는 클라이언트가 접속할 때마다 새 스레드를 만들어서 처리
1:1 서버:
  accept() → Socket → 통신 → 종료

1:N 서버:
  while(true) {
      accept()                          → 새 클라이언트 접속
      new ClientHandler(socket).start() → 전담 스레드 생성
      (다시 accept() 대기)               → 다음 클라이언트 기다림
  }

 

 

 

  • 브로드캐스트를 위해 모든 클라이언트의 출력 스트림을 한 곳에 모아둠                                                                                Vector는 여러 스레드가 동시에 접근해도 안전한 리스트.
  • 일반 ArrayList는 동시에 여러 스레드가 접근하면 데이터가 손상될 수 있음

- 서버 코드

package server.ch04;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Vector;

public class MultiClientServer {

    public static final int PORT = 5000;
    // 연결된 모든 클라이언트의 출력 스트림을 보관할 자료구조
    //Vector는 멀티 스레드 환경에서 안전한 동작을 한다.
    private static Vector<PrintWriter> clientWriterList = new Vector<>();

    public static void main(String[] args) {
        System.out.println("서버  시작 ... ");

        try (ServerSocket serverSocket = new ServerSocket(PORT)) {
            while (true) {
                Socket clientSocket = serverSocket.accept();
                //클라이언트 접속 --> 전담 스레드 생성 --> 메인 스레드는 다시 대기
                new ClientHandler(clientSocket).start();
                System.out.println("클라이언트 접속. 현재 접속자 " + clientWriterList.size() + " 명");
            }
            
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        
    }//end of main

    //각 클라이언트와의 통신을 담당하는 정적 내부 클래스
    private static class ClientHandler extends Thread {

        private Socket socket;
        private PrintWriter out;
        private BufferedReader in;

        public ClientHandler(Socket socket) {
            this.socket = socket;
        }

        //스레드가 start() 호출 되면 서브 작업자가 일함..
        @Override
        public void run() {
            try {
                in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                out = new PrintWriter(socket.getOutputStream(), true);

                //추후 접속자 명수 확인 또는 방송(브로드 캐스트를) 하기 위해 백터 자료구조에 저장할 예정
                clientWriterList.add(out);

                String message;
                while ((message = in.readLine()) != null) {
                    System.out.println("수신 : " + message);
                    //받은 메시지를 연결된 모든 클라이언트에게 전송 (브로드캐스트)
                    broadcast(message);
                }

            } catch (Exception e) {
                System.out.println("누군가 채팅을 종료 했습니다");
                //throw new RuntimeException(e);
            }finally {
                //클라이언트가 강제 종료 및 exit 요청을 했다면 서버에서 관리하고 있는
                //백터 안에 나으
                clientWriterList.remove(out);//출력 스트림 제거

                try {
                    if(socket != null){
                        socket.close();
                    }
                } catch (IOException e) {
                    throw new RuntimeException(e);
                }
            }
        }//end of run

        //방송하기
        private void broadcast(String message) {
            for (PrintWriter writer : clientWriterList) {
                writer.println(message);
            }
        }
    }//end of inner ClientHandler
}//end of class

 


-클라이언트 코드

package client.ch04;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.UnknownHostException;
import java.util.Scanner;

public class ChatClient {

    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("채팅 이름을 입력하세요 : ");
        String name = sc.nextLine();

        //강사 ip 주소 : 192.168.4.101
        try (Socket socket = new Socket("localhost", 5000)) {

            System.out.println(name + " 님, 채팅방 입장 했음 (종료 : exit)");

            //소켓에서 연결 할 입력, 출력 스트림 2개가 필요하다.
            PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
            BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));

            //클라이언트에서 키보드에서 값을 입력 받을 스트림이 필요하다
            BufferedReader keyboardReader = new BufferedReader(new InputStreamReader(System.in));

            //읽기 쓰레드 (서버 측에서 값을 계속 받을 수 있도록 처리)
            Thread readThread = new Thread(new Runnable() {
                @Override
                public void run() {

                    try {
                        String serverMessage;
                        while ((serverMessage = reader.readLine()) != null) {

                            System.out.println("서버 : " + serverMessage);
                        }


                    } catch (IOException e) {
                        System.out.println("서버측과 연결이 끊어졌습니다");
                    }
                }
            });

            //쓰기 스레드 생성(클라이언트 측 키보드에서 값을 받아서 서버측으로 전송)
            Thread writeThread = new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        String clientMessage;
                        while ((clientMessage = keyboardReader.readLine()) != null) {
                            if ("exit".equalsIgnoreCase(clientMessage)) {
                                System.out.println("채팅방 종료");
                                writer.println("[" + name + "] 님이 퇴장 했습니다");
                                break;
                            }
                            //서버에 메세지 전송
                            writer.println("[" + name + "]" + clientMessage);

                        }
                    } catch (IOException e) {
                        System.out.println("메세지 전송 중 오류가 발생했습니다");
                    }
                }
            });
            readThread.start();
            writeThread.start();

            readThread.join();
            writeThread.join();

        } catch (UnknownHostException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 


-코드 실행 결과

더보기

클라이언트 측 입력

서버 측 표시

 


브로드캐스트 동작 흐름

클라이언트 A: writerStream.println("[A]: 안녕!")
                   |
                   ▼
서버 ClientHandler (A 담당 스레드)
  in.readLine() → "[A]: 안녕!" 수신
  broadcast("[A]: 안녕!")
                   |
     ┌─────────────┼────────────────┐
     ▼             ▼                ▼
  A 화면        B 화면           C 화면
[A]: 안녕!    [A]: 안녕!      [A]: 안녕!

클라이언트 코드 리팩토링 - 추상 클래스 활용

-추상 클라이언트 코드

package client.ch05;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;

public abstract class AbstractClient {

    private String name;
    private Socket socket;
    private PrintWriter writerStream;
    private BufferedReader readerStream;
    private BufferedReader keyboardReader;

    public AbstractClient(String name) {
        this.name = name;
    }

    // 실행 흐름 정의 (자식 클래스에서 재정의 불가) final
    public final void run() {
        try {
            connectToServer();
            setupStreams();
            startCommunication();
        } catch (Exception e) {
            e.printStackTrace(); // 스택구조 예외를 추척하게 하는 부분을 출력
        } finally {
            // 최종에는 socket.close() 처리
            cleanUp();
        }
    }

    // AbstractClient 상속 받은 자식 클래스는 무조건 connectToServer()메서드를 재 정의 해야 함.
    protected abstract void connectToServer() throws IOException;

    private void setupStreams() throws IOException {
        writerStream = new PrintWriter(socket.getOutputStream(), true);
        readerStream = new BufferedReader(new InputStreamReader(socket.getInputStream()));
        keyboardReader = new BufferedReader(new InputStreamReader(System.in));
    }

    private void startCommunication() throws InterruptedException {
        Thread readThread = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    String msg;
                    while ( (msg = readerStream.readLine()) != null ) {
                        System.out.println(msg);
                    }
                } catch (Exception e) {
                    System.out.println("서버와의 연결이 끊겼습니다.");
                }
            }
        });

        // 키보드에서 값을 받아서 소켓으로 서버에 메세지 전송
        Thread writeThread = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    String input;
                    while ( (input = keyboardReader.readLine()) != null ) {
                        writerStream.println("[" + name + "]" + input);
                    }
                } catch (Exception e) {
                    System.out.println("전송 중 오류 발생");
                }
            }
        });

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

        readThread.join();
        writeThread.join();

    } // end of startCommunication

    // 외부에서 메서드를 통해서 주소값을 주입 받음
    protected void setSocket(Socket socket) {
        this.socket = socket;
    }

    private void cleanUp() {
        try {
            if( socket != null) {
                socket.close();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

 

-클라이언트 코드

package client.ch05;

import java.io.IOException;
import java.net.Socket;
import java.util.Scanner;

public class ChatClient extends AbstractClient {

    public ChatClient(String name) {
        // 부모 클래스에 사용자 정의 생성자가 있다면 자식 클래스 생성자에서 가장 먼저
        // 부모 생성자를 호출 해야 한다.
        super(name);
    }

    // 서버에 연결 방법만 직접 구현
    @Override
    protected void connectToServer() throws IOException {
        // Socket socket = new Socket("localhost", 5000);
        // setSocket(socket);
        //  부모 클래스 멤버 변수인 socket 에 객체의 주소값을 할당 하지 않으면
        //  nullPointerException 발생 함.
        setSocket(new Socket("localhost", 5000));

    }

    // 실행 하기
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        System.out.print("이름을 입력하세요 : ");
        String name = sc.nextLine();

//        ChatClient chatClient = new ChatClient(name);
//        chatClient.run();

        new ChatClient(name).run();

    }
}

 


리펙토링 전 후

리팩토링 전:
  ChatClient 가 스트림 설정, 스레드 생성, 종료 처리를 모두 직접 관리

리팩토링 후:
  AbstractClient 가 공통 로직 담당 (스트림, 스레드, 종료)
  ChatClient 는 connectToServer() 구현만 담당
  → 나중에 다른 서버에 연결하는 클라이언트를 만들 때도 AbstractClient 재사용 가능

 

핵심 요약

1:N 서버 핵심 구조
  while(true) { new ClientHandler(accept()).start() }
  → 클라이언트마다 전담 스레드 생성

브로드캐스트
  clientWriters (Vector) 에 모든 출력 스트림 보관
  메시지 수신 시 전체에게 전달

Vector
  멀티스레드 환경에서 안전한 리스트
  여러 스레드가 동시에 add/remove 해도 안전

AbstractClient (리팩토링)
  OOP 추상 클래스로 공통 로직 분리
  connectToServer() 만 구현하면 다른 서버에도 재사용 가능