Skip to content

Conversation

@Wannys26
Copy link

배포 링크

🔗배포 링크

💅피그마 링크

🗓️QA 진행 노션


구현 화면

홈 페이지
image

채팅 목록 페이지
image

프로필 페이지 - 나
image

통화 페이지
image


느낀 점

제가 26일 일요일 오후에 시험이 끝나서 조급하게 했습니다. 저번 리뷰에 달아주신 내용을 반영하지는 못했습니다..🥺
우측 상단에 보낸 메시지 삭제 버튼 클릭시, 로컬 스토리지에 있는 메시지들이 삭제되게 해서, 리뷰를 더 편하게 하실 수 있도록 했습니다. 이번 과제 리뷰도 잘 부탁드립니다 ㅎ


✨ 주요 기능

🏠 홈 화면: 내 프로필, 친구 목록, 그룹 목록을 확인할 수 있습니다. 각 항목은 섹션별로 접고 펼칠 수 있는 UI를 적용했습니다.

💬 채팅 목록: 진행 중인 모든 채팅방의 목록을 보여줍니다. 각 채팅방의 마지막 메시지와 시간을 기준으로 정렬되며, 읽지 않은 메시지 수도 표시됩니다.

🗣️ 채팅방: 개인 채팅과 그룹 채팅을 지원합니다.

실시간 메시지 전송: 메시지를 입력하고 전송하면 채팅 내역이 실시간으로 업데이트됩니다.

사용자 시점 변환: 채팅방 내에서 다른 참여자의 프로필 사진을 클릭하여 해당 사용자의 시점으로 대화를 볼 수 있는 기능을 구현했습니다.

데이터 영속성: 주고받은 메시지는 로컬 스토리지를 사용하여 저장되므로, 브라우저를 새로고침해도 대화 내용이 유지됩니다.

👤 프로필 화면: 사용자의 프로필 이미지, 이름, 상태 메시지를 표시합니다. 내 프로필과 다른 사람의 프로필에 따라 다른 액션 버튼(예: 프로필 편집, 1:1 채팅, 음성 통화)이 나타납니다.

📞 통화 모달: 음성 통화 버튼 클릭 시 통화 연결 중임을 나타내는 로딩 모달이 나타납니다.

상태 관리: Zustand를 사용하여 사용자, 채팅, 모달에 대한 상태를 전역으로 관리하여 컴포넌트 간의 데이터 흐름을 효율적으로 처리합니다.

📂 폴더 구조

프로젝트는 기능별로 명확하게 역할을 분리하여 다음과 같이 구성했습니다.

src
├── assets/             # 폰트, SVG 아이콘 등 정적 파일
│   ├── fonts/
│   └── svgs/
├── components/         # 재사용 가능한 UI 컴포넌트
│   ├── chatlist/
│   ├── chatroom/
│   ├── common/
│   ├── header/
│   ├── home/
│   ├── layout/
│   └── statusbar/
├── data/               # 사용자 및 채팅방 목업(mockup) 데이터 (JSON)
│   ├── chatRooms.json
│   └── users.json
├── hooks/              # 커스텀 React Hooks
│   ├── useChat.ts
│   └── useLocalStorage.ts
├── pages/              # 라우팅 단위가 되는 페이지 컴포넌트
│   ├── ChatList.tsx
│   ├── ChatRoom.tsx
│   ├── Home.tsx
│   └── Profile.tsx
├── store/              # Zustand를 사용한 전역 상태 관리
│   ├── callModalStore.ts
│   ├── chatStore.ts
│   └── userStore.ts
├── styles/             # 전역 CSS 및 폰트 설정
│   ├── fonts.css
│   └── globals.css
├── types/              # TypeScript 타입 정의
│   ├── chat.ts
│   └── chatlist.ts
├── utils/              # 공통 유틸리티 함수
│   ├── messageUtils.ts
│   ├── profileUtils.ts
│   ├── timeUtils.ts
│   └── userUtils.ts
├── App.tsx             # 라우팅 설정
└── main.tsx            # 애플리케이션 진입점

