diff --git a/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df b/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df index e6bff58..8d3c743 100644 --- a/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df +++ b/archunit-baseline/05a5820d-39ea-4db6-b8e7-f737ac6a90df @@ -9,7 +9,6 @@ Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.admin.service.PolicyAdminService)> has parameter of type in (AdminPolicyController.java:0) Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (AdminPolicyVerificationController.java:0) Constructor (com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.service.UserService)> has parameter of type in (AdminUserController.java:0) -Constructor (com.carecode.external.publicdata.CareFacilityApiService, com.carecode.domain.facility.service.CareFacilityService, com.carecode.domain.facility.repository.CareFacilityRepository)> has parameter of type in (CareFacilityApiController.java:0) Constructor (com.carecode.domain.notification.repository.NotificationRepository, com.carecode.core.analytics.EventLogger, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (NotificationLinkController.java:0) Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.core.analytics.EventLogger, com.carecode.core.security.CurrentUserFacade)> has parameter of type in (BenefitLinkController.java:0) Field has type in (AdminCommunityController.java:0) @@ -23,7 +22,6 @@ Field has type in (AdminPolicyController.java:0) Field has type in (AdminPolicyVerificationController.java:0) Field has type in (AdminUserController.java:0) -Field has type in (CareFacilityApiController.java:0) Field has type in (NotificationLinkController.java:0) Field has type in (BenefitLinkController.java:0) Method calls method in (AdminCommunityController.java:46) @@ -58,9 +56,6 @@ Method calls method in (AdminUserController.java:177) Method calls method in (AdminUserController.java:56) Method calls method in (AdminUserController.java:134) -Method calls method in (CareFacilityApiController.java:86) -Method calls method in (CareFacilityApiController.java:96) -Method calls method in (CareFacilityApiController.java:266) Method calls method in (NotificationLinkController.java:56) Method calls method in (NotificationLinkController.java:64) Method calls method in (BenefitLinkController.java:43) diff --git a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 index bfa746f..db6675b 100644 --- a/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 +++ b/archunit-baseline/a6b21c55-f3df-4514-8faa-c32628f81448 @@ -217,15 +217,12 @@ notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:108) notification → user 리포지토리: Method calls method in (NotificationPreferenceService.java:344) notification → user 리포지토리: Method calls method in (NotificationService.java:97) -notification → user 리포지토리: Method calls method in (NotificationService.java:196) -notification → user 리포지토리: Method calls method in (NotificationService.java:238) notification → user 리포지토리: Method calls method in (NotificationService.java:382) notification → user 리포지토리: Method calls method in (NotificationService.java:56) notification → user 리포지토리: Method calls method in (NotificationService.java:183) notification → user 리포지토리: Method calls method in (NotificationService.java:172) notification → user 리포지토리: Method calls method in (NotificationService.java:313) notification → user 리포지토리: Method calls method in (NotificationService.java:325) -notification → user 리포지토리: Method calls method in (NotificationService.java:217) policy → notification 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.BenefitAmountReportRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (BenefitReportSolicitor.java:0) policy → notification 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyChangeRepository, com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyChangeNotifier.java:0) policy → notification 리포지토리: Constructor (com.carecode.domain.policy.repository.PolicyRepository, com.carecode.domain.policy.repository.PolicyDeadlineNoticeRepository, com.carecode.domain.user.repository.UserRepository, com.carecode.domain.user.repository.ChildRepository, com.carecode.domain.notification.repository.NotificationRepository, com.carecode.domain.notification.sender.NotificationDispatcher, com.carecode.core.analytics.EventLogger)> has parameter of type in (PolicyDeadlineNotifier.java:0) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 59eea87..3f0f7f6 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -7134,190 +7134,6 @@ "tags" : [ "공공데이터 API" ] } }, - "/api/public/care-facilities/swagger/db-facilities" : { - "get" : { - "operationId" : "swaggerGetDbFacilities", - "parameters" : [ { - "description" : "페이지 번호", - "example" : 0, - "in" : "query", - "name" : "page", - "required" : false, - "schema" : { - "default" : 0, - "format" : "int32", - "type" : "integer" - } - }, { - "description" : "한 페이지 결과 수", - "example" : 10, - "in" : "query", - "name" : "size", - "required" : false, - "schema" : { - "default" : 10, - "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" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "DB 저장된 보육시설 목록", - "tags" : [ "돌봄시설 공공데이터" ] - } - }, - "/api/public/care-facilities/swagger/stats" : { - "get" : { - "description" : "DB에 저장된 보육시설 통계 정보 조회", - "operationId" : "swaggerGetStats", - "parameters" : [ { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "보육시설 통계", - "tags" : [ "돌봄시설 공공데이터" ] - } - }, - "/api/public/care-facilities/swagger/sync" : { - "get" : { - "description" : "Swagger UI에서 쉽게 테스트할 수 있는 GET 방식 동기화", - "operationId" : "swaggerSync", - "parameters" : [ { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "Swagger용 보육시설 동기화", - "tags" : [ "돌봄시설 공공데이터" ] - } - }, - "/api/public/care-facilities/sync-all" : { - "post" : { - "description" : "공공데이터에서 모든 보육시설 정보를 조회하고 DB에 저장", - "operationId" : "syncAllChildcareFacilities", - "parameters" : [ { - "description" : "시도명", - "example" : "서울특별시", - "in" : "query", - "name" : "sido", - "required" : false, - "schema" : { - "default" : "서울특별시", - "type" : "string" - } - }, { - "description" : "시군구명", - "example" : "강남구", - "in" : "query", - "name" : "sigungu", - "required" : false, - "schema" : { - "default" : "강남구", - "type" : "string" - } - }, { - "description" : "한 페이지 결과 수", - "example" : 100, - "in" : "query", - "name" : "numOfRows", - "required" : false, - "schema" : { - "default" : 100, - "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" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "전체 보육시설 정보 동기화", - "tags" : [ "돌봄시설 공공데이터" ] - } - }, "/auth/kakao/complete-registration" : { "post" : { "description" : "카카오 로그인 후 신규 사용자의 이름과 역할을 설정하여 가입을 완료", @@ -13886,140 +13702,6 @@ "tags" : [ "알림 관리" ] } }, - "/notifications/settings/{userId}" : { - "get" : { - "operationId" : "getNotificationSettings", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "path", - "name" : "userId", - "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" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "알림 설정 조회", - "tags" : [ "알림 관리" ] - }, - "put" : { - "description" : "사용자의 알림 설정을 업데이트", - "operationId" : "updateNotificationSettings", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "path", - "name" : "userId", - "required" : true, - "schema" : { - "type" : "string" - } - }, { - "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", - "in" : "header", - "name" : "X-API-Version", - "required" : false, - "schema" : { - "default" : "1", - "type" : "string" - } - } ], - "requestBody" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "description" : "알림 설정", - "type" : "object" - } - } - }, - "required" : true - }, - "responses" : { - "200" : { - "content" : { - "application/json" : { - "schema" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "알림 설정 업데이트", - "tags" : [ "알림 관리" ] - } - }, - "/notifications/statistics/{userId}" : { - "get" : { - "description" : "사용자의 알림 관련 통계 조회", - "operationId" : "getNotificationStatistics", - "parameters" : [ { - "description" : "사용자 ID", - "in" : "path", - "name" : "userId", - "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" : { - "additionalProperties" : { - "type" : "object" - }, - "type" : "object" - } - } - }, - "description" : "OK" - } - }, - "summary" : "알림 통계 조회", - "tags" : [ "알림 관리" ] - } - }, "/notifications/stats" : { "get" : { "operationId" : "getNotificationStats", @@ -16237,9 +15919,6 @@ }, { "description" : "공공데이터 포털 API 호출", "name" : "공공데이터 API" - }, { - "description" : "공공데이터 포털의 돌봄시설 정보 API", - "name" : "돌봄시설 공공데이터" }, { "description" : "본인 계정 관리 API (프로필, 위치, 탈퇴)", "name" : "사용자" diff --git a/docs/quality/architecture-rules.md b/docs/quality/architecture-rules.md index a6887c7..605ad16 100644 --- a/docs/quality/architecture-rules.md +++ b/docs/quality/architecture-rules.md @@ -31,17 +31,20 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. ## 규칙과 현재 위반 -진척: **489건(0단계) → 391건(1b 단계)**. 남은 것은 도메인 경계(271)와 컨트롤러의 리포지토리 -직접 사용(66), core 기반의 도메인 참조(50)입니다. +진척: **489건(0단계) → 391건(1b 단계) → 383건(3a 단계)**. 남은 것은 도메인 경계(268)와 +컨트롤러의 리포지토리 직접 사용(61), core 기반의 도메인 참조(50)입니다. + +> 세는 법: `archunit-baseline/` 에는 규칙별 위반 파일 외에 색인 파일 `stored.rules` 도 있습니다. +> `cat archunit-baseline/* | wc -l` 은 색인 11줄을 함께 세므로 실제 위반보다 11 크게 나옵니다. | 규칙 | 위반 | 뜻 | |------|------|-----| -| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 271 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | +| 다른 도메인의 리포지토리를 직접 쓰지 않는다 | 268 | 남의 테이블을 직접 읽으면 그 도메인의 규칙(소유권 검증 등)을 건너뛴다 | | 도메인 패키지 이름은 소문자다 | **0** | `careFacility`→`facility` 로 해소 (1b 단계) | -| 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 66 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | +| 컨트롤러는 리포지토리를 직접 쓰지 않는다 | 61 | 검증·트랜잭션 경계가 컨트롤러로 샌다 | | core 기반 패키지는 domain 을 의존하지 않는다 | 50 | 기반 코드가 도메인을 알면 공통이 아니다 | | 매퍼는 리포지토리를 쓰지 않는다 | 3 | 변환 한 번에 숨은 쿼리가 생기면 N+1 을 추적할 수 없다 | -| core 에는 컨트롤러가 없다 | 2 | `core/controller/CareFacilityApiController` 373줄 | +| core 에는 컨트롤러가 없다 | **0** | 1a 에서 도메인으로 옮기고, 3a 에서 중복이라 삭제 | | 도메인은 SecurityContextHolder 를 직접 쓰지 않는다 | 1 | principal 모양이 바뀌면 고칠 곳이 흩어진다(시설 API 500 의 원인) | | 서비스·리포지토리는 컨트롤러를 의존하지 않는다 | **0** | 층 방향은 이미 지켜지고 있다 | | 엔티티는 DTO·컨트롤러를 의존하지 않는다 | **0** | 매핑 경계는 지켜지고 있다 | @@ -90,13 +93,40 @@ ArchUnit 의 `FreezingArchRule` 을 씁니다. 이동 PR 에서는 `freeze.refreeze=true` 로 한 번 다시 뜨고, 규칙별 숫자가 늘지 않았는지 확인한 뒤 다시 `false` 로 돌려놓습니다(그래야 CI 가 진짜 새 위반을 잡습니다). +## 같은 일을 하는 입구를 지웠다 (3a 단계) + +`Map` 응답 24곳을 타입으로 바꾸려고 하나씩 읽다가, 그중 7곳은 **타입을 붙일 값이 +아니라는 것**을 알았습니다. 이미 같은 일을 하는 다른 엔드포인트가 있었습니다. + +| 지운 것 | 같은 일을 하는, 남겨 둔 것 | 왜 지웠나 | +|---------|---------------------------|-----------| +| `POST /api/public/care-facilities/sync-all` | `POST /api/admin/public-data/facilities/sync` | 컨트롤러가 리포지토리에 직접 쓰고 페이지 루프를 돌렸다. 남긴 쪽은 `SyncResult` 와 신선도 이력을 남긴다 | +| `GET /api/public/care-facilities/swagger/sync` | 같음 | 위를 GET 으로 한 번 더 노출한 것. **GET 이라 브라우저 접속만으로 DB 쓰기가 실행됐다** | +| `GET /api/public/care-facilities/swagger/db-facilities` | `GET /facilities` | 응답을 `success`/`message` 로 한 겹 더 싸기만 했다 | +| `GET /api/public/care-facilities/swagger/stats` | `GET /facilities/statistics` | 같음 | +| `GET /notifications/settings/{userId}` | `GET /notifications/preferences` | 고정값을 돌려줬다 (이메일 켬, 조용한 시간 22:00–08:00) | +| `PUT /notifications/settings/{userId}` | `PUT /notifications/preferences/...` | **받은 값을 그대로 돌려주고 저장하지 않았다** | +| `GET /notifications/statistics/{userId}` | `GET /notifications/stats` | 우선순위 분포 `HIGH=5, NORMAL=15, LOW=5`, 일별 건수 `2024-01-15: 3` 이 코드에 박혀 있었다 | + +마지막 세 개가 이 단계의 핵심입니다. 응답이 그럴듯하면 프런트는 **설정이 저장된다고 읽습니다.** +타입을 붙이면 그 거짓이 스펙에 정식으로 올라가므로, 타입화가 아니라 삭제가 맞습니다. + +입구가 둘이면 보안 설정도 둘입니다. 실제로 `/api/public/care-facilities/**` 가 통째로 `permitAll` +이어서, 그 아래 동기화 트리거까지 열려 있었고 경로별로 `hasRole("ADMIN")` 을 덧붙여 막아 둔 +상태였습니다. 중복을 지우면서 그 예외 설정도 함께 사라졌습니다. + +되살아나는 것을 막으려고 `AccessControlContractTest.duplicateMappingsRemoved` 가 일곱 경로를 +모두 두드려 404/405 를 확인합니다(복사·붙여넣기로 돌아오기 쉬운 종류입니다). +OpenAPI 스펙은 237 → 231 경로가 됐습니다. + ## 다음 단계 | 단계 | 내용 | 효과 | |------|------|------| | ~~1a. core 정리~~ | ~~조합 계층 분리, core 의 도메인 코드 이동~~ | **완료** — core 에 컨트롤러 0 | | ~~1b. 이름 통일~~ | ~~`careFacility`→`facility`, 문서의 `facade`↔코드 `app`~~ | **완료** — 489 → 391 | -| 2. 예외 한 체계 | `catch(Exception)` 덮어쓰기 제거, ErrorCode 기반 통합 | 상태 코드 정확해짐 | -| 3. `Map` 응답 → DTO | 24곳. 스펙에 타입이 생겨 프런트 계약 대조가 이 경로까지 본다 | 계약 품질 | +| ~~2. 예외 한 체계~~ | ~~"찾을 수 없습니다" 34곳을 `ResourceNotFoundException` 으로~~ | **완료** — 500 → 404 | +| ~~3a. 중복 입구 제거~~ | ~~같은 일을 하는 엔드포인트 7개 삭제~~ | **완료** — 391 → 383 | +| 3b. `Map` 응답 → DTO | 남은 17곳. 스펙에 타입이 생겨 프런트 계약 대조가 이 경로까지 본다 | 계약 품질 | | 4. Child 를 제 자리로 | 엔티티는 `user`, 서비스는 `health` 에 쪼개져 있다 | 결합 1위 해소 | | 5. 거대 서비스 분해 | HealthService 977줄 | 2·3 과 함께 진행 | diff --git a/src/main/java/com/carecode/core/security/SecurityConfig.java b/src/main/java/com/carecode/core/security/SecurityConfig.java index 838d9a0..a3ef835 100644 --- a/src/main/java/com/carecode/core/security/SecurityConfig.java +++ b/src/main/java/com/carecode/core/security/SecurityConfig.java @@ -166,16 +166,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 돌봄시설 공공데이터 API — 조회만 공개다. // - // 동기화는 외부 공공데이터 API 를 페이지 단위로 호출하고 DB 에 쓴다. - // 공개로 두면 누구나 공공데이터 일일 한도를 태우고 DB 를 두드릴 수 있다. - // (이 프로젝트는 "공공데이터 한도 초과" 를 운영 알림으로 잡고 있는데, - // 그 상황을 외부에서 마음대로 만들 수 있는 셈이다.) - // swagger/sync 는 GET 이라 브라우저 접속이나 크롤러만으로도 실행된다. - // - // 같은 기능이 POST /api/admin/public-data/facilities/sync 로 이미 있다. - .requestMatchers("/api/public/care-facilities/sync-all").hasRole("ADMIN") - .requestMatchers("/api/public/care-facilities/swagger/sync").hasRole("ADMIN") - .requestMatchers("/api/public/care-facilities/**").permitAll() + // 동기화 트리거가 이 아래에도 있었다. /api/public/care-facilities/** 가 통째로 + // permitAll 이라 누구나 공공데이터 일일 한도를 태우고 DB 에 쓸 수 있었고, + // 경로마다 hasRole("ADMIN") 을 덧붙여 막아 두었다. 그 경로들은 어드민 동기화 + // (POST /api/admin/public-data/facilities/sync) 와 시설 조회(/facilities)의 + // 중복이어서 삭제했고, 덧붙였던 예외도 함께 지웠다. + // 입구가 하나면 보안 설정도 한 군데서 끝난다. // 병원 조회는 로그인 전에도 보여야 한다. 실제 경로가 /health/hospitals/** 라 // 아래 /health/** 규칙보다 먼저 선언해야 한다. diff --git a/src/main/java/com/carecode/domain/facility/controller/CareFacilityApiController.java b/src/main/java/com/carecode/domain/facility/controller/CareFacilityApiController.java deleted file mode 100644 index 11e52e9..0000000 --- a/src/main/java/com/carecode/domain/facility/controller/CareFacilityApiController.java +++ /dev/null @@ -1,373 +0,0 @@ -package com.carecode.domain.facility.controller; - -import com.carecode.external.publicdata.CareFacilityApiService; -import com.carecode.domain.facility.dto.response.CareFacilityInfo; -import com.carecode.domain.facility.dto.response.CareFacilityStatsResponse; -import com.carecode.domain.facility.entity.FacilityType; -import com.carecode.domain.facility.service.CareFacilityService; -import com.carecode.core.util.PageRequestUtil; -import com.carecode.domain.facility.entity.CareFacility; -import com.carecode.domain.facility.repository.CareFacilityRepository; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.Map; -import java.util.List; -import java.util.Optional; - -/** 돌봄시설 공공데이터 API 컨트롤러 */ -@Slf4j -@RestController -@RequestMapping("/api/public/care-facilities") -@RequiredArgsConstructor -@Tag(name = "돌봄시설 공공데이터", description = "공공데이터 포털의 돌봄시설 정보 API") -public class CareFacilityApiController { - - private final CareFacilityApiService careFacilityApiService; - private final CareFacilityService careFacilityService; - private final CareFacilityRepository careFacilityRepository; - private FacilityType facilityType; - - // 전체 보육시설 정보 동기화 (모든 데이터) - @PostMapping("/sync-all") - @Operation(summary = "전체 보육시설 정보 동기화", description = "공공데이터에서 모든 보육시설 정보를 조회하고 DB에 저장") - public ResponseEntity> syncAllChildcareFacilities( - @Parameter(description = "시도명", example = "서울특별시") @RequestParam(defaultValue = "서울특별시") String sido, - @Parameter(description = "시군구명", example = "강남구") @RequestParam(defaultValue = "강남구") String sigungu, - @Parameter(description = "한 페이지 결과 수", example = "100") @RequestParam(defaultValue = "100") int numOfRows) { - - try { - int totalSaved = 0; - int totalUpdated = 0; - int totalError = 0; - int pageNo = 1; - boolean hasMoreData = true; - - while (hasMoreData) { - try { - log.info("페이지 {} 처리 중...", pageNo); - - // 1. 공공데이터 API에서 데이터 조회 - Map publicData = careFacilityApiService.getChildcareFacilities(sido, sigungu, pageNo, numOfRows); - - // 2. 결과 확인 - Boolean success = (Boolean) publicData.get("success"); - if (success == null || !success) { - log.warn("페이지 {}에서 데이터 조회 실패: {}", pageNo, publicData.get("message")); - break; - } - - @SuppressWarnings("unchecked") - List> facilities = (List>) publicData.get("facilities"); - - if (facilities == null || facilities.isEmpty()) { - log.info("페이지 {}에 더 이상 데이터가 없습니다.", pageNo); - hasMoreData = false; - break; - } - - log.info("페이지 {}에서 {}개 시설 발견", pageNo, facilities.size()); - - // 3. DB에 저장 - 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++; - } else { - // 새 시설 생성 - CareFacility newFacility = createCareFacilityFromPublicData(facilityData); - careFacilityRepository.save(newFacility); - savedCount++; - } - - } catch (Exception e) { - errorCount++; - log.error("시설 데이터 저장 중 오류: {}", e.getMessage()); - } - } - - totalSaved += savedCount; - totalUpdated += updatedCount; - totalError += errorCount; - - log.info("페이지 {} 완료: 신규={}, 업데이트={}, 오류={}", pageNo, savedCount, updatedCount, errorCount); - - // 다음 페이지로 - pageNo++; - - // 너무 많은 페이지를 처리하지 않도록 제한 (안전장치) - if (pageNo > 50) { - log.warn("페이지 제한에 도달했습니다. (최대 50페이지)"); - break; - } - - } catch (Exception e) { - log.error("페이지 {} 처리 중 오류: {}", pageNo, e.getMessage()); - break; - } - } - - return ResponseEntity.ok(Map.of( - "success", true, - "message", "전체 보육시설 정보 동기화 완료", - "totalPages", pageNo - 1, - "totalSaved", totalSaved, - "totalUpdated", totalUpdated, - "totalError", totalError - )); - - } catch (Exception e) { - log.error("전체 보육시설 정보 동기화 실패: {}", e.getMessage(), e); - return ResponseEntity.badRequest().body(Map.of( - "success", false, - "message", "전체 보육시설 정보 동기화 실패: " + e.getMessage() - )); - } - } - - // Swagger UI용 간단한 보육시설 동기화 (GET 방식) - @GetMapping("/swagger/sync") - @Operation(summary = "Swagger용 보육시설 동기화", description = "Swagger UI에서 쉽게 테스트할 수 있는 GET 방식 동기화") - public ResponseEntity> swaggerSync() { - try { - log.info("Swagger용 보육시설 동기화 요청"); - - // 기본 파라미터로 전체 동기화 실행 - return syncAllChildcareFacilities("서울특별시", "강남구", 1000); - - } catch (Exception e) { - log.error("Swagger용 보육시설 동기화 실패: {}", e.getMessage(), e); - return ResponseEntity.badRequest().body(Map.of( - "success", false, - "message", "Swagger용 보육시설 동기화 실패: " + e.getMessage() - )); - } - } - - // DB 저장된 보육시설 목록 조회 - @GetMapping("/swagger/db-facilities") - @Operation(summary = "DB 저장된 보육시설 목록") - public ResponseEntity> swaggerGetDbFacilities( - @Parameter(description = "페이지 번호", example = "0") @RequestParam(defaultValue = "0") int page, - @Parameter(description = "한 페이지 결과 수", example = "10") @RequestParam(defaultValue = "10") int size) { - - try { - log.info("DB 저장된 보육시설 목록 조회 요청: page={}, size={}", page, size); - - // 전체를 읽어 subList 로 자르면 테이블이 커질수록 그대로 부하가 되고, start 가 목록 크기를 넘으면 IndexOutOfBoundsException 이 난다 - int safePage = PageRequestUtil.normalizePage(page); - int safeSize = PageRequestUtil.normalizeSize(size); - List pagedFacilities = careFacilityService.getAllCareFacilities(safePage, safeSize); - - return ResponseEntity.ok(Map.of( - "success", true, - "message", "DB 저장된 보육시설 목록 조회 성공", - "totalCount", careFacilityService.countCareFacilities(), - "currentPage", safePage, - "pageSize", safeSize, - "data", pagedFacilities - )); - - } catch (Exception e) { - log.error("DB 저장된 보육시설 목록 조회 실패: {}", e.getMessage(), e); - return ResponseEntity.badRequest().body(Map.of( - "success", false, - "message", "DB 저장된 보육시설 목록 조회 실패: " + e.getMessage() - )); - } - } - - // 보육시설 통계 - @GetMapping("/swagger/stats") - @Operation(summary = "보육시설 통계", description = "DB에 저장된 보육시설 통계 정보 조회") - public ResponseEntity> swaggerGetStats() { - try { - log.info("보육시설 통계 조회 요청"); - - // CareFacilityService를 통해 통계 조회 - CareFacilityStatsResponse stats = careFacilityService.getFacilityStats(); - - return ResponseEntity.ok(Map.of( - "success", true, - "message", "보육시설 통계 조회 성공", - "data", stats - )); - - } catch (Exception e) { - log.error("보육시설 통계 조회 실패: {}", e.getMessage(), e); - return ResponseEntity.badRequest().body(Map.of( - "success", false, - "message", "보육시설 통계 조회 실패: " + e.getMessage() - )); - } - } - - // 공공데이터로부터 새로운 CareFacility 엔티티 생성 - private CareFacility createCareFacilityFromPublicData(Map facilityData) { - try { - 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(parseDouble(facilityData.get("latitude"))) - .longitude(parseDouble(facilityData.get("longitude"))) - .website((String) facilityData.get("websiteUrl")) - .ageRange((String) facilityData.get("ageGroup")) - .operatingHours(formatOperatingHours(facilityData)) - .tuitionFee(parseInteger(facilityData.get("rentFee"))) - .isActive(true) - .isPublic(true) // 공공데이터는 주로 공립 시설 - .subsidyAvailable((Boolean) facilityData.get("isFree")) - .description(generateDescription(facilityData)) - .build(); - - } catch (Exception e) { - log.error("CareFacility 엔티티 생성 중 오류: {}", e.getMessage(), e); - throw new RuntimeException("CareFacility 엔티티 생성 실패: " + e.getMessage(), e); - } - } - - // 기존 CareFacility 엔티티를 공공데이터로 업데이트 - private void updateCareFacilityFromPublicData(CareFacility existingFacility, Map facilityData) { - try { - 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(parseDouble(facilityData.get("latitude"))); - existingFacility.setLongitude(parseDouble(facilityData.get("longitude"))); - existingFacility.setWebsite((String) facilityData.get("websiteUrl")); - existingFacility.setAgeRange((String) facilityData.get("ageGroup")); - existingFacility.setOperatingHours(formatOperatingHours(facilityData)); - existingFacility.setTuitionFee(parseInteger(facilityData.get("rentFee"))); - existingFacility.setSubsidyAvailable((Boolean) facilityData.get("isFree")); - existingFacility.setDescription(generateDescription(facilityData)); - - careFacilityRepository.save(existingFacility); - - } catch (Exception e) { - log.error("CareFacility 엔티티 업데이트 중 오류: {}", e.getMessage(), e); - throw new RuntimeException("CareFacility 엔티티 업데이트 실패: " + e.getMessage(), e); - } - } - - // 서비스 타입을 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(); - } - - // Double 파싱 헬퍼 메서드 - private Double parseDouble(Object value) { - if (value == null || value.toString().trim().isEmpty()) { - return null; - } - try { - return Double.parseDouble(value.toString()); - } catch (NumberFormatException e) { - log.warn("Double 파싱 실패: {}", value); - return null; - } - } - - // Integer 파싱 헬퍼 메서드 - private Integer parseInteger(Object value) { - if (value == null || value.toString().trim().isEmpty()) { - return null; - } - try { - return Integer.parseInt(value.toString()); - } catch (NumberFormatException e) { - log.warn("Integer 파싱 실패: {}", value); - return null; - } - } -} diff --git a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java index c70d902..e299419 100644 --- a/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java +++ b/src/main/java/com/carecode/domain/notification/app/NotificationFacade.java @@ -134,15 +134,6 @@ public List getUnreadNotifications(String userId) { return notificationService.getUnreadNotifications(userId); } - @Transactional(readOnly = true) - public Map getNotificationSettings(String userId) { return notificationService.getNotificationSettings(userId); } - - @Transactional - public Map updateNotificationSettings(String userId, Map settings) { return notificationService.updateNotificationSettings(userId, settings); } - - @Transactional(readOnly = true) - public Map getNotificationStatistics(String userId) { return notificationService.getNotificationStatistics(userId); } - @Transactional(readOnly = true) public List getUserPreferences(String userId) { return preferenceService.getUserPreferences(userId); } diff --git a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java index 535ff99..1cb2bbe 100644 --- a/src/main/java/com/carecode/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/carecode/domain/notification/controller/NotificationController.java @@ -151,39 +151,14 @@ public ResponseEntity> getUnreadNotifications() { return ResponseEntity.ok(notifications); } - // 알림 설정 조회 - @GetMapping("/settings/{userId}") - @LogExecutionTime - @Operation(summary = "알림 설정 조회") - public ResponseEntity> getNotificationSettings(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { - requireNotificationUserIdMatchesCurrent(userId); - Map settings = notificationFacade.getNotificationSettings(getAuthenticatedUserCode()); - - return ResponseEntity.ok(settings); - } - - // 알림 설정 업데이트 - @PutMapping("/settings/{userId}") - @LogExecutionTime - @Operation(summary = "알림 설정 업데이트", description = "사용자의 알림 설정을 업데이트") - public ResponseEntity> updateNotificationSettings(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId, - @Parameter(description = "알림 설정", required = true) @RequestBody Map settings) { - requireNotificationUserIdMatchesCurrent(userId); - Map updatedSettings = notificationFacade.updateNotificationSettings(getAuthenticatedUserCode(), settings); - - return ResponseEntity.ok(updatedSettings); - } - - // 알림 통계 조회 - @GetMapping("/statistics/{userId}") - @LogExecutionTime - @Operation(summary = "알림 통계 조회", description = "사용자의 알림 관련 통계 조회") - public ResponseEntity> getNotificationStatistics(@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) { - requireNotificationUserIdMatchesCurrent(userId); - Map statistics = notificationFacade.getNotificationStatistics(getAuthenticatedUserCode()); - - return ResponseEntity.ok(statistics); - } + /* + * 알림 설정 조회·수정과 통계는 아래 /preferences 와 /stats 로만 받는다. + * + * 한때 같은 일을 하는 /settings/{userId}(GET·PUT) 와 /statistics/{userId} 가 있었다. + * 둘은 설정을 저장하지 않고 받은 값을 그대로 돌려주거나 고정값(이메일 켬, 조용한 시간 22:00, + * 우선순위 분포 HIGH=5 …)을 돌려줬다. 응답이 그럴듯해서 "설정이 저장된다" 고 읽히는 쪽이 + * 설정이 없는 것보다 위험하므로 삭제했다. 실제 저장·조회는 /preferences 가 한다. + */ // 알림 설정 목록 조회 @GetMapping("/preferences") diff --git a/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java b/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java index 1bd6020..de8abaf 100644 --- a/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/carecode/domain/notification/repository/NotificationRepository.java @@ -37,9 +37,6 @@ List findByDateRange(@Param("userId") Long userId, // 사용자별 알림 개수 조회 long countByUserId(Long userId); - // 읽지 않은 알림 개수 조회 - long countByUserIdAndIsReadFalse(Long userId); - // 알림 타입별 개수 조회 long countByUserIdAndNotificationType(Long userId, Notification.NotificationType notificationType); diff --git a/src/main/java/com/carecode/domain/notification/service/NotificationService.java b/src/main/java/com/carecode/domain/notification/service/NotificationService.java index a44beb1..26ccb7f 100644 --- a/src/main/java/com/carecode/domain/notification/service/NotificationService.java +++ b/src/main/java/com/carecode/domain/notification/service/NotificationService.java @@ -191,73 +191,6 @@ public List getUnreadNotifications(String userId) { .collect(Collectors.toList()); } - // 알림 설정 조회 - @LogExecutionTime - public Map getNotificationSettings(String userId) { - User user = userRepository.findByUserId(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); - - Map settings = new HashMap<>(); - settings.put("userId", userId); - settings.put("emailEnabled", true); - settings.put("pushEnabled", true); - settings.put("smsEnabled", false); - settings.put("enabledTypes", strategyFactory.getSupportedNotificationTypes()); - settings.put("quietHoursStart", "22:00"); - settings.put("quietHoursEnd", "08:00"); - - return settings; - } - - // 알림 설정 업데이트 - @Transactional - public Map updateNotificationSettings(String userId, Map settings) { - log.info("알림 설정 업데이트 - 사용자 ID: {}", userId); - - try { - User user = userRepository.findByUserId(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); - - // 실제로는 NotificationSettings 엔티티를 업데이트 - Map updatedSettings = new HashMap<>(settings); - updatedSettings.put("userId", userId); - updatedSettings.put("updatedAt", LocalDateTime.now().toString()); - - return updatedSettings; - } catch (Exception e) { - log.error("알림 설정 업데이트 실패: {}", e.getMessage()); - throw new CareServiceException("알림 설정 업데이트 중 오류가 발생했습니다.", e); - } - } - - // 알림 통계 조회 - @LogExecutionTime - public Map getNotificationStatistics(String userId) { - log.info("알림 통계 조회 - 사용자 ID: {}", userId); - - try { - User user = userRepository.findByUserId(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + userId)); - - long totalNotifications = notificationRepository.countByUserId(user.getId()); - long unreadCount = notificationRepository.countByUserIdAndIsReadFalse(user.getId()); - long readCount = totalNotifications - unreadCount; - - Map statistics = new HashMap<>(); - statistics.put("userId", userId); - statistics.put("totalNotifications", totalNotifications); - statistics.put("unreadCount", unreadCount); - statistics.put("readCount", readCount); - statistics.put("typeDistribution", calculateTypeDistribution(user.getId())); - statistics.put("priorityDistribution", Map.of("HIGH", 5, "NORMAL", 15, "LOW", 5)); - statistics.put("dailyNotificationCount", Map.of("2024-01-15", 3, "2024-01-16", 2)); - - return statistics; - } catch (Exception e) { - log.error("알림 통계 조회 실패: {}", e.getMessage()); - throw new CareServiceException("알림 통계 조회 중 오류가 발생했습니다.", e); - } - } // 기존 메서드들 (호환성을 위해 유지) diff --git a/src/test/java/com/carecode/integration/AccessControlContractTest.java b/src/test/java/com/carecode/integration/AccessControlContractTest.java index 18b8d41..b700b34 100644 --- a/src/test/java/com/carecode/integration/AccessControlContractTest.java +++ b/src/test/java/com/carecode/integration/AccessControlContractTest.java @@ -196,43 +196,66 @@ void privilegedMappingsRemovedFromUserApi(String path) throws Exception { /** * 공공데이터 동기화는 외부 API 한도를 태우고 DB 에 쓴다. 공개로 두면 누구나 실행할 수 있다. * - *

{@code /api/public/care-facilities/**} 가 통째로 permitAll 이라, 그 아래 있는 - * 동기화 트리거까지 열려 있었다. {@code swagger/sync} 는 GET 이라 브라우저 접속이나 - * 크롤러만으로도 실행된다. + *

한때 같은 동기화가 {@code /api/public/care-facilities/**}(통째로 permitAll) 아래에도 + * 있었고, 그중 하나는 GET 이라 브라우저 접속이나 크롤러만으로 실행됐다. 그 중복 경로는 + * 삭제했고(아래 {@code duplicateMappingsRemoved}), 남은 입구는 어드민 경로 하나뿐이다. */ @Test @DisplayName("공공데이터 동기화는 비로그인으로 실행할 수 없다") void publicDataSyncIsNotOpen() throws Exception { - assertThat(mockMvc.perform(post("/api/public/care-facilities/sync-all")).andReturn() + assertThat(mockMvc.perform(post("/api/admin/public-data/facilities/sync")).andReturn() .getResponse().getStatus()) .as("POST 동기화가 열려 있으면 안 된다") .isIn(401, 403); - - assertThat(mockMvc.perform(get("/api/public/care-facilities/swagger/sync")).andReturn() - .getResponse().getStatus()) - .as("GET 동기화는 브라우저 접속만으로도 실행된다") - .isIn(401, 403); } @Test @DisplayName("일반 회원도 공공데이터 동기화를 실행할 수 없다") @WithMockUser(username = "member@example.com", roles = "PARENT") void publicDataSyncRequiresAdmin() throws Exception { - assertThat(mockMvc.perform(post("/api/public/care-facilities/sync-all")).andReturn() + assertThat(mockMvc.perform(post("/api/admin/public-data/facilities/sync")).andReturn() .getResponse().getStatus()).isEqualTo(403); } - /** 시설·정책 조회는 계속 공개여야 한다. 위 제한이 조회까지 막으면 안 된다. */ + /** 시설 조회는 계속 공개여야 한다. 위 제한이 조회까지 막으면 안 된다. */ @ParameterizedTest(name = "{0} 은 여전히 공개다") @ValueSource(strings = { - "/api/public/care-facilities/swagger/stats", - "/api/public/care-facilities/swagger/db-facilities" + "/facilities", + "/facilities/statistics" }) void publicDataReadStaysOpen(String path) throws Exception { assertThat(mockMvc.perform(get(path)).andReturn().getResponse().getStatus()) .isNotIn(401, 403); } + /** + * 같은 일을 하는 입구가 여러 개면 보안 설정도 그만큼 중복된다. 실제로 동기화 입구가 둘이어서 + * 한쪽만 ADMIN 으로 잠겨 있었다. + * + *

삭제한 중복 경로가 다시 생기면(복사·붙여넣기로 되살아나기 쉽다) 여기서 걸린다. + * 괄호 안은 같은 일을 하는, 남겨 둔 경로다. + */ + @ParameterizedTest(name = "{0} 매핑은 더 이상 존재하지 않는다") + @ValueSource(strings = { + // → POST /api/admin/public-data/facilities/sync + "/api/public/care-facilities/sync-all", + "/api/public/care-facilities/swagger/sync", + // → GET /facilities, GET /facilities/statistics + "/api/public/care-facilities/swagger/db-facilities", + "/api/public/care-facilities/swagger/stats", + // → GET /notifications/preferences, GET /notifications/stats + "/notifications/settings/user-1", + "/notifications/statistics/user-1" + }) + @WithMockUser(username = "admin@example.com", roles = "ADMIN") + void duplicateMappingsRemoved(String path) throws Exception { + MvcResult result = mockMvc.perform(get(path)).andReturn(); + + assertThat(result.getResponse().getStatus()) + .as("%s 는 매핑이 없어야 한다 (404/405)", path) + .isIn(404, 405); + } + /** 버전 필터가 실제 필터 체인에 걸려 있는지. 단위 테스트만으로는 등록 누락을 모른다. */ @Test @DisplayName("응답에 API 버전 헤더가 붙고, 모르는 버전은 400 이다") diff --git a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java index ea90578..bea4110 100644 --- a/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java +++ b/src/test/java/com/carecode/integration/EndpointAuthorizationCoverageTest.java @@ -113,10 +113,6 @@ class EndpointAuthorizationCoverageTest { "GET /community/latest", "GET /community/tags", "GET /community/search/all", "GET /community/popular/limit", "GET /community/latest/limit", - // 공공데이터 조회 (동기화 트리거는 ADMIN 이다) - "GET /api/public/care-facilities/swagger/stats", - "GET /api/public/care-facilities/swagger/db-facilities", - // 조회수 증가. 쓰기지만 비로그인 방문자의 조회도 세야 하므로 공개다. // 다만 이 값이 /facilities/popular 순위에 쓰이므로 부풀릴 수 있다. // 인기 순위를 조작에 민감하게 다뤄야 한다면 여기부터 손봐야 한다.