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

HTTP 프록시 서버 소스코드 구현 완전정복 🚀
Python으로 직접 만드는 프록시 서버 — 개념부터 실전 코드까지
네트워크의 중간자, 프록시 서버를 직접 코딩해보자! 🌐
야, 솔직히 말해보자. 프록시 서버라는 단어, 들어본 적 있지? VPN 쓸 때, 회사 네트워크 설정할 때, 아니면 크롤링 막힐 때 "프록시 써봐"라는 말 한 번쯤은 들어봤을 거야. 근데 막상 "그게 뭔데?"라고 물어보면 대답하기 애매하잖아. 오늘은 그 프록시 서버를 직접 소스코드로 구현하면서 완벽하게 이해해보자! 🎯
프록시(Proxy)는 영어로 "대리인"이라는 뜻이야. 클라이언트(너의 브라우저)와 서버(네이버, 구글 같은 웹사이트) 사이에서 중간에서 요청을 대신 처리해주는 서버가 바로 프록시 서버야. 마치 심부름꾼처럼, 네가 "저 서버에 이 요청 좀 전달해줘"라고 하면 프록시가 대신 가서 받아오는 거지.
🔹 기업 방화벽 — 직원들의 인터넷 접속을 모니터링하고 특정 사이트 차단
🔹 캐싱 서버 — 자주 요청되는 콘텐츠를 저장해서 빠르게 응답 (CDN의 원리)
🔹 익명성 보장 — 실제 IP를 숨기고 프록시 IP로 요청 (VPN과 유사)
🔹 로드 밸런싱 — 여러 서버에 트래픽을 분산 (Nginx, HAProxy)
🔹 콘텐츠 필터링 — 악성 사이트 차단, 광고 제거
🔹 API 게이트웨이 — 마이크로서비스 앞단에서 요청 라우팅
이 글에서는 Python으로 HTTP 프록시 서버를 직접 구현할 거야. 기초적인 포워드 프록시부터 시작해서, HTTPS 터널링(CONNECT 메서드), 캐싱, 로깅까지 단계별로 쌓아올릴 거거든. 코드 한 줄 한 줄 이해하면서 따라오면 네트워크 프로그래밍 실력이 확 올라갈 거야! 💪
프록시에는 크게 두 종류가 있어. 헷갈리기 쉬우니까 확실히 짚고 넘어가자.
클라이언트 쪽에 위치. 클라이언트가 외부 서버에 접근할 때 대신 요청해줌.
예시: 회사 내부망 → 프록시 → 인터넷
서버 입장에서는 프록시 IP만 보임 (클라이언트 IP 숨김)
서버 쪽에 위치. 외부 클라이언트가 내부 서버에 접근할 때 앞단에서 처리.
예시: 인터넷 → Nginx → 내부 서버들
클라이언트 입장에서는 프록시만 보임 (내부 서버 숨김)
우리가 오늘 만들 건 포워드 프록시야. 클라이언트(브라우저)가 프록시 서버를 통해 외부 웹사이트에 접근하는 구조!
프록시를 만들려면 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> ← 응답 바디
프록시 서버는 결국 소켓 프로그래밍이야. 소켓은 네트워크 통신의 끝점(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() # 클라이언트 연결 수락
# 여기서 클라이언트 요청 처리...
자, 이제 진짜 코딩 시작이야! 먼저 가장 기본적인 형태의 HTTP 프록시 서버를 만들어보자. 이 버전은 HTTP(비암호화) 요청만 처리하는 단순한 포워드 프록시야.
proxy_server/
├── proxy.py # 메인 프록시 서버
├── cache.py # 캐싱 모듈 (나중에 추가)
├── logger.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 - 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()
daemon=True로 설정하면 메인 스레드(서버)가 종료될 때 자식 스레드들도 자동으로 종료돼. 이걸 안 하면 Ctrl+C로 서버를 끄려고 해도 클라이언트 처리 스레드들이 살아있어서 프로세스가 안 죽는 상황이 생길 수 있어!
기본 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 데이터가 그냥 통과 (프록시는 내용 모름)
# 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
CONNECT 터널링은 프록시가 암호화된 데이터를 그냥 통과시키는 방식이야. 즉, 프록시는 HTTPS 트래픽의 내용을 볼 수 없어. 만약 HTTPS 내용까지 검사하고 싶다면 SSL 인터셉션(MITM 프록시)이 필요한데, 이건 자체 CA 인증서를 발급해서 클라이언트에 설치해야 하는 복잡한 과정이 필요해. (Burp Suite, mitmproxy가 이 방식을 사용해)
프록시 서버의 핵심 기능 중 하나가 바로 캐싱이야. 같은 리소스를 여러 번 요청할 때 매번 서버에 가지 않고, 프록시가 저장해둔 응답을 바로 돌려주는 거지. 속도도 빠르고 서버 부하도 줄어들어!
# 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)
캐시가 꽉 찼을 때 가장 오래전에 사용된 항목을 제거하는 방식이야. Python의
OrderedDict를 사용하면 순서를 유지하면서 move_to_end()로 최근 사용 항목을 끝으로 보낼 수 있어. 첫 번째 항목이 항상 가장 오래된 거니까 제거할 때 next(iter(dict))로 쉽게 찾을 수 있지!
프록시 서버에서 로깅은 단순히 "뭔가 일어났다"를 기록하는 게 아니야. 누가 언제 어디에 접속했는지를 추적하는 중요한 기능이야. 기업 환경에서는 보안 감사, 문제 해결, 사용 패턴 분석에 필수적이지.
# 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()
자, 이제 지금까지 만든 모든 모듈을 합쳐서 완전한 HTTP 프록시 서버를 완성해보자! 캐싱, 로깅, HTTPS 터널링, 차단 기능이 모두 포함된 버전이야.
# 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()
# 터미널에서 실행
python proxy_complete.py
# 출력 예시:
# ============================================================
# 🚀 HTTP 프록시 서버 v2.0 시작!
# 📡 주소: 0.0.0.0:8080
# 💾 캐싱: 활성화
# 🚫 차단 도메인: 2개
# ============================================================
# 브라우저 프록시 설정: 127.0.0.1:8080
# 종료: Ctrl+C
# ============================================================
설정 → 시스템 → 컴퓨터 프록시 설정 열기 → 수동 프록시 설정
주소:
127.0.0.1, 포트: 8080Firefox 설정:
설정 → 일반 → 네트워크 설정 → 수동 프록시 구성
HTTP 프록시:
127.0.0.1, 포트: 8080curl 테스트:
curl -x http://127.0.0.1:8080 http://httpbin.org/get
# 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}")
지금까지 만든 프록시는 클라이언트마다 새 스레드를 생성하는 방식이야. 근데 동시 접속이 많아지면 스레드 생성/소멸 비용이 커져서 성능이 떨어질 수 있어. 이걸 해결하는 게 바로 스레드 풀(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 방식이 가장 효율적이야. 단일 스레드로 수천 개의 동시 연결을 처리할 수 있거든!
프록시 서버를 실제로 사용하려면 보안도 신경 써야 해. 특히 프록시 헤더 처리와 요청 필터링이 중요해.
# 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
프록시 서버를 통해 공격자가 내부 네트워크의 서버에 접근하려는 시도가 있을 수 있어. 예를 들어
http://192.168.1.1/admin이나 http://localhost:6379(Redis) 같은 요청을 프록시를 통해 보내는 거야. validate_host() 함수로 내부 IP 대역을 차단하는 게 필수야!
프록시 서버가 잘 돌아가고 있는지 확인하려면 모니터링이 필요해. 간단한 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 로테이션 프록시 | 다중 프록시 체인 |
🔹 개인정보 보호법 — 타인의 통신 내용을 무단으로 수집·저장하면 법적 문제가 생길 수 있어. 기업 환경에서는 직원에게 모니터링 사실을 고지해야 해.
🔹 저작권 — 캐싱된 콘텐츠를 무단으로 재배포하면 안 돼.
🔹 서비스 약관 — 특정 서비스의 약관에서 프록시 사용을 금지할 수 있어.
🔹 공개 프록시 운영 — 불특정 다수가 사용하는 공개 프록시는 악용될 위험이 높아. 반드시 인증 기능을 추가해야 해.
SSL 인터셉션 mitmproxy 방식으로 HTTPS 내용 검사
프록시 체인 여러 프록시를 연결해서 익명성 강화
콘텐츠 수정 응답 HTML에서 광고 제거, 스크립트 차단
속도 제한 클라이언트별 대역폭 제한 (Rate Limiting)
Redis 캐시 메모리 캐시 대신 Redis로 영구 캐싱
Docker 컨테이너화해서 어디서든 쉽게 배포
Prometheus 메트릭 수집으로 Grafana 대시보드 연동
이런 프록시 서버 구현 능력은 네트워크 프로그래밍, 보안, 인프라 분야에서 정말 귀하게 쓰여. 만약 이런 기술을 가지고 있다면 재능넷에서 네트워크 프로그래밍 관련 재능을 공유해보는 것도 좋은 방법이야! 프록시 서버 구축, 네트워크 보안 설정, API 게이트웨이 개발 같은 서비스 수요가 꽤 있거든. 🎯
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)을 bind() 전에 설정했는지 확인. 또는 lsof -i :8080으로 포트 사용 프로세스 확인 후 종료.
CONNECT 메서드 처리 함수가 올바르게 구현됐는지 확인. 브라우저 콘솔에서 "ERR_TUNNEL_CONNECTION_FAILED" 오류가 나면 터널 소켓 연결 부분 디버깅.
recv()를 한 번만 호출하면 데이터가 잘릴 수 있어. 루프로 전체 응답을 받아야 해. Content-Length 헤더를 확인하거나 연결이 닫힐 때까지 계속 받아야 함.
daemon=True 설정 확인. 소켓을 finally 블록에서 반드시 close() 호출. 장시간 실행 시 스레드 수를 주기적으로 모니터링.
URL에 한글이 포함된 경우 퍼센트 인코딩 처리 필요.
urllib.parse.quote()로 인코딩하거나, errors='ignore'로 디코딩 오류 무시.
일부 서버는 응답을 청크 단위로 보내. 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 같은 프로덕션 프록시 서버의 동작 원리도 훨씬 쉽게 이해할 수 있어! 💪
혹시 이 코드를 발전시켜서 더 멋진 프록시 서버를 만들었다면, 재능넷에서 네트워크 프로그래밍 튜터링이나 맞춤형 프록시 서버 개발 서비스로 공유해보는 건 어때? 이런 실전 경험이 있는 개발자를 찾는 사람들이 분명 있을 거야! 🚀
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.