Skip to Content
Tax Chat공동주택 공시가격 데이터 (assessed_prices)

공동주택 공시가격 데이터 (assessed_prices)

tax-chat의 get_assessed_value 도구가 사용하는 국토교통부 공동주택 공시가격 정식 데이터의 적재·운영 가이드.

개요

종부세·재산세 정확 계산은 공시가격이 핵심인데, 공식 OpenAPI는 PNU/GIS 기반이라 우리 흐름(단지명+면적)에 직접 매칭이 어렵습니다. 대안으로 공공데이터포털 파일데이터(매년 1회 CSV) Supabase에 직접 적재해서 단지·동·호 단위로 조회합니다.

도구 응답의 source 등급으로 정확도를 구분:

source출처매칭 키정확도
official_unitSupabase assessed_prices_unit단지명+동+호★★★ (호별 정식)
official_area_avgSupabase assessed_prices_area_avg단지명+전용면적★★ (평형 평균, ±5%)
rtms_estimate국토부 RTMS 실거래가 × 0.69단지명 매칭 실패 시 fallback★ (추정치)

데이터 위치

  • Supabase 프로젝트: tax-chat 전용 (env NEXT_PUBLIC_TAX_CHAT_SUPABASE_URL)
  • migration: supabase/migrations/0001_assessed_prices.sql
  • 테이블 2개:
    • public.assessed_prices_unit — 호별 정식 공시가격 (연도당 약 1,500만 행)
    • public.assessed_prices_area_avg — 단지·전용면적(±0.1㎡ bucket) 평균/중앙값 (연도당 약 80만 행)
  • 인덱스: 단지명 trigram (pg_trgm) + (year, complex_name, dong, ho) + 법정동코드 + 건축물대장PK

매년 적재 SOP

공시기준일은 매년 1월 1일, 정기공시 4월 말, 보정공시 6월 말. 공공데이터포털 업로드는 통상 5~6월.

1. CSV 다운로드

2. Supabase 사전 준비

  • Pro plan 필수 (Free 500MB로는 불가). Project Settings → Billing → Pro.
  • Disk size 18~24GB 권장 (Pro 기본 8GB로는 sort temp + WAL 누적 시 부족). Autoscaling 토글 ON.
  • 직접 connection string 발급: Project Settings → Database → URI → Session pooler (port 5432).
    • Direct connection(db.xxx.supabase.co)은 IPv6-only라 macOS 일부 네트워크에서 DNS 실패. Session pooler 사용 권장.
  • statement_timeout 해제 (대량 \COPY/INSERT에 필요):
    psql "$TAX_CHAT_SUPABASE_DB_URL" -c "ALTER ROLE postgres SET statement_timeout = '0';" psql "$TAX_CHAT_SUPABASE_DB_URL" -c "ALTER ROLE postgres SET idle_in_transaction_session_timeout = '0';"

3. 환경변수 + DB 접속 준비

export TAX_CHAT_SUPABASE_DB_URL='postgresql://postgres.<ref>:****@aws-0-<region>.pooler.supabase.com:5432/postgres' export CSV_PATH='/Users/<you>/workspace/howmuchhome-web/data/assessed-prices/국토교통부_주택 공시가격 정보(<YYYY>).csv' # 접속 확인 psql "$TAX_CHAT_SUPABASE_DB_URL" -c "select pg_size_pretty(pg_database_size('postgres'));"

4. staging 테이블 생성 + CSV \COPY 적재 (5~10분)

psql "$TAX_CHAT_SUPABASE_DB_URL" --set ON_ERROR_STOP=on <<'SQL' drop table if exists public._stg_assessed_prices; create unlogged table public._stg_assessed_prices ( base_year text, base_month text, legal_dong_code text, road_address text, sido text, sigungu text, eupmyeon text, dongri text, special_code text, bonbun text, bubun text, special_name text, complex_name text, dong text, ho text, exclusive_area_sqm text, assessed_price text, complex_code text, dong_code text, ho_code text, building_register_pk text ); SQL psql "$TAX_CHAT_SUPABASE_DB_URL" -c "\\COPY public._stg_assessed_prices FROM '${CSV_PATH}' WITH (FORMAT csv, HEADER true, QUOTE '\"', ENCODING 'UTF8')" psql "$TAX_CHAT_SUPABASE_DB_URL" -c "select count(*) as staging_rows from public._stg_assessed_prices;"

5. 본 테이블 적재 — 시·도별 batched (15~30분)

단일 INSERT는 1,558만 행 sort temp + WAL 폭증으로 Pro 기본 8GB disk를 초과시킨다. 시·도별 17개 batch로 분할해 각 sort 부담을 1/17로 줄이고 commit마다 temp/WAL을 회수한다.

