From 947e4f048c2c3b62d29d746a65e85fa035304985 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 10:29:35 +0900 Subject: [PATCH 01/23] =?UTF-8?q?FEAT=20:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B3=B5=EA=B8=89=EC=9E=90=20=EC=B6=94?= =?UTF-8?q?=EC=83=81=ED=99=94=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/provider/DataGoKrProvider.java | 93 +++++++++++++++++++ .../client/provider/PublicDataProvider.java | 38 ++++++++ .../provider/SeoulOpenDataProvider.java | 73 +++++++++++++++ 3 files changed, 204 insertions(+) create mode 100644 src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java create mode 100644 src/main/java/com/carecode/core/client/provider/PublicDataProvider.java create mode 100644 src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java diff --git a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java new file mode 100644 index 00000000..35db00a3 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java @@ -0,0 +1,93 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * 공공데이터포털(data.go.kr) 공급자. + * + *

URL 규격: {@code {baseUrl}/{resource}?serviceKey=...&pageNo=1&numOfRows=100&type=json} + * 서울시와 달리 페이지 번호를 쿼리 파라미터로 넘긴다. + * + *

주의: 발급받는 serviceKey 는 이미 URL 인코딩된 문자열이다. + * {@code UriComponentsBuilder} 로 다시 인코딩하면 `%2B` 가 `%252B` 가 되어 인증에 실패하므로, + * 키만 따로 붙여 {@link URI} 를 직접 만든다. + */ +@Slf4j +@Component +public class DataGoKrProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "DATA_GO_KR"; + + private final RestTemplate restTemplate; + private final String serviceKey; + private final String baseUrl; + + public DataGoKrProvider(RestTemplate restTemplate, + @Value("${public.data.datagokr.service-key:}") String serviceKey, + @Value("${public.data.datagokr.base-url:https://apis.data.go.kr}") String baseUrl) { + this.restTemplate = restTemplate; + this.serviceKey = serviceKey; + this.baseUrl = stripTrailingSlash(baseUrl); + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return serviceKey != null && !serviceKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("공공데이터포털 서비스 키가 설정되지 않았습니다."); + } + + UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl(baseUrl + "/" + stripLeadingSlash(resource)) + .queryParam("pageNo", pageNo) + .queryParam("numOfRows", numOfRows) + .queryParam("type", "json"); + + if (params != null) { + params.forEach((k, v) -> { + if (v != null && !v.isBlank()) { + builder.queryParam(k, v); + } + }); + } + + // serviceKey 는 이미 인코딩된 값이므로 빌더를 거치지 않고 직접 이어 붙인다. + String encoded = builder.encode(StandardCharsets.UTF_8).toUriString(); + String url = encoded + "&serviceKey=" + serviceKey; + + log.debug("공공데이터포털 호출: resource={}, page={}, rows={}", resource, pageNo, numOfRows); + + try { + return restTemplate.getForObject(URI.create(url), String.class); + } catch (Exception e) { + throw new PublicDataApiException( + "공공데이터포털 호출 실패: resource=" + resource + ", 사유=" + e.getMessage(), e); + } + } + + private String stripTrailingSlash(String url) { + return url != null && url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } + + private String stripLeadingSlash(String path) { + return path != null && path.startsWith("/") ? path.substring(1) : path; + } +} diff --git a/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java b/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java new file mode 100644 index 00000000..12e2d55b --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java @@ -0,0 +1,38 @@ +package com.carecode.core.client.provider; + +import java.util.Map; + +/** + * 공공데이터 공급자 추상화. + * + *

공급자마다 인증 방식과 URL 규격이 다르다. + *

+ * + *

이 차이를 구현체가 흡수해서, 도메인 코드는 "몇 페이지에서 몇 건" 만 요청하면 되게 한다. + * 새 공급자를 붙일 때 기존 코드를 건드리지 않기 위한 경계다. + */ +public interface PublicDataProvider { + + /** 공급자 식별자. 로그와 동기화 이력에 사용한다. */ + String getProviderName(); + + /** + * API 키가 설정돼 있어 실제 호출이 가능한지. + * 키가 없으면 호출부가 조용히 건너뛴다 (로컬/CI 기동 보장). + */ + boolean isAvailable(); + + /** + * 데이터를 조회한다. + * + * @param resource 공급자 내 리소스 식별자 (서비스명/오퍼레이션명) + * @param pageNo 1부터 시작하는 페이지 번호 + * @param numOfRows 한 페이지 건수 + * @param params 추가 쿼리 파라미터 (지역 코드 등). 없으면 빈 맵 + * @return 원본 응답 본문 (JSON 문자열) + */ + String fetch(String resource, int pageNo, int numOfRows, Map params); +} diff --git a/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java b/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java new file mode 100644 index 00000000..70f97977 --- /dev/null +++ b/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java @@ -0,0 +1,73 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** + * 서울 열린데이터광장 공급자. + * + *

