>Backouts_
Published on

TLS 시리즈 01: Python 채팅 앱 만들기

Authors
  • avatar
    Name
    Backouts
    Twitter

한동안 고민을 조금 했습니다.
배운 내용을 그냥 글로 정리할지,
아니면 직접 실습을 하며 그 과정을 블로그에 담을지
결론은 실습과정을 담는 게 저한테 더 맞다는 생각을 하게 되어
최근에 배운 TLS 관련 내용을 바탕으로
실습을 해보기로 했습니다.

TLS 시리즈의 예상 순서는 다음과 같습니다.

  1. Python 채팅앱 만들기 (이번 글)
  2. RSA를 이용한 키 교환 구현하기
  3. AES데이터 암호화해보기
  4. ECDHE 적용해보기
  5. HMAC, AEAD 적용해보기

다만 알고리즘을 직접 만드는 것이 아니기에
그냥 TLS v1.2에서 TLS v1.3으로 넘어가는 과정을
간단히 체험해보기 위한 글이라고 생각해주셨으면 합니다.


🤔 TCP란 ?

TCP는 네트워크에서 데이터를 안전하게 주고받기 위해 만들어진 통신 규칙 입니다.
네트워크에는 수많은 컴퓨터와 장비가 연결되어 있고,
서로 원활하게 소통할 수 있도록 미리 정해둔 규칙(Protocol) 이 필요한데,
그 중 하나가 바로 TCP입니다.

🛜 데이터가 전송되는 방식

TCP는 데이터를 패킷(Packet) 이라는 작은 조각으로 나누어 전송합니다.
그리고 각 패킷에는 다음과 같은 정보들이 포함됩니다.

  • 출발지 / 목적지 주소 (IP)
  • 전송 순서를 나타내는 seq 번호 (Sequence Number)
예시)
    [seq=1 data="Hello, "]
    [seq=2 data="Backouts"]

수신측은 seq 번호를 기준으로 패킷을 다시 조립하여 원래의 데이터로 복구합니다.
만약 중간에 패킷이 유실되거나 순서가 바뀌더라도,
TCP는 자동으로 재전송 요청재정렬을 수행하여
사용자가 보는 화면에는 정상적인 데이터가 표시되도록 보장해줍니다.

TCP/UDP 이미지
출처: https://www.colocationamerica.com/blog/tcp-ip-vs-udp

⚠️ 중요한 특징

이번 글에서의 핵심이며, 채팅앱을 만들때 생각해야 할 문제입니다.

TCP스트림입니다.
즉, 데이터가 끊김없이 한 줄로 이어져서 전송된다는 뜻입니다.

Hello, Backouts|TEST|12345

위와 같이 데이터가 이어져서 애플리케이션에 도착하지만,
이 데이터의 경계가 표시되어 있지 않아
어디서부터 어디까지가 하나의 메시지인지 구분할 수 없습니다.

대부분의 TCP 서비스는 이 문제를 프레이밍으로 해결합니다.
나중에 코드를 설명할때 다시 다루겠지만,
프레이밍이란 메시지에 헤더를 붙여서
메시지의 길이를 미리 알려주는 기법입니다.

예를 들어,

0004TEST|0015Hello, Backouts

위와 같이 메시지 앞에 글자수를 붙여주면, 어디서부터 어디까지가 하나의 메시지인지 구분이 가능합니다.

✅ 간단 요약

  • TCP는 데이터를 패킷으로 나누어 전송
  • 패킷에는 출발지/목적지 주소와 seq 번호가 포함됨
  • 수신측은 seq 번호를 기준으로 패킷을 재조립하여 원래 데이터 복구
  • TCP스트림이므로 메시지 경계가 없음

✏️ 프레이밍 없이 채팅앱 만들어보기

TCP가 어떤 방식으로 동작하는지 확인하기 위해
우선 아주 간단한 1:1 채팅 프로그램을 만들어보겠습니다.

프레이밍 없이, socket 라이브러리sendrecv만 사용해서 통신하는 구조입니다.
결과물이 잘 동작하는 것처럼 보이지만,
실제로는 뒤에서 설명할 문제(메시지 잘림 등)가 숨어있습니다.

