
![]()
내가 한 작업 영역에 대해 소개를 할 예정이다.
- 각 기능에 대한 영상 설명
- 어떻게 레이아웃을 잡았는지 설명
- 데이터를 어떻게 받아오는지 코드흐름 설명
이 이루어질 것이다. (코드흐름에 대해 설명하기 때문에 양이 좀 많다 ㅎㅎ)
작업순서:페이지퍼블리싱 -> api연결(서버에서 데이터가 잘 받아와지는지) -> api값 렌더링 (프로필카드) -> 정렬 -> 페이지네이션 -> 질문 모달 (로컬스토리지)설명순서는 아래와 같다.
- 전체레이아웃
- 헤더레이아웃
- 오픈마인드로고 기능설명
- 답변하러가기버튼 기능설명
- 답변 모달 기능설명
- 본문 레이아웃
- 드롭다운 기능설명
- 프로필 레이아웃
- API 함수 설명
- 프로필카드 기능설명
- 페이지네이션 기능설명
Styled-components를 사용했으며, JSX 파일과 스타일 파일을 따로 분리하여 사용했다.
스타일 작업을Styled-components에서 처리한 후, 해당 스타일을export하여 JSX 파일에서import하여 사용하는 방식으로 변경했다.
스타일 작업을 할 때 제일 고민이 많이 되는게전체 레이아웃이다. 너무 오랜만에 하다보니전체 레이아웃에 대한 감이 안왔고 스타일 작업을 하는데 꽤 오랜시간이 소요되었다.
전체 레이아웃을 위와 같은 사진으로 스타일을 잡았다.장점
1. 기본 레이아웃 구성
•
width: 100%와 max-width: 1200px를 사용하여전체 너비를100%로 설정하면서도최대 너비를1200px로 제한했다.
이 방식은 다양한 화면 크기에서 일정한 너비를 유지하면서도 너무 넓어지지 않도록 보장했다.
•margin: 0 auto로 자동 마진을 설정하여Container가 화면 중앙에 위치하도록 했다.
2. 패딩 설정
•
padding: 20px로 기본적인 여백을 제공하여 내부 콘텐츠가 화면에 너무 붙지 않도록 했다.
이렇게 하면 콘텐츠가 읽기 좋고 깔끔하게 보인다.
•@media (max-width: 768px)에서패딩을16px로 변경하여 모바일 화면에서 여백을 줄였다.
모바일 화면에서는 여백을 줄여서 콘텐츠가 더 잘 보이도록 했다.
개선할 점
1. 미디어 쿼리 처리
•
@media (max-width: 768px)는 일반적으로태블릿 화면크기 이하에서 동작하도록 설정했지만, 만약모바일 화면크기 이하(예:480px)에도 다른 레이아웃 조정이 필요하다면, 추가적인미디어 쿼리를 고려할 수 있었다.
예를 들어, 더 작은 화면에서는padding을 더 줄이거나 다른 스타일을 적용할 수 있었다.
2. 패딩을 상대적인 단위로 설정
•
padding: 20px과padding: 16px를절대적인 px 값으로 설정하는 대신,상대적인 단위(rem, em, %, vw, vh 등)를 사용할 수도 있었다.
이렇게 하면 화면 크기에 따라 더 유동적으로 여백을 조정할 수 있었다.
예를 들어,padding: 2rem으로 설정하면 다양한 화면에서 일관된 비율로 패딩이 적용될 수 있었다.3. max-width 범위
•
max-width: 1200px는 데스크톱 화면에서는 잘 작동할 수 있었지만, 더 큰 화면에서Container가 너무 작게 보일 수 있었다.
더 큰 화면에서의 최적화도 고려할 수 있었다.
예를 들어,max-width: 1400px또는1600px로 조정할 수 있었다.
전체 레이아웃 템플릿
프로젝트가 끝나고
구글링을 통해전체 레이아웃 템플릿에 대한 기본이 있는지 살펴보았다.
보통웹사이트의 레이아웃은 크게header, main, footer로 나뉘어진다.
이 부분을 처음부터 정해놓으면 이후에 수정하면서 편안하게 스타일 작업을 할 수 있을 것 같다.import styled from 'styled-components'; // 전체 레이아웃을 감싸는 Wrapper export const Wrapper = styled.div` display: flex; flex-direction: column; min-height: 100vh; `; // 헤더 영역 export const Header = styled.header` background-color: #333; color: white; padding: 20px 0; text-align: center; `; // 본문 영역 export const Main = styled.main` flex-grow: 1; padding: 20px; background-color: #f9f9f9; `; // 푸터 영역 export const Footer = styled.footer` background-color: #333; color: white; padding: 10px 0; text-align: center; `; // 콘텐츠를 감싸는 Container export const Container = styled.div` width: 100%; max-width: 1200px; margin: 0 auto; padding: 20px; `;import React from 'react'; import { Wrapper, Header, Main, Footer, Container } from './styles'; // const App = () => { return ( <Wrapper> <Header> <h1>My Website</h1> </Header> <Main> <Container> <h2>Welcome to the site</h2> <p>This is the main content area.</p> </Container> </Main> <Footer> <p>2025 My Website</p> </Footer> </Wrapper> ); }; export default App;
•Wrapper:전체 레이아웃을 감싸는 컨테이너로flex-direction: column을 사용하여 세로로 정렬한다.
min-height: 100vh로 화면 전체를 채우게 된다.
•Header: 상단의 헤더 영역으로, 배경색과 텍스트 색상, 패딩을 설정하여 간단한헤더를 구현한다.
•Main: 본문 영역으로,flex-grow: 1을 사용하여 본문이 가능한 한 많이 차지하도록 하며, 배경색과 패딩을 추가하여 레이아웃을 깔끔하게 만든다.
•Footer: 하단 푸터 영역으로,Header와 유사한 스타일을 적용하여 웹사이트 하단에 고정된 정보를 넣을 수 있도록 한다.
•Container: 내부 콘텐츠를 중앙에 배치하고최대 너비를1200px로 제한하여 화면이 너무 넓어지지 않도록 한다.
이렇게기본 레이아웃이 잘 구성되면,각 페이지의 스타일링을 이와 동일한 방식으로 적용하고 추가할 수 있기 때문에프로젝트 유지보수에도 유리하다.
레이아웃
내가 생각한 레이아웃
오픈마인드 로고와답변하러 가기 버튼이 양쪽으로 있기 때문에display: flex를 사용했어야했다.
고민한 점은 화면이 늘어나면오픈마인드 로고와답변하러 가기 버튼은 움직이지 않아야했고, 줄어들면 가운데로 모이는 상대적인 스타일을 적용했어야했다.
답변하러가기 버튼은공통 컴포넌트로 다른 개발팀원분이 만들어주셨기 때문에header container와오픈마인드 로고의 스타일만 신경쓰면 됐었다.
오픈마인드 로고와답변하러 가기 버튼을 양쪽에 배치하기 위해display: flex를 사용했다.화면이 늘어나면
오픈마인드 로고와답변하러 가기 버튼이 움직이지 않도록 하고 싶었다.
이를 해결하기 위해justify-content: space-between을 사용하여 두 요소를 양쪽 끝에 고정시켰다.
반면 화면이 줄어들면 두 요소가 가운데로 모이도록 해야 했다. 그래서flex의justify-content: center와align-items: center를 적용하여 두 요소가 화면 중앙에 배치되도록 했다.
답변하러 가기 버튼은 다른 팀원분이 공통 컴포넌트로 만들어주었기 때문에, 나는header container와오픈마인드 로고의 스타일에만 집중하면 되었다.
버튼의 스타일은 이미 완성된 상태였기 때문에, 그 부분에 대한 추가 작업은 필요하지 않았다.
개선할 점
1. 미디어 쿼리의 한계
• 현재
@media (max-width: 600px)로 처리한 것은모바일과 작은 화면에 대한 최적화로 충분하지만,태블릿이나 중간 크기의 화면에 대한 스타일링을 고려할 수 있다.
예를 들어,600px에서1024px사이의 화면에서 어떻게 레이아웃을 구성할지 고려해보면 좋다.
현재는600px이하에서만미디어 쿼리가 적용되고 있기 때문에, 다른 크기의 화면에 대해서도 반응형 처리가 추가되면 더 완벽한 레이아웃을 만들 수 있다.@media (max-width: 1024px) { /* 태블릿 화면에 맞는 스타일 */ }2. 고정된 너비 값 (width: 146px)
•
Logo의 width를146px로 고정한 것은 대부분의 화면에서 잘 작동할 수 있지만, 다양한 화면 크기에서 더 유연하게 처리하고자 한다면,width와height를rem,vw또는em과 같은 상대적인 단위로 설정하는 것이 더 나을 수 있다.
px 단위는 고정되어 있기 때문에 다양한 화면 크기에서 유연한 반응형 디자인을 구현하기 어려울 수 있다.
3. 중복된 코드
•
> img { ... }부분에서width: 100%와height: 100%를 지정하여 이미지를 꽉 채우는 방식인데, 이러한 스타일은Logo 컴포넌트에서 이미 처리되는 스타일이므로 중복이 될 수 있다.
Logo의 크기만 조정하면 자동으로img 크기도 맞춰지기 때문에,img에 대한 스타일을 일부 제거해도 된다.
4. 글로벌 스타일 관리
•
clamp()와min()등을 여러 컴포넌트에서 반복 사용하고 있는데, 이는글로벌 스타일 변수로 정의하여 일관성 있게 관리할 수 있다.
예를 들어,theme 객체나CSS 변수로 관리하면, 추후 변경이 필요할 때 유지보수가 용이하다.
오픈마인드 로고를 누르면메인페이지 (index 페이지)로 이동된다.
해당 기능은 React의Link 기능을 사용하여 만들었다.
a태그는 주소로 이동할 때 새로고침이 일어나지만Link 기능은 주소로 이동할 때 필요한 부분만 재렌더링하는 기능을 하기 때문에Link를 사용하게 되었다.
Link 기능을 사용하기 위해react-router-dom에서import를 해주었다.
하지만Link 이름이 아닌Logo 이름을 사용해주어야하기 때문에 JSX에 사용해주지않고Styled-component에Link를 연결시켜주었다.
(이렇게 사용하면Link 기능도 사용할 수 있고 내가 원하는 이름으로 컴포넌트를 사용할 수 있게된다.)
로컬스토리지에 값이 있을 때
로컬스토리지에 값이 없을 때
메인페이지에서 아이디를 입력하면 해당아이디가 서버에서 보내지만로컬스토리지에도 저장을 시키도록 기능이 구현되어있다.
답변하러가기 버튼을 누르게 되면로컬스토리지에 값이 있는지 없는지 확인부터한다.
- 값이 있으면:
modal 창을 보여주게 되면서 저장된 아이디를 보여준다. (저장된 아이디를 누르게되면 해당 피드로 이동된다.)- 값이 없으면:
아이디를 생성하세요라는 경고창이 뜨고 확인버튼을 누르면메이페이지로 돌아가게 된다.
(해당 기능은로컬스토리지에서 확인을 하게 되기 때문에 만약 다른 개발자가 아이디를 생성했다면 해당아이디는 모달에서 볼 수 없다.)
답변하러가기 함수(handleAnserClick) 코드 설명
1.답변하러 가기 버튼을 누르면onClick 이벤트가 발동되면서handleAnserClick 함수를 실행시킨다.
2.localStorage에서feeds 데이터를 가져와서storedFeeds 변수에 할당한다.
•localStorage.getItem("feeds")를 사용하여feeds라는key에 저장된 데이터를 가져온다.
(feeds에는 위 사진처럼 값이들어있다. 값을 가져왔을 때 문자열로 가져온다.)
3.storedFeeds 변수에 값이 없으면경고(alert)후홈(/)으로 이동한다.
•storedFeeds 변수에 값이 존재하지 않으면,아이디를 입력하세요.라는 알림창을 띄운다.
• 이후navigate("/")를 실행하여 사용자를홈 화면으로 보낸다.
•return을 사용하여 이후 코드 실행을 막는다.
4.storedFeeds 변수에 값이 있으면JSON으로 변환하고배열로 변환한다.
•JSON.parse(storedFeeds)를 통해localStorage에 저장된문자열 데이터를 JavaScript 객체로 변환한다.
(JavaScript 객체로 변환한 값은feedsObject 변수에 할당된다.)
•Object.values(feedsObject)를 사용하여객체의 값들을배열로 변환한다.
(배열로 변환한 값은feedsArray 변수에 할당된다.)
5.사용자 ID를 저장하고AnswerModal을 열어줌
•setUserId(feedsArray)를 호출하여userId state (초기값은 빈배열 [])를 업데이트한다.
•setIsModalOpen(true)를 호출하여AnswerModal을 열어준다.
예시
결론적으로
localStorage에10471: {이름: "7", id: 10471} 10472: {이름: "이언덕", id: 10472}이렇게 값이
feeds에 저장있으면
파싱된 feeds 값이userId state에배열로 저장된다.
useEffect Hook 설명
이useEffect는profiles state와userId state가 변경될 때마다 실행되며, 특정 사용자의 질문 개수를 추적하는 역할을 한다.
(profiles state는API 데이터를 저장하고 있는state이기 때문에 어떻게 담게되는지 미리 API 함수에서 보고 오면 이해가 잘된다.)
1.if문 조건:profiles state,userId state가 존재하고,userId 배열이 비어있지 않은 경우 실행한다.
2.사용자 ID (userId state값: localstorage값)와profiles 데이터 (profiles state: api데이터)를 비교하여 질문 개수 추출
•빈 객체 counts를 생성하여 질문 개수를 저장할 준비를 한다.
•userId.forEach((feed) => { ... })를 사용하여userId 배열을 순회하며 각feed.id를 확인한다.
•profiles.results.find((p) => p.id === feed.id)를 사용하여profiles.results에서 현재feed.id와 일치하는 프로필을 찾는다.
• 일치하는profile이 있으면, 해당 프로필의questionCount 값을 가져온다.
•profile.questionCount가undefined인 경우0을 기본값으로 사용한다.
•counts[feed.id] = questionCount를 통해 해당 사용자의 질문 개수를 저장한다.
3.setQuestionCounts(counts)를 호출하여questionCounts state 업데이트
• 최종적으로counts 객체를setQuestionCounts를 통해questionCounts state로 저장한다.
예시
결론적으로
localStorage에10471: {이름: "7", id: 10471} 10472: {이름: "이언덕", id: 10472}이렇게 값이
feeds에 저장되어 있고
서버에createdAt: "2025-03-20T07:00:44.009757Z" id: 10471 imageSource: "https://fastly.picsum.photos/id/939/200/200.jpg?hmac=iqiSg4LCwK5ANmtBOgb0nQOLsoedkjQNxc_AH-WNlFc" name: "7?" questionCount: 0 createdAt: "2025-03-20T07:00:44.009757Z" id: 10472 imageSource: "https://fastly.picsum.photos/id/939/200/200.jpg?hmac=iqiSg4LCwK5ANmtBOgb0nQOLsoedkjQNxc_AH-WNlFc" name: "이언덕" questionCount: 1이런 값이 있다면
id를 비교해서 같다면questionCount를 뽑아내서questionCounts state에 저장된다.
questionCounts state를 콘솔로 출력해보면 질문개수가 추출되서questionCounts state에 잘 할당된 것을 볼 수 있다.
개선할 점
useEffect Hook을QuestionList 컴포넌트에서 사용을 하지 않고 따로Hook 디렉토리로 나누어서 사용하면 가독성 & 유지보수에 더 좋았을 것 같다.
값이 있게 됐을 때
답변 모달 창이 뜨면서로컬스토리지의 질문들을 보여준다.
현재
userId state에는파싱된 feeds 값이userId state에배열로 저장되어있고
questionCounts state에는id를 비교해서 뽑아낸 질문개수를객체로 저장되어있고
isModalopen state는true로 바뀐 상태이다.
AnserModal 컴포넌트에서onClose 함수가 실행되면isModalopen state는false로 바뀌게 된다. (닫기 버튼을 위해 만듬)
해당state들을AnserModal 컴포넌트의 prop으로 전달해주었다.
코드 설명
1.isOpen, userId, questionCounts등의props를 받아옴
•isOpen:AnserModal이 열려 있는지 여부 (true면 표시됨)
•onClose:AnserModal을 닫는 함수 (닫기 버튼 클릭 시 호출됨)
•userId: 사용자 정보 리스트 (배열)
•questionCounts: 사용자의 질문 개수를 담고 있는 객체
2.AnserModal이 비활성화될 경우null반환if (!isOpen || !userId || userId.length === 0) return null;•
isOpen이false거나userId가 없거나빈 배열이면AnserModal을 렌더링하지 않음
• 즉, `AnserModal이 필요 없을 때는 화면에 표시되지 않도록 최적화
3.AnserModalUI 렌더링<Overlay> <ModalContainer> <ModalContent> {userId.map((feed, index) => ( <div key={index}> <Link to={`/post/${feed.id}/answer`}> {`${feed.이름} (질문 개수: ${questionCounts[feed.id] || 0})`} </Link> </div> ))} </ModalContent> <CloseButton onClick={onClose}>닫기</CloseButton> </ModalContainer> </Overlay>•
Overlay:AnserModal의 배경 (반투명 어두운 영역)
•ModalContainer: 실제AnserModal박스
•ModalContent:AnserModal안에 있는 내용 (질문 목록)
•CloseButton:AnserModal을 닫는 버튼
4.userId.map()을 사용해 사용자 목록을 동적으로 생성{userId.map((feed, index) => ( <div key={index}> <Link to={`/post/${feed.id}/answer`}> {`${feed.이름} (질문 개수: ${questionCounts[feed.id] || 0})`} </Link> </div> ))}•
userId 배열을 순회하면서 각각의feed 정보를 화면에 표시
•feed.id를 사용해Link를 생성하여 해당 사용자의 질문 페이지로 이동할 수 있도록 구현
•questionCounts[feed.id] || 0을 통해 질문 개수가 없을 경우0으로 표시 (질문 개수가 있을 경우에는 질문 개수 화면에 표시)
5. 닫기 버튼 동작<CloseButton onClick={onClose}>닫기</CloseButton>•
onClose 함수를 실행해서AnswerModal을 닫음
멘토님 리뷰 반영 (개선)
✅ 1. useEffect를 추가하여 스크롤 방지 기능 구현
🔹 변경 전 (기존 코드)
const AnswerModal = ({ isOpen, onClose, userId, questionCounts }) => { if (!isOpen || !userId || userId.length === 0) return null;• 문제점
•모달이 열릴 때body의 스크롤을 막지 않음, 즉배경 페이지가 스크롤될 수 있음.
• 사용자가모달을 열었을 때도스크롤이 가능한 문제가 발생할 수 있음.
• 일반적으로모달이 열릴 때는 배경이 고정되어야 더직관적인 UX를 제공할 수 있음.
🔹 변경 후 (useEffect추가)
• 변경 이유
•isOpen이true일 때스크롤을 막고,false일 때 다시 원래대로 복원함.
•useEffect를 사용하여 모달 상태 변화에 따라 실행되도록 개선함.
• 의존성 배열에[isOpen]을 추가하여모달이 열릴 때만 실행하고, 닫힐 때도 실행되도록 처리.
✅ 2. React Portal을 사용하여 모달을 최상위에 렌더링
🔹 변경 전 (기존 코드)
return ( <Overlay> <ModalContainer>• 문제점
•모달이기존 DOM 트리내에서 렌더링됨.
• 특정한 부모 요소에 의해z-index가 덮어씌워질 가능성이 있음.
• 일부 경우에는모달이 의도한 대로 표시되지 않는 문제가 발생할 수 있음.
🔹 변경 후 (Portal 사용)
• 변경 이유
•React Portal을 사용하여#modal-root에모달을 렌더링함.
•모달이DOM 최상단에 배치되므로,기존 부모 컨테이너의 z-index영향에서 벗어나 확실한UI 레이어를 보장함.
• 특정 스타일 충돌 문제를 예방할 수 있음.
✅ 3. 백그라운드 클릭 시 모달 닫기 기능 추가
🔹 변경 전 (기존 코드)
return ( <Overlay> <ModalContainer>• 문제점
• 사용자가모달 바깥(백그라운드)을 클릭해도모달이 닫히지 않음.
•모달을 닫으려면 반드시닫기 버튼을 눌러야 하는 불편함이 있음.
🔹 변경 후 (클릭 이벤트 추가)
• 변경 이유
•handleOverlayClick 함수을 추가하여 백그라운드를 클릭하면모달이 닫히도록 개선.
•e.target === e.currentTarget을 이용해모달 바깥 부분(Overlay)만 클릭 이벤트를 감지.
•ModalContainer 안쪽을 클릭했을 때는e.stopPropagation()을 사용하여 이벤트가 전달되지 않도록 차단함.
•사용자 경험(UX)이 더 직관적으로 개선됨.
레이아웃
내가 생각한 레이아웃
처음에는
드롭다운을프로필과 함께그룹화하려고 했으나, 그렇게 하면모바일 반응형 디자인적용 시제목이 따로 노는 현상이 발생할 수 있었다.
따라서제목과드롭다운을 함께 묶는 방식이 스타일 작업을 더욱 용이하게 할 것으로 판단하여 해당 구조로 변경했다.
간단하게제목과드롭다운은disply:flex&flex-direction:row로 위 아래 구조를 잡았다.
제목은피그마에 맞춰font-size와font-weight를 맞춰주었다.
드롭다운 스타일 설명
드롭다운 스타일이 어떻게 이루어지는지 간단하게 알아보자. (중요CSS 코드만 설명해주었다.)1. SortDropdown (드롭다운 컨테이너)
•
position: relative;→ 내부 요소(DropdownMenu)를 상대적으로 배치하기 위한 기준점이 됨
•display: inline-block;→ 내용 크기에 맞게 컨테이너 크기 설정
2. SortButton (드롭다운 버튼)
•
display: flex; align-items: center; justify-content: center; gap: 6px;→ 아이콘과 텍스트를 가로로 정렬
•상태(active)에 따른 스타일 변화
•border 및 color가active 상태일 때(isDropdownOpen이 true) 더 강조됨
•아이콘(img)도active 상태일 때 색이 유지되며,비활성화 시회색(grayscale(100%)) 적용
3. DropdownMenu (드롭다운 리스트)
✅ 드롭다운 리스트가 버튼 아래에 배치되는 이유
•position: absolute;→ 부모 요소를 기준으로 위치를 지정
(기본적으로absolute는 가장 가까운position: relative;를 가진 부모를 기준으로 배치됨)
•top: 40px;→SortButton 아래쪽으로40px떨어지도록 설정
•left: 0;→SortButton의 왼쪽을 기준으로 정렬
•z-index: 1000;→ 다른 요소 위에 표시📌 정리하면
•
SortDropdown이relative이므로,DropdownMenu는absolute 속성을 사용하여 그 내부에서 상대적 위치를 가짐
•top: 40px;으로 버튼 아래에 배치됨
4. DropdownItem (드롭다운 항목)
•
color: ${({ selected }) => (selected ? "var(--blue50)" : "var(--gray60)")};
• 선택된(selected) 항목은 파란색 강조
•&:hover { background: #f0f0f0; }→ 마우스 호버 시 배경색 변경
드롭다운 동작 방식
1️⃣
SortDropdown클릭 →isDropdownOpen state변경<SortDropdown onClick={() => setIsDropdownOpen(!isDropdownOpen)}>•
SortDropdown을 클릭하면setIsDropdownOpen(!isDropdownOpen)실행됨
• 현재isDropdownOpen이false라면true로 변경 → 드롭다운이 열림
• 현재isDropdownOpen이true라면false로 변경 → 드롭다운이 닫힘
2️⃣SortButton의 스타일변경 (active 상태)<SortButton active={isDropdownOpen}>•
active 속성에isDropdownOpen 값을 전달함
•isDropdownOpen === true→ 버튼의 스타일이active 상태로 변경됨
(styled-components에서active 상태를 감지하고 거기에 맞춰 스타일을 맞춤)
•isDropdownOpen === false→ 버튼이 기본 상태 유지
3️⃣ArrowUpIcon↔ArrowDownIcon변경<img src={isDropdownOpen ? ArrowUpIcon : ArrowDownIcon} active={isDropdownOpen} alt="Arrow" />•
isDropdownOpen 값에 따라 아이콘이 변경됨
•true→ArrowUpIcon(위쪽 화살표) 표시
•false→ArrowDownIcon(아래쪽 화살표) 표시
4️⃣isDropdownOpen이true일 때만DropdownMenu표시{isDropdownOpen && ( <DropdownMenu>조건부 렌더링
•isDropdownOpen이true이면<DropdownMenu>가 렌더링됨
•false이면DropdownMenu가 렌더링되지 않음 (보이지 않음)
5️⃣DropdownItem 클릭시 정렬 기준 변경 및 드롭다운 닫힘<DropdownItem selected={sortOrder === "이름순"} onClick={() => { setSortOrder("이름순"); setIsDropdownOpen(false); }}> 이름순 </DropdownItem>동작 과정:
1.DropdownItem 클릭시onClick 이벤트가 실행됨
2.setSortOrder("이름순")실행 →sortOrder state가"이름순"으로 변경됨
3.setIsDropdownOpen(false)실행 → 드롭다운이 닫힘
해당 기능은
프로필카드를 정렬시키는 기능이기때문에 프로필카드에서 함께 설명하는 것이 좋을 것 같다.
레이아웃
프로필 레이아웃
처음부터 하나의 큰 프로필 레이아웃을 만들고, 이를
프로필 카드와페이지네이션두 개의 주요 컴포넌트로 분리하여 설계했다.
프로필 카드는 한 페이지에8개또는6개가 들어가도록 설정했으며, 화면 크기에 따라 동적으로 조정되도록 구현했다.
이를 효율적으로 배치하기 위해CSS grid 레이아웃을 활용하여 프로필 카드들이 균등하게 정렬되도록 구성했다.
프로필 카드 레이아웃
페이지네이션 레이아웃
API 함수 리퀘스트 코드 설명 (GET 요청)
이 코드는
Axios를 사용하여GET 요청을 보내고,전체 유저(subjects)를 조회하는 함수이다.const BASE_URL = "https://openmind-api.vercel.app/14-5"; const headers = { "Content-Type": "application/json; charset=utf-8" };안바뀌는
API URL과headers를 상수로 지정해놓았다.
export async function getAllDataSubjects() {•
getAllDataSubjects 함수는비동기 함수(async)
• 외부에서 이 함수를 사용하려면import해서 사용 가능 (export 사용)
try { const res = await axios.get(`${BASE_URL}/subjects/?limit=9999`, { headers });✅ 동작 방식
1.axios.get()→GET 요청을 보냄
•URL: "https://openmind-api.vercel.app/14-5/subjects/?limit=9999"
•limit=9999→ 조회할 데이터 개수를9999개로 설정 (최대한 많이 가져오기 위함)
•headers추가하여JSON 형식으로 요청
2.await사용 → 요청이 끝날 때까지 기다린 후res에 응답 저장
return res.data;• 요청이 성공하면 응답 데이터(
res.data) 반환
• 호출한 곳에서 데이터를 받을 수 있음
API 값 불러오기 (handleSubjectsData 함수)
코드 동작 설명
handleSubjectsData 함수는비동기 함수(async)로,getAllDataSubjects API 호출을 통해subjects(프로필 데이터)를 가져오고, 가져온 데이터를console.log로 출력하는 역할을 한다.const handleSubjectsData = async () => {•
handleSubjectsData는 비동기 함수(async)
•API 호출을 수행할 때await을 사용하여 데이터를 받아올 때까지 기다림
try { const profiles = await getAllDataSubjects(); console.log(profiles);
getAllDataSubjects()를 호출하여subjects 데이터를 가져옴await을 사용 → 데이터가 로드될 때까지 기다렸다가profiles 변수에 저장console.log(profiles);→ 가져온 데이터를 콘솔에 출력
} catch (error) { setDataErrorMessage(error.message); }✅ 예외 처리 (Error Handling)
•try...catch를 사용하여API 호출중 에러가 발생하면catch 블록이 실행됨
•error.message를setDataErrorMessage(error.message);를 통해 오류 메시지를 상태에 저장
실행을 하고 콘솔창을 보면subjects(프로필 데이터)를 잘 불러오는 것을 볼 수 있다.
API
subjects(프로필 데이터)렌더링렌더링 구조가
ProfileCard 컴포넌트로subjects(프로필 데이터)를 넘겨주고
한번 더ProfileCard 컴포넌트에서ProfileCardItem 컴포넌트로 전달을 해주었어야했다.1.
subjects(프로필 데이터)를 prop으로 ProfileCard 컴포넌트에 전달하기
✅ 실행 흐름
1.useEffect()가 실행되면서handleSubjectsData()호출 (profiles state가 변경될 때마다handleSubjectsData()가 실행된다.)
2.handleSubjectsData()가 실행되면getAllDataSubjects() API를 호출(해당 API 함수를 모르면 다시 보고 오자)
3. 받은 데이터를profiles state에 저장
4.profiles 데이터를ProfileCard 컴포넌트에prop으로 전달
2. ProfileCard 컴포넌트에서
subjects(프로필 데이터)를 parameter로 받고 다시 prop으로 ProfileCardItem 컴포넌트에 전달하기
•QuestionList 컴포넌트에서 받은profiles 데이터를 받아서ProfileCardItem 컴포넌트로 각각 전달
✅ 실행 흐름
1.profiles 데이터가ProfileCard 컴포넌트로 전달됨
2.profiles?.results.map(...)을 사용해 각각의profile 데이터를ProfileCardItem에 prop으로 전달
(문제점:옵셔널 체이닝을 사용하지 않으면profile 데이터가 출력되지 않는다.)
3. ProfileCardItem 컴포넌트에서
subjects(프로필 데이터)를 parameter로 받아서 사용하기
✅ 실행 흐름
1.ProfileCard 컴포넌트에서 전달받은profile 객체를 구조 분해 할당
2.profile 데이터를 화면에 표시 (id, name, imageSource, questionCount)
3. 해당 프로필을 클릭하면/post/${id}로 이동 (React Router Link사용)
브라우저를 보면 모든 프로필이 나오고 프로필을 누르면 해당 프로필 피드에 들어가지는 것을 볼 수 있다.
정렬 기능
프로필 카드 렌더링을 했으니
최신순&이름순으로 정렬을 해야한다.
sortOrder state를 어떻게 사용하는지 보고 싶다면 다시 본문 (레이아웃) 중에드롭다운부분 보고오기정렬 기능 코드 동작
1️⃣
sortProfiles 함수(정렬 로직)
•profiles 데이터 배열을 받아서이름순또는최신순으로 정렬하는 함수
•sortOrder 값이이름순이면
• localeCompare()를 사용해 이름을 기준으로 정렬
•sortOrder 값이최신순이면
•createdAt 날짜를 비교해 최신 데이터가 위로 가도록 정렬
•profiles 데이터 배열가null또는undefined이면 그대로 반환 (?. 옵셔널체이닝사용)
2️⃣sortedProfiles 변수(정렬된 데이터 저장)
•profiles?.results가 있으면 그배열을sortProfiles 함수에 전달
•sortOrder state에 따라 정렬된 데이터를sortedProfiles 함수 호출 아규먼트에 저장
•profiles?.results가null이면빈 배열 []을 반환하여 에러 방지
•sortProfiles 함수에서 정렬된 데이터가sortedProfiles 변수에 할당된다.
3️⃣useEffect(정렬된 데이터 갱신)
•sortOrder state가 변경될 때마다handleSubjectsData()실행 (profiles에서 바꿔주었다.)
4️⃣ProfileCard 컴포넌트(데이터 전달)<ProfileCard profiles={{ results: sortedProfiles }} message={dataErrorMessage} />•
profiles.results대신sortedProfiles(정렬된 데이터)만results에 직접 넣어서 전달
📌 변경한 이유
1.정렬된 데이터만 전달하기 위해
• 기존profiles는{ results: [...] }형태이므로,sortProfiles(profiles.results, sortOrder)함수에서 정렬 후
{ results: sortedProfiles }형태로 맞춰야 함
•profiles.results를 직접 수정하는 것은 React의 상태 관리 원칙에 어긋날 수 있음
• 따라서sortedProfiles를 별도로 저장하고,{ results: sortedProfiles }형태로ProfileCard 컴포넌트에 전달
2. 데이터 구조 일관성 유지
•ProfileCard 컴포넌트는profiles.results.map(...)으로 데이터를 반복 처리
•기존 profiles가{ results: [...] }형태였으므로,변경된 profiles도{ results: sortedProfiles }형태로 유지해야 문제없이 동작
결과를 보면최신순이름순에 따라 정렬 기능이 잘 동작하는 것을 볼 수 있다.
ProfileCard 컴포넌트
1) 상태(State) 관리
•currentPage: 현재 활성화된 페이지 번호
•cardsPerPage: 한 페이지에 표시할 프로필 카드 개수 (기본값 8)
2) 화면 크기에 따라 cardsPerPage state 동적 변경
1.handleResize 함수
•window.innerWidth가865px이하이면cardsPerPage state를6으로 설정
•865px초과면8로 설정
useEffect내에서
• 최초 실행 시handleResize()호출 → 초기cardsPerPage state설정
•resize 이벤트감지 → 화면 크기 변경될 때마다handleResize()실행
•컴포넌트 언마운트시removeEventListener로이벤트 제거
3) 페이지 변경 함수
•Pagination 컴포넌트에서 클릭한 페이지 번호를setCurrentPage로currentPage state변경
4) 프로필을 cardsPerPage state 개수만큼 그룹화하는 함수 & 프로필 데이터 그룹화 및 총 페이지 수 계산
1.profilesArray 배열을groupSize(즉,cardsPerPage) 단위로 잘라서grouped 배열에 추가
2. 예를 들어,profilesArray.length = 20이고groupSize = 8이면
•[0~7]그룹
•[8~15]그룹
•[16~19]그룹
•groupProfiles 함수를 실행하여 데이터를groupedProfiles 변수에 저장
•groupedProfiles.length를 통해totalPages(전체 페이지 수)계산5) 프로필 카드 & 페이지네이션 렌더링
1.groupedProfiles[currentPage - 1]를 가져와서현재 페이지의 프로필 카드 목록을 가져옴
2.ProfileCardItem 컴포넌트를 사용해 각 프로필을 개별 카드로 렌더링
3.Pagination에currentPage,totalPages,handlePageChange을 전달
Pagination 컴포넌트
1) 페이지 그룹 계산
•
PAGE_GROUP_SIZE = 5→ 한 번에5개의 페이지를 표시
•rangeStart 변수는 현재 페이지가 속한 그룹의 시작 페이지
예를 들어,
•currentPage = 7이면(7 - 1) / 5 = 1, 즉rangeStart = 6
•currentPage = 3이면(3 - 1) / 5 = 0, 즉rangeStart = 1
2) 페이지 번호 배열 생성
•MAX_PAGE_DISPLAY = 5→ 최대5개의 페이지를 만듦
•rangeStart + i를 이용해[rangeStart, rangeStart+1, ..., rangeStart+4]페이지 번호 생성
•totalPages보다 큰 숫자는 제외
예제:totalPages = 7,rangeStart = 6
→pageNumbers = [6, 7](8은totalPages초과이므로 제외)
3) 페이지 변경 함수
• 클릭한 페이지가1이상,totalPages이하, 현재 페이지가 아닐 경우만onPageChange(page)실행
4) 페이지네이션 UI 렌더링
1.<ArrowButton>: 이전/다음 버튼 (currentPage === 1이면 이전 버튼 비활성화)
2.<PageNumber>: 현재 페이지면active 속성을 적용하여 강조
3. 클릭 시handleClick(page)호출하여 페이지 변경