촌집 AI Insight, 매물 등록 시 분석이 만들어지기까지

February 23, 2026

촌집 AI Insight

TL;DR — 촌집 매물이 등록되면 공공데이터 3종(실거래가·공시지가·토지이용계획)을 수집하고, Claude API로 강점·리스크·라이프스타일·요약 4종의 인사이트를 자동 생성한다.

비동기 워커 + DAG 구조 + promptHash 기반 캐시 무효화가 핵심이다.


목차


어쩌다 촌집

지방 여행을 꽤 좋아한다. 국내에도 구석구석 아름다운 곳이 많고 배우자와 귀촌해서 사는 삶을 진지하게 꿈꾸고 있다.

돌아다닐수록 느낀 건 매매·임대 문의가 곳곳에 정말 많다는 것.

"이 집 내놓습니다"라는 종이부터 지역 카페 글까지 공급은 분명 있는데 이걸 모아서 보여주는 플랫폼이 마땅치 않다.

있긴 있는데 올드한 디자인에 등록 비용이 비싸고 직방·다방 같은 서비스는 수도권 아파트 위주다.

지방 촌집은 대체 어디서 찾아야 할까?

그래서 촌집을 만들고 있다. 😎 지방 촌집 매물을 다루는 플랫폼이고 더 나아가 촌라이프 스타일의 공유까지 다루려 한다.

만들다 보니 한 가지 더 느낀 점이 있었다.

지방 매물은 '검색'보다 '해석'이 더 중요하다는 것.

지방 매물은 왜 해석이 필요할까?

도시 아파트는 비교 기준이 명확하다.

  • 평당가
  • 학군
  • 교통
  • 브랜드
  • 시세 차트

근데 시골 매물은 좀 다르다. 집을 보러 갈 때 드는 질문들이 이런 식이다.

  • 이 가격이 적당한 건가?
  • 건축이 가능한 땅인가?
  • 농사는 지을 수 있나?
  • 법적으로 뭔가 걸리는 건 없나?
  • 주변에서 실제로 거래가 이루어지고 있나?

이런 질문들은 매물 설명 텍스트만 읽어서는 답이 안 나온다. 공공데이터도 봐야 하고 법적 제한도 확인해야 하고 주변 시세도 비교해봐야 한다.

그래서 이 부분을 AI로 자동화하기로 했다. 매물이 등록되면 공공데이터를 수집하고 Claude API로 분석해서 인사이트를 만들어주는 구조다.

전체 구조

촌집의 백엔드는 크게 두 영역으로 나뉜다.

기본 기능 (일반적인 CRUD)

  • 매물 등록/수정/삭제
  • 인증
  • 결제
  • 검색, 필터링

AI 분석 (비동기 처리)

  • 팩트 추출
  • 강점 분석
  • 리스크 분석
  • 라이프스타일 해석
  • 종합 요약
  • 시세 스냅샷

당연하겠지만 AI 분석은 매물 등록/수정 같은 핵심 흐름에 끼어들지 않는다. 별도의 비동기 워커에서 돌아간다.

Step 1. 매물 등록과 Job 발행

매물을 DB에 저장하고 AI 분석 Job을 큐에 넣는다.

property.service.tscreate() 메서드가 매물을 DB에 저장한 뒤 세 가지 비동기 작업을 큐에 넣는다. fire-and-forget 방식이라 큐에 넣는 트리거만 한다.

// property.service.ts

// AI insight 사전 계산 (비동기, 실패해도 매물 등록에 영향 없음)
this.insightJobService
  ?.enqueueFullPropertyInsight(savedProperty.id, "created")
  .catch((error) => {
    this.logger.error("Failed to enqueue insight jobs for new property", error);
  });

// Market snapshot 사전 계산 (비동기)
this.insightJobService
  ?.enqueueMarketSnapshot(savedProperty.id, "created")
  .catch((error) => {
    this.logger.error(
      "Failed to enqueue market snapshot for new property",
      error,
    );
  });

enqueueFullPropertyInsight는 내부적으로 두 개의 Job을 큐에 넣는다.

