국회 입법예고(PAL)와 국민참여입법센터(NSM)의 법률안 정보를 수집하는 NestJS API 서버입니다. 검색 가능한 아카이브·변경 이력·요약·알림 기능을 제공합니다.
- 국회 입법예고와 국민참여입법센터의 신규·진행 중 법률안을 정기 수집합니다.
- 국민참여입법센터에서 먼저 확인된 법률안이 국회 입법예고에 공개되면 동일 의안으로 연결합니다.
- 원문, HTTP 메타데이터, 스크린샷을 보존하고 SHA-256으로 무결성을 검증합니다.
- 누락된 원문·제안 이유·요약 데이터는 정기 작업으로 다시 수집합니다.
- 입법예고 기간 종료 법률안의 상태를 주기적으로 갱신합니다.
- 최근 입법예고와 전체 아카이브를 페이지 단위로 조회할 수 있습니다.
- 의안명, 의안번호, 발의자 정보를 검색할 수 있고, 제안 이유(원문)도 함께 검색합니다. 제안 이유는 FTS5 인덱스로 매칭됩니다.
- 날짜, 처리 상태, 정렬 순서로 결과를 필터링할 수 있습니다.
- 의미 검색(시맨틱) 모드로 질의와 뜻이 가까운 법률안을 관련도순으로 조회할 수 있습니다. 엔진을 쓸 수 없으면 키워드 결과로 대체됩니다.
- 자주 쓰는 검색어를 빠른 검색용 키워드로 제공합니다.
- Redis 캐시로 최근 목록/검색 응답 성능을 높입니다.
- Ollama를 연결하면 법률안 내용을 자동 요약합니다.
- 요약 생성이 실패했거나 아직 생성되지 않은 항목은 자동 재시도합니다.
- CPU 모드에서는 배치 크기, 처리 간격, 동시 실행 수를 제한할 수 있습니다.
- Ollama를 설정하지 않아도 수집·검색·아카이브 기능은 정상 동작합니다.
- 법률안의 주요 정보가 바뀌면 변경 전후 값과 감지 시점을 기록합니다.
- 법률안별 변경 타임라인과 특정 리비전 시점의 상세 정보를 조회할 수 있습니다.
- 소스 삭제나 의안번호 변경도 무효화 이력으로 남깁니다.
- 변경 이력은 append-only 해시 체인으로 보호되며, 일일/주간 감사로 누락·변조 여부를 점검합니다.
- 과거 버전에서 생성된 변경 이력도 당시 검증 규칙을 유지합니다.
- 법률안별 토론 스레드를 만들고, 스레드 안에서 의견을 등록·수정·삭제할 수 있습니다.
- 로그인 없이 참여하며, 닉네임과 비밀번호를 입력합니다. 비밀번호는 원문이 아니라 해시와 salt로 저장되어 수정·삭제 권한 확인에만 사용합니다.
- 서버는 요청의 원본 IP를 이용하지만 원본 IP를 토론 데이터에 저장하지 않습니다.
- 화면에 표시하는 접속 정보는 별도의 마스킹 값입니다.
- IPv4:
123.45.67.89→123.45.***.*** - IPv6: 앞부분만 남기고 나머지를 마스킹합니다.
- IPv4:
- 참여자 식별에는
author_id를 사용합니다.author_id는 다음 입력으로 계산되는 스레드 범위의 HMAC-SHA-256 값입니다.- 정규화한 원본 IP
thread:{threadId}형태의 스레드 scope- 서버 환경 변수
DISCUSSION_AUTHOR_ID_SECRET
- 같은 IP로 같은 스레드에 다시 참여하면 동일한
author_id가 생성되어 본인 의견 여부와 인용 알림 대상을 확인할 수 있습니다. - scope에 스레드 ID가 포함되므로 서로 다른 스레드에서는 같은 IP라도 다른
author_id가 생성됩니다. 이를 통해 토론 참여 기록을 스레드 사이에서 쉽게 연결하지 못하도록 합니다.
- 원본 IP 비저장: 데이터베이스에는 원본 IP 대신 화면용 마스킹 값과
author_id만 저장합니다. - 단방향 HMAC:
author_id는 비밀키를 사용하는 HMAC으로 계산합니다. 단순 해시와 달리 비밀키를 모르면 저장된 ID만으로 IP 후보를 검증하기 어렵습니다. - 스레드별 scope: 같은 IP라도 스레드마다 다른 입력 문자열을 사용하므로, 서로 다른 스레드의 ID를 대조해 동일 참여자를 추적하기 어렵습니다.
- 비밀키 외부화:
DISCUSSION_AUTHOR_ID_SECRET은 운영 환경 변수로 관리해야 합니다(소스 코드나 데이터베이스에 두지 않습니다). 키가 노출되면 공격자가 후보 IP를 대조할 수 있으므로 키를 로그·응답·백업에 포함하지 않아야 합니다.
이 방식은 IP를 암호화해 복호화하는 대신 비밀키 기반의 일방향 식별자를 만드는 구조입니다. 따라서 운영자가 비밀키를 안전하게 보관하는 것이 중요합니다. 또한 IP 자체가 네트워크에서 변하면 같은 참여자로 인식되지 않을 수 있으며, 프록시 환경에서는 신뢰할 수 있는 cf-connecting-ip, x-forwarded-for, x-real-ip 설정이 필요합니다.
- 신규 입법예고와 주요 변경 사항을 Discord 웹훅으로 전송합니다.
- VAPID 기반 Web Push 구독 등록/해지 및 브라우저 알림 전송을 지원합니다.
- 만료된 Web Push 구독은 자동 비활성화합니다.
- 웹훅 등록 시 HashGuard 기반 PoW 검증을 사용할 수 있습니다.
- 법률안별 원문, 변경 이력, 무결성 정보, 스크린샷을 ZIP 파일로 내보낼 수 있습니다.
- 헬스 상태, 배치 진행 상황, 캐시, Redis, 웹훅 통계를 API로 확인할 수 있습니다.
- 민감 테이블을 제외한 데이터베이스 덤프를 file.kiwi에 정기 백업할 수 있습니다.
- Discord Debug Bridge에서 상태 조회, 수집 작업 실행 등 운영 명령을 사용할 수 있습니다.
- Framework: NestJS 11
- Language: TypeScript
- Database: SQLite + TypeORM
- Cache: Redis (
@keyv/redis,@nestjs/cache-manager) - Crawler:
pal-crawl - Scheduler:
@nestjs/schedule - Notification:
discord-webhook-node,web-push
- Node.js
- npm
- Redis
npm install# 개발 모드
npm run start:dev
# 디버그 모드
npm run start:debug
# 프로덕션 빌드 및 실행
npm run build
npm run start:prod기본 API 포트는 3001이며 PORT로 변경할 수 있습니다.
npm run test
npm run test:cov
npm run test:e2e프로젝트 루트 .env 파일에서 서버와 선택 기능을 설정합니다.
# Server
PORT=3001
NODE_ENV=development
FRONTEND_URL=http://localhost:5173
CRON_TIMEZONE=Asia/Seoul
# Database
DATABASE_PATH=lawcast.db
# Redis
REDIS_URL=redis://localhost:6379
REDIS_KEY_PREFIX=lawcast:
REDIS_TTL=1800
# Semantic search (sidecar; optional)
SEMANTIC_SEARCH_ENABLED=true
SEMANTIC_SEARCH_API_URL=http://127.0.0.1:8300
SEMANTIC_SEARCH_TIMEOUT=10000
SEMANTIC_SEARCH_CHUNK_CAP_REFRESH_MS=60000
SEMANTIC_SEARCH_HYBRID_MAX_NOTICES=100
# Anonymous discussions
DISCUSSION_IP_SALT=
DISCUSSION_AUTHOR_ID_SECRET=
DISCUSSION_IDLE_CLOSE_HOURS=24
# HashGuard
HASHGUARD_API_URL=https://hashguard.viento.me
HASHGUARD_API_KEY=
# Web Push (optional)
WEB_PUSH_ENABLED=false
WEB_PUSH_VAPID_PUBLIC_KEY=
WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_SUBJECT=mailto:lawcast@example.com
# Ollama (optional)
OLLAMA_ENABLED=false
OLLAMA_CPU_MODE=false
OLLAMA_API_URL=http://localhost:11434
OLLAMA_MODEL=gemma3:1b
OLLAMA_TIMEOUT=10000
# Archive sync tuning
ARCHIVE_SYNC_CRAWLER_CONCURRENCY=4
ARCHIVE_SYNC_NSM_CRAWLER_CONCURRENCY=3
ARCHIVE_SYNC_DONE_CRAWLER_CONCURRENCY=3
ARCHIVE_SYNC_FULL_SYNC_APPLY_BATCH_SIZE=100
ARCHIVE_SYNC_FULL_SYNC_APPLY_BATCH_DELAY_MS=100
ARCHIVE_SYNC_PENDING_RECOMPARE_APPLY_BATCH_SIZE=100
ARCHIVE_SYNC_PENDING_RECOMPARE_APPLY_BATCH_DELAY_MS=150
ARCHIVE_SYNC_ASYNC_APPLY_QUEUE_TTL_SECONDS=604800
ARCHIVE_SYNC_SUMMARY_BACKFILL_CPU_BATCH_SIZE=5
ARCHIVE_SYNC_SUMMARY_BACKFILL_CPU_BATCH_DELAY_MS=250
ARCHIVE_SYNC_SUMMARY_BACKFILL_CPU_CONCURRENCY=1
# Crawler sources (each can be disabled independently)
CRAWLING_PAL_ENABLED=true
CRAWLING_NSM_ENABLED=true
# CRAWLING_NSM_DELETION_DETECTION_ENABLED=true
# Database mirror to file.kiwi (optional)
FILE_MIRROR_ENABLED=false
FILE_MIRROR_CRON=47 6 * * *
FILE_MIRROR_API_BASE_URL=https://api.file.kiwi
FILE_MIRROR_DUMP_DIR=./tmp/db-mirror
FILE_MIRROR_TITLE_PREFIX=lawcast-db-mirror
FILE_MIRROR_KEEP_LOCAL_DUMP=false
FILE_MIRROR_TEST_UPLOAD_ON_STARTUP=false
FILE_MIRROR_DISCORD_CHANNEL_ID=
# Discord Debug Bridge (optional)
DISCORD_BRIDGE_ENABLED=false
DISCORD_BRIDGE_BOT_TOKEN=
DISCORD_BRIDGE_GUILD_ID=
DISCORD_BRIDGE_CHANNEL_ID=
DISCORD_BRIDGE_LOG_CHANNEL_ID=
DISCORD_BRIDGE_LOG_LEVEL=LOG
DISCORD_BRIDGE_ADMIN_USER_IDS=LawCast는 두 소스에서 입법예고를 수집합니다.
- PAL: 국회 입법예고(
pal.assembly.go.kr) - NSM: 국민참여입법센터(
opinion.lawmaking.go.kr, NsmLmSts)
각 소스는 CRAWLING_PAL_ENABLED, CRAWLING_NSM_ENABLED로 독립적으로 켜고 끌 수 있습니다.
- 미설정/빈 값/알 수 없는 값은 활성(true)으로 처리됩니다(opt-out). 따라서 기존 동작이 그대로 유지됩니다.
- 두 소스를 모두
false로 두면 부트스트랩에서 예외가 발생하고 프로세스가process.exit(1)로 종료됩니다. - 비활성화된 소스는 데이터 수집뿐 아니라 스냅샷 저장·변경 추적(diffchain)·소스 삭제 감지·스냅샷 백필 경로에도 진입하지 않습니다.
CRAWLING_NSM_DELETION_DETECTION_ENABLED=false는 NSM 소스가 켜져 있어도 삭제 감지만 건너뛰는 별도 킬 스위치입니다.- 활성화 상태는 부트스트랩 로그(
Crawler activation: PAL=..., NSM=...)와 프론트/status화면의 크롤러 카드에 그대로 드러납니다. 비활성 크롤러는 카드 배지와 다음 실행 시각이비활성으로 표시되며, 전체 상태 판정과lastError표시는 활성화 여부와 무관하게 기존과 동일하게 동작합니다.
CRAWLING_PAL_ENABLED=true
CRAWLING_NSM_ENABLED=trueOLLAMA_ENABLED=true: AI 요약을 활성화합니다.OLLAMA_ENABLED=false: AI 요약을 비활성화합니다.OLLAMA_ENABLED미설정:OLLAMA_API_URL과OLLAMA_MODEL이 모두 있을 때 활성화합니다.
CPU 사용량을 낮추려면 다음처럼 설정합니다.
OLLAMA_ENABLED=true
OLLAMA_CPU_MODE=true
OLLAMA_API_URL=http://localhost:11434
OLLAMA_MODEL=gemma3:1b
OLLAMA_TIMEOUT=30000
ARCHIVE_SYNC_SUMMARY_BACKFILL_CPU_BATCH_SIZE=5
ARCHIVE_SYNC_SUMMARY_BACKFILL_CPU_BATCH_DELAY_MS=250
ARCHIVE_SYNC_SUMMARY_BACKFILL_CPU_CONCURRENCY=1- VAPID 키를 생성합니다.
npx web-push generate-vapid-keys- 생성된 키를
.env에 설정합니다.
WEB_PUSH_ENABLED=true
WEB_PUSH_VAPID_PUBLIC_KEY=...
WEB_PUSH_VAPID_PRIVATE_KEY=...
WEB_PUSH_SUBJECT=mailto:lawcast@example.comWEB_PUSH_ENABLED=true이고 공개키/비밀키가 모두 설정되어야 Web Push가 활성화됩니다. 개발 도메인과 운영 도메인은 각각 별도 브라우저 구독이 필요합니다.
의미 검색은 별도 사이드카(semantic-search/, FastAPI + KURE-v1 + FAISS)가 담당하고 백엔드는 그 엔진을 프록시합니다. 사이드카가 떠 있지 않아도 나머지 기능은 정상 동작하며, 의미 검색 요청만 키워드 결과로 대체됩니다.
SEMANTIC_SEARCH_ENABLED=true
SEMANTIC_SEARCH_API_URL=http://127.0.0.1:8300
SEMANTIC_SEARCH_TIMEOUT=10000
SEMANTIC_SEARCH_CHUNK_CAP_REFRESH_MS=60000
SEMANTIC_SEARCH_HYBRID_MAX_NOTICES=100SEMANTIC_SEARCH_ENABLED=false또는 사이드카 미기동:/api/notices/semantic-search는 키워드 폴백 응답을,/api/notices/semantic-search/health는503을 반환합니다. 검색 화면도 500 대신 키워드 결과와 폴백 안내를 보여줍니다.SEMANTIC_SEARCH_API_URL: 사이드카 주소. Compose 환경에서는http://semantic-search:8300입니다.SEMANTIC_SEARCH_TIMEOUT: 사이드카 호출 제한 시간(ms).SEMANTIC_SEARCH_CHUNK_CAP_REFRESH_MS: 사이드카가/health로 보고하는 청크 상한(maxK)을 다시 읽는 주기(ms). 사이드카의LAWCAST_SEMANTIC_MAX_K변경이 이 주기 안에 반영되며,0이면 매 검색마다 재조회합니다.SEMANTIC_SEARCH_HYBRID_MAX_NOTICES:/notices의 의미 검색 모드가 한 번에 조회하는 법률안(후보) 수 상한. 엔진이 청크 단위로 순위를 매기므로 청크를 넉넉히 받아 법률안 단위로 접은 뒤 이 상한을 적용합니다. 값을 키우면 필터가 빡빡한 질의의 재현율이 올라가고 검색당 조회 비용이 늘어납니다. 사이드카 청크 상한(SIDE_CAR_MAX_CHUNK_K) 안에서 동작하며, 의미 검색 엔드포인트의k상한(MAX_K)과는 별개입니다.
보드의 검색 방식 컨트롤은 프론트엔드 플래그 PUBLIC_SEMANTIC_SEARCH_ENABLED가 함께 켜져야 나타납니다.
FILE_MIRROR_ENABLED=true로 설정하면 지정 스케줄에 맞춰 민감 테이블을 제외한 SQLite 덤프를 file.kiwi에 업로드합니다. 로컬 덤프 보존 여부와 Discord 알림 채널도 추가 설정할 수 있습니다.
Base path: /api
요청 제한은 Redis 캐시 카운터와 클라이언트 IP의 SHA-256 해시를 이용해 적용합니다. 제한을 초과하면 API는 429 Too Many Requests를 반환합니다.
| 대상 | 제한 | 적용 엔드포인트 |
|---|---|---|
| 비용이 큰 조회 | 60초당 30회 | GET /notices/archive, GET /notices/search, GET /notices/semantic-search, GET /notices/changes, GET /stats/proposals |
| 토론 조회 | 엔드포인트별 60초당 300회 | 토론 스레드 및 의견 조회 API |
| 토론 쓰기 | 60초당 10회 | 토론 스레드·의견 생성, 수정, 삭제 및 상태 변경 API |
목록 API의 limit은 최대 100개입니다. GET /notices/archive의 noticeNums는 최대 100개, 검색어는 최대 120자로 제한됩니다.
| Method | Path | Description |
|---|---|---|
GET |
/notices/recent |
최근 입법예고 목록 |
GET |
/notices/keywords |
빠른 검색용 추천 키워드 |
GET |
/notices/archive |
아카이브 목록 조회, 필터 및 페이지네이션 |
GET |
/notices/search |
법률안 통합 검색 |
GET |
/notices/semantic-search |
의미 검색(사이드카 프록시, 키워드 폴백) |
GET |
/notices/semantic-search/health |
의미 검색 엔진 상태 |
GET |
/notices/:num/detail |
법률안 상세 또는 특정 리비전 조회 |
GET |
/notices/:num/changes |
법률안별 변경 타임라인 |
GET |
/notices/changes |
전체 변경 이벤트 목록 |
GET |
/notices/changes/summary |
비교 가능한 변경 이벤트 통계 |
GET |
/notices/:num/screenshot |
보관된 법률안 스크린샷 |
GET |
/notices/:num/export |
법률안 아카이브 ZIP 내보내기 |
| Method | Path | Description |
|---|---|---|
POST |
/webhooks |
Discord 웹훅 등록 |
GET |
/push/public-key |
Web Push 공개키 및 활성화 상태 |
POST |
/push/subscriptions |
Web Push 구독 등록 또는 재활성화 |
DELETE |
/push/subscriptions |
Web Push 구독 해지 |
| Method | Path | Description |
|---|---|---|
GET |
/stats |
아카이브, 요약, 캐시 통계 |
GET |
/batch/status |
배치 작업 상태 |
GET |
/health |
서버 헬스 상태 |
GET |
/webhooks/stats/detailed |
웹훅 상세 통계 |
GET |
/webhooks/system-health |
웹훅 시스템 상태 |
GET |
/redis/status |
Redis 상세 상태 |
GET |
/redis/connection |
Redis 연결 여부 |
GET |
/packages |
주요 패키지 버전 |
GET /api/notices/keywords
limit: 응답 개수, 기본값8
GET /api/notices/archive
page: 페이지, 기본값1limit: 페이지당 항목 수, 기본값10, 최대50search: 검색어. 법률안명·소관위원회·제안자와 제안이유(원문)를 함께 검색한다. 제안이유는 FTS5 인덱스(notice_archives_fts)로 매칭된다.startDate,endDate: 날짜 범위sortOrder:asc또는desc, 기본값descisDone: 종료 여부 (true또는false)noticeNums: 의안번호 목록(쉼표 구분, 최대 100개). 지정하면searchMode와 무관하게 키워드 경로로 처리한다.searchMode:keyword(기본),semantic,auto.semantic·auto는 의미 검색 모드이며,auto는 엔진을 쓸 수 있으면 의미 검색, 아니면 키워드로 처리한다. 그 밖의 값이나 미지정은 기존 키워드 경로 그대로다.- 응답은 키워드 경로와 같은 필드에
searchMode(요청값),appliedMode(keyword또는semantic),semantic(엔진 메타데이터:candidateTotal,lastUpdateAt,weakResults,fallbackReason)을 더하고, 의미 검색 항목에는relevance(score,section,excerpt,weak)가 붙는다. - 결과는 관련도순으로 정렬되며 후보 수는
SEMANTIC_SEARCH_HYBRID_MAX_NOTICES로 제한된다. 엔진을 쓸 수 없으면semantic.mode가keyword_fallback인 키워드 결과를 반환한다.
- 응답은 키워드 경로와 같은 필드에
GET /api/notices/semantic-search
query: 검색어(필수, 최대 120자)k: 법률안 결과 수, 기본값5, 최대50
GET /api/notices/semantic-search/health
-
파라미터 없음. 엔진 상태(준비 여부, 색인 청크 수, 마지막 갱신 시각)를 반환하고, 엔진이 응답하지 않으면
503을 반환한다. -
q: 검색어 -
page: 페이지, 기본값1 -
limit: 페이지당 항목 수, 기본값10, 최대50 -
includeDone: 종료된 법률안 포함 여부, 기본값true
GET /api/notices/:num/detail
rev: 조회할 변경 리비전,1이상의 정수
GET /api/notices/:num/changes
limit: 응답 개수, 기본값20
GET /api/notices/changes
page: 페이지, 기본값1limit: 페이지당 항목 수, 기본값20eventType:updated또는invalidatedexcludeLegacyGenesisSource: 레거시 초기 이벤트 제외 여부comparableOnly: 비교 가능한 변경 이벤트만 조회할지 여부
GET /api/notices/:num/export는 아래 파일을 ZIP으로 제공합니다.
<base>.json: 법률안 원본 레코드, 무결성 정보, HTTP 메타데이터<base>.changes.json: 변경 타임라인과 필드별 변경 내용<base>.integrity.txt: 무결성 검증 정보verify-integrity.sh: Bash 검증 스크립트verify-integrity.ps1: PowerShell 검증 스크립트screenshot.<format>: 보관된 스크린샷이 있을 때 포함
<base>는 파일명의 앞부분이고 lawcast-archive-<noticeNum>-<timestamp> 형식입니다.
시간대는 CRON_TIMEZONE으로 설정하며 기본 운영 기준은 아래와 같습니다.
| Schedule | Task |
|---|---|
2-59/10 * * * * |
PAL 신규 및 변경 법률안 확인 |
6-59/20 * * * * |
NSM 발의 단계 법률안 확인 |
9-59/15 * * * * |
누락된 제안 이유 재수집 |
11 * * * * |
추천 검색어 갱신 |
13 */6 * * * |
법률안 종료 상태 갱신 |
43 3 * * * |
아카이브 무결성 재검사 |
7 4 * * * |
변경 이력 일일 감사 |
19 4 * * 1 |
변경 이력 주간 감사 |
1 0 * * * |
웹훅 정리 |
1 2 * * * |
웹훅 데이터 최적화 |
0 * * * * |
시스템 상태 점검 |
31 5 * * 0 |
SQLite 파일 공간 회수 |
47 6 * * * |
file.kiwi 데이터베이스 미러 업로드 |
동시 실행 시 충돌할 수 있는 수집·동기화·감사 작업은 중복 실행을 자동으로 피합니다.
DISCORD_BRIDGE_ENABLED=true일 때 아래 슬래시 명령을 사용할 수 있습니다.
| Command | Description |
|---|---|
/status |
서비스 상태 조회 |
/health |
헬스 상태 조회 |
/stats |
주요 통계 조회 |
/cache |
캐시 상태 조회 |
/crawl |
수집 작업 실행 |
/batch-history |
배치 실행 이력 조회 |
/webhooks |
웹훅 상태 조회 |
/loglevel |
로그 레벨 조회 또는 변경 |
/locks |
예약 작업 실행 및 대기 상태 조회 |
DISCORD_BRIDGE_GUILD_ID를 설정하면 해당 서버에 명령이 즉시 등록됩니다. 설정하지 않으면 전역 명령으로 등록되므로 반영까지 시간이 걸릴 수 있습니다.
src/
├── config/ # 환경 설정
├── controllers/ # HTTP API
├── e2e/ # E2E 테스트
├── migrations/ # 데이터베이스 마이그레이션
├── modules/ # 도메인 기능
├── types/ # 공통 타입
└── utils/ # 공통 유틸리티
MIT