본문으로 건너뛰기

사용자 참여도 향상 시스템 아키텍처

🏗 전체 시스템 아키텍처

🎯 아키텍처 설계 원칙

1. 도메인 주도 설계 (DDD)

  • 도메인 분리: Sleep, User, Questionnaire, Agent Treatment Flow 도메인 독립 운영
  • 바운디드 컨텍스트: 각 도메인은 자체적인 비즈니스 로직과 데이터 모델 보유
  • 도메인 이벤트: 도메인 간 통신은 이벤트를 통해서만 수행

2. 이벤트 드리븐 아키텍처 (EDA)

  • 비동기 처리: 모든 참여도 향상 메시징은 이벤트 기반 비동기 처리
  • 느슨한 결합: 도메인 간 직접 의존성 제거
  • 확장성: 새로운 메시징 유형 추가 시 기존 코드 변경 최소화
  • 웹훅 기반: n8n은 GCP Pub/Sub 직접 구독 불가로 HTTP 웹훅 방식 사용

3. 워크플로우 오케스트레이션 기반 설계 (병렬 처리)

  • 이벤트 발행: dta-wide-api가 도메인 이벤트 발행
  • 병렬 이벤트 처리: events 토픽에 2개의 Push 구독으로 병렬 처리
    • Push 구독 1: /api/events → 이벤트 저장 (Firestore)
    • Push 구독 2: n8n webhook → User Engagement 워크플로우
  • 워크플로우 처리: n8n이 Pub/Sub Push 구독으로 직접 이벤트 수신 및 처리
  • 템플릿 관리: NHN Cloud Console에서 템플릿 생성 및 심의, n8n에서 내장 매핑
  • API 분리: dta-wide-api는 순수 User Engagement API만 제공

4. Pub/Sub Push 구독 기반 n8n 연동 (최적화된 방식)

  • 제약사항: n8n은 GCP Pub/Sub Pull 구독을 직접 할 수 없음
  • 해결방법: GCP Pub/Sub Push 구독으로 n8n webhook 직접 호출 (중간 단계 제거)
  • 신뢰성: Pub/Sub 내장 재시도 메커니즘 및 Dead Letter Queue 활용
  • 성능: 병렬 처리로 이벤트 저장과 User Engagement 처리 독립 실행
  • 일관성: 기존 terragrunt.hcl의 다른 Push 구독들과 동일한 패턴

5. 템플릿 관리 (간소화)

  • 사전 승인: 모든 템플릿은 NHN Cloud Console에서 사전 승인 필요
  • Template ID 관리: 승인된 Template ID를 n8n 워크플로우에서 직접 매핑으로 관리
  • 매핑 로직: n8n 내장 JavaScript 코드로 이벤트 타입별 Template ID 매핑

🔄 수정된 이벤트 플로우 (n8n 웹훅 기반)

기술적 제약사항과 해결방법

❌ 불가능: n8n → GCP Pub/Sub 직접 구독 (Pull 방식)
✅ 해결책: GCP Pub/Sub → n8n webhook (Push 구독 방식)

전체 플로우 (병렬 처리)

1. Domain Service → Internal Event → Cross Event Publisher
2. Cross Event Publisher → GCP Pub/Sub ('events' 토픽)
3a. Pub/Sub Push 구독 1 → /api/events → 이벤트 저장 (Firestore)
3b. Pub/Sub Push 구독 2 → n8n webhook → 직접 워크플로우 트리거
4. n8n Workflow → 내장 Template 매핑 → User Engagement API 호출
5. User Engagement API → NHN Cloud AlimTalk API → 카카오톡 발송

핵심 구현 포인트 (개선됨)

  • 병렬 처리: 이벤트 저장과 User Engagement 처리가 독립적으로 병렬 실행
  • 성능 향상: /api/events에서 추가 HTTP 호출 부담 제거
  • 신뢰성: Pub/Sub 내장 재시도 메커니즘을 n8n도 직접 활용
  • 간소화: 중간 단계 제거로 아키텍처 복잡도 감소
  • 일관성: 기존 terragrunt.hcl의 다른 Push 구독들과 동일한 패턴

🔧 핵심 컴포넌트 설계

1. Event Bus (Google Cloud Pub/Sub) - 표준 이벤트 아키텍처

Topic 구조 (event-system.md 준수)

# 단일 중앙화된 토픽
events # 모든 크로스 도메인 이벤트가 집중되는 단일 토픽

