From 05e684b602b5f74ff3a82fbdfe4434d15be8af38 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 5 Oct 2026 20:07:02 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EA=B1=B0=EB=8C=80=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EC=97=90=EC=84=9C=20=EB=8F=84=EB=8B=AC=20?= =?UTF-8?q?=EB=B6=88=EA=B0=80=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HealthService 1011줄을 책임별로 쪼개려고 읽다 보니 상당 부분이 아무도 부르지 않는 코드였다. 부르는 곳이 없는 코드를 옮기는 건 옮긴 만큼만 일이 늘어난다. 컨트롤러에서 거꾸로 따라가 도달 가능한지 확인하고 지웠다. HealthService / HealthFacade - analyzeHealthStatus, generateHealthReport: 파사드에는 있는데 컨트롤러가 부르지 않는다. healthScore·riskLevel 같은 값을 만들어 내는데 공개된 적이 없다. - getHealthGoals: 고정 문자열("모든 예방접종 완료")을 돌려준다. 도달 불가. - getHealthChart: 도달 불가. 성장 곡선은 GrowthChartService 가 따로 한다. - checkSystemHealth: {status: UP, version: 1.0.0} 하드코딩. Actuator 의 /actuator/health 와 중복이고 도달 불가. - getHealthRecords, getHealthRecordsByDateRange: 파사드에도 없다. - 위 메서드들만 쓰던 private 헬퍼 11개와 상수 3개. CareFacilityService - saveCareFacilitiesFromPublicData, countCareFacilities 와 헬퍼 5개는 #137 에서 지운 CareFacilityApiController 가 쓰던 잔재였다. 입구를 지우면 그 뒤에 남는 코드가 생기는데, 그때 같이 지우지 않으면 "쓰이는 코드" 처럼 보인다. 크기 - HealthService 1011 → 715줄 - CareFacilityService 580 → 411줄 - HealthFacade 338 → 309줄 - ArchUnit baseline 364 → 362 - 전체 테스트 632개, 실패 0 - OpenAPI 스펙 변화 없음 — 공개 API 를 하나도 건드리지 않았다. 지운 것은 전부 밖에서 부를 수 없던 코드다. 남은 분해(5b): HealthService 715줄은 아직 건강 기록 CRUD, 아이 조회, 접종·검진 일정, 알림, 통계·추천을 함께 들고 있다. 이 중 아이 조회 4개는 user 도메인 일이라 4a 의 입구로 옮기는 것이 다음 자리다. Refs #132 --- .../a6b21c55-f3df-4514-8faa-c32628f81448 | 2 - docs/quality/architecture-rules.md | 48 ++- .../facility/service/CareFacilityService.java | 169 ---------- .../domain/health/app/HealthFacade.java | 24 -- .../domain/health/service/HealthService.java | 296 ------------------ 5 files changed, 45 insertions(+), 494 deletions(-) diff --git a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 index 59ef0a7..5e50680 100644 --- a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 +++ b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 @@ -178,11 +178,9 @@ health → user 리포지토리: Method calls method in (ChildService.java:106) health → user 리포지토리: Method calls method in (ChildService.java:81) health → user 리포지토리: Method calls method in (GrowthChartService.java:85) -health → user 리포지토리: Method calls method in (HealthService.java:311) health → user 리포지토리: Method calls method in (HealthService.java:99) health → user 리포지토리: Method calls method in (HealthService.java:879) health → user 리포지토리: Method calls method in (HealthService.java:871) -health → user 리포지토리: Method calls method in (HealthService.java:334) health → user 리포지토리: Method calls method in (HealthService.java:266) health → user 리포지토리: Method calls method in (HealthService.java:277) health → user 리포지토리: Method calls method in (HealthService.java:288) diff --git a/docs/quality/architecture-rules.md b/docs/quality/architecture-rules.md index a0c329f..d82bb81 100644 --- a/docs/quality/architecture-rules.md +++ b/docs/quality/architecture-rules.md @@ -31,7 +31,7 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. ## 규칙과 현재 위반 -진척: **489건(0단계) → 391건(1b) → 383건(3a) → 364건(4a)**. 남은 것은 도메인 경계(249)와 +진척: **489건(0단계) → 391건(1b) → 383건(3a) → 364건(4a) → 362건(5a)**. 남은 것은 도메인 경계(247)와 컨트롤러의 리포지토리 직접 사용(61), core 기반의 도메인 참조(50)입니다. > 세는 법: `archunit-baseline/` 에는 규칙별 위반 파일 외에 색인 파일 `stored.rules` 도 있습니다. @@ -39,7 +39,7 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. | 규칙 | 위반 | 뜻 | |------|------|-----| -| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 249 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | +| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 247 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | | 도메인 패키지 이름은 소문자다 | **0** | `careFacility`→`facility` 로 해소 (1b 단계) | | 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 61 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | | core 기반 패키지는 domain 을 의존하지 않는다 | 50 | 기반 코드가 도메인을 알면 공통이 아니다 | @@ -228,6 +228,47 @@ public interface ChildDirectory { 남아 있습니다(4b) - 전체 테스트 **624개, 실패 0**. OpenAPI 스펙은 변화 없음 — 응답 모양을 바꾸지 않았습니다 +## 분해 전에 지울 것이 먼저였다 (5a 단계) + +거대 서비스를 쪼개려고 `HealthService` 1011줄을 책임별로 나누기 시작했는데, 나누다 보니 +**상당 부분이 아무도 부르지 않는 코드**였습니다. 부르는 곳이 없는 코드를 옮기는 건 옮긴 만큼만 +일이 늘어납니다. + +컨트롤러에서 거꾸로 따라가 도달 가능한지 확인했습니다. + +| 지운 것 | 왜 | +|---------|-----| +| `analyzeHealthStatus`, `generateHealthReport` | 파사드에는 있는데 컨트롤러가 부르지 않는다. `healthScore`·`riskLevel` 같은 값을 만들어 내는데 공개된 적이 없다 | +| `getHealthGoals` | 고정 문자열("모든 예방접종 완료")을 돌려준다. 도달 불가 | +| `getHealthChart` | 도달 불가. 성장 곡선은 `GrowthChartService` 가 따로 한다 | +| `checkSystemHealth` | `{status: UP, version: 1.0.0}` 을 하드코딩. **Actuator 의 `/actuator/health` 와 중복**이고 도달 불가 | +| `getHealthRecords`, `getHealthRecordsByDateRange` | 파사드에도 없다 | +| 위 메서드들만 쓰던 private 헬퍼 11개 | `calculateHealthScore`, `determineRiskLevel`, `generateRecommendations`, `calculateVaccineStatus`, `calculateCheckupStatus`, `calculateProgress`, `generateHealthSummary`, `extractChartValue`, `validateChartType`, `validateMonths`, `validatePaginationParams` | +| 상수 3개 | `HEALTH_SCORE_HIGH_THRESHOLD` 등. 점수 계산이 사라지며 함께 쓸 곳이 없어졌다 | + +`CareFacilityService` 에서도 같은 것이 나왔습니다. `saveCareFacilitiesFromPublicData`, +`countCareFacilities` 와 그 헬퍼 5개는 **3a 에서 지운 `CareFacilityApiController` 가 쓰던 잔재**였습니다. +입구를 지우면 그 뒤에 남는 코드가 생기는데, 그때 같이 지우지 않으면 "쓰이는 코드" 처럼 보입니다. + +### 숫자 + +| 파일 | 전 | 후 | +|------|-----|-----| +| `HealthService` | 1011줄 | **715줄** | +| `CareFacilityService` | 580줄 | **411줄** | +| `HealthFacade` | 338줄 | **309줄** | + +- ArchUnit baseline 364 → **362** +- 전체 테스트 **632개, 실패 0** +- **OpenAPI 스펙 변화 없음** — 공개 API 를 하나도 건드리지 않았습니다. 지운 것은 전부 밖에서 + 부를 수 없던 코드입니다 + +### 남은 분해 (5b) + +`HealthService` 715줄은 아직 다섯 가지를 함께 들고 있습니다 — 건강 기록 CRUD, 아이 조회 +(연령·성별·특별 요구·이름 검색), 접종·검진 일정, 알림, 통계·추천. 이 중 **아이 조회 4개는 +`user` 도메인 일**이라 4a 의 입구로 옮기는 것이 다음 자리입니다. + ## 다음 단계 | 단계 | 내용 | 효과 | @@ -239,4 +280,5 @@ public interface ChildDirectory { | ~~3b. `Map` 응답 → DTO~~ | ~~남은 17곳~~ | **완료** — 스펙 스키마 134 → 158 | | ~~4a. 자녀 조회 입구~~ | ~~policy·facility 가 `ChildRepository` 를 직접 쓰고 소유권 검증이 3중~~ | **완료** — 383 → 364, 404 로 통일 | | 4b. Child 를 제 자리로 | `ChildService` 가 `health` 에 있다. health 는 아직 엔티티가 필요해 `ChildRepository` 를 쓴다 | 결합 추가 해소 | -| 5. 거대 서비스 분해 | HealthService 977줄 | 2·3 과 함께 진행 | +| ~~5a. 도달 불가 코드 제거~~ | ~~HealthService 1011줄, CareFacilityService 580줄~~ | **완료** — 715줄·411줄 | +| 5b. 남은 책임 분리 | HealthService 는 아직 기록·아이 조회·일정·알림·통계를 함께 들고 있다 | 읽기 쉬움 | diff --git a/src/main/java/com/carecode/domain/facility/service/CareFacilityService.java b/src/main/java/com/carecode/domain/facility/service/CareFacilityService.java index 3a94b2e..a56e070 100644 --- a/src/main/java/com/carecode/domain/facility/service/CareFacilityService.java +++ b/src/main/java/com/carecode/domain/facility/service/CareFacilityService.java @@ -52,170 +52,6 @@ public class CareFacilityService { private final CareFacilityMapper careFacilityMapper; private final FullTextSearchSupport fullTextSearchSupport; - // 공공데이터 API에서 받아온 보육시설 데이터를 DB에 저장 - @Transactional - public void saveCareFacilitiesFromPublicData(Map publicData) { - List> facilities = (List>) publicData.get("facilities"); - - if (facilities == null || facilities.isEmpty()) { - log.warn("저장할 보육시설 데이터가 없습니다."); - return; - } - - int savedCount = 0; - int updatedCount = 0; - int errorCount = 0; - - for (Map facilityData : facilities) { - try { - String facilityId = (String) facilityData.get("facilityId"); - - // 기존 시설이 있는지 확인 - Optional existingFacilityOpt = careFacilityRepository.findByFacilityCode(facilityId); - - if (existingFacilityOpt.isPresent()) { - // 기존 시설 업데이트 - CareFacility existingFacility = existingFacilityOpt.get(); - updateCareFacilityFromPublicData(existingFacility, facilityData); - updatedCount++; - log.debug("보육시설 업데이트: {}", facilityId); - } else { - // 새 시설 생성 - CareFacility newFacility = createCareFacilityFromPublicData(facilityData); - careFacilityRepository.save(newFacility); - savedCount++; - log.debug("보육시설 신규 저장: {}", facilityId); - } - - } catch (Exception e) { - errorCount++; - log.error("보육시설 데이터 저장 중 오류 발생: {}", e.getMessage(), e); - } - } - - log.info("공공데이터 보육시설 저장 완료: 신규={}, 업데이트={}, 오류={}", - savedCount, updatedCount, errorCount); - - } - - // 공공데이터로부터 새로운 CareFacility 엔티티 생성 - private CareFacility createCareFacilityFromPublicData(Map facilityData) { - return CareFacility.builder() - .facilityCode((String) facilityData.get("facilityId")) - .name((String) facilityData.get("facilityName")) - .facilityType(mapServiceTypeToFacilityType((String) facilityData.get("serviceType"))) - .city("서울특별시") // 서울시 API이므로 고정 - .district((String) facilityData.get("district")) - .address((String) facilityData.get("address")) - .latitude(CommonUtil.parseDouble(facilityData.get("latitude"))) - .longitude(CommonUtil.parseDouble(facilityData.get("longitude"))) - .website((String) facilityData.get("websiteUrl")) - .ageRange((String) facilityData.get("ageGroup")) - .operatingHours(formatOperatingHours(facilityData)) - .tuitionFee(CommonUtil.parseInteger(facilityData.get("rentFee"))) - .isActive(true) - .isPublic(true) // 공공데이터는 주로 공립 시설 - .subsidyAvailable((Boolean) facilityData.get("isFree")) - .description(generateDescription(facilityData)) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) - .build(); - - } - - // 기존 CareFacility 엔티티를 공공데이터로 업데이트 - private void updateCareFacilityFromPublicData(CareFacility existingFacility, Map facilityData) { - existingFacility.setName((String) facilityData.get("facilityName")); - existingFacility.setFacilityType(mapServiceTypeToFacilityType((String) facilityData.get("serviceType"))); - existingFacility.setDistrict((String) facilityData.get("district")); - existingFacility.setAddress((String) facilityData.get("address")); - existingFacility.setLatitude(CommonUtil.parseDouble(facilityData.get("latitude"))); - existingFacility.setLongitude(CommonUtil.parseDouble(facilityData.get("longitude"))); - existingFacility.setWebsite((String) facilityData.get("websiteUrl")); - existingFacility.setAgeRange((String) facilityData.get("ageGroup")); - existingFacility.setOperatingHours(formatOperatingHours(facilityData)); - existingFacility.setTuitionFee(CommonUtil.parseInteger(facilityData.get("rentFee"))); - existingFacility.setSubsidyAvailable((Boolean) facilityData.get("isFree")); - existingFacility.setDescription(generateDescription(facilityData)); - existingFacility.setUpdatedAt(LocalDateTime.now()); - - careFacilityRepository.save(existingFacility); - } - - // 서비스 타입을 FacilityType으로 매핑 - private FacilityType mapServiceTypeToFacilityType(String serviceType) { - if (serviceType == null) { - return FacilityType.OTHER; - } - - switch (serviceType) { - case "공동육아나눔터": - return FacilityType.PLAYGROUP; - case "어린이집": - return FacilityType.DAYCARE; - case "유치원": - return FacilityType.KINDERGARTEN; - case "방과후교실": - return FacilityType.NURSERY; - default: - return FacilityType.OTHER; - } - } - - // 운영시간 정보 포맷팅 - private String formatOperatingHours(Map facilityData) { - StringBuilder hours = new StringBuilder(); - - String weekdayStart = (String) facilityData.get("weekdayStartTime"); - String weekdayEnd = (String) facilityData.get("weekdayEndTime"); - String operationType = (String) facilityData.get("operationType"); - Boolean hasSaturday = (Boolean) facilityData.get("hasSaturdayOperation"); - String saturdayStart = (String) facilityData.get("saturdayStartTime"); - String saturdayEnd = (String) facilityData.get("saturdayEndTime"); - - if (weekdayStart != null && weekdayEnd != null) { - hours.append("평일: ").append(weekdayStart).append("~").append(weekdayEnd); - } - - if (operationType != null && !operationType.isEmpty()) { - hours.append(" (").append(operationType).append(")"); - } - - if (hasSaturday != null && hasSaturday && saturdayStart != null && saturdayEnd != null) { - hours.append(", 토요일: ").append(saturdayStart).append("~").append(saturdayEnd); - } - - return hours.toString(); - } - - // 시설 설명 생성 - private String generateDescription(Map facilityData) { - StringBuilder description = new StringBuilder(); - - String serviceType = (String) facilityData.get("serviceType"); - String ageGroup = (String) facilityData.get("ageGroup"); - String district = (String) facilityData.get("district"); - Boolean isFree = (Boolean) facilityData.get("isFree"); - - if (serviceType != null) { - description.append(serviceType); - } - - if (ageGroup != null) { - description.append(" - ").append(ageGroup); - } - - if (district != null) { - description.append(" (").append(district).append(")"); - } - - if (isFree != null && isFree) { - description.append(" - 무료 이용 가능"); - } - - return description.toString(); - } - // 돌봄 시설 목록 조회 /** 시설 목록 조회. 테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. */ @@ -228,11 +64,6 @@ public List getAllCareFacilities(int page, int size) { .collect(Collectors.toList()); } - // 전체 시설 수 - public long countCareFacilities() { - return careFacilityRepository.count(); - } - // 돌봄 시설 상세 조회 @LogExecutionTime @Cacheable(cacheNames = "careFacility", key = "#facilityId") diff --git a/src/main/java/com/carecode/domain/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index 81cc53d..db32d52 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -122,34 +122,10 @@ public List searchChi return healthService.searchChildrenByName(userId, name); } - // ==================== - // 건강 분석 및 리포트 ==================== - public Map analyzeHealthStatus(HealthCreateHealthRecordRequest request, Long actorUserId) { - return healthService.analyzeHealthStatus(request, actorUserId); - } - - public Map generateHealthReport(HealthCreateHealthRecordRequest request, Long actorUserId) { - return healthService.generateHealthReport(request, actorUserId); - } - - public Map getHealthGoals(String userId, Long actorUserId) { - return healthService.getHealthGoals(userId, actorUserId); - } - public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) { return healthService.getIntegratedRecommendations(userId, actorUserId); } - public List> getHealthChart(String userId, String type, String from, String to, Long actorUserId) { - LocalDate fromDate = from != null ? LocalDate.parse(from) : null; - LocalDate toDate = to != null ? LocalDate.parse(to) : null; - return healthService.getHealthChart(userId, type, fromDate, toDate, actorUserId); - } - - public Map checkSystemHealth() { - return healthService.checkSystemHealth(); - } - // ==================== 병원 관리 ==================== // 병원 관련 작업은 Facade에서 직접 처리하므로 트랜잭션 필요 하지만 public List getAllHospitals(int page, int size) { diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 2c69898..9cbb659 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -65,10 +65,7 @@ public class HealthService { // 상수 정의 private static final String DEFAULT_ALERT_PRIORITY = "MEDIUM"; - private static final int DEFAULT_MONTHS_FOR_NEXT_CHECKUP = 3; private static final int MAX_UPCOMING_EVENTS = 5; - private static final int HEALTH_SCORE_HIGH_THRESHOLD = 80; - private static final int HEALTH_SCORE_MEDIUM_THRESHOLD = 60; private final HealthRecordRepository healthRecordRepository; private final VaccinationScheduleRepository vaccinationScheduleRepository; @@ -221,41 +218,6 @@ public void deleteHealthRecord(Long recordId, Long actorUserId) { log.info("건강 기록이 삭제되었습니다: 기록ID={}", recordId); } - // 건강 기록 목록 조회 (페이징) - @LogExecutionTime - public List getHealthRecords(Long childId, int page, int size, Long actorUserId) { - validateChildId(childId); - validatePaginationParams(page, size); - assertChildOwnedByUserId(childId, actorUserId); - - log.info("건강 기록 목록 조회 - 아동 ID: {}, 페이지: {}, 크기: {}", childId, page, size); - - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "recordDate")); - Page records = healthRecordRepository.findByChildIdOrderByRecordDateDesc(childId, pageable); - - return records.getContent().stream() - .map(healthRecordMapper::toResponse) - .collect(Collectors.toList()); - } - - // 기간별 건강 기록 조회 JOIN FETCH를 사용하여 N+1 쿼리 문제 해결 - @LogExecutionTime - public List getHealthRecordsByDateRange(Long childId, LocalDate startDate, LocalDate endDate, Long actorUserId) { - validateChildId(childId); - validateDateRange(startDate, endDate); - assertChildOwnedByUserId(childId, actorUserId); - - log.info("기간별 건강 기록 조회 - 아동 ID: {}, 시작일: {}, 종료일: {}", childId, startDate, endDate); - - // JOIN FETCH를 사용하여 Child와 User를 한 번에 조회 - List records = healthRecordRepository.findByChildIdAndRecordDateBetweenWithChildAndUser( - childId, startDate, endDate); - - return records.stream() - .map(healthRecordMapper::toResponse) - .collect(Collectors.toList()); - } - // ===== 아동 정보 관리 ===== // 연령 범위별 자녀 조회 @@ -302,52 +264,6 @@ public List searchChildrenByName(Long userId, String name) { .collect(Collectors.toList()); } - // 건강 상태 분석 - @LogExecutionTime - public Map analyzeHealthStatus(HealthCreateHealthRecordRequest request, Long actorUserId) { - validateRequest(request); - - Long childId = Long.valueOf(request.getChildId()); - Child child = childRepository.findById(childId) - .orElseThrow(() -> new ResourceNotFoundException("아동을 찾을 수 없습니다: " + request.getChildId())); - assertChildOwnedByUser(child, actorUserId); - - List records = healthRecordRepository.findByChildOrderByRecordDateDesc(child); - - Map analysis = new HashMap<>(); - analysis.put("childId", request.getChildId()); - analysis.put("totalRecords", records.size()); - analysis.put("healthScore", calculateHealthScore(records)); - analysis.put("riskLevel", determineRiskLevel(records)); - analysis.put("recommendations", generateRecommendations(records)); - analysis.put("nextCheckup", LocalDateTime.now().plusMonths(DEFAULT_MONTHS_FOR_NEXT_CHECKUP).toString()); - - return analysis; - } - - // 건강 리포트 생성 - @LogExecutionTime - public Map generateHealthReport(HealthCreateHealthRecordRequest request, Long actorUserId) { - validateRequest(request); - - Long childId = Long.valueOf(request.getChildId()); - Child child = childRepository.findById(childId) - .orElseThrow(() -> new ResourceNotFoundException("아동을 찾을 수 없습니다: " + request.getChildId())); - assertChildOwnedByUser(child, actorUserId); - - List records = healthRecordRepository.findByChildOrderByRecordDateDesc(child); - - Map report = new HashMap<>(); - report.put("childId", request.getChildId()); - report.put("reportDate", LocalDateTime.now().toString()); - report.put("summary", generateHealthSummary(records)); - report.put("vaccineStatus", calculateVaccineStatus(records)); - report.put("checkupStatus", calculateCheckupStatus(records)); - report.put("recommendations", generateRecommendations(records)); - - return report; - } - // 건강 통계 조회 @LogExecutionTime public HealthStatsResponse getHealthStatistics(String userId, Long actorUserId) { @@ -566,69 +482,10 @@ public HealthRecommendationResponse getIntegratedRecommendations(String userId, "아이 연령에 맞는 정책/시설을 확인해보세요."); } - // 건강 목표 조회 - @LogExecutionTime - public Map getHealthGoals(String userId, Long actorUserId) { - validateUserId(userId); - assertUserIdBelongsToActor(userId, actorUserId); - User user = findUserByIdOrUserId(userId); - - List records = healthRecordRepository.findByUserOrderByRecordDateDesc(user); - - Map goals = new HashMap<>(); - goals.put("userId", userId); - goals.put("vaccineGoal", "모든 예방접종 완료"); - goals.put("checkupGoal", "정기 검진 100% 완료"); - // 영양은 목표만 제시하고 달성률은 내지 않는다. 섭취를 기록하는 수단이 없다. - goals.put("nutritionGoal", "균형 잡힌 영양 섭취"); - goals.put("progress", calculateProgress(records)); - - return goals; - } - // ===== 차트 및 시각화 ===== - // 건강 차트 데이터 조회 - @LogExecutionTime - public List> getHealthChart(String userId, String type, LocalDate from, LocalDate to, Long actorUserId) { - validateUserId(userId); - validateChartType(type); - validateDateRange(from, to); - assertUserIdBelongsToActor(userId, actorUserId); - - User user = findUserByIdOrUserId(userId); - List records = healthRecordRepository.findByUserOrderByRecordDateDesc(user); - - return records.stream() - .filter(r -> r.getRecordDate() != null) - .filter(r -> { - LocalDate localDate = r.getRecordDate(); - return (from == null || !localDate.isBefore(from)) && (to == null || !localDate.isAfter(to)); - }) - .map(r -> { - Object value = extractChartValue(r, type); - return Map.of( - "date", r.getRecordDate().toString(), - "value", value != null ? value : "" - ); - }) - .collect(Collectors.toList()); - } - // ===== 시스템 관리 ===== - // 시스템 상태 확인 - public Map checkSystemHealth() { - log.info("시스템 상태 확인"); - - Map healthStatus = new HashMap<>(); - healthStatus.put("status", "UP"); - healthStatus.put("timestamp", System.currentTimeMillis()); - healthStatus.put("version", "1.0.0"); - - return healthStatus; - } - private void assertHealthRecordOwnedByUser(HealthRecord record, Long actorUserId) { if (actorUserId == null) { throw new BusinessException(ErrorCode.UNAUTHORIZED, "인증이 필요합니다."); @@ -713,51 +570,6 @@ private HealthAlertResponse convertToHealthAlertResponse(HealthRecord record) { .build(); } - // ===== 계산 및 분석 Helper Methods ===== - private int calculateHealthScore(List records) { - if (records.isEmpty()) return 0; - - int completedVaccines = countCompletedVaccines(records); - int totalVaccines = (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.VACCINATION) - .count(); - - return totalVaccines > 0 ? (completedVaccines * 100) / totalVaccines : 0; - } - - private String determineRiskLevel(List records) { - if (records.isEmpty()) return "UNKNOWN"; - - int healthScore = calculateHealthScore(records); - if (healthScore >= HEALTH_SCORE_HIGH_THRESHOLD) return "LOW"; - else if (healthScore >= HEALTH_SCORE_MEDIUM_THRESHOLD) return "MEDIUM"; - else return "HIGH"; - } - - private List generateRecommendations(List records) { - List recommendations = new java.util.ArrayList<>(); - - if (records.isEmpty()) { - recommendations.add("첫 건강 기록을 등록해주세요"); - return recommendations; - } - - int completedVaccines = countCompletedVaccines(records); - int totalVaccines = (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.VACCINATION) - .count(); - - if (completedVaccines < totalVaccines) { - recommendations.add("예방접종 완료를 권장합니다"); - } - - if (records.stream().noneMatch(r -> r.getRecordType() == HealthRecord.RecordType.CHECKUP)) { - recommendations.add("정기 건강검진을 권장합니다"); - } - - return recommendations; - } - private int countCompletedVaccines(List records) { return (int) records.stream() .filter(r -> r.getRecordType() == HealthRecord.RecordType.VACCINATION && Boolean.TRUE.equals(r.getIsCompleted())) @@ -798,65 +610,6 @@ private List generateUpcomingEvents(List records) { .collect(Collectors.toList()); } - private String generateHealthSummary(List records) { - if (records.isEmpty()) return "건강 기록이 없습니다."; - - int totalRecords = records.size(); - int completedRecords = (int) records.stream() - .filter(r -> Boolean.TRUE.equals(r.getIsCompleted())) - .count(); - - return String.format("총 %d개의 건강 기록 중 %d개 완료 (완료율: %d%%)", - totalRecords, completedRecords, totalRecords > 0 ? (completedRecords * 100) / totalRecords : 0); - } - - private String calculateVaccineStatus(List records) { - int completedVaccines = countCompletedVaccines(records); - int totalVaccines = (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.VACCINATION) - .count(); - - return totalVaccines > 0 ? - String.format("완료율 %d%%", (completedVaccines * 100) / totalVaccines) : - "예방접종 기록 없음"; - } - - private String calculateCheckupStatus(List records) { - int completedCheckups = countCompletedCheckups(records); - int totalCheckups = (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.CHECKUP) - .count(); - - return totalCheckups > 0 ? - String.format("완료율 %d%%", (completedCheckups * 100) / totalCheckups) : - "검진 기록 없음"; - } - - /** - * 목표별 달성률. 계산할 근거가 없는 항목은 넣지 않는다. - * - *

