콘텐츠 대표 이미지 - HTTP 프록시 서버 소스코드 구현 완전정복 🚀Python으로 직접 만드는 프록시 서버 — 개념부터 실전 코드까지
🛠️ 프로그램/소스 · 프로그램개발

HTTP 프록시 서버 소스코드 구현 완전정복 🚀
Python으로 직접 만드는 프록시 서버 — 개념부터 실전 코드까지

네트워크의 중간자, 프록시 서버를 직접 코딩해보자! 🌐

💻 클라이언트 웹 브라우저 / 앱 HTTP 요청 🔀 프록시 서버 중간 중계자 캐싱 · 필터링 · 로깅 익명성 · 로드밸런싱 요청 전달 🌐 웹 서버 목적지 서버 응답 반환 응답 전달 HTTP 프록시 서버의 동작 흐름 클라이언트 ↔ 프록시 ↔ 목적지 서버
🤔 프록시 서버가 뭔데? 왜 만들어야 해?

야, 솔직히 말해보자. 프록시 서버라는 단어, 들어본 적 있지? VPN 쓸 때, 회사 네트워크 설정할 때, 아니면 크롤링 막힐 때 "프록시 써봐"라는 말 한 번쯤은 들어봤을 거야. 근데 막상 "그게 뭔데?"라고 물어보면 대답하기 애매하잖아. 오늘은 그 프록시 서버를 직접 소스코드로 구현하면서 완벽하게 이해해보자! 🎯

프록시(Proxy)는 영어로 "대리인"이라는 뜻이야. 클라이언트(너의 브라우저)와 서버(네이버, 구글 같은 웹사이트) 사이에서 중간에서 요청을 대신 처리해주는 서버가 바로 프록시 서버야. 마치 심부름꾼처럼, 네가 "저 서버에 이 요청 좀 전달해줘"라고 하면 프록시가 대신 가서 받아오는 거지.

💡 프록시 서버가 쓰이는 실제 상황들
🔹 기업 방화벽 — 직원들의 인터넷 접속을 모니터링하고 특정 사이트 차단
🔹 캐싱 서버 — 자주 요청되는 콘텐츠를 저장해서 빠르게 응답 (CDN의 원리)
🔹 익명성 보장 — 실제 IP를 숨기고 프록시 IP로 요청 (VPN과 유사)
🔹 로드 밸런싱 — 여러 서버에 트래픽을 분산 (Nginx, HAProxy)
🔹 콘텐츠 필터링 — 악성 사이트 차단, 광고 제거
🔹 API 게이트웨이 — 마이크로서비스 앞단에서 요청 라우팅

이 글에서는 Python으로 HTTP 프록시 서버를 직접 구현할 거야. 기초적인 포워드 프록시부터 시작해서, HTTPS 터널링(CONNECT 메서드), 캐싱, 로깅까지 단계별로 쌓아올릴 거거든. 코드 한 줄 한 줄 이해하면서 따라오면 네트워크 프로그래밍 실력이 확 올라갈 거야! 💪


📚 시작 전에 알아야 할 핵심 개념들
🔵 포워드 프록시 vs 리버스 프록시

프록시에는 크게 두 종류가 있어. 헷갈리기 쉬우니까 확실히 짚고 넘어가자.

🔵 포워드 프록시 (Forward Proxy)

클라이언트 쪽에 위치. 클라이언트가 외부 서버에 접근할 때 대신 요청해줌.
예시: 회사 내부망 → 프록시 → 인터넷
서버 입장에서는 프록시 IP만 보임 (클라이언트 IP 숨김)

🟢 리버스 프록시 (Reverse Proxy)

서버 쪽에 위치. 외부 클라이언트가 내부 서버에 접근할 때 앞단에서 처리.
예시: 인터넷 → Nginx → 내부 서버들
클라이언트 입장에서는 프록시만 보임 (내부 서버 숨김)

우리가 오늘 만들 건 포워드 프록시야. 클라이언트(브라우저)가 프록시 서버를 통해 외부 웹사이트에 접근하는 구조!

🔵 HTTP 요청/응답 구조 빠르게 복습

프록시를 만들려면 HTTP 메시지 구조를 알아야 해. 아주 간단하게 정리하면:

# HTTP 요청 메시지 구조
GET /index.html HTTP/1.1          ← 요청 라인 (메서드 + URL + 버전)
Host: www.example.com             ← 헤더들
User-Agent: Mozilla/5.0
Accept: text/html
Connection: keep-alive
                                  ← 빈 줄 (헤더 끝 표시)
[요청 바디 - GET은 보통 없음]

# HTTP 응답 메시지 구조
HTTP/1.1 200 OK                   ← 상태 라인
Content-Type: text/html           ← 헤더들
Content-Length: 1234
Connection: close
                                  ← 빈 줄
<html>...</html>                  ← 응답 바디
🔵 소켓(Socket) 프로그래밍 기초

프록시 서버는 결국 소켓 프로그래밍이야. 소켓은 네트워크 통신의 끝점(endpoint)이고, TCP 소켓을 통해 데이터를 주고받아. Python의 socket 모듈이 이걸 담당해.

import socket

# 서버 소켓 생성 기본 패턴
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# AF_INET = IPv4, SOCK_STREAM = TCP

server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# SO_REUSEADDR: 포트 재사용 허용 (개발 중 자주 재시작할 때 필수!)

server_socket.bind(('0.0.0.0', 8080))  # 모든 인터페이스의 8080 포트
server_socket.listen(100)               # 최대 100개 연결 대기

while True:
    client_sock, addr = server_socket.accept()  # 클라이언트 연결 수락
    # 여기서 클라이언트 요청 처리...

🏗️ 1단계: 기본 HTTP 프록시 서버 구현

자, 이제 진짜 코딩 시작이야! 먼저 가장 기본적인 형태의 HTTP 프록시 서버를 만들어보자. 이 버전은 HTTP(비암호화) 요청만 처리하는 단순한 포워드 프록시야.

📁 프로젝트 구조
proxy_server/
├── proxy.py          # 메인 프록시 서버
├── cache.py          # 캐싱 모듈 (나중에 추가)
├── logger.py         # 로깅 모듈 (나중에 추가)
└── config.py         # 설정 파일
🔧 config.py — 설정 파일
# config.py
# 프록시 서버 설정값들을 한 곳에서 관리

PROXY_HOST = '0.0.0.0'      # 모든 네트워크 인터페이스에서 수신
PROXY_PORT = 8080            # 프록시 서버 포트
BUFFER_SIZE = 4096           # 데이터 수신 버퍼 크기 (4KB)
MAX_CONNECTIONS = 200        # 최대 동시 연결 수
TIMEOUT = 10                 # 소켓 타임아웃 (초)
ENABLE_CACHE = True          # 캐싱 활성화 여부
ENABLE_LOGGING = True        # 로깅 활성화 여부
LOG_FILE = 'proxy.log'       # 로그 파일 경로

# 차단할 도메인 목록 (블랙리스트)
BLOCKED_DOMAINS = [
    'malware-site.com',
    'blocked-ads.net',
]
🚀 proxy.py — 메인 프록시 서버 (기본 버전)
# proxy.py - HTTP 프록시 서버 기본 구현
import socket
import threading
import sys
import logging
from config import *

# 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[
        logging.FileHandler(LOG_FILE),
        logging.StreamHandler(sys.stdout)
    ]
)
logger = logging.getLogger(__name__)


def parse_http_request(raw_data):
    """
    HTTP 요청 데이터를 파싱해서 딕셔너리로 반환
    raw_data: bytes 형태의 HTTP 요청
    """
    try:
        # bytes를 문자열로 디코딩 (오류 무시)
        request_text = raw_data.decode('utf-8', errors='ignore')
        
        # 헤더와 바디 분리 (\r\n\r\n 기준)
        if '\r\n\r\n' in request_text:
            header_section, body = request_text.split('\r\n\r\n', 1)
        else:
            header_section = request_text
            body = ''
        
        # 헤더 라인들로 분리
        lines = header_section.split('\r\n')
        
        # 첫 번째 줄: 요청 라인 파싱
        request_line = lines[0]
        parts = request_line.split(' ')
        
        if len(parts) < 3:
            return None
        
        method = parts[0]       # GET, POST, CONNECT 등
        url = parts[1]          # 요청 URL
        version = parts[2]      # HTTP/1.1
        
        # 헤더 파싱 (딕셔너리로 변환)
        headers = {}
        for line in lines[1:]:
            if ':' in line:
                key, value = line.split(':', 1)
                headers[key.strip().lower()] = value.strip()
        
        # URL에서 호스트와 포트 추출
        host = headers.get('host', '')
        port = 80  # HTTP 기본 포트
        
        # URL이 절대 경로인 경우 (http://host/path)
        if url.startswith('http://'):
            # http:// 제거 후 호스트와 경로 분리
            url_without_scheme = url[7:]
            if '/' in url_without_scheme:
                host_part, path = url_without_scheme.split('/', 1)
                path = '/' + path
            else:
                host_part = url_without_scheme
                path = '/'
            
            # 포트가 명시된 경우 (host:port)
            if ':' in host_part:
                host, port = host_part.split(':', 1)
                port = int(port)
            else:
                host = host_part
        else:
            path = url
        
        return {
            'method': method,
            'url': url,
            'path': path if 'path' in dir() else url,
            'version': version,
            'headers': headers,
            'host': host,
            'port': port,
            'body': body,
            'raw': raw_data
        }
    
    except Exception as e:
        logger.error(f"요청 파싱 오류: {e}")
        return None


