언어/JAVA

[자바/Java] 자바로 멀티 스레드 소켓 채팅 구현해보기

gyungmean 2024. 6. 27. 19:27

이 글의 목표

프로세스와 스레드란 무엇인가? 소켓 통신이란 무엇인가? CS 기초 지식 정리

자바로 멀티 스레드 채팅 서버와 클라이언트를 구현하는 실습을 하면서 개념 익히기

 

프로세스와 스레드

프로세스란?
운영체제로부터 자원을 할당받은 작업의 단위
  • 실행 중에 있는 프로그램
  • 메모리에 올라와있고 실행되고 있는 프로그럼의 인스턴스
  • 스케줄링의 대상이 되는 작업(=task)와 같은 의미로 쓰인다.
  • 실제로는 스레드 단위로 스케줄링을 한다.
  • 하드디스크에 있는 프로그램을 실행 -> 실행을 위한 메모리 할당 -> 할당된 메모리 공간으로 바이너리 코드가 올라감 -> 이 순간부터 프로세스라고 불린다!

프로세스의 주소 공간

프로세스에는 다음과 같은 영역들이 있다.

구역은 왜 나누는 걸까?

최대한 데이터를 공유해서 메모리 사용량을 줄여야 하니까!

  • Code 영역
  • 실행할 프로그램의 코드 or 명령어들이 기계어 형태로 저장된 영역
  • CPU는 코드 영역에 저장된 명령어들을 하나씩 처리
  • Data 영역
  • 코드에서 선언한 전역변수정적변수가 저장되는 영역
  • 프로그램이 실행되면서 할당되고 종료되면 소멸
  • Stack 영역
  • 함수안에서 선언된 지역변수, 매개변수, 리터값 등이 저장된다.
  • 함수 호출시 기록되고 종료되면 제거
  • Heap 영역
  • 관리가 가능한 데이터 이외의 다른 형태의 데이터를 관리하기 위한 자유공간
  • 동적할당시 사용된다 (i.g. new(), malloc()...)

 

멀티 프로세스

하나의 응용 프로그램을 여러 개의 프로세스로 구성하여 각 프로세스가 하나의 태스크를 처리하도록 하는 것

 

멀티 프로세스의 장점

  • 여러개의 프로세스 중 하나에 문제가 발생하면 혼자 죽는거 외에 더 이상 피해가 퍼지지 않음 즉, 안정성

 

멀티프로세스의 단점

  • Context swiching에서 오버헤드 발생 : 프로세스들 간에는 메모리를 공유하지 않기 때문에 캐쉬에 있는 모든 데이터가 매번 리셋되고 다시 불러오는 과정이 발생
  • 프로세스들 사이의 어렵고 복잡한 통신

프로세스 자료구조 PCB(Process Control Block)

운영체제가 프로세스를 표현한 자료구조

  • 특정 프로세스에 대한 정보를 가지고 있음
  • 프로세스가 생성될 때마다 PCB생성 -> 프로세스 완료시 삭제
  • 프로세스간 문맥교환이 일어날 때 진행하던 작업들을 PCB에 저장 -> 이후 자신의 순서가 왔을때 이어서 처리

 

문맥교환(Context Switch)

하나의 프로세스가 이미 CPU를 사용중인 상태에서 다른 프로세스가 CPU를 사용하기 위해 이전 프로세스 상태를 저장하고 새로운 프로세스의 상태를 적재하는 것

예시 : 카톡 켜놓고 유튜브로 노래를 들으며 웹 서핑을 한다 -> 동시에 일어나는 것 같아보이지만 문맥 교환이 일어나고 있는 상태

 

프로세스의 기본 개념은 이러한데 그렇다면 스레드는 무엇인가?

스레드란?
프로세스 안에서 실행되는 여러 흐름 단위
  • 프로세스 하나가 생성될 때, 기본적으로 하나의 스레드가 같이 생성된다.
  • 프로세스는 자신만의 고유 공간과 자원을 할당받아 사용하지만! 스레드는 다른 스레드와 공간, 자원을 공유하면서 사용함
  • (앞서 멀티프로세스의 단점으로 프로세스들 간에는 메모리 공유가 일어나지 않는 다는 점을 꼽았는데 이것과 비교하면 좋을거 같다.)
  • 스레드는 stack 영역만 따로 할당받음

 

왜? 이 영역은 따로 할당받을까?

앞서 프로세스의 stack영역은 함수의 지역변수 같은 것들이 저장되는 공간이라고 했다.

