diff --git a/chatting/ChatFunction/build.gradle b/chatting/ChatFunction/build.gradle index a2a01f69..5ea65dba 100644 --- a/chatting/ChatFunction/build.gradle +++ b/chatting/ChatFunction/build.gradle @@ -31,6 +31,9 @@ dependencies { // JSON Processing implementation 'com.google.code.gson:gson:2.10.1' + // Password Hashing + implementation 'org.mindrot:jbcrypt:0.4' + // Logging implementation 'com.amazonaws:aws-lambda-java-log4j2:1.6.0' implementation 'org.apache.logging.log4j:log4j-api:2.22.1' diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java index d82fe950..fb700dae 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatMessageHandler.java @@ -77,6 +77,8 @@ private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent requ .sk("MSG#" + now + "#" + messageId) .gsi1pk("USER#" + userId) .gsi1sk("MSG#" + now) + .gsi2pk("MSG#" + messageId) // GSI2: messageId로 직접 조회용 + .gsi2sk("ROOM#" + roomId) .messageId(messageId) .roomId(roomId) .userId(userId) @@ -87,11 +89,8 @@ private APIGatewayProxyResponseEvent handlePost(APIGatewayProxyRequestEvent requ ChatMessage savedMessage = chatMessageService.saveMessage(message); - // 채팅방 lastMessageAt 업데이트 - chatRoomRepository.findById(roomId).ifPresent(room -> { - room.setLastMessageAt(now); - chatRoomRepository.save(room); - }); + // 채팅방 lastMessageAt 업데이트 (UpdateExpression으로 1회 호출) + chatRoomRepository.updateLastMessageAt(roomId, now); logger.info("Message sent: {} in room: {}", messageId, roomId); return createResponse(201, ApiResponse.success("Message sent", savedMessage)); diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java index 3f7c0460..d1414843 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatRoomHandler.java @@ -9,6 +9,7 @@ import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; import com.mzc.secondproject.serverless.chatting.model.ChatRoom; import com.mzc.secondproject.serverless.chatting.repository.ChatRoomRepository; +import org.mindrot.jbcrypt.BCrypt; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -108,7 +109,7 @@ private APIGatewayProxyResponseEvent createRoom(APIGatewayProxyRequestEvent requ .currentMembers(1) // 방장 포함 .maxMembers(maxMembers) .isPrivate(isPrivate) - .password(isPrivate ? password : null) + .password(isPrivate && password != null ? BCrypt.hashpw(password, BCrypt.gensalt()) : null) .createdBy(createdBy) .createdAt(now) .lastMessageAt(now) @@ -203,9 +204,11 @@ private APIGatewayProxyResponseEvent joinRoom(APIGatewayProxyRequestEvent reques ChatRoom room = optRoom.get(); - // 비밀번호 확인 - if (room.getIsPrivate() && !room.getPassword().equals(password)) { - return createResponse(403, ApiResponse.error("Invalid password")); + // 비밀번호 확인 (BCrypt 해시 검증) + if (room.getIsPrivate()) { + if (password == null || room.getPassword() == null || !BCrypt.checkpw(password, room.getPassword())) { + return createResponse(403, ApiResponse.error("Invalid password")); + } } // 인원 확인 diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java index 2d97c11b..90f711cd 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/handler/ChatVoiceHandler.java @@ -7,11 +7,15 @@ import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.mzc.secondproject.serverless.chatting.dto.ApiResponse; +import com.mzc.secondproject.serverless.chatting.model.ChatMessage; +import com.mzc.secondproject.serverless.chatting.repository.ChatMessageRepository; import com.mzc.secondproject.serverless.chatting.service.PollyService; +import com.mzc.secondproject.serverless.chatting.service.PollyService.VoiceSynthesisResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.Map; +import java.util.Optional; public class ChatVoiceHandler implements RequestHandler { @@ -19,9 +23,11 @@ public class ChatVoiceHandler implements RequestHandler requestBody = gson.fromJson(body, Map.class); - String text = requestBody.get("text"); + String messageId = requestBody.get("messageId"); + String roomId = requestBody.get("roomId"); String voice = requestBody.getOrDefault("voice", "FEMALE"); - if (text == null || text.isEmpty()) { - return createResponse(400, ApiResponse.error("text is required")); + if (messageId == null || messageId.isEmpty()) { + return createResponse(400, ApiResponse.error("messageId is required")); } + if (roomId == null || roomId.isEmpty()) { + return createResponse(400, ApiResponse.error("roomId is required")); + } + + // 메시지 조회 + Optional messageOpt = messageRepository.findByRoomIdAndMessageId(roomId, messageId); + if (messageOpt.isEmpty()) { + return createResponse(404, ApiResponse.error("Message not found")); + } + + ChatMessage message = messageOpt.get(); + boolean isMale = "MALE".equalsIgnoreCase(voice); + + // 캐시된 음성 키 확인 + String cachedKey = isMale ? message.getMaleVoiceKey() : message.getFemaleVoiceKey(); - String audioUrl = pollyService.synthesizeSpeech(text, voice); + String audioUrl; + boolean cached; + + if (cachedKey != null && !cachedKey.isEmpty()) { + // 캐시 히트: DynamoDB에 키가 있으면 S3에서 URL 생성 + logger.info("DB cache hit for message: {}, voice: {}", messageId, voice); + audioUrl = pollyService.getPresignedUrl(cachedKey); + cached = true; + } else { + // 캐시 미스: Polly 변환 → S3 저장 → DynamoDB 업데이트 + VoiceSynthesisResult result = pollyService.synthesizeSpeechForMessage( + messageId, message.getContent(), voice); + + // DynamoDB에 S3 키 저장 + if (isMale) { + message.setMaleVoiceKey(result.getS3Key()); + } else { + message.setFemaleVoiceKey(result.getS3Key()); + } + messageRepository.save(message); + + audioUrl = result.getAudioUrl(); + cached = result.isCached(); + } - return createResponse(200, ApiResponse.success("Speech synthesized", Map.of("audioUrl", audioUrl))); + return createResponse(200, ApiResponse.success( + cached ? "Speech retrieved from cache" : "Speech synthesized", + Map.of( + "audioUrl", audioUrl, + "cached", cached + ) + )); } catch (Exception e) { logger.error("Error synthesizing speech", e); diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java index b7a1a21a..d0a2726e 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/model/ChatMessage.java @@ -22,6 +22,8 @@ public class ChatMessage { private String sk; // MSG#{timestamp}#{messageId} private String gsi1pk; // USER#{userId} private String gsi1sk; // MSG#{timestamp} + private String gsi2pk; // MSG#{messageId} - messageId로 직접 조회용 + private String gsi2sk; // ROOM#{roomId} private String messageId; private String roomId; @@ -31,6 +33,10 @@ public class ChatMessage { private String createdAt; private Long ttl; + // 음성 캐시용 S3 키 (voice/{messageId}_{voice}.mp3) + private String maleVoiceKey; + private String femaleVoiceKey; + @DynamoDbPartitionKey @DynamoDbAttribute("PK") public String getPk() { @@ -54,4 +60,16 @@ public String getGsi1pk() { public String getGsi1sk() { return gsi1sk; } + + @DynamoDbSecondaryPartitionKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2PK") + public String getGsi2pk() { + return gsi2pk; + } + + @DynamoDbSecondarySortKey(indexNames = "GSI2") + @DynamoDbAttribute("GSI2SK") + public String getGsi2sk() { + return gsi2sk; + } } diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java index 5b679862..9bd2ba31 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatMessageRepository.java @@ -23,16 +23,16 @@ public class ChatMessageRepository { private static final Logger logger = LoggerFactory.getLogger(ChatMessageRepository.class); - private final DynamoDbEnhancedClient enhancedClient; + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("CHAT_TABLE_NAME"); + private final DynamoDbTable table; public ChatMessageRepository() { - DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - this.enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - - String tableName = System.getenv("CHAT_TABLE_NAME"); this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatMessage.class)); } @@ -43,13 +43,18 @@ public ChatMessage save(ChatMessage message) { } public Optional findByRoomIdAndMessageId(String roomId, String messageId) { - Key key = Key.builder() - .partitionValue("ROOM#" + roomId) - .sortValue("MSG#" + messageId) - .build(); + // GSI2를 사용하여 messageId로 직접 조회 (풀스캔 방지) + QueryConditional queryConditional = QueryConditional + .keyEqualTo(Key.builder() + .partitionValue("MSG#" + messageId) + .sortValue("ROOM#" + roomId) + .build()); - ChatMessage message = table.getItem(key); - return Optional.ofNullable(message); + return table.index("GSI2") + .query(queryConditional) + .stream() + .flatMap(page -> page.items().stream()) + .findFirst(); } /** @@ -127,20 +132,32 @@ private Map decodeCursor(String cursor) { } } - public List findByUserId(String userId) { + /** + * 사용자별 메시지 조회 - 페이지네이션 지원 (OOM 방지) + */ + public MessagePage findByUserIdWithPagination(String userId, int limit, String cursor) { QueryConditional queryConditional = QueryConditional .keyEqualTo(Key.builder().partitionValue("USER#" + userId).build()); - QueryEnhancedRequest request = QueryEnhancedRequest.builder() + QueryEnhancedRequest.Builder requestBuilder = QueryEnhancedRequest.builder() .queryConditional(queryConditional) .scanIndexForward(false) // 최신순 - .build(); + .limit(limit); - return table.index("GSI1") - .query(request) - .stream() - .flatMap(page -> page.items().stream()) - .toList(); + if (cursor != null && !cursor.isEmpty()) { + Map exclusiveStartKey = decodeCursor(cursor); + if (exclusiveStartKey != null) { + requestBuilder.exclusiveStartKey(exclusiveStartKey); + } + } + + Page page = table.index("GSI1") + .query(requestBuilder.build()) + .iterator() + .next(); + + String nextCursor = encodeCursor(page.lastEvaluatedKey()); + return new MessagePage(page.items(), nextCursor); } /** diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java index 110f6f69..4b5cef43 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/repository/ChatRoomRepository.java @@ -13,6 +13,7 @@ import software.amazon.awssdk.enhanced.dynamodb.model.QueryEnhancedRequest; import software.amazon.awssdk.services.dynamodb.DynamoDbClient; import software.amazon.awssdk.services.dynamodb.model.AttributeValue; +import software.amazon.awssdk.services.dynamodb.model.UpdateItemRequest; import java.util.Base64; import java.util.HashMap; @@ -24,16 +25,16 @@ public class ChatRoomRepository { private static final Logger logger = LoggerFactory.getLogger(ChatRoomRepository.class); - private final DynamoDbEnhancedClient enhancedClient; + // Singleton 패턴으로 Cold Start 최적화 + private static final DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); + private static final DynamoDbEnhancedClient enhancedClient = DynamoDbEnhancedClient.builder() + .dynamoDbClient(dynamoDbClient) + .build(); + private static final String tableName = System.getenv("CHAT_TABLE_NAME"); + private final DynamoDbTable table; public ChatRoomRepository() { - DynamoDbClient dynamoDbClient = DynamoDbClient.builder().build(); - this.enhancedClient = DynamoDbEnhancedClient.builder() - .dynamoDbClient(dynamoDbClient) - .build(); - - String tableName = System.getenv("CHAT_TABLE_NAME"); this.table = enhancedClient.table(tableName, TableSchema.fromBean(ChatRoom.class)); } @@ -166,6 +167,28 @@ public void delete(String roomId) { logger.info("Deleted room: {}", roomId); } + /** + * 채팅방 lastMessageAt 업데이트 (N+1 방지 - UpdateExpression 사용) + */ + public void updateLastMessageAt(String roomId, String timestamp) { + Map key = new HashMap<>(); + key.put("PK", AttributeValue.builder().s("ROOM#" + roomId).build()); + key.put("SK", AttributeValue.builder().s("METADATA").build()); + + Map expressionValues = new HashMap<>(); + expressionValues.put(":ts", AttributeValue.builder().s(timestamp).build()); + + UpdateItemRequest updateRequest = UpdateItemRequest.builder() + .tableName(tableName) + .key(key) + .updateExpression("SET lastMessageAt = :ts") + .expressionAttributeValues(expressionValues) + .build(); + + dynamoDbClient.updateItem(updateRequest); + logger.info("Updated lastMessageAt for room: {}", roomId); + } + /** * 페이지네이션 결과 클래스 */ diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java index cc983176..a7511066 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/ChatMessageService.java @@ -33,8 +33,8 @@ public ChatMessageRepository.MessagePage getMessagesByRoomWithPagination(String return repository.findByRoomIdWithPagination(roomId, limit, cursor); } - public List getMessagesByUser(String userId) { - logger.info("Getting messages for user: {}", userId); - return repository.findByUserId(userId); + public ChatMessageRepository.MessagePage getMessagesByUserWithPagination(String userId, int limit, String cursor) { + logger.info("Getting messages for user: {} with limit: {}", userId, limit); + return repository.findByUserIdWithPagination(userId, limit, cursor); } } diff --git a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java index b11e2134..57adbd98 100644 --- a/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java +++ b/chatting/ChatFunction/src/main/java/com/mzc/secondproject/serverless/chatting/service/PollyService.java @@ -14,10 +14,12 @@ import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; + import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.time.Duration; -import java.util.UUID; public class PollyService { @@ -35,13 +37,65 @@ public PollyService() { this.bucketName = System.getenv("CHAT_BUCKET_NAME"); } - public String synthesizeSpeech(String text) { - return synthesizeSpeech(text, "FEMALE"); + /** + * 메시지 ID 기반으로 음성 합성 (캐시 지원) + * S3에 파일이 있으면 바로 URL 반환, 없으면 Polly 변환 후 저장 + */ + public VoiceSynthesisResult synthesizeSpeechForMessage(String messageId, String text, String voice) { + String s3Key = generateS3Key(messageId, voice); + + // 캐시 확인: S3에 이미 존재하는지 체크 + if (existsInS3(s3Key)) { + logger.info("Cache hit: {}", s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, true); + } + + // 캐시 미스: Polly 변환 후 S3 저장 + logger.info("Cache miss: synthesizing and saving to {}", s3Key); + synthesizeAndSave(text, voice, s3Key); + String presignedUrl = getPresignedUrl(s3Key); + return new VoiceSynthesisResult(s3Key, presignedUrl, false); + } + + /** + * S3 키로 Pre-signed URL 생성 + */ + public String getPresignedUrl(String s3Key) { + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(1)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + + /** + * S3에 파일 존재 여부 확인 + */ + public boolean existsInS3(String s3Key) { + try { + s3Client.headObject(HeadObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .build()); + return true; + } catch (NoSuchKeyException e) { + return false; + } } - public String synthesizeSpeech(String text, String voice) { + /** + * Polly로 음성 변환 후 지정된 S3 키로 저장 + */ + private void synthesizeAndSave(String text, String voice, String s3Key) { VoiceId voiceId = resolveVoiceId(voice); - logger.info("Synthesizing speech with voice: {}", voiceId); try { SynthesizeSpeechRequest request = SynthesizeSpeechRequest.builder() @@ -53,7 +107,6 @@ public String synthesizeSpeech(String text, String voice) { InputStream audioStream = pollyClient.synthesizeSpeech(request); - // InputStream을 byte[]로 변환 ByteArrayOutputStream buffer = new ByteArrayOutputStream(); byte[] data = new byte[4096]; int bytesRead; @@ -62,41 +115,49 @@ public String synthesizeSpeech(String text, String voice) { } byte[] audioBytes = buffer.toByteArray(); - // S3에 저장 - String audioKey = "voice/" + UUID.randomUUID() + ".mp3"; - s3Client.putObject( PutObjectRequest.builder() .bucket(bucketName) - .key(audioKey) + .key(s3Key) .contentType("audio/mpeg") .build(), RequestBody.fromBytes(audioBytes) ); - // Pre-signed URL 생성 (1시간 유효) - GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) - .key(audioKey) - .build(); - - GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() - .signatureDuration(Duration.ofHours(1)) - .getObjectRequest(getObjectRequest) - .build(); - - PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); - String presignedUrl = presignedRequest.url().toString(); - - logger.info("Generated pre-signed URL for audio: {}", audioKey); - return presignedUrl; - + logger.info("Saved audio to S3: {}", s3Key); } catch (Exception e) { logger.error("Error synthesizing speech", e); throw new RuntimeException("Failed to synthesize speech", e); } } + /** + * 메시지 ID와 음성 타입으로 S3 키 생성 + */ + public String generateS3Key(String messageId, String voice) { + String voiceSuffix = "MALE".equalsIgnoreCase(voice) ? "male" : "female"; + return "voice/" + messageId + "_" + voiceSuffix + ".mp3"; + } + + /** + * 음성 합성 결과 + */ + public static class VoiceSynthesisResult { + private final String s3Key; + private final String audioUrl; + private final boolean cached; + + public VoiceSynthesisResult(String s3Key, String audioUrl, boolean cached) { + this.s3Key = s3Key; + this.audioUrl = audioUrl; + this.cached = cached; + } + + public String getS3Key() { return s3Key; } + public String getAudioUrl() { return audioUrl; } + public boolean isCached() { return cached; } + } + private VoiceId resolveVoiceId(String voice) { if ("MALE".equalsIgnoreCase(voice)) { return VoiceId.MATTHEW; // 미국 영어 남성 (Neural 지원) diff --git a/chatting/template.yaml b/chatting/template.yaml index 66baddd2..1f10da75 100644 --- a/chatting/template.yaml +++ b/chatting/template.yaml @@ -158,6 +158,8 @@ Resources: SnapStart: ApplyOn: PublishedVersions Policies: + - DynamoDBCrudPolicy: + TableName: !Ref ChatTable - S3CrudPolicy: BucketName: group2-englishstudy - Statement: @@ -206,6 +208,10 @@ Resources: AttributeType: S - AttributeName: GSI1SK AttributeType: S + - AttributeName: GSI2PK + AttributeType: S + - AttributeName: GSI2SK + AttributeType: S KeySchema: - AttributeName: PK KeyType: HASH @@ -220,6 +226,14 @@ Resources: KeyType: RANGE Projection: ProjectionType: ALL + - IndexName: GSI2 + KeySchema: + - AttributeName: GSI2PK + KeyType: HASH + - AttributeName: GSI2SK + KeyType: RANGE + Projection: + ProjectionType: ALL TimeToLiveSpecification: AttributeName: ttl Enabled: true