ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [ Proovy ] 첫 팀 프로젝트, 수학 AI 튜터 Proovy를 만들며 배운 것들(25.12~26.02)
    Project/Proovy 2026. 6. 4. 18:01

    Proovy 로고

     

     UMC(University MakeUs Challenge) 9기에서 진행한 Proovy는 이공계 대학생을 위한 퍼스널 AI 튜터 서비스다. Claude, GPT, Gemini 같은 대형 언어 모델을 기반으로, 복잡한 수식의 정확한 풀이 검증과 수식 친화적 입력, 그리고 학습 루프 제공을 핵심 가치로 삼았다. 단순한 챗봇이 아니라, 수학이라는 특수한 도메인에 최적화된 AI 도구를 만드는 것이 목표였다.

    나는 이 프로젝트에서 웹 파트 프론트엔드 개발을 주도하며, 기술 기틀 설계와 배포 인프라 관리까지 담당했다. 웹 파트는 총 4명이었고, 그 외 백엔드·디자인 팀과 함께 11명의 규모로 진행됐다. 프론트 기술 스택은 React 19, TypeScript, Tailwind CSS v4, MSW, GitHub Actions, Zustand, TanStack Query였다.

    이것이 내 첫 번째 팀 프로젝트였다.

    처음이라는 사실은 설렘 자체이었지만, 동시에 상당한 책임감으로 다가왔다. 내가 설계한 폴더 구조와 컨벤션 위에 팀원 4명의 작업이 쌓인다. 내가 관리하는 GitHub Actions 워크플로가 실패하면 팀 전체의 배포가 막힌다. 개인 토이 프로젝트에서 겪는 실수는 나 혼자 감당하면 되지만, 팀 프로젝트에서의 실수는 다르다. 그 긴장감이 나를 더 꼼꼼하게, 더 넓게 생각하게 만들었다.

    이 글은 그 3개월간 마주쳤던 크고 작은 문제들과, 그것을 풀어나가는 과정에서 배운 것들을 기록한 회고다.


    프로젝트 개요

    Proovy의 프론트엔드는 채팅, 수식 입력, PDF 뷰어, 화이트보드 드로잉, 구독/결제까지 UI 복잡도가 매우 높은 화면들로 구성되어 있다. 이를 감당하기 위해 Feature-based 폴더 구조를 채택했다.

    src/
    ├── app/          # 전역 Provider, Router, 스타일
    ├── features/     # 핵심 비즈니스 기능 (chat, editor, notes, subscription ...)
    ├── shared/       # 공용 컴포넌트, 훅, API 클라이언트
    ├── pages/        # 특정 기능에 속하지 않는 페이지
    └── mocks/        # MSW 핸들러
    

    features/ 하위에 chat, editor, notes, subscription 등 도메인을 명확히 분리함으로써, 팀원들이 서로의 작업 영역을 침범하지 않고 병렬 개발할 수 있도록 했다. 처음에 구조를 잡는 데 시간이 걸렸지만, 이 결정이 이후 협업 마찰을 크게 줄였다고 생각한다.


    트러블슈팅

    Issue 1. 중복 크레딧 차감 — Race Condition을 ref로 막다

    상황: AI 채팅 화면에서 전송 버튼을 빠르게 연속으로 클릭하면, 동일한 질문에 대해 크레딧 차감 API가 중복 호출되어 사용자의 크레딧이 한 번에 여러 번 소모되는 현상이 발생했다.

    처음에는 버튼에 disabled 처리가 빠진 단순 실수라고 생각했다. 하지만 disabled 상태를 아무리 빠르게 적용해도 문제가 재현됐다. React의 상태 업데이트는 비동기적이어서, 첫 번째 클릭으로 isLoading 상태가 true로 바뀌기 전에 두 번째 클릭 이벤트가 이미 처리될 수 있었다.

     

    원인: 전형적인 Race Condition이었다. API 요청이 진행 중인 상태에서 버튼의 클릭 이벤트가 차단되지 않아 발생했다. useState로 관리하는 isLoading 값은 렌더링 사이클에 묶여 있어, 클릭 이벤트가 동기적으로 중복 발생하는 상황을 막기에는 반응이 너무 느렸다.

     

    해결: useState 대신 useRef로 isProcessing 플래그를 관리했다. ref는 렌더링과 무관하게 즉시 값이 반영되므로, 전송 로직이 시작되는 순간 동기적으로 중복 진입을 차단할 수 있다.

    // ChatInput.tsx
    const isProcessing = useRef(false);
    
    const handleSend = async () => {
      // ref는 렌더링 사이클과 무관하게 즉시 반영된다
      if (isProcessing.current) return;
      isProcessing.current = true;
    
      try {
        await sendMessage(input);
      } finally {
        // 성공/실패 모두 반드시 해제
        isProcessing.current = false;
      }
    };
    

    버튼의 disabled 처리는 그대로 두되, 로직 레벨에서 이중 잠금 장치를 마련했다. UI 레이어의 방어만으로는 충분하지 않다는 것을 이 경험으로 실감했다. 사용자의 크레딧처럼 금전적 가치가 있는 리소스를 다룰 때는 서버 레이어의 멱등성(Idempotency) 보장까지 함께 고민해야 한다는 점도 백엔드 팀과 논의하는 계기가 됐다.

     

     

    Issue 2. null 참조로 인한 화이트 스크린 — Optional Chaining의 중요성

    상황: /settings 페이지에 진입하거나 사이드바가 로딩될 때, 화면이 하얗게 멈추는 Crash가 발생했다.

    TypeError: Cannot read properties of null (reading 'autoRenew')
    

    에러가 발생한 지점은 subscription.billing.autoRenew를 직접 참조하는 코드였다. 개발 중에는 항상 데이터가 있는 상태에서 테스트했기 때문에 이 문제를 발견하지 못했다.

     

    원인: 백엔드에서 무료 사용자이거나 구독 데이터가 아직 생성되지 않은 경우, subscription.billing 객체 자체가 null로 반환됐다. 프론트엔드 코드는 이 케이스를 전혀 고려하지 않은 채 직접 접근을 시도했고, 런타임에 폭발했다.

    실제 서비스에서는 "데이터가 항상 있다"고 가정할 수 없다는 사실을 뼈저리게 느꼈다. 특히 구독/결제 도메인처럼 사용자마다 상태가 다를 수 있는 영역에서는, 모든 데이터 접근에 방어 코드가 필요하다.

     

    해결: SubscriptionTabContent.tsx, ProfileTabContent.tsx 등 해당 속성을 참조하는 모든 파일에 Optional Chaining(?.)을 적용했다.

    // 수정 전
    const isAutoRenew = subscription.billing.autoRenew;
    
    // 수정 후
    const isAutoRenew = subscription?.billing?.autoRenew ?? false;
    

    ?.은 중간 값이 null 또는 undefined이면 전체 표현식을 undefined로 반환하므로, 크래시 없이 안전하게 처리된다. ?? false로 nullish coalescing을 함께 사용해 기본값을 명확히 지정했다.

    이 수정은 단순한 버그 픽스를 넘어, 팀 전체의 코드 리뷰 기준을 바꾸는 계기가 됐다. 이후 PR 리뷰에서 외부 데이터를 직접 접근하는 코드가 있으면 반드시 짚고 넘어가는 관행이 생겼다.

     

    Issue 3. 프로필 데이터 동기화 에러 — React Query 캐시를 신뢰하는 법

    상황: 사이드바의 SidebarProfile.tsx나 크레딧 탭에서, profile 데이터가 로딩 중이거나 undefined인 초기 렌더링 시점에 내부 속성(profile.credit)에 접근하려다 TypeError가 발생했다.

    비슷한 null 참조 에러처럼 보였지만, 원인은 조금 달랐다.

     

    원인: React Query는 데이터를 가져오는 동안 data 프로퍼티가 undefined인 isLoading 상태를 거친다. 이 상태에 대한 방어 처리가 없었다. 더 까다로운 문제는 크레딧 차감이 성공했을 때, queryClient.setQueryData로 로컬 캐시를 강제 업데이트하는 로직에서 oldData가 undefined인 상황을 체크하지 않아 또 다른 에러가 발생했다는 것이다.

    // 수정 전 (oldData가 undefined일 경우 크래시)
    queryClient.setQueryData(['profile'], (oldData) => ({
      ...oldData,
      credit: oldData.credit - amount
    }));
    

     

    해결: 두 지점을 모두 수정했다. 컴포넌트 렌더링 시에는 profile? 체크와 폴백(fallback) 값을 적용하고, 캐시 업데이트 로직에서는 oldData 존재 여부를 엄격히 검증했다.

    // 컴포넌트에서의 방어 렌더링
    const credit = profile?.credit ?? 0;
    
    // queryClient 캐시 업데이트 시 oldData 검증
    queryClient.setQueryData(['profile'], (oldData: Profile | undefined) => {
      if (!oldData) return oldData; // oldData가 없으면 업데이트 중단
      return {
        ...oldData,
        credit: oldData.credit - amount,
      };
    });
    

    서버 상태 관리 라이브러리를 사용할 때는, 라이브러리가 데이터를 언제 어떤 상태로 제공하는지 이해하는 것이 먼저라는 점을 배웠다. TanStack Query의 isLoading, isFetching, isSuccess 상태를 제대로 구분해서 사용하는 것이 얼마나 중요한지 다시 한번 확인했다.

     

     

    Issue 4. MSW에서 실제 백엔드로 — 응답 구조 불일치와 500 에러

    상황: 개발 초반에는 MSW(Mock Service Worker)로 API를 모킹하며 UI를 완성했다. 이후 실제 백엔드(https://api.proovy.ai.kr)와 연동하는 단계에서 두 가지 문제가 동시에 터졌다.

    첫째, 일부 API에서 500 에러가 반환됐다. 둘째, 에러 없이 200을 받았음에도 화면에 데이터가 표시되지 않는 경우가 있었다.

     

    원인: 두 가지였다. 500 에러는 백엔드 서버 사이드의 일시적인 내부 오류였다. 데이터 미표시 문제는 더 흥미로웠다. MSW 목업에서 eventName으로 정의했던 필드가, 실제 API 응답에서는 eventType으로 반환되고 있었다.

    // MSW 목업 응답
    { eventName: '채팅 사용', amount: -10 }
    
    // 실제 백엔드 응답
    { eventType: '채팅 사용', amount: -10 }
    

    목업을 믿고 UI를 개발했더니, 실제 데이터와 필드명이 달라 화면이 비어버린 것이다. 이는 프론트엔드와 백엔드 간의 API 계약(Contract) 관리가 얼마나 중요한지를 단적으로 보여주는 사례였다.

     

    해결: CreditHistoryTable.tsx 등 관련 컴포넌트에서 두 필드를 모두 허용하는 방어 코드를 추가했다.

    // 필드명 불일치에 대한 임시 대응
    const eventLabel = item.eventName || item.eventType;
    

    그리고 main.tsx에서 MSW 관련 코드를 완전히 제거해 불필요한 네트워크 간섭을 차단했다.

    // main.tsx 수정 전
    async function enableMocking() {
      const { worker } = await import('./mocks/browser');
      return worker.start();
    }
    enableMocking().then(() => { /* render */ });
    
    // 수정 후: MSW 코드 제거, 바로 렌더링
    createRoot(document.getElementById('root')!).render(
      <StrictMode>
        <App />
      </StrictMode>
    );
    

    이 경험 이후로 백엔드 팀과 API 스펙을 정의할 때 필드명 변경 사항을 반드시 공유하는 채널을 만들었다. 목업은 개발 속도를 높이는 훌륭한 도구지만, 목업의 신뢰도가 높을수록 실제 연동 시의 충격도 커진다는 것을 이 경험으로 배웠다.

     

     

    Issue 5. 주석 대량 삭제 중 핵심 로직 손실 — 빌드는 항상 돌려라

    상황: 코드 가시성 개선을 위해 불필요한 주석을 대량으로 삭제하는 클린업 작업을 진행했다. 그런데 이 과정에서 PricingPage.tsx의 핵심 로직, 구체적으로는 구독 버튼의 텍스트 조건과 disabled 처리 로직이 주석과 함께 삭제됐다. 결과적으로 페이지가 렌더링되지 않거나 버튼이 비정상 동작하는 상태가 됐다.

     

    원인: 멀티-라인 범위를 한 번에 선택해 삭제하는 과정에서 발생한 휴먼 에러였다. 주석 블록과 코드 블록이 인접해 있었고, 선택 범위가 실수로 코드 영역까지 포함됐다. 삭제 직후 시각적으로 확인했을 때는 문제를 발견하지 못했다. 빌드나 린트를 돌려보기 전까지는.

    처음 이 상황을 마주쳤을 때, 어디서 무엇이 사라진 건지 파악하는 것 자체가 난감했다. 코드가 '없어졌다'는 사실을 코드만 보고 알기는 어렵다.

     

    해결: 빌드 에러 로그와 린트 경고를 통해 삭제된 로직의 위치를 특정한 뒤, Git 히스토리에서 해당 코드를 복구했다.

    # 삭제 전 커밋 확인
    git log --oneline -- src/pages/PricingPage.tsx
    
    # 특정 커밋 시점의 파일 내용 확인
    git show <commit-hash>:src/pages/PricingPage.tsx
    

    복구 후 pnpm run build로 컴파일 안정성을 재검증했다. 이후로는 코드 클린업 작업을 마친 뒤 반드시 빌드를 돌리는 것을 개인 워크플로에 포함시켰다.

    # 클린업 후 반드시 실행
    pnpm run format
    pnpm run lint
    pnpm run build
    

    작은 리팩터링이라도 빌드와 린트는 항상 통과 확인 후 커밋한다는 원칙이 생긴 계기였다. 컴파일러와 린터는 사람의 눈보다 정직하다.


    이번 프로젝트에서 얻은 점

    1. 방어 코드는 선택이 아니다

    이번 프로젝트에서 가장 많이 마주쳤던 에러 유형은 null/undefined 참조였다. subscription?.billing?.autoRenew, profile?.credit ?? 0. 모두 개발 중에는 문제없어 보이지만, 실제 사용자 데이터는 항상 예상 밖의 상태를 가진다.

    Optional Chaining과 nullish coalescing은 단순한 문법 편의가 아니라, 데이터 신뢰도가 낮은 경계에서의 안전장치다. 외부에서 오는 데이터는 항상 의심하고, 타입이 보장하지 않는 것은 코드가 직접 보장해야 한다.

    2. 목업과 현실의 간극

    MSW를 사용한 개발은 분명 생산성을 높인다. 그러나 목업이 정밀할수록, 실제 백엔드와의 불일치가 표면에 드러나는 시점이 늦어진다는 역설이 있다. eventName과 eventType의 충돌이 대표적이었다.

    이후에는 백엔드 팀과 API 스펙을 정의할 때 OpenAPI(Swagger) 문서를 기준으로 삼고, 필드명 변경이 생기면 즉시 공유하는 프로세스를 만들었다. 도구의 한계를 이해하고, 그 간극을 팀 프로세스로 메우는 것이 중요하다는 것을 배웠다.

    3. 프론트엔드 개발자도 인프라를 이해해야 한다

    프로젝트를 시작할 때, 나는 스스로를 "리액트 코드를 짜는 사람"으로 정의하고 있었다. 하지만 실제 서비스를 만드는 과정은 달랐다. GitHub Actions 워크플로가 실패하고, CloudFront Secrets 값이 틀어지고, 캐시 무효화 타이밍이 에러를 만들어냈다.

    이 과정에서 배포 파이프라인의 전체 흐름을 이해하게 됐다. 코드가 커밋되고, Actions가 빌드하고, S3에 업로드되고, CloudFront가 전 세계 엣지 서버에 배포하는 흐름. 각 단계에서 무엇이 잘못될 수 있는지 파악하고 있어야, 장애가 발생했을 때 빠르게 원인을 찾을 수 있다. 프론트엔드 개발자가 인프라를 모두 깊이 알아야 한다는 것이 아니라, 내 코드가 실제로 어떻게 사용자에게 도달하는지는 알아야 한다는 것이다.

    4. 팀은 본인을 성장시키는 환경이다

    혼자 개발할 때와 팀에서 개발할 때의 가장 큰 차이는, 내 결정이 다른 사람에게 영향을 미친다는 점이다. 폴더 구조를 어떻게 잡느냐, 파일 명명 규칙을 어떻게 정하느냐, PR 리뷰를 어떻게 주고받느냐. 이 모든 것이 팀 전체의 생산성과 코드 품질에 연결된다.

    Race Condition 이슈를 해결하며 백엔드 팀과 멱등성을 논의하고, null 참조 에러를 경험하며 팀 리뷰 기준이 생기고, MSW와 실제 API의 불일치를 겪으며 커뮤니케이션 채널이 만들어졌다. 문제 하나를 해결할 때마다 팀이 조금씩 단단해지는 것을 느꼈다.

    Proovy는 내 첫 팀 프로젝트였다. 그리고 가장 많이 배운 프로젝트이기도 했다. UMC가 끝난 지금도 리팩터링을 이어가고 있는 이유는, 그 과정 자체가 계속해서 나를 성장시키고 있기 때문이다.

     

     

     

    본 포스팅은 UMC 9기 Proovy 프로젝트 웹 파트 개발 경험을 바탕으로 작성되었습니다.

제목 없는 코딩 블로그