스레드는 독립적인 동작을 수행하기 위해서 존재한다. -> 독립적으로 함수를 호출할 수 있어야함 -> 따라서 함수, 매개변수 등등을 저장하는 스택 메모리 영역을 독립적으로 받아야할 필요성이 생긴다.

 

따라서 프로세스는 아래 그림과 같은 구조를 가지게 된다.

출처 : https://velog.io/@aeong98/%EC%9A%B4%EC%98%81%EC%B2%B4%EC%A0%9COS-%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9C

멀티 스레드

하나의 응용프로그램을 여러 개의 스레드로 구성하고 각 스레드로 하여금 하나의 작업을 처리하게 하는 것.

웹 서버는 대표적인 멀티 스레드 응용프로그램이다.

 

멀티 스레드의 장점

  • 시스템 자원 소모 감소
  • 시스템 처리량 증가
  • 간단한 통신 방법으로 응답시간 단축

멀티 스레드의 단점

  • 주의 싶은 설계
  • 디버깅 까다로움
  • 하나의 스레드가 데이터 공간을 망치면 모든 스레드가 망함

 

멀티 프로세스 대신에 멀티 스레드가 사용되는 이유는 뭘까?

프로그램을 여러개 키는 것 보다 하나의 프로그램 안에서 여러 작업을 해결하는게 좋기 때문이다.

  • 자원의 효율성 증대 : 프로세스 생성으로 인한 자원 할당, 시스템 콜이 줄어들어 자원을 효율적으로 관리 가능하다.
  • 처리 비용 감소 및 응답 시간 단축

 

멀티 스레드의 주의할 점!

동기화 문제가 있다.

스레드 간의 자원 공유는 전역변수를 이용하므로 함께 사용할 때 충돌 가능성이 있다.

소켓 통신

소켓(socket)이란?
네트워크상에서 프로세스들끼리 데이터를 주고받을 수 있게 하는 문 같은 역할

OSI 7계층에 대한 개념이 여기서 나오게 되는데 소켓은 전송계층 위에서 전송계층의 프로토콜 제어를 위한 코드를 제공한다. 일반적으로 TCP/IP 프로토콜을 사용한다.

각 소켓은 IP주소와 포트번호로 이루어진 소켓 주소를 갖는다. [address : port]

통신을 위해서는 보내는 쪽과 받는 쪽 모두 소켓을 열어야함

하나의 프로세스가 같은 포트를 가지고 여러 개의 소켓을 열 수 있음

채팅프로그램에서 서로 다른 사람에게 개인톡을 보내는 것 처럼!

 

소켓을 열기위해 필요한 것들

  • 호스트에 할당된 IP주소
  • 포트넘버
  • 프로토콜

호스트가 인터넷을 사용하기 위해서는 고유한 IP주소(32bit)를 갖고 있어야함

그런데 하나의 호스트 위에서 여러개의 프로세스가 동작할 수 있음 -> 특정한 프로세스와 메시지를 주고 받기 위해서는 각각의 프로세스를 식별하기 위한 키가 필요함 -> 그것이 바로 포트번호

+) ip, 포트, 프로토콜로 소켓을 정의할 수는 있지만 그것이 소켓을 유일하게 식별할 수 있는 것은 아님

 

포트(Port)

컴퓨터 내에서 실행 중인 여러 프로세스를 식별하기 위한 번호로 IP주소와 결합하여 프로세스 간의 통신을 가능하게 해준다.

호스트 내부적으로 프로세스가 할당받는 고유한 값이다 = 한 IP주소 안에서 유니크 해야한다

숫자로 표현되기 때문에 포트 넘버라고도 한다

프로세스는 데이터를 전송하기 전에 포트번호를 할당받아야함.

단기 ephemeral 포트 : 클라이언트의 소켓 내 포트 번호는 클라이언트가 연결을 요청할 때 커널이 자동으로 할당한다.

서버의 소켓 내 포트 번호는 대개 영구적으로 이 서비스에 연결되는 잘 알려진 번호 (예를 들어 웹은 80포트 이런거)

 

TCP/IP 소켓 프로그래밍

소켓 통신을 하기 위해 보통은 서버 소켓과 클라이언트 소켓이 있다. 보통 알고 있는 개념대로 서버는 통신 요청을 받아들이고 클라이언트는 통신 연결 요청을 보낸다. 두 소켓은 같은 구조이지만 역할에 따라 처리하는 흐름이 다르다.

클라이언트 소켓 흐름

1. 클라이언트 소켓 생성 : socket()

연결 대상에 대한 정보가 들어 있지 않은 껍데기 소켓 생성

이때 소켓의 종류를 선택한다

  • TCP 소켓 : 스트림 타입으로 지정 가능
  • UDP 소켓 : 데이터그램 타입으로 지정 가능

 