이벤트 타입별 구분 (토픽 내에서 type 필드로 구분)

// 모든 크로스 도메인 이벤트는 동일한 'events' 토픽에 발행되지만
// type 필드로 이벤트 종류를 구분

interface StandardEvent<T = any> {
type: string; // 예: 'user.cycle.started', 'sleep.rtib.calculated'
eventId: string; // 고유 이벤트 ID
timestamp: string; // ISO 형식 타임스탬프
source: string; // 이벤트 소스 (서비스/모듈 이름)
payload: T; // 이벤트 페이로드
version?: string; // 이벤트 스키마 버전
}

Subscription 구조 (단일 Push 구독)

# dta-wide-api의 단일 Push 구독
events-push-subscription # 'events' 토픽 → dta-wide-api /api/events 엔드포인트
# 모든 이벤트 타입을 수신하여 적절한 핸들러로 라우팅

# n8n Webhook은 dta-wide-api를 통해 이벤트 수신
# (직접 Pub/Sub 구독하지 않음)

2. dta-wide-api 컴포넌트 (표준 이벤트 아키텍처)

표준 이벤트 아키텍처 구성

dta-wide-api는 event-system.md에 정의된 표준 이벤트 아키텍처를 준수합니다:

// 1. 도메인 서비스 - 내부 이벤트만 발행
interface DomainService {
// 비즈니스 로직 처리 후 내부 이벤트 발행
processBusiness(): Promise<void>;
}

// 2. 내부 이벤트 핸들러 - 도메인 내부 처리 + 크로스 이벤트 발행 담당
@Injectable()
class DomainInternalEventHandlers {
constructor(
private readonly crossEventPublisher: DomainCrossEventPublisher,
private readonly eventEmitter: EventEmitter2
) {}

@OnEvent('{DOMAIN}_INTERNAL_EVENT_CHANNELS.SOMETHING_HAPPENED')
async handleSomethingHappened(event: SomethingHappenedEvent): Promise<void> {
// 1. 도메인 내부 처리 (캐시 무효화 등)
await this.performInternalTasks(event);

// 2. 필요시 크로스 도메인 이벤트 발행
await this.crossEventPublisher.publishSomethingHappened(event);
}
}

// 3. 크로스 이벤트 발행자 - 'event' 채널로만 발행
@Injectable()
class DomainCrossEventPublisher {
constructor(private readonly eventEmitter: EventEmitter2) {}

async publishSomethingHappened(event: SomethingHappenedEvent): Promise<void> {
const crossEvent = this.createStandardEvent(
'{DOMAIN}_CROSS_EVENT_TYPES.SOMETHING_HAPPENED',
event.payload
);

// 'event' 채널로만 발행 → GCP Pub/Sub 'events' 토픽으로 전송
this.eventEmitter.emit('event', crossEvent);
}
}

AlimTalk API

// dta-wide-api의 알림톡 전송 API
interface AlimTalkApiService {
// 알림톡 전송 (n8n에서 호출)
sendAlimTalk(request: AlimTalkSendRequest): Promise<AlimTalkResult>;

// SMS 대체 발송
sendSMS(request: SMSSendRequest): Promise<SMSResult>;

// 발송 상태 조회
getDeliveryStatus(messageId: string): Promise<DeliveryStatus>;

// 발송 이력 조회
getDeliveryHistory(userId: string, dateRange: DateRange): Promise<DeliveryHistory[]>;
}

interface AlimTalkSendRequest {
userId: string;
templateId: string; // NHN Cloud에서 승인받은 Template ID
templateParams: Record<string, string>; // 템플릿 변수들 (#{회원명}, #{처방병원명} 등)
recipientNumber: string; // 수신자 전화번호
fallbackToSMS?: boolean; // 알림톡 실패 시 SMS 대체 발송 여부
priority: 'high' | 'medium' | 'low';
}

3. n8n 워크플로우 엔진

워크플로우 구조 (기획자 관리형)

