# 부동산 마켓플레이스 (Emergent 제작 가이드)

> 중개사가 매물을 올리고, 매수자가 예산·지역으로 찾아 문의하고,
> 운영자가 관리자 대시보드로 전부 관리하는 **부동산 거래 플랫폼**.
>
> 차별점: 모든 매물에 **정부 실거래 시세**를 자동으로 붙여
> "이 호가가 실제 체결가보다 비싼지 싼지"를 바로 보여줍니다.

이 문서는 **PRD 겸 제작 가이드**입니다. 위에서부터 따라가면 Emergent에서 같은 플랫폼을 만들 수 있습니다.
7~11장의 프롬프트는 그대로 복사해서 쓰셔도 됩니다.

**소요 시간**: 준비 10분(API 키 발급) + 제작 40분 내외
**비용**: 0원 (공공데이터 API는 전부 무료)

> 이 구조는 부동산만의 것이 아닙니다. 렌탈·중고차·구인구직 등
> **"공급자가 등록하고 수요자가 찾는" 모든 마켓플레이스에 그대로 적용됩니다.**
> 바꿔야 할 것은 매물 필드와 검색 조건뿐입니다.

---

## 1. 무엇을 만드나

| 항목 | 내용 |
|---|---|
| 한 줄 정의 | 실거래 시세로 호가를 검증해주는 부동산 마켓플레이스 |
| 사용자 | 매수자 / 중개사 / 운영자 (3개 역할) |
| 원칙 | 매물은 사용자 등록 데이터, 시세는 **공식 공개 API만** 사용 |
| 차별점 | 모든 매물에 같은 평형 실거래 중앙값 대비 % 자동 표시 |

### 역할별 할 수 있는 일

| | 매수자 | 중개사 | 운영자 |
|---|---|---|---|
| 매물 탐색·필터 | ✓ | ✓ | ✓ |
| 관심 매물 저장 | ✓ | ✓ | ✓ |
| 문의 보내기 | ✓ | - | - |
| 매물 등록·수정·삭제 | - | ✓ | ✓ |
| 문의 답변 | - | ✓ | ✓ |
| 회원 관리·매물 상태 변경 | - | - | ✓ |
| 플랫폼 전체 통계 | - | - | ✓ |

### 화면

| 화면 | 접근 | 하는 일 |
|---|---|---|
| **매물** | 누구나 | 검색·필터, 관심 저장, 상세, 문의 |
| 예산 맞춤 | 누구나 | 연봉·자금 입력 → 살 수 있는 단지 순위 |
| 지도 | 누구나 | 지도에서 매물·시세 확인 |
| 청약 | 누구나 | 청약홈 공고 알림 |
| **내 매물** | 중개사 | 매물 등록·관리 |
| **문의** | 로그인 | 중개사는 받은 문의, 매수자는 보낸 문의 |
| **관리자** | 운영자 | 회원·매물·문의 관리, 전체 통계 |

---

## 2. 준비물: 공공데이터포털 API 키

