From f992eaa7ca4313fd61ff58a03712d5a4a364a97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=81?= Date: Tue, 20 Aug 2024 12:07:28 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EB=B0=8F=20CI/CD=20=EA=B5=AC=EC=B6=95?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feature: 채팅 기능 구현 (#2) * Feat: 웹 소켓 의존성 추가 * Feat: 웹 소켓에 대한 설정 클래스 작성 * Feat: Redis에 필요한 클래스 작성 및 DTO 추가 Redis의 Pub/Sub에 필요한 클래스와 설정을 작성하고 필요한 DTO를 추가함. * Test: Redis Sub/Pub 테스트 코드 작성 * Feat: 채팅에 필요한 Entity 클래스 작성 * Refactor: DTO 클래스 필드 수정 채팅 메시지 DTO 클래스의 필드를 수정하였고 그에 따른 다른 코드들도 수정함 * Refactor: 채팅 메시지 DTO 클래스명 수정 채팅 메시지 DTO 클래스명을 좀 더 명확하게 하기 위해 ChatReqeust -> ChatMessageRequest로 변경 또한, 변경에 따른 여러 코드 수정 * Refactor: Entity들의 Builder에 외부접근이 불가능하도록 PRIVATE 으로 설정 * Feat: 채팅에 필요한 여러 Repository 추가 * Test: Repository 테스트 코드 작성 * Feat: 채팅 서비스에 필요한 DTO 클래스 추가 * Feat: 채팅 서비스 로직 작성 * Test: 채팅 서비스 로직 테스트 코드 작성 * Test: 테스트 코드 수정 테스트가 일관성을 유지하도록 저장시간을 지정 * Feat: API 응답 객체 생성 * Feat: REST Docs 의존성 추가 및 설정 작성 * Feat: 채팅 컨트롤러 작성 및 예외처리 로직 추가 * Test: 채팅 컨트롤러단 테스트 코드 작성 및 문서화 코드 추가 * Docs: 채팅 API 문서 생성 --- * CI/CD 구축을 위한 세팅 (#4) * Refactor: 테스트환경과 서비스 환경 분리를 위해 Redis 설정 파일 수정 * Feat: 도커파일 작성 * Feat: 테스트 코드 환경 DB 세팅용 도커컴포즈 파일 작성 * Chore: 빌드 시 1개의 jar 파일만 생성하도록 그래들 수정 * Test: 테스트 코드용 yml파일 작성 * Test: 테스트 yml을 사용하도록 코드 추가 --- Dockerfile | 14 + build.gradle | 37 + docker-compose-chat-test-db.yml | 19 + src/docs/asciidoc/index.adoc | 28 + .../kaboochat/chat/config/RedisConfig.java | 129 +++ .../chat/config/WebSocketConfig.java | 59 ++ .../chat/controller/ChatController.java | 86 ++ .../chat/controller/ControllerAdvice.java | 33 + .../dto/request/ChatMessageRequest.java | 22 + .../domain/dto/request/ChatRoomRequest.java | 25 + .../chat/domain/dto/response/ApiResponse.java | 39 + .../dto/response/ChatMessageResponse.java | 35 + .../domain/dto/response/ChatRoomResponse.java | 55 + .../chat/domain/entity/ChatMember.java | 52 + .../chat/domain/entity/ChatMessage.java | 45 + .../chat/domain/entity/ChatRoom.java | 53 + .../kaboochat/chat/domain/entity/Member.java | 33 + .../chat/domain/redis/RedisPublisher.java | 34 + .../chat/domain/redis/RedisSubscriber.java | 47 + .../chat/repository/ChatMemberRepository.java | 23 + .../repository/ChatMessageRepository.java | 25 + .../chat/repository/ChatRoomRepository.java | 27 + .../chat/repository/MemberRepository.java | 18 + .../kaboochat/chat/service/ChatService.java | 113 +++ src/main/resources/static/docs/index.html | 947 ++++++++++++++++++ .../kaboochat/KabooChatApplicationTests.java | 2 + .../chat/controller/ChatControllerTest.java | 205 ++++ .../chat/domain/redis/RedisPublisherTest.java | 44 + .../domain/redis/RedisSubscriberTest.java | 60 ++ .../repository/ChatMemberRepositoryTest.java | 82 ++ .../repository/ChatMessageRepositoryTest.java | 101 ++ .../repository/ChatRoomRepositoryTest.java | 107 ++ .../chat/service/ChatServiceTest.java | 247 +++++ src/test/resources/application.yml | 23 + 34 files changed, 2869 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose-chat-test-db.yml create mode 100644 src/docs/asciidoc/index.adoc create mode 100644 src/main/java/kaboo/kaboochat/chat/config/RedisConfig.java create mode 100644 src/main/java/kaboo/kaboochat/chat/config/WebSocketConfig.java create mode 100644 src/main/java/kaboo/kaboochat/chat/controller/ChatController.java create mode 100644 src/main/java/kaboo/kaboochat/chat/controller/ControllerAdvice.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/dto/request/ChatMessageRequest.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/dto/request/ChatRoomRequest.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/dto/response/ApiResponse.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/dto/response/ChatMessageResponse.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/dto/response/ChatRoomResponse.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/entity/ChatMember.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/entity/ChatMessage.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/entity/ChatRoom.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/entity/Member.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/redis/RedisPublisher.java create mode 100644 src/main/java/kaboo/kaboochat/chat/domain/redis/RedisSubscriber.java create mode 100644 src/main/java/kaboo/kaboochat/chat/repository/ChatMemberRepository.java create mode 100644 src/main/java/kaboo/kaboochat/chat/repository/ChatMessageRepository.java create mode 100644 src/main/java/kaboo/kaboochat/chat/repository/ChatRoomRepository.java create mode 100644 src/main/java/kaboo/kaboochat/chat/repository/MemberRepository.java create mode 100644 src/main/java/kaboo/kaboochat/chat/service/ChatService.java create mode 100644 src/main/resources/static/docs/index.html create mode 100644 src/test/java/kaboo/kaboochat/chat/controller/ChatControllerTest.java create mode 100644 src/test/java/kaboo/kaboochat/chat/domain/redis/RedisPublisherTest.java create mode 100644 src/test/java/kaboo/kaboochat/chat/domain/redis/RedisSubscriberTest.java create mode 100644 src/test/java/kaboo/kaboochat/chat/repository/ChatMemberRepositoryTest.java create mode 100644 src/test/java/kaboo/kaboochat/chat/repository/ChatMessageRepositoryTest.java create mode 100644 src/test/java/kaboo/kaboochat/chat/repository/ChatRoomRepositoryTest.java create mode 100644 src/test/java/kaboo/kaboochat/chat/service/ChatServiceTest.java create mode 100644 src/test/resources/application.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..451fe6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Build +FROM eclipse-temurin:17-jdk AS build +LABEL authors="pjh5365" + +WORKDIR /src +COPY . /src +RUN ./gradlew build + +# Run +FROM eclipse-temurin:17-jre +EXPOSE 8080 +COPY --from=build /src/build/libs/*SNAPSHOT.jar kaboo-chat.jar + +ENTRYPOINT ["java", "-jar", "kaboo-chat.jar"] diff --git a/build.gradle b/build.gradle index 81ae3ae..d4f9a0b 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.2' id 'io.spring.dependency-management' version '1.1.6' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } group = 'kaboo' @@ -17,6 +18,7 @@ configurations { compileOnly { extendsFrom annotationProcessor } + asciidoctorExt // REST Docs 설정 } repositories { @@ -28,14 +30,49 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // REST Docs 의존성 + asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' +} + +tasks.named('jar') { + // jar 파일 1개만 생성 + enabled = false } tasks.named('test') { useJUnitPlatform() } + +// REST Docs 설정 시작 +tasks.named('bootBuildImage') { + builder = 'paketobuildpacks/builder-jammy-base:latest' +} + +asciidoctor { // adoc 파일 html 로 변환 + configurations 'asciidoctorExt' + dependsOn test // 테스트 실행 후 실행 +} + +task copyDocument(type: Copy) { + dependsOn asciidoctor // asciidoctor 실행 후 실행 + doFirst { // 실행되기 전 기존 파일 삭제 + delete("src/main/resources/static/docs") + } + // 위의 경로를 아래의 경로로 복사 + from file("build/docs/asciidoc/index.html") // index.html 파일만 복사 + into file("src/main/resources/static/docs") +} + +build { // 프로젝트 빌드 전 copyDocument 실행 + dependsOn copyDocument +} +// REST Docs 설정 종료 diff --git a/docker-compose-chat-test-db.yml b/docker-compose-chat-test-db.yml new file mode 100644 index 0000000..ade137d --- /dev/null +++ b/docker-compose-chat-test-db.yml @@ -0,0 +1,19 @@ +# 테스트 DB 환경 세팅 +services: + redis: + image: redis:alpine + container_name: redis + networks: + - testNet + + mongodb: + image: mongo:latest + container_name: mongodb + networks: + - testNet + +networks: + testNet: + name: testNet # 네트워크 이름 지정 + driver: bridge # 브릿지 모드 + attachable: true # 외부접속 허용 diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 0000000..81688f4 --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,28 @@ += 카부 커뮤니티 채팅 API +:doctype: book +:source-highlighter: highlightjs +:toc: left +:toclevels: 2 +:seclinks: + +== 채팅 API (ChatController) +=== 채팅방 생성 성공 API +==== 요청 +operation::chat-controller-test/create-room-test[snippets="http-request,request-fields"] +==== 응답 +operation::chat-controller-test/create-room-test[snippets="http-response,response-fields"] +=== 채팅방 생성 실패 API +==== 요청 +operation::chat-controller-test/create-room-fail-test[snippets="http-request,request-fields"] +==== 응답 +operation::chat-controller-test/create-room-fail-test[snippets="http-response,response-fields"] +=== 채팅방 리스트 조회 API +==== 요청 +operation::chat-controller-test/find-by-username-test[snippets="http-request,query-parameters"] +==== 응답 +operation::chat-controller-test/find-by-username-test[snippets="http-response,response-fields"] +=== 채팅방 채팅내역 조회 API +==== 요청 +operation::chat-controller-test/find-chat-message-test[snippets="http-request,query-parameters"] +==== 응답 +operation::chat-controller-test/find-chat-message-test[snippets="http-response,response-fields"] diff --git a/src/main/java/kaboo/kaboochat/chat/config/RedisConfig.java b/src/main/java/kaboo/kaboochat/chat/config/RedisConfig.java new file mode 100644 index 0000000..cd4276b --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/config/RedisConfig.java @@ -0,0 +1,129 @@ +package kaboo.kaboochat.chat.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import kaboo.kaboochat.chat.domain.redis.RedisSubscriber; + +/** + * Redis 설정 클래스 + *

+ * 이 클래스는 Redis와의 연결을 설정하고, Redis의 Pub/Sub 기능을 사용하여 메시지를 처리하는 데 필요한 빈(Bean)을 정의합니다. + *
+ * Lettuce 클라이언트를 사용하여 Redis와의 연결을 관리하고, RedisTemplate을 통해 Redis에 데이터를 직렬화/역직렬화하여 저장하거나 조회하며, + * Pub/Sub 메시지를 처리하는 리스너와 채널을 설정합니다. + *

+ * @author : parkjihyeok + * @since : 2024/08/17 + */ +@Configuration +public class RedisConfig { + + private final String redisHost; + private final int redisPort; + + public RedisConfig(@Value("${REDIS_HOST}") String redisHost, @Value("${REDIS_PORT}") int redisPort) { + this.redisHost = redisHost; + this.redisPort = redisPort; + } + + /** + * RedisConnectionFactory 빈 생성 + *

+ * Lettuce 클라이언트를 사용하여 Redis 서버와의 연결을 생성하고 관리합니다. + *
+ * 이 빈은 Redis 서버와의 연결을 생성하고, 다른 Redis 관련 빈들이 이 연결을 사용하여 Redis와 통신하게 됩니다. + *

+ * @return RedisConnectionFactory LettuceConnectionFactory 인스턴스 + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + /** + * RedisTemplate 빈 생성 + *

+ * 이 템플릿은 Redis에서 데이터를 저장하고 조회하는 데 사용됩니다. + * 키는 문자열(String)로 직렬화하고, 값은 JSON 형식으로 직렬화하여 Redis에 저장합니다. + *
+ * - 키 직렬화: StringRedisSerializer 사용
+ * - 값 직렬화: GenericJackson2JsonRedisSerializer 사용
+ * - 해시 키 직렬화: StringRedisSerializer 사용
+ * - 해시 값 직렬화: GenericJackson2JsonRedisSerializer 사용 + *

+ * @param connectionFactory RedisConnectionFactory 주입된 RedisConnectionFactory 인스턴스 + * @return RedisTemplate Redis에서 데이터를 처리하는 데 사용되는 템플릿 + */ + @Bean + public RedisTemplate redisChatTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); // Redis 연결 팩토리 설정 + template.setKeySerializer(new StringRedisSerializer()); // 키를 문자열로 직렬화 + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // 값을 JSON 형식으로 직렬화 + template.setHashKeySerializer(new StringRedisSerializer()); // 해시 구조의 키를 문자열로 직렬화 + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); // 해시 구조의 값을 JSON 형식으로 직렬화 + template.afterPropertiesSet(); // 설정된 직렬화기들을 적용하여 템플릿 초기화 + + return template; + } + + /** + * Pub/Sub 채널 정의 + *

+ * Redis의 Pub/Sub 모델에서 사용될 채널을 정의합니다. 이 채널은 "chatroom"이라는 이름을 가지며, + * 메시지가 이 채널로 발행되면 이를 구독하고 있는 모든 구독자에게 메시지가 전달됩니다. + *

+ * @return ChannelTopic "chatroom"이라는 이름을 가진 채널 + */ + @Bean + public ChannelTopic channelTopic() { + return new ChannelTopic("chatroom"); + } + + /** + * RedisMessageListenerContainer 빈 생성 + *

+ * Redis의 Pub/Sub 메시지를 수신하고 처리하기 위한 리스너 컨테이너를 설정합니다.
+ * 이 컨테이너는 Redis 서버에서 발행된 메시지를 실시간으로 수신하고, 지정된 리스너(MessageListenerAdapter)를 통해 메시지를 처리합니다. + *

+ * @param listenerAdapterChatMessage Redis에서 수신된 메시지를 처리하는 리스너 어댑터 + * @param channelTopic 메시지를 구독할 채널 + * @return RedisMessageListenerContainer 메시지 리스너 컨테이너 + */ + @Bean + public RedisMessageListenerContainer redisMessageListener( + MessageListenerAdapter listenerAdapterChatMessage, + ChannelTopic channelTopic + ) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(redisConnectionFactory()); // Redis 연결 팩토리 설정 + container.addMessageListener(listenerAdapterChatMessage, channelTopic); // 리스너와 채널을 컨테이너에 등록 + return container; + } + + /** + * MessageListenerAdapter 빈 생성 + *

+ * 이 어댑터는 Redis에서 수신된 메시지를 처리하는 역할을 합니다.
+ * 여기서는 RedisSubscriber 클래스의 "sendMessage" 메서드를 메시지 처리 메서드로 설정합니다.
+ * 즉, Redis에서 메시지가 수신되면, 이 메서드가 호출되어 메시지를 처리합니다. + *

+ * @param subscriber 실제로 메시지를 처리하는 클래스 (RedisSubscriber) + * @return MessageListenerAdapter 메시지 처리 어댑터 + */ + @Bean + public MessageListenerAdapter listenerAdapterChatMessage(RedisSubscriber subscriber) { + // RedisSubscriber 클래스에 정의된 메서드 이름을 2번째 인자로 넘겨야 한다. + return new MessageListenerAdapter(subscriber, "sendMessage"); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/config/WebSocketConfig.java b/src/main/java/kaboo/kaboochat/chat/config/WebSocketConfig.java new file mode 100644 index 0000000..9c47ec5 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/config/WebSocketConfig.java @@ -0,0 +1,59 @@ +package kaboo.kaboochat.chat.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +/** + * 웹 소켓 설정 클래스 + *

+ * 이 클래스는 웹 소켓과 관련된 설정을 정의합니다. + *

+ * @author : parkjihyeok + * @since : 2024/08/17 + */ +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + /** + * 메시지 브로커 구성 + *

+ * 이 메서드는 클라이언트에게 메시지를 전달하기 위한 메시지 브로커를 설정합니다.
+ * - enableSimpleBroker("/sub"): 간단한 메시지 브로커를 활성화합니다. → + * 클라이언트는 "/sub"로 시작하는 경로를 구독(subscribe)하여 메시지를 받을 수 있습니다.
+ * - setApplicationDestinationPrefixes("/pub"): 클라이언트가 서버로 메시지를 보낼 때 사용할 경로의 접두사를 정의합니다. → + * 클라이언트는 "/pub"로 시작하는 경로를 통해 메시지를 전송하며, 서버는 이 경로를 통해 메시지를 수신하고 처리합니다.
+ *
+ * 예를 들어, 클라이언트가 "/pub/message"로 메시지를 보내면, 서버는 해당 메시지를 처리하고, + * "/sub/response" 경로로 메시지를 브로드캐스트할 수 있습니다. + *

+ * @param registry MessageBrokerRegistry 메시지 브로커를 설정하는 데 사용되는 객체 + */ + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.enableSimpleBroker("/sub"); + registry.setApplicationDestinationPrefixes("/pub"); + } + + /** + * STOMP 엔드포인트 등록 + *

+ * 이 메서드는 클라이언트가 WebSocket 서버에 연결하기 위한 엔드포인트를 정의합니다.
+ * - addEndpoint("/chat/ws"): "/chat/ws" 경로로 클라이언트가 WebSocket 연결을 할 수 있는 엔드포인트를 생성합니다.
+ * - setAllowedOriginPatterns("*"): 모든 도메인에서의 요청을 허용합니다. (추후 특정 도메인만 허용하도록 수정)
+ * - withSockJS(): SockJS를 사용하여 WebSocket을 지원하지 않는 브라우저에서도 폴백 메커니즘을 통해 WebSocket 기능을 사용할 수 있게 합니다.
+ *
+ * 클라이언트는 이 엔드포인트를 통해 WebSocket 연결을 설정하고, 서버와 실시간으로 메시지를 주고받을 수 있습니다. + *

+ * @param registry StompEndpointRegistry 클라이언트의 WebSocket 연결을 관리하는 객체 + */ + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/chat/ws") // WebSocket 엔드포인트를 "/chat/ws"로 설정 + .setAllowedOriginPatterns("*") // 모든 도메인에서의 WebSocket 연결 허용 + .withSockJS(); // SockJS를 활성화하여 WebSocket을 지원하지 않는 브라우저에서도 사용 가능 + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/controller/ChatController.java b/src/main/java/kaboo/kaboochat/chat/controller/ChatController.java new file mode 100644 index 0000000..3a833c2 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/controller/ChatController.java @@ -0,0 +1,86 @@ +package kaboo.kaboochat.chat.controller; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; +import kaboo.kaboochat.chat.domain.dto.request.ChatRoomRequest; +import kaboo.kaboochat.chat.domain.dto.response.ApiResponse; +import kaboo.kaboochat.chat.domain.dto.response.ChatMessageResponse; +import kaboo.kaboochat.chat.domain.dto.response.ChatRoomResponse; +import kaboo.kaboochat.chat.service.ChatService; +import lombok.RequiredArgsConstructor; + +/** + * 채팅을 담당하는 Controller + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@RestController +@RequestMapping("/chat") +@RequiredArgsConstructor +public class ChatController { + + private final ChatService chatService; + + /** + * username으로 참여중인 채팅방 리스트를 불러옵니다. + * + * @param username 사용자 ID + * @return 참여중인 채팅방 리스트 + */ + @GetMapping("/rooms") + public ResponseEntity>> findChatRoomListByUsernameChat(String username) { + List responses = chatService.findByUsername(username); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(responses)); + } + + /** + * 채팅방을 생성합니다. + * + * @param chatRoomRequest 채팅방 생성에 필요한 DTO + * @return 처리 결과 + */ + @PostMapping("/rooms") + public ResponseEntity> createRoom(@RequestBody ChatRoomRequest chatRoomRequest) { + chatService.createRoom(chatRoomRequest); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success("채팅방 이름: " + chatRoomRequest.getChatRoomName())); + } + + /** + * 채팅방의 채팅 내역을 불러옵니다. + * + * @param roomUUID 채팅방 UUID + * @param pageable 페이지 정보 + * @return 채팅내역 + */ + @GetMapping("/messages") + public ResponseEntity>> findMessages(String roomUUID, Pageable pageable) { + List message = chatService.findMessage(roomUUID, pageable); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success(message)); + } + + /** + * 웹소켓의 메세지를 처리합니다. + * + * @param message 메시지 + */ + @MessageMapping("/messages") + public void sendMessage(ChatMessageRequest message) { + chatService.sendChatMessage(message); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/controller/ControllerAdvice.java b/src/main/java/kaboo/kaboochat/chat/controller/ControllerAdvice.java new file mode 100644 index 0000000..4972476 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/controller/ControllerAdvice.java @@ -0,0 +1,33 @@ +package kaboo.kaboochat.chat.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import kaboo.kaboochat.chat.domain.dto.response.ApiResponse; +import lombok.extern.slf4j.Slf4j; + +/** + * 컨트롤러단 예외처리 클래스 + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Slf4j +@RestControllerAdvice +public class ControllerAdvice { + + @ExceptionHandler(Exception.class) + public ResponseEntity> common(Exception e) { + log.error("[Kaboo-Chat]: 예상치 못한 예외가 발생하였습니다. 예외내용 = {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.failure("관리자에게 문의해주세요.")); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> illegalArgument(Exception e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.failure(e.getMessage())); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/dto/request/ChatMessageRequest.java b/src/main/java/kaboo/kaboochat/chat/domain/dto/request/ChatMessageRequest.java new file mode 100644 index 0000000..9b955c6 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/dto/request/ChatMessageRequest.java @@ -0,0 +1,22 @@ +package kaboo.kaboochat.chat.domain.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 채팅 메시지 DTO + * + * @author : parkjihyeok + * @since : 2024/08/17 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ChatMessageRequest { + + private String chatRoomUUID; // 채팅방 UUID + private String username; // 전송한 사용자의 ID + private String nickname; // 전송한 사용자의 닉네임 + private String message; // 메시지 내용 +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/dto/request/ChatRoomRequest.java b/src/main/java/kaboo/kaboochat/chat/domain/dto/request/ChatRoomRequest.java new file mode 100644 index 0000000..3b41b2e --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/dto/request/ChatRoomRequest.java @@ -0,0 +1,25 @@ +package kaboo.kaboochat.chat.domain.dto.request; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 채팅방 생성 DTO + *

+ * 채팅방을 만들기 위해선 채팅방의 참여자 정보와 채팅방의 이름이 필요합니다. + *

+ * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ChatRoomRequest { + + private List usernames; + private String chatRoomName; +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ApiResponse.java b/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ApiResponse.java new file mode 100644 index 0000000..db243ee --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ApiResponse.java @@ -0,0 +1,39 @@ +package kaboo.kaboochat.chat.domain.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * API 응답 DTO + *

+ * 이 클래스는 모든 API의 응답을 감싸기 위한 클래스입니다. + *

+ * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class ApiResponse { + + private boolean success; // 응답 성공 여부 + private String message; // 응답 메시지에 대한 정보 + private T data; // 응답객체를 공통으로 사용하기 위해 제네릭으로 받아 처리하기 + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, "요청이 성공적으로 처리되었습니다.", data); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(true, message, data); + } + + public static ApiResponse failure(T data) { + return new ApiResponse<>(false, "요청에 실패했습니다.", data); + } + + public static ApiResponse failure(String message, T data) { + return new ApiResponse<>(false, message, data); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ChatMessageResponse.java b/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ChatMessageResponse.java new file mode 100644 index 0000000..bf6ed6d --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ChatMessageResponse.java @@ -0,0 +1,35 @@ +package kaboo.kaboochat.chat.domain.dto.response; + +import java.time.LocalDateTime; + +import kaboo.kaboochat.chat.domain.entity.ChatMessage; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** + * 채팅 메시지를 담은 DTO + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class ChatMessageResponse { + + private String username; // 전송한 사용자의 ID + private String nickname; // 전송한 사용자의 닉네임 + private String message; // 메시지 내용 + private LocalDateTime sendAt; // 메시지 전송시간 + + public static ChatMessageResponse fromEntity(ChatMessage chatMessage) { + return ChatMessageResponse.builder() + .username(chatMessage.getUsername()) + .nickname(chatMessage.getNickname()) + .message(chatMessage.getMessage()) + .sendAt(chatMessage.getSendAt()) + .build(); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ChatRoomResponse.java b/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ChatRoomResponse.java new file mode 100644 index 0000000..6771b51 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/dto/response/ChatRoomResponse.java @@ -0,0 +1,55 @@ +package kaboo.kaboochat.chat.domain.dto.response; + +import java.util.List; + +import kaboo.kaboochat.chat.domain.entity.ChatRoom; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** + * 채팅방의 정보를 담은 DTO + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder(access = AccessLevel.PRIVATE) +public class ChatRoomResponse { + + private List usernames; + private String chatRoomUUID; + private String chatRoomName; + + /** + * 하나의 채팅방에 대한 정보를 담은 객체를 생성해 반환합니다. + * + * @param usernames 채팅방 참여자 정보 + * @param chatRoom 채팅방 Entity + * @return 채팅방 정보를 담은 DTO + */ + public static ChatRoomResponse fromEntity(List usernames, ChatRoom chatRoom) { + return ChatRoomResponse.builder() + .usernames(usernames) + .chatRoomUUID(chatRoom.getChatRoomUUID()) + .chatRoomName(chatRoom.getChatRoomName()) + .build(); + } + + /** + * 채팅방 Entity List를 전달받아 채팅방의 간단한 정보만 담고있는 리스트를 반환합니다. + * + * @param chatRooms 채팅방 Entity List + * @return 생성된 List + */ + public static List fromEntityList(List chatRooms) { + return chatRooms.stream() + .map(cr -> ChatRoomResponse.builder() + .chatRoomUUID(cr.getChatRoomUUID()) + .chatRoomName(cr.getChatRoomName()) + .build()) + .toList(); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatMember.java b/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatMember.java new file mode 100644 index 0000000..cc54f36 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatMember.java @@ -0,0 +1,52 @@ +package kaboo.kaboochat.chat.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 채팅방 참여자 Entity + *

+ * 이 클래스는 채팅방 참여자를 RDB에 저장하기 위한 Entity입니다. + *

+ * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // createChatMember 메서드로만 메시지 생성, Entity이기 때문에 PROTECTED로 설정 +@AllArgsConstructor(access = AccessLevel.PRIVATE) // createChatMember 메서드로만 메시지 생성 +@Builder(access = AccessLevel.PRIVATE) // createChatMember 메서드로만 메시지 생성 +public class ChatMember { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "chat_member_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id") + private ChatRoom chatRoom; + + public static ChatMember createChatMember(Member member, ChatRoom chatRoom) { + return ChatMember.builder() + .member(member) + .chatRoom(chatRoom) + .build(); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatMessage.java b/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatMessage.java new file mode 100644 index 0000000..a1eb8f4 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatMessage.java @@ -0,0 +1,45 @@ +package kaboo.kaboochat.chat.domain.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.mongodb.core.mapping.Document; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 채팅 메시지 MongoDB Entity + *

+ * 이 클래스는 MongoDB에 채팅 메시지를 기록하기 위한 Entity입니다. + *

+ * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) // createMessage 메서드로만 메시지 생성 +@AllArgsConstructor(access = AccessLevel.PRIVATE) // createMessage 메서드로만 메시지 생성 +@Builder(access = AccessLevel.PRIVATE) // createMessage 메서드로만 메시지 생성 +@Document(collection = "chatMessage") +public class ChatMessage { + + private String chatRoomUUID; // 채팅방 UUID + private String username; // 전송한 사용자의 ID + private String nickname; // 전송한 사용자의 닉네임 + private String message; // 메시지 내용 + private LocalDateTime sendAt; // 메시지 전송시간 + + public static ChatMessage createMessage(ChatMessageRequest dto) { + return ChatMessage.builder() + .chatRoomUUID(dto.getChatRoomUUID()) + .username(dto.getUsername()) + .nickname(dto.getNickname()) + .message(dto.getMessage()) + .sendAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatRoom.java b/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatRoom.java new file mode 100644 index 0000000..646f226 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/entity/ChatRoom.java @@ -0,0 +1,53 @@ +package kaboo.kaboochat.chat.domain.entity; + +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 채팅방 Entity + *

+ * 이 클래스는 채팅방을 RDB에 저장하기 위한 Entity입니다. + *

+ * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // createRoom 메서드로만 메시지 생성, Entity이기 때문에 PROTECTED로 설정 +@AllArgsConstructor(access = AccessLevel.PRIVATE) // createRoom 메서드로만 메시지 생성 +@Builder(access = AccessLevel.PRIVATE) // createRoom 메서드로만 메시지 생성 +public class ChatRoom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "chat_room_id") + private Long id; // 기본키 + @Column(name = "chat_room_name", nullable = false, updatable = false) + private String chatRoomName; // 채팅방 이름 + @Column(name = "chat_room_uuid", nullable = false, updatable = false) + private String chatRoomUUID; // 채팅방 UUID + + /** + * 채팅방을 생성합니다. + * + * @param chatRoomName 채팅방이름 + * @return 생성한 채팅방 객체 + */ + public static ChatRoom createRoom(String chatRoomName) { + return ChatRoom.builder() + .chatRoomName(chatRoomName) + .chatRoomUUID(UUID.randomUUID().toString()) + .build(); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/entity/Member.java b/src/main/java/kaboo/kaboochat/chat/domain/entity/Member.java new file mode 100644 index 0000000..6aa2214 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/entity/Member.java @@ -0,0 +1,33 @@ +package kaboo.kaboochat.chat.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 회원 Entity + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) // 회원가입 로직은 다른 서비스에서 담당하므로 생성자 접근을 막음, Entity이기 때문에 PROTECTED로 설정 +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; // 기본키 + @Column(nullable = false, updatable = false) + private String username; // 회원 ID + @Column(nullable = false) + private String nickname; // 사용할 닉네임 + @Column(nullable = false) + private String password; // 비밀번호 +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/redis/RedisPublisher.java b/src/main/java/kaboo/kaboochat/chat/domain/redis/RedisPublisher.java new file mode 100644 index 0000000..523a899 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/redis/RedisPublisher.java @@ -0,0 +1,34 @@ +package kaboo.kaboochat.chat.domain.redis; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.stereotype.Service; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; +import lombok.RequiredArgsConstructor; + +/** + * 레디스의 구독을 담당하는 클래스 + *

+ * 이 클래스는 특정 Redis 채널로 메시지를 발행하는 역할을 합니다. + *

+ * + * @author : parkjihyeok + * @since : 2024/08/17 + */ +@Service +@RequiredArgsConstructor +public class RedisPublisher { + + private final ChannelTopic channelTopic; + private final RedisTemplate redisTemplate; + + /** + * 지정된 Redis 채널로 메시지를 발행합니다. + * + * @param request 발행할 메시지 데이터를 담은 DTO + */ + public void publish(ChatMessageRequest request) { + redisTemplate.convertAndSend(channelTopic.getTopic(), request); + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/domain/redis/RedisSubscriber.java b/src/main/java/kaboo/kaboochat/chat/domain/redis/RedisSubscriber.java new file mode 100644 index 0000000..3ff497d --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/domain/redis/RedisSubscriber.java @@ -0,0 +1,47 @@ +package kaboo.kaboochat.chat.domain.redis; + +import org.springframework.messaging.simp.SimpMessageSendingOperations; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Redis에서 구독된 메시지를 처리하고 웹 소켓 클라이언트에게 전송하는 클래스 + *

+ * 이 클래스는 Redis에서 발행된 메시지를 대기하고 DTO로 변환하여, 해당 채팅방을 구독하고 있는 웹 소켓 클라이언트에게 전송합니다. + *

+ * + * @author : parkjihyeok + * @since : 2024/08/17 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisSubscriber { + + private final ObjectMapper objectMapper; + private final SimpMessageSendingOperations messagingTemplate; + + /** + * Redis 채널에서 수신된 메시지를 처리합니다. + *

+ * 이 메서드는 Redis에서 발행된 메시지를 수신했을 때 호출됩니다.
+ * 메시지를 JSON 문자열에서 DTO 객체로 변환한 하여 정보를 추출하고, 해당 채팅방을 구독 중인 WebSocket 클라이언트에게 전송합니다. + *

+ * + * @param chatMessageRequest Redis에서 수신된 JSON 문자열 형식의 메시지. + */ + public void sendMessage(String chatMessageRequest) { + try { + ChatMessageRequest chat = objectMapper.readValue(chatMessageRequest, ChatMessageRequest.class); + // 채팅방을 구독 중인 WebSocket 클라이언트에게 메시지 전송 + messagingTemplate.convertAndSend("/sub/chat/room/" + chat.getChatRoomUUID(), chat); + } catch (Exception e) { + log.error("[Kaboo-Chat]: 예상치 못한 예외가 발생하였습니다. 내용 = {}", e.getMessage()); + } + } +} diff --git a/src/main/java/kaboo/kaboochat/chat/repository/ChatMemberRepository.java b/src/main/java/kaboo/kaboochat/chat/repository/ChatMemberRepository.java new file mode 100644 index 0000000..c26b6df --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/repository/ChatMemberRepository.java @@ -0,0 +1,23 @@ +package kaboo.kaboochat.chat.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import kaboo.kaboochat.chat.domain.entity.ChatMember; + +/** + * 채팅방 참여자를 담당한다. + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +public interface ChatMemberRepository extends JpaRepository { + + /** + * 채팅방 UUID로 채팅방 참여자들 찾기 + */ + @Query("select cm from ChatMember cm join fetch cm.member m where cm.chatRoom.chatRoomUUID = :roomUUID") + List findByChatRoomUUID(String roomUUID); +} diff --git a/src/main/java/kaboo/kaboochat/chat/repository/ChatMessageRepository.java b/src/main/java/kaboo/kaboochat/chat/repository/ChatMessageRepository.java new file mode 100644 index 0000000..d04033d --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/repository/ChatMessageRepository.java @@ -0,0 +1,25 @@ +package kaboo.kaboochat.chat.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.repository.MongoRepository; + +import kaboo.kaboochat.chat.domain.entity.ChatMessage; + +/** + * 채팅 메시지를 담당한다. + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +public interface ChatMessageRepository extends MongoRepository { + + /** + * 채팅방 메시지를 시간으로 정렬하여 페이지로 가져옵니다. + * + * @param roomUUID 채팅방 UUID + * @param pageable 페이지 정보 + * @return 해당 페이지 + */ + Page findByChatRoomUUIDOrderBySendAtDesc(String roomUUID, Pageable pageable); +} diff --git a/src/main/java/kaboo/kaboochat/chat/repository/ChatRoomRepository.java b/src/main/java/kaboo/kaboochat/chat/repository/ChatRoomRepository.java new file mode 100644 index 0000000..fd2678d --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/repository/ChatRoomRepository.java @@ -0,0 +1,27 @@ +package kaboo.kaboochat.chat.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import kaboo.kaboochat.chat.domain.entity.ChatRoom; + +/** + * 채팅방을 담당한다. + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +public interface ChatRoomRepository extends JpaRepository { + + @Query("select cr from ChatRoom cr where cr.chatRoomUUID = :roomUUID") + Optional findByRoomUUID(String roomUUID); + + /** + * username으로 참여자가 참여중인 채팅방 리스트 찾기 + */ + @Query("select cm.chatRoom from ChatMember cm where cm.member.username = :username") + List findByUsername(String username); +} diff --git a/src/main/java/kaboo/kaboochat/chat/repository/MemberRepository.java b/src/main/java/kaboo/kaboochat/chat/repository/MemberRepository.java new file mode 100644 index 0000000..f3183cb --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/repository/MemberRepository.java @@ -0,0 +1,18 @@ +package kaboo.kaboochat.chat.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import kaboo.kaboochat.chat.domain.entity.Member; + +/** + * 회원을 담당한다. + * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +public interface MemberRepository extends JpaRepository { + + Optional findByUsername(String username); +} diff --git a/src/main/java/kaboo/kaboochat/chat/service/ChatService.java b/src/main/java/kaboo/kaboochat/chat/service/ChatService.java new file mode 100644 index 0000000..02c67a0 --- /dev/null +++ b/src/main/java/kaboo/kaboochat/chat/service/ChatService.java @@ -0,0 +1,113 @@ +package kaboo.kaboochat.chat.service; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; +import kaboo.kaboochat.chat.domain.dto.request.ChatRoomRequest; +import kaboo.kaboochat.chat.domain.dto.response.ChatMessageResponse; +import kaboo.kaboochat.chat.domain.dto.response.ChatRoomResponse; +import kaboo.kaboochat.chat.domain.entity.ChatMember; +import kaboo.kaboochat.chat.domain.entity.ChatMessage; +import kaboo.kaboochat.chat.domain.entity.ChatRoom; +import kaboo.kaboochat.chat.domain.redis.RedisPublisher; +import kaboo.kaboochat.chat.repository.ChatMemberRepository; +import kaboo.kaboochat.chat.repository.ChatMessageRepository; +import kaboo.kaboochat.chat.repository.ChatRoomRepository; +import kaboo.kaboochat.chat.repository.MemberRepository; +import lombok.RequiredArgsConstructor; + +/** + * 채팅을 담당하는 Service + *

+ * 이 클래스는 채팅방을 생성, 조회하며 채팅방에 채팅을 발송합니다. + *

+ * + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@Service +@RequiredArgsConstructor +public class ChatService { + + private final RedisPublisher redisPublisher; + private final MemberRepository memberRepository; + private final ChatRoomRepository chatRoomRepository; + private final ChatMemberRepository chatMemberRepository; + private final ChatMessageRepository chatMessageRepository; + + /** + * 채팅방 UUID값으로 채팅방의 정보를 찾아 반환합니다. + * + * @param chatRoomUUID 채팅방 UUID + * @return 채팅방의 정보를 담은 DTO + */ + public ChatRoomResponse findByChatUUID(String chatRoomUUID) { + ChatRoom chatRoom = chatRoomRepository.findByRoomUUID(chatRoomUUID) + .orElseThrow(() -> new IllegalArgumentException("해당 UUID에 해당하는 채팅방을 찾을 수 없습니다.")); + + List usernames = chatMemberRepository.findByChatRoomUUID(chatRoomUUID) + .stream() + .map(cm -> cm.getMember().getUsername()) + .toList(); + + return ChatRoomResponse.fromEntity(usernames, chatRoom); + } + + /** + * 사용자가 참여중인 채팅방의 정보를 찾아 List로 반환합니다. + * + * @param username 사용자 ID + * @return 채팅방 List + */ + public List findByUsername(String username) { + return ChatRoomResponse.fromEntityList(chatRoomRepository.findByUsername(username)); + } + + /** + * 새로운 채팅방을 생성합니다. + *

+ * 새로운 채팅방을 생성하고 입력받은 사용자들을 DB에 검색하여 모두 채팅방 참여자로 추가합니다.
+ * 단, 입력받은 사용자중 1명이라도 검색에 실패하면 채팅방 생성에 실패합니다. + *

+ * + * @param request 채팅방 생성 DTO + */ + @Transactional + public void createRoom(ChatRoomRequest request) { + ChatRoom chatRoom = ChatRoom.createRoom(request.getChatRoomName()); + chatRoomRepository.save(chatRoom); + request.getUsernames() + .stream() + .map(u -> memberRepository.findByUsername(u) + .orElseThrow(() -> new IllegalArgumentException("해당하는 회원정보를 찾을 수 없습니다."))) + .forEach(m -> chatMemberRepository.save(ChatMember.createChatMember(m, chatRoom))); + } + + /** + * 채팅방의 채팅 내역을 불러옵니다. + * + * @param roomUUID 채팅방 UUID + * @param pageable 페이지 정보 + * @return 해당하는 채팅 내역 + */ + public List findMessage(String roomUUID, Pageable pageable) { + return chatMessageRepository.findByChatRoomUUIDOrderBySendAtDesc(roomUUID, pageable) + .map(ChatMessageResponse::fromEntity) + .toList(); + } + + /** + * 채팅방에 채팅을 발송합니다. + * + * @param request 채팅 DTO + */ + @Transactional + public void sendChatMessage(ChatMessageRequest request) { + chatMessageRepository.save(ChatMessage.createMessage(request)); + redisPublisher.publish(request); + } +} diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html new file mode 100644 index 0000000..d3e9b0f --- /dev/null +++ b/src/main/resources/static/docs/index.html @@ -0,0 +1,947 @@ + + + + + + + +카부 커뮤니티 채팅 API + + + + + + +
+
+

채팅 API (ChatController)

+
+
+

채팅방 생성 성공 API

+
+

요청

+
+
HTTP request
+
+
+
POST /chat/rooms HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 81
+Host: localhost:8080
+
+{
+  "usernames" : [ "user1", "user2", "user3" ],
+  "chatRoomName" : "채팅방"
+}
+
+
+
+
+
Request fields
+ +++++ + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

usernames

Array

채팅방 참여자의 ID 리스트

chatRoomName

String

생성할 채팅방 이름

+
+
+
+

응답

+
+
HTTP response
+
+
+
HTTP/1.1 201 Created
+Content-Type: application/json
+Content-Length: 130
+
+{
+  "success" : true,
+  "message" : "요청이 성공적으로 처리되었습니다.",
+  "data" : "채팅방 이름: 채팅방"
+}
+
+
+
+
+
Response fields
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

success

Boolean

성공여부

message

String

응답 메시지

data

String

채팅방 이름

+
+
+
+
+

채팅방 생성 실패 API

+
+

요청

+
+
HTTP request
+
+
+
POST /chat/rooms HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 81
+Host: localhost:8080
+
+{
+  "usernames" : [ "user1", "user2", "user3" ],
+  "chatRoomName" : "채팅방"
+}
+
+
+
+
+
Request fields
+ +++++ + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

usernames

Array

채팅방 참여자의 ID 리스트

chatRoomName

String

생성할 채팅방 이름

+
+
+
+

응답

+
+
HTTP response
+
+
+
HTTP/1.1 400 Bad Request
+Content-Type: application/json
+Content-Length: 138
+
+{
+  "success" : false,
+  "message" : "요청에 실패했습니다.",
+  "data" : "해당하는 회원정보를 찾을 수 없습니다."
+}
+
+
+
+
+
Response fields
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

success

Boolean

성공여부

message

String

응답 메시지

data

String

실패 내용

+
+
+
+
+

채팅방 리스트 조회 API

+
+

요청

+
+
HTTP request
+
+
+
GET /chat/rooms?username=pjh5365 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
Query parameters
+ ++++ + + + + + + + + + + + + +
ParameterDescription

username

검색할 username

+
+
+
+

응답

+
+
HTTP response
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 481
+
+{
+  "success" : true,
+  "message" : "요청이 성공적으로 처리되었습니다.",
+  "data" : [ {
+    "usernames" : null,
+    "chatRoomUUID" : "753f04ee-28e6-44c9-be80-1a7620602164",
+    "chatRoomName" : "채팅방1"
+  }, {
+    "usernames" : null,
+    "chatRoomUUID" : "ac7a6331-dbf9-4416-803e-2740cfcb8e35",
+    "chatRoomName" : "채팅방2"
+  }, {
+    "usernames" : null,
+    "chatRoomUUID" : "e8ef047b-5f7e-468b-8a80-20df87c3590a",
+    "chatRoomName" : "채팅방3"
+  } ]
+}
+
+
+
+
+
Response fields
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

success

Boolean

성공여부

message

String

응답 메시지

data

Array

채팅방 리스트

data[].usernames

Null

참여자 이름 (null)

data[].chatRoomUUID

String

채팅방 UUID

data[].chatRoomName

String

채팅방 이름

+
+
+
+
+

채팅방 채팅내역 조회 API

+
+

요청

+
+
HTTP request
+
+
+
GET /chat/messages?roomUUID=A1A1-B2B2&page=0&size=10 HTTP/1.1
+Host: localhost:8080
+
+
+
+
+
Query parameters
+ ++++ + + + + + + + + + + + + + + + + + + + + +
ParameterDescription

roomUUID

채팅방 UUID

page

현재 페이지 (0부터 시작)

size

한 페이지 크기

+
+
+
+

응답

+
+
HTTP response
+
+
+
HTTP/1.1 200 OK
+Content-Type: application/json
+Content-Length: 795
+
+{
+  "success" : true,
+  "message" : "요청이 성공적으로 처리되었습니다.",
+  "data" : [ {
+    "username" : "pjh5365",
+    "nickname" : "justin",
+    "message" : "거기는 어때?",
+    "sendAt" : "2024-08-18T21:01:13.382868"
+  }, {
+    "username" : "pjh5365",
+    "nickname" : "justin",
+    "message" : "엄청 더워...",
+    "sendAt" : "2024-08-18T21:01:13.382866"
+  }, {
+    "username" : "pibber",
+    "nickname" : "park",
+    "message" : "거기 날씨 어때?",
+    "sendAt" : "2024-08-18T21:01:13.382864"
+  }, {
+    "username" : "pibber",
+    "nickname" : "park",
+    "message" : "반가워",
+    "sendAt" : "2024-08-18T21:01:13.38286"
+  }, {
+    "username" : "pjh5365",
+    "nickname" : "justin",
+    "message" : "안녕",
+    "sendAt" : "2024-08-18T21:01:13.382837"
+  } ]
+}
+
+
+
+
+
Response fields
+ +++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PathTypeDescription

success

Boolean

성공여부

message

String

응답 메시지

data[]

Array

채팅내역 리스트

data[].username

String

참여자 ID

data[].nickname

String

참여자 닉네임

data[].message

String

채팅 내용

data[].sendAt

String

전송 시간

+
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/test/java/kaboo/kaboochat/KabooChatApplicationTests.java b/src/test/java/kaboo/kaboochat/KabooChatApplicationTests.java index 00de6aa..a03fd6d 100644 --- a/src/test/java/kaboo/kaboochat/KabooChatApplicationTests.java +++ b/src/test/java/kaboo/kaboochat/KabooChatApplicationTests.java @@ -2,7 +2,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +@ActiveProfiles("test") @SpringBootTest class KabooChatApplicationTests { diff --git a/src/test/java/kaboo/kaboochat/chat/controller/ChatControllerTest.java b/src/test/java/kaboo/kaboochat/chat/controller/ChatControllerTest.java new file mode 100644 index 0000000..397ca21 --- /dev/null +++ b/src/test/java/kaboo/kaboochat/chat/controller/ChatControllerTest.java @@ -0,0 +1,205 @@ +package kaboo.kaboochat.chat.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; +import kaboo.kaboochat.chat.domain.dto.request.ChatRoomRequest; +import kaboo.kaboochat.chat.domain.dto.response.ChatMessageResponse; +import kaboo.kaboochat.chat.domain.dto.response.ChatRoomResponse; +import kaboo.kaboochat.chat.domain.entity.ChatMessage; +import kaboo.kaboochat.chat.domain.entity.ChatRoom; +import kaboo.kaboochat.chat.service.ChatService; + +/** + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@WebMvcTest(ChatController.class) +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@DisplayName("채팅 Controller 테스트") +class ChatControllerTest { + + @Autowired + MockMvc mockMvc; + @MockBean + ChatService chatService; + ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("참여중인 채팅방 리스트 조회") + void findByUsernameTest() throws Exception { + // Given + ChatRoom chatRoom1 = ChatRoom.createRoom("채팅방1"); + ChatRoom chatRoom2 = ChatRoom.createRoom("채팅방2"); + ChatRoom chatRoom3 = ChatRoom.createRoom("채팅방3"); + List responses = ChatRoomResponse.fromEntityList(List.of(chatRoom1, chatRoom2, chatRoom3)); + given(chatService.findByUsername("pjh5365")).willReturn(responses); + + // When + mockMvc.perform(get("/chat/rooms") + .queryParam("username", "pjh5365")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("요청이 성공적으로 처리되었습니다.")) + .andExpect(jsonPath("$.data").exists()) + .andDo(print()) + .andDo(document("{class-name}/{method-name}/", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("username").description("검색할 username") + ), + responseFields( + fieldWithPath("success").description("성공여부"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data").description("채팅방 리스트"), + fieldWithPath("data[].usernames").description("참여자 이름 (null)"), + fieldWithPath("data[].chatRoomUUID").description("채팅방 UUID"), + fieldWithPath("data[].chatRoomName").description("채팅방 이름") + ))); + + // Then + } + + @Test + @DisplayName("채팅방 생성") + void createRoomTest() throws Exception { + // Given + ChatRoomRequest request = new ChatRoomRequest(List.of("user1", "user2", "user3"), "채팅방"); + + // When + mockMvc.perform(post("/chat/rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("요청이 성공적으로 처리되었습니다.")) + .andExpect(jsonPath("$.data").value("채팅방 이름: 채팅방")) + .andDo(print()) + .andDo(document("{class-name}/{method-name}/", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("usernames").description("채팅방 참여자의 ID 리스트"), + fieldWithPath("chatRoomName").description("생성할 채팅방 이름") + ), + responseFields( + fieldWithPath("success").description("성공여부"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data").description("채팅방 이름") + ))); + + // Then + } + + @Test + @DisplayName("채팅방 생성 실패") + void createRoomFailTest() throws Exception { + // Given + ChatRoomRequest request = new ChatRoomRequest(List.of("user1", "user2", "user3"), "채팅방"); + doThrow(new IllegalArgumentException("해당하는 회원정보를 찾을 수 없습니다.")).when(chatService).createRoom(any()); + + // When + mockMvc.perform(post("/chat/rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("요청에 실패했습니다.")) + .andExpect(jsonPath("$.data").value("해당하는 회원정보를 찾을 수 없습니다.")) + .andDo(print()) + .andDo(document("{class-name}/{method-name}/", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("usernames").description("채팅방 참여자의 ID 리스트"), + fieldWithPath("chatRoomName").description("생성할 채팅방 이름") + ), + responseFields( + fieldWithPath("success").description("성공여부"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data").description("실패 내용") + ))); + + // Then + } + + @Test + @DisplayName("채팅방의 채팅내역 조회") + void findChatMessageTest() throws Exception { + // Given + ChatMessageRequest request1 = new ChatMessageRequest("A1A1-B2B2", "pjh5365", "justin", "안녕"); + ChatMessageRequest request2 = new ChatMessageRequest("A1A1-B2B2", "pibber", "park", "반가워"); + ChatMessageRequest request3 = new ChatMessageRequest("A1A1-B2B2", "pibber", "park", "거기 날씨 어때?"); + ChatMessageRequest request4 = new ChatMessageRequest("A1A1-B2B2", "pjh5365", "justin", "엄청 더워..."); + ChatMessageRequest request5 = new ChatMessageRequest("A1A1-B2B2", "pjh5365", "justin", "거기는 어때?"); + ChatMessage message1 = ChatMessage.createMessage(request1); + ChatMessage message2 = ChatMessage.createMessage(request2); + ChatMessage message3 = ChatMessage.createMessage(request3); + ChatMessage message4 = ChatMessage.createMessage(request4); + ChatMessage message5 = ChatMessage.createMessage(request5); + given(chatService.findMessage(any(), any())).willReturn(List.of( + ChatMessageResponse.fromEntity(message5), + ChatMessageResponse.fromEntity(message4), + ChatMessageResponse.fromEntity(message3), + ChatMessageResponse.fromEntity(message2), + ChatMessageResponse.fromEntity(message1) + )); + + // When + mockMvc.perform(get("/chat/messages") + .queryParam("roomUUID", "A1A1-B2B2") + .queryParam("page", "0") + .queryParam("size", "10")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("요청이 성공적으로 처리되었습니다.")) + .andExpect(jsonPath("$.data").exists()) + .andDo(print()) + .andDo(document("{class-name}/{method-name}/", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("roomUUID").description("채팅방 UUID"), + parameterWithName("page").description("현재 페이지 (0부터 시작)"), + parameterWithName("size").description("한 페이지 크기") + ), + responseFields( + fieldWithPath("success").description("성공여부"), + fieldWithPath("message").description("응답 메시지"), + fieldWithPath("data[]").description("채팅내역 리스트"), + fieldWithPath("data[].username").description("참여자 ID"), + fieldWithPath("data[].nickname").description("참여자 닉네임"), + fieldWithPath("data[].message").description("채팅 내용"), + fieldWithPath("data[].sendAt").description("전송 시간") + ))); + + // Then + } +} diff --git a/src/test/java/kaboo/kaboochat/chat/domain/redis/RedisPublisherTest.java b/src/test/java/kaboo/kaboochat/chat/domain/redis/RedisPublisherTest.java new file mode 100644 index 0000000..4342631 --- /dev/null +++ b/src/test/java/kaboo/kaboochat/chat/domain/redis/RedisPublisherTest.java @@ -0,0 +1,44 @@ +package kaboo.kaboochat.chat.domain.redis; + +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.ChannelTopic; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; + +/** + * @author : parkjihyeok + * @since : 2024/08/17 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisPub 테스트") +class RedisPublisherTest { + + @InjectMocks + private RedisPublisher redisPublisher; + @Mock + private RedisTemplate redisTemplate; + @Mock + private ChannelTopic channelTopic; + + @Test + @DisplayName("Redis발행 테스트") + void publisherTest() { + // Given + given(channelTopic.getTopic()).willReturn("chatroom"); + ChatMessageRequest req = new ChatMessageRequest("AAA-BBB", "pjh5365", "justin", "안녕하세요!"); + + // When + redisPublisher.publish(req); + + // Then + verify(redisTemplate).convertAndSend("chatroom", req); + } +} diff --git a/src/test/java/kaboo/kaboochat/chat/domain/redis/RedisSubscriberTest.java b/src/test/java/kaboo/kaboochat/chat/domain/redis/RedisSubscriberTest.java new file mode 100644 index 0000000..16c4673 --- /dev/null +++ b/src/test/java/kaboo/kaboochat/chat/domain/redis/RedisSubscriberTest.java @@ -0,0 +1,60 @@ +package kaboo.kaboochat.chat.domain.redis; + +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.simp.SimpMessageSendingOperations; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; + +/** + * @author : parkjihyeok + * @since : 2024/08/17 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisSub 테스트") +class RedisSubscriberTest { + + @InjectMocks + private RedisSubscriber redisSubscriber; + @Mock + private ObjectMapper objectMapper; + @Mock + private SimpMessageSendingOperations messagingTemplate; + + @Test + @DisplayName("웹 소켓 클라이언트 전송 테스트 (성공)") + void subscriberTest() throws Exception { + // Given + ChatMessageRequest req = new ChatMessageRequest("AAA-BBB", "pjh5365", "justin", "안녕하세요!"); + given(objectMapper.readValue(anyString(), eq(ChatMessageRequest.class))).willReturn(req); + + // When + redisSubscriber.sendMessage("any string"); + + // Then + verify(messagingTemplate).convertAndSend("/sub/chat/room/AAA-BBB", req); + } + + @Test + @DisplayName("웹 소켓 클라이언트 전송 테스트 (실패)") + void subscriberFailTest() throws Exception { + // Given + ChatMessageRequest req = new ChatMessageRequest("AAA-BBB", "pjh5365", "justin", "안녕하세요!"); + given(objectMapper.readValue(anyString(), eq(ChatMessageRequest.class))) + .willThrow(new RuntimeException("Something Wrong")); + + // When + redisSubscriber.sendMessage("any string"); + + // Then + verify(messagingTemplate, never()).convertAndSend("/sub/chat/room/AAA-BBB", req); + } +} diff --git a/src/test/java/kaboo/kaboochat/chat/repository/ChatMemberRepositoryTest.java b/src/test/java/kaboo/kaboochat/chat/repository/ChatMemberRepositoryTest.java new file mode 100644 index 0000000..2f736d2 --- /dev/null +++ b/src/test/java/kaboo/kaboochat/chat/repository/ChatMemberRepositoryTest.java @@ -0,0 +1,82 @@ +package kaboo.kaboochat.chat.repository; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import kaboo.kaboochat.chat.domain.entity.ChatMember; +import kaboo.kaboochat.chat.domain.entity.ChatRoom; +import kaboo.kaboochat.chat.domain.entity.Member; + +/** + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@ActiveProfiles("test") +@DataJpaTest +@DisplayName("채팅 참여자 Repository 테스트") +@Transactional +class ChatMemberRepositoryTest { + + @Autowired + DataSource dataSource; + @Autowired + MemberRepository memberRepository; + @Autowired + ChatMemberRepository chatMemberRepository; + @Autowired + ChatRoomRepository chatRoomRepository; + ChatRoom chatRoom; + + @BeforeEach + void setUp() { + chatRoom = ChatRoom.createRoom("채팅방1"); + chatRoomRepository.save(chatRoom); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + String sql1 = "insert into member(username, nickname, password) values ('pjh1', '1111', 'pw111')"; + String sql2 = "insert into member(username, nickname, password) values ('pjh2', '2222', 'pw222')"; + String sql3 = "insert into member(username, nickname, password) values ('pjh3', '3333', 'pw333')"; + String sql4 = "insert into member(username, nickname, password) values ('pjh4', '4444', 'pw444')"; + jdbc.execute(sql1); + jdbc.execute(sql2); + jdbc.execute(sql3); + jdbc.execute(sql4); + + Member member1 = memberRepository.findByUsername("pjh1").get(); + Member member2 = memberRepository.findByUsername("pjh2").get(); + Member member3 = memberRepository.findByUsername("pjh3").get(); + Member member4 = memberRepository.findByUsername("pjh4").get(); + + chatMemberRepository.save(ChatMember.createChatMember(member1, chatRoom)); + chatMemberRepository.save(ChatMember.createChatMember(member2, chatRoom)); + chatMemberRepository.save(ChatMember.createChatMember(member3, chatRoom)); + chatMemberRepository.save(ChatMember.createChatMember(member4, chatRoom)); + } + + @Test + @DisplayName("채팅방 UUID로 채팅방 참여자들 찾기") + void findByChatUUIDTest() { + // Given + String UUID = chatRoom.getChatRoomUUID(); + + // When + List result = chatMemberRepository.findByChatRoomUUID(UUID); + + // Then + assertEquals(4, result.size()); + assertEquals("pjh3", result.get(2).getMember().getUsername()); + assertEquals("2222", result.get(1).getMember().getNickname()); + assertEquals("pw444", result.get(3).getMember().getPassword()); + } +} diff --git a/src/test/java/kaboo/kaboochat/chat/repository/ChatMessageRepositoryTest.java b/src/test/java/kaboo/kaboochat/chat/repository/ChatMessageRepositoryTest.java new file mode 100644 index 0000000..afbeb39 --- /dev/null +++ b/src/test/java/kaboo/kaboochat/chat/repository/ChatMessageRepositoryTest.java @@ -0,0 +1,101 @@ +package kaboo.kaboochat.chat.repository; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.util.ReflectionTestUtils.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.mongo.DataMongoTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; +import kaboo.kaboochat.chat.domain.entity.ChatMessage; + +/** + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@ActiveProfiles("test") +@DataMongoTest +@DisplayName("채팅 메시지 Repository 테스트") +class ChatMessageRepositoryTest { + + @Autowired + ChatMessageRepository chatMessageRepository; + + @BeforeEach + void setUp() { + // 테스트용 데이터 삽입 + chatMessageRepository.deleteAll(); // 초기화 + + ChatMessageRequest request1 = new ChatMessageRequest("room1", "user1", "nick1", "안녕1!"); + ChatMessageRequest request2 = new ChatMessageRequest("room1", "user2", "nick2", "안녕2!"); + ChatMessageRequest request3 = new ChatMessageRequest("room1", "user3", "nick3", "안녕3!"); + ChatMessageRequest request4 = new ChatMessageRequest("room2", "user4", "nick4", "안녕4!"); + ChatMessageRequest request5 = new ChatMessageRequest("room2", "user5", "nick5", "안녕5!"); + ChatMessageRequest request6 = new ChatMessageRequest("room1", "user6", "nick6", "안녕6!"); + ChatMessageRequest request7 = new ChatMessageRequest("room1", "user7", "nick7", "안녕7!"); + ChatMessageRequest request8 = new ChatMessageRequest("room1", "user8", "nick8", "안녕8!"); + ChatMessageRequest request9 = new ChatMessageRequest("room1", "user9", "nick9", "안녕9!"); + ChatMessage m1 = ChatMessage.createMessage(request1); + ChatMessage m2 = ChatMessage.createMessage(request2); + ChatMessage m3 = ChatMessage.createMessage(request3); + ChatMessage m4 = ChatMessage.createMessage(request4); + ChatMessage m5 = ChatMessage.createMessage(request5); + ChatMessage m6 = ChatMessage.createMessage(request6); + ChatMessage m7 = ChatMessage.createMessage(request7); + ChatMessage m8 = ChatMessage.createMessage(request8); + ChatMessage m9 = ChatMessage.createMessage(request9); + // 리플렉션을 사용해 시간 필드를 고정시켜 테스트의 일관성유지 + setField(m1, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 1)); + setField(m2, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 2)); + setField(m3, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 3)); + setField(m4, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 4)); + setField(m5, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 5)); + setField(m6, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 6)); + setField(m7, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 7)); + setField(m8, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 8)); + setField(m9, "sendAt", LocalDateTime.of(2024, 1, 1, 1, 9)); + chatMessageRepository.save(m1); + chatMessageRepository.save(m2); + chatMessageRepository.save(m3); + chatMessageRepository.save(m4); + chatMessageRepository.save(m5); + chatMessageRepository.save(m6); + chatMessageRepository.save(m7); + chatMessageRepository.save(m8); + chatMessageRepository.save(m9); + } + + @AfterEach + void tearDown() { + chatMessageRepository.deleteAll(); // 초기화 + } + + @Test + @DisplayName("채팅방 UUID와 페이지로 채팅기록 불러오기") + void findByUUIDTest() { + // Given + String UUID = "room1"; + Pageable pageable = PageRequest.of(1, 3); // 두 번째 페이지 3개씩 자름 (6, 3, 2가 와야한다.) + + // When + Page result = chatMessageRepository.findByChatRoomUUIDOrderBySendAtDesc(UUID, pageable); + + // Then + assertEquals(7, result.getTotalElements()); + assertEquals(3, result.getContent().size()); + assertEquals(3, result.getTotalPages()); + assertEquals("안녕6!", result.getContent().get(0).getMessage()); + assertEquals("user3", result.getContent().get(1).getUsername()); + assertEquals("nick2", result.getContent().get(2).getNickname()); + } +} diff --git a/src/test/java/kaboo/kaboochat/chat/repository/ChatRoomRepositoryTest.java b/src/test/java/kaboo/kaboochat/chat/repository/ChatRoomRepositoryTest.java new file mode 100644 index 0000000..905cd10 --- /dev/null +++ b/src/test/java/kaboo/kaboochat/chat/repository/ChatRoomRepositoryTest.java @@ -0,0 +1,107 @@ +package kaboo.kaboochat.chat.repository; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Optional; + +import javax.sql.DataSource; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import kaboo.kaboochat.chat.domain.entity.ChatMember; +import kaboo.kaboochat.chat.domain.entity.ChatRoom; +import kaboo.kaboochat.chat.domain.entity.Member; + +/** + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@ActiveProfiles("test") +@DataJpaTest +@DisplayName("채팅방 Repository 테스트") +@Transactional +class ChatRoomRepositoryTest { + + @Autowired + DataSource dataSource; + @Autowired + private MemberRepository memberRepository; + @Autowired + private ChatRoomRepository chatRoomRepository; + @Autowired + private ChatMemberRepository chatMemberRepository; + ChatRoom chatRoom; + + @BeforeEach + void setUp() { + chatRoom = ChatRoom.createRoom("채팅방1"); + chatRoomRepository.save(chatRoom); + JdbcTemplate jdbc = new JdbcTemplate(dataSource); + String sql1 = "insert into member(username, nickname, password) values ('pjh1', '1111', 'pw111')"; + String sql2 = "insert into member(username, nickname, password) values ('pjh2', '2222', 'pw222')"; + String sql3 = "insert into member(username, nickname, password) values ('pjh3', '3333', 'pw333')"; + String sql4 = "insert into member(username, nickname, password) values ('pjh4', '4444', 'pw444')"; + jdbc.execute(sql1); + jdbc.execute(sql2); + jdbc.execute(sql3); + jdbc.execute(sql4); + } + + @Test + @DisplayName("UUID값으로 채팅방을 찾아오는 테스트") + void findByRoomUUIDTest() { + // Given + + // When + Optional result = chatRoomRepository.findByRoomUUID(chatRoom.getChatRoomUUID()); + + // Then + assertNotEquals(Optional.empty(), result); + assertEquals("채팅방1", result.get().getChatRoomName()); + } + + @Test + @DisplayName("참여자로 채팅방을 찾아오는 테스트") + void findByUsernameTest1() { + // Given + ChatRoom c2 = ChatRoom.createRoom("채팅방2"); + ChatRoom c3 = ChatRoom.createRoom("채팅방3"); + ChatRoom c4 = ChatRoom.createRoom("채팅방4"); + chatRoomRepository.save(c2); + chatRoomRepository.save(c3); + chatRoomRepository.save(c4); + + Member member1 = memberRepository.findByUsername("pjh1").get(); + chatMemberRepository.save(ChatMember.createChatMember(member1, chatRoom)); + chatMemberRepository.save(ChatMember.createChatMember(member1, c2)); + chatMemberRepository.save(ChatMember.createChatMember(member1, c3)); + chatMemberRepository.save(ChatMember.createChatMember(member1, c4)); + + // When + List result = chatRoomRepository.findByUsername("pjh1"); + + // Then + assertEquals(4, result.size()); + assertEquals("채팅방2", result.get(1).getChatRoomName()); + } + + @Test + @DisplayName("참여자로 채팅방을 찾아오는 테스트 (채팅방 없음)") + void findByUsernameTest2() { + // Given + + // When + List result = chatRoomRepository.findByUsername("pjh1"); + + // Then + assertEquals(0, result.size()); + } +} diff --git a/src/test/java/kaboo/kaboochat/chat/service/ChatServiceTest.java b/src/test/java/kaboo/kaboochat/chat/service/ChatServiceTest.java new file mode 100644 index 0000000..b52c4a8 --- /dev/null +++ b/src/test/java/kaboo/kaboochat/chat/service/ChatServiceTest.java @@ -0,0 +1,247 @@ +package kaboo.kaboochat.chat.service; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import kaboo.kaboochat.chat.domain.dto.request.ChatMessageRequest; +import kaboo.kaboochat.chat.domain.dto.request.ChatRoomRequest; +import kaboo.kaboochat.chat.domain.dto.response.ChatMessageResponse; +import kaboo.kaboochat.chat.domain.dto.response.ChatRoomResponse; +import kaboo.kaboochat.chat.domain.entity.ChatMember; +import kaboo.kaboochat.chat.domain.entity.ChatMessage; +import kaboo.kaboochat.chat.domain.entity.ChatRoom; +import kaboo.kaboochat.chat.domain.entity.Member; +import kaboo.kaboochat.chat.domain.redis.RedisPublisher; +import kaboo.kaboochat.chat.repository.ChatMemberRepository; +import kaboo.kaboochat.chat.repository.ChatMessageRepository; +import kaboo.kaboochat.chat.repository.ChatRoomRepository; +import kaboo.kaboochat.chat.repository.MemberRepository; + +/** + * @author : parkjihyeok + * @since : 2024/08/18 + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("채팅 서비스 테스트") +class ChatServiceTest { + + @InjectMocks + ChatService chatService; + @Mock + RedisPublisher redisPublisher; + @Mock + MemberRepository memberRepository; + @Mock + ChatRoomRepository chatRoomRepository; + @Mock + ChatMemberRepository chatMemberRepository; + @Mock + ChatMessageRepository chatMessageRepository; + + // 회원에 대한 생성자를 전부 막았으므로 모킹한다. + Member member1 = Mockito.mock(Member.class); + Member member2 = Mockito.mock(Member.class); + Member member3 = Mockito.mock(Member.class); + + @Test + @DisplayName("채팅방 UUID값으로 채팅방 DTO 생성") + void findByChatUUIDTest() { + // Given + String roomUUID = "room-uuid"; + ChatRoom chatRoom = ChatRoom.createRoom("채팅방1"); + + given(member1.getUsername()).willReturn("user1"); + given(member2.getUsername()).willReturn("user2"); + given(member3.getUsername()).willReturn("user3"); + + given(chatRoomRepository.findByRoomUUID(roomUUID)).willReturn(Optional.of(chatRoom)); + given(chatMemberRepository.findByChatRoomUUID(roomUUID)) + .willReturn(List.of( + ChatMember.createChatMember(member1, chatRoom), + ChatMember.createChatMember(member2, chatRoom), + ChatMember.createChatMember(member3, chatRoom) + )); + + // When + ChatRoomResponse result = chatService.findByChatUUID(roomUUID); + + // Then + assertEquals(3, result.getUsernames().size()); + assertEquals("채팅방1", result.getChatRoomName()); + assertEquals("user1", result.getUsernames().get(0)); + assertEquals("user2", result.getUsernames().get(1)); + assertEquals("user3", result.getUsernames().get(2)); + verify(chatRoomRepository).findByRoomUUID(any()); + verify(chatMemberRepository).findByChatRoomUUID(any()); + } + + @Test + @DisplayName("채팅방 UUID값으로 채팅방 DTO 생성 실패") + void findByChatUUIDFailTest() { + // Given + + // When + + // Then + assertThrows(IllegalArgumentException.class, () -> chatService.findByChatUUID("room-uuid")); + verify(chatRoomRepository).findByRoomUUID(any()); + verify(chatMemberRepository, never()).findByChatRoomUUID(any()); + } + + @Test + @DisplayName("사용자가 참여중인 채팅방 리스트 반환") + void findByUsernameTest1() { + // Given + given(chatRoomRepository.findByUsername("user1")) + .willReturn(List.of( + ChatRoom.createRoom("채팅방1"), + ChatRoom.createRoom("채팅방2"), + ChatRoom.createRoom("채팅방3"), + ChatRoom.createRoom("채팅방4") + )); + + // When + List result = chatService.findByUsername("user1"); + + // Then + assertEquals(4, result.size()); + assertEquals("채팅방2", result.get(1).getChatRoomName()); + verify(chatRoomRepository).findByUsername("user1"); + } + + @Test + @DisplayName("사용자가 참여중인 채팅방 리스트 반환 0") + void findByUsernameTest2() { + // Given + given(chatRoomRepository.findByUsername("user1")).willReturn(List.of()); + + // When + List result = chatService.findByUsername("user1"); + + // Then + assertEquals(0, result.size()); + verify(chatRoomRepository).findByUsername("user1"); + } + + @Test + @DisplayName("채팅방 생성 테스트") + void createRoomTest() { + // Given + ChatRoomRequest request = new ChatRoomRequest(List.of("user1", "user2", "user3"), "채팅방1"); + given(memberRepository.findByUsername("user1")).willReturn(Optional.of(member1)); + given(memberRepository.findByUsername("user2")).willReturn(Optional.of(member2)); + given(memberRepository.findByUsername("user3")).willReturn(Optional.of(member3)); + + // When + + // Then + assertDoesNotThrow(() -> chatService.createRoom(request)); + verify(memberRepository, times(3)).findByUsername(any()); + verify(chatMemberRepository, times(3)).save(any()); + } + + @Test + @DisplayName("채팅방 생성 실패 테스트") + void createRoomFailTest() { + // Given + ChatRoomRequest request = new ChatRoomRequest(List.of("user1", "user2", "user3"), "채팅방1"); + given(memberRepository.findByUsername("user1")).willReturn(Optional.of(member1)); + given(memberRepository.findByUsername("user2")).willReturn(Optional.empty()); + + // When + + // Then + assertThrows(IllegalArgumentException.class, () -> chatService.createRoom(request)); + verify(memberRepository, times(2)).findByUsername(any()); // 2번째 실행때 롤백됨 + verify(chatMemberRepository).save(any()); // 최초 1번만 실행되고 이후 롤백된다. + } + + @Test + @DisplayName("채팅내역 조회 테스트") + void findMessageTest1() { + // Given + String uuid = "room-uuid"; + Pageable pageable = PageRequest.of(0, 3); + ChatMessageRequest request1 = new ChatMessageRequest(uuid, "user1", "nick1", "메시지1"); + ChatMessageRequest request2 = new ChatMessageRequest(uuid, "user1", "nick1", "메시지2"); + ChatMessageRequest request3 = new ChatMessageRequest(uuid, "user2", "nick2", "메시지3"); + ChatMessageRequest request4 = new ChatMessageRequest(uuid, "user1", "nick1", "메시지4"); + given(chatMessageRepository.findByChatRoomUUIDOrderBySendAtDesc(uuid, pageable)) + .willReturn(new PageImpl<>(List.of( + ChatMessage.createMessage(request1), + ChatMessage.createMessage(request2), + ChatMessage.createMessage(request3), + ChatMessage.createMessage(request4) + ))); + + // When + List result = chatService.findMessage(uuid, pageable); + + // Then + assertEquals(4, result.size()); + assertEquals("메시지1", result.get(0).getMessage()); + assertEquals("user2", result.get(2).getUsername()); + assertEquals("nick1", result.get(3).getNickname()); + verify(chatMessageRepository).findByChatRoomUUIDOrderBySendAtDesc(any(), any()); + } + + @Test + @DisplayName("채팅내역 조회 테스트 0") + void findMessageTest2() { + // Given + String uuid = "room-uuid"; + Pageable pageable = PageRequest.of(0, 3); + given(chatMessageRepository.findByChatRoomUUIDOrderBySendAtDesc(uuid, pageable)) + .willReturn(new PageImpl<>(List.of())); + + // When + List result = chatService.findMessage(uuid, pageable); + + // Then + assertEquals(0, result.size()); + verify(chatMessageRepository).findByChatRoomUUIDOrderBySendAtDesc(any(), any()); + } + + @Test + @DisplayName("메시지 발송 테스트") + void sendChatMessageTest() { + // Given + String uuid = "room-uuid"; + ChatMessageRequest request = new ChatMessageRequest(uuid, "user1", "nick1", "메시지1"); + + // When + chatService.sendChatMessage(request); + + // Then + verify(chatMessageRepository).save(any()); + verify(redisPublisher).publish(any()); + } + + @Test + @DisplayName("메시지 발송 실패 테스트") + void sendChatMessageFailTest() { + // Given + given(chatMessageRepository.save(any())).willThrow(new RuntimeException()); + ChatMessageRequest request = new ChatMessageRequest("uuid", "user1", "nick1", "메시지1"); + + // When + assertThrows(RuntimeException.class, () -> chatService.sendChatMessage(request)); + + // Then + verify(redisPublisher, never()).publish(any()); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..cc92137 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,23 @@ +# 테스트용 환경세팅 +spring: + config: + activate: + on-profile: test + h2: + console: + enabled: true + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb + jpa: + hibernate: + ddl-auto: create + # 테스트용 도커 컨테이너의 설정에 맞춘다. + data: + mongodb: + host: mongodb + port: 27017 + +# 테스트용 도커 컨테이너의 설정에 맞춘다. +REDIS_HOST: redis +REDIS_PORT: 6379 From 0f7f426076b7ecde8c39b7470bf79668dbcc3807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=81?= Date: Tue, 20 Aug 2024 12:28:28 +0900 Subject: [PATCH 2/4] Create deploy.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI/CD 구축을 위한 yml파일 작성 --- .github/workflows/deploy.yml | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..5118d8c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,39 @@ +name: Build and Push Docker Image + +# main, dev 브랜치에 push or PR 이 오면 실행 +on: + push: + branches: + - main + - dev + pull_request: + branches: + - main + - dev + +jobs: + build_and_push: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # 도커 이미지 빌드용 환경 세팅 및 도커 이미지 빌드 + - name: set up test DB and docker build + run: | + docker compose -f docker-compose-chat-test-db.yml up -d # 도커 컴포즈파일로 테스트 환경 세팅 + DOCKER_BUILDKIT=0 docker build --network testNet -t ${{ secrets.DOCKER_IMAGE_NAME }}:latest . # 도커 빌드 (빌드 과정에서 네트워크 사용을 위해 빌드킷 0) + docker compose -f docker-compose-chat-test-db.yml down # 테스트 환경 제거 (네트워크까지 삭제됨) + + # 도커 로그인 + - name: docker Login + uses: docker/login-action@v3.3.0 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # 도커 이미지 push + - name: push docker images + run: | + docker push ${{ secrets.DOCKER_IMAGE_NAME }}:latest + From 932a3bedffc976183006a58b2f952f87f13bc2b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=81?= Date: Sun, 1 Sep 2024 11:42:24 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Feat:=20=EA=B9=83=ED=97=88=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=EC=97=90=20DB=20=EC=84=B8=ED=8C=85=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=9E=85=EB=A0=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to: #3 --- .github/workflows/deploy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5118d8c..e1fb681 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -18,6 +18,10 @@ jobs: steps: - uses: actions/checkout@v4 + # DB 세팅 정보 입력 + - name: Set up application.yml + run: echo "${{ secrets.APPLICATION }}" > ./src/main/resources/application.yml + # 도커 이미지 빌드용 환경 세팅 및 도커 이미지 빌드 - name: set up test DB and docker build run: | From dc5a5df09da3e51ef4fa0db27384f6b8cb803c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=A7=80=ED=98=81?= Date: Sun, 1 Sep 2024 12:29:14 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Feat:=20=EA=B9=83=ED=97=88=EB=B8=8C=20?= =?UTF-8?q?=EC=95=A1=EC=85=98=EC=97=90=20=EB=B0=B0=ED=8F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to: #3 --- .github/workflows/deploy.yml | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e1fb681..2e0a048 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Build and Push Docker Image +name: Build and Push Docker Image and Deploy # main, dev 브랜치에 push or PR 이 오면 실행 on: @@ -12,6 +12,7 @@ on: - dev jobs: + # 도커 이미지 빌드, 푸시 build_and_push: runs-on: ubuntu-latest @@ -41,3 +42,29 @@ jobs: run: | docker push ${{ secrets.DOCKER_IMAGE_NAME }}:latest +# 도커 이미지 EC2 인스턴스에 배포 + deploy-to-ec2: + needs: build_and_push + runs-on: ubuntu-24.04 + + steps: + - name: Deploy to EC2 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} # EC2 IP 주소 + username: ${{ secrets.EC2_USER }} # EC2 사용자 + key: ${{ secrets.PRIVATE_KEY }} # pem 키 + + # 기존 컨테이너 중지 + script: | + CONTAINER_ID=$(sudo docker ps -aq --filter "name=kaboo-chat") + + if [ ! -z "$CONTAINER_ID" ]; then + sudo docker stop $CONTAINER_ID || true + sudo docker rm -f $CONTAINER_ID || true + fi + + # 최신 도커 이미지로 컨테이너 실행 + sudo docker pull ${{ secrets.DOCKER_IMAGE_NAME }}:latest # 도커 최신 이미지 다운로드 + sudo docker run --name kaboo-chat -d -p 8080:8080 ${{ secrets.DOCKER_IMAGE_NAME }}:latest # 도커 이미지 실행 + sudo docker image prune -f # 구버전의 도커 이미지 제거