Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions chatting/ChatFunction/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"));
}
}

// 인원 확인
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,27 @@
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<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {

private static final Logger logger = LoggerFactory.getLogger(ChatVoiceHandler.class);
private static final Gson gson = new GsonBuilder().setPrettyPrinting().create();

private final PollyService pollyService;
private final ChatMessageRepository messageRepository;

public ChatVoiceHandler() {
this.pollyService = new PollyService();
this.messageRepository = new ChatMessageRepository();
}

@Override
Expand All @@ -35,16 +41,61 @@ public APIGatewayProxyResponseEvent handleRequest(APIGatewayProxyRequestEvent re

String body = request.getBody();
Map<String, String> 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<ChatMessage> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatMessage> 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));
}

Expand All @@ -43,13 +43,18 @@ public ChatMessage save(ChatMessage message) {
}

public Optional<ChatMessage> 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();
}

/**
Expand Down Expand Up @@ -127,20 +132,32 @@ private Map<String, AttributeValue> decodeCursor(String cursor) {
}
}

public List<ChatMessage> 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<String, AttributeValue> exclusiveStartKey = decodeCursor(cursor);
if (exclusiveStartKey != null) {
requestBuilder.exclusiveStartKey(exclusiveStartKey);
}
}

Page<ChatMessage> page = table.index("GSI1")
.query(requestBuilder.build())
.iterator()
.next();

String nextCursor = encodeCursor(page.lastEvaluatedKey());
return new MessagePage(page.items(), nextCursor);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ChatRoom> 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));
}

Expand Down Expand Up @@ -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<String, AttributeValue> key = new HashMap<>();
key.put("PK", AttributeValue.builder().s("ROOM#" + roomId).build());
key.put("SK", AttributeValue.builder().s("METADATA").build());

Map<String, AttributeValue> 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);
}

/**
* 페이지네이션 결과 클래스
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ public ChatMessageRepository.MessagePage getMessagesByRoomWithPagination(String
return repository.findByRoomIdWithPagination(roomId, limit, cursor);
}

public List<ChatMessage> 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);
}
}
Loading