2. 연결 요청 : connect()

IP주소와 서비스 포트 넘버로 연결하고 싶은 타겟을 지정함

요청을 냅다 보내면 끝인게 아니라 요청에 대한 결과를 받아야 connect의 실행이 종료됨

 

3. 데이터 송수신 : send()/recv()

연결 요청과 마찬가지로, 요청을 보낸다고 끝나는게 아니고 요청에 대한 결과가 돌아와야함

하지만 보낼 때는 데이터를 언제, 얼마나 보낼지를 내가 알고 있지만 받을 때는 상대가 언제 얼마나 보낼지 알 수 없음

따라서 수신하는 api는 별도의 스레드에서 진행된다.

 

4. 소켓 닫기 : close()

더 이상 데이터가 송수신 될 일이 없다고 판단되면 소켓을 닫는다.

 

서버 소켓의 흐름

1. 서버 소켓 생성 : socket()

연결 대상에 대한 정보가 없는 껍데기 소켓 생성

 

2. 바인딩 : bind()

우리의 컴퓨터에는 여러 어플리케이션이 동시에 돌아가고 있고 각각의 프로세스를 식별하기 위한 포트넘버가 필요하다.

서버 소켓이 고유한 포트넘버를 만들 수 있도록 소켓과 포트넘버를 연결해주는 과정이 바인드 인것

 

3. 클라이언트의 연결 요청 대기 : listen()

서버 소켓에서 포트번호와 바인딩 작업을 마치고나면 클라이언트로부터 연결 요청을 받아들일 준비는 끝

클라이언트가 요청을 보낼 때까지 대기하다가 요청이 도착하면 대기 상태를 종료

 

4. 클라이언트 연결 수립 : accept()

서버 소켓은 연결 요청을 받아들임과 동시에 새로운 소켓을 생성한다.

서버 소켓의 메인 역할은 클라이언트의 요청을 기다리는 것으로 클라이언트 소켓이 연결 요청을 보내면 새로운 소켓을 열고 클라이언트 소켓과 맵핑하여 넘겨주게 되는 것,

 

5&6. 데이터 송수신, 서버 소켓 닫기는 설명 생략


 

소켓 채팅 구현

먼저 구현한 모습은 아래와 같다.

서버를 실행하면 아래와 같이 서버측 콘솔에 출력된다.

출력되고 있는 localhost ip뒤에 붙어있는 포트 번호는 아까 위에서 살펴본 단기 ephemeral 포트로 추정된다. 접속한 사용자들에게 임의로 부여하고 있는 것을 확인할 수 있다.

 

아래는 클라이언트 측 콘솔이다.

누군가가 들어오고 나가는 메시지와 다른 사용자들의 메시지가 정상적으로 출력되는 것을 확인할 수 있다.

 

여기서 몇가지 기능을 더 추가하여

  • 나가려면 그냥 실행중인 프로그램을 종료시키는 식으로 종료 시켜야하는데 exit 라고 입력하면 나가는 것을 구현.
  • 채팅방의 인원을 제한

이 두가지를 추가했다.

 

서버측 코드

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;

public class ChatServer {
    final static int PORT = 7777;
    final static int MAX_CLIENTS = 5;

    HashMap<String, BufferedWriter> clients;

    ChatServer() {
        clients = new HashMap<>();
        Collections.synchronizedMap(clients);
    }

