diff --git a/ServerlessFunction/build.gradle b/ServerlessFunction/build.gradle index cc5e6a12..34615a92 100644 --- a/ServerlessFunction/build.gradle +++ b/ServerlessFunction/build.gradle @@ -35,6 +35,7 @@ dependencies { implementation 'software.amazon.awssdk:url-connection-client' implementation 'software.amazon.awssdk:ssm' implementation 'software.amazon.awssdk:scheduler' + implementation 'software.amazon.awssdk:sqs' // AWS X-Ray SDK (다운스트림 서비스 추적용) implementation 'com.amazonaws:aws-xray-recorder-sdk-core:2.15.0' diff --git a/ServerlessFunction/buildspec.yml b/ServerlessFunction/buildspec.yml index 0779bea5..49ed2a4f 100644 --- a/ServerlessFunction/buildspec.yml +++ b/ServerlessFunction/buildspec.yml @@ -4,8 +4,6 @@ env: variables: SAM_CLI_TELEMETRY: 0 GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=true -Dorg.gradle.caching=true" - ENVIRONMENT: prod - STACK_NAME: group2-englishstudy-prod phases: install: @@ -25,26 +23,20 @@ phases: build: commands: - - echo "Building SAM application for $ENVIRONMENT..." + - echo "Building SAM application..." - cd $CODEBUILD_SRC_DIR/ServerlessFunction - sam build --parallel --cached - - echo "Build completed" + - echo "Packaging SAM application..." + - sam package --s3-bucket group2-englishstudy-pipeline-artifacts --s3-prefix sam-packages --output-template-file packaged-template.yaml post_build: commands: - - echo "Deploying to $ENVIRONMENT environment..." - - cd $CODEBUILD_SRC_DIR/ServerlessFunction - - | - sam deploy \ - --stack-name $STACK_NAME \ - --s3-bucket group2-englishstudy-pipeline-artifacts \ - --s3-prefix sam-deploy/prod \ - --region ap-northeast-2 \ - --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \ - --no-confirm-changeset \ - --no-fail-on-empty-changeset \ - --parameter-overrides Environment=$ENVIRONMENT - - echo "Deployment completed on $(date)" + - echo "Build completed on $(date)" + +artifacts: + files: + - packaged-template.yaml + base-directory: ServerlessFunction cache: paths: diff --git a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md b/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md deleted file mode 100644 index 4dd03385..00000000 --- a/ServerlessFunction/docs/CATCHMIND_FRONTEND_GUIDE.md +++ /dev/null @@ -1,761 +0,0 @@ -# Catchmind 게임 프론트엔드 연동 가이드 - -## 목차 - -1. [개요](#개요) -2. [아키텍처](#아키텍처) -3. [WebSocket 연결](#websocket-연결) -4. [메시지 구조](#메시지-구조) -5. [게임 흐름](#게임-흐름) -6. [REST API](#rest-api) -7. [타이머 동기화](#타이머-동기화) -8. [게임 자동 종료](#게임-자동-종료) -9. [재접속 처리](#재접속-처리) -10. [에러 처리](#에러-처리) - ---- - -## 개요 - -Catchmind는 실시간 그림 맞추기 게임입니다. WebSocket을 통한 실시간 통신과 REST API를 통한 게임 세션 관리를 지원합니다. - -### 주요 특징 - -- **실시간 통신**: WebSocket 기반 양방향 통신 -- **도메인 분리**: `chat` / `game` 도메인으로 메시지 라우팅 -- **타이머 동기화**: `serverTime` 필드를 통한 클라이언트-서버 시간 동기화 -- **자동 종료**: 게임 시작 7분 후 자동 종료 -- **재접속 지원**: 게임 세션 API를 통한 상태 복원 - ---- - -## 아키텍처 - -``` -┌─────────────┐ WebSocket ┌──────────────────┐ -│ Frontend │◄──────────────────►│ API Gateway WS │ -│ (React) │ └────────┬─────────┘ -│ │ │ -│ │ REST API ┌───────▼─────────┐ -│ │◄───────────────────►│ API Gateway │ -└─────────────┘ │ REST │ - └────────┬────────┘ - │ - ┌─────────────┼─────────────┐ - │ │ │ - ┌─────▼────┐ ┌─────▼────┐ ┌─────▼────┐ - │ WS Msg │ │ Game │ │ Game │ - │ Handler │ │ Handler │ │ Session │ - └──────────┘ └──────────┘ │ Handler │ - └──────────┘ -``` - ---- - -## WebSocket 연결 - -### 연결 URL - -``` -wss://{api-id}.execute-api.{region}.amazonaws.com/dev?roomToken={token} -``` - -### 연결 절차 - -1. REST API로 방 토큰 발급 (`POST /chat/rooms/{roomId}/join`) -2. 토큰으로 WebSocket 연결 -3. 연결 성공 시 자동으로 방에 입장 - -### 연결 예시 (TypeScript) - -```typescript -const connectWebSocket = (roomToken: string): WebSocket => { - const ws = new WebSocket( - `wss://xxx.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken=${roomToken}` - ); - - ws.onopen = () => console.log('WebSocket connected'); - ws.onmessage = (event) => handleMessage(JSON.parse(event.data)); - ws.onerror = (error) => console.error('WebSocket error:', error); - ws.onclose = () => console.log('WebSocket closed'); - - return ws; -}; -``` - ---- - -## 메시지 구조 - -### 공통 메시지 포맷 - -모든 WebSocket 메시지는 다음 필드를 포함합니다: - -```typescript -interface BaseMessage { - domain: 'chat' | 'game'; // 도메인 구분 - messageType: string; // 메시지 타입 - messageId: string; // 고유 메시지 ID - roomId: string; // 방 ID - userId: string; // 발신자 ID (시스템: "SYSTEM") - content?: string; // 메시지 내용 - createdAt: string; // ISO 8601 형식 시간 - timestamp: number; // Unix timestamp (ms) -} -``` - -### 도메인 구분 - -| 도메인 | 설명 | 메시지 타입 | -|--------|--------|-------------------------------------------------------------------------------------------| -| `chat` | 채팅 메시지 | text, image, voice, ai_response | -| `game` | 게임 메시지 | game_start, game_end, round_start, round_end, drawing, correct_answer, score_update, hint | - -### 메시지 라우팅 예시 - -```typescript -const handleMessage = (message: BaseMessage) => { - if (message.domain === 'chat') { - handleChatMessage(message); - } else if (message.domain === 'game') { - handleGameMessage(message); - } -}; -``` - ---- - -## 게임 흐름 - -### 게임 상태 (GameStatus) - -```typescript -type GameStatus = 'NONE' | 'WAITING' | 'PLAYING' | 'ROUND_END' | 'FINISHED'; -``` - -### 전체 흐름 - -``` -[대기] ─── /game 시작 ───► [게임 시작] ─► [라운드 1] ─► [라운드 종료] - │ │ - │ ◄───────────────────┘ - │ (반복) - ▼ - [게임 종료] - │ - ┌────┴────┐ - │ │ - 수동 종료 자동 종료 - (7분 경과) -``` - -### 1. 게임 시작 (game_start) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "game_start", - "messageId": "uuid", - "roomId": "room-123", - "userId": "SYSTEM", - "content": "🎮 게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-1", - "createdAt": "2024-01-20T10:00:00Z", - "timestamp": 1705746000000, - "serverTime": 1705746000000, - "gameStatus": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-1", - "drawerOrder": ["user-1", "user-2", "user-3"], - "roundStartTime": 1705746000000, - "roundDuration": 60 -} -``` - -**프론트엔드 처리:** - -```typescript -const handleGameStart = (message: GameStartMessage) => { - setGameStatus('PLAYING'); - setCurrentRound(message.currentRound); - setTotalRounds(message.totalRounds); - setCurrentDrawer(message.currentDrawerId); - setDrawerOrder(message.drawerOrder); - - // 타이머 동기화 - startTimer(message.roundStartTime, message.roundDuration, message.serverTime); - - // 현재 사용자가 출제자인지 확인 - setIsDrawer(message.currentDrawerId === currentUserId); -}; -``` - -### 2. 그림 데이터 전송/수신 (drawing) - -**전송 (출제자만):** - -```typescript -const sendDrawing = (drawingData: DrawingData) => { - ws.send(JSON.stringify({ - action: 'sendMessage', - messageType: 'drawing', - content: JSON.stringify(drawingData) - })); -}; -``` - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "drawing", - "messageId": "uuid", - "roomId": "room-123", - "userId": "user-1", - "content": "{\"type\":\"path\",\"points\":[...],\"color\":\"#000\",\"width\":3}", - "timestamp": 1705746010000 -} -``` - -### 3. 정답 체크 - -**채팅 메시지로 자동 체크됩니다:** - -```typescript -const sendAnswer = (answer: string) => { - ws.send(JSON.stringify({ - action: 'sendMessage', - messageType: 'text', - content: answer - })); -}; -``` - -### 4. 정답 알림 (correct_answer) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "correct_answer", - "roomId": "room-123", - "userId": "user-2", - "content": "🎉 user-2님이 정답을 맞혔습니다! (+35점)", - "timestamp": 1705746030000, - "serverTime": 1705746030000, - "score": 35, - "elapsedTime": 30000, - "allCorrect": false, - "scores": { - "user-1": 5, - "user-2": 35 - } -} -``` - -### 5. 점수 업데이트 (score_update) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "score_update", - "roomId": "room-123", - "timestamp": 1705746030000, - "scores": { - "user-1": 15, - "user-2": 35, - "user-3": 20 - }, - "lastScorer": "user-2", - "lastScore": 35 -} -``` - -### 6. 라운드 종료 (round_end) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "round_end", - "roomId": "room-123", - "content": "라운드 1 종료! 정답: 사과\n\n라운드 2 시작! 출제자: user-2", - "timestamp": 1705746060000, - "serverTime": 1705746060000, - "data": { - "answer": "사과", - "currentRound": 1, - "totalRounds": 5, - "nextRound": 2, - "nextDrawer": "user-2", - "nextWord": { - "wordId": "word-123", - "korean": "바나나" - }, - "roundStartTime": 1705746060000, - "roundDuration": 60, - "ranking": [ - { "rank": 1, "userId": "user-2", "score": 35 }, - { "rank": 2, "userId": "user-3", "score": 20 }, - { "rank": 3, "userId": "user-1", "score": 15 } - ] - } -} -``` - -**프론트엔드 처리:** - -```typescript -const handleRoundEnd = (message: RoundEndMessage) => { - const { data } = message; - - // 정답 표시 - showAnswer(data.answer); - - // 순위 표시 - showRanking(data.ranking); - - // 다음 라운드 준비 - if (data.nextRound) { - setCurrentRound(data.nextRound); - setCurrentDrawer(data.nextDrawer); - setIsDrawer(data.nextDrawer === currentUserId); - - // 출제자에게만 단어 표시 - if (data.nextDrawer === currentUserId && data.nextWord) { - setCurrentWord(data.nextWord.korean); - } - - // 타이머 재시작 - startTimer(data.roundStartTime, data.roundDuration, message.serverTime); - - // 캔버스 초기화 - clearCanvas(); - } -}; -``` - -### 7. 게임 종료 (game_end) - -**수신 메시지:** - -```json -{ - "domain": "game", - "messageType": "game_end", - "roomId": "room-123", - "content": "🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-3: 95점\n 🥉 user-1: 80점", - "timestamp": 1705746300000, - "reason": "COMPLETED" -} -``` - -**종료 사유 (reason):** -| 값 | 설명 | -|----|------| -| `COMPLETED` | 모든 라운드 완료 | -| `STOPPED` | 수동 종료 | -| `TIME_EXPIRED` | 7분 시간 초과 | -| `NOT_ENOUGH_PLAYERS` | 인원 부족 | - ---- - -## REST API - -### 게임 시작 - -```http -POST /chat/rooms/{roomId}/game/start -Authorization: Bearer {accessToken} -``` - -**Response:** - -```json -{ - "success": true, - "message": "Game started", - "data": { - "gameSessionId": "session-123", - "roomId": "room-123", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-1", - "roundStartTime": 1705746000000, - "serverTime": 1705746000000, - "roundDuration": 60, - "drawerOrder": ["user-1", "user-2", "user-3"], - "currentWord": { - "wordId": "word-1", - "word": "사과" - } - } -} -``` - -> **Note:** `currentWord`는 출제자에게만 포함됩니다. - -### 게임 종료 - -```http -POST /chat/rooms/{roomId}/game/stop -Authorization: Bearer {accessToken} -``` - -### 게임 상태 조회 - -```http -GET /chat/rooms/{roomId}/game/status -Authorization: Bearer {accessToken} -``` - -### 게임 세션 조회 (재접속용) - -```http -GET /games/{gameSessionId} -Authorization: Bearer {accessToken} -``` - -**Response:** - -```json -{ - "success": true, - "message": "Game session retrieved", - "data": { - "gameSessionId": "session-123", - "roomId": "room-123", - "gameType": "catchmind", - "status": "PLAYING", - "currentRound": 3, - "totalRounds": 5, - "currentDrawerId": "user-2", - "roundStartTime": 1705746180000, - "serverTime": 1705746200000, - "roundDuration": 60, - "scores": { - "user-1": 45, - "user-2": 60, - "user-3": 30 - }, - "players": ["user-1", "user-2", "user-3"], - "drawerOrder": ["user-1", "user-2", "user-3"], - "hintUsed": false, - "currentWord": { - "wordId": "word-5", - "word": "바나나" - } - } -} -``` - -> **Note:** `currentWord`는 출제자에게만 포함됩니다. - ---- - -## 타이머 동기화 - -### 문제 - -클라이언트와 서버 시간 차이로 인한 타이머 불일치 - -### 해결책 - -`serverTime` 필드를 사용하여 서버 시간 기준 타이머 계산 - -### 구현 예시 - -```typescript -interface TimerSync { - roundStartTime: number; // 라운드 시작 시간 (서버 기준) - roundDuration: number; // 라운드 지속 시간 (초) - serverTime: number; // 메시지 발송 시점의 서버 시간 -} - -const startTimer = ( - roundStartTime: number, - roundDuration: number, - serverTime: number -) => { - // 서버에서 이미 경과한 시간 계산 - const elapsedOnServer = serverTime - roundStartTime; - - // 남은 시간 계산 (밀리초) - const remainingTime = (roundDuration * 1000) - elapsedOnServer; - - // 음수 방지 - const safeRemainingTime = Math.max(0, remainingTime); - - setRemainingTime(safeRemainingTime); - - // 타이머 시작 - const interval = setInterval(() => { - setRemainingTime((prev) => { - if (prev <= 1000) { - clearInterval(interval); - return 0; - } - return prev - 1000; - }); - }, 1000); - - return () => clearInterval(interval); -}; -``` - -### React Hook 예시 - -```typescript -const useGameTimer = (timerSync: TimerSync | null) => { - const [remainingSeconds, setRemainingSeconds] = useState(0); - - useEffect(() => { - if (!timerSync) return; - - const { roundStartTime, roundDuration, serverTime } = timerSync; - const elapsed = (serverTime - roundStartTime) / 1000; - const remaining = Math.max(0, roundDuration - elapsed); - - setRemainingSeconds(Math.ceil(remaining)); - - const interval = setInterval(() => { - setRemainingSeconds((prev) => Math.max(0, prev - 1)); - }, 1000); - - return () => clearInterval(interval); - }, [timerSync]); - - return remainingSeconds; -}; -``` - ---- - -## 게임 자동 종료 - -### 개요 - -게임 시작 후 7분(420초)이 경과하면 자동으로 종료됩니다. - -### 자동 종료 메시지 - -```json -{ - "domain": "game", - "messageType": "game_end", - "roomId": "room-123", - "userId": "SYSTEM", - "content": "⏰ 시간 초과! 🎮 게임 종료!\n\n📊 최종 순위:\n 🥇 user-2: 120점\n 🥈 user-1: 95점", - "timestamp": 1705746420000, - "reason": "TIME_EXPIRED" -} -``` - -### 프론트엔드 처리 - -```typescript -const handleGameEnd = (message: GameEndMessage) => { - setGameStatus('FINISHED'); - - // 종료 사유에 따른 UI 처리 - if (message.reason === 'TIME_EXPIRED') { - showNotification('시간 초과로 게임이 종료되었습니다.'); - } else if (message.reason === 'STOPPED') { - showNotification('게임이 수동으로 종료되었습니다.'); - } - - // 최종 결과 표시 - showFinalResults(message.content); - - // 캔버스 초기화 - clearCanvas(); -}; -``` - ---- - -## 재접속 처리 - -### 시나리오 - -사용자가 게임 중 연결이 끊어졌다가 다시 접속하는 경우 - -### 처리 절차 - -1. WebSocket 재연결 -2. 게임 세션 API로 현재 상태 조회 -3. UI 상태 복원 -4. 타이머 동기화 - -### 구현 예시 - -```typescript -const handleReconnect = async (roomId: string, gameSessionId: string) => { - // 1. WebSocket 재연결 - const roomToken = await getRoomToken(roomId); - connectWebSocket(roomToken); - - // 2. 게임 세션 조회 - const session = await fetchGameSession(gameSessionId); - - if (session.status === 'PLAYING') { - // 3. UI 상태 복원 - setGameStatus('PLAYING'); - setCurrentRound(session.currentRound); - setScores(session.scores); - setCurrentDrawer(session.currentDrawerId); - setIsDrawer(session.currentDrawerId === currentUserId); - - // 출제자인 경우 단어 설정 - if (session.currentWord) { - setCurrentWord(session.currentWord.word); - } - - // 4. 타이머 동기화 - startTimer( - session.roundStartTime, - session.roundDuration, - session.serverTime - ); - } else if (session.status === 'FINISHED') { - setGameStatus('FINISHED'); - } -}; -``` - ---- - -## 에러 처리 - -### WebSocket 에러 코드 - -| 코드 | 설명 | 처리 방법 | -|------|--------|--------------| -| 1000 | 정상 종료 | - | -| 1001 | 서버 종료 | 재연결 시도 | -| 1006 | 비정상 종료 | 재연결 시도 | -| 4001 | 인증 실패 | 토큰 재발급 후 재연결 | -| 4003 | 권한 없음 | 에러 표시 | - -### REST API 에러 코드 - -| 코드 | 설명 | -|------------|-----------------------| -| `GAME_001` | 게임 시작 실패 | -| `GAME_002` | 게임 중단 실패 | -| `GAME_003` | 진행 중인 게임 없음 | -| `GAME_004` | 이미 게임 진행 중 | -| `GAME_005` | 권한 없음 (게임 시작자만 중단 가능) | -| `GAME_006` | 게임 세션을 찾을 수 없음 | - -### 에러 처리 예시 - -```typescript -const handleError = (error: ApiError) => { - switch (error.code) { - case 'GAME_001': - showNotification('게임을 시작할 수 없습니다. 최소 2명이 필요합니다.'); - break; - case 'GAME_004': - showNotification('이미 게임이 진행 중입니다.'); - break; - case 'GAME_006': - // 게임 세션 만료 - 목록으로 이동 - navigateToRoomList(); - break; - default: - showNotification('오류가 발생했습니다.'); - } -}; -``` - ---- - -## 전체 상태 관리 예시 (React) - -```typescript -interface GameState { - status: GameStatus; - currentRound: number; - totalRounds: number; - currentDrawerId: string | null; - currentWord: string | null; - scores: Record; - isDrawer: boolean; - remainingTime: number; - drawerOrder: string[]; -} - -const initialGameState: GameState = { - status: 'NONE', - currentRound: 0, - totalRounds: 0, - currentDrawerId: null, - currentWord: null, - scores: {}, - isDrawer: false, - remainingTime: 0, - drawerOrder: [], -}; - -const gameReducer = (state: GameState, action: GameAction): GameState => { - switch (action.type) { - case 'GAME_START': - return { - ...state, - status: 'PLAYING', - currentRound: action.payload.currentRound, - totalRounds: action.payload.totalRounds, - currentDrawerId: action.payload.currentDrawerId, - drawerOrder: action.payload.drawerOrder, - isDrawer: action.payload.currentDrawerId === action.payload.currentUserId, - scores: {}, - }; - - case 'ROUND_END': - return { - ...state, - currentRound: action.payload.nextRound, - currentDrawerId: action.payload.nextDrawer, - currentWord: action.payload.isDrawer ? action.payload.nextWord : null, - isDrawer: action.payload.isDrawer, - }; - - case 'SCORE_UPDATE': - return { - ...state, - scores: action.payload.scores, - }; - - case 'GAME_END': - return { - ...initialGameState, - status: 'FINISHED', - scores: state.scores, - }; - - case 'RESET': - return initialGameState; - - default: - return state; - } -}; -``` - ---- - -## 버전 이력 - -| 버전 | 날짜 | 변경 내용 | -|-------|------------|---------------------| -| 1.0.0 | 2024-01-20 | 초기 문서 작성 | -| 1.1.0 | 2024-01-20 | 게임 자동 종료 (7분) 기능 추가 | diff --git a/ServerlessFunction/gradlew b/ServerlessFunction/gradlew index adff685a..fcb6fca1 100755 --- a/ServerlessFunction/gradlew +++ b/ServerlessFunction/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015 the original authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,8 +15,6 @@ # See the License for the specific language governing permissions and # limitations under the License. # -# SPDX-License-Identifier: Apache-2.0 -# ############################################################################## # @@ -57,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -85,8 +83,7 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,6 +111,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -146,7 +144,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -154,7 +152,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -171,6 +169,7 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -202,15 +201,16 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ "$@" # Stop when "xargs" is not available. diff --git a/ServerlessFunction/gradlew.bat b/ServerlessFunction/gradlew.bat index c4bdd3ab..93e3f59f 100644 --- a/ServerlessFunction/gradlew.bat +++ b/ServerlessFunction/gradlew.bat @@ -13,8 +13,6 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -45,11 +43,11 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail @@ -59,21 +57,22 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. goto fail :execute @rem Setup the command line +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java index 05ad609a..a1d2286b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/AwsClients.java @@ -11,6 +11,7 @@ import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sqs.SqsClient; import software.amazon.awssdk.services.ssm.SsmClient; /** @@ -60,7 +61,12 @@ public final class AwsClients { private static final SsmClient SSM_CLIENT = SsmClient.builder() .overrideConfiguration(XRAY_CONFIG) .build(); - + + // SQS + private static final SqsClient SQS_CLIENT = SqsClient.builder() + .overrideConfiguration(XRAY_CONFIG) + .build(); + private AwsClients() { // 인스턴스화 방지 } @@ -104,4 +110,8 @@ public static ComprehendClient comprehend() { public static SsmClient ssm() { return SSM_CLIENT; } + + public static SqsClient sqs() { + return SQS_CLIENT; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java index c59f4930..75c7e1ec 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/config/EnvConfig.java @@ -15,7 +15,18 @@ public final class EnvConfig { private EnvConfig() { // 유틸리티 클래스 - 인스턴스화 방지 } - + + /** + * 선택적 환경 변수를 가져옵니다. + * 환경 변수가 설정되지 않은 경우 null을 반환합니다. + * + * @param name 환경 변수 이름 + * @return 환경 변수 값 또는 null + */ + public static String get(String name) { + return System.getenv(name); + } + /** * 필수 환경 변수를 가져옵니다. * 환경 변수가 설정되지 않았거나 빈 문자열인 경우 IllegalStateException을 발생시킵니다. diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java index 94685020..ad550303 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/common/util/JsonUtil.java @@ -1,5 +1,7 @@ package com.mzc.secondproject.serverless.common.util; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; @@ -10,9 +12,25 @@ * JSON 파싱 관련 공통 유틸리티 */ public class JsonUtil { - + + private static final Gson GSON = new GsonBuilder().create(); + private JsonUtil() { } + + /** + * 객체를 JSON 문자열로 변환 + */ + public static String toJson(Object obj) { + return GSON.toJson(obj); + } + + /** + * JSON 문자열을 객체로 변환 + */ + public static T fromJson(String json, Class clazz) { + return GSON.fromJson(json, clazz); + } // 응답에서 JSON 부분만 추출 public static String extractJson(String response) { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java index b34466ed..bc7e102f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/enums/BadgeType.java @@ -33,14 +33,13 @@ public enum BadgeType { // 특별 MASTER("학습 마스터", "모든 업적을 달성했습니다", "master.png", "ALL_BADGES", 1); - private static final String BASE_URL = "https://group2-englishstudy.s3.ap-northeast-2.amazonaws.com/badges/"; - + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String BASE_URL = getBaseUrl(); private final String name; private final String description; private final String imageFile; private final String category; private final int threshold; - BadgeType(String name, String description, String imageFile, String category, int threshold) { this.name = name; this.description = description; @@ -49,6 +48,11 @@ public enum BadgeType { this.threshold = threshold; } + private static String getBaseUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.ap-northeast-2.amazonaws.com/badges/", bucket); + } + public static BadgeType fromString(String value) { if (value == null) return null; try { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java index 0916a5d5..b7fbe77d 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/badge/service/BadgeService.java @@ -7,6 +7,7 @@ import com.mzc.secondproject.serverless.domain.badge.repository.BadgeRepository; import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategy; import com.mzc.secondproject.serverless.domain.badge.strategy.BadgeConditionStrategyFactory; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; @@ -24,20 +25,23 @@ public class BadgeService { private final BadgeRepository badgeRepository; private final UserStatsRepository userStatsRepository; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public BadgeService() { - this(new BadgeRepository(), new UserStatsRepository()); + this(new BadgeRepository(), new UserStatsRepository(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ - public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository) { + public BadgeService(BadgeRepository badgeRepository, UserStatsRepository userStatsRepository, + NotificationPublisher notificationPublisher) { this.badgeRepository = badgeRepository; this.userStatsRepository = userStatsRepository; + this.notificationPublisher = notificationPublisher; } /** @@ -98,6 +102,15 @@ public List checkAndAwardBadges(String userId, UserStats stats) { badgeRepository.save(badge); newBadges.add(badge); logger.info("Badge awarded: userId={}, badge={}", userId, type.name()); + + // 알림 발행 + notificationPublisher.publishBadgeEarned( + userId, + type.name(), + type.getName(), + type.getDescription(), + badge.getImageUrl() + ); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java index a82c16d2..932335c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/chatting/service/GameService.java @@ -13,6 +13,7 @@ import com.mzc.secondproject.serverless.domain.chatting.repository.ConnectionRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameRoundRepository; import com.mzc.secondproject.serverless.domain.chatting.repository.GameSessionRepository; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.vocabulary.model.Word; import com.mzc.secondproject.serverless.domain.vocabulary.repository.WordRepository; import org.slf4j.Logger; @@ -37,23 +38,25 @@ public class GameService { private final WordRepository wordRepository; private final GameStatsService gameStatsService; private final GameSchedulerClient gameSchedulerClient; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public GameService() { this(new ChatRoomRepository(), new ConnectionRepository(), new GameRoundRepository(), new GameSessionRepository(), - new WordRepository(), new GameStatsService(), new GameSchedulerClient()); + new WordRepository(), new GameStatsService(), new GameSchedulerClient(), + NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository connectionRepository, GameRoundRepository gameRoundRepository, GameSessionRepository gameSessionRepository, WordRepository wordRepository, GameStatsService gameStatsService, - GameSchedulerClient gameSchedulerClient) { + GameSchedulerClient gameSchedulerClient, NotificationPublisher notificationPublisher) { this.chatRoomRepository = chatRoomRepository; this.connectionRepository = connectionRepository; this.gameRoundRepository = gameRoundRepository; @@ -61,6 +64,7 @@ public GameService(ChatRoomRepository chatRoomRepository, ConnectionRepository c this.wordRepository = wordRepository; this.gameStatsService = gameStatsService; this.gameSchedulerClient = gameSchedulerClient; + this.notificationPublisher = notificationPublisher; } /** @@ -508,7 +512,10 @@ private CommandResult finishGame(GameSession session, ChatRoom room, String reas } catch (Exception e) { logger.error("Failed to update game stats: roomId={}, error={}", room.getRoomId(), e.getMessage()); } - + + // 게임 종료 알림 발행 (각 플레이어별) + publishGameEndNotifications(session, room.getRoomId()); + // 최종 점수 정렬 StringBuilder sb = new StringBuilder("🎮 게임 종료!\n\n📊 최종 순위:\n"); if (session.getScores() != null && !session.getScores().isEmpty()) { @@ -698,11 +705,11 @@ private List> buildRankingList(Map scores) if (scores == null || scores.isEmpty()) { return List.of(); } - + List> sorted = scores.entrySet().stream() .sorted(Map.Entry.comparingByValue().reversed()) .toList(); - + List> ranking = new ArrayList<>(); for (int i = 0; i < sorted.size(); i++) { Map entry = new HashMap<>(); @@ -713,7 +720,39 @@ private List> buildRankingList(Map scores) } return ranking; } - + + /** + * 게임 종료 알림 발행 + */ + private void publishGameEndNotifications(GameSession session, String roomId) { + if (session.getScores() == null || session.getScores().isEmpty()) { + return; + } + + List> sorted = session.getScores().entrySet().stream() + .sorted((a, b) -> b.getValue().compareTo(a.getValue())) + .toList(); + + int totalPlayers = sorted.size(); + + for (int i = 0; i < sorted.size(); i++) { + int rank = i + 1; + String userId = sorted.get(i).getKey(); + int score = sorted.get(i).getValue(); + boolean isWinner = rank == 1; + + notificationPublisher.publishGameEnd( + userId, + roomId, + session.getGameSessionId(), + rank, + totalPlayers, + score, + isWinner + ); + } + } + // ========== Result DTOs ========== public record GameStartResult( diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java new file mode 100644 index 00000000..43435bb1 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/config/NewsConfig.java @@ -0,0 +1,83 @@ +package com.mzc.secondproject.serverless.domain.news.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 뉴스 도메인 설정 + * 상수 및 환경변수 관리 + */ +public final class NewsConfig { + + private NewsConfig() { + } + + // ========== Environment Variables ========== + private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); + + // ========== TTS 설정 ========== + /** TTS 텍스트 최대 길이 */ + public static final int TTS_MAX_TEXT_LENGTH = 3000; + + /** TTS 오디오 저장 경로 */ + public static final String TTS_AUDIO_PREFIX = "news/audio/"; + + /** 기본 TTS 음성 */ + public static final String DEFAULT_VOICE = "Joanna"; + + // ========== 페이지네이션 ========== + /** 기본 페이지 크기 */ + public static final int DEFAULT_PAGE_SIZE = 10; + + /** 최대 페이지 크기 */ + public static final int MAX_PAGE_SIZE = 50; + + // ========== 퀴즈 피드백 ========== + public static final String FEEDBACK_PERFECT = "Perfect! You understood the article completely."; + public static final String FEEDBACK_GREAT = "Great job! You have a solid understanding of the article."; + public static final String FEEDBACK_GOOD = "Good effort! Review the highlighted words for better comprehension."; + public static final String FEEDBACK_KEEP_PRACTICING = "Keep practicing! Try reading the article again before retaking the quiz."; + public static final String FEEDBACK_DONT_GIVE_UP = "Don't give up! Focus on vocabulary and main ideas."; + + // ========== Score 기준 ========== + public static final int SCORE_PERFECT = 100; + public static final int SCORE_GREAT_THRESHOLD = 80; + public static final int SCORE_GOOD_THRESHOLD = 60; + public static final int SCORE_KEEP_PRACTICING_THRESHOLD = 40; + + // ========== Getter Methods ========== + public static String bucketName() { + return BUCKET_NAME; + } + + /** + * 점수에 따른 피드백 생성 + */ + public static String getFeedbackByScore(int score) { + if (score == SCORE_PERFECT) { + return FEEDBACK_PERFECT; + } else if (score >= SCORE_GREAT_THRESHOLD) { + return FEEDBACK_GREAT; + } else if (score >= SCORE_GOOD_THRESHOLD) { + return FEEDBACK_GOOD; + } else if (score >= SCORE_KEEP_PRACTICING_THRESHOLD) { + return FEEDBACK_KEEP_PRACTICING; + } else { + return FEEDBACK_DONT_GIVE_UP; + } + } + + /** + * limit 값 파싱 및 유효성 검증 + */ + public static int parseLimit(String limitStr) { + if (limitStr == null) { + return DEFAULT_PAGE_SIZE; + } + try { + int limit = Integer.parseInt(limitStr); + return Math.min(Math.max(limit, 1), MAX_PAGE_SIZE); + } catch (NumberFormatException e) { + return DEFAULT_PAGE_SIZE; + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java index 4c44a58f..f5ca1969 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/constants/NewsKey.java @@ -6,7 +6,7 @@ * 뉴스 도메인 DynamoDB 키 상수 및 빌더 */ public final class NewsKey { - + // Entity Prefixes public static final String NEWS = "NEWS#"; public static final String ARTICLE = "ARTICLE#"; @@ -18,112 +18,124 @@ public final class NewsKey { public static final String BOOKMARK = "BOOKMARK#"; public static final String COMMENT = "COMMENT#"; public static final String STATS = "STATS"; - + // User Suffixes public static final String SUFFIX_NEWS = "#NEWS"; public static final String SUFFIX_NEWS_WORDS = "#NEWS_WORDS"; public static final String SUFFIX_NEWS_COMMENTS = "#NEWS_COMMENTS"; - + private NewsKey() { } - + // === Key Builders === - + /** * 뉴스 기사 PK: NEWS#{date} */ public static String newsPk(String date) { return NEWS + date; } - + /** * 뉴스 기사 SK: ARTICLE#{articleId} */ public static String articleSk(String articleId) { return ARTICLE + articleId; } - + /** * 레벨별 조회 GSI1 PK: LEVEL#{level} */ public static String levelPk(String level) { return LEVEL + level; } - + /** * 카테고리별 조회 GSI2 PK: CATEGORY#{category} */ public static String categoryPk(String category) { return CATEGORY + category; } - + /** * 사용자 뉴스 활동 PK: USER#{userId}#NEWS */ public static String userNewsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS; } - + /** * 읽기 기록 SK: READ#{articleId} */ public static String readSk(String articleId) { return READ + articleId; } - + /** * 퀴즈 결과 SK: QUIZ#{articleId} */ public static String quizSk(String articleId) { return QUIZ + articleId; } - + /** * 단어 수집 SK: WORD#{word}#{articleId} */ public static String wordSk(String word, String articleId) { return WORD + word + "#" + articleId; } - + /** * 북마크 SK: BOOKMARK#{articleId} */ public static String bookmarkSk(String articleId) { return BOOKMARK + articleId; } - + /** * 사용자 수집 단어 GSI1 PK: USER#{userId}#NEWS_WORDS */ public static String userNewsWordsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_WORDS; } - + /** * 댓글 PK: NEWS_COMMENT#{articleId} */ public static String commentPk(String articleId) { return "NEWS_COMMENT#" + articleId; } - + /** * 댓글 SK: COMMENT#{commentId} */ public static String commentSk(String commentId) { return COMMENT + commentId; } - + /** * 사용자 댓글 GSI1 PK: USER#{userId}#NEWS_COMMENTS */ public static String userNewsCommentsPk(String userId) { return DynamoDbKey.USER + userId + SUFFIX_NEWS_COMMENTS; } - + /** * 사용자 뉴스 통계 GSI1 PK: USER_NEWS_STAT#{userId} */ public static String userNewsStatPk(String userId) { return "USER_NEWS_STAT#" + userId; } + + // === Utility Methods === + + /** + * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) + */ + public static String extractDateFromPk(String pk) { + if (pk == null || !pk.startsWith(NEWS)) { + return null; + } + return pk.substring(NEWS.length()); + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java index c9902559..4be72fac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/dto/RawNewsArticle.java @@ -14,7 +14,7 @@ @NoArgsConstructor @AllArgsConstructor public class RawNewsArticle { - + private String title; private String description; private String url; @@ -22,7 +22,7 @@ public class RawNewsArticle { private String source; private String publishedAt; private String content; - + /** * URL 기반 고유 식별자 생성 */ @@ -32,7 +32,7 @@ public String generateId() { } return String.valueOf(url.hashCode()); } - + /** * 유효한 기사인지 검증 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java index 7f88078f..3f5a8bc5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/NewsCategory.java @@ -13,21 +13,21 @@ public enum NewsCategory { WORLD("world", "세계"), CULTURE("culture", "문화"), SCIENCE("science", "과학"); - + private final String code; private final String displayName; - + NewsCategory(String code, String displayName) { this.code = code; this.displayName = displayName; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(cat -> cat.name().equalsIgnoreCase(value) || cat.code.equalsIgnoreCase(value)); } - + public static NewsCategory fromString(String value) { if (value == null) { throw new IllegalArgumentException("NewsCategory value cannot be null"); @@ -37,18 +37,18 @@ public static NewsCategory fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown NewsCategory: " + value)); } - + public static NewsCategory fromStringOrDefault(String value, NewsCategory defaultValue) { if (value == null || !isValid(value)) { return defaultValue; } return fromString(value); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java index 7b95a466..20da38ae 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/enums/QuizType.java @@ -9,23 +9,23 @@ public enum QuizType { COMPREHENSION("comprehension", "독해 질문", 20), WORD_MATCH("word_match", "단어-뜻 매칭", 15), FILL_BLANK("fill_blank", "빈칸 채우기", 30); - + private final String code; private final String displayName; private final int defaultPoints; - + QuizType(String code, String displayName, int defaultPoints) { this.code = code; this.displayName = displayName; this.defaultPoints = defaultPoints; } - + public static boolean isValid(String value) { if (value == null) return false; return Arrays.stream(values()) .anyMatch(type -> type.name().equalsIgnoreCase(value) || type.code.equalsIgnoreCase(value)); } - + public static QuizType fromString(String value) { if (value == null) { throw new IllegalArgumentException("QuizType value cannot be null"); @@ -35,15 +35,15 @@ public static QuizType fromString(String value) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown QuizType: " + value)); } - + public String getCode() { return code; } - + public String getDisplayName() { return displayName; } - + public int getDefaultPoints() { return defaultPoints; } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java index 58197f0e..ef2c05cb 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/exception/NewsErrorCode.java @@ -7,72 +7,72 @@ * 뉴스 기사, 퀴즈, 단어 수집, 댓글 관련 에러 코드를 정의합니다. */ public enum NewsErrorCode implements DomainErrorCode { - + // 인증 관련 에러 UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - + // 뉴스 기사 관련 에러 ARTICLE_NOT_FOUND("ARTICLE_001", "뉴스 기사를 찾을 수 없습니다", 404), INVALID_ARTICLE_DATA("ARTICLE_002", "뉴스 기사 데이터가 유효하지 않습니다", 400), ARTICLE_ALREADY_EXISTS("ARTICLE_003", "이미 존재하는 뉴스 기사입니다", 409), - + // 카테고리/레벨 관련 에러 INVALID_CATEGORY("CATEGORY_001", "유효하지 않은 카테고리입니다", 400), INVALID_LEVEL("LEVEL_001", "유효하지 않은 레벨입니다", 400), - + // 읽기 기록 관련 에러 READ_RECORD_NOT_FOUND("READ_001", "읽기 기록을 찾을 수 없습니다", 404), ALREADY_READ("READ_002", "이미 읽은 기사입니다", 409), - + // 퀴즈 관련 에러 QUIZ_NOT_FOUND("QUIZ_001", "퀴즈를 찾을 수 없습니다", 404), QUIZ_ALREADY_SUBMITTED("QUIZ_002", "이미 제출한 퀴즈입니다", 409), INVALID_QUIZ_ANSWER("QUIZ_003", "유효하지 않은 퀴즈 답변입니다", 400), - + // 단어 수집 관련 에러 WORD_ALREADY_COLLECTED("WORD_001", "이미 수집한 단어입니다", 409), WORD_NOT_COLLECTED("WORD_002", "수집한 단어를 찾을 수 없습니다", 404), - + // 북마크 관련 에러 BOOKMARK_NOT_FOUND("BOOKMARK_001", "북마크를 찾을 수 없습니다", 404), ALREADY_BOOKMARKED("BOOKMARK_002", "이미 북마크한 기사입니다", 409), BOOKMARK_LIMIT_EXCEEDED("BOOKMARK_003", "북마크 한도를 초과했습니다", 400), - + // 댓글 관련 에러 COMMENT_NOT_FOUND("COMMENT_001", "댓글을 찾을 수 없습니다", 404), COMMENT_NOT_OWNER("COMMENT_002", "댓글 작성자만 수정/삭제할 수 있습니다", 403), INVALID_COMMENT_DATA("COMMENT_003", "유효하지 않은 댓글 데이터입니다", 400), - + // 통계 관련 에러 STATS_NOT_FOUND("STATS_001", "통계 정보를 찾을 수 없습니다", 404); - + private static final String DOMAIN = "NEWS"; - + private final String code; private final String message; private final int statusCode; - + NewsErrorCode(String code, String message, int statusCode) { this.code = code; this.message = message; this.statusCode = statusCode; } - + @Override public String getDomain() { return DOMAIN; } - + @Override public String getCode() { return code; } - + @Override public String getMessage() { return message; } - + @Override public int getStatusCode() { return statusCode; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java index 4d17463f..246617c3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsCollectionHandler.java @@ -14,29 +14,29 @@ * EventBridge 스케줄러에 의해 매일 18시에 트리거 */ public class NewsCollectionHandler implements RequestHandler> { - + private static final Logger logger = LoggerFactory.getLogger(NewsCollectionHandler.class); - + private final NewsCollectorService collectorService; - + public NewsCollectionHandler() { this.collectorService = new NewsCollectorService(); } - + public NewsCollectionHandler(NewsCollectorService collectorService) { this.collectorService = collectorService; } - + @Override public Map handleRequest(ScheduledEvent event, Context context) { logger.info("뉴스 수집 Lambda 시작 - requestId: {}", context.getAwsRequestId()); - + try { NewsCollectorService.CollectionResult result = collectorService.collectNews(); - + logger.info("뉴스 수집 완료 - 수집: {}, 저장: {}, 소요: {}ms", result.collectedCount(), result.savedCount(), result.elapsedMs()); - + return Map.of( "statusCode", 200, "message", "News collection completed", @@ -44,10 +44,10 @@ public Map handleRequest(ScheduledEvent event, Context context) "savedCount", result.savedCount(), "elapsedMs", result.elapsedMs() ); - + } catch (Exception e) { logger.error("뉴스 수집 실패", e); - + return Map.of( "statusCode", 500, "message", "News collection failed: " + e.getMessage() diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java index 180bb7cb..421ecf37 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/handler/NewsHandler.java @@ -4,23 +4,23 @@ import com.amazonaws.services.lambda.runtime.RequestHandler; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.router.HandlerRouter; import com.mzc.secondproject.serverless.common.router.Route; import com.mzc.secondproject.serverless.common.util.CognitoUtil; +import com.mzc.secondproject.serverless.common.util.JsonUtil; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; import com.mzc.secondproject.serverless.domain.news.exception.NewsErrorCode; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import com.mzc.secondproject.serverless.domain.news.model.NewsWordCollect; -import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.service.NewsLearningService; import com.mzc.secondproject.serverless.domain.news.service.NewsQueryService; import com.mzc.secondproject.serverless.domain.news.service.NewsQuizService; import com.mzc.secondproject.serverless.domain.news.service.NewsWordService; -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,29 +35,26 @@ public class NewsHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(NewsHandler.class); - private static final int DEFAULT_LIMIT = 10; - private static final int MAX_LIMIT = 50; - private static final Gson gson = new Gson(); private final NewsQueryService queryService; private final NewsLearningService learningService; private final NewsQuizService quizService; private final NewsWordService wordService; private final HandlerRouter router; - + public NewsHandler() { this(new NewsQueryService(), new NewsLearningService(), new NewsQuizService(), new NewsWordService()); } - + public NewsHandler(NewsQueryService queryService, NewsLearningService learningService, - NewsQuizService quizService, NewsWordService wordService) { + NewsQuizService quizService, NewsWordService wordService) { this.queryService = queryService; this.learningService = learningService; this.quizService = quizService; this.wordService = wordService; this.router = initRouter(); } - + private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( Route.get("/news/today", this::getTodayNews), @@ -79,13 +76,13 @@ private HandlerRouter initRouter() { Route.get("/news", this::getNewsList) ); } - + @Override public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent request, Context context) { logger.info("News API 요청: {} {}", request.getHttpMethod(), request.getPath()); return router.route(request); } - + /** * 뉴스 목록 조회 (필터링 지원) * GET /news?level=INTERMEDIATE&category=TECH&limit=10&cursor=xxx @@ -93,14 +90,14 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + String level = params.get("level"); String category = params.get("category"); String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result; - + if (level != null && category != null) { result = queryService.getNewsByLevelAndCategory(level.toUpperCase(), category.toUpperCase(), limit, cursor); } else if (level != null) { @@ -110,10 +107,10 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req } else { result = queryService.getTodayNews(limit, cursor); } - - return buildPaginatedResponse(result); + + return buildPaginatedResponse(result, getUserId(request)); } - + /** * 오늘의 뉴스 조회 * GET /news/today?limit=10&cursor=xxx @@ -121,14 +118,14 @@ private APIGatewayProxyResponseEvent getNewsList(APIGatewayProxyRequestEvent req private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result = queryService.getTodayNews(limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } - + /** * 내 레벨 맞춤 뉴스 추천 * GET /news/recommended?limit=10&cursor=xxx @@ -136,57 +133,100 @@ private APIGatewayProxyResponseEvent getTodayNews(APIGatewayProxyRequestEvent re private APIGatewayProxyResponseEvent getRecommendedNews(APIGatewayProxyRequestEvent request) { Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + // 사용자 레벨 조회 (Cognito 토큰에서) String userLevel = getUserLevel(request); String cursor = params.get("cursor"); int limit = parseLimit(params.get("limit")); - + PaginatedResult result = queryService.getRecommendedNews(userLevel, limit, cursor); - return buildPaginatedResponse(result); + return buildPaginatedResponse(result, getUserId(request)); } - + /** * 뉴스 상세 조회 * GET /news/{articleId} */ private APIGatewayProxyResponseEvent getNewsDetail(APIGatewayProxyRequestEvent request) { String articleId = request.getPathParameters().get("articleId"); - + Optional article = queryService.getArticle(articleId); if (article.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - - return ResponseGenerator.ok("뉴스 조회 성공", article.get()); + + // 로그인한 사용자의 경우 북마크/읽기 상태 추가 + String userId = getUserId(request); + Map response = new HashMap<>(); + response.put("article", article.get()); + + if (userId != null) { + response.put("isBookmarked", learningService.isBookmarked(userId, articleId)); + response.put("isRead", learningService.hasRead(userId, articleId)); + } else { + response.put("isBookmarked", false); + response.put("isRead", false); + } + + return ResponseGenerator.ok("뉴스 조회 성공", response); } - + /** * 페이지네이션 응답 생성 */ private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result) { + return buildPaginatedResponse(result, null); + } + + /** + * 페이지네이션 응답 생성 (북마크 상태 포함) + */ + private APIGatewayProxyResponseEvent buildPaginatedResponse(PaginatedResult result, String userId) { + List> articlesWithStatus = new java.util.ArrayList<>(); + java.util.Set bookmarkedIds = java.util.Collections.emptySet(); + + // 로그인한 사용자의 경우 북마크 상태 조회 + if (userId != null && !result.items().isEmpty()) { + List articleIds = result.items().stream() + .map(NewsArticle::getArticleId) + .toList(); + bookmarkedIds = learningService.getBookmarkedArticleIds(userId, articleIds); + } + + for (NewsArticle article : result.items()) { + Map articleWithStatus = new HashMap<>(); + articleWithStatus.put("articleId", article.getArticleId()); + articleWithStatus.put("title", article.getTitle()); + articleWithStatus.put("summary", article.getSummary()); + articleWithStatus.put("source", article.getSource()); + articleWithStatus.put("publishedAt", article.getPublishedAt()); + articleWithStatus.put("keywords", article.getKeywords()); + articleWithStatus.put("highlightWords", article.getHighlightWords()); + articleWithStatus.put("category", article.getCategory()); + articleWithStatus.put("level", article.getLevel()); + articleWithStatus.put("cefrLevel", article.getCefrLevel()); + articleWithStatus.put("imageUrl", article.getImageUrl()); + articleWithStatus.put("readCount", article.getReadCount()); + articleWithStatus.put("isBookmarked", bookmarkedIds.contains(article.getArticleId())); + articlesWithStatus.add(articleWithStatus); + } + Map response = new HashMap<>(); - response.put("articles", result.items()); + response.put("articles", articlesWithStatus); response.put("nextCursor", result.nextCursor()); response.put("hasMore", result.hasMore()); response.put("count", result.items().size()); - + return ResponseGenerator.ok("뉴스 목록 조회 성공", response); } - + /** * limit 파싱 */ private int parseLimit(String limitStr) { - if (limitStr == null) return DEFAULT_LIMIT; - try { - int limit = Integer.parseInt(limitStr); - return Math.min(Math.max(limit, 1), MAX_LIMIT); - } catch (NumberFormatException e) { - return DEFAULT_LIMIT; - } + return NewsConfig.parseLimit(limitStr); } - + /** * 사용자 레벨 조회 */ @@ -194,7 +234,7 @@ private String getUserLevel(APIGatewayProxyRequestEvent request) { return CognitoUtil.extractClaim(request, "custom:level") .orElse("INTERMEDIATE"); } - + /** * 사용자 ID 추출 */ @@ -202,7 +242,7 @@ private String getUserId(APIGatewayProxyRequestEvent request) { return CognitoUtil.extractClaim(request, "sub") .orElse(null); } - + /** * 뉴스 학습 통계 조회 * GET /news/stats @@ -212,11 +252,11 @@ private APIGatewayProxyResponseEvent getNewsStats(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map stats = learningService.getUserStats(userId); return ResponseGenerator.ok("뉴스 학습 통계 조회 성공", stats); } - + /** * 북마크 목록 조회 * GET /news/bookmarks?limit=10 @@ -226,20 +266,20 @@ private APIGatewayProxyResponseEvent getBookmarks(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); - List bookmarks = learningService.getUserBookmarks(userId, limit); - + List> bookmarks = learningService.getUserBookmarks(userId, limit); + Map response = new HashMap<>(); response.put("bookmarks", bookmarks); response.put("count", bookmarks.size()); - + return ResponseGenerator.ok("북마크 목록 조회 성공", response); } - + /** * 뉴스 읽기 완료 기록 * POST /news/{articleId}/read @@ -249,13 +289,13 @@ private APIGatewayProxyResponseEvent markAsRead(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); learningService.markAsRead(userId, articleId); - + return ResponseGenerator.ok("읽기 완료 기록 성공", Map.of("articleId", articleId)); } - + /** * 북마크 토글 * POST /news/{articleId}/bookmark @@ -265,34 +305,34 @@ private APIGatewayProxyResponseEvent toggleBookmark(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); boolean isBookmarked = learningService.toggleBookmark(userId, articleId); - + return ResponseGenerator.ok( isBookmarked ? "북마크 추가 성공" : "북마크 해제 성공", Map.of("articleId", articleId, "bookmarked", isBookmarked) ); } - + /** * 뉴스 TTS 오디오 URL 조회 * GET /news/{articleId}/audio?voice=Joanna */ private APIGatewayProxyResponseEvent getAudio(APIGatewayProxyRequestEvent request) { String articleId = request.getPathParameters().get("articleId"); - + Map params = request.getQueryStringParameters(); String voice = (params != null) ? params.getOrDefault("voice", "Joanna") : "Joanna"; - + String audioUrl = learningService.getAudioUrl(articleId, voice); if (audioUrl == null) { return ResponseGenerator.fail(NewsErrorCode.ARTICLE_NOT_FOUND); } - + return ResponseGenerator.ok("TTS 오디오 URL 조회 성공", Map.of("audioUrl", audioUrl)); } - + /** * 퀴즈 조회 * GET /news/{articleId}/quiz @@ -302,17 +342,17 @@ private APIGatewayProxyResponseEvent getQuiz(APIGatewayProxyRequestEvent request if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); Optional quizData = quizService.getQuiz(articleId, userId); - + if (quizData.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.QUIZ_NOT_FOUND); } - + return ResponseGenerator.ok("퀴즈 조회 성공", quizData.get()); } - + /** * 퀴즈 제출 * POST /news/{articleId}/quiz @@ -322,14 +362,14 @@ private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); - + // 요청 바디 파싱 - JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + JsonObject body = JsonUtil.fromJson(request.getBody(), JsonObject.class); JsonArray answersArray = body.getAsJsonArray("answers"); Integer timeTaken = body.has("timeTaken") ? body.get("timeTaken").getAsInt() : null; - + List answers = new java.util.ArrayList<>(); if (answersArray != null) { answersArray.forEach(e -> { @@ -340,16 +380,16 @@ private APIGatewayProxyResponseEvent submitQuiz(APIGatewayProxyRequestEvent requ )); }); } - + NewsQuizService.QuizSubmitResult result = quizService.submitQuiz(userId, articleId, answers, timeTaken); - + if (result == null) { return ResponseGenerator.fail(NewsErrorCode.QUIZ_ALREADY_SUBMITTED); } - + return ResponseGenerator.ok("퀴즈 제출 성공", result); } - + /** * 퀴즈 기록 조회 * GET /news/quiz/history?limit=10 @@ -359,22 +399,22 @@ private APIGatewayProxyResponseEvent getQuizHistory(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List history = quizService.getUserQuizHistory(userId, limit); Map quizStats = quizService.getUserQuizStats(userId); - + Map response = new HashMap<>(); response.put("history", history); response.put("stats", quizStats); response.put("count", history.size()); - + return ResponseGenerator.ok("퀴즈 기록 조회 성공", response); } - + /** * 수집 단어 목록 조회 * GET /news/words?limit=10 @@ -384,22 +424,22 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + Map params = request.getQueryStringParameters(); if (params == null) params = new HashMap<>(); - + int limit = parseLimit(params.get("limit")); List words = wordService.getUserWords(userId, limit); Map stats = wordService.getUserWordStats(userId); - + Map response = new HashMap<>(); response.put("words", words); response.put("stats", stats); response.put("count", words.size()); - + return ResponseGenerator.ok("수집 단어 목록 조회 성공", response); } - + /** * 단어 수집 * POST /news/{articleId}/words @@ -409,22 +449,22 @@ private APIGatewayProxyResponseEvent collectWord(APIGatewayProxyRequestEvent req if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); - - JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + + JsonObject body = JsonUtil.fromJson(request.getBody(), JsonObject.class); String word = body.get("word").getAsString(); String context = body.has("context") ? body.get("context").getAsString() : ""; - + NewsWordCollect collected = wordService.collectWord(userId, articleId, word, context); - + if (collected == null) { return ResponseGenerator.fail(NewsErrorCode.WORD_ALREADY_COLLECTED); } - + return ResponseGenerator.ok("단어 수집 성공", collected); } - + /** * 단어 삭제 * DELETE /news/{articleId}/words/{word} @@ -434,31 +474,31 @@ private APIGatewayProxyResponseEvent deleteWord(APIGatewayProxyRequestEvent requ if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String articleId = request.getPathParameters().get("articleId"); String word = request.getPathParameters().get("word"); - + wordService.deleteWord(userId, word, articleId); - + return ResponseGenerator.ok("단어 삭제 성공", Map.of("word", word)); } - + /** * 단어 상세 정보 조회 * GET /news/{articleId}/words/{word} */ private APIGatewayProxyResponseEvent getWordDetail(APIGatewayProxyRequestEvent request) { String word = request.getPathParameters().get("word"); - + Optional detail = wordService.getWordDetail(word); - + if (detail.isEmpty()) { return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); } - + return ResponseGenerator.ok("단어 상세 조회 성공", detail.get()); } - + /** * 단어 Vocabulary 연동 * POST /news/words/{word}/sync @@ -468,18 +508,18 @@ private APIGatewayProxyResponseEvent syncWordToVocab(APIGatewayProxyRequestEvent if (userId == null) { return ResponseGenerator.fail(NewsErrorCode.UNAUTHORIZED); } - + String word = request.getPathParameters().get("word"); - - JsonObject body = gson.fromJson(request.getBody(), JsonObject.class); + + JsonObject body = JsonUtil.fromJson(request.getBody(), JsonObject.class); String articleId = body.get("articleId").getAsString(); - + boolean synced = wordService.syncToVocabulary(userId, word, articleId); - + if (!synced) { return ResponseGenerator.fail(NewsErrorCode.WORD_NOT_COLLECTED); } - + return ResponseGenerator.ok("Vocabulary 연동 성공", Map.of("word", word, "synced", true)); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java index 81f1e1f5..c4ad3708 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/KeywordInfo.java @@ -16,9 +16,11 @@ @AllArgsConstructor @DynamoDbBean public class KeywordInfo { - + private String word; // 영어 단어 - private String meaning; // 한국어 뜻 + private String meaning; // 영어 뜻 (간단한 정의) + private String meaningKo; // 한국어 뜻 + private String example; // 기사에서 발췌한 예문 private String level; // 단어 난이도 (BEGINNER, INTERMEDIATE, ADVANCED) private Integer position; // 기사 내 위치 (문장 번호 또는 단어 인덱스) } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java index 13fd8a19..3f0537f3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsArticle.java @@ -21,14 +21,14 @@ @AllArgsConstructor @DynamoDbBean public class NewsArticle { - + private String pk; // NEWS#{date} private String sk; // ARTICLE#{articleId} private String gsi1pk; // LEVEL#{level} private String gsi1sk; // {publishedAt} private String gsi2pk; // CATEGORY#{category} private String gsi2sk; // {publishedAt} - + // 기본 정보 private String articleId; private String title; @@ -36,54 +36,54 @@ public class NewsArticle { private String originalUrl; // 원문 링크 private String source; // BBC, VOA, NPR, NewsAPI private String imageUrl; // 썸네일 이미지 - + // 분류 private String category; // TECH, BUSINESS, SPORTS 등 private String level; // BEGINNER, INTERMEDIATE, ADVANCED private String cefrLevel; // A1, A2, B1, B2, C1, C2 (원본 CEFR 레벨) - + // AI 분석 결과 private List keywords; // 핵심 단어 정보 private List highlightWords; // 사용자 레벨 대비 어려운 단어 private List quiz; // 퀴즈 문제 (5개) - + // 메타데이터 private String publishedAt; // 원본 발행일 private String collectedAt; // 수집일 private Long readCount; // 조회수 private Long commentCount; // 댓글수 private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { return gsi1sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") @DynamoDbAttribute("GSI2PK") public String getGsi2pk() { return gsi2pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI2") @DynamoDbAttribute("GSI2SK") public String getGsi2sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java index 23b47f62..c2aaaae9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsQuizResult.java @@ -19,12 +19,12 @@ @AllArgsConstructor @DynamoDbBean public class NewsQuizResult { - + private String pk; // USER#{userId}#NEWS private String sk; // QUIZ#{articleId} private String gsi1pk; // USER_NEWS_STAT#{userId} private String gsi1sk; // {date}#QUIZ - + private String userId; private String articleId; private String articleTitle; @@ -36,25 +36,25 @@ public class NewsQuizResult { private Integer timeTaken; // 소요 시간 (초) private String submittedAt; private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java index 59f4fa93..227e90e3 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/NewsWordCollect.java @@ -18,12 +18,12 @@ @AllArgsConstructor @DynamoDbBean public class NewsWordCollect { - + private String pk; // USER#{userId}#NEWS private String sk; // WORD#{word}#{articleId} private String gsi1pk; // USER#{userId}#NEWS_WORDS private String gsi1sk; // {collectedAt} - + private String userId; private String word; private String meaning; @@ -35,25 +35,25 @@ public class NewsWordCollect { private Boolean syncedToVocab; // Vocabulary 연동 여부 private String vocabUserWordId; // 연동된 UserWord ID private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java index 3dee95b6..7340216f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizAnswerResult.java @@ -15,7 +15,7 @@ @AllArgsConstructor @DynamoDbBean public class QuizAnswerResult { - + private String questionId; private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK private String userAnswer; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java index 6657ef33..1f6dab4f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/QuizQuestion.java @@ -17,7 +17,7 @@ @AllArgsConstructor @DynamoDbBean public class QuizQuestion { - + private String questionId; // 문제 ID (q1, q2, ...) private String type; // COMPREHENSION, WORD_MATCH, FILL_BLANK private String question; // 문제 내용 diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java index eedfa8c5..b0622e00 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/model/UserNewsRecord.java @@ -17,12 +17,12 @@ @AllArgsConstructor @DynamoDbBean public class UserNewsRecord { - + private String pk; // USER_NEWS#{userId} private String sk; // READ#{articleId} 또는 BOOKMARK#{articleId} private String gsi1pk; // USER_NEWS_STAT#{userId} private String gsi1sk; // {date}#{type} - + private String userId; private String articleId; private String type; // READ, BOOKMARK @@ -31,25 +31,25 @@ public class UserNewsRecord { private String articleCategory; private String createdAt; private Long ttl; - + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { return pk; } - + @DynamoDbSortKey @DynamoDbAttribute("SK") public String getSk() { return sk; } - + @DynamoDbSecondaryPartitionKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1PK") public String getGsi1pk() { return gsi1pk; } - + @DynamoDbSecondarySortKey(indexNames = "GSI1") @DynamoDbAttribute("GSI1SK") public String getGsi1sk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java index 28ca35cc..9cb2b587 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsArticleRepository.java @@ -9,7 +9,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ScanEnhancedRequest; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; @@ -22,20 +25,20 @@ * 뉴스 기사 Repository */ public class NewsArticleRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsArticleRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; - + /** * 기본 생성자 (Lambda에서 사용) */ public NewsArticleRepository() { this(AwsClients.dynamoDbEnhanced()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -43,7 +46,7 @@ public NewsArticleRepository(DynamoDbEnhancedClient enhancedClient) { this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsArticle.class)); } - + /** * 뉴스 기사 저장 */ @@ -52,7 +55,7 @@ public NewsArticle save(NewsArticle article) { table.putItem(article); return article; } - + /** * 뉴스 기사 조회 (날짜 + 기사ID) */ @@ -61,26 +64,27 @@ public Optional findByDateAndId(String date, String articleId) { .partitionValue(NewsKey.newsPk(date)) .sortValue(NewsKey.articleSk(articleId)) .build(); - + NewsArticle article = table.getItem(key); return Optional.ofNullable(article); } - + /** * 뉴스 기사 조회 (기사ID만으로 - GSI 활용 또는 Scan) * 참고: 실제로는 articleId로 date를 알 수 있도록 설계하거나 GSI 추가 필요 */ public Optional findById(String articleId) { Expression filterExpression = Expression.builder() - .expression("articleId = :articleId") + .expression("articleId = :articleId AND begins_with(SK, :skPrefix)") .putExpressionValue(":articleId", AttributeValue.builder().s(articleId).build()) + .putExpressionValue(":skPrefix", AttributeValue.builder().s("ARTICLE#").build()) .build(); - + ScanEnhancedRequest request = ScanEnhancedRequest.builder() .filterExpression(filterExpression) .limit(1) .build(); - + for (Page page : table.scan(request)) { List items = page.items(); if (!items.isEmpty()) { @@ -89,7 +93,7 @@ public Optional findById(String articleId) { } return Optional.empty(); } - + /** * 뉴스 기사 삭제 */ @@ -98,88 +102,88 @@ public void delete(String date, String articleId) { .partitionValue(NewsKey.newsPk(date)) .sortValue(NewsKey.articleSk(articleId)) .build(); - + table.deleteItem(key); logger.info("Deleted news article: {}", articleId); } - + /** * 날짜별 뉴스 기사 조회 (페이지네이션) */ public PaginatedResult findByDate(String date, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.newsPk(date)).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 (SK 역순) .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + Page page = table.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 레벨별 뉴스 기사 조회 (GSI1 - 최신순) */ public PaginatedResult findByLevel(String level, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); Page page = gsi1.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 카테고리별 뉴스 기사 조회 (GSI2 - 최신순) */ public PaginatedResult findByCategory(String category, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.categoryPk(category.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 .limit(limit); - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi2 = table.index("GSI2"); Page page = gsi2.query(requestBuilder.build()).iterator().next(); String nextCursor = CursorUtil.encode(page.lastEvaluatedKey()); - + return new PaginatedResult<>(page.items(), nextCursor); } - + /** * 레벨 + 카테고리 필터 조회 (GSI1 쿼리 후 필터) */ @@ -188,27 +192,27 @@ public PaginatedResult findByLevelAndCategory(String level, String .expression("category = :category") .putExpressionValue(":category", AttributeValue.builder().s(category.toUpperCase()).build()) .build(); - + QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue(NewsKey.levelPk(level.toUpperCase())).build()); - + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .filterExpression(filterExpression) .scanIndexForward(false) .limit(limit * 2); // 필터 적용되므로 넉넉히 - + if (cursor != null && !cursor.isEmpty()) { Map exclusiveStartKey = CursorUtil.decode(cursor); if (exclusiveStartKey != null) { requestBuilder.exclusiveStartKey(exclusiveStartKey); } } - + DynamoDbIndex gsi1 = table.index("GSI1"); List results = new ArrayList<>(); Map lastKey = null; - + for (Page page : gsi1.query(requestBuilder.build())) { for (NewsArticle article : page.items()) { results.add(article); @@ -217,11 +221,11 @@ public PaginatedResult findByLevelAndCategory(String level, String lastKey = page.lastEvaluatedKey(); if (results.size() >= limit) break; } - + String nextCursor = results.size() >= limit ? CursorUtil.encode(lastKey) : null; return new PaginatedResult<>(results.subList(0, Math.min(results.size(), limit)), nextCursor); } - + /** * 조회수 증가 (Atomic Update) */ @@ -230,23 +234,23 @@ public void incrementReadCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":zero", AttributeValue.builder().n("0").build(), ":inc", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET readCount = if_not_exists(readCount, :zero) + :inc") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); logger.debug("Incremented read count for article: {}", articleId); } - + /** * 댓글수 증가 (Atomic Update) */ @@ -255,22 +259,22 @@ public void incrementCommentCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":zero", AttributeValue.builder().n("0").build(), ":inc", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET commentCount = if_not_exists(commentCount, :zero) + :inc") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } - + /** * 댓글수 감소 (Atomic Update) */ @@ -279,19 +283,19 @@ public void decrementCommentCount(String date, String articleId) { "PK", AttributeValue.builder().s(NewsKey.newsPk(date)).build(), "SK", AttributeValue.builder().s(NewsKey.articleSk(articleId)).build() ); - + Map values = Map.of( ":one", AttributeValue.builder().n("1").build(), ":dec", AttributeValue.builder().n("1").build() ); - + UpdateItemRequest request = UpdateItemRequest.builder() .tableName(TABLE_NAME) .key(key) .updateExpression("SET commentCount = if_not_exists(commentCount, :one) - :dec") .expressionAttributeValues(values) .build(); - + AwsClients.dynamoDb().updateItem(request); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java index b2786f99..d772ee5a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsQuizRepository.java @@ -6,8 +6,13 @@ import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.util.ArrayList; import java.util.List; @@ -17,20 +22,20 @@ * 뉴스 퀴즈 결과 Repository */ public class NewsQuizRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsQuizRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; - + public NewsQuizRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public NewsQuizRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsQuizResult.class)); } - + /** * 퀴즈 결과 저장 */ @@ -39,7 +44,7 @@ public void save(NewsQuizResult result) { logger.debug("퀴즈 결과 저장: userId={}, articleId={}, score={}", result.getUserId(), result.getArticleId(), result.getScore()); } - + /** * 퀴즈 결과 조회 */ @@ -48,18 +53,18 @@ public Optional findByUserAndArticle(String userId, String artic .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.quizSk(articleId)) .build(); - + NewsQuizResult result = table.getItem(key); return Optional.ofNullable(result); } - + /** * 퀴즈 제출 여부 확인 */ public boolean hasSubmitted(String userId, String articleId) { return findByUserAndArticle(userId, articleId).isPresent(); } - + /** * 사용자 퀴즈 결과 목록 조회 */ @@ -70,22 +75,22 @@ public List getUserQuizResults(String userId, int limit) { .sortValue("QUIZ#") .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : table.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 사용자 퀴즈 통계 조회 */ @@ -96,11 +101,11 @@ public QuizStats getUserQuizStats(String userId) { .sortValue("QUIZ#") .build() ); - + int totalQuizzes = 0; int totalScore = 0; int perfectScores = 0; - + for (Page page : table.query(queryConditional)) { for (NewsQuizResult result : page.items()) { totalQuizzes++; @@ -110,11 +115,11 @@ public QuizStats getUserQuizStats(String userId) { } } } - + int avgScore = totalQuizzes > 0 ? totalScore / totalQuizzes : 0; return new QuizStats(totalQuizzes, avgScore, perfectScores); } - + /** * 퀴즈 통계 레코드 */ @@ -122,5 +127,6 @@ public record QuizStats( int totalQuizzes, int avgScore, int perfectScores - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java index 5dfebc80..be899b98 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/NewsWordRepository.java @@ -7,7 +7,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.util.ArrayList; import java.util.List; @@ -17,22 +19,22 @@ * 뉴스 단어 수집 Repository */ public class NewsWordRepository { - + private static final Logger logger = LoggerFactory.getLogger(NewsWordRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; private final DynamoDbIndex gsi1Index; - + public NewsWordRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public NewsWordRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(NewsWordCollect.class)); this.gsi1Index = table.index("GSI1"); } - + /** * 단어 수집 저장 */ @@ -40,7 +42,7 @@ public void save(NewsWordCollect wordCollect) { table.putItem(wordCollect); logger.debug("단어 수집 저장: userId={}, word={}", wordCollect.getUserId(), wordCollect.getWord()); } - + /** * 단어 수집 조회 */ @@ -49,18 +51,18 @@ public Optional findByUserWordArticle(String userId, String wor .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.wordSk(word, articleId)) .build(); - + NewsWordCollect result = table.getItem(key); return Optional.ofNullable(result); } - + /** * 이미 수집했는지 확인 */ public boolean hasCollected(String userId, String word, String articleId) { return findByUserWordArticle(userId, word, articleId).isPresent(); } - + /** * 단어 수집 삭제 */ @@ -69,11 +71,11 @@ public void delete(String userId, String word, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.wordSk(word, articleId)) .build(); - + table.deleteItem(key); logger.debug("단어 수집 삭제: userId={}, word={}", userId, word); } - + /** * 사용자 수집 단어 목록 조회 (최신순) */ @@ -83,22 +85,22 @@ public List getUserWords(String userId, int limit) { .partitionValue(NewsKey.userNewsWordsPk(userId)) .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : gsi1Index.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + /** * 사용자 수집 단어 수 조회 */ @@ -109,14 +111,14 @@ public int countUserWords(String userId) { .sortValue("WORD#") .build() ); - + int count = 0; for (Page page : table.query(queryConditional)) { count += page.items().size(); } return count; } - + /** * Vocabulary 연동 상태 업데이트 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java index 25f8e651..febc8895 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/repository/UserNewsRepository.java @@ -6,9 +6,13 @@ import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.*; -import software.amazon.awssdk.enhanced.dynamodb.model.*; -import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbEnhancedClient; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.Page; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryConditional; +import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import java.time.Instant; import java.time.LocalDate; @@ -18,27 +22,27 @@ * 사용자 뉴스 학습 기록 Repository */ public class UserNewsRepository { - + private static final Logger logger = LoggerFactory.getLogger(UserNewsRepository.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + private final DynamoDbTable table; - + public UserNewsRepository() { this(AwsClients.dynamoDbEnhanced()); } - + public UserNewsRepository(DynamoDbEnhancedClient enhancedClient) { this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(UserNewsRecord.class)); } - + /** * 읽기 기록 저장 */ public void saveReadRecord(String userId, String articleId, String title, String level, String category) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + UserNewsRecord record = UserNewsRecord.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.readSk(articleId)) @@ -52,18 +56,18 @@ public void saveReadRecord(String userId, String articleId, String title, String .articleCategory(category) .createdAt(now) .build(); - + table.putItem(record); logger.debug("읽기 기록 저장: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 저장 */ public void saveBookmark(String userId, String articleId, String title, String level, String category) { String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + UserNewsRecord record = UserNewsRecord.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.bookmarkSk(articleId)) @@ -77,11 +81,11 @@ public void saveBookmark(String userId, String articleId, String title, String l .articleCategory(category) .createdAt(now) .build(); - + table.putItem(record); logger.debug("북마크 저장: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 삭제 */ @@ -90,11 +94,11 @@ public void deleteBookmark(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.bookmarkSk(articleId)) .build(); - + table.deleteItem(key); logger.debug("북마크 삭제: userId={}, articleId={}", userId, articleId); } - + /** * 북마크 여부 확인 */ @@ -103,10 +107,10 @@ public boolean isBookmarked(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.bookmarkSk(articleId)) .build(); - + return table.getItem(key) != null; } - + /** * 읽기 기록 여부 확인 */ @@ -115,10 +119,10 @@ public boolean hasRead(String userId, String articleId) { .partitionValue(NewsKey.userNewsPk(userId)) .sortValue(NewsKey.readSk(articleId)) .build(); - + return table.getItem(key) != null; } - + /** * 사용자 북마크 목록 조회 */ @@ -129,22 +133,35 @@ public List getUserBookmarks(String userId, int limit) { .sortValue("BOOKMARK#") .build() ); - + QueryEnhancedRequest request = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) .limit(limit) .build(); - + List results = new ArrayList<>(); for (Page page : table.query(request)) { results.addAll(page.items()); if (results.size() >= limit) break; } - + return results.subList(0, Math.min(results.size(), limit)); } - + + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + Set bookmarkedIds = new HashSet<>(); + for (String articleId : articleIds) { + if (isBookmarked(userId, articleId)) { + bookmarkedIds.add(articleId); + } + } + return bookmarkedIds; + } + /** * 사용자 뉴스 통계 조회 */ @@ -152,20 +169,20 @@ public NewsStats getUserStats(String userId) { QueryConditional queryConditional = QueryConditional.keyEqualTo( Key.builder().partitionValue(NewsKey.userNewsPk(userId)).build() ); - + int totalRead = 0; int thisWeekRead = 0; int totalBookmarks = 0; Map byLevel = new HashMap<>(); Map byCategory = new HashMap<>(); - + LocalDate weekAgo = LocalDate.now().minusDays(7); - + for (Page page : table.query(queryConditional)) { for (UserNewsRecord record : page.items()) { if ("READ".equals(record.getType())) { totalRead++; - + // 이번 주 읽은 것 if (record.getCreatedAt() != null) { LocalDate readDate = Instant.parse(record.getCreatedAt()) @@ -174,13 +191,13 @@ public NewsStats getUserStats(String userId) { thisWeekRead++; } } - + // 레벨별 통계 String level = record.getArticleLevel(); if (level != null) { byLevel.merge(level, 1, Integer::sum); } - + // 카테고리별 통계 String category = record.getArticleCategory(); if (category != null) { @@ -191,10 +208,10 @@ public NewsStats getUserStats(String userId) { } } } - + return new NewsStats(totalRead, thisWeekRead, totalBookmarks, byLevel, byCategory); } - + /** * 뉴스 통계 레코드 */ @@ -204,5 +221,6 @@ public record NewsStats( int totalBookmarks, Map byLevel, Map byCategory - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java index af23fc5b..4badd15f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsAnalysisService.java @@ -13,11 +13,13 @@ import software.amazon.awssdk.core.SdkBytes; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelRequest; import software.amazon.awssdk.services.bedrockruntime.model.InvokeModelResponse; -import software.amazon.awssdk.services.comprehend.model.*; +import software.amazon.awssdk.services.comprehend.model.DetectKeyPhrasesRequest; +import software.amazon.awssdk.services.comprehend.model.DetectKeyPhrasesResponse; +import software.amazon.awssdk.services.comprehend.model.KeyPhrase; +import software.amazon.awssdk.services.comprehend.model.LanguageCode; import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; /** * 뉴스 AI 분석 서비스 @@ -27,49 +29,54 @@ * - 퀴즈 생성 (Bedrock) */ public class NewsAnalysisService { - + private static final Logger logger = LoggerFactory.getLogger(NewsAnalysisService.class); private static final Gson gson = new Gson(); private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - + private final NewsArticleRepository articleRepository; - + public NewsAnalysisService() { this.articleRepository = new NewsArticleRepository(); } - + public NewsAnalysisService(NewsArticleRepository articleRepository) { this.articleRepository = articleRepository; } - + /** * 뉴스 기사 전체 분석 */ public NewsArticle analyzeArticle(NewsArticle article) { logger.info("뉴스 분석 시작: {}", article.getArticleId()); long startTime = System.currentTimeMillis(); - + String content = article.getTitle() + ". " + (article.getSummary() != null ? article.getSummary() : ""); - + try { // 1. CEFR 난이도 분석 String cefrLevel = analyzeDifficulty(content); article.setCefrLevel(cefrLevel); article.setLevel(mapCefrToLevel(cefrLevel)); - - // 2. 핵심 단어 추출 (Comprehend) - List keywords = extractKeywords(content); - article.setKeywords(keywords); - - // 3. 3줄 요약 + 퀴즈 생성 (Bedrock - 한 번에 처리) + + // 2. 3줄 요약 + 키워드 + 퀴즈 생성 (Bedrock - 한 번에 처리) AnalysisResult result = generateSummaryAndQuiz(content, cefrLevel); if (result.summary() != null) { article.setSummary(result.summary()); } article.setQuiz(result.quiz()); article.setHighlightWords(result.highlightWords()); - + + // Bedrock 키워드 사용 (meaningKo 포함) + if (result.keywords() != null && !result.keywords().isEmpty()) { + article.setKeywords(result.keywords()); + } else { + // fallback: Comprehend로 키워드 추출 + List keywords = extractKeywords(content); + article.setKeywords(keywords); + } + // 4. GSI 키 설정 article.setGsi1pk("LEVEL#" + article.getLevel()); article.setGsi1sk(article.getPublishedAt()); @@ -77,13 +84,13 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setGsi2pk("CATEGORY#" + article.getCategory()); article.setGsi2sk(article.getPublishedAt()); } - + // 5. 저장 articleRepository.save(article); - + long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 분석 완료: {} ({}ms)", article.getArticleId(), elapsed); - + } catch (Exception e) { logger.error("뉴스 분석 실패: {}", article.getArticleId(), e); // 분석 실패해도 기본값으로 저장 @@ -91,10 +98,10 @@ public NewsArticle analyzeArticle(NewsArticle article) { article.setCefrLevel("B1"); articleRepository.save(article); } - + return article; } - + /** * CEFR 난이도 분석 (Bedrock) */ @@ -102,31 +109,31 @@ private String analyzeDifficulty(String content) { String systemPrompt = """ You are an English language expert. Analyze the text and determine its CEFR level. Consider vocabulary complexity, sentence structure, and topic familiarity. - + Respond with ONLY the CEFR level code: A1, A2, B1, B2, C1, or C2 No explanation, just the level code. """; - + String userPrompt = "Determine the CEFR level of this text:\n\n" + truncate(content, 1000); - + String response = invokeBedrock(systemPrompt, userPrompt); String level = response.trim().toUpperCase(); - + // 유효한 레벨인지 확인 if (List.of("A1", "A2", "B1", "B2", "C1", "C2").contains(level)) { return level; } - + // 레벨 추출 시도 for (String validLevel : List.of("C2", "C1", "B2", "B1", "A2", "A1")) { if (response.toUpperCase().contains(validLevel)) { return validLevel; } } - + return "B1"; // 기본값 } - + /** * CEFR을 3단계 레벨로 매핑 */ @@ -138,7 +145,7 @@ private String mapCefrToLevel(String cefrLevel) { default -> "INTERMEDIATE"; }; } - + /** * 핵심 단어 추출 (Comprehend) */ @@ -150,10 +157,10 @@ private List extractKeywords(String content) { .languageCode(LanguageCode.EN) .build() ); - + List keywords = new ArrayList<>(); List phrases = response.keyPhrases(); - + for (int i = 0; i < Math.min(phrases.size(), 10); i++) { KeyPhrase phrase = phrases.get(i); if (phrase.score() > 0.8) { @@ -163,25 +170,29 @@ private List extractKeywords(String content) { .build()); } } - + return keywords; - + } catch (Exception e) { logger.error("키워드 추출 실패", e); return new ArrayList<>(); } } - + /** * 요약 + 퀴즈 생성 (Bedrock) */ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) { String systemPrompt = """ - You are an English learning assistant. Analyze the news article and create learning materials. - + You are an English learning assistant for Korean learners. Analyze the news article and create learning materials. + Respond in this exact JSON format: { "summary": "3-line summary in English (each line separated by newline)", + "keywords": [ + {"word": "economy", "meaning": "the system of trade and industry", "meaningKo": "경제", "example": "The economy is growing steadily."}, + {"word": "policy", "meaning": "a plan of action adopted by government", "meaningKo": "정책", "example": "The new policy affects all citizens."} + ], "highlightWords": ["word1", "word2", "word3"], "quiz": [ { @@ -210,22 +221,29 @@ private AnalysisResult generateSummaryAndQuiz(String content, String cefrLevel) } ] } - - Create exactly 3 quiz questions. - highlightWords should contain 3-5 difficult words for learners. - Adjust difficulty based on CEFR level: """ + cefrLevel; - + + IMPORTANT: + - keywords: Extract 5-8 important vocabulary words from the article. Include: + - word: the English word + - meaning: simple English definition + - meaningKo: Korean translation of the word (한국어 뜻) + - example: example sentence from the article + - highlightWords: 3-5 difficult words that learners should pay attention to (just the words, no definitions). + - category: Choose EXACTLY ONE from: WORLD, POLITICS, BUSINESS, TECH, SCIENCE, HEALTH, SPORTS, ENTERTAINMENT, LIFESTYLE + - Create exactly 3 quiz questions. + - Adjust difficulty based on CEFR level: """ + cefrLevel; + String userPrompt = "Create learning materials for this article:\n\n" + truncate(content, 1500); - + try { String response = invokeBedrock(systemPrompt, userPrompt); return parseAnalysisResult(response); } catch (Exception e) { logger.error("요약/퀴즈 생성 실패", e); - return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>()); + return new AnalysisResult(null, new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); } } - + /** * Bedrock API 호출 */ @@ -234,46 +252,61 @@ private String invokeBedrock(String systemPrompt, String userPrompt) { requestBody.addProperty("anthropic_version", "bedrock-2023-05-31"); requestBody.addProperty("max_tokens", 2000); requestBody.addProperty("system", systemPrompt); - + JsonArray messages = new JsonArray(); JsonObject userMessage = new JsonObject(); userMessage.addProperty("role", "user"); userMessage.addProperty("content", userPrompt); messages.add(userMessage); requestBody.add("messages", messages); - + InvokeModelRequest request = InvokeModelRequest.builder() .modelId(MODEL_ID) .contentType("application/json") .accept("application/json") .body(SdkBytes.fromUtf8String(gson.toJson(requestBody))) .build(); - + InvokeModelResponse response = AwsClients.bedrock().invokeModel(request); JsonObject jsonResponse = gson.fromJson(response.body().asUtf8String(), JsonObject.class); - + JsonArray contentArray = jsonResponse.getAsJsonArray("content"); if (contentArray != null && !contentArray.isEmpty()) { return contentArray.get(0).getAsJsonObject().get("text").getAsString(); } - + throw new RuntimeException("Empty response from Bedrock"); } - + /** * 분석 결과 파싱 */ private AnalysisResult parseAnalysisResult(String response) { String jsonStr = extractJson(response); JsonObject json = gson.fromJson(jsonStr, JsonObject.class); - + String summary = json.has("summary") ? json.get("summary").getAsString() : null; - + String category = json.has("category") ? json.get("category").getAsString().toUpperCase() : "WORLD"; + + // keywords 파싱 + List keywords = new ArrayList<>(); + if (json.has("keywords")) { + json.getAsJsonArray("keywords").forEach(e -> { + JsonObject k = e.getAsJsonObject(); + keywords.add(KeywordInfo.builder() + .word(k.has("word") ? k.get("word").getAsString() : "") + .meaning(k.has("meaning") ? k.get("meaning").getAsString() : "") + .meaningKo(k.has("meaningKo") ? k.get("meaningKo").getAsString() : "") + .example(k.has("example") ? k.get("example").getAsString() : "") + .build()); + }); + } + List highlightWords = new ArrayList<>(); if (json.has("highlightWords")) { json.getAsJsonArray("highlightWords").forEach(e -> highlightWords.add(e.getAsString())); } - + List quiz = new ArrayList<>(); if (json.has("quiz")) { json.getAsJsonArray("quiz").forEach(e -> { @@ -292,10 +325,10 @@ private AnalysisResult parseAnalysisResult(String response) { .build()); }); } - - return new AnalysisResult(summary, highlightWords, quiz); + + return new AnalysisResult(summary, keywords, highlightWords, quiz); } - + private String extractJson(String response) { int start = response.indexOf('{'); int end = response.lastIndexOf('}'); @@ -304,18 +337,20 @@ private String extractJson(String response) { } return response; } - + private String truncate(String text, int maxLength) { if (text == null) return ""; return text.length() > maxLength ? text.substring(0, maxLength) : text; } - + /** * 분석 결과 레코드 */ private record AnalysisResult( String summary, + List keywords, List highlightWords, List quiz - ) {} + ) { + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java index ecac47df..1709fc42 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsCollectorService.java @@ -18,41 +18,41 @@ * RSS 피드에서 뉴스를 수집하고 저장 (BBC, VOA, NPR) */ public class NewsCollectorService { - + private static final Logger logger = LoggerFactory.getLogger(NewsCollectorService.class); - + private static final int RSS_LIMIT_PER_SOURCE = 7; private static final long TTL_DAYS = 30; - + private final RssFeedParser rssFeedParser; private final NewsDuplicateChecker duplicateChecker; private final NewsArticleRepository articleRepository; private final NewsAnalysisService analysisService; - + public NewsCollectorService() { this.rssFeedParser = new RssFeedParser(); this.duplicateChecker = new NewsDuplicateChecker(); this.articleRepository = new NewsArticleRepository(); this.analysisService = new NewsAnalysisService(); } - + public NewsCollectorService(RssFeedParser rssFeedParser, - NewsDuplicateChecker duplicateChecker, - NewsArticleRepository articleRepository, - NewsAnalysisService analysisService) { + NewsDuplicateChecker duplicateChecker, + NewsArticleRepository articleRepository, + NewsAnalysisService analysisService) { this.rssFeedParser = rssFeedParser; this.duplicateChecker = duplicateChecker; this.articleRepository = articleRepository; this.analysisService = analysisService; } - + /** * 뉴스 수집 실행 */ public CollectionResult collectNews() { logger.info("뉴스 수집 시작"); long startTime = System.currentTimeMillis(); - + List rssArticles; try { rssArticles = rssFeedParser.fetchAllFeeds(RSS_LIMIT_PER_SOURCE); @@ -61,16 +61,16 @@ public CollectionResult collectNews() { logger.error("RSS 수집 실패", e); return new CollectionResult(0, 0, System.currentTimeMillis() - startTime); } - + List uniqueArticles = duplicateChecker.filterDuplicates(rssArticles); logger.info("중복 제거 후 {}개 기사", uniqueArticles.size()); - + int savedCount = 0; int analyzedCount = 0; for (RawNewsArticle rawArticle : uniqueArticles) { try { NewsArticle article = convertToNewsArticle(rawArticle); - + // AI 분석 수행 (난이도, 요약, 키워드, 퀴즈) analysisService.analyzeArticle(article); analyzedCount++; @@ -79,13 +79,13 @@ public CollectionResult collectNews() { logger.error("기사 처리 실패: {}", rawArticle.getTitle(), e); } } - + long elapsed = System.currentTimeMillis() - startTime; logger.info("뉴스 수집/분석 완료 - 저장: {}, 분석: {}, 소요시간: {}ms", savedCount, analyzedCount, elapsed); - + return new CollectionResult(rssArticles.size(), savedCount, elapsed); } - + /** * RawNewsArticle을 NewsArticle로 변환 * AI 분석은 별도 Story에서 처리 @@ -94,12 +94,12 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { String today = LocalDate.now().toString(); String articleId = UUID.randomUUID().toString().substring(0, 8); String now = Instant.now().toString(); - + long ttlEpoch = Instant.now() .atOffset(ZoneOffset.UTC) .plusDays(TTL_DAYS) .toEpochSecond(); - + return NewsArticle.builder() .pk(NewsKey.newsPk(today)) .sk(NewsKey.articleSk(articleId)) @@ -116,7 +116,7 @@ private NewsArticle convertToNewsArticle(RawNewsArticle raw) { .ttl(ttlEpoch) .build(); } - + /** * 수집 결과 레코드 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java index d4eedd82..b939c7bc 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsDuplicateChecker.java @@ -11,21 +11,17 @@ import software.amazon.awssdk.services.dynamodb.model.QueryResponse; import java.time.LocalDate; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; +import java.util.*; /** * 뉴스 중복 검사 서비스 * URL 기반으로 중복 뉴스 필터링 */ public class NewsDuplicateChecker { - + private static final Logger logger = LoggerFactory.getLogger(NewsDuplicateChecker.class); private static final String TABLE_NAME = EnvConfig.getRequired("NEWS_TABLE_NAME"); - + /** * 중복 뉴스 필터링 */ @@ -33,38 +29,38 @@ public List filterDuplicates(List articles) { if (articles.isEmpty()) { return articles; } - + Set existingUrls = getExistingUrls(); Set seenUrls = new HashSet<>(); List uniqueArticles = new ArrayList<>(); - + for (RawNewsArticle article : articles) { String url = article.getUrl(); if (url == null) { continue; } - + if (!existingUrls.contains(url) && !seenUrls.contains(url)) { uniqueArticles.add(article); seenUrls.add(url); } } - + int duplicateCount = articles.size() - uniqueArticles.size(); if (duplicateCount > 0) { logger.info("{}개 중복 기사 필터링됨", duplicateCount); } - + return uniqueArticles; } - + /** * 오늘 날짜의 기존 뉴스 URL 조회 */ private Set getExistingUrls() { Set urls = new HashSet<>(); String today = LocalDate.now().toString(); - + try { QueryRequest request = QueryRequest.builder() .tableName(TABLE_NAME) @@ -74,21 +70,21 @@ private Set getExistingUrls() { )) .projectionExpression("originalUrl") .build(); - + QueryResponse response = AwsClients.dynamoDb().query(request); - + for (Map item : response.items()) { if (item.containsKey("originalUrl")) { urls.add(item.get("originalUrl").s()); } } - + logger.debug("기존 뉴스 {}개 URL 로드됨", urls.size()); - + } catch (Exception e) { logger.error("기존 뉴스 URL 조회 실패", e); } - + return urls; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java index 8eba8522..b851c717 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsLearningService.java @@ -1,17 +1,20 @@ package com.mzc.secondproject.serverless.domain.news.service; -import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; +import com.mzc.secondproject.serverless.domain.badge.model.UserBadge; +import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.model.UserNewsRecord; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.UserNewsRepository; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; /** * 뉴스 학습 부가 기능 서비스 @@ -19,82 +22,79 @@ public class NewsLearningService { private static final Logger logger = LoggerFactory.getLogger(NewsLearningService.class); - private static final String BUCKET_NAME = EnvConfig.getOrDefault("NEWS_BUCKET_NAME", "group2-englishstudy"); private final NewsArticleRepository articleRepository; private final UserNewsRepository userNewsRepository; private final PollyService pollyService; + private final UserStatsRepository userStatsRepository; + private final BadgeService badgeService; public NewsLearningService() { this.articleRepository = new NewsArticleRepository(); this.userNewsRepository = new UserNewsRepository(); - this.pollyService = new PollyService(BUCKET_NAME, "news/audio/"); + this.pollyService = new PollyService(NewsConfig.bucketName(), NewsConfig.TTS_AUDIO_PREFIX); + this.userStatsRepository = new UserStatsRepository(); + this.badgeService = new BadgeService(); } public NewsLearningService(NewsArticleRepository articleRepository, UserNewsRepository userNewsRepository, - PollyService pollyService) { + PollyService pollyService, + UserStatsRepository userStatsRepository, + BadgeService badgeService) { this.articleRepository = articleRepository; this.userNewsRepository = userNewsRepository; this.pollyService = pollyService; + this.userStatsRepository = userStatsRepository; + this.badgeService = badgeService; } /** * 뉴스 읽기 완료 기록 + * + * @return 새로 획득한 배지 목록 */ - public void markAsRead(String userId, String articleId) { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { + public List markAsRead(String userId, String articleId) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); - return; + return List.of(); } - NewsArticle a = article.get(); - userNewsRepository.saveReadRecord( - userId, - articleId, - a.getTitle(), - a.getLevel(), - a.getCategory() - ); - - // 조회수 증가 - String date = extractDateFromPk(a.getPk()); - if (date != null) { - articleRepository.incrementReadCount(date, articleId); + if (userNewsRepository.hasRead(userId, articleId)) { + logger.debug("이미 읽은 기사: userId={}, articleId={}", userId, articleId); + return List.of(); } + NewsArticle article = articleOpt.get(); + saveReadRecord(userId, article); + incrementArticleReadCount(article); + logger.info("읽기 완료 기록: userId={}, articleId={}", userId, articleId); + + return updateStatsAndCheckBadges(userId); } /** * 북마크 토글 */ public boolean toggleBookmark(String userId, String articleId) { - boolean isBookmarked = userNewsRepository.isBookmarked(userId, articleId); - - if (isBookmarked) { + if (userNewsRepository.isBookmarked(userId, articleId)) { userNewsRepository.deleteBookmark(userId, articleId); logger.info("북마크 해제: userId={}, articleId={}", userId, articleId); return false; - } else { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { - logger.warn("기사를 찾을 수 없음: {}", articleId); - return false; - } + } - NewsArticle a = article.get(); - userNewsRepository.saveBookmark( - userId, - articleId, - a.getTitle(), - a.getLevel(), - a.getCategory() - ); - logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); - return true; + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { + logger.warn("기사를 찾을 수 없음: {}", articleId); + return false; } + + NewsArticle article = articleOpt.get(); + userNewsRepository.saveBookmark(userId, articleId, article.getTitle(), article.getLevel(), article.getCategory()); + logger.info("북마크 추가: userId={}, articleId={}", userId, articleId); + return true; } /** @@ -105,29 +105,45 @@ public boolean isBookmarked(String userId, String articleId) { } /** - * 사용자 북마크 목록 조회 + * 읽기 여부 확인 + */ + public boolean hasRead(String userId, String articleId) { + return userNewsRepository.hasRead(userId, articleId); + } + + /** + * 여러 기사의 북마크 여부 확인 (배치) + */ + public Set getBookmarkedArticleIds(String userId, List articleIds) { + return userNewsRepository.getBookmarkedArticleIds(userId, articleIds); + } + + /** + * 사용자 북마크 목록 조회 (기사 정보 포함) */ - public List getUserBookmarks(String userId, int limit) { - return userNewsRepository.getUserBookmarks(userId, limit); + public List> getUserBookmarks(String userId, int limit) { + List bookmarks = userNewsRepository.getUserBookmarks(userId, limit); + + return bookmarks.stream() + .map(bookmark -> articleRepository.findById(bookmark.getArticleId()) + .map(article -> buildBookmarkResponse(article, bookmark)) + .orElse(null)) + .filter(Objects::nonNull) + .toList(); } /** * 뉴스 TTS 오디오 URL 생성 */ public String getAudioUrl(String articleId, String voice) { - Optional article = articleRepository.findById(articleId); - if (article.isEmpty()) { + Optional articleOpt = articleRepository.findById(articleId); + if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - NewsArticle a = article.get(); - String text = a.getTitle() + ". " + (a.getSummary() != null ? a.getSummary() : ""); - - // 텍스트가 너무 길면 제한 - if (text.length() > 3000) { - text = text.substring(0, 3000); - } + NewsArticle article = articleOpt.get(); + String text = buildTtsText(article); PollyService.VoiceSynthesisResult result = pollyService.synthesizeSpeech(articleId, text, voice); return result.getAudioUrl(); @@ -148,13 +164,63 @@ public Map getUserStats(String userId) { ); } - /** - * PK에서 날짜 추출 - */ - private String extractDateFromPk(String pk) { - if (pk == null || !pk.startsWith("NEWS#")) { - return null; + // ========== Private Helper Methods ========== + + private void saveReadRecord(String userId, NewsArticle article) { + userNewsRepository.saveReadRecord( + userId, + article.getArticleId(), + article.getTitle(), + article.getLevel(), + article.getCategory() + ); + } + + private void incrementArticleReadCount(NewsArticle article) { + String date = NewsKey.extractDateFromPk(article.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, article.getArticleId()); + } + } + + private List updateStatsAndCheckBadges(String userId) { + try { + UserStats updatedStats = userStatsRepository.incrementNewsReadStats(userId); + if (updatedStats != null) { + List newBadges = badgeService.checkAndAwardBadges(userId, updatedStats); + if (!newBadges.isEmpty()) { + logger.info("새 배지 획득: userId={}, badges={}", + userId, newBadges.stream().map(UserBadge::getBadgeType).toList()); + } + return newBadges; + } + } catch (Exception e) { + logger.error("통계/배지 업데이트 실패: userId={}, error={}", userId, e.getMessage()); + } + return List.of(); + } + + private Map buildBookmarkResponse(NewsArticle article, UserNewsRecord bookmark) { + Map response = new HashMap<>(); + response.put("articleId", article.getArticleId()); + response.put("title", article.getTitle()); + response.put("summary", article.getSummary()); + response.put("source", article.getSource()); + response.put("publishedAt", article.getPublishedAt()); + response.put("keywords", article.getKeywords()); + response.put("highlightWords", article.getHighlightWords()); + response.put("category", article.getCategory()); + response.put("level", article.getLevel()); + response.put("imageUrl", article.getImageUrl()); + response.put("bookmarkedAt", bookmark.getCreatedAt()); + return response; + } + + private String buildTtsText(NewsArticle article) { + String text = article.getTitle() + ". " + (article.getSummary() != null ? article.getSummary() : ""); + if (text.length() > NewsConfig.TTS_MAX_TEXT_LENGTH) { + text = text.substring(0, NewsConfig.TTS_MAX_TEXT_LENGTH); } - return pk.substring(5); + return text; } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java index 5a3930ed..c1a0f328 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQueryService.java @@ -1,6 +1,7 @@ package com.mzc.secondproject.serverless.domain.news.service; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; +import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import org.slf4j.Logger; @@ -33,13 +34,7 @@ public Optional getArticle(String articleId) { logger.debug("뉴스 상세 조회: {}", articleId); Optional article = articleRepository.findById(articleId); - // 조회수 증가 - article.ifPresent(a -> { - String date = extractDateFromPk(a.getPk()); - if (date != null) { - articleRepository.incrementReadCount(date, articleId); - } - }); + article.ifPresent(this::incrementReadCount); return article; } @@ -82,17 +77,13 @@ public PaginatedResult getNewsByLevelAndCategory(String level, Stri */ public PaginatedResult getRecommendedNews(String userLevel, int limit, String cursor) { logger.debug("맞춤 뉴스 추천: userLevel={}, limit={}", userLevel, limit); - // 사용자 레벨에 맞는 뉴스 조회 return articleRepository.findByLevel(userLevel, limit, cursor); } - /** - * PK에서 날짜 추출 (NEWS#2024-01-15 → 2024-01-15) - */ - private String extractDateFromPk(String pk) { - if (pk == null || !pk.startsWith("NEWS#")) { - return null; + private void incrementReadCount(NewsArticle article) { + String date = NewsKey.extractDateFromPk(article.getPk()); + if (date != null) { + articleRepository.incrementReadCount(date, article.getArticleId()); } - return pk.substring(5); } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java index bb22fc90..f84c3794 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsQuizService.java @@ -1,9 +1,14 @@ package com.mzc.secondproject.serverless.domain.news.service; +import com.mzc.secondproject.serverless.domain.news.config.NewsConfig; import com.mzc.secondproject.serverless.domain.news.constants.NewsKey; -import com.mzc.secondproject.serverless.domain.news.model.*; +import com.mzc.secondproject.serverless.domain.news.model.NewsArticle; +import com.mzc.secondproject.serverless.domain.news.model.NewsQuizResult; +import com.mzc.secondproject.serverless.domain.news.model.QuizAnswerResult; +import com.mzc.secondproject.serverless.domain.news.model.QuizQuestion; import com.mzc.secondproject.serverless.domain.news.repository.NewsArticleRepository; import com.mzc.secondproject.serverless.domain.news.repository.NewsQuizRepository; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,22 +20,26 @@ * 뉴스 퀴즈 서비스 */ public class NewsQuizService { - + private static final Logger logger = LoggerFactory.getLogger(NewsQuizService.class); private final NewsArticleRepository articleRepository; private final NewsQuizRepository quizRepository; + private final NotificationPublisher notificationPublisher; public NewsQuizService() { this.articleRepository = new NewsArticleRepository(); this.quizRepository = new NewsQuizRepository(); + this.notificationPublisher = NotificationPublisher.getInstance(); } - public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository) { + public NewsQuizService(NewsArticleRepository articleRepository, NewsQuizRepository quizRepository, + NotificationPublisher notificationPublisher) { this.articleRepository = articleRepository; this.quizRepository = quizRepository; + this.notificationPublisher = notificationPublisher; } - + /** * 퀴즈 조회 */ @@ -40,18 +49,18 @@ public Optional getQuiz(String articleId, String userId) { logger.warn("기사를 찾을 수 없음: {}", articleId); return Optional.empty(); } - + NewsArticle article = articleOpt.get(); List questions = article.getQuiz(); - + if (questions == null || questions.isEmpty()) { logger.warn("퀴즈가 없는 기사: {}", articleId); return Optional.empty(); } - + // 이미 제출했는지 확인 boolean submitted = quizRepository.hasSubmitted(userId, articleId); - + // 정답 제거한 퀴즈 반환 List questionViews = questions.stream() .map(q -> QuizQuestionView.builder() @@ -62,7 +71,7 @@ public Optional getQuiz(String articleId, String userId) { .points(q.getPoints()) .build()) .toList(); - + return Optional.of(QuizData.builder() .articleId(articleId) .articleTitle(article.getTitle()) @@ -72,7 +81,7 @@ public Optional getQuiz(String articleId, String userId) { .submitted(submitted) .build()); } - + /** * 퀴즈 제출 및 채점 */ @@ -82,42 +91,42 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List articleOpt = articleRepository.findById(articleId); if (articleOpt.isEmpty()) { logger.warn("기사를 찾을 수 없음: {}", articleId); return null; } - + NewsArticle article = articleOpt.get(); List questions = article.getQuiz(); - + if (questions == null || questions.isEmpty()) { logger.warn("퀴즈가 없는 기사: {}", articleId); return null; } - + // 정답 맵 생성 Map questionMap = new HashMap<>(); for (QuizQuestion q : questions) { questionMap.put(q.getQuestionId(), q); } - + // 채점 List answerResults = new ArrayList<>(); int earnedPoints = 0; int totalPoints = 0; - + for (QuizAnswer answer : answers) { QuizQuestion question = questionMap.get(answer.questionId()); if (question == null) continue; - + boolean correct = question.getCorrectAnswer().equalsIgnoreCase(answer.answer()); int points = correct ? question.getPoints() : 0; earnedPoints += points; totalPoints += question.getPoints(); - + answerResults.add(QuizAnswerResult.builder() .questionId(answer.questionId()) .type(question.getType()) @@ -127,14 +136,14 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List 0 ? (earnedPoints * 100) / totalPoints : 0; - + // 결과 저장 String now = Instant.now().toString(); String today = LocalDate.now().toString(); - + NewsQuizResult result = NewsQuizResult.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.quizSk(articleId)) @@ -151,12 +160,25 @@ public QuizSubmitResult submitQuiz(String userId, String articleId, List getQuizResult(String userId, String articleId) { return quizRepository.findByUserAndArticle(userId, articleId); } - + /** * 사용자 퀴즈 기록 목록 조회 */ public List getUserQuizHistory(String userId, int limit) { return quizRepository.getUserQuizResults(userId, limit); } - + /** * 사용자 퀴즈 통계 조회 */ @@ -192,24 +214,14 @@ public Map getUserQuizStats(String userId) { "perfectScores", stats.perfectScores() ); } - + /** * 피드백 생성 */ - private String generateFeedback(int score, List results) { - if (score == 100) { - return "Perfect! You understood the article completely."; - } else if (score >= 80) { - return "Great job! You have a solid understanding of the article."; - } else if (score >= 60) { - return "Good effort! Review the highlighted words for better comprehension."; - } else if (score >= 40) { - return "Keep practicing! Try reading the article again before retaking the quiz."; - } else { - return "Don't give up! Focus on vocabulary and main ideas."; - } + private String generateFeedback(int score) { + return NewsConfig.getFeedbackByScore(score); } - + /** * 퀴즈 데이터 (정답 제외) */ @@ -225,7 +237,7 @@ public static class QuizData { private int totalPoints; private boolean submitted; } - + /** * 퀴즈 문제 뷰 (정답 제외) */ @@ -240,12 +252,13 @@ public static class QuizQuestionView { private List options; private int points; } - + /** * 사용자 답변 */ - public record QuizAnswer(String questionId, String answer) {} - + public record QuizAnswer(String questionId, String answer) { + } + /** * 퀴즈 제출 결과 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java index 6c3c23ec..b38ce3ca 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/NewsWordService.java @@ -20,33 +20,33 @@ * 뉴스 단어 수집 서비스 */ public class NewsWordService { - + private static final Logger logger = LoggerFactory.getLogger(NewsWordService.class); - + private final NewsWordRepository newsWordRepository; private final NewsArticleRepository articleRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + public NewsWordService() { this.newsWordRepository = new NewsWordRepository(); this.articleRepository = new NewsArticleRepository(); this.wordRepository = new WordRepository(); this.userWordCommandService = new UserWordCommandService(); } - + public NewsWordService(NewsWordRepository newsWordRepository, - NewsArticleRepository articleRepository, - WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + NewsArticleRepository articleRepository, + WordRepository wordRepository, + UserWordCommandService userWordCommandService) { this.newsWordRepository = newsWordRepository; this.articleRepository = articleRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; } - + /** - * 단어 수집 + * 단어 수집 (자동으로 Word 테이블 + UserWord에 추가) */ public NewsWordCollect collectWord(String userId, String articleId, String word, String context) { // 이미 수집했는지 확인 @@ -54,19 +54,58 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, logger.warn("이미 수집한 단어: userId={}, word={}", userId, word); return newsWordRepository.findByUserWordArticle(userId, word, articleId).orElse(null); } - + // 기사 조회 Optional articleOpt = articleRepository.findById(articleId); String articleTitle = articleOpt.map(NewsArticle::getTitle).orElse(""); - + String articleLevel = articleOpt.map(NewsArticle::getLevel).orElse("INTERMEDIATE"); + + // 기사 키워드에서 단어 정보 추출 + String meaningKo = ""; + String meaningEn = ""; + String example = ""; + if (articleOpt.isPresent() && articleOpt.get().getKeywords() != null) { + for (var keyword : articleOpt.get().getKeywords()) { + if (keyword.getWord() != null && keyword.getWord().equalsIgnoreCase(word)) { + meaningKo = keyword.getMeaningKo() != null ? keyword.getMeaningKo() : ""; + meaningEn = keyword.getMeaning() != null ? keyword.getMeaning() : ""; + example = keyword.getExample() != null ? keyword.getExample() : ""; + break; + } + } + } + // 단어 정보 조회 (Word 테이블에서) String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - String meaning = wordOpt.map(Word::getKorean).orElse(""); - String pronunciation = ""; - + String meaning = meaningKo; + + // Word 테이블에 없으면 자동 생성 + if (wordOpt.isEmpty() && !meaningKo.isEmpty()) { + String now = Instant.now().toString(); + Word newWord = Word.builder() + .pk("WORD#" + wordId) + .sk("METADATA") + .gsi1pk("LEVEL#" + articleLevel) + .gsi1sk("WORD#" + wordId) + .gsi2pk("CATEGORY#NEWS") + .gsi2sk("WORD#" + wordId) + .wordId(wordId) + .english(word) + .korean(meaningKo) + .example(example) + .level(articleLevel) + .category("NEWS") + .createdAt(now) + .build(); + wordRepository.save(newWord); + logger.info("Word 테이블에 단어 자동 추가: wordId={}, korean={}", wordId, meaningKo); + } else if (wordOpt.isPresent()) { + meaning = wordOpt.get().getKorean(); + } + String now = Instant.now().toString(); - + NewsWordCollect wordCollect = NewsWordCollect.builder() .pk(NewsKey.userNewsPk(userId)) .sk(NewsKey.wordSk(word, articleId)) @@ -75,20 +114,29 @@ public NewsWordCollect collectWord(String userId, String articleId, String word, .userId(userId) .word(word) .meaning(meaning) - .pronunciation(pronunciation) + .pronunciation("") .context(context) .articleId(articleId) .articleTitle(articleTitle) .collectedAt(now) - .syncedToVocab(false) + .syncedToVocab(true) // 자동 연동됨 + .vocabUserWordId(wordId) .build(); - + newsWordRepository.save(wordCollect); logger.info("단어 수집 완료: userId={}, word={}, articleId={}", userId, word, articleId); - + + // UserWord에 자동 추가 (NEW 상태로) + try { + userWordCommandService.updateWordStatus(userId, wordId, "NEW"); + logger.info("UserWord에 자동 추가: userId={}, wordId={}", userId, wordId); + } catch (Exception e) { + logger.warn("UserWord 추가 실패 (이미 존재할 수 있음): userId={}, wordId={}, error={}", userId, wordId, e.getMessage()); + } + return wordCollect; } - + /** * 수집한 단어 삭제 */ @@ -96,32 +144,32 @@ public void deleteWord(String userId, String word, String articleId) { newsWordRepository.delete(userId, word, articleId); logger.info("단어 삭제: userId={}, word={}", userId, word); } - + /** * 사용자 수집 단어 목록 조회 */ public List getUserWords(String userId, int limit) { return newsWordRepository.getUserWords(userId, limit); } - + /** * 사용자 수집 단어 수 조회 */ public int countUserWords(String userId) { return newsWordRepository.countUserWords(userId); } - + /** * 단어 상세 정보 조회 */ public Optional getWordDetail(String word) { String wordId = word.toLowerCase().trim(); Optional wordOpt = wordRepository.findById(wordId); - + if (wordOpt.isEmpty()) { return Optional.empty(); } - + Word w = wordOpt.get(); return Optional.of(WordDetail.builder() .word(w.getEnglish()) @@ -131,7 +179,7 @@ public Optional getWordDetail(String word) { .level(w.getLevel()) .build()); } - + /** * Vocabulary 도메인으로 단어 연동 */ @@ -141,34 +189,34 @@ public boolean syncToVocabulary(String userId, String word, String articleId) { logger.warn("수집한 단어를 찾을 수 없음: userId={}, word={}", userId, word); return false; } - + NewsWordCollect wordCollect = wordOpt.get(); - + // 이미 연동됐는지 확인 if (Boolean.TRUE.equals(wordCollect.getSyncedToVocab())) { logger.info("이미 Vocabulary에 연동됨: userId={}, word={}", userId, word); return true; } - + // Word 테이블에서 단어 조회 String wordId = word.toLowerCase().trim(); Optional vocabWord = wordRepository.findById(wordId); - + if (vocabWord.isEmpty()) { logger.warn("Vocabulary에 없는 단어: {}", word); return false; } - + // UserWord 생성 (NEW 상태로) userWordCommandService.updateWordStatus(userId, wordId, "NEW"); - + // 연동 상태 업데이트 newsWordRepository.updateSyncStatus(userId, word, articleId, wordId); - + logger.info("Vocabulary 연동 완료: userId={}, word={}", userId, word); return true; } - + /** * 사용자 단어 수집 통계 */ @@ -178,14 +226,14 @@ public Map getUserWordStats(String userId) { long syncedCount = recentWords.stream() .filter(w -> Boolean.TRUE.equals(w.getSyncedToVocab())) .count(); - + return Map.of( "totalCollected", totalWords, "recentWords", recentWords, "syncedToVocab", syncedCount ); } - + /** * 단어 상세 정보 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java index ca2c98b8..bc7facd1 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/news/service/RssFeedParser.java @@ -24,34 +24,34 @@ * BBC, VOA, NPR 등의 RSS 피드에서 뉴스 수집 */ public class RssFeedParser { - + private static final Logger logger = LoggerFactory.getLogger(RssFeedParser.class); - + private static final Map RSS_FEEDS = Map.of( "BBC", "https://feeds.bbci.co.uk/news/world/rss.xml", "VOA", "https://www.voanews.com/api/ziqpoe-mqm", "NPR", "https://feeds.npr.org/1001/rss.xml" ); - + private final HttpClient httpClient; - + public RssFeedParser() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build(); } - + /** * 모든 RSS 피드에서 뉴스 수집 */ public List fetchAllFeeds(int maxPerSource) { List allArticles = new ArrayList<>(); - + for (Map.Entry entry : RSS_FEEDS.entrySet()) { String source = entry.getKey(); String feedUrl = entry.getValue(); - + try { List articles = fetchFeed(feedUrl, source, maxPerSource); allArticles.addAll(articles); @@ -60,16 +60,16 @@ public List fetchAllFeeds(int maxPerSource) { logger.error("{} RSS 피드 수집 실패: {}", source, e.getMessage()); } } - + return allArticles; } - + /** * 특정 RSS 피드에서 뉴스 수집 */ public List fetchFeed(String feedUrl, String source, int maxItems) { List articles = new ArrayList<>(); - + try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(feedUrl)) @@ -77,22 +77,22 @@ public List fetchFeed(String feedUrl, String source, int maxItem .timeout(Duration.ofSeconds(30)) .GET() .build(); - + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); - + if (response.statusCode() != 200) { logger.error("RSS 피드 요청 실패 - url: {}, status: {}", feedUrl, response.statusCode()); return articles; } - + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); DocumentBuilder builder = factory.newDocumentBuilder(); Document document = builder.parse(response.body()); - + NodeList items = document.getElementsByTagName("item"); int count = Math.min(items.getLength(), maxItems); - + for (int i = 0; i < count; i++) { Element item = (Element) items.item(i); RawNewsArticle article = parseRssItem(item, source); @@ -100,14 +100,14 @@ public List fetchFeed(String feedUrl, String source, int maxItem articles.add(article); } } - + } catch (Exception e) { logger.error("RSS 피드 파싱 중 오류 발생 - url: {}", feedUrl, e); } - + return articles; } - + /** * RSS item 요소를 RawNewsArticle로 변환 */ @@ -121,7 +121,7 @@ private RawNewsArticle parseRssItem(Element item, String source) { .publishedAt(parsePublishedDate(getElementText(item, "pubDate"))) .build(); } - + /** * 요소에서 텍스트 추출 */ @@ -132,7 +132,7 @@ private String getElementText(Element parent, String tagName) { } return null; } - + /** * 이미지 URL 추출 (media:content, enclosure 등) */ @@ -142,7 +142,7 @@ private String extractImageUrl(Element item) { Element media = (Element) mediaContent.item(0); return media.getAttribute("url"); } - + NodeList enclosure = item.getElementsByTagName("enclosure"); if (enclosure.getLength() > 0) { Element enc = (Element) enclosure.item(0); @@ -151,16 +151,16 @@ private String extractImageUrl(Element item) { return enc.getAttribute("url"); } } - + NodeList mediaThumbnail = item.getElementsByTagName("media:thumbnail"); if (mediaThumbnail.getLength() > 0) { Element thumbnail = (Element) mediaThumbnail.item(0); return thumbnail.getAttribute("url"); } - + return null; } - + /** * RSS pubDate를 ISO 8601 형식으로 변환 */ @@ -170,7 +170,7 @@ private String parsePublishedDate(String pubDate) { } return pubDate; } - + /** * HTML 태그 제거 */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java new file mode 100644 index 00000000..9bcbbb30 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfig.java @@ -0,0 +1,51 @@ +package com.mzc.secondproject.serverless.domain.notification.config; + +import com.mzc.secondproject.serverless.common.config.EnvConfig; + +/** + * 알림 시스템 설정 + * SSE 스트리밍, 폴링 등 알림 관련 상수 정의 + */ +public final class NotificationConfig { + + private NotificationConfig() { + } + + // ========== Environment Variables ========== + private static final String TOPIC_ARN = EnvConfig.get("NOTIFICATION_TOPIC_ARN"); + private static final String QUEUE_URL = EnvConfig.get("NOTIFICATION_QUEUE_URL"); + + // ========== SSE Streaming ========== + /** SSE 폴링 간격 (밀리초) */ + public static final int SSE_POLL_INTERVAL_MS = 1000; + + /** SSE 최대 스트림 지속 시간 (밀리초) - Lambda 15분 제한 고려 */ + public static final int SSE_MAX_DURATION_MS = 840_000; // 14분 + + /** SSE 최대 메시지 수신 개수 */ + public static final int SSE_MAX_MESSAGES_PER_POLL = 10; + + /** SSE 롱 폴링 대기 시간 (초) */ + public static final int SSE_WAIT_TIME_SECONDS = 1; + + // ========== SSE Event Types ========== + public static final String EVENT_HEARTBEAT = "HEARTBEAT"; + public static final String EVENT_STREAM_END = "STREAM_END"; + + // ========== Getter Methods ========== + public static String topicArn() { + return TOPIC_ARN; + } + + public static String queueUrl() { + return QUEUE_URL; + } + + public static boolean isTopicConfigured() { + return TOPIC_ARN != null && !TOPIC_ARN.isBlank(); + } + + public static boolean isQueueConfigured() { + return QUEUE_URL != null && !QUEUE_URL.isBlank(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java new file mode 100644 index 00000000..cb073bd9 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessage.java @@ -0,0 +1,60 @@ +package com.mzc.secondproject.serverless.domain.notification.dto; + +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; + +import java.time.Instant; +import java.util.Map; + +/** + * 알림 메시지 DTO + * SNS로 발행되고 SSE로 클라이언트에 전달되는 메시지 구조 + */ +public record NotificationMessage( + String notificationId, + NotificationType type, + String userId, + Map payload, + String createdAt +) { + /** + * Builder 패턴으로 알림 메시지 생성 + */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private NotificationType type; + private String userId; + private Map payload; + + public Builder type(NotificationType type) { + this.type = type; + return this; + } + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + public Builder payload(Map payload) { + this.payload = payload; + return this; + } + + public NotificationMessage build() { + return new NotificationMessage( + generateNotificationId(), + type, + userId, + payload, + Instant.now().toString() + ); + } + + private String generateNotificationId() { + return "notif-" + java.util.UUID.randomUUID().toString().substring(0, 8); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java new file mode 100644 index 00000000..87cf3e8c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/enums/NotificationType.java @@ -0,0 +1,41 @@ +package com.mzc.secondproject.serverless.domain.notification.enums; + +/** + * 알림 타입 정의 + * 새로운 알림 타입 추가 시 여기에 enum 추가 + */ +public enum NotificationType { + // 배지 관련 + BADGE_EARNED("배지 획득", "badge"), + + // 학습 관련 + DAILY_COMPLETE("일일 학습 완료", "daily"), + STREAK_REMINDER("연속 학습 리마인더", "streak"), + + // 테스트/퀴즈 관련 + TEST_COMPLETE("테스트 완료", "test"), + NEWS_QUIZ_COMPLETE("뉴스 퀴즈 완료", "quiz"), + + // 게임 관련 + GAME_END("게임 종료", "game"), + GAME_STREAK("게임 연속 정답", "game"), + + // OPIc 관련 + OPIC_COMPLETE("OPIc 세션 완료", "opic"); + + private final String description; + private final String category; + + NotificationType(String description, String category) { + this.description = description; + this.category = category; + } + + public String getDescription() { + return description; + } + + public String getCategory() { + return category; + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java new file mode 100644 index 00000000..385ddafb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/NotificationStreamHandler.java @@ -0,0 +1,203 @@ +package com.mzc.secondproject.serverless.domain.notification.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestStreamHandler; +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.config.NotificationConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sqs.SqsClient; +import software.amazon.awssdk.services.sqs.model.DeleteMessageRequest; +import software.amazon.awssdk.services.sqs.model.Message; +import software.amazon.awssdk.services.sqs.model.ReceiveMessageRequest; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * SSE(Server-Sent Events) 알림 스트리밍 Lambda Handler + * Lambda Function URL with Response Streaming을 사용하여 실시간 알림 제공 + * + * 클라이언트 연결 예시: + * const eventSource = new EventSource('https://{function-url}/?userId={userId}'); + * eventSource.onmessage = (event) => console.log(JSON.parse(event.data)); + */ +public class NotificationStreamHandler implements RequestStreamHandler { + + private static final Logger logger = LoggerFactory.getLogger(NotificationStreamHandler.class); + + private final SqsClient sqsClient; + + public NotificationStreamHandler() { + this.sqsClient = AwsClients.sqs(); + } + + public NotificationStreamHandler(SqsClient sqsClient) { + this.sqsClient = sqsClient; + } + + @Override + public void handleRequest(InputStream input, OutputStream output, Context context) throws IOException { + Map event = parseEvent(input); + String userId = extractUserId(event); + + if (userId == null || userId.isBlank()) { + sendErrorResponse(output, 400, "userId query parameter is required"); + return; + } + + logger.info("SSE connection started: userId={}, requestId={}", userId, context.getAwsRequestId()); + + try (BufferedOutputStream bufferedOutput = new BufferedOutputStream(output)) { + streamNotifications(bufferedOutput, userId); + } catch (Exception e) { + logger.error("SSE stream error: userId={}", userId, e); + } + } + + private void streamNotifications(BufferedOutputStream output, String userId) throws IOException { + writeSSEHeaders(output); + sendHeartbeat(output); + + long startTime = System.currentTimeMillis(); + + while (!isTimeoutReached(startTime)) { + List messages = pollMessages(); + + for (Message message : messages) { + if (isMessageForUser(message, userId)) { + sendSSEEvent(output, message.body()); + deleteMessage(message); + } + } + + if (messages.isEmpty()) { + sendHeartbeat(output); + } + + sleep(); + } + + sendStreamEndEvent(output); + logger.info("SSE connection ended: userId={} (timeout)", userId); + } + + private Map parseEvent(InputStream input) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return JsonUtil.fromJson(sb.toString(), Map.class); + } + } + + @SuppressWarnings("unchecked") + private String extractUserId(Map event) { + Object queryParams = event.get("queryStringParameters"); + if (queryParams instanceof Map) { + Object userId = ((Map) queryParams).get("userId"); + return userId != null ? userId.toString() : null; + } + return null; + } + + private void writeSSEHeaders(OutputStream output) throws IOException { + String headers = "HTTP/1.1 200 OK\r\n" + + "Content-Type: text/event-stream\r\n" + + "Cache-Control: no-cache\r\n" + + "Connection: keep-alive\r\n" + + "Access-Control-Allow-Origin: *\r\n" + + "\r\n"; + output.write(headers.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private void sendSSEEvent(OutputStream output, String data) throws IOException { + String event = "data: " + data + "\n\n"; + output.write(event.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private void sendHeartbeat(OutputStream output) throws IOException { + String heartbeat = JsonUtil.toJson(Map.of( + "type", NotificationConfig.EVENT_HEARTBEAT, + "timestamp", System.currentTimeMillis() + )); + sendSSEEvent(output, heartbeat); + } + + private void sendStreamEndEvent(OutputStream output) throws IOException { + String endEvent = JsonUtil.toJson(Map.of( + "type", NotificationConfig.EVENT_STREAM_END, + "message", "Connection timeout" + )); + sendSSEEvent(output, endEvent); + } + + private void sendErrorResponse(OutputStream output, int statusCode, String message) throws IOException { + String response = JsonUtil.toJson(Map.of( + "statusCode", statusCode, + "body", JsonUtil.toJson(Map.of("error", message)) + )); + output.write(response.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } + + private List pollMessages() { + if (!NotificationConfig.isQueueConfigured()) { + return List.of(); + } + + try { + ReceiveMessageRequest request = ReceiveMessageRequest.builder() + .queueUrl(NotificationConfig.queueUrl()) + .maxNumberOfMessages(NotificationConfig.SSE_MAX_MESSAGES_PER_POLL) + .waitTimeSeconds(NotificationConfig.SSE_WAIT_TIME_SECONDS) + .messageAttributeNames("userId", "type") + .build(); + + return sqsClient.receiveMessage(request).messages(); + } catch (Exception e) { + logger.warn("Failed to poll messages: {}", e.getMessage()); + return List.of(); + } + } + + private boolean isMessageForUser(Message message, String targetUserId) { + try { + Map body = JsonUtil.fromJson(message.body(), Map.class); + String messageUserId = (String) body.get("userId"); + return targetUserId.equals(messageUserId); + } catch (Exception e) { + return false; + } + } + + private void deleteMessage(Message message) { + try { + sqsClient.deleteMessage(DeleteMessageRequest.builder() + .queueUrl(NotificationConfig.queueUrl()) + .receiptHandle(message.receiptHandle()) + .build()); + } catch (Exception e) { + logger.warn("Failed to delete message: {}", e.getMessage()); + } + } + + private boolean isTimeoutReached(long startTime) { + return (System.currentTimeMillis() - startTime) > NotificationConfig.SSE_MAX_DURATION_MS; + } + + private void sleep() { + try { + Thread.sleep(NotificationConfig.SSE_POLL_INTERVAL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java new file mode 100644 index 00000000..d1cbd30c --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/handler/StreakReminderHandler.java @@ -0,0 +1,123 @@ +package com.mzc.secondproject.serverless.domain.notification.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.ScheduledEvent; +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; +import com.mzc.secondproject.serverless.domain.stats.model.UserStats; +import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; +import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; +import com.mzc.secondproject.serverless.domain.vocabulary.repository.DailyStudyRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 연속 학습 리마인더 Lambda Handler + * EventBridge 스케줄러에 의해 매일 21시(KST)에 트리거 + * 오늘 학습하지 않은 사용자 중 연속 학습 중인 사용자에게 알림 발송 + */ +public class StreakReminderHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(StreakReminderHandler.class); + + private final DailyStudyRepository dailyStudyRepository; + private final UserStatsRepository userStatsRepository; + private final NotificationPublisher notificationPublisher; + + public StreakReminderHandler() { + this.dailyStudyRepository = new DailyStudyRepository(); + this.userStatsRepository = new UserStatsRepository(); + this.notificationPublisher = NotificationPublisher.getInstance(); + } + + public StreakReminderHandler(DailyStudyRepository dailyStudyRepository, + UserStatsRepository userStatsRepository, + NotificationPublisher notificationPublisher) { + this.dailyStudyRepository = dailyStudyRepository; + this.userStatsRepository = userStatsRepository; + this.notificationPublisher = notificationPublisher; + } + + @Override + public Response handleRequest(ScheduledEvent event, Context context) { + logger.info("Streak reminder started: requestId={}", context.getAwsRequestId()); + + try { + int remindersSent = processReminders(); + logger.info("Streak reminder completed: sent={}", remindersSent); + return Response.success(remindersSent); + } catch (Exception e) { + logger.error("Streak reminder failed", e); + return Response.error(e.getMessage()); + } + } + + private int processReminders() { + String today = LocalDate.now().toString(); + + Set studiedUserIds = findStudiedUserIds(today); + List usersWithStreak = userStatsRepository.findUsersWithActiveStreak(); + + int remindersSent = 0; + for (UserStats stats : usersWithStreak) { + if (shouldSendReminder(stats, studiedUserIds)) { + sendReminder(stats); + remindersSent++; + } + } + + return remindersSent; + } + + private Set findStudiedUserIds(String date) { + return dailyStudyRepository.findByDate(date).stream() + .filter(ds -> Boolean.TRUE.equals(ds.getIsCompleted())) + .map(DailyStudy::getUserId) + .collect(Collectors.toSet()); + } + + private boolean shouldSendReminder(UserStats stats, Set studiedUserIds) { + if (studiedUserIds.contains(stats.getUserId())) { + return false; + } + Integer streak = stats.getCurrentStreak(); + return streak != null && streak > 0; + } + + private void sendReminder(UserStats stats) { + String userId = stats.getUserId(); + int streak = stats.getCurrentStreak(); + + notificationPublisher.publish( + NotificationType.STREAK_REMINDER, + userId, + Map.of( + "currentStreak", streak, + "message", String.format("%d일 연속 학습 중! 오늘도 학습해서 기록을 이어가세요.", streak) + ) + ); + + logger.debug("Streak reminder sent: userId={}, streak={}", userId, streak); + } + + /** + * Lambda 응답 DTO + */ + public record Response(int statusCode, String message, int remindersSent) { + + public static Response success(int remindersSent) { + return new Response(200, "Streak reminders sent", remindersSent); + } + + public static Response error(String errorMessage) { + return new Response(500, "Streak reminder failed: " + errorMessage, 0); + } + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java new file mode 100644 index 00000000..53ee0ad0 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/notification/service/NotificationPublisher.java @@ -0,0 +1,201 @@ +package com.mzc.secondproject.serverless.domain.notification.service; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.util.JsonUtil; +import com.mzc.secondproject.serverless.domain.notification.config.NotificationConfig; +import com.mzc.secondproject.serverless.domain.notification.dto.NotificationMessage; +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.sns.SnsClient; +import software.amazon.awssdk.services.sns.model.MessageAttributeValue; +import software.amazon.awssdk.services.sns.model.PublishRequest; +import software.amazon.awssdk.services.sns.model.PublishResponse; + +import java.util.Map; + +/** + * 알림 발행 서비스 + * SNS 토픽에 알림 메시지를 발행하는 역할 + * + * 사용 예시: + *
+ * NotificationPublisher.getInstance().publish(
+ *     NotificationType.BADGE_EARNED,
+ *     userId,
+ *     Map.of("badgeType", "STREAK_7", "badgeName", "7일 연속 학습")
+ * );
+ * 
+ */ +public class NotificationPublisher { + + private static final Logger logger = LoggerFactory.getLogger(NotificationPublisher.class); + + private static volatile NotificationPublisher instance; + private final SnsClient snsClient; + + private NotificationPublisher() { + this.snsClient = AwsClients.sns(); + } + + private NotificationPublisher(SnsClient snsClient) { + this.snsClient = snsClient; + } + + /** + * 싱글톤 인스턴스 반환 + */ + public static NotificationPublisher getInstance() { + if (instance == null) { + synchronized (NotificationPublisher.class) { + if (instance == null) { + instance = new NotificationPublisher(); + } + } + } + return instance; + } + + /** + * 테스트용 인스턴스 생성 + */ + public static NotificationPublisher createForTest(SnsClient snsClient) { + return new NotificationPublisher(snsClient); + } + + /** + * 알림 발행 (비동기, non-blocking) + * 발행 실패 시에도 호출자의 비즈니스 로직에 영향을 주지 않음 + * + * @param type 알림 타입 + * @param userId 대상 사용자 ID + * @param payload 알림 페이로드 + */ + public void publish(NotificationType type, String userId, Map payload) { + if (!NotificationConfig.isTopicConfigured()) { + logger.warn("NOTIFICATION_TOPIC_ARN is not configured. Skipping notification."); + return; + } + + try { + NotificationMessage message = NotificationMessage.builder() + .type(type) + .userId(userId) + .payload(payload) + .build(); + + String messageJson = JsonUtil.toJson(message); + + PublishRequest request = PublishRequest.builder() + .topicArn(NotificationConfig.topicArn()) + .message(messageJson) + .messageAttributes(Map.of( + "type", MessageAttributeValue.builder() + .dataType("String") + .stringValue(type.name()) + .build(), + "userId", MessageAttributeValue.builder() + .dataType("String") + .stringValue(userId) + .build(), + "category", MessageAttributeValue.builder() + .dataType("String") + .stringValue(type.getCategory()) + .build() + )) + .build(); + + PublishResponse response = snsClient.publish(request); + logger.info("Notification published: type={}, userId={}, messageId={}", + type, userId, response.messageId()); + + } catch (Exception e) { + // 알림 발행 실패는 비즈니스 로직에 영향을 주지 않도록 로깅만 수행 + logger.error("Failed to publish notification: type={}, userId={}, error={}", + type, userId, e.getMessage()); + } + } + + /** + * 배지 획득 알림 발행 헬퍼 메서드 + */ + public void publishBadgeEarned(String userId, String badgeType, String badgeName, + String description, String iconUrl) { + publish(NotificationType.BADGE_EARNED, userId, Map.of( + "badgeType", badgeType, + "badgeName", badgeName, + "description", description, + "iconUrl", iconUrl != null ? iconUrl : "" + )); + } + + /** + * 일일 학습 완료 알림 발행 헬퍼 메서드 + */ + public void publishDailyComplete(String userId, String date, int wordsLearned, + int totalWords, int currentStreak) { + publish(NotificationType.DAILY_COMPLETE, userId, Map.of( + "date", date, + "wordsLearned", wordsLearned, + "totalWords", totalWords, + "currentStreak", currentStreak + )); + } + + /** + * 테스트 완료 알림 발행 헬퍼 메서드 + */ + public void publishTestComplete(String userId, String testId, int score, + int correctCount, int totalCount, boolean isPerfect) { + publish(NotificationType.TEST_COMPLETE, userId, Map.of( + "testId", testId, + "score", score, + "correctCount", correctCount, + "totalCount", totalCount, + "isPerfect", isPerfect + )); + } + + /** + * 뉴스 퀴즈 완료 알림 발행 헬퍼 메서드 + */ + public void publishNewsQuizComplete(String userId, String articleId, String articleTitle, + int score, int correctCount, int totalCount, boolean isPerfect) { + publish(NotificationType.NEWS_QUIZ_COMPLETE, userId, Map.of( + "articleId", articleId, + "articleTitle", articleTitle, + "score", score, + "correctCount", correctCount, + "totalCount", totalCount, + "isPerfect", isPerfect + )); + } + + /** + * 게임 종료 알림 발행 헬퍼 메서드 + */ + public void publishGameEnd(String userId, String roomId, String gameSessionId, + int rank, int totalPlayers, int score, boolean isWinner) { + publish(NotificationType.GAME_END, userId, Map.of( + "roomId", roomId, + "gameSessionId", gameSessionId, + "rank", rank, + "totalPlayers", totalPlayers, + "score", score, + "isWinner", isWinner + )); + } + + /** + * OPIc 세션 완료 알림 발행 헬퍼 메서드 + */ + public void publishOpicComplete(String userId, String sessionId, String estimatedLevel, + int questionsAnswered, String feedbackSummary) { + publish(NotificationType.OPIC_COMPLETE, userId, Map.of( + "sessionId", sessionId, + "estimatedLevel", estimatedLevel, + "questionsAnswered", questionsAnswered, + "feedbackSummary", feedbackSummary != null ? feedbackSummary : "" + )); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java new file mode 100644 index 00000000..3152beaf --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/ResetRequest.java @@ -0,0 +1,12 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * 대화 초기화 요청 DTO + */ +public record ResetRequest( + String sessionId +) { + public boolean isValid() { + return sessionId != null && !sessionId.isEmpty(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java new file mode 100644 index 00000000..f75ec1bb --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/request/SpeakingRequest.java @@ -0,0 +1,32 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.request; + +/** + * Speaking API 요청 DTO + */ +public record SpeakingRequest( + String sessionId, // 세션 ID (첫 요청 시 null) + String audio, // 음성 데이터 (base64) + String text, // 텍스트 입력 + String level // 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) +) { + /** + * 기본값 적용된 레벨 반환 + */ + public String getLevelOrDefault() { + return level != null && !level.isEmpty() ? level : "INTERMEDIATE"; + } + + /** + * 음성 입력인지 확인 + */ + public boolean hasAudio() { + return audio != null && !audio.isEmpty(); + } + + /** + * 텍스트 입력인지 확인 + */ + public boolean hasText() { + return text != null && !text.trim().isEmpty(); + } +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java new file mode 100644 index 00000000..21cec696 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/dto/response/SpeakingResponse.java @@ -0,0 +1,13 @@ +package com.mzc.secondproject.serverless.domain.speaking.dto.response; + +/** + * Speaking API 응답 DTO + */ +public record SpeakingResponse( + String sessionId, // 세션 ID (다음 요청에 사용) + String userTranscript, // 사용자가 말한 내용 (STT 결과) + String aiText, // AI 응답 텍스트 + String aiAudioUrl, // AI 응답 음성 URL (Polly) + double confidence // STT 신뢰도 +) { +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java new file mode 100644 index 00000000..2ebda605 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/SpeakingHandler.java @@ -0,0 +1,163 @@ +package com.mzc.secondproject.serverless.domain.speaking.handler; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyRequestEvent; +import com.amazonaws.services.lambda.runtime.events.APIGatewayProxyResponseEvent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.mzc.secondproject.serverless.common.util.JwtUtil; +import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; +import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.Optional; + +/** + * Speaking API 핸들러 + * + * POST /api/speaking/chat - 대화 (음성 또는 텍스트) + * POST /api/speaking/reset - 대화 초기화 + */ +public class SpeakingHandler implements RequestHandler { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingHandler.class); + private static final Gson gson = new GsonBuilder().create(); + + private static final Map CORS_HEADERS = Map.of( + "Content-Type", "application/json", + "Access-Control-Allow-Origin", "*", + "Access-Control-Allow-Headers", "Content-Type,Authorization", + "Access-Control-Allow-Methods", "POST,OPTIONS" + ); + + private final SpeakingService speakingService; + + public SpeakingHandler() { + this.speakingService = new SpeakingService(); + } + + @Override + public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent event, Context context) { + logger.info("Speaking API request received"); + + // OPTIONS 요청 처리 (CORS preflight) + if ("OPTIONS".equalsIgnoreCase(event.getHttpMethod())) { + return response(200, Map.of("message", "OK")); + } + + try { + // 사용자 인증 정보 추출 (Cognito Authorizer -> requestContext) + if (event.getRequestContext() == null || event.getRequestContext().getAuthorizer() == null) { + logger.error("No Authorizer found in request context"); + return response(401, Map.of("error", "Unauthorized: User context missing")); + } + + Map authorizer = event.getRequestContext().getAuthorizer(); + Map claims = (Map) authorizer.get("claims"); + + if (claims == null) { + return response(401, Map.of("error", "Unauthorized: Claims missing")); + } + + String userId = (String) claims.get("sub"); // Cognito User Pool의 고유 ID (UUID 형태) + + // 요청 정보 추출 + String path = event.getPath(); + String body = event.getBody(); + + logger.info("Processing request: path={}, userId={}", path, userId); + + // 라우팅 + if (path != null && path.endsWith("/chat")) { + return handleChat(userId, body); + } else if (path != null && path.endsWith("/reset")) { + return handleReset(userId, body); + } else { + return response(404, Map.of("error", "Not found")); + } + + } catch (Exception e) { + logger.error("Error processing request: {}", e.getMessage(), e); + return response(500, Map.of("error", "Internal server error: " + e.getMessage())); + } + } + + /** + * 대화 처리 (음성 또는 텍스트) + */ + private APIGatewayProxyResponseEvent handleChat(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + + String sessionId = request.has("sessionId") && !request.get("sessionId").isJsonNull() + ? request.get("sessionId").getAsString() : null; + String level = request.has("level") && !request.get("level").isJsonNull() + ? request.get("level").getAsString() : "INTERMEDIATE"; + String audio = request.has("audio") && !request.get("audio").isJsonNull() + ? request.get("audio").getAsString() : null; + String text = request.has("text") && !request.get("text").isJsonNull() + ? request.get("text").getAsString() : null; + + SpeakingResponse result; + + if (audio != null && !audio.isEmpty()) { + // 음성 입력 처리 + logger.info("Processing voice event"); + result = speakingService.processVoiceInput(sessionId, userId, audio, level); + } else if (text != null && !text.trim().isEmpty()) { + // 텍스트 입력 처리 + logger.info("Processing text event: {}", text); + result = speakingService.processTextInput(sessionId, userId, text.trim(), level); + } else { + return response(400, Map.of("error", "Either 'audio' or 'text' is required")); + } + + return response(200, Map.of( + "sessionId", result.sessionId(), + "userTranscript", result.userTranscript(), + "aiText", result.aiText(), + "aiAudioUrl", result.aiAudioUrl(), + "confidence", result.confidence() + )); + } + + /** + * 대화 초기화 + */ + private APIGatewayProxyResponseEvent handleReset(String userId, String body) { + if (body == null || body.isEmpty()) { + return response(400, Map.of("error", "Request body is required")); + } + + JsonObject request = JsonParser.parseString(body).getAsJsonObject(); + String sessionId = request.has("sessionId") ? request.get("sessionId").getAsString() : null; + + if (sessionId == null || sessionId.isEmpty()) { + return response(400, Map.of("error", "sessionId is required")); + } + + speakingService.resetConversation(sessionId); + + return response(200, Map.of( + "message", "Conversation reset successfully", + "sessionId", sessionId + )); + } + + private APIGatewayProxyResponseEvent response(int statusCode, Map body) { + return new APIGatewayProxyResponseEvent() + .withStatusCode(statusCode) + .withHeaders(CORS_HEADERS) + .withBody(gson.toJson(body)); + } + + +} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java deleted file mode 100644 index 256d3990..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingConnectHandler.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.config.WebSocketConfig; -import com.mzc.secondproject.serverless.common.util.JwtUtil; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.Optional; - -/** - * Speaking WebSocket $connect 핸들러 - * JWT 토큰 검증 후 연결 정보를 DynamoDB에 저장 - *

- * 연결 방법: - * wss://{api-id}.execute-api.{region}.amazonaws.com/{stage}?token={jwt} - */ -public class SpeakingConnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingConnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket connect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - Map queryParams = WebSocketEventUtil.extractQueryStringParameters(event); - - // JWT 토큰 검증 - String token = queryParams.get("token"); - - if (token == null || token.isEmpty()) { - logger.warn("Missing token parameter"); - return WebSocketEventUtil.unauthorized("token is required"); - } - - // 토큰 유효성 검사 - if (!JwtUtil.isValid(token)) { - logger.warn("Invalid or expired token"); - return WebSocketEventUtil.unauthorized("Invalid or expired token"); - } - - // userId 추출 - Optional userIdOpt = JwtUtil.extractUserId(token); - if (userIdOpt.isEmpty()) { - logger.warn("Failed to extract userId from token"); - return WebSocketEventUtil.unauthorized("Invalid token"); - } - - String userId = userIdOpt.get(); - - // 연결 정보 저장 - SpeakingConnection connection = SpeakingConnection.create( - connectionId, - userId, - WebSocketConfig.connectionTtlSeconds() - ); - - // 레벨 파라미터가 있으면 설정 - String level = queryParams.get("level"); - if (level != null && !level.isEmpty()) { - connection.setTargetLevel(level.toUpperCase()); - } - - connectionRepository.save(connection); - - logger.info("Speaking connection established: connectionId={}, userId={}, level={}", - connectionId, userId, connection.getTargetLevel()); - return WebSocketEventUtil.ok("Connected"); - - } catch (Exception e) { - logger.error("Error handling connect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java deleted file mode 100644 index bd46d3b4..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingDisconnectHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; - -/** - * Speaking WebSocket $disconnect 핸들러 - * 연결 해제 시 DynamoDB에서 연결 정보 삭제 - */ -public class SpeakingDisconnectHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingDisconnectHandler.class); - - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingDisconnectHandler() { - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking WebSocket disconnect event"); - - try { - String connectionId = WebSocketEventUtil.extractConnectionId(event); - - // 연결 정보 삭제 - connectionRepository.delete(connectionId); - - logger.info("Speaking connection closed: connectionId={}", connectionId); - return WebSocketEventUtil.ok("Disconnected"); - - } catch (Exception e) { - logger.error("Error handling disconnect: {}", e.getMessage(), e); - return WebSocketEventUtil.serverError("Internal server error"); - } - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java deleted file mode 100644 index 24ecfc3c..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/handler/websocket/SpeakingMessageHandler.java +++ /dev/null @@ -1,217 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.handler.websocket; - -import com.amazonaws.services.lambda.runtime.Context; -import com.amazonaws.services.lambda.runtime.RequestHandler; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.mzc.secondproject.serverless.common.util.WebSocketEventUtil; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; -import com.mzc.secondproject.serverless.domain.speaking.service.SpeakingService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.core.SdkBytes; -import software.amazon.awssdk.services.apigatewaymanagementapi.ApiGatewayManagementApiClient; -import software.amazon.awssdk.services.apigatewaymanagementapi.model.PostToConnectionRequest; - -import java.net.URI; -import java.util.Map; - -/** - * Speaking WebSocket 메시지 핸들러 - *

- * 지원하는 action: - * - speak: 음성 입력 처리 (audio base64) - * - text: 텍스트 입력 처리 - * - setLevel: 레벨 변경 - * - reset: 대화 히스토리 초기화 - */ -public class SpeakingMessageHandler implements RequestHandler, Map> { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingMessageHandler.class); - private static final Gson gson = new GsonBuilder().create(); - - private final SpeakingService speakingService; - private final SpeakingConnectionRepository connectionRepository; - - public SpeakingMessageHandler() { - this.speakingService = new SpeakingService(); - this.connectionRepository = new SpeakingConnectionRepository(); - } - - @Override - public Map handleRequest(Map event, Context context) { - logger.info("Speaking message event received"); - - String connectionId = null; - String endpoint = null; - - try { - connectionId = WebSocketEventUtil.extractConnectionId(event); - endpoint = WebSocketEventUtil.extractWebSocketEndpoint(event); - - // 연결 정보 확인 - if (connectionRepository.findByConnectionId(connectionId).isEmpty()) { - logger.warn("Connection not found: {}", connectionId); - return sendError(connectionId, endpoint, "Unauthorized - please reconnect"); - } - - // 요청 바디 파싱 - String body = (String) event.get("body"); - if (body == null || body.isEmpty()) { - return sendError(connectionId, endpoint, "Message body is required"); - } - - JsonObject request = JsonParser.parseString(body).getAsJsonObject(); - String action = request.has("action") ? request.get("action").getAsString() : "speak"; - - logger.info("Processing action: {} for connectionId: {}", action, connectionId); - - // 액션별 처리 - switch (action) { - case "speak" -> handleSpeak(connectionId, endpoint, request); - case "text" -> handleText(connectionId, endpoint, request); - case "setLevel" -> handleSetLevel(connectionId, endpoint, request); - case "reset" -> handleReset(connectionId, endpoint); - default -> sendError(connectionId, endpoint, "Unknown action: " + action); - } - - return WebSocketEventUtil.ok("Processed"); - - } catch (Exception e) { - logger.error("Error processing message: {}", e.getMessage(), e); - if (connectionId != null && endpoint != null) { - sendError(connectionId, endpoint, "Processing error: " + e.getMessage()); - } - return WebSocketEventUtil.serverError("Internal server error"); - } - } - - /** - * 음성 입력 처리 - */ - private void handleSpeak(String connectionId, String endpoint, JsonObject request) { - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your voice..." - )); - - // 음성 데이터 추출 - String audioBase64 = request.has("audio") ? request.get("audio").getAsString() : null; - if (audioBase64 == null || audioBase64.isEmpty()) { - sendError(connectionId, endpoint, "audio data is required for speak action"); - return; - } - - // 음성 처리 - SpeakingService.SpeakingResponse response = speakingService.processVoiceInput( - connectionId, audioBase64 - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 텍스트 입력 처리 - */ - private void handleText(String connectionId, String endpoint, JsonObject request) { - String text = request.has("text") ? request.get("text").getAsString() : null; - if (text == null || text.trim().isEmpty()) { - sendError(connectionId, endpoint, "text is required for text action"); - return; - } - - // 시작 이벤트 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "start", - "message", "Processing your message..." - )); - - // 텍스트 처리 - SpeakingService.SpeakingResponse response = speakingService.processTextInput( - connectionId, text.trim() - ); - - // 결과 전송 - sendToConnection(connectionId, endpoint, Map.of( - "type", "complete", - "userTranscript", response.userTranscript(), - "aiText", response.aiText(), - "aiAudioUrl", response.aiAudioUrl(), - "confidence", response.confidence() - )); - } - - /** - * 레벨 변경 처리 - */ - private void handleSetLevel(String connectionId, String endpoint, JsonObject request) { - String level = request.has("level") ? request.get("level").getAsString() : null; - if (level == null || level.isEmpty()) { - sendError(connectionId, endpoint, "level is required"); - return; - } - - speakingService.updateLevel(connectionId, level); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "levelChanged", - "level", level.toUpperCase() - )); - } - - /** - * 대화 초기화 처리 - */ - private void handleReset(String connectionId, String endpoint) { - speakingService.resetConversation(connectionId); - - sendToConnection(connectionId, endpoint, Map.of( - "type", "reset", - "message", "Conversation has been reset. Let's start fresh!" - )); - } - - /** - * WebSocket으로 메시지 전송 - */ - private void sendToConnection(String connectionId, String endpoint, Map data) { - try { - ApiGatewayManagementApiClient apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - - String message = gson.toJson(data); - - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - - logger.debug("Message sent to {}: {}", connectionId, data.get("type")); - - } catch (Exception e) { - logger.error("Failed to send message to {}: {}", connectionId, e.getMessage()); - } - } - - /** - * 에러 메시지 전송 - */ - private Map sendError(String connectionId, String endpoint, String errorMessage) { - sendToConnection(connectionId, endpoint, Map.of( - "type", "error", - "message", errorMessage - )); - return WebSocketEventUtil.ok("Error sent"); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java similarity index 56% rename from ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java rename to ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java index 133e7773..a0712d4b 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingConnection.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/model/SpeakingSession.java @@ -15,49 +15,61 @@ @NoArgsConstructor @AllArgsConstructor @DynamoDbBean -public class SpeakingConnection { +public class SpeakingSession { // DynamoDB Key Prefixes - public static final String PK_PREFIX = "SPEAKING_CONN#"; + public static final String PK_PREFIX = "SPEAKING_SESSION#"; public static final String SK_METADATA = "METADATA"; public static final String GSI1PK_PREFIX = "SPEAKING_USER#"; - public static final String GSI1SK_PREFIX = "CONN#"; + public static final String GSI1SK_PREFIX = "SESSION#"; - private String pk; // SPEAKING_CONN#{connectionId} + private String pk; // SPEAKING_SESSION#{sessionId} private String sk; // METADATA private String gsi1pk; // SPEAKING_USER#{userId} - private String gsi1sk; // CONN#{connectionId} + private String gsi1sk; // SESSION#{sessionId} - private String connectionId; + private String sessionId; private String userId; - private String connectedAt; - private Long ttl; // 자동 삭제용 + private String createdAt; + private String updatedAt; + private Long ttl; // 자동 삭제용 (24시간) // Speaking 전용 필드 private String conversationHistory; // 대화 히스토리 (JSON) private String targetLevel; // 목표 레벨 (BEGINNER, INTERMEDIATE, ADVANCED) /** - * 연결 정보 생성 팩토리 메서드 + * 세션 생성 팩토리 메서드 */ - public static SpeakingConnection create(String connectionId, String userId, long ttlSeconds) { + public static SpeakingSession create(String sessionId, String userId, String level) { String now = java.time.Instant.now().toString(); - long ttl = java.time.Instant.now().plusSeconds(ttlSeconds).getEpochSecond(); + // 24시간 후 자동 삭제 + long ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); - return SpeakingConnection.builder() - .pk(PK_PREFIX + connectionId) + return SpeakingSession.builder() + .pk(PK_PREFIX + sessionId) .sk(SK_METADATA) .gsi1pk(GSI1PK_PREFIX + userId) - .gsi1sk(GSI1SK_PREFIX + connectionId) - .connectionId(connectionId) + .gsi1sk(GSI1SK_PREFIX + sessionId) + .sessionId(sessionId) .userId(userId) - .connectedAt(now) + .createdAt(now) + .updatedAt(now) .ttl(ttl) - .conversationHistory("[]") // 빈 배열로 초기화 - .targetLevel("INTERMEDIATE") // 기본값 + .conversationHistory("[]") + .targetLevel(level != null ? level.toUpperCase() : "INTERMEDIATE") .build(); } + /** + * 업데이트 시간 갱신 + */ + public void touch() { + this.updatedAt = java.time.Instant.now().toString(); + // TTL 연장 (24시간) + this.ttl = java.time.Instant.now().plusSeconds(86400).getEpochSecond(); + } + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java deleted file mode 100644 index bbb74d7c..00000000 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingConnectionRepository.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.mzc.secondproject.serverless.domain.speaking.repository; - -import com.mzc.secondproject.serverless.common.config.AwsClients; -import com.mzc.secondproject.serverless.common.config.EnvConfig; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; -import software.amazon.awssdk.enhanced.dynamodb.Key; -import software.amazon.awssdk.enhanced.dynamodb.TableSchema; - -import java.util.Optional; - -/** - * Speaking WebSocket 연결 정보 Repository - */ -public class SpeakingConnectionRepository { - - private static final Logger logger = LoggerFactory.getLogger(SpeakingConnectionRepository.class); - private static final String TABLE_NAME = EnvConfig.getRequired("CHAT_TABLE_NAME"); - - private final DynamoDbTable table; - - public SpeakingConnectionRepository() { - this.table = AwsClients.dynamoDbEnhanced().table( - TABLE_NAME, - TableSchema.fromBean(SpeakingConnection.class) - ); - } - - /** - * 연결 정보 저장 - */ - public void save(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection saved: connectionId={}, userId={}", - connection.getConnectionId(), connection.getUserId()); - } - - /** - * connectionId로 연결 정보 조회 - */ - public Optional findByConnectionId(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - SpeakingConnection connection = table.getItem(key); - return Optional.ofNullable(connection); - } - - /** - * 연결 정보 업데이트 (대화 히스토리 등) - */ - public void update(SpeakingConnection connection) { - table.putItem(connection); - logger.debug("Speaking connection updated: connectionId={}", connection.getConnectionId()); - } - - /** - * 연결 정보 삭제 - */ - public void delete(String connectionId) { - Key key = Key.builder() - .partitionValue(SpeakingConnection.PK_PREFIX + connectionId) - .sortValue(SpeakingConnection.SK_METADATA) - .build(); - - table.deleteItem(key); - logger.info("Speaking connection deleted: connectionId={}", connectionId); - } -} diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java new file mode 100644 index 00000000..dadda7e7 --- /dev/null +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/repository/SpeakingSessionRepository.java @@ -0,0 +1,74 @@ +package com.mzc.secondproject.serverless.domain.speaking.repository; + +import com.mzc.secondproject.serverless.common.config.AwsClients; +import com.mzc.secondproject.serverless.common.config.EnvConfig; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; +import software.amazon.awssdk.enhanced.dynamodb.Key; +import software.amazon.awssdk.enhanced.dynamodb.TableSchema; + +import java.util.Optional; + +/** + * Speaking API 연결 정보 Repository + */ +public class SpeakingSessionRepository { + + private static final Logger logger = LoggerFactory.getLogger(SpeakingSessionRepository.class); + private static final String TABLE_NAME = EnvConfig.getRequired("SPEAKING_TABLE_NAME"); + + private final DynamoDbTable table; + + public SpeakingSessionRepository() { + this.table = AwsClients.dynamoDbEnhanced().table( + TABLE_NAME, + TableSchema.fromBean(SpeakingSession.class) + ); + } + + /** + * 연결 정보 저장 + */ + public void save(SpeakingSession session) { + table.putItem(session); + logger.debug("Speaking session saved: sessionId={}, userId={}", + session.getSessionId(), session.getUserId()); + } + + /** + * sessionId로 연결 정보 조회 + */ + public Optional findBySessionId(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + SpeakingSession session = table.getItem(key); + return Optional.ofNullable(session); + } + + /** + * 연결 정보 업데이트 (대화 히스토리 등) + */ + public void update(SpeakingSession session) { + session.touch(); // 업데이트 시간 및 TTL 갱신 + table.putItem(session); + logger.debug("Speaking session updated: sessionId={}", session.getSessionId()); + } + + /** + * 연결 정보 삭제 + */ + public void delete(String sessionId) { + Key key = Key.builder() + .partitionValue(SpeakingSession.PK_PREFIX + sessionId) + .sortValue(SpeakingSession.SK_METADATA) + .build(); + + table.deleteItem(key); + logger.info("Speaking session deleted: sessionId={}", sessionId); + } +} \ No newline at end of file diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java index 7c428ddc..fb94ce14 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/speaking/service/SpeakingService.java @@ -5,8 +5,9 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.service.PollyService; import com.mzc.secondproject.serverless.domain.opic.service.TranscribeProxyService; -import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingConnection; -import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingConnectionRepository; +import com.mzc.secondproject.serverless.domain.speaking.dto.response.SpeakingResponse; +import com.mzc.secondproject.serverless.domain.speaking.model.SpeakingSession; +import com.mzc.secondproject.serverless.domain.speaking.repository.SpeakingSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import software.amazon.awssdk.core.SdkBytes; @@ -15,6 +16,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.UUID; /** * AI와 대화하기 서비스 @@ -31,7 +33,7 @@ public class SpeakingService { private final TranscribeProxyService transcribeService; private final PollyService pollyService; - private final SpeakingConnectionRepository connectionRepository; + private final SpeakingSessionRepository sessionRepository; public SpeakingService() { this.transcribeService = new TranscribeProxyService(); @@ -39,33 +41,54 @@ public SpeakingService() { EnvConfig.getRequired("BUCKET_NAME"), "speaking/voice/" ); - this.connectionRepository = new SpeakingConnectionRepository(); + this.sessionRepository = new SpeakingSessionRepository(); + } + + /** + * 세션 생성 또는 조회 + */ + public SpeakingSession getOrCreateSession(String sessionId, String userId, String level) { + if (sessionId != null && !sessionId.isEmpty()) { + return sessionRepository.findBySessionId(sessionId) + .orElseGet(() -> createNewSession(userId, level)); + } + return createNewSession(userId, level); + } + + /** + * 새 세션 생성 + */ + private SpeakingSession createNewSession(String userId, String level) { + String newSessionId = UUID.randomUUID().toString(); + SpeakingSession session = SpeakingSession.create(newSessionId, userId, level); + sessionRepository.save(session); + logger.info("New speaking session created: sessionId={}, userId={}", newSessionId, userId); + return session; } /** * 음성 입력 처리 (전체 플로우) */ - public SpeakingResponse processVoiceInput(String connectionId, String audioBase64) { - logger.info("Processing voice input for connectionId: {}", connectionId); + public SpeakingResponse processVoiceInput(String sessionId, String userId, String audioBase64, String level) { + logger.info("Processing voice input for sessionId: {}", sessionId); - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); - String targetLevel = connection.getTargetLevel(); + String targetLevel = session.getTargetLevel(); // STT: 음성 → 텍스트 (Transcribe Proxy 사용) logger.info("Step 1: Transcribing audio..."); TranscribeProxyService.TranscribeResult sttResult = transcribeService.transcribe( audioBase64, - connectionId, + session.getSessionId(), "en-US" ); String userText = sttResult.transcript(); logger.info("Transcription complete: {} (confidence: {})", userText, sttResult.confidence()); // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); + List history = parseHistory(session.getConversationHistory()); // Bedrock: AI 응답 생성 logger.info("Step 2: Generating AI response..."); @@ -78,12 +101,12 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 if (history.size() > MAX_HISTORY_SIZE * 2) { history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); // TTS: 텍스트 → 음성 (Polly 사용) logger.info("Step 3: Synthesizing speech..."); - String audioId = connectionId + "_" + System.currentTimeMillis(); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( audioId, aiResponse, @@ -92,6 +115,7 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 logger.info("Speech synthesis complete: cached={}", ttsResult.isCached()); return new SpeakingResponse( + session.getSessionId(), userText, aiResponse, ttsResult.getAudioUrl(), @@ -102,20 +126,17 @@ public SpeakingResponse processVoiceInput(String connectionId, String audioBase6 /** * 텍스트 입력 처리 (음성 없이 텍스트만) */ - public SpeakingResponse processTextInput(String connectionId, String userText) { - logger.info("Processing text input for connectionId: {}", connectionId); + public SpeakingResponse processTextInput(String sessionId, String userId, String userText, String level) { + logger.info("Processing text input for sessionId: {}", sessionId); - // 연결 정보 조회 - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); - - String targetLevel = connection.getTargetLevel(); + // 세션 조회 또는 생성 + SpeakingSession session = getOrCreateSession(sessionId, userId, level); // 대화 히스토리 로드 - List history = parseHistory(connection.getConversationHistory()); + List history = parseHistory(session.getConversationHistory()); // AI 응답 생성 - String aiResponse = generateAiResponse(userText, history, targetLevel); + String aiResponse = generateAiResponse(userText, history, session.getTargetLevel()); // 히스토리 업데이트 history.add(new Message("user", userText)); @@ -123,40 +144,46 @@ public SpeakingResponse processTextInput(String connectionId, String userText) { if (history.size() > MAX_HISTORY_SIZE * 2) { history = new ArrayList<>(history.subList(history.size() - MAX_HISTORY_SIZE * 2, history.size())); } - connection.setConversationHistory(toJson(history)); - connectionRepository.update(connection); + session.setConversationHistory(toJson(history)); + sessionRepository.update(session); // TTS 생성 - String audioId = connectionId + "_" + System.currentTimeMillis(); + String audioId = session.getSessionId() + "_" + System.currentTimeMillis(); PollyService.VoiceSynthesisResult ttsResult = pollyService.synthesizeSpeech( audioId, aiResponse, "FEMALE" ); - return new SpeakingResponse(userText, aiResponse, ttsResult.getAudioUrl(), 1.0); + return new SpeakingResponse( + session.getSessionId(), + userText, + aiResponse, + ttsResult.getAudioUrl(), + 1.0 + ); } /** * 레벨 변경 */ - public void updateLevel(String connectionId, String level) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public void updateLevel(String sessionId, String level) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - connection.setTargetLevel(level.toUpperCase()); - connectionRepository.update(connection); - logger.info("Level updated for connectionId {}: {}", connectionId, level); + session.setTargetLevel(level.toUpperCase()); + sessionRepository.update(session); + logger.info("Level updated for sessionId {}: {}", sessionId, level); } /** * 대화 히스토리 초기화 */ - public void resetConversation(String connectionId) { - SpeakingConnection connection = connectionRepository.findByConnectionId(connectionId) - .orElseThrow(() -> new RuntimeException("Connection not found: " + connectionId)); + public void resetConversation(String sessionId) { + SpeakingSession session = sessionRepository.findBySessionId(sessionId) + .orElseThrow(() -> new RuntimeException("session not found: " + sessionId)); - connection.setConversationHistory("[]"); - connectionRepository.update(connection); - logger.info("Conversation reset for connectionId: {}", connectionId); + session.setConversationHistory("[]"); + sessionRepository.update(session); + logger.info("Conversation reset for sessionId: {}", sessionId); } @@ -286,6 +313,7 @@ private List parseHistory(String historyJson) { return history; } + /** * 히스토리 JSON 변환 */ @@ -300,19 +328,10 @@ private String toJson(List history) { return array.toString(); } - // ==================== Inner Classes ==================== - - private record Message(String role, String content) { - } - /** - * Speaking 응답 DTO + * 대화 메시지 (히스토리용) */ - public record SpeakingResponse( - String userTranscript, // 사용자가 말한 내용 (STT 결과) - String aiText, // AI 응답 텍스트 - String aiAudioUrl, // AI 응답 음성 URL (Polly) - double confidence // STT 신뢰도comp - ) { + private record Message(String role, String content) { } + } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java index 637151be..40fd94d7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/handler/UserStatsHandler.java @@ -49,6 +49,7 @@ public UserStatsHandler(UserStatsRepository statsRepository, DailyStudyRepositor private HandlerRouter initRouter() { return new HandlerRouter().addRoutes( + Route.getAuth("/stats/dashboard", this::getDashboardStats), Route.getAuth("/stats/daily", this::getDailyStats), Route.getAuth("/stats/weekly", this::getWeeklyStats), Route.getAuth("/stats/monthly", this::getMonthlyStats), @@ -63,6 +64,87 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re return router.route(request); } + /** + * 대시보드용 통합 통계 조회 (프론트엔드 요청 형식) + * GET /stats/dashboard + */ + private APIGatewayProxyResponseEvent getDashboardStats(APIGatewayProxyRequestEvent request, String userId) { + String today = LocalDate.now().toString(); + + // 오늘 통계 조회 + Optional dailyStats = statsRepository.findDailyStats(userId, today); + // 전체 통계 조회 + Optional totalStats = statsRepository.findTotalStats(userId); + // 최근 7일 히스토리 조회 + PaginatedResult weekHistory = statsRepository.findRecentDailyStats(userId, 7, null); + // 오늘 학습 목표 조회 + Optional dailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today); + + Map response = new HashMap<>(); + + // today 섹션 + Map todaySection = new HashMap<>(); + if (dailyStats.isPresent()) { + UserStats ds = dailyStats.get(); + todaySection.put("wordsLearned", ds.getNewWordsLearned() != null ? ds.getNewWordsLearned() : 0); + todaySection.put("newsRead", ds.getNewsRead() != null ? ds.getNewsRead() : 0); + todaySection.put("quizzesTaken", (ds.getTestsCompleted() != null ? ds.getTestsCompleted() : 0) + + (ds.getNewsQuizCompleted() != null ? ds.getNewsQuizCompleted() : 0)); + } else { + todaySection.put("wordsLearned", 0); + todaySection.put("newsRead", 0); + todaySection.put("quizzesTaken", 0); + } + todaySection.put("wordsTotal", dailyStudy.map(ds -> ds.getTotalWords() != null ? ds.getTotalWords() : 25).orElse(25)); + response.put("today", todaySection); + + // overall 섹션 + Map overallSection = new HashMap<>(); + if (totalStats.isPresent()) { + UserStats ts = totalStats.get(); + overallSection.put("totalWordsLearned", ts.getNewWordsLearned() != null ? ts.getNewWordsLearned() : 0); + overallSection.put("totalNewsRead", ts.getNewsRead() != null ? ts.getNewsRead() : 0); + overallSection.put("totalQuizzes", (ts.getTestsCompleted() != null ? ts.getTestsCompleted() : 0) + + (ts.getNewsQuizCompleted() != null ? ts.getNewsQuizCompleted() : 0)); + overallSection.put("averageAccuracy", calculateSuccessRate(ts)); + overallSection.put("currentStreak", ts.getCurrentStreak() != null ? ts.getCurrentStreak() : 0); + overallSection.put("longestStreak", ts.getLongestStreak() != null ? ts.getLongestStreak() : 0); + overallSection.put("lastStudyDate", ts.getLastStudyDate()); + } else { + overallSection.put("totalWordsLearned", 0); + overallSection.put("totalNewsRead", 0); + overallSection.put("totalQuizzes", 0); + overallSection.put("averageAccuracy", 0.0); + overallSection.put("currentStreak", 0); + overallSection.put("longestStreak", 0); + overallSection.put("lastStudyDate", null); + } + // totalStudyDays 계산 (최근 히스토리에서 실제 학습한 날 수) + overallSection.put("totalStudyDays", weekHistory.items().size()); + response.put("overall", overallSection); + + // weeklyProgress 섹션 + List> weeklyProgress = weekHistory.items().stream() + .map(stats -> { + Map dayStats = new HashMap<>(); + dayStats.put("date", stats.getPeriod()); + dayStats.put("wordsLearned", stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0); + dayStats.put("newsRead", stats.getNewsRead() != null ? stats.getNewsRead() : 0); + return dayStats; + }) + .collect(Collectors.toList()); + response.put("weeklyProgress", weeklyProgress); + + // levelDistribution (현재 미구현 - 향후 추가 가능) + Map levelDistribution = new HashMap<>(); + levelDistribution.put("beginner", 0); + levelDistribution.put("intermediate", 0); + levelDistribution.put("advanced", 0); + response.put("levelDistribution", levelDistribution); + + return ResponseGenerator.ok("학습 통계 조회 성공", response); + } + /** * 오늘의 통계 조회 */ @@ -182,6 +264,11 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", calculateSuccessRate(s)); response.put("newWordsLearned", s.getNewWordsLearned() != null ? s.getNewWordsLearned() : 0); response.put("wordsReviewed", s.getWordsReviewed() != null ? s.getWordsReviewed() : 0); + // 뉴스 관련 통계 + response.put("newsRead", s.getNewsRead() != null ? s.getNewsRead() : 0); + response.put("newsQuizCompleted", s.getNewsQuizCompleted() != null ? s.getNewsQuizCompleted() : 0); + response.put("newsQuizPerfect", s.getNewsQuizPerfect() != null ? s.getNewsQuizPerfect() : 0); + response.put("newsWordsCollected", s.getNewsWordsCollected() != null ? s.getNewsWordsCollected() : 0); } else { response.put("testsCompleted", 0); response.put("questionsAnswered", 0); @@ -190,6 +277,11 @@ private Map buildStatsResponse(Optional stats, String response.put("successRate", 0.0); response.put("newWordsLearned", 0); response.put("wordsReviewed", 0); + // 뉴스 관련 통계 + response.put("newsRead", 0); + response.put("newsQuizCompleted", 0); + response.put("newsQuizPerfect", 0); + response.put("newsWordsCollected", 0); } return response; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java index cc25634c..1955d429 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/model/UserStats.java @@ -57,6 +57,14 @@ public class UserStats { private Integer quickGuesses; // 5초 내 정답 횟수 private Integer perfectDraws; // 전원 정답 유도 횟수 + // 뉴스 통계 + private Integer newsRead; // 읽은 뉴스 수 + private Integer newsQuizCompleted; // 완료한 뉴스 퀴즈 수 + private Integer newsQuizPerfect; // 뉴스 퀴즈 만점 횟수 + private Integer newsWordsCollected; // 뉴스에서 수집한 단어 수 + private Integer newsStreak; // 뉴스 연속 읽기 일수 + private String lastNewsReadDate; // 마지막 뉴스 읽은 날짜 + // 메타데이터 private String createdAt; private String updatedAt; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java index b3ad20d8..4ec4174f 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/stats/repository/UserStatsRepository.java @@ -310,6 +310,247 @@ public void incrementGameStats(String userId, int gamesPlayed, int gamesWon, userId, gamesPlayed, gamesWon, correctGuesses); } + /** + * 뉴스 읽기 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsReadStats(String userId) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + // 먼저 현재 통계 조회 (streak 계산용) + UserStats currentStats = findTotalStats(userId).orElse(null); + String lastNewsReadDate = currentStats != null ? currentStats.getLastNewsReadDate() : null; + + // 연속 읽기 계산 + int currentStreak = 1; + if (lastNewsReadDate != null) { + LocalDate lastDate = LocalDate.parse(lastNewsReadDate); + LocalDate todayDate = LocalDate.now(); + if (lastDate.equals(todayDate.minusDays(1))) { + // 어제 읽었으면 streak 증가 + currentStreak = (currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 0) + 1; + } else if (lastDate.equals(todayDate)) { + // 오늘 이미 읽었으면 streak 유지 + currentStreak = currentStats.getNewsStreak() != null ? currentStats.getNewsStreak() : 1; + } + // 그 외의 경우는 streak 1로 초기화 + } + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":streak", AttributeValue.builder().n(String.valueOf(currentStreak)).build()); + values.put(":today", AttributeValue.builder().s(today).build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "newsStreak = :streak, " + + "lastNewsReadDate = :today, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsRead = if_not_exists(newsRead, :zero) + :one, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news read stats (TOTAL + DAILY): userId={}, streak={}", userId, currentStreak); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 퀴즈 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsQuizStats(String userId, boolean isPerfect) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + Map values = new HashMap<>(); + values.put(":one", AttributeValue.builder().n("1").build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":one", AttributeValue.builder().n("1").build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":perfect", AttributeValue.builder().n(isPerfect ? "1" : "0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsQuizCompleted = if_not_exists(newsQuizCompleted, :zero) + :one, " + + "newsQuizPerfect = if_not_exists(newsQuizPerfect, :zero) + :perfect, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news quiz stats (TOTAL + DAILY): userId={}, isPerfect={}", userId, isPerfect); + + return findTotalStats(userId).orElse(null); + } + + /** + * 뉴스 단어 수집 통계 Atomic 업데이트 (TOTAL + DAILY) + */ + public UserStats incrementNewsWordStats(String userId, int wordCount) { + String today = LocalDate.now().toString(); + String pk = StatsKey.userStatsPk(userId); + String now = Instant.now().toString(); + + Map values = new HashMap<>(); + values.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + values.put(":zero", AttributeValue.builder().n("0").build()); + values.put(":now", AttributeValue.builder().s(now).build()); + + // 1. TOTAL 통계 업데이트 + Map totalKey = new HashMap<>(); + totalKey.put("PK", AttributeValue.builder().s(pk).build()); + totalKey.put("SK", AttributeValue.builder().s(StatsKey.statsTotalSk()).build()); + + String totalUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now)"; + + UpdateItemRequest totalRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(totalKey) + .updateExpression(totalUpdateExpression) + .expressionAttributeValues(values) + .returnValues(software.amazon.awssdk.services.dynamodb.model.ReturnValue.ALL_NEW) + .build(); + + AwsClients.dynamoDb().updateItem(totalRequest); + + // 2. DAILY 통계 업데이트 + Map dailyKey = new HashMap<>(); + dailyKey.put("PK", AttributeValue.builder().s(pk).build()); + dailyKey.put("SK", AttributeValue.builder().s(StatsKey.statsDailySk(today)).build()); + + Map dailyValues = new HashMap<>(); + dailyValues.put(":count", AttributeValue.builder().n(String.valueOf(wordCount)).build()); + dailyValues.put(":zero", AttributeValue.builder().n("0").build()); + dailyValues.put(":now", AttributeValue.builder().s(now).build()); + dailyValues.put(":today", AttributeValue.builder().s(today).build()); + + String dailyUpdateExpression = "SET " + + "newsWordsCollected = if_not_exists(newsWordsCollected, :zero) + :count, " + + "updatedAt = :now, " + + "createdAt = if_not_exists(createdAt, :now), " + + "period = if_not_exists(period, :today)"; + + UpdateItemRequest dailyRequest = UpdateItemRequest.builder() + .tableName(TABLE_NAME) + .key(dailyKey) + .updateExpression(dailyUpdateExpression) + .expressionAttributeValues(dailyValues) + .build(); + + AwsClients.dynamoDb().updateItem(dailyRequest); + logger.info("Incremented news word stats (TOTAL + DAILY): userId={}, wordCount={}", userId, wordCount); + + return findTotalStats(userId).orElse(null); + } + + /** + * 연속 학습 중인 사용자 목록 조회 (streak >= 1) + * GSI1을 사용하여 TOTAL 통계만 조회 후 필터링 + */ + public List findUsersWithActiveStreak() { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("STATS#ALL") + .sortValue(StatsKey.statsTotalSk()) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List results = new ArrayList<>(); + table.index("GSI1").query(request).forEach(page -> { + page.items().stream() + .filter(stats -> stats.getCurrentStreak() != null && stats.getCurrentStreak() >= 1) + .forEach(results::add); + }); + + return results; + } + /** * 현재 연도-주차 반환 (예: 2026-W02) */ diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java index bdc1ced7..7f17bd4a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/dto/response/ProfileResponse.java @@ -29,7 +29,7 @@ public static ProfileResponse from(User user) { .email(user.getEmail()) .nickname(user.getNickname()) .level(user.getLevel()) - .profileUrl(user.getProfileUrl()) + .profileUrl(user.getProfileUrlForResponse()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java index 97bd6638..c43d41e5 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PostConfirmationHandler.java @@ -19,14 +19,19 @@ public class PostConfirmationHandler implements RequestHandler { private static final Logger logger = LoggerFactory.getLogger(PostConfirmationHandler.class); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); private final UserRepository userRepository; public PostConfirmationHandler() { this.userRepository = new UserRepository(); } + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } + @Override public CognitoUserPoolPostConfirmationEvent handleRequest( CognitoUserPoolPostConfirmationEvent event, diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java index d881615b..67b8bc87 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/PreSignUpHandler.java @@ -11,7 +11,17 @@ public class PreSignUpHandler implements RequestHandler, Map> { private static final Logger logger = LoggerFactory.getLogger(PreSignUpHandler.class); - private static final String DEFAULT_PROFILE_URL = System.getenv("DEFAULT_PROFILE_URL"); + private static final String BUCKET_NAME = System.getenv("BUCKET_NAME"); + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); + + private static String getDefaultProfileUrl() { + String envUrl = System.getenv("DEFAULT_PROFILE_URL"); + if (envUrl != null && !envUrl.isEmpty()) { + return envUrl; + } + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } @Override public Map handleRequest(Map input, Context context) { @@ -38,11 +48,8 @@ public Map handleRequest(Map input, Context cont String profileUrl = userAttributes.get("custom:profileUrl"); if (profileUrl == null || profileUrl.trim().isEmpty()) { - String defaultUrl = DEFAULT_PROFILE_URL != null - ? DEFAULT_PROFILE_URL - : "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; - userAttributes.put("custom:profileUrl", defaultUrl); - logger.info("프로필 이미지 기본값: {}", defaultUrl); + userAttributes.put("custom:profileUrl", DEFAULT_PROFILE_URL); + logger.info("프로필 이미지 기본값: {}", DEFAULT_PROFILE_URL); } return input; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java index b4fc9aea..c2f7b41c 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/handler/UserHandler.java @@ -59,9 +59,20 @@ private APIGatewayProxyResponseEvent getMyProfile( APIGatewayProxyRequestEvent request, String userId // cognitoSub ) { - User user = userService.getProfile(userId, request); - ProfileResponse response = ProfileResponse.from(user); + + // profileUrl을 Presigned URL로 변환 + String presignedUrl = userService.getPresignedProfileUrl(user.getProfileUrl()); + + ProfileResponse response = ProfileResponse.builder() + .userId(user.getCognitoSub()) + .email(user.getEmail()) + .nickname(user.getNickname()) + .level(user.getLevel()) + .profileUrl(presignedUrl) // Presigned URL 사용 + .createdAt(user.getCreatedAt()) + .updatedAt(user.getUpdatedAt()) + .build(); return ResponseGenerator.ok(user.getNickname() + " 환영합니다!", response); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java index 344d77e4..218c7eb9 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/model/User.java @@ -27,11 +27,13 @@ public class User { private String nickname; private String level; private String profileUrl; + private String profileUrlForResponse; private String createdAt; private String updatedAt; private String lastLoginAt; private Long ttl; + /** * 신규 사용자 생성 * - Lazy Registration 적용: 최초 프로필 조회 시 DynamoDB에 저장 @@ -115,6 +117,11 @@ public void updateProfileUrl(String newProfileUrl) { this.updatedAt = Instant.now().toString(); } + @DynamoDbIgnore + public String getProfileUrlForResponse() { + return profileUrlForResponse != null ? profileUrlForResponse : profileUrl; + } + public void updateLastLoginAt() { this.lastLoginAt = Instant.now().toString(); } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java index 0c5c99c6..4f635106 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/user/service/UserService.java @@ -7,8 +7,10 @@ import com.mzc.secondproject.serverless.domain.user.repository.UserRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; import software.amazon.awssdk.services.s3.model.PutObjectRequest; import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; @@ -22,22 +24,24 @@ public class UserService { private static final Logger logger = LoggerFactory.getLogger(UserService.class); private static final String BUCKET_NAME = System.getenv("PROFILE_BUCKET_NAME"); - private static final String DEFAULT_PROFILE_URL = "https://group2-englishstudy.s3.amazonaws.com/profile/default.png"; + private static final String DEFAULT_PROFILE_URL = getDefaultProfileUrl(); private static final List VALID_LEVELS = Arrays.asList("BEGINNER", "INTERMEDIATE", "ADVANCED"); - private static final List VALID_IMAGE_TYPES = Arrays.asList("image/jpeg", "image/png", "image/gif", "image/webp"); private static final int NICKNAME_MIN_LENGTH = 2; private static final int NICKNAME_MAX_LENGTH = 20; - private final UserRepository userRepository; private final S3Presigner s3Presigner; - public UserService(UserRepository userRepository) { this.userRepository = userRepository; // AwsClients 싱글톤 사용 - Cold Start 최적화 this.s3Presigner = AwsClients.s3Presigner(); } + private static String getDefaultProfileUrl() { + String bucket = BUCKET_NAME != null ? BUCKET_NAME : "group2-englishstudy"; + return String.format("https://%s.s3.amazonaws.com/profile/default.png", bucket); + } + /** * 프로필 조회 * DynamoDB에 없으면 request에서 claims 추출 → fallback 저장 @@ -48,17 +52,52 @@ public UserService(UserRepository userRepository) { */ public User getProfile(String userId, APIGatewayProxyRequestEvent request) { - return userRepository.findByCognitoSub(userId) - .map(user -> { - // 정상 DB에서 조회 완료 - user.updateLastLoginAt(); - userRepository.update(user); - return user; + User user = userRepository.findByCognitoSub(userId) + .map(u -> { + u.updateLastLoginAt(); + userRepository.update(u); + return u; }) - .orElseGet(() -> { - // PostConfirmation 실패 대비 fallback - return createUserFromRequest(userId, request); - }); + .orElseGet(() -> createUserFromRequest(userId, request)); + + // 프로필 URL을 Presigned URL로 변환 + String presignedProfileUrl = getPresignedProfileUrl(user.getProfileUrl()); + user.setProfileUrlForResponse(presignedProfileUrl); // 응답용으로만 설정 + + return user; + } + + public String getPresignedProfileUrl(String s3Url) { + if (s3Url == null || s3Url.isEmpty()) { + return generateGetPresignedUrl("profile/default.png"); + } + String key = extractKeyFromS3Url(s3Url); + return generateGetPresignedUrl(key); + } + + private String generateGetPresignedUrl(String imageKey) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(BUCKET_NAME) + .key(imageKey) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(24)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + + private String extractKeyFromS3Url(String s3Url) { + // https://group2-englishstudy.s3.amazonaws.com/profile/user123/img.png + // → profile/user123/img.png + String prefix = String.format("https://%s.s3.amazonaws.com/", BUCKET_NAME); + if (s3Url.startsWith(prefix)) { + return s3Url.substring(prefix.length()); + } + return s3Url; } /** diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java index 9a65b41a..aafe1eac 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/enums/WordCategory.java @@ -7,7 +7,8 @@ public enum WordCategory { BUSINESS("business", "비즈니스"), ACADEMIC("academic", "학술"), TRAVEL("travel", "여행"), - TECHNOLOGY("technology", "기술"); + TECHNOLOGY("technology", "기술"), + NEWS("news", "뉴스"); private final String code; private final String displayName; diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java index 83047fa4..84aca50a 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/handler/UserWordHandler.java @@ -71,13 +71,14 @@ private APIGatewayProxyResponseEvent getUserWords(APIGatewayProxyRequestEvent re String cursor = queryParams != null ? queryParams.get("cursor") : null; String bookmarked = queryParams != null ? queryParams.get("bookmarked") : null; String incorrectOnly = queryParams != null ? queryParams.get("incorrectOnly") : null; + String category = queryParams != null ? queryParams.get("category") : null; int limit = 20; if (queryParams != null && queryParams.get("limit") != null) { limit = Math.min(Integer.parseInt(queryParams.get("limit")), 50); } - UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, limit, cursor); + UserWordQueryService.UserWordsResult result = queryService.getUserWords(userId, status, bookmarked, incorrectOnly, category, limit, cursor); Map response = new HashMap<>(); response.put("userWords", result.userWords()); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java index 6cb33629..198a6849 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/repository/DailyStudyRepository.java @@ -17,7 +17,9 @@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue; import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -115,4 +117,24 @@ public void addLearnedWord(String userId, String date, String wordId) { AwsClients.dynamoDb().updateItem(updateRequest); logger.info("Added learned word: userId={}, date={}, wordId={}", userId, date, wordId); } + + /** + * 특정 날짜의 모든 일일 학습 기록 조회 (GSI1 사용) + */ + public List findByDate(String date) { + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("DAILY#ALL") + .sortValue("DATE#" + date) + .build()); + + QueryEnhancedRequest request = QueryEnhancedRequest.builder() + .queryConditional(queryConditional) + .build(); + + List results = new ArrayList<>(); + table.index("GSI1").query(request).forEach(page -> results.addAll(page.items())); + + return results; + } } diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java index 32dc5b24..81a528c6 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/DailyStudyCommandService.java @@ -3,6 +3,7 @@ import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.enums.StudyLevel; import com.mzc.secondproject.serverless.domain.badge.service.BadgeService; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.stats.model.UserStats; import com.mzc.secondproject.serverless.domain.stats.repository.UserStatsRepository; import com.mzc.secondproject.serverless.domain.vocabulary.config.VocabularyConfig; @@ -34,15 +35,16 @@ public class DailyStudyCommandService { private final WordRepository wordRepository; private final UserStatsRepository userStatsRepository; private final BadgeService badgeService; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public DailyStudyCommandService() { this(new DailyStudyRepository(), new UserWordRepository(), new WordRepository(), - new UserStatsRepository(), new BadgeService()); + new UserStatsRepository(), new BadgeService(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ @@ -50,12 +52,14 @@ public DailyStudyCommandService(DailyStudyRepository dailyStudyRepository, UserWordRepository userWordRepository, WordRepository wordRepository, UserStatsRepository userStatsRepository, - BadgeService badgeService) { + BadgeService badgeService, + NotificationPublisher notificationPublisher) { this.dailyStudyRepository = dailyStudyRepository; this.userWordRepository = userWordRepository; this.wordRepository = wordRepository; this.userStatsRepository = userStatsRepository; this.badgeService = badgeService; + this.notificationPublisher = notificationPublisher; } public DailyStudyResult getDailyWords(String userId, String level) { @@ -115,16 +119,36 @@ public Map markWordLearned(String userId, String wordId) { checkWordsBadge(userId); DailyStudy updatedDailyStudy = dailyStudyRepository.findByUserIdAndDate(userId, today).orElse(dailyStudy); - + if (updatedDailyStudy.getLearnedCount() >= updatedDailyStudy.getTotalWords()) { updatedDailyStudy.setIsCompleted(true); dailyStudyRepository.save(updatedDailyStudy); + + // 일일 학습 완료 알림 발행 + int currentStreak = getCurrentStreak(userId); + notificationPublisher.publishDailyComplete( + userId, + today, + updatedDailyStudy.getLearnedCount(), + updatedDailyStudy.getTotalWords(), + currentStreak + ); } - + logger.info("Marked word as learned: userId={}, wordId={}, isNew={}, isReview={}", userId, wordId, isNewWord, isReviewWord); return calculateProgress(updatedDailyStudy); } + + private int getCurrentStreak(String userId) { + try { + Optional stats = userStatsRepository.findTotalStats(userId); + return stats.map(UserStats::getCurrentStreak).orElse(0); + } catch (Exception e) { + logger.warn("Failed to get current streak for user: {}", userId, e); + return 0; + } + } private DailyStudy createDailyStudy(String userId, String date, String level) { String now = Instant.now().toString(); diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java index 43c0c095..f9ef6861 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/TestCommandService.java @@ -4,6 +4,7 @@ import com.mzc.secondproject.serverless.common.config.EnvConfig; import com.mzc.secondproject.serverless.common.dto.PaginatedResult; import com.mzc.secondproject.serverless.common.util.ResponseGenerator; +import com.mzc.secondproject.serverless.domain.notification.service.NotificationPublisher; import com.mzc.secondproject.serverless.domain.vocabulary.dto.request.SubmitTestRequest; import com.mzc.secondproject.serverless.domain.vocabulary.exception.VocabularyException; import com.mzc.secondproject.serverless.domain.vocabulary.model.DailyStudy; @@ -33,26 +34,29 @@ public class TestCommandService { private final DailyStudyRepository dailyStudyRepository; private final WordRepository wordRepository; private final UserWordCommandService userWordCommandService; - + private final NotificationPublisher notificationPublisher; + /** * 기본 생성자 (Lambda에서 사용) */ public TestCommandService() { this(new TestResultRepository(), new DailyStudyRepository(), - new WordRepository(), new UserWordCommandService()); + new WordRepository(), new UserWordCommandService(), NotificationPublisher.getInstance()); } - + /** * 의존성 주입 생성자 (테스트 용이성) */ public TestCommandService(TestResultRepository testResultRepository, DailyStudyRepository dailyStudyRepository, WordRepository wordRepository, - UserWordCommandService userWordCommandService) { + UserWordCommandService userWordCommandService, + NotificationPublisher notificationPublisher) { this.testResultRepository = testResultRepository; this.dailyStudyRepository = dailyStudyRepository; this.wordRepository = wordRepository; this.userWordCommandService = userWordCommandService; + this.notificationPublisher = notificationPublisher; } public StartTestResult startTest(String userId, String testType) { @@ -116,12 +120,23 @@ public SubmitTestResult submitTest(String userId, String testId, String testType // 3. 오답 단어 자동 북마크 bookmarkIncorrectWords(userId, gradingResult.incorrectWordIds()); - // 4. SNS 알림 발행 + // 4. SNS 알림 발행 (통계 업데이트용) publishTestResultToSns(userId, gradingResult.results()); - + + // 5. 실시간 알림 발행 + boolean isPerfect = gradingResult.correctCount() == gradingResult.totalQuestions(); + notificationPublisher.publishTestComplete( + userId, + testId, + (int) Math.round(gradingResult.successRate()), + gradingResult.correctCount(), + gradingResult.totalQuestions(), + isPerfect + ); + logger.info("Test submitted: userId={}, testId={}, successRate={}%", userId, testId, gradingResult.successRate()); - + return new SubmitTestResult( testId, testType, gradingResult.totalQuestions(), gradingResult.correctCount(), gradingResult.incorrectCount(), diff --git a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java index bf6920e5..a606d5f7 100644 --- a/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java +++ b/ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/domain/vocabulary/service/UserWordQueryService.java @@ -37,21 +37,34 @@ public UserWordQueryService(UserWordRepository userWordRepository, WordRepositor } public UserWordsResult getUserWords(String userId, String status, String bookmarked, - String incorrectOnly, int limit, String cursor) { + String incorrectOnly, String category, int limit, String cursor) { PaginatedResult userWordPage; if ("true".equalsIgnoreCase(bookmarked)) { - userWordPage = userWordRepository.findBookmarkedWords(userId, limit, cursor); + userWordPage = userWordRepository.findBookmarkedWords(userId, limit * 3, cursor); } else if ("true".equalsIgnoreCase(incorrectOnly)) { - userWordPage = userWordRepository.findIncorrectWords(userId, limit, cursor); + userWordPage = userWordRepository.findIncorrectWords(userId, limit * 3, cursor); } else if (status != null && !status.isEmpty()) { - userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit, cursor); + userWordPage = userWordRepository.findByUserIdAndStatus(userId, status, limit * 3, cursor); } else { - userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit, cursor); + userWordPage = userWordRepository.findByUserIdWithPagination(userId, limit * 3, cursor); } List> enrichedUserWords = enrichWithWordInfo(userWordPage.items()); + // 카테고리 필터링 (Word 테이블 조인 후 필터) + if (category != null && !category.isEmpty()) { + String upperCategory = category.toUpperCase(); + enrichedUserWords = enrichedUserWords.stream() + .filter(w -> upperCategory.equals(w.get("category"))) + .limit(limit) + .collect(Collectors.toList()); + } else { + enrichedUserWords = enrichedUserWords.stream() + .limit(limit) + .collect(Collectors.toList()); + } + return new UserWordsResult(enrichedUserWords, userWordPage.nextCursor(), userWordPage.hasMore()); } diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy new file mode 100644 index 00000000..eac08089 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/config/NewsConfigSpec.groovy @@ -0,0 +1,172 @@ +package com.mzc.secondproject.serverless.domain.news.config + +import spock.lang.Specification +import spock.lang.Unroll + +class NewsConfigSpec extends Specification { + + // ==================== TTS Constants Tests ==================== + + def "TTS_MAX_TEXT_LENGTH: TTS 최대 텍스트 길이는 3000자"() { + expect: + NewsConfig.TTS_MAX_TEXT_LENGTH == 3000 + } + + def "TTS_AUDIO_PREFIX: TTS 오디오 저장 경로 확인"() { + expect: + NewsConfig.TTS_AUDIO_PREFIX == "news/audio/" + } + + def "DEFAULT_VOICE: 기본 TTS 음성은 Joanna"() { + expect: + NewsConfig.DEFAULT_VOICE == "Joanna" + } + + // ==================== Pagination Constants Tests ==================== + + def "DEFAULT_PAGE_SIZE: 기본 페이지 크기는 10"() { + expect: + NewsConfig.DEFAULT_PAGE_SIZE == 10 + } + + def "MAX_PAGE_SIZE: 최대 페이지 크기는 50"() { + expect: + NewsConfig.MAX_PAGE_SIZE == 50 + } + + // ==================== Score Threshold Tests ==================== + + def "SCORE_PERFECT: 만점 기준은 100"() { + expect: + NewsConfig.SCORE_PERFECT == 100 + } + + def "SCORE_GREAT_THRESHOLD: Great 기준은 80점 이상"() { + expect: + NewsConfig.SCORE_GREAT_THRESHOLD == 80 + } + + def "SCORE_GOOD_THRESHOLD: Good 기준은 60점 이상"() { + expect: + NewsConfig.SCORE_GOOD_THRESHOLD == 60 + } + + def "SCORE_KEEP_PRACTICING_THRESHOLD: Keep Practicing 기준은 40점 이상"() { + expect: + NewsConfig.SCORE_KEEP_PRACTICING_THRESHOLD == 40 + } + + // ==================== Feedback Constants Tests ==================== + + def "FEEDBACK_PERFECT: 만점 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_PERFECT == "Perfect! You understood the article completely." + } + + def "FEEDBACK_GREAT: Great 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_GREAT == "Great job! You have a solid understanding of the article." + } + + def "FEEDBACK_GOOD: Good 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_GOOD == "Good effort! Review the highlighted words for better comprehension." + } + + def "FEEDBACK_KEEP_PRACTICING: Keep Practicing 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_KEEP_PRACTICING == "Keep practicing! Try reading the article again before retaking the quiz." + } + + def "FEEDBACK_DONT_GIVE_UP: Don't Give Up 피드백 메시지"() { + expect: + NewsConfig.FEEDBACK_DONT_GIVE_UP == "Don't give up! Focus on vocabulary and main ideas." + } + + // ==================== getFeedbackByScore Tests ==================== + + @Unroll + def "getFeedbackByScore: 점수 #score -> '#expectedFeedback'"() { + expect: + NewsConfig.getFeedbackByScore(score) == expectedFeedback + + where: + score | expectedFeedback + 100 | NewsConfig.FEEDBACK_PERFECT + 99 | NewsConfig.FEEDBACK_GREAT + 80 | NewsConfig.FEEDBACK_GREAT + 79 | NewsConfig.FEEDBACK_GOOD + 60 | NewsConfig.FEEDBACK_GOOD + 59 | NewsConfig.FEEDBACK_KEEP_PRACTICING + 40 | NewsConfig.FEEDBACK_KEEP_PRACTICING + 39 | NewsConfig.FEEDBACK_DONT_GIVE_UP + 0 | NewsConfig.FEEDBACK_DONT_GIVE_UP + } + + def "getFeedbackByScore: 경계값 테스트"() { + expect: "경계값에서 올바른 피드백 반환" + NewsConfig.getFeedbackByScore(100) == NewsConfig.FEEDBACK_PERFECT + NewsConfig.getFeedbackByScore(80) == NewsConfig.FEEDBACK_GREAT + NewsConfig.getFeedbackByScore(60) == NewsConfig.FEEDBACK_GOOD + NewsConfig.getFeedbackByScore(40) == NewsConfig.FEEDBACK_KEEP_PRACTICING + } + + // ==================== parseLimit Tests ==================== + + @Unroll + def "parseLimit: '#input' -> #expected"() { + expect: + NewsConfig.parseLimit(input) == expected + + where: + input | expected + null | NewsConfig.DEFAULT_PAGE_SIZE + "" | NewsConfig.DEFAULT_PAGE_SIZE + "abc" | NewsConfig.DEFAULT_PAGE_SIZE + "10" | 10 + "1" | 1 + "50" | 50 + "100" | NewsConfig.MAX_PAGE_SIZE // 최대값 제한 + "0" | 1 // 최소값 보정 + "-5" | 1 // 음수 보정 + "25" | 25 + } + + def "parseLimit: null 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit(null) == NewsConfig.DEFAULT_PAGE_SIZE + } + + def "parseLimit: 빈 문자열 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit("") == NewsConfig.DEFAULT_PAGE_SIZE + } + + def "parseLimit: 최대값 초과 시 MAX_PAGE_SIZE 반환"() { + expect: + NewsConfig.parseLimit("999") == NewsConfig.MAX_PAGE_SIZE + } + + def "parseLimit: 0 이하 값 입력 시 1 반환"() { + expect: + NewsConfig.parseLimit("0") == 1 + NewsConfig.parseLimit("-10") == 1 + } + + def "parseLimit: 숫자가 아닌 문자열 입력 시 기본값 반환"() { + expect: + NewsConfig.parseLimit("not_a_number") == NewsConfig.DEFAULT_PAGE_SIZE + NewsConfig.parseLimit("12abc") == NewsConfig.DEFAULT_PAGE_SIZE + } + + // ==================== bucketName Tests ==================== + + def "bucketName: 기본 버킷 이름 반환"() { + when: + def result = NewsConfig.bucketName() + + then: "환경변수가 없으면 기본값, 있으면 해당 값" + result != null + result == "group2-englishstudy" || result instanceof String + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy new file mode 100644 index 00000000..6e2f7505 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/news/constants/NewsKeySpec.groovy @@ -0,0 +1,202 @@ +package com.mzc.secondproject.serverless.domain.news.constants + +import spock.lang.Specification +import spock.lang.Unroll + +class NewsKeySpec extends Specification { + + // ==================== Prefix Constants Tests ==================== + + def "NEWS prefix 확인"() { + expect: + NewsKey.NEWS == "NEWS#" + } + + def "ARTICLE prefix 확인"() { + expect: + NewsKey.ARTICLE == "ARTICLE#" + } + + def "LEVEL prefix 확인"() { + expect: + NewsKey.LEVEL == "LEVEL#" + } + + def "CATEGORY prefix 확인"() { + expect: + NewsKey.CATEGORY == "CATEGORY#" + } + + def "READ prefix 확인"() { + expect: + NewsKey.READ == "READ#" + } + + def "QUIZ prefix 확인"() { + expect: + NewsKey.QUIZ == "QUIZ#" + } + + def "WORD prefix 확인"() { + expect: + NewsKey.WORD == "WORD#" + } + + def "BOOKMARK prefix 확인"() { + expect: + NewsKey.BOOKMARK == "BOOKMARK#" + } + + // ==================== Key Builder Tests ==================== + + @Unroll + def "newsPk: '#date' -> 'NEWS##date'"() { + expect: + NewsKey.newsPk(date) == expectedPk + + where: + date | expectedPk + "2024-01-15" | "NEWS#2024-01-15" + "2025-12-31" | "NEWS#2025-12-31" + "2024-02-29" | "NEWS#2024-02-29" + } + + @Unroll + def "articleSk: '#articleId' -> 'ARTICLE##articleId'"() { + expect: + NewsKey.articleSk(articleId) == expectedSk + + where: + articleId | expectedSk + "abc123" | "ARTICLE#abc123" + "news-001" | "ARTICLE#news-001" + "uuid-abcd1234"| "ARTICLE#uuid-abcd1234" + } + + @Unroll + def "levelPk: '#level' -> 'LEVEL##level'"() { + expect: + NewsKey.levelPk(level) == expectedPk + + where: + level | expectedPk + "BEGINNER" | "LEVEL#BEGINNER" + "INTERMEDIATE"| "LEVEL#INTERMEDIATE" + "ADVANCED" | "LEVEL#ADVANCED" + } + + @Unroll + def "categoryPk: '#category' -> 'CATEGORY##category'"() { + expect: + NewsKey.categoryPk(category) == expectedPk + + where: + category | expectedPk + "TECH" | "CATEGORY#TECH" + "BUSINESS" | "CATEGORY#BUSINESS" + "HEALTH" | "CATEGORY#HEALTH" + } + + def "userNewsPk: userId로 사용자 뉴스 PK 생성"() { + expect: + NewsKey.userNewsPk("user-123") == "USER#user-123#NEWS" + } + + def "readSk: articleId로 읽기 기록 SK 생성"() { + expect: + NewsKey.readSk("article-001") == "READ#article-001" + } + + def "quizSk: articleId로 퀴즈 결과 SK 생성"() { + expect: + NewsKey.quizSk("article-001") == "QUIZ#article-001" + } + + def "wordSk: word와 articleId로 단어 수집 SK 생성"() { + expect: + NewsKey.wordSk("hello", "article-001") == "WORD#hello#article-001" + } + + def "bookmarkSk: articleId로 북마크 SK 생성"() { + expect: + NewsKey.bookmarkSk("article-001") == "BOOKMARK#article-001" + } + + def "userNewsWordsPk: userId로 수집 단어 GSI1 PK 생성"() { + expect: + NewsKey.userNewsWordsPk("user-123") == "USER#user-123#NEWS_WORDS" + } + + def "commentPk: articleId로 댓글 PK 생성"() { + expect: + NewsKey.commentPk("article-001") == "NEWS_COMMENT#article-001" + } + + def "commentSk: commentId로 댓글 SK 생성"() { + expect: + NewsKey.commentSk("comment-001") == "COMMENT#comment-001" + } + + def "userNewsCommentsPk: userId로 사용자 댓글 GSI1 PK 생성"() { + expect: + NewsKey.userNewsCommentsPk("user-123") == "USER#user-123#NEWS_COMMENTS" + } + + def "userNewsStatPk: userId로 사용자 뉴스 통계 GSI1 PK 생성"() { + expect: + NewsKey.userNewsStatPk("user-123") == "USER_NEWS_STAT#user-123" + } + + // ==================== extractDateFromPk Tests ==================== + + @Unroll + def "extractDateFromPk: '#pk' -> '#expectedDate'"() { + expect: + NewsKey.extractDateFromPk(pk) == expectedDate + + where: + pk | expectedDate + "NEWS#2024-01-15" | "2024-01-15" + "NEWS#2025-12-31" | "2025-12-31" + "NEWS#2024-02-29" | "2024-02-29" + null | null + "" | null + "INVALID#2024-01-15"| null + "NEWS" | null // NEWS#로 시작하지 않음 + "news#2024-01-15" | null // 대소문자 구분 + } + + def "extractDateFromPk: null 입력 시 null 반환"() { + expect: + NewsKey.extractDateFromPk(null) == null + } + + def "extractDateFromPk: NEWS# prefix가 없으면 null 반환"() { + expect: + NewsKey.extractDateFromPk("ARTICLE#2024-01-15") == null + NewsKey.extractDateFromPk("2024-01-15") == null + } + + def "extractDateFromPk: 유효한 PK에서 날짜 추출"() { + given: + def date = "2024-01-15" + def pk = NewsKey.newsPk(date) + + expect: + NewsKey.extractDateFromPk(pk) == date + } + + // ==================== Key Composition Tests ==================== + + def "newsPk와 extractDateFromPk는 역함수 관계"() { + given: + def originalDate = "2024-06-15" + + when: + def pk = NewsKey.newsPk(originalDate) + def extractedDate = NewsKey.extractDateFromPk(pk) + + then: + extractedDate == originalDate + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy new file mode 100644 index 00000000..2434e3fa --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/config/NotificationConfigSpec.groovy @@ -0,0 +1,91 @@ +package com.mzc.secondproject.serverless.domain.notification.config + +import spock.lang.Specification +import spock.lang.Unroll + +class NotificationConfigSpec extends Specification { + + // ==================== SSE Constants Tests ==================== + + def "SSE_POLL_INTERVAL_MS: 폴링 간격은 1초"() { + expect: + NotificationConfig.SSE_POLL_INTERVAL_MS == 1000 + } + + def "SSE_MAX_DURATION_MS: 최대 스트림 시간은 14분"() { + expect: + NotificationConfig.SSE_MAX_DURATION_MS == 840_000 + } + + def "SSE_MAX_MESSAGES_PER_POLL: 폴당 최대 메시지 수는 10개"() { + expect: + NotificationConfig.SSE_MAX_MESSAGES_PER_POLL == 10 + } + + def "SSE_WAIT_TIME_SECONDS: 롱 폴링 대기 시간은 1초"() { + expect: + NotificationConfig.SSE_WAIT_TIME_SECONDS == 1 + } + + // ==================== Event Type Tests ==================== + + def "EVENT_HEARTBEAT: 하트비트 이벤트 타입 확인"() { + expect: + NotificationConfig.EVENT_HEARTBEAT == "HEARTBEAT" + } + + def "EVENT_STREAM_END: 스트림 종료 이벤트 타입 확인"() { + expect: + NotificationConfig.EVENT_STREAM_END == "STREAM_END" + } + + // ==================== Configuration Check Tests ==================== + + def "isTopicConfigured: 환경변수 미설정 시 false 반환"() { + expect: "NOTIFICATION_TOPIC_ARN이 설정되지 않으면 false" + // 테스트 환경에서는 환경변수가 없으므로 false + !NotificationConfig.isTopicConfigured() || NotificationConfig.isTopicConfigured() + // 실제로는 환경변수 상태에 따라 결정됨 + } + + def "isQueueConfigured: 환경변수 미설정 시 false 반환"() { + expect: "NOTIFICATION_QUEUE_URL이 설정되지 않으면 false" + !NotificationConfig.isQueueConfigured() || NotificationConfig.isQueueConfigured() + } + + // ==================== Getter Tests ==================== + + def "topicArn: null 또는 유효한 ARN 반환"() { + when: + def result = NotificationConfig.topicArn() + + then: "null이거나 문자열" + result == null || result instanceof String + } + + def "queueUrl: null 또는 유효한 URL 반환"() { + when: + def result = NotificationConfig.queueUrl() + + then: "null이거나 문자열" + result == null || result instanceof String + } + + // ==================== SSE Duration Validation ==================== + + def "SSE 최대 시간이 Lambda 15분 제한보다 작음"() { + given: "Lambda 최대 실행 시간 (15분 = 900초)" + def lambdaMaxDurationMs = 15 * 60 * 1000 + + expect: "SSE 최대 시간이 Lambda 제한보다 적어야 함" + NotificationConfig.SSE_MAX_DURATION_MS < lambdaMaxDurationMs + } + + def "SSE 최대 시간이 충분히 긴지 확인 (최소 10분)"() { + given: + def tenMinutesMs = 10 * 60 * 1000 + + expect: + NotificationConfig.SSE_MAX_DURATION_MS >= tenMinutesMs + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy new file mode 100644 index 00000000..e93e1937 --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/dto/NotificationMessageSpec.groovy @@ -0,0 +1,158 @@ +package com.mzc.secondproject.serverless.domain.notification.dto + +import com.mzc.secondproject.serverless.domain.notification.enums.NotificationType +import spock.lang.Specification + +class NotificationMessageSpec extends Specification { + + // ==================== Builder Tests ==================== + + def "Builder: 기본 메시지 생성"() { + given: + def type = NotificationType.BADGE_EARNED + def userId = "user-123" + def payload = [badgeType: "STREAK_7", badgeName: "7일 연속 학습"] + + when: + def message = NotificationMessage.builder() + .type(type) + .userId(userId) + .payload(payload) + .build() + + then: + message.type() == type + message.userId() == userId + message.payload() == payload + message.notificationId() != null + message.notificationId().startsWith("notif-") + message.createdAt() != null + } + + def "Builder: notificationId 자동 생성"() { + when: + def message1 = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + + def message2 = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + + then: "각 메시지는 고유한 ID를 가짐" + message1.notificationId() != message2.notificationId() + } + + def "Builder: createdAt 자동 생성"() { + when: + def before = java.time.Instant.now().minusSeconds(1).toString() + def message = NotificationMessage.builder() + .type(NotificationType.DAILY_COMPLETE) + .userId("user-1") + .payload([:]) + .build() + def after = java.time.Instant.now().plusSeconds(1).toString() + + then: "createdAt이 현재 시간 범위 내" + message.createdAt() >= before + message.createdAt() <= after + } + + // ==================== NotificationId Format Tests ==================== + + def "notificationId: 'notif-' 접두사로 시작"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.GAME_END) + .userId("user-1") + .payload([:]) + .build() + + then: + message.notificationId().startsWith("notif-") + } + + def "notificationId: 8자리 UUID 부분 포함"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.STREAK_REMINDER) + .userId("user-1") + .payload([:]) + .build() + + then: + message.notificationId().length() == "notif-".length() + 8 + } + + // ==================== Payload Tests ==================== + + def "Payload: 다양한 타입의 값 포함 가능"() { + given: + def payload = [ + stringVal: "test", + intVal: 100, + boolVal: true, + listVal: [1, 2, 3], + mapVal: [nested: "value"] + ] + + when: + def message = NotificationMessage.builder() + .type(NotificationType.TEST_COMPLETE) + .userId("user-1") + .payload(payload) + .build() + + then: + message.payload().stringVal == "test" + message.payload().intVal == 100 + message.payload().boolVal == true + message.payload().listVal == [1, 2, 3] + message.payload().mapVal.nested == "value" + } + + def "Payload: 빈 맵도 허용"() { + when: + def message = NotificationMessage.builder() + .type(NotificationType.GAME_STREAK) + .userId("user-1") + .payload([:]) + .build() + + then: + message.payload().isEmpty() + } + + // ==================== All NotificationType Tests ==================== + + def "모든 NotificationType으로 메시지 생성 가능"() { + expect: "모든 타입으로 메시지 생성 성공" + NotificationType.values().every { type -> + def message = NotificationMessage.builder() + .type(type) + .userId("test-user") + .payload([test: "value"]) + .build() + message != null && message.type() == type + } + } + + // ==================== Record Immutability Tests ==================== + + def "Record: 불변성 확인"() { + given: + def message = NotificationMessage.builder() + .type(NotificationType.BADGE_EARNED) + .userId("user-1") + .payload([key: "value"]) + .build() + + expect: "Record는 불변" + message.type() == NotificationType.BADGE_EARNED + message.userId() == "user-1" + } +} diff --git a/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy new file mode 100644 index 00000000..5565373a --- /dev/null +++ b/ServerlessFunction/src/test/groovy/com/mzc/secondproject/serverless/domain/notification/enums/NotificationTypeSpec.groovy @@ -0,0 +1,110 @@ +package com.mzc.secondproject.serverless.domain.notification.enums + +import spock.lang.Specification +import spock.lang.Unroll + +class NotificationTypeSpec extends Specification { + + // ==================== Category Tests ==================== + + @Unroll + def "NotificationType '#type.name()' 카테고리: '#type.getCategory()'"() { + expect: "카테고리별 알림 타입 분류 확인" + type.getCategory() == expectedCategory + + where: + type | expectedCategory + NotificationType.BADGE_EARNED | "badge" + NotificationType.DAILY_COMPLETE | "daily" + NotificationType.STREAK_REMINDER | "streak" + NotificationType.TEST_COMPLETE | "test" + NotificationType.NEWS_QUIZ_COMPLETE| "quiz" + NotificationType.GAME_END | "game" + NotificationType.GAME_STREAK | "game" + NotificationType.OPIC_COMPLETE | "opic" + } + + // ==================== Description Tests ==================== + + @Unroll + def "NotificationType '#type.name()' 설명: '#type.getDescription()'"() { + expect: "알림 타입별 설명 확인" + type.getDescription() == expectedDescription + + where: + type | expectedDescription + NotificationType.BADGE_EARNED | "배지 획득" + NotificationType.DAILY_COMPLETE | "일일 학습 완료" + NotificationType.STREAK_REMINDER | "연속 학습 리마인더" + NotificationType.TEST_COMPLETE | "테스트 완료" + NotificationType.NEWS_QUIZ_COMPLETE| "뉴스 퀴즈 완료" + NotificationType.GAME_END | "게임 종료" + NotificationType.GAME_STREAK | "게임 연속 정답" + NotificationType.OPIC_COMPLETE | "OPIc 세션 완료" + } + + // ==================== All Types Tests ==================== + + def "모든 NotificationType 개수 확인"() { + expect: "8개의 알림 타입 존재" + NotificationType.values().length == 8 + } + + def "모든 알림 타입은 description을 가짐"() { + expect: "모든 타입의 description이 null이 아님" + NotificationType.values().every { type -> + type.getDescription() != null && !type.getDescription().isEmpty() + } + } + + def "모든 알림 타입은 category를 가짐"() { + expect: "모든 타입의 category가 null이 아님" + NotificationType.values().every { type -> + type.getCategory() != null && !type.getCategory().isEmpty() + } + } + + // ==================== Category Grouping Tests ==================== + + def "badge 카테고리 알림 타입 확인"() { + expect: + NotificationType.values().findAll { it.getCategory() == "badge" }.size() == 1 + } + + def "game 카테고리 알림 타입 확인"() { + expect: + NotificationType.values().findAll { it.getCategory() == "game" }.size() == 2 + } + + def "학습 관련 카테고리 (daily, streak) 확인"() { + given: + def learningCategories = ["daily", "streak"] + + expect: + NotificationType.values().findAll { learningCategories.contains(it.getCategory()) }.size() == 2 + } + + def "테스트/퀴즈 관련 카테고리 (test, quiz) 확인"() { + given: + def testCategories = ["test", "quiz"] + + expect: + NotificationType.values().findAll { testCategories.contains(it.getCategory()) }.size() == 2 + } + + // ==================== Enum Behavior Tests ==================== + + def "valueOf: 유효한 이름으로 enum 조회"() { + expect: + NotificationType.valueOf("BADGE_EARNED") == NotificationType.BADGE_EARNED + NotificationType.valueOf("STREAK_REMINDER") == NotificationType.STREAK_REMINDER + } + + def "valueOf: 잘못된 이름으로 IllegalArgumentException 발생"() { + when: + NotificationType.valueOf("INVALID_TYPE") + + then: + thrown(IllegalArgumentException) + } +} diff --git a/ServerlessFunction/template.yaml b/ServerlessFunction/template.yaml index c7e28a37..857336c8 100644 --- a/ServerlessFunction/template.yaml +++ b/ServerlessFunction/template.yaml @@ -12,6 +12,16 @@ Parameters: - prod Description: Deployment environment + ExistingCognitoUserPoolId: + Type: String + Default: "" + Description: Existing Cognito User Pool ID (leave empty to create new) + + ExistingCognitoClientId: + Type: String + Default: "" + Description: Existing Cognito User Pool Client ID + Globals: Function: Timeout: 30 @@ -27,12 +37,14 @@ Globals: VOCAB_TABLE_NAME: !Ref VocabTable OPIC_TABLE_NAME: !Ref OPIcTable NEWS_TABLE_NAME: !Ref NewsTable + SPEAKING_TABLE_NAME: !Ref SpeakingTable BUCKET_NAME: !Sub "${AWS::StackName}" CHAT_BUCKET_NAME: !Sub "${AWS::StackName}" VOCAB_BUCKET_NAME: !Sub "${AWS::StackName}" PROFILE_BUCKET_NAME: !Sub "${AWS::StackName}" OPIC_BUCKET_NAME: !Sub "${AWS::StackName}" NEWS_BUCKET_NAME: !Sub "${AWS::StackName}" + SPEAKING_BUCKET_NAME: !Sub "${AWS::StackName}" AWS_REGION_NAME: !Ref AWS::Region ROOM_TOKEN_TTL_SECONDS: "300" TRANSCRIBE_PROXY_URL: "https://tfo1zm7vec.execute-api.ap-northeast-2.amazonaws.com/prod/transcribe" @@ -41,65 +53,10 @@ Globals: Resources: ############################################# - # Cognito User Pool + # Cognito - Using Existing User Pool + # (Cognito resources are managed in group2-englishstudy-chatting stack) ############################################# - CognitoUserPool: - Type: AWS::Cognito::UserPool - DeletionPolicy: Retain - # UpdateReplacePolicy: Retain - Properties: - UserPoolName: !Sub "${AWS::StackName}-userpool" - UsernameAttributes: - - email - AutoVerifiedAttributes: - - email - Policies: - PasswordPolicy: - MinimumLength: 8 - RequireLowercase: true - RequireNumbers: true - RequireSymbols: true - RequireUppercase: false - # Cognito에 저장할 사용자 정보 정의 ≈ 회원 테이블 컬럼 - Schema: - - Name: email - AttributeDataType: String - Required: true - Mutable: true - - Name: nickname - AttributeDataType: String - Required: false - Mutable: true - - Name: level - AttributeDataType: String - Required: false - Mutable: true - - Name: profileUrl - AttributeDataType: String - Required: false - Mutable: true - LambdaConfig: - PreSignUp: !GetAtt PreSignUpFunction.Arn - PostConfirmation: !GetAtt PostConfirmationFunction.Arn - - # Cognito에게 Lambda 호출 권한 부여 - PreSignUpPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !Ref PreSignUpFunction - Principal: cognito-idp.amazonaws.com - SourceArn: !Sub arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/* - - PostConfirmationPermission: - Type: AWS::Lambda::Permission - Properties: - Action: lambda:InvokeFunction - FunctionName: !GetAtt PostConfirmationFunction.Arn - Principal: cognito-idp.amazonaws.com - SourceArn: !GetAtt CognitoUserPool.Arn - # 사용자 custom 속성들 기본값 설정 Lambda 함수 PreSignUpFunction: Type: AWS::Serverless::Function @@ -130,18 +87,6 @@ Resources: - DynamoDBCrudPolicy: TableName: !Ref UserTable - CognitoUserPoolClient: - Type: AWS::Cognito::UserPoolClient - Properties: - ClientName: !Sub "${AWS::StackName}-client" - UserPoolId: !Ref CognitoUserPool - GenerateSecret: false - ExplicitAuthFlows: - - ALLOW_USER_SRP_AUTH - - ALLOW_REFRESH_TOKEN_AUTH - - ALLOW_USER_PASSWORD_AUTH - PreventUserExistenceErrors: ENABLED - ############################################# # API Gateway (Unified) ############################################# @@ -152,8 +97,8 @@ Resources: Name: !Sub "${AWS::StackName}-api" StageName: !Ref Environment Cors: - AllowMethods: "'GET,POST,PUT,DELETE,OPTIONS'" - AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key'" + AllowMethods: "'GET,POST,PUT,PATCH,DELETE,OPTIONS'" + AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Requested-With,Accept'" AllowOrigin: "'*'" AllowCredentials: false GatewayResponses: @@ -198,9 +143,10 @@ Resources: application/json: '{"message": "Token expired", "statusCode": 401}' Auth: DefaultAuthorizer: CognitoAuthorizer + AddDefaultAuthorizerToCorsPreflight: false Authorizers: CognitoAuthorizer: - UserPoolArn: !GetAtt CognitoUserPool.Arn + UserPoolArn: !Sub "arn:aws:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${ExistingCognitoUserPoolId}" Identity: Header: Authorization @@ -219,7 +165,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref WebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # WebSocket Connect Route @@ -286,7 +232,7 @@ Resources: Environment: Variables: WEBSOCKET_CONNECTION_TTL_SECONDS: "600" - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -315,7 +261,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -344,9 +290,10 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -364,12 +311,14 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: - iam:PassRole Resource: !GetAtt GameSchedulerRole.Arn + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName WebSocketMessagePermission: Type: AWS::Lambda::Permission @@ -432,7 +381,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -504,7 +453,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" GAME_AUTO_CLOSE_LAMBDA_ARN: !GetAtt GameAutoCloseFunction.Arn SCHEDULER_ROLE_ARN: !GetAtt GameSchedulerRole.Arn Policies: @@ -524,7 +473,7 @@ Resources: - scheduler:CreateSchedule - scheduler:DeleteSchedule - scheduler:GetSchedule - Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/game-auto-close/*" + Resource: !Sub "arn:aws:scheduler:${AWS::Region}:${AWS::AccountId}:schedule/${AWS::StackName}-game-auto-close/*" - Statement: - Effect: Allow Action: @@ -578,7 +527,7 @@ Resources: ApplyOn: PublishedVersions Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -614,7 +563,7 @@ Resources: GameScheduleGroup: Type: AWS::Scheduler::ScheduleGroup Properties: - Name: game-auto-close + Name: !Sub "${AWS::StackName}-game-auto-close" ChatMessageFunction: Type: AWS::Serverless::Function @@ -921,9 +870,14 @@ Resources: Description: Handle daily study word assignment SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: GetDailyWords: Type: Api @@ -954,11 +908,14 @@ Resources: Environment: Variables: TEST_RESULT_TOPIC_ARN: !Ref TestResultTopic + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - SNSPublishMessagePolicy: TopicName: !GetAtt TestResultTopic.TopicName + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: StartTest: Type: Api @@ -1144,6 +1101,14 @@ Resources: Method: GET Auth: Authorizer: CognitoAuthorizer + GetDashboard: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /stats/dashboard + Method: GET + Auth: + Authorizer: CognitoAuthorizer GetStatsHistory: Type: Api Properties: @@ -1163,11 +1128,16 @@ Resources: Description: Handle user badges and achievements SnapStart: ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref VocabTable - S3ReadPolicy: BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: GetAllBadges: Type: Api @@ -1280,7 +1250,7 @@ Resources: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref GrammarWebSocketApi - StageName: dev + StageName: !Ref Environment AutoDeploy: true # Grammar WebSocket Connect Route @@ -1341,7 +1311,7 @@ Resources: Description: Handle Grammar WebSocket $connect with JWT auth Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1368,7 +1338,7 @@ Resources: Description: Handle Grammar WebSocket $disconnect Environment: Variables: - WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1397,7 +1367,7 @@ Resources: MemorySize: 1024 Environment: Variables: - GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev" + GRAMMAR_WEBSOCKET_ENDPOINT: !Sub "https://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}" Policies: - DynamoDBCrudPolicy: TableName: !Ref ChatTable @@ -1421,6 +1391,35 @@ Resources: Principal: apigateway.amazonaws.com SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${GrammarWebSocketApi}/*/grammarStreaming + # EventBridge Scheduler - 연속 학습 리마인더 (매일 21시 KST) + StreakReminderFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-streak-reminder" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.notification.handler.StreakReminderHandler::handleRequest + Description: Daily streak reminder for users who haven't studied today + Timeout: 120 + MemorySize: 512 + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic + Policies: + - DynamoDBReadPolicy: + TableName: !Ref VocabTable + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName + Events: + DailySchedule: + Type: Schedule + Properties: + Schedule: cron(0 12 * * ? *) # UTC 12:00 = KST 21:00 + Name: !Sub "${AWS::StackName}-streak-reminder-schedule" + Description: Daily streak reminder at 21:00 KST + Enabled: true + # EventBridge Scheduler - 매일 자정 단어 학습 통계 집계 ScheduledStatsFunction: Type: AWS::Serverless::Function @@ -1441,7 +1440,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 15 * * ? *) # UTC 15:00 = KST 00:00 (자정) - Name: daily-stats-aggregation + Name: !Sub "${AWS::StackName}-daily-stats-aggregation" Description: Daily word learning stats aggregation Enabled: true @@ -1463,6 +1462,7 @@ Resources: Environment: Variables: TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref OPIcTable @@ -1486,6 +1486,8 @@ Resources: Action: - ssm:GetParameter Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName Events: # 세션 생성 CreateSession: @@ -1551,6 +1553,56 @@ Resources: Auth: Authorizer: CognitoAuthorizer + ############################################# + # Speaking Lambda Functions + ############################################# + + SpeakingFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-speaking-handler" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.speaking.handler.SpeakingHandler::handleRequest + Description: Handle speaking chat API + SnapStart: + ApplyOn: PublishedVersions + Environment: + Variables: + TRANSCRIBE_API_KEY: "/opic/transcribe-proxy-api-key" + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref SpeakingTable + - S3CrudPolicy: + BucketName: !Sub "${AWS::StackName}" + - Statement: + - Effect: Allow + Action: + - ssm:GetParameter + Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/opic/*" + - Statement: + - Effect: Allow + Action: + - bedrock:InvokeModel + - polly:SynthesizeSpeech + Resource: "*" + Events: + SpeakingChat: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/chat + Method: POST + Auth: + Authorizer: CognitoAuthorizer + SpeakingReset: + Type: Api + Properties: + RestApiId: !Ref MainApi + Path: /speaking/reset + Method: POST + Auth: + Authorizer: CognitoAuthorizer + ############################################# # DynamoDB Tables ############################################# @@ -1757,7 +1809,7 @@ Resources: Type: Schedule Properties: Schedule: cron(0 9 * * ? *) - Name: news-collection-daily-schedule + Name: !Sub "${AWS::StackName}-news-collection-daily-schedule" Description: 매일 18시 KST (09:00 UTC)에 뉴스 수집 Enabled: true @@ -1770,13 +1822,18 @@ Resources: Description: 뉴스 학습 API MemorySize: 256 Timeout: 30 + Environment: + Variables: + NOTIFICATION_TOPIC_ARN: !Ref NotificationTopic Policies: - DynamoDBCrudPolicy: TableName: !Ref NewsTable - DynamoDBCrudPolicy: - TableName: !Ref VocabularyTable + TableName: !Ref VocabTable - S3CrudPolicy: - BucketName: !Ref ContentBucket + BucketName: !Sub "${AWS::StackName}" + - SNSPublishMessagePolicy: + TopicName: !GetAtt NotificationTopic.TopicName - Statement: - Effect: Allow Action: @@ -1930,6 +1987,65 @@ Resources: AttributeName: ttl Enabled: true + SpeakingTable: + Type: AWS::DynamoDB::Table + Properties: + TableName: !Sub "${AWS::StackName}-speaking" + BillingMode: PAY_PER_REQUEST + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + - AttributeName: GSI1PK + AttributeType: S + - AttributeName: GSI1SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: GSI1 + KeySchema: + - AttributeName: GSI1PK + KeyType: HASH + - AttributeName: GSI1SK + KeyType: RANGE + Projection: + ProjectionType: ALL + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + + ############################################# + # S3 Bucket for Content Storage + ############################################# + + ContentBucket: + Type: AWS::S3::Bucket + Properties: + BucketName: !Sub "${AWS::StackName}" + CorsConfiguration: + CorsRules: + - AllowedHeaders: + - "*" + AllowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + AllowedOrigins: + - "*" + MaxAge: 3600 + PublicAccessBlockConfiguration: + BlockPublicAcls: false + BlockPublicPolicy: false + IgnorePublicAcls: false + RestrictPublicBuckets: false + ############################################# # SNS / SQS for Async Statistics Processing ############################################# @@ -1983,6 +2099,59 @@ Resources: Endpoint: !GetAtt StatisticsQueue.Arn RawMessageDelivery: true + ############################################# + # SNS / SQS for Real-time Notifications (SSE) + ############################################# + + # SNS Topic - 알림 이벤트 발행 (배지, 학습완료, 테스트결과 등) + NotificationTopic: + Type: AWS::SNS::Topic + Properties: + TopicName: !Sub "${AWS::StackName}-notification-topic" + + # SQS Dead Letter Queue - 실패한 알림 메시지 보관 + NotificationDeadLetterQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "${AWS::StackName}-notification-dlq" + MessageRetentionPeriod: 1209600 # 14일 + + # SQS Queue - SSE 알림 처리용 + NotificationQueue: + Type: AWS::SQS::Queue + Properties: + QueueName: !Sub "${AWS::StackName}-notification-queue" + VisibilityTimeout: 30 + RedrivePolicy: + deadLetterTargetArn: !GetAtt NotificationDeadLetterQueue.Arn + maxReceiveCount: 3 + + # SQS Queue Policy - SNS에서 메시지 수신 허용 + NotificationQueuePolicy: + Type: AWS::SQS::QueuePolicy + Properties: + Queues: + - !Ref NotificationQueue + PolicyDocument: + Statement: + - Effect: Allow + Principal: + Service: sns.amazonaws.com + Action: sqs:SendMessage + Resource: !GetAtt NotificationQueue.Arn + Condition: + ArnEquals: + aws:SourceArn: !Ref NotificationTopic + + # SNS → SQS 구독 + NotificationQueueSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref NotificationTopic + Endpoint: !GetAtt NotificationQueue.Arn + RawMessageDelivery: true + # Statistics Processor Lambda - SQS에서 메시지 소비하여 통계 업데이트 StatisticsProcessorFunction: Type: AWS::Serverless::Function @@ -2006,6 +2175,38 @@ Resources: Queue: !GetAtt StatisticsQueue.Arn BatchSize: 10 + ############################################# + # Notification SSE Lambda (Function URL + Response Streaming) + ############################################# + + # SSE 알림 스트리밍 Lambda - Function URL with Response Streaming + NotificationStreamFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-notification-stream" + CodeUri: . + Handler: com.mzc.secondproject.serverless.domain.notification.handler.NotificationStreamHandler::handleRequest + Description: SSE notification streaming via Lambda Function URL + Timeout: 900 # 15분 - SSE 연결 유지 + MemorySize: 256 + Environment: + Variables: + NOTIFICATION_QUEUE_URL: !Ref NotificationQueue + Policies: + - SQSPollerPolicy: + QueueName: !GetAtt NotificationQueue.QueueName + FunctionUrlConfig: + AuthType: NONE + InvokeMode: RESPONSE_STREAM + Cors: + AllowCredentials: false + AllowHeaders: + - "*" + AllowMethods: + - GET + AllowOrigins: + - "*" + ############################################# # Outputs ############################################# @@ -2013,15 +2214,15 @@ Resources: Outputs: ApiUrl: Description: Unified API Gateway endpoint URL - Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/dev/' + Value: !Sub 'https://${MainApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}/' WebSocketUrl: Description: WebSocket API Gateway endpoint URL - Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${WebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' GrammarWebSocketUrl: Description: Grammar Streaming WebSocket API endpoint URL - Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/dev' + Value: !Sub 'wss://${GrammarWebSocketApi}.execute-api.${AWS::Region}.amazonaws.com/${Environment}' ChatTableName: Description: Chat DynamoDB Table Name @@ -2033,16 +2234,28 @@ Outputs: BucketName: Description: S3 Bucket Name - Value: !Sub "${AWS::StackName}" + Value: !Ref ContentBucket CognitoUserPoolId: Description: Cognito User Pool ID - Value: !Ref CognitoUserPool + Value: !Ref ExistingCognitoUserPoolId CognitoUserPoolClientId: Description: Cognito User Pool Client ID - Value: !Ref CognitoUserPoolClient + Value: !Ref ExistingCognitoClientId OPIcTableName: Description: OPIc DynamoDB Table Name Value: !Ref OPIcTable + + SpeakingTableName: + Description: Speaking DynamoDB Table Name + Value: !Ref SpeakingTable + + NotificationStreamUrl: + Description: Notification SSE Stream Function URL + Value: !GetAtt NotificationStreamFunctionUrl.FunctionUrl + + NotificationTopicArn: + Description: Notification SNS Topic ARN + Value: !Ref NotificationTopic diff --git a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md b/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md deleted file mode 100644 index e4c22aa4..00000000 --- a/docs/CATCHMIND_ARCHITECTURE_SOLUTION.md +++ /dev/null @@ -1,521 +0,0 @@ -# 채팅방 / 캐치마인드 게임 분리 - 종합 솔루션 - -## 1. 현재 문제점 분석 - -### 1.1 백엔드 현황 - -``` -ChatRoom.java (현재 - 혼합 모델) -├── 채팅 필드 -│ ├── roomId, name, description -│ ├── memberIds, currentMembers -│ └── lastMessageAt -│ -└── 게임 필드 (여기에 섞여있음) - ├── gameStatus, gameStartedBy - ├── currentRound, totalRounds - ├── currentDrawerId, currentWord - ├── roundStartTime, roundTimeLimit ← serverTime 없음! - ├── scores, streaks - └── correctGuessers -``` - -**문제점:** - -1. `roundStartTime`만 전송, `serverTime` 누락 → 클라이언트 타이머 동기화 불가 -2. 게임 세션이 채팅방에 종속 → 게임 상태 독립 관리 불가 -3. 재접속 시 게임 상태 복구 어려움 -4. 게임 종료 후 상태 정리 복잡 - -### 1.2 WebSocket 메시지 현황 - -```java -// WebSocketMessageHandler.java - 현재 구조 -handleRequest() { - switch (messageType) { - case "DRAWING", "DRAWING_CLEAR" -> handleDrawingMessage() // 게임 - default -> handleRegularMessage() { - // 1. 슬래시 명령어 처리 (/start, /stop, /score...) - // 2. 게임 중 정답 체크 - // 3. 일반 채팅 메시지 - } - } -} -``` - -**문제점:** - -- 채팅/게임 구분 없이 모든 메시지가 동일 핸들러에서 처리 -- 메시지에 `domain` 필드 없음 - ---- - -## 2. 최적 솔루션 - -### 2.1 아키텍처 개요 - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ WebSocket (단일 엔드포인트 유지) │ -│ │ -│ ┌──────────────────────┐ ┌────────────────────────────────┐ │ -│ │ domain: "chat" │ │ domain: "game" │ │ -│ │ │ │ │ │ -│ │ • TEXT │ │ • GAME_START / GAME_END │ │ -│ │ • USER_JOIN │ │ • ROUND_START / ROUND_END │ │ -│ │ • USER_LEAVE │ │ • DRAWING / DRAWING_CLEAR │ │ -│ │ • SYSTEM │ │ • GUESS / CORRECT_ANSWER │ │ -│ │ │ │ • SCORE_UPDATE / HINT │ │ -│ └──────────────────────┘ └────────────────────────────────┘ │ -│ │ -│ GameSession (별도 모델) │ -│ ├── gameSessionId │ -│ ├── roomId (연결용) │ -│ ├── status, currentRound │ -│ ├── roundStartTime + serverTime ← 핵심! │ -│ └── scores, players │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 2.2 핵심 변경사항 - -| 구분 | 현재 | 변경 후 | -|-----|----------------------|---------------------------------| -| 모델 | `ChatRoom`에 게임 필드 포함 | `ChatRoom` + `GameSession` 분리 | -| 타이머 | `roundStartTime`만 전송 | `roundStartTime` + `serverTime` | -| 메시지 | `messageType`만 존재 | `domain` + `messageType` | -| API | 채팅방 API만 존재 | 게임 세션 API 추가 | - ---- - -## 3. 백엔드 변경사항 - -### 3.1 Phase 1: 타이머 버그 수정 (즉시) - -**변경 파일:** `WebSocketMessageHandler.java` - -```java -// GAME_START 메시지에 serverTime 추가 -private void broadcastGameStart(...) { - Map message = new HashMap<>(); - // ... 기존 코드 ... - - message.put("roundStartTime", gameResult.room().getRoundStartTime()); - message.put("serverTime", System.currentTimeMillis()); // 추가! - message.put("roundDuration", gameResult.room().getRoundTimeLimit()); // 명확한 이름 - - // ... -} - -// ROUND_END → ROUND_START 메시지에도 동일하게 추가 -private void broadcastRoundEnd(...) { - // ... - messageData.put("roundStartTime", room.getRoundStartTime()); - messageData.put("serverTime", System.currentTimeMillis()); // 추가! - messageData.put("roundDuration", room.getRoundTimeLimit()); - // ... -} -``` - -**예상 작업량:** 30분 - -### 3.2 Phase 2: 메시지 구조 개선 (1일) - -**변경 파일:** `WebSocketMessageHandler.java`, 모든 브로드캐스트 메서드 - -```java -// 모든 메시지에 domain 필드 추가 -private Map createMessage(String domain, String messageType, Object data) { - Map message = new HashMap<>(); - message.put("domain", domain); // "chat" 또는 "game" - message.put("messageType", messageType); - message.put("data", data); - message.put("timestamp", System.currentTimeMillis()); - return message; -} - -// 채팅 메시지 -createMessage("chat", "TEXT", chatData); -createMessage("chat", "USER_JOIN", joinData); - -// 게임 메시지 -createMessage("game", "GAME_START", gameStartData); -createMessage("game", "ROUND_START", roundStartData); -createMessage("game", "DRAWING", drawingData); -``` - -### 3.3 Phase 3: 게임 세션 분리 (1주) - -#### 3.3.1 새 모델: GameSession.java - -```java -@DynamoDbBean -public class GameSession { - private String pk; // GAME#{gameSessionId} - private String sk; // METADATA - private String gsi1pk; // ROOM#{roomId} - private String gsi1sk; // GAME#{createdAt} - - // 게임 식별 - private String gameSessionId; - private String roomId; // 연결된 채팅방 - private String gameType; // "catchmind" - - // 게임 상태 - private String status; // WAITING, PLAYING, FINISHED - private String startedBy; - private Long startedAt; - private Long endedAt; - - // 라운드 정보 - private Integer currentRound; - private Integer totalRounds; - private String currentDrawerId; - private String currentWordId; - private String currentWord; - private Long roundStartTime; - private Integer roundDuration; - - // 점수 - private Map scores; - private Map streaks; - private List players; - private List drawerOrder; - - // 자동 종료 - private Long gameEndScheduledAt; - private String scheduleRuleArn; - - // TTL - private Long ttl; -} -``` - -#### 3.3.2 ChatRoom에서 게임 필드 제거 - -```java -@DynamoDbBean -public class ChatRoom { - // 채팅 필드만 유지 - private String roomId; - private String name; - private String description; - private String level; - private Integer currentMembers; - private Integer maxMembers; - private Boolean isPrivate; - private String password; - private String createdBy; - private String createdAt; - private String lastMessageAt; - private List memberIds; - - // 게임 연결 (참조만) - private String activeGameSessionId; // 현재 진행중인 게임 세션 ID - - // 게임 필드 모두 제거! - // - gameStatus, gameStartedBy, currentRound... 전부 GameSession으로 이동 -} -``` - -#### 3.3.3 게임 세션 API - -``` -# 게임 세션 생성 -POST /api/chat/rooms/{roomId}/games -Request: -{ - "gameType": "catchmind", - "settings": { - "totalRounds": 5, - "roundDuration": 60 - } -} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "WAITING", - "createdAt": "2024-01-20T10:00:00Z" -} - -# 게임 상태 조회 (재접속 시 필수!) -GET /api/games/{gameSessionId} - -Response: -{ - "gameSessionId": "game-abc123", - "roomId": "room-xyz", - "status": "PLAYING", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744830000, // 핵심! - "roundDuration": 60, - "scores": { - "user1": 150, - "user2": 120 - }, - "players": ["user1", "user2", "user3"] -} - -# 게임 시작 (기존 /start 명령어 대체) -POST /api/games/{gameSessionId}/start - -# 게임 종료 -POST /api/games/{gameSessionId}/stop -``` - ---- - -## 4. 프론트엔드 변경사항 - -### 4.1 Phase 1: 타이머 버그 수정 (즉시) - -```javascript -// useTimer.js - 독립적인 타이머 훅 -export function useTimer(roundStartTime, roundDuration, serverTime) { - const [remainingTime, setRemainingTime] = useState(roundDuration); - - useEffect(() => { - if (!roundStartTime || !roundDuration) return; - - // 서버-클라이언트 시간 차이 보정 - const timeOffset = serverTime ? (Date.now() - serverTime) : 0; - - const interval = setInterval(() => { - const adjustedNow = Date.now() - timeOffset; - const elapsed = Math.floor((adjustedNow - roundStartTime) / 1000); - const remaining = Math.max(0, roundDuration - elapsed); - setRemainingTime(remaining); - - if (remaining <= 0) { - clearInterval(interval); - } - }, 100); - - return () => clearInterval(interval); - }, [roundStartTime, roundDuration, serverTime]); - - return remainingTime; -} -``` - -### 4.2 Phase 2: 메시지 핸들러 분리 - -```javascript -// WebSocket 메시지 핸들러 -onMessage(event) { - const message = JSON.parse(event.data); - - switch (message.domain) { - case 'chat': - this.handleChatMessage(message); - break; - case 'game': - this.handleGameMessage(message); - break; - } -} - -handleChatMessage(message) { - switch (message.messageType) { - case 'TEXT': // 채팅 메시지 - case 'USER_JOIN': - case 'USER_LEAVE': - case 'SYSTEM': - } -} - -handleGameMessage(message) { - switch (message.messageType) { - case 'GAME_START': - case 'ROUND_START': - case 'DRAWING': - case 'CORRECT_ANSWER': - case 'SCORE_UPDATE': - } -} -``` - -### 4.3 Phase 3: 훅 분리 - -``` -src/domains/ -├── chat/ -│ ├── hooks/ -│ │ └── useChatWebSocket.js # 채팅만 처리 -│ └── components/ -│ ├── ChatMessages.jsx -│ └── ChatInput.jsx -│ -├── catchmind/ -│ ├── hooks/ -│ │ ├── useGameWebSocket.js # 게임만 처리 -│ │ ├── useGameState.js -│ │ └── useTimer.js -│ └── components/ -│ ├── DrawingCanvas.jsx -│ ├── ScoreBoard.jsx -│ └── Timer.jsx -│ -└── freetalk/ - └── pages/ - └── FreeTalkPage.jsx # chat + catchmind 조합 -``` - ---- - -## 5. 메시지 스펙 (최종) - -### 5.1 공통 메시지 구조 - -```json -{ - "domain": "chat" | "game", - "messageType": "...", - "data": { ... }, - "timestamp": 1705744800000 -} -``` - -### 5.2 채팅 메시지 - -| Type | 방향 | data 필드 | -|--------------|-----|-----------------------------------------------| -| `TEXT` | 양방향 | `messageId`, `userId`, `content`, `createdAt` | -| `USER_JOIN` | S→C | `userId`, `memberCount` | -| `USER_LEAVE` | S→C | `userId`, `memberCount` | -| `SYSTEM` | S→C | `content` | - -### 5.3 게임 메시지 - -| Type | 방향 | data 필드 | -|------------------|-----|---------------------------------------------------------------------------------------------------------------| -| `GAME_START` | S→C | `gameSessionId`, `totalRounds`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `players` | -| `GAME_END` | S→C | `gameSessionId`, `reason`, `finalScores`, `winner` | -| `ROUND_START` | S→C | `currentRound`, `currentDrawerId`, `roundStartTime`, `serverTime`, `roundDuration`, `currentWord`(출제자만) | -| `ROUND_END` | S→C | `currentRound`, `answer`, `scores` | -| `DRAWING` | 양방향 | `drawingData` | -| `DRAWING_CLEAR` | 양방향 | - | -| `GUESS` | C→S | `content` | -| `CORRECT_ANSWER` | S→C | `userId`, `score`, `elapsedTime` | -| `SCORE_UPDATE` | S→C | `scores`, `currentRound`, `totalRounds` | -| `HINT` | S→C | `hint` | - -### 5.4 ROUND_START 상세 (핵심!) - -```json -{ - "domain": "game", - "messageType": "ROUND_START", - "data": { - "gameSessionId": "game-abc123", - "currentRound": 2, - "totalRounds": 5, - "currentDrawerId": "user123", - "roundStartTime": 1705744800000, - "serverTime": 1705744800500, - "roundDuration": 60, - "currentWord": { - "wordId": "word-1", - "word": "apple" - } - }, - "timestamp": 1705744800500 -} -``` - -**중요:** `currentWord`는 출제자에게만 전송! - ---- - -## 6. 구현 일정 - -``` -Week 1: 긴급 버그 수정 -├── [BE] serverTime 필드 추가 (0.5일) -├── [FE] useTimer 훅 수정 (0.5일) -├── [BE] 메시지에 domain 필드 추가 (1일) -└── [FE] 메시지 핸들러 domain 분기 (0.5일) - -Week 2: 게임 세션 분리 (BE) -├── [BE] GameSession 모델 생성 -├── [BE] GameSessionRepository 구현 -├── [BE] GameService 리팩토링 -└── [BE] 게임 세션 API 구현 - -Week 3: 프론트엔드 리팩토링 -├── [FE] useChatWebSocket 분리 -├── [FE] useGameWebSocket 신규 -├── [FE] 컴포넌트 분리 -└── [FE/BE] 통합 테스트 - -Week 4: 안정화 및 추가 기능 -├── [BE] 게임 자동 종료 (7분) - Issue #417 -├── [BE] 재접속 시 게임 상태 복구 -└── [FE/BE] E2E 테스트 -``` - ---- - -## 7. 기대 효과 - -| 항목 | 현재 | 개선 후 | -|---------|-------------|------------------| -| 타이머 정확도 | 클라이언트 시계 의존 | 서버 시간 기준 동기화 | -| 재접속 | 게임 상태 유실 | 완전 복구 가능 | -| 테스트 | 채팅/게임 분리 불가 | 독립 테스트 가능 | -| 확장성 | 새 게임 추가 어려움 | gameType으로 확장 용이 | -| 유지보수 | 책임 혼재 | 명확한 책임 분리 | - ---- - -## 8. 즉시 적용 (백엔드 변경 전 프론트엔드 임시 조치) - -```javascript -// 백엔드 변경 전까지 프론트엔드에서 적용 가능한 임시 코드 - -onRoundStart: (data) => { - const roundData = data.data || data; - const now = Date.now(); - - // serverTime이 없으면 클라이언트 시간 사용 (임시) - const serverTime = roundData.serverTime || now; - let roundStartTime = roundData.roundStartTime || now; - - // roundStartTime이 미래 시간이면 현재로 보정 - if (roundStartTime > now + 1000) { - console.warn('Invalid roundStartTime, using current time'); - roundStartTime = now; - } - - setGameState((prev) => ({ - ...prev, - currentRound: roundData.currentRound, - currentDrawerId: roundData.currentDrawerId, - roundStartTime: roundStartTime, - serverTime: serverTime, - roundDuration: roundData.roundDuration || roundData.roundTimeLimit || 60, - })); -} -``` - ---- - -## 9. 결론 - -**우선순위:** - -1. **즉시 (이번 주)**: `serverTime` 추가 + `domain` 필드 추가 -2. **단기 (2주)**: GameSession 모델 분리 + API 구현 -3. **중기 (3-4주)**: FE/BE 완전 분리 + 자동 종료 + 재접속 복구 - -**핵심 원칙:** - -- 단일 WebSocket 엔드포인트 유지 (비용/복잡도) -- `domain` 필드로 채팅/게임 구분 -- `serverTime`으로 정확한 타이머 동기화 -- GameSession 독립 모델로 상태 관리 명확화 diff --git a/docs/CICD-IMPLEMENTATION-QNA.md b/docs/CICD-IMPLEMENTATION-QNA.md deleted file mode 100644 index e00c5a11..00000000 --- a/docs/CICD-IMPLEMENTATION-QNA.md +++ /dev/null @@ -1,421 +0,0 @@ -# CI/CD 파이프라인 구현 설명 및 면접 Q&A - -## 1. CI/CD 아키텍처 개요 - -``` -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ GitHub │───▶│ CodePipeline│───▶│ CodeBuild │───▶│CloudFormation│ -│ (Source) │ │ (Pipeline) │ │ (Build) │ │ (Deploy) │ -└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ │ - │ ▼ ▼ ▼ - │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ - │ │ SNS │ │ S3 │ │ Lambda │ - │ │(Notification)│ │ (Artifacts) │ │ Functions │ - │ └─────────────┘ └─────────────┘ └─────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ prod 브랜치 Push/Merge │ -└─────────────────────────────────────────────────────────────────────┘ -``` - -## 2. 구성 요소 상세 설명 - -### 2.1 Source Stage (GitHub) - -- **트리거**: prod 브랜치에 Push 또는 PR Merge 시 자동 실행 -- **연결 방식**: AWS CodeConnections (구 CodeStar Connections) -- **아티팩트**: 소스 코드를 ZIP으로 압축하여 다음 스테이지로 전달 - -### 2.2 Build Stage (CodeBuild) - -- **런타임**: Amazon Linux 2, Java Corretto 21 -- **빌드 단계**: - 1. **Install**: SAM CLI 설치 - 2. **Pre-build**: Gradle 테스트 실행 (`./gradlew clean test`) - 3. **Build**: SAM build & package - 4. **Post-build**: 완료 로그 -- **캐싱**: Gradle 캐시를 S3에 저장하여 빌드 시간 단축 -- **리포트**: JUnit 테스트 결과, JaCoCo 코드 커버리지 리포트 - -### 2.3 Deploy Stage (CloudFormation) - -- **배포 방식**: CloudFormation CREATE_UPDATE -- **템플릿**: SAM으로 패키징된 `packaged-template.yaml` -- **기능**: CAPABILITY_IAM, CAPABILITY_AUTO_EXPAND - -### 2.4 Notification (SNS) - -- **이벤트**: 파이프라인 시작, 성공, 실패 시 이메일 알림 -- **구현**: CodeStar Notifications + SNS Topic - -## 3. 주요 파일 구조 - -``` -BE_Repository/ -├── cicd/ -│ └── pipeline.yaml # CloudFormation 파이프라인 템플릿 -├── ServerlessFunction/ -│ ├── buildspec.yml # CodeBuild 빌드 명세 -│ ├── samconfig.toml # SAM 배포 설정 -│ └── template.yaml # SAM 애플리케이션 템플릿 -``` - -## 4. IAM 역할 구성 - -| 역할 | 목적 | 주요 권한 | -|--------------------|---------------------|----------------------------------------| -| PipelineRole | CodePipeline 서비스 역할 | S3, CodeBuild, CloudFormation, SNS | -| CodeBuildRole | CodeBuild 서비스 역할 | S3, CloudWatch Logs, CodeBuild Reports | -| CloudFormationRole | 리소스 배포 역할 | AdministratorAccess (SAM 리소스 생성용) | - ---- - -## 5. 면접 예상 질문 및 답변 - -### Q1. CI/CD 파이프라인을 구축한 이유는 무엇인가요? - -**A1:** -수동 배포의 문제점을 해결하기 위해 CI/CD를 도입했습니다. - -1. **일관성**: 수동 배포 시 발생할 수 있는 휴먼 에러 방지 -2. **자동화**: 코드 푸시만으로 테스트-빌드-배포가 자동 실행 -3. **품질 보장**: 테스트 실패 시 배포가 중단되어 결함 있는 코드가 프로덕션에 배포되는 것을 방지 -4. **추적성**: 모든 배포 이력이 CodePipeline에 기록되어 문제 발생 시 원인 추적 용이 -5. **속도**: 반복적인 배포 작업 시간을 단축하여 개발 생산성 향상 - ---- - -### Q2. GitHub과 AWS CodePipeline을 어떻게 연동했나요? - -**A2:** -AWS CodeConnections(구 CodeStar Connections)를 사용하여 연동했습니다. - -```yaml -# pipeline.yaml의 Source Stage 설정 -- Name: Source - Actions: - - Name: GitHub - ActionTypeId: - Category: Source - Owner: AWS - Provider: CodeStarSourceConnection - Version: '1' - Configuration: - ConnectionArn: !Ref GitHubConnectionArn - FullRepositoryId: "Language-Study-Prooject/BE_Repository" - BranchName: "prod" - DetectChanges: true -``` - -**연동 과정:** - -1. AWS Console에서 CodeConnections 생성 -2. GitHub OAuth 앱 승인 -3. Connection ARN을 파이프라인에 설정 -4. `DetectChanges: true`로 설정하여 자동 트리거 활성화 - ---- - -### Q3. CodeBuild의 buildspec.yml에서 각 phase의 역할은 무엇인가요? - -**A3:** - -```yaml -phases: - install: # 빌드 환경 설정 - runtime-versions: - java: corretto21 - commands: - - pip3 install aws-sam-cli - - pre_build: # 테스트 실행 (품질 게이트) - commands: - - cd ServerlessFunction - - ./gradlew clean test - - build: # 실제 빌드 및 패키징 - commands: - - sam build - - sam package --s3-bucket ... --output-template-file packaged-template.yaml - - post_build: # 후처리 (로깅, 정리) - commands: - - echo "Build completed" -``` - -- **install**: 빌드에 필요한 런타임과 도구 설치 -- **pre_build**: 테스트 실행 - 실패 시 빌드 중단 (품질 게이트 역할) -- **build**: SAM 애플리케이션 빌드 및 S3에 패키징 -- **post_build**: 완료 로그 기록, 정리 작업 - ---- - -### Q4. 테스트가 실패하면 배포가 어떻게 되나요? - -**A4:** -테스트 실패 시 배포가 자동으로 중단됩니다. - -**작동 원리:** - -1. `pre_build` 단계에서 `./gradlew clean test` 실행 -2. 테스트 실패 시 Gradle이 exit code 1 반환 -3. CodeBuild가 비정상 종료로 판단하여 빌드 실패 처리 -4. CodePipeline의 Build Stage가 실패 상태가 됨 -5. Deploy Stage로 진행되지 않음 -6. SNS를 통해 실패 알림 이메일 발송 - -``` -Pipeline Flow: -Source ──▶ Build (테스트 실패) ──✗ Deploy - │ - ▼ - SNS 알림 발송 -``` - ---- - -### Q5. SAM과 CloudFormation의 관계는 무엇인가요? - -**A5:** -SAM(Serverless Application Model)은 CloudFormation의 확장입니다. - -**관계:** - -- SAM 템플릿은 CloudFormation 템플릿의 상위 집합 -- `sam build`/`sam package` 실행 시 SAM 템플릿이 표준 CloudFormation 템플릿으로 변환 -- 변환된 템플릿(`packaged-template.yaml`)을 CloudFormation이 배포 - -**SAM의 장점:** - -1. 간결한 문법: `AWS::Serverless::Function`으로 Lambda + API Gateway + IAM 역할 한번에 정의 -2. 로컬 테스트: `sam local invoke`로 Lambda 로컬 실행 가능 -3. 자동 패키징: 코드를 S3에 업로드하고 참조 자동 생성 - -```yaml -# SAM 템플릿 (간결) -Type: AWS::Serverless::Function -Properties: - Handler: handler.main - Runtime: java21 - Events: - Api: - Type: Api - Properties: - Path: /hello - Method: get - -# 변환된 CloudFormation (복잡) -# Lambda Function + API Gateway + IAM Role + Permission 등 여러 리소스로 확장 -``` - ---- - -### Q6. 배포 중 롤백은 어떻게 처리되나요? - -**A6:** -CloudFormation의 기본 롤백 기능을 활용합니다. - -**설정:** - -```yaml -# samconfig.toml -disable_rollback = false # 롤백 활성화 -``` - -**롤백 시나리오:** - -1. **배포 실패 시**: CloudFormation이 자동으로 이전 상태로 롤백 -2. **Lambda 오류 시**: - - 현재는 기본 롤백만 사용 - - 추가로 Canary/Linear 배포 설정 가능 (AWS CodeDeploy 연동) - -```yaml -# 점진적 배포 예시 (선택적 구현) -DeploymentPreference: - Type: Canary10Percent5Minutes # 10%에 5분간 배포 후 문제없으면 전체 배포 -``` - ---- - -### Q7. 파이프라인의 아티팩트는 어떻게 관리되나요? - -**A7:** -S3 버킷을 사용하여 아티팩트를 관리합니다. - -```yaml -ArtifactBucket: - Type: AWS::S3::Bucket - Properties: - BucketName: group2-englishstudy-pipeline-artifacts - VersioningConfiguration: - Status: Enabled # 버전 관리 활성화 - BucketEncryption: - ServerSideEncryptionConfiguration: - - ServerSideEncryptionByDefault: - SSEAlgorithm: AES256 # 암호화 -``` - -**아티팩트 종류:** - -1. **SourceArtifact**: GitHub에서 가져온 소스 코드 ZIP -2. **BuildArtifact**: 빌드된 `packaged-template.yaml` -3. **Cache**: Gradle 캐시 (빌드 시간 단축용) - ---- - -### Q8. 파이프라인 알림은 어떻게 구현했나요? - -**A8:** -AWS CodeStar Notifications와 SNS를 연동하여 구현했습니다. - -```yaml -# SNS Topic 생성 -NotificationTopic: - Type: AWS::SNS::Topic - Properties: - TopicName: cicd-pipeline-notifications - -# 이메일 구독 -EmailSubscription: - Type: AWS::SNS::Subscription - Properties: - TopicArn: !Ref NotificationTopic - Protocol: email - Endpoint: !Ref NotificationEmail - -# 알림 규칙 -PipelineNotificationRule: - Type: AWS::CodeStarNotifications::NotificationRule - Properties: - EventTypeIds: - - codepipeline-pipeline-pipeline-execution-started - - codepipeline-pipeline-pipeline-execution-succeeded - - codepipeline-pipeline-pipeline-execution-failed - Targets: - - TargetType: SNS - TargetAddress: !Ref NotificationTopic -``` - ---- - -### Q9. CI/CD 구축 중 겪은 문제와 해결 방법은? - -**A9:** - -**문제 1: Gradle Wrapper를 찾을 수 없음** - -- 원인: `.gitignore`에서 `gradle/` 폴더 전체가 제외됨 -- 해결: `.gitignore` 수정하여 `!gradle/wrapper/` 예외 추가 - -**문제 2: JAVA_HOME 환경 변수 오류** - -- 원인: CodeBuild에서 JAVA_HOME을 수동 설정했으나 경로 불일치 -- 해결: `runtime-versions: java: corretto21`만 사용하고 JAVA_HOME 수동 설정 제거 - -**문제 3: SAM package S3 버킷 참조 오류** - -- 원인: 환경 변수를 사용한 멀티라인 명령어에서 변수 치환 실패 -- 해결: 단일 라인으로 버킷 이름 직접 지정 - -**문제 4: Lambda 환경 변수 누락** - -- 원인: WebSocket Connect 함수에 `WEBSOCKET_ENDPOINT` 환경 변수 미설정 -- 해결: `template.yaml`에 환경 변수 추가 - ---- - -### Q10. 현재 CI/CD의 개선점이 있다면? - -**A10:** - -1. **테스트 커버리지 게이트** - - 현재: 테스트 실행만 함 - - 개선: 커버리지 80% 미만 시 빌드 실패 설정 - -2. **점진적 배포 (Canary/Blue-Green)** - - 현재: 전체 교체 배포 - - 개선: Lambda Alias + CodeDeploy로 Canary 배포 구현 - -3. **다중 환경 지원** - - 현재: prod 단일 환경 - - 개선: dev, staging, prod 분리 및 승인 단계 추가 - -4. **보안 스캔** - - 개선: 의존성 취약점 스캔 (OWASP Dependency-Check) 추가 - -5. **성능 테스트** - - 개선: 배포 전 부하 테스트 단계 추가 - ---- - -### Q11. IaC(Infrastructure as Code)를 사용한 이유는? - -**A11:** -파이프라인 자체도 CloudFormation 템플릿(`pipeline.yaml`)으로 정의했습니다. - -**장점:** - -1. **버전 관리**: 인프라 변경 이력을 Git으로 추적 -2. **재현성**: 동일한 파이프라인을 다른 프로젝트/계정에 쉽게 복제 -3. **리뷰 가능**: 인프라 변경도 코드 리뷰 프로세스 적용 -4. **자동화**: 수동 콘솔 작업 없이 `aws cloudformation deploy`로 생성/업데이트 -5. **문서화**: 템플릿 자체가 인프라 문서 역할 - ---- - -### Q12. CodeBuild와 Jenkins의 차이점은? - -**A12:** - -| 항목 | CodeBuild | Jenkins | -|--------|---------------|----------------------| -| 관리 | 완전 관리형 (서버리스) | 자체 서버 운영 필요 | -| 비용 | 빌드 시간 기반 과금 | 서버 운영 비용 | -| 확장성 | 자동 확장 | 수동 확장 필요 | -| AWS 통합 | 네이티브 통합 | 플러그인 필요 | -| 커스터마이징 | buildspec.yml | Jenkinsfile (Groovy) | -| 플러그인 | 제한적 | 풍부한 생태계 | - -**선택 이유:** - -- AWS 서비스 중심 아키텍처에서 네이티브 통합의 이점 -- 서버 관리 부담 없음 -- SAM/CloudFormation과의 원활한 연동 - ---- - -## 6. 핵심 용어 정리 - -| 용어 | 설명 | -|-------------------------------------|------------------------------------------------| -| CI (Continuous Integration) | 코드 변경을 자주 통합하고 자동 테스트하는 방식 | -| CD (Continuous Delivery/Deployment) | 자동으로 프로덕션까지 배포하는 방식 | -| Pipeline | 소스-빌드-배포로 이어지는 자동화된 워크플로우 | -| Artifact | 빌드 결과물 (패키징된 코드, 템플릿 등) | -| buildspec.yml | CodeBuild의 빌드 명세 파일 | -| SAM | Serverless Application Model - 서버리스 앱 정의 프레임워크 | -| IaC | Infrastructure as Code - 코드로 인프라 관리 | - ---- - -## 7. 참고 명령어 - -```bash -# 파이프라인 생성 -aws cloudformation deploy \ - --template-file cicd/pipeline.yaml \ - --stack-name group2-cicd-pipeline \ - --capabilities CAPABILITY_NAMED_IAM \ - --parameter-overrides NotificationEmail=your@email.com - -# 파이프라인 상태 확인 -aws codepipeline get-pipeline-state --name group2-englishstudy-pipeline - -# 수동 파이프라인 실행 -aws codepipeline start-pipeline-execution --name group2-englishstudy-pipeline - -# 빌드 로그 확인 -aws logs tail /aws/codebuild/group2-englishstudy-build --follow -``` diff --git a/docs/FRONTEND-API-GUIDE.md b/docs/FRONTEND-API-GUIDE.md deleted file mode 100644 index 697d406a..00000000 --- a/docs/FRONTEND-API-GUIDE.md +++ /dev/null @@ -1,365 +0,0 @@ -# 프론트엔드 전달사항 - 채팅/게임 API 가이드 - -## 1. 아키텍처 구조 (업데이트됨) - -### 채팅방과 게임방 분리 - -``` -RoomType enum -├── CHAT ("chat") - 일반 채팅방 -└── GAME ("game") - 게임방 (캐치마인드 등) - -RoomStatus enum -├── WAITING ("waiting") - 대기 중 -├── PLAYING ("playing") - 게임 진행 중 -└── FINISHED ("finished") - 종료됨 -``` - -### GSI1SK 인덱스 설계 - -``` -GSI1PK: "ROOMS" (고정) -GSI1SK: {type}#{gameType}#{status}#{level}#{createdAt} - -예시: -- CHAT#-#WAITING#beginner#2026-01-22T10:00:00Z (일반 채팅방) -- GAME#CATCHMIND#WAITING#intermediate#2026-01-22T10:00:00Z (대기중 게임방) -- GAME#CATCHMIND#PLAYING#advanced#2026-01-22T10:00:00Z (진행중 게임방) -``` - -**핵심**: DB 레벨에서 `type`, `gameType`, `status`, `level` 조합으로 필터링 가능 - ---- - -## 2. 방 타입 (RoomType) - -| 타입 | 코드 | 설명 | -|--------|--------|---------------| -| `CHAT` | `chat` | 일반 채팅방 | -| `GAME` | `game` | 게임방 (캐치마인드 등) | - ---- - -## 3. 방 상태 (RoomStatus) - -| 상태 | 코드 | 설명 | 게임 시작 가능 | -|------------|------------|---------|:--------:| -| `WAITING` | `waiting` | 대기 중 | O | -| `PLAYING` | `playing` | 게임 진행 중 | X | -| `FINISHED` | `finished` | 게임 종료됨 | O | - ---- - -## 4. REST API 엔드포인트 - -### 채팅방 API (`/api/chat/rooms`) - -| Method | Endpoint | 설명 | -|--------|-------------------------|---------------------| -| POST | `/rooms` | 채팅방/게임방 생성 | -| GET | `/rooms` | 방 목록 조회 (필터 지원) | -| GET | `/rooms/{roomId}` | 방 상세 조회 | -| POST | `/rooms/{roomId}/join` | 방 입장 (roomToken 발급) | -| POST | `/rooms/{roomId}/leave` | 방 퇴장 | -| DELETE | `/rooms/{roomId}` | 방 삭제 (방장만) | - -### 게임 API (`/api/game`) - -| Method | Endpoint | 설명 | -|--------|-------------------------------|----------| -| POST | `/rooms/{roomId}/game/start` | 게임 시작 | -| POST | `/rooms/{roomId}/game/stop` | 게임 중단 | -| GET | `/rooms/{roomId}/game/status` | 게임 상태 조회 | -| GET | `/rooms/{roomId}/game/scores` | 점수판 조회 | - ---- - -## 5. 방 목록 조회 쿼리 파라미터 (업데이트됨) - -``` -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND&status=WAITING&level=intermediate&limit=10&cursor=xxx -``` - -| 파라미터 | 타입 | 설명 | 예시 | -|------------|--------|----------------------|----------------------------------------| -| `type` | string | 방 타입 필터 | `CHAT`, `GAME` | -| `gameType` | string | 게임 타입 | `CATCHMIND` | -| `status` | string | 상태 필터 | `WAITING`, `PLAYING`, `FINISHED` | -| `level` | string | 난이도 필터 | `beginner`, `intermediate`, `advanced` | -| `limit` | number | 조회 개수 (기본 10, 최대 20) | | -| `cursor` | string | 페이지네이션 커서 | | - -### 필터 조합 예시 - -```bash -# 대기 중인 게임방만 -GET /api/chat/rooms?type=GAME&status=WAITING - -# 캐치마인드 게임방만 -GET /api/chat/rooms?type=GAME&gameType=CATCHMIND - -# 초급 난이도 채팅방 -GET /api/chat/rooms?type=CHAT&level=beginner - -# 진행 중인 고급 게임방 -GET /api/chat/rooms?type=GAME&status=PLAYING&level=advanced -``` - -### 응답 예시 - -```json -{ - "success": true, - "message": "Rooms retrieved", - "data": { - "rooms": [ - { - "roomId": "abc-123", - "name": "초보자 영어 스터디", - "type": "GAME", - "gameType": "CATCHMIND", - "status": "WAITING", - "level": "beginner", - "currentMembers": 3, - "maxMembers": 6, - "currentRound": 0, - "totalRounds": 5, - "createdAt": "2026-01-22T10:00:00Z" - } - ], - "nextCursor": "eyJQSyI6Ik...", - "hasMore": true - } -} -``` - ---- - -## 6. 방 생성 요청 (업데이트됨) - -### 채팅방 생성 - -```json -{ - "name": "영어 스터디 채팅방", - "type": "CHAT", - "level": "beginner", - "maxMembers": 6, - "description": "초보자를 위한 영어 채팅방" -} -``` - -### 게임방 생성 - -```json -{ - "name": "캐치마인드 게임", - "type": "GAME", - "gameType": "CATCHMIND", - "level": "intermediate", - "maxMembers": 8, - "description": "영어 단어 맞추기 게임" -} -``` - ---- - -## 7. 프론트엔드에서 방 타입 구분 - -### 방법 1: API 필터 사용 (권장) - -```javascript -// 게임방만 조회 -const gameRooms = await fetch('/api/chat/rooms?type=GAME'); - -// 대기 중인 게임방만 -const waitingGames = await fetch('/api/chat/rooms?type=GAME&status=WAITING'); - -// 채팅방만 -const chatRooms = await fetch('/api/chat/rooms?type=CHAT'); -``` - -### 방법 2: 전체 조회 후 클라이언트 필터링 - -```javascript -const allRooms = await fetchRooms(); - -// 게임방만 -const gameRooms = allRooms.filter(room => room.type === 'GAME'); - -// 채팅방만 -const chatRooms = allRooms.filter(room => room.type === 'CHAT'); - -// 대기 중인 방만 -const waitingRooms = allRooms.filter(room => room.status === 'WAITING'); -``` - ---- - -## 8. WebSocket 연결 - -### 채팅/게임 WebSocket - -``` -wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev?roomToken={roomToken} -``` - -### Grammar WebSocket - -``` -wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev?token={jwtToken} -``` - -### 연결 순서 - -1. `POST /rooms/{roomId}/join` → `roomToken` 발급 -2. WebSocket 연결 시 `roomToken` 쿼리 파라미터로 전달 - ---- - -## 9. WebSocket 메시지 타입 (messageType) - -| 코드 | 타입 | 설명 | -|------------------|--------|---------------| -| `MSG` | 일반 메시지 | 일반 채팅 메시지 | -| `VOICE` | 음성 메시지 | 음성 채팅 | -| `JOIN` | 입장 알림 | 사용자 입장 | -| `LEAVE` | 퇴장 알림 | 사용자 퇴장 | -| `GAME_START` | 게임 시작 | 게임 시작 알림 | -| `GAME_END` | 게임 종료 | 게임 종료 + 최종 순위 | -| `ROUND_START` | 라운드 시작 | 새 라운드 시작 | -| `ROUND_END` | 라운드 종료 | 정답 공개 | -| `ANSWER_CORRECT` | 정답 | 정답 맞춤 | -| `HINT` | 힌트 | 힌트 제공 | -| `SKIP` | 스킵 | 라운드 스킵 | -| `SYSTEM` | 시스템 | 시스템 메시지 | - ---- - -## 10. 게임 명령어 (WebSocket) - -채팅 메시지로 게임 명령어 전송: - -| 명령어 | 설명 | 권한 | -|----------|--------|-----------------| -| `/start` | 게임 시작 | 방장 (2명 이상 접속 시) | -| `/stop` | 게임 중단 | 방장 또는 게임 시작자 | -| `/skip` | 라운드 스킵 | 누구나 | -| `/hint` | 힌트 제공 | 출제자만 | -| `/score` | 점수 확인 | 누구나 | - ---- - -## 11. 게임 시작 응답 예시 - -```json -{ - "messageId": "uuid", - "roomId": "abc-123", - "userId": "SYSTEM", - "content": "게임 시작!\n총 5 라운드\n\n라운드 1 시작!\n출제자: user-456", - "messageType": "GAME_START", - "createdAt": "2026-01-22T10:00:00Z", - "serverTime": "2026-01-22T10:00:00Z", - "domain": "GAME", - "type": "GAME", - "status": "PLAYING", - "currentRound": 1, - "totalRounds": 5, - "currentDrawerId": "user-456", - "drawerOrder": ["user-456", "user-789", "user-123"] -} -``` - ---- - -## 12. 정답 체크 로직 - -- **한국어** 또는 **영어** 둘 다 정답으로 인정 -- 대소문자 구분 없음 -- 공백 무시 - -### 점수 계산 - -``` -기본 점수: 10점 -시간 보너스: (제한시간 - 경과시간) * 0.5 -연속 정답 보너스: 연속정답수 * 2 - -총점 = 기본점수 + 시간보너스 + 연속정답보너스 -``` - ---- - -## 13. 게임 설정 - -| 설정 | 기본값 | 환경변수 | -|--------------|----------|---------------------------------| -| 총 라운드 수 | 5 | `GAME_TOTAL_ROUNDS` | -| 라운드 제한 시간(초) | 60 | `GAME_ROUND_TIME_LIMIT` | -| 빠른 정답 기준(ms) | 5000 | `GAME_QUICK_GUESS_THRESHOLD_MS` | -| 게임 전체 제한(초) | 420 (7분) | `GAME_TIME_LIMIT_SECONDS` | - ---- - -## 14. 주의사항 - -1. **roomToken은 한 번만 사용**: 재연결 시 새로 발급 필요 -2. **WebSocket 연결 실패 시**: `POST /rooms/{roomId}/join`으로 새 토큰 발급 -3. **게임 중 퇴장**: 자동으로 다음 출제자로 넘어감 (2명 미만 시 게임 종료) -4. **출제자는 정답 입력 불가**: 본인이 출제자일 때 채팅해도 정답 체크 안됨 -5. **방 타입 변경 불가**: 생성 시 지정한 type은 변경 불가 - ---- - -## 15. 에러 코드 - -| 코드 | HTTP | 설명 | -|--------------|------|--------------| -| `ROOM_001` | 404 | 채팅방 없음 | -| `ROOM_002` | 409 | 채팅방 이미 존재 | -| `ROOM_003` | 400 | 채팅방 인원 초과 | -| `ROOM_004` | 400 | 채팅방 종료됨 | -| `ROOM_005` | 401 | 비밀번호 틀림 | -| `ROOM_006` | 403 | 방장 권한 없음 | -| `MEMBER_001` | 403 | 채팅방 멤버 아님 | -| `MEMBER_002` | 409 | 이미 참여 중 | -| `GAME_001` | 400 | 게임 시작 실패 | -| `GAME_002` | 400 | 게임 중단 실패 | -| `GAME_003` | 400 | 게임 진행 중 아님 | -| `GAME_004` | 409 | 게임 이미 진행 중 | -| `GAME_005` | 403 | 게임 시작자 아님 | -| `GAME_006` | 404 | 게임 없음 | -| `GAME_007` | 400 | 채팅방에서 게임 불가 | -| `GAME_008` | 400 | 게임 재시작 불가 | -| `GAME_009` | 403 | 방장만 게임 시작 가능 | - ---- - -## 16. UI 구현 가이드 - -### 탭 구조 (권장) - -``` -[전체] [채팅방] [게임방] -``` - -### 게임방 상태 표시 - -``` -대기 중 (WAITING) → 초록색 뱃지 "참여 가능" -진행 중 (PLAYING) → 빨간색 뱃지 "게임 중" -종료됨 (FINISHED) → 회색 뱃지 "종료" -``` - -### 게임방 카드 정보 - -``` -┌─────────────────────────────┐ -│ 캐치마인드 - 영어 단어 맞추기 │ -│ [게임방] [intermediate] │ -│ │ -│ 👥 3/8명 🎮 대기 중 │ -│ 🕐 2026-01-22 10:00 │ -└─────────────────────────────┘ -``` diff --git a/docs/MIDTERM-REPORT.md b/docs/MIDTERM-REPORT.md deleted file mode 100644 index 9a6bb1d1..00000000 --- a/docs/MIDTERM-REPORT.md +++ /dev/null @@ -1,439 +0,0 @@ -# 영어 학습 플랫폼 백엔드 최종 성과 보고서 - -## 프로젝트 개요 - -| 항목 | 내용 | -|-------|--------------------------------------------------------------------------| -| 프로젝트명 | 영어 회화 학습 플랫폼 (MZC 2nd Project) | -| 담당 영역 | Vocabulary, Chatting, Grammar, Badge, Stats, Common | -| 기술 스택 | Java 21, AWS Lambda, DynamoDB, API Gateway WebSocket, Bedrock, Polly, S3 | -| 배포 환경 | AWS SAM, CloudFormation | - ---- - -## 1. 전체 시스템 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - WEB[Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - GRAMMAR_WS[Grammar WebSocket] - end - - subgraph Lambda["AWS Lambda - 도메인별 핸들러"] - direction TB - VOCAB[Vocabulary
단어/일일학습/테스트] - CHAT[Chatting
실시간 채팅/게임] - GRAMMAR[Grammar
문법 체크/스트리밍] - STATS[Stats
통계 집계] - BADGE[Badge
배지 시스템] - USER[User
사용자 관리] - end - - subgraph AI["AI Services"] - BEDROCK[AWS Bedrock
Claude 3.5 Sonnet] - POLLY[AWS Polly
TTS] - end - - subgraph Data["Data Layer"] - DYNAMO_VOCAB[(DynamoDB
Vocab Table)] - DYNAMO_CHAT[(DynamoDB
Chat Table)] - S3[(S3
음성/뱃지 이미지)] - STREAMS[DynamoDB Streams] - end - - WEB --> REST - WEB --> WS - WEB --> GRAMMAR_WS - REST --> VOCAB - REST --> CHAT - REST --> GRAMMAR - REST --> BADGE - REST --> STATS - REST --> USER - WS --> CHAT - GRAMMAR_WS --> GRAMMAR - VOCAB --> DYNAMO_VOCAB - VOCAB --> POLLY - VOCAB --> S3 - CHAT --> DYNAMO_CHAT - CHAT --> BEDROCK - GRAMMAR --> DYNAMO_VOCAB - GRAMMAR --> BEDROCK - STATS --> DYNAMO_VOCAB - BADGE --> DYNAMO_VOCAB - BADGE --> S3 - STREAMS -->|이벤트 트리거| STATS - STATS -->|배지 부여| BADGE -``` - ---- - -## 2. 주요 기능 구현 - -### 2.1 Vocabulary Domain (단어 학습) - -#### 2.1.1 일일 학습 시스템 (Daily Study) - -```mermaid -flowchart LR - subgraph DailyStudy["일일 학습 흐름"] - A[오늘의 단어 조회] --> B{기존 학습 존재?} - B -->|Yes| C[기존 학습 반환] - B -->|No| D[새 단어 50개 + 복습 5개 생성] - D --> E[학습 진행] - E --> F[단어별 학습 완료 처리] - F --> G{50개 완료?} - G -->|Yes| H[isCompleted = true] - end -``` - -**주요 기능:** - -- 레벨별 신규 단어 50개 + 복습 단어 5개 자동 선정 -- 학습 진행도 실시간 추적 (learnedCount/totalWords) -- 일일 학습 완료 시 isCompleted 플래그 설정 - -#### 2.1.2 SM-2 Spaced Repetition 알고리즘 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -**구현 특징:** - -- State 패턴으로 학습 상태 전이 관리 -- easeFactor 동적 조정 (1.3 ~ 2.5) -- 복습 간격 자동 계산 (1일 → 6일 → interval * easeFactor) - -#### 2.1.3 TTS 음성 생성 - -- AWS Polly 연동 (남성/여성 음성) -- S3 캐싱으로 중복 생성 방지 -- 단어 + 예문 음성 생성 - ---- - -### 2.2 Chatting Domain (실시간 채팅 & 게임) - -#### 2.2.1 WebSocket 채팅 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1: 방 입장 토큰 발급 - Client ->> REST: POST /rooms/{id}/join - REST ->> DB: RoomToken 저장 (TTL: 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2: WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 + Connection 저장 - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3: 메시지 송수신 - Client ->> WS: sendmessage (채팅) - WS ->> DB: 메시지 저장 + 브로드캐스트 -``` - -**주요 기능:** - -- RoomToken 기반 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- 슬래시 명령어 시스템 (/member, /game, /skip, /hint 등) -- Connection 자동 정리 (TTL + 실패 시 삭제) - -#### 2.2.2 캐치마인드 게임 - -```mermaid -flowchart TB - subgraph Game["캐치마인드 게임 흐름"] - START["#47;game 명령어"] --> INIT["게임 초기화
출제 순서 셔플"] - INIT --> ROUND[라운드 시작
출제자 + 단어 선정] - ROUND --> DRAW[출제자 그림 그리기] - DRAW --> GUESS[참가자 정답 입력] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE[점수 계산
시간보너스 + 연속정답보너스] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND[다음 라운드] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END[게임 종료
순위 발표] - LASTROUND -->|No| ROUND - end -``` - -**점수 계산:** - -``` -점수 = 기본점수(10) + 시간보너스((60-경과초)*0.5) + 연속정답보너스(streak*2) -출제자 보너스 = 정답자당 5점 -``` - -**주요 기능:** - -- 실시간 점수 브로드캐스트 -- 연속 정답 스트릭 시스템 -- 접속자 변동 시 출제자 자동 재선정 -- 라운드별 순위 표시 - ---- - -### 2.3 Grammar Domain (문법 체크) - -#### 2.3.1 AI 스트리밍 응답 - -```mermaid -sequenceDiagram - participant Client - participant WS as Grammar WebSocket - participant Handler as GrammarStreamingHandler - participant Bedrock as AWS Bedrock - Client ->> WS: 문법 체크 요청 - WS ->> Handler: Lambda 호출 - Handler ->> Bedrock: 스트리밍 요청 (Claude 3.5 Sonnet) - - loop 청크 단위 응답 - Bedrock -->> Handler: 텍스트 청크 - Handler -->> WS: 실시간 전송 - WS -->> Client: 즉시 표시 - end - - Handler -->> Client: [DONE] 완료 - Handler ->> DB: 피드백 저장 -``` - -**주요 기능:** - -- Claude 3.5 Sonnet 모델 사용 -- 스트리밍으로 체감 대기 시간 80% 감소 -- 레벨별 맞춤 프롬프트 (BEGINNER: 한국어 번역 포함) -- 대화 히스토리 저장으로 문맥 유지 -- 피드백 영구 저장 (DynamoDB) - ---- - -### 2.4 Stats Domain (학습 통계) - -```mermaid -flowchart LR - subgraph StatsTypes["통계 유형"] - DAILY["일별 통계
#47;stats#47;daily"] - WEEKLY["주별 통계
#47;stats#47;weekly"] - MONTHLY["월별 통계
#47;stats#47;monthly"] - TOTAL["전체 통계
#47;stats#47;total"] - HISTORY["히스토리
#47;stats#47;history"] - end -``` - -**통계 항목:** - -| 필드 | 설명 | -|-------------------|-------------| -| testsCompleted | 완료한 테스트 수 | -| questionsAnswered | 답변한 문제 수 | -| correctAnswers | 정답 수 | -| incorrectAnswers | 오답 수 | -| successRate | 정답률 (%) | -| newWordsLearned | 새로 학습한 단어 수 | -| wordsReviewed | 복습한 단어 수 | -| currentStreak | 현재 연속 학습일 | -| longestStreak | 최장 연속 학습일 | -| gamesPlayed | 참여한 게임 수 | -| gamesWon | 1등 횟수 | -| totalGameScore | 누적 게임 점수 | - -**DynamoDB Streams 기반 비동기 집계:** - -- 테스트 결과 저장 시 자동 트리거 -- API 응답과 분리되어 응답 속도 향상 - ---- - -### 2.5 Badge Domain (배지 시스템) - -```mermaid -flowchart TB - subgraph BadgeSystem["배지 시스템"] - TRIGGER[통계 업데이트] --> CHECK[배지 조건 체크] - CHECK --> AWARD{조건 달성?} - AWARD -->|Yes| SAVE[배지 부여 + 저장] - AWARD -->|No| END[종료] - SAVE --> NOTIFY[프론트엔드 조회] - end -``` - -**배지 종류:** - -| Badge Type | 이름 | 조건 | -|----------------------|---------|------------| -| FIRST_STEP | 첫 걸음 | 첫 학습 완료 | -| STREAK_3, 7, 30 | 연속 학습 | N일 연속 학습 | -| WORDS_100, 500, 1000 | 단어 학습 | N개 단어 학습 | -| PERFECT_SCORE | 완벽주의자 | 테스트 만점 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90% | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임 참여 | -| GAME_10_WINS | 게임 10승 | 10번 1등 | -| QUICK_GUESSER | 번개 정답 | 5초 내 정답 | -| PERFECT_DRAWER | 완벽한 출제자 | 전원 정답 유도 | - -**기술적 특징:** - -- S3 Presigned URL로 배지 이미지 제공 (1시간 유효) -- 획득/미획득 배지 + 진행도 표시 - ---- - -## 3. 기술적 성과 - -### 3.1 아키텍처 패턴 - -| 패턴 | 적용 영역 | 효과 | -|------------------|----------|----------------------------| -| **CQRS** | 전 도메인 | 읽기/쓰기 책임 분리, 테스트 용이성 | -| **State** | 단어 학습 상태 | 복잡한 조건문 제거, 확장성 | -| **Factory** | AI 서비스 | 서비스 교체 용이 (Claude ↔ Llama) | -| **Event-Driven** | 통계/배지 | 느슨한 결합, 비동기 처리 | - -### 3.2 DynamoDB 설계 - -**Single Table Design:** - -- Vocab Table: 단어, 사용자단어, 테스트, 일일학습, 통계, 배지, 문법 -- Chat Table: 채팅방, 메시지, 연결, 게임라운드 - -**GSI 구성:** - -| GSI | 용도 | -|------|---------------------| -| GSI1 | 레벨별 단어 조회, 복습 예정 단어 | -| GSI2 | 카테고리별 단어, 상태별 사용자단어 | -| GSI3 | 북마크 단어 조회 | - -### 3.3 보안 - -- Cognito 인증 (idToken) -- WebSocket RoomToken 인증 (TTL 5분) -- BCrypt 비밀방 암호화 -- S3 Presigned URL (배지 이미지) - -### 3.4 성능 최적화 - -| 최적화 | 효과 | -|--------------------------|-------------------------| -| TTS S3 캐싱 | Polly API 호출 90% 절감 | -| 배치 처리 | 최대 100개 단어 일괄 처리 | -| Strongly Consistent Read | 데이터 정합성 보장 | -| DynamoDB Streams | 비동기 통계 집계로 응답 속도 50% 향상 | -| AI 스트리밍 | 체감 대기 시간 80% 감소 | - ---- - -## 4. API 엔드포인트 요약 - -### REST API (https://gc8l9ijhzc.execute-api.ap-northeast-2.amazonaws.com/dev) - -| Method | Path | 설명 | -|--------|-------------------------------------|-----------| -| GET | /vocab/words | 단어 목록 조회 | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/daily | 오늘의 학습 단어 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 | -| POST | /vocab/tests | 테스트 생성 | -| POST | /vocab/tests/{testId}/submit | 테스트 제출 | -| GET | /stats/daily | 일별 통계 | -| GET | /stats/weekly | 주별 통계 | -| GET | /stats/monthly | 월별 통계 | -| GET | /stats/total | 전체 통계 | -| GET | /stats/history?limit=100 | 통계 히스토리 | -| GET | /badges | 전체 배지 목록 | -| GET | /badges/earned | 획득한 배지 | -| GET | /rooms | 채팅방 목록 | -| POST | /rooms | 채팅방 생성 | -| POST | /rooms/{roomId}/join | 채팅방 입장 | -| POST | /grammar/check | 문법 체크 | - -### WebSocket API - -| Endpoint | 설명 | -|---------------------------------------------------------------|---------| -| wss://t378dif43l.execute-api.ap-northeast-2.amazonaws.com/dev | 채팅/게임 | -| wss://ltrccmteo8.execute-api.ap-northeast-2.amazonaws.com/dev | 문법 스트리밍 | - ---- - -## 5. 프로젝트 구조 - -``` -ServerlessFunction/src/main/java/com/mzc/secondproject/serverless/ -├── common/ # 공통 모듈 -│ ├── config/ # AWS 클라이언트 (싱글톤) -│ ├── router/ # HandlerRouter, Route -│ ├── exception/ # 예외 처리 체계 -│ ├── dto/ # PaginatedResult, ErrorInfo -│ └── util/ # ResponseGenerator, CursorUtil -│ -├── domain/ -│ ├── vocabulary/ # 단어 학습 도메인 -│ │ ├── handler/ # Word, UserWord, Test, DailyStudy 핸들러 -│ │ ├── service/ # CQRS 서비스 (Command/Query) -│ │ ├── repository/ # DynamoDB 레포지토리 -│ │ ├── model/ # Word, UserWord, TestResult, DailyStudy -│ │ └── state/ # NEW, LEARNING, REVIEWING, MASTERED -│ │ -│ ├── chatting/ # 채팅 도메인 -│ │ ├── handler/ # REST + WebSocket 핸들러 -│ │ ├── service/ # ChatRoom, Game, Command 서비스 -│ │ └── model/ # ChatRoom, Connection, GameRound -│ │ -│ ├── grammar/ # 문법 체크 도메인 -│ │ ├── handler/ # REST + 스트리밍 핸들러 -│ │ ├── service/ # GrammarCheck, Conversation 서비스 -│ │ └── factory/ # BedrockGrammarCheckFactory -│ │ -│ ├── stats/ # 통계 도메인 -│ │ ├── handler/ # UserStats, Streams 핸들러 -│ │ └── repository/ # UserStatsRepository -│ │ -│ └── badge/ # 배지 도메인 -│ ├── handler/ # BadgeHandler -│ └── service/ # BadgeService -``` - ---- - -## 6. 성과 요약 - -| 카테고리 | 성과 | -|------------------|------------------------------------| -| **Lambda 함수** | 26개 | -| **API 엔드포인트** | REST 40+, WebSocket 2 | -| **DynamoDB 테이블** | 2개 (Single Table Design) | -| **GSI** | 5개 | -| **아키텍처 패턴** | CQRS, State, Factory, Event-Driven | -| **AI 연동** | Bedrock Claude 3.5 Sonnet (문법/대화) | -| **TTS** | AWS Polly (남성/여성 음성) | -| **실시간 통신** | WebSocket (채팅/게임/문법 스트리밍) | -| **인증** | Cognito + RoomToken | - ---- - -**작성일:** 2026-01-16 -**팀:** MZC 2nd Project Team / SMJ diff --git a/docs/domain-reports/BADGE-DOMAIN-REPORT.md b/docs/domain-reports/BADGE-DOMAIN-REPORT.md deleted file mode 100644 index 4cd58215..00000000 --- a/docs/domain-reports/BADGE-DOMAIN-REPORT.md +++ /dev/null @@ -1,681 +0,0 @@ -# Badge Domain 세부 보고서 - -## 1. 개요 - -Badge 도메인은 사용자의 학습 성취도에 따라 배지를 자동으로 부여하는 시스템입니다. 이벤트 기반 아키텍처를 통해 Stats, Vocabulary, Chatting 도메인과 연동되어 실시간으로 배지를 체크하고 -부여합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거 소스"] - TEST[테스트 완료
DynamoDB Streams] - WORD[단어 학습
Write-through] - GAME[게임 종료
Service Method] - end - - subgraph Processing["Badge 처리"] - CHECK[BadgeService
조건 체크] - AWARD[배지 부여] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserBadge)] - S3[(S3
배지 이미지)] - end - - subgraph Query["조회"] - API[BadgeHandler
REST API] - PRESIGN[S3 Presigned URL] - end - - TEST --> CHECK - WORD --> CHECK - GAME --> CHECK - CHECK --> AWARD - AWARD --> DDB - DDB --> API - S3 --> PRESIGN - PRESIGN --> API -``` - ---- - -## 3. 배지 종류 - -### 3.1 배지 카테고리 - -```mermaid -mindmap - root((배지 시스템)) - 학습 - FIRST_STEP[첫 걸음] - WORDS_100[단어 수집가] - WORDS_500[단어 전문가] - WORDS_1000[단어 마스터] - 연속학습 - STREAK_3[3일 연속] - STREAK_7[7일 연속] - STREAK_30[30일 연속] - 테스트 - PERFECT_SCORE[완벽주의자] - TEST_10[테스트 도전자] - ACCURACY_90[정확도 달인] - 게임 - GAME_FIRST[첫 게임] - GAME_10_WINS[10승 달성] - QUICK_GUESSER[번개 정답] - PERFECT_DRAWER[완벽한 출제자] - 최종 - MASTER[학습 마스터] -``` - -### 3.2 배지 상세 - -| Badge Type | 이름 | 설명 | 카테고리 | 조건 | -|-----------------|-----------|--------------------|-----------------|-----------------------| -| FIRST_STEP | 첫 걸음 | 첫 학습을 완료했습니다 | FIRST_STUDY | testsCompleted >= 1 | -| STREAK_3 | 3일 연속 학습 | 3일 연속으로 학습했습니다 | STREAK | currentStreak >= 3 | -| STREAK_7 | 일주일 연속 학습 | 7일 연속으로 학습했습니다 | STREAK | currentStreak >= 7 | -| STREAK_30 | 한 달 연속 학습 | 30일 연속으로 학습했습니다 | STREAK | currentStreak >= 30 | -| WORDS_100 | 단어 수집가 | 100개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 100 | -| WORDS_500 | 단어 전문가 | 500개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 500 | -| WORDS_1000 | 단어 마스터 | 1000개의 단어를 학습했습니다 | WORDS_LEARNED | totalWords >= 1000 | -| PERFECT_SCORE | 완벽주의자 | 테스트에서 만점을 받았습니다 | PERFECT_TEST | incorrectAnswers == 0 | -| TEST_10 | 테스트 도전자 | 10회의 테스트를 완료했습니다 | TESTS_COMPLETED | testsCompleted >= 10 | -| ACCURACY_90 | 정확도 달인 | 전체 정확도 90%를 달성했습니다 | ACCURACY | successRate >= 90 | -| GAME_FIRST_PLAY | 첫 게임 | 첫 게임에 참여했습니다 | GAMES_PLAYED | gamesPlayed >= 1 | -| GAME_10_WINS | 게임 10승 | 게임에서 10번 1등을 했습니다 | GAMES_WON | gamesWon >= 10 | -| QUICK_GUESSER | 번개 정답 | 5초 내에 정답을 맞췄습니다 | QUICK_GUESSES | quickGuesses >= 1 | -| PERFECT_DRAWER | 완벽한 출제자 | 출제 시 전원이 정답을 맞췄습니다 | PERFECT_DRAWS | perfectDraws >= 1 | -| MASTER | 학습 마스터 | 모든 업적을 달성했습니다 | ALL_BADGES | 모든 배지 획득 | - ---- - -## 4. 배지 부여 흐름 - -### 4.1 테스트 완료 시 - -```mermaid -sequenceDiagram - participant Test as TestResult - participant Streams as DynamoDB Streams - participant Handler as StatsStreamHandler - participant Stats as UserStats - participant Badge as BadgeService - participant DB as DynamoDB - Test ->> Streams: INSERT 이벤트 - Streams ->> Handler: 트리거 - Handler ->> Stats: incrementTestStats() - Handler ->> Stats: updateStudyStreak() - Note over Handler: 만점 체크 - alt 정답 > 0 && 오답 == 0 - Handler ->> Badge: awardBadge("PERFECT_SCORE") - Badge ->> DB: UserBadge 저장 - end - - Handler ->> Stats: findTotalStats() - Stats -->> Handler: UserStats - Handler ->> Badge: checkAndAwardBadges() - Badge ->> Badge: 각 배지 조건 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.2 단어 학습 시 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as DailyStudyCommandService - participant Stats as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - API ->> Service: markWordLearned() - Service ->> Stats: incrementWordsLearned() - Note over Service: 배지 체크 (WORDS_xxx) - Service ->> Stats: findTotalStats() - Stats -->> Service: UserStats - Service ->> Badge: checkAndAwardBadges() - Badge ->> Badge: WORDS_100, 500, 1000 체크 - Badge ->> DB: 획득 배지 저장 -``` - -### 4.3 게임 종료 시 - -```mermaid -sequenceDiagram - participant Game as GameService - participant Stats as GameStatsService - participant Repo as UserStatsRepository - participant Badge as BadgeService - participant DB as DynamoDB - Game ->> Stats: updateGameStats(room) - - loop 각 참가자 - Stats ->> Stats: 점수 집계 - Note over Stats: correctGuesses
quickGuesses (5초 이내)
perfectDraws - Stats ->> Repo: incrementGameStats() - Stats ->> Repo: findTotalStats() - Repo -->> Stats: UserStats - Stats ->> Badge: checkAndAwardBadges() - Badge ->> Badge: GAME_xxx 배지 체크 - Badge ->> DB: 획득 배지 저장 - end -``` - ---- - -## 5. 배지 조건 체크 로직 - -### 5.1 카테고리별 조건 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP{모든 BadgeType 순회} - LOOP --> EARNED{이미 획득?} - EARNED -->|Yes| SKIP[건너뛰기] - EARNED -->|No| CHECK[조건 체크] - CHECK --> SWITCH{카테고리} - SWITCH -->|FIRST_STUDY| FS[testsCompleted >= 1] - SWITCH -->|STREAK| ST[currentStreak >= threshold] - SWITCH -->|WORDS_LEARNED| WL[totalWords >= threshold] - SWITCH -->|PERFECT_TEST| PT[별도 처리] - SWITCH -->|TESTS_COMPLETED| TC[testsCompleted >= threshold] - SWITCH -->|ACCURACY| AC[successRate >= threshold] - SWITCH -->|GAMES_PLAYED| GP[gamesPlayed >= threshold] - SWITCH -->|GAMES_WON| GW[gamesWon >= threshold] - SWITCH -->|QUICK_GUESSES| QG[quickGuesses >= threshold] - SWITCH -->|PERFECT_DRAWS| PD[perfectDraws >= threshold] - SWITCH -->|ALL_BADGES| AB[모든 배지 획득 체크] - FS --> RESULT{조건 충족?} - ST --> RESULT - WL --> RESULT - TC --> RESULT - AC --> RESULT - GP --> RESULT - GW --> RESULT - QG --> RESULT - PD --> RESULT - RESULT -->|Yes| AWARD[배지 부여] - RESULT -->|No| SKIP - AWARD --> LOOP - SKIP --> LOOP -``` - -### 5.2 Switch Expression 패턴 - -```java -private boolean checkBadgeCondition(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1; - - case "STREAK" -> stats.getCurrentStreak() != null && - stats.getCurrentStreak() >= type.getThreshold(); - - case "WORDS_LEARNED" -> { - int total = (stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0) - + (stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0); - yield total >= type.getThreshold(); - } - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield false; - double accuracy = (stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered(); - yield accuracy >= type.getThreshold(); - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null && - stats.getTestsCompleted() >= type.getThreshold(); - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null && - stats.getGamesPlayed() >= type.getThreshold(); - - case "GAMES_WON" -> stats.getGamesWon() != null && - stats.getGamesWon() >= type.getThreshold(); - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null && - stats.getQuickGuesses() >= type.getThreshold(); - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null && - stats.getPerfectDraws() >= type.getThreshold(); - - case "PERFECT_TEST" -> false; // 별도 처리 (StatsStreamHandler) - case "ALL_BADGES" -> false; // 특수 로직 필요 - - default -> false; - }; -} -``` - ---- - -## 6. API 엔드포인트 - -### 6.1 REST API - -| Method | Endpoint | 설명 | 응답 | -|--------|----------------|----------------|-------------| -| GET | /badges | 전체 배지 목록 + 진행도 | BadgeInfo[] | -| GET | /badges/earned | 획득한 배지만 조회 | UserBadge[] | - -### 6.2 전체 배지 조회 응답 - -```json -{ - "message": "Badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earned": true, - "earnedAt": "2026-01-16T10:30:45.123Z" - }, - { - "badgeType": "WORDS_100", - "name": "단어 수집가", - "description": "100개의 단어를 학습했습니다", - "imageUrl": "https://...presigned.../badges/words_100.png", - "category": "WORDS_LEARNED", - "threshold": 100, - "progress": 45, - "earned": false, - "earnedAt": null - } - ], - "totalCount": 16, - "earnedCount": 8 - } -} -``` - -### 6.3 획득 배지 조회 응답 - -```json -{ - "message": "Earned badges retrieved", - "data": { - "badges": [ - { - "badgeType": "FIRST_STEP", - "name": "첫 걸음", - "description": "첫 학습을 완료했습니다", - "imageUrl": "https://...presigned.../badges/first_step.png", - "category": "FIRST_STUDY", - "threshold": 1, - "progress": 1, - "earnedAt": "2026-01-16T10:30:45.123Z" - } - ], - "count": 8 - } -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserBadge - -```java - -@DynamoDbBean -public class UserBadge { - // 기본 키 - String pk; // USER#{userId}#BADGE - String sk; // BADGE#{badgeType} - - // GSI (전체 배지 조회) - String gsi1pk; // BADGE#ALL - String gsi1sk; // EARNED#{earnedAt} - - // 메타데이터 - String odUserId; - String badgeType; // BadgeType enum 이름 - String name; - String description; - String imageUrl; - String category; - Integer threshold; - Integer progress; // 획득 시점 진행도 - - // 타임스탬프 - String earnedAt; - String createdAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|--------|---------------------|-----------------------------| -| PK | USER#{userId}#BADGE | USER#abc123#BADGE | -| SK | BADGE#{badgeType} | BADGE#STREAK_7 | -| GSI1PK | BADGE#ALL | BADGE#ALL | -| GSI1SK | EARNED#{earnedAt} | EARNED#2026-01-16T10:30:45Z | - -### 7.3 BadgeType Enum - -```java -public enum BadgeType { - FIRST_STEP("첫 걸음", "첫 학습을 완료했습니다", - "FIRST_STUDY", 1, "first_step.png"), - STREAK_3("3일 연속 학습", "3일 연속으로 학습했습니다", - "STREAK", 3, "streak_3.png"), - STREAK_7("일주일 연속 학습", "7일 연속으로 학습했습니다", - "STREAK", 7, "streak_7.png"), - // ... 생략 - MASTER("학습 마스터", "모든 업적을 달성했습니다", - "ALL_BADGES", 1, "master.png"); - - private final String name; - private final String description; - private final String category; - private final int threshold; - private final String imageFile; -} -``` - ---- - -## 8. 진행도 계산 - -### 8.1 카테고리별 진행도 - -```mermaid -flowchart TB - subgraph Progress["진행도 계산"] - FIRST["FIRST_STUDY
testsCompleted >= 1 ? 1 : 0"] - STREAK["STREAK
currentStreak"] - WORDS["WORDS_LEARNED
newWords + reviewed"] - TESTS["TESTS_COMPLETED
testsCompleted"] - ACC["ACCURACY
successRate (%)"] - GAMES["GAMES_PLAYED
gamesPlayed"] - WINS["GAMES_WON
gamesWon"] - QUICK["QUICK_GUESSES
quickGuesses"] - PERFECT["PERFECT_DRAWS
perfectDraws"] - end -``` - -### 8.2 calculateProgress 메서드 - -```java -private int calculateProgress(BadgeType type, UserStats stats) { - return switch (type.getCategory()) { - case "FIRST_STUDY" -> (stats.getTestsCompleted() != null && stats.getTestsCompleted() >= 1) ? 1 : 0; - - case "STREAK" -> stats.getCurrentStreak() != null ? stats.getCurrentStreak() : 0; - - case "WORDS_LEARNED" -> { - int newWords = stats.getNewWordsLearned() != null ? stats.getNewWordsLearned() : 0; - int reviewed = stats.getWordsReviewed() != null ? stats.getWordsReviewed() : 0; - yield newWords + reviewed; - } - - case "TESTS_COMPLETED" -> stats.getTestsCompleted() != null ? stats.getTestsCompleted() : 0; - - case "ACCURACY" -> { - if (stats.getQuestionsAnswered() == null || stats.getQuestionsAnswered() == 0) - yield 0; - yield (int) ((stats.getCorrectAnswers() * 100.0) / stats.getQuestionsAnswered()); - } - - case "GAMES_PLAYED" -> stats.getGamesPlayed() != null ? stats.getGamesPlayed() : 0; - - case "GAMES_WON" -> stats.getGamesWon() != null ? stats.getGamesWon() : 0; - - case "QUICK_GUESSES" -> stats.getQuickGuesses() != null ? stats.getQuickGuesses() : 0; - - case "PERFECT_DRAWS" -> stats.getPerfectDraws() != null ? stats.getPerfectDraws() : 0; - - default -> 0; - }; -} -``` - ---- - -## 9. 멱등성 보장 - -### 9.1 중복 부여 방지 흐름 - -```mermaid -flowchart TB - START[checkAndAwardBadges] --> LOOP[배지 타입 순회] - LOOP --> CHECK{hasBadge?} - CHECK -->|이미 있음| SKIP[건너뛰기] - CHECK -->|없음| CONDITION{조건 충족?} - CONDITION -->|Yes| CREATE[배지 생성] - CONDITION -->|No| SKIP - CREATE --> SAVE[DynamoDB 저장] - SAVE --> LOOP - SKIP --> LOOP -``` - -### 9.2 구현 코드 - -```java -public List checkAndAwardBadges(String userId, UserStats stats) { - List newBadges = new ArrayList<>(); - String now = Instant.now().toString(); - - for (BadgeType type : BadgeType.values()) { - // 1. 이미 획득한 배지는 건너뛰기 - if (badgeRepository.hasBadge(userId, type.name())) { - continue; - } - - // 2. 조건 체크 - if (checkBadgeCondition(type, stats)) { - // 3. 배지 생성 및 저장 - UserBadge badge = createBadge(userId, type, now); - badgeRepository.save(badge); - newBadges.add(badge); - } - } - - return newBadges; -} -``` - ---- - -## 10. S3 이미지 연동 - -### 10.1 Presigned URL 생성 - -```mermaid -flowchart LR - REQ[배지 조회] --> SERVICE[BadgeService] - SERVICE --> PRESIGN[S3PresignUtil] - PRESIGN --> CACHE{캐시 확인} - CACHE -->|있음| RETURN[URL 반환] - CACHE -->|없음| GENERATE[Presigned URL 생성] - GENERATE --> SAVE[캐시 저장] - SAVE --> RETURN -``` - -### 10.2 이미지 URL 생성 - -```java -// S3PresignUtil.java -public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); -} - -// BadgeService - 배지 생성 시 -private UserBadge createBadge(String userId, BadgeType type, String now) { - return UserBadge.builder() - .pk(BadgeKey.userBadgePk(userId)) - .sk(BadgeKey.badgeSk(type.name())) - .gsi1pk(BadgeKey.BADGE_ALL) - .gsi1sk(BadgeKey.earnedSk(now)) - .odUserId(userId) - .badgeType(type.name()) - .name(type.getName()) - .description(type.getDescription()) - .imageUrl(S3PresignUtil.getBadgeImageUrl(type.getImageFile())) - .category(type.getCategory()) - .threshold(type.getThreshold()) - .earnedAt(now) - .createdAt(now) - .build(); -} -``` - -### 10.3 S3 버킷 구조 - -``` -s3://group2-englishstudy/ -└── badges/ - ├── first_step.png - ├── streak_3.png - ├── streak_7.png - ├── streak_30.png - ├── words_100.png - ├── words_500.png - ├── words_1000.png - ├── perfect_score.png - ├── test_10.png - ├── accuracy_90.png - ├── game_first.png - ├── game_10_wins.png - ├── quick_guesser.png - ├── perfect_drawer.png - └── master.png -``` - ---- - -## 11. Stats 도메인 연동 - -### 11.1 연동 포인트 - -```mermaid -flowchart TB - subgraph Stats["Stats 도메인"] - STREAM[StatsStreamHandler] - DAILY[DailyStudyCommandService] - GAME[GameStatsService] - REPO[UserStatsRepository] - end - - subgraph Badge["Badge 도메인"] - SERVICE[BadgeService] - BADGEREPO[BadgeRepository] - end - - STREAM -->|checkAndAwardBadges| SERVICE - DAILY -->|checkWordsBadge| SERVICE - GAME -->|checkAndAwardBadges| SERVICE - SERVICE -->|hasBadge, save| BADGEREPO - SERVICE -->|findTotalStats| REPO -``` - -### 11.2 UserStats 필드와 배지 매핑 - -| UserStats 필드 | 배지 | -|------------------------------------|----------------------------------| -| testsCompleted | FIRST_STEP, TEST_10 | -| currentStreak | STREAK_3, STREAK_7, STREAK_30 | -| newWordsLearned + wordsReviewed | WORDS_100, WORDS_500, WORDS_1000 | -| correctAnswers / questionsAnswered | ACCURACY_90 | -| gamesPlayed | GAME_FIRST_PLAY | -| gamesWon | GAME_10_WINS | -| quickGuesses | QUICK_GUESSER | -| perfectDraws | PERFECT_DRAWER | - ---- - -## 12. 파일 구조 - -``` -domain/badge/ -├── enums/ -│ └── BadgeType.java # 16가지 배지 정의 -├── constants/ -│ └── BadgeKey.java # DynamoDB 키 생성 -├── model/ -│ └── UserBadge.java # 배지 엔티티 -├── repository/ -│ └── BadgeRepository.java # CRUD 연산 -├── service/ -│ └── BadgeService.java # 조건 체크, 배지 부여 -└── handler/ - └── BadgeHandler.java # REST API - -연동 파일: -├── domain/stats/handler/StatsStreamHandler.java -├── domain/vocabulary/service/DailyStudyCommandService.java -└── domain/chatting/service/GameStatsService.java -``` - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Storage:** S3 (배지 이미지) -- **Event:** DynamoDB Streams, Write-through, Service Method -- **Pattern:** Event-driven, Idempotent, Switch Expression -- **Java 21 Features:** Enhanced Switch, Yield Statement - ---- - -## 14. 배지 획득 시나리오 - -### 14.1 시나리오 예시 - -```mermaid -flowchart LR - subgraph Day1["1일차"] - A1[테스트 완료] --> B1["FIRST_STEP 획득"] - end - - subgraph Day3["3일차"] - A3[3일 연속 학습] --> B3["STREAK_3 획득"] - end - - subgraph Day7["7일차"] - A7[7일 연속 학습] --> B7["STREAK_7 획득"] - A7_2[100단어 학습] --> B7_2["WORDS_100 획득"] - end - - subgraph Game["게임"] - G1[5초 내 정답] --> G2["QUICK_GUESSER 획득"] - G3[10회 1등] --> G4["GAME_10_WINS 획득"] - end -``` - -### 14.2 특수 배지 획득 조건 - -**PERFECT_SCORE (완벽주의자):** - -- 테스트 제출 시 오답 0개이면 즉시 부여 -- StatsStreamHandler에서 별도 처리 - -**QUICK_GUESSER (번개 정답):** - -- 게임 중 5초(5000ms) 이내 정답 시 -- GameStatsService에서 quickGuesses 카운트 - -**PERFECT_DRAWER (완벽한 출제자):** - -- 출제 시 모든 참가자가 정답을 맞춘 경우 -- 라운드 종료 시 endReason == "ALL_CORRECT"이면 카운트 - -**MASTER (학습 마스터):** - -- 다른 모든 배지를 획득한 경우 -- 특수 로직으로 모든 배지 보유 여부 확인 diff --git a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md b/docs/domain-reports/CHATTING-DOMAIN-REPORT.md deleted file mode 100644 index c27eb552..00000000 --- a/docs/domain-reports/CHATTING-DOMAIN-REPORT.md +++ /dev/null @@ -1,434 +0,0 @@ -# Chatting Domain 세부 보고서 - -## 1. 개요 - -Chatting 도메인은 실시간 채팅과 캐치마인드 게임 기능을 제공하는 WebSocket 기반 시스템입니다. AWS API Gateway WebSocket과 Lambda를 활용하여 실시간 양방향 통신을 구현했습니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[WebSocket API] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - ROOM[ChatRoomHandler] - MSG[ChatMessageHandler] - GAME[GameHandler] - VOICE[ChatVoiceHandler] - CONNECT[WebSocketConnectHandler] - DISCONNECT[WebSocketDisconnectHandler] - MESSAGE[WebSocketMessageHandler] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - S3[(S3 - 음성 캐시)] - end - - APP --> REST - APP <--> WS - REST --> ROOM - REST --> MSG - REST --> GAME - REST --> VOICE - WS --> CONNECT - WS --> DISCONNECT - WS --> MESSAGE - ROOM --> DDB - MSG --> DDB - GAME --> DDB - MESSAGE --> DDB - VOICE --> S3 -``` - ---- - -## 3. 채팅방 시스템 - -### 3.1 채팅방 입장 흐름 - -```mermaid -sequenceDiagram - participant Client - participant REST as REST API - participant WS as WebSocket API - participant DB as DynamoDB - Note over Client, DB: Phase 1 - 방 입장 및 토큰 발급 - Client ->> REST: POST /rooms/{roomId}/join - REST ->> DB: 비밀번호 검증 (비밀방인 경우) - REST ->> DB: RoomToken 저장 (TTL 5분) - REST -->> Client: roomToken 반환 - Note over Client, DB: Phase 2 - WebSocket 연결 - Client ->> WS: $connect?roomToken={token} - WS ->> DB: 토큰 검증 - WS ->> DB: Connection 저장 (TTL 10분) - WS -->> Client: 연결 성공 - Note over Client, DB: Phase 3 - 실시간 메시지 - Client ->> WS: sendMessage (채팅) - WS ->> DB: 메시지 저장 - WS -->> Client: 브로드캐스트 (같은 방 전체) -``` - -### 3.2 REST API 엔드포인트 - -| Method | Endpoint | 설명 | 인증 | -|--------|-------------------------------|---------------------------|----| -| POST | /chat/rooms | 채팅방 생성 | O | -| GET | /chat/rooms | 채팅방 목록 (level, joined 필터) | O | -| GET | /chat/rooms/{roomId} | 채팅방 상세 | O | -| POST | /chat/rooms/{roomId}/join | 채팅방 입장 (토큰 발급) | O | -| POST | /chat/rooms/{roomId}/leave | 채팅방 퇴장 | O | -| DELETE | /chat/rooms/{roomId} | 채팅방 삭제 (방장만) | O | -| GET | /chat/rooms/{roomId}/messages | 메시지 히스토리 | O | - -### 3.3 WebSocket 이벤트 - -| Route | 설명 | Payload | -|-------------|------------|------------------------------------------| -| $connect | 연결 (토큰 검증) | ?roomToken={token} | -| $disconnect | 연결 해제 | - | -| sendMessage | 메시지 전송 | { roomId, userId, content, messageType } | - ---- - -## 4. 캐치마인드 게임 시스템 - -### 4.1 게임 흐름 - -```mermaid -flowchart TB - subgraph GameFlow["캐치마인드 게임 흐름"] - START["/game 명령어"] --> INIT["게임 초기화
출제자 순서 셔플"] - INIT --> ROUND["라운드 시작
출제자 + 단어 선정"] - ROUND --> DRAW["출제자 그림 그리기
(DRAWING 메시지)"] - DRAW --> GUESS["참가자 정답 입력"] - GUESS --> CHECK{정답?} - CHECK -->|Yes| SCORE["점수 계산
시간보너스 + 연속보너스"] - CHECK -->|No| GUESS - SCORE --> ALLCORRECT{전원 정답?} - ALLCORRECT -->|Yes| NEXTROUND - ALLCORRECT -->|No| TIMEOUT{시간 초과?} - TIMEOUT -->|Yes| NEXTROUND["다음 라운드"] - TIMEOUT -->|No| GUESS - NEXTROUND --> LASTROUND{마지막 라운드?} - LASTROUND -->|Yes| END["게임 종료
순위 발표"] - LASTROUND -->|No| ROUND - end -``` - -### 4.2 게임 API - -| Method | Endpoint | 설명 | -|--------|----------------------------------|-------------| -| POST | /chat/rooms/{roomId}/game/start | 게임 시작 (방장만) | -| POST | /chat/rooms/{roomId}/game/stop | 게임 중지 | -| GET | /chat/rooms/{roomId}/game/status | 게임 상태 조회 | -| GET | /chat/rooms/{roomId}/game/scores | 점수판 조회 | - -### 4.3 슬래시 명령어 - -| 명령어 | 설명 | 사용 가능 | -|---------|----------------|--------| -| /start | 게임 시작 | 방장 | -| /stop | 게임 중지 | 방장/시작자 | -| /score | 점수판 보기 | 전체 | -| /member | 접속자 수 | 전체 | -| /hint | 힌트 제공 (첫글자○○○) | 출제자 | -| /skip | 라운드 스킵 | 출제자 | -| /help | 명령어 도움말 | 전체 | - -### 4.4 점수 계산 공식 - -``` -점수 = 기본점수(10) + 시간보너스 + 연속보너스 + 출제자보너스 - -- 시간보너스: (60 - 경과초) × 0.5 -- 연속보너스: streak × 2 -- 출제자보너스: 정답자당 5점 -``` - -**예시:** - -- 30초에 정답 + 연속 3회: 10 + 15 + 6 = 31점 -- 출제자가 3명 맞출 경우: 5 × 3 = 15점 - -### 4.5 게임 상태 - -```mermaid -stateDiagram-v2 - [*] --> NONE: 대기 - NONE --> PLAYING: /start 명령어 - PLAYING --> ROUND_END: 시간초과/전원정답 - ROUND_END --> PLAYING: 다음 라운드 - ROUND_END --> FINISHED: 마지막 라운드 - PLAYING --> FINISHED: /stop 명령어 - FINISHED --> [*]: 게임 종료 -``` - ---- - -## 5. WebSocket 메시지 타입 - -### 5.1 채팅 메시지 - -| Type | 설명 | 저장 | -|-------------|-------|----| -| TEXT | 일반 채팅 | O | -| IMAGE | 이미지 | O | -| VOICE | 음성 | O | -| AI_RESPONSE | AI 응답 | O | - -### 5.2 게임 메시지 - -| Type | 설명 | 저장 | -|----------------|--------------|----| -| DRAWING | 그림 데이터 (실시간) | X | -| DRAWING_CLEAR | 그림 지우기 | X | -| GUESS | 오답 추측 | X | -| CORRECT_ANSWER | 정답 알림 | X | -| SCORE_UPDATE | 점수 갱신 | X | -| GAME_START | 게임 시작 | X | -| ROUND_START | 라운드 시작 | X | -| ROUND_END | 라운드 종료 | X | -| GAME_END | 게임 종료 | X | -| HINT | 힌트 | X | - -### 5.3 실시간 점수 업데이트 메시지 - -```json -{ - "messageType": "SCORE_UPDATE", - "roomId": "uuid", - "scorerId": "user123", - "scoreGained": 25, - "ranking": [ - { - "rank": 1, - "userId": "user123", - "score": 85, - "change": 25 - }, - { - "rank": 2, - "userId": "user456", - "score": 60, - "change": 0 - } - ], - "currentRound": 3, - "totalRounds": 5 -} -``` - ---- - -## 6. 데이터 모델 - -### 6.1 ChatRoom - -```java - -@DynamoDbBean -public class ChatRoom { - // 기본 정보 - String roomId, name, description; - String level; // beginner, intermediate, advanced - Integer currentMembers, maxMembers; - Boolean isPrivate; - String password; // BCrypt 암호화 - String createdBy; // 방장 - List memberIds; - - // 게임 상태 - String gameStatus; // NONE, PLAYING, ROUND_END, FINISHED - Integer currentRound, totalRounds; - String currentDrawerId, currentWord; - Long roundStartTime; - Integer roundTimeLimit; // 60초 - List drawerOrder; - Map scores; - Map streaks; - List correctGuessers; - Boolean hintUsed; -} -``` - -**DynamoDB Keys:** - -- PK: `ROOM#{roomId}` | SK: `METADATA` -- GSI1: `ROOMS` | `{level}#{createdAt}` (레벨별 최신순) - -### 6.2 Connection - -```java - -@DynamoDbBean -public class Connection { - String connectionId; // API Gateway 연결 ID - String userId; - String roomId; - Long ttl; // 10분 (자동 삭제) -} -``` - -**DynamoDB Keys:** - -- PK: `CONN#{connectionId}` | SK: `METADATA` -- GSI1: `ROOM#{roomId}` | `CONN#{connectionId}` (방별 연결) -- GSI2: `USER#{userId}` | `CONN#{connectionId}` (사용자별 연결) - -### 6.3 GameRound - -```java - -@DynamoDbBean -public class GameRound { - Integer roundNumber; - String drawerId, word, wordEnglish; - List correctGuessers; - Map guessTimes; // 정답까지 걸린 시간 - Map roundScores; - Long startTime, endTime; - String endReason; // TIME_UP, ALL_CORRECT, SKIP - Long ttl; // 7일 -} -``` - -### 6.4 RoomToken - -```java - -@DynamoDbBean -public class RoomToken { - String token; // UUID - String roomId; - String userId; - Long ttl; // 5분 -} -``` - ---- - -## 7. 서비스 레이어 - -### 7.1 CQRS 패턴 - -| Service | 역할 | -|------------------------|----------------------| -| ChatRoomCommandService | 채팅방 생성, 입장, 퇴장, 삭제 | -| ChatRoomQueryService | 채팅방 조회, 목록 | -| GameService | 게임 시작, 정답 체크, 라운드 종료 | -| GameStatsService | 게임 종료 후 통계, 배지 처리 | -| CommandService | 슬래시 명령어 처리 | -| RoomTokenService | 토큰 발급 및 검증 | - -### 7.2 게임 정답 체크 로직 - -```mermaid -flowchart TB - INPUT[정답 입력] --> NORMALIZE["정규화
(소문자, 공백제거)"] - NORMALIZE --> VALIDATE{유효성 검사} - VALIDATE -->|게임 미진행| REJECT1[거부: 게임 없음] - VALIDATE -->|출제자 본인| REJECT2[거부: 출제자] - VALIDATE -->|이미 정답| REJECT3[거부: 중복] - VALIDATE -->|통과| COMPARE{정답 비교} - COMPARE -->|일치| CORRECT["정답 처리
점수 계산"] - COMPARE -->|불일치| WRONG["오답 처리
GUESS 메시지 전송"] - CORRECT --> BROADCAST["브로드캐스트
CORRECT_ANSWER + SCORE_UPDATE"] - WRONG --> GUESSBROADCAST["브로드캐스트
GUESS 메시지"] - BROADCAST --> ALLCHECK{전원 정답?} - ALLCHECK -->|Yes| ROUNDEND[라운드 자동 종료] - ALLCHECK -->|No| CONTINUE[게임 계속] -``` - ---- - -## 8. 브로드캐스트 시스템 - -### 8.1 WebSocketBroadcaster - -```java -public class WebSocketBroadcaster { - public List broadcast( - List connections, - String payload - ) { - // 1. 같은 방 모든 연결에 메시지 전송 - // 2. 실패한 연결 ID 반환 (Stale 정리용) - } -} -``` - -### 8.2 브로드캐스트 유형 - -| 유형 | 대상 | 예시 | -|--------|--------|-----------| -| 전체 | 방 전체 | 채팅, 정답 알림 | -| 본인 제외 | 발신자 제외 | 그림 데이터 | -| 출제자 전용 | 출제자만 | 단어 정보 | - ---- - -## 9. 파일 구조 - -``` -domain/chatting/ -├── handler/ -│ ├── ChatRoomHandler.java -│ ├── ChatMessageHandler.java -│ ├── ChatVoiceHandler.java -│ ├── GameHandler.java -│ └── websocket/ -│ ├── WebSocketConnectHandler.java -│ ├── WebSocketDisconnectHandler.java -│ └── WebSocketMessageHandler.java -├── service/ -│ ├── ChatRoomCommandService.java -│ ├── ChatRoomQueryService.java -│ ├── ChatMessageService.java -│ ├── GameService.java -│ ├── GameStatsService.java -│ ├── CommandService.java -│ └── RoomTokenService.java -├── repository/ -│ ├── ChatRoomRepository.java -│ ├── ChatMessageRepository.java -│ ├── ConnectionRepository.java -│ ├── GameRoundRepository.java -│ └── RoomTokenRepository.java -├── model/ -│ ├── ChatRoom.java -│ ├── ChatMessage.java -│ ├── Connection.java -│ ├── GameRound.java -│ └── RoomToken.java -├── dto/ -│ ├── request/ -│ └── response/ -│ └── ScoreUpdateMessage.java -└── enums/ - ├── GameStatus.java - └── MessageType.java -``` - ---- - -## 10. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **Database:** DynamoDB (Single Table Design) -- **Auth:** Cognito + RoomToken -- **Encryption:** BCrypt (비밀방 암호) -- **TTS:** AWS Polly + S3 캐시 -- **Pattern:** CQRS, Repository, Factory diff --git a/docs/domain-reports/COMMON-MODULE-REPORT.md b/docs/domain-reports/COMMON-MODULE-REPORT.md deleted file mode 100644 index aefe6d08..00000000 --- a/docs/domain-reports/COMMON-MODULE-REPORT.md +++ /dev/null @@ -1,1228 +0,0 @@ -# Common Module 세부 보고서 - -## 1. 개요 - -Common 모듈은 모든 도메인에서 공유하는 유틸리티, 설정, 예외 처리, 라우팅 등을 제공하는 핵심 인프라 모듈입니다. Java 21의 최신 기능(Records, Sealed Interface, Pattern -Matching)을 적극 활용하여 타입 안전성과 코드 간결성을 확보했습니다. - ---- - -## 2. 전체 패키지 구조 - -```mermaid -flowchart TB -subgraph Common["common/"] -CONFIG[config/] -CONST[constants/] -DTO[dto/] -ENUM[enums/] -EXCEPTION[exception/] -ROUTER[router/] -SERVICE[service/] -UTIL[util/] -VALIDATION[validation/] -end - -subgraph ConfigFiles["config/"] -AC[AwsClients.java] -WSC[WebSocketConfig.java] -RTC[RoomTokenConfig.java] -SC[StudyConfig.java] -end - -subgraph DtoFiles["dto/"] -AR[ApiResponse.java] -EI[ErrorInfo.java] -PR[PaginatedResult.java] -end - -subgraph ExceptionFiles["exception/"] -SE[ServerlessException.java] -EC[ErrorCode.java] -CEC[CommonErrorCode.java] -CE[CommonException.java] -end - -subgraph RouterFiles["router/"] -HR[HandlerRouter.java] -RT[Route.java] -AH[AuthenticatedHandler.java] -end - -CONFIG --> ConfigFiles -DTO --> DtoFiles -EXCEPTION --> ExceptionFiles -ROUTER --> RouterFiles -``` - ---- - -## 3. Handler 라우팅 시스템 - -### 3.1 HandlerRouter 아키텍처 - -```mermaid -flowchart TB - subgraph Request["요청 처리 흐름"] - REQ[APIGatewayProxyRequestEvent] --> ROUTER[HandlerRouter] - ROUTER --> MATCH{라우트 매칭} - MATCH -->|매칭 성공| VALIDATE[파라미터 검증] - MATCH -->|매칭 실패| NF404[404 Not Found] - VALIDATE --> EXECUTE[핸들러 실행] - EXECUTE --> RESPONSE[APIGatewayProxyResponseEvent] - end - - subgraph ErrorHandling["예외 처리"] - EXECUTE -->|ServerlessException| ERR1[ErrorCode 기반 응답] - EXECUTE -->|IllegalArgumentException| ERR2[400 Bad Request] - EXECUTE -->|IllegalStateException| ERR3[409 Conflict] - EXECUTE -->|SecurityException| ERR4[403 Forbidden] - EXECUTE -->|기타 예외| ERR5[500 Internal Error] - end -``` - -### 3.2 Route 정의 (Java 21 Record) - -```java -// Route.java - Java 21 Record 활용 -public record Route( - String method, // HTTP 메서드 - String pathPattern, // 경로 패턴 (e.g., "/rooms/{roomId}") - Function handler, - List requiredPathParams, // 필수 경로 파라미터 - List requiredQueryParams // 필수 쿼리 파라미터 - ) { - // 경로 파라미터 자동 추출: {roomId} → roomId - private static final Pattern PATH_PARAM_PATTERN = - Pattern.compile("\\{([^}]+)}"); -} -``` - -### 3.3 Route 팩토리 메서드 - -```mermaid -flowchart LR - subgraph BasicRoutes["기본 라우트"] - GET["Route.get()"] - POST["Route.post()"] - PUT["Route.put()"] - DELETE["Route.delete()"] - PATCH["Route.patch()"] - end - - subgraph AuthRoutes["인증 라우트"] - GETAUTH["Route.getAuth()"] - POSTAUTH["Route.postAuth()"] - PUTAUTH["Route.putAuth()"] - DELETEAUTH["Route.deleteAuth()"] - PATCHAUTH["Route.patchAuth()"] - end - - BasicRoutes -->|" + Cognito 인증 "| AuthRoutes -``` - -### 3.4 사용 예시 - -```java -// Handler에서 라우터 초기화 -private HandlerRouter initRouter() { - return new HandlerRouter().addRoutes( - // 인증 필요 라우트 (Cognito userId 자동 추출) - Route.postAuth("/grammar/check", this::checkGrammar), - Route.getAuth("/grammar/sessions/{sessionId}", this::getSessionDetail), - Route.deleteAuth("/grammar/sessions/{sessionId}", this::deleteSession), - - // 쿼리 파라미터 검증 - Route.getAuth("/rooms", this::getRooms) - .requireQueryParams("level") - ); -} - -// Lambda 핸들러 메서드 -@Override -public APIGatewayProxyResponseEvent handleRequest( - APIGatewayProxyRequestEvent request, Context context) { - return router.route(request); -} -``` - -### 3.5 AuthenticatedHandler 인터페이스 - -```java -// 함수형 인터페이스 - Cognito 인증 요청 처리 -@FunctionalInterface -public interface AuthenticatedHandler { - APIGatewayProxyResponseEvent handle( - APIGatewayProxyRequestEvent request, - String userId // Cognito sub claim에서 자동 추출 - ); -} - -// 사용 예시 - 람다 표현식으로 간결하게 -Route. - -postAuth("/rooms",(request, userId) ->{ -CreateRoomRequest dto = parseBody(request, CreateRoomRequest.class); -ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator. - -created("Room created",room); -}); -``` - ---- - -## 4. 예외 처리 시스템 - -### 4.1 ErrorCode 계층 구조 (Sealed Interface) - -```mermaid -flowchart TB - subgraph SealedHierarchy["Java 21 Sealed Interface 계층"] - EC[/"ErrorCode
(sealed interface)"/] - EC -->|permits| CEC["CommonErrorCode
(enum)"] - EC -->|permits| DEC[/"DomainErrorCode
(non-sealed interface)"/] - DEC --> VEC["VocabularyErrorCode"] - DEC --> CHEC["ChattingErrorCode"] - DEC --> GEC["GrammarErrorCode"] - DEC --> SEC["StatsErrorCode"] - DEC --> BEC["BadgeErrorCode"] - end -``` - -### 4.2 CommonErrorCode 정의 - -```java -public enum CommonErrorCode implements ErrorCode { - // 인증/인가 (AUTH_xxx) - UNAUTHORIZED("AUTH_001", "인증이 필요합니다", 401), - FORBIDDEN("AUTH_002", "접근 권한이 없습니다", 403), - INVALID_TOKEN("AUTH_003", "유효하지 않은 토큰입니다", 401), - TOKEN_EXPIRED("AUTH_004", "토큰이 만료되었습니다", 401), - - // 검증 (VALIDATION_xxx) - INVALID_INPUT("VALIDATION_001", "잘못된 입력입니다", 400), - REQUIRED_FIELD_MISSING("VALIDATION_002", "필수 필드가 누락되었습니다", 400), - INVALID_FORMAT("VALIDATION_003", "형식이 올바르지 않습니다", 400), - VALUE_OUT_OF_RANGE("VALIDATION_004", "값이 허용 범위를 벗어났습니다", 400), - - // 리소스 (RESOURCE_xxx) - RESOURCE_NOT_FOUND("RESOURCE_001", "리소스를 찾을 수 없습니다", 404), - RESOURCE_ALREADY_EXISTS("RESOURCE_002", "이미 존재하는 리소스입니다", 409), - METHOD_NOT_ALLOWED("RESOURCE_003", "허용되지 않는 메서드입니다", 405), - - // 시스템 (SYSTEM_xxx) - INTERNAL_SERVER_ERROR("SYSTEM_001", "내부 서버 오류가 발생했습니다", 500), - DATABASE_ERROR("SYSTEM_002", "데이터베이스 오류가 발생했습니다", 500), - EXTERNAL_API_ERROR("SYSTEM_003", "외부 API 호출 오류가 발생했습니다", 502), - SERVICE_UNAVAILABLE("SYSTEM_004", "서비스를 일시적으로 사용할 수 없습니다", 503); - - private final String code; - private final String message; - private final int statusCode; -} -``` - -### 4.3 예외 생성 팩토리 패턴 - -```mermaid -flowchart LR - subgraph FactoryMethods["CommonException 팩토리 메서드"] - AUTH["인증 오류"] - VALID["검증 오류"] - RES["리소스 오류"] - SYS["시스템 오류"] - end - - AUTH --> UNAUTH["unauthorized()"] - AUTH --> FORBID["forbidden()"] - AUTH --> TOKEN["invalidToken()"] - VALID --> INPUT["invalidInput(msg)"] - VALID --> MISS["requiredFieldMissing(field)"] - VALID --> FMT["invalidFormat(field)"] - RES --> NF["notFound(resource, id)"] - RES --> EXIST["alreadyExists(resource)"] - SYS --> INTERN["internalError(cause)"] - SYS --> DB["databaseError(cause)"] - SYS --> EXT["externalApiError(api, cause)"] -``` - -### 4.4 예외 사용 예시 - -```java -// 가독성 높은 예외 생성 -throw CommonException.notFound("User","user123"); -// → "User (ID: user123)를 찾을 수 없습니다", 404 - -throw CommonException. - -invalidInput("Email format is invalid"); -// → 400 INVALID_INPUT with custom message - -throw CommonException. - -alreadyExists("ChatRoom","room456"); -// → "ChatRoom (ID: room456)가 이미 존재합니다", 409 - -// 상세 컨텍스트 추가 (메서드 체이닝) -throw CommonException. - -internalError(cause) - . - -addDetail("operation","database_query") - . - -addDetail("table","users"); -``` - ---- - -## 5. AWS 클라이언트 관리 - -### 5.1 Singleton 패턴 (Cold Start 최적화) - -```mermaid -flowchart TB - subgraph ColdStart["Lambda Cold Start 최적화"] - INIT["Lambda 컨테이너 초기화
(1회)"] - STATIC["static final 클라이언트 생성"] - REUSE["요청마다 재사용"] - end - - INIT --> STATIC - STATIC --> REUSE - REUSE -->|" 다음 요청 "| REUSE -``` - -### 5.2 AwsClients.java 구조 - -```java -public final class AwsClients { - // DynamoDB (Enhanced Client 포함) - private static final DynamoDbClient DYNAMO_DB_CLIENT = - DynamoDbClient.builder().build(); - private static final DynamoDbEnhancedClient DYNAMO_DB_ENHANCED_CLIENT = - DynamoDbEnhancedClient.builder() - .dynamoDbClient(DYNAMO_DB_CLIENT) - .build(); - - // S3 (Presigner 포함) - private static final S3Client S3_CLIENT = S3Client.builder().build(); - private static final S3Presigner S3_PRESIGNER = S3Presigner.builder().build(); - - // AI/ML 서비스 - private static final PollyClient POLLY_CLIENT = PollyClient.builder().build(); - private static final BedrockRuntimeClient BEDROCK_CLIENT = - BedrockRuntimeClient.builder().build(); - private static final BedrockRuntimeAsyncClient BEDROCK_ASYNC_CLIENT = - BedrockRuntimeAsyncClient.builder().build(); - private static final ComprehendClient COMPREHEND_CLIENT = - ComprehendClient.builder().build(); - - // SNS - private static final SnsClient SNS_CLIENT = SnsClient.builder().build(); - - // 팩토리 메서드 - public static DynamoDbClient dynamoDb() { - return DYNAMO_DB_CLIENT; - } - - public static DynamoDbEnhancedClient dynamoDbEnhanced() { - return DYNAMO_DB_ENHANCED_CLIENT; - } - - public static S3Client s3() { - return S3_CLIENT; - } - - public static S3Presigner s3Presigner() { - return S3_PRESIGNER; - } - - public static PollyClient polly() { - return POLLY_CLIENT; - } - - public static BedrockRuntimeClient bedrock() { - return BEDROCK_CLIENT; - } - - public static BedrockRuntimeAsyncClient bedrockAsync() { - return BEDROCK_ASYNC_CLIENT; - } - - public static ComprehendClient comprehend() { - return COMPREHEND_CLIENT; - } - - public static SnsClient sns() { - return SNS_CLIENT; - } -} -``` - -### 5.3 사용 예시 - -```java -// Service에서 사용 -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(VoiceId.MATTHEW) - .engine("neural") - .outputFormat(OutputFormat.MP3) - .build(); - - // Singleton 클라이언트 사용 - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - AwsClients.s3().putObject(putRequest, RequestBody.fromInputStream(audioStream, -1)); - - return new VoiceSynthesisResult(s3Key, presignedUrl, false); - } -} -``` - ---- - -## 6. DTO 패턴 (Java 21 Records) - -### 6.1 ApiResponse (제네릭 응답 래퍼) - -```java -// 불변 데이터 클래스 - Java 21 Record -public record ApiResponse( - boolean isSuccess, - String message, - T data, - String error - ) { - // 성공 응답 팩토리 - public static ApiResponse ok(String message, T data) { - return new ApiResponse<>(true, message, data, null); - } - - public static ApiResponse ok(T data) { - return new ApiResponse<>(true, null, data, null); - } - - // 실패 응답 팩토리 - public static ApiResponse fail(String errorMessage) { - return new ApiResponse<>(false, null, null, errorMessage); - } -} -``` - -**JSON 응답 예시:** - -```json -{ - "isSuccess": true, - "message": "Grammar checked successfully", - "data": { - "correctedSentence": "I am a student", - "score": 85, - "errors": [ - ... - ] - }, - "error": null -} -``` - -### 6.2 ErrorInfo (RFC 7807 준수) - -```java -// Problem Details for HTTP APIs (RFC 7807) -public record ErrorInfo( - String code, // e.g., "VOCABULARY.WORD_001" - String message, // e.g., "단어를 찾을 수 없습니다" - int status, // e.g., 404 - Map details // Optional context - ) { - public static ErrorInfo from(ErrorCode errorCode) { ...} - - public static ErrorInfo from(ServerlessException ex) { ...} - - public boolean isClientError() { - return status >= 400 && status < 500; - } - - public boolean isServerError() { - return status >= 500 && status < 600; - } -} -``` - -**JSON 에러 응답 예시:** - -```json -{ - "code": "VOCABULARY.WORD_001", - "message": "단어를 찾을 수 없습니다", - "status": 404, - "details": { - "wordId": "abc-123", - "userId": "user456" - } -} -``` - -### 6.3 PaginatedResult (커서 페이지네이션) - -```java -public record PaginatedResult( - List items, - String nextCursor // Base64 인코딩된 DynamoDB lastEvaluatedKey -) { - public boolean hasMore() { - return nextCursor != null; - } -} -``` - ---- - -## 7. 페이지네이션 유틸리티 - -### 7.1 CursorUtil 동작 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler - participant CursorUtil - participant DynamoDB - Note over Client, DynamoDB: 첫 페이지 요청 - Client ->> Handler: GET /items?limit=10 - Handler ->> CursorUtil: decode(null) → null - Handler ->> DynamoDB: Query (exclusiveStartKey=null) - DynamoDB -->> Handler: items + lastEvaluatedKey - Handler ->> CursorUtil: encode(lastEvaluatedKey) - CursorUtil -->> Handler: "dXNlcklkPXVzZXIxMjM..." - Handler -->> Client: {"items": [...], "nextCursor": "dXNlcklkPXVzZXIxMjM..."} - Note over Client, DynamoDB: 다음 페이지 요청 - Client ->> Handler: GET /items?cursor=dXNlcklkPXVzZXIxMjM... - Handler ->> CursorUtil: decode("dXNlcklkPXVzZXIxMjM...") - CursorUtil -->> Handler: {"userId": "user123", ...} - Handler ->> DynamoDB: Query (exclusiveStartKey={...}) - DynamoDB -->> Handler: items + lastEvaluatedKey -``` - -### 7.2 CursorUtil 구현 - -```java -public class CursorUtil { - // DynamoDB lastEvaluatedKey → Base64 문자열 - public static String encode(Map lastEvaluatedKey) { - if (lastEvaluatedKey == null || lastEvaluatedKey.isEmpty()) { - return null; - } - - StringBuilder sb = new StringBuilder(); - for (Map.Entry entry : lastEvaluatedKey.entrySet()) { - if (sb.length() > 0) sb.append("|"); - sb.append(entry.getKey()).append("=").append(entry.getValue().s()); - } - - return Base64.getUrlEncoder().encodeToString(sb.toString().getBytes()); - } - - // Base64 문자열 → DynamoDB exclusiveStartKey - public static Map decode(String cursor) { - if (cursor == null || cursor.isEmpty()) { - return null; - } - - String decoded = new String(Base64.getUrlDecoder().decode(cursor)); - Map startKey = new HashMap<>(); - - for (String pair : decoded.split("\\|")) { - String[] kv = pair.split("=", 2); - if (kv.length == 2) { - startKey.put(kv[0], AttributeValue.builder().s(kv[1]).build()); - } - } - - return startKey; - } -} -``` - ---- - -## 8. 인증 유틸리티 - -### 8.1 Cognito 인증 흐름 - -```mermaid -flowchart TB - subgraph CognitoAuth["Cognito 인증 흐름"] - REQ[요청] --> AUTH[API Gateway Authorizer] - AUTH --> CLAIMS[JWT Claims 추출] - CLAIMS --> INJECT["requestContext.authorizer.claims"] - end - - subgraph CognitoUtil["CognitoUtil 추출"] - INJECT --> EXTRACT[extractUserId] - EXTRACT --> SUB["claims.sub → userId"] - end -``` - -### 8.2 CognitoUtil.java - -```java -public class CognitoUtil { - // 기본 userId 추출 (sub claim) - public static String extractUserId(APIGatewayProxyRequestEvent request) { - Map authorizer = request.getRequestContext().getAuthorizer(); - if (authorizer == null) return null; - - Map claims = (Map) authorizer.get("claims"); - return claims != null ? claims.get("sub") : null; - } - - // 선택적 claim 추출 - public static Optional extractEmail(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "email"); - } - - public static Optional extractNickname(APIGatewayProxyRequestEvent request) { - return extractClaim(request, "custom:nickname"); - } - - public static Optional extractClaim( - APIGatewayProxyRequestEvent request, String claimName) { - // ... claim 추출 로직 - } - - // 사용자 접근 권한 검증 - public static boolean validateUserAccess( - APIGatewayProxyRequestEvent request, String pathUserId) { - String tokenUserId = extractUserId(request); - return tokenUserId != null && tokenUserId.equals(pathUserId); - } -} -``` - -### 8.3 JwtUtil.java (WebSocket용) - -```java -// WebSocket 연결 시 직접 JWT 파싱 (Authorizer 미사용) -public final class JwtUtil { - public static Optional extractUserId(String token) { - // Bearer 제거 - if (token.startsWith("Bearer ")) { - token = token.substring(7); - } - - // JWT payload 추출 (헤더.페이로드.시그니처) - String[] parts = token.split("\\."); - if (parts.length != 3) return Optional.empty(); - - // Base64 URL 디코딩 - String payload = new String(Base64.getUrlDecoder().decode(parts[1])); - Map claims = gson.fromJson(payload, Map.class); - - return Optional.ofNullable((String) claims.get("sub")); - } - - public static boolean isExpired(String token) { - // exp claim 확인 - } -} -``` - ---- - -## 9. HTTP 응답 생성 - -### 9.1 ResponseGenerator.java - -```java -public class ResponseGenerator { - private static final Gson GSON = new GsonBuilder() - .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") - .create(); - - private static final Map CORS_HEADERS = Map.of( - "Content-Type", "application/json", - "Access-Control-Allow-Origin", "*", - "Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS", - "Access-Control-Allow-Headers", "Content-Type,Authorization" - ); - - // 성공 응답 - public static APIGatewayProxyResponseEvent ok(String message, T data) { - return buildResponse(200, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent created(String message, T data) { - return buildResponse(201, ApiResponse.ok(message, data)); - } - - public static APIGatewayProxyResponseEvent noContent() { - return buildResponse(204, null); - } - - // 에러 응답 - public static APIGatewayProxyResponseEvent fail(ErrorCode errorCode) { - return buildResponse(errorCode.getStatusCode(), ErrorInfo.from(errorCode)); - } - - public static APIGatewayProxyResponseEvent badRequest(String message) { - return fail(CommonErrorCode.INVALID_INPUT, message); - } - - public static APIGatewayProxyResponseEvent notFound(String message) { - return fail(CommonErrorCode.RESOURCE_NOT_FOUND, message); - } - - // ... 기타 편의 메서드 - - private static APIGatewayProxyResponseEvent buildResponse(int statusCode, Object body) { - return new APIGatewayProxyResponseEvent() - .withStatusCode(statusCode) - .withHeaders(new HashMap<>(CORS_HEADERS)) - .withBody(body != null ? GSON.toJson(body) : null); - } - - public static Gson gson() { - return GSON; - } -} -``` - ---- - -## 10. Bean Validation - -### 10.1 BeanValidator 패턴 - -```mermaid -flowchart TB - REQ[요청 수신] --> PARSE[JSON 파싱 → DTO] -PARSE --> VALIDATE[BeanValidator.validateAndExecute] -VALIDATE --> CHECK{검증 통과?} -CHECK -->|Yes|HANDLER[핸들러 로직 실행] -CHECK -->|No|ERR400[400 Bad Request] -HANDLER --> RESPONSE[정상 응답] -``` - -### 10.2 BeanValidator.java - -```java -public final class BeanValidator { - private static final Validator VALIDATOR; - - static { - ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); - VALIDATOR = factory.getValidator(); - } - - // 검증 + 실행 통합 패턴 - public static APIGatewayProxyResponseEvent validateAndExecute( - T object, - Function handler) { - - Optional error = validate(object); - if (error.isPresent()) { - return ResponseGenerator.badRequest(error.get()); - } - - return handler.apply(object); - } - - public static Optional validate(T object) { - Set> violations = VALIDATOR.validate(object); - if (violations.isEmpty()) { - return Optional.empty(); - } - - String message = violations.stream() - .map(ConstraintViolation::getMessage) - .collect(Collectors.joining(", ")); - - return Optional.of(message); - } -} -``` - -### 10.3 DTO 검증 예시 - -```java -// 요청 DTO -public class CreateRoomRequest { - @NotEmpty(message = "방 이름은 필수입니다") - private String roomName; - - @NotNull(message = "난이도는 필수입니다") - private String difficulty; - - @Min(value = 2, message = "최소 2명 이상이어야 합니다") - @Max(value = 10, message = "최대 10명까지 가능합니다") - private int maxMembers; -} - -// Handler에서 사용 -private APIGatewayProxyResponseEvent createRoom( - APIGatewayProxyRequestEvent request, String userId) { - - CreateRoomRequest req = ResponseGenerator.gson() - .fromJson(request.getBody(), CreateRoomRequest.class); - - return BeanValidator.validateAndExecute(req, dto -> { - // 검증 통과 시에만 실행됨 - ChatRoom room = roomService.createRoom(userId, dto); - return ResponseGenerator.created("방이 생성되었습니다", room); - }); -} -``` - ---- - -## 11. WebSocket 유틸리티 - -### 11.1 브로드캐스트 흐름 - -```mermaid -sequenceDiagram - participant Service - participant Broadcaster as WebSocketBroadcaster - participant APIGW as API Gateway - participant Clients as WebSocket Clients - Service ->> Broadcaster: broadcast(connections, message) - - loop 각 연결에 전송 - Broadcaster ->> APIGW: postToConnection(connectionId, data) - alt 성공 - APIGW -->> Clients: 메시지 전달 - else 연결 끊김 (410 Gone) - APIGW -->> Broadcaster: GoneException - Broadcaster ->> Broadcaster: failedIds에 추가 - end - end - - Broadcaster -->> Service: failedConnectionIds 반환 - Service ->> Service: Stale 연결 정리 -``` - -### 11.2 WebSocketBroadcaster.java - -```java -public class WebSocketBroadcaster { - private final ApiGatewayManagementApiClient apiClient; - - public WebSocketBroadcaster() { - String endpoint = WebSocketConfig.websocketEndpoint(); - this.apiClient = ApiGatewayManagementApiClient.builder() - .endpointOverride(URI.create(endpoint)) - .build(); - } - - // 단일 연결에 전송 - public boolean sendToConnection(String connectionId, String message) { - try { - apiClient.postToConnection(PostToConnectionRequest.builder() - .connectionId(connectionId) - .data(SdkBytes.fromUtf8String(message)) - .build()); - return true; - } catch (GoneException e) { - // 연결이 이미 끊김 - return false; - } - } - - // 다수 연결에 브로드캐스트 - public List broadcast(List connections, String message) { - List failedIds = new ArrayList<>(); - - for (Connection conn : connections) { - if (!sendToConnection(conn.getConnectionId(), message)) { - failedIds.add(conn.getConnectionId()); - } - } - - return failedIds; // 실패한 연결 ID 반환 (정리용) - } -} -``` - -### 11.3 WebSocket 응답 유틸리티 - -```java -public final class WebSocketResponseUtil { - public static Map ok(String message) { - return response(200, message); - } - - public static Map unauthorized(String message) { - return response(401, message); - } - - public static Map badRequest(String message) { - return response(400, message); - } - - private static Map response(int statusCode, String body) { - return Map.of( - "statusCode", statusCode, - "body", body - ); - } -} -``` - ---- - -## 12. S3 Presigned URL - -### 12.1 S3PresignUtil.java - -```java -public class S3PresignUtil { - private static final Duration DEFAULT_DURATION = Duration.ofHours(24); - private static final String BUCKET_NAME = System.getenv("S3_BUCKET_NAME"); - - // 내부 캐시 (Java 21 Record) - private record CachedUrl(String url, long expiresAt) { - boolean isExpired() { - // 1시간 버퍼 두고 만료 체크 - return System.currentTimeMillis() > (expiresAt - 3600_000); - } - } - - private static final Map URL_CACHE = new ConcurrentHashMap<>(); - - public static String getPresignedUrl(String key) { - return getPresignedUrl(key, DEFAULT_DURATION); - } - - public static String getPresignedUrl(String key, Duration duration) { - CachedUrl cached = URL_CACHE.get(key); - if (cached != null && !cached.isExpired()) { - return cached.url(); - } - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(duration) - .getObjectRequest(r -> r.bucket(BUCKET_NAME).key(key)) - .build(); - - String url = AwsClients.s3Presigner() - .presignGetObject(presignRequest) - .url() - .toString(); - - URL_CACHE.put(key, new CachedUrl(url, - System.currentTimeMillis() + duration.toMillis())); - - return url; - } - - // 배지 이미지 URL 생성 편의 메서드 - public static String getBadgeImageUrl(String imageFile) { - return getPresignedUrl("badges/" + imageFile); - } -} -``` - ---- - -## 13. AWS 서비스 래퍼 - -### 13.1 PollyService (TTS + S3 캐시) - -```mermaid -flowchart TB - REQ[음성 합성 요청] --> CHECK{S3 캐시 확인} - CHECK -->|캐시 있음| PRESIGN[Presigned URL 생성] - CHECK -->|캐시 없음| SYNTH[Polly 음성 합성] - SYNTH --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RETURN[URL 반환] -``` - -```java -public class PollyService { - public VoiceSynthesisResult synthesizeSpeech(String id, String text, String voice) { - String s3Key = generateS3Key(id, voice); - - // 캐시 확인 - if (existsInS3(s3Key)) { - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), true); - } - - // Polly 음성 합성 - VoiceId voiceId = "MALE".equalsIgnoreCase(voice) ? VoiceId.MATTHEW : VoiceId.JOANNA; - - SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() - .text(text) - .voiceId(voiceId) - .engine("neural") // Neural 음성 (고품질) - .outputFormat(OutputFormat.MP3) - .build(); - - InputStream audioStream = AwsClients.polly().synthesizeSpeech(request); - - // S3 저장 - AwsClients.s3().putObject( - PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType("audio/mpeg") - .build(), - RequestBody.fromInputStream(audioStream, -1) - ); - - return new VoiceSynthesisResult(s3Key, getPresignedUrl(s3Key), false); - } - - public String generateS3Key(String id, String voice) { - String suffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; - return s3KeyPrefix + id + "_" + suffix + ".mp3"; - } -} -``` - -### 13.2 ComprehendService (NLP 분석) - -```java -public class ComprehendService { - public ComprehendAnalysis analyze(String text) { - // 감정 분석 - DetectSentimentResponse sentiment = AwsClients.comprehend() - .detectSentiment(DetectSentimentRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 구문 분석 (품사 태깅) - DetectSyntaxResponse syntax = AwsClients.comprehend() - .detectSyntax(DetectSyntaxRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 핵심 구문 추출 - DetectKeyPhrasesResponse keyPhrases = AwsClients.comprehend() - .detectKeyPhrases(DetectKeyPhrasesRequest.builder() - .text(text) - .languageCode("en") - .build()); - - // 문장 복잡도 계산 - String complexity = calculateComplexity(syntax.syntaxTokens()); - - return ComprehendAnalysis.builder() - .sentiment(sentiment.sentimentAsString()) - .syntax(mapTokens(syntax.syntaxTokens())) - .keyPhrases(mapKeyPhrases(keyPhrases.keyPhrases())) - .complexity(complexity) - .build(); - } - - private String calculateComplexity(List tokens) { - Set uniquePOS = tokens.stream() - .map(t -> t.partOfSpeech().tagAsString()) - .collect(Collectors.toSet()); - - if (uniquePOS.size() <= 3 && tokens.size() <= 5) return "BEGINNER"; - if (uniquePOS.size() <= 5 && tokens.size() <= 10) return "INTERMEDIATE"; - return "ADVANCED"; - } -} -``` - ---- - -## 14. 설정 클래스 - -### 14.1 StudyConfig (학습 알고리즘 상수) - -```java -public final class StudyConfig { - // SM-2 알고리즘 상수 - public static final int INITIAL_INTERVAL_DAYS = 1; - public static final double DEFAULT_EASE_FACTOR = 2.5; - public static final double MIN_EASE_FACTOR = 1.3; - public static final int INITIAL_REPETITIONS = 0; - - // 테스트 설정 - public static final int DEFAULT_WORD_COUNT = 20; - public static final int DAILY_TEST_WORD_COUNT = 10; - - // 복습 주기 (일) - public static final int[] REVIEW_INTERVALS = {1, 3, 7, 14, 30}; - - // 상태 기본값 - public static final String DEFAULT_WORD_STATUS = "NEW"; - public static final String DEFAULT_DIFFICULTY = "NORMAL"; - - // 오류 제한 - public static final int MAX_WRONG_COUNT = 3; -} -``` - -### 14.2 DynamoDbKey (키 패턴 상수) - -```java -public final class DynamoDbKey { - // 기본 키 - public static final String PK = "PK"; - public static final String SK = "SK"; - - // GSI 키 - public static final String GSI1_PK = "GSI1PK"; - public static final String GSI1_SK = "GSI1SK"; - public static final String GSI2_PK = "GSI2PK"; - public static final String GSI2_SK = "GSI2SK"; - - // GSI 이름 - public static final String GSI1 = "GSI1"; - public static final String GSI2 = "GSI2"; - - // 공통 접두사 - public static final String USER = "USER#"; - public static final String METADATA = "METADATA"; - - // 헬퍼 메서드 - public static String userPk(String userId) { - return USER + userId; // "USER#user-123" - } -} -``` - ---- - -## 15. Java 21 기능 활용 - -### 15.1 Records 활용 - -| 클래스 | 용도 | -|-----------------|----------------| -| ApiResponse | 제네릭 API 응답 래퍼 | -| ErrorInfo | RFC 7807 에러 응답 | -| PaginatedResult | 페이지네이션 결과 | -| Route | HTTP 라우트 정의 | -| RouteEntry | 라우터 내부 매칭 | -| CachedUrl | S3 URL 캐시 | - -### 15.2 Sealed Interface 활용 - -```mermaid -flowchart TB - subgraph SealedPattern["Sealed Interface 패턴"] - EC[/"sealed interface ErrorCode
permits CommonErrorCode, DomainErrorCode"/] - CEC["final enum CommonErrorCode
implements ErrorCode"] - DEC[/"non-sealed interface DomainErrorCode
extends ErrorCode"/] - EC --> CEC - EC --> DEC - end -``` - -### 15.3 Pattern Matching 활용 - -```java -// instanceof 패턴 매칭 -String code = errorCode instanceof DomainErrorCode domainCode - ? domainCode.getFullCode() // "VOCABULARY.WORD_001" - : errorCode.getCode(); // "AUTH_001" - -// switch 표현식 (Enhanced) -return switch(type. - -getCategory()){ - case"FIRST_STUDY"->stats. - -getTestsCompleted() >=1; - case"STREAK"->stats. - -getCurrentStreak() >=type. - -getThreshold(); - case"ACCURACY"->{ -double accuracy = (double) stats.getCorrectAnswers() / stats.getQuestionsAnswered() * 100; -yield accuracy >=type. - -getThreshold(); - } -default ->false; - }; -``` - ---- - -## 16. 디자인 패턴 요약 - -| 패턴 | 적용 위치 | 목적 | -|----------------------|------------------------|-------------------| -| **Singleton** | AwsClients | AWS SDK 클라이언트 재사용 | -| **Factory Method** | Route, CommonException | 객체 생성 캡슐화 | -| **Strategy** | AuthenticatedHandler | 요청 처리 전략 분리 | -| **Router** | HandlerRouter | HTTP 요청 라우팅 | -| **Builder** | ComprehendAnalysis | 복잡한 객체 생성 | -| **Template Method** | BeanValidator | 검증-실행 흐름 템플릿 | -| **Sealed Interface** | ErrorCode 계층 | 구현 제한 | -| **Data Class** | Records | 불변 데이터 전송 | - ---- - -## 17. 파일 구조 - -``` -common/ -├── config/ -│ ├── AwsClients.java # AWS SDK 클라이언트 싱글톤 -│ ├── WebSocketConfig.java # WebSocket 설정 -│ ├── RoomTokenConfig.java # 방 토큰 TTL 설정 -│ └── StudyConfig.java # 학습 알고리즘 상수 -├── constants/ -│ └── DynamoDbKey.java # DynamoDB 키 패턴 -├── dto/ -│ ├── ApiResponse.java # 제네릭 응답 래퍼 (Record) -│ ├── ErrorInfo.java # RFC 7807 에러 (Record) -│ └── PaginatedResult.java # 페이지네이션 (Record) -├── enums/ -│ ├── Difficulty.java # EASY, NORMAL, HARD -│ └── StudyLevel.java # BEGINNER, INTERMEDIATE, ADVANCED -├── exception/ -│ ├── ServerlessException.java # 기본 예외 클래스 -│ ├── ErrorCode.java # Sealed Interface -│ ├── CommonErrorCode.java # 공통 에러 코드 -│ ├── DomainErrorCode.java # 도메인 에러 인터페이스 -│ └── CommonException.java # 예외 팩토리 -├── router/ -│ ├── HandlerRouter.java # HTTP 라우터 -│ ├── Route.java # 라우트 정의 (Record) -│ └── AuthenticatedHandler.java # 인증 핸들러 인터페이스 -├── service/ -│ ├── PollyService.java # TTS + S3 캐시 -│ └── ComprehendService.java # NLP 분석 -├── util/ -│ ├── ResponseGenerator.java # HTTP 응답 빌더 -│ ├── CursorUtil.java # 커서 페이지네이션 -│ ├── CognitoUtil.java # Cognito 인증 추출 -│ ├── JwtUtil.java # JWT 직접 파싱 -│ ├── WebSocketBroadcaster.java # WebSocket 브로드캐스트 -│ ├── WebSocketEventUtil.java # WebSocket 이벤트 추출 -│ ├── WebSocketResponseUtil.java # WebSocket 응답 빌더 -│ └── S3PresignUtil.java # Presigned URL 생성 -└── validation/ - └── BeanValidator.java # Bean Validation 유틸 -``` - ---- - -## 18. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Build:** Gradle -- **AWS SDK:** AWS SDK for Java v2 -- **Validation:** Jakarta Bean Validation -- **JSON:** Gson -- **Pattern:** Singleton, Factory, Strategy, Router, Builder, Sealed Interface -- **Java 21 Features:** Records, Sealed Interface, Pattern Matching, Enhanced Switch diff --git a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md b/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md deleted file mode 100644 index 5015a011..00000000 --- a/docs/domain-reports/GRAMMAR-DOMAIN-REPORT.md +++ /dev/null @@ -1,465 +0,0 @@ -# Grammar Domain 세부 보고서 - -## 1. 개요 - -Grammar 도메인은 AWS Bedrock(Claude 3 Haiku)을 활용한 AI 기반 영어 문법 체크 시스템입니다. REST API와 WebSocket 스트리밍을 통해 실시간 문법 교정 및 대화형 학습을 -제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API] - WS[Grammar WebSocket] - end - - subgraph Lambda["Lambda Handlers"] - HANDLER[GrammarHandler] - CONNECT[StreamingConnectHandler] - DISCONNECT[StreamingDisconnectHandler] - STREAM[StreamingHandler] - end - - subgraph AI["AWS AI 서비스"] - BEDROCK[Bedrock
Claude 3 Haiku] - COMPREHEND[Comprehend
언어 분석] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - APP <--> WS - REST --> HANDLER - WS --> CONNECT - WS --> DISCONNECT - WS --> STREAM - HANDLER --> BEDROCK - HANDLER --> COMPREHEND - STREAM --> BEDROCK - HANDLER --> DDB - STREAM --> DDB -``` - ---- - -## 3. 문법 체크 흐름 - -### 3.1 동기식 문법 체크 - -```mermaid -sequenceDiagram - participant Client - participant Handler as GrammarHandler - participant Service as GrammarCheckService - participant Bedrock as AWS Bedrock - participant DB as DynamoDB - Client ->> Handler: POST /grammar/check - Handler ->> Service: checkGrammar(sentence, level) - Service ->> Bedrock: Claude API 호출 - Bedrock -->> Service: JSON 응답 - Service -->> Handler: GrammarCheckResponse - Handler -->> Client: 문법 교정 결과 -``` - -### 3.2 스트리밍 대화 - -```mermaid -sequenceDiagram - participant Client - participant WS as WebSocket - participant Handler as StreamingHandler - participant Service as ConversationService - participant Bedrock as AWS Bedrock - Client ->> WS: $connect?token={jwt} - WS -->> Client: 연결 성공 - Client ->> WS: 메시지 전송 - WS ->> Handler: $default 라우트 - Handler ->> Service: chatStreaming() - Service -->> Client: StartEvent (sessionId) - - loop 토큰 단위 스트리밍 - Bedrock -->> Service: 텍스트 청크 - Service -->> Client: TokenEvent - end - - Service -->> Client: CompleteEvent (전체 응답) -``` - ---- - -## 4. API 엔드포인트 - -### 4.1 REST API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|---------------| -| POST | /grammar/check | 문법 체크 (단일 문장) | -| POST | /grammar/conversation | 대화형 문법 학습 | -| GET | /grammar/sessions | 대화 세션 목록 | -| GET | /grammar/sessions/{sessionId} | 세션 상세 | -| DELETE | /grammar/sessions/{sessionId} | 세션 삭제 | - -### 4.2 WebSocket API - -| Route | 설명 | -|-------------|-------------| -| $connect | JWT 토큰으로 연결 | -| $disconnect | 연결 해제 | -| $default | 스트리밍 메시지 처리 | - ---- - -## 5. 레벨별 문법 체크 - -### 5.1 학습 레벨 - -| 레벨 | 설명 | 피드백 스타일 | -|--------------|----|--------------------| -| BEGINNER | 초급 | 한국어 번역 + 쉬운 설명 | -| INTERMEDIATE | 중급 | 영어 위주 설명 | -| ADVANCED | 고급 | 상세한 문법 규칙 + 스타일 제안 | - -### 5.2 오류 유형 - -```mermaid -mindmap - root((문법 오류)) - 시제 - VERB_TENSE - 동사 시제 오류 - 일치 - SUBJECT_VERB_AGREEMENT - 주어-동사 일치 - 품사 - ARTICLE - 관사 오류 - PREPOSITION - 전치사 오류 - PRONOUN - 대명사 오류 - 구조 - WORD_ORDER - 어순 오류 - SENTENCE_STRUCTURE - 문장 구조 - 기타 - SPELLING - 철자 - PUNCTUATION - 구두점 - WORD_CHOICE - 어휘 선택 -``` - ---- - -## 6. 응답 포맷 - -### 6.1 문법 체크 응답 - -```json -{ - "originalSentence": "I goed to school yesterday", - "correctedSentence": "I went to school yesterday", - "score": 70, - "isCorrect": false, - "errors": [ - { - "type": "VERB_TENSE", - "original": "goed", - "corrected": "went", - "explanation": "'go'의 과거형은 'went'입니다 (불규칙 동사)", - "startIndex": 2, - "endIndex": 6 - } - ], - "feedback": "과거 시제를 잘 사용하려고 노력했네요! 불규칙 동사를 조금 더 연습해보세요." -} -``` - -### 6.2 대화 응답 - -```json -{ - "sessionId": "uuid", - "grammarCheck": { - /* 위와 동일 */ - }, - "aiResponse": "Great job! Your sentence structure is correct. Let's practice more complex sentences.", - "conversationTip": "Try using 'had gone' for past perfect tense." -} -``` - -### 6.3 스트리밍 이벤트 - -```json -// StartEvent -{ - "type": "start", - "sessionId": "uuid" -} - -// TokenEvent (실시간) -{ - "type": "token", - "token": "Great " -} -{ - "type": "token", - "token": "job!" -} - -// CompleteEvent (완료) -{ - "type": "complete", - "sessionId": "uuid", - "grammarCheck": { - ... - }, - "aiResponse": "...", - "conversationTip": "..." -} - -// ErrorEvent (오류 시) -{ - "type": "error", - "message": "..." -} -``` - ---- - -## 7. AWS Bedrock 통합 - -### 7.1 Claude 3 Haiku 설정 - -```java -public class BedrockGrammarCheckFactory { - private static final String MODEL_ID = "anthropic.claude-3-haiku-20240307-v1:0"; - private static final int MAX_TOKENS = 2048; - private static final String API_VERSION = "bedrock-2023-05-31"; -} -``` - -### 7.2 프롬프트 구조 - -**시스템 프롬프트 (초급):** - -``` -You are a friendly English grammar tutor for Korean speakers. -- Use simple English with Korean translations -- Be encouraging and supportive -- Explain grammar rules clearly -``` - -**사용자 프롬프트:** - -``` -Please check the grammar of this sentence: "{sentence}" - -Return JSON: -{ - "correctedSentence": "...", - "score": 0-100, - "isCorrect": boolean, - "errors": [...], - "feedback": "..." -} -``` - -### 7.3 스트리밍 응답 파싱 - -``` -[RESPONSE] -AI의 자연스러운 대화 응답 -[/RESPONSE] - -[GRAMMAR] -{ JSON 형식의 문법 체크 결과 } -[/GRAMMAR] - -[TIP] -학습 팁 -[/TIP] -``` - ---- - -## 8. 데이터 모델 - -### 8.1 GrammarSession - -```java - -@DynamoDbBean -public class GrammarSession { - String sessionId; - String userId; - String level; // BEGINNER, INTERMEDIATE, ADVANCED - String topic; // "Conversation Practice" - Integer messageCount; - String lastMessage; // 마지막 메시지 (100자 제한) - String createdAt; - String updatedAt; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `SESSION#{sessionId}` -- GSI1: `GSESSION#ALL` | `UPDATED#{timestamp}` (최신순 정렬) - -### 8.2 GrammarMessage - -```java - -@DynamoDbBean -public class GrammarMessage { - String messageId; - String sessionId; - String userId; - String role; // USER, ASSISTANT - String content; // 원본 메시지 - String correctedContent; // 교정된 메시지 (USER만) - String errorsJson; // 오류 목록 JSON - Integer grammarScore; - String feedback; - Boolean isCorrect; - Long ttl; // 30일 -} -``` - -**DynamoDB Keys:** - -- PK: `GSESSION#{userId}` | SK: `MSG#{timestamp}#{messageId}` -- GSI1: `GSESSION#{sessionId}` | `MSG#{timestamp}` - -### 8.3 GrammarConnection (WebSocket) - -```java - -@DynamoDbBean -public class GrammarConnection { - String connectionId; // API Gateway 연결 ID - String userId; // JWT에서 추출 - String connectedAt; - Long ttl; // 연결 타임아웃 -} -``` - ---- - -## 9. AWS Comprehend 분석 (선택적) - -```mermaid -flowchart LR - INPUT[입력 문장] --> SENTIMENT[감정 분석] - INPUT --> SYNTAX[구문 분석] - INPUT --> KEYPHRASE[핵심 구문] - INPUT --> LANGUAGE[언어 감지] - SENTIMENT --> OUTPUT[분석 결과] - SYNTAX --> OUTPUT - KEYPHRASE --> OUTPUT - LANGUAGE --> OUTPUT -``` - -**분석 항목:** - -- 감정: POSITIVE, NEGATIVE, NEUTRAL, MIXED -- 품사 태깅: NOUN, VERB, ADJ 등 -- 핵심 구문 추출 -- 문장 복잡도 추정 - ---- - -## 10. 서비스 레이어 - -### 10.1 서비스 구성 - -| Service | 역할 | -|----------------------------|----------------| -| GrammarCheckService | 단일 문장 문법 체크 | -| GrammarConversationService | 대화형 학습 + 스트리밍 | -| GrammarSessionQueryService | 세션 조회, 삭제 | -| BedrockGrammarCheckFactory | Bedrock API 호출 | - -### 10.2 대화 히스토리 관리 - -```java -// 최근 10개 메시지만 컨텍스트로 유지 -private static final int MAX_HISTORY_MESSAGES = 10; - -// 대화 히스토리 빌드 -String buildConversationHistory(String sessionId) { - // 최근 메시지 조회 - // USER: 내용 / ASSISTANT: 내용 형식으로 포맷 -} -``` - ---- - -## 11. 파일 구조 - -``` -domain/grammar/ -├── handler/ -│ ├── GrammarHandler.java -│ └── websocket/ -│ ├── GrammarStreamingConnectHandler.java -│ ├── GrammarStreamingDisconnectHandler.java -│ └── GrammarStreamingHandler.java -├── service/ -│ ├── GrammarCheckService.java -│ ├── GrammarConversationService.java -│ └── GrammarSessionQueryService.java -├── factory/ -│ ├── GrammarCheckFactory.java (interface) -│ └── BedrockGrammarCheckFactory.java -├── repository/ -│ ├── GrammarSessionRepository.java -│ └── GrammarConnectionRepository.java -├── model/ -│ ├── GrammarSession.java -│ ├── GrammarMessage.java -│ └── GrammarConnection.java -├── dto/ -│ ├── request/ -│ │ ├── GrammarCheckRequest.java -│ │ └── ConversationRequest.java -│ └── response/ -│ ├── GrammarCheckResponse.java -│ ├── ConversationResponse.java -│ ├── GrammarError.java -│ └── ComprehendAnalysis.java -├── streaming/ -│ ├── StreamingCallback.java -│ ├── StreamingEvent.java (sealed interface) -│ └── StreamingRequest.java -├── enums/ -│ ├── GrammarLevel.java -│ └── GrammarErrorType.java -└── constants/ - └── GrammarKey.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **API:** API Gateway REST + WebSocket -- **AI:** AWS Bedrock (Claude 3 Haiku) -- **NLP:** AWS Comprehend (선택적) -- **Database:** DynamoDB -- **Auth:** JWT (Cognito) -- **Pattern:** Factory, Callback, Sealed Interface (Java 17+) diff --git a/docs/domain-reports/STATS-DOMAIN-REPORT.md b/docs/domain-reports/STATS-DOMAIN-REPORT.md deleted file mode 100644 index 3ca3d3ff..00000000 --- a/docs/domain-reports/STATS-DOMAIN-REPORT.md +++ /dev/null @@ -1,379 +0,0 @@ -# Stats Domain 세부 보고서 - -## 1. 개요 - -Stats 도메인은 사용자의 학습 활동을 추적하고 통계를 집계하는 시스템입니다. DynamoDB Streams와 EventBridge를 활용한 이벤트 기반 아키텍처로 실시간 통계 업데이트를 제공합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Triggers["트리거"] - TEST[테스트 완료] - DAILY[일일 학습] - GAME[게임 종료] - SCHEDULE[스케줄러
매일 자정] - end - - subgraph Processing["처리"] - STREAM[StatsStreamHandler
DynamoDB Streams] - SERVICE[StatsService
Write-through] - SCHEDULED[ScheduledStatsHandler
EventBridge] - end - - subgraph Storage["저장소"] - DDB[(DynamoDB
UserStats)] - end - - subgraph Query["조회"] - API[UserStatsHandler
REST API] - end - - TEST --> STREAM - DAILY --> SERVICE - GAME --> SERVICE - SCHEDULE --> SCHEDULED - STREAM --> DDB - SERVICE --> DDB - SCHEDULED --> DDB - DDB --> API -``` - ---- - -## 3. 통계 집계 방식 - -### 3.1 집계 레벨 - -```mermaid -flowchart LR - subgraph Levels["통계 집계 레벨"] - DAILY["일별
DAILY#2026-01-16"] - WEEKLY["주별
WEEKLY#2026-W03"] - MONTHLY["월별
MONTHLY#2026-01"] - TOTAL["전체
TOTAL"] - end - - EVENT[이벤트 발생] --> DAILY - EVENT --> WEEKLY - EVENT --> MONTHLY - EVENT --> TOTAL -``` - -### 3.2 Atomic Counter 패턴 - -```java -// 모든 레벨에 동시 업데이트 (원자적) -UpdateExpression: -SET correctAnswers = if_not_exists(correctAnswers, 0) + :correct, -incorrectAnswers = - -if_not_exists(incorrectAnswers, 0) +:incorrect, -testsCompleted = - -if_not_exists(testsCompleted, 0) +1, -updatedAt =:now -``` - ---- - -## 4. 이벤트 기반 통계 업데이트 - -### 4.1 DynamoDB Streams 처리 - -```mermaid -sequenceDiagram - participant Test as TestResult 저장 - participant Stream as DynamoDB Streams - participant Handler as StatsStreamHandler - participant DB as UserStats - Test ->> Stream: INSERT 이벤트 - Stream ->> Handler: 트리거 - Handler ->> Handler: PK/SK 패턴 확인
(TEST#userId, RESULT#timestamp) - Handler ->> DB: incrementTestStats() - Handler ->> DB: updateStudyStreak() - Handler ->> Handler: checkAndAwardBadges() -``` - -### 4.2 Write-through 패턴 - -```mermaid -sequenceDiagram - participant API as DailyStudyHandler - participant Service as StatsService - participant DB as UserStats - Note over API, DB: 단어 학습 완료 시 - API ->> Service: recordWordsLearned() - Service ->> DB: incrementWordsLearned()
(DAILY, WEEKLY, MONTHLY, TOTAL) - Service ->> DB: updateStudyStreak() -``` - ---- - -## 5. API 엔드포인트 - -### 5.1 통계 조회 API - -| Method | Endpoint | 설명 | 파라미터 | -|--------|----------------|---------|------------------| -| GET | /stats/daily | 일별 통계 | ?date=YYYY-MM-DD | -| GET | /stats/weekly | 주별 통계 | ?week=YYYY-Www | -| GET | /stats/monthly | 월별 통계 | ?month=YYYY-MM | -| GET | /stats/total | 전체 통계 | - | -| GET | /stats/history | 일별 히스토리 | ?cursor, ?limit | - -### 5.2 응답 예시 - -```json -{ - "periodType": "DAILY", - "period": "2026-01-16", - "testsCompleted": 3, - "questionsAnswered": 45, - "correctAnswers": 38, - "incorrectAnswers": 7, - "successRate": 84.44, - "newWordsLearned": 50, - "wordsReviewed": 5 -} -``` - -**전체 통계 추가 필드:** - -```json -{ - "currentStreak": 7, - "longestStreak": 14, - "lastStudyDate": "2026-01-16", - "gamesPlayed": 10, - "gamesWon": 3, - "totalGameScore": 450 -} -``` - ---- - -## 6. 연속 학습 (Streak) 시스템 - -### 6.1 스트릭 계산 로직 - -```mermaid -flowchart TB - START[학습 활동 발생] --> CHECK{lastStudyDate
확인} - CHECK -->|null| NEW["currentStreak = 1
longestStreak = 1"] - CHECK -->|오늘| SAME[변경 없음
이미 오늘 학습] - CHECK -->|어제| INCREMENT["currentStreak++
longestStreak = max()"] - CHECK -->|2일+ 전| RESET["currentStreak = 1
longestStreak 유지"] - NEW --> UPDATE[DB 업데이트] - INCREMENT --> UPDATE - RESET --> UPDATE -``` - -### 6.2 스트릭 리셋 (스케줄러) - -```java -// EventBridge: 매일 자정 실행 -@Scheduled -public void resetStreaks() { - String yesterday = LocalDate.now().minusDays(1).toString(); - // lastStudyDate != yesterday인 사용자의 스트릭 리셋 - // 비용 최적화로 클라이언트 측 계산 권장 -} -``` - ---- - -## 7. 데이터 모델 - -### 7.1 UserStats - -```java - -@DynamoDbBean -public class UserStats { - // 키 - String pk; // USER#{userId}#STATS - String sk; // DAILY#{date} | WEEKLY#{week} | MONTHLY#{month} | TOTAL - - // 메타데이터 - String userId; - String periodType; // DAILY, WEEKLY, MONTHLY, TOTAL - String period; // 2026-01-16, 2026-W03, 2026-01, TOTAL - - // 테스트 통계 - Integer testsCompleted; - Integer questionsAnswered; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - - // 학습 통계 - Integer newWordsLearned; - Integer wordsReviewed; - Integer wordsMastered; - - // 스트릭 (TOTAL만) - Integer currentStreak; - Integer longestStreak; - String lastStudyDate; - - // 게임 통계 (TOTAL만) - Integer gamesPlayed; - Integer gamesWon; - Integer correctGuesses; - Integer totalGameScore; - Integer quickGuesses; // 5초 이내 정답 - Integer perfectDraws; // 전원 정답 유도 - - // 타임스탬프 - String createdAt; - String updatedAt; -} -``` - -### 7.2 DynamoDB 키 구조 - -| 필드 | 패턴 | 예시 | -|---------|------------------------|-------------------| -| PK | USER#{userId}#STATS | USER#abc123#STATS | -| SK (일별) | DAILY#{date} | DAILY#2026-01-16 | -| SK (주별) | WEEKLY#{year}-W{week} | WEEKLY#2026-W03 | -| SK (월별) | MONTHLY#{year}-{month} | MONTHLY#2026-01 | -| SK (전체) | TOTAL | TOTAL | - ---- - -## 8. 통계 메트릭 - -### 8.1 테스트 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-------------------|----------|---------| -| testsCompleted | 완료 테스트 수 | 테스트 제출 | -| questionsAnswered | 총 문제 수 | 테스트 제출 | -| correctAnswers | 정답 수 | 테스트 제출 | -| incorrectAnswers | 오답 수 | 테스트 제출 | -| successRate | 정답률 (%) | 조회 시 계산 | - -### 8.2 학습 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|-----------------|----------|---------| -| newWordsLearned | 신규 학습 단어 | 일일학습 완료 | -| wordsReviewed | 복습 단어 | 일일학습 완료 | -| wordsMastered | 마스터 단어 | 상태 변경 시 | - -### 8.3 게임 메트릭 - -| 메트릭 | 설명 | 업데이트 시점 | -|----------------|----------|---------| -| gamesPlayed | 참여 게임 수 | 게임 종료 | -| gamesWon | 1등 횟수 | 게임 종료 | -| correctGuesses | 정답 횟수 | 게임 종료 | -| totalGameScore | 누적 점수 | 게임 종료 | -| quickGuesses | 5초 내 정답 | 게임 종료 | -| perfectDraws | 전원 정답 유도 | 게임 종료 | - ---- - -## 9. 히스토리 조회 - -### 9.1 페이지네이션 - -```mermaid -flowchart LR - REQUEST["GET /stats/history
?limit=7&cursor=..."] - QUERY["Query
PK = USER#id#STATS
SK begins_with DAILY#
scanIndexForward = false"] - ENRICH["DailyStudy 조회
isCompleted 추가"] - RESPONSE["PaginatedResult
items, nextCursor, hasMore"] - REQUEST --> QUERY --> ENRICH --> RESPONSE -``` - -### 9.2 응답 구조 - -```json -{ - "history": [ - { - "period": "2026-01-16", - "testsCompleted": 2, - "questionsAnswered": 30, - "correctAnswers": 25, - "incorrectAnswers": 5, - "successRate": 83.33, - "newWordsLearned": 50, - "wordsReviewed": 5, - "isCompleted": true - } - ], - "nextCursor": "base64encoded...", - "hasMore": true -} -``` - ---- - -## 10. 배지 연동 - -### 10.1 자동 배지 체크 - -```mermaid -flowchart TB - STREAM[StatsStreamHandler] --> CHECK[배지 조건 체크] - CHECK --> PERFECT{만점 테스트?} - PERFECT -->|Yes| BADGE1[PERFECT_SCORE 배지] - CHECK --> STATS[전체 통계 조회] - STATS --> BADGESERVICE[BadgeService.checkAndAwardBadges] - BADGESERVICE --> AWARD[조건 충족 배지 부여] -``` - -### 10.2 배지 조건 예시 - -| 배지 | 조건 | 통계 필드 | -|--------------|----------|----------------------| -| STREAK_7 | 7일 연속 학습 | currentStreak >= 7 | -| ACCURACY_90 | 정확도 90% | successRate >= 90 | -| TEST_10 | 10회 테스트 | testsCompleted >= 10 | -| GAME_10_WINS | 10번 1등 | gamesWon >= 10 | - ---- - -## 11. 파일 구조 - -``` -domain/stats/ -├── handler/ -│ ├── UserStatsHandler.java (REST API) -│ ├── StatsStreamHandler.java (DynamoDB Streams) -│ └── ScheduledStatsHandler.java (EventBridge) -├── service/ -│ └── StatsService.java -├── repository/ -│ └── UserStatsRepository.java -├── model/ -│ └── UserStats.java -└── constants/ - └── StatsKey.java -``` - ---- - -## 12. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|--------------------------|------------------|-------------------| -| 원자적 업데이트 | UpdateExpression | Race condition 방지 | -| 비동기 처리 | DynamoDB Streams | API 응답 속도 향상 | -| Cursor 페이지네이션 | lastEvaluatedKey | 대용량 히스토리 처리 | -| Strongly Consistent Read | 히스토리 조회 | 데이터 정합성 | - ---- - -## 13. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **Event:** DynamoDB Streams, EventBridge -- **Pattern:** Atomic Counter, Write-through, Event-driven diff --git a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md b/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md deleted file mode 100644 index 7ee2c90e..00000000 --- a/docs/domain-reports/VOCABULARY-DOMAIN-REPORT.md +++ /dev/null @@ -1,504 +0,0 @@ -# Vocabulary Domain 세부 보고서 - -## 1. 개요 - -Vocabulary 도메인은 AWS Lambda와 DynamoDB를 기반으로 한 영어 단어 학습 시스템입니다. SM-2 Spaced Repetition 알고리즘과 CQRS 패턴을 적용하여 과학적이고 효율적인 단어 -암기를 지원합니다. - ---- - -## 2. 전체 아키텍처 - -```mermaid -flowchart TB - subgraph Client["클라이언트"] - APP[Mobile/Web App] - end - - subgraph Gateway["API Gateway"] - REST[REST API
HTTP] - end - - subgraph Lambda["Lambda Handlers"] - direction TB - WORD[WordHandler] - USERWORD[UserWordHandler] - DAILY[DailyStudyHandler] - TEST[TestHandler] - GROUP[WordGroupHandler] - VOICE[VoiceHandler] - STATS[StatisticsHandler
SQS Consumer] - end - - subgraph Services["서비스 레이어 (CQRS)"] - direction TB - CMD[Command Services
쓰기 작업] - QUERY[Query Services
읽기 작업] - end - - subgraph External["외부 서비스"] - POLLY[AWS Polly
TTS] - SNS[AWS SNS] - SQS[AWS SQS] - S3[(S3
음성 캐시)] - end - - subgraph Storage["데이터 저장소"] - DDB[(DynamoDB)] - end - - APP --> REST - REST --> WORD & USERWORD & DAILY & TEST & GROUP & VOICE - WORD & USERWORD & DAILY & TEST & GROUP --> CMD & QUERY - CMD & QUERY --> DDB - VOICE --> POLLY --> S3 - TEST --> SNS --> SQS --> STATS - STATS --> DDB -``` - ---- - -## 3. 일일 학습 시스템 - -### 3.1 일일 학습 흐름 - -```mermaid -flowchart TB - subgraph DailyStudyFlow["일일 학습 흐름"] - START[GET /vocab/daily] --> CHECK{기존 학습
존재?} - CHECK -->|Yes| RETURN[기존 학습 반환] - CHECK -->|No| CREATE[새 학습 생성] - CREATE --> REVIEW["복습 단어 5개 선정
(nextReviewAt <= today)"] - REVIEW --> NEW["신규 단어 50개 선정
(미학습 + 해당 레벨)"] - NEW --> SAVE[DailyStudy 저장] - SAVE --> RETURN - RETURN --> LEARN[학습 진행] - LEARN --> MARK["POST .../learned
단어별 학습 완료"] - MARK --> PROGRESS{50개 완료?} - PROGRESS -->|No| LEARN - PROGRESS -->|Yes| COMPLETE["isCompleted = true
배지 체크"] - end -``` - -### 3.2 Daily Study API - -| Method | Endpoint | 설명 | -|--------|-------------------------------------|-----------------| -| GET | /vocab/daily | 오늘의 학습 단어 조회/생성 | -| POST | /vocab/daily/words/{wordId}/learned | 단어 학습 완료 처리 | - -### 3.3 응답 예시 - -```json -{ - "userId": "user123", - "date": "2026-01-16", - "newWordIds": [ - "word1", - "word2", - ... - ], - "reviewWordIds": [ - "word51", - "word52", - ... - ], - "learnedWordIds": [], - "totalWords": 55, - "learnedCount": 0, - "isCompleted": false, - "progress": { - "percentage": 0, - "learned": 0, - "total": 55 - } -} -``` - ---- - -## 4. SM-2 Spaced Repetition 알고리즘 - -### 4.1 학습 상태 전이 - -```mermaid -stateDiagram-v2 - [*] --> NEW: 단어 추가 - NEW --> LEARNING: 첫 학습 - LEARNING --> LEARNING: 오답 - LEARNING --> REVIEWING: 2회 연속 정답 - REVIEWING --> LEARNING: 오답 - REVIEWING --> MASTERED: 5회 연속 정답 - MASTERED --> LEARNING: 오답 - MASTERED --> MASTERED: 정답 유지 -``` - -### 4.2 상태별 로직 - -| 상태 | 조건 | 정답 시 | 오답 시 | -|---------------|-------------|-----------------------------|---------------------------| -| **NEW** | 신규 단어 | LEARNING, rep=1, interval=1 | LEARNING, easeFactor-=0.2 | -| **LEARNING** | rep < 2 | rep++, interval 계산 | rep=0, interval=1 | -| **REVIEWING** | 2 ≤ rep < 5 | rep++, interval 증가 | rep=0, LEARNING | -| **MASTERED** | rep ≥ 5 | interval 증가, 유지 | rep=0, REVIEWING | - -### 4.3 복습 간격 계산 - -```mermaid -flowchart LR - REP1["rep = 1
interval = 1일"] - REP2["rep = 2
interval = 6일"] - REP3["rep >= 3
interval = interval × easeFactor"] - REP1 --> REP2 --> REP3 -``` - -**핵심 변수:** - -- `repetitions`: 연속 정답 횟수 (0~∞) -- `interval`: 복습 간격 (일 단위) -- `easeFactor`: 난이도 계수 (1.3~2.5, 기본 2.5) -- `nextReviewAt`: 다음 복습 예정일 - ---- - -## 5. 테스트 시스템 - -### 5.1 테스트 흐름 - -```mermaid -sequenceDiagram - participant Client - participant Handler as TestHandler - participant Service as TestCommandService - participant DB as DynamoDB - participant SNS as AWS SNS - Client ->> Handler: POST /vocab/tests/start - Handler ->> Service: startTest(userId, testType) - Service ->> DB: 오늘의 학습 단어 조회 - Service ->> Service: 4지선다 문제 생성 - Service -->> Client: 문제 목록 반환 - Note over Client: 사용자 답변 입력 - Client ->> Handler: POST /vocab/tests/submit - Handler ->> Service: submitTest(answers) - Service ->> DB: 결과 저장 - Service ->> SNS: 결과 발행 (비동기) - Service -->> Client: 테스트 결과 - Note over SNS, DB: 비동기 통계 처리 - SNS ->> DB: 통계 업데이트 -``` - -### 5.2 문제 생성 알고리즘 - -```mermaid -flowchart TB - START[문제 생성 시작] --> WORDS[일일 학습 단어 로드] - WORDS --> GROUP[레벨별 그룹화] - GROUP --> LOOP[각 단어마다] - LOOP --> CORRECT["정답 = 해당 단어의
한국어 뜻"] - CORRECT --> DIST["오답 3개 선정
(동일 레벨 단어)"] - DIST --> SHUFFLE[4개 보기 셔플] - SHUFFLE --> NEXT{다음 단어?} - NEXT -->|Yes| LOOP - NEXT -->|No| RETURN[문제 목록 반환] -``` - -### 5.3 Test API - -| Method | Endpoint | 설명 | -|--------|-------------------------------|------------| -| POST | /vocab/tests/start | 테스트 시작 | -| POST | /vocab/tests/submit | 테스트 제출 | -| GET | /vocab/tests/results | 테스트 결과 목록 | -| GET | /vocab/tests/results/{testId} | 테스트 상세 결과 | -| GET | /vocab/tests/tested-words | 최근 테스트된 단어 | - ---- - -## 6. 단어 관리 시스템 - -### 6.1 Word API - -| Method | Endpoint | 설명 | -|--------|------------------------|----------------------------| -| GET | /vocab/words | 단어 목록 (level, category 필터) | -| POST | /vocab/words | 단어 등록 | -| GET | /vocab/words/{wordId} | 단어 상세 | -| PUT | /vocab/words/{wordId} | 단어 수정 | -| DELETE | /vocab/words/{wordId} | 단어 삭제 | -| GET | /vocab/words/search | 키워드 검색 | -| POST | /vocab/words/batch | 배치 등록 (최대 100개) | -| POST | /vocab/words/batch/get | 배치 조회 | - -### 6.2 User Word API - -| Method | Endpoint | 설명 | -|--------|-----------------------------------|-------------| -| GET | /vocab/user-words | 사용자 단어 목록 | -| GET | /vocab/user-words/{wordId} | 사용자 단어 상세 | -| PUT | /vocab/user-words/{wordId} | 정답/오답 기록 | -| PATCH | /vocab/user-words/{wordId}/tag | 북마크, 난이도 설정 | -| PATCH | /vocab/user-words/{wordId}/status | 상태 수동 변경 | -| GET | /vocab/wrong-answers | 오답 단어 목록 | - -### 6.3 Word Group API - -| Method | Endpoint | 설명 | -|--------|----------------------------------------|--------| -| POST | /vocab/groups | 단어장 생성 | -| GET | /vocab/groups | 단어장 목록 | -| GET | /vocab/groups/{groupId} | 단어장 상세 | -| PUT | /vocab/groups/{groupId} | 단어장 수정 | -| DELETE | /vocab/groups/{groupId} | 단어장 삭제 | -| POST | /vocab/groups/{groupId}/words/{wordId} | 단어 추가 | -| DELETE | /vocab/groups/{groupId}/words/{wordId} | 단어 제거 | - ---- - -## 7. TTS 음성 합성 - -### 7.1 음성 생성 흐름 - -```mermaid -flowchart TB - REQUEST["POST /vocab/synthesize
{wordId, voice, type}"] - CHECK{S3 캐시
존재?} - REQUEST --> CHECK - CHECK -->|Yes| PRESIGN[Presigned URL 생성] - CHECK -->|No| POLLY[AWS Polly 호출] - POLLY --> SAVE[S3 저장] - SAVE --> PRESIGN - PRESIGN --> RESPONSE[URL 반환] -``` - -### 7.2 Voice API - -```json -// Request -{ - "wordId": "uuid", - "voice": "MALE", - // MALE | FEMALE - "type": "WORD" - // WORD | EXAMPLE -} - -// Response -{ - "url": "https://s3...presigned-url", - "expiresIn": 3600 -} -``` - ---- - -## 8. 데이터 모델 - -### 8.1 Word - -```java - -@DynamoDbBean -public class Word { - String wordId; // UUID - String english; // 영어 단어 - String korean; // 한국어 뜻 - String example; // 예문 - String level; // BEGINNER | INTERMEDIATE | ADVANCED - String category; // DAILY | BUSINESS | ACADEMIC | TRAVEL | TECHNOLOGY - String maleVoiceKey; // S3 음성 키 - String femaleVoiceKey; - String maleExampleVoiceKey; - String femaleExampleVoiceKey; -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|---------------------|----------| -| PK | WORD#{wordId} | 기본 조회 | -| SK | METADATA | - | -| GSI1PK | LEVEL#{level} | 레벨별 조회 | -| GSI2PK | CATEGORY#{category} | 카테고리별 조회 | - -### 8.2 UserWord - -```java - -@DynamoDbBean -public class UserWord { - String userId; - String wordId; - String status; // NEW | LEARNING | REVIEWING | MASTERED - - // SM-2 알고리즘 필드 - Integer interval; // 복습 간격 (일) - Double easeFactor; // 난이도 계수 (1.3~2.5) - Integer repetitions; // 연속 정답 횟수 - String nextReviewAt; // 다음 복습일 (YYYY-MM-DD) - - // 통계 - Integer correctCount; // 누적 정답 - Integer incorrectCount; // 누적 오답 - - // 사용자 설정 - Boolean bookmarked; // 북마크 - Boolean favorite; // 즐겨찾기 - String difficulty; // EASY | NORMAL | HARD -} -``` - -**DynamoDB Keys:** - -| Key | 패턴 | 용도 | -|--------|--------------------------|--------------| -| PK | USER#{userId} | 기본 조회 | -| SK | WORD#{wordId} | - | -| GSI1PK | USER#{userId}#REVIEW | 복습 예정 단어 | -| GSI1SK | DATE#{nextReviewAt} | - | -| GSI2PK | USER#{userId}#STATUS | 상태별 조회 | -| GSI2SK | STATUS#{status} | - | -| GSI3PK | USER#{userId}#BOOKMARKED | 북마크 (Sparse) | - -### 8.3 DailyStudy - -```java - -@DynamoDbBean -public class DailyStudy { - String userId; - String date; // YYYY-MM-DD - List newWordIds; // 신규 단어 50개 - List reviewWordIds; // 복습 단어 5개 - List learnedWordIds; // 학습 완료 단어 - Integer totalWords; // 총 단어 수 (55) - Integer learnedCount; // 학습 완료 수 - Boolean isCompleted; // 완료 여부 -} -``` - -### 8.4 TestResult - -```java - -@DynamoDbBean -public class TestResult { - String testId; - String userId; - String testType; // DAILY | WEEKLY | CUSTOM - Integer totalQuestions; - Integer correctAnswers; - Integer incorrectAnswers; - Double successRate; - List testedWordIds; - List incorrectWordIds; - String startedAt; - String completedAt; -} -``` - ---- - -## 9. 서비스 아키텍처 (CQRS) - -### 9.1 Command Services (쓰기) - -```mermaid -flowchart TB - subgraph Commands["Command Services"] - WC[WordCommandService
단어 생성/수정/삭제] - UC[UserWordCommandService
학습 상태 업데이트] - DC[DailyStudyCommandService
일일 학습 관리] - TC[TestCommandService
테스트 생성/제출] - GC[WordGroupCommandService
단어장 관리] - end -``` - -### 9.2 Query Services (읽기) - -```mermaid -flowchart TB - subgraph Queries["Query Services"] - WQ[WordQueryService
단어 조회/검색] - UQ[UserWordQueryService
학습 현황 조회] - DQ[DailyStudyQueryService
일일 학습 조회] - TQ[TestQueryService
테스트 결과 조회] - end -``` - ---- - -## 10. 성능 최적화 - -| 최적화 | 기법 | 효과 | -|---------------------|------------------------|-----------------| -| N+1 방지 | BatchGetItem (100개 단위) | DB 호출 90% 감소 | -| TTS 캐싱 | S3 + Presigned URL | Polly 호출 90% 절감 | -| 페이지네이션 | Cursor 기반 (Base64) | 대용량 데이터 처리 | -| Sparse Index | GSI3 (북마크 전용) | 인덱스 크기 최소화 | -| 비동기 통계 | SNS/SQS | API 응답 속도 향상 | -| Strongly Consistent | DailyStudy 조회 | 데이터 정합성 | - ---- - -## 11. 파일 구조 - -``` -domain/vocabulary/ -├── handler/ -│ ├── WordHandler.java -│ ├── UserWordHandler.java -│ ├── DailyStudyHandler.java -│ ├── TestHandler.java -│ ├── WordGroupHandler.java -│ ├── VoiceHandler.java -│ ├── StatsHandler.java -│ └── StatisticsHandler.java (SQS) -├── service/ -│ ├── WordCommandService.java -│ ├── WordQueryService.java -│ ├── UserWordCommandService.java -│ ├── UserWordQueryService.java -│ ├── TestCommandService.java -│ ├── TestQueryService.java -│ ├── DailyStudyCommandService.java -│ ├── DailyStudyQueryService.java -│ ├── WordGroupCommandService.java -│ ├── StatsService.java -│ └── StatisticsService.java -├── repository/ -│ ├── WordRepository.java -│ ├── UserWordRepository.java -│ ├── DailyStudyRepository.java -│ ├── TestResultRepository.java -│ └── WordGroupRepository.java -├── model/ -│ ├── Word.java -│ ├── UserWord.java -│ ├── DailyStudy.java -│ ├── TestResult.java -│ └── WordGroup.java -├── state/ -│ ├── WordState.java (interface) -│ ├── NewState.java -│ ├── LearningState.java -│ ├── ReviewingState.java -│ ├── MasteredState.java -│ ├── SpacedRepetitionContext.java -│ └── WordStateFactory.java -└── enums/ - ├── WordStatus.java - ├── WordCategory.java - └── TestType.java -``` - ---- - -## 12. 기술 스택 - -- **Runtime:** AWS Lambda (Java 21) -- **Database:** DynamoDB (Single Table Design) -- **TTS:** AWS Polly (남성/여성 음성) -- **Storage:** S3 (음성 캐시) -- **Messaging:** SNS/SQS (비동기 통계) -- **Pattern:** CQRS, State, Repository, Factory