// n8n 워크플로우의 핵심 로직 (템플릿 매핑은 기획자가 직접 관리)
interface N8nWorkflow {
// 1. 이벤트 수신 (Webhook)
receiveEvent(event: DomainEvent): Promise<EventContext>;

// 2. 템플릿 매핑 (⭐ 기획자가 n8n GUI에서 직접 관리)
mapEventToTemplate(eventType: string): Promise<TemplateConfig>;

// 3. 사용자 정보 조회 (User Domain API)
enrichUserData(userId: string): Promise<UserInfo>;

// 4. 템플릿 데이터 생성
buildTemplateData(templateConfig: TemplateConfig, userInfo: UserInfo, eventData: any): TemplateData;

// 5. Direct API 호출 (템플릿 매핑 완료된 상태)
callDirectNotificationAPI(request: DirectNotificationRequest): Promise<NotificationResult>;
}

// ⭐ 기획자가 n8n에서 관리하는 템플릿 설정
interface TemplateConfig {
templateId: string; // NHN Cloud Template ID (심의 완료)
templateName: string; // 템플릿 이름
priority: 'HIGH' | 'NORMAL' | 'LOW';
channels: ('alimtalk' | 'push')[]; // 발송 채널
buttons?: AlimtalkButton[]; // 알림톡 버튼
fallbackTemplateId?: string; // 대체 템플릿 ID
isActive: boolean; // 활성화 여부
}

n8n 워크플로우 예시

// n8n Workflow: 사용자 가입 완료 알림톡 발송
{
"nodes": [
{
"name": "Webhook Trigger",
"type": "n8n-nodes-base.webhook",
"parameters": {
"path": "user-events-webhook"
}
},
{
"name": "Event Filter",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": {
"string": [
{
"value1": "={{$json.type}}",
"operation": "equal",
"value2": "user.created"
}
]
}
}
},
{
"name": "Template Mapping",
"type": "n8n-nodes-base.function",
"parameters": {
"functionCode": `
const eventType = items[0].json.type;
const userId = items[0].json.payload.userId;
const userName = items[0].json.payload.userName;

// Template ID mapping (NHN Cloud에서 사전 승인받은 ID)
const templateMappings = {
'user.created': 'SLEEPQ_WELCOME_001',
'user.prescription.guidance': 'SLEEPQ_PRESCRIPTION_001',
// ... 기타 매핑들
};

return [{
json: {
templateId: templateMappings[eventType],
userId: userId,
templateParams: {
'회원명': userName,
// ... 기타 변수들
}
}
}];
`
}
},
{
"name": "Validation Check",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"url": "{{$json.apiBaseUrl}}/user/{{$json.userId}}/notification-preferences",
"method": "GET"
}
},
{
"name": "Send AlimTalk",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"url": "{{$json.apiBaseUrl}}/alimtalk/send",
"method": "POST",
"body": {
"userId": "={{$json.userId}}",
"templateId": "={{$json.templateId}}",
"templateParams": "={{$json.templateParams}}",
"recipientNumber": "={{$json.userPhoneNumber}}",
"fallbackToSMS": true,
"priority": "high"
}
}
}
]
}

이벤트 처리 플로우 (표준 이벤트 아키텍처 준수)

4. NHN Cloud Template 관리

NHN Cloud Console 템플릿 관리 프로세스

n8n 내장 템플릿 매핑 구조

// n8n 워크플로우 내부에서 관리하는 Template ID 매핑
const TEMPLATE_MAPPING = {
// Sleep 도메인 이벤트
'sleep.rtib.pre_notification.triggered': {
templateId: 'SLEEPQ_RTIB_PRE_001',
templateParams: ['userName', 'weekNumber', 'sleepLogCount'],
fallbackSms: true,
priority: 'high',
category: 'rtib'
},
'sleep.sleep_log.reminder.insufficient_data': {
templateId: 'SLEEPQ_SLEEP_LOG_REMINDER_001',
templateParams: ['userName', 'dayIndex', 'currentLogCount', 'requiredLogCount'],
fallbackSms: true,
priority: 'high',
category: 'reminder'
},

// User 도메인 이벤트
'user.prescription.guidance.notification.sent': {
templateId: 'SLEEPQ_WELCOME_001',
templateParams: ['userName', 'hospitalName', 'prescriptionDate'],
fallbackSms: true,
priority: 'critical',
category: 'onboarding'
},
'user.registration.completion.notification.sent': {
templateId: 'SLEEPQ_REGISTRATION_001',
templateParams: ['userName', 'estimatedCallDate'],
fallbackSms: false,
priority: 'high',
category: 'onboarding'
},
'user.inactivity.reminder.triggered': {
templateId: 'SLEEPQ_INACTIVITY_001',
templateParams: ['userName', 'daysSinceLastAccess'],
fallbackSms: true,
priority: 'medium',
category: 'reminder'
},

// Questionnaire 도메인 이벤트
'questionnaire.questionnaire.round.reminder.triggered': {
templateId: 'SLEEPQ_SURVEY_REMINDER_001',
templateParams: ['userName', 'roundNumber', 'dayIndex'],
fallbackSms: true,
priority: 'medium',
category: 'survey'
},
'questionnaire.final.questionnaire.reminder.sent': {
templateId: 'SLEEPQ_FINAL_SURVEY_001',
templateParams: ['userName', 'weeksSinceTreatmentEnd'],
fallbackSms: true,
priority: 'low',
category: 'survey'
},

// Agent Treatment Flow 도메인 이벤트
'agent-treatment-flow.crm.call.reminder.sent': {
templateId: 'SLEEPQ_CRM_REMINDER_001',
templateParams: ['userName', 'callDate', 'callTime', 'contactNumber'],
fallbackSms: false,
priority: 'medium',
category: 'crm'
}
};