서버 코드

📌 server.py 코드 펼치기
import socket
import threading

HOST = '127.0.0.1'
PORT = 30003
stop_event = threading.Event()

def recv_thread(conn):
    try:
        while not stop_event.is_set():
            data = conn.recv(1024)
            if not data:
                break
            print(f"[Client] {data.decode()}")
            print("> ", end="", flush=True)
    except (ConnectionResetError, OSError) as e:
        print(f'\n[*] 수신 중 오류: {e}')
    finally:
        stop_event.set()
        conn.close()


def send_thread(conn):
    try:
        while not stop_event.is_set():
            try:
                print('> ', end='', flush=True)
                msg = input()
            except KeyboardInterrupt:
                print('\n[*] 서버 종료 중...')
                stop_event.set()
                break
            except EOFError:
                print('\n[*] EOF 감지, 서버 종료 중...')
                stop_event.set()
                break

            if not msg:
                continue

            try:
                conn.sendall(msg.encode())
            except (BrokenPipeError, OSError, ConnectionResetError) as e:
                print(f'\n[*] 메시지 전송 오류: {e}')
                stop_event.set()
                break
    finally:
        conn.close()

def main():
    # 1. 소켓 생성
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    try:
        # 2. 바인딩
        sock.bind((HOST, PORT))

        # 3. 리스닝
        sock.listen()
        print(f"[Server] {HOST}:{PORT} 대기...")

        # 4. 접속 허용
        conn, addr = sock.accept()
        print(f"[Server] {addr} 접속 허용")

        # 5. 채팅 스레드 시작
        recv_t = threading.Thread(target=recv_thread, args=(conn,))
        send_t = threading.Thread(target=send_thread, args=(conn,))
        recv_t.start()
        send_t.start()

        try:
            while not stop_event.is_set():
                # 메인 스레드 wait
                recv_t.join(timeout=0.5)
                send_t.join(timeout=0.5)
                if not recv_t.is_alive() or not send_t.is_alive():
                    stop_event.set()
        except KeyboardInterrupt:
            print("\n[*] `Ctrl + C 감지` 서버 종료 중...")
            stop_event.set()
    finally:
        sock.close()
        print("\n[Server] 서버 종료")

if __name__ == "__main__":
    main()

클라이언트 코드

📌 client.py 코드 펼치기
import socket
import threading

HOST = '127.0.0.1'
PORT = 30003

stop_event = threading.Event()

def recv_thread(sock):
    try:
        while not stop_event.is_set():
            data = sock.recv(1024)
            if not data:
                print("\n[*] 서버 연결이 끊어졌습니다.")
                break
            print(f"[Server] {data.decode()}")
            print("> ", end="", flush=True)
    except (ConnectionResetError, OSError) as e:
        print(f'\n[*] 수신 중 오류: {e}')
    finally:
        stop_event.set()
        sock.close()

def send_thread(sock):
    try:
        while not stop_event.is_set():
            try:
                print("> ", end="", flush=True)
                msg = input()
            except KeyboardInterrupt:
                print("\n[*] `Ctrl+C 감지` 클라이언트 종료.")
                stop_event.set()
                break
            except EOFError:
                print("\n[*] `EOF 감지` 클라이언트 종료.")
                stop_event.set()
                break
            if not msg:
                continue

            try:
                sock.sendall(msg.encode())
            except (BrokenPipeError, OSError, ConnectionResetError) as e:
                print(f'\n[*] 메시지 전송 오류: {e}')
                stop_event.set()
                break
    finally:
        sock.close()

def main():
    # 1. 소켓 생성
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    try:
        # 2. 서버에 접속
        sock.connect((HOST, PORT))
        print(f'[Client] {HOST}:{PORT} 서버에 접속했습니다.')

        # 3. 채팅 스레드 시작
        recv_t = threading.Thread(target=recv_thread, args=(sock,))
        send_t = threading.Thread(target=send_thread, args=(sock,))
        recv_t.start()
        send_t.start()

        try:
            while not stop_event.is_set():
                recv_t.join(0.5)
                send_t.join(0.5)
                if not recv_t.is_alive() or not send_t.is_alive():
                    stop_event.set()
        except KeyboardInterrupt:
            print("\n[*] `Ctrl+C 감지` 클라이언트 종료.")
            stop_event.set()
    finally:
        sock.close()
        print("\n[Client] 클라이언트 종료.")

