전체 프로젝트운영 중

PG Insight

누구나 가입해 자신의 PostgreSQL을 등록하고, 본인이 등록한 DB만 보는 오픈 멀티테넌트 모니터링 서비스

이 프로젝트가 증명하는 것

  • 테넌트 격리: 전역 가드가 모든 :targetId 라우트의 소유자를 확인하고, 남의 대상에는 403이 아니라 404를 돌려줍니다. 보호되지 않은 변경 라우트가 추가되면 테스트가 실패합니다
  • SSRF 방어: DNS 응답의 모든 IP를 검사하고 연결을 검증한 IP에 고정합니다(TLS SNI 유지). 프로덕션에서 platform-db, 169.254.169.254, 127.0.0.1을 거부했습니다
  • 임의 SQL 제한: EXPLAIN을 6단계로 막고 READ ONLY와 statement_timeout 5초로 실행합니다. pg_sleep은 0.05초, generate_series(1, 10000000000)는 5.4초에 400으로 끊겼습니다
  • 모니터링 대상에 주는 부하를 측정했습니다: 10분 동안 CPU 1코어의 0.06%, 컨테이너 CPU +0.3%
  • 운영: SHA 태그 배포, health 확인 뒤에만 기록, 양방향 rollback 훈련
역할
단독 개발: 설계, 백엔드, 프론트엔드, 보안, 테스트, 배포와 운영 (커밋 81개)
기간
2026.08 – 2026.10
기술 스택
  • NestJS
  • TypeScript
  • Prisma
  • PostgreSQL
  • TimescaleDB
  • Socket.IO
  • React
  • Vite
  • Tailwind CSS
  • Recharts
  • Jest
  • Vitest
  • Docker
  • Caddy

핵심 수치

항목값구분증빙
10분 동안 대상 DB에 보낸 쿼리2,430측정값증빙 보기
대상 DB 부하 (CPU 1코어 기준)0.06 %측정값증빙 보기
쿼리 평균 실행 시간0.15 ms측정값증빙 보기
수집 데이터 지연1.4 s측정값증빙 보기
EXPLAIN 안의 pg_sleep 차단 시간0.05 s측정값증빙 보기
자동 테스트 (백엔드 188 + 프론트엔드 49)237측정값증빙 보기

해결하려는 문제

운영 중인 PostgreSQL의 느린 쿼리, 락 대기, 블로트, 복제 지연은 여러 도구와 SQL을 오가며 따로 확인해야 합니다. 이를 하나의 서비스로 모으되, 외부인이 남의 DB에 접속하는 공개 서비스인 만큼 테넌트 격리, SSRF 방어, 임의 SQL 제한을 처음부터 갖춰야 했습니다.

아키텍처

수집기가 각 DB에서 3초에서 5분 주기로 메트릭을 모아 TimescaleDB(hypertable 10개)에 기록하고, 실시간 화면은 대상 DB를 직접 조회합니다. 사용자, 대상, 알림, 감사 로그는 Prisma 7로 관리하는 플랫폼 DB(PostgreSQL 16)에 있습니다. NestJS 11의 전역 가드(Throttler → JWT → TargetAccess)가 모든 :targetId 라우트의 소유권을 확인하고, 대상 DB 쿼리는 대상별 pool(최대 5)에서 raw SQL로 실행합니다. 역할은 admin(첫 계정)과 user(본인 대상 최대 3개, 공인 주소만) 두 가지이며, UI는 한국어, 영어, 우즈베크어를 지원합니다.

데이터 모델

핵심 제약 조건과 인덱스

  • users.role DEFAULT 'user', bcrypt 12
  • refresh_tokens.tokenHash UNIQUE: SHA-256 해시만 저장, 사용할 때마다 교체
  • targets.passwordEncrypted: AES-256-GCM, createdByUserId = 테넌트 소유자, sslMode DEFAULT 'require'
  • hypertable 10개, 1일 chunk; connection_metrics, query_metrics는 7일 뒤 압축, retention 7–30일
  • audit_logs: 로그인, 가입, 대상 변경, 모든 EXPLAIN 기록

핵심 설계 결정

데모가 아닌 오픈 멀티테넌트 서비스