// insight-job.service.ts

async enqueueFullPropertyInsight(
  propertyId: number,
  reason: InsightJobReason,
): Promise<void> {
  await this.enqueuePropertyFactExtract(propertyId, reason);
  await this.enqueuePropertyInsightGenerate(propertyId, reason);
}

비동기니까 매물 등록 API는 Job 결과를 기다리지 않고 바로 200 OK를 반환한다. AI 분석이 실패해도 매물 등록 자체에는 영향이 없다. Worker는 최대 3개 Job을 동시에 처리하고 실패하면 10초 간격으로 최대 3번 재시도한다. 같은 매물이 30분 안에 다시 들어오면 중복 생성을 방지하기 위해 스킵한다.

Step 2. 팩트 추출 (Fact Extraction)

공공데이터 3종을 수집하고 Claude로 매물 정보를 구조화한다.

PropertyInsightProcessorPROPERTY_FACT_EXTRACT Job을 처리한다.

2-1. 중복 호출 방지

Claude API를 부르기 전에 두 가지를 먼저 확인한다.

  • 같은 프롬프트로 이미 만들어진 데이터가 있으면 스킵
  • 30분 이내에 생성된 데이터가 있으면 스킵 (쿨다운)

여기서 "같은 프롬프트"를 판별하는 게 promptHash라는 개념인데, 프롬프트 템플릿 원본의 SHA-256 해시다. 이 부분은 뒤에서 다시 다룬다.

2-2. Property Context 구성

매물 정보를 Claude에 넘기기 좋은 텍스트 형태로 변환한다. 매물 유형, 거래 유형, 위치, 토지 면적, 가격, 건물면적, 건축년도, 방 수, 난방, 접근성, 지목, 설명 등이 들어간다.

2-3. 공공데이터 3종 병렬 수집

PublicDataService를 통해 정부 API 3종을 동시에 호출한다.

데이터 소스API가져오는 정보캐시 TTL
data.go.kr 실거래가국토교통부 주택/토지 거래 API거래 건수, 평균가, 평당가, 가격 범위24시간
V-World 개별공시지가V-World 개별공시지가 API공시지가(원/㎡), 전년 대비 변동률24시간
V-World 토지이용계획V-World 토지이용계획 API용도지역, 건폐율, 용적률, 건축/농사 가능 여부24시간

V-World 역지오코딩으로 위경도를 PNU(19자리 토지고유번호)로 변환한 뒤 이 PNU로 공시지가와 토지이용계획을 조회한다.

  • 실거래가 — 주변에서 실제로 얼마에 거래됐는지 알아야 이 매물 가격이 비싼지 싼지 판단할 수 있다.
  • 개별공시지가 — 국가가 매긴 기준 가격이다. 시장가가 공시지가보다 얼마나 높은지 보면 프리미엄을 가늠할 수 있다.
  • 토지이용계획 — 건축이 가능한지 아닌지는 추측할 게 아니라 법적으로 확인해야 한다.
  • 역지오코딩 → PNU — 공시지가와 토지이용계획을 조회하려면 PNU(토지고유번호)가 필요하다. 위경도 → 지번 → PNU로 변환하는 과정이 있어야 의미 있는 데이터를 가져올 수 있다.

공공데이터 없이 텍스트만 넣으면 할루시네이션(Hallucination) 확률이 높다.

수집한 데이터는 프롬프트에 넣기 좋은 텍스트로 포맷팅된다. 실제 formatForPrompt()가 만들어내는 텍스트는 이런 형태다.

[공공데이터 실거래 정보  최근 6개월]
 모든 금액은 만원 단위입니다 (: 10,000만원 =  10억원)

 단독/다가구 매매 실거래 (7건)
  평균 거래가: 8,542만원
  최저~최고: 3,200만원 ~ 15,000만원
  대지 평당가(평균): 112만원/
  최근 거래:
    - 서면 단독주택 8,000만원 (대지 231㎡, 건물 89㎡) 2026-01-15
    - 서면 단독주택 5,500만원 (대지 198㎡, 건물 66㎡) 2025-12-20

 토지 매매 (3건)
  평균 토지 평당가: 85만원/