def is_blocked(host):
    """도메인 차단 여부 확인"""
    for blocked in BLOCKED_DOMAINS:
        if blocked in host:
            return True
    return False


def forward_request(request_info):
    """
    파싱된 요청을 실제 목적지 서버로 전달하고 응답 받기
    """
    host = request_info['host']
    port = request_info['port']
    
    try:
        # 목적지 서버에 소켓 연결
        server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        server_socket.settimeout(TIMEOUT)
        server_socket.connect((host, port))
        
        # 원본 요청 전달 (그대로 전달)
        server_socket.sendall(request_info['raw'])
        
        # 응답 수신 (전체 응답 받기)
        response = b''
        while True:
            try:
                chunk = server_socket.recv(BUFFER_SIZE)
                if not chunk:
                    break
                response += chunk
            except socket.timeout:
                break
        
        server_socket.close()
        return response
    
    except socket.gaierror as e:
        logger.error(f"DNS 조회 실패 ({host}): {e}")
        return None
    except ConnectionRefusedError as e:
        logger.error(f"연결 거부 ({host}:{port}): {e}")
        return None
    except Exception as e:
        logger.error(f"요청 전달 오류: {e}")
        return None


def send_error_response(client_socket, status_code, message):
    """클라이언트에게 에러 응답 전송"""
    error_body = f"""<html>
<head><title>Proxy Error {status_code}</title></head>
<body>
<h1>Proxy Error {status_code}</h1>
<p>{message}</p>
</body>
</html>""".encode('utf-8')
    
    response = (
        f"HTTP/1.1 {status_code} Error\r\n"
        f"Content-Type: text/html\r\n"
        f"Content-Length: {len(error_body)}\r\n"
        f"Connection: close\r\n"
        f"\r\n"
    ).encode('utf-8') + error_body
    
    try:
        client_socket.sendall(response)
    except:
        pass


def handle_client(client_socket, client_address):
    """
    클라이언트 연결 처리 함수
    각 클라이언트 연결마다 별도 스레드에서 실행됨
    """
    logger.info(f"새 연결: {client_address[0]}:{client_address[1]}")
    
    try:
        # 클라이언트로부터 요청 수신
        raw_request = b''
        client_socket.settimeout(TIMEOUT)
        
        while True:
            try:
                chunk = client_socket.recv(BUFFER_SIZE)
                if not chunk:
                    break
                raw_request += chunk
                
                # HTTP 헤더 끝 확인 (\r\n\r\n)
                if b'\r\n\r\n' in raw_request:
                    # Content-Length 확인해서 바디까지 받기
                    header_end = raw_request.find(b'\r\n\r\n') + 4
                    headers_raw = raw_request[:header_end].decode('utf-8', errors='ignore')
                    
                    content_length = 0
                    for line in headers_raw.split('\r\n'):
                        if line.lower().startswith('content-length:'):
                            content_length = int(line.split(':', 1)[1].strip())
                            break
                    
                    body_received = len(raw_request) - header_end
                    if body_received >= content_length:
                        break
                        
            except socket.timeout:
                break
        
        if not raw_request:
            return
        
        # 요청 파싱
        request_info = parse_http_request(raw_request)
        
        if not request_info:
            send_error_response(client_socket, 400, "잘못된 요청입니다.")
            return
        
        method = request_info['method']
        host = request_info['host']
        
        logger.info(f"{method} {request_info['url']} → {host}:{request_info['port']}")
        
        # 차단 도메인 확인
        if is_blocked(host):
            logger.warning(f"차단된 도메인 접근 시도: {host}")
            send_error_response(client_socket, 403, f"접근이 차단된 도메인입니다: {host}")
            return
        
        # CONNECT 메서드 처리 (HTTPS 터널링)
        if method == 'CONNECT':
            handle_https_tunnel(client_socket, request_info)
            return
        
        # 일반 HTTP 요청 처리
        response = forward_request(request_info)
        
        if response:
            client_socket.sendall(response)
        else:
            send_error_response(client_socket, 502, "목적지 서버에 연결할 수 없습니다.")
    
    except Exception as e:
        logger.error(f"클라이언트 처리 오류 ({client_address}): {e}")
    
    finally:
        client_socket.close()
        logger.info(f"연결 종료: {client_address[0]}:{client_address[1]}")


def start_proxy_server():
    """프록시 서버 시작"""
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    
    try:
        server_socket.bind((PROXY_HOST, PROXY_PORT))
        server_socket.listen(MAX_CONNECTIONS)
        
        logger.info(f"🚀 HTTP 프록시 서버 시작!")
        logger.info(f"📡 수신 주소: {PROXY_HOST}:{PROXY_PORT}")
        logger.info(f"⚙️  버퍼 크기: {BUFFER_SIZE} bytes")
        logger.info(f"🔗 최대 연결: {MAX_CONNECTIONS}")
        
        while True:
            try:
                client_socket, client_address = server_socket.accept()
                
                # 각 클라이언트를 별도 스레드로 처리 (동시 처리)
                client_thread = threading.Thread(
                    target=handle_client,
                    args=(client_socket, client_address),
                    daemon=True  # 메인 스레드 종료 시 같이 종료
                )
                client_thread.start()
                
            except KeyboardInterrupt:
                logger.info("서버 종료 중...")
                break
            except Exception as e:
                logger.error(f"연결 수락 오류: {e}")
    
    finally:
        server_socket.close()
        logger.info("서버가 종료되었습니다.")


if __name__ == '__main__':
    start_proxy_server()
💡 threading.Thread(daemon=True)가 중요한 이유!
daemon=True로 설정하면 메인 스레드(서버)가 종료될 때 자식 스레드들도 자동으로 종료돼. 이걸 안 하면 Ctrl+C로 서버를 끄려고 해도 클라이언트 처리 스레드들이 살아있어서 프로세스가 안 죽는 상황이 생길 수 있어!

🔒 2단계: HTTPS 터널링 (CONNECT 메서드) 구현

기본 HTTP 프록시는 만들었는데, 요즘 웹사이트들은 거의 다 HTTPS잖아? HTTPS는 암호화되어 있어서 프록시가 내용을 볼 수도 없고, 그냥 전달만 해야 해. 이걸 CONNECT 터널링이라고 해.

HTTPS 연결 시 브라우저는 프록시에게 이런 요청을 보내:

# 브라우저가 프록시에게 보내는 CONNECT 요청
CONNECT www.google.com:443 HTTP/1.1
Host: www.google.com:443
Proxy-Connection: keep-alive

# 프록시가 연결 성공 시 응답
HTTP/1.1 200 Connection Established
Proxy-agent: MyProxy/1.0

# 이후부터는 암호화된 TLS 데이터가 그냥 통과 (프록시는 내용 모름)
🔧 HTTPS 터널링 핸들러 추가
# proxy.py에 추가할 HTTPS 터널링 함수

import select  # I/O 멀티플렉싱을 위한 select 모듈

def handle_https_tunnel(client_socket, request_info):
    """
    HTTPS CONNECT 터널링 처리
    클라이언트와 목적지 서버 사이에 양방향 터널 생성
    """
    host = request_info['host']
    
    # CONNECT 요청에서 포트 추출
    # URL 형식: "hostname:port"
    url = request_info['url']
    if ':' in url:
        host, port = url.rsplit(':', 1)
        port = int(port)
    else:
        host = url
        port = 443  # HTTPS 기본 포트
    
    logger.info(f"HTTPS 터널 생성: {host}:{port}")
    
    try:
        # 목적지 서버에 TCP 연결
        tunnel_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        tunnel_socket.settimeout(TIMEOUT)
        tunnel_socket.connect((host, port))
        
        # 클라이언트에게 터널 연결 성공 응답
        success_response = (
            "HTTP/1.1 200 Connection Established\r\n"
            "Proxy-Agent: PythonProxy/1.0\r\n"
            "\r\n"
        ).encode('utf-8')
        
        client_socket.sendall(success_response)
        
        # 이제 양방향 데이터 중계 시작
        # select를 사용해서 어느 쪽에서 데이터가 오는지 감지
        tunnel_socket.settimeout(None)  # 블로킹 모드로 변경
        client_socket.settimeout(None)
        
        logger.info(f"터널 활성화: {host}:{port} ↔ 클라이언트")
        
        while True:
            # select로 읽기 가능한 소켓 감지 (타임아웃 30초)
            readable, _, exceptional = select.select(
                [client_socket, tunnel_socket],
                [],
                [client_socket, tunnel_socket],
                30  # 30초 타임아웃
            )
            
            # 오류 발생한 소켓 처리
            if exceptional:
                logger.warning(f"소켓 오류 발생: {host}")
                break
            
            # 타임아웃 (readable이 비어있음)
            if not readable:
                logger.debug(f"터널 타임아웃: {host}")
                break
            
            for sock in readable:
                try:
                    data = sock.recv(BUFFER_SIZE)
                    
                    if not data:
                        # 연결 종료
                        logger.info(f"터널 종료: {host}:{port}")
                        return
                    
                    # 데이터 방향에 따라 전달
                    if sock is client_socket:
                        # 클라이언트 → 서버
                        tunnel_socket.sendall(data)
                    else:
                        # 서버 → 클라이언트
                        client_socket.sendall(data)
                        
                except Exception as e:
                    logger.error(f"터널 데이터 전달 오류: {e}")
                    return
    
    except socket.gaierror as e:
        logger.error(f"HTTPS 터널 DNS 오류 ({host}): {e}")
        # 연결 실패 응답
        error_response = (
            "HTTP/1.1 502 Bad Gateway\r\n"
            "Content-Length: 0\r\n"
            "\r\n"
        ).encode('utf-8')
        try:
            client_socket.sendall(error_response)
        except:
            pass
    
    except ConnectionRefusedError:
        logger.error(f"HTTPS 터널 연결 거부: {host}:{port}")
        error_response = (
            "HTTP/1.1 503 Service Unavailable\r\n"
            "Content-Length: 0\r\n"
            "\r\n"
        ).encode('utf-8')
        try:
            client_socket.sendall(error_response)
        except:
            pass
    
    except Exception as e:
        logger.error(f"HTTPS 터널 오류: {e}")
    
    finally:
        try:
            tunnel_socket.close()
        except:
            pass
