대학생이 개인 과제랑 팀 과제를 한 곳에서 관리할 수 있는 과제 관리 서비스다. 캘린더로 마감일을 한눈에 보고, 드래그 앤 드롭으로 우선순위를 정하고, 팀 과제는 실시간으로 함께 관리할 수 있다.

PM 1명, 디자이너 1명, 프론트엔드 3명, 백엔드 5명 총 10명이 함께했다.
나는 프론트엔드 리드로 참여했고, 맡은 파트는 다음과 같다.
2025.12.30 ~ 2026.02.19 (약 2개월)
Frontend
프레임워크로 Next.js를 선택한 건 꽤 자연스러웠다. React 기반이면서 SSR을 지원하고, 파일 기반 라우팅으로 페이지 구조를 잡기 편하다. 특히 App Router의 Route Group 기능이 이 프로젝트에 딱 맞았다.
CHECKTASK는 로그인 전과 후의 레이아웃이 완전히 다르다. 로그인 페이지는 단독 화면이고, 메인 페이지들은 사이드바가 있는 레이아웃이다. App Router의 (main) Route Group을 사용하면 이런 레이아웃 분리를 폴더 구조만으로 깔끔하게 할 수 있었다.
app/
├─ login/ ← 로그인 전용 레이아웃
├─ (main)/ ← 사이드바가 있는 메인 레이아웃
│ ├─ layout.tsx ← AuthProvider + Sidebar 포함
│ ├─ page.tsx ← 홈 대시보드
│ └─ assignment/ ← 과제 관련 페이지들
그리고 서버 컴포넌트에서 쿠키를 직접 읽어서 다크모드 초기값을 HTML에 미리 세팅할 수 있다는 점도 컸다. 이 덕분에 페이지 로드 시 테마가 깜빡이는 현상(FOUC)을 방지할 수 있었다.
// app/layout.tsx
// 서버에서 쿠키를 읽어 초기 테마 세팅
export default async function RootLayout({ children }) {
const cookieStore = await cookies();
const uiCookie = cookieStore.get('ui-storage');
const initialUIState = parseUICookie(uiCookie?.value);
return (
<html data-theme={initialUIState.theme}>
...
</html>
);
}
스타일링 도구로는 PandaCSS를 선택했다. Tailwind도 고려했지만, PandaCSS가 가진 타입 안전성과 디자인 토큰 시스템이 이 프로젝트에 더 적합했다.
CHECKTASK는 다크모드를 지원해야 했고, 폴더별 색상 시스템도 있었다. PandaCSS의 semanticTokens를 사용하면 다크모드에서 자동으로 바뀌는 색상 토큰을 선언적으로 관리할 수 있다.
// panda.config.ts
// 시맨틱 토큰으로 다크모드 색상 자동 전환
semanticTokens: {
colors: {
bg: {
value: { base: '#FCFCFD', _dark: '#081221' },
},
fg: {
default: {
value: { base: '{colors.gray.900}', _dark: '{colors.gray.100}' },
},
},
},
}
컴포넌트에서는 bg: 'bg'라고만 쓰면 라이트/다크 모드에 따라 알아서 색상이 바뀐다. conditions에 data-theme 속성을 연결해두었기 때문에 별도 로직 없이 CSS만으로 테마가 전환된다.
또한 빌드 타임에 CSS를 생성하는 Zero-Runtime 방식이라 런타임 성능 오버헤드도 없었다.
상태 관리 전략은 이번 프로젝트에서 가장 고민이 많았던 부분이다. 결론적으로 클라이언트 상태는 Zustand, 서버 상태는 TanStack Query로 완전히 분리하는 구조를 채택했다.
왜 Zustand인가?
Redux는 보일러플레이트가 너무 많았다. 이 프로젝트에서 클라이언트 상태라고 할 만한 건 인증 정보, UI 테마, 모달, 캘린더 날짜 정도였다. 이 정도 규모에 Redux는 과했고, Zustand는 스토어 하나 만드는 데 코드 10줄이면 충분했다.
// 인증 스토어
export const useAuthStore = create<AuthState>()((set) => ({
isLoggedIn: false,
accessToken: null,
login: (accessToken) => {
localStorage.setItem('accessToken', accessToken);
set({ isLoggedIn: true, accessToken });
},
logout: () => {
localStorage.removeItem('accessToken');
set({ isLoggedIn: false, user: null, accessToken: null });
},
}));
Zustand의 또 다른 장점은 React 외부에서도 상태에 접근할 수 있다는 점이었다. useAuthStore.getState()로 Axios interceptor 안에서 토큰을 꺼내올 수 있어서 인증 흐름을 깔끔하게 구현할 수 있었다.
왜 TanStack Query인가?
과제 목록, 사용자 정보, 알림 같은 서버 데이터는 TanStack Query로 관리했다. 캐싱, 자동 리패칭, 로딩/에러 상태 관리를 직접 구현할 필요가 없다는 게 가장 큰 이유였다.
실제로 queries/ 폴더에 6개, mutations/ 폴더에 29개의 커스텀 훅을 만들었는데, 모두 일관된 패턴으로 작성할 수 있었다. 만약 이걸 전부 Zustand로 관리했다면 로딩 상태, 에러 핸들링, 캐시 무효화를 일일이 구현해야 했을 거다.
홈 화면은 이 프로젝트에서 가장 복잡한 페이지였다. 캘린더에 과제 마감일이 표시되고, 옆에는 과제 리스트가 정렬되어 나타난다. 폴더별 필터링, 3가지 정렬(우선순위/마감일/진척도), 그리고 드래그 앤 드롭까지 하나의 페이지에 다 들어간다.
드래그 앤 드롭 우선순위
dnd-kit을 사용해서 과제 카드의 순서를 드래그로 바꿀 수 있게 했다. 핵심은 낙관적 업데이트(Optimistic Update)다. 카드를 드래그하면 로컬 상태를 즉시 업데이트하고, 백그라운드에서 API를 호출한다. 사용자 입장에서는 즉각적으로 반응하는 느낌을 받는다.
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
const reordered = arrayMove(items, oldIndex, newIndex);
// 1. 로컬 상태 즉시 반영 (사용자에게 즉각 피드백)
setItems(reordered);
// 2. 백그라운드에서 API 호출
const orderedTasks = reordered.map((item, idx) => ({
taskId: item.id,
rank: idx + 1,
}));
updatePriorities.mutate(orderedTasks);
}
};
재밌는 디테일 하나는, 드래그는 우선순위 정렬일 때만 활성화된다는 점이다. 마감일순이나 진척도순일 때 드래그를 허용하면 사용자가 혼란스러워하기 때문에, isDragDisabled={sortType !== 'priority'}로 제어했다.
또한 PointerSensor에 distance: 5 제약을 걸어서 클릭과 드래그를 구분했다. 5px 이상 움직여야 드래그로 인식되고, 그 이하면 클릭(과제 상세 이동)으로 처리된다.
FullCalendar 통합
캘린더에서는 과제와 세부과제의 마감일을 이벤트로 표시한다. 여기서 가장 신경 쓴 부분은 캘린더 위에서의 드래그 앤 드롭으로 마감일을 변경할 수 있게 한 것이다.
const handleEventDrop = (info: EventDropArg) => {
const eventId = info.event.id;
const newDate = info.event.startStr;
if (eventId.startsWith('sub-')) {
// 세부과제 마감일 업데이트
const subTaskId = Number(eventId.replace('sub-', ''));
setSubItems((prev) =>
prev.map((item) =>
item.subTaskId === subTaskId ? { ...item, dueDate: newDate } : item,
),
);
updateSubTaskDeadline.mutate(
{ subTaskId, endDate: newDate },
{ onError: () => info.revert() }, // 실패하면 원래 위치로 복구
);
} else {
// 과제 마감일 업데이트 (같은 패턴)
// ...
}
};
과제 이벤트와 세부과제 이벤트를 구분하기 위해 ID 인코딩 패턴을 사용했다. 과제는 "42" 같은 숫자 ID, 세부과제는 "sub-123" 형태다. 핸들러에서 startsWith('sub-')로 타입을 구분한다.
그리고 세부과제는 부모 과제의 마감일을 넘길 수 없다는 비즈니스 규칙도 캘린더에서 구현했다.
const handleEventAllow = (dropInfo, draggedEvent) => {
if (!draggedEvent.id.startsWith('sub-')) return true; // 과제는 제한 없음
const sub = subItems.find((s) => s.subTaskId === subTaskId);
const parent = items.find((a) => a.id === sub.taskId);
return dropInfo.startStr <= parent.dueDate; // 부모 마감일 이후로는 이동 불가
};
밝기 기반 시각적 위계
과제 카드에는 순서에 따라 색상 밝기가 달라지는 디테일도 넣었다. 상위 3개는 100% 밝기, 4~6번째는 60%, 그 이하는 40%로 자연스럽게 시선이 중요한 과제에 먼저 가도록 했다.
const getBrightness = (index: number): ColorBrightness => {
if (index < 3) return 'high'; // 상위 3개: 진한 색
if (index < 6) return 'medium'; // 중간: 보통
return 'low'; // 하위: 연한 색
};
인증 흐름은 카카오 OAuth → 백엔드 처리 → 프론트 콜백 → 토큰 저장 순서로 진행된다.
전체 흐름
1. 사용자가 "카카오 로그인" 클릭
→ 백엔드 OAuth 엔드포인트로 리다이렉트
2. 카카오에서 인증 후 백엔드가 처리
→ refreshToken을 HttpOnly 쿠키로 설정
→ /auth/kakao/callback으로 리다이렉트
3. 콜백 페이지에서 accessToken 발급
→ POST /auth/refresh (쿠키의 refreshToken 사용)
→ 받은 accessToken을 Zustand + localStorage에 저장
→ 홈으로 이동
콜백 핸들러에서 신경 쓴 부분은 React 18 Strict Mode에서의 중복 호출 방지다. Strict Mode에서는 useEffect가 두 번 실행되기 때문에, useRef로 중복 API 호출을 막았다.
function CallbackHandler() {
const isProcessing = useRef(false);
useEffect(() => {
if (!isProcessing.current) {
isProcessing.current = true;
getAccessToken(); // 딱 한 번만 실행
}
}, [getAccessToken]);
}
Axios Interceptor로 자동 토큰 갱신
인증에서 가장 고민했던 건 "accessToken이 만료되면 어떻게 할 것인가"였다. 매번 토큰 만료를 사용자가 인지하고 다시 로그인하게 할 수는 없으니, Axios Response Interceptor로 자동 갱신을 구현했다.
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; // 무한 루프 방지
try {
// 중복 갱신 요청 방지
if (!refreshTokenPromise) {
refreshTokenPromise = (async () => {
try {
const response = await axios.post('/auth/refresh', {},
{ withCredentials: true });
const { accessToken } = response.data.data;
useAuthStore.getState().login(accessToken);
return accessToken;
} finally {
refreshTokenPromise = null;
}
})();
}
// 새 토큰으로 원래 요청 재시도
const newAccessToken = await refreshTokenPromise;
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axiosInstance(originalRequest);
} catch {
useAuthStore.getState().logout();
window.location.href = '/login';
}
}
return Promise.reject(error);
},
);
여기서 핵심은 refreshTokenPromise 변수다. 여러 API 요청이 동시에 401을 받을 수 있는데, 각각이 refresh 요청을 보내면 비효율적이다. 하나의 Promise를 공유해서 첫 번째 요청만 실제로 갱신하고, 나머지는 그 결과를 기다리게 했다.
라우트 보호 (AuthProvider / GuestGuard)
보호된 페이지에 비로그인 사용자가 접근하는 것과, 로그인 페이지에 이미 로그인한 사용자가 접근하는 것을 각각 방어한다.
// 비로그인 → 로그인 페이지로
export function AuthProvider({ children }) {
const token = useAuthStore((s) => s.accessToken);
useLayoutEffect(() => {
const hasToken = useAuthStore.getState().checkAuth();
if (!hasToken) router.replace('/login');
}, [router]);
if (!token) return null; // 리다이렉트 전 깜빡임 방지
return <>{children}</>;
}
return null이 중요하다. 토큰이 없을 때 null을 반환해서 보호된 페이지가 잠깐 보이는 현상을 방지했다.
이번 프로젝트에서 가장 기억에 남는 기술적 논의가 하나 있다. 사이드바 열림/닫힘에 따라 변하는 UI를 어떻게 구현할 것인가에 대한 것이었다.
나는 다크모드를 data-theme 속성으로 구현한 것처럼, 사이드바도 data-sidebar-collapsed 속성을 활용해서 순수 CSS로 처리하자는 입장이었다.
이렇게 하면 PandaCSS conditions에 사이드바 조건을 추가하고
// conditions.ts에 추가했을 경우
conditions: {
extend: {
sidebarCollapsed: '[data-sidebar-collapsed="true"] &',
},
}
컴포넌트에서는 이렇게 선언적으로 스타일을 쓸 수 있다.
// 'use client' 없이도 가능
<Container css={{
width: '22.75rem',
_sidebarCollapsed: { width: '27.375rem' },
}}>
이 방식의 가장 큰 장점은 'use client'를 쓰지 않아도 된다는 점이다. 사이드바 상태에 따라 너비만 바뀌는 컴포넌트가 굳이 클라이언트 컴포넌트일 필요가 없다.
하지만 사이드바를 담당한 팀원은 Zustand의 isSidebarCollapsed 상태를 직접 읽어서 인라인 스타일로 처리하는 방식을 선택했다.
'use client';
const isSidebarCollapsed = useUIStore((state) => state.isSidebarCollapsed);
return (
<div style={{
marginLeft: isSidebarCollapsed ? '4.5rem' : '15rem',
transition: 'margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}>
{children}
</div>
);
결과적으로 이 방식이 채택되었고, 사이드바 상태를 읽는 파일이 21개에 달하게 되었다. 모두 'use client'가 필요하다.
솔직히 아직도 명확한 정답은 모르겠다. 하지만 프로젝트를 마치고 나서 돌아보면, 하이브리드 접근이 최선이었을 것 같다:
'use client' 없이도 동작할 수 있었다.실제로 다크모드는 이미 이 패턴으로 구현되어 있었다. data-theme="dark" 속성에 따라 PandaCSS의 _dark 조건이 자동으로 스타일을 바꾼다. 같은 원리를 data-sidebar-collapsed에도 적용했으면 일관성도 좋았을 거다.
이 경험에서 배운 건, 팀에서 이미 잡아둔 패턴(다크모드의 data attribute 방식)이 있다면, 같은 패턴을 확장해서 쓰는 게 자연스럽다는 점이다. 그리고 기술적 의견 차이가 있을 때는 두 방식의 장단점을 코드로 비교해보는 시간을 가졌으면 더 좋았을 것 같다.
리드 역할에서 가장 많은 시간을 쓴 건 코딩이 아니라 초기 아키텍처 설계였다. 폴더 구조, 코드 컨벤션을 정하는 데 첫 주의 상당 부분을 썼다.
feature 기반 폴더 구조, Zustand + TanStack Query 조합, PandaCSS 디자인 토큰 시스템 — 이런 결정들이 프로젝트 후반에 빛을 발했다. 팀원이 새로운 기능을 추가할 때 "이건 어디에 넣어야 하지?" 같은 질문이 줄었다.
커밋 메시지(feat:, fix:, refactor:), PR 리뷰 프로세스, 브랜치 전략(feature/issue-number) 같은 걸 초반에 잡아둔 게 큰 도움이 됐다. 특히 프론트엔드 3명이 동시에 작업하다 보면 머지 충돌이 잦은데, 명확한 브랜치 규칙 덕분에 큰 혼란은 없었다.
백엔드 팀과 API 스펙을 먼저 맞추고 개발을 시작하는 것이 중요했다. 응답 형식을 { resultType, message, data } 구조로 통일한 덕분에 프론트에서 에러 핸들링 로직을 일관되게 작성할 수 있었다.
위에서 언급한 것처럼, Zustand의 사이드바 상태를 21개 파일에서 직접 읽고 있다. PandaCSS conditions 방식을 초기에 잡았으면 이 의존성을 줄일 수 있었다. 리드로서 초기 설계 단계에서 이 부분을 더 강하게 주장했어야 했다.
keepPreviousData나 낙관적 업데이트 같은 UX 최적화는 했지만, 번들 사이즈 분석이나 이미지 최적화 같은 성능 작업은 미처 하지 못했다. FullCalendar 같은 큰 라이브러리의 트리 셰이킹이 제대로 되는지도 확인하지 못했다.
QueryClient를 모듈 레벨에서 생성하고 있는데, 사용자별로 격리된 인스턴스를 만들려면 useState로 생성하는 게 Next.js 공식 권장사항이다. 큰 문제는 아니었지만, 다음에는 초기 세팅부터 베스트 프랙티스를 따르려고 한다.
2개월이라는 시간 동안 10명이서 하나의 제품을 만든다는 건 코딩 실력만으로는 안 되는 일이었다.
기술적으로 : Next.js App Router의 서버/클라이언트 컴포넌트 경계를 실무적으로 경험했다. Zustand + TanStack Query 조합으로 상태 관리를 설계하고, PandaCSS로 디자인 시스템을 구축하는 과정에서 각 도구의 강점과 한계를 체감했다.
협업 측면에서 : 리드라는 역할은 "내가 잘하는 것"보다 "팀이 잘할 수 있는 구조를 만드는 것"이 핵심이라는 걸 배웠다. 폴더 구조, 컨벤션 같은 결정들이 팀 전체의 생산성에 직접적으로 영향을 미친다.
그리고 하나 더 : 기술적 의견 차이가 있을 때 "어느 쪽이 맞느냐"보다 "어느 쪽이 우리 프로젝트의 기존 패턴과 일관성이 있느냐"를 기준으로 판단하는 게 중요하다는 걸 느꼈다.