유저 도메인 테크 스펙 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki
- 사용자가 랜딩 → 구글 로그인 → 회원가입(신규일때만) → 메인 화면(캘린더) 진입까지 끊김 없이 진행하도록 UX를 단순화함
- 유저 관련 화면에서 전역 상태(Zustand)/서버 캐시(TanStack Query) 전략을 표준화해 개발 효율과 유지보수성을 높임
- 핵심 결과 (Key Result) 1: 로그인/회원가입 플로우에서 무한 리다이렉트/진입 실패율 0% 달성 (Playwright E2E 기준)
- 핵심 결과 (Key Result) 2: 인증 만료/401 발생 시나리오에서 자동 복구(refresh) 성공/실패 분기가 요구사항대로 동작함을 보장 (Playwright E2E + Vitest/RTL 테스트 통과 기준)
- 핵심 결과 (Key Result) 3: 유저 관련 공통 컴포넌트(폼/모달/프로필 카드/목록 아이템) 재사용률 50% 이상 확보 (Storybook 스토리 수 기준)
- 가입/로그인 분기 복잡성
- 회원/비회원 분기가 명확하지 않으면
/signup진입 조건이 꼬이거나, 보호 페이지 접근 시 리다이렉트가 반복되어 무한 루프가 발생하기 쉬움 - 특히 OAuth 리다이렉트 이후 상태(토큰 저장/내 정보 조회)가 불안정하면 분기 오류가 생길 수 있음
- 회원/비회원 분기가 명확하지 않으면
- 폼 UX 품질 리스크
- 닉네임은 길이/문자 규칙/금칙어/중복(서버 409)처럼 예외 케이스가 많아, 에러 메시지 처리나 버튼 활성화 조건이 조금만 흔들려도 UX가 쉽게 깨짐
- helper text 위치가 고정되지 않으면 입력 중 레이아웃이 밀리는 등 사용성이 떨어짐
- 팔로우 기능 상태 동기화 문제
- 팔로우/언팔로우 이후에 목록(팔로워, 팔로잉), 미니 프로필 모달, 팔로워/팔로잉 카운트가 서로 다른 값으로 보이는 문제가 발생하기 쉬움
- 원인은 캐시 갱신(invalidate) 누락, 낙관적 업데이트/서버 응답 반영 타이밍 불일치, 여러 쿼리의 데이터 소스가 분리된 경우
- 개발 생산성 저하
- 내 정보, 다른 유저 정보, 팔로잉/팔로워 목록을 화면별로 각각 구현하면 API 호출/타입/로딩, 에러 처리 방식이 중복되고, 작은 정책 변경(필드 추가, 응답 구조 변경)에도 수정 범위가 넓어져 유지보수 비용이 커짐
- 공통 훅/컴포넌트/쿼리키 규칙이 없으면 도메인 확장 시 코드 일관성이 깨지기 쉬움
유저 도메인에서
- 라우팅 가드(AuthGate) 규칙을 표준화
- 서버 상태를 TanStack Query로 일관되게 캐싱/갱신
- 모달 열림/닫힘, 탭 선택 등 UI 임시 상태를 Zustand로 분리
- 모달/프로필 카드/목록 컴포넌트를 공통 컴포넌트로 표준화
⇒ 로그인/회원가입 분기와 팔로우 상태 동기화에서 발생하던 오류를 줄임
⇒ UX를 안정화
⇒ 중복 구현 최소화
⇒ 개발 생산성과 유지보수성 개선
- 요구사항 정의서, 화면 설계서, 백엔드 API 명세
- 전사 디자인 시스템 개편
- shadcn/ui 기반으로 필요한 UI를 구성하되, 전사적 디자인 가이드/토큰/컴포넌트 정책을 변경하는 작업은 범위에 포함하지 않음
- 추천/탐색 기능 (유저 추천, 랭킹 등)
- 팔로우/언팔로우 및 팔로워/팔로잉 목록 확인까지만 포함
- 추천 알고리즘, 유저 탐색/랭킹/추천 피드 등은 후속 과제로 진행
- 프레임워크/라이브러리: React + Next.js
- 목적: 유저 플로우(랜딩→로그인→회원가입→메인)에서 라우팅/가드 정책을 일관되게 적용
- 서버 상태 캐싱(Server State): TanStack Query
- 적용 대상:
me,followings/followers,user/{id},myPosts,myComments등 - 이유
- 서버 데이터는 캐싱/리패치/무한스크롤이 핵심
- 팔로우/언팔로우 이후 invalidate로 동기화해야 목록/모달/카운트 불일치 문제가 줄어듦
- 적용 대상:
- 전역 UI 상태(Global UI State): Zustand
- 적용 대상: 모달 열림/닫힘, 현재 탭 상태, 탈퇴 확인 입력값, 파일 업로드 실패 모달 등
- 이유
- 서버 데이터와 분리된 순수 UI 임시 상태를 가볍게 공유/관리하기 좋음
- 모달 상태를 페이지 전반에서 일관되게 제어할 수 있음
- 폼 관리: React Hook Form + Zod
- 적용 대상: 회원가입(MEM-001) 닉네임/관심분야, 프로필 수정 모달(MEM-002-1), 탈퇴 확인 입력(MEM-002-3)
- 이유
- 닉네임 규칙/헬퍼 텍스트/submit 제어가 명확해짐
- 서버 409(중복) 같은 에러를
setError로 필드에 연결하기 쉬움
- 스타일링/UI: Tailwind CSS + shadcn/ui
- 이유
- 입력/모달/리스트 UI를 구현하면서도 컴포넌트 재사용(Storybook) 기준을 맞추기 쉬움
- 이유
(1) / 랜딩 페이지 (AUT-001)
- 주요 기능
- 서비스 소개 슬라이드 최대 5개
- 사용자가 스와이프하여 슬라이드 전환 가능
- 동일 슬라이드 5초 유지 시 자동으로 다음 슬라이드로 이동
- 하단 도트 네비게이션으로 현재 슬라이드 위치 표시
- 구글 OAuth2 로그인/회원가입 시작 버튼 제공
- 사용 컴포넌트
-
LandingCarousel- 역할: 서비스 소개 슬라이드 렌더링, 스와이프 전환, 5초 자동 넘김, 도트 네비 표시
- 주요 Props
-
slides- 캐러셀에서 렌더링할 슬라이드 데이터 목록
- 요구사항 상 최대 5개까지 전달
-
autoPlayDelayMs- 자동 넘김 간격(ms)
- 요구사항인 5초 자동 이동을
5000으로 고정해서 구현 가능
-
swipeEnabled- 사용자가 스와이프/드래그로 슬라이드를 넘길 수 있는지 여부
-
showDots- 하단 도트 네비게이션 표시 여부
- 현재 몇 번째 슬라이드인지 시각적으로 보여줌
-
onSlideChange- 현재 슬라이드 인덱스가 바뀔 때 호출되는 콜백
- 도트 UI 동기화, 사용자 행동 분석, 로그 기록 등 부가 기능 확장에 사용 가능
-
-
GoogleLoginButton- 역할
- 사용자가 클릭하면 구글 OAuth2 인증을 시작하고, 인증 완료 후 우리 서비스로 리다이렉트되어 서버가 회원 여부를 판단할 수 있도록 플로우를 트리거함
- 리다이렉트 트리거
- 역할
-
- 데이터 로딩 시점
- 없음 (정적 UI 중심)
- 단, 로그인 버튼 클릭 시 OAuth 리다이렉트 흐름으로 진입
- 라우팅
-
GoogleLoginButton클릭 → OAuth 인증 시작 - 인증 성공 후 서버에서 회원 여부 판단
- 신규 유저:
/signup로 이동 - 기존 유저: 로그인 완료 후
/calendar로 이동
- 신규 유저:
-
(2) /signup 회원가입 페이지 (MEM-001)
- 주요 기능
- 프로필 사진 업로드(선택)
- 로컬 파일 선택(+ 버튼)
- 미등록 시 기본 프로필(특정 색상 배경 + 닉네임 첫 글자) 사용
- 등록 시 이미지 적용 + 검은 오버레이(투명도 50%)
- 파일 제한: 2MB 이하, 확장자 jpg/jpeg/png/webp
- 제한 초과 시 첨부 실패 모달(FileTooLargeModal) 노출 및 첨부 취소
- 닉네임 입력(필수)
- placeholder:
닉네임을 입력해주세요(2~10자) - 유효성: 2~10자, 띄어쓰기/특수문자 금지, admin/운영자 관련 단어 금지
- 서버 중복(409) 발생 시 helper text로
*중복된 닉네임입니다.표시
- placeholder:
- 관심 분야 선택(선택)
- 미선택 가능
- API가 배열을 요구하면 미선택 시
[]로 전송 - 버튼(칩) 클릭 시 토글, 선택 상태에 따라 스타일 변경
- 시작하기 버튼 (회원가입 제출)
- 닉네임 유효성 미통과 시 비활성화
-
닉네임 유효성 통과AND회원가입 요청 중 아님(isSubmitting)일 때만 활성 (disabled = !nicknameValid || isSubmitting)- 클릭 시 회원가입 요청
- 프로필 사진 업로드(선택)
- 사용 컴포넌트
-
ProfileImagePicker(프로필 이미지 선택/미리보기/삭제(가입은 선택) + 2MB 제한 검사) -
NicknameField(닉네임 입력 + helper text + 금칙어/형식 검증) -
InterestChips(관심분야 토글 UI) -
PrimaryButton(시작하기/제출 버튼) -
FileTooLargeModal(프로필 이미지 2MB 초과 시 안내 모달)
-
- 데이터 로딩 시점
- 페이지 진입 시 사전 로딩 없음(정적 UI)
- 제출 시점에만 API 호출(회원가입)
- 라우팅
- 가입 성공 → 자동 로그인 처리 →
/calendar이동 - 가입 성공 토스트:
회원가입이 완료되었습니다. - 가입 실패 시: 필드 에러(예: 409 중복)
- 가입 성공 → 자동 로그인 처리 →
(3) /profile 마이페이지 (MEM-002)
-
주요 기능
- 내 프로필 정보 표시
- 프로필 이미지, 닉네임, 관심 분야(해시태그)
- 수정하기 버튼 → 회원정보 수정 모달 오픈
- 팔로워/팔로잉 수 표시 및 이동
- 1000명 이상일 경우 1k 표기
- 팔로워/팔로잉이 0명인 경우 클릭 비활성화
- 내가 쓴 글 / 내가 쓴 댓글 탭
- 기본값: 내가 쓴 글
- 탭 전환 시 해당 목록으로 교체 렌더링
- 각 목록은 무한 스크롤, 최신순, 1회 최대 5개 로드
- 회원 정보 수정 플로우
- 프로필 사진/닉네임/관심 분야 수정
- 닉네임 검증(2~10자, 공백/특수문자 금지, admin/운영자 금지)
- 성공 시 헬퍼 텍스트로 “회원 정보가 성공적으로 변경되었습니다.”
- 회원 탈퇴 플로우(Soft Delete)
- “탈퇴하겠습니다/${nickname}” 정확히 입력해야 탈퇴 버튼 활성화
- 탈퇴 완료 시 완료 모달 → 홈으로 돌아가기(랜딩 이동)
- 내 프로필 정보 표시
-
사용 컴포넌트
-
MyProfileHeader- 역할: 프로필 이미지/닉네임/관심태그 + 수정하기 버튼 표시
-
FollowStatButtons- 역할: 팔로워/팔로잉 수 표시(1k 포맷) + 이동 트리거(0이면 비활성)
-
MyPostsTab- 역할: 내가 쓴 글 목록 렌더링(무한스크롤, 최신순, size=5)
-
MyCommentsTab- 역할: 내가 쓴 댓글 목록 렌더링(무한스크롤, 최신순, size=5)
-
EditProfileModal- 역할: 프로필 사진/닉네임/관심 분야 수정 + 변경하기/탈퇴하기 버튼 포함
-
WithdrawConfirmModal- 역할: 탈퇴 확인 입력(“탈퇴하겠습니다/${nickname}”) + 탈퇴 실행
-
WithdrawCompleteModal- 역할: 탈퇴 완료 안내 + 홈으로 돌아가기 액션
모달 오픈/닫힘 상태는 Zustand로 표준화
- 여러 곳에서 열 수 있고, 닫힘/전환 흐름이 명확해짐
-
-
데이터 로딩 시점
- 페이지 진입 시
-
GET /users/me호출 (TanStack Query로 캐싱)
-
- 탭 데이터 로딩
- 기본 탭(내 글):
GET /users/me/posts?size=5&lastId=... - 댓글 탭 클릭 시:
GET /users/me/comments?size=5&lastId=… - 무한스크롤은
useInfiniteQuery기반으로 페이지네이션 처리 권장
- 기본 탭(내 글):
- 페이지 진입 시
-
라우팅
- 팔로워/팔로잉 클릭 시
/profile/follows?tab=followers/profile/follows?tab=followings
- 목록 아이템 클릭 시
- 내가 쓴 글 → 게시글 상세(
/board/:postId) - 내가 쓴 댓글 → 댓글이 달린 게시글 상세(
/board/:postId)로 이동
- 내가 쓴 글 → 게시글 상세(
- 팔로워/팔로잉 클릭 시
-
캐시 동기화 규칙
- 프로필 수정 성공 후:
invalidateQueries(['me'])
- 팔로우/언팔로우로 인해 카운트가 바뀌는 경우
-
invalidateQueries(['me'])+ 해당 목록 key도 함께 invalidate
-
- 탈퇴 성공 후
- 클라이언트 인증 상태 초기화(토큰/세션 제거) + 전역 캐시 reset
- 랜딩으로 이동
- 프로필 수정 성공 후:
(4) /profile/follows 팔로워/팔로잉 목록 (MEM-003)
- 주요 기능
- 팔로워/팔로잉 탭 전환
- 기본 탭은
/profile에서 클릭한 항목을 따라감(tab파라미터 기반) - 현재 활성 탭은 밑줄 하이라이트로 표시
- 기본 탭은
- 무한 스크롤 목록
- 최신 팔로우 순 정렬
- 한 번에 최대 12명 로드
- 스크롤 하단 도달 시 다음 페이지 요청
- 유저 클릭 → 미니 프로필 모달
- 리스트 아이템 클릭 시 해당 유저의 미니 프로필 모달을 오픈
- 모달에는 프로필 이미지/닉네임/관심 태그(해시태그) 노출
- 팔로우/언팔로우
- 미팔로우 상태:
팔로우버튼 → 팔로우 API 호출 후 UI 반영 - 팔로잉 상태:
팔로잉버튼 →UnfollowConfirmModal오픈 → 확정 시 언팔로우 처리 - (팔로우/언팔로우 성공 후) 목록/모달/카운트 간 상태 불일치가 발생하지 않도록 캐시 갱신 규칙 적용
- 미팔로우 상태:
- 팔로워/팔로잉 탭 전환
- 사용 컴포넌트
-
FollowTabs: 팔로워/팔로잉 탭 UI 및 전환 트리거 -
FollowList: 목록 렌더링 + 무한스크롤 트리거(12개 단위) -
FollowListItem: 유저 1명 단위 렌더링(프로필 이미지/닉네임/상태) -
MiniProfileModal: 유저 요약 정보 + 빠른 액션(1:1 채팅, 팔로우/언팔로우) -
UnfollowConfirmModal: 언팔로우 확정 모달(게시글 삭제 모달 UI 재사용)
-
- 데이터 로딩 시점
- 페이지 진입 시 현재 탭에 맞는 목록 fetch
-
followers:GET /users/me/followers?size=12&lastId=… -
followings:GET /users/me/followings?size=12&lastId=…
-
- 탭 변경 시 해당 탭 query로 교체 로딩
- 페이지 진입 시 현재 탭에 맞는 목록 fetch
- 라우팅
-
MEM-002에서 넘어올 때:/profile/follows?tab=followers/profile/follows?tab=followings
- 페이지 내부에서 탭 클릭 시에도 querystring만 변경(새로고침해도 유지)
-
(1) ProfileImagePicker
- 역할
- 프로필 이미지 선택/미리보기 UI 제공
- 파일 확장자/용량(2MB) 검증
- 2MB 초과 시 FileTooLargeModal 트리거
- 디자인/동작 핵심
- 기본: 프로필 사진 + (+버튼)
- 이미지 등록 시: 이미지가 영역을 채움 + 검은 오버레이 50%
- (MEM-002 수정 모달에서) 기존 프로필 이미지가 있으면 URL 프리뷰를 기본으로 표시 가능
- props & Interface
type ProfileImagePickerProps = {
/** 새로 선택된 파일(없으면 null) */
value: File | null;
onChange: (file: File | null) => void;
/** 기존(서버) 프로필 이미지 URL (수정 화면에서 사용) */
initialImageUrl?: string | null;
/** 파일 제한 */
maxSizeBytes?: number; // 기본 2MB
accept?: string[]; // ['image/jpeg','image/png','image/webp'] (MEM-002는 webp 제외 가능)
/** 에러/모달 트리거 */
onTooLarge?: () => void; // 2MB 초과 모달 오픈 트리거
onInvalidType?: () => void; // 확장자/타입 불일치 안내(선택)
};-
Props 설명
-
value- 사용자가 이번에 새로 선택한 파일
- 없으면
null
-
initialImageUrl- 이미 서버에 등록되어 있는 기존 프로필 이미지 URL
-
value가null일 때 기본 프리뷰로 사용 가능(수정 화면)
-
maxSizeBytes- 파일 용량 제한(기본 2MB)
-
accept- 허용 MIME 타입 목록
- 실제
<input accept="...">에는accept.join(',')형태로 전달
-
onTooLarge- 2MB 초과 시 안내 모달을 열기 위한 트리거
-
onInvalidType- 허용되지 않은 타입 선택 시 안내(토스트/헬퍼 등) 트리거
- (필수는 아니고 UX 향상용)
-
-
내부 상태 & 이벤트
- 내부 상태는 최소화하고,
value를 부모 폼 상태(RHF)에서 관리 - 프리뷰 우선 순위
-
value(새 파일)가 있으면 →URL.createObjectURL(value)프리뷰 - 없으면 →
initialImageUrl프리뷰 - 둘 다 없으면 → 기본 프로필(특정 색상 배경 + 닉네임 첫 글자)
-
- 내부 상태는 최소화하고,
-
이벤트 흐름
-
+클릭 → 숨겨진<input type="file">실행 - 파일 선택
- 검증
-
확장자/타입 불일치
→
onInvalidType?.()호출→ 상태 변경 없이 종료 (기존 프리뷰 유지)
-
2MB 초과
→
onTooLarge?.()호출→ 상태 변경 없이 종료
-
통과
→
onChange(file)호출
-
-
-
에러 처리 / 엣지 케이스
- 2MB 초과
- 해당 파일은 첨부되지 않음
- 기존에 첨부된 파일에는 영향을 주지 않음
- 구현 규칙: 초과 시
onChange를 호출하지 않는다
- 확장자/타입 불가
-
<input accept>로 1차 제한 - 선택 후에도 코드에서 2차 검증
- 불일치 시 파일 반영 X
-
- 같은 파일을 연속해서 다시 선택하는 경우가 있을 수 있음
- nput value 초기화로 재선택 가능하게 처리 가능
- 2MB 초과
-
스타일링
- 등록 상태
- 프리뷰 이미지가 영역을 채우고
- 위에 검은 레이어(opacity 50%)를 덮어 편집 가능 상태를 표현
- 미등록 상태
- 기본 컬러 배경
- 중앙에
+버튼
- 등록 상태
-
Storybook
-
Default(미등록: value=null, initialImageUrl=null) -
WithInitialUrl(기존 이미지 URL 프리뷰) -
WithNewFile(새 파일 선택 프리뷰) -
TooLarge(2MB 초과 선택 →onTooLarge호출 확인) -
InvalidType(허용 타입 외 선택 →onInvalidType호출 확인)
-
(2) FileTooLargeModal
- 역할
- 프로필 이미지가 2MB 초과일 때 안내 모달
- Props
type FileTooLargeModalProps = {
open: boolean;
onClose: () => void;
};- 동작 요구사항 체크
-
X클릭 → 닫힘 - 파일은 첨부되지 않음
- 기존 첨부 파일에 영향 없음
- 이건 ProfileImagePicker가 2MB 초과 시 상태를 바꾸지 않는 방식으로 보장됨.
-
- Storybook
OpenClosed
(3) NicknameField
- 역할
- 닉네임 입력 + 유효성 검증 결과를 helper text로 표시
- 유효하면 helper 제거, 에러면 고정 위치 표시(밀리지 않음)
- 유효성 규칙
- 필수
- 길이 2~10자
- 띄어쓰기/특수문자 금지
-
admin,운영자등 금칙어 포함 금지 - 서버 중복(409):
*중복된 닉네임입니다.
- Props & Interface
type NicknameFieldProps = {
value: string;
onChange: (v: string) => void;
errorMessage?: string | null;
placeholder?: string; // "닉네임을 입력해주세요(2~10자)"
};- 내부 상태 & 이벤트
- 클라이언트 검증: 길이/문자/금칙어 (입력 중/blur 등 팀 룰에 맞춤)
- 서버 검증: 제출 시 409 → 필드 에러 반영
- React Hook Form 사용 시
- 409 응답 →
setError("nickname", { message: "중복된 닉네임입니다." })
- 409 응답 →
- React Hook Form 사용 시
- helper text 밀리지 않게 구현
- helper 영역 높이를 항상 확보
- 에러 없으면 빈 문자열/투명 처리
- 레이아웃이 위아래로 흔들리지 않음
- Storybook
ValidTooShortTooLongHasSpaceOrSpecialContainsAdminWordDuplicate(409)
(4) InterestChips
- 역할: 관심 분야 버튼(칩) 토글 UI
- 필수 아님
- Props
type InterestKey = "BACKEND" | "FRONTEND" | "CLOUD" | "AI";
type InterestChipsProps = {
options: { key: InterestKey; label: string }[];
value: InterestKey[];
onChange: (next: InterestKey[]) => void;
};- 내부 상태 & 이벤트
- 칩 클릭 → 포함되어 있으면 제거 / 없으면 추가
- 미선택 가능
- API가 배열을 요구하면 미선택 시
[]로 전송
- Storybook
NoneSelectedOneSelectedMultiSelected
(5) PrimaryButton
- 역할
- 회원가입 제출 트리거
- 활성/비활성 조건이 명확해야 함
- 활성화 조건
- 닉네임 유효성 통과 전: disabled
- 통과 시: enabled
- 클릭 시
- 회원가입 성공 → 자동 로그인 →
/calendar이동 + 토스트
- 회원가입 성공 → 자동 로그인 →
- 구현 포인트
disabled = !formState.isValid || isSubmitting- 서버 응답 에러 처리
- 409 → nickname 필드 에러로 연결
- 기타 → 토스트/모달로 안내
- Storybook
DisabledEnabledLoading
(1) MyProfileHeader
- 역할
- 내 프로필 기본 정보 표시: 프로필 이미지 + 닉네임 + 관심분야(해시태그)
- 수정하기 버튼 제공 →
EditProfileModal오픈 트리거
- Props & Interface
type MyProfileHeaderProps = {
profileImageUrl: string | null; // 없으면 기본 아바타
nickname: string;
interests: string[]; // 예: ["프론트엔드", "클라우드"]
onOpenEdit: () => void; // 수정 모달 열기
};- 내부 상태 & 이벤트
- 상태: 거의 없음 (표시용)
- 이벤트
-
수정하기버튼 클릭 →onOpenEdit()호출
-
- 에러 처리 / 엣지 케이스
-
profileImageUrl === null이면 기본 아바타(이니셜/기본 색상) 렌더링 -
interests.length === 0이면- 해시태그 영역 숨김 또는 미설정 표시(팀 룰 1개로 고정)
-
- Storybook
-
Default(이미지 있음) -
NoImage(기본 아바타) -
NoInterests(관심분야 없음)
-
(2) FollowStatButtons
-
역할
- 팔로워/팔로잉 수 표시
- 클릭 시
/profile/follows?tab=...이동 트리거
-
요구사항
- 1,000 이상 →
1k표기 - 0명인 경우 → 클릭 비활성화
- 1,000 이상 →
-
Props & Interface
type FollowStatButtonsProps = { followerCount: number; followingCount: number; onClickFollowers: () => void; onClickFollowings: () => void; };
-
내부 상태 & 이벤트
- 상태: 없음
- 이벤트
-
followerCount === 0→ 팔로워 버튼 disabled -
followingCount === 0→ 팔로잉 버튼 disabled - 활성 상태에서 클릭 시 각각 콜백 호출
-
-
내부 로직
formatCount(999) => "999"formatCount(1000) => "1k"formatCount(1700) => "1.7k"
-
Storybook
Normal(17/20)Over1k(1000+)ZeroDisabled(0/0)
(3) MyActivityTabs (탭 버튼: “내가 쓴 글 / 내가 쓴 댓글”)
- 역할
- 현재 선택 탭 표시(활성/비활성)
- 탭 변경 이벤트를 상위로 전달
- Props & Interface
type MyActivityTabKey = "posts" | "comments";
type MyActivityTabsProps = {
active: MyActivityTabKey;
onChange: (next: MyActivityTabKey) => void;
};- 이벤트
- “내가 쓴 글” 클릭 →
onChange("posts") - “내가 쓴 댓글” 클릭 →
onChange("comments")
- “내가 쓴 글” 클릭 →
- Storybook
PostsActiveCommentsActive
(4) MyActivityList (내 글/내 댓글 목록 공통)
- 역할
- ‘내 글’ 또는 ‘내 댓글’ 목록 렌더링
- 인피니티 스크롤(최신순, 1회 5개)
- 0개면 빈 상태 메시지 표시
- 요구사항
- 글: 제목 + 작성일시(yyyy.mm.dd HH:MM)
- 댓글: 게시글 제목 + 댓글 내용 + 작성일시(yyyy.mm.dd HH:MM)
- 최신순, 한 번에 최대 5개 로드
- Props & Interface
type MyActivityListProps =
| {
type: "posts";
items: { id: string; title: string; createdAt: string }[];
onClickItem: (postId: string) => void;
onLoadMore: () => void;
hasNextPage: boolean;
isLoading: boolean;
isError: boolean;
}
| {
type: "comments";
items: {
id: string;
postId: string;
postTitle: string;
content: string;
createdAt: string;
}[];
onClickItem: (postId: string) => void;
onLoadMore: () => void;
hasNextPage: boolean;
isLoading: boolean;
isError: boolean;
};- 내부 상태 & 이벤트
- 서버 데이터는 TanStack Query
useInfiniteQuery로 관리 권장 - 스크롤 하단 도달 시
-
hasNextPage === true이면onLoadMore()호출
-
- 아이템 클릭 시
- posts:
onClickItem(postId) - comments:
onClickItem(postId)(댓글이 달린 게시글 상세로 이동)
- posts:
- 서버 데이터는 TanStack Query
- 에러 처리 / 엣지 케이스
- 0개면
- posts:
아직 작성한 게시글이 없습니다. - comments:
아직 작성한 댓글이 없습니다.
- posts:
- 로딩 중
- Spinner
- 에러
- ‘다시 시도’버튼
- 0개면
- Storybook
Posts/NormalPosts/EmptyComments/NormalComments/EmptyLoadingError
(5)EditProfileModal (회원정보 수정 모달)
- 역할
- 프로필 사진 / 닉네임 / 관심 분야 수정
- 변경 사항은 ‘변경하기’ 버튼 클릭 시에만 반영
- ‘탈퇴하기’클릭 시 → 이 모달 닫고
WithdrawConfirmModal오픈
- Props & Interface
type EditProfileModalProps = {
open: boolean;
onClose: () => void;
initial: {
profileImageUrl: string | null;
nickname: string;
interests: string[];
isDefaultProfileImage: boolean; // 삭제 버튼 활성화 판단
};
onSubmit: (payload: {
profileImageFile?: File | null; // 새 파일(선택)
deleteProfileImage?: boolean; // 삭제 버튼 눌렀는지
nickname: string;
interests: string[];
}) => Promise<void>;
onOpenWithdraw: () => void;
onTooLargeImage: () => void; // 2MB 초과 모달 트리거
};- 내부 상태 & 이벤트
- 프로필 이미지
-
ProfileImagePicker를 내부에 포함(수정 버전) - 제한: 2MB 이하, 확장자 jpg/jpeg/png
- 삭제 버튼
-
isDefaultProfileImage === false일 때만 활성화 - 클릭 시
- UI상 ‘기본 프로필로 변경됨’ 상태로 전환
-
deleteProfileImage = true로 제출 payload에 반영
-
- 2MB 초과 정책
- 초과 시
- 모달만 띄움(
onTooLargeImage()) - 기존 이미지 /선택 상태는 유지(상태 변경 X)
- 모달만 띄움(
- 초과 시
-
- 관심 분야 토글 정렬 규칙
- 기존 관심 분야: 초기 선택 상태로 시작
- 토글 규칙:
- 등록되면 상단으로
- 해제되면 하단으로
- 구현 포인트:
-
selected[] + unselected[]로 배열 재구성해서 렌더
-
- 닉네임 유효성 (가입과 동일)
- 2~10자
- 띄어쓰기/특수문자 금지
- admin/운영자 단어 금지
- 서버 중복(409) →
중복된 닉네임입니다.
- 변경하기 버튼
- 클릭 시에만 API 호출(
onSubmit) - 성공 시 helper text:
회원 정보가 성공적으로 변경되었습니다.
- 클릭 시에만 API 호출(
- 탈퇴하기 버튼
- 클릭 시
-
onClose()로 수정 모달 닫기 -
onOpenWithdraw()로 탈퇴 모달 열기
-
- 클릭 시
- 프로필 이미지
- 에러 처리 / 엣지 케이스
- 변경 없이 ‘변경하기’ 클릭
- 변경사항 없으면 API 호출 스킵 + 버튼 disabled 처리
- 서버 409
- 닉네임 필드 에러로 연결
- 기타 실패
- 토스트/모달 안내
- 변경 없이 ‘변경하기’ 클릭
- Storybook
Default-
WithCustomImage(삭제 버튼 활성) NicknameInvalidDuplicateError(409)InterestsReorderSubmitSuccessMessage
(6) WithdrawConfirmModal (회원 탈퇴 모달)
- 역할
- 확인 문구를 정확히 입력해야 ‘탈퇴하기’ 버튼 활성화
- 성공 시
WithdrawCompleteModal로 전환
- 요구사항
- placeholder:
탈퇴하겠습니다/${nickname} - 사용자가 입력한 값이 완전히 동일할 때만 버튼 활성화
- 회원 탈퇴는 Soft Delete
- placeholder:
- Props & Interface
type WithdrawConfirmModalProps = {
open: boolean;
nickname: string;
onClose: () => void;
onWithdraw: () => Promise<void>; // 탈퇴 API 호출
onSuccess: () => void; // 완료 모달로 전환 트리거
};- 내부 상태 & 이벤트
- 로컬 상태
-
inputText(사용자 입력)
-
- 활성 조건
-
inputText === 탈퇴하겠습니다/${nickname}일 때만 활성
-
- 버튼 클릭 흐름
-
onWithdraw()호출 (로딩 상태 표시) - 성공 →
onSuccess()호출 (완료 모달 오픈)
-
- 로컬 상태
- 에러 처리 / 엣지 케이스
- 공백 하나라도 다르면 비활성화 유지
- 탈퇴 API 실패
- 토스트 노출
- 탈퇴 성공 후
- 토큰/세션 제거 + React Query 캐시 reset + 랜딩 이동은 상위 페이지에서 처리
- Storybook
Disabled(미일치)Enabled(정확히 일치)LoadingError
(7) WithdrawCompleteModal (회원 탈퇴 완료 모달)
- 역할
- 탈퇴 완료 안내
- ‘홈으로 돌아가기’ 클릭 → 랜딩(
/) 이동
- Props & Interface
type WithdrawCompleteModalProps = {
open: boolean;
onGoHome: () => void; // "/" 이동
};- 내부 상태 & 이벤트
- 상태 없음
- 이벤트
- ‘홈으로 돌아가기’클릭 → 랜딩(
/) 이동
- ‘홈으로 돌아가기’클릭 → 랜딩(
- Props & Interface
type WithdrawCompleteModalProps = {
open: boolean;
onGoHome: () => void; // "/" 이동
};- 내부 상태 & 이벤트
- 상태 없음
- 이벤트
- ‘홈으로 돌아가기’클릭 →
onGoHome()호출
- ‘홈으로 돌아가기’클릭 →
- Storybook
OpenClosed
(1) FollowTabs
- 역할
- 팔로워/팔로잉 탭 전환 UI 제공
- 현재 활성 탭 밑줄 하이라이트
- 탭 전환 시 querystring(
tab) 변경으로 상태 유지(새로고침해도 유지)
- Props & Interface
type FollowTab = "followers" | "followings";
type FollowTabsProps = {
value: FollowTab; // 현재 활성 탭
onChange: (next: FollowTab) => void; // 탭 변경 트리거
followerCount?: number; // 선택: 탭 라벨에 카운트 표시할 때
followingCount?: number;
};- 내부 상태 & 이벤트
- 내부 상태 없음(표시 + 이벤트 트리거용)
- 이벤트
-
팔로워클릭 →onChange("followers") -
팔로잉클릭 →onChange("followings")
-
- 에러 처리 / 엣지 케이스
- URL에
tab이 없거나 이상한 값이면 기본 탭을 정해야 함
- URL에
- Storybook
- FollowersActive / FollowingsActive
- WithCounts
(2) FollowList
- 역할
- 팔로워/팔로잉 목록 렌더링
- 무한 스크롤 트리거
- 로딩/빈 상태/에러 상태 렌더링을 담당
- Props & Interface
type FollowUser = {
userId: string;
nickname: string;
profileImageUrl: string | null;
interests: string[]; // 해시태그 표시용
isFollowing?: boolean; // (followers 탭에서) 맞팔 여부 표시 등에 사용 가능
};
type FollowListProps = {
items: FollowUser[];
isLoading: boolean;
isFetchingNextPage: boolean;
hasNextPage: boolean;
onLoadMore: () => void; // 다음 페이지 요청(useInfiniteQuery의 fetchNextPage 연결)
onClickItem: (userId: string) => void; // 아이템 클릭 → 모달 오픈 트리거
emptyText: string; // 탭별 빈 상태 문구
};- 내부 상태 & 이벤트
- 무한스크롤 구현 방식
- 리스트 하단에
sentineldiv를 두고IntersectionObserver로 감지 - 감지 &&
hasNextPage면onLoadMore()실행
- 리스트 하단에
- 클릭 이벤트는 아이템에서 위로 올려받음
-
FollowListItem클릭 →onClickItem(userId)
-
- 무한스크롤 구현 방식
- 에러 처리 / 엣지 케이스
- 목록 0개
- 탭별 빈 상태 메시지 노출
- followers:
아직 팔로워가 없습니다. - followings:
아직 팔로잉이 없습니다.
- followers:
- 탭별 빈 상태 메시지 노출
- 다음 페이지 로딩 중엔 중복 호출 방지
-
isFetchingNextPage일 때onLoadMore막기
-
- 목록 0개
- Storybook
- Loading
- Empty
- Normal(12개)
- FetchingNextPage(하단 스피너)
(3) FollowListItem
-
역할
- 유저 1명 단위 렌더링 (프로필 이미지 + 닉네임)
- 클릭 시 MiniProfileModal 오픈 트리거
-
Props & Interface
type FollowListItemProps = { userId: string; nickname: string; profileImageUrl: string | null; onClick: (userId: string) => void; };
-
내부 상태 & 이벤트
- 내부 상태 없음
- 이벤트
- 아이템 클릭 →
onClick(userId)호출
- 아이템 클릭 →
-
엣지 케이스
-
profileImageUrl없으면 기본 아바타(이니셜/기본색)
-
-
Storybook
- Default
- NoImage
- LongNickname
(4) MiniProfileModal
-
역할
- 리스트에서 클릭한 유저의 미니 프로필 정보(이미지/닉네임/관심태그) 표시
- 빠른 액션 제공
- 1:1채팅 버튼(말풍선 버튼) → 해당 유저와 채팅방 입장
- 팔로우/팔로잉 버튼 → 상태에 따라 follow 또는 unfollow 흐름 진입
- 팔로잉 상태에서 버튼 클릭 시 UnfollowConfirmModal 오픈
-
Props & Interface
type MiniProfileModalProps = { open: boolean; onClose: () => void; user: { userId: string; nickname: string; profileImageUrl: string | null; interests: string[]; isFollowing: boolean; // 내가 이 사용자를 팔로우 중인지 } | null; onClickChat: (userId: string) => void; onClickFollow: (userId: string) => Promise<void>; // 팔로우 API 트리거 onClickUnfollow: (userId: string) => void; // 언팔 확인 모달 오픈 트리거 };
-
내부 상태 & 이벤트
- 모달 자체는 ‘표시 + 액션 트리거’ 중심(복잡한 서버 상태는 바깥에서 관리 권장)
- 이벤트 흐름
-
말풍선 버튼클릭 →onClickChat(userId) -
팔로우상태일 때 버튼 클릭 →onClickFollow(userId)실행 -
팔로잉상태일 때 버튼 클릭 →onClickUnfollow(userId)실행(= 확인 모달로 넘김)
-
-
에러 처리 / 엣지 케이스
-
user === null상태에서 open이면 로딩 처리 - 팔로우 API 실패 시
- 토스트로 실패 안내 + 버튼 상태 원복
- 관심 분야가 없으면 해시태그 영역 숨김
-
-
Storybook
- Loading(유저 null)
- NotFollowing(팔로우 버튼)
- Following(팔로잉 버튼)
- NoInterests
-
Global State (Zustand Stores)
-
uiStore- 목적
- 애플리케이션 전역에서 공통으로 사용되는 UI 상태를 관리
- 반복적으로 발생하는 모달 오픈/닫힘 및 전환 흐름을 표준화하기 위해 사용
- 관리 범위
- 모달 열림/닫힘 상태
- 모달 유형(어떤 모달이 열렸는지)
- 필요 시 모달에 전달되는 최소한의 payload
- 상태/액션 예시
activeModal: ModalType | nullopenModal(type, payload?)closeModal()
- 모달 타입 정의 원칙
- 동일 모달이 여러 페이지에서 재사용되는 경우는 페이지명이 아닌 기능 중심 이름을 사용함
- 전환 흐름이 중요한 경우(예: 탈퇴 확인 → 탈퇴 완료)는 모달을 단계별로 명확히 구분함
- 예시
editProfilewithdrawConfirmwithdrawCompletefileTooLargeminiProfileunfollowConfirm
- 목적
-
authStore- 목적
- 인증과 관련된 클라이언트 측 상태를 관리함
- 다만
me(내 정보)와 같은 서버 응답 데이터는authStore에 저장하지 않고, React Query의 캐시로 관리
- 관리 범위
- 로그인 여부(
isAuthenticated) - 토큰 또는 세션 기반 인증 상태(프로젝트 정책에 따름)
- refresh(자동 복구) 진행 상태(
isRefreshing) 등 중복 요청 방지용 플래그 - 로그아웃/탈퇴 성공 시 인증 상태 초기화(
clearAuth)
- 로그인 여부(
- 운영 원칙
- 인증 상태 변경(로그아웃/탈퇴) 이후에는 TanStack Query 캐시 초기화(reset/clear)를 함께 수행하여, 이전 사용자 데이터가 잔존하지 않도록 함
- 목적
-
-
Local State (Component State)
- 로컬 상태는 특정 컴포넌트 내부에서만 사용되며, 전역 공유가 불필요한 UI 임시 값에 한정함
- 대표 사례
-
WithdrawConfirmModal내부의 탈퇴 확인 입력 텍스트 - 프로필 수정 모달에서 ‘제출 전 임시로 선택된 파일’등(최종 제출 시점에만 서버 전송)
- 단일 화면에 국한되는 UI 토글 상태(단, 새로고침/공유가 필요하면 URL 쿼리로 승격 가능)
-
-
Server Cache State (React Query)
- 서버에서 제공되는 데이터는 React Query를 통해 조회·캐싱하며, 필요 시
invalidateQueries를 통해 갱신함 - 본 도메인은 팔로우/언팔로우, 프로필 수정, 목록 무한스크롤 등 캐시 동기화가 UX와 직접적으로 연결되는 영역이므로, 쿼리키를 명확히 정의하고 갱신 규칙을 표준화함
- 대표 Query 목록
-
me:GET /users/me -
userProfile(userId):GET /users/{id}(미니 프로필 모달이 상세 정보를 별도로 조회하는 경우) -
followers:GET /users/me/followers?size=12&lastId=...(useInfiniteQuery) -
followings:GET /users/me/followings?size=12&lastId=...(useInfiniteQuery) -
myPosts:GET /users/me/posts?size=5&lastId=...(useInfiniteQuery) -
myComments:GET /users/me/comments?size=5&lastId=...(useInfiniteQuery)
-
- Query key 표준
['me']['userProfile', userId]['followers']['followings']['myPosts']['myComments']
- 캐시 갱신 규칙
- 프로필 수정 성공 후:
invalidateQueries(['me']) - 팔로우/언팔로우 성공 후:
-
invalidateQueries(['me'])(카운트/상태 동기화 목적) - 현재 보고 있는 목록 탭에 해당하는 쿼리키(
['followers']또는['followings'])도 함께 invalidate
-
- 탈퇴 성공 후:
- 인증 상태 초기화(
authStore.clearAuth()등) - React Query 캐시 초기화(
queryClient.clear()또는resetQueries()정책 적용) - 랜딩(
/)으로 이동
- 인증 상태 초기화(
- 프로필 수정 성공 후:
- 서버에서 제공되는 데이터는 React Query를 통해 조회·캐싱하며, 필요 시
-
호출할 API 목록(유저 도메인)
- 회원가입:
POST /users - 내 정보 조회:
GET /users/me - 내 정보 수정:
PUT /users/me - 회원 탈퇴:
DELETE /users(⚠️ 내 계정 기준이면/users/me인지 백엔드 명세 확인 필요) - 팔로우:
POST /users/{userId}/followers - 언팔로우:
DELETE /users/{userId}/followers - 팔로잉 목록:
GET /users/me/followings?size&lastId&nickname - 팔로워 목록: (백엔드 명세 확인 필요 — 대칭 후보:
GET /users/me/followers?size&lastId&nickname) - 내가 쓴 글:
GET /users/me/posts?size&lastId - 내가 쓴 댓글:
GET /users/me/comments?size&lastId - 다른 유저 프로필:
GET /users/{userId}
- 회원가입:
- 각 API 호출 시점
-
/profile진입 시:GET /users/me -
/profile탭 전환 시:GET /users/me/posts또는GET /users/me/comments(무한스크롤) -
/profile/follows탭 진입 시: 팔로워/팔로잉 목록 API 호출 (무한스크롤) -
MiniProfileModal오픈 시:GET /users/{userId}- 모달은 즉시 오픈(리스트 데이터로 1차 렌더)하고, 상세 정보는 지연 로딩으로 갱신(2차 렌더)한다.
- 팔로우/언팔로우 클릭 시: mutation 호출 → 성공 후 캐시 갱신(invalidate)
-
- React Query 갱신 정책
- 팔로우/언팔로우 성공 시:
-
invalidateQueries(['me'])(내 카운트 동기화) - 현재 탭 목록에 맞는 쿼리 키 invalidate
- followings 탭이면
invalidateQueries(['followings']) - followers 탭이면
invalidateQueries(['followers'])
- followings 탭이면
-
invalidateQueries(['userProfile', userId])(모달/상대 프로필 동기화)
-
- 팔로우/언팔로우 성공 시:
-
라우팅 분류 기준
- 인증 여부에 따라 Public/Protected 라우트를 분리하여 운영
- Public 영역은 비로그인 상태에서도 접근 가능하며, Protected 영역은 로그인(토큰/세션 유효)이 확인된 사용자만 접근할 수 있음
-
Public Routes
-
/(AUT-001, 랜딩)- 비로그인 사용자의 최초 진입 지점
- Google OAuth2 로그인/회원가입 플로우 시작 버튼 제공
-
-
rotected Routes
-
/profile(MEM-002, 마이페이지) -
/profile/follows(MEM-003, 팔로워/팔로잉 목록) -
/calendar(메인 캘린더) - 그 외 서비스 핵심 기능 페이지(게시판/채팅 등)
Protected Routes 접근 시 인증이 확인되지 않으면 Public(
/)로 리다이렉트 -
-
특수 규칙:
/signup접근 정책- 접근 허용 조건
- OAuth 인증은 완료되었으나, 서버 기준 가입 정보 미완성 상태인 사용자
- 예: 백엔드가 신규 회원으로 판단한 경우(회원 여부 판단 결과)
- 차단 및 리다이렉트 규칙
- 이미 가입 완료된 기존 유저가
/signup에 접근한 경우-
/calendar(또는/profile)로 즉시 이동시켜 중복 가입/흐름 꼬임 방지
-
- 이미 가입 완료된 기존 유저가
- 접근 허용 조건
-
구현 방식 제안
- AuthGate(Protected Guard): Protected Routes에 공통 적용
- 인증 실패(401 등) →
/로 이동
- 인증 실패(401 등) →
- SignupGate(Guest-Only Guard):
/signup전용 적용- “이미 가입 완료” 상태 →
/calendar(또는/profile)로 이동 - “신규(가입 미완료)” 상태 →
/signup접근 허용
- “이미 가입 완료” 상태 →
- AuthGate(Protected Guard): Protected Routes에 공통 적용
- 회원가입/프로필 수정 폼: React Hook Form 사용
- 검증 방식
- 클라이언트 측
- 닉네임: Zod 스키마로 규칙 고정(2~10자, 공백/특수문자 금지, 금칙어)
- 프로필 이미지: 파일 선택 순간에 타입/용량 검사
- 서버 측
- 닉네임 중복 같은 건 서버에서 409로 내려올 수 있음 → 프론트는 응답을 받아 helper text 처리
- 클라이언트 측
- 실시간 검증 타이밍
- 닉네임은
onBlur - helper text는 위치 고정
- 닉네임은
-
테스트 전략
- Unit: 닉네임 검증 함수/훅
- Integration: 회원가입 폼 → 가입 성공 → 메인 이동
- E2E(Playwright):
- 비로그인 보호 페이지 접근 → AuthGate 동작
- 팔로우/언팔로우 후 목록/모달 동기화
- 탈퇴 플로우 완료 후 랜딩 이동
-
로깅/분석
- 가입 완료 이벤트, 팔로우 이벤트, 탈퇴 이벤트
- 409(중복 닉네임) 발생률 추적 → 닉네임 정책 개선 근거로 사용 가능
- AuthGate: 보호 페이지 접근 시 로그인 상태를 확인하고, 비로그인이면 랜딩/로그인으로 보내는 라우팅 가드
- InvalidateQueries: React Query에서 캐시를 무효화해서 최신 데이터로 다시 받아오게 하는 것
-
Cursor Pagination:
lastId를 기준으로 다음 페이지를 가져오는 무한스크롤 방식