// n8n 워크플로우에서 Template ID 조회 함수
function getTemplateConfig(eventType) {
const config = TEMPLATE_MAPPING[eventType];
if (!config) {
throw new Error(`No template mapping found for event type: ${eventType}`);
}
return config;
}

n8n 워크플로우 내장 매핑 로직 (간소화)

// n8n 워크플로우에서 직접 관리하는 템플릿 매핑 (DB 없이)

// 이벤트 타입별 Template ID 매핑 함수
function getTemplateIdForEvent(eventType) {
const mapping = getTemplateConfig(eventType);
return mapping.templateId;
}

// 템플릿 파라미터 생성 함수
function buildTemplateParams(event, templateConfig) {
const params = {};

// 이벤트 페이로드에서 필요한 값들 추출
for (const paramName of templateConfig.templateParams) {
params[paramName] = extractParamValue(event.payload, paramName);
}

return params;
}

// 발송 조건 검증 (n8n 내부 로직)
function shouldSendMessage(event, templateConfig) {
// 1. 비즈니스 시간 체크
if (!isBusinessHours()) return false;

// 2. 우선순위별 발송 정책
if (templateConfig.priority === 'critical') return true;

// 3. 사용자 수신 설정 체크
return checkUserNotificationSettings(event.payload.userId);
}

// n8n 워크플로우 메인 로직
function processUserEngagementEvent(event) {
// 1. 템플릿 설정 조회 (내장 매핑)
const templateConfig = getTemplateConfig(event.eventType);

// 2. 발송 조건 검증
if (!shouldSendMessage(event, templateConfig)) {
return { status: 'skipped', reason: 'conditions_not_met' };
}

// 3. 템플릿 파라미터 생성
const templateParams = buildTemplateParams(event, templateConfig);

// 4. User Engagement API 호출
const result = callUserEngagementAPI({
userId: event.payload.userId,
templateId: templateConfig.templateId,
templateParams: templateParams,
fallbackToSMS: templateConfig.fallbackSms,
priority: templateConfig.priority
});

return result;
}

5. 스케줄링 시스템 (Cloud Run Zero Scaling 대응)

기술적 제약사항 분석

❌ n8n 내부 Cron의 문제점:

// Cloud Run Zero Scaling 환경에서의 문제
interface CloudRunScalingIssues {
instanceCount: 0; // 요청 없을 때 인스턴스 없음
cronStatus: 'not-running'; // 내부 스케줄러 실행 불가능
memoryState: 'lost'; // 스케줄 상태 유실
coldStartDelay: '2-5s'; // 인스턴스 시작 지연
}

✅ 권장 솔루션: GCP Scheduler + Pub/Sub

GCP Scheduler 구성 (Pub/Sub 토픽으로 발행)

# 스케줄러가 Pub/Sub 토픽으로 메시지 발행
schedulers:
daily_sleep_log_check:
schedule: "0 14 * * *" # 매일 오후 2시
target_type: "pubsub"
pubsub_target:
topic_name: "projects/dta-cloud-de-dev/topics/scheduler-events"
data: |
{
"type": "sleep.daily.log.check",
"eventId": "{{$uuid}}",
"timestamp": "{{$timestamp}}",
"source": "gcp-scheduler",
"payload": {
"checkType": "sleep_log_adherence",
"scheduledAt": "{{$timestamp}}"
}
}