영양은 목표 문구만 있고 섭취를 기록하는 수단이 없다. 예전에는 여기서 85 를 돌려주어 - * 모든 사용자가 자기 아이의 영양 상태를 85% 로 봤다. 근거 없는 숫자는 없는 것보다 나쁘다. - */ - private Map calculateProgress(List records) { - Map progress = new HashMap<>(); - - int completedVaccines = countCompletedVaccines(records); - int totalVaccines = (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.VACCINATION) - .count(); - - int completedCheckups = countCompletedCheckups(records); - int totalCheckups = (int) records.stream() - .filter(r -> r.getRecordType() == HealthRecord.RecordType.CHECKUP) - .count(); - - progress.put("vaccine", totalVaccines > 0 ? (completedVaccines * 100) / totalVaccines : 0); - progress.put("checkup", totalCheckups > 0 ? (completedCheckups * 100) / totalCheckups : 0); - - return progress; - } - // HealthRecord 엔티티를 HealthRecordResponse DTO로 변환 // HealthRecord 매핑은 HealthRecordMapper 사용 @@ -923,18 +676,6 @@ private void validateUserId(String userId) { } } - // 페이징 파라미터 검증 - private void validatePaginationParams(int page, int size) { - if (page < 0) { - throw new BusinessException(ErrorCode.INVALID_INPUT, - "페이지 번호는 0 이상이어야 합니다: " + page); - } - if (size <= 0 || size > 100) { - throw new BusinessException(ErrorCode.INVALID_INPUT, - "페이지 크기는 1 이상 100 이하여야 합니다: " + size); - } - } - // 날짜 범위 검증 private void validateDateRange(LocalDate startDate, LocalDate endDate) { if (startDate != null && endDate != null && startDate.isAfter(endDate)) { @@ -942,43 +683,6 @@ private void validateDateRange(LocalDate startDate, LocalDate endDate) { } } - // 개월 수 검증 - private void validateMonths(int months) { - if (months <= 0 || months > 120) { - throw new BusinessException(ErrorCode.INVALID_MONTHS, - ErrorCode.INVALID_MONTHS.getMessage() + ": " + months); - } - } - - // 차트 타입 검증 - private void validateChartType(String type) { - if (!StringUtils.hasText(type)) { - throw new BusinessException(ErrorCode.INVALID_INPUT, "차트 타입이 필요합니다."); - } - // 지원하는 타입: weight, height, temperature, pulseRate, bloodPressure - List validTypes = List.of("weight", "height", "temperature", "pulseRate", "bloodPressure"); - if (!validTypes.contains(type.toLowerCase())) { - throw new BusinessException(ErrorCode.INVALID_CHART_TYPE, - ErrorCode.INVALID_CHART_TYPE.getMessage() + ": " + type); - } - } - - // 차트 값 추출 - private Object extractChartValue(HealthRecord record, String type) { - if (record == null || type == null) { - return null; - } - - return switch (type.toLowerCase()) { - case "weight" -> record.getWeight(); - case "height" -> record.getHeight(); - case "temperature" -> record.getTemperature(); - case "pulserate" -> record.getPulseRate(); - case "bloodpressure" -> record.getBloodPressure(); - default -> null; - }; - } - private HealthRecordAttachmentResponse toAttachmentResponse(HealthRecordAttachment attachment) { return HealthRecordAttachmentResponse.builder() .attachmentId(attachment.getId()) From 2e83570615ac1425a59beffaaca7c668c8f26747 Mon Sep 17 00:00:00 2001 From: RosieOh Date: Mon, 5 Oct 2026 20:31:39 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=97=B0=EA=B3=84=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=EC=9D=B4=20=EC=97=B0=20=EB=82=98=EC=9D=B4=EB=A5=BC=20?= =?UTF-8?q?=EA=B0=9C=EC=9B=94=20=EB=8B=A8=EC=9C=84=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EC=97=90=20=EB=84=98=EA=B8=B0=EA=B3=A0=20=EC=9E=88=EC=97=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HealthService 의 아이 조회 네 개를 user 도메인으로 옮기려고 읽다가, 그 옆의 연계 추천이 여섯 번째 월령 계산을 들고 있는 것을 봤다. 그리고 이 하나만 단위가 달랐다. .map(b -> Period.between(b, LocalDate.now()).getYears()) // 연 나이 policyRepository.findByChildAge(childAge) // targetAge 는 개월 정책의 대상 연령은 개월이다(시드: 부모급여(0세) 0~11, 아동수당 0~95, 영유아 건강검진 0~71). 연 나이를 넘기므로 세 살 아이(36개월)가 "3" 으로 들어가 0~11개월 대상 정책 — 부모급여·첫만남이용권·산후조리비 — 이 추천됐다. 돌 지난 아이 전부가 영향을 받는다. 테스트로 먼저 확인했다: 개월로 와야 한다. 3 이면 연 나이를 그대로 넘긴 것이다 expected: 36 but was: 3 자녀가 없을 때도 같은 문제였다. .orElse(0) 이 0개월로 바꿔 신생아 정책을 추천했고 사용자는 그것을 "내 아이 기준 추천" 으로 읽는다. 이제 월령을 모르면 childAge 가 null 이고 추천 목록은 비어 있으며, 안내 문구가 "아이를 등록하면 …" 으로 바뀐다. 이것이 4a 에서 월령 계산을 한 곳으로 모은 이유다. 나머지 다섯 곳을 봐서는 이 하나가 틀렸다는 걸 알 수 없다. 복제의 비용은 "고칠 곳이 많다" 가 아니라 "틀린 곳을 못 찾는다" 다. 함께: /health/children/* 네 개를 지웠다 GET /health/children/{age-range,gender,special-needs,search} GET /children 이 birthDate·gender·specialNeeds·name 을 모두 주고 자녀는 보통 1~3명이다. 서버가 그 목록을 걸러 줄 이유가 없다. 넷 다 필수 userId 파라미터를 받아서 무시하고 있었고(컨트롤러는 인증 주체의 PK 를 넘긴다), 프런트는 쓰지 않는다. AccessControlContractTest.duplicateMappingsRemoved 에 네 경로를 추가했다. 고치지 않고 적어 둔 것: 시설 추천은 연령 기반이 아니다. 시설의 AGE_RANGE_MIN/MAX 를 어떤 동기화도 채우지 않아 대부분 비어 있고, 연령 조건이 비면 전부 일치로 본다. 즉 recommendedFacilities 는 사실상 "운영 중인 시설 세 곳" 이다. 단위를 고쳐도 달라지지 않으므로 응답 DTO 에 한계를 적었다. - HealthService 715 → 688줄 (ChildMapper 의존도 함께 빠졌다) - OpenAPI 232 → 228 경로 - ArchUnit baseline 362 → 357 (도메인 경계 247 → 242) - 전체 테스트 639개, 실패 0 생성자에서 ChildMapper 가 빠져 기존 위반 5건이 새 위반으로 보였다. refreeze 를 한 번 켜고 규칙별 숫자가 늘지 않은 것을 확인한 뒤 되돌렸다. Refs #132 --- .../a6b21c55-f3df-4514-8faa-c32628f81448 | 33 ++- docs/api/openapi.json | 200 ------------------ docs/quality/architecture-rules.md | 66 +++++- .../domain/health/app/HealthFacade.java | 16 -- .../health/controller/HealthController.java | 41 ---- .../HealthRecommendationResponse.java | 11 +- .../domain/health/service/HealthService.java | 83 +++----- .../AccessControlContractTest.java | 8 +- .../HealthRecommendationContractTest.java | 195 +++++++++++++++++ 9 files changed, 315 insertions(+), 338 deletions(-) create mode 100644 src/test/java/com/carecode/integration/HealthRecommendationContractTest.java diff --git a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 index 5e50680..36cb317 100644 --- a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 +++ b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 @@ -138,29 +138,29 @@ facility → user 리포지토리: Field has type in (CareFacilityService.java:0) facility → user 리포지토리: Method calls method in (CareFacilityBookingService.java:52) facility → user 리포지토리: Method calls method in (CareFacilityBookingService.java:100) -facility → user 리포지토리: Method calls method in (CareFacilityService.java:524) -facility → user 리포지토리: Method calls method in (CareFacilityService.java:559) -facility → user 리포지토리: Method calls method in (CareFacilityService.java:549) -health → facility 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) +facility → user 리포지토리: Method calls method in (CareFacilityService.java:355) +facility → user 리포지토리: Method calls method in (CareFacilityService.java:390) +facility → user 리포지토리: Method calls method in (CareFacilityService.java:380) +health → facility 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) health → facility 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → facility 리포지토리: Field has type in (HealthService.java:0) health → facility 리포지토리: Field has type in (SiblingOverviewService.java:0) -health → facility 리포지토리: Method calls method in (HealthService.java:556) +health → facility 리포지토리: Method calls method in (HealthService.java:446) health → facility 리포지토리: Method calls method in (SiblingOverviewService.java:91) -health → policy 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) +health → policy 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) health → policy 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → policy 리포지토리: Constructor (com.carecode.domain.health.service.ChildService, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.policy.repository.PolicyRepository)> has parameter of type in (ChildTimelineService.java:0) health → policy 리포지토리: Field has type in (HealthService.java:0) health → policy 리포지토리: Field has type in (SiblingOverviewService.java:0) health → policy 리포지토리: Field has type in (ChildTimelineService.java:0) -health → policy 리포지토리: Method calls method in (HealthService.java:552) +health → policy 리포지토리: Method calls method in (HealthService.java:442) health → policy 리포지토리: Method calls method in (SiblingOverviewService.java:101) health → policy 리포지토리: Method calls method in (ChildTimelineService.java:180) health → user 리포지토리: Constructor (com.carecode.domain.health.service.HealthService, com.carecode.domain.health.repository.HospitalRepository, com.carecode.core.ops.sync.SyncFreshnessService, com.carecode.domain.health.repository.HospitalLikeRepository, com.carecode.domain.health.repository.HospitalReviewRepository, com.carecode.domain.health.mapper.HospitalMapper, com.carecode.domain.health.mapper.HospitalReviewMapper, com.carecode.domain.user.repository.UserRepository)> has parameter of type in (HealthFacade.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.mapper.ChildMapper, com.carecode.core.security.CurrentUserFacade, com.carecode.domain.health.service.VaccinationScheduleService, com.carecode.core.analytics.EventLogger)> has parameter of type in (ChildService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (GrowthChartService.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) -health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper, com.carecode.domain.health.mapper.ChildMapper)> has parameter of type in (HealthService.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) +health → user 리포지토리: Constructor (com.carecode.domain.health.repository.HealthRecordRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.service.ConsentGuard, com.carecode.domain.health.repository.HealthRecordAttachmentRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.user.app.ChildDirectory, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.facility.repository.CareFacilityRepository, com.carecode.domain.health.mapper.HealthRecordMapper)> has parameter of type in (HealthService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.facility.repository.FacilityWaitlistRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (SiblingOverviewService.java:0) health → user 리포지토리: Constructor (com.carecode.domain.health.repository.VaccinationScheduleRepository, com.carecode.domain.user.repository.ChildRepository)> has parameter of type in (VaccinationScheduleService.java:0) health → user 리포지토리: Field has type in (HealthFacade.java:0) @@ -170,22 +170,17 @@ health → user 리포지토리: Field has type in (HealthService.java:0) health → user 리포지토리: Field has type in (SiblingOverviewService.java:0) health → user 리포지토리: Field has type in (VaccinationScheduleService.java:0) -health → user 리포지토리: Method calls method in (HealthFacade.java:287) -health → user 리포지토리: Method calls method in (HealthFacade.java:184) +health → user 리포지토리: Method calls method in (HealthFacade.java:247) +health → user 리포지토리: Method calls method in (HealthFacade.java:144) health → user 리포지토리: Method calls method in (ChildService.java:50) health → user 리포지토리: Method calls method in (ChildService.java:86) health → user 리포지토리: Method calls method in (ChildService.java:62) health → user 리포지토리: Method calls method in (ChildService.java:106) health → user 리포지토리: Method calls method in (ChildService.java:81) health → user 리포지토리: Method calls method in (GrowthChartService.java:85) -health → user 리포지토리: Method calls method in (HealthService.java:99) -health → user 리포지토리: Method calls method in (HealthService.java:879) -health → user 리포지토리: Method calls method in (HealthService.java:871) -health → user 리포지토리: Method calls method in (HealthService.java:266) -health → user 리포지토리: Method calls method in (HealthService.java:277) -health → user 리포지토리: Method calls method in (HealthService.java:288) -health → user 리포지토리: Method calls method in (HealthService.java:545) -health → user 리포지토리: Method calls method in (HealthService.java:299) +health → user 리포지토리: Method calls method in (HealthService.java:97) +health → user 리포지토리: Method calls method in (HealthService.java:605) +health → user 리포지토리: Method calls method in (HealthService.java:597) health → user 리포지토리: Method calls method in (SiblingOverviewService.java:44) health → user 리포지토리: Method calls method in (VaccinationScheduleService.java:32) notification → user 리포지토리: Constructor (com.carecode.domain.notification.service.NotificationService, com.carecode.domain.notification.service.NotificationPreferenceService, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.domain.notification.repository.NotificationPreferenceRepository)> has parameter of type in (NotificationFacade.java:0) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 3769589..95952f2 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -11927,206 +11927,6 @@ "tags" : [ "건강 관리" ] } }, - "/health/children/age-range" : { - "get" : { - "description" : "특정 연령 범위에 해당하는 자녀 조회", - "operationId" : "getChildrenByAgeRange", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "query", - "name" : "userId", - "required" : true, - "schema" : { - "format" : "int64", - "type" : "integer" - } - }, { - "description" : "최소 연령", - "in" : "query", - "name" : "minAge", - "required" : true, - "schema" : { - "format" : "int32", - "type" : "integer" - } - }, { - "description" : "최대 연령", - "in" : "query", - "name" : "maxAge", - "required" : true, - "schema" : { - "format" : "int32", - "type" : "integer" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "items" : { - "$ref" : "#/components/schemas/ChildInfoResponse" - }, - "type" : "array" - } - } - }, - "description" : "OK" - } - }, - "summary" : "연령 범위별 자녀 조회", - "tags" : [ "건강 관리" ] - } - }, - "/health/children/gender" : { - "get" : { - "description" : "특정 성별의 자녀 조회", - "operationId" : "getChildrenByGender", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "query", - "name" : "userId", - "required" : true, - "schema" : { - "format" : "int64", - "type" : "integer" - } - }, { - "description" : "성별 (MALE, FEMALE)", - "in" : "query", - "name" : "gender", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "items" : { - "$ref" : "#/components/schemas/ChildInfoResponse" - }, - "type" : "array" - } - } - }, - "description" : "OK" - } - }, - "summary" : "성별 자녀 조회", - "tags" : [ "건강 관리" ] - } - }, - "/health/children/search" : { - "get" : { - "operationId" : "searchChildrenByName", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "query", - "name" : "userId", - "required" : true, - "schema" : { - "format" : "int64", - "type" : "integer" - } - }, { - "description" : "검색할 이름", - "in" : "query", - "name" : "name", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "items" : { - "$ref" : "#/components/schemas/ChildInfoResponse" - }, - "type" : "array" - } - } - }, - "description" : "OK" - } - }, - "summary" : "이름으로 자녀 검색", - "tags" : [ "건강 관리" ] - } - }, - "/health/children/special-needs" : { - "get" : { - "operationId" : "getChildrenWithSpecialNeeds", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "query", - "name" : "userId", - "required" : true, - "schema" : { - "format" : "int64", - "type" : "integer" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "items" : { - "$ref" : "#/components/schemas/ChildInfoResponse" - }, - "type" : "array" - } - } - }, - "description" : "OK" - } - }, - "summary" : "특별한 요구사항이 있는 자녀 조회", - "tags" : [ "건강 관리" ] - } - }, "/health/hospitals" : { "get" : { "description" : "등록된 모든 병원 정보 조회", diff --git a/docs/quality/architecture-rules.md b/docs/quality/architecture-rules.md index d82bb81..0a46137 100644 --- a/docs/quality/architecture-rules.md +++ b/docs/quality/architecture-rules.md @@ -31,7 +31,7 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. ## 규칙과 현재 위반 -진척: **489건(0단계) → 391건(1b) → 383건(3a) → 364건(4a) → 362건(5a)**. 남은 것은 도메인 경계(247)와 +진척: **489건(0단계) → 391건(1b) → 383건(3a) → 364건(4a) → 362건(5a) → 357건(5b)**. 남은 것은 도메인 경계(242)와 컨트롤러의 리포지토리 직접 사용(61), core 기반의 도메인 참조(50)입니다. > 세는 법: `archunit-baseline/` 에는 규칙별 위반 파일 외에 색인 파일 `stored.rules` 도 있습니다. @@ -39,7 +39,7 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. | 규칙 | 위반 | 뜻 | |------|------|-----| -| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 247 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | +| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 242 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | | 도메인 패키지 이름은 소문자다 | **0** | `careFacility`→`facility` 로 해소 (1b 단계) | | 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 61 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | | core 기반 패키지는 domain 을 의존하지 않는다 | 50 | 기반 코드가 도메인을 알면 공통이 아니다 | @@ -269,6 +269,65 @@ public interface ChildDirectory { (연령·성별·특별 요구·이름 검색), 접종·검진 일정, 알림, 통계·추천. 이 중 **아이 조회 4개는 `user` 도메인 일**이라 4a 의 입구로 옮기는 것이 다음 자리입니다. +## 아이 조회를 health 에서 뺐고, 복제가 숨긴 버그가 나왔다 (5b 단계) + +`HealthService` 에 아이 조회 네 개가 있었습니다 — 연령 범위·성별·특별 요구·이름 검색. +자녀는 `user` 도메인 일이라 옮기려고 보니, 넷 다 **지울 것**이었습니다. + +| 지운 엔드포인트 | 왜 | +|-----------------|-----| +| `GET /health/children/age-range` | `GET /children` 이 `birthDate`·`gender`·`specialNeeds`·`name` 을 모두 주고, 자녀는 보통 1~3명이다. 서버가 그 목록을 걸러 줄 이유가 없다 | +| `GET /health/children/gender` | 같음 | +| `GET /health/children/special-needs` | 같음 | +| `GET /health/children/search` | 같음 | + +넷 다 **필수 `userId` 파라미터를 받아서 무시**하고 있었습니다(컨트롤러는 인증 주체의 PK 를 넘깁니다). +남의 id 를 넣어도 자기 자녀가 나오므로 보안 문제는 없지만, 계약이 거짓말을 하고 있었습니다. +프런트는 이 넷을 쓰지 않습니다. + +### 복제된 계산이 숨기고 있던 버그 + +연계 추천(`GET /health/recommendations`)이 **여섯 번째 월령 계산**을 들고 있었는데, 이 하나만 +단위가 달랐습니다. + +```java +.map(b -> Period.between(b, LocalDate.now()).getYears()) // 연 나이 +... +policyRepository.findByChildAge(childAge) // targetAgeMin/Max 는 개월 +``` + +정책의 대상 연령은 **개월**입니다(시드: 부모급여(0세) `0~11`, 아동수당 `0~95`, 영유아 건강검진 `0~71`). +그런데 연 나이를 넘기므로 **세 살 아이(36개월)가 "3" 으로 들어가 0~11개월 대상 정책 +— 부모급여·첫만남이용권·산후조리비 — 이 추천됐습니다.** 돌 지난 아이 전부가 영향을 받습니다. + +테스트로 먼저 확인했습니다. + +``` +개월로 와야 한다. 3 이면 연 나이를 그대로 넘긴 것이다 +expected: 36 but was: 3 +``` + +자녀가 없을 때도 같은 문제였습니다. `.orElse(0)` 이 **0개월**로 바꿔 신생아 정책을 추천했고, +사용자는 그것을 "내 아이 기준 추천" 으로 읽습니다. 이제 월령을 모르면 `childAge: null` 이고 +추천 목록은 비어 있으며, 안내 문구가 "아이를 등록하면 …" 으로 바뀝니다. + +**이것이 4a 에서 월령 계산을 한 곳으로 모은 이유입니다.** 나머지 다섯 곳을 봐서는 이 하나가 +틀렸다는 걸 알 수 없습니다. 복제의 비용은 "고칠 곳이 많다" 가 아니라 **"틀린 곳을 못 찾는다"** 입니다. + +### 고치지 않고 적어 둔 것 + +시설 추천은 연령 기반이 아닙니다. 시설의 `AGE_RANGE_MIN/MAX` 를 **어떤 동기화도 채우지 않아** +대부분 비어 있고, 연령 조건이 비면 전부 일치로 봅니다. 즉 `recommendedFacilities` 는 사실상 +"운영 중인 시설 세 곳" 입니다. 단위를 고쳐도 이건 달라지지 않습니다 — 그 값을 채우는 일이 +먼저이고, 그때까지 응답 DTO 에 한계를 적어 두었습니다. + +### 숫자 + +- `HealthService` 715 → **688줄** (`ChildMapper` 의존도 함께 빠졌습니다) +- OpenAPI: 232 → **228** 경로 +- ArchUnit baseline 362 → **357** (도메인 경계 247 → 242) +- 전체 테스트 **639개, 실패 0** + ## 다음 단계 | 단계 | 내용 | 효과 | @@ -281,4 +340,5 @@ public interface ChildDirectory { | ~~4a. 자녀 조회 입구~~ | ~~policy·facility 가 `ChildRepository` 를 직접 쓰고 소유권 검증이 3중~~ | **완료** — 383 → 364, 404 로 통일 | | 4b. Child 를 제 자리로 | `ChildService` 가 `health` 에 있다. health 는 아직 엔티티가 필요해 `ChildRepository` 를 쓴다 | 결합 추가 해소 | | ~~5a. 도달 불가 코드 제거~~ | ~~HealthService 1011줄, CareFacilityService 580줄~~ | **완료** — 715줄·411줄 | -| 5b. 남은 책임 분리 | HealthService 는 아직 기록·아이 조회·일정·알림·통계를 함께 들고 있다 | 읽기 쉬움 | +| ~~5b. 아이 조회를 health 에서 뺀다~~ | ~~`/health/children/*` 4개가 `/children` 과 중복~~ | **완료** — 362 → 357, 단위 버그 발견 | +| 5c. 남은 책임 분리 | HealthService 688줄은 아직 기록·일정·알림·통계를 함께 들고 있다 | 읽기 쉬움 | diff --git a/src/main/java/com/carecode/domain/health/app/HealthFacade.java b/src/main/java/com/carecode/domain/health/app/HealthFacade.java index db32d52..6439c1d 100644 --- a/src/main/java/com/carecode/domain/health/app/HealthFacade.java +++ b/src/main/java/com/carecode/domain/health/app/HealthFacade.java @@ -106,22 +106,6 @@ public void deleteAttachment(Long attachmentId, Long actorUserId) { healthService.deleteAttachment(attachmentId, actorUserId); } - public List getChildrenByAgeRange(Long userId, Integer minAge, Integer maxAge) { - return healthService.getChildrenByAgeRange(userId, minAge, maxAge); - } - - public List getChildrenByGender(Long userId, String gender) { - return healthService.getChildrenByGender(userId, gender); - } - - public List getChildrenWithSpecialNeeds(Long userId) { - return healthService.getChildrenWithSpecialNeeds(userId); - } - - public List searchChildrenByName(Long userId, String name) { - return healthService.searchChildrenByName(userId, name); - } - public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) { return healthService.getIntegratedRecommendations(userId, actorUserId); } diff --git a/src/main/java/com/carecode/domain/health/controller/HealthController.java b/src/main/java/com/carecode/domain/health/controller/HealthController.java index a31dc06..ed21774 100644 --- a/src/main/java/com/carecode/domain/health/controller/HealthController.java +++ b/src/main/java/com/carecode/domain/health/controller/HealthController.java @@ -415,47 +415,6 @@ public ResponseEntity> getHealthRecordsByType(@Parame // ==================== // 자녀 관리 기능 ==================== - // 연령 범위별 자녀 조회 - @GetMapping("/children/age-range") - @LogExecutionTime - @Operation(summary = "연령 범위별 자녀 조회", description = "특정 연령 범위에 해당하는 자녀 조회") - public ResponseEntity> getChildrenByAgeRange(@Parameter(description = "사용자 ID", required = true) @RequestParam Long userId, @Parameter(description = "최소 연령", required = true) @RequestParam Integer minAge, @Parameter(description = "최대 연령", required = true) @RequestParam Integer maxAge) { - List children = healthFacade.getChildrenByAgeRange(getAuthenticatedUserPk(), minAge, maxAge); - - return ResponseEntity.ok(children); - } - - // 성별 자녀 조회 - @GetMapping("/children/gender") - @LogExecutionTime - @Operation(summary = "성별 자녀 조회", description = "특정 성별의 자녀 조회") - public ResponseEntity> getChildrenByGender(@Parameter(description = "사용자 ID", required = true) @RequestParam Long userId, - @Parameter(description = "성별 (MALE, FEMALE)", required = true) @RequestParam String gender) { - List children = healthFacade.getChildrenByGender(getAuthenticatedUserPk(), gender); - return ResponseEntity.ok(children); - } - - // 특별한 요구사항이 있는 자녀 조회 - @GetMapping("/children/special-needs") - @LogExecutionTime - @Operation(summary = "특별한 요구사항이 있는 자녀 조회") - public ResponseEntity> getChildrenWithSpecialNeeds(@Parameter(description = "사용자 ID", required = true) @RequestParam Long userId) { - List children = healthFacade.getChildrenWithSpecialNeeds(getAuthenticatedUserPk()); - - return ResponseEntity.ok(children); - } - - // 이름으로 자녀 검색 - @GetMapping("/children/search") - @LogExecutionTime - @Operation(summary = "이름으로 자녀 검색") - public ResponseEntity> searchChildrenByName(@Parameter(description = "사용자 ID", required = true) @RequestParam Long userId, - @Parameter(description = "검색할 이름", required = true) @RequestParam String name) { - List children = healthFacade.searchChildrenByName(getAuthenticatedUserPk(), name); - - return ResponseEntity.ok(children); - } - private Long getAuthenticatedUserPk() { return currentUserFacade.requireCurrentUserDbId(); } diff --git a/src/main/java/com/carecode/domain/health/dto/response/HealthRecommendationResponse.java b/src/main/java/com/carecode/domain/health/dto/response/HealthRecommendationResponse.java index 71b7c35..44ed6fd 100644 --- a/src/main/java/com/carecode/domain/health/dto/response/HealthRecommendationResponse.java +++ b/src/main/java/com/carecode/domain/health/dto/response/HealthRecommendationResponse.java @@ -8,12 +8,17 @@ *

