본문으로 건너뛰기
강홍재/ James
← Work
EMBA2026 · Solo Builder· Started(First Commit date)

Degree Planner

성균관대 EMBA 학위취득계산서를 웹으로 옮긴 학위요건 계산 보조 도구 - 수강 과목을 입력하면 요건 충족과 취득 가능 학위를 계산. Supabase 위의 독립 앱으로 시작해 8월에 EMBA 앱의 웹뷰 전용 화면으로 이관했고, 그 과정에서 자동 저장이 데이터를 덮어쓸 뻔한 경로를 잡았다.

  • React 19
  • TypeScript
  • Supabase
  • EMBA

Setup

Problem

EMBA 원우가 졸업 요건(총 학점, Core·Advanced, 국제어, 융복합·글로벌)을 종이 계산서로 하나씩 확인해야 했다. 계산이 어긋나면 졸업이 밀린다.

Context

SKKU EMBA 원우용 내부 도구. 학교 공식 계산서를 웹으로 전환하되, 최종 판단은 행정실 몫이라는 걸 명시한 "보조 도구"로 선을 그었다. 2026년 6월 29일 첫 커밋, 9월 3일까지 커밋 165개·PR 74개. 8월 15일 Supabase를 걷어내고 EMBA 앱(원우수첩)의 백엔드로 옮기면서 독립 웹앱에서 앱 안 웹뷰 전용 화면이 됐다. 그 이관 중에 자동 저장이 서버의 11과목 33학점을 한 기기의 4과목으로 덮어쓸 뻔한 경로를 발견했고, 이후 작업의 절반은 그 종류의 경로를 닫는 데 썼다.

Users

학위 요건을 계산하려는 EMBA 원우, 그리고 학기별 개설과목을 관리하는 관리자.

Build

What I did
  • 요건 계산 엔진 - 학부 전공 여부와 학기별 수강 과목으로 총 학점·요건 충족·취득 가능 학위 계산. 8월 30일 이수 학기 요건(4학기)을 추가해 최종 판정의 첫 조건으로 두고 결과 탭 맨 위에 표시
  • 1차(Supabase) - 관리자 CMS(원자적 쓰기 RPC), Google 로그인 + 명단 기반 가입 승인제(RLS), SheetJS 엑셀 가져오기. 8월 15일 이관 때 전부 EMBA 어드민·회원 시스템으로 넘기고 제거
  • 2차(EMBA 백엔드) - Bearer JWT API 클라이언트, 호스트 앱이 주입하는 window.EMBA_USER 인증, 참조 데이터 단일 출처화와 번들 시드 제거, 조건부 저장(updatedAt, 409면 서버 우선)
  • 결과·시간표 이미지 내보내기 - 안드로이드 앱 웹뷰에서 조용히 실패하던 저장을 앱 핸들러·클립보드·캡처 안내 순서로 고치고, 버튼 라벨이 실제 가능한 동작(저장/복사)을 말하게 함
  • 조별 시간표 - 이관 전 RPC를 GET /degree/team-timetable로 대체
Product decisions
  • "보조 도구"라고 못박기 - 학위 판단을 대체한다고 오해되면 안 되니 최종 확인은 행정실이라는 경계를 문구로 명시
  • 충돌은 병합하지 않고 서버를 정답으로 - 계획은 JSON 한 덩어리라 자동 병합이 없다. 409면 서버 계획으로 화면을 맞추고 "다른 기기에서 먼저 저장되었습니다"라고 알린다
  • 서버 플랜을 못 읽었으면 자동 저장을 멈춘다 - "아직 없다"와 "못 읽었다"를 같은 null로 뭉개면 오래된 기기가 최신 서버를 덮어쓴다. 네 가지 상태(loaded·empty·unavailable·error)로 나누고 error만 저장을 막는다
  • 앱 웹뷰 전용 - 로그인 화면 없이 호스트 앱의 토큰을 받고, 토큰 만료는 앱이 웹뷰를 재로드하는 것으로 처리. 이메일은 웹뷰에 넘기지 않는다
  • 앱 배포가 비싸고 드물다는 전제로 설계 - 앱에 아직 없는 saveImage 핸들러를 웹 쪽에 미리 깔아두고, 백엔드 스키마가 되돌아가도 앱이 양쪽을 견디게 한다
