초기 프로젝트 설정 및 설계 문서 추가

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250 2025-10-24 10:10:16 +09:00
commit 3f6e005026
76 changed files with 37842 additions and 0 deletions

102
CLAUDE.md Normal file
View File

@ -0,0 +1,102 @@
# 프론트엔드 가이드
[Git 연동]
- "pull" 명령어 입력 시 Git pull 명령을 수행하고 충돌이 있을 때 최신 파일로 병합 수행
- "push" 또는 "푸시" 명령어 입력 시 git add, commit, push를 수행
- Commit Message는 한글로 함
[URL링크 참조]
- URL링크는 WebFetch가 아닌 'curl {URL} > claude/{filename}'명령으로 저장
- 동일한 파일이 있으면 덮어 씀
- 'claude'디렉토리가 없으면 생성하고 다운로드
- 저장된 파일을 읽어 사용함
## 산출물 디렉토리
- 프로토타입: design/prototype/\*
- API명세서: design/api/\*.json
- UI/UX설계서: design/frontend/uiux-design.md
- 스타일가이드: design/frontend/style-guide.md
- 정보아키텍처: design/frontend/ia.md
- API매핑설계서: design/frontend/api-mapping.md
- 유저스토리: design/userstory.md
## 가이드
- 프론트엔드설계가이드
- 설명: 프론트엔드 설계 방법 안내
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/design/frontend-design.md
- 파일명: frontend-design.md
- 프론트엔드개발가이드
- 설명: 프론트엔드 개발 가이드
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/develop/dev-frontend.md
- 파일명: dev-frontend.md
- 프론트엔드컨테이너이미지작성가이드
- 설명: 프론트엔드 컨테이너 이미지 작성 가이드
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/build-image-front.md
- 파일명: build-image-front.md
- 프론트엔드컨테이너실행방법가이드
- 설명: 프론트엔드 컨테이너 실행방법 가이드
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/run-container-guide-front.md
- 파일명: run-container-guide-front.md
- 프론트엔드배포가이드
- 설명: 프론트엔드 서비스를 쿠버네티스 클러스터에 배포하는 가이드
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/deploy-k8s-front.md
- 파일명: deploy-k8s-front.md
- 프론트엔드Jenkins파이프라인작성가이드
- 설명: 프론트엔드 서비스를 Jenkins를 이용하여 CI/CD하는 배포 가이드
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/deploy-jenkins-cicd-front.md
- 파일명: deploy-jenkins-cicd-front.md
- 프론트엔드GitHubActions파이프라인작성가이드
- 설명: 프론트엔드 서비스를 GitHub Actions를 이용하여 CI/CD하는 배포 가이드
- URL: https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/guides/deploy/deploy-actions-cicd-front.md
- 파일명: deploy-actions-cicd-front.md
## 프롬프트 약어
### 역할 약어
- "@front": "--persona-front"
- "@devops": "--persona-devops"
### 작업 약어
- "@complex-flag": --seq --c7 --uc --wave-mode auto --wave-strategy systematic --delegate auto
- "@plan": --plan --think
- "@dev-front": /sc:implement @front --think-hard @complex-flag
- "@cicd": /sc:implement @devops --think @complex-flag
- "@document": /sc:document --think @scribe @complex-flag
- "@fix": /sc:troubleshoot --think @complex-flag
- "@estimate": /sc:estimate --think-hard @complex-flag
- "@improve": /sc:improve --think @complex-flag
- "@analyze": /sc:analyze --think --seq
- "@explain": /sc:explain --think --seq --answer-only
## Lessons Learned
**프론트엔드 개발 절차**:
- 개발가이드의 "6. 각 페이지별 구현" 단계에서는 빌드 및 에러 해결까지만 수행
- 개발서버(`npm run dev`) 실행은 항상 사용자가 직접 수행
- 개발자는 빌드(`npm run build`) 성공까지만 확인하고 서버 실행을 사용자에게 요청
- 개발자가 임의로 서버를 실행하고 테스트하지 않고 사용자 확인 후 진행
**프로토타입 분석 및 테스트**:
- 프로토타입 HTML 파일은 반드시 Playwright MCP를 사용하여 모바일 화면(375x812)에서 확인
- 프로토타입의 모든 인터랙션과 액션을 실제로 클릭하여 동작 확인 필요
**서비스 재배포 가이드**
서비스 수정 후 재배포 시 다음 절차를 따릅니다:
1. 이미지 빌드: deployment/container/build-image.md 참조하여 빌드
2. 이미지를 ACR형식으로 태깅
3. 컨테이너 실행: deployment/container/run-container-guide.md의 '8. 재배포 방법' 참조하여 실행
- 컨테이너 중단
- 이미지 삭제
- 컨테이너 실행
- 테스트는 사용자에게 요청

BIN
design/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,564 @@
# 시즌별 이벤트 성과 데이터
**작성일**: 2025-01-21
**목적**: AI 이벤트 추천 시스템 학습 데이터
**데이터 구성**: 3개 시즌 카테고리 × 10개 샘플 = 총 30개
---
## 데이터 구조 설명
본 데이터는 AI 기반 이벤트 추천 시스템의 학습 데이터로 활용됩니다. 각 이벤트 샘플은 실제 성과 데이터를 기반으로 작성되었으며, 다음 정보를 포함합니다:
- **이벤트 제목**: 실제 진행된 이벤트명
- **업종**: 음식점, 카페, 베이커리, 치킨집 등
- **지역**: 서울, 경기, 부산 등 주요 지역
- **시즌**: 진행 시기 (명절, 계절, 기념일 등)
- **이벤트 유형**: online(온라인) / offline(오프라인)
- **예산 범위**: low(저예산), medium(중예산), high(고예산)
- **경품/혜택**: 제공한 인센티브
- **참여 방법**: 고객 참여 프로세스
- **실제 참여자 수**: 이벤트 참여 고객 수
- **실제 비용**: 총 이벤트 운영 비용 (원)
- **실제 ROI**: 투자대비수익률 (%)
- **진행 기간**: 이벤트 시작-종료일
---
## 카테고리 1: 한국 전통 명절 & 국가 공휴일 시즌
### 1. 설날 특선 메뉴 이벤트
- **이벤트 제목**: 설날 한정 떡국 세트 할인
- **업종**: 한식당
- **지역**: 서울 강남구
- **시즌**: 설날 (1월 말~2월 초)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 떡국 세트 20% 할인
- **참여 방법**: 매장 방문 시 자동 적용
- **실제 참여자 수**: 285명
- **실제 비용**: 1,850,000원
- **실제 ROI**: 420%
- **진행 기간**: 2024-01-20 ~ 2024-02-05
- **성공 요인**: 전통 명절 음식 수요, 가족 단위 외식 증가
- **참고 사항**: 명절 3일 전부터 예약 주문 집중, 포장 주문 45%
### 2. 추석 선물 세트 사전 예약
- **이벤트 제목**: 추석 한정식 선물 세트 온라인 사전 주문
- **업종**: 한식당
- **지역**: 경기 성남시
- **시즌**: 추석 (9월)
- **이벤트 유형**: online
- **예산 범위**: high
- **경품/혜택**: 사전 주문 시 15% 할인 + 배송비 무료
- **참여 방법**: 온라인 주문 페이지에서 예약 결제
- **실제 참여자 수**: 420명
- **실제 비용**: 5,200,000원
- **실제 ROI**: 380%
- **진행 기간**: 2024-08-25 ~ 2024-09-10
- **성공 요인**: 선물 수요, 사전 예약으로 재고 관리 용이
- **참고 사항**: 지인 추천 이벤트 병행으로 신규 고객 35% 유입
### 3. 크리스마스 커플 세트 프로모션
- **이벤트 제목**: 크리스마스 커플 디너 특가
- **업종**: 양식당
- **지역**: 서울 홍대
- **시즌**: 크리스마스 (12월)
- **이벤트 유형**: offline
- **예산 범위**: high
- **경품/혜택**: 커플 디너 코스 30% 할인
- **참여 방법**: 전화/온라인 예약 후 방문
- **실제 참여자 수**: 320명 (160커플)
- **실제 비용**: 6,400,000원
- **실제 ROI**: 285%
- **진행 기간**: 2024-12-20 ~ 2024-12-26
- **성공 요인**: 연인 데이트 수요, 프리미엄 경험 제공
- **참고 사항**: 인스타그램 감성 인테리어로 SNS 자발적 확산
### 4. 신정 새해 첫 방문 쿠폰
- **이벤트 제목**: 새해 첫 방문 고객 음료 무료
- **업종**: 카페
- **지역**: 부산 해운대구
- **시즌**: 신정 (1월 1일~3일)
- **이벤트 유형**: offline
- **예산 범위**: low
- **경품/혜택**: 아메리카노 무료 제공
- **참여 방법**: 매장 방문 시 자동 제공
- **실제 참여자 수**: 220명
- **실제 비용**: 280,000원
- **실제 ROI**: 550%
- **진행 기간**: 2025-01-01 ~ 2025-01-03
- **성공 요인**: 새해 첫날 특별한 경험, SNS 공유 유도
- **참고 사항**: 해운대 해돋이 방문객 유입 효과
### 5. 어린이날 가족 패키지 이벤트
- **이벤트 제목**: 어린이날 키즈 메뉴 무료 + 가족 할인
- **업종**: 패밀리 레스토랑
- **지역**: 경기 수원시
- **시즌**: 어린이날 (5월 5일 전후)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 어린이 메뉴 1인 무료 + 성인 메뉴 15% 할인
- **참여 방법**: 가족 단위 방문 시 자동 적용
- **실제 참여자 수**: 580명
- **실제 비용**: 2,100,000원
- **실제 ROI**: 485%
- **진행 기간**: 2024-05-03 ~ 2024-05-07
- **성공 요인**: 황금연휴, 가족 외식 수요 폭발
- **참고 사항**: 어린이 경품(장난감) 증정으로 만족도 상승
### 6. 어버이날 SNS 인증 이벤트
- **이벤트 제목**: 부모님과 함께 인증샷 이벤트
- **업종**: 한식당
- **지역**: 서울 강서구
- **시즌**: 어버이날 (5월 8일)
- **이벤트 유형**: online
- **예산 범위**: low
- **경품/혜택**: 인스타그램 인증 시 디저트 무료
- **참여 방법**: 부모님과 식사 사진 + 해시태그 + 매장 태그
- **실제 참여자 수**: 185명
- **실제 비용**: 320,000원
- **실제 ROI**: 520%
- **진행 기간**: 2024-05-05 ~ 2024-05-12
- **성공 요인**: SNS 확산, 가족 외식 트렌드
- **참고 사항**: 인스타그램 팔로워 240명 증가
### 7. 크리스마스 온라인 주문 할인
- **이벤트 제목**: 크리스마스 홈파티 치킨 배달 특가
- **업종**: 치킨집
- **지역**: 서울 송파구
- **시즌**: 크리스마스 (12월)
- **이벤트 유형**: online
- **예산 범위**: medium
- **경품/혜택**: 2마리 이상 주문 시 25% 할인
- **참여 방법**: 배달앱을 통한 온라인 주문
- **실제 참여자 수**: 340명
- **실제 비용**: 1,680,000원
- **실제 ROI**: 395%
- **진행 기간**: 2024-12-20 ~ 2024-12-25
- **성공 요인**: 홈파티 수요, 배달 편의성
- **참고 사항**: 배달앱 쿠폰 추가 발행으로 신규 고객 40% 유입
### 8. 광복절 애국 테마 이벤트
- **이벤트 제목**: 광복절 태극기 인증 할인
- **업종**: 카페
- **지역**: 서울 종로구
- **시즌**: 광복절 (8월 15일)
- **이벤트 유형**: offline
- **예산 범위**: low
- **경품/혜택**: 태극기 옷/소품 착용 시 음료 20% 할인
- **참여 방법**: 매장 방문 시 태극기 착용 확인
- **실제 참여자 수**: 95명
- **실제 비용**: 150,000원
- **실제 ROI**: 380%
- **진행 기간**: 2024-08-15 ~ 2024-08-15
- **성공 요인**: 애국심 테마, SNS 화제성
- **참고 사항**: 지역 특성상 관광객 유입 효과
### 9. 개천절 가을 한정 메뉴 출시
- **이벤트 제목**: 가을 단풍 시즌 신메뉴 시식 이벤트
- **업종**: 베이커리 카페
- **지역**: 경기 남양주시
- **시즌**: 개천절 (10월 초)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 신메뉴 밤 크림빵 시식 + 구매 시 15% 할인
- **참여 방법**: 매장 방문 시 시식 후 구매
- **실제 참여자 수**: 420명
- **실제 비용**: 1,250,000원
- **실제 ROI**: 440%
- **진행 기간**: 2024-10-01 ~ 2024-10-05
- **성공 요인**: 가을 제철 재료, 단풍 관광객
- **참고 사항**: 포장 선물용 구매 비율 60%
### 10. 한글날 한국어 이벤트
- **이벤트 제목**: 한글날 메뉴명 한글로 말하기 이벤트
- **업종**: 카페
- **지역**: 서울 강남구
- **시즌**: 한글날 (10월 9일)
- **이벤트 유형**: offline
- **예산 범위**: low
- **경품/혜택**: 메뉴를 한글로 주문 시 사이즈 업그레이드
- **참여 방법**: 주문 시 한글 메뉴명으로 말하기
- **실제 참여자 수**: 280명
- **실제 비용**: 180,000원
- **실제 ROI**: 610%
- **진행 기간**: 2024-10-09 ~ 2024-10-09
- **성공 요인**: 참여 장벽 낮음, 재미 요소
- **참고 사항**: SNS 인증샷 자발적 공유 다수
---
## 카테고리 2: 계절별 시즌
### 11. 봄 벚꽃 시즌 포장 도시락
- **이벤트 제목**: 벚꽃놀이 피크닉 도시락 세트
- **업종**: 한식당
- **지역**: 서울 여의도
- **시즌**: 봄 (4월 벚꽃 시즌)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 2인 도시락 세트 20,000원 (정상가 대비 25% 할인)
- **참여 방법**: 전화 사전 주문 또는 매장 방문 구매
- **실제 참여자 수**: 520명
- **실제 비용**: 1,950,000원
- **실제 ROI**: 450%
- **진행 기간**: 2024-04-01 ~ 2024-04-10
- **성공 요인**: 벚꽃 축제 관광객, 야외 식사 수요
- **참고 사항**: 여의도 벚꽃 축제 기간 집중 매출
### 12. 여름 보양식 복날 이벤트
- **이벤트 제목**: 삼계탕 복날 특가 프로모션
- **업종**: 한식당
- **지역**: 경기 고양시
- **시즌**: 여름 (초복/중복/말복)
- **이벤트 유형**: offline
- **예산 범위**: low
- **경품/혜택**: 삼계탕 주문 시 음료수 무료 제공
- **참여 방법**: 매장 방문 주문
- **실제 참여자 수**: 385명
- **실제 비용**: 420,000원
- **실제 ROI**: 580%
- **진행 기간**: 2024-07-15 ~ 2024-08-14 (복날 3일간)
- **성공 요인**: 전통 보양식 수요, 복날 문화
- **참고 사항**: 예약 고객 80%, 당일 방문 20%
### 13. 가을 전어 축제 이벤트
- **이벤트 제목**: 가을 제철 전어 구이 할인
- **업종**: 해산물 음식점
- **지역**: 부산 광안리
- **시즌**: 가을 (9월~10월)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 전어 구이 정식 30% 할인
- **참여 방법**: 매장 방문 시 자동 적용
- **실제 참여자 수**: 450명
- **실제 비용**: 2,350,000원
- **실제 ROI**: 410%
- **진행 기간**: 2024-09-15 ~ 2024-10-31
- **성공 요인**: 제철 재료, 가을 단풍 관광객
- **참고 사항**: 주말 예약 필수, 평일 당일 가능
### 14. 겨울 따뜻한 메뉴 프로모션
- **이벤트 제목**: 겨울 한파 특보 찌개 세트 할인
- **업종**: 한식당
- **지역**: 서울 마포구
- **시즌**: 겨울 (12월~2월 한파 기간)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 찌개류 주문 시 공기밥 무료 + 10% 할인
- **참여 방법**: 매장 방문 주문
- **실제 참여자 수**: 620명
- **실제 비용**: 1,850,000원
- **실제 ROI**: 520%
- **진행 기간**: 2024-12-15 ~ 2025-02-28
- **성공 요인**: 한파 특보 타이밍, 따뜻한 음식 수요
- **참고 사항**: 날씨가 추울수록 매출 증가 (날씨 연동 마케팅)
### 15. 봄 신학기 학생 할인
- **이벤트 제목**: 신학기 학생 응원 카페 음료 할인
- **업종**: 카페
- **지역**: 서울 신촌
- **시즌**: 봄 (3월 개학 시즌)
- **이벤트 유형**: offline
- **예산 범위**: low
- **경품/혜택**: 학생증 제시 시 음료 20% 할인
- **참여 방법**: 주문 시 학생증 제시
- **실제 참여자 수**: 580명
- **실제 비용**: 450,000원
- **실제 ROI**: 620%
- **진행 기간**: 2024-03-01 ~ 2024-03-31
- **성공 요인**: 대학가 상권, 신학기 모임 증가
- **참고 사항**: 스터디 그룹 단체 할인 병행
### 16. 여름 빙수 시즌 SNS 이벤트
- **이벤트 제목**: 인스타그램 빙수 인증샷 이벤트
- **업종**: 카페
- **지역**: 경기 의정부시
- **시즌**: 여름 (7월~8월)
- **이벤트 유형**: online
- **예산 범위**: low
- **경품/혜택**: 인스타그램 스토리 태그 시 다음 방문 음료 무료
- **참여 방법**: 빙수 사진 + 매장 태그 + 해시태그
- **실제 참여자 수**: 340명
- **실제 비용**: 380,000원
- **실제 ROI**: 560%
- **진행 기간**: 2024-07-01 ~ 2024-08-31
- **성공 요인**: SNS 확산, 여름 한정 메뉴
- **참고 사항**: 인스타그램 팔로워 420명 증가
### 17. 가을 단풍 시즌 배달 이벤트
- **이벤트 제목**: 단풍놀이 후 배달 주문 할인
- **업종**: 치킨집
- **지역**: 경기 가평군
- **시즌**: 가을 (10월~11월 단풍 시즌)
- **이벤트 유형**: online
- **예산 범위**: medium
- **경품/혜택**: 2만원 이상 주문 시 배달비 무료 + 음료 2병 증정
- **참여 방법**: 배달앱 또는 전화 주문
- **실제 참여자 수**: 280명
- **실제 비용**: 980,000원
- **실제 ROI**: 420%
- **진행 기간**: 2024-10-10 ~ 2024-11-15
- **성공 요인**: 단풍 관광객 타겟, 펜션/숙박 시설 배달
- **참고 사항**: 주말 주문 집중 (금~일 70%)
### 18. 겨울 크리스마스 디저트 출시
- **이벤트 제목**: 크리스마스 한정 케이크 사전 예약
- **업종**: 베이커리
- **지역**: 서울 강남구
- **시즌**: 겨울 (12월 크리스마스)
- **이벤트 유형**: online
- **예산 범위**: high
- **경품/혜택**: 사전 예약 시 15% 할인 + 무료 픽업
- **참여 방법**: 온라인 사전 주문
- **실제 참여자 수**: 520명
- **실제 비용**: 4,800,000원
- **실제 ROI**: 340%
- **진행 기간**: 2024-12-01 ~ 2024-12-24
- **성공 요인**: 크리스마스 케이크 필수 수요, 사전 예약 재고 관리
- **참고 사항**: 12월 20일 이후 주문 마감
### 19. 봄 새학기 MT 단체 주문
- **이벤트 제목**: 대학생 MT 단체 주문 특가
- **업종**: 치킨집
- **지역**: 강원도 춘천시
- **시즌**: 봄 (3월~4월 MT 시즌)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 10마리 이상 주문 시 20% 할인 + 배달비 무료
- **참여 방법**: 전화 사전 예약
- **실제 참여자 수**: 180명 (18건 단체 주문)
- **실제 비용**: 1,520,000원
- **실제 ROI**: 480%
- **진행 기간**: 2024-03-10 ~ 2024-04-30
- **성공 요인**: MT 시즌 수요, 단체 할인 매력
- **참고 사항**: 펜션/민박 배달 집중
### 20. 여름 휴가 시즌 포장 할인
- **이벤트 제목**: 여름 휴가 포장 주문 특가
- **업종**: 한식당
- **지역**: 강원도 속초시
- **시즌**: 여름 (7월~8월 휴가 시즌)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 포장 주문 시 10% 할인 + 반찬 추가 증정
- **참여 방법**: 매장 방문 또는 전화 주문
- **실제 참여자 수**: 680명
- **실제 비용**: 2,150,000원
- **실제 ROI**: 510%
- **진행 기간**: 2024-07-20 ~ 2024-08-25
- **성공 요인**: 속초 관광객, 숙소 내 식사 수요
- **참고 사항**: 저녁 시간대 주문 집중 (17~20시)
---
## 카테고리 3: 상업적 기념일 & 월별 특화 시즌
### 21. 발렌타인데이 커플 세트
- **이벤트 제목**: 발렌타인데이 커플 디저트 세트
- **업종**: 카페
- **지역**: 서울 홍대
- **시즌**: 발렌타인데이 (2월 14일)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 커플 디저트 세트 25% 할인
- **참여 방법**: 2인 이상 방문 시 자동 적용
- **실제 참여자 수**: 380명
- **실제 비용**: 1,450,000원
- **실제 ROI**: 440%
- **진행 기간**: 2024-02-10 ~ 2024-02-14
- **성공 요인**: 연인 데이트 수요, 감성 마케팅
- **참고 사항**: 인스타그램 감성 세팅으로 SNS 확산
### 22. 화이트데이 남성 고객 이벤트
- **이벤트 제목**: 화이트데이 남성 고객 사탕 증정
- **업종**: 베이커리
- **지역**: 경기 분당구
- **시즌**: 화이트데이 (3월 14일)
- **이벤트 유형**: offline
- **예산 범위**: low
- **경품/혜택**: 남성 고객 케이크 구매 시 사탕 세트 무료
- **참여 방법**: 매장 방문 구매
- **실제 참여자 수**: 240명
- **실제 비용**: 380,000원
- **실제 ROI**: 520%
- **진행 기간**: 2024-03-10 ~ 2024-03-14
- **성공 요인**: 답례 선물 수요, 남성 타겟 마케팅
- **참고 사항**: 사전 예약 고객 60%
### 23. 블랙데이 1인 메뉴 특가
- **이벤트 제목**: 블랙데이 짜장면 할인 이벤트
- **업종**: 중식당
- **지역**: 서울 신촌
- **시즌**: 블랙데이 (4월 14일)
- **이벤트 유형**: offline
- **예산 범위**: low
- **경품/혜택**: 짜장면/짬뽕 1인 메뉴 30% 할인
- **참여 방법**: 1인 방문 고객 자동 적용
- **실제 참여자 수**: 185명
- **실제 비용**: 320,000원
- **실제 ROI**: 470%
- **진행 기간**: 2024-04-14 ~ 2024-04-14
- **성공 요인**: 솔로 고객 공감, 재미 요소
- **참고 사항**: SNS 바이럴 효과 (대학생 타겟)
### 24. 빼빼로데이 학생 이벤트
- **이벤트 제목**: 빼빼로데이 꼬치 메뉴 할인
- **업종**: 분식집
- **지역**: 서울 강서구
- **시즌**: 빼빼로데이 (11월 11일)
- **이벤트 유형**: offline
- **예산 범위**: low
- **경품/혜택**: 떡꼬치/어묵 등 길쭉한 메뉴 20% 할인
- **참여 방법**: 학생증 제시 시 적용
- **실제 참여자 수**: 320명
- **실제 비용**: 280,000원
- **실제 ROI**: 580%
- **진행 기간**: 2024-11-09 ~ 2024-11-11
- **성공 요인**: 학생 타겟, 재미있는 컨셉
- **참고 사항**: 학교 근처 상권 효과
### 25. 블랙프라이데이 파격 할인
- **이벤트 제목**: 블랙프라이데이 50% 파격 할인
- **업종**: 치킨집
- **지역**: 경기 안양시
- **시즌**: 블랙프라이데이 (11월 넷째 주 금요일)
- **이벤트 유형**: online
- **예산 범위**: high
- **경품/혜택**: 선착순 50명 치킨 50% 할인
- **참여 방법**: 배달앱 선착순 주문
- **실제 참여자 수**: 50명 (조기 마감)
- **실제 비용**: 850,000원
- **실제 ROI**: 380%
- **진행 기간**: 2024-11-29 ~ 2024-11-29
- **성공 요인**: 희소성 마케팅, 파격 할인율
- **참고 사항**: 2시간 만에 완판, SNS 화제
### 26. 월별 생일 축하 이벤트
- **이벤트 제목**: 생일 고객 케이크 무료 증정
- **업종**: 레스토랑
- **지역**: 서울 용산구
- **시즌**: 연중 (매월)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 생일 당일 방문 시 미니 케이크 무료
- **참여 방법**: 신분증 제시 또는 사전 예약 시 생일 정보 제공
- **실제 참여자 수**: 420명 (월 35명 평균)
- **실제 비용**: 1,680,000원 (연간)
- **실제 ROI**: 550%
- **진행 기간**: 2024-01-01 ~ 2024-12-31
- **성공 요인**: 특별한 날 경험, 재방문 유도
- **참고 사항**: 생일 고객의 재방문율 70%
### 27. 삼겹살데이 고깃집 프로모션
- **이벤트 제목**: 3월 3일 삼겹살 무한리필
- **업종**: 고깃집
- **지역**: 서울 강남구
- **시즌**: 삼겹살데이 (3월 3일)
- **이벤트 유형**: offline
- **예산 범위**: high
- **경품/혜택**: 삼겹살 무한리필 특가 (1인 19,900원)
- **참여 방법**: 매장 방문 (예약 필수)
- **실제 참여자 수**: 520명
- **실제 비용**: 5,800,000원
- **실제 ROI**: 320%
- **진행 기간**: 2024-03-03 ~ 2024-03-03
- **성공 요인**: 기념일 특화, 무한리필 매력
- **참고 사항**: 예약 1주 전 마감
### 28. 치킨데이 배달 프로모션
- **이벤트 제목**: 매월 셋째 주 목요일 치킨 할인
- **업종**: 치킨집
- **지역**: 경기 성남시
- **시즌**: 매월 셋째 주 목요일
- **이벤트 유형**: online
- **예산 범위**: medium
- **경품/혜택**: 치킨 주문 시 음료수 2L 무료
- **참여 방법**: 배달앱 또는 전화 주문
- **실제 참여자 수**: 1,680명 (연간, 월 140명 평균)
- **실제 비용**: 2,520,000원 (연간)
- **실제 ROI**: 480%
- **진행 기간**: 2024-01-01 ~ 2024-12-31
- **성공 요인**: 정기 이벤트로 고객 학습 효과
- **참고 사항**: 목요일 매출 35% 증가
### 29. 커피데이 카페 프로모션
- **이벤트 제목**: 10월 1일 커피의 날 아메리카노 반값
- **업종**: 카페
- **지역**: 부산 서면
- **시즌**: 커피의 날 (10월 1일)
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 아메리카노 50% 할인
- **참여 방법**: 매장 방문 주문
- **실제 참여자 수**: 680명
- **실제 비용**: 1,350,000원
- **실제 ROI**: 520%
- **진행 기간**: 2024-10-01 ~ 2024-10-01
- **성공 요인**: 커피 애호가 타겟, 파격 할인
- **참고 사항**: 1인 최대 2잔 제한
### 30. 빵의 날 베이커리 이벤트
- **이벤트 제목**: 매월 3일 빵 1+1 이벤트
- **업종**: 베이커리
- **지역**: 서울 마포구
- **시즌**: 매월 3일
- **이벤트 유형**: offline
- **예산 범위**: medium
- **경품/혜택**: 빵 1개 구매 시 1개 추가 (동일 상품)
- **참여 방법**: 매장 방문 구매
- **실제 참여자 수**: 1,920명 (연간, 월 160명 평균)
- **실제 비용**: 2,880,000원 (연간)
- **실제 ROI**: 450%
- **진행 기간**: 2024-01-03 ~ 2024-12-03
- **성공 요인**: 정기 이벤트, 1+1 매력
- **참고 사항**: 선착순 200개 한정 (매월)
---
## 데이터 활용 가이드
### AI 학습을 위한 주요 패턴
1. **시즌별 특성**
- 명절: 가족 단위, 전통 음식, 선물 수요
- 계절: 날씨 연동, 제철 재료, 관광 수요
- 기념일: 타겟 세분화, 감성 마케팅, SNS 활용
2. **예산별 ROI 패턴**
- 저예산 (30-50만원): 평균 ROI 550% (높은 효율)
- 중예산 (100-200만원): 평균 ROI 450% (안정적)
- 고예산 (300만원 이상): 평균 ROI 350% (브랜드 가치 상승)
3. **온라인 vs 오프라인**
- 온라인: 데이터 수집 용이, 젊은 층 타겟, ROI 측정 명확
- 오프라인: 체험 중심, 즉각 보상, 재방문 유도
4. **업종별 성공 패턴**
- 한식당: 명절/계절 연계, 전통 가치
- 카페: SNS 활용, 감성 마케팅, 정기 이벤트
- 치킨집: 배달 최적화, 정기 할인, 무료 증정
- 베이커리: 사전 예약, 한정 상품, 시각적 매력
5. **참여 방법별 효과**
- SNS 인증: 바이럴 효과, 낮은 비용, 높은 ROI
- 선착순: 긴급성, 높은 참여율, 매출 집중
- 자동 적용: 참여 장벽 낮음, 만족도 높음
- 사전 예약: 재고 관리, 계획적 운영
### 데이터 확장 방향
본 샘플 데이터는 AI 시스템의 초기 학습용입니다. 실제 운영 시 다음 데이터가 추가로 수집됩니다:
- 사용자 생성 이벤트 데이터
- 실제 성과 데이터 (참여자, 비용, ROI)
- 고객 피드백 (만족도, 재방문율)
- A/B 테스트 결과
- 지역별/업종별 세분화 데이터
---
**데이터 버전**: 1.0
**최종 수정**: 2025-01-21
**다음 업데이트 예정**: 실제 서비스 운영 후 분기별

View File

@ -0,0 +1,982 @@
# 업종별 성공 이벤트 데이터
**작성일**: 2025-10-21
**버전**: 1.0
**목적**: AI 기반 이벤트 추천 시스템 학습 데이터
---
## 개요
본 문서는 한국 외식업 소상공인을 위한 업종별 성공 이벤트 데이터를 제공합니다. 실제 성공 사례와 학술 연구를 기반으로 작성되었으며, AI 이벤트 추천 시스템의 학습 데이터로 활용됩니다.
### 데이터 구조
각 이벤트 데이터는 다음 항목을 포함합니다:
- **업종명**: 외식업 카테고리
- **이벤트 제목**: 고객에게 표시되는 이벤트명
- **이벤트 유형**: 할인, 쿠폰, 경품, 스탬프, 번들, SNS, 창의적, 배달
- **경품/혜택**: 고객에게 제공되는 구체적 혜택
- **참여 방법**: 고객 참여 절차
- **예산 규모**: 저(25-30만원), 중(150-180만원), 고(500-600만원)
- **예상 참여자 수**: 이벤트 기간 중 참여 고객 수
- **예상 비용**: 실제 소요 예상 금액
- **예상 ROI**: 투자 대비 수익률 (%)
- **성공 요인**: 이벤트 성공 핵심 요소
- **적용 시기/계절**: 최적 실행 시기
### 데이터 출처
- 실제 성공 사례: 자담치킨(+28.4%), 크치치킨(매출 3배), 홈플러스 당당치킨(710만팩), 아뜨베(6년 성장)
- 학술 연구: 체험 마케팅 ROI 250-300%, 리뷰 1개당 주문 확률 +5-7%
- 업계 벤치마크: 참여율 15-35%, 전환율 15-40%, 마케팅 ROI 400%+
---
## 1. 한식당 (Korean Restaurant)
### 1-1. 점심 시간대 세트 할인 이벤트
- **업종명**: 한식당
- **이벤트 제목**: "오피스 런치 타임 세트 20% 할인"
- **이벤트 유형**: 시간대별 할인
- **경품/혜택**: 11시-14시 특정 세트 메뉴 20% 할인
- **참여 방법**: 해당 시간대 방문 시 자동 적용
- **예산 규모**: 저비용
- **예상 참여자 수**: 180명 (월 기준)
- **예상 비용**: 280,000원
- **예상 ROI**: 350%
- **성공 요인**:
- 수요 평준화 (비수기 시간대 활성화)
- 명확한 타겟팅 (직장인 점심 수요)
- 즉시 혜택 (복잡한 절차 없음)
- **적용 시기/계절**: 연중, 특히 봄/가을 (날씨 좋은 시기)
### 1-2. 네이버 플레이스 리뷰 이벤트
- **업종명**: 한식당
- **이벤트 제목**: "리뷰 작성하고 음료 무료!"
- **이벤트 유형**: 리뷰 이벤트
- **경품/혜택**: 네이버 플레이스 리뷰 작성 시 음료 1잔 무료
- **참여 방법**: 매장 방문 → 식사 후 네이버 리뷰 작성 → 직원에게 화면 제시
- **예산 규모**: 저비용
- **예상 참여자 수**: 120명
- **예상 비용**: 250,000원 (음료 원가 2,000원 × 120명 + 홍보)
- **예상 ROI**: 280%
- **성공 요인**:
- 온라인 평판 개선 (리뷰 1개당 주문 확률 +5-7%)
- 즉시 보상 (방문 당일 혜택)
- 낮은 참여 장벽
- **적용 시기/계절**: 신규 오픈 후 3개월, 또는 평점 상승 필요 시
### 1-3. 가족 단위 방문 스탬프 적립
- **업종명**: 한식당
- **이벤트 제목**: "가족 식사 10회 적립 시 무료 식사권"
- **이벤트 유형**: 스탬프/포인트
- **경품/혜택**: 10회 방문 적립 시 3만원 상당 식사권 제공
- **참여 방법**: 카카오톡 채널 가입 → 방문 시 디지털 스탬프 적립
- **예산 규모**: 중비용
- **예상 참여자 수**: 250명 (재방문 포함)
- **예상 비용**: 1,500,000원
- **예상 ROI**: 380%
- **성공 요인**:
- 재방문 유도 (완료율 40-60%)
- 고객 데이터 수집 (카카오톡 채널)
- 가족 단위 타겟팅
- **적용 시기/계절**: 연중, 특히 명절 전후 (설날, 추석)
### 1-4. 설날 특별 한정 메뉴 SNS 이벤트
- **업종명**: 한식당
- **이벤트 제목**: "전통 떡국 인스타그램 인증 이벤트"
- **이벤트 유형**: SNS 바이럴
- **경품/혜택**: 인스타그램 게시물 업로드 시 전통 떡국 15% 할인
- **참여 방법**: 매장 방문 → 음식 사진 촬영 → 인스타그램 해시태그 #OO한식당설날떡국 + 매장 태그
- **예산 규모**: 중비용
- **예상 참여자 수**: 200명
- **예상 비용**: 1,800,000원
- **예상 ROI**: 320%
- **성공 요인**:
- 계절성 활용 (설날 수요)
- 바이럴 마케팅 (팔로워 확산)
- 한정 메뉴 희소성
- **적용 시기/계절**: 겨울 (설날 2주 전부터 당일까지)
### 1-5. VIP 고객 초대 시식회
- **업종명**: 한식당
- **이벤트 제목**: "단골 고객님을 위한 신메뉴 시식회"
- **이벤트 유형**: 창의적 이벤트
- **경품/혜택**: 상위 30명 VIP 고객 무료 초대, 신메뉴 시식 및 20% 할인권 제공
- **참여 방법**: 월 3회 이상 방문 고객 대상 카카오톡 개별 초대
- **예산 규모**: 고비용
- **예상 참여자 수**: 30명 (VIP 대상)
- **예상 비용**: 5,500,000원
- **예상 ROI**: 240%
- **성공 요인**:
- 고객 충성도 강화 (VIP 프로그램)
- 입소문 마케팅 (고가치 고객 전도사화)
- 신메뉴 피드백 수집
- **적용 시기/계절**: 신메뉴 출시 1개월 전
---
## 2. 치킨집 (Chicken Restaurant)
### 2-1. 평일 오후 타임 특가
- **업종명**: 치킨집
- **이벤트 제목**: "평일 오후 2시-5시 치킨 30% 할인"
- **이벤트 유형**: 시간대별 할인
- **경품/혜택**: 평일 오후 특정 시간대 후라이드/양념 치킨 30% 할인
- **참여 방법**: 해당 시간대 주문 시 자동 적용 (배달/포장 모두 가능)
- **예산 규모**: 저비용
- **예상 참여자 수**: 150명
- **예상 비용**: 300,000원
- **예상 ROI**: 420%
- **성공 요인**:
- 비수기 시간대 활성화
- 주방 용량 활용 극대화
- 배달앱 리뷰 증가
- **적용 시기/계절**: 연중 (특히 봄/가을)
### 2-2. 스크래치 복권 즉석 당첨
- **업종명**: 치킨집
- **이벤트 제목**: "주문 시 스크래치 복권 증정 이벤트"
- **이벤트 유형**: 경품 이벤트 (즉석 당첨)
- **경품/혜택**: 소주/콜라/치킨무/프렌치프라이/윙봉 추가/치킨 1마리 (200명 기준)
- **참여 방법**: 2만원 이상 주문 시 스크래치 복권 1장 증정 → 즉시 긁어서 당첨 확인
- **예산 규모**: 저비용
- **예상 참여자 수**: 200명
- **예상 비용**: 485,000원 (경품 원가)
- **예상 ROI**: 450%
- **성공 요인**:
- 즉각적 보상 (게이미피케이션 +180-350% 참여)
- 분실 위험 없음 (즉시 사용)
- 재미 요소 (고객 흥미 유발)
- **적용 시기/계절**: 연중, 특히 월드컵/올림픽 등 스포츠 이벤트 기간
### 2-3. 배달앱 리뷰 작성 이벤트
- **업종명**: 치킨집
- **이벤트 제목**: "배민 리뷰 작성하고 다음 주문 15% 할인"
- **이벤트 유형**: 리뷰 + 쿠폰
- **경품/혜택**: 배달의민족 리뷰 작성 시 다음 주문 15% 할인 쿠폰 (1개월 유효)
- **참여 방법**: 주문 → 배달 완료 후 리뷰 작성 → 자동으로 쿠폰 발급
- **예산 규모**: 중비용
- **예상 참여자 수**: 220명
- **예상 비용**: 1,650,000원
- **예상 ROI**: 400%
- **성공 요인**:
- 재방문 유도 (쿠폰 유효기간 1개월)
- 배달앱 순위 상승 (리뷰 개수 ↑)
- 신규 고객 유입 증가
- **적용 시기/계절**: 연중
### 2-4. 매출 상생 맞춤형 마케팅
- **업종명**: 치킨집
- **이벤트 제목**: "AI 데이터 분석 기반 개별 프로모션"
- **이벤트 유형**: 창의적 이벤트 (데이터 기반)
- **경품/혜택**: 고객별 구매 패턴 분석 후 맞춤형 할인/쿠폰 제공
- **참여 방법**: 자동 분석 → 카카오톡/문자로 개별 프로모션 발송
- **예산 규모**: 중비용
- **예상 참여자 수**: 280명
- **예상 비용**: 1,700,000원
- **예상 ROI**: 385%
- **성공 요인**:
- 개인화 마케팅 (구매 패턴 반영)
- 이탈 고객 재활성화
- 데이터 기반 의사결정 (자담치킨 사례: +28.4%)
- **적용 시기/계절**: 연중 (월 1회 분석 및 발송)
### 2-5. 인플루언서 협업 이벤트
- **업종명**: 치킨집
- **이벤트 제목**: "유명 먹방 유튜버와 함께하는 신메뉴 출시"
- **이벤트 유형**: SNS 바이럴 (인플루언서 협업)
- **경품/혜택**: 유튜버 방문 영상 공개 + 신메뉴 20% 할인 (1주일 한정)
- **참여 방법**: 유튜브 영상 시청 → 매장 방문 또는 배달 주문 시 "유튜브 봤어요" 멘트
- **예산 규모**: 고비용
- **예상 참여자 수**: 400명
- **예상 비용**: 5,800,000원 (인플루언서 비용 500만 + 할인 비용)
- **예상 ROI**: 280%
- **성공 요인**:
- 대규모 노출 (유튜버 구독자 수만-수십만)
- 신뢰도 높은 추천 (먹방 유튜버 영향력)
- 신메뉴 빠른 인지도 확보
- **적용 시기/계절**: 신메뉴 출시 시점
---
## 3. 카페 (Cafe)
### 3-1. 모닝 커피 세트 할인
- **업종명**: 카페
- **이벤트 제목**: "오전 7시-10시 모닝 커피+빵 세트 3,500원"
- **이벤트 유형**: 시간대별 번들 프로모션
- **경품/혜택**: 아메리카노 + 크루아상 세트 3,500원 (정상가 6,500원)
- **참여 방법**: 해당 시간대 매장 방문 또는 테이크아웃
- **예산 규모**: 저비용
- **예상 참여자 수**: 200명
- **예상 비용**: 250,000원
- **예상 ROI**: 480%
- **성공 요인**:
- 비수기 시간대 활성화 (아침 시간)
- 직장인 타겟팅
- 세트 구성으로 객단가 상승
- **적용 시기/계절**: 연중 (평일 중심)
### 3-2. 디지털 스탬프 10+1 이벤트
- **업종명**: 카페
- **이벤트 제목**: "커피 10잔 마시면 1잔 무료!"
- **이벤트 유형**: 스탬프/포인트
- **경품/혜택**: 10회 구매 적립 시 아메리카노 1잔 무료
- **참여 방법**: 카카오톡 채널 또는 자체 앱 가입 → 구매 시 자동 적립
- **예산 규모**: 저비용
- **예상 참여자 수**: 180명 (재방문 포함 약 2,000회)
- **예상 비용**: 270,000원
- **예상 ROI**: 520%
- **성공 요인**:
- 재방문 유도 (완료율 40-60%)
- 고객 충성도 구축
- 종이 스탬프 분실 방지 (디지털)
- **적용 시기/계절**: 연중
### 3-3. 인스타그램 감성 포토존 이벤트
- **업종명**: 카페
- **이벤트 제목**: "포토존에서 인증샷 올리고 음료 20% 할인"
- **이벤트 유형**: SNS 바이럴
- **경품/혜택**: 인스타그램 포토존 사진 업로드 + 매장 태그 시 20% 할인
- **참여 방법**: 매장 방문 → 포토존 촬영 → 인스타그램 업로드 + #OO카페 해시태그 → 직원에게 화면 제시
- **예산 규모**: 중비용 (포토존 제작 포함)
- **예상 참여자 수**: 250명
- **예상 비용**: 1,800,000원 (포토존 제작 100만 + 할인 비용)
- **예상 ROI**: 340%
- **성공 요인**:
- 바이럴 마케팅 (인스타그래머블 공간)
- 2030 여성 타겟팅 (성수동 카페 사례)
- UGC 자발적 확산
- **적용 시기/계절**: 연중 (특히 봄/가을 감성 시즌)
### 3-4. 생일 고객 특별 혜택
- **업종명**: 카페
- **이벤트 제목**: "생일 고객님께 음료 1잔 무료 선물"
- **이벤트 유형**: 재방문 유도 (고객 세그먼트)
- **경품/혜택**: 생일 당월 방문 시 음료 1잔 무료 (5천원 이하 메뉴)
- **참여 방법**: 카카오톡 채널 가입 시 생일 등록 → 생일 달 쿠폰 자동 발송
- **예산 규모**: 중비용
- **예상 참여자 수**: 200명
- **예상 비용**: 1,500,000원
- **예상 ROI**: 360%
- **성공 요인**:
- 개인화 마케팅 (특별한 날 혜택)
- 높은 재방문율 (생일 고객 ROI 높음)
- 고객 데이터 수집
- **적용 시기/계절**: 연중
### 3-5. 레트로 콜라보 한정 메뉴
- **업종명**: 카페
- **이벤트 제목**: "감성커피 X 바나나킥 한정 콜라보 메뉴"
- **이벤트 유형**: 창의적 이벤트 (브랜드 협업)
- **경품/혜택**: 바나나킥 라떼 + 카라멜 쉐이크 한정 판매 (2주간)
- **참여 방법**: 매장 방문 또는 배달 주문
- **예산 규모**: 고비용
- **예상 참여자 수**: 350명
- **예상 비용**: 5,200,000원 (협업 비용 + 재료비 + 마케팅)
- **예상 ROI**: 290%
- **성공 요인**:
- 레트로 향수 자극 (MZ세대 타겟)
- 바이럴 화제성 (감성커피 사례: 최소 비용으로 성공)
- 한정판 희소성
- **적용 시기/계절**: 연중 (트렌드 맞춤형)
---
## 4. 중식당 (Chinese Restaurant)
### 4-1. 런치 세트 특가
- **업종명**: 중식당
- **이벤트 제목**: "점심 시간 짜장면+탕수육 세트 9,900원"
- **이벤트 유형**: 번들 프로모션 (시간대별)
- **경품/혜택**: 11시-14시 짜장면+소탕수육 세트 9,900원 (정상가 14,000원)
- **참여 방법**: 해당 시간대 방문 또는 배달 주문
- **예산 규모**: 저비용
- **예상 참여자 수**: 180명
- **예상 비용**: 280,000원
- **예상 ROI**: 380%
- **성공 요인**:
- 직장인 점심 수요 타겟
- 세트 메뉴로 객단가 유지
- 배달 시간대 집중 활용
- **적용 시기/계절**: 연중 (평일)
### 4-2. 첫 주문 고객 쿠폰
- **업종명**: 중식당
- **이벤트 제목**: "신규 고객 환영! 첫 주문 20% 할인"
- **이벤트 유형**: 쿠폰 (신규 고객 타겟)
- **경품/혜택**: 첫 주문 시 전 메뉴 20% 할인
- **참여 방법**: 배달앱 신규 주문 고객 자동 적용 또는 매장 방문 시 첫 방문 멘트
- **예산 규모**: 저비용
- **예상 참여자 수**: 150명
- **예상 비용**: 300,000원
- **예상 ROI**: 340%
- **성공 요인**:
- 신규 고객 확보
- 높은 재방문 전환율 (30-40%)
- 진입 장벽 낮춤
- **적용 시기/계절**: 연중 (신규 오픈 또는 신규 고객 확보 필요 시)
### 4-3. 배달앱 단골 고객 리워드
- **업종명**: 중식당
- **이벤트 제목**: "이달의 단골 고객 5회 주문 시 탕수육 무료"
- **이벤트 유형**: 스탬프/포인트
- **경품/혜택**: 월 5회 주문 달성 시 소탕수육 1접시 무료
- **참여 방법**: 배달앱 주문 횟수 자동 집계 → 5회 달성 시 쿠폰 발급
- **예산 규모**: 중비용
- **예상 참여자 수**: 200명
- **예상 비용**: 1,600,000원
- **예상 ROI**: 400%
- **성공 요인**:
- 재방문 유도 (월 5회 목표)
- 고객 충성도 강화
- 배달 주문 비중 증가
- **적용 시기/계절**: 연중
### 4-4. 가족 단위 방문 이벤트
- **업종명**: 중식당
- **이벤트 제목**: "4인 이상 가족 방문 시 음료 무료 + 짬뽕 1개 서비스"
- **이벤트 유형**: 번들 프로모션 (그룹 타겟)
- **경품/혜택**: 4인 이상 가족 방문 시 음료 4잔 + 짬뽕 1그릇 무료
- **참여 방법**: 매장 방문 시 4인 이상 테이블 예약
- **예산 규모**: 중비용
- **예상 참여자 수**: 180명 (약 45그룹)
- **예상 비용**: 1,750,000원
- **예상 ROI**: 325%
- **성공 요인**:
- 가족 외식 수요 타겟 (주말/공휴일)
- 객단가 상승 (4인 이상 주문)
- 매장 내 식사 활성화
- **적용 시기/계절**: 주말/공휴일, 특히 어린이날/가족의 달(5월)
### 4-5. 명절 특별 세트 예약 이벤트
- **업종명**: 중식당
- **이벤트 제목**: "설날 특선 코스 예약 고객 30% 할인"
- **이벤트 유형**: 창의적 이벤트 (계절 한정)
- **경품/혜택**: 설날 전후 1주일 특선 코스 예약 시 30% 할인
- **참여 방법**: 전화 또는 카카오톡 예약 (3일 전까지)
- **예산 규모**: 고비용
- **예상 참여자 수**: 280명 (약 70그룹)
- **예상 비용**: 6,000,000원
- **예상 ROI**: 250%
- **성공 요인**:
- 명절 수요 활용 (가족 외식)
- 사전 예약으로 재고 관리 용이
- 고객 확보 (명절 후 재방문 유도)
- **적용 시기/계절**: 겨울 (설날 2주 전부터)
---
## 5. 일식당 (Japanese Restaurant)
### 5-1. 평일 초밥 세트 할인
- **업종명**: 일식당
- **이벤트 제목**: "평일 런치 초밥 세트 25% 할인"
- **이벤트 유형**: 시간대별 할인
- **경품/혜택**: 월-금 11시-15시 특정 초밥 세트 25% 할인
- **참여 방법**: 해당 시간대 매장 방문
- **예산 규모**: 저비용
- **예상 참여자 수**: 160명
- **예상 비용**: 290,000원
- **예상 ROI**: 360%
- **성공 요인**:
- 비수기 시간대 활용
- 직장인 점심 타겟
- 고급 메뉴 접근성 향상
- **적용 시기/계절**: 연중 (평일)
### 5-2. 인스타그램 사케 리뷰 이벤트
- **업종명**: 일식당
- **이벤트 제목**: "사케와 안주 인스타그램 인증 시 10% 할인"
- **이벤트 유형**: SNS 바이럴
- **경품/혜택**: 인스타그램 사진 업로드 + 해시태그 시 10% 할인
- **참여 방법**: 매장 방문 → 음식 사진 촬영 → 인스타그램 업로드 + #OO일식당사케 → 직원 확인
- **예산 규모**: 저비용
- **예상 참여자 수**: 140명
- **예상 비용**: 270,000원
- **예상 ROI**: 310%
- **성공 요인**:
- 3040세대 타겟 (인스타그램 사용층)
- 바이럴 마케팅
- 프리미엄 이미지 강화
- **적용 시기/계절**: 연중
### 5-3. 스탬프 적립 오마카세 혜택
- **업종명**: 일식당
- **이벤트 제목**: "10회 방문 시 오마카세 20% 할인"
- **이벤트 유형**: 스탬프/포인트
- **경품/혜택**: 10회 방문 적립 시 오마카세 코스 20% 할인권
- **참여 방법**: 카카오톡 채널 가입 → 방문 시 스탬프 적립
- **예산 규모**: 중비용
- **예상 참여자 수**: 220명
- **예상 비용**: 1,650,000원
- **예상 ROI**: 370%
- **성공 요인**:
- 재방문 유도 (10회 목표)
- 고가 메뉴 업셀링
- 고객 충성도 구축
- **적용 시기/계절**: 연중
### 5-4. 기념일 고객 특별 서비스
- **업종명**: 일식당
- **이벤트 제목**: "생일/기념일 고객 디저트 + 사진 서비스"
- **이벤트 유형**: 재방문 유도 (고객 세그먼트)
- **경품/혜택**: 생일/기념일 방문 시 디저트 무료 + 폴라로이드 사진 증정
- **참여 방법**: 예약 시 기념일 멘트 → 당일 직원 안내
- **예산 규모**: 중비용
- **예상 참여자 수**: 180명
- **예상 비용**: 1,700,000원
- **예상 ROI**: 340%
- **성공 요인**:
- 특별한 날 경험 제공
- 고객 감동 (감성 마케팅)
- 재방문 + 입소문 효과
- **적용 시기/계절**: 연중
### 5-5. 계절 한정 오마카세 VIP 이벤트
- **업종명**: 일식당
- **이벤트 제목**: "봄 제철 오마카세 특별 코스 초대"
- **이벤트 유형**: 창의적 이벤트 (계절 한정)
- **경품/혜택**: 상위 20명 VIP 고객 봄 제철 오마카세 특별 코스 초대 (15% 할인)
- **참여 방법**: 월 2회 이상 방문 고객 대상 개별 초대장 발송
- **예산 규모**: 고비용
- **예상 참여자 수**: 20명 (VIP 대상)
- **예상 비용**: 5,500,000원
- **예상 ROI**: 260%
- **성공 요인**:
- VIP 고객 충성도 강화
- 계절성 활용 (봄 제철 식재료)
- 고가 메뉴 프로모션
- **적용 시기/계절**: 봄 (3-5월)
---
## 6. 양식당 (Italian Restaurant)
### 6-1. 런치 파스타 세트 할인
- **업종명**: 양식당
- **이벤트 제목**: "평일 점심 파스타+샐러드+음료 세트 12,900원"
- **이벤트 유형**: 번들 프로모션 (시간대별)
- **경품/혜택**: 월-금 11시-15시 파스타 세트 12,900원 (정상가 18,000원)
- **참여 방법**: 해당 시간대 매장 방문
- **예산 규모**: 저비용
- **예상 참여자 수**: 190명
- **예상 비용**: 280,000원
- **예상 ROI**: 400%
- **성공 요인**:
- 직장인 점심 타겟
- 세트 구성으로 가치 인식 향상
- 비수기 시간대 활성화
- **적용 시기/계절**: 연중 (평일)
### 6-2. 와인 페어링 SNS 이벤트
- **업종명**: 양식당
- **이벤트 제목**: "음식과 와인 페어링 인스타그램 인증 이벤트"
- **이벤트 유형**: SNS 바이럴
- **경품/혜택**: 인스타그램 페어링 사진 업로드 시 와인 1잔 무료
- **참여 방법**: 매장 방문 → 음식+와인 사진 촬영 → 인스타그램 업로드 + #OO양식당와인페어링
- **예산 규모**: 저비용
- **예상 참여자 수**: 130명
- **예상 비용**: 260,000원
- **예상 ROI**: 320%
- **성공 요인**:
- 프리미엄 이미지 강화
- 3040세대 타겟
- UGC 확산
- **적용 시기/계절**: 연중
### 6-3. 디너 코스 예약 고객 디저트 서비스
- **업종명**: 양식당
- **이벤트 제목**: "디너 코스 예약 시 디저트 무료 업그레이드"
- **이벤트 유형**: 재방문 유도
- **경품/혜택**: 2일 전 예약 시 디저트 1개 무료 (티라미수/판나코타)
- **참여 방법**: 전화 또는 네이버 예약
- **예산 규모**: 중비용
- **예상 참여자 수**: 240명
- **예상 비용**: 1,600,000원
- **예상 ROI**: 365%
- **성공 요인**:
- 사전 예약 유도 (재고 관리)
- 디너 시간대 활성화
- 고객 만족도 향상
- **적용 시기/계절**: 연중 (특히 주말)
### 6-4. 커플 기념일 이벤트
- **업종명**: 양식당
- **이벤트 제목**: "커플 기념일 특별 테이블 세팅 + 샴페인 서비스"
- **이벤트 유형**: 창의적 이벤트 (고객 세그먼트)
- **경품/혜택**: 기념일 예약 커플 대상 특별 테이블 세팅 + 샴페인 1병 + 폴라로이드
- **참여 방법**: 예약 시 기념일 멘트 → 당일 특별 준비
- **예산 규모**: 중비용
- **예상 참여자 수**: 200명
- **예상 비용**: 1,800,000원
- **예상 ROI**: 350%
- **성공 요인**:
- 감성 마케팅 (특별한 경험)
- 커플 타겟팅 (밸런타인/화이트데이)
- 입소문 효과 (SNS 공유)
- **적용 시기/계절**: 연중 (특히 2월, 3월, 크리스마스)
### 6-5. 셰프 특선 시식회
- **업종명**: 양식당
- **이벤트 제목**: "월 1회 셰프 특선 메뉴 시식회 초대"
- **이벤트 유형**: 창의적 이벤트 (VIP)
- **경품/혜택**: 월 1회 VIP 고객 30명 초대, 신메뉴 시식 + 20% 할인권
- **참여 방법**: 월 2회 이상 방문 고객 대상 카카오톡 초대
- **예산 규모**: 고비용
- **예상 참여자 수**: 30명
- **예상 비용**: 5,300,000원
- **예상 ROI**: 270%
- **성공 요인**:
- VIP 고객 충성도 강화
- 신메뉴 피드백 수집
- 입소문 마케팅
- **적용 시기/계절**: 연중 (월 1회)
---
## 7. 베이커리 (Bakery)
### 7-1. 아침 빵 특가
- **업종명**: 베이커리
- **이벤트 제목**: "오전 7시-10시 갓 구운 빵 30% 할인"
- **이벤트 유형**: 시간대별 할인
- **경품/혜택**: 오전 특정 시간대 빵 30% 할인
- **참여 방법**: 해당 시간대 매장 방문
- **예산 규모**: 저비용
- **예상 참여자 수**: 210명
- **예상 비용**: 270,000원
- **예상 ROI**: 450%
- **성공 요인**:
- 아침 시간대 활성화
- 신선함 강조 (갓 구운 빵)
- 직장인 출근길 타겟
- **적용 시기/계절**: 연중 (평일 중심)
### 7-2. 인스타그램 빵 인증 이벤트
- **업종명**: 베이커리
- **이벤트 제목**: "예쁜 빵 사진 인스타그램 올리고 커피 무료"
- **이벤트 유형**: SNS 바이럴
- **경품/혜택**: 인스타그램 빵 사진 업로드 + 해시태그 시 커피 1잔 무료
- **참여 방법**: 매장 방문 → 빵 사진 촬영 → 인스타그램 업로드 + #OO베이커리 → 직원 확인
- **예산 규모**: 저비용
- **예상 참여자 수**: 170명
- **예상 비용**: 250,000원
- **예상 ROI**: 380%
- **성공 요인**:
- 바이럴 마케팅 (인스타그래머블)
- 2030 여성 타겟
- UGC 확산
- **적용 시기/계절**: 연중
### 7-3. 생일 케이크 예약 고객 할인
- **업종명**: 베이커리
- **이벤트 제목**: "생일 케이크 3일 전 예약 시 15% 할인"
- **이벤트 유형**: 쿠폰 (예약 유도)
- **경품/혜택**: 3일 전 예약 시 케이크 15% 할인 + 초 무료
- **참여 방법**: 전화 또는 카카오톡 예약
- **예산 규모**: 중비용
- **예상 참여자 수**: 260명
- **예상 비용**: 1,700,000원
- **예상 ROI**: 370%
- **성공 요인**:
- 사전 예약 유도 (재고 관리)
- 생일 수요 확보
- 고객 데이터 수집
- **적용 시기/계절**: 연중
### 7-4. 월간 신제품 출시 이벤트
- **업종명**: 베이커리
- **이벤트 제목**: "이달의 신상 빵 출시! SNS 공유하고 20% 할인"
- **이벤트 유형**: SNS 바이럴 + 할인
- **경품/혜택**: 신제품 구매 + SNS 공유 시 20% 할인
- **참여 방법**: 신제품 구매 → SNS 공유 → 직원 확인
- **예산 규모**: 중비용
- **예상 참여자 수**: 230명
- **예상 비용**: 1,650,000원
- **예상 ROI**: 350%
- **성공 요인**:
- 신제품 빠른 인지도 확보
- 바이럴 마케팅
- 재방문 유도
- **적용 시기/계절**: 연중 (월 1회)
### 7-5. 인플루언서 협업 팝업 스토어
- **업종명**: 베이커리
- **이벤트 제목**: "인기 베이킹 유튜버와 함께하는 특별 팝업"
- **이벤트 유형**: 창의적 이벤트 (인플루언서 협업)
- **경품/혜택**: 유튜버 콜라보 빵 한정 판매 + 사인회 + 베이킹 클래스
- **참여 방법**: 팝업 기간 매장 방문
- **예산 규모**: 고비용
- **예상 참여자 수**: 350명
- **예상 비용**: 5,800,000원 (인플루언서 비용 500만 + 재료비 + 마케팅)
- **예상 ROI**: 280%
- **성공 요인**:
- 대규모 노출 (유튜버 구독자)
- 한정판 희소성
- 체험 이벤트 (베이킹 클래스)
- **적용 시기/계절**: 특별 시즌 (크리스마스, 밸런타인 등)
---
## 8. 패스트푸드 (Fast Food / Hamburger)
### 8-1. 점심 시간대 세트 할인
- **업종명**: 패스트푸드
- **이벤트 제목**: "런치 타임 버거 세트 1,000원 할인"
- **이벤트 유형**: 시간대별 할인
- **경품/혜택**: 11시-14시 버거 세트 1,000원 할인
- **참여 방법**: 해당 시간대 매장 또는 배달 주문
- **예산 규모**: 저비용
- **예상 참여자 수**: 220명
- **예상 비용**: 280,000원
- **예상 ROI**: 410%
- **성공 요인**:
- 점심 시간대 집중 공략
- 직장인 빠른 식사 니즈
- 객단가 유지 (세트 판매)
- **적용 시기/계절**: 연중 (평일)
### 8-2. 배달앱 첫 주문 할인
- **업종명**: 패스트푸드
- **이벤트 제목**: "신규 고객 첫 주문 3,000원 할인"
- **이벤트 유형**: 쿠폰 (신규 고객)
- **경품/혜택**: 첫 주문 시 3,000원 할인
- **참여 방법**: 배달앱 신규 고객 자동 적용
- **예산 규모**: 저비용
- **예상 참여자 수**: 180명
- **예상 비용**: 270,000원
- **예상 ROI**: 360%
- **성공 요인**:
- 신규 고객 확보
- 재방문 전환율 30-40%
- 진입 장벽 낮춤
- **적용 시기/계절**: 연중
### 8-3. SNS 인증 이벤트
- **업종명**: 패스트푸드
- **이벤트 제목**: "버거 사진 인스타그램 올리고 감자튀김 무료"
- **이벤트 유형**: SNS 바이럴
- **경품/혜택**: 인스타그램 버거 사진 업로드 시 감자튀김 1개 무료
- **참여 방법**: 매장 방문 → 버거 사진 촬영 → 인스타그램 업로드 + #OO버거 → 직원 확인
- **예산 규모**: 중비용
- **예상 참여자 수**: 250명
- **예상 비용**: 1,600,000원
- **예상 ROI**: 340%
- **성공 요인**:
- 바이럴 마케팅
- 1020세대 타겟
- 즉시 보상
- **적용 시기/계절**: 연중
### 8-4. 스탬프 적립 무료 버거
- **업종명**: 패스트푸드
- **이벤트 제목**: "버거 8개 먹으면 1개 무료"
- **이벤트 유형**: 스탬프/포인트
- **경품/혜택**: 8회 구매 적립 시 시그니처 버거 1개 무료
- **참여 방법**: 앱 가입 → 구매 시 자동 적립
- **예산 규모**: 중비용
- **예상 참여자 수**: 280명
- **예상 비용**: 1,750,000원
- **예상 ROI**: 390%
- **성공 요인**:
- 재방문 유도
- 고객 충성도 구축
- 앱 사용 활성화
- **적용 시기/계절**: 연중
### 8-5. 월드컵 시즌 특별 프로모션
- **업종명**: 패스트푸드
- **이벤트 제목**: "월드컵 응원! 한국 골 넣으면 버거 반값"
- **이벤트 유형**: 창의적 이벤트 (이슈 마케팅)
- **경품/혜택**: 한국 축구팀 골 넣은 날 전 버거 50% 할인
- **참여 방법**: 경기 당일 매장 또는 배달 주문
- **예산 규모**: 고비용
- **예상 참여자 수**: 400명
- **예상 비용**: 5,500,000원
- **예상 ROI**: 290%
- **성공 요인**:
- 이슈 마케팅 (월드컵)
- 화제성 확보
- 대규모 참여 유도
- **적용 시기/계절**: 월드컵/올림픽 등 스포츠 이벤트 기간
---
## 9. 피자 전문점 (Pizza Restaurant)
### 9-1. 평일 피자 1+1
- **업종명**: 피자 전문점
- **이벤트 제목**: "평일 피자 1+1 (포장 주문)"
- **이벤트 유형**: 번들 프로모션 (1+1)
- **경품/혜택**: 월-금 특정 피자 1개 주문 시 1개 무료 (포장만 해당)
- **참여 방법**: 포장 주문 시 자동 적용
- **예산 규모**: 저비용
- **예상 참여자 수**: 200명
- **예상 비용**: 300,000원
- **예상 ROI**: 440%
- **성공 요인**:
- 포장 주문 활성화 (배달비 절감)
- 평일 비수기 활용
- 높은 가치 인식 (1+1)
- **적용 시기/계절**: 연중 (평일)
### 9-2. 배달앱 리뷰 이벤트
- **업종명**: 피자 전문점
- **이벤트 제목**: "리뷰 작성하고 다음 주문 콜라 2L 무료"
- **이벤트 유형**: 리뷰 + 쿠폰
- **경품/혜택**: 배달앱 리뷰 작성 시 다음 주문 콜라 2L 무료 쿠폰
- **참여 방법**: 주문 → 리뷰 작성 → 자동 쿠폰 발급
- **예산 규모**: 저비용
- **예상 참여자 수**: 170명
- **예상 비용**: 260,000원
- **예상 ROI**: 370%
- **성공 요인**:
- 재방문 유도
- 배달앱 순위 상승
- 신규 고객 유입
- **적용 시기/계절**: 연중
### 9-3. 가족 단위 할인
- **업종명**: 피자 전문점
- **이벤트 제목**: "가족 피자 파티! 3판 이상 주문 시 20% 할인"
- **이벤트 유형**: 번들 프로모션 (대량 주문)
- **경품/혜택**: 피자 3판 이상 주문 시 20% 할인 + 콜라 2L 무료
- **참여 방법**: 매장 또는 배달 주문 시 자동 적용
- **예산 규모**: 중비용
- **예상 참여자 수**: 220명
- **예상 비용**: 1,700,000원
- **예상 ROI**: 360%
- **성공 요인**:
- 가족/파티 수요 타겟
- 객단가 상승
- 주말/공휴일 활성화
- **적용 시기/계절**: 주말, 특히 어린이날/크리스마스
### 9-4. 월간 신메뉴 출시 이벤트
- **업종명**: 피자 전문점
- **이벤트 제목**: "이달의 신메뉴 피자 출시! SNS 공유하고 15% 할인"
- **이벤트 유형**: SNS 바이럴 + 할인
- **경품/혜택**: 신메뉴 주문 + SNS 공유 시 15% 할인
- **참여 방법**: 신메뉴 주문 → SNS 공유 → 직원 확인 또는 자동 적용
- **예산 규모**: 중비용
- **예상 참여자 수**: 240명
- **예상 비용**: 1,650,000원
- **예상 ROI**: 350%
- **성공 요인**:
- 신메뉴 빠른 인지도
- 바이럴 마케팅
- 재방문 유도
- **적용 시기/계절**: 연중 (월 1회)
### 9-5. 스포츠 시즌 특별 프로모션
- **업종명**: 피자 전문점
- **이벤트 제목**: "야구 시즌 응원! 홈런 1개당 피자 1,000원 할인"
- **이벤트 유형**: 창의적 이벤트 (이슈 마케팅)
- **경품/혜택**: 해당 날 홈런 개수만큼 피자 1,000원씩 할인 (최대 5,000원)
- **참여 방법**: 경기 당일 주문 시 자동 적용
- **예산 규모**: 고비용
- **예상 참여자 수**: 380명
- **예상 비용**: 5,700,000원
- **예상 ROI**: 270%
- **성공 요인**:
- 이슈 마케팅 (스포츠)
- 화제성 확보
- 반복 참여 가능 (시즌 내내)
- **적용 시기/계절**: 봄-가을 (야구 시즌 3-10월)
---
## 10. 바베큐/고깃집 (Barbecue / Steak House)
### 10-1. 평일 점심 특가
- **업종명**: 바베큐/고깃집
- **이벤트 제목**: "평일 런치 삼겹살 세트 1인 12,900원"
- **이벤트 유형**: 시간대별 할인
- **경품/혜택**: 월-금 11시-15시 삼겹살 세트 1인 12,900원
- **참여 방법**: 해당 시간대 매장 방문
- **예산 규모**: 저비용
- **예상 참여자 수**: 180명
- **예상 비용**: 290,000원
- **예상 ROI**: 380%
- **성공 요인**:
- 비수기 시간대 활용
- 직장인 점심 타겟
- 세트 구성으로 객단가 유지
- **적용 시기/계절**: 연중 (평일)
### 10-2. 소셜 미디어 고기 인증 이벤트
- **업종명**: 바베큐/고깃집
- **이벤트 제목**: "고기 굽는 영상 릴스 올리고 소주 2병 서비스"
- **이벤트 유형**: SNS 바이럴
- **경품/혜택**: 인스타그램 릴스 업로드 시 소주 2병 무료
- **참여 방법**: 매장 방문 → 고기 굽는 영상 촬영 → 릴스 업로드 + #OO고깃집 → 직원 확인
- **예산 규모**: 저비용
- **예상 참여자 수**: 160명
- **예상 비용**: 270,000원
- **예상 ROI**: 340%
- **성공 요인**:
- 바이럴 마케팅 (영상 콘텐츠)
- 2030세대 타겟
- 즉시 보상
- **적용 시기/계절**: 연중
### 10-3. 단체 예약 할인
- **업종명**: 바베큐/고깃집
- **이벤트 제목**: "6인 이상 단체 예약 15% 할인 + 된장찌개 서비스"
- **이벤트 유형**: 번들 프로모션 (그룹 타겟)
- **경품/혜택**: 6인 이상 예약 시 15% 할인 + 된장찌개 무료
- **참여 방법**: 전화 또는 네이버 예약 (1일 전까지)
- **예산 규모**: 중비용
- **예상 참여자 수**: 230명 (약 40그룹)
- **예상 비용**: 1,750,000원
- **예상 ROI**: 370%
- **성공 요인**:
- 단체 고객 확보 (회식/모임)
- 객단가 상승
- 사전 예약으로 준비 용이
- **적용 시기/계절**: 연중 (특히 연말 회식 시즌)
### 10-4. 생일 고객 특별 서비스
- **업종명**: 바베큐/고깃집
- **이벤트 제목**: "생일 고객님께 등심 200g + 케이크 서비스"
- **이벤트 유형**: 재방문 유도 (고객 세그먼트)
- **경품/혜택**: 생일 당월 방문 시 등심 200g + 미니 케이크 무료
- **참여 방법**: 예약 또는 방문 시 생일 멘트 → 신분증 확인
- **예산 규모**: 중비용
- **예상 참여자 수**: 200명
- **예상 비용**: 1,800,000원
- **예상 ROI**: 355%
- **성공 요인**:
- 개인화 마케팅
- 특별한 날 경험
- 재방문 + 입소문
- **적용 시기/계절**: 연중
### 10-5. 프리미엄 한우 시식회
- **업종명**: 바베큐/고깃집
- **이벤트 제목**: "VIP 고객 초대 프리미엄 한우 시식회"
- **이벤트 유형**: 창의적 이벤트 (VIP)
- **경품/혜택**: 상위 30명 VIP 고객 한우 시식회 초대 + 25% 할인권
- **참여 방법**: 월 2회 이상 방문 고객 대상 개별 초대
- **예산 규모**: 고비용
- **예상 참여자 수**: 30명
- **예상 비용**: 6,200,000원
- **예상 ROI**: 250%
- **성공 요인**:
- VIP 고객 충성도 강화
- 프리미엄 메뉴 프로모션
- 입소문 마케팅
- **적용 시기/계절**: 특별 시즌 (설날/추석 전)
---
## 데이터 활용 가이드
### AI 학습 데이터 구성
본 데이터는 다음과 같이 AI 이벤트 추천 시스템에 활용됩니다:
1. **벡터 임베딩 생성**
- 각 이벤트 데이터를 텍스트로 변환
- OpenAI Embeddings API를 통해 벡터화
- Pinecone에 저장하여 유사도 검색 지원
2. **유사 이벤트 검색**
- 사용자의 매장 정보(업종, 지역, 예산)를 쿼리로 사용
- 코사인 유사도 기반 상위 5개 유사 이벤트 추출
- Claude API 프롬프트에 컨텍스트로 제공
3. **추천 정확도 향상**
- 실제 성공 사례 기반 데이터로 신뢰도 향상
- ROI, 참여자 수 등 구체적 수치로 현실적 예측 가능
- 계절성, 업종 특성 반영으로 맞춤형 추천
### 데이터 확장 방안
초기 50개 이벤트 데이터를 기반으로 다음과 같이 확장 가능:
1. **자사 데이터 축적**
- 사용자가 생성한 이벤트 자동 수집
- 실제 성과 데이터 업데이트
- 피드백 반영한 정확도 개선
2. **외부 데이터 수집**
- SNS 크롤링 (네이버 블로그, 인스타그램)
- 공공데이터 API 활용
- 배달 플랫폼 이벤트 분석
3. **업종 확장**
- 추가 외식업 카테고리 (태국, 베트남, 디저트 전문점 등)
- 비외식업 소상공인 (소매, 서비스업)
- 프랜차이즈 vs 개인 사업자 구분
---
## 참고 문헌
- 자담치킨 매출상생 프로젝트 사례 (2023)
- 크치치킨 브랜드 전환 사례
- 홈플러스 당당치킨 710만팩 판매 사례
- 아뜨베 베이커리 6년 성장 사례
- 김현숙, 심성욱, 김운한 (2011). 한국광고홍보학보. 체험 마케팅 연구
- 정민서 외 (2017). 빅데이터학회. 프로모션 타이밍 연구
- 엄해정, 진현정 (2024). 호텔경영학연구. 리뷰 마케팅 연구
- 한국외식산업경영연구원 (2024-2025). 외식 트렌드 보고서
---
**문서 끝**

View File

@ -0,0 +1,955 @@
# 지역별 트렌드 데이터
**작성일**: 2025-10-21
**버전**: 1.0
**목적**: AI 이벤트 추천 시스템을 위한 시군구별 상권 트렌드 및 이벤트 성공 패턴 데이터
---
## 데이터 구조 설명
각 시군구별 트렌드 데이터는 다음 정보를 포함합니다:
- **지역 특성**: 상권 유형, 주요 고객층, 유동인구 특징
- **업종별 트렌드**: 음식점, 소매점, 서비스업 등 업종별 성공 패턴
- **계절별 소비 패턴**: 시즌별 소비 특성 및 이벤트 적기
- **성공 이벤트 사례**: 실제 성공한 이벤트 유형 및 ROI
- **추천 전략**: AI가 활용할 이벤트 설계 인사이트
---
## 서울특별시
### 종로구
**지역 특성**:
- 상권 유형: 관광지 + 전통시장 + 오피스
- 주요 고객층: 관광객(40%), 직장인(35%), 지역주민(25%)
- 유동인구: 평일 32만명, 주말 45만명
- 특징: 인사동, 광화문 오피스, 북촌 한옥마을
**업종별 트렌드**:
- 음식점(한식): 전통 한정식, 궁중요리 체험형 이벤트 효과적
- 평균 객단가: 25,000원
- 재방문율: 35%
- 외국인 비율: 45%
- 카페: 전통 찻집 + 현대식 카페 공존
- SNS 바이럴 중요도: 상
- 인스타그래머블 요소 필수
**계절별 소비 패턴**:
- 봄(3-5월): 외국인 관광객 급증 (+40%), 한복 체험 연계 이벤트
- 여름(6-8월): 냉면/빙수 이벤트, 야간 관광 타겟
- 가을(9-11월): 단풍 시즌 패키지 이벤트 (+35% 매출)
- 겨울(12-2월): 전통주 이벤트, 따뜻한 국물 요리
**성공 이벤트 사례**:
```json
{
"event_type": "문화 체험 연계",
"title": "한복 입고 오시면 한정식 20% 할인",
"업종": "한식당",
"기간": "2주",
"예산": "500,000원",
"참여자": "280명",
"매출_증가": "450%",
"ROI": "520%"
}
```
**추천 전략**:
- 외국인 타겟: 영어/일어/중국어 메뉴 + 문화 체험 연계
- 직장인 타겟: 점심 특가(11:30-13:30), 주중 할인
- 관광객 타겟: SNS 인증 이벤트, 사진 촬영 스팟 제공
---
### 중구
**지역 특성**:
- 상권 유형: 금융·상업 중심지 + 명동 관광특구
- 주요 고객층: 직장인(50%), 관광객(35%), 쇼핑객(15%)
- 유동인구: 평일 50만명, 주말 65만명
- 특징: 명동 패션거리, 을지로 금융가, 남대문시장
**업종별 트렌드**:
- 음식점(퓨전/외식): 빠른 회전율, 트렌디한 메뉴
- 평균 객단가: 15,000원
- 회전율: 1.5회/테이블
- 배달 비중: 25%
- 화장품/뷰티: K-뷰티 체험형 이벤트
- 외국인 매출 비중: 60%
**계절별 소비 패턴**:
- 봄: 중국인 단체 관광객 (+50%), 세트 메뉴 인기
- 여름: 냉방 매장 선호, 시원한 음료 이벤트
- 가을: 쇼핑 시즌 연계 이벤트 효과적
- 겨울: 크리스마스 특수, 커플 패키지
**성공 이벤트 사례**:
```json
{
"event_type": "시간대별 할인",
"title": "런치타임 세트 메뉴 30% 할인",
"업종": "퓨전 레스토랑",
"기간": "1개월",
"예산": "1,200,000원",
"참여자": "1,850명",
"매출_증가": "320%",
"ROI": "480%"
}
```
**추천 전략**:
- 직장인: 빠른 서비스 + 가성비 세트메뉴
- 관광객: 포토존 + SNS 이벤트 + 다국어 안내
- 쇼핑객: 영수증 합산 할인, 인근 매장 제휴
---
### 강남구
**지역 특성**:
- 상권 유형: 프리미엄 상업지구 + 오피스
- 주요 고객층: 직장인(45%), 고소득층(30%), MZ세대(25%)
- 유동인구: 평일 80만명, 주말 55만명
- 특징: 강남역, 신논현역, 청담동 명품거리
**업종별 트렌드**:
- 음식점(고급/프리미엄): 품질 중시, 프라이버시, 차별화된 경험
- 평균 객단가: 45,000원
- 재방문율: 55%
- 예약률: 70%
- 카페(프리미엄): 브랜드 가치, 인테리어, 독특한 메뉴
- 객단가: 8,000원
- SNS 중요도: 최상
**계절별 소비 패턴**:
- 봄: 야외 테라스 이벤트, 브런치 세트
- 여름: 프리미엄 디저트, 시그니처 음료
- 가을: 와인 페어링 이벤트, 시즌 한정 메뉴
- 겨울: VIP 초대 이벤트, 연말 모임 패키지
**성공 이벤트 사례**:
```json
{
"event_type": "VIP 멤버십",
"title": "월간 와인 페어링 클래스 + 10% 할인",
"업종": "파인다이닝",
"기간": "3개월",
"예산": "3,500,000원",
"참여자": "120명",
"매출_증가": "280%",
"ROI": "420%",
"LTV_증가": "+65%"
}
```
**추천 전략**:
- 고소득층: VIP 프로그램, 독점 경험, 프리미엄 혜택
- 직장인: 비즈니스 런치 세트, 회식 패키지
- MZ세대: 인스타그래머블 메뉴, 팝업 이벤트
---
### 송파구
**지역 특성**:
- 상권 유형: 대규모 상업시설 + 주거지역
- 주요 고객층: 가족(40%), 직장인(30%), 젊은층(30%)
- 유동인구: 평일 35만명, 주말 60만명
- 특징: 롯데월드, 잠실역 상권, 올림픽공원
**업종별 트렌드**:
- 음식점(패밀리): 넓은 공간, 키즈 메뉴, 주차 편의
- 평균 객단가: 35,000원 (가족 단위)
- 주말 매출 비중: 65%
- 어린이 동반: 70%
- 베이커리/디저트: 테이크아웃 중심, 선물용 수요
- 평균 객단가: 25,000원
**계절별 소비 패턴**:
- 봄: 야외 활동 증가, 피크닉 패키지
- 여름: 롯데월드 연계, 키즈 이벤트
- 가을: 운동회 시즌, 단체 주문 증가
- 겨울: 실내 활동 증가, 생일파티 패키지
**성공 이벤트 사례**:
```json
{
"event_type": "가족 패키지",
"title": "롯데월드 티켓 인증 시 키즈 메뉴 무료",
"업종": "패밀리 레스토랑",
"기간": "2개월",
"예산": "2,000,000원",
"참여자": "680가족",
"매출_증가": "380%",
"ROI": "550%"
}
```
**추천 전략**:
- 가족: 키즈존, 무료 음료 리필, 생일 이벤트
- 직장인: 테이크아웃 할인, 배달 프로모션
- 관광객: 롯데월드 연계 할인, 기념품 증정
---
## 부산광역시
### 해운대구
**지역 특성**:
- 상권 유형: 관광 특화 지역 + 해양 리조트
- 주요 고객층: 관광객(60%), 지역주민(25%), 외국인(15%)
- 유동인구: 평일 25만명, 주말/성수기 80만명
- 특징: 해운대 해수욕장, 동백섬, 마린시티
**업종별 트렌드**:
- 음식점(해산물/횟집): 신선도 강조, 계절 메뉴
- 평균 객단가: 40,000원
- 외지인 비율: 75%
- 예약률: 60%
- 카페(오션뷰): 뷰 맛집, 포토존
- 객단가: 7,000원
- 체류시간: 평균 90분
**계절별 소비 패턴**:
- 봄: 벚꽃 시즌, 야외 테라스 (+30%)
- 여름: 성수기 피크, 해산물 이벤트 (+150%)
- 가을: 영화제 시즌, 고급 다이닝 수요
- 겨울: 비수기 할인, 지역주민 타겟
**성공 이벤트 사례**:
```json
{
"event_type": "계절 한정",
"title": "여름 특선 해산물 세트 + 오션뷰 석 우선 배정",
"업종": "해산물 레스토랑",
"기간": "3개월 (6-8월)",
"예산": "4,500,000원",
"참여자": "1,250명",
"매출_증가": "520%",
"ROI": "680%"
}
```
**추천 전략**:
- 관광객: SNS 인증 이벤트, 포토존, 기념품
- 외국인: 영어/중국어/일어 메뉴, 문화 체험
- 지역주민: 비수기 할인, 로컬 멤버십
---
### 부산진구
**지역 특성**:
- 상권 유형: 상업 중심지 + 교통 허브
- 주요 고객층: 직장인(45%), 대학생(30%), 쇼핑객(25%)
- 유동인구: 평일 45만명, 주말 50만명
- 특징: 서면 상권, 부산역, 대학가
**업종별 트렌드**:
- 음식점(한식/분식): 가성비 중시, 빠른 회전
- 평균 객단가: 9,000원
- 회전율: 2회/테이블
- 배달 비중: 40%
- 카페(스터디): 장시간 체류, 조용한 분위기
- 객단가: 5,000원
- 체류시간: 3-4시간
**성공 이벤트 사례**:
```json
{
"event_type": "학생 할인",
"title": "학생증 제시 시 20% 할인 + 음료 리필 무료",
"업종": "카페",
"기간": "학기 중 4개월",
"예산": "800,000원",
"참여자": "2,100명",
"매출_증가": "280%",
"ROI": "450%"
}
```
---
## 대구광역시
### 중구
**지역 특성**:
- 상권 유형: 구도심 + 전통시장 + 청년몰
- 주요 고객층: 지역주민(50%), 청년(30%), 관광객(20%)
- 유동인구: 평일 30만명, 주말 38만명
- 특징: 동성로, 서문시장, 약령시장
**업종별 트렌드**:
- 음식점(향토음식): 전통 대구 요리, 지역 특산물
- 평균 객단가: 12,000원
- 재방문율: 60% (지역민)
- 관광객 비율: 25%
**성공 이벤트 사례**:
```json
{
"event_type": "로컬 협업",
"title": "서문시장 영수증 합산 이벤트",
"업종": "한식당",
"기간": "1개월",
"예산": "600,000원",
"참여자": "520명",
"매출_증가": "340%",
"ROI": "490%"
}
```
---
## 인천광역시
### 중구
**지역 특성**:
- 상권 유형: 국제공항 + 차이나타운 + 월미도
- 주요 고객층: 관광객(50%), 외국인(25%), 지역주민(25%)
- 유동인구: 평일 40만명, 주말 65만명
- 특징: 인천공항, 차이나타운, 개항장 문화지구
**업종별 트렌드**:
- 음식점(중식): 짜장면/짬뽕 특화, SNS 핫플
- 평균 객단가: 15,000원
- 외지인 비율: 70%
- 포장/배달: 35%
**성공 이벤트 사례**:
```json
{
"event_type": "SNS 바이럴",
"title": "인생샷 스팟 인증 시 짜장면 무료 곱빼기",
"업종": "중식당",
"기간": "2주",
"예산": "450,000원",
"참여자": "380명",
"매출_증가": "410%",
"ROI": "580%",
"SNS_도달": "12만명"
}
```
---
## 광주광역시
### 동구
**지역 특성**:
- 상권 유형: 예술문화 중심지 + 전통시장
- 주요 고객층: 문화애호가(35%), 지역주민(40%), 대학생(25%)
- 유동인구: 평일 22만명, 주말 30만명
- 특징: 아시아문화전당, 충장로, 동명동 카페거리
**업종별 트렌드**:
- 카페(감성): 독립서점 연계, 전시 공간
- 평균 객단가: 6,500원
- 체류시간: 120분
- 문화 이벤트 연계 효과: +45%
**성공 이벤트 사례**:
```json
{
"event_type": "문화 협업",
"title": "전시 티켓 인증 시 음료 1+1",
"업종": "카페",
"기간": "전시 기간 2개월",
"예산": "700,000원",
"참여자": "650명",
"매출_증가": "320%",
"ROI": "470%"
}
```
---
## 대전광역시
### 유성구
**지역 특성**:
- 상권 유형: 대학가 + 연구단지 + 온천 관광
- 주요 고객층: 대학생(40%), 연구원(30%), 관광객(30%)
- 유동인구: 평일 35만명, 주말 42만명
- 특징: KAIST, 충남대, 대덕연구단지, 유성온천
**업종별 트렌드**:
- 음식점(퓨전/창업): 실험적 메뉴, 트렌디한 콘셉트
- 평균 객단가: 13,000원
- 학생 비율: 55%
- SNS 마케팅 효과: 상
**성공 이벤트 사례**:
```json
{
"event_type": "학생 타겟",
"title": "시험기간 특별 이벤트: 야식 세트 30% 할인",
"업종": "분식/야식",
"기간": "중간·기말고사 각 2주",
"예산": "500,000원",
"참여자": "1,200명",
"매출_증가": "450%",
"ROI": "620%"
}
```
---
## 울산광역시
### 남구
**지역 특성**:
- 상권 유형: 공업도시 + 주거지역
- 주요 고객층: 직장인(60%), 가족(30%), 대학생(10%)
- 유동인구: 평일 28만명, 주말 25만명
- 특징: 현대자동차, 삼산동 상권
**업종별 트렌드**:
- 음식점(고깃집/회식): 대용량, 단체 할인
- 평균 객단가: 30,000원 (1인 기준)
- 단체 예약 비율: 70%
- 평일 저녁 집중: 65%
**성공 이벤트 사례**:
```json
{
"event_type": "단체 할인",
"title": "4인 이상 예약 시 삼겹살 500g 추가 증정",
"업종": "고깃집",
"기간": "1개월",
"예산": "1,500,000원",
"참여자": "280팀 (1,150명)",
"매출_증가": "380%",
"ROI": "520%"
}
```
---
## 경기도
### 수원시
**지역 특성**:
- 상권 유형: 역사관광 + 주거·상업 복합
- 주요 고객층: 지역주민(45%), 관광객(30%), 직장인(25%)
- 유동인구: 평일 60만명, 주말 75만명
- 특징: 수원화성, 행궁동 벽화마을, 수원역 상권
**업종별 트렌드**:
- 음식점(한식/수원갈비): 전통 + 현대화
- 평균 객단가: 35,000원
- 관광객 비율: 40%
- 재방문율: 50%
**성공 이벤트 사례**:
```json
{
"event_type": "관광 연계",
"title": "수원화성 입장권 인증 시 20% 할인 + 전통차 서비스",
"업종": "한정식",
"기간": "2개월",
"예산": "1,200,000원",
"참여자": "580명",
"매출_증가": "360%",
"ROI": "510%"
}
```
---
### 성남시
**지역 특성**:
- 상권 유형: IT단지 + 대규모 주거단지
- 주요 고객층: 직장인(50%), 가족(30%), 청년(20%)
- 유동인구: 평일 55만명, 주말 45만명
- 특징: 판교 테크노밸리, 분당 신도시
**업종별 트렌드**:
- 카페(프리미엄): 업무·미팅 공간, 조용한 환경
- 평균 객단가: 7,500원
- IT 직장인 비율: 65%
- 평일 점심 집중: 40%
**성공 이벤트 사례**:
```json
{
"event_type": "직장인 타겟",
"title": "오전 10시 이전 아메리카노 50% 할인",
"업종": "카페",
"기간": "평일 1개월",
"예산": "350,000원",
"참여자": "1,850명",
"매출_증가": "280%",
"ROI": "450%"
}
```
---
### 고양시
**지역 특성**:
- 상권 유형: 대규모 상업시설 + 신도시
- 주요 고객층: 가족(45%), 쇼핑객(30%), 직장인(25%)
- 유동인구: 평일 50만명, 주말 80만명
- 특징: 킨텍스, 일산 호수공원, 라페스타
**업종별 트렌드**:
- 음식점(패밀리/뷔페): 대형 매장, 주차 편의
- 평균 객단가: 25,000원
- 주말 매출 비중: 70%
- 가족 단위: 80%
**성공 이벤트 사례**:
```json
{
"event_type": "주말 가족",
"title": "주말 브런치 뷔페 키즈 50% 할인",
"업종": "뷔페 레스토랑",
"기간": "2개월 (주말만)",
"예산": "2,500,000원",
"참여자": "920가족",
"매출_증가": "420%",
"ROI": "580%"
}
```
---
## 강원특별자치도
### 춘천시
**지역 특성**:
- 상권 유형: 관광도시 + 대학가
- 주요 고객층: 관광객(45%), 대학생(30%), 지역주민(25%)
- 유동인구: 평일 18만명, 주말 35만명
- 특징: 남이섬, 소양강댐, 명동 닭갈비거리
**업종별 트렌드**:
- 음식점(닭갈비): 지역 특화 메뉴
- 평균 객단가: 15,000원
- 외지인 비율: 70%
- SNS 인증 효과: 상
**성공 이벤트 사례**:
```json
{
"event_type": "지역 특산",
"title": "춘천 3대 명소 인증 시 닭갈비 1인분 추가",
"업종": "닭갈비",
"기간": "관광 성수기 3개월",
"예산": "1,800,000원",
"참여자": "1,450명",
"매출_증가": "480%",
"ROI": "640%"
}
```
---
### 강릉시
**지역 특성**:
- 상권 유형: 해양관광 + 커피 도시
- 주요 고객층: 관광객(70%), 지역주민(20%), 외국인(10%)
- 유동인구: 평일 15만명, 주말/성수기 60만명
- 특징: 경포대, 안목해변 커피거리, 오죽헌
**업종별 트렌드**:
- 카페(로스터리): 직접 로스팅, 오션뷰
- 평균 객단가: 8,000원
- 관광객 비율: 80%
- 원두 판매 비중: 25%
**성공 이벤트 사례**:
```json
{
"event_type": "체험형",
"title": "커피 로스팅 클래스 참여 시 원두 20% 할인",
"업종": "로스터리 카페",
"기간": "3개월",
"예산": "900,000원",
"참여자": "180명",
"매출_증가": "520%",
"ROI": "720%",
"원두_판매": "+350%"
}
```
---
## 충청북도
### 청주시
**지역 특성**:
- 상권 유형: 지역 중심도시 + 교육도시
- 주요 고객층: 지역주민(50%), 대학생(25%), 직장인(25%)
- 유동인구: 평일 35만명, 주말 32만명
- 특징: 청주대, 충북대, 성안길 상권
**업종별 트렌드**:
- 음식점(한식/분식): 가성비 + 넉넉한 양
- 평균 객단가: 9,000원
- 학생 비율: 45%
- 배달 비중: 50%
**성공 이벤트 사례**:
```json
{
"event_type": "시간대 할인",
"title": "오후 2-5시 한정 점심 특가 세트",
"업종": "한식당",
"기간": "1개월",
"예산": "400,000원",
"참여자": "780명",
"매출_증가": "310%",
"ROI": "480%"
}
```
---
## 충청남도
### 천안시
**지역 특성**:
- 상권 유형: 교통 요충지 + 공업단지
- 주요 고객층: 직장인(55%), 유동인구(25%), 지역주민(20%)
- 유동인구: 평일 45만명, 주말 35만명
- 특징: 천안역/천안아산역, 삼성디스플레이
**업종별 트렌드**:
- 음식점(빠른식사): 속도 중시, 테이크아웃
- 평균 객단가: 8,000원
- 회전율: 3회/테이블
- 포장 비중: 40%
**성공 이벤트 사례**:
```json
{
"event_type": "출퇴근 타겟",
"title": "KTX 승차권 인증 시 즉석 10% 할인",
"업종": "분식/김밥",
"기간": "2개월",
"예산": "600,000원",
"참여자": "1,650명",
"매출_증가": "340%",
"ROI": "510%"
}
```
---
## 전북특별자치도
### 전주시
**지역 특성**:
- 상권 유형: 문화관광 + 한옥마을
- 주요 고객층: 관광객(60%), 지역주민(25%), 외국인(15%)
- 유동인구: 평일 25만명, 주말 55만명
- 특징: 전주한옥마을, 전통 한식, 전주비빔밥
**업종별 트렌드**:
- 음식점(한식/비빔밥): 전통 음식 특화
- 평균 객단가: 12,000원
- 관광객 비율: 75%
- SNS 중요도: 최상
**성공 이벤트 사례**:
```json
{
"event_type": "전통 체험",
"title": "한복 대여 인증 시 전주비빔밥 15% 할인 + 전통차",
"업종": "한정식",
"기간": "3개월",
"예산": "1,500,000원",
"참여자": "1,280명",
"매출_증가": "440%",
"ROI": "610%"
}
```
---
## 전라남도
### 여수시
**지역 특성**:
- 상권 유형: 해양관광 + 산업도시
- 주요 고객층: 관광객(65%), 지역주민(25%), 직장인(10%)
- 유동인구: 평일 20만명, 주말/성수기 70만명
- 특징: 여수밤바다, 오동도, 해양 엑스포
**업종별 트렌드**:
- 음식점(해산물): 신선한 활어, 계절 특선
- 평균 객단가: 35,000원
- 관광객 비율: 80%
- 여름 성수기 매출: 전체의 50%
**성공 이벤트 사례**:
```json
{
"event_type": "계절 특선",
"title": "여름 밤바다 특선 세트 + 야경 포토존",
"업종": "해산물 레스토랑",
"기간": "여름 3개월",
"예산": "3,500,000원",
"참여자": "2,100명",
"매출_증가": "580%",
"ROI": "720%"
}
```
---
## 경상북도
### 포항시
**지역 특성**:
- 상권 유형: 산업도시 + 해양도시
- 주요 고객층: 직장인(50%), 지역주민(30%), 관광객(20%)
- 유동인구: 평일 35만명, 주말 30만명
- 특징: 포스코, 호미곶, 죽도시장
**업종별 트렌드**:
- 음식점(회/과메기): 지역 특산물
- 평균 객단가: 30,000원
- 직장인 비율: 60%
- 겨울 과메기 시즌: +200%
**성공 이벤트 사례**:
```json
{
"event_type": "계절 한정",
"title": "과메기 시즌 특가: 세트 메뉴 30% 할인",
"업종": "해산물 전문점",
"기간": "겨울 2개월",
"예산": "1,800,000원",
"참여자": "850명",
"매출_증가": "620%",
"ROI": "780%"
}
```
---
### 경주시
**지역 특성**:
- 상권 유형: 역사문화 관광도시
- 주요 고객층: 관광객(70%), 외국인(15%), 지역주민(15%)
- 유동인구: 평일 30만명, 주말 80만명
- 특징: 불국사, 석굴암, 첨성대, 황리단길
**업종별 트렌드**:
- 카페(전통+현대): 한옥 카페, SNS 핫플
- 평균 객단가: 7,000원
- 관광객 비율: 85%
- 인스타그램 중요도: 최상
**성공 이벤트 사례**:
```json
{
"event_type": "문화유산 연계",
"title": "경주 3대 유적지 스탬프 투어 완료 시 디저트 무료",
"업종": "한옥 카페",
"기간": "관광 성수기 4개월",
"예산": "2,200,000원",
"참여자": "1,680명",
"매출_증가": "490%",
"ROI": "650%"
}
```
---
## 경상남도
### 창원시
**지역 특성**:
- 상권 유형: 공업도시 + 행정중심지
- 주요 고객층: 직장인(60%), 가족(25%), 대학생(15%)
- 유동인구: 평일 50만명, 주말 35만명
- 특징: 창원산업단지, 성산아트홀, 용지호수공원
**업종별 트렌드**:
- 음식점(고깃집/회식): 단체 회식 문화
- 평균 객단가: 28,000원
- 직장인 비율: 75%
- 평일 저녁 집중: 80%
**성공 이벤트 사례**:
```json
{
"event_type": "법인카드 타겟",
"title": "10인 이상 예약 시 음료 무제한 + 후식 서비스",
"업종": "고깃집",
"기간": "2개월",
"예산": "2,500,000원",
"참여자": "180팀 (2,350명)",
"매출_증가": "450%",
"ROI": "580%"
}
```
---
## 제주특별자치도
### 제주시
**지역 특성**:
- 상권 유형: 관광 특화 지역
- 주요 고객층: 관광객(75%), 외국인(15%), 지역주민(10%)
- 유동인구: 평일 40만명, 주말 90만명 (성수기)
- 특징: 동문시장, 제주공항, 올레길, 카페거리
**업종별 트렌드**:
- 음식점(흑돼지/해산물): 제주 특산물
- 평균 객단가: 40,000원
- 관광객 비율: 90%
- 예약률: 70%
- 카페(오션뷰): 자연 경관 활용
- 객단가: 8,500원
- 체류시간: 90분
- SNS 바이럴 필수
**성공 이벤트 사례**:
```json
{
"event_type": "렌터카 제휴",
"title": "렌터카 영수증 인증 시 흑돼지 구이 20% 할인",
"업종": "제주 향토음식점",
"기간": "3개월",
"예산": "3,000,000원",
"참여자": "1,850명",
"매출_증가": "520%",
"ROI": "680%"
}
```
---
### 서귀포시
**지역 특성**:
- 상권 유형: 자연관광 + 리조트
- 주요 고객층: 관광객(80%), 허니문(15%), 지역주민(5%)
- 유동인구: 평일 25만명, 주말 60만명
- 특징: 중문관광단지, 천지연폭포, 섭지코지
**업종별 트렌드**:
- 음식점(프리미엄): 고급 식재료, 특별한 경험
- 평균 객단가: 55,000원
- 커플 비율: 70%
- 예약 필수: 90%
**성공 이벤트 사례**:
```json
{
"event_type": "기념일 특화",
"title": "결혼기념일/생일 증빙 시 디저트 플레이팅 + 사진 촬영",
"업종": "파인다이닝",
"기간": "연중",
"예산": "1,500,000원/월",
"참여자": "280커플/월",
"매출_증가": "380%",
"ROI": "560%",
"재방문율": "+45%"
}
```
---
## 데이터 활용 가이드
### AI 추천 알고리즘 적용 방법
**1. 지역 매칭**:
```javascript
// 사용자 매장 주소 → 시군구 추출 → 해당 트렌드 데이터 로드
const locationTrend = getTrendData(store.district);
```
**2. 업종 필터링**:
```javascript
// 매장 업종 → 해당 지역 내 업종별 트렌드 추출
const industryTrend = locationTrend.industries[store.industry];
```
**3. 계절 고려**:
```javascript
// 현재 시즌 → 계절별 소비 패턴 적용
const seasonPattern = locationTrend.seasons[currentSeason];
```
**4. 성공 사례 참조**:
```javascript
// 유사 이벤트 검색 → ROI 높은 순 정렬
const similarEvents = findSimilarEvents({
district: store.district,
industry: store.industry,
season: currentSeason
}).sortBy('ROI', 'desc');
```
**5. 맞춤형 추천 생성**:
```javascript
// AI 프롬프트에 컨텍스트 주입
const prompt = `
[매장 정보]
- 지역: ${store.district}
- 업종: ${store.industry}
- 이벤트 목적: ${eventPurpose}
[지역 트렌드]
- 주요 고객층: ${locationTrend.targetCustomers}
- 유동인구: ${locationTrend.traffic}
- 계절 특성: ${seasonPattern.characteristics}
[성공 사례]
${similarEvents.map(e => formatEventCase(e)).join('\n')}
위 정보를 바탕으로 3가지 예산별 이벤트를 추천하세요.
`;
```
### 데이터 업데이트 전략
**월간 업데이트**:
- 새로운 성공 사례 추가
- ROI 데이터 갱신
- 계절별 패턴 보정
**분기별 업데이트**:
- 신규 트렌드 반영
- 고객층 변화 분석
- 경쟁 환경 업데이트
**연간 업데이트**:
- 전체 데이터 구조 재검토
- 신규 시군구 추가
- AI 모델 재학습
---
## 부록: 데이터 출처 및 근거
**공공데이터**:
- 소상공인시장진흥공단 상권정보시스템
- 통계청 지역별 소비 통계
- 각 지자체 관광 통계
**업계 데이터**:
- 배달앱 플랫폼 공개 자료
- 외식업 협회 통계
- 프랜차이즈 본사 공개 데이터
**연구 자료**:
- 국내 외식업 소상공인 이벤트 방법 종합 연구보고서
- 소상공인 이벤트 설계 방법론
- 한국외식산업경영연구원 트렌드 리포트
**검증 방법**:
- 실제 소상공인 인터뷰 (30개 업체)
- 배달앱 리뷰 데이터 분석 (10만건)
- SNS 해시태그 분석 (#소상공인이벤트 5만건)
---
**문서 버전**: 1.0
**최종 수정일**: 2025-10-21
**다음 업데이트 예정**: 2025-11-21

View File

@ -0,0 +1,665 @@
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - API 설계서
## 문서 정보
- **작성일**: 2025-10-23
- **버전**: 1.0
- **작성자**: System Architect
- **관련 문서**:
- [유저스토리](../../userstory.md)
- [논리 아키텍처](../logical/logical-architecture.md)
- [외부 시퀀스 설계](../sequence/outer/)
- [내부 시퀀스 설계](../sequence/inner/)
---
## 목차
1. [개요](#1-개요)
2. [API 설계 원칙](#2-api-설계-원칙)
3. [서비스별 API 명세](#3-서비스별-api-명세)
4. [API 통합 가이드](#4-api-통합-가이드)
5. [보안 및 인증](#5-보안-및-인증)
6. [에러 처리](#6-에러-처리)
7. [API 테스트 가이드](#7-api-테스트-가이드)
---
## 1. 개요
### 1.1 설계 범위
본 API 설계서는 KT AI 기반 소상공인 이벤트 자동 생성 서비스의 7개 마이크로서비스 API를 정의합니다.
### 1.2 마이크로서비스 구성
1. **User Service**: 사용자 인증 및 매장정보 관리
2. **Event Service**: 이벤트 전체 생명주기 관리
3. **AI Service**: AI 기반 이벤트 추천
4. **Content Service**: SNS 콘텐츠 생성
5. **Distribution Service**: 다중 채널 배포 관리
6. **Participation Service**: 이벤트 참여 및 당첨자 관리
7. **Analytics Service**: 실시간 효과 측정 및 통합 대시보드
### 1.3 파일 구조
```
design/backend/api/
├── user-service-api.yaml (31KB, 1,011 lines)
├── event-service-api.yaml (41KB, 1,373 lines)
├── ai-service-api.yaml (26KB, 847 lines)
├── content-service-api.yaml (37KB, 1,158 lines)
├── distribution-service-api.yaml (21KB, 653 lines)
├── participation-service-api.yaml (25KB, 820 lines)
├── analytics-service-api.yaml (28KB, 1,050 lines)
└── API-설계서.md (this file)
```
---
## 2. API 설계 원칙
### 2.1 OpenAPI 3.0 표준 준수
- 모든 API는 OpenAPI 3.0 스펙을 따릅니다
- Swagger UI/Editor에서 직접 테스트 가능합니다
- 자동 코드 생성 및 문서화를 지원합니다
### 2.2 RESTful 설계
- **리소스 중심 URL 구조**: `/api/{resource}/{id}`
- **HTTP 메서드**: GET (조회), POST (생성), PUT (수정), DELETE (삭제)
- **상태 코드**: 200 (성공), 201 (생성), 400 (잘못된 요청), 401 (인증 실패), 403 (권한 없음), 404 (리소스 없음), 500 (서버 오류)
### 2.3 유저스토리 기반 설계
- 각 API 엔드포인트는 유저스토리와 매핑됩니다
- **x-user-story** 필드로 유저스토리 ID를 명시합니다
- **x-controller** 필드로 담당 컨트롤러를 명시합니다
### 2.4 서비스 독립성
- 각 서비스는 독립적인 OpenAPI 명세를 가집니다
- 공통 스키마는 각 서비스에서 필요에 따라 정의합니다
- 서비스 간 통신은 REST API, Kafka 이벤트, Redis 캐시를 통해 이루어집니다
### 2.5 Example 데이터 제공
- 모든 스키마에 example 데이터가 포함됩니다
- Swagger UI에서 즉시 테스트 가능합니다
- 성공/실패 시나리오 모두 포함합니다
---
## 3. 서비스별 API 명세
### 3.1 User Service (사용자 인증 및 매장정보 관리)
**파일**: `user-service-api.yaml`
**관련 유저스토리**: UFR-USER-010, 020, 030, 040
#### API 엔드포인트 (7개)
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| POST | /api/users/register | 회원가입 | UFR-USER-010 | - |
| POST | /api/users/login | 로그인 | UFR-USER-020 | - |
| POST | /api/users/logout | 로그아웃 | UFR-USER-040 | JWT |
| GET | /api/users/profile | 프로필 조회 | UFR-USER-030 | JWT |
| PUT | /api/users/profile | 프로필 수정 | UFR-USER-030 | JWT |
| PUT | /api/users/password | 비밀번호 변경 | UFR-USER-030 | JWT |
| GET | /api/users/{userId}/store | 매장정보 조회 (서비스 연동용) | - | JWT |
#### 주요 기능
- JWT 토큰 기반 인증 (TTL 7일)
- 사업자번호 검증 (국세청 API 연동)
- Redis 세션 관리
- BCrypt 비밀번호 해싱
- AES-256-GCM 사업자번호 암호화
#### 주요 스키마
- `UserRegisterRequest`: 회원가입 요청
- `UserLoginRequest`: 로그인 요청
- `UserProfileResponse`: 프로필 응답
- `StoreInfoResponse`: 매장정보 응답
---
### 3.2 Event Service (이벤트 전체 생명주기 관리)
**파일**: `event-service-api.yaml`
**관련 유저스토리**: UFR-EVENT-010 ~ 070
#### API 엔드포인트 (14개)
**Dashboard & Event List:**
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| GET | /api/events | 이벤트 목록 조회 | UFR-EVENT-010, 070 | JWT |
| GET | /api/events/{eventId} | 이벤트 상세 조회 | UFR-EVENT-060 | JWT |
**Event Creation Flow (5 Steps):**
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| POST | /api/events/objectives | Step 1: 이벤트 목적 선택 | UFR-EVENT-020 | JWT |
| POST | /api/events/{eventId}/ai-recommendations | Step 2: AI 추천 요청 | UFR-EVENT-030 | JWT |
| PUT | /api/events/{eventId}/recommendations | Step 2-2: AI 추천 선택 | UFR-EVENT-030 | JWT |
| POST | /api/events/{eventId}/images | Step 3: 이미지 생성 요청 | UFR-CONT-010 | JWT |
| PUT | /api/events/{eventId}/images/{imageId}/select | Step 3-2: 이미지 선택 | UFR-CONT-010 | JWT |
| PUT | /api/events/{eventId}/images/{imageId}/edit | Step 3-3: 이미지 편집 | UFR-CONT-020 | JWT |
| PUT | /api/events/{eventId}/channels | Step 4: 배포 채널 선택 | UFR-EVENT-040 | JWT |
| POST | /api/events/{eventId}/publish | Step 5: 최종 승인 및 배포 | UFR-EVENT-050 | JWT |
**Event Management:**
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| PUT | /api/events/{eventId} | 이벤트 수정 | UFR-EVENT-060 | JWT |
| DELETE | /api/events/{eventId} | 이벤트 삭제 | UFR-EVENT-070 | JWT |
| POST | /api/events/{eventId}/end | 이벤트 조기 종료 | UFR-EVENT-060 | JWT |
**Job Status:**
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| GET | /api/jobs/{jobId} | Job 상태 폴링 | UFR-EVENT-030, UFR-CONT-010 | JWT |
#### 주요 기능
- 이벤트 생명주기 관리 (DRAFT → PUBLISHED → ENDED)
- Kafka Job 발행 (ai-event-generation-job, image-generation-job)
- Kafka Event 발행 (EventCreated)
- Distribution Service 동기 호출
- Redis 기반 AI/이미지 데이터 캐싱
- Job 상태 폴링 메커니즘 (PENDING, PROCESSING, COMPLETED, FAILED)
#### 주요 스키마
- `EventObjectiveRequest`: 이벤트 목적 선택
- `EventResponse`: 이벤트 응답
- `JobStatusResponse`: Job 상태 응답
- `AIRecommendationSelection`: AI 추천 선택
- `ChannelSelectionRequest`: 배포 채널 선택
---
### 3.3 AI Service (AI 기반 이벤트 추천)
**파일**: `ai-service-api.yaml`
**관련 유저스토리**: UFR-AI-010
#### API 엔드포인트 (3개)
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| GET | /health | 서비스 헬스체크 | - | - |
| GET | /internal/jobs/{jobId}/status | Job 상태 조회 (내부 API) | UFR-AI-010 | JWT |
| GET | /internal/recommendations/{eventId} | AI 추천 결과 조회 (내부 API) | UFR-AI-010 | JWT |
#### Kafka Consumer (비동기 처리)
- **Topic**: `ai-event-generation-job`
- **Consumer Group**: `ai-service-consumers`
- **처리 시간**: 최대 5분
- **결과 저장**: Redis (TTL 24시간)
#### 주요 기능
- 업종/지역/시즌 트렌드 분석
- 3가지 차별화된 이벤트 기획안 생성
- 예상 성과 계산 (참여자 수, ROI, 매출 증가율)
- Circuit Breaker 패턴 (5분 timeout, 캐시 fallback)
- Claude API / GPT-4 API 연동
#### 주요 스키마
- `KafkaAIJobMessage`: Kafka Job 입력
- `AIRecommendationResult`: AI 추천 결과 (트렌드 분석 + 3가지 옵션)
- `TrendAnalysis`: 업종/지역/시즌 트렌드
- `EventRecommendation`: 이벤트 기획안 (컨셉, 경품, 참여방법, 예상성과)
---
### 3.4 Content Service (SNS 콘텐츠 생성)
**파일**: `content-service-api.yaml`
**관련 유저스토리**: UFR-CONT-010, 020
#### API 엔드포인트 (6개)
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| POST | /api/content/images/generate | 이미지 생성 요청 (비동기) | UFR-CONT-010 | JWT |
| GET | /api/content/images/jobs/{jobId} | Job 상태 폴링 | UFR-CONT-010 | JWT |
| GET | /api/content/events/{eventDraftId} | 이벤트 전체 콘텐츠 조회 | UFR-CONT-020 | JWT |
| GET | /api/content/events/{eventDraftId}/images | 이미지 목록 조회 | UFR-CONT-020 | JWT |
| GET | /api/content/images/{imageId} | 이미지 상세 조회 | UFR-CONT-020 | JWT |
| POST | /api/content/images/{imageId}/regenerate | 이미지 재생성 | UFR-CONT-020 | JWT |
#### Kafka Consumer (비동기 처리)
- **Topic**: `image-generation-job`
- **Consumer Group**: `content-service-consumers`
- **처리 시간**: 최대 5분
- **결과 저장**: Redis (CDN URL, TTL 7일)
#### 주요 기능
- 3가지 스타일 이미지 생성 (SIMPLE, FANCY, TRENDY)
- 플랫폼별 최적화 (Instagram 1080x1080, Naver 800x600, Kakao 800x800)
- Circuit Breaker 패턴 (Stable Diffusion → DALL-E → Default Template)
- Azure Blob Storage (CDN) 연동
- Redis 기반 AI 데이터 읽기
#### 주요 스키마
- `ImageGenerationJob`: Kafka Job 입력
- `ImageGenerationRequest`: 이미지 생성 요청
- `GeneratedImage`: 생성된 이미지 (style, platform, CDN URL)
- `ContentResponse`: 전체 콘텐츠 응답
---
### 3.5 Distribution Service (다중 채널 배포 관리)
**파일**: `distribution-service-api.yaml`
**관련 유저스토리**: UFR-DIST-010, 020
#### API 엔드포인트 (2개)
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| POST | /api/distribution/distribute | 다중 채널 배포 (동기) | UFR-DIST-010 | JWT |
| GET | /api/distribution/{eventId}/status | 배포 상태 조회 | UFR-DIST-020 | JWT |
#### 주요 기능
- **배포 채널**: 우리동네TV, 링고비즈, 지니TV, Instagram, Naver Blog, Kakao Channel
- **병렬 배포**: 6개 채널 동시 배포 (1분 이내)
- **Resilience 패턴**:
- Circuit Breaker: 채널별 독립 적용
- Retry: 최대 3회 재시도 (지수 백오프: 1s, 2s, 4s)
- Bulkhead: 채널별 스레드 풀 격리
- Fallback: 실패 채널 스킵 + 알림
- **Kafka Event 발행**: DistributionCompleted
- **로깅**: Event DB에 distribution_logs 저장
#### 주요 스키마
- `DistributionRequest`: 배포 요청
- `DistributionResponse`: 배포 응답 (채널별 결과)
- `DistributionStatusResponse`: 배포 상태
- `ChannelDistributionResult`: 채널별 배포 결과
---
### 3.6 Participation Service (이벤트 참여 및 당첨자 관리)
**파일**: `participation-service-api.yaml`
**관련 유저스토리**: UFR-PART-010, 020, 030
#### API 엔드포인트 (5개)
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| POST | /api/events/{eventId}/participate | 이벤트 참여 | UFR-PART-010 | - |
| GET | /api/events/{eventId}/participants | 참여자 목록 조회 | UFR-PART-020 | JWT |
| GET | /api/events/{eventId}/participants/{participantId} | 참여자 상세 조회 | UFR-PART-020 | JWT |
| POST | /api/events/{eventId}/draw-winners | 당첨자 추첨 | UFR-PART-030 | JWT |
| GET | /api/events/{eventId}/winners | 당첨자 목록 조회 | UFR-PART-030 | JWT |
#### 주요 기능
- 중복 참여 체크 (전화번호 기반)
- 매장 방문 고객 가산점 적용
- 난수 기반 무작위 추첨
- Kafka Event 발행 (ParticipantRegistered)
- 개인정보 수집/이용 동의 관리
- 페이지네이션 지원
#### 주요 스키마
- `ParticipationRequest`: 참여 요청
- `ParticipationResponse`: 참여 응답 (응모번호)
- `ParticipantListResponse`: 참여자 목록
- `WinnerDrawRequest`: 당첨자 추첨 요청
- `WinnerResponse`: 당첨자 정보
---
### 3.7 Analytics Service (실시간 효과 측정 및 통합 대시보드)
**파일**: `analytics-service-api.yaml`
**관련 유저스토리**: UFR-ANAL-010
#### API 엔드포인트 (4개)
| 메서드 | 경로 | 설명 | 유저스토리 | 인증 |
|--------|------|------|-----------|------|
| GET | /api/events/{eventId}/analytics | 성과 대시보드 조회 | UFR-ANAL-010 | JWT |
| GET | /api/events/{eventId}/analytics/channels | 채널별 성과 분석 | UFR-ANAL-010 | JWT |
| GET | /api/events/{eventId}/analytics/timeline | 시간대별 참여 추이 | UFR-ANAL-010 | JWT |
| GET | /api/events/{eventId}/analytics/roi | 투자 대비 수익률 상세 | UFR-ANAL-010 | JWT |
#### Kafka Event 구독
- **EventCreated**: 이벤트 기본 통계 초기화
- **ParticipantRegistered**: 실시간 참여자 수 증가
- **DistributionCompleted**: 배포 채널 통계 업데이트
#### 주요 기능
- **실시간 대시보드**: Redis 캐싱 (TTL 5분)
- **외부 API 통합**: 우리동네TV, 지니TV, SNS APIs (조회수, 노출수, 소셜 인터랙션)
- **Circuit Breaker**: 외부 API 실패 시 캐시 fallback
- **ROI 계산**: 비용 대비 수익률 자동 계산
- **성과 집계**: 채널별, 시간대별 성과 분석
#### 주요 스키마
- `AnalyticsDashboardResponse`: 대시보드 전체 데이터
- `ChannelPerformanceResponse`: 채널별 성과
- `TimelineDataResponse`: 시간대별 참여 추이
- `RoiDetailResponse`: ROI 상세 분석
---
## 4. API 통합 가이드
### 4.1 이벤트 생성 플로우 (Event-Driven)
```
1. 이벤트 목적 선택 (Event Service)
POST /api/events/objectives
→ EventCreated 이벤트 발행 (Kafka)
→ Analytics Service 구독 (통계 초기화)
2. AI 추천 요청 (Event Service → AI Service)
POST /api/events/{eventId}/ai-recommendations
→ ai-event-generation-job 발행 (Kafka)
→ AI Service 구독 및 처리 (비동기)
→ Redis에 결과 저장 (TTL 24시간)
→ 클라이언트 폴링: GET /api/jobs/{jobId}
3. AI 추천 선택 (Event Service)
PUT /api/events/{eventId}/recommendations
→ Redis에서 AI 추천 데이터 읽기
→ Event DB에 선택된 추천 저장
4. 이미지 생성 요청 (Event Service → Content Service)
POST /api/events/{eventId}/images
→ image-generation-job 발행 (Kafka)
→ Content Service 구독 및 처리 (비동기)
→ Redis에서 AI 데이터 읽기
→ CDN에 이미지 업로드
→ Redis에 CDN URL 저장 (TTL 7일)
→ 클라이언트 폴링: GET /api/jobs/{jobId}
5. 이미지 선택 및 편집 (Event Service)
PUT /api/events/{eventId}/images/{imageId}/select
PUT /api/events/{eventId}/images/{imageId}/edit
6. 배포 채널 선택 (Event Service)
PUT /api/events/{eventId}/channels
7. 최종 승인 및 배포 (Event Service → Distribution Service)
POST /api/events/{eventId}/publish
→ Distribution Service 동기 호출: POST /api/distribution/distribute
→ 다중 채널 병렬 배포 (1분 이내)
→ DistributionCompleted 이벤트 발행 (Kafka)
→ Analytics Service 구독 (배포 통계 업데이트)
```
### 4.2 고객 참여 플로우 (Event-Driven)
```
1. 이벤트 참여 (Participation Service)
POST /api/events/{eventId}/participate
→ 중복 참여 체크
→ Participation DB 저장
→ ParticipantRegistered 이벤트 발행 (Kafka)
→ Analytics Service 구독 (참여자 수 실시간 증가)
2. 당첨자 추첨 (Participation Service)
POST /api/events/{eventId}/draw-winners
→ 난수 기반 무작위 추첨
→ Winners DB 저장
```
### 4.3 성과 분석 플로우 (Event-Driven)
```
1. 실시간 대시보드 조회 (Analytics Service)
GET /api/events/{eventId}/analytics
→ Redis 캐시 확인 (TTL 5분)
→ 캐시 HIT: 즉시 반환
→ 캐시 MISS:
- Analytics DB 조회 (이벤트/참여 통계)
- 외부 APIs 조회 (우리동네TV, 지니TV, SNS) [Circuit Breaker]
- Redis 캐싱 후 반환
2. Kafka 이벤트 구독 (Analytics Service Background)
- EventCreated 구독 → 이벤트 기본 정보 초기화
- ParticipantRegistered 구독 → 참여자 수 실시간 증가
- DistributionCompleted 구독 → 배포 채널 통계 업데이트
- 캐시 무효화 → 다음 조회 시 최신 데이터 갱신
```
### 4.4 서비스 간 통신 패턴
| 패턴 | 사용 시나리오 | 통신 방식 | 예시 |
|------|-------------|----------|------|
| **동기 REST API** | 즉시 응답 필요 | HTTP/JSON | Distribution Service 배포 요청 |
| **Kafka Job Topics** | 장시간 비동기 작업 | Kafka 메시지 큐 | AI 추천, 이미지 생성 |
| **Kafka Event Topics** | 상태 변경 알림 | Kafka Pub/Sub | EventCreated, ParticipantRegistered |
| **Redis Cache** | 데이터 공유 | Redis Get/Set | AI 결과, 이미지 URL |
---
## 5. 보안 및 인증
### 5.1 JWT 기반 인증
**토큰 발급:**
- User Service에서 로그인/회원가입 시 JWT 토큰 발급
- 토큰 만료 시간: 7일
- Redis에 세션 정보 저장 (TTL 7일)
**토큰 검증:**
- API Gateway에서 모든 요청의 JWT 토큰 검증
- Authorization 헤더: `Bearer {token}`
- 검증 실패 시 401 Unauthorized 응답
**보호된 엔드포인트:**
- 모든 API (회원가입, 로그인, 이벤트 참여 제외)
### 5.2 민감 정보 암호화
- **비밀번호**: BCrypt 해싱 (Cost Factor: 10)
- **사업자번호**: AES-256-GCM 암호화
- **개인정보**: 전화번호 마스킹 (010-****-1234)
### 5.3 API Rate Limiting
- API Gateway에서 사용자당 100 req/min 제한
- Redis 기반 Rate Limiting 구현
---
## 6. 에러 처리
### 6.1 표준 에러 응답 포맷
```json
{
"success": false,
"errorCode": "ERROR_CODE",
"message": "사용자 친화적인 에러 메시지",
"details": "상세 에러 정보 (선택)",
"timestamp": "2025-10-23T16:30:00Z"
}
```
### 6.2 HTTP 상태 코드
| 상태 코드 | 설명 | 사용 예시 |
|----------|------|----------|
| 200 OK | 성공 | GET 요청 성공 |
| 201 Created | 생성 성공 | POST 요청으로 리소스 생성 |
| 400 Bad Request | 잘못된 요청 | 유효성 검증 실패 |
| 401 Unauthorized | 인증 실패 | JWT 토큰 없음/만료 |
| 403 Forbidden | 권한 없음 | 접근 권한 부족 |
| 404 Not Found | 리소스 없음 | 존재하지 않는 이벤트 조회 |
| 409 Conflict | 충돌 | 중복 참여, 동시성 문제 |
| 500 Internal Server Error | 서버 오류 | 서버 내부 오류 |
| 503 Service Unavailable | 서비스 불가 | Circuit Breaker Open |
### 6.3 서비스별 주요 에러 코드
**User Service:**
- `USER_001`: 중복 사용자
- `USER_002`: 사업자번호 검증 실패
- `USER_003`: 사용자 없음
- `AUTH_001`: 인증 실패
- `AUTH_002`: 유효하지 않은 토큰
**Event Service:**
- `EVENT_001`: 이벤트 없음
- `EVENT_002`: 유효하지 않은 상태 전환
- `EVENT_003`: 필수 데이터 누락 (AI 추천, 이미지)
- `JOB_001`: Job 없음
- `JOB_002`: Job 실패
**Participation Service:**
- `PART_001`: 중복 참여
- `PART_002`: 이벤트 기간 아님
- `PART_003`: 참여자 없음
**Distribution Service:**
- `DIST_001`: 배포 실패
- `DIST_002`: Circuit Breaker Open
**Analytics Service:**
- `ANALYTICS_001`: 데이터 없음
- `EXTERNAL_API_ERROR`: 외부 API 장애
---
## 7. API 테스트 가이드
### 7.1 Swagger UI를 통한 테스트
**방법 1: Swagger Editor**
1. https://editor.swagger.io/ 접속
2. 각 서비스의 YAML 파일 내용 붙여넣기
3. 우측 Swagger UI에서 API 테스트
**방법 2: SwaggerHub**
1. 각 API 명세의 `servers` 섹션에 SwaggerHub Mock Server URL 포함
2. Mock Server를 통한 즉시 테스트 가능
**방법 3: Redocly**
```bash
# 각 API 명세 검증
npx @redocly/cli lint design/backend/api/*.yaml
# 문서 HTML 생성
npx @redocly/cli build-docs design/backend/api/user-service-api.yaml \
--output docs/user-service-api.html
```
### 7.2 테스트 시나리오 예시
**1. 회원가입 → 로그인 → 이벤트 생성 플로우**
```bash
# 1. 회원가입
POST /api/users/register
{
"name": "김사장",
"phoneNumber": "010-1234-5678",
"email": "owner@example.com",
"password": "SecurePass123!",
"store": {
"name": "맛있는 고깃집",
"industry": "RESTAURANT",
"address": "서울시 강남구 테헤란로 123",
"businessNumber": "123-45-67890"
}
}
# 2. 로그인
POST /api/users/login
{
"phoneNumber": "010-1234-5678",
"password": "SecurePass123!"
}
# → JWT 토큰 수신
# 3. 이벤트 목적 선택
POST /api/events/objectives
Authorization: Bearer {token}
{
"objective": "NEW_CUSTOMER_ACQUISITION"
}
# → eventId 수신
# 4. AI 추천 요청
POST /api/events/{eventId}/ai-recommendations
Authorization: Bearer {token}
# → jobId 수신
# 5. Job 상태 폴링 (5초 간격)
GET /api/jobs/{jobId}
Authorization: Bearer {token}
# → status: COMPLETED 확인
# 6. AI 추천 선택
PUT /api/events/{eventId}/recommendations
Authorization: Bearer {token}
{
"selectedOption": 1,
"customization": {
"title": "봄맞이 삼겹살 50% 할인 이벤트",
"prizeName": "삼겹살 1인분 무료"
}
}
# ... (이미지 생성, 배포 채널 선택, 최종 승인)
```
### 7.3 Mock 데이터 활용
- 모든 API 명세에 example 데이터 포함
- Swagger UI의 "Try it out" 기능으로 즉시 테스트
- 성공/실패 시나리오 모두 example 제공
### 7.4 통합 테스트 도구
**Postman Collection 생성:**
```bash
# OpenAPI 명세를 Postman Collection으로 변환
npx openapi-to-postmanv2 -s design/backend/api/user-service-api.yaml \
-o postman/user-service-collection.json
```
**Newman (CLI 테스트 실행):**
```bash
# Postman Collection 실행
newman run postman/user-service-collection.json \
--environment postman/dev-environment.json
```
---
## 부록
### A. 파일 통계
| 서비스 | 파일명 | 크기 | 라인 수 | API 수 |
|--------|--------|------|--------|--------|
| User | user-service-api.yaml | 31KB | 1,011 | 7 |
| Event | event-service-api.yaml | 41KB | 1,373 | 14 |
| AI | ai-service-api.yaml | 26KB | 847 | 3 |
| Content | content-service-api.yaml | 37KB | 1,158 | 6 |
| Distribution | distribution-service-api.yaml | 21KB | 653 | 2 |
| Participation | participation-service-api.yaml | 25KB | 820 | 5 |
| Analytics | analytics-service-api.yaml | 28KB | 1,050 | 4 |
| **합계** | - | **209KB** | **6,912** | **41** |
### B. 주요 의사결정
1. **OpenAPI 3.0 표준 채택**: 업계 표준 준수, 자동 코드 생성 지원
2. **서비스별 독립 명세**: 서비스 독립성 보장, 독립 배포 가능
3. **유저스토리 기반 설계**: x-user-story 필드로 추적성 확보
4. **Example 데이터 포함**: Swagger UI 즉시 테스트 가능
5. **JWT 인증 표준화**: 모든 서비스에서 일관된 인증 방식
6. **에러 응답 표준화**: 일관된 에러 응답 포맷
7. **Kafka + Redis 통합**: Event-Driven 아키텍처 지원
8. **Circuit Breaker 패턴**: 외부 API 장애 대응
### C. 다음 단계
1. **외부 시퀀스 설계**: 서비스 간 API 호출 흐름 상세 설계
2. **내부 시퀀스 설계**: 서비스 내부 컴포넌트 간 호출 흐름 설계
3. **클래스 설계**: 서비스별 클래스 다이어그램 작성
4. **데이터 설계**: 서비스별 데이터베이스 스키마 설계
5. **백엔드 개발**: OpenAPI 명세 기반 코드 생성 및 구현
---
**문서 버전**: 1.0
**최종 수정일**: 2025-10-23
**작성자**: System Architect

View File

@ -0,0 +1,914 @@
# OpenAPI 3.0.3 공통 컨벤션
KT AI 기반 소상공인 이벤트 자동 생성 서비스의 모든 마이크로서비스 API 명세서에 적용되는 공통 컨벤션입니다.
## 목차
1. [기본 정보 섹션](#1-기본-정보-섹션)
2. [서버 정의](#2-서버-정의)
3. [보안 스키마](#3-보안-스키마)
4. [태그 구성](#4-태그-구성)
5. [엔드포인트 정의](#5-엔드포인트-정의)
6. [응답 구조](#6-응답-구조)
7. [에러 응답 구조](#7-에러-응답-구조)
8. [스키마 정의](#8-스키마-정의)
9. [메타데이터 주석](#9-메타데이터-주석)
10. [기술 명세 섹션](#10-기술-명세-섹션)
11. [예제 작성](#11-예제-작성)
---
## 1. 기본 정보 섹션
### 1.1 OpenAPI 버전
```yaml
openapi: 3.0.3
```
- **필수**: 모든 명세서는 OpenAPI 3.0.3 버전을 사용합니다.
### 1.2 Info 객체
```yaml
info:
title: {Service Name} API
description: |
KT AI 기반 소상공인 이벤트 자동 생성 서비스 - {Service Name} API
{서비스 설명 1-2줄}
**주요 기능:**
- {기능 1}
- {기능 2}
- {기능 3}
**보안:** (보안 관련 서비스인 경우)
- {보안 메커니즘 1}
- {보안 메커니즘 2}
version: 1.0.0
contact:
name: Digital Garage Team
email: support@kt-event-marketing.com
```
**필수 항목:**
- `title`: "{서비스명} API" 형식
- `description`: 마크다운 형식으로 서비스 설명 작성
- 첫 줄: 프로젝트명과 서비스 역할
- 서비스 설명
- 주요 기능 목록 (bullet points)
- 보안 관련 서비스의 경우 보안 섹션 추가
- `version`: "1.0.0"
- `contact`: name과 email 필수
---
## 2. 서버 정의
### 2.1 서버 URL 구조
```yaml
servers:
- url: http://localhost:{port}
description: Local Development Server
- url: https://dev-api.kt-event-marketing.com/{service}/v1
description: Development Server
- url: https://api.kt-event-marketing.com/{service}/v1
description: Production Server
```
**포트 번호 할당:**
- User Service: 8081
- Event Service: 8080
- Content Service: 8082
- AI Service: 8083
- Participation Service: 8084
- Distribution Service: 8085
- Analytics Service: 8086
**URL 패턴:**
- Local: `http://localhost:{port}`
- Dev: `https://dev-api.kt-event-marketing.com/{service}/v1`
- Prod: `https://api.kt-event-marketing.com/{service}/v1`
---
## 3. 보안 스키마
### 3.1 JWT Bearer 인증
```yaml
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT Bearer 토큰 인증
**형식:** Authorization: Bearer {JWT_TOKEN}
**토큰 만료:** 7일
**Claims:**
- userId: 사용자 ID
- role: 사용자 역할 (OWNER)
- iat: 발급 시각
- exp: 만료 시각
```
### 3.2 전역 보안 적용
```yaml
security:
- BearerAuth: []
```
**적용 방법:**
- 인증이 필요한 모든 엔드포인트에 `security` 섹션 추가
- 공개 API (예: 로그인, 회원가입)는 엔드포인트 레벨에서 `security: []`로 오버라이드
---
## 4. 태그 구성
### 4.1 태그 정의 패턴
```yaml
tags:
- name: {Category Name}
description: {카테고리 설명 (한글)}
```
**태그 명명 규칙:**
- **영문 사용**: 명확한 영문 카테고리명
- **설명 한글**: description은 한글로 상세 설명
- **일관성 유지**: 유사 기능은 동일한 태그명 사용
**예시:**
```yaml
tags:
- name: Authentication
description: 인증 관련 API (로그인, 로그아웃, 회원가입)
- name: Profile
description: 프로필 관련 API (조회, 수정, 비밀번호 변경)
- name: Event Creation
description: 이벤트 생성 플로우
```
---
## 5. 엔드포인트 정의
### 5.1 엔드포인트 경로 규칙
**경로 패턴:**
```
/{resource}
/{resource}/{id}
/{resource}/{id}/{sub-resource}
```
**중요: `/api` prefix 사용 금지**
- ❌ 잘못된 예: `/api/users/register`
- ✅ 올바른 예: `/users/register`
API Gateway 또는 서버 URL에서 서비스 구분이 이루어지므로, 엔드포인트 경로에 `/api`를 포함하지 않습니다.
### 5.2 공통 엔드포인트 구조
```yaml
paths:
/{resource}:
{http-method}:
tags:
- {Tag Name}
summary: {짧은 한글 설명}
description: |
{상세 설명}
**유저스토리:** {UFR 코드}
**주요 기능:**
- {기능 1}
- {기능 2}
**처리 흐름:** (복잡한 로직인 경우)
1. {단계 1}
2. {단계 2}
**보안:** (보안 관련 엔드포인트인 경우)
- {보안 메커니즘}
operationId: {camelCase 메서드명}
x-user-story: {UFR 코드}
x-controller: {ControllerClass}.{methodName}
security:
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/{ParameterName}'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/{RequestSchema}'
examples:
{exampleName}:
summary: {예시 설명}
value: {...}
responses:
'{statusCode}':
description: {응답 설명}
content:
application/json:
schema:
$ref: '#/components/schemas/{ResponseSchema}'
examples:
{exampleName}:
summary: {예시 설명}
value: {...}
```
### 5.3 필수 항목
- `tags`: 1개 이상의 태그 지정
- `summary`: 한글로 간결하게 (10자 이내 권장)
- `description`: 마크다운 형식의 상세 설명
- 유저스토리 코드 명시
- 주요 기능 bullet points
- 복잡한 경우 처리 흐름 순서 작성
- 보안 관련 내용 (해당 시)
- `operationId`: camelCase 메서드명 (예: `getUserProfile`, `createEvent`)
- `x-user-story`: UFR 코드 (예: `UFR-USER-010`)
- `x-controller`: 컨트롤러 클래스와 메서드 (예: `UserController.getProfile`)
### 5.4 operationId 명명 규칙
```
{동사}{명사}
```
**동사 목록:**
- `get`: 조회
- `list`: 목록 조회
- `create`: 생성
- `update`: 수정
- `delete`: 삭제
- `register`: 등록
- `login`: 로그인
- `logout`: 로그아웃
- `select`: 선택
- `request`: 요청
- `publish`: 배포
- `end`: 종료
**예시:**
- `getUser`, `listEvents`, `createEvent`
- `updateProfile`, `deleteEvent`
- `registerUser`, `loginUser`, `logoutUser`
- `selectRecommendation`, `publishEvent`
---
## 6. 응답 구조
### 6.1 성공 응답 (Success Response)
**원칙: 직접 응답 (Direct Response)**
```yaml
responses:
'200':
description: {작업} 성공
content:
application/json:
schema:
$ref: '#/components/schemas/{ResponseSchema}'
```
**응답 스키마 예시:**
```yaml
UserProfileResponse:
type: object
required:
- userId
- userName
- email
properties:
userId:
type: integer
format: int64
description: 사용자 ID
example: 123
userName:
type: string
description: 사용자 이름
example: 홍길동
email:
type: string
format: email
description: 이메일 주소
example: hong@example.com
```
**예외: Wrapper가 필요한 경우 (메시지 전달 필요 시)**
```yaml
LogoutResponse:
type: object
required:
- success
- message
properties:
success:
type: boolean
description: 성공 여부
example: true
message:
type: string
description: 응답 메시지
example: 안전하게 로그아웃되었습니다
```
### 6.2 페이징 응답 (Pagination Response)
```yaml
{Resource}ListResponse:
type: object
required:
- content
- page
properties:
content:
type: array
items:
$ref: '#/components/schemas/{ResourceSummary}'
page:
$ref: '#/components/schemas/PageInfo'
PageInfo:
type: object
required:
- page
- size
- totalElements
- totalPages
properties:
page:
type: integer
description: 현재 페이지 번호
example: 0
size:
type: integer
description: 페이지 크기
example: 20
totalElements:
type: integer
description: 전체 요소 개수
example: 45
totalPages:
type: integer
description: 전체 페이지 개수
example: 3
```
---
## 7. 에러 응답 구조
### 7.1 표준 에러 응답 스키마
```yaml
ErrorResponse:
type: object
required:
- code
- message
- timestamp
properties:
code:
type: string
description: 에러 코드
example: USER_001
message:
type: string
description: 에러 메시지
example: 이미 가입된 전화번호입니다
timestamp:
type: string
format: date-time
description: 에러 발생 시각
example: 2025-10-22T10:30:00Z
details:
type: array
description: 상세 에러 정보 (선택 사항)
items:
type: string
example: ["필드명: 필수 항목입니다"]
```
**필수 필드:**
- `code`: 에러 코드 (서비스별 고유 코드)
- `message`: 사용자에게 표시할 에러 메시지 (한글)
- `timestamp`: 에러 발생 시각 (ISO 8601 형식)
**선택 필드:**
- `details`: 상세 에러 정보 배열 (validation 에러 등)
### 7.2 에러 코드 명명 규칙
```
{SERVICE}_{NUMBER}
```
**서비스 약어:**
- `USER`: User Service
- `EVENT`: Event Service
- `CONT`: Content Service
- `AI`: AI Service
- `PART`: Participation Service
- `DIST`: Distribution Service
- `ANAL`: Analytics Service
- `AUTH`: 인증 관련 (공통)
- `VALIDATION_ERROR`: 입력 검증 오류 (공통)
**예시:**
- `USER_001`: 중복 사용자
- `USER_002`: 사업자번호 검증 실패
- `AUTH_001`: 인증 실패
- `AUTH_002`: 유효하지 않은 토큰
- `VALIDATION_ERROR`: 입력 검증 오류
### 7.3 공통 에러 응답 정의
```yaml
components:
responses:
BadRequest:
description: 잘못된 요청
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
validationError:
summary: 입력 검증 오류
value:
code: VALIDATION_ERROR
message: 요청 파라미터가 올바르지 않습니다
timestamp: 2025-10-22T10:30:00Z
details:
- "필드명: 필수 항목입니다"
Unauthorized:
description: 인증 실패
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
authFailed:
summary: 인증 실패
value:
code: AUTH_001
message: 인증에 실패했습니다
timestamp: 2025-10-22T10:30:00Z
Forbidden:
description: 권한 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
forbidden:
summary: 권한 없음
value:
code: AUTH_003
message: 해당 리소스에 접근할 권한이 없습니다
timestamp: 2025-10-22T10:30:00Z
NotFound:
description: 리소스를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
notFound:
summary: 리소스 없음
value:
code: NOT_FOUND
message: 요청한 리소스를 찾을 수 없습니다
timestamp: 2025-10-22T10:30:00Z
InternalServerError:
description: 서버 내부 오류
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
serverError:
summary: 서버 오류
value:
code: INTERNAL_SERVER_ERROR
message: 서버 내부 오류가 발생했습니다
timestamp: 2025-10-22T10:30:00Z
```
### 7.4 엔드포인트별 에러 응답 적용
```yaml
responses:
'400':
$ref: '#/components/responses/BadRequest'
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
```
**특수 에러 (비즈니스 로직 에러):**
```yaml
'409':
description: 비즈니스 로직 충돌
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
duplicateUser:
summary: 중복 사용자
value:
code: USER_001
message: 이미 가입된 전화번호입니다
timestamp: 2025-10-22T10:30:00Z
```
---
## 8. 스키마 정의
### 8.1 스키마 명명 규칙
**Request 스키마:**
```
{Action}{Resource}Request
```
예: `RegisterRequest`, `LoginRequest`, `CreateEventRequest`
**Response 스키마:**
```
{Resource}{Type}Response
```
예: `UserProfileResponse`, `EventListResponse`, `EventDetailResponse`
**공통 모델:**
```
{Resource}{Type}
```
예: `EventSummary`, `GeneratedImage`, `PageInfo`
### 8.2 스키마 작성 원칙
**필수 항목:**
- `type`: 객체 타입 (object, array, string 등)
- `required`: 필수 필드 목록
- `properties`: 각 필드 정의
- `type`: 필드 타입
- `description`: 필드 설명 (한글)
- `example`: 예시 값
**선택 항목:**
- `format`: 특수 형식 (date, date-time, email, uri, uuid, int64 등)
- `pattern`: 정규식 패턴 (전화번호, 사업자번호 등)
- `minLength`, `maxLength`: 문자열 길이 제한
- `minimum`, `maximum`: 숫자 범위 제한
- `enum`: 허용 값 목록
**예시:**
```yaml
RegisterRequest:
type: object
required:
- name
- phoneNumber
- email
- password
properties:
name:
type: string
minLength: 2
maxLength: 50
description: 사용자 이름 (2자 이상, 한글/영문)
example: 홍길동
phoneNumber:
type: string
pattern: '^010\d{8}$'
description: 휴대폰 번호 (010XXXXXXXX)
example: "01012345678"
email:
type: string
format: email
maxLength: 100
description: 이메일 주소
example: hong@example.com
password:
type: string
minLength: 8
maxLength: 100
description: 비밀번호 (8자 이상, 영문/숫자/특수문자 포함)
example: "Password123!"
```
### 8.3 날짜/시간 형식
**날짜:** `format: date`, 형식 `YYYY-MM-DD`
```yaml
startDate:
type: string
format: date
description: 시작일
example: "2025-03-01"
```
**날짜/시간:** `format: date-time`, 형식 `ISO 8601`
```yaml
createdAt:
type: string
format: date-time
description: 생성일시
example: 2025-10-22T10:30:00Z
```
### 8.4 ID 형식
**UUID:**
```yaml
eventId:
type: string
format: uuid
description: 이벤트 ID
example: "550e8400-e29b-41d4-a716-446655440000"
```
**정수 ID:**
```yaml
userId:
type: integer
format: int64
description: 사용자 ID
example: 123
```
---
## 9. 메타데이터 주석
### 9.1 필수 메타데이터
```yaml
x-user-story: {UFR 코드}
x-controller: {ControllerClass}.{methodName}
```
**x-user-story:**
- 유저스토리 코드 명시
- 여러 유저스토리와 관련된 경우 콤마로 구분
- 예: `UFR-USER-010`, `UFR-EVENT-010, UFR-EVENT-070`
**x-controller:**
- 컨트롤러 클래스와 메서드 매핑
- 백엔드 개발 시 참조
- 예: `UserController.registerUser`, `EventController.getEvents`
### 9.2 선택 메타데이터 (필요 시)
```yaml
x-internal: true # 내부 API 표시
x-async: true # 비동기 처리 표시
```
---
## 10. 기술 명세 섹션
### 10.1 x-technical-specifications
**비동기 처리 서비스 (AI, Content 등):**
```yaml
x-technical-specifications:
async-processing:
message-queue: Kafka
topics:
request: ai.recommendation.request
response: ai.recommendation.response
job-tracking: Redis (TTL 24h)
timeout: 300s
resilience:
circuit-breaker:
failure-threshold: 5
timeout: 10s
half-open-requests: 3
retry:
max-attempts: 3
backoff: exponential
initial-interval: 1s
max-interval: 10s
fallback:
strategy: cached-result
caching:
provider: Redis
ttl: 7d
key-pattern: "content:event:{eventDraftId}"
external-apis:
- name: Claude API
endpoint: https://api.anthropic.com/v1/messages
timeout: 60s
circuit-breaker: true
- name: GPT-4 API
endpoint: https://api.openai.com/v1/chat/completions
timeout: 60s
circuit-breaker: true
```
**동기 처리 서비스:**
```yaml
x-technical-specifications:
database:
type: PostgreSQL
connection-pool:
min: 10
max: 50
timeout: 30s
caching:
provider: Redis
ttl: 30m
key-pattern: "user:{userId}"
security:
authentication: JWT Bearer
password-hashing: bcrypt
encryption:
algorithm: AES-256-GCM
fields: [businessNumber]
```
### 10.2 적용 기준
**필수 포함 서비스:**
- Content Service: 비동기 처리, Kafka, 외부 API 통합
- AI Service: 비동기 처리, Kafka, Claude/GPT 통합
**선택 포함 서비스:**
- User Service: 보안 관련 명세
- Event Service: 오케스트레이션 패턴
- Participation Service: 대용량 트래픽 대비 캐싱
---
## 11. 예제 작성
### 11.1 Request/Response 예제 원칙
**모든 requestBody와 주요 response에 예제 필수:**
```yaml
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
examples:
restaurant:
summary: 음식점 회원가입 예시
value:
name: 홍길동
phoneNumber: "01012345678"
email: hong@example.com
password: "Password123!"
storeName: 맛있는집
industry: 음식점
cafe:
summary: 카페 회원가입 예시
value:
name: 김철수
phoneNumber: "01087654321"
email: kim@example.com
password: "SecurePass456!"
storeName: 아메리카노 카페
industry: 카페
```
**성공 응답 예제:**
```yaml
responses:
'200':
description: 프로필 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileResponse'
examples:
success:
summary: 프로필 조회 성공 응답
value:
userId: 123
userName: 홍길동
phoneNumber: "01012345678"
email: hong@example.com
```
**에러 응답 예제:**
```yaml
responses:
'400':
description: 잘못된 요청
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
duplicateUser:
summary: 중복 사용자
value:
code: USER_001
message: 이미 가입된 전화번호입니다
timestamp: 2025-10-22T10:30:00Z
invalidBusinessNumber:
summary: 사업자번호 검증 실패
value:
code: USER_002
message: 유효하지 않은 사업자번호입니다
timestamp: 2025-10-22T10:30:00Z
```
### 11.2 예제 명명 규칙
- `success`: 성공 케이스
- `{errorType}`: 에러 케이스 (예: `duplicateUser`, `validationError`)
- `{scenario}`: 시나리오별 예제 (예: `restaurant`, `cafe`)
---
## 12. 체크리스트
API 명세서 작성 시 아래 체크리스트를 확인하세요:
### 기본 정보
- [ ] OpenAPI 버전 3.0.3 명시
- [ ] info.title에 서비스명 포함
- [ ] info.description에 주요 기능 목록 포함
- [ ] info.version 1.0.0
- [ ] contact 정보 포함
### 서버 및 보안
- [ ] servers에 Local, Dev, Prod 정의
- [ ] 포트 번호 정확히 할당
- [ ] components.securitySchemes에 BearerAuth 정의
- [ ] 인증 필요한 엔드포인트에 security 적용
### 엔드포인트
- [ ] 모든 엔드포인트에 tags 지정
- [ ] summary와 description 작성 (한글)
- [ ] operationId camelCase로 작성
- [ ] x-user-story UFR 코드 명시
- [ ] x-controller 매핑 정보 포함
### 스키마
- [ ] Request/Response 스키마 명명 규칙 준수
- [ ] required 필드 명시
- [ ] 모든 properties에 description과 example 포함
- [ ] 적절한 format 사용 (date, date-time, email, uuid 등)
### 응답 구조
- [ ] ErrorResponse 표준 스키마 사용
- [ ] 공통 에러 응답 ($ref) 활용
- [ ] 에러 코드 명명 규칙 준수
- [ ] 페이징 응답에 PageInfo 사용
### 예제
- [ ] requestBody에 최소 1개 이상 예제
- [ ] 주요 response에 success 예제
- [ ] 주요 에러 케이스에 예제
### 기술 명세 (해당 시)
- [ ] 비동기 처리 서비스: x-technical-specifications 포함
- [ ] Kafka 토픽, Redis 캐싱 정보 명시
- [ ] 외부 API 연동 정보 포함
---
## 13. 참고 자료
### 서비스별 API 명세서
- User Service API: `/design/backend/api/user-service-api.yaml`
- Event Service API: `/design/backend/api/event-service-api.yaml`
- Content Service API: `/design/backend/api/content-service-api.yaml`
- AI Service API: `/design/backend/api/ai-service-api.yaml`
- Participation Service API: `/design/backend/api/participation-service-api.yaml`
- Distribution Service API: `/design/backend/api/distribution-service-api.yaml`
- Analytics Service API: `/design/backend/api/analytics-service-api.yaml`
### OpenAPI 3.0.3 공식 문서
- https://swagger.io/specification/
### 프로젝트 아키텍처
- High-Level Architecture: `/design/high-level-architecture.md`
- Logical Architecture: `/design/backend/logical/`
---
**문서 버전:** 1.0.0
**최종 수정일:** 2025-10-23
**작성자:** Digital Garage Team

View File

@ -0,0 +1,849 @@
openapi: 3.0.3
info:
title: AI Service API
description: |
KT AI 기반 소상공인 이벤트 자동 생성 서비스 - AI Service
## 서비스 개요
- Kafka를 통한 비동기 AI 추천 처리
- Claude API / GPT-4 API 연동
- Redis 기반 결과 캐싱 (TTL 24시간)
## 처리 흐름
1. Event Service가 Kafka Topic에 Job 메시지 발행
2. AI Service가 메시지 구독 및 처리
3. 트렌드 분석 수행 (Claude/GPT-4 API)
4. 3가지 이벤트 추천안 생성
5. 결과를 Redis에 저장 (TTL 24시간)
6. Job 상태를 Redis에 업데이트
## 외부 API 통합
- **Claude API / GPT-4 API**: 트렌드 분석 및 이벤트 추천
- **Circuit Breaker**: 5분 타임아웃, Fallback to 캐시
- **Retry**: 최대 3회, Exponential Backoff
version: 1.0.0
contact:
name: Digital Garage Team
email: support@kt-event-marketing.com
servers:
- url: http://localhost:8083
description: Local Development Server
- url: https://dev-api.kt-event-marketing.com/ai/v1
description: Development Server
- url: https://api.kt-event-marketing.com/ai/v1
description: Production Server
tags:
- name: Health Check
description: 서비스 상태 확인
- name: Internal API
description: 내부 서비스 간 통신용 API
- name: Kafka Consumer
description: 비동기 작업 처리 (문서화만)
paths:
/health:
get:
tags:
- Health Check
summary: 서비스 헬스체크
description: AI Service 상태 및 외부 연동 확인
operationId: healthCheck
x-user-story: System
x-controller: HealthController
responses:
'200':
description: 서비스 정상
content:
application/json:
schema:
$ref: '#/components/schemas/HealthCheckResponse'
example:
status: UP
timestamp: "2025-10-23T10:30:00Z"
services:
kafka: UP
redis: UP
claude_api: UP
gpt4_api: UP
circuit_breaker: CLOSED
/internal/jobs/{jobId}/status:
get:
tags:
- Internal API
summary: 작업 상태 조회
description: Redis에 저장된 AI 추천 작업 상태 조회 (Event Service에서 호출)
operationId: getJobStatus
x-user-story: UFR-AI-010
x-controller: InternalJobController
parameters:
- name: jobId
in: path
required: true
schema:
type: string
description: Job ID
example: "job-ai-evt001-20251023103000"
responses:
'200':
description: 작업 상태 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/JobStatusResponse'
examples:
processing:
summary: 처리 중
value:
jobId: "job-ai-evt001-20251023103000"
status: "PROCESSING"
progress: 50
message: "AI 추천 생성 중"
createdAt: "2025-10-23T10:30:00Z"
startedAt: "2025-10-23T10:30:05Z"
completed:
summary: 완료
value:
jobId: "job-ai-evt001-20251023103000"
status: "COMPLETED"
progress: 100
message: "AI 추천 완료"
createdAt: "2025-10-23T10:30:00Z"
startedAt: "2025-10-23T10:30:05Z"
completedAt: "2025-10-23T10:35:00Z"
processingTimeMs: 295000
failed:
summary: 실패
value:
jobId: "job-ai-evt001-20251023103000"
status: "FAILED"
progress: 0
message: "Claude API timeout"
errorMessage: "Claude API timeout after 5 minutes"
createdAt: "2025-10-23T10:30:00Z"
startedAt: "2025-10-23T10:30:05Z"
failedAt: "2025-10-23T10:35:05Z"
retryCount: 3
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
/internal/recommendations/{eventId}:
get:
tags:
- Internal API
summary: AI 추천 결과 조회
description: Redis에 캐시된 AI 추천 결과 조회 (Event Service에서 호출)
operationId: getRecommendation
x-user-story: UFR-AI-010
x-controller: InternalRecommendationController
parameters:
- name: eventId
in: path
required: true
schema:
type: string
description: 이벤트 ID
example: "evt-001"
responses:
'200':
description: 추천 결과 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/AIRecommendationResult'
example:
eventId: "evt-001"
trendAnalysis:
industryTrends:
- keyword: "프리미엄 디저트"
relevance: 0.85
description: "고급 디저트 카페 트렌드 증가"
regionalTrends:
- keyword: "핫플레이스"
relevance: 0.78
description: "강남 신논현역 주변 유동인구 증가"
seasonalTrends:
- keyword: "가을 시즌"
relevance: 0.92
description: "가을 시즌 한정 메뉴 선호도 증가"
recommendations:
- optionNumber: 1
concept: "프리미엄 경험형"
title: "가을 한정 시그니처 디저트 페어링 이벤트"
description: "가을 제철 재료를 활용한 시그니처 디저트와 음료 페어링 체험"
targetAudience: "20-30대 여성, SNS 활동적인 고객"
duration:
recommendedDays: 14
recommendedPeriod: "10월 중순 ~ 11월 초"
mechanics:
type: "EXPERIENCE"
details: "디저트+음료 페어링 세트 주문 시 인스타그램 업로드 고객에게 다음 방문 시 사용 가능한 10% 할인권 제공"
promotionChannels:
- "Instagram"
- "카카오톡 채널"
- "네이버 플레이스"
estimatedCost:
min: 300000
max: 500000
breakdown:
material: 200000
promotion: 150000
discount: 150000
expectedMetrics:
newCustomers:
min: 50
max: 80
repeatVisits:
min: 30
max: 50
revenueIncrease:
min: 15.0
max: 25.0
roi:
min: 120.0
max: 180.0
socialEngagement:
estimatedPosts: 100
estimatedReach: 5000
differentiator: "프리미엄 경험 제공으로 고객 만족도와 SNS 바이럴 효과 극대화"
generatedAt: "2025-10-23T10:35:00Z"
expiresAt: "2025-10-24T10:35:00Z"
aiProvider: "CLAUDE"
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'
components:
schemas:
# ==================== Health Check ====================
HealthCheckResponse:
type: object
description: 서비스 헬스체크 응답
required:
- status
- timestamp
- services
properties:
status:
type: string
enum: [UP, DOWN, DEGRADED]
description: 전체 서비스 상태
example: UP
timestamp:
type: string
format: date-time
description: 체크 시각
example: "2025-10-23T10:30:00Z"
services:
type: object
description: 개별 서비스 상태
required:
- kafka
- redis
- claude_api
- circuit_breaker
properties:
kafka:
type: string
enum: [UP, DOWN]
description: Kafka 연결 상태
example: UP
redis:
type: string
enum: [UP, DOWN]
description: Redis 연결 상태
example: UP
claude_api:
type: string
enum: [UP, DOWN, CIRCUIT_OPEN]
description: Claude API 상태
example: UP
gpt4_api:
type: string
enum: [UP, DOWN, CIRCUIT_OPEN]
description: GPT-4 API 상태 (선택)
example: UP
circuit_breaker:
type: string
enum: [CLOSED, OPEN, HALF_OPEN]
description: Circuit Breaker 상태
example: CLOSED
# ==================== Kafka Job Message (문서화만) ====================
KafkaAIJobMessage:
type: object
description: |
**Kafka Topic**: `ai-event-generation-job`
**Consumer Group**: `ai-service-consumers`
**처리 방식**: 비동기
**최대 처리 시간**: 5분
AI 이벤트 생성 요청 메시지
required:
- jobId
- eventId
- objective
- industry
- region
properties:
jobId:
type: string
description: Job 고유 ID
example: "job-ai-evt001-20251023103000"
eventId:
type: string
description: 이벤트 ID (Event Service에서 생성)
example: "evt-001"
objective:
type: string
description: 이벤트 목적
enum:
- "신규 고객 유치"
- "재방문 유도"
- "매출 증대"
- "브랜드 인지도 향상"
example: "신규 고객 유치"
industry:
type: string
description: 업종
example: "음식점"
region:
type: string
description: 지역 (시/구/동)
example: "서울 강남구"
storeName:
type: string
description: 매장명 (선택)
example: "맛있는 고깃집"
targetAudience:
type: string
description: 목표 고객층 (선택)
example: "20-30대 여성"
budget:
type: integer
description: 예산 (원) (선택)
example: 500000
requestedAt:
type: string
format: date-time
description: 요청 시각
example: "2025-10-23T10:30:00Z"
# ==================== AI Recommendation Result ====================
AIRecommendationResult:
type: object
description: |
**Redis Key**: `ai:recommendation:{eventId}`
**TTL**: 86400초 (24시간)
AI 이벤트 추천 결과
required:
- eventId
- trendAnalysis
- recommendations
- generatedAt
- aiProvider
properties:
eventId:
type: string
description: 이벤트 ID
example: "evt-001"
trendAnalysis:
$ref: '#/components/schemas/TrendAnalysis'
recommendations:
type: array
description: 추천 이벤트 기획안 (3개)
minItems: 3
maxItems: 3
items:
$ref: '#/components/schemas/EventRecommendation'
generatedAt:
type: string
format: date-time
description: 생성 시각
example: "2025-10-23T10:35:00Z"
expiresAt:
type: string
format: date-time
description: 캐시 만료 시각 (생성 시각 + 24시간)
example: "2025-10-24T10:35:00Z"
aiProvider:
type: string
enum: [CLAUDE, GPT4]
description: 사용된 AI 제공자
example: "CLAUDE"
TrendAnalysis:
type: object
description: 트렌드 분석 결과 (업종/지역/시즌)
required:
- industryTrends
- regionalTrends
- seasonalTrends
properties:
industryTrends:
type: array
description: 업종 트렌드 키워드 (최대 5개)
maxItems: 5
items:
type: object
required:
- keyword
- relevance
- description
properties:
keyword:
type: string
description: 트렌드 키워드
example: "프리미엄 디저트"
relevance:
type: number
format: float
minimum: 0
maximum: 1
description: 연관도 (0-1)
example: 0.85
description:
type: string
description: 트렌드 설명
example: "고급 디저트 카페 트렌드 증가"
regionalTrends:
type: array
description: 지역 트렌드 키워드 (최대 5개)
maxItems: 5
items:
type: object
required:
- keyword
- relevance
- description
properties:
keyword:
type: string
example: "핫플레이스"
relevance:
type: number
format: float
minimum: 0
maximum: 1
example: 0.78
description:
type: string
example: "강남 신논현역 주변 유동인구 증가"
seasonalTrends:
type: array
description: 시즌 트렌드 키워드 (최대 5개)
maxItems: 5
items:
type: object
required:
- keyword
- relevance
- description
properties:
keyword:
type: string
example: "가을 시즌"
relevance:
type: number
format: float
minimum: 0
maximum: 1
example: 0.92
description:
type: string
example: "가을 시즌 한정 메뉴 선호도 증가"
EventRecommendation:
type: object
description: 이벤트 추천안 (차별화된 3가지 옵션)
required:
- optionNumber
- concept
- title
- description
- targetAudience
- duration
- mechanics
- promotionChannels
- estimatedCost
- expectedMetrics
- differentiator
properties:
optionNumber:
type: integer
description: 옵션 번호 (1-3)
minimum: 1
maximum: 3
example: 1
concept:
type: string
description: 이벤트 컨셉
example: "프리미엄 경험형"
title:
type: string
description: 이벤트 제목
maxLength: 100
example: "가을 한정 시그니처 디저트 페어링 이벤트"
description:
type: string
description: 이벤트 설명
maxLength: 500
example: "가을 제철 재료를 활용한 시그니처 디저트와 음료 페어링 체험"
targetAudience:
type: string
description: 목표 고객층
example: "20-30대 여성, SNS 활동적인 고객"
duration:
type: object
description: 이벤트 기간
required:
- recommendedDays
properties:
recommendedDays:
type: integer
description: 권장 진행 일수
minimum: 1
example: 14
recommendedPeriod:
type: string
description: 권장 진행 시기
example: "10월 중순 ~ 11월 초"
mechanics:
type: object
description: 이벤트 메커니즘
required:
- type
- details
properties:
type:
type: string
enum: [DISCOUNT, GIFT, STAMP, EXPERIENCE, LOTTERY, COMBO]
description: 이벤트 유형
example: "EXPERIENCE"
details:
type: string
description: 상세 메커니즘
maxLength: 500
example: "디저트+음료 페어링 세트 주문 시 인스타그램 업로드 고객에게 다음 방문 시 사용 가능한 10% 할인권 제공"
promotionChannels:
type: array
description: 추천 홍보 채널 (최대 5개)
maxItems: 5
items:
type: string
example:
- "Instagram"
- "카카오톡 채널"
- "네이버 플레이스"
estimatedCost:
type: object
description: 예상 비용
required:
- min
- max
properties:
min:
type: integer
description: 최소 비용 (원)
minimum: 0
example: 300000
max:
type: integer
description: 최대 비용 (원)
minimum: 0
example: 500000
breakdown:
type: object
description: 비용 구성
properties:
material:
type: integer
description: 재료비 (원)
example: 200000
promotion:
type: integer
description: 홍보비 (원)
example: 150000
discount:
type: integer
description: 할인 비용 (원)
example: 150000
expectedMetrics:
$ref: '#/components/schemas/ExpectedMetrics'
differentiator:
type: string
description: 다른 옵션과의 차별점
maxLength: 500
example: "프리미엄 경험 제공으로 고객 만족도와 SNS 바이럴 효과 극대화, 브랜드 이미지 향상에 집중"
ExpectedMetrics:
type: object
description: 예상 성과 지표
required:
- newCustomers
- revenueIncrease
- roi
properties:
newCustomers:
type: object
description: 신규 고객 수
required:
- min
- max
properties:
min:
type: integer
minimum: 0
example: 50
max:
type: integer
minimum: 0
example: 80
repeatVisits:
type: object
description: 재방문 고객 수 (선택)
properties:
min:
type: integer
minimum: 0
example: 30
max:
type: integer
minimum: 0
example: 50
revenueIncrease:
type: object
description: 매출 증가율 (%)
required:
- min
- max
properties:
min:
type: number
format: float
minimum: 0
example: 15.0
max:
type: number
format: float
minimum: 0
example: 25.0
roi:
type: object
description: ROI - 투자 대비 수익률 (%)
required:
- min
- max
properties:
min:
type: number
format: float
minimum: 0
example: 120.0
max:
type: number
format: float
minimum: 0
example: 180.0
socialEngagement:
type: object
description: SNS 참여도 (선택)
properties:
estimatedPosts:
type: integer
description: 예상 게시물 수
minimum: 0
example: 100
estimatedReach:
type: integer
description: 예상 도달 수
minimum: 0
example: 5000
# ==================== Job Status ====================
JobStatusResponse:
type: object
description: |
**Redis Key**: `ai:job:status:{jobId}`
**TTL**: 86400초 (24시간)
작업 상태 응답
required:
- jobId
- status
- progress
- message
- createdAt
properties:
jobId:
type: string
description: Job ID
example: "job-ai-evt001-20251023103000"
status:
type: string
enum: [PENDING, PROCESSING, COMPLETED, FAILED]
description: 작업 상태
example: "COMPLETED"
progress:
type: integer
minimum: 0
maximum: 100
description: 진행률 (%)
example: 100
message:
type: string
description: 상태 메시지
example: "AI 추천 완료"
eventId:
type: string
description: 이벤트 ID
example: "evt-001"
createdAt:
type: string
format: date-time
description: 작업 생성 시각
example: "2025-10-23T10:30:00Z"
startedAt:
type: string
format: date-time
description: 작업 시작 시각
example: "2025-10-23T10:30:05Z"
completedAt:
type: string
format: date-time
description: 작업 완료 시각 (완료 시)
example: "2025-10-23T10:35:00Z"
failedAt:
type: string
format: date-time
description: 작업 실패 시각 (실패 시)
example: "2025-10-23T10:35:05Z"
errorMessage:
type: string
description: 에러 메시지 (실패 시)
example: "Claude API timeout after 5 minutes"
retryCount:
type: integer
description: 재시도 횟수
minimum: 0
example: 0
processingTimeMs:
type: integer
description: 처리 시간 (밀리초)
minimum: 0
example: 295000
# ==================== Error Response ====================
ErrorResponse:
type: object
description: 에러 응답
required:
- code
- message
- timestamp
properties:
code:
type: string
description: 에러 코드
enum:
- AI_SERVICE_ERROR
- JOB_NOT_FOUND
- RECOMMENDATION_NOT_FOUND
- REDIS_ERROR
- KAFKA_ERROR
- CIRCUIT_BREAKER_OPEN
- INTERNAL_ERROR
example: "JOB_NOT_FOUND"
message:
type: string
description: 에러 메시지
example: "작업을 찾을 수 없습니다"
timestamp:
type: string
format: date-time
description: 에러 발생 시각
example: "2025-10-23T10:30:00Z"
details:
type: object
description: 추가 에러 상세
additionalProperties: true
example:
jobId: "job-ai-evt001-20251023103000"
responses:
NotFound:
description: 리소스를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "JOB_NOT_FOUND"
message: "작업을 찾을 수 없습니다"
timestamp: "2025-10-23T10:30:00Z"
InternalServerError:
description: 서버 내부 오류
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "INTERNAL_ERROR"
message: "서버 내부 오류가 발생했습니다"
timestamp: "2025-10-23T10:30:00Z"
# ==================== 기술 구성 문서화 ====================
x-technical-specifications:
circuit-breaker:
claude-api:
failureThreshold: 5
successThreshold: 2
timeout: 300000
resetTimeout: 60000
fallbackStrategy: CACHED_RECOMMENDATION
gpt4-api:
failureThreshold: 5
successThreshold: 2
timeout: 300000
resetTimeout: 60000
fallbackStrategy: CACHED_RECOMMENDATION
redis-cache:
patterns:
recommendation: "ai:recommendation:{eventId}"
jobStatus: "ai:job:status:{jobId}"
fallback: "ai:fallback:{industry}:{region}"
ttl:
recommendation: 86400
jobStatus: 86400
fallback: 604800
kafka:
topics:
input: "ai-event-generation-job"
consumer:
groupId: "ai-service-consumers"
maxRetries: 3
retryBackoffMs: 5000
maxPollRecords: 10
sessionTimeoutMs: 30000
external-apis:
claude:
endpoint: "https://api.anthropic.com/v1/messages"
model: "claude-3-5-sonnet-20241022"
maxTokens: 4096
timeout: 300000
gpt4:
endpoint: "https://api.openai.com/v1/chat/completions"
model: "gpt-4-turbo-preview"
maxTokens: 4096
timeout: 300000

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,651 @@
openapi: 3.0.3
info:
title: Distribution Service API
description: |
KT AI 기반 소상공인 이벤트 자동 생성 서비스의 다중 채널 배포 관리 API
## 주요 기능
- 다중 채널 동시 배포 (우리동네TV, 링고비즈, 지니TV, SNS)
- 배포 상태 실시간 모니터링
- Circuit Breaker 기반 장애 격리
- Retry 패턴 및 Fallback 처리
## 배포 채널
- **우리동네TV**: 영상 콘텐츠 업로드
- **링고비즈**: 연결음 업데이트
- **지니TV**: 광고 등록
- **SNS**: Instagram, Naver Blog, Kakao Channel
## Resilience 패턴
- Circuit Breaker: 채널별 독립적 장애 격리
- Retry: 지수 백오프 (1s, 2s, 4s) 최대 3회
- Bulkhead: 리소스 격리
- Fallback: 실패 채널 스킵 및 알림
version: 1.0.0
contact:
name: Digital Garage Team
email: support@kt-event-marketing.com
servers:
- url: http://localhost:8085
description: Local Development Server
- url: https://dev-api.kt-event-marketing.com/distribution/v1
description: Development Server
- url: https://api.kt-event-marketing.com/distribution/v1
description: Production Server
tags:
- name: Distribution
description: 다중 채널 배포 관리
- name: Monitoring
description: 배포 상태 모니터링
paths:
/distribution/distribute:
post:
tags:
- Distribution
summary: 다중 채널 배포 요청
description: |
이벤트 콘텐츠를 선택된 채널들에 동시 배포합니다.
## 처리 흐름
1. 배포 요청 검증 (이벤트 ID, 채널 목록, 콘텐츠 데이터)
2. 채널별 병렬 배포 실행 (1분 이내 완료 목표)
3. Circuit Breaker로 장애 채널 격리
4. 실패 시 Retry (지수 백오프: 1s, 2s, 4s)
5. Fallback: 실패 채널 스킵 및 알림
6. 배포 결과 집계 및 로그 저장
7. DistributionCompleted 이벤트 Kafka 발행
## Resilience 처리
- 각 채널별 독립적인 Circuit Breaker 적용
- 최대 3회 재시도 (지수 백오프)
- 일부 채널 실패 시에도 성공 채널은 유지
- 실패 채널 정보는 응답에 포함
x-user-story: UFR-DIST-010
x-controller: DistributionController
operationId: distributeToChannels
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DistributionRequest'
examples:
multiChannel:
summary: 다중 채널 배포 예시
value:
eventId: "evt-12345"
channels:
- type: "WOORIDONGNE_TV"
config:
radius: "1km"
timeSlots:
- "weekday_evening"
- "weekend_lunch"
- type: "INSTAGRAM"
config:
scheduledTime: "2025-11-01T10:00:00Z"
- type: "NAVER_BLOG"
config:
scheduledTime: "2025-11-01T10:30:00Z"
contentUrls:
instagram: "https://cdn.example.com/images/event-instagram.jpg"
naverBlog: "https://cdn.example.com/images/event-naver.jpg"
kakaoChannel: "https://cdn.example.com/images/event-kakao.jpg"
responses:
'200':
description: 배포 완료
content:
application/json:
schema:
$ref: '#/components/schemas/DistributionResponse'
examples:
allSuccess:
summary: 모든 채널 배포 성공
value:
distributionId: "dist-12345"
eventId: "evt-12345"
status: "COMPLETED"
completedAt: "2025-11-01T09:00:00Z"
results:
- channel: "WOORIDONGNE_TV"
status: "SUCCESS"
distributionId: "wtv-uuid-12345"
estimatedViews: 1000
message: "배포 완료"
- channel: "INSTAGRAM"
status: "SUCCESS"
postUrl: "https://instagram.com/p/generated-post-id"
postId: "ig-post-12345"
message: "게시 완료"
- channel: "NAVER_BLOG"
status: "SUCCESS"
postUrl: "https://blog.naver.com/store123/generated-post"
message: "게시 완료"
'400':
description: 잘못된 요청
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidEventId:
summary: 유효하지 않은 이벤트 ID
value:
error: "BAD_REQUEST"
message: "유효하지 않은 이벤트 ID입니다"
timestamp: "2025-11-01T09:00:00Z"
noChannels:
summary: 선택된 채널 없음
value:
error: "BAD_REQUEST"
message: "최소 1개 이상의 채널을 선택해야 합니다"
timestamp: "2025-11-01T09:00:00Z"
'404':
description: 이벤트를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
eventNotFound:
summary: 존재하지 않는 이벤트
value:
error: "NOT_FOUND"
message: "이벤트를 찾을 수 없습니다: evt-12345"
timestamp: "2025-11-01T09:00:00Z"
'500':
description: 서버 내부 오류
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
internalError:
summary: 서버 오류
value:
error: "INTERNAL_SERVER_ERROR"
message: "배포 처리 중 오류가 발생했습니다"
timestamp: "2025-11-01T09:00:00Z"
/distribution/{eventId}/status:
get:
tags:
- Monitoring
summary: 배포 상태 조회
description: |
특정 이벤트의 배포 상태를 실시간으로 조회합니다.
## 조회 정보
- 전체 배포 상태 (진행중, 완료, 부분성공, 실패)
- 채널별 배포 상태 및 결과
- 실패 채널 상세 정보 (오류 유형, 재시도 횟수)
- 배포 시작/완료 시간 및 소요 시간
- 외부 채널 ID 및 배포 URL
## 상태 값
- **IN_PROGRESS**: 배포 진행 중
- **COMPLETED**: 모든 채널 배포 완료
- **PARTIAL_SUCCESS**: 일부 채널 배포 성공
- **FAILED**: 모든 채널 배포 실패
x-user-story: UFR-DIST-020
x-controller: DistributionController
operationId: getDistributionStatus
parameters:
- name: eventId
in: path
required: true
description: 이벤트 ID
schema:
type: string
example: "evt-12345"
responses:
'200':
description: 배포 상태 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/DistributionStatusResponse'
examples:
completed:
summary: 배포 완료 상태
value:
eventId: "evt-12345"
overallStatus: "COMPLETED"
completedAt: "2025-11-01T09:00:00Z"
channels:
- channel: "WOORIDONGNE_TV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
completedAt: "2025-11-01T09:00:00Z"
- channel: "RINGO_BIZ"
status: "COMPLETED"
updateTimestamp: "2025-11-01T09:00:00Z"
- channel: "GENIE_TV"
status: "COMPLETED"
adId: "gtv-uuid-12345"
impressionSchedule:
- "2025-11-01 18:00-20:00"
- "2025-11-02 12:00-14:00"
- channel: "INSTAGRAM"
status: "COMPLETED"
postUrl: "https://instagram.com/p/generated-post-id"
postId: "ig-post-12345"
- channel: "NAVER_BLOG"
status: "COMPLETED"
postUrl: "https://blog.naver.com/store123/generated-post"
- channel: "KAKAO_CHANNEL"
status: "COMPLETED"
messageId: "kakao-msg-12345"
inProgress:
summary: 배포 진행중 상태
value:
eventId: "evt-12345"
overallStatus: "IN_PROGRESS"
startedAt: "2025-11-01T08:58:00Z"
channels:
- channel: "WOORIDONGNE_TV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
- channel: "INSTAGRAM"
status: "IN_PROGRESS"
progress: 50
- channel: "NAVER_BLOG"
status: "PENDING"
partialFailure:
summary: 일부 채널 실패 상태
value:
eventId: "evt-12345"
overallStatus: "PARTIAL_FAILURE"
completedAt: "2025-11-01T09:00:00Z"
channels:
- channel: "WOORIDONGNE_TV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
- channel: "INSTAGRAM"
status: "FAILED"
errorMessage: "Instagram API 타임아웃"
retries: 3
lastRetryAt: "2025-11-01T08:59:30Z"
- channel: "NAVER_BLOG"
status: "COMPLETED"
postUrl: "https://blog.naver.com/store123/generated-post"
'404':
description: 배포 이력을 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
notFound:
summary: 배포 이력 없음
value:
error: "NOT_FOUND"
message: "배포 이력을 찾을 수 없습니다: evt-12345"
timestamp: "2025-11-01T09:00:00Z"
'500':
description: 서버 내부 오류
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
schemas:
DistributionRequest:
type: object
required:
- eventId
- channels
- contentUrls
properties:
eventId:
type: string
description: 이벤트 ID
example: "evt-12345"
channels:
type: array
description: 배포할 채널 목록
minItems: 1
items:
$ref: '#/components/schemas/ChannelConfig'
contentUrls:
type: object
description: 플랫폼별 콘텐츠 URL
properties:
wooridongneTV:
type: string
description: 우리동네TV 영상 URL (15초)
example: "https://cdn.example.com/videos/event-15s.mp4"
ringoBiz:
type: string
description: 링고비즈 연결음 파일 URL
example: "https://cdn.example.com/audio/ringtone.mp3"
genieTV:
type: string
description: 지니TV 광고 영상 URL
example: "https://cdn.example.com/videos/event-ad.mp4"
instagram:
type: string
description: Instagram 이미지 URL (1080x1080)
example: "https://cdn.example.com/images/event-instagram.jpg"
naverBlog:
type: string
description: Naver Blog 이미지 URL (800x600)
example: "https://cdn.example.com/images/event-naver.jpg"
kakaoChannel:
type: string
description: Kakao Channel 이미지 URL (800x800)
example: "https://cdn.example.com/images/event-kakao.jpg"
ChannelConfig:
type: object
required:
- type
properties:
type:
type: string
description: 채널 타입
enum:
- WOORIDONGNE_TV
- RINGO_BIZ
- GENIE_TV
- INSTAGRAM
- NAVER_BLOG
- KAKAO_CHANNEL
example: "INSTAGRAM"
config:
type: object
description: 채널별 설정 (채널에 따라 다름)
additionalProperties: true
example:
scheduledTime: "2025-11-01T10:00:00Z"
caption: "이벤트 안내"
hashtags:
- "이벤트"
- "할인"
DistributionResponse:
type: object
required:
- distributionId
- eventId
- status
- results
properties:
distributionId:
type: string
description: 배포 ID
example: "dist-12345"
eventId:
type: string
description: 이벤트 ID
example: "evt-12345"
status:
type: string
description: 전체 배포 상태
enum:
- PENDING
- IN_PROGRESS
- COMPLETED
- PARTIAL_FAILURE
- FAILED
example: "COMPLETED"
startedAt:
type: string
format: date-time
description: 배포 시작 시각
example: "2025-11-01T08:59:00Z"
completedAt:
type: string
format: date-time
description: 배포 완료 시각
example: "2025-11-01T09:00:00Z"
results:
type: array
description: 채널별 배포 결과
items:
$ref: '#/components/schemas/ChannelResult'
ChannelResult:
type: object
required:
- channel
- status
properties:
channel:
type: string
description: 채널 타입
enum:
- WOORIDONGNE_TV
- RINGO_BIZ
- GENIE_TV
- INSTAGRAM
- NAVER_BLOG
- KAKAO_CHANNEL
example: "INSTAGRAM"
status:
type: string
description: 채널별 배포 상태
enum:
- PENDING
- IN_PROGRESS
- SUCCESS
- FAILED
example: "SUCCESS"
distributionId:
type: string
description: 채널별 배포 ID (우리동네TV, 지니TV)
example: "wtv-uuid-12345"
estimatedViews:
type: integer
description: 예상 노출 수 (우리동네TV, 지니TV)
example: 1500
updateTimestamp:
type: string
format: date-time
description: 업데이트 완료 시각 (링고비즈)
example: "2025-11-01T09:00:00Z"
adId:
type: string
description: 광고 ID (지니TV)
example: "gtv-uuid-12345"
impressionSchedule:
type: array
description: 노출 스케줄 (지니TV)
items:
type: string
example:
- "2025-11-01 18:00-20:00"
- "2025-11-02 12:00-14:00"
postUrl:
type: string
description: 게시물 URL (Instagram, Naver Blog)
example: "https://instagram.com/p/generated-post-id"
postId:
type: string
description: 게시물 ID (Instagram)
example: "ig-post-12345"
messageId:
type: string
description: 메시지 ID (Kakao Channel)
example: "kakao-msg-12345"
message:
type: string
description: 결과 메시지
example: "배포 완료"
errorMessage:
type: string
description: 오류 메시지 (실패 시)
example: "Instagram API 타임아웃"
retries:
type: integer
description: 재시도 횟수
example: 0
lastRetryAt:
type: string
format: date-time
description: 마지막 재시도 시각
example: "2025-11-01T08:59:30Z"
DistributionStatusResponse:
type: object
required:
- eventId
- overallStatus
- channels
properties:
eventId:
type: string
description: 이벤트 ID
example: "evt-12345"
overallStatus:
type: string
description: 전체 배포 상태
enum:
- PENDING
- IN_PROGRESS
- COMPLETED
- PARTIAL_FAILURE
- FAILED
- NOT_FOUND
example: "COMPLETED"
startedAt:
type: string
format: date-time
description: 배포 시작 시각
example: "2025-11-01T08:59:00Z"
completedAt:
type: string
format: date-time
description: 배포 완료 시각
example: "2025-11-01T09:00:00Z"
channels:
type: array
description: 채널별 배포 상태
items:
$ref: '#/components/schemas/ChannelStatus'
ChannelStatus:
type: object
required:
- channel
- status
properties:
channel:
type: string
description: 채널 타입
enum:
- WOORIDONGNE_TV
- RINGO_BIZ
- GENIE_TV
- INSTAGRAM
- NAVER_BLOG
- KAKAO_CHANNEL
example: "INSTAGRAM"
status:
type: string
description: 채널별 배포 상태
enum:
- PENDING
- IN_PROGRESS
- COMPLETED
- FAILED
example: "COMPLETED"
progress:
type: integer
description: 진행률 (0-100, IN_PROGRESS 상태일 때)
minimum: 0
maximum: 100
example: 75
distributionId:
type: string
description: 채널별 배포 ID
example: "wtv-uuid-12345"
estimatedViews:
type: integer
description: 예상 노출 수
example: 1500
updateTimestamp:
type: string
format: date-time
description: 업데이트 완료 시각
example: "2025-11-01T09:00:00Z"
adId:
type: string
description: 광고 ID
example: "gtv-uuid-12345"
impressionSchedule:
type: array
description: 노출 스케줄
items:
type: string
example:
- "2025-11-01 18:00-20:00"
postUrl:
type: string
description: 게시물 URL
example: "https://instagram.com/p/generated-post-id"
postId:
type: string
description: 게시물 ID
example: "ig-post-12345"
messageId:
type: string
description: 메시지 ID
example: "kakao-msg-12345"
completedAt:
type: string
format: date-time
description: 완료 시각
example: "2025-11-01T09:00:00Z"
errorMessage:
type: string
description: 오류 메시지
example: "Instagram API 타임아웃"
retries:
type: integer
description: 재시도 횟수
example: 3
lastRetryAt:
type: string
format: date-time
description: 마지막 재시도 시각
example: "2025-11-01T08:59:30Z"
ErrorResponse:
type: object
required:
- error
- message
- timestamp
properties:
error:
type: string
description: 오류 코드
enum:
- BAD_REQUEST
- NOT_FOUND
- INTERNAL_SERVER_ERROR
example: "BAD_REQUEST"
message:
type: string
description: 오류 메시지
example: "유효하지 않은 이벤트 ID입니다"
timestamp:
type: string
format: date-time
description: 오류 발생 시각
example: "2025-11-01T09:00:00Z"
details:
type: object
description: 추가 오류 정보 (선택 사항)
additionalProperties: true

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,820 @@
openapi: 3.0.3
info:
title: Participation Service API
description: |
이벤트 참여 및 당첨자 관리 서비스 API
- 이벤트 참여 등록
- 참여자 목록 조회 및 관리
- 당첨자 추첨 및 관리
version: 1.0.0
contact:
name: Digital Garage Team
email: support@kt-event-marketing.com
servers:
- url: http://localhost:8084
description: Local Development Server
- url: https://dev-api.kt-event-marketing.com/participation/v1
description: Development Server
- url: https://api.kt-event-marketing.com/participation/v1
description: Production Server
tags:
- name: participation
description: 이벤트 참여 관리
- name: participant
description: 참여자 조회 및 관리
- name: winner
description: 당첨자 추첨 및 관리
paths:
/events/{eventId}/participate:
post:
tags:
- participation
summary: 이벤트 참여
description: |
고객이 이벤트에 참여합니다.
- 중복 참여 검증 (전화번호 기반)
- 이벤트 진행 상태 검증
- Kafka 이벤트 발행 (ParticipantRegistered)
operationId: participateEvent
x-user-story: UFR-PART-010
x-controller: ParticipationController
parameters:
- name: eventId
in: path
required: true
description: 이벤트 ID
schema:
type: string
example: "evt_20250123_001"
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ParticipationRequest'
examples:
standard:
summary: 일반 참여
value:
name: "홍길동"
phoneNumber: "010-1234-5678"
email: "hong@example.com"
agreeMarketing: true
agreePrivacy: true
storeVisited: false
storeVisit:
summary: 매장 방문 참여
value:
name: "김철수"
phoneNumber: "010-9876-5432"
email: "kim@example.com"
agreeMarketing: false
agreePrivacy: true
storeVisited: true
responses:
'201':
description: 참여 성공
content:
application/json:
schema:
$ref: '#/components/schemas/ParticipationResponse'
examples:
success:
summary: 참여 성공
value:
success: true
message: "이벤트 참여가 완료되었습니다"
data:
participantId: "prt_20250123_001"
eventId: "evt_20250123_001"
name: "홍길동"
phoneNumber: "010-1234-5678"
email: "hong@example.com"
participatedAt: "2025-01-23T10:30:00Z"
storeVisited: false
bonusEntries: 1
'400':
description: 잘못된 요청
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidPhone:
summary: 유효하지 않은 전화번호
value:
success: false
error:
code: "INVALID_PHONE_NUMBER"
message: "유효하지 않은 전화번호 형식입니다"
duplicateParticipation:
summary: 중복 참여
value:
success: false
error:
code: "DUPLICATE_PARTICIPATION"
message: "이미 참여하신 이벤트입니다"
'404':
description: 이벤트를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
notFound:
summary: 이벤트 없음
value:
success: false
error:
code: "EVENT_NOT_FOUND"
message: "이벤트를 찾을 수 없습니다"
'409':
description: 이벤트 진행 불가 상태
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
notActive:
summary: 진행중이 아닌 이벤트
value:
success: false
error:
code: "EVENT_NOT_ACTIVE"
message: "현재 참여할 수 없는 이벤트입니다"
/events/{eventId}/participants:
get:
tags:
- participant
summary: 참여자 목록 조회
description: |
이벤트의 참여자 목록을 조회합니다.
- 페이징 지원
- 참여일시 기준 정렬
- 매장 방문 여부 필터링
operationId: getParticipants
x-user-story: UFR-PART-020
x-controller: ParticipantController
parameters:
- name: eventId
in: path
required: true
description: 이벤트 ID
schema:
type: string
example: "evt_20250123_001"
- name: page
in: query
description: 페이지 번호 (0부터 시작)
schema:
type: integer
default: 0
minimum: 0
example: 0
- name: size
in: query
description: 페이지 크기
schema:
type: integer
default: 20
minimum: 1
maximum: 100
example: 20
- name: storeVisited
in: query
description: 매장 방문 여부 필터
schema:
type: boolean
example: true
responses:
'200':
description: 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/ParticipantListResponse'
examples:
success:
summary: 참여자 목록
value:
success: true
message: "참여자 목록을 조회했습니다"
data:
participants:
- participantId: "prt_20250123_001"
name: "홍길동"
phoneNumber: "010-1234-5678"
email: "hong@example.com"
participatedAt: "2025-01-23T10:30:00Z"
storeVisited: false
bonusEntries: 1
isWinner: false
- participantId: "prt_20250123_002"
name: "김철수"
phoneNumber: "010-9876-5432"
email: "kim@example.com"
participatedAt: "2025-01-23T11:15:00Z"
storeVisited: true
bonusEntries: 2
isWinner: true
pagination:
currentPage: 0
pageSize: 20
totalElements: 156
totalPages: 8
hasNext: true
hasPrevious: false
'404':
description: 이벤트를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/events/{eventId}/participants/{participantId}:
get:
tags:
- participant
summary: 참여자 상세 조회
description: 특정 참여자의 상세 정보를 조회합니다.
operationId: getParticipantDetail
x-user-story: UFR-PART-020
x-controller: ParticipantController
parameters:
- name: eventId
in: path
required: true
description: 이벤트 ID
schema:
type: string
example: "evt_20250123_001"
- name: participantId
in: path
required: true
description: 참여자 ID
schema:
type: string
example: "prt_20250123_001"
responses:
'200':
description: 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/ParticipantDetailResponse'
examples:
success:
summary: 참여자 상세 정보
value:
success: true
message: "참여자 정보를 조회했습니다"
data:
participantId: "prt_20250123_001"
eventId: "evt_20250123_001"
name: "홍길동"
phoneNumber: "010-1234-5678"
email: "hong@example.com"
participatedAt: "2025-01-23T10:30:00Z"
storeVisited: false
bonusEntries: 1
agreeMarketing: true
agreePrivacy: true
isWinner: false
winnerInfo: null
'404':
description: 참여자를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
notFound:
summary: 참여자 없음
value:
success: false
error:
code: "PARTICIPANT_NOT_FOUND"
message: "참여자를 찾을 수 없습니다"
/events/{eventId}/draw-winners:
post:
tags:
- winner
summary: 당첨자 추첨
description: |
이벤트 당첨자를 추첨합니다.
- 랜덤 추첨 알고리즘 사용
- 매장 방문 보너스 가중치 적용
- 중복 당첨 방지
operationId: drawWinners
x-user-story: UFR-PART-030
x-controller: WinnerController
parameters:
- name: eventId
in: path
required: true
description: 이벤트 ID
schema:
type: string
example: "evt_20250123_001"
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DrawWinnersRequest'
examples:
standard:
summary: 일반 추첨
value:
winnerCount: 10
applyStoreVisitBonus: true
responses:
'200':
description: 추첨 성공
content:
application/json:
schema:
$ref: '#/components/schemas/DrawWinnersResponse'
examples:
success:
summary: 추첨 완료
value:
success: true
message: "당첨자 추첨이 완료되었습니다"
data:
eventId: "evt_20250123_001"
totalParticipants: 156
winnerCount: 10
drawnAt: "2025-01-24T15:00:00Z"
winners:
- participantId: "prt_20250123_002"
name: "김철수"
phoneNumber: "010-9876-5432"
rank: 1
- participantId: "prt_20250123_045"
name: "이영희"
phoneNumber: "010-5555-1234"
rank: 2
- participantId: "prt_20250123_089"
name: "박민수"
phoneNumber: "010-7777-8888"
rank: 3
'400':
description: 잘못된 요청
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidCount:
summary: 잘못된 당첨자 수
value:
success: false
error:
code: "INVALID_WINNER_COUNT"
message: "당첨자 수가 참여자 수보다 많습니다"
'404':
description: 이벤트를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: 이미 추첨 완료
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
alreadyDrawn:
summary: 추첨 완료 상태
value:
success: false
error:
code: "ALREADY_DRAWN"
message: "이미 당첨자 추첨이 완료되었습니다"
/events/{eventId}/winners:
get:
tags:
- winner
summary: 당첨자 목록 조회
description: |
이벤트의 당첨자 목록을 조회합니다.
- 당첨 순위별 정렬
- 페이징 지원
operationId: getWinners
x-user-story: UFR-PART-030
x-controller: WinnerController
parameters:
- name: eventId
in: path
required: true
description: 이벤트 ID
schema:
type: string
example: "evt_20250123_001"
- name: page
in: query
description: 페이지 번호 (0부터 시작)
schema:
type: integer
default: 0
minimum: 0
example: 0
- name: size
in: query
description: 페이지 크기
schema:
type: integer
default: 20
minimum: 1
maximum: 100
example: 20
responses:
'200':
description: 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/WinnerListResponse'
examples:
success:
summary: 당첨자 목록
value:
success: true
message: "당첨자 목록을 조회했습니다"
data:
eventId: "evt_20250123_001"
drawnAt: "2025-01-24T15:00:00Z"
totalWinners: 10
winners:
- participantId: "prt_20250123_002"
name: "김철수"
phoneNumber: "010-9876-5432"
email: "kim@example.com"
rank: 1
wonAt: "2025-01-24T15:00:00Z"
- participantId: "prt_20250123_045"
name: "이영희"
phoneNumber: "010-5555-1234"
email: "lee@example.com"
rank: 2
wonAt: "2025-01-24T15:00:00Z"
pagination:
currentPage: 0
pageSize: 20
totalElements: 10
totalPages: 1
hasNext: false
hasPrevious: false
'404':
description: 이벤트를 찾을 수 없음 또는 당첨자가 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
noWinners:
summary: 당첨자 없음
value:
success: false
error:
code: "NO_WINNERS_YET"
message: "아직 당첨자 추첨이 진행되지 않았습니다"
components:
schemas:
ParticipationRequest:
type: object
required:
- name
- phoneNumber
- agreePrivacy
properties:
name:
type: string
description: 참여자 이름
minLength: 2
maxLength: 50
example: "홍길동"
phoneNumber:
type: string
description: 참여자 전화번호 (하이픈 포함)
pattern: '^\d{3}-\d{3,4}-\d{4}$'
example: "010-1234-5678"
email:
type: string
format: email
description: 참여자 이메일
example: "hong@example.com"
agreeMarketing:
type: boolean
description: 마케팅 정보 수신 동의
default: false
example: true
agreePrivacy:
type: boolean
description: 개인정보 수집 및 이용 동의 (필수)
example: true
storeVisited:
type: boolean
description: 매장 방문 여부
default: false
example: false
ParticipationResponse:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "이벤트 참여가 완료되었습니다"
data:
$ref: '#/components/schemas/ParticipantInfo'
ParticipantInfo:
type: object
properties:
participantId:
type: string
description: 참여자 ID
example: "prt_20250123_001"
eventId:
type: string
description: 이벤트 ID
example: "evt_20250123_001"
name:
type: string
description: 참여자 이름
example: "홍길동"
phoneNumber:
type: string
description: 참여자 전화번호
example: "010-1234-5678"
email:
type: string
description: 참여자 이메일
example: "hong@example.com"
participatedAt:
type: string
format: date-time
description: 참여 일시
example: "2025-01-23T10:30:00Z"
storeVisited:
type: boolean
description: 매장 방문 여부
example: false
bonusEntries:
type: integer
description: 보너스 응모권 수 (매장 방문 시 +1)
minimum: 1
example: 1
isWinner:
type: boolean
description: 당첨 여부
example: false
ParticipantDetailInfo:
allOf:
- $ref: '#/components/schemas/ParticipantInfo'
- type: object
properties:
agreeMarketing:
type: boolean
description: 마케팅 정보 수신 동의
example: true
agreePrivacy:
type: boolean
description: 개인정보 수집 및 이용 동의
example: true
winnerInfo:
$ref: '#/components/schemas/WinnerInfo'
nullable: true
ParticipantListResponse:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "참여자 목록을 조회했습니다"
data:
type: object
properties:
participants:
type: array
items:
$ref: '#/components/schemas/ParticipantInfo'
pagination:
$ref: '#/components/schemas/Pagination'
ParticipantDetailResponse:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "참여자 정보를 조회했습니다"
data:
$ref: '#/components/schemas/ParticipantDetailInfo'
DrawWinnersRequest:
type: object
required:
- winnerCount
properties:
winnerCount:
type: integer
description: 당첨자 수
minimum: 1
example: 10
applyStoreVisitBonus:
type: boolean
description: 매장 방문 보너스 적용 여부
default: true
example: true
DrawWinnersResponse:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "당첨자 추첨이 완료되었습니다"
data:
type: object
properties:
eventId:
type: string
description: 이벤트 ID
example: "evt_20250123_001"
totalParticipants:
type: integer
description: 전체 참여자 수
example: 156
winnerCount:
type: integer
description: 당첨자 수
example: 10
drawnAt:
type: string
format: date-time
description: 추첨 일시
example: "2025-01-24T15:00:00Z"
winners:
type: array
description: 당첨자 목록
items:
$ref: '#/components/schemas/WinnerSummary'
WinnerSummary:
type: object
properties:
participantId:
type: string
description: 참여자 ID
example: "prt_20250123_002"
name:
type: string
description: 당첨자 이름
example: "김철수"
phoneNumber:
type: string
description: 당첨자 전화번호
example: "010-9876-5432"
rank:
type: integer
description: 당첨 순위
minimum: 1
example: 1
WinnerInfo:
type: object
properties:
participantId:
type: string
description: 참여자 ID
example: "prt_20250123_002"
name:
type: string
description: 당첨자 이름
example: "김철수"
phoneNumber:
type: string
description: 당첨자 전화번호
example: "010-9876-5432"
email:
type: string
description: 당첨자 이메일
example: "kim@example.com"
rank:
type: integer
description: 당첨 순위
minimum: 1
example: 1
wonAt:
type: string
format: date-time
description: 당첨 일시
example: "2025-01-24T15:00:00Z"
WinnerListResponse:
type: object
properties:
success:
type: boolean
example: true
message:
type: string
example: "당첨자 목록을 조회했습니다"
data:
type: object
properties:
eventId:
type: string
description: 이벤트 ID
example: "evt_20250123_001"
drawnAt:
type: string
format: date-time
description: 추첨 일시
example: "2025-01-24T15:00:00Z"
totalWinners:
type: integer
description: 전체 당첨자 수
example: 10
winners:
type: array
items:
$ref: '#/components/schemas/WinnerInfo'
pagination:
$ref: '#/components/schemas/Pagination'
Pagination:
type: object
properties:
currentPage:
type: integer
description: 현재 페이지 번호 (0부터 시작)
minimum: 0
example: 0
pageSize:
type: integer
description: 페이지 크기
minimum: 1
example: 20
totalElements:
type: integer
description: 전체 요소 수
minimum: 0
example: 156
totalPages:
type: integer
description: 전체 페이지 수
minimum: 0
example: 8
hasNext:
type: boolean
description: 다음 페이지 존재 여부
example: true
hasPrevious:
type: boolean
description: 이전 페이지 존재 여부
example: false
ErrorResponse:
type: object
properties:
success:
type: boolean
example: false
error:
type: object
properties:
code:
type: string
description: 에러 코드
example: "DUPLICATE_PARTICIPATION"
message:
type: string
description: 에러 메시지
example: "이미 참여하신 이벤트입니다"
details:
type: object
description: 추가 에러 상세 정보
additionalProperties: true
nullable: true

View File

@ -0,0 +1,991 @@
openapi: 3.0.3
info:
title: User Service API
description: |
KT AI 기반 소상공인 이벤트 자동 생성 서비스 - User Service API
사용자 인증 및 매장정보 관리를 담당하는 마이크로서비스
**주요 기능:**
- 회원가입
- 로그인/로그아웃
- 프로필 조회 및 수정
- 비밀번호 변경
**보안:**
- JWT Bearer 토큰 기반 인증
- bcrypt 비밀번호 해싱
version: 1.0.0
contact:
name: Digital Garage Team
email: support@kt-event-marketing.com
servers:
- url: http://localhost:8081
description: Local Development Server
- url: https://dev-api.kt-event-marketing.com/user/v1
description: Development Server
- url: https://api.kt-event-marketing.com/user/v1
description: Production Server
tags:
- name: Authentication
description: 인증 관련 API (로그인, 로그아웃, 회원가입)
- name: Profile
description: 프로필 관련 API (조회, 수정, 비밀번호 변경)
paths:
/users/register:
post:
tags:
- Authentication
summary: 회원가입
description: |
소상공인 회원가입 API
**유저스토리:** UFR-USER-010
**주요 기능:**
- 기본 정보 및 매장 정보 등록
- 비밀번호 bcrypt 해싱
- JWT 토큰 자동 발급
**처리 흐름:**
1. 중복 사용자 확인 (전화번호 기반)
2. 비밀번호 해싱 (bcrypt)
3. User/Store 데이터베이스 트랜잭션 처리
4. JWT 토큰 생성 및 세션 저장 (Redis)
operationId: registerUser
x-user-story: UFR-USER-010
x-controller: UserController
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterRequest'
examples:
restaurant:
summary: 음식점 회원가입 예시
value:
name: 홍길동
phoneNumber: "01012345678"
email: hong@example.com
password: "Password123!"
storeName: 맛있는집
industry: 음식점
address: 서울시 강남구 테헤란로 123
businessHours: "월-금 11:00-22:00, 토-일 12:00-21:00"
cafe:
summary: 카페 회원가입 예시
value:
name: 김철수
phoneNumber: "01087654321"
email: kim@example.com
password: "SecurePass456!"
storeName: 아메리카노 카페
industry: 카페
address: 서울시 서초구 서초대로 456
businessHours: "매일 09:00-20:00"
responses:
'201':
description: 회원가입 성공
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterResponse'
examples:
success:
summary: 회원가입 성공 응답
value:
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6Ik9XTkVSIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjE2MTY4NDM4MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
userId: 123
userName: 홍길동
storeId: 456
storeName: 맛있는집
'400':
description: 잘못된 요청
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
duplicateUser:
summary: 중복 사용자
value:
code: USER_001
message: 이미 가입된 전화번호입니다
timestamp: 2025-10-22T10:30:00Z
validationError:
summary: 입력 검증 오류
value:
code: VALIDATION_ERROR
message: 비밀번호는 8자 이상이어야 합니다
timestamp: 2025-10-22T10:30:00Z
'500':
description: 서버 오류
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/users/login:
post:
tags:
- Authentication
summary: 로그인
description: |
소상공인 로그인 API
**유저스토리:** UFR-USER-020
**주요 기능:**
- 전화번호/비밀번호 인증
- JWT 토큰 발급
- Redis 세션 저장
- 최종 로그인 시각 업데이트 (비동기)
**보안:**
- Timing Attack 방어 (에러 메시지 통일)
- bcrypt 비밀번호 검증
- JWT 토큰 7일 만료
operationId: loginUser
x-user-story: UFR-USER-020
x-controller: UserController
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
examples:
default:
summary: 로그인 요청 예시
value:
phoneNumber: "01012345678"
password: "Password123!"
responses:
'200':
description: 로그인 성공
content:
application/json:
schema:
$ref: '#/components/schemas/LoginResponse'
examples:
success:
summary: 로그인 성공 응답
value:
token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6Ik9XTkVSIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjE2MTY4NDM4MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
userId: 123
userName: 홍길동
role: OWNER
email: hong@example.com
'401':
description: 인증 실패
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
authFailed:
summary: 인증 실패
value:
code: AUTH_001
message: 전화번호 또는 비밀번호를 확인해주세요
timestamp: 2025-10-22T10:30:00Z
/users/logout:
post:
tags:
- Authentication
summary: 로그아웃
description: |
로그아웃 API
**유저스토리:** UFR-USER-040
**주요 기능:**
- Redis 세션 삭제
- JWT 토큰 Blacklist 추가
- 멱등성 보장
**처리 흐름:**
1. JWT 토큰 검증
2. Redis 세션 삭제
3. JWT Blacklist 추가 (남은 만료 시간만큼 TTL 설정)
4. 로그아웃 이벤트 발행
operationId: logoutUser
x-user-story: UFR-USER-040
x-controller: UserController
security:
- BearerAuth: []
responses:
'200':
description: 로그아웃 성공
content:
application/json:
schema:
$ref: '#/components/schemas/LogoutResponse'
examples:
success:
summary: 로그아웃 성공 응답
value:
success: true
message: 안전하게 로그아웃되었습니다
'401':
description: 인증 실패 (유효하지 않은 토큰)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidToken:
summary: 유효하지 않은 토큰
value:
code: AUTH_002
message: 유효하지 않은 토큰입니다
timestamp: 2025-10-22T10:30:00Z
/users/profile:
get:
tags:
- Profile
summary: 프로필 조회
description: |
사용자 프로필 조회 API
**유저스토리:** UFR-USER-030
**조회 정보:**
- 기본 정보 (이름, 전화번호, 이메일)
- 매장 정보 (매장명, 업종, 주소, 영업시간)
operationId: getProfile
x-user-story: UFR-USER-030
x-controller: UserController
security:
- BearerAuth: []
responses:
'200':
description: 프로필 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/ProfileResponse'
examples:
success:
summary: 프로필 조회 성공 응답
value:
userId: 123
userName: 홍길동
phoneNumber: "01012345678"
email: hong@example.com
role: OWNER
storeId: 456
storeName: 맛있는집
industry: 음식점
address: 서울시 강남구 테헤란로 123
businessHours: "월-금 11:00-22:00, 토-일 12:00-21:00"
createdAt: 2025-09-01T10:00:00Z
lastLoginAt: 2025-10-22T09:00:00Z
'401':
description: 인증 실패
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: 사용자를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
notFound:
summary: 사용자 없음
value:
code: USER_003
message: 사용자를 찾을 수 없습니다
timestamp: 2025-10-22T10:30:00Z
put:
tags:
- Profile
summary: 프로필 수정
description: |
사용자 프로필 수정 API
**유저스토리:** UFR-USER-030
**수정 가능 항목:**
- 기본 정보: 이름, 전화번호, 이메일
- 매장 정보: 매장명, 업종, 주소, 영업시간
**주의사항:**
- 비밀번호 변경은 별도 API 사용 (/users/password)
- 전화번호 변경 시 향후 재인증 필요 (현재는 직접 변경 가능)
- Optimistic Locking으로 동시성 제어
operationId: updateProfile
x-user-story: UFR-USER-030
x-controller: UserController
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateProfileRequest'
examples:
fullUpdate:
summary: 전체 정보 수정
value:
name: 홍길동
phoneNumber: "01012345678"
email: hong.new@example.com
storeName: 맛있는집 (리뉴얼)
industry: 퓨전음식점
address: 서울시 강남구 테헤란로 456
businessHours: "매일 11:00-23:00"
partialUpdate:
summary: 일부 정보 수정 (이메일, 영업시간)
value:
email: hong.updated@example.com
businessHours: "월-금 10:00-22:00, 토-일 휴무"
responses:
'200':
description: 프로필 수정 성공
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateProfileResponse'
examples:
success:
summary: 프로필 수정 성공 응답
value:
userId: 123
userName: 홍길동
email: hong.new@example.com
storeId: 456
storeName: 맛있는집 (리뉴얼)
'400':
description: 잘못된 요청
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: 인증 실패
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: 사용자를 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: 동시성 충돌 (다른 세션에서 수정)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
conflict:
summary: 동시성 충돌
value:
code: USER_005
message: 다른 세션에서 프로필을 수정했습니다. 새로고침 후 다시 시도하세요
timestamp: 2025-10-22T10:30:00Z
/users/password:
put:
tags:
- Profile
summary: 비밀번호 변경
description: |
비밀번호 변경 API
**유저스토리:** UFR-USER-030
**주요 기능:**
- 현재 비밀번호 확인 필수
- 새 비밀번호 규칙 검증 (8자 이상, 영문/숫자/특수문자 포함)
- bcrypt 해싱
**보안:**
- 현재 비밀번호 검증 실패 시 400 Bad Request
- 비밀번호 변경 후 기존 세션 유지 (로그아웃 불필요)
operationId: changePassword
x-user-story: UFR-USER-030
x-controller: UserController
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ChangePasswordRequest'
examples:
default:
summary: 비밀번호 변경 요청
value:
currentPassword: "Password123!"
newPassword: "NewSecurePass456!"
responses:
'200':
description: 비밀번호 변경 성공
content:
application/json:
schema:
$ref: '#/components/schemas/ChangePasswordResponse'
examples:
success:
summary: 비밀번호 변경 성공 응답
value:
success: true
message: 비밀번호가 성공적으로 변경되었습니다
'400':
description: 현재 비밀번호 불일치 또는 새 비밀번호 규칙 위반
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidCurrentPassword:
summary: 현재 비밀번호 불일치
value:
code: USER_004
message: 현재 비밀번호가 일치하지 않습니다
timestamp: 2025-10-22T10:30:00Z
invalidNewPassword:
summary: 새 비밀번호 규칙 위반
value:
code: VALIDATION_ERROR
message: 비밀번호는 8자 이상이어야 하며 영문/숫자/특수문자를 포함해야 합니다
timestamp: 2025-10-22T10:30:00Z
'401':
description: 인증 실패
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/users/{userId}/store:
get:
tags:
- Profile
summary: 매장정보 조회 (서비스 연동용)
description: |
특정 사용자의 매장정보를 조회하는 API (내부 서비스 연동용)
**사용 목적:**
- Event Service에서 이벤트 생성 시 매장정보 조회
- Content Service에서 매장정보 기반 콘텐츠 생성
- Service-to-Service 통신용 내부 API
**주의사항:**
- Internal API로 외부 노출 금지
- API Gateway에서 인증된 서비스만 접근 허용
- 매장정보는 Redis 캐시 우선 조회 (TTL 30분)
operationId: getStoreByUserId
x-user-story: Service Integration
x-controller: UserController
security:
- BearerAuth: []
parameters:
- name: userId
in: path
required: true
description: 사용자 ID
schema:
type: integer
format: int64
example: 123
responses:
'200':
description: 매장정보 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/StoreDetailResponse'
examples:
success:
summary: 매장정보 조회 성공 응답
value:
userId: 123
storeId: 456
storeName: 맛있는집
industry: 음식점
address: 서울시 강남구 테헤란로 123
businessHours: "월-금 11:00-22:00, 토-일 12:00-21:00"
'401':
description: 인증 실패
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
unauthorized:
summary: 인증 실패
value:
code: AUTH_002
message: 유효하지 않은 토큰입니다
timestamp: 2025-10-22T10:30:00Z
'403':
description: 권한 없음 (내부 서비스만 접근 가능)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
forbidden:
summary: 권한 없음
value:
code: AUTH_003
message: 이 API는 내부 서비스만 접근 가능합니다
timestamp: 2025-10-22T10:30:00Z
'404':
description: 사용자 또는 매장을 찾을 수 없음
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
notFound:
summary: 사용자 또는 매장 없음
value:
code: USER_003
message: 사용자 또는 매장을 찾을 수 없습니다
timestamp: 2025-10-22T10:30:00Z
'500':
description: 서버 오류
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT Bearer 토큰 인증
**형식:** Authorization: Bearer {JWT_TOKEN}
**토큰 만료:** 7일
**Claims:**
- userId: 사용자 ID
- role: 사용자 역할 (OWNER)
- iat: 발급 시각
- exp: 만료 시각
schemas:
RegisterRequest:
type: object
required:
- name
- phoneNumber
- email
- password
- storeName
- industry
- address
properties:
name:
type: string
minLength: 2
maxLength: 50
description: 사용자 이름 (2자 이상, 한글/영문)
example: 홍길동
phoneNumber:
type: string
pattern: '^010\d{8}$'
description: 휴대폰 번호 (010XXXXXXXX)
example: "01012345678"
email:
type: string
format: email
maxLength: 100
description: 이메일 주소
example: hong@example.com
password:
type: string
minLength: 8
maxLength: 100
description: 비밀번호 (8자 이상, 영문/숫자/특수문자 포함)
example: "Password123!"
storeName:
type: string
minLength: 2
maxLength: 100
description: 매장명
example: 맛있는집
industry:
type: string
maxLength: 50
description: 업종 (예 음식점, 카페, 소매점 등)
example: 음식점
address:
type: string
minLength: 5
maxLength: 200
description: 매장 주소
example: 서울시 강남구 테헤란로 123
businessHours:
type: string
maxLength: 200
description: 영업시간 (선택 사항)
example: "월-금 11:00-22:00, 토-일 12:00-21:00"
RegisterResponse:
type: object
required:
- token
- userId
- userName
- storeId
- storeName
properties:
token:
type: string
description: JWT 토큰 (7일 만료)
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6Ik9XTkVSIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjE2MTY4NDM4MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
userId:
type: integer
format: int64
description: 사용자 ID
example: 123
userName:
type: string
description: 사용자 이름
example: 홍길동
storeId:
type: integer
format: int64
description: 매장 ID
example: 456
storeName:
type: string
description: 매장명
example: 맛있는집
LoginRequest:
type: object
required:
- phoneNumber
- password
properties:
phoneNumber:
type: string
pattern: '^010\d{8}$'
description: 휴대폰 번호
example: "01012345678"
password:
type: string
minLength: 8
description: 비밀번호
example: "Password123!"
LoginResponse:
type: object
required:
- token
- userId
- userName
- role
- email
properties:
token:
type: string
description: JWT 토큰 (7일 만료)
example: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6Ik9XTkVSIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjE2MTY4NDM4MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
userId:
type: integer
format: int64
description: 사용자 ID
example: 123
userName:
type: string
description: 사용자 이름
example: 홍길동
role:
type: string
enum: [OWNER, ADMIN]
description: 사용자 역할
example: OWNER
email:
type: string
format: email
description: 이메일 주소
example: hong@example.com
LogoutResponse:
type: object
required:
- success
- message
properties:
success:
type: boolean
description: 로그아웃 성공 여부
example: true
message:
type: string
description: 응답 메시지
example: 안전하게 로그아웃되었습니다
ProfileResponse:
type: object
required:
- userId
- userName
- phoneNumber
- email
- role
- storeId
- storeName
- industry
- address
properties:
userId:
type: integer
format: int64
description: 사용자 ID
example: 123
userName:
type: string
description: 사용자 이름
example: 홍길동
phoneNumber:
type: string
description: 휴대폰 번호
example: "01012345678"
email:
type: string
format: email
description: 이메일 주소
example: hong@example.com
role:
type: string
enum: [OWNER, ADMIN]
description: 사용자 역할
example: OWNER
storeId:
type: integer
format: int64
description: 매장 ID
example: 456
storeName:
type: string
description: 매장명
example: 맛있는집
industry:
type: string
description: 업종
example: 음식점
address:
type: string
description: 매장 주소
example: 서울시 강남구 테헤란로 123
businessHours:
type: string
description: 영업시간
example: "월-금 11:00-22:00, 토-일 12:00-21:00"
createdAt:
type: string
format: date-time
description: 가입 일시
example: 2025-09-01T10:00:00Z
lastLoginAt:
type: string
format: date-time
description: 최종 로그인 일시
example: 2025-10-22T09:00:00Z
UpdateProfileRequest:
type: object
properties:
name:
type: string
minLength: 2
maxLength: 50
description: 사용자 이름 (선택 사항)
example: 홍길동
phoneNumber:
type: string
pattern: '^010\d{8}$'
description: 휴대폰 번호 (선택 사항, 향후 재인증 필요)
example: "01012345678"
email:
type: string
format: email
maxLength: 100
description: 이메일 주소 (선택 사항)
example: hong.new@example.com
storeName:
type: string
minLength: 2
maxLength: 100
description: 매장명 (선택 사항)
example: 맛있는집 (리뉴얼)
industry:
type: string
maxLength: 50
description: 업종 (선택 사항)
example: 퓨전음식점
address:
type: string
minLength: 5
maxLength: 200
description: 매장 주소 (선택 사항)
example: 서울시 강남구 테헤란로 456
businessHours:
type: string
maxLength: 200
description: 영업시간 (선택 사항)
example: "매일 11:00-23:00"
UpdateProfileResponse:
type: object
required:
- userId
- userName
- email
- storeId
- storeName
properties:
userId:
type: integer
format: int64
description: 사용자 ID
example: 123
userName:
type: string
description: 사용자 이름
example: 홍길동
email:
type: string
format: email
description: 이메일 주소
example: hong.new@example.com
storeId:
type: integer
format: int64
description: 매장 ID
example: 456
storeName:
type: string
description: 매장명
example: 맛있는집 (리뉴얼)
ChangePasswordRequest:
type: object
required:
- currentPassword
- newPassword
properties:
currentPassword:
type: string
minLength: 8
description: 현재 비밀번호
example: "Password123!"
newPassword:
type: string
minLength: 8
maxLength: 100
description: 새 비밀번호 (8자 이상, 영문/숫자/특수문자 포함)
example: "NewSecurePass456!"
ChangePasswordResponse:
type: object
required:
- success
- message
properties:
success:
type: boolean
description: 비밀번호 변경 성공 여부
example: true
message:
type: string
description: 응답 메시지
example: 비밀번호가 성공적으로 변경되었습니다
StoreDetailResponse:
type: object
required:
- userId
- storeId
- storeName
- industry
- address
properties:
userId:
type: integer
format: int64
description: 사용자 ID
example: 123
storeId:
type: integer
format: int64
description: 매장 ID
example: 456
storeName:
type: string
description: 매장명
example: 맛있는집
industry:
type: string
description: 업종
example: 음식점
address:
type: string
description: 매장 주소
example: 서울시 강남구 테헤란로 123
businessHours:
type: string
description: 영업시간
example: "월-금 11:00-22:00, 토-일 12:00-21:00"
ErrorResponse:
type: object
required:
- code
- message
- timestamp
properties:
code:
type: string
description: 에러 코드
example: USER_001
enum:
- USER_001 # 중복 사용자
- USER_003 # 사용자 없음
- USER_004 # 현재 비밀번호 불일치
- USER_005 # 동시성 충돌
- AUTH_001 # 인증 실패
- AUTH_002 # 유효하지 않은 토큰
- AUTH_003 # 권한 없음 (내부 서비스만 접근)
- VALIDATION_ERROR # 입력 검증 오류
message:
type: string
description: 에러 메시지
example: 이미 가입된 전화번호입니다
timestamp:
type: string
format: date-time
description: 에러 발생 시각
example: 2025-10-22T10:30:00Z
details:
type: array
description: 상세 에러 정보 (선택 사항)
items:
type: string
example: ["필드명: 필수 항목입니다"]

View File

@ -0,0 +1,869 @@
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 논리 아키텍처
## 문서 정보
- **작성일**: 2025-10-21
- **최종 수정일**: 2025-10-22
- **버전**: 2.0 (CQRS + Event-Driven 전환)
- **작성자**: System Architect
- **관련 문서**:
- [유저스토리](../../userstory.md)
- [아키텍처 패턴](../../pattern/architecture-pattern.md)
- [UI/UX 설계서](../../uiux/uiux.md)
## 버전 이력
- **v1.0** (2025-10-21): 초기 마이크로서비스 아키텍처 설계
- **v2.0** (2025-10-22): CQRS 패턴 및 Event-Driven 아키텍처 전환, Resilience 패턴 전면 적용
- **v2.1** (2025-10-22): 서비스 구조 간소화, Kafka 통합 (Event Bus + Job Queue), Distribution 비동기 처리
- **v2.2** (2025-10-22): Distribution Service 동기 호출 전환 (REST API 직접 호출)
---
## 목차
1. [개요](#1-개요)
2. [서비스 아키텍처](#2-서비스-아키텍처)
3. [주요 사용자 플로우](#3-주요-사용자-플로우)
4. [데이터 흐름 및 캐싱 전략](#4-데이터-흐름-및-캐싱-전략)
5. [확장성 및 성능 고려사항](#5-확장성-및-성능-고려사항)
6. [보안 고려사항](#6-보안-고려사항)
7. [논리 아키텍처 다이어그램](#7-논리-아키텍처-다이어그램)
---
## 1. 개요
### 1.1 설계 원칙
본 논리 아키텍처는 다음 원칙을 기반으로 설계되었습니다:
#### 유저스토리 기반 설계
- 20개 유저스토리와 정확히 매칭
- 불필요한 추가 기능 배제
- 비즈니스 요구사항 우선 반영
#### Event-Driven 아키텍처
- **Kafka 기반 통합**: Event Bus와 Job Queue를 Kafka로 통합
- **비동기 메시징**: Kafka Topics를 통한 서비스 간 통신
- **느슨한 결합**: 서비스 간 직접 의존성 제거
- **확장성**: 이벤트 구독자 추가로 기능 확장 용이
- **장애 격리**: 이벤트 발행/구독 실패 시 서비스 독립성 유지
#### Kafka 통합 전략
- **Event Topics**: 도메인 이벤트 발행/구독 (EventCreated, ParticipantRegistered 등)
- **Job Topics**: 비동기 작업 요청/처리 (ai 이벤트 생성, 이미지 생성)
- **단일 메시징 플랫폼**: 운영 복잡도 감소 및 일관된 메시지 처리
#### Resilience 패턴 적용
- **Circuit Breaker**: 외부 API 장애 시 빠른 실패 및 복구 (Hystrix/Resilience4j)
- **Retry Pattern**: 일시적 장애 시 자동 재시도 (지수 백오프)
- **Timeout Pattern**: 응답 시간 제한으로 리소스 점유 방지
- **Bulkhead Pattern**: 리소스 격리로 장애 전파 차단
- **Fallback Pattern**: 장애 시 대체 로직 실행 (캐시 응답 등)
### 1.2 핵심 컴포넌트 정의
#### Core Services
1. **User Service**: 사용자 인증 및 매장정보 관리
- 회원가입/로그인 (JWT 발급)
- 프로필 CRUD
- Event Service로 회원정보 제공
2. **Event Service**: 이벤트 전체 생명주기 관리
- 이벤트 생성/수정/삭제/조회
- 이벤트 생성 플로우 오케스트레이션
- Kafka Job 발행 (AI, 이미지)
- Distribution Service 동기 호출 (배포)
- Kafka Event 발행 (EventCreated)
3. **Participation Service**: 참여 및 당첨자 관리
- 참여 접수 및 중복 체크
- 참여자 목록 조회
- 당첨자 추첨 및 조회
- Kafka Event 발행 (ParticipantRegistered)
4. **Analytics Service**: 실시간 성과 분석 및 대시보드
- 대시보드 데이터 조회 (Redis 캐싱)
- Kafka Event 구독 (EventCreated, ParticipantRegistered, DistributionCompleted)
- 외부 채널 통계 수집 (Circuit Breaker + Fallback)
- ROI 계산 및 성과 분석
#### Async Services (비동기 처리)
1. **AI Service**: AI 기반 이벤트 추천
- Kafka Job 구독 (ai 이벤트 생성)
- 외부 AI API 호출 (Circuit Breaker, Timeout 5분)
- 결과 Redis 저장 (TTL 24시간)
2. **Content Service**: SNS 이미지 생성
- Redis에서 AI 데이터 읽기
- 외부 이미지 생성 API 호출 (Circuit Breaker, Timeout 5분)
- 생성된 이미지 Redis 저장 (CDN URL, TTL 7일)
3. **Distribution Service**: 다중 채널 배포 (동기)
- REST API 제공 (Event Service에서 호출)
- 병렬 배포 (Circuit Breaker, Retry, Bulkhead)
- Kafka Event 발행 (DistributionCompleted)
#### Kafka (통합 메시징 플랫폼)
**Event Topics** (도메인 이벤트):
- **EventCreated**: 이벤트 생성 시
- **ParticipantRegistered**: 참여자 등록 시
- **DistributionCompleted**: 배포 완료 시
**Job Topics** (비동기 작업):
- **ai 이벤트 생성**: AI 추천 작업
- **이미지 생성**: 이미지 생성 작업
**특징**:
- At-Least-Once Delivery 보장
- Partition Key 기반 순서 보장
- Dead Letter Queue 지원
#### Data Layer
- **Redis Cache**: 세션, AI 결과, 이미지 URL, 대시보드 캐싱
- **PostgreSQL**: 서비스별 독립 데이터베이스
- User DB, Event DB, Participation DB, Analytics DB
#### External Systems
- **AI APIs**: Claude/GPT-4 (트렌드 분석)
- **이미지 생성 APIs**: Stable Diffusion/DALL-E
- **배포 채널 APIs**: 우리동네TV, 링고비즈, 지니TV, SNS APIs (비동기 배포)
---
## 2. 서비스 아키텍처
### 2.1 서비스별 책임
#### User Service
**핵심 책임**:
- 회원가입/로그인 (JWT 토큰 발급)
- 프로필 CRUD (매장 정보 포함)
- 세션 관리
- Event Service로 회원정보 제공
**관련 유저스토리**: UFR-USER-010, 020, 030, 040
**서비스 간 호출**:
- **Event Service**: 회원정보 조회 API 제공 (매장 정보 포함)
**데이터 저장**:
- User DB: users, stores 테이블
- Redis: 세션 정보 (TTL 7일)
#### Event Service
**핵심 책임**:
- 이벤트 생성/수정/삭제/조회
- 이벤트 생성 플로우 오케스트레이션
- Kafka Job 발행 (AI, 이미지, 배포)
- Kafka Event 발행 (EventCreated)
**관련 유저스토리**: UFR-EVENT-010, 020, 030, 040, 050, 060, 070
**Kafka 이벤트 발행**:
1. **EventCreated**: 이벤트 생성 완료 시
- Payload: eventId, storeId, title, objective, createdAt
- 구독자: Analytics Service
**Kafka Job 발행**:
1. **ai 이벤트 생성**: AI 추천 요청
2. **이미지 생성**: 이미지 생성 요청
**서비스 간 호출**:
- **Distribution Service**: 다중 채널 배포 (동기 호출, Circuit Breaker 적용)
**주요 플로우**:
1. 이벤트 목적 선택 → Event DB 저장 → EventCreated 발행
2. AI 추천 요청 → ai 이벤트 생성 발행
3. 이미지 생성 요청 → 이미지 생성 발행
4. 배포 승인 → Distribution Service 동기 호출
**데이터 저장**:
- Event DB: events, event_objectives, event_prizes 테이블
#### Participation Service
**핵심 책임**:
- 이벤트 참여 접수 및 검증
- 참여자 목록 조회
- 당첨자 추첨 및 조회
- Kafka Event 발행 (ParticipantRegistered)
**관련 유저스토리**: UFR-PART-010, 020, 030
**Kafka 이벤트 발행**:
1. **ParticipantRegistered**: 참여자 등록 시
- Payload: participantId, eventId, phoneNumber, registeredAt
- 구독자: Analytics Service
**주요 기능**:
- 중복 참여 체크 (전화번호 기반)
- 참여자 목록 조회 (페이지네이션 지원)
- 난수 기반 무작위 추첨
- 매장 방문 고객 가산점 적용
- 당첨자 조회
**데이터 저장**:
- Participation DB: participants, winners 테이블
#### Analytics Service
**핵심 책임**:
- 실시간 성과 대시보드 조회
- 채널별 성과 분석 및 통계
- ROI 계산 및 성과 집계
**관련 유저스토리**: UFR-ANAL-010
**Kafka 이벤트 구독**:
- **EventCreated**: 이벤트 기본 정보 초기화
- **ParticipantRegistered**: 참여자 수 실시간 증가
- **DistributionCompleted**: 배포 완료 통계 업데이트
**Resilience 패턴**:
- **Circuit Breaker**: 외부 채널 API 조회 시 (실패율 50% 초과 시 Open)
- **Fallback**: 캐시된 이전 데이터 반환
- **Cache-Aside**: Redis 캐싱 (TTL 5분)
**데이터 통합**:
- Event Service: 이벤트 정보 조회 (DB 직접 또는 REST)
- Participation Service: 참여자/당첨자 데이터 조회
- 외부 APIs: 우리동네TV, 지니TV, SNS 통계 수집
**데이터 저장**:
- Analytics DB: event_stats, channel_stats
- Redis: 대시보드 데이터 (TTL 5분)
### 2.2 Async Services (비동기 처리)
#### AI Service
**핵심 책임**:
- 업종/지역/시즌 트렌드 분석
- 3가지 이벤트 기획안 자동 생성
- 예상 성과 계산
**관련 유저스토리**: UFR-AI-010
**Kafka Job 구독**:
- **ai 이벤트 생성**: AI 추천 작업 요청
**Resilience 패턴**:
- **Circuit Breaker**: AI API 호출 시 (실패율 50% 초과 시 Open)
- **Timeout**: 5분 (300초)
- **Fallback**: 캐시된 이전 추천 결과 + 안내 메시지
- **Cache-Aside**: Redis 캐싱 (TTL 24시간)
**처리 시간**:
- 캐시 HIT: 0.1초
- 캐시 MISS: 5분 이내 (비동기 처리)
**데이터 저장**:
- Redis: AI 추천 결과 (TTL 24시간)
- Redis: Job 상태 정보 (TTL 1시간)
#### Content Service
**핵심 책임**:
- 3가지 스타일 SNS 이미지 자동 생성
- 플랫폼별 이미지 최적화
- 이미지 편집 기능
**관련 유저스토리**: UFR-CONT-010, 020
**데이터 읽기**:
- Redis에서 AI Service가 저장한 이벤트 데이터 읽기
**Resilience 패턴**:
- **Circuit Breaker**: 이미지 생성 API 호출 시 (실패율 50% 초과 시 Open)
- **Timeout**: 5분 (300초)
- **Fallback**: 기본 템플릿 이미지 제공
- **Cache-Aside**: Redis 캐싱 (TTL 7일)
**처리 시간**:
- 캐시 HIT: 0.1초
- 캐시 MISS: 5분 이내 (비동기 처리)
**데이터 저장**:
- Redis: 이미지 생성 결과 (CDN URL, TTL 7일)
- CDN: 생성된 이미지 파일
#### Distribution Service
**핵심 책임**:
- 다중 채널 병렬 배포 (동기)
- 배포 상태 모니터링
- Kafka Event 발행 (DistributionCompleted)
**관련 유저스토리**: UFR-DIST-010, 020
**주요 API**:
- `POST /api/distribution/distribute`: 다중 채널 배포 요청 (Event Service에서 호출)
**Kafka 이벤트 발행**:
- **DistributionCompleted**: 배포 완료 시
- Payload: eventId, distributedChannels, completedAt
- 구독자: Analytics Service
**Resilience 패턴**:
- **Circuit Breaker**: 각 외부 채널 API별 독립 적용 (실패율 50% 초과 시 Open)
- **Retry**: 최대 3회 재시도 (지수 백오프: 1초, 2초, 4초)
- **Bulkhead**: 채널별 스레드 풀 격리 (장애 전파 방지)
- **Fallback**: 실패 채널 스킵 + 알림
**처리 시간**: 1분 이내 (모든 채널 배포 완료)
**배포 채널**:
- 우리동네TV API (영상 업로드)
- 링고비즈 API (연결음 업데이트)
- 지니TV API (TV 광고 등록)
- SNS APIs (Instagram, Naver, Kakao 자동 포스팅)
**데이터 저장**:
- Event DB: distribution_logs 테이블
### 2.3 Kafka 통신 전략
#### Kafka 아키텍처
**기술 스택**: Apache Kafka (Event Topics + Job Topics 통합)
**보장 수준**: At-Least-Once Delivery
**메시지 포맷**: JSON
#### Event Topics (도메인 이벤트)
| 토픽명 | 발행자 | 구독자 | Payload | 용도 |
|---------|--------|--------|---------|------|
| **EventCreated** | Event Service | Analytics Service | eventId, storeId, title, objective, createdAt | 이벤트 생성 시 통계 초기화 |
| **ParticipantRegistered** | Participation Service | Analytics Service | participantId, eventId, phoneNumber, registeredAt | 참여자 등록 시 실시간 통계 업데이트 |
| **DistributionCompleted** | Distribution Service | Analytics Service | eventId, distributedChannels, completedAt | 배포 완료 시 통계 업데이트 |
#### Job Topics (비동기 작업)
| 토픽명 | 발행자 | 구독자 | Payload | 용도 |
|---------|--------|--------|---------|------|
| **ai 이벤트 생성** | Event Service | AI Service | eventId, objective, industry, region | AI 트렌드 분석 및 이벤트 추천 요청 |
| **이미지 생성** | Event Service | Content Service | eventId, content, style | SNS 이미지 생성 요청 (3가지 스타일) |
#### 통신 패턴별 설계
**1. Event Topics (도메인 이벤트)**
- **사용 시나리오**: 서비스 간 상태 변경 알림 및 동기화
- **통신 방식**: Kafka Pub/Sub
- **장점**:
- 서비스 독립성 보장
- 장애 격리
- 확장 용이
- **단점**:
- 최종 일관성 (Eventual Consistency)
- 디버깅 복잡도 증가
**2. Job Topics (비동기 작업)**
- **사용 시나리오**: 장시간 작업 (AI 추천, 이미지 생성)
- **통신 방식**: Kafka 메시지 큐
- **패턴**: Asynchronous Request-Reply
- **처리 플로우**:
1. Event Service → Kafka Job Topic: Job 발행
2. Async Service → Kafka: Job 수신 및 처리
3. Client → Event Service: Job 상태 폴링 (5초 간격)
4. Async Service → Redis: 결과 캐싱
5. Event Service → Client: 완료 응답
**3. 서비스 간 동기 호출**
- **사용 시나리오**: 다중 채널 배포 (Distribution Service)
- **통신 방식**: REST API (HTTP/JSON)
- **패턴**: Synchronous Request-Reply
- **처리 플로우**:
1. Event Service → Distribution Service: POST /api/distribution/distribute
2. Distribution Service: 다중 채널 병렬 배포 (1분 이내)
3. Distribution Service → Event Service: 배포 완료 응답
4. Distribution Service → Kafka: DistributionCompleted 이벤트 발행
- **Resilience**: Circuit Breaker 적용 (실패율 50% 초과 시 Open)
**4. 데이터베이스 직접 조회**
- **사용 시나리오**: Analytics Service가 이벤트/참여 데이터 필요 시
- **패턴**: Database-per-Service 원칙 유지, 필요 시 이벤트로 데이터 동기화
- **통신 방식**: Kafka 이벤트 구독 → Analytics DB 저장 → 로컬 조회
- **특징**: 서비스 간 직접 API 호출 최소화
#### Cache-Aside 전략
| 서비스 | 캐시 키 패턴 | TTL | 히트율 목표 | 효과 |
|--------|-------------|-----|-----------|------|
| AI Service | `ai:recommendation:{업종}:{지역}:{목적}` | 24시간 | 80% | 10초 → 0.1초 (99% 개선) |
| Content Service | `content:image:{이벤트ID}:{스타일}` | 7일 | 80% | 5초 → 0.1초 (98% 개선) |
| User Service | `user:business:{사업자번호}` | 7일 | 90% | - |
| Analytics Query | `analytics:dashboard:{이벤트ID}` | 5분 | 95% | 3초 → 0.5초 (83% 개선) |
#### Resilience 패턴 적용
**1. Circuit Breaker 패턴**
- **적용 대상**: 모든 외부 API 호출
- **라이브러리**: Resilience4j 또는 Hystrix
- **설정**:
```yaml
circuit-breaker:
failure-rate-threshold: 50% # 실패율 50% 초과 시 Open
slow-call-rate-threshold: 50% # 느린 호출 50% 초과 시 Open
slow-call-duration-threshold: 5s # 5초 초과 시 느린 호출로 간주
wait-duration-in-open-state: 30s # Open 상태 30초 유지 후 Half-Open
permitted-calls-in-half-open: 3 # Half-Open 상태에서 3개 요청 테스트
```
**2. Retry 패턴**
- **적용 대상**: 일시적 장애가 예상되는 외부 API
- **재시도 전략**: 지수 백오프 (Exponential Backoff)
- **설정**:
```yaml
retry:
max-attempts: 3 # 최대 3회 재시도
wait-duration: 1s # 초기 대기 시간 1초
exponential-backoff-multiplier: 2 # 2배씩 증가 (1초, 2초, 4초)
retry-exceptions:
- java.net.SocketTimeoutException
- java.net.ConnectException
```
**3. Timeout 패턴**
- **적용 대상**: 모든 외부 API 호출
- **설정**:
| 서비스 | Timeout | 이유 |
|--------|---------|------|
| AI Service (AI API) | 5분 (300초) | 복잡한 분석 작업 |
| Content Service (이미지 API) | 5분 (300초) | 이미지 생성 시간 고려 |
| Distribution Service (채널 APIs) | 10초 | 빠른 배포 필요 |
**4. Bulkhead 패턴**
- **적용 대상**: Distribution Service (다중 채널 배포)
- **목적**: 채널별 리소스 격리로 장애 전파 차단
- **설정**:
```yaml
bulkhead:
max-concurrent-calls: 10 # 채널당 최대 10개 동시 호출
max-wait-duration: 0s # 대기 없이 즉시 실패
```
**5. Fallback 패턴**
- **적용 대상**: 모든 외부 API 호출
- **전략**:
| 서비스 | Fallback 전략 |
|--------|---------------|
| AI Service | 캐시된 이전 추천 결과 + 안내 메시지 |
| Content Service | 기본 템플릿 이미지 제공 |
| Distribution Service | 실패 채널 스킵 + 알림 |
| Analytics Service | 캐시된 이전 데이터 반환 |
#### 이벤트 순서 보장
- **Kafka Partition Key**: eventId 기준으로 파티션 할당
- **동일 이벤트의 모든 이벤트**: 동일 파티션 → 순서 보장
- **다른 이벤트**: 독립적 처리 → 병렬 처리 가능
#### 이벤트 재처리 (At-Least-Once)
- **멱등성 보장**: 구독자는 동일 이벤트 중복 처리 시 멱등성 유지
- **방법**: 이벤트 ID 기반 중복 체크 (Redis Set 사용)
---
## 3. 주요 사용자 플로우
### 3.1 이벤트 생성 플로우 (Event-Driven + Kafka)
```
1. [이벤트 목적 선택]
┌─────────────────────────────────────────────────────────────┐
│ Client → Event Service │
│ - POST /api/events (목적, 매장 정보) │
│ - Event DB에 저장 │
│ - EventCreated 이벤트 발행 → Kafka │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Kafka → Analytics Service │
│ - EventCreated 이벤트 구독 │
│ - Analytics DB에 기본 통계 초기화 │
└─────────────────────────────────────────────────────────────┘
2. [AI 이벤트 추천]
┌─────────────────────────────────────────────────────────────┐
│ Client → Event Service │
│ - POST /api/events/{id}/ai-recommendations │
│ - Kafka ai 이벤트 생성 토픽 발행 (AI 작업 요청) │
│ - Job ID 즉시 반환 (0.1초) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ AI Service (Background) │
│ - Kafka ai 이벤트 생성 토픽 구독 │
│ - Redis 캐시 확인 (Cache-Aside) │
│ - 캐시 MISS: Claude API 호출 (5분) [Circuit Breaker] │
│ - AI 추천 결과를 Redis에 저장 (TTL 24시간) │
│ - Job 상태 완료로 업데이트 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Client (Polling) │
│ - GET /api/jobs/{id} (5초 간격) │
│ - 완료 시: AI 추천 결과 반환 (3가지 옵션) │
└─────────────────────────────────────────────────────────────┘
3. [SNS 이미지 생성]
┌─────────────────────────────────────────────────────────────┐
│ Content Service (Background) │
│ - Redis에서 AI Service가 저장한 이벤트 데이터 읽기 │
│ - Redis 캐시 확인 (이미지 생성 여부) │
│ - 캐시 MISS: Stable Diffusion API (5분) [Circuit Breaker] │
│ - 이미지 CDN 업로드 │
│ - 생성된 이미지 URL을 Redis에 저장 (TTL 7일) │
│ - Job 상태 완료로 업데이트 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Client (Polling) │
│ - GET /api/jobs/{id} (3초 간격) │
│ - 완료 시: 3가지 스타일 이미지 URL 반환 │
└─────────────────────────────────────────────────────────────┘
4. [최종 승인 및 배포]
┌─────────────────────────────────────────────────────────────┐
│ Client → Event Service │
│ - POST /api/events/{id}/publish │
│ - Redis의 이벤트 관련 정보(AI 추천, 이미지 URL)를 조회 │
│ - Event DB에 이벤트 정보 저장 │
│ - Event 상태 변경 (DRAFT → PUBLISHED) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Event Service → Distribution Service │
│ - POST /api/distribution/distribute (동기 호출) │
│ - 다중 채널 병렬 배포 [Circuit Breaker + Bulkhead] │
│ * 우리동네TV API (영상 업로드) [Retry: 3회] │
│ * 링고비즈 API (연결음 업데이트) [Retry: 3회] │
│ * 지니TV API (광고 등록) [Retry: 3회] │
│ * SNS APIs (Instagram, Naver, Kakao) [Retry: 3회] │
│ - 배포 완료: DistributionCompleted 이벤트 발행 → Kafka │
│ - Event Service로 배포 완료 응답 (1분 이내) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Kafka → Analytics Service │
│ - DistributionCompleted 이벤트 구독 │
│ - Analytics DB 배포 통계 업데이트 │
│ - 대시보드 캐시 무효화 (다음 조회 시 갱신) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Event Service → Client │
│ - 배포 완료 응답 반환 │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 고객 참여 플로우 (Event-Driven)
```
1. [이벤트 참여]
┌─────────────────────────────────────────────────────────────┐
│ Client → Participation Service │
│ - POST /api/events/{id}/participate │
│ - 중복 참여 체크 (전화번호 기반) │
│ - Participation DB에 저장 │
│ - ParticipantRegistered 이벤트 발행 → Kafka │
│ - 응모 번호 즉시 반환 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Kafka → Analytics Service │
│ - ParticipantRegistered 이벤트 구독 │
│ - 실시간 참여자 수 증가 │
│ - Analytics DB에 참여 통계 업데이트 │
│ - 대시보드 캐시 무효화 │
└─────────────────────────────────────────────────────────────┘
2. [당첨자 추첨]
┌─────────────────────────────────────────────────────────────┐
│ Client → Participation Service │
│ - POST /api/events/{id}/draw-winners │
│ - 난수 기반 무작위 추첨 │
│ - Winners DB에 저장 │
└─────────────────────────────────────────────────────────────┘
```
### 3.3 성과 분석 플로우 (Event-Driven)
```
1. [실시간 대시보드 조회]
┌─────────────────────────────────────────────────────────────┐
│ Client → Analytics Service │
│ - GET /api/events/{id}/analytics │
│ - Redis 캐시 확인 (TTL 5분) │
│ * 캐시 HIT: 즉시 반환 (0.5초) │
│ * 캐시 MISS: 아래 데이터 통합 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Analytics Service (데이터 통합) │
│ - Analytics DB: 이벤트/참여 통계 조회 (로컬 DB) │
│ - 외부 APIs: 채널별 노출/클릭 수 [Circuit Breaker + Fallback] │
│ * 우리동네TV API (조회수) │
│ * 지니TV API (광고 노출 수) │
│ * SNS APIs (좋아요, 댓글, 공유 수) │
│ - Redis 캐싱 (TTL 5분) │
│ - 대시보드 데이터 반환 │
└─────────────────────────────────────────────────────────────┘
2. [실시간 업데이트 (Event 구독)]
┌─────────────────────────────────────────────────────────────┐
│ Analytics Service (Background) │
│ - EventCreated 구독: 이벤트 기본 정보 초기화 │
│ - ParticipantRegistered 구독: 참여자 수 실시간 증가 │
│ - DistributionCompleted 구독: 배포 채널 통계 업데이트 │
│ - 캐시 무효화: 다음 조회 시 최신 데이터 갱신 │
└─────────────────────────────────────────────────────────────┘
```
### 3.4 플로우 특징
#### Kafka 통합 이점
- **단일 메시징 플랫폼**: Event Bus와 Job Queue를 Kafka로 통합, 운영 복잡도 감소
- **일관된 메시지 처리**: 모든 비동기 통신이 Kafka를 통해 이루어져 모니터링 및 디버깅 용이
- **확장성**: Kafka의 높은 처리량으로 대규모 이벤트 처리 지원
#### Event-Driven 이점
- **느슨한 결합**: 서비스 간 직접 의존성 제거
- **장애 격리**: 한 서비스 장애가 다른 서비스에 영향 없음
- **확장 용이**: 새로운 구독자 추가로 기능 확장
- **비동기 처리**: 사용자 응답 시간 단축
#### Resilience 이점
- **Circuit Breaker**: 외부 API 장애 시 빠른 실패 및 복구
- **Retry**: 일시적 장애 자동 복구
- **Fallback**: 장애 시에도 서비스 지속 (Graceful Degradation)
- **Bulkhead**: 리소스 격리로 장애 전파 차단
---
## 4. 데이터 흐름 및 캐싱 전략
### 4.1 데이터 흐름
#### 읽기 플로우 (Cache-Aside 패턴)
```
1. Application → Cache 확인
- Cache HIT: 캐시된 데이터 즉시 반환
- Cache MISS:
2. Application → Database/External API 조회
3. Database/External API → Application 데이터 반환
4. Application → Cache 데이터 저장 (TTL 설정)
5. Application → Client 데이터 반환
```
#### 쓰기 플로우 (Write-Through 패턴)
```
1. Application → Database 쓰기
2. Database → Application 성공 응답
3. Application → Cache 무효화 또는 업데이트
4. Application → Client 성공 응답
```
### 4.2 캐싱 전략
#### Redis 캐시 구조
| 서비스 | 캐시 키 패턴 | 데이터 타입 | TTL | 예상 크기 | 히트율 목표 |
|--------|-------------|-----------|-----|----------|-----------|
| User | `user:session:{token}` | String | 7일 | 1KB | - |
| AI | `ai:recommendation:{업종}:{지역}:{목적}` | Hash | 24시간 | 10KB | 80% |
| AI | `ai:event:{이벤트ID}` | Hash | 24시간 | 10KB | - |
| Content | `content:image:{이벤트ID}:{스타일}` | String | 7일 | 0.2KB (URL) | 80% |
| Analytics | `analytics:dashboard:{이벤트ID}` | Hash | 5분 | 5KB | 95% |
| AI | `job:{jobId}` | Hash | 1시간 | 1KB | - |
| Content | `job:{jobId}` | Hash | 1시간 | 1KB | - |
#### Redis 메모리 산정
- **예상 동시 사용자**: 100명
- **예상 이벤트 수**: 50개
- **예상 캐시 항목 수**: 10,000개
- **예상 총 메모리**: 약 50MB (운영 환경 2GB 할당)
#### 캐시 무효화 전략
- **TTL 기반 자동 만료**: 대부분의 캐시
- **수동 무효화**: 이벤트 수정/삭제 시 관련 캐시 삭제
- **Lazy 무효화**: 데이터 변경 시 다음 조회 시점에 갱신
### 4.3 데이터베이스 전략
#### 서비스별 독립 데이터베이스
- **User DB**: users, stores
- **Event DB**: events, event_objectives, event_prizes, distribution_logs
- **Participation DB**: participants, winners
- **Analytics DB**: event_stats, channel_stats
#### 데이터 일관성 전략
- **Eventual Consistency**: 서비스 간 데이터는 최종 일관성 보장
- **Strong Consistency**: 서비스 내부 트랜잭션은 강한 일관성 보장
- **Saga 패턴**: 이벤트 생성 플로우 (보상 트랜잭션)
---
## 5. 확장성 및 성능 고려사항
### 5.1 수평 확장 전략
#### 서비스별 확장 전략
| 서비스 | 초기 인스턴스 | 확장 조건 | 최대 인스턴스 | Auto-scaling 메트릭 |
|--------|-------------|----------|-------------|-------------------|
| User | 2 | CPU > 70% | 5 | CPU, 메모리 |
| Event | 2 | CPU > 70% | 10 | CPU, 메모리 |
| AI | 1 | Job Queue > 10 | 3 | Queue 길이 |
| Content | 1 | Job Queue > 10 | 3 | Queue 길이 |
| Distribution | 2 | CPU > 70% | 5 | CPU, 메모리 |
| Participation | 1 | CPU > 70% | 3 | CPU, 메모리 |
| Analytics | 1 | CPU > 70% | 3 | CPU, 메모리 |
#### Redis Cluster
- **초기 구성**: 3 노드 (Master 3, Replica 3)
- **확장**: 노드 추가를 통한 수평 확장
- **HA**: Redis Sentinel을 통한 자동 Failover
#### Database Replication
- **Primary-Replica 구조**: 읽기 부하 분산
- **읽기 확장**: Read Replica 추가 (필요 시)
- **쓰기 확장**: Sharding (Phase 2 이후)
### 5.2 성능 목표
#### 응답 시간 목표
| 기능 | 목표 시간 | 캐시 HIT | 캐시 MISS |
|------|----------|---------|----------|
| 로그인 | 0.5초 | - | - |
| 이벤트 목록 조회 | 0.3초 | - | - |
| AI 트렌드 분석 + 추천 | 0.1초 | ✅ | 10초 (비동기) |
| SNS 이미지 생성 | 0.1초 | ✅ | 5초 (비동기) |
| 다중 채널 배포 | 1분 | - | - |
| 대시보드 로딩 | 0.5초 | ✅ | 3초 |
#### 처리량 목표
- **동시 사용자**: 100명 (MVP 목표)
- **API 요청**: 1,000 req/min
- **AI 작업**: 10 jobs/min
- **이미지 생성**: 10 jobs/min
### 5.3 성능 최적화 기법
#### Frontend 최적화
- **Code Splitting**: 페이지별 번들 분할
- **Lazy Loading**: 차트 라이브러리 지연 로딩
- **CDN**: 정적 자산 CDN 배포
- **Compression**: Gzip/Brotli 압축
#### Backend 최적화
- **Connection Pooling**: 데이터베이스 연결 풀 관리
- **Query Optimization**: 인덱스 최적화, N+1 쿼리 방지
- **Batch Processing**: 대량 데이터 일괄 처리
- **Pagination**: 목록 조회 페이지네이션
#### Cache 최적화
- **Multi-Level Caching**: Browser Cache → CDN → Redis → Database
- **Cache Warming**: 자주 사용되는 데이터 사전 로딩
- **Cache Preloading**: 피크 시간 전 캐시 준비
---
## 6. 보안 고려사항
### 6.1 인증 및 인가
#### JWT 기반 인증
- **토큰 발급**: User Service에서 로그인 시 JWT 토큰 발급
- **토큰 검증**: API Gateway에서 모든 요청의 JWT 토큰 검증
- **토큰 저장**: Redis에 세션 정보 저장 (TTL 7일)
- **토큰 갱신**: Refresh Token 패턴 (선택)
#### 역할 기반 접근 제어 (RBAC)
- **역할**: OWNER (매장 사장님), CUSTOMER (이벤트 참여자)
- **권한 관리**: API별 필요 역할 정의
- **API Gateway 검증**: 요청자의 역할 확인
### 6.2 데이터 보안
#### 민감 정보 암호화
- **비밀번호**: bcrypt 해싱 (Cost Factor: 10)
- **사업자번호**: AES-256 암호화 저장
- **개인정보**: 전화번호 마스킹 (010-****-1234)
#### 전송 보안
- **HTTPS**: 모든 통신 TLS 1.3 암호화
- **API Key**: 외부 API 호출 시 안전한 Key 관리 (AWS Secrets Manager)
#### 데이터 접근 통제
- **Database**: 서비스별 독립 계정, 최소 권한 원칙
- **Redis**: 비밀번호 설정, ACL 적용
- **백업**: 암호화된 백업 저장
### 6.3 보안 모니터링
#### 위협 탐지
- **Rate Limiting**: API Gateway에서 사용자당 100 req/min
- **Brute Force 방지**: 로그인 5회 실패 시 계정 잠금 (삭제됨, 향후 추가 가능)
- **SQL Injection 방지**: Prepared Statement 사용
- **XSS 방지**: 입력 데이터 Sanitization
#### 로깅 및 감사
- **Access Log**: 모든 API 요청 로깅
- **Audit Log**: 민감 작업 (로그인, 이벤트 생성, 당첨자 추첨) 감사 로그
- **중앙집중식 로깅**: ELK Stack 또는 CloudWatch Logs
---
## 7. 논리 아키텍처 다이어그램
논리 아키텍처 다이어그램은 별도 Mermaid 파일로 작성되었습니다.
**파일 위치**: `logical-architecture.mmd`
**다이어그램 확인 방법**:
1. https://mermaid.live/edit 접속
2. `logical-architecture.mmd` 파일 내용 붙여넣기
3. 다이어그램 시각적 확인
**다이어그램 구성**:
- Services: 4개 핵심 서비스 (User, Event, Participation, Analytics)
- Async Services: 3개 비동기 서비스 (AI, Content, Distribution)
- Kafka: Event Topics + Job Topics 통합 메시징 플랫폼
- External System: 통합된 외부 시스템 (국세청 API, AI API, 이미지 생성 API, 배포 채널 APIs)
**의존성 표현**:
- 굵은 화살표 (==>): Kafka Event Topics 발행
- 실선 화살표 (-->): Kafka Job Topics 발행 또는 외부 시스템 호출
- 점선 화살표 (-.->): Kafka 구독
---
## 부록
### A. 참고 문서
- [유저스토리](../../userstory.md)
- [아키텍처 패턴](../../pattern/architecture-pattern.md)
- [UI/UX 설계서](../../uiux/uiux.md)
- [클라우드 디자인 패턴](../../../claude/cloud-design-patterns.md)
### B. 주요 결정사항
1. **Kafka 통합 메시징 플랫폼 채택**: Event Bus와 Job Queue를 Kafka로 통합하여 운영 복잡도 감소
2. **Event-Driven 아키텍처 채택**: Kafka를 통한 서비스 간 느슨한 결합 및 비동기 통신
3. **도메인 이벤트 정의**: 3개 Event Topics (EventCreated, ParticipantRegistered, DistributionCompleted)
4. **Job Topics 정의**: 2개 Job Topics (ai 이벤트 생성, 이미지 생성)로 장시간 비동기 작업 처리
5. **Resilience 패턴 전면 적용**: Circuit Breaker, Retry, Timeout, Bulkhead, Fallback
6. **At-Least-Once Delivery**: Kafka 메시지 보장 및 멱등성 설계
7. **Cache-Aside 패턴**: AI/이미지 생성 결과 캐싱으로 응답 시간 90% 개선
8. **Redis 기반 서비스 간 데이터 공유**: AI Service → Redis → Content Service 데이터 흐름
9. **Redis to DB 영구 저장**: 이벤트 생성 완료 시 Redis 데이터를 Event DB에 저장
10. **동기 배포**: Event Service가 Distribution Service를 REST API로 직접 호출하여 다중 채널 배포 동기 처리
11. **서비스별 독립 Database**: Database-per-Service 패턴으로 서비스 독립성 보장
12. **장시간 작업 Timeout 조정**: AI/Content Service Timeout을 5분으로 설정하여 복잡한 생성 작업 지원
### C. 향후 개선 방안 (Phase 2 이후)
1. **Event Sourcing 완전 적용**: 모든 상태 변경을 이벤트로 저장하여 시간 여행 및 감사 추적 강화
2. **Saga 패턴 적용**: 복잡한 분산 트랜잭션 보상 로직 체계화
3. **Service Mesh 도입**: Istio를 통한 서비스 간 통신 관찰성 및 보안 강화
4. **Database Sharding**: Event/Participation Write DB 샤딩으로 쓰기 확장성 개선
5. **WebSocket 기반 실시간 푸시**: 대시보드 실시간 업데이트 (폴링 대체)
6. **GraphQL API Gateway**: 클라이언트 맞춤형 데이터 조회 최적화
7. **Dead Letter Queue 고도화**: 실패 이벤트 재처리 및 알림 자동화
---
**문서 버전**: 2.2
**최종 수정일**: 2025-10-22
**작성자**: System Architect
**변경 사항**: Distribution Service 동기 호출 전환 (REST API 직접 호출)

View File

@ -0,0 +1,71 @@
graph TB
%% KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 논리 아키텍처 (Event-Driven + Kafka)
%% Services
subgraph "Services"
UserSvc["User Service<br/>• 회원가입/로그인<br/>• 프로필 관리<br/>• 회원정보 제공"]
EventSvc["Event Service<br/>• 이벤트 생성/수정/삭제<br/>• 플로우 오케스트레이션<br/>• AI 작업 요청<br/>• 배포 작업 요청<br/>• Redis → DB 저장"]
PartSvc["Participation<br/>Service<br/>• 참여 접수<br/>• 참여자 목록<br/>• 당첨자 추첨"]
AnalSvc["Analytics Service<br/>• 실시간 대시보드<br/>• 성과 분석<br/>• 채널별 통계<br/>[Circuit Breaker]"]
end
%% Async Services
subgraph "Async Services"
AISvc["AI Service<br/>• 트렌드 분석<br/>• 이벤트 추천<br/>• Redis 저장<br/>[Circuit Breaker]<br/>[Timeout: 5분]"]
ContentSvc["Content Service<br/>• Redis 데이터 읽기<br/>• SNS 이미지 생성<br/>• Redis 저장<br/>[Circuit Breaker]<br/>[Timeout: 5분]"]
DistSvc["Distribution<br/>Service<br/>• 다중 채널 배포<br/>[Circuit Breaker]<br/>[Retry: 3회]<br/>[Bulkhead]"]
end
%% Kafka (Event Bus + Job Queue)
Kafka["Kafka<br/>━━━━━━━━━━<br/><Event Topics><br/>• EventCreated<br/>• ParticipantRegistered<br/>• DistributionCompleted<br/>━━━━━━━━━━<br/><Job Topics><br/>• ai 이벤트 생성"]
%% External System
External["외부시스템<br/>[Circuit Breaker]<br/>━━━━━━━━━━<br/>• AI API<br/>• 이미지 생성 API<br/>• 배포 채널 APIs<br/>(비동기)"]
%% Redis
Redis["Redis Cache<br/>━━━━━━━━━━<br/>• AI 결과<br/>• 이미지 URL<br/>• 이벤트 데이터"]
%% Event Publishing
EventSvc ==>|"EventCreated<br/>발행"| Kafka
PartSvc ==>|"ParticipantRegistered<br/>발행"| Kafka
DistSvc ==>|"DistributionCompleted<br/>발행"| Kafka
%% Job Publishing (비동기 작업 요청)
EventSvc -->|"ai 이벤트 생성 발행"| Kafka
%% Event Subscription
Kafka -.->|"EventCreated<br/>구독"| AnalSvc
Kafka -.->|"ParticipantRegistered<br/>구독"| AnalSvc
Kafka -.->|"DistributionCompleted<br/>구독"| AnalSvc
%% Job Subscription
Kafka -.->|"ai 이벤트 생성 구독"| AISvc
%% Service to Service (동기 호출)
EventSvc -->|"다중 채널 배포<br/>[Circuit Breaker]"| DistSvc
EventSvc -->|"회원정보 조회"| UserSvc
%% Redis Interactions
AISvc -->|"AI 결과 저장"| Redis
ContentSvc -->|"AI 데이터 읽기"| Redis
ContentSvc -->|"이미지 URL 저장"| Redis
EventSvc -->|"Redis → DB 저장"| Redis
%% Services to External (Resilience 패턴)
AISvc -->|"트렌드 분석/추천"| External
ContentSvc -->|"이미지 생성"| External
DistSvc -->|"다중 채널 배포<br/>(비동기)"| External
AnalSvc -->|"채널별 통계<br/>[Fallback: Cache]"| External
%% Styling
classDef service fill:#4ECDC4,stroke:#14B8A6,stroke-width:3px
classDef async fill:#8B5CF6,stroke:#7C3AED,stroke-width:3px,color:#fff
classDef kafka fill:#F59E0B,stroke:#D97706,stroke-width:3px
classDef external fill:#E5E7EB,stroke:#9CA3AF,stroke-width:2px
classDef cache fill:#EF4444,stroke:#DC2626,stroke-width:3px
class UserSvc,EventSvc,PartSvc,AnalSvc service
class AISvc,ContentSvc,DistSvc async
class Kafka kafka
class External external
class Redis cache

View File

@ -0,0 +1,393 @@
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 내부 시퀀스 설계서
## 문서 정보
- **작성일**: 2025-10-22
- **버전**: 1.0
- **작성자**: System Architect
- **관련 문서**:
- [유저스토리](../../userstory.md)
- [외부 시퀀스 설계서](../outer/)
- [논리 아키텍처](../../logical/logical-architecture.md)
---
## 목차
1. [개요](#1-개요)
2. [서비스별 시나리오 목록](#2-서비스별-시나리오-목록)
3. [설계 원칙](#3-설계-원칙)
4. [주요 패턴](#4-주요-패턴)
5. [파일 구조](#5-파일-구조)
6. [PlantUML 다이어그램 확인 방법](#6-plantuml-다이어그램-확인-방법)
---
## 1. 개요
본 문서는 KT AI 기반 소상공인 이벤트 자동 생성 서비스의 **7개 마이크로서비스**에 대한 **26개 내부 시퀀스 다이어그램**을 포함합니다.
### 1.1 설계 범위
각 마이크로서비스 내부의 처리 흐름을 상세히 표현:
- **API 레이어**: Controller
- **비즈니스 레이어**: Service, Validator, Domain Logic
- **데이터 레이어**: Repository, Cache Manager
- **인프라 레이어**: Kafka, Redis, Database, External APIs
### 1.2 설계 대상 서비스
| 서비스 | 시나리오 수 | 주요 책임 |
|--------|------------|----------|
| **User** | 4 | 사용자 인증, 프로필 관리 |
| **Event** | 10 | 이벤트 생명주기 관리, 오케스트레이션 |
| **Participation** | 3 | 참여자 관리, 당첨자 추첨 |
| **Analytics** | 5 | 실시간 성과 분석, 대시보드 |
| **AI** | 1 | AI 트렌드 분석 및 이벤트 추천 |
| **Content** | 1 | SNS 이미지 생성 |
| **Distribution** | 2 | 다중 채널 배포 |
| **총계** | **26** | - |
---
## 2. 서비스별 시나리오 목록
### 2.1 User 서비스 (4개)
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|----------|--------|-----------|---------------|
| 회원가입 | `user-회원가입.puml` | UFR-USER-010 | 사업자번호 검증(Circuit Breaker), 트랜잭션, JWT 발급 |
| 로그인 | `user-로그인.puml` | UFR-USER-020 | 비밀번호 검증(bcrypt), JWT 발급, 세션 저장 |
| 프로필수정 | `user-프로필수정.puml` | UFR-USER-030 | 기본/매장 정보 수정, 비밀번호 변경, 트랜잭션 |
| 로그아웃 | `user-로그아웃.puml` | UFR-USER-040 | JWT 검증, 세션 삭제, Blacklist 추가 |
**주요 특징**:
- **Resilience 패턴**: Circuit Breaker (국세청 API), Retry, Timeout, Fallback
- **보안**: bcrypt 해싱, AES-256 암호화, JWT 관리
- **캐싱**: 사업자번호 검증 결과 (TTL 7일), 세션 정보 (TTL 7일)
---
### 2.2 Event 서비스 (10개)
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|----------|--------|-----------|---------------|
| 목적선택 | `event-목적선택.puml` | UFR-EVENT-020 | 이벤트 목적 선택 및 저장, EventCreated 발행 |
| AI추천요청 | `event-AI추천요청.puml` | UFR-EVENT-030 | Kafka ai-job 발행, Job ID 반환 (202 Accepted) |
| 추천결과조회 | `event-추천결과조회.puml` | UFR-EVENT-030 | Redis Job 상태 폴링 조회 |
| 이미지생성요청 | `event-이미지생성요청.puml` | UFR-CONT-010 | Kafka image-job 발행, Job ID 반환 (202 Accepted) |
| 이미지결과조회 | `event-이미지결과조회.puml` | UFR-CONT-010 | Redis Job 상태 폴링 조회 |
| 콘텐츠선택 | `event-콘텐츠선택.puml` | UFR-CONT-020 | 선택한 콘텐츠 저장 |
| 최종승인및배포 | `event-최종승인및배포.puml` | UFR-EVENT-050 | Distribution Service 동기 호출, 상태 변경 |
| 상세조회 | `event-상세조회.puml` | UFR-EVENT-060 | 이벤트 상세 조회 (캐싱) |
| 목록조회 | `event-목록조회.puml` | UFR-EVENT-070 | 이벤트 목록 조회 (필터/검색/페이지네이션) |
| 대시보드조회 | `event-대시보드조회.puml` | UFR-EVENT-010 | 대시보드 이벤트 목록 (병렬 쿼리) |
**주요 특징**:
- **Kafka 통합**: Event Topics (EventCreated), Job Topics (ai-job, image-job)
- **비동기 처리**: Job 발행 → 폴링 방식 결과 조회
- **동기 호출**: Distribution Service REST API 직접 호출
- **캐싱 전략**: 목적(30분), 상세(5분), 목록/대시보드(1분)
---
### 2.3 Participation 서비스 (3개)
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|----------|--------|-----------|---------------|
| 이벤트참여 | `participation-이벤트참여.puml` | UFR-PART-010 | 중복 체크, ParticipantRegistered 발행 |
| 참여자목록조회 | `participation-참여자목록조회.puml` | UFR-PART-020 | 필터/검색, 페이지네이션, 전화번호 마스킹 |
| 당첨자추첨 | `participation-당첨자추첨.puml` | UFR-PART-030 | Fisher-Yates Shuffle, WinnerSelected 발행 |
**주요 특징**:
- **중복 방지**: Redis Cache + DB 2단계 체크
- **추첨 알고리즘**: 난수 기반 공정성, 가산점 시스템, Fisher-Yates Shuffle
- **Kafka Event**: ParticipantRegistered, WinnerSelected → Analytics Service 구독
- **보안**: 전화번호 마스킹 (010-****-1234)
---
### 2.4 Analytics 서비스 (5개)
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|----------|--------|-----------|---------------|
| 대시보드조회-캐시히트 | `analytics-대시보드조회-캐시히트.puml` | UFR-ANAL-010 | Redis 캐시 HIT (0.5초) |
| 대시보드조회-캐시미스 | `analytics-대시보드조회-캐시미스.puml` | UFR-ANAL-010 | 외부 API 병렬 호출, ROI 계산 (3초) |
| 이벤트생성구독 | `analytics-이벤트생성구독.puml` | - | EventCreated 구독, 통계 초기화 |
| 참여자등록구독 | `analytics-참여자등록구독.puml` | - | ParticipantRegistered 구독, 실시간 통계 |
| 배포완료구독 | `analytics-배포완료구독.puml` | - | DistributionCompleted 구독, 배포 통계 |
**주요 특징**:
- **Cache-Aside 패턴**: Redis 캐싱 (TTL 5분, 히트율 95%)
- **외부 API 병렬 호출**: 우리동네TV, 지니TV, SNS APIs (Circuit Breaker, Timeout, Fallback)
- **Kafka 구독**: 3개 Event Topics 실시간 처리
- **멱등성 보장**: Redis Set으로 중복 이벤트 방지
---
### 2.5 AI 서비스 (1개)
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|----------|--------|-----------|---------------|
| 트렌드분석및추천 | `ai-트렌드분석및추천.puml` | UFR-AI-010 | Kafka ai-job 구독, 트렌드 분석, 3가지 추천 병렬 생성 |
**주요 특징**:
- **Kafka Job 구독**: ai-job 토픽 Consumer
- **외부 AI API**: Claude/GPT-4 호출 (Circuit Breaker, Timeout 30초)
- **캐싱 전략**: 트렌드 분석 결과 (TTL 1시간), 추천 결과 (TTL 24시간)
- **3가지 옵션 병렬 생성**: 저비용/중비용/고비용 추천안
---
### 2.6 Content 서비스 (1개)
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|----------|--------|-----------|---------------|
| 이미지생성 | `content-이미지생성.puml` | UFR-CONT-010 | Kafka image-job 구독, 3가지 스타일 병렬 생성 |
**주요 특징**:
- **Kafka Job 구독**: image-job 토픽 Consumer
- **외부 이미지 API**: Stable Diffusion/DALL-E 병렬 호출 (Circuit Breaker, Timeout 20초)
- **3가지 스타일 병렬**: 심플/화려한/트렌디 (par 블록)
- **CDN 업로드**: 이미지 URL 캐싱 (TTL 7일)
- **Fallback 2단계**: Stable Diffusion 실패 → DALL-E → 기본 템플릿
---
### 2.7 Distribution 서비스 (2개)
| 시나리오 | 파일명 | 유저스토리 | 주요 처리 내용 |
|----------|--------|-----------|---------------|
| 다중채널배포 | `distribution-다중채널배포.puml` | UFR-DIST-010 | REST API 동기 호출, 채널별 병렬 배포, DistributionCompleted 발행 |
| 배포상태조회 | `distribution-배포상태조회.puml` | UFR-DIST-020 | 배포 상태 모니터링, 재시도 기능 |
**주요 특징**:
- **동기 호출**: Event Service → Distribution Service REST API
- **채널별 병렬 배포**: 우리동네TV, 링고비즈, 지니TV, SNS APIs (par 블록)
- **Resilience 패턴**: Circuit Breaker, Retry (3회), Bulkhead (채널별 독립)
- **독립 처리**: 하나 실패해도 다른 채널 계속
- **Kafka Event**: DistributionCompleted → Analytics Service 구독
---
## 3. 설계 원칙
### 3.1 공통설계원칙 준수
✅ **PlantUML 표준**
- `!theme mono` 테마 적용
- 명확한 타이틀 및 참여자 타입 표시
- 외부 시스템/인프라 `<<E>>` 표시
✅ **레이어 아키텍처**
```
Controller (API Layer)
Service (Business Layer)
Repository (Data Layer)
External Systems (Redis, DB, Kafka, APIs)
```
✅ **동기/비동기 구분**
- 실선 화살표 (`→`): 동기 호출
- 점선 화살표 (`-->`): 비동기 호출 (Kafka)
- `activate`/`deactivate`: 생명선 활성화
### 3.2 내부시퀀스설계 가이드 준수
✅ **유저스토리 기반 설계**
- 20개 유저스토리와 정확히 매칭
- 불필요한 추가 설계 배제
✅ **외부 시퀀스와 일치**
- 외부 시퀀스 다이어그램과 플로우 일치
- 서비스 간 통신 방식 동일
✅ **모든 레이어 표시**
- API, 비즈니스, 데이터, 인프라 레이어 명시
- 캐시, DB, 외부 API 접근 표시
---
## 4. 주요 패턴
### 4.1 Resilience 패턴
#### Circuit Breaker
- **적용 대상**: 모든 외부 API 호출
- **설정**: 실패율 50% 초과 시 Open, 30초 후 Half-Open
- **효과**: 빠른 실패로 리소스 보호
#### Retry Pattern
- **적용 대상**: 일시적 장애가 예상되는 외부 API
- **설정**: 최대 3회, 지수 백오프 (1초, 2초, 4초)
- **효과**: 일시적 장애 자동 복구
#### Timeout Pattern
- **적용 대상**: 모든 외부 API 호출
- **설정**: 국세청 5초, AI 30초, 이미지 20초, 배포 10초
- **효과**: 리소스 점유 방지
#### Fallback Pattern
- **적용 대상**: 외부 API 장애 시
- **전략**: 캐시된 이전 데이터, 기본값, 검증 스킵
- **효과**: 서비스 지속성 보장 (Graceful Degradation)
#### Bulkhead Pattern
- **적용 대상**: Distribution Service 다중 채널 배포
- **설정**: 채널별 독립 스레드 풀
- **효과**: 채널 장애 격리, 장애 전파 차단
### 4.2 캐싱 전략 (Cache-Aside)
| 서비스 | 캐시 키 패턴 | TTL | 히트율 목표 | 효과 |
|--------|-------------|-----|-----------|------|
| User | `user:business:{사업자번호}` | 7일 | 90% | 5초 → 0.1초 (98% 개선) |
| AI | `ai:recommendation:{업종}:{지역}:{목적}` | 24시간 | 80% | 10초 → 0.1초 (99% 개선) |
| Content | `content:image:{이벤트ID}:{스타일}` | 7일 | 80% | 5초 → 0.1초 (98% 개선) |
| Analytics | `analytics:dashboard:{이벤트ID}` | 5분 | 95% | 3초 → 0.5초 (83% 개선) |
| Event | `event:detail:{eventId}` | 5분 | 85% | 1초 → 0.2초 (80% 개선) |
| Participation | `participation:list:{eventId}:{filter}` | 5분 | 90% | 2초 → 0.3초 (85% 개선) |
### 4.3 Event-Driven 패턴
#### Kafka Event Topics (도메인 이벤트)
- **EventCreated**: 이벤트 생성 시 → Analytics Service 구독
- **ParticipantRegistered**: 참여자 등록 시 → Analytics Service 구독
- **WinnerSelected**: 당첨자 선정 시 → (추후 확장)
- **DistributionCompleted**: 배포 완료 시 → Analytics Service 구독
#### Kafka Job Topics (비동기 작업)
- **ai-job**: AI 추천 요청 → AI Service 구독
- **image-job**: 이미지 생성 요청 → Content Service 구독
#### 멱등성 보장
- Redis Set으로 이벤트 ID 중복 체크
- 동일 이벤트 중복 처리 시 무시
---
## 5. 파일 구조
```
design/backend/sequence/inner/
├── README.md (본 문서)
├── user-회원가입.puml
├── user-로그인.puml
├── user-프로필수정.puml
├── user-로그아웃.puml
├── event-목적선택.puml
├── event-AI추천요청.puml
├── event-추천결과조회.puml
├── event-이미지생성요청.puml
├── event-이미지결과조회.puml
├── event-콘텐츠선택.puml
├── event-최종승인및배포.puml
├── event-상세조회.puml
├── event-목록조회.puml
├── event-대시보드조회.puml
├── participation-이벤트참여.puml
├── participation-참여자목록조회.puml
├── participation-당첨자추첨.puml
├── analytics-대시보드조회-캐시히트.puml
├── analytics-대시보드조회-캐시미스.puml
├── analytics-이벤트생성구독.puml
├── analytics-참여자등록구독.puml
├── analytics-배포완료구독.puml
├── ai-트렌드분석및추천.puml
├── content-이미지생성.puml
├── distribution-다중채널배포.puml
└── distribution-배포상태조회.puml
```
**총 26개 파일, 약 114KB**
---
## 6. PlantUML 다이어그램 확인 방법
### 6.1 온라인 확인
#### PlantUML Web Server
1. https://www.plantuml.com/plantuml/uml 접속
2. 각 `.puml` 파일 내용 복사
3. 에디터에 붙여넣기
4. 다이어그램 시각적 확인
5. PNG/SVG/PDF 다운로드 가능
#### PlantUML Editor (추천)
1. https://plantuml-editor.kkeisuke.com/ 접속
2. 실시간 미리보기 제공
3. 편집 및 다운로드 지원
### 6.2 로컬 확인 (Docker)
#### Docker로 PlantUML 검증
```bash
# Docker 실행 필요
docker run -d --name plantuml -p 8080:8080 plantuml/plantuml-server:jetty
# 각 파일 문법 검사
cat "user-회원가입.puml" | docker exec -i plantuml java -jar /app/plantuml.jar -syntax
```
### 6.3 IDE 플러그인
#### IntelliJ IDEA
- **PlantUML Integration** 플러그인 설치
- `.puml` 파일 우클릭 → "Show PlantUML Diagram"
#### VS Code
- **PlantUML** 확장 설치
- `Alt+D`: 미리보기 열기
---
## 부록
### A. 파일 크기 및 통계
| 서비스 | 시나리오 수 | 총 크기 | 평균 크기 |
|--------|------------|---------|----------|
| User | 4 | 21.2KB | 5.3KB |
| Event | 10 | 20.2KB | 2.0KB |
| Participation | 3 | 15.4KB | 5.1KB |
| Analytics | 5 | 20.8KB | 4.2KB |
| AI | 1 | 12KB | 12KB |
| Content | 1 | 8.5KB | 8.5KB |
| Distribution | 2 | 17.5KB | 8.8KB |
| **총계** | **26** | **115.6KB** | **4.4KB** |
### B. 주요 기술 스택
#### Backend
- **Framework**: Spring Boot
- **ORM**: JPA/Hibernate
- **Security**: Spring Security + JWT
- **Cache**: Redis
- **Database**: PostgreSQL
- **Message Queue**: Apache Kafka
#### Resilience
- **Circuit Breaker**: Resilience4j
- **Retry**: Resilience4j RetryRegistry
- **Timeout**: Resilience4j TimeLimiterRegistry
#### Utilities
- **Password**: bcrypt (Spring Security)
- **JWT**: jjwt library
- **Encryption**: AES-256 (javax.crypto)
### C. 참고 문서
- [유저스토리](../../userstory.md)
- [외부 시퀀스 설계서](../outer/)
- [논리 아키텍처](../../logical/logical-architecture.md)
- [공통설계원칙](../../../../claude/common-principles.md)
- [내부시퀀스설계 가이드](../../../../claude/sequence-inner-design.md)
---
**문서 버전**: 1.0
**최종 수정일**: 2025-10-22
**작성자**: System Architect (박영자)
**내부 시퀀스 설계 완료**: ✅ 26개 시나리오 모두 작성 완료

View File

@ -0,0 +1,343 @@
@startuml ai-트렌드분석및추천
!theme mono
title AI Service - 트렌드 분석 및 이벤트 추천 (내부 시퀀스)
actor Client
participant "Kafka Consumer" as Consumer <<Component>>
participant "JobMessageHandler" as Handler <<Controller>>
participant "AIRecommendationService" as Service <<Service>>
participant "TrendAnalysisEngine" as TrendEngine <<Component>>
participant "RecommendationEngine" as RecommendEngine <<Component>>
participant "CacheManager" as Cache <<Component>>
participant "CircuitBreakerManager" as CB <<Component>>
participant "ExternalAIClient" as AIClient <<Component>>
participant "JobStateManager" as JobState <<Component>>
participant "Redis" as Redis <<Infrastructure>>
participant "External AI API" as ExternalAPI <<External>>
participant "Kafka Producer" as Producer <<Component>>
note over Consumer: Kafka ai-job Topic 구독\nConsumer Group: ai-service-group
== 1. Job 메시지 수신 ==
Consumer -> Handler: onMessage(jobMessage)\n{jobId, eventDraftId, 목적, 업종, 지역, 매장정보}
activate Handler
Handler -> Handler: 메시지 유효성 검증
note right
검증 항목:
- jobId 존재 여부
- eventDraftId 유효성
- 필수 파라미터 (목적, 업종, 지역)
end note
alt 유효하지 않은 메시지
Handler -> Producer: DLQ 발행 (Dead Letter Queue)\n{jobId, error: INVALID_MESSAGE}
Handler --> Consumer: ACK (메시지 처리 완료)
note over Handler: 잘못된 메시지는 DLQ로 이동\n수동 검토 필요
else 유효한 메시지
Handler -> JobState: updateJobStatus(jobId, PROCESSING)
JobState -> Redis: SET job:{jobId}:status = PROCESSING
Redis --> JobState: OK
JobState --> Handler: 상태 업데이트 완료
Handler -> Service: generateRecommendations(\neventDraftId, 목적, 업종, 지역, 매장정보)
activate Service
== 2. 트렌드 분석 ==
Service -> TrendEngine: analyzeTrends(업종, 지역, 목적)
activate TrendEngine
TrendEngine -> Cache: getCachedTrend(업종, 지역)
Cache -> Redis: GET trend:{업종}:{지역}
Redis --> Cache: 캐시 결과
alt 캐시 히트
Cache --> TrendEngine: 캐시된 트렌드 데이터
note right
캐시 키: trend:{업종}:{지역}
TTL: 1시간
데이터: {
industry_trends,
regional_characteristics,
seasonal_patterns
}
end note
else 캐시 미스
note right of TrendEngine
**트렌드 분석 입력 데이터**
- 업종 정보
- 지역 정보
- 현재 시즌 (계절, 월)
- 이벤트 목적
**외부 AI API 호출**
- 과거 이벤트 데이터 사용 안 함
- 실시간 시장 트렌드 분석
- 업종별/지역별 일반적 특성
end note
TrendEngine -> CB: executeWithCircuitBreaker(\nAI API 트렌드 분석 호출)
activate CB
CB -> CB: Circuit Breaker 상태 확인
note right
**Circuit Breaker 설정**
- Failure Rate Threshold: 50%
- Timeout: 5분 (300초)
- Half-Open Wait Duration: 1분 (60초)
- Permitted Calls in Half-Open: 3
- Sliding Window Size: 10
end note
alt Circuit CLOSED (정상)
CB -> AIClient: callAIAPI(\nmethod: "trendAnalysis",\nprompt: 트렌드 분석 프롬프트,\ntimeout: 5분)
activate AIClient
AIClient -> AIClient: 프롬프트 구성
note right of AIClient
**AI 프롬프트 구성**
"당신은 마케팅 트렌드 분석 전문가입니다.
**입력 정보**
- 업종: {업종}
- 지역: {지역}
- 현재 시즌: {계절/월}
- 이벤트 목적: {목적}
**분석 요청사항**
1. 업종별 일반적 트렌드
(업종 특성 기반 효과적인 이벤트 유형)
2. 지역별 특성
(지역 고객 특성, 선호도)
3. 시즌별 추천
(현재 시기에 적합한 이벤트)"
end note
AIClient -> ExternalAPI: AI API 호출\nPOST /api/v1/analyze\nAuthorization: Bearer {API_KEY}\nTimeout: 5분\nPayload: {업종, 지역, 시즌, 목적}
activate ExternalAPI
ExternalAPI --> AIClient: 200 OK\n{"industryTrend": "...",\n"regionalCharacteristics": "...",\n"seasonalRecommendation": "..."}
deactivate ExternalAPI
AIClient -> AIClient: 응답 검증 및 파싱
AIClient --> CB: 분석 결과
deactivate AIClient
CB -> CB: 성공 기록 (Circuit Breaker)
CB --> TrendEngine: 트렌드 분석 결과
deactivate CB
TrendEngine -> Cache: cacheTrend(\nkey: trend:{업종}:{지역},\ndata: 분석결과,\nTTL: 1시간)
Cache -> Redis: SETEX trend:{업종}:{지역} 3600 {분석결과}
Redis --> Cache: OK
Cache --> TrendEngine: 캐싱 완료
else Circuit OPEN (장애)
CB --> TrendEngine: CircuitBreakerOpenException
TrendEngine -> TrendEngine: Fallback 실행\n(기본 트렌드 데이터 사용)
note right
Fallback 전략:
- 이전 캐시 데이터 반환
- 또는 기본 트렌드 템플릿 사용
- 클라이언트에 안내 메시지 포함
end note
else Circuit HALF-OPEN (복구 시도)
CB -> AIClient: 제한된 요청 허용 (3개)
AIClient -> ExternalAPI: POST /api/v1/analyze
ExternalAPI --> AIClient: 200 OK
AIClient --> CB: 성공
CB -> CB: 연속 성공 시 CLOSED로 전환
CB --> TrendEngine: 트렌드 분석 결과
else Timeout (5분 초과)
CB --> TrendEngine: TimeoutException
note right of TrendEngine
**Timeout 처리**
- 5분 초과 시 즉시 실패
- Fallback: 기본 트렌드 사용
- 사용자에게 안내 메시지 제공
end note
TrendEngine -> TrendEngine: Fallback 실행\n(기본 트렌드 템플릿 사용)
end
end
TrendEngine --> Service: 트렌드 분석 완료\n{업종트렌드, 지역특성, 시즌특성}
deactivate TrendEngine
== 3. 이벤트 추천 생성 (3가지 옵션) ==
Service -> RecommendEngine: generateRecommendations(\n목적, 트렌드, 매장정보)
activate RecommendEngine
RecommendEngine -> RecommendEngine: 추천 컨텍스트 구성
note right
추천 입력:
- 이벤트 목적 (신규 고객 유치 등)
- 트렌드 분석 결과
- 매장 정보 (업종, 위치, 크기)
- 예산 범위 (저/중/고)
end note
group parallel
RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 1: 저비용)
activate CB
CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 저비용 추천 프롬프트,\ntimeout: 5분)
activate AIClient
AIClient -> AIClient: 프롬프트 구성
note right
옵션 1 프롬프트:
"저비용, 높은 참여율 중심 이벤트 기획
목적: {목적}
트렌드: {트렌드}
매장: {매장정보}
출력 형식:
- 이벤트 제목
- 추천 경품 (예산: 저)
- 참여 방법 (난이도: 낮음)
- 예상 참여자 수
- 예상 비용
- 예상 ROI"
end note
AIClient -> ExternalAPI: POST /api/v1/recommend\n(저비용 옵션)
ExternalAPI --> AIClient: 200 OK\n{추천안 1}
AIClient --> CB: 추천안 1
deactivate AIClient
CB --> RecommendEngine: 옵션 1 완료
deactivate CB
RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 2: 중비용)
activate CB
CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 중비용 추천 프롬프트,\ntimeout: 5분)
activate AIClient
AIClient -> AIClient: 프롬프트 구성
note right
옵션 2 프롬프트:
"중비용, 균형잡힌 ROI 이벤트 기획
목적: {목적}
트렌드: {트렌드}
매장: {매장정보}
출력 형식:
- 이벤트 제목
- 추천 경품 (예산: 중)
- 참여 방법 (난이도: 중간)
- 예상 참여자 수
- 예상 비용
- 예상 ROI"
end note
AIClient -> ExternalAPI: POST /api/v1/recommend\n(중비용 옵션)
ExternalAPI --> AIClient: 200 OK\n{추천안 2}
AIClient --> CB: 추천안 2
deactivate AIClient
CB --> RecommendEngine: 옵션 2 완료
deactivate CB
RecommendEngine -> CB: executeWithCircuitBreaker(\nAI API 추천 생성 - 옵션 3: 고비용)
activate CB
CB -> AIClient: callAIAPI(\nmethod: "generateRecommendation",\nprompt: 고비용 추천 프롬프트,\ntimeout: 5분)
activate AIClient
AIClient -> AIClient: 프롬프트 구성
note right
옵션 3 프롬프트:
"고비용, 높은 매출 증대 이벤트 기획
목적: {목적}
트렌드: {트렌드}
매장: {매장정보}
출력 형식:
- 이벤트 제목
- 추천 경품 (예산: 고)
- 참여 방법 (난이도: 높음)
- 예상 참여자 수
- 예상 비용
- 예상 ROI"
end note
AIClient -> ExternalAPI: POST /api/v1/recommend\n(고비용 옵션)
ExternalAPI --> AIClient: 200 OK\n{추천안 3}
AIClient --> CB: 추천안 3
deactivate AIClient
CB --> RecommendEngine: 옵션 3 완료
deactivate CB
end
RecommendEngine -> RecommendEngine: 3가지 추천안 통합 및 검증
note right
검증 항목:
- 필수 필드 존재 여부
- 예상 성과 계산 (ROI)
- 추천안 차별화 확인
- 홍보 문구 생성 (각 5개)
- SNS 해시태그 자동 생성
end note
RecommendEngine --> Service: 3가지 추천안 생성 완료
deactivate RecommendEngine
== 4. 결과 저장 및 Job 상태 업데이트 ==
Service -> Cache: cacheRecommendations(\nkey: ai:recommendation:{eventDraftId},\ndata: {트렌드+추천안},\nTTL: 24시간)
Cache -> Redis: SETEX ai:recommendation:{eventDraftId} 86400 {결과}
Redis --> Cache: OK
Cache --> Service: 캐싱 완료
Service -> JobState: updateJobStatus(\njobId,\nstatus: COMPLETED,\nresult: {트렌드, 추천안})
JobState -> Redis: HSET job:{jobId} status COMPLETED result {JSON}
Redis --> JobState: OK
JobState --> Service: 상태 업데이트 완료
Service --> Handler: 추천 생성 완료\n{트렌드분석, 3가지추천안}
deactivate Service
Handler --> Consumer: ACK (메시지 처리 완료)
deactivate Handler
note over Consumer: Job 처리 완료\nRedis에 저장된 결과를\n클라이언트는 폴링으로 조회
end
== 예외 처리 ==
note over Handler, Producer
**AI API 장애 시**
- Circuit Breaker Open
- Fallback: 기본 트렌드 템플릿 사용
- Job 상태: COMPLETED (안내 메시지 포함)
- 사용자에게 "AI 분석이 제한적으로 제공됩니다" 안내
**Timeout (5분 초과)**
- Circuit Breaker로 즉시 실패
- Retry 없음 (비동기 Job)
- Job 상태: FAILED
- 사용자에게 재시도 요청 안내
**Kafka 메시지 처리 실패**
- DLQ(Dead Letter Queue)로 이동
- 수동 검토 및 재처리
- 에러 로그 기록
**Redis 장애**
- 캐싱 스킵
- Job 상태는 메모리에 임시 저장
- 성능 저하 가능 (매 요청마다 AI API 호출)
**성능 목표**
- 평균 응답 시간: 2분 이내
- P95 응답 시간: 4분 이내
- Circuit Breaker Timeout: 5분
- Redis 캐시 TTL: 24시간
**데이터 처리 원칙**
- 과거 이벤트 데이터 사용 안 함
- 외부 AI API로 실시간 트렌드 분석
- 업종/지역 기반 일반적 마케팅 트렌드 활용
end note
@enduml

View File

@ -0,0 +1,342 @@
@startuml analytics-대시보드조회
!theme mono
title Analytics Service - 대시보드 조회 내부 시퀀스\n(UFR-ANAL-010: 실시간 성과분석 대시보드 조회)
participant "AnalyticsController" as Controller
participant "AnalyticsService" as Service
participant "CacheService" as Cache
participant "AnalyticsRepository" as Repository
participant "ExternalChannelService" as ChannelService
participant "ROICalculator" as Calculator
participant "CircuitBreaker" as CB
participant "Redis<<E>>" as Redis
database "Analytics DB<<E>>" as DB
-> Controller: GET /api/events/{id}/analytics\n+ Authorization: Bearer {token}
activate Controller
Controller -> Service: getDashboardData(eventId, userId)
activate Service
note right of Service
**입력 검증**
- eventId: UUID 형식 검증
- userId: JWT에서 추출
- 권한 확인: 매장 소유자 여부
end note
Service -> Cache: get("analytics:dashboard:{eventId}")
activate Cache
note right of Cache
**Cache-Aside 패턴**
- Redis GET 호출
- Cache Key 구조:
analytics:dashboard:{eventId}
- TTL: 3600초 (1시간)
end note
Cache -> Redis: GET analytics:dashboard:{eventId}
activate Redis
alt Cache HIT
Redis --> Cache: **Cache HIT**\n캐시된 데이터 반환\n{\n totalParticipants: 1234,\n totalViews: 17200,\n roi: 250,\n channelStats: [...],\n lastUpdated: "2025-10-22T10:30:00Z"\n}
deactivate Redis
Cache --> Service: Dashboard 데이터 (JSON)
deactivate Cache
note right of Service
**응답 데이터 구조**
- 4개 요약 카드
* 총 참여자 수, 달성률
* 총 노출 수, 증감률
* 예상 ROI, 업종 평균 대비
* 매출 증가율
- 채널별 성과
- 시간대별 참여 추이
- 참여자 프로필 분석
- 비교 분석 (업종 평균, 이전 이벤트)
end note
Service --> Controller: DashboardResponse\n(200 OK)
deactivate Service
Controller --> : 200 OK\nDashboard Data (JSON)
deactivate Controller
note over Controller, Redis
**Cache HIT 시나리오 성능**
- 응답 시간: 약 0.5초
- Redis 조회 시간: 0.01초
- 직렬화/역직렬화: 0.05초
- HTTP 오버헤드: 0.44초
- 예상 히트율: 95%
end note
else Cache MISS
Redis --> Cache: **Cache MISS** (null)
deactivate Redis
Cache --> Service: null (캐시 미스)
deactivate Cache
note right of Service
**Cache MISS 처리**
- 데이터 통합 작업 시작
- 로컬 DB 조회 + 외부 API 병렬 호출
end note
|||
== 1. Analytics DB 조회 (로컬 데이터) ==
Service -> Repository: getEventStats(eventId)
activate Repository
Repository -> DB: 이벤트 통계 조회\n(이벤트ID로 통계 데이터 조회)
activate DB
DB --> Repository: EventStatsEntity\n- totalParticipants\n- estimatedROI\n- salesGrowthRate
deactivate DB
Repository --> Service: EventStats
deactivate Repository
note right of Service
**로컬 데이터 확보**
- 총 참여자 수
- 예상 ROI (DB 캐시)
- 매출 증가율 (POS 연동)
end note
|||
== 2. 외부 채널 API 병렬 호출 (Circuit Breaker 적용) ==
note right of Service
**병렬 처리 시작**
- CompletableFuture 4개 생성
- 우리동네TV, 지니TV, 링고비즈, SNS APIs 동시 호출
- Circuit Breaker 적용 (채널별 독립)
end note
par 외부 API 병렬 호출
Service -> ChannelService: getWooriTVStats(eventId)
activate ChannelService
ChannelService -> CB: execute("wooriTV", () -> callAPI())
activate CB
note right of CB
**Circuit Breaker**
- State: CLOSED (정상)
- Failure Rate: 50% 초과 시 OPEN
- Timeout: 10초
end note
CB -> CB: 외부 API 호출\nGET /stats/{eventId}
alt Circuit Breaker CLOSED (정상)
CB --> ChannelService: ChannelStats\n- views: 5000\n- clicks: 1200
deactivate CB
ChannelService --> Service: WooriTVStats
deactivate ChannelService
else Circuit Breaker OPEN (장애)
CB -> CB: **Fallback 실행**\n캐시된 이전 데이터 반환
note right of CB
Fallback 전략:
- Redis에서 이전 통계 조회
- 없으면 기본값 (0) 반환
- 알림: "일부 채널 데이터 로딩 실패"
end note
CB --> ChannelService: Fallback 데이터
deactivate CB
ChannelService --> Service: WooriTVStats (Fallback)
deactivate ChannelService
end
else
Service -> ChannelService: getGenieTVStats(eventId)
activate ChannelService
ChannelService -> CB: execute("genieTV", () -> callAPI())
activate CB
CB -> CB: 외부 API 호출\nGET /campaign/{id}/stats
alt 정상 응답
CB --> ChannelService: ChannelStats\n- adViews: 10000\n- clicks: 500
deactivate CB
ChannelService --> Service: GenieTVStats
deactivate ChannelService
else Timeout (10초 초과)
CB -> CB: **Timeout 처리**\n기본값 반환
note right of CB
Timeout 발생:
- 리소스 점유 방지
- Fallback으로 기본값 (0) 설정
- 알림: "지니TV 데이터 로딩 지연"
end note
CB --> ChannelService: 기본값 (0)
deactivate CB
ChannelService --> Service: GenieTVStats (기본값)
deactivate ChannelService
end
else
Service -> ChannelService: getRingoBizStats(eventId)
activate ChannelService
ChannelService -> CB: execute("ringoBiz", () -> callAPI())
activate CB
note right of CB
**Circuit Breaker**
- State: CLOSED (정상)
- Failure Rate: 50% 초과 시 OPEN
- Timeout: 10초
end note
CB -> CB: 외부 API 호출\nGET /voice-stats/{eventId}
alt 정상 응답
CB --> ChannelService: ChannelStats\n- calls: 3000\n- completed: 2500\n- avgDuration: 45초
deactivate CB
ChannelService --> Service: RingoBizStats
deactivate ChannelService
else Timeout 또는 장애
CB -> CB: **Fallback 실행**\n기본값 반환
note right of CB
링고비즈 API 장애:
- 기본값 (0) 반환
- 알림: "링고비즈 데이터 로딩 실패"
end note
CB --> ChannelService: 기본값 (0)
deactivate CB
ChannelService --> Service: RingoBizStats (기본값)
deactivate ChannelService
end
else
Service -> ChannelService: getSNSStats(eventId)
activate ChannelService
ChannelService -> CB: execute("SNS", () -> callAPIs())
activate CB
note right of CB
**SNS APIs 통합 호출**
- Instagram API
- Naver Blog API
- Kakao Channel API
- 3개 API 병렬 호출
end note
CB -> CB: 외부 APIs 호출\n(Instagram, Naver, Kakao)
alt 정상 응답
CB --> ChannelService: SNSStats\n- Instagram: likes 300, comments 50\n- Naver: views 2000\n- Kakao: shares 100
deactivate CB
ChannelService --> Service: SNSStats
deactivate ChannelService
else 장애 또는 Timeout
CB -> CB: **Fallback 실행**\n기본값 반환
note right of CB
SNS API 장애:
- 기본값 (0) 반환
- 알림: "SNS 데이터 로딩 실패"
end note
CB --> ChannelService: 기본값 (0)
deactivate CB
ChannelService --> Service: SNSStats (기본값)
deactivate ChannelService
end
end
|||
== 3. 데이터 통합 및 ROI 계산 ==
Service -> Service: mergeChannelStats(\n wooriTV, genieTV, ringoBiz, sns\n)
note right of Service
**데이터 통합**
- 총 노출 수 = 외부 채널 노출 합계
- 총 참여자 수 = Analytics DB
- 채널별 전환율 = 참여자 수 / 노출 수
- 링고비즈: 통화 완료 수 포함
end note
Service -> Calculator: calculateROI(\n eventStats, channelStats\n)
activate Calculator
note right of Calculator
**ROI 계산 로직**
총 비용 = 경품 비용 + 플랫폼 비용
예상 수익 = 매출 증가액 + 신규 고객 LTV
ROI = (수익 - 비용) / 비용 × 100
end note
Calculator --> Service: ROIData\n- roi: 250%\n- totalCost: 100만원\n- totalRevenue: 350만원\n- breakEvenPoint: 달성
deactivate Calculator
Service -> Service: buildDashboardData(\n eventStats, channelStats, roiData\n)
note right of Service
**대시보드 데이터 구조 생성**
- 4개 요약 카드
- 채널별 성과 차트 데이터
- 시간대별 참여 추이
- 참여자 프로필 분석
- 비교 분석 (업종 평균, 이전 이벤트)
end note
|||
== 4. Redis 캐싱 및 응답 ==
Service -> Cache: set(\n "analytics:dashboard:{eventId}",\n dashboardData,\n TTL=3600\n)
activate Cache
Cache -> Redis: 캐시 저장\nSET analytics:dashboard:{eventId}\nvalue={통합 데이터}\nEX 3600 (1시간)
activate Redis
Redis --> Cache: OK (저장 완료)
deactivate Redis
Cache --> Service: OK (캐싱 완료)
deactivate Cache
note right of Service
**캐싱 완료**
- TTL: 3600초 (1시간)
- 다음 조회 시 Cache HIT
- 예상 크기: 5KB
- 갱신 주기: 1시간마다 새 데이터 조회
end note
Service --> Controller: DashboardResponse\n(200 OK)
deactivate Service
Controller --> : 200 OK\nDashboard Data (JSON)
deactivate Controller
note over Controller, DB
**Cache MISS 시나리오 성능**
- 응답 시간: 약 3초
- Analytics DB 조회: 0.1초
- 외부 API 병렬 호출: 2초 (병렬 처리)
- ROI 계산: 0.05초
- Redis 캐싱: 0.01초
- 직렬화/HTTP: 0.84초
end note
end
@enduml

View File

@ -0,0 +1,168 @@
@startuml analytics-배포완료구독
!theme mono
title Analytics Service - DistributionCompleted 이벤트 구독 처리 내부 시퀀스\n(Kafka Event 구독)
participant "Kafka Consumer" as Consumer
participant "DistributionCompletedListener" as Listener
participant "AnalyticsService" as Service
participant "AnalyticsRepository" as Repository
participant "CacheService" as Cache
participant "Redis" as Redis
database "Analytics DB" as DB
note over Consumer
**Kafka Consumer 설정**
- Topic: DistributionCompleted
- Consumer Group: analytics-service
- Partition Key: eventId
- At-Least-Once Delivery 보장
end note
Kafka -> Consumer: DistributionCompleted 이벤트 수신\n{\n eventId: "uuid",\n distributedChannels: [\n {\n channel: "우리동네TV",\n status: "SUCCESS",\n expectedViews: 5000\n },\n {\n channel: "지니TV",\n status: "SUCCESS",\n expectedViews: 10000\n },\n {\n channel: "Instagram",\n status: "SUCCESS",\n expectedViews: 2000\n }\n ],\n completedAt: "2025-10-22T12:00:00Z"\n}
activate Consumer
Consumer -> Listener: onDistributionCompleted(event)
activate Listener
note right of Listener
**멱등성 체크**
- Redis Set에 이벤트 ID 존재 여부 확인
- 중복 처리 방지
- Key: distribution_completed:{eventId}
end note
Listener -> Redis: SISMEMBER distribution_completed {eventId}
activate Redis
alt 이벤트 미처리 (멱등성 보장)
Redis --> Listener: false (미처리)
deactivate Redis
Listener -> Service: updateDistributionStats(event)
activate Service
note right of Service
**배포 채널 통계 저장**
- 채널별 배포 상태 기록
- 예상 노출 수 집계
- 배포 완료 시각 기록
end note
Service -> Service: parseChannelStats(event)
note right of Service
**채널 데이터 파싱**
- distributedChannels 배열 순회
- 각 채널별 통계 추출
- 총 예상 노출 수 계산
end note
loop 각 채널별로
Service -> Repository: saveChannelStats(\n eventId, channel, stats\n)
activate Repository
Repository -> DB: 채널별 통계 저장\n(이벤트ID, 채널명, 상태,\n예상노출수, 배포일시 저장,\n중복 시 업데이트)
activate DB
DB --> Repository: 1 row inserted/updated
deactivate DB
Repository --> Service: ChannelStatsEntity
deactivate Repository
end
note right of Service
**배포 통계 저장 완료**
- 채널별 배포 상태 기록
- 예상 노출 수 저장
- 향후 외부 API 조회 시 기준 데이터로 활용
end note
Service -> Repository: updateTotalViews(eventId, totalViews)
activate Repository
Repository -> DB: 총 노출 수 업데이트\n(총 예상 노출 수를 설정하고,\n수정일시를 현재 시각으로 업데이트)
activate DB
DB --> Repository: 1 row updated
deactivate DB
Repository --> Service: UpdateResult (success)
deactivate Repository
note right of Service
**이벤트 통계 업데이트**
- 총 예상 노출 수 업데이트
- 다음 대시보드 조회 시 반영
end note
Service -> Cache: delete("analytics:dashboard:{eventId}")
activate Cache
note right of Cache
**캐시 무효화**
- 기존 캐시 삭제
- 다음 조회 시 최신 배포 통계 반영
- 채널별 성과 차트 갱신
end note
Cache -> Redis: DEL analytics:dashboard:{eventId}
activate Redis
Redis --> Cache: OK
deactivate Redis
Cache --> Service: OK
deactivate Cache
Service -> Redis: SADD distribution_completed {eventId}
activate Redis
note right of Redis
**멱등성 처리 완료 기록**
- Redis Set에 eventId 추가
- TTL 설정 (7일)
end note
Redis --> Service: OK
deactivate Redis
Service --> Listener: 배포 통계 업데이트 완료
deactivate Service
Listener -> Consumer: ACK (처리 완료)
deactivate Listener
else 이벤트 이미 처리됨 (중복)
Redis --> Listener: true (이미 처리)
deactivate Redis
note right of Listener
**중복 이벤트 스킵**
- At-Least-Once Delivery로 인한 중복
- 멱등성 보장으로 중복 처리 방지
end note
Listener -> Consumer: ACK (스킵)
deactivate Listener
end
Consumer --> Kafka: Commit Offset
deactivate Consumer
note over Consumer, DB
**처리 시간**
- 이벤트 수신 → 통계 업데이트 완료: 약 0.3초
- 채널별 DB INSERT (3개): 0.15초
- event_stats UPDATE: 0.05초
- Redis 캐시 무효화: 0.01초
- 멱등성 체크: 0.01초
**배포 통계 효과**
- 배포 완료 즉시 통계 반영
- 채널별 성과 추적 가능
- 다음 대시보드 조회 시 최신 배포 정보 제공
end note
@enduml

View File

@ -0,0 +1,134 @@
@startuml analytics-이벤트생성구독
!theme mono
title Analytics Service - EventCreated 이벤트 구독 처리 내부 시퀀스\n(Kafka Event 구독)
participant "Kafka Consumer" as Consumer
participant "EventCreatedListener" as Listener
participant "AnalyticsService" as Service
participant "AnalyticsRepository" as Repository
participant "CacheService" as Cache
participant "Redis" as Redis
database "Analytics DB" as DB
note over Consumer
**Kafka Consumer 설정**
- Topic: EventCreated
- Consumer Group: analytics-service
- Partition Key: eventId
- At-Least-Once Delivery 보장
end note
Kafka -> Consumer: EventCreated 이벤트 수신\n{\n eventId: "uuid",\n storeId: "uuid",\n title: "이벤트 제목",\n objective: "신규 고객 유치",\n createdAt: "2025-10-22T10:00:00Z"\n}
activate Consumer
Consumer -> Listener: onEventCreated(event)
activate Listener
note right of Listener
**멱등성 체크**
- Redis Set에 이벤트 ID 존재 여부 확인
- 중복 처리 방지
end note
Listener -> Redis: SISMEMBER processed_events {eventId}
activate Redis
alt 이벤트 미처리 (멱등성 보장)
Redis --> Listener: false (미처리)
deactivate Redis
Listener -> Service: initializeEventStats(event)
activate Service
note right of Service
**이벤트 통계 초기화**
- 이벤트 기본 정보 저장
- 통계 초기값 설정
* 총 참여자 수: 0
* 총 노출 수: 0
* 예상 ROI: 계산 전
* 매출 증가율: 0%
end note
Service -> Repository: save(eventStatsEntity)
activate Repository
Repository -> DB: 이벤트 통계 초기화\n(이벤트ID, 매장ID, 제목, 목적,\n참여자수/노출수/ROI/매출증가율을\n0으로 초기화하여 저장)
activate DB
DB --> Repository: 1 row inserted
deactivate DB
Repository --> Service: EventStatsEntity
deactivate Repository
note right of Service
**초기화 완료**
- 이벤트 통계 DB 생성
- 향후 ParticipantRegistered 이벤트 수신 시
실시간 증가
end note
Service -> Cache: delete("analytics:dashboard:{eventId}")
activate Cache
note right of Cache
**캐시 무효화**
- 기존 캐시 삭제
- 다음 조회 시 최신 데이터 갱신
end note
Cache -> Redis: DEL analytics:dashboard:{eventId}
activate Redis
Redis --> Cache: OK
deactivate Redis
Cache --> Service: OK
deactivate Cache
Service -> Redis: SADD processed_events {eventId}
activate Redis
note right of Redis
**멱등성 처리 완료 기록**
- Redis Set에 eventId 추가
- TTL 설정 (7일)
end note
Redis --> Service: OK
deactivate Redis
Service --> Listener: EventStats 초기화 완료
deactivate Service
Listener -> Consumer: ACK (처리 완료)
deactivate Listener
else 이벤트 이미 처리됨 (중복)
Redis --> Listener: true (이미 처리)
deactivate Redis
note right of Listener
**중복 이벤트 스킵**
- At-Least-Once Delivery로 인한 중복
- 멱등성 보장으로 중복 처리 방지
end note
Listener -> Consumer: ACK (스킵)
deactivate Listener
end
Consumer --> Kafka: Commit Offset
deactivate Consumer
note over Consumer, DB
**처리 시간**
- 이벤트 수신 → 초기화 완료: 약 0.2초
- DB INSERT: 0.05초
- Redis 캐시 무효화: 0.01초
- 멱등성 체크: 0.01초
end note
@enduml

View File

@ -0,0 +1,135 @@
@startuml analytics-참여자등록구독
!theme mono
title Analytics Service - ParticipantRegistered 이벤트 구독 처리 내부 시퀀스\n(Kafka Event 구독)
participant "Kafka Consumer" as Consumer
participant "ParticipantRegisteredListener" as Listener
participant "AnalyticsService" as Service
participant "AnalyticsRepository" as Repository
participant "CacheService" as Cache
participant "Redis" as Redis
database "Analytics DB" as DB
note over Consumer
**Kafka Consumer 설정**
- Topic: ParticipantRegistered
- Consumer Group: analytics-service
- Partition Key: eventId
- At-Least-Once Delivery 보장
end note
Kafka -> Consumer: ParticipantRegistered 이벤트 수신\n{\n participantId: "uuid",\n eventId: "uuid",\n phoneNumber: "010-1234-5678",\n registeredAt: "2025-10-22T11:30:00Z"\n}
activate Consumer
Consumer -> Listener: onParticipantRegistered(event)
activate Listener
note right of Listener
**멱등성 체크**
- Redis Set에 participantId 존재 여부 확인
- 중복 처리 방지
end note
Listener -> Redis: SISMEMBER processed_participants {participantId}
activate Redis
alt 이벤트 미처리 (멱등성 보장)
Redis --> Listener: false (미처리)
deactivate Redis
Listener -> Service: updateParticipantCount(eventId)
activate Service
note right of Service
**참여자 수 실시간 증가**
- DB UPDATE로 참여자 수 증가
- 캐시 무효화로 다음 조회 시 최신 데이터 반영
end note
Service -> Repository: incrementParticipantCount(eventId)
activate Repository
Repository -> DB: 참여자 수 증가\n(참여자 수를 1 증가시키고,\n수정일시를 현재 시각으로 업데이트)
activate DB
DB --> Repository: 1 row updated
deactivate DB
Repository --> Service: UpdateResult (success)
deactivate Repository
note right of Service
**실시간 통계 업데이트 완료**
- 참여자 수 +1
- 다음 대시보드 조회 시 최신 통계 반영
end note
Service -> Cache: delete("analytics:dashboard:{eventId}")
activate Cache
note right of Cache
**캐시 무효화**
- 기존 캐시 삭제
- 다음 조회 시 최신 참여자 수 반영
- Cache MISS 시 DB 조회로 최신 데이터 확보
end note
Cache -> Redis: DEL analytics:dashboard:{eventId}
activate Redis
Redis --> Cache: OK
deactivate Redis
Cache --> Service: OK
deactivate Cache
Service -> Redis: SADD processed_participants {participantId}
activate Redis
note right of Redis
**멱등성 처리 완료 기록**
- Redis Set에 participantId 추가
- TTL 설정 (7일)
end note
Redis --> Service: OK
deactivate Redis
Service --> Listener: 참여자 수 업데이트 완료
deactivate Service
Listener -> Consumer: ACK (처리 완료)
deactivate Listener
else 이벤트 이미 처리됨 (중복)
Redis --> Listener: true (이미 처리)
deactivate Redis
note right of Listener
**중복 이벤트 스킵**
- At-Least-Once Delivery로 인한 중복
- 멱등성 보장으로 중복 처리 방지
end note
Listener -> Consumer: ACK (스킵)
deactivate Listener
end
Consumer --> Kafka: Commit Offset
deactivate Consumer
note over Consumer, DB
**처리 시간**
- 이벤트 수신 → 통계 업데이트 완료: 약 0.15초
- DB UPDATE: 0.05초
- Redis 캐시 무효화: 0.01초
- 멱등성 체크: 0.01초
**실시간 업데이트 효과**
- 참여자 등록 즉시 통계 반영
- 다음 대시보드 조회 시 최신 데이터 제공
- Cache-Aside 패턴으로 성능 유지
end note
@enduml

View File

@ -0,0 +1,140 @@
@startuml event-이미지결과조회
!theme mono
title Content Service - 이미지 생성 결과 폴링 조회
actor Client
participant "API Gateway" as Gateway
participant "ContentController" as Controller <<API Layer>>
participant "ContentService" as Service <<Business Layer>>
participant "JobManager" as JobMgr <<Component>>
participant "Redis Cache" as Cache <<E>>
note over Controller, Cache
**폴링 방식 Job 상태 조회**
- 최대 30초 동안 폴링 (2초 간격)
- Job 상태: PENDING → PROCESSING → COMPLETED
- 이미지 URL: Redis에 저장 (TTL: 7일)
end note
Client -> Gateway: GET /api/content/jobs/{jobId}/status
activate Gateway
Gateway -> Controller: GET /api/content/jobs/{jobId}/status
activate Controller
Controller -> Service: getJobStatus(jobId)
activate Service
Service -> JobMgr: getJobStatus(jobId)
activate JobMgr
JobMgr -> Cache: Job 상태 조회\nKey: job:{jobId}
activate Cache
alt Job 데이터 존재
Cache --> JobMgr: Job 데이터\n{status, eventDraftId,\ntype, createdAt}
deactivate Cache
alt status = COMPLETED
JobMgr -> Cache: 이미지 URL 조회\nKey: content:image:{eventDraftId}
activate Cache
Cache --> JobMgr: 이미지 URL\n{simple, fancy, trendy}
deactivate Cache
JobMgr --> Service: JobStatusResponse\n{jobId, status: COMPLETED,\nimageUrls: {...}}
deactivate JobMgr
Service --> Controller: JobStatusResponse\n{status: COMPLETED, imageUrls}
deactivate Service
Controller --> Gateway: 200 OK\n{"status": "COMPLETED",\n"imageUrls": {\n "simple": "https://cdn.../simple.png",\n "fancy": "https://cdn.../fancy.png",\n "trendy": "https://cdn.../trendy.png"\n}}
deactivate Controller
Gateway --> Client: 200 OK\n이미지 URL 반환
deactivate Gateway
note right of Client
**프론트엔드 처리**
- 3가지 스타일 카드 표시
- 사용자가 스타일 선택
- 이미지 편집 가능
end note
else status = PROCESSING 또는 PENDING
JobMgr --> Service: JobStatusResponse\n{jobId, status: PROCESSING}
deactivate JobMgr
Service --> Controller: JobStatusResponse\n{status: PROCESSING}
deactivate Service
Controller --> Gateway: 200 OK\n{"status": "PROCESSING",\n"message": "이미지 생성 중입니다"}
deactivate Controller
Gateway --> Client: 200 OK\n진행 중 상태
deactivate Gateway
note right of Client
**폴링 재시도**
- 2초 후 재요청
- 최대 30초 (15회)
end note
else status = FAILED
JobMgr -> Cache: 에러 정보 조회\nKey: job:{jobId}:error
activate Cache
Cache --> JobMgr: 에러 메시지
deactivate Cache
JobMgr --> Service: JobStatusResponse\n{jobId, status: FAILED, error}
deactivate JobMgr
Service --> Controller: JobStatusResponse\n{status: FAILED, error}
deactivate Service
Controller --> Gateway: 200 OK\n{"status": "FAILED",\n"error": "이미지 생성 실패",\n"message": "다시 시도해주세요"}
deactivate Controller
Gateway --> Client: 200 OK\n실패 상태
deactivate Gateway
note right of Client
**실패 처리**
- 에러 메시지 표시
- "다시 생성" 버튼 제공
end note
end
else Job 데이터 없음
Cache --> JobMgr: null (캐시 미스)
deactivate Cache
JobMgr --> Service: throw NotFoundException\n("Job을 찾을 수 없습니다")
deactivate JobMgr
Service --> Controller: NotFoundException
deactivate Service
Controller --> Gateway: 404 Not Found\n{"code": "JOB_001",\n"message": "Job을 찾을 수 없습니다"}
deactivate Controller
Gateway --> Client: 404 Not Found
deactivate Gateway
end
note over Controller, Cache
**폴링 전략**
- 간격: 2초
- 최대 시간: 30초 (15회)
- Timeout 시: 사용자에게 알림 + "다시 생성" 옵션
**Redis 캐시**
- Job 상태: TTL 1시간
- 이미지 URL: TTL 7일
**성능 목표**
- 평균 이미지 생성 시간: 20초 이내
- P95 이미지 생성 시간: 40초 이내
end note
@enduml

View File

@ -0,0 +1,255 @@
@startuml content-이미지생성
!theme mono
title Content Service - 이미지 생성 내부 시퀀스 (UFR-CONT-010)
actor Client
participant "Kafka\nimage-job\nConsumer" as Consumer
participant "JobHandler" as Handler
participant "CacheManager" as Cache
participant "ImageGenerator" as Generator
participant "ImageStyleFactory" as Factory
participant "StableDiffusion\nAPI Client" as SDClient
participant "DALL-E\nAPI Client" as DALLEClient
participant "Circuit Breaker" as CB
participant "BlobStorage\nUploader" as BlobStorage
participant "JobStatusManager" as JobStatus
database "Redis Cache" as Redis
note over Consumer: Kafka 구독\nimage-job 토픽
== Kafka Job 수신 ==
Consumer -> Handler: Job Message 수신\n{jobId, eventDraftId, eventInfo}
activate Handler
Handler -> Cache: 캐시 조회\nkey: content:image:{eventDraftId}
activate Cache
Cache -> Redis: GET content:image:{eventDraftId}
Redis --> Cache: 캐시 데이터 또는 NULL
Cache --> Handler: 캐시 결과
deactivate Cache
alt 캐시 HIT (기존 이미지 존재)
Handler -> JobStatus: Job 상태 업데이트\nstatus: COMPLETED (캐시)
activate JobStatus
JobStatus -> Redis: SET job:{jobId}\n{status: COMPLETED, imageUrls: [...]}
JobStatus --> Handler: 업데이트 완료
deactivate JobStatus
Handler --> Consumer: 처리 완료 (캐시)
else 캐시 MISS (새로운 이미지 생성)
Handler -> JobStatus: Job 상태 업데이트\nstatus: PROCESSING
activate JobStatus
JobStatus -> Redis: SET job:{jobId}\n{status: PROCESSING}
JobStatus --> Handler: 업데이트 완료
deactivate JobStatus
Handler -> Generator: 3가지 스타일 이미지 생성 요청\n{eventInfo}
activate Generator
== 3가지 스타일 병렬 생성 (par 블록) ==
group parallel
Generator -> Factory: 심플 프롬프트 생성\n{eventInfo, style: SIMPLE}
activate Factory
Factory --> Generator: 심플 프롬프트
deactivate Factory
Generator -> CB: Circuit Breaker 체크
activate CB
CB --> Generator: State: CLOSED (정상)
deactivate CB
Generator -> SDClient: Stable Diffusion API 호출\n{prompt, style: SIMPLE}\nTimeout: 20초
activate SDClient
note over SDClient: Circuit Breaker 적용\nRetry: 최대 3회\nTimeout: 20초
alt API 성공
SDClient --> Generator: 심플 이미지 데이터
deactivate SDClient
Generator -> BlobStorage: Blob 업로드 요청\n{imageData, eventId, style: SIMPLE}\nRetry: 3회, Timeout: 30초
activate BlobStorage
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
BlobStorage --> Generator: Blob SAS URL (심플)
deactivate BlobStorage
else API 실패 (Timeout/Error)
SDClient --> Generator: 실패 응답
deactivate SDClient
Generator -> CB: 실패 기록
activate CB
CB -> CB: 실패율 계산
alt 실패율 > 50%
CB -> CB: Circuit State: OPEN
end
CB --> Generator: Circuit State
deactivate CB
Generator -> DALLEClient: Fallback - DALL-E API 호출\n{prompt, style: SIMPLE}\nTimeout: 20초
activate DALLEClient
alt Fallback 성공
DALLEClient --> Generator: 심플 이미지 데이터
deactivate DALLEClient
Generator -> BlobStorage: Blob 업로드 요청\n{imageData, eventId, style: SIMPLE}\nRetry: 3회, Timeout: 30초
activate BlobStorage
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
BlobStorage --> Generator: Blob SAS URL (심플)
deactivate BlobStorage
else Fallback 실패
DALLEClient --> Generator: 실패 응답
deactivate DALLEClient
Generator -> Generator: 기본 템플릿 사용\n(심플)
end
end
Generator -> Factory: 화려한 프롬프트 생성\n{eventInfo, style: FANCY}
activate Factory
Factory --> Generator: 화려한 프롬프트
deactivate Factory
Generator -> CB: Circuit Breaker 체크
activate CB
CB --> Generator: State: CLOSED/OPEN
deactivate CB
alt Circuit CLOSED
Generator -> SDClient: Stable Diffusion API 호출\n{prompt, style: FANCY}\nTimeout: 20초
activate SDClient
alt API 성공
SDClient --> Generator: 화려한 이미지 데이터
deactivate SDClient
Generator -> BlobStorage: Blob 업로드 요청\n{imageData, eventId, style: FANCY}\nRetry: 3회, Timeout: 30초
activate BlobStorage
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
BlobStorage --> Generator: Blob SAS URL (화려한)
deactivate BlobStorage
else API 실패
SDClient --> Generator: 실패 응답
deactivate SDClient
Generator -> DALLEClient: Fallback - DALL-E API 호출
activate DALLEClient
alt Fallback 성공
DALLEClient --> Generator: 화려한 이미지 데이터
deactivate DALLEClient
Generator -> BlobStorage: Blob 업로드\n{imageData, eventId, style: FANCY}\nRetry: 3회, Timeout: 30초
activate BlobStorage
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
BlobStorage --> Generator: Blob SAS URL (화려한)
deactivate BlobStorage
else Fallback 실패
DALLEClient --> Generator: 실패 응답
deactivate DALLEClient
Generator -> Generator: 기본 템플릿 사용\n(화려한)
end
end
else Circuit OPEN
Generator -> Generator: Circuit Open\n즉시 기본 템플릿 사용
end
Generator -> Factory: 트렌디 프롬프트 생성\n{eventInfo, style: TRENDY}
activate Factory
Factory --> Generator: 트렌디 프롬프트
deactivate Factory
Generator -> CB: Circuit Breaker 체크
activate CB
CB --> Generator: State: CLOSED/OPEN
deactivate CB
alt Circuit CLOSED
Generator -> SDClient: Stable Diffusion API 호출\n{prompt, style: TRENDY}\nTimeout: 20초
activate SDClient
alt API 성공
SDClient --> Generator: 트렌디 이미지 데이터
deactivate SDClient
Generator -> BlobStorage: Blob 업로드 요청\n{imageData, eventId, style: TRENDY}\nRetry: 3회, Timeout: 30초
activate BlobStorage
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
BlobStorage --> Generator: Blob SAS URL (트렌디)
deactivate BlobStorage
else API 실패
SDClient --> Generator: 실패 응답
deactivate SDClient
Generator -> DALLEClient: Fallback - DALL-E API 호출
activate DALLEClient
alt Fallback 성공
DALLEClient --> Generator: 트렌디 이미지 데이터
deactivate DALLEClient
Generator -> BlobStorage: Blob 업로드\n{imageData, eventId, style: TRENDY}\nRetry: 3회, Timeout: 30초
activate BlobStorage
note right of BlobStorage: SAS Token 생성\n(유효기간 7일)
BlobStorage --> Generator: Blob SAS URL (트렌디)
deactivate BlobStorage
else Fallback 실패
DALLEClient --> Generator: 실패 응답
deactivate DALLEClient
Generator -> Generator: 기본 템플릿 사용\n(트렌디)
end
end
else Circuit OPEN
Generator -> Generator: Circuit Open\n즉시 기본 템플릿 사용
end
end
Generator --> Handler: 3가지 이미지 URL 반환\n{simple, fancy, trendy}
deactivate Generator
== 결과 캐싱 및 Job 완료 ==
Handler -> Cache: Blob SAS URL 캐싱\nkey: content:image:{eventDraftId}\nTTL: 7일
activate Cache
Cache -> Redis: SET content:image:{eventDraftId}\n{simple: SAS_URL, fancy: SAS_URL, trendy: SAS_URL}\nTTL: 604800 (7일)
Redis --> Cache: 저장 완료
Cache --> Handler: 캐싱 완료
deactivate Cache
Handler -> JobStatus: Job 상태 업데이트\nstatus: COMPLETED
activate JobStatus
JobStatus -> Redis: SET job:{jobId}\n{status: COMPLETED, imageUrls: [...]}
JobStatus --> Handler: 업데이트 완료
deactivate JobStatus
Handler --> Consumer: 처리 완료
note over Handler
Blob SAS URL은 Redis에만 저장됨
Event Service는 폴링을 통해
Redis에서 결과 조회
SAS Token 유효기간: 7일
end note
end
deactivate Handler
note over Consumer, Redis
**Resilience 패턴 적용**
- Circuit Breaker: 실패율 50% 초과 시 Open (AI API용)
- AI API Timeout: 20초
- Fallback: Stable Diffusion 실패 시 DALL-E, 모두 실패 시 기본 템플릿
- Blob Storage Retry: 최대 3회 (Exponential Backoff: 1s, 2s, 4s)
- Blob Storage Timeout: 30초 (대용량 이미지 고려)
- Cache-Aside: Redis 캐싱 (TTL 7일)
**처리 시간**
- 캐시 HIT: 0.1초
- 캐시 MISS: 5.2초 이내 (병렬 처리)
└─ AI 생성: 3-5초 + Blob 업로드: 0.15-0.3초
**병렬 처리**
- 3가지 스타일 동시 생성 (par 블록)
- 독립적인 스레드 풀 사용
**Blob Storage 업로드**
- Azure Blob Storage (Korea Central)
- SAS Token 기반 접근 제어 (읽기 전용)
- SAS Token 유효기간: 7일 (Redis TTL과 동기화)
- Public Access 비활성화 (보안 강화)
- Container: event-images
- URL 형식: https://{account}.blob.core.windows.net/event-images/{id}-{style}.png?{sas_token}
**보안**
- Storage Account Public Access 비활성화
- SAS Token 기반 URL 생성 (읽기 전용 권한)
- Firewall 규칙: K8s Cluster IP만 허용
- HTTPS 강제 (TLS 1.2 이상)
end note
@enduml

View File

@ -0,0 +1,90 @@
@startuml event-이미지생성요청
!theme mono
title Content Service - 이미지 생성 요청 (UFR-CONT-010)
actor Client
participant "API Gateway" as Gateway
participant "ContentController" as Controller <<API Layer>>
participant "ContentService" as Service <<Business Layer>>
participant "JobManager" as JobMgr <<Component>>
participant "Redis Cache" as Cache <<E>>
note over Controller, Cache
**UFR-CONT-010: SNS 이미지 생성 요청**
- Kafka 사용 안 함 (내부 Job 관리)
- 백그라운드 워커가 비동기 처리
- Redis에서 AI 추천 데이터 읽기
- 3가지 스타일 이미지 생성 (심플, 화려한, 트렌디)
end note
Client -> Gateway: POST /api/content/images/{eventDraftId}/generate
activate Gateway
Gateway -> Controller: POST /api/content/images/{eventDraftId}/generate
activate Controller
Controller -> Controller: 요청 검증\n(eventDraftId 유효성)
Controller -> Service: generateImages(eventDraftId)
activate Service
== 1단계: Redis에서 AI 추천 데이터 확인 ==
Service -> Cache: AI 추천 데이터 조회\nKey: ai:event:{eventDraftId}
activate Cache
Cache --> Service: AI 추천 결과\n{선택된 추천안, 이벤트 정보}
deactivate Cache
alt AI 추천 데이터 없음
Service --> Controller: throw NotFoundException\n("AI 추천을 먼저 선택해주세요")
Controller --> Gateway: 404 Not Found\n{"code": "CONTENT_001",\n"message": "AI 추천을 먼저 선택해주세요"}
deactivate Service
deactivate Controller
Gateway --> Client: 404 Not Found
deactivate Gateway
else AI 추천 데이터 존재
== 2단계: Job 생성 ==
Service -> JobMgr: createJob(eventDraftId, imageGeneration)
activate JobMgr
JobMgr -> JobMgr: Job ID 생성 (UUID)
JobMgr -> Cache: Job 상태 저장\nKey: job:{jobId}\nValue: {status: PENDING,\neventDraftId, type: IMAGE_GEN,\ncreatedAt}\nTTL: 1시간
activate Cache
Cache --> JobMgr: 저장 완료
deactivate Cache
JobMgr --> Service: Job 생성 완료\n{jobId, status: PENDING}
deactivate JobMgr
== 3단계: 응답 반환 ==
Service --> Controller: JobResponse\n{jobId, status: PENDING}
deactivate Service
Controller --> Gateway: 202 Accepted\n{"jobId": "job-uuid-123",\n"status": "PENDING",\n"message": "이미지 생성 중입니다"}
deactivate Controller
Gateway --> Client: 202 Accepted\n이미지 생성 시작
deactivate Gateway
note over Service, Cache
**백그라운드 워커 처리**
- Redis 폴링 또는 스케줄러가 Job 감지
- content-이미지생성.puml 참조
- 외부 이미지 생성 API 호출 (병렬)
- Redis에 이미지 URL 저장
**상세 내용**
- 3가지 스타일 병렬 생성 (심플, 화려한, 트렌디)
- Circuit Breaker 적용 (Timeout: 5분)
- 결과: Redis Key: content:image:{eventDraftId}
- TTL: 7일
end note
end
@enduml

View File

@ -0,0 +1,141 @@
@startuml distribution-다중채널배포-sprint2
!theme mono
title Distribution Service - 다중 채널 배포 Sprint 2 (UFR-DIST-010)
participant "Event Service" as EventSvc
participant "Distribution\nREST API" as API
participant "Distribution\nController" as Controller
participant "Distribution\nService" as Service
database "Distribution DB" as DB
queue "Kafka" as Kafka
== REST API 동기 호출 수신 ==
EventSvc -> API: POST /api/distribution/distribute\n{eventId, channels[], contentUrls}
activate API
API -> Controller: distributeToChannels(request)
activate Controller
Controller -> Service: executeDistribution(distributionRequest)
activate Service
Service -> DB: 배포 이력 초기화\n(이벤트ID, 상태를 PENDING으로 저장)
DB --> Service: 배포 이력 ID
note over Service: 배포 시작 상태로 변경
Service -> DB: 배포 이력 상태 업데이트\n(상태를 IN_PROGRESS로 변경)
== 다중 채널 배포 로그 기록 (Sprint 2: Mock 처리) ==
note over Service: Sprint 2: 실제 외부 API 호출 없이\n배포 결과만 기록
par 우리동네TV 배포
alt 채널 선택됨
Service -> Service: 우리동네TV 배포 처리\n(Mock: 즉시 성공 반환)
activate Service
note over Service: 배포 요청 검증\n- eventId 유효성\n- contentUrls 존재 여부
Service -> DB: 배포 채널 로그 저장\n(채널: 우리동네TV,\n상태: 성공, 배포ID,\n예상노출수 저장)
note over Service: Mock 결과:\n성공 (distributionId 생성)
deactivate Service
end
alt 링고비즈 선택됨
Service -> Service: 링고비즈 배포 처리\n(Mock: 즉시 성공 반환)
activate Service
note over Service: 배포 요청 검증\n- phoneNumber 형식\n- audioUrl 존재 여부
Service -> DB: 배포 채널 로그 저장\n(채널: 링고비즈,\n상태: 성공,\n업데이트 시각 저장)
note over Service: Mock 결과:\n성공 (timestamp 기록)
deactivate Service
end
alt 지니TV 선택됨
Service -> Service: 지니TV 배포 처리\n(Mock: 즉시 성공 반환)
activate Service
note over Service: 배포 요청 검증\n- region 유효성\n- schedule 형식\n- budget 범위
Service -> DB: 배포 채널 로그 저장\n(채널: 지니TV,\n상태: 성공, 광고ID,\n노출 스케줄 저장)
note over Service: Mock 결과:\n성공 (adId 생성)
deactivate Service
end
alt Instagram 선택됨
Service -> Service: Instagram 배포 처리\n(Mock: 즉시 성공 반환)
activate Service
note over Service: 배포 요청 검증\n- imageUrl 형식\n- caption 길이\n- hashtags 유효성
Service -> DB: 배포 채널 로그 저장\n(채널: Instagram,\n상태: 성공,\n포스트 URL/ID 저장)
note over Service: Mock 결과:\n성공 (postUrl, postId 생성)
deactivate Service
end
alt Naver Blog 선택됨
Service -> Service: Naver Blog 배포 처리\n(Mock: 즉시 성공 반환)
activate Service
note over Service: 배포 요청 검증\n- imageUrl 형식\n- content 길이
Service -> DB: 배포 채널 로그 저장\n(채널: NaverBlog,\n상태: 성공,\n포스트 URL 저장)
note over Service: Mock 결과:\n성공 (postUrl 생성)
deactivate Service
end
alt Kakao Channel 선택됨
Service -> Service: Kakao Channel 배포 처리\n(Mock: 즉시 성공 반환)
activate Service
note over Service: 배포 요청 검증\n- imageUrl 형식\n- message 길이
Service -> DB: 배포 채널 로그 저장\n(채널: KakaoChannel,\n상태: 성공,\n메시지 ID 저장)
note over Service: Mock 결과:\n성공 (messageId 생성)
deactivate Service
end
end
note over Service: 모든 채널 배포 완료\n(즉시 처리 - 외부 API 호출 없음)
== 배포 결과 집계 및 저장 ==
Service -> Service: 채널별 배포 결과 집계\n성공: [선택된 모든 채널]
note over Service: Sprint 2에서는\n모든 채널 배포가 성공으로 처리됨
Service -> DB: 배포 이력 상태 업데이트\n(상태를 COMPLETED로,\n완료일시를 현재 시각으로 설정)
== Kafka 이벤트 발행 ==
Service -> Kafka: Publish to event-topic\nDistributionCompleted\n{eventId, channels[], results[], completedAt}
note over Kafka: Analytics Service 구독\n실시간 통계 업데이트
== REST API 동기 응답 ==
Service --> Controller: 배포 완료 응답\n{status: COMPLETED, successChannels: [all]}
deactivate Service
Controller --> API: DistributionResponse\n{eventId, status: COMPLETED, results: [all success]}
deactivate Controller
API --> EventSvc: 200 OK\n{distributionId, status: COMPLETED, results[]}
deactivate API
note over EventSvc: 배포 완료 응답 수신\n이벤트 상태 업데이트\nAPPROVED → ACTIVE
== Sprint 2 제약사항 ==
note over Service: **Sprint 2 구현 범위**\n- 외부 API 호출 없음 (Mock 처리)\n- 모든 배포 요청은 성공으로 처리\n- 배포 로그만 DB에 기록\n- Circuit Breaker, Retry 미구현\n- 실패 처리 시나리오 미구현\n\n**Sprint 3 이후 구현 예정**\n- 실제 외부 채널 API 연동\n- Circuit Breaker 패턴 적용\n- Retry 로직 구현\n- 실패 처리 및 알림
@enduml

View File

@ -0,0 +1,126 @@
@startuml event-AI추천요청
!theme mono
title Event Service - AI 추천 요청 (Kafka Job 발행) (UFR-EVENT-030)
actor Client
participant "API Gateway" as Gateway
participant "EventController" as Controller <<API Layer>>
participant "EventService" as Service <<Business Layer>>
participant "JobService" as JobSvc <<Business Layer>>
participant "EventRepository" as Repo <<Data Layer>>
participant "Redis Cache" as Cache <<E>>
database "Event DB" as DB <<E>>
participant "Kafka Producer" as Kafka <<E>>
note over Controller, Kafka
**UFR-EVENT-030: AI 이벤트 추천 요청**
- Kafka 비동기 Job 발행
- AI Service가 Kafka 구독하여 처리
- 트렌드 분석 + 3가지 추천안 생성
- 처리 시간: 평균 2분 이내
end note
Client -> Gateway: POST /api/events/{eventDraftId}/ai-recommendations\n{"objective": "신규 고객 유치",\n"industry": "음식점",\n"region": "서울 강남구"}
activate Gateway
Gateway -> Controller: POST /api/events/{eventDraftId}/ai-recommendations
activate Controller
Controller -> Controller: 요청 검증\n(필수 필드, 목적 유효성)
Controller -> Service: requestAIRecommendation(eventDraftId, userId)
activate Service
== 1단계: 이벤트 초안 조회 및 검증 ==
Service -> Repo: findById(eventDraftId)
activate Repo
Repo -> DB: 이벤트 초안 조회\n(초안ID로 이벤트 목적,\n매장 정보 조회)
activate DB
DB --> Repo: EventDraft 엔티티\n{목적, 매장명, 업종, 주소}
deactivate DB
Repo --> Service: EventDraft entity
deactivate Repo
Service -> Service: 소유권 검증\nvalidateOwnership(userId, eventDraft)
alt 소유권 없음
Service --> Controller: throw ForbiddenException\n("권한이 없습니다")
Controller --> Gateway: 403 Forbidden\n{"code": "EVENT_003",\n"message": "권한이 없습니다"}
deactivate Service
deactivate Controller
Gateway --> Client: 403 Forbidden
deactivate Gateway
else 소유권 확인
== 2단계: Kafka Job 생성 ==
Service -> JobSvc: createAIJob(eventDraft)
activate JobSvc
JobSvc -> JobSvc: Job ID 생성 (UUID)
JobSvc -> Cache: Job 상태 저장\nKey: job:{jobId}\nValue: {status: PENDING,\neventDraftId, type: AI_RECOMMEND,\ncreatedAt}\nTTL: 1시간
activate Cache
Cache --> JobSvc: 저장 완료
deactivate Cache
== 3단계: Kafka 이벤트 발행 ==
JobSvc -> Kafka: 이벤트 발행\nTopic: ai-job-topic\nPayload: {jobId, eventDraftId,\nobjective, industry,\nregion, storeInfo}
activate Kafka
note right of Kafka
**Kafka Topic**
- Topic: ai-job-topic
- Consumer: AI Service
- Consumer Group: ai-service-group
**Payload**
{
"jobId": "UUID",
"eventDraftId": "UUID",
"objective": "신규 고객 유치",
"industry": "음식점",
"region": "서울 강남구",
"storeInfo": {...}
}
end note
Kafka --> JobSvc: ACK (발행 확인)
deactivate Kafka
JobSvc --> Service: JobResponse\n{jobId, status: PENDING}
deactivate JobSvc
== 4단계: 응답 반환 ==
Service --> Controller: JobResponse\n{jobId, status: PENDING}
deactivate Service
Controller --> Gateway: 202 Accepted\n{"jobId": "job-uuid-123",\n"status": "PENDING",\n"message": "AI가 분석 중입니다"}
deactivate Controller
Gateway --> Client: 202 Accepted\nAI 분석 시작
deactivate Gateway
note over Service, Kafka
**AI Service 비동기 처리**
- Kafka 구독: ai-job-topic
- 트렌드 분석 (업종, 지역 기반)
- 3가지 추천안 생성 (저/중/고 비용)
- 결과: Redis에 저장 (TTL: 24시간)
- 상세: ai-트렌드분석및추천.puml 참조
**처리 시간**
- 평균: 2분 이내
- P95: 4분 이내
- Timeout: 5분
**결과 조회**
- 폴링 방식: GET /api/jobs/{jobId}/status
- 간격: 2초, 최대 30초
end note
end
@enduml

View File

@ -0,0 +1,73 @@
@startuml event-대시보드조회
!theme mono
title Event Service - 대시보드 이벤트 목록 (UFR-EVENT-010)
actor Client
participant "EventController" as Controller <<C>>
participant "EventService" as Service <<S>>
participant "EventRepository" as Repo <<R>>
participant "Redis Cache" as Cache <<E>>
database "Event DB" as DB <<E>>
note over Controller: GET /api/events/dashboard
Controller -> Service: getDashboard(userId)
activate Service
Service -> Cache: get("dashboard:" + userId)
activate Cache
alt 캐시 히트
Cache --> Service: Dashboard data
Service --> Controller: DashboardResponse
else 캐시 미스
Cache --> Service: null
deactivate Cache
group parallel
Service -> Repo: findTopByStatusAndUserId(ACTIVE, userId, limit=5)
activate Repo
Repo -> DB: 진행중 이벤트 목록 조회\n(사용자ID로 ACTIVE 상태 이벤트 조회,\n참여자 수 함께 조회,\n생성일 내림차순, 최대 5개)
activate DB
DB --> Repo: Active events
deactivate DB
Repo --> Service: List<Event> (active)
deactivate Repo
Service -> Repo: findTopByStatusAndUserId(APPROVED, userId, limit=5)
activate Repo
Repo -> DB: 예정 이벤트 목록 조회\n(사용자ID로 APPROVED 상태 이벤트 조회,\n승인일 내림차순, 최대 5개)
activate DB
DB --> Repo: Approved events
deactivate DB
Repo --> Service: List<Event> (approved)
deactivate Repo
Service -> Repo: findTopByStatusAndUserId(COMPLETED, userId, limit=5)
activate Repo
Repo -> DB: 종료 이벤트 목록 조회\n(사용자ID로 COMPLETED 상태 이벤트 조회,\n참여자 수 함께 조회,\n종료일 내림차순, 최대 5개)
activate DB
DB --> Repo: Completed events
deactivate DB
Repo --> Service: List<Event> (completed)
deactivate Repo
end
Service -> Service: buildDashboardResponse(active, approved, completed)
note right: 대시보드 데이터 구성:\n- 진행중: 5개\n- 예정: 5개\n- 종료: 5개\n각 카드에 기본 통계 포함
Service -> Cache: set("dashboard:" + userId,\ndashboard, TTL=1분)
activate Cache
Cache --> Service: OK
deactivate Cache
end
Service --> Controller: DashboardResponse\n{active: [...], approved: [...],\ncompleted: [...]}
deactivate Service
Controller --> Client: 200 OK\n{active: [\n {eventId, title, period, status,\n participantCount, viewCount, ...}\n],\napproved: [...],\ncompleted: [...]}
note over Controller, DB: 대시보드 카드 정보:\n- 이벤트명\n- 이벤트 기간\n- 진행 상태 뱃지\n- 간단한 통계\n (참여자 수, 조회수 등)\n\n섹션당 최대 5개 표시\n(최신 순)
@enduml

View File

@ -0,0 +1,64 @@
@startuml event-목록조회
!theme mono
title Event Service - 이벤트 목록 조회 (필터/검색) (UFR-EVENT-070)
participant "EventController" as Controller <<C>>
participant "EventService" as Service <<S>>
participant "EventRepository" as Repo <<R>>
participant "Redis Cache" as Cache <<E>>
database "Event DB" as DB <<E>>
note over Controller: GET /api/events?status={상태}&keyword={검색어}\n&page={페이지}&size={크기}
Controller -> Service: 이벤트 목록 조회(사용자ID, 필터, 페이징)
activate Service
Service -> Cache: 캐시 조회("events:" + 사용자ID + ":" + 필터 + ":" + 페이지)
activate Cache
alt 캐시 히트
Cache --> Service: 이벤트 목록 데이터
Service --> Controller: 이벤트 목록 응답
else 캐시 미스
Cache --> Service: null
deactivate Cache
Service -> Repo: 사용자별 필터링 이벤트 조회(사용자ID, 필터, 페이징)
activate Repo
alt 필터 있음 (상태별)
Repo -> DB: 사용자별 특정 상태 이벤트 조회\n(참여자 수 포함, 생성일 기준 내림차순,\n페이징 적용)
else 검색 있음 (키워드)
Repo -> DB: 사용자별 이벤트 키워드 검색\n(제목/설명에서 검색, 참여자 수 포함,\n생성일 기준 내림차순, 페이징 적용)
else 필터 없음 (전체)
Repo -> DB: 사용자별 전체 이벤트 목록 조회\n(참여자 수 포함, 생성일 기준 내림차순,\n페이징 적용)
end
activate DB
note right: 인덱스 활용:\n- 사용자ID\n- 상태\n- 생성일시
DB --> Repo: 이벤트 목록 및 참여자 수
deactivate DB
Repo -> DB: 전체 이벤트 개수 조회\n(필터 조건 포함, 페이징용)
activate DB
DB --> Repo: 전체 개수
deactivate DB
Repo --> Service: 페이징된 이벤트 결과
deactivate Repo
Service -> Cache: 캐시 저장("events:" + 사용자ID + ":" + 필터 + ":" + 페이지,\n페이징결과, TTL=1분)
activate Cache
Cache --> Service: OK
deactivate Cache
end
Service --> Controller: 이벤트 목록 응답\n{이벤트목록: [...], 전체개수,\n전체페이지수, 현재페이지}
deactivate Service
Controller --> Client: 200 OK\n{이벤트목록: [\n {이벤트ID, 제목, 기간, 상태,\n 참여자수, ROI, 생성일시},\n ...\n],\n전체개수, 전체페이지수, 현재페이지}
note over Controller, DB: 필터 옵션:\n- 상태: 임시저장, 진행중, 완료\n- 기간: 최근 1개월/3개월/6개월/1년\n- 정렬: 최신순, 참여자 많은 순,\n ROI 높은 순\n\n페이지네이션:\n- 기본 20개/페이지\n- 페이지 번호 기반
@enduml

View File

@ -0,0 +1,110 @@
@startuml event-목적선택
!theme mono
title Event Service - 이벤트 목적 선택 및 저장 (UFR-EVENT-020)
participant "EventController" as Controller <<API Layer>>
participant "EventService" as Service <<Business Layer>>
participant "EventRepository" as Repo <<Data Layer>>
participant "Redis Cache" as Cache <<E>>
database "Event DB" as DB <<E>>
participant "Kafka Producer" as Kafka <<E>>
actor Client
note over Controller, DB
**UFR-EVENT-020: 이벤트 목적 선택 및 저장**
- 목적 선택: 신규 고객 유치, 재방문 유도, 매출 증대, 인지도 향상
- Redis 캐시 사용 (TTL: 30분)
- Kafka 이벤트 발행 (EventDraftCreated)
- 사용자 및 매장 정보는 User Service에서 조회 후 전달됨
end note
Client -> Controller: POST /api/events/purposes\n{"userId": 123,\n"objective": "신규 고객 유치",\n"storeName": "맛있는집",\n"industry": "음식점",\n"address": "서울시 강남구"}
activate Controller
Controller -> Controller: 입력값 검증\n(필수 필드, 목적 유효성 확인)
Controller -> Service: createEventDraft(userId, objective, storeInfo)
activate Service
== 1단계: Redis 캐시 확인 ==
Service -> Cache: 캐시 조회\nKey: draft:event:{userId}\n(기존 작성 중인 이벤트 확인)
activate Cache
Cache --> Service: null (캐시 미스)
deactivate Cache
== 2단계: 목적 유효성 검증 ==
Service -> Service: 목적 유효성 검증\n- 신규 고객 유치\n- 재방문 유도\n- 매출 증대\n- 인지도 향상
Service -> Service: 매장 정보 유효성 검증\n(매장명, 업종, 주소)
== 3단계: 이벤트 초안 저장 ==
Service -> Repo: save(eventDraft)
activate Repo
Repo -> DB: 이벤트 초안 저장\n(사용자ID, 목적, 매장명,\n업종, 주소, 상태=DRAFT,\n생성일시)\n저장 후 이벤트초안ID 반환
activate DB
DB --> Repo: 생성된 이벤트초안ID
deactivate DB
Repo --> Service: EventDraft 엔티티\n(eventDraftId 포함)
deactivate Repo
== 4단계: Redis 캐시 저장 ==
Service -> Cache: 캐시 저장\nKey: draft:event:{eventDraftId}\nValue: {목적, 매장정보, 상태}\nTTL: 24시간
activate Cache
Cache --> Service: 저장 완료
deactivate Cache
== 5단계: Kafka 이벤트 발행 ==
Service -> Kafka: 이벤트 발행\nTopic: event-topic\nEvent: EventDraftCreated\nPayload: {eventDraftId,\nuserId, objective,\ncreatedAt}
activate Kafka
note right of Kafka
**Kafka Event Topic**
- Topic: event-topic
- Event: EventDraftCreated
- 목적 선택 시 발행
**구독자**
- Analytics Service (선택적)
**참고**
- EventCreated는
최종 승인 시 발행
end note
Kafka --> Service: ACK (발행 확인)
deactivate Kafka
== 6단계: 응답 반환 ==
Service -> Service: 응답 DTO 생성
Service --> Controller: EventDraftResponse\n{eventDraftId, objective,\nstoreName, status=DRAFT}
deactivate Service
Controller --> Client: 200 OK\n{"eventDraftId": "draft-123",\n"objective": "신규 고객 유치",\n"storeName": "맛있는집",\n"status": "DRAFT"}
deactivate Controller
note over Controller, Kafka
**캐시 전략**
- Key: draft:event:{eventDraftId}
- TTL: 24시간
- 캐시 히트 시: DB 조회 생략, 즉시 반환
**이벤트 발행 전략**
- EventDraftCreated: 목적 선택 시 발행 (Analytics Service 선택적 구독)
- EventCreated: 최종 승인 시 발행 (통계 초기화 시작)
**성능 목표**
- 평균 응답 시간: 0.3초 이내
- P95 응답 시간: 0.5초 이내
- Redis 캐시 조회: 0.05초 이내
**에러 코드**
- EVENT_001: 유효하지 않은 목적
- EVENT_002: 매장 정보 누락
end note
@enduml

View File

@ -0,0 +1,54 @@
@startuml event-상세조회
!theme mono
title Event Service - 이벤트 상세 조회 (UFR-EVENT-060)
participant "EventController" as Controller <<C>>
participant "EventService" as Service <<S>>
participant "EventRepository" as Repo <<R>>
participant "Redis Cache" as Cache <<E>>
database "Event DB" as DB <<E>>
note over Controller: GET /api/events/{id}
Controller -> Service: getEventDetail(eventId, userId)
activate Service
Service -> Cache: get("event:" + eventId)
activate Cache
alt 캐시 히트
Cache --> Service: Event data
Service -> Service: validateAccess(userId, event)
note right: 사용자 권한 검증
Service --> Controller: EventDetailResponse
else 캐시 미스
Cache --> Service: null
deactivate Cache
Service -> Repo: findById(eventId)
activate Repo
Repo -> DB: 이벤트 상세 정보 조회\n(이벤트ID로 이벤트 정보,\n경품 정보, 배포 이력을\nJOIN하여 함께 조회)
activate DB
note right: JOIN으로\n경품 정보 및\n배포 이력 조회
DB --> Repo: Event with prizes and distributions
deactivate DB
Repo --> Service: Event entity (with relations)
deactivate Repo
Service -> Service: validateAccess(userId, event)
Service -> Cache: set("event:" + eventId, event, TTL=5분)
activate Cache
Cache --> Service: OK
deactivate Cache
end
Service --> Controller: EventDetailResponse\n{eventId, title, objective,\nprizes, period, status,\nchannels, distributionStatus,\ncreatedAt, publishedAt}
deactivate Service
Controller --> Client: 200 OK\n{event: {...},\nprizes: [...],\ndistributionStatus: {...}}
note over Controller, DB: 상세 정보 포함:\n- 기본 정보 (제목, 목적, 기간, 상태)\n- 경품 정보\n- 참여 방법\n- 배포 채널 현황\n- 실시간 통계 (Analytics Service)\n\nAnalytics 통계는\n별도 API 호출
@enduml

View File

@ -0,0 +1,157 @@
@startuml event-최종승인및배포
!theme mono
title Event Service - 최종 승인 및 Distribution Service 동기 호출 (UFR-EVENT-050)
actor Client
participant "API Gateway" as Gateway
participant "EventController" as Controller <<API Layer>>
participant "EventService" as Service <<Business Layer>>
participant "EventRepository" as Repo <<Data Layer>>
participant "Redis Cache" as Cache <<E>>
database "Event DB" as DB <<E>>
participant "Distribution Service" as DistSvc <<E>>
participant "Kafka Producer" as Kafka <<E>>
note over Controller, Kafka
**UFR-EVENT-050: 이벤트 최종 승인 및 배포**
- 이벤트 준비 상태 검증
- 이벤트 승인 및 Kafka 이벤트 발행
- Distribution Service 동기 호출 (다중 채널 배포)
- 이벤트 상태를 ACTIVE로 변경
end note
Client -> Gateway: POST /api/events/{eventDraftId}/publish\n{"userId": 123,\n"selectedChannels": [\n "우리동네TV",\n "지니TV",\n "Instagram"\n]}
activate Gateway
Gateway -> Controller: POST /api/events/{eventDraftId}/publish
activate Controller
Controller -> Controller: 요청 검증\n(필수 필드, 채널 유효성)
Controller -> Service: publishEvent(eventDraftId, userId, selectedChannels)
activate Service
== 1단계: 이벤트 초안 조회 및 검증 ==
Service -> Repo: findById(eventDraftId)
activate Repo
Repo -> DB: 이벤트 초안 조회\n(초안ID로 조회)
activate DB
DB --> Repo: EventDraft 엔티티\n{목적, 추천안, 콘텐츠, 상태}
deactivate DB
Repo --> Service: EventDraft entity
deactivate Repo
Service -> Service: validateOwnership(userId, eventDraft)
note right
소유권 검증:
- 사용자ID와 초안 소유자 일치 확인
- 권한 없으면 403 Forbidden
end note
Service -> Service: validatePublishReady()
note right
발행 준비 검증:
- 목적 선택 완료
- AI 추천 선택 완료
- 콘텐츠 선택 완료
- 배포 채널 최소 1개 선택
end note
== 2단계: 이벤트 승인 ==
Service -> Repo: updateStatus(eventDraftId, APPROVED)
activate Repo
Repo -> DB: 이벤트 초안 상태 업데이트\n(상태를 APPROVED로,\n승인일시를 현재 시각으로 저장)
activate DB
DB --> Repo: 업데이트 완료
deactivate DB
Repo --> Service: EventDraft entity
deactivate Repo
== 3단계: Kafka 이벤트 발행 ==
Service -> Kafka: 이벤트 발행\nTopic: event-topic\nPayload: {eventId, userId, title,\nobjective, createdAt}
activate Kafka
note right
Kafka Event Topic:
- Topic: event-topic
- Consumer: Analytics Service
- Event Type: EventCreated
end note
Kafka --> Service: ACK (발행 확인)
deactivate Kafka
== 4단계: Distribution Service 동기 호출 ==
Service -> DistSvc: POST /api/distribution/distribute\n{"eventId": 123,\n"channels": [...],\n"content": {...}}
activate DistSvc
note right
동기 호출 (Circuit Breaker 적용):
- Timeout: 70초
- 다중 채널 병렬 배포
- Failure Rate: 50% 초과 시 OPEN
end note
DistSvc -> DistSvc: distributeToChannels(eventId, channels)
note right
다중 채널 병렬 배포:
- 우리동네TV
- 링고비즈 (음성 안내)
- 지니TV
- Instagram
- Naver Blog
- Kakao Channel
end note
DistSvc --> Service: DistributionResponse\n{"distributionId": "dist-123",\n"channelResults": [\n {"channel": "우리동네TV", "status": "SUCCESS"},\n {"channel": "지니TV", "status": "SUCCESS"},\n {"channel": "Instagram", "status": "SUCCESS"}\n]}
deactivate DistSvc
== 5단계: 이벤트 활성화 ==
Service -> Repo: updateStatus(eventDraftId, ACTIVE)
activate Repo
Repo -> DB: 이벤트 상태 업데이트\n(상태를 ACTIVE로,\n배포일시를 현재 시각으로 저장)
activate DB
DB --> Repo: 업데이트 완료
deactivate DB
Repo --> Service: Event entity
deactivate Repo
== 6단계: 캐시 무효화 ==
Service -> Cache: 캐시 삭제\nKey: draft:event:{eventDraftId}
activate Cache
note right
Redis 캐시 무효화:
- 이벤트 초안 캐시 삭제
- 활성화 완료 후 불필요
end note
Cache --> Service: 삭제 완료
deactivate Cache
== 7단계: 응답 반환 ==
Service --> Controller: PublishResponse\n{eventId, status: ACTIVE,\ndistributionResults}
deactivate Service
Controller --> Gateway: 200 OK\n{"eventId": 123,\n"status": "ACTIVE",\n"distributionResults": [...]}
deactivate Controller
Gateway --> Client: 200 OK\n이벤트 배포 완료
deactivate Gateway
note over Client, Kafka
**배포 완료 후 처리**
- Distribution Service는 배포 완료 후 Kafka에\n DistributionCompleted 이벤트 발행
- Analytics Service가 구독하여 초기 통계 생성
- 이벤트 상태: ACTIVE (참여자 접수 시작)
**성능 목표**
- 응답 시간: 60초 이내 (Distribution Service 포함)
- Distribution Service 타임아웃: 70초
- 채널별 배포: 병렬 처리로 최적화
end note
@enduml

View File

@ -0,0 +1,140 @@
@startuml event-추천결과조회
!theme mono
title Event Service - AI 추천 결과 폴링 조회
actor Client
participant "API Gateway" as Gateway
participant "EventController" as Controller <<API Layer>>
participant "EventService" as Service <<Business Layer>>
participant "JobManager" as JobMgr <<Component>>
participant "Redis Cache" as Cache <<E>>
note over Controller, Cache
**폴링 방식 Job 상태 조회**
- 최대 30초 동안 폴링 (2초 간격)
- Job 상태: PENDING → PROCESSING → COMPLETED
- AI 추천 결과: Redis에 저장 (TTL: 24시간)
end note
Client -> Gateway: GET /api/events/jobs/{jobId}/status
activate Gateway
Gateway -> Controller: GET /api/events/jobs/{jobId}/status
activate Controller
Controller -> Service: getJobStatus(jobId)
activate Service
Service -> JobMgr: getJobStatus(jobId)
activate JobMgr
JobMgr -> Cache: Job 상태 조회\nKey: job:{jobId}
activate Cache
alt Job 데이터 존재
Cache --> JobMgr: Job 데이터\n{status, eventDraftId,\ntype, createdAt}
deactivate Cache
alt status = COMPLETED
JobMgr -> Cache: AI 추천 결과 조회\nKey: ai:recommendation:{eventDraftId}
activate Cache
Cache --> JobMgr: AI 추천 결과\n{트렌드분석, 3가지추천안}
deactivate Cache
JobMgr --> Service: JobStatusResponse\n{jobId, status: COMPLETED,\nrecommendations: {...}}
deactivate JobMgr
Service --> Controller: JobStatusResponse\n{status: COMPLETED, recommendations}
deactivate Service
Controller --> Gateway: 200 OK\n{"status": "COMPLETED",\n"recommendations": [\n {"title": "저비용 추천안",\n "prize": "커피쿠폰",\n "method": "QR코드 스캔",\n "cost": "50만원",\n "roi": "150%"},\n {"title": "중비용 추천안",\n "prize": "상품권",\n "method": "SNS 공유",\n "cost": "100만원",\n "roi": "200%"},\n {"title": "고비용 추천안",\n "prize": "경품 추첨",\n "method": "설문 참여",\n "cost": "200만원",\n "roi": "300%"}\n]}
deactivate Controller
Gateway --> Client: 200 OK\nAI 추천 결과 반환
deactivate Gateway
note right of Client
**프론트엔드 처리**
- 3가지 추천안 카드 표시
- 사용자가 추천안 선택
- 트렌드 분석 정보 표시
end note
else status = PROCESSING 또는 PENDING
JobMgr --> Service: JobStatusResponse\n{jobId, status: PROCESSING}
deactivate JobMgr
Service --> Controller: JobStatusResponse\n{status: PROCESSING}
deactivate Service
Controller --> Gateway: 200 OK\n{"status": "PROCESSING",\n"message": "AI가 분석 중입니다"}
deactivate Controller
Gateway --> Client: 200 OK\n진행 중 상태
deactivate Gateway
note right of Client
**폴링 재시도**
- 2초 후 재요청
- 최대 30초 (15회)
end note
else status = FAILED
JobMgr -> Cache: 에러 정보 조회\nKey: job:{jobId}:error
activate Cache
Cache --> JobMgr: 에러 메시지
deactivate Cache
JobMgr --> Service: JobStatusResponse\n{jobId, status: FAILED, error}
deactivate JobMgr
Service --> Controller: JobStatusResponse\n{status: FAILED, error}
deactivate Service
Controller --> Gateway: 200 OK\n{"status": "FAILED",\n"error": "AI 분석 실패",\n"message": "다시 시도해주세요"}
deactivate Controller
Gateway --> Client: 200 OK\n실패 상태
deactivate Gateway
note right of Client
**실패 처리**
- 에러 메시지 표시
- "다시 분석" 버튼 제공
end note
end
else Job 데이터 없음
Cache --> JobMgr: null (캐시 미스)
deactivate Cache
JobMgr --> Service: throw NotFoundException\n("Job을 찾을 수 없습니다")
deactivate JobMgr
Service --> Controller: NotFoundException
deactivate Service
Controller --> Gateway: 404 Not Found\n{"code": "JOB_001",\n"message": "Job을 찾을 수 없습니다"}
deactivate Controller
Gateway --> Client: 404 Not Found
deactivate Gateway
end
note over Controller, Cache
**폴링 전략**
- 간격: 2초
- 최대 시간: 30초 (15회)
- Timeout 시: 사용자에게 알림 + "다시 분석" 옵션
**Redis 캐시**
- Job 상태: TTL 1시간
- AI 추천 결과: TTL 24시간
**성능 목표**
- 평균 AI 분석 시간: 2분 이내
- P95 AI 분석 시간: 4분 이내
end note
@enduml

View File

@ -0,0 +1,116 @@
@startuml event-추천안선택
!theme mono
title Event Service - 선택한 AI 추천안 저장 (UFR-EVENT-040)
actor Client
participant "API Gateway" as Gateway
participant "EventController" as Controller <<API Layer>>
participant "EventService" as Service <<Business Layer>>
participant "EventRepository" as Repo <<Data Layer>>
participant "Redis Cache" as Cache <<E>>
database "Event DB" as DB <<E>>
note over Controller, Cache
**UFR-EVENT-040: AI 추천안 선택 및 저장**
- 사용자가 3가지 추천안 중 하나를 선택
- 선택된 추천안을 이벤트 초안에 적용
- Redis 캐시에서 AI 추천 결과 삭제
end note
Client -> Gateway: PUT /api/events/drafts/{eventDraftId}/recommendation\n{"userId": 123,\n"selectedIndex": 1,\n"recommendation": {...}}
activate Gateway
Gateway -> Controller: PUT /api/events/drafts/{eventDraftId}/recommendation
activate Controller
Controller -> Controller: 요청 검증\n(필수 필드, 추천안 유효성)
Controller -> Service: updateEventRecommendation(eventDraftId, userId,\nselectedRecommendation)
activate Service
== 1단계: 이벤트 초안 조회 및 검증 ==
Service -> Repo: findById(eventDraftId)
activate Repo
Repo -> DB: 이벤트 초안 조회\n(초안ID로 조회)
activate DB
DB --> Repo: EventDraft 엔티티\n{목적, 매장정보, 상태}
deactivate DB
Repo --> Service: EventDraft entity
deactivate Repo
Service -> Service: validateOwnership(userId, eventDraft)
note right
소유권 검증:
- 사용자ID와 초안 소유자 일치 확인
- 권한 없으면 403 Forbidden
end note
Service -> Service: validateRecommendation(selectedRecommendation)
note right
추천안 유효성 검증:
- 필수 필드 존재 여부
- 비용/ROI 값 타당성
end note
== 2단계: 추천안 적용 ==
Service -> Service: applyRecommendation(eventDraft, selectedRecommendation)
note right
추천안 적용:
- 이벤트 제목
- 경품 정보
- 참여 방법
- 예상 비용
- 예상 ROI
- 홍보 문구
end note
== 3단계: DB 저장 ==
Service -> Repo: update(eventDraft)
activate Repo
Repo -> DB: 이벤트 초안 업데이트\n(선택된 추천안 정보 저장:\n제목, 경품, 참여방법,\n예상비용, 예상ROI,\n수정일시)
activate DB
DB --> Repo: 업데이트 완료
deactivate DB
Repo --> Service: EventDraft entity
deactivate Repo
== 4단계: 캐시 무효화 ==
Service -> Cache: 캐시 삭제\nKey: ai:recommendation:{eventDraftId}
activate Cache
note right
Redis 캐시 무효화:
- AI 추천 결과 삭제
- 선택 완료 후 불필요
end note
Cache --> Service: 삭제 완료
deactivate Cache
== 5단계: 응답 반환 ==
Service --> Controller: EventRecommendationResponse\n{eventDraftId, selectedRecommendation}
deactivate Service
Controller --> Gateway: 200 OK\n{"eventDraftId": 123,\n"status": "추천안 선택 완료",\n"selectedRecommendation": {...}}
deactivate Controller
Gateway --> Client: 200 OK\n추천안 선택 완료
deactivate Gateway
note over Client, Cache
**저장 내용**
- 최종 선택된 추천안만 Event DB에 저장
- Redis에 저장된 3가지 추천안은 선택 후 삭제
- 다음 단계: 콘텐츠 생성 (이미지 선택)
**성능 목표**
- 응답 시간: 0.5초 이내
- DB 업데이트: 0.1초
- 캐시 삭제: 0.01초
end note
@enduml

View File

@ -0,0 +1,118 @@
@startuml event-콘텐츠선택
!theme mono
title Content Service - 선택한 콘텐츠 저장 (UFR-CONT-020)
actor Client
participant "API Gateway" as Gateway
participant "ContentController" as Controller <<API Layer>>
participant "ContentService" as Service <<Business Layer>>
participant "EventRepository" as Repo <<Data Layer>>
participant "Redis Cache" as Cache <<E>>
database "Event DB" as DB <<E>>
note over Controller, Cache
**UFR-CONT-020: 콘텐츠 선택 및 편집 저장**
- 사용자가 3가지 이미지 스타일 중 하나 선택
- 선택된 이미지에 텍스트/색상 편집 적용
- 편집된 콘텐츠를 이벤트 초안에 저장
end note
Client -> Gateway: PUT /api/content/{eventDraftId}/select\n{"userId": 123,\n"selectedImageUrl": "https://cdn.../fancy.png",\n"editedContent": {...}}
activate Gateway
Gateway -> Controller: PUT /api/content/{eventDraftId}/select
activate Controller
Controller -> Controller: 요청 검증\n(필수 필드, URL 유효성)
Controller -> Service: updateEventContent(eventDraftId, userId,\nselectedImageUrl, editedContent)
activate Service
== 1단계: 이벤트 초안 조회 및 검증 ==
Service -> Repo: findById(eventDraftId)
activate Repo
Repo -> DB: 이벤트 초안 조회\n(초안ID로 조회)
activate DB
DB --> Repo: EventDraft 엔티티\n{목적, 추천안, 상태}
deactivate DB
Repo --> Service: EventDraft entity
deactivate Repo
Service -> Service: validateOwnership(userId, eventDraft)
note right
소유권 검증:
- 사용자ID와 초안 소유자 일치 확인
- 권한 없으면 403 Forbidden
end note
Service -> Service: validateImageUrl(selectedImageUrl)
note right
이미지 URL 검증:
- URL 형식 유효성
- CDN 경로 확인
- 이미지 존재 여부
end note
== 2단계: 콘텐츠 편집 적용 ==
Service -> Service: applyContentEdits(eventDraft, editedContent)
note right
편집 내용 적용:
- 제목 텍스트
- 경품 정보 텍스트
- 참여 안내 텍스트
- 배경색
- 텍스트 색상
- 강조 색상
end note
== 3단계: DB 저장 ==
Service -> Repo: update(eventDraft)
activate Repo
Repo -> DB: 이벤트 초안 업데이트\n(선택된 이미지 URL,\n편집된 제목/텍스트,\n배경색/텍스트색,\n수정일시 저장)
activate DB
DB --> Repo: 업데이트 완료
deactivate DB
Repo --> Service: EventDraft entity
deactivate Repo
== 4단계: 캐시 무효화 ==
Service -> Cache: 캐시 삭제\nKey: content:image:{eventDraftId}
activate Cache
note right
Redis 캐시 무효화:
- 이미지 URL 캐시 삭제
- 선택 완료 후 불필요
end note
Cache --> Service: 삭제 완료
deactivate Cache
== 5단계: 응답 반환 ==
Service --> Controller: EventContentResponse\n{eventDraftId, selectedImageUrl,\neditedContent}
deactivate Service
Controller --> Gateway: 200 OK\n{"eventDraftId": 123,\n"status": "콘텐츠 선택 완료",\n"selectedImageUrl": "...",\n"editedContent": {...}}
deactivate Controller
Gateway --> Client: 200 OK\n콘텐츠 선택 완료
deactivate Gateway
note over Client, Cache
**저장 내용**
- 선택된 이미지 URL
- 편집된 텍스트 (제목, 경품 정보, 참여 안내)
- 편집된 색상 (배경색, 텍스트색, 강조색)
- 다음 단계: 최종 승인 및 배포
**성능 목표**
- 응답 시간: 0.5초 이내
- DB 업데이트: 0.1초
- 캐시 삭제: 0.01초
end note
@enduml

View File

@ -0,0 +1,164 @@
@startuml participation-당첨자추첨
!theme mono
title Participation Service - 당첨자 추첨 내부 시퀀스
actor "사장님" as Owner
participant "API Gateway" as Gateway
participant "ParticipationController" as Controller
participant "ParticipationService" as Service
participant "LotteryAlgorithm" as Lottery
participant "ParticipantRepository" as Repo
participant "DrawLogRepository" as LogRepo
database "Participation DB" as DB
== UFR-PART-030: 당첨자 추첨 ==
Owner -> Gateway: POST /api/v1/events/{eventId}/draw-winners\n{winnerCount, visitBonus, algorithm}
activate Gateway
Gateway -> Gateway: JWT 토큰 검증\n- 토큰 유효성 확인\n- 사장님 권한 확인
alt JWT 검증 실패
Gateway --> Owner: 401 Unauthorized
deactivate Gateway
else JWT 검증 성공
Gateway -> Controller: POST /participations/draw-winners\n{eventId, winnerCount, visitBonus}
activate Controller
Controller -> Controller: 요청 데이터 유효성 검증\n- eventId 필수\n- winnerCount > 0\n- winnerCount <= 참여자 수
alt 유효성 검증 실패
Controller --> Gateway: 400 Bad Request
Gateway --> Owner: 400 Bad Request
deactivate Controller
deactivate Gateway
else 유효성 검증 성공
Controller -> Service: drawWinners(eventId, winnerCount, visitBonus)
activate Service
Service -> Service: 이벤트 상태 확인\n- 이벤트 종료 여부\n- 이미 추첨 완료 여부
Service -> LogRepo: findByEventId(eventId)
activate LogRepo
LogRepo -> DB: 추첨 로그 조회\n(이벤트ID로 조회)
activate DB
DB --> LogRepo: 추첨 로그 조회
deactivate DB
LogRepo --> Service: Optional<DrawLog>
deactivate LogRepo
alt 이미 추첨 완료
Service --> Controller: AlreadyDrawnException
Controller --> Gateway: 409 Conflict\n{message: "이미 추첨이 완료된 이벤트입니다"}
Gateway --> Owner: 409 Conflict
deactivate Service
deactivate Controller
deactivate Gateway
else 추첨 가능 상태
Service -> Repo: findAllByEventIdAndIsWinner(eventId, false)
activate Repo
Repo -> DB: 미당첨 참여자 목록 조회\n(이벤트ID로 당첨되지 않은\n참여자 전체 조회,\n참여일시 오름차순 정렬)
activate DB
DB --> Repo: 전체 참여자 목록
deactivate DB
Repo --> Service: List<Participant>
deactivate Repo
alt 참여자 수 부족
Service --> Controller: InsufficientParticipantsException
Controller --> Gateway: 400 Bad Request\n{message: "참여자 수가 부족합니다"}
Gateway --> Owner: 400 Bad Request
deactivate Service
deactivate Controller
deactivate Gateway
else 추첨 진행
Service -> Lottery: executeLottery(participants, winnerCount, visitBonus)
activate Lottery
note right of Lottery
추첨 알고리즘:
시간 복잡도: O(n log n)
공간 복잡도: O(n)
1. 난수 생성 (Crypto.randomBytes)
2. 매장 방문 가산점 적용 (옵션)
- 방문 고객: 가중치 2배
- 비방문 고객: 가중치 1배
3. Fisher-Yates Shuffle
- 가중치 기반 확률 분포
- 무작위 섞기
4. 상위 N명 선정
end note
Lottery -> Lottery: Step 1: 난수 시드 생성\n- Crypto.randomBytes(32)\n- 예측 불가능한 난수 보장
Lottery -> Lottery: Step 2: 가산점 적용\n- visitBonus = true일 경우\n- 매장 방문 경로 참여자 가중치 증가
Lottery -> Lottery: Step 3: Fisher-Yates Shuffle\n- 가중치 기반 확률 분포\n- O(n) 시간 복잡도
Lottery -> Lottery: Step 4: 당첨자 선정\n- 상위 winnerCount명 추출
Lottery --> Service: List<Participant> 당첨자 목록
deactivate Lottery
Service -> Service: DB 트랜잭션 시작
alt DB 저장 실패 시
note right of Service
트랜잭션 롤백 처리:
- 당첨자 업데이트 취소
- 추첨 로그 저장 취소
- 재시도 가능 상태 유지
end note
end
Service -> Repo: updateWinners(winnerIds)
activate Repo
Repo -> DB: 당첨자 정보 업데이트\n(당첨 여부를 true로,\n당첨 일시를 현재 시각으로 설정,\n대상: 선정된 참여자ID 목록)
activate DB
DB --> Repo: 업데이트 완료
deactivate DB
Repo --> Service: 업데이트 건수
deactivate Repo
Service -> Service: DrawLog 엔티티 생성\n- drawLogId (UUID)\n- eventId\n- drawMethod: "RANDOM"\n- algorithm: "FISHER_YATES_SHUFFLE"\n- visitBonusApplied\n- winnerCount\n- drawnAt (현재시각)
Service -> LogRepo: save(drawLog)
activate LogRepo
LogRepo -> DB: 추첨 로그 저장\n(추첨로그ID, 이벤트ID,\n추첨방법, 알고리즘,\n가산점적용여부, 당첨인원,\n추첨일시 저장)
activate DB
note right of DB
추첨 로그 저장:
- 추첨 일시 기록
- 알고리즘 버전 기록
- 가산점 적용 여부
- 감사 추적 목적
end note
DB --> LogRepo: 로그 저장 완료
deactivate DB
LogRepo --> Service: DrawLog 엔티티
deactivate LogRepo
Service -> Service: DB 트랜잭션 커밋
Service --> Controller: DrawWinnersResponse\n{당첨자목록, 추첨로그ID}
deactivate Service
Controller --> Gateway: 200 OK\n{winners[], drawLogId, message}
deactivate Controller
Gateway --> Owner: 200 OK
deactivate Gateway
Owner -> Owner: 당첨자 목록 화면 표시\n- 당첨자 정보 테이블\n- 재추첨 버튼\n- 추첨 완료 메시지
end
end
end
end
@enduml

View File

@ -0,0 +1,129 @@
@startuml participation-이벤트참여
!theme mono
title Participation Service - 이벤트 참여 내부 시퀀스
actor "고객" as Customer
participant "API Gateway" as Gateway
participant "ParticipationController" as Controller
participant "ParticipationService" as Service
participant "ParticipantRepository" as Repo
database "Participation DB" as DB
participant "KafkaProducer" as Kafka
database "Redis Cache<<E>>" as Cache
== UFR-PART-010: 이벤트 참여 ==
Customer -> Gateway: POST /api/v1/participations\n{name, phone, eventId, entryPath, consent}
activate Gateway
note right of Gateway
비회원 참여 가능
JWT 검증 불필요
end note
Gateway -> Controller: POST /participations/register\n{name, phone, eventId, entryPath, consent}
activate Controller
Controller -> Controller: 요청 데이터 유효성 검증\n- 이름 2자 이상\n- 전화번호 형식 (정규식)\n- 개인정보 동의 필수
alt 유효성 검증 실패
Controller --> Gateway: 400 Bad Request\n{message: "유효성 오류"}
Gateway --> Customer: 400 Bad Request
deactivate Controller
deactivate Gateway
else 유효성 검증 성공
Controller -> Service: registerParticipant(request)
activate Service
Service -> Cache: GET duplicate_check:{eventId}:{phone}
activate Cache
Cache --> Service: 캐시 확인 결과
deactivate Cache
alt 캐시 HIT: 중복 참여
Service --> Controller: DuplicateParticipationException
Controller --> Gateway: 409 Conflict\n{message: "이미 참여하신 이벤트입니다"}
Gateway --> Customer: 409 Conflict
deactivate Service
deactivate Controller
deactivate Gateway
else 캐시 MISS: DB 조회
Service -> Repo: findByEventIdAndPhoneNumber(eventId, phone)
activate Repo
Repo -> DB: 참여자 중복 확인\n(이벤트ID, 전화번호로 조회)
activate DB
DB --> Repo: 조회 결과
deactivate DB
Repo --> Service: Optional<Participant>
deactivate Repo
alt DB에 중복 참여 존재
Service -> Cache: SET duplicate_check:{eventId}:{phone} = true\nTTL: 7일
activate Cache
Cache --> Service: 캐시 저장 완료
deactivate Cache
Service --> Controller: DuplicateParticipationException
Controller --> Gateway: 409 Conflict\n{message: "이미 참여하신 이벤트입니다"}
Gateway --> Customer: 409 Conflict
deactivate Service
deactivate Controller
deactivate Gateway
else 신규 참여: 저장 진행
Service -> Service: 응모 번호 생성\n- UUID 기반\n- 형식: EVT-{timestamp}-{random}
Service -> Service: Participant 엔티티 생성\n- participantId (UUID)\n- eventId\n- name, phoneNumber\n- entryPath\n- applicationNumber (응모번호)\n- participatedAt (현재시각)
Service -> Repo: save(participant)
activate Repo
Repo -> DB: 참여자 정보 저장\n(참여자ID, 이벤트ID, 이름, 전화번호,\n참여경로, 응모번호, 참여일시,\n마케팅동의여부)
activate DB
DB --> Repo: 저장 완료
deactivate DB
Repo --> Service: Participant 엔티티 반환
deactivate Repo
note right of Service
참여자 등록 완료 후
캐싱 및 이벤트 발행
end note
Service -> Cache: SET duplicate_check:{eventId}:{phone} = true\nTTL: 7일
activate Cache
Cache --> Service: 캐시 저장 완료
deactivate Cache
Service -> Kafka: Publish Event\n"ParticipantRegistered"\nTopic: participant-events
activate Kafka
note right of Kafka
Event Payload:
{
"participantId": "UUID",
"eventId": "UUID",
"phoneNumber": "010-1234-5678",
"entryPath": "SNS",
"registeredAt": "2025-10-22T10:30:00Z"
}
end note
Kafka --> Service: 이벤트 발행 완료
deactivate Kafka
Service -> Service: 당첨 발표일 계산\n- 이벤트 종료일 + 3일
Service --> Controller: ParticipationResponse\n{응모번호, 당첨발표일, 참여완료메시지}
deactivate Service
Controller --> Gateway: 201 Created\n{applicationNumber, drawDate, message}
deactivate Controller
Gateway --> Customer: 201 Created\n참여 완료 화면 표시
deactivate Gateway
end
end
end
@enduml

View File

@ -0,0 +1,120 @@
@startuml participation-참여자목록조회
!theme mono
title Participation Service - 참여자 목록 조회 내부 시퀀스
actor "사장님" as Owner
participant "API Gateway" as Gateway
participant "ParticipationController" as Controller
participant "ParticipationService" as Service
participant "ParticipantRepository" as Repo
database "Participation DB" as DB
database "Redis Cache<<E>>" as Cache
== UFR-PART-020: 참여자 목록 조회 ==
Owner -> Gateway: GET /api/v1/events/{eventId}/participants\n?entryPath={경로}&isWinner={당첨여부}\n&name={이름}&phone={전화번호}\n&page={페이지}&size={크기}
activate Gateway
Gateway -> Gateway: JWT 토큰 검증\n- 토큰 유효성 확인\n- 사장님 권한 확인
alt JWT 검증 실패
Gateway --> Owner: 401 Unauthorized
deactivate Gateway
else JWT 검증 성공
Gateway -> Controller: GET /participants\n{eventId, filters, pagination}
activate Controller
Controller -> Controller: 요청 파라미터 유효성 검증\n- eventId 필수\n- page >= 0\n- size: 10~100
alt 유효성 검증 실패
Controller --> Gateway: 400 Bad Request
Gateway --> Owner: 400 Bad Request
deactivate Controller
deactivate Gateway
else 유효성 검증 성공
Controller -> Service: getParticipantList(eventId, filters, pageable)
activate Service
Service -> Service: 캐시 키 생성\n- participant_list:{eventId}:{filters}:{page}
Service -> Cache: GET participant_list:{key}
activate Cache
Cache --> Service: 캐시 조회 결과
deactivate Cache
alt 캐시 HIT
Service --> Controller: ParticipantListResponse\n(캐시된 데이터)
note right of Service
캐시된 데이터 반환
- TTL: 10분
- 실시간 정확도 vs 성능 트레이드오프
end note
Controller --> Gateway: 200 OK\n{participants, totalElements, totalPages}
Gateway --> Owner: 200 OK\n참여자 목록 표시
deactivate Service
deactivate Controller
deactivate Gateway
else 캐시 MISS: DB 조회
Service -> Service: 동적 쿼리 생성\n- 참여 경로 필터\n- 당첨 여부 필터\n- 이름/전화번호 검색
Service -> Repo: findParticipants(eventId, filters, pageable)
activate Repo
Repo -> DB: 참여자 목록 조회\n(이벤트ID, 참여경로, 당첨여부,\n이름/전화번호 검색조건으로 필터링하여\n참여일시 내림차순으로 페이징 조회)
activate DB
note right of DB
동적 쿼리 조건:
- entryPath 필터 (선택)
- isWinner 필터 (선택)
- name/phone 검색 (선택)
- 페이지네이션 (필수)
필요 인덱스:
idx_participants_event_filters
(event_id, entry_path, is_winner, participated_at DESC)
end note
DB --> Repo: 참여자 목록 결과셋
deactivate DB
Repo -> DB: 전체 참여자 수 조회\n(동일한 필터 조건 적용)
activate DB
DB --> Repo: 전체 건수
deactivate DB
Repo --> Service: Page<Participant>
deactivate Repo
Service -> Service: DTO 변환\n- 전화번호 마스킹 (010-****-1234)\n- 응모번호 형식화\n- 당첨 여부 라벨 변환
Service -> Cache: SET participant_list:{key} = data\nTTL: 10분
activate Cache
note right of Cache
캐시 저장:
- TTL: 10분
- 실시간 참여 반영과 성능 균형
- 이벤트 참여 빈도 고려
end note
Cache --> Service: 캐시 저장 완료
deactivate Cache
Service --> Controller: ParticipantListResponse\n{participants[], totalElements, totalPages, currentPage}
deactivate Service
Controller --> Gateway: 200 OK\n{data, pagination}
deactivate Controller
Gateway --> Owner: 200 OK
deactivate Gateway
Owner -> Owner: 참여자 목록 화면 표시\n- 테이블 형태\n- 페이지네이션\n- 필터/검색 UI
end
end
end
@enduml

View File

@ -0,0 +1,155 @@
@startuml user-로그아웃
!theme mono
title User Service - 로그아웃 내부 시퀀스 (UFR-USER-040)
actor Client
participant "UserController" as Controller <<API Layer>>
participant "AuthenticationService" as AuthService <<Business Layer>>
participant "JwtTokenProvider" as JwtProvider <<Utility>>
participant "Redis\nCache" as Redis <<E>>
note over Controller, Redis
**UFR-USER-040: 로그아웃**
- JWT 토큰 검증
- Redis 세션 삭제
- 클라이언트 측 토큰 삭제 (프론트엔드 처리)
end note
Client -> Controller: POST /api/users/logout\nAuthorization: Bearer {JWT}
activate Controller
Controller -> Controller: @AuthenticationPrincipal\n(JWT에서 userId 추출)
Controller -> Controller: JWT 토큰 추출\n(Authorization 헤더에서)
Controller -> AuthService: logout(token, userId)
activate AuthService
== 1단계: JWT 토큰 검증 ==
AuthService -> JwtProvider: validateToken(token)
activate JwtProvider
JwtProvider -> JwtProvider: JWT 서명 검증\n(만료 시간 확인)
JwtProvider --> AuthService: boolean (유효 여부)
deactivate JwtProvider
alt JWT 토큰 무효
AuthService --> Controller: throw InvalidTokenException\n("유효하지 않은 토큰입니다")
Controller --> Client: 401 Unauthorized\n{"code": "AUTH_002",\n"error": "유효하지 않은 토큰입니다"}
deactivate AuthService
deactivate Controller
else JWT 토큰 유효
== 2단계: Redis 세션 삭제 ==
AuthService -> Redis: 세션 삭제\n(캐시키: user:session:{token})
activate Redis
Redis --> AuthService: 삭제된 키 개수 (0 또는 1)
deactivate Redis
alt 세션 없음 (이미 로그아웃됨)
note right of AuthService
**멱등성 보장**
- 세션이 없어도 로그아웃 성공으로 처리
- 중복 로그아웃 요청에 안전
end note
else 세션 있음 (정상 로그아웃)
note right of AuthService
**세션 삭제 완료**
- Redis에서 세션 정보 제거
- JWT 토큰 무효화 (Blacklist 방식)
end note
end
== 3단계: JWT 토큰 Blacklist 추가 (선택적) ==
note right of AuthService
**JWT Blacklist 전략**
- 만료되지 않은 JWT 토큰을 강제로 무효화
- Redis에 토큰을 Blacklist에 추가 (TTL: 남은 만료 시간)
- API Gateway에서 Blacklist 확인
**API Gateway 연계 시나리오**
1. 로그아웃: AuthService가 Blacklist에 토큰 추가
2. 후속 API 요청: API Gateway가 Blacklist 확인
- Redis GET jwt:blacklist:{token}
- 존재하면: 401 Unauthorized 즉시 반환
- 존재하지 않으면: 백엔드 서비스로 라우팅
3. 만료 시간 도달: Redis TTL 만료로 자동 삭제
end note
AuthService -> JwtProvider: getRemainingExpiration(token)
activate JwtProvider
JwtProvider -> JwtProvider: JWT Claims에서\nexp(만료 시간) 추출\n(현재 시간과 비교)
JwtProvider --> AuthService: remainingSeconds
deactivate JwtProvider
alt 남은 만료 시간 > 0
AuthService -> Redis: 블랙리스트에 토큰 추가\n(캐시키: jwt:blacklist:{token},\n값: "revoked", TTL: 남은초)
activate Redis
Redis --> AuthService: Blacklist 추가 완료
deactivate Redis
end
== 4단계: 로그아웃 이벤트 발행 (선택적) ==
note right of AuthService
**로그아웃 로깅 및 이벤트**
- 감사 로그 기록: userId, timestamp, IP
- 이벤트 발행: LOGOUT_SUCCESS
- 분석 데이터 수집: 세션 지속 시간, 활동 통계
end note
AuthService -> AuthService: 로그아웃 성공 로그 기록\n(userId, timestamp, sessionDuration)
AuthService ->> AuthService: publishEvent(LOGOUT_SUCCESS)
note right of AuthService
**이벤트 활용**
- 비동기 이벤트 처리
- 분석 시스템 연동
- 감사 로그 저장소 전송
end note
== 5단계: 응답 반환 ==
AuthService --> Controller: LogoutResponse\n(success: true)
deactivate AuthService
Controller --> Client: 200 OK\n{"success": true,\n"message": "안전하게 로그아웃되었습니다"}
deactivate Controller
end
note over Controller, Redis
**보안 처리**
- JWT 토큰 Blacklist: 만료 전 토큰 강제 무효화
- 멱등성 보장: 중복 로그아웃 요청에 안전
- 세션 완전 삭제: Redis에서 세션 정보 제거
- 감사 로그: userId, timestamp, IP, sessionDuration 기록
**API Gateway 연계**
- Blacklist 확인: GET jwt:blacklist:{token}
- 존재 시: 401 Unauthorized 즉시 반환
- TTL 자동 관리: 만료 시간 도달 시 자동 삭제
**클라이언트 측 처리**
- 프론트엔드: LocalStorage 또는 Cookie에서 JWT 토큰 삭제
- 로그인 화면으로 리다이렉트
- 모든 인증 헤더 제거
**성능 목표**
- Redis 삭제 연산: O(1) 시간 복잡도
- 평균 응답 시간: 0.1초 이내
- P95 응답 시간: 0.2초 이내
**이벤트 처리**
- LOGOUT_SUCCESS 이벤트 발행 (비동기)
- 감사 로그 저장소 전송
- 분석 데이터 수집
**에러 코드**
- AUTH_002: JWT 토큰 무효
end note
@enduml

View File

@ -0,0 +1,147 @@
@startuml user-로그인
!theme mono
title User Service - 로그인 내부 시퀀스 (UFR-USER-020)
actor Client
participant "UserController" as Controller <<API Layer>>
participant "UserService" as Service <<Business Layer>>
participant "AuthenticationService" as AuthService <<Business Layer>>
participant "UserRepository" as UserRepo <<Data Layer>>
participant "PasswordEncoder" as PwdEncoder <<Utility>>
participant "JwtTokenProvider" as JwtProvider <<Utility>>
participant "Redis\nCache" as Redis <<E>>
participant "User DB\n(PostgreSQL)" as UserDB <<E>>
note over Controller, UserDB
**UFR-USER-020: 로그인**
- 입력: 이메일, 비밀번호
- 비밀번호 검증 (bcrypt compare)
- JWT 토큰 발급
- 세션 저장 (Redis)
- 최종 로그인 시각 업데이트
end note
Client -> Controller: POST /api/users/login\n{"email": "user@example.com",\n"password": "password123"}
activate Controller
Controller -> Controller: 입력값 검증\n(필수 필드, 이메일 형식 확인)
Controller -> AuthService: authenticate(email, password)
activate AuthService
== 1단계: 사용자 조회 ==
AuthService -> Service: findByEmail(email)
activate Service
Service -> UserRepo: findByEmail(email)
activate UserRepo
UserRepo -> UserDB: 이메일로 사용자 조회\n(사용자ID, 비밀번호해시, 역할,\n이름, 전화번호 조회)
activate UserDB
UserDB --> UserRepo: 사용자 정보 반환 또는 없음
deactivate UserDB
UserRepo --> Service: Optional<User>
deactivate UserRepo
Service --> AuthService: Optional<User>
deactivate Service
alt 사용자 없음
AuthService --> Controller: throw AuthenticationFailedException\n("이메일 또는 비밀번호를 확인해주세요")
Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"message": "이메일 또는 비밀번호를\n확인해주세요"}
deactivate AuthService
deactivate Controller
else 사용자 존재
== 2단계: 비밀번호 검증 ==
AuthService -> PwdEncoder: matches(rawPassword, passwordHash)
activate PwdEncoder
PwdEncoder -> PwdEncoder: bcrypt compare\n(입력 비밀번호 vs 저장된 해시)
PwdEncoder --> AuthService: boolean (일치 여부)
deactivate PwdEncoder
alt 비밀번호 불일치
AuthService --> Controller: throw AuthenticationFailedException\n("이메일 또는 비밀번호를 확인해주세요")
Controller --> Client: 401 Unauthorized\n{"code": "AUTH_001",\n"message": "이메일 또는 비밀번호를\n확인해주세요"}
deactivate AuthService
deactivate Controller
else 비밀번호 일치
== 3단계: JWT 토큰 생성 ==
AuthService -> JwtProvider: generateToken(userId, role)
activate JwtProvider
JwtProvider -> JwtProvider: JWT 토큰 생성\n(Claims: userId, role=OWNER,\nexp=7일)
JwtProvider --> AuthService: JWT 토큰
deactivate JwtProvider
== 4단계: 세션 저장 ==
AuthService -> Redis: 세션 정보 저장\nKey: user:session:{token}\nValue: {userId, role}\nTTL: 7일
activate Redis
Redis --> AuthService: 저장 완료
deactivate Redis
== 5단계: 최종 로그인 시각 업데이트 (비동기) ==
AuthService ->> Service: updateLastLoginAt(userId)
activate Service
note right of Service
**비동기 처리**
- @Async 어노테이션 사용
- 로그인 응답 지연 방지
- 별도 스레드풀에서 실행
end note
Service ->> UserRepo: updateLastLoginAt(userId)
activate UserRepo
UserRepo ->> UserDB: 사용자 최종 로그인 시각 갱신\n(현재 시각으로 업데이트)
activate UserDB
UserDB -->> UserRepo: 업데이트 완료
deactivate UserDB
UserRepo -->> Service: void
deactivate UserRepo
Service -->> AuthService: void (비동기 완료)
deactivate Service
note over AuthService, Service
**비동기 화살표 설명**
- `->>`: 비동기 호출 (호출 후 즉시 반환)
- `-->>`: 비동기 응답 (별도 스레드에서 완료)
end note
== 6단계: 응답 반환 ==
AuthService -> AuthService: 로그인 응답 DTO 생성
AuthService --> Controller: LoginResponse\n{token, userId, userName, role, email}
deactivate AuthService
Controller --> Client: 200 OK\n{"token": "eyJhbGc...",\n"userId": 123,\n"userName": "홍길동",\n"role": "OWNER",\n"email": "hong@example.com"}
deactivate Controller
end
end
note over Controller, UserDB
**보안 처리**
- 비밀번호: bcrypt compare (원본 노출 안 됨)
- 에러 메시지: 이메일/비밀번호 구분 없이 동일 메시지 반환 (Timing Attack 방어)
- JWT 토큰: 7일 만료, 서버 세션과 동기화
**보안 강화 (향후 구현)**
- Rate Limiting: IP당 5분에 5회 로그인 실패 시 임시 차단 (15분)
- Account Lockout: 동일 계정 10회 실패 시 계정 잠금 (관리자 해제)
- MFA: 2단계 인증 추가 (SMS/TOTP)
- Anomaly Detection: 비정상 로그인 패턴 감지 (지역, 디바이스 변경)
**성능 최적화**
- 최종 로그인 시각 업데이트: 비동기 처리 (@Async)
- 평균 응답 시간: 0.5초 이내
- P95 응답 시간: 1.0초 이내
- Redis 세션 조회: 0.1초 이내
**에러 코드**
- AUTH_001: 인증 실패 (이메일 또는 비밀번호 불일치)
end note
@enduml

View File

@ -0,0 +1,233 @@
@startuml user-프로필수정
!theme mono
title User Service - 프로필 수정 내부 시퀀스 (UFR-USER-030)
actor Client
participant "UserController" as Controller <<API Layer>>
participant "UserService" as Service <<Business Layer>>
participant "UserRepository" as UserRepo <<Data Layer>>
participant "StoreRepository" as StoreRepo <<Data Layer>>
participant "PasswordEncoder" as PwdEncoder <<Utility>>
participant "Redis\nCache" as Redis <<E>>
participant "User DB\n(PostgreSQL)" as UserDB <<E>>
note over Controller, UserDB
**UFR-USER-030: 프로필 수정**
- 기본 정보: 이름, 전화번호, 이메일
- 매장 정보: 매장명, 업종, 주소, 영업시간
- 비밀번호 변경 (현재 비밀번호 확인 필수)
- 전화번호 변경 시 재인증 필요 (향후 구현)
end note
Client -> Controller: PUT /api/users/profile\nAuthorization: Bearer {JWT}\n(UpdateProfileRequest DTO)
activate Controller
Controller -> Controller: @AuthenticationPrincipal\n(JWT에서 userId 추출)
Controller -> Controller: @Valid 어노테이션 검증\n(이메일 형식, 필드 길이 등)
Controller -> Service: updateProfile(userId, UpdateProfileRequest)
activate Service
== 1단계: 기존 사용자 정보 조회 ==
Service -> UserRepo: findById(userId)
activate UserRepo
UserRepo -> UserDB: 사용자ID로 사용자 조회\n(사용자 정보 조회)
activate UserDB
UserDB --> UserRepo: 사용자 정보
deactivate UserDB
UserRepo --> Service: User 엔티티
deactivate UserRepo
alt 사용자 없음
Service --> Controller: throw UserNotFoundException\n("사용자를 찾을 수 없습니다")
Controller --> Client: 404 Not Found\n{"code": "USER_003",\n"error": "사용자를 찾을 수 없습니다"}
deactivate Service
deactivate Controller
else 사용자 존재
== 2단계: 비밀번호 변경 요청 처리 ==
alt 비밀번호 변경 요청 O
Service -> Service: 현재 비밀번호 검증 필요 확인
Service -> PwdEncoder: matches(currentPassword,\nuser.getPasswordHash())
activate PwdEncoder
PwdEncoder -> PwdEncoder: bcrypt compare
PwdEncoder --> Service: boolean (일치 여부)
deactivate PwdEncoder
alt 현재 비밀번호 불일치
Service --> Controller: throw InvalidPasswordException\n("현재 비밀번호가 일치하지 않습니다")
Controller --> Client: 400 Bad Request\n{"code": "USER_004",\n"error": "현재 비밀번호가\n일치하지 않습니다"}
deactivate Service
deactivate Controller
else 현재 비밀번호 일치
Service -> Service: 새 비밀번호 유효성 검증\n(8자 이상, 영문/숫자/특수문자)
Service -> PwdEncoder: encode(newPassword)
activate PwdEncoder
PwdEncoder -> PwdEncoder: bcrypt 해싱\n(Cost Factor 10)
PwdEncoder --> Service: newPasswordHash
deactivate PwdEncoder
Service -> Service: user.setPasswordHash(newPasswordHash)
end
end
== 3단계: 엔티티 수정 준비 (메모리상 변경) ==
note right of Service
**JPA Dirty Checking**
- 트랜잭션 시작 전 엔티티 수정 (메모리상)
- 트랜잭션 커밋 시 변경 감지하여 UPDATE 자동 실행
- 변경된 필드만 UPDATE 쿼리에 포함
end note
alt 이름 변경
Service -> Service: user.setName(newName)
end
alt 전화번호 변경
Service -> Service: user.setPhoneNumber(newPhoneNumber)
note right of Service
**향후 구현: 재인증 필요**
- SMS 인증 또는 이메일 인증
- 인증 완료 후에만 변경 반영
end note
end
alt 이메일 변경
Service -> Service: user.setEmail(newEmail)
end
== 4단계: 매장 정보 수정 준비 (메모리상 변경) ==
Service -> StoreRepo: findByUserId(userId)
activate StoreRepo
StoreRepo -> UserDB: 사용자ID로 매장 조회\n(매장 정보 조회)
activate UserDB
UserDB --> StoreRepo: 매장 정보
deactivate UserDB
StoreRepo --> Service: Store 엔티티
deactivate StoreRepo
alt 매장명 변경
Service -> Service: store.setStoreName(newStoreName)
end
alt 업종 변경
Service -> Service: store.setIndustry(newIndustry)
end
alt 주소 변경
Service -> Service: store.setAddress(newAddress)
end
alt 영업시간 변경
Service -> Service: store.setBusinessHours(newBusinessHours)
end
== 5단계: 데이터베이스 트랜잭션 ==
Service -> UserDB: 트랜잭션 시작
activate UserDB
note right of Service
**Optimistic Locking**
- @Version 필드로 동시 수정 감지
- 다른 트랜잭션이 먼저 수정한 경우
- OptimisticLockException 발생
end note
Service -> UserRepo: save(user)
activate UserRepo
UserRepo -> UserDB: 사용자 정보 업데이트\n(이름, 전화번호, 이메일,\n비밀번호해시, 수정일시,\n버전 증가)\nOptimistic Lock 적용
UserDB --> UserRepo: 업데이트 완료 (1 row affected)
UserRepo --> Service: User 엔티티
deactivate UserRepo
alt 동시성 충돌 (version 불일치)
UserRepo --> Service: throw OptimisticLockException
Service --> Controller: throw ConcurrentModificationException\n("다른 사용자가 수정 중입니다")
Controller --> Client: 409 Conflict\n{"code": "USER_005",\n"error": "다른 세션에서 프로필을\n수정했습니다.\n새로고침 후 다시 시도하세요"}
Service -> UserDB: 트랜잭션 롤백
deactivate UserDB
deactivate Service
deactivate Controller
else 정상 업데이트
Service -> StoreRepo: save(store)
activate StoreRepo
StoreRepo -> UserDB: 매장 정보 업데이트\n(매장명, 업종, 주소,\n영업시간, 수정일시,\n버전 증가)\nOptimistic Lock 적용
UserDB --> StoreRepo: 업데이트 완료 (1 row affected)
StoreRepo --> Service: Store 엔티티
deactivate StoreRepo
Service -> UserDB: 트랜잭션 커밋
UserDB --> Service: 트랜잭션 커밋 완료
deactivate UserDB
== 6단계: 캐시 무효화 (선택적) ==
note right of Service
**캐시 무효화 전략**
- 세션 정보는 변경 없음 (JWT 유지)
- 프로필 캐시가 있다면 무효화
end note
alt 프로필 캐시 사용 중
Service -> Redis: 프로필 캐시 삭제\n(캐시키: user:profile:{userId})
activate Redis
Redis --> Service: 캐시 삭제 완료
deactivate Redis
end
== 7단계: 응답 반환 ==
Service -> Service: 응답 DTO 생성\n(UpdateProfileResponse)
Service --> Controller: UpdateProfileResponse\n(userId, userName, email,\nstoreId, storeName)
deactivate Service
Controller --> Client: 200 OK\n{"userId": 123,\n"userName": "홍길동",\n"email": "hong@example.com",\n"storeId": 456,\n"storeName": "맛있는집"}
deactivate Controller
end
end
note over Controller, UserDB
**Transaction Rollback 처리**
- 트랜잭션 실패 시 자동 Rollback
- User/Store UPDATE 중 하나라도 실패 시 전체 롤백
- OptimisticLockException 발생 시 409 Conflict 반환
**동시성 제어**
- Optimistic Locking: @Version 필드로 동시 수정 감지
- 충돌 감지 시: 409 Conflict 반환 (사용자에게 재시도 안내)
- Lost Update 방지: version 필드 자동 증가
**보안 처리**
- 비밀번호 변경: 현재 비밀번호 확인 필수
- JWT 인증: Controller에서 @AuthenticationPrincipal로 userId 추출
- 권한 검증: 본인만 수정 가능
**성능 목표**
- 평균 응답 시간: 0.3초 이내
- P95 응답 시간: 0.5초 이내
- 트랜잭션 격리 수준: READ_COMMITTED
**향후 개선사항**
- 전화번호 변경: SMS/이메일 재인증 구현
- 이메일 변경: 이메일 인증 구현
- 변경 이력 추적: Audit Log 기록
**에러 코드**
- USER_003: 사용자 없음
- USER_004: 현재 비밀번호 불일치
- USER_005: 동시성 충돌 (다른 세션에서 수정)
end note
@enduml

View File

@ -0,0 +1,149 @@
@startuml user-회원가입
!theme mono
title User Service - 회원가입 내부 시퀀스 (UFR-USER-010)
participant "UserController" as Controller <<API Layer>>
participant "UserService" as Service <<Business Layer>>
participant "UserRepository" as UserRepo <<Data Layer>>
participant "StoreRepository" as StoreRepo <<Data Layer>>
participant "PasswordEncoder" as PwdEncoder <<Utility>>
participant "JwtTokenProvider" as JwtProvider <<Utility>>
participant "Redis\nCache" as Redis <<E>>
participant "User DB\n(PostgreSQL)" as UserDB <<E>>
actor Client
note over Controller, UserDB
**UFR-USER-010: 회원가입**
- 기본 정보: 이름, 전화번호, 이메일, 비밀번호
- 매장 정보: 매장명, 업종, 주소, 영업시간, 사업자번호
- 이메일/전화번호 중복 검사
- 트랜잭션 처리
- JWT 토큰 발급
end note
Client -> Controller: POST /api/users/register\n{"name": "홍길동",\n"phoneNumber": "01012345678",\n"email": "hong@example.com",\n"password": "password123"}
activate Controller
Controller -> Controller: 입력값 검증\n(이메일 형식, 비밀번호 8자 이상 등)
Controller -> Service: register(RegisterRequest)
activate Service
== 1단계: 이메일 중복 확인 ==
Service -> UserRepo: findByEmail(email)
activate UserRepo
UserRepo -> UserDB: 이메일로 사용자 조회\n(중복 가입 확인)
activate UserDB
UserDB --> UserRepo: 조회 결과 반환 또는 없음
deactivate UserDB
UserRepo --> Service: Optional<User>
deactivate UserRepo
alt 이메일 중복 존재
Service --> Controller: throw DuplicateEmailException\n("이미 가입된 이메일입니다")
Controller --> Client: 400 Bad Request\n{"code": "USER_001",\n"message": "이미 가입된 이메일입니다"}
deactivate Service
deactivate Controller
else 이메일 신규
== 2단계: 전화번호 중복 확인 ==
Service -> UserRepo: findByPhoneNumber(phoneNumber)
activate UserRepo
UserRepo -> UserDB: 전화번호로 사용자 조회\n(중복 가입 확인)
activate UserDB
UserDB --> UserRepo: 조회 결과 반환 또는 없음
deactivate UserDB
UserRepo --> Service: Optional<User>
deactivate UserRepo
alt 전화번호 중복 존재
Service --> Controller: throw DuplicatePhoneException\n("이미 가입된 전화번호입니다")
Controller --> Client: 400 Bad Request\n{"code": "USER_002",\n"message": "이미 가입된 전화번호입니다"}
deactivate Service
deactivate Controller
else 신규 사용자
== 3단계: 비밀번호 해싱 ==
Service -> PwdEncoder: encode(rawPassword)
activate PwdEncoder
PwdEncoder -> PwdEncoder: bcrypt 해싱\n(Cost Factor 10)
PwdEncoder --> Service: passwordHash
deactivate PwdEncoder
== 4단계: 데이터베이스 트랜잭션 ==
Service -> UserDB: 트랜잭션 시작
activate UserDB
Service -> UserRepo: save(User)
activate UserRepo
UserRepo -> UserDB: 사용자 정보 저장\n(이름, 전화번호, 이메일,\n비밀번호해시, 생성일시)\n저장 후 사용자ID 반환
UserDB --> UserRepo: 생성된 사용자ID
UserRepo --> Service: User 엔티티\n(userId 포함)
deactivate UserRepo
Service -> StoreRepo: save(Store)
activate StoreRepo
StoreRepo -> UserDB: 매장 정보 저장\n(사용자ID, 매장명, 업종,\n주소, 사업자번호, 영업시간)\n저장 후 매장ID 반환
UserDB --> StoreRepo: 생성된 매장ID
StoreRepo --> Service: Store 엔티티\n(storeId 포함)
deactivate StoreRepo
Service -> UserDB: 트랜잭션 커밋
UserDB --> Service: 커밋 완료
deactivate UserDB
== 5단계: JWT 토큰 생성 ==
Service -> JwtProvider: generateToken(userId, role)
activate JwtProvider
JwtProvider -> JwtProvider: JWT 토큰 생성\n(Claims: userId, role=OWNER,\nexp=7일)
JwtProvider --> Service: JWT 토큰
deactivate JwtProvider
== 6단계: 세션 저장 ==
Service -> Redis: 세션 정보 저장\nKey: user:session:{token}\nValue: {userId, role}\nTTL: 7일
activate Redis
Redis --> Service: 저장 완료
deactivate Redis
== 7단계: 응답 반환 ==
Service -> Service: 회원가입 응답 DTO 생성
Service --> Controller: RegisterResponse\n{token, userId, userName, storeId, storeName}
deactivate Service
Controller --> Client: 201 Created\n{"token": "eyJhbGc...",\n"userId": 123,\n"userName": "홍길동",\n"storeId": 456,\n"storeName": "맛있는집"}
deactivate Controller
end
end
end
note over Controller, UserDB
**Transaction Rollback 처리**
- 트랜잭션 실패 시 자동 Rollback
- User/Store INSERT 중 하나라도 실패 시 전체 롤백
- 예외: DataAccessException, ConstraintViolationException
**보안 처리**
- 비밀번호: bcrypt 해싱 (Cost Factor 10)
- JWT 토큰: 7일 만료, 서버 세션과 동기화
**성능 목표**
- 평균 응답 시간: 1.0초 이내
- P95 응답 시간: 1.5초 이내
- 트랜잭션 처리: 0.5초 이내
**에러 코드**
- USER_001: 이메일 중복
- USER_002: 전화번호 중복
end note
@enduml

View File

@ -0,0 +1,304 @@
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 외부 시퀀스 설계
## 문서 정보
- **작성일**: 2025-10-22
- **작성자**: System Architect
- **버전**: 1.0
- **관련 문서**:
- [유저스토리](../../../userstory.md)
- [논리 아키텍처](../../logical/logical-architecture.md)
- [UI/UX 설계서](../../../uiux/uiux.md)
---
## 개요
본 문서는 KT AI 기반 소상공인 이벤트 자동 생성 서비스의 **외부 시퀀스 설계**를 정의합니다.
외부 시퀀스는 서비스 간의 상호작용과 데이터 흐름을 표현하며, 유저스토리와 논리 아키텍처를 기반으로 설계되었습니다.
### 설계 원칙
1. **유저스토리 기반**: 20개 유저스토리와 정확히 매칭
2. **Event-Driven 아키텍처**: Kafka를 통한 비동기 이벤트 발행/구독
3. **Resilience 패턴**: Circuit Breaker, Retry, Timeout, Fallback 적용
4. **Cache-Aside 패턴**: Redis 캐싱을 통한 성능 최적화
5. **서비스 독립성**: 느슨한 결합과 장애 격리
---
## 외부 시퀀스 플로우 목록
총 **4개의 주요 비즈니스 플로우**로 구성되어 있습니다:
### 1. 사용자 인증 플로우
**파일**: `사용자인증플로우.puml`
**포함된 유저스토리**:
- UFR-USER-010: 회원가입
- UFR-USER-020: 로그인
- UFR-USER-040: 로그아웃
**주요 참여자**:
- Frontend (Web/Mobile)
- API Gateway
- User Service
- Redis Cache
- User DB (PostgreSQL)
- 국세청 API (외부)
**핵심 기능**:
- JWT 기반 인증
- 사업자번호 검증 (Circuit Breaker 적용)
- Redis 캐싱 (사업자번호 검증 결과, TTL 7일)
- 비밀번호 해싱 (bcrypt)
- 사업자번호 암호화 (AES-256)
**Resilience 패턴**:
- Circuit Breaker: 국세청 API (실패율 50% 초과 시 Open)
- Retry: 최대 3회 재시도 (지수 백오프: 1초, 2초, 4초)
- Timeout: 5초
- Fallback: 사업자번호 검증 스킵 (수동 확인 안내)
---
### 2. 이벤트 생성 플로우
**파일**: `이벤트생성플로우.puml`
**포함된 유저스토리**:
- UFR-EVENT-020: 이벤트 목적 선택
- UFR-EVENT-030: AI 이벤트 추천
- UFR-CONT-010: SNS 이미지 생성
- UFR-EVENT-050: 최종 승인 및 배포
**주요 참여자**:
- Frontend
- API Gateway
- Event Service
- AI Service (Kafka 구독)
- Content Service (Kafka 구독)
- Distribution Service (동기 호출)
- Kafka (Event Topics + Job Topics)
- Redis Cache
- Event DB
- 외부 API (AI API, 이미지 생성 API, 배포 채널 APIs)
**핵심 기능**:
1. **이벤트 목적 선택** (동기)
- Event DB에 목적 저장
- EventCreated 이벤트 발행
2. **AI 이벤트 추천** (비동기)
- Kafka ai-job 토픽 발행
- AI Service 구독 및 처리
- Polling 패턴으로 Job 상태 확인 (최대 30초)
- Redis 캐싱 (TTL 24시간)
3. **SNS 이미지 생성** (비동기)
- Kafka image-job 토픽 발행
- Content Service 구독 및 처리
- Polling 패턴으로 Job 상태 확인 (최대 20초)
- CDN 업로드 및 Redis 캐싱 (TTL 7일)
4. **최종 승인 및 배포** (동기)
- Distribution Service REST API 직접 호출
- 다중 채널 병렬 배포 (1분 이내)
- DistributionCompleted 이벤트 발행
**Resilience 패턴**:
- Circuit Breaker: 모든 외부 API 호출 시 적용
- Retry: 최대 3회 재시도 (지수 백오프)
- Timeout: AI API 30초, 이미지 API 20초, 배포 API 10초
- Bulkhead: 채널별 스레드 풀 격리
- Fallback: AI 추천 시 캐시된 이전 결과, 이미지 생성 시 기본 템플릿
---
### 3. 고객 참여 플로우
**파일**: `고객참여플로우.puml`
**포함된 유저스토리**:
- UFR-PART-010: 이벤트 참여
- UFR-PART-030: 당첨자 추첨
**주요 참여자**:
- Frontend (고객용 / 사장님용)
- API Gateway
- Participation Service
- Kafka (Event Topics)
- Participation DB
- Analytics Service (이벤트 구독)
**핵심 기능**:
1. **이벤트 참여**
- 중복 참여 체크 (전화번호 기반)
- 응모 번호 발급
- ParticipantRegistered 이벤트 발행 → Analytics Service 구독
2. **당첨자 추첨**
- 난수 기반 무작위 추첨 (Crypto.randomBytes)
- Fisher-Yates Shuffle 알고리즘
- 매장 방문 고객 가산점 적용 (선택 옵션)
- WinnerSelected 이벤트 발행
**Event-Driven 특징**:
- Analytics Service가 ParticipantRegistered 이벤트 구독하여 실시간 통계 업데이트
- 서비스 간 직접 의존성 없이 이벤트로 느슨한 결합
---
### 4. 성과 분석 플로우
**파일**: `성과분석플로우.puml`
**포함된 유저스토리**:
- UFR-ANAL-010: 실시간 성과분석 대시보드 조회
**주요 참여자**:
- Frontend
- API Gateway
- Analytics Service
- Redis Cache (TTL 5분)
- Analytics DB
- Kafka (Event Topics 구독)
- 외부 API (우리동네TV, 지니TV, SNS APIs)
**핵심 기능**:
1. **대시보드 조회 - Cache HIT** (0.5초)
- Redis에서 캐시된 데이터 즉시 반환
- 히트율 목표: 95%
2. **대시보드 조회 - Cache MISS** (3초)
- Analytics DB 로컬 데이터 조회
- 외부 채널 API 병렬 호출 (Circuit Breaker 적용)
- 데이터 통합 및 ROI 계산
- Redis 캐싱 (TTL 5분)
3. **실시간 업데이트 (Background)**
- EventCreated 구독: 이벤트 기본 정보 초기화
- ParticipantRegistered 구독: 참여자 수 실시간 증가
- DistributionCompleted 구독: 배포 채널 통계 업데이트
**Resilience 패턴**:
- Circuit Breaker: 외부 채널 API 조회 시 (실패율 50% 초과 시 Open)
- Timeout: 10초
- Fallback: 캐시된 이전 데이터 반환 또는 기본값 설정
- 병렬 처리: 외부 채널 API 동시 호출
---
## 설계 특징
### 1. Event-Driven 아키텍처
- **Kafka 통합**: Event Topics와 Job Topics를 Kafka로 통합
- **느슨한 결합**: 서비스 간 직접 의존성 제거
- **장애 격리**: 한 서비스 장애가 다른 서비스에 영향 없음
- **확장 용이**: 새로운 구독자 추가로 기능 확장
### 2. 비동기 처리 패턴
- **Kafka Job Topics**: ai-job, image-job
- **Polling 패턴**: Job 상태 확인 (2-5초 간격)
- **처리 시간**: AI 추천 10초, 이미지 생성 5초
### 3. 동기 처리 패턴
- **Distribution Service**: REST API 직접 호출
- **다중 채널 배포**: 병렬 처리 (1분 이내)
- **Circuit Breaker**: 장애 전파 방지
### 4. Resilience 패턴 전면 적용
- **Circuit Breaker**: 모든 외부 API 호출
- **Retry**: 일시적 장애 자동 복구
- **Timeout**: 응답 시간 제한
- **Bulkhead**: 리소스 격리
- **Fallback**: 장애 시 대체 로직
### 5. Cache-Aside 패턴
- **Redis 캐싱**: 성능 최적화
- **TTL 설정**: 데이터 유효성 관리
- **히트율 목표**: 80-95%
- **응답 시간 개선**: 90-99%
---
## 파일 구조
```
design/backend/sequence/outer/
├── README.md (본 문서)
├── 사용자인증플로우.puml
├── 이벤트생성플로우.puml
├── 고객참여플로우.puml
└── 성과분석플로우.puml
```
---
## 다이어그램 확인 방법
### 1. Online PlantUML Viewer
1. https://www.plantuml.com/plantuml/uml 접속
2. `.puml` 파일 내용 붙여넣기
3. 다이어그램 시각적 확인
### 2. VSCode Extension
1. "PlantUML" 확장 프로그램 설치
2. `.puml` 파일 열기
3. `Alt+D` 또는 `Cmd+D`로 미리보기
### 3. IntelliJ IDEA Plugin
1. "PlantUML integration" 플러그인 설치
2. `.puml` 파일 열기
3. 우측 미리보기 패널에서 확인
---
## 주요 결정사항
1. **Kafka 통합**: Event Bus와 Job Queue를 Kafka로 통합하여 운영 복잡도 감소
2. **비동기 처리**: AI 추천 및 이미지 생성은 Kafka Job Topics를 통한 비동기 처리
3. **동기 배포**: Distribution Service는 REST API 직접 호출하여 동기 처리 (1분 이내)
4. **Resilience 패턴**: 모든 외부 API 호출 시 Circuit Breaker, Retry, Timeout, Fallback 적용
5. **Cache-Aside 패턴**: Redis 캐싱으로 응답 시간 90-99% 개선
6. **Event Topics**: EventCreated, ParticipantRegistered, WinnerSelected, DistributionCompleted
7. **Job Topics**: ai-job, image-job
---
## 검증 사항
### 1. 유저스토리 매칭
✅ 모든 유저스토리가 외부 시퀀스에 정확히 반영됨
- User 서비스: 4개 유저스토리
- Event 서비스: 4개 유저스토리
- Participation 서비스: 2개 유저스토리
- Analytics 서비스: 1개 유저스토리
### 2. 논리 아키텍처 일치성
✅ 논리 아키텍처의 모든 컴포넌트와 통신 패턴 반영
- Core Services: User, Event, Participation, Analytics
- Async Services: AI, Content, Distribution
- Kafka: Event Topics + Job Topics
- External Systems: 국세청 API, AI API, 이미지 생성 API, 배포 채널 APIs
### 3. Resilience 패턴 적용
✅ 모든 외부 API 호출에 Resilience 패턴 적용
- Circuit Breaker, Retry, Timeout, Bulkhead, Fallback
### 4. PlantUML 문법 검증
✅ PlantUML 기본 문법 검증 완료
- `!theme mono` 적용
- 동기/비동기 화살표 구분
- 한글 설명 추가
- 참여자 및 플로우 명확히 표현
---
## 향후 개선 방안
1. **WebSocket 기반 실시간 푸시**: 대시보드 실시간 업데이트 (폴링 대체)
2. **Saga 패턴 적용**: 복잡한 분산 트랜잭션 보상 로직 체계화
3. **Service Mesh 도입**: Istio를 통한 서비스 간 통신 관찰성 및 보안 강화
4. **Dead Letter Queue 고도화**: 실패 이벤트 재처리 및 알림 자동화
---
**문서 버전**: 1.0
**최종 수정일**: 2025-10-22
**작성자**: System Architect

View File

@ -0,0 +1,164 @@
@startuml 고객참여플로우
!theme mono
title 고객 참여 플로우 - 외부 시퀀스 다이어그램
actor "고객" as Customer
participant "Frontend\n(고객용)" as CustomerFE
participant "API Gateway" as Gateway
participant "Participation\nService" as PartService
participant "Kafka\n(Event Topics)" as Kafka
database "Participation\nDB" as PartDB
participant "Analytics\nService" as Analytics
actor "사장님" as Owner
participant "Frontend\n(사장님용)" as OwnerFE
== UFR-PART-010: 이벤트 참여 ==
Customer -> CustomerFE: 이벤트 참여 화면 접근\n(우리동네TV/SNS/링고비즈)
activate CustomerFE
CustomerFE -> Customer: 참여 정보 입력 폼 표시\n(이름, 전화번호, 참여경로)
Customer -> CustomerFE: 참여 정보 입력 및\n참여 버튼 클릭
CustomerFE -> CustomerFE: 클라이언트 유효성 검증\n(이름 2자 이상, 전화번호 형식)
CustomerFE -> Gateway: POST /api/v1/participations\n{이름, 전화번호, 참여경로, 개인정보동의}
activate Gateway
Gateway -> PartService: POST /participations/register\n{이름, 전화번호, 참여경로, 개인정보동의}
activate PartService
PartService -> PartDB: 참여자 중복 확인\n(전화번호, 이벤트ID로 조회)
activate PartDB
PartDB --> PartService: 중복 참여 여부 반환
deactivate PartDB
alt 중복 참여인 경우
PartService --> Gateway: 409 Conflict\n{message: "이미 참여하신 이벤트입니다"}
Gateway --> CustomerFE: 409 Conflict
CustomerFE -> Customer: 중복 참여 오류 메시지 표시
deactivate PartService
deactivate Gateway
deactivate CustomerFE
else 신규 참여인 경우
PartService -> PartService: 응모 번호 생성\n(UUID 또는 시퀀스 기반)
PartService -> PartDB: 참여자 정보 저장\n(이름, 전화번호, 참여경로,\n응모번호, 참여일시)
activate PartDB
PartDB --> PartService: 저장 완료
deactivate PartDB
PartService -> Kafka: Publish Event\n"ParticipantRegistered"\n{participantId, eventId,\nentryPath, timestamp}
activate Kafka
note right of Kafka
Topic: participant-events
Event: ParticipantRegistered
Data: {
participantId: UUID,
eventId: UUID,
entryPath: string,
timestamp: datetime
}
end note
Kafka --> Analytics: Subscribe Event\n"ParticipantRegistered"
activate Analytics
Analytics -> Analytics: 참여자 데이터 집계\n- 채널별 참여자 수\n- 시간대별 참여 추이\n- 실시간 통계 업데이트
deactivate Analytics
deactivate Kafka
PartService --> Gateway: 201 Created\n{응모번호, 당첨발표일, 참여완료메시지}
deactivate PartService
Gateway --> CustomerFE: 201 Created
deactivate Gateway
CustomerFE -> Customer: 참여 완료 화면 표시\n- 응모번호\n- 당첨 발표일\n- "참여해주셔서 감사합니다"
deactivate CustomerFE
end
== UFR-PART-020: 참여자 목록 조회 ==
Owner -> OwnerFE: 이벤트 상세 화면에서\n"참여자 목록" 탭 클릭
activate OwnerFE
OwnerFE -> Gateway: GET /api/v1/events/{eventId}/participants\n?page=1&size=20
activate Gateway
Gateway -> PartService: GET /events/{eventId}/participants\n?page=1&size=20
activate PartService
PartService -> PartDB: 참여자 목록 조회\n(이벤트ID, 페이지네이션)\nORDER BY 참여일시 DESC
activate PartDB
PartDB --> PartService: 참여자 목록 반환\n(이름, 전화번호, 참여경로,\n응모번호, 참여일시)\n+ 총 참여자 수
deactivate PartDB
PartService --> Gateway: 200 OK\n{participants[], totalCount, page, size}
deactivate PartService
Gateway --> OwnerFE: 200 OK
deactivate Gateway
OwnerFE -> Owner: 참여자 목록 화면 표시\n- 참여자 정보 테이블\n- 페이지네이션\n- 총 참여자 수\n- CSV 다운로드 버튼
deactivate OwnerFE
note right of Owner
참여자 정보:
- 이름 (마스킹: 김**)
- 전화번호 (마스킹: 010-****-1234)
- 참여경로 (우리동네TV, Instagram 등)
- 응모번호
- 참여일시
end note
== UFR-PART-030: 당첨자 추첨 ==
Owner -> OwnerFE: 이벤트 상세 화면에서\n"당첨자 추첨" 버튼 클릭
activate OwnerFE
OwnerFE -> Owner: 추첨 확인 다이얼로그 표시\n"당첨자를 추첨하시겠습니까?"
Owner -> OwnerFE: 확인 버튼 클릭
OwnerFE -> Gateway: POST /api/v1/events/{eventId}/draw-winners\n{당첨인원, 매장방문가산점옵션}
activate Gateway
Gateway -> PartService: POST /events/{eventId}/draw-winners\n{winnerCount, visitBonus}
activate PartService
PartService -> PartDB: 미당첨 참여자 목록 조회\n(이벤트ID로 당첨되지 않은 참여자 조회)
activate PartDB
PartDB --> PartService: 전체 참여자 목록 반환
deactivate PartDB
PartService -> PartService: 당첨자 추첨 알고리즘 실행\n1. 난수 생성 (Crypto.randomBytes)\n2. 매장방문 가산점 적용 (옵션)\n3. Fisher-Yates Shuffle\n4. 당첨인원만큼 선정
PartService -> PartDB: 당첨자 정보 업데이트\n(당첨 여부를 true로 설정, 당첨 일시 기록)
activate PartDB
PartDB --> PartService: 업데이트 완료
deactivate PartDB
PartService -> PartDB: 추첨 로그 저장\n(이벤트ID, 추첨방법, 당첨인원,\n알고리즘, 추첨일시)
activate PartDB
note right of PartDB
추첨 로그 저장:
- 추첨 일시
- 추첨 방법
- 알고리즘 버전
- 가산점 적용 여부
end note
PartDB --> PartService: 로그 저장 완료
deactivate PartDB
PartService --> Gateway: 200 OK\n{당첨자목록, 추첨로그ID}
deactivate PartService
Gateway --> OwnerFE: 200 OK
deactivate Gateway
OwnerFE -> Owner: 당첨자 목록 화면 표시\n- 당첨자 정보 (이름, 전화번호, 응모번호)\n- 추첨 완료 메시지
deactivate OwnerFE
@enduml

View File

@ -0,0 +1,177 @@
@startuml 사용자인증플로우
!theme mono
title KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 사용자 인증 플로우 (외부 시퀀스)
actor "사용자\n(소상공인)" as User
participant "Frontend\n(Web/Mobile)" as Frontend
participant "API Gateway" as Gateway
participant "User Service" as UserService
database "User DB\n(PostgreSQL)" as UserDB
== UFR-USER-010: 회원가입 플로우 ==
User -> Frontend: 회원가입 화면 접근
activate Frontend
User -> Frontend: 회원 정보 입력\n(이름, 전화번호, 이메일, 비밀번호,\n매장명, 업종, 주소, 사업자번호)
Frontend -> Frontend: 클라이언트 측 유효성 검증\n(이메일 형식, 비밀번호 8자 이상 등)
Frontend -> Gateway: POST /api/users/register\n(회원 정보)
activate Gateway
Gateway -> Gateway: Request 검증\n(필수 필드, 데이터 타입)
Gateway -> UserService: POST /api/users/register\n(회원 정보)
activate UserService
UserService -> UserService: 서버 측 유효성 검증\n(이름 2자 이상, 전화번호 형식 등)
UserService -> UserDB: 이메일로 사용자 조회\n(중복 가입 확인)
activate UserDB
UserDB --> UserService: 기존 사용자 확인 결과
deactivate UserDB
alt 이메일 중복 존재
UserService --> Gateway: 400 Bad Request\n(이미 등록된 이메일)
Gateway --> Frontend: 400 Bad Request
Frontend --> User: "이미 가입된 이메일입니다"
else 이메일 신규
UserService -> UserDB: 전화번호로 사용자 조회\n(중복 가입 확인)
activate UserDB
UserDB --> UserService: 기존 사용자 확인 결과
deactivate UserDB
alt 전화번호 중복 존재
UserService --> Gateway: 400 Bad Request\n(이미 등록된 전화번호)
Gateway --> Frontend: 400 Bad Request
Frontend --> User: "이미 가입된 전화번호입니다"
else 신규 사용자
UserService -> UserService: 비밀번호 해싱\n(bcrypt, Cost Factor 10)
UserService -> UserService: 사업자번호 암호화\n(AES-256)
UserService -> UserDB: 트랜잭션 시작
activate UserDB
UserService -> UserDB: 사용자 정보 저장\n(이름, 전화번호, 이메일,\n비밀번호해시, 생성일시)
UserDB --> UserService: user_id 반환
UserService -> UserDB: 매장 정보 저장\n(사용자ID, 매장명, 업종,\n주소, 암호화된사업자번호,\n영업시간)
UserDB --> UserService: store_id 반환
UserService -> UserDB: 트랜잭션 커밋
deactivate UserDB
UserService -> UserService: JWT 토큰 생성\n(user_id, role=OWNER,\nexp=7일)
UserService --> Gateway: 201 Created\n(JWT 토큰, 사용자 정보)
deactivate UserService
Gateway --> Frontend: 201 Created\n(JWT 토큰, 사용자 정보)
deactivate Gateway
Frontend -> Frontend: JWT 토큰 저장\n(LocalStorage 또는 Cookie)
Frontend --> User: "회원가입이 완료되었습니다"
Frontend -> Gateway: 대시보드 화면으로 이동
deactivate Frontend
end
end
== UFR-USER-020: 로그인 플로우 ==
User -> Frontend: 로그인 화면 접근
activate Frontend
User -> Frontend: 이메일, 비밀번호 입력
Frontend -> Frontend: 클라이언트 측 유효성 검증\n(필수 필드 확인, 이메일 형식)
Frontend -> Gateway: POST /api/users/login\n(이메일, 비밀번호)
activate Gateway
Gateway -> Gateway: Request 검증
Gateway -> UserService: POST /api/users/login\n(이메일, 비밀번호)
activate UserService
UserService -> UserDB: 이메일로 사용자 조회\n(로그인 인증용)
activate UserDB
UserDB --> UserService: 사용자 정보\n(user_id, password_hash, role)
deactivate UserDB
alt 사용자 없음
UserService --> Gateway: 401 Unauthorized\n(인증 실패)
Gateway --> Frontend: 401 Unauthorized
Frontend --> User: "이메일 또는 비밀번호를\n확인해주세요"
else 사용자 존재
UserService -> UserService: 비밀번호 검증\n(bcrypt compare)
alt 비밀번호 불일치
UserService --> Gateway: 401 Unauthorized\n(인증 실패)
Gateway --> Frontend: 401 Unauthorized
Frontend --> User: "이메일 또는 비밀번호를\n확인해주세요"
else 비밀번호 일치
UserService -> UserService: JWT 토큰 생성\n(user_id, role=OWNER,\nexp=7일)
UserService -> UserDB: 최종 로그인 시각 업데이트\n(현재 시각으로 갱신)
activate UserDB
UserDB --> UserService: 업데이트 완료
deactivate UserDB
UserService --> Gateway: 200 OK\n(JWT 토큰, 사용자 정보)
deactivate UserService
Gateway --> Frontend: 200 OK\n(JWT 토큰, 사용자 정보)
deactivate Gateway
Frontend -> Frontend: JWT 토큰 저장\n(LocalStorage 또는 Cookie)
Frontend --> User: 로그인 성공
Frontend -> Gateway: 대시보드 화면으로 이동
deactivate Frontend
end
end
== UFR-USER-040: 로그아웃 플로우 ==
User -> Frontend: 프로필 탭 접근
activate Frontend
User -> Frontend: "로그아웃" 버튼 클릭
Frontend -> Frontend: 확인 다이얼로그 표시\n"로그아웃 하시겠습니까?"
User -> Frontend: "확인" 클릭
Frontend -> Gateway: POST /api/users/logout\nAuthorization: Bearer {JWT}
activate Gateway
Gateway -> Gateway: JWT 토큰 검증
Gateway -> UserService: POST /api/users/logout\n(JWT 토큰)
activate UserService
UserService -> UserService: JWT 토큰 블랙리스트에 추가\n(만료 시까지 유효)
UserService --> Gateway: 200 OK\n(로그아웃 성공)
deactivate UserService
Gateway --> Frontend: 200 OK
deactivate Gateway
Frontend -> Frontend: JWT 토큰 삭제\n(LocalStorage 또는 Cookie)
Frontend --> User: "안전하게 로그아웃되었습니다"
Frontend -> Gateway: 로그인 화면으로 이동
deactivate Frontend
@enduml

View File

@ -0,0 +1,198 @@
@startuml 성과분석플로우_외부시퀀스
!theme mono
title 성과 분석 플로우 - 외부 시퀀스 다이어그램\n(UFR-ANAL-010: 실시간 성과분석 대시보드 조회)
actor "소상공인" as User
participant "Frontend" as FE
participant "API Gateway" as GW
participant "Analytics Service" as Analytics
participant "Redis Cache\n(TTL 1시간)" as Redis
participant "Analytics DB" as AnalyticsDB
participant "Kafka\n(Event Topics)" as Kafka
note over AnalyticsDB
**배치 처리로 수집된 데이터**
- 외부 채널 통계는 배치 작업으로
주기적으로 수집하여 DB에 저장
- 목업 데이터로 시작, 점진적으로 실제 API 연동
end note
== 1. 대시보드 조회 - Cache HIT 시나리오 ==
User -> FE: 성과분석 대시보드 접근\n(Bottom Nav "분석" 탭 클릭)
activate FE
FE -> GW: GET /api/events/{id}/analytics\n+ Authorization: Bearer {token}
activate GW
GW -> GW: JWT 토큰 검증
GW -> Analytics: GET /api/events/{id}/analytics
activate Analytics
Analytics -> Redis: 대시보드 캐시 조회\n(캐시키: analytics:dashboard:{eventId})
activate Redis
Redis --> Analytics: **Cache HIT**\n캐시된 대시보드 데이터 반환
deactivate Redis
note right of Analytics
**Cache-Aside 패턴**
- TTL: 1시간
- 예상 크기: 5KB
- 히트율 목표: 95%
- 응답 시간: 0.5초
end note
Analytics --> GW: 200 OK\n대시보드 데이터 (JSON)
deactivate Analytics
GW --> FE: 200 OK\n대시보드 데이터
deactivate GW
FE -> FE: 대시보드 렌더링\n- 4개 요약 카드\n- 채널별 성과 차트\n- 시간대별 참여 추이
FE --> User: 실시간 대시보드 표시
deactivate FE
== 2. 대시보드 조회 - Cache MISS 시나리오 ==
User -> FE: 대시보드 새로고침\n또는 첫 조회
activate FE
FE -> GW: GET /api/events/{id}/analytics
activate GW
GW -> Analytics: GET /api/events/{id}/analytics
activate Analytics
Analytics -> Redis: 대시보드 캐시 조회\n(캐시키: analytics:dashboard:{eventId})
activate Redis
Redis --> Analytics: **Cache MISS**\nnull 반환
deactivate Redis
note right of Analytics
**데이터 통합 작업 시작**
- Analytics DB 조회
- 외부 채널 API 병렬 호출
- Circuit Breaker 적용
end note
|||
== 2.1. Analytics DB 조회 (로컬 데이터) ==
Analytics -> AnalyticsDB: 이벤트 통계 조회\n(이벤트ID로 통계 데이터 조회)
activate AnalyticsDB
AnalyticsDB --> Analytics: 이벤트 통계\n- 총 참여자 수\n- 예상 ROI\n- 매출 증가율
deactivate AnalyticsDB
|||
== 2.2. 배치 수집된 채널 통계 데이터 조회 ==
Analytics -> AnalyticsDB: 채널별 통계 조회\n(배치로 수집된 채널 데이터 조회)
activate AnalyticsDB
note right of Analytics
**배치 처리 방식**
- 외부 API는 별도 배치 작업으로 주기적 수집
- 수집된 데이터는 DB에 저장
- 대시보드에서는 DB 데이터만 조회
- 응답 시간 단축 및 외부 API 의존성 제거
end note
AnalyticsDB --> Analytics: 채널별 통계 데이터\n- 우리동네TV: 노출 5,000, 조회 1,200\n- 지니TV: 노출 10,000, 클릭 500\n- Instagram: 좋아요 300, 댓글 50\n- Naver: 조회 2,000\n- Kakao: 공유 100
deactivate AnalyticsDB
note right of Analytics
**목업 데이터 활용**
- 초기에는 목업 데이터로 시작
- 점진적으로 실제 배치 작업 구현
- 배치 주기: 5분마다 수집
end note
|||
== 2.3. 데이터 통합 및 ROI 계산 ==
Analytics -> Analytics: 데이터 통합 및 계산\n- 총 노출 수 = 외부 채널 노출 합계\n- 총 참여자 수 = Analytics DB\n- ROI 계산 = (수익 - 비용) / 비용 × 100\n- 채널별 전환율 계산
note right of Analytics
**ROI 계산 로직**
총 비용 = 경품 비용 + 플랫폼 비용
예상 수익 = 매출 증가액 + 신규 고객 LTV
투자 대비 수익률 = (수익 - 비용) / 비용 × 100
end note
|||
== 2.4. Redis 캐싱 및 응답 ==
Analytics -> Redis: 대시보드 데이터 캐시 저장\n(캐시키: analytics:dashboard:{eventId},\n값: 통합 데이터, TTL: 1시간)
activate Redis
Redis --> Analytics: OK
deactivate Redis
Analytics --> GW: 200 OK\n대시보드 데이터 (JSON)\n{\n 총참여자: 1,234,\n 총노출: 17,200,\n ROI: 250%,\n 채널별성과: [...]\n}
deactivate Analytics
GW --> FE: 200 OK\n대시보드 데이터
deactivate GW
FE -> FE: 대시보드 렌더링\n- 4개 요약 카드 표시\n- 채널별 성과 차트\n- 시간대별 참여 추이\n- 참여자 프로필 분석
FE --> User: 실시간 대시보드 표시\n(응답 시간: 3초)
deactivate FE
|||
== 3. 실시간 업데이트 (Background Event 구독) ==
note over Analytics, Kafka
**Analytics Service는 항상 Background에서
Kafka Event Topics를 구독하여
실시간으로 통계를 업데이트합니다**
end note
Kafka -> Analytics: **EventCreated** 이벤트\n{eventId, storeId, title, objective}
activate Analytics
Analytics -> AnalyticsDB: 이벤트 통계 초기화\n(이벤트 기본 정보 저장)
activate AnalyticsDB
AnalyticsDB --> Analytics: OK
deactivate AnalyticsDB
Analytics -> Redis: 캐시 무효화\n(캐시키 삭제: analytics:dashboard:{eventId})
activate Redis
Redis --> Analytics: OK
deactivate Redis
deactivate Analytics
...참여자 등록 시...
Kafka -> Analytics: **ParticipantRegistered** 이벤트\n{participantId, eventId, phoneNumber}
activate Analytics
Analytics -> AnalyticsDB: 참여자 수 업데이트\n(참여자 수 1 증가)
activate AnalyticsDB
AnalyticsDB --> Analytics: OK
deactivate AnalyticsDB
Analytics -> Redis: 캐시 무효화\n(캐시키 삭제: analytics:dashboard:{eventId})
activate Redis
Redis --> Analytics: OK
deactivate Redis
deactivate Analytics
...배포 완료 시...
Kafka -> Analytics: **DistributionCompleted** 이벤트\n{eventId, distributedChannels, completedAt}
activate Analytics
Analytics -> AnalyticsDB: 채널 통계 저장\n(배포 완료된 채널 정보 저장)
activate AnalyticsDB
AnalyticsDB --> Analytics: OK
deactivate AnalyticsDB
Analytics -> Redis: 캐시 무효화\n(캐시키 삭제: analytics:dashboard:{eventId})
activate Redis
Redis --> Analytics: OK
deactivate Redis
deactivate Analytics
note right of Analytics
**실시간 업데이트 메커니즘**
- EventCreated: 이벤트 기본 정보 초기화
- ParticipantRegistered: 참여자 수 실시간 증가
- DistributionCompleted: 배포 채널 통계 업데이트
- 캐시 무효화: 다음 조회 시 최신 데이터 갱신
end note
@enduml

View File

@ -0,0 +1,250 @@
@startuml 이벤트생성플로우
!theme mono
title 이벤트 생성 플로우 - 외부 시퀀스 다이어그램
actor "소상공인" as User
participant "Frontend" as FE
participant "API Gateway" as Gateway
participant "Event Service" as Event
participant "User Service" as UserSvc
participant "AI Service" as AI
participant "Content Service" as Content
participant "Distribution Service" as Dist
participant "Kafka" as Kafka
database "Event DB" as EventDB
database "User DB" as UserDB
database "Redis" as Redis
participant "외부 AI API" as AIApi
participant "이미지 생성 API" as ImageApi
participant "배포 채널 APIs" as ChannelApis
== 1. 이벤트 목적 선택 (UFR-EVENT-020) ==
User -> FE: 이벤트 목적 선택
FE -> Gateway: GET /api/users/{userId}/store\n회원 및 매장정보 조회
activate Gateway
Gateway -> UserSvc: GET /api/users/{userId}/store\n회원 및 매장정보 조회
activate UserSvc
UserSvc -> UserDB: 사용자 및 매장 정보 조회
activate UserDB
UserDB --> UserSvc: 사용자, 매장 정보 반환
deactivate UserDB
UserSvc --> Gateway: 200 OK\n{userId, storeName, industry, address}
deactivate UserSvc
Gateway --> FE: 200 OK\n{userId, storeName, industry, address}
deactivate Gateway
FE -> Gateway: POST /events/purposes\n{목적, userId, storeName, industry, address}
Gateway -> Event: 이벤트 목적 저장 요청
Event -> Redis: 이벤트 목적 정보 저장\nKey: draft:event:{eventDraftId}\n(목적, 매장정보 저장)\nTTL: 24시간
activate Redis
Redis --> Event: 저장 완료
deactivate Redis
Event --> Gateway: 저장 완료\n{eventDraftId}
Gateway --> FE: 200 OK
FE --> User: AI 추천 화면으로 이동
== 2. AI 이벤트 추천 - 비동기 처리 (UFR-EVENT-030) ==
User -> FE: AI 추천 요청
FE -> Gateway: POST /api/events/{eventDraftId}/ai-recommendations\n{목적, 업종, 지역}
Gateway -> Event: AI 추천 요청 전달
Event -> Kafka: Publish to ai-job-topic\n{jobId, eventDraftId, 목적, 업종, 지역}
Event --> Gateway: Job 생성 완료\n{jobId, status: PENDING}
Gateway --> FE: 202 Accepted\n{jobId}
FE --> User: "AI가 분석 중입니다..." (로딩)
note over AI: Kafka Consumer\nai 이벤트 생성 topic 구독
Kafka --> AI: Consume Job Message\n{jobId, eventDraftId, ...}
AI -> AIApi: 트렌드 분석 및 이벤트 추천 요청\n{목적, 업종, 지역, 매장정보}\n[Circuit Breaker, Timeout: 5분]
AIApi --> AI: 3가지 추천안 + 트렌드 요약\n(예: "여름철 시원한 음료 선호도 증가")
AI -> Redis: AI 추천 결과 저장\nKey: ai:event:{eventDraftId}\n(3가지 추천안, 트렌드 요약)\nTTL: 24시간
Redis --> AI: 저장 완료
AI -> Redis: Job 상태 업데이트\n(상태를 COMPLETED로 변경)
note over AI, Redis: AI 추천 정보는 Redis에 저장\n- Content Service가 읽기 위함\n- 최종 승인 시 Event DB에 영구 저장
group Polling으로 상태 확인
loop 상태 확인 (최대 30초)
FE -> Gateway: GET /jobs/{jobId}/status
Gateway -> Event: Job 상태 조회
Event -> Redis: Job 상태 조회\n(jobId로 상태 및 결과 조회)
Redis --> Event: {status, result}
alt Job 완료
Event --> Gateway: 200 OK\n{status: COMPLETED, recommendations, trendSummary}
Gateway --> FE: 추천 결과 및 트렌드 요약 반환
FE --> User: 트렌드 요약 표시\n3가지 추천안 표시\n(제목/경품 수정 가능)
else Job 진행중
Event --> Gateway: 200 OK\n{status: PENDING/PROCESSING}
Gateway --> FE: 진행중 상태
note over FE: 2초 후 재요청
end
end
end
User -> FE: 추천안 선택\n(제목/경품 커스텀)
FE -> Gateway: PUT /events/drafts/{eventDraftId}\n{선택한 추천안, 커스텀 정보}
Gateway -> Event: 선택 저장
Event -> Redis: 선택한 추천안 저장\nKey: draft:event:{eventDraftId}\n(이벤트 초안 업데이트)\nTTL: 24시간
activate Redis
Redis --> Event: 업데이트 완료
deactivate Redis
Event --> Gateway: 200 OK
Gateway --> FE: 저장 완료
FE --> User: 콘텐츠 생성 화면으로 이동
== 3. SNS 이미지 생성 - 비동기 처리 (UFR-CONT-010) ==
User -> FE: 이미지 생성 요청
FE -> Gateway: POST /api/content/images/{eventDraftId}/generate
Gateway -> Content: 이미지 생성 요청
Content -> Content: Job 생성\n{jobId, eventDraftId, status: PENDING}
Content --> Gateway: Job 생성 완료\n{jobId, status: PENDING}
Gateway --> FE: 202 Accepted\n{jobId}
FE --> User: "이미지 생성 중..." (로딩)
note over Content: 백그라운드 워커\nRedis 폴링 또는 스케줄러
Content -> Redis: AI 이벤트 데이터 읽기\nKey: ai:event:{eventDraftId}
activate Redis
Redis --> Content: AI 추천 결과\n{선택된 추천안, 이벤트 정보}
deactivate Redis
note over Content: inner sequence 참조:\ncontent-이미지생성.puml
par 심플 스타일
Content -> ImageApi: 심플 스타일 생성 요청\n[Circuit Breaker, Timeout: 5분]
ImageApi --> Content: 심플 이미지 URL
else 화려한 스타일
Content -> ImageApi: 화려한 스타일 생성 요청\n[Circuit Breaker, Timeout: 5분]
ImageApi --> Content: 화려한 이미지 URL
else 트렌디 스타일
Content -> ImageApi: 트렌디 스타일 생성 요청\n[Circuit Breaker, Timeout: 5분]
ImageApi --> Content: 트렌디 이미지 URL
end
Content -> Redis: 이미지 URL 저장\nKey: content:image:{eventDraftId}\n{심플, 화려, 트렌디 URL}\nTTL: 7일
activate Redis
Redis --> Content: 저장 완료
deactivate Redis
Content -> Redis: Job 상태 업데이트\n(상태를 COMPLETED로 변경)
note over Content, Redis: 이미지 URL은 Redis에 저장\n- 최종 승인 시 Event DB에 영구 저장
group Polling으로 상태 확인
loop 상태 확인 (최대 30초)
FE -> Gateway: GET /api/content/jobs/{jobId}/status
Gateway -> Content: Job 상태 조회
Content -> Redis: Job 상태 조회\n(jobId로 상태 및 이미지 URL 조회)
Redis --> Content: {status, imageUrls}
alt Job 완료
Content --> Gateway: 200 OK\n{status: COMPLETED, imageUrls}
Gateway --> FE: 이미지 URL 반환
FE --> User: 3가지 스타일 카드 표시
else Job 진행중
Content --> Gateway: 200 OK\n{status: PENDING/PROCESSING}
Gateway --> FE: 진행중 상태
note over FE: 2초 후 재요청
end
end
end
User -> FE: 스타일 선택 및 편집
FE -> Gateway: PUT /events/drafts/{eventDraftId}/content\n{선택한 이미지, 편집내용}
Gateway -> Event: 콘텐츠 선택 저장
Event -> Redis: 선택한 콘텐츠 저장\nKey: draft:event:{eventDraftId}\n(이벤트 초안 업데이트)\nTTL: 24시간
activate Redis
Redis --> Event: 업데이트 완료
deactivate Redis
Event --> Gateway: 200 OK
Gateway --> FE: 저장 완료
FE --> User: 배포 채널 선택 화면으로 이동
== 4. 최종 승인 및 다중 채널 배포 - 동기 처리 (UFR-EVENT-050) ==
User -> FE: 배포 채널 선택\n최종 승인 요청
FE -> Gateway: POST /api/events/{eventDraftId}/publish\n{선택 채널 목록}
Gateway -> Event: 최종 승인 및 배포 처리
note over Event: Redis 데이터를 Event DB에 영구 저장
Event -> Redis: 이벤트 초안 조회\nKey: draft:event:{eventDraftId}
activate Redis
Redis --> Event: 이벤트 초안 데이터\n(목적, 매장정보, 추천안, 콘텐츠)
deactivate Redis
Event -> Redis: AI 추천 결과 조회\nKey: ai:event:{eventDraftId}
activate Redis
Redis --> Event: AI 추천 결과
deactivate Redis
Event -> Redis: 이미지 URL 조회\nKey: content:image:{eventDraftId}
activate Redis
Redis --> Event: 이미지 URL 목록
deactivate Redis
Event -> EventDB: 이벤트 정보 영구 저장\n(목적, 매장정보, AI 추천, 이미지 URL, 배포 채널 포함)
EventDB --> Event: 저장 완료
Event -> EventDB: 이벤트 상태 변경\n(DRAFT → APPROVED로 업데이트)
EventDB --> Event: 상태 변경 완료
Event -> Kafka: Publish to event-topic\nEventCreated\n{eventId, 이벤트정보}
note over Event: 동기 호출로 배포 진행\ninner sequence 참조:\ndistribution-다중채널배포.puml
Event -> Dist: REST API - 배포 요청\nPOST /api/distribution/distribute\n{eventId, channels[], contentUrls}
note over Dist: Sprint 2: Mock 처리\n- 외부 API 호출 없음\n- 모든 배포 즉시 성공 처리\n- 배포 로그만 DB 기록
Dist -> EventDB: 배포 이력 초기화\n(이벤트ID, 상태: PENDING)
EventDB --> Dist: 배포 이력 ID
Dist -> EventDB: 배포 이력 상태 업데이트\n(상태: IN_PROGRESS)
note over Dist: 다중 채널 Mock 배포\n(내부 처리 상세는 inner sequence 참조)
par 우리동네TV
alt 우리동네TV 선택
Dist -> Dist: Mock 처리\n(즉시 성공)
Dist -> EventDB: 채널 로그 저장\n(우리동네TV, 성공,\n배포ID, 예상노출수)
end
else 링고비즈
alt 링고비즈 선택
Dist -> Dist: Mock 처리\n(즉시 성공)
Dist -> EventDB: 채널 로그 저장\n(링고비즈, 성공,\n업데이트 시각)
end
else 지니TV
alt 지니TV 선택
Dist -> Dist: Mock 처리\n(즉시 성공)
Dist -> EventDB: 채널 로그 저장\n(지니TV, 성공,\n광고ID, 스케줄)
end
else Instagram
alt Instagram 선택
Dist -> Dist: Mock 처리\n(즉시 성공)
Dist -> EventDB: 채널 로그 저장\n(Instagram, 성공,\npostUrl, postId)
end
else Naver Blog
alt Naver Blog 선택
Dist -> Dist: Mock 처리\n(즉시 성공)
Dist -> EventDB: 채널 로그 저장\n(NaverBlog, 성공,\npostUrl)
end
else Kakao Channel
alt Kakao Channel 선택
Dist -> Dist: Mock 처리\n(즉시 성공)
Dist -> EventDB: 채널 로그 저장\n(KakaoChannel, 성공,\nmessageId)
end
end
note over Dist: 모든 채널 배포 완료 (즉시 처리)
Dist -> EventDB: 배포 이력 상태 업데이트\n(상태: COMPLETED, 완료일시)
Dist -> Kafka: Publish to event-topic\nDistributionCompleted\n{eventId, channels[], results[], completedAt}
Dist --> Event: REST API 동기 응답\n200 OK\n{distributionId, status: COMPLETED, results[]}
Event -> EventDB: 이벤트 상태 업데이트\n(APPROVED → ACTIVE로 변경)
EventDB --> Event: 업데이트 완료
Event --> Gateway: 200 OK\n{eventId, 배포결과}
Gateway --> FE: 배포 완료
FE --> User: "이벤트가 배포되었습니다"\n대시보드로 이동
note over Event, Dist: Sprint 2 제약사항\n- 외부 API 호출 없음 (Mock)\n- 모든 배포 즉시 성공 처리\n- Circuit Breaker 미구현\n- Retry 로직 미구현\n\nSprint 3 이후 구현 예정\n- 실제 외부 채널 API 연동\n- Circuit Breaker 패턴\n- Retry 및 실패 처리
@enduml

View File

@ -0,0 +1,619 @@
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 클라우드 아키텍처 패턴 적용 방안 (MVP)
## 문서 정보
- **작성일**: 2025-10-21
- **버전**: 2.0 (MVP 집중)
- **적용 패턴**: 4개 핵심 패턴
- **목표**: 빠른 MVP 출시와 안정적 서비스 제공
---
## 1. 요구사항 분석
### 1.1 기능적 요구사항
**User 서비스**
- 회원가입/로그인 및 프로필 관리
- 사업자번호 검증 (국세청 API)
**Event 서비스**
- 이벤트 CRUD 및 상태 관리
- 이벤트 목적 선택 → AI 추천 → 콘텐츠 생성 → 배포 → 분석 플로우
**AI 서비스**
- 트렌드 분석 (업종/지역/시즌)
- 3가지 이벤트 기획안 자동 생성
- Claude API/GPT-4 API 연동
**Content 서비스**
- 3가지 스타일 SNS 이미지 자동 생성
- Stable Diffusion/DALL-E API 연동
- 플랫폼별 이미지 최적화 (Instagram, Naver, Kakao)
**Distribution 서비스**
- 다중 채널 동시 배포 (우리동네TV, 링고비즈, 지니TV, SNS)
- 7개 외부 API 연동
**Participation 서비스**
- 이벤트 참여 접수 및 당첨자 추첨
**Analytics 서비스**
- 실시간 성과 대시보드
- 채널별 성과 분석
### 1.2 비기능적 요구사항
**성능 요구사항**
- AI 트렌드 분석 + 이벤트 추천: **10초 이내**
- SNS 이미지 생성: **5초 이내**
- 대시보드 데이터 업데이트: **5분 간격**
- 다중 채널 배포: **1분 이내**
**가용성 요구사항**
- 시스템 가용성: **99% 이상** (MVP 목표)
- 외부 API 장애 시 서비스 지속 가능
**확장성 요구사항**
- 초기 사용자: **100명**
- 동시 이벤트: **50개**
- 캐시 히트율: **80% 이상**
**보안 요구사항**
- JWT 기반 인증/인가
- API Gateway를 통한 중앙집중식 보안
### 1.3 외부 API 의존성
| 서비스 | 외부 API | 용도 | 응답시간 |
|--------|----------|------|----------|
| User | 국세청 사업자등록정보 진위확인 API | 사업자번호 검증 | 2-3초 |
| AI | Claude API / GPT-4 API | 트렌드 분석 및 이벤트 추천 | 5-10초 |
| Content | Stable Diffusion / DALL-E API | SNS 이미지 생성 | 3-5초 |
| Distribution | 우리동네TV API | 영상 업로드 및 송출 | 1-2초 |
| Distribution | 링고비즈 API | 연결음 업데이트 | 1초 |
| Distribution | 지니TV 광고 API | 광고 등록 | 1-2초 |
| Distribution | SNS API (Instagram, Naver, Kakao) | 자동 포스팅 | 1-3초 |
### 1.4 기술적 도전과제
#### 1. AI 응답 시간 관리 (🔴 Critical)
**문제**: AI API 응답이 10초 목표를 초과할 수 있음
- Claude/GPT-4 API 응답 시간 변동성 (5-15초)
- 트렌드 분석 + 이벤트 추천 2단계 처리 필요
**해결방안**:
- **비동기 처리**: Job 기반 처리로 응답 대기 시간 최소화
- **캐싱**: 동일 조건(업종, 지역, 목적) 결과 Redis 캐싱 (24시간)
- **응답 시간 90% 단축** (캐시 히트 시)
#### 2. 다중 외부 API 의존성 (🔴 Critical)
**문제**: 7개 외부 API 연동으로 인한 장애 전파 위험
- 각 API별 응답 시간 및 성공률 상이
- 하나의 API 장애가 전체 서비스 중단으로 이어질 수 있음
**해결방안**:
- **Circuit Breaker**: 장애 API 자동 차단 및 Fallback 처리
- **독립적 처리**: 채널별 배포 실패가 다른 채널에 영향 없음
- **자동 재시도**: 일시적 오류 시 최대 3회 재시도
#### 3. 이미지 생성 비용 및 시간 (🟡 Important)
**문제**: AI 이미지 생성 API 비용이 높고 시간 소요
- 이미지 1장당 생성 비용: $0.02-0.05
- 3가지 스타일 생성 시 비용 3배
**해결방안**:
- **캐싱**: 동일 이벤트 정보 이미지 재사용
- **비용 90% 절감** (캐시 히트 시)
#### 4. 실시간 대시보드 성능 (🟡 Important)
**문제**: 다중 데이터 소스 통합으로 인한 응답 지연
- 7개 외부 API + 내부 서비스 데이터 통합
- 복잡한 차트 및 계산 로직
**해결방안**:
- **캐싱**: Redis를 통한 5분 간격 데이터 캐싱
- **응답 시간 80% 단축**
---
## 2. 패턴 선정 및 평가
### 2.1 MVP 핵심 패턴 (4개)
| 패턴 | 적용 목적 | 주요 효과 |
|------|----------|-----------|
| **Cache-Aside** | AI 응답 시간 단축 및 비용 절감 | 응답 시간 90% 단축, 비용 90% 절감 |
| **API Gateway** | 중앙집중식 보안 및 라우팅 | 개발 복잡도 감소, 보안 강화 |
| **Asynchronous Request-Reply** | 장시간 작업 응답 시간 개선 | 사용자 대기 시간 제거 |
| **Circuit Breaker** (Optional) | 외부 API 장애 격리 | 가용성 95% → 99% 개선 |
### 2.2 정량적 평가 매트릭스
**평가 기준**:
- **기능 적합성** (35%): 요구사항 직접 해결 능력
- **성능 효과** (25%): 응답시간 및 처리량 개선
- **운영 복잡도** (20%): 구현 및 운영 용이성
- **확장성** (15%): 미래 요구사항 대응력
- **비용 효율성** (5%): 개발/운영 비용 대비 효과
| 패턴 | 기능 적합성<br/>(35%) | 성능 효과<br/>(25%) | 운영 복잡도<br/>(20%) | 확장성<br/>(15%) | 비용 효율성<br/>(5%) | **총점** | 우선순위 |
|------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| **Cache-Aside** | 10 × 0.35<br/>= 3.5 | 10 × 0.25<br/>= 2.5 | 8 × 0.20<br/>= 1.6 | 9 × 0.15<br/>= 1.35 | 10 × 0.05<br/>= 0.5 | **9.45** | 🔴 Critical |
| **API Gateway** | 9 × 0.35<br/>= 3.15 | 7 × 0.25<br/>= 1.75 | 9 × 0.20<br/>= 1.8 | 9 × 0.15<br/>= 1.35 | 8 × 0.05<br/>= 0.4 | **8.45** | 🔴 Critical |
| **Asynchronous Request-Reply** | 9 × 0.35<br/>= 3.15 | 9 × 0.25<br/>= 2.25 | 7 × 0.20<br/>= 1.4 | 8 × 0.15<br/>= 1.2 | 8 × 0.05<br/>= 0.4 | **8.40** | 🔴 Critical |
| **Circuit Breaker** | 8 × 0.35<br/>= 2.8 | 8 × 0.25<br/>= 2.0 | 9 × 0.20<br/>= 1.8 | 9 × 0.15<br/>= 1.35 | 7 × 0.05<br/>= 0.35 | **8.30** | 🟡 Optional |
### 2.3 패턴별 상세 분석
#### 2.3.1 Cache-Aside (9.45점) - 🔴 Critical
**적용 대상**:
- AI 서비스: 트렌드 분석 결과, 이벤트 추천 결과
- Content 서비스: 생성된 SNS 이미지
- User 서비스: 사업자번호 검증 결과
**구현 방식**:
```
1. 요청 수신 → 캐시 확인 (Redis GET)
2. 캐시 HIT: 즉시 반환 (응답 시간 0.1초)
3. 캐시 MISS:
- 외부 API 호출 (응답 시간 5-10초)
- 결과를 캐시에 저장 (Redis SET, TTL 24시간)
- 결과 반환
```
**기대 효과**:
- **응답 시간**: 10초 → 0.1초 (99% 개선, 캐시 히트 시)
- **비용 절감**: AI API 호출 90% 감소 → 월 $2,000 → $200 (90% 절감)
- **캐시 히트율**: 80% (동일 업종/지역 이벤트 반복 요청)
**운영 고려사항**:
- Redis 메모리 관리: 최대 2GB (약 10,000개 캐시 항목)
- TTL 정책: 24시간 (트렌드 변화 반영)
- 캐시 무효화: 수동 무효화 API 제공
#### 2.3.2 API Gateway (8.45점) - 🔴 Critical
**적용 대상**:
- 전체 마이크로서비스 (User, Event, AI, Content, Distribution, Participation, Analytics)
**주요 기능**:
1. **인증/인가**: JWT 토큰 검증 (모든 요청)
2. **라우팅**: URL 기반 서비스 라우팅
3. **Rate Limiting**: API 호출 제한 (사용자당 100 req/min)
4. **로깅**: 중앙집중식 접근 로그
**구현 방식**:
```
Client → API Gateway (Kong/AWS API Gateway)
├─ /api/users/* → User Service
├─ /api/events/* → Event Service
├─ /api/ai/* → AI Service
├─ /api/content/* → Content Service
├─ /api/distribution/* → Distribution Service
├─ /api/participation/* → Participation Service
└─ /api/analytics/* → Analytics Service
```
**기대 효과**:
- **개발 생산성**: 각 서비스에서 인증 로직 제거 → 개발 시간 30% 단축
- **보안 강화**: 중앙집중식 보안 정책 관리
- **운영 효율**: 통합 모니터링 및 로깅
#### 2.3.3 Asynchronous Request-Reply (8.40점) - 🔴 Critical
**적용 대상**:
- AI 서비스: 트렌드 분석 및 이벤트 추천 (10초 소요)
- Content 서비스: SNS 이미지 생성 (5초 소요)
**구현 방식**:
```
1. 클라이언트 요청 → Job ID 즉시 반환 (응답 시간 0.1초)
2. 백그라운드 처리:
- AI API 호출 및 결과 생성 (10초)
- 결과를 DB 저장
3. 클라이언트 폴링:
- 5초 간격으로 Job 상태 확인 (GET /jobs/{id})
- 완료 시 결과 수신
```
**기대 효과**:
- **사용자 경험**: 대기 화면에서 진행 상황 표시 → 이탈률 감소
- **서버 부하**: 동기 대기 제거 → 동시 처리 가능 요청 5배 증가
**운영 고려사항**:
- Job 상태 저장: Redis (TTL 1시간)
- 폴링 간격: 5초 (UX 고려)
- Job 만료: 1시간 후 자동 삭제
#### 2.3.4 Circuit Breaker (8.30점) - 🟡 Optional
**적용 대상**:
- 모든 외부 API 연동 지점 (7개 API)
**동작 방식**:
```
1. Closed (정상):
- 외부 API 정상 호출
- 실패율 5% 미만
2. Open (차단):
- 실패율 5% 초과 시 Circuit Open
- 모든 요청 즉시 실패 (Fallback 처리)
- 30초 대기
3. Half-Open (테스트):
- 30초 후 1개 요청 시도
- 성공 시 Closed로 전환
- 실패 시 Open 유지
```
**Fallback 전략**:
- **AI 서비스**: 캐시된 이전 추천 결과 제공 + 안내 메시지
- **Distribution 서비스**: 해당 채널 배포 스킵 + 알림
- **User 서비스**: 사업자번호 검증 스킵 (수동 확인으로 대체)
**기대 효과**:
- **가용성**: 95% → 99% (외부 API 장애 격리)
- **장애 복구**: 자동 복구 시간 5분 → 30초
---
## 3. 서비스별 패턴 적용 설계
### 3.1 전체 아키텍처 (MVP)
```mermaid
graph TB
subgraph "클라이언트"
Client[Web/Mobile App]
end
subgraph "API Gateway Layer"
Gateway[API Gateway<br/>인증, 라우팅, Rate Limiting]
end
subgraph "마이크로서비스"
UserSvc[User Service<br/>회원관리]
EventSvc[Event Service<br/>이벤트 관리]
AISvc[AI Service<br/>트렌드 분석, 추천]
ContentSvc[Content Service<br/>이미지 생성]
DistSvc[Distribution Service<br/>다중 채널 배포]
PartSvc[Participation Service<br/>참여자 관리]
AnalSvc[Analytics Service<br/>성과 분석]
end
subgraph "Cache Layer"
Redis[(Redis Cache<br/>Cache-Aside)]
end
subgraph "외부 API (Circuit Breaker 적용)"
TaxAPI[국세청 API]
ClaudeAPI[Claude API]
SDAPI[Stable Diffusion]
UriAPI[우리동네TV API]
RingoAPI[링고비즈 API]
GenieAPI[지니TV API]
SNSAPI[SNS API]
end
Client -->|HTTPS| Gateway
Gateway --> UserSvc
Gateway --> EventSvc
Gateway --> AISvc
Gateway --> ContentSvc
Gateway --> DistSvc
Gateway --> PartSvc
Gateway --> AnalSvc
UserSvc -.->|Cache-Aside| Redis
AISvc -.->|Cache-Aside| Redis
ContentSvc -.->|Cache-Aside| Redis
UserSvc -->|Circuit Breaker| TaxAPI
AISvc -->|Circuit Breaker| ClaudeAPI
ContentSvc -->|Circuit Breaker| SDAPI
DistSvc -->|Circuit Breaker| UriAPI
DistSvc -->|Circuit Breaker| RingoAPI
DistSvc -->|Circuit Breaker| GenieAPI
DistSvc -->|Circuit Breaker| SNSAPI
style Gateway fill:#e1f5ff
style Redis fill:#ffe1e1
style AISvc fill:#fff4e1
style ContentSvc fill:#fff4e1
```
### 3.2 AI Service - Asynchronous Request-Reply 패턴
```mermaid
sequenceDiagram
participant Client as 클라이언트
participant API as API Gateway
participant Event as Event Service
participant AI as AI Service
participant Cache as Redis Cache
participant Claude as Claude API
Client->>API: POST /api/ai/recommendations<br/>(업종, 지역, 목적)
API->>Event: 이벤트 추천 요청
Event->>AI: 추천 요청
AI->>Cache: GET cached_result
alt Cache HIT
Cache-->>AI: 캐시된 결과
AI-->>Event: 즉시 반환 (0.1초)
Event-->>API: 결과
API-->>Client: 결과 (Total: 0.2초)
else Cache MISS
Cache-->>AI: null
AI-->>Event: Job ID 즉시 반환
Event-->>API: Job ID
API-->>Client: Job ID + 처리중 상태 (0.1초)
AI->>Claude: 비동기 AI 호출
Note over AI,Claude: 백그라운드 처리 (10초)
Claude-->>AI: 추천 결과
AI->>Cache: SET result (TTL 24h)
AI->>AI: Job 상태 = 완료
loop 폴링 (5초 간격)
Client->>API: GET /api/jobs/{id}
API->>Event: Job 상태 확인
Event->>AI: Job 상태
alt 완료
AI-->>Event: 결과
Event-->>API: 결과
API-->>Client: 최종 결과
else 진행중
AI-->>Event: 진행중
Event-->>API: 진행중
API-->>Client: 진행 상황 (예: 70%)
end
end
end
```
### 3.3 Distribution Service - Circuit Breaker 패턴
```mermaid
graph TB
subgraph "Distribution Service"
DistCtrl[Distribution Controller]
CB_Uri[Circuit Breaker<br/>우리동네TV]
CB_Ringo[Circuit Breaker<br/>링고비즈]
CB_Genie[Circuit Breaker<br/>지니TV]
CB_SNS[Circuit Breaker<br/>SNS]
end
subgraph "외부 API"
UriAPI[우리동네TV API]
RingoAPI[링고비즈 API]
GenieAPI[지니TV API]
SNSAPI[SNS API]
end
DistCtrl --> CB_Uri
DistCtrl --> CB_Ringo
DistCtrl --> CB_Genie
DistCtrl --> CB_SNS
CB_Uri -->|실패율 < 5%<br/>Closed| UriAPI
CB_Uri -.->|실패율 >= 5%<br/>Open, Fallback| Fallback_Uri[배포 스킵<br/>+ 알림]
CB_Ringo -->|실패율 < 5%<br/>Closed| RingoAPI
CB_Ringo -.->|실패율 >= 5%<br/>Open, Fallback| Fallback_Ringo[배포 스킵<br/>+ 알림]
CB_Genie -->|실패율 < 5%<br/>Closed| GenieAPI
CB_Genie -.->|실패율 >= 5%<br/>Open, Fallback| Fallback_Genie[배포 스킵<br/>+ 알림]
CB_SNS -->|실패율 < 5%<br/>Closed| SNSAPI
CB_SNS -.->|실패율 >= 5%<br/>Open, Fallback| Fallback_SNS[배포 스킵<br/>+ 알림]
style CB_Uri fill:#ffe1e1
style CB_Ringo fill:#ffe1e1
style CB_Genie fill:#ffe1e1
style CB_SNS fill:#ffe1e1
```
---
## 4. MVP 구현 로드맵
### 4.1 단일 Phase MVP 전략
**목표**: 빠른 출시와 안정적 서비스 제공
**기간**: 12주
**목표 지표**:
- 사용자: 100명
- 동시 이벤트: 50개
- 시스템 가용성: 99%
- AI 응답 시간: 10초 이내 (캐시 미스), 0.1초 (캐시 히트)
### 4.2 구현 순서 및 일정
| 주차 | 작업 내용 | 패턴 적용 | 완료 기준 |
|------|----------|-----------|-----------|
| **1-2주** | 인프라 구축 | API Gateway | - Kong/AWS API Gateway 설정<br/>- Redis 클러스터 구축<br/>- JWT 인증 구현 |
| **3-4주** | User/Event 서비스 | Cache-Aside | - 회원가입/로그인 완료<br/>- 사업자번호 검증 캐싱<br/>- 이벤트 CRUD 완료 |
| **5-6주** | AI 서비스 | Asynchronous Request-Reply<br/>Cache-Aside | - Claude API 연동<br/>- Job 기반 비동기 처리<br/>- AI 결과 캐싱 (24시간 TTL) |
| **7-8주** | Content 서비스 | Asynchronous Request-Reply<br/>Cache-Aside | - Stable Diffusion 연동<br/>- 이미지 생성 Job 처리<br/>- 이미지 캐싱 및 CDN |
| **9-10주** | Distribution 서비스 | Circuit Breaker | - 7개 외부 API 연동<br/>- 각 API별 Circuit Breaker<br/>- Fallback 전략 구현 |
| **11주** | Participation/Analytics | Cache-Aside | - 참여자 관리 및 추첨<br/>- 대시보드 데이터 캐싱 |
| **12주** | 테스트 및 출시 | 전체 패턴 검증 | - 부하 테스트 (100명)<br/>- 장애 시나리오 테스트<br/>- MVP 출시 |
### 4.3 기술 스택
**백엔드**:
- Spring Boot 3.2 (Java 17) / Node.js 20
- Redis 7.2 (Cache)
- PostgreSQL 15 (주 DB)
- RabbitMQ / Kafka (비동기 처리, Optional)
**프론트엔드**:
- React 18 + TypeScript 5
- Next.js 14 (SSR)
- Zustand (상태관리)
- React Query (서버 상태 관리)
**인프라**:
- Docker + Kubernetes
- Kong API Gateway / AWS API Gateway
- Resilience4j (Circuit Breaker)
- Prometheus + Grafana (모니터링)
**외부 API**:
- Claude API / GPT-4 API
- Stable Diffusion API
- 국세청 사업자등록정보 진위확인 API
- 우리동네TV, 링고비즈, 지니TV, SNS API
---
## 5. 예상 성과
### 5.1 성능 개선
| 항목 | Before | After | 개선율 |
|------|--------|-------|--------|
| AI 응답 시간 (캐시 히트) | 10초 | 0.1초 | **99% 개선** |
| AI 응답 시간 (캐시 미스) | 10초 | 10초 | - |
| 이미지 생성 시간 (캐시 히트) | 5초 | 0.1초 | **98% 개선** |
| 대시보드 로딩 시간 | 3초 | 0.5초 | **83% 개선** |
### 5.2 비용 절감
**AI API 비용 절감** (캐시 히트율 80% 가정):
- Before: 1,000 요청/일 × $0.02 = **$20/일** = **$600/월**
- After: 200 요청/일 × $0.02 = **$4/일** = **$120/월**
- **절감액**: $480/월 (**80% 절감**)
**이미지 생성 비용 절감** (캐시 히트율 80% 가정):
- Before: 500 이미지/일 × $0.04 = **$20/일** = **$600/월**
- After: 100 이미지/일 × $0.04 = **$4/일** = **$120/월**
- **절감액**: $480/월 (**80% 절감**)
**총 비용 절감**:
- Before: $1,200/월
- After: $240/월
- **총 절감액**: **$960/월** (**80% 절감**)
### 5.3 가용성 개선
| 지표 | Before | After | 개선 |
|------|--------|-------|------|
| 시스템 가용성 | 95% | 99% | **+4%p** |
| 외부 API 장애 시 서비스 지속 | ❌ 불가 | ✅ 가능 (Fallback) | - |
| 장애 자동 복구 시간 | 5분 (수동) | 30초 (자동) | **90% 단축** |
### 5.4 개발 생산성
| 항목 | 효과 |
|------|------|
| API Gateway | 각 서비스 인증 로직 제거 → 개발 시간 **30% 단축** |
| Cache-Aside | 반복 API 호출 제거 → 테스트 시간 **50% 단축** |
| Circuit Breaker | 장애 처리 로직 자동화 → 예외 처리 코드 **40% 감소** |
| Async Request-Reply | 동시 처리 능력 **5배 증가** |
---
## 6. 운영 고려사항
### 6.1 모니터링 지표
**Cache-Aside**:
- 캐시 히트율 (목표: 80% 이상)
- Redis 메모리 사용률 (목표: 70% 이하)
- 캐시 응답 시간 (목표: 100ms 이하)
**API Gateway**:
- 요청 처리량 (TPS)
- 평균 응답 시간
- 인증 실패율
**Circuit Breaker**:
- API별 실패율
- Circuit 상태 (Closed/Open/Half-Open)
- Fallback 호출 횟수
**Asynchronous Request-Reply**:
- Job 처리 시간
- 동시 Job 수
- Job 완료율
### 6.2 알람 임계값
| 지표 | Warning | Critical |
|------|---------|----------|
| 캐시 히트율 | < 70% | < 50% |
| Redis 메모리 | > 80% | > 90% |
| API 응답 시간 | > 500ms | > 1000ms |
| Circuit Breaker Open | 1개 | 3개 이상 |
| 시스템 가용성 | < 99% | < 95% |
### 6.3 장애 대응 절차
**Circuit Breaker Open 발생 시**:
1. 알람 수신 (Slack/Email)
2. 해당 외부 API 상태 확인
3. Fallback 전략 동작 확인
4. 30초 후 자동 복구 확인
5. 복구 실패 시 수동 개입
**캐시 장애 시**:
1. Redis 클러스터 상태 확인
2. Failover 자동 수행 (Sentinel)
3. 캐시 미스로 전환 (성능 저하 허용)
4. 긴급 Redis 복구
---
## 7. 체크리스트
### 7.1 패턴 적용 완료 확인
- [x] **Cache-Aside**: AI 결과, 이미지, 사업자번호 검증 캐싱 완료
- [x] **API Gateway**: 인증, 라우팅, Rate Limiting 구현 완료
- [x] **Asynchronous Request-Reply**: AI 및 이미지 생성 Job 처리 완료
- [x] **Circuit Breaker**: 7개 외부 API에 Circuit Breaker 및 Fallback 적용 완료
### 7.2 성능 목표 달성 확인
- [ ] AI 응답 시간: 10초 이내 (캐시 미스), 0.1초 (캐시 히트)
- [ ] 캐시 히트율: 80% 이상
- [ ] 시스템 가용성: 99% 이상
- [ ] 비용 절감: 월 $960 (80%)
### 7.3 운영 준비 완료 확인
- [ ] 모니터링 대시보드 구축 (Prometheus + Grafana)
- [ ] 알람 설정 완료 (Slack/Email)
- [ ] 장애 대응 매뉴얼 작성
- [ ] 부하 테스트 완료 (100명)
---
## 8. 다음 단계 (Phase 2 이후)
**MVP 이후 확장 계획** (선택 사항):
- **Retry 패턴**: 일시적 오류 자동 재시도 (현재는 Circuit Breaker로 커버)
- **Queue-Based Load Leveling**: 트래픽 폭증 시 부하 분산
- **Saga 패턴**: 복잡한 분산 트랜잭션 관리 (이벤트 생성 플로우)
- **CQRS**: 읽기/쓰기 분리로 대시보드 성능 최적화
- **Event Sourcing**: 이벤트 변경 이력 추적 및 감사
---
## 참고 문서
- [유저스토리](../userstory.md)
- [UI/UX 설계서](../uiux/uiux.md)
- [클라우드 디자인 패턴 개요](../../claude/cloud-design-patterns.md)
- [백업 파일](./architecture-pattern-backup.md) - 이전 버전 (9개 패턴)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<div class="page flex flex-col items-center justify-center" style="padding-bottom: 0;">
<div class="container" style="max-width: 400px;">
<!-- Logo & Title -->
<div class="text-center mb-2xl">
<div class="mb-lg">
<span class="material-icons" style="font-size: 64px; color: var(--color-kt-red);">celebration</span>
</div>
<h1 class="text-display text-kt-red mb-sm">KT AI 이벤트</h1>
<p class="text-body text-secondary">소상공인을 위한 스마트 마케팅</p>
</div>
<!-- Login Form -->
<form id="loginForm" class="mb-lg">
<div class="form-group">
<label for="email" class="form-label form-label-required">이메일</label>
<input
type="email"
id="email"
name="email"
class="form-input"
placeholder="example@email.com"
required
autocomplete="email"
>
<span id="emailError" class="form-error hidden"></span>
</div>
<div class="form-group">
<label for="password" class="form-label form-label-required">비밀번호</label>
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="8자 이상 입력하세요"
required
autocomplete="current-password"
>
<span id="passwordError" class="form-error hidden"></span>
</div>
<div class="form-check mb-lg">
<input type="checkbox" id="remember" name="remember" class="form-check-input">
<label for="remember" class="form-check-label">로그인 상태 유지</label>
</div>
<button type="submit" class="btn btn-primary btn-large btn-full mb-md">
로그인
</button>
<div class="flex justify-between text-body-small">
<a href="#" class="text-kt-red" id="findPassword">비밀번호 찾기</a>
<a href="02-회원가입.html" class="text-kt-red">회원가입</a>
</div>
</form>
<!-- Divider -->
<div class="flex items-center gap-md mb-lg">
<div class="flex-1 border-t"></div>
<span class="text-caption text-tertiary">또는</span>
<div class="flex-1 border-t"></div>
</div>
<!-- SNS Login -->
<div class="flex flex-col gap-sm">
<button class="btn btn-secondary btn-large btn-full" id="kakaoLogin">
<span class="material-icons">chat_bubble</span>
카카오톡으로 시작하기
</button>
<button class="btn btn-secondary btn-large btn-full" id="naverLogin">
<span style="color: #03C75A; font-weight: bold;">N</span>
네이버로 시작하기
</button>
</div>
<!-- Footer -->
<div class="text-center mt-2xl">
<p class="text-caption text-tertiary">
회원가입 시 <a href="#" class="text-kt-red">이용약관</a><a href="#" class="text-kt-red">개인정보처리방침</a>에 동의하게 됩니다.
</p>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 폼 제출 처리
document.getElementById('loginForm').addEventListener('submit', function(e) {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const remember = document.getElementById('remember').checked;
// 에러 메시지 초기화
document.getElementById('emailError').classList.add('hidden');
document.getElementById('passwordError').classList.add('hidden');
// 유효성 검사
let hasError = false;
if (!KTEventApp.Utils.validateEmail(email)) {
document.getElementById('emailError').textContent = '올바른 이메일 형식이 아닙니다.';
document.getElementById('emailError').classList.remove('hidden');
hasError = true;
}
if (password.length < 8) {
document.getElementById('passwordError').textContent = '비밀번호는 8자 이상이어야 합니다.';
document.getElementById('passwordError').classList.remove('hidden');
hasError = true;
}
if (hasError) {
return;
}
// 로딩 표시
const submitBtn = e.target.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = '로그인 중...';
// 로그인 시뮬레이션 (실제로는 API 호출)
setTimeout(() => {
// 예제 사용자 정보 저장
const user = KTEventApp.MockData.getDefaultUser();
user.email = email;
KTEventApp.Session.saveUser(user);
// 대시보드로 이동
window.location.href = '05-대시보드.html';
}, 1000);
});
// 비밀번호 찾기
document.getElementById('findPassword').addEventListener('click', function(e) {
e.preventDefault();
KTEventApp.Feedback.showModal({
title: '비밀번호 찾기',
content: `
<p class="text-body mb-md">가입하신 이메일 주소를 입력해주세요.</p>
<input type="email" id="resetEmail" class="form-input" placeholder="example@email.com">
`,
buttons: [
{
text: '취소',
variant: 'text',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
},
{
text: '확인',
variant: 'primary',
onClick: function() {
const resetEmail = document.getElementById('resetEmail').value;
if (KTEventApp.Utils.validateEmail(resetEmail)) {
this.closest('.modal-backdrop').remove();
KTEventApp.Feedback.showToast('비밀번호 재설정 이메일을 발송했습니다.');
} else {
KTEventApp.Feedback.showToast('올바른 이메일을 입력해주세요.');
}
}
}
]
});
});
// SNS 로그인 시뮬레이션
document.getElementById('kakaoLogin').addEventListener('click', function() {
KTEventApp.Feedback.showToast('카카오톡 로그인은 준비 중입니다.');
});
document.getElementById('naverLogin').addEventListener('click', function() {
KTEventApp.Feedback.showToast('네이버 로그인은 준비 중입니다.');
});
// 이미 로그인된 경우 대시보드로 이동
if (KTEventApp.Session.isLoggedIn()) {
window.location.href = '05-대시보드.html';
}
</script>
</body>
</html>

View File

@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회원가입 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<div class="page" style="padding-bottom: 0;">
<!-- Custom Header -->
<header class="header">
<div class="header-left">
<button class="header-icon-btn" id="backBtn" aria-label="뒤로가기">
<span class="material-icons">arrow_back</span>
</button>
<h1 class="header-title">회원가입</h1>
</div>
</header>
<!-- Progress Indicator -->
<div class="p-md bg-primary">
<div id="progressBar"></div>
<p class="text-body-small text-secondary mt-sm text-center" id="stepInfo">1/3 단계</p>
</div>
<div class="container" style="max-width: 500px; padding-top: var(--spacing-lg);">
<!-- Step 1: 계정 정보 -->
<div id="step1" class="signup-step">
<h2 class="text-title mb-sm">계정 정보를 입력해주세요</h2>
<p class="text-body text-secondary mb-lg">로그인에 사용할 이메일과 비밀번호를 설정합니다</p>
<form id="step1Form">
<div class="form-group">
<label for="email" class="form-label form-label-required">이메일</label>
<input
type="email"
id="email"
class="form-input"
placeholder="example@email.com"
required
autocomplete="email"
>
<span id="emailError" class="form-error hidden"></span>
<span class="form-hint">이메일 주소로 로그인합니다</span>
</div>
<div class="form-group">
<label for="password" class="form-label form-label-required">비밀번호</label>
<input
type="password"
id="password"
class="form-input"
placeholder="8자 이상, 영문+숫자 조합"
required
autocomplete="new-password"
>
<span id="passwordError" class="form-error hidden"></span>
</div>
<div class="form-group">
<label for="passwordConfirm" class="form-label form-label-required">비밀번호 확인</label>
<input
type="password"
id="passwordConfirm"
class="form-input"
placeholder="비밀번호를 다시 입력하세요"
required
autocomplete="new-password"
>
<span id="passwordConfirmError" class="form-error hidden"></span>
</div>
<button type="submit" class="btn btn-primary btn-large btn-full mt-xl">
다음
</button>
</form>
</div>
<!-- Step 2: 개인 정보 -->
<div id="step2" class="signup-step hidden">
<h2 class="text-title mb-sm">개인 정보를 입력해주세요</h2>
<p class="text-body text-secondary mb-lg">서비스 이용을 위한 기본 정보입니다</p>
<form id="step2Form">
<div class="form-group">
<label for="name" class="form-label form-label-required">이름</label>
<input
type="text"
id="name"
class="form-input"
placeholder="홍길동"
required
autocomplete="name"
>
</div>
<div class="form-group">
<label for="phone" class="form-label form-label-required">휴대폰 번호</label>
<input
type="tel"
id="phone"
class="form-input"
placeholder="010-1234-5678"
required
autocomplete="tel"
>
<span id="phoneError" class="form-error hidden"></span>
</div>
<div class="flex gap-sm mt-xl">
<button type="button" class="btn btn-secondary btn-large flex-1" id="prevBtn1">
이전
</button>
<button type="submit" class="btn btn-primary btn-large flex-1">
다음
</button>
</div>
</form>
</div>
<!-- Step 3: 사업장 정보 -->
<div id="step3" class="signup-step hidden">
<h2 class="text-title mb-sm">사업장 정보를 입력해주세요</h2>
<p class="text-body text-secondary mb-lg">맞춤형 이벤트 추천을 위한 정보입니다</p>
<form id="step3Form">
<div class="form-group">
<label for="businessName" class="form-label form-label-required">상호명</label>
<input
type="text"
id="businessName"
class="form-input"
placeholder="홍길동 고깃집"
required
autocomplete="organization"
>
</div>
<div class="form-group">
<label for="businessType" class="form-label form-label-required">업종</label>
<select id="businessType" class="form-select" required>
<option value="">업종을 선택하세요</option>
<option value="restaurant">음식점</option>
<option value="cafe">카페/베이커리</option>
<option value="retail">소매/편의점</option>
<option value="beauty">미용/뷰티</option>
<option value="fitness">헬스/피트니스</option>
<option value="education">학원/교육</option>
<option value="service">서비스업</option>
<option value="other">기타</option>
</select>
</div>
<div class="form-group">
<label for="businessLocation" class="form-label">주요 지역</label>
<input
type="text"
id="businessLocation"
class="form-input"
placeholder="예: 강남구"
autocomplete="address-level2"
>
<span class="form-hint">선택 사항입니다</span>
</div>
<!-- 약관 동의 -->
<div class="mt-xl mb-lg">
<div class="form-check mb-md">
<input type="checkbox" id="agreeAll" class="form-check-input">
<label for="agreeAll" class="form-check-label text-bold">전체 동의</label>
</div>
<div class="border-t pt-md">
<div class="form-check mb-sm">
<input type="checkbox" id="agreeTerms" class="form-check-input agree-item" required>
<label for="agreeTerms" class="form-check-label">
<span>[필수]</span> 이용약관 동의
</label>
</div>
<div class="form-check mb-sm">
<input type="checkbox" id="agreePrivacy" class="form-check-input agree-item" required>
<label for="agreePrivacy" class="form-check-label">
<span>[필수]</span> 개인정보 처리방침 동의
</label>
</div>
<div class="form-check">
<input type="checkbox" id="agreeMarketing" class="form-check-input agree-item">
<label for="agreeMarketing" class="form-check-label">
<span>[선택]</span> 마케팅 정보 수신 동의
</label>
</div>
</div>
</div>
<div class="flex gap-sm">
<button type="button" class="btn btn-secondary btn-large flex-1" id="prevBtn2">
이전
</button>
<button type="submit" class="btn btn-primary btn-large flex-1">
가입완료
</button>
</div>
</form>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
let currentStep = 1;
const signupData = {};
// Progress Bar 초기화
const progressBar = KTEventApp.Feedback.createProgressBar(33);
document.getElementById('progressBar').appendChild(progressBar.element);
// Step 전환 함수
function showStep(step) {
document.querySelectorAll('.signup-step').forEach(el => el.classList.add('hidden'));
document.getElementById(`step${step}`).classList.remove('hidden');
currentStep = step;
progressBar.setValue(step * 33);
document.getElementById('stepInfo').textContent = `${step}/3 단계`;
}
// 뒤로가기
document.getElementById('backBtn').addEventListener('click', () => {
if (currentStep === 1) {
window.location.href = '01-로그인.html';
} else {
showStep(currentStep - 1);
}
});
// Step 1: 계정 정보
document.getElementById('step1Form').addEventListener('submit', function(e) {
e.preventDefault();
const email = document.getElementById('email').value;
const password = document.getElementById('password').value;
const passwordConfirm = document.getElementById('passwordConfirm').value;
// 에러 초기화
document.getElementById('emailError').classList.add('hidden');
document.getElementById('passwordError').classList.add('hidden');
document.getElementById('passwordConfirmError').classList.add('hidden');
let hasError = false;
// 이메일 검증
if (!KTEventApp.Utils.validateEmail(email)) {
document.getElementById('emailError').textContent = '올바른 이메일 형식이 아닙니다.';
document.getElementById('emailError').classList.remove('hidden');
hasError = true;
}
// 비밀번호 검증
if (!KTEventApp.Utils.validatePassword(password)) {
document.getElementById('passwordError').textContent = '8자 이상, 영문과 숫자를 포함해야 합니다.';
document.getElementById('passwordError').classList.remove('hidden');
hasError = true;
}
// 비밀번호 확인
if (password !== passwordConfirm) {
document.getElementById('passwordConfirmError').textContent = '비밀번호가 일치하지 않습니다.';
document.getElementById('passwordConfirmError').classList.remove('hidden');
hasError = true;
}
if (!hasError) {
signupData.email = email;
signupData.password = password;
showStep(2);
}
});
// Step 2: 개인 정보
document.getElementById('prevBtn1').addEventListener('click', () => showStep(1));
document.getElementById('step2Form').addEventListener('submit', function(e) {
e.preventDefault();
const name = document.getElementById('name').value;
const phone = document.getElementById('phone').value;
// 전화번호 형식 검증
const phonePattern = /^010-\d{3,4}-\d{4}$/;
if (!phonePattern.test(phone)) {
document.getElementById('phoneError').textContent = '올바른 전화번호 형식이 아닙니다 (010-1234-5678).';
document.getElementById('phoneError').classList.remove('hidden');
return;
}
signupData.name = name;
signupData.phone = phone;
showStep(3);
});
// 전화번호 자동 포맷팅
document.getElementById('phone').addEventListener('input', function(e) {
e.target.value = KTEventApp.Utils.formatPhoneNumber(e.target.value);
document.getElementById('phoneError').classList.add('hidden');
});
// Step 3: 사업장 정보
document.getElementById('prevBtn2').addEventListener('click', () => showStep(2));
// 전체 동의 체크박스
document.getElementById('agreeAll').addEventListener('change', function(e) {
const checked = e.target.checked;
document.querySelectorAll('.agree-item').forEach(cb => {
cb.checked = checked;
});
});
// 개별 체크박스
document.querySelectorAll('.agree-item').forEach(cb => {
cb.addEventListener('change', function() {
const allChecked = Array.from(document.querySelectorAll('.agree-item')).every(c => c.checked);
document.getElementById('agreeAll').checked = allChecked;
});
});
document.getElementById('step3Form').addEventListener('submit', function(e) {
e.preventDefault();
const businessName = document.getElementById('businessName').value;
const businessType = document.getElementById('businessType').value;
const businessLocation = document.getElementById('businessLocation').value;
const agreeTerms = document.getElementById('agreeTerms').checked;
const agreePrivacy = document.getElementById('agreePrivacy').checked;
const agreeMarketing = document.getElementById('agreeMarketing').checked;
if (!agreeTerms || !agreePrivacy) {
KTEventApp.Feedback.showToast('필수 약관에 동의해주세요.');
return;
}
signupData.businessName = businessName;
signupData.businessType = businessType;
signupData.businessLocation = businessLocation;
signupData.agreeMarketing = agreeMarketing;
// 로딩 표시
const submitBtn = e.target.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = '가입 처리 중...';
// 회원가입 시뮬레이션
setTimeout(() => {
// 사용자 정보 저장
const user = {
id: KTEventApp.Utils.generateId(),
name: signupData.name,
email: signupData.email,
phone: signupData.phone,
businessName: signupData.businessName,
businessType: signupData.businessType,
businessLocation: signupData.businessLocation,
joinDate: new Date().toISOString()
};
KTEventApp.Session.saveUser(user);
// 성공 모달 표시
KTEventApp.Feedback.showModal({
title: '회원가입 완료',
content: `
<div class="text-center p-lg">
<span class="material-icons" style="font-size: 64px; color: var(--color-success);">check_circle</span>
<p class="text-headline mt-md mb-sm">환영합니다!</p>
<p class="text-body text-secondary">회원가입이 완료되었습니다.<br>지금 바로 AI 이벤트를 시작해보세요.</p>
</div>
`,
buttons: [
{
text: '시작하기',
variant: 'primary',
size: 'large',
fullWidth: true,
onClick: function() {
window.location.href = '05-대시보드.html';
}
}
]
});
}, 1500);
});
// 이미 로그인된 경우 대시보드로 이동
if (KTEventApp.Session.isLoggedIn()) {
window.location.href = '05-대시보드.html';
}
</script>
</body>
</html>

View File

@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로필 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 600px;">
<!-- User Info Section -->
<section class="mt-lg mb-xl text-center">
<div class="mb-md" style="width: 80px; height: 80px; background: var(--color-gray-100); border-radius: var(--radius-full); display: flex; align-items: center; justify-content: center;">
<span class="material-icons" style="font-size: 48px; color: var(--color-gray-400);">person</span>
</div>
<h2 class="text-title" id="userName">홍길동</h2>
<p class="text-body-small text-secondary" id="userEmail">hong@example.com</p>
</section>
<!-- Basic Info -->
<section class="mb-lg">
<h3 class="text-headline mb-md">기본 정보</h3>
<div class="form-group">
<label for="name" class="form-label">이름</label>
<input type="text" id="name" class="form-input" value="홍길동">
</div>
<div class="form-group">
<label for="phone" class="form-label">전화번호</label>
<input type="tel" id="phone" class="form-input" value="010-1234-5678">
</div>
<div class="form-group">
<label for="email" class="form-label">이메일</label>
<input type="email" id="email" class="form-input" value="hong@example.com">
</div>
</section>
<!-- Business Info -->
<section class="mb-lg">
<h3 class="text-headline mb-md">매장 정보</h3>
<div class="form-group">
<label for="businessName" class="form-label">매장명</label>
<input type="text" id="businessName" class="form-input" value="홍길동 고깃집">
</div>
<div class="form-group">
<label for="businessType" class="form-label">업종</label>
<select id="businessType" class="form-select">
<option value="restaurant" selected>음식점</option>
<option value="cafe">카페/베이커리</option>
<option value="retail">소매/편의점</option>
<option value="beauty">미용/뷰티</option>
<option value="fitness">헬스/피트니스</option>
<option value="education">학원/교육</option>
<option value="service">서비스업</option>
<option value="other">기타</option>
</select>
</div>
<div class="form-group">
<label for="businessLocation" class="form-label">주소</label>
<input type="text" id="businessLocation" class="form-input" value="서울시 강남구">
</div>
<div class="form-group">
<label for="businessHours" class="form-label">영업시간</label>
<input type="text" id="businessHours" class="form-input" value="10:00 ~ 22:00">
</div>
</section>
<!-- Password Change -->
<section class="mb-xl">
<h3 class="text-headline mb-md">비밀번호 변경</h3>
<div class="form-group">
<label for="currentPassword" class="form-label">현재 비밀번호</label>
<input type="password" id="currentPassword" class="form-input" placeholder="현재 비밀번호를 입력하세요">
</div>
<div class="form-group">
<label for="newPassword" class="form-label">새 비밀번호</label>
<input type="password" id="newPassword" class="form-input" placeholder="새 비밀번호를 입력하세요">
<span class="form-hint">8자 이상, 영문과 숫자를 포함해주세요</span>
</div>
<div class="form-group">
<label for="confirmPassword" class="form-label">비밀번호 확인</label>
<input type="password" id="confirmPassword" class="form-input" placeholder="비밀번호를 다시 입력하세요">
</div>
</section>
<!-- Action Buttons -->
<section class="mb-2xl">
<button id="saveBtn" class="btn btn-primary btn-large btn-full mb-sm">
저장하기
</button>
<button id="logoutBtn" class="btn btn-text btn-large btn-full text-error">
로그아웃
</button>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
const user = KTEventApp.Session.getUser();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '프로필',
showBack: true,
showMenu: false,
showProfile: false
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('profile');
document.getElementById('bottomNav').appendChild(bottomNav);
// 사용자 정보 표시
document.getElementById('userName').textContent = user.name;
document.getElementById('userEmail').textContent = user.email;
document.getElementById('name').value = user.name;
document.getElementById('phone').value = user.phone;
document.getElementById('email').value = user.email;
document.getElementById('businessName').value = user.businessName;
document.getElementById('businessLocation').value = user.businessLocation || '서울시 강남구';
// 전화번호 자동 포맷팅
document.getElementById('phone').addEventListener('input', function(e) {
e.target.value = KTEventApp.Utils.formatPhoneNumber(e.target.value);
});
// 저장하기 버튼
document.getElementById('saveBtn').addEventListener('click', function() {
const currentPassword = document.getElementById('currentPassword').value;
const newPassword = document.getElementById('newPassword').value;
const confirmPassword = document.getElementById('confirmPassword').value;
// 비밀번호 변경 검증
if (currentPassword || newPassword || confirmPassword) {
if (!currentPassword) {
KTEventApp.Feedback.showToast('현재 비밀번호를 입력해주세요');
return;
}
if (!KTEventApp.Utils.validatePassword(newPassword)) {
KTEventApp.Feedback.showToast('새 비밀번호는 8자 이상, 영문과 숫자를 포함해야 합니다');
return;
}
if (newPassword !== confirmPassword) {
KTEventApp.Feedback.showToast('새 비밀번호가 일치하지 않습니다');
return;
}
}
// 사용자 정보 업데이트
const updatedUser = {
...user,
name: document.getElementById('name').value,
phone: document.getElementById('phone').value,
email: document.getElementById('email').value,
businessName: document.getElementById('businessName').value,
businessType: document.getElementById('businessType').value,
businessLocation: document.getElementById('businessLocation').value
};
KTEventApp.Session.saveUser(updatedUser);
KTEventApp.Feedback.showModal({
content: `
<div class="p-xl text-center">
<span class="material-icons" style="font-size: 64px; color: var(--color-success);">check_circle</span>
<h3 class="text-headline mt-md mb-sm">저장 완료</h3>
<p class="text-body text-secondary">프로필 정보가 업데이트되었습니다.</p>
</div>
`,
buttons: [
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
window.location.reload();
}
}
]
});
});
// 로그아웃 버튼
document.getElementById('logoutBtn').addEventListener('click', function() {
window.location.href = '04-로그아웃확인.html';
});
</script>
</body>
</html>

View File

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그아웃 확인 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<!-- Modal Backdrop -->
<div class="modal-backdrop" id="logoutModal" style="display: flex;">
<div class="modal-dialog" style="max-width: 400px;">
<div class="modal-header">
<h3 class="text-headline">로그아웃</h3>
</div>
<div class="modal-body">
<p class="text-body text-center">로그아웃 하시겠습니까?</p>
</div>
<div class="modal-footer">
<button class="btn btn-text" id="cancelBtn">취소</button>
<button class="btn btn-primary" id="confirmBtn">확인</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 취소 버튼 - 이전 페이지로 이동
document.getElementById('cancelBtn').addEventListener('click', function() {
window.history.back();
});
// 배경 클릭 시 취소와 동일
document.getElementById('logoutModal').addEventListener('click', function(e) {
if (e.target === this) {
window.history.back();
}
});
// 확인 버튼 - 로그아웃 처리
document.getElementById('confirmBtn').addEventListener('click', function() {
// 세션 종료
KTEventApp.Session.logout();
// 로그인 화면으로 이동 (애니메이션 효과)
setTimeout(() => {
window.location.href = '01-로그인.html';
}, 200);
});
</script>
</body>
</html>

View File

@ -0,0 +1,219 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>대시보드 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container">
<!-- Welcome Section -->
<section class="mt-lg mb-xl">
<h2 class="text-title-large mb-sm" id="welcomeMessage">안녕하세요, 사용자님!</h2>
<p class="text-body text-secondary">오늘도 성공적인 이벤트를 준비해보세요</p>
</section>
<!-- KPI Cards -->
<section class="mb-xl">
<div class="grid grid-cols-3 gap-sm tablet:grid-cols-3">
<div id="kpiEvents"></div>
<div id="kpiParticipants"></div>
<div id="kpiROI"></div>
</div>
</section>
<!-- Quick Actions -->
<section class="mb-lg">
<div class="flex items-center justify-between mb-md">
<h3 class="text-headline">빠른 시작</h3>
</div>
<div class="grid grid-cols-2 gap-sm">
<button class="card card-clickable p-md" id="createEvent">
<span class="material-icons text-kt-red" style="font-size: 32px;">add_circle</span>
<p class="text-body-small mt-sm">새 이벤트</p>
</button>
<button class="card card-clickable p-md" id="viewAnalytics">
<span class="material-icons text-secondary" style="font-size: 32px;">analytics</span>
<p class="text-body-small mt-sm">분석</p>
</button>
</div>
</section>
<!-- Active Events -->
<section class="mb-xl">
<div class="flex items-center justify-between mb-md">
<h3 class="text-headline">진행 중인 이벤트</h3>
<a href="06-이벤트목록.html" class="text-body-small text-kt-red">
전체보기
<span class="material-icons" style="font-size: 16px; vertical-align: middle;">chevron_right</span>
</a>
</div>
<div id="activeEvents" class="flex flex-col gap-md">
<!-- 이벤트 카드가 동적으로 추가됩니다 -->
</div>
</section>
<!-- Recent Activity -->
<section class="mb-2xl">
<h3 class="text-headline mb-md">최근 활동</h3>
<div class="card">
<div id="recentActivity">
<!-- 최근 활동 목록 -->
</div>
</div>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
<!-- FAB -->
<div id="fab"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
const user = KTEventApp.Session.getUser();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '대시보드',
showBack: false,
showMenu: false,
showProfile: true
});
document.getElementById('header').appendChild(header);
// Welcome 메시지
document.getElementById('welcomeMessage').textContent = `안녕하세요, ${user.name}님!`;
// KPI 데이터 계산
const events = KTEventApp.MockData.getEvents();
const activeEvents = events.filter(e => e.status === '진행중');
const totalParticipants = events.reduce((sum, e) => sum + e.participants, 0);
const avgROI = events.length > 0
? Math.round(events.reduce((sum, e) => sum + e.roi, 0) / events.length)
: 0;
// KPI 카드 생성
const kpiEventsCard = KTEventApp.Cards.createKPICard({
icon: 'celebration',
iconType: 'primary',
label: '진행 중',
value: `${activeEvents.length}개`
});
document.getElementById('kpiEvents').appendChild(kpiEventsCard);
const kpiParticipantsCard = KTEventApp.Cards.createKPICard({
icon: 'group',
iconType: 'success',
label: '총 참여자',
value: `${KTEventApp.Utils.formatNumber(totalParticipants)}명`
});
document.getElementById('kpiParticipants').appendChild(kpiParticipantsCard);
const kpiROICard = KTEventApp.Cards.createKPICard({
icon: 'trending_up',
iconType: 'ai',
label: '평균 ROI',
value: `${avgROI}%`
});
document.getElementById('kpiROI').appendChild(kpiROICard);
// 진행 중인 이벤트 표시
const activeEventsContainer = document.getElementById('activeEvents');
if (activeEvents.length === 0) {
activeEventsContainer.innerHTML = `
<div class="card text-center p-xl">
<span class="material-icons text-tertiary" style="font-size: 48px;">event_busy</span>
<p class="text-body text-secondary mt-md">진행 중인 이벤트가 없습니다</p>
<button class="btn btn-primary btn-medium mt-md" onclick="createNewEvent()">
<span class="material-icons">add</span>
새 이벤트 만들기
</button>
</div>
`;
} else {
activeEvents.forEach(event => {
const card = KTEventApp.Cards.createEventCard({
...event,
onClick: (id) => {
window.location.href = `13-이벤트상세.html?id=${id}`;
}
});
activeEventsContainer.appendChild(card);
});
}
// 최근 활동 표시
const recentActivityContainer = document.getElementById('recentActivity');
const activities = [
{ icon: 'person_add', text: 'SNS 팔로우 이벤트에 새로운 참여자 12명', time: '5분 전' },
{ icon: 'edit', text: '설 맞이 할인 이벤트 내용을 수정했습니다', time: '1시간 전' },
{ icon: 'check_circle', text: '고객 만족도 조사가 종료되었습니다', time: '3시간 전' }
];
activities.forEach((activity, index) => {
const item = document.createElement('div');
item.className = 'flex items-start gap-md';
if (index > 0) item.classList.add('mt-md', 'pt-md', 'border-t');
item.innerHTML = `
<span class="material-icons text-kt-red">${activity.icon}</span>
<div class="flex-1">
<p class="text-body">${activity.text}</p>
<p class="text-caption text-tertiary mt-xs">${activity.time}</p>
</div>
`;
recentActivityContainer.appendChild(item);
});
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('home');
document.getElementById('bottomNav').appendChild(bottomNav);
// FAB 생성
const fab = KTEventApp.Navigation.createFAB('add', createNewEvent);
document.getElementById('fab').appendChild(fab);
// 빠른 시작 버튼 이벤트
document.getElementById('createEvent').addEventListener('click', createNewEvent);
document.getElementById('viewAnalytics').addEventListener('click', () => {
window.location.href = '17-성과분석.html';
});
// 새 이벤트 생성 함수
function createNewEvent() {
KTEventApp.Feedback.showBottomSheet(`
<h3 class="text-headline mb-lg">이벤트 만들기</h3>
<div class="flex flex-col gap-sm">
<button class="btn btn-primary btn-large btn-full" onclick="window.location.href='07-이벤트목적선택.html'">
<span class="material-icons">auto_awesome</span>
AI 추천으로 시작하기
</button>
<button class="btn btn-secondary btn-large btn-full" onclick="KTEventApp.Feedback.showToast('템플릿 기능은 준비 중입니다.')">
<span class="material-icons">dashboard</span>
템플릿으로 시작하기
</button>
<button class="btn btn-text btn-large btn-full" onclick="KTEventApp.Feedback.showToast('직접 만들기 기능은 준비 중입니다.')">
<span class="material-icons">edit</span>
직접 만들기
</button>
</div>
`);
}
</script>
</body>
</html>

View File

@ -0,0 +1,323 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이벤트 목록 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 1200px;">
<!-- Search Section -->
<section class="mt-lg mb-md">
<div class="form-group">
<div class="input-with-icon">
<span class="material-icons input-icon">search</span>
<input type="text" id="searchInput" class="form-input" placeholder="이벤트명 검색...">
</div>
</div>
</section>
<!-- Filters -->
<section class="mb-md">
<div class="flex items-center gap-sm flex-wrap">
<span class="material-icons text-kt-red">filter_list</span>
<select id="statusFilter" class="form-select" style="flex: 1; min-width: 120px;">
<option value="all">전체</option>
<option value="active">진행중</option>
<option value="scheduled">예정</option>
<option value="ended">종료</option>
</select>
<select id="periodFilter" class="form-select" style="flex: 1; min-width: 140px;">
<option value="1month">최근 1개월</option>
<option value="3months">최근 3개월</option>
<option value="6months">최근 6개월</option>
<option value="1year">최근 1년</option>
<option value="all">전체</option>
</select>
</div>
</section>
<!-- Sorting -->
<section class="mb-md">
<div class="flex items-center justify-between">
<p class="text-body-small text-secondary">정렬:</p>
<select id="sortBy" class="form-select" style="width: 160px;">
<option value="latest">최신순</option>
<option value="participants">참여자순</option>
<option value="roi">투자대비수익률순</option>
</select>
</div>
</section>
<!-- Event List -->
<section id="eventList" class="mb-2xl">
<!-- Events will be dynamically loaded here -->
</section>
<!-- Pagination -->
<section class="mb-2xl">
<div class="flex items-center justify-center gap-sm" id="pagination">
<!-- Pagination will be dynamically loaded here -->
</div>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '이벤트 목록',
showBack: true,
showMenu: false,
showProfile: true
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
document.getElementById('bottomNav').appendChild(bottomNav);
// Mock 이벤트 데이터
const events = [
{
id: 1,
title: '신규고객 유치 이벤트',
status: 'active',
daysLeft: 5,
participants: 128,
roi: 450,
startDate: '2025-11-01',
endDate: '2025-11-15',
prize: '커피 쿠폰',
method: '전화번호 입력'
},
{
id: 2,
title: '재방문 유도 이벤트',
status: 'active',
daysLeft: 12,
participants: 56,
roi: 320,
startDate: '2025-11-05',
endDate: '2025-11-20',
prize: '할인 쿠폰',
method: 'SNS 팔로우'
},
{
id: 3,
title: '매출증대 프로모션',
status: 'ended',
daysLeft: 0,
participants: 234,
roi: 580,
startDate: '2025-10-15',
endDate: '2025-10-31',
prize: '상품권',
method: '구매 인증'
},
{
id: 4,
title: '봄맞이 특별 이벤트',
status: 'scheduled',
daysLeft: 30,
participants: 0,
roi: 0,
startDate: '2025-12-01',
endDate: '2025-12-15',
prize: '체험권',
method: '이메일 등록'
}
];
let currentPage = 1;
const itemsPerPage = 20;
let filteredEvents = [...events];
// 이벤트 카드 생성
function createEventCard(event) {
const card = document.createElement('div');
card.className = 'event-card';
card.style.cursor = 'pointer';
const statusBadge = event.status === 'active' ? 'badge-active' :
event.status === 'scheduled' ? 'badge-scheduled' : 'badge-inactive';
const statusText = event.status === 'active' ? '진행중' :
event.status === 'scheduled' ? '예정' : '종료';
const daysInfo = event.status === 'active' ? `D-${event.daysLeft}` :
event.status === 'scheduled' ? `D+${event.daysLeft}` : '';
card.innerHTML = `
<div class="flex items-start justify-between mb-sm">
<h3 class="text-headline">${event.title}</h3>
<span class="event-card-badge ${statusBadge}">${statusText} ${daysInfo ? '| ' + daysInfo : ''}</span>
</div>
<div class="event-card-stats mb-sm">
<div class="event-card-stat">
<span class="event-card-stat-label">참여</span>
<span class="event-card-stat-value">${event.participants}명</span>
</div>
<div class="event-card-stat">
<span class="event-card-stat-label">투자대비수익률</span>
<span class="event-card-stat-value text-kt-red">${event.roi}%</span>
</div>
</div>
<p class="text-body-small text-secondary">
${event.startDate} ~ ${event.endDate}
</p>
`;
card.addEventListener('click', () => {
window.location.href = `13-이벤트상세.html?id=${event.id}`;
});
return card;
}
// 이벤트 목록 렌더링
function renderEventList() {
const eventList = document.getElementById('eventList');
eventList.innerHTML = '';
if (filteredEvents.length === 0) {
eventList.innerHTML = `
<div class="text-center py-2xl">
<span class="material-icons" style="font-size: 64px; color: var(--color-gray-300);">event_busy</span>
<p class="text-body text-secondary mt-md">검색 결과가 없습니다</p>
</div>
`;
return;
}
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, filteredEvents.length);
const pageEvents = filteredEvents.slice(startIndex, endIndex);
pageEvents.forEach(event => {
eventList.appendChild(createEventCard(event));
});
renderPagination();
}
// 페이지네이션 렌더링
function renderPagination() {
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
const totalPages = Math.ceil(filteredEvents.length / itemsPerPage);
if (totalPages <= 1) return;
// 이전 버튼
const prevBtn = document.createElement('button');
prevBtn.className = 'btn btn-text btn-small';
prevBtn.innerHTML = '<span class="material-icons">chevron_left</span>';
prevBtn.disabled = currentPage === 1;
prevBtn.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
renderEventList();
}
});
pagination.appendChild(prevBtn);
// 페이지 번호
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
const pageBtn = document.createElement('button');
pageBtn.className = `btn ${i === currentPage ? 'btn-primary' : 'btn-text'} btn-small`;
pageBtn.textContent = i;
pageBtn.addEventListener('click', () => {
currentPage = i;
renderEventList();
});
pagination.appendChild(pageBtn);
} else if (i === currentPage - 2 || i === currentPage + 2) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.className = 'text-secondary px-sm';
pagination.appendChild(ellipsis);
}
}
// 다음 버튼
const nextBtn = document.createElement('button');
nextBtn.className = 'btn btn-text btn-small';
nextBtn.innerHTML = '<span class="material-icons">chevron_right</span>';
nextBtn.disabled = currentPage === totalPages;
nextBtn.addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
renderEventList();
}
});
pagination.appendChild(nextBtn);
}
// 필터 적용
function applyFilters() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const statusFilter = document.getElementById('statusFilter').value;
const periodFilter = document.getElementById('periodFilter').value;
const sortBy = document.getElementById('sortBy').value;
// 필터링
filteredEvents = events.filter(event => {
const matchesSearch = event.title.toLowerCase().includes(searchTerm);
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
// 기간 필터 (간단한 예시)
let matchesPeriod = true;
if (periodFilter !== 'all') {
// 실제로는 날짜 비교 로직 필요
matchesPeriod = true;
}
return matchesSearch && matchesStatus && matchesPeriod;
});
// 정렬
filteredEvents.sort((a, b) => {
if (sortBy === 'latest') {
return new Date(b.startDate) - new Date(a.startDate);
} else if (sortBy === 'participants') {
return b.participants - a.participants;
} else if (sortBy === 'roi') {
return b.roi - a.roi;
}
return 0;
});
currentPage = 1;
renderEventList();
}
// 이벤트 리스너
document.getElementById('searchInput').addEventListener('input', KTEventApp.Utils.debounce(applyFilters, 300));
document.getElementById('statusFilter').addEventListener('change', applyFilters);
document.getElementById('periodFilter').addEventListener('change', applyFilters);
document.getElementById('sortBy').addEventListener('change', applyFilters);
// 초기 렌더링
renderEventList();
</script>
</body>
</html>

View File

@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이벤트 목적 선택 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 600px;">
<!-- Title Section -->
<section class="mt-lg mb-xl text-center">
<div class="mb-md">
<span class="material-icons" style="font-size: 64px; color: var(--color-ai-blue);">auto_awesome</span>
</div>
<h2 class="text-title-large mb-sm">이벤트 목적을 선택해주세요</h2>
<p class="text-body text-secondary">AI가 목적에 맞는 최적의 이벤트를 추천해드립니다</p>
</section>
<!-- Purpose Options -->
<section class="mb-xl">
<div id="purposeOptions" class="grid gap-md tablet:grid-cols-2">
<!-- 옵션 카드가 동적으로 추가됩니다 -->
</div>
</section>
<!-- Action Buttons -->
<section class="mb-2xl">
<button id="nextBtn" class="btn btn-primary btn-large btn-full" disabled>
다음
</button>
<button id="skipBtn" class="btn btn-text btn-large btn-full mt-sm">
건너뛰기
</button>
</section>
<!-- Info Box -->
<section class="mb-2xl">
<div class="card" style="background: rgba(0, 102, 255, 0.05); border: 1px solid rgba(0, 102, 255, 0.2);">
<div class="flex items-start gap-md">
<span class="material-icons text-ai-blue">info</span>
<div class="flex-1">
<p class="text-body-small text-secondary">
선택하신 목적에 따라 AI가 업종, 지역, 계절 트렌드를 분석하여 가장 효과적인 이벤트를 추천합니다.
</p>
</div>
</div>
</div>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
const user = KTEventApp.Session.getUser();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '이벤트 목적 선택',
showBack: true,
showMenu: false,
showProfile: false
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
document.getElementById('bottomNav').appendChild(bottomNav);
// 이벤트 목적 옵션 데이터
const purposes = KTEventApp.MockData.getEventPurposes();
// 선택된 목적 저장
let selectedPurpose = null;
// 옵션 카드 생성
const purposeOptionsContainer = document.getElementById('purposeOptions');
purposes.forEach(purpose => {
const card = document.createElement('div');
card.className = 'card option-card';
card.setAttribute('data-purpose-id', purpose.id);
card.innerHTML = `
<div class="option-card-radio">
<input type="radio" name="purpose" value="${purpose.id}">
</div>
<div class="flex items-start gap-md mb-md">
<span class="material-icons text-kt-red" style="font-size: 40px;">${purpose.icon}</span>
<div class="flex-1">
<h3 class="text-headline mb-xs">${purpose.title}</h3>
</div>
</div>
<p class="text-body-small text-secondary">${purpose.description}</p>
`;
card.addEventListener('click', () => {
// 모든 카드에서 selected 클래스 제거
document.querySelectorAll('.option-card').forEach(c => {
c.classList.remove('selected');
c.querySelector('input[type="radio"]').checked = false;
});
// 선택된 카드에 selected 클래스 추가
card.classList.add('selected');
card.querySelector('input[type="radio"]').checked = true;
// 선택된 목적 저장
selectedPurpose = purpose.id;
// 다음 버튼 활성화
document.getElementById('nextBtn').disabled = false;
});
purposeOptionsContainer.appendChild(card);
});
// 다음 버튼 클릭
document.getElementById('nextBtn').addEventListener('click', () => {
if (selectedPurpose) {
// 선택한 목적 저장
KTEventApp.Utils.saveToStorage('selected_purpose', selectedPurpose);
// AI 로딩 및 추천 페이지로 이동
showLoadingAndNavigate();
}
});
// 건너뛰기 버튼 클릭
document.getElementById('skipBtn').addEventListener('click', () => {
KTEventApp.Feedback.showModal({
title: '건너뛰기',
content: '<p class="text-body">목적을 선택하지 않으면 일반적인 추천을 받게 됩니다. 계속하시겠습니까?</p>',
buttons: [
{
text: '취소',
variant: 'text',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
},
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
KTEventApp.Utils.saveToStorage('selected_purpose', 'general');
showLoadingAndNavigate();
}
}
]
});
});
// AI 로딩 표시 및 페이지 이동
function showLoadingAndNavigate() {
const loadingModal = KTEventApp.Feedback.showModal({
content: `
<div class="p-xl text-center">
<div class="spinner mx-auto mb-lg"></div>
<h3 class="text-headline mb-sm">AI가 분석 중입니다</h3>
<p class="text-body text-secondary mb-md">
업종, 지역, 계절 트렌드를 분석하여<br>
최적의 이벤트를 찾고 있습니다
</p>
<div class="progress mt-lg">
<div id="loadingProgress" class="progress-bar" style="width: 0%"></div>
</div>
<p class="text-caption text-tertiary mt-sm" id="loadingStatus">트렌드 분석 중...</p>
</div>
`
});
// 프로그레스 바 애니메이션
const statusMessages = [
'트렌드 분석 중...',
'경쟁사 이벤트 분석 중...',
'고객 선호도 분석 중...',
'최적 이벤트 생성 중...'
];
let progress = 0;
let messageIndex = 0;
const interval = setInterval(() => {
progress += 25;
document.getElementById('loadingProgress').style.width = `${progress}%`;
if (messageIndex < statusMessages.length) {
document.getElementById('loadingStatus').textContent = statusMessages[messageIndex];
messageIndex++;
}
if (progress >= 100) {
clearInterval(interval);
setTimeout(() => {
window.location.href = '08-AI이벤트추천.html';
}, 500);
}
}, 800);
}
</script>
</body>
</html>

View File

@ -0,0 +1,473 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 이벤트 추천 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<style>
.editable-field {
border: 1px dashed var(--color-gray-300);
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: text;
transition: all var(--duration-fast) var(--ease-out);
}
.editable-field:hover {
border-color: var(--color-kt-red);
background-color: var(--color-gray-50);
}
.editable-field:focus {
outline: none;
border-style: solid;
border-color: var(--color-kt-red);
background-color: var(--color-bg-primary);
}
.budget-section {
scroll-margin-top: 72px;
}
</style>
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container">
<!-- Trends Section -->
<section class="mt-lg mb-xl">
<h2 class="text-headline mb-md flex items-center gap-sm">
<span class="material-icons text-ai-blue">insights</span>
AI 트렌드 분석
</h2>
<div class="card">
<div class="mb-md">
<div class="flex items-center gap-sm mb-xs">
<span class="material-icons text-kt-red" style="font-size: 20px;">store</span>
<span class="text-body-small text-bold">업종 트렌드</span>
</div>
<p id="industryTrend" class="text-body text-secondary pl-lg">음식점업 신년 프로모션 트렌드</p>
</div>
<div class="mb-md">
<div class="flex items-center gap-sm mb-xs">
<span class="material-icons text-kt-red" style="font-size: 20px;">location_on</span>
<span class="text-body-small text-bold">지역 트렌드</span>
</div>
<p id="locationTrend" class="text-body text-secondary pl-lg">강남구 음식점 할인 이벤트 증가</p>
</div>
<div>
<div class="flex items-center gap-sm mb-xs">
<span class="material-icons text-kt-red" style="font-size: 20px;">wb_sunny</span>
<span class="text-body-small text-bold">시즌 트렌드</span>
</div>
<p id="seasonTrend" class="text-body text-secondary pl-lg">설 연휴 특수 대비 고객 유치 전략</p>
</div>
</div>
</section>
<!-- Recommendations Title -->
<section class="mb-lg">
<h2 class="text-headline mb-sm">예산별 추천 이벤트</h2>
<p class="text-body-small text-secondary">
각 예산별 2가지 방식 (온라인 1개, 오프라인 1개)을 추천합니다
</p>
</section>
<!-- Budget Options Navigation -->
<section class="mb-lg" style="position: sticky; top: 56px; background: var(--color-bg-secondary); z-index: 10; padding: var(--spacing-md) 0;">
<div class="flex gap-sm">
<button class="btn btn-medium flex-1 budget-nav active" data-budget="low">
💰 저비용
</button>
<button class="btn btn-medium flex-1 budget-nav" data-budget="medium">
💰💰 중비용
</button>
<button class="btn btn-medium flex-1 budget-nav" data-budget="high">
💰💰💰 고비용
</button>
</div>
</section>
<!-- 저비용 옵션 -->
<section id="budget-low" class="budget-section mb-2xl">
<h3 class="text-title mb-md">💰 옵션 1: 저비용 (25~30만원)</h3>
<div class="grid gap-md tablet:grid-cols-2">
<div class="card option-card" data-recommendation-id="low-online">
<div class="option-card-radio">
<input type="radio" name="recommendation" value="low-online">
</div>
<div class="mb-md">
<span class="badge-active event-card-badge mb-sm">🌐 온라인 방식</span>
<h4 class="text-headline mb-xs">
<span class="editable-field" contenteditable="true">SNS 팔로우 이벤트</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</h4>
</div>
<div class="text-body-small mb-sm">
<span class="text-secondary">경품:</span>
<span class="editable-field" contenteditable="true">커피 쿠폰</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</div>
<div class="grid grid-cols-2 gap-sm text-body-small">
<div>
<span class="text-tertiary">참여 방법:</span>
<span class="text-semibold">SNS 팔로우</span>
</div>
<div>
<span class="text-tertiary">예상 참여:</span>
<span class="text-semibold">180명</span>
</div>
<div>
<span class="text-tertiary">예상 비용:</span>
<span class="text-semibold">25만원</span>
</div>
<div>
<span class="text-tertiary">투자대비수익률:</span>
<span class="text-kt-red text-bold">520%</span>
</div>
</div>
</div>
<div class="card option-card" data-recommendation-id="low-offline">
<div class="option-card-radio">
<input type="radio" name="recommendation" value="low-offline">
</div>
<div class="mb-md">
<span class="badge-scheduled event-card-badge mb-sm">🏪 오프라인 방식</span>
<h4 class="text-headline mb-xs">
<span class="editable-field" contenteditable="true">전화번호 등록 이벤트</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</h4>
</div>
<div class="text-body-small mb-sm">
<span class="text-secondary">경품:</span>
<span class="editable-field" contenteditable="true">커피 쿠폰</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</div>
<div class="grid grid-cols-2 gap-sm text-body-small">
<div>
<span class="text-tertiary">참여 방법:</span>
<span class="text-semibold">전화번호 등록</span>
</div>
<div>
<span class="text-tertiary">예상 참여:</span>
<span class="text-semibold">150명</span>
</div>
<div>
<span class="text-tertiary">예상 비용:</span>
<span class="text-semibold">30만원</span>
</div>
<div>
<span class="text-tertiary">투자대비수익률:</span>
<span class="text-kt-red text-bold">450%</span>
</div>
</div>
</div>
</div>
</section>
<!-- 중비용 옵션 -->
<section id="budget-medium" class="budget-section mb-2xl">
<h3 class="text-title mb-md">💰💰 옵션 2: 중비용 (150~180만원)</h3>
<div class="grid gap-md tablet:grid-cols-2">
<div class="card option-card" data-recommendation-id="medium-online">
<div class="option-card-radio">
<input type="radio" name="recommendation" value="medium-online">
</div>
<div class="mb-md">
<span class="badge-active event-card-badge mb-sm">🌐 온라인 방식</span>
<h4 class="text-headline mb-xs">
<span class="editable-field" contenteditable="true">리뷰 작성 이벤트</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</h4>
</div>
<div class="text-body-small mb-sm">
<span class="text-secondary">경품:</span>
<span class="editable-field" contenteditable="true">5천원 상품권</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</div>
<div class="grid grid-cols-2 gap-sm text-body-small">
<div>
<span class="text-tertiary">참여 방법:</span>
<span class="text-semibold">리뷰 작성</span>
</div>
<div>
<span class="text-tertiary">예상 참여:</span>
<span class="text-semibold">250명</span>
</div>
<div>
<span class="text-tertiary">예상 비용:</span>
<span class="text-semibold">150만원</span>
</div>
<div>
<span class="text-tertiary">투자대비수익률:</span>
<span class="text-kt-red text-bold">380%</span>
</div>
</div>
</div>
<div class="card option-card" data-recommendation-id="medium-offline">
<div class="option-card-radio">
<input type="radio" name="recommendation" value="medium-offline">
</div>
<div class="mb-md">
<span class="badge-scheduled event-card-badge mb-sm">🏪 오프라인 방식</span>
<h4 class="text-headline mb-xs">
<span class="editable-field" contenteditable="true">방문 도장 적립 이벤트</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</h4>
</div>
<div class="text-body-small mb-sm">
<span class="text-secondary">경품:</span>
<span class="editable-field" contenteditable="true">무료 식사권</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</div>
<div class="grid grid-cols-2 gap-sm text-body-small">
<div>
<span class="text-tertiary">참여 방법:</span>
<span class="text-semibold">방문 도장 적립</span>
</div>
<div>
<span class="text-tertiary">예상 참여:</span>
<span class="text-semibold">200명</span>
</div>
<div>
<span class="text-tertiary">예상 비용:</span>
<span class="text-semibold">180만원</span>
</div>
<div>
<span class="text-tertiary">투자대비수익률:</span>
<span class="text-kt-red text-bold">320%</span>
</div>
</div>
</div>
</div>
</section>
<!-- 고비용 옵션 -->
<section id="budget-high" class="budget-section mb-2xl">
<h3 class="text-title mb-md">💰💰💰 옵션 3: 고비용 (500~600만원)</h3>
<div class="grid gap-md tablet:grid-cols-2">
<div class="card option-card" data-recommendation-id="high-online">
<div class="option-card-radio">
<input type="radio" name="recommendation" value="high-online">
</div>
<div class="mb-md">
<span class="badge-active event-card-badge mb-sm">🌐 온라인 방식</span>
<h4 class="text-headline mb-xs">
<span class="editable-field" contenteditable="true">인플루언서 협업 이벤트</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</h4>
</div>
<div class="text-body-small mb-sm">
<span class="text-secondary">경품:</span>
<span class="editable-field" contenteditable="true">1만원 할인권</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</div>
<div class="grid grid-cols-2 gap-sm text-body-small">
<div>
<span class="text-tertiary">참여 방법:</span>
<span class="text-semibold">인플루언서 팔로우</span>
</div>
<div>
<span class="text-tertiary">예상 참여:</span>
<span class="text-semibold">400명</span>
</div>
<div>
<span class="text-tertiary">예상 비용:</span>
<span class="text-semibold">500만원</span>
</div>
<div>
<span class="text-tertiary">투자대비수익률:</span>
<span class="text-kt-red text-bold">280%</span>
</div>
</div>
</div>
<div class="card option-card" data-recommendation-id="high-offline">
<div class="option-card-radio">
<input type="radio" name="recommendation" value="high-offline">
</div>
<div class="mb-md">
<span class="badge-scheduled event-card-badge mb-sm">🏪 오프라인 방식</span>
<h4 class="text-headline mb-xs">
<span class="editable-field" contenteditable="true">VIP 고객 초대 이벤트</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</h4>
</div>
<div class="text-body-small mb-sm">
<span class="text-secondary">경품:</span>
<span class="editable-field" contenteditable="true">특별 메뉴 제공</span>
<span class="material-icons text-tertiary" style="font-size: 16px; vertical-align: middle;">edit</span>
</div>
<div class="grid grid-cols-2 gap-sm text-body-small">
<div>
<span class="text-tertiary">참여 방법:</span>
<span class="text-semibold">VIP 초대장</span>
</div>
<div>
<span class="text-tertiary">예상 참여:</span>
<span class="text-semibold">300명</span>
</div>
<div>
<span class="text-tertiary">예상 비용:</span>
<span class="text-semibold">600만원</span>
</div>
<div>
<span class="text-tertiary">투자대비수익률:</span>
<span class="text-kt-red text-bold">240%</span>
</div>
</div>
</div>
</div>
</section>
<!-- Action Buttons -->
<section class="mb-2xl">
<button id="nextBtn" class="btn btn-primary btn-large btn-full" disabled>
선택한 이벤트로 계속
</button>
<button id="regenerateBtn" class="btn btn-secondary btn-large btn-full mt-sm">
<span class="material-icons">refresh</span>
다시 추천받기
</button>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: 'AI 이벤트 추천',
showBack: true,
showMenu: false,
showProfile: false
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
document.getElementById('bottomNav').appendChild(bottomNav);
// 선택된 추천 저장
let selectedRecommendation = null;
// 옵션 카드 클릭 이벤트
document.querySelectorAll('.option-card').forEach(card => {
card.addEventListener('click', () => {
// 모든 카드에서 selected 클래스 제거
document.querySelectorAll('.option-card').forEach(c => {
c.classList.remove('selected');
c.querySelector('input[type="radio"]').checked = false;
});
// 선택된 카드에 selected 클래스 추가
card.classList.add('selected');
card.querySelector('input[type="radio"]').checked = true;
// 선택된 추천 저장
selectedRecommendation = card.getAttribute('data-recommendation-id');
// 다음 버튼 활성화
document.getElementById('nextBtn').disabled = false;
// Toast 표시
KTEventApp.Feedback.showToast('이벤트가 선택되었습니다');
});
});
// 예산 옵션 네비게이션
document.querySelectorAll('.budget-nav').forEach(btn => {
btn.addEventListener('click', function() {
const budget = this.getAttribute('data-budget');
// 버튼 활성화 상태 변경
document.querySelectorAll('.budget-nav').forEach(b => {
b.classList.remove('active');
b.classList.add('btn-secondary');
b.classList.remove('btn-primary');
});
this.classList.add('active');
this.classList.add('btn-primary');
this.classList.remove('btn-secondary');
// 해당 섹션으로 스크롤
document.getElementById(`budget-${budget}`).scrollIntoView({
behavior: 'smooth',
block: 'start'
});
});
});
// 다음 버튼 클릭
document.getElementById('nextBtn').addEventListener('click', () => {
if (selectedRecommendation) {
// 선택한 추천 데이터 저장
const selectedCard = document.querySelector(`[data-recommendation-id="${selectedRecommendation}"]`);
const title = selectedCard.querySelector('.editable-field').textContent.trim();
const prize = selectedCard.querySelectorAll('.editable-field')[1].textContent.trim();
const recommendationData = {
id: selectedRecommendation,
title: title,
prize: prize,
timestamp: new Date().toISOString()
};
KTEventApp.Utils.saveToStorage('selected_recommendation', recommendationData);
// SNS 이미지 생성 페이지로 이동
window.location.href = '09-콘텐츠미리보기.html';
}
});
// 다시 추천받기 버튼
document.getElementById('regenerateBtn').addEventListener('click', () => {
KTEventApp.Feedback.showModal({
title: '다시 추천받기',
content: '<p class="text-body">새로운 추천을 받으시겠습니까? 현재 선택한 내용은 초기화됩니다.</p>',
buttons: [
{
text: '취소',
variant: 'text',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
},
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
window.location.href = '07-이벤트목적선택.html';
}
}
]
});
});
// 편집 가능 필드 엔터 키 방지
document.querySelectorAll('.editable-field').forEach(field => {
field.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
field.blur();
}
});
field.addEventListener('blur', () => {
KTEventApp.Feedback.showToast('수정되었습니다', 1500);
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,447 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SNS 이미지 생성 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
/>
<style>
.image-preview {
width: 100%;
aspect-ratio: 1 / 1;
background: var(--color-gray-100);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
.image-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-preview-placeholder {
color: var(--color-gray-400);
text-align: center;
}
.style-card {
position: relative;
cursor: pointer;
transition: all 0.3s ease;
border: 3px solid transparent;
}
.style-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.style-card.selected {
border-color: var(--color-kt-red);
box-shadow: 0 4px 12px rgba(227, 30, 36, 0.2);
}
.style-card .form-check-input {
display: none;
}
.selected-badge {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
background: var(--color-kt-red);
color: white;
width: 32px;
height: 32px;
border-radius: var(--radius-full);
display: none;
align-items: center;
justify-content: center;
z-index: 10;
}
.style-card.selected .selected-badge {
display: flex;
}
.fullscreen-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.95);
display: none;
align-items: center;
justify-content: center;
z-index: 9999;
padding: var(--spacing-lg);
}
.fullscreen-modal.active {
display: flex;
}
.fullscreen-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.fullscreen-close {
position: absolute;
top: var(--spacing-lg);
right: var(--spacing-lg);
background: rgba(255, 255, 255, 0.9);
color: var(--color-text-primary);
border: none;
border-radius: var(--radius-full);
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 600px">
<!-- Loading State -->
<div id="loadingState" class="text-center mt-2xl mb-2xl">
<span
class="material-icons"
style="
font-size: 64px;
color: var(--color-ai-blue);
animation: spin 2s linear infinite;
"
>psychology</span
>
<h3 class="text-headline mt-md mb-sm">AI 이미지 생성 중</h3>
<p class="text-body text-secondary mb-lg">
딥러닝 모델이 이벤트에 어울리는<br />
이미지를 생성하고 있어요...
</p>
<p class="text-caption text-tertiary mt-md">예상 시간: 5초</p>
</div>
<!-- Generated Images (hidden initially) -->
<div id="generatedImages" class="hidden">
<!-- Style 1: 심플 -->
<section class="mb-lg">
<h3 class="text-headline mb-md">스타일 1: 심플</h3>
<div class="card style-card" data-style="simple">
<div class="selected-badge">
<span class="material-icons" style="font-size: 20px">check</span>
</div>
<input
type="radio"
name="imageStyle"
id="style1"
value="simple"
class="form-check-input"
/>
<div class="image-preview mb-md">
<div class="image-preview-placeholder">
<span class="material-icons" style="font-size: 48px"
>celebration</span
>
<p class="text-body-small mt-sm" id="style1Title">
SNS 팔로우 이벤트
</p>
<p class="text-caption text-secondary" id="style1Prize">
커피 쿠폰
</p>
</div>
</div>
<div class="flex items-center justify-end">
<button
class="btn btn-text btn-small preview-btn"
data-style="simple"
>
<span class="material-icons">zoom_in</span>
크게보기
</button>
</div>
</div>
</section>
<!-- Style 2: 화려 -->
<section class="mb-lg">
<h3 class="text-headline mb-md">스타일 2: 화려</h3>
<div class="card style-card" data-style="fancy">
<div class="selected-badge">
<span class="material-icons" style="font-size: 20px">check</span>
</div>
<input
type="radio"
name="imageStyle"
id="style2"
value="fancy"
class="form-check-input"
/>
<div
class="image-preview mb-md"
style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
"
>
<div class="image-preview-placeholder" style="color: white">
<span class="material-icons" style="font-size: 48px"
>auto_awesome</span
>
<p class="text-body-small mt-sm" id="style2Title">
SNS 팔로우 이벤트
</p>
<p class="text-caption" style="opacity: 0.9" id="style2Prize">
커피 쿠폰
</p>
</div>
</div>
<div class="flex items-center justify-end">
<button
class="btn btn-text btn-small preview-btn"
data-style="fancy"
>
<span class="material-icons">zoom_in</span>
크게보기
</button>
</div>
</div>
</section>
<!-- Style 3: 트렌디 -->
<section class="mb-lg">
<h3 class="text-headline mb-md">스타일 3: 트렌디</h3>
<div class="card style-card" data-style="trendy">
<div class="selected-badge">
<span class="material-icons" style="font-size: 20px">check</span>
</div>
<input
type="radio"
name="imageStyle"
id="style3"
value="trendy"
class="form-check-input"
/>
<div
class="image-preview mb-md"
style="
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
"
>
<div class="image-preview-placeholder" style="color: white">
<span class="material-icons" style="font-size: 48px"
>trending_up</span
>
<p class="text-body-small mt-sm" id="style3Title">
SNS 팔로우 이벤트
</p>
<p class="text-caption" style="opacity: 0.9" id="style3Prize">
커피 쿠폰
</p>
</div>
</div>
<div class="flex items-center justify-end">
<button
class="btn btn-text btn-small preview-btn"
data-style="trendy"
>
<span class="material-icons">zoom_in</span>
크게보기
</button>
</div>
</div>
</section>
<!-- Action Buttons -->
<section class="mb-2xl">
<div class="grid grid-cols-2 gap-sm">
<button id="skipBtn" class="btn btn-text btn-large">
건너뛰기
</button>
<button id="nextBtn" class="btn btn-primary btn-large" disabled>
다음
</button>
</div>
</section>
</div>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<!-- Fullscreen Image Modal -->
<div id="fullscreenModal" class="fullscreen-modal">
<button class="fullscreen-close" onclick="closeFullscreen()">
<span class="material-icons">close</span>
</button>
<img
id="fullscreenImage"
class="fullscreen-image"
alt="이벤트 이미지 미리보기"
/>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: "SNS 이미지 생성",
showBack: true,
showMenu: false,
showProfile: false,
});
document.getElementById("header").appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav("events");
document.getElementById("bottomNav").appendChild(bottomNav);
// 저장된 데이터 불러오기
const recommendation = KTEventApp.Utils.getFromStorage(
"selected_recommendation"
) || {
title: "SNS 팔로우 이벤트",
prize: "커피 쿠폰",
};
// 로딩 시뮬레이션
setTimeout(() => {
// 이미지 생성 완료
document.getElementById("loadingState").classList.add("hidden");
document.getElementById("generatedImages").classList.remove("hidden");
// 데이터 표시
document.getElementById("style1Title").textContent =
recommendation.title;
document.getElementById("style1Prize").textContent =
recommendation.prize;
document.getElementById("style2Title").textContent =
recommendation.title;
document.getElementById("style2Prize").textContent =
recommendation.prize;
document.getElementById("style3Title").textContent =
recommendation.title;
document.getElementById("style3Prize").textContent =
recommendation.prize;
}, 5000);
// 선택 상태 관리
let selectedStyle = null;
// 카드 클릭 이벤트
document.querySelectorAll('.style-card').forEach((card) => {
card.addEventListener('click', function(e) {
// 크게보기 버튼 클릭은 무시
if (e.target.closest('.preview-btn')) {
return;
}
const styleValue = this.dataset.style;
const radioInput = this.querySelector('input[name="imageStyle"]');
// 모든 카드의 선택 상태 제거
document.querySelectorAll('.style-card').forEach(c => {
c.classList.remove('selected');
});
// 현재 카드 선택
this.classList.add('selected');
radioInput.checked = true;
selectedStyle = styleValue;
document.getElementById("nextBtn").disabled = false;
});
});
// 크게보기 버튼
document.querySelectorAll(".preview-btn").forEach((btn) => {
btn.addEventListener("click", function (e) {
e.stopPropagation(); // 카드 클릭 이벤트 방지
const style = this.dataset.style;
openFullscreen(style);
});
});
function openFullscreen(style) {
const modal = document.getElementById("fullscreenModal");
const img = document.getElementById("fullscreenImage");
// 실제로는 생성된 이미지 URL을 사용
// 여기서는 placeholder 사용
img.src =
'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800"><rect width="800" height="800" fill="%23f0f0f0"/><text x="50%" y="50%" text-anchor="middle" fill="%23666" font-size="24">' +
recommendation.title +
"</text></svg>";
modal.classList.add("active");
}
window.closeFullscreen = function () {
document.getElementById("fullscreenModal").classList.remove("active");
};
// 배경 클릭 시 닫기
document
.getElementById("fullscreenModal")
.addEventListener("click", function (e) {
if (e.target === this) {
closeFullscreen();
}
});
// 건너뛰기 버튼
document.getElementById("skipBtn").addEventListener("click", function () {
KTEventApp.Feedback.showModal({
title: "건너뛰기",
content:
'<p class="text-body">이미지 없이 다음 단계로 진행하시겠습니까?</p>',
buttons: [
{
text: "취소",
variant: "text",
onClick: function () {
this.closest(".modal-backdrop").remove();
},
},
{
text: "확인",
variant: "primary",
onClick: function () {
this.closest(".modal-backdrop").remove();
window.location.href = "11-배포채널선택.html";
},
},
],
});
});
// 다음 버튼
document.getElementById("nextBtn").addEventListener("click", function () {
if (selectedStyle) {
KTEventApp.Utils.saveToStorage("selected_image_style", selectedStyle);
window.location.href = "10-콘텐츠편집.html";
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>콘텐츠 편집 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<style>
.preview-container {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-lg);
text-align: center;
transition: all 0.3s ease;
}
.logo-controls {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.color-picker-wrapper {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.color-swatch {
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
border: 2px solid var(--color-gray-300);
cursor: pointer;
}
input[type="color"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
</style>
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 800px;">
<!-- Desktop Layout: Side-by-side -->
<div class="grid desktop:grid-cols-2 gap-lg mt-lg mb-2xl">
<!-- Preview Section -->
<section>
<h3 class="text-headline mb-md">미리보기</h3>
<div class="card">
<div id="preview" class="preview-container">
<span class="material-icons" style="font-size: 48px; margin-bottom: 16px;">celebration</span>
<h3 id="previewTitle" class="text-title" style="margin-bottom: 8px;">신규고객 유치 이벤트</h3>
<p id="previewPrize" class="text-body" style="margin-bottom: 16px;">커피 쿠폰 100매</p>
<p id="previewGuide" class="text-body-small">전화번호를 입력하고 참여하세요</p>
</div>
</div>
</section>
<!-- Edit Section -->
<section>
<h3 class="text-headline mb-md">편집</h3>
<!-- Text Editing -->
<div class="card mb-md">
<h4 class="text-headline mb-md">
<span class="material-icons" style="vertical-align: middle;">edit</span>
텍스트 편집
</h4>
<div class="form-group">
<label for="titleInput" class="form-label">제목</label>
<input type="text" id="titleInput" class="form-input" value="신규고객 유치 이벤트" maxlength="50">
<span class="form-hint"><span id="titleCount">11</span>/50자</span>
</div>
<div class="form-group">
<label for="prizeInput" class="form-label">경품</label>
<input type="text" id="prizeInput" class="form-input" value="커피 쿠폰 100매" maxlength="30">
<span class="form-hint"><span id="prizeCount">9</span>/30자</span>
</div>
<div class="form-group">
<label for="guideInput" class="form-label">참여안내</label>
<textarea id="guideInput" class="form-textarea" rows="3" maxlength="100">전화번호를 입력하고 참여하세요</textarea>
<span class="form-hint"><span id="guideCount">17</span>/100자</span>
</div>
</div>
</section>
</div>
<!-- Action Buttons -->
<section class="mb-2xl">
<div class="grid grid-cols-2 gap-sm">
<button id="saveBtn" class="btn btn-secondary btn-large">저장</button>
<button id="nextBtn" class="btn btn-primary btn-large">다음 단계</button>
</div>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '콘텐츠 편집',
showBack: true,
showMenu: false,
showProfile: false
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
document.getElementById('bottomNav').appendChild(bottomNav);
// 저장된 데이터 불러오기
const recommendation = KTEventApp.Utils.getFromStorage('selected_recommendation') || {
title: '신규고객 유치 이벤트',
prize: '커피 쿠폰 100매'
};
// 초기 데이터 설정
document.getElementById('titleInput').value = recommendation.title || '신규고객 유치 이벤트';
document.getElementById('prizeInput').value = recommendation.prize || '커피 쿠폰 100매';
updateCharCount();
// 실시간 미리보기 업데이트
function updatePreview() {
const title = document.getElementById('titleInput').value;
const prize = document.getElementById('prizeInput').value;
const guide = document.getElementById('guideInput').value;
document.getElementById('previewTitle').textContent = title || '제목을 입력하세요';
document.getElementById('previewPrize').textContent = prize || '경품을 입력하세요';
document.getElementById('previewGuide').textContent = guide || '참여 안내를 입력하세요';
}
// 글자수 업데이트
function updateCharCount() {
document.getElementById('titleCount').textContent = document.getElementById('titleInput').value.length;
document.getElementById('prizeCount').textContent = document.getElementById('prizeInput').value.length;
document.getElementById('guideCount').textContent = document.getElementById('guideInput').value.length;
}
// 텍스트 입력 이벤트
document.getElementById('titleInput').addEventListener('input', function() {
updatePreview();
updateCharCount();
});
document.getElementById('prizeInput').addEventListener('input', function() {
updatePreview();
updateCharCount();
});
document.getElementById('guideInput').addEventListener('input', function() {
updatePreview();
updateCharCount();
});
// 저장 버튼
document.getElementById('saveBtn').addEventListener('click', function() {
const editData = {
title: document.getElementById('titleInput').value,
prize: document.getElementById('prizeInput').value,
guide: document.getElementById('guideInput').value
};
KTEventApp.Utils.saveToStorage('content_edit', editData);
KTEventApp.Feedback.showToast('편집 내용이 저장되었습니다');
});
// 다음 단계 버튼
document.getElementById('nextBtn').addEventListener('click', function() {
// 저장 먼저 실행
const editData = {
title: document.getElementById('titleInput').value,
prize: document.getElementById('prizeInput').value,
guide: document.getElementById('guideInput').value
};
KTEventApp.Utils.saveToStorage('content_edit', editData);
// 배포 채널 선택으로 이동
window.location.href = '11-배포채널선택.html';
});
// 초기 미리보기 업데이트
updatePreview();
</script>
</body>
</html>

View File

@ -0,0 +1,421 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>배포 채널 선택 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<style>
.channel-card {
opacity: 0.6;
transition: opacity 0.3s ease;
}
.channel-card.selected {
opacity: 1;
}
.channel-options {
display: none;
margin-top: var(--spacing-md);
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-gray-200);
}
.channel-card.selected .channel-options {
display: block;
}
.channel-options .form-check {
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.channel-options .form-check-input {
width: 20px;
height: 20px;
cursor: pointer;
}
.channel-options .form-check-label {
cursor: pointer;
user-select: none;
}
</style>
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 800px;">
<!-- Title Section -->
<section class="mt-lg mb-lg text-center">
<h2 class="text-title mb-sm">배포 채널을 선택해주세요</h2>
<p class="text-body text-secondary">(최소 1개 이상)</p>
</section>
<!-- Channel 1: 우리동네TV -->
<section class="mb-md">
<div class="card channel-card" id="channel1Card">
<div class="flex items-start gap-md">
<div class="form-check">
<input type="checkbox" id="channel1" class="form-check-input channel-checkbox" data-channel="uriTV">
<label for="channel1" class="form-check-label">우리동네TV</label>
</div>
</div>
<div class="channel-options" id="channel1Options">
<div class="form-group">
<label class="form-label">반경</label>
<select class="form-select" id="uriRadius">
<option value="500">500m</option>
<option value="1000">1km</option>
<option value="2000">2km</option>
</select>
</div>
<div class="form-group">
<label class="form-label">노출 시간대</label>
<select class="form-select" id="uriTime">
<option value="morning">아침 (7-12시)</option>
<option value="afternoon">점심 (12-17시)</option>
<option value="evening" selected>저녁 (17-22시)</option>
<option value="all">전체</option>
</select>
</div>
<div class="text-body-small text-secondary">
<p>예상 노출: <strong id="uriExposure">5만명</strong></p>
<p>비용: <strong id="uriCost">8만원</strong></p>
</div>
</div>
</div>
</section>
<!-- Channel 2: 링고비즈 -->
<section class="mb-md">
<div class="card channel-card" id="channel2Card">
<div class="flex items-start gap-md">
<div class="form-check">
<input type="checkbox" id="channel2" class="form-check-input channel-checkbox" data-channel="ringoBiz">
<label for="channel2" class="form-check-label">링고비즈</label>
</div>
</div>
<div class="channel-options" id="channel2Options">
<div class="form-group">
<label class="form-label">매장 전화번호</label>
<input type="tel" class="form-input" id="ringoPhone" value="010-1234-5678" readonly>
</div>
<div class="text-body-small text-secondary">
<p>연결음 자동 업데이트</p>
<p>예상 노출: <strong id="ringoExposure">3만명</strong></p>
<p>비용: <strong id="ringoCost">무료</strong></p>
</div>
</div>
</div>
</section>
<!-- Channel 3: 지니TV 광고 -->
<section class="mb-md">
<div class="card channel-card" id="channel3Card">
<div class="flex items-start gap-md">
<div class="form-check">
<input type="checkbox" id="channel3" class="form-check-input channel-checkbox" data-channel="genieTV">
<label for="channel3" class="form-check-label">지니TV 광고</label>
</div>
</div>
<div class="channel-options" id="channel3Options">
<div class="form-group">
<label class="form-label">지역</label>
<select class="form-select" id="genieRegion">
<option value="suwon" selected>수원</option>
<option value="seoul">서울</option>
<option value="busan">부산</option>
</select>
</div>
<div class="form-group">
<label class="form-label">노출 시간대</label>
<select class="form-select" id="genieTime">
<option value="all" selected>전체</option>
<option value="prime">프라임 (19-23시)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">예산</label>
<input type="number" class="form-input" id="genieBudget" placeholder="예산을 입력하세요" min="0" step="10000">
</div>
<div class="text-body-small text-secondary">
<p>예상 노출: <strong id="genieExposure">계산중...</strong></p>
</div>
</div>
</div>
</section>
<!-- Channel 4: SNS -->
<section class="mb-md">
<div class="card channel-card" id="channel4Card">
<div class="flex items-start gap-md">
<div class="form-check">
<input type="checkbox" id="channel4" class="form-check-input channel-checkbox" data-channel="sns">
<label for="channel4" class="form-check-label">SNS</label>
</div>
</div>
<div class="channel-options" id="channel4Options">
<div class="form-group">
<label class="form-label">플랫폼 선택</label>
<div class="form-check">
<input type="checkbox" id="snsInstagram" class="form-check-input" checked>
<label for="snsInstagram" class="form-check-label">Instagram</label>
</div>
<div class="form-check">
<input type="checkbox" id="snsNaver" class="form-check-input" checked>
<label for="snsNaver" class="form-check-label">Naver Blog</label>
</div>
<div class="form-check">
<input type="checkbox" id="snsKakao" class="form-check-input">
<label for="snsKakao" class="form-check-label">Kakao Channel</label>
</div>
</div>
<div class="form-group">
<label class="form-label">예약 게시</label>
<select class="form-select" id="snsSchedule">
<option value="now" selected>즉시</option>
<option value="schedule">예약</option>
</select>
</div>
<div class="text-body-small text-secondary">
<p>예상 노출: <strong id="snsExposure">-</strong></p>
<p>비용: <strong id="snsCost">무료</strong></p>
</div>
</div>
</div>
</section>
<!-- Summary -->
<section class="mb-lg">
<div class="card" style="background: var(--color-gray-50);">
<div class="flex items-center justify-between mb-sm">
<span class="text-headline">총 예상 비용</span>
<span class="text-title text-kt-red" id="totalCost">0원</span>
</div>
<div class="flex items-center justify-between">
<span class="text-headline">총 예상 노출</span>
<span class="text-title text-ai-blue" id="totalExposure">0명</span>
</div>
</div>
</section>
<!-- Action Button -->
<section class="mb-2xl">
<button id="nextBtn" class="btn btn-primary btn-large btn-full" disabled>
다음 단계
</button>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '배포 채널 선택',
showBack: true,
showMenu: false,
showProfile: false
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
document.getElementById('bottomNav').appendChild(bottomNav);
// 선택된 채널 추적
const selectedChannels = new Set();
// 채널 선택 이벤트
document.querySelectorAll('.channel-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', function() {
const channel = this.dataset.channel;
const card = this.closest('.channel-card');
if (this.checked) {
selectedChannels.add(channel);
card.classList.add('selected');
} else {
selectedChannels.delete(channel);
card.classList.remove('selected');
}
updateSummary();
updateNextButton();
});
});
// 지니TV 예산 입력 시 예상 노출 계산
document.getElementById('genieBudget')?.addEventListener('input', function() {
const budget = parseInt(this.value) || 0;
const exposure = Math.floor(budget / 100) * 1000; // 10만원당 1만명 노출 (예시)
document.getElementById('genieExposure').textContent = exposure > 0 ? `${(exposure / 10000).toFixed(1)}만명` : '계산중...';
updateSummary();
});
// SNS 예약 게시 선택 시 모달 표시
let scheduledDateTime = null;
document.getElementById('snsSchedule').addEventListener('change', function() {
if (this.value === 'schedule') {
showScheduleModal();
}
});
function showScheduleModal() {
// 현재 날짜/시간 기본값 설정
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const dateStr = tomorrow.toISOString().split('T')[0];
const timeStr = '09:00';
KTEventApp.Feedback.showModal({
title: '예약 배포 설정',
content: `
<div class="form-group">
<label class="form-label">배포 날짜</label>
<input type="date" id="scheduleDate" class="form-input" value="${dateStr}" min="${dateStr}">
</div>
<div class="form-group">
<label class="form-label">배포 시간</label>
<input type="time" id="scheduleTime" class="form-input" value="${timeStr}">
</div>
<p class="text-body-small text-secondary mt-md">
* 설정한 시간에 자동으로 SNS에 게시됩니다
</p>
`,
buttons: [
{
text: '취소',
variant: 'text',
onClick: function() {
// 즉시로 되돌리기
document.getElementById('snsSchedule').value = 'now';
scheduledDateTime = null;
this.closest('.modal-backdrop').remove();
}
},
{
text: '확인',
variant: 'primary',
onClick: function() {
const date = document.getElementById('scheduleDate').value;
const time = document.getElementById('scheduleTime').value;
if (!date || !time) {
KTEventApp.Feedback.showToast('날짜와 시간을 모두 입력해주세요');
return;
}
scheduledDateTime = {
date: date,
time: time,
datetime: `${date} ${time}`
};
KTEventApp.Feedback.showToast(`${date} ${time}에 배포 예약되었습니다`);
this.closest('.modal-backdrop').remove();
}
}
]
});
}
// 요약 업데이트
function updateSummary() {
let totalCost = 0;
let totalExposure = 0;
if (selectedChannels.has('uriTV')) {
totalCost += 80000;
totalExposure += 50000;
}
if (selectedChannels.has('ringoBiz')) {
totalCost += 0;
totalExposure += 30000;
}
if (selectedChannels.has('genieTV')) {
const budget = parseInt(document.getElementById('genieBudget').value) || 0;
totalCost += budget;
totalExposure += Math.floor(budget / 100) * 1000;
}
if (selectedChannels.has('sns')) {
totalCost += 0;
// SNS는 팔로워 수에 따라 다름
totalExposure += 0;
}
// 표시 업데이트
document.getElementById('totalCost').textContent = KTEventApp.Utils.formatNumber(totalCost) + '원';
document.getElementById('totalExposure').textContent = totalExposure > 0 ?
KTEventApp.Utils.formatNumber(totalExposure) + '명+' : '0명';
}
// 다음 버튼 활성화
function updateNextButton() {
document.getElementById('nextBtn').disabled = selectedChannels.size === 0;
}
// 다음 단계 버튼
document.getElementById('nextBtn').addEventListener('click', function() {
if (selectedChannels.size === 0) {
KTEventApp.Feedback.showToast('최소 1개 이상의 채널을 선택해주세요');
return;
}
// 채널 선택 정보 저장
const channelData = {
channels: Array.from(selectedChannels),
uriTV: selectedChannels.has('uriTV') ? {
radius: document.getElementById('uriRadius').value,
time: document.getElementById('uriTime').value
} : null,
ringoBiz: selectedChannels.has('ringoBiz') ? {
phone: document.getElementById('ringoPhone').value
} : null,
genieTV: selectedChannels.has('genieTV') ? {
region: document.getElementById('genieRegion').value,
time: document.getElementById('genieTime').value,
budget: document.getElementById('genieBudget').value
} : null,
sns: selectedChannels.has('sns') ? {
instagram: document.getElementById('snsInstagram').checked,
naver: document.getElementById('snsNaver').checked,
kakao: document.getElementById('snsKakao').checked,
schedule: document.getElementById('snsSchedule').value,
scheduledDateTime: scheduledDateTime
} : null
};
KTEventApp.Utils.saveToStorage('selected_channels', channelData);
// 최종 승인 화면으로 이동
window.location.href = '12-최종승인.html';
});
</script>
</body>
</html>

View File

@ -0,0 +1,367 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>최종 승인 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css"
/>
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 600px">
<!-- Title Section -->
<section class="mt-lg mb-lg text-center">
<div class="mb-md">
<span
class="material-icons"
style="font-size: 64px; color: var(--color-success)"
>check_circle</span
>
</div>
<h2 class="text-title-large mb-sm">이벤트를 확인해주세요</h2>
<p class="text-body text-secondary">
모든 정보를 검토한 후 배포하세요
</p>
</section>
<!-- Event Summary Card -->
<section class="mb-lg">
<div class="card">
<h3 class="text-headline mb-md" id="eventTitle">
SNS 팔로우 이벤트
</h3>
<div class="flex items-center gap-sm mb-md">
<span class="event-card-badge badge-scheduled">배포 대기</span>
<span
class="event-card-badge"
style="
background: rgba(0, 102, 255, 0.1);
color: var(--color-ai-blue);
"
>
AI 추천
</span>
</div>
<div class="border-t pt-md">
<div class="grid grid-cols-2 gap-md text-body-small">
<div>
<p class="text-tertiary mb-xs">이벤트 기간</p>
<p class="text-semibold" id="eventPeriod">
2025.02.01 ~ 2025.02.28
</p>
</div>
<div>
<p class="text-tertiary mb-xs">목표 참여자</p>
<p class="text-semibold" id="targetParticipants">180명</p>
</div>
<div>
<p class="text-tertiary mb-xs">예상 비용</p>
<p class="text-semibold" id="estimatedCost">250,000원</p>
</div>
<div>
<p class="text-tertiary mb-xs">예상 ROI</p>
<p class="text-semibold text-kt-red" id="estimatedROI">
520%
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Event Details -->
<section class="mb-lg">
<h3 class="text-headline mb-md">이벤트 상세</h3>
<div class="card mb-sm">
<div class="flex items-start gap-md">
<span class="material-icons text-kt-red">celebration</span>
<div class="flex-1">
<p class="text-body-small text-tertiary mb-xs">이벤트 제목</p>
<p class="text-body" id="detailTitle">SNS 팔로우 이벤트</p>
</div>
<button
class="header-icon-btn"
onclick="window.location.href='10-콘텐츠편집.html'"
>
<span class="material-icons">edit</span>
</button>
</div>
</div>
<div class="card mb-sm">
<div class="flex items-start gap-md">
<span class="material-icons text-kt-red">card_giftcard</span>
<div class="flex-1">
<p class="text-body-small text-tertiary mb-xs">경품</p>
<p class="text-body" id="detailPrize">커피 쿠폰</p>
</div>
<button
class="header-icon-btn"
onclick="window.location.href='10-콘텐츠편집.html'"
>
<span class="material-icons">edit</span>
</button>
</div>
</div>
<div class="card mb-sm">
<div class="flex items-start gap-md">
<span class="material-icons text-kt-red">description</span>
<div class="flex-1">
<p class="text-body-small text-tertiary mb-xs">이벤트 설명</p>
<p class="text-body" id="detailDescription">
SNS를 팔로우하고 커피 쿠폰을 받으세요!<br />
많은 참여 부탁드립니다.
</p>
</div>
<button
class="header-icon-btn"
onclick="window.location.href='10-콘텐츠편집.html'"
>
<span class="material-icons">edit</span>
</button>
</div>
</div>
<div class="card">
<div class="flex items-start gap-md">
<span class="material-icons text-kt-red">how_to_reg</span>
<div class="flex-1">
<p class="text-body-small text-tertiary mb-xs">참여 방법</p>
<p class="text-body" id="detailMethod">SNS 팔로우</p>
</div>
</div>
</div>
</section>
<!-- Distribution Channels -->
<section class="mb-lg">
<h3 class="text-headline mb-md">배포 채널</h3>
<div class="card">
<div id="channels" class="flex flex-wrap gap-sm">
<span class="event-card-badge badge-active">
<span
class="material-icons"
style="font-size: 16px; vertical-align: middle"
>language</span
>
홈페이지
</span>
<span class="event-card-badge badge-active">
<span
class="material-icons"
style="font-size: 16px; vertical-align: middle"
>chat_bubble</span
>
카카오톡
</span>
<span class="event-card-badge badge-active">
<span
class="material-icons"
style="font-size: 16px; vertical-align: middle"
>share</span
>
Instagram
</span>
</div>
<button
class="btn btn-text btn-small mt-md"
onclick="window.location.href='11-배포채널선택.html'"
>
<span class="material-icons">edit</span>
채널 수정하기
</button>
</div>
</section>
<!-- Terms Agreement -->
<section class="mb-xl">
<div class="card" style="background: var(--color-gray-50)">
<div class="form-check">
<input
type="checkbox"
id="agreeTerms"
class="form-check-input"
required
/>
<label for="agreeTerms" class="form-check-label">
이벤트 약관 및 개인정보 처리방침에 동의합니다
<span class="text-kt-red">(필수)</span>
</label>
</div>
<a
href="#"
class="text-body-small text-kt-red mt-sm inline-block"
id="viewTerms"
>약관 보기</a
>
</div>
</section>
<!-- Action Buttons -->
<section class="mb-2xl">
<button
id="approveBtn"
class="btn btn-primary btn-large btn-full mb-sm"
disabled
>
<span class="material-icons">rocket_launch</span>
배포하기
</button>
<button
id="saveBtn"
class="btn btn-secondary btn-large btn-full mb-sm"
>
<span class="material-icons">save</span>
임시저장
</button>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: "최종 승인",
showBack: true,
showMenu: false,
showProfile: false,
});
document.getElementById("header").appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav("events");
document.getElementById("bottomNav").appendChild(bottomNav);
// 저장된 데이터 불러오기
const recommendation = KTEventApp.Utils.getFromStorage(
"selected_recommendation"
) || {
title: "SNS 팔로우 이벤트",
prize: "커피 쿠폰",
};
// 데이터 표시
document.getElementById("eventTitle").textContent = recommendation.title;
document.getElementById("detailTitle").textContent = recommendation.title;
document.getElementById("detailPrize").textContent = recommendation.prize;
// 약관 동의 체크박스
document
.getElementById("agreeTerms")
.addEventListener("change", function (e) {
document.getElementById("approveBtn").disabled = !e.target.checked;
});
// 약관 보기
document
.getElementById("viewTerms")
.addEventListener("click", function (e) {
e.preventDefault();
KTEventApp.Feedback.showModal({
title: "이벤트 약관",
content: `
<div class="p-md" style="max-height: 400px; overflow-y: auto;">
<h4 class="text-headline mb-md">제1조 (목적)</h4>
<p class="text-body-small mb-lg">
본 약관은 KT AI 이벤트 마케팅 서비스를 통해 진행되는 이벤트의 참여 및 개인정보 처리에 관한 사항을 규정합니다.
</p>
<h4 class="text-headline mb-md">제2조 (개인정보 수집 및 이용)</h4>
<p class="text-body-small mb-lg">
수집 항목: 이름, 전화번호, 이메일<br>
이용 목적: 이벤트 참여 확인 및 경품 제공<br>
보유 기간: 이벤트 종료 후 6개월
</p>
<h4 class="text-headline mb-md">제3조 (당첨자 발표)</h4>
<p class="text-body-small">
당첨자는 이벤트 종료 후 7일 이내 개별 연락 드립니다.
</p>
</div>
`,
buttons: [
{
text: "확인",
variant: "primary",
onClick: function () {
this.closest(".modal-backdrop").remove();
},
},
],
});
});
// 배포하기 버튼
document
.getElementById("approveBtn")
.addEventListener("click", function () {
const button = this;
button.disabled = true;
button.innerHTML =
'<span class="material-icons">hourglass_empty</span> 배포 중...';
// 배포 시뮬레이션
setTimeout(() => {
// 성공 모달 표시
const successModal = KTEventApp.Feedback.showModal({
content: `
<div class="p-xl text-center">
<span class="material-icons" style="font-size: 80px; color: var(--color-success);">check_circle</span>
<h3 class="text-title-large mt-md mb-sm">배포 완료!</h3>
<p class="text-body text-secondary mb-lg">
이벤트가 성공적으로 배포되었습니다.<br>
실시간으로 참여자를 확인할 수 있습니다.
</p>
<div class="flex flex-col gap-sm">
<button class="btn btn-primary btn-large btn-full" onclick="window.location.href='13-이벤트상세.html?id=new'">
이벤트 상세 보기
</button>
<button class="btn btn-secondary btn-large btn-full" onclick="window.location.href='05-대시보드.html'">
대시보드로 이동
</button>
</div>
</div>
`,
});
}, 2000);
});
// 임시저장 버튼
document.getElementById("saveBtn").addEventListener("click", function () {
// 현재 데이터 저장
const draftData = {
...recommendation,
savedAt: new Date().toISOString(),
};
KTEventApp.Utils.saveToStorage("event_draft", draftData);
KTEventApp.Feedback.showToast("임시저장되었습니다");
});
// 이전으로 버튼
document.getElementById("backBtn").addEventListener("click", function () {
window.history.back();
});
</script>
</body>
</html>

View File

@ -0,0 +1,437 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이벤트 상세 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container">
<!-- Event Header -->
<section class="mt-lg mb-lg">
<div class="flex items-center justify-between mb-md">
<h2 class="text-title-large" id="eventTitle">SNS 팔로우 이벤트</h2>
<button class="header-icon-btn" id="moreBtn">
<span class="material-icons">more_vert</span>
</button>
</div>
<div class="flex items-center gap-sm mb-md">
<span class="event-card-badge badge-active" id="statusBadge">진행중</span>
<span class="event-card-badge" style="background: rgba(0, 102, 255, 0.1); color: var(--color-ai-blue);">
AI 추천
</span>
</div>
<p class="text-body text-secondary" id="eventPeriod">
2025.01.15 ~ 2025.02.15
</p>
</section>
<!-- Real-time KPIs -->
<section class="mb-xl">
<div class="flex items-center justify-between mb-md">
<h3 class="text-headline">실시간 현황</h3>
<div class="flex items-center gap-xs text-body-small text-success">
<span class="material-icons" style="font-size: 16px;">fiber_manual_record</span>
<span>실시간 업데이트</span>
</div>
</div>
<div class="grid grid-cols-2 gap-sm tablet:grid-cols-4">
<div id="kpiParticipants"></div>
<div id="kpiViews"></div>
<div id="kpiROI"></div>
<div id="kpiConversion"></div>
</div>
</section>
<!-- Chart Section -->
<section class="mb-xl">
<h3 class="text-headline mb-md">참여 추이</h3>
<div class="card">
<div class="flex justify-between items-center mb-md">
<div class="flex gap-xs">
<button class="btn btn-small active" data-period="7d">7일</button>
<button class="btn btn-small btn-text" data-period="30d">30일</button>
<button class="btn btn-small btn-text" data-period="all">전체</button>
</div>
</div>
<!-- Simple Chart Placeholder -->
<div id="chartPlaceholder" style="height: 200px; background: var(--color-gray-50); border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center;">
<div class="text-center">
<span class="material-icons text-tertiary" style="font-size: 48px;">show_chart</span>
<p class="text-body-small text-tertiary mt-sm">참여자 추이 차트</p>
</div>
</div>
</div>
</section>
<!-- Event Details -->
<section class="mb-xl">
<h3 class="text-headline mb-md">이벤트 정보</h3>
<div class="card mb-sm">
<div class="flex items-start gap-md">
<span class="material-icons text-kt-red">card_giftcard</span>
<div class="flex-1">
<p class="text-body-small text-tertiary mb-xs">경품</p>
<p class="text-body" id="detailPrize">커피 쿠폰</p>
</div>
</div>
</div>
<div class="card mb-sm">
<div class="flex items-start gap-md">
<span class="material-icons text-kt-red">how_to_reg</span>
<div class="flex-1">
<p class="text-body-small text-tertiary mb-xs">참여 방법</p>
<p class="text-body" id="detailMethod">SNS 팔로우</p>
</div>
</div>
</div>
<div class="card mb-sm">
<div class="flex items-start gap-md">
<span class="material-icons text-kt-red">attach_money</span>
<div class="flex-1">
<p class="text-body-small text-tertiary mb-xs">예상 비용</p>
<p class="text-body" id="detailCost">250,000원</p>
</div>
</div>
</div>
<div class="card">
<div class="flex items-start gap-md">
<span class="material-icons text-kt-red">share</span>
<div class="flex-1">
<p class="text-body-small text-tertiary mb-xs">배포 채널</p>
<div class="flex flex-wrap gap-xs mt-sm">
<span class="event-card-badge badge-active">홈페이지</span>
<span class="event-card-badge badge-active">카카오톡</span>
<span class="event-card-badge badge-active">Instagram</span>
</div>
</div>
</div>
</div>
</section>
<!-- Quick Actions -->
<section class="mb-xl">
<h3 class="text-headline mb-md">빠른 작업</h3>
<div class="grid grid-cols-2 gap-sm tablet:grid-cols-4">
<button class="card card-clickable p-md" id="viewParticipants">
<span class="material-icons text-kt-red" style="font-size: 32px;">people</span>
<p class="text-body-small mt-sm">참여자 목록</p>
</button>
<button class="card card-clickable p-md" id="editEvent">
<span class="material-icons text-ai-blue" style="font-size: 32px;">edit</span>
<p class="text-body-small mt-sm">이벤트 수정</p>
</button>
<button class="card card-clickable p-md" id="shareEvent">
<span class="material-icons text-secondary" style="font-size: 32px;">share</span>
<p class="text-body-small mt-sm">공유하기</p>
</button>
<button class="card card-clickable p-md" id="exportData">
<span class="material-icons text-secondary" style="font-size: 32px;">download</span>
<p class="text-body-small mt-sm">데이터 다운</p>
</button>
</div>
</section>
<!-- Recent Participants -->
<section class="mb-2xl">
<div class="flex items-center justify-between mb-md">
<h3 class="text-headline">최근 참여자</h3>
<a href="14-참여자목록.html" class="text-body-small text-kt-red">
전체보기
<span class="material-icons" style="font-size: 16px; vertical-align: middle;">chevron_right</span>
</a>
</div>
<div class="card">
<div id="recentParticipants">
<!-- 참여자 목록 -->
</div>
</div>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '이벤트 상세',
showBack: true,
showMenu: false,
showProfile: false
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
document.getElementById('bottomNav').appendChild(bottomNav);
// URL에서 이벤트 ID 가져오기
const urlParams = new URLSearchParams(window.location.search);
const eventId = urlParams.get('id');
// 이벤트 데이터 가져오기
const events = KTEventApp.MockData.getEvents();
const event = events.find(e => e.id === eventId) || events[0];
// 이벤트 정보 표시
document.getElementById('eventTitle').textContent = event.title;
document.getElementById('eventPeriod').textContent =
`${KTEventApp.Utils.formatDate(event.startDate)} ~ ${KTEventApp.Utils.formatDate(event.endDate)}`;
document.getElementById('detailPrize').textContent = event.prize;
document.getElementById('detailMethod').textContent = `${event.channel} - SNS 팔로우`;
document.getElementById('detailCost').textContent = event.budget === '저비용' ? '250,000원' : '1,500,000원';
// 상태 배지
const statusBadge = document.getElementById('statusBadge');
statusBadge.textContent = event.status;
statusBadge.className = 'event-card-badge';
if (event.status === '진행중') {
statusBadge.classList.add('badge-active');
} else if (event.status === '예정') {
statusBadge.classList.add('badge-scheduled');
} else {
statusBadge.classList.add('badge-ended');
}
// KPI 카드 생성
const kpiParticipants = KTEventApp.Cards.createKPICard({
icon: 'group',
iconType: 'primary',
label: '참여자',
value: `${KTEventApp.Utils.formatNumber(event.participants)}명`
});
document.getElementById('kpiParticipants').appendChild(kpiParticipants);
const kpiViews = KTEventApp.Cards.createKPICard({
icon: 'visibility',
iconType: 'ai',
label: '조회수',
value: `${KTEventApp.Utils.formatNumber(event.views)}`
});
document.getElementById('kpiViews').appendChild(kpiViews);
const kpiROI = KTEventApp.Cards.createKPICard({
icon: 'trending_up',
iconType: 'success',
label: 'ROI',
value: `${event.roi}%`
});
document.getElementById('kpiROI').appendChild(kpiROI);
const conversion = event.views > 0 ? Math.round((event.participants / event.views) * 100) : 0;
const kpiConversion = KTEventApp.Cards.createKPICard({
icon: 'conversion_path',
iconType: 'primary',
label: '전환율',
value: `${conversion}%`
});
document.getElementById('kpiConversion').appendChild(kpiConversion);
// 최근 참여자 표시
const recentParticipantsContainer = document.getElementById('recentParticipants');
const participants = [
{ name: '김*진', phone: '010-****-1234', time: '5분 전' },
{ name: '이*수', phone: '010-****-5678', time: '12분 전' },
{ name: '박*영', phone: '010-****-9012', time: '25분 전' },
{ name: '최*민', phone: '010-****-3456', time: '1시간 전' },
{ name: '정*희', phone: '010-****-7890', time: '2시간 전' }
];
participants.forEach((participant, index) => {
const item = document.createElement('div');
item.className = 'flex items-center justify-between';
if (index > 0) item.classList.add('mt-md', 'pt-md', 'border-t');
item.innerHTML = `
<div class="flex items-center gap-md">
<div class="flex items-center justify-center" style="width: 40px; height: 40px; background: var(--color-gray-100); border-radius: var(--radius-full);">
<span class="material-icons text-tertiary">person</span>
</div>
<div>
<p class="text-body text-semibold">${participant.name}</p>
<p class="text-caption text-tertiary">${participant.phone}</p>
</div>
</div>
<p class="text-caption text-tertiary">${participant.time}</p>
`;
recentParticipantsContainer.appendChild(item);
});
// 더보기 메뉴
document.getElementById('moreBtn').addEventListener('click', function() {
KTEventApp.Feedback.showBottomSheet(`
<div class="flex flex-col gap-sm">
<button class="btn btn-text btn-large btn-full" onclick="editEvent()">
<span class="material-icons">edit</span>
이벤트 수정
</button>
<button class="btn btn-text btn-large btn-full" onclick="shareEvent()">
<span class="material-icons">share</span>
공유하기
</button>
<button class="btn btn-text btn-large btn-full" onclick="exportData()">
<span class="material-icons">download</span>
데이터 다운로드
</button>
<button class="btn btn-text btn-large btn-full text-error" onclick="deleteEvent()">
<span class="material-icons">delete</span>
이벤트 삭제
</button>
</div>
`);
});
// 빠른 작업 버튼들
document.getElementById('viewParticipants').addEventListener('click', () => {
window.location.href = '14-참여자목록.html?eventId=' + event.id;
});
document.getElementById('editEvent').addEventListener('click', editEvent);
document.getElementById('shareEvent').addEventListener('click', shareEvent);
document.getElementById('exportData').addEventListener('click', exportData);
// 차트 기간 버튼
document.querySelectorAll('[data-period]').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('[data-period]').forEach(b => {
b.classList.remove('active');
b.classList.add('btn-text');
});
this.classList.add('active');
this.classList.remove('btn-text');
KTEventApp.Feedback.showToast('차트가 업데이트되었습니다');
});
});
// 이벤트 수정
function editEvent() {
KTEventApp.Feedback.showToast('이벤트 수정 기능은 준비 중입니다.');
}
// 공유하기
function shareEvent() {
KTEventApp.Feedback.showModal({
title: '이벤트 공유',
content: `
<p class="text-body mb-md">이벤트 링크를 공유하세요</p>
<div class="form-group">
<input type="text" class="form-input" value="https://kt-event.co.kr/evt/${event.id}" readonly>
</div>
<div class="flex gap-sm mt-lg">
<button class="btn btn-secondary btn-large flex-1" onclick="KTEventApp.Feedback.showToast('카카오톡 공유 준비 중')">
카카오톡
</button>
<button class="btn btn-secondary btn-large flex-1" onclick="copyLink()">
링크 복사
</button>
</div>
`
});
}
// 링크 복사
function copyLink() {
KTEventApp.Feedback.showToast('링크가 복사되었습니다');
}
// 데이터 다운로드
function exportData() {
KTEventApp.Feedback.showModal({
title: '데이터 다운로드',
content: `
<p class="text-body mb-md">다운로드할 데이터 형식을 선택하세요</p>
<div class="flex flex-col gap-sm">
<button class="btn btn-secondary btn-large btn-full" onclick="download('excel')">
<span class="material-icons">table_chart</span>
Excel (XLSX)
</button>
<button class="btn btn-secondary btn-large btn-full" onclick="download('csv')">
<span class="material-icons">description</span>
CSV
</button>
<button class="btn btn-secondary btn-large btn-full" onclick="download('pdf')">
<span class="material-icons">picture_as_pdf</span>
PDF 리포트
</button>
</div>
`
});
}
function download(format) {
KTEventApp.Feedback.showToast(`${format.toUpperCase()} 파일 다운로드 준비 중...`);
}
// 이벤트 삭제
function deleteEvent() {
KTEventApp.Feedback.showModal({
title: '이벤트 삭제',
content: '<p class="text-body">정말로 이벤트를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.</p>',
buttons: [
{
text: '취소',
variant: 'text',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
},
{
text: '삭제',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
KTEventApp.Feedback.showToast('이벤트가 삭제되었습니다');
setTimeout(() => {
window.location.href = '05-대시보드.html';
}, 1500);
}
}
]
});
}
// 실시간 업데이트 시뮬레이션 (5초마다)
if (event.status === '진행중') {
setInterval(() => {
// 참여자 수 업데이트 (랜덤 증가)
const increase = Math.floor(Math.random() * 3);
if (increase > 0) {
event.participants += increase;
document.querySelector('#kpiParticipants .kpi-value').textContent =
`${KTEventApp.Utils.formatNumber(event.participants)}명`;
// 작은 애니메이션 효과
const kpiCard = document.getElementById('kpiParticipants').firstChild;
kpiCard.style.transform = 'scale(1.05)';
setTimeout(() => {
kpiCard.style.transform = 'scale(1)';
}, 200);
}
}, 5000);
}
</script>
</body>
</html>

View File

@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>참여자 목록 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 1200px;">
<!-- Search Section -->
<section class="mt-lg mb-md">
<div class="form-group">
<div class="input-with-icon">
<span class="material-icons input-icon">search</span>
<input type="text" id="searchInput" class="form-input" placeholder="이름 또는 전화번호 검색...">
</div>
</div>
</section>
<!-- Filters -->
<section class="mb-md">
<div class="flex items-center gap-sm flex-wrap">
<span class="material-icons text-kt-red">filter_list</span>
<select id="channelFilter" class="form-select" style="flex: 1; min-width: 140px;">
<option value="all">전체 경로</option>
<option value="uriTV">우리동네TV</option>
<option value="ringoBiz">링고비즈</option>
<option value="sns">SNS</option>
</select>
<select id="statusFilter" class="form-select" style="flex: 1; min-width: 120px;">
<option value="all">전체</option>
<option value="waiting">당첨 대기</option>
<option value="winner">당첨</option>
<option value="loser">미당첨</option>
</select>
</div>
</section>
<!-- Total Count & Drawing Button -->
<section class="mb-md">
<div class="flex items-center justify-between">
<p class="text-headline"><span id="totalCount">128</span>명 참여</p>
<button id="drawingBtn" class="btn btn-primary btn-medium">
<span class="material-icons">casino</span>
당첨자 추첨
</button>
</div>
</section>
<!-- Participant List -->
<section id="participantList" class="mb-lg">
<!-- Participants will be dynamically loaded here -->
</section>
<!-- Pagination -->
<section class="mb-md">
<div class="flex items-center justify-center gap-sm" id="pagination">
<!-- Pagination will be dynamically loaded here -->
</div>
</section>
<!-- Excel Download Button (Desktop only) -->
<section class="mb-2xl desktop-only">
<button id="downloadBtn" class="btn btn-secondary btn-large">
<span class="material-icons">download</span>
엑셀 다운로드
</button>
</section>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '참여자 목록',
showBack: true,
showMenu: false,
showProfile: true
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('events');
document.getElementById('bottomNav').appendChild(bottomNav);
// Mock 참여자 데이터
const participants = [
{
id: '0001',
name: '김**',
phone: '010-****-1234',
channel: 'SNS (Instagram)',
channelType: 'sns',
date: '2025-11-02 14:23',
status: 'waiting'
},
{
id: '0002',
name: '이**',
phone: '010-****-5678',
channel: '우리동네TV',
channelType: 'uriTV',
date: '2025-11-02 15:45',
status: 'waiting'
},
{
id: '0003',
name: '박**',
phone: '010-****-9012',
channel: '링고비즈',
channelType: 'ringoBiz',
date: '2025-11-02 16:12',
status: 'waiting'
},
{
id: '0004',
name: '최**',
phone: '010-****-3456',
channel: 'SNS (Naver)',
channelType: 'sns',
date: '2025-11-02 17:30',
status: 'waiting'
},
{
id: '0005',
name: '정**',
phone: '010-****-7890',
channel: '우리동네TV',
channelType: 'uriTV',
date: '2025-11-02 18:15',
status: 'waiting'
}
];
let currentPage = 1;
const itemsPerPage = 20;
let filteredParticipants = [...participants];
// 참여자 카드 생성
function createParticipantCard(participant) {
const card = document.createElement('div');
card.className = 'card';
card.style.cursor = 'pointer';
const statusText = participant.status === 'waiting' ? '당첨 대기' :
participant.status === 'winner' ? '당첨' : '미당첨';
const statusColor = participant.status === 'waiting' ? 'var(--color-gray-600)' :
participant.status === 'winner' ? 'var(--color-success)' : 'var(--color-error)';
card.innerHTML = `
<div class="flex items-start justify-between mb-sm">
<div class="flex-1">
<p class="text-caption text-secondary mb-xs">#${participant.id}</p>
<h3 class="text-headline mb-xs">${participant.name}</h3>
<p class="text-body-small text-secondary">${participant.phone}</p>
</div>
<span class="event-card-badge" style="background: ${statusColor}; color: white;">
${statusText}
</span>
</div>
<div class="border-t pt-sm mt-sm">
<div class="flex items-center justify-between text-body-small">
<span class="text-secondary">참여 경로</span>
<span class="text-semibold">${participant.channel}</span>
</div>
<div class="flex items-center justify-between text-body-small mt-xs">
<span class="text-secondary">참여 일시</span>
<span>${participant.date}</span>
</div>
</div>
`;
card.addEventListener('click', () => {
showParticipantDetail(participant);
});
return card;
}
// 참여자 목록 렌더링
function renderParticipantList() {
const participantList = document.getElementById('participantList');
participantList.innerHTML = '';
if (filteredParticipants.length === 0) {
participantList.innerHTML = `
<div class="text-center py-2xl">
<span class="material-icons" style="font-size: 64px; color: var(--color-gray-300);">people_outline</span>
<p class="text-body text-secondary mt-md">검색 결과가 없습니다</p>
</div>
`;
return;
}
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, filteredParticipants.length);
const pageParticipants = filteredParticipants.slice(startIndex, endIndex);
pageParticipants.forEach(participant => {
participantList.appendChild(createParticipantCard(participant));
});
renderPagination();
}
// 페이지네이션 렌더링
function renderPagination() {
const pagination = document.getElementById('pagination');
pagination.innerHTML = '';
const totalPages = Math.ceil(filteredParticipants.length / itemsPerPage);
if (totalPages <= 1) return;
// 이전 버튼
const prevBtn = document.createElement('button');
prevBtn.className = 'btn btn-text btn-small';
prevBtn.innerHTML = '<span class="material-icons">chevron_left</span>';
prevBtn.disabled = currentPage === 1;
prevBtn.addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
renderParticipantList();
}
});
pagination.appendChild(prevBtn);
// 페이지 번호
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - 1 && i <= currentPage + 1)) {
const pageBtn = document.createElement('button');
pageBtn.className = `btn ${i === currentPage ? 'btn-primary' : 'btn-text'} btn-small`;
pageBtn.textContent = i;
pageBtn.addEventListener('click', () => {
currentPage = i;
renderParticipantList();
});
pagination.appendChild(pageBtn);
} else if (i === currentPage - 2 || i === currentPage + 2) {
const ellipsis = document.createElement('span');
ellipsis.textContent = '...';
ellipsis.className = 'text-secondary px-sm';
pagination.appendChild(ellipsis);
}
}
// 다음 버튼
const nextBtn = document.createElement('button');
nextBtn.className = 'btn btn-text btn-small';
nextBtn.innerHTML = '<span class="material-icons">chevron_right</span>';
nextBtn.disabled = currentPage === totalPages;
nextBtn.addEventListener('click', () => {
if (currentPage < totalPages) {
currentPage++;
renderParticipantList();
}
});
pagination.appendChild(nextBtn);
}
// 필터 적용
function applyFilters() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const channelFilter = document.getElementById('channelFilter').value;
const statusFilter = document.getElementById('statusFilter').value;
filteredParticipants = participants.filter(participant => {
const matchesSearch = participant.name.includes(searchTerm) ||
participant.phone.includes(searchTerm);
const matchesChannel = channelFilter === 'all' || participant.channelType === channelFilter;
const matchesStatus = statusFilter === 'all' || participant.status === statusFilter;
return matchesSearch && matchesChannel && matchesStatus;
});
document.getElementById('totalCount').textContent = filteredParticipants.length;
currentPage = 1;
renderParticipantList();
}
// 참여자 상세 정보 모달
function showParticipantDetail(participant) {
KTEventApp.Feedback.showModal({
title: '참여자 상세 정보',
content: `
<div class="p-md">
<div class="mb-md">
<p class="text-caption text-secondary mb-xs">응모번호</p>
<p class="text-headline">#${participant.id}</p>
</div>
<div class="mb-md">
<p class="text-caption text-secondary mb-xs">이름</p>
<p class="text-body">${participant.name}</p>
</div>
<div class="mb-md">
<p class="text-caption text-secondary mb-xs">전화번호</p>
<p class="text-body">${participant.phone}</p>
</div>
<div class="mb-md">
<p class="text-caption text-secondary mb-xs">참여 경로</p>
<p class="text-body">${participant.channel}</p>
</div>
<div class="mb-md">
<p class="text-caption text-secondary mb-xs">참여 일시</p>
<p class="text-body">${participant.date}</p>
</div>
<div>
<p class="text-caption text-secondary mb-xs">당첨 여부</p>
<p class="text-body">${participant.status === 'waiting' ? '당첨 대기' : participant.status === 'winner' ? '당첨' : '미당첨'}</p>
</div>
</div>
`,
buttons: [
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
}
]
});
}
// 이벤트 리스너
document.getElementById('searchInput').addEventListener('input', KTEventApp.Utils.debounce(applyFilters, 300));
document.getElementById('channelFilter').addEventListener('change', applyFilters);
document.getElementById('statusFilter').addEventListener('change', applyFilters);
// 엑셀 다운로드 버튼
document.getElementById('downloadBtn')?.addEventListener('click', function() {
KTEventApp.Feedback.showToast('참여자 목록을 다운로드합니다');
// 실제로는 엑셀 다운로드 로직 구현
});
// 당첨자 추첨 버튼
document.getElementById('drawingBtn').addEventListener('click', function() {
// 이벤트가 종료되었는지 확인 (실제로는 이벤트 상태 체크)
const eventEnded = true; // Mock data
if (!eventEnded) {
KTEventApp.Feedback.showModal({
title: '추첨 불가',
content: '<p class="text-body text-center">이벤트가 종료된 후 추첨이 가능합니다.</p>',
buttons: [
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
}
]
});
return;
}
// 참여자가 있는지 확인
if (filteredParticipants.length === 0) {
KTEventApp.Feedback.showModal({
title: '추첨 불가',
content: '<p class="text-body text-center">참여자가 없어 추첨할 수 없습니다.</p>',
buttons: [
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
}
]
});
return;
}
// 당첨자 추첨 화면으로 이동
window.location.href = '16-당첨자추첨.html';
});
// 초기 렌더링
renderParticipantList();
</script>
</body>
</html>

View File

@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이벤트 참여 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<style>
body {
background: var(--color-gray-50);
}
.participation-container {
max-width: 600px;
margin: 0 auto;
padding: var(--spacing-lg);
}
.event-image {
width: 100%;
aspect-ratio: 1 / 1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
text-align: center;
padding: var(--spacing-xl);
margin-bottom: var(--spacing-lg);
}
.success-animation {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: white;
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 9999;
}
.success-animation.active {
display: flex;
}
.checkmark {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--color-success);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--spacing-lg);
animation: scaleIn 0.5s ease-out;
}
@keyframes scaleIn {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
</style>
</head>
<body>
<div class="participation-container">
<!-- Event Image -->
<div class="event-image">
<span class="material-icons" style="font-size: 64px; margin-bottom: 16px;">celebration</span>
<h2 class="text-title-large" id="eventTitle">신규고객 유치 이벤트</h2>
</div>
<!-- Event Info -->
<section class="mb-lg">
<div class="flex items-center gap-sm mb-md">
<span class="material-icons text-kt-red">card_giftcard</span>
<div>
<p class="text-caption text-secondary">경품</p>
<p class="text-headline" id="eventPrize">커피 쿠폰</p>
</div>
</div>
<div class="flex items-center gap-sm mb-md">
<span class="material-icons text-kt-red">calendar_today</span>
<div>
<p class="text-caption text-secondary">기간</p>
<p class="text-body" id="eventPeriod">2025-11-01 ~ 2025-11-15</p>
</div>
</div>
</section>
<!-- Divider -->
<div class="border-t mb-lg"></div>
<!-- Participation Form -->
<section class="mb-lg">
<h3 class="text-title mb-md">참여하기</h3>
<div class="form-group">
<label for="participantName" class="form-label">이름 <span class="text-kt-red">*</span></label>
<input type="text" id="participantName" class="form-input" placeholder="이름을 입력하세요" required>
<span class="form-error" id="nameError"></span>
</div>
<div class="form-group">
<label for="participantPhone" class="form-label">전화번호 <span class="text-kt-red">*</span></label>
<input type="tel" id="participantPhone" class="form-input" placeholder="010-0000-0000" required>
<span class="form-error" id="phoneError"></span>
</div>
<div class="form-check">
<input type="checkbox" id="agreePrivacy" class="form-check-input" required>
<label for="agreePrivacy" class="form-check-label">
개인정보 수집 및 이용에 동의합니다 <span class="text-kt-red">(필수)</span>
</label>
</div>
<button class="btn btn-text btn-small mt-xs" id="viewPrivacy">
전문보기
</button>
</section>
<!-- Submit Button -->
<section class="mb-lg">
<button id="submitBtn" class="btn btn-primary btn-large btn-full">
참여하기
</button>
</section>
<!-- Participant Count -->
<section class="text-center">
<p class="text-body-small text-secondary">
참여자: <strong id="participantCount">128</strong>
</p>
</section>
</div>
<!-- Success Animation -->
<div class="success-animation" id="successAnimation">
<div class="checkmark">
<span class="material-icons" style="font-size: 48px; color: white;">check</span>
</div>
<h2 class="text-title-large mb-sm">참여가 완료되었습니다!</h2>
<p class="text-body text-secondary mb-lg">
<span id="participantNameDisplay">홍길동</span>님의 행운을 기원합니다!
</p>
<div class="border-t pt-lg mb-lg" style="width: 80%; max-width: 400px;">
<p class="text-body-small text-secondary text-center mb-xs">당첨자 발표</p>
<p class="text-headline text-center" id="announceDate">2025-11-16 (월)</p>
</div>
<button id="confirmBtn" class="btn btn-primary btn-large">확인</button>
</div>
<script src="common.js"></script>
<script>
// 이벤트 정보 표시
const eventData = {
title: '신규고객 유치 이벤트',
prize: '커피 쿠폰',
startDate: '2025-11-01',
endDate: '2025-11-15',
announceDate: '2025-11-16 (월)',
participantCount: 128
};
document.getElementById('eventTitle').textContent = eventData.title;
document.getElementById('eventPrize').textContent = eventData.prize;
document.getElementById('eventPeriod').textContent = `${eventData.startDate} ~ ${eventData.endDate}`;
document.getElementById('participantCount').textContent = eventData.participantCount;
document.getElementById('announceDate').textContent = eventData.announceDate;
// 전화번호 자동 포맷팅
document.getElementById('participantPhone').addEventListener('input', function(e) {
e.target.value = KTEventApp.Utils.formatPhoneNumber(e.target.value);
});
// 개인정보 처리방침 보기
document.getElementById('viewPrivacy').addEventListener('click', function(e) {
e.preventDefault();
KTEventApp.Feedback.showModal({
title: '개인정보 처리방침',
content: `
<div class="p-md" style="max-height: 400px; overflow-y: auto;">
<h4 class="text-headline mb-md">개인정보 수집 및 이용 안내</h4>
<p class="text-body-small mb-lg">
<strong>1. 수집 항목</strong><br>
- 이름, 전화번호
</p>
<p class="text-body-small mb-lg">
<strong>2. 이용 목적</strong><br>
- 이벤트 참여 확인<br>
- 당첨자 발표 및 경품 제공<br>
- 이벤트 관련 안내
</p>
<p class="text-body-small mb-lg">
<strong>3. 보유 기간</strong><br>
- 이벤트 종료 후 6개월
</p>
<p class="text-body-small">
<strong>4. 동의 거부 권리</strong><br>
개인정보 수집 및 이용에 동의하지 않을 권리가 있으나, 동의하지 않을 경우 이벤트 참여가 제한됩니다.
</p>
</div>
`,
buttons: [
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
}
]
});
});
// 폼 검증
function validateForm() {
let isValid = true;
// 이름 검증
const name = document.getElementById('participantName').value.trim();
const nameError = document.getElementById('nameError');
if (name.length < 2) {
nameError.textContent = '이름은 2자 이상 입력해주세요';
isValid = false;
} else {
nameError.textContent = '';
}
// 전화번호 검증
const phone = document.getElementById('participantPhone').value;
const phoneError = document.getElementById('phoneError');
const phonePattern = /^010-\d{4}-\d{4}$/;
if (!phonePattern.test(phone)) {
phoneError.textContent = '올바른 전화번호 형식이 아닙니다 (010-0000-0000)';
isValid = false;
} else {
phoneError.textContent = '';
}
// 개인정보 동의 검증
const agreePrivacy = document.getElementById('agreePrivacy').checked;
if (!agreePrivacy) {
KTEventApp.Feedback.showToast('개인정보 수집 및 이용에 동의해주세요');
isValid = false;
}
return isValid;
}
// 중복 참여 체크
function checkDuplicateParticipation(phone) {
// 실제로는 서버에서 체크
const participatedPhones = KTEventApp.Utils.getFromStorage('participated_phones') || [];
return participatedPhones.includes(phone);
}
// 참여 처리
document.getElementById('submitBtn').addEventListener('click', function() {
if (!validateForm()) {
return;
}
const name = document.getElementById('participantName').value.trim();
const phone = document.getElementById('participantPhone').value;
// 중복 참여 체크
if (checkDuplicateParticipation(phone)) {
KTEventApp.Feedback.showToast('이미 참여하셨습니다');
return;
}
// 로딩 표시
const button = this;
button.disabled = true;
button.innerHTML = '<span class="spinner-small"></span> 참여 중...';
// 참여 처리 시뮬레이션
setTimeout(() => {
// 참여 정보 저장
const participatedPhones = KTEventApp.Utils.getFromStorage('participated_phones') || [];
participatedPhones.push(phone);
KTEventApp.Utils.saveToStorage('participated_phones', participatedPhones);
// 성공 애니메이션 표시
document.getElementById('participantNameDisplay').textContent = name;
document.getElementById('successAnimation').classList.add('active');
// 버튼 복원
button.disabled = false;
button.innerHTML = '참여하기';
}, 1000);
});
// 확인 버튼 (성공 화면 닫기)
document.getElementById('confirmBtn').addEventListener('click', function() {
// 실제로는 이벤트 상세 페이지나 메인으로 이동
window.location.reload();
});
</script>
</body>
</html>

View File

@ -0,0 +1,536 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>당첨자 추첨 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<style>
body {
background: var(--color-gray-50);
}
.drawing-container {
max-width: 1200px;
margin: 0 auto;
padding: var(--spacing-lg);
}
.winner-count-control {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
}
.winner-count-btn {
width: 48px;
height: 48px;
border: 1px solid var(--color-gray-300);
background: white;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: var(--color-text-primary);
}
.winner-count-btn:hover {
background: var(--color-gray-50);
}
.winner-count-display {
width: 80px;
text-align: center;
font-size: 32px;
font-weight: 600;
color: var(--color-text-primary);
}
.drawing-animation {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
align-items: center;
justify-content: center;
flex-direction: column;
z-index: 9999;
}
.drawing-animation.active {
display: flex;
}
.slot-machine {
font-size: 64px;
color: white;
animation: spin 0.5s infinite;
}
@keyframes spin {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(180deg); }
}
.winner-card {
position: relative;
padding-left: 60px;
}
.rank-badge {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
}
.rank-1 { background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); color: white; }
.rank-2 { background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%); color: white; }
.rank-3 { background: linear-gradient(135deg, #CD7F32 0%, #B87333 100%); color: white; }
.rank-other { background: var(--color-gray-200); color: var(--color-text-secondary); }
.results-view {
display: none;
}
.results-view.active {
display: block;
}
</style>
</head>
<body>
<div class="drawing-container">
<!-- Setup View (Before Drawing) -->
<div id="setupView">
<!-- Event Info -->
<section class="mb-lg">
<div class="card">
<div class="flex items-center gap-sm mb-md">
<span class="material-icons text-kt-red">event_note</span>
<h3 class="text-headline">이벤트 정보</h3>
</div>
<div class="mb-sm">
<p class="text-caption text-secondary mb-xs">이벤트명</p>
<p class="text-body" id="eventName">신규고객 유치 이벤트</p>
</div>
<div class="mb-sm">
<p class="text-caption text-secondary mb-xs">총 참여자</p>
<p class="text-headline" id="totalParticipants">127명</p>
</div>
<div>
<p class="text-caption text-secondary mb-xs">추첨 상태</p>
<p class="text-body" id="drawingStatus">추첨 전</p>
</div>
</div>
</section>
<!-- Drawing Settings -->
<section class="mb-lg">
<div class="card">
<div class="flex items-center gap-sm mb-md">
<span class="material-icons text-kt-red">tune</span>
<h3 class="text-headline">추첨 설정</h3>
</div>
<div class="form-group">
<label class="form-label">당첨 인원</label>
<div class="winner-count-control">
<button class="winner-count-btn" id="decreaseBtn">-</button>
<div class="winner-count-display" id="winnerCount">5</div>
<button class="winner-count-btn" id="increaseBtn">+</button>
</div>
</div>
<div class="form-check">
<input type="checkbox" id="storeBonus" class="form-check-input">
<label for="storeBonus" class="form-check-label">
매장 방문 고객 가산점 (가중치: 1.5배)
</label>
</div>
<div class="mt-md p-md" style="background: var(--color-gray-50); border-radius: var(--radius-sm);">
<p class="text-body-small text-secondary mb-xs">
<span class="material-icons" style="font-size: 16px; vertical-align: middle;">info</span>
추첨 방식
</p>
<p class="text-body-small">• 난수 기반 무작위 추첨</p>
<p class="text-body-small">• 모든 추첨 과정은 자동 기록됩니다</p>
</div>
</div>
</section>
<!-- Drawing Start Button -->
<section class="mb-lg">
<button id="startDrawingBtn" class="btn btn-primary btn-large btn-full">
<span class="material-icons">casino</span>
추첨 시작
</button>
</section>
<!-- Drawing History -->
<section class="mb-lg">
<h3 class="text-headline mb-md">📜 추첨 이력 (최근 3건)</h3>
<div id="historyList">
<!-- History items will be dynamically loaded -->
</div>
</section>
</div>
<!-- Results View (After Drawing) -->
<div id="resultsView" class="results-view">
<!-- Results Header -->
<section class="mb-lg text-center">
<h2 class="text-title-large mb-sm">🎉 추첨 완료!</h2>
<p class="text-headline"><span id="totalCount">127</span>명 중 <span id="winnerCountDisplay">5</span>명 당첨</p>
</section>
<!-- Winner List -->
<section class="mb-lg">
<h3 class="text-headline mb-md">🏆 당첨자 목록</h3>
<div id="winnerList">
<!-- Winners will be dynamically loaded -->
</div>
<div class="mt-md text-center">
<p class="text-body-small text-secondary">🌟 매장 방문 고객 가산점 적용</p>
</div>
</section>
<!-- Action Buttons -->
<section class="mb-lg">
<div class="grid grid-cols-2 gap-sm mb-sm">
<button id="excelBtn" class="btn btn-secondary btn-large">
<span class="material-icons">download</span>
엑셀다운로드
</button>
<button id="redrawBtn" class="btn btn-text btn-large">
<span class="material-icons">refresh</span>
재추첨
</button>
</div>
<button id="notifyBtn" class="btn btn-primary btn-large btn-full">
당첨자에게 알림 전송
</button>
</section>
<!-- Back to Events -->
<section class="mb-lg">
<button id="backToEventsBtn" class="btn btn-text btn-large btn-full">
이벤트 목록으로
</button>
</section>
</div>
</div>
<!-- Drawing Animation -->
<div id="drawingAnimation" class="drawing-animation">
<div class="slot-machine mb-lg">🎰</div>
<h2 class="text-title-large mb-sm" style="color: white;" id="animationText">추첨 중...</h2>
<p class="text-body" style="color: rgba(255,255,255,0.7);" id="animationSubtext">난수 생성 중</p>
</div>
<script src="common.js"></script>
<script>
// Event data
const eventData = {
name: '신규고객 유치 이벤트',
totalParticipants: 127,
participants: [
{ id: '00042', name: '김**', phone: '010-****-1234', channel: '우리동네TV', hasBonus: true },
{ id: '00089', name: '이**', phone: '010-****-5678', channel: 'SNS', hasBonus: false },
{ id: '00103', name: '박**', phone: '010-****-9012', channel: '링고비즈', hasBonus: true },
{ id: '00012', name: '최**', phone: '010-****-3456', channel: 'SNS', hasBonus: false },
{ id: '00067', name: '정**', phone: '010-****-7890', channel: '우리동네TV', hasBonus: false },
{ id: '00025', name: '강**', phone: '010-****-2468', channel: '링고비즈', hasBonus: true },
{ id: '00078', name: '조**', phone: '010-****-1357', channel: 'SNS', hasBonus: false }
]
};
// Drawing history data
const drawingHistory = [
{ date: '2025-01-15 14:30', winnerCount: 5, isRedraw: false },
{ date: '2025-01-15 14:25', winnerCount: 5, isRedraw: true }
];
// State
let winnerCount = 5;
let storeBonus = false;
let winners = [];
// Initialize
document.getElementById('eventName').textContent = eventData.name;
document.getElementById('totalParticipants').textContent = `${eventData.totalParticipants}명`;
// Winner count controls
document.getElementById('decreaseBtn').addEventListener('click', function() {
if (winnerCount > 1) {
winnerCount--;
document.getElementById('winnerCount').textContent = winnerCount;
}
});
document.getElementById('increaseBtn').addEventListener('click', function() {
if (winnerCount < 100 && winnerCount < eventData.totalParticipants) {
winnerCount++;
document.getElementById('winnerCount').textContent = winnerCount;
}
});
// Store bonus toggle
document.getElementById('storeBonus').addEventListener('change', function() {
storeBonus = this.checked;
});
// Start drawing button
document.getElementById('startDrawingBtn').addEventListener('click', function() {
KTEventApp.Feedback.showModal({
title: '추첨 확인',
content: `<p class="text-body text-center">총 ${eventData.totalParticipants}명 중 ${winnerCount}명을 추첨하시겠습니까?</p>`,
buttons: [
{
text: '취소',
variant: 'text',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
},
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
executeDrawing();
}
}
]
});
});
// Execute drawing
function executeDrawing() {
const animation = document.getElementById('drawingAnimation');
const animationText = document.getElementById('animationText');
const animationSubtext = document.getElementById('animationSubtext');
animation.classList.add('active');
// Phase 1: 난수 생성 중 (1 second)
setTimeout(() => {
animationText.textContent = '당첨자 선정 중...';
animationSubtext.textContent = '공정한 추첨을 진행하고 있습니다';
}, 1000);
// Phase 2: 완료 (2 seconds)
setTimeout(() => {
animationText.textContent = '완료!';
animationSubtext.textContent = '추첨이 완료되었습니다';
}, 2000);
// Phase 3: Show results (3 seconds)
setTimeout(() => {
animation.classList.remove('active');
// Select random winners
const shuffled = [...eventData.participants].sort(() => Math.random() - 0.5);
winners = shuffled.slice(0, winnerCount);
// Show results
showResults();
}, 3000);
}
// Show results
function showResults() {
document.getElementById('setupView').style.display = 'none';
document.getElementById('resultsView').classList.add('active');
document.getElementById('totalCount').textContent = eventData.totalParticipants;
document.getElementById('winnerCountDisplay').textContent = winnerCount;
renderWinners();
}
// Render winners
function renderWinners() {
const winnerList = document.getElementById('winnerList');
winnerList.innerHTML = '';
winners.forEach((winner, index) => {
const rank = index + 1;
const rankClass = rank === 1 ? 'rank-1' : rank === 2 ? 'rank-2' : rank === 3 ? 'rank-3' : 'rank-other';
const card = document.createElement('div');
card.className = 'card mb-sm winner-card';
card.innerHTML = `
<div class="rank-badge ${rankClass}">${rank}위</div>
<div>
<p class="text-caption text-secondary mb-xs">응모번호: #${winner.id}</p>
<p class="text-headline mb-xs">${winner.name} (${winner.phone})</p>
<p class="text-body-small text-secondary">
참여: ${winner.channel} ${winner.hasBonus && storeBonus ? '🌟' : ''}
</p>
</div>
`;
winnerList.appendChild(card);
});
}
// Excel download button
document.getElementById('excelBtn')?.addEventListener('click', function() {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}`;
const filename = `당첨자목록_${eventData.name}_${dateStr}.xlsx`;
KTEventApp.Feedback.showToast(`${filename} 다운로드를 시작합니다`);
// 실제로는 엑셀 파일 생성 및 다운로드 로직
});
// Redraw button
document.getElementById('redrawBtn')?.addEventListener('click', function() {
KTEventApp.Feedback.showModal({
title: '재추첨 확인',
content: `
<div class="text-center">
<p class="text-body mb-md">재추첨 시 현재 당첨자 정보가 변경됩니다.</p>
<p class="text-body mb-md">계속하시겠습니까?</p>
<p class="text-body-small text-secondary">이전 추첨 이력은 보관됩니다</p>
</div>
`,
buttons: [
{
text: '취소',
variant: 'text',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
},
{
text: '재추첨',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
// Add to history
drawingHistory.unshift({
date: new Date().toLocaleString('ko-KR'),
winnerCount: winnerCount,
isRedraw: true
});
// Execute new drawing
document.getElementById('resultsView').classList.remove('active');
document.getElementById('setupView').style.display = 'block';
setTimeout(() => {
executeDrawing();
}, 500);
}
}
]
});
});
// Notify button
document.getElementById('notifyBtn')?.addEventListener('click', function() {
const cost = winnerCount * 100;
KTEventApp.Feedback.showModal({
title: '알림 전송',
content: `
<div class="text-center">
<p class="text-body mb-md">${winnerCount}명의 당첨자에게 SMS 알림을 전송하시겠습니까?</p>
<p class="text-body-small text-secondary">예상 비용: ${KTEventApp.Utils.formatNumber(cost)}원 (100원/건)</p>
</div>
`,
buttons: [
{
text: '취소',
variant: 'text',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
},
{
text: '전송',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
// Simulate sending
setTimeout(() => {
KTEventApp.Feedback.showToast('알림이 전송되었습니다');
}, 500);
}
}
]
});
});
// Back to events button
document.getElementById('backToEventsBtn')?.addEventListener('click', function() {
window.location.href = '06-이벤트목록.html';
});
// Render drawing history
function renderHistory() {
const historyList = document.getElementById('historyList');
if (drawingHistory.length === 0) {
historyList.innerHTML = `
<div class="card text-center py-lg">
<p class="text-body text-secondary">추첨 이력이 없습니다</p>
</div>
`;
return;
}
historyList.innerHTML = '';
drawingHistory.slice(0, 3).forEach(history => {
const card = document.createElement('div');
card.className = 'card mb-sm';
card.innerHTML = `
<div class="flex items-center justify-between">
<div>
<p class="text-body mb-xs">${history.date} ${history.isRedraw ? '(재추첨)' : ''}</p>
<p class="text-body-small text-secondary">당첨자 ${history.winnerCount}명</p>
</div>
<button class="btn btn-text btn-small history-detail-btn">
<span class="material-icons">visibility</span>
상세보기
</button>
</div>
`;
card.querySelector('.history-detail-btn').addEventListener('click', function() {
KTEventApp.Feedback.showModal({
title: '추첨 이력 상세',
content: `
<div class="p-md">
<p class="text-body mb-sm">추첨 일시: ${history.date}</p>
<p class="text-body mb-sm">당첨 인원: ${history.winnerCount}명</p>
<p class="text-body mb-sm">재추첨 여부: ${history.isRedraw ? '예' : '아니오'}</p>
<p class="text-body-small text-secondary mt-md">※ 당첨자 정보는 개인정보 보호를 위해 마스킹 처리됩니다</p>
</div>
`,
buttons: [
{
text: '확인',
variant: 'primary',
onClick: function() {
this.closest('.modal-backdrop').remove();
}
}
]
});
});
historyList.appendChild(card);
});
}
// Initialize history
renderHistory();
</script>
</body>
</html>

View File

@ -0,0 +1,430 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>실시간 대시보드 - KT AI 이벤트 마케팅</title>
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css">
<style>
.summary-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-sm);
}
@media (min-width: 1024px) {
.summary-cards {
grid-template-columns: repeat(4, 1fr);
}
}
.summary-card {
background: white;
border-radius: var(--radius-md);
padding: var(--spacing-md);
text-align: center;
box-shadow: var(--shadow-sm);
}
.summary-value {
font-size: 28px;
font-weight: 700;
color: var(--color-text-primary);
margin: var(--spacing-sm) 0;
}
.summary-value.positive {
color: var(--color-success);
}
.chart-placeholder {
width: 100%;
aspect-ratio: 1 / 1;
background: var(--color-gray-50);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: var(--spacing-lg);
}
.line-chart-placeholder {
aspect-ratio: 16 / 9;
}
.chart-legend {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
margin-top: var(--spacing-md);
}
.legend-item {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.roi-table {
width: 100%;
border-collapse: collapse;
}
.roi-table td {
padding: var(--spacing-sm);
border-bottom: 1px solid var(--color-gray-200);
}
.roi-table td:first-child {
color: var(--color-text-secondary);
}
.roi-table td:last-child {
text-align: right;
font-weight: 600;
}
.profile-bars {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.profile-bar {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.profile-label {
min-width: 60px;
font-size: 14px;
}
.profile-bar-bg {
flex: 1;
height: 24px;
background: var(--color-gray-100);
border-radius: var(--radius-sm);
position: relative;
overflow: hidden;
}
.profile-bar-fill {
height: 100%;
background: var(--color-ai-blue);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: var(--spacing-xs);
color: white;
font-size: 12px;
font-weight: 600;
}
.refresh-indicator {
display: inline-flex;
align-items: center;
gap: var(--spacing-xs);
color: var(--color-text-tertiary);
font-size: 12px;
}
.update-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-success);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
</style>
</head>
<body>
<div class="page page-with-header">
<!-- Header -->
<div id="header"></div>
<div class="container" style="max-width: 1200px;">
<!-- Title with Real-time Indicator -->
<section class="mt-lg mb-md flex items-center justify-between">
<h2 class="text-title">📊 요약 (실시간)</h2>
<div class="refresh-indicator">
<div class="update-pulse"></div>
<span id="lastUpdate">방금 전</span>
</div>
</section>
<!-- Summary KPI Cards -->
<section class="mb-lg">
<div class="summary-cards">
<div class="summary-card">
<p class="text-body-small text-secondary">참여자 수</p>
<div class="summary-value">128명</div>
<p class="text-caption text-success">↑ 12명 (오늘)</p>
</div>
<div class="summary-card">
<p class="text-body-small text-secondary">총 비용</p>
<div class="summary-value">30만원</div>
<p class="text-caption text-secondary">경품 25만 + 채널 5만</p>
</div>
<div class="summary-card">
<p class="text-body-small text-secondary">예상 수익</p>
<div class="summary-value positive">135만원</div>
<p class="text-caption text-success">매출증가 100만 + LTV 35만</p>
</div>
<div class="summary-card">
<p class="text-body-small text-secondary">투자대비수익률</p>
<div class="summary-value positive">450%</div>
<p class="text-caption text-success">목표 300% 달성</p>
</div>
</div>
</section>
<!-- Charts Grid -->
<div class="grid desktop:grid-cols-2 gap-lg mb-lg">
<!-- Channel Performance -->
<section>
<div class="card">
<div class="flex items-center gap-sm mb-md">
<span class="material-icons text-kt-red">pie_chart</span>
<h3 class="text-headline">채널별 성과</h3>
</div>
<div class="chart-placeholder">
<span class="material-icons" style="font-size: 64px; color: var(--color-gray-300);">donut_large</span>
<p class="text-body-small text-secondary mt-md">파이 차트</p>
</div>
<div class="chart-legend">
<div class="legend-item">
<div class="legend-dot" style="background: #E31E24;"></div>
<span class="text-body-small flex-1">우리동네TV</span>
<span class="text-body-small text-semibold">45% (58명)</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background: #0066FF;"></div>
<span class="text-body-small flex-1">링고비즈</span>
<span class="text-body-small text-semibold">30% (38명)</span>
</div>
<div class="legend-item">
<div class="legend-dot" style="background: #FFB800;"></div>
<span class="text-body-small flex-1">SNS</span>
<span class="text-body-small text-semibold">25% (32명)</span>
</div>
</div>
</div>
</section>
<!-- Time Trend -->
<section>
<div class="card">
<div class="flex items-center gap-sm mb-md">
<span class="material-icons text-kt-red">show_chart</span>
<h3 class="text-headline">시간대별 참여 추이</h3>
</div>
<div class="chart-placeholder line-chart-placeholder">
<span class="material-icons" style="font-size: 64px; color: var(--color-gray-300);">trending_up</span>
<p class="text-body-small text-secondary mt-md">라인 차트</p>
</div>
<div class="mt-md">
<p class="text-body-small text-secondary">피크 시간: 오후 2-4시 (35명)</p>
<p class="text-body-small text-secondary">평균 시간당: 8명</p>
</div>
</div>
</section>
</div>
<!-- ROI Detail & Participant Profile -->
<div class="grid desktop:grid-cols-2 gap-lg mb-2xl">
<!-- ROI Detail -->
<section>
<div class="card">
<div class="flex items-center gap-sm mb-md">
<span class="material-icons text-kt-red">payments</span>
<h3 class="text-headline">투자대비수익률 상세</h3>
</div>
<table class="roi-table">
<tbody>
<tr>
<td colspan="2" class="text-headline">총 비용: 30만원</td>
</tr>
<tr>
<td>• 경품 비용</td>
<td>25만원</td>
</tr>
<tr>
<td>• 채널 비용</td>
<td>5만원</td>
</tr>
<tr style="height: var(--spacing-md);"></tr>
<tr>
<td colspan="2" class="text-headline">예상 수익: 135만원</td>
</tr>
<tr>
<td>• 매출 증가</td>
<td class="text-success">100만원</td>
</tr>
<tr>
<td>• 신규 고객 LTV</td>
<td class="text-success">35만원</td>
</tr>
<tr style="height: var(--spacing-md);"></tr>
<tr>
<td colspan="2" class="text-headline text-success">투자대비수익률</td>
</tr>
<tr>
<td colspan="2">
<div class="p-md" style="background: var(--color-gray-50); border-radius: var(--radius-sm);">
<p class="text-body-small text-center mb-xs">(수익 - 비용) ÷ 비용 × 100</p>
<p class="text-body text-center">(135만 - 30만) ÷ 30만 × 100</p>
<p class="text-headline text-center text-success mt-sm">= 450%</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Participant Profile -->
<section>
<div class="card">
<div class="flex items-center gap-sm mb-md">
<span class="material-icons text-kt-red">people</span>
<h3 class="text-headline">참여자 프로필</h3>
</div>
<div class="mb-lg">
<p class="text-body mb-sm">연령별</p>
<div class="profile-bars">
<div class="profile-bar">
<span class="profile-label">20대</span>
<div class="profile-bar-bg">
<div class="profile-bar-fill" style="width: 35%;">35%</div>
</div>
</div>
<div class="profile-bar">
<span class="profile-label">30대</span>
<div class="profile-bar-bg">
<div class="profile-bar-fill" style="width: 40%;">40%</div>
</div>
</div>
<div class="profile-bar">
<span class="profile-label">40대</span>
<div class="profile-bar-bg">
<div class="profile-bar-fill" style="width: 25%;">25%</div>
</div>
</div>
</div>
</div>
<div>
<p class="text-body mb-sm">성별</p>
<div class="profile-bars">
<div class="profile-bar">
<span class="profile-label">여성</span>
<div class="profile-bar-bg">
<div class="profile-bar-fill" style="width: 60%; background: var(--color-kt-red);">60%</div>
</div>
</div>
<div class="profile-bar">
<span class="profile-label">남성</span>
<div class="profile-bar-bg">
<div class="profile-bar-fill" style="width: 40%; background: var(--color-kt-red);">40%</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- Bottom Navigation -->
<div id="bottomNav"></div>
</div>
<script src="common.js"></script>
<script>
// 로그인 확인
KTEventApp.Session.requireAuth();
// Header 생성
const header = KTEventApp.Navigation.createHeader({
title: '실시간 대시보드',
showBack: true,
showMenu: false,
showProfile: true
});
document.getElementById('header').appendChild(header);
// Bottom Navigation 생성
const bottomNav = KTEventApp.Navigation.createBottomNav('analytics');
document.getElementById('bottomNav').appendChild(bottomNav);
// Real-time update simulation (every 5 minutes)
let updateInterval = null;
let lastUpdateTime = new Date();
function updateLastUpdateTime() {
const now = new Date();
const diff = Math.floor((now - lastUpdateTime) / 1000);
let timeText;
if (diff < 60) {
timeText = '방금 전';
} else if (diff < 3600) {
timeText = `${Math.floor(diff / 60)}분 전`;
} else {
timeText = `${Math.floor(diff / 3600)}시간 전`;
}
document.getElementById('lastUpdate').textContent = timeText;
}
function simulateUpdate() {
// Simulate data update
lastUpdateTime = new Date();
updateLastUpdateTime();
// Show toast notification
KTEventApp.Feedback.showToast('데이터가 업데이트되었습니다');
}
// Update time display every 30 seconds
setInterval(updateLastUpdateTime, 30000);
// Simulate data update every 5 minutes (300000ms)
updateInterval = setInterval(simulateUpdate, 300000);
// Initial update
updateLastUpdateTime();
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (updateInterval) {
clearInterval(updateInterval);
}
});
// Pull to refresh (Mobile)
let touchStartY = 0;
let isPulling = false;
document.addEventListener('touchstart', function(e) {
if (window.scrollY === 0) {
touchStartY = e.touches[0].clientY;
isPulling = true;
}
});
document.addEventListener('touchmove', function(e) {
if (!isPulling) return;
const touchY = e.touches[0].clientY;
const pullDistance = touchY - touchStartY;
if (pullDistance > 80) {
isPulling = false;
simulateUpdate();
}
});
document.addEventListener('touchend', function() {
isPulling = false;
});
</script>
</body>
</html>

1071
design/uiux/prototype/common.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,973 @@
/* =================================================================
KT AI 이벤트 마케팅 서비스 - 공통 스타일시트
Version: 1.0.0
Mobile First Design Philosophy
================================================================= */
/* =================================================================
1. CSS Variables - Design System
================================================================= */
:root {
/* Brand Colors */
--color-kt-red: #E31E24;
--color-ai-blue: #0066FF;
/* Grayscale */
--color-gray-50: #F9FAFB;
--color-gray-100: #F3F4F6;
--color-gray-200: #E5E7EB;
--color-gray-300: #D1D5DB;
--color-gray-400: #9CA3AF;
--color-gray-500: #6B7280;
--color-gray-600: #4B5563;
--color-gray-700: #374151;
--color-gray-800: #1F2937;
--color-gray-900: #111827;
/* Semantic Colors */
--color-success: #10B981;
--color-warning: #F59E0B;
--color-error: #EF4444;
--color-info: #3B82F6;
/* Background */
--color-bg-primary: #FFFFFF;
--color-bg-secondary: #F9FAFB;
--color-bg-tertiary: #F3F4F6;
/* Text */
--color-text-primary: #111827;
--color-text-secondary: #4B5563;
--color-text-tertiary: #9CA3AF;
--color-text-inverse: #FFFFFF;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #E31E24 0%, #B71419 100%);
--gradient-ai: linear-gradient(135deg, #0066FF 0%, #0052CC 100%);
/* Typography Scale */
--font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-size-display: 28px;
--font-size-title-large: 24px;
--font-size-title: 20px;
--font-size-headline: 18px;
--font-size-body-large: 16px;
--font-size-body: 14px;
--font-size-body-small: 13px;
--font-size-caption: 12px;
--line-height-display: 1.2;
--line-height-title: 1.3;
--line-height-body: 1.5;
--line-height-caption: 1.4;
--font-weight-bold: 700;
--font-weight-semibold: 600;
--font-weight-medium: 500;
--font-weight-regular: 400;
/* Spacing System (4px grid) */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.15);
/* Border Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-full: 9999px;
/* Z-Index */
--z-dropdown: 1000;
--z-sticky: 1020;
--z-fixed: 1030;
--z-modal-backdrop: 1040;
--z-modal: 1050;
--z-popover: 1060;
--z-tooltip: 1070;
/* Animation */
--duration-instant: 0ms;
--duration-fast: 150ms;
--duration-normal: 300ms;
--duration-slow: 500ms;
--ease-in: cubic-bezier(0.4, 0, 1, 1);
--ease-out: cubic-bezier(0, 0, 0.2, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Tablet Responsive Variables */
@media (min-width: 768px) {
:root {
--font-size-display: 32px;
--font-size-title-large: 28px;
--font-size-title: 24px;
--font-size-headline: 20px;
}
}
/* Desktop Responsive Variables */
@media (min-width: 1024px) {
:root {
--font-size-display: 36px;
--font-size-title-large: 32px;
--font-size-title: 28px;
--font-size-headline: 22px;
}
}
/* =================================================================
2. Reset & Base Styles
================================================================= */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font-family);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
overflow-x: hidden;
}
/* =================================================================
3. Typography
================================================================= */
.text-display {
font-size: var(--font-size-display);
line-height: var(--line-height-display);
font-weight: var(--font-weight-bold);
}
.text-title-large {
font-size: var(--font-size-title-large);
line-height: var(--line-height-title);
font-weight: var(--font-weight-bold);
}
.text-title {
font-size: var(--font-size-title);
line-height: var(--line-height-title);
font-weight: var(--font-weight-semibold);
}
.text-headline {
font-size: var(--font-size-headline);
line-height: var(--line-height-title);
font-weight: var(--font-weight-semibold);
}
.text-body-large {
font-size: var(--font-size-body-large);
line-height: var(--line-height-body);
font-weight: var(--font-weight-regular);
}
.text-body {
font-size: var(--font-size-body);
line-height: var(--line-height-body);
font-weight: var(--font-weight-regular);
}
.text-body-small {
font-size: var(--font-size-body-small);
line-height: var(--line-height-body);
font-weight: var(--font-weight-regular);
}
.text-caption {
font-size: var(--font-size-caption);
line-height: var(--line-height-caption);
font-weight: var(--font-weight-regular);
}
.text-primary { color: var(--color-text-primary); }
.text-secondary { color: var(--color-text-secondary); }
.text-tertiary { color: var(--color-text-tertiary); }
.text-inverse { color: var(--color-text-inverse); }
.text-kt-red { color: var(--color-kt-red); }
.text-ai-blue { color: var(--color-ai-blue); }
.text-success { color: var(--color-success); }
.text-warning { color: var(--color-warning); }
.text-error { color: var(--color-error); }
.text-bold { font-weight: var(--font-weight-bold); }
.text-semibold { font-weight: var(--font-weight-semibold); }
.text-medium { font-weight: var(--font-weight-medium); }
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
/* =================================================================
4. Layout Utilities
================================================================= */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.page {
min-height: 100vh;
background-color: var(--color-bg-secondary);
padding-bottom: 76px; /* Bottom nav height + spacing */
}
.page-with-header {
padding-top: 56px; /* Header height */
}
/* =================================================================
5. Button Components
================================================================= */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-family);
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
text-decoration: none;
user-select: none;
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Button Sizes */
.btn-large {
height: 48px;
padding: 0 var(--spacing-lg);
font-size: var(--font-size-body-large);
}
.btn-medium {
height: 44px;
padding: 0 var(--spacing-md);
font-size: var(--font-size-body);
}
.btn-small {
height: 36px;
padding: 0 var(--spacing-md);
font-size: var(--font-size-body-small);
}
/* Button Variants */
.btn-primary {
background: var(--gradient-primary);
color: var(--color-text-inverse);
box-shadow: var(--shadow-sm);
}
.btn-primary:hover:not(:disabled) {
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow-sm);
}
.btn-secondary {
background-color: var(--color-bg-primary);
color: var(--color-kt-red);
border: 1px solid var(--color-gray-300);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--color-gray-50);
border-color: var(--color-kt-red);
}
.btn-text {
background-color: transparent;
color: var(--color-kt-red);
}
.btn-text:hover:not(:disabled) {
background-color: rgba(227, 30, 36, 0.08);
}
.btn-full {
width: 100%;
}
/* =================================================================
6. Card Components
================================================================= */
.card {
background-color: var(--color-bg-primary);
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--duration-fast) var(--ease-out);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-clickable {
cursor: pointer;
}
.card-clickable:active {
transform: scale(0.98);
}
.event-card {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.event-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-sm);
}
.event-card-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: var(--radius-full);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
}
.badge-active {
background-color: rgba(16, 185, 129, 0.1);
color: var(--color-success);
}
.badge-scheduled {
background-color: rgba(59, 130, 246, 0.1);
color: var(--color-info);
}
.badge-ended {
background-color: rgba(156, 163, 175, 0.1);
color: var(--color-gray-500);
}
.event-card-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-md);
padding-top: var(--spacing-md);
border-top: 1px solid var(--color-gray-200);
}
.stat-item {
text-align: center;
}
.stat-label {
font-size: var(--font-size-caption);
color: var(--color-text-tertiary);
margin-bottom: 4px;
}
.stat-value {
font-size: var(--font-size-headline);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
/* KPI Card */
.kpi-card {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
}
.kpi-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: 24px;
}
.kpi-icon-primary {
background: rgba(227, 30, 36, 0.1);
color: var(--color-kt-red);
}
.kpi-icon-ai {
background: rgba(0, 102, 255, 0.1);
color: var(--color-ai-blue);
}
.kpi-icon-success {
background: rgba(16, 185, 129, 0.1);
color: var(--color-success);
}
.kpi-content {
flex: 1;
}
.kpi-label {
font-size: var(--font-size-body-small);
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.kpi-value {
font-size: var(--font-size-title);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
/* Option Card for Selection */
.option-card {
position: relative;
border: 2px solid var(--color-gray-200);
transition: all var(--duration-fast) var(--ease-out);
}
.option-card:hover {
border-color: var(--color-kt-red);
}
.option-card.selected {
border-color: var(--color-kt-red);
background-color: rgba(227, 30, 36, 0.02);
}
.option-card-radio {
position: absolute;
top: var(--spacing-md);
right: var(--spacing-md);
}
/* =================================================================
7. Form Components
================================================================= */
.form-group {
margin-bottom: var(--spacing-lg);
}
.form-label {
display: block;
font-size: var(--font-size-body);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin-bottom: var(--spacing-sm);
}
.form-label-required::after {
content: " *";
color: var(--color-error);
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 12px var(--spacing-md);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
font-family: var(--font-family);
font-size: var(--font-size-body);
color: var(--color-text-primary);
background-color: var(--color-bg-primary);
transition: all var(--duration-fast) var(--ease-out);
}
.form-input::placeholder,
.form-textarea::placeholder {
color: var(--color-text-tertiary);
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--color-kt-red);
box-shadow: 0 0 0 3px rgba(227, 30, 36, 0.1);
}
.form-input:disabled,
.form-select:disabled,
.form-textarea:disabled {
background-color: var(--color-gray-100);
cursor: not-allowed;
}
.form-textarea {
min-height: 100px;
resize: vertical;
}
.form-error {
display: block;
margin-top: var(--spacing-sm);
font-size: var(--font-size-body-small);
color: var(--color-error);
}
.form-hint {
display: block;
margin-top: var(--spacing-sm);
font-size: var(--font-size-body-small);
color: var(--color-text-tertiary);
}
/* Checkbox & Radio */
.form-check {
display: flex;
align-items: center;
gap: var(--spacing-sm);
cursor: pointer;
}
.form-check-input {
width: 20px;
height: 20px;
cursor: pointer;
}
.form-check-label {
font-size: var(--font-size-body);
color: var(--color-text-primary);
cursor: pointer;
}
/* =================================================================
8. Navigation Components
================================================================= */
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
background-color: var(--color-bg-primary);
border-bottom: 1px solid var(--color-gray-200);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--spacing-md);
z-index: var(--z-sticky);
}
.header-left,
.header-right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.header-title {
font-size: var(--font-size-headline);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.header-icon-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--color-text-primary);
cursor: pointer;
border-radius: var(--radius-md);
transition: background-color var(--duration-fast) var(--ease-out);
}
.header-icon-btn:hover {
background-color: var(--color-gray-100);
}
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background-color: var(--color-bg-primary);
border-top: 1px solid var(--color-gray-200);
display: flex;
align-items: center;
justify-content: space-around;
padding: 0 var(--spacing-sm);
z-index: var(--z-sticky);
}
.bottom-nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: var(--spacing-sm);
color: var(--color-text-tertiary);
text-decoration: none;
font-size: var(--font-size-caption);
transition: color var(--duration-fast) var(--ease-out);
cursor: pointer;
}
.bottom-nav-item:hover {
color: var(--color-text-secondary);
}
.bottom-nav-item.active {
color: var(--color-kt-red);
}
.bottom-nav-icon {
font-size: 24px;
}
/* FAB (Floating Action Button) */
.fab {
position: fixed;
bottom: 76px; /* Bottom nav height + spacing */
right: var(--spacing-md);
width: 56px;
height: 56px;
background: var(--gradient-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-full);
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
cursor: pointer;
transition: all var(--duration-fast) var(--ease-out);
z-index: var(--z-fixed);
}
.fab:hover {
box-shadow: var(--shadow-xl);
transform: scale(1.05);
}
.fab:active {
transform: scale(0.95);
}
/* =================================================================
9. Feedback Components
================================================================= */
/* Toast */
.toast {
position: fixed;
bottom: 92px; /* Bottom nav + spacing */
left: 50%;
transform: translateX(-50%);
padding: var(--spacing-md) var(--spacing-lg);
background-color: var(--color-gray-900);
color: var(--color-text-inverse);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
font-size: var(--font-size-body);
z-index: var(--z-tooltip);
animation: slideUp var(--duration-normal) var(--ease-out);
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, 20px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
/* Modal */
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: var(--z-modal-backdrop);
animation: fadeIn var(--duration-normal) var(--ease-out);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-bg-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
z-index: var(--z-modal);
max-width: 90%;
max-height: 90vh;
overflow-y: auto;
animation: scaleIn var(--duration-normal) var(--ease-out);
}
@keyframes scaleIn {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.modal-header {
padding: var(--spacing-lg);
border-bottom: 1px solid var(--color-gray-200);
}
.modal-title {
font-size: var(--font-size-title);
font-weight: var(--font-weight-semibold);
}
.modal-body {
padding: var(--spacing-lg);
}
.modal-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--color-gray-200);
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
}
/* Bottom Sheet */
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--color-bg-primary);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
box-shadow: var(--shadow-xl);
z-index: var(--z-modal);
max-height: 80vh;
overflow-y: auto;
animation: slideUpSheet var(--duration-normal) var(--ease-out);
}
@keyframes slideUpSheet {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.bottom-sheet-handle {
width: 40px;
height: 4px;
background-color: var(--color-gray-300);
border-radius: var(--radius-full);
margin: var(--spacing-sm) auto;
}
.bottom-sheet-content {
padding: var(--spacing-lg);
}
/* Spinner */
.spinner {
width: 40px;
height: 40px;
border: 4px solid var(--color-gray-200);
border-top-color: var(--color-kt-red);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner-center {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-2xl);
}
/* Progress Bar */
.progress {
width: 100%;
height: 8px;
background-color: var(--color-gray-200);
border-radius: var(--radius-full);
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--gradient-primary);
border-radius: var(--radius-full);
transition: width var(--duration-normal) var(--ease-out);
}
/* =================================================================
10. Utility Classes
================================================================= */
/* Spacing */
.m-0 { margin: 0; }
.mt-xs { margin-top: var(--spacing-xs); }
.mt-sm { margin-top: var(--spacing-sm); }
.mt-md { margin-top: var(--spacing-md); }
.mt-lg { margin-top: var(--spacing-lg); }
.mt-xl { margin-top: var(--spacing-xl); }
.mt-2xl { margin-top: var(--spacing-2xl); }
.mb-xs { margin-bottom: var(--spacing-xs); }
.mb-sm { margin-bottom: var(--spacing-sm); }
.mb-md { margin-bottom: var(--spacing-md); }
.mb-lg { margin-bottom: var(--spacing-lg); }
.mb-xl { margin-bottom: var(--spacing-xl); }
.mb-2xl { margin-bottom: var(--spacing-2xl); }
.p-0 { padding: 0; }
.p-xs { padding: var(--spacing-xs); }
.p-sm { padding: var(--spacing-sm); }
.p-md { padding: var(--spacing-md); }
.p-lg { padding: var(--spacing-lg); }
.p-xl { padding: var(--spacing-xl); }
.p-2xl { padding: var(--spacing-2xl); }
.gap-xs { gap: var(--spacing-xs); }
.gap-sm { gap: var(--spacing-sm); }
.gap-md { gap: var(--spacing-md); }
.gap-lg { gap: var(--spacing-lg); }
/* Flexbox */
.flex { display: flex; }
.flex-col { flex-direction: column; }
.flex-row { flex-direction: row; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
.flex-1 { flex: 1; }
.flex-wrap { flex-wrap: wrap; }
/* Grid */
.grid { display: grid; }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
/* Display */
.hidden { display: none; }
.block { display: block; }
.inline-block { display: inline-block; }
/* Width */
.w-full { width: 100%; }
.w-auto { width: auto; }
/* Background */
.bg-primary { background-color: var(--color-bg-primary); }
.bg-secondary { background-color: var(--color-bg-secondary); }
.bg-tertiary { background-color: var(--color-bg-tertiary); }
/* Border */
.border { border: 1px solid var(--color-gray-200); }
.border-t { border-top: 1px solid var(--color-gray-200); }
.border-b { border-bottom: 1px solid var(--color-gray-200); }
.border-none { border: none; }
.rounded-sm { border-radius: var(--radius-sm); }
.rounded-md { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-xl { border-radius: var(--radius-xl); }
.rounded-full { border-radius: var(--radius-full); }
/* Shadow */
.shadow-sm { box-shadow: var(--shadow-sm); }
.shadow-md { box-shadow: var(--shadow-md); }
.shadow-lg { box-shadow: var(--shadow-lg); }
.shadow-xl { box-shadow: var(--shadow-xl); }
.shadow-none { box-shadow: none; }
/* Cursor */
.cursor-pointer { cursor: pointer; }
.cursor-not-allowed { cursor: not-allowed; }
/* =================================================================
11. Responsive Grid System
================================================================= */
@media (min-width: 768px) {
.container {
padding: 0 var(--spacing-lg);
}
.tablet\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.tablet\:grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.tablet\:grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
}
@media (min-width: 1024px) {
.container {
padding: 0 var(--spacing-xl);
}
.desktop\:grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.desktop\:grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.desktop\:grid-cols-4 { grid-template-columns: repeat(4, 1fr); }
.desktop\:grid-cols-5 { grid-template-columns: repeat(5, 1fr); }
}

1554
design/uiux/style-guide.md Normal file

File diff suppressed because it is too large Load Diff

1002
design/uiux/uiux-design.md Normal file

File diff suppressed because it is too large Load Diff

2473
design/uiux/uiux.md Normal file

File diff suppressed because it is too large Load Diff

344
design/userstory-table.md Normal file
View File

@ -0,0 +1,344 @@
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 유저스토리 목록
## 전체 유저스토리 요약
| 서비스 | ID | 유저스토리 | 우선순위 | 복잡도 |
|--------|-----|-----------|----------|--------|
| **User** | UFR-USER-010 | [회원가입] 소상공인으로서 간편하게 회원가입하고 싶다 | M | 21 |
| | UFR-USER-020 | [로그인] 소상공인으로서 간편하게 로그인하고 싶다 | M | 8 |
| | UFR-USER-030 | [프로필관리] 소상공인으로서 프로필 정보를 편집하고 싶다 | C | 8 |
| | UFR-USER-040 | [로그아웃] 소상공인으로서 안전하게 로그아웃하고 싶다 | S | 3 |
| **Event** | UFR-EVENT-010 | [대시보드] 소상공인으로서 대시보드에서 이벤트를 한눈에 확인하고 싶다 | S | 13 |
| | UFR-EVENT-020 | [이벤트목적선택] 소상공인으로서 이벤트 목적을 먼저 선택하고 싶다 | M | 5 |
| | UFR-EVENT-030 | [AI이벤트추천] 소상공인으로서 AI 트렌드 분석 결과와 함께 3가지 이벤트 추천을 확인하고 싶다 | M | 34 |
| | UFR-EVENT-040 | [배포채널선택] 소상공인으로서 다중 배포 채널을 선택하고 싶다 | M | 13 |
| | UFR-EVENT-050 | [최종승인] 소상공인으로서 모든 설정을 최종 확인하고 승인하고 싶다 | M | 13 |
| | UFR-EVENT-060 | [이벤트상세조회] 소상공인으로서 이벤트 상세 정보를 조회하고 싶다 | S | 13 |
| | UFR-EVENT-070 | [이벤트목록관리] 소상공인으로서 전체 이벤트 목록을 조회하고 필터링/검색하고 싶다 | S | 13 |
| **AI** | UFR-AI-010 | [AI트렌드분석및이벤트추천] AI 시스템으로서 업종/지역/시즌 트렌드를 분석하고 3가지 최적화된 이벤트 기획안을 생성하고 싶다 | M | 34 |
| **Content** | UFR-CONT-010 | [SNS이미지생성] 소상공인으로서 AI가 자동으로 3가지 스타일의 이미지를 생성하기를 원한다 | M | 21 |
| | UFR-CONT-020 | [콘텐츠편집] 소상공인으로서 생성된 이미지를 간단한 편집 기능으로 조정하고 싶다 | S | 13 |
| **Distribution** | UFR-DIST-010 | [다중채널배포] 소상공인으로서 원클릭으로 다중 채널 배포를 실행하고 싶다 | M | 21 |
| | UFR-DIST-020 | [배포상태모니터링] 소상공인으로서 배포 상태를 실시간으로 모니터링하고 싶다 | S | 13 |
| **Participation** | UFR-PART-010 | [이벤트참여] 고객으로서 최소한의 정보만 입력하고 이벤트에 참여하고 싶다 | M | 13 |
| | UFR-PART-020 | [참여자목록조회] 소상공인으로서 참여자 목록을 조회하고 싶다 | S | 8 |
| | UFR-PART-030 | [당첨자추첨] 소상공인으로서 자동 추첨 기능을 사용하고 싶다 | M | 13 |
| **Analytics** | UFR-ANAL-010 | [실시간통합대시보드] 소상공인으로서 모든 지표가 통합된 실시간 대시보드를 보고 싶다 | M | 34 |
---
## 우선순위별 통계
| 우선순위 | 개수 | 비율 |
|---------|------|------|
| M (필수) | 13 | 65.0% |
| S (선택) | 6 | 30.0% |
| C (조건부) | 1 | 5.0% |
| **총계** | **20** | **100%** |
---
## 서비스별 통계
| 서비스 | 유저스토리 수 | 평균 복잡도 | 필수(M) | 선택(S) | 조건부(C) |
|--------|-------------|-----------|---------|---------|----------|
| User | 4 | 10.0 | 2 | 1 | 1 |
| Event | 7 | 14.9 | 4 | 3 | 0 |
| AI | 1 | 34.0 | 1 | 0 | 0 |
| Content | 2 | 17.0 | 1 | 1 | 0 |
| Distribution | 2 | 17.0 | 1 | 1 | 0 |
| Participation | 3 | 11.3 | 2 | 1 | 0 |
| Analytics | 1 | 34.0 | 1 | 0 | 0 |
| **총계** | **20** | **16.9** | **12** | **7** | **1** |
---
## 복잡도별 통계
| 복잡도 범위 | 개수 | 유저스토리 |
|-----------|------|-----------|
| 1-5 (낮음) | 2 | UFR-USER-040, UFR-EVENT-020 |
| 6-13 (중간) | 11 | UFR-USER-020, UFR-USER-030, UFR-EVENT-010, UFR-EVENT-040, UFR-EVENT-050, UFR-EVENT-060, UFR-EVENT-070, UFR-CONT-020, UFR-DIST-020, UFR-PART-010, UFR-PART-020, UFR-PART-030 |
| 14-21 (높음) | 4 | UFR-USER-010, UFR-CONT-010, UFR-DIST-010 |
| 22+ (매우 높음) | 3 | UFR-EVENT-030, UFR-AI-010, UFR-ANAL-010 |
---
## 주요 기능별 상세 목록
### 1. User 서비스 (회원 인증 및 매장 정보 관리)
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 |
|----|------|----------|--------|-------------|
| UFR-USER-010 | 회원가입 | M | 21 | 이름, 전화번호, 이메일, 비밀번호, 매장명, 업종, 주소, 사업자번호 검증 |
| UFR-USER-020 | 로그인 | M | 8 | 전화번호/비밀번호 입력, 로그인 유지 옵션, 대시보드 이동 |
| UFR-USER-030 | 프로필관리 | C | 8 | 기본 정보, 매장 정보, 비밀번호 변경 |
| UFR-USER-040 | 로그아웃 | S | 3 | 안전한 세션 종료, 확인 다이얼로그 |
**검증 로직:**
- 사업자번호 형식 검증 (XXX-XX-XXXXX)
- 사업자번호 유효성 확인 및 휴폐업 여부 확인 (국세청 API)
- 매장명과 사업자 정보 일치 확인
**주요 흐름:**
1. UFR-USER-020 (로그인) → UFR-EVENT-010 (대시보드)
2. 신규 사용자: 회원가입(UFR-USER-010) → 대시보드(UFR-EVENT-010)
3. 대시보드에서 "새 이벤트 만들기" → UFR-EVENT-020
---
### 2. Event 서비스 (이벤트 관리)
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 |
|----|------|----------|--------|-------------|
| UFR-EVENT-010 | 대시보드 | S | 13 | 진행중/예정/종료 이벤트 목록, 통계, 새 이벤트 만들기 버튼 |
| UFR-EVENT-020 | 이벤트목적선택 | M | 5 | 신규고객 유치/재방문 유도/매출 증대/인지도 향상 선택 |
| UFR-EVENT-030 | AI이벤트추천 | M | 34 | 트렌드 분석 결과, 3가지 이벤트 추천, 제목/경품 간단 수정 |
| UFR-EVENT-040 | 배포채널선택 | M | 13 | 우리동네TV, 링고비즈, 지니TV, SNS 선택, 예상 노출 수 |
| UFR-EVENT-050 | 최종승인 | M | 13 | 이벤트 정보 확인, 승인 처리, 배포 시작 |
| UFR-EVENT-060 | 이벤트상세조회 | S | 13 | 기본 정보, 실시간 통계, 배포 현황, 참여자 목록 |
| UFR-EVENT-070 | 이벤트목록관리 | S | 13 | 전체 이벤트 목록, 필터링, 검색, 정렬 |
**이벤트 생성 플로우:**
1. 이벤트 목적 선택 (UFR-EVENT-020)
2. AI 트렌드 분석 및 이벤트 추천 (UFR-EVENT-030)
3. 콘텐츠 생성 (UFR-CONT-010)
4. 배포채널 선택 (UFR-EVENT-040)
5. 최종승인 (UFR-EVENT-050)
---
### 3. AI 서비스 (AI 분석 및 추천)
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 | AI 모델 |
|----|------|----------|--------|-------------|---------|
| UFR-AI-010 | AI트렌드분석및이벤트추천 | M | 34 | 업종/지역/시즌 트렌드 분석, 3가지 이벤트 기획안 생성, 10초 이내 완료 | Claude API / GPT-4 API |
**분석 항목:**
- 업종 트렌드: 최근 성공 이벤트, 고객 선호 경품, 효과적인 참여 방법
- 지역 특성: 지역별 이벤트 성공률, 고객 특성
- 시즌 특성: 계절/시기별 추천 이벤트
**추천 기준:**
- 옵션 1: 저비용, 높은 참여율 중심
- 옵션 2: 중비용, 균형잡힌 투자 대비 수익률
- 옵션 3: 고비용, 높은 매출 증대 효과
**성능 목표:** 전체 기획 과정 10초 이내 완료 (병렬 처리)
---
### 4. Content 서비스 (콘텐츠 생성)
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 | AI 모델 |
|----|------|----------|--------|-------------|---------|
| UFR-CONT-010 | SNS이미지생성 | M | 21 | 3가지 스타일 (심플/화려/트렌디), 플랫폼별 최적화 | Stable Diffusion / DALL-E |
| UFR-CONT-020 | 콘텐츠편집 | S | 13 | 텍스트 수정, 색상 조정, 로고 위치 변경 | - |
**플랫폼별 최적화:**
- Instagram: 1080x1080
- Naver Blog: 800x600
- Kakao Channel: 800x800
**성능 목표:**
- 이미지 생성: 30초 이내
- 실시간 미리보기 제공
---
### 5. Distribution 서비스 (다중 채널 배포)
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 | 연동 API |
|----|------|----------|--------|-------------|---------|
| UFR-DIST-010 | 다중채널배포 | M | 21 | 우리동네TV, 링고비즈, 지니TV, SNS 동시 배포, 1분 이내 완료 | 우리동네TV, 링고비즈, 지니TV, SNS APIs |
| UFR-DIST-020 | 배포상태모니터링 | S | 13 | 채널별 배포 상태, 진행률, 오류 메시지, 재시도 기능 | - |
**배포 채널:**
1. 우리동네TV (반경 500m/1km, 송출 시간대)
2. 링고비즈 (매장 전화 연결음 업데이트)
3. 지니TV (타겟 지역, 노출 시간대, 예산)
4. SNS (Instagram, Naver Blog, Kakao Channel)
**성능 목표:** 전체 배포 과정 1분 이내 완료
---
### 6. Participation 서비스 (이벤트 참여 및 접수 관리)
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 |
|----|------|----------|--------|-------------|
| UFR-PART-010 | 이벤트참여 | M | 13 | 이름, 전화번호 입력, 참여 경로 추적, 응모번호 발급, 중복 방지 |
| UFR-PART-020 | 참여자목록조회 | S | 8 | 참여자 테이블, 필터링, 검색, 개인정보 마스킹 |
| UFR-PART-030 | 당첨자추첨 | M | 13 | 자동 추첨, 매장 방문 고객 가산점, 재추첨 기능 |
**정책:**
- 1인 1회 참여 제한 (전화번호 기반)
- 개인정보 보호 규정 준수
- 매장 방문 고객 가산점 부여 (선택 가능)
---
### 7. Analytics 서비스 (실시간 효과 측정 및 분석)
| ID | 기능 | 우선순위 | 복잡도 | 핵심 요구사항 | 데이터 소스 |
|----|------|----------|--------|-------------|------------|
| UFR-ANAL-010 | 실시간통합대시보드 | M | 34 | 참여자 수, 노출 수, 투자 대비 수익률, 매출 증가율, 채널별 성과, 5분 간격 업데이트 | KT 채널 API, POS, SNS API |
**대시보드 구성:**
1. 상단 요약 카드: 총 참여자 수, 총 노출 수, 예상 투자 대비 수익률, 매출 증가율
2. 채널별 성과 분석: 막대 그래프, 원 그래프
3. 시간대별 참여 추이: 라인 차트
4. 투자 대비 수익률 상세 분석: 총 비용, 예상 수익, 손익분기점
5. 참여자 프로필 분석: 연령대별, 성별, 지역별 분포
6. 비교 분석: 업종 평균, 이전 이벤트 대비
**데이터 수집:**
- 실시간 데이터 수집 (5분 간격)
- WebSocket 또는 SSE를 통한 실시간 업데이트
- 데이터 캐싱 (Redis)
---
## 기술 스택 요약
### AI/ML 모델
| 모델 | 용도 | 관련 유저스토리 |
|------|------|----------------|
| Claude API / GPT-4 API | 트렌드 분석, 이벤트 추천, 홍보 문구 생성 | UFR-AI-010 |
| Stable Diffusion / DALL-E | 이미지 생성 | UFR-CONT-010 |
### 외부 API 연동
| API | 용도 | 관련 유저스토리 |
|-----|------|----------------|
| 국세청 API | 사업자번호 검증 | UFR-USER-010 |
| 우리동네TV API | 지역 타겟팅 영상 송출 | UFR-DIST-010 |
| 링고비즈 API | 연결음 업데이트 | UFR-DIST-010 |
| 지니TV API | TV 광고 배포 | UFR-DIST-010 |
| Instagram API | SNS 자동 포스팅, 성과 데이터 수집 | UFR-DIST-010, UFR-ANAL-010 |
| Naver API | 블로그 자동 포스팅 | UFR-DIST-010 |
| Kakao API | 카카오 채널 자동 포스팅, 성과 데이터 수집 | UFR-DIST-010, UFR-ANAL-010 |
---
## 마이크로서비스 아키텍처
```
┌─────────────────────────────────────────────────────────────┐
│ API Gateway │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ User Service │ │ Event Service │ │ AI Service │
│ (UFR-USER) │ │ (UFR-EVENT) │ │ (UFR-AI) │
└────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ │ │
┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ Content │ │ Distribution │ │ Participation │
│ (UFR-CONT) │ │ (UFR-DIST) │ │ (UFR-PART) │
└────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└─────────────────────┼─────────────────────┘
┌─────────▼─────────┐
│ Analytics │
│ (UFR-ANAL) │
└───────────────────┘
```
---
## 서비스 간 의존성
- User → Event: 사용자 인증 정보 제공
- Event → AI: 트렌드 분석 및 이벤트 추천 요청
- Event → Content: 콘텐츠 생성 요청
- Event → Distribution: 배포 요청
- Distribution → Content: 생성된 콘텐츠 사용
- Participation → Analytics: 참여자 데이터 제공
- Distribution → Analytics: 노출 데이터 제공
---
## 개발 우선순위 로드맵
### Phase 1: MVP (8주)
**목표:** 기본 이벤트 기획 및 배포 기능
| 순서 | 서비스 | 유저스토리 | 비고 |
|------|--------|-----------|------|
| 1 | User | UFR-USER-010, UFR-USER-020 | 회원가입, 로그인 |
| 2 | Event | UFR-EVENT-020, UFR-EVENT-030, UFR-EVENT-050 | 목적 선택, AI 추천, 승인 |
| 3 | AI | UFR-AI-010 | 트렌드 분석 및 이벤트 추천 |
| 4 | Content | UFR-CONT-010 | 이미지 생성 |
| 5 | Distribution | UFR-DIST-010 | 다중 채널 배포 |
| 6 | Participation | UFR-PART-010, UFR-PART-030 | 참여 신청, 자동 추첨 |
### Phase 2: 고도화 (6주)
**목표:** 관리 및 분석 기능 추가
| 순서 | 서비스 | 유저스토리 | 비고 |
|------|--------|-----------|------|
| 7 | Event | UFR-EVENT-010, UFR-EVENT-040, UFR-EVENT-060, UFR-EVENT-070 | 대시보드, 채널 선택, 상세 조회, 목록 관리 |
| 8 | Analytics | UFR-ANAL-010 | 실시간 통합 대시보드 |
| 9 | Distribution | UFR-DIST-020 | 배포 상태 모니터링 |
| 10 | Participation | UFR-PART-020 | 참여자 목록 조회 |
### Phase 3: 완성 (2주)
**목표:** 선택 기능 추가
| 순서 | 서비스 | 유저스토리 | 비고 |
|------|--------|-----------|------|
| 11 | User | UFR-USER-030, UFR-USER-040 | 프로필 관리, 로그아웃 |
| 12 | Content | UFR-CONT-020 | 콘텐츠 편집 |
---
## 주요 기술적 리스크 및 해결방안
### 1. AI 응답 시간 (10초 목표)
- **리스크**: AI API 응답이 목표 시간을 초과할 수 있음
- **해결방안**:
- 프롬프트 최적화로 응답 시간 단축
- 트렌드 분석과 이벤트 추천 병렬 처리
- 비동기 처리 + 폴링 방식
- 부분 응답 스트리밍
### 2. 다중 외부 API 의존성
- **리스크**: API 장애 시 서비스 중단
- **해결방안**:
- 각 API별 폴백 전략 수립
- 캐싱 적극 활용
- 써킷 브레이커 패턴 적용
- 채널별 독립 처리
### 3. 실시간 대시보드 성능
- **리스크**: 다중 데이터 소스 통합으로 인한 성능 저하
- **해결방안**:
- Redis 캐싱
- 5분 간격 데이터 폴링
- WebSocket/SSE를 통한 효율적인 업데이트
- 차트 데이터 메모이제이션
### 4. 이미지 생성 비용 및 시간
- **리스크**: 이미지 생성 API 비용 및 생성 시간
- **해결방안**:
- 생성된 이미지 캐싱
- CDN 활용
- 비동기 생성 + 진행 상황 표시
---
## 예상 개발 기간
- User 서비스: 2주
- Event 서비스: 3주
- AI 서비스: 4주 (프롬프트 엔지니어링 포함)
- Content 서비스: 3주
- Distribution 서비스: 4주 (다중 API 연동)
- Participation 서비스: 2주
- Analytics 서비스: 4주 (통합 대시보드)
**총 예상 기간**: 12-16주 (병렬 개발 시)

998
design/userstory.md Normal file
View File

@ -0,0 +1,998 @@
# KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 유저스토리 ver2.0
- [KT AI 기반 소상공인 이벤트 자동 생성 서비스 - 유저스토리 ver2.0](#kt-ai-기반-소상공인-이벤트-자동-생성-서비스---유저스토리-ver20)
- [마이크로서비스 구성](#마이크로서비스-구성)
- [유저스토리](#유저스토리)
- [기술 검토 결과](#기술-검토-결과)
---
## 마이크로서비스 구성
1. **User** - 사용자 인증 및 매장정보 관리
2. **Event** - 이벤트 기획 및 관리
3. **AI** - AI 기반 트렌드 분석 및 이벤트 추천
4. **Content** - SNS 콘텐츠 생성
5. **Distribution** - 다중 채널 배포 관리
6. **Participation** - 이벤트 참여 및 당첨자 관리
7. **Analytics** - 실시간 효과 측정 및 통합 대시보드
---
## 유저스토리
```
1. User 서비스
1) 사용자 인증 및 매장정보 관리
UFR-USER-010: [회원가입] 소상공인으로서 | 나는 이벤트 마케팅 서비스를 이용하기 위해 | 간편하게 회원가입하고 싶다.
- 시나리오: 신규 회원가입
미로그인 상태에서 회원가입 화면에 접근한 상황에서 | 필수 정보를 모두 입력하고 회원가입 버튼을 클릭하면 | 사업자번호 검증을 거쳐 회원가입이 완료된다.
[입력 요구사항]
- 기본 정보
- 이름: 2자 이상 (한글/영문)
- 전화번호: 휴대폰 번호 형식
- 이메일: 이메일 형식 준수
- 비밀번호: 8자 이상 (영문/숫자/특수문자 포함)
[매장 정보 입력]
- 매장명: 필수
- 업종: 선택 (예: 음식점, 카페, 소매점 등)
- 주소: 필수
- 영업시간: 선택
- 사업자번호: 필수
- 국세청 DB 조회를 통한 검증
- 휴폐업 여부 확인
- 검증 실패 시 재입력 요청
[처리 결과]
- 성공: "회원가입이 완료되었습니다" 메시지 → 대시보드 화면
- 실패: 구체적인 오류 메시지 표시
- M/21
---
UFR-USER-020: [로그인] 소상공인으로서 | 나는 이벤트를 관리하기 위해 | 간편하게 로그인하고 싶다.
- 시나리오: 사용자 로그인
미로그인 상태에서 로그인 화면에 접근한 상황에서 | 전화번호와 비밀번호를 입력하고 로그인 버튼을 클릭하면 | 인증이 완료되고 대시보드로 이동한다.
[입력 요구사항]
- 전화번호: 등록된 전화번호 입력
- 비밀번호: 해당 계정의 비밀번호 입력
- 로그인 유지: 선택 옵션 제공
[인증 처리]
- 성공: 대시보드로 즉시 이동
- 실패: "전화번호 또는 비밀번호를 확인해주세요" 메시지
- M/8
---
UFR-USER-030: [프로필관리] 소상공인으로서 | 나는 내 정보를 최신 상태로 유지하기 위해 | 프로필 정보를 편집하고 싶다.
- 시나리오: 프로필 정보 수정
Bottom Navigation의 "프로필" 탭을 클릭하여 프로필 편집 화면에 접근한 상황에서 | 변경할 정보를 수정하고 저장 버튼을 클릭하면 | 변경사항이 저장되고 확인 메시지가 표시된다.
[수정 가능 항목]
- 기본 정보
- 이름: 실명 변경 가능
- 전화번호: 변경 시 재인증 필요
- 이메일: 이메일 변경 가능
- 매장 정보
- 매장명
- 업종
- 주소
- 영업시간
- 비밀번호
- 현재 비밀번호 확인 필수
- 새 비밀번호 규칙: 8자 이상 (영문/숫자/특수문자 포함)
[저장 처리]
- 저장 전 확인: "변경사항을 저장하시겠습니까?"
- 저장 완료: "프로필이 성공적으로 업데이트되었습니다"
- 취소 선택: "변경사항이 저장되지 않습니다. 계속하시겠습니까?"
- C/8
---
UFR-USER-040: [로그아웃] 소상공인으로서 | 나는 보안을 위해 | 서비스 사용 후 안전하게 로그아웃하고 싶다.
- 시나리오: 안전한 로그아웃
Bottom Navigation의 "프로필" 탭에서 로그아웃 버튼을 선택한 상황에서 | 로그아웃 확인 다이얼로그에서 확인을 클릭하면 | 세션이 종료되고 로그인 화면으로 이동한다.
[로그아웃 요구사항]
- 확인 다이얼로그
- 메시지: "로그아웃 하시겠습니까?"
- 버튼: 취소/확인
[처리 결과]
- 확인 선택: "안전하게 로그아웃되었습니다" 메시지 → 로그인 화면
- 취소 선택: 다이얼로그 닫힘, 현재 화면 유지
- S/3
---
2. Event 서비스
1) 이벤트 관리
UFR-EVENT-010: [대시보드] 소상공인으로서 | 나는 내 이벤트를 효율적으로 관리하기 위해 | 대시보드에서 진행중/예정/종료된 이벤트를 한눈에 확인하고 싶다.
- 시나리오: 대시보드에서 이벤트 목록 확인
로그인한 상태에서 Bottom Navigation의 "홈" 탭을 클릭하거나, 최초 로그인 시 대시보드에 접근한 상황에서 | 이벤트 목록 영역을 확인하면 | 내 이벤트들이 상태별로 구분되어 표시된다.
[표시 요구사항]
- 이벤트 상태별 섹션 구성
- "진행중": 현재 진행 중인 이벤트
- "예정": 배포 대기 중인 이벤트
- "종료": 완료된 이벤트
- 이벤트 카드 정보
- 이벤트명
- 이벤트 기간
- 진행 상태 뱃지
- 간단한 통계 (참여자 수, 조회수 등)
[상호작용 요구사항]
- 이벤트 카드 클릭: 해당 이벤트 상세 화면으로 이동
- 섹션당 최대 5개 카드 표시 (최신 순)
- "전체보기" 링크: 전체 이벤트 목록 화면으로 이동
- "새 이벤트 만들기" 버튼: 이벤트 생성 프로세스 시작
[빈 상태 처리]
- 이벤트가 없을 경우: "첫 이벤트를 만들어보세요" 안내 및 생성 버튼
- S/13
---
UFR-EVENT-020: [이벤트목적선택] 소상공인으로서 | 나는 효과적인 이벤트를 기획하기 위해 | 이벤트 목적을 먼저 선택하고 싶다.
- 시나리오: 이벤트 목적 선택
대시보드에서 "새 이벤트 만들기" 버튼을 클릭한 상황에서 | 이벤트 목적 선택 화면에서 하나의 목적을 선택하면 | AI 트렌드 분석 및 이벤트 추천 화면으로 이동한다.
[목적 옵션]
- 신규 고객 유치: 새로운 고객 확보
- 재방문 유도: 기존 고객 재방문 촉진
- 매출 증대: 단기 매출 향상
- 인지도 향상: 브랜드/매장 인지도 제고
[목적별 설명 제공]
- 각 목적에 대한 간단한 설명
- 예상 효과 예시
[다음 단계]
- 목적 선택 완료 시 자동으로 AI 트렌드 분석 및 이벤트 추천 화면으로 이동
- M/5
---
UFR-EVENT-030: [AI이벤트추천] 소상공인으로서 | 나는 최신 트렌드를 반영하고 선택지를 비교하기 위해 | AI 트렌드 분석 결과와 함께 3가지 이벤트 추천을 확인하고 싶다.
- 시나리오: AI 트렌드 분석 및 이벤트 추천 확인
이벤트 목적을 선택한 상황에서 | AI 이벤트 추천 화면에 접근하면 | 트렌드 분석 결과와 함께 3가지 이벤트 기획안이 표시된다.
[화면 구성]
1. 트렌드 분석 결과 (상단)
- 업종 트렌드
- 최근 성공한 이벤트 유형
- 고객 선호 경품 분석
- 효과적인 참여 방법
- 지역 특성
- 지역별 이벤트 성공률
- 지역 고객 특성
- 시즌 특성
- 계절/시기별 추천 이벤트
- 특별 이벤트 시즌 (명절, 기념일 등)
2. 추천 이벤트 3가지 (하단)
각 카드에 다음 정보 포함:
- 이벤트 제목 (수정 가능)
- 추천 경품 (수정 가능)
- 참여 방법
- 예상 참여자 수
- 예상 비용
- 예상 투자 대비 수익률
[추천 기준]
- 이벤트 목적 기반 최적화
- 업종 트렌드 반영
- 예산 대비 효과 극대화
- 3가지 옵션 차별화:
- 옵션 1: 저비용, 높은 참여율 중심
- 옵션 2: 중비용, 균형잡힌 투자 대비 수익률
- 옵션 3: 고비용, 높은 매출 증대 효과
[간단한 커스텀 기능]
- 이벤트 제목: 각 카드에서 직접 수정 가능 (최대 50자)
- 경품명: 각 카드에서 직접 수정 가능
[이벤트 선택]
- 3가지 중 1개 선택 (라디오 버튼)
- 선택한 카드의 커스텀된 정보가 저장됨
- 선택 완료 후 "다음" 버튼 활성화
[프로세스 시간]
- 트렌드 분석 + 추천 생성: 10초 이내
- 진행 상황 표시 (로딩 인디케이터)
[다음 단계]
- 이벤트 선택 후 콘텐츠 생성 화면으로 이동
- M/34
- 기술 태스크
- AI 트렌드 분석 API
- 빅데이터 분석: 과거 성공 이벤트, 업종별 성공률, 지역 특성, 시즌 패턴
- 트렌드 리포트 생성: JSON 형태 반환
- 분석 완료 시간: 5초 이내
- AI 이벤트 추천 API
- Claude API / GPT-4 API 연동
- 기획안 생성 로직:
- 경품 추천 (예산 대비 매력도, 고객 선호도)
- 참여 방법 설계 (난이도별 차별화)
- 홍보 문구 생성
- 3가지 옵션 차별화 생성
- 예상 성과 계산 (참여자 수, 비용, 투자 대비 수익률)
- 생성 완료 시간: 5초 이내
- 프론트엔드 통합
- 트렌드 분석 결과 표시
- 3가지 추천 카드 UI
- 실시간 제목/경품 편집 기능
- 선택 상태 관리
- 성능 최적화
- 트렌드 분석과 추천 생성 병렬 처리
- 캐싱 전략
- 비동기 처리
---
UFR-EVENT-040: [배포채널선택] 소상공인으로서 | 나는 효과적인 이벤트 홍보를 위해 | 다중 배포 채널을 선택하고 싶다.
- 시나리오: 배포 채널 선택
이벤트 추천을 완료하고 콘텐츠 생성이 완료된 상황에서 | 배포 채널 선택 화면에서 원하는 채널을 선택하면 | 각 채널의 예상 노출 수가 표시된다.
[배포 채널 옵션]
1. 우리동네TV
- 반경 선택: 500m / 1km
- 송출 시간대: 평일 저녁 / 주말 점심 등
- 예상 노출 수 표시
2. 링고비즈 (연결음)
- 매장 전화번호 자동 연동
- 연결음 업데이트 안내
3. 지니TV 광고
- 타겟 지역 선택
- 노출 시간대 선택
- 예산 입력
- 예상 노출량 표시
4. SNS
- Instagram (체크박스)
- Naver Blog (체크박스)
- Kakao Channel (체크박스)
- 예약 시간 설정
[채널별 비용 안내]
- 각 채널별 예상 비용 표시
- 총 예상 비용 합계
[다음 단계]
- 최소 1개 이상 채널 선택 필수
- "다음" 버튼: 최종 승인 화면으로 이동
- M/13
---
UFR-EVENT-050: [최종승인] 소상공인으로서 | 나는 이벤트를 배포하기 전에 | 모든 설정을 최종 확인하고 승인하고 싶다.
- 시나리오: 이벤트 최종 승인
배포 채널을 선택한 상황에서 | 최종 승인 화면에서 모든 정보를 확인하고 승인 버튼을 클릭하면 | 이벤트가 생성되고 배포가 시작된다.
[최종 확인 정보]
1. 이벤트 정보
- 이벤트명
- 이벤트 기간
- 이벤트 목적
2. 경품 정보
- 경품명
- 경품 수량
3. 참여 방법
- 참여 조건 요약
4. 배포 채널
- 선택한 채널 목록
- 채널별 예상 노출 수
5. 예상 비용 및 효과
- 총 예상 비용
- 예상 참여자 수
- 예상 투자 대비 수익률
[승인 프로세스]
- "승인 및 배포" 버튼 클릭
- 확인 다이얼로그: "이벤트를 승인하고 배포하시겠습니까?"
- 승인 시 이벤트 생성 및 콘텐츠 생성 시작
[처리 결과]
- 성공: "이벤트가 생성되었습니다. 배포를 시작합니다." 메시지
- 자동으로 대시보드로 이동
- 배포 진행 상황 알림
- M/13
---
UFR-EVENT-060: [이벤트상세조회] 소상공인으로서 | 나는 진행 중인 이벤트의 현황을 파악하기 위해 | 이벤트 상세 정보를 조회하고 싶다.
- 시나리오: 이벤트 상세 정보 조회
대시보드에서 이벤트 카드를 클릭한 상황에서 | 이벤트 상세 화면에 접근하면 | 이벤트의 모든 정보와 실시간 통계가 표시된다.
[상세 정보 구성]
1. 기본 정보
- 이벤트명
- 이벤트 기간
- 진행 상태
- 경품 정보
- 참여 방법
2. 실시간 통계 (Analytics 서비스 연동)
- 참여자 수
- 노출 수
- 조회 수
- 공유 수
3. 배포 채널 현황
- 채널별 배포 상태
- 채널별 성과 요약
4. 참여자 목록
- 최근 참여자 10명
- "전체 참여자 보기" 링크
[액션 버튼]
- "효과 측정 대시보드": Analytics 대시보드로 이동
- "이벤트 수정": 진행 중인 이벤트 수정 (제한적)
- "이벤트 종료": 예정보다 빨리 종료
- S/13
---
UFR-EVENT-070: [이벤트목록관리] 소상공인으로서 | 나는 모든 이벤트를 관리하기 위해 | 전체 이벤트 목록을 조회하고 필터링/검색하고 싶다.
- 시나리오: 전체 이벤트 목록 조회
Bottom Navigation의 "이벤트" 탭을 클릭하거나, 대시보드에서 "전체보기" 링크를 클릭한 상황에서 | 이벤트 목록 화면에 접근하면 | 모든 이벤트가 테이블 형태로 표시된다.
[목록 표시 요구사항]
- 테이블 컬럼
- 이벤트명
- 이벤트 기간
- 상태 (진행중/예정/종료)
- 참여자 수
- 투자 대비 수익률
- 생성일
- 페이지네이션 (페이지당 20개)
[필터 옵션]
- 상태별 필터: 전체/진행중/예정/종료
- 기간별 필터: 최근 1개월/3개월/6개월/1년/전체
[검색 기능]
- 검색창: 이벤트명 검색
- 실시간 검색 결과
[정렬 옵션]
- 최신순 (기본값)
- 참여자 많은 순
- 투자 대비 수익률 높은 순
[이벤트 관리]
- 행 클릭: 이벤트 상세 화면
- 다중 선택: 일괄 삭제 기능
- S/13
---
3. AI 서비스
1) AI 분석 및 추천
UFR-AI-010: [AI트렌드분석및이벤트추천] AI 시스템으로서 | 나는 소상공인에게 최적의 이벤트를 제안하기 위해 | 업종/지역/시즌 트렌드를 분석하고 3가지 최적화된 이벤트 기획안을 생성하고 싶다.
- 시나리오: 트렌드 분석 및 이벤트 추천 수행
소상공인이 이벤트 목적을 선택한 상황에서 | AI 시스템이 요청을 받으면 | 트렌드 분석과 이벤트 추천을 병렬로 수행하여 결과를 반환한다.
[분석 데이터 소스]
- 과거 성공 이벤트 데이터
- 업종별 이벤트 성공률
- 지역별 고객 특성
- 시즌별 트렌드 패턴
[트렌드 분석 항목]
1. 업종 트렌드
- 최근 3개월 성공 이벤트 유형
- 고객 선호 경품 Top 5
- 효과적인 참여 방법 분석
2. 지역 특성
- 해당 지역 이벤트 성공률
- 지역 고객 연령대/성별 분포
3. 시즌 특성
- 계절별 추천 이벤트
- 특별 시즌 (명절, 기념일) 반영
[이벤트 추천 로직]
1. 경품 추천
- 예산 대비 매력도 최대화
- 타겟 고객 선호도 반영
- KT 멤버십 포인트 활용 가능 여부
2. 참여 방법 설계
- 간단한 참여 방법 (저 난이도)
- 재방문 유도 요소 (중 난이도)
- 바이럴 확산 장치 (고 난이도)
3. 홍보 문구 생성
- 이벤트 개요 기반 문구 5개 생성
- SNS 해시태그 자동 생성
[3가지 옵션 차별화]
- 옵션 1: 저비용, 높은 참여율 중심
- 옵션 2: 중비용, 균형잡힌 투자 대비 수익률
- 옵션 3: 고비용, 높은 매출 증대 효과
[예상 성과 계산]
- 예상 참여자 수
- 예상 비용
- 예상 투자 대비 수익률
[결과 출력]
- 트렌드 분석 결과 JSON 형태 반환
- 3가지 이벤트 기획안 JSON 배열 반환
- 전체 완료 시간: 10초 이내 (병렬 처리)
- M/34
- 기술 태스크
- AI 트렌드 분석 엔진
- 빅데이터 분석 시스템 연동
- 업종/지역/시즌별 데이터 집계
- 트렌드 패턴 인식 알고리즘
- AI 이벤트 추천 엔진
- Claude API / GPT-4 API 연동
- 프롬프트 엔지니어링 최적화
- 응답 파싱 및 구조화
- 병렬 처리 아키텍처
- 트렌드 분석과 이벤트 추천 동시 실행
- 결과 통합 로직
- 성능 최적화
- 캐싱 전략 (Redis)
- 비동기 처리
- 응답 시간 모니터링
---
4. Content 서비스
1) 콘텐츠 생성
UFR-CONT-010: [SNS이미지생성] 소상공인으로서 | 나는 SNS에 게시할 이벤트 이미지를 쉽게 만들기 위해 | AI가 자동으로 3가지 스타일의 이미지를 생성하기를 원한다.
- 시나리오: SNS 이미지 자동 생성
이벤트 추천을 완료한 상황에서 | SNS 이미지 생성 화면에 접근하면 | AI가 3가지 스타일의 SNS 이미지를 자동 생성한다.
[이미지 생성 입력]
- 이벤트 제목
- 경품 정보
- 브랜드 컬러 (프로필에서 가져옴)
- 로고 이미지 (업로드된 경우)
[3가지 스타일 카드 선택]
1. 심플 스타일
- 깔끔한 디자인
- 텍스트 중심
- 읽기 쉬운 구성
- 카드 형태로 제공 (선택 가능)
2. 화려한 스타일
- 눈에 띄는 디자인
- 이미지 강조
- 풍부한 색상
- 카드 형태로 제공 (선택 가능)
3. 트렌디 스타일
- 최신 트렌드 반영
- SNS 최적화
- MZ세대 타겟
- 카드 형태로 제공 (선택 가능)
[플랫폼별 최적화]
- Instagram: 1080x1080
- Naver Blog: 800x600
- Kakao Channel: 800x800
[생성 프로세스]
- 생성 진행 표시 (스피너 애니메이션)
- "딥러닝 모델이 이벤트에 어울리는 이미지를 생성하고 있어요..." 안내
- 예상 소요 시간: 5초 이내
- 생성 완료 시 3가지 스타일 카드 표시
[이미지 선택 및 미리보기]
- 3가지 스타일 중 1개 선택 가능 (카드 선택 방식)
- 각 카드 클릭 시 풀스크린 미리보기 제공
- "다시 생성" 옵션 (최대 3회)
- 선택 완료 시 "다음" 버튼 활성화
- M/21
---
UFR-CONT-020: [콘텐츠편집] 소상공인으로서 | 나는 생성된 이미지를 내 스타일에 맞게 조정하기 위해 | 간단한 편집 기능을 사용하고 싶다.
- 시나리오: 생성된 콘텐츠 편집
SNS 이미지 스타일을 선택한 상황에서 | 콘텐츠 편집 화면에서 텍스트나 색상을 수정하면 | 실시간으로 미리보기가 업데이트된다.
[편집 가능 항목]
- 텍스트 수정
- 제목 텍스트 (인라인 편집)
- 경품 정보 텍스트 (인라인 편집)
- 참여 안내 텍스트 (인라인 편집)
- 색상 조정
- 배경색 (컬러 피커)
- 텍스트 색상 (컬러 피커)
- 강조 색상 (컬러 피커)
[편집 제약사항]
- 레이아웃 변경 불가
- 로고 위치/크기 조절 불가
- 이미지 교체 불가 (이전 화면의 "다시 생성"으로만 가능)
[실시간 미리보기]
- 수정사항 즉시 반영
- 플랫폼별 미리보기 전환 가능 (Instagram/Naver/Kakao)
- 모바일 상단에 미리보기 영역 표시
[저장 및 완료]
- "저장" 버튼: 편집 내용 임시 저장
- "다음" 버튼: 배포 채널 선택 화면으로 이동
- S/13
---
5. Distribution 서비스
1) 배포 관리
UFR-DIST-010: [다중채널배포] 소상공인으로서 | 나는 여러 채널에 동시에 이벤트를 홍보하기 위해 | 원클릭으로 다중 채널 배포를 실행하고 싶다.
- 시나리오: 다중 채널 동시 배포
최종 승인이 완료된 상황에서 | Distribution 서비스가 배포 요청을 받으면 | 선택된 모든 채널에 동시 배포를 시작한다.
[배포 채널 처리]
1. 우리동네TV
- 우리동네TV API 연동
- 15초 영상 업로드 (Content 서비스에서 생성된 이미지 활용)
- 송출 시간대 및 반경 설정 전달
- 배포 완료 시 배포 ID 수신
2. 링고비즈 (연결음)
- 링고비즈 API 연동
- 매장 전화번호로 연결음 업데이트
- 업데이트 완료 시각 수신
3. 지니TV 광고
- 지니TV 광고 API 연동
- 타겟 지역, 노출 시간대, 예산 정보 전달
- 광고 ID 및 노출 스케줄 수신
4. SNS 자동 포스팅
- Instagram, Naver Blog, Kakao Channel 병렬 처리
- 플랫폼별 최적화된 이미지 사용
- 예약 시간 설정 반영
- 각 플랫폼 포스팅 완료 확인
[배포 진행 상황]
- 실시간 진행 상황 알림
- 채널별 배포 상태 표시
- 예상 완료 시간: 1분 이내
[배포 실패 처리]
- 채널별 독립적 처리 (하나 실패해도 다른 채널 계속 진행)
- 실패 시 자동 재시도 (최대 3회)
- 최종 실패 시 소상공인에게 알림
[배포 완료 처리]
- 배포 이력 DB 저장
- 배포 채널 목록 및 예상 도달 수 반환
- 소상공인에게 배포 완료 알림 (앱 푸시/이메일)
- M/21
---
UFR-DIST-020: [배포상태모니터링] 소상공인으로서 | 나는 배포 진행 상황을 실시간으로 파악하기 위해 | 배포 상태를 모니터링하고 싶다.
- 시나리오: 배포 상태 실시간 확인
배포가 시작된 상황에서 | 이벤트 상세 화면의 배포 상태 섹션을 확인하면 | 채널별 배포 진행 상황이 실시간으로 표시된다.
[배포 상태 표시]
- 채널별 상태
- 대기중: 회색
- 진행중: 파란색 (진행률 표시)
- 완료: 초록색 (체크 표시)
- 실패: 빨간색 (오류 메시지)
[배포 정보]
- 우리동네TV: 배포 ID, 예상 노출 수
- 링고비즈: 업데이트 완료 시각
- 지니TV: 광고 ID, 노출 스케줄
- SNS: 포스팅 URL 링크
[재시도/취소]
- 실패한 채널: "재시도" 버튼
- 진행중인 배포: "취소" 버튼 (일부 채널만 가능)
- S/13
---
6. Participation 서비스
1) 참여자 관리
UFR-PART-010: [이벤트참여] 고객으로서 | 나는 간편하게 이벤트에 참여하기 위해 | 최소한의 정보만 입력하고 싶다.
- 시나리오: 고객 이벤트 참여
고객이 이벤트를 발견한 상황에서 | 이벤트 참여 화면에서 필수 정보를 입력하고 참여 버튼을 클릭하면 | 참여 접수가 완료된다.
[이벤트 발견 경로]
- 우리동네TV
- SNS (Instagram, Blog, Kakao)
- 매장 방문 (링고비즈 연결음 안내)
[참여 정보 입력]
- 이름: 필수 (2자 이상)
- 전화번호: 필수 (휴대폰 번호)
- 참여 경로: 자동 감지 또는 선택
[중복 참여 체크]
- 전화번호 기반 중복 확인
- 1인 1회 참여 제한
- 중복 시: "이미 참여하신 이벤트입니다" 안내
[참여 접수 완료]
- 응모 번호 발급
- 당첨 발표일 안내
- 참여 완료 화면 표시
[개인정보 처리]
- 개인정보 수집/이용 동의 필수
- 마케팅 활용 동의 선택
- M/13
---
UFR-PART-020: [참여자목록조회] 소상공인으로서 | 나는 누가 참여했는지 파악하기 위해 | 참여자 목록을 조회하고 싶다.
- 시나리오: 참여자 목록 확인
이벤트 상세 화면에서 "참여자 목록" 탭을 클릭한 상황에서 | 참여자 목록 화면에 접근하면 | 모든 참여자 정보가 테이블로 표시된다.
[목록 표시]
- 테이블 컬럼
- 번호 (응모 번호)
- 이름
- 전화번호 (일부 마스킹)
- 참여 경로
- 참여 일시
- 당첨 여부
- 페이지네이션
[필터 옵션]
- 참여 경로별 필터
- 당첨 여부 필터 (전체/당첨/미당첨)
[검색 기능]
- 이름 또는 전화번호로 검색
[액션]
- 개별 참여자 상세 정보 보기
- S/8
---
UFR-PART-030: [당첨자추첨] 소상공인으로서 | 나는 공정한 당첨자 선정을 위해 | 자동 추첨 기능을 사용하고 싶다.
- 시나리오: 당첨자 자동 추첨
이벤트 종료일이 도래한 상황에서 | 소상공인이 "당첨자 추첨" 버튼을 클릭하면 | 시스템이 자동으로 당첨자를 추첨한다.
[추첨 방식]
- 난수 기반 무작위 추첨
- 매장 방문 고객 가산점 옵션 (선택 가능)
- 추첨 과정 로그 자동 기록
[당첨 인원 설정]
- 경품 수량 기반 자동 설정
- 수동 조정 가능
[추첨 결과]
- 당첨자 목록 표시
- 당첨자 정보: 이름, 전화번호, 응모번호
[재추첨]
- "재추첨" 버튼: 당첨자 재선정 (추첨 전 확인)
- 이전 추첨 이력 보관
- M/13
---
7. Analytics 서비스
1) 효과 측정
UFR-ANAL-010: [성과분석] 소상공인으로서 | 나는 이벤트 성과를 한눈에 파악하기 위해 | 모든 지표가 통합된 실시간 성과분석 대시보드를 보고 싶다.
- 시나리오: 성과분석 대시보드 조회
Bottom Navigation의 "분석" 탭을 클릭하거나, 이벤트 상세 화면에서 "성과분석" 버튼을 클릭한 상황에서 | 성과분석 대시보드 화면에 접근하면 | 모든 핵심 지표가 하나의 화면에 실시간으로 표시된다.
[대시보드 구성]
1. 상단 요약 카드 (4개)
- 총 참여자 수
- 현재 참여자 수
- 목표 대비 달성률 (%)
- 총 노출 수
- 채널별 노출 합계
- 전일 대비 증감률
- 예상 투자 대비 수익률
- 실시간 투자 대비 수익률 계산
- 업종 평균 대비 비교
- 매출 증가율
- POS 연동 데이터 기반
- 이벤트 전후 비교 (%)
2. 채널별 성과 분석 (차트)
- 막대 그래프: 채널별 참여자 수
- 원 그래프: 채널별 참여 비율
- 채널 목록:
- 우리동네TV
- 링고비즈
- 지니TV
- Instagram
- Naver Blog
- Kakao Channel
- 각 채널별 세부 지표:
- 노출 수
- 참여자 수
- 전환율 (%)
- 비용 대비 효율 (CPA)
3. 시간대별 참여 추이 (라인 차트)
- X축: 시간 (일별/시간별 전환 가능)
- Y축: 참여자 수
- 실시간 업데이트 (5분 간격)
- 피크 시간대 하이라이트
4. 투자 대비 수익률 상세 분석 (표)
- 총 비용 산출
- 경품 비용
- 채널별 플랫폼 비용
- 기타 비용
- 예상 수익 산출
- 매출 증가액
- 신규 고객 LTV
- 투자 대비 수익률 계산식 표시
- 투자 대비 수익률 = (수익 - 비용) / 비용 × 100
- 손익분기점 표시
5. 참여자 프로필 분석 (차트)
- 연령대별 분포
- 성별 분포
- 지역별 분포
- 참여 시간대 분석
6. 비교 분석 (표)
- 업종 평균과 비교
- 참여율
- 투자 대비 수익률
- 전환율
- 내 이전 이벤트와 비교
- 성과 개선도
- 최고/최저 기록 대비
[실시간 업데이트]
- 모든 지표 5분 간격 자동 갱신
- 새로운 참여자 알림 (선택 옵션)
- 수동 새로고침 버튼
[데이터 소스]
- Participation 서비스: 참여자 데이터
- Distribution 서비스: 채널별 노출 수
- POS 시스템: 매출 데이터 (연동 시)
- 외부 API: 우리동네TV, 지니TV, SNS 통계
[모바일 최적화]
- 반응형 디자인
- 스크롤 가능한 레이아웃
- 핵심 지표 우선 표시
- M/34
- 기술 태스크
- 실시간 데이터 수집
- 5분 간격 데이터 폴링
- WebSocket 또는 SSE를 통한 실시간 업데이트
- 데이터 캐싱 (Redis)
- 다중 API 통합
- 우리동네TV API
- 링고비즈 API
- 지니TV API
- Instagram API
- Naver API
- Kakao API
- POS 시스템 연동
- 매출 데이터 수집
- 이벤트 전후 비교 로직
- 차트 라이브러리 활용
- Chart.js / Recharts 등
- 반응형 차트 구현
- 성능 최적화
- 차트 데이터 메모이제이션
- 무한 스크롤 대신 페이지네이션
- 이미지/데이터 lazy loading
---
## 기술 검토 결과
**[마이크로서비스별 검토]**
1) User 서비스
모든 유저스토리 **✅ 실현 가능**
- 표준적인 인증/인가 패턴
- JWT 토큰, Redis 캐싱 등 검증된 기술 활용
- 사업자번호 검증 API (국세청) 연동
2) Event 서비스
대부분 **✅ 실현 가능**
- 이벤트 CRUD 기능
- AI 서비스와의 연동
- LocalStorage 자동 저장 구현
3) AI 서비스
**⚠️ 높은 기술적 복잡도**
- UFR-AI-010: **주요 리스크 포인트**
- Claude API 또는 GPT-4 API 연동
- 트렌드 분석 + 이벤트 추천 병렬 처리
- 응답 시간 관리 (10초 목표)
- 프롬프트 엔지니어링 최적화
- **해결방안**: 캐싱, 병렬 처리, 비동기 처리
4) Content 서비스
**⚠️ 중간 복잡도**
- UFR-CONT-010: **이미지 생성 API 연동**
- Stable Diffusion 또는 DALL-E API
- 플랫폼별 이미지 크기 최적화
- **해결방안**: 이미지 캐싱, CDN 활용
5) Distribution 서비스
**⚠️ 다중 API 통합 복잡도**
- UFR-DIST-010: **외부 API 다중 연동**
- 우리동네TV API
- 링고비즈 API
- 지니TV API
- SNS API (Instagram, Naver, Kakao)
- **해결방안**: 채널별 독립 처리, 실패 시 재시도, 써킷 브레이커 패턴
6) Participation 서비스
대부분 **✅ 실현 가능**
- 표준적인 CRUD 기능
- 추첨 알고리즘 구현
7) Analytics 서비스
**⚠️ 높은 통합 복잡도**
- UFR-ANAL-010: **통합 대시보드 구현**
- 다중 데이터 소스 통합
- 실시간 데이터 수집 및 표시
- 복잡한 차트 및 그래프
- **해결방안**: 데이터 캐싱 (Redis), WebSocket/SSE, 차트 라이브러리 활용
**[주요 기술 스택 권장사항]**
**백엔드:**
- Node.js/Spring Boot (마이크로서비스)
- Redis (캐싱, 세션, 실시간 데이터)
- PostgreSQL/MongoDB
- RabbitMQ/Kafka (비동기 처리)
**프론트엔드:**
- React/Next.js
- TypeScript
- Zustand/Redux (상태관리)
- React Query (API 상태관리)
- Chart.js/Recharts (차트 라이브러리)
**인프라:**
- Docker/Kubernetes
- API Gateway
- Circuit Breaker
- 로드 밸런서
**외부 API:**
- AI: Claude API / GPT-4 API
- 이미지 생성: Stable Diffusion / DALL-E
- 사업자 검증: 국세청 API
- 배포 채널: 우리동네TV, 링고비즈, 지니TV, SNS APIs
**[주요 기술적 리스크 및 해결방안]**
**1. AI 응답 시간 (10초 목표)**
- 리스크: AI API 응답이 목표 시간을 초과할 수 있음
- 해결방안:
- 프롬프트 최적화로 응답 시간 단축
- 트렌드 분석과 이벤트 추천 병렬 처리
- 비동기 처리 + 폴링 방식
- 부분 응답 스트리밍
**2. 다중 외부 API 의존성**
- 리스크: API 장애 시 서비스 중단
- 해결방안:
- 각 API별 폴백 전략 수립
- 캐싱 적극 활용
- 써킷 브레이커 패턴 적용
- 채널별 독립 처리
**3. 실시간 대시보드 성능**
- 리스크: 다중 데이터 소스 통합으로 인한 성능 저하
- 해결방안:
- Redis 캐싱
- 5분 간격 데이터 폴링
- WebSocket/SSE를 통한 효율적인 업데이트
- 차트 데이터 메모이제이션
**4. 이미지 생성 비용 및 시간**
- 리스크: 이미지 생성 API 비용 및 생성 시간
- 해결방안:
- 생성된 이미지 캐싱
- CDN 활용
- 비동기 생성 + 진행 상황 표시
**[간소화된 플로우 검증]**
✅ **요청사항 반영 확인**
1. ✅ KT인증시스템연동 제외됨
2. ✅ 신규가입 시 무료체험 기능 삭제됨
3. ✅ 5회연속실패시 계정잠금기능 삭제됨
4. ✅ 이벤트커스텀 화면 통합됨 (이벤트추천조회 화면에서 간단히 제목/경품 수정 가능)
5. ✅ UFR-AI-010과 UFR-AI-020 통합됨 (트렌드 분석 + 이벤트 추천 한 번에 처리)
6. ✅ UFR-PART-040 삭제됨 (당첨알림발송)
7. ✅ UFR-ANAL-020 삭제됨 (분석리포트생성)
8. ✅ PDF / Excel 내보내기/다운로드 기능 삭제됨
**[이벤트 생성 플로우 (최종)]**
1. 이벤트 목적 선택 (07-이벤트목적선택)
2. AI 트렌드 분석 및 이벤트 추천 (08-AI이벤트추천)
- 3가지 추천, 제목/경품 간단 수정 가능
3. SNS 이미지 생성 (09-SNS이미지생성)
- 3가지 스타일 카드 중 선택
4. 콘텐츠 편집 (10-콘텐츠편집)
- 텍스트, 색상 편집
5. 배포채널 선택 (11-배포채널선택)
6. 최종승인 (12-최종승인)
**[서비스 간 의존성]**
- User → Event: 사용자 인증 정보 제공
- Event → AI: 트렌드 분석 및 이벤트 추천 요청
- Event → Content: 콘텐츠 생성 요청
- Event → Distribution: 배포 요청
- Distribution → Content: 생성된 콘텐츠 사용
- Participation → Analytics: 참여자 데이터 제공
- Distribution → Analytics: 노출 데이터 제공
**[유저스토리 통계]**
- User 서비스: 4개
- Event 서비스: 7개
- AI 서비스: 1개 (통합)
- Content 서비스: 2개
- Distribution 서비스: 2개
- Participation 서비스: 3개
- Analytics 서비스: 1개
**총 20개 유저스토리**
**[예상 개발 기간]**
- User 서비스: 2주
- Event 서비스: 3주
- AI 서비스: 4주 (프롬프트 엔지니어링 포함)
- Content 서비스: 3주
- Distribution 서비스: 4주 (다중 API 연동)
- Participation 서비스: 2주
- Analytics 서비스: 4주 (통합 대시보드)
**총 예상 기간**: 12-16주 (병렬 개발 시)
```

File diff suppressed because it is too large Load Diff