python 기반 채팅프로그램 - 2

김두현·2023년 7월 11일
0

python_chat

목록 보기
3/4

이번 포스트에서는 thread를 적용한 채팅 프로그램을 구현할 것이다. 전 post에 올라온 단일 thread를 이용한 server-client 구조로 서로 메세지를 주고 받는 실습을 할 수 있었지만 이 프로그램은 치명적인 문제를 가지고 있는데 바로 아래와 같다.

  • 서버 프로그램은 두 개 이상의 클라이언트를 받아들일 수 없다.
  • 소켓으로 연결된 서버와 클라이언트는 한 쪽이 메세지를 보내면 받고 다시 메세지를 보낼 수 있다. 한 쪽이 일방적으로 여러 메세지를 보낼 수 없다.

그러면 먼저 본격적으로 Mulit-Thread 기반의 채팅 프로그램을 보기 전에 내가 계속 언급한 “thread”는 무엇인가? 알아보고 더 나아가 multi-thread를 적용한 채팅 프로그램까지 post 해보겠당.

Thread

내가 생각하기에 thread와 process 단어는 cs 면접에서 두 단어의 차이를 물어보거나 multi-process 프로그래밍과 mulit-thread 프로그래밍의 차이를 물어보는 등 자주 등장하는 주제로 코딩을 조금이라도 해봤다면 다들 한 번은 들어봤다고 생각한다.

음… 일단 먼저 thread에 대해서 짚고 가기 전에 process의 개념에 대해 이해하는 것이 필요하다. 바로 알아보자!

Process란?

프로세스는 실행 중인 프로그램의 instance를 말한다.

→ 흔히 실행 중인 프로그램이라고 자주 표현

운영체제는 프로세스에게 필요한 자원(CPU 시간, 메모리 등)을 할당하며, 각 프로세스는 독립적인 메모리 공간을 가지고 있어 독립적으로 실행되고 다른 프로세스와는 격리되어 있다.

→ 한 프로세스가 다른 프로세스의 공유 데이터에 직접 접근할 수 없다는 것을 의미

(💡 추가로 process는 하나 이상의 thread를 가진다.)

Thread란?

thread를 정의하는 여러 방식이 존재하는 걸로 아는데 영어 단어 뜻 자체로는 “실”의 의미를 갖고 있다.

thread는 process 내에서 실행되는 실행 흐름의 단위이다.

한 process 내에서 여러 thread를 생성이 가능하며, 같은 프로세스에 속한 thread들은 process의 자원(ex, Memory, File Descriptor)을 공유하며 독립적으로 실행될 수 있다. 이 말은 두 가지의 장점을 가진다.

  • thread 간 데이터를 통신하는 데 있어 process보다 훨씬 빠르고 효율적
    (process 간 통신은 IPC 등의 복잡한 매커니즘을 이용해서 통신이 가능)
  • 한 thread가 차단되거나 긴 작업을 수행하고 있어도 다른 thread는 계속 실행 가능

→ 추가적으로 운영체제 관점에서 thread를 Light Weight Process(LWP) 라고 표현

Thread vs Process

위의 두 그림은 chat gpt-4를 이용해서 그린 thread와 process의 차이를 돕기 위한 다이어그램이다.

thread와 process의 가장 큰 차이점은 '자원 공유'이다.

process는 운영체제에게 할당 받은 각각 독립적인 자원을 가지지만, thread는 같은 process 내에서 자원을 공유하며 이로 인해 thread 간의 데이터 공유가 훨씬 빠르고 효율적이라고 볼 수 있다.

그리고 그림에서 볼 수 있듯이, 각 process는 독립적인 힙, 전역 변수, 코드 등의 자원을 가지고 있지만, thread들은 각자의 CPU 시간과 스택을 가지고 있지만, 힙과 같은 메모리 공간은 프로세스와 공유하고 있는 걸 알 수 있다.

또한, process는 운영체제로부터 자원을 할당 받아 독립적으로 실행되는 반면, thread는 process 내에서 실행되는 흐름의 단위(보다 경량화)이다.

→ thread 생성이 process 생성보다 더 빠르고 자원을 적게 소모

Thread in Python

지금까지 thread의 개념에 대해서 간단히 살펴봤는데 이제부턴 본격적으로 thread를 적용한 채팅 프로그램을 구현하기 위해 python에서 thread 사용법과 code 등을 위주로 살펴보자.

파이썬에서 ‘threading’ 모듈을 import 하면서 thread를 사용할 수 있는데 다양한 thread 관련 기능을 파이썬에서 제공한다.
나는 최대한 파이썬에서 제공한 라이브러리를 적극적으로 활용할 예정…!

일단 먼저 python에서 thread의 특징은 다른 언어와 차이점이 하나 존재한다. 바로 CIL(Global Interpreter Lock)라는 파이썬의 정책인데 간단히 정리한 내용은 아래 참고.

