공개적으로 SaaS 엔진 구축: 고정된 방식이 아닌 제휴 프로그램
출처: Dev.to
대부분의 시리즈에서 나는 SaaS의 눈에 보이는 부분—인증, 멀티 테넌시, 관리자 콘솔, 청구—을 공개적으로 배포해 왔습니다. 제휴 프로그램은 마지막에 남겨두기로 의도한 부분인데, 특별한 경우에만 구현되고 나중에 후회하게 될 가능성이 가장 높기 때문입니다. 이 글은 그 후회 없이 제휴 프로그램을 마무리하는 방법을 다룹니다.
LaraFoundry는 현재 운영 중인 CRM에서 하나씩 모듈을 추출해 공개하고 있는 재사용 가능한 Laravel SaaS 코어입니다. 코어 자체는 무료이며, 금전과 관련된 모든 기능을 제외하고는 무료로 유지됩니다. 기업이 자체 고객에게 요금을 부과하고 싶을 때만 유료 청구 애드온을 사용합니다. 제휴 프로그램은 명백히 유료 쪽에 속합니다: 유료 매출을 늘리는 수단이므로, 몇 게시물 전에서 다룬 청구 엔진 위에 추가되는 유료 애드온 안에 포함됩니다.
‘완료’가 의미하는 구체적인 내용
- 고유한 추천 코드를 가진 파트너 아이덴티티
- 새로운 테넌트를 해당 파트너에게 연결하는 캡처 링크
- 추천된 테넌트가 결제할 때 선택한 규칙에 따라 발생하는 커미션
- 결제가 환불되거나 실패했을 때 자동 회수(clawback)
- 통화별 합계와 CSV 내보내기를 지원하는 슈퍼‑관리자 지급 콘솔
- 파트너가 자신의 추천 현황과 잔액을 확인할 수 있는 셀프‑서비스 대시보드
이 모든 기능을 314개의 Pest 테스트로 검증했습니다.
두 가지 결정이 모든 코드를 좌우했으며, 바로 이 글의 핵심이기도 합니다.
1️⃣ 쉬운 방법 vs 재사용 가능한 코어
제휴 프로그램을 가장 쉽게 구현하는 방법은 ‘원하는 스키마’를 하드코딩하는 것입니다. 예를 들어 “20 % 재발 결제마다 지급” 같은 고정 규칙을 넣는 것이죠. 하루면 구현이 끝나고, 코어를 사용하는 모든 호스트가 해당 커미션 정책에 묶이게 됩니다. 하지만 재사용 가능한 코어는 그렇게 할 수 없습니다. 그래서 나는 스키마가 아니라 엔진을 만들었고, 서로 독립적인 세 축(axis)으로 구성했습니다.
'affiliate' => [
'enabled' => true,
'eligibility' => 'auto', // auto | self_serve | admin
'commission' => [
'trigger' => 'recurring', // first_payment | recurring | lifetime
'window_months' => 12,
'basis' => 'per_plan', // per_plan | percent | fixed
],
],
-
eligibility(자격): 파트너가 될 수 있는 조건을 정의합니다.
auto: 모든 사용자에게 코드를 자동으로 발급해 누구나 링크를 공유할 수 있게 함self_serve: 사용자가 직접 신청하지만 관리자의 승인을 기다림admin: 관리자가 초대한 파트너만 허용
-
trigger(지급 시점): 어떤 결제에 대해 커미션을 지급할지 결정합니다.
first_payment: 추천당 한 번만 지급되는 일회성 보상recurring: 기본값인 12개월 동안 매 결제마다 지급, 기간이 끝나면 중단lifetime: 영구적으로 지급
-
basis(계산 기준): 금액 산정 방식을 정의합니다.
per_plan: 플랜 사전 정의에 따라 커미션 금액을 읽음percent: 순수익의 일정 비율을 지급fixed: 고정 금액을 지급
파트너별 오버라이드도 가능해 특정 파트너에 대해 비율을 높이거나 낮출 수 있습니다.
세 축이 **직교(orthogonal)**하기 때문에 어떤 조합이든 유효하고, 엔진은 복잡한 특수 케이스로 얽히지 않습니다. 트리거는 결제가 커미션 대상인지 판단하고, 베이시스는 독립적으로 금액을 계산합니다. 기본값은 “모두에게 코드 제공, 1년 동안 재발 결제, 플랜당 가격”이라는 합리적인 설정이지만, 이는 기본값일 뿐 법칙은 아닙니다.
2️⃣ 파트너 프로그램 활성화가 코드 한 줄도 추가하지 않는다
파트너 프로그램을 켜는 것만으로도 호스트 애플리케이션에 PHP 코드를 한 줄도 추가하지 않습니다. 이미 존재하던 두 이벤트에만 연결하면 됩니다.
// 무료 코어, 공개: 테넌트가 생성될 때마다 발생
event(new CompanyCreated($company, $owner));
// 유료 애드온, 결제가 처리될 때마다 발생
event(new CompanyPaymentProcessed($payment));
CompanyCreated는 무료 코어의 일부이며, 청구나 제휴와 무관하게 모든 회원가입 시 발생합니다. 코어 자체 회계에 필요하기 때문이죠.CompanyPaymentProcessed는 청구 애드온이 결제 게이트웨이 웹훅을 통해 결제가 확정될 때 발생합니다. 두 이벤트 모두 제휴 프로그램을 위해 새로 만든 것이 아니라 기존에 있던 것입니다.
제휴 프로그램은 이 두 이벤트에 구독만 하면 됩니다.
Event::listen(CompanyCreated::class, AttributeReferral::class);
Event::listen(CompanyPaymentProcessed::class, AccrueAffiliateCommission::class);
AttributeReferral: 캡처 링크가 만든 쿠키를 읽어 새 회사를 파트너와 연결합니다.AccrueAffiliateCommission: 트리거와 베이시스 로직을 실행해 커미션 레코드를 저장합니다.
두 리스너와 그 뒤의 엔진은 애드온의 전용 부분이므로 여기서는 구현을 보여주지 않고 개념만 설명합니다. 중요한 점은 애드온이 자체 서비스 프로바이더에서 이 리스너들을 affiliate.enabled 플래그에 따라 등록한다는 것입니다. 호스트는 설정 파일에서 플래그를 켜고, 두 개의 Inertia 페이지를 퍼블리시하면 끝납니다. 컨트롤러를 작성하거나, 직접 이벤트를 발생시키거나, 모델을 건드릴 필요가 없습니다. 핵심(core)에는 이미 시점(seam)이 존재하고, 애드온은 그 시점에 끼워지는 형태입니다.
3️⃣ 추천 링크와 최초 고정(Attribution)
추천 링크는 /r/{code} 형태입니다. 방문하면 암호화된 쿠키가 설정되고 바로 리다이렉트됩니다. 리다이렉트는 코드가 유효하든 아니든 동일한 위치로 이동하므로, 엔드포인트가 유효 코드를 열거할 수 있는 오라클이 되지 않습니다. 이후 방문자가 회사를 생성하면 AttributeReferral가 작동합니다.
- Attribution(속성 부여)은 한 번만 이루어집니다. 첫 번째 파트너가 회사를 추천하면 그 파트너에게 고정되고, 이후 다른 링크가 동일 테넌트를 가로채지 못합니다. 이는
referred_company컬럼에 고유 제약을 두었기 때문입니다. - Self‑referral(자기 추천)은 차단됩니다. 파트너가 자신의 코드로 가입할 수 없습니다.
first_payment트리거일 경우 추천당 한 번만 보상이 지급됩니다. 웹훅이 순서가 뒤섞이더라도 두 번 지급되지 않도록 멱등성(idempotency) 키를 “추천당 하나”로 설계했습니다. 이 부분은 실제 버그가 있었고, 리뷰 과정에서 발견돼 수정되었습니다.
4️⃣ 통화별 커미션 관리
커미션은 정수형 마이너 단위(예: 센트)로 통화별로 저장되며, 별도의 환율 변환 로직이 없습니다. 이는 청구 엔진이 따르는 규칙과 동일합니다. 유로, 폴란드 즈워티, 달러 등 여러 통화로 결제된 고객을 추천한 파트너는 세 개의 별도 잔액을 갖게 되며, 콘솔에서도 통화별 합계가 각각 표시됩니다.
5️⃣ 수동 지급(Payout)은 의도된 기능
수동 지급은 기능이지 결함이 아닙니다. 애드온은 실제 돈을 출금하는 결제 제공자를 포함하지 않으며, 그런 척도 하지 않습니다. 대신 운영자에게 ‘누구에게 얼마를 언제 지급해야 하는지’에 대한 정확한 보고서와 상태 머신을 제공합니다.
- 커미션이 발생하면 pending 상태가 됩니다.
- 운영자는 일괄 승인( pending → approved )하고, 실제로 돈이 나갔을 때 paid 로 전환하면서 지급 참조 번호를 입력합니다.
- 환불이나 결제 실패 시, 해당 인보이스와 연결된 커미션은 자동으로 clawback(회수)됩니다. 단, 이미 지급된 경우에는 인간이 직접 조정하도록 남겨둡니다.
6️⃣ CSV 내보내기와 보안
콘솔은 현재 필터링된 보고서를 CSV 파일로 내보냅니다. 이 CSV는 스프레드시트 공식 인젝션을 방지하기 위해, 위험한 문자(=, +, -, @)로