공동주택 공시가격 데이터 (assessed_prices)
tax-chat의 get_assessed_value 도구가 사용하는 국토교통부 공동주택 공시가격 정식 데이터의 적재·운영 가이드.
개요
종부세·재산세 정확 계산은 공시가격이 핵심인데, 공식 OpenAPI는 PNU/GIS 기반이라 우리 흐름(단지명+면적)에 직접 매칭이 어렵습니다. 대안으로 공공데이터포털 파일데이터(매년 1회 CSV) 를 Supabase에 직접 적재해서 단지·동·호 단위로 조회합니다.
도구 응답의 source 등급으로 정확도를 구분:
| source | 출처 | 매칭 키 | 정확도 |
|---|---|---|---|
official_unit | Supabase assessed_prices_unit | 단지명+동+호 | ★★★ (호별 정식) |
official_area_avg | Supabase 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 다운로드
- 공공데이터포털 → 국토교통부_주택 공시가격 정보 로그인 후 다운로드
- ZIP 안
data.csv(약 3GB) + 레이아웃 가이드(xlsx) - 권장 위치:
data/assessed-prices/국토교통부_주택 공시가격 정보(<YYYY>).csv(gitignore 처리됨)
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 사용 권장.
- Direct connection(
- 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;
SQL8. 적재 후 원복 (선택)
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 초과. 해결:
- Disk 18~24GB로 확장 (4시간 cooldown 적용, autoscaling ON 권장)
- 시·도별 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)
- DB 최신 연도 자동 감지 —
raw_args.year미지정 시max(year)사용. 요청 연도가 DB 최신보다 미래면 자동 fallback +yearMismatchNote안내. - 호별 정식 매칭 (
dong+ho입력 시) →source: 'official_unit' - 평형 평균 (
area_sqm입력 시, ±1㎡ bucket) →source: 'official_area_avg' - 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-11 | 2025 | 15,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차”) — 별칭 매핑 테이블 검토