외주 레거시 재구축을 에이전트에 맡긴 9개월, 그리고 테이블 2벌7분 읽기 · 0%
구축기

외주 레거시 재구축을 에이전트에 맡긴 9개월, 그리고 테이블 2벌

외주 레거시 재구축을
에이전트에 맡긴 9개월, 그리고 테이블 2벌

도입 배경

2025년 12월 24일 새벽 2시 15분에 올라온 커밋 제목은 "MySQL → Supabase 데이터 마이그레이션 완료"였습니다. 본문에는 이관한 47개 테이블과 행 수가 있었습니다. user_info 6,733행, lesson 7,151행, history 12,425행. 트레일러는 Co-Authored-By: Claude Opus 4.5.

약 3,000명이 쓰고 매주 주말마다 행사가 도는 교원 연수·역량 플랫폼입니다. 원래는 외주로 만든 Express + React(CRA) 서비스와 게시판용 WordPress 사이트였습니다. 저희는 2025년 12월부터 2026년 9월까지 이 전체를 Next.js 16 + Supabase로 다시 지었고, 작업 대부분을 Claude Code에 위임했습니다.

저 커밋은 최종 이관이 아니었습니다. 2026년 3월까지는 "이렇게 바꾸겠다"를 보여주는 PoC 구간이었고, 그날 찍은 스냅샷은 반년 뒤 데이터 모델이 2벌로 갈라지는 출발점이 됩니다.

레거시 baseline과 재작성 결정

재작성 전에 레거시 상태를 측정했습니다.

  • API 엔드포인트 175개, 서버 21,914 LOC, 프론트 38,725 LOC, MySQL 테이블 55개
  • 테스트 0개, ESLint·Prettier 설정 0개. CI 워크플로 4개는 "빌드 → zip → scp/ssh → pm2 reload"만 합니다
  • surveyService.js 1,960줄. 마지막 6개월 fix 32건 중 11건이 퀴즈 생성 로직 한 곳에 몰렸습니다

권한 처리가 더 문제였습니다. 검색 쿼리는 사용자 입력을 LIKE '%${s}%'로 Sequelize literal()에 결합했고, 권한 미들웨어 3종은 userId를 JWT payload가 아니라 req.query·body·headers에서 읽어 역할을 판정했습니다. 침투 테스트는 하지 않았으니 구조상 위험한 패턴이 있었다는 것까지만 말하겠습니다.

재작성을 택한 이유는 코드 품질이 아니라 요구사항이었습니다. 기존 기능 정리에 더해 다른 시스템과의 결합이 필요했습니다. 도메인 31개를 대응시키면 그대로 이식 3개, 재설계 10개, 폐기 4개, v2 신규 13개이고 AI 기능은 전부 신규입니다. 레거시의 LLM 의존성은 0개였습니다. "코드를 개선한다"가 아니라 "플랫폼을 바꾸면서 범위를 넓힌다"가 정확합니다.

버린 것은 레거시 코드베이스와 Express·MySQL 운영 지식, 얻은 것은 RLS 정책 368개로 권한 판정을 DB 층에 고정한 구조입니다. 테스트 0개·게이트 0개인 코드를 물려받는 비용이 더 크다고 봤습니다.

Claude Code에 맡긴 것, 사람이 잡은 것

위임 범위를 한 문장으로 줄이면 "사람은 UI만 확인"이었습니다. 커밋 트레일러로 잰 공동작성 비율은 초기 master 103커밋 중 12개(약 12%)에서 develop 788커밋 중 246개(약 31%)로 올라갔습니다. 레거시는 세 가지 방식으로 읽혔습니다.

  • 대응표 문서 먼저. 저장소 첫날인 2025년 12월 17일에 MIGRATION_GUIDE.md가 함께 커밋됐습니다(현재 812줄). 테이블 30여 개 스키마, API·라우트 대응표, Phase 1 Auth부터 Phase 7 Admin까지의 단계. 코드 전체를 읽히는 대신 이 대응표가 이관의 출발점이었습니다
  • 심볼 그래프. 2026년 5월 8일 레거시 저장소에 AGENTS.md와 gitnexus 인덱스(심볼 1,515개·관계 2,852개)를 붙였습니다. 핵심 규칙은 둘. 심볼 수정 전 impact 분석, 리네임에 find-and-replace 금지
  • 인용 후 폐기. openspec 제안서 원문은 "문구를 옮긴 뒤 이 레포를 다시 참조할 일이 없도록 원문 전량을 스펙과 설계 문서에 인용해 둔다"입니다. v1 원문의 오타도 손대지 않았습니다. 고치는 순간 대조 기준이 사라지기 때문입니다