[개별공시지가 정보  2025년 기준]
  공시지가: 42,300원/ ( 140만원/)
  전년도 공시지가: 40,500원/
  전년 대비 변동:  4.44%

[토지이용계획 정보]
   용도지역: 계획관리지역
  건폐율 제한: 40% 이하
  용적률 제한: 100% 이하
  주택 건축 가능: 가능
  영농 가능: 가능
  포함 용도지역/구역 (2건):
    - 계획관리지역 (포함)
    - 가축사육제한구역 (저촉)

2-4. Claude API 호출 — 팩트 추출

수집한 데이터를 extractFacts 프롬프트에 넣어서 Claude를 호출한다.

실제 callLlm() 메서드를 보면 몇 가지 설계 결정이 보인다.

// ai-insight.service.ts

private async callLlm(prompt: string, promptKey?: PromptKey): Promise<string> {
  const { system, userMessage } = this.splitSystemMessage(prompt);

  const response = await this.anthropic.messages.create({
    model: this.model,
    max_tokens: (promptKey && PROMPT_MAX_TOKENS[promptKey]) || this.maxTokens,
    ...(system
      ? {
          system: `${system}\n\nIMPORTANT: You MUST respond with ONLY valid JSON.`,
        }
      : {}),
    messages: [{ role: "user", content: userMessage }],
    temperature: 0.3,
  });

  const textBlock = response.content.find((block) => block.type === "text");
  const content = textBlock?.type === "text" ? textBlock.text : null;

  if (!content) {
    throw new Error("Anthropic returned empty response");
  }

  return this.extractJson(content);
}

temperature: 0.3으로 호출한다. temperature는 Claude 응답의 다양성을 조절하는 값인데 1.0에 가까울수록 창의적이고 0에 가까울수록 일관된다. 매물 분석은 같은 매물을 넣으면 거의 같은 결과가 나와야 하니까 변동성을 낮춘 것이다.

프롬프트 파일에서 SYSTEM: 블록을 파싱해서 Anthropic API의 system 파라미터로 분리 전송한다. Anthropic API는 system 메시지와 user 메시지를 분리하는 걸 권장하는데, 프롬프트 파일 하나에 둘 다 쓸 수 있게 한 것이다. 그리고 system 메시지 끝에 "반드시 JSON으로만 응답하라"는 강제 지시를 붙인다.

그래도 Claude가 가끔 마크다운 코드블록으로 감싸거나 부가 텍스트를 붙이는 경우가 있다. extractJson()이 이걸 처리한다.

// ai-insight.service.ts

private extractJson(raw: string): string {
  // 1. 마크다운 코드블록에서 추출 시도
  const codeBlockMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
  if (codeBlockMatch) {
    return codeBlockMatch[1].trim();
  }

  // 2. JSON 객체/배열 패턴 찾기
  const jsonMatch = raw.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
  if (jsonMatch) {
    return jsonMatch[1].trim();
  }

  // 3. 그대로 반환
  return raw.trim();
}

3단계 폴백으로 어떤 형태로 나오든 JSON을 뽑아낸다. 프롬프트에 의존하는 게 아니라 응답 후처리로 안정성을 확보하는 방식이다.

추출 결과는 13개 카테고리로 나뉜다: location, price, sizes, property_type, access, utilities, surroundings, market_context, official_land_price, land_use, notable_features, evidence, uncertainties

2-5. 저장

  • 기존 활성 row를 isActive: false로 비활성화
  • 새 row 삽입 (factsJson JSONB + rawLlmResponse 원본 보관)
  • 캐시에 저장 (30분 TTL)

같은 매물의 Job이 동시에 돌면 unique constraint 위반이 발생할 수 있는데 이때는 기존 row를 그대로 쓰는 식으로 처리한다.

Step 3. 인사이트 4종 순차 생성

팩트를 바탕으로 강점/리스크/라이프스타일/요약 4종을 만든다.

