Skip to content

Repository files navigation

CareCode App (맘편한)

부모와 자녀를 위한 육아 정보 플랫폼 "맘편한"의 프론트엔드입니다. CareCode 백엔드(Spring Boot)의 REST API를 소비합니다.

하나의 소스에서 웹과 네이티브 앱(Android · iOS)을 모두 만듭니다. 화면 코드는 한 벌이고, 갈라지는 것은 빌드 설정과 몇 군데의 환경 분기뿐입니다.

웹 앱
명령 npm run build npm run build:app
결과물 서버가 있는 Next 앱 정적 파일 묶음 → WebView 에 동봉

앱 빌드·자격증명·스토어 제출은 docs/앱-빌드와-배포.md 에 따로 정리돼 있습니다. 이 문서는 화면과 API 쪽 규약을 다룹니다.

이 저장소는 CareCode_FE 에서 갈라져 나왔습니다. 웹에서 고친 것이 앱으로 자동으로 오지 않으므로 .upstream-sync 로 반영 지점을 관리하고, 매주 밀린 변경을 이슈로 알립니다. 절차는 위 문서의 "웹 저장소와의 관계".

기술 스택

영역 사용 기술
프레임워크 Next.js 15 (App Router) · React 19
상태·서버 TanStack Query v5 · zustand
스타일 Tailwind CSS v4 · Radix UI
폼·검증 react-hook-form · zod
HTTP axios (src/apis/interceptor.ts)

시작하기

npm ci
cp .env.example .env.local   # NEXT_PUBLIC_API_URL 을 백엔드 주소로 설정
npm run dev

http://localhost:3000 에서 확인할 수 있습니다. 백엔드 CORS 허용 목록(CORS_ALLOWED_ORIGINS)에 이 주소가 포함돼 있어야 합니다.

환경 변수