사람이 끝까지 잡은 지점은 화면 확인과 운영 DB 적재 직전의 승인 두 곳입니다. 나머지 게이트(Playwright e2e 29개, pre-push 훅, CI 잡, code-reviewer 승인)는 대부분 사고가 난 뒤에 하나씩 생겼습니다.

9개월 뒤 결과

2026년 9월 3일 기준 실측치입니다.

  • src/ TS/TSX 1,091개, 약 23만 줄. API route 90개(레거시 엔드포인트는 175개였습니다)
  • supabase/migrations 315개(2026-06-10 squash 이후), RLS CREATE POLICY 368개, 관리자 3단계
  • 커밋은 master 103개, develop 788개(2026-06-15~2026-09-02)

못 하는 말도 셋 있습니다.

  • 성능·비용·버그율 before/after 수치가 없습니다. 레거시에 측정 기준선이 없었고 신규에서도 재지 않았습니다
  • 단위 테스트는 사실상 없습니다. develop 788커밋 중 단위 테스트를 건드린 커밋이 5개(0.6%), e2e 작성도 7월 25개·8월 4개·9월 0개로 줄었습니다
  • git 히스토리가 3~4회 재구성됐습니다. 현재 HEAD로 이어지지 않는 root 커밋이 7개, 5월 18일~6월 15일 4주치 git 객체가 없습니다. 사유는 기록이 남지 않았습니다

트러블슈팅: 재구축 중 만난 4가지 함정

1. 같은 도메인 개념이 테이블 2벌로 존재

2026년 6월 26일 데이터 모델 전수 감사를 돌렸습니다. 감사 문서 첫 줄은 이렇습니다.

v1→v2 마이그레이션이 '덮어쓰기'가 아니라 '병존'으로 진행됨. 같은 도메인 개념이 ①v1 테이블 잔존 + ②v2 재설계 테이블 신규생성으로 2벌 존재.

감사는 v1 테이블 27개를 v2 스테이징의 테이블 104개·뷰 4개와 대조했습니다. 발견 사항은 높음 9건, 중간 20건, 낮음 8건. 높음 1번 "역량 추천 매핑 전면 오류"는 추천 훅(use-recommendation.ts:8-21)의 하드코딩 매핑이 실제 테이블 구조와 어긋난 것으로, 화면 검수로는 걸러지지 않습니다.

원인: 세 가지가 겹쳤습니다. 첫째, v1 테이블 이름을 그대로 계승했는데 2026년 3월 UI 리뉴얼에서 화면 용어는 전면 교체됐습니다. "설문"은 "역량 측정"으로, "퀴즈"는 "문제풀이"로 메뉴·버튼·파일명까지 바꿨는데 DB 테이블은 surveyquiz 그대로였습니다. 예컨대 "역량 측정" 화면을 고치라는 지시를 받은 모델은 survey 테이블로 가야 한다는 사실을 세션마다 다시 알아내야 했습니다. 개발자의 표현으로는 "AI가 오랫동안 헷갈려함"입니다.

둘째, 사람은 UI만 확인했습니다. 화면이 정상이면 통과였으므로 데이터 층에 같은 개념이 2벌 있다는 사실은 아무도 보지 않는 자리에 있었습니다.

셋째, 발견했을 때는 리네임이 이미 불가능했습니다. AGENTS.md에 남은 근거는 "user_info를 리네임하면 함수 59개가 조용히 깨진다"입니다. 테이블명이 DB 함수 59개 안에 문자열로 들어가 있으면 리네임은 컴파일 에러가 아니라 런타임 침묵으로 돌아옵니다.