if __name__ == '__main__':
    main()

✅ 동작 방식

  1. 서버를 먼저 실행해야 합니다. python server.py
  2. 그런 다음 클라이언트를 실행합니다. python client.py
  3. 클라이언트와 서버가 연결되면, 양쪽에서 메시지를 주고받을 수 있습니다.

⚠️ 문제점: 메시지 잘림

메시지 잘림 말고도 try를 너무 남발하는 등의 문제가 있습니다만...
이건 넘어가도록 하겠습니다.

  • 소규모 텍스트에서는 전혀 문제가 없어 보입니다.
    • "Hello, Backouts"
    • "Hi evreryone!"
  • 대규모 텍스트에서는 1024 바이트로 잘리게 됩니다. recv(1024)
    • 채팅앱에서 상대가 보낼 메시지의 크기를 미리 알 수 없습니다.
    • 따라서 메시지가 중간에 잘릴 수 있습니다.
소규모 텍스트
대규모 텍스트
대규모 텍스트는 잘림

🤔 문제가 발생한 원인과 해결법

앞에서 설명했듯이 TCP바이트 스트림 기반입니다. TCP는 데이터의 순서를 보장하지만,
데이터의 경계를 알려주지 않습니다.

그래서 실제 송수신 중 이런 일도 일어납니다.

  • 두 메시지가 하나로 합쳐져 도착
    • 예: "Hello, " + "Backouts" -> "Hello, Backouts"
  • 하나의 메시지가 여러 조각으로 나뉘어 도착
    • 예: "This is a long message" -> "This is a " + "long message"

그래서 TCP를 이용한 서비스의 경우
안정적인 통신을 위해서 프레이밍 기법을 반드시 사용합니다.

  • HTTP 헤더의 Content-Length
  • WebSocket의 Payload Length
  • TLS의 Record Length
  • FTP의 CRLF

등 다양한 서비스들이 제각각의 프레이밍 기법을 사용하고 있습니다.
이중 제가 선택한 방법은 메시지 앞에 길이를 붙여주는 헤더 방식입니다.


⌨️ 프레이밍(Length-Prefix) 구현하기

앞에서 봤듯이, TCP는 메시지 경계를 구분하지 않기 때문에
우리가 직접 "여기까지가 하나의 데이터다”라는 정보를 붙여서 보내줘야 합니다.

🤔 프레이밍 방식 설계

저는 길이 기반 프레이밍을 사용할 것이기 때문에
다음과 같이 일반적인 형식을 사용할 예정입니다.

[앞 4바이트: 메시지의 길이][그 뒤에 실제 메시지]
  • 길이는 4바이트의 *정수(unsigned int)*로 표현함
  • Network byte order빅 엔디안 방식을 사용
  • 실제 메시지는 UTF-8인코딩한 바이트를 붙여서 전송

이렇게 전송하면 수신측은

  1. 앞의 4바이트를 읽어 메시지의 길이를 구함
  2. 그 길이만큼 recv를 해서 딱 메시지의 크기만큼 읽어들임

이런 식으로 통신이 가능합니다.

✅ 공통 유틸 생성하기

📌 utils.py 코드 펼치기
import struct

# 바이트 블록을 송신하는 함수
def send_block(sock, data):
    length = len(data)
    # 데이터의 길이를 4바이트로 패킹하여 전송함
    sock.sendall(struct.pack("!I", length) + data)

# 바이트 블록을 수신하는 함수
def recv_block(sock):
    # 4바이트를 먼저 수신하여 데이터 전체의 길이를 알아냄
    header = sock.recv(4)
    if not header:
        # 헤더가 없을때 에러
        raise ConnectionError("헤더가 없습니다.")
    length = struct.unpack("!I", header)[0]

    buf = b''
    # 지정된 길이만큼의 데이터를 수신하여 버퍼에 저장
    while len(buf) < length:
        chunk = sock.recv(length - len(buf))
        if not chunk:
            raise ConnectionError("데이터 수신에 실패했습니다.")
        buf += chunk
    return buf

