요즘 코드잇에서 운영중인 프론트엔지니어 부트캠프 '스프린트'에 참여하고 있다.
2~3월 약 3주간의 첫 프로젝트를 마무리하고, 프로젝트 과정을 회고해보고자 한다.
예전 유행했던 어느 익명 질문답변 사이트 '애스크'와 비슷하다.
본인의 계정을 만들면, 다른 사람들이 해당 사용자의 공간에 질문을 생성하고, 달린 질문들에 답변을 달아
서로서로 '마음을 열고 대화를 나누는' 소통 플랫폼이다.
디자인과 백엔드 API는 코드잇 측에서 준비해주었고, 우리 팀은 프론트엔드 개발에만 온전히 집중할 수 있는 환경이었다.
프로젝트 Github 링크
위 링크로 접속하면 프로젝트 소개 및 소스코드를 확인할 수 있다.
일정 및 역할 분담을 수행한 후, 개발 환경을 세팅하였다.
메인 기술 스택은 React, Styled Component 등이었고, IDE는 팀원 각자 편한 툴을 사용하였다.
나는 Jetbrain의 Webstorm을 사용하였다.
이 외에도 공통된 코드 스타일을 맞추기 위해 prettier를 설정하였다.
ESLint를 적용할까 했지만, 무수히 많은 오류들을 접했고, 해결하지 못하여 결국 다음 기회로 미뤄두기로...
팀원은 총 5명이었고, 5명 안에서도 2명/3명 두 팀으로 나누어 개발을 진행하였다.
나는 2명 팀이었고, 우리 팀은 메인 페이지/질문 페이지/답변 페이지를 맡아 개발하였다.
파일 구조는 아래와 같다. 편의 상 우리 팀이 개발한 부분만 표시하였다.
- pages
- Main.jsx
- components
- main
- UserBox.jsx
- UserInputForm.jsx
- common
- Button.jsx
- LogoBox.jsx
- pages
- Post.jsx
- components
- post
- AnswerBadge.jsx
- AnswerInputForm.jsx
- Kebab.jsx
- NoQuesion.jsx
- PostBanner.jsx
- PostList.jsx
- PostItem.jsx
- Reactions.jsx
- Share.jsx
- QnAItem.jsx
- QuestionContent.jsx
- common
- Avatar.jsx
- Icons.jsx
- Toast.jsx
각 컴포넌트에 대한 자세한 설명은 아래 개발 과정에서 기록하겠다.
메인 페이지는 아래와 같은 컴포넌트들로 이루어져 있다.
LogoBox
LogoBox의 경우 프로젝트의 모든 페이지에서 쓰이고 있기 때문에 common 컴포넌트로 분리해주었다.
Button
Button의 경우 컴포넌트를 어떤 방식으로 구현해야할지 고민이 많이 되었다.
프로젝트에서 쓰이는 모든 버튼들의 디자인이 길이, 색상, 테두리와 같은 사소한 부분만 다르고, 모두 유사했다.
해당 디자인들 각각에 대해 컴포넌트를 분리하여 사용하기에는
컴포넌트 분리의 의미가 없이 중복되는 부분이 많아질 것이라 판단하였다.
그래서 프로젝트에서 쓰이는 모든 버튼들을 Button 컴포넌트 하나로 구현할 수 있도록 만들기로 하였다.
그렇다 보니 재사용성이 높은 컴포넌트를 만들어야했다.
프로젝트에서 사용되는 버튼은 크게
기본 어두운 갈색 버튼, 기본 밝은 갈색 버튼, 테두리가 둥근 floating 버튼, 아이콘 버튼
네가지 종류로 나눌 수 있었다.
기본 버튼
우선, Styled Component로 기본 버튼의 디자인을 구현해주었다.
import styled from 'styled-components';
const BasicButton = styled.button`
width: ${({ $width }) => $width}px;
height: ${({ $height }) => ($height ? $height : 46)}px;
padding: 12px 24px;
font-size: 16px;
font-weight: 400;
white-space: nowrap;
...
// 버튼 밝기 조정
background-color: ${({ $bright }) => $bright ? 'var(--btColor2)' : 'var(--btColor1)'};
...
// 버튼 비활성화 스타일
opacity: ${({ $inactive }) => ($inactive ? 0.5 : 1)};
...
// 버튼 애니메이션
&:not([disabled]):hover {
border: 2px solid var(--brown40);
}
&:not([disabled]):active {
...
`;
Button 컴포넌트를 import하여 사용할 때 받는 props들을 이용하여 css를 작성하였다.
버튼들의 너비가 유동적이므로 width는 필수로 받아와 너비를 지정해주었고,
height의 경우 선택적으로 조정할 수 있게 하였고,
버튼의 색상이 어두운/밝은 갈색 두가지 밖에 없으므로 boolean 타입의 bright prop을 받아와 색상을 조정하였다.
버튼이 비활성화 되어있을 때의 디자인과
버튼의 hover/active 시 효과도 지정해주었다.
효과 지정 시, 버튼이 disable 상태일 때는 효과조차 나타나지 않도록 해주고 싶었고,
&:not([disabled]):hover {
...
}
구글링을 통해 위와 같이 구현해주면 된다는 것을 알았다.
floating 버튼
floating 버튼의 경우 기본 버튼과 디자인은 동일하며,
다른 점은 border radius와 box shadow 정도였다.
그래서 기본 버튼 디자인을 상속 받아, 두가지 디자인을 추가하여 새로운 styled component를 만들었다.
const FloatingButton = styled(BasicButton)`
box-shadow: 0 4px 4px 0 #00000040;
border-radius: 200px;
`;
아이콘 버튼
아이콘 자체가 버튼의 역할을 해야하므로,
children prop으로 아이콘을 받아와 버튼 태그로 감싸주었다.
<button onClick={onClick} className={className} disabled={inactive}>
{children}
</button>
버튼 렌더링
이렇게 만든 버튼들의 형태는, Button 컴포넌트의 variant prop에 따라 조건부로 렌더링 해주었다.
(편의 상 나머지 props는 생략하였다)
const Button = ({..., variant}) => {
if (variant === 'icon') {
return (<button> {children} </button>);
}
if (variant === 'floating') {
return ( <FloatingButton> {children} </FloatingButton> );
}
return ( <BasicButton> {children} </BasicButton>);
};
위와 같이 구현해줌으로써, 프로젝트의 모든 버튼이 Button 컴포넌트를 통해 구현 가능하였다.
하지만 아직 마음 한 켠에 걸리는 것은.. 아래와 같이 받아와야 할 props가 많아 코드가 지저분해보인다는 것이다.
<BasicButton
onClick={onClick}
className={className}
disabled={inactive}
$width={width}
$height={height}
$bright={bright}
$inactive={inactive}
>
{children}
</BasicButton>
버튼과 같은 만능(?) 컴포넌트를 어떤 식으로 만드는 것이 좋은 방법인지는 아직 잘 모르겠다.
여러 사람들의 코드도 보고, 멘토님들의 조언도 들으며 해답을 찾아나가야 할 것 같다.
UserBox
UserBox에는 생성할 유저의 이름을 받는 input 컴포넌트인 UserInputForm,
input을 작성하고 제출할 때 클릭하는 Button 컴포넌트로 이루어져 있다.
UserInputForm
UserInputForm은 사람 모양의 아이콘과, input을 배치하였다.
아이콘의 경우 position을 활용하여 input 안으로 들어갈 수 있도록 구현하였다.
Button
버튼의 경우 가장 기본적인 디자인의 버튼이다. prop으로 width만 넘겨주었다.
<SubmitButton width={336}> 질문 받기 </SubmitButton>
배경 그림
메인 페이지의 경우 구현하기 까다로운 부분이 거의 없었는데,
배경의 그림을 배치하는 것이 가장 까다로웠다.
제공받은 그림의 형태 때문에 Main.jsx 페이지 컴포넌트의 background 이미지로 적용하는 방법을 택하였다.
처음 적용해주었을 때는 이미지가 원하는 곳에 배치되지 않거나,
이미지로 꽉 채워지지 않아 화면의 양 옆이 비워지거나 하는 문제가 생겼다.
아래와 같이 background에 center 옵션을 추가하고, background-size 속성과 height를 주어 해결하였다.
const MainContainer = styled.div`
background: url('/images/background_image.svg')`} no-repeat center;
background-size: contain;
position: relative;
width: 100%;
height: 100vh;
`;
background에 bottom, center, top 등의 속성을 주어
배경 이미지를 원하는 곳에 배치할 수 있는 방법을 리마인드 하였다.
반응형 디자인
모바일 사이즈부터는 요소들의 배치가 다르게 해주었다.
반응형 디자인은 미디어 쿼리를 활용하였다.
대부분 디자인이 바뀌어지는 기준은 모바일 화면이었고, 폰트 사이즈나 크기만 달라지는 수준이었다.
(모바일 화면의 기준은 max-width: 767px
로 구현해주었다.)
하지만 메인 페이지에서는 요소들의 배치가 바뀌기 때문에 대표적인 반응형 디자인 예시로 기록해보고자 한다.
기존 오른쪽 상단에 있던 질문하러 가기 버튼의 배치만 바꿔주었다.
const Main = () => {
return (
<MainContainer>
<ThemeToggleButton />
<MainLogo />
<Link to="/list?page=1&sort=createdAt">
<HeadButton width={160} bright={true}>
질문하러 가기 →
</HeadButton>
</Link>
<UserBox />
</MainContainer>
);
};
위의 HeadButton 컴포넌트는 아래와 같이 이루어져 있다.
const HeadButton = styled(Button)`
position: absolute;
top: 45px;
right: 6%;
@media (max-width: 767px) {
position: static;
display: flex;
justify-content: center;
margin: 10% auto;
...
}
`;
기존에는 absolute position으로 오른쪽 상단에 고정되어 있다가,
모바일 화면에서는 static position으로 자연의 흐름(?)에 따라 위에서 아래로 배치될 수 있도록 해주었다.
메인 페이지에는 다음과 같은 기능들이 있다.
const [nickName, setNickName] = useState(null);
const handleChangeNickName = e => {
setNickName(e.target.value);
};
UserInputForm에 생성할 유저 정보를 입력하면, nickname state에 입력한 값이 저장된다.
제출 버튼을 누르면, nickname이 비어있는지 확인한다.
const checkEmptyNickName = () => {
return nickName;
};
const navigate = useNavigate();
const handleQuestionClick = () => {
const isFilled = checkEmptyNickName();
if (isFilled) {
createInterviewer(nickName).then(result => {
setLocalStorage(result.id, result.name);
navigate(`/post/${result.id}/answer`);
});
} else {
alert('닉네임을 입력해주세요.');
}
};
확인 후 비어있다면 닉네임을 입력해달라는 경고창이 뜨고,
입력되어 있다면 유저 정보를 서버로 보내는 api 함수를 호출하여 유저를 생성한다.
api 함수 동작이 완료되었다면 local storage에 생성한 유저의 정보를 저장한 후,
생성한 유저의 페이지(답변할 수 있는 페이지)로 이동한다.
페이지를 이동하는 기능은 useNavigate 훅을 사용하여 구현해주었다.
어떤 동작을 실행한 후에 링크를 이동하고자 할 때는 navigate 함수를 사용하는 것이 적절하다고 생각하였다.
이 과정에서 유저의 정보를 local storage에 저장하는 이유는,
한 사람은 여러 개의 유저를 생성할 수 있으며, 답변을 남기고자 할 때
내가 생성했던 유저들을 불러와 해당 답변 페이지로 접속할 수 있도록 하기 위함이다.
질문하러 가기 버튼을 클릭했을 때 질문 목록 페이지로 이동하는 부분은
react router dom의 Link 컴포넌트를 사용하였다.
react 프로젝트에서는 a태그를 사용하는 것보다 Link 컴포넌트 사용을 지향한다.
a태그는 페이지를 이동시킬 때 페이지를 새로 불러오며, 상태 값이 유지되지 못하고 속도도 저하된다.
반면에, Link 컴포넌트는 브라우저의 주소만 바꿀 뿐, 페이지를 새로 불러오지는 않기 때문이라고 한다.
출처: https://gomgomkim.tistory.com/9
질문 페이지는 아래와 같은 컴포넌트들로 이루어져 있다.
PostBanner
PostBanner의 경우 특별히 구현하기 어려운 부분은 없었다.
로고 사이즈와 프로필 이미지의 위치, 사이즈, 반응형 디자인을 Styled Component로 조정해주었다.
프로필 이미지의 경우 Avatar라는 컴포넌트로 분리하여 이미지와 사이즈를 props로 받아 활용할 수 있도록 하였다.
Share
소셜 공유 기능을 위해 소셜 서비스 아이콘들을 모아 둔 컴포넌트이다.
PostBanner와 분리한 이유는, PostBanner에는 온전히 사용자의 정보만 들어갈 수 있도록 하기 위함이었다.
앞서 Button 컴포넌트에 만들어두었던 아이콘 버튼을 활용하였다.
이 때 Icons라는 파일을 하나 만들어 styled component로 각 아이콘들을 만들어 export 해주었다.
const socialIconSize = css`
width: 40px;
height: 40px;
`;
export const LinkCopy = styled.div`
background: url('/icons/Link.svg');
${socialIconSize}
`;
export const Facebook = styled.div`
background: url('/icons/Facebook.svg');
${socialIconSize}
`;
export const Kakao = styled.div`
background: url('/icons/Kakao.svg');
${socialIconSize}
`;
이렇게 아이콘들을 미리 만들어 두고, 아래와 같이 버튼에 끼워주는 식으로 아이콘 버튼을 구현하였다.
import * as Icons from 'components/common/Icons';
...
<Button variant="icon"> <Icons.LinkCopy /> </Button>
Feed
질문/답변 페이지의 메인 본문의 내용이 오는 부분이다.
사실 이번 프로젝트에서 이 부분에 가장 신경을 많이썼고, 또 그만큼 가장 복잡하게? 깊게? 컴포넌트가 이루어져 있다.
전체적인 구조를 정리해보자면 아래와 같고,
- Feed
- PostCount
- NoQuestion (질문이 없을 때)
- PostList
- PostItem
- AnswerBadge
- Kebab (답변 페이지)
- KebabOptions
- QnAItem
- QuestionContent
- AnswerInputForm (답변 페이지)
- Reactions
보기 쉽게 화면으로 정리하자면,
질문이 없을 때는 아래와 같이 NoQuestion 컴포넌트를 렌더링한다.
개발을 할 당시에는 각각의 컴포넌트를 위와 같이 분리하는 것이 당연하다.. 라고 생각했지만
개발을 마무리 할 쯤 컴포넌트의 전체적인 구조를 보았을 때는 너무 복잡한가..? 라는 생각이 들었다.
그래도 이렇게 개발한 이유에 대해 해명(?)을 하나씩 해보자면,,
PostCount
질문의 갯수를 보여준다.
말풍선 아이콘과 함께 'n개의 질문이 있습니다.' 라는 문구를,
질문이 없을 때는 '아직 질문이 없습니다.'라는 문구를 보여준다.
질문/답변 본문 내용들과 분리하기 위해 따로 컴포넌트로 만들어 배치해주었다.
NoQuestion
질문이 없을 때 보여줄 상자 아이콘이다.
Post 페이지 컴포넌트 코드의 가독성을 위해 컴포넌트로 분리하였다.
PostList
질문/답변 한 개의 세트인 PostItem을 매핑해주는 컴포넌트이다.
질문/답변 여러 개의 세트를 보여준다.
PostItem
질문/답변 한 개의 세트를 보여준다.
미답변/답변완료/답변거절 세가지로 나뉘는 질문의 상태를 표시해주는 AnswerBadge 컴포넌트,
답변 페이지에서 답변을 관리해주는 Kebab 컴포넌트,
질문/답변 본문 내용을 보여주는 QnAItem 컴포넌트,
좋아요/싫어요의 반응을 표시할 수 있는 Reactions 컴포넌트로 분리하여
PostItem에 배치해주었다.
QnAItem
질문/답변 본문 내용만을 보여주는 컴포넌트이다.
질문 본문, 답변 본문을 나타내는 QuestionContent 컴포넌트 두개로 이루어져있다.
유저의이름, 본문 생성 시간, 본문 내용 등을 포함하는 중복성을 줄이고자 QuestionContent라는 컴포넌트로 분리하였다.
QuestionContent
질문 본문일 경우에는 '질문', 답변 본문일 경우에는 유저의 이름을 표시하도록 해주었고,
답변 본문일 경우에는 본문 왼쪽에 프로필 이미지를 표시해주기 위해 Avatar 컴포넌트를 조건부 렌더링해주었다.
답변을 거절했을 경우에는 위와 같은 UI가 표시될 수 있도록 하였다.
QuestionContent 컴포넌트에 type과 textContents prop으로 답변 거절을 나타내는 값을 넘겨주어 구현하였다.
Reactions
좋아요/싫어요의 반응을 표시할 수 있는 아이콘 두 개를 모아놓은 컴포넌트이다.
질문/답변 본문 내용과 분리하기 위해 컴포넌트로 만들어 배치하였다.
아이콘 버튼을 클릭했을 경우 likeClicked/disLikeClicked state를 변경하여
styled component의 prop으로 주어 색을 변화시켜주었다.
Kebab
질문들의 주인인 유저가 답변들을 관리할 수 있는 케밥 버튼 컴포넌트이다.
버튼 자체는 기존 Button 컴포넌트로 구현하였지만,
해당 버튼을 클릭했을 때 케밥 메뉴들이 표시될 수 있도록 하기 위해 따로 컴포넌트로 분리하였다.
이 때 표시되는 케밥 메뉴들은 KebabOptions라는 컴포넌트를 렌더링하여 구현하였다.
KebabOptions 컴포넌트에는 기존 Button 컴포넌트로 구현한 각 메뉴 버튼들을 모아두었다.
AnswerInputForm
답변 페이지에서 답변을 작성할 수 있는 input form 이다.
textarea와 제출 버튼을 배치하여 컴포넌트로 분리하였다.
미답변 상태이거나, 답변을 수정할 때 보여주어야 하므로 중복을 줄이기 위해 컴포넌트로 분리하였다.
답변 수정 상태일 경우에는 textarea의 value 속성을 기존 답변으로 설정하여 placeholder 대신 표시해주었고,
수정 취소 버튼도 추가하였다.
// children prop이 전달되면 답변 수정상황으로 판단하여, children을 초기상태로 사용
// 그렇지 않다면 답변 생성상황으로 판단하여, 빈 문자열을 초기 상태로 사용
const [answer, setAnswer] = useState(children || '');
...
<StyledTextArea placeholder={placeholder} value={answer} />
버튼의 경우 textarea가 비어있거나, 수정 시에 내용이 수정되지 않았을 경우
inactive prop을 주어 버튼이 비활성화 되도록 하였다.
const isAnswerUnchanged = originalAnswer.trim() === answer.trim();
...
<StyledCompleteButton inactive={answer.trim() === '' || isAnswerUnchanged}>
{buttonText}
</StyledCompleteButton>
수정 시 원본 답변과 현재 답변이 같은지를 판단하여 true라면 비활성화, false라면 활성화될 수 있도록 하였다.
질문/답변 페이지의 기능 구현은 CRUD 및 다양한 기술들을 활용해볼 수 있는 기회였다.
어떻게 보면 나에게는 이 프로젝트의 꽃이었다고 할 수 있겠다.🌸
이 페이지에서 구현한 기능은 다음과 같다.
기능 구현에 있어 서버에서 데이터를 받아오거나 보내주려면 api 통신이 필요했다.
api를 요청하는 함수를 api.js 파일에 따로 모아두고, 작업을 진행하였다.
fetch 함수를 통해 api 통신 작업을 하였다.
export async function getSubjectById(id) {
try {
const response = await fetch(`${BASE_URL}/subjects/${id}/`);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.json();
} catch (error) {
// 네트워크 연결 오류 처리
console.error('Network error:', error);
return null;
}
}
외부의 컴포넌트들에서 호출할 수 있도록 export 해주었고,
함수를 호출할 때 타겟이 되는 id 등 필요한 파라미터를 받아와 실행할 수 있도록 하였다.
response를 정상적으로 받아오면 결과값을 리턴해주고, 그렇지 않을 경우 에러를 띄워주었다.
기본적으로 틀은 위와 유사하고,
POST, DELETE 등 GET 이외의 통신은 그에 맞는 request 정보를 추가하여 구현하였다.
PostBanner에 띄울 유저의 정보를 불러오는 기능이다.
기본적으로 GET api 요청을 통해 서버에서 유저의 정보를 가져왔다.
이후에 고민이 되었던 부분은 이러한 유저 정보를 어떤 식으로 컴포넌트에 내려서 렌더링을 해줄 것인지였다.
처음에는 Post 페이지 컴포넌트에서 유저정보(subject)를 얻어와 PostBanner에만 내려주면 그만이었다.
하지만 문제는 이 유저 정보가 답변을 표시할 때도 필요하다는 것이었다.
답변을 가져오는 api에는 해당 답변의 주인인 유저 정보가 따로 응답되지 않아서
Post 페이지에서 받아온 유저 정보를 갖다가 써야했다.
그러려면,,
Post > PostList > PostItem > QnAItem > QuestionContent 순으로
유저 정보를 내려주어야 하므로 엄청난 Props Drilling이 발생했다!
이러한 고민을 가지고 팀원들과 토론해본 결과,
이렇게 깊은 props drilling이 발생했을 경우 사용하는 것이 Context 아닌가? 라는 결론에 도달했다.
Context 개념은 이번 교육과정을 통해 처음 배웠는데, 배운 것을 활용도 해볼 겸
유저 정보는 Context 활용하여 처리해보기로 하였다.
context
구현된 api 이름이 subject 였으므로 subjectContexts 라는 파일을 만들어주었다.
// 현재 접속한 Subject(유저)의 상태를 관리
import React, { createContext, useContext, useState } from 'react';
const SubjectContext = createContext(undefined);
export const SubjectProvider = ({ children }) => {
const [currentSubject, setCurrentSubject] = useState({
id: null,
name: '',
});
return (
<SubjectContext.Provider value={{ currentSubject, setCurrentSubject }}>
{children}
</SubjectContext.Provider>
);
};
export const useSubject = () => {
const context = useContext(SubjectContext);
if (!context) {
throw new Error('');
}
return context;
};
우선 createContext 함수로 컨텍스트를 생성한 후,
context 사용 범위를 정해줄 SubjectProvider 함수를 생성하고,
context 값을 사용할 컴포넌트에서 context가 내려준 데이터를 가져다 쓸 수 있는 useSubject 훅을 만들어주었다.
위와 같이 context를 만든 후에 유저 정보가 필요한
Post, PostItem, QnAItem 컴포넌트 안에서 아래와 같이 간단히 사용해주었다.
const { currentSubject, setCurrentSubject } = useSubject();
currentSubject에 저장되어 있는 값으로 유저 정보를 사용하였고,
useParams 훅을 사용하여 pathname에 저장되어 있는 아이디 값을 불러와
해당 아이디에 맞는 유저 정보를 GET 하여 유저 정보가 바뀔 때마다
setCurrentSubject 함수로 그때 그때 유저 정보를 갱신해주었다.
const { postId } = useParams();
useEffect(() => {
getSubjectById(postId).then(setCurrentSubject);
}, [postId]);
현재 접속해있는 링크의 주소를 소셜 서비스에 공유하는 기능이다.
해당 유저의 질문-답변들을 외부로 공유하고자 하는 의도로 구현한 기능이다.
링크를 클립보드에 복사하거나 카카오톡, 페이스북으로 공유하는 세가지 방법이 있다.
링크 클립보드 복사
어떤 값을 클립보드에 복사하는 기능은 react-copy-to-clipboard 패키지를 사용하여 구현하였다.
처음 해보는 작업이라 뭔가 되게 복잡할 것 같다고 생각했는데 이 패키지를 사용하니 아주 간편하게 구현할 수 있었다.
링크 복사 아이콘을 react-copy-to-clipboard 패키지의 CopyToClipboard 컴포넌트로 감싸주면 되었다.
이 때 CopyToClipboard 컴포넌트 안에는 text, button 등의 태그가 포함되어야 정상적으로 동작한다.
<CopyToClipboard
className="CopyLink"
text={window.location.href}
onCopy={handleShowToast}>
<Button variant="icon"> <Icons.LinkCopy /> </Button>
</CopyToClipboard>
나는 내가 만들어 둔 Button 컴포넌트를 안에 넣어 구현하였다.
CopyToClipboard 컴포넌트의 text 속성은 복사할 내용이 들어가고,
onCopy 속성은 복사가 되었을 때 실행할 함수를 넣는 이벤트 리스너이다.
Toast 알림
이 onCopy 속성에 handleShowToast 함수를 넣어서
복사가 되었을 때 문구를 보여주는 토스트 알림(지금까지 많이 봐왔던..!)을 구현하였다.
const [showToast, setShowToast] = useState(false);
const handleShowToast = () => {
setShowToast(true);
setTimeout(() => setShowToast(false), 1000);
};
Toast 라는 div 요소의 컴포넌트를 만들고,
showToast라는 state가 true가 되었을 때만 보여줄 수 있도록 handleShowToast 함수를 만들었다.
이 토스트 알림은 약 몇 초간만 떴다가 없어지게 해야하기 때문에, setTimeout 함수를 사용하였다. 링
크 주소가 복사되면, showToast를 true로 만들었다가, 1초 후에 false로 바꾸어 토스트 알림이 꺼지도록 한다.
뭔가.. 새로운 패키지도 써보고, 유명한(?) setTimeout도 써보고, 그다지 어렵지 않은 난이도에
그동안 많이 봐왔던 기능을 직접 구현해볼 수 있었어서 뿌듯한 작업 탑5 안에 든다.
카카오/페이스북 공유
소셜 서비스 공유는 각 서비스들에서 제공하는 api를 사용하였다.
페이스북의 경우
간단히 'http://www.facebook.com/sharer/sharer.php?u=' + url 라는 주소로 이동해주면 되었고,
카카오톡의 경우 살짝 복잡했다.
우선 카카오 developer에 앱 정보를 등록해 .env 파일에 앱 키를 등록한다.
이 때 .env 파일은 프로젝트 최상단에 위치해야 하고, gitignore에 추가하여 git에 개인 정보가 올라가지 않게 주의하자!!
위 두가지 모두 주의하지 않아 어이없이 에러에 허우적 거렸던 경험이 있다..
다음은 index.html에 sdk 링크를 삽입해준다.
그리고 카카오 공유 함수에는 등록한 앱 키와 함께 초기화를 먼저 해 주고,
카카오 공유 api 공식문서를 참고하여 공유 형식을 정해주는 커스텀 로직을 구현하면 된다.
우리 팀은 sendCustom 함수를 사용하였다.
메시지 템플릿을 미리 만들어 놓고, 템플릿 아이디를 지정하면 해당 템플릿에 맞추어 공유 형식이 결정된다.
질문/답변 조회
질문과 답변은 한 api의 응답 안에 함께 데이터가 묶여있었다.
위에서 유저 정보를 불러왔던 postId 값을 넣어 질문 데이터들을 GET 하는 api를 호출하면,
질문 데이터 각각에 answer 데이터가 있는 형식이다.
따라서 질문 데이터를 가져오는 GET api를 호출하여 질문/답변을 모두 조회할 수 있었다.
Post 페이지 컴포넌트에서 데이터를 불러와 PostList로 내려서 매핑하였다.
이후 컴포넌트에서는 필요한 데이터만 뽑아 내려서 렌더링 해주었다.
질문 상태 표시 기능
위에서 구현하였던 AnswerBadge에서 표시해주고 있는 기능이다.
답변이 아직 없는 질문에는 미답변, 답변이 있는 질문에는 답변 완료 라는 문구의 뱃지가 달려있다.
<AnswerBadge isAnswered={qnaData.answer} />
이는 질문/답변 데이터를 조회할 때 각각의 질문 데이터(qnaData)에
answer 데이터가 있는지, 없는지를 판단하여 구현하였다.
answer 데이터가 있다면 isAnswered 에는 true 값이, 없다면 false 값이 들어가므로
조건부 렌더링을 통해 각각에 맞는 문구를 출력해줄 수 있다.
질문/답변 시간 표시 기능
질문과 답변이 언제 달렸는지 표시해주는 기능이다.
현재 시각을 기준으로, 몇 분 전, 몇 시간 전, 몇 일 전, 몇 주 전, 몇 달 전 까지 알려주고 있다.
질문/답변 데이터에는 createdAt이라는 생성 시각 정보가 존재한다.
이 정보와 현재 시간 정보를 활용하여 dateUtils 파일에 시간을 계산해주는 함수를 생성하여 구현하였다.
export const getTimeDifference = createdDate => {
createdDate = new Date(createdDate);
const currentDate = new Date();
const differenceInSeconds = Math.floor((currentDate - createdDate) / 1000);
const secondsInMinute = 60;
const secondsInHour = 3600;
...
if (differenceInSeconds <= secondsInHour) {
const minutes = Math.floor(differenceInSeconds / secondsInMinute);
return `${minutes}분전`;
}
if (differenceInSeconds < secondsInDay) {
const hours = Math.floor(differenceInSeconds / secondsInHour);
return `${hours}시간전`;
}
...
분, 시간, 일, 주, 달 단위로 초를 지정하였다.
좋아요/싫어요 기능
질문&답변 세트에 좋아요/싫어요 반응을 남길 수 있는 기능을 구현하였다.
각 반응을 POST 할 수 있는 api가 제공되어 활용하였다. 다만 제공된 api에 반응을 취소하는 기능은 없어서
한 번 반응을 남기면 낙장불입(...)인 방식으로 구현하였다.
const handleLike = () => {
if (!likeClicked) {
postQuestionsReaction(qnaData.id, 'like');
setLikeLocalStorage(qnaData.id);
setLikeClicked(true);
}
};
useEffect(() => {
if (
localStorage.getItem('like') &&
localStorage.getItem('like').includes(qnaData.id)
) {
setLikeClicked(true);
}
...
}, []);
반응을 클릭하면 아이콘의 색상이 바뀌는 기능은 UI 구현에서 기술하였고,
아이콘이 눌렸을 때 POST api를 요청하는 함수를 호출하여 반응 데이터를 서버에 보내주었다.
이 함수는 해당 아이콘이 이전에 눌린 적이 없을 경우에만 실행되도록 하여 낙장불입(?) 방식을 구현하였다.
이러한 낙장불입 시스템을 좀 더 강화하기 위해, 한 사용자는 해당 질문에 대해 딱 한번만 반응을 남길 수 있도록하였다.
이 기능도 앞서 유저 정보를 기억해놨던 것 처럼 local storage를 활용하였다.
local storage에 내가 반응을 남겼던 질문 데이터의 아이디를 저장해놓고,
페이지에 접속할 때 해당 질문에 대해서는 useEffect 훅을 사용하여 like/dislikeClicked state를 true로 설정하는 것이다.
이 기능을 구현할 때 어려움을 겪었던 부분은 의외로 좋아요/싫어요 갯수를 표시하는 작업이었다.
무엇이 문제였냐면,, api 응답으로 받아온 반응의 갯수를 화면에 렌더링하고 있는데,
아이콘을 클릭하는 시점에 서버와 클라이언트의 데이터 싱크가 맞지 않아 올바르게 갯수가 출력되지 않는 것이었다.
(예를 들어 좋아요가 3일 때 아이콘을 누르면 4가 표시 되어야 하는데 그렇지 않음..)
이 부분을 해결하기 위해
처음에는 기존 갯수에 1을 더한 갯수를 화면에 렌더링 하였다.
<LikeButton variant="icon" onClick={handleLike} $clicked={likeClicked}> <Icons.ThumbsUp $clicked={likeClicked} /> 좋아요 {like+1} </LikeButton>
→ 흠.. 이렇게 하니 당연하게도 서버에 저장되어있는 갯수와 다르게 표시가 된다.
그리고 이 방식은 처음에 아이콘을 누르기 전에는 갯수를 보여주지 않던 초기 버전의 코드였다.
다음으로는 이후에 기술하겠지만, 서버와 데이터 싱크를 reload 없이 맞춰주기 위해 사용했던
'POST 보내자마자 GET 다시 받아오기' 방법을 사용했다.
→ 문제가 해결되기는 하였지만, 현재 우리 프로젝트에서는 질문이 많은 페이지에 대해 무한 스크롤 기능을 제공하고 있는데, GET을 매번 다시 받아오다 보니 스크롤이 내가 있던 위치에 가만히 있지 않고 초기화 되는 현상이 발생했다.
마지막으로 시도해본 방법은 개인적으로 원시적
(지금 생각해보니 그냥 처음 리액트를 접했을 때 사용했던 방법이라 그런 거였나..? 그게 무조건 안좋은 방법이었던건 아니니까..)이라고 생각되어서 제쳐두었던 방법이었다.
바로 like/dislike 갯수에 대한 state를 따로 만들어 관리하는 방법이다.const [like, setLike] = useState(qnaData.like); const [dislike, setDisLike] = useState(qnaData.dislike); const handleLike = () => { if (!likeClicked) { ... setLike(prev => prev + 1); } };
초깃값은 서버에서 받아온 갯수 데이터로 설정하고, 아이콘을 클릭했을 때 실행될 함수 안에서
이전 값에 1을 더해 state를 갱신하는 것이다.
→ 렌더링할 때도 이 state 값을 바로 뿌려주어 실시간으로 올바르게 데이터가 표시되면서도,
굳이 api를 여러번 호출할 필요가 없어지고, 결과적으로 스크롤 초기화 현상도 해결이 되었다.
이렇게 살짝의 우여곡절 끝에 좋아요/싫어요 기능을 완성할 수 있었다.
유저 페이지(Subject) 삭제 기능`
저 '삭제하기' 버튼을 누르면 asdsad 라는 유저의 질문 페이지 자체가 사라지는 기능이다.
간단히 버튼을 클릭했을 때 DELETE api를 요청하는 방식으로 구현할 수 있었으나,
DELETE api 통신을 해본 것을 처음이었어서 몇가지 에러를 만났다.
export async function deleteSubject(id) {
try {
const response = await fetch(`${BASE_URL}/subjects/${id}/`, {
method: 'DELETE',
});
if (response.ok) {
return;
}
return new Error('');
} catch (e) {
if (e instanceof Error) {
return e;
}
}
}
결과적으로 위와 같이 api 함수를 생성하여 삭제할 유저의 id만 넣어 사용해주면 구현이 되었다.
이 때 사용되는 id도 위에서 계속 사용했었던 param에 있는 postId이다.
이 기능은 무엇이냐 하면, 답변 수정, 질문 삭제, 답변 삭제, 답변 거절의 케밥 옵션 4가지가 있고,
옵션을 클릭했을 때 각각에 맞는 기능이 실행될 수 있도록 하는 것이다.
컴포넌트가 Kebab/KebabOptions로 분리되어 있기도 하고, 각 기능들을 담당하는 부분이 흩어져있어서
구현하기 전에 이걸 어떤 식으로 할 수 있을까,, 막막했었기 때문에 이걸 해결해낸 지금,
회고록으로써 한 번 더 정리해보고자 한다.
<KebabOptions
isClick={isClicked}
onEditClick={onEditClick}
onDeleteQuestionClick={onDeleteQuestionClick}
onDeleteAnswerClick={onDeleteAnswerClick}
onRejectClick={onRejectClick} />
우선 KebabOptions 컴포넌트에 onEditClick
,onDeleteQuestionClick
,onDeleteAnswerClick
, onRejectClick
props를 받아오도록 해주었다.
<Kebab
onEditClick={() => {
if (!qnaData.answer) alert('수정할 답변이 없어요.😭');
else setIsEdit(true);}}
onDeleteQuestionClick={handleDeleteQuestion}
onDeleteAnswerClick={handleDeleteAnswer}
onRejectClick={handleRejectAnswer} />
그리고 PostItem 컴포넌트 안에서 답변 수정, 질문 삭제, 답변 삭제, 답변 거절 기능을 수행하는 함수를 구현하였고,
이 컴포넌트에서 Kebab 컴포넌트를 불러올때 각 prop들에 알맞은 기능을 하는 함수들을 내려주었다.
PostItem에서 기능들을 구현해준 이유는, 질문/답변 데이터, isEdit state 등
기능 동작에 필요한 데이터들이 이 컴포넌트에 존재하기 때문이다.
미미한 props drilling이 존재하더라도,
굳이 Kebab 컴포넌트에서 또 이 데이터들을 불러오기 위해 api를 호출할 필요가 없다고 판단했기 때문이다.
<OptionButton variant="icon" onClick={onEditClick}> <Icons.Edit /> 수정하기 </OptionButton>
<OptionButton variant="icon" onClick={onDeleteQuestionClick}> <Icons.DeleteQuestion /> 질문삭제 </OptionButton>
<OptionButton variant="icon" onClick={onDeleteAnswerClick}> <Icons.DeleteAnswer /> 답변삭제 </OptionButton>
<OptionButton variant="icon" onClick={onRejectClick}> <Icons.Rejection />답변거절 </OptionButton>
결론적으로 KebabOptions에서 각각의 버튼을 눌렀을 때 PostItem에서> kebab에서 > props로 넘겨받은 함수를 실행하게 된다.
각 기능에 대한 구현 과정은 아래에서 설명을 이어나간다.
답변을 관리하는 기능은 기존 질문 페이지 경로 뒤에 /answer이라는 Pathname이 추가되면 활성된다.
현재 우리 프로젝트에서는, 질문 목록 페이지에서 내가 생성한 질문 페이지를 클릭하면
../post/{postId}/answer로 접속하게 되어 있고,
내가 생성하지 않은 다른사람의 질문 페이지에서 URL을 조작하여 .../answer 경로로 이동하려고 하면
404 페이지를 띄워주고 있다. 이 기능을 구현함에 있어 앞서 저장해두었던 내가 만든 유저 정보를 사용하는 것이다.
여튼 간에 Kebab 컴포넌트가 나타나 답변을 관리할 수 있는 기능은 useLocation 훅을 사용하였다.
const { pathname } = useLocation();
const paths = pathname.split('/');
const isAnswerPage = paths[paths.length - 1] === 'answer';
useLocation 훅으로 pathname 값을 가져오고, 이것을 /로 나누어 가장 마지막 경로가 answer라면
이 페이지는 답변하기 페이지임을 나타내는 것이다. isAnswerPage 값에 따라 조건부 렌더링 해주고 있다.
답변 생성 기능(reload 없이)
답변하기 페이지로 페이지가 바뀌면, 답변이 없는 질문에 대해서는 기본적으로 유저가 답변을 달 수 있는
AnswerInputForm 컴포넌트가 띄워져 있다.
답변이 있는지, 없는지 확인하는 과정 역시
질문 데이터에 answer 데이터가 있는지, 없는지 유무로 판단하여 AnswerInputForm 컴포넌트를 렌더링한다.
정확히 말하자면 QnAItem 컴포넌트에서 이를 판단하여
QustionContents 컴포넌트에 type이라는 prop으로 'create answer' 라는 값을 내려준다.
그러면 QustionContents 컴포넌트에서는 이 type 값을 바탕으로 AnswerInputForm를 조건부 렌더링 하는 것이다.
이제 input에 답변을 입력한 후, 답변 완료 버튼을 누르면 POST api를 호출하여
서버에 해당 질문에 대한 답변으로 데이터를 전달하게 된다.
export async function createAnswer(id, content, isRejected = false) {
try {
const response = await fetch(`${BASE_URL}/questions/${id}/answers/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
questionId: id,
content: content,
isRejected: isRejected,
team: '8',
}),
});
if (response.ok) {
return response.json();
}
return new Error('');
} catch (e) {
if (e instanceof Error) {
return e;
}
}
}
이 때 받아오는 id는 답변을 달 질문의 id이고, content는 말 그대로 답변 내용이다.
isRejected 값은 아래에서 구현할 답변 거절에 대한 파라미터이다. 기본적으로 false 값을 가지도록 해주었다.
POST 통신의 경우 request body가 필요한데, 이 body를 넘겨줄 때 몇가지 오류를 맞닥뜨렸다.
먼저 headers를 지정해주는 것이다.
이 headers의 Content-Type을 우리가 보낼 json 타입이라고 명시해주지 않으면, 400에러가 뜨게 된다.
다음은 body로 보내줄 값들을 제공된 api request body 형식에 맞추어 JSON.stringify로 감싸주는 것이다.
처음에 값들을 감싸주지 않고 그대로 보냈다가, 계속 body 값이 올바르지 않다는 에러가 났다.
아무리 확인해봐도 형식에 다 맞추어서 보내줬는데,, 하다가 구글링을 통해 해결할 수 있었다..
이리도 기본적인 실수를 하다니..
reload 없이 데이터 싱크 맞추기
다음으로 문제가 된 것은, 이번 프로젝트에서 손 꼽히는 큰 이슈(?) 였던 부분이다.
이것은 해결방법을 잘 몰라 멘토님에게 까지 조언을 구했던 작업이다.
아래 등장할 모든 답변 관리 기능에서 적용되는 이슈이다.
답변을 생성/수정/삭제/거절 즉 답변의 상태를 변경했을 때,
서버에는 데이터가 반영이 되었지만 클라이언트 화면에서는 바로 적용이 되지 않는 문제이다.
(원래는 이렇게 바로바로 반영이 되어야 한다)
이것을 클라이언트, 서버 데이터 상태가 다른 상태, 즉 stale 상태라고 한다.
이러한 문제는 데이터 싱크를 맞추어 해결할 수 있고, 이를 위해 react-query(tanstack query) 를 사용한다고 하셨다.
(막연히 react-query를 배워봐야겠다고 생각만 했지,, 이런데서 쓰이고 있구나를 딱 깨달았다)
여튼 우리는 아직 react-query를 모르는 상태여서,
처음에는 이것을 해결하기 위해 window 객체의 reload 함수를 써서 POST 요청을 하자마자
재빨리 새로고침하여 바로 반영이 되는듯(?)하게 보이도록 해두었지만.. 코드를 쓰면서도 생각했다..
이것.. 비효율적이다..! 뭔가 본능적으로? 불필요한 새로고침은 성능을 떨어뜨리겠구나 라고 와닿았던 것 같다.
하지만 당장엔 방법을 모르겠으니..
팀장님과 논의 후에 떠올린 아이디어는 POST 요청을 보낸 후 바로 GET 요청으로 데이터 싱크를 맞추는 방법!
이것을 멘토님께 제안해보았고, 긍정적인 반응(reload 보단 낫다!)을 보여주셔서 이것을 반영해보기로 하였다.
export const handleAsyncOperation = (operation, postId, onSuccess, onError) => {
const asyncHandler = () => {
operation()
.then(() => getQuestionsById(postId))
.then(res => {
const { results, count } = res;
onSuccess(results, count);
})
.catch(error => {
console.error('오류가 발생했습니다:', error);
onError(error);
});
};
return asyncHandler;
};
handleAsyncOperation util 함수를 만들어,
특정 작업 후 GET 요청으로 질문/답변 데이터들을 불러오는 작업이 바로 실행될 수 있도록 하였다.
const handleCreateAnswer = () => {
const asyncHandler = handleAsyncOperation(
() => createAnswer(questionId, answer),
postId,
results => setPostData(() => results),
error => console.error('답변을 생성하는데 문제가 생겼습니다.', error),
);
asyncHandler();
};
AnswerInputForm 안에 asyncHandler라는 함수를 만들어,
위에서 만든 handleAsyncOperation의 operation 부분에 답변을 생성하는 api 함수를 넣어주었다.
이 과정들이 모두 문제없이 실행이 되었다면, 결과 값으로 받아온 results를 setPostData에 넣어준다.
이 postData/setPostData는 현재 페이지에 존재하는 모든 질문/답변 데이터들을 렌더링 하기 위한 state이다.
무려 Post 페이지 컴포넌트에서부터 무시무시한 props drilling으로 내려온 prop이지만..
조금의 props drilling이 발생하더라도 한 곳에서 데이터를 불러서 내려주는게 낫다.
무조건 깊은 단계의 props drilling이 불편한 것이 아니라, 불편하다는 것은 굳이 불필요한 컨테이너를 만들어서 데이터를 꽂아줘야 하는 상황이 발생할 때!라는 멘토님의 조언 아래.. 이렇게 구현해두었다.
정리하자면, 답변이 생성되는 바로 그 시점에
답변 생성 POST → 현재 페이지 질문/답변 데이터 GET → 현재 페이지 postData state 갱신
의 과정을 보이지 않게 거쳐서 마치 데이터 싱크가 바로 맞춰지는 것처럼 하여 원하는 결과를 얻을 수 있었다.
이러한 방법이 가장 최선의 방법인지는 아직 잘 모르겠다.
만약 이후 react-query를 배우게 된다면 그것을 이용해서 우아하게 처리하는 방법을 알 수도 있을 것 같다.
아래 기술될 모든 답변 관리 기능에서는 이 과정을 거치도록 구현하였다.
답변 수정 기능
답변 수정 기능은 답변 생성 기능과 굉장히 유사했다.
다만 다른 점은 수정 기능일 때는 AnswerInputForm의 버튼도 수정완료/수정취소 두개가 생겨야하고,
PUT 메소드로 api를 호출해야했다.
이를 위해 현재 상태가 답변 생성 상태인지, 수정 상태인지 판단해야했고,
isEdit이라는 state를 Kebab을 렌더링하고 있는 PostItem 컴포넌트에서 만들어서 관리했다.
그래서 Kebab의 onEditClick이라는 Prop으로 setIsEdit(true);
를 내려주었고,
Kebab에서는 이것을 그대로 KebabOptions에 prop으로 내려주어
KebabOptions에서 수정하기 버튼을 누르면 setIsEdit(true);
가 실행되도록 하였다.
이 때 변경된 isEdit의 state는 PostItem에서 적용이 되기 때문에
이 값을 QnAItem을 호출할 때 QnAItem의 prop으로 넘겨주었고,
QnAItem 컴포넌트에서 전달받은 이 값을 사용하여 수정하기 상태인지, 아닌지를 판단하여 작업해주었다.
참고로 QnAItem 컴포넌트에서 받은 isEdit 값이 true 라면
QuestionContent 컴포넌트를 호출할 때 type이라는 prop을 'edit answer'로 넘겨주어
QuestionContent 안에서 그에 맞는 조건부 렌더링을 수행한다.
추가적으로 수정할 답변이 없는데, 수정하기 버튼을 클릭했을 경우 답변이 없다는 문구의 알림창을 띄워주었고,
수정 취소 버튼을 누르면 onEditClick이라는 prop에 setIsEdit(false);
를 넘겨주었다.
답변을 수정할 때는 PUT 메소드로 api를 호출해야한다.
PUT의 경우 method 부분만 PUT으로 바꿔주면, request body 등 POST와 똑같다고 볼 수 있다.
이번 프로젝트의 경우 답변을 생성하고 수정할 때 보내줘야 하는 request body의 형식이 똑같아서
특별히 바꿔줄 것은 없었다.
질문/답변 삭제 기능
달려있는 질문을 삭제하고, 내가 달았던 답변을 삭제하는 기능이다.
두 기능 모두 질문 페이지 삭제와 똑같이 DELETE 메소드로 api를 호출하여 구현해주었다.
DELETE 시에 어떤 데이터를 삭제할지 구별하는 id 값이 필요했으므로
모두 qnaData(개별 질문/답변 데이터)가 존재하는 PostItem 컴포넌트 안에서 구현해주었다.
답변 삭제의 경우에도 삭제할 답변이 없는데, 답변삭제 버튼을 클릭했을 경우 답변이 없다는 문구의 알림창을 띄워주었다.
TMI)
이 기능을 구현하기에 앞서 팀원들과 답변 삭제 기능은 굳이 있어야 하나,, 라는 필요성에 대해 논의 했었다.
수정 기능도 있고, 답변 거절도 있고, 질문 삭제도 있기 때문이다.
하지만 답변 삭제가 있어야 질문을 삭제하지 않고도
답변완료 상태의 답변을 미답변 상태로 바꿀 수 있기 때문에, 답변 삭제 기능도 구현하는 것으로 결정하였다.
답변 거절 기능
답변하기 곤란한 답변에 대해 답변을 거절하는 기능이다.
이미 달아놓은 답변을 거절처리 할 수도 있고, 아예 처음부터 답변 거절 상태로 만들 수도 있다.
이 기능은 제공된 답변 데이터 api 응답의 isRejected 상태를 활용하면 되었다.
따라서 기존에 답변 데이터가 있는 상태에서 거절처리를 하면,
request body에 원본 답변 내용과 isRejected를 true 값으로 담아 PUT api 요청을 하였고,
기존에 답변 데이터가 없는 상태에서 거절처리를 하면, 답변을 하나 새로 생성해야 하기 때문에
request body에 빈 문자열과 isRejected를 true 값으로 담아 POST api 요청을 하였다.
이렇게 답변을 수정/생성 후 답변의 데이터를 불러오게 되면, isRejected 값이 true가 되어있을 것이고,
이 isRejected 데이터를 바탕으로 QustionContents 컴포넌트의 type prop에 'rejected answer' 값을 넘겨주어
Styled Component에 적용하여 UI를 다르게(빨간색 글씨, '답변 거절' 문구) 구현하였다.
거절된 답변에 대해 수정을 하려고 할 때는 원본 답변의 내용이 textarea 안에 떠있다. (새로 생성된 경우에는 빈 문자열)
이렇게 열심히 기능들을 구현한 후,,
개발 단계에서 항상 개발자 도구 창에 뜨던 빨간 에러들이 신경쓰였다..
Manifest: Line: 1, column: 1, Syntax error.
https://anerim.tistory.com/209
index.html 파일에서 manifest 부분을 지워줌으로써 해결되었다.
received 'true' for a non-boolean attribute
Styled Component를 사용하면서 발생한 에러였다. Styled Component 에서 사용할 props에 $ 표시를 붙임으로써 차이를 주어 해결할 수 있었다.
<LikeButton varient="icon" onClick={handleDislike} $clicked={dislikeClicked} >
소문자로 바꾸라는 에러 : ~.. spell it as 'isanswer' ..~
React는 camelCase로 DOM 속성으로 인식하는데,
내가 설정해준 isAnswer라는 props는 기존의 HTML 프로퍼티가 아니기 때문에 발생하는 에러이다.
이 또한 $로 styled componenet에 대한 Props임을 명시해줌으로써 해결할 수 있었다.
아직 정석은 아니지만, 무언가 제대로 알고 프로젝트를 해 본 첫 경험이었다.
무언가 제대로 안다는 것은,, JS의 문법이라던가, React의 여러 기능들..?
지금까지는 개발을 독학하고, 부딪히면서 배우자 라는 마인드로 프로젝트를 진행해왔다 보니,
무지성으로 코드만 짰던 것 같다.
컴포넌트와 파일구조를 어떻게 설계할지 고민하고, 재사용성이 높은 컴포넌트를 만들기 위해 고민하고,,
훨씬 신경쓸게 많아졌다. 그래도 기본적인 지식을 전에 비해 탄탄히 해놓고 돌입한 프로젝트는
훨씬 배울 것이 많았고, 어떤 벽을 만나더라도 그것을 넘어가는 것도 수월했다.
또 지금까지는 프론트엔드 개발을 거의 혼자 하다시피 했었는데,
같은 기술을 쓰고 같은 작업을 하고 같은 고민을 하는 팀원들과 협업하는 과정은 새로웠다.
혼자 고민을 하던 부분에서 조언을 구하는 것도 도움이 많이 되었고,
이런 것들을 공유하며 멘토링 시간에 멘토님께 도움을 구하면서도 많이 배울 수 있었다.
혼자 하면 몇시간 만에 끝낼 수 있는 작업도 여러명이 같이 하다보면 더 오래 걸린다는 사실도 알았다..
처음에는 이게 안좋다.. 힘들다.. 라고만 생각했는데, 구현 과정을 팀원에게 설명하고 함께 논의하며
한 번 더 왜 이런 과정을 떠올렸는지 검토해볼 수도 있고, 이것보다 더 나은 방법이나 내 생각의 오류를 발견하면서
로직을 한 단계 발전 시킬 수 있는 과정들이 소중함을 느꼈다.
무엇보다 이전에는 그냥 회사 공고들만 보면서
어,, 이것도 필요하고,, 이것도 필요하고,, 이것도 필요하구나,, 해야겠네,,? 라는 막연한 학습 목적만 있었다면,
이번 프로젝트를 통해 몸소 경험하면서
아 이런 문제를 해결하기 위해 이런게 필요하구나! 하고 알아가는 시간들이 굉장히 뜻깊었던 것 같다.
앞서 기술했던 react-query 라던지,
또 컴포넌트를 쪼개다 보니 state들을 관리하는 곳이 달라져서 구조가 복잡해지는 상황에서는
redux의 필요성을 알 수 있었다. 어떻게 또 하필 내가 공부하려고 했던 것들이 마침 필요해져서 더욱 와닿았던 것 같다.
많이 배울 수 있었지만, 아직도 나는 내 틀에 갇혀 개발하고 있다는 생각을 지울 수 없다.
내가 코드 리뷰를 받고, 또 나도 코드 리뷰를 해주고,,
코드 리뷰가 아니더라도 실력 좋은 사람들의 다른 코드들을 보면서 나도 그들처럼 성장해나가야 한다..
근데 이게 말이 쉽지,,,,,,, 참 실행이 안되는 것 같다.
그러다 보니 리팩토링 단계에서도 난 이미 최선의 코드를 짰는데, 어디를 고쳐야 할까,, 라는 편협한 생각만 든다.
이제는 정말 좋은 코드를 짜는 기술을 익힐 때가 온 것 같다.
이번 기초 프로젝트에서 아쉬웠던 부분들을 예민하게 받아들이고 개선하여
다가올 중급 프로젝트에서는 한 층 성장할 수 있도록 노력해봐야겠다.
지금 팀원 분들도 그런 부분에 있어서 많이 신경을 쓰고 계시는 것 같아 다행이랄까.!
다음 중급 프로젝트 회고록에서는 더 좋은 모습으로 돌아올 수 있길 바라며!! 아디오스
그리고 나혼자 신나게 여행 다녀올 동안..
여러 추가 기능들을 구현하시고 발전 시켜주시며 든든히 자리를 지켜주신 8팀에게 무한한 감사를..
(기초 프로젝트 회고록,, 미루고 미루다가 끝나고도 거의 한달만에 시작해서.. 이틀만에 끝내려니 너무도 힘들다 헉헉,,🥵 다음 프로젝트 부터는 좀 꾸준히.. 하자.. 기능 하나 끝날 때마다..)
ㄷㄷ 이정도면 영혼을 갈아서 넣었는데.....