Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,9 @@ health → user 리포지토리: Method <com.carecode.domain.health.service.Chil
health → user 리포지토리: Method <com.carecode.domain.health.service.ChildService.requireOwnedChild(java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (ChildService.java:106)
health → user 리포지토리: Method <com.carecode.domain.health.service.ChildService.updateChild(java.lang.Long, com.carecode.domain.health.dto.request.ChildCreateRequest)> calls method <com.carecode.domain.user.repository.ChildRepository.save(java.lang.Object)> in (ChildService.java:81)
health → user 리포지토리: Method <com.carecode.domain.health.service.GrowthChartService.requireOwnedChild(java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (GrowthChartService.java:85)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.analyzeHealthStatus(com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest, java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (HealthService.java:311)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.createHealthRecord(com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest, java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (HealthService.java:99)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.findUserByIdOrUserId(java.lang.String)> calls method <com.carecode.domain.user.repository.UserRepository.findById(java.lang.Object)> in (HealthService.java:879)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.findUserByIdOrUserId(java.lang.String)> calls method <com.carecode.domain.user.repository.UserRepository.findByUserId(java.lang.String)> in (HealthService.java:871)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.generateHealthReport(com.carecode.domain.health.dto.request.HealthCreateHealthRecordRequest, java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findById(java.lang.Object)> in (HealthService.java:334)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.getChildrenByAgeRange(java.lang.Long, java.lang.Integer, java.lang.Integer)> calls method <com.carecode.domain.user.repository.ChildRepository.findByUserIdAndAgeRange(java.lang.Long, java.lang.Integer, java.lang.Integer)> in (HealthService.java:266)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.getChildrenByGender(java.lang.Long, java.lang.String)> calls method <com.carecode.domain.user.repository.ChildRepository.findByUserIdAndGender(java.lang.Long, java.lang.String)> in (HealthService.java:277)
health → user 리포지토리: Method <com.carecode.domain.health.service.HealthService.getChildrenWithSpecialNeeds(java.lang.Long)> calls method <com.carecode.domain.user.repository.ChildRepository.findByUserIdAndHasSpecialNeeds(java.lang.Long)> in (HealthService.java:288)
Expand Down
48 changes: 45 additions & 3 deletions docs/quality/architecture-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ 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` 도 있습니다.
> `cat archunit-baseline/* | wc -l` 은 색인 11줄을 함께 세므로 실제 위반보다 11 크게 나옵니다.

| 규칙 | 위반 | 뜻 |
|------|------|-----|
| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 249 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 |
| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 247 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 |
| 도메인 패키지 이름은 소문자다 | **0** | `careFacility`→`facility` 로 해소 (1b 단계) |
| 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 61 | 검증·트랜잭션 경계가 컨트롤러로 샌다 |
| core 기반 패키지는 domain 을 의존하지 않는다 | 50 | 기반 코드가 도메인을 알면 공통이 아니다 |
Expand Down Expand Up @@ -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 의 입구로 옮기는 것이 다음 자리입니다.

## 다음 단계

| 단계 | 내용 | 효과 |
Expand All @@ -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 는 아직 기록·아이 조회·일정·알림·통계를 함께 들고 있다 | 읽기 쉬움 |
Original file line number Diff line number Diff line change
Expand Up @@ -52,170 +52,6 @@ public class CareFacilityService {
private final CareFacilityMapper careFacilityMapper;
private final FullTextSearchSupport fullTextSearchSupport;

// 공공데이터 API에서 받아온 보육시설 데이터를 DB에 저장
@Transactional
public void saveCareFacilitiesFromPublicData(Map<String, Object> publicData) {
List<Map<String, Object>> facilities = (List<Map<String, Object>>) publicData.get("facilities");

if (facilities == null || facilities.isEmpty()) {
log.warn("저장할 보육시설 데이터가 없습니다.");
return;
}

int savedCount = 0;
int updatedCount = 0;
int errorCount = 0;

for (Map<String, Object> facilityData : facilities) {
try {
String facilityId = (String) facilityData.get("facilityId");

// 기존 시설이 있는지 확인
Optional<CareFacility> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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();
}

// 돌봄 시설 목록 조회

/** 시설 목록 조회. 테이블 전체를 메모리로 올리지 않도록 항상 페이지 단위로 읽는다. */
Expand All @@ -228,11 +64,6 @@ public List<CareFacilityInfo> getAllCareFacilities(int page, int size) {
.collect(Collectors.toList());
}

// 전체 시설 수
public long countCareFacilities() {
return careFacilityRepository.count();
}

// 돌봄 시설 상세 조회
@LogExecutionTime
@Cacheable(cacheNames = "careFacility", key = "#facilityId")
Expand Down
24 changes: 0 additions & 24 deletions src/main/java/com/carecode/domain/health/app/HealthFacade.java
Original file line number Diff line number Diff line change
Expand Up @@ -122,34 +122,10 @@ public List<com.carecode.domain.health.dto.response.ChildInfoResponse> searchChi
return healthService.searchChildrenByName(userId, name);
}

// ====================
// 건강 분석 및 리포트 ====================
public Map<String, Object> analyzeHealthStatus(HealthCreateHealthRecordRequest request, Long actorUserId) {
return healthService.analyzeHealthStatus(request, actorUserId);
}

public Map<String, Object> generateHealthReport(HealthCreateHealthRecordRequest request, Long actorUserId) {
return healthService.generateHealthReport(request, actorUserId);
}

public Map<String, Object> getHealthGoals(String userId, Long actorUserId) {
return healthService.getHealthGoals(userId, actorUserId);
}

public HealthRecommendationResponse getIntegratedRecommendations(String userId, Long actorUserId) {
return healthService.getIntegratedRecommendations(userId, actorUserId);
}

public List<Map<String, Object>> 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<String, Object> checkSystemHealth() {
return healthService.checkSystemHealth();
}

// ==================== 병원 관리 ====================
// 병원 관련 작업은 Facade에서 직접 처리하므로 트랜잭션 필요 하지만
public List<HospitalInfoResponse> getAllHospitals(int page, int size) {
Expand Down
Loading
Loading