questionnaire_reminder_check:
schedule: "0 10 * * *" # 매일 오전 10시
target_type: "pubsub"
pubsub_target:
topic_name: "projects/dta-cloud-de-dev/topics/scheduler-events"
data: |
{
"type": "questionnaire.daily.reminder.check",
"eventId": "{{$uuid}}",
"timestamp": "{{$timestamp}}",
"source": "gcp-scheduler",
"payload": {
"reminderType": "daily_questionnaire",
"targetDayIndexes": [15, 29]
}
}

final_questionnaire_reminder:
schedule: "0 10 * * 1" # 매주 월요일 오전 10시
target_type: "pubsub"
pubsub_target:
topic_name: "projects/dta-cloud-de-dev/topics/scheduler-events"
data: |
{
"type": "questionnaire.final.reminder.check",
"eventId": "{{$uuid}}",
"timestamp": "{{$timestamp}}",
"source": "gcp-scheduler",
"payload": {
"reminderType": "final_questionnaire",
"dayIndexThreshold": 43
}
}

rtib_pre_notification_check:
schedule: "0 9 * * *" # 매일 오전 9시
target_type: "pubsub"
pubsub_target:
topic_name: "projects/dta-cloud-de-dev/topics/scheduler-events"
data: |
{
"type": "sleep.rtib.pre_notification.check",
"eventId": "{{$uuid}}",
"timestamp": "{{$timestamp}}",
"source": "gcp-scheduler",
"payload": {
"checkType": "rtib_eligibility",
"targetDayIndexes": [8, 15, 22, 29, 36]
}
}

GCP Scheduler + Pub/Sub + n8n Webhook 아키텍처 (권장)

Cloud Run Zero Scaling 환경에서는 외부 스케줄링이 필수입니다:

# GCP Cloud Scheduler 설정
schedulers:
daily_questionnaire_check:
schedule: "0 10 * * *" # 매일 오전 10시
target_type: "pubsub"
pubsub_target:
topic_name: "projects/dta-cloud-de-dev/topics/scheduler-events"
data: |
{
"type": "questionnaire.daily.reminder.check",
"eventId": "{{uuid}}",
"timestamp": "{{timestamp}}",
"source": "gcp-scheduler"
}

weekly_final_questionnaire_check:
schedule: "0 10 * * 1" # 매주 월요일 오전 10시
target_type: "pubsub"
pubsub_target:
topic_name: "projects/dta-cloud-de-dev/topics/scheduler-events"
data: |
{
"type": "questionnaire.final.reminder.check",
"eventId": "{{uuid}}",
"timestamp": "{{timestamp}}",
"source": "gcp-scheduler"
}
# Pub/Sub Push 구독 설정 (n8n webhook 호출)
subscriptions:
scheduler-events-to-n8n:
topic: "scheduler-events"
push_config:
push_endpoint: "https://n8n.sleepq.ai/webhook/scheduler-events"
attributes:
x-goog-version: "v1"
retry_policy:
minimum_backoff: "10s"
maximum_backoff: "600s"
// n8n 워크플로우: 스케줄 이벤트 수신 및 처리
{
"name": "Scheduler Event Handler",
"nodes": [
{
"name": "Scheduler Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": {
"path": "scheduler-events",
"httpMethod": "POST"
}
},
{
"name": "Decode PubSub Message",
"type": "n8n-nodes-base.function",
"parameters": {
"functionCode": `
// Pub/Sub 메시지 디코딩
const pubsubMessage = items[0].json.message;
const data = Buffer.from(pubsubMessage.data, 'base64').toString('utf-8');
const event = JSON.parse(data);

return [{ json: event }];
`
}
},
{
"name": "Event Type Router",
"type": "n8n-nodes-base.switch",
"parameters": {
"values": {
"string": [
{
"conditions": {
"equal": {
"value1": "={{$json.type}}",
"value2": "questionnaire.daily.reminder.check"
}
},
"output": 0
},
{
"conditions": {
"equal": {
"value1": "={{$json.type}}",
"value2": "questionnaire.final.reminder.check"
}
},
"output": 1
}
]
}
}
},
{
"name": "Get Eligible Users",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"url": "https://api.sleepq.ai/users/questionnaire-reminder-eligible?type={{$json.type}}",
"method": "GET"
}
},
{
"name": "Process User Batch",
"type": "n8n-nodes-base.splitInBatches",
"parameters": {
"options": {
"batchSize": 10
}
}
},
{
"name": "Trigger User Questionnaire Event",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"url": "https://api.sleepq.ai/internal/events/trigger-questionnaire-reminder",
"method": "POST",
"body": {
"userId": "={{$json.userId}}",
"eventType": "={{$parent.type}}",
"scheduledAt": "={{$now}}"
}
}
}
]
}

