사용자 참여도 향상 시스템 구현 및 유지보수 가이드
🚀 개발 시작하기
1. 개발 환경 설정
필수 요구사항
# Node.js 및 패키지 매니저
node: ">=18.0.0"
npm: ">=8.0.0"
# Google Cloud SDK
gcloud CLI: latest
firebase-tools: latest
# 개발 도구
nestjs/cli: latest
typescript: "^5.0.0"
로컬 개발 환경
# 프로젝트 클론 및 의존성 설치
git clone <repository>
cd dta-wide-mono-doc
npm install
# 환경 변수 설정
cp .env.example .env.local
# 필요한 환경 변수들을 .env.local에 설정
# 로컬 Firebase 에뮬레이터 시작
npm run firebase:emulators
# 개발 서버 시작
npm run dev
필수 환경 변수
# .env.local 예시
GOOGLE_CLOUD_PROJECT=dta-cloud-de-dev
FIREBASE_PROJECT_ID=dta-cloud-de-dev
KAKAO_ALIMTALK_API_KEY=your_kakao_api_key
KAKAO_ALIMTALK_SENDER_KEY=your_sender_key
SMS_API_KEY=your_sms_api_key
REDIS_HOST=localhost
REDIS_PORT=6379
2. 프로젝트 구조 이해
apps/
├── dta-wide-api/ # Main API 서버
│ ├── src/notification/ # Notification 도메인 구현
│ └── src/scheduler/ # 스케줄링 로직
├── dta-wide-agent-qa/ # Agent QA 서비스
└── dta-wide-mcp/ # MCP 서비스
libs/
├── core/
│ ├── event/ # 이벤트 처리 공통 라이브러리
│ ├── firebase/ # Firebase 연동
│ └── logging/ # 로깅 시스템
├── feature/
│ ├── sleep/ # Sleep 도메인 구현
│ ├── user/ # User 도메인 구현
│ ├── questionnaire/ # Questionnaire 도메인 구현
│ └── agent-treatment-flow/ # Agent 도메인 구현
└── shared/
├── contracts-sleep/ # Sleep 이벤트 계약
├── contracts-user/ # User 이벤트 계약
├── contracts-questionnaire/ # Questionnaire 이벤트 계약
└── contracts-notification/ # Notification 이벤트 계약
🔨 구현 가이드라인
1. 새로운 알림톡 추가하기
새로운 알림톡을 추가하는 과정은 다음과 같습니다:
Step 1: NHN Cloud Console에서 템플릿 생성 및 심의
1. NHN Cloud Console 로그인
2. AlimTalk > Template 관리 메뉴 접근
3. 새 템플릿 생성
- 템플릿명: 슬립큐_새로운알림_001
- 카테고리: 해당하는 카테고리 선택
- 내용 작성 (변수 #{회원명}, #{추가정보} 등 포함)
- 버튼 설정 (필요시)
4. 카카오톡 심의 신청
5. 심의 승인 후 Template ID 확보 (예: SLEEPQ_NEW_001)
Step 2: 도메인 이벤트 정의
// libs/shared/contracts-{domain}/src/lib/events/{domain}-events.ts
// 1. 크로스 도메인 이벤트 타입 추가
export const {DOMAIN}_CROSS_EVENT_TYPES = {
// ... 기존 이벤트들
NEW_NOTIFICATION_TRIGGERED: '{domain}.new.notification.triggered',
} as const;
// 2. 이벤트 인터페이스 정의
export interface NewNotificationTriggeredCrossEvent extends BaseCrossDomainEvent<{
userId: string;
userCycleId: string;
// 알림톡에 필요한 변수들
userName: string;
additionalInfo?: string;
context: Record<string, any>;
}> {
type: typeof {DOMAIN}_CROSS_EVENT_TYPES.NEW_NOTIFICATION_TRIGGERED;
}
// 3. 유니온 타입에 추가
export type {Domain}CrossEvent =
| {기존이벤트들}
| NewNotificationTriggeredCrossEvent;
Step 3: n8n 워크플로우에 Template 매핑 추가
// n8n 워크플로우의 TEMPLATE_MAPPING에 새로운 이벤트 매핑 추가
// n8n 워크플로우 편집기에서 Code 노드의 TEMPLATE_MAPPING 수정
const TEMPLATE_MAPPING = {
// 기존 매핑들...
// 새로 추가되는 이벤트 매핑
'{domain}.new.notification.triggered': {
templateId: 'SLEEPQ_NEW_001', // NHN Cloud에서 발급받은 Template ID
templateParams: ['userName', 'additionalInfo'], // 템플릿에서 사용할 변수들
fallbackSms: true, // SMS 대체 발송 여부
priority: 'medium', // 발송 우선순위 (critical/high/medium/low)
category: 'reminder', // 템플릿 카테고리
description: '새로운 알림 메시지', // 관리용 설명
buttons: [
{ name: '앱으로 이동', scheme: 'sleepq://main' }
]
}
};
// 템플릿 파라미터 생성 로직도 함께 업데이트
function buildTemplateParams(event, templateConfig) {
const params = {};
for (const paramName of templateConfig.templateParams) {
switch (paramName) {
case 'userName':
params.userName = event.payload.userName || '고객님';
break;
case 'additionalInfo':
params.additionalInfo = event.payload.additionalInfo || '';
break;
default:
params[paramName] = event.payload[paramName] || '';
}
}
return params;
}
Step 4: dta-wide-api에서 표준 이벤트 아키텍처로 구현
중요: event-system.md의 표준 이벤트 아키텍처를 준수해야 합니다.
// 1. 도메인 서비스: 비즈니스 로직 + 내부 이벤트만 발행
// apps/dta-wide-api/src/{domain}/services/{domain}.service.ts
@Injectable()
export class {Domain}Service {
constructor(
private readonly eventEmitter: EventEmitter2
) {}
async triggerNewNotification(userId: string, context: any): Promise<void> {
// 1. 비즈니스 로직 수행
const user = await this.getUserInfo(userId);
// 2. 조건 체크
if (this.shouldSendNewNotification(user, context)) {
// 3. 내부 이벤트만 발행 (크로스 도메인 이벤트는 핸들러가 담당)
const internalEvent = new NewNotificationTriggeredEvent(
user.id,
user.currentCycleId,
user.name,
context.additionalInfo,
context
);
// '{domain}.internal.*' 패턴으로 내부 이벤트 발행
this.eventEmitter.emit(
'{DOMAIN}_INTERNAL_EVENT_CHANNELS.NEW_NOTIFICATION_TRIGGERED',
internalEvent
);
}
}
}
// 2. 내부 이벤트 핸들러: 도메인 내부 처리 + 크로스 이벤트 발행
// apps/dta-wide-api/src/{domain}/events/handlers/{domain}-internal-event-handlers.ts
@Injectable()
export class {Domain}InternalEventHandlers {
constructor(
private readonly crossEventPublisher: {Domain}CrossEventPublisher,
private readonly logger: LoggerService
) {}
@OnEvent('{DOMAIN}_INTERNAL_EVENT_CHANNELS.NEW_NOTIFICATION_TRIGGERED')
async handleNewNotificationTriggered(event: NewNotificationTriggeredEvent): Promise<void> {
try {
// 1. 도메인 내부 처리 (필요한 경우)
await this.performInternalTasks(event);
// 2. 크로스 도메인 이벤트 발행
await this.crossEventPublisher.publishNewNotificationTriggered(
event.userId,
event.userCycleId,
event.userName,
event.additionalInfo,
event.context
);
await this.logger.debug('New notification triggered processed successfully', event.userId);
} catch (error: unknown) {
await this.logger.error('Failed to process new notification triggered', error, { event });
throw error;
}
}
}
// 3. 크로스 이벤트 발행자: 'event' 채널로만 발행
// apps/dta-wide-api/src/{domain}/events/handlers/{domain}-cross-event-publisher.ts
@Injectable()
export class {Domain}CrossEventPublisher {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService
) {}
async publishNewNotificationTriggered(
userId: string,
userCycleId: string,
userName: string,
additionalInfo?: string,
context?: any
): Promise<void> {
try {
// 표준 크로스 도메인 이벤트 생성
const crossEvent = this.createStandardEvent(
'{DOMAIN}_CROSS_EVENT_TYPES.NEW_NOTIFICATION_TRIGGERED',
{
userId,
userCycleId,
userName,
additionalInfo,
context
}
);
// 'event' 채널로만 발행 → GCP Pub/Sub 'events' 토픽으로 전송
this.eventEmitter.emit('event', crossEvent);
await this.logger.debug('Published new notification cross event', userId);
} catch (error) {
await this.logger.error('Failed to publish new notification cross event', error);
throw error;
}
}
}
Step 5: Pub/Sub Push 구독으로 n8n 직접 연동
개선된 방식: events 토픽에 n8n용 별도 Push 구독을 추가하여 /api/events를 거치지 않고 직접 n8n webhook으로 이벤트 전달
A. Terragrunt.hcl에 n8n Push 구독 추가
# infrastructure/terragrunt/dev/pubsub/terragrunt.hcl
# events 토픽에 n8n용 별도 Push 구독 추가
{
name = "events-to-n8n-user-engagement"
topic = "events"
push_config = {
push_endpoint = "https://dta-wide-mcp-dev.weltcorp.com/webhook/user-engagement"
attributes = {
environment = local.environment
version = "v1"
purpose = "user-engagement"
}
oidc_token = {
service_account_email = local.event_system.service_account_email
audience = "https://dta-wide-mcp-dev.weltcorp.com/webhook/user-engagement"
}
}
labels = {
purpose = "user-engagement"
environment = local.environment
managed_by = "terragrunt"
target = "n8n-workflow"
}
dead_letter_policy = {
dead_letter_topic = "events-dead-letter"
max_delivery_attempts = 5
}
ack_deadline_seconds = 30
retry_policy = {
minimum_backoff = "10s"
maximum_backoff = "300s"
}
message_retention_duration = "604800s" # 7 days
}
B. /api/events 엔드포인트 간소화 (n8n 호출 로직 제거)
// /api/events는 이제 이벤트 저장만 담당 (간소화)
// apps/dta-wide-api/src/app/events/controllers/events.controller.ts
@Controller('api/events')
export class EventsController {
constructor(
private readonly eventsService: EventsService,
private readonly redis: RedisService,
private readonly logger: LoggerService
) {}
@Post()
async receiveEvent(@Body() pubsubMessage: PubSubMessage): Promise<{ success: boolean }> {
try {
// 1. Pub/Sub 메시지에서 이벤트 추출
const event = this.extractEventFromPubSubMessage(pubsubMessage);
// 2. 중복 방지 체크 (Redis)
const isDuplicate = await this.redis.get(`event:${event.eventId}`);
if (isDuplicate) {
await this.logger.warn('Duplicate event ignored', event.eventId);
return { success: true };
}
// 3. 이벤트 저장 (n8n 호출 로직 제거됨)
await this.eventsService.storeEvent(event);
// 4. 중복 방지를 위한 Redis 키 설정 (24시간 TTL)
await this.redis.setex(`event:${event.eventId}`, 86400, 'processed');
return { success: true };
} catch (error) {
await this.logger.error('Failed to store event', error);
throw error;
}
}
private extractEventFromPubSubMessage(pubsubMessage: PubSubMessage): StandardEvent {
// Pub/Sub 메시지에서 표준 이벤트 추출
const data = Buffer.from(pubsubMessage.data, 'base64').toString();
return JSON.parse(data);
}
}
C. n8n 워크플로우 구성 (Pub/Sub Push 구독 수신)
// n8n 워크플로우 구성 (Pub/Sub Push 구독으로 직접 이벤트 수신)
{
"name": "User Engagement Event Processor",
"nodes": [
{
"name": "User Engagement Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": {
"path": "user-engagement", // /webhook/user-engagement (Pub/Sub Push 구독 엔드포인트)
"httpMethod": "POST",
"responseMode": "responseNode",
"options": {
"rawBody": true // Pub/Sub 메시지 형식 수신
}
}
},
{
"name": "Parse Pub/Sub Message",
"type": "n8n-nodes-base.code",
"parameters": {
"jsCode": `
// Pub/Sub Push 구독 메시지에서 이벤트 추출
const pubsubMessage = items[0].json;
const eventData = Buffer.from(pubsubMessage.message.data, 'base64').toString();
const event = JSON.parse(eventData);
return [{
json: {
eventType: event.type,
eventId: event.eventId,
timestamp: event.timestamp,
source: event.source,
payload: event.payload,
version: event.version,
pubsubAttributes: pubsubMessage.message.attributes
}
}];
`
}
},
{
"name": "Event Type Filter",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": {
"string": [{
"value1": "={{$json.type}}",
"operation": "equal",
"value2": "{domain}.new.notification.triggered"
}]
}
}
},
{
"name": "Duplicate Check",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"url": "https://api.sleepq.ai/internal/events/check-duplicate",
"method": "POST",
"body": {
"eventId": "={{$json.eventId}}"
}
}
},
{
"name": "Template Mapping",
"type": "n8n-nodes-base.function",
"parameters": {
"functionCode": `
const payload = items[0].json.payload;
const eventType = items[0].json.type;
// Template ID 매핑 (NHN Cloud에서 승인받은 ID들)
const templateMappings = {
'{domain}.new.notification.triggered': 'SLEEPQ_NEW_001',
// ... 기타 매핑들
};
return [{
json: {
templateId: templateMappings[eventType],
userId: payload.userId,
templateParams: {
'회원명': payload.userName,
'추가정보': payload.additionalInfo || ''
},
fallbackToSMS: true,
priority: 'medium'
}
}];
`
}
},
{
"name": "Send AlimTalk",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"url": "https://api.sleepq.ai/user-engagement/send",
"method": "POST",
"body": {
"userId": "={{$json.userId}}",
"templateId": "={{$json.templateId}}",
"templateParams": "={{$json.templateParams}}",
"fallbackToSMS": "={{$json.fallbackToSMS}}",
"priority": "={{$json.priority}}"
}
}
}
]
}
Step 6: 스케줄링 설정 (Cloud Run Zero Scaling 대응)
중요: Cloud Run 환경에서는 내부 Cron이 작동하지 않으므로 외부 스케줄링을 사용해야 합니다.
# GCP Cloud Scheduler 설정 (권장 방식)
schedulers:
new_notification_check:
schedule: "0 10 * * *" # 매일 오전 10시
target_type: "pubsub"
pubsub_target:
topic_name: "projects/dta-cloud-de-dev/topics/scheduler-events"
data: |
{
"type": "{domain}.new.notification.check",
"eventId": "{{$uuid}}",
"timestamp": "{{$timestamp}}",
"source": "gcp-scheduler",
"payload": {
"checkType": "new_notification",
"scheduledAt": "{{$timestamp}}"
}
}
// dta-wide-api에서 스케줄 이벤트 처리 엔드포인트 추가
// apps/dta-wide-api/src/scheduler/controllers/scheduler.controller.ts
@Controller('internal/scheduler')
export class SchedulerController {
constructor(
private readonly {domain}Service: {Domain}Service,
private readonly logger: LoggerService
) {}
// GCP Scheduler가 직접 호출하는 엔드포인트 (대안 방식)
@Post('new-notification-check')
async handleNewNotificationCheck(): Promise<{ processed: number }> {
try {
const users = await this.getUsersForNewNotification();
let processedCount = 0;
for (const user of users) {
const shouldTrigger = await this.checkTriggerCondition(user);
if (shouldTrigger) {
// 표준 이벤트 아키텍처: 도메인 서비스 메서드 호출
await this.{domain}Service.triggerNewNotification(
user.id,
{ additionalInfo: '스케줄된 알림' }
);
processedCount++;
}
}
await this.logger.info(`Processed ${processedCount} new notification checks`);
return { processed: processedCount };
} catch (error) {
await this.logger.error('Failed to process new notification check', error);
throw error;
}
}
private async getUsersForNewNotification(): Promise<User[]> {
// 새로운 알림이 필요한 사용자들 조회 로직
return [];
}
private async checkTriggerCondition(user: User): Promise<boolean> {
// 발송 조건 체크 로직
return true;
}
}
// n8n 워크플로우: GCP Scheduler 이벤트 수신 및 처리
{
"name": "New Notification Scheduler Handler",
"nodes": [
{
"name": "Scheduler Webhook",
"type": "n8n-nodes-base.webhook",
"parameters": {
"path": "scheduler-events",
"httpMethod": "POST"
}
},
{
"name": "Decode Scheduler 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 schedulerEvent = JSON.parse(data);
return [{ json: schedulerEvent }];
`
}
},
{
"name": "Filter Event Type",
"type": "n8n-nodes-base.if",
"parameters": {
"conditions": {
"string": [{
"value1": "={{$json.type}}",
"operation": "equal",
"value2": "{domain}.new.notification.check"
}]
}
}
},
{
"name": "Trigger Scheduler Endpoint",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"url": "https://api.sleepq.ai/internal/scheduler/new-notification-check",
"method": "POST",
"sendBody": false
}
},
{
"name": "Log Result",
"type": "n8n-nodes-base.function",
"parameters": {
"functionCode": `
const result = items[0].json;
console.log('Scheduler processed', result.processed, 'notifications');
return items;
`
}
}
]
}
스케줄링 방식 비교
| 방식 | 장점 | 단점 | 권장도 |
|---|---|---|---|
| GCP Scheduler + Pub/Sub + n8n | - 완전 관리형 - 인스턴스 자동 깨우기 - 재시도 보장 | - 복잡한 설정 - Pub/Sub 비용 | ⭐⭐⭐⭐⭐ |
| GCP Scheduler + HTTP 직접 호출 | - 단순한 설정 - 낮은 지연시간 | - 중복 방지 어려움 - 재시도 제한적 | ⭐⭐⭐ |
| n8n 내부 Cron | - 설정 간단 | - Cloud Run에서 작동 안함 - 상태 유실 위험 | ⭐ (권장 안함) |
표준 이벤트 아키텍처 요약
새로운 알림톡을 추가할 때의 표준 이벤트 플로우:
이 구조를 따르면 event-system.md의 표준 아키텍처와 완전히 일치합니다.
2. 코딩 컨벤션
TypeScript 스타일 가이드
// ✅ 올바른 예시
export class NotificationService {
constructor(
private readonly eventService: EventService,
private readonly templateService: MessageTemplateService,
) {}
/**
* 알림톡을 발송합니다.
* @param request 알림톡 발송 요청
* @returns 발송 결과
*/
async sendAlimTalk(request: AlimTalkRequest): Promise<SendResult> {
// 구현 로직
}
// private 메서드는 underscore 접두사 사용 금지 (기존 룰 준수)
private validateRequest(request: AlimTalkRequest): void {
// 검증 로직
}
}
// ❌ 잘못된 예시
export class NotificationService {
constructor(
eventService: EventService, // private readonly 누락
private templateService: any // any 타입 사용 금지
) {}
sendAlimTalk(request) { // 타입 정의 누락, async/await 누락
// ...
}
}
에러 처리 패턴
// ✅ 표준 에러 처리
export class AlimTalkService {
async sendMessage(message: AlimTalkMessage): Promise<SendResult> {
try {
const result = await this.kakaoAPI.send(message);
// 성공 로깅
this.logger.info('AlimTalk sent successfully', {
messageId: result.messageId,
userId: message.userId,
type: message.type
});
return {
success: true,
messageId: result.messageId,
sentAt: new Date()
};
} catch (error) {
// 에러 로깅
this.logger.error('Failed to send AlimTalk', {
userId: message.userId,
type: message.type,
error: error.message,
stack: error.stack
});
// NestJS 표준 예외 사용
if (error.status === 400) {
throw new BadRequestException('Invalid message format');
} else if (error.status === 401) {
throw new UnauthorizedException('Invalid API key');
} else {
throw new InternalServerErrorException('AlimTalk service error');
}
}
}
}
3. 테스트 작성 가이드
단위 테스트 예시
// tests/notification/notification.service.spec.ts
describe('NotificationService', () => {
let service: NotificationService;
let eventService: jest.Mocked<EventService>;
let templateService: jest.Mocked<MessageTemplateService>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
NotificationService,
{
provide: EventService,
useValue: {
publishEvent: jest.fn(),
},
},
{
provide: MessageTemplateService,
useValue: {
generateMessage: jest.fn(),
},
},
],
}).compile();
service = module.get<NotificationService>(NotificationService);
eventService = module.get(EventService);
templateService = module.get(MessageTemplateService);
});
describe('sendAlimTalk', () => {
it('should send AlimTalk successfully', async () => {
// Given
const request: AlimTalkRequest = {
userId: 'user-123',
templateId: 'welcome-template',
variables: { userName: '홍길동' }
};
templateService.generateMessage.mockResolvedValue({
title: '환영합니다',
body: '홍길동님, 환영합니다!',
buttons: []
});
// When
const result = await service.sendAlimTalk(request);
// Then
expect(result.success).toBe(true);
expect(templateService.generateMessage).toHaveBeenCalledWith(
'welcome-template',
'user-123',
{ userName: '홍길동' }
);
});
it('should handle template generation failure', async () => {
// Given
const request: AlimTalkRequest = {
userId: 'user-123',
templateId: 'invalid-template',
variables: {}
};
templateService.generateMessage.mockRejectedValue(
new Error('Template not found')
);
// When & Then
await expect(service.sendAlimTalk(request))
.rejects
.toThrow('Template not found');
});
});
});
통합 테스트 예시
// tests/e2e/alimtalk-flow.e2e-spec.ts
describe('AlimTalk Flow (e2e)', () => {
let app: INestApplication;
let eventService: EventService;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
eventService = app.get<EventService>(EventService);
await app.init();
});
it('should process user registration event and send welcome AlimTalk', async () => {
// Given
const userRegisteredEvent: UserCreatedCrossEvent = {
type: 'user.created',
payload: {
userId: 'test-user-123',
userCycleId: 'cycle-123',
userName: '테스트사용자',
registeredAt: new Date().toISOString(),
// ... 기타 필드들
},
metadata: {
eventId: 'event-123',
timestamp: new Date().toISOString(),
version: '1.0'
}
};
// When
await eventService.publishEvent(userRegisteredEvent);
// Wait for async processing
await new Promise(resolve => setTimeout(resolve, 1000));
// Then
// 알림톡 발송 로그 확인
const notificationLogs = await getNotificationLogs('test-user-123');
expect(notificationLogs).toHaveLength(1);
expect(notificationLogs[0].type).toBe('registration_completion');
expect(notificationLogs[0].status).toBe('sent');
});
});
🔍 디버깅 및 문제 해결
1. 일반적인 문제들
알림톡이 발송되지 않는 경우
# 1. 이벤트 발행 확인
# Cloud Logging에서 이벤트 발행 로그 검색
gcloud logging read "resource.type=gce_instance AND jsonPayload.message:'Event published'"
# 2. 이벤트 구독 상태 확인
# Pub/Sub 구독 상태 확인
gcloud pubsub subscriptions describe notification-service-sleep-events-sub
# 3. 알림 설정 확인
# Firestore에서 사용자 알림 설정 확인
# user_notification_preferences/{userId} 문서 조회
# 4. 중복 발송 방지 상태 확인
# Redis에서 쿨다운 키 확인
redis-cli get "notification_cooldown:{userId}:{type}"
메시지 템플릿 오류
// 템플릿 변수 검증 도구
export class TemplateValidator {
validateTemplate(template: MessageTemplate, variables: Record<string, string>): ValidationResult {
const errors: string[] = [];
// 필수 변수 확인
for (const requiredVar of template.variables) {
if (!variables[requiredVar]) {
errors.push(`Missing variable: ${requiredVar}`);
}
}
// 메시지 길이 확인 (알림톡 제한: 1000자)
const renderedBody = this.renderTemplate(template.content.body, variables);
if (renderedBody.length > 1000) {
errors.push('Message body exceeds 1000 characters');
}
return {
isValid: errors.length === 0,
errors
};
}
}
스케줄링 문제
// 스케줄링 상태 확인 도구
export class SchedulerDebugger {
async checkUserSchedules(userId: string): Promise<DebugInfo> {
const schedules = await this.getScheduledNotifications(userId);
const userTimeline = await this.getUserTimeline(userId);
return {
userId,
currentDayIndex: userTimeline.dayIndex,
scheduledNotifications: schedules,
nextScheduledNotification: this.getNextScheduled(schedules),
missedNotifications: this.getMissedNotifications(schedules, userTimeline)
};
}
}
2. 로깅 및 모니터링
구조화된 로깅
export class StructuredLogger {
info(message: string, context: LogContext): void {
console.log(JSON.stringify({
level: 'info',
message,
timestamp: new Date().toISOString(),
service: 'notification-service',
...context
}));
}
error(message: string, context: ErrorContext): void {
console.error(JSON.stringify({
level: 'error',
message,
timestamp: new Date().toISOString(),
service: 'notification-service',
error: {
name: context.error?.name,
message: context.error?.message,
stack: context.error?.stack
},
...context
}));
}
}
interface LogContext {
userId?: string;
eventType?: string;
notificationType?: string;
[key: string]: any;
}
커스텀 메트릭
// 메트릭 수집
export class NotificationMetrics {
private metrics = {
notificationsSent: new Map<string, number>(),
notificationsFailed: new Map<string, number>(),
processingTime: new Map<string, number[]>()
};
recordNotificationSent(type: string): void {
const current = this.metrics.notificationsSent.get(type) || 0;
this.metrics.notificationsSent.set(type, current + 1);
}
recordProcessingTime(type: string, timeMs: number): void {
const times = this.metrics.processingTime.get(type) || [];
times.push(timeMs);
this.metrics.processingTime.set(type, times);
}
async exportMetrics(): Promise<MetricSummary> {
return {
totalSent: Array.from(this.metrics.notificationsSent.values())
.reduce((sum, count) => sum + count, 0),
totalFailed: Array.from(this.metrics.notificationsFailed.values())
.reduce((sum, count) => sum + count, 0),
averageProcessingTime: this.calculateAverageProcessingTime(),
byType: this.getMetricsByType()
};
}
}
🔄 배포 및 운영
1. 배포 프로세스
CI/CD 파이프라인
# .github/workflows/deploy-notification-service.yml
name: Deploy Notification Service
on:
push:
branches: [main]
paths:
- 'apps/dta-wide-api/src/notification/**'
- 'libs/shared/contracts-notification/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm ci
- run: npm run test:notification
- run: npm run test:e2e:notification
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: google-github-actions/setup-gcloud@v0
with:
service_account_key: ${{ secrets.GCP_SA_KEY }}
- run: npm run build:notification
- run: gcloud app deploy --quiet
스테이지별 배포
# Development 환경 배포
npm run deploy:dev
# Staging 환경 배포 (QA 테스트용)
npm run deploy:staging
# Production 환경 배포
npm run deploy:prod
# 롤백 (필요시)
gcloud app versions list
gcloud app services set-traffic notification-service --splits [이전버전]=1
2. 모니터링 설정
Health Check 엔드포인트
@Controller('health')
export class HealthController {
constructor(
private readonly eventService: EventService,
private readonly alimTalkService: AlimTalkService,
private readonly cacheService: CacheService
) {}
@Get()
async check(): Promise<HealthCheckResult> {
const checks = await Promise.allSettled([
this.checkEventService(),
this.checkAlimTalkService(),
this.checkCacheService(),
this.checkDatabase()
]);
return {
status: checks.every(check => check.status === 'fulfilled') ? 'healthy' : 'unhealthy',
timestamp: new Date().toISOString(),
checks: {
eventService: checks[0].status === 'fulfilled',
alimTalkService: checks[1].status === 'fulfilled',
cacheService: checks[2].status === 'fulfilled',
database: checks[3].status === 'fulfilled'
}
};
}
}
알림 규칙 설정
# monitoring/alert-rules.yml
alerting:
notification_error_rate:
condition: rate(notification_errors_total[5m]) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "High notification error rate detected"
description: "Notification error rate is {{ $value }} over the last 5 minutes"
notification_processing_delay:
condition: histogram_quantile(0.95, notification_processing_duration_seconds) > 30
for: 10m
labels:
severity: warning
annotations:
summary: "Notification processing is slow"
description: "95th percentile processing time is {{ $value }}s"
3. 운영 작업
일일 점검 체크리스트
## 알림톡 서비스 일일 점검
### 필수 확인 사항
- [ ] 전일 알림톡 발송 성공률 (>95%)
- [ ] 에러율 체크 (<1%)
- [ ] API 응답 시간 체크 (<5초)
- [ ] 큐 처리 지연 확인
- [ ] 외부 API (카카오톡) 상태 확인
### 대시보드 확인
- [ ] Cloud Monitoring 대시보드
- [ ] Firebase 사용량 대시보드
- [ ] Pub/Sub 메시지 처리 현황
- [ ] 비용 사용량 체크
### 로그 확인
- [ ] 치명적 에러 로그 확인
- [ ] 사용자 신고 접수 확인
- [ ] 성능 이슈 로그 확인
장애 대응 절차
## 알림톡 장애 대응 매뉴얼
### 1. 즉시 대응 (5분 이내)
1. 장애 현황 파악
- 에러율, 응답 시간 확인
- 영향받는 사용자 수 추산
2. 임시 조치
- 트래픽 제한 (필요시)
- SMS 대체 발송 활성화
### 2. 원인 분석 (30분 이내)
1. 로그 분석
- 에러 패턴 확인
- 외부 API 상태 확인
2. 인프라 상태 확인
- 서버 리소스 사용률
- 데이터베이스 성능
- 네트워크 상태
### 3. 복구 작업
1. 근본 원인 해결
2. 서비스 정상화 확인
3. 밀린 알림톡 재발송 계획 수립
### 4. 사후 조치
1. 장애 보고서 작성
2. 재발 방지 대책 수립
3. 모니터링 개선
📚 참고 자료
1. API 문서
2. 내부 문서
3. 구현 참고자료
4. 운영 도구
❓ 자주 묻는 질문 (FAQ)
Q1: 새로운 알림톡 타입을 추가할 때 주의사항은?
- 반드시 이벤트 스키마 검증을 추가하세요
- 메시지 템플릿은 카카오톡 정책을 준수해야 합니다
- 중복 발송 방지 로직을 구현하세요
- 테스트 환경에서 충분히 검증 후 배포하세요
Q2: 알림톡 발송이 실패했을 때 어떻게 대처하나요?
- 자동 재시도가 3회까지 수행됩니다
- 재시도 후에도 실패하면 SMS로 대체 발송됩니다
- 모든 실패는 로그에 기록되며 모니터링 알림이 발송됩니다
Q3: 사용자가 알림톡 수신을 거부하면?
user_notification_preferences에서 설정이 업데이트됩니다- 해당 사용자에게는 더 이상 알림톡이 발송되지 않습니다
- 중요한 알림 (처방 관련)은 SMS로 대체 발송될 수 있습니다
Q4: 성능 이슈가 발생했을 때 확인할 점은?
- Redis 캐시 성능 확인
- Pub/Sub 메시지 처리 지연 확인
- Firebase 쿼리 성능 확인
- 외부 API (카카오톡) 응답 시간 확인