해결: 이름을 고치는 대신 뜻을 고정하는 쪽을 택했습니다.

  1. 병존 패턴을 다섯 가지로 분류했습니다. v1 잔존과 v2 재명명 공존, 다계층 분류의 orphan화, 식별 체계 N중화, 척도 혼재, 명세와 DB의 용어 drift
  2. 연결되지 않은 테이블은 DROP했습니다. 6월 18일, 6월 26일(초기 이식한 v1 테이블 4개), 7월 15일 세 차례
  3. 이름을 못 바꾸는 테이블은 매핑을 문서로 고정하고, v1 접두어 테이블 8개는 "신규 사용 금지" 네임스페이스로 격리했습니다
  4. 도메인 5개의 SSOT 문서와 docs/domains/ 15개 문서로 용어 판정을 한곳에 모았습니다

완치는 아닙니다. 어긋난 상태를 문서로 덮은 것이고, 그 문서를 읽지 않은 세션은 같은 실수를 합니다.

2. 스냅샷 이후 양쪽에서 생성된 id

2025년 12월 23일 스냅샷으로 v2를 채운 뒤에도 v1은 운영을 계속했습니다. 같은 id가 양쪽에서 다른 행을 가리키기 시작했고, 2026년 6월 11일 v1 MySQL을 다시 덤프(55테이블, 97.9MB)해 옮기려 할 때 충돌이 드러났습니다.

원인: PoC 기간의 1회성 스냅샷을 최종 이관처럼 적재해 둔 것입니다. 그때 쓴 import 스크립트는 gitignore된 디렉터리에 있어 커밋조차 되지 않아 git에 남지 않았습니다.

해결: 2세대 ETL 스크립트를 새로 썼습니다. 테이블마다 정책을 세 갈래로 나누고 컬럼과 id는 별도 매핑으로 처리했습니다.

LOAD                # v1 → v2 적재 대상
EXCLUDE_CURATED     # v2에서 자체 큐레이션한 데이터 보호 (예: 문항 42행 dedup)
EXCLUDE_RETIRED     # v2가 계승하지 않는 도메인 폐기 (design, scrap, 연수 섹션 등)

COLMAP              # v1 컬럼명 → v2 컬럼명 매핑
NATURAL_KEY         # 자연키 기준 NOT EXISTS 가드 (중복 적재 차단)
SQUATTED_IDS        # v2가 자체 생성해 선점한 id 4개를 리맵

적재는 스크립트 보강 → 스테이징 리허설(+3,371행 정합 확인) → 사용자 승인 후 운영 적재(+3,374행)의 3단계로 끊었습니다.

3. 타임스탬프 역전으로 잠복한 GRANT 누락 8건

2026년 5월 12일, 다른 hotfix의 backfill을 시뮬레이션하다가 우연히 발견했습니다. 권한 GRANT 누락이 8건 잠복해 있었고 지역 관리자용 RPC가 깨져 있었습니다.

원인: 커밋 본문 원문은 "원인은 default safety check 가 timestamp 역전 마이그 차단. branch merge 시 timestamp 역전이 자주 발생해 잠복 누락 누적"입니다. 브랜치마다 마이그레이션을 만들다 보니 병합 후 타임스탬프가 뒤집혔고, 배포 도구의 안전장치가 그 파일들을 조용히 건너뛴 겁니다.

해결: 5월에는 우선 db push --include-all과 dry-run pre-flight로 건너뛴 파일을 강제 적용했고, 6월 10일에 마이그레이션 131개를 baseline 4개로 squash했으며, 7월부터는 검사를 pre-push 훅으로 옮겼습니다. 훅은 중복 타임스탬프, 순서 역전, 기존 파일 리네임·삭제, anon 롤 GRANT 재부여, 화이트리스트 대비 GRANT 누락을 봅니다. CI에서는 pg-policy-drift 잡이 RLS를 정적 검사한 뒤 라이브 스키마와 대조합니다. 훅을 bash가 아니라 sh로 쓴 이유도 주석에 있습니다. GitHub Desktop의 MinGit에 bash.exe가 없어 bash로 쓰면 실행이 WSL로 샜습니다.