Cloud Run Zero Scaling 환경에서의 스케줄링 모범 사례

✅ 권장되는 방식들:

  1. GCP Scheduler + Pub/Sub: 관리형 서비스로 안정적인 스케줄링
  2. 외부 트리거: 항상 외부에서 Cloud Run 인스턴스를 깨우는 구조
  3. Webhook 기반: n8n이 HTTP 요청을 받아 워크플로우 실행
  4. 상태 비저장: 스케줄 상태를 외부 저장소에 보관

❌ 피해야 할 방식들:

  1. 내부 Cron: n8n 자체 cron은 인스턴스가 없을 때 작동하지 않음
  2. 메모리 기반 상태: 인스턴스 재시작 시 상태 유실
  3. 긴 시간 대기: Cloud Run의 request timeout (최대 60분) 제한
  4. 직접 HTTP 호출: Cloud Scheduler → API 직접 호출 시 중복 방지 어려움

스케줄링 신뢰성 보장

// Pub/Sub 구독 설정으로 안정성 보장
interface PubSubSubscriptionConfig {
ackDeadline: 600; // 10분 (충분한 처리 시간)
maxDeliveryAttempts: 5; // 최대 5회 재시도
minBackoff: '10s'; // 최소 백오프
maxBackoff: '600s'; // 최대 백오프 (10분)
enableExactlyOnceDelivery: false; // At-least-once로 중복 방지는 애플리케이션에서
}

// n8n에서의 중복 처리 방지
const isDuplicate = await checkDuplicateEvent(eventId);
if (isDuplicate) {
return { status: 'already_processed', eventId };
}

비용 최적화

# Cloud Run 설정으로 비용 최적화
cloud_run_config:
min_instances: 0 # Zero scaling으로 비용 절약
max_instances: 10 # 적절한 상한선
concurrency: 80 # 높은 동시성으로 인스턴스 효율성 증대
memory: "1Gi" # n8n 워크플로우에 충분한 메모리
cpu: "1000m" # 1 vCPU로 안정적 처리

# Pub/Sub 설정으로 비용 최적화
pubsub_config:
message_retention: "7d" # 7일간 메시지 보관
push_config:
max_outstanding_messages: 100 # 적절한 처리량 조절

💾 데이터 저장 전략

1. Firestore 데이터 모델

알림 발송 이력

// Collection: notification_logs
interface NotificationLog {
id: string;
userId: string;
userCycleId: string;
notificationType: string;
channel: 'alimtalk' | 'sms';
status: 'pending' | 'sent' | 'failed' | 'cancelled';
sentAt?: Timestamp;
failedAt?: Timestamp;
errorCode?: string;
errorMessage?: string;
retryCount: number;
messageContent: string;
personalizedVariables: Record<string, string>;
externalMessageId?: string;
createdAt: Timestamp;
updatedAt: Timestamp;
}

사용자 알림 설정

// Collection: user_notification_preferences
interface UserNotificationPreference {
userId: string;
alimtalkEnabled: boolean;
smsEnabled: boolean;
quietHoursStart?: string; // "22:00"
quietHoursEnd?: string; // "08:00"
timezone: string; // "Asia/Seoul"
optOutCategories: string[]; // 거부한 알림 카테고리
createdAt: Timestamp;
updatedAt: Timestamp;
}

스케줄된 알림

// Collection: scheduled_notifications
interface ScheduledNotification {
id: string;
userId: string;
notificationType: string;
scheduledAt: Timestamp;
context: Record<string, any>;
status: 'scheduled' | 'processed' | 'cancelled';
processedAt?: Timestamp;
createdAt: Timestamp;
}

2. Redis 캐시 전략

캐시 키 구조

user_preferences:{userId}           # 사용자 알림 설정
message_templates:{templateId} # 메시지 템플릿
notification_cooldown:{userId}:{type} # 중복 발송 방지
rate_limit:{userId} # 발송 속도 제한

