토마토들 프로젝트를 맺으면서
2024년 11월 3일
목차
들어가면서
지난 9월 중순부터 10월까지, 프로젝트 캠프: Next.js 3기라는 교육 프로그램에 참여하게 됐습니다. 2주간의 사전 교육 기간 이후 4주동안의 팀 프로젝트를 진행했고, 이에 대한 회고를 풀어보고자 합니다.
토마토들이란
토마토들
은 대학생들이 참여할 수 있는 대외활동과 공모전 정보를 손쉽게 찾을 수 있도록 제공하고, 취업과 대학생활에 도움이 되는 다양한 정보를 안내하는 웹 서비스입니다. 프로젝트는 기업과의 연계 과정으로 진행되었고, 필요한 디자인 파일과 서비스에 대한 소개는 미리 기업측으로부터 제공받았습니다.
토마토들의 주요 기능은 크게 홈, 매거진, 대외활동 및 공모전 페이지와 검색 페이지로 나뉩니다.
- 홈: 매거진과 공모전 및 대외활동 정보 모아보기
- 공모전/대외활동: 공모전과 대외활동에 대한 정보를 필터와 정렬 기능을 통해 확인하기
- 매거진: 대학생활 및 대외활동에 관련된 다양한 팁 모아보기
- 검색: 공모전과 대외활동 및 매거진에 대한 정보 검색하기
저는 이 중에서 공모전 및 대외활동 페이지와, 헤더와 페이지네이션 공통 컴포넌트를 구현했습니다. 그리고 대외활동 및 공모전 페이지에서 사용하기 위한 데이터를 크롤링하는 작업도 담당했습니다. 프로젝트를 진행하면서 어떤 기능들을 어떻게 구현했고, 또 어떤 고민들을 했는지 알아보기에 앞서 프로젝트의 개발 환경에 대한 이야기를 해보고자 합니다.
개발환경
개발 환경은 크게 프론트/백엔드의 기술 스택과 폴더 구조, 그리고 데이터로 나누어 보았습니다. 먼저 기술 스택부터 살펴볼까요?
프론트엔드
프론트엔드에서는 Next.js
, TypeScript
, TailwindCSS
를 사용했습니다.
Next.js
의 SSR을 사용하면 다양한 공모전 및 대외활동 정보의 페이지를 빠르게 불러올 수 있고, 파일 구조에 기반 라우팅을 사용하기 때문에 프로젝트의 확장성과 유지보수가 용이하다는 장점이 있어서 선택했습니다.
TypeScript
는 이제는 사실상 Next.js를 사용하는 프로젝트에서는 디폴트가 된 스택이라고 생각하지만, 이번 프로젝트에서 사용한 그 이유를 고르자면 단연 타입 때문입니다. 다양한 컴포넌트에서 다양한 데이터 타입을 사용해야 했는데, 타입스크립트의 정적 타입 검사 기능을 통해 어디에 어떤 값이 들어가야 하는지 보다 명확하게 알 수 있었습니다.
Tailwind CSS
는 쉽고 빠른 적용을 위해 사용했습니다. 사전에 정의된 유틸리티 클래스 이름을 사용하는 것만으로 간편하게 스타일을 적용할 수 있다는 장점이 있었고, Next.js와의 기본 통합이 간편했어요. 추후에 이야기 할 내용지만, 사전에 정의된 유틸리티 클래스라는 것이 때로는 마냥 좋은 것만은 아니기도 했습니다.
그 외에도 다양한 이유가 있겠지만, 추가적으로 2주간의 사전 직무 교육에서 위 세 가지 기술 스택을 학습했다는 점도 있었어요. 프로젝트 기간동안 새로운 기술 스택을 배우기보다는 모두가 어느정도 익숙해진 기술 스택을 사용하는 것이 더 빠른 생산성을 보장할 수 있다고 생각했기 때문입니다.
백엔드
백엔드에서는 Supabase
, Python
등을 사용했습니다.
저희 팀은 프로젝트에서 활용할 수 있는 데이터가 따로 주어지지 않았기 때문에 데이터를 직접 크롤링한 뒤 가공해야 했는데, 가공된 데이터를 저장할 수 있는 DB와 이를 사용자에게 전달해줄 수 있는 서버가 필요했습니다. 지금 사용하고 있는 블로그를 만들면서 Supabase
를 사용해 본 적이 있었는데, 마침 다른 팀원도 사용해 본 적이 있다고 해서 빠르게 습득하고 활용 가능한 해당 기술을 선택했습니다.
Python
의 경우 프로젝트를 진행하면서 정말 오랜만에 사용해 본 언어였습니다. 기초적인 문법을 제외하면 아예 모르고 있다고 말해도 과언이 아닐 정도로 부족한 이해도를 가지고 있어서, 크롤링 관련 부분에 대해서는 LLM에 많이 의존하면서 코드를 작성한 것 같습니다.
폴더 구조
Next.js는 기본적으로 정해진 폴더 구조가 있어서, 특히 app 폴더의 경우 해당 컨벤션을 잘 따르기만 하는 것으로도 충분했습니다. 그러나 보다 좋은 폴더 구조를 사용하고 싶다는 생각이 들어서, 관련 자료를 찾아보던 중 containers
폴더를 활용해 UI 및 레이아웃과 라우터 관련 로직을 분리하면 컴포넌트의 가독성을 높일 수 있다는 글을 읽게 되었습니다.