SIDOS=$(psql "$TAX_CHAT_SUPABASE_DB_URL" -tA \ -c "select sido from public._stg_assessed_prices where sido <> '' group by sido order by sido;") while IFS= read -r SIDO; do [ -z "$SIDO" ] && continue echo "→ $SIDO" psql "$TAX_CHAT_SUPABASE_DB_URL" --quiet --set ON_ERROR_STOP=on \ -v sido_var="$SIDO" <<'SQL' insert into public.assessed_prices_unit ( year, base_month, legal_dong_code, sido, sigungu, complex_name, dong, ho, exclusive_area_sqm, assessed_price, road_address, building_register_pk ) select yr, bm, legal_v, sido_v, sigungu_v, name_v, dong_v, ho_v, area_v, price_v, road_v, bpk_v from ( select distinct on (yr, sido_v, sigungu_v, name_v, dong_v, ho_v, area_v) yr, bm, legal_v, sido_v, sigungu_v, name_v, dong_v, ho_v, area_v, price_v, road_v, bpk_v from ( select nullif(base_year, '')::smallint as yr, nullif(base_month, '')::smallint as bm, nullif(legal_dong_code, '') as legal_v, sido as sido_v, nullif(sigungu, '') as sigungu_v, nullif(complex_name, '') as name_v, nullif(dong, '') as dong_v, nullif(ho, '') as ho_v, nullif(exclusive_area_sqm, '')::numeric(7,3) as area_v, nullif(replace(assessed_price, ',', ''), '')::bigint as price_v, nullif(road_address, '') as road_v, nullif(building_register_pk, '') as bpk_v from public._stg_assessed_prices where sido = :'sido_var' and assessed_price <> '' and complex_name <> '' and sigungu <> '' ) cast_data order by yr, sido_v, sigungu_v, name_v, dong_v, ho_v, area_v, bm desc nulls last ) dedup on conflict (year, sido, sigungu, complex_name, dong, ho, exclusive_area_sqm) do update set base_month = excluded.base_month, legal_dong_code = excluded.legal_dong_code, assessed_price = excluded.assessed_price, road_address = excluded.road_address, building_register_pk = excluded.building_register_pk; SQL done <<< "$SIDOS" psql "$TAX_CHAT_SUPABASE_DB_URL" -c "analyze public.assessed_prices_unit;"

6. 단지·전용면적 평균 집계 — 시·도별 batched (5~10분)

SIDOS=$(psql "$TAX_CHAT_SUPABASE_DB_URL" -tA \ -c "select distinct sido from public.assessed_prices_unit where year = (select max(year) from public.assessed_prices_unit) order by sido;") while IFS= read -r SIDO; do [ -z "$SIDO" ] && continue echo "→ $SIDO" psql "$TAX_CHAT_SUPABASE_DB_URL" --quiet --set ON_ERROR_STOP=on \ -v sido_var="$SIDO" <<'SQL' with target_year as ( select max(year) as y from public.assessed_prices_unit ) insert into public.assessed_prices_area_avg ( year, sido, sigungu, complex_name, exclusive_area_sqm_bucket, avg_assessed_price, median_assessed_price, min_assessed_price, max_assessed_price, unit_count ) select u.year, u.sido, u.sigungu, u.complex_name, round(u.exclusive_area_sqm, 1) as exclusive_area_sqm_bucket, round(avg(u.assessed_price))::bigint as avg_assessed_price, percentile_cont(0.5) within group (order by u.assessed_price)::bigint as median_assessed_price, min(u.assessed_price) as min_assessed_price, max(u.assessed_price) as max_assessed_price, count(*) as unit_count from public.assessed_prices_unit u cross join target_year t where u.year = t.y and u.sido = :'sido_var' group by u.year, u.sido, u.sigungu, u.complex_name, round(u.exclusive_area_sqm, 1) on conflict (year, sido, sigungu, complex_name, exclusive_area_sqm_bucket) do update set avg_assessed_price = excluded.avg_assessed_price, median_assessed_price = excluded.median_assessed_price, min_assessed_price = excluded.min_assessed_price, max_assessed_price = excluded.max_assessed_price, unit_count = excluded.unit_count; SQL done <<< "$SIDOS" psql "$TAX_CHAT_SUPABASE_DB_URL" -c "analyze public.assessed_prices_area_avg;"

7. staging 정리 + 검증

psql "$TAX_CHAT_SUPABASE_DB_URL" <<'SQL' drop table if exists public._stg_assessed_prices; select year, count(distinct complex_name) as complex_count, count(*) as unit_count from public.assessed_prices_unit group by year order by year desc; select pg_size_pretty(pg_database_size('postgres')) as db_size; SQL

8. 적재 후 원복 (선택)

psql "$TAX_CHAT_SUPABASE_DB_URL" -c "ALTER ROLE postgres RESET statement_timeout;" psql "$TAX_CHAT_SUPABASE_DB_URL" -c "ALTER ROLE postgres RESET idle_in_transaction_session_timeout;"

