지인의 주방상판 업체를 돕게 된 건 2026년 5월 말이었다. 현재 운영 방식이 어떻게 되냐고 물어봤더니, "엑셀로 견적서 만들고 카카오톡으로 보내요"라고 했다. 그게 다였다.
일정 확인도 카카오톡. 시공기사에게 도면 전달도 카카오톡. 대금 수납 여부도 별도 엑셀 파일. 문제가 뭐냐고 물으니 처음에는 "잘 모르겠다"고 했다. 그러다 한 가지 얘기가 나왔다.
"견적서 버전이 두 개인데 어떤 게 최신인지 모르겠어."
그 말이 시작이었다.
문제를 정확히 파악하는 데 일주일 걸렸다
실제로 어떻게 운영되는지 들여다보니 다섯 가지가 동시에 엉켜 있었다.
- 견적서 버전 관리 불가 — 파일이 여러 개, 어떤 게 최종인지 모름
- 고객사가 진행 현황을 알 방법이 없음 — 매번 전화해서 물어봐야 함
- 시공기사가 현장 정보를 실시간으로 받을 수 없음
- 상태 변경 알림을 수동 발송 — 빠뜨리면 끝
- 매출 데이터 없음 — 사업 판단 근거 부재
다섯 가지를 하나의 플랫폼으로 묶는 게 목표였다. 견적 발행 → 시공 완료 → 정산까지 전 과정을 한 곳에서.
기술 스택은 하루 만에 골랐다
선택 기준은 단 하나였다. 혼자 만들고 혼자 운영할 수 있어야 한다.
| 기술 | 이유 |
|---|---|
| Next.js 14 App Router | 프론트·백엔드를 한 레포에서 관리. API Routes로 서버리스 백엔드 내장 |
| Supabase | PostgreSQL + 인증 + RLS를 한 번에. Firebase 대비 SQL 쓸 수 있음 |
| Tailwind CSS 4 | 디자인 토큰을 @theme CSS 변수로 정의하면 유틸리티·인라인 양쪽 활용 가능 |
| Vercel | Next.js와 연결이 제일 자연스럽다. Blob 스토리지까지 포함 |
| TypeScript | 견적 계산처럼 비즈니스 로직이 복잡한 곳엔 타입이 필수 |
DB 설계에서 가장 오래 고민한 것
처음 DB를 설계할 때 한 가지 고민이 있었다. 최상위 테이블이 무엇이어야 하는가.
- Option A: 견적이 최상위 → 견적에 현장이 달림
- Option B: 현장이 최상위 → 현장에 견적이 달림
B2B 거래 현실을 보면 정답은 B였다. 같은 고객사의 "강남점 리노베이션"과 "분당점 신축"이 동시에 진행된다. 현장 단위로 묶어야 운영자가 "이 고객사 어디어디 진행 중이야"를 한눈에 파악할 수 있다.
고객사(clients)
└── 현장(sites)
├── 견적(client_quotes)
├── 발주(production_orders)
├── 일정(schedules)
└── 첨부파일(attachments)
역할은 세 가지로 시작했다. ADMIN(사무실 직원), CLIENT(인테리어사 담당자), TECH(시공기사). 세 역할 각각이 보는 화면과 접근 가능한 데이터가 달라야 했다.
모노레포로 시작했다가 일주일 만에 포기
첫 커밋에서 packages/shared-types로 타입을 공유하는 모노레포 구조를 잡았다. 로컬에서는 잘 됐는데 Vercel 배포에서 줄줄이 터졌다.
fix: lib 경로 및 의존성 문제 해결
fix: OrderStatus enum 일치 문제 해결
fix: TypeScript 빌드 에러 수정
fix: 불필요한 shared-types 의존성 제거
fix: 모든 shared-types import 제거
커밋 다섯 개가 같은 원인이었다. Vercel 빌드 환경이 로컬 패키지 참조 경로를 제대로 해석하지 못했다.
해결책은 간단했다. 모노레포를 버리고 lib/types.ts 하나로 통합했다. 배포 안정성을 희생하면서까지 초기에 모노레포를 고집할 필요가 없었다. 나중에 필요해지면 그때 도입하면 된다.
B2B 운영 콘솔을 첫 달에 완성했다
기술 스택 확정, DB 설계, Vercel 배포, 세 역할 체계, 관리자 대시보드까지 6월 한 달에 다 만들었다.
관리자 미들웨어 설계에서 한 가지 선택이 있었다. Next.js 미들웨어 방식 대신 각 API 라우트 핸들러에서 직접 인증을 호출하는 방식을 택했다. 이유는 Next.js App Router의 미들웨어가 Edge 환경에서 실행되어 crypto, JWT 라이브러리 같은 Node.js API 사용에 제약이 있었기 때문이다.
// lib/admin-auth.ts
export async function requireAdmin(request: NextRequest) {
const token = request.cookies.get('auth_token')?.value;
const payload = jwt.verify(token, JWT_SECRET);
if (payload.role !== 'ADMIN') throw new ForbiddenError();
return payload;
}
관리자 대시보드는 아침마다 파악해야 하는 정보 하나에서 출발했다. "지금 어느 단계에 몇 건이 있나." 파이프라인 4단계 시각화, 건수 클릭 → 드릴다운 목록. 그게 전부였다.
다음 편에서는 가장 복잡한 문제로 들어간다. 주방상판 견적을 코드로 옮기는 일.