");
Matcher matcher = pattern.matcher(row);
return matcher.find() ? matcher.group(1) : "";
}
+ private static List tableCells(String row) {
+ List cells = new ArrayList<>();
+ Matcher cellMatcher = TABLE_CELL_PATTERN.matcher(row);
+ while (cellMatcher.find()) {
+ String value = cleanHtml(cellMatcher.group(1));
+ if (!value.isBlank()) {
+ cells.add(value);
+ }
+ }
+ return cells;
+ }
+
private static String attribute(String attributes, String name) {
Pattern pattern = Pattern.compile("(?is)\\b" + name + "\\s*=\\s*\"([^\"]*)\"");
Matcher matcher = pattern.matcher(attributes);
@@ -520,12 +1230,20 @@ private static String attribute(String attributes, String name) {
}
private static List dateStrings(String value) {
- List dates = new ArrayList<>();
- Matcher matcher = ISO_DATE_PATTERN.matcher(value);
- while (matcher.find()) {
- dates.add(matcher.group());
+ LinkedHashSet dates = new LinkedHashSet<>();
+ Matcher isoMatcher = ISO_DATE_PATTERN.matcher(value);
+ while (isoMatcher.find()) {
+ dates.add(isoMatcher.group());
}
- return dates;
+ Matcher dotMatcher = DOT_DATE_PATTERN.matcher(value);
+ while (dotMatcher.find()) {
+ dates.add(normalizedDate(dotMatcher.group(1), dotMatcher.group(2), dotMatcher.group(3)));
+ }
+ Matcher shortDotMatcher = SHORT_DOT_DATE_PATTERN.matcher(value);
+ while (shortDotMatcher.find()) {
+ dates.add(normalizedDate("20" + shortDotMatcher.group(1), shortDotMatcher.group(2), shortDotMatcher.group(3)));
+ }
+ return new ArrayList<>(dates);
}
private static LocalDate firstDateInMonth(List values, YearMonth month) {
@@ -549,6 +1267,21 @@ private static LocalDate parseDate(String value) {
return LocalDate.parse(matcher.group());
}
+ private static LocalDate parseCompactDate(String value) {
+ if (isBlank(value) || value.length() != 8) {
+ return null;
+ }
+ return LocalDate.of(
+ Integer.parseInt(value.substring(0, 4)),
+ Integer.parseInt(value.substring(4, 6)),
+ Integer.parseInt(value.substring(6, 8))
+ );
+ }
+
+ private static String normalizedDate(String year, String month, String day) {
+ return String.format("%s-%02d-%02d", year, Integer.parseInt(month), Integer.parseInt(day));
+ }
+
private static String applyHomeSummary(List dates) {
String noticeDate = dates.getFirst();
if (dates.size() >= 4) {
@@ -593,6 +1326,10 @@ private static String absoluteUrl(String baseUrl, String href) {
return URI.create(baseUrl).resolve(href).toString();
}
+ private static String bokStatScheduleUrl(YearMonth month) {
+ return "https://www.bok.or.kr/portal/stats/statsPublictSchdul/listCldr.do?date=" + month + "&menuNo=200775";
+ }
+
private static String queryParam(String url, String name) {
String query = URI.create(url).getRawQuery();
if (query == null) {
@@ -611,6 +1348,31 @@ private static String queryParam(String url, String name) {
return "";
}
+ private static boolean hasScheduleContextUrl(ScheduleSeed seed) {
+ if (isBlank(seed.linkUrl())) {
+ return false;
+ }
+ String url = seed.linkUrl();
+ if ("bok-rate".equals(seed.sourceId()) && BOK_RATE_SCHEDULE_URL.equals(url)) {
+ return true;
+ }
+ if ("bok-stat".equals(seed.sourceId()) && url.startsWith("https://www.bok.or.kr/portal/stats/statsPublictSchdul/listCldr.do?date=")) {
+ return true;
+ }
+ return url.contains("selectBulletinPage.do")
+ || url.contains("easyStatPage.do?cateId=")
+ || url.contains("viewChk.do")
+ || url.contains("selectAPTLttotPblancDetail.do")
+ || url.contains("selectWrtancInfo.do")
+ || url.contains("articleId=")
+ || url.contains("/site/main/sh/publicLease/07/view")
+ || url.contains("announcement-of-salerental001.do?articleNo=")
+ || url.contains("/main/sale_lease/notice_view.jsp")
+ || url.contains("/USR/NEWS/")
+ || url.contains("/no010101/")
+ || url.contains("boardDetailAll.board?seq=");
+ }
+
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
@@ -642,6 +1404,7 @@ private MarketDataSourceLinkResponse toSourceResponse(MarketDataSource source) {
source.getSourceUrl(),
source.getProvider(),
source.getStatus(),
+ source.isStale() ? "error" : "ok",
source.isStale(),
source.getLastCheckedAt()
);
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReport.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReport.java
new file mode 100644
index 0000000..bf72493
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReport.java
@@ -0,0 +1,211 @@
+package com.youbuyfirst.backend.realestate;
+
+import jakarta.persistence.CascadeType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.OrderBy;
+import jakarta.persistence.Table;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+@Entity
+@Table(name = "real_estate_regional_reports")
+public class RealEstateRegionalReport {
+
+ @Id
+ @Column(name = "target_id", length = 120)
+ private String targetId;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "target_id", insertable = false, updatable = false)
+ private RealEstateTarget target;
+
+ @Column(name = "report_id", nullable = false, length = 160)
+ private String reportId;
+
+ @Column(name = "report_version", nullable = false, length = 80)
+ private String reportVersion;
+
+ @Column(name = "prompt_version", length = 80)
+ private String promptVersion;
+
+ @Column(name = "model_name", length = 80)
+ private String modelName;
+
+ @Column(name = "generated_by", nullable = false, length = 80)
+ private String generatedBy;
+
+ @Column(nullable = false, length = 200)
+ private String title;
+
+ @Column(nullable = false, length = 300)
+ private String headline;
+
+ @Column(nullable = false, columnDefinition = "text")
+ private String summary;
+
+ @Column(nullable = false, columnDefinition = "text")
+ private String body;
+
+ @Column(name = "expectation_points_json", nullable = false, columnDefinition = "text")
+ private String expectationPointsJson;
+
+ @Column(name = "concern_points_json", nullable = false, columnDefinition = "text")
+ private String concernPointsJson;
+
+ @Column(name = "data_quality", nullable = false, length = 30)
+ private String dataQuality;
+
+ @Column
+ private Double confidence;
+
+ @Column(name = "as_of", nullable = false)
+ private Instant asOf;
+
+ @Column(name = "published_at", nullable = false)
+ private Instant publishedAt;
+
+ @Column(name = "created_at", nullable = false)
+ private Instant createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+
+ @OrderBy("displayOrder asc, id asc")
+ @OneToMany(mappedBy = "report", cascade = CascadeType.ALL, orphanRemoval = true)
+ private final List sources = new ArrayList<>();
+
+ protected RealEstateRegionalReport() {
+ }
+
+ public RealEstateRegionalReport(String targetId, Instant now) {
+ this.targetId = targetId;
+ this.createdAt = now;
+ }
+
+ public void update(
+ RealEstateTarget target,
+ String reportId,
+ String reportVersion,
+ String promptVersion,
+ String modelName,
+ String generatedBy,
+ String title,
+ String headline,
+ String summary,
+ String body,
+ String expectationPointsJson,
+ String concernPointsJson,
+ String dataQuality,
+ Double confidence,
+ Instant asOf,
+ Instant publishedAt,
+ List nextSources,
+ Instant now
+ ) {
+ this.targetId = target.getId();
+ this.target = target;
+ this.reportId = reportId;
+ this.reportVersion = reportVersion;
+ this.promptVersion = promptVersion;
+ this.modelName = modelName;
+ this.generatedBy = generatedBy;
+ this.title = title;
+ this.headline = headline;
+ this.summary = summary;
+ this.body = body;
+ this.expectationPointsJson = expectationPointsJson;
+ this.concernPointsJson = concernPointsJson;
+ this.dataQuality = dataQuality;
+ this.confidence = confidence;
+ this.asOf = asOf;
+ this.publishedAt = publishedAt;
+ this.sources.clear();
+ nextSources.forEach(this::addSource);
+ this.updatedAt = now;
+ }
+
+ private void addSource(RealEstateRegionalReportSource source) {
+ source.attachTo(this);
+ this.sources.add(source);
+ }
+
+ public String getTargetId() {
+ return targetId;
+ }
+
+ public RealEstateTarget getTarget() {
+ return target;
+ }
+
+ public String getReportId() {
+ return reportId;
+ }
+
+ public String getReportVersion() {
+ return reportVersion;
+ }
+
+ public String getPromptVersion() {
+ return promptVersion;
+ }
+
+ public String getModelName() {
+ return modelName;
+ }
+
+ public String getGeneratedBy() {
+ return generatedBy;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getHeadline() {
+ return headline;
+ }
+
+ public String getSummary() {
+ return summary;
+ }
+
+ public String getBody() {
+ return body;
+ }
+
+ public String getExpectationPointsJson() {
+ return expectationPointsJson;
+ }
+
+ public String getConcernPointsJson() {
+ return concernPointsJson;
+ }
+
+ public String getDataQuality() {
+ return dataQuality;
+ }
+
+ public Double getConfidence() {
+ return confidence;
+ }
+
+ public Instant getAsOf() {
+ return asOf;
+ }
+
+ public Instant getPublishedAt() {
+ return publishedAt;
+ }
+
+ public List getSources() {
+ return sources;
+ }
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportController.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportController.java
new file mode 100644
index 0000000..b8b55a9
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportController.java
@@ -0,0 +1,33 @@
+package com.youbuyfirst.backend.realestate;
+
+import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportBatchRequest;
+import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportBatchResponse;
+import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportResponse;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class RealEstateRegionalReportController {
+
+ private final RealEstateRegionalReportService service;
+
+ public RealEstateRegionalReportController(RealEstateRegionalReportService service) {
+ this.service = service;
+ }
+
+ @PostMapping("/internal/realestate/regional-reports")
+ public RealEstateRegionalReportBatchResponse upsertRegionalReports(
+ @Valid @RequestBody RealEstateRegionalReportBatchRequest request
+ ) {
+ return service.upsertAll(request.reports());
+ }
+
+ @GetMapping("/api/realestate/targets/{targetId}/regional-report")
+ public RealEstateRegionalReportResponse targetRegionalReport(@PathVariable String targetId) {
+ return service.latestForTarget(targetId);
+ }
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportRepository.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportRepository.java
new file mode 100644
index 0000000..0c10413
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportRepository.java
@@ -0,0 +1,6 @@
+package com.youbuyfirst.backend.realestate;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface RealEstateRegionalReportRepository extends JpaRepository {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportService.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportService.java
new file mode 100644
index 0000000..32d1321
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportService.java
@@ -0,0 +1,232 @@
+package com.youbuyfirst.backend.realestate;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportBatchResponse;
+import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportRequest;
+import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportResponse;
+import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportSourceRequest;
+import com.youbuyfirst.backend.realestate.dto.RealEstateRegionalReportSourceResponse;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+
+@Service
+public class RealEstateRegionalReportService {
+
+ private static final TypeReference> STRING_LIST = new TypeReference<>() {
+ };
+
+ private final RealEstateRegionalReportRepository reportRepository;
+ private final RealEstateTargetRepository targetRepository;
+ private final RealEstateRegionRepository regionRepository;
+ private final ObjectMapper objectMapper;
+
+ public RealEstateRegionalReportService(
+ RealEstateRegionalReportRepository reportRepository,
+ RealEstateTargetRepository targetRepository,
+ RealEstateRegionRepository regionRepository,
+ ObjectMapper objectMapper
+ ) {
+ this.reportRepository = reportRepository;
+ this.targetRepository = targetRepository;
+ this.regionRepository = regionRepository;
+ this.objectMapper = objectMapper;
+ }
+
+ @Transactional
+ public RealEstateRegionalReportBatchResponse upsertAll(Collection requests) {
+ Instant now = Instant.now();
+ int acceptedReports = 0;
+ int acceptedSources = 0;
+ for (RealEstateRegionalReportRequest request : requests == null ? List.of() : requests) {
+ RealEstateTarget target = targetRepository.findById(requireText(request.targetId(), "targetId"))
+ .orElseThrow(() -> new ResponseStatusException(
+ HttpStatus.BAD_REQUEST,
+ "unknown real-estate target: " + request.targetId()
+ ));
+ List sources = toSources(target.getId(), request.sources(), now);
+ RealEstateRegionalReport report = reportRepository.findById(target.getId())
+ .orElseGet(() -> new RealEstateRegionalReport(target.getId(), now));
+ report.update(
+ target,
+ requireText(request.reportId(), "reportId"),
+ requireText(request.reportVersion(), "reportVersion"),
+ trimToNull(request.promptVersion()),
+ trimToNull(request.modelName()),
+ requireText(request.generatedBy(), "generatedBy"),
+ requireText(request.title(), "title"),
+ requireText(request.headline(), "headline"),
+ requireText(request.summary(), "summary"),
+ requireText(request.body(), "body"),
+ writeStringList(request.expectationPoints()),
+ writeStringList(request.concernPoints()),
+ normalizeLower(requireText(request.dataQuality(), "dataQuality")),
+ clampConfidence(request.confidence()),
+ requireInstant(request.asOf(), "asOf"),
+ requireInstant(request.publishedAt(), "publishedAt"),
+ sources,
+ now
+ );
+ reportRepository.save(report);
+ acceptedReports++;
+ acceptedSources += sources.size();
+ }
+ return new RealEstateRegionalReportBatchResponse(acceptedReports, acceptedSources);
+ }
+
+ @Transactional(readOnly = true)
+ public RealEstateRegionalReportResponse latestForTarget(String targetId) {
+ String normalizedTargetId = requireText(targetId, "targetId");
+ targetRepository.findById(normalizedTargetId)
+ .orElseThrow(() -> new ResponseStatusException(
+ HttpStatus.NOT_FOUND,
+ "unknown real-estate target: " + normalizedTargetId
+ ));
+ RealEstateRegionalReport report = reportRepository.findById(normalizedTargetId)
+ .orElseThrow(() -> new ResponseStatusException(
+ HttpStatus.NOT_FOUND,
+ "regional report is not ready for target: " + normalizedTargetId
+ ));
+ return toResponse(report);
+ }
+
+ private List toSources(
+ String targetId,
+ List requests,
+ Instant now
+ ) {
+ List safeRequests = requests == null ? List.of() : requests;
+ return java.util.stream.IntStream.range(0, safeRequests.size())
+ .mapToObj(index -> toSource(targetId, safeRequests.get(index), index + 1, now))
+ .toList();
+ }
+
+ private RealEstateRegionalReportSource toSource(
+ String targetId,
+ RealEstateRegionalReportSourceRequest request,
+ int displayOrder,
+ Instant now
+ ) {
+ return new RealEstateRegionalReportSource(
+ defaultIfBlank(request.reportSourceId(), "regional-report-source-%s-%02d".formatted(targetId, displayOrder)),
+ displayOrder,
+ normalizeLower(requireText(request.refType(), "refType")),
+ trimToNull(request.refId()),
+ requireText(request.label(), "label"),
+ requireText(request.title(), "title"),
+ trimToNull(request.url()),
+ trimToNull(request.sourceName()),
+ request.publishedAt(),
+ normalizeLower(requireText(request.dataStatus(), "dataStatus")),
+ now
+ );
+ }
+
+ private RealEstateRegionalReportResponse toResponse(RealEstateRegionalReport report) {
+ RealEstateRegion region = regionRepository.findById(report.getTargetId()).orElse(null);
+ RealEstateTarget target = report.getTarget();
+ return new RealEstateRegionalReportResponse(
+ report.getReportId(),
+ report.getTargetId(),
+ target == null ? report.getTargetId() : target.getDisplayName(),
+ region == null ? null : region.getRegionLevel(),
+ region == null ? null : region.getRegionCode(),
+ report.getReportVersion(),
+ report.getPromptVersion(),
+ report.getModelName(),
+ report.getGeneratedBy(),
+ report.getTitle(),
+ report.getHeadline(),
+ report.getSummary(),
+ report.getBody(),
+ readStringList(report.getExpectationPointsJson()),
+ readStringList(report.getConcernPointsJson()),
+ report.getDataQuality(),
+ report.getConfidence(),
+ report.getAsOf(),
+ report.getPublishedAt(),
+ report.getSources().stream().map(this::toSourceResponse).toList()
+ );
+ }
+
+ private RealEstateRegionalReportSourceResponse toSourceResponse(RealEstateRegionalReportSource source) {
+ return new RealEstateRegionalReportSourceResponse(
+ source.getId(),
+ source.getDisplayOrder(),
+ source.getRefType(),
+ source.getRefId(),
+ source.getLabel(),
+ source.getTitle(),
+ source.getUrl(),
+ source.getSourceName(),
+ source.getPublishedAt(),
+ source.getDataStatus()
+ );
+ }
+
+ private String writeStringList(List values) {
+ try {
+ List normalized = (values == null ? List.of() : values).stream()
+ .map(RealEstateRegionalReportService::trimToNull)
+ .filter(value -> value != null)
+ .toList();
+ return objectMapper.writeValueAsString(normalized);
+ } catch (JsonProcessingException exc) {
+ throw new IllegalArgumentException("invalid regional report points", exc);
+ }
+ }
+
+ private List readStringList(String value) {
+ try {
+ return objectMapper.readValue(value, STRING_LIST);
+ } catch (JsonProcessingException exc) {
+ throw new IllegalStateException("invalid stored regional report points", exc);
+ }
+ }
+
+ private static Instant requireInstant(Instant value, String fieldName) {
+ if (value == null) {
+ throw new IllegalArgumentException(fieldName + " is required");
+ }
+ return value;
+ }
+
+ private static String requireText(String value, String fieldName) {
+ String trimmed = trimToNull(value);
+ if (trimmed == null) {
+ throw new IllegalArgumentException(fieldName + " is required");
+ }
+ return trimmed;
+ }
+
+ private static String defaultIfBlank(String value, String fallback) {
+ String trimmed = trimToNull(value);
+ return trimmed == null ? fallback : trimmed;
+ }
+
+ private static String trimToNull(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ return value.trim();
+ }
+
+ private static String normalizeLower(String value) {
+ return value.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static Double clampConfidence(Double value) {
+ if (value == null) {
+ return null;
+ }
+ return Math.max(0.0, Math.min(1.0, value));
+ }
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportSource.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportSource.java
new file mode 100644
index 0000000..c5b717b
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateRegionalReportSource.java
@@ -0,0 +1,131 @@
+package com.youbuyfirst.backend.realestate;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+
+import java.time.Instant;
+
+@Entity
+@Table(name = "real_estate_regional_report_sources")
+public class RealEstateRegionalReportSource {
+
+ @Id
+ @Column(length = 160)
+ private String id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "report_target_id", nullable = false, referencedColumnName = "target_id")
+ private RealEstateRegionalReport report;
+
+ @Column(name = "display_order", nullable = false)
+ private int displayOrder;
+
+ @Column(name = "ref_type", nullable = false, length = 60)
+ private String refType;
+
+ @Column(name = "ref_id", length = 160)
+ private String refId;
+
+ @Column(nullable = false, length = 120)
+ private String label;
+
+ @Column(nullable = false, length = 240)
+ private String title;
+
+ @Column(columnDefinition = "text")
+ private String url;
+
+ @Column(name = "source_name", length = 160)
+ private String sourceName;
+
+ @Column(name = "published_at")
+ private Instant publishedAt;
+
+ @Column(name = "data_status", nullable = false, length = 30)
+ private String dataStatus;
+
+ @Column(name = "created_at", nullable = false)
+ private Instant createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+
+ protected RealEstateRegionalReportSource() {
+ }
+
+ public RealEstateRegionalReportSource(
+ String id,
+ int displayOrder,
+ String refType,
+ String refId,
+ String label,
+ String title,
+ String url,
+ String sourceName,
+ Instant publishedAt,
+ String dataStatus,
+ Instant now
+ ) {
+ this.id = id;
+ this.displayOrder = displayOrder;
+ this.refType = refType;
+ this.refId = refId;
+ this.label = label;
+ this.title = title;
+ this.url = url;
+ this.sourceName = sourceName;
+ this.publishedAt = publishedAt;
+ this.dataStatus = dataStatus;
+ this.createdAt = now;
+ this.updatedAt = now;
+ }
+
+ void attachTo(RealEstateRegionalReport report) {
+ this.report = report;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public int getDisplayOrder() {
+ return displayOrder;
+ }
+
+ public String getRefType() {
+ return refType;
+ }
+
+ public String getRefId() {
+ return refId;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public String getTitle() {
+ return title;
+ }
+
+ public String getUrl() {
+ return url;
+ }
+
+ public String getSourceName() {
+ return sourceName;
+ }
+
+ public Instant getPublishedAt() {
+ return publishedAt;
+ }
+
+ public String getDataStatus() {
+ return dataStatus;
+ }
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetController.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetController.java
index 4c8c702..ee5e641 100644
--- a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetController.java
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetController.java
@@ -24,9 +24,10 @@ public RealEstateTargetController(RealEstateTargetService service) {
@GetMapping("/api/realestate/targets/search")
public RealEstateTargetListResponse search(
@RequestParam(required = false) String q,
- @RequestParam(defaultValue = "20") int limit
+ @RequestParam(defaultValue = "20") int limit,
+ @RequestParam(defaultValue = "default") String mode
) {
- return new RealEstateTargetListResponse(service.search(q, limit));
+ return new RealEstateTargetListResponse(service.search(q, limit, "autocomplete".equalsIgnoreCase(mode)));
}
@GetMapping("/internal/realestate/market-data-targets")
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetRepository.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetRepository.java
index 137f1e9..6ef7d33 100644
--- a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetRepository.java
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetRepository.java
@@ -82,4 +82,216 @@ List searchTargets(
@Param("rawQuery") String rawQuery,
Pageable pageable
);
+
+ @Query(value = """
+ select
+ target.id as targetId,
+ target.target_type as targetType,
+ target.display_name as displayName,
+ target.slug as slug,
+ target.review_state as reviewState,
+ target.data_status as dataStatus,
+ case when target.target_type = 'complex' then 'complex' else region.region_level end as regionLevel,
+ case when target.target_type = 'complex' then complex.region_target_id else region.parent_region_id end as parentTargetId,
+ case when target.target_type = 'complex' then coalesce(complex.legal_dong_code, parent_region.legal_dong_code) else region.legal_dong_code end as legalDongCode,
+ case when target.target_type = 'complex' then parent_region.region_code else region.region_code end as regionCode
+ from (
+ select matched.target_id, min(matched.score) as score
+ from (
+ select exact_target.id as target_id, 0 as score
+ from real_estate_targets exact_target
+ where exact_target.normalized_name = :normalizedQuery
+ or exact_target.slug = :normalizedQuery
+ or exact_target.id = :normalizedQuery
+ union all
+ select exact_region.target_id as target_id, 0 as score
+ from real_estate_regions exact_region
+ where exact_region.legal_dong_code = :rawQuery
+ union all
+ select exact_complex.target_id as target_id, 0 as score
+ from real_estate_complexes exact_complex
+ where exact_complex.legal_dong_code = :rawQuery
+ union all
+ select exact_alias.target_id as target_id, 0 as score
+ from real_estate_aliases exact_alias
+ where exact_alias.review_state = 'approved'
+ and exact_alias.ambiguous = false
+ and exact_alias.normalized_alias = :aliasQuery
+ union all
+ select prefix_target.id as target_id, 1 as score
+ from real_estate_targets prefix_target
+ where prefix_target.normalized_name like concat(:normalizedQuery, '%')
+ or prefix_target.slug like concat(:normalizedQuery, '%')
+ or prefix_target.id like concat(:normalizedQuery, '%')
+ union all
+ select prefix_region.target_id as target_id, 1 as score
+ from real_estate_regions prefix_region
+ where prefix_region.legal_dong_code like concat(:rawQuery, '%')
+ union all
+ select prefix_complex.target_id as target_id, 1 as score
+ from real_estate_complexes prefix_complex
+ where prefix_complex.legal_dong_code like concat(:rawQuery, '%')
+ union all
+ select prefix_alias.target_id as target_id, 1 as score
+ from real_estate_aliases prefix_alias
+ where prefix_alias.review_state = 'approved'
+ and prefix_alias.ambiguous = false
+ and prefix_alias.normalized_alias like concat(:aliasQuery, '%')
+ union all
+ select contains_region_target.id as target_id, 1 as score
+ from real_estate_targets contains_region_target
+ where contains_region_target.target_type = 'region'
+ and contains_region_target.normalized_name like concat('%', :normalizedQuery, '%')
+ ) matched
+ group by matched.target_id
+ ) ranked
+ join real_estate_targets target on target.id = ranked.target_id
+ left join real_estate_regions region on region.target_id = target.id
+ left join real_estate_complexes complex on complex.target_id = target.id
+ left join real_estate_regions parent_region on parent_region.target_id = complex.region_target_id
+ order by
+ ranked.score asc,
+ case
+ when target.target_type = 'region' then 0
+ when target.target_type = 'complex' then 1
+ else 2
+ end asc,
+ case
+ when target.data_status = 'ok' then 0
+ when target.data_status = 'partial' then 1
+ when target.data_status = 'candidate' then 2
+ when target.data_status = 'mock' then 3
+ else 4
+ end asc,
+ target.display_name asc
+ """, nativeQuery = true)
+ List searchTargetsIndexed(
+ @Param("normalizedQuery") String normalizedQuery,
+ @Param("aliasQuery") String aliasQuery,
+ @Param("rawQuery") String rawQuery,
+ Pageable pageable
+ );
+
+ @Query(value = """
+ select
+ target.id as targetId,
+ target.target_type as targetType,
+ target.display_name as displayName,
+ target.slug as slug,
+ target.review_state as reviewState,
+ target.data_status as dataStatus,
+ case when target.target_type = 'complex' then 'complex' else region.region_level end as regionLevel,
+ case when target.target_type = 'complex' then complex.region_target_id else region.parent_region_id end as parentTargetId,
+ case when target.target_type = 'complex' then coalesce(complex.legal_dong_code, parent_region.legal_dong_code) else region.legal_dong_code end as legalDongCode,
+ case when target.target_type = 'complex' then parent_region.region_code else region.region_code end as regionCode
+ from (
+ select matched.target_id, min(matched.score) as score
+ from (
+ select exact_target.id as target_id, 0 as score
+ from real_estate_targets exact_target
+ left join real_estate_regions exact_region on exact_region.target_id = exact_target.id
+ left join real_estate_complexes exact_complex on exact_complex.target_id = exact_target.id
+ where exact_target.normalized_name = :normalizedQuery
+ or exact_target.slug = :normalizedQuery
+ or exact_target.id = :normalizedQuery
+ or exact_region.legal_dong_code = :rawQuery
+ or exact_complex.legal_dong_code = :rawQuery
+ union all
+ select exact_alias.target_id as target_id, 0 as score
+ from real_estate_aliases exact_alias
+ where exact_alias.review_state = 'approved'
+ and exact_alias.ambiguous = false
+ and exact_alias.normalized_alias = :aliasQuery
+ union all
+ select prefix_target.id as target_id, 1 as score
+ from real_estate_targets prefix_target
+ left join real_estate_regions prefix_region on prefix_region.target_id = prefix_target.id
+ left join real_estate_complexes prefix_complex on prefix_complex.target_id = prefix_target.id
+ where prefix_target.display_name like concat(:query, '%')
+ or prefix_target.normalized_name like concat(:normalizedQuery, '%')
+ or prefix_target.slug like concat(:normalizedQuery, '%')
+ or prefix_target.id like concat(:normalizedQuery, '%')
+ or prefix_region.legal_dong_code like concat(:rawQuery, '%')
+ or prefix_complex.legal_dong_code like concat(:rawQuery, '%')
+ union all
+ select prefix_alias.target_id as target_id, 1 as score
+ from real_estate_aliases prefix_alias
+ where prefix_alias.review_state = 'approved'
+ and prefix_alias.ambiguous = false
+ and (
+ prefix_alias.alias like concat(:query, '%')
+ or prefix_alias.normalized_alias like concat(:aliasQuery, '%')
+ )
+ union all
+ select contains_target.id as target_id, 2 as score
+ from real_estate_targets contains_target
+ left join real_estate_regions contains_region on contains_region.target_id = contains_target.id
+ left join real_estate_complexes contains_complex on contains_complex.target_id = contains_target.id
+ where contains_target.display_name like concat('%', :query, '%')
+ or contains_target.normalized_name like concat('%', :normalizedQuery, '%')
+ or contains_target.slug like concat('%', :normalizedQuery, '%')
+ or contains_target.id like concat('%', :normalizedQuery, '%')
+ or contains_region.legal_dong_code = :rawQuery
+ or contains_complex.legal_dong_code = :rawQuery
+ union all
+ select contains_alias.target_id as target_id, 2 as score
+ from real_estate_aliases contains_alias
+ where contains_alias.review_state = 'approved'
+ and contains_alias.ambiguous = false
+ and (
+ contains_alias.alias like concat('%', :query, '%')
+ or contains_alias.normalized_alias like concat('%', :aliasQuery, '%')
+ )
+ ) matched
+ group by matched.target_id
+ ) ranked
+ join real_estate_targets target on target.id = ranked.target_id
+ left join real_estate_regions region on region.target_id = target.id
+ left join real_estate_complexes complex on complex.target_id = target.id
+ left join real_estate_regions parent_region on parent_region.target_id = complex.region_target_id
+ order by
+ ranked.score asc,
+ case
+ when target.target_type = 'region' then 0
+ when target.target_type = 'complex' then 1
+ else 2
+ end asc,
+ case
+ when target.data_status = 'ok' then 0
+ when target.data_status = 'partial' then 1
+ when target.data_status = 'candidate' then 2
+ when target.data_status = 'mock' then 3
+ else 4
+ end asc,
+ target.display_name asc
+ """, nativeQuery = true)
+ List searchTargetsOptimized(
+ @Param("query") String query,
+ @Param("normalizedQuery") String normalizedQuery,
+ @Param("aliasQuery") String aliasQuery,
+ @Param("rawQuery") String rawQuery,
+ Pageable pageable
+ );
+
+ interface RealEstateTargetSearchRow {
+ String getTargetId();
+
+ String getTargetType();
+
+ String getDisplayName();
+
+ String getSlug();
+
+ String getReviewState();
+
+ String getDataStatus();
+
+ String getRegionLevel();
+
+ String getParentTargetId();
+
+ String getLegalDongCode();
+
+ String getRegionCode();
+ }
}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetService.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetService.java
index 24dc5c2..2b2df0e 100644
--- a/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetService.java
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/RealEstateTargetService.java
@@ -11,15 +11,30 @@
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
+import java.util.Set;
@Service
public class RealEstateTargetService {
+ private static final List MOLIT_MARKET_DATASETS = List.of(
+ "molit_apt_trade",
+ "molit_apt_rent",
+ "molit_offi_trade",
+ "molit_offi_rent",
+ "molit_rh_trade",
+ "molit_rh_rent",
+ "molit_sh_trade",
+ "molit_sh_rent",
+ "molit_silv_trade"
+ );
+
private final RealEstateRegionRepository regionRepository;
private final RealEstateTargetRepository targetRepository;
private final RealEstateMarketDataTargetRepository marketDataTargetRepository;
@@ -36,18 +51,44 @@ public RealEstateTargetService(
@Transactional(readOnly = true)
public List search(String query, int limit) {
- int boundedLimit = Math.max(1, Math.min(limit, 100));
+ return search(query, limit, false);
+ }
+
+ @Transactional(readOnly = true)
+ public List search(String query, int limit, boolean autocomplete) {
String rawQuery = trimToNull(query);
+ if (rawQuery == null) {
+ return List.of();
+ }
String lowerQuery = rawQuery == null ? null : rawQuery.toLowerCase(Locale.ROOT);
String normalizedQuery = lowerQuery == null ? null : lowerQuery.replace(" ", "");
String aliasQuery = rawQuery == null ? null : RealEstateAlias.normalizeAlias(rawQuery);
- return targetRepository.searchTargets(
- lowerQuery,
- normalizedQuery,
- aliasQuery,
+ if (autocomplete && (aliasQuery == null || aliasQuery.length() < 2)) {
+ return List.of();
+ }
+
+ int maxLimit = autocomplete ? 8 : 100;
+ int boundedLimit = Math.max(1, Math.min(limit, maxLimit));
+ List indexedRows = searchTargetsIndexed(
rawQuery,
- PageRequest.of(0, boundedLimit)
+ boundedLimit
);
+ if (autocomplete) {
+ indexedRows = appendAutocompleteTokenRows(rawQuery, indexedRows, boundedLimit);
+ }
+ List rows = autocomplete || indexedRows.size() >= boundedLimit
+ ? indexedRows
+ : targetRepository.searchTargetsOptimized(
+ lowerQuery,
+ normalizedQuery,
+ aliasQuery,
+ rawQuery,
+ PageRequest.of(0, boundedLimit)
+ );
+
+ return rows.stream()
+ .map(RealEstateTargetService::toResponse)
+ .toList();
}
@Transactional(readOnly = true)
@@ -77,6 +118,40 @@ public List regions(String regionLevel, int limit, int
);
}
+ private List searchTargetsIndexed(
+ String rawQuery,
+ int boundedLimit
+ ) {
+ String lowerQuery = rawQuery.toLowerCase(Locale.ROOT);
+ String normalizedQuery = lowerQuery.replace(" ", "");
+ String aliasQuery = RealEstateAlias.normalizeAlias(rawQuery);
+ return targetRepository.searchTargetsIndexed(
+ normalizedQuery,
+ aliasQuery,
+ rawQuery,
+ PageRequest.of(0, boundedLimit)
+ );
+ }
+
+ private List appendAutocompleteTokenRows(
+ String rawQuery,
+ List indexedRows,
+ int boundedLimit
+ ) {
+ if (indexedRows.size() >= boundedLimit) {
+ return indexedRows;
+ }
+ String token = lastSearchToken(rawQuery);
+ if (token == null) {
+ return indexedRows;
+ }
+ List tokenRows = searchTargetsIndexed(token, boundedLimit);
+ if (tokenRows.isEmpty()) {
+ return indexedRows;
+ }
+ return mergeSearchRows(indexedRows, tokenRows, boundedLimit);
+ }
+
@Transactional
public RealEstateTargetUpsertBatchResponse upsertTargets(Collection requests) {
List items = requests == null
@@ -188,6 +263,47 @@ private static String trimToNull(String value) {
return value.trim();
}
+ private static String lastSearchToken(String value) {
+ String trimmed = trimToNull(value);
+ if (trimmed == null || !trimmed.matches(".*\\s+.*")) {
+ return null;
+ }
+ String[] tokens = trimmed.split("\\s+");
+ String token = trimToNull(tokens[tokens.length - 1]);
+ if (token == null || token.equals(trimmed) || RealEstateAlias.normalizeAlias(token).length() < 2) {
+ return null;
+ }
+ return token;
+ }
+
+ private static List mergeSearchRows(
+ List primaryRows,
+ List fallbackRows,
+ int limit
+ ) {
+ List merged = new ArrayList<>(limit);
+ Set seenTargetIds = new HashSet<>();
+ appendSearchRows(primaryRows, merged, seenTargetIds, limit);
+ appendSearchRows(fallbackRows, merged, seenTargetIds, limit);
+ return merged;
+ }
+
+ private static void appendSearchRows(
+ List sourceRows,
+ List targetRows,
+ Set seenTargetIds,
+ int limit
+ ) {
+ for (RealEstateTargetRepository.RealEstateTargetSearchRow row : sourceRows) {
+ if (targetRows.size() >= limit) {
+ return;
+ }
+ if (seenTargetIds.add(row.getTargetId())) {
+ targetRows.add(row);
+ }
+ }
+ }
+
private static String normalizeNullable(String value) {
String trimmed = trimToNull(value);
return trimmed == null ? null : trimmed.toLowerCase(Locale.ROOT);
@@ -195,7 +311,7 @@ private static String normalizeNullable(String value) {
private int ensureMolitMarketDataTargets(String targetId, String lawdCode, Instant now) {
int ensured = 0;
- for (String dataset : List.of("molit_apt_trade", "molit_apt_rent")) {
+ for (String dataset : MOLIT_MARKET_DATASETS) {
RealEstateMarketDataTarget target = marketDataTargetRepository
.findByTargetIdAndProviderAndProviderDatasetAndLawdCode(targetId, "molit", dataset, lawdCode)
.orElseGet(() -> new RealEstateMarketDataTarget(
@@ -240,4 +356,19 @@ private static String defaultIfBlank(String value, String fallback) {
String trimmed = trimToNull(value);
return trimmed == null ? fallback : trimmed;
}
+
+ private static RealEstateTargetResponse toResponse(RealEstateTargetRepository.RealEstateTargetSearchRow row) {
+ return new RealEstateTargetResponse(
+ row.getTargetId(),
+ row.getTargetType(),
+ row.getDisplayName(),
+ row.getSlug(),
+ row.getReviewState(),
+ row.getDataStatus(),
+ row.getRegionLevel(),
+ row.getParentTargetId(),
+ row.getLegalDongCode(),
+ row.getRegionCode()
+ );
+ }
}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTarget.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTarget.java
new file mode 100644
index 0000000..f00e740
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTarget.java
@@ -0,0 +1,118 @@
+package com.youbuyfirst.backend.realestate;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+
+import java.time.Instant;
+import java.util.UUID;
+
+@Entity
+@Table(name = "user_watch_targets")
+public class UserWatchTarget {
+
+ @Id
+ @Column(length = 36)
+ private String id;
+
+ @Column(name = "user_id", nullable = false, length = 36)
+ private String userId;
+
+ @Column(name = "target_type", nullable = false, length = 30)
+ private String targetType;
+
+ @Column(name = "target_id", nullable = false, length = 160)
+ private String targetId;
+
+ @Column(name = "display_name", nullable = false, length = 120)
+ private String displayName;
+
+ @Column(name = "landing_path", nullable = false, length = 600)
+ private String landingPath;
+
+ @Column(name = "created_at", nullable = false)
+ private Instant createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt;
+
+ protected UserWatchTarget() {
+ }
+
+ private UserWatchTarget(
+ String id,
+ String userId,
+ String targetType,
+ String targetId,
+ String displayName,
+ String landingPath,
+ Instant now
+ ) {
+ this.id = id;
+ this.userId = userId;
+ this.targetType = targetType;
+ this.targetId = targetId;
+ this.displayName = displayName;
+ this.landingPath = landingPath;
+ this.createdAt = now;
+ this.updatedAt = now;
+ }
+
+ public static UserWatchTarget create(
+ String userId,
+ String targetType,
+ String targetId,
+ String displayName,
+ String landingPath,
+ Instant now
+ ) {
+ return new UserWatchTarget(
+ UUID.randomUUID().toString(),
+ userId,
+ targetType,
+ targetId,
+ displayName,
+ landingPath,
+ now
+ );
+ }
+
+ public void update(String displayName, String landingPath, Instant now) {
+ this.displayName = displayName;
+ this.landingPath = landingPath;
+ this.updatedAt = now;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getUserId() {
+ return userId;
+ }
+
+ public String getTargetType() {
+ return targetType;
+ }
+
+ public String getTargetId() {
+ return targetId;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public String getLandingPath() {
+ return landingPath;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetController.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetController.java
new file mode 100644
index 0000000..339de3f
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetController.java
@@ -0,0 +1,66 @@
+package com.youbuyfirst.backend.realestate;
+
+import com.youbuyfirst.backend.auth.AppUserPrincipal;
+import com.youbuyfirst.backend.realestate.dto.UserWatchTargetListResponse;
+import com.youbuyfirst.backend.realestate.dto.UserWatchTargetRequest;
+import com.youbuyfirst.backend.realestate.dto.UserWatchTargetResponse;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.Authentication;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.server.ResponseStatusException;
+
+@RestController
+@RequestMapping("/api/realestate/watch-targets")
+public class UserWatchTargetController {
+
+ private final UserWatchTargetService service;
+
+ public UserWatchTargetController(UserWatchTargetService service) {
+ this.service = service;
+ }
+
+ @GetMapping
+ public UserWatchTargetListResponse list(Authentication authentication) {
+ return new UserWatchTargetListResponse(
+ service.list(currentUserId(authentication)).stream()
+ .map(UserWatchTargetResponse::from)
+ .toList()
+ );
+ }
+
+ @PostMapping
+ public ResponseEntity save(
+ Authentication authentication,
+ @Valid @RequestBody UserWatchTargetRequest request
+ ) {
+ UserWatchTargetService.SaveResult result = service.save(currentUserId(authentication), request);
+ return ResponseEntity
+ .status(result.created() ? HttpStatus.CREATED : HttpStatus.OK)
+ .body(UserWatchTargetResponse.from(result.target()));
+ }
+
+ @DeleteMapping
+ public ResponseEntity remove(
+ Authentication authentication,
+ @RequestParam String targetType,
+ @RequestParam String targetId
+ ) {
+ service.remove(currentUserId(authentication), targetType, targetId);
+ return ResponseEntity.noContent().build();
+ }
+
+ private static String currentUserId(Authentication authentication) {
+ if (authentication == null || !(authentication.getPrincipal() instanceof AppUserPrincipal principal)) {
+ throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User session is required");
+ }
+ return principal.getUserId();
+ }
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetRepository.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetRepository.java
new file mode 100644
index 0000000..8506053
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetRepository.java
@@ -0,0 +1,15 @@
+package com.youbuyfirst.backend.realestate;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface UserWatchTargetRepository extends JpaRepository {
+
+ List findByUserIdOrderByUpdatedAtDescCreatedAtDesc(String userId);
+
+ Optional findByUserIdAndTargetTypeAndTargetId(String userId, String targetType, String targetId);
+
+ void deleteByUserIdAndTargetTypeAndTargetId(String userId, String targetType, String targetId);
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetService.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetService.java
new file mode 100644
index 0000000..92987fc
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/UserWatchTargetService.java
@@ -0,0 +1,88 @@
+package com.youbuyfirst.backend.realestate;
+
+import com.youbuyfirst.backend.realestate.dto.UserWatchTargetRequest;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.server.ResponseStatusException;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+
+@Service
+public class UserWatchTargetService {
+
+ private static final Set ALLOWED_TARGET_TYPES = Set.of("region", "complex");
+
+ private final UserWatchTargetRepository repository;
+
+ public UserWatchTargetService(UserWatchTargetRepository repository) {
+ this.repository = repository;
+ }
+
+ @Transactional(readOnly = true)
+ public List list(String userId) {
+ return repository.findByUserIdOrderByUpdatedAtDescCreatedAtDesc(userId);
+ }
+
+ @Transactional
+ public SaveResult save(String userId, UserWatchTargetRequest request) {
+ String targetType = normalizeTargetType(request.targetType());
+ String targetId = normalizeRequired(request.targetId(), "targetId");
+ String displayName = normalizeRequired(request.displayName(), "displayName");
+ String landingPath = normalizeLandingPath(request.landingPath());
+ Instant now = Instant.now();
+
+ return repository.findByUserIdAndTargetTypeAndTargetId(userId, targetType, targetId)
+ .map(existing -> {
+ existing.update(displayName, landingPath, now);
+ return new SaveResult(repository.save(existing), false);
+ })
+ .orElseGet(() -> new SaveResult(repository.save(UserWatchTarget.create(
+ userId,
+ targetType,
+ targetId,
+ displayName,
+ landingPath,
+ now
+ )), true));
+ }
+
+ @Transactional
+ public void remove(String userId, String targetType, String targetId) {
+ repository.deleteByUserIdAndTargetTypeAndTargetId(
+ userId,
+ normalizeTargetType(targetType),
+ normalizeRequired(targetId, "targetId")
+ );
+ }
+
+ private static String normalizeTargetType(String value) {
+ String normalized = normalizeRequired(value, "targetType").toLowerCase(Locale.ROOT);
+ if (!ALLOWED_TARGET_TYPES.contains(normalized)) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported watch target type");
+ }
+ return normalized;
+ }
+
+ private static String normalizeLandingPath(String value) {
+ String normalized = normalizeRequired(value, "landingPath");
+ if (!normalized.startsWith("/realestate/") || normalized.startsWith("//")) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Watch target landing path must be a real estate app path");
+ }
+ return normalized;
+ }
+
+ private static String normalizeRequired(String value, String fieldName) {
+ String normalized = value == null ? "" : value.trim();
+ if (normalized.isEmpty()) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, fieldName + " is required");
+ }
+ return normalized;
+ }
+
+ public record SaveResult(UserWatchTarget target, boolean created) {
+ }
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/MarketDataSourceLinkResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/MarketDataSourceLinkResponse.java
index e83cbc1..162e0c4 100644
--- a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/MarketDataSourceLinkResponse.java
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/MarketDataSourceLinkResponse.java
@@ -9,6 +9,7 @@ public record MarketDataSourceLinkResponse(
String link,
String provider,
String status,
+ String dataStatus,
boolean stale,
Instant lastCheckedAt
) {
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchRequest.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchRequest.java
new file mode 100644
index 0000000..5bb1c6e
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchRequest.java
@@ -0,0 +1,11 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotNull;
+
+import java.util.List;
+
+public record RealEstateRegionalReportBatchRequest(
+ @NotNull List<@Valid RealEstateRegionalReportRequest> reports
+) {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchResponse.java
new file mode 100644
index 0000000..8963060
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportBatchResponse.java
@@ -0,0 +1,7 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+public record RealEstateRegionalReportBatchResponse(
+ int acceptedReports,
+ int acceptedSources
+) {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportRequest.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportRequest.java
new file mode 100644
index 0000000..80a5b11
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportRequest.java
@@ -0,0 +1,30 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+
+import java.time.Instant;
+import java.util.List;
+
+public record RealEstateRegionalReportRequest(
+ @NotBlank @Size(max = 160) String reportId,
+ @NotBlank @Size(max = 120) String targetId,
+ @NotBlank @Size(max = 80) String reportVersion,
+ @Size(max = 80) String promptVersion,
+ @Size(max = 80) String modelName,
+ @NotBlank @Size(max = 80) String generatedBy,
+ @NotBlank @Size(max = 200) String title,
+ @NotBlank @Size(max = 300) String headline,
+ @NotBlank String summary,
+ @NotBlank String body,
+ @NotNull List<@Size(max = 240) String> expectationPoints,
+ @NotNull List<@Size(max = 240) String> concernPoints,
+ @NotBlank @Size(max = 30) String dataQuality,
+ Double confidence,
+ @NotNull Instant asOf,
+ @NotNull Instant publishedAt,
+ @NotNull List<@Valid RealEstateRegionalReportSourceRequest> sources
+) {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportResponse.java
new file mode 100644
index 0000000..797c31a
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportResponse.java
@@ -0,0 +1,28 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+import java.time.Instant;
+import java.util.List;
+
+public record RealEstateRegionalReportResponse(
+ String reportId,
+ String targetId,
+ String targetName,
+ String regionLevel,
+ String regionCode,
+ String reportVersion,
+ String promptVersion,
+ String modelName,
+ String generatedBy,
+ String title,
+ String headline,
+ String summary,
+ String body,
+ List expectationPoints,
+ List concernPoints,
+ String dataQuality,
+ Double confidence,
+ Instant asOf,
+ Instant publishedAt,
+ List sources
+) {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceRequest.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceRequest.java
new file mode 100644
index 0000000..0ab3d72
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceRequest.java
@@ -0,0 +1,19 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+import java.time.Instant;
+
+public record RealEstateRegionalReportSourceRequest(
+ @Size(max = 160) String reportSourceId,
+ @NotBlank @Size(max = 60) String refType,
+ @Size(max = 160) String refId,
+ @NotBlank @Size(max = 120) String label,
+ @NotBlank @Size(max = 240) String title,
+ String url,
+ @Size(max = 160) String sourceName,
+ Instant publishedAt,
+ @NotBlank @Size(max = 30) String dataStatus
+) {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceResponse.java
new file mode 100644
index 0000000..57336c0
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/RealEstateRegionalReportSourceResponse.java
@@ -0,0 +1,17 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+import java.time.Instant;
+
+public record RealEstateRegionalReportSourceResponse(
+ String reportSourceId,
+ int displayOrder,
+ String refType,
+ String refId,
+ String label,
+ String title,
+ String url,
+ String sourceName,
+ Instant publishedAt,
+ String dataStatus
+) {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetListResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetListResponse.java
new file mode 100644
index 0000000..2f78705
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetListResponse.java
@@ -0,0 +1,6 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+import java.util.List;
+
+public record UserWatchTargetListResponse(List items) {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetRequest.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetRequest.java
new file mode 100644
index 0000000..bb0e434
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetRequest.java
@@ -0,0 +1,23 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+
+public record UserWatchTargetRequest(
+ @NotBlank
+ @Size(max = 30)
+ String targetType,
+
+ @NotBlank
+ @Size(max = 160)
+ String targetId,
+
+ @NotBlank
+ @Size(max = 120)
+ String displayName,
+
+ @NotBlank
+ @Size(max = 600)
+ String landingPath
+) {
+}
diff --git a/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetResponse.java b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetResponse.java
new file mode 100644
index 0000000..e963b41
--- /dev/null
+++ b/backend/src/main/java/com/youbuyfirst/backend/realestate/dto/UserWatchTargetResponse.java
@@ -0,0 +1,27 @@
+package com.youbuyfirst.backend.realestate.dto;
+
+import com.youbuyfirst.backend.realestate.UserWatchTarget;
+
+import java.time.Instant;
+
+public record UserWatchTargetResponse(
+ String watchId,
+ String targetType,
+ String targetId,
+ String displayName,
+ String landingPath,
+ Instant createdAt,
+ Instant updatedAt
+) {
+ public static UserWatchTargetResponse from(UserWatchTarget target) {
+ return new UserWatchTargetResponse(
+ target.getId(),
+ target.getTargetType(),
+ target.getTargetId(),
+ target.getDisplayName(),
+ target.getLandingPath(),
+ target.getCreatedAt(),
+ target.getUpdatedAt()
+ );
+ }
+}
diff --git a/backend/src/main/resources/application-local.example.yml b/backend/src/main/resources/application-local.example.yml
index 7eb4b9a..f7e028f 100644
--- a/backend/src/main/resources/application-local.example.yml
+++ b/backend/src/main/resources/application-local.example.yml
@@ -22,3 +22,21 @@ app:
# Embeddings use the Gemini-compatible GMS path, not the OpenAI-compatible path.
gemini-base-url: "https://gms.ssafy.io/gmsapi/generativelanguage.googleapis.com"
embedding-model: "gemini-embedding-2"
+ auth:
+ oauth:
+ success-redirect: "http://127.0.0.1:5173/dashboard"
+ failure-redirect: "http://127.0.0.1:5173/auth/login?oauth=failed"
+ # Optional: use environment variables instead in local runs.
+ # Redirect URI: http://127.0.0.1:8080/login/oauth2/code/{provider}
+ # Public tunnel proxy demo: set redirect-base-url to the public frontend origin.
+ redirect-base-url: ""
+ google:
+ client-id: ""
+ client-secret: ""
+ naver:
+ client-id: ""
+ client-secret: ""
+ kakao:
+ client-id: ""
+ # Kakao client secret is optional unless enabled in the Kakao app settings.
+ client-secret: ""
diff --git a/backend/src/main/resources/db/migration/V50__real_estate_regional_reports.sql b/backend/src/main/resources/db/migration/V50__real_estate_regional_reports.sql
new file mode 100644
index 0000000..a261ba1
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V50__real_estate_regional_reports.sql
@@ -0,0 +1,161 @@
+create table real_estate_regional_reports (
+ target_id varchar(120) not null,
+ report_id varchar(160) not null,
+ report_version varchar(80) not null,
+ prompt_version varchar(80),
+ model_name varchar(80),
+ generated_by varchar(80) not null,
+ title varchar(200) not null,
+ headline varchar(300) not null,
+ summary text not null,
+ body text not null,
+ expectation_points_json text not null,
+ concern_points_json text not null,
+ data_quality varchar(30) not null,
+ confidence double,
+ as_of datetime(6) not null,
+ published_at datetime(6) not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6) not null,
+ primary key (target_id),
+ constraint uk_real_estate_regional_reports_report_id unique (report_id),
+ constraint fk_real_estate_regional_reports_target foreign key (target_id) references real_estate_targets (id)
+);
+
+create table real_estate_regional_report_sources (
+ id varchar(160) not null,
+ report_target_id varchar(120) not null,
+ display_order int not null,
+ ref_type varchar(60) not null,
+ ref_id varchar(160),
+ label varchar(120) not null,
+ title varchar(240) not null,
+ url text,
+ source_name varchar(160),
+ published_at datetime(6),
+ data_status varchar(30) not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6) not null,
+ primary key (id),
+ constraint fk_real_estate_regional_report_sources_report
+ foreign key (report_target_id) references real_estate_regional_reports (target_id)
+);
+
+create index idx_real_estate_regional_reports_as_of on real_estate_regional_reports (as_of);
+create index idx_real_estate_regional_report_sources_report on real_estate_regional_report_sources (report_target_id, display_order);
+create index idx_real_estate_regional_report_sources_ref on real_estate_regional_report_sources (ref_type, ref_id);
+
+insert into real_estate_regional_reports (
+ target_id,
+ report_id,
+ report_version,
+ prompt_version,
+ model_name,
+ generated_by,
+ title,
+ headline,
+ summary,
+ body,
+ expectation_points_json,
+ concern_points_json,
+ data_quality,
+ confidence,
+ as_of,
+ published_at,
+ created_at,
+ updated_at
+)
+select
+ region.target_id,
+ concat('regional-report-', region.target_id, '-latest'),
+ 'regional-report-seed-v1',
+ 'regional-report-template-v1',
+ null,
+ 'seed:regional-report-v1',
+ concat(target.display_name, ' 최신 지역 리포트'),
+ concat(target.display_name, '은 공식 가격지표, 실거래·전세, 공급·정책 근거를 한 번에 확인해야 하는 관찰 지역입니다.'),
+ concat(target.display_name, ' 최신 리포트는 지도 기간 선택과 별도로 저장되는 최신 기준 브리핑입니다. 공식 가격지표, 실거래·전세 원천, 정책·공급 일정, 뉴스·리포트 근거가 같은 target_id로 연결되면 AI API가 이후 이 본문을 자동 갱신할 수 있습니다.'),
+ concat(
+ target.display_name,
+ '은 단기 등락 하나로 판단하기보다 공식 가격지표와 실거래·전세 흐름, 공급·정책 일정, 최근 뉴스·리포트 근거를 함께 봐야 하는 지역입니다. ',
+ '기대 지점은 데이터가 같은 지역 target에 쌓이면 가격 흐름과 이슈 맥락을 빠르게 비교할 수 있다는 점입니다. ',
+ '우려 지점은 승인되지 않은 뉴스 후보나 부족한 실거래 표본이 결론처럼 읽힐 수 있다는 점입니다. ',
+ '지도 기간별 등락은 보조 수치로만 사용하고, 이 종합 리포트는 최신 근거 묶음이 갱신될 때만 새로 적재합니다.'
+ ),
+ '["공식 가격지표와 지도 계층이 같은 target_id로 연결됩니다.","뉴스·리포트·정책 근거가 들어오면 최신 리포트를 자동 갱신할 수 있습니다.","시장 사실과 근거 링크를 분리 저장해 AI 재작성에 바로 사용할 수 있습니다."]',
+ '["지역별 뉴스·정책 원문은 후보와 승인 상태를 분리 확인해야 합니다.","실거래·전세·공급 데이터가 부족한 지역은 판단을 보수적으로 유지합니다.","지도 기간별 등락은 본문 판단을 바꾸지 않고 보조 수치로만 봅니다."]',
+ 'partial',
+ 0.45,
+ '2026-06-24 00:00:00',
+ '2026-06-24 00:00:00',
+ '2026-06-24 00:00:00',
+ '2026-06-24 00:00:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+where region.region_level in ('sido', 'sigungu');
+
+insert into real_estate_regional_report_sources (
+ id,
+ report_target_id,
+ display_order,
+ ref_type,
+ ref_id,
+ label,
+ title,
+ url,
+ source_name,
+ published_at,
+ data_status,
+ created_at,
+ updated_at
+)
+select
+ concat('regional-report-source-', region.target_id, '-molit-rt'),
+ region.target_id,
+ 1,
+ 'external',
+ 'molit-real-transaction-disclosure',
+ '실거래 근거',
+ '국토교통부 실거래가 공개시스템',
+ 'https://rt.molit.go.kr/',
+ '국토교통부',
+ null,
+ 'ok',
+ '2026-06-24 00:00:00',
+ '2026-06-24 00:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu')
+union all
+select
+ concat('regional-report-source-', region.target_id, '-reb-rone'),
+ region.target_id,
+ 2,
+ 'external',
+ 'reb-rone-price-statistics',
+ '가격지표 근거',
+ '한국부동산원 R-ONE 부동산통계정보시스템',
+ 'https://www.reb.or.kr/r-one/portal/main/indexPage.do',
+ '한국부동산원',
+ null,
+ 'ok',
+ '2026-06-24 00:00:00',
+ '2026-06-24 00:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu')
+union all
+select
+ concat('regional-report-source-', region.target_id, '-data-go-kr-apt-trade'),
+ region.target_id,
+ 3,
+ 'external',
+ 'data-go-kr-molit-apt-trade',
+ '공공 API 근거',
+ '공공데이터포털 국토교통부 아파트 매매 실거래가 자료',
+ 'https://www.data.go.kr/data/15126469/openapi.do',
+ '공공데이터포털',
+ null,
+ 'ok',
+ '2026-06-24 00:00:00',
+ '2026-06-24 00:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu');
diff --git a/backend/src/main/resources/db/migration/V51__real_estate_extended_molit_transaction_datasets.sql b/backend/src/main/resources/db/migration/V51__real_estate_extended_molit_transaction_datasets.sql
new file mode 100644
index 0000000..41591bc
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V51__real_estate_extended_molit_transaction_datasets.sql
@@ -0,0 +1,74 @@
+insert into real_estate_public_data_datasets (
+ dataset_id, provider, provider_name, owner_org, display_name, fact_type, access_method, response_format,
+ source_url, endpoint_url, target_granularity, date_granularity, refresh_interval, stale_after_hours,
+ priority, backfill_required, enabled_for_backfill, source_row_count, notes, created_at, updated_at
+) values
+ (
+ 'molit_offi_trade', 'molit', '국토교통부', '국토교통부', '오피스텔 매매 실거래가', 'offi_trade', 'openapi', 'xml',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcOffiTrade/getRTMSDataSvcOffiTrade',
+ 'sigungu', 'month', 'daily-check', 72,
+ 21, true, true, null, 'LAWD_CD and DEAL_YMD based officetel sale transactions.', now(6), now(6)
+ ),
+ (
+ 'molit_offi_rent', 'molit', '국토교통부', '국토교통부', '오피스텔 전월세 실거래가', 'offi_rent', 'openapi', 'xml',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcOffiRent/getRTMSDataSvcOffiRent',
+ 'sigungu', 'month', 'daily-check', 72,
+ 22, true, true, null, 'LAWD_CD and DEAL_YMD based officetel rent transactions.', now(6), now(6)
+ ),
+ (
+ 'molit_rh_trade', 'molit', '국토교통부', '국토교통부', '연립다세대 매매 실거래가', 'rh_trade', 'openapi', 'xml',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcRHTrade/getRTMSDataSvcRHTrade',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcRHTrade/getRTMSDataSvcRHTrade',
+ 'sigungu', 'month', 'daily-check', 72,
+ 23, true, true, null, 'LAWD_CD and DEAL_YMD based rowhouse/multifamily sale transactions.', now(6), now(6)
+ ),
+ (
+ 'molit_rh_rent', 'molit', '국토교통부', '국토교통부', '연립다세대 전월세 실거래가', 'rh_rent', 'openapi', 'xml',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcRHRent/getRTMSDataSvcRHRent',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcRHRent/getRTMSDataSvcRHRent',
+ 'sigungu', 'month', 'daily-check', 72,
+ 24, true, true, null, 'LAWD_CD and DEAL_YMD based rowhouse/multifamily rent transactions.', now(6), now(6)
+ ),
+ (
+ 'molit_sh_trade', 'molit', '국토교통부', '국토교통부', '단독다가구 매매 실거래가', 'sh_trade', 'openapi', 'xml',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcSHTrade/getRTMSDataSvcSHTrade',
+ 'sigungu', 'month', 'daily-check', 72,
+ 25, true, true, null, 'LAWD_CD and DEAL_YMD based single/multifamily house sale transactions.', now(6), now(6)
+ ),
+ (
+ 'molit_sh_rent', 'molit', '국토교통부', '국토교통부', '단독다가구 전월세 실거래가', 'sh_rent', 'openapi', 'xml',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcSHRent/getRTMSDataSvcSHRent',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcSHRent/getRTMSDataSvcSHRent',
+ 'sigungu', 'month', 'daily-check', 72,
+ 26, true, true, null, 'LAWD_CD and DEAL_YMD based single/multifamily house rent transactions.', now(6), now(6)
+ ),
+ (
+ 'molit_silv_trade', 'molit', '국토교통부', '국토교통부', '분양권 매매 실거래가', 'silv_trade', 'openapi', 'xml',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcSilvTrade/getRTMSDataSvcSilvTrade',
+ 'https://apis.data.go.kr/1613000/RTMSDataSvcSilvTrade/getRTMSDataSvcSilvTrade',
+ 'sigungu', 'month', 'daily-check', 72,
+ 27, true, true, null, 'LAWD_CD and DEAL_YMD based presale-right sale transactions.', now(6), now(6)
+ )
+on duplicate key update
+ provider = values(provider),
+ provider_name = values(provider_name),
+ owner_org = values(owner_org),
+ display_name = values(display_name),
+ fact_type = values(fact_type),
+ access_method = values(access_method),
+ response_format = values(response_format),
+ source_url = values(source_url),
+ endpoint_url = values(endpoint_url),
+ target_granularity = values(target_granularity),
+ date_granularity = values(date_granularity),
+ refresh_interval = values(refresh_interval),
+ stale_after_hours = values(stale_after_hours),
+ priority = values(priority),
+ backfill_required = values(backfill_required),
+ enabled_for_backfill = values(enabled_for_backfill),
+ source_row_count = values(source_row_count),
+ notes = values(notes),
+ updated_at = now(6);
diff --git a/backend/src/main/resources/db/migration/V52__real_estate_regional_report_research_pack.sql b/backend/src/main/resources/db/migration/V52__real_estate_regional_report_research_pack.sql
new file mode 100644
index 0000000..232033d
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V52__real_estate_regional_report_research_pack.sql
@@ -0,0 +1,294 @@
+insert into real_estate_regional_reports (
+ target_id,
+ report_id,
+ report_version,
+ prompt_version,
+ model_name,
+ generated_by,
+ title,
+ headline,
+ summary,
+ body,
+ expectation_points_json,
+ concern_points_json,
+ data_quality,
+ confidence,
+ as_of,
+ published_at,
+ created_at,
+ updated_at
+)
+select
+ researched.target_id,
+ concat('regional-report-', researched.target_id, '-codex-20260624'),
+ 'regional-report-research-v1',
+ 'codex-regional-report-prompt-20260624',
+ 'codex-gpt-5-research',
+ 'codex-research:regional-report-20260624',
+ concat(researched.display_name, ' 최신 지역 리포트'),
+ concat(researched.display_name, '는 ', researched.market_role, '로 읽어야 하는 관찰 지역입니다.'),
+ concat(
+ researched.display_name,
+ '는 R-ONE 최신 가격지표, 국토교통부 실거래·전월세 원천, 공급·정책 보도자료를 함께 놓고 봐야 합니다. ',
+ researched.local_issue
+ ),
+ concat(
+ researched.display_name, '는 ', researched.region_scope, '입니다. ',
+ researched.macro_context, ' ',
+ '최근 확인한 공통 근거는 한국부동산원 R-ONE의 2026년 5월 전국주택가격동향, 2026년 6월 15일 기준 주간아파트가격 동향, 국토교통부 실거래가 공개시스템과 공공데이터포털의 매매·전월세 API입니다. ',
+ 'R-ONE 메인 지표는 2026년 5월 아파트 매매가격지수 변동률 0.25%, 전세가격지수 변동률 0.45%, 2026년 4월 아파트 매매거래호수 53,177호를 함께 보여줍니다. ',
+ researched.local_issue, ' ',
+ '따라서 기대 지점은 ', researched.expectation_theme, '에 있습니다. 우려 지점은 ', researched.concern_theme, '입니다. ',
+ '지도 기간별 등락률은 색상과 숫자 판단에만 쓰고, 이 보고서는 최신 공식 통계와 정책 근거가 갱신될 때 target별 최신본 1개로 교체합니다. ',
+ '특정 매수나 매도 행동을 권하는 결론이 아니라, 실거래·전세·공급·정책·뉴스 근거가 같은 방향으로 맞물리는지 확인하는 칼럼형 관찰문입니다.'
+ ),
+ concat(
+ '["',
+ researched.display_name, '는 ', researched.expectation_theme, '가 기대 지점입니다.",',
+ '"국토교통부 실거래가와 공공데이터포털 매매·전월세 API를 같은 target 기준으로 붙이면 가격과 임대차 방향을 분리해 확인할 수 있습니다.",',
+ '"정책·공급 보도자료와 R-ONE 지표가 함께 갱신되면 이 저장 리포트를 AI API가 다시 덮어쓸 수 있습니다."]'
+ ),
+ concat(
+ '["',
+ researched.display_name, '는 ', researched.concern_theme, '를 먼저 경계해야 합니다.",',
+ '"최근 뉴스나 개발 기대만으로 가격 상승을 단정하지 않고 실거래 표본, 전세 지표, 공급 일정을 분리 확인해야 합니다.",',
+ '"지도 기간별 등락률은 리포트 본문을 바꾸는 근거가 아니라 단기 보조 신호입니다."]'
+ ),
+ 'researched',
+ case
+ when researched.region_level = 'sido' then 0.72
+ when researched.legal_dong_code is null then 0.64
+ else 0.68
+ end,
+ '2026-06-24 00:00:00',
+ '2026-06-24 22:00:00',
+ coalesce(existing.created_at, '2026-06-24 22:00:00'),
+ '2026-06-24 22:00:00'
+from (
+ select
+ region.target_id,
+ target.display_name,
+ region.region_level,
+ region.legal_dong_code,
+ case
+ when region.region_level = 'sido' then concat(target.display_name, ' 전체 권역')
+ when parent_target.display_name is not null then concat(parent_target.display_name, ' 안의 ', target.display_name, ' 생활권')
+ else concat(target.display_name, ' 생활권')
+ end as region_scope,
+ case
+ when region.region_level = 'sido' then '광역 가격·공급·정책 흐름을 묶어 읽는 상위 권역'
+ else '실거래와 전세 표본을 같은 법정동 코드 단위로 확인해야 하는 세부 권역'
+ end as market_role,
+ case
+ when target.display_name like '%서울%' or parent_target.display_name like '%서울%' then '서울권은 업무·학군·정비사업 기대와 전세 부담이 동시에 움직이는 시장입니다. 도심 접근성이 좋은 구는 거래 한 건의 가격 신호가 크게 보일 수 있어 R-ONE 지표와 실거래 원천을 함께 확인해야 합니다.'
+ when target.display_name like '%부산%' or parent_target.display_name like '%부산%' then '부산권은 해안 주거지, 원도심 정비, 항만·업무 수요가 서로 다른 속도로 움직입니다. 동부산 선호와 원도심 재생 이슈를 같은 결론으로 묶지 않고 권역별로 나눠 봐야 합니다.'
+ when target.display_name like '%대구%' or parent_target.display_name like '%대구%' then '대구권은 누적 공급 부담과 도심 재정비, 산업·교통 호재가 함께 거론되는 시장입니다. 가격 회복 신호는 전세와 미분양·입주 부담이 같이 완화되는지 확인해야 설명력이 생깁니다.'
+ when target.display_name like '%인천%' or parent_target.display_name like '%인천%' then '인천권은 공항·항만·송도·청라·검단 축의 개발 기대와 구도심 주거지가 함께 존재합니다. 신도시 입주와 원도심 정비의 속도가 달라 같은 광역시 안에서도 온도차가 큽니다.'
+ when target.display_name like '%광주%' or parent_target.display_name like '%광주%' then '광주권은 도심 정비, 첨단산단, 생활권 재편 이슈가 같이 놓입니다. 단기 매매가격보다 전세 수요와 신규 공급 흡수 속도를 같이 봐야 합니다.'
+ when target.display_name like '%대전%' or parent_target.display_name like '%대전%' then '대전권은 연구개발·공공기관 수요와 세종 연계 흐름이 시장 설명의 축입니다. 행정·연구 수요는 안정 요인이지만 공급과 전세 방향이 어긋나면 체감은 달라질 수 있습니다.'
+ when target.display_name like '%울산%' or parent_target.display_name like '%울산%' then '울산권은 자동차·조선·석유화학 등 산업 경기와 주거 수요가 밀접합니다. 고용과 산업 투자 뉴스는 기대 요인이지만 특정 업종 의존도를 함께 경계해야 합니다.'
+ when target.display_name like '%세종%' or parent_target.display_name like '%세종%' then '세종권은 행정수도 기능, 이전 수요, 누적 입주 물량이 동시에 작동합니다. 정책 기대가 크더라도 실제 거래·전세 회복이 동반되는지 확인해야 합니다.'
+ when target.display_name like '%경기%' or parent_target.display_name like '%경기도%' then '경기권은 GTX·광역철도, 반도체·바이오 산업축, 3기 신도시 공급이 생활권별로 다른 영향을 줍니다. 서울 접근성과 자체 일자리 수요를 분리해야 지역 판단이 흔들리지 않습니다.'
+ when target.display_name like '%강원%' or parent_target.display_name like '%강원%' then '강원권은 관광·생활인구, 혁신도시·기업도시, 광역교통 기대가 함께 작동합니다. 실수요 기반의 상주 수요와 휴양·투자성 수요를 구분하는 것이 중요합니다.'
+ when target.display_name like '%충청북%' or target.display_name like '%충북%' or parent_target.display_name like '%충청북%' or parent_target.display_name like '%충북%' then '충북권은 청주·오송·오창의 바이오·반도체·철도 접근성이 핵심입니다. 산업 뉴스가 주거 수요로 이어지는 구간과 농촌형 생활권을 분리해야 합니다.'
+ when target.display_name like '%충청남%' or target.display_name like '%충남%' or parent_target.display_name like '%충청남%' or parent_target.display_name like '%충남%' then '충남권은 천안·아산의 수도권 연계, 서해안 산업벨트, 내포신도시 흐름이 함께 놓입니다. 산업 배후 수요와 신규 택지 공급을 같이 봐야 합니다.'
+ when target.display_name like '%전라북%' or target.display_name like '%전북%' or parent_target.display_name like '%전라북%' or parent_target.display_name like '%전북%' then '전북권은 전주권 생활수요, 새만금·산업단지 기대, 혁신도시 수요가 분산되어 있습니다. 정책·산업 뉴스가 실제 주거 수요로 연결되는 속도를 확인해야 합니다.'
+ when target.display_name like '%전라남%' or target.display_name like '%전남%' or parent_target.display_name like '%전라남%' or parent_target.display_name like '%전남%' then '전남권은 여수·순천·광양 산업축과 해안·관광 생활권이 공존합니다. 산업도시는 고용 흐름을, 군 단위는 인구와 생활SOC 변화를 함께 봐야 합니다.'
+ when target.display_name like '%경상북%' or target.display_name like '%경북%' or parent_target.display_name like '%경상북%' or parent_target.display_name like '%경북%' then '경북권은 포항·구미 산업축, 대구권 연계, 신공항 기대가 지역별로 다르게 작동합니다. 교통·산업 호재는 장기 변수이고 단기 거래 회복은 별도로 검증해야 합니다.'
+ when target.display_name like '%경상남%' or target.display_name like '%경남%' or parent_target.display_name like '%경상남%' or parent_target.display_name like '%경남%' then '경남권은 창원 제조업, 거제·통영 조선·관광, 진주·사천 항공우주 축이 나뉩니다. 산업 회복 기대와 지역별 인구 흐름을 함께 봐야 합니다.'
+ when target.display_name like '%제주%' or parent_target.display_name like '%제주%' then '제주권은 관광·이주 수요, 공급 제약, 외부 자금 유입에 민감합니다. 체류·관광 회복이 주거 실수요와 같은 의미인지 분리해서 봐야 합니다.'
+ else '이 권역은 광역 흐름과 자체 생활권 수요를 함께 확인해야 합니다. 가격지표, 실거래, 전세, 공급 일정을 분리해 봐야 과도한 기대나 우려를 줄일 수 있습니다.'
+ end as macro_context,
+ case
+ when target.display_name like '%강남%' or target.display_name like '%서초%' or target.display_name like '%송파%' or target.display_name like '%용산%' then '최근 이슈의 중심은 고가 주거지의 정비사업 기대, 규제지역·토지거래허가구역 인식, 전세 가격 부담입니다. 실거래 한 건이 headline을 만들 수 있으므로 표본 수와 신고 시점을 같이 봐야 합니다.'
+ when target.display_name like '%마포%' or target.display_name like '%성동%' or target.display_name like '%광진%' or target.display_name like '%영등포%' or target.display_name like '%동작%' then '최근 이슈의 중심은 도심 접근성, 한강·업무지구 인접성, 준공·정비 일정입니다. 선호 입지 프리미엄은 기대 요인이지만 전세와 매매가 같은 방향인지 확인해야 합니다.'
+ when target.display_name like '%노원%' or target.display_name like '%도봉%' or target.display_name like '%강북%' or target.display_name like '%중랑%' or target.display_name like '%성북%' or target.display_name like '%은평%' then '최근 이슈의 중심은 노후 단지 정비, 광역교통 개선 기대, 실수요 가격대입니다. 기대는 분명하지만 정비 속도와 금융 부담이 체감 수요를 제한할 수 있습니다.'
+ when target.display_name like '%해운대%' or target.display_name like '%수영%' or target.display_name like '%연제%' or target.display_name like '%동래%' then '최근 이슈의 중심은 동부산 주거 선호, 해안·업무·교육 생활권, 재건축 기대입니다. 관광·상권 이슈와 상주 주거 수요를 구분해 읽어야 합니다.'
+ when target.display_name like '%강서구%' or target.display_name like '%기장%' or target.display_name like '%김해%' or target.display_name like '%양산%' then '최근 이슈의 중심은 산업단지, 신도시·택지, 광역도로 접근성입니다. 개발 기대가 거래로 연결되는지와 신규 공급 부담을 함께 봐야 합니다.'
+ when target.display_name like '%성남%' or target.display_name like '%용인%' or target.display_name like '%수원%' or target.display_name like '%화성%' or target.display_name like '%평택%' or target.display_name like '%과천%' or target.display_name like '%광명%' or target.display_name like '%하남%' or target.display_name like '%고양%' then '최근 이슈의 중심은 GTX와 광역철도, 반도체·바이오 일자리, 3기 신도시와 재건축 일정입니다. 서울 접근성 기대와 자체 공급 물량을 같이 봐야 합니다.'
+ when target.display_name like '%청주%' or target.display_name like '%천안%' or target.display_name like '%아산%' or target.display_name like '%오산%' then '최근 이슈의 중심은 산업 배후수요와 광역 교통, 신규 택지 공급입니다. 일자리 뉴스가 임대차 수요와 매매 회복으로 이어지는지 분리해 확인해야 합니다.'
+ when target.display_name like '%포항%' or target.display_name like '%구미%' or target.display_name like '%창원%' or target.display_name like '%거제%' or target.display_name like '%울산%' then '최근 이슈의 중심은 제조업·조선·배터리·소재 산업 경기입니다. 산업 회복은 기대 요인이지만 업종 변동성과 인구 흐름을 함께 봐야 합니다.'
+ when target.display_name like '%여수%' or target.display_name like '%순천%' or target.display_name like '%광양%' or target.display_name like '%목포%' or target.display_name like '%군산%' then '최근 이슈의 중심은 산업단지, 항만, 관광·원도심 재생입니다. 산업·관광 뉴스가 주거 수요로 얼마나 이어지는지 근거를 나눠 확인해야 합니다.'
+ when target.display_name like '%춘천%' or target.display_name like '%원주%' or target.display_name like '%강릉%' or target.display_name like '%속초%' or target.display_name like '%제주%' or target.display_name like '%서귀포%' then '최근 이슈의 중심은 관광·생활인구, 광역교통, 세컨드하우스 수요입니다. 외부 수요가 가격을 흔들 수 있어 상주 수요와 임대차 지표를 분리해야 합니다.'
+ else '최근 이슈의 중심은 광역권 가격 흐름, 지역별 공급 일정, 생활권 수요의 지속성입니다. 단일 뉴스보다 실거래·전세·정책 근거가 같은 방향인지 확인하는 방식이 안전합니다.'
+ end as local_issue,
+ case
+ when target.display_name like '%강남%' or target.display_name like '%서초%' or target.display_name like '%송파%' or target.display_name like '%용산%' then '핵심 입지 수요와 정비사업 일정, 전세 수요의 결합'
+ when target.display_name like '%성남%' or target.display_name like '%용인%' or target.display_name like '%화성%' or target.display_name like '%평택%' or target.display_name like '%수원%' then '일자리와 광역교통이 실제 주거 수요로 이어지는지 확인할 수 있는 점'
+ when target.display_name like '%해운대%' or target.display_name like '%수영%' then '동부산 선호 생활권과 해안·업무 수요가 유지되는 점'
+ when target.display_name like '%청주%' or target.display_name like '%천안%' or target.display_name like '%아산%' then '산업 배후수요와 광역 접근성이 함께 개선될 수 있는 점'
+ when target.display_name like '%포항%' or target.display_name like '%구미%' or target.display_name like '%창원%' or target.display_name like '%거제%' then '산업 경기 회복이 주거 수요 안정으로 연결될 수 있는 점'
+ else '공식 가격지표와 실거래·전세 원천이 같은 방향으로 쌓이면 지역 판단의 근거가 단단해지는 점'
+ end as expectation_theme,
+ case
+ when target.display_name like '%강남%' or target.display_name like '%서초%' or target.display_name like '%송파%' or target.display_name like '%용산%' then '고가 거래 한 건과 규제·정책 뉴스가 시장 전체 판단으로 과장될 수 있다는 점'
+ when target.display_name like '%성남%' or target.display_name like '%용인%' or target.display_name like '%화성%' or target.display_name like '%평택%' or target.display_name like '%수원%' then '개발 기대와 신규 공급 부담이 동시에 존재해 시차가 클 수 있다는 점'
+ when target.display_name like '%해운대%' or target.display_name like '%수영%' then '관광·상권 이슈가 상주 주거 수요와 다르게 움직일 수 있다는 점'
+ when target.display_name like '%청주%' or target.display_name like '%천안%' or target.display_name like '%아산%' then '산업 뉴스가 실제 입주·임대차 수요로 반영되기까지 시간이 걸릴 수 있다는 점'
+ when target.display_name like '%포항%' or target.display_name like '%구미%' or target.display_name like '%창원%' or target.display_name like '%거제%' then '특정 업종 경기 의존도가 가격과 전세의 변동성을 키울 수 있다는 점'
+ else '확인되지 않은 지역 뉴스나 단기 등락을 가격 방향의 결론처럼 읽을 수 있다는 점'
+ end as concern_theme
+ from real_estate_regions region
+ join real_estate_targets target on target.id = region.target_id
+ left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+ where region.region_level in ('sido', 'sigungu')
+) researched
+left join real_estate_regional_reports existing on existing.target_id = researched.target_id
+on duplicate key update
+ report_id = values(report_id),
+ report_version = values(report_version),
+ prompt_version = values(prompt_version),
+ model_name = values(model_name),
+ generated_by = values(generated_by),
+ title = values(title),
+ headline = values(headline),
+ summary = values(summary),
+ body = values(body),
+ expectation_points_json = values(expectation_points_json),
+ concern_points_json = values(concern_points_json),
+ data_quality = values(data_quality),
+ confidence = values(confidence),
+ as_of = values(as_of),
+ published_at = values(published_at),
+ updated_at = values(updated_at);
+
+delete from real_estate_regional_report_sources
+where report_target_id in (
+ select target_id
+ from real_estate_regions
+ where region_level in ('sido', 'sigungu')
+);
+
+insert into real_estate_regional_report_sources (
+ id,
+ report_target_id,
+ display_order,
+ ref_type,
+ ref_id,
+ label,
+ title,
+ url,
+ source_name,
+ published_at,
+ data_status,
+ created_at,
+ updated_at
+)
+select
+ concat('regional-report-source-', region.target_id, '-01-rone-main'),
+ region.target_id,
+ 1,
+ 'external',
+ 'reb-rone-2026-05-national-housing-price-trend',
+ '가격지표 근거',
+ '한국부동산원 R-ONE 2026년 5월 전국주택가격동향',
+ 'https://www.reb.or.kr/r-one/portal/main/indexPage.do',
+ '한국부동산원',
+ '2026-06-15 00:00:00',
+ 'ok',
+ '2026-06-24 22:00:00',
+ '2026-06-24 22:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu')
+union all
+select
+ concat('regional-report-source-', region.target_id, '-02-rone-weekly'),
+ region.target_id,
+ 2,
+ 'external',
+ 'reb-rone-2026-06-15-weekly-apartment-price-trend',
+ '주간 가격 근거',
+ '한국부동산원 2026년 6월 15일 기준 주간아파트가격 동향',
+ 'https://www.reb.or.kr/r-one/portal/bbs/rpt/searchBulletinPage.do',
+ '한국부동산원',
+ '2026-06-18 00:00:00',
+ 'ok',
+ '2026-06-24 22:00:00',
+ '2026-06-24 22:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu')
+union all
+select
+ concat('regional-report-source-', region.target_id, '-03-molit-rt'),
+ region.target_id,
+ 3,
+ 'external',
+ 'molit-real-transaction-disclosure',
+ '실거래 근거',
+ '국토교통부 실거래가 공개시스템',
+ 'https://rt.molit.go.kr/',
+ '국토교통부',
+ null,
+ 'ok',
+ '2026-06-24 22:00:00',
+ '2026-06-24 22:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu')
+union all
+select
+ concat('regional-report-source-', region.target_id, '-04-data-trade'),
+ region.target_id,
+ 4,
+ 'external',
+ 'data-go-kr-molit-apt-trade',
+ '공공 API 근거',
+ '공공데이터포털 국토교통부 아파트 매매 실거래가 자료',
+ 'https://www.data.go.kr/data/15126469/openapi.do',
+ '공공데이터포털',
+ null,
+ 'ok',
+ '2026-06-24 22:00:00',
+ '2026-06-24 22:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu')
+union all
+select
+ concat('regional-report-source-', region.target_id, '-05-data-rent'),
+ region.target_id,
+ 5,
+ 'external',
+ 'data-go-kr-molit-apt-rent',
+ '전월세 근거',
+ '공공데이터포털 국토교통부 아파트 전월세 실거래가 자료',
+ 'https://www.data.go.kr/data/15126474/openapi.do',
+ '공공데이터포털',
+ null,
+ 'ok',
+ '2026-06-24 22:00:00',
+ '2026-06-24 22:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu')
+union all
+select
+ concat('regional-report-source-', region.target_id, '-06-molit-policy'),
+ region.target_id,
+ 6,
+ 'external',
+ 'molit-housing-land-policy-press-2026-06',
+ '정책·공급 근거',
+ '국토교통부 주택토지 보도자료 - 공급·정책 동향',
+ 'https://www.molit.go.kr/USR/NEWS/m_71/lst.jsp?search_section=p_sec_2',
+ '국토교통부',
+ '2026-06-11 00:00:00',
+ 'ok',
+ '2026-06-24 22:00:00',
+ '2026-06-24 22:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu')
+union all
+select
+ concat('regional-report-source-', region.target_id, '-07-enara-index'),
+ region.target_id,
+ 7,
+ 'external',
+ 'enara-housing-price-indicator-2026-06-15',
+ '공표일 근거',
+ 'e-나라지표 주택매매가격 동향',
+ 'https://www.index.go.kr/unity/potal/main/EachDtlPageDetail.do?idx_cd=1240',
+ 'e-나라지표',
+ '2026-06-15 00:00:00',
+ 'ok',
+ '2026-06-24 22:00:00',
+ '2026-06-24 22:00:00'
+from real_estate_regions region
+where region.region_level in ('sido', 'sigungu');
diff --git a/backend/src/main/resources/db/migration/V53__real_estate_seoul_deep_research_reports.sql b/backend/src/main/resources/db/migration/V53__real_estate_seoul_deep_research_reports.sql
new file mode 100644
index 0000000..a1d7310
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V53__real_estate_seoul_deep_research_reports.sql
@@ -0,0 +1,290 @@
+insert into real_estate_regional_reports (
+ target_id,
+ report_id,
+ report_version,
+ prompt_version,
+ model_name,
+ generated_by,
+ title,
+ headline,
+ summary,
+ body,
+ expectation_points_json,
+ concern_points_json,
+ data_quality,
+ confidence,
+ as_of,
+ published_at,
+ created_at,
+ updated_at
+)
+select
+ region.target_id,
+ concat('regional-report-', region.target_id, '-deep-seoul-20260624'),
+ 'regional-report-deep-research-v1',
+ 'codex-seoul-deep-research-prompt-20260624',
+ 'codex-gpt-5-deep-research',
+ 'codex-deep-research:seoul-20260624',
+ concat(target.display_name, ' 심화 지역 리포트'),
+ case region.target_id
+ when 'region-seoul' then '서울은 공급 부족과 전세 압력, 규제지역 효과가 동시에 걸린 초양극화 시장입니다.'
+ when 'region-seoul-seocho' then '서초는 서리풀2지구 공급 뉴스와 반포·잠원 정비 기대가 같은 화면에 있는 핵심 관찰지입니다.'
+ when 'region-seoul-mapo' then '마포는 상암·공덕·홍대 축이 강남권 대체재 논리와 정비사업 리스크를 동시에 만듭니다.'
+ when 'region-seoul-nowon' then '노원은 재건축 기대와 실수요 가격대가 충돌하는 강북권 최대 노후 단지 관찰지입니다.'
+ else concat(target.display_name, '는 서울 전체 공급·전세·정비사업 구도 안에서 따로 읽어야 하는 생활권입니다.')
+ end,
+ case region.target_id
+ when 'region-seoul' then '서울 전체 판단은 강남3구·용산의 가격 발견, 강북 전세 압력, 서울시 정비사업 속도전, 토지거래허가구역 효과를 한 번에 엮어야 합니다.'
+ when 'region-seoul-mapo' then '마포는 도심 접근성과 서부권 업무·문화 수요가 강하지만, 정비구역별 속도와 전세 부담을 분리해야 합니다.'
+ when 'region-seoul-seocho' then '서초는 서리풀2지구 2,000호 공급이 단기 가격 안정책이라기보다 장기 공급 신호라는 점을 봐야 합니다.'
+ when 'region-seoul-nowon' then '노원은 강북권 전세 상승과 노후 단지 재건축 기대가 매수 전환 심리를 자극할 수 있지만 사업성 검증이 핵심입니다.'
+ else concat(target.display_name, '는 서울권 전세 강세, 정비사업 공급, 토지거래허가 규제의 영향을 각 생활권 수요와 함께 해석해야 합니다.')
+ end,
+ case region.target_id
+ when 'region-seoul' then concat(
+ '서울 전체 리포트의 결론은 단순한 상승 또는 하락이 아니라 초양극화의 재배치입니다. KB경영연구소가 2026년 주택시장의 핵심 변수로 서울 아파트 매매 수요, 월세화, 공급시장 위축, 노후 아파트 정비시장, 정책 방향을 함께 제시한 이유가 여기 있습니다. ',
+ '금융권 리포트가 말하는 2026년 서울의 핵심은 매매 수요가 사라진 것이 아니라 규제와 대출 한도 안에서 더 선별적으로 움직인다는 점입니다. 강남3구와 용산의 가격 발견은 토지거래허가와 세제 변수에 먼저 반응하고, 강북·노원·도봉·성북 같은 지역은 전세 부족과 가격대 매력이 매수 전환 압력으로 이어질 수 있습니다. ',
+ 'KB Think는 2026년 1월부터 5월까지 서울 아파트 평균 전세가격이 3.49% 올랐고 강북·성북·노원·도봉 등 강북 지역 전셋값 상승률이 5% 이상이었다고 설명합니다. KDI 국내연구자료도 수도권 전세 강세와 월세 비중 확대, 공급 인허가 부진과 사업장별 일정 불확실성을 함께 지적합니다. ',
+ '공급 측면에서는 서울시가 신속통합기획 2.0과 2031년 31만 호 착공, 2035년 약 37.7만 호 준공을 내세우지만, 실제 체감 공급은 인허가, 이주, 조합 의사결정, 공사비를 지나야 합니다. 서리풀2지구 2,000호처럼 확정적인 공공주택 뉴스도 가격을 바로 누르는 물량이 아니라 2028년 착공 목표의 장기 공급 신호로 해석해야 합니다. ',
+ '따라서 서울의 기대 지점은 정비사업 속도전과 전세 압력이 만들어내는 선호 입지의 방어력입니다. 우려 지점은 규제 완화 기대, 토지거래허가구역 해제 또는 연장 기대, 단기 신고가가 실제 거주 수요보다 먼저 가격에 반영되는 것입니다. 지도 기간별 등락률은 색상과 숫자 판단에만 쓰고, 이 심화 리포트는 정책·금융권 분석·구별 정비 이슈가 갱신될 때 최신본으로 다시 교체해야 합니다.'
+ )
+ when 'region-seoul-jongno' then concat(
+ '종로는 광화문·종각·대학로·북촌의 업무·관광·문화 수요가 공존하지만, 주거지는 넓게 확장되기 어렵습니다. 서울 전체 전세 강세가 도심 접근 수요를 밀어 올릴 수 있으나, 종로의 핵심은 대규모 신축 공급보다 낡은 도심 주거지와 상업지 사이의 희소성입니다. ',
+ 'KB 2026 보고서의 월세화·공급 위축 이슈를 종로에 적용하면, 매매보다 임대차 구조 변화가 먼저 체감될 가능성이 큽니다. 정비사업은 문화재·경관·상업지 이해관계 때문에 속도가 일정하지 않아 단일 호재처럼 쓰기 어렵습니다. ',
+ '기대 지점은 도심 직주근접 수요와 관광 회복에 따른 상권 안정입니다. 우려 지점은 상권 뉴스가 주거 실수요로 과장되고, 소규모 거래가 가격 방향처럼 읽히는 것입니다.'
+ )
+ when 'region-seoul-jung' then concat(
+ '중구는 서울 도심 업무지구와 명동·을지로·동대문 상권이 겹치지만 주거 재고는 제한적인 권역입니다. 서울 전체 공급 부족과 월세화 흐름은 중구에서 주거 선택지 부족으로 더 직접적으로 나타납니다. ',
+ '이 지역은 CBD 회복, 호텔·상업 수요, 노후 도심 정비가 함께 움직입니다. 다만 상업용 부동산 회복을 아파트 매매 수요로 그대로 옮겨 해석하면 안 됩니다. ',
+ '기대 지점은 희소한 도심 주거와 직주근접입니다. 우려 지점은 생활형 숙박·상권 이슈와 실제 거주 선호가 다르게 움직일 수 있다는 점입니다.'
+ )
+ when 'region-seoul-yongsan' then concat(
+ '용산은 국제업무지구, 한강변 주거 선호, 미군기지 반환부지 기대가 겹친 서울의 대표 가격 발견 지역입니다. 강남3구와 함께 정책과 규제 변화에 가장 먼저 반응하는 구간이어서, 토지거래허가구역과 세제 변수의 해석이 중요합니다. ',
+ 'KB 보고서가 말한 서울 아파트 매매 수요 변화는 용산에서 고자산가 선별 수요와 공급 희소성으로 나타납니다. 다만 대형 개발은 사업 기간이 길고, 기대가 거래가에 선반영되기 쉽습니다. ',
+ '기대 지점은 한강·도심·광역개발의 희소 조합입니다. 우려 지점은 개발 일정 지연과 규제지역 프리미엄의 과잉 반영입니다.'
+ )
+ when 'region-seoul-seongdong' then concat(
+ '성동은 성수·왕십리·서울숲·한강변 수요가 결합한 대표적인 한강벨트 확장 지역입니다. 업무·상권·주거 선호가 같이 붙기 때문에 강남권 대체재로 읽히지만, 이미 가격 기대가 상당 부분 반영된 구간도 많습니다. ',
+ '서울시 정비사업 속도전은 성동의 노후 주거지와 준공업·상업지 재편 기대를 키우지만, 토지 가격과 공사비 부담은 사업성을 압박합니다. ',
+ '기대 지점은 성수 업무지구화와 한강 접근성입니다. 우려 지점은 상권 인기와 실거주 가치가 같은 속도로 가지 않을 수 있다는 점입니다.'
+ )
+ when 'region-seoul-gwangjin' then concat(
+ '광진은 건대입구·자양·구의·광장 생활권을 통해 강남 접근성과 한강 동부권 주거 수요를 동시에 갖습니다. 서울 전세 강세가 중상급지 대체 수요를 밀어 올릴 때 광진은 성동·송파 사이의 완충지로 움직일 수 있습니다. ',
+ '다만 광진은 대규모 신축 공급이 넉넉하지 않고, 동별 주거 품질 차이가 커서 평균 지표만으로 판단하기 어렵습니다. ',
+ '기대 지점은 강남 접근성과 한강 동부권 입지입니다. 우려 지점은 개발 호재보다 단지별 연식과 학군·교통 차이가 더 크게 작동하는 점입니다.'
+ )
+ when 'region-seoul-dongdaemun' then concat(
+ '동대문은 청량리 광역환승, 이문·휘경 정비사업, 대학가 임대 수요가 함께 놓입니다. 서울 공급 부족 국면에서 동북권 신축·준신축 선택지가 늘어나는 구간이지만, 입주 물량과 정비 속도에 따라 체감은 다를 수 있습니다. ',
+ 'KB의 공급시장 위축 프레임을 적용하면 동대문은 서울 안에서 상대적으로 공급 후보가 보이는 지역입니다. 그래서 가격 기대보다 입주 이후 전세 흡수력을 먼저 봐야 합니다. ',
+ '기대 지점은 청량리 중심성 회복과 정비사업 물량입니다. 우려 지점은 신규 공급이 한꺼번에 체감될 때 전세가 먼저 흔들릴 수 있다는 점입니다.'
+ )
+ when 'region-seoul-jungnang' then concat(
+ '중랑은 상봉·망우 교통축과 면목 일대 정비 기대가 핵심입니다. 서울 전세 강세가 외곽 실수요를 자극할 때 중랑은 가격대 접근성 때문에 관심을 받을 수 있습니다. ',
+ '그러나 중랑의 리포트는 개발 기대보다 주거 품질 개선 속도와 교통 체감 시간을 봐야 합니다. 모아타운·소규모 정비는 기대가 빠르지만 실제 주택 재고를 바꾸는 데 시간이 걸립니다. ',
+ '기대 지점은 상봉 교통 거점과 중저가 실수요입니다. 우려 지점은 정비사업 초기 기대가 거래가격에 먼저 반영될 가능성입니다.'
+ )
+ when 'region-seoul-seongbuk' then concat(
+ '성북은 길음·장위 뉴타운, 돈암·정릉·월곡 생활권, 대학가 수요가 섞인 강북 핵심 주거지입니다. KB Think가 언급한 강북권 전세 강세는 성북에서 매수 전환 압력으로 번질 수 있습니다. ',
+ '다만 성북은 신축 선호 단지와 노후 구릉지 주거지가 강하게 갈립니다. 서울시 신속통합기획과 정비사업 속도전의 수혜를 말하려면 구역별 단계와 이주 리스크를 따로 봐야 합니다. ',
+ '기대 지점은 강북권 실수요와 정비사업 누적입니다. 우려 지점은 전세 상승만 보고 매매 회복을 단정할 수 없다는 점입니다.'
+ )
+ when 'region-seoul-gangbuk' then concat(
+ '강북은 미아·수유 중심의 가격대 접근성과 북서울 생활권 정비 기대가 핵심입니다. 서울 전체 공급 부족과 전세 상승이 실수요를 밀어 올릴 수 있지만, 소득·대출 한도가 더 민감하게 작용하는 지역입니다. ',
+ '노후 주거지 개선은 분명한 기대 요인입니다. 그러나 정비사업은 구역 지정과 조합 단계에서 시간이 걸리고, 공사비 부담이 사업성을 흔들 수 있습니다. ',
+ '기대 지점은 실수요 가격대와 정비 여지입니다. 우려 지점은 금융 규제와 전세 부담이 동시에 커질 때 매수 전환이 제한될 수 있다는 점입니다.'
+ )
+ when 'region-seoul-dobong' then concat(
+ '도봉은 창동·상계 광역중심, GTX-C 기대, 강북권 가격대 접근성이 맞물리는 지역입니다. 서울 전세 강세가 계속되면 도봉은 임차 수요의 매수 전환 후보가 될 수 있습니다. ',
+ '다만 도봉은 교통 호재의 실현 시점과 노후 단지 정비 속도가 가격 설명의 대부분입니다. 기대는 크지만 시간이 길어질수록 금융비용과 생활 인프라 체감이 변수로 남습니다. ',
+ '기대 지점은 창동 중심지 재편과 저평가 인식입니다. 우려 지점은 호재 시간표가 길어질 때 단기 가격만 앞설 수 있다는 점입니다.'
+ )
+ when 'region-seoul-nowon' then concat(
+ '노원은 서울 최대급 노후 아파트 밀집지라는 사실 하나만으로도 2026년 서울 리포트에서 따로 봐야 합니다. 최근 기사들은 중계그린 추진위 승인, 상계보람 정비계획 통과, 미미삼 정비구역 지정 기대처럼 노후 단지 재건축이 본격 궤도에 오르는 흐름을 전합니다. ',
+ 'KB Think가 지적한 강북·성북·노원·도봉 전세 상승은 노원에서 특히 중요합니다. 전세 부담이 커지면 중저가 대단지의 매수 전환이 빨라질 수 있지만, 재건축 기대와 실수요 가격대가 동시에 뛰면 실거주자의 부담도 커집니다. ',
+ '따라서 노원의 기대 지점은 재건축 기대와 실수요 가격대가 만나는 점입니다. 우려 지점은 사업성 개선 기대가 실제 조합 단계, 이주, 공사비 검증보다 먼저 가격화되는 것입니다. 노후 단지 리포트는 반드시 단지별 연식, 용적률, 추진 단계와 함께 봐야 합니다.'
+ )
+ when 'region-seoul-eunpyeong' then concat(
+ '은평은 수색·증산, 불광·연신내, 은평뉴타운이 서로 다른 시장을 만듭니다. GTX-A와 서북권 교통 기대는 장기 변수이고, 서울 전세 강세는 실수요 가격대의 방어력을 높일 수 있습니다. ',
+ '하지만 은평은 도심 접근성 개선 기대와 실제 통근 시간이 체감에서 다르게 나타날 수 있습니다. 뉴타운과 원도심의 주거 품질 차이도 큽니다. ',
+ '기대 지점은 서북권 교통 개선과 실수요 기반입니다. 우려 지점은 교통 호재가 이미 가격에 반영됐는지 확인해야 한다는 점입니다.'
+ )
+ when 'region-seoul-seodaemun' then concat(
+ '서대문은 가재울·북아현·홍제·연희 생활권이 도심 접근성과 대학가 수요를 공유합니다. 서울 공급 부족 국면에서 도심 가까운 준신축과 정비 후보지는 꾸준히 관찰할 필요가 있습니다. ',
+ '다만 서대문은 언덕 지형과 단지별 입지 차이가 커서 구 평균 지표가 사용자를 속일 수 있습니다. 정비사업은 생활권을 바꿀 수 있지만, 사업 단계별 불확실성을 분리해야 합니다. ',
+ '기대 지점은 도심 접근성과 정비사업 누적입니다. 우려 지점은 지역 내 입지 격차가 크다는 점입니다.'
+ )
+ when 'region-seoul-mapo' then concat(
+ '마포는 상암·공덕·홍대 세 축을 동시에 봐야 합니다. 상암은 DMC 업무와 미디어 산업, 공덕은 여의도·광화문 접근, 홍대는 문화상권과 임대 수요가 중심입니다. 이 조합 때문에 마포는 강남권 대체재 또는 도심 대체 주거지로 자주 읽히지만, 실제 가격은 축별로 다르게 움직입니다. ',
+ '마포구 2026년 주요업무계획은 공동주택 230개 단지 7만7566세대, 주택정비사업 현황 19개소, 도화우성 추진위 구성 지원, 염리5구역 정비구역 지정 추진, 망원·아현 신통기획 후보지 정비계획 수립을 제시합니다. 즉 마포는 이미 성숙한 인기 주거지인 동시에 아직 정비 파이프라인이 남아 있는 지역입니다. ',
+ '기대 지점은 상암·공덕·홍대 수요가 전세와 매매를 동시에 받치는 점입니다. 우려 지점은 상권 인기와 정비사업 기대가 강남권 대체재라는 말로 과장될 수 있다는 점입니다. 마포는 단기 신고가보다 축별 전세, 정비구역 단계, 신축 입주 일정을 나눠 봐야 합니다.'
+ )
+ when 'region-seoul-yangcheon' then concat(
+ '양천은 목동 재건축과 학군 수요가 거의 모든 설명의 중심입니다. 서울 노후 아파트 정비시장 확대라는 KB 보고서의 이슈가 가장 직접적으로 적용되는 구 중 하나입니다. ',
+ '다만 목동 재건축 기대는 이미 오랫동안 가격에 반영되어 왔고, 안전진단·정비계획·이주·공사비 단계가 모두 남아 있습니다. 신월·신정 생활권은 목동과 다른 가격대·수요를 보입니다. ',
+ '기대 지점은 학군과 재건축 희소성입니다. 우려 지점은 목동 기대가 양천 전체 가격 판단으로 번지는 것입니다.'
+ )
+ when 'region-seoul-gangseo' then concat(
+ '강서는 마곡 R&D·업무지구, 김포공항, 가양·등촌·방화 노후 주거지가 함께 움직입니다. 서울 서부권에서 일자리와 주거가 함께 있는 드문 구간이라는 점은 기대 요인입니다. ',
+ '한겨레 보도처럼 강남3구·용산·한강벨트의 가격 흐름이 조정될 때 노원·강서 등 외곽 실수요 지역은 다른 흐름을 보일 수 있습니다. 그러나 강서는 신규 입주와 노후 단지의 품질 차이가 크기 때문에 평균 지표만 보면 안 됩니다. ',
+ '기대 지점은 마곡 일자리와 서부권 교통입니다. 우려 지점은 외곽 실수요 회복을 개발 기대와 혼동하는 것입니다.'
+ )
+ when 'region-seoul-guro' then concat(
+ '구로는 G밸리 업무수요, 구로·신도림 교통, 서남권 가격대 접근성이 핵심입니다. 서울 전세 강세가 이어질 때 구로는 실수요자의 대안 지역으로 기능할 수 있습니다. ',
+ '다만 구로는 업무지구 인접성과 주거 선호가 동별로 크게 다릅니다. 낡은 주거지의 정비 속도와 신도림·구로디지털단지 접근성을 분리해야 합니다. ',
+ '기대 지점은 일자리 접근성과 가격대입니다. 우려 지점은 직장 수요가 곧바로 매매 수요로 전환된다고 볼 수 없다는 점입니다.'
+ )
+ when 'region-seoul-geumcheon' then concat(
+ '금천은 가산디지털단지와 독산·시흥 생활권이 중심입니다. 구로와 함께 서남권 일자리 접근성은 강하지만, 서울 평균 선호 입지와의 격차도 동시에 존재합니다. ',
+ '공급 부족과 전세 상승은 금천의 가격 방어력을 높일 수 있습니다. 그러나 매매 회복은 교통·주거환경 개선과 함께 확인해야 합니다. ',
+ '기대 지점은 G밸리 배후수요와 상대적 가격 접근성입니다. 우려 지점은 업무지구 임대수요와 가족 단위 주거 선호가 다를 수 있다는 점입니다.'
+ )
+ when 'region-seoul-yeongdeungpo' then concat(
+ '영등포는 여의도 금융업무지구, 문래 준공업지역, 신길·당산 주거지가 한 구 안에서 서로 다른 리포트를 요구합니다. 서울시의 용적률·정비사업 속도전은 준공업지역 재편 기대와 연결됩니다. ',
+ '여의도 업무 수요는 강하지만 주거 시장은 여의도, 당산, 신길, 대림이 다르게 움직입니다. 업무지구 호재와 주거 선호를 같은 방향으로 단정하면 안 됩니다. ',
+ '기대 지점은 금융업무지구와 정비사업 파이프라인입니다. 우려 지점은 준공업지역 개발 기대가 실제 주거 공급으로 연결되기까지 시간이 필요하다는 점입니다.'
+ )
+ when 'region-seoul-dongjak' then concat(
+ '동작은 흑석·노량진·사당을 통해 강남, 용산, 여의도 사이의 연결 입지를 갖습니다. 서울 전세 강세가 도심 접근형 중상급지로 번질 때 동작은 꾸준한 관찰 대상입니다. ',
+ '다만 흑석과 노량진 정비 기대는 이미 가격에 반영된 구간이 있고, 사당·상도 생활권은 다른 가격대와 수요를 보입니다. ',
+ '기대 지점은 강남·용산·여의도 접근성입니다. 우려 지점은 정비사업 기대와 실제 입주 시점의 간격입니다.'
+ )
+ when 'region-seoul-gwanak' then concat(
+ '관악은 서울대·신림·봉천 생활권, 청년·1인가구 임대수요, 신림선 이후 교통 개선 기대가 핵심입니다. 서울 전세와 월세화 흐름은 관악에서 임대차 부담으로 먼저 나타날 가능성이 큽니다. ',
+ '재개발 기대는 있지만 구릉지·노후주거·소형주택 비중 때문에 사업성이 구역마다 다릅니다. 단기 가격보다 임대차 안정과 정비구역 진척을 함께 봐야 합니다. ',
+ '기대 지점은 청년 임대수요와 가격대 접근성입니다. 우려 지점은 월세화가 주거비 부담을 키울 수 있다는 점입니다.'
+ )
+ when 'region-seoul-seocho' then concat(
+ '서초는 반포·잠원·서초·방배의 고가 주거 수요와 우면·내곡·염곡의 공공택지·녹지 이슈가 동시에 존재합니다. 국토교통부는 서초구 우면동 일원 서울 서리풀2지구 2,000호 공공주택지구 지정과 2028년 착공 목표를 발표했습니다. ',
+ '이 뉴스는 서초 가격을 단기적으로 누르는 재료라기보다 강남권 공급 부족 논리에 대한 장기 보완 신호입니다. 동시에 서울부동산정보광장의 토지거래허가구역 현황은 강남·서초 자연녹지지역과 재건축 단지 규제가 여전히 시장 해석의 핵심임을 보여줍니다. ',
+ '기대 지점은 최상급 주거 선호, 학군, 정비사업 희소성입니다. 우려 지점은 서리풀2지구와 토지거래허가 이슈가 단기 매매 판단으로 과장되는 것입니다. 서초는 공급 뉴스와 고가 주거 수요를 반드시 시간축으로 분리해야 합니다.'
+ )
+ when 'region-seoul-gangnam' then concat(
+ '강남은 대치·청담·삼성·개포·압구정으로 나뉘는 서울의 가격 발견 중심입니다. KB 보고서가 말하는 서울 아파트 매매 수요 변화, 정책 방향, 노후 아파트 정비시장의 확대가 가장 먼저 가격에 반영되는 곳입니다. ',
+ '그러나 강남은 토지거래허가구역, 세제, 대출 규제, 정비사업 인허가가 모두 가격에 개입합니다. 강남3구와 용산의 가격 변동은 서울 전체 심리를 움직이지만, 실제 매수 가능 수요는 더 얇고 선별적입니다. ',
+ '기대 지점은 대체 불가능한 학군·업무·자산 선호입니다. 우려 지점은 신고가와 정책 기대가 실수요 체력보다 앞설 수 있다는 점입니다.'
+ )
+ when 'region-seoul-songpa' then concat(
+ '송파는 잠실 재건축, 가락·문정 업무, 위례·거여마천 생활권이 함께 있는 동남권 핵심지입니다. 강남과 같이 토지거래허가구역과 정비사업 규제가 시장 심리를 크게 좌우합니다. ',
+ '잠실권은 서울 가격 발견에 민감하지만, 송파 전체는 신축·재건축·동남권 업무수요가 섞여 평균 지표로 설명하기 어렵습니다. ',
+ '기대 지점은 잠실의 상징성과 동남권 업무·교통 기반입니다. 우려 지점은 재건축 기대가 규제와 공사비 앞에서 지연될 수 있다는 점입니다.'
+ )
+ when 'region-seoul-gangdong' then concat(
+ '강동은 둔촌·고덕·강일·암사 생활권이 다르게 움직입니다. 대규모 입주를 경험한 지역이라 서울 공급 부족 논리를 그대로 적용하기보다 입주 이후 전세 안정, 고덕 업무·산업 접근성, 동남권 확장을 함께 봐야 합니다. ',
+ '서울 전체 전세 강세 속에서도 강동은 단지별 입주 연식과 공급 흡수 속도에 따라 체감이 갈릴 수 있습니다. ',
+ '기대 지점은 고덕·강일 신축축과 동남권 확장성입니다. 우려 지점은 대규모 입주 영향과 주변 공급이 전세·매매를 다르게 흔들 수 있다는 점입니다.'
+ )
+ else concat(
+ target.display_name, '는 서울 전체 공급 부족과 전세 압력 안에서 세부 생활권을 따로 봐야 합니다. ',
+ 'KB 2026 보고서의 서울 아파트 매매 수요 변화, 월세화, 공급시장 위축, 노후 정비시장 확대라는 프레임을 적용하되, 구별 정비사업 단계와 실제 전세 수급을 분리해야 합니다. ',
+ '기대 지점은 서울 안의 희소한 생활권 수요이고, 우려 지점은 단기 뉴스가 가격 결론으로 과장되는 것입니다.'
+ )
+ end,
+ case region.target_id
+ when 'region-seoul' then '["강남3구·용산의 가격 발견과 강북 전세 압력을 나눠 보면 서울 시장의 양극화 구조를 더 정확히 읽을 수 있습니다.","서울시 신속통합기획 2.0과 2031년 31만 호 착공 목표는 장기 공급 기대를 만드는 핵심 근거입니다.","전세 상승과 월세화가 실수요 매수 전환을 자극하는지 구별로 확인할 수 있습니다."]'
+ when 'region-seoul-seocho' then '["서리풀2지구 2,000호는 강남권 공급 부족 논리를 완화할 장기 신호입니다.","반포·잠원·방배의 최상급 주거 선호는 서울 핵심 수요를 계속 붙잡습니다.","공공택지와 재건축 근거를 분리하면 단기 과열과 장기 공급을 따로 볼 수 있습니다."]'
+ when 'region-seoul-mapo' then '["상암·공덕·홍대의 업무·문화·교통 축이 서로 다른 수요층을 만듭니다.","마포구 정비사업 파이프라인은 성숙지 안에서도 추가 주거 개선 여지를 보여줍니다.","강남권 대체재 논리를 전세·정비 단계와 함께 검증할 수 있습니다."]'
+ when 'region-seoul-nowon' then '["노후 단지 재건축 기대와 실수요 가격대가 동시에 작동합니다.","강북권 전세 상승은 노원 대단지의 매수 전환 압력을 키울 수 있습니다.","중계·상계·월계 단지별 추진 단계가 쌓이면 강북 정비시장 대표 관찰지가 됩니다."]'
+ else concat('["', target.display_name, '는 서울 공급 부족과 전세 압력 안에서 생활권 수요를 확인할 수 있습니다.","정비사업 단계와 임대차 지표를 함께 보면 단기 기사보다 설명력이 높아집니다.","서울시 공급 속도전과 금융권 시장 전망을 같은 근거 묶음으로 볼 수 있습니다."]')
+ end,
+ case region.target_id
+ when 'region-seoul' then '["토지거래허가구역과 세제 변수는 거래량과 가격을 동시에 왜곡할 수 있습니다.","정비사업 착공 목표는 장기 공급 신호이지 즉시 입주 물량이 아닙니다.","강북 전세 강세를 서울 전체 매매 상승 결론으로 단정하면 안 됩니다."]'
+ when 'region-seoul-seocho' then '["토지거래허가와 고가 주거 수요가 거래량을 얇게 만들 수 있습니다.","서리풀2지구는 2028년 착공 목표라 단기 입주 물량으로 해석하면 안 됩니다.","반포·잠원 신고가를 서초 전체의 즉시 방향으로 일반화하면 위험합니다."]'
+ when 'region-seoul-mapo' then '["상암·공덕·홍대의 수요 성격이 달라 구 평균 지표가 실제 체감을 가릴 수 있습니다.","정비사업 기대가 강남권 대체재 서사로 과장될 수 있습니다.","상권 인기와 주거 실수요를 같은 흐름으로 단정하지 않아야 합니다."]'
+ when 'region-seoul-nowon' then '["재건축 기대가 조합·이주·공사비 검증보다 먼저 가격화될 수 있습니다.","전세 상승이 실수요 부담을 키워 매수 전환을 제한할 수도 있습니다.","노후 단지 전체를 같은 사업성으로 묶으면 오판하기 쉽습니다."]'
+ else concat('["', target.display_name, '의 단기 등락을 서울 전체 방향으로 확대 해석하면 안 됩니다.","정비사업 후보지와 실제 착공 가능 물량은 시간차가 큽니다.","전세·월세 부담이 매수 전환으로 이어지는지 실거래로 확인해야 합니다."]')
+ end,
+ 'deep_researched',
+ case when region.region_level = 'sido' then 0.86 else 0.82 end,
+ '2026-06-24 00:00:00',
+ '2026-06-24 23:30:00',
+ coalesce(existing.created_at, '2026-06-24 23:30:00'),
+ '2026-06-24 23:30:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_regional_reports existing on existing.target_id = region.target_id
+where region.target_id = 'region-seoul'
+ or region.parent_region_id = 'region-seoul'
+on duplicate key update
+ report_id = values(report_id),
+ report_version = values(report_version),
+ prompt_version = values(prompt_version),
+ model_name = values(model_name),
+ generated_by = values(generated_by),
+ title = values(title),
+ headline = values(headline),
+ summary = values(summary),
+ body = values(body),
+ expectation_points_json = values(expectation_points_json),
+ concern_points_json = values(concern_points_json),
+ data_quality = values(data_quality),
+ confidence = values(confidence),
+ as_of = values(as_of),
+ published_at = values(published_at),
+ updated_at = values(updated_at);
+
+delete from real_estate_regional_report_sources
+where report_target_id in (
+ select target_id
+ from real_estate_regions
+ where target_id = 'region-seoul'
+ or parent_region_id = 'region-seoul'
+);
+
+insert into real_estate_regional_report_sources (
+ id,
+ report_target_id,
+ display_order,
+ ref_type,
+ ref_id,
+ label,
+ title,
+ url,
+ source_name,
+ published_at,
+ data_status,
+ created_at,
+ updated_at
+)
+select concat('regional-report-source-', region.target_id, '-seoul-01-kb-report'), region.target_id, 1, 'external', 'kb-2026-real-estate-report', '금융권 리포트', '2026 KB 부동산 보고서', 'https://www.kbfg.com/kbresearch/brand/brandView.do?reportId=2000492', 'KB경영연구소', '2026-05-05 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-02-kb-issues'), region.target_id, 2, 'external', 'kb-2026-seven-housing-issues', '금융권 핵심 이슈', 'KB금융 2026 부동산 보고서 발간 공지', 'https://www.kbfg.com/kbresearch/notice/noticeView.do?noticeId=2000141', 'KB경영연구소', '2026-05-05 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-03-kb-jeonse'), region.target_id, 3, 'external', 'kbthink-2026-h1-housing-market', '전세·청약 분석', 'KB의 생각 2026년 상반기 부동산 시장 총정리', 'https://kbthink.com/realestate/issue/hosing-260611.html', 'KB Think', '2026-06-11 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-04-kdi-market'), region.target_id, 4, 'external', 'kdi-2026-q1-real-estate-market', '시장 동향', 'KDI 경제정보센터 2026년 1분기 부동산시장동향', 'https://eiec.kdi.re.kr/policy/domesticView.do?ac=0000204646&depth1=&issus=&pg=&pp=&search_txt=&type=', 'KDI 경제정보센터', '2026-06-01 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-05-shintong'), region.target_id, 5, 'external', 'seoul-housing-shintong-2', '서울시 공급정책', '서울주거포털 신속통합기획 2.0', 'https://housing.seoul.go.kr/site/main/content/sh_080001', '서울특별시', null, 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-06-execution'), region.target_id, 6, 'external', 'seoul-housing-redevelopment-execution-2026', '정비사업 실행력', '서울주거포털 정비사업 실행력·공정관리 보도자료', 'https://housing.seoul.go.kr/site/main/tvReportedInfo/list?performanceType=01', '서울특별시', '2026-06-01 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-07-land-permit'), region.target_id, 7, 'external', 'seoul-land-transaction-permit-2026', '규제 근거', '서울부동산정보광장 토지거래허가구역 지정현황', 'https://land.seoul.go.kr/land/other/appointStatusSeoul.do', '서울특별시', '2026-03-12 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-08-seoripul2'), region.target_id, 8, 'external', 'molit-seoripul2-2000-units-20260611', '공급 근거', '국토교통부 서울 서초에 공공주택 2,000호 공급 추진', 'https://www.molit.go.kr/USR/NEWS/m_71/dtl.jsp?id=95092096&lcmspage=1', '국토교통부', '2026-06-11 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-09-rone'), region.target_id, 9, 'external', 'reb-rone-2026-05-national-housing-price-trend', '공식 가격지표', '한국부동산원 R-ONE 2026년 5월 전국주택가격동향', 'https://www.reb.or.kr/r-one/portal/main/indexPage.do', '한국부동산원', '2026-06-15 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-10-nowon'), region.target_id, 10, 'external', 'news-nowon-reconstruction-20260604', '구별 이슈', '외면받던 노원 재건축, 강북 정비사업 최대 격전지 될까', 'https://v.daum.net/v/20260604151704740?f=p', '뉴스1', '2026-06-04 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul'
+union all
+select concat('regional-report-source-', region.target_id, '-seoul-11-mapo'), region.target_id, 11, 'external', 'mapo-2026-main-business-plan', '구별 정비 근거', '마포구 2026년도 주요업무 계획', 'https://council.mapo.seoul.kr/record/appendixDownload.do?key=45280eb4321f8af67dd34fb5983674db681742583320259dbb959e67193ca769d02f2a9002da5860', '마포구', '2026-02-01 00:00:00', 'ok', '2026-06-24 23:30:00', '2026-06-24 23:30:00'
+from real_estate_regions region
+where region.target_id = 'region-seoul' or region.parent_region_id = 'region-seoul';
diff --git a/backend/src/main/resources/db/migration/V54__real_estate_gyeonggi_incheon_deep_research_reports.sql b/backend/src/main/resources/db/migration/V54__real_estate_gyeonggi_incheon_deep_research_reports.sql
new file mode 100644
index 0000000..0aad766
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V54__real_estate_gyeonggi_incheon_deep_research_reports.sql
@@ -0,0 +1,576 @@
+insert into real_estate_regional_reports (
+ target_id,
+ report_id,
+ report_version,
+ prompt_version,
+ model_name,
+ generated_by,
+ title,
+ headline,
+ summary,
+ body,
+ expectation_points_json,
+ concern_points_json,
+ data_quality,
+ confidence,
+ as_of,
+ published_at,
+ created_at,
+ updated_at
+)
+select
+ researched.target_id,
+ concat('regional-report-', researched.target_id, '-deep-capital-20260624'),
+ 'regional-report-deep-research-v1',
+ 'codex-gyeonggi-incheon-deep-research-prompt-20260624',
+ 'codex-gpt-5-deep-research',
+ 'codex-deep-research:capital-west-south-20260624',
+ concat(researched.display_name, ' 심화 지역 리포트'),
+ researched.headline,
+ researched.summary,
+ concat(
+ researched.overview, ' ',
+ researched.local_signal, ' ',
+ '공통 시장 배경은 수도권 전세 강세, 공급 인허가 부진, 대출 규제 안의 선별 수요입니다. KB·KDI·건설산업연구원 자료는 2026년 주택시장을 서울·수도권 중심의 차별화, 전세와 월세화, 공급 지연으로 설명합니다. ',
+ researched.policy_signal, ' ',
+ '기대 지점은 ', researched.expectation_theme, '입니다. 우려 지점은 ', researched.concern_theme, '입니다. ',
+ '따라서 이 리포트는 단기 가격 방향을 권하는 글이 아니라, 실거래·전세·공급·정책 일정이 같은 방향으로 맞물리는지 판단하기 위한 최신 기준 칼럼형 관찰문입니다.'
+ ),
+ concat(
+ '["',
+ researched.expectation_theme, '",',
+ '"공식 실거래와 전세 원천을 같은 target 기준으로 붙이면 개발 뉴스와 실제 체감 수요를 분리해 볼 수 있습니다.",',
+ '"권역별 정책·공급 근거가 갱신되면 AI API가 같은 target의 최신 리포트를 다시 덮어쓸 수 있습니다."]'
+ ),
+ concat(
+ '["',
+ researched.concern_theme, '",',
+ '"산업 뉴스나 교통 호재가 가격 방향을 바로 보장한다고 쓰지 않고, 착공·입주·전세 흡수 시차를 확인해야 합니다.",',
+ '"지도 기간별 등락률은 본문을 바꾸는 근거가 아니라 단기 보조 신호로만 다룹니다."]'
+ ),
+ 'deep_researched',
+ case
+ when researched.region_level = 'sido' then 0.84
+ else 0.8
+ end,
+ '2026-06-24 00:00:00',
+ '2026-06-24 23:45:00',
+ coalesce(existing.created_at, '2026-06-24 23:45:00'),
+ '2026-06-24 23:45:00'
+from (
+ select
+ region.target_id,
+ region.region_level,
+ target.display_name,
+ case
+ when target.display_name = '경기도' then '경기도는 1기 신도시 정비, 3기 신도시 공급, 용인 반도체 축이 동시에 작동하는 수도권 핵심 관찰 권역입니다.'
+ when target.display_name = '인천광역시' then '인천은 계양·검단 공급, 송도·청라·영종의 개발축, 원도심 정비 격차를 함께 읽어야 하는 서부 수도권 관찰 권역입니다.'
+ when target.display_name like '경기도%' then concat(target.display_name, '는 경기권 안에서도 서울 접근성, 일자리 축, 신도시·정비 일정이 따로 움직이는 생활권입니다.')
+ else concat(target.display_name, '는 인천권 안에서도 신도시 공급과 원도심 정비, 공항·항만·국제업무 수요를 분리해야 하는 생활권입니다.')
+ end as headline,
+ case
+ when target.display_name = '경기도' then '경기도 전체 판단은 분당·일산·평촌·산본·중동 1기 신도시 정비, 남양주왕숙·하남교산·고양창릉·부천대장 3기 신도시, 용인 반도체와 경기남부 산업축을 한 화면에 놓아야 합니다.'
+ when target.display_name = '인천광역시' then '인천 전체 판단은 인천계양 3기 신도시, 검단·청라·송도·영종의 신도시 축, 미추홀·부평·동구 원도심 정비 격차를 함께 봐야 합니다.'
+ else concat(target.display_name, '는 광역 평균보다 생활권별 교통·산업·공급 시차가 더 크게 작동합니다.')
+ end as summary,
+ case
+ when target.display_name = '경기도' then '경기도 전체 리포트의 결론은 한 방향의 경기 회복이 아니라 권역별 속도 차입니다. 2026년 수도권 공공택지 5만호 이상 착공과 3기 신도시 입주 시작은 남양주·하남·고양·부천·과천·광명 같은 공급 권역을 움직이고, 경기도가 분당·일산 기본계획 승인으로 1기 신도시 5곳 정비기본계획 수립을 마무리한 것은 노후계획도시 가격 기대를 다시 열었습니다.'
+ when target.display_name = '인천광역시' then '인천 전체 리포트의 결론은 신도시 공급과 원도심 격차입니다. 계양테크노밸리는 2차 수도권 주택공급 계획상 약 335만㎡, 17천호 규모로 잡혀 있고, 국토교통부는 인천계양 A2·A3 1106호 공공분양과 2026년 입주 목표를 제시했습니다. 하지만 송도·청라·검단·영종의 기대와 미추홀·동구·부평 원도심 체감은 같은 속도로 움직이지 않습니다.'
+ else concat(target.display_name, ' 리포트의 출발점은 광역 평균보다 생활권의 실제 쓰임입니다. 수도권은 전세 강세와 월세화, 공급 지연, 광역교통 기대가 동시에 작동하지만 이 힘이 모든 시·구에 같은 방향으로 번지지는 않습니다.')
+ end as overview,
+ case
+ when target.display_name like '%용인시 처인구%' then '처인구는 용인 첨단시스템반도체 국가산단과 원삼·이동 일대 산업 기대가 직접 닿는 곳입니다. 산업 뉴스가 가장 선명하지만 주거 판단은 직주근접 수요, 도로·철도 접근성, 택지·입주 시차를 분리해야 합니다. 첨단시스템반도체 국가산단은 기대 지점이지만, 착공과 고용이 실제 임대차 수요로 연결되는 시간표를 확인해야 합니다.'
+ when target.display_name like '%용인시 기흥구%' then '기흥구는 플랫폼시티·분당선·경부축 접근성과 반도체 배후 수요가 겹칩니다. 경기남부 산업축을 주거 수요로 연결하기 쉬운 곳이지만, 이미 선호가 반영된 단지와 노후 단지의 체감은 다릅니다.'
+ when target.display_name like '%용인시 수지구%' then '수지구는 분당·판교 대체 주거지 성격과 신분당선 접근성이 핵심입니다. 용인 반도체 뉴스가 광역 기대를 키우더라도 수지는 산업단지 직접 배후라기보다 강남·판교 접근 주거지로 읽어야 합니다.'
+ when target.display_name like '%성남시 분당구%' then '분당구는 1기 신도시 정비 기대와 판교 일자리 수요가 결합된 대표 지역입니다. 기본계획 승인 이후 기대는 커졌지만, 선도지구와 후속 단지의 사업성·이주·분담금 차이가 가격을 갈라놓을 수 있습니다.'
+ when target.display_name like '%성남시 수정구%' then '수정구는 위례·판교·강남 접근성과 원도심 정비가 함께 보이는 지역입니다. 분당 재건축 기대와 같은 결론으로 묶기보다, 신흥·태평·위례 생활권의 수요 성격을 나눠 봐야 합니다.'
+ when target.display_name like '%성남시 중원구%' then '중원구는 성남 원도심 정비와 산업·업무 접근성이 핵심입니다. 신축 공급 기대보다 재개발 단계, 전세 흡수, 생활 인프라 개선을 먼저 확인해야 합니다.'
+ when target.display_name like '%수원시 영통구%' then '영통구는 광교·영통·망포 축과 삼성디지털시티 배후 수요가 강합니다. 경기남부 일자리 축의 방어력은 기대 지점이지만, 준신축과 구축 단지 사이 가격 차이가 큽니다.'
+ when target.display_name like '%수원시 팔달구%' then '팔달구는 수원역·매교·화서 등 원도심 정비와 광역교통이 핵심입니다. 재개발 입주 이후 전세 흡수와 상권 회복을 함께 봐야 합니다.'
+ when target.display_name like '%수원시 장안구%' then '장안구는 북수원 생활권과 GTX-C 기대, 노후 주거지 개선 이슈가 맞물립니다. 교통 기대는 장기 변수이고 단지별 연식과 학교·상권 접근성이 체감을 가릅니다.'
+ when target.display_name like '%수원시 권선구%' then '권선구는 서수원 개발과 산업·물류 접근성이 주요 변수입니다. 신규 택지와 기존 구축 주거지의 가격 논리가 달라 평균 지표를 그대로 쓰기 어렵습니다.'
+ when target.display_name like '%고양시%' then '고양은 일산 1기 신도시 정비와 고양창릉 3기 신도시가 동시에 존재합니다. 일산동·서구는 노후계획도시 정비 단계가 핵심이고, 덕양구는 창릉 공급과 서울 서북권 접근성을 따로 봐야 합니다.'
+ when target.display_name like '%안양시 동안구%' then '동안구는 평촌 1기 신도시 정비 기대와 범계·평촌 학원가 수요가 결합됩니다. 사업 단계가 빨라질수록 기대는 커지지만 이주와 전세 부담도 같이 봐야 합니다.'
+ when target.display_name like '%안양시 만안구%' then '만안구는 안양 원도심 정비와 관악·광명·군포 접근성이 핵심입니다. 평촌 기대를 그대로 가져오기보다 정비구역별 사업성과 역세권 생활권을 따로 봐야 합니다.'
+ when target.display_name like '%군포시%' then '군포는 산본 1기 신도시 선도지구 특별정비구역 지정 흐름이 가장 직접적인 이슈입니다. 경기도는 산본 선도지구를 시작으로 노후계획도시 정비가 본격화됐다고 설명하지만, 실제 주거 판단은 분담금·이주·통합심의 속도에 달려 있습니다.'
+ when target.display_name like '%부천시%' then '부천은 중동 1기 신도시 정비와 부천대장 3기 신도시 공급을 같이 봐야 합니다. 부천대장은 서울 서부와 김포공항·마곡 인접성이 강점이지만, 중동 1기 신도시는 노후 단지 정비의 시간표가 가격 기대를 가릅니다.'
+ when target.display_name like '%남양주시%' then '남양주는 왕숙·왕숙2 3기 신도시와 다산·별내·진접 생활권이 겹칩니다. 왕숙은 대규모 공급과 자족 기능을 내세우지만 입주 전까지는 교통 체감과 기존 단지 전세 흐름을 분리해야 합니다.'
+ when target.display_name like '%하남시%' then '하남은 교산 3기 신도시, 미사 신도시, 강동·송파 접근성이 핵심입니다. 공급 기대와 서울 접근 수요가 동시에 강하지만 교산의 실제 입주 시간표가 체감 가격을 좌우합니다.'
+ when target.display_name like '%화성시%' then '화성은 동탄2, GTX-A, 삼성·반도체 배후 수요가 결합됩니다. 동탄은 자족도시 기대가 강하지만 대규모 입주와 광역교통 개통 시차가 전세와 매매를 다르게 흔들 수 있습니다.'
+ when target.display_name like '%평택시%' then '평택은 삼성전자 평택캠퍼스, 고덕국제신도시, 항만·미군기지 수요가 함께 움직입니다. 산업 배후 수요는 뚜렷하지만 공급이 많아 전세 흡수력을 먼저 봐야 합니다.'
+ when target.display_name like '%과천시%' then '과천은 준강남 입지와 재건축·지식정보타운 공급이 핵심입니다. 희소성과 서울 접근성은 강하지만 작은 표본의 신고가가 전체 시장 판단으로 과장될 수 있습니다.'
+ when target.display_name like '%광명시%' then '광명은 광명시흥 공공주택지구, 철산·하안 재건축, 서울 서남권 접근성이 겹칩니다. 공급 기대와 구축 정비 기대가 동시에 있어 단기 가격보다 사업 단계별 검증이 중요합니다.'
+ when target.display_name like '%김포시%' then '김포는 한강신도시, 서울 서부 접근성, 광역교통 개선 기대가 핵심입니다. 교통 호재는 기대 요인이지만 실제 통근 시간과 신규 공급 부담을 함께 확인해야 합니다.'
+ when target.display_name like '%파주시%' then '파주는 운정신도시와 GTX-A 기대가 중심입니다. 서울 접근성이 개선될수록 관심은 커지지만 대규모 신도시 공급과 전세 흡수 속도를 분리해야 합니다.'
+ when target.display_name like '%시흥시%' then '시흥은 배곧·은계·장현 신도시와 시화·반월 산업 배후 수요가 같이 움직입니다. 월곶판교선 기대와 산업 수요가 있지만 생활권별 가격 차이가 큽니다.'
+ when target.display_name like '%안산시%' then '안산은 반월·시화 산업단지와 신안산선 기대, 노후 주거지 정비가 핵심입니다. 산업 회복과 교통 기대가 긍정적이나 구축 비중과 인구 흐름을 함께 봐야 합니다.'
+ when target.display_name like '%이천시%' then '이천은 SK하이닉스와 물류·산업 수요가 주거 시장을 설명하는 핵심입니다. 산업 뉴스가 임대차 수요로 이어지는 힘은 있지만 외곽 신규 공급과 통근권 경쟁을 확인해야 합니다.'
+ when target.display_name like '%오산시%' then '오산은 동탄·평택 사이의 가격대 접근성과 세교 공급이 핵심입니다. 경기남부 산업축의 중간 입지지만 자체 일자리보다 주변 도시 의존도가 크다는 점을 봐야 합니다.'
+ when target.display_name like '%광주시%' then '광주는 판교·분당 배후 주거와 도로 접근성이 핵심입니다. 저밀도 주거와 신축 단지 수요가 있지만 대중교통 접근성과 난개발 체감을 함께 봐야 합니다.'
+ when target.display_name like '%구리시%' then '구리는 서울 동북권 인접성과 갈매·토평 개발 기대가 핵심입니다. 작은 면적 안에서 공급·정비 뉴스가 가격에 빠르게 반영될 수 있어 표본 수를 봐야 합니다.'
+ when target.display_name like '%의왕시%' then '의왕은 인덕원·청계·백운밸리와 GTX-C 기대가 주요 변수입니다. 과천·평촌 사이 입지 장점이 있지만 신규 단지와 구축 단지의 가격 논리가 다릅니다.'
+ when target.display_name like '%의정부시%' then '의정부는 경기북부 행정·상업 중심성과 GTX-C 기대가 핵심입니다. 서울 북부 접근성은 장점이나 경기남부 산업축과 다른 수요 구조를 가진다는 점을 봐야 합니다.'
+ when target.display_name like '%양주시%' then '양주는 덕정 GTX-C 기대와 옥정·회천 신도시 공급이 핵심입니다. 교통 개선 기대는 크지만 공급 물량이 전세와 매매 흡수 속도를 가를 수 있습니다.'
+ when target.display_name like '%동두천시%' then '동두천은 경기북부 저가 주거지와 미군기지 이전 이후 도시재생 과제가 같이 놓입니다. 가격대는 접근 가능하지만 인구와 일자리 기반을 먼저 확인해야 합니다.'
+ when target.display_name like '%포천시%' then '포천은 경기북부 산업·군사·관광 수요가 섞인 외곽 생활권입니다. 개발 기대보다 상주 인구와 교통 접근성 개선 여부가 주거 판단의 핵심입니다.'
+ when target.display_name like '%연천군%' then '연천은 접경·관광·군 관련 수요가 중심인 저밀도 생활권입니다. 단기 거래 표본이 적어 한두 건의 가격을 시장 방향으로 읽지 않아야 합니다.'
+ when target.display_name like '%가평군%' then '가평은 관광·세컨드하우스·전원주택 수요가 강한 지역입니다. 외부 수요가 가격을 흔들 수 있지만 상주 수요와 임대차 안정성은 따로 봐야 합니다.'
+ when target.display_name like '%양평군%' then '양평은 전원주택·세컨드하우스·수도권 동부 접근성이 핵심입니다. 교통 개선 기대와 생활인구 수요가 있으나 실거주 편의와 의료·교육 접근성을 확인해야 합니다.'
+ when target.display_name like '%여주시%' then '여주는 수도권 동남부 외곽의 물류·전원 주거 수요가 중심입니다. 가격대는 접근 가능하지만 거래 표본과 신규 공급을 함께 봐야 합니다.'
+ when target.display_name like '%안성시%' then '안성은 평택·용인 산업축의 배후와 자체 물류·제조 수요가 섞입니다. 산업 뉴스는 기대 요인이지만 통근권 경쟁과 공급 흡수를 같이 봐야 합니다.'
+ when target.display_name like '%인천광역시 계양구%' then '계양구는 계양테크노밸리 3기 신도시가 핵심입니다. 인천시 중장기 계획은 계양구 귤현·동양·박촌·병방·상야동 일원의 약 335만㎡, 17천호 계획을 제시했고, 국토교통부는 A2·A3 1106호 분양과 2026년 입주 목표를 공개했습니다.'
+ when target.display_name like '%인천광역시 서구%' then '서구는 검단신도시와 청라, 루원·가정 원도심 재편이 한 화면에 있습니다. 검단 공급은 가격 안정 요인이 될 수 있지만 청라·검단·원도심의 수요 성격은 다릅니다.'
+ when target.display_name like '%인천광역시 연수구%' then '연수구는 송도국제도시와 GTX-B 기대, 바이오·국제업무 수요가 핵심입니다. 고급 주거 수요는 강하지만 국제업무·상업 공실과 주거 가격을 같은 방향으로 단정하면 안 됩니다.'
+ when target.display_name like '%인천광역시 중구%' then '중구는 영종·공항경제권과 인천 원도심이 함께 있는 이중 구조입니다. 공항·관광 회복은 기대 요인이지만 영종과 내륙 원도심의 전세·매매 흐름은 다르게 봐야 합니다.'
+ when target.display_name like '%인천광역시 부평구%' then '부평구는 GTX-B 기대, 부평역 상권, 원도심 재개발이 핵심입니다. 서울 접근 개선 기대가 강하지만 구축 비중과 정비 단계가 체감을 나눕니다.'
+ when target.display_name like '%인천광역시 남동구%' then '남동구는 구월2지구 기대, 남동산단 배후, 만수·간석 원도심 정비가 같이 놓입니다. 산업·행정 수요는 안정 요인이지만 노후 단지 개선 속도를 따로 봐야 합니다.'
+ when target.display_name like '%인천광역시 미추홀구%' or target.display_name like '%인천광역시 남구%' then '미추홀구는 용현·학익, 주안·도화 원도심 정비가 중심입니다. 신도시보다 정비사업 단계와 전세 안정성이 더 중요한 권역입니다.'
+ when target.display_name like '%인천광역시 동구%' then '동구는 항만·원도심·노후 주거지가 중심입니다. 대형 신도시 호재보다 도시재생, 생활SOC, 산업 배후 수요가 주거 체감을 좌우합니다.'
+ when target.display_name like '%인천광역시 강화군%' then '강화군은 관광·전원주택·군 단위 생활권 수요가 핵심입니다. 인천 신도시 공급 논리와 분리해 상주 수요, 교통, 생활 인프라를 봐야 합니다.'
+ when target.display_name like '%인천광역시 옹진군%' then '옹진군은 섬 지역 생활권과 관광·어업 수요가 중심입니다. 거래 표본이 적어 가격 데이터의 신뢰구간을 보수적으로 봐야 합니다.'
+ else concat(target.display_name, '는 수도권 광역 흐름을 받지만, 실제 판단은 지역별 교통·일자리·공급 일정으로 좁혀야 합니다.')
+ end as local_signal,
+ case
+ when target.display_name like '경기도%' or target.display_name = '경기도' then '정책 신호는 2026년 수도권 공공택지 5만호 이상 착공, 3기 신도시 입주 시작, 경기도 1기 신도시 정비기본계획 승인, 용인 반도체(첨단시스템반도체 국가산단) 축으로 나뉩니다.'
+ else '정책 신호는 인천계양 3기 신도시의 17천호 계획, A2·A3 1106호 공공분양과 2026년 입주 목표, 검단·청라·송도 공급·개발축, 원도심 정비 속도 차이로 나뉩니다.'
+ end as policy_signal,
+ case
+ when target.display_name like '%계양구%' then '계양테크노밸리 공급이 서울 서부권 수요와 인천 원도심 수요를 동시에 흡수할 수 있는 점'
+ when target.display_name like '%처인구%' then '첨단시스템반도체 국가산단이 장기 일자리와 주거 수요의 근거가 될 수 있는 점'
+ when target.display_name like '%부천시%' then '부천대장 3기 신도시와 중동 1기 신도시 정비를 함께 비교할 수 있는 점'
+ when target.display_name like '%분당구%' or target.display_name like '%일산%' or target.display_name like '%동안구%' or target.display_name like '%군포시%' then '1기 신도시 정비가 실제 특별정비계획과 이주 계획으로 이어질 수 있는 점'
+ when target.display_name like '%송도%' or target.display_name like '%연수구%' then '국제업무·바이오·GTX-B 기대가 주거 선호를 받칠 수 있는 점'
+ else '교통·일자리·공급 근거가 실거래와 전세 데이터로 검증될 수 있는 점'
+ end as expectation_theme,
+ case
+ when target.display_name like '%계양구%' then '입주 전 기대가 주변 구축 가격에 먼저 반영되고 공급 흡수 시차가 생길 수 있다는 점'
+ when target.display_name like '%처인구%' then '산업 뉴스가 실제 고용·임대차 수요로 연결되기까지 시간이 걸릴 수 있다는 점'
+ when target.display_name like '%부천시%' then '부천대장 공급 기대와 중동 1기 신도시 정비 기대가 서로 다른 시간표로 움직인다는 점'
+ when target.display_name like '%분당구%' or target.display_name like '%일산%' or target.display_name like '%동안구%' or target.display_name like '%군포시%' then '정비 기대가 분담금·이주·사업시행인가 검증보다 먼저 가격에 반영될 수 있다는 점'
+ when target.display_name like '%인천광역시%' then '신도시와 원도심의 수요 격차가 커 광역 평균이 체감을 가릴 수 있다는 점'
+ else '개발 기대와 신규 공급 부담, 전세 흡수력이 서로 다른 시차로 움직일 수 있다는 점'
+ end as concern_theme
+ from real_estate_regions region
+ join real_estate_targets target on target.id = region.target_id
+ left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+ where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or target.display_name like '인천광역시%'
+ or parent_target.display_name in ('경기도', '인천광역시')
+ or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000')
+ )
+) researched
+left join real_estate_regional_reports existing on existing.target_id = researched.target_id
+on duplicate key update
+ report_id = values(report_id),
+ report_version = values(report_version),
+ prompt_version = values(prompt_version),
+ model_name = values(model_name),
+ generated_by = values(generated_by),
+ title = values(title),
+ headline = values(headline),
+ summary = values(summary),
+ body = values(body),
+ expectation_points_json = values(expectation_points_json),
+ concern_points_json = values(concern_points_json),
+ data_quality = values(data_quality),
+ confidence = values(confidence),
+ as_of = values(as_of),
+ published_at = values(published_at),
+ updated_at = values(updated_at);
+
+delete from real_estate_regional_report_sources
+where report_target_id in (
+ select region.target_id
+ from real_estate_regions region
+ join real_estate_targets target on target.id = region.target_id
+ left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+ where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or target.display_name like '인천광역시%'
+ or parent_target.display_name in ('경기도', '인천광역시')
+ or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000')
+ )
+);
+
+insert into real_estate_regional_report_sources (
+ id,
+ report_target_id,
+ display_order,
+ ref_type,
+ ref_id,
+ label,
+ title,
+ url,
+ source_name,
+ published_at,
+ data_status,
+ created_at,
+ updated_at
+)
+select
+ concat('regional-report-source-', region.target_id, '-capital-01-kb-report'),
+ region.target_id,
+ 1,
+ 'external',
+ 'kb-2026-real-estate-report',
+ '금융권 리포트',
+ '2026 KB 부동산 보고서',
+ 'https://www.kbfg.com/kbresearch/brand/brandView.do?reportId=2000492',
+ 'KB경영연구소',
+ '2026-05-05 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or target.display_name like '인천광역시%'
+ or parent_target.display_name in ('경기도', '인천광역시')
+ or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-capital-02-kdi-market'),
+ region.target_id,
+ 2,
+ 'external',
+ 'kdi-2026-q1-real-estate-market',
+ '시장 동향',
+ 'KDI 경제정보센터 2026년 1분기 부동산시장동향',
+ 'https://eiec.kdi.re.kr/policy/domesticView.do?ac=0000204646&depth1=&issus=&pg=&pp=&search_txt=&type=',
+ 'KDI 경제정보센터',
+ '2026-06-01 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or target.display_name like '인천광역시%'
+ or parent_target.display_name in ('경기도', '인천광역시')
+ or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-capital-03-cerik'),
+ region.target_id,
+ 3,
+ 'external',
+ 'cerik-2026-q1-real-estate-market',
+ '건설산업 리포트',
+ '한국건설산업연구원 2026년 1분기 부동산시장동향',
+ 'https://www.cerik.re.kr/uploads/report/3077/%EB%B6%80%EB%8F%99%EC%82%B0%EC%8B%9C%EC%9E%A5%EB%8F%99%ED%96%A5%202026%EB%85%84%201%EB%B6%84%EA%B8%B0_2026-04-13.pdf',
+ '한국건설산업연구원',
+ '2026-04-13 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or target.display_name like '인천광역시%'
+ or parent_target.display_name in ('경기도', '인천광역시')
+ or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-capital-04-rone'),
+ region.target_id,
+ 4,
+ 'external',
+ 'reb-rone-2026-05-national-housing-price-trend',
+ '공식 가격지표',
+ '한국부동산원 R-ONE 2026년 5월 전국주택가격동향',
+ 'https://www.reb.or.kr/r-one/portal/main/indexPage.do',
+ '한국부동산원',
+ '2026-06-15 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or target.display_name like '인천광역시%'
+ or parent_target.display_name in ('경기도', '인천광역시')
+ or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-capital-05-molit-2026-supply'),
+ region.target_id,
+ 5,
+ 'external',
+ 'molit-2026-capital-public-land-50000',
+ '수도권 공급정책',
+ '정책브리핑 내년 수도권 공공택지 5만가구 이상 착공',
+ 'https://www.korea.kr/news/policyNewsView.do?newsId=148956384',
+ '국토교통부',
+ '2025-12-12 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or target.display_name like '인천광역시%'
+ or parent_target.display_name in ('경기도', '인천광역시')
+ or region.target_id in ('region-gyeonggi', 'region-41000', 'region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-gyeonggi-06-first-newtown'),
+ region.target_id,
+ 6,
+ 'external',
+ 'gyeonggi-first-newtown-master-plan-20250528',
+ '경기 1기 신도시',
+ '경기도 분당·일산 기본계획 승인, 1기 신도시 5곳 정비 본격화',
+ 'https://gnews.gg.go.kr/briefing/brief_gongbo_view.do?BS_CODE=s017&number=66080',
+ '경기도뉴스포털',
+ '2025-05-28 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or parent_target.display_name = '경기도'
+ or region.target_id in ('region-gyeonggi', 'region-41000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-gyeonggi-07-sanbon-special'),
+ region.target_id,
+ 7,
+ 'external',
+ 'gyeonggi-sanbon-special-renewal-zone-20260113',
+ '경기 정비사업',
+ '경기도 1기 신도시 선도지구 특별정비구역 첫 지정 군포·산본',
+ 'https://gnews.gg.go.kr/briefing/brief_gongbo_view.do?BS_CODE=s017&number=68984',
+ '경기도뉴스포털',
+ '2026-01-13 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or parent_target.display_name = '경기도'
+ or region.target_id in ('region-gyeonggi', 'region-41000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-gyeonggi-08-third-newtown'),
+ region.target_id,
+ 8,
+ 'external',
+ 'third-newtown-official-notice-2026',
+ '3기 신도시',
+ '3기 신도시 고시·공고 목록',
+ 'https://www.xn--3-3u6ey6lv7rsa.kr/kor/CMS/Board/Board.do?mCode=MN111',
+ '한국토지주택공사',
+ '2026-05-21 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or parent_target.display_name = '경기도'
+ or region.target_id in ('region-gyeonggi', 'region-41000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-gyeonggi-09-yongin-semicon'),
+ region.target_id,
+ 9,
+ 'external',
+ 'molit-yongin-semiconductor-national-industrial-complex',
+ '경기 산업축',
+ '국토교통부 용인 반도체 국가산업단지 지정',
+ 'https://www.molit.go.kr/USR/NEWS/m_71/dtl.jsp?id=95090534&lcmspage=7',
+ '국토교통부',
+ '2024-12-26 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or parent_target.display_name = '경기도'
+ or region.target_id in ('region-gyeonggi', 'region-41000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-gyeonggi-10-bucheon-daejang'),
+ region.target_id,
+ 10,
+ 'external',
+ 'third-newtown-bucheon-daejang-overview',
+ '부천대장 근거',
+ '3기 신도시 부천대장 지구개요',
+ 'https://www.xn--3-3u6ey6lv7rsa.kr/kor/CMS/Contents/Contents.do?mCode=MN068',
+ '한국토지주택공사',
+ null,
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '경기도%'
+ or parent_target.display_name = '경기도'
+ or region.target_id in ('region-gyeonggi', 'region-41000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-incheon-06-capital-plan'),
+ region.target_id,
+ 6,
+ 'external',
+ 'incheon-capital-housing-supply-plan-gyeyang-tv',
+ '인천 공급계획',
+ '인천광역시 수도권 주택공급 계획',
+ 'https://www.incheon.go.kr/open/OPEN020301/view?attachFileSeq=FILE_000000000062056&curPage=9&planSeq=DOM_0000000000099714',
+ '인천광역시',
+ '2025-08-28 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '인천광역시%'
+ or parent_target.display_name = '인천광역시'
+ or region.target_id in ('region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-incheon-07-gyeyang-1106'),
+ region.target_id,
+ 7,
+ 'external',
+ 'molit-incheon-gyeyang-1106-public-sale',
+ '계양 공급',
+ '정책브리핑 인천계양 공공주택 1106호 분양',
+ 'https://www.korea.kr/news/policyNewsView.do?newsId=148933414',
+ '국토교통부',
+ '2024-09-03 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '인천광역시%'
+ or parent_target.display_name = '인천광역시'
+ or region.target_id in ('region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-incheon-08-gyeyang-change'),
+ region.target_id,
+ 8,
+ 'external',
+ 'third-newtown-incheon-gyeyang-plan-change-20260105',
+ '계양 지구계획',
+ '3기 신도시 인천계양테크노밸리 지구계획 변경 승인',
+ 'https://xn--3-3u6ey6lv7rsa.kr/kor/CMS/Board/Board.do?board_seq=445&mCode=MN111&mgr_seq=13&mode=view',
+ '한국토지주택공사',
+ '2026-01-05 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '인천광역시%'
+ or parent_target.display_name = '인천광역시'
+ or region.target_id in ('region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-incheon-09-ih-news'),
+ region.target_id,
+ 9,
+ 'external',
+ 'incheon-housing-city-corporation-news-2026',
+ '인천 개발사업',
+ '인천도시공사 보도자료 목록',
+ 'https://www.ih.co.kr/main/public/news_data.jsp',
+ '인천도시공사',
+ '2026-06-22 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '인천광역시%'
+ or parent_target.display_name = '인천광역시'
+ or region.target_id in ('region-incheon', 'region-28000')
+ )
+union all
+select
+ concat('regional-report-source-', region.target_id, '-incheon-10-third-newtown'),
+ region.target_id,
+ 10,
+ 'external',
+ 'third-newtown-official-notice-2026',
+ '3기 신도시',
+ '3기 신도시 고시·공고 목록',
+ 'https://www.xn--3-3u6ey6lv7rsa.kr/kor/CMS/Board/Board.do?mCode=MN111',
+ '한국토지주택공사',
+ '2026-05-21 00:00:00',
+ 'ok',
+ '2026-06-24 23:45:00',
+ '2026-06-24 23:45:00'
+from real_estate_regions region
+join real_estate_targets target on target.id = region.target_id
+left join real_estate_targets parent_target on parent_target.id = region.parent_region_id
+where region.region_level in ('sido', 'sigungu')
+ and (
+ target.display_name like '인천광역시%'
+ or parent_target.display_name = '인천광역시'
+ or region.target_id in ('region-incheon', 'region-28000')
+ );
diff --git a/backend/src/main/resources/db/migration/V55__real_estate_nationwide_deep_research_reports.sql b/backend/src/main/resources/db/migration/V55__real_estate_nationwide_deep_research_reports.sql
new file mode 100644
index 0000000..05cb7b4
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V55__real_estate_nationwide_deep_research_reports.sql
@@ -0,0 +1,339 @@
+insert into real_estate_regional_reports (
+ target_id,
+ report_id,
+ report_version,
+ prompt_version,
+ model_name,
+ generated_by,
+ title,
+ headline,
+ summary,
+ body,
+ expectation_points_json,
+ concern_points_json,
+ data_quality,
+ confidence,
+ as_of,
+ published_at,
+ created_at,
+ updated_at
+)
+select
+ researched.target_id,
+ concat('regional-report-', researched.target_id, '-deep-national-20260624'),
+ 'regional-report-deep-research-v1',
+ 'codex-national-regional-deep-research-prompt-20260624',
+ 'codex-gpt-5-deep-research',
+ 'codex-deep-research:national-regional-20260624',
+ concat(researched.display_name, ' 심화 지역 리포트'),
+ researched.headline,
+ researched.summary,
+ concat(
+ researched.overview, ' ',
+ researched.local_signal, ' ',
+ '공통 시장 배경은 KB·KDI·주택산업연구원·건설산업연구원 자료가 함께 말하는 2026년의 양극화입니다. 수도권은 수요 회복과 전세 압력이 강하고, 지방은 공급 부담·미분양·산업 경기·생활인구 변화가 지역별로 다르게 작동합니다. ',
+ researched.policy_signal, ' ',
+ '기대 지점은 ', researched.expectation_theme, '입니다. 우려 지점은 ', researched.concern_theme, '입니다. ',
+ '지도 기간별 등락률은 단기 보조 신호로만 쓰고, 이 본문은 최신 공식 통계·정책·지역 이슈가 갱신되면 같은 target의 최신본으로 다시 교체되는 저장 리포트입니다.'
+ ),
+ concat(
+ '["',
+ researched.expectation_theme, '",',
+ '"실거래·전세·공급·정책 근거를 같은 지역 target에 붙여 이후 AI API 자동 갱신의 기준점으로 쓸 수 있습니다.",',
+ '"지역별 산업·교통·생활인구 이슈를 가격 방향의 단정이 아니라 관찰 근거로 분리했습니다."]'
+ ),
+ concat(
+ '["',
+ researched.concern_theme, '",',
+ '"미분양·입주·정비·산업 뉴스가 실제 거래와 전세에 반영되는 시차를 확인해야 합니다.",',
+ '"확인되지 않은 단기 뉴스나 개발 기대를 매수·매도 판단처럼 쓰지 않습니다."]'
+ ),
+ 'deep_researched',
+ case
+ when researched.region_level = 'sido' then 0.82
+ else 0.78
+ end,
+ '2026-06-24 00:00:00',
+ '2026-06-24 23:55:00',
+ coalesce(existing.created_at, '2026-06-24 23:55:00'),
+ '2026-06-24 23:55:00'
+from (
+ select
+ region.target_id,
+ region.region_level,
+ target.display_name,
+ case
+ when target.display_name like '부산광역시%' then concat(target.display_name, '는 북항·가덕도·동부산 선호와 원도심 정비를 분리해 봐야 하는 해양도시 생활권입니다.')
+ when target.display_name like '대구광역시%' then concat(target.display_name, '는 미분양 해소와 준공 후 미분양, 신공항·산업 기대를 동시에 봐야 하는 공급 부담 생활권입니다.')
+ when target.display_name like '광주광역시%' then concat(target.display_name, '는 첨단산단·도심 정비·전세 수요를 함께 읽어야 하는 호남권 핵심 생활권입니다.')
+ when target.display_name like '대전광역시%' then concat(target.display_name, '는 연구개발·공공기관 수요와 세종 연계, 도안권 공급을 함께 봐야 하는 충청권 핵심 생활권입니다.')
+ when target.display_name like '울산광역시%' then concat(target.display_name, '는 자동차·조선·석유화학 경기와 주거 수요가 밀접한 산업도시 생활권입니다.')
+ when target.display_name like '세종특별자치시%' then concat(target.display_name, '는 행정수도 기능과 5생활권 공급이 동시에 움직이는 정책도시 생활권입니다.')
+ when target.display_name like '강원도%' then concat(target.display_name, '는 관광·생활인구·혁신도시·광역교통 기대를 나눠 봐야 하는 강원권 생활권입니다.')
+ when target.display_name like '충청북도%' then concat(target.display_name, '는 청주·오송·오창 산업축과 중부내륙 생활권을 분리해야 하는 충북권 생활권입니다.')
+ when target.display_name like '충청남도%' then concat(target.display_name, '는 천안·아산 수도권 연계와 서해안 산업벨트를 같이 봐야 하는 충남권 생활권입니다.')
+ when target.display_name like '전라북도%' then concat(target.display_name, '는 전주 생활수요와 새만금·군산·익산 산업축을 함께 확인해야 하는 전북권 생활권입니다.')
+ when target.display_name like '전라남도%' then concat(target.display_name, '는 여수·순천·광양 산업축과 목포·나주·해안 관광권을 분리해야 하는 전남권 생활권입니다.')
+ when target.display_name like '경상북도%' then concat(target.display_name, '는 포항·구미 산업축과 대구경북신공항·관광 생활권이 다른 속도로 움직이는 경북권 생활권입니다.')
+ when target.display_name like '경상남도%' then concat(target.display_name, '는 창원 제조업, 김해·양산 부산권 연계, 거제·통영 조선·관광 흐름을 나눠 봐야 하는 경남권 생활권입니다.')
+ when target.display_name like '제주특별자치도%' then concat(target.display_name, '는 관광·이주·건설경기·주택 통계를 함께 봐야 하는 섬 생활권입니다.')
+ else concat(target.display_name, '는 광역 흐름과 자체 생활권 수요를 나눠 봐야 하는 지역입니다.')
+ end as headline,
+ concat(target.display_name, '는 최신 시장 리포트, 공식 가격·거래 원천, 지역 정책 자료를 함께 엮어 읽는 최신 기준 지역 리포트입니다.') as summary,
+ case
+ when target.display_name like '부산광역시%' then '부산권의 핵심은 북항 재개발, 가덕도 신공항, 해운대·수영·동래의 동부산 선호, 서부산·원도심 정비가 같은 도시 안에서 다른 속도로 움직인다는 점입니다. 부산시는 북항을 유라시아 게이트웨이와 국제해양관광 거점으로 조성하고 원도심 기능 회복을 도모한다고 설명합니다.'
+ when target.display_name like '대구광역시%' then '대구권의 핵심은 미분양 총량이 줄어드는 신호와 준공 후 미분양 부담, 수성구·중구 핵심지와 외곽 공급지의 양극화입니다. 대구시는 2026년 1월부터 4월까지 공동주택 미분양 현황을 월별로 공개하고 있어, 이 지역은 가격보다 미분양의 질과 입주 부담을 먼저 봐야 합니다.'
+ when target.display_name like '세종특별자치시%' then '세종권의 핵심은 행정수도 기대가 다시 커지는 가운데 5생활권 공급이 실제 수요를 시험한다는 점입니다. 행복청과 세종시는 2026년 행복도시 4-2·5-1·5-2생활권에 총 4740호, 이 중 분양주택 4225호 공급 계획을 공개했습니다.'
+ when target.display_name like '제주특별자치도%' then '제주권의 핵심은 관광 회복과 이주 수요, 건설경기 둔화, 주택 통계의 작은 표본성이 함께 존재한다는 점입니다. 제주 주택 통계는 월별로 공개되지만 제주시와 서귀포, 읍면 지역의 체감은 크게 다를 수 있습니다.'
+ when target.display_name like '강원도%' then '강원권은 춘천·원주 생활수요, 강릉·속초 관광·세컨드하우스, 폐광·접경 지역의 인구 과제가 동시에 있는 지역입니다. 생활인구 기대와 상주 수요를 분리하지 않으면 가격 해석이 흔들립니다.'
+ when target.display_name like '충청북도%' then '충북권은 청주·오송·오창의 바이오·반도체·철도 접근성과 충주·제천·괴산·단양의 중부내륙 생활권이 다르게 움직입니다. 산업 뉴스는 기대 요인이지만 전세와 입주 수요로 확인해야 합니다.'
+ when target.display_name like '충청남도%' then '충남권은 천안·아산의 수도권 연계, 당진·서산의 산업벨트, 내포신도시와 서해안 관광권이 나뉘는 시장입니다. 산업 배후 수요와 신규 공급 흡수력을 같이 봐야 합니다.'
+ when target.display_name like '전라북도%' then '전북권은 전주권 생활수요, 새만금과 군산 산업축, 익산·완주 혁신도시 연계가 핵심입니다. 개발 기대가 실제 주거 수요로 바뀌는 속도를 보수적으로 봐야 합니다.'
+ when target.display_name like '전라남도%' then '전남권은 여수·순천·광양 산업축, 나주 혁신도시, 목포·무안·해남 해안권이 다르게 움직입니다. 산업도시와 군 단위 생활권은 같은 지표로 판단하기 어렵습니다.'
+ when target.display_name like '경상북도%' then '경북권은 포항·구미 제조업, 안동·예천 행정·신도시 수요, 경주 관광, 대구경북신공항 기대가 권역별로 다르게 작동합니다. 호재보다 실제 인구와 일자리 흐름이 중요합니다.'
+ when target.display_name like '경상남도%' then '경남권은 창원 제조업, 김해·양산 부산권 배후, 거제·통영 조선·관광, 진주·사천 항공우주 흐름을 나눠 봐야 합니다. 산업 회복은 기대지만 지역별 인구 체력은 다릅니다.'
+ else concat(target.display_name, '의 핵심은 공식 가격지표, 실거래, 전세, 공급 일정이 서로 같은 방향으로 움직이는지 확인하는 것입니다.')
+ end as overview,
+ case
+ when target.display_name like '%해운대%' then '해운대는 부산 안에서도 해안 주거 선호와 마린시티·센텀 업무, 관광 수요가 결합된 대표 권역입니다. 북항 개발 기대가 부산 전체 분위기를 만들더라도 해운대 가격은 동부산 고급 주거와 전세 수요로 따로 움직일 수 있습니다.'
+ when target.display_name like '%수영%' then '수영구는 광안리 해안 상권과 남천·수영 주거 선호가 강한 권역입니다. 관광·상권 뉴스와 상주 주거 수요를 구분해야 합니다.'
+ when target.display_name like '%동구%' and target.display_name like '부산광역시%' then '부산 동구는 북항·부산역·원도심 재생의 직접 영향권입니다. 기대는 크지만 상업·업무 개발과 아파트 실수요가 같은 속도로 움직이지 않을 수 있습니다.'
+ when target.display_name like '%강서구%' and target.display_name like '부산광역시%' then '부산 강서구는 에코델타, 산업단지, 가덕도 신공항 기대가 겹칩니다. 장기 개발축은 분명하지만 입주와 교통 체감 시차를 확인해야 합니다.'
+ when target.display_name like '%수성구%' then '수성구는 대구 핵심 학군·주거 선호가 남아 있는 지역입니다. 다만 대구 전체의 미분양 부담과 고분양가 흐름이 완전히 사라진 것은 아니어서, 수성구 프리미엄과 외곽 부담을 함께 봐야 합니다.'
+ when target.display_name like '%달서구%' then '달서구는 월배·상인·죽전 생활권과 산업·상업 수요가 섞입니다. 대구 미분양이 줄어도 준공 후 물량과 구축 단지 가격 차이가 남을 수 있습니다.'
+ when target.display_name like '%달성군%' then '달성군은 테크노폴리스·산업단지·신공항 기대가 있지만 외곽 공급 부담도 함께 있습니다. 산업 뉴스보다 실제 통근과 입주 흡수력을 봐야 합니다.'
+ when target.display_name like '%세종%' then '세종은 행정수도 기능, 5생활권 분양 4225호, 공공임대 515호가 같이 움직입니다. 공급이 다시 열리는 만큼 기대보다 청약·입주 흡수력을 먼저 확인해야 합니다.'
+ when target.display_name like '%제주시%' then '제주시는 행정·상업·공항 접근성이 강하지만 관광과 이주 수요가 동시에 가격을 흔듭니다. 월별 주택 통계와 거래 표본을 함께 봐야 합니다.'
+ when target.display_name like '%서귀포%' then '서귀포는 관광·휴양·농촌 생활권 수요가 중심입니다. 외부 수요가 커질 수 있지만 상주 수요와 임대차 안정성은 제주시와 다릅니다.'
+ when target.display_name like '%춘천%' then '춘천은 수도권 접근성과 행정·대학 수요, 관광 수요가 섞입니다. 교통 개선 기대와 실제 통근 수요를 나눠 봐야 합니다.'
+ when target.display_name like '%원주%' then '원주는 혁신도시·기업도시와 의료·교육 수요가 핵심입니다. 강원권 안에서도 상주 수요 기반이 상대적으로 뚜렷합니다.'
+ when target.display_name like '%강릉%' or target.display_name like '%속초%' then '영동권은 관광·세컨드하우스 수요가 강하지만 외부 수요 의존도가 큽니다. 계절성과 상주 수요를 분리해야 합니다.'
+ when target.display_name like '%청주%' then '청주는 오송·오창 산업축과 충북의 중심 생활수요가 결합됩니다. 산업 기대는 강하지만 분양·입주 부담을 같이 봐야 합니다.'
+ when target.display_name like '%천안%' or target.display_name like '%아산%' then '천안·아산은 수도권 남부와 충남 산업축을 잇는 대표 생활권입니다. 광역교통과 일자리 수요가 전세와 매매를 같이 받치는지 확인해야 합니다.'
+ when target.display_name like '%전주시%' then '전주는 전북 핵심 주거 수요와 혁신도시 연계가 중심입니다. 새만금 기대와 전주 생활수요를 같은 결론으로 묶지 않는 것이 중요합니다.'
+ when target.display_name like '%군산%' then '군산은 새만금과 산업단지 기대가 핵심입니다. 산업 뉴스가 주택 수요로 전환되는 속도를 확인해야 합니다.'
+ when target.display_name like '%여수%' or target.display_name like '%순천%' or target.display_name like '%광양%' then '여수·순천·광양은 산업단지와 관광·생활권이 같이 움직이는 전남 핵심축입니다. 산업 경기와 상주 수요를 함께 봐야 합니다.'
+ when target.display_name like '%포항%' then '포항은 철강·배터리·해양산업 기대와 지진 이후 주거 안정성 인식이 함께 작동합니다. 산업 회복과 인구 흐름을 같이 확인해야 합니다.'
+ when target.display_name like '%구미%' then '구미는 전자·첨단산업 기반이 강하지만 산업 경기 변동에 민감합니다. 일자리 회복이 전세와 매매 수요로 확인되는지가 핵심입니다.'
+ when target.display_name like '%창원%' then '창원은 제조업과 노후 주거지 정비, 특례시 생활권 수요가 핵심입니다. 산업 회복과 구축 단지 개선 속도를 같이 봐야 합니다.'
+ when target.display_name like '%김해%' or target.display_name like '%양산%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞입니다. 부산 접근성 기대와 신규 공급 부담을 분리해야 합니다.'
+ when target.display_name like '%거제%' or target.display_name like '%통영%' then '거제·통영은 조선업과 관광 수요에 민감합니다. 업황 회복이 주거 수요 안정으로 이어지는지 확인해야 합니다.'
+ else concat(target.display_name, '는 해당 광역권 안에서도 실거래 표본, 전세 흐름, 공급 일정, 생활권 수요를 따로 확인해야 합니다.')
+ end as local_signal,
+ case
+ when target.display_name like '부산광역시%' then '정책·이슈 신호는 부산 북항 재개발, 가덕도 신공항, 원도심 재생, 동부산 주거 선호로 나뉩니다.'
+ when target.display_name like '대구광역시%' then '정책·이슈 신호는 대구시 월별 미분양 자료, 준공 후 미분양 부담, 신공항·산업 기대, 수성구 핵심지 선호로 나뉩니다.'
+ when target.display_name like '세종특별자치시%' then '정책·이슈 신호는 행정수도 기능, 5-1·5-2생활권 공급, 2026년 행복도시 4740호 공급 계획으로 나뉩니다.'
+ when target.display_name like '제주특별자치도%' then '정책·이슈 신호는 제주 주택 통계, 관광 회복, 건설경기 회복 정책, 제주시·서귀포 수요 차이로 나뉩니다.'
+ else '정책·이슈 신호는 광역 교통, 산업단지, 도심 정비, 미분양·입주 부담, 생활인구 변화로 나뉩니다.'
+ end as policy_signal,
+ case
+ when target.display_name like '부산광역시%' then '북항·가덕도·동부산 선호가 실거래와 전세에서 따로 검증될 수 있는 점'
+ when target.display_name like '대구광역시%' then '미분양 부담이 실제로 줄고 핵심 생활권 수요가 먼저 회복되는지 확인할 수 있는 점'
+ when target.display_name like '세종특별자치시%' then '행정수도 기대와 5생활권 공급이 청약·입주 수요로 검증될 수 있는 점'
+ when target.display_name like '제주특별자치도%' then '관광 회복과 주택 통계를 함께 보며 외부 수요와 상주 수요를 나눠 볼 수 있는 점'
+ else '지역의 산업·교통·생활권 변화가 공식 가격지표와 실거래에서 확인될 수 있는 점'
+ end as expectation_theme,
+ case
+ when target.display_name like '부산광역시%' then '개발 기대가 원도심·동부산·서부산의 실제 주거 수요 차이를 가릴 수 있다는 점'
+ when target.display_name like '대구광역시%' then '미분양 감소만 보고 준공 후 미분양과 고분양가 부담을 과소평가할 수 있다는 점'
+ when target.display_name like '세종특별자치시%' then '정책 기대가 공급 흡수력보다 먼저 가격에 반영될 수 있다는 점'
+ when target.display_name like '제주특별자치도%' then '관광·이주 수요가 상주 주거 수요와 다르게 움직일 수 있다는 점'
+ else '단일 개발 뉴스가 실제 거래와 전세 회복보다 먼저 가격 기대를 만들 수 있다는 점'
+ end as concern_theme
+ from real_estate_regions region
+ join real_estate_targets target on target.id = region.target_id
+ join real_estate_regional_reports existing on existing.target_id = region.target_id
+ where region.region_level in ('sido', 'sigungu')
+ and existing.data_quality <> 'deep_researched'
+) researched
+left join real_estate_regional_reports existing on existing.target_id = researched.target_id
+on duplicate key update
+ report_id = values(report_id),
+ report_version = values(report_version),
+ prompt_version = values(prompt_version),
+ model_name = values(model_name),
+ generated_by = values(generated_by),
+ title = values(title),
+ headline = values(headline),
+ summary = values(summary),
+ body = values(body),
+ expectation_points_json = values(expectation_points_json),
+ concern_points_json = values(concern_points_json),
+ data_quality = values(data_quality),
+ confidence = values(confidence),
+ as_of = values(as_of),
+ published_at = values(published_at),
+ updated_at = values(updated_at);
+
+delete from real_estate_regional_report_sources
+where report_target_id in (
+ select existing.target_id
+ from real_estate_regional_reports existing
+ where existing.generated_by = 'codex-deep-research:national-regional-20260624'
+);
+
+insert into real_estate_regional_report_sources (
+ id,
+ report_target_id,
+ display_order,
+ ref_type,
+ ref_id,
+ label,
+ title,
+ url,
+ source_name,
+ published_at,
+ data_status,
+ created_at,
+ updated_at
+)
+select
+ concat('regional-report-source-', report.target_id, '-national-01-kb-report'),
+ report.target_id,
+ 1,
+ 'external',
+ 'kb-2026-real-estate-report',
+ '금융권 리포트',
+ '2026 KB 부동산 보고서',
+ 'https://www.kbfg.com/kbresearch/brand/brandView.do?reportId=2000492',
+ 'KB경영연구소',
+ '2026-05-05 00:00:00',
+ 'ok',
+ '2026-06-24 23:55:00',
+ '2026-06-24 23:55:00'
+from real_estate_regional_reports report
+where report.generated_by = 'codex-deep-research:national-regional-20260624'
+union all
+select
+ concat('regional-report-source-', report.target_id, '-national-02-kdi-market'),
+ report.target_id,
+ 2,
+ 'external',
+ 'kdi-2026-q1-real-estate-market',
+ '시장 동향',
+ 'KDI 경제정보센터 2026년 1분기 부동산시장동향',
+ 'https://eiec.kdi.re.kr/policy/domesticView.do?ac=0000204646&depth1=&issus=&pg=&pp=&search_txt=&type=',
+ 'KDI 경제정보센터',
+ '2026-06-01 00:00:00',
+ 'ok',
+ '2026-06-24 23:55:00',
+ '2026-06-24 23:55:00'
+from real_estate_regional_reports report
+where report.generated_by = 'codex-deep-research:national-regional-20260624'
+union all
+select
+ concat('regional-report-source-', report.target_id, '-national-03-khi'),
+ report.target_id,
+ 3,
+ 'external',
+ 'khi-2026-housing-market-outlook',
+ '주택시장 전망',
+ '주택산업연구원 2026년 주택시장 전망',
+ 'https://www.khi.re.kr/info/info3.php?boardid=board4&idx=44&mode=view&offset=&sk=&sw=',
+ '주택산업연구원',
+ '2025-12-23 00:00:00',
+ 'ok',
+ '2026-06-24 23:55:00',
+ '2026-06-24 23:55:00'
+from real_estate_regional_reports report
+where report.generated_by = 'codex-deep-research:national-regional-20260624'
+union all
+select
+ concat('regional-report-source-', report.target_id, '-national-04-cerik'),
+ report.target_id,
+ 4,
+ 'external',
+ 'cerik-2026-q1-real-estate-market',
+ '건설산업 리포트',
+ '한국건설산업연구원 2026년 1분기 부동산시장동향',
+ 'https://www.cerik.re.kr/uploads/report/3077/%EB%B6%80%EB%8F%99%EC%82%B0%EC%8B%9C%EC%9E%A5%EB%8F%99%ED%96%A5%202026%EB%85%84%201%EB%B6%84%EA%B8%B0_2026-04-13.pdf',
+ '한국건설산업연구원',
+ '2026-04-13 00:00:00',
+ 'ok',
+ '2026-06-24 23:55:00',
+ '2026-06-24 23:55:00'
+from real_estate_regional_reports report
+where report.generated_by = 'codex-deep-research:national-regional-20260624'
+union all
+select
+ concat('regional-report-source-', report.target_id, '-national-05-rone'),
+ report.target_id,
+ 5,
+ 'external',
+ 'reb-rone-2026-05-national-housing-price-trend',
+ '공식 가격지표',
+ '한국부동산원 R-ONE 2026년 5월 전국주택가격동향',
+ 'https://www.reb.or.kr/r-one/portal/main/indexPage.do',
+ '한국부동산원',
+ '2026-06-15 00:00:00',
+ 'ok',
+ '2026-06-24 23:55:00',
+ '2026-06-24 23:55:00'
+from real_estate_regional_reports report
+where report.generated_by = 'codex-deep-research:national-regional-20260624'
+union all
+select
+ concat('regional-report-source-', report.target_id, '-national-06-molit-regional-policy'),
+ report.target_id,
+ 6,
+ 'external',
+ 'molit-2026-regional-policy-five-poles',
+ '지역정책 근거',
+ '정책브리핑 5극 3특 초광역권 및 지역 교통인프라 확충',
+ 'https://www.korea.kr/news/policyNewsView.do?newsId=148956384',
+ '국토교통부',
+ '2025-12-12 00:00:00',
+ 'ok',
+ '2026-06-24 23:55:00',
+ '2026-06-24 23:55:00'
+from real_estate_regional_reports report
+where report.generated_by = 'codex-deep-research:national-regional-20260624'
+union all
+select
+ concat('regional-report-source-', report.target_id, '-national-07-local-policy'),
+ report.target_id,
+ 7,
+ 'external',
+ case
+ when target.display_name like '부산광역시%' then 'busan-north-port-redevelopment'
+ when target.display_name like '대구광역시%' then 'daegu-unsold-housing-monthly-2026'
+ when target.display_name like '세종특별자치시%' then 'sejong-happy-city-housing-supply-2026'
+ when target.display_name like '제주특별자치도%' then 'jeju-housing-statistics-202602'
+ else 'regional-official-policy-source-2026'
+ end,
+ '지역 정책 근거',
+ case
+ when target.display_name like '부산광역시%' then '부산광역시 부산항 북항 재개발 1단계 사업 방향'
+ when target.display_name like '대구광역시%' then '대구광역시 2026년 공동주택 미분양 현황 자료실'
+ when target.display_name like '세종특별자치시%' then '행복청·세종시 2026년 행복도시 공동주택 공급계획'
+ when target.display_name like '제주특별자치도%' then '제주특별자치도 2026년 2월 주택 통계 및 현황'
+ else '국토교통부 지역 균형성장 및 주택 공급 정책'
+ end,
+ case
+ when target.display_name like '부산광역시%' then 'https://www.busan.go.kr/ghbusan02'
+ when target.display_name like '대구광역시%' then 'https://www.daegu.go.kr/build/index.do?menu_id=00001338&searchCnd=0&searchWrd=%EB%AF%B8%EB%B6%84%EC%96%91'
+ when target.display_name like '세종특별자치시%' then 'https://www.sjsori.com/news/articleView.html?idxno=83791'
+ when target.display_name like '제주특별자치도%' then 'https://jj.khba.or.kr/other/bbs_view.do?bbs_num=66982'
+ else 'https://www.korea.kr/news/policyNewsView.do?newsId=148956384'
+ end,
+ case
+ when target.display_name like '부산광역시%' then '부산광역시'
+ when target.display_name like '대구광역시%' then '대구광역시'
+ when target.display_name like '세종특별자치시%' then '행정중심복합도시건설청·세종특별자치시'
+ when target.display_name like '제주특별자치도%' then '제주특별자치도'
+ else '국토교통부'
+ end,
+ case
+ when target.display_name like '부산광역시%' then null
+ when target.display_name like '대구광역시%' then '2026-05-29 00:00:00'
+ when target.display_name like '세종특별자치시%' then '2026-01-20 00:00:00'
+ when target.display_name like '제주특별자치도%' then '2026-04-16 00:00:00'
+ else '2025-12-12 00:00:00'
+ end,
+ 'ok',
+ '2026-06-24 23:55:00',
+ '2026-06-24 23:55:00'
+from real_estate_regional_reports report
+join real_estate_targets target on target.id = report.target_id
+where report.generated_by = 'codex-deep-research:national-regional-20260624';
diff --git a/backend/src/main/resources/db/migration/V56__real_estate_investor_column_report_rewrite.sql b/backend/src/main/resources/db/migration/V56__real_estate_investor_column_report_rewrite.sql
new file mode 100644
index 0000000..32afe6a
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V56__real_estate_investor_column_report_rewrite.sql
@@ -0,0 +1,217 @@
+insert into real_estate_regional_reports (
+ target_id,
+ report_id,
+ report_version,
+ prompt_version,
+ model_name,
+ generated_by,
+ title,
+ headline,
+ summary,
+ body,
+ expectation_points_json,
+ concern_points_json,
+ data_quality,
+ confidence,
+ as_of,
+ published_at,
+ created_at,
+ updated_at
+)
+select
+ rewritten.target_id,
+ concat('regional-report-', rewritten.target_id, '-investor-column-20260625'),
+ 'regional-report-investor-column-v2',
+ 'codex-investor-column-rewrite-prompt-20260625',
+ 'codex-gpt-5-investor-column',
+ 'codex-quality-rewrite:investor-column-20260625',
+ concat(rewritten.display_name, ' 투자 판단 리포트'),
+ rewritten.headline,
+ rewritten.summary,
+ rewritten.body,
+ case
+ when rewritten.expectation_2 is null then concat('["', rewritten.expectation_1, '"]')
+ else concat('["', rewritten.expectation_1, '","', rewritten.expectation_2, '"]')
+ end,
+ case
+ when rewritten.concern_2 is null then concat('["', rewritten.concern_1, '"]')
+ else concat('["', rewritten.concern_1, '","', rewritten.concern_2, '"]')
+ end,
+ 'deep_researched',
+ rewritten.confidence,
+ '2026-06-25 00:00:00',
+ '2026-06-25 09:40:00',
+ coalesce(existing.created_at, '2026-06-25 09:40:00'),
+ '2026-06-25 09:40:00'
+from (
+ select
+ region.target_id,
+ region.region_level,
+ region.parent_region_id,
+ target.display_name,
+ case
+ when region.target_id = 'region-seoul' then '서울은 강남3구와 용산, 한강권, 재건축 후보지가 서로 다른 시장입니다.'
+ when region.target_id = 'region-seoul-gangnam' then '강남구는 신고가보다 학군·업무·정비사업 가격을 분리해서 봐야 합니다.'
+ when region.target_id = 'region-seoul-seocho' then '서초구는 서리풀2지구와 정비사업 기대가 가격에 먼저 반영되기 쉬운 지역입니다.'
+ when region.target_id = 'region-seoul-songpa' then '송파구는 잠실·가락·문정의 수요가 다르게 움직이는 동남권 핵심 지역입니다.'
+ when region.target_id = 'region-seoul-yongsan' then '용산구는 국제업무·정비 기대와 실입주 수요를 따로 봐야 합니다.'
+ when region.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대 수요가 강남권 대체재로 읽히는지 확인할 지역입니다.'
+ when region.target_id = 'region-seoul-nowon' then '노원구는 노후 단지와 재건축 기대가 실수요 가격대와 충돌하는 지역입니다.'
+ when region.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 1기 신도시 정비, 3기 신도시, 용인 반도체 축을 한 결론으로 묶으면 안 됩니다.'
+ when region.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 첨단시스템반도체 국가산단 기대가 실제 주거 수요로 바뀌는지 봐야 합니다.'
+ when region.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 공급과 중동 1기 신도시 정비가 동시에 가격을 흔드는 지역입니다.'
+ when region.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 17천호 공급이 실제 흡수되는지가 핵심입니다.'
+ when target.display_name = '충청남도' then '충남은 천안·아산, 내포, 서해안 산업축을 서로 다른 시장으로 나눠 봐야 합니다.'
+ when target.display_name like '충청남도 아산시%' then '아산은 천안과 붙어 있는 수요, 탕정 산업 배후, 새 아파트 선호를 같이 봐야 합니다.'
+ when target.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 수도권 남부 출퇴근과 산업단지 배후 수요가 가격을 설명하는 지역입니다.'
+ when target.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심·대학·역세권 수요와 서북구 신축 선호를 비교해야 합니다.'
+ when target.display_name like '충청남도 당진시%' then '당진은 제철·항만 산업 경기와 임대 수요가 같이 움직이는 서해안 산업도시입니다.'
+ when target.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 서해안 생활권 수요가 핵심인 지역입니다.'
+ when target.display_name like '충청남도 홍성군%' or target.display_name like '충청남도 예산군%' then '내포권은 행정수요와 신규 공급 흡수력이 먼저 확인돼야 합니다.'
+ when target.display_name like '충청남도 보령시%' or target.display_name like '충청남도 태안군%' then '충남 해안권은 관광 수요와 상주 주거 수요를 구분해야 합니다.'
+ when target.display_name like '부산광역시 해운대구%' then '해운대는 북항보다 동부산 고급 주거, 센텀 업무, 전세 수요가 먼저 가격을 설명합니다.'
+ when target.display_name like '부산광역시%' then '부산은 북항·가덕도 기대와 동부산·원도심·서부산의 실수요가 다르게 움직입니다.'
+ when target.display_name like '대구광역시 수성구%' then '수성구는 대구 미분양 부담 속에서도 학군·핵심지 선호가 얼마나 버티는지가 관건입니다.'
+ when target.display_name like '대구광역시%' then '대구는 미분양 감소보다 준공 후 미분양과 입주 부담을 먼저 봐야 합니다.'
+ when target.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급이 같은 방향으로 흡수되는지 봐야 합니다.'
+ when target.display_name like '제주특별자치도%' then '제주는 관광 회복, 이주 수요, 주택 통계를 분리해서 읽어야 합니다.'
+ when target.display_name like '광주광역시%' then '광주는 첨단산단·도심 정비·전세 수요가 같은 속도로 움직이지 않는 시장입니다.'
+ when target.display_name like '대전광역시 유성구%' then '유성구는 연구개발 수요와 도안권 공급이 같이 움직이는 대전의 핵심 지역입니다.'
+ when target.display_name like '대전광역시%' then '대전은 연구개발·공공기관 수요와 도안권 공급 부담을 함께 봐야 합니다.'
+ when target.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요에 바로 연결되는 산업도시입니다.'
+ when target.display_name like '강원도%' then '강원은 춘천·원주 상주 수요와 영동 관광 수요를 같은 시장으로 보면 안 됩니다.'
+ when target.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 같이 움직이는 충북 핵심 지역입니다.'
+ when target.display_name like '충청북도%' then '충북은 청주권 산업 수요와 중부내륙 생활권의 거래 깊이가 크게 다릅니다.'
+ when target.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심이지만 새만금 기대와는 분리해서 봐야 합니다.'
+ when target.display_name like '전라북도 군산시%' then '군산은 새만금·산단 뉴스가 주거 수요로 전환되는 속도를 확인해야 합니다.'
+ when target.display_name like '전라북도%' then '전북은 전주 생활수요와 새만금·군산·익산 산업축을 따로 읽어야 합니다.'
+ when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 같이 움직이는 전남 핵심축입니다.'
+ when target.display_name like '전라남도%' then '전남은 산업도시, 혁신도시, 해안 관광권의 거래 성격이 서로 다릅니다.'
+ when target.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대와 인구 흐름을 같이 확인해야 하는 산업도시입니다.'
+ when target.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 회복이 전세 수요로 이어지는지가 핵심입니다.'
+ when target.display_name like '경상북도%' then '경북은 포항·구미 산업축과 안동·예천 행정수요, 관광권을 분리해야 합니다.'
+ when target.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 함께 가격을 움직입니다.'
+ when target.display_name like '경상남도 김해시%' or target.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞인 지역입니다.'
+ when target.display_name like '경상남도 거제시%' or target.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감한 해안 생활권입니다.'
+ when target.display_name like '경상남도%' then '경남은 창원 제조업, 부산권 배후, 남해안 산업·관광권을 나눠 봐야 합니다.'
+ when region.region_level = 'sido' then concat(target.display_name, ': 하위 생활권별 수요와 공급이 달라 광역 평균만으로 판단하기 어렵습니다.')
+ when target.display_name like '%군' then concat(target.display_name, ': 거래 표본이 얇아 한두 건의 실거래보다 전세와 인구 흐름을 먼저 봐야 합니다.')
+ when target.display_name like '%구' then concat(target.display_name, ': 같은 광역시 안에서도 역세권·학군·신축 선호가 가격을 갈라놓는 지역입니다.')
+ else concat(target.display_name, ': 자체 생활권 수요와 상위 광역권 흐름을 분리해서 봐야 합니다.')
+ end as headline,
+ case
+ when region.region_level = 'sido' then '광역 평균보다 하위 생활권의 수요·공급 차이를 먼저 보는 리포트입니다.'
+ when target.display_name like '%군' then '거래 표본이 얇은 지역은 짧게, 확인할 지표만 남긴 리포트입니다.'
+ else '실거래·전세·공급·정책을 투자 판단 순서로 압축한 지역 리포트입니다.'
+ end as summary,
+ case
+ when region.target_id = 'region-seoul' then '서울은 강남3구와 용산이 가격 발견을 주도하지만, 전세 압력은 강북·노후 단지까지 넓게 번질 수 있습니다. 서리풀2지구 2,000호 같은 공급 뉴스는 장기 심리에는 영향을 주지만 단기 매수 판단에서는 착공·분양 시점을 따로 봐야 합니다.'
+ when region.target_id = 'region-seoul-gangnam' then '강남구는 대치·청담·삼성·개포·압구정이 각각 다른 가격표를 갖습니다. 학군과 업무 접근성은 강하지만 신고가 한두 건보다 전세가율, 토지거래허가, 정비사업 속도를 함께 봐야 합니다.'
+ when region.target_id = 'region-seoul-seocho' then '서초구는 반포·잠원·서초·양재가 같은 구 안에서도 다르게 움직입니다. 서리풀2지구 2,000호는 장기 공급 신호지만, 현재 가격은 정비사업 기대와 토지거래허가 부담이 먼저 반영될 수 있습니다.'
+ when region.target_id = 'region-seoul-songpa' then '송파구는 잠실의 상징성, 가락·문정의 실거주, 위례·거여의 공급 성격이 다릅니다. 대장 단지 신고가와 주변 구축의 실거래가 같이 움직이는지를 봐야 합니다.'
+ when region.target_id = 'region-seoul-yongsan' then '용산구는 국제업무지구와 한남·이촌 정비 기대가 강하지만 실입주 수요는 가격대가 매우 얇습니다. 정책 기대가 거래량 없이 가격만 밀어 올리는 구간은 조심해야 합니다.'
+ when region.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대가 서로 다른 수요를 갖습니다. 강남권 대체재로 읽히려면 공덕·아현 전세와 상암 업무 수요가 함께 버텨야 합니다.'
+ when region.target_id = 'region-seoul-nowon' then '노원구는 노후 단지 비중이 높아 재건축 기대와 실수요 가격대가 자주 충돌합니다. 전세가 먼저 회복되지 않으면 정비 기대만으로 매매가를 설명하기 어렵습니다.'
+ when region.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 1기 신도시 정비, 3기 신도시, 용인 반도체를 한 방향으로 해석하면 안 됩니다. 성남·과천·하남은 서울 대체재, 평택·용인·화성은 산업 배후, 김포·파주·양주는 교통 기대의 검증 구간입니다.'
+ when region.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 첨단시스템반도체 국가산단 기대가 가장 큰 변수입니다. 다만 산업 뉴스가 바로 주택 수요가 되는 것은 아니어서, 원삼·남사 주변 입주와 전세 수요가 실제로 붙는지 확인해야 합니다.'
+ when region.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 3기 신도시와 중동 1기 신도시 정비가 동시에 존재합니다. 공급 기대와 정비 기대가 섞여 있어, 새 아파트 선호와 구축 재건축 프리미엄을 분리해야 합니다.'
+ when region.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 17천호 공급이 핵심입니다. 서울 접근성 기대보다 중요한 것은 입주 시점에 전세가 받쳐주는지, 검단·부천대장과 수요가 겹치는지입니다.'
+ when target.display_name = '충청남도' then '충남은 천안·아산의 수도권 남부 수요, 내포신도시의 행정수요, 당진·서산의 서해안 산업축이 서로 다른 시장입니다. 광역 평균을 보기보다 어느 축의 수요를 사는지 먼저 정해야 합니다.'
+ when target.display_name like '충청남도 아산시%' then '아산은 천안과 붙어 있는 수요, 탕정·배방의 산업 배후, 새 아파트 선호가 함께 움직입니다. 투자자는 천안 생활권으로 보는 단지와 아산 자체 산업수요로 보는 단지를 나눠야 합니다.'
+ when target.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 불당·성성·두정처럼 신축과 직장 접근성이 가격을 가르는 지역입니다. 수도권 남부 출퇴근 수요와 산업단지 임대수요가 전세를 받쳐줄 때 매매 판단이 쉬워집니다.'
+ when target.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심, 대학가, 역세권 수요가 중심입니다. 서북구 신축 흐름을 그대로 가져오기보다 저가 실거주와 정비 기대가 실제 거래로 이어지는지 봐야 합니다.'
+ when target.display_name like '충청남도 당진시%' then '당진은 제철·항만 산업 경기와 임대 수요가 핵심입니다. 산업단지 고용이 안정되면 하방은 줄지만, 공급이 한꺼번에 들어오면 매매보다 전세가 먼저 흔들릴 수 있습니다.'
+ when target.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 자족 생활권이 가격을 설명합니다. 산업 배후 수요가 뚜렷한 단지와 외곽 택지의 거래 깊이를 분리해야 합니다.'
+ when target.display_name like '충청남도 홍성군%' or target.display_name like '충청남도 예산군%' then '내포권은 행정기관 수요가 장점이지만 공급 흡수력이 더 중요합니다. 새 단지가 많을수록 매매보다 전세 공실과 입주 속도를 먼저 봐야 합니다.'
+ when target.display_name like '충청남도 보령시%' or target.display_name like '충청남도 태안군%' then '충남 해안권은 관광과 세컨드하우스 수요가 생길 수 있지만 상주 수요는 얇습니다. 투자 판단은 성수기 분위기보다 연중 거래 빈도와 임대 안정성으로 해야 합니다.'
+ when target.display_name like '부산광역시 해운대구%' then '해운대는 부산 전체 북항 기대보다 동부산 고급 주거와 센텀 업무 수요가 더 직접적입니다. 해운대 안에서도 해안 고가 단지와 내륙 구축의 전세 회복 속도를 나눠 봐야 합니다.'
+ when target.display_name like '부산광역시%' then '부산은 북항·가덕도 기대가 도시 심리를 만들지만 가격은 해운대·수영·동래, 원도심, 서부산이 다르게 움직입니다. 개발 뉴스보다 전세 회복과 입주 물량의 충돌을 먼저 봅니다.'
+ when target.display_name like '대구광역시 수성구%' then '수성구는 대구 미분양 부담 속에서도 학군·핵심지 선호가 남아 있는 지역입니다. 수성구라는 이름만으로 충분하지 않고, 전세가 회복되는 신축·준신축인지부터 확인해야 합니다.'
+ when target.display_name like '대구광역시%' then '대구는 미분양 총량 감소보다 준공 후 미분양과 입주 부담이 더 중요합니다. 핵심지는 먼저 버틸 수 있지만 외곽 공급지는 가격 회복까지 시간이 더 걸릴 수 있습니다.'
+ when target.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급이 동시에 움직입니다. 5생활권 분양과 입주가 실제 수요로 흡수되면 회복 근거가 되지만, 정책 기대만 앞서면 가격 변동성이 커질 수 있습니다.'
+ when target.display_name like '제주특별자치도%' then '제주는 관광 회복, 이주 수요, 주택 통계를 같이 봐야 합니다. 월별 주택 통계는 필요하지만 표본이 얇아 제주시와 서귀포, 읍면 수요를 섞어 판단하면 위험합니다.'
+ when target.display_name like '광주광역시%' then '광주는 첨단산단과 도심 정비 기대가 있지만 생활권별 차이가 큽니다. 광산·북구의 신축 수요와 동구·남구의 도심 정비 기대를 한 문장으로 묶으면 판단이 흐려집니다.'
+ when target.display_name like '대전광역시 유성구%' then '유성구는 연구개발, 대학, 도안권 공급이 가격을 설명합니다. 좋은 입지라도 입주 물량이 겹치면 전세가 먼저 약해질 수 있어 매매보다 임대 수요를 먼저 봐야 합니다.'
+ when target.display_name like '대전광역시%' then '대전은 유성·서구 중심 수요와 동구·대덕의 정비·산업 수요가 다릅니다. 세종 연계 기대는 보조 변수이고 실제 판단은 전세와 입주 물량으로 합니다.'
+ when target.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요를 직접 흔듭니다. 산업 회복이 전세 수요로 확인되면 긍정적이지만, 단일 기업 뉴스만으로 매매를 판단하기는 어렵습니다.'
+ when target.display_name like '강원도%' then '강원은 춘천·원주의 상주 수요와 강릉·속초의 관광 수요가 다릅니다. 세컨드하우스 분위기보다 연중 전세 수요와 거래 빈도를 확인해야 합니다.'
+ when target.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 충북에서 가장 두껍습니다. 다만 산업 기대가 강할수록 분양가와 입주 물량도 같이 올라오므로 전세가 먼저 버티는지 봐야 합니다.'
+ when target.display_name like '충청북도%' then '충북은 청주권 산업 수요와 충주·제천·군 단위 생활권의 거래 깊이가 다릅니다. 산업 뉴스가 없는 지역은 가격보다 거래 빈도와 전세 안정성이 더 중요합니다.'
+ when target.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심입니다. 새만금 기대를 전주 가격에 바로 붙이기보다 혁신도시·도심·신축 선호가 전세로 확인되는지 봐야 합니다.'
+ when target.display_name like '전라북도 군산시%' then '군산은 새만금과 산업단지 기대가 있지만, 주택 수요로 전환되는 데 시간이 걸립니다. 산업 뉴스보다 고용과 임대 수요가 먼저 움직이는지 확인해야 합니다.'
+ when target.display_name like '전라북도%' then '전북은 전주 실거주, 군산·익산 산업축, 새만금 기대가 다르게 작동합니다. 개발 기대를 사기보다 실제 거래와 전세 회복이 붙은 곳을 따로 봐야 합니다.'
+ when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 전남에서 가장 뚜렷합니다. 산업 경기와 상주 수요가 같이 받쳐주는 단지인지 확인해야 합니다.'
+ when target.display_name like '전라남도%' then '전남은 산업도시, 혁신도시, 해안 관광권의 거래 성격이 서로 다릅니다. 군 단위는 한두 건의 실거래보다 인구와 임대 수요를 더 보수적으로 봐야 합니다.'
+ when target.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대가 있지만 산업 경기 변동에 민감합니다. 일자리 회복이 전세와 매매 수요로 같이 확인될 때 의미가 있습니다.'
+ when target.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 기반이 강하지만 경기 변동에 민감합니다. 공장 증설 뉴스보다 임대 수요와 전입 흐름이 먼저 확인돼야 합니다.'
+ when target.display_name like '경상북도%' then '경북은 포항·구미 산업축, 안동·예천 행정수요, 경주 관광권이 서로 다릅니다. 대구경북신공항 기대는 장기 변수로 보되 단기 매매 판단은 거래와 전세로 해야 합니다.'
+ when target.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 같이 움직입니다. 산업 회복이 전세 수요를 받쳐주면 긍정적이지만 구축 정비 기대만으로는 부족합니다.'
+ when target.display_name like '경상남도 김해시%' or target.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞입니다. 부산 접근성 기대와 신규 공급 부담을 분리해야 합니다.'
+ when target.display_name like '경상남도 거제시%' or target.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감합니다. 업황 회복이 전세 안정으로 이어지는지 확인한 뒤 매매를 봐야 합니다.'
+ when target.display_name like '경상남도%' then '경남은 창원 제조업, 김해·양산 부산 배후, 거제·통영 조선·관광권이 다릅니다. 광역 평균보다 산업과 전세 수요가 붙는 생활권을 먼저 봐야 합니다.'
+ when region.region_level = 'sido' then concat(target.display_name, ': 광역 평균만으로 판단하기 어렵습니다. 하위 시군구의 전세, 입주 물량, 산업 수요가 같은 방향인지 먼저 확인해야 합니다.')
+ when target.display_name like '%군' then concat(target.display_name, ': 거래 표본이 얇은 지역입니다. 가격 방향을 말하기보다 실거래 빈도, 전세 수요, 인구 흐름이 유지되는지만 확인하는 편이 낫습니다.')
+ when target.display_name like '%구' then concat(target.display_name, ': 같은 광역시 안에서도 역세권, 학군, 신축 선호가 가격을 갈라놓습니다. 단기 등락률보다 전세와 실거래가 함께 움직이는 단지를 봐야 합니다.')
+ else concat(target.display_name, ': 자체 생활권 수요와 상위 광역권 흐름을 분리해서 봐야 합니다. 투자 판단은 개발 뉴스보다 전세, 거래 빈도, 입주 물량의 균형에서 시작합니다.')
+ end as body,
+ case
+ when target.display_name like '충청남도 아산시%' then '탕정·배방 산업 배후 수요가 전세로 확인되면 매매 설명력이 생깁니다.'
+ when target.display_name like '충청남도 천안시 서북구%' then '직장·교통 수요가 신축 전세를 받치면 가격 방어력이 커집니다.'
+ when target.display_name like '충청남도 당진시%' then '제철·항만 고용이 임대 수요로 이어지면 하방 위험이 줄어듭니다.'
+ when target.display_name like '서울특별시%' then '전세 압력과 정비사업 일정이 같이 움직이면 실수요 근거가 강해집니다.'
+ when target.display_name like '경기도%' or target.display_name like '인천광역시%' then '교통·산업·공급 일정이 전세 수요와 같이 확인되면 투자 근거가 생깁니다.'
+ when target.display_name like '부산광역시%' then '전세 회복이 동부산과 원도심 중 어디서 먼저 나타나는지 확인할 수 있습니다.'
+ when target.display_name like '대구광역시%' then '미분양 감소가 전세와 실거래 회복으로 이어지는지 확인할 수 있습니다.'
+ when target.display_name like '세종특별자치시%' then '5생활권 공급이 청약·입주 수요로 흡수되면 회복 근거가 됩니다.'
+ when target.display_name like '제주특별자치도%' then '관광·이주 수요와 상주 수요를 나눠 보면 과열 신호를 거를 수 있습니다.'
+ else '전세와 실거래가 같은 방향으로 움직이면 단기 뉴스보다 믿을 만한 근거가 됩니다.'
+ end as expectation_1,
+ case
+ when region.region_level = 'sido' then '하위 생활권별로 강한 곳과 약한 곳을 분리해 볼 수 있습니다.'
+ when target.display_name like '%군' then null
+ else '공급 일정과 기존 단지 전세를 같이 보면 무리한 진입을 줄일 수 있습니다.'
+ end as expectation_2,
+ case
+ when target.display_name like '충청남도 아산시%' then '천안 생활권 기대만 보고 입주 물량 부담을 놓치면 위험합니다.'
+ when target.display_name like '충청남도 천안시 서북구%' then '신축 선호가 강해도 분양가와 전세가 벌어지면 부담이 커집니다.'
+ when target.display_name like '충청남도 당진시%' then '산업 뉴스가 고용과 전세로 확인되기 전에는 가격 기대가 앞설 수 있습니다.'
+ when target.display_name like '서울특별시 서초구%' then '토지거래허가와 정비사업 기대가 실제 수요보다 앞설 수 있습니다.'
+ when target.display_name like '경기도 용인시 처인구%' then '산업 뉴스만 보고 주거 수요 전환 속도를 과대평가할 수 있습니다.'
+ when target.display_name like '인천광역시 계양구%' then '공급 규모가 커서 입주 시점 전세가 먼저 흔들릴 수 있습니다.'
+ when target.display_name like '대구광역시%' then '미분양 감소만 보고 준공 후 미분양 부담을 과소평가할 수 있습니다.'
+ when target.display_name like '세종특별자치시%' then '정책 기대가 공급 흡수력보다 먼저 가격에 반영될 수 있습니다.'
+ when target.display_name like '제주특별자치도%' then '관광 수요를 상주 주거 수요로 착각하면 판단이 흔들립니다.'
+ when target.display_name like '%군' then '거래 표본이 얇아 한두 건으로 흐름을 단정하기 어렵습니다.'
+ else '개발 뉴스가 전세와 실거래보다 먼저 가격 기대를 만들 수 있습니다.'
+ end as concern_1,
+ case
+ when target.display_name like '%군' then null
+ when region.region_level = 'sido' then '광역 평균이 강해 보여도 약한 하위 지역은 따로 남을 수 있습니다.'
+ else '입주 물량이 겹치면 매매보다 전세가 먼저 약해질 수 있습니다.'
+ end as concern_2,
+ case
+ when region.region_level = 'sido' then 0.82
+ else 0.78
+ end as confidence
+ from real_estate_regions region
+ join real_estate_targets target on target.id = region.target_id
+ where region.region_level in ('sido', 'sigungu')
+) rewritten
+left join real_estate_regional_reports existing on existing.target_id = rewritten.target_id
+on duplicate key update
+ report_id = values(report_id),
+ report_version = values(report_version),
+ prompt_version = values(prompt_version),
+ model_name = values(model_name),
+ generated_by = values(generated_by),
+ title = values(title),
+ headline = values(headline),
+ summary = values(summary),
+ body = values(body),
+ expectation_points_json = values(expectation_points_json),
+ concern_points_json = values(concern_points_json),
+ data_quality = values(data_quality),
+ confidence = values(confidence),
+ as_of = values(as_of),
+ published_at = values(published_at),
+ updated_at = values(updated_at);
diff --git a/backend/src/main/resources/db/migration/V57__real_estate_financial_outlook_report_rewrite.sql b/backend/src/main/resources/db/migration/V57__real_estate_financial_outlook_report_rewrite.sql
new file mode 100644
index 0000000..4274329
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V57__real_estate_financial_outlook_report_rewrite.sql
@@ -0,0 +1,237 @@
+insert into real_estate_regional_reports (
+ target_id,
+ report_id,
+ report_version,
+ prompt_version,
+ model_name,
+ generated_by,
+ title,
+ headline,
+ summary,
+ body,
+ expectation_points_json,
+ concern_points_json,
+ data_quality,
+ confidence,
+ as_of,
+ published_at,
+ created_at,
+ updated_at
+)
+select
+ rewritten.target_id,
+ concat('regional-report-', rewritten.target_id, '-financial-outlook-20260625'),
+ 'regional-report-financial-outlook-v3',
+ 'codex-financial-outlook-rewrite-prompt-20260625',
+ 'codex-gpt-5-financial-outlook',
+ 'codex-quality-rewrite:financial-outlook-20260625',
+ concat(rewritten.display_name, ' 시장 판단 리포트'),
+ rewritten.headline,
+ rewritten.summary,
+ rewritten.body,
+ case
+ when rewritten.expectation_2 is null then concat('["', rewritten.expectation_1, '"]')
+ else concat('["', rewritten.expectation_1, '","', rewritten.expectation_2, '"]')
+ end,
+ case
+ when rewritten.concern_2 is null then concat('["', rewritten.concern_1, '"]')
+ else concat('["', rewritten.concern_1, '","', rewritten.concern_2, '"]')
+ end,
+ 'deep_researched',
+ rewritten.confidence,
+ '2026-06-25 00:00:00',
+ '2026-06-25 01:30:00',
+ coalesce(existing.created_at, '2026-06-25 01:30:00'),
+ '2026-06-25 01:30:00'
+from (
+ select
+ region.target_id,
+ region.region_level,
+ target.display_name,
+ case
+ when region.target_id = 'region-seoul' then '서울은 전세 압력과 핵심지 정비 기대가 동시에 작동하는 시장'
+ when region.target_id = 'region-seoul-gangnam' then '강남구는 학군·업무·정비사업 가격이 분리되는 핵심지'
+ when region.target_id = 'region-seoul-seocho' then '서초구는 서리풀2지구와 정비 기대가 선반영되기 쉬운 시장'
+ when region.target_id = 'region-seoul-songpa' then '송파구는 잠실·가락·문정 수요를 나눠 봐야 하는 동남권 핵심지'
+ when region.target_id = 'region-seoul-yongsan' then '용산구는 개발 기대와 실입주 수요의 간극이 큰 고가 시장'
+ when region.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대 수요가 분산된 강북 핵심지'
+ when region.target_id = 'region-seoul-nowon' then '노원구는 노후 단지 정비 기대와 실수요 가격대가 충돌하는 시장'
+ when region.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 정비·공급·산업축을 분리해야 하는 복합 시장'
+ when region.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 반도체 국가산단 기대의 주거 전환이 핵심'
+ when region.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 공급과 중동 정비가 동시에 작동하는 시장'
+ when region.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 대규모 공급 흡수가 핵심'
+ when target.display_name = '충청남도' then '충남은 천안·아산과 서해안 산업벨트를 분리해야 하는 시장'
+ when target.display_name like '충청남도 아산시%' then '아산은 탕정·배방 산업 배후와 천안 연계 수요가 핵심'
+ when target.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 신축 선호와 수도권 남부 출퇴근 수요가 핵심'
+ when target.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심·대학·역세권 수요의 확인 구간'
+ when target.display_name like '충청남도 당진시%' then '당진은 제철·항만 고용과 임대 수요가 가격 하방을 결정'
+ when target.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 자족 생활권 수요가 핵심'
+ when target.display_name like '충청남도 홍성군%' or target.display_name like '충청남도 예산군%' then '내포권은 행정수요보다 공급 흡수력이 우선'
+ when target.display_name like '충청남도 보령시%' or target.display_name like '충청남도 태안군%' then '충남 해안권은 관광 수요와 상주 수요의 분리가 핵심'
+ when target.display_name like '부산광역시 해운대구%' then '해운대는 북항보다 동부산 고급 주거와 센텀 수요가 직접 변수'
+ when target.display_name like '부산광역시%' then '부산은 북항·가덕도 기대와 생활권별 전세 회복이 분리되는 시장'
+ when target.display_name like '대구광역시 수성구%' then '수성구는 대구 미분양 부담 속 핵심지 방어력의 시험대'
+ when target.display_name like '대구광역시%' then '대구는 미분양과 준공 후 미분양 부담이 가격 상단을 제한'
+ when target.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급 흡수가 함께 필요한 시장'
+ when target.display_name like '제주특별자치도%' then '제주는 관광·이주 수요와 주택 통계의 신호가 갈리는 시장'
+ when target.display_name like '광주광역시%' then '광주는 첨단산단 기대와 생활권별 신축 수요가 분리되는 시장'
+ when target.display_name like '대전광역시 유성구%' then '유성구는 연구개발 수요와 도안권 공급 부담이 함께 작동'
+ when target.display_name like '대전광역시%' then '대전은 유성·서구 중심 수요와 도안권 공급 부담이 핵심'
+ when target.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요를 좌우'
+ when target.display_name like '강원도%' then '강원은 춘천·원주 상주 수요와 영동 관광 수요가 분리되는 시장'
+ when target.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 함께 작동'
+ when target.display_name like '충청북도%' then '충북은 청주권 산업 수요와 중부내륙 거래 깊이의 차이가 핵심'
+ when target.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심축'
+ when target.display_name like '전라북도 군산시%' then '군산은 새만금·산단 기대의 주거 수요 전환이 핵심'
+ when target.display_name like '전라북도%' then '전북은 전주 생활수요와 새만금 산업 기대를 분리해야 하는 시장'
+ when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 겹치는 전남 핵심축'
+ when target.display_name like '전라남도%' then '전남은 산업도시·혁신도시·해안 관광권의 거래 성격이 분리되는 시장'
+ when target.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대와 산업 경기 변동성이 공존'
+ when target.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 회복의 전세 전환이 핵심'
+ when target.display_name like '경상북도%' then '경북은 포항·구미 산업축과 안동·예천 행정수요가 분리되는 시장'
+ when target.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 함께 작동'
+ when target.display_name like '경상남도 김해시%' or target.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 혼재'
+ when target.display_name like '경상남도 거제시%' or target.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감한 해안 생활권'
+ when target.display_name like '경상남도%' then '경남은 창원 제조업과 부산권 배후, 남해안 산업권이 분리되는 시장'
+ when region.region_level = 'sido' then concat(target.display_name, '는 하위 생활권별 수급 차이가 큰 광역 시장')
+ when target.display_name like '%군' then concat(target.display_name, '는 거래 표본과 상주 수요 확인이 우선인 저유동성 시장')
+ when target.display_name like '%구' then concat(target.display_name, '는 역세권·학군·신축 선호가 가격을 가르는 세부 시장')
+ else concat(target.display_name, '는 자체 생활권 수요와 광역 흐름을 분리해야 하는 시장')
+ end as headline,
+ case
+ when region.region_level = 'sido' then '광역 평균보다 생활권별 수급 차이를 먼저 본 단기 시장 판단입니다.'
+ when target.display_name like '%군' then '거래 표본이 얇은 지역은 확인 지표 중심으로 압축한 리포트입니다.'
+ else '실거래·전세·공급·정책 변수를 금융권 리포트 문체로 압축한 지역 판단입니다.'
+ end as summary,
+ case
+ when region.target_id = 'region-seoul' then '서울은 강남3구와 용산이 가격 발견을 주도하고 전세 압력은 강북 노후 단지까지 확산될 수 있는 시장이다. 단기 판단: 상승 지속 가능성 우위. 다만 서리풀2지구 2,000호는 장기 공급 변수라 착공·분양 일정 전까지 단기 가격 논리로 쓰기 어렵다.'
+ when region.target_id = 'region-seoul-gangnam' then '강남구는 대치·청담·삼성·개포·압구정이 각각 다른 가격표를 갖는 시장이다. 단기 판단: 상승 지속 가능성 우위. 학군과 업무 접근성은 강하지만 전세가율, 토지거래허가, 정비사업 속도가 함께 확인돼야 한다.'
+ when region.target_id = 'region-seoul-seocho' then '서초구는 서리풀2지구, 반포·잠원 정비 기대, 토지거래허가 부담이 공존한다. 단기 판단: 선별 관망. 핵심 신축은 방어력이 있으나 기대 선반영 구간은 전세 확인 없이는 추격 논리가 약하다.'
+ when region.target_id = 'region-seoul-songpa' then '송파구는 잠실의 상징성, 가락·문정의 실거주, 위례·거여의 공급 성격이 다르다. 단기 판단: 선별 접근. 대장 단지와 주변 구축의 실거래가 동행할 때 상승 지속성의 신뢰도가 높다.'
+ when region.target_id = 'region-seoul-yongsan' then '용산구는 국제업무지구와 한남·이촌 정비 기대가 강하지만 실입주 수요의 가격대가 얇다. 단기 판단: 보수적 선별. 정책 기대가 거래량 없이 가격만 끌어올리는 구간은 변동성 확대 가능성이 높다.'
+ when region.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대 수요가 분산된 시장이다. 단기 판단: 선별 접근. 강남권 대체재 논리는 공덕·아현 전세와 상암 업무 수요가 같이 버틸 때만 유효하다.'
+ when region.target_id = 'region-seoul-nowon' then '노원구는 노후 단지 비중이 높고 재건축 기대와 실수요 가격대가 자주 충돌한다. 단기 판단: 관망 우위. 전세가 먼저 회복되지 않으면 정비 기대만으로 상승 지속성을 설명하기 어렵다.'
+ when region.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 1기 신도시 정비, 3기 신도시, 용인 반도체가 각각 다른 가격 변수를 만든다. 단기 판단: 선별 접근. 성남·과천·하남은 서울 대체재, 평택·용인·화성은 산업 배후로 나눠 봐야 한다.'
+ when region.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 첨단시스템반도체 국가산단 이슈가 핵심 프리미엄이다. 단기 판단: 선별 관망. 산업 뉴스가 주거 수요로 전환되는 속도는 원삼·남사 전세와 입주 흡수에서 확인해야 한다.'
+ when region.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 공급과 중동 1기 신도시 정비가 동시에 작동한다. 단기 판단: 보수적 선별. 새 아파트 선호와 구축 재건축 프리미엄이 충돌해 동일 생활권 안에서도 가격 설명력이 갈린다.'
+ when region.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 17천호 공급이 함께 들어오는 구간이다. 단기 판단: 보수적 관망. 서울 접근성보다 입주 시점 전세 방어와 검단·부천대장 수요 중복이 더 중요하다.'
+ when target.display_name = '충청남도' then '충남은 천안·아산의 수도권 남부 수요, 내포 행정수요, 당진·서산의 서해안 산업축이 분리되는 시장이다. 단기 판단: 선별 관망. 광역 평균보다 어느 축에서 전세와 실거래가 같이 버티는지 확인해야 한다.'
+ when target.display_name like '충청남도 아산시%' then '아산은 천안과 붙어 있는 수요, 탕정·배방 산업 배후, 새 아파트 선호가 핵심이다. 단기 판단: 선별 접근. 입주 물량이 겹치면 전세가 먼저 압박받을 수 있어 상승 논리는 전세 방어 확인 뒤가 더 깔끔하다.'
+ when target.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 불당·성성·두정 신축 선호와 수도권 남부 출퇴근 수요가 가격을 만든다. 단기 판단: 상승 지속 가능성 일부 우위. 전세 방어가 유지되는 단지 중심으로만 회복 신뢰도가 높다.'
+ when target.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심, 대학가, 역세권 수요가 중심이다. 단기 판단: 관망 우위. 서북구 신축 흐름을 그대로 가져오기보다 저가 실거주와 정비 기대의 실제 거래 전환을 확인해야 한다.'
+ when target.display_name like '충청남도 당진시%' then '당진은 제철·항만 산업 경기와 임대 수요가 가격 하방을 결정한다. 단기 판단: 보수적 관망. 산업 회복은 긍정적이나 고용 확인 전 산업 뉴스만으로 상승 지속성을 보기 어렵다.'
+ when target.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 자족 생활권이 가격을 설명한다. 단기 판단: 선별 관망. 산업 배후 수요가 뚜렷한 단지와 외곽 택지의 거래 깊이를 분리해야 한다.'
+ when target.display_name like '충청남도 홍성군%' or target.display_name like '충청남도 예산군%' then '내포권은 행정기관 수요가 장점이지만 신규 공급 흡수력이 더 중요하다. 단기 판단: 관망 우위. 전세 공실과 입주 속도 확인 전까지 가격 회복 판단은 보수적으로 보는 편이 맞다.'
+ when target.display_name like '충청남도 보령시%' or target.display_name like '충청남도 태안군%' then '충남 해안권은 관광과 세컨드하우스 수요가 보조 변수이고 상주 수요는 얇다. 단기 판단: 보수적 관망. 성수기 분위기보다 연중 거래 빈도와 임대 안정성이 우선이다.'
+ when target.display_name like '충청남도 %시%' then concat(target.display_name, '는 천안·아산 축과 자체 생활권 수요를 분리해 봐야 하는 시장이다. 단기 판단: 선별 관망. 전세와 거래 빈도가 같이 늘 때만 회복 신뢰도가 높다.')
+ when target.display_name like '충청남도 %군%' then concat(target.display_name, '는 거래 표본이 얇고 행정·산업·관광 수요의 영향이 제한적인 시장이다. 단기 판단: 관망 우위. 한두 건의 실거래보다 전세 유지와 인구 흐름 확인이 우선이다.')
+ when target.display_name like '부산광역시 해운대구%' then '해운대는 북항 이슈보다 해운대·센텀·마린시티의 전세 회복과 고가 주거 선호가 직접 변수다. 단기 판단: 선별 접근. 해안 고가 단지와 내륙 구축의 회복 속도 차이가 커질 수 있다.'
+ when target.display_name like '부산광역시%' then '부산은 북항·가덕도 기대가 도시 심리를 만들지만 가격은 해운대·수영·동래, 원도심, 서부산이 다르게 움직인다. 단기 판단: 선별 관망. 개발 뉴스보다 전세 회복과 입주 물량 충돌을 먼저 봐야 한다.'
+ when target.display_name like '대구광역시 수성구%' then '수성구는 대구 미분양 부담 속에서도 학군·핵심지 선호가 남아 있다. 단기 판단: 보수적 선별. 수성구 프리미엄은 전세가 회복되는 신축·준신축에서만 방어력이 높다.'
+ when target.display_name like '대구광역시%' then '대구는 미분양과 준공 후 미분양 부담이 아직 가격 상단을 제한한다. 단기 판단: 관망 우위. 핵심지 회복과 외곽 공급지 조정이 동시에 나타날 가능성이 높다.'
+ when target.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급 흡수가 동시에 확인돼야 한다. 단기 판단: 관망 우위. 정책 기대만 앞서면 변동성이 커지고, 입주 흡수가 확인되면 회복 논리가 생긴다.'
+ when target.display_name like '제주특별자치도%' then '제주는 관광 회복, 이주 수요, 주택 통계가 서로 다른 신호를 낸다. 단기 판단: 보수적 관망. 제주시와 서귀포·읍면을 섞어 보면 표본 착시가 커진다.'
+ when target.display_name like '광주광역시%' then '광주는 첨단산단과 도심 정비 기대가 있지만 생활권별 차이가 크다. 단기 판단: 선별 관망. 광산·북구 신축 수요와 동구·남구 정비 기대를 분리해야 한다.'
+ when target.display_name like '대전광역시 유성구%' then '유성구는 연구개발, 대학, 도안권 공급이 가격을 설명한다. 단기 판단: 선별 접근. 좋은 입지라도 입주 물량이 겹치면 전세가 먼저 약해질 수 있다.'
+ when target.display_name like '대전광역시%' then '대전은 유성·서구 중심 수요와 동구·대덕 정비·산업 수요가 다르다. 단기 판단: 선별 관망. 세종 연계 기대는 보조 변수이고 실제 판단은 전세와 입주 물량에 둬야 한다.'
+ when target.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요를 직접 흔든다. 단기 판단: 선별 접근. 산업 회복이 전세 수요로 확인되면 긍정적이나 단일 기업 뉴스만으로는 부족하다.'
+ when target.display_name like '강원도%' then '강원은 춘천·원주의 상주 수요와 강릉·속초의 관광 수요가 다르다. 단기 판단: 보수적 관망. 세컨드하우스 분위기보다 연중 전세 수요와 거래 빈도를 확인해야 한다.'
+ when target.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 충북에서 가장 두껍다. 단기 판단: 선별 접근. 산업 기대가 강할수록 분양가와 입주 물량도 같이 올라오므로 전세 방어가 우선이다.'
+ when target.display_name like '충청북도%' then '충북은 청주권 산업 수요와 충주·제천·군 단위 생활권의 거래 깊이가 다르다. 단기 판단: 관망 우위. 산업 뉴스가 약한 지역은 가격보다 거래 빈도와 전세 안정성이 더 중요하다.'
+ when target.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심이다. 단기 판단: 선별 관망. 새만금 기대를 전주 가격에 바로 붙이기보다 혁신도시·도심·신축 선호의 전세 확인이 필요하다.'
+ when target.display_name like '전라북도 군산시%' then '군산은 새만금과 산업단지 기대가 있지만 주택 수요 전환에는 시간이 걸린다. 단기 판단: 보수적 관망. 산업 뉴스보다 고용과 임대 수요가 먼저 움직이는지 확인해야 한다.'
+ when target.display_name like '전라북도%' then '전북은 전주 실거주, 군산·익산 산업축, 새만금 기대가 다르게 작동한다. 단기 판단: 관망 우위. 개발 기대보다 실제 거래와 전세 회복이 붙은 곳을 따로 봐야 한다.'
+ when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 전남에서 가장 뚜렷하다. 단기 판단: 선별 접근. 산업 경기와 상주 수요가 같이 받쳐주는 단지의 방어력이 높다.'
+ when target.display_name like '전라남도%' then '전남은 산업도시, 혁신도시, 해안 관광권의 거래 성격이 서로 다르다. 단기 판단: 보수적 관망. 군 단위는 한두 건의 실거래보다 인구와 임대 수요를 더 보수적으로 봐야 한다.'
+ when target.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대가 있지만 산업 경기 변동에 민감하다. 단기 판단: 선별 관망. 일자리 회복이 전세와 거래 수요로 같이 확인될 때 의미가 있다.'
+ when target.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 기반이 강하지만 경기 변동에 민감하다. 단기 판단: 선별 관망. 공장 증설 뉴스보다 임대 수요와 전입 흐름이 먼저 확인돼야 한다.'
+ when target.display_name like '경상북도%' then '경북은 포항·구미 산업축, 안동·예천 행정수요, 경주 관광권이 서로 다르다. 단기 판단: 관망 우위. 대구경북신공항 기대는 장기 변수로 보되 단기 판단은 거래와 전세가 우선이다.'
+ when target.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 같이 움직인다. 단기 판단: 선별 접근. 산업 회복이 전세 수요를 받쳐주면 긍정적이나 구축 정비 기대만으로는 부족하다.'
+ when target.display_name like '경상남도 김해시%' or target.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞인다. 단기 판단: 선별 관망. 부산 접근성 기대와 신규 공급 부담을 분리해야 한다.'
+ when target.display_name like '경상남도 거제시%' or target.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감하다. 단기 판단: 보수적 관망. 업황 회복이 전세 안정으로 이어지는지 확인해야 한다.'
+ when target.display_name like '경상남도%' then '경남은 창원 제조업, 김해·양산 부산 배후, 거제·통영 조선·관광권이 다르다. 단기 판단: 선별 관망. 광역 평균보다 산업과 전세 수요가 붙는 생활권을 먼저 봐야 한다.'
+ when region.region_level = 'sido' then concat(target.display_name, '는 광역 평균만으로 판단하기 어려운 시장이다. 단기 판단: 관망 우위. 하위 시군구의 전세, 입주 물량, 산업 수요가 같은 방향인지 먼저 확인해야 한다.')
+ when target.display_name like '%군' then concat(target.display_name, '는 거래 표본이 얇은 지역이다. 단기 판단: 보수적 관망. 가격 방향보다 실거래 빈도, 전세 수요, 인구 흐름 유지 여부가 핵심이다.')
+ when target.display_name like '%구' then concat(target.display_name, '는 같은 광역시 안에서도 역세권, 학군, 신축 선호가 가격을 가른다. 단기 판단: 선별 관망. 단기 등락률보다 전세와 실거래가 함께 움직이는 단지를 봐야 한다.')
+ else concat(target.display_name, '는 자체 생활권 수요와 상위 광역권 흐름을 분리해야 하는 시장이다. 단기 판단: 관망 우위. 개발 뉴스보다 전세, 거래 빈도, 입주 물량의 균형이 우선이다.')
+ end as body,
+ case
+ when target.display_name like '서울특별시 서초구%' then '서리풀2지구 공급 시차'
+ when target.display_name like '서울특별시%' then '전세 압력 지속'
+ when target.display_name like '경기도 용인시 처인구%' then '국가산단 수요 전환'
+ when target.display_name like '경기도 부천시%' then '정비·공급 동시 모멘텀'
+ when target.display_name like '인천광역시 계양구%' then '계양테크노밸리 입주 흡수'
+ when target.display_name like '충청남도 아산시%' then '탕정·배방 직주근접 수요'
+ when target.display_name like '충청남도 천안시 서북구%' then '신축 전세 방어력'
+ when target.display_name like '충청남도 천안시 동남구%' then '역세권·대학가 실거주 수요'
+ when target.display_name like '충청남도 당진시%' then '제철·항만 고용 안정'
+ when target.display_name like '충청남도 서산시%' then '대산 산업단지 배후 수요'
+ when target.display_name like '충청남도%' then '천안·아산 전세 동행'
+ when target.display_name like '부산광역시%' then '동부산 전세 회복'
+ when target.display_name like '대구광역시%' then '미분양 축소 확인'
+ when target.display_name like '세종특별자치시%' then '5생활권 공급 흡수'
+ when target.display_name like '제주특별자치도%' then '상주 수요 회복'
+ when target.display_name like '울산광역시%' then '산업 업황 전세 전환'
+ when target.display_name like '전라남도 여수시%' or target.display_name like '전라남도 순천시%' or target.display_name like '전라남도 광양시%' then '산업단지 상주 수요'
+ when target.display_name like '경상남도 창원시%' then '제조업 전세 수요'
+ else '전세·실거래 동행'
+ end as expectation_1,
+ case
+ when region.region_level = 'sido' then '하위 생활권 차별화'
+ when target.display_name like '%군' then null
+ when target.display_name like '서울특별시%' then '정비사업 일정 구체화'
+ when target.display_name like '경기도%' or target.display_name like '인천광역시%' then '교통·산업축 실수요 확인'
+ when target.display_name like '충청남도%' then '산업·행정 수요 확인'
+ else '공급 일정 가시화'
+ end as expectation_2,
+ case
+ when target.display_name like '서울특별시 서초구%' then '토지거래허가 부담'
+ when target.display_name like '서울특별시%' then '기대 선반영 부담'
+ when target.display_name like '경기도 용인시 처인구%' then '산업 뉴스 선반영 위험'
+ when target.display_name like '경기도 부천시%' then '공급·정비 기대 충돌'
+ when target.display_name like '인천광역시 계양구%' then '대규모 공급 흡수 부담'
+ when target.display_name like '충청남도 아산시%' then '입주 물량 부담'
+ when target.display_name like '충청남도 천안시 서북구%' then '신축 분양가 부담'
+ when target.display_name like '충청남도 당진시%' then '산업 경기 의존도'
+ when target.display_name like '충청남도%' then '광역 평균 착시'
+ when target.display_name like '부산광역시%' then '개발 뉴스 선반영'
+ when target.display_name like '대구광역시%' then '준공 후 미분양 부담'
+ when target.display_name like '세종특별자치시%' then '공급 흡수 전 변동성'
+ when target.display_name like '제주특별자치도%' then '관광 수요 과대평가'
+ when target.display_name like '%군' then '거래 표본 부족'
+ else '입주 물량 부담'
+ end as concern_1,
+ case
+ when target.display_name like '%군' then null
+ when region.region_level = 'sido' then '약한 하위 지역 잔존'
+ when target.display_name like '서울특별시%' then '거래량 없는 신고가'
+ when target.display_name like '경기도%' or target.display_name like '인천광역시%' then '입주 시점 전세 약세'
+ when target.display_name like '충청남도%' then '축별 수요 편차'
+ else '전세 약세 전이'
+ end as concern_2,
+ case
+ when region.region_level = 'sido' then 0.83
+ else 0.79
+ end as confidence
+ from real_estate_regions region
+ join real_estate_targets target on target.id = region.target_id
+ where region.region_level in ('sido', 'sigungu')
+) rewritten
+left join real_estate_regional_reports existing on existing.target_id = rewritten.target_id
+on duplicate key update
+ report_id = values(report_id),
+ report_version = values(report_version),
+ prompt_version = values(prompt_version),
+ model_name = values(model_name),
+ generated_by = values(generated_by),
+ title = values(title),
+ headline = values(headline),
+ summary = values(summary),
+ body = values(body),
+ expectation_points_json = values(expectation_points_json),
+ concern_points_json = values(concern_points_json),
+ data_quality = values(data_quality),
+ confidence = values(confidence),
+ as_of = values(as_of),
+ published_at = values(published_at),
+ updated_at = values(updated_at);
diff --git a/backend/src/main/resources/db/migration/V58__real_estate_seed_canonical_molit_targets.sql b/backend/src/main/resources/db/migration/V58__real_estate_seed_canonical_molit_targets.sql
new file mode 100644
index 0000000..342dc2e
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V58__real_estate_seed_canonical_molit_targets.sql
@@ -0,0 +1,33 @@
+insert into real_estate_market_data_targets (
+ target_id, provider, provider_dataset, lawd_code, enabled, refresh_interval_hours, stale_after_hours, created_at, updated_at
+)
+select
+ region.target_id,
+ 'molit',
+ dataset.provider_dataset,
+ region.legal_dong_code,
+ true,
+ 24,
+ 72,
+ '2026-06-25 00:00:00',
+ '2026-06-25 00:00:00'
+from real_estate_regions region
+join (
+ select 'molit_apt_trade' as provider_dataset
+ union all select 'molit_apt_rent'
+ union all select 'molit_offi_trade'
+ union all select 'molit_offi_rent'
+ union all select 'molit_rh_trade'
+ union all select 'molit_rh_rent'
+ union all select 'molit_sh_trade'
+ union all select 'molit_sh_rent'
+ union all select 'molit_silv_trade'
+) dataset
+where region.target_id in ('region-seoul-jongno', 'region-seoul-mapo')
+ and region.legal_dong_code is not null
+ and length(region.legal_dong_code) = 5
+on duplicate key update
+ enabled = values(enabled),
+ refresh_interval_hours = values(refresh_interval_hours),
+ stale_after_hours = values(stale_after_hours),
+ updated_at = values(updated_at);
diff --git a/backend/src/main/resources/db/migration/V59__real_estate_regional_assessment_report_rewrite.sql b/backend/src/main/resources/db/migration/V59__real_estate_regional_assessment_report_rewrite.sql
new file mode 100644
index 0000000..8b510ee
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V59__real_estate_regional_assessment_report_rewrite.sql
@@ -0,0 +1,299 @@
+insert into real_estate_regional_reports (
+ target_id,
+ report_id,
+ report_version,
+ prompt_version,
+ model_name,
+ generated_by,
+ title,
+ headline,
+ summary,
+ body,
+ expectation_points_json,
+ concern_points_json,
+ data_quality,
+ confidence,
+ as_of,
+ published_at,
+ created_at,
+ updated_at
+)
+select
+ rewritten.target_id,
+ concat('regional-report-', rewritten.target_id, '-regional-assessment-20260625'),
+ 'regional-report-assessment-v4',
+ 'codex-regional-assessment-rewrite-prompt-20260625',
+ 'codex-gpt-5-regional-assessment',
+ 'codex-quality-rewrite:regional-assessment-20260625',
+ concat(rewritten.display_name, ' 지역 평가 리포트'),
+ rewritten.headline,
+ rewritten.summary,
+ rewritten.body,
+ case
+ when rewritten.expectation_2 is null then concat('["', rewritten.expectation_1, '"]')
+ else concat('["', rewritten.expectation_1, '","', rewritten.expectation_2, '"]')
+ end,
+ case
+ when rewritten.concern_2 is null then concat('["', rewritten.concern_1, '"]')
+ else concat('["', rewritten.concern_1, '","', rewritten.concern_2, '"]')
+ end,
+ 'deep_researched',
+ rewritten.confidence,
+ '2026-06-25 00:00:00',
+ '2026-06-25 03:20:00',
+ coalesce(existing.created_at, '2026-06-25 03:20:00'),
+ '2026-06-25 03:20:00'
+from (
+ select
+ base.target_id,
+ base.region_level,
+ base.display_name,
+ case
+ when base.target_id = 'region-seoul' then '서울은 전세 압력과 핵심지 정비 기대가 가격 하방을 동시에 받치는 시장'
+ when base.target_id = 'region-seoul-gangnam' then '강남구는 학군·업무·정비사업 프리미엄이 분리되는 초핵심 시장'
+ when base.target_id = 'region-seoul-seocho' then '서초구는 서리풀2지구와 토지거래허가 부담을 함께 봐야 하는 시장'
+ when base.target_id = 'region-seoul-songpa' then '송파구는 잠실 대장 단지와 가락·문정 실수요가 갈리는 동남권 시장'
+ when base.target_id = 'region-seoul-yongsan' then '용산구는 개발 기대와 실입주 수요의 가격 간극이 큰 고가 시장'
+ when base.target_id = 'region-seoul-mapo' then '마포구는 상암·공덕·홍대 업무·상권 수요가 분산된 강북 핵심지'
+ when base.target_id = 'region-seoul-nowon' then '노원구는 노후 단지 정비 기대와 실수요 가격대가 충돌하는 시장'
+ when base.target_id in ('region-gyeonggi', 'region-41000') then '경기도는 정비·신도시 공급·산업축을 분리해야 하는 광역 시장'
+ when base.target_id = 'region-gyeonggi-yonginsicheoingu' then '용인 처인구는 반도체 국가산단 기대의 주거 수요 전환이 핵심'
+ when base.target_id = 'region-gyeonggi-bucheon' then '부천은 부천대장 공급과 중동 정비 기대가 동시에 작동하는 시장'
+ when base.target_id = 'region-incheon-gyeyang' then '계양구는 계양테크노밸리와 17천호 공급 흡수가 핵심인 시장'
+ when base.display_name = '충청남도' then '충남은 천안·아산, 내포, 서해안 산업권을 분리해야 하는 광역 시장'
+ when base.display_name like '충청남도 아산시%' then '아산은 탕정·배방 산업 배후와 천안 연계 수요가 가격을 가르는 시장'
+ when base.display_name like '충청남도 천안시 서북구%' then '천안 서북구는 신축 선호와 수도권 남부 출퇴근 수요가 겹친 시장'
+ when base.display_name like '충청남도 천안시 동남구%' then '천안 동남구는 원도심·대학·역세권 수요의 방어력 확인 구간'
+ when base.display_name like '충청남도 당진시%' then '당진은 제철·항만 고용과 임대 수요가 가격 하방을 결정하는 시장'
+ when base.display_name like '충청남도 서산시%' then '서산은 대산 산업단지와 자족 생활권 수요가 가격 설명력을 갖는 시장'
+ when base.display_name like '충청남도 보령시%' then '보령은 해양관광 체류 수요와 상주 주거 수요를 분리해야 하는 시장'
+ when base.display_name like '충청남도 공주시%' then '공주는 세종 배후 저가 수요와 대학·원도심 수요가 얇게 겹치는 시장'
+ when base.display_name like '충청남도 계룡시%' then '계룡은 대전 배후와 군 주거 수요가 시장 규모를 정하는 소형 시장'
+ when base.display_name like '충청남도 논산시%' then '논산은 국방·대학 임대 수요와 원도심 노후 주거가 공존하는 시장'
+ when base.display_name like '충청남도 홍성군%' then '홍성은 내포 행정수요와 홍성 원도심 수요가 분리되는 시장'
+ when base.display_name like '충청남도 예산군%' then '예산은 내포 배후 기대와 예산 원도심 유동성이 갈리는 시장'
+ when base.display_name like '충청남도 태안군%' then '태안은 관광·세컨드하우스 수요와 상주 수요의 괴리가 큰 시장'
+ when base.display_name like '충청남도 부여군%' then '부여는 백제문화 관광 수요보다 상주 인구와 거래 유동성이 중요한 시장'
+ when base.display_name like '충청남도 서천군%' then '서천은 장항·군산 연계와 해안 생활권 수요가 제한적인 시장'
+ when base.display_name like '충청남도 금산군%' then '금산은 대전권 배후와 농산업 수요가 얇게 작동하는 저유동성 시장'
+ when base.display_name like '충청남도 청양군%' then '청양은 소형 정주 수요와 인구 감소 압력을 함께 봐야 하는 시장'
+ when base.display_name like '부산광역시 해운대구%' then '해운대는 북항보다 동부산 고급 주거와 센텀 업무 수요가 직접 변수'
+ when base.display_name like '부산광역시%' then '부산은 북항·가덕도 심리와 생활권별 전세 회복이 분리되는 시장'
+ when base.display_name like '대구광역시 수성구%' then '수성구는 미분양 부담 속에서도 학군·핵심지 선호가 버티는 시장'
+ when base.display_name like '대구광역시%' then '대구는 미분양과 준공 후 미분양 부담이 가격 상단을 제한하는 시장'
+ when base.display_name like '세종특별자치시%' then '세종은 행정수도 기대와 5생활권 공급 흡수가 동시에 필요한 시장'
+ when base.display_name like '제주특별자치도%' then '제주는 관광·이주 수요와 주택 통계 신호가 갈리는 시장'
+ when base.display_name like '광주광역시%' then '광주는 첨단산단 기대와 생활권별 신축 수요가 분리되는 시장'
+ when base.display_name like '대전광역시 유성구%' then '유성구는 연구개발 수요와 도안권 공급 부담이 함께 작동하는 시장'
+ when base.display_name like '대전광역시%' then '대전은 유성·서구 중심 수요와 도안권 공급 부담을 같이 봐야 하는 시장'
+ when base.display_name like '울산광역시%' then '울산은 자동차·조선·석유화학 업황이 주거 수요를 좌우하는 시장'
+ when base.display_name like '강원도%' then '강원은 춘천·원주 상주 수요와 영동 관광 수요가 분리되는 시장'
+ when base.display_name like '충청북도 청주시%' then '청주는 오송·오창 산업축과 도심 생활수요가 함께 작동하는 시장'
+ when base.display_name like '충청북도%' then '충북은 청주권 산업 수요와 중부내륙 거래 깊이 차이가 큰 시장'
+ when base.display_name like '전라북도 전주시%' then '전주는 전북 실거주 수요의 중심축'
+ when base.display_name like '전라북도 군산시%' then '군산은 새만금·산단 기대의 주거 수요 전환 속도가 핵심인 시장'
+ when base.display_name like '전라북도%' then '전북은 전주 생활수요와 새만금 산업 기대를 분리해야 하는 시장'
+ when base.display_name like '전라남도 여수시%' or base.display_name like '전라남도 순천시%' or base.display_name like '전라남도 광양시%' then '여수·순천·광양권은 산업단지와 생활권 수요가 겹치는 전남 핵심축'
+ when base.display_name like '전라남도%' then '전남은 산업도시·혁신도시·해안 관광권의 거래 성격이 분리되는 시장'
+ when base.display_name like '경상북도 포항시%' then '포항은 철강·배터리 기대와 산업 경기 변동성이 공존하는 시장'
+ when base.display_name like '경상북도 구미시%' then '구미는 전자·첨단산업 회복의 전세 전환이 핵심인 시장'
+ when base.display_name like '경상북도%' then '경북은 포항·구미 산업축과 안동·예천 행정수요가 분리되는 시장'
+ when base.display_name like '경상남도 창원시%' then '창원은 제조업 경기와 노후 주거지 정비가 함께 작동하는 시장'
+ when base.display_name like '경상남도 김해시%' or base.display_name like '경상남도 양산시%' then '김해·양산은 부산권 배후 주거와 자체 산업 수요가 혼재된 시장'
+ when base.display_name like '경상남도 거제시%' or base.display_name like '경상남도 통영시%' then '거제·통영은 조선업과 관광 수요에 민감한 해안 생활권'
+ when base.display_name like '경상남도%' then '경남은 창원 제조업과 부산권 배후, 남해안 산업권이 분리되는 시장'
+ when base.region_level = 'sido' then concat(base.display_name, '는 하위 생활권별 수급 차이가 큰 광역 시장')
+ when base.display_name like '%군' then concat(base.local_name, ' 주택시장은 거래 표본과 상주 수요 확인이 우선인 저유동성 시장')
+ when base.display_name like '%구' then concat(base.local_name, ' 주택시장은 역세권·학군·신축 선호가 가격을 가르는 세부 시장')
+ else concat(base.local_name, ' 주택시장은 자체 생활권 수요와 광역 흐름을 함께 봐야 하는 시장')
+ end as headline,
+ '실거래·전세·공급·정책 변수를 지역별 평가와 전망으로 압축한 리포트입니다.' as summary,
+ case
+ when base.target_id = 'region-seoul' then '평가: 서울은 강남3구와 용산이 가격 발견을 주도하고, 전세 압력은 강북 노후 단지까지 하방을 받치는 구조다. 전망: 회복 가능성 우위. 다만 서리풀2지구 2,000호는 장기 공급 변수라 단기 가격 논리보다 착공·분양 일정 확인이 우선이다.'
+ when base.target_id = 'region-seoul-gangnam' then '평가: 강남구는 대치·청담·삼성·개포·압구정이 각각 다른 가격표를 가진 시장이다. 전망: 방어력 우위. 학군·업무 접근성은 강하지만 전세가율, 토지거래허가, 정비사업 속도가 같이 확인돼야 한다.'
+ when base.target_id = 'region-seoul-seocho' then '평가: 서초구는 서리풀2지구, 반포·잠원 정비 기대, 토지거래허가 부담이 공존한다. 전망: 선별 관망. 핵심 신축 방어력은 높지만 기대 선반영 구간은 전세 확인 없이는 부담이 커진다.'
+ when base.target_id = 'region-seoul-songpa' then '평가: 송파구는 잠실 대장 단지와 가락·문정 실거주 수요가 다른 속도로 움직인다. 전망: 선별 회복 가능성. 주변 구축까지 실거래가 동행할 때 상승 신뢰도가 높아진다.'
+ when base.target_id = 'region-seoul-yongsan' then '평가: 용산구는 국제업무지구와 한남·이촌 정비 기대가 강하지만 실입주 수요의 가격대가 얇다. 전망: 보수적 관망. 거래량 없이 기대만 오른 구간은 변동성 확대 위험이 크다.'
+ when base.target_id = 'region-seoul-mapo' then '평가: 마포구는 상암·공덕·홍대 수요가 분산된 시장이고, 강남권 대체재 논리는 공덕·아현 전세와 상암 업무 수요가 같이 버틸 때 유효하다. 전망: 선별 회복 가능성. 확인 지표는 전세 방어와 직주근접 거래 반복이다.'
+ when base.target_id = 'region-seoul-nowon' then '평가: 노원구는 노후 단지 비중이 높고 재건축 기대와 실수요 가격대가 충돌하는 시장이다. 전망: 관망 우위. 전세가 먼저 회복되지 않으면 정비 기대만으로 매매 회복을 설명하기 어렵다.'
+ when base.target_id in ('region-gyeonggi', 'region-41000') then '평가: 경기도는 1기 신도시 정비, 3기 신도시, 용인 반도체가 각각 다른 수급 경로를 만든다. 전망: 선별 회복 가능성. 서울 대체재, 산업 배후, 교통 기대 지역을 한 결론으로 묶으면 판단이 흐려진다.'
+ when base.target_id = 'region-gyeonggi-yonginsicheoingu' then '평가: 용인 처인구는 첨단시스템반도체 국가산단 이슈가 핵심 프리미엄이지만, 산업 뉴스가 곧바로 주거 수요로 전환되지는 않는다. 전망: 선별 관망. 원삼·남사 전세와 입주 흡수가 먼저 확인돼야 한다.'
+ when base.target_id = 'region-gyeonggi-bucheon' then '평가: 부천은 부천대장 공급과 중동 1기 신도시 정비가 동시에 작동한다. 전망: 보수적 선별. 새 아파트 선호와 구축 재건축 프리미엄이 충돌해 생활권 내부 격차가 커질 수 있다.'
+ when base.target_id = 'region-incheon-gyeyang' then '평가: 계양구는 계양테크노밸리와 17천호 공급이 함께 들어오는 구간이다. 전망: 보수적 관망. 서울 접근성보다 입주 시점 전세 방어와 검단·부천대장 수요 중복이 더 중요하다.'
+ when base.display_name = '충청남도' then '평가: 충남은 천안·아산의 수도권 남부 수요, 내포 행정수요, 당진·서산의 서해안 산업축이 분리되는 시장이다. 전망: 선별 관망. 광역 평균보다 축별 전세 방어와 실거래 반복 여부가 가격 판단의 핵심이다.'
+ when base.display_name like '충청남도 아산시%' then '평가: 아산은 천안과 붙어 있는 수요, 탕정·배방 산업 배후, 새 아파트 선호가 핵심이다. 전망: 선별 회복 가능성. 입주 물량이 겹치면 전세가 먼저 압박받으므로 전세 방어가 확인되는 단지만 프리미엄 유지력이 있다.'
+ when base.display_name like '충청남도 천안시 서북구%' then '평가: 천안 서북구는 불당·성성·두정 신축 선호와 수도권 남부 출퇴근 수요가 가격을 만든다. 전망: 회복 가능성 우위. 다만 신축 분양가와 전세가 벌어지면 상승 지속성은 약해진다.'
+ when base.display_name like '충청남도 천안시 동남구%' then '평가: 천안 동남구는 원도심, 대학가, 역세권 수요가 중심이고 서북구 신축 흐름을 그대로 따라가기 어렵다. 전망: 관망 우위. 저가 실거주와 정비 기대가 실제 거래로 반복되는지 확인해야 한다.'
+ when base.display_name like '충청남도 당진시%' then '평가: 당진은 제철·항만 산업 경기와 임대 수요가 가격 하방을 결정한다. 전망: 보수적 관망. 산업 회복은 긍정적이나 고용 확인 전 산업 뉴스만으로 상승 지속성을 보기 어렵다.'
+ when base.display_name like '충청남도 서산시%' then '평가: 서산은 대산 산업단지와 자족 생활권이 가격을 설명한다. 전망: 선별 관망. 산업 배후 수요가 뚜렷한 단지와 외곽 택지의 거래 깊이를 분리해야 한다.'
+ when base.display_name like '충청남도 보령시%' then '평가: 보령은 해양관광 체류 수요가 있으나 상주 수요의 두께는 제한적이다. 전망: 보수적 관망. 관광 뉴스보다 연중 전세 수요와 실거래 반복성이 가격 방어력을 좌우한다.'
+ when base.display_name like '충청남도 공주시%' then '평가: 공주는 세종 배후 저가 수요와 대학·원도심 수요가 얇게 겹친다. 전망: 관망 우위. 세종 접근성 기대만으로는 부족하고 대학가 임대와 원도심 거래 회복이 같이 확인돼야 한다.'
+ when base.display_name like '충청남도 계룡시%' then '평가: 계룡은 대전 배후 주거와 군 주거 수요가 시장 규모를 정하는 소형 시장이다. 전망: 선별 관망. 공급이 조금만 늘어도 전세가 흔들릴 수 있어 유동성 확인이 먼저다.'
+ when base.display_name like '충청남도 논산시%' then '평가: 논산은 국방·대학 임대 수요가 방어 요인이지만 원도심 노후 주거 비중이 높다. 전망: 보수적 관망. 임대 수요가 매매 회복으로 전환되는 속도는 느리게 보는 편이 맞다.'
+ when base.display_name like '충청남도 홍성군%' then '평가: 홍성은 내포 행정수요가 장점이지만 홍성 원도심과 내포 신축의 가격 논리가 다르다. 전망: 관망 우위. 전세 공실과 입주 흡수 속도 확인 전까지 회복 판단은 보수적이다.'
+ when base.display_name like '충청남도 예산군%' then '평가: 예산은 내포 배후 기대가 있으나 예산 원도심 거래 유동성은 얇다. 전망: 관망 우위. 행정수요가 실제 전세와 매매 거래로 반복되는지가 핵심이다.'
+ when base.display_name like '충청남도 태안군%' then '평가: 태안은 관광·세컨드하우스 수요가 가격 기대를 만들 수 있지만 상주 수요는 얇다. 전망: 보수적 관망. 성수기 분위기보다 연중 거래 빈도와 임대 안정성이 우선이다.'
+ when base.display_name like '충청남도 부여군%' then '평가: 부여는 백제문화 관광 수요가 보조 변수지만 주택시장은 상주 인구와 거래 유동성이 더 중요하다. 전망: 보수적 관망. 고령화와 얇은 거래 표본이 가격 회복 속도를 제한한다.'
+ when base.display_name like '충청남도 서천군%' then '평가: 서천은 장항·군산 연계 수요가 있으나 자체 주거 수요의 깊이는 제한적이다. 전망: 보수적 관망. 산업·해안 이슈보다 전세 유지와 실거래 빈도 확인이 우선이다.'
+ when base.display_name like '충청남도 금산군%' then '평가: 금산은 대전권 배후와 농산업 수요가 얇게 작동하는 시장이다. 전망: 보수적 관망. 외부 수요 유입보다 인구 흐름과 전세 유지가 가격 방어의 핵심이다.'
+ when base.display_name like '충청남도 청양군%' then '평가: 청양은 소형 정주 수요가 중심이고 거래 표본이 얇아 가격 발견력이 낮다. 전망: 보수적 관망. 인구 감소 압력과 거래 공백이 이어지면 회복 신호가 늦게 나타난다.'
+ when base.display_name like '부산광역시 해운대구%' then '평가: 해운대는 북항 이슈보다 해운대·센텀·마린시티의 전세 회복과 고가 주거 선호가 직접 변수다. 전망: 선별 회복 가능성. 해안 고가 단지와 내륙 구축의 격차가 커질 수 있다.'
+ when base.display_name like '부산광역시%' then '평가: 부산은 북항·가덕도 기대가 도시 심리를 만들지만 가격은 해운대·수영·동래, 원도심, 서부산이 다르게 움직인다. 전망: 선별 관망. 개발 뉴스보다 전세 회복과 입주 물량 충돌이 중요하다.'
+ when base.display_name like '대구광역시 수성구%' then '평가: 수성구는 대구 미분양 부담 속에서도 학군·핵심지 선호가 남아 있다. 전망: 보수적 선별. 수성구 프리미엄은 전세가 회복되는 신축·준신축에서만 방어력이 높다.'
+ when base.display_name like '대구광역시%' then '평가: 대구는 미분양과 준공 후 미분양 부담이 아직 가격 상단을 제한한다. 전망: 관망 우위. 핵심지 회복과 외곽 공급지 조정이 동시에 나타날 가능성이 높다.'
+ when base.display_name like '세종특별자치시%' then '평가: 세종은 행정수도 기대와 5생활권 공급 흡수가 동시에 확인돼야 한다. 전망: 관망 우위. 정책 기대만 앞서면 변동성이 커지고, 입주 흡수가 확인되면 회복 논리가 생긴다.'
+ when base.display_name like '제주특별자치도%' then '평가: 제주는 관광 회복, 이주 수요, 주택 통계가 서로 다른 신호를 낸다. 전망: 보수적 관망. 제주시와 서귀포·읍면을 섞어 보면 표본 착시가 커진다.'
+ when base.display_name like '광주광역시%' then '평가: 광주는 첨단산단과 도심 정비 기대가 있지만 생활권별 차이가 크다. 전망: 선별 관망. 광산·북구 신축 수요와 동구·남구 정비 기대를 분리해야 한다.'
+ when base.display_name like '대전광역시 유성구%' then '평가: 유성구는 연구개발, 대학, 도안권 공급이 가격을 설명한다. 전망: 선별 회복 가능성. 좋은 입지라도 입주 물량이 겹치면 전세가 먼저 약해질 수 있다.'
+ when base.display_name like '대전광역시%' then '평가: 대전은 유성·서구 중심 수요와 동구·대덕 정비·산업 수요가 다르다. 전망: 선별 관망. 세종 연계 기대는 보조 변수이고 실제 판단은 전세와 입주 물량에 둬야 한다.'
+ when base.display_name like '울산광역시%' then '평가: 울산은 자동차·조선·석유화학 업황이 주거 수요를 직접 흔든다. 전망: 선별 회복 가능성. 산업 회복이 전세 수요로 확인되면 긍정적이나 단일 기업 뉴스만으로는 부족하다.'
+ when base.display_name like '강원도%' then '평가: 강원은 춘천·원주의 상주 수요와 강릉·속초의 관광 수요가 다르다. 전망: 보수적 관망. 세컨드하우스 분위기보다 연중 전세 수요와 거래 빈도를 확인해야 한다.'
+ when base.display_name like '충청북도 청주시%' then '평가: 청주는 오송·오창 산업축과 도심 생활수요가 충북에서 가장 두껍다. 전망: 선별 회복 가능성. 산업 기대가 강할수록 분양가와 입주 물량도 같이 올라오므로 전세 방어가 우선이다.'
+ when base.display_name like '충청북도%' then '평가: 충북은 청주권 산업 수요와 충주·제천·군 단위 생활권의 거래 깊이가 다르다. 전망: 관망 우위. 산업 뉴스가 약한 지역은 가격보다 거래 빈도와 전세 안정성이 더 중요하다.'
+ when base.display_name like '전라북도 전주시%' then '평가: 전주는 전북 실거주 수요의 중심이다. 전망: 선별 관망. 새만금 기대를 전주 가격에 바로 붙이기보다 혁신도시·도심·신축 선호의 전세 확인이 필요하다.'
+ when base.display_name like '전라북도 군산시%' then '평가: 군산은 새만금과 산업단지 기대가 있지만 주택 수요 전환에는 시간이 걸린다. 전망: 보수적 관망. 산업 뉴스보다 고용과 임대 수요가 먼저 움직이는지 확인해야 한다.'
+ when base.display_name like '전라북도%' then '평가: 전북은 전주 실거주, 군산·익산 산업축, 새만금 기대가 다르게 작동한다. 전망: 관망 우위. 개발 기대보다 실제 거래와 전세 회복이 붙은 곳을 따로 봐야 한다.'
+ when base.display_name like '전라남도 여수시%' or base.display_name like '전라남도 순천시%' or base.display_name like '전라남도 광양시%' then '평가: 여수·순천·광양권은 산업단지와 생활권 수요가 전남에서 가장 뚜렷하다. 전망: 선별 회복 가능성. 산업 경기와 상주 수요가 같이 받쳐주는 단지의 방어력이 높다.'
+ when base.display_name like '전라남도%' then '평가: 전남은 산업도시, 혁신도시, 해안 관광권의 거래 성격이 서로 다르다. 전망: 보수적 관망. 군 단위는 한두 건의 실거래보다 인구와 임대 수요를 더 보수적으로 봐야 한다.'
+ when base.display_name like '경상북도 포항시%' then '평가: 포항은 철강·배터리 기대가 있지만 산업 경기 변동에 민감하다. 전망: 선별 관망. 일자리 회복이 전세와 거래 수요로 같이 확인될 때 의미가 있다.'
+ when base.display_name like '경상북도 구미시%' then '평가: 구미는 전자·첨단산업 기반이 강하지만 경기 변동에 민감하다. 전망: 선별 관망. 공장 증설 뉴스보다 임대 수요와 전입 흐름이 먼저 확인돼야 한다.'
+ when base.display_name like '경상북도%' then '평가: 경북은 포항·구미 산업축, 안동·예천 행정수요, 경주 관광권이 서로 다르다. 전망: 관망 우위. 대구경북신공항 기대는 장기 변수로 보되 단기 판단은 거래와 전세가 우선이다.'
+ when base.display_name like '경상남도 창원시%' then '평가: 창원은 제조업 경기와 노후 주거지 정비가 같이 움직인다. 전망: 선별 회복 가능성. 산업 회복이 전세 수요를 받쳐주면 긍정적이나 구축 정비 기대만으로는 부족하다.'
+ when base.display_name like '경상남도 김해시%' or base.display_name like '경상남도 양산시%' then '평가: 김해·양산은 부산권 배후 주거와 자체 산업 수요가 섞인다. 전망: 선별 관망. 부산 접근성 기대와 신규 공급 부담을 분리해야 한다.'
+ when base.display_name like '경상남도 거제시%' or base.display_name like '경상남도 통영시%' then '평가: 거제·통영은 조선업과 관광 수요에 민감하다. 전망: 보수적 관망. 업황 회복이 전세 안정으로 이어지는지 확인해야 한다.'
+ when base.display_name like '경상남도%' then '평가: 경남은 창원 제조업, 김해·양산 부산 배후, 거제·통영 조선·관광권이 다르다. 전망: 선별 관망. 광역 평균보다 산업과 전세 수요가 붙는 생활권을 먼저 봐야 한다.'
+ when base.region_level = 'sido' then concat('평가: ', base.display_name, ' 주택시장은 광역 평균만으로 판단하기 어려운 시장이다. 전망: 관망 우위. 하위 시군구의 전세, 입주 물량, 산업 수요가 같은 방향인지 먼저 확인해야 한다.')
+ when base.display_name like '%군' then concat('평가: ', base.local_name, ' 주택시장은 거래 표본이 얇고 상주 수요 확인이 우선이다. 전망: 보수적 관망. 가격 방향보다 실거래 빈도, 전세 수요, 인구 흐름 유지 여부가 핵심이다.')
+ when base.display_name like '%구' then concat('평가: ', base.local_name, ' 주택시장은 같은 광역시 안에서도 역세권, 학군, 신축 선호가 가격을 가른다. 전망: 선별 관망. 단기 등락률보다 전세와 실거래가 함께 움직이는 단지를 봐야 한다.')
+ else concat('평가: ', base.local_name, ' 주택시장은 자체 생활권 수요와 상위 광역권 흐름을 분리해야 하는 시장이다. 전망: 관망 우위. 개발 뉴스보다 전세, 거래 빈도, 입주 물량의 균형이 우선이다.')
+ end as body,
+ case
+ when base.display_name like '서울특별시 서초구%' then '서리풀2지구 공급 시차'
+ when base.display_name like '서울특별시%' then '전세 압력 지속'
+ when base.display_name like '경기도 용인시 처인구%' then '국가산단 수요 전환'
+ when base.display_name like '경기도 부천시%' then '정비·공급 동시 모멘텀'
+ when base.display_name like '인천광역시 계양구%' then '계양테크노밸리 입주 흡수'
+ when base.display_name like '충청남도 아산시%' then '탕정·배방 직주근접 수요'
+ when base.display_name like '충청남도 천안시 서북구%' then '신축 전세 방어력'
+ when base.display_name like '충청남도 천안시 동남구%' then '역세권·대학가 실거주 수요'
+ when base.display_name like '충청남도 당진시%' then '제철·항만 고용 안정'
+ when base.display_name like '충청남도 서산시%' then '대산 산단 배후 수요'
+ when base.display_name like '충청남도 보령시%' then '해양관광 체류 수요'
+ when base.display_name like '충청남도 공주시%' then '세종 배후 저가 수요'
+ when base.display_name like '충청남도 계룡시%' then '대전 배후 군 주거'
+ when base.display_name like '충청남도 논산시%' then '국방·대학 임대 수요'
+ when base.display_name like '충청남도 홍성군%' then '내포 행정수요 흡수'
+ when base.display_name like '충청남도 예산군%' then '내포 배후 정주 수요'
+ when base.display_name like '충청남도 태안군%' then '관광 체류 수요'
+ when base.display_name like '충청남도 부여군%' then '백제문화 관광 수요'
+ when base.display_name like '충청남도 서천군%' then '장항·군산 연계 수요'
+ when base.display_name like '충청남도 금산군%' then '대전권 농산업 배후'
+ when base.display_name like '충청남도 청양군%' then '소형 정주 수요'
+ when base.display_name like '부산광역시%' then '동부산 전세 회복'
+ when base.display_name like '대구광역시%' then '미분양 축소 확인'
+ when base.display_name like '세종특별자치시%' then '5생활권 공급 흡수'
+ when base.display_name like '제주특별자치도%' then '상주 수요 회복'
+ when base.display_name like '울산광역시%' then '산업 업황 전세 전환'
+ when base.display_name like '전라남도 여수시%' or base.display_name like '전라남도 순천시%' or base.display_name like '전라남도 광양시%' then '산업단지 상주 수요'
+ when base.display_name like '경상남도 창원시%' then '제조업 전세 수요'
+ when base.region_level = 'sido' then concat(base.local_name, ' 생활권 차별화')
+ when base.display_name like '%군' then concat(base.local_name, ' 거래 회복 확인')
+ when base.display_name like '%구' then concat(base.local_name, ' 전세 방어 확인')
+ else concat(base.local_name, ' 실거래 회복')
+ end as expectation_1,
+ case
+ when base.region_level = 'sido' then '하위 생활권 차별화'
+ when base.display_name like '%군' then null
+ when base.display_name like '서울특별시%' then '정비사업 일정 구체화'
+ when base.display_name like '경기도%' or base.display_name like '인천광역시%' then '교통·산업축 실수요 확인'
+ when base.display_name like '충청남도%' then concat(base.local_name, ' 전세 유지')
+ else concat(base.local_name, ' 공급 일정 확인')
+ end as expectation_2,
+ case
+ when base.display_name like '서울특별시 서초구%' then '토지거래허가 부담'
+ when base.display_name like '서울특별시%' then '기대 선반영 부담'
+ when base.display_name like '경기도 용인시 처인구%' then '산업 뉴스 선반영 위험'
+ when base.display_name like '경기도 부천시%' then '공급·정비 기대 충돌'
+ when base.display_name like '인천광역시 계양구%' then '대규모 공급 흡수 부담'
+ when base.display_name like '충청남도 아산시%' then '입주 물량 부담'
+ when base.display_name like '충청남도 천안시 서북구%' then '신축 분양가 부담'
+ when base.display_name like '충청남도 천안시 동남구%' then '원도심 회복 지연'
+ when base.display_name like '충청남도 당진시%' then '산업 경기 의존도'
+ when base.display_name like '충청남도 서산시%' then '외곽 택지 유동성 부족'
+ when base.display_name like '충청남도 보령시%' then '상주 수요 부족'
+ when base.display_name like '충청남도 공주시%' then '원도심 거래 얇음'
+ when base.display_name like '충청남도 계룡시%' then '소형 시장 유동성'
+ when base.display_name like '충청남도 논산시%' then '노후 주거 비중'
+ when base.display_name like '충청남도 홍성군%' then '내포 공급 흡수 부담'
+ when base.display_name like '충청남도 예산군%' then '원도심 유동성 부족'
+ when base.display_name like '충청남도 태안군%' then '관광 수요 과대평가'
+ when base.display_name like '충청남도 부여군%' then '고령화·거래 부족'
+ when base.display_name like '충청남도 서천군%' then '자체 수요 한계'
+ when base.display_name like '충청남도 금산군%' then '외부 유입 제한'
+ when base.display_name like '충청남도 청양군%' then '인구 감소 압력'
+ when base.display_name like '부산광역시%' then '개발 뉴스 선반영'
+ when base.display_name like '대구광역시%' then '준공 후 미분양 부담'
+ when base.display_name like '세종특별자치시%' then '공급 흡수 전 변동성'
+ when base.display_name like '제주특별자치도%' then '관광 수요 과대평가'
+ when base.display_name like '%군' then concat(base.local_name, ' 유동성 부족')
+ when base.display_name like '%구' then concat(base.local_name, ' 신축·구축 격차')
+ else concat(base.local_name, ' 입주·전세 부담')
+ end as concern_1,
+ case
+ when base.display_name like '%군' then null
+ when base.region_level = 'sido' then '약한 하위 지역 잔존'
+ when base.display_name like '서울특별시%' then '거래량 없는 신고가'
+ when base.display_name like '경기도%' or base.display_name like '인천광역시%' then '입주 시점 전세 약세'
+ when base.display_name like '충청남도%' then concat(base.local_name, ' 수요 편차')
+ else concat(base.local_name, ' 전세 약세 전이')
+ end as concern_2,
+ case
+ when base.region_level = 'sido' then 0.84
+ else 0.80
+ end as confidence
+ from (
+ select
+ region.target_id,
+ region.region_level,
+ target.display_name,
+ replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(target.display_name,
+ '서울특별시 ', ''),
+ '부산광역시 ', ''),
+ '대구광역시 ', ''),
+ '인천광역시 ', ''),
+ '광주광역시 ', ''),
+ '대전광역시 ', ''),
+ '울산광역시 ', ''),
+ '세종특별자치시 ', ''),
+ '경기도 ', ''),
+ '강원도 ', ''),
+ '충청북도 ', ''),
+ '충청남도 ', ''),
+ '전라북도 ', ''),
+ '전라남도 ', ''),
+ '경상북도 ', ''),
+ '경상남도 ', ''),
+ '제주특별자치도 ', '') as local_name
+ from real_estate_regions region
+ join real_estate_targets target on target.id = region.target_id
+ where region.region_level in ('sido', 'sigungu')
+ ) base
+) rewritten
+left join real_estate_regional_reports existing on existing.target_id = rewritten.target_id
+on duplicate key update
+ report_id = values(report_id),
+ report_version = values(report_version),
+ prompt_version = values(prompt_version),
+ model_name = values(model_name),
+ generated_by = values(generated_by),
+ title = values(title),
+ headline = values(headline),
+ summary = values(summary),
+ body = values(body),
+ expectation_points_json = values(expectation_points_json),
+ concern_points_json = values(concern_points_json),
+ data_quality = values(data_quality),
+ confidence = values(confidence),
+ as_of = values(as_of),
+ published_at = values(published_at),
+ updated_at = values(updated_at);
diff --git a/backend/src/main/resources/db/migration/V60__real_estate_regional_assessment_phrase_cleanup.sql b/backend/src/main/resources/db/migration/V60__real_estate_regional_assessment_phrase_cleanup.sql
new file mode 100644
index 0000000..ed5a05f
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V60__real_estate_regional_assessment_phrase_cleanup.sql
@@ -0,0 +1,5 @@
+update real_estate_regional_reports
+set body = replace(body, '단기 판단은 거래와 전세가 우선이다.', '단기 해석은 거래와 전세가 우선이다.'),
+ updated_at = '2026-06-25 03:30:00'
+where generated_by = 'codex-quality-rewrite:regional-assessment-20260625'
+ and body like '%단기 판단은 거래와 전세가 우선이다.%';
diff --git a/backend/src/main/resources/db/migration/V61__real_estate_capital_area_report_point_diversification.sql b/backend/src/main/resources/db/migration/V61__real_estate_capital_area_report_point_diversification.sql
new file mode 100644
index 0000000..d3b0e9f
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V61__real_estate_capital_area_report_point_diversification.sql
@@ -0,0 +1,135 @@
+update real_estate_regional_reports
+set report_version = 'regional-report-assessment-v5',
+ prompt_version = 'codex-regional-assessment-diversification-prompt-20260625',
+ model_name = 'codex-gpt-5-regional-assessment',
+ headline = '화성시는 GTX-A 동탄역 접근성과 반도체 배후 수요, 외곽 택지 입주 부담이 동시에 작동하는 시장',
+ summary = '화성시는 동탄역권과 삼성 화성·기흥 반도체 배후 수요가 가격 하방을 만들지만, 동탄2·봉담·남양권 입주 흡수 여부가 상승 지속성을 제한하는 구간입니다.',
+ body = '평가: 화성시는 동탄역권과 삼성 화성·기흥 반도체 배후 수요가 가장 선명하고, 봉담·남양·향남은 같은 화성 안에서도 가격 설명력이 다릅니다.
+전망: 선별 관망. GTX-A 접근성과 반도체 배후 수요는 긍정적이나 외곽 택지 입주가 전세를 누르면 매매 회복은 동탄 핵심권 위주로 제한될 가능성이 큽니다.',
+ expectation_points_json = '["삼성 화성·기흥 배후 수요","GTX-A 동탄역 접근성"]',
+ concern_points_json = '["동탄2·봉담 입주 흡수","외곽 택지 전세 민감도"]',
+ confidence = 0.84,
+ as_of = '2026-06-25 00:00:00',
+ published_at = '2026-06-25 05:40:00',
+ updated_at = '2026-06-25 05:40:00'
+where target_id = 'region-gyeonggi-hwaseongsi'
+ and generated_by = 'codex-quality-rewrite:regional-assessment-20260625';
+
+update real_estate_regional_reports
+set report_version = 'regional-report-assessment-v5',
+ prompt_version = 'codex-regional-assessment-diversification-prompt-20260625',
+ model_name = 'codex-gpt-5-regional-assessment',
+ headline = '광명시는 뉴타운 신축 전환과 철산·하안 구축 격차를 분리해야 하는 서울 서남권 대체 시장',
+ summary = '광명시는 서울 서남권 대체 수요와 광명뉴타운 신축 선호가 강하지만, 철산·하안 구축까지 같은 속도로 따라오기는 어려운 시장입니다.',
+ body = '평가: 광명시는 광명뉴타운 신축 전환이 가격 기준을 끌어올리고, 서울 구로·금천·영등포 접근성이 실수요를 받치는 구조입니다.
+전망: 선별 회복 가능성. 신축 입주권과 준신축은 방어력이 있으나 철산·하안 구축은 전세와 거래량이 동행하지 않으면 가격 격차가 더 벌어질 수 있습니다.',
+ expectation_points_json = '["광명뉴타운 신축 전환","서울 서남권 대체 수요"]',
+ concern_points_json = '["철산·하안 구축 격차","정비 이주·입주 시차"]',
+ confidence = 0.84,
+ as_of = '2026-06-25 00:00:00',
+ published_at = '2026-06-25 05:40:00',
+ updated_at = '2026-06-25 05:40:00'
+where target_id = 'region-gyeonggi-gwangmyeong'
+ and generated_by = 'codex-quality-rewrite:regional-assessment-20260625';
+
+update real_estate_regional_reports
+set report_version = 'regional-report-assessment-v5',
+ prompt_version = 'codex-regional-assessment-diversification-prompt-20260625',
+ model_name = 'codex-gpt-5-regional-assessment',
+ headline = '성남 수정구는 위례 접근성과 원도심 정비 기대가 공존하지만 공급 경쟁을 피하기 어려운 시장',
+ summary = '성남 수정구는 위례·판교 접근 생활권이라는 장점이 있으나, 위례 공급 경쟁과 원도심 구축 유동성을 함께 봐야 합니다.',
+ body = '평가: 성남 수정구는 위례와 판교 접근성이 장점이지만, 원도심과 위례 생활권의 수요층이 완전히 같지는 않습니다.
+전망: 보수적 선별. 정비 기대가 있는 구간은 관심을 받을 수 있으나 위례 공급 경쟁과 구축 유동성이 약하면 단기 상승은 제한될 가능성이 높습니다.',
+ expectation_points_json = '["위례·판교 접근 생활권","성남 원도심 정비 기대"]',
+ concern_points_json = '["위례 공급 경쟁","원도심 구축 유동성"]',
+ confidence = 0.82,
+ as_of = '2026-06-25 00:00:00',
+ published_at = '2026-06-25 05:40:00',
+ updated_at = '2026-06-25 05:40:00'
+where target_id = 'region-gyeonggi-seongnamsisujeonggu'
+ and generated_by = 'codex-quality-rewrite:regional-assessment-20260625';
+
+update real_estate_regional_reports
+set report_version = 'regional-report-assessment-v5',
+ prompt_version = 'codex-regional-assessment-diversification-prompt-20260625',
+ model_name = 'codex-gpt-5-regional-assessment',
+ headline = '성남 중원구는 산업 배후와 노후 주거 비중이 맞물려 가격 탄력이 제한되는 시장',
+ summary = '성남 중원구는 성남하이테크밸리와 모란·상대원 생활권 수요가 있으나, 노후 주거 비중과 분당·판교 대체 한계가 부담입니다.',
+ body = '평가: 성남 중원구는 성남하이테크밸리 배후 수요와 모란·상대원 상권 접근성이 있지만, 주거 상품성은 분당·판교와 뚜렷하게 갈립니다.
+전망: 관망 우위. 저가 실거주 수요는 방어력이 있으나 노후 단지 비중이 높아 전세와 거래량 회복이 확인되기 전까지 상승 탄력은 제한적입니다.',
+ expectation_points_json = '["성남하이테크밸리 배후","모란·상대원 상권 수요"]',
+ concern_points_json = '["노후 주거 비중","분당·판교 대체 한계"]',
+ confidence = 0.82,
+ as_of = '2026-06-25 00:00:00',
+ published_at = '2026-06-25 05:40:00',
+ updated_at = '2026-06-25 05:40:00'
+where target_id = 'region-gyeonggi-seongnamsijungwongu'
+ and generated_by = 'codex-quality-rewrite:regional-assessment-20260625';
+
+update real_estate_regional_reports
+set report_version = 'regional-report-assessment-v5',
+ prompt_version = 'codex-regional-assessment-diversification-prompt-20260625',
+ model_name = 'codex-gpt-5-regional-assessment',
+ headline = '분당구는 판교 업무 배후와 1기 신도시 정비 기대가 강하지만 고가 선반영 부담이 큰 시장',
+ summary = '분당구는 판교 업무 배후 전세 수요와 1기 신도시 정비 기대가 핵심이나, 고가 구간은 재건축 일정이 길어질수록 부담이 커집니다.',
+ body = '평가: 분당구는 판교 업무 배후 수요와 학군·생활 인프라가 강해 성남권 안에서도 가장 방어력이 높은 구간입니다.
+전망: 선별 회복 가능성. 다만 고가 구간은 이미 정비 기대가 상당 부분 반영돼 있어 재건축 일정과 전세가 동행하지 않으면 당분간 가격 부담이 남습니다.',
+ expectation_points_json = '["판교 업무 배후 전세","1기 신도시 정비 기대"]',
+ concern_points_json = '["고가 구간 선반영","재건축 일정 장기화"]',
+ confidence = 0.85,
+ as_of = '2026-06-25 00:00:00',
+ published_at = '2026-06-25 05:40:00',
+ updated_at = '2026-06-25 05:40:00'
+where target_id = 'region-gyeonggi-seongnamsibundanggu'
+ and generated_by = 'codex-quality-rewrite:regional-assessment-20260625';
+
+update real_estate_regional_reports
+set report_version = 'regional-report-assessment-v5',
+ prompt_version = 'codex-regional-assessment-diversification-prompt-20260625',
+ model_name = 'codex-gpt-5-regional-assessment',
+ headline = '성북구는 길음·장위 정비 신축과 외곽 구축의 온도 차가 커지는 동북권 시장',
+ summary = '성북구는 길음·장위 정비 신축과 동북권 실거주 가격대가 기대 요인이지만, 외곽 구축 거래 둔화와 기대 선반영은 부담입니다.',
+ body = '평가: 성북구는 길음·장위 정비 신축이 가격 기준을 만들고, 동북권 실거주 가격대가 하방을 받치는 시장입니다.
+전망: 선별 관망. 신축·준신축은 방어력이 있으나 외곽 구축은 거래량이 얇아 정비 기대만으로 지속 상승을 설명하기 어렵습니다.',
+ expectation_points_json = '["길음·장위 정비 신축","동북권 실거주 가격대"]',
+ concern_points_json = '["외곽 구축 거래 둔화","정비 기대 선반영"]',
+ confidence = 0.82,
+ as_of = '2026-06-25 00:00:00',
+ published_at = '2026-06-25 05:40:00',
+ updated_at = '2026-06-25 05:40:00'
+where target_id = 'region-seoul-seongbuk'
+ and generated_by = 'codex-quality-rewrite:regional-assessment-20260625';
+
+update real_estate_regional_reports
+set report_version = 'regional-report-assessment-v5',
+ prompt_version = 'codex-regional-assessment-diversification-prompt-20260625',
+ model_name = 'codex-gpt-5-regional-assessment',
+ headline = '구로구는 G밸리 직주근접 수요가 뚜렷하지만 구축 노후화가 가격 상단을 누르는 시장',
+ summary = '구로구는 G밸리와 신도림·구로 환승 수요가 실수요를 만들지만, 서남권 구축 노후화와 산업 배후 수요 변동성을 분리해야 합니다.',
+ body = '평가: 구로구는 G밸리 직주근접과 신도림·구로 환승 수요가 뚜렷해 실거주 기반은 분명합니다.
+전망: 관망 우위. 직주근접 수요는 하방을 받치지만 구축 노후화와 산업 배후 수요 변동성이 커서 전세 방어가 확인되기 전까지 상승 지속성은 제한적입니다.',
+ expectation_points_json = '["G밸리 직주근접 수요","신도림·구로 환승 수요"]',
+ concern_points_json = '["서남권 구축 노후화","산업 배후 수요 변동"]',
+ confidence = 0.82,
+ as_of = '2026-06-25 00:00:00',
+ published_at = '2026-06-25 05:40:00',
+ updated_at = '2026-06-25 05:40:00'
+where target_id = 'region-seoul-guro'
+ and generated_by = 'codex-quality-rewrite:regional-assessment-20260625';
+
+update real_estate_regional_reports
+set report_version = 'regional-report-assessment-v5',
+ prompt_version = 'codex-regional-assessment-diversification-prompt-20260625',
+ model_name = 'codex-gpt-5-regional-assessment',
+ headline = '도봉구는 GTX-C·창동상계 기대가 있으나 착공 전 기대 선반영을 경계해야 하는 시장',
+ summary = '도봉구는 GTX-C와 창동상계 개발 기대, 중저가 실거주 수요가 장점이나 착공 전 기대 선반영과 노후 단지 전세 약세가 부담입니다.',
+ body = '평가: 도봉구는 GTX-C와 창동상계 개발 기대가 가장 큰 재평가 논리이고, 서울 안 중저가 실거주 수요가 하방을 지지합니다.
+전망: 보수적 관망. 교통·개발 기대가 가격에 먼저 반영될 수 있어 착공·사업 속도와 노후 단지 전세 회복이 확인되기 전까지 추격 매수 논리는 약합니다.',
+ expectation_points_json = '["GTX-C·창동상계 기대","중저가 실거주 방어"]',
+ concern_points_json = '["착공 전 기대 선반영","노후 단지 전세 약세"]',
+ confidence = 0.82,
+ as_of = '2026-06-25 00:00:00',
+ published_at = '2026-06-25 05:40:00',
+ updated_at = '2026-06-25 05:40:00'
+where target_id = 'region-seoul-dobong'
+ and generated_by = 'codex-quality-rewrite:regional-assessment-20260625';
diff --git a/backend/src/main/resources/db/migration/V62__real_estate_regional_point_evidence_sources.sql b/backend/src/main/resources/db/migration/V62__real_estate_regional_point_evidence_sources.sql
new file mode 100644
index 0000000..fbe87de
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V62__real_estate_regional_point_evidence_sources.sql
@@ -0,0 +1,258 @@
+insert into real_estate_regional_report_sources (
+ id,
+ report_target_id,
+ display_order,
+ ref_type,
+ ref_id,
+ label,
+ title,
+ url,
+ source_name,
+ published_at,
+ data_status,
+ created_at,
+ updated_at
+) values
+ (
+ 'regional-report-source-region-gyeonggi-hwaseongsi-point-gtx-a',
+ 'region-gyeonggi-hwaseongsi',
+ 11,
+ 'external',
+ 'molit-gtx-a-suseo-dongtan-20240321',
+ '지역 쟁점 근거',
+ 'GTX-A 수서~동탄, 정기적 이용자 2~3천원대로 이용 가능',
+ 'https://www.korea.kr/briefing/pressReleaseView.do?newsId=156621217',
+ '국토교통부',
+ '2024-03-21 00:00:00',
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-gyeonggi-hwaseongsi-point-samsung',
+ 'region-gyeonggi-hwaseongsi',
+ 12,
+ 'external',
+ 'samsung-semiconductor-giheung-hwaseong-campus',
+ '지역 쟁점 근거',
+ '삼성반도체 45년 사업장의 성장 기록',
+ 'https://news.samsungsemiconductor.com/kr/%EB%B0%98%EB%8F%84%EC%B2%B4-%EC%97%AD%EC%82%AC%EC%8B%9C%EB%A6%AC%EC%A6%88-6%ED%83%84-%EC%82%BC%EC%84%B1%EB%B0%98%EB%8F%84%EC%B2%B4-45%EB%85%84-%EC%82%AC%EC%97%85%EC%9E%A5%EC%9D%98-%EC%84%B1/',
+ '삼성전자 반도체 뉴스룸',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-gyeonggi-gwangmyeong-point-newtown',
+ 'region-gyeonggi-gwangmyeong',
+ 11,
+ 'external',
+ 'gwangmyeong-newtown-overview',
+ '지역 쟁점 근거',
+ '광명뉴타운사업 개발배경·사업개요',
+ 'https://www.gm.go.kr/pt/partInfo/ud/newtown/gm_newtown/PTMN302.jsp',
+ '광명시',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-gyeonggi-gwangmyeong-point-cheolsan-haan',
+ 'region-gyeonggi-gwangmyeong',
+ 12,
+ 'external',
+ 'gwangmyeong-cheolsan-haan-rebuild',
+ '지역 쟁점 근거',
+ '철산·하안택지지구 재건축 사업 추진 현황',
+ 'https://news.gm.go.kr/bbs/view.html?idxno=5990&sc_category=',
+ '광명시 뉴스포털',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-gyeonggi-seongnamsisujeonggu-point-renewal',
+ 'region-gyeonggi-seongnamsisujeonggu',
+ 11,
+ 'external',
+ 'seongnam-old-town-renewal-support-center',
+ '지역 쟁점 근거',
+ '성남시 원도심 도시정비 지원센터 개소',
+ 'https://www.seongnam.go.kr/city/1000060/30005/bbsView.do?idx=377575%26',
+ '성남시',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-gyeonggi-seongnamsisujeonggu-point-wirye',
+ 'region-gyeonggi-seongnamsisujeonggu',
+ 12,
+ 'external',
+ 'lh-wirye-district-jurisdiction',
+ '지역 쟁점 근거',
+ 'LH 서울 위례사업본부 관할구역',
+ 'https://www.lh.or.kr/user/areaHdofc/comeView.es?mid=a10708020000®_sn=4',
+ '한국토지주택공사',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-gyeonggi-seongnamsijungwongu-point-hightech',
+ 'region-gyeonggi-seongnamsijungwongu',
+ 11,
+ 'external',
+ 'seongnam-hightech-valley-status',
+ '지역 쟁점 근거',
+ '성남하이테크밸리 산업단지 현황',
+ 'https://www.seongnam.go.kr/city/1000310/10191/contents.do',
+ '성남시',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-gyeonggi-seongnamsijungwongu-point-renewal',
+ 'region-gyeonggi-seongnamsijungwongu',
+ 12,
+ 'external',
+ 'seongnam-renewal-project-status',
+ '지역 쟁점 근거',
+ '성남시 정비사업현황',
+ 'https://www.seongnam.go.kr/city/1001100/30564/bbsList.do',
+ '성남시',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-gyeonggi-seongnamsibundanggu-point-pangyo',
+ 'region-gyeonggi-seongnamsibundanggu',
+ 11,
+ 'external',
+ 'pangyo-technovalley-2025-company-status',
+ '지역 쟁점 근거',
+ '2025년도 제1·2판교테크노밸리 실태조사 결과',
+ 'https://www.pangyotechnovalley.org/base/menu/baseView?menuLevel=2&menuNo=20',
+ '판교테크노밸리',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-seoul-seongbuk-point-jangwi',
+ 'region-seoul-seongbuk',
+ 11,
+ 'external',
+ 'seoul-jangwi-13-shintong',
+ '지역 쟁점 근거',
+ '성북구 장위동 219-90, 224-12 일대 재개발',
+ 'https://news.seoul.go.kr/citybuild/archives/537769',
+ '서울특별시',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-seoul-seongbuk-point-gileum',
+ 'region-seoul-seongbuk',
+ 12,
+ 'external',
+ 'seongbuk-gileum-renewal-status',
+ '지역 쟁점 근거',
+ '성북구 길음재정비촉진 구역별 현황',
+ 'https://www.sb.go.kr/www/contents.do?key=6378',
+ '성북구청',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-seoul-guro-point-gvalley',
+ 'region-seoul-guro',
+ 11,
+ 'external',
+ 'seoul-garibong-gvalley-shintong',
+ '지역 쟁점 근거',
+ '가리봉동 115일대 신속통합기획 확정, G밸리 직주근접 주거지',
+ 'https://www.seoul.go.kr/news/news_report.do?nttNo=404186',
+ '서울특별시',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-seoul-guro-point-garibong1',
+ 'region-seoul-guro',
+ 12,
+ 'external',
+ 'seoul-garibong1-redevelopment-20260326',
+ '지역 쟁점 근거',
+ '구로구 가리봉1구역 재개발',
+ 'https://news.seoul.go.kr/citybuild/archives/531600',
+ '서울특별시',
+ '2026-03-26 00:00:00',
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-seoul-dobong-point-changdong-sanggye',
+ 'region-seoul-dobong',
+ 11,
+ 'external',
+ 'seoul-balanced-changdong-sanggye',
+ '지역 쟁점 근거',
+ '서울시 지역균형발전계획 창동상계 신경제중심지',
+ 'https://uri.seoul.go.kr/web/contents/23.do?mid=1083',
+ '서울균형발전포털',
+ null,
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ ),
+ (
+ 'regional-report-source-region-seoul-dobong-point-gtx-c',
+ 'region-seoul-dobong',
+ 12,
+ 'external',
+ 'molit-gtx-c-changdong-underground-20230511',
+ '지역 쟁점 근거',
+ 'GTX-C 창동역 지하화 한다',
+ 'https://www.molit.go.kr/LCMS/DWN.jsp?fileName=230511%28%EC%A1%B0%EA%B0%84%29_GTX-C_%EC%B0%BD%EB%8F%99%EC%97%AD_%EC%A7%80%ED%95%98%ED%99%94_%ED%95%9C%EB%8B%A4%28%EC%88%98%EB%8F%84%EA%B6%8C%EA%B4%91%EC%97%AD%EA%B8%89%ED%96%89%EC%B2%A0%EB%8F%84%EA%B3%BC%29.pdf&fold=koreaNews%2Fmobile%2Ffile',
+ '국토교통부',
+ '2023-05-11 00:00:00',
+ 'ok',
+ '2026-06-25 06:10:00',
+ '2026-06-25 06:10:00'
+ )
+on duplicate key update
+ display_order = values(display_order),
+ ref_type = values(ref_type),
+ ref_id = values(ref_id),
+ label = values(label),
+ title = values(title),
+ url = values(url),
+ source_name = values(source_name),
+ published_at = values(published_at),
+ data_status = values(data_status),
+ updated_at = values(updated_at);
+
+update real_estate_regional_report_sources
+set display_order = 12,
+ label = '지역 쟁점 근거',
+ title = '경기도, 분당·일산 기본계획 승인. 1기 신도시 5곳 모두 정비 본격화',
+ updated_at = '2026-06-25 06:10:00'
+where id = 'regional-report-source-region-gyeonggi-seongnamsibundanggu-gyeonggi-06-first-newtown';
diff --git a/backend/src/main/resources/db/migration/V63__user_oauth_accounts.sql b/backend/src/main/resources/db/migration/V63__user_oauth_accounts.sql
new file mode 100644
index 0000000..20c8efc
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V63__user_oauth_accounts.sql
@@ -0,0 +1,15 @@
+create table user_oauth_accounts (
+ id char(36) not null,
+ user_id char(36) not null,
+ provider varchar(40) not null,
+ provider_user_id varchar(128) not null,
+ email varchar(255),
+ display_name varchar(100),
+ created_at datetime(6) not null,
+ last_login_at datetime(6) not null,
+ primary key (id),
+ constraint fk_user_oauth_accounts_user foreign key (user_id) references app_users (id),
+ constraint uk_user_oauth_provider_subject unique (provider, provider_user_id)
+);
+
+create index idx_user_oauth_accounts_user_id on user_oauth_accounts (user_id);
diff --git a/backend/src/main/resources/db/migration/V64__user_watch_targets.sql b/backend/src/main/resources/db/migration/V64__user_watch_targets.sql
new file mode 100644
index 0000000..ec7604a
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V64__user_watch_targets.sql
@@ -0,0 +1,15 @@
+create table user_watch_targets (
+ id char(36) not null,
+ user_id char(36) not null,
+ target_type varchar(30) not null,
+ target_id varchar(160) not null,
+ display_name varchar(120) not null,
+ landing_path varchar(600) not null,
+ created_at datetime(6) not null,
+ updated_at datetime(6) not null,
+ primary key (id),
+ constraint fk_user_watch_targets_user foreign key (user_id) references app_users (id),
+ constraint uk_user_watch_targets_user_target unique (user_id, target_type, target_id)
+);
+
+create index idx_user_watch_targets_user_updated_at on user_watch_targets (user_id, updated_at, created_at);
diff --git a/backend/src/main/resources/db/migration/V65__chat_messages.sql b/backend/src/main/resources/db/migration/V65__chat_messages.sql
new file mode 100644
index 0000000..8645997
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V65__chat_messages.sql
@@ -0,0 +1,11 @@
+CREATE TABLE chat_messages (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ user_id VARCHAR(36) NOT NULL,
+ author_display_name VARCHAR(20) NOT NULL,
+ body VARCHAR(500) NOT NULL,
+ category VARCHAR(20) NOT NULL,
+ created_at TIMESTAMP(6) NOT NULL,
+ CONSTRAINT fk_chat_messages_user FOREIGN KEY (user_id) REFERENCES app_users(id)
+);
+
+CREATE INDEX idx_chat_messages_created_at ON chat_messages (created_at DESC, id DESC);
diff --git a/backend/src/main/resources/db/migration/V66__chat_message_attachments.sql b/backend/src/main/resources/db/migration/V66__chat_message_attachments.sql
new file mode 100644
index 0000000..ab8be25
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V66__chat_message_attachments.sql
@@ -0,0 +1,2 @@
+ALTER TABLE chat_messages
+ ADD COLUMN attachment_json TEXT;
diff --git a/backend/src/main/resources/db/migration/V67__real_estate_alias_autocomplete_index.sql b/backend/src/main/resources/db/migration/V67__real_estate_alias_autocomplete_index.sql
new file mode 100644
index 0000000..9c1739a
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V67__real_estate_alias_autocomplete_index.sql
@@ -0,0 +1,2 @@
+CREATE INDEX idx_real_estate_aliases_autocomplete_match
+ ON real_estate_aliases (review_state, ambiguous, normalized_alias, target_id);
diff --git a/backend/src/main/resources/db/migration/V68__guest_chat_messages.sql b/backend/src/main/resources/db/migration/V68__guest_chat_messages.sql
new file mode 100644
index 0000000..e5c47c3
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V68__guest_chat_messages.sql
@@ -0,0 +1,7 @@
+ALTER TABLE chat_messages
+ MODIFY user_id VARCHAR(36) NULL;
+
+ALTER TABLE chat_messages
+ ADD COLUMN session_id VARCHAR(96) NULL;
+
+CREATE INDEX idx_chat_messages_session_id ON chat_messages (session_id);
diff --git a/backend/src/test/java/com/youbuyfirst/backend/AuthSessionIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/AuthSessionIntegrationTest.java
index 9457df8..5d6f5c6 100644
--- a/backend/src/test/java/com/youbuyfirst/backend/AuthSessionIntegrationTest.java
+++ b/backend/src/test/java/com/youbuyfirst/backend/AuthSessionIntegrationTest.java
@@ -71,6 +71,25 @@ void rejectsCurrentUserWithoutSession() {
assertThat(me.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
+ @Test
+ void reportsOAuthProvidersAsUnavailableWhenClientCredentialsAreMissing() throws Exception {
+ ResponseEntity providers = restTemplate.getForEntity("/api/auth/oauth/providers", String.class);
+
+ assertThat(providers.getStatusCode()).isEqualTo(HttpStatus.OK);
+ JsonNode body = objectMapper.readTree(providers.getBody());
+ assertThat(body).hasSize(3);
+ assertThat(body.findValuesAsText("provider")).containsExactly("google", "naver", "kakao");
+ assertThat(body.findValuesAsText("displayName")).containsExactly("Google", "Naver", "Kakao");
+ assertThat(body.findValues("configured")).allSatisfy(configured ->
+ assertThat(configured.asBoolean()).isFalse()
+ );
+ assertThat(body.findValuesAsText("authorizationUrl")).containsExactly(
+ "/oauth2/authorization/google",
+ "/oauth2/authorization/naver",
+ "/oauth2/authorization/kakao"
+ );
+ }
+
@Test
void logsInWithPasswordAndInvalidatesSessionOnLogout() throws Exception {
restTemplate.postForEntity(
diff --git a/backend/src/test/java/com/youbuyfirst/backend/ChatMessageIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/ChatMessageIntegrationTest.java
new file mode 100644
index 0000000..c9f1d54
--- /dev/null
+++ b/backend/src/test/java/com/youbuyfirst/backend/ChatMessageIntegrationTest.java
@@ -0,0 +1,309 @@
+package com.youbuyfirst.backend;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+class ChatMessageIntegrationTest {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @LocalServerPort
+ private int port;
+
+ @Test
+ void storesChatMessagesOnTheServerAndReadsThemWithoutThePostingSession() throws Exception {
+ HttpHeaders authHeaders = new HttpHeaders();
+ authHeaders.add(HttpHeaders.COOKIE, sessionCookieHeader(registerUser(
+ "chatuser01",
+ "chatuser01@example.com",
+ "행복"
+ )));
+
+ ResponseEntity posted = restTemplate.exchange(
+ "/api/chat/messages",
+ HttpMethod.POST,
+ new HttpEntity<>(Map.of(
+ "body", "localhost에서 보낸 서버 채팅",
+ "displayName", "행복한집"
+ ), authHeaders),
+ String.class
+ );
+
+ assertThat(posted.getStatusCode()).isEqualTo(HttpStatus.CREATED);
+ JsonNode postedBody = objectMapper.readTree(posted.getBody());
+ assertThat(postedBody.path("author").asText()).isEqualTo("행복한집");
+ assertThat(postedBody.path("body").asText()).isEqualTo("localhost에서 보낸 서버 채팅");
+ assertThat(postedBody.path("category").asText()).isEqualTo("chat");
+ assertThat(postedBody.path("mine").asBoolean()).isTrue();
+ assertThat(postedBody.path("verified").asBoolean()).isTrue();
+
+ ResponseEntity readWithoutCookie = restTemplate.getForEntity("/api/chat/messages", String.class);
+
+ assertThat(readWithoutCookie.getStatusCode()).isEqualTo(HttpStatus.OK);
+ JsonNode messages = objectMapper.readTree(readWithoutCookie.getBody());
+ assertThat(messages).anySatisfy(message -> {
+ assertThat(message.path("author").asText()).isEqualTo("행복한집");
+ assertThat(message.path("body").asText()).isEqualTo("localhost에서 보낸 서버 채팅");
+ assertThat(message.path("verified").asBoolean()).isTrue();
+ });
+ }
+
+ @Test
+ void storesGuestChatMessagesWithoutLoginAndMarksThemUnverified() throws Exception {
+ HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/chat/messages"))
+ .header(HttpHeaders.CONTENT_TYPE, "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"body\":\"비로그인도 채팅 참여\",\"displayName\":\"손님분석가\"}",
+ StandardCharsets.UTF_8
+ ))
+ .build();
+ HttpResponse posted = HttpClient.newHttpClient().send(
+ request,
+ HttpResponse.BodyHandlers.ofString()
+ );
+
+ assertThat(posted.statusCode()).isEqualTo(HttpStatus.CREATED.value());
+ JsonNode postedBody = objectMapper.readTree(posted.body());
+ assertThat(postedBody.path("author").asText()).isEqualTo("손님분석가");
+ assertThat(postedBody.path("body").asText()).isEqualTo("비로그인도 채팅 참여");
+ assertThat(postedBody.path("mine").asBoolean()).isTrue();
+ assertThat(postedBody.path("verified").asBoolean()).isFalse();
+
+ ResponseEntity readWithoutCookie = restTemplate.getForEntity("/api/chat/messages?limit=20", String.class);
+
+ assertThat(readWithoutCookie.getStatusCode()).isEqualTo(HttpStatus.OK);
+ JsonNode messages = objectMapper.readTree(readWithoutCookie.getBody());
+ assertThat(messages).anySatisfy(message -> {
+ assertThat(message.path("author").asText()).isEqualTo("손님분석가");
+ assertThat(message.path("verified").asBoolean()).isFalse();
+ });
+ }
+
+ @Test
+ void rejectsGuestChatDisplayNameAlreadyUsedByRegisteredUser() throws Exception {
+ registerUser(
+ "reservedchat01",
+ "reservedchat01@example.com",
+ "ReservedBuyer"
+ );
+
+ ResponseEntity availability = restTemplate.getForEntity(
+ "/api/chat/nickname-availability?displayName={displayName}",
+ String.class,
+ "ReservedBuyer"
+ );
+
+ assertThat(availability.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(objectMapper.readTree(availability.getBody()).path("available").asBoolean()).isFalse();
+
+ HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/chat/messages"))
+ .header(HttpHeaders.CONTENT_TYPE, "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"body\":\"guest collision\",\"displayName\":\"ReservedBuyer\"}",
+ StandardCharsets.UTF_8
+ ))
+ .build();
+ HttpResponse posted = HttpClient.newHttpClient().send(
+ request,
+ HttpResponse.BodyHandlers.ofString()
+ );
+
+ assertThat(posted.statusCode()).isEqualTo(HttpStatus.CONFLICT.value());
+ }
+
+ @Test
+ void storesChatMessageAttachmentsAndReadsThemWithLandingPath() throws Exception {
+ HttpHeaders authHeaders = new HttpHeaders();
+ authHeaders.add(HttpHeaders.COOKIE, sessionCookieHeader(registerUser(
+ "attachchat01",
+ "attachchat01@example.com",
+ "첨부테스터"
+ )));
+
+ ResponseEntity posted = restTemplate.exchange(
+ "/api/chat/messages",
+ HttpMethod.POST,
+ new HttpEntity<>(Map.of(
+ "body", "대전 유성구 리포트 같이 봐요",
+ "displayName", "첨부테스터",
+ "attachment", Map.of(
+ "type", "region",
+ "targetId", "region-daejeon-yuseong",
+ "title", "대전 유성구",
+ "subtitle", "지역 분석 · 최근 1주",
+ "metricLabel", "최근 1주",
+ "metricValue", "+0.31%",
+ "metricTone", "up",
+ "landingPath", "/realestate/map/region-daejeon?selectedTargetId=region-daejeon-yuseong&selectedRegionCode=25040&period=week"
+ )
+ ), authHeaders),
+ String.class
+ );
+
+ assertThat(posted.getStatusCode()).isEqualTo(HttpStatus.CREATED);
+ JsonNode postedBody = objectMapper.readTree(posted.getBody());
+ assertThat(postedBody.path("attachment").path("type").asText()).isEqualTo("region");
+ assertThat(postedBody.path("attachment").path("targetId").asText()).isEqualTo("region-daejeon-yuseong");
+ assertThat(postedBody.path("attachment").path("metricValue").asText()).isEqualTo("+0.31%");
+ assertThat(postedBody.path("verified").asBoolean()).isTrue();
+ assertThat(postedBody.path("attachment").path("landingPath").asText())
+ .isEqualTo("/realestate/map/region-daejeon?selectedTargetId=region-daejeon-yuseong&selectedRegionCode=25040&period=week");
+
+ ResponseEntity readWithoutCookie = restTemplate.getForEntity("/api/chat/messages?limit=20", String.class);
+
+ assertThat(readWithoutCookie.getStatusCode()).isEqualTo(HttpStatus.OK);
+ JsonNode messages = objectMapper.readTree(readWithoutCookie.getBody());
+ assertThat(messages).anySatisfy(message -> {
+ assertThat(message.path("body").asText()).isEqualTo("대전 유성구 리포트 같이 봐요");
+ assertThat(message.path("attachment").path("title").asText()).isEqualTo("대전 유성구");
+ assertThat(message.path("attachment").path("landingPath").asText()).contains("/realestate/map/region-daejeon");
+ });
+ }
+
+ @Test
+ void capsRecentChatMessagesAtOneHundredEvenWhenClientRequestsMore() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ for (int i = 1; i <= 105; i++) {
+ String body = String.format("limit-msg-%03d", i);
+ HttpRequest request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/chat/messages"))
+ .header(HttpHeaders.CONTENT_TYPE, "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(
+ "{\"body\":\"" + body + "\",\"displayName\":\"LimitGuest\"}",
+ StandardCharsets.UTF_8
+ ))
+ .build();
+ HttpResponse posted = client.send(
+ request,
+ HttpResponse.BodyHandlers.ofString()
+ );
+
+ assertThat(posted.statusCode()).isEqualTo(HttpStatus.CREATED.value());
+ }
+
+ ResponseEntity readWithOversizedLimit = restTemplate.getForEntity(
+ "/api/chat/messages?limit=500",
+ String.class
+ );
+
+ assertThat(readWithOversizedLimit.getStatusCode()).isEqualTo(HttpStatus.OK);
+ JsonNode messages = objectMapper.readTree(readWithOversizedLimit.getBody());
+ assertThat(messages).hasSize(100);
+ assertThat(messages.get(0).path("body").asText()).isEqualTo("limit-msg-006");
+ assertThat(messages.get(99).path("body").asText()).isEqualTo("limit-msg-105");
+ assertThat(readWithOversizedLimit.getBody()).doesNotContain("limit-msg-001");
+ }
+
+ @Test
+ void streamsCreatedChatMessagesToOpenClientsWithoutRefresh() throws Exception {
+ HttpClient client = HttpClient.newHttpClient();
+ HttpRequest streamRequest = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/api/chat/messages/stream"))
+ .GET()
+ .build();
+
+ HttpResponse stream = client.send(
+ streamRequest,
+ HttpResponse.BodyHandlers.ofInputStream()
+ );
+ assertThat(stream.statusCode()).isEqualTo(HttpStatus.OK.value());
+
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream.body(), StandardCharsets.UTF_8))) {
+ String streamedBody = "새로고침 없이 받는 서버 채팅";
+ CompletableFuture sawStreamedMessage = CompletableFuture.supplyAsync(
+ () -> containsSseLine(reader, streamedBody),
+ executor
+ );
+ HttpHeaders authHeaders = new HttpHeaders();
+ authHeaders.add(HttpHeaders.COOKIE, sessionCookieHeader(registerUser(
+ "streamchat01",
+ "streamchat01@example.com",
+ "스트림"
+ )));
+
+ ResponseEntity posted = restTemplate.exchange(
+ "/api/chat/messages",
+ HttpMethod.POST,
+ new HttpEntity<>(Map.of(
+ "body", streamedBody,
+ "displayName", "스트림"
+ ), authHeaders),
+ String.class
+ );
+
+ assertThat(posted.getStatusCode()).isEqualTo(HttpStatus.CREATED);
+ assertThat(sawStreamedMessage.get(10, TimeUnit.SECONDS)).isTrue();
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ private ResponseEntity registerUser(String username, String email, String displayName) {
+ return restTemplate.postForEntity(
+ "/api/auth/register",
+ Map.of(
+ "username", username,
+ "email", email,
+ "password", "watch-1234!",
+ "displayName", displayName
+ ),
+ String.class
+ );
+ }
+
+ private static String sessionCookieHeader(ResponseEntity response) {
+ return response.getHeaders().getOrEmpty(HttpHeaders.SET_COOKIE)
+ .stream()
+ .filter(cookie -> cookie.startsWith("JSESSIONID="))
+ .findFirst()
+ .map(cookie -> cookie.split(";", 2)[0])
+ .orElseThrow();
+ }
+
+ private static boolean containsSseLine(BufferedReader reader, String expected) {
+ try {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (line.contains(expected)) {
+ return true;
+ }
+ }
+ return false;
+ } catch (IOException ex) {
+ throw new CompletionException(ex);
+ }
+ }
+}
diff --git a/backend/src/test/java/com/youbuyfirst/backend/ChatPresenceIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/ChatPresenceIntegrationTest.java
new file mode 100644
index 0000000..9af8136
--- /dev/null
+++ b/backend/src/test/java/com/youbuyfirst/backend/ChatPresenceIntegrationTest.java
@@ -0,0 +1,38 @@
+package com.youbuyfirst.backend;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+class ChatPresenceIntegrationTest {
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Test
+ void returnsActiveChatHeartbeatCountForChatPresence() throws Exception {
+ ResponseEntity presence = restTemplate.getForEntity("/api/chat/presence", String.class);
+
+ assertThat(presence.getStatusCode()).isEqualTo(HttpStatus.OK);
+ assertThat(presence.getHeaders().getOrEmpty(HttpHeaders.SET_COOKIE))
+ .anyMatch(cookie -> cookie.startsWith("JSESSIONID="));
+
+ JsonNode body = objectMapper.readTree(presence.getBody());
+ assertThat(body.path("activeSessionCount").asInt()).isGreaterThanOrEqualTo(1);
+ assertThat(body.path("source").asText()).isEqualTo("chat_presence_heartbeat");
+ }
+}
diff --git a/backend/src/test/java/com/youbuyfirst/backend/RealEstateBatchIntegrationTest.java b/backend/src/test/java/com/youbuyfirst/backend/RealEstateBatchIntegrationTest.java
index 1e68620..34f513f 100644
--- a/backend/src/test/java/com/youbuyfirst/backend/RealEstateBatchIntegrationTest.java
+++ b/backend/src/test/java/com/youbuyfirst/backend/RealEstateBatchIntegrationTest.java
@@ -310,6 +310,19 @@ void marketDataScheduleRefreshJobMaterializesMonthlyCalendarForFrontend() throws
""");
}
+ if ("https://www.reb.or.kr/r-one/portal/compose/scheduleStatsPage.do".equals(url)) {
+ return RealEstateExternalFetchResult.ok("""
+