Steven
Steven4 분 소요

하나의 코드베이스, 두 개의 앱: 포크 없이 화이트라벨링하는 방법

GeekBye와 Pavleur는 단일 저장소에서 만들어진, 서로 다르게 브랜딩된 두 개의 데스크톱 앱입니다 — 포크도 없고, 코드베이스를 복제하지도 않았습니다. 이것이 하나의 코드베이스를 두 개의 제품으로 컴파일하게 만드는 빌드 타임 장치이며, 우리의 두 번째 앱이 잘못된 이름으로 자신을 소개하게 만든 한 줄짜리 버그의 이야기입니다.

엔지니어링
아키텍처
빌드
GeekBye 릴리스
하나의 코드베이스, 두 개의 앱: 포크 없이 화이트라벨링하는 방법

GeekBye는 회의 노트 필기 앱입니다. Pavleur는 AI 면접 연습 앱입니다. 둘은 서로 다른 두 제품처럼 보입니다 — 왜냐하면 실제로 서로 다른 두 제품이기 때문입니다 — 하지만 둘은 하나의 코드베이스에서 만들어지며, 포크도 없고 갈라져 나가는 브랜치도 없습니다. 이것이 화이트라벨링의 엔지니어링 측면입니다: "왜 브랜드 앱을 파는가"(그건 다른 글입니다)가 아니라, 실제로 어떻게 하나의 저장소를 두 개의 앱으로 컴파일하는가 — 그리고 찾아 나서기 전까지 모든 화이트라벨 시스템에 숨어 있는, 화려하지 않은 버그의 이야기입니다.

분기하지 말고, 생성하라

하나의 코드베이스에서 두 제품을 돌리는 잘못된 방법은 앱 곳곳에 if (product === 'pavleur')를 뿌리거나, 포크한 뒤 두 사본이 절대 갈라지지 않기를 기도하는 것입니다. 둘 다 빠르게 썩습니다.

GeekBye의 접근법은 빌드 타임 코드 생성입니다. 각 제품은 config 파일을 가집니다 — configs/geekbye/config.ts, configs/pavleur/config.ts — 그 안에 정체성을 담고 있습니다: 이름, 번들 id, URL 프로토콜, 자동 업데이트용 릴리스 저장소 등등. 빌드 전에 하나의 스크립트가 실행됩니다:

node scripts/build-product.js pavleur

그 스크립트는 선택된 제품의 config를 읽고 세 가지 구체적인 일을 합니다:

  1. 두 개의 config 모듈을 생성한다 — 하나는 렌더러용(src/config/product.ts), 하나는 메인 프로세스용(electron/config/product.ts) — 각각에 "수동으로 편집하지 말 것 — 변경 사항은 덮어쓰입니다" 헤더가 찍힙니다. 앱의 나머지는 그저 config에서 productName, applicationId, protocol을 임포트할 뿐, 자신이 어떤 브랜드인지 전혀 생각하지 않습니다.
  2. 해당 브랜드의 에셋만 복사한다 — 아이콘, 작은 로고, 토스트 알림 아이콘. GeekBye 빌드는 물리적으로 Pavleur 아트를 하나도 담지 않으며, 그 반대도 마찬가지입니다. 새어 나갈 것이 없습니다.
  3. package.json을 다시 쓴다(릴리스 빌드에서) — 앱 id, 제품 이름, URL 프로토콜, 배포할 GitHub 릴리스 저장소, 그리고 반갑게도 브랜드가 반영된 마이크 권한 텍스트까지 — macOS가 "Pavleur가 마이크에 접근하려 합니다"라고 묻고, 잘못된 브랜드를 내밀지 않도록.

앱 전체는 브랜드에 무관하고, 브랜드는 빌드 타임에 주입됩니다. 런타임 제품 전환을 잘못할 여지가 없습니다. 왜냐하면 런타임에는 구워 넣어진 하나의 제품만 존재하기 때문입니다.

하나의 backend, 두 개의 브랜드

두 앱은 같은 backend와 통신합니다. 그렇다면 어떤 요청이 어떤 제품 — 그리고 어떤 가격, 프롬프트, 한도 — 에 속하는지 서버는 어떻게 알까요?

두 개의 신호가 있고, 둘 다 빌드 타임에 설정됩니다. 첫째, config에 구워 넣어져 요청과 함께 전송되는 application id(geekbye 또는 pavleur)로, backend가 올바른 제품의 동작으로 라우팅할 수 있게 합니다. 둘째, 클라이언트가 모든 backend 호출에 붙이는 User-Agent로, Product/version (platform) 형태입니다 — 예를 들어 Pavleur/1.2.5 (win32)GeekBye/1.7.3 (darwin). 이 헤더 하나로 backend는 별도의 분석 필드 없이 제품별 그리고 OS별 구분을 공짜로 얻습니다. 한편 자동 업데이트는 분리된 채로 유지됩니다: 각 제품은 자기 자신의 릴리스 저장소에 배포하므로, GeekBye 업데이트가 Pavleur 설치본에 전달될 일은 절대 없습니다.

온보딩도 제품마다 다릅니다 — GeekBye의 마법사는 회의 전사에 관한 것이고, Pavleur의 것은 면접 연습에 관한 것입니다 — 그리고 올바른 쪽이 구워 넣어진 단일 application id로부터 런타임에 선택됩니다. 같은 장치, 두 개의 현관입니다.

버그: Pavleur가 자신을 GeekBye라 불렀을 때

여기 전쟁 이야기가 있고, 이것은 모든 화이트라벨 시스템이 나름의 버전을 가진 이야기입니다.