캐시 TTL 정책

  • 사용자 설정: 1시간
  • 메시지 템플릿: 24시간
  • 중복 발송 방지: 24시간
  • 발송 속도 제한: 1시간

3. BigQuery 분석 데이터

알림 성과 분석

-- 테이블: notification_analytics
CREATE TABLE notification_analytics (
event_timestamp TIMESTAMP,
user_id STRING,
notification_type STRING,
channel STRING,
status STRING,
day_index INT64,
treatment_phase STRING,
response_action STRING, -- 'clicked', 'ignored', 'opted_out'
device_type STRING,
location STRING
);

🔒 보안 및 컴플라이언스

1. 개인정보 보호

데이터 암호화

  • 전송 중: TLS 1.3 적용
  • 저장 시: AES-256 암호화
  • 개인정보: 별도 암호화 필드 적용

개인정보 마스킹

interface PersonalInfoMasking {
maskPhoneNumber(phone: string): string; // 010-****-1234
maskUserName(name: string): string; // 홍*동
maskHospitalName(hospital: string): string; // **병원
}

2. API 보안

인증 및 인가

  • Service-to-Service: JWT 기반 서비스 인증
  • External API: API Key 기반 인증
  • Rate Limiting: 서비스별 요청 제한

API Gateway 설정

api_gateway:
rate_limits:
notification_service: 1000/minute
alimtalk_service: 100/minute
authentication:
type: "jwt"
issuer: "dta-wide-auth"
cors:
allowed_origins: ["https://admin.sleepq.ai"]

3. 감사 및 모니터링

감사 로그

interface AuditLog {
timestamp: string;
service: string;
action: string;
userId?: string;
resourceId?: string;
result: 'success' | 'failure';
ipAddress: string;
userAgent?: string;
details: Record<string, any>;
}

📊 모니터링 및 알림

1. 핵심 지표 모니터링

SLI (Service Level Indicators)

interface NotificationSLI {
// 가용성
uptime: number; // 99.9% target

// 성능
averageProcessingTime: number; // < 5초 target
p95ProcessingTime: number; // < 10초 target

// 품질
deliveryRate: number; // > 95% target
errorRate: number; // < 1% target

// 처리량
throughput: number; // requests/second
}

알림 규칙

monitoring_alerts:
high_error_rate:
condition: "error_rate > 5%"
duration: "5m"
severity: "critical"

low_delivery_rate:
condition: "delivery_rate < 90%"
duration: "10m"
severity: "warning"

high_processing_time:
condition: "p95_processing_time > 30s"
duration: "5m"
severity: "warning"

2. 대시보드 구성

운영 대시보드

  • 실시간 발송 현황
  • 도메인별 이벤트 발생 현황
  • 알림 채널별 성공/실패율
  • API 응답 시간 및 에러율

비즈니스 대시보드

  • Timeline별 알림 발송 현황
  • 사용자 참여율 (버튼 클릭, 앱 접속 등)
  • 치료 단계별 이탈률 분석
  • 알림 효과성 분석

🚀 확장성 및 성능 최적화

1. 수평 확장 전략

서비스 확장

  • Stateless Design: 모든 서비스는 상태를 외부 저장소에 보관
  • Load Balancing: Cloud Load Balancer 기반 트래픽 분산
  • Auto Scaling: CPU/메모리 사용률 기반 자동 확장

데이터베이스 확장

  • Firestore Sharding: 사용자 ID 기반 샤딩
  • Read Replica: 읽기 전용 복제본 활용
  • Caching: Redis 클러스터를 통한 읽기 성능 향상

2. 성능 최적화

배치 처리

interface BatchProcessor {
processBatch(notifications: NotificationRequest[]): Promise<BatchResult>;
batchSize: number; // 100개씩 배치 처리
maxWaitTime: number; // 최대 5초 대기
}

비동기 처리

  • Event Streaming: 실시간 이벤트 스트리밍 처리
  • Queue Management: 우선순위 기반 큐 관리
  • Dead Letter Queue: 실패한 메시지 별도 처리

3. 글로벌 확장 고려사항

다중 지역 배포

  • Primary Region: asia-northeast3 (Seoul)
  • Backup Region: asia-northeast1 (Tokyo)
  • Data Replication: 지역 간 데이터 복제

지역화 지원

interface LocalizationService {
getSupportedLocales(): string[];
localizeMessage(templateId: string, locale: string): Promise<LocalizedMessage>;
getTimezone(userId: string): Promise<string>;
}