Review Questions

React Router의 동적 라우팅(Dynamic Routing)이란 무엇이며, 언제 사용하나요?

정의

  • URL의 특정 부분을 변수처럼 사용하여 하나의 라우트로 여러 페이지 처리
  • : 기호로 동적 세그먼트 정의

기본 사용법

// 라우트 정의
<Route path="/user/:userId" element={<UserProfile />} />

// 컴포넌트에서 사용
import { useParams } from 'react-router-dom';

function UserProfile() {
  const { userId } = useParams();
  return <div>User ID: {userId}</div>;
}

참고 자료


네트워크 속도가 느린 환경에서 사용자 경험을 개선하기 위해 사용할 수 있는 UI/UX 디자인 전략과 기술적 최적화 방법은 무엇인가요?

UI/UX 디자인 전략

스켈레톤 스크린

  • 빈 화면 대신 콘텐츠 윤곽 표시
  • 체감 로딩 속도 개선

점진적 로딩

  • 중요한 콘텐츠 우선 표시
  • 부가 콘텐츠는 지연 로드

명확한 피드백

  • 로딩 상태 표시
  • 진행률 바 또는 메시지 제공

오프라인 지원

  • Service Worker 활용
  • 캐시된 데이터로 기본 기능 제공

기술적 최적화 방법

이미지 최적화

<img 
  src="image.webp" 
  loading="lazy"
  srcSet="small.webp 400w, large.webp 800w"
/>

Code Splitting

const Heavy = lazy(() => import('./Heavy'));

<Suspense fallback={<Skeleton />}>
  <Heavy />
</Suspense>

데이터 캐싱

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUser,
  staleTime: 5 * 60 * 1000
});

기타 최적화

  • HTTP 요청 최소화 (API 병합)
  • CDN 활용
  • Gzip/Brotli 압축
  • 번들 크기 최적화

참고 자료

React에서 useState와 useReducer를 활용한 지역 상태 관리와 Context API 및 전역 상태 관리 라이브러리의 차이점을 설명하세요.

지역 상태 관리

useState

const [count, setCount] = useState(0);
  • 간단한 상태 (폼 입력, 토글 등)
  • 단일 컴포넌트 내부

useReducer

const [state, dispatch] = useReducer(reducer, initialState);
  • 복잡한 상태 로직
  • 여러 연관된 상태값
  • Redux 패턴, 테스트 용이

Context API

특징

  • 상태 전달 도구 (관리 도구 아님)
  • useState/useReducer와 함께 사용

장점

  • prop drilling 해결
  • 설정 간단, 추가 라이브러리 불필요

단점

  • 성능 이슈 (Provider value 변경 시 전체 리렌더링)
  • 디버깅 도구 부족

적합 케이스

  • 테마, 언어 설정
  • 인증 정보
  • 자주 변경되지 않는 전역 설정

전역 상태 라이브러리

Zustand (가볍고 간단)

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 }))
}));

// Provider 없이 사용
const { count, increment } = useStore();

Redux Toolkit (대규모 앱)

const slice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: { increment: state => { state.value += 1 } }
});

선택 가이드

상황 추천 도구
단일 컴포넌트 간단한 상태 useState
복잡한 로컬 상태 로직 useReducer
prop drilling 해결 (소규모) Context API
중간 규모 앱 Zustand
대규모, 복잡한 비즈니스 로직 Redux Toolkit
서버 상태 React Query/SWR

실전 팁

  • useState로 시작
  • prop drilling 3단계 이상 → Context 고려
  • Context 성능 문제 → Zustand 전환
  • Redux는 정말 필요할 때만 (복잡한 미들웨어, DevTools 필수)

참고 자료

Copy link