config 생성은 작동했습니다. 아이콘은 교체됐습니다. 마이크 프롬프트는 브랜드가 반영됐습니다. 프로토콜과 번들 id는 정확했습니다. 눈에 보이는 모든 척도로 Pavleur 빌드는 Pavleur였습니다. 그런데도 한동안, 모든 Pavleur 빌드가 자신을 GeekBye라 부르며 backend에 전화를 걸고 있었습니다.

범인은 하드코딩된 문자열 하나였고, 상상할 수 있는 가장 화려하지 않은 곳에 숨어 있었습니다: 네트워크 리트라이 헬퍼 안에 끼워진 User-Agent 빌더입니다. 사용자에게 보이는 모든 것은 config를 거치도록 되어 있었지만, 이 하나의 깊은 배관 유틸리티만은 여전히 `GeekBye/${version}` 리터럴을 품고 있었습니다. 아무도 리트라이 유틸리티를 "브랜딩"이라고 생각하지 않기에, 아무도 그것을 확인하지 않았습니다. 수정은 네 줄이었습니다 — config에서 동적 productName을 임포트해 하드코딩된 브랜드 대신 그것을 삽입하는 것 — 하지만 교훈이야말로 핵심입니다:

화이트라벨링은 "로고가 바뀌었는가"가 아니다. "모든 식별 문자열이 config를 거치는가"이다. 그리고 마지막 잔당은 당신이 살펴볼 UI에는 결코 살지 않는다. 그것은 User-Agent, 딥링크 프로토콜 핸들러, 권한 프롬프트 문구, 에러 메시지 — 아무도 "브랜드"로 분류하지 않는 배관에 산다. 유일하게 믿을 만한 방어는 브랜드 리터럴을 능동적으로 사냥하는 대상으로 취급하는 것이다: 코드베이스에서 하드코딩된 이름을 grep하고, 그것을 정의해야 할 config 바깥 어디든 나타나면 빌드를 실패시켜라.

화이트라벨링이 우리에게 가르쳐 준 세 가지

  1. 분기하지 말고, 생성하라. 두 개의 작은 config 모듈에 대한 빌드 타임 코드 생성과 선택적 에셋 복사는 포크와 런타임 제품 체크의 덤불 둘 다를 이긴다. 앱은 브랜드에 무관한 채로 남고, 브랜드는 빌드 입력이다.
  2. 식별 문자열은 배관으로 새어 든다. UI는 쉬운 90%다. 어려운 10%는 User-Agent, URL 프로토콜, 마이크 권한 문자열, 에러 문구다. 그것들을 모두 하나의 productName / applicationId 익스포트를 거치게 하라 — 그리고 원시 브랜드 이름을 CI 게이트로 grep해서 떨어져 나온 리터럴이 절대 출시되지 못하게 하라.
  3. 하나의 backend, 여러 브랜드, id와 헤더로 구분하라. 빌드 타임 application id에 Product/version (platform) User-Agent를 더하면, 중복된 인프라 없이 제품별·플랫폼별 구분이 손에 들어온다 — 불변의 빌드 타임 정체성을 런타임 동작으로부터 깔끔하게 분리하면서.

자기 브랜드로 화이트라벨 노트 필기 앱을 운영하는 것의 제품과 프라이버시 측면에 대해서는 화이트라벨 AI 노트 필기 앱을 보세요. 이것은 GeekBye v2가 되는 v1 이야기의 여섯 번째 장입니다 — 이전 장은 127커밋 릴리스란 실제로 무엇인가(v1.7.0)이고; 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.

관련 글

CSS 변수 하나, 다섯 번의 코드 리뷰, 그리고 거짓말을 한 Swift 툴체인
Steven
Steven5 분 소요

CSS 변수 하나, 다섯 번의 코드 리뷰, 그리고 거짓말을 한 Swift 툴체인

GeekBye v2.0.7은 오버레이 전체를 조절 가능한 반투명으로 만들었습니다 — 한 줄짜리 CSS 변경처럼 들리지만 절대 그렇지 않았습니다. 진짜 이야기는 코드 리뷰가 잡아낸 것들입니다: 흐려지기를 거부한 두 개의 표면, 같은 불투명도인데도 잘못 보이던 녹음 바, 그리고 우리 자신의 문서가 리뷰어에게 잘못된 Swift 버전을 알려준 탓에 672바이트가 왔다 갔다 한 컴파일된 바이너리.

엔지니어링
디자인
빌드
오디오 코드 5,000줄을 지웠더니 — 회의록이 두 번씩 나타나기 시작했다
Steven
Steven5 분 소요

오디오 코드 5,000줄을 지웠더니 — 회의록이 두 번씩 나타나기 시작했다

GeekBye v1.6은 온디바이스 Swift 전사기 두 개를 뜯어내고 하나의 통합 파이프라인으로 교체했습니다 — 순삭 5,000줄이 넘는 작업을, 사람들이 앱에서 한창 회의 중일 때 해냈습니다. 그러자 회의록이 두 번씩 나타나기 시작했습니다. 버그가 여전히 엔진이 두 개라고 믿고 있던 그 한 층으로 옮겨 갔기 때문입니다.

엔지니어링
전사
아키텍처
127개 커밋 릴리스란 실제로 무엇인가
Steven
Steven5 분 소요

127개 커밋 릴리스란 실제로 무엇인가

GeekBye v1.7.0은 열하루 동안 127개의 커밋이었습니다. 바깥에서 보면 자잘한 것 백 가지처럼 보입니다. 안에서 보면 그것은 함께 엮인 두 개의 큰 기능이었습니다 — 그리고 그중 하나는 엉뚱한 곳에 지어졌다가, 릴리스 도중에 뜯겨 나가 다시 지어졌습니다. 여기 큰 릴리스의 해부가 있습니다.

엔지니어링
릴리스
아키텍처