- Published on
나라장터 입찰 공고 자동 모니터링 시스템 — 키워드 알림으로 사업 기회 놓치지 않기
- Authors

- Name
- 매크로 마스터

안녕하세요, 매크로마스터입니다.
이번 글은 나라장터 입찰 공고 자동 모니터링 시스템을 직접 짜고 운영하는 방법을 처음부터 끝까지 풀어두려고 합니다. 공공조달은 매출의 절반 이상이 정부·공공기관에서 나오는 중소 IT 업체, 인쇄·디자인 업체, 시설관리 회사, 컨설팅사한테는 거의 산소 같은 채널인데, 막상 들어가 보면 "우리 회사가 들어갈 만한 공고를 그날그날 어떻게 빠짐없이 찾느냐"가 영원한 숙제로 남습니다.
수동으로 매일 새로고침하는 회사가 아직 많습니다. 키워드 검색해서 엑셀로 옮겨 적고, 마감일 보고, 또 다음날 같은 일을 합니다. 이 글을 다 읽으시면 그 1시간짜리 수작업이 분 단위 자동화로 바뀌고, 키워드 매칭과 텔레그램 알림, LLM을 활용한 공고 분류까지 한 번에 정리됩니다. 텔레그램 봇 토큰 발급이 처음이시면 텔레그램 봇 만들기 글을 같이 보시면 좋고, 공공 API 활용 패턴 전반은 DART API 완벽 가이드와 국가법령정보 OPEN API에 시리즈로 묶어뒀습니다.
입찰 공고 모니터링이 왜 그렇게 어려운가
나라장터(국가종합전자조달, g2b.go.kr)에는 평일 기준 하루 평균 2,000건 안팎의 신규 입찰 공고가 올라옵니다. 분기 말이나 회계연도 마감(12월, 6월) 직전이 되면 하루 4,000건을 넘기도 합니다. 여기에 조달청 본청 공고, 각 지방자치단체 공고, 공공기관 자체 공고가 다 모입니다. 우리 회사가 정말로 들어갈 수 있는 건 그 중 0.5%, 많아 봐야 1%입니다.
문제는 그 0.5%를 찾는 데 들어가는 시간입니다. 사람이 들어갈 만한 공고를 검색하려면 보통 이런 순서를 거칩니다.
- 나라장터 입찰공고 검색 페이지 진입
- 업종/품명 코드 선택, 또는 키워드 입력
- 결과 페이지에서 한 건씩 클릭해서 본문 확인
- 우리 회사 자격으로 입찰 가능한지 (지역제한, 실적, 면허) 확인
- 마감일·예산·낙찰방식 확인 후 엑셀이나 노션에 기록
이걸 매일 한 명이 1시간씩 한다고 잡으면 월 20시간이 사라집니다. 그것보다 더 큰 문제는 놓치는 공고입니다. 키워드 검색은 공고명에 우리 키워드가 정확히 들어 있을 때만 잡힙니다. "소프트웨어 유지보수"로 검색하는데 실제 공고명이 "정보시스템 유지관리 용역"으로 등록되면 검색에서 안 나옵니다. 같은 일을 가리키는 표현이 발주처마다 미묘하게 다르고, 동의어 사전을 사람이 매번 머릿속에서 처리하는 건 거의 불가능에 가깝습니다. 실제로 의뢰주들 얘기를 들어보면 수동 모니터링으로 놓치는 공고가 30%는 된다고 합니다.
또 하나, 마감일 관리가 따로 또 일입니다. 공고가 떴는데 마감까지 7일밖에 안 남은 경우가 흔합니다. 자격 검토만 하다 시간이 지나가는 일이 비일비재합니다. 자동화의 핵심 가치는 단순히 "빠르게 본다"가 아니라, 놓치지 않는다 + 마감 전에 충분한 검토 시간을 확보한다에 있습니다.
나라장터 데이터, OPEN API와 웹 크롤링 중 어느 쪽이 정답인가
나라장터 데이터를 자동으로 받는 길은 두 가지입니다. 어느 쪽이 나은지는 케이스마다 다르니까 단점까지 정직하게 정리합니다.
1. 공공데이터포털 OPEN API (조달청 나라장터 입찰공고정보서비스)
조달청이 공공데이터포털(data.go.kr)을 통해 표준 API로 풉니다. 가장 많이 쓰는 게 "조달청_나라장터 입찰공고정보서비스"입니다. 공고번호, 공고명, 발주처, 입찰 마감일, 예산금액, 업종코드, 공고 URL 등 정형 필드를 JSON 또는 XML로 받을 수 있습니다.
장점은 명확합니다. 합법적이고, 안정적이고, 호출 한도(보통 1만 건/일)도 모니터링 용도엔 충분합니다. 서비스 키 한 장으로 끝나고, 응답 스키마가 깔끔합니다.
단점도 있습니다. 첫째, 첨부파일 본문은 안 들어옵니다. 입찰공고서, 과업지시서, 제안요청서(RFP) 같은 PDF·HWP 첨부는 API로는 못 받습니다. 둘째, 반영 지연이 있습니다. 나라장터 사이트에 게재된 직후 API에 반영되기까지 평균 10~30분이 걸립니다. 마감이 임박한 긴급 공고에서는 이 지연이 치명적일 수 있습니다. 셋째, 일부 비공개 항목이나 발주처 자체 공고(나라장터를 거치지 않고 자기 홈페이지에 올리는 곳)는 빠집니다.
2. 웹 크롤링 (나라장터 사이트 직접 파싱)
g2b.go.kr을 직접 긁는 방법입니다. requests + BeautifulSoup나, JavaScript 렌더링이 필요한 부분은 Playwright로 처리합니다.
장점은 첨부파일까지 다 받을 수 있고, 사이트 게재와 거의 동시에 반응할 수 있다는 점입니다. API에 안 나오는 항목까지 다 긁을 수 있습니다.
단점은 솔직하게 셋입니다. 우선 유지보수 비용입니다. HTML 구조가 한 번씩 바뀌면 크롤러가 그날 멈춥니다. 두 번째, IP 차단 위험입니다. 초당 호출 수를 조절하지 않으면 일시 차단됩니다. 세 번째, 이용약관 검토입니다. 자동 수집 자체가 명시적으로 금지되어 있지는 않지만, 과도한 트래픽이나 상업적 재배포는 문제가 될 수 있습니다. 실무에서는 합리적 호출 간격을 두고, 받은 데이터는 내부 모니터링용으로만 쓰는 게 안전합니다.
현실적인 답
대부분의 프로젝트에서 가장 잘 동작하는 조합은 이렇습니다.
- 메인 파이프라인은 OPEN API: 키워드 매칭, 알림, 모니터링 대부분을 API로 처리
- 첨부파일 확인이 필요한 공고만 크롤링: 키워드 매칭으로 후보가 좁혀진 뒤에만 본문 PDF/HWP를 받아옴
- 긴급 공고 대응이 핵심이면 두 채널 병행: API는 5분 폴링, 사이트 RSS와 실시간 페이지를 보조로
이 글의 본문은 OPEN API 중심으로 갑니다. 가장 안정적이고, 처음 도입하는 회사들이 90%의 가치를 가장 빨리 가져가는 길이기 때문입니다.
공공데이터포털 인증키 발급과 나라장터 API 등록
OPEN API를 쓰려면 공공데이터포털 계정과 서비스별 활용 신청이 필요합니다. 사이트별로 키가 따로 발급되는 게 아니라, 공공데이터포털 한 곳에서 통합 키 하나로 여러 서비스를 신청해서 쓰는 구조입니다.
1단계. 공공데이터포털 회원가입
data.go.kr에 들어가서 회원가입합니다. 사업자라면 사업자번호로 등록하는 게 호출 한도 협의가 쉽습니다.
2단계. "조달청_나라장터 입찰공고정보서비스" 활용 신청
상단 검색창에 "나라장터 입찰공고"로 검색하면 조달청이 제공하는 OPEN API 서비스가 나옵니다. 정식 명칭은 "조달청_나라장터 입찰공고정보서비스"이고, REST 방식이며 JSON/XML 두 가지 응답 포맷을 지원합니다.
활용 신청 버튼을 누르고, 활용 목적을 짧게 적습니다. "사내 입찰 모니터링 시스템 구축" 정도면 거의 즉시 자동 승인됩니다. 일부 서비스는 운영기관 심사가 들어가서 2~3일 걸리기도 하지만, 입찰공고정보는 대부분 자동 승인 카테고리입니다.
3단계. 서비스 키 확인
마이페이지의 "오픈 API > 개발계정"에 들어가면 발급된 인코딩 키와 디코딩 키가 둘 다 보입니다. 인코딩 키는 URL 파라미터에 그대로 붙여 쓸 수 있는 형태이고, 디코딩 키는 코드 안에서 직접 URL 인코딩할 때 쓰는 형태입니다. 라이브러리(파이썬 requests)가 자동으로 인코딩해주므로, 보통은 디코딩 키를 환경변수로 저장해두고 코드 안에서 처리합니다.
4단계. 호출 한도와 트래픽 제한 확인
기본 한도는 서비스마다 다른데, 입찰공고정보서비스는 일반적으로 일 10,000건 트래픽까지 줍니다. 모니터링 폴링 용도라면 5분 간격으로 하루 288회만 호출해도 거의 모든 신규 공고를 잡아냅니다. 키워드 검색을 페이지네이션 포함해서 돌려도 일일 한도에는 한참 못 미칩니다. 한도가 부족할 일은 거의 없습니다.
5단계. 엔드포인트 문서 확인
활용 신청한 서비스 상세 페이지에 OAS(OpenAPI Specification) 문서와 샘플 코드가 같이 있습니다. 자주 쓰는 오퍼레이션 위주로 정리하면 이렇습니다.
getBidPblancListInfoServc: 공고 목록 (용역) 조회getBidPblancListInfoCnstwk: 공고 목록 (공사) 조회getBidPblancListInfoThng: 공고 목록 (물품) 조회getBidPblancListInfoFrgcpt: 외자 공고 조회getBidPblancListInfoServcPPSSrch: 키워드 검색 (용역)
servc(용역), cnstwk(공사), thng(물품)이 핵심 3분류입니다. IT 회사라면 거의 servc만 보면 됩니다. 인쇄·디자인은 thng(물품) 비중이 높고, 시설관리는 servc + cnstwk 둘 다 봐야 합니다.
실전 Python 예제 1: 나라장터 OPEN API로 공고 검색
본격적으로 코드입니다. 외부 의존성 최소화를 위해 requests와 표준 라이브러리만 씁니다.
import os
import requests
from datetime import datetime, timedelta
from typing import Iterator
SERVICE_KEY = os.environ["G2B_SERVICE_KEY"] # 디코딩 키
BASE_URL = (
"https://apis.data.go.kr/1230000/ad/BidPublicInfoService"
)
def fetch_bid_list(
operation: str = "getBidPblancListInfoServc",
days: int = 1,
rows: int = 100,
) -> Iterator[dict]:
"""최근 N일치 입찰 공고 목록을 페이지 단위로 순회.
operation: servc(용역) / cnstwk(공사) / thng(물품) 셋 중 하나."""
end = datetime.now()
start = end - timedelta(days=days)
fmt = "%Y%m%d%H%M"
page = 1
while True:
params = {
"serviceKey": SERVICE_KEY,
"pageNo": page,
"numOfRows": rows,
"inqryDiv": 1, # 1: 공고게시일자 기준
"inqryBgnDt": start.strftime(fmt),
"inqryEndDt": end.strftime(fmt),
"type": "json",
}
r = requests.get(f"{BASE_URL}/{operation}", params=params, timeout=15)
r.raise_for_status()
body = r.json().get("response", {}).get("body", {})
items = body.get("items") or []
if not items:
break
for it in items:
yield it
total = int(body.get("totalCount", 0))
if page * rows >= total:
break
page += 1
if __name__ == "__main__":
for bid in fetch_bid_list(days=1, rows=100):
print(bid["bidNtceNo"], bid["bidNtceNm"], bid["dminsttNm"])
핵심 필드 몇 개만 짚어두겠습니다.
bidNtceNo: 입찰 공고번호 (재공고 카운트까지 포함된 고유 ID)bidNtceNm: 공고명dminsttNm: 수요기관명 (실제 발주 부서)ntceInsttNm: 공고기관명 (조달청, 지자체 등)bidNtceDt: 공고 게시일시bidClseDt: 입찰 마감일시presmptPrce: 추정가격 (단위: 원, 문자열로 옵니다)bidNtceDtlUrl: 공고 상세 페이지 URL
함정 하나, 응답 스키마가 두 가지 모드로 옵니다. 결과가 1건이면 items가 dict로 떨어지고, 여러 건이면 list로 떨어지는 케이스가 일부 엔드포인트에 남아 있습니다. 위 코드에서는 items를 list로 가정했는데, 안전하게 짜려면 isinstance(items, dict)로 분기해서 단일 dict일 때는 리스트로 감싸주는 한 줄을 넣어두는 게 좋습니다. 실제 운영 코드에서 가장 자주 만나는 사이드케이스입니다.
또 하나, 추정가격(presmptPrce)이 공란으로 오는 경우가 있습니다. 협상에 의한 계약이나 일부 수의계약은 예가를 비공개로 처리합니다. 추정가격 기준으로 필터링하는 코드는 빈 문자열을 0으로 캐스팅해버리지 않도록 주의하셔야 합니다.
실전 Python 예제 2: 키워드 매칭과 텔레그램 알림
이제 모니터링의 본체입니다. 매 5분마다 신규 공고를 받아서, 우리 회사 키워드 목록과 매칭되는 것만 텔레그램으로 쏩니다.
import os
import re
import sqlite3
import requests
from datetime import datetime, timedelta
KEYWORDS = [
"정보시스템 유지관리",
"소프트웨어 개발",
"홈페이지 구축",
"데이터 분석",
"클라우드",
"AI",
# 동의어/유사어를 의도적으로 넓게 둠
"유지보수", "운영", "고도화",
]
EXCLUDE = ["하드웨어 납품", "CCTV 설치"] # 우리가 안 하는 영역
TG_TOKEN = os.environ["TG_TOKEN"]
TG_CHAT_ID = os.environ["TG_CHAT_ID"]
DB_PATH = "g2b_seen.db"
def matches(name: str) -> list[str]:
"""공고명에 매칭되는 키워드 목록을 반환. 제외어가 들어 있으면 빈 리스트."""
if any(ex in name for ex in EXCLUDE):
return []
return [kw for kw in KEYWORDS if kw in name]
def notify(bid: dict, hits: list[str]) -> None:
price = bid.get("presmptPrce") or "공란"
if price.isdigit():
price = f"{int(price):,}원"
msg = (
f"📢 [{', '.join(hits)}] 신규 공고\n"
f"{bid['bidNtceNm']}\n"
f"발주: {bid['dminsttNm']}\n"
f"마감: {bid['bidClseDt']}\n"
f"추정가: {price}\n"
f"{bid['bidNtceDtlUrl']}"
)
requests.post(
f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
data={"chat_id": TG_CHAT_ID, "text": msg},
timeout=10,
)
def run_once() -> None:
conn = sqlite3.connect(DB_PATH)
conn.execute(
"CREATE TABLE IF NOT EXISTS seen("
"bid_no TEXT PRIMARY KEY, sent_at TEXT)"
)
for op in ("getBidPblancListInfoServc",
"getBidPblancListInfoThng",
"getBidPblancListInfoCnstwk"):
for bid in fetch_bid_list(operation=op, days=1, rows=100):
bid_no = bid["bidNtceNo"]
already = conn.execute(
"SELECT 1 FROM seen WHERE bid_no=?", (bid_no,)
).fetchone()
if already:
continue
hits = matches(bid["bidNtceNm"])
if hits:
notify(bid, hits)
conn.execute(
"INSERT INTO seen VALUES(?, ?)",
(bid_no, datetime.now().isoformat()),
)
conn.commit()
conn.close()
if __name__ == "__main__":
run_once()
리눅스 서버에 올린다면 cron으로 5분 간격이 표준입니다.
*/5 * * * * /usr/bin/python3 /opt/g2b-monitor/run.py >> /var/log/g2b.log 2>&1
GitHub Actions를 쓰셔도 됩니다. .github/workflows/g2b.yml에 schedule: - cron: "*/5 * * * *"로 걸어두면 서버 없이 운영할 수 있습니다. 단, Actions의 cron은 부하 상황에 따라 5~15분 지연되는 경우가 종종 있어서 SLA를 분 단위로 요구하는 환경이면 자체 서버가 안전합니다.
키워드를 짤 때 가장 중요한 건 동의어를 넓게 깔고 제외어로 잡음을 거르는 방식입니다. "유지보수"만 넣으면 "유지관리"가 빠지고, "유지관리"만 넣으면 "운영관리"가 빠집니다. 처음엔 넓게 잡고 알림이 시끄러우면 제외어를 늘려가는 쪽이 빠뜨리는 것보다 낫습니다.