@only1Ksy only1Ksy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요, 주완 님! 이번주차 과제도 수고 많으셨습니다 👏 오늘 오후에 시험이 끝나신다니... 파이팅!!!


const DateSeparator = ({ date, isFirstMessage = false }: DateSeparatorProps) => {
return (
<div className={`flex justify-center items-center ${isFirstMessage ? 'mb-4' : 'mt-8 mb-4'}`}>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div className={`flex justify-center items-center ${isFirstMessage ? 'mb-4' : 'mt-8 mb-4'}`}>
<div className={`flex justify-center items-center mb-4 ${!isFirstMessage && 'mt-8'}`}>

사소하지만 이렇게 하면 공통 스타일 분리할 수 있을 것 같네요!

<div
className={`px-3 pt-[7px] pb-[6px] break-all flex items-center ${
isMe
? 'bg-white text-gray-7'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기는 조건부 제거해도 되지 않을까요??

ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onKeyPress는 deprecated 된 방법이라 React에서는 onKeyDown을 사용하길 권장한다고 하네요!
onKeyPress deprecated (onKeyPress를...

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우왕 이 부분까지 구현하셨네요 👍 👍

<img src={Filter} alt="필터" className="w-[22px] h-[22px] text-gray-0" />
</button>
<button className='cursor-pointer'>
<img src={Image} alt="이미지" className="w-[22px] h-[22px]" />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전체적으로 svg 파일을 img로 불러와서 쓰고 계신데, svg를 컴포넌트화 하면 상태에 따라 UI 변화를 tailwind로 대응하기 더 쉬울 것 같습니다!

[React] vite + ts에서 vite-plugin-svgr로 svg 사용 설정


interface ChatroomHeaderProps {
title?: string;
onBack?: () => void;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chatroom에서 back btn을 눌렀을 때, 현재 선택된 유저가 기본으로 돌아오면 좋을 것 같아요! 지금은 그대로 저장되는 것 같은데 각 채팅방마다 저장된 값이 달라서 헷갈리네용


<div className="flex items-center gap-1">
<span className="text-body3-r text-gray-6">2</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 아이콘들과 다르게 이 부분만 inline svg로 삽입하신 이유가 있을까요?? 동일한 패턴으로 통일하면 좋을 것 같습니다!

return (
<div className="flex items-center justify-between w-[335px] mx-auto py-3">
<span className="text-body3-m1 text-gray-6">LINE 서비스</span>
{/* arrow-down.svg로 대체 필요 */}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

</div>
{/* 우측 아이콘들 */}
<div className='gap-[7px] flex justify-center items-center'>
<img src={Signal} alt="mobile signal" className={isProfilePage ? 'brightness-0 invert' : ''} />

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<img src={Signal} alt="mobile signal" className={isProfilePage ? 'brightness-0 invert' : ''} />
<img src={Signal} alt="mobile signal"className={isProfilePage && 'brightness-0 invert'} />

Comment on lines +14 to +18
const lastMessage = messages[messages.length - 1];
let lastMessageDate: Date | null = null;
if (lastMessage) {
const lastTimestamp = new Date(lastMessage.timestamp);
lastMessageDate = new Date(lastTimestamp.getFullYear(), lastTimestamp.getMonth(), lastTimestamp.getDate());

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 lastMessageDate 값을 만드는 이유가 isSameDate에 넘겨주기 위함인 것 같은데, isSameDate 내부 로직을 보니 여기서 수행하는 new Date(lastTimestamp.getFullYear(), lastTimestamp.getMonth(), lastTimestamp.getDate());랑 동일한 로직으로 작동하는 것 같아요!

이 부분을 const lastMessage = messages[messages.length - 1]; 만 남겨두고,
아래 조건문에서 if (!lastMessage || !isSameDate(new Date(lastMessage.timestamp), today)) 이런 식으로 처리할 수도 있을 것 같습니당!

users={users}
currentUserId={currentUserId}
/>
)}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

친구 프로필 이미지를 불러오는 데 어려움이 있었는데, 저도 주완님처럼 개인 프로필과 그룹프로필 나눠서 불러오면 더 쉬웠을 거 같습니다! 저도 나중에 리팩토링할때 반영해볼게요 👍

[
{
"chatId": "1",
"chatType": "individual",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chatType key가 있어서 채팅방 데이터 불러올 때 좀 더 편할 수 있곘네요!👍

userId={user.id}
showProfile={showProfile}
isLastInGroup={isLastInGroup}
onUserNameClick={onUserNameClick}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사용자 이름 클릭하면 대화 상대와 '나'의 버블이 바뀌는 거 같은데 이 기능의 목적이 궁금합니당

<div
className="flex justify-center items-center px-3 py-[6px] rounded-[30px]"
style={{
background: 'rgba(255, 255, 255, 0.20)',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

왜 tailwind로 다 적용안하고 분리했는지 궁금합니당

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

내 프로필에서는 프로필 편집 버튼이라 이 아이콘이 맞는데 친구 프로필에서는 음성통화 버튼에 이 아이콘이 들어가 있습니다! 기능에 맞는 아이콘을 사용하면 더 좋을 거 같아요..!

// 채팅방에 입장하면 unreadCount를 0으로 설정
if (roomId) {
updateUnreadCount(roomId, 0);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 기능까지 개발하셨네요👍

return otherParticipants || currentChatRoom.chatName;
}
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컴포넌트 폴더에 있는 ChatListItem 파일에서
getChatRoomName 이름의 함수랑 똑같은 함수가 반복되는 거 같습니다..!


{/* 친구 섹션 */}
<div className="flex flex-col gap-4 py-4">
<CollapsibleSection title="친구" count={friends.length}>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

친구 수 표시도 되고 숨길 수도 있네요👍


// 특정 메시지 삭제
const deleteMessage = (messageId: string) => {
setMessages(prev => prev.filter(msg => msg.id !== messageId));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

메시지 삭제 기능까지 구현하신 게 인상적입니다

// 3초 후 자동으로 닫기
const timer = setTimeout(() => {
onClose();
}, 3000);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전 자동으로 닫는 것까지는 생각을 못했는데 유저 입장에서 편리할 것 같습니다!

Copy link

@jungyungee jungyungee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

과제 너무 수고 많으셨습니다..!
너무 잘하셔서 보면서 깜짝 놀랐습니다...
읽지 않은 메세지 읽고 나면 읽지 않은 숫자 표시 사라지는 것이나, 메세지 보낸 순서에 따라 메세지 리스트 창에서 정렬되는 것 (최근 메세지가 가장 위로 오게 하는 것!) 넘 좋습니다..
프로필에서 채팅방 넘어가는 것이나, 전화페이지 로딩스피너 등도 사소하지만 UI 적으로 신경 많이 쓰신 걸 느꼈습니다...
코드나 구현하신 것 보면서 저는 왜 이렇게 하지 않았지,,하고 많이 배웠습니다
너무 수고 많으셨어요.!!!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

favicon을 적용한 것 좋습니다..!

Comment on lines +37 to +50
{!isMe && showProfile && (
<div className="flex flex-col items-center mr-2">
<div className="w-8 h-8 rounded-full overflow-hidden flex-shrink-0">
<img
src={userProfile}
alt="profile"
className="w-full h-full object-cover"
/>
</div>
</div>
)}

{/* 상대방 메시지에서 프로필이 없을 때 공간 확보 */}
{!isMe && !showProfile && (

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기 같은 조건이 중복되는 것 같아서 공통조건은 묶어두면 더 깔끔할 것 같습니다..!

Comment on lines +63 to +66
const sortedChatRooms = [...chatRooms].sort((a, b) => {
return new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime();
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이렇게 되면 렌더링 시마다 새로 배열이 생성되어서 useMemo를 사용해서

const sortedChatRooms = useMemo(() => {
  return [...chatRooms].sort((a, b) => 
    new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime()
  );
}, [chatRooms]);

이런식으로 하면 더 좋을 것 같습니다.!!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zustand를 사용해서 상태 관리를 하신 점 너무 좋습니다..!!

Comment on lines +86 to +99
<button className="flex flex-col items-center gap-2 cursor-pointer">
<div className="w-[60px] h-[60px] rounded-[34px] bg-[#57CE82] bg-opacity-20 flex items-center justify-center">
<img src={EditIcon} alt="편집" className="w-7 h-7" />
</div>
<span className="text-caption1-m text-white">프로필 편집</span>
</button>

{/* 나의 메모 */}
<button className="flex flex-col items-center gap-2 cursor-pointer">
<div className="w-[60px] h-[60px] rounded-[34px] bg-[#57CE82] bg-opacity-20 flex items-center justify-center">
<img src={MessageIcon} alt="메모" className="w-8 h-8 brightness-0 invert" />
</div>
<span className="text-caption1-m text-white">나의 메모</span>
</button>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

버튼들이 구조가 유사한 것 같아, 컴포넌트화하고 이벤트를 props로 넘기는 방식도 좋을 것 같습니다..!
또 아이콘을 img 말고 svg 컴포넌트화하면 좋을 것 같구요..!

Comment on lines +31 to +44
<div className="w-[375px] h-full bg-white shadow-2xl flex flex-col relative">
<StatusBar />
{/* 나머지 공간을 모두 차지하도록 main 영역 설정 + 세로 스크롤바 숨김 처리 */}
<main className="flex-1 flex flex-col overflow-y-hidden relative">
<Outlet />
{/* 통화 로딩 모달 */}
<CallLoadingModal
isOpen={isCallModalOpen}
onClose={closeCallModal}
/>
</main>
{/* Navbar - 프로필 페이지 제외, 통화 모달 뜨는 동안 숨김 */}
{!hideNavbar && !isCallModalOpen && <Navbar onCallClick={openCallModal} />}
</div>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

layout을 공통 프레임으로 사용하고 으로 하위 페이지들 동적으로 렌더링되도록 하는 것 좋습니다..!

Comment on lines +19 to +20
const isHome = location.pathname === '/home';
const isChat = location.pathname === '/' || location.pathname.startsWith('/chatroom');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useLocation으로 현재 경로에서 열린 페이지에 맞게 하단바 디자인 표시될 수 있도록 한 점 좋습니다..!

Comment on lines +17 to +38
// textarea 높이 자동 조정
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (!textarea) return;

// 높이를 초기화하여 scrollHeight를 정확히 계산
textarea.style.height = 'auto';

const scrollHeight = textarea.scrollHeight;
const maxHeight = 40; // 2줄 이상일 때의 고정 높이

if (scrollHeight <= 36) { // 1줄 높이
// 1줄: 기본 높이
setIsMultiline(false);
textarea.style.height = 'auto';
} else {
// 2줄 이상: 고정 높이
setIsMultiline(true);
textarea.style.height = `${maxHeight}px`;
}
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

입력 줄 수에 따라 높이 변하도록 한 점 좋은 것 같습니다..! 이 점은 저는 생각하지 않았었는데 해당 방식으로 적용하면 좋을 것 같습니다!!

Comment on lines +73 to +78
<div className={`flex flex-1 gap-2 ${isMultiline ? 'items-end' : 'items-center'}`}>
<div className={`flex flex-1 ${isMultiline ? 'items-end' : 'items-center'}`}>
{/* 입력창 */}
<div
className="bg-gray-2 rounded-[10px] flex flex-1 py-2 pl-4 pr-3"
>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

div의 중첩이 많아서 알아보기 어려운 점이 있는 것 같습니다..! 중복된 것들 없애고 묶으면 더 깔끔할 것 같아요..!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants