Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
db01eeb
CI/CD 구축 과정을 위한 main 업데이트 (#68)
34-43 Dec 24, 2024
0a3b5db
충돌 해결
gbognon25 Dec 28, 2024
89ac47a
remove: 중복된 ChatWebSocketHandler의 file 삭제 #80
gbognon25 Dec 28, 2024
08c3e84
feat: PlantAlarm entity 구현 #80
gbognon25 Dec 28, 2024
7cea908
feat: PlantAlarm DTOs 구현 #80
gbognon25 Dec 28, 2024
6103279
feat: PlantAlarm mapper 구현 #80
gbognon25 Dec 28, 2024
6284d62
feat: PlantAlarm repository 구현 #80
gbognon25 Dec 28, 2024
525a964
feat: PlantAlarm service 구현 #80
gbognon25 Dec 28, 2024
8657423
fix: 예외 처리 message 추가 #80
gbognon25 Dec 28, 2024
24b5c23
feat: PlantAlarm controller 구현 #80
gbognon25 Dec 28, 2024
e2c8a56
fix: N+1 문제 대비를 위한 PlantAlarm repository에 JPA fetch join 추가 #80
gbognon25 Dec 28, 2024
08266f1
feat: 식물 물주기 알림 기능을 위해 설정 #80
gbognon25 Dec 30, 2024
e60af0d
fix: 예외 처리 message 추가가 #80
gbognon25 Dec 30, 2024
107e9a8
fix: 식물 물주기 기능의 test code 구현 #80
gbognon25 Dec 30, 2024
ea0ed67
chore: graledlew env(변경 없음) #80
gbognon25 Dec 30, 2024
6fecb31
fix: Chat entity에 '@Builder.Default' annotation 추가 #80
gbognon25 Dec 30, 2024
844bbce
충돌 해결
gbognon25 Dec 30, 2024
013a190
remove: WebConfig에서 restTemplate Bean 분리 #80
gbognon25 Dec 31, 2024
b31d70e
fix: 사용되지 않은 import 제거 #80
gbognon25 Dec 31, 2024
aded884
refactor: 'TestUtils' class 이름이 'PlantAlarmTestUtils'로 변경 #80
gbognon25 Dec 31, 2024
53d0722
fix: 시간 지정과 간격 계산 문제를 해결하기 위해 field과 method 추가 #80
gbognon25 Dec 31, 2024
212085e
fix: PlantAlarmController에 CommonResDto 적용 #80
gbognon25 Dec 31, 2024
c357454
fix: PlantAlarm 기능 test code 수정 #80
gbognon25 Dec 31, 2024
9c01f1f
Merge branch 'dev' of https://github.com/sparta-Sounganization/Botani…
gbognon25 Dec 31, 2024
36f8dad
chore: 이메일 dependencies 추가 #90
gbognon25 Jan 1, 2025
761dd4f
feat: 이메일 인증의 설정 구현 #90
gbognon25 Jan 1, 2025
70dfdfb
feat: 이메일 인증의 Redis 설정 구현 #90
gbognon25 Jan 1, 2025
ecc8626
feat: 이메일 인증의 API endpoints과 logic 추가 #90
gbognon25 Jan 1, 2025
3522b80
fix: 이메일 인증과 관련된 예외 처리 message 추가 #90
gbognon25 Jan 1, 2025
c36cf2e
Merge branch 'dev' of https://github.com/sparta-Sounganization/Botani…
gbognon25 Jan 3, 2025
d937852
Merge branch 'dev' of https://github.com/sparta-Sounganization/Botani…
gbognon25 Jan 3, 2025
cf6dd25
Merge branch 'dev' of https://github.com/sparta-Sounganization/Botani…
gbognon25 Jan 3, 2025
efd2bf3
Merge branch 'dev' of https://github.com/sparta-Sounganization/Botani…
gbognon25 Jan 4, 2025
c9109fc
fix: 식물 알람 dto에 validation 추가 #90
gbognon25 Jan 4, 2025
6a640f9
Merge branch 'dev' of https://github.com/sparta-Sounganization/Botani…
gbognon25 Jan 4, 2025
fc368f4
Merge branch 'dev' of https://github.com/sparta-Sounganization/Botani…
gbognon25 Jan 5, 2025
3bf10ae
fix: README file 수정 #90
gbognon25 Jan 5, 2025
84d379d
design: README file 수정 #90
gbognon25 Jan 5, 2025
e71588f
fix: README file 수정(Docker 추가) #90
gbognon25 Jan 5, 2025
c683f8d
design: README file 수정 #90
gbognon25 Jan 5, 2025
12b6e2a
fix: README file 수정 (image 추가) #90
gbognon25 Jan 5, 2025
634913a
충돌 해결
gbognon25 Jan 6, 2025
49e166b
fix: 메시지 비동기적으로 저장하기 위해 설정 #90
gbognon25 Jan 15, 2025
b48dbed
fix: Redis와 WebSocket 설정 수정 #90
gbognon25 Jan 15, 2025
45b7d88
fix: DB와 Redis에 메시지의 중복 저장 및 발행 문제 해결 #90
gbognon25 Jan 15, 2025
3dac21d
fix: Test code 수정 #90
gbognon25 Jan 15, 2025
fcdedbe
remove: "ChatMessageEvent" file 제거 #90
gbognon25 Jan 15, 2025
090ade7
충돌 해결
gbognon25 Jan 15, 2025
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
Binary file added .DS_Store
Binary file not shown.
Binary file added Botanify_ERD.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
- [서비스 소개](#-서비스-소개)
- [기술 스택](#-기술-스택)
- [프로젝트 설치 및 실행법](#-프로젝트-설치-및-실행법)
- [프로젝트 구조](#-프로젝트-구조)
- [주요기능](#-주요기능)
- [Developer](#-developer)
- [프로젝트 구조 ](#-프로젝트-구조 )
- [주요 기능](#-주요기능)
- [Developer](#-Developer)


## 💁‍♀️ 서비스 소개
Expand Down
Binary file added src/.DS_Store
Binary file not shown.
Binary file added src/main/.DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.sounganization.botanify.common.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;

@Configuration
public class TransactionConfig {

@Bean
public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
template.setTimeout(30);
return template;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.sounganization.botanify.common.config.async;

import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
@Slf4j
public class AsyncConfig implements AsyncConfigurer {

@Bean(name = "chatThreadPoolTaskExecutor")
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("ChatAsync-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.initialize();
return executor;
}

@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> {
log.error("비동기 메서드 {}에서 예외 발생: {}", method.getName(), ex.getMessage());
};
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
package com.sounganization.botanify.common.config.redis;

import com.sounganization.botanify.common.config.websocket.ChatMessageListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

@Configuration
@ConditionalOnProperty(name = "spring.redis.enabled", havingValue = "true", matchIfMissing = true)
@Slf4j
public class RedisConfig {

@Value("${spring.redis.master.host}")
Expand Down Expand Up @@ -46,13 +50,36 @@ public RedisTemplate<String, String> redisTemplate() {
return redisTemplate;
}

@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
StringRedisTemplate template = new StringRedisTemplate(connectionFactory);
template.setEnableTransactionSupport(true);
return template;
}

@Bean
public ThreadPoolTaskExecutor redisThreadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("RedisExecutor-");
executor.initialize();
return executor;
}

@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
ChatMessageListener chatMessageListener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();

container.setConnectionFactory(connectionFactory);
container.addMessageListener(chatMessageListener, new PatternTopic("chat_room_*"));
container.setTaskExecutor(redisThreadPoolTaskExecutor());
container.setRecoveryInterval(5000L);
container.addMessageListener(chatMessageListener, new PatternTopic("chat_room_broadcast_*"));

log.info("최적화된 설정으로 Redis message listener container 구성");
return container;
}
}
Original file line number Diff line number Diff line change
@@ -1,29 +1,67 @@
package com.sounganization.botanify.common.config.websocket;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sounganization.botanify.common.config.websocket.service.WebSocketChatService;
import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto;
import com.sounganization.botanify.domain.chat.entity.ChatMessage;
import com.sounganization.botanify.domain.chat.service.ChatMessageService;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

@Component
import java.nio.charset.StandardCharsets;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
@Slf4j
public class ChatMessageListener implements MessageListener {
private final ObjectMapper objectMapper;
private final WebSocketChatService webSocketChatService;
private final ChatMessageService chatMessageService;
private final Set<String> processedMessages = ConcurrentHashMap.newKeySet();

@Override
public void onMessage(Message message, byte[] pattern) {
public void onMessage(@NonNull Message message, byte[] pattern) {
try {
String messageBody = new String(message.getBody());
ChatMessageReqDto chatMessage = objectMapper.readValue(messageBody, ChatMessageReqDto.class);
webSocketChatService.handleChatMessage(chatMessage);
String messageContent = new String(message.getBody(), StandardCharsets.UTF_8);
if (!processedMessages.add(messageContent)) {
log.debug("Listener를 통해 이미 처리된 메시지입니다. 건너뜁니다.");
return;
}

ChatMessageReqDto chatMessage = objectMapper.readValue(messageContent, ChatMessageReqDto.class);

if (chatMessage.source() != ChatMessageReqDto.MessageSource.WEBSOCKET) {
log.debug("WebSocket 메시지가 아니므로 건너뜁니다.");
return;
}

ChatMessage savedMessage = chatMessageService.findExistingMessage(
chatMessage.roomId(),
chatMessage.senderId(),
chatMessage.content()
);

if (savedMessage != null) {
chatMessageService.markMessageAsDelivered(savedMessage.getId());
log.debug("브로드캐스트 후 메시지가 전달됨으로 표시되었습니다 - messageId: {}", savedMessage.getId());
}

removeProcessedMessageAfterDelay(messageContent);

} catch (Exception e) {
log.error("채팅 메시지 처리 중 오류 발생: {}", e.getMessage());
log.error("Redis Pub/Sub에서 메시지 처리 실패", e);
}
}
}

private void removeProcessedMessageAfterDelay(String messageContent) {
CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(() -> {
processedMessages.remove(messageContent);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.sounganization.botanify.common.config.websocket.handler.ChatWebSocketHandler;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
Expand All @@ -10,8 +11,8 @@
@Configuration
@EnableWebSocket
@RequiredArgsConstructor
@Slf4j
public class WebSocketConfig implements WebSocketConfigurer {

private final ChatWebSocketHandler chatWebSocketHandler;

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,40 +17,41 @@
@Slf4j
@RequiredArgsConstructor
public class ChatWebSocketHandler extends TextWebSocketHandler {

private final ObjectMapper objectMapper;
private final WebSocketChatService webSocketChatService;
private final ConnectionFailureHandler connectionFailureHandler;

@Override
public void afterConnectionEstablished(WebSocketSession session) {
log.info("새로운 WebSocket 연결이 열렸습니다. 세션 ID: {}", session.getId());
}

@Override
protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) throws Exception {
String payload = message.getPayload();
log.info("WebSocket 메시지를 수신했습니다: {}", payload);

ChatMessageReqDto chatMessage = objectMapper.readValue(payload, ChatMessageReqDto.class);

try {
switch (chatMessage.type()) {
case ENTER:
log.info("사용자 {}이(가) 방 {}에 입장합니다.", chatMessage.senderId(), chatMessage.roomId());
webSocketChatService.handleEnterRoom(session, chatMessage.roomId(), chatMessage.senderId());
break;
case TALK:
if (!session.isOpen()) {
log.warn("메시지를 보내는 중 세션이 종료되었습니다.");
connectionFailureHandler.handleConnectionFailure(chatMessage);
return;
}
log.info("사용자 {}이(가) 방 {}에서 TALK 메시지를 처리 중입니다.",
chatMessage.senderId(), chatMessage.roomId());
webSocketChatService.handleChatMessage(chatMessage);
break;
case LEAVE:
log.info("사용자 {}이(가) 방 {}에서 나갑니다.", chatMessage.senderId(), chatMessage.roomId());
webSocketChatService.handleLeaveRoom(chatMessage.roomId(), chatMessage.senderId());
break;
}
} catch (Exception e) {
log.error("메시지 처리 중 오류 발생: ", e);
handleError(session, e);
// 메시지 처리 중 오류 발생시 연결 실패로 처리
if (chatMessage.type() == ChatMessageReqDto.MessageType.TALK) {
connectionFailureHandler.handleConnectionFailure(chatMessage);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ private void retryMessageDelivery(ChatMessage message) {
convertToDtoMessageType(message.getType()),
message.getChatRoom().getId(),
message.getSenderId(),
message.getContent()
message.getContent(),
ChatMessageReqDto.MessageSource.WEBSOCKET
);

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,68 @@
import com.sounganization.botanify.domain.chat.dto.req.ChatMessageReqDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.socket.WebSocketSession;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
@Slf4j
public class MessageBroadcastService {

private final ObjectMapper objectMapper;
private final StringRedisTemplate redisTemplate;
private final Map<Long, Map<Long, WebSocketSession>> chatRoomSessions = new ConcurrentHashMap<>();
private final Set<String> processedMessageIds = ConcurrentHashMap.newKeySet();

public void broadcastMessage(Long roomId, ChatMessageReqDto message) {
String messageId = generateMessageId(roomId, message);

if (!processedMessageIds.add(messageId)) {
log.debug("이미 브로드캐스트된 메시지입니다. 건너뜁니다: {}", messageId);
return;
}

try {
String messageJson = objectMapper.writeValueAsString(message);
WebSocketUtils.broadcastMessageToRoom(roomId, messageJson, chatRoomSessions);
log.debug("방 {}에 메시지를 브로드캐스트 중입니다.", roomId);
redisTemplate.convertAndSend(
"chat_room_broadcast_" + roomId,
objectMapper.writeValueAsString(message)
);

removeProcessedMessageIdAfterDelay(messageId);
} catch (JsonProcessingException e) {
log.error("메시지 변환 중 오류 발생: {}", e.getMessage());
log.error("Redis에 메시지 게시 실패", e);
processedMessageIds.remove(messageId);
}
}

private String generateMessageId(Long roomId, ChatMessageReqDto message) {
return String.format("%d:%d:%d:%s",
roomId,
message.senderId(),
message.content().hashCode(),
LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS)
);
}

private void removeProcessedMessageIdAfterDelay(String messageId) {
CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(() -> {
processedMessageIds.remove(messageId);
});
}

public void addSession(Long roomId, Long userId, WebSocketSession session) {
chatRoomSessions.computeIfAbsent(roomId, k -> new ConcurrentHashMap<>())
.put(userId, session);
log.debug("roomId: {}, userId: {}에 대한 세션을 추가했습니다.", roomId, userId);
}

public void removeSession(Long roomId, Long userId) {
Expand All @@ -40,10 +76,12 @@ public void removeSession(Long roomId, Long userId) {
WebSocketSession session = roomSessions.remove(userId);
if (session != null) {
WebSocketUtils.closeSession(session);
log.debug("roomId: {}, userId: {}에 대한 세션을 제거했습니다.", roomId, userId);
}
if (roomSessions.isEmpty()) {
chatRoomSessions.remove(roomId);
log.debug("빈 방을 제거했습니다: {}", roomId);
}
}
}
}
}
Loading
Loading