이름 설명
NEXT_PUBLIC_API_URL CareCode 백엔드 베이스 URL (예: http://localhost:8080)
NEXT_PUBLIC_FIREBASE_* 웹 푸시(FCM) 설정. 선택 — 비우면 푸시 기능 전체가 꺼집니다
NEXT_PUBLIC_FIREBASE_VAPID_KEY 웹 푸시 인증서 공개 키. 위와 함께 있어야 동작합니다
NEXT_PUBLIC_DEV_LOGIN_EMAIL / _PASSWORD 개발용 빠른 로그인 계정. 선택 — 비우면 버튼이 나오지 않습니다

디렉터리 구조

src/
├── apis/         도메인별 HTTP 호출 + zod 응답 검증
├── queries/      TanStack Query 훅 (query-key-factory 기반 키)
├── types/apis/   서버 DTO 대응 zod 스키마 & 타입
├── app/          App Router 라우트
│   ├── (with-tabs)/     하단 탭이 있는 화면 (커뮤니티·육아 정보·홈·챗봇·마이페이지)
│   ├── (without-tabs)/  단독 화면 (아이 관리·시설·게시글 상세·알림·회원가입 등)
│   ├── admin/           관리자 (자체 레이아웃 + AdminGuard)
│   └── auth/            OAuth 콜백 (화면이 아니라 통과 지점이라 그룹 밖)
├── components/
│   ├── common/   디자인 시스템 단위 컴포넌트
│   └── features/ 도메인 컴포넌트
└── utils/        날짜·파일 등 순수 유틸

모든 화면은 두 그룹 중 하나에 들어갑니다. 그룹 밖에 두면 어느 레이아웃도 받지 못해 스크롤 컨테이너를 페이지마다 다시 짜게 됩니다. 특히 하단 탭의 목적지는 반드시 (with-tabs)/ 여야 합니다 — 그룹 밖 경로를 탭에 넣으면 그 탭을 누르는 순간 탭 바가 사라져 다른 탭으로 돌아갈 수 없습니다.

의존성 규칙

import 하는 패키지는 반드시 package.json 에 선언합니다. 한동안 framer-motion 과 radix 서브패키지 5개가 선언 없이 다른 패키지의 전이 의존성 호이스팅으로만 동작했습니다. npm 에서는 우연히 동작하지만 pnpm·yarn PnP 로 옮기거나 상위 패키지가 의존성을 정리하면 그날로 빌드가 깨집니다. motion 은 motion/react 경로 하나로만 import 합니다.

API 레이어 규칙

  • apis/* 는 요청 body/param 을 zod 로 parse 한 뒤 보내고, 응답도 parse 해서 반환합니다. 서버 DTO 가 바뀌면 여기에서 즉시 드러납니다.
  • 서버가 null 을 줄 수 있는 필드는 nullish() 로 둡니다. 공공데이터 동기화 결과(시설 등)는 상당수 항목이 비어 있습니다.
  • queries/* 만 컴포넌트에서 import 합니다. 컴포넌트가 apis/* 를 직접 부르는 것은 조회수 집계처럼 캐시가 필요 없는 fire-and-forget 호출로 한정합니다.

인증

토큰을 JS 로 읽을 수 있는 저장소에 두지 않는 것이 원칙입니다.

  • 액세스 토큰: 모듈 메모리(src/apis/auth.ts)에만 둡니다. 저장소에 남기면 XSS 로 그대로 읽힙니다.
  • 리프레시 토큰: 웹에서는 서버가 HttpOnly 쿠키로 심습니다(Path=/auth). 프런트는 값을 알지도, 다루지도 않습니다. 앱에서는 그럴 수 없습니다 — WebView 의 출처가 https://localhost(Android) / capacitor://localhost(iOS) 라서 API 서버로 가는 쿠키가 서드파티 쿠키가 되고, iOS WKWebView 가 이를 기본 차단합니다. 그래서 앱만 응답 본문의 토큰을 받아 Keychain / EncryptedSharedPreferences 에 보관하고 갱신 요청에 실어 보냅니다 (src/apis/session.ts). 서버가 갱신마다 토큰을 교체하므로 새 토큰 저장을 기다립니다 — 던져 두면 그 직후 앱이 종료됐을 때 다음 실행에서 이유 없이 로그아웃됩니다.
  • 새로고침: 메모리가 비므로 <SessionBootstrap> 이 부팅 시 갱신을 한 번 시도해 세션을 복구합니다. 복구가 끝날 때까지 하위 화면 렌더를 미뤄 불필요한 401 을 막습니다.
  • 401 처리: 인터셉터가 갱신 후 원 요청을 한 번만 재시도합니다. 동시 요청이 여러 개 401 을 받아도 갱신 호출은 하나로 묶입니다(single-flight).
  • 가드: 로그인이 필요한 화면은 <AuthGuard> 로 감쌉니다. 토큰이 서버에서 보이지 않아 미들웨어로는 판별할 수 없기 때문입니다.

백엔드는 쿠키가 없으면 요청 본문의 refreshToken 도 계속 받습니다. 쿠키를 쓸 수 없는 클라이언트(모바일 네이티브 등)와 함께 동작해야 하기 때문입니다.

프로필 완성도 응답은 불리언 맵입니다

GET /users/profile/completion 은 이런 모양입니다.

{ "completionPercentage": 20, "complete": false,
  "missingFields": { "needsAddress": true, "needsGender": true, ... } }

프런트 스키마는 completionRate 와 missingFields: string[] 을 기다리고 있었습니다. 모든 필드가 nullish() 라 파싱은 통과하고 값만 전부 undefined 가 되어 완성도 0%, 빠진 항목 없음처럼 조용히 틀렸고, complete 를 못 읽어 이미 다 채운 사용자에게도 안내가 계속 떴습니다. 계약 테스트로 고정했습니다.

주소가 비면 지역별 지원금 비교와 주변 시설 추천이 아예 동작하지 않는데 그 사실을 알려주는 곳이 없었습니다. 마이페이지 상단에서 빠진 항목을 이름으로 알려주고 수정 화면으로 보냅니다.

안 읽은 알림은 종 아이콘에 표시합니다

IconButton 에 showBadge 가 있고 조회 훅도 있었지만 실제 화면에서 아무도 넘기지 않아 (컴포넌트 갤러리에서만 썼습니다) 알림이 와도 알림함에 들어가 보기 전까지 알 수 없었습니다. useHasUnreadNotifications() 를 상단바가 있는 다섯 화면에서 씁니다 — 쿼리 키가 같아 요청은 한 번만 나갑니다.

작성자 판별은 어느 식별자인지 확인하고 씁니다

서버는 사용자를 두 가지로 가리키고, 응답마다 담는 쪽이 다릅니다.

값 예시 담기는 곳
userId user_1787417490710_394 토큰·세션, 시설 리뷰의 userId
id 2 게시글·댓글의 authorId, 병원 리뷰의 userId

한쪽만 보고 비교하면 항상 거짓이 되어 본인 글에도 수정·삭제가 뜨지 않습니다 — 실제로 게시글 상세가 그 상태였습니다(getUserId() === post.authorId). useCurrentUser() 가 둘을 함께 돌려주므로, 비교할 필드가 어느 쪽인지 확인하고 골라 씁니다.

아이 수정은 전체 교체입니다

PUT /children/{id} 는 보내지 않은 필드를 null 로 만듭니다(ChildService.updateChild). 그래서 수정 화면은 현재 값을 모두 읽어와 채운 뒤 저장해야 합니다.

specialNeeds(알레르기·기저질환)는 등록 요청은 받으면서 응답에는 없어서 읽어올 방법이 없었고, 이름만 고쳐도 특이사항이 지워졌습니다. 백엔드 ChildInfoResponse·ChildMapper 에 필드를 추가해 왕복이 되도록 고쳤습니다.

토큰 응답의 신원은 항상 user 안에 있습니다

서버 TokenDto 에는 최상위 userId/email/role 필드가 있지만 AuthServiceImpl.issueTokenForUser 는 이 셋을 채우지 않습니다. 로그인·갱신·카카오 로그인 모두 신원을 중첩된 user 로만 내려줍니다.

이 값을 최상위에서 필수로 읽고 있어서 두 가지가 조용히 망가져 있었습니다.

  • 일반 로그인: 200 과 토큰을 받고도 zod 파싱에서 실패해 한 번도 성공한 적이 없었습니다.
  • 세션 복구: POST /auth/refresh 도 같은 모양이라 SessionBootstrap 이 파싱 실패를 세션 만료로 보고 clearTokens() 를 불렀습니다. 결과적으로 새로고침할 때마다 로그아웃됐고, 인터셉터의 401 → 갱신 → 재시도도 마지막 단계에서 항상 무너졌습니다.

둘 다 화면에는 아무 표시가 나지 않는 종류라 계약 테스트로 고정했습니다 (postLoginResponseSchema / postRefreshTokenResponseSchema).

역할 값도 한 곳에서만 정의합니다. 로그인 응답 스키마가 ['PARENT', 'CHILD'] 로 좁혀져 있어 서버에 없는 CHILD 를 기다리는 대신 실제 값인 CAREGIVER·ADMIN·GUEST 를 거부했습니다. 지금은 types/apis/user.ts 의 USER_ROLE 이 정본이고 나머지는 이를 참조합니다.

첫 렌더는 서버와 클라이언트가 같아야 합니다

SessionBootstrap 이 useState(() => hasStoredSession() && ...) 로 시작하면, 서버에서는 localStorage 를 읽을 수 없어 false(children 렌더), 클라이언트에서는 true(대기 화면 렌더)가 되어 모든 페이지에서 hydration 이 깨집니다. 저장소를 읽는 판단은 effect 안에서만 하고, 첫 렌더는 양쪽 모두 대기 화면으로 시작합니다.

개발용 빠른 로그인

카카오 로그인은 실제 앱 키와 등록된 리다이렉트 URI 가 있어야 해서 로컬에서는 쓸 수 없습니다. 그러면 로그인 뒤 화면(아이 관리·건강 기록·마이페이지)을 전혀 확인할 수 없으므로, 일반 로그인을 쓰는 개발 전용 버튼을 로그인 화면에 둡니다.

# 1) 백엔드에 개발 계정을 만든다 (role 을 ADMIN 으로 주면 /admin 까지 확인할 수 있다)
curl -X POST http://localhost:8082/auth/register   -H 'Content-Type: application/json'   -d '{"email":"dev@carecode.local","password":"devpassword123!","name":"dev","role":"PARENT"}'

# 2) .env.local 에 계정을 넣는다
NEXT_PUBLIC_DEV_LOGIN_EMAIL=dev@carecode.local
NEXT_PUBLIC_DEV_LOGIN_PASSWORD=devpassword123!

버튼은 두 겹으로 막혀 있습니다 — NODE_ENV 는 빌드 시 상수로 치환되므로 분기 전체가 죽은 코드가 되어 제거되고, 계정 정보는 환경변수로만 들어옵니다. 값이 없으면 버튼이 렌더되지 않습니다. 환경변수를 채운 채로 프로덕션 빌드를 돌려도 번들에 이메일·비밀번호·컴포넌트 이름이 남지 않는 것을 확인했습니다.

동의 기반 접근 차단

건강·의료 정보는 개인정보보호법상 민감정보라 서버가 HEALTH_DATA 동의 없이는 저장을 막고 403 { error: "CONSENT_REQUIRED", consentType, displayName, ... } 를 내려줍니다.

같은 403 이라도 권한 부족은 막다른 길이지만 동의 미완료는 동의만 하면 풀리므로 화면에서 구분합니다. parseConsentRequired()(src/apis/errors.ts) 로 판별하고 <ConsentRequiredDialog> 를 띄워 그 자리에서 동의를 받은 뒤 막혔던 동작을 자동으로 재시도합니다. 설정 화면으로 보내면 사용자가 입력하던 내용을 잃기 때문입니다.

주요 기능

지원금 — 서비스의 핵심 가치

  • 놓친 지원금: 자녀가 대상이었으나 지나간 지원금과 소급 신청 가능 여부, 남은 기간
  • 지역별 지원금 비교: 지역별 예상 수령액 순위와 현재 거주지 대비 차액
  • 맞춤 추천: 자녀 월령·거주지 기반 추천 (추천 근거를 함께 노출)
  • 정책 북마크

신청 링크는 반드시 getPolicyApplyUrl() 이 만드는 /policies/{id}/apply 를 거칩니다. 서버가 클릭을 집계한 뒤 실제 신청처로 리다이렉트하며, 이 전환율이 "지원금을 실제로 찾아줬는지" 를 보여주는 유일한 지표입니다. websiteUrl 로 직접 연결하면 안 됩니다.

그 외

  • 카카오 로그인 / 회원가입
  • 정책 조회·검색, AI 챗봇 상담
  • 커뮤니티: 글 작성·수정·삭제, 댓글, 좋아요·북마크, 신고
  • 아이 관리: 등록 시 표준 예방접종 일정 자동 생성, 접종 완료 처리, WHO 기준 성장 곡선
  • 건강 기록: 검진·투약·증상 기록, 키·몸무게 측정값(성장 곡선의 입력), 첨부파일 업로드
  • 병원 찾기: 검색, 내 주변 반경 조회, 요양기관 종별 필터, 상세·리뷰·찜
  • 시설: 검색·필터(유형·정원 여유·보조금), 상세, 리뷰 작성, 방문 예약 및 예약 취소
  • 알림: 목록, 읽음·모두 읽음, 알림을 통한 재방문 집계, 유형·채널별 수신 설정, 웹 푸시 기기 등록
  • 시설 입소 가능성: 정원 관측 기반 예측, 실제 대기 기간 통계, 충원율 추이
  • 대기 관리: 대기 신청 기록, 입소·포기 결과 남기기 (이 기록이 다른 부모의 통계가 됩니다)
  • 자녀 통합 현황: 다자녀 가구를 위한 접종·대기·다자녀 혜택 한눈에 보기
  • 약관·처리방침 원문 열람
  • 마이페이지: 프로필 수정, 나의 활동(좋아요·북마크한 글·북마크한 지원금), 내 예약, 차단한 사용자, 개인정보 동의·동의 이력·데이터 내보내기·탈퇴, 프로필 완성도 안내
  • 커뮤니티 정리: 본인 글·댓글 수정·삭제, 신고, 사용자 차단(마이페이지에서 해제)
  • 리뷰: 시설·병원 리뷰 작성과 본인 리뷰 수정·삭제
  • 챗봇: 지난 상담 내역(세션별 문답 다시 보기)

관리자 (/admin)

/admin 은 요약 대시보드 + 섹션 인덱스입니다. 앱 셸이 모바일 폭(max-w-sm)이라 탭을 늘리면 넘치므로 탭 바 대신 인덱스에서 각 섹션으로 들어가는 구조로 두었습니다.

경로 내용
/admin 건수 요약, 신규 가입 추이, 최근 활동, 섹션 목록
/admin/reports 신고 숨김/반려
/admin/bookings 예약 확정·반려, 대기/확정/오늘 현황
/admin/users 역할 변경, 계정 정지/해제
/admin/community 게시글 직접 삭제
/admin/policies/manage 정책 등록·수정·삭제
/admin/policies 지역별 금액 검증률
/admin/hospitals 병원 목록·삭제
/admin/public-data 시설·유치원·정책·병원 동기화, 좌표 보정
/admin/analytics 온보딩 퍼널(이탈 구간 강조), 코호트 리텐션, 이벤트 건수
/admin/notifications 알림 발송·삭제
/admin/health-records 건강기록 목록·삭제 (민감정보)
/admin/sample-data 샘플 데이터 적재·제거 (개발 환경 전용)

별도 어드민 앱을 만들지 않고 같은 앱에 역할 기반 라우트로 두었습니다. <AdminGuard> 는 권한 없는 사용자가 빈 화면과 403 을 보지 않게 하는 안내일 뿐이고, 실제 통제는 서버(/api/admin/** → hasRole("ADMIN"))가 합니다. 어드민 쿼리는 useIsAdmin() 으로 역할을 확인한 뒤에만 나가 불필요한 403 을 만들지 않습니다.

운영 중 실수를 막기 위해 지킨 규칙:

  • 본인 계정의 역할·상태는 바꿀 수 없습니다. 스스로 관리자에서 내려오면 계정이 잠깁니다.
  • 관리자 승격 시 권한 범위를 한 번 더 알립니다.
  • 공공데이터 동기화는 한 번에 하나만 실행됩니다. 동시에 돌리면 외부 API 호출 한도를 넘길 수 있습니다. 응답이 수 분 걸리므로 이 요청만 타임아웃을 10분으로 늘려 두었습니다.
  • 신고를 거친 건은 신고 처리 화면에서 다루도록 안내합니다 — 처리 기록이 남기 때문입니다.
  • 예약은 상태 변경(취소)을 기본으로 씁니다. 하드 삭제 API 는 레이어에만 두고 화면에 버튼을 만들지 않았습니다 — 지우면 누가 언제 취소했는지 기록이 사라집니다.
  • 건강기록은 민감정보라 목록에 측정값을 표시하지 않고, 열람 주의 문구를 함께 둡니다.
  • 샘플 데이터 화면은 page.dev.tsx 라 개발 서버에서만 열리고 프로덕션 번들에서 빠집니다.

정책 수정은 PATCH 입니다

PUT /api/admin/policies/{id} 는 요청 값으로 정책 전체를 교체합니다. 보내지 않은 필드는 null 이 되므로, 일부만 고칠 때는 PATCH 를 씁니다. 프런트 수정 화면은 PATCH 만 사용합니다.

서버는 값의 null 여부가 아니라 요청 JSON 에 그 키가 있었는지로 판단합니다.

요청 결과
키 없음 기존 값 유지
키 있음 + 값 있음 그 값으로 변경
키 있음 + null 해당 항목을 비움

null 만으로 판단하면 "비우기" 와 "건드리지 않기" 를 구분할 수 없어 둘 중 하나는 불가능해집니다. 그래서 프런트도 이 구분을 지켜야 합니다 — toPolicyPatchBody() 는 폼이 다루는 항목만 키로 넣고, 입력이 비었으면 undefined 가 아니라 null 을 담습니다 (JSON 직렬화에서 undefined 는 키째 사라져 "유지" 가 되어버립니다).

조회도 사용자용 PolicyDto 가 아니라 AdminPolicyDetailResponse 를 씁니다. PolicyDto 는 화면 표시용으로 값을 가공하기 때문에 (신청 기간을 "2026.01.01 ~ 2026.12.31" 문자열로 합치고 policyCode 는 아예 내려주지 않음) 그 값으로는 수정 폼을 채울 수 없습니다.

이 규약은 양쪽 테스트가 고정합니다:

  • PolicyAdminServicePatchTest — 키 존재 여부에 따른 유지/변경/비우기
  • src/components/features/admin/__tests__/policyForm.test.ts — 프런트가 보내는 키의 모양

약관 버전은 서버가 정합니다

동의 이력에 남는 값이라 GET /legal/version(useLegalVersion)에서 받아 씁니다. 프런트에 상수로 박아 두면 서버가 개정할 때 무엇에 동의했는지 증명할 수 없게 됩니다 — 실제로 한동안 프런트가 v1, 서버가 v1.0 으로 어긋나 있었습니다. 버전을 아직 못 받았으면 동의 토글과 버튼을 비활성화합니다.

알림 열람 집계만 fetch 를 씁니다

GET /notifications/{id}/open 은 클릭을 기록하고 읽음 처리한 뒤 딥링크(carecode://)로 302 를 줍니다. 모바일 앱을 위한 설계라 웹에서 리다이렉트를 따라가면 실패합니다.

그래서 이 호출만 axios 대신 fetch(..., { redirect: 'manual' }) 을 씁니다. 요청은 전달되어 집계·읽음 처리가 이뤄지고, 응답은 무시하고 화면 이동은 앱 안에서 직접 합니다. 집계 실패가 사용자의 이동을 막으면 안 되므로 오류는 삼킵니다.

알림 설정은 채널 단위로 저장합니다

PUT /notifications/preferences 는 조회 응답 DTO 를 그대로 본문으로 받습니다. 화면에 없는 값(emailAddress, deviceToken 등)까지 함께 덮어써서, 토글 하나 바꾸려다 기기 토큰을 지울 수 있습니다. 그래서 PUT /notifications/preferences/{type}/channels/{channel} 을 씁니다. 한 번에 한 채널만 바꾸므로 건드리지 않은 값이 사라지지 않습니다. 채널 이름은 서버가 toLowerCase() 로 분기하므로 inapp(inApp 아님)·push·email·sms 만 보냅니다.

조회는 유형별 설정 배열이고, 한 번도 설정한 적 없으면 빈 배열입니다. 서버가 준 것만 그리면 신규 사용자에게 빈 화면이 나오므로 5개 유형 전체를 기본값 위에 덮어 그립니다.

이 화면을 붙이면서 백엔드 두 곳을 함께 고쳤습니다.

  • 기본 설정 행이 이메일을 켠 채로 생성돼, 토글을 하나 끄는 순간(그때 행이 만들어지면서) 요청한 적 없는 이메일 알림이 켜졌습니다. 설정 행이 없을 때 실제로 발송되는 채널 (앱 알림함·푸시)과 같도록 맞췄습니다.
  • disable-all 이 저장된 행만 껐습니다. 설정을 한 번도 건드린 적 없는 사용자는 "모두 끄기"를 눌러도 알림이 계속 왔습니다. 행이 없는 유형까지 끄도록 고쳤습니다.
  • 푸시 토큰 등록은 SYSTEM 설정 행에만 저장하는데 발송은 알림 유형별 행에서 토큰을 읽었습니다. 그래서 SYSTEM 을 뺀 모든 푸시가 조용히 실패했습니다(지원금 마감도, 예방접종 알림도). 토큰은 기기의 성질이지 알림 유형의 성질이 아니므로 유형과 무관하게 찾도록 고쳤습니다.

두 값은 어긋나면 조용히 틀리는 종류라 양쪽에 테스트로 고정했습니다.

못 쓰는 채널은 이유와 함께 잠급니다

이메일은 발신 주소 설정이, 푸시는 FCM 자격증명이 있어야 나갑니다. 문자는 아직 사업자 연동 전이라 켜도 절대 발송되지 않습니다(SmsNotificationSender.send() 가 항상 실패). 전부 서버 설정에서 오는 사정이라 클라이언트가 알 방법이 없습니다.

그대로 두면 사용자는 토글을 켜 두고 오지 않는 알림을 기다리게 됩니다. 그래서 GET /notifications/channels 로 채널별 가용 여부와 그 이유를 내려주고, 화면은 못 쓰는 채널을 꺼진 상태로 잠근 뒤 이유를 함께 보여줍니다. "왜 못 켜는지" 를 밝히지 않으면 고장으로 보입니다.

이유는 각 발송기가 직접 답합니다(NotificationSender.getUnavailableReason()). 왜 못 쓰는지는 채널마다 다르고 발송기 자신만 알기 때문입니다.

반대로 채널 상태 조회가 실패하면 전부 사용 가능으로 봅니다. 응답을 못 받았다고 토글을 잠그면 멀쩡한 설정까지 못 바꾸게 되는데, 그건 이 기능이 없던 때보다 나쁩니다.

가용 여부는 서버 설정뿐 아니라 이 사용자에게 보낼 수단이 있는지까지 봅니다. 발송기가 살아 있어도 이메일 주소·전화번호·등록된 기기가 없으면 알림은 오지 않습니다. 다만 둘은 사용자에게 뜻이 다릅니다.

  • SERVER_NOT_CONFIGURED — 사용자가 할 수 있는 일이 없습니다. 안내만 합니다.
  • NO_DESTINATION — 등록하면 해결됩니다. 이때만 기기 등록 버튼을 띄웁니다.

문구는 바뀔 수 있으므로 화면은 unavailableReason 문자열이 아니라 reasonCode 로 판단합니다. 서버 설정 문제가 수신처 문제보다 먼저 안내됩니다 — 둘 다 문제인데 "번호를 등록하세요" 라고 하면 사용자가 등록하고도 알림을 받지 못합니다.

푸시는 웹과 앱이 다른 길로 갑니다

화면은 src/apis/push.ts 하나만 봅니다. 그 안에서 웹(브라우저 Notification + 서비스 워커)과 앱(네이티브 FCM, src/apis/pushNative.ts)이 갈립니다.

앱에서 @capacitor/push-notifications 대신 @capacitor-firebase/messaging 을 쓰는 이유: 서버가 FCM 으로 쏘는데(PushNotificationSender) 전자는 iOS 에서 APNs 원시 토큰을 줍니다. 그 토큰을 FCM 에 넣으면 발송이 실패해 안드로이드만 되고 iOS 는 조용히 안 오는 상태가 됩니다.

알림을 탭해서 앱이 열린 경우도 받습니다. 웹은 서비스 워커가 알림함을 열어 주지만 (firebase-messaging-sw.js 의 notificationclick) 앱에는 그 서비스 워커가 없습니다.

아래는 웹 쪽 이야기입니다.

NEXT_PUBLIC_FIREBASE_* 와 VAPID 키가 모두 있어야 푸시 기능이 동작합니다. 하나라도 비면 isPushConfigured() 가 false 를 반환하고, 설정 화면은 푸시를 잠근 채 등록 버튼을 감춥니다. 자격증명 없이 초기화를 시도해봐야 콘솔만 더럽히고 서버 발송기도 어차피 비활성입니다.

firebase SDK 는 항상 지연 로딩합니다. 이 모듈은 루트 레이아웃에서 쓰이므로 정적으로 import 하면 SDK 가 모든 페이지 첫 로딩에 실립니다. 푸시를 설정하지 않은 환경에서는 한 번도 쓰지 않을 코드입니다.

권한 요청은 사용자가 버튼을 누를 때만 합니다. 화면에 들어오자마자 물으면 대부분 거절하고, 한 번 거절하면 브라우저 설정에서 직접 풀기 전까지 다시 물을 수 없습니다.

앱이 열려 있는 동안 도착한 푸시는 시스템 알림이 뜨지 않습니다. PushListener 가 이때 목록과 안 읽음 배지를 다시 읽습니다. 그러지 않으면 알림은 왔는데 화면은 그대로입니다.

시설 검색은 엔드포인트가 둘입니다

  • /facilities/search — 키워드·지역으로 좁힘 (페이지 응답)
  • /facilities/advanced-search — 정원 여유·보조금 같은 조건으로 거름 (배열 응답, 키워드 없음)

조건 필터를 켜면 후자로 전환되고, 키워드는 클라이언트에서 마저 적용합니다.

추정치는 추정치로 보여줍니다

입소 예측·대기 통계·충원율은 표본이 모자라면 서버가 available: false 와 unavailableReason 을 내려줍니다. 이때 숫자를 지어내지 않고 이유를 그대로 보여줍니다. 근거 없는 확률은 없느니만 못합니다. 예측이 가능할 때도 confidence 와 관측량(일수·횟수)을 함께 노출합니다.

금액 표기 규칙

추정치를 확정 금액처럼 보여주면 안 됩니다. 서버가 dataQuality(VERIFIED/PARTIAL/ESTIMATED)와 disclaimers 를 함께 내려주므로 금액 옆에 항상 노출합니다. 금액을 모르는 정책은 0원 이 아니라 "금액 미상" 으로 표시합니다 — 둘은 사용자에게 전혀 다른 의미입니다.

스크립트

npm run dev        # 개발 서버 (turbopack)
npm run build      # 웹 프로덕션 빌드
npm run lint       # ESLint + Prettier (설정 파일 포함 전체)
npm run lint:fix   # 자동 수정
npm run typecheck  # tsc --noEmit
npm test           # Vitest

npm run build:app  # 앱용 정적 빌드 (out/)
npm run app:sync   # build:app + 네이티브 프로젝트로 복사
npm run app:android  # app:sync + Android Studio 열기
npm run app:ios      # app:sync + Xcode 열기 (맥에서만)

next lint 는 Next 15.3 에서 deprecated 되어 16 에서 제거되므로 eslint . 를 직접 씁니다. src/ 만 보던 예전과 달리 next.config.ts 같은 설정 파일도 검사 대상입니다.

이 네 가지는 PR 마다 CI(.github/workflows/ci.yml)에서 함께 돌아갑니다 — 여기에 빌드까지 더해 다섯 단계입니다. 계약 테스트가 통과해도 서버 컴포넌트 경계 문제로 빌드가 깨질 수 있어 빌드를 따로 둡니다.

확장자로 라우트 걸러내기

next.config.ts 의 pageExtensions 로 빌드마다 포함할 라우트를 가릅니다.

  • *.dev.tsx — 개발 서버에서만. 컴포넌트 갤러리 /component-test 가 이 방식입니다.
  • *.web.tsx — 서버가 있어야 도는 라우트. 앱 빌드에서 통째로 빠집니다. /policy/[id] 같은 동적 상세 주소가 여기 해당합니다. 정적 export 는 [id] 에 들어갈 값을 빌드 시점에 전부 알아야 하는데, 임의의 게시글·지원금 id 를 미리 알 수 없기 때문입니다.

앱 안에서의 이동은 언제나 src/utils/routes.ts 를 거쳐 쿼리 주소(/policy/detail?id=)로 갑니다. 두 주소가 같은 화면 컴포넌트를 렌더합니다 — 자세한 내용은 docs/앱-빌드와-배포.md 참고.

테스트

Vitest + Testing Library (jsdom) 로 세 층을 덮습니다.

계약 테스트 — src/types/apis/__tests__/contracts.test.ts 백엔드 응답 DTO 를 그대로 옮긴 픽스처로 zod 스키마를 검증합니다. 서버가 필드를 바꾸거나 null 을 내리기 시작하면 화면이 아니라 여기서 먼저 깨지는 것이 목적이므로, 값이 다 채워진 경우보다 비어 있는 경우를 우선 검증합니다.

컴포넌트 테스트 — src/components/**/__tests__/*.test.tsx 분기가 있는 컴포넌트만 다룹니다. 스타일이 아니라 사용자가 실제로 보고 누르는 것 (접근 가능한 이름, aria-pressed, 비활성 상태)을 기준으로 검증합니다.

인터셉터 테스트 — src/apis/__tests__/interceptor.test.ts 401 → 갱신 → 재시도 경로는 틀려도 화면에 아무 표시가 나지 않고 사용자만 이유 없이 로그아웃됩니다. 갱신이 요청 수만큼 나가지 않는지(single-flight), 재시도가 한 번으로 끝나는지, 갱신 API 자체의 401 을 갱신 대상으로 오해하지 않는지를 고정합니다.

설정 메모:

  • @vitejs/plugin-react 는 이 프로젝트의 @babel/core 와 peer 충돌이 있어 쓰지 않습니다. 테스트에는 Fast Refresh 가 필요 없으므로 esbuild 의 JSX 변환(esbuild.jsx: 'automatic')만 켭니다.
  • SVG 는 앱에서 @svgr/webpack 으로 컴포넌트가 되지만 Vitest 에는 그 로더가 없어 src/test/svg-mock.tsx 로 별칭 처리합니다. 정규식 별칭은 매칭된 부분만 치환하므로 /^.*\.svg$/ 처럼 경로 전체를 잡아야 합니다.

커밋 컨벤션

commitlint.config.cjs 를 따릅니다.

  • .husky/commit-msg — 커밋 메시지 형식 검사
  • .husky/pre-commit — npm run lint

라이선스

이 프로젝트의 소스 코드는 MIT 라이선스를 따릅니다. Copyright (c) 2025-2026 CareCode. 전문은 LICENSE 파일에 있습니다.

About

맘편한 — 하나의 소스에서 웹과 네이티브 앱(Android·iOS)을 만든다

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages