유저 도메인 테크 스펙 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

1️⃣ 배경 (Background)

프로젝트 목표 (Objective)

  • 사용자가 랜딩 → 구글 로그인 → 회원가입(신규일때만) → 메인 화면(캘린더) 진입까지 끊김 없이 진행하도록 UX를 단순화함
  • 유저 관련 화면에서 전역 상태(Zustand)/서버 캐시(TanStack Query) 전략을 표준화해 개발 효율과 유지보수성을 높임
    • 핵심 결과 (Key Result) 1: 로그인/회원가입 플로우에서 무한 리다이렉트/진입 실패율 0% 달성 (Playwright E2E 기준)
    • 핵심 결과 (Key Result) 2: 인증 만료/401 발생 시나리오에서 자동 복구(refresh) 성공/실패 분기가 요구사항대로 동작함을 보장 (Playwright E2E + Vitest/RTL 테스트 통과 기준)
    • 핵심 결과 (Key Result) 3: 유저 관련 공통 컴포넌트(폼/모달/프로필 카드/목록 아이템) 재사용률 50% 이상 확보 (Storybook 스토리 수 기준)

문제 정의 (Problem)

  • 가입/로그인 분기 복잡성
    • 회원/비회원 분기가 명확하지 않으면 /signup 진입 조건이 꼬이거나, 보호 페이지 접근 시 리다이렉트가 반복되어 무한 루프가 발생하기 쉬움
    • 특히 OAuth 리다이렉트 이후 상태(토큰 저장/내 정보 조회)가 불안정하면 분기 오류가 생길 수 있음
  • 폼 UX 품질 리스크
    • 닉네임은 길이/문자 규칙/금칙어/중복(서버 409)처럼 예외 케이스가 많아, 에러 메시지 처리나 버튼 활성화 조건이 조금만 흔들려도 UX가 쉽게 깨짐
    • helper text 위치가 고정되지 않으면 입력 중 레이아웃이 밀리는 등 사용성이 떨어짐
  • 팔로우 기능 상태 동기화 문제
    • 팔로우/언팔로우 이후에 목록(팔로워, 팔로잉), 미니 프로필 모달, 팔로워/팔로잉 카운트가 서로 다른 값으로 보이는 문제가 발생하기 쉬움
    • 원인은 캐시 갱신(invalidate) 누락, 낙관적 업데이트/서버 응답 반영 타이밍 불일치, 여러 쿼리의 데이터 소스가 분리된 경우
  • 개발 생산성 저하
    • 내 정보, 다른 유저 정보, 팔로잉/팔로워 목록을 화면별로 각각 구현하면 API 호출/타입/로딩, 에러 처리 방식이 중복되고, 작은 정책 변경(필드 추가, 응답 구조 변경)에도 수정 범위가 넓어져 유지보수 비용이 커짐
    • 공통 훅/컴포넌트/쿼리키 규칙이 없으면 도메인 확장 시 코드 일관성이 깨지기 쉬움

가설 (Hypothesis)

유저 도메인에서

  • 라우팅 가드(AuthGate) 규칙을 표준화
  • 서버 상태를 TanStack Query로 일관되게 캐싱/갱신
  • 모달 열림/닫힘, 탭 선택 등 UI 임시 상태를 Zustand로 분리
  • 모달/프로필 카드/목록 컴포넌트를 공통 컴포넌트로 표준화

⇒ 로그인/회원가입 분기와 팔로우 상태 동기화에서 발생하던 오류를 줄임

⇒ UX를 안정화

⇒ 중복 구현 최소화

⇒ 개발 생산성과 유지보수성 개선

관련 자료

  • 요구사항 정의서, 화면 설계서, 백엔드 API 명세

2️⃣ 목표가 아닌 것 (Non-goals)

  • 전사 디자인 시스템 개편
    • shadcn/ui 기반으로 필요한 UI를 구성하되, 전사적 디자인 가이드/토큰/컴포넌트 정책을 변경하는 작업은 범위에 포함하지 않음
  • 추천/탐색 기능 (유저 추천, 랭킹 등)
    • 팔로우/언팔로우 및 팔로워/팔로잉 목록 확인까지만 포함
    • 추천 알고리즘, 유저 탐색/랭킹/추천 피드 등은 후속 과제로 진행

3️⃣ 설계 및 기술 자료 (Architecture and Technical Documentation)

아키텍처 개요 (Architecture Overview)

  • 프레임워크/라이브러리: 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) 기준을 맞추기 쉬움

주요 페이지 / 컴포넌트 구조

페이지 (Pages)