GIL은 파이썬 인터프리터가 한 번에 하나의 thread만을 실행하도록 제한하는 매커니즘으로 이는 메모리 관리를 단순화하고, CPython 인터프리터의 설계를 간단하게 만든다.


GIL로 인해 파이썬 multi-thread 프로그램은 실질적으로 동시에 여러 작업을 수행하지 못 하며, CPU 사용률이 저하된다. cpu-bound 작업은 cpu 계산이 많이 필요한 작업으로 cpu 성능에 의존하는데 CIL로 인해 thread들은 동시에 실행되지 못 하며 번갈아 실행되는 것과 유사하게 동작해 cpu-bound 작업에서 성능 저하를 초래할 수 있다.

하지만, I/O bound 관점에서는 이야기가 다르다. I/O bound 작업은 네트워크 요청, disk write and read 등 cpu가 계산을 하지 않고 다른 메모리나 장치(I/O)에서 작업을 하는 것으로 cpu는 block 상태가 되는데 이 block 상태를 기다리는 동안 다른 thread가 실행될 수 있다.

→ 채팅 프로그램은 네트워크 작업이 많은 프로그램으로 multi-thread가 적합!!

Thread 예시

아래 코드는 thread 사용하지 않고 1초 쉬고 print를 호출하는 프로그램과 thraeding 모듈을 이용해 구현한 프로그램의 설명으로 각 차이점을 위주로 보면 이해하기 쉬울 수도…

import time

def do_someting():
    print("1초 잘랭")
    time.sleep(1)
    print("다시 깨어남")

start = time.perf_counter()
for _ in range(10):
    do_someting()
finish = time.perf_counter()

print(f'총 걸린 시간: {finish - start} 초')

  • 위의 코드는 thread를 사용하지 않고 1초에 한번을 주기로 print를 출력하는 코드이다.
  • 약 10초 이상의 실행 시간이 소요됨을 확인할 수 있다.
import time
import threading

def do_someting():
    print("1초 잘랭")
    time.sleep(1)
    print("다시 깨어남")

threads = []
start = time.perf_counter()
for _ in range(10):
    thread = threading.Thread(target=do_someting)
    thread.start()
    threads.append(thread)

for thread in threads:
    thread.join()
finish = time.perf_counter()

print(f'총 걸린 시간: {finish - start} 초')

  • 위의 코드는 threading 모듈을 이용해서 총 10개의 thread를 생성한 뒤 위와 같은 print를 호출한다.
  • thread가 적용된 프로그램은 전 프로그램의 실행 시간과 달리 1초 조금 넘는 실행 시간이 소요됨을 알 수 있다. → 한 thread가 1초 쉬는 동안 다른 thread가 실행!!

Code

아래부터 본격적으로 python으로 구현한 Multi-Thread 채팅 프로그램의 코드이다. 채팅 프로그램은 서버와 클라이언트 부분으로 구성되며, server와 client 프로그램의 코드를 리뷰하고 실행 결과를 중심으로 설명하겠습니당~

💡 저번 단일 thread 채팅 프로그램과 차이점을 위주로~~

Server.py

def client_accept(client_size):
    child_sock, child_addr = server_sock.accept()
    client_size += 1
    client_sockets.append([child_sock, child_addr])
    print(f'{child_addr}에서 접속')

    return client_size
  • client_accept 함수는 새로운 클라이언트의 접속을 받아들이고 대기한다.
  • accept() 메소드를 호출하여 클라이언트의 접속을 기다리고, 접속한 클라이언트의 소켓과 주소를 client_sockets 리스트에 추가한다
  • 리턴 값으로 접속한 클라이언트의 수를 나타내는 client_size를 반환한다.

def send_msg_all(msg, from_sock):
    for client_socket in client_sockets:
        if not client_socket == from_sock:
            client_socket[0].sendall(msg)
  • send_msg_all 함수는 받은 메시지를 모든 클라이언트에게 전송한다.
    이때, 메시지를 보낸 클라이언트에게는 메시지를 다시 보내지 않는다

def recv_msg(from_sock):
    while True:
        message = from_sock[0].recv(1024)
        send_msg_all(message, from_sock)
        print(f'client({from_sock[1]}): {message.decode()}')
  • recv_msg 함수는 클라이언트로부터 메시지를 받아서 send_msg_all 함수를 호출하여 모든 클라이언트에게 메시지를 전송한다.
  • 이 함수는 별도의 thread에서 실행되므로, 여러 클라이언트가 동시에 메시지를 보내도 각 메시지를 제대로 처리할 수 있다.

threads = []
threads_size = 0

client_sockets = []
client_size = 0
  • client_sockets 리스트는 접속한 클라이언트의 소켓과 주소를 저장한다.
  • threads 리스트는 클라이언트별로 생성한 스레드를 저장한다.