⚠️ HTTPS 터널링의 한계
CONNECT 터널링은 프록시가 암호화된 데이터를 그냥 통과시키는 방식이야. 즉, 프록시는 HTTPS 트래픽의 내용을 볼 수 없어. 만약 HTTPS 내용까지 검사하고 싶다면 SSL 인터셉션(MITM 프록시)이 필요한데, 이건 자체 CA 인증서를 발급해서 클라이언트에 설치해야 하는 복잡한 과정이 필요해. (Burp Suite, mitmproxy가 이 방식을 사용해)
HTTP vs HTTPS 프록시 처리 방식 비교 🔵 HTTP 요청 처리 흐름 클라이언트 GET /page HTTP/1.1 프록시 서버 요청 파싱 + 전달 목적지 서버 HTTP 응답 반환 프록시 기능 ✅ 내용 읽기 가능 ✅ 캐싱 가능 ✅ 필터링 가능 🔴 HTTPS CONNECT 터널링 흐름 클라이언트 CONNECT host:443 프록시 서버 200 Established 목적지 서버 TLS 암호화 통신 터널 특성 ❌ 내용 읽기 불가 ❌ 캐싱 불가 ✅ 연결 로깅 가능 ✅ 도메인 차단 가능 🔒 암호화됨 프록시 내용 불가 HTTP는 평문 전달, HTTPS는 암호화 터널만 생성

💾 3단계: 캐싱 모듈 구현

프록시 서버의 핵심 기능 중 하나가 바로 캐싱이야. 같은 리소스를 여러 번 요청할 때 매번 서버에 가지 않고, 프록시가 저장해둔 응답을 바로 돌려주는 거지. 속도도 빠르고 서버 부하도 줄어들어!

📦 cache.py — 메모리 기반 캐시 구현
# cache.py - HTTP 응답 캐싱 모듈
import hashlib
import time
import threading
import logging
from collections import OrderedDict

logger = logging.getLogger(__name__)


class ProxyCache:
    """
    LRU(Least Recently Used) 방식의 HTTP 응답 캐시
    메모리 기반으로 동작하며, 최대 크기 초과 시 가장 오래된 항목 제거
    """
    
    def __init__(self, max_size=100, default_ttl=300):
        """
        max_size: 최대 캐시 항목 수 (기본 100개)
        default_ttl: 기본 캐시 유효 시간 (초, 기본 5분)
        """
        self.max_size = max_size
        self.default_ttl = default_ttl
        self.cache = OrderedDict()  # 순서 유지 딕셔너리 (LRU 구현용)
        self.lock = threading.Lock()  # 스레드 안전성을 위한 락
        self.hits = 0    # 캐시 히트 횟수
        self.misses = 0  # 캐시 미스 횟수
    
    def _make_key(self, method, url, headers=None):
        """
        요청 정보로 캐시 키 생성 (MD5 해시)
        """
        # GET 요청만 캐싱 (POST, PUT 등은 캐싱 안 함)
        key_string = f"{method}:{url}"
        
        # Accept 헤더도 키에 포함 (같은 URL이라도 다른 형식 요청 가능)
        if headers and 'accept' in headers:
            key_string += f":{headers['accept']}"
        
        return hashlib.md5(key_string.encode()).hexdigest()
    
    def _parse_cache_control(self, response_bytes):
        """
        HTTP 응답에서 Cache-Control 헤더 파싱
        TTL(유효 시간) 결정에 사용
        """
        try:
            # 헤더 부분만 추출
            if b'\r\n\r\n' in response_bytes:
                header_bytes = response_bytes.split(b'\r\n\r\n')[0]
                headers_text = header_bytes.decode('utf-8', errors='ignore')
                
                for line in headers_text.split('\r\n'):
                    line_lower = line.lower()
                    
                    # Cache-Control: no-store, no-cache → 캐싱 금지
                    if 'cache-control' in line_lower:
                        if 'no-store' in line_lower or 'no-cache' in line_lower:
                            return 0  # 캐싱 금지
                        
                        # max-age 추출
                        if 'max-age=' in line_lower:
                            try:
                                max_age_part = line_lower.split('max-age=')[1]
                                max_age = int(max_age_part.split(',')[0].strip())
                                return max_age
                            except:
                                pass
                    
                    # Pragma: no-cache → 캐싱 금지
                    if 'pragma: no-cache' in line_lower:
                        return 0
        
        except Exception as e:
            logger.debug(f"Cache-Control 파싱 오류: {e}")
        
        return self.default_ttl  # 기본 TTL 반환
    
    def _is_cacheable(self, method, response_bytes):
        """
        캐싱 가능한 응답인지 확인
        """
        # GET 요청만 캐싱
        if method != 'GET':
            return False
        
        try:
            # 응답 상태 코드 확인
            first_line = response_bytes.split(b'\r\n')[0].decode('utf-8', errors='ignore')
            status_code = int(first_line.split(' ')[1])
            
            # 200 OK만 캐싱 (301, 302 리다이렉트 등은 제외)
            if status_code != 200:
                return False
            
            # TTL 확인 (0이면 캐싱 금지)
            ttl = self._parse_cache_control(response_bytes)
            if ttl == 0:
                return False
            
            return True
        
        except:
            return False
    
    def get(self, method, url, headers=None):
        """
        캐시에서 응답 조회
        반환: (응답 bytes, 캐시 히트 여부) 또는 (None, False)
        """
        if method != 'GET':
            return None, False
        
        key = self._make_key(method, url, headers)
        
        with self.lock:
            if key not in self.cache:
                self.misses += 1
                return None, False
            
            entry = self.cache[key]
            
            # 만료 시간 확인
            if time.time() > entry['expires_at']:
                del self.cache[key]
                self.misses += 1
                logger.debug(f"캐시 만료: {url}")
                return None, False
            
            # LRU: 최근 사용된 항목을 끝으로 이동
            self.cache.move_to_end(key)
            self.hits += 1
            
            remaining = int(entry['expires_at'] - time.time())
            logger.debug(f"캐시 히트: {url} (남은 시간: {remaining}초)")
            
            return entry['response'], True
    
    def set(self, method, url, response_bytes, headers=None):
        """
        캐시에 응답 저장
        """
        if not self._is_cacheable(method, response_bytes):
            return False
        
        key = self._make_key(method, url, headers)
        ttl = self._parse_cache_control(response_bytes)
        
        with self.lock:
            # 최대 크기 초과 시 가장 오래된 항목 제거 (LRU)
            if len(self.cache) >= self.max_size:
                oldest_key = next(iter(self.cache))
                del self.cache[oldest_key]
                logger.debug("캐시 LRU 제거 실행")
            
            self.cache[key] = {
                'response': response_bytes,
                'url': url,
                'cached_at': time.time(),
                'expires_at': time.time() + ttl,
                'size': len(response_bytes)
            }
            
            logger.debug(f"캐시 저장: {url} (TTL: {ttl}초, 크기: {len(response_bytes)} bytes)")
            return True
    
    def get_stats(self):
        """캐시 통계 반환"""
        with self.lock:
            total_size = sum(entry['size'] for entry in self.cache.values())
            hit_rate = (self.hits / (self.hits + self.misses) * 100) if (self.hits + self.misses) > 0 else 0
            
            return {
                'items': len(self.cache),
                'max_size': self.max_size,
                'hits': self.hits,
                'misses': self.misses,
                'hit_rate': f"{hit_rate:.1f}%",
                'total_size_bytes': total_size,
                'total_size_kb': f"{total_size / 1024:.1f} KB"
            }
    
    def clear(self):
        """캐시 전체 삭제"""
        with self.lock:
            self.cache.clear()
            logger.info("캐시 전체 삭제됨")


# 전역 캐시 인스턴스 생성
proxy_cache = ProxyCache(max_size=200, default_ttl=300)
💡 LRU(Least Recently Used) 캐시란?
캐시가 꽉 찼을 때 가장 오래전에 사용된 항목을 제거하는 방식이야. Python의 OrderedDict를 사용하면 순서를 유지하면서 move_to_end()로 최근 사용 항목을 끝으로 보낼 수 있어. 첫 번째 항목이 항상 가장 오래된 거니까 제거할 때 next(iter(dict))로 쉽게 찾을 수 있지!

📊 4단계: 로깅 모듈 구현

프록시 서버에서 로깅은 단순히 "뭔가 일어났다"를 기록하는 게 아니야. 누가 언제 어디에 접속했는지를 추적하는 중요한 기능이야. 기업 환경에서는 보안 감사, 문제 해결, 사용 패턴 분석에 필수적이지.

📝 logger.py — 구조화된 로깅 모듈
# logger.py - 프록시 서버 로깅 모듈
import logging
import json
import time
import os
from datetime import datetime
from threading import Lock


class ProxyLogger:
    """
    구조화된 JSON 형식의 프록시 로거
    접속 로그, 에러 로그, 통계를 별도 파일로 관리
    """
    
    def __init__(self, log_dir='logs'):
        self.log_dir = log_dir
        self.lock = Lock()
        
        # 로그 디렉토리 생성
        os.makedirs(log_dir, exist_ok=True)
        
        # 접속 로그 파일 (JSON Lines 형식)
        self.access_log_path = os.path.join(log_dir, 'access.log')
        # 에러 로그 파일
        self.error_log_path = os.path.join(log_dir, 'error.log')
        # 통계 데이터
        self.stats = {
            'total_requests': 0,
            'http_requests': 0,
            'https_tunnels': 0,
            'blocked_requests': 0,
            'cache_hits': 0,
            'errors': 0,
            'bytes_transferred': 0,
            'start_time': time.time()
        }
        
        # Python 기본 로거 설정
        self.logger = logging.getLogger('ProxyServer')
        self.logger.setLevel(logging.DEBUG)
        
        # 콘솔 핸들러 (INFO 이상만)
        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.INFO)
        console_format = logging.Formatter(
            '%(asctime)s [%(levelname)s] %(message)s',
            datefmt='%Y-%m-%d %H:%M:%S'
        )
        console_handler.setFormatter(console_format)
        
        # 파일 핸들러 (DEBUG 이상 모두)
        file_handler = logging.FileHandler(
            os.path.join(log_dir, 'proxy.log'),
            encoding='utf-8'
        )
        file_handler.setLevel(logging.DEBUG)
        file_handler.setFormatter(console_format)
        
        self.logger.addHandler(console_handler)
        self.logger.addHandler(file_handler)
    
    def log_request(self, client_ip, method, url, host, port,
                    status_code=None, response_size=0,
                    duration=0, cached=False, blocked=False):
        """
        HTTP 요청/응답 로그 기록 (JSON Lines 형식)
        """
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'client_ip': client_ip,
            'method': method,
            'url': url,
            'host': host,
            'port': port,
            'status_code': status_code,
            'response_size': response_size,
            'duration_ms': round(duration * 1000, 2),
            'cached': cached,
            'blocked': blocked
        }
        
        with self.lock:
            # JSON Lines 형식으로 파일에 기록
            with open(self.access_log_path, 'a', encoding='utf-8') as f:
                f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
            
            # 통계 업데이트
            self.stats['total_requests'] += 1
            if method == 'CONNECT':
                self.stats['https_tunnels'] += 1
            else:
                self.stats['http_requests'] += 1
            if blocked:
                self.stats['blocked_requests'] += 1
            if cached:
                self.stats['cache_hits'] += 1
            self.stats['bytes_transferred'] += response_size
        
        # 콘솔 출력 (간결하게)
        status_str = f" [{status_code}]" if status_code else ""
        cache_str = " [CACHE]" if cached else ""
        block_str = " [BLOCKED]" if blocked else ""
        
        self.logger.info(
            f"{client_ip} {method} {url}{status_str}"
            f" {response_size}B {duration*1000:.0f}ms"
            f"{cache_str}{block_str}"
        )
    
    def log_error(self, error_type, message, client_ip=None, url=None):
        """에러 로그 기록"""
        log_entry = {
            'timestamp': datetime.now().isoformat(),
            'error_type': error_type,
            'message': message,
            'client_ip': client_ip,
            'url': url
        }
        
        with self.lock:
            with open(self.error_log_path, 'a', encoding='utf-8') as f:
                f.write(json.dumps(log_entry, ensure_ascii=False) + '\n')
            self.stats['errors'] += 1
        
        self.logger.error(f"[{error_type}] {message} (클라이언트: {client_ip}, URL: {url})")
    
    def get_stats(self):
        """서버 통계 반환"""
        with self.lock:
            uptime = time.time() - self.stats['start_time']
            stats = self.stats.copy()
            stats['uptime_seconds'] = round(uptime, 1)
            stats['uptime_human'] = self._format_uptime(uptime)
            stats['bytes_transferred_mb'] = round(
                stats['bytes_transferred'] / (1024 * 1024), 2
            )
            return stats
    
    def _format_uptime(self, seconds):
        """업타임을 사람이 읽기 쉬운 형식으로 변환"""
        hours = int(seconds // 3600)
        minutes = int((seconds % 3600) // 60)
        secs = int(seconds % 60)
        return f"{hours}시간 {minutes}분 {secs}초"
    
    def print_stats(self):
        """통계를 콘솔에 출력"""
        stats = self.get_stats()
        self.logger.info("=" * 50)
        self.logger.info("📊 프록시 서버 통계")
        self.logger.info(f"  업타임: {stats['uptime_human']}")
        self.logger.info(f"  총 요청: {stats['total_requests']}")
        self.logger.info(f"  HTTP 요청: {stats['http_requests']}")
        self.logger.info(f"  HTTPS 터널: {stats['https_tunnels']}")
        self.logger.info(f"  차단된 요청: {stats['blocked_requests']}")
        self.logger.info(f"  캐시 히트: {stats['cache_hits']}")
        self.logger.info(f"  에러: {stats['errors']}")
        self.logger.info(f"  전송 데이터: {stats['bytes_transferred_mb']} MB")
        self.logger.info("=" * 50)


# 전역 로거 인스턴스
proxy_logger = ProxyLogger()

🔄 5단계: 모든 것을 합친 완성 버전

자, 이제 지금까지 만든 모든 모듈을 합쳐서 완전한 HTTP 프록시 서버를 완성해보자! 캐싱, 로깅, HTTPS 터널링, 차단 기능이 모두 포함된 버전이야.

🚀 proxy_complete.py — 완성 버전
# proxy_complete.py - 완성된 HTTP 프록시 서버
import socket
import threading
import select
import time
import signal
import sys
from config import *
from cache import proxy_cache
from logger import proxy_logger


def parse_http_request(raw_data):
    """HTTP 요청 파싱 (앞서 구현한 함수와 동일)"""
    try:
        request_text = raw_data.decode('utf-8', errors='ignore')
        
        if '\r\n\r\n' in request_text:
            header_section, body = request_text.split('\r\n\r\n', 1)
        else:
            header_section = request_text
            body = ''
        
        lines = header_section.split('\r\n')
        request_line = lines[0]
        parts = request_line.split(' ')
        
        if len(parts) < 3:
            return None
        
        method, url, version = parts[0], parts[1], parts[2]
        
        headers = {}
        for line in lines[1:]:
            if ':' in line:
                key, value = line.split(':', 1)
                headers[key.strip().lower()] = value.strip()
        
        host = headers.get('host', '')
        port = 80
        path = url
        
        if url.startswith('http://'):
            url_without_scheme = url[7:]
            if '/' in url_without_scheme:
                host_part, path_part = url_without_scheme.split('/', 1)
                path = '/' + path_part
            else:
                host_part = url_without_scheme
                path = '/'
            
            if ':' in host_part:
                host, port_str = host_part.split(':', 1)
                port = int(port_str)
            else:
                host = host_part
        
        return {
            'method': method, 'url': url, 'path': path,
            'version': version, 'headers': headers,
            'host': host, 'port': port,
            'body': body, 'raw': raw_data
        }
    except Exception as e:
        proxy_logger.log_error('PARSE_ERROR', str(e))
        return None


def is_blocked(host):
    """차단 도메인 확인"""
    for blocked in BLOCKED_DOMAINS:
        if blocked in host:
            return True
    return False


def handle_https_tunnel(client_socket, request_info, client_ip):
    """HTTPS CONNECT 터널링 처리"""
    url = request_info['url']
    
    if ':' in url:
        host, port_str = url.rsplit(':', 1)
        port = int(port_str)
    else:
        host = url
        port = 443
    
    start_time = time.time()
    bytes_transferred = 0
    
    try:
        tunnel_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        tunnel_socket.settimeout(TIMEOUT)
        tunnel_socket.connect((host, port))
        
        # 연결 성공 응답
        client_socket.sendall(
            b"HTTP/1.1 200 Connection Established\r\n"
            b"Proxy-Agent: PythonProxy/2.0\r\n\r\n"
        )
        
        tunnel_socket.settimeout(None)
        client_socket.settimeout(None)
        
        while True:
            readable, _, exceptional = select.select(
                [client_socket, tunnel_socket],
                [], [client_socket, tunnel_socket], 30
            )
            
            if exceptional or not readable:
                break
            
            for sock in readable:
                try:
                    data = sock.recv(BUFFER_SIZE)
                    if not data:
                        return
                    bytes_transferred += len(data)
                    
                    if sock is client_socket:
                        tunnel_socket.sendall(data)
                    else:
                        client_socket.sendall(data)
                except:
                    return
    
    except Exception as e:
        proxy_logger.log_error('TUNNEL_ERROR', str(e), client_ip, url)
        try:
            client_socket.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
        except:
            pass
    
    finally:
        duration = time.time() - start_time
        proxy_logger.log_request(
            client_ip, 'CONNECT', url, host, port,
            200, bytes_transferred, duration
        )
        try:
            tunnel_socket.close()
        except:
            pass


def handle_http_request(client_socket, request_info, client_ip):
    """일반 HTTP 요청 처리 (캐싱 포함)"""
    method = request_info['method']
    url = request_info['url']
    host = request_info['host']
    port = request_info['port']
    
    start_time = time.time()
    
    # 캐시 확인 (GET 요청만)
    if ENABLE_CACHE and method == 'GET':
        cached_response, hit = proxy_cache.get(method, url, request_info['headers'])
        
        if hit:
            # 캐시 히트! 저장된 응답 반환
            try:
                # Age 헤더 추가 (캐시된 지 얼마나 됐는지)
                client_socket.sendall(cached_response)
                duration = time.time() - start_time
                proxy_logger.log_request(
                    client_ip, method, url, host, port,
                    200, len(cached_response), duration, cached=True
                )
                return
            except Exception as e:
                proxy_logger.log_error('CACHE_SEND_ERROR', str(e), client_ip, url)
    
    # 캐시 미스 → 실제 서버에 요청
    try:
        server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        server_socket.settimeout(TIMEOUT)
        server_socket.connect((host, port))
        server_socket.sendall(request_info['raw'])
        
        # 응답 수신
        response = b''
        while True:
            try:
                chunk = server_socket.recv(BUFFER_SIZE)
                if not chunk:
                    break
                response += chunk
            except socket.timeout:
                break
        
        server_socket.close()
        
        if response:
            # 클라이언트에게 응답 전달
            client_socket.sendall(response)
            
            # 응답 상태 코드 추출
            status_code = 200
            try:
                first_line = response.split(b'\r\n')[0].decode('utf-8', errors='ignore')
                status_code = int(first_line.split(' ')[1])
            except:
                pass
            
            # 캐시에 저장 (캐싱 가능한 경우)
            if ENABLE_CACHE:
                proxy_cache.set(method, url, response, request_info['headers'])
            
            duration = time.time() - start_time
            proxy_logger.log_request(
                client_ip, method, url, host, port,
                status_code, len(response), duration
            )
        else:
            # 빈 응답
            error_msg = b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"
            client_socket.sendall(error_msg)
            proxy_logger.log_error('EMPTY_RESPONSE', '빈 응답 수신', client_ip, url)
    
    except socket.gaierror as e:
        error_msg = b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"
        try:
            client_socket.sendall(error_msg)
        except:
            pass
        proxy_logger.log_error('DNS_ERROR', str(e), client_ip, url)
    
    except Exception as e:
        error_msg = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n"
        try:
            client_socket.sendall(error_msg)
        except:
            pass
        proxy_logger.log_error('REQUEST_ERROR', str(e), client_ip, url)


def handle_client(client_socket, client_address):
    """클라이언트 연결 처리 메인 함수"""
    client_ip = client_address[0]
    
    try:
        raw_request = b''
        client_socket.settimeout(TIMEOUT)
        
        # 요청 수신
        while True:
            try:
                chunk = client_socket.recv(BUFFER_SIZE)
                if not chunk:
                    break
                raw_request += chunk
                
                if b'\r\n\r\n' in raw_request:
                    header_end = raw_request.find(b'\r\n\r\n') + 4
                    headers_raw = raw_request[:header_end].decode('utf-8', errors='ignore')
                    
                    content_length = 0
                    for line in headers_raw.split('\r\n'):
                        if line.lower().startswith('content-length:'):
                            try:
                                content_length = int(line.split(':', 1)[1].strip())
                            except:
                                pass
                            break
                    
                    if len(raw_request) - header_end >= content_length:
                        break
            except socket.timeout:
                break
        
        if not raw_request:
            return
        
        # 요청 파싱
        request_info = parse_http_request(raw_request)
        if not request_info:
            return
        
        method = request_info['method']
        host = request_info['host']
        
        # 차단 도메인 확인
        if is_blocked(host):
            proxy_logger.log_request(
                client_ip, method, request_info['url'],
                host, request_info['port'], 403, 0, 0, blocked=True
            )
            client_socket.sendall(
                b"HTTP/1.1 403 Forbidden\r\n"
                b"Content-Type: text/plain\r\n"
                b"Content-Length: 22\r\n\r\n"
                b"Access Denied by Proxy"
            )
            return
        
        # 메서드에 따라 처리 분기
        if method == 'CONNECT':
            handle_https_tunnel(client_socket, request_info, client_ip)
        else:
            handle_http_request(client_socket, request_info, client_ip)
    
    except Exception as e:
        proxy_logger.log_error('CLIENT_ERROR', str(e), client_ip)
    
    finally:
        try:
            client_socket.close()
        except:
            pass


def signal_handler(signum, frame):
    """Ctrl+C 시그널 처리"""
    print("\n\n서버 종료 중...")
    proxy_logger.print_stats()
    
    # 캐시 통계 출력
    cache_stats = proxy_cache.get_stats()
    print(f"캐시 통계: {cache_stats}")
    
    sys.exit(0)


def start_proxy_server():
    """프록시 서버 시작"""
    # Ctrl+C 핸들러 등록
    signal.signal(signal.SIGINT, signal_handler)
    
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    
    try:
        server_socket.bind((PROXY_HOST, PROXY_PORT))
        server_socket.listen(MAX_CONNECTIONS)
        
        proxy_logger.logger.info("=" * 60)
        proxy_logger.logger.info("🚀 HTTP 프록시 서버 v2.0 시작!")
        proxy_logger.logger.info(f"📡 주소: {PROXY_HOST}:{PROXY_PORT}")
        proxy_logger.logger.info(f"💾 캐싱: {'활성화' if ENABLE_CACHE else '비활성화'}")
        proxy_logger.logger.info(f"🚫 차단 도메인: {len(BLOCKED_DOMAINS)}개")
        proxy_logger.logger.info("=" * 60)
        proxy_logger.logger.info("브라우저 프록시 설정: 127.0.0.1:8080")
        proxy_logger.logger.info("종료: Ctrl+C")
        proxy_logger.logger.info("=" * 60)
        
        while True:
            client_socket, client_address = server_socket.accept()
            
            thread = threading.Thread(
                target=handle_client,
                args=(client_socket, client_address),
                daemon=True
            )
            thread.start()
    
    except Exception as e:
        proxy_logger.log_error('SERVER_ERROR', str(e))
    
    finally:
        server_socket.close()


if __name__ == '__main__':
    start_proxy_server()

🧪 6단계: 테스트 및 사용 방법
🖥️ 서버 실행
# 터미널에서 실행
python proxy_complete.py

# 출력 예시:
# ============================================================
# 🚀 HTTP 프록시 서버 v2.0 시작!
# 📡 주소: 0.0.0.0:8080
# 💾 캐싱: 활성화
# 🚫 차단 도메인: 2개
# ============================================================
# 브라우저 프록시 설정: 127.0.0.1:8080
# 종료: Ctrl+C
# ============================================================
🌐 브라우저 프록시 설정 방법
Chrome / Edge 설정:
설정 → 시스템 → 컴퓨터 프록시 설정 열기 → 수동 프록시 설정
주소: 127.0.0.1, 포트: 8080

Firefox 설정:
설정 → 일반 → 네트워크 설정 → 수동 프록시 구성
HTTP 프록시: 127.0.0.1, 포트: 8080

curl 테스트:
curl -x http://127.0.0.1:8080 http://httpbin.org/get
🔬 Python으로 프록시 테스트
# test_proxy.py - 프록시 서버 테스트 스크립트
import requests
import time

# 프록시 설정
proxies = {
    'http': 'http://127.0.0.1:8080',
    'https': 'http://127.0.0.1:8080',
}

def test_http_request():
    """HTTP 요청 테스트"""
    print("🧪 HTTP 요청 테스트...")
    try:
        response = requests.get(
            'http://httpbin.org/get',
            proxies=proxies,
            timeout=10
        )
        print(f"✅ 상태 코드: {response.status_code}")
        print(f"✅ 응답 크기: {len(response.content)} bytes")
        return True
    except Exception as e:
        print(f"❌ 오류: {e}")
        return False

def test_https_tunnel():
    """HTTPS 터널링 테스트"""
    print("\n🧪 HTTPS 터널링 테스트...")
    try:
        response = requests.get(
            'https://httpbin.org/get',
            proxies=proxies,
            timeout=10,
            verify=True  # SSL 인증서 검증
        )
        print(f"✅ 상태 코드: {response.status_code}")
        print(f"✅ 응답 크기: {len(response.content)} bytes")
        return True
    except Exception as e:
        print(f"❌ 오류: {e}")
        return False

def test_caching():
    """캐싱 테스트 (같은 URL 두 번 요청)"""
    print("\n🧪 캐싱 테스트...")
    url = 'http://httpbin.org/cache/60'  # 60초 캐시 설정된 URL
    
    # 첫 번째 요청 (캐시 미스)
    start = time.time()
    r1 = requests.get(url, proxies=proxies, timeout=10)
    t1 = time.time() - start
    
    # 두 번째 요청 (캐시 히트 기대)
    start = time.time()
    r2 = requests.get(url, proxies=proxies, timeout=10)
    t2 = time.time() - start
    
    print(f"✅ 첫 번째 요청: {t1*1000:.0f}ms")
    print(f"✅ 두 번째 요청: {t2*1000:.0f}ms")
    
    if t2 < t1:
        print(f"🚀 캐시 효과: {((t1-t2)/t1*100):.1f}% 빠름!")
    
    return True

def test_blocked_domain():
    """차단 도메인 테스트"""
    print("\n🧪 차단 도메인 테스트...")
    try:
        response = requests.get(
            'http://malware-site.com/',
            proxies=proxies,
            timeout=5
        )
        if response.status_code == 403:
            print("✅ 차단 정상 작동 (403 Forbidden)")
            return True
        else:
            print(f"⚠️ 예상치 못한 상태 코드: {response.status_code}")
            return False
    except Exception as e:
        print(f"ℹ️ 연결 오류 (차단 또는 DNS 실패): {e}")
        return True  # 차단으로 인한 오류도 정상

if __name__ == '__main__':
    print("=" * 50)
    print("🔍 HTTP 프록시 서버 테스트 시작")
    print("=" * 50)
    
    results = [
        test_http_request(),
        test_https_tunnel(),
        test_caching(),
        test_blocked_domain(),
    ]
    
    passed = sum(results)
    total = len(results)
    
    print(f"\n{'='*50}")
    print(f"📊 테스트 결과: {passed}/{total} 통과")
    print(f"{'='*50}")

⚡ 7단계: 성능 최적화 — ThreadPool 방식

지금까지 만든 프록시는 클라이언트마다 새 스레드를 생성하는 방식이야. 근데 동시 접속이 많아지면 스레드 생성/소멸 비용이 커져서 성능이 떨어질 수 있어. 이걸 해결하는 게 바로 스레드 풀(Thread Pool)이야!

# proxy_threadpool.py - 스레드 풀 방식 프록시 서버
from concurrent.futures import ThreadPoolExecutor
import socket
import logging

logger = logging.getLogger(__name__)

class ThreadPoolProxyServer:
    """
    ThreadPoolExecutor를 사용한 고성능 프록시 서버
    스레드를 미리 생성해두고 재사용 → 오버헤드 감소
    """
    
    def __init__(self, host='0.0.0.0', port=8080, max_workers=50):
        self.host = host
        self.port = port
        self.max_workers = max_workers
        self.server_socket = None
        self.running = False
        
        # 스레드 풀 생성 (max_workers개의 스레드를 미리 준비)
        self.executor = ThreadPoolExecutor(
            max_workers=max_workers,
            thread_name_prefix='proxy-worker'
        )
    
    def start(self):
        """서버 시작"""
        self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self.server_socket.bind((self.host, self.port))
        self.server_socket.listen(200)
        self.running = True
        
        logger.info(f"🚀 ThreadPool 프록시 서버 시작 (워커: {self.max_workers}개)")
        logger.info(f"📡 {self.host}:{self.port}")
        
        try:
            while self.running:
                try:
                    client_socket, client_address = self.server_socket.accept()
                    
                    # 스레드 풀에 작업 제출 (새 스레드 생성 없이 풀에서 처리)
                    future = self.executor.submit(
                        handle_client,
                        client_socket,
                        client_address
                    )
                    
                    # 예외 처리 콜백 등록
                    future.add_done_callback(self._handle_future_exception)
                    
                except KeyboardInterrupt:
                    break
                except Exception as e:
                    logger.error(f"연결 수락 오류: {e}")
        
        finally:
            self.stop()
    
    def _handle_future_exception(self, future):
        """스레드 풀 작업 완료 후 예외 확인"""
        try:
            future.result()
        except Exception as e:
            logger.error(f"워커 스레드 오류: {e}")
    
    def stop(self):
        """서버 종료"""
        self.running = False
        self.executor.shutdown(wait=False)  # 진행 중인 작업 완료 대기 없이 종료
        if self.server_socket:
            self.server_socket.close()
        logger.info("서버 종료됨")


# asyncio 버전 (더 고성능)
import asyncio

class AsyncProxyServer:
    """
    asyncio 기반 비동기 프록시 서버
    단일 스레드로 수천 개의 동시 연결 처리 가능
    """
    
    def __init__(self, host='0.0.0.0', port=8080):
        self.host = host
        self.port = port
    
    async def handle_client(self, reader, writer):
        """비동기 클라이언트 처리"""
        client_addr = writer.get_extra_info('peername')
        
        try:
            # 요청 수신 (비동기)
            raw_request = await asyncio.wait_for(
                reader.read(4096),
                timeout=10.0
            )
            
            if not raw_request:
                return
            
            # 요청 파싱 (동기 함수 호출)
            request_info = parse_http_request(raw_request)
            if not request_info:
                return
            
            method = request_info['method']
            host = request_info['host']
            port = request_info['port']
            
            if method == 'CONNECT':
                await self._handle_connect(reader, writer, host, port)
            else:
                await self._handle_http(writer, request_info)
        
        except asyncio.TimeoutError:
            logger.debug(f"타임아웃: {client_addr}")
        except Exception as e:
            logger.error(f"비동기 처리 오류: {e}")
        finally:
            try:
                writer.close()
                await writer.wait_closed()
            except:
                pass
    
    async def _handle_connect(self, client_reader, client_writer, host, port):
        """비동기 HTTPS 터널링"""
        try:
            # 목적지 서버에 비동기 연결
            server_reader, server_writer = await asyncio.wait_for(
                asyncio.open_connection(host, port),
                timeout=10.0
            )
            
            # 연결 성공 응답
            client_writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
            await client_writer.drain()
            
            # 양방향 데이터 중계 (비동기)
            async def relay(src_reader, dst_writer):
                try:
                    while True:
                        data = await asyncio.wait_for(
                            src_reader.read(4096), timeout=30.0
                        )
                        if not data:
                            break
                        dst_writer.write(data)
                        await dst_writer.drain()
                except:
                    pass
                finally:
                    try:
                        dst_writer.close()
                    except:
                        pass
            
            # 두 방향 동시 실행
            await asyncio.gather(
                relay(client_reader, server_writer),
                relay(server_reader, client_writer),
                return_exceptions=True
            )
        
        except Exception as e:
            logger.error(f"비동기 터널 오류: {e}")
            try:
                client_writer.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
                await client_writer.drain()
            except:
                pass
    
    async def _handle_http(self, client_writer, request_info):
        """비동기 HTTP 요청 처리"""
        host = request_info['host']
        port = request_info['port']
        
        try:
            server_reader, server_writer = await asyncio.wait_for(
                asyncio.open_connection(host, port),
                timeout=10.0
            )
            
            server_writer.write(request_info['raw'])
            await server_writer.drain()
            
            response = await asyncio.wait_for(
                server_reader.read(65536),
                timeout=10.0
            )
            
            if response:
                client_writer.write(response)
                await client_writer.drain()
            
            server_writer.close()
        
        except Exception as e:
            logger.error(f"비동기 HTTP 오류: {e}")
    
    async def run(self):
        """비동기 서버 실행"""
        server = await asyncio.start_server(
            self.handle_client,
            self.host,
            self.port
        )
        
        logger.info(f"🚀 비동기 프록시 서버 시작: {self.host}:{self.port}")
        
        async with server:
            await server.serve_forever()


# 실행 방법
if __name__ == '__main__':
    import sys
    
    mode = sys.argv[1] if len(sys.argv) > 1 else 'thread'
    
    if mode == 'async':
        # 비동기 방식 (고성능)
        server = AsyncProxyServer()
        asyncio.run(server.run())
    elif mode == 'pool':
        # 스레드 풀 방식
        server = ThreadPoolProxyServer(max_workers=100)
        server.start()
    else:
        # 기본 스레드 방식
        start_proxy_server()
💡 어떤 방식을 선택해야 할까?
🔹 기본 스레드 방식 — 학습용, 소규모 프로젝트. 구현 간단
🔹 스레드 풀 방식 — 중간 규모. 스레드 생성 오버헤드 감소
🔹 asyncio 비동기 방식 — 대규모, 고성능. I/O 바운드 작업에 최적화

실제 프로덕션 환경에서는 asyncio 방식이 가장 효율적이야. 단일 스레드로 수천 개의 동시 연결을 처리할 수 있거든!

프록시 서버 구현 방식별 성능 비교 동시 연결 1000개 기준 (상대적 수치) 0 20 40 60 80 100 50 80 48 68 64 66 95 86 93 처리량 (req/s) 메모리 효율 응답 속도 기본 스레드 스레드 풀 asyncio 비동기

🛡️ 8단계: 보안 강화 — 헤더 처리와 필터링

프록시 서버를 실제로 사용하려면 보안도 신경 써야 해. 특히 프록시 헤더 처리와 요청 필터링이 중요해.

🔐 보안 헤더 처리 모듈
# security.py - 프록시 보안 모듈

def sanitize_request_headers(headers_dict):
    """
    프록시를 통과하는 요청 헤더 정제
    - 프록시 관련 헤더 제거
    - 보안 위협 헤더 필터링
    """
    # 제거할 헤더 목록 (프록시 내부 헤더, 보안 위협)
    headers_to_remove = [
        'proxy-connection',      # 프록시 연결 헤더 (서버에 전달 불필요)
        'proxy-authenticate',    # 프록시 인증 헤더
        'proxy-authorization',   # 프록시 인증 정보 (민감!)
        'proxy-features',
        'proxy-instruction',
        'security',
        'cookie2',               # 구식 쿠키 헤더
        'te',                    # Transfer-Encoding 협상
        'trailers',
    ]
    
    sanitized = {}
    for key, value in headers_dict.items():
        key_lower = key.lower()
        
        # 제거 대상 헤더 스킵
        if key_lower in headers_to_remove:
            continue
        
        # 헤더 값 길이 제한 (헤더 인젝션 방지)
        if len(value) > 8192:  # 8KB 초과 헤더 거부
            continue
        
        # 헤더 값에 줄바꿈 문자 포함 시 거부 (헤더 인젝션 공격)
        if '\r' in value or '\n' in value:
            continue
        
        sanitized[key] = value
    
    # Via 헤더 추가 (프록시 경유 표시)
    sanitized['via'] = '1.1 PythonProxy/2.0'
    
    # X-Forwarded-For 헤더 처리
    # (클라이언트 IP를 서버에 알려주는 헤더)
    # 주의: 익명 프록시라면 이 헤더를 추가하면 안 됨!
    # sanitized['x-forwarded-for'] = client_ip
    
    return sanitized


def rebuild_request(request_info, sanitized_headers):
    """
    정제된 헤더로 HTTP 요청 재구성
    """
    method = request_info['method']
    path = request_info.get('path', '/')
    version = request_info['version']
    
    # 요청 라인
    request_line = f"{method} {path} {version}\r\n"
    
    # 헤더 재구성
    headers_str = ""
    for key, value in sanitized_headers.items():
        # 헤더 이름 정규화 (Title-Case)
        normalized_key = '-'.join(word.capitalize() for word in key.split('-'))
        headers_str += f"{normalized_key}: {value}\r\n"
    
    # 빈 줄 + 바디
    rebuilt = (request_line + headers_str + "\r\n").encode('utf-8')
    
    if request_info.get('body'):
        rebuilt += request_info['body'].encode('utf-8', errors='ignore')
    
    return rebuilt


def check_request_size(raw_request, max_size=10 * 1024 * 1024):
    """
    요청 크기 제한 (기본 10MB)
    DDoS 및 메모리 고갈 공격 방지
    """
    if len(raw_request) > max_size:
        return False, f"요청 크기 초과: {len(raw_request)} bytes (최대: {max_size} bytes)"
    return True, None


def validate_host(host):
    """
    호스트 유효성 검사
    - 내부 IP 접근 차단 (SSRF 방지)
    - 유효하지 않은 호스트 차단
    """
    import ipaddress
    
    # 빈 호스트 거부
    if not host:
        return False, "호스트가 비어있습니다"
    
    # 호스트 길이 제한
    if len(host) > 253:
        return False, "호스트 이름이 너무 깁니다"
    
    try:
        # IP 주소인 경우 내부 IP 차단 (SSRF 방지)
        ip = ipaddress.ip_address(host)
        
        # 사설 IP 대역 차단
        if ip.is_private:
            return False, f"내부 IP 접근 차단: {host}"
        
        # 루프백 주소 차단
        if ip.is_loopback:
            return False, f"루프백 주소 차단: {host}"
        
        # 링크-로컬 주소 차단
        if ip.is_link_local:
            return False, f"링크-로컬 주소 차단: {host}"
    
    except ValueError:
        # IP가 아닌 도메인 이름 → 추가 검증
        # localhost 차단
        if host.lower() in ['localhost', 'localhost.localdomain']:
            return False, f"localhost 접근 차단"
        
        # .local 도메인 차단 (내부 네트워크)
        if host.lower().endswith('.local'):
            return False, f"내부 도메인 차단: {host}"
    
    return True, None


# 사용 예시
def process_request_with_security(request_info, client_ip):
    """보안 검사를 포함한 요청 처리"""
    
    # 1. 요청 크기 검사
    valid, error = check_request_size(request_info['raw'])
    if not valid:
        return None, error
    
    # 2. 호스트 유효성 검사 (SSRF 방지)
    valid, error = validate_host(request_info['host'])
    if not valid:
        return None, error
    
    # 3. 헤더 정제
    sanitized_headers = sanitize_request_headers(request_info['headers'])
    
    # 4. 요청 재구성
    safe_request = rebuild_request(request_info, sanitized_headers)
    
    return safe_request, None
⚠️ SSRF(Server-Side Request Forgery) 공격 주의!
프록시 서버를 통해 공격자가 내부 네트워크의 서버에 접근하려는 시도가 있을 수 있어. 예를 들어 http://192.168.1.1/admin이나 http://localhost:6379(Redis) 같은 요청을 프록시를 통해 보내는 거야. validate_host() 함수로 내부 IP 대역을 차단하는 게 필수야!

📈 9단계: 모니터링 대시보드 (간단 버전)

프록시 서버가 잘 돌아가고 있는지 확인하려면 모니터링이 필요해. 간단한 HTTP 관리 엔드포인트를 추가해보자. 재능넷 같은 플랫폼에서 서비스를 운영할 때도 이런 모니터링 기능이 정말 유용하게 쓰여!

# admin_server.py - 프록시 관리 서버 (별도 포트)
import json
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from cache import proxy_cache
from logger import proxy_logger


class AdminHandler(BaseHTTPRequestHandler):
    """
    프록시 서버 관리 HTTP 핸들러
    포트 8081에서 실행 (프록시는 8080)
    """
    
    def log_message(self, format, *args):
        """기본 로그 출력 억제"""
        pass
    
    def do_GET(self):
        """GET 요청 처리"""
        
        if self.path == '/stats':
            # 서버 통계 JSON 반환
            stats = proxy_logger.get_stats()
            cache_stats = proxy_cache.get_stats()
            
            response_data = {
                'server': stats,
                'cache': cache_stats,
                'status': 'running'
            }
            
            self._send_json(200, response_data)
        
        elif self.path == '/health':
            # 헬스 체크 엔드포인트
            self._send_json(200, {'status': 'ok', 'service': 'proxy'})
        
        elif self.path == '/cache/clear':
            # 캐시 초기화
            proxy_cache.clear()
            self._send_json(200, {'message': '캐시가 초기화되었습니다'})
        
        elif self.path == '/':
            # 간단한 HTML 대시보드
            self._send_html(self._generate_dashboard())
        
        else:
            self._send_json(404, {'error': '엔드포인트를 찾을 수 없습니다'})
    
    def _send_json(self, status_code, data):
        """JSON 응답 전송"""
        body = json.dumps(data, ensure_ascii=False, indent=2).encode('utf-8')
        self.send_response(status_code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', len(body))
        self.end_headers()
        self.wfile.write(body)
    
    def _send_html(self, html):
        """HTML 응답 전송"""
        body = html.encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', len(body))
        self.end_headers()
        self.wfile.write(body)
    
    def _generate_dashboard(self):
        """간단한 HTML 대시보드 생성"""
        stats = proxy_logger.get_stats()
        cache_stats = proxy_cache.get_stats()
        
        return f"""<!DOCTYPE html>
<html>
<head>
    <title>프록시 서버 대시보드</title>
    <meta charset="utf-8">
    <meta http-equiv="refresh" content="5">
    <style>
        body {{ font-family: sans-serif; background: #1a1a2e; color: #e0e8f5; padding: 20px; }}
        .card {{ background: #16213e; border-radius: 8px; padding: 20px; margin: 10px 0; }}
        .stat {{ display: inline-block; margin: 10px; text-align: center; }}
        .stat-value {{ font-size: 2em; font-weight: bold; color: #4facfe; }}
        .stat-label {{ font-size: 0.85em; color: #a8b8d8; }}
        h1 {{ color: #4facfe; }}
        h2 {{ color: #a8b8d8; font-size: 1em; }}
    </style>
</head>
<body>
    <h1>🚀 프록시 서버 대시보드</h1>
    <p>업타임: {stats.get('uptime_human', 'N/A')} | 5초마다 자동 갱신</p>
    
    <div class="card">
        <h2>📊 요청 통계</h2>
        <div class="stat">
            <div class="stat-value">{stats.get('total_requests', 0)}</div>
            <div class="stat-label">총 요청</div>
        </div>
        <div class="stat">
            <div class="stat-value">{stats.get('http_requests', 0)}</div>
            <div class="stat-label">HTTP</div>
        </div>
        <div class="stat">
            <div class="stat-value">{stats.get('https_tunnels', 0)}</div>
            <div class="stat-label">HTTPS 터널</div>
        </div>
        <div class="stat">
            <div class="stat-value">{stats.get('blocked_requests', 0)}</div>
            <div class="stat-label">차단됨</div>
        </div>
        <div class="stat">
            <div class="stat-value">{stats.get('bytes_transferred_mb', 0)} MB</div>
            <div class="stat-label">전송량</div>
        </div>
    </div>
    
    <div class="card">
        <h2>💾 캐시 통계</h2>
        <div class="stat">
            <div class="stat-value">{cache_stats.get('hit_rate', '0%')}</div>
            <div class="stat-label">히트율</div>
        </div>
        <div class="stat">
            <div class="stat-value">{cache_stats.get('items', 0)}</div>
            <div class="stat-label">캐시 항목</div>
        </div>
        <div class="stat">
            <div class="stat-value">{cache_stats.get('total_size_kb', '0 KB')}</div>
            <div class="stat-label">캐시 크기</div>
        </div>
    </div>
    
    <div class="card">
        <h2>🔗 API 엔드포인트</h2>
        <p><a href="/stats" style="color:#4facfe">/stats</a> - JSON 통계</p>
        <p><a href="/health" style="color:#4facfe">/health</a> - 헬스 체크</p>
        <p><a href="/cache/clear" style="color:#f093fb">/cache/clear</a> - 캐시 초기화</p>
    </div>
</body>
</html>"""


def start_admin_server(port=8081):
    """관리 서버 시작 (별도 스레드)"""
    admin_server = HTTPServer(('0.0.0.0', port), AdminHandler)
    
    thread = threading.Thread(
        target=admin_server.serve_forever,
        daemon=True
    )
    thread.start()
    
    print(f"📊 관리 대시보드: http://localhost:{port}")
    return admin_server

🎯 실전 활용 팁 & 주의사항
✅ 실제 사용 시나리오
시나리오 활용 방법 주요 기능
🏢 기업 내부망 직원 인터넷 접속 제어 도메인 차단, 로깅
🔬 API 테스트 요청/응답 분석 및 수정 헤더 조작, 로깅
⚡ 성능 최적화 정적 리소스 캐싱 LRU 캐시
🕵️ 보안 감사 트래픽 모니터링 JSON 로깅, 통계
🌍 지역 우회 다른 지역 서버 경유 포워드 프록시
🤖 크롤링 IP 로테이션 프록시 다중 프록시 체인
⚠️ 법적/윤리적 주의사항
⚠️ 반드시 알아야 할 주의사항!
🔹 개인정보 보호법 — 타인의 통신 내용을 무단으로 수집·저장하면 법적 문제가 생길 수 있어. 기업 환경에서는 직원에게 모니터링 사실을 고지해야 해.
🔹 저작권 — 캐싱된 콘텐츠를 무단으로 재배포하면 안 돼.
🔹 서비스 약관 — 특정 서비스의 약관에서 프록시 사용을 금지할 수 있어.
🔹 공개 프록시 운영 — 불특정 다수가 사용하는 공개 프록시는 악용될 위험이 높아. 반드시 인증 기능을 추가해야 해.
🚀 더 발전시키려면?
🔧 추가 구현 아이디어 인증 Basic Auth 또는 토큰 기반 프록시 인증 추가
SSL 인터셉션 mitmproxy 방식으로 HTTPS 내용 검사
프록시 체인 여러 프록시를 연결해서 익명성 강화
콘텐츠 수정 응답 HTML에서 광고 제거, 스크립트 차단
속도 제한 클라이언트별 대역폭 제한 (Rate Limiting)
Redis 캐시 메모리 캐시 대신 Redis로 영구 캐싱
Docker 컨테이너화해서 어디서든 쉽게 배포
Prometheus 메트릭 수집으로 Grafana 대시보드 연동

이런 프록시 서버 구현 능력은 네트워크 프로그래밍, 보안, 인프라 분야에서 정말 귀하게 쓰여. 만약 이런 기술을 가지고 있다면 재능넷에서 네트워크 프로그래밍 관련 재능을 공유해보는 것도 좋은 방법이야! 프록시 서버 구축, 네트워크 보안 설정, API 게이트웨이 개발 같은 서비스 수요가 꽤 있거든. 🎯


🔍 자주 발생하는 문제와 해결법
🐛 트러블슈팅 가이드
1
포트 이미 사용 중 오류 (Address already in use)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
을 bind() 전에 설정했는지 확인. 또는 lsof -i :8080으로 포트 사용 프로세스 확인 후 종료.
2
HTTPS 사이트 연결 안 됨
CONNECT 메서드 처리 함수가 올바르게 구현됐는지 확인. 브라우저 콘솔에서 "ERR_TUNNEL_CONNECTION_FAILED" 오류가 나면 터널 소켓 연결 부분 디버깅.
3
응답이 잘리거나 불완전함
recv()를 한 번만 호출하면 데이터가 잘릴 수 있어. 루프로 전체 응답을 받아야 해. Content-Length 헤더를 확인하거나 연결이 닫힐 때까지 계속 받아야 함.
4
메모리 누수 (스레드 계속 증가)
daemon=True 설정 확인. 소켓을 finally 블록에서 반드시 close() 호출. 장시간 실행 시 스레드 수를 주기적으로 모니터링.
5
한글 URL 처리 오류
URL에 한글이 포함된 경우 퍼센트 인코딩 처리 필요. urllib.parse.quote()로 인코딩하거나, errors='ignore'로 디코딩 오류 무시.
6
Chunked Transfer Encoding 처리
일부 서버는 응답을 청크 단위로 보내. Content-Length 없이 Transfer-Encoding: chunked를 사용하는 경우 청크 파싱 로직이 필요해.
# 디버깅 팁: 요청/응답 내용 출력
import logging
logging.basicConfig(level=logging.DEBUG)

# 소켓 타임아웃 늘리기 (느린 서버 대응)
TIMEOUT = 30  # 10초 → 30초로 증가

# 스레드 수 모니터링
import threading
print(f"현재 활성 스레드 수: {threading.active_count()}")

# 메모리 사용량 확인
import tracemalloc
tracemalloc.start()
# ... 코드 실행 ...
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:5]:
    print(stat)

🎓 마무리 — 배운 것 정리

와, 정말 긴 여정이었어! 😄 처음에 "프록시 서버가 뭔데?"에서 시작해서 이제는 직접 구현까지 해봤잖아. 마지막으로 오늘 배운 핵심 내용을 정리해보자.

📌 오늘 구현한 것들
  • ✅ 기본 HTTP 프록시 서버 — 소켓 프로그래밍, HTTP 파싱, 요청 전달
  • ✅ HTTPS CONNECT 터널링 — select() 기반 양방향 데이터 중계
  • ✅ LRU 캐싱 모듈 — OrderedDict, Cache-Control 파싱, TTL 관리
  • ✅ 구조화된 로깅 — JSON Lines 형식, 통계 수집
  • ✅ 보안 강화 — 헤더 정제, SSRF 방지, 도메인 차단
  • ✅ 성능 최적화 — ThreadPool, asyncio 비동기 방식
  • ✅ 모니터링 대시보드 — HTTP 관리 엔드포인트

이 코드를 기반으로 더 발전시켜서 실제 프로젝트에 활용해봐. 네트워크 프로그래밍은 처음엔 어렵게 느껴지지만, 직접 만들어보면 HTTP가 어떻게 동작하는지, TCP 소켓이 어떻게 쓰이는지 정말 깊이 이해하게 돼. 그리고 이런 경험이 쌓이면 Nginx, HAProxy 같은 프로덕션 프록시 서버의 동작 원리도 훨씬 쉽게 이해할 수 있어! 💪

혹시 이 코드를 발전시켜서 더 멋진 프록시 서버를 만들었다면, 재능넷에서 네트워크 프로그래밍 튜터링이나 맞춤형 프록시 서버 개발 서비스로 공유해보는 건 어때? 이런 실전 경험이 있는 개발자를 찾는 사람들이 분명 있을 거야! 🚀

🏗️ HTTP 프록시 서버 전체 아키텍처 ⚙️ 프록시 코어 요청 파싱 · 라우팅 HTTP / HTTPS 처리 스레드 풀 관리 💾 캐시 모듈 LRU · TTL · 통계 📝 로깅 모듈 JSON · 통계 · 파일 🛡️ 보안 모듈 SSRF · 헤더 정제 📊 관리 대시보드 HTTP API · 모니터링 💻 클라이언트 브라우저 / 앱 🌐 웹 서버 목적지 서버 Python · socket · threading · asyncio · select · json · hashlib
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.