[data.go.kr](https://www.data.go.kr) 가입 후 **3개를 각각 활용신청**합니다. 승인은 즉시 납니다.

| API | 용도 |
|---|---|
| 국토교통부_아파트 매매 실거래가 자료 | 매물 시세 비교의 근거 |
| 국토교통부_아파트 전월세 실거래가 자료 | 전세 매물 시세 비교 |
| 한국부동산원_청약홈 분양정보 조회 서비스 | 청약 공고 |

발급된 **일반 인증키(Decoding)** 를 환경변수 `DATA_GO_KR_KEY` 로 넣습니다.

> 지도까지 쓰려면 네이버 클라우드 플랫폼에서 **Maps → Web Dynamic Map + Geocoding** 을
> 신청합니다. 없어도 플랫폼은 완전히 동작합니다.

---

## 3. 데이터 모델

**매물(listings)과 실거래(trades)는 완전히 다른 데이터입니다.**
매물은 "팔겠다고 내놓은 호가", 실거래는 "실제로 팔린 값". 이 둘을 붙이는 게 이 플랫폼의 핵심입니다.

```
users      { id, email, pwHash, role(buyer|agent|admin), name, phone,
             agencyName, licenseNo, status(active|suspended), createdAt }
sessions   { token, userId, expiresAt }

listings   { id, agentId, dealType(sale|jeonse|monthly), lawdCd, aptName,
             umdNm, address, area, floor, totalFloor, buildYear,
             priceManwon, depositManwon, rentManwon,
             title, description, features,
             status(active|sold|hidden|pending), viewCount, createdAt }

favorites  { userId, listingId }
inquiries  { id, listingId, buyerId, agentId, message, contact,
             status(new|read|replied|closed), reply, repliedAt }

trades     { lawdCd, dealDate, aptName, area, priceManwon, floor,
             dealingGbn, cdealType }        <- 정부 API
rents      { lawdCd, dealDate, aptName, area, deposit, monthlyRent }
notices    { id, source, type, title, district, lawdCd, applyStart, applyEnd, url }
```

---

## 4. ⚠️ 먼저 읽어야 할 함정 9가지

실제로 만들면서 부딪힌 것들입니다. **모르면 그럴듯하게 틀린 앱이 나옵니다.**

### 4-1. 비밀번호를 평문이나 단순 해시로 저장하지 마라

`pbkdf2_sha256` 에 반복 20만회 + 랜덤 솔트로 저장하세요. MD5·SHA1 단독은 안 됩니다.
로그인 실패 메시지는 **"이메일 또는 비밀번호가 올바르지 않습니다"** 하나로 통일하세요.
"없는 계정입니다"로 나누면 어떤 이메일이 가입돼 있는지 알려주는 셈입니다.

### 4-2. 세션 쿠키는 HttpOnly

JS에서 읽을 수 있는 쿠키(또는 localStorage)에 세션을 두면 XSS 한 번에 계정이 털립니다.
`HttpOnly; SameSite=Lax` 로 내려주세요.

### 4-3. 권한은 반드시 서버에서 검사한다

프론트에서 메뉴를 숨기는 건 UI 편의일 뿐입니다. **매수자가 API를 직접 호출하면 통과됩니다.**
모든 라우트에서 역할을 확인하고, 매물 수정·삭제는 **소유자 본인인지**까지 봐야 합니다.

### 4-4. 계정 정지하면 세션도 끊어라

`status = suspended` 만 바꾸면 이미 로그인한 사용자는 계속 씁니다. 세션 레코드도 지우세요.

### 4-5. data.go.kr은 기본 User-Agent를 차단한다

기본 HTTP 클라이언트로 호출하면 `400 Request Blocked` HTML이 옵니다. 브라우저 UA를 넣어야 200이 옵니다.

```
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0 Safari/537.36
```

### 4-6. 법정동코드는 수시로 폐지·신설된다

인터넷 코드표를 그대로 쓰면 **일부 지역이 통째로 빕니다.** 확인한 것만 6개가 폐지 상태였습니다.

| 개편 | 내용 |
|---|---|
| 인천 (2026-07-01) | 중구·동구 폐지 → `28125` 제물포구 / `28155` 영종구<br>서구 → `28275` 서해구 개칭, `28290` 검단구 분리 |
| 부천시 | 일반구 부활: `41192` 원미구 / `41194` 소사구 / `41196` 오정구 |
| 화성시 | 일반구 신설: `41591` 만세구 / `41593` 효행구 / `41595` 병점구 / `41597` 동탄구 |

> **검증 방법**: 실거래 API를 코드별로 호출해 응답의 `sggCd`·`umdNm` 을 보면 됩니다.
> 빈 응답이면 폐지된 코드입니다.

### 4-7. 시세 비교는 반드시 같은 평형끼리 (가장 중요)

면적 버킷(`115㎡~` 같은)으로 묶으면 안 됩니다.

> **실제 사례**: 수지쌍용파크뷰는 전용 120.03㎡가 평당 2,175~2,699만,
> 전용 144.76㎡가 평당 1,849~1,941만에 거래됩니다. 두 타입을 한 버킷에 넣으면
> 거래 많은 120㎡가 중앙값을 지배해 **144㎡ 매물이 전부 "-24% 급매"로 표시됩니다.**
> 실제로는 자기 타입 대비 -5%였습니다.

**큰 평형은 원래 평당가가 낮습니다.** 평당가로 정규화해도 안 없어집니다.
**전용면적 ±3% 밴드**로 좁혀 같은 평형끼리만 비교하세요. 밴드가 좁으면 면적이 사실상 같으니
평당가 말고 **거래 총액을 직접 비교**하면 됩니다. 해석도 쉬워집니다.

### 4-8. 직거래는 시세 계산에서 빼라

가족 간 증여성 거래가 섞여 시세보다 한참 싸게 찍힙니다. `dealingGbn == "직거래"` 를 빼면
오탐이 눈에 띄게 줍니다. IQR 이상치 제거도 함께 넣고, **표본 2건 미만이면 비교하지 마세요.**

### 4-9. 실거래 신고는 최대 30일 지연된다

최근 1개월 수치는 "집계 중"으로 표기하고, **최근 2개월만 재조회**하고 그 이전은 영구 캐시하세요.

---

## 5. 시세 비교 로직 (플랫폼의 핵심)

```
1. 매물의 (lawdCd, aptName, area) 를 가져온다
2. 같은 단지에서 전용면적 ±3% 인 실거래를 최근 12개월치 조회
   - 매매 매물 → trades 테이블
   - 전세 매물 → rents 테이블 (monthlyRent = 0 인 순수 전세만)
   - 월세 매물 → 비교하지 않음
3. IQR 이상치 제거
4. 중앙값 계산. 표본 2건 미만이면 "비교 불가"
5. 비율 = 매물 호가 / 실거래 중앙값
```

표시 문구:

| 비율 | 문구 | 색 |
|---|---|---|
| ≤ 0.90 | "실거래 중앙값보다 N% 낮은 호가" | 초록 |
| 0.90 ~ 1.00 | "실거래 중앙값 대비 N%" | 파랑 |
| 1.00 ~ 1.05 | "실거래 중앙값 수준" | 파랑 |
| > 1.05 | "실거래 중앙값보다 N% 높은 호가" | 빨강 |

매물 목록에 **"시세 이하만"** 필터를 넣으면 매수자가 저평가 매물만 골라볼 수 있습니다.

---

## 6. 사용자 흐름

```
[매수자]  가입 → 매물 검색(예산·지역·면적) → 시세 비교 확인
          → 관심 저장 → 상세 → 문의 발송 → 답변 확인

[중개사]  가입(중개사무소명 필수) → 매물 등록 → 시세 비교 자동 표시
          → 문의 수신(배지) → 답변 → 상태 변경(판매중/거래완료)

[운영자]  현황 대시보드 → 회원 관리(정지/해제) → 매물 상태 변경 → 문의 모니터링
```

---

## 7. 프롬프트 1: 인증과 역할

> 부동산 마켓플레이스 웹앱을 만들어줘. 사용자 역할이 셋이야:
> **buyer(매수자), agent(중개사), admin(운영자)**
>
> **회원가입/로그인**
> - 이메일 + 비밀번호. 비밀번호는 pbkdf2_sha256 (반복 20만회, 랜덤 솔트)로 저장해.
>   평문이나 단순 해시는 절대 안 돼.
> - 중개사 가입은 중개사무소명이 필수야. 중개등록번호는 선택.
> - 운영자는 **첫 가입자만** 선택할 수 있게 해. 이미 있으면 거부.
> - 로그인 실패 메시지는 "이메일 또는 비밀번호가 올바르지 않습니다" 하나로 통일해.
>   계정 존재 여부를 알려주면 안 돼.
>
> **세션**
> - 랜덤 토큰을 DB에 저장하고 **HttpOnly; SameSite=Lax** 쿠키로 내려줘.
>   localStorage나 JS에서 읽는 쿠키는 쓰지 마.
> - 만료 14일.
>
> **권한**
> - 모든 API에서 **서버 측으로** 역할을 검사해. 프론트에서 메뉴 숨기는 건 UI일 뿐이야.
> - 매물 수정·삭제는 등록한 본인(또는 운영자)만 가능해야 해.
> - 계정을 정지하면 그 사용자의 세션도 전부 삭제해.
>
> 헤더에 로그인 상태와 역할 배지를 보여주고, 역할에 따라 메뉴를 다르게 노출해줘.

---

## 8. 프롬프트 2: 매물 등록·탐색

> **매물 등록 (중개사)**
> 필드: 거래유형(매매/전세/월세), 지역(수도권 시군구), 단지명, 읍면동, 주소,
> 전용면적, 층/총층, 준공연도, 가격(매매가·전세보증금), 월세보증금, 월세,
> 제목, 상세설명, 특징(태그), 상태(판매중/거래완료/숨김)
>
> 검증: 거래유형·지역·단지명·전용면적은 필수. 월세면 월세금액 필수, 아니면 가격 필수.
>
> **매물 탐색 (누구나)**
> 필터: 거래유형, 지역, 검색어(단지명·주소), 최소/최대 가격, 전용면적 범위
> 정렬: 최신순 / 가격 낮은순 / 가격 높은순 / 면적 넓은순 / 조회순
> 페이지네이션은 "더 보기" 방식으로.
>
> 카드에는 제목·거래유형·지역·단지명·전용면적·층·가격, 그리고
> **시세 비교 칩**과 중개사무소명을 보여줘.
>
> **관심 매물**
> 카드의 ♡ 를 누르면 저장/해제. 로그인 안 했으면 로그인 화면으로 보내.
> 목록 상단에 '관심 매물' 토글을 둬서 저장한 것만 볼 수 있게 해.
>
> **매물 상세**
> 가격·면적·층·준공연도를 큰 숫자로, 시세 비교 박스, 상세설명, 특징 태그,
> 중개사 정보(사무소명·담당자·연락처), 네이버 부동산 링크
> (`https://fin.land.naver.com/search?query={읍면동} {단지명}`, 단지명만 쓰면
> "주공1단지" 같은 게 전국에서 걸리니 읍면동을 꼭 붙여).
> 조회수를 카운트해줘.

---

## 9. 프롬프트 3: 실거래 시세 비교 (차별점)

> 국토교통부 실거래가 API로 데이터를 수집하고, **모든 매물에 시세 비교를 자동으로 붙여줘.**
>
> **수집**
> `https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade`
> 파라미터: serviceKey, LAWD_CD(법정동 5자리), DEAL_YMD(YYYYMM), numOfRows, pageNo
> 응답은 XML. aptNm, umdNm, excluUseAr(전용면적), dealAmount(만원, 콤마 포함),
> dealYear/Month/Day, floor, buildYear, dealingGbn, cdealType(해제여부)
> 전월세는 `RTMSDataSvcAptRent/getRTMSDataSvcAptRent`, deposit·monthlyRent.
>
> **중요**: data.go.kr은 기본 User-Agent를 차단해. 브라우저 UA 헤더를 꼭 붙여줘.
> 수도권 시군구 코드를 내장하고 지역×월 단위로 수집해. 이미 받은 (지역,월)은
> 재조회하지 말되, 최근 2개월은 신고 지연 때문에 다시 받아야 해.
>
> **시세 비교**
> ```
> 같은 단지 + 전용면적 ±3% 인 최근 12개월 실거래를 찾아
> IQR 이상치와 직거래를 제거한 뒤 중앙값을 낸다.
> 비율 = 매물 호가 / 실거래 중앙값
> ```
> **면적 버킷으로 묶지 마.** 같은 버킷 안에서도 평형이 다르면 평당가가 달라서
> 큰 평형 매물이 전부 "싸다"고 잘못 표시돼. 반드시 ±3% 밴드로 좁혀.
>
> 표시: 0.90 이하 "중앙값보다 N% 낮은 호가"(초록) / 1.05 초과 "N% 높은 호가"(빨강) /
> 그 사이는 "중앙값 대비 N%"(파랑). 표본 2건 미만이면 "비교 불가".
>
> 매물 목록에 **"시세 이하만"** 필터를 넣어줘.
> 상세 화면에는 중앙값·표본수·평당가·최근 거래일도 같이 보여주고,
> "호가와 실거래는 다릅니다"라는 안내를 붙여줘.

---

## 10. 프롬프트 4: 문의와 관리자

> **문의**
> 매수자가 매물 상세에서 중개사에게 문의를 보낸다. 내용 + 연락처(선택).
> 본인이 등록한 매물에는 문의할 수 없고, 판매중인 매물에만 가능해.
>
> 중개사는 '문의' 화면에서 받은 문의를 보고 답변한다.
> 상태는 신규 → 읽음 → 답변완료. 신규 문의 개수를 헤더 메뉴에 빨간 배지로 표시해줘.
> 매수자는 같은 화면에서 보낸 문의와 받은 답변을 본다.
>
> **관리자 대시보드**
> 탭 4개: 현황 / 회원 / 매물 / 문의
>
> - **현황**: 전체 회원(중개사·매수자·정지 계정), 전체 매물(판매중·거래완료·숨김),
>   전체 문의(미응답·답변완료), 관심 저장 수, 오늘 신규 건수.
>   거래유형별·지역별 매물 분포, 수집된 실거래 건수도.
> - **회원**: 목록에서 정지/해제. 운영자 계정과 본인 계정은 정지 못 하게 막아줘.
>   정지하면 그 사용자 세션도 삭제해.
> - **매물**: 전체 매물을 보고 상태를 판매중/검토중/숨김/거래완료로 바꿀 수 있게.
> - **문의**: 최근 문의를 모니터링.

---

## 11. 프롬프트 5: 예산 맞춤 추천 (선택)

매수자가 "내가 얼마짜리를 살 수 있는지" 모르는 게 실제 문제입니다. 있으면 강력한 기능입니다.

> 연봉과 여유자금을 넣으면 살 수 있는 단지를 순위로 뽑는 화면을 만들어줘.
>
> ```
> LTV한도   = 매매가 × 지역별 LTV
> DSR한도   = (연소득 × 0.40 − 기존 연상환액) / 연환산상환계수
> 대출가능액 = min(LTV한도, DSR한도, 총액상한)
> 매수가능총액 = 가용현금 + 대출가능액 − 취득부대비용
> ```
> LTV가 매매가에 비례하고 취득세율도 가격 구간별 누진이라 식이 순환해.
> 두 제약을 닫힌 형태로 풀어 작은 쪽을 취하고, 취득세율은 고정점 반복으로 수렴시켜줘.
>
> 실거래로 단지를 평가해서 100점 스코어를 내줘. 거래 유동성 25, 가격 모멘텀 18,
> 전세가율 18, 고점대비 낙폭 12, 저가매물 빈도 12, 예산 활용도 15.
>
> **전세가율은 절대 척도로.** 68~82%가 만점이고 90% 이상은 감점.
> 높을수록 고득점으로 하면 **깡통전세 단지가 1등**을 해. 실제로 그랬어.
>
> 정렬 기본값은 종합 점수순. 예산은 정렬 키가 아니라 하한선(85%)으로 걸러.
> 예산을 정렬 키로 쓰면 그냥 "비싼 순"이 되고 점수 낮은 단지가 위로 와.

---

## 12. 완성 확인 체크리스트

**인증·권한**
- [ ] 매수자 계정으로 매물 등록 API를 직접 호출하면 403이 뜬다
- [ ] 다른 중개사의 매물을 수정하려 하면 거부된다
- [ ] 계정을 정지하면 그 사용자가 즉시 로그아웃된다
- [ ] 브라우저 콘솔에서 `document.cookie` 로 세션 토큰이 안 보인다

**매물·시세**
- [ ] 매물을 등록하면 시세 비교가 자동으로 붙는다
- [ ] 시세 비교에 쓰인 실거래가 전부 **비슷한 전용면적**이다
- [ ] 실거래가 없는 단지는 "비교 불가"로 나온다
- [ ] "시세 이하만" 필터가 동작한다

**거래 흐름**
- [ ] 매수자 문의 → 중개사 답변 → 매수자 확인까지 왕복된다
- [ ] 중개사 헤더에 미응답 문의 배지가 뜬다
- [ ] 관심 매물 저장/해제가 새로고침 후에도 유지된다

**관리자**
- [ ] 대시보드 숫자가 실제 DB와 일치한다
- [ ] 매물 상태를 숨김으로 바꾸면 매물 목록에서 사라진다

---

## 13. 이 구조를 다른 업종으로

바꿔야 할 것은 **매물 필드와 검색 조건**뿐입니다. 나머지(역할·인증·문의·관리자)는 그대로입니다.

| 업종 | listings 를 바꾸면 | 시세 비교 대신 |
|---|---|---|
| 렌탈·임대 | 장비명, 일 대여료, 가용 기간 | 동종 장비 평균가 |
| 중고차 | 차종, 연식, 주행거리 | 동일 모델 시세 |
| 구인구직 | 직무, 연봉, 근무지 | 직군 평균 연봉 |
| 지역 서비스 | 서비스명, 단가, 소요시간 | 지역 평균 단가 |

**"공급자가 등록 → 수요자가 검색·문의 → 운영자가 관리"** 는 어느 업종이든 같습니다.

---

## 14. 마지막 원칙

- **매물은 사용자 등록 데이터, 시세는 공개 API 데이터.** 둘을 섞어 표시하면 안 됩니다.
  화면에 "매물은 중개사 등록 정보이고, 시세 비교는 국토교통부 실거래가 기준"이라고 명시하세요.
- **호가와 실거래는 다릅니다.** 시세 비교는 참고 지표이지 감정가가 아닙니다.
- 실제 계약 전에는 현장 확인, 권리분석, 등기부 열람이 반드시 필요합니다.