PROPERTY_INSIGHT_GENERATE Job은 팩트를 기반으로 4개의 인사이트를 순서대로 만든다. 순차 실행인 이유는 마지막 Summary가 앞의 3개 분석 결과를 입력으로 받기 때문이다.

실제 코드를 보면 이 흐름이 명확하다.

// property-insight.processor.ts

const PROPERTY_INSIGHT_TYPES = [
  { insightType: "strengthsAnalysis", promptKey: "property/strengthsAnalysis" },
  { insightType: "riskAnalysis", promptKey: "property/riskAnalysis" },
  {
    insightType: "lifestyleInterpretation",
    promptKey: "property/lifestyleInterpretation",
  },
  { insightType: "summary", promptKey: "property/summary" },
];

// 이전 분석 결과를 모아두는 객체
const generatedInsights: Record<string, Record<string, any>> = {};

for (const { insightType, promptKey } of types) {
  const context: Record<string, string> = {
    ...baseContext,
    property_facts_json: factsJson,
  };

  // Summary 프롬프트에는 앞의 3개 결과를 주입
  if (insightType === "summary") {
    context.strengths_bullets = JSON.stringify(
      generatedInsights.strengthsAnalysis ?? {},
    );
    context.lifestyle_json = JSON.stringify(
      generatedInsights.lifestyleInterpretation ?? {},
    );
    context.risks_json = JSON.stringify(generatedInsights.riskAnalysis ?? {});
  }

  const result = await this.aiInsightService.getOrGeneratePropertyInsight(
    propertyId,
    insightType,
    promptKey,
    context,
  );

  // 다음 인사이트에서 쓸 수 있도록 결과 저장
  if (result.resultJson) {
    generatedInsights[insightType] = result.resultJson;
  }
}

generatedInsights 객체가 핵심이다. 각 인사이트 결과를 여기에 쌓아두고, Summary 차례가 오면 앞의 3개를 꺼내서 프롬프트에 주입한다. for 루프가 순서를 보장하니까 Summary가 실행되는 시점에는 항상 3개가 다 채워져 있다.

이게 어떤 결과를 만들어내는지, 실제 매물 하나를 예시로 보자.

실제 분석 예시: 경기도 양평 토지 매물

입력 (매물 정보 + 공공데이터)

매물 유형: 토지
거래 유형: 매매
위치: 경기도 양평군 서종면
토지 면적: 661㎡
가격: 9,900만원 ( 1.0억)
지목: 
설명: 남향 토지, 도로 접함, 전기 인입 완료

---
[공공데이터 실거래  최근 6개월]
 토지 매매 (5건)
  평균 토지 평당가: 73만원/

[개별공시지가  2025년 기준]
  공시지가: 33,800원/ ( 112만원/)
  전년 대비:  3.2%

[토지이용계획]
   용도지역: 계획관리지역
  건폐율 제한: 40% 이하 / 용적률: 100% 이하
  주택 건축 가능: 가능 / 영농 가능: 가능

Claude 분석 결과 — 강점 (strengthsAnalysis)

{
  "items": [
    {
      "title": "주변 시세 대비 저렴한 평당가",
      "description": "이 매물의 평당가는 약 49만원으로, 서종면 최근 6개월 토지 평균 평당가 73만원 대비 약 33% 저렴합니다.",
      "evidence": [
        "매물 평당가: 9,900만원 ÷ 200평 ≈ 49만원/평",
        "공공데이터 평균: 73만원/평 (최근 5건)"
      ]
    },
    {
      "title": "계획관리지역 — 건축과 영농 모두 가능",
      "description": "계획관리지역은 관리지역 중 가장 개발 친화적인 용도지역입니다. 건폐율 40%, 용적률 100%로 단독주택 건축이 가능하고 농사도 지을 수 있어 귀농·귀촌에 적합합니다.",
      "evidence": [
        "토지이용계획: 계획관리지역",
        "건축 가능 여부: 가능",
        "영농 가능 여부: 가능"
      ]
    },
    {
      "title": "기반 인프라 확보",
      "description": "전기 인입이 완료되어 있고 도로에 접해 있어, 토지 매입 후 추가 인프라 비용이 상대적으로 적습니다.",
      "evidence": ["매물 설명: 전기 인입 완료", "매물 설명: 도로 접함"]
    }
  ]
}