실전 Python 예제 3: SQLite 저장과 정정공고 처리
매번 신규만 보는 게 아니라 과거 공고까지 검색하고 분석하려면, 받은 공고를 로컬 DB에 차곡차곡 쌓아두는 게 정석입니다. 그리고 나라장터에서 자주 만나는 케이스가 정정공고입니다. 같은 사업이 일부 조건 변경으로 1차, 2차로 다시 게재되는데, 공고번호의 차수만 바뀌면서 사실상 같은 사업입니다.
import sqlite3
from contextlib import closing
DDL = """
CREATE TABLE IF NOT EXISTS bid (
bid_no TEXT PRIMARY KEY,
bid_name TEXT,
bid_name_norm TEXT, -- 차수/접미사 제거된 정규화 공고명
notice_inst TEXT,
demand_inst TEXT,
notice_dt TEXT,
close_dt TEXT,
estimate_price INTEGER,
detail_url TEXT,
raw_json TEXT,
inserted_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bid_name_norm ON bid(bid_name_norm);
CREATE INDEX IF NOT EXISTS idx_bid_notice_dt ON bid(notice_dt);
"""
def normalize_name(name: str) -> str:
"""공고명에서 [재공고], (2차), 차수 표기를 제거해 정정공고를 묶음."""
import re
name = re.sub(r"\[(재|긴급|정정|취소).*?\]", "", name)
name = re.sub(r"\((\d+)\s*차\)", "", name)
name = re.sub(r"\s+", " ", name).strip()
return name
def upsert_bid(bid: dict, db_path: str = "g2b.db") -> bool:
"""저장 후 신규 사업인지 정정공고인지 반환 (True=신규)."""
with closing(sqlite3.connect(db_path)) as conn:
conn.executescript(DDL)
norm = normalize_name(bid["bidNtceNm"])
existed = conn.execute(
"SELECT bid_no FROM bid WHERE bid_name_norm=? "
"AND demand_inst=? AND notice_dt > date('now','-90 days')",
(norm, bid["dminsttNm"]),
).fetchone()
price_raw = bid.get("presmptPrce", "")
price = int(price_raw) if price_raw.isdigit() else None
conn.execute(
"INSERT OR REPLACE INTO bid VALUES("
"?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)",
(
bid["bidNtceNo"], bid["bidNtceNm"], norm,
bid["ntceInsttNm"], bid["dminsttNm"],
bid["bidNtceDt"], bid["bidClseDt"],
price, bid["bidNtceDtlUrl"],
str(bid),
),
)
conn.commit()
return existed is None # 직전 90일 안에 같은 사업이 없었으면 신규
upsert_bid가 신규로 판정한 건만 알림 채널로 보내고, 정정공고로 묶인 건은 "기존 공고가 정정되었습니다"라는 별도 채널로 분리하면 알림 피로감이 확 줄어듭니다. 실제로 사내 운영팀이 가장 짜증 내는 게 같은 사업 알림이 3번 4번 오는 거라서, 이 부분만 잘 처리해도 시스템 만족도가 크게 올라갑니다.
입찰 공고 분류 자동화 — LLM 활용 옵션
키워드 매칭만으로는 한계가 있습니다. "데이터 분석 시스템 구축"과 "통계 시스템 운영 용역"은 본질적으로 같은 영역인데 키워드가 안 겹칩니다. 이 빈틈을 LLM 기반 분류로 메울 수 있습니다.
방법은 두 갈래입니다.
A. 키워드 1차 필터 + LLM 2차 검토
키워드로 들어온 후보를 LLM에 "이 공고가 우리 회사 영역인가? (예/아니오 + 이유)"로 물어보는 방식입니다. 비용 부담이 적습니다. 하루 1만 건 공고 중 키워드 매칭 후보가 30~50건 정도이고, GPT-4 계열 mini 모델로 분류하면 한 건당 토큰 비용이 0.5원 미만, 월 1만원 안으로 끝납니다.
B. 임베딩 기반 의미 검색
회사가 과거에 수주했던 공고 본문과 RFP 텍스트를 임베딩해두고, 신규 공고를 같은 모델로 임베딩해서 코사인 유사도가 일정 임계값 이상이면 후보로 올리는 방식입니다. 키워드를 안 쓰고도 의미상 유사한 공고를 잡아냅니다. 단점은 초기 데이터(과거 수주 사례 30건 이상)가 있어야 하고, 임베딩 DB(FAISS, Chroma, pgvector 중 택일) 운영이 추가됩니다.
# B 방식의 핵심만 의사코드로
from openai import OpenAI
client = OpenAI()
def embed(text: str) -> list[float]:
return client.embeddings.create(
model="text-embedding-3-small",
input=text[:8000],
).data[0].embedding
# 우리 회사 과거 수주 사례를 미리 임베딩해서 저장
COMPANY_PROFILE = embed("\n\n".join(load_past_winnings()))
def relevance(bid_name: str, threshold: float = 0.45) -> bool:
v = embed(bid_name)
sim = cosine(v, COMPANY_PROFILE)
return sim >= threshold
실무 권장은 A + B 병행입니다. 키워드로 못 잡은 공고를 임베딩이 보완하고, 임베딩이 너무 넓게 잡은 후보를 LLM 분류가 정리합니다. 처음 도입한다면 A부터 시작해서 한 달 운영 데이터가 쌓이면 B를 얹는 순서가 안전합니다.
LLM을 쓸 때 마지막 한 가지, 프롬프트에 회사 컨텍스트를 충분히 넣어주세요. "우리는 50인 미만 SI 업체이고, 공공기관 정보시스템 유지관리와 데이터 분석 용역을 주력으로 합니다. 하드웨어 납품과 보안 컨설팅은 하지 않습니다" 같은 한 문단이 들어가야 분류 정확도가 확 올라갑니다. 회사 자기 소개를 빈약하게 두면 LLM이 알아서 판단해버리고, 그 판단이 자주 틀립니다.
사례: 중소 SI 업체의 ROI를 예로 들면
수치는 가상이지만, 실제로 이 규모 회사들에서 흔히 보는 패턴입니다.
상황
- 임직원 15명, 공공조달이 매출의 약 60%인 중소 SI 업체
- 영업기획팀 직원 1명이 매일 오전 1시간씩 나라장터를 수동 모니터링
- 키워드 후보 약 20개, 관심 발주처 약 30곳
- 월 수주 평균 2
3건, 그중 공공조달이 12건
자동화 전 (수동 운영) 추정 비용·손실
- 영업기획 인건비: 시간당 25,000원 × 1시간 × 22일 = 월 약 55만원 상당의 시간
- 놓치는 공고: 키워드 표현 차이로 약 30% 누락 추정
- 마감 임박 공고 대응 실패: 분기당 1~2건 (검토 시간 부족)
자동화 후 (월정기 운영) 예상 효과
- 매일 수동 검색 시간 0분 (자동 알림이 텔레그램으로 들어옴)
- 키워드 누락률 약 30% → 5% 미만 (동의어 사전 + LLM 분류 병행)
- 마감 D-7 이상 확보 비율 95% 이상 (게시 후 평균 15분 안에 알림)
- 월 운영비 50~80만원 (서버 인프라 포함, 클라이언트별 견적 다름)
가장 큰 가치는 비용 절감 자체보다 놓치지 않는다는 안정감입니다. 영업기획 담당자도 매일 아침 새로고침 반복에서 해방되고, 자기 본업인 제안서 작성과 발주처 영업에 시간을 더 쓸 수 있습니다. 본 사례는 일반적 패턴 기준으로 추정한 수치이며, 회사 규모와 키워드 범위에 따라 달라집니다. 실제로 진행해 드린 케이스들의 형태는 포트폴리오에서 확인하실 수 있습니다.
운영하면서 만나는 함정들
데모 코드는 위에서 정리한 대로 깔끔하지만, 6개월·12개월 운영해보면 사방에서 예외가 터집니다. 의뢰 프로젝트에서 가장 자주 만난 함정만 정리합니다.
서비스 키 인코딩 충돌
공공데이터포털 키가 +나 = 같은 문자를 포함하는 경우가 많습니다. requests가 알아서 URL 인코딩을 처리해주지만, 직접 URL 문자열을 조립하는 코드에서는 이중 인코딩이 일어나서 "SERVICE_KEY_NOT_REGISTERED_ERROR"가 떨어집니다. 거의 모든 첫 호출 실패가 이 이유입니다. 디코딩 키를 params dict로 전달하는 게 가장 안전합니다.
timezone 불일치
API는 KST 기준 YYYYMMDDHHMM 형식인데, 서버를 UTC로 운영하는 경우 9시간 차이로 빈 결과가 나옵니다. datetime.now(tz=ZoneInfo("Asia/Seoul"))로 명시적 처리하는 게 좋습니다.
중복 알림
신규 판정 로직이 단순 bidNtceNo 비교만 하면, 정정공고가 새 공고번호로 올라올 때 같은 사업 알림이 또 갑니다. 위에서 보여드린 정규화 공고명 + 발주처 + 90일 윈도우 기준의 중복 판정을 꼭 넣으세요.
휴일·연말 트래픽 폭증
회계연도 마감 직전(12월 둘째 주넷째 주)과 신회계연도 시작(1월 둘째 주)은 공고가 평소의 35배까지 올라옵니다. 키워드 범위가 넓으면 알림이 분 단위로 쏟아져서 가독성이 무너집니다. 시즌성 차단(특정 발주처만, 특정 금액 이상만) 로직을 따로 두는 게 좋습니다.
첨부파일 본문 안의 진짜 요구사항
공고명만 보고 매칭한 건 30%는 실제로 들어가보면 우리 영역이 아닙니다. 본문 PDF/HWP의 과업지시서를 받아서 LLM에 요약시키는 2차 워크플로를 붙이면 후보의 질이 크게 올라갑니다. HWP 파싱은 pyhwp나 olefile 기반으로 처리할 수 있고, PDF는 pdfplumber나 pymupdf가 안정적입니다.
API 응답 스키마의 단수/복수 분기
위에서도 언급했지만 가장 자주 만나는 사이드케이스입니다. 결과가 1건일 때 items가 dict로 떨어지는 엔드포인트가 여전히 남아 있습니다. 모든 응답 처리 함수에 단수/복수 분기를 기본으로 깔아두세요.
호출 실패 재시도 전략
조달청 API는 새벽 시간대(02:00~05:00) 정기 점검으로 일시 무응답이 됩니다. 지수 백오프(1초, 2초, 4초, 8초, 16초)로 5회 정도 재시도하는 로직을 넣어두면 대부분 알아서 복구됩니다. 5회 실패 후에도 응답이 없으면 Slack/Telegram으로 운영자한테 알림을 보내고 다음 cron 사이클로 넘기는 fallback이 안전합니다.
자주 묻는 질문
Q1. 나라장터 OPEN API는 유료인가요?
무료입니다. 공공데이터포털 가입 후 서비스별 활용 신청만 하면 됩니다. 입찰공고정보서비스는 대부분 자동 승인으로 즉시 사용 가능합니다.
Q2. 한 키로 여러 회사가 모니터링해도 되나요?
API 활용 신청은 사용 목적과 활용 주체를 명시하게 되어 있습니다. 사내 시스템 한 곳에서 쓰는 건 문제없지만, 외부 고객사 서비스로 재배포하는 형태는 운영기관 협의가 필요합니다. 외주로 구축하실 경우, 보통 고객사 명의로 키를 발급받고 코드와 키를 함께 이전합니다.
Q3. 나라장터 외에 다른 입찰 사이트도 같이 모니터링 가능한가요?
가능합니다. 전자조달 시스템은 나라장터 외에도 한국전력 KEPCO 입찰시스템, LH 공사 자체 입찰, 서울교통공사, 한국수자원공사 같은 자체 시스템이 있습니다. 각 기관별 API 또는 RSS, 크롤링을 조합해서 하나의 통합 알림으로 묶을 수 있습니다.
Q4. 알림이 너무 많이 오는데 어떻게 줄이나요?
세 가지를 동시에 조절하시면 됩니다. 첫째, 제외어 목록을 늘려서 명백히 우리 영역이 아닌 공고를 1차 차단합니다. 둘째, 추정가격 하한선을 둡니다. 영업 가능 규모 미만은 자동 스킵. 셋째, LLM 분류를 2차로 넣어서 키워드 매칭은 됐지만 본질적으로 우리 영역이 아닌 공고를 걸러냅니다.
Q5. 실시간 알림이 얼마나 빠른가요?
API는 폴링 방식입니다. 5분 간격으로 호출하면 평균 게시 후 약 5분 안에 알림이 갑니다. 최대 지연은 호출 주기 + API 반영 지연(10~30분)으로 약 35분입니다. 더 빠르게 가려면 호출 간격을 1분으로 줄이거나, 사이트 직접 크롤링을 보조 채널로 같이 두는 방법이 있습니다.
Q6. 첨부파일(과업지시서)까지 자동으로 받아서 요약할 수 있나요?
가능합니다. 공고 상세 페이지에서 첨부파일 URL을 파싱하고, HWP/PDF를 다운로드해서 텍스트 추출 후 LLM에 요약 프롬프트로 던지는 파이프라인을 추가하면 됩니다. 한 공고당 첨부가 5~10개씩 붙는 경우가 흔해서, 다운로드와 OCR 비용까지 고려한 견적이 필요합니다.
Q7. 직접 구축이 나은 경우와 외주가 나은 경우는?
사내 개발자 풀타임 1명 이상이 이미 있고, API 학습과 운영 모니터링 시간이 본업과 충돌하지 않는다면 직접 구축이 합리적입니다. 반대로 영업·기획 본업이 따로 있고 자동화는 도구로만 쓰는 경우, 외주로 한 번에 받아서 월정기 운영을 붙이는 쪽이 거의 항상 빠르고 저렴합니다. 같은 시스템을 여러 번 만들어본 사람이 짧게 끝낸다는 단순한 이유입니다.
Q8. 운영 중에 API 스펙이 바뀌면 누가 책임지나요?
월정기 운영 계약에 포함됩니다. 조달청 API는 연 1~2회 마이너 업데이트가 있고, 응답 필드 추가나 인코딩 정책 변경이 가끔 발생합니다. 자체 운영이라면 사내에서 모니터링·대응해야 하고, 외주 월정기 계약이면 운영사가 무중단으로 처리합니다.
마무리: 모니터링은 자동으로, 의사결정은 사람이
나라장터 자동 모니터링은 기술적으로 어렵지 않습니다. 인증키 받고, 키워드 짜고, 알림 봇 붙이는 데까지 빠르면 하루면 끝납니다. 정작 어려운 건 매일 안정적으로 받아서 알림이 누락 없이 가고, 정정공고를 깔끔하게 묶고, 6개월 12개월 운영하면서 키워드와 분류 정확도를 다듬어가는 작업입니다. 보통 한 분기는 잡아야 운영팀이 신뢰하고 쓰는 시스템이 됩니다.
본인 본업이 데이터 엔지니어링이 아니라면, 그 한 분기는 제안서 쓰고 발주처 만나는 데 쓰는 편이 훨씬 남습니다. 나라장터 크롤링, 입찰 모니터링 자동화, 공공조달 알림, 입찰 공고 자동 수집 시스템 구축·운영 의뢰는 서비스 페이지에서 견적과 월정기 운영 패키지를 확인하실 수 있습니다. 사내 키워드 사전과 발주처 화이트리스트만 미리 정리해 주시면, 보통 2~3주 안에 텔레그램 알림까지 가는 시스템을 납품해 드립니다.
현금영수증 또는 세금계산서 발행 가능합니다.