4. 서브에이전트가 RPC 하나를 쿼리 3개로 풀어쓴 18분

2026년 2월 6일 13시 56분 "Admin 대시보드 에러 수정" 커밋 본문은 "RPC 함수 호출을 직접 쿼리로 변경(에러 원인 수정)"이었고 use-statistics.ts가 +311/-78로 바뀌었습니다. 이어 18분 동안 커밋 5개. @ts-nocheck 우회, 타입 에러 수정, 캐스팅 수정, 14시 14분 revert: use-statistics.ts 이전 버전으로 복구 (서브에이전트 수정 전).

-      const { data, error } = await (supabase.rpc as any)('get_statistics_summary', { p_filter: filter })
+      try {
+        const { count: userCount } = await supabase.from('survey_result').select('*', { count: 'exact', head: true }).eq('is_last_time', 1)
+        const { data: schoolData } = await supabase.from('survey_result').select('school_name').eq('is_last_time', 1).not('school_name', 'is', null)
+        const uniqueSchools = new Set(schoolData?.map(s => s.school_name) || [])
+        const { data: chartRawData } = await supabase.from('survey_result').select(filter).eq('is_last_time', 1).not(filter, 'is', null)
+        const chartCounts: Record<string, number> = {}
+        chartRawData?.forEach(item => { const key = item[filter] as string; if (key) chartCounts[key] = (chartCounts[key] || 0) + 1 })
+        ...
+      } catch (error) { ... }

원인: 에러의 뿌리는 호출부가 아니라 RPC 쪽 권한·역할 설정에 있었던 것으로 보입니다. 같은 날 관리자 권한 체크 위치가 6번 뒤집혔습니다. 서브에이전트는 자기 변경 범위 밖의 DB 함수와 권한이 보이지 않아 증상이 나는 호출부를 통째로 재작성했습니다.

해결: 전면 revert. 이후 레거시 저장소에는 "심볼 수정 전 impact 분석 필수"가, 신규 저장소에는 변경한 파일만 lint·tsc로 보고 전체 검사는 CI에 맡기는 규칙이 생겼습니다. 5월 18일 승격 커밋에는 "code-reviewer APPROVE: 회귀 4건 100% 복원, v2 SSOT 보존, PDF 캡처 정상"이 남아 있습니다.

교훈과 다르게 할 것

1. UI 검수가 보지 못하는 층

화면 회귀는 사람 눈이 가장 빠르게 잡습니다. 반면 테이블 2벌 병존, 타임스탬프 역전으로 건너뛴 GRANT, 갈라진 id 공간은 화면에 흔적을 남기지 않습니다. 감사 문서, pre-push 훅, CI drift 검사, openspec, code-reviewer는 전부 이 빈칸을 메운 것이고 대부분 사고가 난 뒤에 생겼습니다.

2. 에이전트가 읽는 인터페이스로서의 이름

모델은 도메인 개념과 테이블을 이름으로 연결합니다. 화면 용어를 "역량 측정"으로 바꾸고 테이블을 survey로 두면, 그 간극은 세션마다 다시 설명해야 하는 비용이 됩니다. 사람끼리는 맥락으로 메워지지만, 에이전트에게 이름은 검색 키라서 어긋난 이름 하나가 9개월 내내 같은 오해를 반복시킵니다.

다시 한다면, 테이블명부터

다시 하면 무엇을 바꾸겠냐는 질문의 답은 테이블명 하나였습니다. 3월에 UI 용어를 바꿨고, 테이블은 v1 이름을 유지했고, 6월 감사에서 병존이 드러났고, 그때는 리네임하면 함수 59개가 깨지는 상태였습니다. 도메인 용어 사전 파일은 2025년 12월 23일에 이미 있었습니다. 없어서 못 한 게 아니라 그 사전과 테이블명을 맞추는 일을 PoC 첫 주에 하지 않은 것이 문제였습니다.

  • #ClaudeCode
  • #VibeCoding
  • #레거시마이그레이션
  • #Supabase