Claude 분석 결과 — 리스크 (riskAnalysis)

{
  "risks": [
    {
      "title": "거래 유동성 낮음",
      "why": "최근 6개월간 서종면 토지 거래가 5건에 불과합니다. 매수 희망자가 적을 수 있어 매도 시 시간이 걸릴 수 있습니다.",
      "evidence": ["공공데이터: 토지 매매 5건 (6개월)"],
      "severity": "medium"
    },
    {
      "title": "상수도·하수도 미확인",
      "why": "지목이 ''이고 상수도·하수도 연결 여부가 명시되지 않았습니다. 지하수 개발이 필요할 수 있으며, 정화조 설치 비용이 추가될 수 있습니다.",
      "evidence": ["매물 설명에 수도 관련 정보 없음"],
      "severity": "medium"
    }
  ],
  "unknowns": [
    {
      "question": "진입 도로의 폭과 포장 상태는?",
      "why_it_matters": "건축 허가 시 도로 폭 4m 이상이 필요합니다. 비포장이면 우천 시 접근이 어려울 수 있습니다."
    }
  ],
  "mitigation_notes": ["현장 방문 시 상수도 인입 가능 여부와 도로 폭 확인 필요"]
}

Claude 분석 결과 — 라이프스타일 (lifestyleInterpretation)

{
  "lifestyle_type": "주말 귀농형 전원생활",
  "daily_living_description": "서울에서 차로 약 1시간 30분 거리인 양평 서종면은 주말마다 오가며 텃밭을 가꾸기에 적합한 위치입니다. 남향이라 일조량이 좋고 661㎡(약 200평) 면적이면 소규모 농사와 주말주택을 함께 운영할 수 있습니다.",
  "ideal_for": [
    "주말 텃밭을 원하는 수도권 직장인",
    "은퇴 후 전원생활을 준비 중인 50~60대"
  ],
  "not_ideal_for": [
    "매일 서울 출퇴근이 필요한 직장인",
    "대중교통 의존도가 높은 가구"
  ],
  "assumptions": ["자가용 보유 전제"],
  "uncertainty_notes": ["겨울철 도로 결빙 상태 미확인"]
}

Claude 분석 결과 — 종합 요약 (summary)

{
  "summary": "양평 서종면에 위치한 661㎡ 토지로, 계획관리지역이라 주택 건축과 농사 모두 가능합니다. 평당 약 49만원으로 주변 평균(73만원/평) 대비 약 33% 저렴하며, 공시지가 기준 토지 가치는 평당 약 112만원으로 전년 대비 3.2% 상승 추세입니다.",
  "highlights": [
    "평당가 주변 대비 33% 저렴",
    "계획관리지역 — 건축·영농 모두 가능",
    "전기 인입 완료, 도로 접함"
  ],
  "considerations": [
    "상수도·하수도 연결 여부 현장 확인 필요",
    "진입 도로 폭과 포장 상태 확인",
    "최근 거래 5건으로 유동성 낮음 — 매도 시 시간 소요 가능"
  ]
}

프롬프트에 "뻔한 분석 지양"이라는 규칙을 넣어둔 덕에, "넓은 토지"처럼 정보를 반복하는 게 아니라 공공데이터 수치를 비교해서 "주변 대비 33% 저렴"같은 구체적 문장이 나온다. 공공데이터가 없었으면 이런 비교 분석은 불가능했을 것이다.

왜 순차 실행인가?

인사이트 생성 흐름을 보면 이런 모양이다.

Summary가 앞선 3개 결과를 다 받아야 하니까 단순히 하나씩 부르는 게 아니라 의존 관계를 고려해서 순서를 짠 것이다. (DAG 구조)

이렇게 하면 좋은 점이 있다.

  • 강점 분석만 바뀌면 Summary만 다시 만들면 된다 (부분 재계산)
  • Strengths, Risk, Lifestyle는 독립적이니까 나중에 병렬화할 수 있다
  • 안 바뀐 부분은 캐시를 그대로 쓸 수 있다