추천 정책·시설은 **이름 문자열만** 담는다. id 가 없어 상세로 바로 보낼 수 없고, 화면에서는 * 검색 지름길로 쓴다. (id 를 주려면 정책·시설 쪽 추천 API 를 쓰는 게 맞다.) * - *

등록된 자녀가 없으면 {@code childAge} 가 0 이다. 그 경우 추천은 0세 기준이 되므로, - * 화면은 "자녀를 등록하세요" 를 먼저 보여 주는 쪽이 정확하다. + *

{@code childAge} 는 개월이다. 정책의 대상 연령이 개월 단위라서다(부모급여 0~11, + * 아동수당 0~95). 등록된 자녀가 없거나 생년월일이 없으면 {@code null} 이고, 그때는 추천 목록도 + * 비어 있다 — 모르는 나이를 0 으로 바꾸면 신생아 정책이 추천된다. + * + *

시설 추천의 한계: 시설의 {@code AGE_RANGE_MIN/MAX} 는 어떤 동기화도 채우지 않아 + * 대부분 비어 있다. 연령 조건이 비면 전부 일치로 보므로, 지금 {@code recommendedFacilities} 는 + * 사실상 "운영 중인 시설 세 곳" 이다. 연령 기반이 되려면 그 값을 채우는 일이 먼저다. */ public record HealthRecommendationResponse( String userId, - int childAge, + Integer childAge, List recommendedPolicies, List recommendedFacilities, String nudgeMessage) { diff --git a/src/main/java/com/carecode/domain/health/service/HealthService.java b/src/main/java/com/carecode/domain/health/service/HealthService.java index 9cbb659..bd8621d 100644 --- a/src/main/java/com/carecode/domain/health/service/HealthService.java +++ b/src/main/java/com/carecode/domain/health/service/HealthService.java @@ -21,7 +21,6 @@ import com.carecode.domain.health.dto.response.CheckupScheduleResponse; import com.carecode.domain.health.dto.response.HealthStatsResponse; import com.carecode.domain.health.dto.response.HealthAlertResponse; -import com.carecode.domain.health.dto.response.ChildInfoResponse; import com.carecode.domain.health.entity.HealthRecord; import com.carecode.domain.health.entity.HealthRecordAttachment; import com.carecode.domain.policy.entity.Policy; @@ -37,7 +36,6 @@ import com.carecode.domain.user.repository.ChildRepository; import com.carecode.domain.user.repository.UserRepository; import com.carecode.domain.health.mapper.HealthRecordMapper; -import com.carecode.domain.health.mapper.ChildMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -71,13 +69,16 @@ public class HealthService { private final VaccinationScheduleRepository vaccinationScheduleRepository; private final ConsentGuard consentGuard; private final HealthRecordAttachmentRepository healthRecordAttachmentRepository; + /** + * 건강 기록을 자녀에 붙이려면 엔티티가 필요해 남겨 둔다. 조회·소유권 판단은 + * {@link ChildDirectory} 가 하고, 여기서는 통과한 뒤 연관을 걸 때만 쓴다. + */ private final ChildRepository childRepository; private final ChildDirectory childDirectory; private final UserRepository userRepository; private final PolicyRepository policyRepository; private final CareFacilityRepository careFacilityRepository; private final HealthRecordMapper healthRecordMapper; - private final ChildMapper childMapper; // ===== 건강 기록 관리 ===== @@ -220,50 +221,6 @@ public void deleteHealthRecord(Long recordId, Long actorUserId) { // ===== 아동 정보 관리 ===== - // 연령 범위별 자녀 조회 - @LogExecutionTime - public List getChildrenByAgeRange(Long userId, Integer minAge, Integer maxAge) { - log.info("연령 범위별 자녀 조회 - 사용자 ID: {}, 최소 연령: {}, 최대 연령: {}", userId, minAge, maxAge); - - List children = childRepository.findByUserIdAndAgeRange(userId, minAge, maxAge); - return children.stream() - .map(childMapper::toResponse) - .collect(Collectors.toList()); - } - - // 성별 자녀 조회 - @LogExecutionTime - public List getChildrenByGender(Long userId, String gender) { - log.info("성별 자녀 조회 - 사용자 ID: {}, 성별: {}", userId, gender); - - List children = childRepository.findByUserIdAndGender(userId, gender); - return children.stream() - .map(childMapper::toResponse) - .collect(Collectors.toList()); - } - - // 특별한 요구사항이 있는 자녀 조회 - @LogExecutionTime - public List getChildrenWithSpecialNeeds(Long userId) { - log.info("특별한 요구사항이 있는 자녀 조회 - 사용자 ID: {}", userId); - - List children = childRepository.findByUserIdAndHasSpecialNeeds(userId); - return children.stream() - .map(childMapper::toResponse) - .collect(Collectors.toList()); - } - - // 이름으로 자녀 검색 - @LogExecutionTime - public List searchChildrenByName(Long userId, String name) { - log.info("이름으로 자녀 검색 - 사용자 ID: {}, 이름: {}", userId, name); - - List children = childRepository.findByUserIdAndNameContaining(userId, name); - return children.stream() - .map(childMapper::toResponse) - .collect(Collectors.toList()); - } - // 건강 통계 조회 @LogExecutionTime public HealthStatsResponse getHealthStatistics(String userId, Long actorUserId) { @@ -454,29 +411,46 @@ public List getHealthAlerts(String userId, Long actorUserId } } + /** + * 아이 연령 기준 연계 추천. + * + *

연령 단위는 개월이다. 정책의 {@code targetAgeMin/Max} 가 개월이기 때문이다 + * (시드 데이터: "부모급여(0세)" 0~11, "아동수당" 0~95). 전에는 여기서만 + * {@code Period.between(...).getYears()} 로 연 나이를 계산해 그 쿼리에 넘겼다. + * 세 살 아이가 "3" 으로 들어가 0~11개월 대상 정책이 추천됐다. + * + *

월령을 모르면({@code null}) 연령 기반 조회를 하지 않는다. 전에는 {@code .orElse(0)} 이 + * 0개월로 바꿔, 자녀를 등록하지 않은 사용자에게 신생아 정책을 추천했다. + */ @LogExecutionTime public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) { assertUserIdBelongsToActor(userId, actorUserId); User user = findUserByIdOrUserId(userId); - Integer childAge = childRepository.findByUserIdOrderByCreatedAtDesc(user.getId()).stream() - .map(Child::getBirthDate) + + Integer childAgeMonths = childDirectory.childrenOf(user.getId()).stream() + .map(child -> child.ageMonths(LocalDate.now())) .filter(java.util.Objects::nonNull) - .map(b -> java.time.Period.between(b, LocalDate.now()).getYears()) .findFirst() - .orElse(0); + .orElse(null); + + if (childAgeMonths == null) { + return new HealthRecommendationResponse( + user.getUserId(), null, List.of(), List.of(), + "아이를 등록하면 연령에 맞는 정책과 시설을 알려드립니다."); + } - List recommendedPolicies = policyRepository.findByChildAge(childAge).stream() + List recommendedPolicies = policyRepository.findByChildAge(childAgeMonths).stream() .limit(3) .map(Policy::getTitle) .collect(Collectors.toList()); - List recommendedFacilities = careFacilityRepository.findByChildAge(childAge).stream() + List recommendedFacilities = careFacilityRepository.findByChildAge(childAgeMonths).stream() .limit(3) .map(CareFacility::getName) .collect(Collectors.toList()); return new HealthRecommendationResponse( user.getUserId(), - childAge, + childAgeMonths, recommendedPolicies, recommendedFacilities, "아이 연령에 맞는 정책/시설을 확인해보세요."); @@ -529,7 +503,6 @@ private void assertUserIdBelongsToActor(String userId, Long actorUserId) { // Child Entity를 DTO로 변환 - // Child 매핑은 ChildMapper 사용 // 예방접종 스케줄 응답 DTO 변환 private VaccineScheduleResponse convertToVaccineScheduleResponse(HealthRecord record) { diff --git a/src/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java index b700b34..1950e31 100644 --- a/src/test/java/com/carecode/integration/AccessControlContractTest.java +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -245,7 +245,13 @@ void publicDataReadStaysOpen(String path) throws Exception { "/api/public/care-facilities/swagger/stats", // → GET /notifications/preferences, GET /notifications/stats "/notifications/settings/user-1", - "/notifications/statistics/user-1" + "/notifications/statistics/user-1", + // → GET /children (목록을 받아 화면에서 고른다). 서버가 1~3개짜리 목록을 + // 걸러 주던 경로들이고, 필수 userId 파라미터를 받아서 무시하고 있었다. + "/health/children/age-range?userId=1&minAge=0&maxAge=5", + "/health/children/gender?userId=1&gender=FEMALE", + "/health/children/special-needs?userId=1", + "/health/children/search?userId=1&name=아" }) @WithMockUser(username = "admin@example.com", roles = "ADMIN") void duplicateMappingsRemoved(String path) throws Exception { diff --git a/src/test/java/com/carecode/integration/HealthRecommendationContractTest.java b/src/test/java/com/carecode/integration/HealthRecommendationContractTest.java new file mode 100644 index 0000000..77509d8 --- /dev/null +++ b/src/test/java/com/carecode/integration/HealthRecommendationContractTest.java @@ -0,0 +1,195 @@ +package com.carecode.integration; + +import com.carecode.CareCodeApplication; +import com.carecode.domain.policy.entity.Policy; +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.entity.UserRole; +import com.carecode.domain.user.repository.ChildRepository; +import com.carecode.domain.user.repository.UserRepository; +import com.carecode.domain.user.service.JwtService; +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.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +/** + * 연계 추천의 연령 단위를 고정한다. + * + *

정책의 {@code targetAgeMin/Max} 는 개월이다 — 시드 데이터가 "부모급여(0세) 0~11", + * "아동수당 0~95" 로 넣는다. 그런데 이 추천은 {@code Period.between(...).getYears()} 로 + * 연 나이를 계산해 그 쿼리에 넘겼다. 세 살 아이(36개월)가 "3" 으로 들어가므로 + * 0~11개월 대상 정책(부모급여·첫만남이용권·산후조리비)이 추천된다. + * + *

월령 계산이 코드 여섯 군데에 복제돼 있었는데, 그중 이 한 곳만 단위가 달랐다. 복제가 + * 위험한 이유가 이것이다 — 나머지 다섯 곳을 보고는 이 하나가 틀렸다는 걸 알 수 없다. + * + *

등록된 자녀가 없을 때도 같은 문제가 있었다. {@code .orElse(0)} 이 0개월로 바꿔 신생아 + * 정책을 추천했다. 모르는 값은 모르는 값으로 둬야 한다. + */ +@SpringBootTest( + classes = CareCodeApplication.class, + properties = { + "spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration," + + "org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration," + + "org.springframework.boot.autoconfigure.mail.MailSenderAutoConfiguration", + "spring.cache.type=none", + "spring.datasource.url=jdbc:h2:mem:carecode_health_reco;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.flyway.enabled=false", + "jwt.secret=testJwtSecretKeyForAccessControlTestMustBe256BitsLong0123456789", + "springdoc.api-docs.enabled=false", + "springdoc.swagger-ui.enabled=false", + "public.data.api.key=dummy", + "KAKAO_CLIENT_ID=dummy-kakao-client", + "KAKAO_CLIENT_SECRET=dummy-kakao-secret", + "MAIL_USERNAME=dummy", + "MAIL_PASSWORD=dummy" + } +) +@AutoConfigureMockMvc +@DisplayName("연계 추천 계약") +class HealthRecommendationContractTest { + + @MockBean RedisConnectionFactory redisConnectionFactory; + @MockBean StringRedisTemplate stringRedisTemplate; + @MockBean JavaMailSender javaMailSender; + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + @Autowired JwtService jwtService; + @Autowired UserRepository userRepository; + @Autowired ChildRepository childRepository; + @Autowired PolicyRepository policyRepository; + + private User parent; + + @BeforeEach + void setUp() { + policyRepository.deleteAll(); + parent = saveUser(); + + // 시드 데이터와 같은 단위(개월)로 넣는다. + savePolicy("부모급여(0세)", 0, 11); + savePolicy("세 살 대상 지원금", 24, 47); + } + + @Test + @DisplayName("세 살 아이에게 0~11개월 대상 정책을 추천하지 않는다") + void doesNotRecommendNewbornPoliciesToAThreeYearOld() throws Exception { + saveChild(LocalDate.now().minusMonths(36)); + + JsonNode body = json(mockMvc.perform(get("/health/recommendations") + .header("Authorization", "Bearer " + token(parent))).andReturn()); + + assertThat(body.path("childAge").asInt()) + .as("개월로 와야 한다. 3 이면 연 나이를 그대로 넘긴 것이다") + .isEqualTo(36); + + var titles = toList(body); + assertThat(titles).as("세 살 아이의 추천").contains("세 살 대상 지원금"); + assertThat(titles).as("0~11개월 대상은 들어가면 안 된다").doesNotContain("부모급여(0세)"); + } + + /** + * 자녀가 없으면 월령을 알 수 없다. 0 으로 바꾸면 신생아 정책이 추천되고, 사용자는 그것을 + * "내 아이 기준 추천" 으로 읽는다. + */ + @Test + @DisplayName("등록된 자녀가 없으면 나이를 지어내지 않는다") + void noChildMeansNoFabricatedAge() throws Exception { + JsonNode body = json(mockMvc.perform(get("/health/recommendations") + .header("Authorization", "Bearer " + token(parent))).andReturn()); + + assertThat(body.path("childAge").isNull()) + .as("자녀가 없을 때 childAge: %s", body.path("childAge")) + .isTrue(); + assertThat(toList(body)).as("나이를 모르면 연령 기반 추천을 내지 않는다").isEmpty(); + } + + @Test + @DisplayName("생년월일이 없는 자녀만 있어도 나이를 지어내지 않는다") + void missingBirthDateMeansNoFabricatedAge() throws Exception { + saveChild(null); + + JsonNode body = json(mockMvc.perform(get("/health/recommendations") + .header("Authorization", "Bearer " + token(parent))).andReturn()); + + assertThat(body.path("childAge").isNull()).isTrue(); + } + + private java.util.List toList(JsonNode body) { + java.util.List titles = new java.util.ArrayList<>(); + body.path("recommendedPolicies").forEach(n -> titles.add(n.asText())); + return titles; + } + + private void savePolicy(String title, int ageMinMonths, int ageMaxMonths) { + policyRepository.save(Policy.builder() + .policyCode("TEST-" + UUID.randomUUID().toString().substring(0, 8)) + .title(title) + .description(title) + .targetAgeMin(ageMinMonths) + .targetAgeMax(ageMaxMonths) + .isActive(true) + .build()); + } + + private void saveChild(LocalDate birthDate) { + childRepository.save(Child.builder() + .user(parent) + .name("아이") + .birthDate(birthDate) + .gender("FEMALE") + .createdAt(LocalDateTime.now()) + .build()); + } + + private JsonNode json(MvcResult result) throws Exception { + String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + assertThat(result.getResponse().getStatus()).as(body).isEqualTo(200); + return objectMapper.readTree(body); + } + + private String token(User user) { + return jwtService.generateAccessToken(user.getUserId(), user.getEmail(), user.getRole().name()); + } + + private User saveUser() { + String id = UUID.randomUUID().toString().substring(0, 8); + return userRepository.save(User.builder() + .userId("user_" + id) + .email(id + "@example.com") + .password("{noop}unused") + .name("보호자" + id) + .role(UserRole.PARENT) + .isActive(true) + .emailVerified(true) + .registrationCompleted(true) + .createdAt(LocalDateTime.now()) + .build()); + } +}