위와 같이 api를 제외하면 app과 완전히 동일한 형태의 구조를 containers에서 갖춘 것을 확인할 수 있습니다.
import { fetchActivityContestAbstractWith } from '@/lib/fetchActivityAbstractWith';
import Activity from '@/containers/activity/Activity';
// 메타데이터와 같은 페이지 컴포넌트에 필요한 로직
export default async function Page({ searchParams }: PageProps) {
const filters = searchParams.filters?.split(',').filter(Boolean) || [];
const sort = searchParams.sort || '관련도순';
const { data: activitiesContests, error } =
await fetchActivityContestAbstractWith({
filters,
sort,
mainCategory: '대외활동',
});
if (error) {
// 에러 핸들링 로직
}
return <Activity activitiesContests={activitiesContests || []} />;
}
그 결과 페이지 컴포넌트에서 이렇게 UI와 페이지의 로직을 분리해, 보다 가독성 높은 컴포넌트를 작성할 수 있게 되었어요. 나머지 다른 폴더는 일반적인 폴더 구조와 그 역할이 동일했습니다. 묘하게 이질적인 폴더가 하나 있는데, 바로 scripts
폴더입니다. 앞서 데이터가 주어지지 않았다고 했는데, 이제 데이터와 크롤링에 대한 이야기를 해 보려고 합니다.
데이터
토마토들에는 크게 두 가지의 데이터가 필요했습니다. 대학생활 꿀팁과 관련된 매거진 페이지에서 보여주는 콘텐츠에 필요한 데이터와, 공모전 및 대외활동 페이지에서 보여주는 콘텐츠에 필요한 데이터였어요. 저는 둘 중 후자를 구해야 했습니다.

위와 같은 페이지에 필요한 데이터를 구해야 했는데, 다행히 대학생을 위한 대외활동과 공모전을 소개하는 기존의 서비스는 많았기에 그중 한 곳인 '캠퍼스픽'에서 데이터를 크롤링해 오기로 했습니다. 크롤링한 데이터를 무단으로 상업적인 용도로 활용하면 당연히 법적인 문제를 야기할 수 있기 때문에, 설명을 이어가기에 앞서 저희가 크롤링 한 데이터는 비상업적인 용도로, 어떠한 수익도 창출하지 않음을 미리 밝힙니다.
결국 LLM과 함께 열심히 고민한 뒤, 우여곡절 끝에 데이터를 크롤링하긴 했습니다. 하지만 파이썬에 대한 지식이 부족했기 때문에 새로운 데이터 구조를 추가할 때마다 스크립트를 매번 실행시켜야 했고, 덕분에 정제된 데이터를 얻는 데까지 꽤나 오랜 시간이 들었습니다. 팀원의 도움이 없었다면 더 긴 시간을 크롤링에 소모해야 했을 것입니다. 하지만 문제는 이것 뿐만이 아니었습니다.

