프로그래밍 언어/Python

소켓코드를 보고 현상정리 ( feat. 버퍼 )

JMDev 2023. 9. 21. 18:37
class EOFError(Exception):
    pass

# 서버는 한번에 하나씩 처리, 클라이언트 세션상태 유지하는 클래스
class ConnectionBase:
    # 3
    def __init__(self, connection):
        self.connection = connection
        self.file = connection.makefile('rb')

    def send(self, command):
        line = command + '\\n'
        data = line.encode()
        self.connection.send(data)

    def receive(self):
        line = self.file.readline()
        if not line:
            raise EOFError('Connection closed')
        return line[:-1].decode()

# Example 2
import random

WARMER = 'Warmer'
COLDER = 'Colder'
UNSURE = 'Unsure'
CORRECT = 'Correct'

class UnknownCommandError(Exception):
    pass

# handle_connection 1
class Session(ConnectionBase):
    def __init__(self, *args):
        super().__init__(*args)
        self._clear_state(None, None)

    def _clear_state(self, lower, upper):
        self.lower = lower
        self.upper = upper
        self.secret = None
        self.guesses = []

    # 클라이언트 들어오는 메시지를 처리해 명령에 맞는 메서드 호출
    def loop(self):
        while command := self.receive():
            parts = command.split(' ')
            if parts[0] == 'PARAMS':
                self.set_params(parts)
            elif parts[0] == 'NUMBER':
                self.send_number()
            elif parts[0] == 'REPORT':
                self.receive_report(parts)
            else:
                raise UnknownCommandError(command)

# 서버가 추측할 값의 상한과 하한 설정
    def set_params(self, parts):
        assert len(parts) == 3
        lower = int(parts[1])
        upper = int(parts[2])
        self._clear_state(lower, upper)

# 클라이언트가 해당하는 Session 인스턴스에 저장된 이전ㄴ 상태 바탕으로 새로운 수 추측. 
# 서버가 파라미터 설정된 시점 이후에 같은 수를 두번 반복해 추측하지 않도록 보장
    def next_guess(self):
        if self.secret is not None:
            return self.secret

        while True:
            guess = random.randint(self.lower, self.upper)
            if guess not in self.guesses:
                return guess

    def send_number(self):
        guess = self.next_guess()
        self.guesses.append(guess)
        self.send(format(guess))

# 서버 추측이 상태에 대해 클라이언트가 보낸 결과를 받은 후 Session 상태를 적절히 바꿈
    def receive_report(self, parts):
        assert len(parts) == 2
        decision = parts[1]

        last = self.guesses[-1]
        if decision == CORRECT:
            self.secret = last

        print(f'Server: {last} is {decision}')

# Example 7
import contextlib
import math

class Client(ConnectionBase):
    def __init__(self, *args):
        super().__init__(*args)
        self._clear_state()

    def _clear_state(self):
        self.secret = None
        self.last_distance = None

# 파라미터를 with 문을 통해 설정함으로써 서버측에 상태를 제대로 관리.
# 첫ㅅ 명령어를 서버에게 보냄
    @contextlib.contextmanager
    def session(self, lower, upper, secret):
        print(f'Guess a number between {lower} and {upper}!'
              f' Shhhhh, it\\'s {secret}.'')
        self.secret = secret
        self.send(f'PARAMS {lower} {upper}')
        try:
            yield
        finally:
            self._clear_state()
            self.send('PARAMS 0 -1')

# 구현하는 다른 메서드를 사용해 새로운 추측 서버에 요청
    def request_numbers(self, count):
        for _ in range(count):
            self.send('NUMBER')
            data = self.receive()
            yield int(data)
            if self.last_distance == 0:
                return

# 세번째 명령 구현한 마지막 메서드를 통해 서버가 돌려준 추측이 마지막 결과를 알려준 추측보다
# 차갑거나 뜨거운지 알려줌
    def report_outcome(self, number):
        new_distance = math.fabs(number - self.secret)
        decision = UNSURE

        if new_distance == 0:
            decision = CORRECT
        elif self.last_distance is None:
            pass
        elif new_distance < self.last_distance:
            decision = WARMER
        elif new_distance > self.last_distance:
            decision = COLDER

        self.last_distance = new_distance

        self.send(f'REPORT {decision}')
        return decision

# 소켓에 리슨하는 스레드 하나 사용하고 새 연결 들어올떄 마다 스레드 추가로 시작하는 방식으로 서버실행
import socket
from threading import Thread

def handle_connection(connection):
    with connection:
        session = Session(connection)
        try:
            session.loop()
        except EOFError:
            pass

def run_server(address):
    with socket.socket() as listener:
        # Allow the port to be reused
        listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        listener.bind(address)
        listener.listen()
        while True:
            connection, _ = listener.accept()
            thread = Thread(target=handle_connection,
                            args=(connection,),
                            daemon=True)
            thread.start()

