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/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/main/java/com/carecode/domain/health/entity/Hospital.java b/src/main/java/com/carecode/domain/health/entity/Hospital.java
index f378aa60..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,6 +20,10 @@ 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/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..c29ec226 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;
@@ -134,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 {
@@ -220,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);
@@ -262,6 +264,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/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/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;
+ }
+}
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"
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index e87113c1..f548d6ef 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -141,6 +141,12 @@ 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}
+ kindergarten-cron: ${PUBLIC_DATA_KINDERGARTEN_CRON:0 0 4 * * MON}
+ benefit-cron: ${PUBLIC_DATA_BENEFIT_CRON:0 30 3 * * *}
+ hospital-cron: ${PUBLIC_DATA_HOSPITAL_CRON:0 0 3 * * TUE}
jwt:
secret: ${JWT_SECRET}
@@ -192,6 +198,37 @@ 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}
+
+ # 심평원 병원정보서비스
+ hospital:
+ # 진료과목 코드. 심평원 코드표 기준 소아청소년과는 "10".
+ # 코드가 개정될 수 있어 설정으로 뺀다.
+ pediatric-subject-code: ${HOSPITAL_PEDIATRIC_SUBJECT_CODE:10}
+ subject-name: ${HOSPITAL_SUBJECT_NAME:소아청소년과}
+
+ # 데이터셋 경로. 공공데이터 오퍼레이션은 개편되므로 재배포 없이 바꿀 수 있게 뺀다.
+ # 절대 URL 을 넣으면 base-url 대신 그대로 호출한다(표준데이터는 호스트가 다르다).
+ resource:
+ childcare: ${PUBLIC_DATA_RESOURCE_CHILDCARE:B551014/CCEF/childcare}
+ kindergarten: ${PUBLIC_DATA_RESOURCE_KINDERGARTEN:http://api.data.go.kr/openapi/tn_pubr_public_kindergarten_api}
+ benefit: ${PUBLIC_DATA_RESOURCE_BENEFIT:1741000/publicServiceInformations/publicServiceInformation}
+ hospital: ${PUBLIC_DATA_RESOURCE_HOSPITAL:B551182/hospInfoServicev2/getHospBasisList}
+
+ sync:
+ # 무한 루프 방지 상한. 도달하면 중단 사유를 남긴다.
+ max-pages: ${PUBLIC_DATA_SYNC_MAX_PAGES:200}
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);
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/client/XmlResponseParserTest.java b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java
new file mode 100644
index 00000000..abcb86b0
--- /dev/null
+++ b/src/test/java/com/carecode/core/client/XmlResponseParserTest.java
@@ -0,0 +1,81 @@
+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 응답 파서 검증. */
+@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();
+ }
+}
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..937b5cdc
--- /dev/null
+++ b/src/test/java/com/carecode/core/client/provider/DataGoKrProviderTest.java
@@ -0,0 +1,115 @@
+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;
+
+/** 공공데이터포털 공급자 검증. */
+@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("절대 URL 리소스는 기본 호스트를 붙이지 않는다")
+ void usesAbsoluteResourceUrlAsIs() {
+ 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();
+ // 표준데이터는 별도 호스트에 있다
+ 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() {
+ 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();
+ }
+}
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);
+ }
+ }
+}
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;
+ }
+ }
+}
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();
+ }
+}
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();
+ }
+}
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);
+ }
+}
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();
+ }
+}