자체 호스팅 콘솔을 시작하며 가장 먼저 한 일은 코드를 짜는 것이 아니라 스택을 고르는 것이었습니다. 혼자 만들고 혼자 운영하는 서비스에서 스택 선택은 취향의 문제가 아니라 앞으로 몇 달간 내가 얼마나 고생할지를 정하는 결정입니다. 결제와 인프라 프로비저닝처럼 실수가 곧 돈으로 이어지는 영역이라 더 그랬습니다. 어떤 기준으로 무엇을 골랐고 무엇을 피했는지 정리합니다.
뼈대는 이미 익숙한 것으로
프레임워크는 Next.js를 App Router로 쓰기로 일찌감치 정했습니다. 새로운 것을 배우는 비용을 결제와 인프라 같은 진짜 어려운 부분에 쓰고 싶었기 때문입니다. UI는 손에 익은 컴포넌트 라이브러리를 그대로 가져와 화면을 빠르게 세웠습니다. 언어는 두말할 것 없이 TypeScript입니다. 결제 금액을 계산하고 인프라를 생성하는 코드에서 타입 오류를 컴파일 시점에 잡을 수 있다는 것은 선택이 아니라 필수였습니다. 잘못된 값이 EC2 생성이나 결제 금액으로 흘러 들어가는 사고를 미리 막아 주니까요.
데이터는 관계형으로 갔습니다. 구독과 플랜, 인스턴스, 인보이스는 서로 명확한 관계로 얽혀 있고 결제와 프로비저닝을 원자적으로 처리하려면 트랜잭션이 필수라 관계형 데이터베이스가 자연스러운 선택이었습니다. 그 위에 올릴 ORM으로 Prisma를 골랐습니다.
Prisma를 고른 이유, 그리고 버전
ORM으로 Prisma를 고른 데는 두 가지 이유가 있습니다. 하나는 스키마를 먼저 정의하고 마이그레이션을 관리하는 방식이 처음부터 만드는 프로젝트에 잘 맞았기 때문입니다. 다른 하나는 데이터베이스 상태를 눈으로 확인하는 도구가 함께 딸려 온다는 점입니다. 구독이 지금 어떤 상태인지, 인보이스가 제대로 쌓였는지를 개발 중에 바로 들여다볼 수 있다는 것은 결제 로직을 짤 때 큰 도움이 됐습니다.
버전은 최신 메이저를 택했습니다. 이 버전은 쿼리가 빨라지고 번들이 작아진 세대라 서버리스 환경에도 부담이 적습니다. 다만 최신 메이저를 쓸 때는 함께 물리는 인증 어댑터 같은 주변 라이브러리가 이 버전을 지원하는지를 먼저 확인해야 합니다. 최신을 택하는 대가로 호환성 검증이라는 숙제가 따라오는 셈인데, 이 확인만 통과하면 얻는 이득이 컸습니다.
스케줄링에는 왜 Vercel 크론이 아니라 BullMQ인가
가장 고민한 선택이 정기결제의 스케줄링이었습니다. 간단하게는 배포 플랫폼이 제공하는 크론 기능으로 매달 작업을 트리거할 수 있습니다. 하지만 이걸 정기결제에 쓰기엔 부족했습니다. 결제는 실패할 수 있고 실패하면 재시도해야 하며 같은 청구가 두 번 나가지 않도록 멱등성을 지켜야 하는데, 단순 크론 트리거는 이 재시도와 중복 방지, 작업 상태 관리를 대신해 주지 않습니다.
그래서 작업 큐인 BullMQ를 골랐습니다. BullMQ는 반복 작업 스케줄러로 매일 정해진 시각에 작업을 만들고, 실패한 작업을 재시도하며, 작업에 식별자를 붙여 중복을 거르고, 작업의 상태를 추적합니다. 결제와 프로비저닝처럼 실패 처리가 핵심인 작업에는 이 기능들이 전부 필요했습니다. 대신 BullMQ는 Redis를 요구하므로 Redis 클라이언트도 함께 들였고 워커는 앱과 분리해 돌릴 수 있는 구조로 두었습니다. 서버리스 환경은 오래 사는 워커를 붙잡아 두지 못하기 때문입니다.
bcryptjs, argon2를 피한 이유
비밀번호 해싱에서는 이론적으로 더 우수한 argon2 대신 순수 자바스크립트로 구현된 bcryptjs를 골랐습니다. argon2가 보안적으로 더 강하다는 것은 알지만 그 라이브러리는 네이티브 애드온을 요구합니다. 이 애드온은 도커 빌드나 특정 실행 환경, 일부 CI에서 자주 문제를 일으킵니다. 혼자 운영하는 서비스에서 빌드가 환경마다 깨지는 것은 감당하기 어려운 비용이라 네이티브 의존성이 전혀 없는 순수 자바스크립트 구현을 택했습니다. 지금 규모에서는 이 정도 강도로 충분하고 배포가 어디서든 똑같이 도는 안정성이 더 값졌습니다.
인증은 프레임워크와 잘 맞물리는 표준 라이브러리를 썼습니다. 아직 정식 안정 버전 태그가 붙지 않은 세대이지만 실무에서 이미 널리 쓰이고 최신 프레임워크의 App Router와 맞도록 설계된 버전이라 오히려 이쪽이 새 프로젝트에 맞는 선택이었습니다. 여기에 입력 검증은 스키마 기반 라이브러리로 모든 서버 경계에서 강제했습니다. 결제나 프로비저닝으로 들어가는 데이터는 데이터베이스나 AWS에 닿기 전에 반드시 검증을 통과하게 했습니다.
정리하며
혼자 만드는 SaaS의 스택은 결국 운영 부담을 줄이는 쪽으로 수렴했습니다. 익숙한 프레임워크로 학습 비용을 아끼고, 트랜잭션이 필요한 곳엔 관계형 데이터베이스를, 재시도가 필요한 곳엔 작업 큐를, 어디서든 똑같이 빌드되어야 하는 곳엔 네이티브 의존성 없는 라이브러리를 골랐습니다. 더 화려한 선택지도 있었지만 혼자 감당할 수 있는가라는 질문 앞에서는 대부분 지루한 쪽이 답이었습니다.
이 스택 위에 올린 정기결제와 자동 프로비저닝의 실제 설계는 스케줄러 없는 결제사로 정기결제 만들기와 EC2 자동 프로비저닝을 백그라운드 워커로에서, 전체 그림은 SaaS 운영 콘솔 구축기에서 볼 수 있습니다.
자주 묻는 질문
정기결제 스케줄링에 배포 플랫폼 크론을 쓰면 안 되나요?
단순히 매달 작업을 트리거하는 데는 쓸 수 있지만 결제에는 부족합니다. 결제는 실패 시 재시도가 필요하고 같은 청구가 두 번 나가지 않도록 멱등성을 지켜야 하는데, 단순 크론 트리거는 재시도와 중복 방지, 작업 상태 추적을 대신해 주지 않습니다. 그래서 이 기능들을 갖춘 작업 큐를 택했습니다.
argon2가 더 안전한데 왜 bcryptjs를 썼나요?
argon2가 이론적으로 더 강하지만 네이티브 애드온을 요구해 도커 빌드나 일부 실행 환경, CI에서 자주 깨집니다. 혼자 운영하는 서비스에서 환경마다 빌드가 달라지는 것은 큰 부담이라 네이티브 의존성이 없는 순수 자바스크립트 구현을 택했습니다. 지금 규모에서는 강도도 충분합니다.
최신 메이저 버전을 쓰는 게 위험하지 않나요?
이득과 숙제가 함께 옵니다. 최신 버전은 성능과 번들 크기에서 이점이 있지만 함께 물리는 주변 라이브러리가 그 버전을 지원하는지 먼저 확인해야 합니다. 인증 어댑터처럼 데이터베이스 버전에 민감한 부분은 호환성을 검증한 뒤에 채택했고 이 확인만 통과하면 최신을 쓰는 이득이 컸습니다.