아래 코드에서 사용된 struct.pack() 함수는 파이썬의 객체(정수, 문자열 등)를
네트워크에서 사용하는 바이너리 데이터 형식(bytes) 으로 변환해주는 함수입니다.
네트워크 통신은 텍스트가 아니라 바이트 스트림으로 데이터를 주고받기 때문에,
전송하려는 값을 정확한 바이트 형태로 변환해야 합니다.

pack() 함수에서 사용된 " !I "는 변환 규칙을 의미합니다.

문법의미
!네트워크 표준인 Big Endian 방식으로 정렬
I4바이트 unsigned int(부호 없는 정수)

즉, 아래 코드는 length 변수에 들어있는 데이터 길이를
Big Endian 방식의 4바이트 정수로 변환하여 전송하고,
그 뒤에 실제 데이터를 이어 붙인다
는 의미입니다.

이런 방식이 바로 Length-Prefix 프레이밍 기법으로,
수신 측은 먼저 4바이트 길이를 읽고 그 길이만큼 데이터를 정확히 읽을 수 있습니다.

sock.sendall(struct.pack("!I", length) + data) # 메시지 크기를 헤더에 추가
length = struct.unpack("!I", header)[0] # 헤더에서 메시지 크기를 가져옴

🖥️ 서버 코드 수정하기

📌 server_framing.py 코드 펼치기
import socket
import threading

from utils import recv_block, send_block

HOST = '127.0.0.1'
PORT = 30003
stop_event = threading.Event()

def recv_thread(conn):
    try:
        while not stop_event.is_set():
            data = recv_block(conn)
            if not data:
                break
            print(f"[Client] {data.decode()}")
            print("> ", end="", flush=True)
    except (ConnectionResetError, OSError) as e:
        print(f'\n[*] 수신 중 오류: {e}')
    finally:
        stop_event.set()
        conn.close()


def send_thread(conn):
    try:
        while not stop_event.is_set():
            try:
                print("> ", end="", flush=True)
                msg = input()
            except KeyboardInterrupt:
                print('\n[*] 서버 종료 중...')
                stop_event.set()
                break
            except EOFError:
                print('\n[*] EOF 감지, 서버 종료 중...')
                stop_event.set()
                break

            if not msg:
                continue

            try:
                send_block(conn, msg.encode())
            except (BrokenPipeError, OSError, ConnectionResetError) as e:
                print(f'\n[*] 메시지 전송 오류: {e}')
                stop_event.set()
                break
    finally:
        conn.close()

def main():
    # 1. 소켓 생성
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

    try:
        # 2. 바인딩
        sock.bind((HOST, PORT))

        # 3. 리스닝
        sock.listen()
        print(f"[Server] {HOST}:{PORT} 대기...")

        # 4. 접속 허용
        conn, addr = sock.accept()
        print(f"[Server] {addr} 접속 허용")

        # 5. 채팅 스레드 시작
        recv_t = threading.Thread(target=recv_thread, args=(conn,))
        send_t = threading.Thread(target=send_thread, args=(conn,))
        recv_t.start()
        send_t.start()

        try:
            while not stop_event.is_set():
                # 메인 스레드 wait
                recv_t.join(timeout=0.5)
                send_t.join(timeout=0.5)
                if not recv_t.is_alive() or not send_t.is_alive():
                    stop_event.set()
        except KeyboardInterrupt:
            print("\n[*] `Ctrl + C 감지` 서버 종료 중...")
            stop_event.set()
    finally:
        sock.close()
        print("\n[Server] 서버 종료")

if __name__ == "__main__":
    main()

💻 클라이언트 코드 수정하기

📌 client_framing.py 코드 펼치기
import socket
import threading

from utils import recv_block, send_block

HOST = '127.0.0.1'
PORT = 30003

stop_event = threading.Event()