    public void start() {
        ServerSocket serverSocket = null;
        Socket socket = null;

        try {
            serverSocket = new ServerSocket(PORT);
            System.out.println("채팅 서버가 시작되었습니다");

            while (true) {
                socket = serverSocket.accept();

                synchronized (clients) {
                    if (clients.size() >= MAX_CLIENTS) {
                        BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"));
                        out.write("서버의 최대 접속 인원이 초과되었습니다. 나중에 다시 시도해주세요.");
                        out.newLine();
                        out.flush();
                        out.close();
                        socket.close();
                        System.out.println("접속 거부: [" + socket.getInetAddress() + ":" + socket.getPort() + "]");
                    } else {
                        System.out.println("[" + socket.getInetAddress() + ":" + socket.getPort() + "]에서 접속하였습니다.");
                        ServerReceiver thread = new ServerReceiver(socket);
                        thread.start();
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (serverSocket != null) {
                    serverSocket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    void sendToAll(String msg) {
        Iterator<String> it = clients.keySet().iterator();

        while (it.hasNext()) {
            try {
                BufferedWriter out = clients.get(it.next());
                out.write(msg);
                out.newLine();
                out.flush();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String args[]) {
        new ChatServer().start();
    }

    class ServerReceiver extends Thread {
        Socket socket;
        BufferedReader in;
        BufferedWriter out;

        ServerReceiver(Socket socket) {
            this.socket = socket;
            try {
                in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
                out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        public void run() {
            String name = "";
            try {
                name = in.readLine();
                synchronized (clients) {
                    sendToAll("#" + name + "님이 들어오였습니다.");
                    clients.put(name, out);
                    System.out.println("현재 서버의 접속자는 " + clients.size() + "입니다.");
                }

                String msg;
                while ((msg = in.readLine()) != null) {
                    sendToAll(msg);
                }
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                synchronized (clients) {
                    sendToAll("#" + name + "님이 나가셨습니다.");
                    clients.remove(name);
                    System.out.println("[" + socket.getInetAddress() + ":" + socket.getPort() + "]에서 접속을 종료하였습니다.");
                    System.out.println("현재 서버의 접속자 수는 " + clients.size() + "입니다.");
                }
                try {
                    if (socket != null) {
                        socket.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    } 
}

 

start()

  • ServerSocket과 Socket을 사용한다. 위에서 서버소켓과 클라이언트 소켓의 차이를 공부했었는데 자바에는 ServerSocket이라는 객체가 있다. new ServerSocket(포트번호)를 해주는 과정에서 바인딩과 listen과정이 진행되는 것.
  • 임의의 포트번호는 위에서 상수로 정의 해주었다.
  • while문 안에서 서버 소켓이 accept을 통해 서버가 클라이언트의 연결 요청을 기다리며 블로킹 상태에 있도록 한다.
  • accept() 메서드는 클라이언트의 연결 요청이 있을 때까지 블로킹되며, 클라이언트가 연결 요청을 하면 해당 클라이언트와의 통신을 위한 Socket 객체를 반환한다.
  • 아래에서 synchronized 블록을 통해 클라이언트를 관리하고 있다.

아래는 CS스터디 할 때 synchronized 에 대해서 정리 했던 부분을 일부 가져왔다.

java에서 제공하는 synchronized 키워드가 있다.
여러개의 스레드가 한개의 자원을 사용하고자 할 때,
현재 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들은 데이터에 접근 할 수 없도록 막는 개념이다.
문제점 : 성능저하

 

 

  • 최대 접속자 역시 상수로 위에서 선언해두었고 clients의 사이즈가 이를 넘기게 되면 초과 메시지를 클라이언트에게 전달하고 연결된 소켓을 닫았다.
  • 정상적으로 접속되었다면 스레드를 생성하고 실행시킨다.

ServerReceiver 클래스

  • 각 클라이언트와의 연결마다 생성되어 해당 클라이언트와의 통신을 담당한다.
  • 생성자로 소켓을 전달 받고 입출력을 위한 BufferedReader와 BufferedWriter를 초기화 한다.

  • 입장 메시지 전송 및 클라이언트 관리: 클라이언트가 처음 접속할 때, in.readLine()을 통해 클라이언트가 전송한 이름을 읽음. 이후 sendToAll() 메서드를 사용하여 모든 클라이언트에게 해당 클라이언트의 입장 메시지를 전송. clients 맵에 클라이언트 이름과 출력 스트림(out)을 저장
  • 메시지 전송: 클라이언트로부터 메시지를 받아서 sendToAll() 메서드를 사용하여 모든 클라이언트에게 메시지를 전송
  • 퇴장 처리 및 자원 정리: 클라이언트가 통신을 종료할 때 finally 블록에서는 synchronized (clients) 블록 내에서 해당 클라이언트의 퇴장 메시지를 모든 클라이언트에게 전송하고, clients 맵에서 해당 클라이언트를 제거 socket.close()를 호출하여 클라이언트와의 소켓 연결을 종료
  • 예외 처리: 통신 중 예외가 발생할 경우 해당 예외를 콘솔에 출력

 

 

 

클라이언트측 코드

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.ConnectException;
import java.net.Socket;
import java.net.SocketException;
import java.util.Scanner;

public class ChatClient {
    final static int PORT = 7777;

    // 서버와의 통신 메시지 정의
    private static final String SERVER_FULL_MESSAGE = "서버의 최대 접속 인원이 초과되었습니다. 나중에 다시 시도해주세요.";

    public static void main(String[] args) {
        if (args.length != 1) {
            System.out.println("대화명 args 필요");
            System.exit(0);
        }

        try {
            String serverIp = "127.0.0.1"; // localhost
            // 소켓을 생성하여 연결을 요청한다.
            Socket socket = new Socket(serverIp, PORT);
            BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
            String serverResponse = in.readLine();

            if (serverResponse.equals(SERVER_FULL_MESSAGE)) {
                handleServerFullMessage();
            } else {
                System.out.println("채팅 서버에 접속하였습니다. 어서오세요!");
                System.out.println("exit를 입력하면 나갈 수 있습니다!");
                Thread sender = new Thread(new ClientSender(socket, args[0]));
                Thread receiver = new Thread(new ClientReceiver(socket));
                sender.start();
                receiver.start();
            }
        } catch (ConnectException ce) {
            ce.printStackTrace();
        } catch (SocketException se) {
            System.out.println("서버와의 연결이 종료되었습니다.");
        } catch (IOException e) {
            e.printStackTrace();
        }
    } // main

    // 서버의 최대 접속 인원 초과 메시지 처리
    private static void handleServerFullMessage() {
        System.out.println("채팅방 수용인원 초과로 접속할 수 없습니다.");
    }

    static class ClientSender extends Thread {
        Socket socket;
        BufferedWriter out;
        String name;
        boolean running = true;

        ClientSender(Socket socket, String name) {
            this.socket = socket;
            try {
                out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), "UTF-8"));
                this.name = name;
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        public void run() {
            Scanner scanner = new Scanner(System.in);
            try {
                if (out != null) {
                    out.write(name);
                    out.newLine();
                    out.flush();
                }

                while (running) {
                    String message = scanner.nextLine();
                    if (message.equalsIgnoreCase("exit")) {
                        out.write("[" + name + "]님이 나갔습니다.");
                        out.newLine();
                        out.flush();
                        running = false; // 플래그 설정하여 스레드 종료 준비
                    } else {
                        out.write("[" + name + "]" + message);
                        out.newLine();
                        out.flush();
                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    if (out != null) {
                        out.close();
                    }
                    if (socket != null && !socket.isClosed()) {
                        socket.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        } // run()
    } // ClientSender

    static class ClientReceiver extends Thread {
        Socket socket;
        BufferedReader in;
        boolean running = true;

        ClientReceiver(Socket socket) {
            this.socket = socket;
            try {
                in = new BufferedReader(new InputStreamReader(socket.getInputStream(), "UTF-8"));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        public void run() {
            while (running) {
                try {
                    String message = in.readLine();
                    if (message == null) {
                        break;
                    }
                    System.out.println(message);
                } catch (IOException e) {
                    running = false; // 소켓 예외 발생 시 루프 종료
                }
            }
            try {
                if (in != null) {
                    in.close();
                }
                if (socket != null && !socket.isClosed()) {
                    socket.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    } // ClientReceiver
}

main()

대화명이 필요하기 때문에 args를 검증하는 코드 작성

로컬호스트의 7777 포트에 소켓 생성하여 서버에 연결을 요청함

서버에서 보내는 첫 메시지를 읽어와서 방에 인원이 다 찼다는 메시지라면 종료하고 아니라면 채팅이 시작됨

 

ClientSender 클래스

소켓 및 출력 스트림 초기화는 생성자를 통해 처리한다

서버에 대화명을 전송하고 버퍼를 비운다. 

이후에는 사용자의 입력을 대기하며 입력된 메시지를 서버로 전달한다. 여기서 만약 사용자가 입력한 글자가 exit이라면 채팅을 종료하도록 설정하엿다.

ClientReceiver 클래스

소켓 및 입력 스트림초기화는 위와 마찬가지

서버로부터 메시지를 받아서 콘솔에 출력한다. 서버로 부터 null을 받으면 통신이 종료된 것으로 간주하고 루프를 종료한다.


코드가 복잡한 줄 알았지만 하나씩 뜯어보니 정말 간단하게 소켓 흐름에 충실하게 채팅을 구현할 수 있었다.

지금은 콘솔창을 통해서 힘겹게 통신을 하고 있지만 향후 GUI로 바꿔보는 실습도 해보고 싶다.

 

참고한 블로그

https://velog.io/@lsh2613/%EC%9E%90%EB%B0%94-TCP-%EC%86%8C%EC%BC%93-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D%EC%9D%84-%ED%86%B5%ED%95%9C-%EB%A9%80%ED%8B%B0-%EC%B1%84%ED%8C%85-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

 

[자바] TCP 소켓 프로그래밍을 통한 멀티 채팅 구현하기

이번 게시글은 TCP 소켓 프로그래밍을 통해 멀티 채팅을 구현을 소개할 예정이다. TCP 소켓 프로그래밍에 대한 소개는 이전 게시글 TCP 소켓 프로그래밍을 통한 멀티 채팅 구현하기를 참고하고 바

velog.io