내 마음대로 네트워크 시리즈 - 4

김경준·2025년 12월 1일

네트워크

목록 보기
4/8

소스코드

https://github.com/kyeongjun-dev/network

개요

실제 백엔드 서비스에서 테스트해보기

시리즈3에서는 파이썬으로 만든 client, server 스크립트 파일로 테스트를 진행하고 tcpdump를 이용해 패킷을 분석했다. 이번 글에서는 실제 백엔드 서버를 server 역할로 두고 테스트를 진행해본다.
사용하는 백엔드는 gunicorn과 flask다.

백엔드 서버 만들기

gunicorn, flask 코드 작성 (소스코드 : 레포 06/flask 디렉토리)

먼저 flask 코드(06/flask/app.py)다. 정말 간단하게 /로 접근 시, 문자열을 return (status : 200) 한다.

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello():
    return "Hello from Flask App behind Gunicorn!"

설치하는 파이썬 패키지는 아래와 같다. (06/flask/requirements.txt)

Flask==3.1.2
gunicorn==23.0.0
gevent==25.9.1

패키지 별로 간단히 설명을 붙이자면 아래와 같다.

  • flask : API 서버 역할을 수행.
  • gunicorn : WSGI로, flask 앞단에서 client와의 연결 수행.
  • gevent : gunicorn의 기본 워커는 동기(sync)인데, gevent를 워커타입으로 설정하여 비동기(async)로 사용 - gunicorn의 경우 워커가 비동기여야keep alive 설정이 적용됨 (공식문서링크)

다음은 실행 명령어다. (06/flask/cmd)

gunicorn --workers 1 -k gevent --bind 0.0.0.0:8000 --timeout 10 --keep-alive 10 app:app --log-level debug

실행 명령어 설명은 아래와 같다. 자세한 설명은 공식문서링크 참고 가능

  • --workers : gunicorn을 실행할 때, 워커 개수를 지정한다.
  • -k(--worker-class) : 실행하는 워커 타입을 지정한다. 기본값은 sync
  • --bind : 주소랑 포트를 지정해서 실행
  • --timeout : 워커가 동작을 처리하는 최대 시간을 지정. 이 시간을 초과하도록 작업을 끝내지 못하면, 작업을 진행중인 워커 프로세스는 종료된다.
  • --keep-alive : client랑 Keep-Alive 연결을 지속하는 시간을 지정. 이 시간을 초과하도록 아무런 작업(패킷교환)이 없으면, 연결을 종료한다.
  • --log-level : gunicorn의 로그레벨을 설정. debug로 설정하고 gunicorn을 실행하면, 현재 설정된 값들을 확인할 수 있다.

클라이언트 만들기

파이썬으로 스크립트 작성

06/client.py 파일로, 실행할 때 요청을 보내는 time interval을 매개변수로 입력한다. 입력하지 않으면, 기본 값으로 60초 간격으로 요청을 보낸다.

import socket
import time
import errno
import sys  # 1. sys 모듈 임포트

# --- TIME_INTERVAL 설정 로직 ---
TIME_INTERVAL = 60  # 2. 기본값 60초

try:
    if len(sys.argv) > 1:
        # 3. 커맨드라인 인자(argv[1])가 있으면, 그것을 TIME_INTERVAL로 사용
        TIME_INTERVAL = int(sys.argv[1])
    else:
        # 4. 커맨드라인 인자가 없으면, 사용자에게 입력받음
        user_input = input('insert TIME_INTERVAL (default 60): ')
        
        if user_input:
            # 5. 사용자가 값을 입력한 경우
            TIME_INTERVAL = int(user_input)
        # (사용자가 아무것도 입력하지 않으면(Enter), 기본값 60이 사용됨)

except ValueError:
    print(f"Error: Invalid input. Using default TIME_INTERVAL = 60s.")
    TIME_INTERVAL = 60
except KeyboardInterrupt:
    print("\nCanceled by user. Exiting.")
    sys.exit(0)
# ------------------------------


# --- 설정 ---
HOST = 'localhost'
PORT = 8000
YOUR_HOST_HEADER = 'localhost'
# -----------

# 사용할 요청 (GET /)
REQUEST_KEEP_ALIVE = (
    f"GET / HTTP/1.1\r\n"
    f"Host: {YOUR_HOST_HEADER}\r\n"
    # keep alive 연결 명시
    f"Connection: keep-alive\r\n"
    f"\r\n"
).encode('utf-8')


