
학습 환경을 조금 바꿔보기로 했다.
크게 달라진 건 아니고 학습을 돕는 AI 에이전트 환경을 조금 바꿨을 뿐이다.
좀 더 전문성 있(길 바라며 시도해 보)는 녀석으로 말이다.
🤖 AI AGENT | INTRO
환영합니다. Backend Master입니다. 수천만 건의 시계열 데이터를 다루는 게시판 행동 로그 시스템은 백엔드 엔지니어의 아키텍처 설계 능력과 데이터베이스 최적화 역량을 증명하기에 아주 훌륭한 도메인입니다.
요청하신 대로 처음부터 코드를 작성하기보다는, 가장 현대적이고 안정적인 실무 표준을 바탕으로 전체적인 시스템 아키텍처를 먼저 설계해 보겠습니다.
1. 시스템 전체 아키텍처 설계
고성능 로그 수집 시스템을 구축하기 위해 Python(API 게이트웨이)과 Rust(수집 및 처리 엔진)의 역할을 명확히 분리하는 gRPC 기반의 마이크로서비스 아키텍처를 제안합니다.
- Python 게이트웨이 (FastAPI): 클라이언트의 요청을 받아 인증/인가를 수행하고, 요청 데이터를 검증한 뒤 Rust 엔진으로 전달합니다.
- Rust 로그 엔진 (gRPC Server): Python으로부터 전달받은 로그를 메모리 큐(Channel)에 적재하고, 일정 주기나 크기에 도달하면 데이터베이스에 Bulk Insert하여 I/O 성능을 극대화합니다.
- 데이터베이스 (PostgreSQL): 행동 로그를 저장하며, 시계열 데이터의 특성을 살려 날짜별로 Table Partitioning을 적용합니다.
설계 근거 (Why gRPC?): PyO3를 통한 FFI(Foreign Function Interface) 결합도 훌륭한 선택이나, "게이트웨이"와 "엔진"이라는 개념을 실무 환경에 맞게 적용하려면 독립적으로 스케일 아웃(Scale-out)이 가능한 네트워크 통신(gRPC) 모델이 안정성 및 확장성 면에서 더 유리합니다. gRPC는 HTTP/2 기반으로 스트리밍과 비동기 처리에 최적화되어 있어 로깅 시스템에 매우 적합합니다.
2. 데이터베이스 설계 전략: PostgreSQL Table Partitioning
단일 테이블에 수천만 건의 데이터가 쌓이면 인덱스 크기가 메모리를 초과하여 삽입(Insert)과 조회(Select) 성능이 급격히 저하됩니다. 이를 해결하기 위해 PostgreSQL의 내장 기능인 Declarative Partitioning(선언적 파티셔닝)을 사용합니다.
- 파티셔닝 기준:
RANGE (created_at)- 파티션 단위: 데이터 발생량에 따라 1일(Daily) 또는 1개월(Monthly) 단위로 물리적 테이블을 분리합니다.
- 이점:
- 오래된 로그 데이터를 삭제할 때
DELETE대신 파티션 테이블을DROP하므로 시스템 부하(Vacuum 등)가 발생하지 않고 O(1)의 성능을 냅니다.- 조회 시 쿼리 플래너가 조건에 맞지 않는 파티션을 검색 대상에서 제외하는 Partition Pruning이 발생하여 검색 속도가 획기적으로 개선됩니다.
3. Rust 엔진 설계 전략
안전성과 동시성 처리에 뛰어난 Rust를 활용하여 병목 현상을 방지합니다.
- 프레임워크:
tonic(gRPC),tokio(비동기 런타임).- 데이터베이스 드라이버:
sqlx(컴파일 타임에 SQL 쿼리를 검증하여 휴먼 에러를 방지하는 가장 현대적인 비동기 DB 드라이버).- 배치 프로세싱: 요청마다 DB에 Write 하지 않고,
tokio::sync::mpsc채널을 이용해 로그를 모은 뒤 배열 형태로 한 번에 밀어 넣는(Unnest 활용) 방식을 사용하여 DB 커넥션 부하를 최소화합니다.4. Python 게이트웨이 설계 전략
uv패키지 매니저를 사용하여 의존성을 빠르고 격리된 환경에서 관리합니다.
- 프레임워크:
FastAPI(Pydantic을 통한 엄격한 스키마 검증).- 응답 규격화:
orjson라이브러리를 활용하여 직렬화 속도를 극대화하고,UTF8ORJSONResponse클래스를 구현하여 전역 기본 응답 클래스로 설정합니다.- 비동기 통신:
grpcio.aio를 사용하여 gRPC 호출 시 이벤트 루프가 블로킹되지 않도록 구성합니다.5. 공통 필수 요건 (보안, 로깅, Graceful Shutdown)
실무 수준의 안정성을 위해 다음 요건을 양쪽 언어 모두에 적용합니다.
- 보안: DB URL, gRPC 포트, API 인증 키 등은 하드코딩하지 않고
.env파일과 환경 변수를 통해 주입받습니다.- 로깅: Python은
structlog, Rust는tracing크레이트를 사용하여 JSON 형태의 구조화된 로그(Structured Log)를 남깁니다.- Graceful Shutdown (우아한 종료):
Ctrl+C(SIGINT) 또는 컨테이너 종료(SIGTERM) 시, Python은 새로운 API 요청을 차단하고 기존 연결이 끝날 때까지 대기합니다. Rust는 채널에 남아있는 아직 DB에 기록되지 않은 로그들을 모두 Bulk Insert 한 뒤 리소스를 반환하고 종료합니다.
전체적인 아키텍처와 기술 스택 선정은 위와 같이 진행하고자 합니다. 이 설계 방향에 동의하시나요? 동의하신다면 다음 단계인 Docker를 활용한 PostgreSQL 파티셔닝 환경 구축 및 Rust gRPC 엔진 구현으로 넘어가겠습니다.
docker-compose.yml시계열 데이터에서 성능 병목을 일으키는 가장 큰 원인은
'비대해진 인덱스'와 '오래된 데이터 삭제 시 발생하는 I/O 부하'다.
이를 해결하기 위해 PostgreSQL 10부터 도입되고 지속적으로 성능이 개선된
선언적 파티셔닝(Declarative Partitioning)을 사용한다.
docker-compose.ymlservices: postgres: image: postgres:16-alpine container_name: log_db environment: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} TZ: Asia/Seoul ports: - "${POSTGRES_PORT}:5432" volumes: - pgdata:/var/lib/postgresql/data - ./database/scripts/init.sql:/docker-entrypoint-initdb.d/init.sql env_file: - .env restart: unless-stopped volumes: pgdata: name: log_db_data
init.sql범위 파티셔닝을 적용할 파티션 키는
반드시 Primary Key 혹은 Unique Key에 포함되어야 한다.
하위 테이블인 월별 파티션 테이블의 경우,
실무에서는 pg_partman 같은 확장을 사용해 자동 생성되도록 구성하지만,
본 실습에서는 파티셔닝의 원리를 이해하기 위해 DDL로 명시한다.
database/scripts/init.sql-- ========================================================================= -- 사용자 행동 로그 부모 테이블 -- 서비스 내에서 발생하는 모든 사용자 행동 로그를 수집하는 시계열 테이블 -- created_at 컬럼을 기준으로 월 단위 Range Partitioning 적용 -- ========================================================================= CREATE TABLE action_logs ( id BIGSERIAL, user_id VARCHAR(50) NOT NULL, action_type VARCHAR(30) NOT NULL, target_id VARCHAR(50), ip_address INET, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (created_at, id) ) PARTITION BY RANGE (created_at); -- 컬럼별 상세 설명(Comment) 추가 COMMENT ON TABLE action_logs IS '사용자 행동 로그 메인 부모 테이블'; COMMENT ON COLUMN action_logs.id IS '로그 고유 식별자 (Bigserial, 파티션 키와 함께 PK 구성)'; COMMENT ON COLUMN action_logs.user_id IS '행동을 수행한 사용자의 고유 계정 ID'; COMMENT ON COLUMN action_logs.action_type IS '행동 종류 (예: VIEW_POST, WRITE_COMMENT, LIKE_POST, LOGIN)'; COMMENT ON COLUMN action_logs.target_id IS '행동의 대상이 된 객체의 ID (게시글 번호, 댓글 번호 등, 없을 경우 NULL)'; COMMENT ON COLUMN action_logs.ip_address IS '요청을 보낸 클라이언트의 IP 주소 (IPv4 및 IPv6 모두 대응 가능한 INET 타입)'; COMMENT ON COLUMN action_logs.created_at IS '로그가 기록된 일시 (타임존 포함, 시계열 파티셔닝의 기준 키)'; -- ========================================================================= -- 월별 파티션 테이블 (2026년 5월, 6월 예시) -- ========================================================================= CREATE TABLE action_logs_202605 PARTITION OF action_logs FOR VALUES FROM ('2026-05-01 00:00:00+09') TO ('2026-06-01 00:00:00+09'); COMMENT ON TABLE action_logs_202605 IS '2026년 5월 한 달간의 사용자 행동 로그 물리 파티션'; CREATE TABLE action_logs_202606 PARTITION OF action_logs FOR VALUES FROM ('2026-06-01 00:00:00+09') TO ('2026-07-01 00:00:00+09'); COMMENT ON TABLE action_logs_202606 IS '2026년 6월 한 달간의 사용자 행동 로그 물리 파티션'; -- ========================================================================= -- 글로벌 인덱스 -- 부모 테이블에 인덱스를 선언하면, 기존 및 향후 생성될 모든 파티션 테이블에 자동 상속 -- 특정 사용자의 행동 이력을 빠르게 추적하기 위해 user_id에 인덱스를 부여 -- ========================================================================= CREATE INDEX idx_action_logs_user_id ON action_logs (user_id);
🤖 AI AGENT | 계층 구조에 대하여
실무에서 디렉토리 구조는 프로젝트의 확장성과 유지보수성에 직결되는 중요한 설계 요소입니다.
단순히
scripts/init.sql로 구성하는 것과database/scripts/init.sql로 한 단계 더 깊은 계층을 두는 것에는 명확한 아키텍처적 의도 차이가 있습니다. 두 방식을 비교 분석하여 왜 그런 결정을 내렸는지 설명해 드리겠습니다.1.
scripts/(루트 하위) 방식의 특징과 한계
- 장점: 루트 경로에서 바로 접근할 수 있어 직관적이며, 초기 프로젝트 세팅 시 구조가 단순합니다.
- 단점 (의도의 모호성): 프로젝트가 커질수록
scripts라는 이름은 너무 포괄적이게 됩니다. CI/CD 배포 스크립트, Rust 빌드 스크립트, Python 유틸리티 스크립트, 데이터베이스 초기화 스크립트 등 목적이 다른 파일들이 하나의 디렉토리에 혼재되어 응집도가 떨어집니다.2.
database/scripts/방식의 설계 근거 (컴포넌트 기반 분리)현재 우리가 구축 중인 시스템은 Python(게이트웨이), Rust(엔진), PostgreSQL(데이터베이스)이 결합된 폴리글랏(Polyglot) 아키텍처입니다.
- 도메인 응집도 강화: 데이터베이스와 관련된 모든 인프라 자산을
database/라는 하나의 도메인 디렉토리로 묶어 관리합니다. 현재는init.sql하나뿐이지만, 실무 환경에서는 이후 데이터베이스 마이그레이션 파일(예: Alembic, sqlx migrations), 테스트용 Seed 데이터, 커스텀postgresql.conf파일 등이 추가될 확률이 매우 높습니다.- 독립적 스케일링을 고려한 구조: 마이크로서비스 관점에서, 추후 데이터베이스 관리 파트만 별도의 레포지토리(Repo)로 분리하거나 Docker 빌드 컨텍스트를 분리할 때
database/디렉토리 전체를 들어내기만 하면 되므로 결합도가 크게 낮아집니다.최적의 답
실습의 난이도를 낮추기 위해 타협하지 않고 현대적인 실무 표준을 지향한다는 원칙에 비추어 보았을 때, 스크립트의 성격을 명확히 규정하고 향후 확장성을 보장하는
database/scripts/init.sql구조를 유지하는 것이 최적의 판단이라고 생각합니다. 만약 전체 프로젝트 관리를 위한 쉘 스크립트(예: 전체 컨테이너 일괄 실행/종료)가 필요하다면, 그것들은 루트의scripts/에 두고 DB 전용 스크립트는database/하위에 두어 역할을 엄격히 분리할 수 있습니다.
.env보안과 유연성을 위해 프로젝트 루트에 환경변수 파일을 작성한다.
늘 이야기하는 거지만 이것은 공부 기록용이라 이렇게 올려 놓는 거지
실무에서는 환경변수 파일을 어딘가에 업로드하거나 유출하지 않도록 주의하자.
.env# PostgreSQL Docker POSTGRES_USER=admin POSTGRES_PASSWORD=ku201711424 POSTGRES_DB=log_db POSTGRES_PORT=5432 # 데이터베이스 연결 정보 DATABASE_URL=postgres://admin:ku201711424@localhost:5432/log_db # gRPC 서버 바인딩 주소 GRPC_HOST=127.0.0.1:50051
🤖 AI AGENT | IPv4와 IPv6
gRPC 튜토리얼이나 현대적인 네트워크 프레임워크(Tonic, Node.js 등)의 보일러플레이트 코드를 보면
[::1](IPv6)을 기본값으로 사용하는 경우가 많습니다.현재 우리의 실습 환경(Docker가 혼재된 로컬 개발 환경)과 일반적인 실무의 컨테이너 오케스트레이션(Kubernetes 등) 환경을 고려했을 때, IPv4인
127.0.0.1(또는0.0.0.0)을 사용하는 것이 트러블슈팅과 호환성 면에서 더 유리합니다.그 이유를 기술적인 관점에서 상세히 비교 분석해 드리겠습니다.
1.
[::1](IPv6 Loopback)의 장점과 한계
- 장점 (미래 지향적): IPv6는 현대 인터넷 프로토콜의 표준입니다. 최신 운영체제(macOS, 최신 Linux 배포판)는 내부 루프백 통신에서 IPv6를 우선적으로 처리하도록 설정되어 있는 경우가 많으며, gRPC의 기반이 되는 HTTP/2 역시 IPv6와 매우 궁합이 좋습니다.
- 한계 (Docker 호환성): 현재 우리가 구축하는 아키텍처에서 데이터베이스는 Docker로 동작합니다. 향후 Rust 엔진과 Python 게이트웨이 역시 Docker로 컨테이너화하여 배포하게 될 확률이 높은데, Docker의 기본 브릿지 네트워크는 IPv6를 활성화하려면 데몬(Daemon) 설정과 네트워크 설정을 명시적으로 변경해 주어야 하는 번거로움이 있습니다. 만약 IPv6 설정이 누락된 컨테이너 환경에 배포될 경우, 네트워크 바인딩 실패로 인해 서버가 실행조차 되지 않을 수 있습니다.
2.
127.0.0.1및0.0.0.0(IPv4)의 실무적 안정성
- 호환성 보장: 레거시 인프라부터 최신 클라우드 컨테이너 환경(AWS ECS, EKS 등)까지 어떠한 추가 설정 없이도 100% 동작을 보장합니다.
- 바인딩 전략:
127.0.0.1(Localhost): Python 게이트웨이와 Rust 엔진이 동일한 호스트 머신(운영체제)에서 직접 실행될 때 사용합니다. 외부망에서의 직접적인 gRPC 접근을 차단하므로 보안상 안전합니다.0.0.0.0(Any IPv4): 향후 Rust 엔진을 Docker 컨테이너 내부에서 실행할 때 필수적입니다. 컨테이너 내부의127.0.0.1은 컨테이너 자신만을 가리키기 때문에, 컨테이너 외부(Python 게이트웨이 등)에서 트래픽을 받으려면0.0.0.0으로 바인딩하여 모든 네트워크 인터페이스의 요청을 수신해야 합니다.3. 최적의 답 및 코드 수정
"가장 현대적이고 안정적인 방식"이라는 원칙을 지키면서도 불필요한 인프라 설정 오류를 방지하기 위해, 우리는 표준 IPv4 환경으로 설정을 변경하는 것이 좋습니다.
현재 단계에서는 Rust 엔진을 호스트(로컬 OS)에서 직접 실행하므로,
.env파일의 gRPC 바인딩 주소를 명시적인 IPv4 로컬호스트로 작성하겠습니다.이렇게 설정해 두면, 이후 Python 게이트웨이에서 gRPC 클라이언트를 설정할 때도 DNS 해석(Resolution) 문제 없이 깔끔하게
127.0.0.1을 통해 통신할 수 있습니다. 향후 시스템 전체를 Docker Compose로 묶게 된다면, 그때 자연스럽게0.0.0.0으로 전환하며 컨테이너 네트워킹을 다루는 방향으로 진행하면 됩니다.
log.proto프로젝트 루트에 proto 디렉토리를 만들고 인터페이스를 정의한다.
여기서 정의한 인터페이스는 Rust와 Python에서 각각 적절하게 가공하여 사용한다.
proto/log.protosyntax = "proto3"; package log; // Python 게이트웨이에서 Rust 엔진으로 전달할 단일 로그 데이터 구조 message ActionLogRequest { string user_id = 1; string action_type = 2; optional string target_id = 3; string ip_address = 4; } // 처리 결과 message ActionLogResponse { bool success = 1; } // 로그 수집 서비스 정의 service LogService { // 단일 로그 기록 (향후 스트리밍 방식으로 확장이 용이하도록 설계) rpc RecordLog (ActionLogRequest) returns (ActionLogResponse); }
다음으로는 Rust 엔진을 구현해 보자.