Step 4. 시세 스냅샷 (Market Snapshot)

주변 거래 데이터를 기반으로 시세 지표를 계산한다.

인사이트 생성과 별개로 동시에 돌아가는 Job이다. MarketSnapshotProcessor가 처리하고, PropertyMarketSnapshot 엔티티에 다음 정보를 저장한다.

필드설명
spiSpatial Price Index (공간 가격 지수)
volatilityIndex가격 변동성 지수
changeRate3y3년간 가격 변화율
transactionCount주변 거래 건수
trendData월별 가격·거래량 추이 (JSONB)
aiSummaryAI가 만든 시세 요약
aiRiskInsightAI가 만든 시세 리스크
aiOpportunityInsightAI가 만든 기회 요인
radius분석 반경 (기본 800m)

Step 5. 데이터 조회와 Staleness 관리

사용자가 매물 상세를 볼 때 분석 결과를 반환한다.

사용자가 매물 상세 화면에 들어오면 GraphQL 쿼리가 aiInsight 필드를 요청한다. AgentRouterService가 이걸 처리하는 흐름은 이렇다.

  1. 활성 데이터 조회PropertyFacts + PropertyInsightisActive=true인 것
  2. Staleness 체크 — 프롬프트가 바뀌었거나 데이터가 오래됐는지 확인
  3. 메타데이터 반환 — 4개의 상태 플래그로 프론트엔드에 알려줌
플래그의미
cached: trueDB에서 기존 데이터를 가져옴
stale: true데이터가 있긴 한데 오래돼서 갱신 중
recomputeTriggered: true백그라운드에서 새 분석을 시작함
preparing: true아직 분석 데이터가 없음 (처음 만들고 있는 중)

이 플래그 조합으로 프론트엔드가 UI를 분기한다.

  • 분석 완료 전 (preparing: true) → "분석 중" 로딩 UI
  • 분석 완료 후 (cached: true, stale: false) → 인사이트 즉시 표시
  • 시간 경과 후 (stale: true, recomputeTriggered: true) → 기존 데이터 보여주면서 "업데이트 중" 뱃지

Stale-While-Revalidate 패턴이다. 오래된 데이터라도 일단 보여주고 백그라운드에서 갱신하는 방식. 사용자는 빈 화면을 보지 않는다.

프롬프트 관리

프롬프트 파일을 수정하면 기존에 만들어둔 모든 인사이트가 자동으로 다시 만들어지는 구조를 만들고 싶었다. 핵심은 promptHash다.

// prompt-builder.ts

export function buildPrompt(
  key: PromptKey,
  vars: Record<string, string> = {},
): BuildPromptResult {
  const template = getPromptTemplate(key); // 파일에서 읽기 (메모리 캐시)
  const promptHash = getPromptHash(key); // SHA-256 (변수 치환 전 원본 기준)

  let prompt = template;
  for (const [varName, value] of Object.entries(vars)) {
    prompt = prompt.replaceAll(`{{${varName}}}`, value);
  }

  return { prompt, promptHash, promptKey: key };
}

export function getPromptHash(key: PromptKey): string {
  const template = getPromptTemplate(key);
  const hash = createHash("sha256").update(template).digest("hex");
  return hash;
}

해시는 변수 치환 전 원본 템플릿에 대해 계산한다. 이게 중요한 결정인데, 이렇게 하면 두 가지가 가능해진다.

  • 프롬프트 문구를 수정하면 → 해시가 바뀜 → 기존 인사이트가 모두 stale 처리 → 자동 재생성
  • 같은 프롬프트 + 다른 매물 데이터면 → 해시 같음 → DB에서 (propertyId, promptHash) 조합으로 캐시 조회

프롬프트를 고치면 일괄 재계산도 가능하다. INSIGHT_RECOMPUTE_ON_PROMPT_CHANGE라는 Job 타입이 있어서, 프롬프트 변경이 감지되면 해당 프롬프트를 쓰는 모든 매물의 인사이트를 비동기로 다시 만든다.

