이 글은 정부지원사업 공고를 반복 확인하는 창업팀·운영 담당자를 위한 가이드다. K-Startup 공식 API를 페이지네이션으로 수집해 후보를 좁히되, 신청 자격과 마감은 공고 원문에서 최종 확인하도록 설계한다.
원문 확인 필수
자동 필터와 AI 추천은 검토할 후보일 뿐이다. 신청 자격, 제외 업종, 제출 서류, 접수 마감 시각, 변경·연장 여부는 반드시 K-Startup 공식 포털의 공고 원문과 첨부파일에서 다시 확인한다.
이 글은 서비스 키를 노출하지 않는 Python 표준 라이브러리 예제와 누락·중복 테스트까지 다룬다. 기존 글의 근거 없는 공고 건수·시간 절감·사업 성과 수치는 사용하지 않는다.
먼저 알아둘 두 API의 차이
K-Startup 지원사업 공고 정보 API와 통합공고 지원사업 정보 API는 용도가 다른 데이터다(확인일: 2026-07-20).
| 구분 | 지원사업 공고 정보 | 통합공고 지원사업 정보 |
|---|---|---|
| 목적 | 현재의 개별 모집 공고 수집 | 연도별 사업 소개·예산·지원 내용 조회 |
| 대표 필드 | 공고 일련번호, 공고명, 접수기간, 대상, 지역, 모집 진행 여부, 상세 URL | 사업 카테고리, 사업 제목, 지원 대상·예산·내용, 사업연도, 상세 URL |
| 이 글의 용도 | 매일 수집하고 알림할 기본 데이터 | 공고를 사업 단위로 보강할 때 선택 사용 |
매일 새 공고와 마감 변경을 확인하려면 지원사업 공고 정보를 기본으로 사용한다. 통합공고 데이터는 개별 접수 공고를 대체하지 않는다.
공식 문서에서 확인한 공고 조회 주소는 다음과 같다.
https://apis.data.go.kr/B552735/kisedKstartupService01/getAnnouncementInformation01
1. 무료 API 이용 신청과 키 보관
- 공공데이터포털의 K-Startup 조회서비스에 로그인한다.
- 활용신청을 선택하고 개발 용도를 작성한다.
- 승인된 일반 인증키를 발급받는다.
- 키를 소스 코드에 붙이지 말고 환경 변수
KSTARTUP_SERVICE_KEY로 전달한다.
공공데이터포털은 이 API를 무료, 개발·운영 단계 자동승인으로 안내한다. 페이지에 표시되는 개발계정 트래픽은 10,000이지만 정책과 한도는 바뀔 수 있으므로 운영 전 상세 페이지를 다시 확인한다. 호출 간격을 불필요하게 짧게 잡지 말고 하루 한두 차례 증분 수집부터 시작하는 편이 안전하다.
Linux·macOS에서는 현재 셸에만 키를 넣을 수 있다.
export KSTARTUP_SERVICE_KEY='YOUR_ISSUED_SERVICE_KEY'
python3 kstartup_collector.py --live
Windows PowerShell이라면 다음과 같다.
$env:KSTARTUP_SERVICE_KEY = 'YOUR_ISSUED_SERVICE_KEY'
python kstartup_collector.py --live
키가 포함된 .env, 로그, 화면 캡처를 공개 저장소에 올리지 않는다. 이미 커밋한 키는 .gitignore만 추가해도 기록에서 사라지지 않는다. 공공데이터포털에서 키를 재발급한 뒤 저장소 기록과 배포 환경을 점검해야 한다.
2. 요청 매개변수와 응답 필드 이해하기
공식 API 명세의 핵심 요청 매개변수는 다음과 같다.
| 매개변수 | 필수 | 역할 |
|---|---|---|
serviceKey |
예 | 공공데이터포털 인증키 |
page |
아니요 | 1부터 시작하는 페이지 번호 |
perPage |
아니요 | 페이지당 결과 수 |
returnType |
아니요 | json 또는 xml |
cond[rcrt_prgs_yn::EQ] |
아니요 | 모집 중 Y, 마감 N |
cond[supt_regin::LIKE] |
아니요 | 지원지역 부분 일치 |
cond[biz_pbanc_nm::LIKE] |
아니요 | 공고명 부분 일치 |
cond[pbanc_rcpt_bgng_dt::GTE] |
아니요 | 접수 시작일 하한, yyyyMMdd |
cond[pbanc_rcpt_end_dt::LTE] |
아니요 | 접수 종료일 상한, yyyyMMdd |
응답에서 실제로 보관할 최소 필드는 다음처럼 매핑할 수 있다.
| 저장 열 | API 필드 | 의미 |
|---|---|---|
announcement_id |
pbanc_sn |
공고 일련번호이자 중복 제거 키 |
title |
biz_pbanc_nm |
사업공고명 |
organization |
pbanc_ntrp_nm, sprv_inst |
공고기관·주관기관 |
starts_on, ends_on |
pbanc_rcpt_bgng_dt, pbanc_rcpt_end_dt |
접수 시작·종료일 |
target, excluded_target |
aply_trgt_ctnt, aply_excl_trgt_ctnt |
신청·제외 대상 설명 |
region |
supt_regin |
지원지역 |
announcement_url |
biz_aply_url |
공고 상세 URL |
application_url |
detl_pg_url |
사업 신청 URL |
guidance_url |
biz_gdnc_url |
사업 안내 URL |
is_open |
rcrt_prgs_yn |
모집 진행 여부 |
source_updated_at |
없음 | 공식 응답에 수정시각 필드가 없어 null 유지 |
fetched_at |
수집기가 생성 | 이 응답을 받은 UTC 시각 |
content_hash |
수집기가 생성 | 주요 필드 변경 감지용 해시 |
중요한 한계가 있다. 확인한 공식 공고 응답 명세에는 수정시각 필드가 없다. 수정시각을 임의로 만들지 말고 source_updated_at은 비워 둔다. 대신 fetched_at과 주요 필드의 content_hash를 저장하면 “언제 확인했는지”와 “지난번 이후 내용이 달라졌는지”를 구분할 수 있다. 원문에 표시된 수정일을 별도 수집하려면 포털 이용정책과 화면 구조 변경 가능성을 먼저 검토해야 한다.
응답 스키마 변경을 더 엄격하게 막고 싶다면 공고 응답 스키마 검증 방법을 함께 적용한다.
3. 페이지네이션·중복 제거가 포함된 Python 수집기
아래 코드는 외부 패키지를 사용하지 않는다. 기본 실행은 내장 샘플로만 테스트하며 네트워크를 호출하지 않는다. 실제 호출은 --live를 명시하고 환경 변수에 키가 있을 때만 수행한다.
# kstartup_collector.py
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sqlite3
import sys
import time
from datetime import datetime, timezone
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
API_URL = (
"https://apis.data.go.kr/B552735/"
"kisedKstartupService01/getAnnouncementInformation01"
)
def extract_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
"""공식 명세 형태와 현재 JSON 형태를 모두 허용한다."""
data = payload.get("data", [])
if isinstance(data, dict):
data = data.get("data", [])
if not isinstance(data, list):
raise ValueError("응답의 data가 배열이 아닙니다")
return data
def normalize(row: dict[str, Any], fetched_at: str) -> dict[str, Any]:
announcement_id = str(row.get("pbanc_sn") or "").strip()
title = str(row.get("biz_pbanc_nm") or "").strip()
source_url = str(row.get("biz_aply_url") or "").strip()
if not announcement_id or not title or not source_url.startswith("https://"):
raise ValueError("필수 필드(pbanc_sn, biz_pbanc_nm, HTTPS biz_aply_url) 누락")
stable = {
"announcement_id": announcement_id,
"title": title,
"organization": str(row.get("pbanc_ntrp_nm") or "").strip(),
"supervising_organization": str(row.get("sprv_inst") or "").strip(),
"starts_on": str(row.get("pbanc_rcpt_bgng_dt") or "").strip(),
"ends_on": str(row.get("pbanc_rcpt_end_dt") or "").strip(),
"target": str(row.get("aply_trgt_ctnt") or "").strip(),
"excluded_target": str(row.get("aply_excl_trgt_ctnt") or "").strip(),
"region": str(row.get("supt_regin") or "").strip(),
"source_url": source_url,
"application_url": str(row.get("detl_pg_url") or "").strip(),
"guidance_url": str(row.get("biz_gdnc_url") or "").strip(),
"is_open": str(row.get("rcrt_prgs_yn") or "").strip(),
"source_updated_at": None,
}
digest_source = json.dumps(stable, ensure_ascii=False, sort_keys=True)
return {
**stable,
"fetched_at": fetched_at,
"content_hash": hashlib.sha256(digest_source.encode()).hexdigest(),
}
def fetch_page(service_key: str, page: int, per_page: int = 100) -> dict[str, Any]:
query = urlencode(
{
"serviceKey": service_key,
"page": page,
"perPage": per_page,
"returnType": "json",
"cond[rcrt_prgs_yn::EQ]": "Y",
}
)
request = Request(f"{API_URL}?{query}", headers={"User-Agent": "kstartup-monitor/1.0"})
try:
with urlopen(request, timeout=20) as response:
return json.load(response)
except HTTPError as error:
raise RuntimeError(f"API HTTP 오류: {error.code}") from error
except (URLError, TimeoutError) as error:
raise RuntimeError("API 연결 또는 시간 초과 오류") from error
def fetch_all(service_key: str, per_page: int = 100) -> list[dict[str, Any]]:
page = 1
seen: dict[str, dict[str, Any]] = {}
fetched_at = datetime.now(timezone.utc).isoformat()
while True:
payload = fetch_page(service_key, page, per_page)
rows = extract_items(payload)
for row in rows:
item = normalize(row, fetched_at)
seen[item["announcement_id"]] = item
total = int(payload.get("totalCount", len(seen)))
if not rows or page * per_page >= total:
break
page += 1
time.sleep(0.2)
return list(seen.values())
def save_sqlite(path: str, items: list[dict[str, Any]]) -> None:
with sqlite3.connect(path) as connection:
connection.execute(
"""CREATE TABLE IF NOT EXISTS announcements (
announcement_id TEXT PRIMARY KEY,
payload_json TEXT NOT NULL,
content_hash TEXT NOT NULL,
fetched_at TEXT NOT NULL
)"""
)
for item in items:
connection.execute(
"""INSERT INTO announcements VALUES (?, ?, ?, ?)
ON CONFLICT(announcement_id) DO UPDATE SET
payload_json=excluded.payload_json,
content_hash=excluded.content_hash,
fetched_at=excluded.fetched_at""",
(
item["announcement_id"],
json.dumps(item, ensure_ascii=False),
item["content_hash"],
item["fetched_at"],
),
)
def self_test() -> None:
base = {
"pbanc_sn": 101,
"biz_pbanc_nm": "테스트 창업 지원사업",
"pbanc_ntrp_nm": "테스트 기관",
"sprv_inst": "테스트 주관기관",
"pbanc_rcpt_bgng_dt": "20260701",
"pbanc_rcpt_end_dt": "20260731",
"aply_trgt_ctnt": "예비창업자",
"supt_regin": "전국",
"biz_aply_url": "https://www.k-startup.go.kr/example/101",
"detl_pg_url": "https://www.k-startup.go.kr/apply/101",
"biz_gdnc_url": "https://www.k-startup.go.kr/guide/101",
"rcrt_prgs_yn": "Y",
}
payload = {"data": [base, dict(base)], "totalCount": 2}
fetched_at = "2026-07-20T00:00:00+00:00"
deduped = {
item["announcement_id"]: item
for item in (normalize(row, fetched_at) for row in extract_items(payload))
}
assert len(deduped) == 1, "중복 공고 제거 실패"
assert deduped["101"]["source_updated_at"] is None
missing = dict(base)
missing.pop("pbanc_sn")
try:
normalize(missing, fetched_at)
except ValueError:
pass
else:
raise AssertionError("필수 필드 누락을 감지하지 못함")
nested = {"data": {"data": [base]}, "totalCount": 1}
assert len(extract_items(nested)) == 1, "중첩 응답 파싱 실패"
print("SELF_TEST_OK: dedupe=PASS missing_field=PASS nested_data=PASS")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--live", action="store_true")
parser.add_argument("--db", default="kstartup.sqlite3")
args = parser.parse_args()
if not args.live:
self_test()
return
key = os.getenv("KSTARTUP_SERVICE_KEY")
if not key:
sys.exit("KSTARTUP_SERVICE_KEY 환경 변수가 필요합니다")
items = fetch_all(key)
save_sqlite(args.db, items)
print(f"저장 완료: {len(items)}건")
if __name__ == "__main__":
main()
테스트만 실행하려면 다음 명령을 사용한다.
python3 kstartup_collector.py
실제 호출에서는 totalCount와 perPage를 기준으로 마지막 페이지까지 순회한다. 같은 pbanc_sn이 다시 오면 딕셔너리와 SQLite 기본키가 한 건으로 합친다. 다만 공급자가 과거 공고를 삭제하거나 페이지 순서를 바꾸는 경우도 있으므로 페이지 중복 제거만으로 “누락 없음”이 보장되지는 않는다.
4. 필터는 넓게, 자격 판단은 원문에서
API 단계에서 모집 중=Y를 적용하고, 로컬에서는 다음처럼 우선순위를 정하는 편이 안전하다.
- 마감일: 오늘 이후이고 3~14일 안에 끝나는 공고를 상단에 둔다.
- 지역:
전국또는 사업장 소재지와 일치하는 후보를 남긴다. - 업력·대상: 예비창업자, 3년 미만 등 명백한 불일치만 제외한다.
- 제외 조건:
aply_excl_trgt_ctnt가 있으면 자동 통과시키지 말고 검토 대상으로 표시한다. - 원문 링크: 모든 알림에 공고 상세 URL인
biz_aply_url과 “원문 확인 필수” 배너를 넣는다.detl_pg_url은 신청 URL로 분리한다.
키워드가 없다는 이유로 공고를 자동 탈락시키면 표현 차이 때문에 좋은 후보를 놓칠 수 있다. LLM을 붙이더라도 결과는 추천, 검토 필요, 명백한 불일치 정도로만 분류하고, 최종 자격 판정 문구는 만들지 않는 것이 좋다. 추천 후보를 실제 제안서 준비 단계로 넘길 때는 추천 결과를 제안서로 연결하는 방법을 참고할 수 있다.
알림 예시는 다음처럼 근거가 보여야 한다.
[검토 후보] 테스트 창업 지원사업
- 기관: 테스트 기관 / 테스트 주관기관
- 접수: 2026-07-01 ~ 2026-07-31
- 지역·대상: 전국 / 예비창업자
- 추천 이유: 지역 일치, 모집 진행 여부 Y
- 주의: 자동 추천은 자격을 보장하지 않습니다. 마감 시각·제외 대상·첨부파일을 원문에서 확인하세요.
- 원문: https://www.k-startup.go.kr/example/101
5. 누락·중복을 어떻게 검증할까
자동 수집은 “코드가 오류 없이 끝났다”만으로 충분하지 않다. 최소 7일 동안 아래 운영표를 매일 남긴다.
| 날짜 | API totalCount |
받은 행 | 고유 pbanc_sn |
중복 행 | 필수 필드 누락 | 원문 표본 대조 | 결과 |
|---|---|---|---|---|---|---|---|
| YYYY-MM-DD | 실제값 | 실제값 | 실제값 | 실제값 | 실제값 | 10건 중 실제값 | PASS/FAIL |
이 표는 템플릿일 뿐이며 측정 전 숫자를 채우지 않는다. 검증 절차는 다음과 같다.
- 페이지 완전성: 페이지별 받은 행의 합이 응답
totalCount와 일치하는지 확인한다. - 중복 검사: 전체 행 수와 고유
pbanc_sn수의 차이를 기록한다. - 필수 필드 검사: ID·제목·HTTPS 원문 URL이 없는 행은 저장하지 않고 별도 오류 큐로 보낸다.
- 경계값 검사: 첫 페이지와 마지막 페이지의 ID를 로그에 남겨 중간 중단을 찾는다.
- 포털 표본 대조: 같은 시각 K-Startup 포털의 모집 중 목록에서 무작위 10건을 골라 API에 있는지 확인한다.
- 변경 검사: 같은 ID의
content_hash가 바뀌면 마감일·대상·원문을 재검토하고 수정 알림을 보낸다. - 삭제 검사: 어제 있던 ID가 오늘 응답에서 사라져도 즉시 삭제하지 말고
not_seen_since상태로 보관한다.
특정 7일의 실측을 마친 뒤에만 “하루 수집 건수”를 말할 수 있다. 공고 수는 검색 조건·시점·페이지 정책에 따라 달라지므로 다른 기간의 숫자를 일반화하지 않는다. API 장애, 인증 오류, 응답 구조 변경에 대한 재시도와 알림 설계는 수집 실패와 알림 예외처리에서 이어서 다룬다.
6. 운영 배치와 실패 처리
처음에는 하루 2회로 충분하다. 예를 들어 오전 9시와 오후 4시에 실행하고, 새 ID 또는 해시가 달라진 ID만 알림한다.
운영에서 반드시 구분할 실패는 다음과 같다.
401: 인증키가 잘못됐거나 활성화되지 않은 경우429또는 한도 초과: 호출을 멈추고 다음 주기로 넘길 경우5xx, 연결 실패, 시간 초과: 지수 백오프로 제한 횟수만 재시도할 경우- JSON 파싱 실패·필수 필드 누락: 응답 원문을 비밀정보 없이 격리하고 스키마 경보를 보낼 경우
- 부분 수집: 이전 성공 스냅샷을 유지하고 새 결과로 덮어쓰지 않을 경우
임시 장애 때 빈 배열을 “공고 0건”으로 저장하면 기존 공고가 모두 삭제된 것처럼 보일 수 있다. 모든 페이지를 정상 수집하고 합계 검증까지 통과한 뒤에만 스냅샷을 완료 상태로 바꾼다.
비용·권한·정책 한계
공공데이터포털 활용신청과 유효한 서비스 키가 필요하다. 현재 무료·자동승인·개발계정 10,000건으로 표시되더라도 요금, 승인 방식, 호출 한도는 바뀔 수 있으므로 운영 전에 공식 상세 페이지를 다시 확인한다. 공고 본문과 첨부파일을 저장·재배포할 때는 각 자료의 이용조건, 개인정보, 저작권을 별도로 확인한다. API 결과는 신청 자격이나 접수 완료를 보장하지 않는다.
7. 배포 전 체크리스트
- 공공데이터포털에서 본인 용도로 API 활용신청을 했다.
- 키를 코드·Git·로그·브라우저에 노출하지 않았다.
-
page,perPage,totalCount로 마지막 페이지까지 순회한다. -
pbanc_sn을 문자열 기본키로 사용한다. - 제목·기관·접수기간·대상·제외대상·지역·HTTPS 원문 URL을 저장한다.
- 공식 수정시각이 없음을 데이터 모델에 반영하고
fetched_at과 해시를 저장한다. - 누락 필드, 중복 ID, 중첩 응답 형태를 자동 테스트한다.
- 최소 7일 동안 API 합계와 포털 표본을 대조한다.
- AI 추천에 “후보” 표시와 원문 확인 배너를 넣는다.
- 장애 시 이전 정상 스냅샷을 보존한다.
자주 묻는 질문
API 데이터만 보고 신청해도 되나?
안 된다. API는 탐색과 후보 선별에 유용하지만 첨부파일, 제외 조건, 마감 시각, 정정 공고까지 모두 대신 보장하지 않는다. 신청 전 원문과 담당기관 안내를 확인한다.
공고 ID가 같으면 항상 같은 내용인가?
같은 pbanc_sn도 접수기간이나 설명이 바뀔 수 있다. ID는 중복 제거에 쓰고, 내용 변경은 content_hash로 따로 감지한다.
수정시각을 API에서 받을 수 있나?
2026년 7월 20일 확인한 공식 공고 응답 명세에는 수정시각 필드가 없다. 없는 값을 추정하지 말고 수집시각과 내용 해시를 보관한다.
7일 실측에서 누락이 발견되면 어떻게 하나?
필터를 모두 뺀 원본 수집 결과와 포털 표본부터 비교한다. 페이지 중단, 날짜 조건의 방향, 모집 상태 필터, 응답 스키마 변경을 순서대로 확인한 뒤 누락 원인을 기록한다.
공식 자료
- K-Startup 지원사업 공고 정보 Open API 조회 — 확인일: 2026-07-20
- K-Startup 통합공고 지원사업 정보 Open API 조회 — 확인일: 2026-07-20
- 공공데이터포털 K-Startup 조회서비스 — 확인일: 2026-07-20
- K-Startup 공식 포털 — 확인일: 2026-07-20
'🤖 1인 에이전트 구축기' 카테고리의 다른 글
| 외주 개발/디자인 프로젝트 관리를 위한 캘린더-노션-슬랙 실시간 태스크 동기화로 누락 방지 (0) | 2026.07.01 |
|---|---|
| 고객사 맞춤 제안서 자동화: 포트폴리오 검색부터 검수까지 (0) | 2026.06.25 |
| 국세청 홈택스 자료 연동을 위한 영수증 OCR 및 부가세 신고용 지출 증빙 자동 분류 (0) | 2026.06.24 |
| 정기 구독형 서비스(SaaS) 구축을 위한 토스페이먼츠 API와 n8n 연동 기초 (0) | 2026.06.24 |
| 디지털 상품(전자책/VOD) 판매 자동화: 결제 즉시 다운로드 링크 발송 시스템 (1) | 2026.06.24 |