들어가며
저는 AIvance라는 이름으로 SaaS 제품 11개를 혼자 만들고 운영합니다. 비결은 제품을 11번 만든 게 아니라, 골격을 한 번 만들고 11번 재사용했다는 데 있습니다.
"혼자서 11개를 어떻게 관리하냐"는 질문을 자주 받습니다. 솔직히 제품마다 따로 설계했다면 불가능했을 겁니다. 인증, 에러 응답, 배포, 테스트 방식이 제품마다 달랐다면 저는 지금쯤 제 코드를 매번 다시 읽느라 하루를 다 썼을 겁니다.
이 글은 그 공통 골격이 무엇인지, 그리고 그 위에서 어떤 기능은 별도 서비스로 떼고 어떤 기능은 그대로 붙여 두는지, 제 판단 기준을 정리한 기록입니다. 26년 동안 PHP를 해 오면서 MSA를 "할 수 있으니까" 하는 경우를 많이 봤습니다. 1인 운영자에게는 그 반대, "뗄 이유가 있을 때만 뗀다"가 원칙이었습니다.
제품 지도
11개 제품은 대상도 업종도 제각각이지만, 모두 CodeIgniter 4와 PHP 8 위에 같은 규약으로 서 있습니다. 이름도 "AI + 라틴어" 규칙 하나로 통일했습니다.
|
제품 |
한 줄 설명 |
대상 |
|---|---|---|
|
테마·관리자·게시판·소셜로그인을 갖춘 CI4 웹에이전시 솔루션 |
기업 홈페이지·CMS |
|
|
AI로 업무 효율을 높이는 쇼핑몰 솔루션 |
이커머스 |
|
|
성형광고 및 일반 광고 솔루션 |
병의원·광고주 |
|
|
토큰 기반 인증 API |
서비스 간 인증 |
|
|
온·오프라인 라이선스 인증 서비스 |
소프트웨어 판매자 |
|
|
흩어진 매입·매출을 AI가 정리하는 간편장부 ERP |
간이사업자 |
|
|
AIFid |
고객사 웹사이트에 위젯을 심어 실시간 채팅 상담을 하는 멀티테넌트 SaaS |
고객지원 조직 |
|
AILign |
목공·CNC 견적 솔루션 |
공방·CNC 업체 |
|
AILego |
적응형 질문과 AI 평가로 인재를 고르는 녹화형 화상면접 솔루션 |
채용 담당자 |
|
AICella |
가구를 2D·3D로 배치하고 동선을 확인하는 룸 플래너 |
일반 소비자 |
|
AIPicto |
그림책 프로젝트 |
(대상) |
표만 보면 서로 관계없는 제품들입니다. 하지만 코드를 열어 보면 디렉터리 구조, 응답 포맷, 에러 코드, 브랜치 전략, 배포 스크립트가 거의 같습니다. 이 "거의 같음"이 1인 운영의 전부입니다.
공통 골격 ①: 출발점은 두 개뿐
새 제품은 항상 두 저장소 중 하나에서 시작합니다. API 중심이면 ci4-board, 화면 중심이면 AICreo입니다.
|
출발점 |
성격 |
들어 있는 것 |
|---|---|---|
|
ci4-board |
API 인프라 보일러플레이트 |
JWT 인증, Google·Kakao·Naver 소셜 로그인, API 버저닝, Swagger, 다국어(i18n), 통합 테스트 149개 |
|
AICreo |
웹에이전시 보일러플레이트(MIT) |
테마 시스템, 관리자 패널, 게시판, 소셜 로그인, TDD 워크플로 |
ci4-board는 AI와 함께 한 달 만에 만들었습니다. 그 한 달이 이후 제품들의 첫 주를 통째로 없애 줬습니다. 로그인, 토큰 갱신, 버전별 라우팅, API 문서화는 어떤 제품이든 첫 주에 해야 하는 일인데, 이제는 복사해서 시작하면 끝입니다.
여기서 중요한 건 테스트까지 같이 가져온다는 점입니다. 통합 테스트 149개가 함께 넘어오기 때문에, 새 제품에서 공통 부분을 건드려도 무엇이 깨졌는지 바로 압니다. 보일러플레이트의 가치는 코드보다 테스트에 있습니다.
공통 골격 ②: 레이어 구조와 규약
모든 제품은 같은 레이어로 나뉩니다. 나중에 어떤 기능을 별도 서비스로 떼어낼 때, 이 경계가 곧 절취선이 됩니다.
app/
├── Controllers/Api/V1/ # 요청·응답만. 비즈니스 로직 금지
├── Services/ # 유스케이스 하나 = 메서드 하나, 트랜잭션은 여기서
├── Repositories/ # DB 접근은 여기서만
├── Models/ # 스키마 정의 + 기본 쿼리
├── DTO/ # readonly class로 요청·응답 구조 고정
├── Enums/ # 상태·유형은 Backed Enum
├── Filters/ # 인증·권한·Rate limit 같은 횡단 관심사
├── Libraries/ # 외부 서비스 연동
└── Exceptions/ # 도메인 예외
규칙은 단순합니다. 컨트롤러는 얇게, 서비스는 하나의 유스케이스만, 모델은 리포지토리를 거쳐서만 씁니다. 다른 서비스를 부를 때는 구현이 아니라 인터페이스에 의존합니다. 그래야 지금은 같은 프로세스 안의 클래스 호출이던 것을, 나중에 HTTP 호출로 바꿔 끼울 수 있습니다.
에러 응답은 "영구 계약"
제품이 늘면서 가장 먼저 표준화한 것은 에러 응답입니다. 제품마다 에러 포맷이 다르면 프론트엔드와 다른 서비스가 제품별 분기 코드를 새로 짜야 하고, 나중에 회수하는 비용이 큽니다.
- 포맷은 RFC 9457 Problem Details를 따르고, CI4 쪽에는 공통 ApiException 계층을 둡니다.
- 에러 코드는
..형식으로 짓습니다. - HTTP 상태는 카테고리, 에러 코드는 원인입니다. 같은 401이라도 토큰 만료와 서명 불일치는 클라이언트가 다르게 처리해야 하기 때문입니다.
- 한 번 배포된 코드 문자열은 바꾸지 않습니다. 메시지는 바꿔도 코드는 못 바꿉니다.
- 5xx 응답에는 내부 정보를 절대 담지 않습니다.
이 규약은 문서로만 두지 않고 Claude Code의 커스텀 스킬(api-error-contract)로 만들었습니다. API에 실패 경로가 생기는 작업이면 AI가 알아서 이 규약을 불러와 적용합니다. 11개 제품의 에러 응답이 같은 모양인 이유는 제가 꼼꼼해서가 아니라, 규칙이 저장소와 스킬에 박혀 있기 때문입니다.
공통 골격 ③: 인증·결제·AI 호출은 한 번만 만든다
제품마다 반드시 들어가지만, 제품의 차별점은 아닌 기능이 있습니다. 인증, 결제, AI 모델 호출입니다. 이 셋은 한 번 제대로 만들고 모든 제품이 같은 방식으로 씁니다.
인증. 토큰 발급과 검증은 AITessera라는 별도 제품으로 만들었습니다. RSA·ECDSA 서명을 쓰고, Redis 기반 슬라이딩 윈도우로 Rate limit을 겁니다. 인증 엔드포인트는 분당 10회, 일반 요청은 분당 120회입니다. 인증은 보안 정책이 바뀌면 모든 제품이 한꺼번에 따라가야 하는 영역이라, 한곳에 모아 두는 게 맞았습니다.
결제. 토스페이먼츠 연동을 한 번 정리해 두고 재사용합니다. AILign에서는 이 위에 일회성 구매, 구독, 크레딧 세 가지 과금을 얹었습니다. 크레딧 차감은 DB 트랜잭션과 멱등성 키를 필수로 걸어, 같은 요청이 두 번 들어와도 두 번 빠지지 않게 했습니다.
AI 호출. 컨트롤러나 도메인 서비스가 모델 API를 직접 부르지 않습니다. 중간에 AiService 계층을 둡니다. AICassa의 구조가 대표적입니다.
Controller → Service → AiService → Repository
AICassa의 세금계산서·영수증 파싱은 Claude Vision을 쓰는데, 기본은 Sonnet, 실패하면 Opus로 넘기는 설정이 AiService 안에 있습니다. 모델을 바꾸거나 OpenRouter 같은 멀티 모델 라우팅으로 옮겨도 도메인 코드는 손대지 않습니다. 모델 가격과 성능이 몇 달마다 바뀌는 지금, 이 계층이 없으면 11개 제품을 매번 뜯어고쳐야 합니다.
MSA 서비스 분할 기준
저는 새 기능을 만들 때 질문 세 개를 순서대로 던집니다. 세 질문에 모두 "아니오"면 떼지 않습니다.