while True:
    client_size = client_accept(client_size)
    print(f'현재 접속자 수: {client_size}')
    recv_thread = threading.Thread(target=recv_msg, args=(client_sockets[client_size - 1],))
    recv_thread.start()
    threads.append(recv_thread)
  1. while 문을 통해 무한 루프를 돌면서 클라이언트의 접속을 계속 받아들인다.
  2. 새로운 클라이언트가 접속하면, client_accept 함수를 호출하여 클라이언트의 소켓과 주소를 저장한다
  3. recv_msg 함수를 실행하는 새로운 thread를 생성 후 생성한 thread는 threads 리스트에 추가된다.

Client.py

def recv_msg():
    while True:
        msg = client_sock.recv(1024)
        print(f'Server: {msg.decode()}\n>>> ', end='')
  • recv_msg 함수는 서버로부터 메시지를 받아 msg 변수에 저장해 클라이언트 터미널에 출력한다.
  • 이 함수는 별도의 thread에서 실행되므로, 사용자가 메시지를 입력하는 동안에도 서버로부터 메시지를 계속 받을 수 있다.

def send_msg(msg):
    client_sock.sendall(msg.encode('utf-8'))
  • send_msg 함수는 사용자가 입력한 메시지를 서버에게 전송한다.
  • sendall 메소드를 사용하여 메시지의 모든 바이트가 전송될 때까지 전송을 시도한다.

recv_thread = threading.Thread(target=recv_msg)
recv_thread.start()
  • 서버로부터 메시지를 받는 작업을 별도의 thread에서 실행하도록 설정한다.
  • 사용자가 메시지를 입력하는 동안에도 서버로부터 메시지를 계속 받을 수 있다.

Multi-Thread 채팅 프로그램의 장점

이렇게 multi-thread를 사용하여 채팅 프로그램을 구현하면, 단일 thread 프로그램과 비교하여 다음과 같은 장점이 있다:

  1. 동시성
    여러 클라이언트가 동시에 메시지를 보내도, 각 메시지를 별도의 thread에서 처리하므로 메시지가 누락되거나 순서가 바뀌는 일이 없다.
  2. 응답성
    서버는 클라이언트의 메시지를 즉시 처리하고 전송할 수 있으므로, 클라이언트는 메시지를 보낸 후 응답을 빠르게 받을 수 있다.
  3. 자원 활용
    multi-thread를 사용하면, CPU와 네트워크 자원을 효율적으로 활용할 수 있어 특히, I/O-bound 작업인 네트워크 통신에서는 멀티스레드의 장점이 크게 부각된다.

지금까지 thread를 적용해 멀티스레드 채팅 프로그램을 구현하는 과정을 posting 하는 과정에서 thread의 이해와 여러가지 등을 배울 수 있었다. 다음 post에서는 이 프로그램에 추가적인 기능과 유저 기능을 추가하겠습니당~~~

Reference

https://velog.io/@aeong98/운영체제OS-프로세스와-스레드

[분산 시스템] 2-3. CPU Bound & I/O Bound

[병렬 프로그래밍] 1. Multi-Thread 사용하기 with Python

원본 코드

server.py

import socket
import threading

def client_accept(client_size):
    child_sock, child_addr = server_sock.accept()
    client_size += 1
    client_sockets.append([child_sock, child_addr])
    print(f'{child_addr}에서 접속')

    return client_size

def send_msg_all(msg, from_sock):                      # for 문을 통해 접속된 client 모두에게 같은 msg 전송
    for client_socket in client_sockets:
        if not client_socket == from_sock:              # 메시지를 보낸 클라이언트에게는 다시 보내지 않음
            client_socket[0].sendall(msg)

def recv_msg(from_sock):
    while True:
        message = from_sock[0].recv(1024)
        send_msg_all(message, from_sock)
        print(f'client({from_sock[1]}): {message.decode()}')
        

threads = []
threads_size = 0

client_sockets = []
client_size = 0

host = 'localhost'
port = 55555

server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

server_sock.bind((host, port))
server_sock.listen(5)
print(f'채팅 서버 open\nhost: {host}\tport: {port}\n---------------------------------------')

while True:
    client_size = client_accept(client_size)        # 만약 연결된 클라이언트가 있으면 다음 코드로 넘어감
    print(f'현재 접속자 수: {client_size}')
    recv_thread = threading.Thread(target=recv_msg, args=(client_sockets[client_size - 1],))
    recv_thread.start()
    threads.append(recv_thread)

# child_sock.close()
# parent_sock.close()

client.py

import socket
import threading

def recv_msg():
    while True:
        msg = client_sock.recv(1024)
        print(f'Server: {msg.decode()}\n>>> ', end='')

def send_msg(msg):
    client_sock.sendall(msg.encode('utf-8'))        # send vs sendaall: sendall이 버퍼에 있는 데이터를 다 보냈음을 보장함!!

server_host = 'localhost'
server_port = 55555

client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

#서버와 연결
client_sock.connect((server_host, server_port))
print(f'Server: {server_host}, {server_port}와 정상적으로 연결')

recv_thread = threading.Thread(target=recv_msg)
recv_thread.start()

while True:
    msg = input(">>> ")
    send_msg(msg)

client_sock.close()
profile
끄적끄적

0개의 댓글

관련 채용 정보