토마토들은 대외활동과 공모전에서 적용 가능한 다양한 필터를 제공합니다. 그리고 그 종류가 총 84가지나 됐어요. 크롤링된 데이터에는 필터가 없었기에 그 필터를 직접 적용해 줘야 했는데, 수많은 데이터를 하나씩 읽어보면서 직접 종류에 맞는 필터를 삽입해 주는 것 말고는 다른 방법이 떠오르지 않았습니다.
결국 임시방편으로 데이터를 크롤링한 뒤 정제하는 과정에서 무작위로 필터를 적용해 주었습니다. 그래서 해외 봉사활동인데 국내 필터가 적용되어 있다거나 하는 안타까운 일이 벌어지고 말았지만, 딱히 더 좋은 방법이 떠오르지 않았고 기한 내에 프로젝트를 완수해야 했기 때문에 어쩔 수 없이 이러한 결정을 내리게 되었습니다. 이렇게 정제된 데이터를 Supabase에 저장하고, 필요에 따라 요청을 보내 불러오는 식으로 활용했습니다.
구현과 고민
헤더와 내비게이션
제가 담당했던 헤더와 내비게이션 바 컴포넌트는 웹과 모바일에서 레이아웃이 상이했습니다.

모바일에서는 이렇게 내비게이션 바가 분리되어 스크린의 하단에 위치하는 형태였습니다. 이걸 구현할 수 있는 두 가지 방법을 떠올려 봤고, 저는 두 번째 방법을 선택했습니다.
- 웹과 모바일에서 사용하는 내비게이션 컴포넌트를 두 개 만들고, 조건부로 렌더링하기
- CSS를 활용해 하니의 내비게이션 컴포넌트를 스크린 width에 따라 다르게 렌더링하기 ✅
const containerClasses =
'flex flex-1 items-center fixed md:static bottom-[30px] left-1/2 transform -translate-x-1/2 md:transform-none z-50';
const listClasses =
'flex gap-6 lg:gap-10 bg-white h-full px-8 md:px-0 py-4 md:py-0 whitespace-nowrap shadow md:shadow-none border md:border-none border-sub-gray-100 rounded-full md:rounded-none items-center';
const linkItemClasses =
'whitespace-nowrap text-base lg:text-xl font-medium lg:font-semibold rounded-full py-2 px-3';
const activeLinkClasses = 'bg-sub-yellow-500 text-sub-gray-500';
const inactiveLinkClasses = 'bg-sub-yellow-100 text-sub-gray-400';
이렇게 커스텀 클래스를 만들고 반응형으로 적용될 수 있도록 구현했습니다.
페이지네이션과 그리드 뷰
헤더와 내비게이션이 맛보기였다면, 페이지네이션과 그리드 뷰가 저에게는 메인 디쉬였습니다. 요즘 말로는 킥이라고 할 수도 있겠네요. 흔히 많은 양의 데이터를 한 번에 표시하지 않고 나눠서 표시하는 기능을 페이지네이션(Pagination)이라고 부릅니다. 토마토들에서 사용되는 페이지네이션 컴포넌트를 살펴볼까요?

