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
35 changes: 14 additions & 21 deletions archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448

Large diffs are not rendered by default.

200 changes: 0 additions & 200 deletions docs/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : "등록된 모든 병원 정보 조회",
Expand Down
108 changes: 105 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) → 357건(5b)**. 남은 것은 도메인 경계(242)와
컨트롤러의 리포지토리 직접 사용(61), core 기반의 도메인 참조(50)입니다.

> 세는 법: `archunit-baseline/` 에는 규칙별 위반 파일 외에 색인 파일 `stored.rules` 도 있습니다.
> `cat archunit-baseline/* | wc -l` 은 색인 11줄을 함께 세므로 실제 위반보다 11 크게 나옵니다.

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

## 아이 조회를 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**

## 다음 단계

| 단계 | 내용 | 효과 |
Expand All @@ -239,4 +339,6 @@ 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. 아이 조회를 health 에서 뺀다~~ | ~~`/health/children/*` 4개가 `/children` 과 중복~~ | **완료** — 362 → 357, 단위 버그 발견 |
| 5c. 남은 책임 분리 | HealthService 688줄은 아직 기록·일정·알림·통계를 함께 들고 있다 | 읽기 쉬움 |
Loading
Loading