DABOM 팀 | URECA 프로젝트 | 2026
한 줄 요약: 100만 가족 구성원의 공유 데이터를 실시간으로 집계하고, 부모/관리자가 설정한 정책(차단/한도/시간대 제한)을 100ms 이내에 즉시 반영하는 이벤트 기반 데이터 제어 시스템
팀 구성: DABOM 팀 7인 (BE 5 + FE 2) — 김동혁, 나원빈, 박예진, 백승우, 임지우, 장예찬, 최승언
- 프로젝트 소개
- 프로젝트 규모 및 목표 수치
- 사용자 정의 및 권한 모델
- 핵심 시나리오
- 정책 유형 정의
- 기능 요구사항
- 시스템 아키텍처 — 기술 스택 상세, 백엔드 프로젝트 구조, 서비스별 도메인 구성
- 핵심 컴포넌트 상세 설계 — processor-usage 상태 판정, 정책 동기화, DB 영속화 상세
- 이벤트 백본 설계 (Kafka) — 5개 토픽 (usage-realtime 포함)
- 데이터 플로우 (E2E)
- 동시성 제어 전략
- 데이터 모델 — RedisTemplate 구성, 캐시 전략
- API 명세 — api-core + api-notification SSE 분리, JWT AOP
- 프론트엔드 아키텍처
- 비기능 요구사항 — 코드 품질, Observability, 환경 변수, 빌드
- 인프라 아키텍처
- 테스트 전략
- 알림 정책
- 구현 로드맵 (7주)
- R&R (역할 분담)
- 용어집 (Glossary)
실시간 가족 데이터 통합 관리 및 스마트 제약 시스템 (Data Policy Control System, DPCS)
- 가족 구성원 간의 데이터를 실시간으로 공유하고, 부모가 자녀의 데이터 사용을 제어하는 니즈 증가
- 통신사의 가족 데이터 공유 서비스(SKT 데이터 공유, KT 패밀리 박스, LG U+ 데이터 공유) 벤치마킹
- 대규모 트래픽 처리, 동시성 제어, 실시간 정책 반영 기술 학습
| 영역 | 학습 목표 |
|---|---|
| 백엔드 | 대용량 이벤트 스트리밍 파이프라인, 동시성 제어(정합성), 분산락/원자 연산, 장애 및 재처리(Idempotency) 설계 경험 |
| 프론트엔드 | 실시간 UI(대시보드), 실시간 알림/상태 반영(SSE) 경험, PWA 푸시 알림 |
| 항목 | 값 | 비고 |
|---|---|---|
| 가상 사용자 수 | 1,000,000명 | 시뮬레이션 대상 |
| 가족 그룹 수 | 250,000개 | 그룹당 평균 4인, 최대 10명 |
| 이벤트 처리량 | 5,000 TPS | 초당 Usage Event |
| 차단 판정 지연 | 100ms 이하 | P99 기준 |
| 테스트 커버리지 | 70% | 필수: 동시성 Lua Script, 정책 엔진, Idempotency |
| 이벤트 발생 방식 | simulator-usage | Go 기반 시뮬레이터 |
- 본인의 실시간 데이터 사용량 조회
- 가족 전체 잔여 데이터 조회
- 가족 구성원별 사용 비중 시각화 조회
- 알림 수신 (잔여량 경고, 차단 알림 등)
- Owner가 설정한 정책의 적용을 받음
일반 구성원 기능 포함 + 관리 권한:
- 구성원별 데이터 한도 설정 (슬라이더 UI)
- 특정 사용자 실시간 차단/해제
- 시간대별 데이터 사용 차단 정책 설정 (예: 야간 22시~07시)
- 알림 임계값 설정 (50%, 30%, 10%)
- 가족 구성원 추가/삭제/권한 변경
- 다른 구성원을 OWNER로 승격 가능
왜 복수 Owner? 실제 가정에서 부모 양쪽 모두 자녀 데이터를 제어할 수 있어야 한다. 단일 Owner 제약은 "아빠가 설정했는데 엄마가 변경할 수 없다"는 비현실적 상황을 만든다.
왜 Last Write Wins? 복수 Owner 간 충돌 해결을 위해 분산 합의(Paxos/Raft) 수준은 과도하다.
audit_log로 모든 변경 이력을 추적할 수 있으므로 단순한 LWW가 적절하다.
- 정책 템플릿 CRUD (생성/조회/수정/삭제)
- 개별 가족 정책 직접 수정 (고객 요청 시)
- 가족 그룹별 데이터 사용량 조회 및 리포트
- 사용자/그룹 검색 및 상세 조회
- 가족 구성원 권한 관리 (Owner 계정 지정/해제)
- 시스템 모니터링 및 감사 로그(Audit Log) 조회
상황: 김씨 가족(부모 2명, 자녀 2명)이 공유 데이터 잔여량 10MB인 상태에서 4명이 동시에 데이터를 사용하려 함
[T0] 초기 상태
├─ 가족 잔여량: 10MB
└─ 동시 요청 발생:
├─ 아빠: 5MB 사용 요청 (뉴스 앱)
├─ 엄마: 3MB 사용 요청 (메시지)
├─ 자녀1: 8MB 사용 요청 (유튜브)
└─ 자녀2: 4MB 사용 요청 (게임)
[T1] simulator-usage → Kafka (직접 발행)
├─ 4개의 Usage Event 동시 발행
├─ simulator-usage가 eventId 부여 (UUID v4, 중복 처리 대비)
└─ Kafka 토픽(usage-events)으로 직접 발행
└─ 파티션 키: familyId (가족 단위 순서 보장)
[T2] processor-usage 처리 (검증 + 정책 평가 + 쿼터 차감)
├─ usage-events 소비 (동일 파티션 → 순차 처리)
├─ 이벤트 검증 + 중복 체크 (Redis SETNX)
├─ Redis 원자 연산 (Lua 스크립트)
│ ├─ 잔여량 확인: 10MB
│ ├─ 아빠 5MB 차감 → 잔여 5MB ✅ (선착순 완전 승인)
│ ├─ 엄마 3MB 차감 → 잔여 2MB ✅
│ ├─ 자녀1 8MB 요청 > 잔여 2MB → 차단 ❌
│ └─ 자녀2 4MB 요청 > 잔여 2MB → 차단 ❌
└─ notification-events 토픽으로 통합 발행
[T3] 실시간 반영
├─ Redis 상태 업데이트 (즉시)
├─ RDS 이력 저장 (Write-Behind 비동기)
├─ 알림 발송 (PWA 푸시)
└─ SSE를 통해 대시보드 실시간 업데이트
sequenceDiagram
autonumber
participant K as Kafka
participant PE as Policy Engine
participant R as Redis (Lua)
participant NS as Notification Service
Note over K, NS: 잔여 데이터 10MB 상황
K->>PE: User A, B, C, D (각 요청) 순차 전달
PE->>R: [A] 10MB >= 5MB? -> 승인 (남은 5MB)
PE->>R: [B] 5MB >= 3MB? -> 승인 (남은 2MB)
PE->>R: [C] 2MB >= 8MB? -> 거절!
PE->>R: [D] 2MB >= 4MB? -> 거절!
PE->>NS: User C, D에게 즉시 차단 알림 전송
왜 선착순 완전 승인(부분 승인 안 함)? 부분 승인은 "3MB만 허용"처럼 요청을 잘라야 하므로 앱 레벨에서 중간 상태 처리가 복잡해진다. 통신사 실제 정책도 잔여량 부족 시 전체 거부 방식이다.
상황: Owner가 자녀1의 월별 한도를 2GB → 500MB로 축소. 자녀1은 이미 이번 달 1.2GB 사용 중
[T0] Owner가 web-service(Owner 모드)에서 한도 변경
├─ UI: 자녀1 한도 슬라이더 → 500MB
└─ API 호출: PATCH /families/policies
[T1] api-core 처리
├─ RDS POLICY_ASSIGNMENT 갱신
├─ policy-updated 이벤트 발행 (Kafka)
└─ 응답: 200 OK
[T2] processor-usage 정책 즉시 반영
├─ policy-updated 이벤트 수신
├─ Redis constraints Hash 갱신
│ └─ HSET family:{id}:customer:{cid}:constraints LIMIT:DATA:MONTHLY 536870912
├─ 현재 사용량(1.2GB) vs 신규 한도(500MB) 비교
├─ 사용량 > 한도 → 즉시 차단
│ └─ HSET family:{id}:customer:{cid}:constraints BLOCK:ACCESS "1"
└─ notification-events 발행 (CUSTOMER_BLOCKED)
[T3] 실시간 반영
├─ 자녀1 앱: 즉시 차단 화면 표시 (차단 사유 + 부모 연락처)
├─ 가족 대시보드: 자녀1 상태 "차단됨"으로 변경
└─ 자녀1에게 PWA 푸시: "데이터 한도 초과로 차단되었습니다"
왜 소급 적용 + 즉시 차단? 정책 변경 후 "다음 이벤트부터"만 적용하면, 이미 한도를 초과한 사용자가 차단되지 않는 허점이 발생한다. 부모의 제어 의도를 즉시 반영하기 위해 소급 적용이 필수이다.
상황: 부모가 자녀들에게 밤 22시 ~ 아침 07시 데이터 사용 차단 정책 설정
[T0] 정책 설정
├─ 부모 → web-service에서 시간대 차단 정책 등록
├─ 대상: 자녀1, 자녀2
├─ 차단 시간: 22:00 ~ 07:00 (서버 시간 KST 기준)
└─ api-core → RDS 저장 + policy-updated 발행
[T1] 22:00 도달 (정책 활성화)
├─ Lua Script가 constraints의 BLOCK:TIME:START/END 확인
├─ 현재 시각(2200) >= 시작(2200) → 시간대 차단 활성
└─ 알림: "야간 차단 정책이 활성화되었습니다"
[T2] 차단 중 데이터 요청
├─ 자녀1 → simulator-usage → Kafka (usage-events)
├─ processor-usage → Lua Script 실행
│ └─ constraints BLOCK:TIME:START/END 확인 → "BLOCKED_TIME" 반환
└─ 결과: 차단 (해제 예정: 07:00)
[T3] 07:00 도달 (정책 비활성화)
├─ Lua Script가 현재 시각(0700) >= END(0700) 확인 → 차단 해제
└─ 알림: "야간 차단이 해제되었습니다"
왜 KST 고정? 사용자별 타임존 처리는 Lua Script 복잡도를 높이고, 국내 서비스 대상이므로 KST 단일 기준으로 통일한다.
| 임계치 | 알림 대상 | 중복 방지 |
|---|---|---|
| 50% | 전체 가족 | Redis family:{fid}:alert:threshold:50 |
| 30% | 전체 가족 | Redis family:{fid}:alert:threshold:30 |
| 10% | 전체 가족 | Redis family:{fid}:alert:threshold:10 |
왜 임계치당 1회만? 잔여량이 경계 근처에서 오르내릴 때 알림 폭풍을 방지한다. Redis TTL 기반 발송 기록으로 간단하게 구현한다.
| 정책 유형 | 설명 | 우선순위 |
|---|---|---|
| 월별 한도 정책 (MONTHLY_LIMIT) | 구성원별 월별 데이터 한도 설정 | 높음 |
| 즉시 차단 정책 (MANUAL_BLOCK) | Owner가 특정 구성원을 즉시 차단/해제 | 높음 |
| 시간대 차단 정책 (TIME_BLOCK) | 특정 시간대(야간 등) 자동 차단 (KST 기준) | 중간 |
| 임계값 알림 정책 | 잔여량 기준(50/30/10%) 알림 발송, 임계치당 1회 | 높음 |
| 정책 유형 | 설명 |
|---|---|
| 앱별 차단 정책 (APP_BLOCK) | 특정 앱/서비스 사용 차단 (유튜브, 게임 등) |
| 앱별 가속 정책 | 특정 앱 우선순위 상향 (QoS) |
| 속도 제한 정책 | 한도 초과 시 속도 제한 (차단 대신) |
| 필드 | DB 컬럼 | 설명 |
|---|---|---|
| 정책 설명 | description |
정책 템플릿의 상세 설명. 프론트엔드 툴팁 표시용 |
| 최소 설정 권한 | require_role |
MEMBER이면 MEMBER 이상이 설정 가능, OWNER이면 OWNER만 설정 가능 |
| 기본 규칙 | default_rules |
정책 타입별 기본 규칙 JSON |
| 활성화 여부 | is_activate |
FALSE이면 정책 목록에서 제외되며 신규 적용 불가 |
default_rules vs rules 관계:
POLICY.default_rules: 템플릿 수준의 기본값 (운영자가 설정)POLICY_ASSIGNMENT.rules: 실제 적용된 규칙 (Owner가 커스터마이즈 가능)- 적용 흐름: Owner가 정책을 적용하면
default_rules가rules로 복사되고, 이후 독립적으로 수정 가능
왜 default_rules와 rules를 분리? 관리자가 템플릿(default_rules)을 수정해도, 이미 적용된 정책(rules)에 영향을 주지 않도록 설계했다. 템플릿 변경이 기존 사용자의 설정을 의도치 않게 바꾸는 것을 방지한다.
6.1 web-service (가족 앱, www.dabom.site) — PWA
| 기능 | 설명 | 실시간 여부 |
|---|---|---|
| 가족 통합 대시보드 | 가족 잔여 데이터량, 구성원별 사용 비중 시각화 | SSE |
| 개인 사용량 상세 | 시간대별/앱별/구성원별 상세 분석 리포트 | SSE |
| 알림 센터 | 잔여량 경고, 차단 알림, 정책 변경 알림 | 1차: SSE (브라우저 접속 중 Push, 미접속 시 재접속 시 일괄 전송) / 2차: SSE + Web Push (FCM, 브라우저 꺼져도 OS 레벨 알림) |
| 차단 상태 표시 | 즉시 차단 화면 (차단 사유, 부모 연락처) | SSE |
| 기능 | 설명 | 실시간 여부 |
|---|---|---|
| 구성원별 한도 설정 | 슬라이더 UI로 월별 데이터 한도 조절 | 즉시 적용 |
| 실시간 차단/해제 | 특정 구성원의 데이터 사용 즉시 차단 또는 해제 | 즉시 적용 |
| 시간대 정책 설정 | 야간 등 특정 시간대 차단 정책 설정 | 즉시 적용 |
| 알림 임계값 설정 | 50%, 30%, 10% 등 잔여량 알림 기준 설정 | - |
| 상세 분석 리포트 | 시간대별, 앱별, 구성원별 사용량 분석 | - |
| 구성원 관리 | 추가/삭제, 탈퇴 시 할당량 수동 재배치 | - |
| 기능 | 설명 |
|---|---|
| 정책 관리 | 정책 템플릿 CRUD (시간대 차단, 한도 유형 등) |
| 개별 가족 정책 수정 | 고객 요청 시 관리자가 직접 정책 수정 가능 |
| 정책 즉시 적용 | 정책 변경 시 다음 이벤트부터 즉시 반영 보장 |
| 가족 그룹 검색/조회 | 그룹 ID, 사용자 이름, 전화번호 등으로 검색 |
| 사용량 모니터링 | 그룹별/사용자별 실시간 사용량 및 이력 조회 |
| 권한 관리 | Owner 계정 지정/해제 (복수 Owner 허용) |
| 감사 로그 | 정책 변경, 차단/해제 이력 등 감사 로그 조회 |
| 기능 | 설명 |
|---|---|
| 이벤트 생성 | familyId, userId, appId, bytesUsed, timestamp 포함 |
| RPS 제어 | 초당 이벤트 발생률 조절 (고정 RPS + 런타임 변경) |
| 랜덤 이벤트 | 무작위 사용자에 대한 데이터 사용 이벤트 생성 |
| 목표 | 설명 |
|---|---|
| 대규모 트래픽 처리 | 100만 유저, 25만 가족, 5,000 TPS 안정적 처리 |
| 실시간 이벤트 처리 | 이벤트 기반 실시간 처리 중심 |
| 동시성 제어 | "마지막 10MB" 케이스에서 정합성 보장 (100ms 이내) |
| 정책 즉시 반영 | 정책 변경 시 다음 이벤트부터 즉시 적용 |
| Idempotency | 중복/재처리 상황에서 일관된 결과 보장 |
| 장애 내성 | Redis 장애 시 DB Fallback, Circuit Breaker 전체 적용 |
왜 이벤트 기반 우선? API 동기 호출 중심이면 5,000 TPS에서 api-core가 병목이 된다. Kafka를 이벤트 백본으로 두면 버스트 트래픽을 디스크 버퍼링으로 흡수하고, processor-usage가 자신의 속도로 소비할 수 있다.
왜 이중 저장(Redis + RDS)? Redis만 쓰면 장애 시 데이터 유실, RDS만 쓰면 100ms 이내 응답 불가. Redis로 실시간 판정, RDS로 영속 보관을 분리하여 성능과 신뢰성을 동시에 확보한다.
왜 Write-Behind(자기소비 패턴)? Write-Through는 매 이벤트마다 DB 동기 쓰기로 지연이 증가한다. processor-usage가
usage-persist토픽에 발행 후 자체 소비하여 배치(5초/100건) 단위로 MySQL Bulk Insert하면 DB 부하를 1/100로 줄일 수 있다.
왜 Soft Delete? 물리 삭제 시 감사 추적이 불가하다.
deleted_at컬럼으로 논리 삭제하면 데이터 복구 가능성과 감사 로그 일관성을 보장한다.
| 영역 | 기술 | 선정 사유 |
|---|---|---|
| simulator-usage | Go | 고성능 이벤트 펌프, goroutine 동시성, 단일 바이너리 ~20MB |
| processor-usage | Spring Boot 3.4 / Java 21 | 복잡한 비즈니스 로직 + Kafka Consumer, Redis Atomic |
| api-core | Spring Boot 3.4 / Java 21 | 풍부한 생태계, JPA, JWT 인증 |
| api-notification | Spring Boot 3.4 / Java 21 | SSE 비동기 처리, notification-events consumer |
| Kafka | Apache Kafka (Amazon MSK) | 대용량 이벤트 스트림, familyId 순서 보장, 버스트 흡수 |
| Redis | Redis Sentinel (ElastiCache) | 원자 연산(Lua Script), 저지연 캐시 |
| MySQL | Amazon RDS | JSON 지원, Read 성능, 팀 숙련도 |
| web-core | Next.js + Turborepo | SSR, 모노레포 코드 공유 (web-service + web-admin) |
| Container | Docker + ECS Fargate | 오케스트레이션, 오토스케일링 |
| Observability | Micrometer + Prometheus + Grafana + Jaeger | 로그/메트릭/트레이싱 통합 |
| 항목 | 기술 | 버전 |
|---|---|---|
| Framework | Spring Boot | 3.4 |
| Language | Java | 21 |
| ORM | Spring Data JPA + QueryDSL | 5.1 |
| Cache | Spring Data Redis | - |
| Messaging | Spring Kafka | - |
| Build | Gradle (Groovy DSL) | Java 21 Toolchain |
| Container | Docker Multi-stage | eclipse-temurin:21-jdk → eclipse-temurin:21-jre |
| Observability | Micrometer + Prometheus + OpenTelemetry | - |
| Logging | Logstash Logback Encoder | 구조화된 JSON 로그 |
| 서비스 | 추가 기술 |
|---|---|
| api-core | JWT (jjwt 0.11.2), Springdoc OpenAPI (Swagger), SSE (SseEmitter) |
| processor-usage | Redis Lua Script (원자적 연산) |
| api-notification | JWT (jjwt 0.11.2), Springdoc OpenAPI (Swagger), SSE (SseEmitter) |
flowchart TB
classDef client fill:#E3F2FD,stroke:#2196F3,stroke-width:2px,color:#0D47A1
classDef sim fill:#FFF3E0,stroke:#FB8C00,stroke-width:2px,color:#E65100
classDef api fill:#E8F5E9,stroke:#43A047,stroke-width:2px,color:#1B5E20
classDef event fill:#F3E5F5,stroke:#8E24AA,stroke-width:2px,color:#4A0072
classDef proc fill:#FFEBEE,stroke:#E53935,stroke-width:2px,color:#B71C1C
classDef data fill:#ECEFF1,stroke:#607D8B,stroke-width:2px,color:#263238
classDef infra fill:#F1F8E9,stroke:#7CB342,stroke-width:2px,color:#33691E
subgraph CLIENT["Client Layer"]
subgraph WC["web-core (Turborepo)"]
WS["web-service<br/>www.dabom.site"]
WA["web-admin<br/>admin.dabom.site"]
end
class WS,WA client
end
subgraph SIM["Simulation Layer"]
TG["simulator-usage<br/>Kafka 테스트 트래픽"]
class TG sim
end
subgraph API["API Layer"]
AC["api-core<br/>정책/권한 API"]
AN["api-notification<br/>SSE API<br/>notification-events consumer"]
class AC,AN api
end
subgraph EVENT["Event Backbone"]
KF[["Kafka Cluster<br/>usage-events | policy-updated | usage-persist | usage-realtime | notification-events"]]
class KF event
end
subgraph PROC["Processing Layer"]
UP["processor-usage<br/>정책평가/쿼터차감<br/>Redis Atomic<br/>usage-persist produce & consume"]
class UP proc
end
subgraph DATA["Data Layer"]
RD[("Redis<br/>Realtime Cache")]
DB[("MySQL<br/>Source of Truth")]
class RD,DB data
end
subgraph INFRA["Infra Layer"]
WAF["WAF"]
ALB["ALB"]
class WAF,ALB infra
end
TG -->|usage-events| KF
WS --> WAF --> ALB --> AC
WA --> WAF
WS --> AN
AC -->|CRUD/조회| DB
AC -->|캐시| RD
AC -->|policy-updated| KF
KF -->|usage-events| UP
KF -->|policy-updated| UP
UP -->|Lua 원자 연산| RD
UP -.->|Fallback| DB
UP -->|usage-persist| KF
KF -->|usage-persist| UP
UP -->|MySQL Write-Behind| DB
UP -->|usage-realtime| KF
KF -->|usage-realtime| AC
UP -->|notification-events| KF
KF -->|notification-events| AN
AN -->|SSE Push| WS
| 컴포넌트 | 역할 | 기술 스택 |
|---|---|---|
| simulator-usage | 데이터 사용 이벤트 시뮬레이션, eventId 생성, Kafka 직접 발행 | Go |
| processor-usage | Kafka 소비, 검증, 중복 체크, 정책평가/쿼터차감, Write-Behind 자기소비, 알림 발행 | Spring Boot |
| api-core | 5개 도메인 REST API, JWT 인증, 정책 즉시 반영 트리거 | Spring Boot |
| api-notification | notification-events consumer, SSE + REST 알림 API, PWA Push | Spring Boot |
| web-service | 가족 사용자 PWA (www.dabom.site) | Next.js + PWA |
| web-admin | 백오피스 관리 UI (admin.dabom.site) | Next.js |
┌─────────────────────────────────────────────────────────────┐
│ Client Layer │
│ ┌─────────────── web-core (Turborepo) ─────────────────┐ │
│ │ ┌─────────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ web-service │ │ web-admin │ │ │
│ │ │ www.dabom.site (PWA) │ │ admin.dabom.site │ │ │
│ │ └─────────────────────┘ └─────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ ┌──────────────────┐
┌──────┴──────┐ │ Simulation Layer │
│ WAF + ALB │ │ simulator-usage │
└──────┬──────┘ └────────┬─────────┘
│ │
┌─────────────────────────────────────────────────────────────┐
│ API Layer │
│ ┌────────────────────────┐ ┌────────────────────────┐ │
│ │ api-core │ │ api-notification │ │
│ │ (Command/Query) │ │ (SSE API) │ │
│ └────────────────────────┘ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ │
┌─────────────────────────────────────────────────────────────┐
│ Event Backbone (버스트 흡수) │
│ Kafka: usage-events | policy-updated | usage-persist │
│ | usage-realtime | notification-events │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ Processing Layer │
│ processor-usage (정책평가 + Write-Behind) │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ Data Layer │
│ Redis (Realtime) MySQL (Persist) │
└─────────────────────────────────────────────────────────────┘
3개 백엔드 서비스 공통 구조:
com.project
├─ Application.java
├─ domain/{feature}/
│ ├─ controller/ # REST 엔드포인트 (api-core, api-notification)
│ ├─ service/ # Interface + Impl 구조
│ │ └─ port/ # 인프라 추상화 인터페이스
│ ├─ repository/ # Spring Data JPA
│ │ └─ impl/ # QueryDSL 구현체
│ ├─ entity/ # JPA 엔티티
│ ├─ enums/ # 도메인 Enum
│ ├─ dto/
│ │ ├─ request/ # 요청 DTO (Java record)
│ │ └─ response/ # 응답 DTO (Java record + static from())
│ └─ infra/ # 인프라 어댑터
│ ├─ cache/ # Redis 캐시 Repository
│ ├─ messaging/ # Kafka Producer/Consumer
│ ├─ event/ # Spring ApplicationEvent (api-core)
│ └─ sse/ # SSE Emitter Registry (api-core)
└─ global/
├─ config/ # Redis, Kafka, JPA, QueryDSL, Swagger, CORS, Cache
├─ exception/ # ExceptionAdvice, BaseException, ErrorCode
├─ auth/ # JWT 인증/인가, AOP (api-core, api-notification)
├─ api/response/ # ApiResponse<T> 공통 응답 래퍼
├─ event/dto/ # 공유 Kafka 이벤트 Payload DTO
└─ util/ # BaseEntity, RedisKeyGenerator
왜 Feature-Based Layered? 기능(도메인)별로 패키지를 나누면 관련 코드가 한 폴더에 모여 응집도가 높다. 순수 계층별(controller/, service/, repository/) 구조는 파일이 흩어져 기능 추가 시 여러 패키지를 오가야 한다.
# api-core
Controller → Service → Repository → Entity
# processor-usage (REST API 없음)
Consumer → Service → Repository → Entity
→ Redis (Lua Script)
→ Producer (이벤트 발행)
# api-notification
Controller → Service → Repository → Entity
Consumer → Service → SseEmitterManager (SSE 푸시)
api-core:
| 도메인 | 패키지 | 역할 |
|---|---|---|
| customer | domain/customer |
사용자 인증(로그인), 사용량/정책 조회 |
| admin | domain/admin |
관리자 인증(로그인), 백오피스 관리 |
| family | domain/family |
가족 그룹 관리, 대시보드, 검색, SSE 실시간 사용량 |
| policy | domain/policy |
정책 템플릿 CRUD, 가족 구성원 정책 적용/수정 |
| usagerecord | domain/usagerecord |
사용량 기록 관리, SSE 실시간 스트림 |
processor-usage:
| 도메인 | 패키지 | 역할 |
|---|---|---|
| usage | domain/usage |
사용량 이벤트 수신, Redis Lua Script 실시간 처리, 이벤트 팬아웃 |
| policy | domain/policy |
정책 변경 이벤트 수신, Redis 제약 조건 동기화 |
| family | domain/family |
가족 그룹 정보 관리, Redis 캐시 저장/조회 |
| customer | domain/customer |
고객 엔티티 및 월별 할당량(CustomerQuota) 관리 |
| notification | domain/notification |
알림 이벤트 발행 (Sealed Interface 기반 다형 알림) |
api-notification:
| 도메인 | 패키지 | 역할 |
|---|---|---|
| notification | domain/notification |
SSE 실시간 알림 + REST 알림 이력 조회 |
| customer | domain/customer |
고객 엔티티, 할당량(CustomerQuota) |
| family | domain/family |
가족 그룹 엔티티, 구성원 매핑 |
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class BaseEntity {
@CreatedDate private LocalDateTime createdAt;
@LastModifiedDate private LocalDateTime updatedAt;
private LocalDateTime deletedAt;
public void softDelete() { this.deletedAt = LocalDateTime.now(); }
public boolean isDeleted() { return this.deletedAt != null; }
}public enum RoleType {
MEMBER, // 일반 가족 구성원
OWNER, // Owner 계정 (복수 가능)
ADMIN // 백오피스 운영자 — FamilyMember에서는 MEMBER/OWNER만 사용, ADMIN은 별도 ADMIN 테이블 인증 체계
}
public enum PolicyType {
MONTHLY_LIMIT, // 월별 한도
TIME_BLOCK, // 시간대 차단
MANUAL_BLOCK, // 즉시 차단
APP_BLOCK, // 앱별 차단
WEBSITE_BLOCK // 웹사이트 차단
}| 엔티티 | 테이블 | 예상 규모 |
|---|---|---|
| Customer | customer |
~1,000,000 |
| Family | family |
~250,000 |
| FamilyMember | family_member |
~1,000,000 |
| CustomerQuota | customer_quota |
~1,000,000/월 |
| UsageRecord | usage_record |
~432,000,000/일 |
| Policy | policy |
~100 |
| PolicyAssignment | policy_assignment |
~500,000 |
| NotificationLog | notification_log |
~수백만/월 |
역할: 데이터 사용 이벤트 시뮬레이션 — Kafka usage-events 토픽에 직접 발행 (HTTP 엔드포인트 없음)
기능:
- 고정 RPS로 랜덤 이벤트 생성 (정상 5,000 TPS, 버스트 최대 100만 동시)
- eventId 생성 (UUID v4) — 멱등성 보장을 위한 고유 식별자
- familyId, customerId, appId, bytesUsed, timestamp, metadata 포함
- 런타임 TPS 조절 HTTP API 제공
- 현실적 데이터 분포: 가족 규모(2~10명), 사용자 유형(Heavy/Normal/Light), 앱 카테고리(6종), 시간대별 트래픽 패턴
Kafka Producer 설정:
buffer.memory=67108864 # 64MB 버퍼
batch.size=65536 # 64KB 배치
linger.ms=5 # 5ms 대기 후 발송
max.block.ms=60000 # 60초 블로킹 허용
acks=1 # Leader 응답만 대기 (처리량 우선)
compression.type=lz4 # 압축으로 네트워크 효율화왜 Go를 선택했는가? (vs Python)
- simulator-usage의 본질은 "고성능 이벤트 펌프"로, 비즈니스 로직이 거의 없고 빠른 Kafka 발행이 핵심
- Go의 goroutine으로 수만 개 동시 실행 가능, Python은 GIL 제약으로 버스트(100만 동시) 구간에서 한계
- 단일 바이너리 ~20MB vs Python 런타임 ~200MB+
golang.org/x/time/rate로 TPS 조절이 자연스럽고 런타임 변경도 채널 통신으로 깔끔
| 항목 | Go | Python |
|---|---|---|
| Kafka Producer 성능 | confluent-kafka-go (librdkafka), 5,000+ TPS 여유 | GIL로 콜백 병목 가능 |
| 동시성 모델 | goroutine 수만 개, 채널 통신 | asyncio/threading, GIL 제약 |
| 메모리 | 바이너리 ~10MB | 인터프리터 + 의존성 수십~수백MB |
| 배포 | 단일 바이너리, Docker ~20MB | Docker ~200MB+ |
| 버스트 대응 | goroutine + channel로 100만 동시 가능 | multiprocessing 필요, 프로세스 간 통신 복잡 |
왜 커스텀 개발? (vs k6, JMeter, kafka-producer-perf-test 등)
- 범용 도구로는 familyId 기반 파티션 분포, 도메인 JSON 페이로드, eventId UUID 생성, E2E 파이프라인 검증 불가
- simulator-usage는 부하 도구가 아니라 "시스템 컴포넌트"로서 운영 환경 트래픽 패턴을 재현
- 단, kafka-producer-perf-test는 인프라 검증 단계에서 병행 활용
부하 테스트 도구 계층 전략:
┌─────────────────────────────────────────────────────┐
│ Kafka 파이프라인 테스트 │
│ │
│ 1단계: kafka-producer-perf-test │
│ → MSK 인프라가 5,000 TPS 버티는가? │
│ │
│ 2단계: simulator-usage (Go) │
│ → 실제 페이로드 E2E 파이프라인 테스트 │
│ → "Last 10MB" 동시성 시나리오 재현 │
├───────────────────────────────────────────────────────┤
│ HTTP API 테스트 │
│ │
│ 3단계: k6 → api-core/api-notification HTTP 부하 │
└───────────────────────────────────────────────────────┘
역할: 이벤트 검증, 중복 체크, 정책평가/쿼터차감, Redis Atomic, Write-Behind 자기소비
9가지 책임:
- Kafka Consumer:
usage-events,policy-updated,usage-persist토픽 소비 - 페이로드 검증: 필수 필드 확인, 형식 검증
- 중복 체크: Redis
SETNX event:processed:{eventId}(TTL 24시간) - Redis Lua Script: 원자적 정책 평가/차감 (100ms 이내)
- 선착순 완전 승인: 잔여량 부족 시 요청 전체 거부
- 정책 변경 즉시 반영: 메모리 캐시 갱신 + 소급 적용 검사
- DB Fallback: Redis 장애 시 MySQL 전환 (Circuit Breaker)
- 알림 이벤트 발행:
notification-events토픽으로 통합 발행 - Write-Behind (자기소비):
usage-persist토픽으로 발행 후, 동일 토픽을 소비하여 MySQL Bulk Insert
처리 흐름:
Kafka(usage-events) → 검증 → 중복체크(Redis) → Lua Script(쿼터 평가)
→ notification-events 발행 → usage-persist 발행
Kafka(usage-persist) → processor-usage 자기소비 → MySQL Bulk Insert
왜 processor-usage가 usage-persist를 자기소비(Self-Consumption)? 별도 서비스로 분리하면 운영 복잡도가 증가한다. processor-usage가 이미 처리 컨텍스트를 갖고 있으므로, 같은 프로세스에서 배치 수집(5초/100건) 후 Bulk Insert하는 것이 가장 효율적이다.
UsageEventsConsumer
1. ConsumerRecord 수신
2. EventEnvelope<UsagePayload> 역직렬화
3. UsageEventValidator 검증
4. UsageSyncService.syncUsage() 위임
│
▼
Redis Lua Script (usage_update.lua) 실행
1. 개인 제약 조건(BLOCK:ACCESS) 확인
2. 개인 월간 한도(LIMIT:DATA:MONTHLY) 초과 확인
3. 가족 잔여량 부족 확인
4. 가족 잔여량 차감 (DECRBY)
5. 개인 월간 사용량 증가 (INCRBY)
6. 임계치 경고 판정 (WARNING_10/30/50)
7. 경고 중복 발송 방지 (HEXISTS/HSET)
│
▼
3개 토픽 팬아웃 발행
├─ usage-persist (DB Write-Behind)
├─ usage-realtime (대시보드 갱신)
└─ notification-events (알림, 조건부)
| 상태 | 의미 | 트리거 조건 | 후속 동작 |
|---|---|---|---|
NORMAL |
정상 | 차단/경고 해당 없음 | Persist + Realtime 발행 |
WARNING_10 |
잔여 10% 미만 | 잔여/총량 < 0.1 (최초 1회) | + ThresholdAlert 발행 |
WARNING_30 |
잔여 30% 미만 | 잔여/총량 < 0.3 (최초 1회) | + ThresholdAlert 발행 |
WARNING_50 |
잔여 50% 미만 | 잔여/총량 < 0.5 (최초 1회) | + ThresholdAlert 발행 |
BLOCKED_ACCESS |
완전 차단 | constraints[BLOCK:ACCESS] == "1" |
+ CustomerBlocked 발행 |
BLOCKED_LIMIT_MONTHLY |
월간 한도 초과 | 월간 사용량 + bytesUsed > limit | + CustomerBlocked 발행 |
BLOCKED_FAMILY_QUOTA |
가족 데이터 소진 | 가족 잔여량 < bytesUsed | + CustomerBlocked 발행 |
PolicyKafkaConsumer
1. policy-updated 토픽 수신
2. POLICY_UPDATED 타입만 처리
3. PolicyConstraintSyncService.sync() 위임
│
▼
Redis Lua Script (policy_constraint_update.lua) 실행
1. 이벤트 중복 방지 (SET NX EX)
2. 버전 비교 (stale event 방어)
3. HSET 또는 HDEL 수행
결과: APPLIED / DUPLICATE / STALE / INVALID_REQUEST
| 정책 키 | 값 형식 | 설명 |
|---|---|---|
BLOCK:ACCESS |
"0" / "1" |
완전 접근 차단 |
BLOCK:TIME:START |
"HHmm" |
시간대 차단 시작 |
BLOCK:TIME:END |
"HHmm" |
시간대 차단 종료 |
THROTTLE:SPEED |
양의 정수 (bytes/s) | 속도 제한 |
BLOCK:APP:{appId} |
"0" / "1" |
특정 앱 차단 |
LIMIT:DATA:{period} |
양의 정수 (bytes) | 데이터 한도 |
usage-persist (Topic) → UsagePersistKafkaConsumer → UsagePersistService.persist()
1. USAGE_PERSIST 타입 확인
2. payload 필수값 검증
3. Redis 중복 이벤트 방지 (setIfAbsent, Redis 장애 시 DB 저장 허용)
4. eventTime에서 KST 기준 월 추출
5. CustomerQuota UPDATE 시도
6. UPDATE 0건이면 INSERT (과거 최신 monthlyLimitBytes 승계)
7. INSERT 경합 시 UPDATE 재시도
역할: 정책/권한 CRUD, 대시보드 조회, 인증
책임:
- JWT 인증/인가 처리 (Access 30분 / Refresh 7일)
- 가족/구성원 관리 API
- 정책 CRUD API
- 정책 변경 시 RDS 갱신 + Kafka 이벤트 발행 (Redis는 processor-usage가 갱신)
- API 버전 관리 (Accept-Version 헤더)
- SSE 실시간 사용량 Push (
usage-realtime토픽 소비 → SseEmitter)
인가 AOP 어노테이션:
| 어노테이션 | 역할 | 설명 |
|---|---|---|
@AdminOnly |
admin | 관리자 전용 엔드포인트 보호 |
@OwnerOnly |
owner | Owner 전용 엔드포인트 보호 |
@CustomerId |
member | JWT에서 customerId를 추출하여 파라미터에 주입 |
역할: notification-events 소비, SSE 실시간 알림 전달, PWA Push
책임:
- Kafka
notification-events토픽 소비 (subType: CUSTOMER_BLOCKED, THRESHOLD_ALERT) - 중복 알림 방지 (임계치당 1회)
- SSE API 실시간 스트림 전송
- PWA Push 알림 발송
- 3회 실패 시 DLQ 이동
왜 3개 알림 토픽을 notification-events 단일 토픽으로 통합? Consumer 관리 단순화.
subType으로 구분하면 토픽 수를 줄이면서도 이벤트 타입별 처리가 가능하다. Kafka 파티션 설정도 1개 토픽으로 관리할 수 있다.
flowchart LR
subgraph Topics["Kafka Topics"]
T1["usage-events<br/>━━━━━━━━━<br/>파티션 키: familyId<br/>버스트 흡수"]
T2["policy-updated<br/>━━━━━━━━━<br/>파티션 키: familyId"]
T3["notification-events<br/>━━━━━━━━━<br/>통합 알림 이벤트"]
T4["usage-persist<br/>━━━━━━━━━<br/>Write-Behind용"]
T5["usage-realtime<br/>━━━━━━━━━<br/>대시보드 갱신용"]
end
TG[simulator-usage] -->|직접 발행| T1
AC[api-core] -->|발행| T2
T1 --> UP[processor-usage]
T2 --> UP
UP -->|발행| T3
UP -->|발행| T4
UP -->|발행| T5
T3 --> AN[api-notification]
T4 -->|자기소비| UP
T5 --> AC2[api-core SSE Push]
UP -->|MySQL Write-Behind| DB[(MySQL)]
파티셔닝 전략:
usage-events,policy-updated: familyId 기반 파티셔닝- 파티션 수: 10개 (processor-usage 오토스케일 최대 인스턴스 수와 동일)
왜 familyId를 파티션 키로? 동일 가족의 이벤트가 같은 파티션에 들어가야 순서가 보장된다. "Last 10MB" 시나리오에서 아빠→엄마→자녀 순서가 뒤바뀌면 동시성 제어가 깨진다.
왜 파티션 10개? processor-usage 인스턴스 수와 동일하게 맞춰 1:1 매핑. 파티션 > Consumer면 유휴 Consumer, Consumer > 파티션이면 유휴 인스턴스가 발생한다.
버스트 처리: Kafka가 100만 동시 요청도 디스크 버퍼링으로 흡수. Consumer는 자신의 처리 속도로 소비 (Lag 허용)
재시도 정책: 3회 실패 시 DLQ로 이동, 지수 백오프 (1초, 5초, 30초)
모든 이벤트는 EventEnvelope<T> 래퍼 구조로 전송. eventType 필드 값에 따라 payload 타입이 자동 매핑.
{
"eventId": "evt_550e8400-...",
"eventType": "DATA_USAGE | POLICY_UPDATED | USAGE_PERSIST | NOTIFICATION | USAGE_REALTIME",
"subType": null,
"timestamp": "2026-02-06T14:30:00Z",
"payload": { }
}왜 EventEnvelope 래퍼 패턴? 모든 이벤트를 단일 구조로 감싸면 직렬화/역직렬화를 통일할 수 있고, Jackson
@JsonTypeInfo로 eventType에 따라 payload 타입을 자동 매핑한다. 새로운 이벤트 타입 추가 시@JsonSubTypes에 한 줄만 추가하면 된다.
Java Record 구현:
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public record EventEnvelope<T>(
String eventId,
String eventType,
String subType,
String timestamp,
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "eventType")
@JsonSubTypes({
@JsonSubTypes.Type(value = UsagePayload.class, name = "DATA_USAGE"),
@JsonSubTypes.Type(value = PolicyUpdatedPayload.class, name = "POLICY_UPDATED"),
@JsonSubTypes.Type(value = UsagePersistPayload.class, name = "USAGE_PERSIST"),
@JsonSubTypes.Type(value = NotificationPayload.class, name = "NOTIFICATION"),
@JsonSubTypes.Type(value = UsageRealtimePayload.class, name = "USAGE_REALTIME")
})
T payload
) {}{
"eventId": "evt_550e8400-e29b-41d4-a716-446655440000",
"eventType": "DATA_USAGE",
"subType": null,
"timestamp": "2026-02-06T14:30:00Z",
"payload": {
"familyId": 100,
"customerId": 12345,
"appId": "com.youtube.app",
"bytesUsed": 5242880,
"metadata": { "deviceId": "device_pixel_9", "networkType": "5G" }
}
}public record UsagePayload(
Long familyId, Long customerId, String appId, Long bytesUsed, Map<String, Object> metadata
) {}{
"eventId": "pol_7a3d2e1f-...",
"eventType": "POLICY_UPDATED",
"timestamp": "2026-02-06T14:35:00Z",
"payload": {
"familyId": 100,
"targetCustomerId": 12345,
"policyKey": "LIMIT:DATA:DAILY",
"oldValue": "2147483648",
"newValue": "536870912",
"changedBy": 99999
}
}왜 policyKey(String)를 policyType(Enum) 대신? Enum은 새 정책 추가마다 코드 수정+배포가 필요하다. String 기반
ACTION:TYPE:TARGET패턴(예:LIMIT:DATA:DAILY,BLOCK:APP:com.youtube)이면 코드 변경 없이 정책 확장이 가능하다.
public record PolicyUpdatedPayload(
Long familyId, Long targetCustomerId,
String policyKey, // "LIMIT:DATA:DAILY", "BLOCK:APP:com.youtube", "THROTTLE:SPEED"
String oldValue, String newValue, Long changedBy
) {}{
"eventId": "persist_8c9d0e1f-...",
"eventType": "USAGE_PERSIST",
"timestamp": "2026-02-06T14:30:01Z",
"payload": {
"originEventId": "evt_550e8400-...",
"familyId": 100, "customerId": 12345,
"bytesUsed": 5242880, "appId": "com.youtube.app",
"processResult": "ALLOWED", "remainingAfter": 4823449,
"eventTime": "2026-02-06T14:30:00Z"
}
}eventType은 NOTIFICATION으로 고정, subType으로 구분:
CUSTOMER_BLOCKED (사용자 차단):
{ "subType": "CUSTOMER_BLOCKED", "payload": {
"familyId": 100, "customerId": 12346,
"blockReason": "LIMIT:DATA:DAILY", "blockedAt": "2026-02-06T14:32:00Z"
} }THRESHOLD_ALERT (임계치 알림):
{ "subType": "THRESHOLD_ALERT", "payload": {
"familyId": 100, "thresholdPercent": 10,
"message": "가족 데이터가 10% 미만입니다! 데이터를 충전해주세요."
} }Notification Sealed Interface + Java 21 Pattern Matching:
public sealed interface NotificationPayload
permits CustomerBlockedPayload, ThresholdAlertPayload {}
public record CustomerBlockedPayload(Long familyId, Long customerId,
String blockReason, String blockedAt
) implements NotificationPayload {}
public record ThresholdAlertPayload(Long familyId, Integer thresholdPercent,
String message
) implements NotificationPayload {}
// Java 21 Consumer 예시
@KafkaListener(topics = "notification-events")
public void handleNotification(EventEnvelope<NotificationPayload> event) {
switch (event.payload()) {
case CustomerBlockedPayload(var fid, var uid, var reason, var at) ->
sendBlockPush(uid, reason);
case ThresholdAlertPayload(var fid, var pct, var msg) ->
sendAlertPush(fid, msg);
}
}processor-usage가 사용량 처리 후 api-core로 대시보드 갱신 데이터를 전달하는 토픽:
{
"eventId": "rt_9f8e7d6c-...",
"eventType": "USAGE_REALTIME",
"timestamp": "2026-02-06T14:30:01Z",
"payload": {
"familyId": 100,
"customerId": 200,
"totalUsedBytes": 5242880,
"totalLimitBytes": 10485760,
"remainingBytes": 5242880,
"usedPercent": 50.0,
"monthlyUsedBytes": 1048576,
"userUsagePercent": 10.0,
"monthlyLimitBytes": 3145728
}
}public record UsageRealtimePayload(
Long familyId, Long customerId,
Long totalUsedBytes, Long totalLimitBytes, Long remainingBytes,
Double usedPercent, Long monthlyUsedBytes,
Double userUsagePercent, Long monthlyLimitBytes
) {}왜 usage-realtime 토픽을 별도로? processor-usage가 처리한 실시간 사용량 결과를 api-core의 SSE(SseEmitter)로 즉시 Push해야 한다. usage-persist(DB 저장용)와 분리하면 대시보드 갱신은 즉시, DB 저장은 배치로 독립적으로 동작할 수 있다.
| Consumer Group | 토픽 | 인스턴스 수 | 처리 방식 |
|---|---|---|---|
processor-usage-group |
usage-events, policy-updated, usage-persist | 10 (파티션 수) | 순차 처리 + Write-Behind |
api-core-group |
usage-realtime | 3 | SSE Push |
notification-group |
notification-events | 2 | 병렬 처리 |
sequenceDiagram
autonumber
participant TG as simulator-usage
participant KF as Kafka
participant UP as processor-usage
participant RD as Redis
participant DB as RDS
participant AN as api-notification
participant WS as web-service
TG->>TG: eventId 생성 (UUID v4)
TG->>KF: Kafka Producer 발행<br/>(usage-events, key=familyId)
Note over TG,KF: HTTP 없음, Kafka 직접 발행<br/>버스트 트래픽 흡수
KF->>UP: 이벤트 수신 (순서 보장)
UP->>UP: 페이로드 검증
UP->>RD: eventId 중복 체크 (SETNX TTL)
alt 중복 이벤트
RD-->>UP: 이미 존재
Note over UP: 무시 (Idempotent)
else 신규 이벤트
RD-->>UP: 저장 성공
UP->>RD: Lua Script 실행 (100ms 이내)
Note over UP,RD: 1. 정책 조회<br/>2. 잔여량 확인<br/>3. 차감 or 차단<br/>4. 상태 업데이트
end
alt 허용 (선착순 완전 승인)
RD-->>UP: allowed: true
UP->>KF: usage-persist 발행 (Write-Behind)
UP->>KF: usage-realtime 발행 (대시보드 갱신)
else 차단
RD-->>UP: allowed: false
UP->>KF: notification-events 발행 (CUSTOMER_BLOCKED)
end
opt 임계값 도달
UP->>KF: notification-events 발행 (THRESHOLD_ALERT)
end
KF->>UP: usage-persist 자기소비
UP->>DB: MySQL Bulk Insert (Write-Behind)
KF->>AN: notification-events 수신
AN->>WS: SSE 실시간 업데이트 + PWA 푸시
sequenceDiagram
autonumber
participant OW as Owner (any)
participant WS as web-service
participant AC as api-core
participant DB as RDS
participant RD as Redis
participant KF as Kafka
participant UP as processor-usage
OW->>WS: 자녀 한도 변경 (2GB → 500MB)
WS->>AC: PATCH /families/policies
AC->>DB: 정책 원본 저장 (Source of Truth)
AC->>KF: policy-updated 발행
AC-->>WS: 200 OK
WS-->>OW: 변경 완료 표시
KF->>UP: policy-updated 수신
UP->>RD: Redis constraints Hash 갱신
UP->>UP: 메모리 정책 캐시 갱신
Note over UP: 이후 모든 usage-events는<br/>최신 정책으로 평가됨
UP->>RD: 소급 적용 검사
Note over UP,RD: 현재 사용량 > 신규 한도?
opt 즉시 차단 필요
UP->>RD: blocked = true
UP->>KF: notification-events 발행 (CUSTOMER_BLOCKED)
end
왜 api-core가 RDS + Kafka만 갱신? RDS는 Source of Truth(영속), Kafka는 processor-usage에 정책 변경을 전파한다. Redis는 processor-usage가 policy-updated 이벤트를 수신한 후 갱신하므로, api-core는 RDS 저장과 Kafka 발행만 담당한다.
sequenceDiagram
participant UP as processor-usage
participant KF as Kafka
participant AN as api-notification
participant RD as Redis
participant WS as web-service
UP->>KF: notification-events 발행 (THRESHOLD_ALERT, 50%)
KF->>AN: notification-events 수신
AN->>RD: 알림 발송 여부 확인
Note over AN,RD: family:{familyId}:alert:50
alt 미발송
RD-->>AN: 없음
AN->>RD: 발송 기록 저장 (TTL: quota reset까지)
AN->>WS: SSE 실시간 알림 + PWA 푸시
else 이미 발송됨 (임계치당 1회)
RD-->>AN: 발송 기록 존재
Note over AN: 중복 발송 방지
end
3단계 해결 전략:
- Kafka 파티셔닝: familyId를 파티션 키로 사용 → 동일 가족 이벤트 순서 보장
- 단일 Consumer: 파티션당 하나의 processor-usage Consumer가 순차 처리
- Redis Lua Script: 원자적 "확인 → 차감 → 상태 변경"
┌─────────────────────────────────────────────────────────────┐
│ 동시 요청 (4명) │
│ 아빠: 5MB │ 엄마: 3MB │ 자녀1: 8MB │ 자녀2: 4MB │
└─────────────────────────────────────────────────────────────┘
│
simulator-usage가 Kafka로 직접 발행
│
┌─────────────────────────────────────────────────────────────┐
│ Kafka (familyId 파티션) — 순서 보장 │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ processor-usage (순차 처리) │
│ │
│ 아빠 5MB ✅ (잔여 5MB) → 엄마 3MB ✅ (잔여 2MB) │
│ 자녀1 8MB ❌ (차단) → 자녀2 4MB ❌ (차단) │
└─────────────────────────────────────────────────────────────┘
constraints Hash를 순회하며 BLOCK/LIMIT/THROTTLE 등 다양한 정책을 코드 수정 없이 평가한다.
Input/Output 명세:
| 구분 | Key/Arg | 설명 |
|---|---|---|
KEYS[1] |
family:{fid}:remaining |
가족 잔여 데이터량 |
KEYS[2] |
family:{fid}:customer:{cid}:usage |
기간별 사용량 Key Prefix |
KEYS[3] |
family:{fid}:customer:{cid}:constraints |
제약 조건 Hash |
ARGV[1] |
request_bytes |
요청 데이터량 |
ARGV[2] |
app_id |
현재 실행 앱 패키지명 |
ARGV[3] |
current_hhmm |
현재 시각 (예: "2230") |
-- 1. 제약 조건 로딩
local constraints_array = redis.call('HGETALL', KEYS[3])
local request_bytes = tonumber(ARGV[1])
local app_id = ARGV[2]
local current_time = tonumber(ARGV[3] or "0000")
local constraints = {}
for i = 1, #constraints_array, 2 do
constraints[constraints_array[i]] = constraints_array[i+1]
end
-- 2. [Block] 무조건 차단 체크 (Priority 1)
if constraints['BLOCK:ACCESS'] == "1" then
return "BLOCKED_ACCESS"
end
if app_id and constraints['BLOCK:APP:' .. app_id] == "1" then
return "BLOCKED_APP"
end
-- 시간대 차단 (자정 넘기는 케이스 포함)
local start_time = tonumber(constraints['BLOCK:TIME:START'])
local end_time = tonumber(constraints['BLOCK:TIME:END'])
if start_time and end_time then
if start_time < end_time then
if current_time >= start_time and current_time < end_time then
return "BLOCKED_TIME"
end
else
if current_time >= start_time or current_time < end_time then
return "BLOCKED_TIME"
end
end
end
-- 3. [Limit] 데이터 한도 체크 (Priority 2)
for field, value in pairs(constraints) do
if string.sub(field, 1, 11) == "LIMIT:DATA:" then
local period = string.sub(field, 12)
local usage_key = KEYS[2] .. ":" .. string.lower(period)
local current = tonumber(redis.call('GET', usage_key) or 0)
if (current + request_bytes) > tonumber(value) then
return "BLOCKED_LIMIT_" .. period
end
end
end
-- 4. [Family] 가족 공용 잔여량 체크 (Priority 3)
local family_rem = tonumber(redis.call('GET', KEYS[1]) or 0)
if family_rem < request_bytes then
return "BLOCKED_FAMILY_QUOTA"
end
-- 5. [Apply] 사용 처리 (여기까지 왔으면 통과)
redis.call('DECRBY', KEYS[1], request_bytes)
for field, _ in pairs(constraints) do
if string.sub(field, 1, 11) == "LIMIT:DATA:" then
local period = string.sub(field, 12)
local usage_key = KEYS[2] .. ":" .. string.lower(period)
redis.call('INCRBY', usage_key, request_bytes)
end
end
-- 6. [Result] 최종 결과 (QoS 속도 제한 포함)
local throttle = constraints['THROTTLE:SPEED'] or 0
return { "ALLOWED", family_rem, throttle }왜 Redis Lua Script? Redis의 Lua 실행은 단일 스레드에서 원자적으로 처리된다. "잔여량 확인→정책 평가→차감→상태 변경"을 하나의 트랜잭션처럼 실행하여 Race Condition을 원천 차단한다. 분산 락(Redlock)보다 단순하고 빠르다.
왜 Poly-Policy Engine(constraints Hash 동적 순회)? 하드코딩된 if-else 대신 constraints Hash의 필드를 패턴 매칭으로 순회하면, 관리자가 새 정책(예:
BLOCK:APP:com.tiktok)을 추가해도 Lua Script 수정 없이 즉시 동작한다. 정책 확장의 핵심 설계이다.
| 계층 | 전략 | 효과 |
|---|---|---|
| Kafka | 파티션 키 = familyId | 동일 가족 이벤트는 같은 파티션 → 순서 보장 |
| Consumer | 단일 스레드 처리 (파티션당) | 파티션 내 이벤트 순차 처리 |
| Redis | Lua Script 원자 실행 | 확인-차감-상태변경 단일 트랜잭션 |
flowchart TD
A[이벤트 수신] --> B{eventId 존재?}
B -->|Redis SETNX 성공| C[신규 이벤트<br/>처리 진행]
B -->|Redis SETNX 실패| D[중복 이벤트<br/>무시]
C --> E[처리 완료]
E --> F[RDS 저장<br/>event_id UNIQUE]
F -->|성공| G[완료]
F -->|Duplicate Key| H[재시도 케이스<br/>무시]
이중 방어:
- Redis:
SETNX event:processed:{eventId}(TTL 24시간) — 1차 필터 - RDS:
usage_record.event_idUNIQUE 제약 — 최종 안전망
왜 이중 방어(Redis SETNX + RDS UNIQUE)? Redis는 TTL 만료 후 키가 사라지므로, 장시간 후 재처리되는 이벤트는 Redis만으로 걸러낼 수 없다. RDS의 event_id UNIQUE 제약이 최종 안전망 역할을 한다.
| 저장소 | 역할 | 특징 |
|---|---|---|
| Redis Sentinel | 실시간 캐시, 동시성 제어, 상태 관리 | 원자 연산(Lua Script), 저지연, 휘발성 |
| MySQL | 영속 데이터, 원본(Source of Truth), 감사 로그 | ACID 보장, 장기 보관, 복잡한 쿼리 |
| Kafka | 이벤트 백본, 비동기 메시징 | 순서 보장, 재처리 가능, 이력 보관 |
| S3 | 콜드 데이터 아카이브 | 저비용 장기 보관 |
| Key 패턴 | 타입 | 설명 | TTL |
|---|---|---|---|
family:{fid}:info |
Hash | 가족 기본 정보 (name, total_quota, created_at) | 영구 |
family:{fid}:remaining |
String | 가족 공용 실시간 잔여량 (DECRBY 대상) | 월초 리셋 |
HSET family:100:info name "HappyFamily" total_quota "107374182400" created_at "1707462000"
SET family:100:remaining "12884901888"| Key 패턴 | 타입 | 설명 | TTL |
|---|---|---|---|
family:{fid}:customer:{cid}:usage:{period} |
String | 기간별 누적 사용량 | 해당 기간 만료 시 |
SET family:100:customer:1:usage:monthly "5368709120" # 월별 5GB
SET family:100:customer:1:usage:daily "524288000" # 일별 500MB왜 사용량을 기간별(daily/monthly/weekly)로 분리 저장? 정책이 "일일 한도", "월별 한도" 등 다양한 기간을 지원해야 하므로, Lua Script가 constraints의
LIMIT:DATA:{PERIOD}에서 기간을 추출하여 동적으로 해당 키를 참조한다. 새 기간(weekly) 추가 시 키만 추가하면 된다.
| Key 패턴 | 타입 | 설명 |
|---|---|---|
family:{fid}:customer:{cid}:constraints |
Hash | 사용자의 현재 유효 제약 조건 모음 |
Field 표준 (ACTION:TYPE 패턴):
| Category | Field Name | Value | 설명 |
|---|---|---|---|
| 차단 | BLOCK:ACCESS |
'1' |
전체 데이터 차단 |
BLOCK:APP:{APP_ID} |
'1' |
특정 앱 차단 | |
BLOCK:TIME:START |
"HHMM" |
차단 시작 시간 | |
BLOCK:TIME:END |
"HHMM" |
차단 종료 시간 | |
| 한도 | LIMIT:DATA:{PERIOD} |
Bytes (Long) | 기간별 데이터 한도 |
| 속도 | THROTTLE:SPEED |
bps (Int) | 전송 속도 제한 |
# 일일 1GB 한도 + 유튜브 차단
HMSET family:100:customer:1:constraints \
LIMIT:DATA:DAILY "1073741824" \
BLOCK:APP:com.google.youtube "1"
# 야간 시간대 접속 차단 (22:00 ~ 07:00)
HMSET family:100:customer:4:constraints \
BLOCK:TIME:START "2200" \
BLOCK:TIME:END "0700"| Key 패턴 | 타입 | 설명 | TTL |
|---|---|---|---|
event:dedup:policy:{eventId}:{customerId} |
String | 정책 이벤트 중복 방지 | 1시간 |
event:dedup:usage-persist:{originEventId} |
String | 사용량 DB 저장 중복 방지 | 10분 |
family:{fid}:alert:{type}:{value} |
String | 알림 중복 발송 방지 | 월초 리셋 |
| Key 패턴 | 타입 | 설명 |
|---|---|---|
policy:def:{code} |
Hash | 관리자 정의 정책 템플릿 |
family:{fid}:policy:{code} |
Hash | 가족에게 적용된 정책 설정값 |
| Bean | 용도 | Value Serializer |
|---|---|---|
familyCacheRedisTemplate |
가족 캐시 | Jackson2JsonRedisSerializer (FamilyCacheDto) |
exampleCacheRedisTemplate |
예제 캐시 | GenericJackson2JsonRedisSerializer |
familyStringRedisTemplate |
문자열/Lua Script | StringRedisSerializer |
캐시 전략: Cache Look-Aside 패턴 (api-core 기본) — 캐시 확인 → 캐시 미스 시 DB 조회 → 캐시 저장 (TTL 설정)
erDiagram
CUSTOMER ||--|{ FAMILY_MEMBER : "belongs to"
FAMILY ||--|{ FAMILY_MEMBER : "contains"
CUSTOMER ||--o{ CUSTOMER_QUOTA : "has monthly quota"
FAMILY ||--o{ CUSTOMER_QUOTA : "scoped to"
CUSTOMER ||--o{ USAGE_RECORD : "generates"
FAMILY ||--o{ USAGE_RECORD : "belongs to"
POLICY ||--o{ POLICY_ASSIGNMENT : "applied as"
FAMILY ||--o{ POLICY_ASSIGNMENT : "receives"
CUSTOMER ||--o{ POLICY_ASSIGNMENT : "targeted by"
CUSTOMER ||--o{ NOTIFICATION_LOG : "receives"
FAMILY ||--o{ NOTIFICATION_LOG : "scoped to"
FAMILY ||--o{ INVITE : "has"
CUSTOMER ||--o{ AUDIT_LOG : "performs"
CUSTOMER {
bigint id PK "AUTO_INCREMENT"
varchar phone_number UK "NOT NULL, 숫자만 11자리 (로그인 ID)"
varchar password_hash "NOT NULL, BCrypt"
varchar name "NOT NULL"
varchar email "NULL"
datetime created_at "DEFAULT CURRENT_TIMESTAMP"
datetime updated_at "DEFAULT CURRENT_TIMESTAMP"
datetime deleted_at "NULL, Soft Delete"
}
ADMIN {
bigint id PK "AUTO_INCREMENT"
varchar email UK "NOT NULL (로그인 ID)"
varchar password_hash "NOT NULL, BCrypt"
varchar name "NOT NULL"
datetime created_at "DEFAULT CURRENT_TIMESTAMP"
datetime deleted_at "NULL, Soft Delete"
}
FAMILY {
bigint id PK "AUTO_INCREMENT"
varchar name "NOT NULL, 가족 그룹명"
bigint created_by_id FK "NOT NULL, 최초 생성자 (이력 전용)"
bigint total_quota_bytes "NOT NULL, DEFAULT 100GB"
bigint used_bytes "NOT NULL DEFAULT 0"
date current_month "NOT NULL, 현재 과금 월"
datetime deleted_at "NULL, Soft Delete"
}
FAMILY_MEMBER {
bigint id PK "AUTO_INCREMENT"
bigint family_id FK "NOT NULL"
bigint customer_id FK "NOT NULL"
enum role "MEMBER or OWNER"
datetime joined_at "DEFAULT CURRENT_TIMESTAMP"
datetime deleted_at "NULL, Soft Delete"
}
CUSTOMER_QUOTA {
bigint id PK "AUTO_INCREMENT"
bigint customer_id FK "NOT NULL"
bigint family_id FK "NOT NULL"
bigint monthly_limit_bytes "NULL = 무제한"
bigint monthly_used_bytes "NOT NULL DEFAULT 0"
date current_month "NOT NULL"
boolean is_blocked "NOT NULL DEFAULT FALSE"
varchar block_reason "NULL"
datetime deleted_at "NULL, Soft Delete"
}
USAGE_RECORD {
bigint id PK "AUTO_INCREMENT"
varchar event_id UK "NOT NULL, Idempotency 키"
bigint customer_id FK "NOT NULL"
bigint family_id FK "NOT NULL"
bigint bytes_used "NOT NULL"
varchar app_id "NULL"
datetime event_time "NOT NULL"
datetime deleted_at "NULL, Soft Delete"
}
POLICY {
bigint id PK "AUTO_INCREMENT"
varchar name "NOT NULL"
varchar description "NULL"
enum require_role "DEFAULT MEMBER"
enum type "MONTHLY_LIMIT or TIME_BLOCK or APP_BLOCK or MANUAL_BLOCK"
json default_rules "NOT NULL"
boolean is_system "DEFAULT FALSE"
boolean is_activate "NOT NULL DEFAULT TRUE"
datetime deleted_at "NULL, Soft Delete"
}
POLICY_ASSIGNMENT {
bigint id PK "AUTO_INCREMENT"
bigint policy_id FK "NOT NULL"
bigint family_id FK "NOT NULL"
bigint target_customer_id FK "NULL = 가족 전체"
json rules "NOT NULL"
boolean is_active "NOT NULL DEFAULT TRUE"
datetime applied_at "DEFAULT CURRENT_TIMESTAMP"
bigint applied_by_id FK "NOT NULL"
datetime deleted_at "NULL, Soft Delete"
}
NOTIFICATION_LOG {
bigint id PK "AUTO_INCREMENT"
bigint customer_id FK "NOT NULL"
bigint family_id FK "NOT NULL"
enum type "THRESHOLD_ALERT or BLOCKED or UNBLOCKED or POLICY_CHANGED"
text message "NOT NULL"
json payload "NULL"
boolean is_read "NOT NULL DEFAULT FALSE"
datetime sent_at "DEFAULT CURRENT_TIMESTAMP"
datetime deleted_at "NULL, Soft Delete"
}
AUDIT_LOG {
bigint id PK "AUTO_INCREMENT"
bigint actor_id FK "NULL"
varchar action "NOT NULL"
varchar entity_type "NOT NULL"
bigint entity_id "NOT NULL"
json old_value "NULL"
json new_value "NULL"
varchar ip_address "NULL"
datetime created_at "DEFAULT CURRENT_TIMESTAMP"
}
INVITE {
bigint id PK "AUTO_INCREMENT"
bigint family_id FK "NOT NULL"
varchar phone_number "NOT NULL, 숫자만 11자리"
enum role "MEMBER or OWNER"
enum status "PENDING or ACCEPTED or EXPIRED or CANCELLED"
datetime expires_at "NOT NULL"
datetime deleted_at "NULL, Soft Delete"
}
왜 CUSTOMER와 ADMIN을 분리? CUSTOMER(전화번호 로그인)와 ADMIN(이메일 로그인)은 인증 방식 자체가 다르다. 단일 USER 테이블에서 role로 구분하면 인증 로직에서 항상 role 체크가 필요하고 테이블이 비대해진다.
왜 family.created_by_id는 이력 전용? OWNER 권한 판단은
family_member.role='OWNER'로만 수행한다.created_by_id를 권한에 사용하면 "최초 생성자가 항상 OWNER"라는 제약이 생겨 유연성이 저하된다.
CREATE TABLE customer (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
phone_number VARCHAR(11) NOT NULL COMMENT '숫자만 11자리',
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
email VARCHAR(255),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE (phone_number, deleted_at)
);
CREATE TABLE admin (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE (email, deleted_at)
);
CREATE TABLE family (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_by_id BIGINT NOT NULL REFERENCES customer(id),
total_quota_bytes BIGINT NOT NULL DEFAULT 107374182400,
used_bytes BIGINT NOT NULL DEFAULT 0,
current_month DATE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
);
CREATE TABLE family_member (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
family_id BIGINT NOT NULL REFERENCES family(id),
customer_id BIGINT NOT NULL REFERENCES customer(id),
role ENUM('MEMBER', 'OWNER') NOT NULL DEFAULT 'MEMBER',
joined_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE (family_id, customer_id, deleted_at)
);
CREATE TABLE customer_quota (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customer(id),
family_id BIGINT NOT NULL REFERENCES family(id),
monthly_limit_bytes BIGINT,
monthly_used_bytes BIGINT NOT NULL DEFAULT 0,
current_month DATE NOT NULL,
is_blocked BOOLEAN NOT NULL DEFAULT FALSE,
block_reason VARCHAR(50),
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
UNIQUE (customer_id, family_id, current_month, deleted_at)
);
CREATE TABLE usage_record (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(50) NOT NULL UNIQUE,
customer_id BIGINT NOT NULL REFERENCES customer(id),
family_id BIGINT NOT NULL REFERENCES family(id),
bytes_used BIGINT NOT NULL,
app_id VARCHAR(100),
event_time DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
);
CREATE TABLE policy (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description VARCHAR(255),
require_role ENUM('MEMBER', 'OWNER') NOT NULL DEFAULT 'MEMBER',
type ENUM('MONTHLY_LIMIT', 'TIME_BLOCK', 'APP_BLOCK', 'MANUAL_BLOCK') NOT NULL,
default_rules JSON NOT NULL,
is_system BOOLEAN DEFAULT FALSE,
is_activate BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
);
CREATE TABLE policy_assignment (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
policy_id BIGINT NOT NULL REFERENCES policy(id),
family_id BIGINT NOT NULL REFERENCES family(id),
target_customer_id BIGINT REFERENCES customer(id),
rules JSON NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP,
applied_by_id BIGINT NOT NULL REFERENCES customer(id),
deleted_at DATETIME NULL
);
CREATE TABLE notification_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customer(id),
family_id BIGINT NOT NULL REFERENCES family(id),
type ENUM('THRESHOLD_ALERT', 'BLOCKED', 'UNBLOCKED', 'POLICY_CHANGED') NOT NULL,
message TEXT NOT NULL,
payload JSON,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
sent_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
);
CREATE TABLE audit_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
actor_id BIGINT REFERENCES customer(id),
action VARCHAR(50) NOT NULL,
entity_type VARCHAR(50) NOT NULL,
entity_id BIGINT NOT NULL,
old_value JSON,
new_value JSON,
ip_address VARCHAR(45),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
);
CREATE TABLE invite (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
family_id BIGINT NOT NULL REFERENCES family(id),
phone_number VARCHAR(11) NOT NULL,
role ENUM('MEMBER', 'OWNER') NOT NULL DEFAULT 'MEMBER',
status ENUM('PENDING', 'ACCEPTED', 'EXPIRED', 'CANCELLED') NOT NULL DEFAULT 'PENDING',
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME NULL
);flowchart LR
subgraph "실시간 경로"
A[processor-usage] --> B[Redis Lua Script]
B --> C[응답 반환 100ms 이내]
end
subgraph "비동기 경로 (자기소비)"
A --> D[Kafka usage-persist]
D --> E[processor-usage 자기소비]
E --> F[MySQL Bulk Insert]
end
- processor-usage가 Redis Lua Script 실행 (실시간)
- 처리 결과를 Kafka
usage-persist토픽으로 발행 - 동일 processor-usage가
usage-persist를 소비하여 배치 수집 (5초/100건) - MySQL에 Bulk Insert
매일 새벽 3시 Redis-RDS 불일치 보정:
SELECT family_id, SUM(bytes_used) as total_used
FROM usage_record
WHERE event_time >= DATE_FORMAT(CURRENT_DATE, '%Y-%m-01')
AND deleted_at IS NULL
GROUP BY family_id;flowchart TD
A[요청 수신] --> B{Redis 상태?}
B -->|정상| C[Redis Lua Script 100ms 이내]
B -->|장애 Circuit Open| D[MySQL Fallback 성능 저하 감수]
C --> E[처리 완료]
D --> F[Row Lock으로 동시성 제어]
F --> E
-- Row Lock 기반 Fallback
SELECT * FROM customer_quota
WHERE customer_id = ? AND family_id = ? AND current_month = ? AND deleted_at IS NULL
FOR UPDATE;
UPDATE customer_quota
SET monthly_used_bytes = monthly_used_bytes + ?
WHERE customer_id = ? AND family_id = ? AND current_month = ? AND deleted_at IS NULL
AND (monthly_limit_bytes IS NULL OR monthly_used_bytes + ? <= monthly_limit_bytes);| 계층 | 기간 | 저장소 | 용도 |
|---|---|---|---|
| Hot | 7일 | Redis + RDS | 실시간 조회, 대시보드 |
| Warm | 180일 | RDS | 리포트, 분석 |
| Cold | 180일+ | S3 (Parquet) | 장기 보관, 감사 |
주요 인덱스:
| 테이블 | 인덱스 | 용도 |
|---|---|---|
| customer | (phone_number) | 로그인 조회 |
| admin | (email) | 로그인 조회 |
| family_member | (family_id), (customer_id) | 가족/사용자 목록 |
| customer_quota | (customer_id, current_month) | 월별 한도 조회 |
| usage_record | (family_id, event_time), (customer_id, event_time), (event_id) | 사용량 조회, Idempotency |
| policy_assignment | (family_id), (target_customer_id) | 정책 조회 |
| notification_log | (customer_id, sent_at DESC), (customer_id, type, sent_at DESC) | 알림 목록/타입별 필터 |
| audit_log | (actor_id, created_at DESC), (entity_type, entity_id, created_at DESC) | 감사 이력 |
usage_record 월별 파티셔닝:
CREATE TABLE usage_record (
...
) PARTITION BY RANGE (YEAR(event_time) * 100 + MONTH(event_time)) (
PARTITION p2025_01 VALUES LESS THAN (202502),
PARTITION p2025_02 VALUES LESS THAN (202503),
PARTITION p_future VALUES LESS THAN MAXVALUE
);왜 usage_record를 월별 파티셔닝? 일일 4억 건(5,000 TPS x 86,400초) 규모에서 단일 테이블은 쿼리 성능이 급락한다. 월별 파티셔닝으로 조회 범위를 제한하고, 180일+ 데이터는 파티션 단위로 S3에 아카이브한다.
db/migration/
├── V1__create_user_table.sql
├── V2__create_family_tables.sql
├── V3__create_usage_tables.sql
├── V4__create_policy_tables.sql
├── V5__create_notification_tables.sql
├── V6__add_primary_parent.sql
├── V7__add_notification_type_index.sql
├── V8__add_soft_delete_columns.sql
├── V9__unify_role_system.sql
├── V10__rename_owner_id_to_created_by_id.sql
├── V11__split_user_to_customer_admin.sql ← USER→CUSTOMER/ADMIN 분리
├── V12__daily_to_monthly_quota.sql ← daily→monthly 전환
├── V13__move_policy_rules_to_assignment.sql ← rules→assignment 이동
├── V14__rename_family_group_to_family.sql ← FAMILY_GROUP→FAMILY
├── V15__add_policy_description_role_rules.sql ← POLICY 필드 추가
└── V16__add_policy_is_activate.sql ← is_activate 추가
| 항목 | 값 |
|---|---|
| Base URL | https://api.dabom.site |
| 인증 | JWT (Access 30분, Refresh 7일) |
| 버전 관리 | Accept-Version: 1.0 헤더 |
왜 Accept-Version 헤더 방식? (vs URL /v1/) URL 버전은 경로가 변경되어 클라이언트 수정 범위가 크다. Accept-Version 헤더는 URL을 깔끔하게 유지하면서 서버에서 버전별 라우팅이 가능하다.
왜 JWT 자체 구현? (vs OAuth 2.0 / 소셜 로그인) 가족 데이터 제어 시스템에서 외부 인증 의존은 장애 전파 위험이 있다. 자체 JWT로 customerId, role을 토큰에 포함하면 매 요청마다 DB 조회 없이 사용자 식별이 가능하다. familyId는 DB에서 조회한다.
JWT Payload:
{ "sub": "12345", "role": "OWNER", "exp": 1705312200 }공통 응답 형식:
// 성공
{ "success": true, "data": { ... }, "timestamp": "2024-01-15T10:30:00Z" }
// 에러
{ "success": false, "error": { "code": "ERROR_CODE", "message": "...", "details": {} }, "timestamp": "..." }권한 모델: member (모든 인증된 구성원) / owner (정책 수정, 복수 가능) / admin (시스템 전체 관리)
| 메서드 | 경로 | 권한 | 설명 |
|---|---|---|---|
| POST | /customers/login |
- | 전화번호 로그인 |
| POST | /customers/refresh |
member | 토큰 갱신 |
| POST | /customers/logout |
member | 로그아웃 |
| POST | /customers/signup |
- | 회원가입 |
| GET | /customers/usage |
member | 내 사용량 조회 |
| GET | /customers/policies |
member | 내 정책 조회 |
| 메서드 | 경로 | 권한 | 설명 |
|---|---|---|---|
| GET | /families/dashboard/usage?year=&month= |
member | 대시보드 사용량 |
| GET | /families/reports/usage?year=&month= |
member | 사용량 상세 리포트 |
| GET | /families/usage/current |
member | 실시간 가족 사용량 (SSE) |
| GET | /families/usage/customers |
member | 구성원별 실시간 사용량 |
| POST | /families/{familyId}/invite |
owner | 가족 초대 (전화번호) |
| POST | /families |
admin | 가족 그룹 검색 |
| GET | /families/{familyId} |
admin | 가족 상세 조회 |
| GET | /families/policies |
owner | 가족 구성원 정책 조회 |
| PATCH | /families/policies |
owner | 가족 구성원 정책 수정 |
| 메서드 | 경로 | 권한 | 설명 |
|---|---|---|---|
| GET | /policies |
admin | 정책 목록 조회 |
| POST | /policies |
admin | 정책 생성 |
| DELETE | /policies/{policyId} |
admin | 정책 삭제 |
| GET | /policies/{policyId} |
admin | 정책 상세 조회 |
| PATCH | /policies/{policyId} |
admin | 정책 수정 |
| 메서드 | 경로 | 권한 | 설명 |
|---|---|---|---|
| GET | /notifications |
member | 누적 알림 조회 (페이징, 읽음 필터) |
| GET | /notifications/alert |
member | 잔여량 경고 알림 (THRESHOLD_ALERT) |
| GET | /notifications/block |
member | 차단 알림 (BLOCKED, UNBLOCKED) |
| GET | /notifications/stream |
member | SSE 실시간 알림 스트림 |
| 메서드 | 경로 | 권한 | 설명 |
|---|---|---|---|
| POST | /admin/login |
- | 관리자 로그인 (이메일) |
| POST | /admin/refresh |
admin | 관리자 토큰 갱신 |
| POST | /admin/logout |
admin | 관리자 로그아웃 |
| GET | /admin/audit-log |
admin | 감사 로그 조회 |
| GET | /admin/dashboard |
admin | 관리자 대시보드 |
GET /families/usage/current— 가족 총 사용량 실시간 Push → Event:usage-updatedGET /families/usage/customers— 구성원별 사용량 실시간 Push → Event:usage-updated-by-memberSseEmitter기반 (UsageSseEmitterRegistry)- Spring
ApplicationEvent→@EventListener→ SSE Push usage-realtimeKafka 토픽 소비 → 실시간 대시보드 갱신
연결: GET /notifications/stream (Accept: text/event-stream)
- JWT 기반 사용자 식별
- Heartbeat: 30초 간격
- 재연결:
Last-Event-ID헤더 활용
Event Types:
| Event | Data 형식 |
|---|---|
| CUSTOMER_BLOCKED | {"customerId":..., "blockReason":..., "blockedAt":...} |
| THRESHOLD_ALERT | {"threshold":50, "remainingPercent":48.5, "message":...} |
| CUSTOMER_UNBLOCKED | {"customerId":..., "reason":..., "unblockedAt":...} |
왜 SSE? (vs WebSocket) 서버→클라이언트 단방향 푸시만 필요(사용량 갱신, 차단 알림)하다. WebSocket의 양방향은 불필요한 복잡도를 추가한다. SSE는 HTTP/2 위에서 자동 멀티플렉싱되고, 재연결(Last-Event-ID)이 프로토콜 수준에서 지원된다.
| 카테고리 | 코드 | HTTP | 설명 |
|---|---|---|---|
| 인증 | AUTH_INVALID_CREDENTIALS | 401 | 잘못된 인증 정보 |
| AUTH_TOKEN_EXPIRED | 401 | 토큰 만료 | |
| AUTH_TOKEN_INVALID | 401 | 유효하지 않은 토큰 | |
| AUTH_INSUFFICIENT_PERMISSION | 403 | 권한 부족 | |
| 데이터 | DATA_FAMILY_NOT_FOUND | 404 | 가족 그룹 없음 |
| DATA_USER_NOT_FOUND | 404 | 사용자 없음 | |
| DATA_MEMBER_LIMIT_EXCEEDED | 400 | 최대 구성원 수 초과 (10명) | |
| 정책 | POLICY_USER_BLOCKED | 403 | 사용자 차단됨 |
| POLICY_QUOTA_EXCEEDED | 403 | 할당량 초과 | |
| POLICY_TIME_BLOCKED | 403 | 시간대 차단 중 | |
| POLICY_ALREADY_BLOCKED | 409 | 이미 차단됨 | |
| POLICY_TEMPLATE_NOT_FOUND | 404 | 정책 템플릿 없음 | |
| POLICY_TEMPLATE_IN_USE | 409 | 사용 중인 정책 템플릿 | |
| 시스템 | SYS_INTERNAL_ERROR | 500 | 내부 서버 오류 |
| SYS_REDIS_UNAVAILABLE | 503 | Redis 장애 (DB Fallback) | |
| SYS_RATE_LIMIT_EXCEEDED | 429 | API 호출 제한 초과 |
| 기술 | 선택 사유 |
|---|---|
| Next.js | SSR/SSG/ISR로 초기 로딩 성능 최적화, 파일 기반 라우팅으로 생산성 향상 |
| TypeScript | 정적 타이핑으로 컴파일 단계 오류 검출, IDE 자동 완성 강화 |
| Tailwind CSS | Utility-First로 빠르고 일관된 UI 구축, CSS 비대화 방지 |
| TanStack Query | 서버 상태 캐싱/동기화 핵심. SSE 실시간 데이터로 캐시 갱신, 낙관적 업데이트 |
| React Hook Form + Zod | 비제어 컴포넌트로 리렌더링 최소화 + Zod 스키마로 런타임 타입 검증 |
| PWA | 네이티브 앱 없이 설치 가능한 앱 + Service Worker 기반 푸시 알림 |
왜 Next.js? SSR/SSG/ISR로 초기 로딩 성능을 최적화하고, 파일 기반 라우팅으로 생산성을 높인다. React 생태계를 그대로 활용할 수 있다.
왜 Turborepo 모노레포? web-service(사용자)와 web-admin(관리자)이 공통 컴포넌트/타입/유틸을 공유해야 한다. 모노레포로
packages/shared를 두면 코드 중복 제거 + UI 일관성 보장. Turborepo의 캐싱으로 빌드 시간도 단축된다.
왜 PWA? 네이티브 앱 개발 리소스 없이 설치 가능한 앱 + 푸시 알림을 제공한다. Service Worker 기반 PWA Push로 차단/임계치 알림을 즉시 전달한다.
왜 TanStack Query? 서버 상태(사용량, 정책)의 캐싱/동기화가 핵심이다. SSE로 실시간 데이터를 받아 캐시를 갱신하고, 낙관적 업데이트로 슬라이더 조작의 체감 속도를 향상시킨다.
왜 React Hook Form + Zod? 비제어 컴포넌트로 리렌더링을 최소화하고, Zod 스키마로 TypeScript 타입과 런타임 검증을 하나의 스키마로 통일한다.
.
├── apps
│ ├── admin (admin.dabom.site)
│ │ └── src
│ └── service (www.dabom.site)
│ └── src
├── packages
│ └── shared (@repo/shared)
│ └── src
├── package.json
├── pnpm-workspace.yaml
├── tsconfig.json
└── turbo.json
| 워크스페이스 | 서브도메인 | 설명 |
|---|---|---|
apps/service |
www.dabom.site |
가족 사용자 서비스 (PWA) |
apps/admin |
admin.dabom.site |
백오피스 관리 |
- apps/admin: 관리자 기능 (정책 CRUD, 가족 관리, 감사 로그).
@tanstack/react-query,dayjs - apps/service: 사용자 서비스 (대시보드, 마이페이지, 알림).
chart.js,recharts,react-hot-toast - packages/shared: 공용 UI Components (
Button,Badge,InputField), Utils (cn,http), Types (familyType,policyType), Assets (SVG → React viasvgr).tsup으로 CJS/ESM 번들링
Color System:
| 토큰 | Hex | 용도 |
|---|---|---|
primary-400 |
#fd3e97 |
![]() |
primary-500 |
#e42068 |
![]() |
gray-100 |
#eaeaea |
![]() |
brand-white |
#fdfdfe |
![]() |
brand-black |
#101010 |
![]() |
bg-base |
#f0f0f3 |
![]() |
Typography: Pretendard 폰트, Mobile/Desktop 별도 스케일 (displaycaption, 0.75rem3rem)
| 도메인 | 기능 | 동작 |
|---|---|---|
| 유저 | 로그인 | 이메일/비밀번호로 백오피스 접근 |
| 정책 | 조회/생성/수정/삭제 | 활성화/비활성화 상태 관리, 정책 템플릿 CRUD |
| 가족 | 리스트 조회 | 전체 가족 리스트, 대표자 1명 표시 |
| 상세 조회 | 가족 총 사용량, 구성원별 {이름, 권한, 사용량, 한도} | |
| 권한 수정 | owner/member 전환, 19세 이상 자녀 권한 승격 | |
| 한도 수정 | 구성원별 데이터 한도 강제 수정 (CS 목적) | |
| 검색/필터 | 가족ID, 전화번호, 이름 + 최대 2가지 필터 조합 |
| 도메인 | 기능 | 동작 |
|---|---|---|
| 유저 | 로그인/로그아웃/탈퇴 | 전화번호+비밀번호, 토큰 삭제, 정보 영구 삭제 |
| 대시보드 | 구성원별 사용량 | 현재 달: SSE 실시간, 과거 달: HTTP 분기 처리 |
| 알림 | 누적 알림 | 지금까지 누적된 알림 확인 |
| 사용 경고 | 임계값(50/30/10%)에 따른 잔여량 경고 | |
| 차단 알림 | 잔여량 소진 또는 강제 차단 시 알림 | |
| 마이페이지 | 내 사용량/정책 | 가로 막대그래프, 적용 정책 확인 |
| 정책 관리 | 구성원 정책 조회/수정 | Owner 전용: 정책 변경 |
| 시간대 차단 | 휠 피커로 차단 시간 설정 | |
| 데이터 한도 | 슬라이더 + 디바운싱, 실패 시 롤백 | |
| 차단 토글 | 즉시 차단/해제 | |
| 차단 | 글로벌 차단 | SSE 차단 이벤트 수신 → 즉시 toast 표시 |
| 지표 | 예상 값 | 대응 전략 |
|---|---|---|
| 동시 접속자 | ~100,000 | SSE 서버 수평 확장 |
| 초당 Usage Event | ~5,000 TPS | Kafka 파티션 확장 |
| processor-usage 처리 | ~5,000 TPS | Consumer 인스턴스 = 파티션 수 (10개) |
| 버스트 트래픽 | ~1,000,000 동시 | Kafka 버퍼링으로 흡수 |
| 일일 이벤트 | ~4억 건 | RDS 파티셔닝, 콜드 아카이빙 |
flowchart LR
subgraph "simulator-usage"
TG["Kafka Producer"]
end
subgraph "Kafka Cluster"
P0["Partition 0"]
P1["Partition 1"]
PN["Partition N"]
end
TG --> P0
TG --> P1
TG --> PN
subgraph "processor-usage (Consumer Group)"
C0["Consumer 0"]
C1["Consumer 1"]
CN["Consumer N"]
end
P0 --> C0
P1 --> C1
PN --> CN
subgraph "Redis Sentinel"
R1["Master"]
R2["Replica 1"]
R3["Replica 2"]
end
C0 --> R1
C1 --> R2
CN --> R3
Circuit Breaker 전체 적용:
| 대상 | 장애 감지 | Fallback |
|---|---|---|
| Redis | 연결 실패 3회 | MySQL Fallback |
| Kafka | Producer 실패 3회 | 로컬 버퍼 + 재시도 |
| MySQL | 쿼리 타임아웃 | Redis 캐시 조회 |
| 외부 알림 | 발송 실패 3회 | DLQ + 수동 처리 |
왜 Redis 장애 시 MySQL Fallback? Redis Sentinel 전체 장애는 극히 드물지만, 발생 시 서비스 중단 대신 MySQL Row Lock으로 동시성 제어하여 성능은 저하되지만 서비스를 유지한다. Fail-Open(무제한 허용)보다 정합성이 중요한 데이터 제어 시스템이므로 "Graceful Degradation"을 선택했다.
왜 DLQ(Dead Letter Queue)? 3회 실패한 이벤트를 무한 재시도하면 Consumer가 블로킹된다. DLQ에 격리하여 정상 이벤트 처리를 계속하고, 운영자가 수동으로 DLQ를 점검/재처리한다.
장애 시나리오별 대응:
| 장애 시나리오 | 대응 전략 |
|---|---|
| Kafka 브로커 장애 | 3개 이상 브로커, replication.factor=3 |
| processor-usage 장애 | Consumer Group 리밸런싱, 자동 복구 |
| Redis 노드 장애 | Sentinel 모드, MySQL Fallback |
| RDS 장애 | 읽기 전용 Replica, Redis 캐시로 서비스 유지 |
| 시나리오 | 대응 |
|---|---|
| 이벤트 유실 | 유실 허용, Batch를 활용한 보정 |
| Redis-RDS 불일치 | Write-Behind 비동기, 주기적 Reconciliation |
| 중복 이벤트 | eventId 기반 Idempotency (Redis + RDS) |
왜 이벤트 유실을 허용하고 Batch를 활용한 보정? Kafka
acks=1(Leader만)으로 처리량을 우선한다. 극소수 유실 이벤트는 매일 새벽 3시 Redis-RDS Reconciliation으로 보정한다. 100% 무손실보다 처리량과 지연시간이 중요한 트레이드오프이다.
| 항목 | 구현 |
|---|---|
| 인증 | JWT 자체 구현 (Access + Refresh Token) |
| 인가 | RBAC (Member, Owner — 복수 가능, Admin). Last Write Wins, audit_log 이력 |
| DDoS 방어 | AWS WAF 자동 차단 |
| Rate Limiting | 동적 제한 (시스템 부하에 따라 조정) |
| 감사 로그 | 모든 정책 변경 이력 기록 |
| 항목 | 도구 | 적용 방법 |
|---|---|---|
| 코드 포매팅 | Google Java Format (AOSP, 4-space indent) | ./gradlew spotlessApply |
| 코드 규칙 | Naver Java Convention (Checkstyle) | ./gradlew checkstyleMain (maxWarnings=0) |
Import 순서: java → javax → jakarta → org → net → com → 기타 → lombok
| 대상 | 규칙 |
|---|---|
| Entity | Setter 금지, @Builder/static factory 생성, BaseEntity 상속 |
| DTO | Response는 static from() + Java record, Request에 toEntity() 금지 |
| Service | Interface + Impl 구조, @Transactional은 Service에만, 100줄 초과 시 분리 |
| 예외 처리 | ApplicationException 사용, 도메인별 ErrorCode enum, RuntimeException 직접 throw 금지 |
| API 응답 | 모든 응답 ApiResponse<T>로 통일 |
왜 Interface + Impl 구조? 테스트 시 Mock 교체가 용이하고, port/adapter 인프라 추상화와 일관된 패턴을 유지한다. 100줄 초과 시 분리 규칙으로 SRP를 강제한다.
| 도구 | 용도 |
|---|---|
| JUnit 5 + Mockito | 단위 테스트 |
| Spring Boot Test | 통합 테스트 |
| EmbeddedKafka | Kafka 통합 테스트 |
| H2 | 인메모리 DB (테스트용) |
| JaCoCo | 커버리지 리포트 (목표 70%) |
| 경로 | 설명 |
|---|---|
/actuator/health |
헬스 체크 (liveness/readiness probe) |
/actuator/prometheus |
Prometheus 메트릭 수집 |
/actuator/metrics |
시스템 메트릭 |
/actuator/info |
애플리케이션 정보 |
/actuator/loggers |
런타임 로그 레벨 변경 |
- Logstash Logback Encoder: 구조화된 JSON 로그 출력
- 로그 보안: 외부 입력값
sanitizeForLog()처리 (CR/LF/TAB 치환, 128자 제한)
- Micrometer Tracing + OpenTelemetry Exporter
- OTLP 엔드포인트로 trace 데이터 전송
- 환경변수:
OTEL_TRACING_ENABLED,OTEL_SAMPLING_PROBABILITY
| 변수 | 기본값 | 설명 |
|---|---|---|
DATABASE_URL |
jdbc:mysql://localhost:3310/app_db?... |
DB 접속 URL |
DATABASE_USER |
app_user |
DB 사용자 |
DATABASE_PASSWORD |
app_password |
DB 비밀번호 |
REDIS_HOST |
localhost |
Redis 호스트 |
REDIS_PORT |
6379 |
Redis 포트 |
KAFKA_BOOTSTRAP_SERVERS |
localhost:9092 |
Kafka 브로커 |
SERVER_PORT |
8080 |
서버 포트 |
FRONTEND_URL |
http://localhost:3000 |
CORS 허용 origin |
OTEL_TRACING_ENABLED |
false |
트레이싱 활성화 |
| 변수 | 서비스 | 설명 |
|---|---|---|
JWT_SECRET_KEY |
api-core, api-notification | JWT 서명 키 |
JWT_ACCESS_TOKEN_EXPIRES_IN |
api-core, api-notification | Access Token 유효기간 (ms) |
JWT_REFRESH_TOKEN_EXPIRES_IN |
api-core, api-notification | Refresh Token 유효기간 (ms) |
KAFKA_CONSUMER_GROUP_ID |
전체 (값 다름) | api-core: dabom-api-core-dev, processor-usage: dabom-processor-usage-dev, api-notification: dabom-notification-dev |
KAFKA_POLICY_DEDUP_TTL_SECONDS |
processor-usage | 정책 이벤트 중복 방지 TTL (기본 3600초) |
KAFKA_USAGE_PERSIST_DEDUP_TTL_SECONDS |
processor-usage | 사용량 DB 저장 중복 방지 TTL (기본 600초) |
| 태스크 | 설명 |
|---|---|
./gradlew bootRun |
애플리케이션 실행 (.env 자동 로딩) |
./gradlew bootJar |
실행 가능한 JAR 빌드 |
./gradlew test |
단위 테스트 + JaCoCo 커버리지 |
./gradlew spotlessApply |
Google Java Format 자동 포매팅 |
./gradlew checkstyleMain |
Naver Java Convention 검증 |
./gradlew sonar |
SonarQube 정적 분석 |
FROM eclipse-temurin:21-jdk AS builder
# ...빌드 단계 (bootJar)...
FROM eclipse-temurin:21-jre
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]왜 Multi-stage Build? 빌드 도구(JDK, Gradle)를 최종 이미지에서 제외하여 이미지 크기를 최소화한다. eclipse-temurin:21-jdk(~400MB) → eclipse-temurin:21-jre(~200MB)로 절반 이상 감소.
flowchart TB
subgraph "Edge"
CF["CloudFront CDN"]
WAF["AWS WAF"]
end
subgraph "AWS VPC"
subgraph "Public Subnet"
ALB["Application<br/>Load Balancer"]
end
subgraph "Private Subnet - Compute"
subgraph "ECS Fargate"
AC["api-core<br/>Replicas: 3"]
UP["processor-usage<br/>Replicas: 10"]
AN["api-notification<br/>Replicas: 2"]
end
end
subgraph "Private Subnet - Data"
MSK["Amazon MSK<br/>(Kafka)"]
EC["ElastiCache<br/>(Redis Sentinel)"]
RDS["Amazon RDS<br/>(MySQL)"]
end
end
CF --> WAF
WAF --> ALB
ALB --> AC
ALB --> AN
AC --> RDS
AC --> EC
AC --> MSK
UP --> MSK
UP --> EC
UP --> RDS
AN --> MSK
Note: simulator-usage는 별도 환경(온프레미스/EC2)에서 실행되며, MSK로 직접 이벤트 발행
| 서비스 | 배포 환경 | Replicas | 리소스 |
|---|---|---|---|
| api-core | ECS Fargate | 3 | 2 vCPU, 4GB |
| processor-usage | ECS Fargate | 10 | 2 vCPU, 4GB |
| api-notification | ECS Fargate | 2 | 1 vCPU, 2GB |
| simulator-usage | EC2 / 온프레미스 | 1-3 | 가변 |
| web-service | CloudFront + S3 | - | Static (www.dabom.site) |
| web-admin | CloudFront + S3 | - | Static (admin.dabom.site) |
왜 ECS Fargate?: 서버리스 컨테이너로 인프라 관리 부담 최소화. 트래픽에 따라 Auto Scaling이 자동 동작하며, Kubernetes 대비 운영 복잡도가 낮아 7인 팀에 적합
왜 web-core를 서브도메인 분리 배포?:
www.dabom.site(사용자)와admin.dabom.site(관리자)를 분리하면 독립 배포가 가능하고, CloudFront 캐시 정책도 역할별로 최적화 가능
| 영역 | 도구 | 용도 |
|---|---|---|
| 로그 | CloudWatch Logs / ELK | 애플리케이션 로그 수집 |
| 메트릭 | Prometheus + Grafana | 시스템 메트릭 시각화 |
| 트레이싱 | Jaeger / X-Ray | 분산 추적 |
| 알림 | PagerDuty / Slack | 장애 알림 |
왜 Prometheus + Grafana?: 오픈소스 기반으로 비용 부담 없이 메트릭 수집/시각화 가능. Grafana 대시보드로 Kafka Consumer Lag, Redis 응답시간, TPS 등을 실시간 모니터링
| 메트릭 | 임계값 | 알림 채널 |
|---|---|---|
| processor-usage 처리 지연 | > 100ms (P99) | Slack 알림 |
| Kafka Consumer Lag | > 10,000 | PagerDuty |
| Redis 응답 시간 | > 5ms (P99) | Slack 알림 |
| 차단 비율 급증 | > 10% (1분간) | Slack 알림 |
| 에러율 | > 1% | PagerDuty |
| 시나리오 | 설정 | 검증 포인트 |
|---|---|---|
| 기본 부하 | 고정 RPS 5,000 | 처리량, 지연 시간 |
| 랜덤 이벤트 | 무작위 familyId/customerId | 순서 보장, 정합성 |
| 단계 | TPS | 지속 시간 | 목표 |
|---|---|---|---|
| Warm-up | 1,000 | 5분 | 시스템 안정화 |
| Normal | 5,000 | 30분 | 정상 운영 확인 |
| Peak | 10,000 | 10분 | 피크 대응 확인 |
| Stress | 15,000 | 5분 | 한계점 파악 |
왜 3단계 부하 테스트 도구 전략?:
- 1단계 kafka-producer-perf-test: MSK 인프라 자체의 한계점을 먼저 확인 (인프라 병목 분리)
- 2단계 simulator-usage (Go): 실제 도메인 페이로드로 E2E 파이프라인 검증 (도메인 시나리오)
- 3단계 k6: api-core/api-notification HTTP API 부하 테스트 (API 레벨)
각 계층을 분리하여 병목 구간을 정확히 식별할 수 있음
| 시나리오 | 테스트 방법 | 예상 결과 |
|---|---|---|
| Redis 장애 | 노드 강제 종료 | MySQL Fallback 전환 |
| Kafka 브로커 장애 | 브로커 1개 종료 | 자동 리밸런싱 |
| processor-usage 장애 | 인스턴스 강제 종료 | Consumer Group 리밸런싱 |
| 네트워크 지연 | tc를 통한 지연 주입 | 타임아웃 처리 확인 |
| 버스트 트래픽 | 100만 동시 이벤트 발행 | Kafka 버퍼링, Lag 증가 후 정상 처리 |
| usage-persist 자기소비 지연 | Consumer Lag 인위 증가 | Write-Behind 지연 허용, 최종 정합성 확인 |
- 목표: 70%
- 필수 테스트 영역: 동시성 제어 Lua Script, 정책 엔진, Idempotency
| 트리거 | 알림 대상 | 알림 내용 | 이벤트 |
|---|---|---|---|
| 잔여 50% 도달 | 전체 가족 | "가족 데이터가 50% 남았습니다" | notification-events (THRESHOLD_ALERT) |
| 잔여 30% 도달 | 전체 가족 | "가족 데이터가 30% 남았습니다" | notification-events (THRESHOLD_ALERT) |
| 잔여 10% 도달 | 전체 가족 | "가족 데이터가 10% 미만입니다!" | notification-events (THRESHOLD_ALERT) |
| 개인 한도 초과 | 해당 구성원 + Owner | "데이터 한도 초과로 차단됩니다" | notification-events (CUSTOMER_BLOCKED) |
| 시간대 차단 시작 | 차단 대상 | "야간 차단이 활성화되었습니다" | notification-events (CUSTOMER_BLOCKED) |
| 시간대 차단 종료 | 차단 대상 | "야간 차단이 해제되었습니다" | notification-events (CUSTOMER_UNBLOCKED) |
| 정책 변경 | 영향받는 구성원 | "데이터 정책이 변경되었습니다" | policy-updated |
| 채널 | 전달 방식 | 지연 |
|---|---|---|
| 인앱 알림 | SSE 기반 실시간 스트림 | 즉시 |
| PWA 푸시 알림 | Service Worker 기반 | 즉시 |
- 임계치당 1회: 50%, 30%, 10% 각 임계치별로 한 번만 발송
- Redis 기록:
family:{familyId}:alert:threshold:{value}키로 발송 기록 (TTL: quota reset까지)
왜 임계치당 1회만?: 잔여량이 경계 근처에서 오르내릴 때 알림 폭풍(Notification Storm) 방지. Redis TTL 기반 발송 기록으로 간단하게 구현하며, 월간 리셋 시점에 TTL이 자동 만료되어 다음 월에는 다시 알림 가능
가장 리스크가 큰 트래픽 처리와 동시성 제어(최우선 기능)를 먼저 검증합니다.
W1 (설계 및 세팅)
- Kafka 토픽 설계 (
usage-events,policy-updated,notification-events,usage-persist) - Redis Sentinel 구성 및 Lua Script 프로토타이핑
- simulator-usage 제작: 고정 RPS 랜덤 이벤트 생성
W2 (수집 및 제어)
- simulator-usage 구현 (eventId 생성, Kafka 직접 발행)
- processor-usage 핵심 로직 (Kafka Consumer, 검증, Redis Lua Script, 선착순 완전 승인)
- "Last 10MB" 동시성 테스트 수행
W3 (시각화 및 연동)
- SSE 서버 구현
- 기본 프론트엔드 연동 (실시간 대시보드)
W4 (정책 고도화)
- 동적 정책 적용 (Owner가 한도 변경 시 RDS 갱신 + Kafka 전파 → processor-usage가 Redis 반영)
- 시간대별 차단 정책 (KST 기준)
- 가족 구성원 권한 관리 (RBAC)
- JWT 인증 시스템 구현
W5 (백오피스 & 정산)
- Admin API 개발 (정책 CRUD, 개별 가족 수정)
- Write-Behind 패턴: Kafka를 통한 RDS 비동기 저장
- 상세 분석 리포트 구현
W6 (안정성)
- Circuit Breaker 전체 적용 (Redis, Kafka 포함)
- DB Fallback: Redis 장애 시 MySQL 전환
- Dead Letter Queue(DLQ) 처리: 3회 실패 시 DLQ
- Flyway 자동 마이그레이션
W7 (최종 검증)
- 부하 테스트 (simulator-usage)
- 전체 Observability 구성 (로그 + 메트릭 + 트레이싱)
- 테스트 커버리지 70% 달성
- 최종 발표 자료 및 데모 준비
왜 Phase 1에서 동시성 제어를 최우선?: 가장 리스크가 큰 기술적 난제(Redis Lua Script, Kafka 순서 보장, "Last 10MB")를 먼저 검증해야 이후 비즈니스 로직 구현의 기반이 됨. 실패 시 아키텍처 전면 재설계가 필요하므로 조기 검증이 핵심. "가장 어려운 것을 먼저, 가장 확실하지 않은 것을 먼저"
| 역할 | 담당 영역 | 주요 기술 스택 & 책임 |
|---|---|---|
| BE 1 (Core) | Policy Engine & Concurrency | Redis(Lua), Kafka Consumer, 동시성 제어 (최우선) |
| BE 2 (Ingest) | Traffic Gateway & Simulator | Kafka Producer, simulator-usage 개발 (Go) |
| BE 3 (Biz) | Family Service & API | Spring Boot, JPA, JWT 인증, SSE |
| BE 4 (Data) | Settlement & Persistence | Write-Behind, MySQL, Flyway |
| BE 5 (Ops) | Backoffice & DevOps | Admin API, Docker/K8s, Observability |
| 역할 | 담당 영역 | 주요 기술 스택 |
|---|---|---|
| FE 1 | web-service (가족 앱, www.dabom.site) | Next.js, TypeScript, Tailwind, SSE, PWA |
| FE 2 | web-admin (백오피스, admin.dabom.site) | Next.js, TypeScript, Tailwind |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 가족 그룹 | Family Group | 데이터를 공유하는 사용자들의 집합. 최대 10명까지 구성 가능 |
| 가족 구성원 | Family Member | 가족 그룹에 속한 일반 사용자. 데이터 조회만 가능 |
| Owner 계정 | Owner | 가족 그룹 내 정책 수정 권한을 가진 관리 사용자. 복수 Owner 가능 (family_member.role='OWNER' 기준) |
| 운영자 | Backoffice Admin | 시스템 전체를 관리하는 내부 관리자 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 할당량 | Quota | 사용자 또는 그룹에 할당된 데이터 한도 (바이트 단위) |
| 잔여량 | Remaining | 사용 가능한 남은 데이터량 |
| 사용량 | Usage | 실제로 사용한 데이터량 |
| 월별 한도 | Monthly Limit | 한 달 동안 사용할 수 있는 최대 데이터량 |
| 임계치 | Threshold | 알림을 발송하는 기준점 (50%, 30%, 10%) |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 정책 | Policy | 데이터 사용에 적용되는 규칙 (한도, 시간대 차단 등) |
| 시간대 차단 | Time Block | 특정 시간대(예: 22:00~07:00)에 데이터 사용을 차단하는 정책 |
| 즉시 차단 | Manual Block | Owner가 특정 구성원의 데이터 사용을 즉시 차단하는 기능 |
| 앱별 차단 | App Block | 특정 앱/서비스의 데이터 사용을 차단하는 정책 (MVP 제외) |
| 선착순 완전 승인 | First-Come-First-Served | 동시 요청 시 먼저 도착한 요청만 전체 승인, 나머지는 즉시 차단 |
| 기본 규칙 | Default Rules | 정책 템플릿에 정의된 기본 규칙 JSON (default_rules). 적용 시 POLICY_ASSIGNMENT.rules로 복사 |
| 최소 설정 권한 | Require Role | 정책을 설정할 수 있는 최소 역할 (require_role). MEMBER이면 MEMBER 이상이 설정 가능, OWNER이면 OWNER만 설정 가능 |
| 정책 활성화 | Is Active | 정책 템플릿의 활성/비활성 상태 (DB: is_activate, API: isActive). FALSE이면 신규 적용 불가 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 임계치 알림 | Threshold Alert | 잔여량이 특정 임계치(50/30/10%)에 도달했을 때 발송되는 알림 |
| 차단 알림 | Block Notification | 사용자가 차단되었을 때 발송되는 알림 |
| 정책 변경 알림 | Policy Update Notification | 정책이 변경되었을 때 영향받는 사용자에게 발송되는 알림 |
| 임계치당 1회 | Once Per Threshold | 각 임계치별로 한 번만 알림을 발송하는 정책 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 마이페이지 | MyPage | 로그인한 사용자 본인의 사용량 및 적용 정책을 조회하는 개인 영역 |
| 데이터 차단/허용 | Data Block | Owner가 특정 구성원의 데이터 사용을 즉시 차단 또는 허용하는 통합 기능 |
| 정책 템플릿 | Policy Template | 관리자(admin)가 생성/관리하는 재사용 가능한 정책 정의 |
| REST 알림 조회 | REST Notification | SSE 실시간 알림과 병행하여 알림 이력을 REST API로 조회하는 기능 |
| 고객 | Customer | 시스템의 일반 사용자 (가족 구성원). 기존 USER 테이블에서 CUSTOMER로 분리 |
| 구성원 월별 할당량 | Customer Quota | 구성원별 월별 데이터 한도와 사용량, 차단 상태를 관리하는 엔티티 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 이벤트 기반 아키텍처 | Event-Driven Architecture (EDA) | 이벤트의 발생, 감지, 처리를 중심으로 설계된 아키텍처 패턴 |
| Write-Behind | Write-Behind Pattern | 먼저 캐시(Redis)에 기록하고, 나중에 비동기로 DB에 반영하는 패턴 |
| 자기소비 패턴 | Self-Consumption Pattern | 동일 서비스가 Kafka 토픽에 발행한 뒤 같은 토픽을 직접 소비하는 패턴 |
| Soft Delete | Soft Delete | 물리적 삭제 대신 deleted_at 컬럼에 삭제 시각을 기록하는 논리 삭제 방식 |
| 멱등성 | Idempotency | 동일 요청을 여러 번 처리해도 결과가 같은 성질 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 원자 연산 | Atomic Operation | 분리할 수 없는 단일 단위로 실행되는 연산. 전부 성공하거나 전부 실패 |
| 파티션 키 | Partition Key | Kafka에서 메시지를 파티션에 분배하는 기준이 되는 키 (familyId) |
| 컨슈머 그룹 | Consumer Group | 동일한 토픽을 구독하는 컨슈머들의 집합 |
| 컨슈머 랙 | Consumer Lag | 메시지 발행 속도와 처리 속도의 차이로 발생하는 지연 |
| DLQ | Dead Letter Queue | 처리에 실패한 메시지를 저장하는 별도의 큐 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 런타임 제약 | Runtime Constraints | 다양한 정책이 계산되어 실제 적용될 제약 조건의 모음. Redis Hash에 ACTION:TYPE 형태의 Field로 저장 |
| 이벤트 봉투 | Event Envelope | 모든 Kafka 이벤트를 감싸는 공통 래퍼 구조. Jackson @JsonTypeInfo 활용 다형성 역직렬화 지원 |
| 정책 키 | Policy Key | 런타임 제약의 Field Name 표준. ACTION:TYPE:TARGET 형태 (예: LIMIT:DATA:DAILY, BLOCK:APP:com.youtube) |
| Poly-Policy Engine | Poly-Policy Engine | Redis Lua Script 기반의 다중 정책 평가 엔진. constraints Hash를 동적으로 순회하며 코드 수정 없이 정책 평가 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 분산 락 | Distributed Lock | 여러 서버에서 동시에 같은 리소스에 접근하는 것을 방지하는 잠금 메커니즘 |
| Lua 스크립트 | Lua Script | Redis에서 여러 명령을 원자적으로 실행하기 위해 사용하는 스크립트 |
| CAS | Compare-And-Set | 현재 값을 비교하고 일치할 때만 새 값으로 설정하는 원자 연산 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 서킷 브레이커 | Circuit Breaker | 장애가 발생한 서비스에 대한 호출을 일시적으로 차단하는 패턴 |
| 폴백 | Fallback | 주 시스템 장애 시 대체 시스템으로 전환하는 것 |
| Fail-Open | Fail-Open | 장애 시 제한 없이 허용하는 정책 (사용성 우선) |
| Fail-Closed | Fail-Closed | 장애 시 모든 요청을 차단하는 정책 (안전 우선) |
| 지수 백오프 | Exponential Backoff | 재시도 간격을 점진적으로 늘리는 전략 (1초, 5초, 30초 등) |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| Batch를 활용한 보정 | Reconciliation | Redis와 RDS 간 데이터 불일치를 주기적으로 보정하는 작업. 매일 새벽 3시 실행 |
| 계층형 보관 | Tiered Storage (Hot/Warm/Cold) | 데이터 접근 빈도에 따라 저장소를 분리. Hot(7일: Redis+RDS), Warm(180일: RDS), Cold(180일+: S3) |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| SSE | Server-Sent Events | 서버에서 클라이언트로 단방향 실시간 스트림을 전송하는 기술 |
| WebSocket | WebSocket | 서버와 클라이언트 간 양방향 실시간 통신을 지원하는 프로토콜 |
| PWA | Progressive Web App | 웹 기술로 구현된 네이티브 앱 같은 웹 애플리케이션 |
| 컴포넌트 | 역할 |
|---|---|
| simulator-usage | 실시간 데이터 소모 이벤트 시뮬레이터. eventId 생성 후 Kafka로 직접 발행 |
| processor-usage | 이벤트 검증, 중복 체크, 정책평가/쿼터차감, Redis Atomic, usage-persist 자기소비(Write-Behind), notification-events 발행 |
| api-core | 5개 도메인 REST API, JWT 인증, 정책 즉시 반영 트리거 |
| api-notification | notification-events consumer, SSE API + REST 알림 조회 API, 실시간 알림 Push |
| web-core | Turborepo 기반 프론트엔드 모노레포 (web-service + web-admin + shared) |
| web-service | 가족 사용자 PWA (www.dabom.site) |
| web-admin | 백오피스 관리 UI (admin.dabom.site) |
| 이벤트 | 설명 |
|---|---|
usage-events |
데이터 사용 이벤트 (simulator-usage → Kafka → processor-usage) |
policy-updated |
정책 변경 이벤트 (api-core → processor-usage) |
notification-events |
통합 알림 이벤트 (processor-usage → api-notification). subType: CUSTOMER_BLOCKED, THRESHOLD_ALERT |
usage-persist |
DB 저장용 이벤트 (processor-usage → Kafka → processor-usage 자기소비 → MySQL) |
| 약어 | 전체 표현 | 의미 |
|---|---|---|
| TPS | Transactions Per Second | 초당 트랜잭션 수 |
| SSE | Server-Sent Events | 서버에서 클라이언트로의 단방향 스트림 |
| PWA | Progressive Web App | 설치 가능한 웹 앱 |
| JWT | JSON Web Token | JSON 기반 인증 토큰 |
| RBAC | Role-Based Access Control | 역할 기반 접근 제어 |
| DLQ | Dead Letter Queue | 실패 메시지 저장 큐 |
| TTL | Time To Live | 데이터 유효 기간 |
| RDS | Relational Database Service | 관계형 데이터베이스 서비스 |
| MSK | Managed Streaming for Apache Kafka | AWS 관리형 Kafka |
| ECS | Elastic Container Service | AWS 컨테이너 서비스 |
| ALB | Application Load Balancer | 애플리케이션 로드 밸런서 |
| WAF | Web Application Firewall | 웹 애플리케이션 방화벽 |
| CDN | Content Delivery Network | 콘텐츠 전송 네트워크 |
| KST | Korea Standard Time | 한국 표준시 |
| P99 | 99th Percentile | 상위 99% 기준값 |
| ADR | Architecture Decision Record | 아키텍처 결정 기록 |
| 용어 (한글) | 용어 (영문) | 정의 |
|---|---|---|
| 다중 Owner | Multiple Owners | 한 가족 그룹 내에 복수의 OWNER 역할 구성원이 존재하는 구조 |
| Last Write Wins | LWW | 복수 OWNER가 동일 정책을 동시에 수정할 경우, 마지막으로 수정한 값이 적용. 모든 변경은 audit_log에 기록 |
| 그룹 생성자 | Created By | 가족 그룹을 최초로 생성한 사용자. 이력/감사 전용으로 OWNER 권한 판단에는 사용하지 않음 |
| 코드 | 설명 |
|---|---|
MONTHLY_LIMIT_EXCEEDED |
월별 한도 초과 |
FAMILY_QUOTA_EXCEEDED |
가족 할당량 소진 |
TIME_BLOCK |
시간대 차단 정책 |
MANUAL |
Owner에 의한 수동 차단 |
APP_BLOCK |
앱별 차단 정책 (MVP 제외) |
DABOM 팀 | URECA 프로젝트 | 2026