(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로 *중복된 닉네임입니다. 표시
    • 관심 분야 선택(선택)
      • 미선택 가능
      • 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로 교체 로딩
  • 라우팅
    • MEM-002에서 넘어올 때:
      • /profile/follows?tab=followers
      • /profile/follows?tab=followings
    • 페이지 내부에서 탭 클릭 시에도 querystring만 변경(새로고침해도 유지)

주요 컴포넌트 (MEM-001)

(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
      • valuenull일 때 기본 프리뷰로 사용 가능(수정 화면)
    • 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 초기화로 재선택 가능하게 처리 가능
  • 스타일링

    • 등록 상태
      • 프리뷰 이미지가 영역을 채우고
      • 위에 검은 레이어(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
    • Open
    • Closed

(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: "중복된 닉네임입니다." })
  • helper text 밀리지 않게 구현
    • helper 영역 높이를 항상 확보
    • 에러 없으면 빈 문자열/투명 처리
      • 레이아웃이 위아래로 흔들리지 않음
  • Storybook
    • Valid
    • TooShort
    • TooLong
    • HasSpaceOrSpecial
    • ContainsAdminWord
    • Duplicate(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
    • NoneSelected
    • OneSelected
    • MultiSelected

(5) PrimaryButton

  • 역할
    • 회원가입 제출 트리거
    • 활성/비활성 조건이 명확해야 함
  • 활성화 조건
    • 닉네임 유효성 통과 전: disabled
    • 통과 시: enabled
    • 클릭 시
      • 회원가입 성공 → 자동 로그인 → /calendar 이동 + 토스트
  • 구현 포인트
    • disabled = !formState.isValid || isSubmitting
    • 서버 응답 에러 처리
      • 409 → nickname 필드 에러로 연결
      • 기타 → 토스트/모달로 안내
  • Storybook
    • Disabled
    • Enabled
    • Loading

주요 컴포넌트 (MEM-002)

(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명인 경우 → 클릭 비활성화
  • 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
    • PostsActive
    • CommentsActive

(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) (댓글이 달린 게시글 상세로 이동)
  • 에러 처리 / 엣지 케이스
    • 0개면
      • posts: 아직 작성한 게시글이 없습니다.
      • comments: 아직 작성한 댓글이 없습니다.
    • 로딩 중
      • Spinner
    • 에러
      • ‘다시 시도’버튼
  • Storybook
    • Posts/Normal
    • Posts/Empty
    • Comments/Normal
    • Comments/Empty
    • Loading
    • Error

(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:
        • 회원 정보가 성공적으로 변경되었습니다.
    • 탈퇴하기 버튼
      • 클릭 시
        • onClose()로 수정 모달 닫기
        • onOpenWithdraw()로 탈퇴 모달 열기
  • 에러 처리 / 엣지 케이스
    • 변경 없이 ‘변경하기’ 클릭
      • 변경사항 없으면 API 호출 스킵 + 버튼 disabled 처리
    • 서버 409
      • 닉네임 필드 에러로 연결
    • 기타 실패
      • 토스트/모달 안내
  • Storybook
    • Default
    • WithCustomImage (삭제 버튼 활성)
    • NicknameInvalid
    • DuplicateError(409)
    • InterestsReorder
    • SubmitSuccessMessage

(6) WithdrawConfirmModal (회원 탈퇴 모달)

  • 역할
    • 확인 문구를 정확히 입력해야 ‘탈퇴하기’ 버튼 활성화
    • 성공 시 WithdrawCompleteModal로 전환
  • 요구사항
    • placeholder: 탈퇴하겠습니다/${nickname}
    • 사용자가 입력한 값이 완전히 동일할 때만 버튼 활성화
    • 회원 탈퇴는 Soft Delete
  • 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(정확히 일치)
    • Loading
    • Error

(7) WithdrawCompleteModal (회원 탈퇴 완료 모달)

  • 역할
    • 탈퇴 완료 안내
    • ‘홈으로 돌아가기’ 클릭 → 랜딩(/) 이동
  • Props & Interface
type WithdrawCompleteModalProps = {
  open: boolean;
  onGoHome: () => void; // "/" 이동
};
  • 내부 상태 & 이벤트
    • 상태 없음
    • 이벤트
      • ‘홈으로 돌아가기’클릭 → 랜딩(/) 이동
  • Props & Interface
type WithdrawCompleteModalProps = {
  open: boolean;
  onGoHome: () => void; // "/" 이동
};
  • 내부 상태 & 이벤트
    • 상태 없음
    • 이벤트
      • ‘홈으로 돌아가기’클릭 → onGoHome() 호출
  • Storybook
    • Open
    • Closed

주요 컴포넌트 (MEM-003)

(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이 없거나 이상한 값이면 기본 탭을 정해야 함
  • 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;            // 탭별 빈 상태 문구
};
  • 내부 상태 & 이벤트
    • 무한스크롤 구현 방식
      • 리스트 하단에 sentinel div를 두고 IntersectionObserver로 감지
      • 감지 && hasNextPageonLoadMore() 실행
    • 클릭 이벤트는 아이템에서 위로 올려받음
      • FollowListItem 클릭 → onClickItem(userId)
  • 에러 처리 / 엣지 케이스
    • 목록 0개
      • 탭별 빈 상태 메시지 노출
        • followers: 아직 팔로워가 없습니다.
        • followings: 아직 팔로잉이 없습니다.
    • 다음 페이지 로딩 중엔 중복 호출 방지
      • isFetchingNextPage일 때 onLoadMore 막기
  • 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

상태 관리 전략 (State Management Strategy)

  • Global State (Zustand Stores)
    • uiStore
      • 목적
        • 애플리케이션 전역에서 공통으로 사용되는 UI 상태를 관리
        • 반복적으로 발생하는 모달 오픈/닫힘 및 전환 흐름을 표준화하기 위해 사용
      • 관리 범위
        • 모달 열림/닫힘 상태
        • 모달 유형(어떤 모달이 열렸는지)
        • 필요 시 모달에 전달되는 최소한의 payload
      • 상태/액션 예시
        • activeModal: ModalType | null
        • openModal(type, payload?)
        • closeModal()
      • 모달 타입 정의 원칙
        • 동일 모달이 여러 페이지에서 재사용되는 경우는 페이지명이 아닌 기능 중심 이름을 사용함
        • 전환 흐름이 중요한 경우(예: 탈퇴 확인 → 탈퇴 완료)는 모달을 단계별로 명확히 구분함
      • 예시
        • editProfile
        • withdrawConfirm
        • withdrawComplete
        • fileTooLarge
        • miniProfile
        • unfollowConfirm
    • 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() 정책 적용)
        • 랜딩(/)으로 이동

API 연동 (API Integration)

  • 호출할 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'])
      • invalidateQueries(['userProfile', userId]) (모달/상대 프로필 동기화)

라우팅 (Routing)

  • 라우팅 분류 기준

    • 인증 여부에 따라 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 등) → /로 이동
    • SignupGate(Guest-Only Guard): /signup 전용 적용
      • “이미 가입 완료” 상태 → /calendar(또는 /profile)로 이동
      • “신규(가입 미완료)” 상태 → /signup 접근 허용

폼 처리 및 유효성 검증 (Forms & Validation)

  • 회원가입/프로필 수정 폼: React Hook Form 사용
  • 검증 방식
    • 클라이언트 측
      • 닉네임: Zod 스키마로 규칙 고정(2~10자, 공백/특수문자 금지, 금칙어)
      • 프로필 이미지: 파일 선택 순간에 타입/용량 검사
    • 서버 측
      • 닉네임 중복 같은 건 서버에서 409로 내려올 수 있음 → 프론트는 응답을 받아 helper text 처리
  • 실시간 검증 타이밍
    • 닉네임은 onBlur
    • helper text는 위치 고정

4️⃣ 이외 고려사항들 (Other Considerations) (Optional)

  • 테스트 전략
    • Unit: 닉네임 검증 함수/훅
    • Integration: 회원가입 폼 → 가입 성공 → 메인 이동
    • E2E(Playwright):
      • 비로그인 보호 페이지 접근 → AuthGate 동작
      • 팔로우/언팔로우 후 목록/모달 동기화
      • 탈퇴 플로우 완료 후 랜딩 이동
  • 로깅/분석
    • 가입 완료 이벤트, 팔로우 이벤트, 탈퇴 이벤트
    • 409(중복 닉네임) 발생률 추적 → 닉네임 정책 개선 근거로 사용 가능

5️⃣ 용어 정의 (Glossary) (Optional)

  • AuthGate: 보호 페이지 접근 시 로그인 상태를 확인하고, 비로그인이면 랜딩/로그인으로 보내는 라우팅 가드
  • InvalidateQueries: React Query에서 캐시를 무효화해서 최신 데이터로 다시 받아오게 하는 것
  • Cursor Pagination: lastId를 기준으로 다음 페이지를 가져오는 무한스크롤 방식

⚠️ **GitHub.com Fallback** ⚠️