Merge branch 'main' of https://github.com/hwanny1128/HGZero into feat/meeting

This commit is contained in:
cyjadela 2025-10-27 11:31:18 +09:00
commit a7ce5a6edd
75 changed files with 30507 additions and 10309 deletions

7
.gitignore vendored
View File

@ -43,6 +43,9 @@ examples/
.claude/settings.local.json
# Backup files
design/*/*backup.md
design/*backup.md
design/*/*/*/*back*
design/*/*/*back*
design/*/*back*
design/*back*
backup/
.vscode/settings.json

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,88 @@
# UI/UX 설계서 v1.4.20 업데이트 요약
## 업데이트 일시
2025-10-25
## 변경 사항
### 1. 회의 종료 화면 (07-회의종료) - "옵션 2: 바로 최종 확정" 정책 명확화
**위치**: 인터랙션 섹션, 라인 958-976
**변경 내용**:
- UFR-MEET-050 시나리오 2 명시 추가
- 확인 다이얼로그 메시지 구체화: "바로 최종 확정하시겠습니까? AI가 정리한 내용 그대로 확정됩니다."
- 안건별 검증완료 처리 단계 추가
- 회의록 상태 변경 명확화: "작성중" → "확정완료"
- 이동 페이지 변경: 02-대시보드.html → 10-회의록상세조회.html
- 시나리오 2 특징 상세 설명 추가:
- 회의록 수정 단계를 건너뜀
- AI 생성 내용을 그대로 확정
- 모든 안건이 자동으로 검증완료 처리됨
- 확정 후에도 회의 생성자는 수정 가능 (잠금 해제 필요)
### 2. 회의록 수정 화면 (11-회의록수정) - 안건 기반 충돌 해결 메커니즘 추가
**위치**: 인터랙션 섹션, 라인 1531 이후 (새로운 섹션 9 추가)
**변경 내용**:
- UFR-COLLAB-020 안건 기반 충돌 방지 메커니즘 상세 추가
- **안건 기반 충돌 방지 메커니즘**:
- **다른 안건 동시 편집**: 충돌 없음 (참석자 A는 안건 1, 참석자 B는 안건 2)
- **동일 안건 내 다른 필드 편집**: 자동 병합 (상세 요약, 관련회의록 등)
- **동일 필드 동시 수정**: Last Write Wins 방식 (덮어쓰기 경고 + 선택 옵션)
- **편집 중 표시**:
- 다른 사용자 편집 중인 안건 표시
- 편집자 아바타 + 이름 실시간 표시
- 예: "김민준님이 이 안건을 편집 중입니다" + 아바타
- **충돌 경고 모달**:
- 제목: "동시 수정 감지"
- 메시지: "다른 사용자가 이미 이 내용을 수정했습니다"
- 옵션: "최신 내용 보기" / "내 변경사항 유지"
### 3. 회의록 수정 화면 (11-회의록수정) - UI 구성요소에 편집 중 표시 추가
**위치**: UI 구성요소 > 안건 헤더, 라인 1389-1394
**변경 내용**:
- 안건 헤더에 편집 중 표시 추가:
- 다른 사용자 아바타 + 이름
- 예: "김민준님 편집 중" (아이콘 + 텍스트)
### 4. 회의록 수정 화면 (11-회의록수정) - 에러 처리 섹션 업데이트
**위치**: 에러 처리 섹션
**변경 내용**:
- 충돌 발생 에러 처리 상세화:
- 안건 기반 충돌 방지로 최소화
- 동일 필드 동시 수정 시 경고 모달 표시
- 선택 옵션 제공: 최신 내용 확인 / 내 변경사항 유지
- 병합 실패 시 에러 메시지: "병합 중 오류가 발생했습니다"
### 5. 변경 이력 추가
**위치**: 변경 이력 테이블
**변경 내용**:
- v1.4.20 (2025-10-25) 항목 추가:
- 유저스토리 v2.3.0 반영
- 회의 종료 화면 정책 명확화 (확인 전용, 바로 최종 확정 옵션 상세화)
- UFR-MEET-050: 최종 확정 2가지 시나리오 설명 추가
- UFR-COLLAB-020: 안건 기반 충돌 해결 메커니즘 상세 추가
- 실시간 협업 충돌 방지 정책 강화
## 업데이트 방법
- Python 스크립트를 이용한 자동 업데이트
- 5개의 주요 업데이트 항목 모두 성공적으로 적용됨
## 검증 완료
- ✓ Update 1: 옵션 2: 바로 최종 확정 - Updated
- ✓ Update 2: 안건 기반 충돌 해결 섹션 - Added
- ✓ Update 3: 안건 헤더에 편집 중 표시 - Added
- ✓ Update 4: 충돌 처리 업데이트 - Updated
- ✓ Update 5: 변경 이력 추가 - Added
## 관련 유저스토리
- UFR-MEET-050: 회의록 최종 확정
- UFR-COLLAB-020: 실시간 협업 및 충돌 해결

View File

@ -0,0 +1,217 @@
# 유저스토리 v2.2.0 → v2.3.0 변경사항 요약
## 📊 한눈에 보는 변경사항
```
v2.2.0 (25개) v2.3.0 (27개)
┌─────────────────┐ ┌─────────────────┐
│ AFR-USER-010 │ ──────────────────>│ UFR-USER-010 ✨ │ (로그인 상세화)
│ AFR-USER-020 │ ──────────────────>│ UFR-USER-020 ✨ │ (대시보드 재설계)
├─────────────────┤ ├─────────────────┤
│ UFR-MEET-010 │ ──────────────────>│ UFR-MEET-010 ✨ │ (회의예약 개선)
│ │ │ UFR-MEET-015 🆕 │ (참석자 실시간 초대)
│ UFR-MEET-020 │ ──────────────────>│ UFR-MEET-020 ✨ │ (템플릿선택 상세화)
│ UFR-MEET-030 │ ──────────────────>│ UFR-MEET-030 ✨ │ (회의시작 4개 탭)
│ UFR-MEET-040 │ ──────────────────>│ UFR-MEET-040 ✨ │ (회의종료 3가지 액션)
│ UFR-MEET-050 │ ──────────────────>│ UFR-MEET-050 ✨ │ (최종확정 2가지 시나리오)
│ UFR-MEET-046 │ ──────────────────>│ UFR-MEET-046 ✨ │ (목록조회 샘플 30개)
│ UFR-MEET-047 │ ──────────────────>│ UFR-MEET-047 ✨ │ (상세조회 관련회의록)
│ UFR-MEET-055 │ ──────────────────>│ UFR-MEET-055 ✨ │ (회의록수정 3가지 시나리오)
├─────────────────┤ ├─────────────────┤
│ UFR-AI-010 │ ──────────────────>│ UFR-AI-010 │
│ UFR-AI-020 │ ──────────────────>│ UFR-AI-020 │
│ │ │ UFR-AI-030 🆕🎯 │ (실시간 AI 제안 - 차별화!)
│ UFR-AI-035 │ ──────────────────>│ UFR-AI-035 │
│ UFR-AI-036 │ ──────────────────>│ UFR-AI-036 │
│ UFR-AI-040 │ ──────────────────>│ UFR-AI-040 │
├─────────────────┤ ├─────────────────┤
│ UFR-STT-010 │ ──────────────────>│ UFR-STT-010 │
│ UFR-STT-020 │ ──────────────────>│ UFR-STT-020 │
├─────────────────┤ ├─────────────────┤
│ UFR-RAG-010 │ ──────────────────>│ UFR-RAG-010 │
│ UFR-RAG-020 │ ──────────────────>│ UFR-RAG-020 │
├─────────────────┤ ├─────────────────┤
│ UFR-COLLAB-010 │ ──────────────────>│ UFR-COLLAB-010 │
│ UFR-COLLAB-020 │ ──────────────────>│ UFR-COLLAB-020 │
│ UFR-COLLAB-030 │ ──────────────────>│ UFR-COLLAB-030 │
├─────────────────┤ ├─────────────────┤
│ UFR-TODO-010 │ ──────────────────>│ UFR-TODO-010 │
│ UFR-TODO-030 │ ──────────────────>│ UFR-TODO-030 │
│ UFR-TODO-040 │ ──────────────────>│ UFR-TODO-040 │
└─────────────────┘ ├─────────────────┤
│ UFR-NOTI-010 🆕 │ (알림발송 - 폴링 방식)
└─────────────────┘
범례:
🆕 = 완전 신규 추가
🎯 = 차별화 핵심 기능
✨ = 대폭 개선 (프로토타입 기반 재작성)
```
---
## 🎯 3대 신규 기능
### 1. UFR-MEET-015: 참석자 실시간 초대 🆕
- **위치**: 회의 진행 화면 "참석자" 탭
- **기능**: 회의 중 검색 모달로 참석자 추가 → 실시간 동기화 → 알림 발송
- **의미**: 회의 진행 중 동적 참석자 관리로 유연성 향상
### 2. UFR-AI-030: 실시간 AI 제안 🆕🎯
- **위치**: 회의 진행 화면 "AI 제안" 탭
- **기능**: STT 텍스트 실시간 분석 → 주요 내용 감지 → 제안 카드 생성 → 메모에 추가
- **의미**: **차별화 전략 "지능형 회의 진행 지원" 실현**
- **효과**: 회의 중 놓치는 내용 최소화
### 3. UFR-NOTI-010: 알림 발송 🆕
- **방식**: 폴링 (1분 간격) → 이메일 발송 → 최대 3회 재시도
- **알림 유형**: Todo 할당, Todo 완료, 회의 시작, 회의록 확정, 참석자 초대, 회의록 수정
- **의미**: **알림 아키텍처 폴링 방식으로 통일** → Notification 서비스 독립성 확보
---
## 📈 유저스토리 품질 개선
### 형식 표준화 (Before & After)
#### v2.2.0 (자유 형식)
```
UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, ...
- 시나리오: 회의 예약 및 참석자 초대
회의 예약 화면에 접근한 상황에서 | ...
[입력 요구사항]
- 회의 제목: 최대 100자 (필수)
...
[처리 결과]
- 회의가 예약됨
...
- M/13
```
#### v2.3.0 (표준 형식)
```
### UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, ...
**수행절차:**
1. 대시보드에서 "회의예약" FAB 버튼 클릭
2. 회의 제목 입력 (최대 100자)
3. 날짜 선택 (오늘 이후 날짜, 달력 UI)
...
10. "임시저장" 버튼 또는 "예약 완료" 버튼 클릭
**입력:**
- 회의 제목: 텍스트 입력, 필수, 최대 100자, 문자 카운터 표시
- 날짜: date 타입, 필수, 오늘 이후 날짜만 선택 가능
...
**출력/결과:**
- 예약 완료: "회의가 예약되었습니다" 토스트 메시지, 대시보드로 이동
- 임시저장: "임시 저장되었습니다" 토스트 메시지
...
**예외처리:**
- 제목 미입력: "회의 제목을 입력해주세요" 토스트, 제목 필드 포커스
- 과거 날짜 선택: "과거 날짜는 선택할 수 없습니다" 토스트
...
**관련 유저스토리:**
- UFR-USER-020: 대시보드 조회
- UFR-MEET-020: 템플릿선택
```
### 개선 효과
- ✅ **수행절차**: 단계별 명확한 작업 흐름
- ✅ **입력**: 필드 타입, 검증 규칙, UI 요소 상세 명세
- ✅ **출력/결과**: 성공/실패 시나리오별 응답 명시
- ✅ **예외처리**: 에러 상황별 처리 방법 구체화
- ✅ **관련 유저스토리**: 기능 간 연계성 추적
---
## 🏗️ 프로토타입 연계 강화
| 프로토타입 화면 | 연계 유저스토리 | 상태 |
|----------------|----------------|------|
| 01-로그인.html | UFR-USER-010 | ✅ 1:1 매핑 |
| 02-대시보드.html | UFR-USER-020 | ✅ 1:1 매핑 |
| 03-회의예약.html | UFR-MEET-010 | ✅ 1:1 매핑 |
| 04-템플릿선택.html | UFR-MEET-020 | ✅ 1:1 매핑 |
| 05-회의진행.html | UFR-MEET-030, UFR-MEET-015 (신규), UFR-AI-030 (신규) | ✅ 1:N 매핑 |
| 07-회의종료.html | UFR-MEET-040 | ✅ 1:1 매핑 |
| 10-회의록상세조회.html | UFR-MEET-047 | ✅ 1:1 매핑 |
| 11-회의록수정.html | UFR-MEET-055 | ✅ 1:1 매핑 |
| 12-회의록목록조회.html | UFR-MEET-046 | ✅ 1:1 매핑 |
**결과**: 10개 프로토타입 화면 100% 유저스토리 연계 완료
---
## 🔑 핵심 아키텍처 변경
### 알림 아키텍처: 실시간 → 폴링 방식
#### Before (v2.2.0)
```
[Meeting Service] ──(실시간 발송)──> [Notification Service] ──> [Email]
Todo 할당 발생 → 즉시 이메일 발송
```
#### After (v2.3.0)
```
[Meeting Service] ──(DB 레코드 생성)──> [Notification 테이블]
(1분 간격 폴링)
[Notification Service] ──> [Email]
(발송 상태 업데이트)
```
**개선 효과**:
- ✅ **Notification 서비스 독립성 강화**: 마이크로서비스 간 느슨한 결합
- ✅ **시스템 안정성 향상**: 이메일 발송 실패 시 자동 재시도 (최대 3회)
- ✅ **확장성 확보**: 폴링 주기 조정으로 트래픽 제어 가능
---
## 📊 통계 비교
| 항목 | v2.2.0 | v2.3.0 | 변화 |
|------|--------|--------|------|
| **유저스토리 수** | 25개 | 27개 | +2개 (+8%) |
| **신규 추가** | - | 3개 | - |
| **AFR 코드** | 2개 | 0개 | -2개 (100% 제거) |
| **UFR 코드** | 23개 | 27개 | +4개 (+17%) |
| **평균 상세도** | 20-30줄 | 60-100줄 | **약 3배** |
| **프로토타입 연계** | 부분적 | 100% (10개 화면) | - |
| **표준 형식 적용** | 0% | 100% (27개) | - |
---
## ✅ 권장 후속 조치 체크리스트
### 🔴 긴급 (1주 내)
- [ ] 신규 유저스토리 3개 기반 API 설계 (UFR-MEET-015, UFR-AI-030, UFR-NOTI-010)
- [ ] 알림 아키텍처 폴링 방식 반영 (물리 아키텍처 업데이트)
- [ ] 프로토타입 ↔ 유저스토리 1:1 매핑 검증
### 🟡 중요 (2주 내)
- [ ] API 설계서 v2.3.0 기반 전면 업데이트 (입력/출력 명세 반영)
- [ ] 예외처리 시나리오 → 테스트 케이스 전환
- [ ] 관련 유저스토리 기반 통합 테스트 시나리오 작성
### 🟢 일반 (3주 내)
- [ ] 유저스토리별 개발 우선순위 재평가
- [ ] 신규 기능 3개 개발 일정 수립
- [ ] 프로토타입 기반 개발 가이드 작성
---
**분석 일시**: 2025-10-25
**분석 파일**:
- 상세 분석 (JSON): `claude/userstory-comparison-v2.2.0-to-v2.3.0.json`
- 상세 분석 (Markdown): `claude/userstory-comparison-v2.2.0-to-v2.3.0.md`

View File

@ -0,0 +1,343 @@
{
"comparisonMetadata": {
"previousVersion": "v2.2.0",
"currentVersion": "v2.3.0",
"comparisonDate": "2025-10-25",
"analyst": "Claude (AI Assistant)",
"previousVersionDate": "2025-10-23",
"currentVersionDate": "2025-10-24"
},
"documentStructure": {
"v2.2.0": {
"description": "기존 구조: 유저스토리 섹션과 논리 아키텍처 반영 사항 요약 섹션 포함",
"mainSections": [
"차별화 전략",
"마이크로서비스 구성",
"유저스토리",
"논리 아키텍처 반영 사항 요약",
"문서 이력"
],
"userStoryFormat": "계층적 구조 (서비스 > 기능 그룹 > 유저스토리)",
"userStoryPrefix": "AFR/UFR 혼용",
"totalUserStories": 25
},
"v2.3.0": {
"description": "신규 구조: 프로토타입 기반으로 재정비, 논리 아키텍처 섹션 제거, 유저스토리 형식 표준화",
"mainSections": [
"차별화 전략",
"마이크로서비스 구성",
"유저스토리 v2.3.0 - USER & MEETING 서비스",
"문서 이력"
],
"userStoryFormat": "표준화된 형식 (수행절차, 입력, 출력/결과, 예외처리, 관련 유저스토리)",
"userStoryPrefix": "UFR로 통일 (AFR 제거)",
"totalUserStories": 27
},
"changes": [
"논리 아키텍처 반영 사항 요약 섹션 삭제 (설계 문서로 이관)",
"유저스토리 형식 대폭 개선: 기존의 자유 형식에서 구조화된 템플릿으로 전환",
"모든 유저스토리에 '수행절차', '입력', '출력/결과', '예외처리', '관련 유저스토리' 섹션 추가",
"AFR 코드 제거 및 UFR로 통일 (더 이상 아키텍처 참조 코드 사용하지 않음)",
"프로토타입 화면과의 연계성 강화 (화면 번호, 파일명 명시)",
"유저스토리 ID 체계 유지 (기존 24개 ID 승계)"
]
},
"addedStories": [
{
"code": "UFR-USER-010",
"previousCode": "AFR-USER-010",
"title": "[로그인] 사용자로서 | 나는, 시스템에 접근하기 위해 | 사번과 비밀번호로 로그인하고 싶다",
"description": "기존 AFR-USER-010에서 UFR-USER-010으로 전환. 상세한 수행절차, 입력/출력 명세, 예외처리 추가",
"significance": "프로토타입 01-로그인.html과 직접 연계. 로그인 흐름, 검증 규칙, 에러 처리가 구체화됨",
"newFeatures": [
"로그인 상태 유지 체크박스 추가",
"Enter 키 입력 시 다음 필드로 자동 이동",
"비밀번호 최소 8자 검증",
"로딩 상태 UI 명시",
"이미 로그인된 경우 자동 리다이렉트"
],
"relatedPrototype": "01-로그인.html"
},
{
"code": "UFR-USER-020",
"previousCode": "AFR-USER-020",
"title": "[대시보드] 사용자로서 | 나는, 나의 회의 및 Todo 현황을 파악하기 위해 | 대시보드를 조회하고 싶다",
"description": "기존 AFR-USER-020에서 UFR-USER-020으로 전환. 대시보드 위젯 구성 재정의 및 상세 명세 추가",
"significance": "프로토타입 02-대시보드.html 기반으로 대시보드 구성 완전 재설계. 통계 블록, 최근 회의, Todo, 회의록 섹션 구체화",
"newFeatures": [
"통계 블록 2열 그리드 (예정된 회의, 나의 Todo)",
"최근 회의 목록 (회의록 미생성 우선, 최대 3개)",
"나의 Todo 목록 (미완료 우선, D-day 표시, 최대 3개)",
"나의 회의록 2x2 그리드 (최대 4개)",
"FAB 메뉴 (회의예약, 바로시작)",
"반응형 네비게이션 (데스크톱 사이드바, 모바일 하단 탭)"
],
"relatedPrototype": "02-대시보드.html"
},
{
"code": "UFR-MEET-015",
"previousCode": null,
"title": "[회의진행] 회의 참석자로서 | 나는, 회의 중 추가 참석자가 필요할 때 | 실시간으로 참석자를 초대하고 싶다",
"description": "**신규 추가**: 회의 진행 중 실시간 참석자 초대 기능",
"significance": "프로토타입 05-회의진행.html의 '참석자' 탭 기능 반영. 회의 진행 중 동적 참석자 관리 가능",
"newFeatures": [
"회의 진행 중 검색 모달을 통한 참석자 초대",
"초대된 참석자 실시간 표시",
"Notification 서비스 연동 (초대 알림 발송)",
"모든 참석자에게 WebSocket 기반 실시간 동기화"
],
"relatedPrototype": "05-회의진행.html",
"relatedUserStories": [
"UFR-MEET-030 (회의시작)",
"UFR-COLLAB-010 (회의록수정동기화)",
"UFR-NOTI-010 (알림발송)"
]
},
{
"code": "UFR-AI-030",
"previousCode": null,
"title": "[실시간AI제안] 회의 참석자로서 | 나는, 회의 중 놓치는 내용을 최소화하기 위해 | AI가 실시간으로 주요 내용을 분석하여 제안하고 싶다",
"description": "**신규 추가**: 회의 진행 중 AI 실시간 분석 및 제안 기능",
"significance": "프로토타입 05-회의진행.html의 'AI 제안' 탭 기능 구현. 회의 중 AI가 주요 내용을 감지하여 자동 제안하는 차별화 기능",
"newFeatures": [
"STT 텍스트 실시간 분석",
"주요 내용 감지 시 AI 제안 카드 자동 생성",
"'메모에 추가' 버튼으로 회의 메모에 즉시 추가",
"모든 참석자에게 실시간 동기화",
"로컬 캐시를 통한 네트워크 오류 대응"
],
"relatedPrototype": "05-회의진행.html",
"relatedUserStories": [
"UFR-STT-020 (텍스트변환)",
"UFR-MEET-030 (회의시작)",
"UFR-COLLAB-010 (회의록수정동기화)"
],
"differentiatorImpact": "지능형 회의 진행 지원의 핵심 기능으로, 회의 중 놓치는 내용 최소화"
},
{
"code": "UFR-NOTI-010",
"previousCode": null,
"title": "[알림발송] Notification 시스템으로서 | 나는, 사용자에게 중요한 이벤트를 알리기 위해 | 주기적으로 알림 대상을 확인하여 이메일을 발송하고 싶다",
"description": "**신규 추가**: 알림 시스템 폴링 방식 명세",
"significance": "알림 아키텍처를 실시간 발송에서 주기적 폴링 방식으로 통일. Notification 서비스의 독립성과 안정성 확보",
"newFeatures": [
"주기적 폴링 (1분 간격) 방식 알림 발송",
"이메일 발송 실패 시 최대 3회 재시도",
"알림 유형별 템플릿 적용",
"6가지 알림 유형 지원 (Todo 할당, Todo 완료, 회의 시작, 회의록 확정, 참석자 초대, 회의록 수정)"
],
"relatedUserStories": [
"UFR-TODO-010 (Todo할당)",
"UFR-TODO-030 (Todo완료처리)",
"UFR-MEET-015 (참석자 실시간 초대)",
"UFR-MEET-050 (최종확정)"
],
"architectureImpact": "Notification 서비스를 독립적인 폴링 기반 마이크로서비스로 명확히 정의"
}
],
"removedStories": [
{
"code": "AFR-USER-010",
"title": "[사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 기능을 원한다",
"reason": "UFR-USER-010으로 전환. AFR(아키텍처 참조) 코드 체계 폐지 및 UFR(사용자 기능 요구사항)로 통일",
"impact": "코드 변경일 뿐, 기능은 UFR-USER-010으로 승계되어 유지됨"
},
{
"code": "AFR-USER-020",
"title": "[대시보드] 사용자로서 | 나는, 회의록 서비스의 주요 정보를 한눈에 파악하기 위해 | 대시보드를 통해 요약 정보를 확인하고 싶다",
"reason": "UFR-USER-020으로 전환. 프로토타입 기반으로 상세 명세 재작성",
"impact": "코드 변경 및 내용 대폭 보강. 기능은 강화되어 승계됨"
}
],
"modifiedStories": [
{
"code": "UFR-MEET-010",
"title": "[회의예약] 회의 생성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다",
"changes": [
"프로토타입 03-회의예약.html 기반으로 전면 재작성",
"상세한 수행절차 추가 (10단계)",
"입력 필드 상세 명세 (종일 회의 토글, 온라인/오프라인 회의 토글, 회의 링크 자동 생성)",
"예외처리 8가지 추가 (과거 날짜 선택, 뒤로가기 확인 모달 등)",
"임시저장 기능 추가",
"참석자 검색 모달 UI 상세화"
],
"significance": "프로토타입과의 정확한 매칭으로 개발 시 명확한 가이드 제공"
},
{
"code": "UFR-MEET-020",
"title": "[템플릿선택] 회의 생성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다",
"changes": [
"프로토타입 04-템플릿선택.html 기반으로 재작성",
"4가지 템플릿 내용 상세 명세 (일반, 스크럼, 킥오프, 주간 회의)",
"건너뛰기 옵션 추가",
"템플릿 미리보기 구성 명시 (아이콘, 설명, 섹션 목록)"
],
"significance": "템플릿별 섹션 구성이 구체화되어 일관된 회의록 작성 지원"
},
{
"code": "UFR-MEET-030",
"title": "[회의시작] 회의 생성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다",
"changes": [
"프로토타입 05-회의진행.html 기반으로 전면 재작성",
"8단계 상세 수행절차 추가",
"4개 탭 네비게이션 명시 (참석자, AI 제안, 용어사전, 관련회의록)",
"웨이브폼 애니메이션, 타이머, 녹음 상태 UI 추가",
"하단 고정 메모 영역 추가",
"일시정지 및 종료 확인 모달 추가"
],
"significance": "회의 진행 화면의 핵심 UX가 상세히 정의되어 실시간 협업 기능 구현 가이드 제공"
},
{
"code": "UFR-MEET-040",
"title": "[회의종료] 회의 생성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 요약 내용을 확인한 후 다음 단계를 선택하고 싶다",
"changes": [
"프로토타입 07-회의종료.html 기반으로 재작성",
"통계 카드 4개 명시 (참석자, 시간, 안건, Todo)",
"주요 키워드 태그 표시 추가",
"안건별 아코디언 카드 구조 명시 (AI 한줄 요약 + 상세 요약 + Todo)",
"읽기 전용 안내 표시",
"하단 액션 바 3가지 옵션 명시 (회의록 수정, 바로 최종 확정, 대시보드)"
],
"significance": "회의 종료 후 워크플로우가 명확해져 사용자 선택권 확대 및 UX 개선"
},
{
"code": "UFR-MEET-050",
"title": "[최종확정] 회의 생성자로서 | 나는, 회의록을 완성하기 위해 | 모든 안건을 검증하고 최종 회의록을 확정하고 싶다",
"changes": [
"2가지 시나리오로 분리 (회의록 수정 후 확정, 회의 종료 화면에서 바로 확정)",
"각 시나리오별 수행절차 5-6단계 상세화",
"확인 모달 메시지 구체화",
"바로 확정 시 모든 안건 자동 검증 완료 처리 로직 추가",
"확정 후 편집 권한 정책 명시 (회의 생성자만 잠금 해제 후 수정 가능)"
],
"significance": "유연한 확정 워크플로우 제공으로 사용자 편의성 향상"
},
{
"code": "UFR-MEET-046",
"title": "[회의록목록조회] 회의 참석자로서 | 나는, 참여한 회의록들을 효율적으로 관리하기 위해 | 회의록 목록을 조회하고 필터링하고 싶다",
"changes": [
"프로토타입 12-회의록목록조회.html 기반으로 재작성",
"데이터 소스 명시 (common.js → SAMPLE_MINUTES 배열)",
"필터링 옵션 상세화 (상태별, 정렬, 참여 유형, 검색)",
"통계 표시 추가",
"페이지네이션 방식 명시 (초기 10개, '10개 더보기' 버튼)",
"목록 표시 정보 8가지 추가",
"우선순위 M → S로 변경 (MVP 집중)"
],
"significance": "프로토타입 연계 강화, 샘플 데이터 30개 기반 개발 가능"
},
{
"code": "UFR-MEET-047",
"title": "[회의록상세조회] 회의 참석자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다",
"changes": [
"프로토타입 10-회의록상세조회.html 기반으로 재작성",
"회의 기본 정보 표시 항목 7가지 상세화",
"섹션별 상세 내용 표시 구조 추가",
"관련 회의록 섹션 추가 (최대 3개, 관련도 % 표시)",
"탭 네비게이션 구성 명시 (대시보드, 회의록 2개 탭)"
],
"significance": "회의록 조회 화면 정보 구조 명확화 및 관련 회의록 연결 강화"
},
{
"code": "UFR-MEET-055",
"title": "[회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 수정하고 싶다",
"changes": [
"프로토타입 11-회의록수정.html 기반으로 전면 재작성",
"3가지 시나리오로 확장 (작성중 회의록 수정, 확정완료 회의록 수정, 안건 검증)",
"각 시나리오별 상세 수행절차 추가",
"잠금 해제 메커니즘 명시 (확정완료 회의록의 경우)",
"검증 완료 프로세스 상세화 (안건별 체크 버튼, 검증률 표시)"
],
"significance": "회의록 수정 워크플로우가 상태별로 명확해져 협업 시나리오 지원 강화"
}
],
"overallImpact": {
"userExperience": {
"improvements": [
"프로토타입 기반 유저스토리로 실제 사용 흐름과 정확히 일치",
"상세한 수행절차로 사용자 작업 흐름 명확화",
"예외처리 시나리오 추가로 에러 상황 대응 개선",
"실시간 AI 제안 기능으로 회의 중 놓치는 내용 최소화",
"유연한 확정 워크플로우로 사용자 선택권 확대",
"회의 진행 중 참석자 실시간 초대로 협업 유연성 향상"
],
"keyEnhancements": [
"대시보드 재설계로 정보 접근성 향상 (통계 블록, 최근 회의, Todo, 회의록 4개 섹션)",
"회의 진행 화면 4개 탭으로 기능 분리 (참석자, AI 제안, 용어사전, 관련회의록)",
"회의 종료 화면 3가지 액션 옵션으로 워크플로우 유연성 확보"
]
},
"functionality": {
"improvements": [
"신규 기능 3개 추가 (참석자 실시간 초대, 실시간 AI 제안, 알림 발송)",
"알림 아키텍처 폴링 방식으로 통일하여 시스템 안정성 확보",
"모든 유저스토리에 입력/출력 명세 추가로 API 설계 가이드 제공",
"예외처리 시나리오 추가로 에러 핸들링 강화",
"관련 유저스토리 명시로 기능 간 연계성 파악 용이"
],
"architectureAlignment": [
"Notification 서비스의 독립성 강화 (폴링 방식)",
"프로토타입 10개 화면과 유저스토리 1:1 매핑",
"WebSocket 기반 실시간 동기화 시나리오 명확화"
]
},
"documentation": {
"improvements": [
"유저스토리 형식 표준화로 일관성 확보",
"AFR/UFR 혼용 제거, UFR로 통일하여 코드 체계 단순화",
"프로토타입 파일명 명시로 개발 시 참조 용이성 향상",
"각 유저스토리에 관련 유저스토리 섹션 추가로 추적성 확보",
"문서 구조 간소화 (논리 아키텍처 섹션 제거)"
],
"qualityEnhancement": [
"v2.2.0: 25개 유저스토리, 자유 형식",
"v2.3.0: 27개 유저스토리, 표준 형식 (수행절차, 입력, 출력/결과, 예외처리, 관련 유저스토리)",
"평균 유저스토리 상세도 약 3배 증가 (기존 20-30줄 → 60-100줄)"
]
}
},
"statistics": {
"v2.2.0": {
"totalUserStories": 25,
"afrCodes": 2,
"ufrCodes": 23,
"averageLinesPerStory": "20-30 (추정)"
},
"v2.3.0": {
"totalUserStories": 27,
"afrCodes": 0,
"ufrCodes": 27,
"averageLinesPerStory": "60-100 (추정)"
},
"changes": {
"added": 5,
"removed": 2,
"modified": "대다수 (프로토타입 기반 재작성)"
}
},
"keyTakeaways": [
"v2.3.0은 프로토타입 분석을 통해 유저스토리를 전면 재정비한 버전",
"신규 기능 3개 추가: 참석자 실시간 초대, 실시간 AI 제안, 알림 발송",
"알림 아키텍처를 폴링 방식으로 통일하여 시스템 안정성 확보",
"유저스토리 형식 표준화로 개발 가이드 역할 강화",
"프로토타입 10개 화면과 유저스토리 1:1 매핑으로 개발 명확성 확보",
"기존 24개 유저스토리 ID 승계하여 연속성 유지",
"평균 유저스토리 상세도 약 3배 증가로 품질 대폭 향상"
],
"recommendedActions": [
"API 설계서를 v2.3.0 유저스토리 기반으로 업데이트 (입력/출력 명세 반영)",
"프로토타입과 유저스토리 간 1:1 매핑 검증",
"신규 추가된 UFR-MEET-015, UFR-AI-030, UFR-NOTI-010 기반 API 및 시퀀스 설계",
"알림 아키텍처 폴링 방식 반영하여 물리 아키텍처 업데이트",
"각 유저스토리의 예외처리 시나리오를 테스트 케이스로 전환",
"관련 유저스토리 섹션을 활용하여 통합 테스트 시나리오 작성"
]
}

View File

@ -0,0 +1,404 @@
# 유저스토리 v2.2.0 → v2.3.0 변경사항 분석 보고서
**분석 일시**: 2025-10-25
**이전 버전**: v2.2.0 (2025-10-23)
**현재 버전**: v2.3.0 (2025-10-24)
**분석자**: Claude (AI Assistant)
---
## 📊 주요 통계
| 항목 | v2.2.0 | v2.3.0 | 변화 |
|------|--------|--------|------|
| **총 유저스토리 수** | 25개 | 27개 | +2개 |
| **신규 추가** | - | 5개 | - |
| **삭제 (AFR → UFR 전환)** | 2개 | - | - |
| **AFR 코드** | 2개 | 0개 | -2개 |
| **UFR 코드** | 23개 | 27개 | +4개 |
| **평균 상세도** | 20-30줄 | 60-100줄 | 약 3배 증가 |
---
## 🏗️ 문서 구조 변경
### v2.2.0 구조
```
1. 차별화 전략
2. 마이크로서비스 구성
3. 유저스토리 (자유 형식)
4. 논리 아키텍처 반영 사항 요약
5. 문서 이력
```
### v2.3.0 구조 (개선)
```
1. 차별화 전략
2. 마이크로서비스 구성
3. 유저스토리 v2.3.0 - USER & MEETING 서비스 (표준 형식)
- 수행절차
- 입력
- 출력/결과
- 예외처리
- 관련 유저스토리
4. 문서 이력
```
### 주요 구조 변경사항
- ✅ **논리 아키텍처 반영 사항 요약 섹션 삭제**: 설계 문서로 이관
- ✅ **유저스토리 형식 표준화**: 모든 유저스토리에 5개 필수 섹션 적용
- ✅ **AFR 코드 폐지**: UFR로 통일하여 코드 체계 단순화
- ✅ **프로토타입 연계 강화**: 화면 번호, 파일명 명시
---
## ✨ 신규 추가 유저스토리 (5개)
### 1. UFR-USER-010: [로그인]
**이전**: AFR-USER-010 (아키텍처 참조 코드)
**변경**: UFR-USER-010 (사용자 기능 요구사항 코드)
**주요 개선사항**:
- 프로토타입 `01-로그인.html` 기반 재작성
- 상세 수행절차 6단계 추가
- 입력 검증 규칙 명시 (비밀번호 최소 8자, Enter 키 자동 이동)
- 예외처리 5가지 추가
- 로그인 상태 유지 체크박스 추가
**관련 프로토타입**: `01-로그인.html`
---
### 2. UFR-USER-020: [대시보드]
**이전**: AFR-USER-020
**변경**: UFR-USER-020
**주요 개선사항**:
- 프로토타입 `02-대시보드.html` 기반 전면 재설계
- 통계 블록 2열 그리드 (예정된 회의, 나의 Todo)
- 최근 회의 목록 (최대 3개, 회의록 미생성 우선)
- 나의 Todo 목록 (최대 3개, 미완료 우선, D-day 표시)
- 나의 회의록 2x2 그리드 (최대 4개)
- FAB 메뉴 (회의예약, 바로시작)
- 반응형 네비게이션 (데스크톱 사이드바, 모바일 하단 탭)
**관련 프로토타입**: `02-대시보드.html`
---
### 3. UFR-MEET-015: [회의진행] 참석자 실시간 초대 🆕
**완전 신규 추가**
**기능 설명**:
- 회의 진행 중 추가 참석자가 필요할 때 실시간으로 초대
- 검색 모달을 통한 사용자 검색 및 선택
- 초대된 참석자 실시간 표시
- Notification 서비스 연동 (초대 알림 발송)
- 모든 참석자에게 WebSocket 기반 실시간 동기화
**의미**:
- 프로토타입 `05-회의진행.html`의 "참석자" 탭 기능 구현
- 회의 진행 중 동적 참석자 관리로 유연성 향상
**관련 유저스토리**:
- UFR-MEET-030 (회의시작)
- UFR-COLLAB-010 (회의록수정동기화)
- UFR-NOTI-010 (알림발송)
**관련 프로토타입**: `05-회의진행.html`
---
### 4. UFR-AI-030: [실시간AI제안] 🆕 🎯
**완전 신규 추가** - **차별화 핵심 기능**
**기능 설명**:
- 회의 진행 중 STT 텍스트 실시간 분석
- AI가 주요 내용 감지 시 제안 카드 자동 생성
- 제안 제목, 내용 (1-2문장), 타임스탬프 표시
- "메모에 추가" 버튼으로 회의 메모에 즉시 반영
- 모든 참석자에게 실시간 동기화
- 로컬 캐시를 통한 네트워크 오류 대응
**의미**:
- 프로토타입 `05-회의진행.html`의 "AI 제안" 탭 핵심 기능
- **차별화 전략의 "지능형 회의 진행 지원" 실현**
- 회의 중 놓치는 내용 최소화로 회의록 품질 향상
**관련 유저스토리**:
- UFR-STT-020 (텍스트변환)
- UFR-MEET-030 (회의시작)
- UFR-COLLAB-010 (회의록수정동기화)
**관련 프로토타입**: `05-회의진행.html`
---
### 5. UFR-NOTI-010: [알림발송] 🆕
**완전 신규 추가** - **알림 아키텍처 정의**
**기능 설명**:
- 주기적 폴링 방식 (1분 간격) 알림 발송
- Notification 테이블에서 발송 대기 알림 조회
- 이메일 발송 실패 시 최대 3회 재시도
- 알림 유형별 템플릿 적용
- 6가지 알림 유형 지원:
- Todo 할당
- Todo 완료
- 회의 시작 (10분 전)
- 회의록 확정
- 참석자 초대
- 회의록 수정
**의미**:
- **알림 아키텍처를 실시간 발송에서 폴링 방식으로 통일**
- Notification 서비스의 독립성과 안정성 확보
- 마이크로서비스 간 느슨한 결합 실현
**관련 유저스토리**:
- UFR-TODO-010 (Todo할당)
- UFR-TODO-030 (Todo완료처리)
- UFR-MEET-015 (참석자 실시간 초대)
- UFR-MEET-050 (최종확정)
---
## ❌ 삭제된 유저스토리 (2개)
### 1. AFR-USER-010: [사용자관리]
**삭제 이유**: UFR-USER-010으로 전환 (AFR 코드 체계 폐지)
**영향**: 기능은 UFR-USER-010으로 승계되어 유지됨
### 2. AFR-USER-020: [대시보드]
**삭제 이유**: UFR-USER-020으로 전환 (프로토타입 기반 재작성)
**영향**: 기능은 강화되어 UFR-USER-020으로 승계됨
---
## 🔄 주요 수정된 유저스토리
### 1. UFR-MEET-010: [회의예약]
**변경사항**:
- 프로토타입 `03-회의예약.html` 기반 전면 재작성
- 상세한 수행절차 10단계 추가
- 입력 필드 상세 명세 (종일 회의 토글, 온라인/오프라인 회의 토글, 회의 링크 자동 생성)
- 예외처리 8가지 추가 (과거 날짜 선택, 뒤로가기 확인 모달 등)
- 임시저장 기능 추가
- 참석자 검색 모달 UI 상세화
**의미**: 프로토타입과의 정확한 매칭으로 개발 시 명확한 가이드 제공
---
### 2. UFR-MEET-020: [템플릿선택]
**변경사항**:
- 프로토타입 `04-템플릿선택.html` 기반 재작성
- 4가지 템플릿 내용 상세 명세:
- 일반 회의: 회의 개요, 논의 사항, 결정 사항, 액션 아이템
- 스크럼 회의: 어제 한 일, 오늘 할 일, 블로커/이슈
- 킥오프 회의: 프로젝트 개요, 목표 및 범위, 역할 및 책임, 일정 및 마일스톤
- 주간 회의: 지난주 성과, 이번주 계획, 주요 이슈, 다음 액션
- 건너뛰기 옵션 추가
- 템플릿 미리보기 구성 명시
**의미**: 템플릿별 섹션 구성이 구체화되어 일관된 회의록 작성 지원
---
### 3. UFR-MEET-030: [회의시작]
**변경사항**:
- 프로토타입 `05-회의진행.html` 기반 전면 재작성
- 8단계 상세 수행절차 추가
- **4개 탭 네비게이션 명시**:
- 참석자: 참석자 목록 및 실시간 초대
- **AI 제안**: 실시간 AI 분석 결과 및 메모 추가 (신규)
- 용어사전: 자동 추출된 용어 및 검색
- 관련회의록: 자동 연결된 이전 회의록
- 웨이브폼 애니메이션, 타이머, 녹음 상태 UI 추가
- 하단 고정 메모 영역 추가
- 일시정지 및 종료 확인 모달 추가
**의미**: 회의 진행 화면의 핵심 UX가 상세히 정의되어 실시간 협업 기능 구현 가이드 제공
---
### 4. UFR-MEET-040: [회의종료]
**변경사항**:
- 프로토타입 `07-회의종료.html` 기반 재작성
- 통계 카드 4개 명시 (참석자, 시간, 안건, Todo)
- 주요 키워드 태그 표시 추가
- 안건별 아코디언 카드 구조 명시:
- AI 한줄 요약 (30자 이내, 편집 불가)
- AI 상세 요약 (편집 가능, 재생성 가능)
- 자동 추출된 Todo 목록
- 읽기 전용 안내 표시
- **하단 액션 바 3가지 옵션**:
- 옵션 1: 회의록 수정 → 회의록 수정 화면으로 이동
- 옵션 2: 바로 최종 확정 → 모든 안건 자동 검증 완료 처리
- 옵션 3: 대시보드 → 대시보드로 이동
**의미**: 회의 종료 후 워크플로우가 명확해져 사용자 선택권 확대 및 UX 개선
---
### 5. UFR-MEET-050: [최종확정]
**변경사항**:
- **2가지 시나리오로 분리**:
- 시나리오 1: 회의록 수정 화면에서 최종 확정
- 시나리오 2: 회의 종료 화면에서 바로 확정
- 각 시나리오별 수행절차 5-6단계 상세화
- 확인 모달 메시지 구체화
- 바로 확정 시 모든 안건 자동 검증 완료 처리 로직 추가
- 확정 후 편집 권한 정책 명시 (회의 생성자만 잠금 해제 후 수정 가능)
**의미**: 유연한 확정 워크플로우 제공으로 사용자 편의성 향상
---
### 6. UFR-MEET-046: [회의록목록조회]
**변경사항**:
- 프로토타입 `12-회의록목록조회.html` 기반 재작성
- **데이터 소스 명시**: `common.js``SAMPLE_MINUTES` 배열 (30개 샘플 데이터)
- 필터링 옵션 상세화:
- 상태별: 전체 / 작성중 / 확정완료
- 정렬: 최근수정순 / 최근회의순 / 제목순
- 참여 유형: 참석한 회의 / 생성한 회의
- 검색: 회의 제목, 참석자, 키워드
- 통계 표시 추가
- 페이지네이션 방식 명시 (초기 10개, "10개 더보기" 버튼)
- 목록 표시 정보 8가지 추가
- **우선순위 변경**: M (Must) → S (Should) - MVP 집중
**의미**: 프로토타입 연계 강화, 샘플 데이터 30개 기반 개발 가능
---
### 7. UFR-MEET-047: [회의록상세조회]
**변경사항**:
- 프로토타입 `10-회의록상세조회.html` 기반 재작성
- 회의 기본 정보 표시 항목 7가지 상세화
- 섹션별 상세 내용 표시 구조 추가
- **관련 회의록 섹션 추가** (최대 3개, 관련도 % 표시)
- 탭 네비게이션 구성 명시 (대시보드, 회의록 2개 탭)
**의미**: 회의록 조회 화면 정보 구조 명확화 및 관련 회의록 연결 강화
---
### 8. UFR-MEET-055: [회의록수정]
**변경사항**:
- 프로토타입 `11-회의록수정.html` 기반 전면 재작성
- **3가지 시나리오로 확장**:
- 시나리오 1: 작성중 회의록 수정
- 시나리오 2: 확정완료 회의록 수정 (잠금 해제 필요)
- 시나리오 3: 안건 검증
- 각 시나리오별 상세 수행절차 추가
- 잠금 해제 메커니즘 명시 (확정완료 회의록의 경우)
- 검증 완료 프로세스 상세화 (안건별 체크 버튼, 검증률 표시)
**의미**: 회의록 수정 워크플로우가 상태별로 명확해져 협업 시나리오 지원 강화
---
## 🎯 전체 영향 분석
### 1. 사용자 경험 (UX) 개선
#### 주요 개선사항
- ✅ **프로토타입 기반 유저스토리**로 실제 사용 흐름과 정확히 일치
- ✅ **상세한 수행절차**로 사용자 작업 흐름 명확화
- ✅ **예외처리 시나리오 추가**로 에러 상황 대응 개선
- ✅ **실시간 AI 제안 기능**으로 회의 중 놓치는 내용 최소화 (차별화)
- ✅ **유연한 확정 워크플로우**로 사용자 선택권 확대
- ✅ **회의 진행 중 참석자 실시간 초대**로 협업 유연성 향상
#### 핵심 UX 강화
- **대시보드 재설계**: 통계 블록, 최근 회의, Todo, 회의록 4개 섹션으로 정보 접근성 향상
- **회의 진행 화면 4개 탭**: 참석자, AI 제안, 용어사전, 관련회의록으로 기능 분리
- **회의 종료 화면 3가지 액션 옵션**: 회의록 수정, 바로 최종 확정, 대시보드로 워크플로우 유연성 확보
---
### 2. 기능성 (Functionality) 개선
#### 신규 기능
1. **UFR-MEET-015**: 회의 진행 중 참석자 실시간 초대
2. **UFR-AI-030**: 실시간 AI 제안 (차별화 핵심)
3. **UFR-NOTI-010**: 알림 발송 (폴링 방식)
#### 아키텍처 정렬
- **알림 아키텍처 폴링 방식으로 통일**: 실시간 발송 → 주기적 폴링 (1분 간격)
- **Notification 서비스 독립성 강화**: 마이크로서비스 간 느슨한 결합
- **프로토타입 10개 화면과 유저스토리 1:1 매핑**: 개발 명확성 확보
- **WebSocket 기반 실시간 동기화 시나리오 명확화**: 협업 기능 강화
#### API 설계 가이드 제공
- 모든 유저스토리에 **입력/출력 명세** 추가
- **예외처리 시나리오** 추가로 에러 핸들링 강화
- **관련 유저스토리** 명시로 기능 간 연계성 파악 용이
---
### 3. 문서화 (Documentation) 개선
#### 표준화 및 일관성
- ✅ **유저스토리 형식 표준화**: 5개 필수 섹션 (수행절차, 입력, 출력/결과, 예외처리, 관련 유저스토리)
- ✅ **AFR/UFR 혼용 제거**: UFR로 통일하여 코드 체계 단순화
- ✅ **프로토타입 파일명 명시**: 개발 시 참조 용이성 향상
- ✅ **관련 유저스토리 섹션 추가**: 추적성 확보
- ✅ **문서 구조 간소화**: 논리 아키텍처 섹션 제거 (설계 문서로 이관)
#### 품질 향상
| 지표 | v2.2.0 | v2.3.0 | 개선율 |
|------|--------|--------|--------|
| 유저스토리 수 | 25개 | 27개 | +8% |
| 평균 상세도 | 20-30줄 | 60-100줄 | **약 3배** |
| 코드 체계 통일 | AFR/UFR 혼용 | UFR로 통일 | 100% 통일 |
| 프로토타입 연계 | 부분적 | 1:1 매핑 | 100% 매핑 |
---
## 💡 핵심 시사점 (Key Takeaways)
1. **v2.3.0은 프로토타입 분석을 통해 유저스토리를 전면 재정비한 버전**
2. **신규 기능 3개 추가**: 참석자 실시간 초대, 실시간 AI 제안, 알림 발송
3. **알림 아키텍처를 폴링 방식으로 통일**하여 시스템 안정성 확보
4. **유저스토리 형식 표준화**로 개발 가이드 역할 강화
5. **프로토타입 10개 화면과 유저스토리 1:1 매핑**으로 개발 명확성 확보
6. **기존 24개 유저스토리 ID 승계**하여 연속성 유지
7. **평균 유저스토리 상세도 약 3배 증가**로 품질 대폭 향상
---
## 📋 권장 후속 조치 (Recommended Actions)
### 1. 설계 문서 업데이트
- [ ] **API 설계서**를 v2.3.0 유저스토리 기반으로 업데이트 (입력/출력 명세 반영)
- [ ] 신규 추가된 **UFR-MEET-015, UFR-AI-030, UFR-NOTI-010** 기반 API 및 시퀀스 설계
- [ ] **알림 아키텍처 폴링 방식** 반영하여 물리 아키텍처 업데이트
### 2. 프로토타입 검증
- [ ] 프로토타입과 유저스토리 간 **1:1 매핑 검증**
- [ ] 프로토타입 화면별 유저스토리 커버리지 확인
### 3. 테스트 계획
- [ ] 각 유저스토리의 **예외처리 시나리오를 테스트 케이스로 전환**
- [ ] **관련 유저스토리 섹션**을 활용하여 통합 테스트 시나리오 작성
- [ ] 신규 기능 3개에 대한 우선 테스트 계획 수립
### 4. 개발 가이드
- [ ] 유저스토리별 개발 우선순위 재평가
- [ ] 신규 기능 3개 개발 일정 수립
- [ ] 프로토타입 기반 개발 가이드 작성
---
## 📎 참조 파일
- **v2.2.0**: `C:\Users\yabo0\home\workspace\HGZero\design\userstory_v2.2.0_backup.md`
- **v2.3.0**: `C:\Users\yabo0\home\workspace\HGZero\design\userstory.md`
- **상세 분석 (JSON)**: `C:\Users\yabo0\home\workspace\HGZero\claude\userstory-comparison-v2.2.0-to-v2.3.0.json`
---
**분석 완료** ✅

View File

@ -0,0 +1,53 @@
# 유저스토리 M/S/C 및 기능점수 분석
## 분석 대상
파일: design/userstory.md (v2.1.2)
총 요구사항: 25개
## M/S/C 및 기능점수 현황
| Line | 요구사항 ID | M/S/C | 점수 | 서비스 | 기능 |
|------|-----------|-------|------|--------|------|
| 75 | AFR-USER-010 | M | 8 | User | 사용자 인증 |
| 135 | AFR-USER-020 | M | 8 | User | 대시보드 |
| 157 | UFR-MEET-010 | M | 13 | Meeting | 회의예약 |
| 179 | UFR-MEET-020 | S | 5 | Meeting | 템플릿선택 |
| 200 | UFR-MEET-030 | M | 8 | Meeting | 회의시작 |
| 250 | UFR-MEET-040 | M | 8 | Meeting | 회의종료 |
| 316 | UFR-MEET-050 | M | 13 | Meeting | 최종확정 |
| 357 | UFR-MEET-046 | M | 8 | Meeting | 회의록목록조회 |
| 423 | UFR-MEET-047 | M | 8 | Meeting | 회의록상세조회 |
| 473 | UFR-MEET-055 | M | 13 | Meeting | 회의록수정 |
| 508 | UFR-STT-010 | M | 21 | STT | 음성녹음인식 |
| 536 | UFR-STT-020 | M | 13 | STT | 텍스트변환 |
| 594 | UFR-AI-010 | M | 34 | AI | 회의록자동작성 |
| 643 | UFR-AI-020 | M | 21 | AI | Todo자동추출 |
| 683 | UFR-AI-035 | M | 21 | AI | 섹션AI요약 |
| 718 | UFR-AI-040 | M | 21 | AI | 관련회의록연결 |
| 756 | UFR-AI-050 | S | 13 | AI | 용어설명 |
| 796 | UFR-AI-060 | S | 13 | AI | 회의록검색 |
| 844 | UFR-AI-070 | S | 21 | AI | 회의패턴분석 |
| 889 | UFR-COLLAB-010 | M | 34 | Meeting | 실시간협업 |
| 945 | UFR-COLLAB-020 | M | 21 | Meeting | 충돌방지 |
| 988 | UFR-TODO-010 | M | 8 | Meeting | Todo관리 |
| 1045 | UFR-TODO-020 | M | 13 | Meeting | Todo연결 |
| 1084 | UFR-NOTI-010 | M | 8 | Notification | 알림발송 |
| 1170 | NFR-PERF-010 | M | 13 | 전체 | 성능요구사항 |
## 통계
- Must (M): 20개 (80%)
- Should (S): 5개 (20%)
- Could (C): 0개 (0%)
- 평균 기능점수: 15.48점
- 최고점: 34점 (UFR-AI-010, UFR-COLLAB-010)
- 최저점: 5점 (UFR-MEET-020)
## 서비스별 분포
- User: 2개 (M:2)
- Meeting: 11개 (M:9, S:0)
- STT: 2개 (M:2)
- AI: 7개 (M:4, S:3)
- Notification: 1개 (M:1)
- NFR: 1개 (M:1)
- 실시간협업/Todo: Meeting 서비스에 통합됨

220
claude/userstory-review.md Normal file
View File

@ -0,0 +1,220 @@
# 유저스토리 M/S/C 및 기능점수 검토 결과
## 검토자: 민준(PO), 서연(AI), 준호(Backend), 유진(Frontend), 도현(QA), 지수(Designer)
---
## 1. M/S/C 우선순위 검토
### ✅ 적절한 Must (M) 항목들
1. **AFR-USER-010** (사용자 인증, M/8) - 핵심 보안 기능
2. **UFR-MEET-010** (회의예약, M/13) - 서비스 핵심 플로우
3. **UFR-MEET-030** (회의시작, M/8) - 서비스 핵심 플로우
4. **UFR-MEET-040** (회의종료, M/8) - 서비스 핵심 플로우
5. **UFR-MEET-050** (최종확정, M/13) - 회의록 완성 필수
6. **UFR-STT-010** (음성녹음인식, M/21) - 기본 기능이지만 필수
7. **UFR-AI-010** (회의록자동작성, M/34) - 핵심 차별화
8. **UFR-COLLAB-010** (실시간협업, M/34) - 핵심 차별화
### ⚠️ M → S로 변경 제안
**UFR-MEET-046** (회의록목록조회, M/8 → **S/8**)
- **이유**: 대시보드에서 최근 회의록을 볼 수 있으므로 1차 출시에서는 목록 조회가 없어도 서비스 가능
- **민준(PO)**: 사용자들이 과거 회의록을 찾기 위해서는 필요하지만, MVP에서는 대시보드만으로도 가능
- **유진(Frontend)**: 1차에서는 대시보드의 "최근 회의" 섹션으로 충분, 필터/검색은 2차 출시 추가 가능
**UFR-MEET-047** (회의록상세조회, M/8 → **S/8**)
- **이유**: 대시보드에서 회의록을 바로 열 수 있으므로, 별도 상세조회 화면은 2차 출시 가능
- **민준(PO)**: 상세조회는 있으면 좋지만, 대시보드 → 수정 화면으로 바로 진입 가능하면 MVP 가능
**AFR-USER-020** (대시보드, M/8 → **유지 M/8**)
- **검토**: 대시보드는 사용자 경험의 시작점이므로 Must 유지 필요
- **지수(Designer)**: 대시보드는 사용자 첫 인상과 전체 서비스 파악에 핵심적
### ⚠️ S → M으로 변경 제안
**UFR-AI-050** (용어설명, S/13 → **M/13**)
- **이유**: 차별화 전략 문서에서 "맥락 기반 용어 설명"을 핵심 차별화 포인트로 명시
- **서연(AI)**: 이 기능이 경쟁사와의 핵심 차별점이므로 1차 출시에 포함해야 함
- **민준(PO)**: 차별화 전략과 일관성을 위해 Must로 승격 필요
**UFR-AI-060** (회의록검색, S/13 → **유지 S/13**)
- **검토**: RAG 기반 검색은 고도화 기능으로 2차 출시 적절
- **서연(AI)**: 벡터 DB 구축 시간 고려 시 2차 출시가 현실적
### ⚠️ Should(S) 추가 제안
**UFR-TODO-020** (Todo연결, M/13 → **S/13**)
- **이유**: Todo 기본 관리(UFR-TODO-010)만 있어도 서비스 가능, 양방향 연결은 고도화 기능
- **준호(Backend)**: Todo-회의록 양방향 연결 복잡도가 높아 2차 출시 권장
- **민준(PO)**: 차별화 전략에 "강화된 Todo 연결"이 있지만, 기본 Todo 관리로도 차별화 가능
---
## 2. 기능점수 검토
### ⚠️ 점수 조정 제안
#### **UFR-AI-035** (섹션AI요약, M/21 → **M/13**)
**현재 점수**: 21
**조정 점수**: 13
**이유**:
- 복잡도: 단일 섹션 요약은 비교적 단순 (LLM 단일 호출)
- UFR-AI-010(회의록자동작성)의 부분 기능으로 기술 재사용 가능
- 처리 시간: 2-5초로 명시되어 있어 기술적 난이도 낮음
**서연(AI)**: 프롬프트 엔지니어링 재사용으로 8-13점 정도가 적절
#### **UFR-AI-040** (관련회의록연결, M/21 → **M/13**)
**현재 점수**: 21
**조정 점수**: 13
**이유**:
- 복잡도: 벡터 유사도 검색 표준 기술 사용
- UFR-AI-060(회의록검색)과 기술 스택 공유
- RAG 인프라 구축 시 함께 개발 가능
**서연(AI)**: 벡터 DB 구축되면 단순 유사도 검색이므로 13점 적절
#### **UFR-STT-010** (음성녹음인식, M/21 → **M/13**)
**현재 점수**: 21
**조정 점수**: 13
**이유**:
- 기본 기능으로 Azure Speech 등 외부 API 사용
- 화자 식별 없이 단순 텍스트 변환만
- 기술 리스크 낮음 (검증된 외부 서비스)
**준호(Backend)**: Azure Speech SDK 연동은 복잡도 낮아 13점 적절
#### **UFR-MEET-010** (회의예약, M/13 → **M/8**)
**현재 점수**: 13
**조정 점수**: 8
**이유**:
- 복잡도: 기본 CRUD + 이메일 발송
- 알림 서비스(UFR-NOTI-010, M/8)에 의존하지만 단순 호출
- 캘린더 연동은 나중 고도화 가능
**준호(Backend)**: 기본 예약 기능은 8점, 캘린더 자동 등록 제외 시 더 낮아질 수 있음
#### **UFR-TODO-020** (Todo연결, M/13 → **S/13 유지**)
**현재 점수**: 13
**조정 후**: S/13
**이유**:
- 복잡도: Todo-회의록 양방향 동기화는 고도화 기능
- UFR-TODO-010 (기본 Todo 관리)로도 서비스 가능
**준호(Backend)**: 양방향 연결은 복잡도 13점 적절, Should로 분류 권장
#### **UFR-COLLAB-010** (실시간협업, M/34) - **점수 유지**
**검토 결과**: 34점 적절
**이유**:
- WebSocket 인프라 + 버전 관리 + 충돌 해결
- 기술 복잡도 매우 높음
- 다수 동시 접속 처리 필요
**준호(Backend)**: WebSocket 서버 + Redis 캐시 + 버전 관리 로직으로 34점 타당
#### **UFR-AI-010** (회의록자동작성, M/34) - **점수 유지**
**검토 결과**: 34점 적절
**이유**:
- 실시간 처리 + 회의 종료 시 전체 요약 (2단계)
- 템플릿 반영 + 주요 메모 통합
- LLM 프롬프트 엔지니어링 복잡도 높음
**서연(AI)**: 실시간/배치 2단계 처리로 34점 타당
---
## 3. 종합 개선 제안
### M/S/C 변경 요약
| 요구사항 ID | 현재 | 제안 | 변경 이유 |
|-----------|------|------|----------|
| UFR-MEET-046 | M/8 | S/8 | 대시보드로 대체 가능 |
| UFR-MEET-047 | M/8 | S/8 | 대시보드 → 수정 바로 진입 가능 |
| UFR-AI-050 | S/13 | M/13 | 차별화 전략 핵심 |
| UFR-TODO-020 | M/13 | S/13 | 기본 Todo로 충분 |
### 기능점수 변경 요약
| 요구사항 ID | 현재 | 제안 | 변경 이유 |
|-----------|------|------|----------|
| UFR-AI-035 | M/21 | M/13 | 기술 재사용으로 복잡도 낮음 |
| UFR-AI-040 | M/21 | M/13 | 표준 벡터 검색 기술 |
| UFR-STT-010 | M/21 | M/13 | 외부 API 사용으로 리스크 낮음 |
| UFR-MEET-010 | M/13 | M/8 | 기본 CRUD 수준 |
### 변경 후 통계
**M/S/C 분포**:
- Must (M): 18개 (72%) ← 기존 20개
- Should (S): 7개 (28%) ← 기존 5개
- Could (C): 0개 (0%)
**평균 기능점수**:
- 변경 전: 15.48점
- 변경 후: 14.00점 (약 10% 감소)
**총 기능점수**:
- 변경 전: 387점
- 변경 후: 350점
---
## 4. MVP 출시 범위 권장사항
### 1차 출시 (Must만)
- 사용자 인증 및 대시보드
- 회의 예약/시작/종료/확정
- 음성 인식 및 회의록 자동 작성
- Todo 자동 추출 및 기본 관리
- 섹션 AI 요약 재생성
- **용어 설명 (차별화)**
- 관련 회의록 자동 연결
- 실시간 협업 및 충돌 방지
- 알림 발송
총 기능점수: **292점** (변경 후)
### 2차 출시 (Should 추가)
- 회의록 목록 조회 및 필터링
- 회의록 상세 조회 전용 화면
- 템플릿 선택 및 커스터마이징
- 회의록 RAG 검색
- 회의 패턴 분석 및 추천
- Todo 양방향 연결 강화
총 추가 기능점수: **58점**
---
## 5. 리스크 및 제약사항
### 기술 리스크
1. **UFR-COLLAB-010** (실시간협업, 34점)
- WebSocket 동시 접속 부하 테스트 필수
- Redis 캐시 장애 시 대응 방안 필요
2. **UFR-AI-010** (회의록자동작성, 34점)
- LLM 응답 시간 변동성 관리
- 프롬프트 품질 검증 시간 필요
3. **UFR-AI-050** (용어설명, M/13)
- RAG 인프라 구축 리드타임 (벡터 DB, 임베딩)
- 사내 문서 수집 및 전처리 시간
### 일정 제약
- 변경 후 총 기능점수: 350점
- 1인당 월 평균 생산성: 30-40점 (경험치)
- 4명 개발팀 기준: **약 2.5개월** 소요 예상
---
## 6. 최종 권고사항
### 지수(Designer)
- UFR-MEET-046, UFR-MEET-047을 Should로 변경하되, 사용자 경험 테스트 후 재검토 권장
- 대시보드만으로 충분한지 프로토타입 단계에서 검증 필요
### 서연(AI)
- UFR-AI-050(용어설명)은 차별화 전략 핵심이므로 Must로 승격 강력 권장
- UFR-AI-035, UFR-AI-040의 점수 하향은 기술 재사용 관점에서 타당
### 준호(Backend)
- UFR-TODO-020을 Should로 변경하여 개발 복잡도 분산 권장
- UFR-COLLAB-010의 기술 리스크 대비 시간 확보 필요
### 도현(QA)
- Must 항목 18개로 축소하면 테스트 범위 집중 가능
- 실시간 협업(UFR-COLLAB-010) 부하 테스트 충분한 시간 필요
### 민준(PO)
- 차별화 전략과 일관성 유지를 위해 UFR-AI-050을 Must로 승격 승인
- MVP 범위를 명확히 하여 1차 출시 집중, 2차 출시 계획 수립

154
claude/userstory-writing.md Normal file
View File

@ -0,0 +1,154 @@
# 유저스토리 작성 방법
## 개요
이 가이드는 마이크로서비스 기반 시스템 개발을 위한 유저스토리 작성 표준을 제공합니다.
표준화된 형식을 통해 일관성 있고 완전한 요구사항을 정의할 수 있습니다.
## 작성 구성 요소
### 1. 서비스
마이크로서비스명을 명시합니다.
- **형식**: 서비스 도메인명
- **예시**: 홈페이지, 가입설계, 주문관리, 결제처리
### 2. ID
User Story ID로서 표준화된 식별자입니다.
- **형식**: `<유저스토리 유형 코드>-<서비스약어>-<일련번호>`
- 유저스토리 유형 코드
- UFR(User Functional Requirements): 사용자 기능 요구사항
- AFR(Admin Functional Requirements): 어드민 기능 요구사항
- NFR(Non Functiional Requirements): 비기능 요구사항(확장성, 회복성, 유연성, 성능, 보안, 운영성)
- 서비스약어: 3~4자로 작성
- 일련번호: 3자리로 하고 010부터 시작하여 10개씩 증가 예) UFR-HOME-010, UFR-HOME-020
- **예시**:
- `UFR-HOME-010`: 홈페이지 서비스의 첫 번째 유저스토리
- `UFR-PAY-020`: 결제 서비스의 다섯 번째 유저스토리
### 3. Epic
유저스토리의 상위 카테고리를 분류합니다.
- **용도**: 관련 유저스토리들을 그룹화
- **예시**: 사용자 관리, 상품 관리, 주문 처리
### 4. 유저스토리
표준 형식에 따라 작성합니다. 각 파트는 파이프로 구분합니다.
- **형식**: `[유저스토리 제목] <유저유형>으로서 | 나는, <비즈니스 목적>을 위해 | <작업/기능>을(를) 원합니다.`
- **예시**:
- [상품검색] 쇼핑몰 고객으로서 | 나는, 상품을 쉽게 찾기 위해 | 카테고리별 상품 검색 기능을 원합니다.
- [주문현황] 관리자로서 | 나는, 주문 상태를 파악하기 위해 | 실시간 주문 현황 대시보드를 원합니다.
중요) 유저유형은 사람 뿐 아니라 시스템, API 등으로 정의할 수도 있음
- 이벤트 스토밍은 사용자 중심으로 수행하기 위해 사람만 Actor로 허용
- 유저스토리는 충분한 요구사항 전달을 위해 사람이 아닌 유저유형도 허용함
### 5. Biz중요도 (MoSCoW 분류)
우선순위에 따른 분류입니다.
| 분류 | 의미 | 설명 |
|------|------|------|
| **M (Must)** | 반드시 필요 | 핵심 비즈니스 기능, 없으면 서비스 불가능 |
| **S (Should)** | 매우 필요하나 대체할 방법은 있음 | 중요하지만 우회 방법이 존재 |
| **C (Could)** | 있으면 좋으나 우선 순위는 떨어짐 (Nice to have) | 사용자 편의성 향상 기능 |
| **W (Won't)** | 가장 우선순위가 떨어지므로 보류해도 됨 | 향후 개발 고려 기능 |
### 6. 인수테스트 시나리오
기능 완성도를 검증하기 위한 테스트 시나리오입니다.
#### 시나리오명
- 테스트할 기능이나 상황을 명확히 표현
- **예시**: "정상적인 회원 가입 처리", "중복 이메일 가입 시도"
#### 인수기준 (Given-When-Then 형식)
- **형식**: `<Given> | <When> | <Then>`
- **Given (사전 조건/상황)**: 테스트 실행 전 준비사항
- **When (Action)**: 사용자가 수행하는 액션
- **Then (결과)**: 기대되는 결과 또는 시스템 반응
- **예시**
```
미 로그인 상태로 서비스에 접근하여 | ID와 암호를 입력하여 로그인 요청을 하면 | 대시보드 페이지가 표시된다.
```
#### 체크리스트
세부 테스트 항목을 최대한 자세히 작성합니다.
- 기능/비기능 요구사항 검증 항목
- 예외 상황 처리 검증
- 통합 테스트 항목
### 7. Score
구현 난이도를 피보나치 수열을 이용하여 표현합니다.
- **수열**: 1, 2, 3, 5, 8, 13, 21, 34, 55, 89...
- **기준**:
| 점수 | 난이도 | 설명 | 세부 기준 | 예시 |
|------|--------|------|-----------|------|
| **1-2** | **매우 간단** | 기본적인 CRUD 작업 | • 단일 파일 수정<br>• 간단한 설정 변경<br>• 단순 명령어 실행 | • README 작성<br>• 간단한 스크립트 실행<br>• 환경변수 설정 |
| **3-5** | **간단** | 기본 비즈니스 로직 포함 | • 여러 파일 수정<br>• 간단한 테스트 작성<br>• 기본 에러 처리 | • 설정 파일 파싱<br>• 간단한 데이터 변환<br>• 기본 유효성 검사 |
| **8-13** | **보통** | 복잡한 비즈니스 로직 | • API 연동<br>• 데이터베이스 처리<br>• 복잡한 알고리즘<br>• 외부 도구 통합 | • MCP 서버 연동<br>• 파일 시스템 조작<br>• CLI 인터페이스 개발 |
| **21-34** | **복잡** | 다중 시스템 연동 | • 여러 서비스 통합<br>• 복잡한 상태 관리<br>• 성능 최적화 필요<br>• 보안 고려사항 | • GitHub API 통합<br>• 실시간 모니터링<br>• 복잡한 워크플로우 |
| **55+** | **매우 복잡** | 새로운 기술/패러다임 | • 신규 아키텍처 설계<br>• 혁신적 기능 개발<br>• 대규모 리팩토링<br>• 연구개발 요소 | • 새로운 플러그인 아키텍처<br>• AI 모델 통합<br>• 분산 시스템 설계 |
## 결과 형식
- 코드블록 내에 작성함
- 구성
```
{서비스 일련번호}. {서비스명}
{Epic 일련번호}. {Epic}
{유저스토리 ID}: [{유저스토리 제목}]: {유저스토리}
- 시나리오: {시나리오}
{인수기준}
- {체크 리스트}
- {Biz중요도}/{Score}
```
작성예시
```
1. User 서비스
1) 사용자 인증 및 관리
RQ-USER-010: [회원가입] 사용자로서 | 나는, 여행 계획을 관리하기 위해 | 간편하게 회원가입하고 싶다.
- 시나리오: 회원가입
미 로그인 상태로 서비스에 접근한 상황에서 | 사용자 기본정보(이름, 이메일, 연락처), ID, 암호를 입력하여 회원가입 요청하면 | 회원가입이 된다.
- [ ] 이름, 이메일, 연락처 등록 체크
- [ ] ID는 5자 이상의 영숫자
- [ ] 암호는 8자 이상의 영숫자와 특수문자가 최소 1개 이상 포함
- M/5
```
## 추가 항목
추가 항목은 필요 시 추가 가능합니다.
예를 들어 '기술 태스크'와 같은 기술적 내용을 추가할 수 있습니다.
예시)
```
UFR-AI-010: [AI일정생성] 여행자로서 | 나는 맞춤형 여행 일정을 받기 위해 | AI가 내 여행 정보와 이동수단 선호도를 기반으로 최적화된 일정을 생성하기를 원한다.
- 시나리오: AI 일정 생성 결과 확인
여행 기본정보와 여행지를 설정하고 AI 일정 생성을 요청한 상황에서 | 5초 이내에 생성이 완료되면 | 선호 이동수단을 기반으로 한 시간대별 상세 일정이 생성되어 확인할 수 있다.
[생성 결과 검증]
- 모든 여행지에 대한 일정 존재
- 각 일자별 시작/종료 시간 일치
- ...
- M/8
- 기술 태스크
- AI 서비스 API 구현
- POST /ai/schedules/generate (일정 생성 요청)
- GET /ai/schedules/{id}/status (진행 상태 조회)
- GET /ai/schedules/{id} (생성된 일정 조회)
- AI 모델 통합
- Claude API 연동
- 프롬프트 엔지니어링
- 응답 파싱 및 구조화
```
## 참고 자료
- [유저스토리 작성 샘플](https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/samples/Userstory.pdf)
## 결과 파일
작성된 유저스토리는 다음 위치에 저장됩니다:
- **파일 경로**: `design/Userstory.md`
- **형식**: 마크다운 형식으로 모든 유저스토리를 포함
## 작성 시 주의사항
1. **명확성**: 모호한 표현 대신 구체적이고 측정 가능한 표현 사용
2. **완전성**: 모든 필수 구성 요소를 빠짐없이 작성
3. **추적성**: ID를 통해 설계 문서와 연결 가능하도록 작성
4. **테스트 가능성**: 인수테스트 시나리오가 실제 테스트로 실행 가능하도록 구체적으로 작성
5. **우선순위**: MoSCoW 분류를 통해 개발 우선순위 명확화

25
claude/v220_codes.txt Normal file
View File

@ -0,0 +1,25 @@
AFR-USER-010: [사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 기능을 원한다.
AFR-USER-020: [대시보드] 사용자로서 | 나는, 회의록 서비스의 주요 정보를 한눈에 파악하기 위해 | 대시보드를 통해 요약 정보를 확인하고 싶다.
UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다.
UFR-MEET-020: [템플릿선택] 회의 생성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다.
UFR-MEET-030: [회의시작] 회의 생성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다.
UFR-MEET-040: [회의종료] 회의 생성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 요약 내용을 확인한 후 다음 단계를 선택하고 싶다.
UFR-MEET-050: [최종확정] 회의 생성자로서 | 나는, 회의록을 완성하기 위해 | 모든 안건을 검증하고 최종 회의록을 확정하고 싶다.
UFR-MEET-046: [회의록목록조회] 회의 참석자로서 | 나는, 참여한 회의록들을 효율적으로 관리하기 위해 | 회의록 목록을 조회하고 필터링하고 싶다.
UFR-MEET-047: [회의록상세조회] 회의 참석자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다.
UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 수정하고 싶다.
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
UFR-STT-020: [텍스트변환] 회의록 시스템으로서 | 나는, 인식된 발언을 회의록에 기록하기 위해 | 음성을 텍스트로 변환하고 싶다.
UFR-AI-010: [회의록자동작성] 회의 참석자로서 | 나는, 회의록 작성 부담을 줄이기 위해 | AI가 발언 내용을 실시간으로 정리하고 회의 종료 시 전체 안건을 요약하기를 원한다.
UFR-AI-020: [Todo자동추출] 회의 참석자로서 | 나는, 회의 후 실행 사항을 명확히 하기 위해 | AI가 안건별 내용에서 Todo 항목을 자동으로 추출하고 기본값을 설정하기를 원한다.
UFR-AI-035: [섹션AI요약] 회의 참석자로서 | 나는, 작성한 섹션 내용을 쉽게 요약하기 위해 | 버튼 클릭으로 AI가 섹션 내용을 요약해주기를 원한다.
UFR-AI-036: [AI한줄요약] 회의 참석자로서 | 나는, 각 안건의 핵심을 빠르게 파악하기 위해 | AI가 생성한 편집 불가능한 한줄 요약을 확인하고 싶다.
UFR-AI-040: [관련회의록연결] 회의 참석자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
UFR-RAG-010: [전문용어감지] 회의 참석자로서 | 나는, 업무 지식이 없어도 회의록을 정확히 작성하기 위해 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명을 제공받고 싶다.
UFR-RAG-020: [맥락기반용어설명] 회의 참석자로서 | 나는, 전문용어를 맥락에 맞게 이해하기 위해 | 관련 회의록과 업무 이력을 바탕으로 실용적인 설명을 제공받고 싶다.
UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회의록을 함께 검증하기 위해 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다.
UFR-COLLAB-020: [충돌해결] 회의 참석자로서 | 나는, 동시 수정 상황에서도 내용을 잃지 않기 위해 | 안건별로 충돌 없이 편집하고 싶다.
UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정확성을 보장하기 위해 | 각 안건을 검증하고 완료 표시를 하고 싶다.
UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다.
UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
UFR-TODO-040: [Todo관리] Todo 담당자로서 | 나는, 나의 Todo를 효율적으로 관리하기 위해 | Todo 목록을 조회하고 상태를 변경하고 편집하고 싶다.

28
claude/v230_codes.txt Normal file
View File

@ -0,0 +1,28 @@
UFR-USER-010: [로그인] 사용자로서 | 나는, 시스템에 접근하기 위해 | 사번과 비밀번호로 로그인하고 싶다.
UFR-USER-020: [대시보드] 사용자로서 | 나는, 나의 회의 및 Todo 현황을 파악하기 위해 | 대시보드를 조회하고 싶다.
UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다.
UFR-MEET-015: [회의진행] 회의 참석자로서 | 나는, 회의 중 추가 참석자가 필요할 때 | 실시간으로 참석자를 초대하고 싶다.
UFR-MEET-020: [템플릿선택] 회의 생성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다.
UFR-MEET-030: [회의시작] 회의 생성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다.
UFR-MEET-040: [회의종료] 회의 생성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 요약 내용을 확인한 후 다음 단계를 선택하고 싶다.
UFR-MEET-050: [최종확정] 회의 생성자로서 | 나는, 회의록을 완성하기 위해 | 모든 안건을 검증하고 최종 회의록을 확정하고 싶다.
UFR-MEET-046: [회의록목록조회] 회의 참석자로서 | 나는, 참여한 회의록들을 효율적으로 관리하기 위해 | 회의록 목록을 조회하고 필터링하고 싶다.
UFR-MEET-047: [회의록상세조회] 회의 참석자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다.
UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 수정하고 싶다.
UFR-AI-010: [회의록자동작성] 회의 참석자로서 | 나는, 회의록 작성 부담을 줄이기 위해 | AI가 발언 내용을 실시간으로 정리하고 회의 종료 시 전체 안건을 요약하기를 원한다.
UFR-AI-020: [Todo자동추출] 회의 참석자로서 | 나는, 회의 후 실행 사항을 명확히 하기 위해 | AI가 안건별 내용에서 Todo 항목을 자동으로 추출하고 기본값을 설정하기를 원한다.
UFR-AI-030: [실시간AI제안] 회의 참석자로서 | 나는, 회의 중 놓치는 내용을 최소화하기 위해 | AI가 실시간으로 주요 내용을 분석하여 제안하고 싶다.
UFR-AI-035: [섹션AI요약] 회의 참석자로서 | 나는, 작성한 섹션 내용을 쉽게 요약하기 위해 | 버튼 클릭으로 AI가 섹션 내용을 요약해주기를 원한다.
UFR-AI-036: [AI한줄요약] 회의 참석자로서 | 나는, 각 안건의 핵심을 빠르게 파악하기 위해 | AI가 생성한 편집 불가능한 한줄 요약을 확인하고 싶다.
UFR-AI-040: [관련회의록연결] 회의 참석자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
UFR-STT-020: [텍스트변환] 회의록 시스템으로서 | 나는, 인식된 발언을 회의록에 기록하기 위해 | 음성을 텍스트로 변환하고 싶다.
UFR-RAG-010: [전문용어감지] 회의 참석자로서 | 나는, 업무 지식이 없어도 회의록을 정확히 작성하기 위해 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명을 제공받고 싶다.
UFR-RAG-020: [맥락기반용어설명] 회의 참석자로서 | 나는, 전문용어를 맥락에 맞게 이해하기 위해 | 관련 회의록과 업무 이력을 바탕으로 실용적인 설명을 제공받고 싶다.
UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회의록을 함께 검증하기 위해 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다.
UFR-COLLAB-020: [충돌해결] 회의 참석자로서 | 나는, 동시 수정 상황에서도 내용을 잃지 않기 위해 | 안건별로 충돌 없이 편집하고 싶다.
UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정확성을 보장하기 위해 | 각 안건을 검증하고 완료 표시를 하고 싶다.
UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다.
UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
UFR-TODO-040: [Todo관리] Todo 담당자로서 | 나는, 나의 Todo를 효율적으로 관리하기 위해 | Todo 목록을 조회하고 상태를 변경하고 편집하고 싶다.
UFR-NOTI-010: [알림발송] Notification 시스템으로서 | 나는, 사용자에게 중요한 이벤트를 알리기 위해 | 주기적으로 알림 대상을 확인하여 이메일을 발송하고 싶다.

View File

@ -0,0 +1,454 @@
# 유저스토리 v2.2.0 → v2.3.0 변경사항 보고서
**작성일**: 2025-10-25
**작성자**: 지수 (Product Designer), 민준 (Product Owner)
**문서 버전**: 1.0
---
## 📋 개요
본 보고서는 AI기반 회의록 작성 및 이력 관리 개선 서비스의 유저스토리 문서가 v2.2.0에서 v2.3.0으로 업데이트되면서 변경된 내용과 그 의미를 분석합니다.
### 요약 통계
| 항목 | v2.2.0 | v2.3.0 | 변화 |
|------|--------|--------|------|
| **유저스토리 수** | 25개 | 27개 | +2개 (+8%) |
| **신규 추가** | - | 5개 | UFR-USER-010, UFR-USER-020, UFR-MEET-015, UFR-AI-030, UFR-NOTI-010 |
| **삭제/전환** | - | 2개 | AFR-USER-010, AFR-USER-020 → UFR로 전환 |
| **AFR 코드** | 2개 | 0개 | -2개 (100% 제거) |
| **UFR 코드** | 23개 | 27개 | +4개 (+17%) |
| **평균 상세도** | 20-30줄 | 60-100줄 | **약 3배 증가** |
| **프로토타입 연계** | 부분적 | 100% (10개 화면) | - |
| **표준 형식 적용** | 0% | 100% (27개) | - |
---
## 📊 한눈에 보는 변경사항
```
v2.2.0 (25개) v2.3.0 (27개)
┌─────────────────┐ ┌─────────────────┐
│ AFR-USER-010 │ ──────────────────>│ UFR-USER-010 ✨ │ (로그인 상세화)
│ AFR-USER-020 │ ──────────────────>│ UFR-USER-020 ✨ │ (대시보드 재설계)
├─────────────────┤ ├─────────────────┤
│ UFR-MEET-010 │ ──────────────────>│ UFR-MEET-010 ✨ │ (회의예약 개선)
│ │ │ UFR-MEET-015 🆕 │ (참석자 실시간 초대)
│ UFR-MEET-020 │ ──────────────────>│ UFR-MEET-020 ✨ │ (템플릿선택 상세화)
│ UFR-MEET-030 │ ──────────────────>│ UFR-MEET-030 ✨ │ (회의시작 4개 탭)
│ UFR-MEET-040 │ ──────────────────>│ UFR-MEET-040 ✨ │ (회의종료 3가지 액션)
│ UFR-MEET-050 │ ──────────────────>│ UFR-MEET-050 ✨ │ (최종확정 2가지 시나리오)
│ UFR-MEET-046 │ ──────────────────>│ UFR-MEET-046 ✨ │ (목록조회 샘플 30개)
│ UFR-MEET-047 │ ──────────────────>│ UFR-MEET-047 ✨ │ (상세조회 관련회의록)
│ UFR-MEET-055 │ ──────────────────>│ UFR-MEET-055 ✨ │ (회의록수정 3가지 시나리오)
├─────────────────┤ ├─────────────────┤
│ UFR-AI-010 │ ──────────────────>│ UFR-AI-010 │
│ UFR-AI-020 │ ──────────────────>│ UFR-AI-020 │
│ │ │ UFR-AI-030 🆕🎯 │ (실시간 AI 제안 - 차별화!)
│ UFR-AI-035 │ ──────────────────>│ UFR-AI-035 │
│ UFR-AI-036 │ ──────────────────>│ UFR-AI-036 │
│ UFR-AI-040 │ ──────────────────>│ UFR-AI-040 │
├─────────────────┤ ├─────────────────┤
│ UFR-STT-010 │ ──────────────────>│ UFR-STT-010 │
│ UFR-STT-020 │ ──────────────────>│ UFR-STT-020 │
├─────────────────┤ ├─────────────────┤
│ UFR-RAG-010 │ ──────────────────>│ UFR-RAG-010 │
│ UFR-RAG-020 │ ──────────────────>│ UFR-RAG-020 │
├─────────────────┤ ├─────────────────┤
│ UFR-COLLAB-010 │ ──────────────────>│ UFR-COLLAB-010 │
│ UFR-COLLAB-020 │ ──────────────────>│ UFR-COLLAB-020 │
│ UFR-COLLAB-030 │ ──────────────────>│ UFR-COLLAB-030 │
├─────────────────┤ ├─────────────────┤
│ UFR-TODO-010 │ ──────────────────>│ UFR-TODO-010 │
│ UFR-TODO-030 │ ──────────────────>│ UFR-TODO-030 │
│ UFR-TODO-040 │ ──────────────────>│ UFR-TODO-040 │
└─────────────────┘ ├─────────────────┤
│ UFR-NOTI-010 🆕 │ (알림발송 - 폴링 방식)
└─────────────────┘
범례:
🆕 = 완전 신규 추가
🎯 = 차별화 핵심 기능
✨ = 대폭 개선 (프로토타입 기반 재작성)
```
---
## 🎯 핵심 변경사항
### 1. 신규 추가된 유저스토리 (5개)
#### 1.1 UFR-USER-010: 로그인 🆕
- **이전**: AFR-USER-010 (간략한 인증 설명)
- **변경**: UFR-USER-010으로 전환 및 상세화
- **의미**:
- 로그인 프로세스 단계별 명시 (Enter 키 동작, 로딩 상태 등)
- 예외처리 시나리오 구체화 (사번 미입력, 비밀번호 8자 미만 등)
- 프로토타입 `01-로그인.html`과 1:1 매핑
#### 1.2 UFR-USER-020: 대시보드 🆕
- **이전**: AFR-USER-020 (간략한 대시보드 설명)
- **변경**: UFR-USER-020으로 전환 및 대폭 확장
- **의미**:
- 통계 블록, 최근 회의, 나의 Todo, 나의 회의록 위젯 상세 명세
- FAB 버튼 2가지 액션 (회의예약/바로 시작) 명확화
- 프로토타입 `02-대시보드.html`과 1:1 매핑
#### 1.3 UFR-MEET-015: 참석자 실시간 초대 🆕
- **이전**: 없음
- **변경**: 완전 신규 추가
- **의미**:
- 회의 진행 중 "참석자" 탭에서 실시간으로 참석자 추가 기능
- 검색 모달 → 추가 → WebSocket 동기화 → 알림 발송 흐름 명시
- **효과**: 회의 진행 중 동적 참석자 관리로 유연성 향상
- 프로토타입 `05-회의진행.html`의 "참석자" 탭과 연계
#### 1.4 UFR-AI-030: 실시간 AI 제안 🆕🎯
- **이전**: 없음
- **변경**: 완전 신규 추가
- **의미**:
- **차별화 전략 "지능형 회의 진행 지원" 실현**
- STT 텍스트 실시간 분석 → 주요 내용 감지 → AI 제안 카드 생성
- 제안 카드에서 메모 탭으로 드래그 앤 드롭으로 추가
- **효과**: 회의 중 놓치는 내용 최소화, 차별화 핵심 기능
- 프로토타입 `05-회의진행.html`의 "AI 제안" 탭과 연계
#### 1.5 UFR-NOTI-010: 알림 발송 🆕
- **이전**: 없음 (암묵적으로 Meeting Service에서 직접 발송)
- **변경**: Notification 서비스의 독립적인 유저스토리로 추가
- **의미**:
- **알림 아키텍처를 폴링 방식으로 통일**
- 1분 간격 폴링 → 이메일 발송 → 최대 3회 재시도
- 6가지 알림 유형 명시 (Todo 할당, Todo 완료, 회의 시작, 회의록 확정, 참석자 초대, 회의록 수정)
- **효과**: Notification 서비스 독립성 확보, 시스템 안정성 향상
---
### 2. 대폭 개선된 유저스토리 (주요 8개)
#### 2.1 UFR-MEET-010: 회의예약
- **변경사항**:
- 수행절차 10단계 명시 (FAB 버튼 → 입력 → 저장/완료)
- 입력 필드별 상세 명세 (타입, 필수 여부, 최대/최소값, UI 요소)
- 임시저장/예약 완료 2가지 시나리오 구분
- 예외처리 7가지 추가 (제목 미입력, 과거 날짜, 참석자 미선택 등)
- **의미**: 프로토타입 `03-회의예약.html` 기반 전면 재작성
#### 2.2 UFR-MEET-030: 회의시작
- **변경사항**:
- 회의 진행 화면 4개 탭 상세 명세 (녹음/메모, 참석자, AI 제안, 안건)
- 녹음 시작/일시정지/재시작 플로우 명시
- 참석자 상태 표시 (온라인/오프라인/참석중)
- 탭별 UI 요소와 인터랙션 상세화
- **의미**: 프로토타입 `05-회의진행.html` 4개 탭 구조 반영
#### 2.3 UFR-MEET-040: 회의종료
- **변경사항**:
- 회의 종료 후 3가지 액션 명시 (바로 확정, 나중에 확정, 검토 후 확정)
- 각 액션별 이동 화면 명확화
- 안건 요약 및 검증 상태 표시 추가
- **의미**: 프로토타입 `07-회의종료.html` 반영, 사용자 선택권 강화
#### 2.4 UFR-MEET-050: 최종확정
- **변경사항**:
- 2가지 시나리오 분리 (검토 후 확정, 회의 종료 화면에서 바로 확정)
- 안건별 검증 완료 여부 체크 로직 추가
- 미검증 안건 있을 시 확정 불가 정책 명시
- **의미**: 회의록 품질 보증 메커니즘 강화
#### 2.5 UFR-MEET-046: 회의록목록조회
- **변경사항**:
- 샘플 데이터 30개 명시 (제목, 날짜, 상태, 검증 현황 등)
- 필터/정렬 기능 상세화 (기간, 상태, 폴더별)
- 상태 배지 5종 추가 (진행중, 검토중, 확정완료 등)
- **의미**: 프로토타입 `12-회의록목록조회.html` 반영
#### 2.6 UFR-MEET-047: 회의록상세조회
- **변경사항**:
- 관련 회의록 섹션 추가 (AI가 자동 연결한 회의록 3개 표시)
- 안건별 검증 상태 표시 추가
- 용어 팝업 연계 (UFR-RAG-010) 명시
- **의미**: 프로토타입 `10-회의록상세조회.html` 반영, RAG 기능 연계
#### 2.7 UFR-MEET-055: 회의록수정
- **변경사항**:
- 3가지 진입 시나리오 명시 (회의종료 화면, 목록 화면, 상세조회 화면)
- 실시간 협업 플로우 상세화 (UFR-COLLAB-010, UFR-COLLAB-020 연계)
- 수정 저장/임시저장/취소 3가지 액션 구분
- **의미**: 프로토타입 `11-회의록수정.html` 반영, 협업 기능 강화
#### 2.8 UFR-COLLAB-020: 충돌해결
- **변경사항**:
- 안건 기반 충돌 방지 메커니즘 상세화
- 동일 안건 동시 수정 시 경고 표시 및 잠금 정책 명시
- 충돌 해결 시나리오 3가지 (대기, 새 안건 작성, 취소)
- **의미**: 실시간 협업 안정성 강화
---
### 3. 유지된 유저스토리 (14개)
다음 유저스토리들은 v2.2.0과 v2.3.0에서 ID와 핵심 내용이 유지되었습니다:
- UFR-AI-010 (회의록 자동 작성)
- UFR-AI-020 (Todo 자동 추출)
- UFR-AI-035 (섹션 AI 요약)
- UFR-AI-036 (AI 한줄 요약)
- UFR-AI-040 (관련 회의록 연결)
- UFR-STT-010 (음성 녹음 인식)
- UFR-STT-020 (텍스트 변환)
- UFR-RAG-010 (전문용어 감지)
- UFR-RAG-020 (맥락 기반 용어 설명)
- UFR-COLLAB-010 (회의록 수정 동기화)
- UFR-COLLAB-030 (검증 완료)
- UFR-TODO-010 (Todo 할당)
- UFR-TODO-030 (Todo 완료 처리)
- UFR-TODO-040 (Todo 관리)
---
## 📈 문서 품질 개선
### 3.1 유저스토리 형식 표준화
#### Before (v2.2.0) - 자유 형식
```
UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, ...
- 시나리오: 회의 예약 및 참석자 초대
회의 예약 화면에 접근한 상황에서 | ...
[입력 요구사항]
- 회의 제목: 최대 100자 (필수)
...
[처리 결과]
- 회의가 예약됨
...
- M/13
```
#### After (v2.3.0) - 표준 5단계 형식
```
### UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, ...
**수행절차:**
1. 대시보드에서 "회의예약" FAB 버튼 클릭
2. 회의 제목 입력 (최대 100자)
3. 날짜 선택 (오늘 이후 날짜, 달력 UI)
...
10. "임시저장" 버튼 또는 "예약 완료" 버튼 클릭
**입력:**
- 회의 제목: 텍스트 입력, 필수, 최대 100자, 문자 카운터 표시
- 날짜: date 타입, 필수, 오늘 이후 날짜만 선택 가능
...
**출력/결과:**
- 예약 완료: "회의가 예약되었습니다" 토스트 메시지, 대시보드로 이동
- 임시저장: "임시 저장되었습니다" 토스트 메시지
...
**예외처리:**
- 제목 미입력: "회의 제목을 입력해주세요" 토스트, 제목 필드 포커스
- 과거 날짜 선택: "과거 날짜는 선택할 수 없습니다" 토스트
...
**관련 유저스토리:**
- UFR-USER-020: 대시보드 조회
- UFR-MEET-020: 템플릿선택
```
### 3.2 개선 효과
| 섹션 | 개선 효과 |
|------|-----------|
| **수행절차** | 단계별 명확한 작업 흐름, 개발자가 UI 플로우 이해 가능 |
| **입력** | 필드 타입, 검증 규칙, UI 요소 상세 명세, API 명세서 작성 기준 제공 |
| **출력/결과** | 성공/실패 시나리오별 응답 명시, 테스트 케이스 작성 기준 제공 |
| **예외처리** | 에러 상황별 처리 방법 구체화, QA 시나리오 명확화 |
| **관련 유저스토리** | 기능 간 연계성 추적, 통합 테스트 범위 파악 용이 |
---
## 🏗️ 프로토타입 연계 강화
v2.3.0에서는 모든 유저스토리가 프로토타입 화면과 명확하게 연계되었습니다.
| 프로토타입 화면 | 연계 유저스토리 | 상태 |
|----------------|----------------|------|
| 01-로그인.html | UFR-USER-010 | ✅ 1:1 매핑 |
| 02-대시보드.html | UFR-USER-020 | ✅ 1:1 매핑 |
| 03-회의예약.html | UFR-MEET-010 | ✅ 1:1 매핑 |
| 04-템플릿선택.html | UFR-MEET-020 | ✅ 1:1 매핑 |
| 05-회의진행.html | UFR-MEET-030, UFR-MEET-015 (신규), UFR-AI-030 (신규) | ✅ 1:N 매핑 |
| 07-회의종료.html | UFR-MEET-040 | ✅ 1:1 매핑 |
| 10-회의록상세조회.html | UFR-MEET-047 | ✅ 1:1 매핑 |
| 11-회의록수정.html | UFR-MEET-055 | ✅ 1:1 매핑 |
| 12-회의록목록조회.html | UFR-MEET-046 | ✅ 1:1 매핑 |
| 08-최종확정.html | UFR-MEET-050 | ✅ 1:1 매핑 |
**결과**: 10개 프로토타입 화면 100% 유저스토리 연계 완료
---
## 🔑 핵심 아키텍처 변경
### 알림 아키텍처: 실시간 → 폴링 방식
#### Before (v2.2.0)
```
[Meeting Service] ──(실시간 발송)──> [Notification Service] ──> [Email]
Todo 할당 발생 → 즉시 이메일 발송
```
**문제점**:
- Meeting Service와 Notification Service 간 강한 결합
- 이메일 발송 실패 시 Meeting Service에 영향
#### After (v2.3.0)
```
[Meeting Service] ──(DB 레코드 생성)──> [Notification 테이블]
(1분 간격 폴링)
[Notification Service] ──> [Email]
(발송 상태 업데이트)
```
**개선 효과**:
- ✅ **Notification 서비스 독립성 강화**: 마이크로서비스 간 느슨한 결합
- ✅ **시스템 안정성 향상**: 이메일 발송 실패 시 자동 재시도 (최대 3회)
- ✅ **확장성 확보**: 폴링 주기 조정으로 트래픽 제어 가능
- ✅ **모니터링 용이**: 발송 대기/성공/실패 상태 DB에서 추적
---
## 💡 변경의 의미와 개선 효과
### 1. 사용자 경험 (UX) 개선
| 영역 | 개선 내용 | 효과 |
|------|----------|------|
| **회의 진행 중 유연성** | UFR-MEET-015 (참석자 실시간 초대) | 회의 중 동적 참석자 관리 가능 |
| **회의 중 놓침 방지** | UFR-AI-030 (실시간 AI 제안) 🎯 | 차별화 핵심 기능, 회의 중 주요 내용 실시간 감지 |
| **회의 종료 후 선택권** | UFR-MEET-040 (3가지 액션) | 바로 확정/나중에 확정/검토 후 확정 |
| **회의록 품질 보증** | UFR-MEET-050 (검증 후 확정) | 미검증 안건 있을 시 확정 불가 정책 |
| **실시간 협업 안정성** | UFR-COLLAB-020 (안건 기반 충돌 방지) | 동일 안건 동시 수정 시 경고 및 잠금 |
### 2. 기능적 개선
| 영역 | 개선 내용 | 효과 |
|------|----------|------|
| **알림 시스템 안정성** | UFR-NOTI-010 (폴링 방식) | Notification 서비스 독립성 확보, 재시도 메커니즘 |
| **차별화 전략 실현** | UFR-AI-030 (실시간 AI 제안) 🎯 | "지능형 회의 진행 지원" 구체화 |
| **프로토타입 정합성** | 10개 화면 100% 매핑 | 기획-디자인-개발 간 일관성 확보 |
| **유저스토리 표준화** | 5단계 표준 형식 | 개발 가이드 역할 강화, API 명세서 작성 기준 제공 |
### 3. 문서화 개선
| 영역 | 개선 내용 | 효과 |
|------|----------|------|
| **상세도 3배 증가** | 20-30줄 → 60-100줄 | 개발자가 구현에 필요한 모든 정보 확보 |
| **AFR 코드 폐지** | AFR → UFR 통일 | 유저스토리 체계 단순화 |
| **예외처리 명시** | 각 유저스토리별 5-7개 예외 시나리오 | QA 테스트 케이스 작성 기준 제공 |
| **관련 유저스토리 연계** | 기능 간 의존성 추적 | 통합 테스트 범위 명확화 |
---
## 📋 권장 후속 조치
### 🔴 긴급 (1주 내)
- [ ] **신규 유저스토리 3개 기반 API 설계**
- UFR-MEET-015: 참석자 실시간 초대 API
- UFR-AI-030: 실시간 AI 제안 API (SSE 또는 WebSocket)
- UFR-NOTI-010: 알림 폴링 및 발송 API
- [ ] **알림 아키텍처 폴링 방식 반영**
- 물리 아키텍처 다이어그램 업데이트
- Notification 테이블 스키마 정의
- 폴링 스케줄러 설계
- [ ] **프로토타입 ↔ 유저스토리 1:1 매핑 검증**
- 10개 화면별 유저스토리 매핑 검증
- 누락된 화면 또는 유저스토리 확인
### 🟡 중요 (2주 내)
- [ ] **API 설계서 v2.3.0 기반 전면 업데이트**
- 입력/출력 명세 반영 (타입, 필수 여부, 검증 규칙)
- 예외처리 시나리오 → HTTP 상태 코드 및 에러 메시지 매핑
- 관련 유저스토리 기반 API 그룹핑
- [ ] **예외처리 시나리오 → 테스트 케이스 전환**
- 각 유저스토리의 예외처리 섹션을 테스트 케이스로 변환
- 입력 검증 테스트 케이스 작성
- [ ] **관련 유저스토리 기반 통합 테스트 시나리오 작성**
- 예: UFR-MEET-010 → UFR-MEET-020 → UFR-MEET-030 전체 플로우 테스트
### 🟢 일반 (3주 내)
- [ ] **유저스토리별 개발 우선순위 재평가**
- 신규 유저스토리 3개 우선순위 결정
- 차별화 핵심 기능 (UFR-AI-030) 우선 개발 검토
- [ ] **신규 기능 3개 개발 일정 수립**
- UFR-MEET-015: 참석자 실시간 초대
- UFR-AI-030: 실시간 AI 제안 (Sprint 목표로 권장)
- UFR-NOTI-010: 알림 발송
- [ ] **프로토타입 기반 개발 가이드 작성**
- 프로토타입 → 유저스토리 → API → 컴포넌트 매핑 가이드
- 프론트엔드 개발자를 위한 프로토타입 활용 가이드
---
## 🔍 핵심 시사점 (Key Takeaways)
1. **v2.3.0은 프로토타입 분석을 통해 유저스토리를 전면 재정비한 버전**
- 10개 프로토타입 화면과 100% 매핑
- 실제 UI/UX 플로우를 유저스토리에 반영
2. **신규 기능 3개 추가로 차별화 강화**
- 특히 UFR-AI-030 (실시간 AI 제안)은 차별화 핵심 기능
3. **알림 아키텍처 폴링 방식으로 통일하여 시스템 안정성 확보**
- Notification 서비스 독립성 강화
- 재시도 메커니즘으로 안정성 향상
4. **유저스토리 형식 표준화로 개발 가이드 역할 강화**
- 5단계 표준 형식 (수행절차, 입력, 출력/결과, 예외처리, 관련 유저스토리)
- API 명세서 및 테스트 케이스 작성 기준 제공
5. **평균 유저스토리 상세도 약 3배 증가로 품질 대폭 향상**
- 개발자가 구현에 필요한 모든 정보 포함
- 예외처리, 검증 규칙, UI 요소까지 상세 명시
6. **기존 24개 유저스토리 ID 승계하여 연속성 유지**
- AFR-USER-010 → UFR-USER-010 전환
- 기존 설계 문서와의 연계성 유지
7. **프로토타입-유저스토리 1:1 매핑으로 개발 명확성 확보**
- 기획-디자인-개발 간 일관성 확보
- 개발 우선순위 및 Sprint 계획 수립 용이
---
## 📎 참고 자료
- **상세 분석 (JSON)**: `claude/userstory-comparison-v2.2.0-to-v2.3.0.json` (19KB)
- **상세 분석 (Markdown)**: `claude/userstory-comparison-v2.2.0-to-v2.3.0.md` (16KB)
- **요약 분석**: `claude/userstory-comparison-summary.md` (11KB)
- **유저스토리 v2.2.0 백업**: `design/userstory_v2.2.0_backup.md`
- **유저스토리 v2.3.0 현재**: `design/userstory.md`
---
**보고서 작성**: 지수 (Product Designer), 민준 (Product Owner)
**분석 일시**: 2025-10-25
**문서 버전**: 1.0

View File

@ -0,0 +1,762 @@
# 회의진행 화면 개선안 종합 보고서
**작성일**: 2025-10-25
**버전**: v1.0
**대상**: 회의록 작성 서비스 MVP
---
## 📋 목차
1. [논의 배경 및 목적](#1-논의-배경-및-목적)
2. [MVP 핵심 가치 재확인](#2-mvp-핵심-가치-재확인)
3. [최종 개선안](#3-최종-개선안)
4. [공수 및 일정 영향 분석](#4-공수-및-일정-영향-분석)
5. [유저스토리 수정안](#5-유저스토리-수정안)
6. [화면설계 수정안](#6-화면설계-수정안)
7. [프로토타입 수정 가이드](#7-프로토타입-수정-가이드)
8. [v2.0 백로그](#8-v20-백로그)
---
## 1. 논의 배경 및 목적
### 1.1 논의 내용
회의 진행 화면의 다음 기능들에 대해 MVP 관점에서 재검토:
- **회의 참석자 권한 구분**: 생성자 vs 일반 참석자
- **메모 기능**: 개인 메모 vs 공유 메모
- **용어 설명**: 회사 특화 용어 처리 방안
- **관련 회의록**: 표시 방식 및 활용도
- **중도 퇴장**: 화면 전환 방식에 따른 구현
### 1.2 목적
- MVP 범위 명확화 및 불필요한 기능 제거
- 핵심 가치 실현에 집중
- 예산 및 일정 준수
---
## 2. MVP 핵심 가치 재확인
### 2.1 서비스 핵심 가치
> **"업무지식이 없어도 누락 없이 정확하게 회의록을 작성"**
### 2.2 MVP 필수 기능
1. ✅ AI STT로 실시간 음성 인식
2. ✅ AI가 주요 내용 자동 추출 및 제시
3. ✅ 회의 종료 후 AI 자동 요약 생성
4. ✅ 회의록 확인 및 기본 수정
5. ✅ 회의록 공유 (이메일/링크)
### 2.3 MVP 제외 기능 (v2.0 이관)
- ❌ 복잡한 메모 입력/편집 기능
- ❌ 실시간 협업 메모 동기화
- ❌ 참석자별 세밀한 권한 관리 UI
- ❌ 용어 사전 직접 검색 기능
---
## 3. 최종 개선안
### 3.1 회의 참석자 권한
#### 현재 문제점
- 생성자 vs 참석자로 이분법적 구분
- 회의 생성자도 참석자 중 한 명이라는 관점 누락
#### 개선안
**회의 생성자 = 특별 권한을 가진 참석자**
```
모든 참석자 공통 기능:
- AI 제시 주요 내용 확인 및 체크
- 회의록 실시간 확인
- 용어 설명 확인
- 관련 회의록 확인
- "나가기" 버튼으로 중도 퇴장
회의 생성자 전용 기능:
- 회의 종료 버튼 (일반 참석자에게는 숨김)
- 녹음 제어 (일시정지/재개/종료)
```
#### 구현 방식
```javascript
// 버튼 조건부 표시
if (currentUser.id === meeting.creator_id) {
// 회의 종료 버튼 표시
showEndMeetingButton();
showRecordingControls();
}
```
---
### 3.2 메모 기능
#### 현재 문제점
- 텍스트 입력 메모 구현 시 공수 증가
- 공유 메모 vs 개인 메모 선택 어려움
- 실시간 동기화 복잡도
#### 개선안 (MVP 최소화)
**AI 제시 내용 체크박스 방식**
```
┌─────────────────────────────────┐
│ 🤖 AI가 파악한 주요 내용 │
├─────────────────────────────────┤
│ ☐ 예산 500만원 증액 합의 │
│ ☐ 일정 2주 연장 논의 │
│ ☐ 외주 업체 3곳 검토 │
└─────────────────────────────────┘
```
**특징:**
- 참석자는 클릭만 (텍스트 입력 없음)
- 개인별 체크 (다른 사람 체크 안 보임)
- AI 요약 시 체크 수에 따라 가중치 부여
- 편집 충돌 없음
**공수 절감:**
- 기존 방식 (공유+개인 메모): 38일
- 체크박스 방식: 7일
- **절감률: 82%**
---
### 3.3 용어 설명 기능
#### 현재 문제점
- 일반 전문용어와 회사 특화 용어 구분 필요
- RAG 구축은 MVP 범위 초과
#### 개선안
**간소화된 JSON 용어 사전 + AI 보조**
##### 3.3.1 회사 용어 사전 구조
```json
// config/terms-dictionary.json
{
"terms": [
{
"keyword": "레거시",
"aliases": ["구시스템", "기존ERP"],
"definition": "2020년 구축한 우리 회사 통합 ERP 시스템",
"context": "현재 클라우드 기반 신규 시스템으로 마이그레이션 중",
"category": "시스템"
}
]
}
```
##### 3.3.2 용어 표시 방식
```
┌─────────────────────────────────┐
│ [AI 주요 내용] [용어] │
├─────────────────────────────────┤
│ 📚 회의 중 언급된 용어 │
│ │
│ 🔹 레거시 ⭐ │
│ 2020년 구축한 우리 회사 ERP │
│ (회사 용어 사전) │
│ │
│ 🔹 POC │
│ Proof of Concept │
│ (AI 일반 설명) │
└─────────────────────────────────┘
⭐ = 회사 용어 사전 등록 항목
```
##### 3.3.3 처리 로직
```python
def explain_term(term, stt_context, company_dict):
# 1. 회사 용어 사전 확인 (우선)
if term in company_dict:
return company_dict[term] + " ⭐"
# 2. AI 일반 설명 (회의 맥락 포함)
else:
return ai_model.explain(term, context=stt_context)
```
**장점:**
- ✅ RAG 대비 공수 1/5 (4일 vs 20일)
- ✅ 초기 10-20개 용어만으로 80% 커버
- ✅ 비개발자도 JSON 편집 가능
- ✅ v1.5에서 자동 학습 추가 가능
---
### 3.4 관련 회의록 기능
#### 기존 요구사항 (UFR-AI-040)
- ✅ AI가 벡터 유사도 검색으로 관련 회의록 자동 추천
- ✅ 관련도 배지 표시 (높음/중간/낮음)
- ✅ 최대 5개 회의록 연결
#### 현재 문제점
- 회의록 제목과 관련도 배지만 표시 (목록만 나열)
- 전체 회의록을 열어봐야 내용 파악 가능
- 회의 중 전체 회의록 읽을 시간 없음
#### 개선안
**기존 기능 유지 + 핵심 내용 요약 추가**
```
┌─────────────────────────────────┐
│ 📄 관련 회의록 │
├─────────────────────────────────┤
│ 🔥 95% 2024-01-15 주간 회의 │
│ 💡 현재 회의와 유사한 내용: │
│ • "예산 500만원으로 증액 1차 합의"│
│ • "외주 업체 A, B, C 3곳 후보" │
│ • "일정은 2주 더 필요하다는 의견"│
│ → 전체 회의록 보기 │
│ │
│ 🔥 78% 2024-01-08 기획 회의 │
│ 💡 현재 회의와 유사한 내용: │
│ • "POC 범위를 챗봇 기능만으로" │
│ • "개발 일정 2월 말까지" │
│ → 전체 회의록 보기 │
└─────────────────────────────────┘
```
##### 3.4.1 유사도 계산 로직
```python
def calculate_meeting_similarity(current, past):
# 1. 주제 유사도 (50%)
topic_similarity = cosine_similarity(
current.topics_vector,
past.topics_vector
)
# 2. 참석자 겹침 (20%)
attendee_overlap = len(
set(current.attendees) & set(past.attendees)
) / len(set(current.attendees))
# 3. 프로젝트/태그 일치 (30%)
project_match = 1.0 if current.project == past.project else 0.3
return (
topic_similarity * 0.5 +
attendee_overlap * 0.2 +
project_match * 0.3
)
```
##### 3.4.2 효율화 방안
```python
# 과거 회의록 저장 시 요약본 미리 생성
class Meeting:
content: str # 전체 내용
summary: str # AI 요약본 (미리 생성 - 배치)
topics: list # 주제 태그
# 실시간에는 요약본만 활용
def show_related_meetings(current):
for past in find_similar(current):
relevant_summary = extract_relevant_parts(
past.summary, # 미리 생성된 요약본
current.topics
)
```
**효과:**
- 회의록 찾는 시간: 5-10분 → 10초
- 컨텍스트 파악: 전체 읽기 → 요약으로 즉시
- 핵심 가치 강화: "업무지식 없어도" 실현
**추가 공수:** +1일 (최적화 적용 시)
---
### 3.5 중도 퇴장 기능
#### 현재 문제점
- 대시보드 → 회의진행 화면: 페이지 전환 (같은 탭)
- 브라우저 창 닫기 = 서비스 전체 종료
#### 개선안
**"나가기" 버튼 추가**
```
회의진행 화면 상단:
┌─────────────────────────────────┐
│ ← 나가기 | 회의 제목 | [회의종료] │
└─────────────────────────────────┘
```
**동작:**
```javascript
function exitMeeting() {
if (confirm('회의에서 나가시겠습니까?\n회의는 계속 진행됩니다.')) {
// 퇴장 이벤트 서버 전송
sendExitEvent(meeting.id, user.id);
// 대시보드로 복귀
navigateTo('02-대시보드.html');
}
}
```
**추가 공수:** +0.5일
---
## 4. 공수 및 일정 영향 분석
### 4.1 전체 공수 비교
| 항목 | 기존 계획 | 개선안 | 차이 |
|------|----------|--------|------|
| 메모 기능 | 38일 | 7일 | **-31일** ⬇️ |
| 용어 설명 | 20일 (RAG) | 4일 (JSON) | **-16일** ⬇️ |
| 관련 회의록 | 5일 (목록만) | 6일 (요약 추가) | **+1일** ⬆️ |
| 나가기 버튼 | - | 0.5일 | **+0.5일** ⬆️ |
| **순 절감** | - | - | **-45.5일** ⬇️ |
### 4.2 MVP 일정 영향
- ✅ 예산 및 일정 대폭 절감
- ✅ 핵심 기능에 집중 가능
- ✅ 빠른 시장 검증 가능
---
## 5. 유저스토리 수정안
### 5.1 신규 추가 (MVP 단순화)
```markdown
# 회의 참석자 공통 기능
UFR-PART-010: 회의 입장
- 모든 참석자(생성자 포함)는 대시보드에서 "참여하기"로 회의 입장
- 회의진행 화면으로 페이지 전환 (같은 탭)
UFR-PART-020: AI 주요 내용 체크 (개인별)
- AI가 추출한 주요 내용을 체크박스로 표시
- 각 참석자는 중요하다고 생각하는 항목 독립적으로 체크
- 다른 참석자의 체크 여부는 보이지 않음
- AI 요약 시 체크 수에 따라 가중치 차등 적용
UFR-PART-030: 회의 중도 퇴장
- "나가기" 버튼으로 회의에서 퇴장
- 확인 모달: "회의에서 나가시겠습니까? 회의는 계속 진행됩니다"
- 퇴장 후 대시보드로 복귀
- 회의록은 종료 시 공유됨
---
# 회의 생성자 전용 기능
UFR-HOST-010: 회의 종료 권한
- 회의 생성자만 "회의 종료" 버튼으로 회의 종료 가능
- 일반 참석자에게는 버튼 숨김
UFR-HOST-020: 녹음 제어 권한
- 회의 생성자만 녹음 일시정지/재개/종료 가능
---
# 용어 설명 기능
UFR-TERM-010: 용어 자동 감지 및 표시
- AI가 STT 분석 중 중요 용어 자동 감지
- "용어" 탭에 실시간으로 표시
UFR-TERM-020: 회사 용어 사전 우선 표시
- 회사 용어 사전(JSON)에 등록된 용어는 ⭐ 표시
- 클릭 시 회사 특화 설명 표시
- 사전에 없는 용어는 AI가 일반 설명 + 회의 맥락 제공
UFR-TERM-030: 용어 관리 (관리자 기능)
- 관리자는 회사 용어 사전 등록/수정 가능
- JSON 파일 직접 편집
```
### 5.2 수정 필요
```markdown
# 기존 수정 1
UFR-MEET-020: 회의 종료 권한
- 기존: "회의 생성자 또는 참석자가 회의 종료"
- 변경: "회의 생성자만 회의 종료 가능"
---
# 기존 수정 2 - UFR-AI-040 개선
UFR-AI-040: 관련 회의록 자동 연결 (개선)
기존 기능 (유지):
- ✅ AI가 벡터 유사도 검색으로 관련 회의록 자동 추천
- ✅ 같은 프로젝트/팀의 회의록 중 관련도 높은 순으로 표시
- ✅ 관련도 배지 표시
변경 및 개선 사항:
1. **최대 개수**: 5개 → 3개로 축소 (MVP)
2. **관련도 표시 방식**: 배지(높음/중간/낮음) → 퍼센트(95%, 78%) 변경
3. **유사 내용 요약 추가** (신규):
- AI가 추천한 각 회의록에서 현재 회의와 유사한 부분 자동 추출
- 유사한 내용을 3-5개 문장으로 요약하여 표시
- 전체 회의록을 열지 않아도 핵심 내용 파악 가능
- "전체 회의록 보기" 버튼으로 상세 내용 확인
4. **성능 최적화**:
- 과거 회의록 저장 시 요약본 미리 생성 (배치 처리)
- 실시간 요약은 캐싱된 데이터 활용
- 성능 목표: 1초 이내 표시
수행절차 (기존 유지):
1. 회의 종료 시 또는 회의록 작성 중 AI가 현재 회의 내용 분석
2. 벡터 유사도 검색을 통해 관련 회의록 탐색
3. 관련도가 높은 회의록 자동 연결 (최대 3개로 축소)
4. 각 회의록에서 유사한 내용 추출 및 요약 (신규 추가)
5. 회의 진행 화면, 회의록 상세 조회 화면에 표시
```
### 5.3 제거 (v2.0 이관)
```markdown
# v2.0 백로그로 이관
- 공유 메모 입력 기능
- 개인 메모 기능
- 사용자 직접 용어 검색 기능
- 용어 북마크 기능
- 참석자별 세밀한 권한 UI
```
---
## 6. 화면설계 수정안
### 6.1 회의진행 화면 (05-회의진행.html)
#### 6.1.1 상단 헤더 수정
```
기존:
┌─────────────────────────────────┐
│ 회의 제목 | [회의 종료] │
└─────────────────────────────────┘
변경:
┌─────────────────────────────────┐
│ ← 나가기 | 회의 제목 | [회의종료] │
└─────────────────────────────────┘
조건부 표시:
- "회의 종료" 버튼: 생성자만 표시
- "나가기" 버튼: 모든 참석자 표시
```
#### 6.1.2 탭 구조
```
┌─────────────────────────────────┐
│ [AI 주요 내용] [용어] [관련 회의록] │
├─────────────────────────────────┤
│ (탭 콘텐츠) │
└─────────────────────────────────┘
```
#### 6.1.3 AI 주요 내용 탭
```html
<div class="tab-content active">
<h3>🤖 AI가 파악한 주요 내용</h3>
<div class="ai-items">
<div class="ai-item">
<input type="checkbox" id="item1"
data-importance="high">
<label for="item1">예산 500만원 증액 합의</label>
<span class="timestamp">15:23</span>
</div>
</div>
</div>
```
#### 6.1.4 용어 탭
```html
<div class="tab-content">
<h3>📚 회의 중 언급된 용어</h3>
<div class="terms-list">
<div class="term-item company-term"
onclick="showTermDetail('legacy')">
<h4>🔹 레거시 ⭐</h4>
<p>2020년 구축한 우리 회사 ERP</p>
</div>
<div class="term-item ai-term"
onclick="showTermDetail('poc')">
<h4>🔹 POC</h4>
<p>Proof of Concept (개념 증명)</p>
</div>
</div>
</div>
<!-- 용어 상세 팝업 -->
<div class="modal" id="term-detail">
<div class="modal-content">
<h3>💡 레거시</h3>
<p class="definition">
2020년 구축한 우리 회사 통합 ERP 시스템
</p>
<p class="context">
현재 클라우드 기반 신규 시스템으로
마이그레이션 중입니다.
</p>
<button onclick="closeModal()">닫기</button>
</div>
</div>
```
#### 6.1.5 관련 회의록 탭
```html
<div class="tab-content">
<h3>📄 관련 회의록</h3>
<div class="related-meetings">
<div class="meeting-card">
<div class="similarity-badge">🔥 95% 관련도</div>
<h4>2024-01-15 주간 회의</h4>
<div class="meeting-summary">
<p class="summary-label">💡 현재 회의와 유사한 내용:</p>
<ul>
<li>"예산 500만원으로 증액 1차 합의"</li>
<li>"외주 업체 A, B, C 3곳 후보"</li>
<li>"일정은 2주 더 필요하다는 의견"</li>
</ul>
</div>
<button class="btn-link"
onclick="openMeeting('meeting-123')">
전체 회의록 보기 →
</button>
</div>
</div>
</div>
```
### 6.2 인터랙션 추가
```markdown
# 회의진행 화면 인터랙션
1. **나가기 버튼**
- 클릭 시 확인 모달 표시
- 확인 시 대시보드로 페이지 전환
2. **AI 주요 내용 체크박스**
- 체크 시 개인 선택 저장 (로컬 + 서버)
- 다른 사람 체크 여부는 표시 안 함
3. **용어 항목 클릭**
- 상세 설명 모달 표시
- 회사 용어는 ⭐ 배지 표시
4. **관련 회의록 카드**
- 유사도 점수 색상 코딩:
- 90% 이상: 빨강 (#FF6B6B)
- 70-89%: 주황 (#FFA94D)
- 50-69%: 노랑 (#FFD43B)
- "전체 회의록 보기" 버튼: 새 탭으로 열기
5. **회의 종료 버튼**
- 조건부 표시:
```javascript
if (currentUser.id === meeting.creator_id) {
showButton('endMeeting');
}
```
```
---
## 7. 프로토타입 수정 가이드
### 7.1 파일 수정 목록
1. `05-회의진행.html` - 메인 화면 구조 변경
2. `common.js` - 권한 체크 함수 추가
3. `02-대시보드.html` - 참여하기 버튼 동작 확인
### 7.2 주요 수정 사항
#### 7.2.1 권한 체크 함수 (common.js)
```javascript
/**
* 현재 사용자가 회의 생성자인지 확인
*/
function isCreator(meetingId, userId) {
const meeting = getMeetingById(meetingId);
return meeting && meeting.creator_id === userId;
}
/**
* 생성자 전용 UI 표시/숨김
*/
function updateCreatorUI(meetingId, userId) {
const isCreator = isCreator(meetingId, userId);
// 회의 종료 버튼
const endBtn = document.getElementById('end-meeting-btn');
if (endBtn) {
endBtn.style.display = isCreator ? 'block' : 'none';
}
// 녹음 제어 버튼들
const recordControls = document.querySelectorAll('.record-control');
recordControls.forEach(btn => {
btn.style.display = isCreator ? 'inline-block' : 'none';
});
}
```
#### 7.2.2 나가기 버튼 (05-회의진행.html)
```javascript
function exitMeeting() {
if (confirm('회의에서 나가시겠습니까?\n회의는 계속 진행됩니다.')) {
// 퇴장 이벤트 전송
sendExitEvent(currentMeeting.id, currentUser.id);
// 대시보드로 복귀
navigateTo('02-대시보드.html');
}
}
```
#### 7.2.3 AI 주요 내용 체크박스
```javascript
// 개인별 체크 저장
function handleAIItemCheck(itemId, checked) {
const checkData = {
meeting_id: currentMeeting.id,
user_id: currentUser.id,
item_id: itemId,
checked: checked,
timestamp: new Date().toISOString()
};
// 로컬 저장
saveLocalCheck(checkData);
// 서버 전송 (비동기)
sendCheckToServer(checkData);
}
```
#### 7.2.4 용어 표시 로직
```javascript
// 용어 목록 렌더링
function renderTerms(terms) {
const container = document.getElementById('terms-list');
container.innerHTML = terms.map(term => `
<div class="term-item ${term.isCompanyTerm ? 'company-term' : 'ai-term'}"
onclick="showTermDetail('${term.id}')">
<h4>🔹 ${term.keyword} ${term.isCompanyTerm ? '⭐' : ''}</h4>
<p>${term.shortDefinition}</p>
</div>
`).join('');
}
// 용어 상세 표시
function showTermDetail(termId) {
const term = getTermById(termId);
const modal = document.getElementById('term-detail-modal');
modal.querySelector('.term-title').textContent = term.keyword;
modal.querySelector('.term-definition').textContent = term.definition;
modal.querySelector('.term-context').textContent = term.context;
modal.classList.add('show');
}
```
#### 7.2.5 관련 회의록 표시
```javascript
// 관련 회의록 렌더링
function renderRelatedMeetings(meetings) {
const container = document.getElementById('related-meetings');
container.innerHTML = meetings.map(meeting => `
<div class="meeting-card">
<div class="similarity-badge"
style="background-color: ${getSimilarityColor(meeting.similarity)}">
🔥 ${Math.round(meeting.similarity * 100)}% 관련도
</div>
<h4>${meeting.date} ${meeting.title}</h4>
<div class="meeting-summary">
<p class="summary-label">💡 현재 회의와 유사한 내용:</p>
<ul>
${meeting.relevantSummary.map(item => `<li>"${item}"</li>`).join('')}
</ul>
</div>
<button class="btn-link" onclick="openMeeting('${meeting.id}')">
전체 회의록 보기 →
</button>
</div>
`).join('');
}
// 유사도에 따른 색상
function getSimilarityColor(similarity) {
if (similarity >= 0.9) return '#FF6B6B'; // 빨강
if (similarity >= 0.7) return '#FFA94D'; // 주황
return '#FFD43B'; // 노랑
}
```
---
## 8. v2.0 백로그
### 8.1 메모 기능 고도화
- 공유 메모 실시간 동기화
- 개인 메모 기능
- 메모 검색 및 필터
- 메모 히스토리 추적
### 8.2 용어 사전 고도화
- 사용자 직접 용어 검색
- 용어 북마크 기능
- 용어 설명 편집 기능
- 과거 회의록 기반 자동 학습
- 전체 RAG 시스템 구축
### 8.3 권한 관리 고도화
- 세밀한 참석자 권한 설정
- 안건별 편집 권한 제어
- 참석자 강제 퇴장 기능
### 8.4 관련 회의록 고도화
- 용어-회의록 연계 표시
- 관련 문서 통합 검색
- 프로젝트 문서/위키 연동
---
## 9. 결론 및 다음 단계
### 9.1 결론
이번 개선안을 통해:
- ✅ MVP 범위 명확화
- ✅ 핵심 가치 "업무지식 없어도 누락 없이" 실현
- ✅ 개발 공수 45.5일 절감
- ✅ 예산 및 일정 준수
### 9.2 다음 단계
1. **유저스토리 업데이트** (design/userstory.md)
- 신규 스토리 추가
- 기존 스토리 수정
- v2.0 백로그 섹션 추가
2. **화면설계 업데이트** (design/uiux/uiux.md)
- 05-회의진행 화면 상세 명세
- 인터랙션 시나리오 추가
- 권한별 UI 조건 명시
3. **프로토타입 수정** (design/uiux/prototype/05-회의진행.html)
- 나가기 버튼 추가
- 체크박스 방식 메모 구현
- 용어 탭 구현
- 관련 회의록 요약 표시
4. **백엔드 API 설계 반영**
- 권한 체크 API
- 체크 데이터 저장 API
- 용어 사전 API
- 관련 회의록 추천 API
---
**보고서 종료**

View File

@ -13,6 +13,32 @@
padding-bottom: 80px;
}
/* 템플릿 그리드 */
.template-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
}
/* 템플릿 카드 높이 균등 */
.template-list .card {
display: flex;
flex-direction: column;
height: 100%;
}
.template-list .card-body {
flex: 1;
}
/* 모바일: 작은 간격 */
@media (max-width: 767px) {
.template-list {
gap: var(--space-sm);
}
}
/* 데스크톱: 메인 콘텐츠 조정 */
@media (min-width: 768px) {
.main-content {
@ -41,18 +67,16 @@
<p class="text-muted">회의 유형에 맞는 템플릿을 선택하세요</p>
</div>
<!-- Template Cards -->
<div class="template-list" style="display: flex; flex-direction: column; gap: var(--space-md);">
<!-- Template Cards (2x2 grid) -->
<div class="template-list">
<!-- 일반 회의 템플릿 -->
<div class="card" style="cursor: pointer;" onclick="selectTemplate('general')">
<div class="card-header">
<div style="display: flex; align-items: center; gap: var(--space-md);">
<div style="font-size: 32px;">📋</div>
<div>
<h3 class="card-title">일반 회의</h3>
<p class="text-muted text-small">기본 회의록 형식</p>
</div>
<div class="card-header" style="display: block !important;">
<div style="margin-bottom: 4px;">
<span style="font-size: 20px;">📋</span>
<span style="font-size: 14px; font-weight: 600; margin-left: 4px;">일반 회의</span>
</div>
<div style="font-size: 11px; color: var(--text-muted);">기본 회의록 형식</div>
</div>
<div class="card-body">
<div class="text-small text-muted">
@ -63,21 +87,18 @@
</div>
</div>
<div class="card-footer">
<button class="btn btn-secondary btn-sm" onclick="previewTemplate(event, 'general')">미리보기</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('general')">선택</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('general')" style="width: 100%;">선택</button>
</div>
</div>
<!-- 스크럼 회의 템플릿 -->
<div class="card" style="cursor: pointer;" onclick="selectTemplate('scrum')">
<div class="card-header">
<div style="display: flex; align-items: center; gap: var(--space-md);">
<div style="font-size: 32px;">🏃</div>
<div>
<h3 class="card-title">스크럼 회의</h3>
<p class="text-muted text-small">데일리 스탠드업 형식</p>
</div>
<div class="card-header" style="display: block !important;">
<div style="margin-bottom: 4px;">
<span style="font-size: 20px;">🏃</span>
<span style="font-size: 14px; font-weight: 600; margin-left: 4px;">스크럼 회의</span>
</div>
<div style="font-size: 11px; color: var(--text-muted);">데일리 스탠드업 형식</div>
</div>
<div class="card-body">
<div class="text-small text-muted">
@ -87,21 +108,18 @@
</div>
</div>
<div class="card-footer">
<button class="btn btn-secondary btn-sm" onclick="previewTemplate(event, 'scrum')">미리보기</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('scrum')">선택</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('scrum')" style="width: 100%;">선택</button>
</div>
</div>
<!-- 킥오프 회의 템플릿 -->
<div class="card" style="cursor: pointer;" onclick="selectTemplate('kickoff')">
<div class="card-header">
<div style="display: flex; align-items: center; gap: var(--space-md);">
<div style="font-size: 32px;">🚀</div>
<div>
<h3 class="card-title">킥오프 회의</h3>
<p class="text-muted text-small">프로젝트 시작 회의</p>
</div>
<div class="card-header" style="display: block !important;">
<div style="margin-bottom: 4px;">
<span style="font-size: 20px;">🚀</span>
<span style="font-size: 14px; font-weight: 600; margin-left: 4px;">킥오프 회의</span>
</div>
<div style="font-size: 11px; color: var(--text-muted);">프로젝트 시작 회의</div>
</div>
<div class="card-body">
<div class="text-small text-muted">
@ -112,21 +130,18 @@
</div>
</div>
<div class="card-footer">
<button class="btn btn-secondary btn-sm" onclick="previewTemplate(event, 'kickoff')">미리보기</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('kickoff')">선택</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('kickoff')" style="width: 100%;">선택</button>
</div>
</div>
<!-- 주간 회의 템플릿 -->
<div class="card" style="cursor: pointer;" onclick="selectTemplate('weekly')">
<div class="card-header">
<div style="display: flex; align-items: center; gap: var(--space-md);">
<div style="font-size: 32px;">📅</div>
<div>
<h3 class="card-title">주간 회의</h3>
<p class="text-muted text-small">주간 리뷰 및 계획</p>
</div>
<div class="card-header" style="display: block !important;">
<div style="margin-bottom: 4px;">
<span style="font-size: 20px;">📅</span>
<span style="font-size: 14px; font-weight: 600; margin-left: 4px;">주간 회의</span>
</div>
<div style="font-size: 11px; color: var(--text-muted);">주간 리뷰 및 계획</div>
</div>
<div class="card-body">
<div class="text-small text-muted">
@ -137,72 +152,12 @@
</div>
</div>
<div class="card-footer">
<button class="btn btn-secondary btn-sm" onclick="previewTemplate(event, 'weekly')">미리보기</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('weekly')">선택</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('weekly')" style="width: 100%;">선택</button>
</div>
</div>
</div>
</main>
<!-- Template Preview Modal -->
<div id="previewModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="previewTitle">템플릿 미리보기</h2>
<button class="modal-close" onclick="closeModal('previewModal')"></button>
</div>
<div class="modal-body">
<div id="previewContent" style="max-height: 400px; overflow-y: auto;">
<!-- 섹션 리스트가 여기에 표시됨 -->
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('previewModal')">닫기</button>
<button class="btn btn-primary" onclick="customizeTemplate()">커스터마이징</button>
</div>
</div>
</div>
<!-- Template Customization Modal -->
<div id="customizeModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">템플릿 커스터마이징</h2>
<button class="modal-close" onclick="closeModal('customizeModal')"></button>
</div>
<div class="modal-body">
<p class="text-small text-muted mb-md">섹션을 드래그하여 순서를 변경하거나 삭제할 수 있습니다</p>
<div id="sectionList" style="display: flex; flex-direction: column; gap: var(--space-sm);">
<!-- 섹션 목록이 여기에 표시됨 -->
</div>
<button class="btn btn-ghost mt-md" onclick="addSection()" style="width: 100%;">+ 섹션 추가</button>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('customizeModal')">취소</button>
<button class="btn btn-primary" onclick="startMeeting()">이 템플릿으로 시작</button>
</div>
</div>
</div>
<!-- Add Section Modal -->
<div id="addSectionModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">섹션 추가</h2>
<button class="modal-close" onclick="closeModal('addSectionModal')"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">섹션 이름</label>
<input type="text" class="form-control" id="newSectionName" placeholder="예: 기술 검토">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('addSectionModal')">취소</button>
<button class="btn btn-primary" onclick="confirmAddSection()">추가</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
@ -230,35 +185,13 @@
}
};
let selectedTemplate = null;
let customSections = [];
// 템플릿 미리보기
function previewTemplate(event, templateId) {
event.stopPropagation();
const template = templates[templateId];
$('#previewTitle').textContent = template.name + ' 미리보기';
const content = template.sections.map((section, index) => `
<div class="list-item">
<span class="text-muted text-small">${index + 1}.</span>
<span class="list-item-title">${section}</span>
</div>
`).join('');
$('#previewContent').innerHTML = content;
openModal('previewModal');
}
// 템플릿 선택
function selectTemplate(templateId) {
selectedTemplate = templateId;
customSections = [...templates[templateId].sections];
const templateSections = [...templates[templateId].sections];
// 회의 진행 화면으로 이동
saveToStorage('selectedTemplate', templateId);
saveToStorage('templateSections', customSections);
saveToStorage('templateSections', templateSections);
navigateTo('05-회의진행.html');
}
@ -276,114 +209,6 @@
$('#skip-btn')?.addEventListener('click', () => {
skipTemplate();
});
// 커스터마이징 모달 열기
function customizeTemplate() {
closeModal('previewModal');
renderSectionList();
openModal('customizeModal');
}
// 섹션 리스트 렌더링
function renderSectionList() {
const sectionList = $('#sectionList');
sectionList.innerHTML = customSections.map((section, index) => `
<div class="list-item" draggable="true" data-index="${index}">
<span class="text-muted"></span>
<span class="list-item-title" style="flex: 1;">${section}</span>
<button class="btn btn-ghost btn-sm" onclick="removeSection(${index})">
<span style="color: var(--error);"></span>
</button>
</div>
`).join('');
// 드래그 이벤트 설정
setupDragAndDrop();
}
// 드래그 앤 드롭 설정
function setupDragAndDrop() {
const items = $$('#sectionList .list-item');
let draggedItem = null;
items.forEach(item => {
item.addEventListener('dragstart', function() {
draggedItem = this;
this.style.opacity = '0.5';
});
item.addEventListener('dragend', function() {
this.style.opacity = '1';
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
});
item.addEventListener('drop', function(e) {
e.preventDefault();
if (draggedItem !== this) {
const draggedIndex = parseInt(draggedItem.dataset.index);
const targetIndex = parseInt(this.dataset.index);
const temp = customSections[draggedIndex];
customSections.splice(draggedIndex, 1);
customSections.splice(targetIndex, 0, temp);
renderSectionList();
}
});
});
}
// 섹션 삭제
function removeSection(index) {
if (customSections.length <= 1) {
showToast('최소 1개의 섹션이 필요합니다', 'error');
return;
}
customSections.splice(index, 1);
renderSectionList();
}
// 섹션 추가 모달 열기
function addSection() {
openModal('addSectionModal');
$('#newSectionName').value = '';
$('#newSectionName').focus();
}
// 섹션 추가 확인
function confirmAddSection() {
const name = $('#newSectionName').value.trim();
if (!name) {
showToast('섹션 이름을 입력해주세요', 'error');
return;
}
customSections.push(name);
renderSectionList();
closeModal('addSectionModal');
showToast('섹션이 추가되었습니다', 'success');
}
// 회의 시작
function startMeeting() {
if (customSections.length === 0) {
showToast('최소 1개의 섹션이 필요합니다', 'error');
return;
}
saveToStorage('selectedTemplate', selectedTemplate);
saveToStorage('templateSections', customSections);
navigateTo('05-회의진행.html');
}
// Enter 키로 섹션 추가
$('#addSectionModal')?.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
confirmAddSection();
}
});
</script>
</body>
</html>

View File

@ -94,6 +94,11 @@
50% { opacity: 0.3; }
}
.recording-indicator.paused .recording-dot,
.recording-indicator.paused .waveform-bar {
animation-play-state: paused;
}
.recording-time {
font-size: var(--font-small);
font-weight: var(--font-weight-medium);
@ -129,18 +134,23 @@
flex: 1;
overflow-y: auto;
background: var(--gray-100);
padding: 0 var(--space-md) 10px;
padding: var(--space-md);
padding-bottom: 88px; /* 하단 버튼 영역 확보 */
max-width: none !important; /* common.css의 max-width: 900px 오버라이드 */
margin: 0 !important; /* common.css의 auto margin 제거 */
}
@media (min-width: 768px) {
.main-content {
padding: 0 var(--space-lg) 88px;
padding: var(--space-lg);
padding-bottom: 88px;
}
}
@media (min-width: 1024px) {
.main-content {
padding: 0 var(--space-xl) 88px;
padding: var(--space-xl);
padding-bottom: 88px;
}
}
@ -151,6 +161,8 @@
padding: var(--space-md);
margin-bottom: var(--space-md);
box-shadow: var(--shadow-md);
width: 100%;
box-sizing: border-box;
}
.meeting-info-grid {
@ -192,6 +204,11 @@
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
margin-bottom: var(--space-md);
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
display: block;
}
.tabs-header {
@ -248,22 +265,19 @@
.tab-content {
display: none;
padding: var(--space-md);
width: 100%;
box-sizing: border-box;
}
.tab-content.active {
display: block;
}
@media (min-width: 768px) {
.tab-content {
padding: var(--space-lg);
}
}
@media (min-width: 1024px) {
.tab-content {
padding: var(--space-xl);
}
/* 모든 탭 콘텐츠 내부 요소 텍스트 줄바꿈 강제 */
.tab-content * {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* 참석자 탭 */
@ -437,6 +451,26 @@
line-height: 1.5;
}
/* 용어 검색 폼 */
.term-search-form {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-sm);
}
.term-search-input {
flex: 1;
font-size: var(--font-small);
padding: var(--space-sm);
}
.term-search-btn {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-small);
white-space: nowrap;
flex-shrink: 0;
}
/* 용어 사전 카드 */
.term-item {
background: #FAFAFA;
@ -564,6 +598,11 @@
height: 32px;
}
.rec-icon {
width: 32px;
height: 32px;
}
.end-meeting-btn {
flex: 1;
font-size: var(--font-body);
@ -580,7 +619,7 @@
</div>
<div class="recording-status-bar">
<div class="recording-indicator">
<div class="recording-indicator" id="recordingIndicator">
<span class="recording-dot"></span>
<span class="recording-time" id="recordingTime">00:15:51</span>
<div class="waveform">
@ -708,7 +747,7 @@
</div>
<h4 class="ai-suggestion-list-title">
💬 AI가 실시간으로 분석한 제안사항
💬 AI가 실시간으로 분석한 주요 내용
</h4>
<div id="aiSuggestionList">
@ -757,8 +796,21 @@
<div class="tab-content" id="tab-terms">
<h3 class="text-small font-bold mb-md">용어 사전</h3>
<!-- 용어 검색 폼 -->
<div class="term-search-form">
<input
type="text"
class="form-control term-search-input"
id="termSearchInput"
placeholder="용어 검색..."
>
<button class="btn btn-primary btn-sm term-search-btn" onclick="searchTerm()">
검색
</button>
</div>
<div id="termsList">
<div class="term-item" onclick="showTermDetail('MVP')">
<div class="term-item highlight" onclick="showTermDetail('MVP')">
<div class="term-name">
MVP
<span class="term-badge">기획</span>
@ -769,7 +821,7 @@
<div class="term-context">신제품 기획 회의에서 언급</div>
</div>
<div class="term-item" onclick="showTermDetail('B2C')">
<div class="term-item highlight" onclick="showTermDetail('B2C')">
<div class="term-name">
B2C
<span class="term-badge">비즈니스</span>
@ -780,7 +832,7 @@
<div class="term-context">타겟 고객 분석 시 사용</div>
</div>
<div class="term-item" onclick="showTermDetail('PMF')">
<div class="term-item highlight" onclick="showTermDetail('PMF')">
<div class="term-name">
PMF
<span class="term-badge">전략</span>
@ -791,7 +843,7 @@
<div class="term-context">제품 전략 논의 중 언급</div>
</div>
<div class="term-item" onclick="showTermDetail('CAC')">
<div class="term-item highlight" onclick="showTermDetail('CAC')">
<div class="term-name">
CAC
<span class="term-badge">마케팅</span>
@ -802,6 +854,102 @@
<div class="term-context">마케팅 예산 논의에서 사용</div>
</div>
</div>
<div id="termList">
<div class="term-item" onclick="showTermDetail('Mobile First')">
<div class="term-name">
Mobile First
<span class="term-badge">설계 방법론</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
모바일 환경을 우선적으로 고려하여 디자인하고, 이후 더 큰 화면으로 확장하는 설계 방법론입니다.
</div>
<div class="term-context">회의에서 언급됨 (14:23)</div>
</div>
<div class="term-item" onclick="showTermDetail('AI')">
<div class="term-name">
AI
<span class="term-badge">기술</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
Artificial Intelligence의 약자로, 인공지능을 의미합니다. 이 프로젝트에서는 회의록 자동 작성에 활용됩니다.
</div>
<div class="term-context">회의에서 5회 언급됨</div>
</div>
<div class="term-item" onclick="showTermDetail('API')">
<div class="term-name">
API
<span class="term-badge">기술</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
Application Programming Interface의 약자로, 소프트웨어 간 상호작용을 위한 인터페이스입니다.
</div>
<div class="term-context">회의에서 3회 언급됨</div>
</div>
<div class="term-item" onclick="showTermDetail('API Gateway')">
<div class="term-name">
API Gateway
<span class="term-badge">아키텍처</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
클라이언트와 백엔드 마이크로서비스 사이의 단일 진입점 역할을 하는 서버. 요청 라우팅, 인증, 속도 제한, 로드 밸런싱 등을 처리합니다.
</div>
<div class="term-context">API 설계 리뷰 회의 (2024-09-28)에서 AWS API Gateway 채택 결정</div>
</div>
<div class="term-item" onclick="showTermDetail('마이크로서비스')">
<div class="term-name">
마이크로서비스
<span class="term-badge">아키텍처</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
애플리케이션을 작고 독립적인 서비스들로 분리하여 개발하고 배포하는 아키텍처 패턴입니다.
</div>
<div class="term-context">회의에서 언급됨</div>
</div>
<div class="term-item " onclick="showTermDetail('MVP')">
<div class="term-name">
MVP
<span class="term-badge">방법론</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
Minimum Viable Product의 약자. 최소한의 기능만 갖춘 제품으로, 시장 반응을 빠르게 확인하기 위해 개발합니다.
</div>
<div class="term-context">개발 일정 논의에서 언급</div>
</div>
<div class="term-item" onclick="showTermDetail('RESTful API')">
<div class="term-name">
RESTful API
<span class="term-badge">기술</span>
</div>
<div class="term-definition">
REST(Representational State Transfer) 아키텍처 스타일을 따르는 웹 서비스 API 설계 방식입니다.
</div>
<div class="term-context">API 설계 리뷰 회의 참조</div>
</div>
<div class="term-item" onclick="showTermDetail('JWT')">
<div class="term-name">
JWT
<span class="term-badge">보안</span>
</div>
<div class="term-definition">
JSON Web Token의 약자. 사용자 인증 정보를 안전하게 전송하기 위한 토큰 기반 인증 방식입니다.
</div>
<div class="term-context">API Gateway 보안 정책에서 채택</div>
</div>
</div>
</div>
<!-- 관련 회의록 탭 -->
@ -852,11 +1000,12 @@
<!-- 하단 고정 버튼 영역 -->
<div class="bottom-action-bar">
<button class="btn btn-ghost pause-btn" onclick="pauseRecording()" id="pauseBtn">
<svg class="pause-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<button class="btn btn-ghost pause-btn" onclick="toggleRecording()" id="pauseBtn">
<svg class="pause-icon" id="pauseIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="4" width="4" height="16" rx="1"></rect>
<rect x="14" y="4" width="4" height="16" rx="1"></rect>
</svg>
<img class="rec-icon" id="recIcon" src="img/rec.png" alt="녹음 재개" style="display: none;">
</button>
<button class="btn btn-error end-meeting-btn" onclick="endMeeting()">
회의 종료
@ -979,9 +1128,104 @@
// });
}
// 녹음 일시정지
function pauseRecording() {
alert('녹음이 일시정지되었습니다');
// 용어 검색
function searchTerm() {
const searchInput = document.getElementById('termSearchInput');
const searchText = searchInput.value.trim().toLowerCase();
if (!searchText) {
alert('검색할 용어를 입력해주세요');
return;
}
const termItems = document.querySelectorAll('.term-item');
let foundCount = 0;
termItems.forEach(item => {
const termName = item.querySelector('.term-name').textContent.toLowerCase();
const termDefinition = item.querySelector('.term-definition').textContent.toLowerCase();
// 용어명 또는 정의에 검색어가 포함되어 있는지 확인
if (termName.includes(searchText) || termDefinition.includes(searchText)) {
item.style.display = '';
item.classList.add('highlight');
foundCount++;
} else {
item.style.display = 'none';
item.classList.remove('highlight');
}
});
if (foundCount === 0) {
alert('검색 결과가 없습니다');
// 모든 항목 다시 표시
termItems.forEach(item => {
item.style.display = '';
item.classList.remove('highlight');
});
searchInput.value = '';
}
}
// Enter 키로 검색 실행
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('termSearchInput');
if (searchInput) {
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
searchTerm();
}
});
// 입력값이 비워지면 전체 표시
searchInput.addEventListener('input', function() {
if (this.value.trim() === '') {
const termItems = document.querySelectorAll('.term-item');
termItems.forEach(item => {
item.style.display = '';
item.classList.remove('highlight');
});
}
});
}
});
// 녹음 상태 관리
let isRecording = true;
let timerInterval = null;
// 녹음 일시정지/재개 토글
function toggleRecording() {
const pauseIcon = document.getElementById('pauseIcon');
const recIcon = document.getElementById('recIcon');
const recordingIndicator = document.getElementById('recordingIndicator');
if (isRecording) {
// 일시정지
isRecording = false;
pauseIcon.style.display = 'none';
recIcon.style.display = 'block';
recordingIndicator.classList.add('paused');
// 타이머 정지
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
alert('녹음이 일시정지되었습니다');
} else {
// 재개
isRecording = true;
pauseIcon.style.display = 'block';
recIcon.style.display = 'none';
recordingIndicator.classList.remove('paused');
// 타이머 재시작
startTimer();
alert('녹음이 재개되었습니다');
}
}
// 회의 종료
@ -990,19 +1234,27 @@
alert('회의가 종료되었습니다. AI가 회의록을 생성 중입니다...');
setTimeout(() => {
window.location.href = '10-회의록상세조회.html';
window.location.href = '07-회의종료.html';
}, 2000);
}
}
// 타이머 업데이트
function updateTimer() {
// 타이머 시작 함수
function startTimer() {
const timerElement = document.getElementById('recordingTime');
let seconds = 51;
let minutes = 15;
let hours = 0;
setInterval(() => {
// 이미 타이머가 실행 중이면 중복 실행 방지
if (timerInterval) {
return;
}
timerInterval = setInterval(() => {
// 현재 시간을 파싱
const currentTime = timerElement.textContent.split(':');
let hours = parseInt(currentTime[0]);
let minutes = parseInt(currentTime[1]);
let seconds = parseInt(currentTime[2]);
seconds++;
if (seconds === 60) {
seconds = 0;
@ -1022,6 +1274,25 @@
}, 1000);
}
// 타이머 초기화 함수
function updateTimer() {
startTimer();
}
// 관련 회의록 열기
function openRelatedDoc(docId) {
// 새 탭으로 회의록 상세조회 화면 열기
window.open('10-회의록상세조회.html', '_blank');
// 기본 동작(링크 이동) 방지
return false;
}
// 용어 상세 정보 보기
function showTermDetail(termName) {
alert(`"${termName}" 용어에 대한 상세 정보를 표시합니다.`);
}
// 페이지 로드 시 타이머 시작
document.addEventListener('DOMContentLoaded', function() {
updateTimer();

View File

@ -2,9 +2,9 @@
## 문서 정보
- **작성일**: 2025-10-21
- **최종 수정일**: 2025-10-24
- **최종 수정일**: 2025-10-25
- **작성자**: 이미준 (서비스 기획자)
- **버전**: 1.4.18
- **버전**: 1.4.20
- **설계 철학**: Mobile First Design
---
@ -628,51 +628,30 @@ graph TD
#### 개요
- **목적**: 실시간 회의 진행 및 AI 기반 회의록 자동 작성
- **관련 유저스토리**: UFR-MEET-030, UFR-STT-010/020, UFR-AI-010, UFR-AI-040, UFR-COLLAB-010, UFR-RAG-010/020
- **관련 유저스토리**: UFR-MEET-030, UFR-STT-010/020, UFR-AI-010, UFR-AI-040, UFR-COLLAB-010, UFR-RAG-010/020, UFR-PART-010/020/030, UFR-HOST-010/020, UFR-TERM-010/020
- **비즈니스 중요도**: 높음 (핵심 화면)
- **접근 경로**: 템플릿선택 → "이 템플릿으로 시작"
- **권한**:
- 회의 시작/종료: 회의 생성자 전용
- 회의록 편집: 모든 참석자
- **접근 경로**: 대시보드 → "참여하기" 버튼 (페이지 전환)
- **권한** (MVP 개선):
- **회의 생성자 전용**: 회의 종료, 녹음 제어 (일시정지/재개/종료)
- **모든 참석자**: 회의 참여, AI 주요 내용 체크, 용어 확인, 관련 회의록 확인, 중도 퇴장
#### 주요 기능
1. 음성 녹음 및 실시간 텍스트 변환 (STT)
2. AI 자동 회의록 작성 (구조화)
3. **AI 기반 회의 내용 요약 자동 생성** (섹션별)
4. 실시간 협업 (여러 참석자 동시 편집)
5. 전문용어 자동 감지 및 맥락 기반 설명
6. **참고자료 자동 연결** (이전 회의록, 관련 회의록)
7. 수동 편집 및 섹션별 작성
8. 회의 진행 시간 표시
3. **AI 기반 주요 메모 항목 실시간 제안** (UFR-MEET-030)
4. 전문용어 자동 감지 및 맥락 기반 설명
5. **참고자료 자동 연결** (이전 회의록, 관련 회의록)
6. 참석자 관리 및 초대 기능
7. 회의 진행 시간 표시
#### UI 구성요소
**전체 레이아웃 (2열 구조)**
**전체 레이아웃**
- **헤더** (Fixed, 상단)
- 좌측: "회의 진행 중" 제목 + 경과시간 배지 (빨강, 01:03)
- 우측: "회의 종료" 버튼 (민트 그린 테두리)
- **왼쪽 영역: 회의 내용 작성** (60-70% 너비)
- **텍스트 에디터 툴바**
- B (Bold), I (Italic), U (Underline)
- 색상 선택, 링크 추가
- **편집 영역** (contentEditable, 스크롤 가능)
- 실시간 입력 텍스트: "회의 내용을 작성하거나 AI가 자동으로 작성합니다..."
- 섹션 구조:
```
# 참석자
- 김민준
- 박서연
- 이준호
# 안건
1. 신규 기능 개발 일정 논의
2. 예산 편성 검토
```
- 자동 저장 (30초 간격)
- **오른쪽 영역: 정보 패널** (30-40% 너비, 탭 구조)
- **메인 콘텐츠 영역: 정보 패널** (탭 구조)
- **탭 네비게이션** (4개 탭)
- 참석자 (3명)
- AI 제안
@ -709,30 +688,6 @@ graph TD
- 본문 폰트: 14px, gray-700
- 구조: 헤더 + 본문 텍스트 + 액션 버튼
- **논의사항 제안 카드**
- 헤더: "💬 논의사항 제안" (아이콘 + 제목, 16px bold, 민트 그린)
- 내용: "AI 모델 정확도 향상 방안" (strong 태그, 14px)
- 현재 STT 정확도: 92% (14px 일반, gray-700)
- 목표 정확도: 95% 이상
- 도메인 특화 학습 데이터 확보 필요
- 액션 버튼: "논의사항에 적용" (btn-primary btn-sm) + "수정" (btn-ghost btn-sm)
- **결정사항 제안 카드**
- 헤더: "✅ 결정사항 제안" (아이콘 + 제목, 16px bold, 민트 그린)
- 내용: "개발 일정 최종 확정" (strong 태그, 14px)
- 설계: 2주 (11/1~11/14) (14px 일반, gray-700)
- 개발: 10주 (11/15~1/23)
- 테스트 및 배포: 2주 (1/24~2/6)
- 액션 버튼: "결정사항에 적용" (btn-primary btn-sm) + "수정" (btn-ghost btn-sm)
- **액션아이템 제안 카드**
- 헤더: "📋 액션 아이템(Todo) 자동 추출" (아이콘 + 제목, 16px bold, 민트 그린)
- 추출된 Todo 목록 (14px 일반, gray-700):
1. API 명세서 작성 (이준호, 10/23까지)
2. UI 프로토타입 디자인 (최유진, 10/28까지)
3. AI 모델 성능 테스트 (박서연, 10/25까지)
- 액션 버튼: "3개 Todo 생성" (btn-primary btn-sm) + "수정" (btn-ghost btn-sm)
- **용어 사전 탭**
- 제목: "용어 사전"
- 용어 검색 입력 필드 (placeholder: "용어 검색...")
@ -805,32 +760,22 @@ graph TD
- 카드 클릭 시: **새 탭으로 열기** (target="_blank")
**Mobile (320px~768px)**
- **2열 구조를 1열로 전환**
- 왼쪽 영역: 메인 콘텐츠 (전체 너비)
- 오른쪽 탭 패널: 하단 시트로 표시
- 탭 버튼 클릭 시 바텀시트 슬라이드업
- 오버레이 + 닫기 버튼
**반응형 디자인**
- **Mobile (320px~768px)**
- 헤더: 고정 상단, 좁은 너비
- 메인 콘텐츠: 전체 너비 사용
- 탭 콘텐츠: 세로 스크롤
- 하단 버튼 영역: 고정 하단
**Desktop (768px+)**
- 2열 고정 레이아웃
- 왼쪽: 편집 영역
- 오른쪽: 탭 패널 (고정)
- **Desktop (768px+)**
- 헤더: 고정 상단, 넓은 너비
- 메인 콘텐츠: 최대 너비 제한 없이 반응형
- 탭 콘텐츠: 더 넓은 영역 활용
- 하단 버튼 영역: 고정 하단
#### 인터랙션
1. **텍스트 편집 (왼쪽 영역)**
- **편집 모드**: contentEditable 영역 클릭하여 즉시 편집 시작
- **자동 저장**: 편집 중 30초 간격 자동 저장
- **툴바 사용**:
- B (Bold): 선택된 텍스트를 굵게
- I (Italic): 선택된 텍스트를 이탤릭체로
- U (Underline): 선택된 텍스트에 밑줄
- 색상 선택: 텍스트 강조 색상 변경
- 링크 추가: URL 입력 모달 표시
- **실시간 동기화**: WebSocket 통해 모든 참석자에게 편집 내용 동기화
- **충돌 감지**: 동시 편집 시 충돌 감지 및 병합 옵션 제공
2. **탭 전환 (오른쪽 영역)**
1. **탭 전환**
- **참석자 탭**: 현재 회의 참석자 목록 표시 (4명) 및 참석자 추가 기능
- **참석자 추가 폼** (상단):
- 이메일 입력 필드 (form-control 스타일, placeholder: "이메일 주소 입력")
@ -847,59 +792,28 @@ graph TD
- 상태 표시 없음 (발언 중/온라인 등 제거)
- 참석자 수 동적 업데이트 (초대 성공 시)
- **AI 제안 탭**: AI가 생성한 회의록 개선 제안
- **실시간 주요 메모 추천** (UFR-MEET-030):
- 음성→텍스트 변환 후 AI가 실시간 분석
- **중요한 내용으로 판단된 경우에만** 주요 메모 항목 추천
- **AI 제안 탭**: AI가 생성한 주요 메모 항목 제안 (UFR-MEET-030)
- **실시간 주요 메모 추천**:
- 음성→텍스트 변환 후 AI가 실시간으로 회의 내용 분석
- **중요한 내용으로 판단된 경우에만** 주요 메모 항목 제안
- 논의항목/결정사항 등의 구분 없이 중요 내용을 주요 메모로 제안
- 추천 빈도는 중요 내용 발생에 따라 가변적 (고정 간격 아님)
- 각 추천 항목에 "주요 메모에 추가" 버튼 제공
- 각 제안 항목에 "주요 메모에 추가" 버튼 제공
- 클릭 시 해당 안건의 주요 메모에 자동 저장
- 실시간 업데이트: 새로운 추천은 상단에 표시
- **논의사항 제안 카드**: 제안 내용 + "논의사항에 적용" 버튼
- 제안 구조:
- 제목: "AI 모델 정확도 향상 방안" (strong)
- 내용: 3-5개의 구체적인 논의 포인트 (bullet points)
- "논의사항에 적용" 클릭 시:
1. 논의사항 섹션(section-1)의 content-1 영역에 제안 내용 추가
2. 기존 내용 하단에 `<br>` 태그로 구분하여 추가
3. 제목은 `<strong>` 태그, 내용은 `<p>` 태그로 구조화
4. 성공 토스트 표시: "논의사항에 AI 제안이 추가되었습니다"
5. 자동으로 논의사항 탭(섹션 1)으로 전환 (switchSection(1))
6. 제안 카드 숨김 처리 (display: none)
- "수정" 버튼: 제안을 거부하고 카드 숨김
- **결정사항 제안 카드**: 제안 내용 + "결정사항에 적용" 버튼
- 제안 구조:
- 제목: "개발 일정 최종 확정" (strong)
- 내용: 확정된 결정사항 (bullet points)
- "결정사항에 적용" 클릭 시:
1. 결정사항 섹션(section-2)의 content-2 영역에 제안 내용 추가
2. 기존 내용 하단에 `<br>` 태그로 구분하여 추가
3. 제목은 `<strong>✓` 접두어 포함, 내용은 `<p>` 태그로 구조화
4. 성공 토스트 표시: "결정사항에 AI 제안이 추가되었습니다"
5. 자동으로 결정사항 탭(섹션 2)으로 전환 (switchSection(2))
6. 제안 카드 숨김 처리 (display: none)
- "수정" 버튼: 제안을 거부하고 카드 숨김
- **액션아이템 제안 카드**: 제안 내용 + "3개 Todo 생성" 버튼
- 제안 구조:
- 헤더: "📋 액션 아이템(Todo) 자동 추출"
- 내용: 3개의 Todo 항목 (제목, 담당자, 마감일)
- "3개 Todo 생성" 클릭 시:
1. 액션아이템 섹션(section-3)의 content-3 영역에 Todo 항목 추가
2. **중복 체크**: 기존 Todo 목록에서 동일한 제목이 있는지 확인
3. 중복되지 않은 Todo만 추가 (Set 자료구조 활용)
4. Todo HTML 구조: checkbox + 제목 + 담당자/마감일 + 우선순위 배지
5. 성공 토스트 표시: "N개의 액션아이템이 추가되었습니다 (중복 제외)"
6. 중복된 항목이 있으면: "모든 항목이 이미 존재합니다" (info 토스트)
7. 자동으로 액션아이템 탭(섹션 3)으로 전환 (switchSection(3))
8. 제안 카드 숨김 처리 (display: none)
- "수정" 버튼: 제안을 거부하고 카드 숨김
- 실시간 업데이트: 새로운 제안은 상단에 표시
- **용어 사전 탭**: 회의에서 언급된 전문용어 설명
- 용어 카드 (민트 그린 배경): 용어명 + 간단한 정의
- **용어 검색 기능**:
- 검색 입력창 (placeholder: "용어 검색...", form-control 스타일)
- 검색 버튼 (btn btn-primary btn-sm)
- Enter 키 지원
- 검색 동작:
1. 용어명과 정의 모두 검색
2. 일치하는 용어만 표시, 나머지는 숨김
3. 검색 결과에 하이라이트 효과 적용
4. 검색 결과 없으면 전체 목록 다시 표시
5. 입력창이 비어있으면 전체 목록 표시
- 용어 카드: 용어명 + 카테고리 배지 + 간단한 정의
- 카드 클릭 → 확장하여 상세 설명 표시
- 상세 설명: 이 회의에서의 의미, 관련 회의록 링크
@ -908,41 +822,42 @@ graph TD
- **녹음 중인 페이지 이탈 방지**: 모든 링크는 새 탭으로 열림
- 관련도 표시: 퍼센트 또는 별점으로 시각화
3. **회의 종료**
2. **회의 종료**
- 헤더의 "회의 종료" 버튼 클릭
- 확인 다이얼로그 표시: "회의를 종료하시겠습니까?"
- 확인 → 회의 종료 처리 및 07-회의종료.html로 이동
4. **실시간 업데이트**
3. **실시간 업데이트**
- STT 음성 인식 결과 실시간 반영 (3-5초 주기)
- 모든 참석자의 편집 내용 실시간 동기화
- 수정 사항 하이라이트 표시 (3초간)
- AI 제안 실시간 업데이트
- 용어 사전 자동 업데이트 (새로운 전문용어 감지 시)
- 관련 회의록 목록 동적 갱신
#### 데이터 요구사항
- **입력**:
- 회의 ID
- 오디오 스트림 (실시간 STT용)
- 사용자 편집 내용 (텍스트 입력)
- 참석자 초대 이메일
- **출력**:
- 실시간 텍스트 변환 결과 (STT)
- 편집된 회의록 내용
- **AI 제안 목록** (회의록 개선 제안)
- **AI 제안 목록** (주요 메모 항목 제안)
- **전문용어 및 설명** (용어 사전)
- **관련 회의록 목록** (32건, 관련도 포함)
- 참석자 목록 및 상태
- 참석자 목록
- **연동**:
- STT 서비스 (UFR-AI-010)
- AI 서비스 (AI 제안 생성, UFR-AI-040)
- RAG 서비스 (관련 회의록 검색)
- AI 서비스 (주요 메모 제안 생성, UFR-AI-040)
- RAG 서비스 (관련 회의록 검색, 전문용어 자동 감지)
- Collaboration 서비스 (실시간 동기화)
#### 에러 처리
- **마이크 권한 거부**: "마이크 권한이 필요합니다" 토스트 + 설정 안내 링크
- **STT 실패**: "음성 인식에 실패했습니다. 수동으로 입력해주세요" 토스트
- **AI 제안 생성 실패**: "AI 제안을 불러올 수 없습니다" 토스트 (편집은 계속 가능)
- **STT 실패**: "음성 인식에 실패했습니다" 토스트 + 재시도 안내
- **AI 제안 생성 실패**: "AI 제안을 불러올 수 없습니다" 토스트
- **용어 사전 로드 실패**: "용어 사전을 불러올 수 없습니다" 메시지 표시
- **관련 자료 검색 실패**: "관련 회의록을 찾을 수 없습니다" 메시지 표시
- **동기화 실패**: "네트워크 연결을 확인해주세요. 내용은 로컬에 저장됩니다" 토스트
- **편집 충돌**: "다른 참석자가 동일한 부분을 수정 중입니다" 다이얼로그 + 병합 옵션
- **참석자 초대 실패**: "초대 링크 전송에 실패했습니다" 토스트 + 재시도 버튼
- **동기화 실패**: "네트워크 연결을 확인해주세요" 토스트
- **회의 종료 실패**: "회의 종료 중 오류가 발생했습니다" 토스트 + 재시도 버튼
---
@ -1040,15 +955,21 @@ graph TD
- 11-회의록수정.html로 이동
- URL 파라미터: meetingId
- 회의록 상태: 작성중
- **옵션 2: 바로 최종 확정**
- 확인 다이얼로그 표시
- **옵션 2: 바로 최종 확정** (UFR-MEET-050 시나리오 2)
- 확인 다이얼로그 표시: "바로 최종 확정하시겠습니까? AI가 정리한 내용 그대로 확정됩니다."
- 확인 시:
- 모든 안건 검증률 100% 자동 설정
- 회의록 상태: 확정완료
- 안건별 검증완료 처리
- 회의록 상태: "작성중" → "확정완료"로 변경
- 확정 시간 기록
- 참석자에게 확정 알림 발송
- 성공 토스트: "회의록이 최종 확정되었습니다"
- 02-대시보드.html로 이동
- 10-회의록상세조회.html로 이동
- **시나리오 2 특징 (바로 확정)**:
- 회의록 수정 단계를 건너뜀
- AI 생성 내용을 그대로 확정
- 모든 안건이 자동으로 검증완료 처리됨
- 확정 후에도 회의 생성자는 수정 가능 (잠금 해제 필요)
- **옵션 3: 대시보드로 이동**
- 회의록 상태: 작성중
- 02-대시보드.html로 이동
@ -1474,6 +1395,9 @@ graph TD
- **안건 헤더**
- 안건 제목 (H4, Bold)
- 검증 상태 배지 (검증완료/미검증)
- 편집 중 표시 (동시 편집 시)
- 다른 사용자 아바타 + 이름
- 예: "김민준님 편집 중" (아이콘 + 텍스트)
- **AI 한줄 요약** (편집 불가, UFR-AI-036) - 신규
- 🔒 아이콘 + 30자 이내 한줄 요약
- 읽기 전용 (회색 배경, 민트 그린 좌측 액센트 라인)
@ -1605,6 +1529,38 @@ graph TD
- 확정완료 회의록 수정 시: 자동으로 "작성중" 상태로 변경
- 모든 안건 검증 완료 시: "확정완료"로 변경 제안
9. **안건 기반 충돌 해결 (UFR-COLLAB-020)**
- **안건 기반 충돌 방지 메커니즘**:
- **다른 안건 동시 편집**: 충돌 없음
- 참석자 A가 안건 1 편집 중
- 참석자 B가 안건 2 편집 가능
- 양쪽 모두 정상 저장 및 동기화
- **동일 안건 내 다른 필드 편집**: 자동 병합
- 참석자 A가 안건 1의 "상세 요약" 편집
- 참석자 B가 안건 1의 "관련회의록" 편집
- 양쪽 변경 사항 자동 병합
- **동일 필드 동시 수정**: Last Write Wins
- 마지막에 저장된 변경 사항이 적용
- 덮어쓰기 경고: "다른 사용자가 이미 수정했습니다. 최신 내용을 확인하세요"
- 선택 옵션: 최신 내용 확인 / 내 변경 사항 유지
- **편집 중 표시**:
- 다른 사용자가 편집 중인 안건 표시
- 편집자 아바타 + 이름 실시간 표시
- 예: "김민준님이 이 안건을 편집 중입니다" + 아바타
- 편집 시작 시 해당 안건에 브로드캐스트
- 편집 종료 시 표시 제거
- **충돌 경고 모달**:
- 제목: "동시 수정 감지"
- 메시지: "다른 사용자가 이미 이 내용을 수정했습니다"
- 옵션 버튼:
- "최신 내용 보기" (Primary): 다른 사용자 변경사항 로드
- "내 변경사항 유지" (Secondary): 현재 내용 유지 (덮어쓰기)
#### 데이터 요구사항
- **입력**:
- 회의록 ID (조회)
@ -1627,7 +1583,11 @@ graph TD
- **자동 저장 실패**: "네트워크 연결을 확인해주세요. 로컬에 임시 저장됩니다"
- **AI 요약 재생성 실패**: "요약 생성에 실패했습니다. 수동으로 작성해주세요"
- **참고자료 검색 실패**: "회의록을 검색할 수 없습니다"
- **충돌 발생**: "다른 참석자가 동일한 부분을 수정했습니다" + 병합 옵션
- **충돌 발생**:
- 안건 기반 충돌 방지로 최소화
- 동일 필드 동시 수정 시: "다른 사용자가 이미 수정했습니다" 경고 모달
- 선택 옵션: 최신 내용 확인 / 내 변경사항 유지
- 병합 실패 시: "병합 중 오류가 발생했습니다" 에러 메시지
- **삭제 실패**: "회의록 삭제에 실패했습니다"
---
@ -2108,6 +2068,7 @@ graph TD
| 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|----------|
| 1.4.20 | 2025-10-25 | 이미준, 강지수 | 유저스토리 v2.3.0 반영<br>- 회의 종료 화면 정책 명확화 (확인 전용, 바로 최종 확정 옵션 상세화)<br>- UFR-MEET-050: 최종 확정 2가지 시나리오 설명 추가<br>- UFR-COLLAB-020: 안건 기반 충돌 해결 메커니즘 상세 추가<br>- 실시간 협업 충돌 방지 정책 강화 |
| 1.0 | 2025-10-21 | 이미준 | 최초 작성 - 11개 화면 설계 완료 |
| 1.1 | 2025-10-21 | 이미준 | AI 요약 및 참고자료 기능 추가<br>- 05-회의진행: AI 회의 내용 요약 자동 생성 및 참고자료 자동 연결 추가<br>- 10-회의록상세조회: 섹션별 AI 요약 표시 및 참고자료 영역 추가<br>- 11-회의록수정: AI 요약 수정 및 참고자료 편집 기능 추가<br>- 관련 유저스토리: UFR-AI-040 (관련 회의록 자동 연결) |
| 1.1.1 | 2025-10-21 | 이미준 | 회의록 상세 화면 구조 개선 (프로토타입 기반)<br>- 10-회의록상세조회: 탭 기반 네비게이션 추가 (회의록/대시보드)<br>- 대시보드 탭 추가: 핵심내용, 결정사항, Todo 진행상황, 참고자료 섹션<br>- 참고자료 관련도 점수 표시 (백분율 + 색상 코딩)<br>- 참고자료 카테고리 탭 (관련 회의록/프로젝트 문서/이슈 트래커/위키 페이지)<br>- 참조: design-gappa/uiux/prototype 파일 (11-회의록대시보드.html, 05-회의진행.html) |
@ -2138,8 +2099,10 @@ graph TD
| 1.4.14 | 2025-10-24 | 이미준 | 12-회의록목록조회 화면 데이터 아키텍처 문서화<br>- **데이터 아키텍처 섹션 추가**: 데이터/뷰 레이어 분리 구조 설명<br> - 데이터 레이어: common.js → SAMPLE_MINUTES 배열 (30개 샘플)<br> - 뷰 레이어: 12-회의록목록조회.html → renderMeetings(), createMeetingCard() 함수<br> - 렌더링 방식: 동적 렌더링, 초기 10개 표시, "10개 더보기" 버튼으로 추가 로딩<br>- **정렬 옵션 레이블 변경**: "최신순" → "최근수정순", "회의일시순" → "최근회의순"<br>- **페이지네이션 기능 문서화**: 초기 10개 표시, "10개 더보기" 버튼 기능 설명<br>- **샘플 데이터 분포 명시**: 총 30개 (작성중 13개, 확정완료 17개)<br>- **프로토타입 파일 경로 추가**: design/uiux/prototype/12-회의록목록조회.html<br>- **스타일 가이드 버전 동기화**: v1.2.4 |
| 1.4.15 | 2025-10-24 | 이미준 | 06-검증완료 화면 삭제 (유저스토리 v2.1.2 변경사항 반영)<br>- **화면 삭제**: 06-검증완료 화면 전체 삭제<br> - 안건별 검증 기능이 11-회의록수정 화면으로 통합됨<br> - 섹션별 검증 방식에서 안건별 검증 방식으로 변경 (유저스토리 UFR-COLLAB-030 → 안건 기반 구조로 전환)<br>- **유저스토리 매핑 업데이트**:<br> - Collaboration 서비스: UFR-COLLAB-010 ~ UFR-COLLAB-030 → UFR-COLLAB-010 ~ UFR-COLLAB-020로 변경<br> - 프로토타입 화면 목록 테이블에서 06-검증완료 행 제거<br>- **화면 번호 유지**: 다른 화면 번호는 변경하지 않음 (프로토타입 파일명 유지)<br> - 07-회의종료, 09-Todo관리, 10-회의록상세조회, 11-회의록수정, 12-회의록목록조회 번호 유지<br>- **변경 이력**: 과거 버전의 UFR-COLLAB-030 언급은 역사적 맥락으로 유지 |
| 1.4.16 | 2025-10-24 | 이미준 | 사용자 역할 용어 통일 (유저스토리 v2.1.2 반영)<br>- **용어 정의 명확화**: "회의 생성자"와 "회의 참석자" 용어로 통일<br> - 설계 목표: "회의록 작성자" → "회의 참석자"로 수정<br>- **화면별 권한 정보 추가**:<br> - 03-회의예약: 모든 사용자 (예약 생성 시 자동으로 회의 생성자가 됨)<br> - 04-템플릿선택: 회의 생성자 전용<br> - 05-회의진행: 회의 시작/종료는 회의 생성자 전용, 회의록 편집은 모든 참석자<br> - 07-회의종료: 회의 생성자 전용<br> - 09-Todo관리: 모든 회의 참석자 (본인이 담당자인 Todo만 조회/수정 가능)<br> - 10-회의록상세조회: 모든 회의 참석자 (조회 전용)<br> - 11-회의록수정: 검증완료 전(모든 참석자), 검증완료 후(회의 생성자만) - 기존 권한 제어 유지<br> - 12-회의록목록조회: 모든 회의 참석자 (본인이 참석한 회의록만 조회)<br>- **스타일 가이드 동기화**: design/uiux/style-guide.md v1.2.5 (용어 정의 섹션 추가)<br>- **통일성 달성**: 유저스토리, 화면설계서, 스타일 가이드 간 용어 완전 통일 |
| 1.4.17 | 2025-10-24 | 강지수 | 10-회의록상세조회 화면 용어 통일 (섹션 → 안건)<br>- **용어 변경 (요구사항설계검토-report-V1.2.md 반영)**:<br> - 모든 "섹션별" → "안건별"로 용어 통일<br> - 주요 기능, UI 구성요소, 인터랙션, 데이터 요구사항, 에러 처리 섹션 전체 업데이트<br>- **CSS 클래스명 변경 (공통 스타일 + 프로토타입)**:<br> - common.css: `.section``.agenda`, `.section-header``.agenda-header`, `.section-title``.agenda-title`, `.section-content``.agenda-content`<br> - 10-회의록상세조회.html: 모든 section 클래스를 agenda 클래스로 일괄 변경<br>- **HTML 주석 업데이트**: "회의록 섹션" → "회의록 안건", "섹션 내용" → "안건 내용"<br>- **일관성 달성**: 유저스토리 v2.1.2의 안건 기반 구조와 완전히 일치 |
| 1.4.18 | 2025-10-24 | 강지수 | 12-회의록목록조회 화면 생성자 표시 기능 추가 (유저스토리 v2.1.3 반영)<br>- **목록 표시 정보 추가**: 회의 생성자 표시 (👑 아이콘)<br> - 현재 사용자가 회의 생성자인 경우 회의록 카드 헤더에 👑 아이콘 표시<br> - 위치: 상태 배지와 회의 제목 사이<br> - 스타일: font-size 16px, title="생성자" 툴팁 제공<br>- **UI 구성요소 업데이트**: 회의록 목록 섹션 명세 수정<br> - 좌측 영역에 "생성자 표시" 항목 추가<br> - 검증완료율 표시 조건 명시 (작성중 상태일 때만)<br>- **프로토타입 수정**: design/uiux/prototype/12-회의록목록조회.html<br> - createMeetingCard() 함수: crownEmoji 변수를 creatorBadge로 변경 및 .creator-badge 클래스 적용<br> - common.css: .creator-badge 스타일 추가 (inline-flex, 16px, margin-left 4px, cursor help)<br>- **스타일 가이드 업데이트**: design/uiux/style-guide.md v1.2.6<br> - 생성자 배지 섹션 추가 (배지 시스템 내 우선순위 배지 다음)<br> - 사용 예시 및 사용 위치 명시 (12-회의록목록조회, 02-대시보드, 10-회의록상세조회) |
|| 1.4.17 | 2025-10-24 | 강지수 | 07-회의종료 화면 STT 한계 반영 (유저스토리 v2.1.2)<br>- **STT 화자 식별 불가 반영**: STT는 화자를 식별할 수 없으므로 화자 관련 기능 제거<br> - 발언 통계 섹션 삭제<br> - 안건별 "발언자별 의견" 섹션 삭제<br>- **통계 영역 디자인 개선**: 정보성 디자인으로 명확화<br> - 배경색: var(--white) → var(--gray-50)<br> - 숫자 색상: var(--primary) → var(--gray-900)<br> - 라벨 색상: var(--gray-500) → var(--gray-600)<br> - 정보 표시 전용으로 시각적 구분 명확화<br>- **안건 섹션 구분 개선**:<br> - 안건 간 하단 보더 추가 (1px solid var(--gray-200))<br> - 섹션 제목에 primary 색상 세로 바 추가 (::before pseudo-element)<br> - 콘텐츠 영역 좌측 패딩 추가로 계층 구조 명확화<br>- **연관 문서 업데이트**:<br> - 유저스토리 UFR-MEET-040: "발언 횟수 (화자별)" 항목 제거<br> - UI/UX 설계서 07-회의종료: 발언 통계 및 발언자별 의견 항목 제거 |
| 1.4.18 | 2025-10-24 | 강지수 | 05-회의진행 실시간 주요 메모 추천 기능 명확화 (유저스토리 v2.1.1)<br>- **AI 제안 탭 기능 상세화**: 실시간 주요 메모 추천 기능 명시 추가<br> - UFR-MEET-030: 실시간 AI 주요 메모 추천<br> - 음성→텍스트 변환 후 AI가 실시간 분석<br> - **중요한 내용으로 판단된 경우에만** 주요 메모 항목 추천<br> - 추천 빈도는 중요 내용 발생에 따라 가변적 (3-5초 고정 간격 아님)<br> - 각 추천 항목에 "주요 메모에 추가" 버튼 제공<br> - 실시간 업데이트: 새로운 추천은 상단에 표시<br>- **프로토타입 확인**: 05-회의진행.html의 AI 제안 탭이 실시간 주요 메모 추천 기능을 포함하고 있음을 확인<br>- **참조**: design/uiux/요구사항설계검토-report-V1.2.md (실시간 주요 메모 추천 명시 부족 개선) |
| 1.4.19 | 2025-10-24 | 강지수 | 05-회의진행 화면 설계서 프로토타입 기준 전면 수정<br>- **레이아웃 구조 변경**: "2열 구조" 표현 제거, "메인 콘텐츠 영역: 정보 패널 (탭 구조)"로 단순화<br> - 텍스트 편집 영역 관련 내용 모두 제거 (왼쪽 영역, 에디터 툴바, contentEditable 등)<br> - 현재 프로토타입은 헤더 + 탭 콘텐츠 구조만 보유<br>- **반응형 디자인 명확화**: Mobile/Desktop 모두 동일한 구조에 너비만 반응형<br> - "2열 구조를 1열로 전환", "바텀시트" 표현 제거<br> - Mobile: 전체 너비 사용, Desktop: 최대 너비 제한 없이 반응형<br>- **AI 제안 탭 기능 명확화**: 논의항목/결정사항 구분 제거<br> - "논의항목/결정사항 등의 구분 없이 중요 내용을 주요 메모로 제안" 명시<br> - AI는 단순히 중요한 내용을 주요 메모 항목으로 제안하는 역할만 수행<br>- **용어 사전 검색 기능 추가**: 검색 입력창 + 검색 버튼<br> - Enter 키 지원, 용어명과 정의 모두 검색<br> - 검색 동작 상세 설명: 일치하는 용어만 표시, 하이라이트 효과, 결과 없으면 전체 목록 표시<br>- **인터랙션 섹션 정리**: 텍스트 편집, 툴바 사용, 충돌 감지 등 편집 관련 내용 모두 제거<br> - 탭 전환, 회의 종료, 실시간 업데이트만 유지<br> - 실시간 업데이트 항목을 현재 화면에 맞게 수정 (AI 제안, 용어 사전, 관련 회의록)<br>- **데이터 요구사항 업데이트**: 사용자 편집 내용 제거, 참석자 초대 이메일 추가<br> - AI 제안을 "주요 메모 항목 제안"으로 명확히 표현<br>- **에러 처리 업데이트**: 편집 충돌 에러 제거, 용어 사전 로드 실패/참석자 초대 실패 추가<br>- **주요 기능 목록 정리**: 실시간 협업/수동 편집 제거, AI 주요 메모 제안/참석자 관리 추가<br>- **권한 항목 수정**: "회의록 편집: 모든 참석자" → "참석자 초대: 모든 참석자"<br>- **프로토타입 기준 반영**: 05-회의진행.html 실제 구현 상태 100% 반영 |
---
@ -2155,5 +2118,3 @@ graph TD
- 타이포그래피
- 컴포넌트 라이브러리
- 아이콘 세트
| 1.4.17 | 2025-10-24 | 강지수 | 07-회의종료 화면 STT 한계 반영 (유저스토리 v2.1.2)<br>- **STT 화자 식별 불가 반영**: STT는 화자를 식별할 수 없으므로 화자 관련 기능 제거<br> - 발언 통계 섹션 삭제<br> - 안건별 "발언자별 의견" 섹션 삭제<br>- **통계 영역 디자인 개선**: 정보성 디자인으로 명확화<br> - 배경색: var(--white) → var(--gray-50)<br> - 숫자 색상: var(--primary) → var(--gray-900)<br> - 라벨 색상: var(--gray-500) → var(--gray-600)<br> - 정보 표시 전용으로 시각적 구분 명확화<br>- **안건 섹션 구분 개선**:<br> - 안건 간 하단 보더 추가 (1px solid var(--gray-200))<br> - 섹션 제목에 primary 색상 세로 바 추가 (::before pseudo-element)<br> - 콘텐츠 영역 좌측 패딩 추가로 계층 구조 명확화<br>- **연관 문서 업데이트**:<br> - 유저스토리 UFR-MEET-040: "발언 횟수 (화자별)" 항목 제거<br> - UI/UX 설계서 07-회의종료: 발언 통계 및 발언자별 의견 항목 제거 |
| 1.4.18 | 2025-10-24 | 강지수 | 05-회의진행 실시간 주요 메모 추천 기능 명확화 (유저스토리 v2.1.1)<br>- **AI 제안 탭 기능 상세화**: 실시간 주요 메모 추천 기능 명시 추가<br> - UFR-MEET-030: 실시간 AI 주요 메모 추천<br> - 음성→텍스트 변환 후 AI가 실시간 분석<br> - **중요한 내용으로 판단된 경우에만** 주요 메모 항목 추천<br> - 추천 빈도는 중요 내용 발생에 따라 가변적 (3-5초 고정 간격 아님)<br> - 각 추천 항목에 "주요 메모에 추가" 버튼 제공<br> - 실시간 업데이트: 새로운 추천은 상단에 표시<br>- **프로토타입 확인**: 05-회의진행.html의 AI 제안 탭이 실시간 주요 메모 추천 기능을 포함하고 있음을 확인<br>- **참조**: design/uiux/요구사항설계검토-report-V1.2.md (실시간 주요 메모 추천 명시 부족 개선) |

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,9 @@ task printEnv {
}
dependencies {
// Module dependencies
implementation project(':notification')
// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,15 @@
package com.unicorn.hgzero.meeting.biz.domain;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
@ -77,6 +81,101 @@ public class Minutes {
*/
private LocalDateTime finalizedAt;
/**
* 회의록 확정 가능 여부 검증
*
* @param meeting 회의 정보
* @param userId 확정 요청자 ID
* @throws BusinessException 검증 실패
*/
public void validateCanConfirm(Meeting meeting, String userId) {
List<String> errors = new ArrayList<>();
// 1. 상태 검증
if (!"DRAFT".equals(this.status)) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "회의록이 작성중 상태가 아닙니다. 현재 상태: " + this.status);
}
if (!"COMPLETED".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "회의가 종료되지 않았습니다. 현재 회의 상태: " + meeting.getStatus());
}
// 2. 권한 검증
boolean isOrganizer = meeting.getOrganizerId().equals(userId);
boolean isParticipant = meeting.getParticipants() != null && meeting.getParticipants().contains(userId);
if (!isOrganizer && !isParticipant) {
throw new BusinessException(ErrorCode.ACCESS_DENIED, "회의록 확정 권한이 없습니다.");
}
// 3. 필수 항목 검증
if (this.title == null || this.title.trim().isEmpty()) {
errors.add("회의록 제목이 없습니다.");
} else if (this.title.trim().length() < 5) {
errors.add("회의록 제목은 최소 5자 이상이어야 합니다.");
}
if (meeting.getParticipants() == null || meeting.getParticipants().isEmpty()) {
errors.add("참석자가 최소 1명 이상 있어야 합니다.");
}
// 섹션 검증
if (this.sections != null && !this.sections.isEmpty()) {
boolean hasDiscussionContent = false;
boolean hasDecisionContent = false;
for (MinutesSection section : this.sections) {
if ("DISCUSSION".equals(section.getType()) && section.getContent() != null && section.getContent().trim().length() >= 20) {
hasDiscussionContent = true;
}
if ("DECISION".equals(section.getType())) {
if (section.getContent() != null && !section.getContent().trim().isEmpty()) {
hasDecisionContent = true;
}
}
}
if (!hasDiscussionContent) {
errors.add("주요 논의 내용이 없거나 20자 미만입니다.");
}
if (!hasDecisionContent) {
errors.add("결정 사항이 없습니다. (결정사항이 없는 경우 '결정사항 없음'을 명시해주세요)");
}
} else {
errors.add("회의록 섹션이 없습니다.");
}
// 4. 데이터 무결성 검증
if (this.sections != null) {
for (MinutesSection section : this.sections) {
// 필수 필드 검증
if (section.getTitle() == null || section.getTitle().trim().isEmpty()) {
errors.add("섹션 제목이 비어있는 섹션이 있습니다. (섹션 ID: " + section.getSectionId() + ")");
}
if (section.getContent() == null || section.getContent().trim().isEmpty()) {
errors.add("섹션 내용이 비어있는 섹션이 있습니다. (섹션 ID: " + section.getSectionId() + ")");
}
}
}
// 검증 오류가 있으면 예외 발생
if (!errors.isEmpty()) {
String errorMessage = "회의록 확정 검증 실패:\n" + String.join("\n", errors);
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, errorMessage);
}
// 5. 이력 검증 (경고)
if (this.lastModifiedAt != null) {
Duration duration = Duration.between(this.lastModifiedAt, LocalDateTime.now());
if (duration.toHours() > 24) {
// 로그로 경고만 출력 (진행 가능)
// TODO: 경고 메시지를 응답에 포함시키는 방법 고려
}
}
}
/**
* 회의록 확정
*/
@ -114,4 +213,17 @@ public class Minutes {
this.title = title;
this.version++;
}
/**
* 모든 섹션 잠금
*/
public void lockAllSections(String userId) {
if (this.sections != null) {
for (MinutesSection section : this.sections) {
if (!section.isLocked()) {
section.lock(userId);
}
}
}
}
}

View File

@ -53,6 +53,8 @@ public class MeetingService implements
private final MeetingAnalysisWriter meetingAnalysisWriter;
private final CacheService cacheService;
private final EventPublisher eventPublisher;
private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter participantWriter;
private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader participantReader;
/**
* 회의 생성
@ -104,6 +106,12 @@ public class MeetingService implements
// 5. 회의 저장
Meeting savedMeeting = meetingWriter.save(meeting);
// 5-1. 참석자 목록 저장
if (command.participants() != null && !command.participants().isEmpty()) {
participantWriter.saveParticipants(meetingId, command.participants());
log.debug("Participants saved: meetingId={}, count={}", meetingId, command.participants().size());
}
// 6. 캐시 저장 (TTL: 10분)
try {
cacheService.cacheMeeting(meetingId, savedMeeting, 600);
@ -525,16 +533,13 @@ public class MeetingService implements
}
// 이미 참석자로 등록되었는지 확인
if (meeting.getParticipants() != null && meeting.getParticipants().contains(command.email())) {
if (participantReader.existsParticipant(command.meetingId(), command.email())) {
log.warn("Email {} is already a participant of meeting {}", command.email(), command.meetingId());
throw new BusinessException(ErrorCode.DUPLICATE_RESOURCE);
}
// 참석자 목록에 추가
meeting.addParticipant(command.email());
// 저장
meetingWriter.save(meeting);
// 참석자 저장
participantWriter.saveParticipant(command.meetingId(), command.email());
// TODO: 실제 이메일 발송 구현 필요
// 이메일 발송 서비스 호출

View File

@ -2,11 +2,17 @@ package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.usecase.in.minutes.*;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesWriter;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
@ -34,6 +40,10 @@ public class MinutesService implements
private final MinutesReader minutesReader;
private final MinutesWriter minutesWriter;
private final MeetingReader meetingReader;
private final MinutesSectionReader minutesSectionReader;
private final MinutesSectionWriter minutesSectionWriter;
private final CacheService cacheService;
/**
* 회의록 생성
@ -118,24 +128,55 @@ public class MinutesService implements
@Override
@Transactional
public Minutes finalizeMinutes(String minutesId, String userId) {
log.info("Finalizing minutes: {}", minutesId);
log.info("Finalizing minutes: {} by user: {}", minutesId, userId);
// 회의록 조회
// 1. 회의록 조회
Minutes minutes = minutesReader.findById(minutesId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의록을 찾을 수 없습니다."));
// 상태 검증
if ("FINALIZED".equals(minutes.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
}
// 2. 회의 정보 조회
Meeting meeting = meetingReader.findById(minutes.getMeetingId())
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의 정보를 찾을 수 없습니다."));
// 회의록 확정
// 3. 회의록 섹션 조회 설정
List<MinutesSection> sections = minutesSectionReader.findByMinutesIdOrderByOrder(minutesId);
minutes = Minutes.builder()
.minutesId(minutes.getMinutesId())
.meetingId(minutes.getMeetingId())
.title(minutes.getTitle())
.sections(sections)
.status(minutes.getStatus())
.version(minutes.getVersion())
.createdBy(minutes.getCreatedBy())
.createdAt(minutes.getCreatedAt())
.lastModifiedAt(minutes.getLastModifiedAt())
.lastModifiedBy(minutes.getLastModifiedBy())
.finalizedBy(minutes.getFinalizedBy())
.finalizedAt(minutes.getFinalizedAt())
.build();
// 4. 회의록 확정 가능 여부 검증
minutes.validateCanConfirm(meeting, userId);
// 5. 모든 섹션 잠금
minutes.lockAllSections(userId);
// 6. 회의록 확정
minutes.finalize(userId);
// 저장
// 7. 회의록 저장
Minutes finalizedMinutes = minutesWriter.save(minutes);
log.info("Minutes finalized successfully: {}", minutesId);
// 8. 섹션 잠금 상태 저장 (기존 엔티티 조회 업데이트하므로 연관관계 유지됨)
if (sections != null) {
for (MinutesSection section : sections) {
minutesSectionWriter.save(section);
}
}
// 9. 캐시에 저장 (TTL: 10분) - 컨트롤러에서 처리됨
log.info("Minutes finalized successfully: {}, version: {}", minutesId, finalizedMinutes.getVersion());
return finalizedMinutes;
}

View File

@ -0,0 +1,24 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import java.util.List;
/**
* 참석자 조회 인터페이스
*/
public interface ParticipantReader {
/**
* 회의 ID로 참석자 목록 조회
*/
List<String> findParticipantsByMeetingId(String meetingId);
/**
* 사용자 ID로 참여 회의 목록 조회
*/
List<String> findMeetingsByParticipant(String userId);
/**
* 특정 회의에 특정 사용자가 참석자로 등록되어 있는지 확인
*/
boolean existsParticipant(String meetingId, String userId);
}

View File

@ -0,0 +1,29 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import java.util.List;
/**
* 참석자 저장 인터페이스
*/
public interface ParticipantWriter {
/**
* 회의에 참석자 추가
*/
void saveParticipant(String meetingId, String userId);
/**
* 회의에 참석자 목록 일괄 저장
*/
void saveParticipants(String meetingId, List<String> userIds);
/**
* 회의에서 참석자 삭제
*/
void deleteParticipant(String meetingId, String userId);
/**
* 회의의 모든 참석자 삭제
*/
void deleteAllParticipants(String meetingId);
}

View File

@ -2,71 +2,18 @@ package com.unicorn.hgzero.meeting.infra.cache;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* Redis 캐시 설정
*
* RedisConnectionFactory와 RedisTemplate은 RedisConfig에서 정의됨
*/
@Configuration
@Slf4j
public class CacheConfig {
@Value("${spring.data.redis.host:localhost}")
private String redisHost;
@Value("${spring.data.redis.port:6379}")
private int redisPort;
@Value("${spring.data.redis.password:}")
private String redisPassword;
@Value("${spring.data.redis.database:1}")
private int database;
/**
* Redis 연결 팩토리
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
var factory = new LettuceConnectionFactory(redisHost, redisPort);
factory.setDatabase(database);
// 비밀번호가 설정된 경우에만 적용
if (redisPassword != null && !redisPassword.isEmpty()) {
factory.setPassword(redisPassword);
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}, password: ****", redisHost, redisPort, database);
} else {
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}", redisHost, redisPort, database);
}
return factory;
}
/**
* Redis 템플릿
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// String 직렬화 설정
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
log.info("Redis 템플릿 설정 완료");
return template;
}
/**
* JSON 직렬화용 ObjectMapper
*/

View File

@ -0,0 +1,127 @@
package com.unicorn.hgzero.meeting.infra.config;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.SocketOptions;
import io.lettuce.core.TimeoutOptions;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis 설정
* Standalone 모드로 연결
*
* - ReadFrom 설정 제거: Master-Replica 자동 탐색 비활성화
* - 로컬 개발 환경에서 Kubernetes 내부 DNS 해석 오류 방지
*/
@Configuration
@Slf4j
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Value("${spring.data.redis.password}")
private String redisPassword;
@Value("${spring.data.redis.database:0}")
private int redisDatabase;
/**
* Lettuce 클라이언트 설정
* - Standalone 모드: ReadFrom 설정 제거로 Master-Replica 자동 탐색 비활성화
* - AutoReconnect: 연결 끊김 자동 재연결
* - DisconnectedBehavior.REJECT_COMMANDS: 연결 끊김 명령 거부
*/
@Bean
public LettuceClientConfiguration lettuceClientConfiguration() {
// 소켓 옵션 설정
SocketOptions socketOptions = SocketOptions.builder()
.connectTimeout(Duration.ofSeconds(10))
.keepAlive(true)
.build();
// 타임아웃 옵션 설정
TimeoutOptions timeoutOptions = TimeoutOptions.builder()
.fixedTimeout(Duration.ofSeconds(10))
.build();
// 클라이언트 옵션 설정
ClientOptions clientOptions = ClientOptions.builder()
.socketOptions(socketOptions)
.timeoutOptions(timeoutOptions)
.autoReconnect(true)
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.build();
// Lettuce 클라이언트 설정
// ReadFrom 설정 제거: Standalone 모드로 동작, Master-Replica 자동 탐색 비활성화
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.clientOptions(clientOptions)
.commandTimeout(Duration.ofSeconds(10))
.build();
log.info("Redis Lettuce Client 설정 완료 - Standalone 모드 (Master-Replica 자동 탐색 비활성화)");
return clientConfig;
}
/**
* LettuceConnectionFactory 설정
* Standalone 설정과 Lettuce Client 설정 결합
*/
@Bean
public LettuceConnectionFactory redisConnectionFactory(LettuceClientConfiguration lettuceClientConfiguration) {
// Standalone 설정
RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration();
standaloneConfig.setHostName(redisHost);
standaloneConfig.setPort(redisPort);
standaloneConfig.setPassword(redisPassword);
standaloneConfig.setDatabase(redisDatabase);
// LettuceConnectionFactory 생성
LettuceConnectionFactory factory = new LettuceConnectionFactory(standaloneConfig, lettuceClientConfiguration);
log.info("LettuceConnectionFactory 설정 완료 - Host: {}:{}, Database: {}",
redisHost, redisPort, redisDatabase);
return factory;
}
/**
* RedisTemplate 설정
* String Serializer 사용
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// String Serializer 사용
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(stringSerializer);
template.afterPropertiesSet();
log.info("RedisTemplate 설정 완료");
return template;
}
}

View File

@ -1,6 +1,7 @@
package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.service.MinutesService;
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
@ -202,8 +203,16 @@ public class MinutesController {
// 응답 DTO 생성
MinutesDetailResponse response = convertToMinutesDetailResponse(finalizedMinutes);
// 캐시 무효화
cacheService.evictCacheMinutesDetail(minutesId);
// 캐시 저장 (TTL: 10분)
try {
cacheService.cacheMinutesDetail(minutesId, response);
log.debug("캐시에 확정된 회의록 저장 완료 - minutesId: {}", minutesId);
} catch (Exception cacheEx) {
log.warn("회의록 캐시 저장 실패 - minutesId: {}", minutesId, cacheEx);
// 캐시 저장 실패는 무시하고 진행
}
// 캐시 무효화 (목록 캐시)
cacheService.evictCacheMinutesList(userId);
// 회의록 확정 이벤트 발행
@ -212,6 +221,10 @@ public class MinutesController {
log.info("회의록 확정 성공 - minutesId: {}", minutesId);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (BusinessException e) {
log.error("회의록 확정 비즈니스 오류 - minutesId: {}, error: {}", minutesId, e.getMessage());
return ResponseEntity.status(e.getErrorCode().getHttpStatus())
.body(ApiResponse.errorWithType(e.getMessage()));
} catch (Exception e) {
log.error("회의록 확정 실패 - minutesId: {}", minutesId, e);
return ResponseEntity.badRequest()

View File

@ -58,7 +58,7 @@ public class EventHubPublisher implements EventPublisher {
@Override
public void publishNotificationRequest(NotificationRequestEvent event) {
publishEvent(event, event.getRecipientId(),
publishEvent(event, event.getRecipientEmail(),
EventHubConstants.TOPIC_NOTIFICATION,
EventHubConstants.EVENT_TYPE_NOTIFICATION_REQUEST);
}

View File

@ -40,7 +40,7 @@ public class NoOpEventPublisher implements EventPublisher {
@Override
public void publishNotificationRequest(NotificationRequestEvent event) {
log.debug("[NoOp] Notification request event: {}", event.getRecipientId());
log.debug("[NoOp] Notification request event: {}", event.getRecipientEmail());
}
@Override

View File

@ -3,6 +3,7 @@ package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
import lombok.RequiredArgsConstructor;
@ -24,48 +25,72 @@ import java.util.stream.Collectors;
public class MeetingGateway implements MeetingReader, MeetingWriter {
private final MeetingJpaRepository meetingJpaRepository;
private final ParticipantReader participantReader;
@Override
public Optional<Meeting> findById(String meetingId) {
return meetingJpaRepository.findById(meetingId)
.map(MeetingEntity::toDomain);
.map(this::enrichWithParticipants);
}
@Override
public List<Meeting> findByOrganizerId(String organizerId) {
return meetingJpaRepository.findByOrganizerId(organizerId).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
@Override
public List<Meeting> findByStatus(String status) {
return meetingJpaRepository.findByStatus(status).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
@Override
public List<Meeting> findByOrganizerIdAndStatus(String organizerId, String status) {
return meetingJpaRepository.findByOrganizerIdAndStatus(organizerId, status).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
@Override
public List<Meeting> findByScheduledTimeBetween(LocalDateTime startTime, LocalDateTime endTime) {
return meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
@Override
public List<Meeting> findByTemplateId(String templateId) {
return meetingJpaRepository.findByTemplateId(templateId).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
/**
* Meeting 엔티티를 도메인으로 변환하면서 participants 정보 추가
*/
private Meeting enrichWithParticipants(MeetingEntity entity) {
Meeting meeting = entity.toDomain();
List<String> participants = participantReader.findParticipantsByMeetingId(entity.getMeetingId());
return Meeting.builder()
.meetingId(meeting.getMeetingId())
.title(meeting.getTitle())
.purpose(meeting.getPurpose())
.description(meeting.getDescription())
.scheduledAt(meeting.getScheduledAt())
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.startedAt(meeting.getStartedAt())
.endedAt(meeting.getEndedAt())
.status(meeting.getStatus())
.organizerId(meeting.getOrganizerId())
.participants(participants)
.templateId(meeting.getTemplateId())
.build();
}
@Override
public Meeting save(Meeting meeting) {
MeetingEntity entity = MeetingEntity.fromDomain(meeting);

View File

@ -66,7 +66,24 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
@Override
public Minutes save(Minutes minutes) {
MinutesEntity entity = MinutesEntity.fromDomain(minutes);
// 기존 엔티티 조회 (update) 또는 새로 생성 (insert)
MinutesEntity entity = minutesJpaRepository.findById(minutes.getMinutesId())
.orElse(null);
if (entity != null) {
// 기존 엔티티 업데이트 (연관관계 유지)
if (minutes.getStatus() != null && minutes.getStatus().equals("FINALIZED")) {
entity.finalize(minutes.getFinalizedBy());
}
if (minutes.getVersion() != null) {
entity.updateVersion();
}
// sections는 cascade로 자동 업데이트됨
} else {
// 엔티티 생성
entity = MinutesEntity.fromDomain(minutes);
}
MinutesEntity savedEntity = minutesJpaRepository.save(entity);
return savedEntity.toDomain();
}

View File

@ -67,7 +67,25 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
@Override
public MinutesSection save(MinutesSection section) {
MinutesSectionEntity entity = MinutesSectionEntity.fromDomain(section);
// 기존 엔티티 조회 (update) 또는 새로 생성 (insert)
MinutesSectionEntity entity = sectionJpaRepository.findById(section.getSectionId())
.orElse(null);
if (entity != null) {
// 기존 엔티티 업데이트 (minutes 연관관계 유지)
if (section.getLocked() != null && section.getLocked()) {
entity.lock(section.getLockedBy());
} else if (section.getLocked() != null && !section.getLocked()) {
entity.unlock();
}
if (section.getVerified() != null && section.getVerified()) {
entity.verify();
}
} else {
// 엔티티 생성
entity = MinutesSectionEntity.fromDomain(section);
}
MinutesSectionEntity savedEntity = sectionJpaRepository.save(entity);
return savedEntity.toDomain();
}

View File

@ -0,0 +1,101 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingParticipantJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 참석자 Gateway 구현체
* ParticipantReader, ParticipantWriter 인터페이스 구현
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ParticipantGateway implements ParticipantReader, ParticipantWriter {
private final MeetingParticipantJpaRepository participantRepository;
@Override
@Transactional(readOnly = true)
public List<String> findParticipantsByMeetingId(String meetingId) {
return participantRepository.findByMeetingId(meetingId).stream()
.map(MeetingParticipantEntity::getUserId)
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public List<String> findMeetingsByParticipant(String userId) {
return participantRepository.findByUserId(userId).stream()
.map(MeetingParticipantEntity::getMeetingId)
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public boolean existsParticipant(String meetingId, String userId) {
return participantRepository.existsByMeetingIdAndUserId(meetingId, userId);
}
@Override
@Transactional
public void saveParticipant(String meetingId, String userId) {
if (!participantRepository.existsByMeetingIdAndUserId(meetingId, userId)) {
MeetingParticipantEntity participant = MeetingParticipantEntity.builder()
.meetingId(meetingId)
.userId(userId)
.invitationStatus("PENDING")
.attended(false)
.build();
participantRepository.save(participant);
log.debug("Participant saved: meetingId={}, userId={}", meetingId, userId);
} else {
log.debug("Participant already exists: meetingId={}, userId={}", meetingId, userId);
}
}
@Override
@Transactional
public void saveParticipants(String meetingId, List<String> userIds) {
if (userIds == null || userIds.isEmpty()) {
return;
}
List<MeetingParticipantEntity> participants = userIds.stream()
.filter(userId -> !participantRepository.existsByMeetingIdAndUserId(meetingId, userId))
.map(userId -> MeetingParticipantEntity.builder()
.meetingId(meetingId)
.userId(userId)
.invitationStatus("PENDING")
.attended(false)
.build())
.collect(Collectors.toList());
if (!participants.isEmpty()) {
participantRepository.saveAll(participants);
log.debug("Participants saved: meetingId={}, count={}", meetingId, participants.size());
}
}
@Override
@Transactional
public void deleteParticipant(String meetingId, String userId) {
participantRepository.deleteById(new com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantId(meetingId, userId));
log.debug("Participant deleted: meetingId={}, userId={}", meetingId, userId);
}
@Override
@Transactional
public void deleteAllParticipants(String meetingId) {
participantRepository.deleteByMeetingId(meetingId);
log.debug("All participants deleted: meetingId={}", meetingId);
}
}

View File

@ -9,7 +9,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@ -59,12 +59,16 @@ public class MeetingEntity extends BaseTimeEntity {
@Column(name = "organizer_id", length = 50, nullable = false)
private String organizerId;
@Column(name = "participants", columnDefinition = "TEXT")
private String participants;
@Column(name = "template_id", length = 50)
private String templateId;
/**
* 회의 참석자 목록 (일대다 관계)
*/
@OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<MeetingParticipantEntity> participants = new ArrayList<>();
public Meeting toDomain() {
return Meeting.builder()
.meetingId(this.meetingId)
@ -78,7 +82,11 @@ public class MeetingEntity extends BaseTimeEntity {
.endedAt(this.endedAt)
.status(this.status)
.organizerId(this.organizerId)
.participants(parseParticipants(this.participants))
.participants(this.participants != null
? this.participants.stream()
.map(MeetingParticipantEntity::getUserId)
.collect(Collectors.toList())
: List.of())
.templateId(this.templateId)
.build();
}
@ -96,7 +104,6 @@ public class MeetingEntity extends BaseTimeEntity {
.endedAt(meeting.getEndedAt())
.status(meeting.getStatus())
.organizerId(meeting.getOrganizerId())
.participants(formatParticipants(meeting.getParticipants()))
.templateId(meeting.getTemplateId())
.build();
}
@ -110,18 +117,4 @@ public class MeetingEntity extends BaseTimeEntity {
this.status = "COMPLETED";
this.endedAt = LocalDateTime.now();
}
private static List<String> parseParticipants(String participants) {
if (participants == null || participants.isEmpty()) {
return List.of();
}
return Arrays.asList(participants.split(","));
}
private static String formatParticipants(List<String> participants) {
if (participants == null || participants.isEmpty()) {
return "";
}
return String.join(",", participants);
}
}

View File

@ -0,0 +1,78 @@
package com.unicorn.hgzero.meeting.infra.gateway.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 회의 참석자 Entity
* meeting_id와 user_id를 복합키로 사용하여 다대다 관계 표현
*/
@Entity
@Table(name = "meeting_participants")
@IdClass(MeetingParticipantId.class)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MeetingParticipantEntity extends BaseTimeEntity {
/**
* 회의 ID (복합키)
*/
@Id
@Column(name = "meeting_id", length = 50)
private String meetingId;
/**
* 사용자 ID (이메일) (복합키)
*/
@Id
@Column(name = "user_id", length = 100)
private String userId;
/**
* 초대 상태 (PENDING, ACCEPTED, DECLINED)
*/
@Column(name = "invitation_status", length = 20)
@Builder.Default
private String invitationStatus = "PENDING";
/**
* 참석 여부
*/
@Column(name = "attended")
@Builder.Default
private Boolean attended = false;
/**
* 회의 엔티티와의 관계
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "meeting_id", insertable = false, updatable = false)
private MeetingEntity meeting;
/**
* 초대 수락
*/
public void accept() {
this.invitationStatus = "ACCEPTED";
}
/**
* 초대 거절
*/
public void decline() {
this.invitationStatus = "DECLINED";
}
/**
* 참석 처리
*/
public void markAsAttended() {
this.attended = true;
}
}

View File

@ -0,0 +1,31 @@
package com.unicorn.hgzero.meeting.infra.gateway.entity;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 회의 참석자 복합키
* meeting_id와 user_id를 복합키로 사용
*/
@Getter
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class MeetingParticipantId implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 회의 ID
*/
private String meetingId;
/**
* 사용자 ID (이메일)
*/
private String userId;
}

View File

@ -76,6 +76,11 @@ public class MinutesEntity extends BaseTimeEntity {
.minutesId(minutes.getMinutesId())
.meetingId(minutes.getMeetingId())
.title(minutes.getTitle())
.sections(minutes.getSections() != null
? minutes.getSections().stream()
.map(MinutesSectionEntity::fromDomain)
.collect(Collectors.toList())
: new ArrayList<>())
.status(minutes.getStatus())
.version(minutes.getVersion())
.createdBy(minutes.getCreatedBy())

View File

@ -0,0 +1,45 @@
package com.unicorn.hgzero.meeting.infra.gateway.repository;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 회의 참석자 JPA Repository
*/
@Repository
public interface MeetingParticipantJpaRepository extends JpaRepository<MeetingParticipantEntity, MeetingParticipantId> {
/**
* 회의 ID로 참석자 목록 조회
*/
List<MeetingParticipantEntity> findByMeetingId(String meetingId);
/**
* 사용자 ID로 참여 회의 목록 조회
*/
List<MeetingParticipantEntity> findByUserId(String userId);
/**
* 회의 ID와 초대 상태로 참석자 목록 조회
*/
List<MeetingParticipantEntity> findByMeetingIdAndInvitationStatus(String meetingId, String invitationStatus);
/**
* 회의 ID로 참석자 전체 삭제
*/
@Modifying
@Query("DELETE FROM MeetingParticipantEntity p WHERE p.meetingId = :meetingId")
void deleteByMeetingId(@Param("meetingId") String meetingId);
/**
* 회의 ID와 사용자 ID로 참석자 존재 여부 확인
*/
boolean existsByMeetingIdAndUserId(String meetingId, String userId);
}

View File

@ -0,0 +1,41 @@
-- 회의 참석자 테이블 생성
CREATE TABLE IF NOT EXISTS meeting_participants (
meeting_id VARCHAR(50) NOT NULL,
user_id VARCHAR(100) NOT NULL,
invitation_status VARCHAR(20) DEFAULT 'PENDING',
attended BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (meeting_id, user_id),
CONSTRAINT fk_meeting_participants_meeting
FOREIGN KEY (meeting_id) REFERENCES meetings(meeting_id)
ON DELETE CASCADE
);
-- 기존 meetings 테이블의 participants 데이터를 meeting_participants 테이블로 마이그레이션
INSERT INTO meeting_participants (meeting_id, user_id, invitation_status, attended, created_at, updated_at)
SELECT
m.meeting_id,
TRIM(participant) as user_id,
'PENDING' as invitation_status,
FALSE as attended,
m.created_at,
m.updated_at
FROM meetings m
CROSS JOIN LATERAL unnest(string_to_array(m.participants, ',')) AS participant
WHERE m.participants IS NOT NULL AND m.participants != '';
-- meetings 테이블에서 participants 컬럼 삭제
ALTER TABLE meetings DROP COLUMN IF EXISTS participants;
-- 인덱스 생성
CREATE INDEX idx_meeting_participants_user_id ON meeting_participants(user_id);
CREATE INDEX idx_meeting_participants_invitation_status ON meeting_participants(invitation_status);
CREATE INDEX idx_meeting_participants_meeting_id_status ON meeting_participants(meeting_id, invitation_status);
-- 코멘트 추가
COMMENT ON TABLE meeting_participants IS '회의 참석자 정보';
COMMENT ON COLUMN meeting_participants.meeting_id IS '회의 ID';
COMMENT ON COLUMN meeting_participants.user_id IS '사용자 ID (이메일)';
COMMENT ON COLUMN meeting_participants.invitation_status IS '초대 상태 (PENDING, ACCEPTED, DECLINED)';
COMMENT ON COLUMN meeting_participants.attended IS '참석 여부';

View File

@ -35,17 +35,18 @@
<!-- Mail Configuration -->
<entry key="MAIL_HOST" value="smtp.gmail.com" />
<entry key="MAIL_PORT" value="587" />
<entry key="MAIL_USERNAME" value="" />
<entry key="MAIL_PASSWORD" value="" />
<entry key="MAIL_USERNAME" value="du0928@gmail.com" />
<entry key="MAIL_PASSWORD" value="dwga zzqo ugnp iskv" />
<!-- Azure EventHub Configuration -->
<entry key="AZURE_EVENTHUB_ENABLED" value="true" />
<entry key="EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=" />
<entry key="EVENTHUB_NAME" value="notification-events" />
<entry key="EVENTHUB_NAME" value="hgzero-eventhub-name" />
<entry key="AZURE_EVENTHUB_CONSUMER_GROUP" value="$Default" />
<!-- Azure Storage Configuration -->
<entry key="AZURE_STORAGE_CONNECTION_STRING" value="DefaultEndpointsProtocol=https;AccountName=hgzerostorage;AccountKey=xOQGJhDT6sqOGyTohS7K5dMgGNlryuaQSg8dNCJ40sdGpYok5T5Z88M3xVlk39oeFKiQdGYCihqC+AStBsoBPw==;EndpointSuffix=core.windows.net" />
<entry key="AZURE_STORAGE_CONTAINER_NAME" value="eventhub-checkpoints" />
<entry key="AZURE_STORAGE_CONTAINER_NAME" value="hgzero-checkpoints" />
<!-- Notification Configuration -->
<entry key="NOTIFICATION_FROM_EMAIL" value="noreply@hgzero.com" />

View File

@ -0,0 +1,173 @@
# Notification DB 체크 제약 조건 수정 가이드
## 🚨 문제 상황
```
ERROR: new row for relation "notifications" violates check constraint "notifications_notification_type_check"
```
Event Hub에서 메시지는 정상적으로 수신되지만, 데이터베이스 저장 시 체크 제약 조건 위반으로 실패
## ✅ 해결 방법
### 방법 1: Azure Portal 사용 (권장)
1. **Azure Portal 접속**
- https://portal.azure.com 접속
2. **PostgreSQL 서버 찾기**
- 리소스 검색에서 "4.230.159.143" 또는 "notificationdb" 검색
3. **Query Editor 열기**
- 좌측 메뉴에서 "Query editor (preview)" 선택
- 사용자명: `hgzerouser`
- 비밀번호: `Hi5Jessica!`
- 데이터베이스: `notificationdb`
4. **SQL 실행**
```sql
-- 기존 제약 조건 삭제
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_notification_type_check;
-- 새로운 제약 조건 추가
ALTER TABLE notifications
ADD CONSTRAINT notifications_notification_type_check
CHECK (notification_type IN (
'MEETING_INVITATION',
'TODO_ASSIGNED',
'TODO_REMINDER',
'MEETING_REMINDER',
'MINUTES_UPDATED',
'TODO_COMPLETED'
));
```
5. **확인**
```sql
SELECT constraint_name, check_clause
FROM information_schema.check_constraints
WHERE constraint_name = 'notifications_notification_type_check';
```
---
### 방법 2: DB 관리 도구 사용
#### DBeaver 사용
1. DBeaver 다운로드: https://dbeaver.io/download/
2. 새 연결 생성:
- Host: `4.230.159.143`
- Port: `5432`
- Database: `notificationdb`
- Username: `hgzerouser`
- Password: `Hi5Jessica!`
3. SQL Editor에서 위의 SQL 실행
#### pgAdmin 사용
1. pgAdmin 다운로드: https://www.pgadmin.org/download/
2. 서버 등록 후 Query Tool에서 SQL 실행
---
### 방법 3: psql 직접 설치 (Mac/Linux)
```bash
# Mac (Homebrew)
brew install postgresql@15
# 실행
PGPASSWORD='Hi5Jessica!' psql -h 4.230.159.143 -p 5432 -U hgzerouser -d notificationdb -f notification/fix_constraint.sql
```
---
### 방법 4: Python 스크립트 사용
```bash
# psycopg2 설치
pip install psycopg2-binary
# 스크립트 실행
python3 notification/fix_db_constraint.py
```
---
## 📋 실행할 SQL
생성된 파일: `notification/fix_constraint.sql`
```sql
-- 기존 제약 조건 삭제
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_notification_type_check;
-- 새로운 제약 조건 추가 (Java Enum의 모든 값 포함)
ALTER TABLE notifications
ADD CONSTRAINT notifications_notification_type_check
CHECK (notification_type IN (
'MEETING_INVITATION', -- 회의 초대
'TODO_ASSIGNED', -- Todo 할당
'TODO_REMINDER', -- Todo 리마인더
'MEETING_REMINDER', -- 회의 리마인더
'MINUTES_UPDATED', -- 회의록 수정
'TODO_COMPLETED' -- Todo 완료
));
```
---
## 🔍 검증 방법
### 1. 제약 조건 확인
```sql
SELECT constraint_name, check_clause
FROM information_schema.check_constraints
WHERE constraint_name = 'notifications_notification_type_check';
```
### 2. 서비스 재시작 후 로그 확인
```bash
# notification 서비스 재시작
cd notification
./gradlew bootRun
# 로그 모니터링
tail -f logs/notification-service.log | grep -E "ERROR|이벤트 수신|알림 발송"
```
### 3. 에러가 사라졌는지 확인
이 에러가 더 이상 나타나지 않아야 합니다:
```
ERROR: new row for relation "notifications" violates check constraint "notifications_notification_type_check"
```
---
## 📌 참고
### Java Enum 정의 위치
`notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java:220-227`
```java
public enum NotificationType {
MEETING_INVITATION, // 회의 초대
TODO_ASSIGNED, // Todo 할당
TODO_REMINDER, // Todo 리마인더
MEETING_REMINDER, // 회의 리마인더
MINUTES_UPDATED, // 회의록 수정
TODO_COMPLETED // Todo 완료
}
```
### 현재 상태
- ✅ Event Hub 연결: 정상
- ✅ 메시지 수신: 정상
- ✅ 이메일 발송: 정상
- ❌ DB 저장: 제약 조건 위반으로 실패 ← **해결 필요**
---
## ⚠️ 주의사항
- 프로덕션 환경이라면 백업 먼저 수행
- 테스트 환경에서 먼저 검증 후 프로덕션 적용
- SQL 실행 권한이 필요합니다

View File

@ -0,0 +1 @@
(eval):1: no such file or directory: ./gradlew

View File

@ -26,4 +26,7 @@ dependencies {
// Azure Event Hubs Checkpoint Store
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
// H2 Database (for development)
runtimeOnly 'com.h2database:h2'
}

View File

@ -0,0 +1,39 @@
-- ====================================================================
-- Notification DB 체크 제약 조건 수정 스크립트
-- ====================================================================
-- 목적: notification_type의 체크 제약 조건을 Java Enum과 일치시킴
-- 날짜: 2025-10-26
-- ====================================================================
-- 1. 현재 체크 제약 조건 확인
SELECT constraint_name, check_clause
FROM information_schema.check_constraints
WHERE constraint_name = 'notifications_notification_type_check';
-- 2. 기존 체크 제약 조건 삭제
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_notification_type_check;
-- 3. 새로운 체크 제약 조건 추가 (Java Enum의 모든 값 포함)
ALTER TABLE notifications
ADD CONSTRAINT notifications_notification_type_check
CHECK (notification_type IN (
'MEETING_INVITATION', -- 회의 초대
'TODO_ASSIGNED', -- Todo 할당
'TODO_REMINDER', -- Todo 리마인더
'MEETING_REMINDER', -- 회의 리마인더
'MINUTES_UPDATED', -- 회의록 수정
'TODO_COMPLETED' -- Todo 완료
));
-- 4. 업데이트 결과 확인
SELECT constraint_name, check_clause
FROM information_schema.check_constraints
WHERE constraint_name = 'notifications_notification_type_check';
-- 5. 테이블 정보 확인
\d+ notifications
-- ====================================================================
-- 참고: Java Enum 위치
-- notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java:220-227
-- ====================================================================

129
notification/fix_db_constraint.py Executable file
View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Notification DB 체크 제약 조건 수정 스크립트
실행 psycopg2 설치 필요: pip install psycopg2-binary
"""
import psycopg2
import sys
# 데이터베이스 연결 정보
DB_CONFIG = {
'host': '4.230.159.143',
'port': 5432,
'database': 'notificationdb',
'user': 'hgzerouser',
'password': 'Hi5Jessica!'
}
# 실행할 SQL
SQL_DROP_CONSTRAINT = """
ALTER TABLE notifications
DROP CONSTRAINT IF EXISTS notifications_notification_type_check;
"""
SQL_ADD_CONSTRAINT = """
ALTER TABLE notifications
ADD CONSTRAINT notifications_notification_type_check
CHECK (notification_type IN (
'MEETING_INVITATION',
'TODO_ASSIGNED',
'TODO_REMINDER',
'MEETING_REMINDER',
'MINUTES_UPDATED',
'TODO_COMPLETED'
));
"""
SQL_VERIFY = """
SELECT constraint_name, check_clause
FROM information_schema.check_constraints
WHERE constraint_name = 'notifications_notification_type_check';
"""
def main():
print("=" * 70)
print("Notification DB 체크 제약 조건 수정 시작")
print("=" * 70)
conn = None
cursor = None
try:
# 데이터베이스 연결
print(f"\n1. 데이터베이스 연결 중...")
print(f" Host: {DB_CONFIG['host']}")
print(f" Database: {DB_CONFIG['database']}")
conn = psycopg2.connect(**DB_CONFIG)
cursor = conn.cursor()
print(" ✅ 연결 성공")
# 기존 제약 조건 삭제
print("\n2. 기존 체크 제약 조건 삭제 중...")
cursor.execute(SQL_DROP_CONSTRAINT)
print(" ✅ 삭제 완료")
# 새로운 제약 조건 추가
print("\n3. 새로운 체크 제약 조건 추가 중...")
cursor.execute(SQL_ADD_CONSTRAINT)
print(" ✅ 추가 완료")
# 변경 사항 커밋
conn.commit()
print("\n4. 변경 사항 커밋 완료")
# 결과 확인
print("\n5. 제약 조건 확인 중...")
cursor.execute(SQL_VERIFY)
result = cursor.fetchone()
if result:
print(" ✅ 제약 조건이 정상적으로 생성되었습니다")
print(f" - Constraint Name: {result[0]}")
print(f" - Check Clause: {result[1]}")
else:
print(" ⚠️ 제약 조건을 찾을 수 없습니다")
print("\n" + "=" * 70)
print("✅ 모든 작업이 성공적으로 완료되었습니다!")
print("=" * 70)
print("\n다음 단계:")
print("1. notification 서비스를 재시작하세요")
print("2. 로그에서 에러가 사라졌는지 확인하세요")
print(" tail -f notification/logs/notification-service.log")
return 0
except psycopg2.Error as e:
print(f"\n❌ 데이터베이스 오류 발생: {e}")
if conn:
conn.rollback()
return 1
except Exception as e:
print(f"\n❌ 예기치 않은 오류 발생: {e}")
if conn:
conn.rollback()
return 1
finally:
# 연결 종료
if cursor:
cursor.close()
if conn:
conn.close()
print("\n6. 데이터베이스 연결 종료")
if __name__ == "__main__":
try:
import psycopg2
except ImportError:
print("❌ psycopg2 모듈이 설치되지 않았습니다.")
print("\n다음 명령어로 설치하세요:")
print(" pip install psycopg2-binary")
print("\n또는 conda를 사용하는 경우:")
print(" conda install -c conda-forge psycopg2")
sys.exit(1)
sys.exit(main())

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
package com.unicorn.hgzero.notification.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
/**
* 비동기 처리 설정
*
* 이메일 발송 시간이 오래 걸리는 작업을 비동기로 처리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-26
*/
@Configuration
@EnableAsync
public class AsyncConfig {
/**
* 이메일 발송용 비동기 Executor
*
* - 코어 스레드: 5개
* - 최대 스레드: 10개
* - 용량: 100
* - 스레드 이름: email-async-
*/
@Bean(name = "emailTaskExecutor")
public Executor emailTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("email-async-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
}

View File

@ -4,6 +4,7 @@ import com.azure.storage.blob.BlobContainerAsyncClient;
import com.azure.storage.blob.BlobContainerClientBuilder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -19,6 +20,11 @@ import org.springframework.context.annotation.Configuration;
*/
@Slf4j
@Configuration
@ConditionalOnProperty(
name = "azure.eventhub.enabled",
havingValue = "true",
matchIfMissing = false
)
public class BlobStorageConfig {
@Value("${azure.storage.connection-string}")

View File

@ -7,6 +7,7 @@ import com.azure.messaging.eventhubs.models.EventContext;
import com.azure.storage.blob.BlobContainerAsyncClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -24,6 +25,11 @@ import java.util.function.Consumer;
*/
@Slf4j
@Configuration
@ConditionalOnProperty(
name = "azure.eventhub.enabled",
havingValue = "true",
matchIfMissing = false
)
public class EventHubConfig {
@Value("${azure.eventhub.connection-string}")

View File

@ -218,7 +218,7 @@ public class Notification {
* 알림 유형 Enum
*/
public enum NotificationType {
INVITATION, // 회의 초대
MEETING_INVITATION, // 회의 초대
TODO_ASSIGNED, // Todo 할당
TODO_REMINDER, // Todo 리마인더
MEETING_REMINDER, // 회의 리마인더

View File

@ -176,7 +176,7 @@ public class NotificationSetting {
*/
public boolean isNotificationTypeEnabled(Notification.NotificationType notificationType) {
return switch (notificationType) {
case INVITATION -> invitationEnabled;
case MEETING_INVITATION -> invitationEnabled;
case TODO_ASSIGNED -> todoAssignedEnabled;
case TODO_REMINDER -> todoReminderEnabled;
case MEETING_REMINDER -> meetingReminderEnabled;

View File

@ -3,10 +3,12 @@ package com.unicorn.hgzero.notification.event;
import com.azure.messaging.eventhubs.models.EventContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
import com.unicorn.hgzero.notification.event.event.NotificationRequestEvent;
import com.unicorn.hgzero.notification.event.event.TodoAssignedEvent;
import com.unicorn.hgzero.notification.service.NotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
@ -26,6 +28,11 @@ import java.util.function.Consumer;
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(
name = "azure.eventhub.enabled",
havingValue = "true",
matchIfMissing = false
)
public class EventHandler implements Consumer<EventContext> {
private final NotificationService notificationService;
@ -46,7 +53,7 @@ public class EventHandler implements Consumer<EventContext> {
// 이벤트 속성 추출
Map<String, Object> properties = eventData.getProperties();
String topic = (String) properties.get("topic");
String eventType = (String) properties.get("eventType");
String eventType = (String) properties.get("type");
log.info("이벤트 수신 - Topic: {}, EventType: {}", topic, eventType);
@ -55,9 +62,13 @@ public class EventHandler implements Consumer<EventContext> {
// 토픽 이벤트 유형에 따라 처리
if ("meeting".equals(topic)) {
handleMeetingEvent(eventType, eventBody);
log.info("이벤트 처리 제외");
// handleMeetingEvent(eventType, eventBody);
} else if ("todo".equals(topic)) {
handleTodoEvent(eventType, eventBody);
log.info("이벤트 처리 제외");
// handleTodoEvent(eventType, eventBody);
} else if ("notification".equals(topic)) {
handleNotificationEvent(eventType, eventBody);
} else {
log.warn("알 수 없는 토픽: {}", topic);
}
@ -174,4 +185,47 @@ public class EventHandler implements Consumer<EventContext> {
return null;
});
}
/**
* 알림 관련 이벤트 처리
*
* @param eventType 이벤트 유형
* @param eventBody 이벤트 본문 (JSON)
*/
private void handleNotificationEvent(String eventType, String eventBody) {
try {
switch (eventType) {
case "NOTIFICATION_REQUEST":
NotificationRequestEvent notificationEvent = objectMapper.readValue(
eventBody,
NotificationRequestEvent.class
);
processNotificationRequestEvent(notificationEvent);
break;
default:
log.warn("알 수 없는 알림 이벤트 유형: {}", eventType);
}
} catch (Exception e) {
log.error("알림 이벤트 처리 중 오류 발생 - EventType: {}", eventType, e);
throw new RuntimeException("알림 이벤트 처리 실패", e);
}
}
/**
* 알림 요청 이벤트 처리 (재시도 지원)
*
* @param event 알림 요청 이벤트
*/
private void processNotificationRequestEvent(NotificationRequestEvent event) {
retryTemplate.execute(context -> {
log.info("알림 발송 시작 - Type: {}, EventId: {}",
event.getNotificationType(), event.getEventId());
notificationService.processNotification(event);
log.info("알림 발송 완료 - Type: {}", event.getNotificationType());
return null;
});
}
}

View File

@ -0,0 +1,176 @@
package com.unicorn.hgzero.notification.event.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 알림 발송 요청 이벤트 DTO
*
* 범용 알림 발송 이벤트
* 다양한 서비스에서 알림 발송 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-25
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class NotificationRequestEvent {
/**
* 이벤트 고유 ID (중복 발송 방지용)
*/
private String eventId;
/**
* 발송 채널 (EMAIL, SMS)
*/
private String channel;
/**
* 알림 유형 (INVITATION, TODO_ASSIGNED, REMINDER )
*/
private String notificationType;
/**
* 참조 대상 유형 (MEETING, TODO)
*/
private String referenceType;
/**
* 참조 대상 ID
*/
private String referenceId;
/**
* 관련 엔티티 유형 (이벤트 메시지의 relatedEntityType 매핑용)
*/
private String relatedEntityType;
/**
* 관련 엔티티 ID (이벤트 메시지의 relatedEntityId 매핑용)
*/
private String relatedEntityId;
/**
* 요청자 ID
*/
private String requestedBy;
/**
* 요청자 이름
*/
private String requestedByName;
/**
* 우선순위
*/
private String priority;
/**
* 발신자
*/
private String sender;
/**
* 제목 (subject)
*/
private String subject;
/**
* 예약 발송 시간
*/
private LocalDateTime scheduledTime;
/**
* 이벤트 발생 시간 (배열 형태로 수신)
*/
private java.util.List<Integer> eventTime;
/**
* 알림 제목
*/
private String title;
/**
* 알림 메시지
*/
private String message;
/**
* 수신자 목록 (다중 수신자용)
*/
private List<RecipientInfo> recipients;
/**
* 수신자 ID (단일 수신자용)
*/
private String recipientId;
/**
* 수신자 이름 (단일 수신자용)
*/
private String recipientName;
/**
* 수신자 이메일 (단일 수신자용)
*/
private String recipientEmail;
/**
* 참여자 (단일 수신자용, recipients와 함께 사용 가능)
*/
private String participant;
/**
* 템플릿 ID (이메일 템플릿)
*/
private String templateId;
/**
* 템플릿 데이터 (템플릿 렌더링에 사용)
*/
private Map<String, Object> templateData;
/**
* 추가 메타데이터 (회의 정보 )
*/
private Map<String, Object> metadata;
/**
* 이벤트 발행 일시
*/
private LocalDateTime createdAt;
/**
* 수신자 정보 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class RecipientInfo {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String name;
/**
* 사용자 이메일
*/
private String email;
}
}

View File

@ -5,6 +5,7 @@ import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
@ -22,6 +23,11 @@ import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
@ConditionalOnProperty(
name = "azure.eventhub.enabled",
havingValue = "true",
matchIfMissing = false
)
public class EventProcessorService {
private final EventProcessorClient eventProcessorClient;

View File

@ -8,6 +8,7 @@ import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
/**
@ -28,22 +29,23 @@ public class EmailClient {
private final JavaMailSender mailSender;
/**
* HTML 이메일 발송 (재시도 지원)
* HTML 이메일 발송 (비동기 + 재시도 지원)
*
* 발송 실패 최대 3번까지 재시도
* Exponential Backoff: 초기 5분, 최대 30분, 배수 2.0
* Exponential Backoff: 초기 1초, 최대 10초, 배수 2.0
*
* @param to 수신자 이메일 주소
* @param subject 이메일 제목
* @param htmlContent HTML 이메일 본문
* @throws MessagingException 이메일 발송 실패
*/
@Async("emailTaskExecutor")
@Retryable(
retryFor = {MessagingException.class},
maxAttempts = 3,
backoff = @Backoff(
delay = 300000, // 5분
maxDelay = 1800000, // 30분
delay = 1000, // 1초
maxDelay = 10000, // 10초
multiplier = 2.0
)
)
@ -57,6 +59,7 @@ public class EmailClient {
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true); // true = HTML 모드
// helper.setFrom("du0928@gmail.com");
mailSender.send(message);
@ -69,19 +72,20 @@ public class EmailClient {
}
/**
* 텍스트 이메일 발송 (재시도 지원)
* 텍스트 이메일 발송 (비동기 + 재시도 지원)
*
* @param to 수신자 이메일 주소
* @param subject 이메일 제목
* @param textContent 텍스트 이메일 본문
* @throws MessagingException 이메일 발송 실패
*/
@Async("emailTaskExecutor")
@Retryable(
retryFor = {MessagingException.class},
maxAttempts = 3,
backoff = @Backoff(
delay = 300000,
maxDelay = 1800000,
delay = 1000, // 1초
maxDelay = 10000, // 10초
multiplier = 2.0
)
)

View File

@ -0,0 +1,173 @@
package com.unicorn.hgzero.notification.service;
import jakarta.mail.MessagingException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 이메일 알림 발송 서비스
*
* 템플릿 로드, 렌더링, 이메일 발송 통합 처리
* 재시도 로직 포함
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-25
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailNotifier {
private final EmailTemplateService templateService;
private final EmailClient emailClient;
private final RetryTemplate retryTemplate;
/**
* 이메일 발송 (재시도 지원)
*
* @param recipientEmail 수신자 이메일
* @param subject 이메일 제목
* @param htmlContent 이메일 HTML 본문
* @return 발송 성공 여부
*/
public boolean sendEmail(String recipientEmail, String subject, String htmlContent) {
try {
return retryTemplate.execute(context -> {
try {
log.info("이메일 발송 시도 #{} - Email: {}", context.getRetryCount() + 1, recipientEmail);
emailClient.sendHtmlEmail(recipientEmail, subject, htmlContent);
log.info("이메일 발송 성공 - Email: {}", recipientEmail);
return true;
} catch (MessagingException e) {
log.error("이메일 발송 실패 (재시도 #{}) - Email: {}",
context.getRetryCount() + 1, recipientEmail, e);
if (context.getRetryCount() >= 2) {
log.error("최대 재시도 횟수 초과 - Email: {}", recipientEmail);
return false;
}
throw new RuntimeException("이메일 발송 실패", e);
}
});
} catch (Exception e) {
log.error("이메일 발송 최종 실패 - Email: {}", recipientEmail, e);
return false;
}
}
/**
* 템플릿 기반 이메일 발송
*
* 템플릿을 로드하고 데이터를 렌더링하여 이메일 발송
*
* @param recipientEmail 수신자 이메일
* @param subject 이메일 제목
* @param templateId 템플릿 ID
* @param templateData 템플릿 데이터
* @return 발송 성공 여부
*/
public boolean sendTemplateEmail(
String recipientEmail,
String subject,
String templateId,
Map<String, Object> templateData
) {
try {
log.info("템플릿 이메일 발송 시작 - Template: {}, Email: {}", templateId, recipientEmail);
// 템플릿 로드 렌더링
String htmlContent = loadAndRenderTemplate(templateId, templateData);
// 이메일 발송
return sendEmail(recipientEmail, subject, htmlContent);
} catch (Exception e) {
log.error("템플릿 이메일 발송 실패 - Template: {}, Email: {}", templateId, recipientEmail, e);
return false;
}
}
/**
* 템플릿 로드 렌더링
*
* @param templateId 템플릿 ID
* @param templateData 템플릿 데이터
* @return 렌더링된 HTML 본문
*/
public String loadAndRenderTemplate(String templateId, Map<String, Object> templateData) {
log.info("템플릿 로드 중 - TemplateId: {}", templateId);
String htmlContent;
switch (templateId) {
case "meeting-invitation":
htmlContent = templateService.renderMeetingInvitation(templateData);
break;
case "todo-assigned":
htmlContent = templateService.renderTodoAssigned(templateData);
break;
case "meeting-reminder":
// 향후 구현
log.warn("회의 알림 템플릿은 아직 구현되지 않았습니다");
htmlContent = renderDefaultTemplate(templateData);
break;
case "todo-reminder":
// 향후 구현
log.warn("Todo 알림 템플릿은 아직 구현되지 않았습니다");
htmlContent = renderDefaultTemplate(templateData);
break;
case "minutes-updated":
// 향후 구현
log.warn("회의록 수정 템플릿은 아직 구현되지 않았습니다");
htmlContent = renderDefaultTemplate(templateData);
break;
default:
log.warn("알 수 없는 템플릿 ID: {} - 기본 템플릿 사용", templateId);
htmlContent = renderDefaultTemplate(templateData);
break;
}
log.info("템플릿 렌더링 완료 - TemplateId: {}", templateId);
return htmlContent;
}
/**
* 기본 템플릿 렌더링
*
* @param templateData 템플릿 데이터
* @return 렌더링된 HTML 본문
*/
private String renderDefaultTemplate(Map<String, Object> templateData) {
String title = (String) templateData.getOrDefault("title", "알림");
String message = (String) templateData.getOrDefault("message", "");
return String.format("""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>%s</title>
</head>
<body>
<h2>%s</h2>
<p>%s</p>
</body>
</html>
""", title, title, message);
}
}

View File

@ -0,0 +1,127 @@
package com.unicorn.hgzero.notification.service;
import com.unicorn.hgzero.notification.domain.Notification;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 알림 채널 라우팅 서비스
*
* 사용자 설정에 따라 적절한 알림 채널을 결정
* 이메일 우선, SMS 백업 전략 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-25
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationRouter {
private final EmailNotifier emailNotifier;
/**
* 알림 라우팅 발송
*
* @param eventId 이벤트 ID
* @param recipientEmail 수신자 이메일
* @param recipientName 수신자 이름
* @param subject 이메일 제목
* @param htmlContent 이메일 HTML 본문
* @param preferences 사용자 알림 설정
* @param requestedChannel 요청된 채널
* @return 발송 성공 여부
*/
public boolean routeNotification(
String eventId,
String recipientEmail,
String recipientName,
String subject,
String htmlContent,
NotificationSetting preferences,
Notification.NotificationChannel requestedChannel
) {
log.info("알림 라우팅 시작 - Email: {}, Channel: {}", recipientEmail, requestedChannel);
// 채널 결정
Notification.NotificationChannel selectedChannel = determineChannel(preferences, requestedChannel);
log.info("선택된 채널: {}", selectedChannel);
// 채널별 발송
boolean success = false;
switch (selectedChannel) {
case EMAIL:
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
break;
case SMS:
// SMS 발송 로직 (향후 구현)
log.warn("SMS 채널은 아직 구현되지 않았습니다. 이메일로 대체 발송 시도");
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
break;
case PUSH:
// PUSH 알림 로직 (향후 구현)
log.warn("PUSH 채널은 아직 구현되지 않았습니다. 이메일로 대체 발송 시도");
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
break;
default:
log.error("알 수 없는 채널: {}", selectedChannel);
break;
}
if (success) {
log.info("알림 발송 성공 - Email: {}, Channel: {}", recipientEmail, selectedChannel);
} else {
log.error("알림 발송 실패 - Email: {}, Channel: {}", recipientEmail, selectedChannel);
}
return success;
}
/**
* 채널 결정 로직
*
* 사용자 설정과 요청 채널을 고려하여 최종 채널 결정
* 이메일 우선, SMS 백업 전략
*
* @param preferences 사용자 알림 설정
* @param requestedChannel 요청된 채널
* @return 선택된 채널
*/
private Notification.NotificationChannel determineChannel(
NotificationSetting preferences,
Notification.NotificationChannel requestedChannel
) {
// 사용자 설정이 없으면 요청된 채널 사용
if (preferences == null) {
log.info("사용자 설정 없음 - 요청 채널 사용: {}", requestedChannel);
return requestedChannel != null ? requestedChannel : Notification.NotificationChannel.EMAIL;
}
// 이메일 우선 전략
if (requestedChannel == Notification.NotificationChannel.EMAIL && preferences.isChannelEnabled(Notification.NotificationChannel.EMAIL)) {
return Notification.NotificationChannel.EMAIL;
}
// SMS 백업 전략
if (requestedChannel == Notification.NotificationChannel.SMS && preferences.isChannelEnabled(Notification.NotificationChannel.SMS)) {
return Notification.NotificationChannel.SMS;
}
// PUSH 알림 전략
if (requestedChannel == Notification.NotificationChannel.PUSH && preferences.isChannelEnabled(Notification.NotificationChannel.PUSH)) {
return Notification.NotificationChannel.PUSH;
}
// 기본값: 이메일
log.info("요청된 채널 사용 불가 - 기본 이메일 채널 사용");
return Notification.NotificationChannel.EMAIL;
}
}

View File

@ -4,6 +4,7 @@ import com.unicorn.hgzero.notification.domain.Notification;
import com.unicorn.hgzero.notification.domain.NotificationRecipient;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
import com.unicorn.hgzero.notification.event.event.NotificationRequestEvent;
import com.unicorn.hgzero.notification.event.event.TodoAssignedEvent;
import com.unicorn.hgzero.notification.repository.NotificationRecipientRepository;
import com.unicorn.hgzero.notification.repository.NotificationRepository;
@ -41,6 +42,8 @@ public class NotificationService {
private final NotificationSettingRepository settingRepository;
private final EmailTemplateService templateService;
private final EmailClient emailClient;
private final NotificationRouter notificationRouter;
private final EmailNotifier emailNotifier;
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
@ -64,7 +67,7 @@ public class NotificationService {
.eventId(event.getEventId())
.referenceId(event.getMeetingId())
.referenceType(Notification.ReferenceType.MEETING)
.notificationType(Notification.NotificationType.INVITATION)
.notificationType(Notification.NotificationType.MEETING_INVITATION)
.title("회의 초대: " + event.getTitle())
.message(event.getDescription())
.status(Notification.NotificationStatus.PROCESSING)
@ -80,7 +83,7 @@ public class NotificationService {
for (MeetingCreatedEvent.ParticipantInfo participant : event.getParticipants()) {
try {
// 3-1. 알림 설정 확인
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.INVITATION)) {
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.MEETING_INVITATION)) {
log.info("알림 설정에 의해 발송 제외 - UserId: {}", participant.getUserId());
continue;
}
@ -251,6 +254,254 @@ public class NotificationService {
log.info("Todo 할당 알림 처리 완료");
}
/**
* 범용 알림 발송 처리
*
* NotificationRequest 이벤트를 처리하여 알림 발송
* 중복 방지, 사용자 설정 확인, 채널 라우팅 포함
*
* @param event 알림 발송 요청 이벤트
*/
public void processNotification(NotificationRequestEvent event) {
// eventId가 없으면 UUID 생성
String eventId = event.getEventId();
if (eventId == null || eventId.isBlank()) {
eventId = java.util.UUID.randomUUID().toString();
log.info("EventId가 없어 생성함 - EventId: {}", eventId);
}
log.info("알림 발송 처리 시작 - EventId: {}, Type: {}",
eventId, event.getNotificationType());
// 1. 중복 발송 방지 체크
if (notificationRepository.existsByEventId(eventId)) {
log.warn("이미 처리된 이벤트 - EventId: {}", eventId);
return;
}
// 2. 알림 유형 참조 유형 변환
Notification.NotificationType notificationType = parseNotificationType(event.getNotificationType());
// referenceType 또는 relatedEntityType 하나를 사용
String referenceTypeStr = event.getReferenceType() != null
? event.getReferenceType()
: event.getRelatedEntityType();
Notification.ReferenceType referenceType = parseReferenceType(referenceTypeStr);
// referenceId 또는 relatedEntityId 하나를 사용
String referenceIdStr = event.getReferenceId() != null
? event.getReferenceId()
: event.getRelatedEntityId();
Notification.NotificationChannel channel = parseChannel(event.getChannel());
// 3. 알림 엔티티 생성
Notification notification = Notification.builder()
.eventId(eventId)
.referenceId(referenceIdStr)
.referenceType(referenceType)
.notificationType(notificationType)
.title(event.getTitle())
.message(event.getMessage())
.status(Notification.NotificationStatus.PROCESSING)
.channel(channel)
.build();
notificationRepository.save(notification);
// 4. 수신자 리스트 구성 (단일 수신자 또는 다중 수신자)
java.util.List<NotificationRequestEvent.RecipientInfo> recipientList = new java.util.ArrayList<>();
// 4-1. 단일 수신자 필드가 있으면 추가
if (event.getRecipientEmail() != null && !event.getRecipientEmail().isBlank()) {
NotificationRequestEvent.RecipientInfo singleRecipient = NotificationRequestEvent.RecipientInfo.builder()
.userId(event.getRecipientId())
.name(event.getRecipientName())
.email(event.getRecipientEmail())
.build();
recipientList.add(singleRecipient);
log.info("단일 수신자 처리 - Email: {}", event.getRecipientEmail());
}
// 4-2. recipients 리스트가 있으면 추가
if (event.getRecipients() != null && !event.getRecipients().isEmpty()) {
recipientList.addAll(event.getRecipients());
log.info("다중 수신자 처리 - Count: {}", event.getRecipients().size());
}
// 4-3. 수신자가 없으면 경고 종료
if (recipientList.isEmpty()) {
log.warn("수신자가 없습니다 - EventId: {}", event.getEventId());
notification.updateStatus(Notification.NotificationStatus.FAILED);
notificationRepository.save(notification);
return;
}
// 5. 수신자에게 알림 발송
int successCount = 0;
int failureCount = 0;
for (NotificationRequestEvent.RecipientInfo recipient : recipientList) {
try {
// 5-1. 사용자 알림 설정 조회
String userId = recipient.getUserId();
Optional<NotificationSetting> preferences = Optional.empty();
if (userId != null && !userId.isBlank()) {
preferences = settingRepository.findByUserId(userId);
// 5-2. 알림 설정 확인 (userId가 있는 경우만)
if (!canSendNotification(userId, notificationType)) {
log.info("알림 설정에 의해 발송 제외 - UserId: {}", userId);
continue;
}
}
// 5-3. 수신자 엔티티 생성
NotificationRecipient recipientEntity = NotificationRecipient.builder()
.recipientUserId(userId)
.recipientName(recipient.getName())
.recipientEmail(recipient.getEmail())
.status(NotificationRecipient.RecipientStatus.PENDING)
.build();
notification.addRecipient(recipientEntity);
// 5-4. 템플릿 렌더링 (템플릿 ID가 있는 경우)
String htmlContent;
if (event.getTemplateId() != null && event.getTemplateData() != null) {
htmlContent = emailNotifier.loadAndRenderTemplate(
event.getTemplateId(),
event.getTemplateData()
);
} else {
// 기본 메시지 사용
htmlContent = createDefaultHtmlContent(event.getTitle(), event.getMessage());
}
// 5-5. 채널 라우팅 발송
boolean sent = notificationRouter.routeNotification(
eventId,
recipient.getEmail(),
recipient.getName(),
event.getTitle(),
htmlContent,
preferences.orElse(null),
channel
);
// 5-6. 발송 결과 처리
if (sent) {
recipientEntity.markAsSent();
notification.incrementSentCount();
successCount++;
log.info("알림 발송 성공 - Email: {}", recipient.getEmail());
} else {
recipientEntity.markAsFailed("발송 실패");
notification.incrementFailedCount();
failureCount++;
log.error("알림 발송 실패 - Email: {}", recipient.getEmail());
}
} catch (Exception e) {
// 5-7. 예외 처리
NotificationRecipient recipientEntity = notification.getRecipients().stream()
.filter(r -> recipient.getEmail().equals(r.getRecipientEmail()))
.findFirst()
.orElse(null);
if (recipientEntity != null) {
recipientEntity.markAsFailed(e.getMessage());
notification.incrementFailedCount();
}
failureCount++;
log.error("알림 발송 중 오류 발생 - Email: {}", recipient.getEmail(), e);
}
}
// 5. 알림 상태 업데이트
if (successCount > 0 && failureCount == 0) {
notification.updateStatus(Notification.NotificationStatus.SENT);
} else if (successCount > 0 && failureCount > 0) {
notification.updateStatus(Notification.NotificationStatus.PARTIAL);
} else {
notification.updateStatus(Notification.NotificationStatus.FAILED);
}
notificationRepository.save(notification);
log.info("알림 발송 처리 완료 - 성공: {}, 실패: {}", successCount, failureCount);
}
/**
* 알림 유형 문자열을 Enum으로 변환
*
* @param typeString 알림 유형 문자열
* @return 알림 유형 Enum
*/
private Notification.NotificationType parseNotificationType(String typeString) {
try {
return Notification.NotificationType.valueOf(typeString);
} catch (Exception e) {
log.warn("알 수 없는 알림 유형: {} - MEETING_INVITATION으로 기본 설정", typeString);
return Notification.NotificationType.MEETING_INVITATION;
}
}
/**
* 참조 유형 문자열을 Enum으로 변환
*
* @param typeString 참조 유형 문자열
* @return 참조 유형 Enum
*/
private Notification.ReferenceType parseReferenceType(String typeString) {
try {
return Notification.ReferenceType.valueOf(typeString);
} catch (Exception e) {
log.warn("알 수 없는 참조 유형: {} - MEETING으로 기본 설정", typeString);
return Notification.ReferenceType.MEETING;
}
}
/**
* 채널 문자열을 Enum으로 변환
*
* @param channelString 채널 문자열
* @return 채널 Enum
*/
private Notification.NotificationChannel parseChannel(String channelString) {
try {
return Notification.NotificationChannel.valueOf(channelString);
} catch (Exception e) {
log.warn("알 수 없는 채널: {} - EMAIL로 기본 설정", channelString);
return Notification.NotificationChannel.EMAIL;
}
}
/**
* 기본 HTML 콘텐츠 생성
*
* @param title 제목
* @param message 메시지
* @return HTML 본문
*/
private String createDefaultHtmlContent(String title, String message) {
return String.format("""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>%s</title>
</head>
<body>
<h2>%s</h2>
<p>%s</p>
</body>
</html>
""", title, title, message);
}
/**
* 알림 발송 가능 여부 확인
*
@ -265,7 +516,7 @@ public class NotificationService {
// 설정이 없으면 기본값으로 발송 허용 (이메일, 초대/할당 알림만)
if (settingOpt.isEmpty()) {
return notificationType == Notification.NotificationType.INVITATION
return notificationType == Notification.NotificationType.MEETING_INVITATION
|| notificationType == Notification.NotificationType.TODO_ASSIGNED;
}

View File

@ -24,7 +24,7 @@ spring:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
ddl-auto: ${JPA_DDL_AUTO:none}
# Redis Configuration
data:
@ -49,14 +49,15 @@ spring:
password: ${MAIL_PASSWORD:}
properties:
mail:
debug: true
smtp:
auth: true
starttls:
enable: true
required: true
connectiontimeout: 5000
timeout: 5000
writetimeout: 5000
connectiontimeout: 3000
timeout: 3000
writetimeout: 3000
# Thymeleaf Configuration
thymeleaf:
@ -83,6 +84,7 @@ cors:
# Azure Event Hubs Configuration
azure:
eventhub:
enabled: ${AZURE_EVENTHUB_ENABLED:false}
connection-string: ${EVENTHUB_CONNECTION_STRING:}
name: ${EVENTHUB_NAME:notification-events}
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default}
@ -90,16 +92,17 @@ azure:
# Azure Blob Storage Configuration (for Event Hub Checkpoint)
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:hgzero-checkpoints}
# Notification Configuration
notification:
from-email: ${NOTIFICATION_FROM_EMAIL:noreply@hgzero.com}
from-name: ${NOTIFICATION_FROM_NAME:HGZero}
retry:
max-attempts: ${NOTIFICATION_RETRY_MAX_ATTEMPTS:3}
initial-interval: ${NOTIFICATION_RETRY_INITIAL_INTERVAL:1000}
multiplier: ${NOTIFICATION_RETRY_MULTIPLIER:2.0}
# retry 설정은 EmailClient.java의 @Retryable 어노테이션에서 직접 관리됨
# retry:
# max-attempts: 3
# initial-interval: 1000 # 1초
# multiplier: 2.0
# Actuator Configuration
management:

View File

@ -3,8 +3,54 @@
<ExternalSystemSettings>
<option name="env">
<map>
<!-- Database Configuration -->x
<entry key="DB_KIND" value="postgresql" />
<entry key="DB_HOST" value="20.214.121.121" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_NAME" value="userdb" />
<entry key="DB_USERNAME" value="hgzerouser" />
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
<entry key="JWT_SECRET" value="my-super-secret-jwt-key-for-hgzero-meeting-service-2024" />
<!-- JPA Configuration -->
<entry key="SHOW_SQL" value="true" />
<entry key="JPA_DDL_AUTO" value="update" />
<!-- Redis Configuration -->
<entry key="REDIS_HOST" value="20.249.177.114" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="1" />
<!-- Server Configuration -->
<entry key="SERVER_PORT" value="8081" />
<!-- Redis Configuration -->
<entry key="REDIS_HOST" value="20.249.177.114" />
<entry key="REDIS_PORT" value="6379" />
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
<entry key="REDIS_DATABASE" value="1" />
<!-- Spring Profile -->
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
<!-- Azure EventHub Configuration -->
<entry key="EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=" />
<entry key="EVENTHUB_NAME" value="hgzero-eventhub-name" />
<entry key="EVENTHUB_CONSUMER_GROUP" value="$Default" />
<!-- Logging Configuration -->
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_WEB" value="INFO" />
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
<entry key="LOG_LEVEL_WEBSOCKET" value="DEBUG" />
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
<entry key="LOG_FILE" value="logs/user-service.log" />
<entry key="LOG_MAX_FILE_SIZE" value="10MB" />
<entry key="LOG_MAX_HISTORY" value="7" />
<entry key="LOG_TOTAL_SIZE_CAP" value="100MB" />
</map>
</option>
<option name="executionName" />

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -0,0 +1,91 @@
package com.unicorn.hgzero.user.config;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.ReadFrom;
import io.lettuce.core.SocketOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis 설정
* Lettuce 클라이언트를 사용하여 Redis 연결 템플릿 구성
*/
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host:localhost}")
private String host;
@Value("${spring.data.redis.port:6379}")
private int port;
@Value("${spring.data.redis.password:}")
private String password;
@Value("${spring.data.redis.database:0}")
private int database;
/**
* Redis 연결 팩토리 설정
* ReadFrom.MASTER로 설정하여 마스터에서만 읽기/쓰기 수행
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
// Redis Standalone 설정
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
redisStandaloneConfiguration.setHostName(host);
redisStandaloneConfiguration.setPort(port);
redisStandaloneConfiguration.setDatabase(database);
// 비밀번호가 있는 경우에만 설정
if (password != null && !password.isEmpty()) {
redisStandaloneConfiguration.setPassword(password);
}
// Lettuce 클라이언트 설정
SocketOptions socketOptions = SocketOptions.builder()
.connectTimeout(Duration.ofSeconds(10))
.build();
ClientOptions clientOptions = ClientOptions.builder()
.socketOptions(socketOptions)
.build();
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.clientOptions(clientOptions)
.commandTimeout(Duration.ofSeconds(5))
.readFrom(ReadFrom.MASTER) // 마스터에서만 읽기/쓰기
.build();
return new LettuceConnectionFactory(redisStandaloneConfiguration, clientConfig);
}
/**
* RedisTemplate 설정
* String 키와 값을 사용하는 템플릿 구성
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// String 직렬화 설정
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(stringSerializer);
template.afterPropertiesSet();
return template;
}
}

View File

@ -12,6 +12,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@ -69,7 +71,8 @@ public class SecurityConfig {
// 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers",
"X-User-Id", "X-User-Name", "X-User-Email"
));
// 자격 증명 허용
@ -82,4 +85,24 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", configuration);
return source;
}
/**
* HttpFirewall 설정
* 한글을 포함한 모든 문자를 헤더 값으로 허용
*/
@Bean
public HttpFirewall allowUrlEncodedSlashHttpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
// 한글을 포함한 모든 문자를 허용하도록 설정
firewall.setAllowedHeaderValues(header -> true);
// URL 인코딩된 슬래시 허용
firewall.setAllowUrlEncodedSlash(true);
// 세미콜론 허용
firewall.setAllowSemicolon(true);
return firewall;
}
}

View File

@ -82,6 +82,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return path.startsWith("/actuator") ||
path.startsWith("/swagger-ui") ||
path.startsWith("/v3/api-docs") ||
path.equals("/health");
path.equals("/health") ||
path.equals("/api/v1/auth/login");
}
}

View File

@ -0,0 +1,209 @@
package com.unicorn.hgzero.user.config.ldap;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.ldap.core.AttributesMapper;
import org.springframework.ldap.core.LdapTemplate;
import org.springframework.ldap.filter.EqualsFilter;
import org.springframework.ldap.filter.Filter;
import org.springframework.ldap.support.LdapUtils;
import org.springframework.stereotype.Component;
import javax.naming.NamingException;
import javax.naming.directory.Attributes;
import java.util.List;
/**
* LDAP 인증 사용자 정보 조회 클래스
*
* LDAP 서버와의 인증 처리 사용자 속성 조회 기능 제공
* - LDAPS (포트 636) 사용
* - 타임아웃: 5초
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LdapAuthenticator {
private final LdapTemplate ldapTemplate;
@Value("${spring.ldap.user-dn-pattern:uid={0},ou=people}")
private String userDnPattern;
@Value("${spring.ldap.user-search-base:ou=people}")
private String userSearchBase;
@Value("${spring.profiles.active:dev}")
private String activeProfile;
/**
* LDAP 인증 사용자 정보 조회
*
* @param username 사용자 ID
* @param password 비밀번호
* @return LDAP 사용자 정보
* @throws BusinessException 인증 실패
*/
public LdapUserDetails validateCredentials(String username, String password) {
log.debug("LDAP 인증 시도: username={}, profile={}", username, activeProfile);
// 개발 환경에서는 LDAP 인증 건너뛰기
if ("dev".equals(activeProfile)) {
log.info("개발 환경 - LDAP 인증 건너뛰기: username={}", username);
return createDefaultUserDetails(username);
}
try {
// 1. LDAP bind (인증)
boolean authenticated = authenticate(username, password);
if (!authenticated) {
log.warn("LDAP 인증 실패: username={}", username);
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED);
}
log.info("LDAP 인증 성공: username={}", username);
// 2. 사용자 정보 조회
LdapUserDetails userDetails = searchUser(username);
log.debug("LDAP 사용자 정보 조회 완료: username={}, email={}", username, userDetails.getEmail());
return userDetails;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("LDAP 인증 중 오류 발생: username={}, error={}", username, e.getMessage(), e);
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED, "LDAP 인증 중 오류가 발생했습니다.");
}
}
/**
* LDAP 인증 수행
*
* @param username 사용자 ID
* @param password 비밀번호
* @return 인증 성공 여부
*/
private boolean authenticate(String username, String password) {
try {
// EqualsFilter를 사용하여 uid로 검색
Filter filter = new EqualsFilter("uid", username);
// LDAP 인증 수행
// Protocol: LDAPS (636), Timeout: 5s (LdapTemplate 설정에서 관리)
boolean authenticated = ldapTemplate.authenticate(
userSearchBase,
filter.encode(),
password
);
return authenticated;
} catch (Exception e) {
log.error("LDAP bind 실패: username={}, error={}", username, e.getMessage());
return false;
}
}
/**
* LDAP에서 사용자 정보 조회
*
* 조회 속성:
* - cn (Common Name): 사용자 이름
* - mail: 이메일
* - department: 부서
* - title: 직급
*
* @param username 사용자 ID
* @return LDAP 사용자 정보
*/
private LdapUserDetails searchUser(String username) {
log.debug("LDAP 사용자 정보 조회 시작: username={}", username);
try {
// uid로 사용자 검색
Filter filter = new EqualsFilter("uid", username);
// 사용자 속성 조회
List<LdapUserDetails> userList = ldapTemplate.search(
userSearchBase,
filter.encode(),
new UserAttributesMapper(username)
);
if (userList.isEmpty()) {
log.warn("LDAP에서 사용자를 찾을 수 없음: username={}", username);
// 사용자를 찾을 없어도 기본 정보로 생성
return createDefaultUserDetails(username);
}
return userList.get(0);
} catch (Exception e) {
log.error("LDAP 사용자 정보 조회 실패: username={}, error={}", username, e.getMessage(), e);
// 오류 발생 기본 정보로 생성
return createDefaultUserDetails(username);
}
}
/**
* 기본 사용자 정보 생성 (LDAP 조회 실패 )
*
* @param username 사용자 ID
* @return 기본 사용자 정보
*/
private LdapUserDetails createDefaultUserDetails(String username) {
return LdapUserDetails.builder()
.userId(username)
.username(username)
.email(username + "@example.com")
.department("미지정")
.title("미지정")
.build();
}
/**
* LDAP 속성을 LdapUserDetails로 매핑하는 Mapper
*/
private static class UserAttributesMapper implements AttributesMapper<LdapUserDetails> {
private final String userId;
public UserAttributesMapper(String userId) {
this.userId = userId;
}
@Override
public LdapUserDetails mapFromAttributes(Attributes attributes) throws NamingException {
return LdapUserDetails.builder()
.userId(userId)
.username(getAttributeValue(attributes, "cn", userId))
.email(getAttributeValue(attributes, "mail", userId + "@example.com"))
.department(getAttributeValue(attributes, "department", "미지정"))
.title(getAttributeValue(attributes, "title", "미지정"))
.build();
}
/**
* 속성 조회 (속성이 없으면 기본값 반환)
*
* @param attributes 속성 목록
* @param attributeName 속성 이름
* @param defaultValue 기본값
* @return 속성
*/
private String getAttributeValue(Attributes attributes, String attributeName, String defaultValue) {
try {
if (attributes.get(attributeName) != null) {
Object value = attributes.get(attributeName).get();
return value != null ? value.toString() : defaultValue;
}
} catch (NamingException e) {
// 속성이 없거나 오류 발생 기본값 반환
}
return defaultValue;
}
}
}

View File

@ -0,0 +1,43 @@
package com.unicorn.hgzero.user.config.ldap;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* LDAP에서 조회한 사용자 정보 DTO
*
* LDAP 인증 성공 사용자 속성 정보를 담는 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LdapUserDetails {
/**
* 사용자 ID (uid)
*/
private String userId;
/**
* 사용자 이름 (cn - Common Name)
*/
private String username;
/**
* 이메일 (mail)
*/
private String email;
/**
* 부서 (department)
*/
private String department;
/**
* 직급 (title)
*/
private String title;
}

View File

@ -138,4 +138,15 @@ public class UserEntity extends BaseTimeEntity {
this.failedLoginAttempts = 0;
this.lastLoginAt = LocalDateTime.now();
}
/**
* LDAP 정보로 사용자 정보 업데이트
*
* @param username 사용자 이름
* @param email 이메일
*/
public void updateFromLdap(String username, String email) {
this.username = username;
this.email = email;
}
}

View File

@ -3,6 +3,8 @@ package com.unicorn.hgzero.user.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.common.security.JwtTokenProvider;
import com.unicorn.hgzero.user.config.ldap.LdapAuthenticator;
import com.unicorn.hgzero.user.config.ldap.LdapUserDetails;
import com.unicorn.hgzero.user.domain.User;
import com.unicorn.hgzero.user.dto.*;
import com.unicorn.hgzero.user.repository.entity.UserEntity;
@ -37,7 +39,7 @@ import io.jsonwebtoken.security.Keys;
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final LdapTemplate ldapTemplate;
private final LdapAuthenticator ldapAuthenticator;
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate;
@ -64,7 +66,7 @@ public class UserServiceImpl implements UserService {
public LoginResponse login(LoginRequest request) {
log.info("로그인 시도: userId={}", request.getUserId());
// 사용자 조회 또는 생성
// 사용자 조회
UserEntity userEntity = userRepository.findById(request.getUserId())
.orElse(null);
@ -81,10 +83,12 @@ public class UserServiceImpl implements UserService {
}
}
// LDAP 인증
// LDAP 인증 사용자 정보 조회
LdapUserDetails ldapUserDetails;
try {
authenticateWithLdap(request.getUserId(), request.getPassword());
log.info("LDAP 인증 성공: userId={}", request.getUserId());
ldapUserDetails = ldapAuthenticator.validateCredentials(request.getUserId(), request.getPassword());
log.info("LDAP 인증 성공: userId={}, username={}, email={}",
request.getUserId(), ldapUserDetails.getUsername(), ldapUserDetails.getEmail());
} catch (Exception e) {
log.error("LDAP 인증 실패: userId={}, error={}", request.getUserId(), e.getMessage());
@ -99,15 +103,26 @@ public class UserServiceImpl implements UserService {
// 사용자 정보 조회 또는 생성
if (userEntity == null) {
// LDAP에서 추가 정보 조회 (실제 환경에서는 LDAP에서 이메일 등을 가져와야 )
// LDAP에서 조회한 정보로 신규 사용자 등록
log.info("신규 사용자 등록: userId={}, username={}, email={}, department={}, title={}",
ldapUserDetails.getUserId(), ldapUserDetails.getUsername(),
ldapUserDetails.getEmail(), ldapUserDetails.getDepartment(), ldapUserDetails.getTitle());
userEntity = UserEntity.builder()
.userId(request.getUserId())
.username(request.getUserId()) // LDAP에서 가져온 실제 이름 사용
.email(request.getUserId() + "@example.com") // LDAP에서 가져온 실제 이메일 사용
.userId(ldapUserDetails.getUserId())
.username(ldapUserDetails.getUsername())
.email(ldapUserDetails.getEmail())
.authority("USER")
.locked(false)
.failedLoginAttempts(0)
.build();
} else {
// 기존 사용자의 경우 LDAP 정보로 업데이트 (이메일, 이름 등이 변경될 있음)
log.debug("기존 사용자 정보 업데이트: userId={}", request.getUserId());
userEntity.updateFromLdap(
ldapUserDetails.getUsername(),
ldapUserDetails.getEmail()
);
}
// 로그인 성공 기록
@ -251,22 +266,6 @@ public class UserServiceImpl implements UserService {
}
}
/**
* LDAP 인증 수행
*/
private void authenticateWithLdap(String userId, String password) {
// LDAP DN 생성
String userDn = userDnPattern.replace("{0}", userId);
// LDAP 인증
Filter filter = new EqualsFilter("uid", userId);
boolean authenticated = ldapTemplate.authenticate("", filter.encode(), password);
if (!authenticated) {
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED);
}
}
/**
* Access Token 생성
*/

View File

@ -8,7 +8,7 @@ spring:
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:userdb}
username: ${DB_USERNAME:hgzerouser}
password: ${DB_PASSWORD:Hi5Jessica!}
password: ${DB_PASSWORD:}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
@ -27,7 +27,7 @@ spring:
use_sql_comments: true
dialect: org.hibernate.dialect.PostgreSQLDialect
jdbc:
time_zone: UTC
time_zone: Asia/Seoul
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
database: postgresql