프롬프트는 @chonzip/prompts 패키지로 따로 관리하고 있다. 총 18개 프롬프트가 5개 폴더로 나뉘어 있다.

카테고리프롬프트 수용도
property/5개매물 분석 (이 글의 주제)
area/5개지역 리포트
comparison/4개매물 비교
recommendation/4개추천 설명
market/1개시세 인사이트

인사이트별로 토큰 예산도 다르게 잡았다. 모든 호출에 max_tokens 4096을 주면 비용이 낭비되니까, 응답 길이가 다른 프롬프트별로 예산을 달리 설정한다.

// ai-insight.service.ts

const PROMPT_MAX_TOKENS: Partial<Record<PromptKey, number>> = {
  "property/extractFacts": 4096, // 12개 카테고리 구조화
  "property/strengthsAnalysis": 4096, // 근거 기반 상세 분석
  "property/riskAnalysis": 4096, // risk + unknown 분리
  "property/lifestyleInterpretation": 2048, // 서술형이라 이 정도면 충분
  "property/summary": 1024, // 간결한 요약
};

Summary는 2~4문장 + 목록 3개씩이니까 1024 토큰이면 충분하다. 반면 Fact Extraction은 12개 카테고리의 구조화된 JSON을 뽑아야 하니까 4096이 필요하다. Claude API 비용은 출력 토큰에 비례하니까 이렇게 잘라두면 불필요한 과금을 줄일 수 있다.

전체 타임라인

매물 등록 API 응답은 0.1초 만에 끝나고 나머지는 전부 백그라운드에서 돌아간다. Claude API 호출은 총 5번이고, 각 호출당 3-5초 정도 걸린다. 전체 파이프라인은 대략 25-30초 안에 끝난다. 시세 스냅샷은 Claude 호출과 병렬로 진행되니까 전체 시간에 영향을 주지 않는다.

실패하면? Worker가 10초 후에 재시도하고, 3번 다 실패하면 포기한다. 사용자가 매물 상세에 다시 들어올 때 preparing: true로 한 번 더 트리거되니까, 일시적인 API 장애는 자연스럽게 복구된다.

만들면서 배운 것들 — 컨텍스트 프롬프팅

이 프로젝트에서 가장 크게 배운 건 컨텍스트 프롬프팅 (Contextual Prompting)의 중요성이다.

프롬프트 문구를 다듬는 것보다 AI한테 넘기는 맥락 데이터를 잘 구성하는 게 결과 품질에 훨씬 큰 영향을 준다는 개념이다.

주변 실거래가 3건과 공시지가를 같이 넣으니까 "주변 평균 대비 20% 저렴"이라는 근거 있는 문장이 나왔다. 토지이용계획을 추가하니까 "농림지역이라 증축 제한이 있을 수 있다"는 실질적인 리스크도 짚어줬다. 프롬프트는 그대로인데 컨텍스트만 바뀌니까 분석 품질이 완전히 달라졌다.

DAG 구조도 결국 컨텍스트를 단계별로 쌓아가는 과정이다. Facts 단계에서 공공데이터를 정리해두면 그다음 Strengths·Risk·Lifestyle 분석에서 그걸 컨텍스트로 쓴다. 앞 단계 결과가 뒷 단계의 입력이 되니까 분석이 점점 구체적으로 된다. 처음부터 모든 걸 한 번에 분석하라고 했을 때보다 단계별로 컨텍스트를 넘기는 게 결과가 훨씬 좋았다.

temperature를 0.3으로 낮춘 것도 같은 맥락이다. 컨텍스트를 충분히 줬으면 Claude가 창작할 필요가 없다. 데이터에 기반해서 정리만 하면 된다. 창의성을 낮추니까 오히려 할루시네이션이 줄고 일관된 분석이 나왔다.

결국 AI한테 판단에 필요한 재료를 빠짐없이 넘기는 게 중요했다. 끝.

촌집 앱 출시도 곧 앞두고 있다. 지방 촌집에 관심 있다면 한 번 들러주시길 😎