서비스로 떼는 것은 위의 두 경우뿐이고, 나머지는 한 프로세스 안에 경계만 긋습니다.
실행 모델이 다른가. 가장 확실한 분할 신호입니다. PHP-FPM은 요청이 오면 응답하고 끝나는 모델이라, 상시 연결이 필요한 실시간 채팅이나 음성 전송을 맡기면 억지가 됩니다. 이때는 전송 계층만 떼어 Node 같은 다른 런타임에 맡기고, 비즈니스 규칙은 CI4에 남깁니다. 실시간 인터콤을 설계할 때도 같은 원칙으로, CI4는 인증과 룸 토큰 발급만 맡고 시그널링과 미디어는 별도 구성요소로 나눴습니다.
여러 제품이 함께 쓰는가. 인증처럼 모든 제품이 같은 정책을 따라야 하는 기능은 한곳에 모읍니다. 제품마다 복사해 두면 보안 패치를 11번 해야 합니다.
확장 축이 다른가. AI 호출이나 파일 처리처럼 나중에 부하나 비용이 따로 커질 수 있는 기능은, 지금 떼지 않고 경계만 분명히 긋습니다. 요청 컨텍스트에 의존하지 않는 서비스 클래스로 만들어 두면, 필요할 때 그 클래스만 큐와 워커로 옮기면 됩니다.
분리한 서비스에는 두 가지 규칙을 꼭 지킵니다. 첫째, 각 서비스는 자기 DB 스키마를 소유하고 다른 서비스와 DB JOIN을 하지 않습니다. 둘째, 서비스 간 통신은 REST(JSON) 또는 메시지 큐로만 합니다. 이 두 가지가 무너지면 서비스를 나눈 게 아니라 배포만 복잡해진 모놌리스가 됩니다.
사례로 보는 분할 판단
같은 기준을 적용해도 제품마다 답은 달랐습니다. 네 가지 사례를 정리합니다.
|
제품 |
결정 |
이유 |
|---|---|---|
|
AIFid |
CI4 백오피스 + Node 채팅엔진으로 분리 |
상시 연결이 필요한 실시간 전송은 PHP-FPM의 요청·응답 모델과 맞지 않음 |
|
AITessera |
인증을 독립 서비스로 분리 |
여러 제품이 같은 인증을 쓰고, 보안 정책은 한곳에서 바뀌어야 함 |
|
AILign |
떼지 않고 한 코드베이스 안의 플러그인으로 |
고객별로 켜고 끄는 기능일 뿐, 실행 모델과 데이터가 같음 |
|
AICassa |
떼지 않고 AiService 계층으로 경계만 |
지금은 부하가 작지만 AI 호출 비용·지연이 다른 축으로 커질 수 있음 |
AIFid: 처음 구상을 뒤집은 분할
AIFid는 처음에 Node.js 풀스택으로 구상했습니다. 검토 끝에 운영자 어드민과 상담 API는 CI4로 옮기고, Node.js는 WebSocket 전송 계층만 맡기는 하이브리드로 바꿨습니다. 최종 구조는 CI4 백오피스, Node 채팅엔진, React 상담원 콘솔, Preact 위젯 네 덩어리입니다.
핵심은 "Node가 무엇을 하지 않는가"를 정한 것입니다. 테넌트, 권한, 상담 이력 같은 비즈니스 규칙은 전부 CI4에 있고, Node는 메시지를 나르기만 합니다. 그래서 비즈니스 로직은 다른 10개 제품과 같은 골격, 같은 테스트, 같은 에러 규약 안에 머뭅니다. 문서로 정한 M0~M6 로드맵을 그대로 따라 끝낼 수 있었던 것도 경계가 처음부터 분명했기 때문입니다.
AILign: 떼지 않기로 한 결정
AILign은 반대로 일부러 쪼개지 않았습니다. AICreo 한 카피를 호스팅하고, 사용자별로 플러그인을 구매·활성화하는 구조입니다. 견적, 단가, AI 견적 같은 기능은 별도 서비스가 아니라 app/Plugins/{Name}/ 아래의 독립 모듈입니다.
그 대신 모듈 사이의 규칙은 서비스만큼 엄격하게 잡았습니다.
- 플러그인끼리는 CI4 Events로만 통신합니다. 서로의 클래스를 직접 부르지 않습니다.
- 모든 플러그인 테이블은
UserScopedModel트레이트로user_id범위를 강제합니다. 범위가 빠지면 보안 결함으로 취급합니다. - 접근 제어는
EntitlementFilter하나로만 합니다. - 구독이 끝나면 데이터를 잠그지 않고 읽기 전용으로 둡니다.
프로세스는 하나지만 경계는 MSA처럼 그어 둔 셈입니다. 나중에 특정 플러그인만 트래픽이 몰리면, 이벤트로만 소통하던 그 플러그인을 떼어내면 됩니다.
경계만 그어 두고 기다리는 경우
구상 중인 사진 동선 서비스(PhotoTrail)도 같은 방식입니다. 사진을 내려받아 EXIF를 추출하는 PhotoIngestService를 HTTP 요청 컨텍스트에 의존하지 않게 짰습니다. 입력은 미디어 ID 배열과 토큰, 출력은 좌표·시간 배열입니다. 지금은 CI4 안에서 돌지만, 사용자가 늘면 이 서비스만 큐와 워커 컨테이너로 옮기면 됩니다.
운영 골격: 검증은 기계가, 판단은 사람이
코드 골격만큼 중요한 것이 운영 골격입니다. 11개 제품의 브랜치 전략, 품질 검사, 배포 절차가 모두 같아야 혼자서 돌릴 수 있습니다.
- 로컬 품질 게이트. 커밋할 때 PSR-12로 자동 정리하고, dev로 푸시할 때 PHPStan과 전체 테스트를 돌립니다. main으로의 직접 푸시는 막아 둡니다.
- 워크트리별 테스트 DB. 작업 폴더마다 별도 테스트 DB를 씁니다. 워크트리 여러 개에서 AI가 동시에 테스트를 돌려도 서로의 스키마를 건드리지 않습니다.
- 셀프호스티드 CI. 개발 초기에 GitHub Actions 무료 플랜 월 2,000분을 넘겨 CI가 멈춘 적이 있습니다. 그 뒤로 집에 있는 Ubuntu 26.04 서버에 셀프호스티드 러너를 GitHub 조직 단위로 등록하고, Docker로 3대를 돌립니다. 제품이 늘어도 CI 비용은 늘지 않습니다.
- 같은 모양의 배포. 배포는 GitHub Actions에서 SSH로 서버에 접속해 CI4 spark 명령을 실행하는 방식으로 통일했습니다. 키는 저장소별 ED25519 배포 키를 씁니다. 운영 서버는 Ubuntu에 PHP 8.5, 제품은 서브도메인으로 나눕니다(copia·cura·creo.aivance.kr).
- 반복 작업은 스킬로. "이슈 231 개발" 한 줄이면 브랜치 생성, TDD 구현, PR, 머지, 이슈 종료까지 한 흐름으로 끝나도록 이슈 사이클 스킬을 만들었습니다. 이 스킬들은 11개 제품 어디서든 똑같이 동작합니다.
제가 하루에 쓰는 시간의 대부분은 AI가 짠 코드 한 줄 한 줄을 읽는 데가 아니라, 경계가 맞게 그어졌는지 보는 데 갑니다. 코드 정리와 정적 분석과 테스트는 기계가 빠짐없이 하니까요.
마치며
정리하면 1인이 제품 11개를 운영하는 방법은 세 줄입니다.
- 골격은 한 번 만들고 복사한다. 출발점은 ci4-board와 AICreo 두 개, 레이어와 에러 규약은 모든 제품이 같습니다.
- 뗄 이유가 있을 때만 뗀다. 실행 모델이 다르거나, 여러 제품이 함께 쓰거나, 확장 축이 다를 때만 별도 서비스로 분리합니다. 나머지는 경계만 그어 두고 한 프로세스에 둡니다.
- 규칙은 대화가 아니라 저장소에 남긴다. CLAUDE.md, 훅, 스킬에 적어 둔 규칙은 다음 세션에도, 다음 제품에도 그대로 이어집니다.
MSA는 팀을 나누기 위한 구조라는 말이 있습니다. 1인 개발자에게 서비스 하나를 더 떼는 건 배포, 모니터링, 장애 대응을 하나 더 떠안는다는 뜻입니다. 그래서 저는 분리 자체보다 "언제든 분리할 수 있는 상태"를 유지하는 데 공을 들입니다. 26년 동안 쌓은 경험이 가장 많이 쓰이는 곳도 결국 그 경계를 긋는 순간이었습니다.
댓글 0개