print(f"\n--- Keep-Alive Test (Non-TLS) ---")
# 6. http:// 스키마 및 포트 번호 명시
print(f"Target: http://{HOST}:{PORT}") 
print(f"Sending 'GET /' request every {TIME_INTERVAL} seconds.")
print("Press Ctrl+C to stop the test.")

s = None # finally 블록에서 s를 참조할 수 있도록 외부에 선언
try:
    # 1. 소켓 생성 연결 (Non-TLS)
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    print(f"\nConnecting to {HOST}:{PORT} (TCP)...")
    s.connect((HOST, PORT))
    print("TCP Connected!")

    # 2. 첫 번째 요청 전송 (연결 확인용)
    print("\n--- Sending first request (GET /) ---")
    s.sendall(REQUEST_KEEP_ALIVE)
    
    # 3. 첫 번째 응답 수신
    response = s.recv(4096)
    if not response:
        print("\n*** TEST FAILED: Received empty response on first request. ***")
        raise socket.error("Server returned empty response on first request")
        
    print("--- Received first response ---")
    print(response.decode('utf-8', errors='ignore').split('\r\n')[0])

    # 4. TIME_INTERVAL 간격으로 요청 무한 반복
    count = 1
    while True:
        print(f"\n--- Waiting for {TIME_INTERVAL} seconds... ---")
        time.sleep(TIME_INTERVAL)
        
        count += 1
        print(f"--- Sending keep-alive request #{count} (GET /) ---")
        s.sendall(REQUEST_KEEP_ALIVE)
        
        # 응답 수신
        response = s.recv(4096)
        
        # 서버가 연결을 닫았는지 확인 (0바이트 수신)
        if not response:
            print("\n*** TEST FAILED: Server closed connection (recv() returned 0 bytes) ***")
            raise socket.error(errno.ECONNRESET, "Connection closed by peer (recv() returned 0)")
        
        print(f"--- Received response #{count} ---")
        print(response.decode('utf-8', errors='ignore').split('\r\n')[0])


except socket.error as e:
    # 5. 연결이 끊어지면 "실패"로 간주 (Keep-Alive 실패)
    if e.errno in (errno.ECONNRESET, errno.EPIPE):
        print(f"\n*** TEST FAILED: Connection was reset by peer! ***")
        print(f"Error (Code: {e.errno}): {e.strerror}")
    else:
        print(f"\n*** TEST FAILED: Caught unexpected socket error ***")
        print(f"Error (Code: {e.errno}): {e.strerror}")
except KeyboardInterrupt:
    # 6. 사용자가 Ctrl+C로 정상 종료
    # (루프가 시작되기 전에 중단될 경우 'count' 변수가 없을 수 있어 'locals()'로 확인)
    req_count_str = f" after {count} requests" if 'count' in locals() else ""
    print(f"\n\n--- Test manually interrupted by user (Ctrl+C){req_count_str}. ---")
    print("*** TEST STOPPED ***")
except Exception as e:
    print(f"\n*** TEST FAILED: Caught a non-socket error ***")
    print(f"Error: {e}")

finally:
    if s:
        s.close()
        print("\nSocket closed.")

ping-pong 테스트하기

로컬에서 테스트하기 (소스코드 : 레포 06 디렉토리)

먼저 백엔드 서버를 실행한다.

// 06/flask 디렉토리로 이동
cd 06/flask

// venv 생성
python3.13 -m venv venv

// venv 활성화
source venv/bin/activate

// 파이썬 패키지 설치
pip install -r requirements.txt

// gunicorn 백엔드 서버 실행
gunicorn --workers 1 -k gevent --bind 0.0.0.0:8000 --timeout 10 --keep-alive 10 app:app --log-level debug

다음으로 06/client.py를 8초로 실행한다.

python3.13 client.py 8

8초 간격으로 GET 요청이 잘 전달되는 것을 확인할 수 있다.

다음으로 06/client.py를 11초롤 실행한다.

python3.13 client.py 11

8초로 실행했을 때와 달리 2번째 요청부터 바로 실패하는 것을 확인할 수 있다.

docker compose로 패킷 확인해보기 (소스코드 : 레포 07 디렉토리)

07/client/client.py를 8초, 15초(11초로 했을 시, 요청이 성공하는 케이스가 존재함)로 실행했을 때의 패킷을 tcpdump랑 wireshark로 확인해보자.