URL 규격: {@code {baseUrl}/{apiKey}/json/{service}/{startIndex}/{endIndex}/} + * 페이지 번호가 아니라 시작·종료 인덱스를 경로에 넣는다. 한 번에 최대 1000건. + */ +@Slf4j +@Component +public class SeoulOpenDataProvider implements PublicDataProvider { + + public static final String PROVIDER_NAME = "SEOUL_OPEN_DATA"; + + /** 서울시 API가 한 번에 허용하는 최대 건수. */ + private static final int MAX_ROWS = 1000; + + private final RestTemplate restTemplate; + private final String apiKey; + private final String baseUrl; + + public SeoulOpenDataProvider(RestTemplate restTemplate, + @Value("${public.data.seoul.api-key:${public.data.api.key:}}") String apiKey, + @Value("${public.data.seoul.base-url:http://openapi.seoul.go.kr:8088}") String baseUrl) { + this.restTemplate = restTemplate; + this.apiKey = apiKey; + this.baseUrl = stripTrailingSlash(baseUrl); + } + + @Override + public String getProviderName() { + return PROVIDER_NAME; + } + + @Override + public boolean isAvailable() { + return apiKey != null && !apiKey.isBlank(); + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + if (!isAvailable()) { + throw new PublicDataApiException("서울 열린데이터광장 API 키가 설정되지 않았습니다."); + } + + int rows = Math.min(numOfRows, MAX_ROWS); + int startIndex = (pageNo - 1) * rows + 1; + int endIndex = pageNo * rows; + + String url = String.format("%s/%s/json/%s/%d/%d/", baseUrl, apiKey, resource, startIndex, endIndex); + log.debug("서울 열린데이터광장 호출: service={}, {}~{}", resource, startIndex, endIndex); + + try { + return restTemplate.getForObject(url, String.class); + } catch (Exception e) { + // API 키가 URL 경로에 들어가므로 예외 메시지에 URL 을 넣지 않는다. + throw new PublicDataApiException( + "서울 열린데이터광장 호출 실패: service=" + resource + ", 사유=" + e.getMessage(), e); + } + } + + private String stripTrailingSlash(String url) { + return url != null && url.endsWith("/") ? url.substring(0, url.length() - 1) : url; + } +} From 090472084f7dc343146f6d0d95aade615de31ef9 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 10:29:35 +0900 Subject: [PATCH 02/23] =?UTF-8?q?FEAT=20:=20=EC=A0=84=EA=B5=AD=20=EC=96=B4?= =?UTF-8?q?=EB=A6=B0=EC=9D=B4=EC=A7=91=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sync/CareFacilityUpsertService.java | 142 ++++++++++++++++++ ...ationwideChildcareFacilitySyncService.java | 110 ++++++++++++++ .../carecode/core/client/sync/SyncResult.java | 63 ++++++++ 3 files changed, 315 insertions(+) create mode 100644 src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java create mode 100644 src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java create mode 100644 src/main/java/com/carecode/core/client/sync/SyncResult.java diff --git a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java new file mode 100644 index 00000000..4fc71bc6 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java @@ -0,0 +1,142 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 시설 한 건을 저장하는 트랜잭션 경계. + * + *

동기화 루프와 분리한 이유: {@code @Transactional} 은 프록시로 동작하므로 + * 같은 클래스 안에서 호출하면 적용되지 않는다(self-invocation). + * 한 건 실패가 전체 배치를 롤백시키지 않으려면 별도 빈이어야 한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class CareFacilityUpsertService { + + private final CareFacilityRepository careFacilityRepository; + + /** + * 시설 코드 기준 upsert. + * + * @return 신규 생성이면 true, 기존 갱신이면 false + * @throws IllegalArgumentException 시설 코드가 없는 경우 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row) { + String facilityCode = text(row, "STCODE", "crcodeCd", "crcode"); + if (facilityCode == null) { + throw new IllegalArgumentException("시설 코드가 없는 응답입니다."); + } + + CareFacility facility = careFacilityRepository.findByFacilityCode(facilityCode).orElse(null); + boolean isNew = facility == null; + if (isNew) { + facility = CareFacility.builder() + .facilityCode(facilityCode) + .isActive(true) + .viewCount(0) + .build(); + } + + applyIfPresent(text(row, "CRNAME", "crname"), facility::setName); + applyIfPresent(text(row, "CRADDR", "craddr"), facility::setAddress); + applyIfPresent(text(row, "CRTELNO", "crtelno"), facility::setPhone); + applyIfPresent(text(row, "CRHOME", "crhome"), facility::setWebsite); + + String typeName = text(row, "CRTYPENAME", "crtypeName"); + if (typeName != null) { + facility.setFacilityType(resolveType(typeName)); + } else if (isNew) { + facility.setFacilityType(FacilityType.DAYCARE); + } + + Integer capacity = integer(row, "CRCAPAT", "crcapat"); + if (capacity != null) { + facility.setCapacity(capacity); + } + Integer enrollment = integer(row, "CRCHCNT", "crchcnt"); + if (enrollment != null) { + facility.setCurrentEnrollment(enrollment); + Integer effectiveCapacity = capacity != null ? capacity : facility.getCapacity(); + if (effectiveCapacity != null) { + facility.setAvailableSpots(Math.max(0, effectiveCapacity - enrollment)); + } + } + + Double lat = decimal(row, "LA", "la", "LAT"); + Double lng = decimal(row, "LO", "lo", "LNG"); + if (lat != null && lng != null) { + facility.setLatitude(lat); + facility.setLongitude(lng); + } + + facility.setUpdatedAt(LocalDateTime.now()); + careFacilityRepository.save(facility); + return isNew; + } + + private void applyIfPresent(String value, java.util.function.Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + /** 응답 필드명이 대문자/카멜케이스로 섞여 오는 경우가 있어 후보를 순서대로 본다. */ + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Integer integer(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value.replaceAll("[^0-9-]", "")); + } catch (NumberFormatException e) { + return null; + } + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } + + private FacilityType resolveType(String typeName) { + if (typeName.contains("유치원")) { + return FacilityType.KINDERGARTEN; + } + if (typeName.contains("어린이집")) { + return FacilityType.DAYCARE; + } + return FacilityType.OTHER; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java new file mode 100644 index 00000000..3d6d6ad6 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java @@ -0,0 +1,110 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 전국 어린이집 정보 동기화. + * + *

출처: 한국사회보장정보원_전국 어린이집 정보 조회 (공공데이터포털) + * + *

기존 연동은 서울 열린데이터광장 하나뿐이라 서울 외 지역에는 시설 데이터가 없었다. + * 이 서비스는 전국 데이터를 시설 코드 기준으로 upsert 한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class NationwideChildcareFacilitySyncService { + + /** 공공데이터포털 오퍼레이션 경로. 데이터셋 문서의 상세 기능 경로와 맞춘다. */ + private static final String RESOURCE = "B551014/CCEF/childcare"; + + private static final int ROWS_PER_PAGE = 500; + + private final DataGoKrProvider provider; + private final CareFacilityUpsertService upsertService; + private final ObjectMapper objectMapper; + + /** 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다(조용한 중단 금지). */ + @Value("${public.data.sync.max-pages:200}") + private int maxPages; + + public SyncResult sync() { + SyncResult result = new SyncResult(provider.getProviderName(), "전국어린이집"); + + if (!provider.isAvailable()) { + result.stop("공공데이터포털 서비스 키 미설정"); + log.info("전국 어린이집 동기화 건너뜀 - 서비스 키가 없습니다."); + return result; + } + + for (int page = 1; page <= maxPages; page++) { + JsonNode rows; + try { + String body = provider.fetch(RESOURCE, page, ROWS_PER_PAGE, Map.of()); + rows = extractRows(body); + } catch (Exception e) { + log.error("전국 어린이집 조회 실패 - page={}", page, e); + result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); + return result; + } + + if (rows == null || !rows.isArray() || rows.isEmpty()) { + return result; // 더 이상 데이터 없음 — 정상 종료 + } + + for (JsonNode row : rows) { + try { + if (upsertService.upsert(row)) { + result.countCreated(); + } else { + result.countUpdated(); + } + } catch (Exception e) { + // 한 건 실패가 배치 전체를 중단시키지 않는다. + result.countFailed(); + log.warn("시설 저장 실패: {}", e.getMessage()); + } + } + result.countPage(); + + if (rows.size() < ROWS_PER_PAGE) { + return result; // 마지막 페이지 + } + } + + result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); + log.warn("전국 어린이집 동기화가 페이지 상한에 걸렸습니다. public.data.sync.max-pages 설정을 확인하세요."); + return result; + } + + /** + * 공공데이터포털 응답에서 데이터 배열을 꺼낸다. + * 데이터셋마다 래핑 구조가 조금씩 달라 흔한 형태를 순서대로 시도한다. + */ + private JsonNode extractRows(String body) throws Exception { + if (body == null || body.isBlank()) { + return null; + } + JsonNode root = objectMapper.readTree(body); + + JsonNode items = root.path("response").path("body").path("items"); + if (items.isArray()) { + return items; + } + if (items.path("item").isArray()) { + return items.path("item"); + } + if (root.path("items").isArray()) { + return root.path("items"); + } + return root.isArray() ? root : null; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/SyncResult.java b/src/main/java/com/carecode/core/client/sync/SyncResult.java new file mode 100644 index 00000000..ffa8d050 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/SyncResult.java @@ -0,0 +1,63 @@ +package com.carecode.core.client.sync; + +import lombok.Getter; + +/** + * 공공데이터 동기화 결과. + * + *

이전에는 동기화가 조용히 중단돼도 알 방법이 없었다. + * 성공·실패 건수와 중단 사유를 담아 로그와 응답에 남긴다. + */ +@Getter +public class SyncResult { + + private final String provider; + private final String resource; + private int created; + private int updated; + private int failed; + private int pagesProcessed; + + /** 정상 완료가 아니면 중단 사유. 정상이면 null. */ + private String stoppedReason; + + public SyncResult(String provider, String resource) { + this.provider = provider; + this.resource = resource; + } + + public void countCreated() { + created++; + } + + public void countUpdated() { + updated++; + } + + public void countFailed() { + failed++; + } + + public void countPage() { + pagesProcessed++; + } + + public void stop(String reason) { + this.stoppedReason = reason; + } + + public boolean isCompleted() { + return stoppedReason == null; + } + + public int getTotalProcessed() { + return created + updated; + } + + @Override + public String toString() { + return String.format("[%s/%s] 신규=%d, 갱신=%d, 실패=%d, 페이지=%d%s", + provider, resource, created, updated, failed, pagesProcessed, + stoppedReason != null ? ", 중단사유=" + stoppedReason : ""); + } +} From 6c5ee7519675be67cd328d0ee54f339b192bb8be Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 10:29:35 +0900 Subject: [PATCH 03/23] =?UTF-8?q?FEAT=20:=20=EB=B3=B4=EC=A1=B0=EA=B8=8824?= =?UTF-8?q?=20=EC=A0=95=EB=B6=80=EC=A7=80=EC=9B=90=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sync/GovernmentBenefitSyncService.java | 151 ++++++++++++++++++ .../core/client/sync/PolicyUpsertService.java | 79 +++++++++ 2 files changed, 230 insertions(+) create mode 100644 src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java create mode 100644 src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java diff --git a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java new file mode 100644 index 00000000..e70a83fa --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java @@ -0,0 +1,151 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 정부 지원 서비스(보조금24) 동기화. + * + *

출처: 행정안전부_대한민국 공공서비스(혜택) 정보 (공공데이터포털) + * + *

정책 데이터가 코드에 하드코딩돼 있어 매년 바뀌는 내용을 재배포 없이는 반영할 수 없었다. + * 중앙부처·지자체 서비스를 주기적으로 받아 자동 최신화한다. + * + *

약 7,500개 서비스 전부가 육아와 관련되지는 않으므로 키워드로 걸러 적재한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class GovernmentBenefitSyncService { + + private static final String RESOURCE = "1741000/publicServiceInformations/publicServiceInformation"; + + private static final int ROWS_PER_PAGE = 100; + + /** 육아 관련 서비스만 적재하기 위한 키워드. 하나라도 포함되면 대상으로 본다. */ + private static final List CARE_KEYWORDS = List.of( + "육아", "출산", "임신", "보육", "양육", "아동", "어린이", "영유아", + "유아", "산모", "신생아", "돌봄", "child", "어린이집", "유치원"); + + private final DataGoKrProvider provider; + private final PolicyUpsertService upsertService; + private final ObjectMapper objectMapper; + + @Value("${public.data.sync.max-pages:200}") + private int maxPages; + + public SyncResult sync() { + SyncResult result = new SyncResult(provider.getProviderName(), "정부지원서비스"); + + if (!provider.isAvailable()) { + result.stop("공공데이터포털 서비스 키 미설정"); + log.info("정부 지원 서비스 동기화 건너뜀 - 서비스 키가 없습니다."); + return result; + } + + for (int page = 1; page <= maxPages; page++) { + JsonNode rows; + try { + String body = provider.fetch(RESOURCE, page, ROWS_PER_PAGE, buildParams()); + rows = extractRows(body); + } catch (Exception e) { + log.error("정부 지원 서비스 조회 실패 - page={}", page, e); + result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); + return result; + } + + if (rows == null || !rows.isArray() || rows.isEmpty()) { + return result; + } + + for (JsonNode row : rows) { + if (!isCareRelated(row)) { + continue; // 육아와 무관한 서비스는 건너뛴다 + } + try { + if (upsertService.upsert(row)) { + result.countCreated(); + } else { + result.countUpdated(); + } + } catch (Exception e) { + result.countFailed(); + log.warn("정책 저장 실패: {}", e.getMessage()); + } + } + result.countPage(); + + if (rows.size() < ROWS_PER_PAGE) { + return result; + } + } + + result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); + log.warn("정부 지원 서비스 동기화가 페이지 상한에 걸렸습니다."); + return result; + } + + private Map buildParams() { + // 데이터셋이 연령·가구 조건 필터를 명세에 노출하지 않아, 전체를 받아 키워드로 거른다. + // 서버 측 필터가 확인되면 이 자리에서 파라미터로 좁히는 편이 트래픽에 유리하다. + return new LinkedHashMap<>(); + } + + /** 서비스명·요약·분야 중 하나라도 육아 키워드를 포함하면 적재 대상. */ + private boolean isCareRelated(JsonNode row) { + String haystack = String.join(" ", + nullToEmpty(text(row, "서비스명", "servNm", "SVC_NM")), + nullToEmpty(text(row, "서비스목적요약", "servDgst", "SVC_DGST")), + nullToEmpty(text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM")), + nullToEmpty(text(row, "지원대상", "trgterIndvdlArray"))) + .toLowerCase(); + + return CARE_KEYWORDS.stream().anyMatch(k -> haystack.contains(k.toLowerCase())); + } + + private JsonNode extractRows(String body) throws Exception { + if (body == null || body.isBlank()) { + return null; + } + JsonNode root = objectMapper.readTree(body); + + JsonNode data = root.path("data"); + if (data.isArray()) { + return data; + } + JsonNode items = root.path("response").path("body").path("items"); + if (items.isArray()) { + return items; + } + if (items.path("item").isArray()) { + return items.path("item"); + } + return root.isArray() ? root : null; + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private String nullToEmpty(String value) { + return value != null ? value : ""; + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java new file mode 100644 index 00000000..be8c1d17 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java @@ -0,0 +1,79 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +/** + * 정책 한 건을 저장하는 트랜잭션 경계. + * + *

관리자가 직접 수정한 정책을 공공데이터가 덮어쓰지 않도록, + * 외부에서 받은 정책은 {@code policyCode} 에 접두어를 붙여 출처를 구분한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PolicyUpsertService { + + /** 공공데이터 출처 정책임을 나타내는 코드 접두어. 관리자 수기 등록 정책과 구분한다. */ + public static final String EXTERNAL_CODE_PREFIX = "GOV-"; + + private final PolicyRepository policyRepository; + + /** + * 서비스 ID 기준 upsert. + * + * @return 신규 생성이면 true + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + @CacheEvict(cacheNames = "policy", allEntries = true) + public boolean upsert(JsonNode row) { + String serviceId = text(row, "서비스ID", "servId", "SVC_ID"); + if (serviceId == null) { + throw new IllegalArgumentException("서비스 ID가 없는 응답입니다."); + } + + String policyCode = EXTERNAL_CODE_PREFIX + serviceId; + Policy policy = policyRepository.findByPolicyCode(policyCode).orElse(null); + boolean isNew = policy == null; + if (isNew) { + policy = new Policy(); + policy.setPolicyCode(policyCode); + policy.setViewCount(0); + policy.setIsActive(true); + } + + policy.setTitle(text(row, "서비스명", "servNm", "SVC_NM")); + policy.setDescription(text(row, "서비스목적요약", "servDgst", "SVC_DGST")); + policy.setPolicyType(text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM")); + policy.setTargetRegion(text(row, "소관기관명", "jurMnofNm", "JURISDICTION")); + policy.setApplicationUrl(text(row, "상세조회URL", "servDtlLink", "DETAIL_URL")); + policy.setContactInfo(text(row, "전화문의", "rprsCtadr", "CONTACT")); + policy.setRequiredDocuments(text(row, "구비서류", "docCn")); + policy.setUpdatedAt(LocalDateTime.now()); + + policyRepository.save(policy); + return isNew; + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } +} From bee53d4019f3ad7ae3fd1c8aa1d9b63c3b95979c Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 10:29:35 +0900 Subject: [PATCH 04/23] =?UTF-8?q?FEAT=20:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A3=BC=EA=B8=B0=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20=EC=B6=94=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/PublicDataSyncScheduler.java | 56 ++++++++++++++++++ .../controller/AdminPublicDataController.java | 58 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java create mode 100644 src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java new file mode 100644 index 00000000..5a65ffb5 --- /dev/null +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -0,0 +1,56 @@ +package com.carecode.core.scheduler; + +import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; +import com.carecode.core.client.sync.SyncResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 공공데이터 주기 동기화. + * + *

이전에는 관리자가 API 를 직접 호출해야 했고, 페이지 상한에 걸리면 조용히 멈췄다. + * 여기서는 정기적으로 돌리고 결과(신규/갱신/실패/중단 사유)를 로그로 남긴다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PublicDataSyncScheduler { + + private final NationwideChildcareFacilitySyncService facilitySyncService; + private final GovernmentBenefitSyncService benefitSyncService; + + /** + * 전국 어린이집 동기화. 매주 월요일 새벽 3시. + * 시설 정보는 변동이 잦지 않아 주 1회로 충분하고, 트래픽 한도(일 10,000건)도 아낄 수 있다. + */ + @Scheduled(cron = "${app.scheduler.public-data.facility-cron:0 0 3 * * MON}", zone = "Asia/Seoul") + public void syncChildcareFacilities() { + SyncResult result = facilitySyncService.sync(); + logResult("전국 어린이집", result); + } + + /** + * 정부 지원 서비스(보조금24) 동기화. 매일 새벽 3시 30분. + * 정책은 신청 기간이 있어 시설보다 자주 확인한다. + */ + @Scheduled(cron = "${app.scheduler.public-data.benefit-cron:0 30 3 * * *}", zone = "Asia/Seoul") + public void syncGovernmentBenefits() { + SyncResult result = benefitSyncService.sync(); + logResult("정부 지원 서비스", result); + } + + private void logResult(String label, SyncResult result) { + if (!result.isCompleted()) { + log.warn("{} 동기화 미완료 - {}", label, result); + return; + } + if (result.getTotalProcessed() == 0 && result.getFailed() == 0) { + log.debug("{} 동기화: 변경 없음", label); + return; + } + log.info("{} 동기화 완료 - {}", label, result); + } +} diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java new file mode 100644 index 00000000..3551f9da --- /dev/null +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -0,0 +1,58 @@ +package com.carecode.domain.admin.controller; + +import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; +import com.carecode.core.client.sync.SyncResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 공공데이터 수동 동기화 API. + * + *

정기 동기화는 {@code PublicDataSyncScheduler} 가 담당한다. + * 이 엔드포인트는 즉시 반영이 필요할 때 쓰는 보조 수단이다. + */ +@RestController +@RequestMapping("/api/admin/public-data") +@RequiredArgsConstructor +@Tag(name = "어드민 - 공공데이터", description = "공공데이터 수동 동기화 API") +public class AdminPublicDataController { + + private final NationwideChildcareFacilitySyncService facilitySyncService; + private final GovernmentBenefitSyncService benefitSyncService; + + @PostMapping("/facilities/sync") + @Operation(summary = "전국 어린이집 동기화", + description = "공공데이터포털에서 전국 어린이집 정보를 받아 시설 코드 기준으로 갱신합니다.") + public ResponseEntity> syncFacilities() { + return ResponseEntity.ok(toResponse(facilitySyncService.sync())); + } + + @PostMapping("/benefits/sync") + @Operation(summary = "정부 지원 서비스 동기화", + description = "보조금24 공공서비스 정보에서 육아 관련 서비스를 받아 정책으로 갱신합니다.") + public ResponseEntity> syncBenefits() { + return ResponseEntity.ok(toResponse(benefitSyncService.sync())); + } + + private Map toResponse(SyncResult result) { + Map body = new LinkedHashMap<>(); + body.put("provider", result.getProvider()); + body.put("resource", result.getResource()); + body.put("completed", result.isCompleted()); + body.put("created", result.getCreated()); + body.put("updated", result.getUpdated()); + body.put("failed", result.getFailed()); + body.put("pagesProcessed", result.getPagesProcessed()); + body.put("stoppedReason", result.getStoppedReason()); + return body; + } +} From fa91a8e4b10a80fe9e6a73762fc27a8414a56c67 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 10:29:35 +0900 Subject: [PATCH 05/23] =?UTF-8?q?REFACTOR=20:=20=EB=AF=B8=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EA=B3=B5=EA=B3=B5=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=BD=94=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/PublicDataApiConstants.java | 126 ++++++++---------- 1 file changed, 56 insertions(+), 70 deletions(-) diff --git a/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java b/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java index 883d4d50..53e25e9a 100644 --- a/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java +++ b/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java @@ -1,82 +1,68 @@ package com.carecode.core.client.constants; +import java.util.Map; + /** - * 공공데이터 API 관련 상수 - * API 엔드포인트, 파라미터명, 응답 코드 등을 상수로 관리 + * 공공데이터 API 공통 상수. + * + *

이전에는 엔드포인트 상수 5종과 정책·교육 유형 코드가 정의돼 있었으나 + * 어디서도 참조되지 않는 죽은 코드였다("연동돼 있다"는 착각만 만들었다). + * 실제로 쓰이는 값만 남긴다. */ -public class PublicDataApiConstants { - - // API 응답 코드 - public static final String SUCCESS_CODE = "00"; - public static final String ERROR_CODE = "99"; - - // API 파라미터명 - public static final String PARAM_SERVICE_KEY = "serviceKey"; - public static final String PARAM_PAGE_NO = "pageNo"; - public static final String PARAM_NUM_OF_ROWS = "numOfRows"; - public static final String PARAM_TYPE = "type"; - public static final String PARAM_SIDO_CD = "sidoCd"; - public static final String PARAM_POLICY_TYPE = "policyType"; - public static final String PARAM_EDUCATION_TYPE = "educationType"; - - // API 응답 타입 - public static final String RESPONSE_TYPE_JSON = "json"; - public static final String RESPONSE_TYPE_XML = "xml"; - - // 육아 관련 API 엔드포인트 - public static final String ENDPOINT_CHILDCARE_FACILITIES = "/getChildcareFacilities"; - public static final String ENDPOINT_CHILDCARE_POLICIES = "/getChildcarePolicies"; - public static final String ENDPOINT_PEDIATRIC_HOSPITALS = "/getPediatricHospitals"; - public static final String ENDPOINT_CHILDCARE_SUBSIDIES = "/getChildcareSubsidies"; - public static final String ENDPOINT_CHILDCARE_EDUCATION = "/getChildcareEducation"; - - // 지역 코드 - public static final String REGION_SEOUL = "11"; - public static final String REGION_BUSAN = "21"; - public static final String REGION_DAEGU = "22"; - public static final String REGION_INCHEON = "23"; - public static final String REGION_GWANGJU = "24"; - public static final String REGION_DAEJEON = "25"; - public static final String REGION_ULSAN = "26"; - public static final String REGION_SEJONG = "29"; - public static final String REGION_GYEONGGI = "31"; - public static final String REGION_GANGWON = "32"; - public static final String REGION_CHUNGBUK = "33"; - public static final String REGION_CHUNGNAM = "34"; - public static final String REGION_JEONBUK = "35"; - public static final String REGION_JEONNAM = "36"; - public static final String REGION_GYEONGBUK = "37"; - public static final String REGION_GYEONGNAM = "38"; - public static final String REGION_JEJU = "39"; +public final class PublicDataApiConstants { - // 정책 유형 - public static final String POLICY_TYPE_SUBSIDY = "subsidy"; - public static final String POLICY_TYPE_FACILITY = "facility"; - public static final String POLICY_TYPE_EDUCATION = "education"; - public static final String POLICY_TYPE_MEDICAL = "medical"; + /** 공공데이터포털 공통 응답 코드. */ + public static final String RESULT_CODE_SUCCESS = "00"; - // 교육 유형 - public static final String EDUCATION_TYPE_PARENTING = "parenting"; - public static final String EDUCATION_TYPE_CHILDCARE = "childcare"; - public static final String EDUCATION_TYPE_SAFETY = "safety"; - public static final String EDUCATION_TYPE_HEALTH = "health"; - - // 기본 페이지 설정 - public static final int DEFAULT_PAGE_NO = 1; - public static final int DEFAULT_NUM_OF_ROWS = 10; + /** 한 번에 요청할 수 있는 최대 건수 (서울 열린데이터광장 기준). */ public static final int MAX_NUM_OF_ROWS = 1000; - // 타임아웃 설정 (밀리초) - public static final int CONNECT_TIMEOUT = 10000; - public static final int READ_TIMEOUT = 30000; - - // 에러 메시지 - public static final String ERROR_MSG_API_CALL_FAILED = "API 호출에 실패했습니다."; - public static final String ERROR_MSG_INVALID_RESPONSE = "잘못된 응답입니다."; - public static final String ERROR_MSG_TIMEOUT = "요청 시간이 초과되었습니다."; - public static final String ERROR_MSG_NETWORK_ERROR = "네트워크 오류가 발생했습니다."; + /** + * 시도명 → 법정동 시도 코드. + * 공공데이터포털 API 가 지역 필터를 지원할 때 사용한다. + */ + public static final Map SIDO_CODES = Map.ofEntries( + Map.entry("서울특별시", "11"), + Map.entry("부산광역시", "21"), + Map.entry("대구광역시", "22"), + Map.entry("인천광역시", "23"), + Map.entry("광주광역시", "24"), + Map.entry("대전광역시", "25"), + Map.entry("울산광역시", "26"), + Map.entry("세종특별자치시", "29"), + Map.entry("경기도", "31"), + Map.entry("강원특별자치도", "32"), + Map.entry("충청북도", "33"), + Map.entry("충청남도", "34"), + Map.entry("전북특별자치도", "35"), + Map.entry("전라남도", "36"), + Map.entry("경상북도", "37"), + Map.entry("경상남도", "38"), + Map.entry("제주특별자치도", "39")); private PublicDataApiConstants() { // 유틸리티 클래스이므로 인스턴스화 방지 } -} \ No newline at end of file + + /** + * 시도명으로 시도 코드를 찾는다. 표기가 조금 달라도(예: "서울") 매칭한다. + * + * @return 찾지 못하면 null + */ + public static String findSidoCode(String sidoName) { + if (sidoName == null || sidoName.isBlank()) { + return null; + } + String normalized = sidoName.trim(); + + String exact = SIDO_CODES.get(normalized); + if (exact != null) { + return exact; + } + return SIDO_CODES.entrySet().stream() + .filter(e -> e.getKey().startsWith(normalized) || normalized.startsWith(e.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } +} From b0d5a0be5a4158bc6bc015aaa3dd2cbf2c416529 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 10:29:35 +0900 Subject: [PATCH 06/23] =?UTF-8?q?CHORE=20:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B3=B5=EA=B8=89=EC=9E=90=EB=B3=84=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20=EB=8F=99=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=ED=81=AC=EB=A1=A0=20=EC=B6=94=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e87113c1..75342196 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -141,6 +141,10 @@ app: cron: ${BOOKING_REMINDER_CRON:0 0 18 * * *} cleanup: cron: ${CLEANUP_CRON:0 0 4 * * *} + public-data: + # 시설 정보는 변동이 잦지 않아 주 1회, 정책은 신청 기간이 있어 매일 확인한다. + facility-cron: ${PUBLIC_DATA_FACILITY_CRON:0 0 3 * * MON} + benefit-cron: ${PUBLIC_DATA_BENEFIT_CRON:0 30 3 * * *} jwt: secret: ${JWT_SECRET} @@ -192,6 +196,22 @@ kakao: public: data: + # 레거시 키 (서울 열린데이터광장 전용 CareFacilityApiService 가 참조) api: key: ${PUBLIC_DATA_API_KEY} base-url: ${PUBLIC_DATA_API_BASE_URL:http://openapi.seoul.go.kr:8088} + + # 서울 열린데이터광장 — 경로 파라미터 방식 + seoul: + api-key: ${SEOUL_OPEN_DATA_KEY:${PUBLIC_DATA_API_KEY:}} + base-url: ${SEOUL_OPEN_DATA_BASE_URL:http://openapi.seoul.go.kr:8088} + + # 공공데이터포털(data.go.kr) — serviceKey 쿼리 파라미터 방식. + # 비워두면 해당 공급자를 쓰는 동기화가 건너뛰어진다(기동에는 영향 없음). + datagokr: + service-key: ${DATA_GO_KR_SERVICE_KEY:} + base-url: ${DATA_GO_KR_BASE_URL:https://apis.data.go.kr} + + sync: + # 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다. + max-pages: ${PUBLIC_DATA_SYNC_MAX_PAGES:200} From eb445368d3165852c6c0e2b346137261c0d246f6 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 10:29:36 +0900 Subject: [PATCH 07/23] =?UTF-8?q?TEST=20:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EA=B3=B5=EA=B8=89=EC=9E=90=20=EB=B0=8F=20?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=BD=94=EB=93=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../constants/PublicDataApiConstantsTest.java | 38 +++++++ .../client/provider/DataGoKrProviderTest.java | 100 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java create mode 100644 src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java diff --git a/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java b/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java new file mode 100644 index 00000000..2ae02e7c --- /dev/null +++ b/src/test/java/com/carecode/core/client/constants/PublicDataApiConstantsTest.java @@ -0,0 +1,38 @@ +package com.carecode.core.client.constants; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("공공데이터 시도 코드") +class PublicDataApiConstantsTest { + + @Test + @DisplayName("정식 명칭으로 조회한다") + void findsByFullName() { + assertThat(PublicDataApiConstants.findSidoCode("서울특별시")).isEqualTo("11"); + assertThat(PublicDataApiConstants.findSidoCode("경기도")).isEqualTo("31"); + } + + @Test + @DisplayName("축약 표기도 매칭한다") + void findsByShortName() { + assertThat(PublicDataApiConstants.findSidoCode("서울")).isEqualTo("11"); + assertThat(PublicDataApiConstants.findSidoCode("제주")).isEqualTo("39"); + } + + @Test + @DisplayName("알 수 없는 지역은 null을 반환한다") + void returnsNullForUnknown() { + assertThat(PublicDataApiConstants.findSidoCode("존재하지않는시")).isNull(); + assertThat(PublicDataApiConstants.findSidoCode("")).isNull(); + assertThat(PublicDataApiConstants.findSidoCode(null)).isNull(); + } + + @Test + @DisplayName("전국 17개 시도가 정의돼 있다") + void coversAllProvinces() { + assertThat(PublicDataApiConstants.SIDO_CODES).hasSize(17); + } +} diff --git a/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java new file mode 100644 index 00000000..0f4fd613 --- /dev/null +++ b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java @@ -0,0 +1,100 @@ +package com.carecode.core.client.provider; + +import com.carecode.core.client.exception.PublicDataApiException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.net.URI; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * 공공데이터포털 공급자 검증. + * + *

serviceKey 는 발급 시점에 이미 URL 인코딩된 문자열이라, + * 빌더로 다시 인코딩하면 {@code %2B} 가 {@code %252B} 로 이중 인코딩돼 인증에 실패한다. + */ +@DisplayName("DataGoKrProvider") +class DataGoKrProviderTest { + + private static final String ENCODED_KEY = "abc%2Bdef%3D%3D"; + + @Test + @DisplayName("서비스 키가 없으면 비활성 상태다") + void inactiveWithoutServiceKey() { + DataGoKrProvider provider = new DataGoKrProvider(new RestTemplate(), "", "https://apis.data.go.kr"); + + assertThat(provider.isAvailable()).isFalse(); + assertThatThrownBy(() -> provider.fetch("some/resource", 1, 10, Map.of())) + .isInstanceOf(PublicDataApiException.class) + .hasMessageContaining("서비스 키"); + } + + @Test + @DisplayName("serviceKey를 이중 인코딩하지 않는다") + void doesNotDoubleEncodeServiceKey() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> { + URI uri = request.getURI(); + String query = uri.getRawQuery(); + assertThat(query).contains("serviceKey=" + ENCODED_KEY); + // 이중 인코딩되면 %252B 가 된다 + assertThat(query).doesNotContain("%252B"); + }) + .andExpect(method(HttpMethod.GET)) + .andRespond(withSuccess("{\"response\":{}}", MediaType.APPLICATION_JSON)); + + String body = provider.fetch("B551014/CCEF/childcare", 1, 100, Map.of()); + + assertThat(body).isEqualTo("{\"response\":{}}"); + server.verify(); + } + + @Test + @DisplayName("페이지·건수를 쿼리 파라미터로 전달한다") + void sendsPagingAsQueryParams() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> { + String query = request.getURI().getRawQuery(); + assertThat(query).contains("pageNo=3"); + assertThat(query).contains("numOfRows=50"); + assertThat(query).contains("sidoCd=11"); + }) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + provider.fetch("some/resource", 3, 50, Map.of("sidoCd", "11")); + + server.verify(); + } + + @Test + @DisplayName("빈 파라미터 값은 쿼리에서 제외한다") + void skipsBlankParams() { + RestTemplate restTemplate = new RestTemplate(); + MockRestServiceServer server = MockRestServiceServer.bindTo(restTemplate).build(); + DataGoKrProvider provider = new DataGoKrProvider(restTemplate, ENCODED_KEY, "https://apis.data.go.kr"); + + server.expect(request -> assertThat(request.getURI().getRawQuery()).doesNotContain("sidoCd")) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + java.util.Map params = new java.util.HashMap<>(); + params.put("sidoCd", ""); + provider.fetch("some/resource", 1, 10, params); + + server.verify(); + } +} From a4c1671d0c59541f5114dd45710810b0f24dbfd9 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:04:24 +0900 Subject: [PATCH 08/23] =?UTF-8?q?FEAT=20:=20XML=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=ED=8C=8C=EC=84=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=8F=AC=EB=A7=B7=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9=EC=A7=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/client/XmlResponseParser.java | 107 ++++++++++++++++++ .../client/provider/DataGoKrProvider.java | 11 +- 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/carecode/core/client/XmlResponseParser.java diff --git a/src/main/java/com/carecode/core/client/XmlResponseParser.java b/src/main/java/com/carecode/core/client/XmlResponseParser.java new file mode 100644 index 00000000..4db9c437 --- /dev/null +++ b/src/main/java/com/carecode/core/client/XmlResponseParser.java @@ -0,0 +1,107 @@ +package com.carecode.core.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Component; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.StringReader; + +/** + * XML 응답을 {@link JsonNode} 로 변환한다. + * + *

공공데이터포털 데이터셋 중 일부(예: 심평원 병원정보서비스)는 XML 만 반환한다. + * {@code jackson-dataformat-xml} 을 클래스패스에 넣으면 Spring MVC 가 + * XML 메시지 컨버터를 자동 등록해 애플리케이션 응답 협상 동작까지 바뀌므로, + * 의존성을 늘리지 않고 JDK 내장 DOM 파서로 변환한다. + */ +@Component +public class XmlResponseParser { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * XML 문자열을 JsonNode 트리로 변환한다. + * + * @return 파싱할 수 없으면 null + */ + public JsonNode parse(String xml) { + if (xml == null || xml.isBlank()) { + return null; + } + try { + Document document = newSecureBuilder().parse(new InputSource(new StringReader(xml))); + document.getDocumentElement().normalize(); + return toNode(document.getDocumentElement()); + } catch (Exception e) { + return null; + } + } + + /** + * 외부 엔티티 참조(XXE)를 차단한 DocumentBuilder. + * 외부에서 받은 XML 을 파싱하므로 기본 설정을 그대로 쓰면 안 된다. + */ + private DocumentBuilder newSecureBuilder() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + return factory.newDocumentBuilder(); + } + + private JsonNode toNode(Element element) { + NodeList children = element.getChildNodes(); + + boolean hasElementChild = false; + for (int i = 0; i < children.getLength(); i++) { + if (children.item(i).getNodeType() == Node.ELEMENT_NODE) { + hasElementChild = true; + break; + } + } + + // 자식 엘리먼트가 없으면 텍스트 값 노드 + if (!hasElementChild) { + String text = element.getTextContent(); + return objectMapper.getNodeFactory().textNode(text != null ? text.trim() : ""); + } + + ObjectNode node = objectMapper.createObjectNode(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + Element childElement = (Element) child; + String name = childElement.getNodeName(); + JsonNode childNode = toNode(childElement); + + JsonNode existing = node.get(name); + if (existing == null) { + node.set(name, childNode); + } else if (existing.isArray()) { + // 같은 이름이 반복되면 배열로 모은다 (......) + ((ArrayNode) existing).add(childNode); + } else { + ArrayNode array = objectMapper.createArrayNode(); + array.add(existing); + array.add(childNode); + node.set(name, array); + } + } + return node; + } +} diff --git a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java index 35db00a3..0362dff4 100644 --- a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java +++ b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java @@ -58,8 +58,15 @@ public String fetch(String resource, int pageNo, int numOfRows, Map { From 3bd84c6cab8fc5a63612d404768f1d689388d4c3 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:04:25 +0900 Subject: [PATCH 09/23] =?UTF-8?q?FEAT=20:=20=EB=B3=91=EC=9B=90=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=EC=8B=9D=EB=B3=84=EC=9E=90=20=EC=BB=AC=EB=9F=BC=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20V3=20=EC=B6=94=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/carecode/domain/health/entity/Hospital.java | 8 ++++++++ .../health/repository/HospitalRepository.java | 4 ++++ .../db/migration/V3__hospital_external_code.sql | 13 +++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 src/main/resources/db/migration/V3__hospital_external_code.sql diff --git a/src/main/java/com/carecode/domain/health/entity/Hospital.java b/src/main/java/com/carecode/domain/health/entity/Hospital.java index f378aa60..6395e96a 100644 --- a/src/main/java/com/carecode/domain/health/entity/Hospital.java +++ b/src/main/java/com/carecode/domain/health/entity/Hospital.java @@ -20,6 +20,14 @@ public class Hospital { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + /** + * 심평원 암호화 요양기호(ykiho). + * 공공데이터 동기화 시 중복 적재를 막는 외부 식별자다. + * 복호화 수단은 제공되지 않으므로 값 자체를 키로 쓴다. + */ + @Column(name = "external_code", unique = true, length = 100) + private String externalCode; + @Column(nullable = false) private String name; diff --git a/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java b/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java index 53f0a20d..1c0638cb 100644 --- a/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java +++ b/src/main/java/com/carecode/domain/health/repository/HospitalRepository.java @@ -6,10 +6,14 @@ import org.springframework.data.repository.query.Param; import org.springframework.data.domain.Pageable; import java.util.List; +import java.util.Optional; public interface HospitalRepository extends JpaRepository { List findByType(String type); + /** 공공데이터 동기화 시 중복 적재 방지용 조회. */ + Optional findByExternalCode(String externalCode); + @Query("SELECT h FROM Hospital h WHERE FUNCTION('ST_Distance_Sphere', point(h.longitude, h.latitude), point(:lng, :lat)) <= :radius") List findNearby(@Param("lat") double lat, @Param("lng") double lng, @Param("radius") double radius); diff --git a/src/main/resources/db/migration/V3__hospital_external_code.sql b/src/main/resources/db/migration/V3__hospital_external_code.sql new file mode 100644 index 00000000..9f5afdee --- /dev/null +++ b/src/main/resources/db/migration/V3__hospital_external_code.sql @@ -0,0 +1,13 @@ +-- ================================================================================ +-- V3: 병원 외부 식별자(심평원 암호화 요양기호) 추가 +-- +-- 공공데이터 동기화 시 같은 병원을 중복 적재하지 않기 위한 키다. +-- 심평원은 ykiho 를 암호화해 제공하며 복호화 수단이 없으므로 값 자체를 식별자로 쓴다. +-- 기존 수기 등록 병원은 NULL 로 남고, UNIQUE 제약은 NULL 을 중복으로 보지 않는다. +-- ================================================================================ + +ALTER TABLE TBL_HOSPITAL + ADD COLUMN external_code VARCHAR(100) NULL COMMENT '심평원 암호화 요양기호(ykiho)'; + +ALTER TABLE TBL_HOSPITAL + ADD CONSTRAINT uk_hospital_external_code UNIQUE (external_code); From 11670ac465603533bb7cd68db1404e02c475e623 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:04:25 +0900 Subject: [PATCH 10/23] =?UTF-8?q?FEAT=20:=20=EC=86=8C=EC=95=84=EC=B2=AD?= =?UTF-8?q?=EC=86=8C=EB=85=84=EA=B3=BC=20=EB=B3=91=EC=9B=90=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/sync/HospitalUpsertService.java | 100 +++++++++++ .../sync/PediatricHospitalSyncService.java | 155 ++++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java create mode 100644 src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java diff --git a/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java new file mode 100644 index 00000000..4b1a26ed --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java @@ -0,0 +1,100 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.health.entity.Hospital; +import com.carecode.domain.health.repository.HospitalRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 병원 한 건을 저장하는 트랜잭션 경계. + * + *

동기화 루프와 분리한 이유는 {@link CareFacilityUpsertService} 와 같다 — + * {@code @Transactional} 은 프록시로 동작하므로 같은 클래스 안에서 호출하면 적용되지 않는다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class HospitalUpsertService { + + private final HospitalRepository hospitalRepository; + + /** + * 요양기호(ykiho) 기준 upsert. + * + * @param defaultType 진료과목명. 응답에 종별명이 없을 때 사용한다. + * @return 신규 생성이면 true + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row, String defaultType) { + String ykiho = text(row, "ykiho", "YKIHO"); + if (ykiho == null) { + throw new IllegalArgumentException("요양기호(ykiho)가 없는 응답입니다."); + } + + Hospital hospital = hospitalRepository.findByExternalCode(ykiho).orElse(null); + boolean isNew = hospital == null; + if (isNew) { + hospital = Hospital.builder().externalCode(ykiho).build(); + } + + String name = text(row, "yadmNm", "YADMNM"); + if (name != null) { + hospital.setName(name); + } else if (isNew) { + // name 은 NOT NULL 이라 값이 없으면 저장할 수 없다. + throw new IllegalArgumentException("요양기관명이 없는 응답입니다: ykiho=" + ykiho); + } + + applyIfPresent(text(row, "addr", "ADDR"), hospital::setAddress); + applyIfPresent(text(row, "telno", "TELNO"), hospital::setPhone); + + String clCdNm = text(row, "clCdNm", "CLCDNM"); + hospital.setType(clCdNm != null ? clCdNm : defaultType); + + // 심평원 좌표는 XPos=경도, YPos=위도 순서다. 뒤집으면 지도에서 엉뚱한 위치가 나온다. + Double lng = decimal(row, "XPos", "XPOS"); + Double lat = decimal(row, "YPos", "YPOS"); + if (lat != null && lng != null) { + hospital.setLatitude(lat); + hospital.setLongitude(lng); + } + + hospitalRepository.save(hospital); + return isNew; + } + + private void applyIfPresent(String value, java.util.function.Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java new file mode 100644 index 00000000..7a132378 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java @@ -0,0 +1,155 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.XmlResponseParser; +import com.carecode.core.client.provider.DataGoKrProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 소아청소년과 병원 동기화. + * + *

출처: 건강보험심사평가원_병원정보서비스 (공공데이터포털) + * + *

{@code Hospital} 엔티티는 있었으나 데이터를 넣을 경로가 없어 비어 있었다. + * 진료과목 코드로 소아청소년과만 걸러 적재한다. + * + *

이 데이터셋은 XML 로 응답하므로 {@link XmlResponseParser} 를 거친다. + * (다른 data.go.kr 데이터셋과 달리 JSON 을 지원하지 않을 수 있어 두 포맷을 모두 처리한다.) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PediatricHospitalSyncService { + + private static final String RESOURCE = "B551182/hospInfoServicev2/getHospBasisList"; + + private static final int ROWS_PER_PAGE = 100; + + private final DataGoKrProvider provider; + private final HospitalUpsertService upsertService; + private final XmlResponseParser xmlResponseParser; + private final ObjectMapper objectMapper; + + /** + * 진료과목 코드. 심평원 코드표 기준 소아청소년과는 "10". + * 코드가 개정될 수 있어 설정으로 뺀다. + */ + @Value("${public.data.hospital.pediatric-subject-code:10}") + private String pediatricSubjectCode; + + @Value("${public.data.hospital.subject-name:소아청소년과}") + private String subjectName; + + @Value("${public.data.sync.max-pages:200}") + private int maxPages; + + public SyncResult sync() { + SyncResult result = new SyncResult(provider.getProviderName(), "소아청소년과병원"); + + if (!provider.isAvailable()) { + result.stop("공공데이터포털 서비스 키 미설정"); + log.info("병원 동기화 건너뜀 - 서비스 키가 없습니다."); + return result; + } + + for (int page = 1; page <= maxPages; page++) { + JsonNode rows; + try { + String body = provider.fetch(RESOURCE, page, ROWS_PER_PAGE, buildParams()); + rows = extractRows(body); + } catch (Exception e) { + log.error("병원 정보 조회 실패 - page={}", page, e); + result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); + return result; + } + + if (rows == null || rows.isEmpty()) { + return result; + } + + int rowCount = 0; + for (JsonNode row : rows) { + rowCount++; + try { + if (upsertService.upsert(row, subjectName)) { + result.countCreated(); + } else { + result.countUpdated(); + } + } catch (Exception e) { + result.countFailed(); + log.warn("병원 저장 실패: {}", e.getMessage()); + } + } + result.countPage(); + + if (rowCount < ROWS_PER_PAGE) { + return result; + } + } + + result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); + log.warn("병원 동기화가 페이지 상한에 걸렸습니다."); + return result; + } + + private Map buildParams() { + Map params = new LinkedHashMap<>(); + params.put("dgsbjtCd", pediatricSubjectCode); + // 이 데이터셋은 XML 이 기본이다. JSON 을 지원하면 _type 으로 받고, 아니면 XML 로 파싱한다. + params.put("_type", "json"); + return params; + } + + /** + * 응답에서 항목 배열을 꺼낸다. JSON 과 XML 응답을 모두 처리한다. + * + *

단일 항목이면 배열이 아니라 객체로 오는 경우가 있어(특히 XML→JSON 변환 시) + * 그 경우 1건짜리 배열로 감싼다. + */ + private JsonNode extractRows(String body) { + if (body == null || body.isBlank()) { + return null; + } + + JsonNode root = null; + String trimmed = body.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + root = objectMapper.readTree(body); + } catch (Exception ignored) { + // JSON 파싱 실패 시 XML 로 재시도 + } + } + if (root == null) { + root = xmlResponseParser.parse(body); + } + if (root == null) { + return null; + } + + JsonNode items = root.path("body").path("items"); + if (items.isMissingNode()) { + items = root.path("response").path("body").path("items"); + } + + JsonNode item = items.path("item"); + if (item.isArray()) { + return item; + } + if (item.isObject()) { + return objectMapper.createArrayNode().add(item); + } + if (items.isArray()) { + return items; + } + return null; + } +} From 76417eefcfb748221fb4868562f33da18d93b41c Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:04:25 +0900 Subject: [PATCH 11/23] =?UTF-8?q?FEAT=20:=20=EB=B3=91=EC=9B=90=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A6=AC=EC=9E=90=20API=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/scheduler/PublicDataSyncScheduler.java | 12 ++++++++++++ .../admin/controller/AdminPublicDataController.java | 9 +++++++++ src/main/resources/application.yml | 7 +++++++ 3 files changed, 28 insertions(+) diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java index 5a65ffb5..896c2a28 100644 --- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -2,6 +2,7 @@ import com.carecode.core.client.sync.GovernmentBenefitSyncService; import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; +import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -21,6 +22,7 @@ public class PublicDataSyncScheduler { private final NationwideChildcareFacilitySyncService facilitySyncService; private final GovernmentBenefitSyncService benefitSyncService; + private final PediatricHospitalSyncService hospitalSyncService; /** * 전국 어린이집 동기화. 매주 월요일 새벽 3시. @@ -42,6 +44,16 @@ public void syncGovernmentBenefits() { logResult("정부 지원 서비스", result); } + /** + * 소아청소년과 병원 동기화. 매주 화요일 새벽 3시. + * 시설 동기화와 같은 날 돌리면 일일 트래픽 한도를 함께 소진하므로 하루 띄운다. + */ + @Scheduled(cron = "${app.scheduler.public-data.hospital-cron:0 0 3 * * TUE}", zone = "Asia/Seoul") + public void syncPediatricHospitals() { + SyncResult result = hospitalSyncService.sync(); + logResult("소아청소년과 병원", result); + } + private void logResult(String label, SyncResult result) { if (!result.isCompleted()) { log.warn("{} 동기화 미완료 - {}", label, result); diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java index 3551f9da..5a45f281 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -2,6 +2,7 @@ import com.carecode.core.client.sync.GovernmentBenefitSyncService; import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; +import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -28,6 +29,7 @@ public class AdminPublicDataController { private final NationwideChildcareFacilitySyncService facilitySyncService; private final GovernmentBenefitSyncService benefitSyncService; + private final PediatricHospitalSyncService hospitalSyncService; @PostMapping("/facilities/sync") @Operation(summary = "전국 어린이집 동기화", @@ -43,6 +45,13 @@ public ResponseEntity> syncBenefits() { return ResponseEntity.ok(toResponse(benefitSyncService.sync())); } + @PostMapping("/hospitals/sync") + @Operation(summary = "소아청소년과 병원 동기화", + description = "심평원 병원정보서비스에서 소아청소년과 진료 병원을 받아 요양기호 기준으로 갱신합니다.") + public ResponseEntity> syncHospitals() { + return ResponseEntity.ok(toResponse(hospitalSyncService.sync())); + } + private Map toResponse(SyncResult result) { Map body = new LinkedHashMap<>(); body.put("provider", result.getProvider()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 75342196..f7d82d00 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -212,6 +212,13 @@ public: service-key: ${DATA_GO_KR_SERVICE_KEY:} base-url: ${DATA_GO_KR_BASE_URL:https://apis.data.go.kr} + # 심평원 병원정보서비스 + hospital: + # 진료과목 코드. 심평원 코드표 기준 소아청소년과는 "10". + # 코드가 개정될 수 있어 설정으로 뺀다. + pediatric-subject-code: ${HOSPITAL_PEDIATRIC_SUBJECT_CODE:10} + subject-name: ${HOSPITAL_SUBJECT_NAME:소아청소년과} + sync: # 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다. max-pages: ${PUBLIC_DATA_SYNC_MAX_PAGES:200} From 4fdaa639fec4108f4509b16a1241e386c7bbd5af Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:04:25 +0900 Subject: [PATCH 12/23] =?UTF-8?q?TEST=20:=20XML=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=ED=8C=8C=EC=84=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/client/XmlResponseParserTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/test/java/com/carecode/core/client/XmlResponseParserTest.java diff --git a/src/test/java/com/carecode/core/client/XmlResponseParserTest.java b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java new file mode 100644 index 00000000..9bdf5c42 --- /dev/null +++ b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java @@ -0,0 +1,86 @@ +package com.carecode.core.client; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * XML 응답 파서 검증. + * + *

심평원 병원정보서비스처럼 XML 만 반환하는 데이터셋을 처리하기 위한 것으로, + * 외부에서 받은 XML 을 다루므로 XXE 차단이 특히 중요하다. + */ +@DisplayName("XmlResponseParser") +class XmlResponseParserTest { + + private final XmlResponseParser parser = new XmlResponseParser(); + + @Test + @DisplayName("반복되는 엘리먼트를 배열로 모은다") + void collectsRepeatedElementsAsArray() { + String xml = """ + + A1행복소아과 + A2사랑소아과 + + """; + + JsonNode root = parser.parse(xml); + JsonNode items = root.path("body").path("items").path("item"); + + assertThat(items.isArray()).isTrue(); + assertThat(items).hasSize(2); + assertThat(items.get(0).path("yadmNm").asText()).isEqualTo("행복소아과"); + assertThat(items.get(1).path("ykiho").asText()).isEqualTo("A2"); + } + + @Test + @DisplayName("항목이 하나면 객체로 반환된다") + void singleItemBecomesObject() { + String xml = """ + + A1행복소아과 + + """; + + JsonNode item = parser.parse(xml).path("body").path("items").path("item"); + + assertThat(item.isObject()).isTrue(); + assertThat(item.path("yadmNm").asText()).isEqualTo("행복소아과"); + } + + @Test + @DisplayName("좌표와 전화번호 등 값 노드를 문자열로 읽는다") + void readsLeafValues() { + String xml = "127.0537.5102-123-4567"; + + JsonNode node = parser.parse(xml); + + assertThat(node.path("XPos").asText()).isEqualTo("127.05"); + assertThat(node.path("YPos").asText()).isEqualTo("37.51"); + assertThat(node.path("telno").asText()).isEqualTo("02-123-4567"); + } + + @Test + @DisplayName("XXE 공격 페이로드는 파싱하지 않는다") + void rejectsXxePayload() { + String malicious = """ + + ]> + &xxe; + """; + + // DOCTYPE 선언 자체를 막으므로 파싱이 실패하고 null 이 된다. + assertThat(parser.parse(malicious)).isNull(); + } + + @Test + @DisplayName("빈 입력과 잘못된 XML은 null을 반환한다") + void returnsNullForInvalidInput() { + assertThat(parser.parse(null)).isNull(); + assertThat(parser.parse("")).isNull(); + assertThat(parser.parse("")).isNull(); + } +} From 1eb0cd7277edf8c0f92d9282583d46dc38b1ebce Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:39:48 +0900 Subject: [PATCH 13/23] =?UTF-8?q?STYLE=20:=20=EC=97=AC=EB=9F=AC=20?= =?UTF-8?q?=EC=A4=84=20=EC=A3=BC=EC=84=9D=EC=9D=84=20=ED=95=9C=20=EC=A4=84?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/client/XmlResponseParser.java | 20 ++----------- .../constants/PublicDataApiConstants.java | 19 ++---------- .../client/provider/DataGoKrProvider.java | 11 +------ .../client/provider/PublicDataProvider.java | 29 ++----------------- .../provider/SeoulOpenDataProvider.java | 8 +---- .../sync/CareFacilityUpsertService.java | 15 ++-------- .../sync/GovernmentBenefitSyncService.java | 11 +------ .../client/sync/HospitalUpsertService.java | 14 ++------- ...ationwideChildcareFacilitySyncService.java | 14 ++------- .../sync/PediatricHospitalSyncService.java | 24 ++------------- .../core/client/sync/PolicyUpsertService.java | 13 ++------- .../carecode/core/client/sync/SyncResult.java | 7 +---- .../scheduler/PublicDataSyncScheduler.java | 22 +++----------- .../controller/AdminPublicDataController.java | 16 +++------- .../domain/health/entity/Hospital.java | 6 +--- .../core/client/XmlResponseParserTest.java | 7 +---- .../client/provider/DataGoKrProviderTest.java | 7 +---- 17 files changed, 35 insertions(+), 208 deletions(-) diff --git a/src/main/java/com/carecode/core/client/XmlResponseParser.java b/src/main/java/com/carecode/core/client/XmlResponseParser.java index 4db9c437..639c1429 100644 --- a/src/main/java/com/carecode/core/client/XmlResponseParser.java +++ b/src/main/java/com/carecode/core/client/XmlResponseParser.java @@ -16,24 +16,13 @@ import javax.xml.parsers.DocumentBuilderFactory; import java.io.StringReader; -/** - * XML 응답을 {@link JsonNode} 로 변환한다. - * - *

공공데이터포털 데이터셋 중 일부(예: 심평원 병원정보서비스)는 XML 만 반환한다. - * {@code jackson-dataformat-xml} 을 클래스패스에 넣으면 Spring MVC 가 - * XML 메시지 컨버터를 자동 등록해 애플리케이션 응답 협상 동작까지 바뀌므로, - * 의존성을 늘리지 않고 JDK 내장 DOM 파서로 변환한다. - */ +/** XML 응답을 JsonNode 로 변환한다. */ @Component public class XmlResponseParser { private final ObjectMapper objectMapper = new ObjectMapper(); - /** - * XML 문자열을 JsonNode 트리로 변환한다. - * - * @return 파싱할 수 없으면 null - */ + /** XML 문자열을 JsonNode 트리로 변환한다. */ public JsonNode parse(String xml) { if (xml == null || xml.isBlank()) { return null; @@ -47,10 +36,7 @@ public JsonNode parse(String xml) { } } - /** - * 외부 엔티티 참조(XXE)를 차단한 DocumentBuilder. - * 외부에서 받은 XML 을 파싱하므로 기본 설정을 그대로 쓰면 안 된다. - */ + /** 외부 엔티티 참조(XXE)를 차단한 DocumentBuilder. */ private DocumentBuilder newSecureBuilder() throws Exception { DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); diff --git a/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java b/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java index 53e25e9a..56bc0129 100644 --- a/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java +++ b/src/main/java/com/carecode/core/client/constants/PublicDataApiConstants.java @@ -2,13 +2,7 @@ import java.util.Map; -/** - * 공공데이터 API 공통 상수. - * - *

이전에는 엔드포인트 상수 5종과 정책·교육 유형 코드가 정의돼 있었으나 - * 어디서도 참조되지 않는 죽은 코드였다("연동돼 있다"는 착각만 만들었다). - * 실제로 쓰이는 값만 남긴다. - */ +/** 공공데이터 API 공통 상수. */ public final class PublicDataApiConstants { /** 공공데이터포털 공통 응답 코드. */ @@ -17,10 +11,7 @@ public final class PublicDataApiConstants { /** 한 번에 요청할 수 있는 최대 건수 (서울 열린데이터광장 기준). */ public static final int MAX_NUM_OF_ROWS = 1000; - /** - * 시도명 → 법정동 시도 코드. - * 공공데이터포털 API 가 지역 필터를 지원할 때 사용한다. - */ + /** 시도명 → 법정동 시도 코드. */ public static final Map SIDO_CODES = Map.ofEntries( Map.entry("서울특별시", "11"), Map.entry("부산광역시", "21"), @@ -44,11 +35,7 @@ private PublicDataApiConstants() { // 유틸리티 클래스이므로 인스턴스화 방지 } - /** - * 시도명으로 시도 코드를 찾는다. 표기가 조금 달라도(예: "서울") 매칭한다. - * - * @return 찾지 못하면 null - */ + /** 시도명으로 시도 코드를 찾는다. */ public static String findSidoCode(String sidoName) { if (sidoName == null || sidoName.isBlank()) { return null; diff --git a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java index 0362dff4..a2d073fc 100644 --- a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java +++ b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java @@ -11,16 +11,7 @@ import java.nio.charset.StandardCharsets; import java.util.Map; -/** - * 공공데이터포털(data.go.kr) 공급자. - * - *

URL 규격: {@code {baseUrl}/{resource}?serviceKey=...&pageNo=1&numOfRows=100&type=json} - * 서울시와 달리 페이지 번호를 쿼리 파라미터로 넘긴다. - * - *

주의: 발급받는 serviceKey 는 이미 URL 인코딩된 문자열이다. - * {@code UriComponentsBuilder} 로 다시 인코딩하면 `%2B` 가 `%252B` 가 되어 인증에 실패하므로, - * 키만 따로 붙여 {@link URI} 를 직접 만든다. - */ +/** 공공데이터포털(data.go.kr) 공급자. */ @Slf4j @Component public class DataGoKrProvider implements PublicDataProvider { diff --git a/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java b/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java index 12e2d55b..00c4b669 100644 --- a/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java +++ b/src/main/java/com/carecode/core/client/provider/PublicDataProvider.java @@ -2,37 +2,14 @@ import java.util.Map; -/** - * 공공데이터 공급자 추상화. - * - *

공급자마다 인증 방식과 URL 규격이 다르다. - *

- * - *

이 차이를 구현체가 흡수해서, 도메인 코드는 "몇 페이지에서 몇 건" 만 요청하면 되게 한다. - * 새 공급자를 붙일 때 기존 코드를 건드리지 않기 위한 경계다. - */ +/** 공공데이터 공급자 추상화. 공급자별 인증 방식과 URL 규격 차이를 구현체가 흡수한다. */ public interface PublicDataProvider { - /** 공급자 식별자. 로그와 동기화 이력에 사용한다. */ String getProviderName(); - /** - * API 키가 설정돼 있어 실제 호출이 가능한지. - * 키가 없으면 호출부가 조용히 건너뛴다 (로컬/CI 기동 보장). - */ + /** API 키가 없으면 false. 호출부가 조용히 건너뛴다. */ boolean isAvailable(); - /** - * 데이터를 조회한다. - * - * @param resource 공급자 내 리소스 식별자 (서비스명/오퍼레이션명) - * @param pageNo 1부터 시작하는 페이지 번호 - * @param numOfRows 한 페이지 건수 - * @param params 추가 쿼리 파라미터 (지역 코드 등). 없으면 빈 맵 - * @return 원본 응답 본문 (JSON 문자열) - */ + /** 원본 응답 본문을 반환한다. pageNo는 1부터 시작. */ String fetch(String resource, int pageNo, int numOfRows, Map params); } diff --git a/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java b/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java index 70f97977..57a038c2 100644 --- a/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java +++ b/src/main/java/com/carecode/core/client/provider/SeoulOpenDataProvider.java @@ -8,19 +8,13 @@ import java.util.Map; -/** - * 서울 열린데이터광장 공급자. - * - *

URL 규격: {@code {baseUrl}/{apiKey}/json/{service}/{startIndex}/{endIndex}/} - * 페이지 번호가 아니라 시작·종료 인덱스를 경로에 넣는다. 한 번에 최대 1000건. - */ +/** 서울 열린데이터광장 공급자. 페이지 번호 대신 시작·종료 인덱스를 URL 경로에 넣는다. */ @Slf4j @Component public class SeoulOpenDataProvider implements PublicDataProvider { public static final String PROVIDER_NAME = "SEOUL_OPEN_DATA"; - /** 서울시 API가 한 번에 허용하는 최대 건수. */ private static final int MAX_ROWS = 1000; private final RestTemplate restTemplate; diff --git a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java index 4fc71bc6..bccd1766 100644 --- a/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/CareFacilityUpsertService.java @@ -12,13 +12,7 @@ import java.time.LocalDateTime; -/** - * 시설 한 건을 저장하는 트랜잭션 경계. - * - *

동기화 루프와 분리한 이유: {@code @Transactional} 은 프록시로 동작하므로 - * 같은 클래스 안에서 호출하면 적용되지 않는다(self-invocation). - * 한 건 실패가 전체 배치를 롤백시키지 않으려면 별도 빈이어야 한다. - */ +/** 시설 한 건을 저장하는 트랜잭션 경계. */ @Slf4j @Service @RequiredArgsConstructor @@ -26,12 +20,7 @@ public class CareFacilityUpsertService { private final CareFacilityRepository careFacilityRepository; - /** - * 시설 코드 기준 upsert. - * - * @return 신규 생성이면 true, 기존 갱신이면 false - * @throws IllegalArgumentException 시설 코드가 없는 경우 - */ + /** 시설 코드 기준 upsert. */ @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean upsert(JsonNode row) { String facilityCode = text(row, "STCODE", "crcodeCd", "crcode"); diff --git a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java index e70a83fa..52866d16 100644 --- a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java +++ b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java @@ -12,16 +12,7 @@ import java.util.List; import java.util.Map; -/** - * 정부 지원 서비스(보조금24) 동기화. - * - *

출처: 행정안전부_대한민국 공공서비스(혜택) 정보 (공공데이터포털) - * - *

정책 데이터가 코드에 하드코딩돼 있어 매년 바뀌는 내용을 재배포 없이는 반영할 수 없었다. - * 중앙부처·지자체 서비스를 주기적으로 받아 자동 최신화한다. - * - *

약 7,500개 서비스 전부가 육아와 관련되지는 않으므로 키워드로 걸러 적재한다. - */ +/** 정부 지원 서비스(보조금24) 동기화. */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java index 4b1a26ed..cf4cf97f 100644 --- a/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/HospitalUpsertService.java @@ -9,12 +9,7 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -/** - * 병원 한 건을 저장하는 트랜잭션 경계. - * - *

동기화 루프와 분리한 이유는 {@link CareFacilityUpsertService} 와 같다 — - * {@code @Transactional} 은 프록시로 동작하므로 같은 클래스 안에서 호출하면 적용되지 않는다. - */ +/** 병원 한 건을 저장하는 트랜잭션 경계. */ @Slf4j @Service @RequiredArgsConstructor @@ -22,12 +17,7 @@ public class HospitalUpsertService { private final HospitalRepository hospitalRepository; - /** - * 요양기호(ykiho) 기준 upsert. - * - * @param defaultType 진료과목명. 응답에 종별명이 없을 때 사용한다. - * @return 신규 생성이면 true - */ + /** 요양기호(ykiho) 기준 upsert. */ @Transactional(propagation = Propagation.REQUIRES_NEW) public boolean upsert(JsonNode row, String defaultType) { String ykiho = text(row, "ykiho", "YKIHO"); diff --git a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java index 3d6d6ad6..e4dc0b59 100644 --- a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java +++ b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java @@ -10,14 +10,7 @@ import java.util.Map; -/** - * 전국 어린이집 정보 동기화. - * - *

출처: 한국사회보장정보원_전국 어린이집 정보 조회 (공공데이터포털) - * - *

기존 연동은 서울 열린데이터광장 하나뿐이라 서울 외 지역에는 시설 데이터가 없었다. - * 이 서비스는 전국 데이터를 시설 코드 기준으로 upsert 한다. - */ +/** 전국 어린이집 정보 동기화. */ @Slf4j @Service @RequiredArgsConstructor @@ -85,10 +78,7 @@ public SyncResult sync() { return result; } - /** - * 공공데이터포털 응답에서 데이터 배열을 꺼낸다. - * 데이터셋마다 래핑 구조가 조금씩 달라 흔한 형태를 순서대로 시도한다. - */ + /** 공공데이터포털 응답에서 데이터 배열을 꺼낸다. */ private JsonNode extractRows(String body) throws Exception { if (body == null || body.isBlank()) { return null; diff --git a/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java index 7a132378..efe484a2 100644 --- a/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java +++ b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java @@ -12,17 +12,7 @@ import java.util.LinkedHashMap; import java.util.Map; -/** - * 소아청소년과 병원 동기화. - * - *

출처: 건강보험심사평가원_병원정보서비스 (공공데이터포털) - * - *

{@code Hospital} 엔티티는 있었으나 데이터를 넣을 경로가 없어 비어 있었다. - * 진료과목 코드로 소아청소년과만 걸러 적재한다. - * - *

이 데이터셋은 XML 로 응답하므로 {@link XmlResponseParser} 를 거친다. - * (다른 data.go.kr 데이터셋과 달리 JSON 을 지원하지 않을 수 있어 두 포맷을 모두 처리한다.) - */ +/** 소아청소년과 병원 동기화. */ @Slf4j @Service @RequiredArgsConstructor @@ -37,10 +27,7 @@ public class PediatricHospitalSyncService { private final XmlResponseParser xmlResponseParser; private final ObjectMapper objectMapper; - /** - * 진료과목 코드. 심평원 코드표 기준 소아청소년과는 "10". - * 코드가 개정될 수 있어 설정으로 뺀다. - */ + /** 진료과목 코드. */ @Value("${public.data.hospital.pediatric-subject-code:10}") private String pediatricSubjectCode; @@ -108,12 +95,7 @@ private Map buildParams() { return params; } - /** - * 응답에서 항목 배열을 꺼낸다. JSON 과 XML 응답을 모두 처리한다. - * - *

단일 항목이면 배열이 아니라 객체로 오는 경우가 있어(특히 XML→JSON 변환 시) - * 그 경우 1건짜리 배열로 감싼다. - */ + /** 응답에서 항목 배열을 꺼낸다. */ private JsonNode extractRows(String body) { if (body == null || body.isBlank()) { return null; diff --git a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java index be8c1d17..a1819bd6 100644 --- a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java @@ -12,12 +12,7 @@ import java.time.LocalDateTime; -/** - * 정책 한 건을 저장하는 트랜잭션 경계. - * - *

관리자가 직접 수정한 정책을 공공데이터가 덮어쓰지 않도록, - * 외부에서 받은 정책은 {@code policyCode} 에 접두어를 붙여 출처를 구분한다. - */ +/** 정책 한 건을 저장하는 트랜잭션 경계. */ @Slf4j @Service @RequiredArgsConstructor @@ -28,11 +23,7 @@ public class PolicyUpsertService { private final PolicyRepository policyRepository; - /** - * 서비스 ID 기준 upsert. - * - * @return 신규 생성이면 true - */ + /** 서비스 ID 기준 upsert. */ @Transactional(propagation = Propagation.REQUIRES_NEW) @CacheEvict(cacheNames = "policy", allEntries = true) public boolean upsert(JsonNode row) { diff --git a/src/main/java/com/carecode/core/client/sync/SyncResult.java b/src/main/java/com/carecode/core/client/sync/SyncResult.java index ffa8d050..39d6e7ff 100644 --- a/src/main/java/com/carecode/core/client/sync/SyncResult.java +++ b/src/main/java/com/carecode/core/client/sync/SyncResult.java @@ -2,12 +2,7 @@ import lombok.Getter; -/** - * 공공데이터 동기화 결과. - * - *

이전에는 동기화가 조용히 중단돼도 알 방법이 없었다. - * 성공·실패 건수와 중단 사유를 담아 로그와 응답에 남긴다. - */ +/** 공공데이터 동기화 결과. */ @Getter public class SyncResult { diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java index 896c2a28..a28f9bc2 100644 --- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -9,12 +9,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -/** - * 공공데이터 주기 동기화. - * - *

이전에는 관리자가 API 를 직접 호출해야 했고, 페이지 상한에 걸리면 조용히 멈췄다. - * 여기서는 정기적으로 돌리고 결과(신규/갱신/실패/중단 사유)를 로그로 남긴다. - */ +/** 공공데이터 주기 동기화. */ @Slf4j @Component @RequiredArgsConstructor @@ -24,30 +19,21 @@ public class PublicDataSyncScheduler { private final GovernmentBenefitSyncService benefitSyncService; private final PediatricHospitalSyncService hospitalSyncService; - /** - * 전국 어린이집 동기화. 매주 월요일 새벽 3시. - * 시설 정보는 변동이 잦지 않아 주 1회로 충분하고, 트래픽 한도(일 10,000건)도 아낄 수 있다. - */ + /** 전국 어린이집 동기화. */ @Scheduled(cron = "${app.scheduler.public-data.facility-cron:0 0 3 * * MON}", zone = "Asia/Seoul") public void syncChildcareFacilities() { SyncResult result = facilitySyncService.sync(); logResult("전국 어린이집", result); } - /** - * 정부 지원 서비스(보조금24) 동기화. 매일 새벽 3시 30분. - * 정책은 신청 기간이 있어 시설보다 자주 확인한다. - */ + /** 정부 지원 서비스(보조금24) 동기화. */ @Scheduled(cron = "${app.scheduler.public-data.benefit-cron:0 30 3 * * *}", zone = "Asia/Seoul") public void syncGovernmentBenefits() { SyncResult result = benefitSyncService.sync(); logResult("정부 지원 서비스", result); } - /** - * 소아청소년과 병원 동기화. 매주 화요일 새벽 3시. - * 시설 동기화와 같은 날 돌리면 일일 트래픽 한도를 함께 소진하므로 하루 띄운다. - */ + /** 소아청소년과 병원 동기화. */ @Scheduled(cron = "${app.scheduler.public-data.hospital-cron:0 0 3 * * TUE}", zone = "Asia/Seoul") public void syncPediatricHospitals() { SyncResult result = hospitalSyncService.sync(); diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java index 5a45f281..134ddb05 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -15,12 +15,7 @@ import java.util.LinkedHashMap; import java.util.Map; -/** - * 공공데이터 수동 동기화 API. - * - *

정기 동기화는 {@code PublicDataSyncScheduler} 가 담당한다. - * 이 엔드포인트는 즉시 반영이 필요할 때 쓰는 보조 수단이다. - */ +/** 공공데이터 수동 동기화 API. */ @RestController @RequestMapping("/api/admin/public-data") @RequiredArgsConstructor @@ -32,22 +27,19 @@ public class AdminPublicDataController { private final PediatricHospitalSyncService hospitalSyncService; @PostMapping("/facilities/sync") - @Operation(summary = "전국 어린이집 동기화", - description = "공공데이터포털에서 전국 어린이집 정보를 받아 시설 코드 기준으로 갱신합니다.") + @Operation(summary = "전국 어린이집 동기화", description = "시설 코드 기준으로 갱신") public ResponseEntity> syncFacilities() { return ResponseEntity.ok(toResponse(facilitySyncService.sync())); } @PostMapping("/benefits/sync") - @Operation(summary = "정부 지원 서비스 동기화", - description = "보조금24 공공서비스 정보에서 육아 관련 서비스를 받아 정책으로 갱신합니다.") + @Operation(summary = "정부 지원 서비스 동기화", description = "육아 관련 서비스만 정책으로 갱신") public ResponseEntity> syncBenefits() { return ResponseEntity.ok(toResponse(benefitSyncService.sync())); } @PostMapping("/hospitals/sync") - @Operation(summary = "소아청소년과 병원 동기화", - description = "심평원 병원정보서비스에서 소아청소년과 진료 병원을 받아 요양기호 기준으로 갱신합니다.") + @Operation(summary = "소아청소년과 병원 동기화", description = "요양기호 기준으로 갱신") public ResponseEntity> syncHospitals() { return ResponseEntity.ok(toResponse(hospitalSyncService.sync())); } diff --git a/src/main/java/com/carecode/domain/health/entity/Hospital.java b/src/main/java/com/carecode/domain/health/entity/Hospital.java index 6395e96a..c6fdb7a4 100644 --- a/src/main/java/com/carecode/domain/health/entity/Hospital.java +++ b/src/main/java/com/carecode/domain/health/entity/Hospital.java @@ -20,11 +20,7 @@ public class Hospital { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - /** - * 심평원 암호화 요양기호(ykiho). - * 공공데이터 동기화 시 중복 적재를 막는 외부 식별자다. - * 복호화 수단은 제공되지 않으므로 값 자체를 키로 쓴다. - */ + /** 심평원 암호화 요양기호(ykiho). */ @Column(name = "external_code", unique = true, length = 100) private String externalCode; diff --git a/src/test/java/com/carecode/core/client/XmlResponseParserTest.java b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java index 9bdf5c42..abcb86b0 100644 --- a/src/test/java/com/carecode/core/client/XmlResponseParserTest.java +++ b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java @@ -6,12 +6,7 @@ import static org.assertj.core.api.Assertions.assertThat; -/** - * XML 응답 파서 검증. - * - *

심평원 병원정보서비스처럼 XML 만 반환하는 데이터셋을 처리하기 위한 것으로, - * 외부에서 받은 XML 을 다루므로 XXE 차단이 특히 중요하다. - */ +/** XML 응답 파서 검증. */ @DisplayName("XmlResponseParser") class XmlResponseParserTest { diff --git a/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java index 0f4fd613..a447af15 100644 --- a/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java +++ b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java @@ -16,12 +16,7 @@ import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; -/** - * 공공데이터포털 공급자 검증. - * - *

serviceKey 는 발급 시점에 이미 URL 인코딩된 문자열이라, - * 빌더로 다시 인코딩하면 {@code %2B} 가 {@code %252B} 로 이중 인코딩돼 인증에 실패한다. - */ +/** 공공데이터포털 공급자 검증. */ @DisplayName("DataGoKrProvider") class DataGoKrProviderTest { From 1a78b046fb5a6b95c9fc26ce8ef109473a954627 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:39:55 +0900 Subject: [PATCH 14/23] =?UTF-8?q?PERF=20:=20=EC=8B=9C=EC=84=A4=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EA=B2=80=EC=83=89=20=EB=B0=94=EC=9A=B4=EB=94=A9=20?= =?UTF-8?q?=EB=B0=95=EC=8A=A4=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=EC=88=98=EC=A0=95=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/carecode/core/util/BoundingBox.java | 27 ++++++++++++ .../db/migration/V4__search_indexes.sql | 18 ++++++++ .../carecode/core/util/BoundingBoxTest.java | 41 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 src/main/java/com/carecode/core/util/BoundingBox.java create mode 100644 src/main/resources/db/migration/V4__search_indexes.sql create mode 100644 src/test/java/com/carecode/core/util/BoundingBoxTest.java diff --git a/src/main/java/com/carecode/core/util/BoundingBox.java b/src/main/java/com/carecode/core/util/BoundingBox.java new file mode 100644 index 00000000..7b997873 --- /dev/null +++ b/src/main/java/com/carecode/core/util/BoundingBox.java @@ -0,0 +1,27 @@ +package com.carecode.core.util; + +/** 반경 검색용 위경도 사각 범위. 인덱스를 탈 수 있는 BETWEEN 조건으로 후보를 좁히는 데 쓴다. */ +public record BoundingBox(double minLat, double maxLat, double minLng, double maxLng) { + + /** 위도 1도당 거리(km). 경도와 달리 위치에 관계없이 거의 일정하다. */ + private static final double KM_PER_LAT_DEGREE = 111.045; + + /** 중심점과 반경(km)으로 사각 범위를 만든다. */ + public static BoundingBox around(double latitude, double longitude, double radiusKm) { + double latDelta = radiusKm / KM_PER_LAT_DEGREE; + + // 경도 1도의 거리는 위도가 높을수록 짧아진다. 극지방에서 0으로 나누지 않도록 하한을 둔다. + double cosLat = Math.max(Math.cos(Math.toRadians(latitude)), 0.01); + double lngDelta = radiusKm / (KM_PER_LAT_DEGREE * cosLat); + + return new BoundingBox( + clampLat(latitude - latDelta), + clampLat(latitude + latDelta), + longitude - lngDelta, + longitude + lngDelta); + } + + private static double clampLat(double lat) { + return Math.max(-90.0, Math.min(90.0, lat)); + } +} diff --git a/src/main/resources/db/migration/V4__search_indexes.sql b/src/main/resources/db/migration/V4__search_indexes.sql new file mode 100644 index 00000000..48068efb --- /dev/null +++ b/src/main/resources/db/migration/V4__search_indexes.sql @@ -0,0 +1,18 @@ +-- V4: 위치 검색 및 전문 검색 인덱스 +-- +-- 반경 검색은 그동안 모든 행에 삼각함수를 계산한 뒤 HAVING 으로 걸러 풀 스캔이었다. +-- 바운딩 박스로 후보를 좁히도록 바꾸면서 BETWEEN 조건이 인덱스를 타도록 추가한다. +-- 검색은 LIKE '%키워드%' 라 인덱스를 쓸 수 없어 FULLTEXT 를 추가한다(ngram: 한글 대응). + +-- 위치 검색: 위도로 범위를 좁히고 경도로 다시 좁힌다. +CREATE INDEX idx_facility_location ON TBL_CARE_FACILITIES (LATITUDE, LONGITUDE); +CREATE INDEX idx_hospital_location ON TBL_HOSPITAL (LATITUDE, LONGITUDE); + +-- 전문 검색 (ngram 파서는 MySQL 5.7+ / MariaDB 10.0+ 에서 한글 토큰화에 필요) +CREATE FULLTEXT INDEX ft_facility_search ON TBL_CARE_FACILITIES (NAME, ADDRESS) WITH PARSER ngram; +CREATE FULLTEXT INDEX ft_policy_search ON TBL_POLICIES (TITLE, DESCRIPTION) WITH PARSER ngram; +CREATE FULLTEXT INDEX ft_post_search ON TBL_POST (TITLE, CONTENT) WITH PARSER ngram; + +-- 목록 정렬에 쓰이는 컬럼 +CREATE INDEX idx_facility_active_rating ON TBL_CARE_FACILITIES (IS_ACTIVE, RATING); +CREATE INDEX idx_policy_active_created ON TBL_POLICIES (IS_ACTIVE, CREATED_AT); diff --git a/src/test/java/com/carecode/core/util/BoundingBoxTest.java b/src/test/java/com/carecode/core/util/BoundingBoxTest.java new file mode 100644 index 00000000..5217d20e --- /dev/null +++ b/src/test/java/com/carecode/core/util/BoundingBoxTest.java @@ -0,0 +1,41 @@ +package com.carecode.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("바운딩 박스 계산") +class BoundingBoxTest { + + @Test + @DisplayName("반경만큼 위도 범위를 넓힌다") + void expandsLatitudeByRadius() { + BoundingBox box = BoundingBox.around(37.5665, 126.9780, 5); + + // 위도 1도 ≈ 111km 이므로 5km 는 약 0.045도 + assertThat(box.maxLat() - box.minLat()).isCloseTo(0.09, org.assertj.core.data.Offset.offset(0.005)); + assertThat(box.minLat()).isLessThan(37.5665); + assertThat(box.maxLat()).isGreaterThan(37.5665); + } + + @Test + @DisplayName("고위도일수록 경도 범위가 넓어진다") + void widensLongitudeAtHighLatitude() { + BoundingBox seoul = BoundingBox.around(37.5, 127.0, 5); + BoundingBox equator = BoundingBox.around(0.0, 127.0, 5); + + double seoulWidth = seoul.maxLng() - seoul.minLng(); + double equatorWidth = equator.maxLng() - equator.minLng(); + assertThat(seoulWidth).isGreaterThan(equatorWidth); + } + + @Test + @DisplayName("극지방에서도 위도가 범위를 벗어나지 않는다") + void clampsLatitudeAtPoles() { + BoundingBox box = BoundingBox.around(89.9, 0.0, 100); + + assertThat(box.maxLat()).isLessThanOrEqualTo(90.0); + assertThat(box.minLat()).isGreaterThanOrEqualTo(-90.0); + } +} From 258aa552eed74c1525d8a290e8b029bab3b91fa6 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:39:55 +0900 Subject: [PATCH 15/23] =?UTF-8?q?FEAT=20:=20=EC=8B=9C=EC=84=A4=20=EC=A0=84?= =?UTF-8?q?=EB=AC=B8=20=EA=B2=80=EC=83=89=20=EB=8F=84=EC=9E=85=20=EB=B0=8F?= =?UTF-8?q?=20LIKE=20=ED=8F=B4=EB=B0=B1=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/search/FullTextSearchSupport.java | 38 +++++++++++++++++ .../repository/CareFacilityRepository.java | 41 +++++++++++++------ .../service/CareFacilityService.java | 30 ++++++++++---- .../search/FullTextSearchSupportTest.java | 39 ++++++++++++++++++ 4 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/carecode/core/search/FullTextSearchSupport.java create mode 100644 src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java diff --git a/src/main/java/com/carecode/core/search/FullTextSearchSupport.java b/src/main/java/com/carecode/core/search/FullTextSearchSupport.java new file mode 100644 index 00000000..667bd810 --- /dev/null +++ b/src/main/java/com/carecode/core/search/FullTextSearchSupport.java @@ -0,0 +1,38 @@ +package com.carecode.core.search; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** 전문 검색 사용 가능 여부와 키워드 정규화. H2 등 MATCH AGAINST 미지원 DB에서는 끌 수 있다. */ +@Slf4j +@Component +public class FullTextSearchSupport { + + /** FULLTEXT 인덱스는 너무 짧은 토큰을 무시하므로 그 이하는 LIKE 로 처리한다. */ + private static final int MIN_KEYWORD_LENGTH = 2; + + private final boolean enabled; + + public FullTextSearchSupport(@Value("${app.search.fulltext-enabled:true}") boolean enabled) { + this.enabled = enabled; + if (!enabled) { + log.info("전문 검색이 비활성화되어 LIKE 검색으로 동작합니다."); + } + } + + /** 이 키워드를 전문 검색으로 처리할 수 있는지. */ + public boolean canUseFullText(String keyword) { + return enabled && normalize(keyword) != null; + } + + /** 검색 연산자로 해석될 수 있는 문자를 제거한다. 제거 후 유효하지 않으면 null. */ + public String normalize(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + // +, -, *, ", ~, <, >, ( ) 는 불리언 모드 연산자이거나 파싱을 깨뜨린다. + String cleaned = keyword.replaceAll("[+\\-*\"~<>()@]", " ").replaceAll("\\s+", " ").trim(); + return cleaned.length() >= MIN_KEYWORD_LENGTH ? cleaned : null; + } +} diff --git a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java index 868845c6..4965e157 100644 --- a/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java +++ b/src/main/java/com/carecode/domain/careFacility/repository/CareFacilityRepository.java @@ -124,18 +124,35 @@ List searchFacilities(@Param("facilityType") FacilityType facility List findNewFacilities(org.springframework.data.domain.Pageable pageable); - // 반경 내 시설 조회 - @Query(value = "SELECT cf.*, " + - "(6371 * acos(cos(radians(:latitude)) * cos(radians(cf.latitude)) * " + - "cos(radians(cf.longitude) - radians(:longitude)) + " + - "sin(radians(:latitude)) * sin(radians(cf.latitude)))) AS distance " + - "FROM care_facilities cf " + - "WHERE cf.is_active = true " + - "HAVING distance <= :radius " + - "ORDER BY distance", nativeQuery = true) - List findWithinRadius(@Param("latitude") Double latitude, - @Param("longitude") Double longitude, - @Param("radius") Double radius); + /** 반경 내 시설 조회. 바운딩 박스로 후보를 좁힌 뒤 정확한 거리를 계산한다. */ + @Query(value = "SELECT cf.* FROM (" + + " SELECT c.*, (6371 * acos(LEAST(1, " + + " cos(radians(:latitude)) * cos(radians(c.LATITUDE)) * " + + " cos(radians(c.LONGITUDE) - radians(:longitude)) + " + + " sin(radians(:latitude)) * sin(radians(c.LATITUDE))))) AS distance " + + " FROM TBL_CARE_FACILITIES c " + + " WHERE c.IS_ACTIVE = true " + + " AND c.LATITUDE BETWEEN :minLat AND :maxLat " + + " AND c.LONGITUDE BETWEEN :minLng AND :maxLng" + + ") cf WHERE cf.distance <= :radius ORDER BY cf.distance", + nativeQuery = true) + List findWithinBoundingBox(@Param("latitude") double latitude, + @Param("longitude") double longitude, + @Param("radius") double radius, + @Param("minLat") double minLat, + @Param("maxLat") double maxLat, + @Param("minLng") double minLng, + @Param("maxLng") double maxLng); + + /** 전문 검색. LIKE '%키워드%' 와 달리 인덱스를 타고 관련도 순으로 정렬된다. */ + @Query(value = "SELECT * FROM TBL_CARE_FACILITIES " + + "WHERE IS_ACTIVE = true " + + "AND MATCH(NAME, ADDRESS) AGAINST (:keyword IN NATURAL LANGUAGE MODE)", + countQuery = "SELECT COUNT(*) FROM TBL_CARE_FACILITIES " + + "WHERE IS_ACTIVE = true " + + "AND MATCH(NAME, ADDRESS) AGAINST (:keyword IN NATURAL LANGUAGE MODE)", + nativeQuery = true) + Page searchByFullText(@Param("keyword") String keyword, Pageable pageable); // 검색 조건으로 시설 검색 (페이징) @Query("SELECT cf FROM CareFacility cf WHERE cf.isActive = true " + diff --git a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java index 72e063dd..714c2415 100644 --- a/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java +++ b/src/main/java/com/carecode/domain/careFacility/service/CareFacilityService.java @@ -2,6 +2,8 @@ import org.springframework.cache.annotation.Cacheable; import com.carecode.core.annotation.LogExecutionTime; +import com.carecode.core.util.BoundingBox; +import com.carecode.core.search.FullTextSearchSupport; import com.carecode.core.annotation.ValidateLocation; import com.carecode.core.exception.CareFacilityNotFoundException; import com.carecode.domain.careFacility.dto.request.CareFacilitySearchRequest; @@ -49,6 +51,7 @@ public class CareFacilityService { private final ReviewRepository reviewRepository; private final UserRepository userRepository; private final CareFacilityMapper careFacilityMapper; + private final FullTextSearchSupport fullTextSearchSupport; // 공공데이터 API에서 받아온 보육시설 데이터를 DB에 저장 @@ -272,14 +275,20 @@ public CareFacilityListResponse searchCareFacilities(CareFacilitySearchRequest r Sort.Direction.ASC ); Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), sort); - - Page facilityPage = careFacilityRepository.findBySearchCriteria( - request.getKeyword(), - null, - request.getCity(), - pageable - ); - + + // 키워드만 있는 검색은 전문 검색으로 처리한다. LIKE '%키워드%' 는 인덱스를 못 탄다. + boolean keywordOnly = request.getCity() == null || request.getCity().isBlank(); + Page facilityPage; + if (keywordOnly && fullTextSearchSupport.canUseFullText(request.getKeyword())) { + String normalized = fullTextSearchSupport.normalize(request.getKeyword()); + facilityPage = careFacilityRepository.searchByFullText(normalized, + PageRequest.of(request.getPage(), request.getSize())); + } else { + facilityPage = careFacilityRepository.findBySearchCriteria( + request.getKeyword(), null, request.getCity(), pageable); + } + + List facilities = facilityPage.getContent().stream() .map(careFacilityMapper::toResponse) .collect(Collectors.toList()); @@ -323,7 +332,10 @@ public List getCareFacilitiesByLocation(String location) { @LogExecutionTime @ValidateLocation public List getCareFacilitiesWithinRadius(Double latitude, Double longitude, Double radius) { - List facilities = careFacilityRepository.findWithinRadius(latitude, longitude, radius); + BoundingBox box = BoundingBox.around(latitude, longitude, radius); + List facilities = careFacilityRepository.findWithinBoundingBox( + latitude, longitude, radius, + box.minLat(), box.maxLat(), box.minLng(), box.maxLng()); return facilities.stream() .map(careFacilityMapper::toResponse) .collect(Collectors.toList()); diff --git a/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java b/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java new file mode 100644 index 00000000..305c2aa5 --- /dev/null +++ b/src/test/java/com/carecode/core/search/FullTextSearchSupportTest.java @@ -0,0 +1,39 @@ +package com.carecode.core.search; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("전문 검색 키워드 정규화") +class FullTextSearchSupportTest { + + private final FullTextSearchSupport support = new FullTextSearchSupport(true); + + @Test + @DisplayName("불리언 모드 연산자를 제거한다") + void stripsBooleanOperators() { + assertThat(support.normalize("강남 +어린이집 -사립")).isEqualTo("강남 어린이집 사립"); + } + + @Test + @DisplayName("한 글자 키워드는 전문 검색으로 처리하지 않는다") + void rejectsTooShortKeyword() { + assertThat(support.normalize("가")).isNull(); + assertThat(support.canUseFullText("가")).isFalse(); + } + + @Test + @DisplayName("연산자만 있는 키워드는 무효로 본다") + void rejectsOperatorOnlyKeyword() { + assertThat(support.normalize("+++")).isNull(); + } + + @Test + @DisplayName("비활성화하면 항상 LIKE 로 폴백한다") + void fallsBackWhenDisabled() { + FullTextSearchSupport disabled = new FullTextSearchSupport(false); + + assertThat(disabled.canUseFullText("어린이집")).isFalse(); + } +} From 764b8fa090d052fd5fb1685d87eba47a8cdeb3d3 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:39:55 +0900 Subject: [PATCH 16/23] =?UTF-8?q?FEAT=20:=20=EC=B1=97=EB=B4=87=20=EA=B7=BC?= =?UTF-8?q?=EA=B1=B0=20=EA=B2=80=EC=83=89=20=EB=8B=A4=EC=A4=91=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=EB=B0=8F=20=EC=A1=B0=EC=82=AC=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatbot/rag/CareKnowledgeRetriever.java | 76 +++++++++++-------- .../rag/CareKnowledgeRetrieverTest.java | 54 +++++++++++++ 2 files changed, 100 insertions(+), 30 deletions(-) create mode 100644 src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java diff --git a/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java b/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java index 146e0bf0..07d2ac7f 100644 --- a/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java +++ b/src/main/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetriever.java @@ -11,17 +11,10 @@ import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; -/** - * 챗봇 답변 근거 검색기 (RAG 의 R). - * - *

사용자 질문에서 키워드를 뽑아 DB 의 정책·시설 데이터를 조회한다. - * 이렇게 하면 챗봇이 일반론 대신 "우리 서비스에 실제로 등록된" 내용을 근거로 답할 수 있다. - * - *

지금은 키워드 LIKE 검색이다. 데이터가 커지면 임베딩 기반 벡터 검색으로 - * 이 클래스 내부만 교체하면 된다 — 호출부는 {@link RetrievedContext} 에만 의존한다. - */ +/** 챗봇 답변 근거 검색기(RAG 의 R). 임베딩 검색으로 바꾸려면 이 클래스 내부만 교체하면 된다. */ @Slf4j @Component @RequiredArgsConstructor @@ -29,29 +22,42 @@ public class CareKnowledgeRetriever { private static final int MAX_PER_SOURCE = 3; private static final int MAX_CONTENT_LENGTH = 400; + private static final int MAX_KEYWORDS = 3; - /** 검색어에서 제외할 조사·의문사. 이걸 키워드로 쓰면 아무거나 매칭된다. */ + /** 검색어에서 제외할 의문사·범용어. 이걸로 검색하면 아무거나 매칭된다. */ private static final List STOP_WORDS = List.of( "알려줘", "알려주세요", "어디", "무엇", "뭐가", "뭔가요", "있나요", "있어", - "해줘", "하고", "그리고", "관련", "대해", "대한", "정보", "추천"); + "해줘", "하고", "그리고", "관련", "대해", "대한", "정보", "추천", + "우리", "저희", "지금", "가까운", "근처"); + + /** 길이 내림차순. 긴 것부터 떼어내야 "에서" 가 "서" 로 잘리지 않는다. */ + private static final List JOSA = List.of( + "에서는", "으로는", "에게는", "에서", "으로", "에게", "부터", "까지", "이랑", + "은", "는", "이", "가", "을", "를", "의", "에", "도", "와", "과", "로"); private final PolicyRepository policyRepository; private final CareFacilityRepository careFacilityRepository; @Transactional(readOnly = true) public RetrievedContext retrieve(String question) { - String keyword = extractKeyword(question); - if (keyword == null) { + List keywords = extractKeywords(question); + if (keywords.isEmpty()) { return RetrievedContext.builder().snippets(List.of()).build(); } + // 변별력 높은 키워드부터 시도하고, 근거를 찾으면 멈춘다. List snippets = new ArrayList<>(); - try { - snippets.addAll(searchPolicies(keyword)); - snippets.addAll(searchFacilities(keyword)); - } catch (Exception e) { - // 검색 실패가 대화 자체를 막지 않도록 한다. 근거 없이 일반 답변으로 진행한다. - log.error("챗봇 근거 검색 실패 - keyword={}", keyword, e); + for (String keyword : keywords) { + try { + snippets.addAll(searchPolicies(keyword)); + snippets.addAll(searchFacilities(keyword)); + } catch (Exception e) { + // 검색 실패가 대화를 막지 않도록 한다. 근거 없이 일반 답변으로 진행한다. + log.error("챗봇 근거 검색 실패 - keyword={}", keyword, e); + } + if (!snippets.isEmpty()) { + break; + } } return RetrievedContext.builder().snippets(snippets).build(); @@ -100,26 +106,36 @@ private String buildPolicyContent(Policy policy) { return truncate(sb.toString()); } - /** - * 질문에서 검색에 쓸 핵심 키워드를 뽑는다. - * 불용어를 제거하고 가장 긴 토큰을 사용한다 — 짧은 토큰일수록 노이즈가 많다. - */ - private String extractKeyword(String question) { + /** 질문에서 검색어 후보를 길이 내림차순으로 뽑는다. 긴 토큰일수록 변별력이 높다. */ + List extractKeywords(String question) { if (question == null || question.isBlank()) { - return null; + return List.of(); } String cleaned = question.replaceAll("[^가-힣a-zA-Z0-9\\s]", " "); - String best = null; + List keywords = new ArrayList<>(); for (String token : cleaned.split("\\s+")) { - if (token.length() < 2 || STOP_WORDS.contains(token)) { + String normalized = stripJosa(token); + if (normalized.length() < 2 || STOP_WORDS.contains(normalized) || keywords.contains(normalized)) { continue; } - if (best == null || token.length() > best.length()) { - best = token; + keywords.add(normalized); + } + keywords.sort(Comparator.comparingInt(String::length).reversed()); + return keywords.size() > MAX_KEYWORDS ? keywords.subList(0, MAX_KEYWORDS) : keywords; + } + + /** + * 흔한 조사를 떼어낸다. 형태소 분석기 없이 "어린이집은" → "어린이집" 정도만 맞춘다. + * 조사를 떼면 2자 미만이 되는 짧은 단어는 원형을 유지한다. + */ + private String stripJosa(String token) { + for (String josa : JOSA) { + if (token.length() > josa.length() + 1 && token.endsWith(josa)) { + return token.substring(0, token.length() - josa.length()); } } - return best; + return token; } private String truncate(String text) { diff --git a/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java b/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java new file mode 100644 index 00000000..95d94533 --- /dev/null +++ b/src/test/java/com/carecode/domain/chatbot/rag/CareKnowledgeRetrieverTest.java @@ -0,0 +1,54 @@ +package com.carecode.domain.chatbot.rag; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("챗봇 키워드 추출") +class CareKnowledgeRetrieverTest { + + private final CareKnowledgeRetriever retriever = new CareKnowledgeRetriever(null, null); + + @Test + @DisplayName("조사를 떼어내고 원형을 남긴다") + void stripsJosa() { + List keywords = retriever.extractKeywords("강남구에서 어린이집은 어디에 있나요"); + + assertThat(keywords).contains("어린이집"); + assertThat(keywords).noneMatch(k -> k.endsWith("에서")); + } + + @Test + @DisplayName("의문사·범용어는 검색어에서 제외한다") + void dropsStopWords() { + List keywords = retriever.extractKeywords("부모급여 정보 알려줘"); + + assertThat(keywords).containsExactly("부모급여"); + } + + @Test + @DisplayName("긴 토큰을 먼저 시도하도록 정렬한다") + void ordersByLengthDescending() { + List keywords = retriever.extractKeywords("아동수당 신청"); + + assertThat(keywords).containsExactly("아동수당", "신청"); + } + + @Test + @DisplayName("키워드는 최대 3개까지만 뽑는다") + void limitsKeywordCount() { + List keywords = retriever.extractKeywords("서울 강남 어린이집 보육료 지원 신청 방법"); + + assertThat(keywords).hasSize(3); + } + + @Test + @DisplayName("빈 질문이면 검색을 건너뛴다") + void returnsEmptyForBlankQuestion() { + assertThat(retriever.extractKeywords(" ")).isEmpty(); + assertThat(retriever.extractKeywords(null)).isEmpty(); + } +} From 615ce35e31fa4e5ffe7cc6667572ac21f0c42801 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:40:02 +0900 Subject: [PATCH 17/23] =?UTF-8?q?FEAT=20:=20=EC=9E=90=EB=85=80=20=EC=9B=94?= =?UTF-8?q?=EB=A0=B9=C2=B7=EA=B1=B0=EC=A3=BC=EC=A7=80=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=A0=95=EC=B1=85=20=EA=B0=9C=EC=9D=B8=ED=99=94=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/security/SecurityConfig.java | 6 +- .../domain/policy/app/PolicyFacade.java | 8 + .../policy/controller/PolicyController.java | 10 ++ .../response/PersonalizedPolicyResponse.java | 15 ++ .../policy/repository/PolicyRepository.java | 3 + .../service/PolicyRecommendationService.java | 141 ++++++++++++++++++ 6 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java create mode 100644 src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 6397b835..d474bcf5 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -147,7 +147,11 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.POST, "/hospitals/*/like").authenticated() .requestMatchers(HttpMethod.DELETE, "/hospitals/*/like").authenticated() - // 정책 API 엔드포인트 (공개 접근) + // 정책 API: 개인화·북마크는 인증 필요, 나머지 조회는 공개 + // 아래 /policies/* 와일드카드보다 먼저 선언해야 적용된다. + .requestMatchers("/policies/recommendations").authenticated() + .requestMatchers("/policies/bookmarks").authenticated() + .requestMatchers("/policies/*/bookmarks").authenticated() .requestMatchers("/policies").permitAll() .requestMatchers("/policies/search").permitAll() .requestMatchers("/policies/categories").permitAll() diff --git a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java index dfe4acca..7803f8e3 100644 --- a/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java +++ b/src/main/java/com/carecode/domain/policy/app/PolicyFacade.java @@ -1,10 +1,12 @@ package com.carecode.domain.policy.app; import com.carecode.domain.policy.dto.request.PolicySearchRequest; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.PolicyBookmarkResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.response.PolicyListResponse; import com.carecode.domain.policy.dto.response.PolicyStatsSimpleResponse; +import com.carecode.domain.policy.service.PolicyRecommendationService; import com.carecode.domain.policy.service.PolicyService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -17,6 +19,7 @@ public class PolicyFacade { private final PolicyService policyService; + private final PolicyRecommendationService policyRecommendationService; @Transactional(readOnly = true) public List getAllPolicies(int page, int size) { return policyService.getAllPolicies(page, size); } @@ -71,6 +74,11 @@ public List getBookmarks(String userIdOrEmail) { public void removeBookmark(String userIdOrEmail, Long policyId) { policyService.removeBookmark(userIdOrEmail, policyId); } + + @Transactional(readOnly = true) + public List recommendPolicies(int limit) { + return policyRecommendationService.recommendForCurrentUser(limit); + } } diff --git a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java index 916c3a4c..94ca6dfa 100644 --- a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java +++ b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java @@ -7,6 +7,7 @@ import com.carecode.core.security.CurrentUserFacade; import com.carecode.core.exception.CareServiceException; import com.carecode.core.exception.PolicyNotFoundException; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; import com.carecode.domain.policy.dto.response.PolicyDto; import com.carecode.domain.policy.dto.request.PolicySearchRequest; import com.carecode.domain.policy.dto.response.PolicyListResponse; @@ -262,6 +263,15 @@ public ResponseEntity removeBookmark( return ResponseEntity.ok(ApiSuccess.builder().timestamp(new Date()).message("북마크가 삭제되었습니다.").build()); } + // 개인화 정책 추천 + @GetMapping("/recommendations") + @LogExecutionTime + @Operation(summary = "맞춤 정책 추천", description = "자녀 월령과 거주지에 맞는 정책을 추천합니다.") + public ResponseEntity> getRecommendations( + @Parameter(description = "추천 개수", example = "10") @RequestParam(defaultValue = "10") Integer limit) { + return ResponseEntity.ok(policyFacade.recommendPolicies(PageRequestUtil.normalizeSize(limit))); + } + private String getAuthenticatedUserCode() { return currentUserFacade.requireCurrentUserId(); } diff --git a/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java b/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java new file mode 100644 index 00000000..68378979 --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/dto/response/PersonalizedPolicyResponse.java @@ -0,0 +1,15 @@ +package com.carecode.domain.policy.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +/** 개인화 추천 결과 한 건. 왜 추천됐는지 근거를 함께 내려 준다. */ +@Getter +@Builder +public class PersonalizedPolicyResponse { + private PolicyDto policy; + private int score; + private List reasons; +} diff --git a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java index 80086340..326bae72 100644 --- a/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java +++ b/src/main/java/com/carecode/domain/policy/repository/PolicyRepository.java @@ -26,6 +26,9 @@ public interface PolicyRepository extends JpaRepository { // 활성화된 정책 목록 조회 List findByIsActiveTrue(); + /** 추천 후보 조회. 우선순위 높은 정책부터 가져와 상위 N건만 채점한다. */ + Page findByIsActiveTrueOrderByPriorityDescViewCountDesc(Pageable pageable); + // 정책 유형별 조회 List findByPolicyType(String policyType); diff --git a/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java new file mode 100644 index 00000000..e0b0e2af --- /dev/null +++ b/src/main/java/com/carecode/domain/policy/service/PolicyRecommendationService.java @@ -0,0 +1,141 @@ +package com.carecode.domain.policy.service; + +import com.carecode.core.security.CurrentUserFacade; +import com.carecode.domain.policy.dto.response.PersonalizedPolicyResponse; +import com.carecode.domain.policy.entity.Policy; +import com.carecode.domain.policy.mapper.PolicyMapper; +import com.carecode.domain.policy.repository.PolicyRepository; +import com.carecode.domain.user.entity.Child; +import com.carecode.domain.user.entity.User; +import com.carecode.domain.user.repository.ChildRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** 아이 월령과 거주지로 정책을 추천한다. 협업 필터링은 사용자 로그가 쌓인 뒤에나 의미가 있다. */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PolicyRecommendationService { + + private static final int CANDIDATE_SIZE = 200; + private static final int SCORE_AGE_MATCH = 5; + private static final int SCORE_REGION_MATCH = 3; + private static final int SCORE_DEADLINE_SOON = 2; + private static final int DEADLINE_SOON_DAYS = 30; + + private final PolicyRepository policyRepository; + private final ChildRepository childRepository; + private final PolicyMapper policyMapper; + private final CurrentUserFacade currentUserFacade; + + /** 로그인 사용자에게 맞는 정책을 점수 순으로 반환한다. */ + public List recommendForCurrentUser(int limit) { + User user = currentUserFacade.requireCurrentUser(); + List children = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()); + LocalDate today = LocalDate.now(); + + List candidates = policyRepository + .findByIsActiveTrueOrderByPriorityDescViewCountDesc(PageRequest.of(0, CANDIDATE_SIZE)) + .getContent(); + + List scored = new ArrayList<>(); + for (Policy policy : candidates) { + if (isExpired(policy, today)) { + continue; + } + List reasons = new ArrayList<>(); + int score = score(policy, user, children, today, reasons); + if (score <= 0) { + continue; + } + scored.add(PersonalizedPolicyResponse.builder() + .policy(policyMapper.toResponse(policy)) + .score(score) + .reasons(reasons) + .build()); + } + + scored.sort(Comparator.comparingInt(PersonalizedPolicyResponse::getScore).reversed()); + return scored.size() > limit ? scored.subList(0, limit) : scored; + } + + /** 연령 조건이 있는데 맞는 아이가 없으면 0점으로 제외한다. */ + private int score(Policy policy, User user, List children, LocalDate today, List reasons) { + int score = 1; // 조건 없는 범용 정책도 노출되도록 하는 기본 점수 + + boolean hasAgeCondition = policy.getTargetAgeMin() != null || policy.getTargetAgeMax() != null; + if (hasAgeCondition) { + Child matched = children.stream().filter(c -> matchesAge(policy, c, today)).findFirst().orElse(null); + if (matched == null) { + return 0; + } + score += SCORE_AGE_MATCH; + reasons.add(matched.getName() + " 연령 조건에 해당합니다."); + } + + if (matchesRegion(policy, user, reasons)) { + score += SCORE_REGION_MATCH; + } + + if (isDeadlineNear(policy, today)) { + score += SCORE_DEADLINE_SOON; + reasons.add("신청 마감이 " + DEADLINE_SOON_DAYS + "일 이내입니다."); + } + + Integer priority = policy.getPriority(); + if (priority != null && priority > 0) { + score += Math.min(priority, 3); + } + return score; + } + + /** 정책 대상 월령과 아이의 월령을 비교한다. 시드 데이터 기준 targetAge 단위는 개월이다. */ + private boolean matchesAge(Policy policy, Child child, LocalDate today) { + if (child.getBirthDate() == null) { + return false; + } + long months = ChronoUnit.MONTHS.between(child.getBirthDate(), today); + Integer min = policy.getTargetAgeMin(); + Integer max = policy.getTargetAgeMax(); + return (min == null || months >= min) && (max == null || months <= max); + } + + /** 정책 대상 지역이 사용자 주소와 겹치는지. 전국 정책은 항상 일치로 본다. */ + private boolean matchesRegion(Policy policy, User user, List reasons) { + String region = policy.getTargetRegion(); + if (region == null || region.isBlank() || region.contains("전국")) { + reasons.add("전국 어디서나 신청할 수 있습니다."); + return true; + } + String address = user.getAddress(); + if (address == null || address.isBlank()) { + return false; + } + // 주소는 "서울특별시 강남구...", 대상 지역은 "서울" 처럼 표기 단위가 달라 양방향으로 확인한다. + if (address.contains(region) || region.contains(address)) { + reasons.add(region + " 거주자 대상 정책입니다."); + return true; + } + return false; + } + + private boolean isExpired(Policy policy, LocalDate today) { + LocalDate end = policy.getApplicationEndDate(); + return end != null && end.isBefore(today); + } + + private boolean isDeadlineNear(Policy policy, LocalDate today) { + LocalDate end = policy.getApplicationEndDate(); + return end != null && !end.isBefore(today) && ChronoUnit.DAYS.between(today, end) <= DEADLINE_SOON_DAYS; + } +} From 00cef24784ddd27401fad53b10850ca6ca633cc3 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:40:02 +0900 Subject: [PATCH 18/23] =?UTF-8?q?FEAT=20:=20=EC=9A=94=EC=B2=AD=EB=B3=84=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=88=98=20=EC=B8=A1=EC=A0=95=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20N+1=20=EA=B4=80=EC=B8=A1=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=A7=88=EB=A0=A8=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/config/QueryCountConfig.java | 42 +++++++++++++++++ .../core/monitoring/QueryCountFilter.java | 46 +++++++++++++++++++ .../core/monitoring/QueryCountHolder.java | 31 +++++++++++++ .../core/monitoring/QueryCountInspector.java | 13 ++++++ src/main/resources/application-dev.yml | 11 +++++ 5 files changed, 143 insertions(+) create mode 100644 src/main/java/com/carecode/core/config/QueryCountConfig.java create mode 100644 src/main/java/com/carecode/core/monitoring/QueryCountFilter.java create mode 100644 src/main/java/com/carecode/core/monitoring/QueryCountHolder.java create mode 100644 src/main/java/com/carecode/core/monitoring/QueryCountInspector.java diff --git a/src/main/java/com/carecode/core/config/QueryCountConfig.java b/src/main/java/com/carecode/core/config/QueryCountConfig.java new file mode 100644 index 00000000..2049b476 --- /dev/null +++ b/src/main/java/com/carecode/core/config/QueryCountConfig.java @@ -0,0 +1,42 @@ +package com.carecode.core.config; + +import com.carecode.core.monitoring.QueryCountFilter; +import com.carecode.core.monitoring.QueryCountInspector; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import java.util.Map; + +/** 쿼리 수 측정. 기본은 꺼져 있고 app.monitoring.query-count.enabled=true 일 때만 동작한다. */ +@Slf4j +@Configuration +@ConditionalOnProperty(name = "app.monitoring.query-count.enabled", havingValue = "true") +public class QueryCountConfig { + + @Value("${app.monitoring.query-count.threshold:20}") + private int threshold; + + /** StatementInspector 는 SessionFactory 생성 시점에 주입해야 하므로 프로퍼티로 넣는다. */ + @Bean + public HibernatePropertiesCustomizer queryCountInspectorCustomizer() { + log.info("쿼리 수 측정 활성화 - 임계치 {}건", threshold); + return (Map props) -> + props.put("hibernate.session_factory.statement_inspector", new QueryCountInspector()); + } + + /** 가장 바깥에서 감싸야 요청 전체의 쿼리를 센다. */ + @Bean + public FilterRegistrationBean queryCountFilterRegistration() { + FilterRegistrationBean registration = + new FilterRegistrationBean<>(new QueryCountFilter(threshold)); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); + registration.addUrlPatterns("/*"); + return registration; + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java b/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java new file mode 100644 index 00000000..dca1734c --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountFilter.java @@ -0,0 +1,46 @@ +package com.carecode.core.monitoring; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** 요청별 쿼리 수를 집계해 임계치를 넘으면 경고한다. 어느 엔드포인트가 N+1 인지 로그로 드러난다. */ +@Slf4j +@RequiredArgsConstructor +public class QueryCountFilter extends OncePerRequestFilter { + + private final int threshold; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + QueryCountHolder.start(); + long startedAt = System.nanoTime(); + try { + chain.doFilter(request, response); + } finally { + int queryCount = QueryCountHolder.get(); + QueryCountHolder.clear(); + + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000; + String endpoint = request.getMethod() + " " + request.getRequestURI(); + if (queryCount >= threshold) { + log.warn("N+1 의심 - {} 쿼리 {}건, {}ms (임계치 {}건)", endpoint, queryCount, elapsedMs, threshold); + } else if (queryCount > 0) { + log.debug("쿼리 측정 - {} 쿼리 {}건, {}ms", endpoint, queryCount, elapsedMs); + } + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith("/actuator") || path.startsWith("/swagger-ui") || path.startsWith("/v3/api-docs"); + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java b/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java new file mode 100644 index 00000000..b7866ded --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountHolder.java @@ -0,0 +1,31 @@ +package com.carecode.core.monitoring; + +/** 요청 하나가 실행한 SQL 수를 센다. N+1 을 추정이 아니라 숫자로 확인하기 위한 도구다. */ +public final class QueryCountHolder { + + private static final ThreadLocal COUNTER = new ThreadLocal<>(); + + private QueryCountHolder() { + } + + public static void start() { + COUNTER.set(new int[1]); + } + + public static void increment() { + int[] counter = COUNTER.get(); + if (counter != null) { + counter[0]++; + } + } + + public static int get() { + int[] counter = COUNTER.get(); + return counter == null ? 0 : counter[0]; + } + + /** 스레드 풀 재사용 시 카운트가 누적되지 않도록 요청 종료 시 반드시 호출한다. */ + public static void clear() { + COUNTER.remove(); + } +} diff --git a/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java b/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java new file mode 100644 index 00000000..39e09634 --- /dev/null +++ b/src/main/java/com/carecode/core/monitoring/QueryCountInspector.java @@ -0,0 +1,13 @@ +package com.carecode.core.monitoring; + +import org.hibernate.resource.jdbc.spi.StatementInspector; + +/** Hibernate 가 SQL 을 실행할 때마다 호출된다. SQL 자체는 바꾸지 않고 개수만 센다. */ +public class QueryCountInspector implements StatementInspector { + + @Override + public String inspect(String sql) { + QueryCountHolder.increment(); + return sql; + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 37c72398..8cdb176d 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -7,6 +7,15 @@ spring: hibernate: ddl-auto: update show-sql: true + properties: + hibernate: + generate_statistics: true + +app: + monitoring: + query-count: + enabled: true + threshold: 20 springdoc: swagger-ui: @@ -29,5 +38,7 @@ logging: org.springframework.security: DEBUG org.hibernate.SQL: DEBUG org.hibernate.orm.jdbc.bind: TRACE + org.hibernate.stat: DEBUG + com.carecode.core.monitoring: DEBUG pattern: console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" From cb7f2348c6d28125d8e10b265e0c3ef2ff278b5a Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 11:51:58 +0900 Subject: [PATCH 19/23] =?UTF-8?q?FEAT=20:=20=EA=B3=B5=EA=B3=B5=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A7=80=EC=9B=90=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=97=B0=EB=A0=B9=20=EA=B0=9C=EC=9B=94=20=ED=99=98?= =?UTF-8?q?=EC=82=B0=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/client/sync/PolicyUpsertService.java | 21 ++++ .../carecode/core/util/AgeRangeParser.java | 114 ++++++++++++++++++ .../policy/controller/PolicyController.java | 11 +- .../carecode/domain/policy/entity/Policy.java | 4 +- .../core/util/AgeRangeParserTest.java | 92 ++++++++++++++ 5 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/carecode/core/util/AgeRangeParser.java create mode 100644 src/test/java/com/carecode/core/util/AgeRangeParserTest.java diff --git a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java index a1819bd6..2c139869 100644 --- a/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java +++ b/src/main/java/com/carecode/core/client/sync/PolicyUpsertService.java @@ -1,5 +1,6 @@ package com.carecode.core.client.sync; +import com.carecode.core.util.AgeRangeParser; import com.carecode.domain.policy.entity.Policy; import com.carecode.domain.policy.repository.PolicyRepository; import com.fasterxml.jackson.databind.JsonNode; @@ -49,12 +50,32 @@ public boolean upsert(JsonNode row) { policy.setApplicationUrl(text(row, "상세조회URL", "servDtlLink", "DETAIL_URL")); policy.setContactInfo(text(row, "전화문의", "rprsCtadr", "CONTACT")); policy.setRequiredDocuments(text(row, "구비서류", "docCn")); + applyAgeRange(policy, row); policy.setUpdatedAt(LocalDateTime.now()); policyRepository.save(policy); return isNew; } + /** 자유 텍스트에서 연령 조건을 개월로 환산해 채운다. 못 찾으면 기존 값을 건드리지 않는다. */ + private void applyAgeRange(Policy policy, JsonNode row) { + String source = String.join(" ", + nullToEmpty(text(row, "지원대상", "trgterIndvdlArray")), + nullToEmpty(text(row, "선정기준", "slctCritCn")), + nullToEmpty(text(row, "서비스목적요약", "servDgst", "SVC_DGST"))); + + AgeRangeParser.AgeRange range = AgeRangeParser.parse(source); + if (range == null) { + return; + } + policy.setTargetAgeMin(range.minMonths()); + policy.setTargetAgeMax(range.maxMonths()); + } + + private String nullToEmpty(String value) { + return value != null ? value : ""; + } + private String text(JsonNode row, String... keys) { for (String key : keys) { JsonNode node = row.get(key); diff --git a/src/main/java/com/carecode/core/util/AgeRangeParser.java b/src/main/java/com/carecode/core/util/AgeRangeParser.java new file mode 100644 index 00000000..7c7cbc2d --- /dev/null +++ b/src/main/java/com/carecode/core/util/AgeRangeParser.java @@ -0,0 +1,114 @@ +package com.carecode.core.util; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 공공데이터의 자유 텍스트 지원대상에서 연령 조건을 개월 단위로 뽑아낸다. */ +public final class AgeRangeParser { + + /** 정책 연령 필드의 단위는 개월이다. 세 단위 표기는 모두 여기서 개월로 환산한다. */ + private static final int MONTHS_PER_YEAR = 12; + + /** 육아 정책의 상한. 이보다 큰 숫자는 연령이 아니라 금액·연도일 가능성이 높다. */ + private static final int MAX_PLAUSIBLE_YEAR = 19; + private static final int MAX_PLAUSIBLE_MONTH = MAX_PLAUSIBLE_YEAR * MONTHS_PER_YEAR; + + // "생후 12~23개월", "0~23 개월" + private static final Pattern MONTH_RANGE = Pattern.compile("(\\d{1,3})\\s*[~\\-–]\\s*(\\d{1,3})\\s*개월"); + // "24개월 미만", "36개월 이하", "12개월 이상" + private static final Pattern MONTH_BOUND = Pattern.compile("(\\d{1,3})\\s*개월\\s*(미만|이하|이상)"); + // "만 3~5세", "만 3세 ~ 만 5세", "3세~5세" + private static final Pattern YEAR_RANGE = + Pattern.compile("(?:만\\s*)?(\\d{1,2})\\s*세?\\s*[~\\-–]\\s*(?:만\\s*)?(\\d{1,2})\\s*세"); + // "만 7세 미만", "만 8세 이하", "만 6세 이상" + private static final Pattern YEAR_BOUND = Pattern.compile("(?:만\\s*)?(\\d{1,2})\\s*세\\s*(미만|이하|이상)"); + + private AgeRangeParser() { + } + + /** 연령 조건. 경계가 없으면 해당 값이 null 이다. */ + public record AgeRange(Integer minMonths, Integer maxMonths) { + } + + /** 연령 조건을 찾지 못하면 null 을 반환한다. 억지로 추정하지 않는다. */ + public static AgeRange parse(String text) { + if (text == null || text.isBlank()) { + return null; + } + + // 개월 표기가 더 정확하므로 먼저 본다. + AgeRange range = matchMonthRange(text); + if (range != null) { + return range; + } + range = matchYearRange(text); + if (range != null) { + return range; + } + range = matchMonthBound(text); + if (range != null) { + return range; + } + return matchYearBound(text); + } + + private static AgeRange matchMonthRange(String text) { + Matcher m = MONTH_RANGE.matcher(text); + if (!m.find()) { + return null; + } + int min = Integer.parseInt(m.group(1)); + int max = Integer.parseInt(m.group(2)); + return isValid(min, max, MAX_PLAUSIBLE_MONTH) ? new AgeRange(min, max) : null; + } + + private static AgeRange matchYearRange(String text) { + Matcher m = YEAR_RANGE.matcher(text); + if (!m.find()) { + return null; + } + int minYear = Integer.parseInt(m.group(1)); + int maxYear = Integer.parseInt(m.group(2)); + if (!isValid(minYear, maxYear, MAX_PLAUSIBLE_YEAR)) { + return null; + } + // "만 3~5세" 는 5세 생일부터 6세 생일 전날까지 포함하므로 상한은 71개월이다. + return new AgeRange(minYear * MONTHS_PER_YEAR, maxYear * MONTHS_PER_YEAR + MONTHS_PER_YEAR - 1); + } + + private static AgeRange matchMonthBound(String text) { + Matcher m = MONTH_BOUND.matcher(text); + if (!m.find()) { + return null; + } + int months = Integer.parseInt(m.group(1)); + if (months > MAX_PLAUSIBLE_MONTH) { + return null; + } + return switch (m.group(2)) { + case "미만" -> new AgeRange(0, months - 1); + case "이하" -> new AgeRange(0, months); + default -> new AgeRange(months, null); + }; + } + + private static AgeRange matchYearBound(String text) { + Matcher m = YEAR_BOUND.matcher(text); + if (!m.find()) { + return null; + } + int years = Integer.parseInt(m.group(1)); + if (years > MAX_PLAUSIBLE_YEAR) { + return null; + } + return switch (m.group(2)) { + case "미만" -> new AgeRange(0, years * MONTHS_PER_YEAR - 1); + case "이하" -> new AgeRange(0, years * MONTHS_PER_YEAR + MONTHS_PER_YEAR - 1); + default -> new AgeRange(years * MONTHS_PER_YEAR, null); + }; + } + + private static boolean isValid(int min, int max, int upperBound) { + return min <= max && max <= upperBound; + } +} diff --git a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java index 94ca6dfa..c29ec226 100644 --- a/src/main/java/com/carecode/domain/policy/controller/PolicyController.java +++ b/src/main/java/com/carecode/domain/policy/controller/PolicyController.java @@ -135,10 +135,10 @@ public ResponseEntity> getPoliciesByLocation( // 연령대별 정책 조회 @GetMapping("/age") @LogExecutionTime - @Operation(summary = "연령대별 정책 조회", description = "특정 연령대에 해당하는 육아 정책 목록을 조회합니다.") + @Operation(summary = "연령대별 정책 조회", description = "월령 범위에 해당하는 정책을 조회합니다.") public ResponseEntity> getPoliciesByAgeRange( - @Parameter(description = "최소 연령", required = true) @RequestParam Integer minAge, - @Parameter(description = "최대 연령", required = true) @RequestParam Integer maxAge) { + @Parameter(description = "최소 월령", example = "0", required = true) @RequestParam Integer minAge, + @Parameter(description = "최대 월령", example = "71", required = true) @RequestParam Integer maxAge) { log.info("연령대별 정책 조회: 최소연령={}, 최대연령={}", minAge, maxAge); try { @@ -221,8 +221,9 @@ public ResponseEntity getPolicyStatistics() { // 아이 연령별 정책 조회 @GetMapping("/child-age") @LogExecutionTime - @Operation(summary = "아이 연령별 정책 조회", description = "특정 연령의 아이에게 해당하는 정책을 조회합니다.") - public ResponseEntity> getPoliciesByChildAge(@Parameter(description = "아이 연령", required = true) @RequestParam Integer childAge) { + @Operation(summary = "아이 연령별 정책 조회", description = "해당 월령의 아이가 받을 수 있는 정책을 조회합니다.") + public ResponseEntity> getPoliciesByChildAge( + @Parameter(description = "아이 월령", example = "24", required = true) @RequestParam Integer childAge) { List policies = policyFacade.getPoliciesByChildAge(childAge); return ResponseEntity.ok(policies); diff --git a/src/main/java/com/carecode/domain/policy/entity/Policy.java b/src/main/java/com/carecode/domain/policy/entity/Policy.java index c6472de3..751d4ceb 100644 --- a/src/main/java/com/carecode/domain/policy/entity/Policy.java +++ b/src/main/java/com/carecode/domain/policy/entity/Policy.java @@ -41,9 +41,11 @@ public class Policy { @Column(name = "policy_type") private String policyType; + /** 대상 연령 하한(개월). 세 단위 표기는 AgeRangeParser 로 환산해 넣는다. */ @Column(name = "target_age_min") private Integer targetAgeMin; - + + /** 대상 연령 상한(개월). null 이면 상한 없음. */ @Column(name = "target_age_max") private Integer targetAgeMax; diff --git a/src/test/java/com/carecode/core/util/AgeRangeParserTest.java b/src/test/java/com/carecode/core/util/AgeRangeParserTest.java new file mode 100644 index 00000000..eec064d6 --- /dev/null +++ b/src/test/java/com/carecode/core/util/AgeRangeParserTest.java @@ -0,0 +1,92 @@ +package com.carecode.core.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("지원대상 연령 파싱") +class AgeRangeParserTest { + + @Test + @DisplayName("개월 범위를 그대로 읽는다") + void parsesMonthRange() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("생후 12~23개월 아동"); + + assertThat(range.minMonths()).isEqualTo(12); + assertThat(range.maxMonths()).isEqualTo(23); + } + + @Test + @DisplayName("세 범위는 개월로 환산하고 상한은 해당 연도의 마지막 달로 잡는다") + void convertsYearRangeToMonths() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 3~5세 유아"); + + assertThat(range.minMonths()).isEqualTo(36); + assertThat(range.maxMonths()).isEqualTo(71); + } + + @Test + @DisplayName("만 N세 사이에 만이 반복돼도 인식한다") + void parsesRepeatedManPrefix() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 0세 ~ 만 2세"); + + assertThat(range.minMonths()).isZero(); + assertThat(range.maxMonths()).isEqualTo(35); + } + + @Test + @DisplayName("미만은 상한을 한 단위 낮춘다") + void parsesExclusiveUpperBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 7세 미만 아동"); + + assertThat(range.minMonths()).isZero(); + assertThat(range.maxMonths()).isEqualTo(83); + } + + @Test + @DisplayName("이하는 해당 연도 끝까지 포함한다") + void parsesInclusiveUpperBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 8세 이하"); + + assertThat(range.maxMonths()).isEqualTo(107); + } + + @Test + @DisplayName("이상은 상한을 두지 않는다") + void parsesLowerBoundOnly() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("만 6세 이상 아동"); + + assertThat(range.minMonths()).isEqualTo(72); + assertThat(range.maxMonths()).isNull(); + } + + @Test + @DisplayName("개월 미만 표기도 처리한다") + void parsesMonthBound() { + AgeRangeParser.AgeRange range = AgeRangeParser.parse("24개월 미만 영아"); + + assertThat(range.maxMonths()).isEqualTo(23); + } + + @Test + @DisplayName("연령 조건이 없으면 추정하지 않는다") + void returnsNullWhenNoAgeCondition() { + assertThat(AgeRangeParser.parse("임신부 및 산모")).isNull(); + assertThat(AgeRangeParser.parse("")).isNull(); + assertThat(AgeRangeParser.parse(null)).isNull(); + } + + @Test + @DisplayName("연령으로 보기 어려운 큰 숫자는 무시한다") + void ignoresImplausibleNumbers() { + assertThat(AgeRangeParser.parse("2024~2025세")).isNull(); + assertThat(AgeRangeParser.parse("500개월 미만")).isNull(); + } + + @Test + @DisplayName("역순 범위는 잘못된 표기로 보고 버린다") + void rejectsInvertedRange() { + assertThat(AgeRangeParser.parse("5~3세")).isNull(); + } +} From 97819dedd58d48486d6577ed7b42c9a32c800049 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 12:33:20 +0900 Subject: [PATCH 20/23] =?UTF-8?q?REFACTOR=20:=20=EA=B3=B5=EA=B3=B5?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=20=EA=B3=B5=ED=86=B5=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=B6=88=EC=9D=BC=EC=B9=98=20=EC=A1=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=A4=91=EB=8B=A8=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sync/GovernmentBenefitSyncService.java | 112 ++------ ...ationwideChildcareFacilitySyncService.java | 88 +------ .../core/client/sync/PagedSyncTemplate.java | 160 ++++++++++++ .../sync/PediatricHospitalSyncService.java | 111 +------- .../carecode/core/client/sync/SyncResult.java | 11 +- .../carecode/core/client/sync/SyncSpec.java | 36 +++ .../client/sync/PagedSyncTemplateTest.java | 246 ++++++++++++++++++ 7 files changed, 492 insertions(+), 272 deletions(-) create mode 100644 src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java create mode 100644 src/main/java/com/carecode/core/client/sync/SyncSpec.java create mode 100644 src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java diff --git a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java index 52866d16..f68e6ab6 100644 --- a/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java +++ b/src/main/java/com/carecode/core/client/sync/GovernmentBenefitSyncService.java @@ -2,15 +2,12 @@ import com.carecode.core.client.provider.DataGoKrProvider; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; /** 정부 지원 서비스(보조금24) 동기화. */ @Slf4j @@ -18,8 +15,6 @@ @RequiredArgsConstructor public class GovernmentBenefitSyncService { - private static final String RESOURCE = "1741000/publicServiceInformations/publicServiceInformation"; - private static final int ROWS_PER_PAGE = 100; /** 육아 관련 서비스만 적재하기 위한 키워드. 하나라도 포함되면 대상으로 본다. */ @@ -29,114 +24,41 @@ public class GovernmentBenefitSyncService { private final DataGoKrProvider provider; private final PolicyUpsertService upsertService; - private final ObjectMapper objectMapper; + private final PagedSyncTemplate syncTemplate; - @Value("${public.data.sync.max-pages:200}") - private int maxPages; + @Value("${public.data.resource.benefit:1741000/publicServiceInformations/publicServiceInformation}") + private String resource; public SyncResult sync() { - SyncResult result = new SyncResult(provider.getProviderName(), "정부지원서비스"); - - if (!provider.isAvailable()) { - result.stop("공공데이터포털 서비스 키 미설정"); - log.info("정부 지원 서비스 동기화 건너뜀 - 서비스 키가 없습니다."); - return result; - } - - for (int page = 1; page <= maxPages; page++) { - JsonNode rows; - try { - String body = provider.fetch(RESOURCE, page, ROWS_PER_PAGE, buildParams()); - rows = extractRows(body); - } catch (Exception e) { - log.error("정부 지원 서비스 조회 실패 - page={}", page, e); - result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); - return result; - } - - if (rows == null || !rows.isArray() || rows.isEmpty()) { - return result; - } - - for (JsonNode row : rows) { - if (!isCareRelated(row)) { - continue; // 육아와 무관한 서비스는 건너뛴다 - } - try { - if (upsertService.upsert(row)) { - result.countCreated(); - } else { - result.countUpdated(); - } - } catch (Exception e) { - result.countFailed(); - log.warn("정책 저장 실패: {}", e.getMessage()); - } - } - result.countPage(); - - if (rows.size() < ROWS_PER_PAGE) { - return result; - } - } - - result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); - log.warn("정부 지원 서비스 동기화가 페이지 상한에 걸렸습니다."); - return result; - } - - private Map buildParams() { - // 데이터셋이 연령·가구 조건 필터를 명세에 노출하지 않아, 전체를 받아 키워드로 거른다. - // 서버 측 필터가 확인되면 이 자리에서 파라미터로 좁히는 편이 트래픽에 유리하다. - return new LinkedHashMap<>(); + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("정부지원서비스") + .rowsPerPage(ROWS_PER_PAGE) + .filter(this::isCareRelated) + .upsert(upsertService::upsert) + .build()); } /** 서비스명·요약·분야 중 하나라도 육아 키워드를 포함하면 적재 대상. */ private boolean isCareRelated(JsonNode row) { String haystack = String.join(" ", - nullToEmpty(text(row, "서비스명", "servNm", "SVC_NM")), - nullToEmpty(text(row, "서비스목적요약", "servDgst", "SVC_DGST")), - nullToEmpty(text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM")), - nullToEmpty(text(row, "지원대상", "trgterIndvdlArray"))) + text(row, "서비스명", "servNm", "SVC_NM"), + text(row, "서비스목적요약", "servDgst", "SVC_DGST"), + text(row, "서비스분야", "srvPvsnNm", "INTRS_THEMA_NM"), + text(row, "지원대상", "trgterIndvdlArray")) .toLowerCase(); return CARE_KEYWORDS.stream().anyMatch(k -> haystack.contains(k.toLowerCase())); } - private JsonNode extractRows(String body) throws Exception { - if (body == null || body.isBlank()) { - return null; - } - JsonNode root = objectMapper.readTree(body); - - JsonNode data = root.path("data"); - if (data.isArray()) { - return data; - } - JsonNode items = root.path("response").path("body").path("items"); - if (items.isArray()) { - return items; - } - if (items.path("item").isArray()) { - return items.path("item"); - } - return root.isArray() ? root : null; - } - private String text(JsonNode row, String... keys) { for (String key : keys) { JsonNode node = row.get(key); if (node != null && !node.isNull()) { - String value = node.asText().trim(); - if (!value.isEmpty()) { - return value; - } + return node.asText().trim(); } } - return null; - } - - private String nullToEmpty(String value) { - return value != null ? value : ""; + return ""; } } diff --git a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java index e4dc0b59..cf49d9d8 100644 --- a/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java +++ b/src/main/java/com/carecode/core/client/sync/NationwideChildcareFacilitySyncService.java @@ -1,100 +1,34 @@ package com.carecode.core.client.sync; import com.carecode.core.client.provider.DataGoKrProvider; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import java.util.Map; - /** 전국 어린이집 정보 동기화. */ @Slf4j @Service @RequiredArgsConstructor public class NationwideChildcareFacilitySyncService { - /** 공공데이터포털 오퍼레이션 경로. 데이터셋 문서의 상세 기능 경로와 맞춘다. */ - private static final String RESOURCE = "B551014/CCEF/childcare"; - private static final int ROWS_PER_PAGE = 500; private final DataGoKrProvider provider; private final CareFacilityUpsertService upsertService; - private final ObjectMapper objectMapper; + private final PagedSyncTemplate syncTemplate; - /** 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다(조용한 중단 금지). */ - @Value("${public.data.sync.max-pages:200}") - private int maxPages; + /** 데이터셋 경로는 개편될 수 있어 재배포 없이 바꿀 수 있게 프로퍼티로 둔다. */ + @Value("${public.data.resource.childcare:B551014/CCEF/childcare}") + private String resource; public SyncResult sync() { - SyncResult result = new SyncResult(provider.getProviderName(), "전국어린이집"); - - if (!provider.isAvailable()) { - result.stop("공공데이터포털 서비스 키 미설정"); - log.info("전국 어린이집 동기화 건너뜀 - 서비스 키가 없습니다."); - return result; - } - - for (int page = 1; page <= maxPages; page++) { - JsonNode rows; - try { - String body = provider.fetch(RESOURCE, page, ROWS_PER_PAGE, Map.of()); - rows = extractRows(body); - } catch (Exception e) { - log.error("전국 어린이집 조회 실패 - page={}", page, e); - result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); - return result; - } - - if (rows == null || !rows.isArray() || rows.isEmpty()) { - return result; // 더 이상 데이터 없음 — 정상 종료 - } - - for (JsonNode row : rows) { - try { - if (upsertService.upsert(row)) { - result.countCreated(); - } else { - result.countUpdated(); - } - } catch (Exception e) { - // 한 건 실패가 배치 전체를 중단시키지 않는다. - result.countFailed(); - log.warn("시설 저장 실패: {}", e.getMessage()); - } - } - result.countPage(); - - if (rows.size() < ROWS_PER_PAGE) { - return result; // 마지막 페이지 - } - } - - result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); - log.warn("전국 어린이집 동기화가 페이지 상한에 걸렸습니다. public.data.sync.max-pages 설정을 확인하세요."); - return result; - } - - /** 공공데이터포털 응답에서 데이터 배열을 꺼낸다. */ - private JsonNode extractRows(String body) throws Exception { - if (body == null || body.isBlank()) { - return null; - } - JsonNode root = objectMapper.readTree(body); - - JsonNode items = root.path("response").path("body").path("items"); - if (items.isArray()) { - return items; - } - if (items.path("item").isArray()) { - return items.path("item"); - } - if (root.path("items").isArray()) { - return root.path("items"); - } - return root.isArray() ? root : null; + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("전국어린이집") + .rowsPerPage(ROWS_PER_PAGE) + .upsert(upsertService::upsert) + .build()); } } diff --git a/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java b/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java new file mode 100644 index 00000000..5df96fad --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/PagedSyncTemplate.java @@ -0,0 +1,160 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.XmlResponseParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** 공공데이터 페이징 수집 공통 절차. 데이터셋별 차이는 SyncSpec 으로 받는다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PagedSyncTemplate { + + private final ObjectMapper objectMapper; + private final XmlResponseParser xmlResponseParser; + + /** 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다 — 조용한 중단은 하지 않는다. */ + @Value("${public.data.sync.max-pages:200}") + private int maxPages; + + public SyncResult run(SyncSpec spec) { + SyncResult result = new SyncResult(spec.getProvider().getProviderName(), spec.getLabel()); + + if (!spec.getProvider().isAvailable()) { + result.stop("서비스 키 미설정"); + log.info("{} 동기화 건너뜀 - 서비스 키가 없습니다.", spec.getLabel()); + return result; + } + + for (int page = 1; page <= maxPages; page++) { + JsonNode rows; + try { + String body = spec.getProvider() + .fetch(spec.getResource(), page, spec.getRowsPerPage(), spec.getParams()); + rows = extractRows(body); + } catch (Exception e) { + log.error("{} 조회 실패 - page={}", spec.getLabel(), page, e); + result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); + return result; + } + + if (rows == null || !rows.isArray() || rows.isEmpty()) { + return result; // 더 이상 데이터 없음 — 정상 종료 + } + if (page == 1) { + logFieldNames(spec.getLabel(), rows.get(0)); + } + + int attempted = 0; + int failedInPage = 0; + for (JsonNode row : rows) { + if (!spec.getFilter().test(row)) { + result.countSkipped(); + continue; + } + attempted++; + try { + if (Boolean.TRUE.equals(spec.getUpsert().apply(row))) { + result.countCreated(); + } else { + result.countUpdated(); + } + } catch (Exception e) { + // 한 건 실패가 배치 전체를 중단시키지 않는다. + result.countFailed(); + failedInPage++; + log.warn("{} 저장 실패: {}", spec.getLabel(), e.getMessage()); + } + } + result.countPage(); + + // 첫 페이지가 전멸이면 응답 스펙이 바뀐 것이다. 200페이지를 헛돌지 않고 멈춘다. + if (page == 1 && attempted > 0 && failedInPage == attempted) { + result.stop("첫 페이지 전건 실패 - 응답 필드 매핑 불일치 의심"); + log.error("{} 동기화 중단 - 첫 페이지 {}건이 모두 실패했습니다. 응답 필드명을 확인하세요.", + spec.getLabel(), attempted); + return result; + } + + if (rows.size() < spec.getRowsPerPage()) { + return result; // 마지막 페이지 + } + } + + result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); + log.warn("{} 동기화가 페이지 상한에 걸렸습니다. public.data.sync.max-pages 설정을 확인하세요.", spec.getLabel()); + return result; + } + + /** 매핑이 어긋났을 때 원인을 바로 찾을 수 있도록 실제 응답 필드명을 남긴다. */ + private void logFieldNames(String label, JsonNode firstRow) { + if (firstRow == null || !log.isDebugEnabled()) { + return; + } + List names = new ArrayList<>(); + firstRow.fieldNames().forEachRemaining(names::add); + log.debug("{} 응답 필드: {}", label, names); + } + + /** 공공데이터 응답은 래핑 구조가 제각각이라 알려진 위치를 순서대로 확인한다. */ + private JsonNode extractRows(String body) { + JsonNode root = parseBody(body); + if (root == null) { + return null; + } + if (root.isArray()) { + return root; + } + + // XML 로 받으면 최상위 response 가 벗겨져 body 부터 시작한다. + for (JsonNode items : List.of( + root.path("response").path("body").path("items"), + root.path("body").path("items"), + root.path("data"), + root.path("items"))) { + JsonNode rows = toArray(items); + if (rows != null) { + return rows; + } + } + return null; + } + + /** items 가 배열이거나, item 을 한 겹 더 감싸거나, 단건이면 객체로 온다. */ + private JsonNode toArray(JsonNode items) { + if (items.isArray()) { + return items; + } + JsonNode item = items.path("item"); + if (item.isArray()) { + return item; + } + if (item.isObject()) { + return objectMapper.createArrayNode().add(item); + } + return null; + } + + /** JSON 을 먼저 시도하고, 아니면 XML 로 파싱한다. 데이터셋마다 기본 포맷이 다르다. */ + private JsonNode parseBody(String body) { + if (body == null || body.isBlank()) { + return null; + } + String trimmed = body.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return objectMapper.readTree(trimmed); + } catch (Exception e) { + log.debug("JSON 파싱 실패, XML 로 재시도합니다: {}", e.getMessage()); + } + } + return xmlResponseParser.parse(trimmed); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java index efe484a2..851d2278 100644 --- a/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java +++ b/src/main/java/com/carecode/core/client/sync/PediatricHospitalSyncService.java @@ -1,9 +1,6 @@ package com.carecode.core.client.sync; -import com.carecode.core.client.XmlResponseParser; import com.carecode.core.client.provider.DataGoKrProvider; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -18,14 +15,14 @@ @RequiredArgsConstructor public class PediatricHospitalSyncService { - private static final String RESOURCE = "B551182/hospInfoServicev2/getHospBasisList"; - private static final int ROWS_PER_PAGE = 100; private final DataGoKrProvider provider; private final HospitalUpsertService upsertService; - private final XmlResponseParser xmlResponseParser; - private final ObjectMapper objectMapper; + private final PagedSyncTemplate syncTemplate; + + @Value("${public.data.resource.hospital:B551182/hospInfoServicev2/getHospBasisList}") + private String resource; /** 진료과목 코드. */ @Value("${public.data.hospital.pediatric-subject-code:10}") @@ -34,104 +31,22 @@ public class PediatricHospitalSyncService { @Value("${public.data.hospital.subject-name:소아청소년과}") private String subjectName; - @Value("${public.data.sync.max-pages:200}") - private int maxPages; - public SyncResult sync() { - SyncResult result = new SyncResult(provider.getProviderName(), "소아청소년과병원"); - - if (!provider.isAvailable()) { - result.stop("공공데이터포털 서비스 키 미설정"); - log.info("병원 동기화 건너뜀 - 서비스 키가 없습니다."); - return result; - } - - for (int page = 1; page <= maxPages; page++) { - JsonNode rows; - try { - String body = provider.fetch(RESOURCE, page, ROWS_PER_PAGE, buildParams()); - rows = extractRows(body); - } catch (Exception e) { - log.error("병원 정보 조회 실패 - page={}", page, e); - result.stop("페이지 " + page + " 조회 실패: " + e.getMessage()); - return result; - } - - if (rows == null || rows.isEmpty()) { - return result; - } - - int rowCount = 0; - for (JsonNode row : rows) { - rowCount++; - try { - if (upsertService.upsert(row, subjectName)) { - result.countCreated(); - } else { - result.countUpdated(); - } - } catch (Exception e) { - result.countFailed(); - log.warn("병원 저장 실패: {}", e.getMessage()); - } - } - result.countPage(); - - if (rowCount < ROWS_PER_PAGE) { - return result; - } - } - - result.stop("최대 페이지(" + maxPages + ") 도달 - 남은 데이터가 있을 수 있습니다."); - log.warn("병원 동기화가 페이지 상한에 걸렸습니다."); - return result; + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("소아청소년과병원") + .rowsPerPage(ROWS_PER_PAGE) + .params(buildParams()) + .upsert(row -> upsertService.upsert(row, subjectName)) + .build()); } private Map buildParams() { Map params = new LinkedHashMap<>(); params.put("dgsbjtCd", pediatricSubjectCode); - // 이 데이터셋은 XML 이 기본이다. JSON 을 지원하면 _type 으로 받고, 아니면 XML 로 파싱한다. + // 이 데이터셋은 XML 이 기본이다. JSON 을 지원하면 _type 으로 받고, 아니면 XML 로 파싱된다. params.put("_type", "json"); return params; } - - /** 응답에서 항목 배열을 꺼낸다. */ - private JsonNode extractRows(String body) { - if (body == null || body.isBlank()) { - return null; - } - - JsonNode root = null; - String trimmed = body.trim(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) { - try { - root = objectMapper.readTree(body); - } catch (Exception ignored) { - // JSON 파싱 실패 시 XML 로 재시도 - } - } - if (root == null) { - root = xmlResponseParser.parse(body); - } - if (root == null) { - return null; - } - - JsonNode items = root.path("body").path("items"); - if (items.isMissingNode()) { - items = root.path("response").path("body").path("items"); - } - - JsonNode item = items.path("item"); - if (item.isArray()) { - return item; - } - if (item.isObject()) { - return objectMapper.createArrayNode().add(item); - } - if (items.isArray()) { - return items; - } - return null; - } } diff --git a/src/main/java/com/carecode/core/client/sync/SyncResult.java b/src/main/java/com/carecode/core/client/sync/SyncResult.java index 39d6e7ff..ec886390 100644 --- a/src/main/java/com/carecode/core/client/sync/SyncResult.java +++ b/src/main/java/com/carecode/core/client/sync/SyncResult.java @@ -11,6 +11,9 @@ public class SyncResult { private int created; private int updated; private int failed; + + /** 필터에 걸려 적재하지 않은 건수. 실패와 구분해야 필터가 과도한지 알 수 있다. */ + private int skipped; private int pagesProcessed; /** 정상 완료가 아니면 중단 사유. 정상이면 null. */ @@ -33,6 +36,10 @@ public void countFailed() { failed++; } + public void countSkipped() { + skipped++; + } + public void countPage() { pagesProcessed++; } @@ -51,8 +58,8 @@ public int getTotalProcessed() { @Override public String toString() { - return String.format("[%s/%s] 신규=%d, 갱신=%d, 실패=%d, 페이지=%d%s", - provider, resource, created, updated, failed, pagesProcessed, + return String.format("[%s/%s] 신규=%d, 갱신=%d, 실패=%d, 제외=%d, 페이지=%d%s", + provider, resource, created, updated, failed, skipped, pagesProcessed, stoppedReason != null ? ", 중단사유=" + stoppedReason : ""); } } diff --git a/src/main/java/com/carecode/core/client/sync/SyncSpec.java b/src/main/java/com/carecode/core/client/sync/SyncSpec.java new file mode 100644 index 00000000..339c4469 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/SyncSpec.java @@ -0,0 +1,36 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.PublicDataProvider; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; +import java.util.function.Function; +import java.util.function.Predicate; + +/** 페이징 동기화 한 건의 명세. 데이터셋마다 다른 부분만 여기에 담는다. */ +@Getter +@Builder +public class SyncSpec { + + private final PublicDataProvider provider; + + /** 공급자 기준 상대 경로 또는 절대 URL. */ + private final String resource; + + /** 로그·응답에 쓰는 사람이 읽는 이름. */ + private final String label; + + private final int rowsPerPage; + + @Builder.Default + private final Map params = Map.of(); + + /** 적재 대상이 아닌 행을 걸러낸다. 기본은 전부 적재. */ + @Builder.Default + private final Predicate filter = row -> true; + + /** 한 건 저장. 신규면 true, 갱신이면 false 를 반환한다. */ + private final Function upsert; +} diff --git a/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java b/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java new file mode 100644 index 00000000..f7c47e5e --- /dev/null +++ b/src/test/java/com/carecode/core/client/sync/PagedSyncTemplateTest.java @@ -0,0 +1,246 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.XmlResponseParser; +import com.carecode.core.client.provider.PublicDataProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("공공데이터 페이징 동기화") +class PagedSyncTemplateTest { + + private PagedSyncTemplate template; + + @BeforeEach + void setUp() { + template = new PagedSyncTemplate(new ObjectMapper(), new XmlResponseParser()); + ReflectionTestUtils.setField(template, "maxPages", 5); + } + + @Test + @DisplayName("서비스 키가 없으면 호출하지 않고 사유를 남긴다") + void skipsWhenProviderUnavailable() { + StubProvider provider = new StubProvider(false, List.of()); + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("서비스 키"); + assertThat(provider.callCount).isZero(); + } + + @Test + @DisplayName("요청 건수보다 적게 오면 마지막 페이지로 보고 더 호출하지 않는다") + void stopsOnShortPage() { + StubProvider provider = new StubProvider(true, List.of(page(1))); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(1); + assertThat(result.getPagesProcessed()).isEqualTo(1); + assertThat(provider.callCount).isEqualTo(1); + assertThat(result.isCompleted()).isTrue(); + } + + @Test + @DisplayName("빈 응답이 오면 정상 종료한다") + void stopsOnEmptyResponse() { + StubProvider provider = new StubProvider(true, List.of(page(2))); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.isCompleted()).isTrue(); + assertThat(provider.callCount).isEqualTo(2); // 두 번째 호출에서 데이터 없음을 확인 + } + + @Test + @DisplayName("첫 페이지가 전건 실패면 매핑 불일치로 보고 즉시 중단한다") + void stopsWhenFirstPageEntirelyFails() { + StubProvider provider = new StubProvider(true, List.of(page(3), page(3))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .upsert(row -> { + throw new IllegalArgumentException("필수 코드 없음"); + }) + .build()); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("매핑 불일치"); + assertThat(result.getFailed()).isEqualTo(3); + assertThat(provider.callCount).isEqualTo(1); // 나머지 페이지를 헛돌지 않는다 + } + + @Test + @DisplayName("일부만 실패하면 계속 진행한다") + void continuesOnPartialFailure() { + StubProvider provider = new StubProvider(true, List.of(page(3))); + List seen = new ArrayList<>(); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .upsert(row -> { + int id = row.get("id").asInt(); + seen.add(id); + if (id == 1) { + throw new IllegalStateException("한 건 실패"); + } + return true; + }) + .build()); + + assertThat(seen).hasSize(3); + assertThat(result.getFailed()).isEqualTo(1); + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.isCompleted()).isTrue(); + } + + @Test + @DisplayName("필터에 걸린 행은 실패가 아니라 제외로 센다") + void countsFilteredRowsAsSkipped() { + StubProvider provider = new StubProvider(true, List.of(page(4))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(4) + .filter(row -> row.get("id").asInt() % 2 == 0) + .upsert(row -> true) + .build()); + + assertThat(result.getSkipped()).isEqualTo(2); + assertThat(result.getCreated()).isEqualTo(2); + assertThat(result.getFailed()).isZero(); + } + + @Test + @DisplayName("전건 제외는 매핑 불일치로 보지 않는다") + void doesNotStopWhenAllRowsFiltered() { + StubProvider provider = new StubProvider(true, List.of(page(3))); + + SyncResult result = template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(3) + .filter(row -> false) + .upsert(row -> true) + .build()); + + assertThat(result.isCompleted()).isTrue(); + assertThat(result.getSkipped()).isEqualTo(3); + } + + @Test + @DisplayName("XML 로 응답해도 항목을 읽는다") + void parsesXmlResponse() { + String xml = """ + + 1 + 2 + + """; + StubProvider provider = new StubProvider(true, List.of(xml)); + + SyncResult result = run(provider, row -> true); + + assertThat(result.getCreated()).isEqualTo(2); + } + + @Test + @DisplayName("페이지 상한에 걸리면 조용히 끝내지 않고 사유를 남긴다") + void reportsPageLimit() { + List pages = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + pages.add(page(2)); + } + StubProvider provider = new StubProvider(true, pages); + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("최대 페이지"); + assertThat(result.getPagesProcessed()).isEqualTo(5); + } + + @Test + @DisplayName("조회 실패는 사유와 함께 중단한다") + void stopsOnFetchFailure() { + StubProvider provider = new StubProvider(true, List.of()) { + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + throw new IllegalStateException("연결 시간 초과"); + } + }; + + SyncResult result = run(provider, row -> true); + + assertThat(result.isCompleted()).isFalse(); + assertThat(result.getStoppedReason()).contains("연결 시간 초과"); + } + + private SyncResult run(PublicDataProvider provider, Function upsert) { + return template.run(SyncSpec.builder() + .provider(provider) + .resource("test") + .label("테스트") + .rowsPerPage(2) + .upsert(upsert) + .build()); + } + + /** id 만 담은 행 n 건짜리 페이지 JSON. */ + private String page(int rowCount) { + StringBuilder sb = new StringBuilder("{\"response\":{\"body\":{\"items\":["); + for (int i = 0; i < rowCount; i++) { + sb.append(i > 0 ? "," : "").append("{\"id\":").append(i).append("}"); + } + return sb.append("]}}}").toString(); + } + + /** 페이지를 순서대로 돌려주고, 다 떨어지면 빈 응답을 준다. */ + private static class StubProvider implements PublicDataProvider { + private final boolean available; + private final List pages; + private int callCount; + + StubProvider(boolean available, List pages) { + this.available = available; + this.pages = pages; + } + + @Override + public String getProviderName() { + return "STUB"; + } + + @Override + public boolean isAvailable() { + return available; + } + + @Override + public String fetch(String resource, int pageNo, int numOfRows, Map params) { + callCount++; + return pageNo <= pages.size() ? pages.get(pageNo - 1) : null; + } + } +} From 32736a34edc253bfd0bc1aebfe22f7012739d6dc Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 12:33:21 +0900 Subject: [PATCH 21/23] =?UTF-8?q?FEAT=20:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=85=8B=20=EA=B2=BD=EB=A1=9C=20=EC=99=B8=EB=B6=80=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=88=EB=8C=80=20URL=20=EB=A6=AC=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EC=A7=80=EC=9B=90=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/provider/DataGoKrProvider.java | 10 +++++++++- src/main/resources/application.yml | 10 ++++++++++ .../client/provider/DataGoKrProviderTest.java | 20 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java index a2d073fc..39ff9203 100644 --- a/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java +++ b/src/main/java/com/carecode/core/client/provider/DataGoKrProvider.java @@ -47,7 +47,7 @@ public String fetch(String resource, int pageNo, int numOfRows, Map { + URI uri = request.getURI(); + // 표준데이터는 별도 호스트에 있다 + assertThat(uri.getHost()).isEqualTo("api.data.go.kr"); + assertThat(uri.getPath()).isEqualTo("/openapi/tn_pubr_public_kindergarten_api"); + }) + .andRespond(withSuccess("{}", MediaType.APPLICATION_JSON)); + + provider.fetch("http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api", 1, 10, Map.of()); + + server.verify(); + } + @Test @DisplayName("빈 파라미터 값은 쿼리에서 제외한다") void skipsBlankParams() { From 55ddf3fa82eb7ce949c648dae033a07feb36e867 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 12:33:21 +0900 Subject: [PATCH 22/23] =?UTF-8?q?FEAT=20:=20=EC=A0=84=EA=B5=AD=20=EC=9C=A0?= =?UTF-8?q?=EC=B9=98=EC=9B=90=20=ED=91=9C=EC=A4=80=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/sync/KindergartenSyncService.java | 35 ++++ .../sync/KindergartenUpsertService.java | 155 ++++++++++++++++++ .../scheduler/PublicDataSyncScheduler.java | 9 + .../controller/AdminPublicDataController.java | 9 + .../sync/KindergartenUpsertServiceTest.java | 125 ++++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java create mode 100644 src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java create mode 100644 src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java b/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java new file mode 100644 index 00000000..502b8a92 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/KindergartenSyncService.java @@ -0,0 +1,35 @@ +package com.carecode.core.client.sync; + +import com.carecode.core.client.provider.DataGoKrProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** 전국 유치원 표준데이터 동기화. 어린이집만 있고 유치원이 비어 있던 공백을 메운다. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KindergartenSyncService { + + private static final int ROWS_PER_PAGE = 500; + + private final DataGoKrProvider provider; + private final KindergartenUpsertService upsertService; + private final PagedSyncTemplate syncTemplate; + + /** 표준데이터는 apis.data.go.kr 이 아닌 별도 호스트라 절대 URL 로 지정한다. */ + @Value("${public.data.resource.kindergarten:" + + "http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api}") + private String resource; + + public SyncResult sync() { + return syncTemplate.run(SyncSpec.builder() + .provider(provider) + .resource(resource) + .label("전국유치원") + .rowsPerPage(ROWS_PER_PAGE) + .upsert(upsertService::upsert) + .build()); + } +} diff --git a/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java new file mode 100644 index 00000000..3c966788 --- /dev/null +++ b/src/main/java/com/carecode/core/client/sync/KindergartenUpsertService.java @@ -0,0 +1,155 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.function.Consumer; + +/** 유치원 한 건을 저장하는 트랜잭션 경계. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KindergartenUpsertService { + + /** 어린이집 시설 코드와 충돌하지 않도록 구분한다. */ + public static final String CODE_PREFIX = "KG-"; + + private final CareFacilityRepository careFacilityRepository; + + /** 시설 코드 기준 upsert. 표준데이터에 고유 코드가 없으면 이름+주소로 만들어 쓴다. */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean upsert(JsonNode row) { + String name = text(row, "유치원명", "kindrgrtnNm", "KINDER_NM"); + String address = text(row, "소재지도로명주소", "rdnmadr", "소재지지번주소", "lnmadr", "ADDR"); + if (name == null) { + throw new IllegalArgumentException("유치원명이 없는 응답입니다."); + } + + String facilityCode = resolveCode(row, name, address); + CareFacility facility = careFacilityRepository.findByFacilityCode(facilityCode).orElse(null); + boolean isNew = facility == null; + if (isNew) { + facility = CareFacility.builder() + .facilityCode(facilityCode) + .facilityType(FacilityType.KINDERGARTEN) + .isActive(true) + .viewCount(0) + .build(); + } + + facility.setName(name); + applyIfPresent(address, facility::setAddress); + applyIfPresent(text(row, "전화번호", "telno", "TEL"), facility::setPhone); + applyIfPresent(text(row, "홈페이지주소", "homepageAddr", "HOMEPAGE"), facility::setWebsite); + applyIfPresent(text(row, "운영시간", "operPdCn"), facility::setOperatingHours); + applyIfPresent(text(row, "시도명", "ctprvnNm"), facility::setCity); + applyIfPresent(text(row, "시군구명", "signguNm"), facility::setDistrict); + + // 국공립 여부는 설립유형에서 판단한다. 사립은 비용 부담이 달라 사용자에게 중요한 구분이다. + String establishment = text(row, "설립유형", "estblshSe", "설립구분"); + if (establishment != null) { + facility.setIsPublic(establishment.contains("공립") || establishment.contains("국립")); + } + + Integer capacity = integer(row, "정원", "fixnum", "TOTAL_CAPACITY"); + if (capacity != null) { + facility.setCapacity(capacity); + } + Integer enrollment = integer(row, "현원", "nowNmpr", "CURRENT_CNT"); + if (enrollment != null) { + facility.setCurrentEnrollment(enrollment); + Integer effectiveCapacity = capacity != null ? capacity : facility.getCapacity(); + if (effectiveCapacity != null) { + facility.setAvailableSpots(Math.max(0, effectiveCapacity - enrollment)); + } + } + + Double lat = decimal(row, "위도", "latitude", "LAT"); + Double lng = decimal(row, "경도", "longitude", "LNG"); + if (lat != null && lng != null) { + facility.setLatitude(lat); + facility.setLongitude(lng); + } + + facility.setUpdatedAt(LocalDateTime.now()); + careFacilityRepository.save(facility); + return isNew; + } + + /** + * 표준데이터는 고유 식별자를 주지 않는 경우가 많다. + * 이름+주소 해시를 코드로 쓰면 재동기화 때 같은 유치원이 중복 생성되지 않는다. + */ + private String resolveCode(JsonNode row, String name, String address) { + String external = text(row, "유치원코드", "kindrgrtnCode", "KINDER_CD"); + if (external != null) { + return CODE_PREFIX + external; + } + String naturalKey = name + "|" + (address != null ? address : ""); + return CODE_PREFIX + hash(naturalKey); + } + + private String hash(String value) { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(digest, 0, 12); + } catch (java.security.NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 을 사용할 수 없습니다.", e); + } + } + + private void applyIfPresent(String value, Consumer setter) { + if (value != null) { + setter.accept(value); + } + } + + /** 표준데이터는 한글 필드명, 오픈API 는 영문 필드명을 쓰므로 후보를 순서대로 본다. */ + private String text(JsonNode row, String... keys) { + for (String key : keys) { + JsonNode node = row.get(key); + if (node != null && !node.isNull()) { + String value = node.asText().trim(); + if (!value.isEmpty()) { + return value; + } + } + } + return null; + } + + private Integer integer(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value.replaceAll("[^0-9-]", "")); + } catch (NumberFormatException e) { + return null; + } + } + + private Double decimal(JsonNode row, String... keys) { + String value = text(row, keys); + if (value == null) { + return null; + } + try { + return Double.valueOf(value); + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java index a28f9bc2..3c9be021 100644 --- a/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java +++ b/src/main/java/com/carecode/core/scheduler/PublicDataSyncScheduler.java @@ -1,6 +1,7 @@ package com.carecode.core.scheduler; import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.KindergartenSyncService; import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; @@ -16,6 +17,7 @@ public class PublicDataSyncScheduler { private final NationwideChildcareFacilitySyncService facilitySyncService; + private final KindergartenSyncService kindergartenSyncService; private final GovernmentBenefitSyncService benefitSyncService; private final PediatricHospitalSyncService hospitalSyncService; @@ -26,6 +28,13 @@ public void syncChildcareFacilities() { logResult("전국 어린이집", result); } + /** 전국 유치원 동기화. 어린이집 작업과 겹치지 않게 시간을 벌린다. */ + @Scheduled(cron = "${app.scheduler.public-data.kindergarten-cron:0 0 4 * * MON}", zone = "Asia/Seoul") + public void syncKindergartens() { + SyncResult result = kindergartenSyncService.sync(); + logResult("전국 유치원", result); + } + /** 정부 지원 서비스(보조금24) 동기화. */ @Scheduled(cron = "${app.scheduler.public-data.benefit-cron:0 30 3 * * *}", zone = "Asia/Seoul") public void syncGovernmentBenefits() { diff --git a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java index 134ddb05..f2ed8dd0 100644 --- a/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java +++ b/src/main/java/com/carecode/domain/admin/controller/AdminPublicDataController.java @@ -1,6 +1,7 @@ package com.carecode.domain.admin.controller; import com.carecode.core.client.sync.GovernmentBenefitSyncService; +import com.carecode.core.client.sync.KindergartenSyncService; import com.carecode.core.client.sync.NationwideChildcareFacilitySyncService; import com.carecode.core.client.sync.PediatricHospitalSyncService; import com.carecode.core.client.sync.SyncResult; @@ -23,6 +24,7 @@ public class AdminPublicDataController { private final NationwideChildcareFacilitySyncService facilitySyncService; + private final KindergartenSyncService kindergartenSyncService; private final GovernmentBenefitSyncService benefitSyncService; private final PediatricHospitalSyncService hospitalSyncService; @@ -32,6 +34,12 @@ public ResponseEntity> syncFacilities() { return ResponseEntity.ok(toResponse(facilitySyncService.sync())); } + @PostMapping("/kindergartens/sync") + @Operation(summary = "전국 유치원 동기화", description = "유치원명·주소 기준으로 갱신") + public ResponseEntity> syncKindergartens() { + return ResponseEntity.ok(toResponse(kindergartenSyncService.sync())); + } + @PostMapping("/benefits/sync") @Operation(summary = "정부 지원 서비스 동기화", description = "육아 관련 서비스만 정책으로 갱신") public ResponseEntity> syncBenefits() { @@ -52,6 +60,7 @@ private Map toResponse(SyncResult result) { body.put("created", result.getCreated()); body.put("updated", result.getUpdated()); body.put("failed", result.getFailed()); + body.put("skipped", result.getSkipped()); body.put("pagesProcessed", result.getPagesProcessed()); body.put("stoppedReason", result.getStoppedReason()); return body; diff --git a/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java new file mode 100644 index 00000000..7e3affd3 --- /dev/null +++ b/src/test/java/com/carecode/core/client/sync/KindergartenUpsertServiceTest.java @@ -0,0 +1,125 @@ +package com.carecode.core.client.sync; + +import com.carecode.domain.careFacility.entity.CareFacility; +import com.carecode.domain.careFacility.entity.FacilityType; +import com.carecode.domain.careFacility.repository.CareFacilityRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("유치원 적재") +class KindergartenUpsertServiceTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private CareFacilityRepository repository; + private KindergartenUpsertService service; + + @BeforeEach + void setUp() { + repository = mock(CareFacilityRepository.class); + when(repository.findByFacilityCode(anyString())).thenReturn(Optional.empty()); + service = new KindergartenUpsertService(repository); + } + + @Test + @DisplayName("표준데이터 한글 필드명을 읽는다") + void mapsStandardDataFields() { + boolean isNew = service.upsert(row(""" + {"유치원명":"행복유치원","소재지도로명주소":"서울특별시 강남구 테헤란로 1", + "전화번호":"02-123-4567","설립유형":"공립","정원":"100","현원":"80", + "위도":"37.5","경도":"127.0","시도명":"서울특별시","시군구명":"강남구"} + """)); + + assertThat(isNew).isTrue(); + CareFacility saved = captureSaved(); + assertThat(saved.getName()).isEqualTo("행복유치원"); + assertThat(saved.getFacilityType()).isEqualTo(FacilityType.KINDERGARTEN); + assertThat(saved.getIsPublic()).isTrue(); + assertThat(saved.getAvailableSpots()).isEqualTo(20); + assertThat(saved.getLatitude()).isEqualTo(37.5); + assertThat(saved.getCity()).isEqualTo("서울특별시"); + } + + @Test + @DisplayName("영문 필드명으로 와도 동일하게 읽는다") + void mapsEnglishFieldNames() { + service.upsert(row(""" + {"kindrgrtnNm":"한빛유치원","rdnmadr":"부산광역시 해운대구 1","telno":"051-1234-5678"} + """)); + + CareFacility saved = captureSaved(); + assertThat(saved.getName()).isEqualTo("한빛유치원"); + assertThat(saved.getPhone()).isEqualTo("051-1234-5678"); + } + + @Test + @DisplayName("고유 코드가 없어도 같은 유치원은 같은 코드가 나온다") + void generatesStableCodeFromNaturalKey() { + String json = """ + {"유치원명":"행복유치원","소재지도로명주소":"서울특별시 강남구 테헤란로 1"} + """; + + service.upsert(row(json)); + String first = captureSaved().getFacilityCode(); + + service.upsert(row(json)); + String second = captureSaved().getFacilityCode(); + + assertThat(first).isEqualTo(second).startsWith(KindergartenUpsertService.CODE_PREFIX); + } + + @Test + @DisplayName("이름이 같아도 주소가 다르면 다른 시설로 본다") + void distinguishesSameNameDifferentAddress() { + service.upsert(row("{\"유치원명\":\"행복유치원\",\"소재지도로명주소\":\"서울특별시 강남구 1\"}")); + String seoul = captureSaved().getFacilityCode(); + + service.upsert(row("{\"유치원명\":\"행복유치원\",\"소재지도로명주소\":\"부산광역시 해운대구 1\"}")); + String busan = captureSaved().getFacilityCode(); + + assertThat(seoul).isNotEqualTo(busan); + } + + @Test + @DisplayName("고유 코드가 있으면 그것을 쓴다") + void prefersExternalCode() { + service.upsert(row("{\"유치원명\":\"행복유치원\",\"유치원코드\":\"K12345\"}")); + + assertThat(captureSaved().getFacilityCode()) + .isEqualTo(KindergartenUpsertService.CODE_PREFIX + "K12345"); + } + + @Test + @DisplayName("유치원명이 없으면 저장하지 않는다") + void rejectsRowWithoutName() { + assertThatThrownBy(() -> service.upsert(row("{\"소재지도로명주소\":\"서울특별시\"}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유치원명"); + } + + private CareFacility captureSaved() { + ArgumentCaptor captor = ArgumentCaptor.forClass(CareFacility.class); + verify(repository, org.mockito.Mockito.atLeastOnce()).save(captor.capture()); + return captor.getValue(); + } + + private JsonNode row(String json) { + try { + return objectMapper.readTree(json); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} From 2cd6154c7196dbaf95a95e7fff7b73538c632e47 Mon Sep 17 00:00:00 2001 From: thoh Date: Tue, 4 Aug 2026 12:55:53 +0900 Subject: [PATCH 23/23] =?UTF-8?q?DOCS=20:=20=EC=9D=B4=EC=8A=88=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=C2=B7=ED=83=80=EC=9E=85=C2=B7=EB=A7=88=EC=9D=BC?= =?UTF-8?q?=EC=8A=A4=ED=86=A4=20=EC=9A=B4=EC=98=81=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ISSUE_MANAGEMENT.md | 109 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/ISSUE_MANAGEMENT.md diff --git a/docs/ISSUE_MANAGEMENT.md b/docs/ISSUE_MANAGEMENT.md new file mode 100644 index 00000000..89b58664 --- /dev/null +++ b/docs/ISSUE_MANAGEMENT.md @@ -0,0 +1,109 @@ +# 이슈 운영 가이드 + +이슈가 늘어나도 분류 기준이 흔들리지 않도록 라벨·타입·마일스톤·관계의 사용 규칙을 고정한다. + +## 제목 + +`.github/ISSUE_TEMPLATE` 규칙을 따른다. + +``` +[ 영역 ] 내용 +``` + +- 대괄호 안에 **공백**을 넣는다. `[ Auth ]` (O), `[Auth]` (X) +- 영역은 도메인 또는 관심사. `Core` `Auth` `Infra` `Booking` `Notification` `Child` `Policy` `Search` `PublicData` `CI` +- 커밋 메시지 규칙(`TYPE : 한글 설명 (#이슈번호)`)과는 별개다. 섞지 않는다. + +## Type (GitHub Issue Type) + +한 이슈에 하나만 붙는다. 라벨 `type:*` 과 중복돼 보이지만, Type 은 **GitHub 전역 집계·필터**에 쓰이고 `type:*` 라벨은 저장소 안에서 더 세분화된 성격을 나타낸다. + +| Type | 기준 | +|---|---| +| `Bug` | 지금 동작이 잘못됐다. 기동 실패, 잘못된 결과, 보안 결함, 설정이 효력 없음 | +| `Feature` | 없던 것을 만든다 | +| `Task` | 동작은 그대로인데 주변을 정비한다. 테스트, CI, 문서, 리팩토링, 트래킹 | + +성능 결함은 **의도한 결과가 나오지 않는 것**이면 `Bug`, 단순히 더 빠르게 만드는 것이면 `Task` 다. + +## 라벨 + +### priority + +실행 순서를 정하는 유일한 기준이다. 담당자가 다음에 뭘 할지 고를 때 이것만 본다. + +| 라벨 | 기준 | 대응 | +|---|---|---| +| `priority:P0` | 서비스가 지금 망가져 있다. 기동 불가, 인가 우회, 데이터 유출 | 즉시, 다른 작업 중단 | +| `priority:P1` | 사용자가 체감하는 오류이거나 배포를 막는다 | 현재 스프린트 안 | +| `priority:P2` | 개선하면 좋지만 미루어도 손해가 누적되지 않는다 | 여유 있을 때 | + +판단이 갈리면 **"내일 아침 배포를 막을 일인가"** 를 묻는다. 막으면 P0/P1. + +### domain + +건드리는 코드의 위치. 한 이슈가 여러 도메인에 걸치면 **가장 무게가 실린 쪽 하나**만 붙인다. + +| 라벨 | 범위 | +|---|---| +| `domain:core` | 공통 유틸, 보안 필터, 전역 예외, 설정 클래스 | +| `domain:api` | 컨트롤러 계약, 요청/응답 스키마, 페이징 | +| `domain:infra` | 빌드, CI/CD, 배포, Docker, 마이그레이션 실행 | +| `domain:data` | 시드 데이터, 외부 데이터 연동, 마이그레이션 내용 | +| `domain:health` | 건강 기록, 예방접종, 성장, 병원 | +| `domain:search` | 검색, 추천, 랭킹 | + +### type + +Type 필드를 세분화한다. 복수 부착 가능. + +`type:bug` `type:feature` `type:perf` `type:security` `type:test` `type:tech-debt` + +`type:security` 는 항상 `priority:P0` 또는 `P1` 이다. 보안 결함에 P2 를 붙이지 않는다. + +## 마일스톤 + +**언제 나가야 하는가**로 나눈다. 크기나 난이도가 아니다. + +| 마일스톤 | 기준 | +|---|---| +| `Hotfix` | 다음 배포를 기다릴 수 없다. P0 는 여기로 | +| `API Stabilization` | 클라이언트가 붙기 전에 계약이 확정돼야 한다 | +| `Refactor Sprint` | 동작을 바꾸지 않고 구조를 정리한다 | +| `Production Readiness` | 운영에 올리기 전에 갖춰야 한다. 관측, 배포, 테스트, 데이터 | + +## Relationships (하위 이슈) + +여러 이슈에 걸친 작업은 **상위 트래킹 이슈**를 만들고 하위 이슈로 연결한다. 본문에 번호를 나열만 하지 말고 실제 sub-issue 로 등록한다 — 진행률이 자동 집계된다. + +- 상위 이슈는 `Task` 타입 +- 상위 이슈 본문의 마지막 To Do 는 항상 `PR 머지 후 본 이슈 종료` +- 하위 이슈는 **독립적으로 닫을 수 있는 단위**여야 한다. 나눌 수 없으면 하나로 둔다 + +예: #40 (검토 제안 트래킹) ← #41~#52 / #64 (백엔드 전수 점검) ← #53~#59, #61, #63 + +## 신규 이슈 체크리스트 + +- [ ] 제목이 `[ 영역 ] 내용` 형식인가 +- [ ] Type 을 지정했는가 +- [ ] `priority:*` 를 붙였는가 (없으면 실행 순서에서 누락된다) +- [ ] `domain:*` 을 하나 붙였는가 +- [ ] 마일스톤을 배정했는가 +- [ ] 프로젝트 보드에 올렸는가 +- [ ] 상위 작업의 일부라면 sub-issue 로 연결했는가 +- [ ] To Do 가 **검증 가능한 단위**로 쪼개져 있는가 + +## 종료 기준 + +- 계획한 To Do 가 전부 체크됐고 코드가 기본 브랜치에 올라갔을 때 닫는다 +- PR 로 해소되는 이슈는 PR 본문에 `Closes #번호` 를 넣어 **머지 시 자동으로 닫히게** 한다. 수동으로 미리 닫지 않는다 +- **부분 완료는 닫지 않는다.** 완료된 To Do 를 체크하고 남은 범위를 코멘트로 명시한 뒤 열어 둔다 +- 다른 이슈가 같은 범위를 완전히 흡수했으면 `duplicate` 사유로 닫고 흡수한 이슈를 링크한다 + +## 정기 점검 + +월 1회 다음을 확인한다. + +- Type/`priority:*`/마일스톤이 비어 있는 열린 이슈 +- 90일 이상 갱신 없는 이슈 — 여전히 유효한지, 아니면 `not planned` 로 닫을지 +- 상위 트래킹 이슈의 하위 진행률과 본문 To Do 가 어긋나지 않는지