저는 페이지네이션 컴포넌트를 이렇게 분리했어요. 페이지 컨트롤까지 포함하는 컴포넌트는 Pagination, 내부 컨텐츠를 그리드 형태로 렌더링하는 컴포넌트는 GridView 였습니다. 문제는 이 두 컴포넌트가 정말 다양한 곳에서, 정말 다양한 형태로 사용되고 있었어요. 먼저 GridView 컴포넌트부터 살펴볼까요?
GridView
- 매거진
- 토마토 Pick (웹 4 * 2 / 모바일 2 * 3)
- 토마토 Tip (웹 3 * 5 / 모바일 1 * 12)
- 대외활동 및 공모전
- 웹 4 * 4 / 모바일 2 * 5
- 홈
- BEST PICK (웹 2 * 4 / 모바일 2 * 3)
- 토마토들 추천활동 (웹 2 * 4 / 모바일 그리드 뷰 미사용)
이렇게 GridView 컴포넌트는 서비스 내의 많은 곳에서 다양한 형태로 사용되고 있었습니다. 내부에 렌더링되는 콘텐츠의 양도 페이지별로 달랐고요. 그래서 공용 컴포넌트를 하나 만들어 둔다면 재사용성을 높일 수 있을 거라고 생각했고, 고민 끝에 다음과 같은 구조로 GridView 컴포넌트를 구현했습니다.
export default function GridView<T>({
items,
GridItem,
columnStyle,
gapStyle,
}: GridViewProps<T>) {
const columnStyles = {
web4mobile2: 'grid-cols-2 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-4',
web3mobile1: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3',
};
const gapStyles = {
gapStyle1: 'gap-x-4 gap-y-8 md:gap-x-8 md:gap-y-12',
gapStyle2: 'gap-x-6 gap-y-10 md:gap-x-10 md:gap-y-12',
gapStyle3: 'gap-x-2 gap-y-6 md:gap-x-8 md:gap-y-10',
};
const gridClass =
`grid ${columnStyles[columnStyle]} ${gapStyles[gapStyle]}`.trim();
return (
<div className={gridClass}>
{items.map((item, index) => (
<GridItem key={index} item={item} />
))}
</div>
);
}
GridView 컴포넌트는 다음과 같은 props를 받습니다:
- items: 렌더링되어야 하는 콘텐츠의 데이터
- GridItem: 렌더링되어야 하는 콘텐츠 레이아웃 컴포넌트
- columnStyle: 열의 형태
- gapStyle: 갭의 형태
사용자가 해당 컴포넌트에 필요한 props를 전달하면, 이를 내부에서 사용할 수 있도록 구현했습니다. 보다 유연한 컴포넌트의 사용을 위해서, 제네릭 타입을 활용했어요.