import socket
import time
import errno
import sys  # 1. sys 모듈 임포트

# --- TIME_INTERVAL 설정 로직 ---
TIME_INTERVAL = 60  # 2. 기본값 60초

try:
    if len(sys.argv) > 1:
        # 3. 커맨드라인 인자(argv[1])가 있으면, 그것을 TIME_INTERVAL로 사용
        TIME_INTERVAL = int(sys.argv[1])
    else:
        # 4. 커맨드라인 인자가 없으면, 사용자에게 입력받음
        user_input = input('insert TIME_INTERVAL (default 60): ')
        
        if user_input:
            # 5. 사용자가 값을 입력한 경우
            TIME_INTERVAL = int(user_input)
        # (사용자가 아무것도 입력하지 않으면(Enter), 기본값 60이 사용됨)

except ValueError:
    print(f"Error: Invalid input. Using default TIME_INTERVAL = 60s.")
    TIME_INTERVAL = 60
except KeyboardInterrupt:
    print("\nCanceled by user. Exiting.")
    sys.exit(0)
# ------------------------------


# --- 설정 ---
HOST = 'server'
PORT = 8000
YOUR_HOST_HEADER = 'server'
# -----------

# 사용할 요청 (GET /)
REQUEST_KEEP_ALIVE = (
    f"GET / HTTP/1.1\r\n"
    f"Host: {YOUR_HOST_HEADER}\r\n"
    # keep alive 연결 명시
    f"Connection: keep-alive\r\n"
    f"\r\n"
).encode('utf-8')


print(f"\n--- Keep-Alive Test (Non-TLS) ---")
# 6. http:// 스키마 및 포트 번호 명시
print(f"Target: http://{HOST}:{PORT}") 
print(f"Sending 'GET /' request every {TIME_INTERVAL} seconds.")
print("Press Ctrl+C to stop the test.")

s = None # finally 블록에서 s를 참조할 수 있도록 외부에 선언
try:
    # 1. 소켓 생성 연결 (Non-TLS)
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    print(f"\nConnecting to {HOST}:{PORT} (TCP)...")
    s.connect((HOST, PORT))
    print("TCP Connected!")

    # 2. 첫 번째 요청 전송 (연결 확인용)
    print("\n--- Sending first request (GET /) ---")
    s.sendall(REQUEST_KEEP_ALIVE)
    
    # 3. 첫 번째 응답 수신
    response = s.recv(4096)
    if not response:
        print("\n*** TEST FAILED: Received empty response on first request. ***")
        raise socket.error("Server returned empty response on first request")
        
    print("--- Received first response ---")
    print(response.decode('utf-8', errors='ignore').split('\r\n')[0])

    # 4. TIME_INTERVAL 간격으로 요청 무한 반복
    count = 1
    while True:
        print(f"\n--- Waiting for {TIME_INTERVAL} seconds... ---")
        time.sleep(TIME_INTERVAL)
        
        count += 1
        print(f"--- Sending keep-alive request #{count} (GET /) ---")
        s.sendall(REQUEST_KEEP_ALIVE)
        
        # 응답 수신
        response = s.recv(4096)
        
        # 서버가 연결을 닫았는지 확인 (0바이트 수신)
        if not response:
            print("\n*** TEST FAILED: Server closed connection (recv() returned 0 bytes) ***")
            raise socket.error(errno.ECONNRESET, "Connection closed by peer (recv() returned 0)")
        
        print(f"--- Received response #{count} ---")
        print(response.decode('utf-8', errors='ignore').split('\r\n')[0])


except socket.error as e:
    # 5. 연결이 끊어지면 "실패"로 간주 (Keep-Alive 실패)
    if e.errno in (errno.ECONNRESET, errno.EPIPE):
        print(f"\n*** TEST FAILED: Connection was reset by peer! ***")
        print(f"Error (Code: {e.errno}): {e.strerror}")
    else:
        print(f"\n*** TEST FAILED: Caught unexpected socket error ***")
        print(f"Error (Code: {e.errno}): {e.strerror}")
except KeyboardInterrupt:
    # 6. 사용자가 Ctrl+C로 정상 종료
    # (루프가 시작되기 전에 중단될 경우 'count' 변수가 없을 수 있어 'locals()'로 확인)
    req_count_str = f" after {count} requests" if 'count' in locals() else ""
    print(f"\n\n--- Test manually interrupted by user (Ctrl+C){req_count_str}. ---")
    print("*** TEST STOPPED ***")