상황
누구나 자신의 DB를 직접 모니터링할 수 있어야 했습니다.
결정
가입을 열고, 사용자마다 본인이 등록한 대상만 보이게 했습니다.
트레이드오프
테넌트 격리, SSRF 방어, 할당량, 레이트 리밋이 필수가 됐습니다.

소유권 검사는 컨트롤러가 아니라 전역 가드에서

상황
새 라우트를 추가할 때 검사를 빠뜨릴 수 없어야 합니다.
결정
Throttler → JWT → TargetAccess 순서의 전역 가드가 모든 :targetId 라우트를 확인합니다.
트레이드오프
다른 ID를 쓰는 라우트에는 @TargetAccess 데코레이터가 필요합니다.

남의 대상에는 403이 아니라 404

상황
대상이 존재한다는 사실 자체도 드러내지 않아야 합니다.
결정
권한이 없으면 존재하지 않는 대상과 똑같이 404를 돌려줍니다.
트레이드오프
admin에 대해서도 존재 여부를 따로 확인해야 합니다.

호스트를 resolve해 IP에 고정

상황
검사 시점과 연결 시점의 DNS 응답이 다르면(DNS rebinding) 내부망에 접속할 수 있습니다.
결정
DNS 응답의 모든 IP를 net.BlockList로 검사하고, 검증한 IP로 직접 연결합니다.
트레이드오프
TLS SNI를 직접 넘겨야 하고, 셀프 호스팅용으로 TARGET_ALLOW_PRIVATE_HOSTS 옵션을 뒀습니다.

EXPLAIN은 남기되 READ ONLY + 5초 + 함수 차단

상황
소유자가 자기 DB의 실행 계획을 볼 수 있어야 하지만, EXPLAIN ANALYZE는 쿼리를 실제로 실행합니다.
결정
6단계 검사 뒤 READ ONLY 트랜잭션에서 statement_timeout과 lock_timeout을 걸고 실행합니다.
트레이드오프
시간과 함수 제한 때문에 일부 정상 쿼리도 거절될 수 있습니다.

새 대상의 기본값은 sslmode=require

상황
pg 드라이버에는 libpq의 prefer(TLS, 실패하면 평문)가 없어서 기본값 prefer는 실제로 암호화되지 않은 연결이었습니다.
결정
새 대상과 DB 기본값을 require로 바꿨습니다 (47287b4).
트레이드오프
TLS가 없는 DB는 사용자가 직접 disable을 골라야 합니다.

마이그레이션은 되돌리지 않는다

상황
rollback은 이미지만 바꿔야 빠르고 안전합니다.
결정
모든 마이그레이션을 추가형으로 작성합니다(먼저 추가하고 나중에 정리).
트레이드오프
모든 마이그레이션이 이전 코드와도 호환되어야 합니다.

개선 전후 측정

개선 전restart → 0 active targets, no data 14:23–17:25 UTC
개선 후restart → 2 active targets, 2,430 queries / 10 min증빙 보기
무엇을 바꿨나
수집 오케스트레이터가 onModuleInit에서 pool 연결 전에 시작되던 것을 onApplicationBootstrap으로 옮기고, 재시작 순서를 검증하는 lifecycle 테스트를 추가했습니다 (6246ce2). 같은 G 단계에서 lock 수집 RENAME 오류, 메트릭 시각, 일시정지 상태, TLS 기본값, /health 500 → 503도 고쳤습니다.

장애 대응 기록

증상
API를 재시작한 뒤 어떤 대상도 모니터링되지 않았습니다. 부하 측정에서 PG Insight 쿼리가 0건으로 나왔습니다.
원인
로그에 Starting collection for 0 active target(s)가 찍혔습니다. 오케스트레이터가 onModuleInit에서 pool이 연결되기 전에 시작됐고, 14:23–17:25 UTC 데이터가 사라졌습니다.
해결
시작 시점을 onApplicationBootstrap으로 옮겼습니다 (6246ce2). 재시작 직후 대상 2개 모두 수집됩니다.
재발 방지
오류를 재현하는 lifecycle 테스트를 추가했고, 일시정지한 대상을 건너뛰는지도 함께 검사합니다. 새 테스트는 이전 코드에서 실패하는 것을 확인했습니다.

테스트와 품질

테스트 수
237
커버리지
—
CI
GitHub Actions (워크플로 준비, billing 문제로 현재 중단)
테스트 도구
Jest, Supertest, Vitest, 실제 PostgreSQL 16

