apt-chat
연봉 기반 아파트 시뮬레이터 + Text-to-SQL AI 챗봇
서울 아파트 실거래 데이터를 가지고, 내 연봉으로 살 수 있는 집을 한 화면에서 찾아볼 수 있는 웹 서비스입니다. 좌측 시뮬레이터는 DSR 기반 대출 한도와 총예산을 계산해 매물 목록을 보여주고, 우측 AI 챗봇은 같은 데이터셋에 대해 자연어 질문을 SQL로 변환해 결과를 시각화합니다. 두 기능이 한 대시보드에서 컨텍스트를 공유합니다. 그래서 슬라이더를 조정한 뒤 바로 "방금 조건에서 평당가가 가장 높은 동은?" 같은 질문으로 이어갈 수 있습니다. 기획부터 개발까지 엔드투엔드로 혼자 완성한 프로젝트입니다.

사용 기술 Next.js 16 (App Router), React 19, TypeScript, TanStack Query FastAPI, Anthropic SDK, sqlglot Supabase (PostgreSQL)
전체 구현 흐름
프론트(Next.js) · API 서버(FastAPI) · 데이터 수집기(ETL)를 pnpm workspace 모노레포로 묶고, Anthropic 호출과 DB 연결은 서버에서만 일어나도록 했습니다.
AI 오케스트레이션 설계
AI native 방식으로 제작했습니다. 설계·파이프라인·성능 최적화는 직접 개입하고, 코드 작성은 LLM에 위임하되 의도대로 결과가 나오도록 가드레일을 설계했습니다.
Plan 문서를 진실 소스로 두고 15개 phase로 구현 단계를 쪼갠 뒤, 커스텀 스킬이 각 phase마다 탐색(Explore) → 구현(Implement) → 검증(Verify) 3단계 루프를 반복 실행하도록 만들었습니다. 이 루프는 단일 워커가 아니라 역할별 서브에이전트에 분배되어, 탐색·구현·검증 워커가 백그라운드에서 병렬로 돌아가도록 설계했습니다. 단계별로 스킬·모델을 다르게 주입해 LLM이 의도한 방향으로 코드를 작성하게 했고, 구현 워커와 검증 워커를 분리해 "성공했습니다" 류의 거짓 양성을 차단했습니다.
검증 이후에는 테스트 코드를 모두 통과하고 에러가 없어야 커밋할 수 있도록 lefthook pre-commit 훅으로 통제했습니다. LLM이 임의로 커밋/푸시하지 못하도록 막아, plan과 코드 상태가 어긋나지 않게 잠금장치를 두었습니다.
Simulator 전역 리렌더 제거 / atomic store + selector 구독

문제 상황
슬라이더 값이 하나라도 바뀌면 대시보드의 모든 UI 요소가 리렌더링되는 상황이었습니다. useSimulator() 훅이 단일 객체({ state, result, loading, setSalary, ... })를 반환하고 Dashboard가 이를 구독해 자식들에 prop drilling하는 구조였기 때문에, 한 필드만 바뀌어도 object reference가 새로 생성 → Dashboard 리렌더 → 모든 자식이 cascade로 리렌더되었습니다.
해결
useSyncExternalStore 기반의 vanilla store를 직접 만들고, 상태가 바뀌면 구독한 컴포넌트에만 알림이 가도록 구성했습니다. useSimulatorActions()는 setter 묶음만 반환하고 listener를 등록하지 않아(stable reference) 버튼/슬라이더 change 핸들러에 붙여도 리렌더를 트리거하지 않습니다. SliderGroup은 SalarySlider · SavingsSlider · LoanYearsSlider로 쪼개 각자 자기 필드만 selector로 구독하게 했습니다.
성과
슬라이더 드래그 중 리렌더 범위가 슬라이더 컴포넌트 1개로 감소했습니다. Debounce 덕분에 /api/simulate는 드래그를 놓고 300ms 후 1회만 호출되며, 그 시점에 SummaryCards / AptList만 갱신됩니다.

문제 상황
페이지 진입 후 시뮬레이터 결과가 나오기까지 700ms+의 지연이 있었고, 그동안 skeleton만 보였습니다.
해결
app/page.tsx를 async Server Component로 전환하고, unstable_cache로 래핑한 fetchDefaultSimulateCached()가 default state에 대한 simulate 결과를 서버에서 미리 계산하게 했습니다. Suspense로 감싸 fallback이 먼저 스트리밍되고 실제 결과는 별도 청크로 나가도록 했고, useSimulator(initialResult)가 받은 값을 React Query의 initialData로 주입해 첫 렌더부터 데이터가 존재하도록 했습니다.
트러블 슈팅
처음에는 TanStack Query의 RSC 패턴(HydrationBoundary + dehydrate)을 적용했는데 hydration mismatch가 발생했습니다. prefetch에 쓰인 QueryClient와 context의 QueryClient가 분리돼 있어, RSC streaming 타이밍상 두 client의 동기화가 보장되지 않았기 때문입니다.
결국 서버는 loading HTML을, 클라이언트는 데이터가 포함된 HTML을 렌더링하는 불일치가 생겼습니다. 이를 해결하기 위해 dehydrate 패턴 대신 prop 기반 `initialData`로 전환하여, 서버와 클라이언트가 동일한 데이터를 공유하도록 구조를 변경했습니다.
성과
- simulate 결과가 HTML에 렌더된 채로 도착, Skeleton flash가 완전히 사라져 첫 페인트부터 완성된 값을 보여줍니다.
Text-to-SQL 파이프라인 / 비용 상한과 SQL 안전성 제어
핵심적으로 고려한 부분
자연어 → SQL 변환은 챗봇의 중심 기능이지만, LLM 기반 파이프라인에는 3가지 위험이 있었습니다. ① API 호출 비용이 끝없이 불어날 수 있다는 점, ② SQL injection이나 DELETE 같은 파괴적 구문을 막기 어렵다는 점, ③ 실패 시 자동 재시도 로직이 비용을 증가시킬 수 있다는 점이었습니다.
LLM 호출 파이프라인
- Step 1. Haiku로 intent 추출 + 키워드 정규식 기반 schema retrieval
- Step 2. intent가 SUPPORTED_INTENTS(최근 거래, 지역별 평균가, 월별 추이, 가격 필터, Top N)에 매칭되면 intent_mapper의 코드 상수 템플릿 SQL로 종결, 미매칭일 때만 Sonnet으로 SQL 생성
SQL 검증
sqlglot AST로 파싱해 규칙을 강제: ALLOWED_TABLES 3개로 제한, SELECT 단일 statement만 허용, MAX_JOINS=3 / MAX_SUBQUERY_DEPTH=2 / MAX_LIMIT=100 강제 주입. 검증에 실패하면 재시도 없이 곧장 HTTP 400으로 전파합니다.
이중 안전장치
- query_cache: TTLCache(1000, 24h) + threading.Lock. 캐시 히트 시 LLM 호출 0회.
- rate_limit: 세션당 3회 제한(서버 + 클라이언트 이중 카운터).
- Anthropic API 키는 서버 .env에만 두고, Next.js 프록시는 관련 헤더를 포워딩하지 않습니다.
결과
요청당 LLM 호출 상한이 명확해졌습니다. 캐시 히트 시 0회, intent 매칭 시 1~2회, fallback 시 2~3회로 일정합니다. injection·허가되지 않은 테이블·파괴적 구문은 검증 단계에서 예외 없이 차단됩니다.