QA considerations
  • 같은 입력에 같은 결과 - 요건 계산을 부수효과 없는 순수 함수·상수로 떼어 경계 조합을 단위 테스트로 못박았는가(정확히 45학점, 경영6/비경영12, Advanced 18, 이수 4학기)
  • 오탐(false-positive) 비용 인정 - "졸업 가능" 오판은 실제 졸업이 밀리는 피해라 자동 판정 대신 최종 확인은 행정실로 Disclaimer 명시
  • "못 읽었다"와 "없다"를 구분하는가 - 참조 데이터 전부 실패면 null(캐시 유지), 성공인데 0건이면 빈 배열(캐시 비움). 플랜도 같은 원칙. 한 번 뭉갰다가 데이터를 잃을 뻔했다
  • 다른 기기와의 충돌 - 읽은 시점의 updatedAt을 보내고 409면 서버 계획을 받아들이며 다시 덮어쓰지 않는가. 열기만 해서는 저장하지 않는가(매번 열 때 저장이 나가던 결함을 #70에서 잡음)
  • 백엔드를 되돌려도 앱이 죽지 않는가 - 과목 코드가 숫자로 돌아와도 정렬이 살아남는 방어, 예전 영역 라벨이 서버·캐시·플랜 어디서 와도 정규화
  • 조용한 실패가 없는가 - 안드로이드 앱 웹뷰에서 "이미지 저장"이 아무 반응이 없던 문제. 공유 API가 있어 보여도 믿지 않고, 사용자 취소와 환경 실패를 구분하고, 되지 않으면 되지 않는다고 말한다. 17개 케이스
  • 스토어 빌드에 개발 API가 섞이지 않는가 - dist에서 dev/api 문자열 0개를 배포 게이트로

Outcome

Metrics

이번 기수 내부 사용. EMBA 앱 안에서만 열리는 웹뷰라 공개 지표는 없음. 커밋 165개(2026-06-29 ~ 09-03), PR 74개, 테스트 104개(7월 1일 30개에서), 버전 0.1.0.

진행 과정 (기획 → 개발)

문제·가설에서 스코프·스펙(SSOT)·개발·QA 게이트·배포까지, 같은 틀로 이 앱을 어떻게 기획하고 끌고 갔는지.

문제 · 가설

EMBA 원우가 엑셀 학위취득계산서로 총 학점·Core·Advanced·국제어·융복합/글로벌 요건을 과목 코드와 영역까지 직접 대조해야 했고, 계산이 어긋나면 졸업이 밀린다. 가설은 계산 기준을 학기 개설과목이 아니라 사용자가 실제 입력한 수강과목(UserCourse)으로 두면, 과거 과목이 현재 학기 목록에 없어도 같은 입력에 같은 결과를 낼 수 있다는 것이었다(docs/ssot.md 5장 핵심 원칙).

역량 · 문제 정의 · 가설

스코프 · 우선순위

docs/ssot.md 4장에서 MVP 범위와 제외 범위를 먼저 못박았다 - 요건 계산 엔진·결과 대시보드·학기/과목/개설과목 관리가 코어, 학교 시스템 연동·성적 자동 수집·수강신청·공식 졸업사정 대체는 제외다. 20장 Phase 1~6 순서대로 계산기를 먼저 세우고 Admin·Excel 업로드·운영 기능을 뒤에 얹었으며, 로그인 기반 저장은 원래 MVP 제외였다가 이후 Google 로그인·명단 승인·계정별 저장으로 실제로 확장했다.

역량 · 스코핑 · 우선순위

스펙 우선 (SSOT)

docs/ssot.md는 이름 그대로 Single Source of Truth로, 요건 규칙(9장)·계산 로직(13장)·데이터 모델(11장)·화면(7장)을 코드보다 먼저 규정했다. 구현이 이 문서를 따라간 흔적이 남아 있다 - constants.ts의 요건 상수는 SSOT 9장을, calculateRequirements.ts의 각 계산은 13.x 절을 주석으로 인용하고, 테스트 describe 블록도 SSOT 절 번호를 그대로 붙였다.

역량 · 스펙 · 문서화

개발 · 이터레이션

1차(6월 29일 ~ 8월 15일)는 React 19 + Vite + TypeScript + Tailwind v4 + Radix(shadcn) 프론트에 Supabase 백엔드였다. 요건 계산은 부수효과 없는 순수 함수(calculateRequirements.ts)로 뽑고 기준값은 constants.ts로 분리했으며, DB 쪽은 원자적 쓰기 RPC(replace_semester_courses), 명단 기반 승인 RLS, SheetJS 엑셀 임포트가 핵심이었다. 2차(8월 15일 PR #58)에서 Supabase·xlsx 의존성과 관리자 화면·엑셀 파서 49개 파일 5,010줄을 걷어내고 EMBA 백엔드(NestJS·MySQL)로 이관했다. 참조 데이터(학기·과목·개설과목)는 백엔드가 단일 출처고 localStorage는 초기 표시용 캐시다. 인증은 호스트 Flutter 앱이 문서 로드 전에 window.EMBA_USER(회원 ID·기수·조·액세스 토큰)를 주입하는 방식이라 로그인 화면이 없다. 과목 관리는 EMBA 어드민으로 옮겼고, 번들 시드 75과목도 #64에서 제거해 "운영에서 지워도 앱에는 남는" 상태를 없앴다. 순수 함수와 상수 분리는 그대로 살아남아 이수 학기 요건(4학기)이 같은 자리에 추가됐다.

역량 · 실행 · 전달

검증 (QA 게이트)

계산기의 신뢰를 같은 입력에 같은 결과, 그리고 규칙이 학교 기준과 일치로 정의하고, 요건 로직을 순수 함수·상수로 떼어낸 뒤 경계 조합을 단위 테스트로 못박았다 - 정확히 45학점, 경영 6 vs 비경영 12, Advanced 총 18은 채웠지만 한 영역에 몰려 세부 4개 미충족, 반대로 4개 영역은 채웠지만 총 18 미달 같은 엇갈림까지. 테스트는 7월 1일 30개에서 지금 16개 파일 104개가 됐고, CI(ci.yml)는 push·PR마다 typecheck·lint·test·build를 Node 22에서 돈다. 가장 중요한 QA 결정은 오탐 비용을 인정한 것으로, 졸업 가능을 잘못 말하면 원우 졸업이 실제로 밀리므로 자동 판정 대신 Disclaimer로 최종 확인은 행정실 몫임을 상기시키고, 미충족이지만 일부 채운 상태는 isPartiallyMet로 완화 표시했다. 이관 뒤 추가된 테스트의 중심은 "못 읽었다"와 "없다"를 가르는 것이다. 서버 플랜 조회가 실패하면 자동 저장을 멈추고(사유를 화면에 적음), 서버에 저장된 적이 없으면 로컬을 올리고, 연동 불가면 로컬 전용. 조건부 저장은 읽은 시점의 updatedAt을 같이 보내고 409면 서버 계획을 받아들여 다시 덮어쓰지 않는다. 백엔드가 과목 코드를 숫자에서 문자열로 바꿨을 때 되돌려도 죽지 않는 정렬, 영역 라벨 정규화를 서버 응답·캐시·플랜 세 읽기 지점 모두에, 안드로이드 웹뷰 이미지 저장 17개 케이스까지 전부 테스트로 고정했다.

역량 · 품질 · 리스크

배포

이 앱은 서버에 배포하지 않는다. 빌드 산출물을 EMBA 앱 저장소에 Flutter 에셋으로 복사하고 앱이 InAppLocalhostServer(8091)로 서빙하며, 스토어를 거쳐 나간다. 앱 쪽은 8월 30일부터 원격 번들(명함 웹 /planner)을 먼저 읽고 실패하면 에셋으로 폴백한다. 배포 전 게이트는 README에 grep 한 줄로 적혀 있다 - dist 안에 dev/api 문자열이 0개여야 한다. Cloudflare Pages는 개발 중 경로였고 8월 20일 wrangler.toml과 함께 제거했다. HashRouter·상대경로 빌드(base:"./")는 그대로다. 버전은 0.1.0, 빌드 식별자는 git 짧은 해시를 푸터 data-build 속성으로만 남기고 눈에 보이는 표기는 뺐다. 검색 색인은 noindex로 막았다. 스토어를 거치는 번들이라 되돌리기가 즉시 안 된다는 전제가 방어적 정렬 같은 결정의 근거다.

역량 · 릴리스

Tech stack
  • React 19
  • TypeScript
  • Vite
  • Tailwind v4
  • Vitest
  • EMBA 백엔드 API (NestJS · MySQL)
  • Flutter InAppWebView 에셋 번들
  • Supabase (1차, 2026-08 이관으로 제거)