Internship - 과제 1
회원가입, 리스트, 디테일 페이지 구현 과제
프로젝트 깃허브 링크
기능 구현 상세
회원가입 페이지
- 가입하기 클릭 시 input tag 에 빈값이나 알맞지 않는 형식일 경우 에러 문구 띄어주는 기능 구현
- 인증번호 받기 API 이용해서 인증번호 받아서 인증번호 인증까지 기능 구현
- 휴대폰 번호는 82-1012341234 으로 입력
- 에러핸들링
- 인증완료시 휴대폰 인증하기 버튼 disabled 처리
리스트 페이지
- 데이터 상태관리는 SWR를 사용하여 api 호출
- 전체글 , 인기글 필터링 기능 구현
- 리스트 페이지의 경우 swr infinte scroll과 react-intersection-observer 사용
디테일 페이지
- getStaticProps , getStaticPath 이용하여 pre-rendering 구현
- 이전글, 다음글 nav바 구현
Challenging Points :
- TypeScript, Next.js 모두 처음 접해보았기 때문에 이 언어, 프레임워크를 이해하고 적응하는 것이 가장 큰 과제로 다가왔다.
- getStaticProps, getStaticPaths, SWR을 익히는 과정이 까다로웠으나, 현직에서 자주 사용할 일이 많았기에 성취감이 컸다.
- 인턴 첫주에는 매일 같이 새벽 4시까지 공부하고 코딩을 했었는데, 조금 일찍 번아웃을 겪었던 것 같다.
- 사수들에게 질문하는 게 너무 어려웠다! 알아서 찾아 해결하는게 습관이 되다 보니, 질문하는 일을 너무 아꼈던 것 같다.
공유하고 싶은 코드 :
1.회원가입 페이지
회원가입 페이지 전체 코드 링크
핸드폰 번호로 인증번호를 요청과 입력한 인증번호 검증을 요청하는 fetch 함수
이 페이지에서는 유일하게 api와 통신하는 함수였기 때문에 기억에 남는다.
처음 react-hook-form과 함께 사용하였기 때문에 조금 더 난이도 있게 느껴졌었던 것 같다.
현직에서는 이 함수들을 libs/ 같은 폴더에 분리하여 import해서 사용했다.
const fetchRegister = async () => {
const phone = watch("id");
try {
const response = await fetch(`../api/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (response.ok) {
const result = await response.json();
setRegister(true);
alert(`인증번호는 ${result?.data?.message} 입니다.`);
trigger("id");
} else {
throw await response.json();
}
} catch (e) {
const error = e as Error;
alert(error.data?.message);
setError("id", {
type: "wrong number",
message: "잘못된 번호입니다.",
});
}
};
const fetchAuth = async () => {
const auth = watch("auth");
try {
const response = await fetch(`../api/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auth }),
});
if (response.ok) {
setAuth(true);
alert(`인증되었습니다.`);
trigger("auth");
} else {
throw await response.json();
}
} catch (e) {
const error = e as Error;
alert(error.data.message);
setError("auth", {
type: "wrong number",
message: "잘못된 번호입니다.",
});
}
};
2.리스트 페이지
리스트 컨테이너 컴포넌트 링크
리스트 페이지의 무한스크롤 구현을 위한 컴포넌트 코드이다.
SWR을 사용하면 fetch로 받은 데이터를 useEffect로 주입시켜주는 과정이 생략되는데,
그게 너무 혁명적으로 다가와서 감동받았다 (..)
(실제로도 현직에서 swr을 사용했기에 useEffect로 fetching 데이터 관리를 할 일이 잘 없었다.)
또한 처음으로 SSG같은 nextjs의 고유한 기능과 swr을 이용해 쿼리를 업데이트하는 페이지였기 때문에 더욱 특별하게 기억에 남는다.
돌아보니 내 개인 블로그를 개발할 때 큰 밑거름이 되어준 것 같다.
function ListContainer({ likes }: { likes: boolean }) {
// useSwrInfinite에 들어갈 fetch 함수
const getKey = (
pageIndex: number,
prevPageData: { data: { data: object } }
) => {
let order = "";
// 더이상보낼 페이지가 없으면 return
if (prevPageData && !prevPageData.data) {
return null;
}
// 좋아요 필터링 적용시 다른 URI로 요청
if (likes) {
order = "orderBy=likes&";
}
return `https://api.dev.coinghost.com/blogs?${order}page=${
pageIndex + 1
}&limit=10`;
};
const scrollLoading = (): void => {
// 무한 스크롤 구현 함수.
// fetch로 보내온 페이지 데이터가 totalPage(전체 페이지)보다 크다면 return
if (
!error &&
data &&
data[data.length - 1].data?.meta.totalPage <=
data[data.length - 1].data?.meta.page
) {
return;
}
setSize(size + 1);
};
const { data, setSize, size, error } = useSWRInfinite<
fetchDataInterface,
object
>(getKey, fetcher);
// intersection-observer ref 객체와 옵션에 함수 적용
const { ref } = useInView({
onChange: () => {
scrollLoading();
},
threshold: 0,
});
// 컴포넌트 출력 함수
const Dataprint = data && data.map((el) => el.data).map((el) => el.data);
return (
<ListWrapper>
<div className="position">
{!Dataprint
? ""
: Dataprint.flat().map((el) => {
return (
<ListContent
id={el.id}
key={el.id}
title={el.title}
creator={el.creator}
createdAt={el.createdAt}
defaultThumbnail={el.defaultThumbnail}
likes={el.likes}
comments={el.comments}
/>
);
})}
</div>
{data &&
data[data.length - 1].data?.meta.page !==
data[data.length - 1].data?.meta.totalPage ? (
<InView>
// 로딩 시 스피너가 나오도록 함
<LoaderStyle ref={ref}>
<FadeLoader color={theme.colors.sign} />
</LoaderStyle>
</InView>
) : (
<ListTip />
)}
</ListWrapper>
);
}
3.디테일 페이지
디테일 페이지 부모 컴포넌트 링크
NextJs의 꽃인 Pre-Rendering을 경험할 수 있어서 특별히 기억에 남는다.
개발자 도구의 네트워크 창에서 pre-render된 페이지를 보고 꽤나 뿌듯해하던 기억이 난다.
html을 열어보았을 때 <div id="root"> 대신에 (fetching된 데이터임에도) 코드가 미리 다 찍혀있는 것도 신기했다.
SSG는 맛보기라고만 생각했는데, 지나고보니 현직에서도 디테일 페이지는 SSG를 많이 썼기 때문에 요긴했다.
당시에 조금 더 fallback에 대해 살펴보면 좋았었겠지만, 여튼 때가 되니 다 알게 되었다.
const fetcher = (url: RequestInfo) => fetch(url).then((res) => res.json());
const detail: NextPage = ({
post,
blogs,
}: InferGetStaticPropsType<typeof getStaticProps>) => {
const router = useRouter();
const result: DataType = post?.data?.data;
const blogsData = blogs?.data?.data;
const currentIndex = blogsData?.findIndex(
(i: { id: number }) => i.id === Number(router.query.id)
);
// 이전 글, 다음 글을 위해 인덱스 가져오기
const next =
blogsData &&
blogsData.find((el: object, i: number) => i === currentIndex + 1);
const prev =
blogsData &&
blogsData.find((el: object, i: number) => i === currentIndex - 1);
return (
<Layout width="750px">
<DetailHeader />
<ContentWrapper>
<Title title={result?.title} size="33px" margin="45px 0" />
<Profile
nickName={result?.creator?.nickName}
createdAt={result?.createdAt}
view={result?.views}
url={result?.defaultThumbnail?.url}
/>
<BreakLine />
<Content content={result?.contents} />
<NavButton />
<DetailBanner url={result?.thumbnail?.url} />
<Comment likes={result?.likes} comments={result?.comments} />
<ListNav prev={prev} next={next} />
</ContentWrapper>
<Footer />
</Layout>
);
};
// 게시글 id에 따라 동적으로 url 생성
export const getStaticPaths: GetStaticPaths = async () => {
const res = await fetch(`https://api.dev.coinghost.com/blogs`);
const data = await res.json();
const posts = data?.data.data;
const paths = posts.map((post: { id: number }) => ({
params: { id: post.id.toString() },
}));
return { paths, fallback: true };
};
// pre-rendering을 위한 getStaticProps
export const getStaticProps: GetStaticProps = async ({ params }) => {
const post = await fetcher(
`https://api.dev.coinghost.com/blogs/${params?.id}`
);
const blogs = await fetcher("<https://api.dev.coinghost.com/blogs>");
return { props: { post, blogs } };
};
디테일 페이지 자식 컴포넌트 링크
html-react-parser 이용, JSON 데이터의 블로그 내용 string을 html 코드로 변환
import styled from "styled-components";
function Content({ content }: { content: string }) {
const parse = require("html-react-parser");
return <ContentStyle>{parse(content)}</ContentStyle>;
}
const ContentStyle = styled.div`
width: 100%;
margin-bottom: 45px;
font-size: 26px;
font-weight: normal;
font-stretch: normal;
font-style: normal;
line-height: 1.54;
text-align: left;
word-break: break-word;
color: ${(props) => props.theme.colors.black};
img {
width: 100%;
}
`;