'오늘의 집'을 모티브로 한 프로젝트를 돌아보며
1차 프로젝트가 끝나고 바로 2차프로젝트에 돌입 하게 되었다.
2차 프로젝트에 돌입 하기 전 1차 프로젝트에서 굴렀던 짬빠가 있어서 근거없는 자신감에 가득찬 상태로 프로젝트에 들어가게 되었다.
크게 돌아보면서 어떤 점은 아쉬웠고 어떤 점은 성장이 느껴져서 너무 좋았다.
1차 때는 걷는 법을 배우면서 넘어지는 방법과 잘 일어나는 방법에 대해서 배웠고,
2차 때는 내가 좀 더 잘 걷기 위해서 어떠한 장비를 갖추어야 하고 그 장비를 가지고 적절히 뛰는 방법도 배운 것 같다.
일단, 두 번의 프로젝트 모두 잘 성장한 것 같아 너무 기뻣고, 이제 프로젝트를 천천히 돌아보려 한다.
⛳️ tl;dr
시연 동영상
어떤걸 만들꺼죠?
우리는 오늘의 집을 모티브로 한 인테리어 커뮤니티 프로젝트를 진행하였다.
그중 나는 새로운 포스트를 올릴 수 있는 NewPost
페이지와 Login
페이지
그리고 다양하게 재활용 할 수 있는 Componenet들 (Button
, Select
, Input
, GlobalNavigationBar
, Avatar
) 를 제작 하였다.
🕺🏻 유저 플로우
- [랜딩페이지] 메인페이지
- 오늘의 집에서 store(상품판매) 부분을 제외하고 개발을 하였기 때문에
기존의 메인페이지 + 커뮤니티 사진쪽 리스트페이지를 합쳐서 만들었다. (실질적으로는 리스트페이지이다) - 게시글의 메타데이터에 따라 필터링도 되어야 한다.
- 오늘의 집에서 store(상품판매) 부분을 제외하고 개발을 하였기 때문에
- [로그인페이지] 소셜로그인페이지
- 글쓰기, 댓글달기, 좋아요 등 유저의 Action을 위해선 로그인이 필요했다.
- 우리는 카카오 소셜로그인 (OAuth 2.0)을 이용하여 유저 로그인을 구현하였다.
- [글쓰기페이지] 유저가 포스트를 올릴 수 있는 페이지
- 포스트를 올릴 수 있는 페이지로써 많은 input들을 관리 하여야 한다.(
react-hook-form
이용) - 유저는 Drag & Drop 을 이용해 사진의 순서를 바꿀 수 있어야 한다.
- 포스트를 올릴 수 있는 페이지로써 많은 input들을 관리 하여야 한다.(
- [디테일페이지] 포스트의 상세 내용을 확인 할 수 있는 페이지
- 글쓰기페이지에 올렸던 내용을 모두 확인 할 수 있는 페이지이다.
- 포스팅에 대한 댓글을 달 수 도 있다.
🔮 기능 정의
기능정의에는 백엔드에 이아영님의 손길이 들어가있다.. Thank you!
🖼 User View !! (가안 figma)
Login Page
Main(List) Page
Detail(포스팅 내용 확인) Page
NewPost(포스팅 올리는 페이지) Page
Figma에서도 확인할 수 있어요! 누르면 Figma로 넘어감 ㅅㄱ
🧨 만들며 부딫혔던 난관들
1. 소셜로그인.. 그거 어떻게 하는건데...!
처음으로 적용해보는 OAuth2.0 개념이어서 맨 처음에는 어떻게 해야 하는지 전혀 몰랐다.
카카오 DEV 와 여러 블로그의 시행 착오를 보면서 적용에 성공하였다.
우선 OAuth2.0 에 대한 공부가 필요했다.
OAuth2.0 의 Flow
우리는 KAKAO 와 FRONT 와 BACKEND 가 있다. 흐름은 다음과 같다.
1. FRONT 가 KAKAO 에게 인가코드를 주라고 요청 한다
// src/socialLogin/KakaoLogin.js
export const KAKAO_GET_AUTH_URL = `${HOST_URL}/oauth/authorize?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code`;
// src/pages/Login.jsx
<LoginButton as="a" href={KAKAO_GET_AUTH_URL}>
<img
src="/images/kakao_login_large_wide.png"
alt="카카오톡로그인"
/>
</LoginButton>
2.그럼 Redirect 된 페이지로 이동 하니 (우리의 경우 /oauth
) FRONT는 그 페이지에서 처리 해줘야 하는 return 은 null 인 로직만 있는 Route를 하나 새로 파서 거기서 기능들을 구현한다.
// src/Router.jsx
function Router() {
return (
<BrowserRouter>
<GNB />
<Layout>
<Routes>
<Route path="/users/login" element={<Login />} />
👉🏻👉🏻 <Route path="/oauth" element={<KakaoRequest />} />
<Route path="/" element={<Main />} />
<Route path="/postings/:id" element={<Detail />} />
<Route path="/postings/:id/comments" element={<Detail />} />
<Route path="/contents/new" element={<NewPost />} />
</Routes>
</Layout>
</BrowserRouter>
);
}
// src/SocialLogin/KakaoRequest.jsx
// 카카오와 연결해서 토큰 받아오고 navigate 를 통해 Main 페이지로 redirect 시킨다.
import { useLocation, useNavigate } from 'react-router-dom';
import { KAKAO_GET_TOKEN_URL } from './KakaoLogin';
import qs from 'qs';
import { useEffect } from 'react';
import { api } from 'config';
const { Kakao } = window;
Kakao.init(process.env.REACT_APP_KAKAO_JAVASCRIPT_KEY);
function KakaoRequest() {
const navigate = useNavigate();
const location = useLocation();
const authCode = qs.parse(location.search, {
ignoreQueryPrefix: true,
}).code;
const getKakaoToken = async () => {
const requestToken = qs.stringify({
grant_type: 'authorization_code',
client_id: process.env.REACT_APP_KAKAO_REST_API_KEY,
redirect_uri: process.env.REACT_APP_KAKAO_REDIRECT,
code: authCode,
});
try {
await fetch(KAKAO_GET_TOKEN_URL, {
method: 'POST',
headers: {
'Content-type': 'application/x-www-form-urlencoded;charset=utf-8',
},
body: requestToken,
})
.then(res => res.json())
.then(data => {
const { access_token } = data; // 👉🏻 이게 kakao에서 주는 토큰 이다.
// 여기까지가 2번 내용까지!!
// 이 밑으로는 백엔드와 우리 서버 전용 토큰을 받아오는 내용!
fetch(api.login, {
headers: {
Authorization: access_token,
},
})
.then(res => res.json())
.then(data => {
const { nickname, email, profile_image } = data.user_information;
sessionStorage.setItem('access_token', data.token);
sessionStorage.setItem(
'userInfo',
JSON.stringify({ nickname, profile_image, email })
);
navigate('/');
});
});
} catch (err) {
alert('다시 한번 시도해보세요');
navigate(-1);
}
};
useEffect(() => {
getKakaoToken();
}, []);
return <></>;
}
export default KakaoRequest;
- 위의 코드에서 토큰을 받아온 후 다시 우리쪽 login End-Point 로 fetch를 날리는 부분부터
백엔드에서 처리를 하고 카카오에서Redirect URI
유효성 검증을 한 후우리사이트 전용 토큰
을 받는 내용이다.
처음 해보는 내용이라 생소 했을 뿐이지! 한 번 해봐서 이제 적용을 잘 할 수 있을 듯 하다!!
2. [충격/기괴] Drag & Drop을 하면 파일이 사라진다..?
결국 코드를 보면 내가 실수 했던 부분인데, 이걸로 꽤 오랜 시간 방황했던 경험이라서 작성한다.
// /src/pages/NewPost/NewPost.jsx
const reorderImages = (destination, source, draggableId) => {
if (!destination) return;
const copyUploadedImages = [...uploadImages];
copyUploadedImages.splice(source.index, 1);
copyUploadedImages.splice(destination.index, 0, {
url: draggableId,
file: uploadImages[destination.index]?.file, 👈🏻👈🏻👈🏻👈🏻 여기가 문제임
});
setUploadImages(copyUploadedImages);
};
// /src/pages/NewPost/ContainerCardList.jsx
const onDragEnd = ({ destination, source, draggableId }) => {
reorderImages(destination, source, draggableId);
};
해당 로직은 사용자가 Drag & Drop이 끝났을 때 재정렬된 내용을 state 가 인지 할 수 있게 만든 코드이다.
Beautiful DND
에는 Drag가 끝났을 때 onDragEnd 함수를 제공해주고 있는데, 거기에는
destination
:: 드래그엔 드롭 이벤트가 종료되었을 때 드롭된 곳(종착지)에 대한 정보를 담고 있다.
source
:: 드래그앤 드롭이 출발한 곳에 대한 정보(원천)를 담고 있다.
draggableID
:: draggable 한 곳에는 draggableID 가 필수 이다. 드래그한 녀석의 identify를 해준다. 나의 경우 이미지의 blob:url
을 id 로 사용하였다.
🚨 문제점
근데 Drag & Drop
을 할 경우 기존 source 에 있던 파일이 날라가고 destination 에 있는 파일로 덮어써졌다...
왜 그런지 봤더니 실수 한 부분이었다... (실수라서 솔직히 제일 디버깅하기 힘들었다)
해결
const reorderImages = (destination, source, draggableId) => {
if (!destination) return;
const copyUploadedImages = [...uploadImages];
copyUploadedImages.splice(source.index, 1);
copyUploadedImages.splice(destination.index, 0, {
url: draggableId,
file: uploadImages[source.index]?.file, 👈🏻👈🏻👈🏻👈🏻 destination => source 로 수정
});
setUploadImages(copyUploadedImages);
};
source 에 있는 uploadImages 로 수정해주었따.
3. 어? 계속 Key Error 나는데요..??
백엔드와 통신 하기 전 콘솔에 찍어서 어떤식으로 내가 파일을 보내줄 지 전달해주었다.
하지만 실제 통신 시에는 계속해서 Key Error가 났다....
분명 Key 값도 다 맞춰줬는데... 왜이러지...?
해당 내용은 여기서 확인 할 수 있습니다.
4. 어.. 이거 버튼한테 props가 잘 전달 안되는것 같은데요??
재사용을 위한 컴포넌트를 제작하던 중 다음과 같은 리뷰를 받았다.
리뷰 내용을 반영하여 Button에게 받는 모든 내용들을 props로 받을 수 있게끔 수정하였다.
function Button({ children, styleType = 'primary', btnSize = 'md', ...props }) {
return (
<>
{
{
primary: (
<PrimaryButton size={btnSize} {...props}>
{children}
</PrimaryButton>
),
outline: (
<OutlineButton size={btnSize} {...props}>
{children}
</OutlineButton>
),
}[styleType]
}
</>
);
}
그런데 이걸 소개 할 때 Button 보다는 Select 를 보여주면 좋을 것 같아서 Select 재사용 코드를 공유한다.
// src/components/Select/Select.jsx
import styled from 'styled-components';
import { GoTriangleDown } from 'react-icons/go';
function Select({
register,
options,
name,
defaultValue,
required = 'false',
...otherProps
}) {
return (
<SelectWrapper>
<SelectInput {...register(name, { required: required })} {...otherProps}>
{defaultValue && (
<option selected value="" disabled>
{defaultValue}
</option>
)}
{options?.map((value, index) => (
<option key={value} value={index + 1}>
{value}
</option>
))}
</SelectInput>
<TriangleDown />
</SelectWrapper>
);
}
export default Select;
// Select 를 사용 하는 곳
function MetadataForm(props) {
const { register } = useFormContext();
return (
<MetadataFormDiv>
<SelectGroup>
<Select
register={register}
name="size"
defaultValue="평수"
options={SIZE_OPTIONS}
/>
<Select
register={register}
name="residence"
defaultValue="주거형태"
options={RESIDENCE_OPTIONS}
/>
<Select
register={register}
name="style"
defaultValue="스타일"
options={STYLE_OPTIONS}
/>
</SelectGroup>
<TitleInput
{...register('title', { required: true })}
placeholder="포스트제목을 입력해주세요"
/>
</MetadataFormDiv>
);
}
Select 는 react-hook-form 을 이용 하는 곳이라서 props 로 보내줘야 할 것들이 많았다.
다음과 같이 Select 에 보내고자 하는 것들을 다 보내고 Select.jsx 에서 다시 재조립을 한다음 유저의 View 에 보여주게 만들었다.
코드 재사용을 활용하여 코드가 줄어들고 읽기 편해지면 정말 짜릿하다..👍🏻👍🏻
5. 새로운 라이브러리 이거 적용 어떻게 해...?
코드샌드박스를 이용해서 로직과 패턴을 공부하고 적용을 하니 든든해졌다..!
이 부분은 여기서 확인 할 수 있다!
6. 로컬/세션스토리지에는 배열이 들어가지 않는군요?
const [searchHistory, setSearchHistory] = useState(
JSON.parse(localStorage.getItem('searchHistory'))
);
localStorage.setItem('searchHistory', JSON.stringify(searchHistory));
스토리지에 올릴 때는 stringify 를 통해 직렬화를 하여 올려주고
다시 받아서 사용할 때에는 parse 를 통해 다시 객체로 만들어 주면 됐다.
7. 6번에서 이어진 버그:: 배열이 undefined 라서 에러 뱉을게요~
그런데 위에 코드를 보면 에러 냄새가 폴폴난다.
searchHistory 를 보면 저기서 localStorage 에 searchHistory
가 없으면 야단이 좀 나겠는걸...?
다음과 같이 수정해주었다.
const [searchHistory, setSearchHistory] = useState([]);
useEffect(() => {
if(localStorage.getItem('searchHistory')) setSearchHistory(JSON.parse(localStorage.getItem('searchHistory')));
if(!localStorage.getItem('searchHistory')) {
sessionStorage.setItem('searchHistory', JSON.stringify(['']));
setSearchHistory(JSON.parse(localStorage.getItem('searchHistory')));
}
},[searchHistory])
<SearchDropDownWrapper isSearchDropDownMenuOpened={isOpened}>
<SearchList>
<SearchListHeader as="header">최근 검색어</SearchListHeader>
{searchHistory?.map((searchItem, index) => (
<MenuItem key={index}>
<Link to="#">{searchItem}</Link>
</MenuItem>
))}
</SearchList>
</SearchDropDownWrapper>
실제 드롭다운 부분에서도 optional chaning
을 사용하여 없더라도 에러가 안뜨게 핸들링 하였다.
💁🏻♀️ 공유 하고 싶은 것 들
1. 로직을 구조로 해결하자!
이 부분은 여기서 확인 할 수 있다!
if
문 없이 Object
를 사용하여 구조로 해결 했더니 조금 더 깔끔해졌다.
렌더하는 쪽은 로직을 신경 쓰기보단 한눈에 어떤게 그려질지 보여지면 더더욱 좋을 것 같아 실행 해본 방법이다.
2. 새로운걸 적용하기 전 CodeSandBox를 적극 활용하자.
이 부분은 여기서 확인 할 수 있다!
위에서 본 새로운 라이브러리 이거 적용 어떻게 해...? 와 같은 링크입니다.
처음 라이브러리를 배우는 상황이거나 공식문서를 보고 따라해봐야 하는데, create-react-app 을 하나 만들기는 좀 그렇고, 그렇다고 내가 만들어놓은 코드에 적용을 바로 하기에는 무섭고... (물론 커밋을 해놓고 revert 를 해도 되지만) 이럴 때 정말 좋은게 Code Sand Box 였다.
마무리하며
이번 프로젝트를 진행하며 아.. 진짜 많이 성장했구나.. 를 느꼈다.
처음 디딤발을 내밀 때가 엊그제 같은데 이제 전체적인 구조를 파악 할 수도 있고,
필요에 따라 재활용성이 좋은 Component 를 제작할 수도 있고,
여러 라이브러리를 공부하며 빠르게 적용 할 수도 있었고 등등...
그리고 좋은 팀웤으로 함께 헤쳐나간 블로커들...
여러모로 함께 해준 팀원들에게도 무한 감사를...
샤라웃 투 해수님, 상일님, 병연님, 원석님, 아영님..
Author And Source
이 문제에 관하여('오늘의 집'을 모티브로 한 프로젝트를 돌아보며), 우리는 이곳에서 더 많은 자료를 발견하고 링크를 클릭하여 보았다 https://velog.io/@yunkuk/프로젝트를-돌아보며저자 귀속: 원작자 정보가 원작자 URL에 포함되어 있으며 저작권은 원작자 소유입니다.
우수한 개발자 콘텐츠 발견에 전념 (Collection and Share based on the CC Protocol.)