그리고 JSDoc을 사용해 컴포넌트를 처음 사용하더라도 빠르게 활용할 수 있도록 매개변수와 사용 방법을 명시해 주었습니다.
Pagination
페이지네이션 컴포넌트도 GridView 만큼이나 여러 페이지에서 다양하게 사용되고 있었습니다.
- 매거진
- 토마토 Pick (웹 8개 / 모바일 6개)
- 토마토 Tip (웹 15개 / 모바일 12개)
- 대외활동 및 공모전
- 웹 16개 / 모바일 10개
- 홈
- BEST PICK (웹 8개 / 모바일 6개)
- 토마토들 추천활동 (웹 8개 / 모바일 그리드 뷰 미사용)
보다시피, 페이지네이션은 한 페이지당 표시되는 콘텐츠의 개수가 페이지마다, 웹과 모바일 환겸마다 상이했습니다. 개인적으로는 페이지당 표시되는 컨텐츠가 웹과 모바일 환경에서 동일하게 유지되는 것이 사용자에게 더 나은 경험을 제공한다고 생각하지만(구현하기도 더 쉽고요), 최대한 디자인을 따르기로 했습니다. 그 전에, 우선 이렇게 페이지당 표시되는 컨텐츠가 웹/모바일에서 달라질 경우 발생하는 문제점에 대해서 살펴볼까요?
먼저 60개의 컨텐츠에 대한 페이지네이션이 이루어진다고 가정해봅시다. 웹은 페이지당 16개의 컨텐츠를, 모바일은 페이지당 10개의 컨텐츠를 렌더링합니다. 이 경우, 생성되는 페이지의 수는 각각 다음과 같습니다.
- 웹: ( 16 × 3 + 12 × 1 페이지) = 4 페이지
- 모바일: (10 × 6 페이지) = 6 페이지
처음부터 다른 환경에서 웹과 모바일 페이지를 확인하고 있다면 큰 문제가 되지는 않겠지만, 만약 사용자가 브라우저의 가로 크기를 모바일만큼 줄여서 보다가 웹의 가로 크기만큼 늘린다고 가정해봅시다. 이때 사용자가 모바일에서 6페이지를 보고 있었다면, 웹에서는 존재하지 않는 페이지를 보게 됩니다. 당연히 문제가 발생할 수 밖에 없습니다.
export default function useResponsiveItemsPerPage(
webItemsPerPage: number,
mobileItemsPerPage: number,
) {
const [itemsPerPage, setItemsPerPage] = useState(webItemsPerPage);
useEffect(() => {
function handleResize() {
if (window.innerWidth >= 768) {
setItemsPerPage(webItemsPerPage);
} else {
setItemsPerPage(mobileItemsPerPage);
}
}
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [webItemsPerPage, mobileItemsPerPage]);
return itemsPerPage;
}
그래서 해당 문제를 해결할 수 있도록 커스텀 훅을 만들었습니다. 이 훅은 웹과 모바일 환경에서 필요한 페이지 당 컨텐츠의 개수를 훅으로 받아, 화면의 가로 크기를 감지하고 적절한 컨텐츠 개수인 itermsPerPage
를 반환합니다. 이를 Pagination 컴포넌트에 적용시켜 스크린 크기에 따라 적절하게 페이지를 조절할 수 있도록 했어요.
const itemsPerPage = useResponsiveItemsPerPage(
webItemPerPage,
mobileItemPerPage,
);
const totalItems = contents.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
if (currentPage < 1 || currentPage > totalPages) {
notFound();
}
또한, 앞서 말했던 존재하지 않는 페이지에 접근하게 되는 문제에 대한 해결책은 사용자가 존재하지 않는 페이지에 접근할 경우 notFound()
를 호출해 적어도 사용자에게 잘못된 접근임을 알려줄 수 있는 방식으로 처리했습니다. 최종적으로는 웹 → 모바일, 모바일 → 웹 전환 시 이를 감지하고 사용자가 바뀐 뷰에서 위치하는 페이지를 어림잡아 계산해 보여주는 방식을 사용하고 싶었지만, 해당 부분은 구현하지 못했습니다.
필터와 정렬 처리하기

먼저 공모전/대외활동의 메인 페이지를 살펴보면, 이런 식으로 필터를 토글하거나, 정렬 옵션을 선택할 수 있습니다.

그리고 사용자가 선택했던 필터는 이렇게 상세 페이지에서도 동일하게 적용되어 있어야 했어요. 또한 이 필터를 바탕으로 카테고리별 메인 페이지에서 필터에 맞는 컨텐츠만을 불러오도록 해야 했습니다. 어떤 방법으로 이 기능을 구현할 수 있을까 고민하다가, 쿼리 스트링이 떠올랐습니다.
const updateURL = (newTab: string, newFilters: string[], newSort: string) => {
const params = new URLSearchParams();
if (newTab) params.set('tab', newTab);
if (newFilters.length > 0) params.set('filters', newFilters.join(','));
if (newSort) params.set('sort', newSort);
router.push(`${pathname}?${params.toString()}`);
};
필터를 토글할 때마다 호출되는 updateURL
은 선택된 필터와 정렬 옵션을 기반으로 URL을 업데이트하는 함수입니다. Next.js의 router.push
덕분에, 페이지를 새로고침하지 않고도 URL을 업데이트할 수 있었어요. 이제 페이지에서 URL을 기반으로 Supabase에 쿼리를 보낸 뒤 데이터를 가져와 처리해야 했는데, 이는 searchParams
를 사용해 구현했습니다. 아래와 같이 페이지에서 searchParams를 받아오고, 필터와 정렬 옵션을 추출해 데이터 페칭 함수에 전달하는 방식입니다.
export default async function Page({ searchParams }: PageProps) {
const filters = searchParams.filters?.split(',').filter(Boolean) || [];
const sort = searchParams.sort || '관련도순';
const { data: activitiesContests, error } =
await fetchActivityContestAbstractWith({
filters,
sort,
mainCategory: '대외활동',
});
if (error) {
// 에러 핸들링 로직
}
return <Activity activitiesContests={activitiesContests || []} />;
}
아쉬웠던 부분들
토마토들은 그렇게 마무리됐지만, 아쉬운 부분들이 남아있었습니다.
부실한 디자인
기업으로부터 디자인을 제공받았을 때, 디자인 시스템이 준비되어있다는 초기 프로젝트 설명회의 말과는 달리 폰트와 공통 컴포넌트 몇 개 정도가 끝이었습니다. 페이지 와이어프레임도 아예 제공되지 않은 부분들이 많았고(개발자의 상상력을 믿고 있었는지), 디자인에 일관적인 부분이 적었어요.
없는 기능명세와 데이터
기능명세도 아예 제공되지 않았습니다. 물론 주어진 와이어프레임을 보고 소위 말하는 '알잘딱깔센' 을 발휘해서 만들수도 있었겠지만, 제가 지원한 과정은 ‘교육 프로그램’ 과정이었고, 그래서 최소한의 융통성을 기대했어요. 데이터도 전혀 제공되지 않아 직접 크롤링을 진행했습니다. 더욱이 같은 기업의 다른 프로젝트에는 제공된 디자인 시스템과 유저 플로우 등이 저희 프로젝트에는 존재하지 않아서, 그 부재가 더욱 크게 느껴졌던 것 같습니다.
원활하지 못했던 소통 환경
기업과의 소통도 그렇게 원할하지는 않았습니다. 각 팀별 멘토는 뒤늦게 배정됐고, 피드백도 빠르게 받아볼 수 없었어요. 물론 연락을 하자마자 바로 답변을 준다거나, 꼭두새벽부터 답변을 바랐던 것은 아니었습니다. 그러나 통상 걸리는 피드백 시간보다도 조금은 더 길었던 것 같아요. 디자인과 관련해서도, 디자이너와 직접적인 소통을 할 수 있었다면 퍼블리싱 과정이 더 원활했을 것이라고 생각합니다.
완벽한 상황은 없다
그렇다고 일방적으로 한쪽 탓만 할 수는 없었습니다. 실무에서는 이런 경우가 비일비재하다고들 하기도 하고(놀랍게도 이보다 더한 경우도 있다고도 하고요), 저희 팀도 어쨌든 주어진 시간 안에 프로젝트를 어떻게든 완성해 결과물을 구현해야 했기 때문입니다. 그래서 개발 단계에서 아쉬운 부분들도 있었습니다.
어떤 상황에서도 유연하게 대처하는 개발을 위해서, 프로젝트 초기에 시간을 좀 더 들여 보다 체계적인 계획과 설계를 만들었다면 프로젝트 중간에 고통받는 일이 없었을 것 같다는 생각이 들었습니다. 하지만 일단 어떻게든 만들고 보자는 마음에 제대로 준비를 하지 못한 채 개발을 시작했고, 그러다보니 계속해서 의견이 바뀌는 바람에 같은 컴포넌트나 페이지를 몇번이고 다시 수정해야 했던 순간들이 있었어요. 최소한 기능 명세라도 만들고 시작했어야 했다는 생각이 머리를 스쳤습니다.
제대로 활용하지 못했던 기술스택
그래서 이런저런 상황에 임기응변식으로 대처하다 보니, 일정에 차질이 생기기 시작했습니다. 결국 호기롭게 시작했던 프로젝트는 어느새 이것만이라도 제대로 구현해보자는 상태로 진행됐고, 결국 프로젝트에서 100% 활용해 보고 싶었던 것들을 활용하지 못했습니다.
- 제대로 활용하지 못한 Next.js의 장점
- 다소 지저분한 코드
- 임시방편으로 만들었던 데이터 구조와 쿼리
- 접근성
- 성능 최적화
토마토들 프로젝트와 연금복권의 공통점이라면 ‘뭔가 계속 나온다’라는 겁니다.
나오면서: 그렇게 또 성장한다
그래도 3주라는 기간동안 아무것도 얻어가지 못한 것은 아닙니다. 우선 Next.js를 활용해 본 두 번째 프로젝트이기도 했습니다. 블로그를 만들면서 적용해 본 것과, 토마토들을 만들면서 적용해본 부분들이 서로 달랐기에 상호보완적으로 리팩토링을 진행할 수 있을 것 같아요. 또한 다양한 상황에 대처하기 위해서 좋은 초석을 쌓아야 하는 이유를 몸소 느꼈습니다. 그리고 페이지네이션과 같이 더 재사용성이 높은 컴포넌트를 만들어보는 경험도 가져갈 수 있었고요. 처음 사용해보는 폴더 구조를 적용해 본 것도 있고, 돌아보면 배웠던 점들이 꽤 많을 것 같습니다.
그러나 무엇보다도, 다시 한번 내가 무엇을 모르는지 알게 됐다는 점이 가장 큰 것 같습니다. 스스로 어떤 부분이 부족하고, 어떤 부분을 어떻게 채워나가야 하는지를 조금 더 명확하게 알 수 있었던 3주였어요. 프로그램 기간은 끝났지만 아직 토마토들은 끝나지 않았고, 다음주부터 또 팀원들과 리팩토링 계획에 대해서 이야기해 볼 예정입니다.