except Exception as e:
    print(f"\n*** TEST FAILED: Caught a non-socket error ***")
    print(f"Error: {e}")

finally:
    if s:
        try:
            # 소켓을 닫기 전에 남은 데이터를 싹 비웁니다.
            s.settimeout(0.1) # 타임아웃을 짧게 설정
            while True:
                data = s.recv(4096)
                if not data: break
        except Exception:
            pass # 타임아웃이나 에러가 나면 그냥 무시
            
        s.close() # 이제 버퍼가 비었으므로 FIN을 보냄
        print("\nSocket closed.")

8초 간격으로 요청 보내기

먼저 docker-compose로 client, server 컨테이너를 실행한다.

docker-compose up --build

client, server 컨테이너에 각각 tcpdump를 실행한다. - 8000번 포트로 변경됐다.

docker exec -it client tcpdump -i any -n 'port 8000' -w /app/captures/client.pcap
docker exec -it server tcpdump -i any -n 'port 8000' -w /app/captures/server.pcap

먼저 client.py를 8초로 실핸한다.

docker exec -it client python client.py 8

5회만 요청을 확인한 뒤, Ctrl + C로 client를 종료한다.

tcpdump를 실행한 터미널에서도 Ctrl + C로 종료한 뒤, client_vol, server_vol에 생성된 .pcap 파일을 확인한다.

8초로 실행했을 때의 패킷을 wireshark로 확인한다.

패킷 분석은 간단하게, 5번 요청을 보내고 36초에 client(172.22.0.2)에서 server(172.22.0.3)에 FIN 패킷을 전송하고 연결이 끝난다.

15초 간격으로 요청 보내기

docker compose down으로 종료한 뒤, client_vol, server_vol 디렉토리를 삭제한 뒤 동일하게 docker compose up과 tcpdump까지 실행한다.

docker-compose down -v
rm -rf client_vol server_vol

docker-compose up --build
docker exec -it client tcpdump -i any -n 'port 8000' -w /app/captures/client.pcap
docker exec -it server tcpdump -i any -n 'port 8000' -w /app/captures/server.pcap
docker exec -it client python client.py 15

15초 후의 요청이 바로 실패하는 것을 확인할 수 있다.

tcpdump한 파일을 wireshark로 확인해보면 아래와 같다.

client.pcap 기준, 시간대별로 해석해보면 아래와 같다. client(172.22.0.2), server(172.22.0.3)

  • 0 ~ 0.000026 : tcp 연결
  • 0.000071 ~ 0.000075 : 열결 확인용 첫 번째 GET 요청 전송
  • 0.001427 ~ 0.001456 : 첫 번째 응답 수신
  • 10.004 : server에서 client에 FIN 패킷 전달
  • 10.045 : client에서 server에 ACK 패킷 전달 >> tcp 연결 종료 - gunicorn의 keep-alive 10초 설정의 효과
  • 15.004438 : client가 연결이 닫힌 줄 모르고, GET 요청을 server에 전달
  • 15.004490 : server가 client에게 RST 패킷을 전달해서, 이미 닫힌 연결임을 알림

정리 및 다음 글에서는...

이번 글에서는 실제 백엔드 서버 중 하나인 gunicorn에서 keep-alive가 어떻게 동작하는지 확인했다. 하나 확인할 수 있는 것은 아래와 같다.

client에서는 server의 keep-alive 설정 시간 내에 작업을 끝내고, 연결을 명시적으로 끊어줘야 한다.
keep-alive 연결을 계속 이용하려면, client에서 server에 '이 연결은 유효한 연결이에요'를 server의 keep-alive 시간 내에 계속 알려줘야 한다.

지금까지는 로컬에서 docker compose를 이용해 client, server 컨테이너 2개만 이용하여 테스트를 진행했다. 하지만 실제 환경에서 위처럼 사용하는 경우는 없다. 대부분의 경우, 아래와 같이 중간에 load balancer가 쓰이기 때문이다.
1. client > load balancer > server
2. client > load balancer > proxy server > server

이제 AWS 환경에서 많이 사용되는 NLB, ALB를 사이에 두고 테스트랑 패킷 분석을 해보자.

profile
DevOps로 일하고 있습니다

0개의 댓글