공공데이터 + LLM으로<br>아파트 입찰 낙찰 분석 시스템 만들기
아파트 입찰 낙찰 분석 시스템 만들기
K-APT 공공 API에서 아파트 입찰/낙찰 데이터를 수집하고, Claude AI로 자동 분석하는 풀스택 웹 애플리케이션 구축기
프로젝트 배경 — 왜 만들었는가
아파트 관리를 하다 보면 주기적으로 각종 공사 입찰이 발생합니다. 바닥공사, 방수, 도장, 창호 교체 등 — 적정한 낙찰 금액은 얼마인지, 어떤 업체가 실적이 좋은지 판단하기가 쉽지 않습니다.
다행히 K-APT(공동주택관리정보시스템)에서 공공데이터 API를 통해 전국 아파트의 입찰/낙찰 정보를 제공하고 있었습니다. 하지만 데이터가 방대하고 개별 건을 하나하나 비교하기엔 한계가 있었습니다.
전체 아키텍처
시스템은 크게 프론트엔드(React), 백엔드(Express), 리버스 프록시(HAProxy), 인증서 자동화(ACME) 네 개의 레이어로 구성됩니다.
+ Map-based Routing
Let's Encrypt 자동 갱신
프로젝트 구조
kapt/
├── src/
│ ├── App.jsx # 메인 UI + 데이터 수집 로직
│ └── main.jsx # React 엔트리포인트
├── server.js # Express (Claude API 프록시 + 정적 파일)
├── Dockerfile # 멀티스테이지 빌드
├── docker-compose.yml # HAProxy + Node + ACME 오케스트레이션
├── haproxy.cfg # SSL termination, map 기반 라우팅
├── acme/
│ ├── dns_ncloud.sh # NCloud DNS API 훅
│ └── issue.sh # 와일드카드 인증서 발급
└── .env # 환경변수 (API 키)
공공데이터 API 연동
이 프로젝트에서는 공공데이터포털에서 제공하는 두 가지 API를 활용합니다.
| API | 용도 | 주요 데이터 |
|---|---|---|
| ApHusBidResultNoticeInfoOfferServiceV2 | 입찰 공고 / 낙찰 상태 / 업체 조회 | 공고명, 낙찰금액, 낙찰방식, 입찰조건, 참여업체 |
| AptBasisInfoServiceV4 | 아파트 기본정보 조회 | 단지명, 세대수, 단지코드 |
데이터 수집 흐름
// 연도별 병렬 조회 핵심 코드
const fetches = years.map(async year => {
const url = `${BASE}/getBidSttusSearchV2?serviceKey=${apiKey}
&bidState=5&searchYear=${year}&numOfRows=${numRows}&type=json`;
const r = await fetch(url);
const d = await r.json();
return d.response.body.items || [];
});
let allItems = (await Promise.all(fetches)).flat();
프론트엔드 — React 19 + Vite
프론트엔드는 React 19와 Vite 6를 사용합니다. 별도의 UI 라이브러리 없이 인라인 스타일로 다크 테마 UI를 구성했습니다.
UI 구성 요소
- API 키 입력 — 공공데이터포털 인증키를 사용자가 직접 입력 (서버에 저장하지 않음)
- 수집 조건 설정 — 키워드 태그 선택, 연도 복수 선택, 조회 방식/건수/최소 세대수 필터
- 실시간 수집 로그 — 단계별 진행 상황을 모노스페이스 로그로 표시
- 결과 요약 카드 — 총 건수, 평균 금액, 낙찰업체 수를 대시보드 형태로 표시
- LLM 분석 프롬프트 — 사용자가 분석 방향을 직접 편집 가능
디자인 시스템
컬러 팔레트를 객체로 정의하고, 스타일 팩토리 함수로 일관된 UI를 유지했습니다. 버튼은 linear-gradient로 시안-블루 그라데이션을 적용하고, 데이터 테이블은 세대수에 따라 초록/노란색으로 시각적 구분을 줬습니다.
// 컬러 팔레트 + 버튼 팩토리
const C = {
bg: "#0d1117", ac: "#00d2ff", ac2: "#3a7bd5",
ok: "#3fb950", mu: "#7d8590",
};
const btn = (color) => ({
background: color === "ac"
? `linear-gradient(90deg, ${C.ac2}, ${C.ac})`
: `linear-gradient(90deg, #2d6a4f, ${C.ok})`,
color: "#000", fontWeight: 700,
});
백엔드 — Express API 프록시
백엔드는 Express 5로 최소한의 역할만 수행합니다. 핵심은 Claude API 프록시 — 클라이언트가 API 키를 노출하지 않도록 서버에서 대신 Anthropic API를 호출합니다.
// server.js — Claude API 프록시
app.post("/api/claude", async (req, res) => {
const apiKey = process.env.CLAUDE_API_KEY;
if (!apiKey) return res.status(500).json({ error: "Not configured" });
const resp = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(req.body),
});
res.status(resp.status).json(await resp.json());
});
개발/프로덕션 모드 분리
if (isProd) {
app.use(express.static(join(__dirname, "dist")));
app.get("{*path}", (req, res) =>
res.sendFile(join(__dirname, "dist/index.html")));
} else {
const vite = await (await import("vite")).createServer({
server: { middlewareMode: true }
});
app.use(vite.middlewares);
}
LLM 분석 파이프라인
단순히 원본 데이터를 던지는 것이 아니라, 구조화된 요약 데이터를 만들어 전달합니다.
데이터 전처리
- 낙찰업체 순위 TOP 15 — 낙찰 횟수 기준 업체 랭킹
- 세대수별 낙찰금액 통계 — ~500, 500~1000, 1000~1500, 1500~2000, 2000↑ 구간별 평균/최소/최대
- 입찰 조건 통계 — 보증보험, 신용평가, 실적증명, 제한경쟁, 적격심사 비율
- 샘플 데이터 상위 20건 — 단지명, 세대수, 공고명, 금액, 업체, 조건
{
"검색조건": { "키워드": "바닥공사", "연도": [2026, 2025], "총건수": 42 },
"낙찰업체순위TOP15": [{ "업체명": "○○건설", "낙찰건수": 5 }, ...],
"세대수별낙찰금액통계": [
{ "range": "1000~1500", "avg": 8500, "min": 3200, "max": 15000 }
],
"입찰조건통계": [...],
"샘플데이터상위20건": [...]
}
프롬프트 설계
2. 세대수 규모별 평균 낙찰금액 분포
3. 아파트들이 공통으로 내건 입찰 조건 (보증보험, 신용평가, 실적증명 등)
4. 주목할 만한 패턴이나 이상치
5. 1500세대 기준 예상 공사금액 범위
인프라 — Docker + HAProxy + ACME
Docker 멀티스테이지 빌드
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js .
COPY --from=build /app/dist ./dist
ENV NODE_ENV=production PORT=3000
CMD ["node", "server.js"]
HAProxy — SSL 종료 + Map 기반 라우팅
host2backend.map 파일을 통해 호스트명 기반으로 백엔드를 동적 라우팅합니다. 재시작 없이 런타임에 매핑을 추가/삭제할 수 있어 같은 서버에서 여러 서비스를 유연하게 운영할 수 있습니다.
# haproxy.cfg 핵심 설정
frontend https
bind *:443 ssl crt /etc/haproxy/certs/zinok.me-combined.pem alpn h2,http/1.1
use_backend %[req.hdr(host),lower,map(/usr/local/etc/haproxy/host2backend.map)]
# 런타임 매핑 관리
$ docker compose exec haproxy haproxy-map.sh list
$ docker compose exec haproxy haproxy-map.sh set app.zinok.me node
$ docker compose exec haproxy haproxy-map.sh del app.zinok.me
ACME — Let's Encrypt 와일드카드 자동화
별도의 ACME 컨테이너에서 acme.sh를 사용해 NCloud Global DNS API 기반 DNS-01 챌린지로 와일드카드 인증서(*.zinok.me)를 자동 발급합니다. 매일 크론으로 갱신을 체크하고, HAProxy 런타임에 즉시 반영합니다.
배운 점과 회고
잘 된 점
- 공공데이터 + LLM 조합의 가능성 — 정형 데이터를 구조화해서 LLM에 넘기면 사람이 직접 분석하기 어려운 패턴과 인사이트를 빠르게 도출할 수 있었습니다.
- Vite 미들웨어 모드 — Express에 Vite를 미들웨어로 통합하면 API 프록시와 HMR을 동시에 사용할 수 있어 개발 경험이 좋았습니다.
- HAProxy map 런타임 관리 — 재시작 없이 라우팅을 변경할 수 있어 다른 프로젝트를 같은 서버에 추가하기가 매우 편리합니다.
- ACME 자동화 — DNS-01 챌린지로 와일드카드 인증서를 자동 발급/갱신하니 서브도메인 추가 시 인증서 걱정이 없습니다.
개선할 점
- 세대수 조회 병목 — 현재 순차적으로 API를 호출하므로 건수가 많으면 느립니다. 배치 처리나 병렬화가 필요합니다.
- 데이터 캐싱 — 같은 조건의 반복 조회 시 API를 다시 호출합니다. IndexedDB 등을 활용한 클라이언트 캐시를 고려할 수 있습니다.
- LLM 토큰 관리 — 데이터가 많으면 토큰 한도를 초과할 수 있습니다. 요약 단계를 추가하거나 청킹 전략이 필요합니다.
기술 스택 요약
| 레이어 | 기술 | 선택 이유 |
|---|---|---|
| 프론트엔드 | React 19 + Vite 6 | 빠른 개발 + HMR, 최신 React 기능 |
| 백엔드 | Express 5 | 최소한의 API 프록시만 필요 |
| AI | Claude API (Anthropic) | 한국어 분석 품질, 구조화된 데이터 해석 |
| 리버스 프록시 | HAProxy 3.1 | SSL 종료 + map 기반 동적 라우팅 |
| 인증서 | acme.sh + NCloud DNS | 와일드카드 인증서 자동 발급/갱신 |
| 컨테이너 | Docker Compose | 멀티서비스 오케스트레이션, 볼륨 공유 |