Skip to content

refactor: Map 응답 17곳을 DTO 로 바꾼다 - #138

Merged
RosieOh merged 2 commits into
mainfrom
refactor/typed-responses
Oct 5, 2026
Merged

RosieOh merged 2 commits into
mainfrom
refactor/typed-responses

Conversation

@RosieOh

@RosieOh RosieOh commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

Refs #132 (3b 단계) · #137 다음에 머지해 주세요 (3a 위에 올라가 있습니다)

컨트롤러가 Map<String, Object> 를 돌려주면 OpenAPI 에는 type: object 만 남습니다. 키도 타입도 스펙에 없으니, 공들여 만든 프런트 계약 대조(openapi-contract.test.ts)가 그 경로에서는 경로가 있는지만 확인하고 응답은 아무것도 확인하지 못합니다.

3a 에서 중복 7곳을 지우고, 남은 17곳을 레코드로 바꿨습니다.

어디 DTO
대기 등록·내 대기 목록 WaitlistRegisterResponse, WaitlistEntryResponse
좋아요·북마크 토글·개수 PostLikeToggleResponse, PostBookmarkToggleResponse, PostLikeCountResponse, PostBookmarkCountResponse
카카오 로그인 URL KakaoLoginUrlResponse
연계 추천 HealthRecommendationResponse
공공데이터 동기화 4종·좌표 보정·빈자리/마감 알림 SyncResultResponse, GeocodingResultResponse, VacancyNotifyResponse, DeadlineNotifyResponse
주기 작업 상태·예측 정확도 측정 SyncStatusResponse, ForecastAccuracyMeasureResponse
정책 검증·지역별 검증 현황 PolicyVerificationResponse, RegionVerificationStatusResponse
어드민 대시보드·예약 대시보드 AdminDashboardResponse, AdminBookingDashboardResponse
내 데이터 내려받기 MyDataExportResponse

깨질 수 있는 지점은 값이 아니라 키 이름이다

Map.put("isLiked", ...) 는 키를 그대로 쓰지만, 같은 값을 Lombok @Getter 클래스의 boolean isLiked 로 담으면 Jackson 이 is 를 떼어 키가 liked 로 나갑니다. 프런트는 isLiked 를 z.boolean() 으로 필수로 읽으므로 그 순간 토글이 파싱 단계에서 깨집니다. 이 프로젝트는 같은 사고를 isRead·isAnonymous·zScore 로 이미 겪었습니다.

레코드는 컴포넌트 이름을 그대로 키로 쓰므로 지금은 문제가 없습니다 — 임시 테스트로 직접 확인했습니다({"isLiked":true,"likeCount":7}). 다만 나중에 클래스로 바꾸면 되살아나므로 @JsonProperty 를 명시하고, JsonFieldNameContractTest 가 바꾼 응답 전부의 키 집합을 고정합니다. 키가 빠지는 것뿐 아니라 늘어나는 것도 계약 변경이므로 containsExactlyInAnyOrder 로 봅니다.

한 군데는 값도 고쳤습니다

내 데이터 내려받기의 자녀 생일·성별이 String.valueOf() 를 거쳐, 값이 없으면 "null" 이라는 문자열로 내려갔습니다. 열람권 행사로 받은 파일에 "null" 이 적혀 있는 셈입니다. 이제 없는 값은 null 로 보냅니다(프런트는 받은 JSON 을 그대로 파일로 저장합니다).

숫자

  • OpenAPI 스키마: 134 → 158 (경로 수는 그대로 231)
  • 프런트가 z.record(z.unknown()) 으로 받던 내려받기 응답에 처음으로 모양이 생겼습니다
  • 전체 테스트 609개, 실패 0

프런트 영향

응답 키는 전부 그대로입니다(위 계약 테스트로 고정). 머지 후 npm run sync:openapi 를 돌리면 프런트가 새 스키마를 받습니다. 내려받기 응답은 그때 z.record(z.unknown()) 대신 실제 모양으로 좁힐 수 있습니다.

Map<String, Object> 응답 24곳을 DTO 로 바꾸려고 하나씩 읽다가, 그중 7곳은
타입을 붙일 값이 아니라는 것을 알았다. 이미 같은 일을 하는 엔드포인트가 있었다.

지운 것 → 남겨 둔 것
- POST /api/public/care-facilities/sync-all
- GET  /api/public/care-facilities/swagger/sync
  → POST /api/admin/public-data/facilities/sync
  지운 쪽은 컨트롤러가 리포지토리에 직접 쓰고 50페이지 루프를 돌렸다. 남긴 쪽은
  SyncResult 로 신규·갱신·실패·제외를 세고 신선도 이력(SyncRunTracker)에 남긴다.
  swagger/sync 는 GET 이라 브라우저 접속이나 크롤러만으로 DB 쓰기가 실행됐다.
- GET /api/public/care-facilities/swagger/db-facilities → GET /facilities
- GET /api/public/care-facilities/swagger/stats        → GET /facilities/statistics
  success/message 로 한 겹 더 싼 것 말고 차이가 없었다.
- GET /notifications/settings/{userId} → GET /notifications/preferences
- PUT /notifications/settings/{userId} → PUT /notifications/preferences/...
- GET /notifications/statistics/{userId} → GET /notifications/stats

알림 쪽 세 개는 값이 코드에 박혀 있었다. 설정 조회는 고정값(이메일 켬, 조용한 시간
22:00-08:00)을 돌려주고, 설정 수정은 받은 값을 그대로 돌려주며 아무것도 저장하지
않았다. 통계는 우선순위 분포 HIGH=5, NORMAL=15, LOW=5 와 일별 건수 2024-01-15: 3 이
박혀 있었다. 응답이 그럴듯하면 프런트는 설정이 저장된다고 읽는다. 타입을 붙이면
그 거짓이 스펙에 정식으로 올라가므로, 타입화가 아니라 삭제가 맞다.

입구가 둘이면 보안 설정도 둘이다. /api/public/care-facilities/** 가 통째로 permitAll
이어서 그 아래 동기화 트리거까지 열려 있었고, 경로마다 hasRole("ADMIN") 을 덧붙여
막아 둔 상태였다. 중복을 지우면서 그 예외도 함께 사라졌다.

되살아나는 것을 막으려고 AccessControlContractTest.duplicateMappingsRemoved 가 일곱
경로를 두드려 404/405 를 확인한다. 동기화가 ADMIN 전용인지 보던 테스트는 남은
어드민 경로로 옮겼다.

- OpenAPI 스펙: 237 → 231 경로
- ArchUnit baseline: 391 → 383 (컨트롤러→리포지토리 66→61, 도메인 경계 271→268)
- 전체 테스트 607개, 실패 0

Refs #132
컨트롤러가 Map<String, Object> 를 돌려주면 OpenAPI 에는 type: object 만 남는다.
키도 타입도 스펙에 없으니, 프런트 계약 대조(openapi-contract.test.ts)가 그 경로에서는
경로가 있는지만 확인하고 응답은 아무것도 확인하지 못한다.

- 대기 등록·내 대기 목록 → WaitlistRegisterResponse, WaitlistEntryResponse
- 좋아요·북마크 토글·개수 → PostLikeToggleResponse, PostBookmarkToggleResponse,
  PostLikeCountResponse, PostBookmarkCountResponse
- 카카오 로그인 URL → KakaoLoginUrlResponse
- 연계 추천 → HealthRecommendationResponse
- 공공데이터 동기화 4종·좌표 보정·빈자리/마감 알림 → SyncResultResponse,
  GeocodingResultResponse, VacancyNotifyResponse, DeadlineNotifyResponse
- 주기 작업 상태·예측 정확도 측정 → SyncStatusResponse, ForecastAccuracyMeasureResponse
- 정책 검증·지역별 검증 현황 → PolicyVerificationResponse, RegionVerificationStatusResponse
- 어드민 대시보드·예약 대시보드 → AdminDashboardResponse, AdminBookingDashboardResponse
- 내 데이터 내려받기 → MyDataExportResponse

바꾸면서 깨질 수 있는 지점은 값이 아니라 키 이름이다. Map.put("isLiked", ...) 는 키를
그대로 쓰지만, 같은 값을 Lombok @Getter 클래스의 boolean isLiked 로 담으면 Jackson 이
is 를 떼어 키가 liked 로 나간다. 프런트는 isLiked 를 z.boolean() 으로 필수로 읽으므로
그 순간 토글이 파싱 단계에서 깨진다(isRead·isAnonymous·zScore 로 이미 겪은 사고다).

레코드는 컴포넌트 이름을 그대로 키로 쓰므로 지금은 문제가 없다 — 임시 테스트로 확인했다
({"isLiked":true,"likeCount":7}). 나중에 클래스로 바꾸면 되살아나므로 @JsonProperty 를
명시하고, JsonFieldNameContractTest 가 바꾼 응답 전부의 키 집합을 고정한다. 키가 빠지는
것뿐 아니라 늘어나는 것도 계약 변경이므로 containsExactlyInAnyOrder 로 본다.

한 군데는 값도 고쳤다. 내 데이터 내려받기의 자녀 생일·성별이 String.valueOf() 를 거쳐
값이 없으면 "null" 이라는 문자열로 내려갔다. 열람권 행사로 받은 파일에 "null" 이 적혀
있는 셈이다. 이제 없는 값은 null 로 보낸다(프런트는 받은 JSON 을 그대로 파일로 저장한다).

- OpenAPI 스키마: 134 → 158 (경로 수는 그대로 231)
- 프런트가 z.record(z.unknown()) 으로 받던 내려받기 응답에 처음으로 모양이 생겼다
- 전체 테스트 609개, 실패 0

Refs #132
@RosieOh
RosieOh merged commit c23800e into main Oct 5, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant