대학생 과제 관리 서비스 CHECKTASK 회고

성태경·2026년 2월 27일
post-thumbnail

프로젝트 소개

CHECKTASK가 뭔데?

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

팀 구성 & 나의 역할

PM 1명, 디자이너 1명, 프론트엔드 3명, 백엔드 5명 총 10명이 함께했다.

나는 프론트엔드 리드로 참여했고, 맡은 파트는 다음과 같다.

  • 홈 대시보드 : FullCalendar 캘린더 뷰, 폴더 필터링, 정렬, 드래그 앤 드롭 우선순위
  • 로그인 : 카카오 OAuth 연동, JWT 토큰 관리, 인증 흐름 전체 설계
  • 프로필 : 사용자 정보 수정, 알림 설정
  • 프론트엔드 아키텍처 : 폴더 구조, 코드 컨벤션 정립

개발 기간

2025.12.30 ~ 2026.02.19 (약 2개월)

기술 스택

Frontend

  • Next.js 16 (App Router) / React 19 / TypeScript
  • Zustand + TanStack Query v5
  • PandaCSS
  • FullCalendar, dnd-kit, Socket.IO Client

GitHub 링크
배포 URL


기술 스택, 왜 이걸 골랐을까

Next.js 16 App Router

프레임워크로 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

스타일링 도구로는 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'라고만 쓰면 라이트/다크 모드에 따라 알아서 색상이 바뀐다. conditionsdata-theme 속성을 연결해두었기 때문에 별도 로직 없이 CSS만으로 테마가 전환된다.

또한 빌드 타임에 CSS를 생성하는 Zero-Runtime 방식이라 런타임 성능 오버헤드도 없었다.

Zustand + TanStack Query

상태 관리 전략은 이번 프로젝트에서 가장 고민이 많았던 부분이다. 결론적으로 클라이언트 상태는 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로 관리했다면 로딩 상태, 에러 핸들링, 캐시 무효화를 일일이 구현해야 했을 거다.


내가 구현한 핵심 기능

1. 홈 대시보드 — 캘린더 + 드래그 앤 드롭

홈 화면은 이 프로젝트에서 가장 복잡한 페이지였다. 캘린더에 과제 마감일이 표시되고, 옆에는 과제 리스트가 정렬되어 나타난다. 폴더별 필터링, 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';                   // 하위: 연한 색
};

2. 카카오 OAuth + JWT 토큰 관리

인증 흐름은 카카오 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을 반환해서 보호된 페이지가 잠깐 보이는 현상을 방지했다.


팀원과의 기술적 논쟁 — 사이드바를 CSS로 할까, JS로 할까

이번 프로젝트에서 가장 기억에 남는 기술적 논의가 하나 있다. 사이드바 열림/닫힘에 따라 변하는 UI를 어떻게 구현할 것인가에 대한 것이었다.

내 생각: PandaCSS의 data attribute 조건으로 처리하자

나는 다크모드를 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 상태를 직접 읽어서 처리

하지만 사이드바를 담당한 팀원은 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'가 필요하다.

돌아보며 — 뭐가 정답이었을까?

솔직히 아직도 명확한 정답은 모르겠다. 하지만 프로젝트를 마치고 나서 돌아보면, 하이브리드 접근이 최선이었을 것 같다:

  • 사이드바 컴포넌트 자체는 Zustand가 필요하다. 토글 로직, 드롭다운 포털, 애니메이션 제어 같은 복잡한 인터랙션이 있어서 JS가 필수다.
  • 사이드바 상태에 따라 너비만 바뀌는 컴포넌트들은 PandaCSS conditions로 충분했다. 이 컴포넌트들은 '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 설계 협의

백엔드 팀과 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로 디자인 시스템을 구축하는 과정에서 각 도구의 강점과 한계를 체감했다.

협업 측면에서 : 리드라는 역할은 "내가 잘하는 것"보다 "팀이 잘할 수 있는 구조를 만드는 것"이 핵심이라는 걸 배웠다. 폴더 구조, 컨벤션 같은 결정들이 팀 전체의 생산성에 직접적으로 영향을 미친다.

그리고 하나 더 : 기술적 의견 차이가 있을 때 "어느 쪽이 맞느냐"보다 "어느 쪽이 우리 프로젝트의 기존 패턴과 일관성이 있느냐"를 기준으로 판단하는 게 중요하다는 걸 느꼈다.

0개의 댓글