배포와 운영

배포 환경
iwinv VPS, Docker Compose: api(NestJS, non-root), web(Caddy + React 정적 파일, /api/*와 /socket.io/* 프록시), platform-db(PostgreSQL 16), metrics-db(TimescaleDB). DB 포트는 외부에 열지 않습니다. deploy.sh: 충돌 표시 검사 → git merge --ff-only → api와 web을 SHA 태그로 빌드 → health를 45 × 2초 대기 → 성공했을 때만 APP_TAG와 .deploy-history에 기록. API 컨테이너가 시작할 때 prisma migrate deploy를 실행합니다.
백업
서버 공통 cron 백업(매일 03:00, 7일 보관)에 platform-db와 metrics-db가 포함됩니다. 사용자 DB의 pg_dump 백업은 admin만 만들 수 있습니다.
롤백
rollback.sh는 빌드 없이 기존 이미지로 api와 web을 다시 만들고, 먼저 두 이미지가 있는지 확인합니다. 프로덕션에서 양방향(71dceaf ↔ d587ce6)으로 시험해 둘 다 health 200이었습니다.
모니터링
실사용 대상: LedgerCore와 FlashDrop 프로덕션 DB를 등록해 모니터링(같은 서버의 내부 Docker 네트워크). /api/v1/health(DB 장애 시 503), 감사 로그(로그인, 가입, 대상 변경, 모든 EXPLAIN), 가입은 IP당 시간당 5회, 로그인은 분당 10회로 제한. GitHub private vulnerability reporting(SECURITY.md)

스크린샷

한계와 회고

현재 한계

  • 계정 관리가 없습니다: 이메일 인증, 비밀번호 재설정, 계정 삭제 미구현
  • 진단과 보안 점검 결과, 권고, 오류 메시지는 백엔드에서 영어로만 옵니다 (UI는 3개 언어)
  • connection_by_app hypertable에는 retention이 없어 계속 커지고, 메트릭 DB에는 마이그레이션 체계가 없습니다(init-platform.sql은 볼륨을 처음 만들 때만 실행)
  • 실시간 소켓은 JWT와 소유권으로 보호되지만 UI는 아직 subscribeToTarget을 쓰지 않고 REST polling으로 갱신합니다
  • 모니터링 쿼리 최적화 전: pg_settings를 10초마다 다시 읽고 대상당 pool이 5개입니다. 캐시와 2–3개 pool이 다음 단계입니다
  • query_metrics 저장량(대상당 시간당 약 4MB)은 22분 측정 기준 추정치이며 24시간 측정은 아직입니다
  • 기존 대상 2개(LedgerCore, FlashDrop)는 내부 Docker 네트워크에서 prefer, 즉 암호화되지 않은 연결입니다(트래픽은 서버 밖으로 나가지 않음)
  • CI 워크플로는 준비됐지만 GitHub Actions billing 문제로 멈춰 있어 테스트를 로컬에서 실행합니다. 키 교체 스크립트(rotate-encryption-key.ts)는 Prisma 7 adapter 이전 코드라 프로덕션에서 시험하지 않았고, Playwright E2E는 없습니다

핵심 코드

현재 상태와 다음 단계

수집이 대상 DB에 주는 부하는 10분 동안 쿼리 2,430건, 실행 시간 합계 353.4ms로 CPU 1코어의 약 0.06%였습니다. 실제 프로덕션 DB(LedgerCore, FlashDrop)에서 5건, 코드 리뷰와 테스트에서 3건의 버그를 찾았고, 모두 먼저 테스트로 재현한 뒤 고쳤습니다. 다른 사례: 실시간 브로드캐스트 오류가 수집기를 멈추던 문제(b9825e4), lock 수집 쿼리의 RENAME 문법 오류(b9825e4), 연결 메트릭 시각이 세션 시작 시각으로 기록되던 문제(d587ce6, 잘못된 행 891개 삭제), 일시정지한 대상이 재시작 뒤 다시 켜지던 문제(41569c7), 비밀번호 변경 시 SSL 설정이 사라지고 prefer가 실제로는 암호화하지 않던 문제(41569c7, 47287b4), 첫 rollback 훈련에서 이전 이미지가 없어 스크립트가 안전하게 멈춘 일.