프로토타입을 넘어 본격적인 기능 개발에 들어갔습니다.
그중 가장 고민이 많았던 부분이 캐시백 정책 DB 설계였습니다.
1. 캐시백 정책은 생각보다 복잡했다
기획 단계에서 정리된 요구사항은 대략 이랬습니다.
- 브랜드마다 캐시백 비율이 다를 수 있음
- 같은 브랜드라도 상품 종류에 따라 비율이 달라질 수 있음
- 일반 캐시백 / 상향 캐시백 존재
- 인플루언서는 일반 사용자와 다른 혜택을 받을 수 있어야 함
예를 들면 이런 식이었습니다.
- 뷰티 브랜드A: 신규 고객 35%, 기존 고객 7.5% (상향 캐시백: 신규 고객 65%, 기존 고객 8%)
- 뷰티 브랜드B: 스킨케어 15%, 메이크업 10%, 향수 5%
- 뷰티 브랜드C: 전 상품 0.5% (인플루언서는 5%)
각 브랜드마다 완전히 다른 캐시백 정책이 있을 수 있다보니, 어떻게 DB를 설계해야할지 머리가 복잡했습니다.
더 어려운 건, 기획 단계에서 확정된 게 거의 없었다는 겁니다. 앞으로 어떤 조건이 추가될지, 어떤 브랜드가 어떤 정책을 가져올지 알 수 없었습니다.
요일, 구매 금액, 결제 수단 등 무한히 늘어날 수 있는 조건들을 어떻게 설계해야할까 고민했습니다.
2. 확장성을 고려한 DB 설계
방법 1) 컬럼 늘리기
처음 떠올린 방법은 가장 직관적인 방식이었습니다.
유저 타입, 상품 타입, 신규/기존 여부, 상향 여부 등 컬럼을 계속 추가하는 것이었죠.
하지만 조금 생각해보니 바로 한계가 보였고, 컬럼 몇 개로 해결할 문제가 아니라고 판단했습니다.
방법 2) JSONB RuleEngine 패턴
조사하다 보니 Rule Engine 패턴이라는 걸 알게 됐습니다.
정책 조건을 JSON 형태로 저장하고, 백엔드에서 이 JSON을 파싱해서 조건을 평가하는 방식입니다.
마침 PostgreSQL은 다른 RDBMS에 비해 JSONB를 다루는 것에 강점이 있었습니다. 단순히 JSON을 텍스트로 저장하는 게 아니라 Binary 형태로 저장하고, GIN 인덱스를 통해 빠르게 검색할 수 있기 때문입니다.
[GIN 인덱스]
GIN 인덱스(Generalized Inverted Index)는 JSONB, 배열 같은 '복합 데이터'를 빠르게 검색해줍니다.
일반 B-tree처럼 '하나의 값'이 아니라 '하나의 JSONB 안에 여러 값'들을 키워드별로 찾아줍니다.
JSON vs JSONB
- JSON (텍스트 그대로 저장)
입력: {"field": "user_status", "operator": "EQUAL", "value": "NEW_CUSTOMER"} DB에 저장: '{"field": "user_status", "operator": "EQUAL", "value": "NEW_CUSTOMER"}' ← 문자열 그대로- 장점: 입력 그대로 보존 (공백, 순서, 중복 키)
- 단점: 매 쿼리마다 파싱해야 해서 느림
- JSONB (바이너리 저장)
입력: {"field": "user_status", "operator": "EQUAL", "value": "NEW_CUSTOMER"} DB에 저장: [바이너리 데이터] 내부적으로 "field" → 키 번호 1번 "user_status" → 문자열 포인터 "operator" → 키 번호 2번 "EQUAL" → 문자열 포인터 "value" → 키 번호 3번 "NEW_CUSTOMER" → 문자열 포인터- 장점: 파싱이 되어있어 쿼리에 바로 사용 가능해서 빠름, 압축되어 있어 용량이 작음
- 단점: 쿼리가 복잡해져 디버깅이 어려움
B-tree 인덱스 vs GIN 인덱스
- B-tree 인덱스(일반 컬럼용)
-- 예: brand_id로 검색 (단일 값) CREATE INDEX idx_brand ON cashback_policy(brand_id); SELECT * FROM cashback_policy WHERE brand_id = 101;-- 동작 방식 B-tree 구조: [105] / \ [102] [108] / \ / \ [101][103][106][109] brand_id = 101 찾기 → 105 → 102 → 101 (3단계)-- JSONB 전체를 B-tree로 인덱싱하는 경우 -- JSONB 전체 내용이 완전히 똑같아야만 매칭, 키 순서 하나만 달라도 못 찾음 CREATE INDEX idx_rules_btree ON cashback_policy(condition_rules); WHERE condition_rules = '{"field": "user_status", "operator": "EQUAL", "value": "NEW_CUSTOMER"}';
- GIN 인덱스(복합 데이터용)
CREATE INDEX idx_policy_rules ON cashback_policy USING GIN (condition_rules); -- "field"가 "user_status"인 정책 찾기 SELECT * FROM cashback_policy WHERE condition_rules @> '{"field": "user_status"}'; -- "value"가 "NEW_CUSTOMER"이거나 "SKINCARE"인 정책 찾기 WHERE condition_rules @> '{"value": "NEW_CUSTOMER"}' OR condition_rules @> '{"value": "SKINCARE"}';-- 동작 방식 GIN 인덱스 구조: [키워드] → [문서(정책) ID들] "field" → [1, 2, 3, 4, 5] (모든 정책이 field 키 가짐) "user_status" → [1, 2] (정책 1, 2가 이 값 가짐) "product_type" → [3, 4] (정책 3, 4가 이 값 가짐) "NEW_CUSTOMER" → [1] (정책 1) "EXISTING_CUSTOMER"→ [2] (정책 2) "SKINCARE" → [3] (정책 3) "MAKEUP" → [4] (정책 4) 검색: {"field": "user_status"} 1. "field" 키워드 → 정책 [1,2,3,4,5] 2. "user_status" 값 → 정책 [1,2] 3. 교집합 → 정책 [1,2] 반환
방법 3) 단일 조건 정책 테이블
각 정책에 대해 condition_type, condition_value 컬럼의 조합으로 모든 조건을 일반화하는 방식으로, 조건은 단일 조건만 허용합니다.
- condition_type: 비교 기준 (고객 유형, 상품 유형, 조건 없음 등)
- condition_value: 기준에 대한 값 (신규 고객, 스킨케어 등)
| id | brand_id | policy_name | condition_type | condition_value | regular_rate | upward_rate |
| 1 | 101 | 신규 고객 | USER_TYPE | NEW_CUSTOMER | 20.00 | 30.00 |
| 2 | 101 | 기존 고객 | USER_TYPE | EXISTING_CUSTOMER | 10.00 | 15.00 |
| 3 | 102 | 스킨케어 | PRODUCT_TYPE | SKINCARE | 15.00 | null |
| 4 | 102 | 메이크업 | PRODUCT_TYPE | MAKEUP | 10.00 | null |
| 5 | 103 | 전체 상품 | ALL | null | 0.50 | 4.00 |
3. 최종 선택: 단일 조건 정책 테이블
고민하다가, 현재 계약하려는 브랜드들의 정책을 다시 자세히 봤습니다.
서비스의 메인 카테고리는 뷰티이고, 확장되더라도 헬스, 여행 정도였습니다. 샵백을 벤치마킹하면서 뷰티 브랜드들의 정책을 살펴보니 패턴이 보였습니다.
대부분의 정책이 단일 조건 비교면 충분했습니다.
'주말에 스킨케어 제품을 10만원 이상 구매한 신규 고객' 같은 복잡한 정책(AND/OR 조합)은 당장 계획에 없었습니다.
JSONB Rule Engine은 미래에 어떤 정책이든 대응이 가능합니다. 하지만 지금 당장 필요 없는 복잡도로 인해 개발 시간이 오래 걸릴 것으로 판단됐습니다. 또한 파싱 오류로 인한 디버깅의 어려움, 백오피스 구현, 복합 조건 테스트 케이스 등을 고려했을 때, 지금 단계에선 부담이 컸습니다.
이에 반해 단일 조건 정책 테이블은 이해하기 쉽고, 빠르게 개발할 수 있습니다. 충분히 현재 요구사항을 만족하며 디버깅도 용이합니다. 개발 속도가 중요한 상황에서 미래를 대비한다는 명목으로, 지금 당장 필요 없는 복잡한 구조를 만드는 것이 오히려 리스크라고 판단했습니다.
[확장 가능성]
나중에 복잡한 정책이 생길 경우를 생각해봤습니다.
- Enum 추가
- 새로운 조건 유형이 생기면 condition_type에 추가하면 됩니다.
-- 요일별 캐시백 ALTER TYPE condition_type ADD VALUE 'DAY_OF_WEEK'; -- 구매 금액별 차등 ALTER TYPE condition_type ADD VALUE 'PURCHASE_AMOUNT'; - 복합 조건이 필요한 경우
- 복잡한 AND/OR 조합이 필요해지면 그 때 JSONB로 마이그레이션합니다.
- 기존 컬럼인 condition_type, condition_value가 이미 field, value의 개념이기 때문에 변경하기 용이합니다.
-- 기존 데이터를 JSONB로 변환 ALTER TABLE cashback_policy ADD COLUMN condition_rules JSONB; UPDATE cashback_policy SET condition_rules = jsonb_build_object( 'field', condition_type, 'operator', 'EQUAL', 'value', condition_value );
모든 정책을 한 번에 바꾸지 않고 점진적으로 전환하는 방법도 있습니다.
- 단순한 정책: 기존 구조 유지
- 복잡한 정책: JSONB 사용
4. 확장성의 진짜 의미
개발 과정에서 확장성을 위해 처음에는 JSONB Rule Engine을 고려했습니다.
하지만 "이게 정말 지금 필요한가?" 의문이 들었습니다.
현재 요구사항을 다시 봤을 때, 단순한 구조로도 충분했습니다. 그리고 단순한 구조는 개발이 빠르고 이해하기도 쉽습니다.
더군다나 사수가 없기 때문에, 스스로 책임질 수 있는 선택을 하는 게 더 중요하다고 생각했습니다.
이번 설계를 하면서 가장 크게 느낀 건 이거였습니다.
확장성은 무한히 복잡하게 만드는 것이 아니라, 필요할 때 확장할 수 있게 만드는 것입니다.
지금 당장 모든 경우의 수를 대비할 필요는 없습니다. 팀과 일정을 고려했을 때 감당 가능한 변화가 어느정도인지를 파악하는 것이 중요합니다.
그렇게 현재 요구사항을 만족하면서 나중에 확장하기 쉬운 구조를 만드는 것이, 진짜 확장 가능한 설계라고 생각합니다.
'개발 > 주니어 개발자의 캐시백 앱 단독 개발기' 카테고리의 다른 글
| 5. 인프라 입문: Android HTTP 차단과 HTTPS 적용기 (1) | 2026.01.21 |
|---|---|
| 4. 갑자기 해외 서비스도 추가된다고? (0) | 2026.01.19 |
| 3. 앱 소셜 로그인의 첫 관문 - SHA-1과 키 해시 (0) | 2026.01.07 |
| 1. 웹에서는 당연했던 SVG가 앱에서는 당연하지 않았다 (0) | 2025.12.24 |
| 0. 기록을 남기기로 한 이유 (5) | 2025.12.22 |