def recv_thread(sock):
    try:
        while not stop_event.is_set():
            data = recv_block(sock)
            if not data:
                print("\n[*] 서버 연결이 끊어졌습니다.")
                break
            print(f"[Server] {data.decode()}")
            print("> ", end="", flush=True)
    except (ConnectionResetError, OSError) as e:
        print(f'\n[*] 수신 중 오류: {e}')
    finally:
        stop_event.set()
        sock.close()

def send_thread(sock):
    try:
        while not stop_event.is_set():
            try:
                print("> ", end="", flush=True)
                msg = input()
            except KeyboardInterrupt:
                print("\n[*] `Ctrl+C 감지` 클라이언트 종료.")
                stop_event.set()
                break
            except EOFError:
                print("\n[*] `EOF 감지` 클라이언트 종료.")
                stop_event.set()
                break
            if not msg:
                continue

            try:
                send_block(sock, msg.encode())
            except (BrokenPipeError, OSError, ConnectionResetError) as e:
                print(f'\n[*] 메시지 전송 오류: {e}')
                stop_event.set()
                break
    finally:
        sock.close()

def main():
    # 1. 소켓 생성
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    try:
        # 2. 서버에 접속
        sock.connect((HOST, PORT))
        print(f'[Client] {HOST}:{PORT} 서버에 접속했습니다.')

        # 3. 채팅 스레드 시작
        recv_t = threading.Thread(target=recv_thread, args=(sock,))
        send_t = threading.Thread(target=send_thread, args=(sock,))
        recv_t.start()
        send_t.start()

        try:
            while not stop_event.is_set():
                recv_t.join(0.5)
                send_t.join(0.5)
                if not recv_t.is_alive() or not send_t.is_alive():
                    stop_event.set()
        except KeyboardInterrupt:
            print("\n[*] `Ctrl+C 감지` 클라이언트 종료.")
            stop_event.set()
    finally:
        sock.close()
        print("\n[Client] 클라이언트 종료.")

if __name__ == '__main__':
    main()

서버 코드와 클라이언트 코드에서 수정한 부분은 단 하나입니다.
기존에는 send()recv() 함수를 통해 데이터를 그대로 주고받았지만,
이제는 utils.py에 정의한 send_block()recv_block() 함수를 사용해 통신합니다.

변경 전변경 후
sock.sendall(msg.encode())send_block(sock, msg.encode())
sock.recv(1024)recv_block(sock)

🛡️ 프레이밍 테스트 하기

이전에 send, recv를 사용할때 문자가 잘렸던
대용량 텍스트를 전송해보았습니다.

대규모 텍스트
대규모 텍스트도 안 잘림

한번 데이터가 어떻게 전송됐는지 패킷을 보겠습니다.

프레이밍 패킷 캡쳐

위 사진은 대용량 텍스트를 보낼 때 패킷을 캡쳐한 사진입니다.
암호화가 되어 있지 않아 제가 보낸 텍스트가 평문으로 보이죠?
그중 윗부분 그러니까 00 00 06 ba 부분이 바로 제가 추가한
4바이트인 문자 길이를 나타내는 헤더입니다.

0x06BA가 텍스트의 길이를 나타내니
제가 보낸 텍스트는 1722자 라는걸 알 수 있습니다.

0x06BA = (0x06 * 256) + 0xBA
       = (6 * 256) + 186
       = 1536 + 186
       = 1722

✏️ 정리하기

지금까지 한 실습을 정리하면

  • TCP 특징 알아보기

    • 순서 보장 O
    • 신뢰성 O
    • 메시지 경계 X
  • 프레이밍 도입 전

    • 짧은 문자열 위주로 테스트하면 되는 것처럼 보임
    • 메시지 길어질 때 데이터 잘림
  • 프레이밍 도입 후

    • 우리가 정의한 메시지 단위로 정확히 송수신
    • 암호화(AES, RSA)를 구현 할 수 있는 토대가 마련됨

이제 안정적인 통신이 가능해졌으니
암호화를 할 토대가 마련된것 같습니다.

다음편에서는 RSA를 이용해 키 교환을 구현해보기로 하겠습니다.


이번 글에서 처음으로 이모지를 써봤는데 생각보다 재밌네요