# 클라이언트는 주 스레드에 실행되며 추측게임 결과를 호출한 쪽에 돌려줌
# 이 코드는 명시적 다양한 파이썬 언어 기능 활용
def run_client(address):
    with socket.create_connection(address) as connection:
        client = Client(connection)
        results = []

        with client.session(1, 5, 3):
            results = [(x, client.report_outcome(x))
                       for x in client.request_numbers(5)]

        with client.session(10, 15, 12):
            for number in client.request_numbers(5):
                outcome = client.report_outcome(number)
                results.append((number, outcome))

    return results

# Example 13
def main():
    address = ('127.0.0.1', 1234)
    server_thread = Thread(
        target=run_server, args=(address,), daemon=True)
    server_thread.start()

    results = run_client(address)
    for number, outcome in results:
        print(f'Client: {number} is {outcome}')

main()

위 코드로 Thread와 동시에 코루틴으로 리팩토링하는 과정을 설명해주는 목차였는데

심각한 것은 소켓통신을 하는 과정 이후에 작동순서들이 머릿 속에서 그려지지가 않았다.

그래서 위 코드를 하나하나 살펴보면서 알아갔던 것들을 정리해보려고 한다.

import socket

def start_client():
    client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client_socket.connect(('127.0.0.1', 12345))
    
    # 소켓을 파일 객체로 변환
    client_file = client_socket.makefile('rw', buffering=1)
    
    client_file.write("안녕, 서버!\\n")
    client_file.flush()
    
    line = client_file.readline().strip()
    print(f"받은 데이터: {line}")
    
    client_file.close()
    client_socket.close()

start_client()

소켓통신 예제를 살펴보던 중, 두 예제에서 소켓을 사용하는 방식이 좀 상이했다.

맨 위의 코드는 서버에 통신하기 위해서 send() 라는 메소드를 사용했고,

위의 코드는 write() 에 전송할 문자를 담고, flush()를 하여 서버에 통신하였다.

 

왜 이런 차이점이 발생한 것 일까?

결론은 큰 차이점은 존재하지 않는다 이다. 다만 write() 를 하게 되면 내부버퍼에 데이터가 쌓이게 되고,

flush() 를 호출하게 되면 내부버퍼의 데이터가 송신버퍼로 넘어가게 되는 차이인데,

send() 는 내부버퍼에 데이터를 쌓는 과정을 무시하고 바로 송신버퍼로 보내버린 것이다

 

makefile() 는 왜 쓰는 걸까?

그 이유는 소켓를 파일객체처럼 쓰기 위해서 라고 한다.

위 코드들을 보면 readline()를 써서 소켓에 통신한 데이터를 읽을 수 있게 구현됨을 볼 수 있는데,

혹시나 나처럼 C → B → A 순으로 데이터를 보내게 되면 A , AB, ABC 로 받는 거라고 생각하면 안된다.

readline() 를 하게 되면 Queue.pop() 과 유사한 효과를 나타내어서 데이터를 읽는 과정에서 읽혀진 데이터는 삭제된다고 한다

 

송신버퍼, 수신버퍼에 대한 이해

소켓에서 클라이언트에게 통신할 때, 거치는 버퍼가 총 3개의 버퍼가 있다 내부버퍼, 송신버퍼, 수신버퍼 이렇게 총 3가지가 있다.

여기 궁금한 부분이 송신버퍼에 데이터가 꽉차버리면 어떻게 될까였다.

이 부분을 테스트하긴 어렵고 이론적으로는 내부버퍼 쪽에 블로킹을 걸어 송신버퍼의 공간이 확보되면

수신버퍼의 데이터를 통신하고 자리가 남으면 받는 원리로 작동한다고 한다.

그렇다면 내부버퍼가 꽉 차버리는 경우는 어떻게 될까?

파이썬 기준으로 내부버퍼가 꽉차면 flush()를 하여 공간을 확보하는 경우도 있으며, 블로킹이 걸릴 수 도 있다고 한다

만일 내부버퍼에 ‘바보’, ‘말미잘’, ‘멍청이’ 가 들어있는 상태인데

송신버퍼에는 당장에 2개의 글자수만 가져올 수 있는 상황이라고 가정해봤을 때

나는 통신버퍼에 2자리 밖에 남지 않아, 바보가 쌓일 줄 알았다.

하지만 그렇지 않고 ‘멍청’ 까지만 쌓고 ‘바보말미잘멍청’ 은 내부버퍼에 남아있는 상태가 되는 것이다

그리고 위와 같은 상태가 되었을 떄, 서로에 대한 경계가 없어져버린 것이 아닌가? 했지만

바이트(멍청이)길이/바이트(멍청이) 이런식으로 데이터가 형성되어 쭉 쌓여있는 모습이여서 수신 측에서는 바이트길이를 읽고 데이터를 읽을 수 있는 원리로 돌아간다고 한다

 

버퍼의 동작 원리이해를 돕기 위한 발그림