Disk size는 보수적으로 큰 채로 유지하거나 dashboard에서 축소 가능 (4시간 cooldown 후).

트러블슈팅 (실제 발생 이력)

statement timeout (2분)

Supabase pooler 기본값. PGOPTIONS 환경변수는 pooler에서 무시되므로 ALTER ROLE postgres SET statement_timeout = '0' 으로 영구 설정 필요. 적재 후 RESET.

ON CONFLICT DO UPDATE command cannot affect row a second time

CSV 안에 같은 (year, sido, sigungu, complex_name, dong, ho, exclusive_area_sqm) 키가 중복으로 들어있어 발생. 원인: 정기공시(1월) + 보정공시(6월) 행이 같이 들어있거나, 데이터 자체 중복. 해결: DISTINCT ON (...) ORDER BY ..., base_month DESC NULLS LAST로 staging에서 dedup. 같은 키 중 최신 보정공시 채택. 위 step 5 INSERT 쿼리에 이미 반영됨.

could not extend file ... No space left on device

단일 INSERT가 1,558만 행 sort temp + WAL 폭증으로 Pro 기본 8GB disk 초과. 해결:

  1. Disk 18~24GB로 확장 (4시간 cooldown 적용, autoscaling ON 권장)
  2. 시·도별 batch INSERT로 분할 — 위 step 5·step 6의 bash 루프가 이미 17개 시·도 분할이라 각 sort temp 1/17로 축소. commit마다 temp/WAL 자동 회수.

permission denied to execute CHECKPOINT

Supabase managed Postgres는 superuser 권한 없음. CHECKPOINT/pg_switch_wal 등은 직접 못 씀. WAL은 autovacuum이 자동 회수하므로 잠시 기다리면 정리됨.

IPv6 DNS 해석 실패 (db.xxx.supabase.co)

Direct connection 호스트는 IPv6-only. macOS에서 일부 네트워크는 IPv4만 사용. Session pooler URL(aws-0-<region>.pooler.supabase.com:5432) 로 교체.

운영 명령어 모음

검증

-- 연도별 단지/호수 확인 select year, count(distinct complex_name) as complex_count, count(*) as unit_count from public.assessed_prices_unit group by year order by year desc; -- 단지명 검색 (trigram 인덱스 활용) select complex_name, dong, ho, exclusive_area_sqm, assessed_price from public.assessed_prices_unit where year = 2025 and complex_name ilike '%반포자이%' limit 10; -- 평형 평균 확인 select complex_name, exclusive_area_sqm_bucket, avg_assessed_price, median_assessed_price, unit_count from public.assessed_prices_area_avg where year = 2025 and complex_name ilike '%반포자이%' order by exclusive_area_sqm_bucket; -- DB 사이즈 확인 select pg_size_pretty(pg_database_size('postgres'));

회수 (특정 연도만 재적재할 때)

delete from public.assessed_prices_unit where year = 2025; delete from public.assessed_prices_area_avg where year = 2025;

재적재 (신규 보정공시 또는 다음 연도)

CSV만 새로 다운로드해서 위 매년 적재 SOP의 step 1~7을 그대로 따라가면 됨. ON CONFLICT DO UPDATE로 멱등성 보장 (기존 호는 갱신, 신규 호는 추가).

도구 동작 흐름 (get_assessed_value)

  1. DB 최신 연도 자동 감지raw_args.year 미지정 시 max(year) 사용. 요청 연도가 DB 최신보다 미래면 자동 fallback + yearMismatchNote 안내.
  2. 호별 정식 매칭 (dong+ho 입력 시) → source: 'official_unit'
  3. 평형 평균 (area_sqm 입력 시, ±1㎡ bucket) → source: 'official_area_avg'
  4. RTMS 실거래가 ×0.69 추정 (DB miss) → source: 'rtms_estimate'

응답 message에 source 등급 라벨이 자동으로 박혀 사용자에게도 전달됩니다. 시스템 프롬프트(apps/app/src/app/api/tax-chat/route.ts)와 Guidelines(apps/app/src/features/tax-chat/lib/prompt.ts)에 source별 답변 라벨링 룰이 강제됩니다.

적재 이력

일자연도행수(unit / area_avg)DB size비고
2026-05-11202515,436,068 / ~7.3 GB최초 적재. HMH-6838. CSV 1,558만 → dedup 후 1,544만

향후 작업

  • 2026년분 공시 발표(통상 4~6월) 후 같은 절차로 재적재
  • 등기 데이터(estateDetail)의 building_register_pk(24년~) 매칭으로 단지명·동·호 정확 매칭 → 현재 ilike fuzzy 매칭의 false-positive 해소
  • 단지명 정규화 (예: “반포자이” vs “반포자이아파트” vs “반포자이1차”) — 별칭 매핑 테이블 검토
Last updated on