커뮤니티 도메인 ‐ FE 테크스펙 문서 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
사용자가 커뮤니티 글 조회부터 작성, 동행모집 참여까지 매끄럽고 직관적인 인터페이스를 통해 빠르고 쉽게 커뮤니티 기능을 사용할 수 있도록 UX를 극대화하고, 프론트엔드 성능과 개발 효율성을 고려해 설계한다.
요구사항 분석 문서: 커뮤니티 도메인 요구사항 분석
화면: 커뮤니티 도메인 Figma
백엔드 API 명세: API 링크
이번 프로젝트에서 다루지 않는 내용:
- 커뮤니티 활동에 따른 알림 설계: 알림 도메인 테크스펙 문서에서 다룰 예정입니다.
- 커뮤니티 동행모집 채팅 설계: 채팅 도메인 테크스펙 문서에서 다룰 예정입니다.
-
프레임워크/라이브러리 : next.js(v16) + react(v19)
- SSR/ISR 활용 및 라우팅, 이미지최적화 등 편의성 고려
-
상태관리: zustand
- 보일러플레이트가 적고 Context API 대비 성능 이점, Redux보다 쉬운 사용성
- UI 컴포넌트: base ui 라이브러리 사용
-
스타일링: tailwind CSS
- 개발 편의성 및 SEED ui 호환성 고려
-
폼 관리: React Hook Form
- 폼 상태 관리 및 유효성 검증 효율화
-
데이터 페칭: Tanstack Query
- 캐싱, 재시도, 무한스크롤, 서버상태관리 용이성
-
SSR / ISR / SSG 적용 범위
- 페이지와 레이아웃은 Server Component를 기본으로 사용
- 사용자 인터렉션이 필요한 영역만 Client Component로 분리
-
/posts/[postId]- 게시글 본문: ‘use cache’ 기반 ondemand ISR
- 댓글 목록: SSR
- 댓글 인풋+전송버튼 : Client Component 사용해서 hydration
-
/: 홈화면
-
주요 기능: 주변 핀 게시글 조회, 지도 이동
-
사용 컴포넌트 :
-
BottomNav (바텀네비바)
-
CommunityViewTabs (커뮤니티/혼잡도 전환 탭)
-
Map (지도영역)
-
Logo ( 로고 )
-
Avatar (유저 프로필이미지)
-
Menu (프로필 클릭 시 나오는 메뉴)
-
MyLocationMarker (유저 현위치 마커)
-
MyLocationButton (유저 현위치로 이동시키는 버튼)
-
CompanionPin (동행 모집 핀)
-
CommunityPin (커뮤니티 핀)
-
CreatePostFloatingButton (게시글 작성 플로팅 버튼)
-
BottomSheet (바텀시트)
-
Text (텍스트 공통 컴포넌트)
-
PostList (게시글 리스트)
- PostItem (게시글 리스트 안에 들어가는 하나의 게시글 항목)
- TagGroup (텍스트 태그를 수평으로 나열해 속성을 보여주는 컴포넌트)
- DataLoadError (데이터 페칭 실패시 나오는 에러 상태 컴포넌트)
- RetryIcon (재시도 아이콘)
- PostListSkeleton (로딩 스켈레톤 ui)
- Skeleton (스켈레톤 공통 컴포넌트)
- PostItem (게시글 리스트 안에 들어가는 하나의 게시글 항목)
-
BottomModal (화면 하단에 위치하는 상세정보 확인 모달)
-
CompanionDetailContent (동행모집 게시글 상세정보)
- Button (버튼 공통 컴포넌트)
- Avatar (유저 프로필이미지)
- Badge (뱃지 공통 컴포넌트)
- Text (텍스트 공통 컴포넌트)
- DataLoadError (데이터 페칭 실패시 나오는 에러 상태 컴포넌트)
- RetryIcon (재시도 아이콘)
- CompanionDetailSkeleton (로딩 스켈레톤 Ui)
- Skeleton (스켈레톤 공통 컴포넌트)
-
CommunityDetailContent (커뮤니티 게시글 상세정보)
- CommentList (댓글리스트)
- CommentItem (댓글 리스트 안에 들어가있는 하나의 댓글 항목)
- Avatar (유저 프로필이미지)
- DataLoadError (데이터 페칭 실패시 나오는 에러 상태 컴포넌트)
- RetryIcon (재시도 아이콘)
- CommentItem (댓글 리스트 안에 들어가있는 하나의 댓글 항목)
- CommentForm (댓글 입력 폼)
- CommentInput (댓글 입력 인풋)
- CommentSubmitButton (댓글 전송 버튼)
- CommunityDetailSkeleton (로딩 스켈레톤 Ui)
- Skeleton (스켈레톤 공통 컴포넌트)
- CommentList (댓글리스트)
-
-
RetryNotice ( 위치/핀 데이터 조회 재시도 패널)
-
LocationPermissionModal (위치정보 허용 유도 모달)
-
Toast (토스트 컴포넌트)
컴포넌트 의존성 다이어그램
화살표는 이 페이지에서의 컴포넌트 조합·사용 관계를 나타낸다.
Loadingflowchart TD page["/"] component1["BottomNav"] component2["CommunityViewTabs"] component3["Map"] component4["Logo"] component5["Avatar"] component6["Menu"] component7["MyLocationMarker"] component8["MyLocationButton"] component9["CompanionPin"] component10["CommunityPin"] component11["CreatePostFloatingButton"] component12["BottomSheet"] component13["Text"] component14["PostList"] component15["PostItem"] component16["TagGroup"] component17["DataLoadError"] component18["RetryIcon"] component19["PostListSkeleton"] component20["Skeleton"] component21["BottomModal"] component22["CompanionDetailContent"] component23["Button"] component24["Badge"] component25["CompanionDetailSkeleton"] component26["CommunityDetailContent"] component27["CommentList"] component28["CommentItem"] component29["CommentForm"] component30["CommentInput"] component31["CommentSubmitButton"] component32["CommunityDetailSkeleton"] component33["RetryNotice"] component34["LocationPermissionModal"] component35["Toast"] page --> component1 page --> component2 page --> component3 page --> component4 page --> component5 page --> component6 component3 --> component7 page --> component8 component3 --> component9 component3 --> component10 page --> component11 page --> component12 page --> component13 component12 --> component14 component14 --> component15 component15 --> component16 component14 --> component17 component17 --> component18 component14 --> component19 component19 --> component20 page --> component21 component21 --> component22 component22 --> component23 component22 --> component5 component22 --> component24 component22 --> component13 component22 --> component17 component22 --> component25 component25 --> component20 component21 --> component26 component26 --> component27 component27 --> component28 component28 --> component5 component27 --> component17 component26 --> component29 component29 --> component30 component29 --> component31 component26 --> component32 component32 --> component20 page --> component33 page --> component34 page --> component35
-
-
데이터 로딩 시점
- 페이지 진입 시 유저 위치데이터 -> GET /map-pins, GET /nearby-posts 순서로 로딩
- 유저 위치데이터와 백엔드 api 호출은 순서대로 호출
- 백엔드 api들은 병렬로 호출
-
라우팅 :
- 게시글 추가 버튼 클릭 시 -> /posts/write/location
- 혼잡도 탭 클릭 시 -> /congestion
- 게시글 목록 바텀시트 확장 버튼 클릭 시 -> /posts
- 게시글 상세 모달 확장 버튼 클릭 시 -> /posts/[postId]
-
/posts: 게시글 목록 화면
-
주요 기능: 게시글 목록 조회, 게시글 상세 이동
-
사용 컴포넌트:
- Header (헤더 공통 컴포넌트)
- BackButton (뒤로가기 버튼)
- BottomNav (바텀네비바)
- Text (텍스트 공통 컴포넌트)
- PostList (게시글 리스트)
- PostItem (게시글 리스트 안에 들어가는 하나의 게시글 항목)
- Avatar (유저 프로필 이미지)
- TagGroup (텍스트 태그를 수평으로 나열해 속성을 보여주는 컴포넌트)
- DataLoadError (데이터 페칭 실패시 나오는 에러 상태 컴포넌트)
- RetryIcon (재시도 아이콘)
- PostListSkeleton (로딩 스켈레톤 ui)
- Skeleton (스켈레톤 공통 컴포넌트)
- PostItem (게시글 리스트 안에 들어가는 하나의 게시글 항목)
컴포넌트 의존성 다이어그램
화살표는 이 페이지에서의 컴포넌트 조합·사용 관계를 나타낸다.
Loadingflowchart TD page["/posts"] component1["Header"] component2["BackButton"] component3["BottomNav"] component4["Text"] component5["PostList"] component6["PostItem"] component7["Avatar"] component8["TagGroup"] component9["DataLoadError"] component10["RetryIcon"] component11["PostListSkeleton"] component12["Skeleton"] page --> component1 component1 --> component2 page --> component3 page --> component4 page --> component5 component5 --> component6 component6 --> component7 component6 --> component8 component5 --> component9 component9 --> component10 component5 --> component11 component11 --> component12
- Header (헤더 공통 컴포넌트)
-
데이터 로딩 시점
-
api : GET /nearby-posts
-
홈화면에서 캐싱된 데이터가 있는경우: 해당 데이터 즉시 표시 후 백그라운드 refetch
-
캐싱된 데이터가 없는 경우: 페이지 진입 시 호출
-
라우팅:
-
게시글 아이템 클릭 시 -> /posts/[postId] 이동
-
뒤로가기 버튼 클릭 시 -> /
-
/posts/[postId]: 게시글 상세 화면
-
주요 기능: 게시글 상세 조회, 동행모집-> 채팅방 참여 / 커뮤니티 -> 댓글등록
-
사용 컴포넌트:
-
Header (헤더 공통 컴포넌트)
- BackButton (뒤로가기 버튼)
-
BottomNav (바텀네비바)
-
CompanionDetailContent (동행모집 게시글 상세정보)
- Button (버튼 공통 컴포넌트)
- Avatar (유저 프로필이미지)
- Badge (뱃지 공통 컴포넌트)
- Text (텍스트 공통 컴포넌트)
- DataLoadError (데이터 페칭 실패시 나오는 에러 상태 컴포넌트)
- RetryIcon (재시도 아이콘)
- CompanionDetailSkeleton (로딩 스켈레톤 Ui)
- Skeleton (스켈레톤 공통 컴포넌트)
-
CommunityDetailContent (커뮤니티 게시글 상세정보)
- CommentList (댓글리스트)
- CommentItem (댓글 리스트 안에 들어가있는 하나의 댓글 항목)
- Avatar (유저 프로필이미지)
- DataLoadError (데이터 페칭 실패시 나오는 에러 상태 컴포넌트)
- RetryIcon (재시도 아이콘)
- CommentItem (댓글 리스트 안에 들어가있는 하나의 댓글 항목)
- CommentForm (댓글 입력 폼)
- CommentInput (댓글 입력 인풋)
- CommentSubmitButton (댓글 전송 버튼)
- CommunityDetailSkeleton (로딩 스켈레톤 Ui)
- Skeleton (스켈레톤 공통 컴포넌트)
- CommentList (댓글리스트)
컴포넌트 의존성 다이어그램
화살표는 이 페이지에서의 컴포넌트 조합·사용 관계를 나타낸다.
Loadingflowchart TD page["/posts/[postId]"] component1["Header"] component2["BackButton"] component3["BottomNav"] component4["CompanionDetailContent"] component5["Button"] component6["Avatar"] component7["Badge"] component8["Text"] component9["DataLoadError"] component10["RetryIcon"] component11["CompanionDetailSkeleton"] component12["Skeleton"] component13["CommunityDetailContent"] component14["CommentList"] component15["CommentItem"] component16["CommentForm"] component17["CommentInput"] component18["CommentSubmitButton"] component19["CommunityDetailSkeleton"] page --> component1 component1 --> component2 page --> component3 page --> component4 component4 --> component5 component4 --> component6 component4 --> component7 component4 --> component8 component4 --> component9 component9 --> component10 component4 --> component11 component11 --> component12 page --> component13 component13 --> component14 component14 --> component15 component15 --> component6 component14 --> component9 component13 --> component16 component16 --> component17 component16 --> component18 component13 --> component19 component19 --> component12
-
-
데이터 로딩 시점
-
api :
- GET /companion-posts/{companion_id}
- GET /community-posts/{companion_id}
-
홈화면에서 캐싱된 데이터가 있는경우: 해당 데이터 즉시 표시 후 백그라운드 refetch
-
캐싱된 데이터가 없는 경우: 페이지 진입 시 호출
-
라우팅
- 채팅 참여하기 버튼 클릭 시 -> 해당 채팅방 참여 요청 성공하면 해당 채팅방으로 이동
chat/[roomId] - 뒤로가기 버튼 클릭 시 -> 이전 화면으로 이동 (홈
/또는 게시글 목록/posts)
- 채팅 참여하기 버튼 클릭 시 -> 해당 채팅방 참여 요청 성공하면 해당 채팅방으로 이동
-
/posts/write/location: 게시글 등록 - 위치선택 화면
-
주요 기능: 지도 드래그로 위치 선택, 위치 검색, 선택한 위치 확인
-
사용 컴포넌트:
-
Map (지도 영역)
-
LocationPickerPin (위치 지정 핀)
-
SearchLocationInput (위치 검색 인풋)
- BackButton (뒤로가기 버튼)
-
LocationList (위치 검색 결과 리스트)
- LocationItem (리스트안에 있는 하나의 위치 항목)
-
Button (핀 등록 버튼 - 버튼 공통 컴포넌트 사용)
-
Text (조회 위치 정보 확인 텍스트 - 텍스트 공통 컴포넌트 사용)
-
RetryNotice ( 위치/핀 데이터 조회 재시도 안내 패널)
-
LocationPermissionModal (위치정보 허용 유도 모달)
컴포넌트 의존성 다이어그램
화살표는 이 페이지에서의 컴포넌트 조합·사용 관계를 나타낸다.
Loadingflowchart TD page["/posts/write/location"] component1["Map"] component2["LocationPickerPin"] component3["SearchLocationInput"] component4["BackButton"] component5["LocationList"] component6["LocationItem"] component7["Button"] component8["Text"] component9["RetryNotice"] component10["LocationPermissionModal"] page --> component1 page --> component2 page --> component3 component3 --> component4 page --> component5 component5 --> component6 page --> component7 page --> component8 page --> component9 page --> component10
-
-
데이터 로딩 시점
- 사용자 위치 정보 : maximumAge: 60_000 전역 클라이언트 상태로 관리
- 검색 데이터 : 사용자가 엔터 혹은 검색 버튼을 눌렀을때 카카오맵 장소 검색 API 호출
-
라우팅
- 뒤로가기 버튼 클릭 시
- 검색 리스트가 열려있을때 : 검색 리스트 닫기
- 검색 리스트가 닫혀있을때 : 홈화면
/으로 이동
- 이 위치에 핀 등록 버튼 클릭 시 ->
/posts/write/type
- 뒤로가기 버튼 클릭 시
-
/posts/write/type: 게시글 등록 - 게시글 타입 선택 화면
-
주요 기능: 게시글 타입 선택
-
사용 컴포넌트:
- Header (헤더 공통 컴포넌트)
- BackButton (뒤로가기 버튼)
- Button (다음 버튼 - 버튼 공통 컴포넌트 사용)
- CommunityButton (커뮤니티 선택 버튼)
- CompanionButton (동행모집 선택 버튼)
- Text (페이지 안내 텍스트 - 텍스트 공통 컴포넌트 사용)
컴포넌트 의존성 다이어그램
화살표는 이 페이지에서의 컴포넌트 조합·사용 관계를 나타낸다.
Loadingflowchart TD page["/posts/write/type"] component1["Header"] component2["BackButton"] component3["Button"] component4["CommunityButton"] component5["CompanionButton"] component6["Text"] page --> component1 component1 --> component2 page --> component3 page --> component4 page --> component5 page --> component6
- Header (헤더 공통 컴포넌트)
-
라우팅
- 뒤로가기 버튼 클릭 시 ->
/posts/write/location - 다음 버튼 클릭 시 ->
/posts/write?type={고른 타입}
- 뒤로가기 버튼 클릭 시 ->
-
/posts/write?type=accompany: 게시글 등록 - 동행모집 게시글 작성 화면
-
주요 기능: 동행 모집 게시글 작성
-
사용 컴포넌트:
- Header (헤더 공통 컴포넌트)
- BackButton (뒤로가기 버튼)
- DateInputButton (날짜 인풋)
- TimeInputButton (시간 인풋)
- LocationInputButton (위치 인풋)
- InputButton (날짜,시간,위치 인풋에 사용됨. - 공통 컴포넌트)
- DatePicker (출발 날짜 - 공통 컴포넌트 사용)
- TimePicker (출발 시간 - 공통 컴포넌트 사용)
- Select (이동수단,모집인원 - 공통 컴포넌트 사용)
- Textarea (모집 내용 - 공통 컴포넌트 사용)
- Button (등록하기 버튼 - 공통 컴포넌트 사용)
- Toast (등록 완료 토스트, 예외 토스트 - 공통 컴포넌트 사용)
컴포넌트 의존성 다이어그램
화살표는 이 페이지에서의 컴포넌트 조합·사용 관계를 나타낸다.
Loadingflowchart TD page["/posts/write?type=accompany"] component1["Header"] component2["BackButton"] component3["InputButton"] component4["DateInputButton"] component5["TimeInputButton"] component6["LocationInputButton"] component7["DatePicker"] component8["TimePicker"] component9["Select"] component10["Textarea"] component11["Button"] component12["Toast"] page --> component1 component1 --> component2 component4 --> component3 page --> component4 component5 --> component3 page --> component5 component6 --> component3 page --> component6 page --> component7 page --> component8 page --> component9 page --> component10 page --> component11 page --> component12
- Header (헤더 공통 컴포넌트)
-
라우팅
- 뒤로가기 버튼 클릭 시 ->
/posts/write/type - 등록하기 버튼 클릭 후 요청 성공 시 ->
/
- 뒤로가기 버튼 클릭 시 ->
-
상태 초기화
- 등록이 성공적으로 완료된 뒤 세션스토리지 초기화
- 뒤로가기 버튼 클릭 후 게시글 type이 변경된 경우(타입 선택 페이지에서 다음 버튼 클릭) 초기화
-
/posts/write?type=community: 게시글 등록 - 커뮤니티 게시글 작성 화면
-
주요 기능: 커뮤니티 게시글 작성
-
사용 컴포넌트:
- Header (헤더 공통 컴포넌트)
- BackButton (뒤로가기 버튼)
- Input (제목 입력 - 공통컴포넌트 사용)
- Textarea (내용 입력 - 공통 컴포넌트 사용)
- Button (등록하기 버튼 - 공통 컴포넌트 사용)
- Toast (등록 완료 토스트, 예외 토스트 - 공통 컴포넌트 사용)
컴포넌트 의존성 다이어그램
화살표는 이 페이지에서의 컴포넌트 조합·사용 관계를 나타낸다.
Loadingflowchart TD page["/posts/write?type=community"] component1["Header"] component2["BackButton"] component3["Input"] component4["Textarea"] component5["Button"] component6["Toast"] page --> component1 component1 --> component2 page --> component3 page --> component4 page --> component5 page --> component6
- Header (헤더 공통 컴포넌트)
-
라우팅
- 뒤로가기 버튼 클릭 시 ->
/posts/write/type - 등록하기 버튼 클릭 후 요청 성공 시 ->
/
- 뒤로가기 버튼 클릭 시 ->
-
상태 초기화
- 등록이 성공적으로 완료된 뒤 세션스토리지 초기화
- 뒤로가기 버튼 클릭 후 게시글 type이 변경된 경우(타입 선택 페이지에서 다음 버튼 클릭) 초기화
-
역할: 화면 하단에 위치하며, 선택한 메뉴 기능의 페이지로 이동시킨다.
-
props & Interface
type BottomNavProps = { className?: string; //부모에서 설정한 커스텀 스타일 };Props 설명
Prop / 타입 필드 설명 className 부모가 추가하는 스타일 클래스. 기본 내비게이션 스타일 위에 적용한다. 내부 상태 상세
별도의 선택 메뉴 useState는 두지 않는다. 현재 pathname이 바뀔 때 활성 메뉴를 다시 계산한다. 클릭 시 Link가 라우팅하고 그 결과가 활성 표시의 입력이 된다. hover/focus는 DOM 상태이며 메뉴 선택 상태와 구분한다.
-
내부 상태 & 이벤트
- usePathname()으로 현재 URL의 pathname을 확인한다.
- 메뉴 클릭 시 Link를 통해 페이지를 이동한다.
- pathname과 메뉴의 href를 비교하여 활성 상태를 계산한다.
- 비교는 prefix 방식 사용
-
사용 예시
import { BottomNav } from "@/shared/ui/BottomNav";
export default function Layout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<main>{children}</main>
<BottomNav /> // 기본 사용
<BottomNav className="md:hidden" /> // 추가 스타일 전달
</>
);
}-
에러 처리 / 엣지 케이스
- trailing slash 처리
- prefix 매칭으로 하위 페이지 처리
- 고정 네비게이션에 의한 콘텐츠 가림을 방지하기위해 safe-area 적용
-
스타일링
- 기본 스타일은 BottomNav 내부에서 관리하고, className은 외부에서 추가 스타일을 적용할 때 사용
-
Storybook
- Default : 기본값 - 홈
- Home : 홈 활성화
- Matching : 매칭 활성화
- ChatRequest : 채팅·요청 활성화
- WithCustomClassName : 커스텀 스타일 적용
-
역할
- 유저가 등록한 카카오톡 또는 서비스 프로필 이미지를 보여준다.
-
props & Interface
type AvatarSize = 'sm' | 'md' | 'lg'; interface AvatarProps { src?: string | null; alt: string; size?: AvatarSize; className?: string; }
Props 설명
Prop / 타입 필드 설명 src 프로필 이미지 URL. null/undefined/빈 문자열은 이미지가 없는 상태다. alt 프로필 이미지를 설명하는 필수 대체 텍스트다. size sm은 댓글/목록, md는 기본 프로필, lg는 상세 강조에 사용한다. className Avatar 최외곽의 추가 스타일이다. 내부 상태 상세
이미지 로드 상태 idle/loading/loaded/error는 Base UI Avatar.Image가 관리한다. src가 바뀌면 새 이미지의 로딩 결과를 기준으로 표시한다. loaded에서는 이미지, 이미지 없음 또는 error에서는 Fallback을 표시한다. 로딩 중에도 외곽 크기는 유지한다. 사용자 조회·클릭·프로필 메뉴 상태는 Avatar 내부에 저장하지 않는다.
-
내부 상태 & 이벤트
- Base UI Avatar.Image의 onLoadingStatusChange를 이용해 idle | loading | loaded | error 상태를 관리한다.
- src가 없거나 이미지 로드 상태가 error이면 Avatar.Fallback을 표시한다.
- onError를 별도로 전달받지 않아도 Base UI의 이미지 로드 실패 상태를 fallback 전환에 사용한다.
- Avatar는 프로필 이미지 표시만 담당하며, 클릭 이벤트나 유저 정보 조회는 상위 컴포넌트에서 처리한다.
-
사용 예시
<Avatar
src={user.profileImageUrl}
alt={`${user.nickname}님의 프로필 이미지`}
size="md"
/>-
스타일링
- 크기는 size prop에 따라 지정한다.
- sm: 댓글 및 목록용
- md: 게시글 및 기본 프로필용
- lg: 게시글 상세 및 프로필 강조 영역용
- 이미지가 없는 경우 기본 배경색과 기본 프로필 아이콘을 표시한다.
- 로딩 중에도 컨테이너 크기를 유지해 layout shift를 방지한다.
- 크기는 size prop에 따라 지정한다.
-
에러 처리 / 엣지 케이스
- src가 null, undefined 또는 빈 문자열인 경우 기본 프로필 이미지를 표시한다.
- 이미지 URL이 유효하지 않거나 로드에 실패한 경우 기본 프로필 이미지로 대체한다.
- 이미지에 alt 값을 제공해 접근성을 보장한다.
-
Storybook
- 기본 프로필 이미지
- 프로필 이미지가 없는 경우
- 이미지 로드에 실패한 경우
- sm, md, lg 크기별 상태
- 긴 alt 텍스트가 전달된 경우
-
역할
- 사용자의 현재 위치를 지도 위에 표시한다.
- 전역 클라이언트 상태로 관리되는 위치 좌표를 전달받아 마커 위치를 갱신한다.
- 위치 정보 조회나 권한 요청은 담당하지 않는다.
-
props & Interface
interface MapCoordinate { latitude: number; longitude: number; } interface MyLocationMarkerProps { position: MapCoordinate | null; accuracy?: number | null; visible?: boolean; className?: string; }
Props 설명
Prop / 타입 필드 설명 position 위도/경도를 담은 좌표다. null이면 현재 위치 마커를 표시하지 않는다. accuracy 미터 단위 위치 정확도다. 제공되면 정확도 원 표현에 사용한다. visible 마커를 표시할지 부모가 지정한다. false이면 유효한 좌표가 있어도 숨긴다. className 마커 표시 요소의 추가 스타일이다. MapCoordinate.latitude / longitude 각각 위도(-90 90), 경도(-180180)이며 유한한 숫자여야 한다.내부 상태 상세
표시용 좌표를 내부 React state에 복사하지 않는다. position 변경은 지도 adapter가 기존 marker 위치에 반영한다. position=null 또는 visible=false이면 마커 표시를 해제한다. SDK marker/circle 참조와 지도 연결 생명주기는 Map 또는 adapter가 관리한다. 위치 조회 진행·권한·오류 상태는 상위 위치 훅의 책임이다.
-
내부 상태 & 이벤트
- 내부 React 상태는 두지 않는다. position 변경을 지도 SDK marker 인스턴스에 반영한다.
- position이 변경되면 marker 위치를 갱신한다.
- position이 null이거나 visible이 false이면 marker를 표시하지 않는다.
- SDK 인스턴스 생성·갱신·해제는 Map 또는 지도 adapter가 담당한다.
-
사용 예시
<MyLocationMarker
position={location.coordinate}
accuracy={location.accuracy}
/>-
스타일링
- 사용자 위치는 게시글 핀과 구별되는 색상과 정확도 원을 사용한다.
- 지도 축척에 따라 정확도 원의 실제 반경을 유지한다.
-
에러 처리 / 엣지 케이스
- 위도는 -90..90, 경도는 -180..180 범위의 유한한 숫자만 허용한다.
- 유효하지 않은 좌표는 렌더링하지 않고 상위 상태에 기록할 수 있는 warning만 남긴다.
- 위치 권한 거부·위치 조회 실패는 이 컴포넌트에서 처리하지 않는다.
-
Storybook
- 정상 좌표
- position={null}
- visible={false}
- 정확도 원이 있는 상태
-
역할
- 사용자의 현재 위치가 보이는 지점으로 지도를 이동시키도록 상위 컴포넌트에 요청한다.
- Base UI Button을 사용한다.
- 브라우저 위치 API 호출과 권한 요청은 onLocate를 구현하는 상위 훅 또는 컨테이너가 담당한다.
-
props & Interface
export interface MyLocationButtonProps { onLocate: () => void | Promise<void>; status?: AsyncStatus; disabled?: boolean; className?: string; 'aria-label'?: string; }
Props 설명
Prop / 타입 필드 설명 onLocate 버튼 활성화 시 호출하는 위치 조회/지도 이동 요청 함수. 비동기 완료를 기다릴 수 있다. status 위치 조회의 진행 상태다. loading이면 진행 표시와 중복 클릭 차단에 사용한다. disabled 상위에서 지정하는 버튼 사용 불가 상태다. className 버튼 외곽의 추가 스타일이다. aria-label 아이콘 버튼을 설명하는 접근성 이름이다. 내부 상태 상세
위치 좌표와 요청 결과를 자체 보관하지 않는다. 부모가 요청 시작 전 loading으로 바꾸고 성공/실패 결과를 반영한다. 버튼은 status와 disabled에서 실행 가능 여부를 계산한다. 성공 시 지도 이동과 reject 시 오류 안내는 onLocate를 구현한 부모가 처리한다.
-
내부 상태 & 이벤트
- 클릭 시 onLocate를 한 번 호출한다.
- loading 중에는 중복 클릭을 막고 버튼에 진행 상태를 표시한다.
- 성공 시 지도 이동은 부모가 처리한다.
-
사용 예시
<MyLocationButton
onLocate={handleMoveToMyLocation}
status={location.status}
aria-label="내 위치로 이동"
/>-
스타일링
- 지도 위 floating action button으로 배치하며 지도 컨트롤과 겹치지 않도록 safe area를 고려한다.
- aria-label을 제공한다.
-
에러 처리 / 엣지 케이스
- 위치 권한이 없을 때 버튼 자체가 권한 요청 오류를 직접 해석하지 않고 부모의 상태를 표시한다.
- loading 중 재클릭을 방지한다.
- 위치가 아직 없으면 버튼은 동작할 수 있지만, 실패 메시지는 Toast로 표시한다.
- onLocate가 throw/reject하는 경우 부모가 error 상태를 설정해야 한다.
-
Storybook
- 기본 상태
- 로딩 상태
-
역할
- 게시글 목록을 렌더링한다.
- 각 항목은 PostItem 컴포넌트에 위임한다.
- 데이터 조회·페이지네이션·라우팅은 상위 컨테이너에서 처리한다.
-
props & Interface
export interface PostAuthor { nickname: string; profileImageUrl: string | null; } interface BasePostSummary { id: number; title: string; author: PostAuthor; distanceM: number; } export interface CompanionPostSummary extends BasePostSummary { type: 'COMPANION'; currentCount: number; capacity: number; departureAt: string; isExpired: boolean; } export interface CommunityPostSummary extends BasePostSummary { type: 'COMMUNITY'; commentCount: number; createdAt: string; } export type PostSummary = | CompanionPostSummary | CommunityPostSummary; --- export interface PostListProps { posts: PostSummary[]; status?: AsyncStatus; errorMessage?: string; hasNextPage?: boolean; onPostSelect?: (post: PostSummary) => void; onRetry?: () => void; onLoadMore?: () => void; className?: string; }
Props 설명
Prop / 타입 필드 설명 posts 화면에 렌더링할 게시글 요약 배열이다. 목록 데이터의 소유자는 상위 Query/컨테이너다. status 초기 로딩·성공·실패 화면을 선택하기 위한 요청 상태다. errorMessage 실패 상태에서 표시할 사용자 안내 문구다. hasNextPage 다음 페이지 존재 여부다. false이면 추가 로딩 trigger를 표시하지 않는다. onPostSelect 선택한 게시글 전체 요약을 부모에 전달한다. onRetry 현재 조회의 재시도 요청을 부모에 전달한다. onLoadMore 다음 페이지를 불러오도록 부모에 요청한다. className 목록 외곽의 추가 스타일이다. PostAuthor.nickname / profileImageUrl 작성자 표시 이름과 프로필 URL이며 이미지가 없으면 null이다. BasePostSummary.id / title / author / distanceM 게시글 식별자·제목·작성자·미터 단위 거리다. CompanionPostSummary.type / currentCount / capacity / departureAt / isExpired 동행 유형 식별값·현재 인원·정원·출발 시각·만료 여부다. CommunityPostSummary.type / commentCount / createdAt 커뮤니티 유형 식별값·댓글 수·작성 시각이다. PostSummary type으로 두 요약 타입을 구분하는 union이다. 내부 상태 상세
posts를 별도 내부 배열에 복제하거나 내부에서 fetch하지 않는다. status와 posts.length로 로딩/오류/빈 목록/항목 표시를 결정한다. 선택·재시도·더보기는 callback만 호출한다. IntersectionObserver 및 추가 요청 진행 상태는 목록 컨테이너나 상위 훅에서 관리하고, 관찰 대상 해제 시 observer를 정리한다.
-
내부 상태 & 이벤트
- 목록 데이터는 내부에서 fetch하지 않는다.
- status가 loading이면 skeleton, error이면 오류 상태, 빈 배열이면 empty state를 표시한다.
- onPostSelect, onRetry, onLoadMore 이벤트를 상위로 전달한다.
- IntersectionObserver는 목록 컨테이너 또는 상위 hook에서 관리한다.
-
사용 예시
<PostList
posts={posts}
status={query.status}
hasNextPage={query.hasNextPage}
onPostSelect={(post) => navigate(`/posts/${post.id}`)}
onRetry={query.refetch}
onLoadMore={query.fetchNextPage}
/>-
스타일링
- skeleton은 실제 Post와 동일한 높이 구조를 유지한다.
- 목록의 제목은 aria-label 또는 상위 heading으로 제공한다.
-
에러 처리 / 엣지 케이스
- 게시글이 없을 때 오류 상태와 구분되는 empty state를 표시한다.
- 중복 id가 있어도 React key 충돌이 나지 않도록 데이터 정합성을 상위에서 검증한다.
- hasNextPage가 false이면 load more trigger를 렌더링하지 않는다.
-
Storybook
- 게시글 1개
- 게시글 여러 개
- 로딩 skeleton
- 빈 목록
- 오류 및 재시도 상태
- 다음 페이지 로딩 상태
- 긴 제목/긴 본문 미리보기
-
역할
- 게시글 목록에서 프로필이미지, 제목, 출발시간/위치, 참여자 수/댓글 수를 표시한다.
- 항목 선택을 이벤트로 전달한다.
- 게시글 상세 데이터 조회나 라우팅은 담당하지 않는다.
-
props & Interface
export interface PostItemProps { post: PostSummary; onSelect?: (post: PostSummary) => void; className?: string; } export interface CompanionPostProps { post: CompanionPostSummary; onSelect?: (post: CompanionPostSummary) => void; className?: string; } export interface CommunityPostProps { post: CommunityPostSummary; onSelect?: (post: CommunityPostSummary) => void; className?: string; }
Props 설명
Prop / 타입 필드 설명 post 렌더링할 PostSummary다. type으로 유형별 컴포넌트와 표시 필드를 구분한다. onSelect 선택한 post를 상위에 전달한다. 생략 시 선택 이벤트를 전달하지 않는다. className 항목 외곽의 추가 스타일이다. CompanionPostProps.post / onSelect 동행 요약만 받으며 callback도 같은 타입을 전달한다. CommunityPostProps.post / onSelect 커뮤니티 요약만 받으며 callback도 같은 타입을 전달한다. 내부 상태 상세
내부 선택/상세 데이터 상태를 두지 않는다. post.type에서 CompanionPost 또는 CommunityPost를 결정하고 각 유형의 전용 필드를 표시한다. 클릭하면 현재 props의 post를 전달한다. 제목·작성자 fallback은 API 변환 계층, 프로필 실패는 Avatar가 처리한다.
-
내부 상태 & 이벤트
- post.type을 확인해 CompanionPost 또는 CommunityPost를 렌더링한다. TypeScript가 각 variant의 전용 필드를 자동으로 좁혀준다.
- 제목·본문 영역 클릭은 onSelect(post)를 호출한다.
-
사용 예시
<PostItem
post={post}
onSelect={(selectedPost) => navigate(`/posts/${selectedPost.id}`)}
/>-
스타일링
- li 안에서 콘텐츠 영역과 액션 영역을 구분한다.
- 클릭 가능한 전체 영역은 키보드 focus가 가능해야 한다.
- 제목은 1줄, 디자인 정책에 따른 line clamp를 적용한다.
-
에러 처리 / 엣지 케이스
- 작성자 이미지 실패는 Avatar가 처리한다.
- 제목이나 작성자 닉네임이 비어 있으면 API 변환 계층에서 fallback을 제공한다.
- COMMUNITY의 commentCount가 0이면 0을 표시한다.
-
Storybook
- COMPANION 게시글
- COMMUNITY 게시글
- 긴 제목·본문
- 이미지가 없는 작성자
-
역할
- 모바일 중심의 하단 패널을 제공한다.
- Base UI Drawer의 focus management, escape, backdrop, portal, swipe 동작을 사용한다.
- 내용은 children으로 전달받고, 데이터 조회는 담당하지 않는다.
-
props & Interface
export type BottomSheetSnapPoint = number | string; export interface BottomSheetProps { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; snapPoints?: BottomSheetSnapPoint[]; defaultSnapPoint?: BottomSheetSnapPoint | null; snapPoint?: BottomSheetSnapPoint | null; onSnapPointChange?: (snapPoint: BottomSheetSnapPoint | null) => void; title: ReactNode; description?: ReactNode; children: ReactNode; showViewAllButton?: boolean; onViewAll?: () => void; className?: string; }
Props 설명
Prop / 타입 필드 설명 open 외부에서 제어하는 열림 상태다. defaultOpen 비제어 사용 시 최초 열림 값이다. 이후 변경을 지시하는 값은 아니다. onOpenChange 열기/닫기 요청을 부모에 알린다. snapPoints 시트가 멈출 수 있는 높이 목록이다. defaultSnapPoint 비제어 사용 시 최초 snap 위치다. snapPoint 부모가 제어하는 현재 snap 위치다. onSnapPointChange 드래그 등으로 변경된 snap 위치를 부모에 알린다. title Drawer.Title에 연결되는 필수 제목이다. description 선택적인 설명. 없으면 Drawer.Description을 렌더링하지 않는다. children 시트 안에 표시할 콘텐츠다. showViewAllButton 전체보기 버튼 표시 여부다. onViewAll 전체보기 클릭 시 목록 페이지 이동을 부모에 요청한다. 표시할 때 함께 전달한다. className 시트 외곽의 추가 스타일이다. 내부 상태 상세
open 또는 snapPoint가 제공되면 부모 값을 기준으로 표시하고 변경 요청은 callback으로 전달한다. defaultOpen/defaultSnapPoint는 비제어 모드 초기화에만 사용한다. 드래그 진행·focus·Escape·backdrop·swipe 처리는 Base UI Drawer가 관리한다. 내용의 조회 상태는 children의 소유자가 관리하며 시트가 복제하지 않는다. 전체보기 이벤트는 snap 변화와 별개다.
-
내부 상태 & 이벤트
- onOpenChange는 열림·닫힘을 상위에 전달한다.
- Escape, backdrop 클릭, drawer swipe로 닫힐 수 있다.
- snapPoints로 바텀시트가 멈출 수 있는 높이를 지정한다.
- defaultSnapPoint를 사용하면 바텀시트가 처음 열릴 때 일부 높이만 표시할 수 있다.
- showViewAllButton이 true이면 onViewAll을 호출하는 전체보기 버튼을 표시한다. 이 버튼은 전체 목록 페이지 이동을 요청한다.
-
사용 예시
<BottomSheet
defaultOpen
snapPoints={['240px', 1]}
defaultSnapPoint="240px"
title="주변 게시글"
showViewAllButton
onViewAll={() => navigate('/posts')}
>
<PostList posts={posts} />
</BottomSheet>-
스타일링
- Drawer.Popup은 하단 고정, 상단 radius를 사용한다.
- 부분 오픈 상태와 전체 오픈 상태의 snap point를 디자인 토큰으로 관리한다.
- 바텀시트 위쪽의 막대 모양 drag handle은 “이 영역을 위아래로 끌 수 있다”는 시각적 안내일 뿐, 닫기 버튼 역할을 하면 안 된다.
- 긴 콘텐츠는 내부 scroll 영역으로 분리하고 body scroll은 잠근다.
- 하단 safe area를 env(safe-area-inset-bottom)으로 보정한다.
-
에러 처리 / 엣지 케이스
- title은 필수이며 Drawer.Title에 연결한다.
- description이 없으면 Drawer.Description을 렌더링하지 않는다.
- showViewAllButton이 true이면 onViewAll을 함께 전달해야 한다.
-
Storybook
- 기본
- 초기 부분 오픈 상태
- snap point 간 드래그 확장
- 긴 콘텐츠
- 전체보기 버튼 표시 및 페이지 이동
- backdrop/Escape 닫기
- swipe 닫기
공통 텍스트 컴포넌트입니다.
variant로 저장된 Text Style을 선택하거나, 필요할 때 토큰 키를 개별 prop으로 지정할 수 있습니다. 임의의 px 값이나 폰트 이름은 사용하지 않습니다.
- 컴포넌트:
src/shared/ui/text.tsx - Typography 스타일:
src/shared/ui/text.module.css - 토큰:
src/shared/styles/typography-tokens.css - 폰트 선언:
src/shared/styles/font-faces.css - Storybook:
src/shared/ui/text.stories.tsx
import { Text } from '@/shared/ui/text';
export function Example() {
return <Text variant="articleBody">본문 텍스트입니다.</Text>;
}| Prop | 허용 값 / 타입 | 기본값 | 설명 |
|---|---|---|---|
children |
ReactNode |
없음 | 표시할 텍스트 또는 React 노드입니다. 문자열, 숫자, 강조 태그, 링크, 아이콘 등을 전달할 수 있습니다. |
as |
ElementType |
span |
렌더링할 HTML 요소 또는 컴포넌트입니다. 예: span, p, h1, h2, strong, a
|
variant |
screenTitlearticleBodyt1Regular ~ t14Regulart1Bold ~ t14Bold
|
articleBody |
Text의 기본 Typography 스타일을 지정합니다. |
className |
string |
없음 | 레이아웃, 여백, 너비 등 추가 스타일을 지정합니다. Typography 속성 변경에는 사용하지 않습니다. |
fontSize |
t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14
|
variant의 값 |
폰트 크기를 개별 Typography 토큰으로 지정합니다. |
lineHeight |
t1, t2, t3, t4, t5, t6, t7, t8, t9, t10, t11, t12, t13, t14
|
fontSize에 대응하는 값 |
줄 높이를 개별 Typography 토큰으로 지정합니다. |
fontWeight |
regularmediumbold
|
variant의 값 |
폰트 굵기를 지정합니다. |
maxLines |
number |
없음 | 표시할 최대 줄 수입니다. 지정한 줄 수를 초과한 내용은 숨겨집니다. |
align |
leftcenterright
|
브라우저 기본값 | 텍스트의 가로 정렬 방향을 지정합니다. |
whiteSpace |
normalnowrapprepre-wrappre-linebreak-spaces
|
normal |
공백, 개행, 자동 줄바꿈 처리 방식을 지정합니다. |
userSelect |
autononetext
|
브라우저 기본값 | 텍스트 선택 가능 여부를 지정합니다. |
textDecorationLine |
noneline-throughunderline
|
none |
텍스트 장식을 지정합니다. |
color |
fg.brandfg.brandContrastfg.criticalfg.criticalContrastfg.disabledfg.informativefg.informativeContrastfg.neutralfg.neutralInvertedfg.neutralMutedfg.neutralSubtlefg.placeholderfg.positivefg.positiveContrastfg.warningfg.warningContrast
|
상속 | Foreground 색상 토큰을 지정합니다. |
style |
CSSProperties |
없음 | 인라인 스타일을 지정합니다. 기본 Typography 토큰으로 표현할 수 없는 예외적인 스타일에만 사용합니다. |
-
역할
- 서비스의 심볼 또는 워드마크를 일관된 크기와 비율로 표시한다.
- 링크 이동은 href 또는 상위 click handler로 제공한다.
-
props & Interface
export type LogoVariant = 'full' | 'symbol'; export type LogoSize = 'sm' | 'md' | 'lg'; export interface LogoProps { variant?: LogoVariant; size?: LogoSize; href?: string; alt?: string; className?: string; }
Props 설명
Prop / 타입 필드 설명 variant full은 전체 로고, symbol은 심볼 표현이다. size sm/md/lg 크기 토큰을 선택한다. href 있으면 링크로 렌더링하며 이동 대상이 된다. alt 이미지 대체 텍스트다. 장식용 로고는 빈 문자열을 사용할 수 있다. className 로고 외곽의 추가 스타일이다. 내부 상태 상세
변경 가능한 내부 상태는 없다. variant/size/href에서 표시 형태를 계산한다. 링크 이동과 클릭 처리 책임은 링크 또는 상위에 있으며 Logo가 별도 라우팅 상태를 저장하지 않는다.
-
내부 상태 & 이벤트
- 내부 상태는 없다.
- href가 있으면 anchor로 렌더링하고, 없으면 span 또는 div로 렌더링한다.
- 로고 클릭에 따른 라우팅은 링크 또는 상위 라우터에 위임한다.
-
사용 예시
<Logo variant="full" size="md" href="/" alt="서비스 홈" />-
스타일링
- symbol은 정사각형, full은 고정 aspect ratio를 유지한다.
- SVG를 사용할 경우 display: block과 aria-hidden 정책을 일관되게 적용한다.
-
에러 처리 / 엣지 케이스
- 장식용 로고는 alt="" 또는 aria-hidden="true"를 사용한다.
- 링크 로고에는 의미 있는 accessible name을 제공한다.
- 로고 asset이 로드되지 않아도 레이아웃이 무너지지 않도록 intrinsic size를 지정한다.
-
Storybook
- full/symbol 변형
- sm/md/lg 크기
-
역할
- 데이터 요청 실패를 화면 상단에서 안내하고 다시 시도할 수 있는 action을 제공한다.
- 실제 재요청은 onRetry를 호출해 상위에 위임한다.
-
props & Interface
export type RetryNoticeStatus = 'error' | 'retrying'; export interface RetryNoticeProps { visible: boolean; status: RetryNoticeStatus; onRetry: () => void | Promise<void>; message: ReactNode; retryLabel?: string; className?: string; }
Props 설명
Prop / 타입 필드 설명 visible 안내 패널 표시 여부다. false이면 렌더링하지 않는다. status error는 재시도 가능한 오류 상태, retrying은 재요청 진행 상태다. onRetry 재시도 버튼 활성화 시 호출하는 부모 함수다. message 실패 원인과 조치를 설명하는 콘텐츠다. retryLabel 재시도 버튼 문구를 지정한다. className 안내 패널 외곽의 추가 스타일이다. 내부 상태 상세
visible/status는 부모나 요청 훅이 관리한다. error에서 클릭하면 onRetry를 호출하고, 부모가 retrying으로 전환하면 버튼을 잠근다. 성공은 부모의 visible=false로 숨기고, 재실패는 error와 새 message로 표시한다. 별도 닫기/Escape/backdrop 상태는 없다.
-
내부 상태 & 이벤트
- visible과 status는 요청 hook 또는 상위 컨테이너에서 상태를 관리한다.
- visible이 false이면 렌더링하지 않는다.
- Retry 클릭 시 onRetry를 호출하고 retrying 상태에서는 버튼을 비활성화한다.
- 재시도 요청이 성공하면 상위에서 visible={false}로 변경해 자동으로 숨긴다.
- 별도의 닫기 버튼, Escape close, backdrop close 이벤트는 제공하지 않는다.
-
사용 예시
const shouldShowRetry =
query.status === 'error' || query.status === 'loading';
<RetryNotice
visible={shouldShowRetry}
status={query.status === 'loading' ? 'retrying' : 'error'}
onRetry={query.refetch}
message="게시글을 불러오지 못했어요. 잠시 후 다시 시도해 주세요."
/>-
스타일링
- 화면 상단에 오류 아이콘, 안내 문구, 재시도 버튼을 수직으로 배치한다.
- 오류 안내 컨테이너에는 role="alert" 또는 aria-live="assertive"를 적용한다.
- retrying 중에는 retry label을 다시 불러오는 중…으로 변경한다.
- 일반 콘텐츠와 시각적으로 구분하되 페이지 이용을 막는 backdrop은 사용하지 않는다.
-
에러 처리 / 엣지 케이스
- 연속 재시도 시 loading lock을 적용한다.
- 네트워크 오류와 권한 오류를 같은 문구로 뭉뚱그리지 않는다.
- 재시도 자체가 실패하면 안내 영역을 유지하고 최신 오류 메시지를 상위에서 갱신한다.
- visible이 true인데 status가 retrying이면 버튼은 비활성화되어야 한다.
- 오류가 해결된 뒤에만 상위에서 visible을 false로 변경한다.
-
Storybook
- 기본 오류
- 재시도 loading
- 재시도 성공 후 닫힘
- 재시도 실패
- 긴 안내 문구
- retry button disabled 상태
-
역할
- 위치 기능에 필요한 권한과 사용 목적을 안내한다.
- Base UI Dialog를 사용한다.
- 브라우저 권한 요청은 사용자의 명시적인 버튼 클릭에서 onRequestPermission을 호출한다.
-
props & Interface
export type LocationPermissionStatus = | 'prompt' // 아직 위치 권한을 요청하지 않은 상태 | 'requesting' // 사용자가 허용 버튼을 눌러 권한 요청 중인 상태 | 'granted' // 위치 권한이 허용된 상태 | 'denied' // 사용자가 위치 권한을 거부한 상태 | 'unavailable'; // 브라우저 미지원, HTTPS 아님 등으로 위치 기능을 사용할 수 없는 상태 export interface LocationPermissionModalProps { open: boolean; onOpenChange: (open: boolean) => void; onRequestPermission: () => void | Promise<void>; status?: LocationPermissionStatus; errorMessage?: string; className?: string; }
Props 설명
Prop / 타입 필드 설명 open 부모가 제어하는 모달 열림 상태다. onOpenChange 닫기 등 열림 상태 변경 요청을 부모에 전달한다. onRequestPermission 사용자가 허용 버튼을 눌렀을 때 위치 권한 요청을 실행한다. status prompt/requesting/granted/denied/unavailable에 따라 설명과 버튼 상태를 정한다. errorMessage 권한 요청 실패 또는 환경 문제 안내다. className 모달 외곽의 추가 스타일이다. 내부 상태 상세
권한 상태는 위치 훅이 Permissions/Geolocation API 결과로 관리한다. prompt에서 허용 클릭 시 requesting, 결과에 따라 granted/denied/unavailable로 전환한다. requesting 동안 중복 요청을 막고 granted일 때 부모가 닫는다. 모달 mount 자체로 권한을 요청하지 않는다. 권한 허용 뒤 좌표 취득 실패는 권한 거부와 구분한다. Dialog focus 상태는 Base UI가 관리한다.
-
내부 상태 & 이벤트
- 위치 관련 hook 또는 부모 컴포넌트가 브라우저의 Permissions API와 Geolocation API 결과를 바탕으로 권한 상태를 관리한다. - 허용 버튼은 onRequestPermission을 호출한다.
- requesting 중 중복 요청을 막는다.
- 권한 허용 시 상위에서 모달을 닫는다.
-
사용 예시
<LocationPermissionModal
open={locationPermission === 'prompt' || locationPermission === 'denied'}
onOpenChange={setPermissionModalOpen}
onRequestPermission={requestLocationPermission}
status={locationPermission}
errorMessage="브라우저 설정에서 위치 권한을 허용해 주세요."
/>-
스타일링
- 위치 아이콘, 사용 목적, 허용 버튼, 나중에 하기 버튼을 명확히 구분한다.
- 개인정보 안내는 본문에서 읽을 수 있는 대비와 크기로 제공한다.
- denied 상태에서는 브라우저 설정으로 이동해야 할 수 있다는 안내를 추가한다.
-
에러 처리 / 엣지 케이스
- HTTPS가 아니거나 브라우저가 Geolocation API를 지원하지 않으면 unavailable을 표시한다.
- 권한 요청을 모달 mount 시 자동 호출하지 않는다.
- 사용자가 거절한 경우 계속 반복해서 강제 노출하지 않고, 상위에서 재노출 정책을 결정한다.
- 허용됐지만 위치 취득에 실패한 경우 permission 오류와 위치 취득 오류를 구분한다.
-
Storybook
- prompt
- requesting
- granted
- denied
- unavailable
- 긴 오류 메시지
-
역할
- 상태, 카테고리, 숫자 등 짧은 보조 정보를 표시한다.
-
props & Interface
export type BadgeVariant = 'neutral' | 'primary' | 'success' | 'warning' | 'danger'; export type BadgeSize = 'sm' | 'md'; export interface BadgeProps { children: ReactNode; variant?: BadgeVariant; size?: BadgeSize; className?: string; 'aria-label'?: string; }
Props 설명
Prop / 타입 필드 설명 children 상태/카테고리/숫자 등 표시 내용이다. variant neutral/primary/success/warning/danger 의미에 따른 시각 표현이다. size sm/md 크기를 지정한다. className 뱃지 외곽의 추가 스타일이다. aria-label 축약 문구 등 시각 텍스트를 보완하는 접근성 이름이다. 내부 상태 상세
내부 React 상태나 클릭 처리 상태를 두지 않는다. children/variant/size 변경이 그대로 표시 변경이 된다. 빈 children 처리와 긴 문구 제한은 기존 명세를 따른다.
-
사용 예시
<Badge variant="success" size="sm">수락됨</Badge>-
스타일링
- 짧은 텍스트 기준으로 line-height와 padding을 고정한다.
- 긴텍스트는 기본적으로 줄바꿈하지 않고 상위에서 문구를 제한한다.
-
에러 처리 / 엣지 케이스
- 빈 children은 렌더링하지 않거나 개발 환경 warning을 남긴다.
-
Storybook
- variant 전체
- sm/md 크기
- 긴 텍스트
-
역할
- 서비스 전반에서 사용하는 버튼의 의미, 상태, 크기, 시각 스타일을 통일한다.
- Base UI Button을 기반으로 만든다.
- 페이지 이동 링크는 Button이 아니라 anchor 또는 라우터 Link를 사용한다.
-
props & Interface
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; export type ButtonSize = 'sm' | 'md' | 'lg'; export interface ButtonProps extends Omit<ComponentPropsWithoutRef<'button'>, 'className'> { variant?: ButtonVariant; size?: ButtonSize; loading?: boolean; className?: string; }
Props 설명
Prop / 타입 필드 설명 variant primary/secondary/ghost/danger 시각 위계를 지정한다. size sm/md/lg 크기와 padding을 지정한다. loading 요청 진행 상태다. 중복 클릭 방지와 로딩 표시의 입력이다. className 버튼 외곽의 추가 스타일이다. children (상속) 버튼 내부 텍스트 또는 아이콘이다. disabled (상속) 버튼 실행 불가 상태다. onClick (상속) 클릭 이벤트를 부모로 전달한다. type (상속) button/submit/reset 역할이다. 폼 제출 버튼은 submit을 명시한다. aria-label 및 나머지 button 속성 (상속) 접근성 이름, name, form 등 native button 속성을 전달한다. 내부 상태 상세
비동기 요청 상태는 부모 소유다. loading 또는 disabled에서 실행 가능 여부를 계산하며 버튼 내부에서 동일 요청 상태를 복제하지 않는다. hover/focus/active는 Base UI와 DOM이 관리한다. 요청 완료/실패에 따른 loading 해제와 안내는 부모가 처리한다.
-
내부 상태 & 이벤트
- Base UI Button에 disabled, focusableWhenDisabled, onClick을 전달한다.
- loading이면 중복 클릭을 막고 loading indicator와 accessible label을 표시한다.
- submit button은 type="submit"을 명시적으로 전달한다.
- 비동기 처리 완료와 오류는 부모가 관리한다.
-
사용 예시
<Button variant="primary" size="md" onClick={handleSubmit}>
저장하기
</Button>-
스타일링
- hover, active, focus-visible, disabled, loading 상태를 제공한다.
- icon-only button은 고정 정사각형 크기와 필수 aria-label을 사용한다.
-
에러 처리 / 엣지 케이스
- loading 상태에서 label이 사라져 accessible name이 없어지지 않도록 aria-labelledby 또는 숨김 label을 제공한다.
- Button으로 anchor를 흉내 내지 않는다.
- disabled 상태에서도 상태 설명이 필요한 경우 tooltip 또는 별도 안내를 사용한다.
-
Storybook
- variant 전체
- sm/md/lg
- hover/focus/active/disabled
- loading
- icon-only
-
역할
- 게시글 목록 또는 지도에서 선택한 게시글의 상세 정보를 하단 패널로 보여준다.
-
props & Interface
export interface BottomModalProps { open: boolean; onOpenChange: (open: boolean) => void; onViewDetail: () => void; children: ReactNode; className?: string; }
Props 설명
Prop / 타입 필드 설명 open 상세 패널의 표시 여부다. 부모가 현재 값을 관리한다. onOpenChange 닫기 버튼 등에서 false를 전달해 부모에 닫기를 요청한다. onViewDetail 상세 보기 버튼 클릭 시 실행한다. 실제 페이지 이동은 부모가 담당한다. children 부모가 선택한 상세 내용 또는 로딩/오류 UI다. className 패널 외곽의 추가 스타일이다. 내부 상태 상세
별도 내부 open 상태를 두지 않는 제어형이다. 닫기 버튼은 onOpenChange(false), 상세 보기 버튼은 onViewDetail을 호출한다. 선택된 게시글과 조회 상태는 부모/children이 소유하며 이 컴포넌트는 fetch하지 않는다. 상세 보기 이후 닫힘 여부도 부모가 결정한다.
-
내부 상태 & 이벤트
- 바텀모달의 열림 상태는 부모 컴포넌트가 관리한다. 부모는 open으로 현재 상태를 전달하고, 모달의 닫기 요청은 onOpenChange를 통해 전달받는다.
- 닫기 버튼은 항상 렌더링하며, 클릭 시 onOpenChange(false)를 호출한다.
- 상세 보기 버튼은 항상 렌더링하며, 클릭 시 onViewDetail을 호출한다.
- onViewDetail에서 라우팅을 실행할 수 있지만, 실제 라우팅은 상위 컴포넌트가 담당한다.
- 게시글 데이터 조회나 내부 fetch는 하지 않고 children만 렌더링한다.
-
사용 예시
<BottomModal
open={selectedPost !== null}
onOpenChange={(open) => !open && setSelectedPost(null)}
onViewDetail={() => {
if (selectedPost) {
navigate(`/posts/${selectedPost.id}`);
}
}}
>
<PostDetailPreview post={selectedPost} />
</BottomModal>-
스타일링
- 화면 좌우에 margin을 두고 하단 네비게이션 위에 떠 있는 floating card 형태로 배치한다.
- 카드 하단은 화면 끝에 연결하지 않고, 하단 네비게이션과 일정한 간격을 둔다.
- 상단 영역에 닫기 버튼과 상세 보기 버튼을 항상 배치한다.
- 닫기 버튼은 aria-label="닫기", 상세 보기 버튼은 aria-label="상세 페이지로 이동"을 제공한다.
- children 영역은 사용처에서 필요한 UI를 자유롭게 구성한다.
- 카드에는 shadow를 적용해 배경 지도와 구분한다.
- 하단 위치는 하단 네비게이션 높이와 safe area를 함께 고려한다.
-
에러 처리 / 엣지 케이스
- children은 필수이며, 선택된 게시글이 없을 때의 empty/unavailable UI도 상위에서 전달한다.
- onViewDetail은 필수이므로 상세 보기 버튼이 동작하지 않는 상태를 허용하지 않는다.
- 상세 보기 버튼 클릭 후 모달을 닫을지 여부는 상위 라우팅 정책에서 결정한다.
- children 내부의 요청 상태나 오류는 해당 children 컴포넌트가 처리한다.
-
Storybook
- 닫기 버튼 동작
- 상세 보기 버튼을 통한 상세 페이지 이동
- 긴 콘텐츠
-
역할
- 댓글, 글작성 등 단일 행 문자열 입력을 담당하는 순수 input 컴포넌트다.
- label, description, error layout은 담당하지 않는다.
- 입력값은 Base UI convention에 맞춰 value와 onValueChange로 관리한다.
- 여러 줄 입력은 별도 Textarea 컴포넌트로 분리한다.
-
props & Interface
export type InputSize = 'sm' | 'md' | 'lg'; export interface InputProps extends Omit< ComponentPropsWithoutRef<'input'>, 'size' | 'className' | 'value' | 'defaultValue' | 'onChange' > { value: string; onValueChange: (value: string) => void; size?: InputSize; className?: string; }}
Props 설명
Prop / 타입 필드 설명 value 현재 입력 문자열이다. 부모가 관리한다. onValueChange 변경된 문자열을 부모에 전달한다. DOM 이벤트 자체가 아닌 문자열을 받는다. size sm/md/lg 시각 크기이며 native input의 size 속성을 대신한다. className 실제 input의 추가 스타일이다. name / id (상속) 폼 필드 이름과 label 연결에 사용하는 ID다. placeholder / maxLength (상속) 입력 힌트와 도메인에서 지정한 입력 길이 제한이다. disabled / readOnly (상속) 사용 불가/수정 금지 상태다. onBlur 및 aria-* (상속) 포커스 이탈 이벤트와 접근성 정보를 상위 폼에 연결한다. 그 밖의 native input 속성 (상속) Omit에 명시한 속성 외에는 일반 input 속성을 전달할 수 있다. 내부 상태 상세
value를 내부 useState에 복제하지 않는다. 타이핑 결과를 onValueChange로 전달하고 부모의 새 value를 표시한다. focus/disabled 등은 Base UI/DOM 상태로 표현한다. trim·금칙어·검증 오류·제출 여부는 상위 form/InputField가 소유한다.
-
내부 상태 & 이벤트
- value는 현재 입력값이고, onValueChange는 변경된 문자열을 상위에 전달한다.
- validation과 submit은 부모 form, InputField, 또는 form library에서 처리한다.
-
사용 예시
<Input
name="comment"
value={comment}
onValueChange={setComment}
placeholder="댓글을 입력해 주세요"
maxLength={300}
/>-
스타일링
- input control 자체의 크기, border, background, typography를 관리한다.
- data-focused, data-disabled, data-invalid 등 Base UI 상태 속성을 selector로 활용한다.
- 입력창 높이는 size별로 고정해 레이아웃 이동을 방지한다.
-
에러 처리 / 엣지 케이스
- 단독으로 사용할 때는 상위 label 또는 aria-label을 통해 accessible name을 제공한다.
- 문자열 trim, 최대 길이, 금칙어 검사는 컴포넌트가 아닌 도메인 validation에서 처리한다.
- value와 onValueChange는 항상 함께 전달한다.
-
Storybook
- default
- disabled
- sm/md/lg
- placeholder
-
역할
- Input과 Base UI Field를 조합해 label, description, error를 제공한다.
- InputField가 label 연결, 오류 상태 표시, 오류 메시지 연결 같은 반복적인 접근성 처리를 공통으로 담당한다.
- 실제 입력값과 validation 결과는 상위 컴포넌트에 전달한다.
-
props & Interface
export interface InputFieldProps extends Omit<InputProps, 'className'> { label: ReactNode; description?: ReactNode; errorMessage?: ReactNode; className?: string; inputClassName?: string; }
Props 설명
Prop / 타입 필드 설명 InputProps (상속) value/onValueChange/size와 native input 속성을 내부 Input에 전달한다. label 필수 입력 이름이다. Field.Label로 input과 연결한다. description 입력 방법을 설명하는 선택 안내다. errorMessage 부모가 전달하는 검증 오류다. 있으면 invalid 표시와 오류 메시지 연결에 사용한다. className Field wrapper의 추가 스타일이다. inputClassName 내부 Input control의 추가 스타일이다. 내부 상태 상세
입력값과 검증 결과를 자체 계산하거나 저장하지 않는다. Field.Root/Label/Description/Error와 Input을 조합한다. errorMessage에서 invalid 표시를 파생하고 value/onValueChange는 그대로 전달한다. focus와 메시지 연결은 Base UI Field가 관리한다.
-
내부 상태 & 이벤트
- Field.Root, Field.Label, Field.Description, Field.Error와 Input을 조합한다.
- label은 필수로 받아 input의 accessible name을 보장한다.
- errorMessage가 있으면 Field를 invalid 상태로 표시하고 error message를 연결한다.
- value와 onValueChange는 내부 Input으로 그대로 전달한다.
-
사용 예시
<InputField
name="comment"
label="댓글"
value={comment}
onValueChange={setComment}
description="최대 300자까지 입력할 수 있어요."
errorMessage={commentError}
placeholder="댓글을 입력해 주세요"
maxLength={300}
/>-
스타일링
- error 상태에서는 input의 aria-invalid와 error message 연결을 보장한다.
- className은 field wrapper, inputClassName은 실제 input control에 적용한다.
- Field 상태에 따라 data-invalid, data-focused, data-disabled 스타일을 적용한다.
-
에러 처리 / 엣지 케이스
- description과 error가 동시에 있을 수 없다.
-
Storybook
- label만 있는 기본 필드
- description
- error 상태
- disabled/readOnly
- 긴 label
- 긴 error message
- sm/md/lg
-
역할
- 저장 성공, 요청 실패, 복사 완료 등 일시적인 상태 피드백을 제공한다.
- Base UI Toast.Provider, Toast.useToastManager 또는 global manager를 사용한다.
- 토스트 내용은 한 줄의 content로만 제공한다.
- 긴 오류 내용이나 사용자의 즉시 확인이 필요한 내용은 Dialog를 사용한다.
-
props & Interface
export type ToastVariant = 'info' | 'success' | 'warning' | 'error'; export interface ToastData { content: string; variant?: ToastVariant; duration?: number; } export interface ToastProps extends ToastData { id: string; }
Props 설명
Prop / 타입 필드 설명 ToastData.content 사용자에게 보여 줄 한 줄 메시지다. ToastData.variant info/success/warning/error 의미를 지정한다. ToastData.duration 토스트 표시 시간이다. 시간 단위는 manager에 맞춰 밀리초로 전달한다. ToastProps.id 각 토스트를 식별해 닫거나 갱신할 때 사용하는 값이다. 내부 상태 상세
앱 root의 Toast.Provider/manager가 토스트 목록과 생명주기를 소유한다. add로 생성하고 close/update/promise로 관리한다. 각 Toast가 별도 목록이나 중복 만료 timer를 만들지 않는다. duration 만료 제거는 manager가 수행하며 페이지 unmount에도 Provider는 유지한다. 동일 요청의 중복 토스트 방지는 요청 훅/상위 계층에서 처리한다.
-
내부 상태 & 이벤트
- 앱 root에 하나의 Toast.Provider와 viewport를 둔다.
- toastManager.add, close, update, promise를 사용해 toast lifecycle을 관리한다.
- duration이 만료되면 Base UI toast manager가 토스트를 자동으로 제거한다.
-
사용 예시
const toastManager = Toast.useToastManager();
toastManager.add({
// app Toast wrapper의 content를 Base UI Toast.Description으로 매핑한다.
description: '저장했어요',
data: { variant: 'success' },
});-
스타일링
- 토스트 위치는 desktop/mobile 및 safe area를 고려한다.
- content는 한 줄로 표시하고, 긴 문구는 ellipsis로 제한한다.
- 여러 개가 쌓일 때 --toast-index, --toast-offset-y를 사용해 stack animation을 관리한다.
-
에러 처리 / 엣지 케이스
- 토스트에 너무 긴 서버 오류 원문을 표시하지 않는다.
- content가 빈 문자열이면 토스트를 생성하지 않고 개발 환경 warning을 남긴다.
- 페이지가 unmount돼도 전역 Provider가 유지되는 위치에 둔다.
- 동일 요청에 대한 중복 토스트 방지는 API 요청 hook 또는 상위 상태 관리 계층에서 담당한다.
-
Storybook
- info/success/warning/error
- 한 줄 content
- stacked toast
- 긴 content ellipsis
- duration에 따른 자동 dismiss
-
역할
- 카카오맵 SDK의 지도 생성·표시 영역과 공통 lifecycle을 캡슐화한다.
- 중심 좌표, 지도 level, children overlay를 받아 MyLocationMarker, CompanionPin, CommunityPin을 표시한다.
- 위치 조회, 게시글 fetch, 권한 요청은 담당하지 않는다.
- 지도 SDK의 Map 인스턴스를 context로 제공해 자식 overlay 컴포넌트가 지도에 연결될 수 있도록 한다.
-
props & Interface
export interface MapProps { center: MapCoordinate; level: number; children?: ReactNode; className?: string; }
Props 설명
Prop / 타입 필드 설명 center 지도의 중심 좌표다. 위도/경도를 받아 지도 위치에 반영한다. level 지도 확대 수준이다. children 지도 context를 사용하는 위치 마커/게시글 핀 등 overlay다. className 지도 container의 추가 스타일이다. 내부 상태 상세
SDK 준비와 지도 container mount 이후 지도 인스턴스를 생성하고 ref/context로 보관한다. center/level 변경은 기존 인스턴스에 반영한다. 자식 overlay는 context를 통해 같은 지도에 연결한다. 등록한 이벤트와 overlay는 해제 시 정리하고 참조를 비운다. 지도 생성 준비 상태와 SDK 참조는 지도 내부/adapter가 소유하며, 사용자 위치 조회·게시글 데이터·권한 상태는 소유하지 않는다.
-
내부 상태 & 이벤트
-
status는loading | ready | error로 관리한다. SDK 로딩과 지도 생성 중에는 loading, 인스턴스 생성 완료 시 ready, SDK 로드 또는 초기화 실패 시 error로 전환한다. ready는 지도 인스턴스를 사용할 수 있다는 의미이며 모든 지도 타일의 다운로드 완료를 의미하지 않는다. -
containerRef는 지도 DOM을,mapRef는 SDK 지도 인스턴스를 보관한다. SDK 준비와 container mount가 모두 완료된 뒤 생성하고, 준비된 인스턴스를 context로 제공한다. 자식 overlay는 context가 준비된 뒤 지도에 연결한다. -
center.latitude,center.longitude값이 변경되면 기존 인스턴스의setCenter,level이 변경되면setLevel로 반영한다. 같은 좌표의 새 객체가 전달되거나 children만 변경된 경우 지도를 다시 생성하거나 중심을 되돌리지 않는다. - 사용자 드래그·확대/축소의 실제 화면 위치는 SDK가 관리한다. 외부 center/level 값이 변경될 때만 해당 값을 반영하며, 지도 이동마다 동일한 값을 React state에 중복 저장하지 않는다.
- 지도 context를 사용하는 기능별 adapter/hook은 필요에 따라
dragend(드래그 종료),zoom_changed(확대 수준 변경),idle(이동·확대/축소 종료)을 구독한다. 게시글 조회와 선택 위치 저장은 해당 기능 계층에서 처리한다. 현재 MapProps에는 외부 이벤트 callback이 없다. - container 크기가 바뀌면 ResizeObserver로 감지해 현재 지도 중심을 보관하고
relayout()후 복원한다. 크기가 0인 동안은 갱신을 보류한다. SDK 메서드와 이벤트는 카카오 지도 Web API 문서를 따른다. - unmount 시 각 등록 주체가 이벤트 listener, overlay 연결, ResizeObserver, 로딩 표시 timer를 정리하고 지도 참조를 비운다. SDK 로드가 늦게 완료되어도 이미 해제된 container에 지도를 생성하거나 상태를 갱신하지 않는다.
-
-
사용 예시
Map과 MyLocationMarker를 가져온 Client Component 안에서 사용한다. 아래 좌표는 예시이며 실제 사용자 위치는 상위 위치 hook에서 전달한다.
function MapExample() { const exampleCenter: MapCoordinate = { latitude: 37.5665, longitude: 126.9780, }; return ( <section aria-label="주변 지도" className="h-[400px] w-full"> <Map center={exampleCenter} level={3} className="h-full w-full"> <MyLocationMarker position={exampleCenter} accuracy={30} /> </Map> </section> ); }
- 지도만 표시하려면 children을 생략한다. 실제 위치를 아직 얻지 못한 경우 상위에서 기본 지도 중심을 전달하고, MyLocationMarker에는
position={null}을 전달해 마커를 숨긴다. - 내 위치 이동이나 검색 결과 이동은 상위에서 center를 변경해 요청한다. 부모 영역의 높이를 지정해야
h-full이 실제 지도 높이로 계산된다.
- 지도만 표시하려면 children을 생략한다. 실제 위치를 아직 얻지 못한 경우 상위에서 기본 지도 중심을 전달하고, MyLocationMarker에는
-
스타일링
- 기본 container는
relative h-full w-full overflow-hidden으로 구성하고 className을 병합한다. 화면별 높이와 배치는 부모가 지정한다. - SDK가 DOM을 생성하는 지도 영역과 React가 렌더링하는 로딩·오류 안내 영역을 분리한다. 지도 영역은 container를 채우고 안내는 그 위에 배치한다.
- 로딩·오류 상태에서도 지도 영역의 크기를 유지한다. 로딩 표시는 기존 공통 정책에 따라 1초 이상 걸릴 때 노출하고, 안내에는
role="status", 오류에는role="alert"를 적용한다. - 지도 위 버튼·바텀시트는 페이지에서 배치하고 safe area와 겹침을 조정한다. SDK의 로고·저작권 표기가 가려지지 않도록 한다.
- 기본 container는
-
에러 처리 / 엣지 케이스
- SDK 로드 실패·초기화 예외는 error로 전환하고 지도 영역 안에 “지도를 불러오지 못했어요” 안내를 표시한다. 네트워크 오류는 재시도 버튼으로 SDK 로딩·초기화를 다시 시도하며 진행 중 중복 클릭을 막는다. 앱 키·허용 도메인 설정 오류는 개발 로그로 구분하고 자동 재시도를 반복하지 않는다.
- center는 위도 -90부터 90, 경도 -180부터 180 범위의 유한한 숫자만 허용한다. level은 사용하는 지도 유형이 지원하는 범위의 정수인지 검증한다. 초기 입력이 잘못되면 SDK 생성을 중단하고 안내하며, 생성 이후 잘못된 값이 들어오면 마지막 정상 화면을 유지하고 개발 warning을 남긴다. 유효한 값이 전달되면 다시 초기화 또는 갱신한다.
- SDK는 브라우저 mount 이후 로드한다. 서버 렌더링 중 window나 지도 SDK에 접근하지 않으며, 공통 loader가 script 로드 요청을 공유해 여러 Map이 동시에 mount되어도 중복 삽입하지 않는다.
- 숨겨진 탭·패널에서 처음 표시되거나 가로/세로 방향이 바뀌면 실제 크기를 확인한 뒤 relayout한다. 부모 높이가 지정되지 않은 경우 개발 warning으로 원인을 알린다.
- children이 없으면 지도만 표시한다. 사용자 위치가 없거나 권한이 거부되어도 유효한 center가 있으면 지도를 표시하며 위치 권한 안내는 상위 기능에서 처리한다.
- 빠른 mount/unmount와 개발 환경의 effect 재실행에서도 인스턴스·listener·overlay가 중복 생성되지 않도록 생성과 정리를 대칭으로 처리한다. 한 Map의 해제 때문에 다른 Map이 사용하는 공통 SDK script를 제거하지 않는다.
-
Storybook
- Default: 유효한 center와 level로 children 없이 지도를 표시한다.
- WithMyLocationMarker: 자식 마커와 정확도 원을 표시하고, position이 null이면 마커만 숨겨지는지 확인한다.
- CenterAndLevelChange: Controls에서 중심 좌표·확대 수준을 변경했을 때 기존 지도 인스턴스에 반영되는지 확인한다. 같은 좌표의 새 객체를 전달해도 드래그한 위치가 되돌아가지 않는지 확인한다.
- Loading: SDK 준비를 지연시켜 영역 크기가 유지되고 1초 이후 로딩 안내가 나타나는지 확인한다.
- LoadErrorAndRetry: SDK 로드 실패, 재시도 중 버튼 비활성화, 재시도 성공·재실패 상태를 확인한다.
- InvalidInput: 범위를 벗어난 좌표·level 및 NaN 입력에 대한 안내, 기존 정상 화면 유지, 정상 값으로 복구되는 동작을 확인한다.
- ResizeAndRemount: 모바일/데스크톱 크기, 숨김 후 표시, 반복 mount/unmount에서 지도 재배치와 listener·overlay 정리를 확인한다.
- 시각·상호작용 검증용 story는 SDK loader/adapter mock으로 성공·지연·실패를 재현한다. 실제 SDK 연결 story는 별도로 두어 등록된 Storybook 도메인에서 확인한다. 로딩·오류 재현을 위해 MapProps에 없는 prop을 추가하지 않는다.
-
역할
- 페이지 상단의 제목, left/right slot, 서비스 로고를 일관되게 배치한다.
- 뒤로가기 버튼과 보조 action은 slot으로 전달받아 렌더링한다.
- semantic 와 heading hierarchy를 보장한다.
-
props & Interface
export interface HeaderProps { title?: ReactNode; logo?: ReactNode; leftSlot?: ReactNode; rightSlot?: ReactNode; className?: string; }
Props 설명
Prop / 타입 필드 설명 title 페이지 제목을 배치하는 슬롯이다. logo 서비스 로고를 배치하는 슬롯이다. leftSlot 뒤로가기 등 왼쪽 액션을 전달한다. rightSlot 보조 버튼 등 오른쪽 액션을 전달한다. className 헤더 외곽의 추가 스타일이다. 내부 상태 상세
변경 가능한 내부 상태는 없다. 슬롯이 바뀌면 전달된 내용만 다시 렌더링한다. 뒤로가기/보조 액션의 이벤트와 진행 상태는 각 슬롯을 제공한 부모 또는 액션 컴포넌트가 관리한다.
-
내부 상태 & 이벤트
- 내부 상태는 없다.
- leftSlot, rightSlot에 전달된 action을 그대로 렌더링한다.
- 뒤로가기 버튼의 UI와 실제 라우팅은 상위 컴포넌트가 결정한다.
- right slot action의 상태는 해당 action 컴포넌트가 관리한다.
-
사용 예시
<Header
title="커뮤니티"
leftSlot={
<IconButton
icon={<ArrowLeftIcon aria-hidden="true" />}
label="뒤로가기"
onClick={() => router.back()}
/>
}
rightSlot={<Button aria-label="게시글 작성">작성</Button>}
/>-
스타일링
- 높이와 좌우 padding을 token으로 고정한다.
- 항상 position: sticky로 동작한다.
- icon-only control에는 accessible label을 제공한다.
-
에러 처리 / 엣지 케이스
- title, logo, leftSlot이 모두 없으면 빈 헤더를 렌더링하지 않고 개발 warning을 남긴다.
-
Storybook
- title만 있는 상태
- leftSlot에 back button을 전달한 상태
- logo
- right actions
- 긴 title
-
역할
- 여러 옵션 중 하나의 값을 선택하는 UI를 제공한다.
- Base UI Select를 감싼 프로젝트 공통 wrapper로 사용한다.
- 택시, 자차, 지하철, 버스처럼 선택한 값을 폼 데이터로 관리해야 하는 경우에 사용한다.
-
props & Interface
export interface SelectOption<T extends string = string> { value: T; label: ReactNode; disabled?: boolean; } export interface SelectProps<T extends string = string> { label: ReactNode; options: SelectOption<T>[]; value: T | null; onValueChange: (value: T | null) => void; placeholder?: ReactNode; disabled?: boolean; className?: string; }
Props 설명
Prop / 타입 필드 설명 label 선택 입력의 필수 이름이다. options 선택 후보 배열이다. 각 항목은 value/label/disabled를 갖는다. value 현재 선택값이다. null은 미선택이다. onValueChange 선택된 값 또는 null을 부모에 전달한다. placeholder 미선택 상태의 안내 콘텐츠다. disabled 선택 컴포넌트 전체의 사용 불가 상태다. className Select wrapper의 추가 스타일이다. SelectOption.value 폼에 저장할 고유 값이며 T extends string이다. SelectOption.label 옵션에 표시하는 콘텐츠다. SelectOption.disabled 해당 옵션만 선택할 수 없게 한다. 내부 상태 상세
선택값은 value/onValueChange를 통한 부모 소유다. popup 열림·현재 키보드 탐색 항목·focus 복귀는 Base UI Select가 관리한다. 탐색과 값 확정을 구분하고 선택 확정 시 callback을 호출한다. 외부 value가 바뀌면 표시를 갱신한다. options에서 찾을 수 없는 값은 기존 명세의 placeholder/invalid 처리로 표시한다.
-
내부 상태 & 이벤트
- base-ui의 Select를 BaseSelect로 alias해 이름 충돌을 방지한다.
- BaseSelect.Root, Label, Trigger, Value, Portal, Positioner, Popup, Item을 조합한다.
- 선택값은 value와 onValueChange로 관리한다.
- 키보드 탐색, focus management, escape close는 Base UI Select에 위임한다.
- disabled option은 선택할 수 없다.
-
사용 예시
import { Select as BaseSelect } from '@base-ui/react/select';
type Transport = 'taxi' | 'car' | 'subway' | 'bus';
const transportOptions: SelectOption<Transport>[] = [
{ value: 'taxi', label: '택시' },
{ value: 'car', label: '자차' },
{ value: 'subway', label: '지하철' },
{ value: 'bus', label: '버스' },
];
function TransportSelect(
props: Omit<SelectProps<Transport>, 'label' | 'options'>,
) {
return (
<Select
{...props}
label="이동수단"
options={transportOptions}
placeholder="이동수단 선택"
/>
);
}-
스타일링
- 드롭다운이 열리고 닫힐때 애니메이션을 추가한다
- 선택된 항목과 아닌 항목의 구분이 명확해야한다
-
에러 처리 / 엣지 케이스
- options가 비어 있으면 popup을 열지 않거나 empty state를 표시한다.
- label은 필수로 받아 accessible name을 보장한다.
- 중복된 option value를 허용하지 않는다.
- 현재 value가 options에 없으면 placeholder 또는 invalid 상태를 표시한다.
-
Storybook
- 기본 상태 (아무것도 선택하지 않은 상태)
- 펼쳐진 상태
- 항목이 선택된 상태
- disabled 상태
- 옵션 하나가 disabled인 상태
-
역할
- 사용자가 날짜 하나를 선택할 수 있는 입력 UI를 제공한다.
-
props & Interface
export type DateValue = string; // YYYY-MM-DD, timezone 없는 calendar date export interface DatePickerProps { value?: DateValue | null; defaultValue?: DateValue | null; onValueChange?: (value: DateValue | null) => void; min?: DateValue; max?: DateValue; disabled?: boolean; placeholder?: string; locale?: string; className?: string; }
Props 설명
Prop / 타입 필드 설명 value 부모가 제어하는 선택 날짜. YYYY-MM-DD 문자열 또는 null이다. defaultValue 비제어 사용 시 최초 선택 날짜다. onValueChange 날짜 선택 결과 또는 선택 해제 null을 전달한다. min / max 선택 가능 날짜 범위의 하한/상한이다. disabled 날짜 선택 사용 불가 상태다. placeholder 날짜가 없을 때의 안내다. locale 달력의 언어/날짜 표시 형식에 사용한다. className 날짜 선택기 외곽의 추가 스타일이다. 내부 상태 상세
선택값은 제어형이면 value, 비제어형이면 defaultValue로 초기화한 내부 값이 기준이다. 팝업 open, 표시 중인 년/월, 키보드로 탐색 중인 날짜, 년/월 선택 화면 여부는 로컬 UI 상태다. 처음 펼칠 때 현재 년/월을 보여 주는 기존 정책을 유지한다. 년/월 이동은 탐색 상태만 변경하고 날짜를 선택했을 때 값을 확정해 callback 후 popup을 닫는다.
-
내부 상태 & 이벤트
- 처음 펼쳐졌을때는 현재의 년/월 화면이 나오도록 한다.
- 하나의 날짜만 고를 수 있도록 한다.
- 날짜 선택 시 onValueChange를 호출하고 popup을 닫는다.
-
사용 예시
<DatePicker
value={startDate}
onValueChange={setStartDate}
min="2026-01-01"
placeholder="날짜를 선택해 주세요"
/>-
스타일링
- 오늘 날짜 / 선택 날짜 / 미선택 날짜를 구분할수있게 표현한다.
- DatePicker가 열리고 닫힐때 애니메이션을 추가한다.
-
에러 처리 / 엣지 케이스
- 하나의 날짜만 고를 수 있게 한다.
- 년/월 부분을 선택하면 휠 형태의 날짜 picker가 팝업된다.
-
Storybook
- 기본 상태
- 날짜를 고른 상태
- 년/월 변경 ui
- disabled 상태
- 잘못된 value
-
역할
- 사용자가 하루 중 시간을 선택할 수 있는 입력 UI를 제공한다.
-
props & Interface
export type TimeValue = string; // HH:mm, 24-hour local time export interface TimePickerProps { value?: TimeValue | null; defaultValue?: TimeValue | null; onValueChange?: (value: TimeValue | null) => void; min?: TimeValue; max?: TimeValue; step?: number; // minutes, default 30 disabled?: boolean; placeholder?: string; className?: string; }
Props 설명
Prop / 타입 필드 설명 value 부모가 제어하는 HH:mm 형식의 선택 시간이다. defaultValue 비제어 사용 시 최초 시간이다. onValueChange 확정된 시간 또는 null을 부모에 전달한다. min / max 선택 가능한 시간의 하한/상한이다. step 옵션 간 분 간격이다. 기존 명세의 기본값은 30분이다. disabled 시간 선택 사용 불가 상태다. placeholder 시간이 없을 때 안내다. className 시간 선택기 외곽의 추가 스타일이다. 내부 상태 상세
제어형 선택값은 부모, 비제어형 선택값은 defaultValue로 초기화한 내부 상태가 소유한다. popup open, 직접 입력 중인 문자열/parsing 결과, 탐색 중인 옵션은 내부 UI 상태다. 옵션은 step과 범위에서 계산하며 탐색 highlight와 확정값을 구분한다. 옵션 선택 시 HH:mm으로 포매팅해 onValueChange를 호출한다.
-
내부 상태 & 이벤트
- value/defaultValue와 onValueChange를 지원한다.
- 입력값 parsing, popup open, option highlight를 내부에서 관리한다.
- step에 맞는 option을 생성하고 선택 시 HH:mm으로 포매팅한다.
-
사용 예시
<TimePicker
value={startTime}
onValueChange={setStartTime}
step={30}
min="09:00"
max="18:00"
placeholder="시간을 선택해 주세요"
/>-
스타일링
- 입력 형식은 24시간 기준으로 통일한다.
- 선택된 시간, disabled 시간, 현재 focus option을 구분한다.
-
에러 처리 / 엣지 케이스
-
Storybook
- 빈 상태
- 기본 시간
- 30분/15분 step
- disabled
- invalid 입력
-
역할
- 아이콘 버튼이나 축약된 UI의 보조 설명을 제공한다.
- Base UI Tooltip.Provider, Tooltip.Root, Tooltip.Trigger, Tooltip.Popup을 조합한다.
-
props & Interface
export type TooltipSide = 'top' | 'right' | 'bottom' | 'left'; export interface TooltipProps { content: ReactNode; children: ReactNode; side?: TooltipSide; sideOffset?: number; delay?: number; disabled?: boolean; className?: string; }
Props 설명
Prop / 타입 필드 설명 content trigger를 보충하는 설명이다. children tooltip을 연결할 trigger 요소다. side top/right/bottom/left 중 표시 방향이다. sideOffset trigger와 popup 사이의 간격이다. delay tooltip 표시 시점에 사용하는 지연 값이다. disabled tooltip 표시 사용 불가 상태다. className tooltip popup의 추가 스타일이다. 내부 상태 상세
앱/feature root의 Tooltip.Provider를 공유한다. hover/focus에 따른 열림·닫힘과 지연 처리는 Base UI Tooltip이 관리한다. content와 표시 방향은 props에서 읽고 복제하지 않는다. trigger가 사라지면 연결된 popup과 진행 중인 표시 처리를 정리한다.
-
내부 상태 & 이벤트 앱 또는 feature root에서 Tooltip.Provider를 한 번 제공한다. pointer hover와 keyboard focus에서 tooltip을 표시한다.
-
사용 예시
<Tooltip
content="현재 위치로 이동합니다"
delay={300}
>
<button type="button" aria-label="현재 위치">
<LocationIcon aria-hidden="true" />
</button>
</Tooltip>-
스타일링
- popup은 trigger를 가리지 않도록 side offset을 둔다.
-
에러 처리 / 엣지 케이스
- content가 비어 있으면 tooltip을 렌더링하지 않는다.
- 모바일 환경에서는 trigerr 버튼을 누르면 delay 시간동안 툴팁이 노출되고, 해당 시간이 끝나면 자동으로 닫힌다.
- 툴팁이 팝업/닫힐때 서서히 나타나고 사라지는 애니메션을 추가한다.
-
Storybook
- icon trigger
- 각 side
- delay 변경
- bottomSheetStore: 바텀시트의 열림·닫힘 상태와 현재 snap point
- open: 바텀시트 표시 여부
- snapPoint: 현재 바텀시트 높이 또는 확장 단계
- openBottomSheet, closeBottomSheet, setSnapPoint, reset 액션 제공
interface BottomSheetState {
open: boolean;
snapPoint: number | string;
openBottomSheet: (snapPoint?: number | string) => void;
closeBottomSheet: () => void;
setSnapPoint: (snapPoint: number | string) => void;
reset: () => void;
}- postDetailStore: 게시글 상세 모달의 표시 여부와 대상 게시글 식별자
- open: 상세 모달 표시 여부
- postRef: 현재 선택된 게시글의 type과 id, 대상이 없으면 null
- openPostDetail(postRef), closePostDetail, reset 액션 제공
- openPostDetail 호출 시 모달을 열고 게시글 식별자를 저장한다.
- closePostDetail 호출 시 모달을 닫고 postRef를 null로 초기화한다.
- 상세 제목·본문·작성자 등 서버 데이터는 postRef를 query key로 사용하는 React Query에서 조회한다.
interface PostReference {
type: 'COMPANION' | 'COMMUNITY';
id: number;
}
interface PostDetailState {
open: boolean;
postRef: PostReference | null;
openPostDetail: (postRef: PostReference) => void;
closePostDetail: () => void;
reset: () => void;
}- locationStore: 사용자의 위치 좌표와 위치 권한 상태
- coordinate: 마지막으로 확인된 위치 좌표, 없으면 null
- permissionStatus: prompt | requesting | granted | denied | unavailable
- isLoading: 위치 조회 진행 여부
- errorMessage: 위치 조회 실패 시 안내 메시지
- setCoordinate, setPermissionStatus, setLoading, setError, reset 액션 제공
- 브라우저 권한 요청과 geolocation.watchPosition 구독은 위치 hook 또는 서비스 계층에서 수행하고, 결과만 스토어에 반영한다.
- 권한 요청을 무한히 반복하지 않도록 denied와 unavailable 상태를 구분한다.
interface LocationState {
coordinate: MapCoordinate | null;
permissionStatus: LocationPermissionStatus;
isLoading: boolean;
errorMessage: string | null;
setCoordinate: (coordinate: MapCoordinate | null) => void;
setPermissionStatus: (status: LocationPermissionStatus) => void;
setLoading: (isLoading: boolean) => void;
setError: (errorMessage: string | null) => void;
reset: () => void;
}- postDraftStore: 작성 중인 게시글의 임시 입력 상태
- type: COMPANION 또는 COMMUNITY
- title, content: 제목과 본문
- origin, destination, departureAt, capacity, transport: 동행 게시글 전용 입력값
- setField, setType, resetDraft, saveDraft 액션 제공
- 작성 화면 진입 시 기존 임시 작성 상태를 불러올 수 있고, 게시글 작성 완료·취소·로그아웃 시 resetDraft로 초기화한다.
- 서버에 게시글 작성 요청이 성공한 뒤에만 작성 완료로 간주하며, 요청 실패 시에는 사용자가 재시도할 수 있도록 draft를 유지한다.
- 동행모집 게시글 작성 중, type을 COMMUNITY로 변경하면 동행 게시글 전용 입력값은 초기화된다.
- 해당 데이터는 Zutstand persist로 세션스토리지에 저장되며, 새로고침해도 데이터가 사라지지 않는다.
단일 컴포넌트 내 UI 상태 (e.g., 토스트 팝업, 모달 열림/닫힘 , 입력 필드 값 상태)
API 응답 데이터 캐싱 (e.g., 핀 데이터, 게시글 목록, 게시글 상세 정보 등)
-
GET //nearby-posts?lat={}&lng={}&sw_lat={}&sw_lng={}&ne_lat={}&ne_lng={}&cursor={cursor} : 근처 게시글 조회
- 홈화면 접근 시 호출, 캐시된 데이터가 fresh하다면 캐시데이터 노출
- 게시글 리스트 페이지 접근 시에는 캐시된 데이터 확인 후 해당 데이터가 stale하면 요청
-
GET /map-pins?sw_lat={}&sw_lng={}&ne_lat={}&ne_lng={} : 지도 핀 조회
- 홈화면 접근 시 호출, 캐시된 데이터가 fresh하다면 캐시데이터 노출
- 유저가 지도 드래그를 종료하면 호출
-
GET/companion-posts/{companion_id} : 동행모집 게시글 상세 조회
-
GET /community-posts/{companion_id} : 커뮤니티 게시글 상세 조회
- 해당 게시글의 핀을 클릭했을때 호출
- 게시글 상세 페이지 접근 시 호출
- 캐시된 데이터가 fresh하다면 캐시데이터 노출
- 데이터를 불러오는 중에 아래와 같은 행동을 하면 이전 요청 취소
- 다른 핀을 클릭
- 모달을 닫기
-
GET /community-posts/{post_id}/comments?cursor={cursor} : 댓글 목록 조회
- 커뮤니티 게시글의 핀을 클릭했을때 호출
- 커뮤니티 게시글의 상세 페이지 접근 시 호출
- 캐시된 데이터가 fresh하다면 캐시데이터 노출
-
POST /community-posts/{post_id}/comments : 댓글 등록
- 커뮤니티 게시글에서 댓글 등록 버튼 클릭 시 호출
-
POST /companion-posts : 동행모집 게시글 등록
- 동행 모집 게시글 작성 페이지에서 등록 버튼 클릭 시 호출
-
POST /community-posts : 커뮤니티 게시글 등록
- 커뮤니티 게시글 작성 페이지에서 등록 버튼 클릭 시 호출
- fetch API 사용
- API 요청/응답 상태에 따른 로딩 인디케이터(Spinner, Skeleton UI) 표시.
- 버튼 로딩의 경우는 Spinner 표시
- 게시글 목록이나 게시글 상세같은 경우는 스켈레톤 표시
- 지도 위에 올라가는 핀이나 사용자 위치는 이전 값을 표시하거나 아예 표시하지 않는다.
- 모든 로딩 상태는 로딩 시간이 1초 이상일때 표시한다.
- API 오류 발생 시 사용자에게 친화적인 메시지 표시 및 로깅.
- POST 요청의 경우 기본적으로는 토스트를 통해 알린다.
- GET 요청의 경우는 해당 섹션에 오류 안내와 재시도 버튼을 제공한다.
- React Query를 활용하여 중복 호출 방지, 캐시 관리, 자동 재요청 등 처리.
- React Hook Form 사용하여 폼 상태 관리 및 유효성 검증 로직 구현.
- 각 페이지에서 폼을 정의할 때는
useForm()훅을 최상단에서 사용. - 복잡한 폼 등은 별도 컴포넌트로 분리하여
useFormContext()또는Controller를 활용.
- 각 페이지에서 폼을 정의할 때는
- 유효성 검증 로직
-
클라이언트 측:
- React Hook Form의
register에required,pattern등 기본 옵션 사용. -
Zod를 연동해
resolver형태로 적용. - 제출 시(
handleSubmit) 폼 전역의 모든 필드에 대한 동시 검증이 이뤄짐.
- React Hook Form의
-
서버 측(백엔드)에서도 별도 검증 로직 존재:
- 서버로 전송한 데이터가 유효하지 않을 경우(예: 허용되지 않은 포맷, 중복된 쿠폰 등), API 에러(4xx)로 응답.
- 프론트엔드 처리: 이 에러를 받아서 폼 필드로 노출 (예: “본문을 500자 미만으로 작성해주세요”).
- 최초 유효성 검사 시점은 버튼 클릭시, 재검사는 onChange 시점에 진행
- 오류 발생 시 인풋 필드에 명확한 오류 메시지 표시
-
클라이언트 측:
-
Unit Test: Vitest
- 순수 함수, 유틸리티, Zustand store 단위 테스트
-
Integration Test: Vitest + React Testing Library + MSW
- 여러 컴포넌트의 상호작용
- 사용자 입력 및 상태 변경
- API 요청·성공·실패 시나리오
-
E2E Test: Playwright
- 실제 브라우저 환경의 주요 사용자 흐름
- 게시글 조회, 핀 선택, 상세 모달, 게시글 작성 등
- GA를 사용해 사용자 인터렉션 분석
- Sentry를 통해 에러분석
- 디자인시스템에 있는 컴포넌트를 최대한 재사용
- 기반이 되는 기본 공통 컴포넌트 구축에 신경쓰기