사전교육 2주차에는 React를 이용한 사진 앨범 프로젝트와 NextJS를 이용한 Todo 보드 프로젝트를 제작하였다. 사실상 과제를 제출하며 배운 내용이 곧 학습 내용이기 때문에 합쳐 작성한다.
Unsplash API를 이용하여 사진 데이터를 가져와 페이지에 제공하는 프로젝트다.
페이지 상단 메뉴의 이름이 카테고리이며 각 카테고리 별 사진을 조회할 수 있다.
이 프로젝트의 구현에서 중요했던 기능은 세 가지이다.
export const fetchApi = async (
searchValue: string,
page: number,
per_page: number = 30
) => {
const API_KEY = '발급 받은 Unsplash API 키';
const BASE_URL = 'https://api.unsplash.com/search/photos';
try {
const res = await axios.get(
`${BASE_URL}?client_id=${API_KEY}&page=${page}&query=${searchValue}&per_page=${per_page}`
);
return res;
} catch (error) {
console.error(error);
}
};
const fetchImages = useCallback(async () => {
try {
const res = await fetchApi(searchValue, page);
if (res && res.status === 200 && res.data) {
console.log(res.data);
setImageList(res.data.results);
setTotalImageNum(res.data.total);
toast({
title: `${page}번째 페이지의 이미지 호출 성공!`,
});
} else {
toast({
variant: 'destructive',
title: '이미지 호출 실패!',
description: '필수 파라미터 값을 확인해주세요.',
});
}
} catch (error) {
console.error(error);
}
}, [page, searchValue, toast, setTotalImageNum]);
useEffect(() => {
fetchImages();
}, [fetchImages]);
사진 조회 결과를 imageList라는 상태로 관리하고 이를 페이지에서 렌더링한다.
const onAddBookmark = () => {
if (!bookmark.length) {
localStorage.setItem('bookmark', JSON.stringify([data]));
setBookmark([data]);
toast({
title: '북마크 추가 성공하였습니다.',
});
} else {
if (isIncluded) {
toast({
variant: 'destructive',
title: '이미지가 이미 북마크 되어 있습니다.',
});
} else {
localStorage.setItem('bookmark', JSON.stringify([...bookmark, data]));
setBookmark([...bookmark, data]);
toast({
title: '북마크 추가 성공하였습니다.',
});
}
}
};
북마크는 이미지 카드 컴포넌트에서 버튼을 클릭하면 추가할 수 있다. 북마크에 대한 관리는 북마크 페이지 컴포넌트와 이미지 카드 컴포넌트에서 아용하므로 전역 상태로 관리한다.
북마크는 로컬 스토리지로 관리하는데
{isIncluded && (
<Button
size="icon"
className=" absolute bg-neutral-500 bg-opacity-50 top-2 right-16 z-10"
onClick={onClickDeleteBookmark}
>
<Trash className="h-5" />
</Button>
)}
ImageCard에 삭제 버튼을 추가했다. isIncluded
는 북마크에 해당 카드가 포함이 되어있는지 여부를 나타낸다. 따라서 북마크 지정된 카드만 삭제 버튼이 나타난다.
const bookmark: ImageCardType[] = JSON.parse(
localStorage.getItem('bookmark') || '[]'
); // 로컬 스토리지에 bookmark가 존재하지 않는 경우 '[]'를 parse하여 오류를 방지합니다.
const isIncluded =
bookmark.findIndex((item: ImageCardType) => item.id === data.id) > -1;
const onAddBookmark = () => {
...
};
const onClickDeleteBookmark = () => {
const deletedBookmark = bookmark.filter((item) => item.id !== data.id);
localStorage.setItem('bookmark', JSON.stringify(deletedBookmark));
toast({
title: '북마크가 삭제되었습니다.',
});
};
북마크 관련 코드이다. bookmark를 filter
메서드를 이용하여 이미지 데이터들 중 현재 이미지(=삭제될 이미지)를 제외한 이미지 데이터만 남기고 deletedBookmark
변수에 저장한다. 그리고 그 데이터를 로컬스토리지에 새로 저장한다.
import { Header, Nav } from '@/components/common';
import { ImageCard } from '@/components/Home';
import { ImageCardType } from '@/types';
import { useEffect, useState } from 'react';
function BookmarkPage() {
const [bookmark, setBookmark] = useState<ImageCardType[]>([]);
useEffect(() => {
const storedBookmark = JSON.parse(localStorage.getItem('bookmark') || '[]');
setBookmark(storedBookmark);
}, [bookmark]);
return (
<div className="page">
<div className="page__container">
<Header />
<Nav />
{bookmark.length ? (
<div className="page__container__contents">
{bookmark.map((image) => (
<ImageCard key={image.id} data={image} />
))}
</div>
) : (
<div className="w-full h-[800px] flex items-center justify-center">
<h3 className="text-2xl font-bold">북마크가 없습니다.</h3>
</div>
)}
</div>
</div>
);
}
export default BookmarkPage;
북마크 페이지 코드이다. bookmark를 상태로 저장하고 bookmark가 변경될 때마다 다시 로컬스토리지에서 값을 가져오도록 했다. 이 코드가 없으면 새로고침해야 삭제된 이후의 데이터가 반영된다.
※ 이 코드에는 문제가 있다… 북마크를 해제했을 때 바로 반영되었던 이유는 무한루프를 돌기 때문이다.
useEffect(() => {
const storedBookmark = JSON.parse(localStorage.getItem('bookmark') || '[]');
setBookmark(storedBookmark);
}, [bookmark]);
bookmark가 바뀔 때마다 useEffect가 실행되는데 useEffect 코드 안에서 setBookmark
를 이용하여 bookmark를 변경하고 있으니 무한 루프가 발생한다. 따라서 bookmark를 전역 상태로 관리하는 방법을 사용했다.
const [bookmark, setBookmark] = useAtom(bookmarkAtom);
프로젝트 내에서 사용하고 있는 Jotai
라이브러리를 이용하여 bookmark를 전역 상태로 관리한다.
const onClickDeleteBookmark = () => {
const deletedBookmark = bookmark.filter((item) => item.id !== data.id);
localStorage.setItem('bookmark', JSON.stringify(deletedBookmark));
setBookmark(deletedBookmark);
toast({
title: '북마크가 삭제되었습니다.',
});
};
이전 로직에 setBookmark를 이용하여 전역 상태를 바꾸는 코드를 추가한다.
import { Header, Nav } from '@/components/common';
import { ImageCard } from '@/components/Home';
import { bookmarkAtom } from '@/store';
import { useAtom } from 'jotai';
import { useEffect } from 'react';
function BookmarkPage() {
const [bookmark, setBookmark] = useAtom(bookmarkAtom);
return (
<div className="page">
<div className="page__container">
<Header />
<Nav />
{bookmark.length ? (
<div className="page__container__contents">
{bookmark.map((image) => (
<ImageCard key={image.id} data={image} />
))}
</div>
) : (
<div className="w-full h-[800px] flex items-center justify-center">
<h3 className="text-2xl font-bold">북마크가 없습니다.</h3>
</div>
)}
</div>
</div>
);
}
export default BookmarkPage;
북마크 페이지에서는 단순하게 전역 상태 bookmark를 가져와서 렌더링한다.
근데 이것마저도 전역 상태를 사용할 필요 없이 삭제하는 경우 다시 로컬 스토리지에서 가져와서 상태로 저장하면 문제 없이 반영이 될 것 같다.
관련 코드
페이지네이션 UI는 shadcn이 제공하는 컴포넌트를 이용하였다.
const { pathname } = useLocation();
const [page, setPage] = useAtom(pageAtom);
const perPage = 30;
const [total] = useAtom(totalImageNumAtom);
const totalPage = Math.ceil(total / perPage);
우선, 페이지 별 사진을 조회했을 때의 코드를 보면 totalImageNum을 전역 상태로 관리하는 것을 알 수 있다. 이 상태는 조회된 사진의 총 개수를 저장하고 있다. 이를 활용하는 이유는 총 몇개의 페이지가 생성되는지 계산하기 위함이다.
pathname
: 라우팅 경로를 얻기 위해 useLocation을 이용하였다.page
: 전역 상태로 관리하는 값. 현재 페이지를 나타내며 API를 이용하여 이미지를 가져올 때도 사용된다.perPage
: 한 페이지 당 렌더링하는 이미지의 개수. 고정 값으로 지정.total
: 이미지를 가져왔을 때의 결과 값(res)에 이미지의 총 개수와 총 페이지 개수가 들어있다. total은 이미지의 총 개수를 의미한다. totalPage
: 총 페이지의 개수. 총 이미지의 개수에서 한 페이지에 표시할 사진의 개수를 나누어 계산한다.const pageList = Array.from({ length: 10 }, (_, index) => {
return Math.floor((page - 1) / 10) * 10 + index + 1;
});
Math.floor((page - 1) / 10) * 10 + index + 1
page - 1
을 한 이유는, -1을 하지 않고 계산하면 page가 10으로 활성화 된 경우에 pageList가 [11 ~ 20]까지로 계산되어 버린다. 따라서 1을 빼주어야 page가 10이어도 [1 ~ 10]의 결과가 나온다.* 10
: 10개씩 페이지네이션 바를 생성하기 때문에 10을 곱한다.index + 1
은 index가 0부터 시작하는데, 페이지는 1부터 시작해야하기 때문에 1을 더한다.const onClickPageNumber = (type: string, num?: number) => {
if (type === 'prev') {
if (page === 1) {
setPage(1);
} else {
setPage((prev) => prev - 1);
}
}
if (type === 'next') {
if (page === totalPage) {
setPage(totalPage);
} else {
setPage((prev) => prev + 1);
}
}
if (type === 'num' && num) {
setPage(num);
}
};
onClickPageNumber
는 페이지네이션 아이템들을 클릭했을 때 호출된다. 실행할 때 type (prev
, next
, num
)을 전달한다.
prev
: Previous를 눌렀을 때 실행된다.next
: Next를 눌렀을 때 실행된다.num
: 페이지 번호를 눌렀을 때 실행된다. 어떤 숫자를 눌렀는지도 함께 전달된다.prev일 때는 1보다 작은 페이지를 설정해서는 안되므로 page를 1로 설정하고, page가 1이 아닌 경우에는 page-1
을 설정한다.
next일 때는 총 페이지 수보다 page가 커지면 안되므로 totalPage
를 설정하고 그 외에는 page+1
을 설정한다.
페이지 번호를 눌렀을 때는 page를 해당 번호로 설정한다.
return (
<Pagination>
<PaginationContent>
<PaginationItem onClick={() => onClickPageNumber('prev')}>
<PaginationPrevious href={`${pathname}`} />
</PaginationItem>
{pageList.map((item) => (
<PaginationItem
key={'page' + item}
onClick={() => onClickPageNumber('num', item)}
className={item === page ? 'bg-blue-200 rounded' : ''}
>
<PaginationLink href={`${pathname}`}>{item}</PaginationLink>
</PaginationItem>
))}
<PaginationItem onClick={() => onClickPageNumber('next')}>
<PaginationNext href={`${pathname}`} />
</PaginationItem>
</PaginationContent>
</Pagination>
);
페이지네이션 바를 렌더링한다. pageList를 map 메서드로 순회하며 렌더링하고 해당 페이지인 경우에는 파란색 배경을 주어 눈으로 확인할 수 있도록 했다.
href의 경우에는 쿼리 파라미터로 ?page=pageNumber
형태로 표현할까 했는데 딱히 사용하지 않을 것 같아 표시하지는 않았다. (근데 하는게 나을 것 같다.)
쓰다보니 리액트 프로젝트의 내용이 길어졌는데 사실 2주차에는 NextJS를 학습한 것이 메인이다.
※ 기능 구현 후 스스로 프로젝트를 업그레이드 시키고 있기 때문에 UI가 조금 다를 수 있다.
이 프로젝트는 Supabase DB를 연동하여 데이터를 관리한다.
Supabase 홈페이지에서 프로젝트를 생성한다. 그 후에 .env.local
파일에 supabase url과 supabase api key를 세팅한다.
import { createClient } from '@supabase/supabase-js';
// 환경 변수에서 Supabase 설정 가져오기
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL as string;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY as string;
export const supabase = createClient(supabaseUrl, supabaseKey);
클라이언트에서 supabase를 사용할 수 있도록 위의 파일을 작성한다.
npm i @supabase/supabase-js
정상적으로 CRUD를 하려면 RLS 정책을 설정해야 한다.
RLS 정책은 템플릿을 이용하되 일단은 모든 사용자가 권한을 가질 수 있도록 설정한다.
프로젝트에서 데이터를 생성하는 기능이 필요한 경우는 두 가지다.
export const useCreatePage = () => {
const [pages, setPages] = useAtom(pagesAtom);
const { toast } = useToast();
const router = useRouter();
const createPage = async () => {
try {
const { data, status, error } = await supabase
.from('todos')
.insert([{ title: '', boards: [], from: null, to: null }])
.select();
if (status === 201 && data) {
setPages((prevPages) => [...prevPages, data[0]]);
toast({
title: '페이지 생성이 완료되었습니다.',
description: `/boards/${data[0].id}`,
});
router.push(`/boards/${data[0].id}`);
}
if (error) {
toast({
variant: 'destructive',
title: '에러가 발생했습니다.',
description: '개발자 도구 창을 확인하세요.',
});
}
} catch (error) {
console.error(error);
}
};
return [createPage];
};
supabase에 데이터를 주입하는 일은 비동기로 진행되기 때문에 try-catch 문을 이용하여 에러를 핸들링할 수 있도록 한다. supabase 인스턴스에서 from 메서드로 어떤 테이블을 참조할 지 지정한다. 그 후에 insert 메서드 안에 추가할 데이터를 전달한다. 추가된 데이터를 다시 받아오기 위해 select 메서드를 사용한다.
성공적으로 데이터를 생성하고 반환된 데이터가 있다면 pages 상태를 갱신한다. pages 상태는 전체 todo 페이지를 배열로 갖고 있는 상태이다. 사이드 바에 todo들을 렌더링 하고 라우팅을 위해 사용된다. 그 후에 toast UI로 결과를 표시하고 생성된 페이지로 유저를 이동시킨다.
export const useCreateBoard = () => {
const [currentPage] = useAtom(currentPageAtom);
const { toast } = useToast();
const [, fetchPage] = useFetchCurrentPage();
const createBoard = async (id: string | string[] | undefined) => {
const boardContent = {
id: nanoid(8),
isCompleted: false,
title: '',
from: null,
to: null,
contents: '',
};
const newBoards: BoardData[] = [
...(currentPage.boards || []),
boardContent,
];
try {
const { status, error } = await supabase
.from('todos')
.update({ boards: newBoards })
.eq('id', id)
.select();
if (status === 200) {
toast({ title: '보드가 생성되었습니다.' });
fetchPage(id);
}
if (error) {
toast({
variant: 'destructive',
title: '보드 생성에 실패했습니다.',
description: '개발자 도구 창을 확인하세요.',
});
}
} catch (error) {
console.error(error);
}
};
return [createBoard];
};
newBoards
: 기존의 boards 배열에 새로운 보드가 추가된 배열을 의미한다.
const onChangeTitle = (e: ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
setCurrentPage((prev) => ({ ...prev, title: input }));
};
새롭게 사용자가 입력한 값을 이용하여 현재 페이지의 title에 할당하고 상태를 변경한다.
const onSelectDate = (label: 'from' | 'to', date: Date) => {
// 시간 오프셋 계산
const koreaTime = calculateTimeOffset(date);
setCurrentPage((prev) => ({ ...prev, [label]: koreaTime }));
};
<div className="flex items-center gap-2">
<DatePicker
label="From"
data={currentPage.from}
onSelect={onSelectDate}
/>
<DatePicker
label="To"
data={currentPage.to}
onSelect={onSelectDate}
/>
</div>
onSelectDate
는 DatePicker에서 날짜를 골랐을 때 실행되는 함수이다.
데이터베이스에 저장되는 시간과 현지 시간에 차이가 있기 때문에 시차를 계산하고 상태에 반영한다.
const onChangeTitle = (e: ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
const changedBoards = currentPage.boards.map((board) =>
board.id === data.id ? { ...board, title: input } : board
);
setCurrentPage({ ...currentPage, boards: changedBoards });
};
보드 자체의 상태에 사용자의 입력을 반영한다. 그 후에 현재 페이지에서 현재 보드에 새로운 상태(제목이 변경된 보드의 상태)를 반영한다.
const onCheck = (checked: boolean | string) => {
const changedBoards = currentPage.boards.map((board) =>
board.id === data.id
? { ...board, isCompleted: checked === true ? true : false }
: board
);
setCurrentPage({ ...currentPage, boards: changedBoards });
};
<Checkbox
className="w-5 h-5 border-neutral-400"
onCheckedChange={(checked) => onCheck(checked)}
checked={boardData.isCompleted}
/>
Checkbox는 shadcn ui가 제공하는 컴포넌트로 onCheckedChange 이벤트를 사용하면 체크 상태를 가져올 수 있다.
checked 여부를 보드의 상태에 반영한다. 그 후에 현재 페이지 상태에 보드의 상태를 반영한다.
const onClickDuplicate = () => {
const newBoards = [...currentPage.boards];
newBoards.push({
...data,
id: nanoid(8),
isCompleted: false,
});
const page = { ...currentPage, boards: newBoards };
setCurrentPage(page);
};
보드를 추가하는 로직과 비슷하지만 보드의 내용이 Duplicate 버튼을 누른 보드와 동일하도록 설정한다. 다만, id는 겹치면 안되므로 새로 생성하고 완료 여부는 복사하지 않는다. 그 후에 현재 페이지에 상태를 반영한다.
export const useDeleteBoard = () => {
const [currentPage] = useAtom(currentPageAtom);
const currentBoard = currentPage.boards;
const { toast } = useToast();
const [, fetchPage] = useFetchCurrentPage();
const deleteBoard = async (
pageId: string | string[] | undefined,
boardId: string
) => {
try {
const deletedBoard = currentBoard.filter((board) => board.id !== boardId);
const { status, error } = await supabase
.from('todos')
.update({ boards: deletedBoard })
.eq('id', pageId)
.select();
if (status === 200) {
toast({
title: '보드가 삭제되었습니다.',
});
fetchPage(pageId);
}
if (error) {
toast({
variant: 'destructive',
title: '보드 삭제에 실패했습니다.',
description: '개발자 도구 창을 확인하세요.',
});
}
} catch (error) {
console.error(error);
}
};
return [deleteBoard];
};
filter 메서드를 이용하여 현재 페이지의 보드 배열에서 Delete 버튼을 누른 현재 배열을 제외한 데이터만 반영한다. 그 이후에 supabase의 todo를 업데이트 한다.
const onSelectDate = (label: 'from' | 'to', date: Date) => {
// 시간 오프셋 계산
const koreaTime = calculateTimeOffset(date);
const changedBoards = currentPage.boards.map((board) =>
board.id === data.id ? { ...board, [label]: koreaTime } : board
);
setCurrentPage({ ...currentPage, boards: changedBoards });
};
페이지의 날짜 변경 코드와 유사하며 상태 변경 대상만 다릅니다. 보드의 날짜 변경이기 때문에 현재 페이지에서 현재 보드를 찾아 날짜를 변경하는 방식입니다.
'use client';
import { Dispatch, ReactNode, SetStateAction, useState } from 'react';
import MarkdownEditor from '@uiw/react-markdown-editor';
...
interface Props {
children: ReactNode;
data: BoardData;
setData: Dispatch<SetStateAction<BoardData>>;
}
function MarkDownEditorDialog({ children, data, setData }: Props) {
const [boardData, setBoardData] = useState(data);
const [markdown, setMarkdown] = useState<string>(data.contents);
const [currentPage, setCurrentPage] = useAtom<Page>(currentPageAtom);
const currentBoardIndex = currentPage.boards.findIndex(
(board) => board.id === data.id
);
const onSelectDate = (label: 'from' | 'to', date: Date) => {
// 시간 오프셋 계산
const offsetInMinutes = date.getTimezoneOffset();
const koreaTime = new Date(date.getTime() - offsetInMinutes * 60 * 1000);
currentPage.boards[currentBoardIndex][label] = koreaTime;
setCurrentPage({ ...currentPage });
};
const onClickDone = () => {
currentPage.boards[currentBoardIndex].contents = markdown;
setData({ ...boardData, contents: markdown });
setCurrentPage({ ...currentPage });
};
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<div className="flex items-center mb-2 gap-2">
{/* 체크박스 */}
<Checkbox
className="w-5 h-5 border-neutral-400"
// onCheckedChange={(checked) => onCheck(checked)}
checked={boardData.isCompleted}
/>
<DialogTitle className="font-semibold text-2xl">
{boardData.title || 'title'}
</DialogTitle>
</div>
<div className="flex items-center gap-2">
<DatePicker
label="From"
data={boardData.from}
onSelect={onSelectDate}
/>
<DatePicker
label="To"
data={boardData.to}
onSelect={onSelectDate}
/>
</div>
</DialogHeader>
<div className="flex items-center justify-center">
{/* 에디터 영역 */}
<MarkdownEditor
value={markdown}
height="320px"
onChange={(value) => setMarkdown(value)}
className="w-full"
/>
</div>
<DialogFooter>
<DialogClose asChild>
<CustomButton type="text">Cancel</CustomButton>
</DialogClose>
<DialogClose asChild>
<CustomButton type="filled" onClick={onClickDone}>
Done
</CustomButton>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { MarkDownEditorDialog };
react-markdown-editor
라는 라이브러리를 이용했다.
const onClickDone = () => {
currentPage.boards[currentBoardIndex].contents = markdown;
setData({ ...boardData, contents: markdown });
setCurrentPage({ ...currentPage });
};
shadcn ui가 제공하는 다이얼로그 컴포넌트에 Done 버튼을 추가하였고 이 버튼을 클릭하면 실행되는 함수이다. Markdown 컴포넌트에서 작성하는 내용을 markdown 상태로 관리했고 이를 현재 페이지의 contents에 저장하여 반영한다.
const onDeletePage = async () => {
try {
const { status, error } = await supabase
.from('todos')
.delete()
.eq('id', id);
if (status === 204) {
const newPages = pages.filter((page) => page.id !== Number(id));
setPages(newPages);
toast({
title: `${currentPage.title} TODO 삭제가 완료되었습니다.`,
description: '홈페이지로 이동합니다.',
});
router.replace('/');
}
if (error) {
toast({
variant: 'destructive',
title: '삭제에 실패했습니다.',
description: '개발자 도구 창을 확인해주세요.',
});
}
} catch (error) {
console.error(error);
}
};
supabase의 인스턴스에 delete 메서드를 이용하여 삭제할 수 있다. 현재 페이지만 삭제 해야하므로 eq 메서드를 이용하여 id가 일치하는 데이터를 찾아 삭제한다. 성공적으로 삭제(status 204)하면 전체 페이지를 관리하는 pages 상태에 현재 페이지를 삭제하여 반영한다. 그 후에 사용자에게 toast UI를 이용하여 결과를 알려주고 사용자를 홈페이지로 이동시킨다. 사용자가 뒤로가기 했을 때 삭제된 페이지로 이동할 수 없도록 router.replace
를 이용하여 뒤로가기 해도 예외 상황이 생기지 않도록 한다.
오류가 발생한 경우에는 삭제에 실패했다는 내용을 toast UI로 표시한다.
위의 세 가지가 2주차동안 학습하며 느끼고 겪은 것들이다.
useEffect에 대한 것은 실수이긴 했지만 여전히 헷갈리는 것이기 때문에 더 공부해 볼 필요가 있는 것 같다.
그리고 개인적으로 접해보지 않았던 프레임워크나 DB를 사용하면서 부딪히고 배워나가는 것을 좋아하는데 이번 기회에 Supabase를 사용하면서 prisma나 firebase를 사용하는 것과 비교해볼 수 있는 기회가 되어서 좋았다. Supabase는 다른 것들에 비하면 좀 더 간단하게 쓸 수 있는 것 같다.
가장 고민을 많이 했던 건 CRUD 로직이다. 나는 전역 상태를 사용했는데 수업에서는 전역 상태를 사용하지 않는 방식으로 진행되었다. 개인적으로는 API 호출을 줄이는게 더 효율적인 방식이지 않을까 생각해서 전역 상태를 사용했는데 코드를 구현하면서 복잡해지는 점들이 있는 것 같아 여러가지 고민을 하게된 것 같다.
이런 고민들을 하면서 또 새로운 인사이트들을 배워나가는 것 같아서 좋은 경험이 되었던 2주차였다.
본 후기는 [유데미x스나이퍼팩토리] 프론트엔드 프로젝트 캠프 과정(B-log) 리뷰로 작성 되었습니다.