diff --git a/app.log b/app.log new file mode 100644 index 00000000..6b8138c4 --- /dev/null +++ b/app.log @@ -0,0 +1,194 @@ +Starting a Gradle Daemon, 1 incompatible Daemon could not be reused, use --status for details +> Task :compileJava UP-TO-DATE +> Task :processResources UP-TO-DATE +> Task :classes UP-TO-DATE +> Task :resolveMainClassName + +> Task :bootRun + + . ____ _ __ _ _ + /\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \ +( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \ + \\/ ___)| |_)| | | | | || (_| | ) ) ) ) + ' |____| .__|_| |_|_| |_\__, | / / / / + =========|_|==============|___/=/_/_/_/ + + :: Spring Boot ::   (v3.5.5) + +2025-09-29T11:30:26.041+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] com.back.Application  : Starting Application using Java 21.0.2 with PID 61575 (/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main started by user in /Users/user/ideaProjects/WEB6_8_Catfe_BE) +2025-09-29T11:30:26.042+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] com.back.Application  : The following 1 profile is active: "dev" +2025-09-29T11:30:26.061+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable +2025-09-29T11:30:26.061+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG' +2025-09-29T11:30:26.532+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode +2025-09-29T11:30:26.533+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode. +2025-09-29T11:30:26.622+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 82 ms. Found 8 JPA repository interfaces. +2025-09-29T11:30:26.630+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Multiple Spring Data modules found, entering strict repository configuration mode +2025-09-29T11:30:26.630+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data Redis repositories in DEFAULT mode. +2025-09-29T11:30:26.640+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data Redis - Could not safely identify store assignment for repository candidate interface com.back.domain.study.plan.repository.StudyPlanExceptionRepository; If you want this repository to be a Redis repository, consider annotating your entities with one of these annotations: org.springframework.data.redis.core.RedisHash (preferred), or consider extending one of the following types with your repository: org.springframework.data.keyvalue.repository.KeyValueRepository +2025-09-29T11:30:26.641+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data Redis - Could not safely identify store assignment for repository candidate interface com.back.domain.study.plan.repository.StudyPlanRepository; If you want this repository to be a Redis repository, consider annotating your entities with one of these annotations: org.springframework.data.redis.core.RedisHash (preferred), or consider extending one of the following types with your repository: org.springframework.data.keyvalue.repository.KeyValueRepository +2025-09-29T11:30:26.641+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data Redis - Could not safely identify store assignment for repository candidate interface com.back.domain.studyroom.repository.RoomChatMessageRepository; If you want this repository to be a Redis repository, consider annotating your entities with one of these annotations: org.springframework.data.redis.core.RedisHash (preferred), or consider extending one of the following types with your repository: org.springframework.data.keyvalue.repository.KeyValueRepository +2025-09-29T11:30:26.641+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data Redis - Could not safely identify store assignment for repository candidate interface com.back.domain.studyroom.repository.RoomMemberRepository; If you want this repository to be a Redis repository, consider annotating your entities with one of these annotations: org.springframework.data.redis.core.RedisHash (preferred), or consider extending one of the following types with your repository: org.springframework.data.keyvalue.repository.KeyValueRepository +2025-09-29T11:30:26.641+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data Redis - Could not safely identify store assignment for repository candidate interface com.back.domain.studyroom.repository.RoomRepository; If you want this repository to be a Redis repository, consider annotating your entities with one of these annotations: org.springframework.data.redis.core.RedisHash (preferred), or consider extending one of the following types with your repository: org.springframework.data.keyvalue.repository.KeyValueRepository +2025-09-29T11:30:26.641+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data Redis - Could not safely identify store assignment for repository candidate interface com.back.domain.user.repository.UserProfileRepository; If you want this repository to be a Redis repository, consider annotating your entities with one of these annotations: org.springframework.data.redis.core.RedisHash (preferred), or consider extending one of the following types with your repository: org.springframework.data.keyvalue.repository.KeyValueRepository +2025-09-29T11:30:26.641+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data Redis - Could not safely identify store assignment for repository candidate interface com.back.domain.user.repository.UserRepository; If you want this repository to be a Redis repository, consider annotating your entities with one of these annotations: org.springframework.data.redis.core.RedisHash (preferred), or consider extending one of the following types with your repository: org.springframework.data.keyvalue.repository.KeyValueRepository +2025-09-29T11:30:26.641+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .RepositoryConfigurationExtensionSupport : Spring Data Redis - Could not safely identify store assignment for repository candidate interface com.back.domain.user.repository.UserTokenRepository; If you want this repository to be a Redis repository, consider annotating your entities with one of these annotations: org.springframework.data.redis.core.RedisHash (preferred), or consider extending one of the following types with your repository: org.springframework.data.keyvalue.repository.KeyValueRepository +2025-09-29T11:30:26.641+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 6 ms. Found 0 Redis repository interfaces. +2025-09-29T11:30:27.126+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http) +2025-09-29T11:30:27.135+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.apache.catalina.core.StandardService  : Starting service [Tomcat] +2025-09-29T11:30:27.135+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/10.1.44] +2025-09-29T11:30:27.154+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.a.c.c.C.[Tomcat].[localhost].[/]  : Initializing Spring embedded WebApplicationContext +2025-09-29T11:30:27.155+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1093 ms +2025-09-29T11:30:27.264+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [name: default] +2025-09-29T11:30:27.293+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] org.hibernate.Version  : HHH000412: Hibernate ORM core version 6.6.26.Final +2025-09-29T11:30:27.315+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.h.c.internal.RegionFactoryInitiator  : HHH000026: Second-level cache disabled +2025-09-29T11:30:27.452+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.s.o.j.p.SpringPersistenceUnitInfo  : No LoadTimeWeaver setup: ignoring JPA class transformer +2025-09-29T11:30:27.466+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Starting... +2025-09-29T11:30:27.589+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] com.zaxxer.hikari.pool.HikariPool  : HikariPool-1 - Added connection conn0: url=jdbc:h2:./db_dev user=SA +2025-09-29T11:30:27.590+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Start completed. +2025-09-29T11:30:27.604+09:00  WARN 61575 --- [catfe-backend] [ restartedMain] org.hibernate.orm.deprecation  : HHH90000025: H2Dialect does not need to be specified explicitly using 'hibernate.dialect' (remove the property setting and it will be selected by default) +2025-09-29T11:30:27.615+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] org.hibernate.orm.connections.pooling  : HHH10001005: Database info: + Database JDBC URL [Connecting through datasource 'HikariDataSource (HikariPool-1)'] + Database driver: undefined/unknown + Database version: 2.3.232 + Autocommit mode: undefined/unknown + Isolation level: undefined/unknown + Minimum pool size: undefined/unknown + Maximum pool size: undefined/unknown +2025-09-29T11:30:28.575+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.h.e.t.j.p.i.JtaPlatformInitiator  : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration) +[Hibernate] + alter table if exists room_member + drop constraint if exists UKsctyxu6tuv0mn89bont17k5mf +[Hibernate] + alter table if exists room_member + add constraint UKsctyxu6tuv0mn89bont17k5mf unique (room_id, user_id) +2025-09-29T11:30:28.615+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default' +2025-09-29T11:30:28.864+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.s.d.j.r.query.QueryEnhancerFactory  : Hibernate is in classpath; If applicable, HQL parser will be used. +2025-09-29T11:30:29.615+09:00  WARN 61575 --- [catfe-backend] [ restartedMain] ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'roomChatWebSocketController' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/domain/chat/room/controller/RoomChatWebSocketController.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'org.springframework.web.socket.config.annotation.DelegatingWebSocketMessageBrokerConfiguration': Unsatisfied dependency expressed through method 'setConfigurers' parameter 0: Error creating bean with name 'webSocketConfig' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/config/WebSocketConfig.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'webSocketSessionManager' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/websocket/service/WebSocketSessionManager.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'brokerMessagingTemplate': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? +2025-09-29T11:30:29.622+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default' +2025-09-29T11:30:29.623+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Shutdown initiated... +2025-09-29T11:30:29.644+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] com.zaxxer.hikari.HikariDataSource  : HikariPool-1 - Shutdown completed. +2025-09-29T11:30:29.646+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] o.apache.catalina.core.StandardService  : Stopping service [Tomcat] +2025-09-29T11:30:29.654+09:00  INFO 61575 --- [catfe-backend] [ restartedMain] .s.b.a.l.ConditionEvaluationReportLogger : + +Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled. +2025-09-29T11:30:29.664+09:00 ERROR 61575 --- [catfe-backend] [ restartedMain] o.s.boot.SpringApplication  : Application run failed + +org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'roomChatWebSocketController' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/domain/chat/room/controller/RoomChatWebSocketController.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'org.springframework.web.socket.config.annotation.DelegatingWebSocketMessageBrokerConfiguration': Unsatisfied dependency expressed through method 'setConfigurers' parameter 0: Error creating bean with name 'webSocketConfig' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/config/WebSocketConfig.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'webSocketSessionManager' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/websocket/service/WebSocketSessionManager.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'brokerMessagingTemplate': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.instantiateSingleton(DefaultListableBeanFactory.java:1222) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingleton(DefaultListableBeanFactory.java:1188) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:1123) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:987) ~[spring-context-6.2.10.jar:6.2.10] + at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:627) ~[spring-context-6.2.10.jar:6.2.10] + at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:146) ~[spring-boot-3.5.5.jar:3.5.5] + at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:752) ~[spring-boot-3.5.5.jar:3.5.5] + at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:439) ~[spring-boot-3.5.5.jar:3.5.5] + at org.springframework.boot.SpringApplication.run(SpringApplication.java:318) ~[spring-boot-3.5.5.jar:3.5.5] + at org.springframework.boot.SpringApplication.run(SpringApplication.java:1361) ~[spring-boot-3.5.5.jar:3.5.5] + at org.springframework.boot.SpringApplication.run(SpringApplication.java:1350) ~[spring-boot-3.5.5.jar:3.5.5] + at com.back.Application.main(Application.java:12) ~[main/:na] + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na] + at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na] + at org.springframework.boot.devtools.restart.RestartLauncher.run(RestartLauncher.java:50) ~[spring-boot-devtools-3.5.5.jar:3.5.5] +Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.web.socket.config.annotation.DelegatingWebSocketMessageBrokerConfiguration': Unsatisfied dependency expressed through method 'setConfigurers' parameter 0: Error creating bean with name 'webSocketConfig' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/config/WebSocketConfig.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'webSocketSessionManager' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/websocket/service/WebSocketSessionManager.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'brokerMessagingTemplate': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.resolveMethodArguments(AutowiredAnnotationBeanPostProcessor.java:896) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.inject(AutowiredAnnotationBeanPostProcessor.java:849) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.annotation.InjectionMetadata.inject(InjectionMetadata.java:146) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor.postProcessProperties(AutowiredAnnotationBeanPostProcessor.java:509) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.populateBean(AbstractAutowireCapableBeanFactory.java:1459) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:606) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.instantiateUsingFactoryMethod(ConstructorResolver.java:413) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.instantiateUsingFactoryMethod(AbstractAutowireCapableBeanFactory.java:1375) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1205) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1752) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1635) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) ~[spring-beans-6.2.10.jar:6.2.10] + ... 24 common frames omitted +Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'webSocketConfig' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/config/WebSocketConfig.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'webSocketSessionManager' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/websocket/service/WebSocketSessionManager.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'brokerMessagingTemplate': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.addCandidateEntry(DefaultListableBeanFactory.java:2003) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.findAutowireCandidates(DefaultListableBeanFactory.java:1967) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveMultipleBeanCollection(DefaultListableBeanFactory.java:1857) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveMultipleBeans(DefaultListableBeanFactory.java:1825) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1701) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1635) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor$AutowiredMethodElement.resolveMethodArguments(AutowiredAnnotationBeanPostProcessor.java:888) ~[spring-beans-6.2.10.jar:6.2.10] + ... 48 common frames omitted +Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'webSocketSessionManager' defined in file [/Users/user/ideaProjects/WEB6_8_Catfe_BE/build/classes/java/main/com/back/global/websocket/service/WebSocketSessionManager.class]: Unsatisfied dependency expressed through constructor parameter 1: Error creating bean with name 'brokerMessagingTemplate': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1395) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1232) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:569) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBean(AbstractAutowireCapableBeanFactory.java:529) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.lambda$doGetBean$0(AbstractBeanFactory.java:339) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:373) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1752) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1635) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) ~[spring-beans-6.2.10.jar:6.2.10] + ... 65 common frames omitted +Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'brokerMessagingTemplate': Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency? + at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.beforeSingletonCreation(DefaultSingletonBeanRegistry.java:544) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultSingletonBeanRegistry.getSingleton(DefaultSingletonBeanRegistry.java:312) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:337) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.config.DependencyDescriptor.resolveCandidate(DependencyDescriptor.java:254) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1752) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.DefaultListableBeanFactory.resolveDependency(DefaultListableBeanFactory.java:1635) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.resolveAutowiredArgument(ConstructorResolver.java:913) ~[spring-beans-6.2.10.jar:6.2.10] + at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:791) ~[spring-beans-6.2.10.jar:6.2.10] + ... 79 common frames omitted + + +> Task :bootRun FAILED + +FAILURE: Build failed with an exception. + +* What went wrong: +Execution failed for task ':bootRun'. +> Process 'command '/Users/user/Library/Java/JavaVirtualMachines/graalvm-ce-21.0.2/Contents/Home/bin/java'' finished with non-zero exit value 1 + +* Try: +> Run with --stacktrace option to get the stack trace. +> Run with --info or --debug option to get more log output. +> Run with --scan to get full insights. +> Get more help at https://help.gradle.org. + +BUILD FAILED in 10s +4 actionable tasks: 2 executed, 2 up-to-date diff --git a/src/main/java/com/back/domain/studyroom/controller/RoomController.java b/src/main/java/com/back/domain/studyroom/controller/RoomController.java index d14a70ea..89bcf2d5 100644 --- a/src/main/java/com/back/domain/studyroom/controller/RoomController.java +++ b/src/main/java/com/back/domain/studyroom/controller/RoomController.java @@ -3,8 +3,11 @@ import com.back.domain.studyroom.dto.*; import com.back.domain.studyroom.entity.Room; import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.entity.RoomRole; import com.back.domain.studyroom.service.RoomService; import com.back.global.common.dto.RsData; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; import com.back.global.security.user.CurrentUser; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -14,6 +17,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -34,6 +38,7 @@ @RestController @RequestMapping("/api/rooms") @RequiredArgsConstructor +@Slf4j @Tag(name = "Room API", description = "스터디 룸 관련 API") @SecurityRequirement(name = "Bearer Authentication") public class RoomController { @@ -74,7 +79,10 @@ public ResponseEntity> createRoom( @PostMapping("/{roomId}/join") @Operation( summary = "방 입장", - description = "특정 스터디 룸에 입장합니다. 공개방은 바로 입장 가능하며, 비공개방은 비밀번호가 필요합니다." + description = "특정 스터디 룸에 입장합니다." + + " 공개방은 바로 입장 가능하며, 비공개방은 비밀번호가 필요합니다." + + " 입장 후 WebSocket 연결 정보와 현재 온라인 멤버 목록을 함께 제공합니다." + ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "방 입장 성공"), @@ -94,11 +102,28 @@ public ResponseEntity> joinRoom( } RoomMember member = roomService.joinRoom(roomId, password, currentUserId); - JoinRoomResponse response = JoinRoomResponse.from(member); - - return ResponseEntity - .status(HttpStatus.OK) - .body(RsData.success("방 입장 완료", response)); + + // 🆕 WebSocket 기반 온라인 멤버 목록과 WebSocket 연결 정보 포함하여 응답 생성 + try { + List onlineMembers = roomService.getOnlineMembersWithWebSocket(roomId, currentUserId); + int onlineCount = onlineMembers.size(); + + JoinRoomResponse response = JoinRoomResponse.withWebSocketInfo(member, onlineMembers, onlineCount); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 입장 완료", response)); + + } catch (Exception e) { + log.warn("WebSocket 정보 포함 응답 생성 실패, 기본 응답 사용 - 방: {}, 사용자: {}", roomId, currentUserId, e); + + // WebSocket 연동 실패 시 기본 응답으로 폴백 + JoinRoomResponse response = JoinRoomResponse.from(member); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("방 입장 완료", response)); + } } @PostMapping("/{roomId}/leave") @@ -159,7 +184,7 @@ public ResponseEntity>> getRooms( @GetMapping("/{roomId}") @Operation( summary = "방 상세 정보 조회", - description = "특정 방의 상세 정보와 현재 온라인 멤버 목록을 조회합니다. 비공개 방은 멤버만 조회 가능합니다." + description = "특정 방의 상세 정보와 현재 온라인 멤버 목록을 조회합니다. 비공개 방은 멤버만 조회 가능하며, WebSocket 기반 실시간 온라인 상태를 반영합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), @@ -173,11 +198,9 @@ public ResponseEntity> getRoomDetail( Long currentUserId = currentUser.getUserId(); Room room = roomService.getRoomDetail(roomId, currentUserId); - List members = roomService.getRoomMembers(roomId, currentUserId); - - List memberResponses = members.stream() - .map(RoomMemberResponse::from) - .collect(Collectors.toList()); + + // 🆕 WebSocket 기반 온라인 멤버 목록 조회 + List memberResponses = roomService.getOnlineMembersWithWebSocket(roomId, currentUserId); RoomDetailResponse response = RoomDetailResponse.of(room, memberResponses); @@ -273,7 +296,7 @@ public ResponseEntity> deleteRoom( @GetMapping("/{roomId}/members") @Operation( summary = "방 멤버 목록 조회", - description = "방의 현재 온라인 멤버 목록을 조회합니다. 역할별로 정렬됩니다(방장>부방장>멤버>방문객)." + description = "방의 현재 온라인 멤버 목록을 조회합니다. 역할별로 정렬되며, WebSocket 기반 실시간 온라인 상태를 반영합니다." ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "조회 성공"), @@ -286,11 +309,8 @@ public ResponseEntity>> getRoomMembers( Long currentUserId = currentUser.getUserId(); - List members = roomService.getRoomMembers(roomId, currentUserId); - - List memberList = members.stream() - .map(RoomMemberResponse::from) - .collect(Collectors.toList()); + // 🆕 WebSocket 기반 온라인 멤버 목록 조회 + List memberList = roomService.getOnlineMembersWithWebSocket(roomId, currentUserId); return ResponseEntity .status(HttpStatus.OK) @@ -329,4 +349,98 @@ public ResponseEntity>> getPopularRooms( .status(HttpStatus.OK) .body(RsData.success("인기 방 목록 조회 완료", response)); } + + + // ======================== WebSocket 연동 API ======================== + @GetMapping("/{roomId}/websocket-status") + @Operation( + summary = "방 WebSocket 상태 조회", + description = "특정 방의 WebSocket 연결 상태와 실시간 온라인 멤버 수를 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "403", description = "비공개 방에 대한 접근 권한 없음"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> getWebSocketStatus( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long currentUserId = currentUser.getUserId(); + + // 방 접근 권한 확인 + roomService.getRoomDetail(roomId, currentUserId); + + try { + List onlineMembers = roomService.getOnlineMembersWithWebSocket(roomId, currentUserId); + + Map status = new HashMap<>(); + status.put("roomId", roomId); + status.put("onlineCount", onlineMembers.size()); + status.put("onlineMembers", onlineMembers); + status.put("websocketChannels", Map.of( + "roomUpdates", "/topic/rooms/" + roomId + "/updates", + "roomChat", "/topic/rooms/" + roomId + "/chat", + "privateMessages", "/user/queue/messages" + )); + status.put("lastUpdated", java.time.LocalDateTime.now()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("WebSocket 상태 조회 완료", status)); + + } catch (Exception e) { + log.error("WebSocket 상태 조회 실패 - 방: {}", roomId, e); + + Map errorStatus = new HashMap<>(); + errorStatus.put("roomId", roomId); + errorStatus.put("error", "WebSocket 상태 조회 실패"); + errorStatus.put("message", e.getMessage()); + + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(RsData.fail(ErrorCode.WS_REDIS_ERROR, errorStatus)); + } + } + + @PostMapping("/{roomId}/refresh-online-members") + @Operation( + summary = "온라인 멤버 목록 강제 새로고침", + description = "특정 방의 온라인 멤버 목록을 강제로 새로고침하고 모든 멤버에게 업데이트를 브로드캐스트합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "새로고침 성공"), + @ApiResponse(responseCode = "403", description = "권한 없음 (방장/부방장만 가능)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 방"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity>> refreshOnlineMembers( + @Parameter(description = "방 ID", required = true) @PathVariable Long roomId) { + + Long currentUserId = currentUser.getUserId(); + + // 방장 또는 부방장 권한 확인 + RoomRole userRole = roomService.getUserRoomRole(roomId, currentUserId); + if (!userRole.canManageRoom()) { + throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); + } + + try { + List onlineMembers = roomService.getOnlineMembersWithWebSocket(roomId, currentUserId); + + // TODO: SessionManager를 통해 온라인 멤버 목록 브로드캐스트 강제 실행 + // sessionManager.broadcastOnlineMembersUpdate(roomId); + + log.info("온라인 멤버 목록 강제 새로고침 완료 - 방: {}, 요청자: {}, 온라인 멤버: {}명", + roomId, currentUserId, onlineMembers.size()); + + return ResponseEntity + .status(HttpStatus.OK) + .body(RsData.success("온라인 멤버 목록 새로고침 완료", onlineMembers)); + + } catch (Exception e) { + log.error("온라인 멤버 목록 새로고침 실패 - 방: {}, 요청자: {}", roomId, currentUserId, e); + throw new CustomException(ErrorCode.WS_REDIS_ERROR); + } + } } diff --git a/src/main/java/com/back/domain/studyroom/dto/JoinRoomResponse.java b/src/main/java/com/back/domain/studyroom/dto/JoinRoomResponse.java index 2efefa91..aa9741be 100644 --- a/src/main/java/com/back/domain/studyroom/dto/JoinRoomResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/JoinRoomResponse.java @@ -6,6 +6,8 @@ import lombok.Getter; import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; @Getter @Builder @@ -15,6 +17,11 @@ public class JoinRoomResponse { private RoomRole role; private LocalDateTime joinedAt; + // 🆕 WebSocket 관련 정보 + private int currentOnlineCount; + private List onlineMembers; + private WebSocketChannelInfo websocketInfo; + public static JoinRoomResponse from(RoomMember member) { return JoinRoomResponse.builder() .roomId(member.getRoom().getId()) @@ -23,4 +30,47 @@ public static JoinRoomResponse from(RoomMember member) { .joinedAt(member.getJoinedAt()) .build(); } + + /** + * 🆕 WebSocket 정보를 포함한 응답 생성 + */ + public static JoinRoomResponse withWebSocketInfo(RoomMember member, + List onlineMembers, + int onlineCount) { + return JoinRoomResponse.builder() + .roomId(member.getRoom().getId()) + .userId(member.getUser().getId()) + .role(member.getRole()) + .joinedAt(member.getJoinedAt()) + .currentOnlineCount(onlineCount) + .onlineMembers(onlineMembers) + .websocketInfo(WebSocketChannelInfo.forRoom(member.getRoom().getId())) + .build(); + } + + /** + * WebSocket 채널 정보 + */ + @Getter + @Builder + public static class WebSocketChannelInfo { + private String roomUpdatesChannel; + private String roomChatChannel; + private String privateMessageChannel; + private Map subscribeTopics; + + public static WebSocketChannelInfo forRoom(Long roomId) { + return WebSocketChannelInfo.builder() + .roomUpdatesChannel("/topic/rooms/" + roomId + "/updates") + .roomChatChannel("/topic/rooms/" + roomId + "/chat") + .privateMessageChannel("/user/queue/messages") + .subscribeTopics(Map.of( + "roomUpdates", "/topic/rooms/" + roomId + "/updates", + "roomChat", "/topic/rooms/" + roomId + "/chat", + "privateMessages", "/user/queue/messages", + "notifications", "/user/queue/notifications" + )) + .build(); + } + } } diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomBroadcastMessage.java b/src/main/java/com/back/domain/studyroom/dto/RoomBroadcastMessage.java new file mode 100644 index 00000000..36de1425 --- /dev/null +++ b/src/main/java/com/back/domain/studyroom/dto/RoomBroadcastMessage.java @@ -0,0 +1,207 @@ +package com.back.domain.studyroom.dto; + +import com.back.domain.studyroom.entity.RoomMember; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 방 관련 실시간 브로드캐스트 메시지 DTO + * - WebSocket을 통해 방 내 모든 멤버에게 전송되는 메시지의 표준 형식 정의 + - 다양한 방 이벤트를 실시간으로 알림 + + * 사용 패턴: + 1. 서버에서 방 이벤트 발생 시 해당하는 정적 메서드 호출 + 2. 생성된 메시지를 WebSocketSessionManager.broadcastToRoom()으로 전송 + 3. 클라이언트에서 /topic/rooms/{roomId}/updates 채널을 구독하여 수신 + */ +@Getter +public class RoomBroadcastMessage { + + // 브로드캐스트 메시지의 종류 (어떤 이벤트인지 구분) + private final BroadcastType type; + + // 메시지가 발생한 방의 ID (클라이언트에서 어느 방의 이벤트인지 확인용) + private final Long roomId; + + // 메시지 생성 시각 (클라이언트에서 시간순 정렬이나 만료 체크용) + private final LocalDateTime timestamp; + + // 이벤트와 관련된 실제 데이터 (멤버 정보, 온라인 목록 등) + // Object 타입으로 다양한 데이터 구조를 담을 수 있도록 설계 + private final Object data; + + // 사용자에게 표시할 사람이 읽기 쉬운 메시지 (UI에서 사용될 수 있는 부분) + private final String message; + + /** + * 브로드캐스트 메시지 생성자 (private) + - 외부에서 직접 생성하지 않고 정적 팩토리 메서드를 통해서만 생성 + - 이렇게 하면 메시지 타입별로 적절한 데이터와 메시지가 확실히 설정됨 + */ + private RoomBroadcastMessage(BroadcastType type, Long roomId, Object data, String message) { + this.type = type; + this.roomId = roomId; + this.timestamp = LocalDateTime.now(); // 메시지 생성 시점 자동 기록 + this.data = data; + this.message = message; + } + + /** + * 멤버 입장 알림 메시지 생성 + RoomService.joinRoom() 메서드에서 멤버가 성공적으로 입장했을 때 + * @param roomId 입장한 방의 ID + * @param member 입장한 멤버 정보 (RoomMember 엔티티) + * @return 입장 알림 브로드캐스트 메시지 + */ + public static RoomBroadcastMessage memberJoined(Long roomId, RoomMember member) { + // 멤버 정보를 클라이언트용 DTO로 변환 (민감한 정보 제외하고 필요한 정보만) + RoomMemberResponse memberData = RoomMemberResponse.from(member); + + // 사용자 친화적인 알림 메시지 생성 (닉네임 사용) + String message = String.format("%s님이 방에 입장했습니다.", member.getUser().getNickname()); + + return new RoomBroadcastMessage(BroadcastType.MEMBER_JOINED, roomId, memberData, message); + } + + /** + * 멤버 퇴장 알림 메시지 생성 + RoomService.leaveRoom() 메서드에서 멤버가 퇴장했을 때 (명시적 퇴장 또는 강제 퇴장) + * @param roomId 퇴장한 방의 ID + * @param member 퇴장한 멤버 정보 (퇴장 처리 전에 미리 정보 백업 필요) + * @return 퇴장 알림 브로드캐스트 메시지 + */ + public static RoomBroadcastMessage memberLeft(Long roomId, RoomMember member) { + // 퇴장한 멤버의 정보를 포함 (클라이언트에서 UI 업데이트용) + RoomMemberResponse memberData = RoomMemberResponse.from(member); + + // 퇴장 알림 메시지 생성 + String message = String.format("%s님이 방에서 나갔습니다.", member.getUser().getNickname()); + + return new RoomBroadcastMessage(BroadcastType.MEMBER_LEFT, roomId, memberData, message); + } + + /** + * 온라인 멤버 목록 업데이트 알림 + - 멤버 입장/퇴장 후 온라인 목록이 변경되었을 때 + - 관리자가 강제로 온라인 목록 새로고침을 요청했을 때 + * @param roomId 업데이트된 방의 ID + * @param onlineUserIds 현재 온라인 상태인 사용자 ID 목록 + * @return 온라인 멤버 목록 업데이트 알림 메시지 + */ + public static RoomBroadcastMessage onlineMembersUpdated(Long roomId, List onlineUserIds) { + // 현재 온라인 멤버 수 표시 + String message = String.format("현재 온라인 멤버: %d명", onlineUserIds.size()); + + // 온라인 사용자 ID 목록을 데이터로 전송 (클라이언트에서 상세 정보 요청 가능) + return new RoomBroadcastMessage(BroadcastType.ONLINE_MEMBERS_UPDATED, roomId, onlineUserIds, message); + } + + /** + * 방 설정 변경 알림 메시지 + RoomService.updateRoomSettings() 메서드에서 방 설정이 변경되었을 때 + * @param roomId 설정이 변경된 방의 ID + * @param updateMessage 변경 내용을 설명하는 메시지 + * @return 방 설정 변경 알림 메시지 + */ + public static RoomBroadcastMessage roomUpdated(Long roomId, String updateMessage) { + // 설정 변경의 경우 별도 데이터 없이 메시지만 전송 + return new RoomBroadcastMessage(BroadcastType.ROOM_UPDATED, roomId, null, updateMessage); + } + + /** + * 방장 변경 알림 메시지 + - 기존 방장이 퇴장하여 새 방장이 자동 선정되었을 때 (로직 확인 예정) + - 방장이 다른 멤버에게 방장 권한을 이양했을 때 + * @param roomId 방장이 변경된 방의 ID + * @param newHost 새로운 방장이 된 멤버 정보 + * @return 방장 변경 알림 메시지 + */ + public static RoomBroadcastMessage hostChanged(Long roomId, RoomMember newHost) { + // 새 방장의 정보를 포함 (클라이언트에서 UI 권한 업데이트용) + RoomMemberResponse hostData = RoomMemberResponse.from(newHost); + + // 새 방장 알림 메시지 생성 + String message = String.format("%s님이 새로운 방장이 되었습니다.", newHost.getUser().getNickname()); + + return new RoomBroadcastMessage(BroadcastType.HOST_CHANGED, roomId, hostData, message); + } + + /** + * 멤버 역할 변경 알림 메시지 + RoomService.changeUserRole() 메서드에서 멤버의 역할이 변경되었을 때 + * @param roomId 역할이 변경된 방의 ID + * @param member 역할이 변경된 멤버 정보 (변경 후 정보) + * @return 멤버 역할 변경 알림 메시지 + */ + public static RoomBroadcastMessage memberRoleChanged(Long roomId, RoomMember member) { + // 변경된 멤버의 새로운 역할 정보 포함 + RoomMemberResponse memberData = RoomMemberResponse.from(member); + + // 역할 변경 알림 메시지 (역할의 한글명 표시) + String message = String.format("%s님의 역할이 %s로 변경되었습니다.", + member.getUser().getNickname(), member.getRole().getDisplayName()); + + return new RoomBroadcastMessage(BroadcastType.MEMBER_ROLE_CHANGED, roomId, memberData, message); + } + + /** + * 멤버 추방 알림 메시지 + RoomService.kickMember() 메서드에서 멤버가 추방되었을 때 + * @param roomId 추방이 발생한 방의 ID + * @param memberName 추방된 멤버의 닉네임 (추방 후에는 멤버 정보 조회 불가능하므로 미리 백업) + * @return 멤버 추방 알림 메시지 + */ + public static RoomBroadcastMessage memberKicked(Long roomId, String memberName) { + // 추방 알림 메시지 생성 + String message = String.format("%s님이 방에서 추방되었습니다.", memberName); + + // 추방의 경우 이미 멤버 정보가 제거되므로 데이터는 null + return new RoomBroadcastMessage(BroadcastType.MEMBER_KICKED, roomId, null, message); + } + + /** + * 방 종료 알림 메시지 + - 방장이 수동으로 방을 종료했을 때 + - 모든 멤버가 퇴장하여 방이 자동 종료되었을 때 + * @param roomId 종료된 방의 ID + * @return 방 종료 알림 메시지 + */ + public static RoomBroadcastMessage roomTerminated(Long roomId) { + // 방 종료 메시지 (간단명료) + String message = "방이 종료되었습니다."; + + // 방 종료의 경우 별도 데이터 없이 메시지만 전송 + return new RoomBroadcastMessage(BroadcastType.ROOM_TERMINATED, roomId, null, message); + } + + // 브로드캐스트 메시지 타입 + public enum BroadcastType { + // 멤버 관련 이벤트 + MEMBER_JOINED("멤버 입장"), // 새 멤버가 방에 입장했을 때 + MEMBER_LEFT("멤버 퇴장"), // 멤버가 방에서 퇴장했을 때 + MEMBER_ROLE_CHANGED("멤버 역할 변경"), // 멤버의 역할(권한)이 변경되었을 때 + MEMBER_KICKED("멤버 추방"), // 멤버가 강제로 추방되었을 때 + + // 방 상태 관련 이벤트 + ONLINE_MEMBERS_UPDATED("온라인 멤버 목록 업데이트"), // 온라인 멤버 목록이 변경되었을 때 + ROOM_UPDATED("방 설정 변경"), // 방 제목, 설명, 정원 등이 변경되었을 때 + HOST_CHANGED("방장 변경"), // 방장이 바뀌었을 때 + ROOM_TERMINATED("방 종료"); // 방이 종료되었을 때 + + private final String description; // 한글 설명 (디버깅이나 로그용) + + BroadcastType(String description) { + this.description = description; + } + + /** + * 브로드캐스트 타입의 한글 설명 반환 + * 주로 로그 출력이나 디버깅 시 사용 + */ + public String getDescription() { + return description; + } + } +} diff --git a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java index 2d12c824..03303d0a 100644 --- a/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java +++ b/src/main/java/com/back/domain/studyroom/dto/RoomMemberResponse.java @@ -7,22 +7,39 @@ import java.time.LocalDateTime; +/** + * 방 멤버 정보 응답 DTO + isOnline 필드 제거: 이 DTO로 변환된 멤버는 이미 온라인 상태임을 의미 + RoomService.getOnlineMembersWithWebSocket()에서 Redis 기반 필터링 후 변환 + // Redis에서 온라인 사용자만 필터링 + (Set)Long onlineUserIds = sessionManager.getOnlineUsersInRoom(roomId); + + // 해당 사용자들의 멤버 정보 조회 + (List)RoomMember onlineMembers = repository.findByRoomIdAndUserIdIn(roomId, onlineUserIds); + + // DTO 변환 (이미 온라인인 멤버들만 변환됨) + (List)RoomMemberResponse response = onlineMembers.stream() + .map(RoomMemberResponse::from) + .collect(Collectors.toList()); + */ @Getter @Builder public class RoomMemberResponse { private Long userId; private String nickname; private RoomRole role; - private boolean isOnline; private LocalDateTime joinedAt; private LocalDateTime lastActiveAt; + /** + * RoomMember 엔티티를 응답 DTO로 변환 + 주의..) 이 메서드로 변환된 멤버는 온라인 상태로 인식되기 때문에 getOnlineMembersWithWebSocket()에서만 사용 해야함!!!! + */ public static RoomMemberResponse from(RoomMember member) { return RoomMemberResponse.builder() .userId(member.getUser().getId()) .nickname(member.getUser().getNickname()) .role(member.getRole()) - .isOnline(member.isOnline()) .joinedAt(member.getJoinedAt()) .lastActiveAt(member.getLastActiveAt() != null ? member.getLastActiveAt() : member.getJoinedAt()) .build(); diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java index e442c692..6096a869 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomMember.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomMember.java @@ -8,15 +8,10 @@ import java.time.LocalDateTime; -/* - RoomMember 엔티티 - 방과 사용자 간의 멤버십 관계를 나타냄 - 연관관계 : - - Room (1) : RoomMember (N) - 한 방에 여러 멤버가 있을 수 있음 - - User (1) : RoomMember (N) - 한 사용자가 여러 방의 멤버가 될 수 있음 - @JoinColumn vs @JoinTable 선택 이유: - - @JoinColumn: 외래키를 이용한 직접 관계 (현재 변경) - - @JoinTable: 별도의 연결 테이블을 만드는 관계 - RoomMember 테이블에서 그냥 room_id와 user_id 외래키로 직접 연결. +/** + * RoomMember 엔티티 - 방과 사용자 간의 영구적인 멤버십 관계를 나타냄 + * room (1) : RoomMember (N) - 한 방에 여러 멤버 + * user (1) : RoomMember (N) - 한 사용자가 여러 방의 멤버 */ @Entity @NoArgsConstructor @@ -24,111 +19,92 @@ @Table(uniqueConstraints = @UniqueConstraint(columnNames = {"room_id", "user_id"})) public class RoomMember extends BaseEntity { - // 방 정보 - 어떤 방의 멤버인지 + // ==================== 영구 데이터 (DB에서 관리) ==================== @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "room_id") // room_member 테이블의 room_id 컬럼이 room 테이블의 id를 참조 + @JoinColumn(name = "room_id", nullable = false) private Room room; - // 사용자 정보 - 누가 이 방의 멤버인지 @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") // room_member 테이블의 user_id 컬럼이 users 테이블의 id를 참조 + @JoinColumn(name = "user_id", nullable = false) private User user; - // 방 내에서의 역할 (방장, 부방장, 멤버, 방문객) @Enumerated(EnumType.STRING) @Column(nullable = false) private RoomRole role = RoomRole.VISITOR; - // 멤버십 기본 정보 @Column(nullable = false) - private LocalDateTime joinedAt; // 방에 처음 입장한 시간 - private LocalDateTime lastActiveAt; // 마지막으로 활동한 시간 + private LocalDateTime joinedAt; // 방에 처음 가입한 시간 (불변) - // 실시간 상태 관리 필드들 - @Column(nullable = false) - private boolean isOnline = false; // 현재 방에 온라인 상태인지 - - private String connectionId; // WebSocket 연결 ID (실시간 통신용) - - private LocalDateTime lastHeartbeat; // 마지막 heartbeat 시간 (연결 상태 확인용) + private LocalDateTime lastActiveAt; // 마지막 활동 시간 (참고용, 정확성 낮음) - // 💡 권한 확인 메서드들 (RoomRole enum의 메서드를 위임) + // ==================== 권한 확인 메서드 ==================== /** - * 방 관리 권한이 있는지 확인 (방장, 부방장) - 방 설정 변경, 공지사항 작성 등의 권한이 필요할 때 + * 방 관리 권한 확인 (방장, 부방장) + * 사용: 방 설정 변경, 공지사항 작성 등 */ public boolean canManageRoom() { return role.canManageRoom(); } /** - * 멤버 추방 권한이 있는지 확인 (방장, 부방장) - 다른 멤버를 추방하려고 할 때 + * 멤버 추방 권한 확인 (방장, 부방장) + * 사용: 다른 멤버를 추방할 때 */ public boolean canKickMember() { return role.canKickMember(); } /** - * 공지사항 관리 권한이 있는지 확인 (방장, 부방장) - 공지사항을 작성하거나 삭제할 때 + * 공지사항 관리 권한 확인 (방장, 부방장) + * 사용: 공지사항 작성/삭제 */ public boolean canManageNotices() { return role.canManageNotices(); } /** - * 방장인지 확인 - 방 소유자만 가능한 작업 (방 삭제, 호스트 권한 이양 등) + * 방장 여부 확인 + * 사용: 방 삭제, 호스트 권한 이양 등 */ public boolean isHost() { return role.isHost(); } /** - * 정식 멤버인지 확인 (방문객이 아닌 멤버, 부방장, 방장) - 멤버만 접근 가능한 기능 (파일 업로드, 학습 기록 등) + * 정식 멤버 여부 확인 (방문객이 아닌 멤버, 부방장, 방장) + * 사용: 멤버만 접근 가능한 기능 */ public boolean isMember() { return role.isMember(); } - /** - * 현재 활성 상태인지 확인 - 온라인 멤버 목록 표시, 비활성 사용자 정리 등 - 온라인 상태이고 최근 설정된 시간 이내에 heartbeat가 있었던 경우 - */ - public boolean isActive(int timeoutMinutes) { - return isOnline && lastHeartbeat != null && - lastHeartbeat.isAfter(LocalDateTime.now().minusMinutes(timeoutMinutes)); - } - + // ==================== 정적 팩토리 메서드 ==================== /** - 기본 멤버 생성 메서드, 처음 입장 시 사용 + * 기본 멤버 생성 */ - public static RoomMember create(Room room, User user, RoomRole role) { + private static RoomMember create(Room room, User user, RoomRole role) { RoomMember member = new RoomMember(); member.room = room; member.user = user; member.role = role; member.joinedAt = LocalDateTime.now(); member.lastActiveAt = LocalDateTime.now(); - member.isOnline = true; // 생성 시 온라인 상태 - member.lastHeartbeat = LocalDateTime.now(); - return member; } - // 방장 멤버 생성 -> 새로운 방을 생성할 때 방 생성자를 방장으로 등록 + /** + * 방장 멤버 생성 + * 사용: 방 생성 시 생성자를 방장으로 등록 + */ public static RoomMember createHost(Room room, User user) { return create(room, user, RoomRole.HOST); } /** - * 일반 멤버 생성, 권한 자동 변경 - - 비공개 방에서 초대받은 사용자를 정식 멤버로 등록할 때 (로직 검토 중) + * 일반 멤버 생성 + * 사용: 비공개 방에 초대된 사용자를 정식 멤버로 등록 */ public static RoomMember createMember(Room room, User user) { return create(room, user, RoomRole.MEMBER); @@ -136,59 +112,27 @@ public static RoomMember createMember(Room room, User user) { /** * 방문객 생성 - * 사용 상황: 공개 방에 처음 입장하는 사용자를 임시 방문객으로 등록 + * 사용: 공개 방에 처음 입장하는 사용자를 임시 방문객으로 등록 */ public static RoomMember createVisitor(Room room, User user) { return create(room, user, RoomRole.VISITOR); } + // ==================== 상태 변경 메서드 ==================== + /** - * 멤버의 역할 변경 - 방장이 멤버를 부방장으로 승격시키거나 강등시킬 때 + * 멤버 역할 변경 + * 사용: 방장이 멤버를 승격/강등시킬 때 */ public void updateRole(RoomRole newRole) { this.role = newRole; } /** - * 온라인 상태 변경 - * 사용 상황: 멤버가 방에 입장하거나 퇴장할 때 - 활동 시간도 함께 업데이트, 온라인이 되면 heartbeat도 갱신 + * 마지막 활동 시간 업데이트 + * 참고용이며, 정확한 활동 추적은 Redis의 WebSocketSessionManager를 사용하세요. */ - public void updateOnlineStatus(boolean online) { - this.isOnline = online; + public void updateLastActivity() { this.lastActiveAt = LocalDateTime.now(); - if (online) { - this.lastHeartbeat = LocalDateTime.now(); - } - } - - /** - * WebSocket 연결 ID 업데이트 - * 사용 상황: 멤버가 웹소켓으로 방에 연결될 때 - + heartbeat도 함께 갱신 - */ - public void updateConnectionId(String connectionId) { - this.connectionId = connectionId; - this.lastHeartbeat = LocalDateTime.now(); - } - - /** - * 사용 : 클라이언트에서 주기적으로 서버에 연결 상태를 알릴 때 - * 목적: 연결이 끊어진 멤버를 자동으로 감지하기 위해 사용, 별도의 다른 것으로 변경 가능 - */ - public void heartbeat() { - this.lastHeartbeat = LocalDateTime.now(); - this.lastActiveAt = LocalDateTime.now(); - this.isOnline = true; - } - - /** - * 방 퇴장 처리 (명시적 퇴장과 연결 끊김 상태 로직 분할 예정임.. 일단은 임시로 통합 상태) - 멤버가 방을 나가거나 연결이 끊어졌을 때, 오프라인 상태로 변경하고 연결 ID 제거 - */ - public void leave() { - this.isOnline = false; - this.connectionId = null; } } diff --git a/src/main/java/com/back/domain/studyroom/entity/RoomRole.java b/src/main/java/com/back/domain/studyroom/entity/RoomRole.java index ff7f4b9e..b1f6e02a 100644 --- a/src/main/java/com/back/domain/studyroom/entity/RoomRole.java +++ b/src/main/java/com/back/domain/studyroom/entity/RoomRole.java @@ -26,6 +26,10 @@ public enum RoomRole { public String getDescription() { return description; } + + public String getDisplayName() { + return description; + } /* 방 관리 권한 확인 (방 설정 변경, 공지사항 관리) diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java index 213f3890..4c3fae32 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepository.java @@ -4,12 +4,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.util.Optional; - +/** + * 현재.. ) + findByConnectionId 메서드 제거 (connectionId 필드 제거로 인해) + 실시간 상태 관련 쿼리 메서드 제거 + 커스텀 쿼리는 RoomMemberRepositoryCustom에서 관리 + */ @Repository public interface RoomMemberRepository extends JpaRepository, RoomMemberRepositoryCustom { - /** - * WebSocket 연결 ID로 멤버 조회 - */ - Optional findByConnectionId(String connectionId); + // 기본 CRUD는 JpaRepository가 제공 + // 커스텀 쿼리는 RoomMemberRepositoryCustom에 정의 } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java index 7740e0d1..9d64966a 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java @@ -5,7 +5,14 @@ import java.util.List; import java.util.Optional; - +import java.util.Set; + +/** + 멤버십 정보 조회 (영구 데이터) + 역할 기반 쿼리 + 실시간 온라인 상태 (WebSocketSessionManager 사용) + 온라인 상태는 Redis(WebSocketSessionManager)에서 관리 + */ public interface RoomMemberRepositoryCustom { /** @@ -19,19 +26,13 @@ public interface RoomMemberRepositoryCustom { List findByRoomIdOrderByRole(Long roomId); /** - * 방의 온라인 멤버 조회 - */ - List findOnlineMembersByRoomId(Long roomId); - - /** - * 방의 활성 멤버 수 조회 - */ - int countActiveMembersByRoomId(Long roomId); - - /** - * 사용자가 참여 중인 모든 방의 멤버십 조회 + * 방의 멤버 중 특정 사용자 ID 목록에 해당하는 멤버만 조회 + WebSocket에서 온라인 사용자 ID 목록을 받아와서 해당 멤버들의 상세 정보 조회 + * @param roomId 방 ID + * @param userIds 조회할 사용자 ID 목록 + * @return 해당하는 멤버 목록 */ - List findActiveByUserId(Long userId); + List findByRoomIdAndUserIdIn(Long roomId, Set userIds); /** * 특정 역할의 멤버 조회 @@ -62,14 +63,4 @@ public interface RoomMemberRepositoryCustom { * 특정 역할의 멤버 수 조회 */ int countByRoomIdAndRole(Long roomId, RoomRole role); - - /** - * 방 퇴장 처리 (벌크 업데이트) - */ - void leaveRoom(Long roomId, Long userId); - - /** - * 방의 모든 멤버를 오프라인 처리 (방 종료 시) - */ - void disconnectAllMembers(Long roomId); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java index 81bd17a7..1540ddce 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java @@ -10,33 +10,24 @@ import java.util.List; import java.util.Optional; +import java.util.Set; /** - * 주요 기능: - * - 방별/사용자별 멤버십 조회 - * - 역할(Role)별 멤버 필터링 - * - 온라인 상태 관리 - * - JOIN FETCH를 통한 N+1 문제 해결 - * - 벌크 업데이트 쿼리 + 방별/사용자별 멤버십 조회 + isOnline, connectionId 필드 제거 + 실시간 상태 관련 메서드 제거 (Redis로 이관) + 벌크 업데이트 쿼리 제거 (불필요) */ @Repository @RequiredArgsConstructor public class RoomMemberRepositoryImpl implements RoomMemberRepositoryCustom { private final JPAQueryFactory queryFactory; - - // QueryDSL Q 클래스 인스턴스 private final QRoomMember roomMember = QRoomMember.roomMember; private final QUser user = QUser.user; /** * 방의 특정 사용자 멤버십 조회 - * - 사용자가 특정 방에 참여 중인지 확인 - * - 방 입장 시 기존 멤버십 존재 여부 확인 - * - 사용자의 방 내 역할 확인 - * @param roomId 방 ID - * @param userId 사용자 ID - * @return 멤버십 정보 (Optional) */ @Override public Optional findByRoomIdAndUserId(Long roomId, Long userId) { @@ -53,43 +44,45 @@ public Optional findByRoomIdAndUserId(Long roomId, Long userId) { /** * 방의 모든 멤버 조회 (역할순 정렬) - * - 1순위: 역할 (HOST > SUB_HOST > MEMBER > VISITOR) - * - 2순위: 입장 시간 (먼저 입장한 순) - * - 방 설정 페이지에서 전체 멤버 목록 표시 - * - 멤버 관리 기능 - * @param roomId 방 ID - * @return 정렬된 멤버 목록 + * 정렬 우선순위: + 1. 역할 (HOST > SUB_HOST > MEMBER > VISITOR) + 2. 가입 시간 (먼저 가입한 순) */ @Override public List findByRoomIdOrderByRole(Long roomId) { return queryFactory .selectFrom(roomMember) + .leftJoin(roomMember.user, user).fetchJoin() // N+1 방지 .where(roomMember.room.id.eq(roomId)) .orderBy( roomMember.role.asc(), // 역할순 (HOST가 먼저) - roomMember.joinedAt.asc() // 입장 시간순 + roomMember.joinedAt.asc() // 가입 시간순 ) .fetch(); } /** - * 방의 온라인 멤버 조회 - * - 현재 온라인 상태인 멤버만 (isOnline = true) - * - 1순위: 역할 (HOST > SUB_HOST > MEMBER > VISITOR) - * - 2순위: 마지막 활동 시간 (최근 활동 순) - * - 방 상세 페이지에서 현재 접속 중인 멤버 표시 - * - 실시간 멤버 목록 업데이트 + 방의 멤버 중 특정 사용자 ID 목록에 해당하는 멤버만 조회 + * 현재 음 구현한 시나리오 로직: + 1. WebSocketSessionManager에서 온라인 사용자 ID 목록 조회 (Redis) + 2. 이 메서드로 해당 ID들의 상세 멤버 정보 조회 (DB) + 3. RoomMemberResponse DTO로 변환하여 클라이언트에 반환 * @param roomId 방 ID - * @return 온라인 멤버 목록 + * @param userIds 조회할 사용자 ID 목록 (Redis에서 가져온 온라인 사용자) + * @return 해당하는 멤버 목록 (역할순 정렬) */ @Override - public List findOnlineMembersByRoomId(Long roomId) { + public List findByRoomIdAndUserIdIn(Long roomId, Set userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + return queryFactory .selectFrom(roomMember) .leftJoin(roomMember.user, user).fetchJoin() // N+1 방지 .where( roomMember.room.id.eq(roomId), - roomMember.isOnline.eq(true) + roomMember.user.id.in(userIds) ) .orderBy( roomMember.role.asc(), // 역할순 @@ -98,63 +91,14 @@ public List findOnlineMembersByRoomId(Long roomId) { .fetch(); } - /** - * 방의 활성 멤버 수 조회 - * - 현재 온라인 상태인 멤버 (isOnline = true) - * - 방 목록에서 현재 참가자 수 표시 - * - 정원 체크 (현재 참가자 vs 최대 참가자) - * - 통계 데이터 수집 로직 구현 시 연결 해야함.. - * @param roomId 방 ID - * @return 활성 멤버 수 - */ - @Override - public int countActiveMembersByRoomId(Long roomId) { - Long count = queryFactory - .select(roomMember.count()) - .from(roomMember) - .where( - roomMember.room.id.eq(roomId), - roomMember.isOnline.eq(true) - ) - .fetchOne(); - - return count != null ? count.intValue() : 0; - } - - /** - * 사용자가 참여 중인 모든 방의 멤버십 조회 - * @param userId 사용자 ID - * @return 참여 중인 방의 멤버십 목록 - */ - @Override - public List findActiveByUserId(Long userId) { - return queryFactory - .selectFrom(roomMember) - .where( - roomMember.user.id.eq(userId), - roomMember.isOnline.eq(true) - ) - .fetch(); - } - /** * 특정 역할의 멤버 조회 - * - 방장(HOST) 찾기 - * - 부방장(SUB_HOST) 목록 조회 - * - 역할별 멤버 필터링 - * 예시: - * ```java - * // 방의 모든 부방장 조회 - * List subHosts = findByRoomIdAndRole(roomId, RoomRole.SUB_HOST); - * ``` - * @param roomId 방 ID - * @param role 역할 (HOST, SUB_HOST, MEMBER, VISITOR) - * @return 해당 역할의 멤버 목록 */ @Override public List findByRoomIdAndRole(Long roomId, RoomRole role) { return queryFactory .selectFrom(roomMember) + .leftJoin(roomMember.user, user).fetchJoin() .where( roomMember.room.id.eq(roomId), roomMember.role.eq(role) @@ -164,17 +108,12 @@ public List findByRoomIdAndRole(Long roomId, RoomRole role) { /** * 방장 조회 - * - 방장 권한 확인 - * - 방 소유자 정보 표시 - * - 정상적인 방이라면 반드시 방장이 1명 존재 - * - Optional.empty()인 경우는 데이터 오류 상태 - * @param roomId 방 ID - * @return 방장 멤버십 (Optional) */ @Override public Optional findHostByRoomId(Long roomId) { RoomMember host = queryFactory .selectFrom(roomMember) + .leftJoin(roomMember.user, user).fetchJoin() .where( roomMember.room.id.eq(roomId), roomMember.role.eq(RoomRole.HOST) @@ -186,17 +125,12 @@ public Optional findHostByRoomId(Long roomId) { /** * 관리자 권한을 가진 멤버들 조회 (HOST, SUB_HOST) - * - HOST: 방장 (최고 권한) - * - SUB_HOST: 부방장 (방장이 위임한 권한) - * - 관리자 목록 표시 - * - 권한 체크 (이 목록에 있는 사용자만 특정 작업 가능) - * @param roomId 방 ID - * @return 관리자 멤버 목록 (HOST, SUB_HOST) */ @Override public List findManagersByRoomId(Long roomId) { return queryFactory .selectFrom(roomMember) + .leftJoin(roomMember.user, user).fetchJoin() .where( roomMember.room.id.eq(roomId), roomMember.role.in(RoomRole.HOST, RoomRole.SUB_HOST) @@ -207,19 +141,6 @@ public List findManagersByRoomId(Long roomId) { /** * 사용자가 특정 방에서 관리자 권한을 가지고 있는지 확인 - * - HOST 또는 SUB_HOST 역할 - * - 방 설정 변경 권한 체크 - * - 멤버 추방 권한 체크 - * - 공지사항 작성 권한 체크 - * 사용 예시: - * ```java - * if (!roomMemberRepository.isManager(roomId, userId)) { - * throw new CustomException(ErrorCode.NOT_ROOM_MANAGER); - * } - * ``` - * @param roomId 방 ID - * @param userId 사용자 ID - * @return 관리자 권한 여부 */ @Override public boolean isManager(Long roomId, Long userId) { @@ -238,19 +159,6 @@ public boolean isManager(Long roomId, Long userId) { /** * 사용자가 이미 해당 방의 멤버인지 확인 - * ( 해당 로직 활용해서 유저 밴 등으로 추후에 확장 가능) - * - 방 입장 전 중복 참여 체크 - * - 비공개 방 접근 권한 확인 - * - 멤버 전용 기능 접근 권한 확인 - * 사용 예시: - * ```java - * if (room.isPrivate() && !roomMemberRepository.existsByRoomIdAndUserId(roomId, userId)) { - * throw new CustomException(ErrorCode.ROOM_FORBIDDEN); - * } - * ``` - * @param roomId 방 ID - * @param userId 사용자 ID - * @return 멤버 여부 */ @Override public boolean existsByRoomIdAndUserId(Long roomId, Long userId) { @@ -267,19 +175,7 @@ public boolean existsByRoomIdAndUserId(Long roomId, Long userId) { } /** - * 특정 역할의 온라인 멤버 수 조회 - * - 특정 역할의 멤버 - * - 현재 온라인 상태만 - * 예시: - * ```java - * int hostCount = countByRoomIdAndRole(roomId, RoomRole.HOST); - * if (hostCount == 0) { - * - * } - * ``` - * @param roomId 방 ID - * @param role 역할 - * @return 해당 역할의 온라인 멤버 수 + * 특정 역할의 멤버 수 조회 */ @Override public int countByRoomIdAndRole(Long roomId, RoomRole role) { @@ -288,85 +184,10 @@ public int countByRoomIdAndRole(Long roomId, RoomRole role) { .from(roomMember) .where( roomMember.room.id.eq(roomId), - roomMember.role.eq(role), - roomMember.isOnline.eq(true) + roomMember.role.eq(role) ) .fetchOne(); return count != null ? count.intValue() : 0; } - - /** - * 방 퇴장 처리 (벌크 업데이트) - * - isOnline을 false로 변경 - * - connectionId를 null로 초기화 - * - * ai 코드 리뷰 결과 : - * - 한 번의 쿼리로 처리하여 성능 최적화 상태 - * - 벌크 연산은 영속성 컨텍스트를 무시 - * - 이후 해당 엔티티를 조회하면 DB와 불일치 가능 - * - 필요시 em.clear() 또는 em.refresh() 사용 - * ( 추후 기초 기능 개발 완료 후 개선 예정) - * - * - 사용자가 명시적으로 방을 나갈 때 - * - WebSocket 연결 끊김 감지 시 - * - 타임아웃으로 자동 퇴장 처리 시 - * @param roomId 방 ID - * @param userId 사용자 ID - */ - @Override - public void leaveRoom(Long roomId, Long userId) { - queryFactory - .update(roomMember) - .set(roomMember.isOnline, false) - .setNull(roomMember.connectionId) - .where( - roomMember.room.id.eq(roomId), - roomMember.user.id.eq(userId) - ) - .execute(); - } - - /** - * 방의 모든 멤버를 오프라인 처리 (방 종료 시) - * - 해당 방의 모든 멤버를 오프라인으로 변경 - * - 모든 멤버의 connectionId 제거 - * - * - 방장이 방을 종료할 때 - * - 방이 자동으로 종료될 때 (참가자 0명 + 일정 시간 경과) - * - 긴급 상황으로 방을 강제 종료할 때 - * - * ai 코드 리뷰 결과 : - * 해당 부분도 쿼리 한번으로 동작되는 내용이기 때문에, - * 그렇게 동작 시에는 웹소켓에 미리 종료 알림을 주는 형식으로 변경하라고 함. - * 이 작업 후 방 상태를 TERMINATED로 변경해야 함 - * - * 사용 예시: - * ```java - * @Transactional - * public void terminateRoom(Long roomId) { - * Room room = roomRepository.findById(roomId)...; - * - * // 모든 멤버 오프라인 처리 - * roomMemberRepository.disconnectAllMembers(roomId); - * - * // 방 종료 - * room.terminate(); - * - * // WebSocket으로 종료 알림 - * notifyRoomTermination(roomId); - * } - * ``` - * - * @param roomId 방 ID - */ - @Override - public void disconnectAllMembers(Long roomId) { - queryFactory - .update(roomMember) - .set(roomMember.isOnline, false) - .setNull(roomMember.connectionId) - .where(roomMember.room.id.eq(roomId)) - .execute(); - } } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java index dea97567..b6cea45b 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepository.java @@ -38,10 +38,6 @@ public interface RoomRepository extends JpaRepository, RoomRepositor @Query("SELECT r FROM Room r WHERE r.id = :roomId AND r.isPrivate = true AND r.password = :password") Optional findByIdAndPassword(@Param("roomId") Long roomId, @Param("password") String password); - // 참가자 수 업데이트 - @Modifying - @Query("UPDATE Room r SET r.currentParticipants = " + - "(SELECT COUNT(rm) FROM RoomMember rm WHERE rm.room.id = r.id AND rm.isOnline = true) " + - "WHERE r.id = :roomId") - void updateCurrentParticipants(@Param("roomId") Long roomId); + // 참가자 수는 Room.incrementParticipant/decrementParticipant로 관리 (다음 커밋때 제거 예정) + // 온라인 상태는 Redis(WebSocketSessionManager)에서 관리하므로 이 쿼리는 제거 (다음 커밋때 제거 예정) } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java index bf0e81c2..3095285e 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomRepositoryImpl.java @@ -75,12 +75,10 @@ public Page findJoinablePublicRooms(Pageable pageable) { } /** - * 사용자가 참여 중인 방 조회 - * 조회 조건: - * - 특정 사용자가 멤버로 등록된 방 - * - 현재 온라인 상태인 방만 + * 사용자가 참여 중인 방 조회(모든 방) + 온라인/오프라인 상태는 Redis에서 관리하므로, 여기서는 멤버십만 확인 * @param userId 사용자 ID - * @return 참여 중인 방 목록 + * @return 참여 중인 방 목록 (멤버로 등록된 모든 방) */ @Override public List findRoomsByUserId(Long userId) { @@ -89,8 +87,7 @@ public List findRoomsByUserId(Long userId) { .leftJoin(room.createdBy, user).fetchJoin() // N+1 방지 .join(room.roomMembers, roomMember) // 멤버 조인 .where( - roomMember.user.id.eq(userId), - roomMember.isOnline.eq(true) + roomMember.user.id.eq(userId) ) .fetch(); } diff --git a/src/main/java/com/back/domain/studyroom/service/RoomService.java b/src/main/java/com/back/domain/studyroom/service/RoomService.java index c7d021e4..01699d47 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -1,12 +1,16 @@ package com.back.domain.studyroom.service; import com.back.domain.studyroom.config.StudyRoomProperties; +import com.back.domain.studyroom.dto.RoomBroadcastMessage; +import com.back.domain.studyroom.dto.RoomMemberResponse; import com.back.domain.studyroom.entity.*; import com.back.domain.studyroom.repository.*; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; +import com.back.global.websocket.service.WebSocketBroadcastService; +import com.back.global.websocket.service.WebSocketSessionManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -16,19 +20,43 @@ import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; /** - - 방 생성, 입장, 퇴장 로직 처리 - - 멤버 권한 관리 (승격, 강등, 추방) - - 방 상태 관리 (활성화, 일시정지, 종료) - - 방장 위임 로직 (방장이 나갈 때 자동 위임) - - 실시간 참가자 수 동기화 - - - 모든 권한 검증을 서비스 레이어에서 처리 - - 비공개 방 접근 권한 체크 - - 방장/부방장 권한이 필요한 작업들의 권한 검증 - - * 설정값 주입을 StudyRoomProperties를 통해 외부 설정 관리 + * 스터디룸 서비스 - 방 생성, 입장, 퇴장 및 실시간 상태 관리 + * + *

주요 기능:

+ *
    + *
  • 방 생성, 입장, 퇴장 로직 처리
  • + *
  • 멤버 권한 관리 (승격, 강등, 추방)
  • + *
  • 방 상태 관리 (활성화, 일시정지, 종료)
  • + *
  • 방장 위임 로직 (방장이 나갈 때 자동 위임)
  • + *
  • 🆕 WebSocket 기반 실시간 참가자 수 및 온라인 상태 동기화
  • + *
+ * + *

권한 검증:

+ *
    + *
  • 모든 권한 검증을 서비스 레이어에서 처리
  • + *
  • 비공개 방 접근 권한 체크
  • + *
  • 방장/부방장 권한이 필요한 작업들의 권한 검증
  • + *
+ * + *

🆕 WebSocket 연동 (PR #2):

+ *
    + *
  • 권장 메서드: {@link #getOnlineMembersWithWebSocket(Long, Long)} - WebSocket + DB 통합 조회
  • + *
  • Deprecated: {@link #getRoomMembers(Long, Long)} - DB만 조회 (하위 호환용으로만 유지)
  • + *
+ * + *

중요: 온라인 멤버 목록 조회 시 반드시 {@code getOnlineMembersWithWebSocket()}를 사용하세요. + * 이 메서드는 실시간 WebSocket 연결 상태와 DB 정보를 결합하여 정확한 온라인 상태를 제공합니다.

+ * + *

설정값 관리:

+ *

StudyRoomProperties를 통해 외부 설정 관리 (application.yml)

+ * + * @since 1.0 + * @see WebSocketSessionManager WebSocket 세션 관리 + * @see WebSocketBroadcastService 실시간 브로드캐스트 */ @Service @RequiredArgsConstructor @@ -40,6 +68,8 @@ public class RoomService { private final RoomMemberRepository roomMemberRepository; private final UserRepository userRepository; private final StudyRoomProperties properties; + private final WebSocketSessionManager sessionManager; + private final WebSocketBroadcastService broadcastService; /** * 방 생성 메서드 @@ -48,16 +78,16 @@ public class RoomService { * 2. Room 엔티티 생성 (외부 설정값 적용) * 3. 방장을 RoomMember로 등록 * 4. 참가자 수 1로 설정 - + *

* 기본 설정: - - 상태: WAITING (대기 중) - - 카메라/오디오/화면공유: application.yml의 설정값 사용 - - 참가자 수: 0명에서 시작 후 방장 추가로 1명 + * - 상태: WAITING (대기 중) + * - 카메라/오디오/화면공유: application.yml의 설정값 사용 + * - 참가자 수: 0명에서 시작 후 방장 추가로 1명 */ @Transactional - public Room createRoom(String title, String description, boolean isPrivate, - String password, int maxParticipants, Long creatorId) { - + public Room createRoom(String title, String description, boolean isPrivate, + String password, int maxParticipants, Long creatorId) { + User creator = userRepository.findById(creatorId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); @@ -68,32 +98,34 @@ public Room createRoom(String title, String description, boolean isPrivate, roomMemberRepository.save(hostMember); savedRoom.incrementParticipant(); - - log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}", + + log.info("방 생성 완료 - RoomId: {}, Title: {}, CreatorId: {}", savedRoom.getId(), title, creatorId); - + return savedRoom; } /** * 방 입장 메서드 - * + *

* 입장 검증 과정: * 1. 방 존재 및 활성 상태 확인 (비관적 락으로 동시성 제어) * 2. 방 상태가 입장 가능한지 확인 (WAITING, ACTIVE) * 3. 정원 초과 여부 확인 * 4. 비공개 방인 경우 비밀번호 확인 * 5. 이미 참여 중인지 확인 (재입장 처리) - + *

* 멤버 등록: (현재는 visitor로 등록이지만 추후 역할 부여가 안된 인원을 visitor로 띄우는 식으로 저장 데이터 줄일 예정) * - 신규 사용자: VISITOR 역할로 등록 * - 기존 사용자: 온라인 상태로 변경 - * + *

* 동시성 제어: 비관적 락(PESSIMISTIC_WRITE)으로 정원 초과 방지 + *

+ * 🆕 WebSocket 연동: 입장 후 실시간 알림 및 세션 관리 */ @Transactional public RoomMember joinRoom(Long roomId, String password, Long userId) { - + // 비관적 락으로 방 조회 - 동시 입장 시 정원 초과 방지 Room room = roomRepository.findByIdWithLock(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); @@ -120,74 +152,118 @@ public RoomMember joinRoom(Long roomId, String password, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + RoomMember member; + boolean isReturningMember = false; + + // 기존 멤버십 확인 Optional existingMember = roomMemberRepository.findByRoomIdAndUserId(roomId, userId); + if (existingMember.isPresent()) { - RoomMember member = existingMember.get(); - if (member.isOnline()) { - throw new CustomException(ErrorCode.ALREADY_JOINED_ROOM); + // 이미 멤버인 경우 (재입장) + member = existingMember.get(); + + // Redis에서 이미 온라인인지 확인 + if (sessionManager.isUserConnected(userId)) { + Long currentRoomId = sessionManager.getUserCurrentRoomId(userId); + if (currentRoomId != null && currentRoomId.equals(roomId)) { + throw new CustomException(ErrorCode.ALREADY_JOINED_ROOM); + } } - member.updateOnlineStatus(true); + + // 마지막 활동 시간 업데이트 + member.updateLastActivity(); + room.incrementParticipant(); + isReturningMember = true; + + } else { + // 신규 멤버 생성 + member = RoomMember.createVisitor(room, user); + member = roomMemberRepository.save(member); room.incrementParticipant(); - return member; } - RoomMember newMember = RoomMember.createVisitor(room, user); - RoomMember savedMember = roomMemberRepository.save(newMember); + log.info("방 입장 완료 (DB 처리) - RoomId: {}, UserId: {}, Role: {}, 재입장: {}", + roomId, userId, member.getRole(), isReturningMember); - room.incrementParticipant(); - - log.info("방 입장 완료 - RoomId: {}, UserId: {}, Role: {}", - roomId, userId, newMember.getRole()); - - return savedMember; + return member; } /** * 방 나가기 메서드 - * + *

* 🚪 퇴장 처리: - * - 일반 멤버: 단순 오프라인 처리 및 참가자 수 감소 + * - 일반 멤버: 참가자 수 감소 * - 방장: 특별 처리 로직 실행 (handleHostLeaving) - * + *

* 🔄 방장 퇴장 시 처리: * - 다른 멤버가 없으면 → 방 자동 종료 * - 다른 멤버가 있으면 → 새 방장 자동 위임 + *

+ * 📝 참고: 실제 온라인 상태는 Redis에서 관리 */ @Transactional public void leaveRoom(Long roomId, Long userId) { - + Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); RoomMember member = roomMemberRepository.findByRoomIdAndUserId(roomId, userId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); - if (!member.isOnline()) { + // Redis에서 온라인 상태 확인 + boolean isCurrentlyOnline = sessionManager.isUserConnected(userId); + Long currentRoomId = sessionManager.getUserCurrentRoomId(userId); + + // 이 방에 있지 않으면 퇴장 처리 불필요 + if (!isCurrentlyOnline || currentRoomId == null || !currentRoomId.equals(roomId)) { + log.debug("이미 오프라인 상태이거나 다른 방에 있음 - UserId: {}, CurrentRoomId: {}", userId, currentRoomId); return; } + // 퇴장 전 멤버 정보 백업 (브로드캐스트용) + String memberName = member.getUser().getNickname(); + boolean wasHost = member.isHost(); + if (member.isHost()) { handleHostLeaving(room, member); } else { - member.leave(); + // 일반 멤버 퇴장 처리 room.decrementParticipant(); + member.updateLastActivity(); } - log.info("방 퇴장 완료 - RoomId: {}, UserId: {}", roomId, userId); + log.info("방 퇴장 완료 (DB 처리) - RoomId: {}, UserId: {}, 방장여부: {}", roomId, userId, wasHost); } private void handleHostLeaving(Room room, RoomMember hostMember) { - List onlineMembers = roomMemberRepository.findOnlineMembersByRoomId(room.getId()); + // Redis에서 실제 온라인 사용자 조회 + Set onlineUserIds = sessionManager.getOnlineUsersInRoom(room.getId()); - List otherOnlineMembers = onlineMembers.stream() - .filter(m -> !m.getId().equals(hostMember.getId())) - .toList(); + // 온라인 사용자 중 방장 제외 + Set otherOnlineUserIds = onlineUserIds.stream() + .filter(id -> !id.equals(hostMember.getUser().getId())) + .collect(Collectors.toSet()); - if (otherOnlineMembers.isEmpty()) { + if (otherOnlineUserIds.isEmpty()) { + // 다른 온라인 멤버가 없으면 방 종료 room.terminate(); - hostMember.leave(); room.decrementParticipant(); + + log.info("방 자동 종료 (온라인 멤버 없음) - RoomId: {}", room.getId()); + + // 방 종료 알림 브로드캐스트 + try { + broadcastService.broadcastToRoom(room.getId(), RoomBroadcastMessage.roomTerminated(room.getId())); + } catch (Exception e) { + log.warn("방 종료 브로드캐스트 실패 - 방: {}", room.getId(), e); + } + } else { + // 다른 온라인 멤버가 있으면 새 방장 선정 + List otherOnlineMembers = roomMemberRepository + .findByRoomIdAndUserIdIn(room.getId(), otherOnlineUserIds); + + // 우선순위: 부방장 > 가장 먼저 가입한 멤버 RoomMember newHost = otherOnlineMembers.stream() .filter(m -> m.getRole() == RoomRole.SUB_HOST) .findFirst() @@ -197,11 +273,18 @@ private void handleHostLeaving(Room room, RoomMember hostMember) { if (newHost != null) { newHost.updateRole(RoomRole.HOST); - hostMember.leave(); room.decrementParticipant(); - - log.info("새 방장 지정 - RoomId: {}, NewHostId: {}", + + log.info("새 방장 지정 - RoomId: {}, NewHostId: {}", room.getId(), newHost.getUser().getId()); + + // 새 방장 지정 알림 브로드캐스트 + try { + broadcastService.broadcastToRoom(room.getId(), + RoomBroadcastMessage.hostChanged(room.getId(), newHost)); + } catch (Exception e) { + log.warn("새 방장 지정 브로드캐스트 실패 - 방: {}", room.getId(), e); + } } } } @@ -229,10 +312,10 @@ public List getUserRooms(Long userId) { } @Transactional - public void updateRoomSettings(Long roomId, String title, String description, - int maxParticipants, boolean allowCamera, - boolean allowAudio, boolean allowScreenShare, Long userId) { - + public void updateRoomSettings(Long roomId, String title, String description, + int maxParticipants, boolean allowCamera, + boolean allowAudio, boolean allowScreenShare, Long userId) { + Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); @@ -244,15 +327,24 @@ public void updateRoomSettings(Long roomId, String title, String description, throw new CustomException(ErrorCode.BAD_REQUEST); } - room.updateSettings(title, description, maxParticipants, - allowCamera, allowAudio, allowScreenShare); - + room.updateSettings(title, description, maxParticipants, + allowCamera, allowAudio, allowScreenShare); + + // 🆕 방 설정 변경 알림 브로드캐스트 + try { + String updateMessage = String.format("방 설정이 변경되었습니다. (제목: %s, 최대인원: %d명)", + title, maxParticipants); + broadcastService.broadcastRoomUpdate(roomId, updateMessage); + } catch (Exception e) { + log.warn("방 설정 변경 브로드캐스트 실패 - 방: {}", roomId, e); + } + log.info("방 설정 변경 완료 - RoomId: {}, UserId: {}", roomId, userId); } @Transactional public void terminateRoom(Long roomId, Long userId) { - + Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); @@ -261,14 +353,30 @@ public void terminateRoom(Long roomId, Long userId) { } room.terminate(); - roomMemberRepository.disconnectAllMembers(roomId); - + + // 방 종료 알림 브로드캐스트 (WebSocket 세션 정리 전에 알림 전송) + try { + broadcastService.broadcastToRoom(roomId, RoomBroadcastMessage.roomTerminated(roomId)); + } catch (Exception e) { + log.warn("방 종료 브로드캐스트 실패 - 방: {}", roomId, e); + } + + // Redis에서 모든 세션 정리 (자동으로 방에서 퇴장 처리됨) + Set onlineUserIds = sessionManager.getOnlineUsersInRoom(roomId); + for (Long onlineUserId : onlineUserIds) { + try { + sessionManager.leaveRoom(onlineUserId, roomId); + } catch (Exception e) { + log.warn("방 종료 시 세션 정리 실패 - 방: {}, 사용자: {}", roomId, onlineUserId, e); + } + } + log.info("방 종료 완료 - RoomId: {}, UserId: {}", roomId, userId); } @Transactional public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Long requesterId) { - + RoomMember requester = roomMemberRepository.findByRoomIdAndUserId(roomId, requesterId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); @@ -284,13 +392,39 @@ public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Lon } targetMember.updateRole(newRole); - - log.info("멤버 권한 변경 완료 - RoomId: {}, TargetUserId: {}, NewRole: {}, RequesterId: {}", + + // 🆕 멤버 역할 변경 알림 브로드캐스트 + try { + broadcastService.broadcastToRoom(roomId, RoomBroadcastMessage.memberRoleChanged(roomId, targetMember)); + } catch (Exception e) { + log.warn("멤버 역할 변경 브로드캐스트 실패 - 방: {}", roomId, e); + } + + log.info("멤버 권한 변경 완료 - RoomId: {}, TargetUserId: {}, NewRole: {}, RequesterId: {}", roomId, targetUserId, newRole, requesterId); } - public List getRoomMembers(Long roomId, Long userId) { - + /** + * WebSocket 기반 온라인 멤버 목록 조회 + * + *

동작 방식:

+ *
    + *
  1. Redis에서 온라인 사용자 ID 목록 조회 (실시간 상태)
  2. + *
  3. DB에서 해당 ID들의 멤버 상세 정보 조회
  4. + *
  5. 두 정보를 결합하여 RoomMemberResponse DTO 반환
  6. + *
+ * + *

Redis가 Single Source of Truth:

+ *

온라인 상태는 Redis만 신뢰하며, DB는 멤버십 정보만 제공

+ * + * @param roomId 조회할 방의 ID + * @param userId 요청한 사용자의 ID (권한 체크용) + * @return 실시간 온라인 멤버 목록 + * @throws CustomException ROOM_NOT_FOUND - 방이 존재하지 않음 + * @throws CustomException ROOM_FORBIDDEN - 비공개 방에 대한 접근 권한 없음 + */ + public List getOnlineMembersWithWebSocket(Long roomId, Long userId) { + Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); @@ -301,7 +435,38 @@ public List getRoomMembers(Long roomId, Long userId) { } } - return roomMemberRepository.findOnlineMembersByRoomId(roomId); + try { + // 1단계: Redis에서 온라인 사용자 ID 목록 조회 (실시간) + Set onlineUserIds = sessionManager.getOnlineUsersInRoom(roomId); + + if (onlineUserIds.isEmpty()) { + log.debug("온라인 멤버 없음 - 방: {}", roomId); + return List.of(); + } + + // 2단계: DB에서 해당 사용자들의 멤버 상세 정보 조회 + List onlineMembers = roomMemberRepository + .findByRoomIdAndUserIdIn(roomId, onlineUserIds); + + // 3단계: DTO 변환 + List response = onlineMembers.stream() + .map(RoomMemberResponse::from) + .collect(Collectors.toList()); + + log.debug("온라인 멤버 조회 성공 - 방: {}, Redis: {}명, DB 매칭: {}명", + roomId, onlineUserIds.size(), onlineMembers.size()); + + return response; + + } catch (CustomException e) { + // CustomException은 다시 던져서 상위에서 처리 + throw e; + + } catch (Exception e) { + log.error("온라인 멤버 조회 실패 - 방: {}, 오류: {}", roomId, e.getMessage(), e); + // 실패 시 빈 목록 반환 (서비스 중단 방지) + return List.of(); + } } public RoomRole getUserRoomRole(Long roomId, Long userId) { @@ -322,7 +487,7 @@ public Page getPopularRooms(Pageable pageable) { */ @Transactional public void kickMember(Long roomId, Long targetUserId, Long requesterId) { - + RoomMember requester = roomMemberRepository.findByRoomIdAndUserId(roomId, requesterId) .orElseThrow(() -> new CustomException(ErrorCode.NOT_ROOM_MEMBER)); @@ -337,13 +502,31 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { throw new CustomException(ErrorCode.CANNOT_KICK_HOST); } - targetMember.leave(); - + // 추방 전 멤버 정보 백업 (브로드캐스트용) + String memberName = targetMember.getUser().getNickname(); + Room room = roomRepository.findById(roomId) .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); - room.decrementParticipant(); - log.info("멤버 추방 완료 - RoomId: {}, TargetUserId: {}, RequesterId: {}", + // 참가자 수 감소 + room.decrementParticipant(); + + // WebSocket 세션 정리 (강제 퇴장) + try { + sessionManager.leaveRoom(targetUserId, roomId); + + // 멤버 추방 알림 브로드캐스트 + broadcastService.broadcastToRoom(roomId, RoomBroadcastMessage.memberKicked(roomId, memberName)); + + } catch (Exception e) { + log.warn("추방 처리 중 WebSocket 연동 실패 - 방: {}, 대상: {}", roomId, targetUserId, e); + } + + log.info("멤버 추방 완료 - RoomId: {}, TargetUserId: {}, RequesterId: {}", roomId, targetUserId, requesterId); } + + // ======================== 삭제 예정: WebSocket 헬퍼 메서드 ======================== + // 이 메서드들은 Phase 2에서 이벤트 기반으로 재구성될 예정입니다. + // 현재는 RoomService에서 직접 sessionManager와 broadcastService를 호출합니다. } diff --git a/src/main/java/com/back/global/websocket/service/WebSocketBroadcastService.java b/src/main/java/com/back/global/websocket/service/WebSocketBroadcastService.java new file mode 100644 index 00000000..e46cee55 --- /dev/null +++ b/src/main/java/com/back/global/websocket/service/WebSocketBroadcastService.java @@ -0,0 +1,102 @@ +package com.back.global.websocket.service; + +import com.back.domain.studyroom.dto.RoomBroadcastMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * WebSocket 브로드캐스트 전용 서비스 + * - 메시지 전송에만 집중 + * - SimpMessagingTemplate 의존성을 여기서만 관리 + * - 세션 관리와 분리하여 순환 참조 방지 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WebSocketBroadcastService { + + private final SimpMessagingTemplate messagingTemplate; + private final WebSocketSessionManager sessionManager; + + /** + * 특정 방의 모든 온라인 사용자에게 메시지 브로드캐스트 + */ + public void broadcastToRoom(Long roomId, RoomBroadcastMessage message) { + try { + Set onlineUsers = sessionManager.getOnlineUsersInRoom(roomId); + + if (onlineUsers.isEmpty()) { + log.debug("브로드캐스트 대상이 없음 - 방: {}", roomId); + return; + } + + // 방 전체 토픽으로 브로드캐스트 + String destination = "/topic/rooms/" + roomId + "/updates"; + messagingTemplate.convertAndSend(destination, message); + + log.info("방 브로드캐스트 완료 - 방: {}, 타입: {}, 대상: {}명", + roomId, message.getType(), onlineUsers.size()); + + } catch (Exception e) { + log.error("방 브로드캐스트 실패 - 방: {}, 타입: {}", roomId, message.getType(), e); + // 예외를 던지지 않고 로깅만 (브로드캐스트 실패가 핵심 기능을 막지 않도록) + } + } + + /** + * 특정 사용자에게 개인 메시지 전송 + */ + public void sendToUser(Long userId, String destination, Object message) { + try { + if (sessionManager.isUserConnected(userId)) { + messagingTemplate.convertAndSendToUser( + userId.toString(), + destination, + message + ); + + log.debug("개인 메시지 전송 완료 - 사용자: {}, 목적지: {}", userId, destination); + } else { + log.debug("오프라인 사용자에게 메시지 전송 시도 - 사용자: {}", userId); + } + } catch (Exception e) { + log.error("개인 메시지 전송 실패 - 사용자: {}", userId, e); + } + } + + /** + * 방의 온라인 멤버 목록 업데이트 브로드캐스트 + */ + public void broadcastOnlineMembersUpdate(Long roomId) { + try { + Set onlineUsers = sessionManager.getOnlineUsersInRoom(roomId); + + // 온라인 사용자 ID 목록을 브로드캐스트 + RoomBroadcastMessage message = RoomBroadcastMessage.onlineMembersUpdated( + roomId, + onlineUsers.stream().toList() + ); + + broadcastToRoom(roomId, message); + + } catch (Exception e) { + log.error("온라인 멤버 목록 브로드캐스트 실패 - 방: {}", roomId, e); + } + } + + /** + * 방 상태 변경 알림 브로드캐스트 + */ + public void broadcastRoomUpdate(Long roomId, String updateMessage) { + try { + RoomBroadcastMessage message = RoomBroadcastMessage.roomUpdated(roomId, updateMessage); + broadcastToRoom(roomId, message); + } catch (Exception e) { + log.error("방 상태 변경 브로드캐스트 실패 - 방: {}", roomId, e); + } + } +} diff --git a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java index 3e770c04..488a96da 100644 --- a/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java +++ b/src/main/java/com/back/global/websocket/service/WebSocketSessionManager.java @@ -16,6 +16,13 @@ import java.util.Set; import java.util.stream.Collectors; +/** + * WebSocket 세션 관리 전용 서비스 + * - Redis 기반 세션 상태 관리 + * - 방 입장/퇴장 처리 + * - 온라인 사용자 조회 + * - 브로드캐스트는 WebSocketBroadcastService로 분리 + */ @Slf4j @Service @RequiredArgsConstructor diff --git a/src/test/java/com/back/domain/chat/room/service/RoomChatServiceTest.java b/src/test/java/com/back/domain/chat/room/service/RoomChatServiceTest.java index c8bec684..c394a184 100644 --- a/src/test/java/com/back/domain/chat/room/service/RoomChatServiceTest.java +++ b/src/test/java/com/back/domain/chat/room/service/RoomChatServiceTest.java @@ -444,8 +444,9 @@ void t14() { Long userId = 1L; int deletedCount = 8; - // RoomMember 생성 (부방장) - RoomMember subHostMember = RoomMember.create(testRoom, testUser, RoomRole.SUB_HOST); + // RoomMember 생성 (부방장) - createMember로 생성 후 역할 변경 + RoomMember subHostMember = RoomMember.createMember(testRoom, testUser); + subHostMember.updateRole(RoomRole.SUB_HOST); // Mock 설정 given(roomRepository.findById(roomId)).willReturn(Optional.of(testRoom)); diff --git a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java index e1be5d07..4a3c1059 100644 --- a/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java +++ b/src/test/java/com/back/domain/studyroom/controller/RoomControllerTest.java @@ -194,7 +194,10 @@ void getRoomDetail() { given(currentUser.getUserId()).willReturn(1L); given(roomService.getRoomDetail(eq(1L), eq(1L))).willReturn(testRoom); - given(roomService.getRoomMembers(eq(1L), eq(1L))).willReturn(Arrays.asList(testMember)); + + // 🆕 변경: getRoomMembers() → getOnlineMembersWithWebSocket() + List memberResponses = Arrays.asList(RoomMemberResponse.from(testMember)); + given(roomService.getOnlineMembersWithWebSocket(eq(1L), eq(1L))).willReturn(memberResponses); // when ResponseEntity> response = roomController.getRoomDetail(1L); @@ -207,7 +210,7 @@ void getRoomDetail() { verify(currentUser, times(1)).getUserId(); verify(roomService, times(1)).getRoomDetail(eq(1L), eq(1L)); - verify(roomService, times(1)).getRoomMembers(eq(1L), eq(1L)); + verify(roomService, times(1)).getOnlineMembersWithWebSocket(eq(1L), eq(1L)); } @Test @@ -302,7 +305,9 @@ void getRoomMembers() { // given given(currentUser.getUserId()).willReturn(1L); - given(roomService.getRoomMembers(eq(1L), eq(1L))).willReturn(Arrays.asList(testMember)); + // 🆕 변경: getRoomMembers() → getOnlineMembersWithWebSocket() + List memberResponses = Arrays.asList(RoomMemberResponse.from(testMember)); + given(roomService.getOnlineMembersWithWebSocket(eq(1L), eq(1L))).willReturn(memberResponses); // when ResponseEntity>> response = roomController.getRoomMembers(1L); @@ -315,7 +320,7 @@ void getRoomMembers() { assertThat(response.getBody().getData().get(0).getNickname()).isEqualTo("테스트유저"); verify(currentUser, times(1)).getUserId(); - verify(roomService, times(1)).getRoomMembers(eq(1L), eq(1L)); + verify(roomService, times(1)).getOnlineMembersWithWebSocket(eq(1L), eq(1L)); } @Test diff --git a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java index 4ccd73dd..0e9702fb 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -46,6 +46,12 @@ class RoomServiceTest { @Mock private StudyRoomProperties properties; + + @Mock + private com.back.global.websocket.service.WebSocketSessionManager sessionManager; + + @Mock + private com.back.global.websocket.service.WebSocketBroadcastService broadcastService; @InjectMocks private RoomService roomService; @@ -185,9 +191,9 @@ void joinRoom_WrongPassword() { @DisplayName("방 나가기 - 성공") void leaveRoom_Success() { // given - testMember.updateOnlineStatus(true); given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(testMember)); + // Redis Mock은 별도로 설정하지 않음 (단위 테스트에서는 DB 로직만 검증) // when roomService.leaveRoom(1L, 1L); @@ -303,7 +309,7 @@ void updateRoomSettings_NotOwner() { void terminateRoom_Success() { // given given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); - willDoNothing().given(roomMemberRepository).disconnectAllMembers(1L); + // disconnectAllMembers는 제거되었으므로 stub 제거 // when roomService.terminateRoom(1L, 1L); @@ -311,7 +317,7 @@ void terminateRoom_Success() { // then assertThat(testRoom.getStatus()).isEqualTo(RoomStatus.TERMINATED); assertThat(testRoom.isActive()).isFalse(); - verify(roomMemberRepository, times(1)).disconnectAllMembers(1L); + // WebSocket 관련 검증은 통합 테스트에서 수행 } @Test diff --git a/src/test/java/com/back/global/websocket/WebSocketIntegrationTest.java b/src/test/java/com/back/global/websocket/WebSocketIntegrationTest.java new file mode 100644 index 00000000..f2ff5449 --- /dev/null +++ b/src/test/java/com/back/global/websocket/WebSocketIntegrationTest.java @@ -0,0 +1,343 @@ +package com.back.global.websocket; + +import com.back.domain.studyroom.dto.RoomBroadcastMessage; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.entity.RoomMember; +import com.back.domain.studyroom.service.RoomService; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.entity.UserProfile; +import com.back.domain.user.entity.UserStatus; +import com.back.domain.user.repository.UserRepository; +import com.back.global.security.jwt.JwtTokenProvider; +import com.back.global.websocket.service.WebSocketSessionManager; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.*; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; +import org.springframework.web.socket.sockjs.client.SockJsClient; +import org.springframework.web.socket.sockjs.client.WebSocketTransport; + +import java.lang.reflect.Type; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * WebSocket 통합 테스트 + * - 실제 WebSocket 연결 테스트 + * - 방 입장/퇴장 시 브로드캐스트 테스트 + * - Redis 세션 관리 테스트 + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@DisplayName("WebSocket 통합 테스트") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class WebSocketIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private RoomService roomService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Autowired + private WebSocketSessionManager sessionManager; + + @Autowired + private ObjectMapper objectMapper; + + private WebSocketStompClient stompClient; + private String wsUrl; + private User testUser; + private String accessToken; + private Room testRoom; + + @BeforeEach + void setUp() { + // WebSocket URL 설정 + wsUrl = String.format("ws://localhost:%d/ws", port); + + // STOMP 클라이언트 설정 + stompClient = new WebSocketStompClient( + new SockJsClient(List.of(new WebSocketTransport(new StandardWebSocketClient()))) + ); + stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + + // 테스트 사용자 생성 + testUser = createTestUser("testuser", "test@test.com"); + accessToken = jwtTokenProvider.createAccessToken( + testUser.getId(), + testUser.getUsername(), + testUser.getRole().name() + ); + + // 테스트 방 생성 + testRoom = roomService.createRoom( + "테스트 방", + "테스트 설명", + false, + null, + 10, + testUser.getId() + ); + } + + @AfterEach + void tearDown() { + // Redis 세션 정리 + if (sessionManager.isUserConnected(testUser.getId())) { + try { + sessionManager.leaveRoom(testUser.getId(), testRoom.getId()); + } catch (Exception ignored) { + } + } + } + + @Test + @Order(1) + @DisplayName("WebSocket 연결 및 인증 테스트") + void testWebSocketConnection() throws Exception { + // given + BlockingQueue messageQueue = new LinkedBlockingQueue<>(); + + StompHeaders connectHeaders = new StompHeaders(); + connectHeaders.add("Authorization", "Bearer " + accessToken); + + // when - 명시적으로 5개 파라미터 메서드 호출 + StompSession session = stompClient.connectAsync( + wsUrl, + (WebSocketHttpHeaders) null, // WebSocketHttpHeaders + connectHeaders, // StompHeaders + new StompSessionHandlerAdapter() { + @Override + public void afterConnected(StompSession session, StompHeaders connectedHeaders) { + messageQueue.add("CONNECTED"); + } + + @Override + public void handleException(StompSession session, StompCommand command, + StompHeaders headers, byte[] payload, Throwable exception) { + messageQueue.add("ERROR: " + exception.getMessage()); + } + }, + new Object[0] // varargs + ).get(5, TimeUnit.SECONDS); + + // then + String message = messageQueue.poll(3, TimeUnit.SECONDS); + assertThat(message).isEqualTo("CONNECTED"); + assertThat(session.isConnected()).isTrue(); + + // cleanup + session.disconnect(); + } + + @Test + @Order(2) + @DisplayName("방 입장 시 브로드캐스트 메시지 수신 테스트") + void testRoomJoinBroadcast() throws Exception { + // given + BlockingQueue messageQueue = new LinkedBlockingQueue<>(); + + StompHeaders connectHeaders = new StompHeaders(); + connectHeaders.add("Authorization", "Bearer " + accessToken); + + // WebSocket 연결 + StompSession session = stompClient.connectAsync( + wsUrl, + (WebSocketHttpHeaders) null, + connectHeaders, + new StompSessionHandlerAdapter() {}, + new Object[0] + ).get(5, TimeUnit.SECONDS); + + // 방 업데이트 채널 구독 + session.subscribe("/topic/rooms/" + testRoom.getId() + "/updates", new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return RoomBroadcastMessage.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + messageQueue.add((RoomBroadcastMessage) payload); + } + }); + + // when - 방에 입장 + RoomMember member = roomService.joinRoom(testRoom.getId(), null, testUser.getId()); + + // then - 브로드캐스트 메시지 수신 확인 + RoomBroadcastMessage message = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + assertThat(message.getType()).isEqualTo(RoomBroadcastMessage.BroadcastType.MEMBER_JOINED); + assertThat(message.getRoomId()).isEqualTo(testRoom.getId()); + + // 온라인 멤버 목록 업데이트 메시지도 수신 + RoomBroadcastMessage onlineMembersMessage = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(onlineMembersMessage).isNotNull(); + assertThat(onlineMembersMessage.getType()) + .isEqualTo(RoomBroadcastMessage.BroadcastType.ONLINE_MEMBERS_UPDATED); + + // cleanup + roomService.leaveRoom(testRoom.getId(), testUser.getId()); + session.disconnect(); + } + + @Test + @Order(3) + @DisplayName("방 퇴장 시 브로드캐스트 메시지 수신 테스트") + void testRoomLeaveBroadcast() throws Exception { + // given + BlockingQueue messageQueue = new LinkedBlockingQueue<>(); + + // 먼저 방에 입장 + roomService.joinRoom(testRoom.getId(), null, testUser.getId()); + + StompHeaders connectHeaders = new StompHeaders(); + connectHeaders.add("Authorization", "Bearer " + accessToken); + + // WebSocket 연결 + StompSession session = stompClient.connectAsync( + wsUrl, + (WebSocketHttpHeaders) null, + connectHeaders, + new StompSessionHandlerAdapter() {}, + new Object[0] + ).get(5, TimeUnit.SECONDS); + + // 방 업데이트 채널 구독 + session.subscribe("/topic/rooms/" + testRoom.getId() + "/updates", new StompFrameHandler() { + @Override + public Type getPayloadType(StompHeaders headers) { + return RoomBroadcastMessage.class; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + messageQueue.add((RoomBroadcastMessage) payload); + } + }); + + // when - 방에서 퇴장 + roomService.leaveRoom(testRoom.getId(), testUser.getId()); + + // then - 퇴장 브로드캐스트 메시지 수신 확인 + RoomBroadcastMessage message = messageQueue.poll(5, TimeUnit.SECONDS); + assertThat(message).isNotNull(); + assertThat(message.getType()).isEqualTo(RoomBroadcastMessage.BroadcastType.ROOM_UPDATED); + assertThat(message.getRoomId()).isEqualTo(testRoom.getId()); + + // cleanup + session.disconnect(); + } + + @Test + @Order(4) + @DisplayName("Redis 세션 관리 테스트 - 방 입장/퇴장") + void testRedisSessionManagement() throws Exception { + // given - 초기 상태 확인 + assertThat(sessionManager.isUserConnected(testUser.getId())).isFalse(); + assertThat(sessionManager.getRoomOnlineUserCount(testRoom.getId())).isEqualTo(0); + + // when - 방에 입장 + roomService.joinRoom(testRoom.getId(), null, testUser.getId()); + + // then - Redis 세션 확인 + assertThat(sessionManager.getRoomOnlineUserCount(testRoom.getId())).isEqualTo(1); + assertThat(sessionManager.getOnlineUsersInRoom(testRoom.getId())) + .contains(testUser.getId()); + assertThat(sessionManager.getUserCurrentRoomId(testUser.getId())) + .isEqualTo(testRoom.getId()); + + // when - 방에서 퇴장 + roomService.leaveRoom(testRoom.getId(), testUser.getId()); + + // then - 세션 정리 확인 + assertThat(sessionManager.getRoomOnlineUserCount(testRoom.getId())).isEqualTo(0); + assertThat(sessionManager.getUserCurrentRoomId(testUser.getId())).isNull(); + } + + @Test + @Order(5) + @DisplayName("여러 사용자 동시 입장 테스트") + void testMultipleUsersJoin() throws Exception { + // given - 추가 사용자 생성 + User user2 = createTestUser("testuser2", "test2@test.com"); + User user3 = createTestUser("testuser3", "test3@test.com"); + + // when - 3명의 사용자가 방에 입장 + roomService.joinRoom(testRoom.getId(), null, testUser.getId()); + roomService.joinRoom(testRoom.getId(), null, user2.getId()); + roomService.joinRoom(testRoom.getId(), null, user3.getId()); + + // then - Redis에서 온라인 사용자 수 확인 + assertThat(sessionManager.getRoomOnlineUserCount(testRoom.getId())).isEqualTo(3); + assertThat(sessionManager.getOnlineUsersInRoom(testRoom.getId())) + .containsExactlyInAnyOrder(testUser.getId(), user2.getId(), user3.getId()); + + // cleanup + roomService.leaveRoom(testRoom.getId(), testUser.getId()); + roomService.leaveRoom(testRoom.getId(), user2.getId()); + roomService.leaveRoom(testRoom.getId(), user3.getId()); + } + + @Test + @Order(6) + @DisplayName("Heartbeat 타임아웃 테스트") + void testHeartbeatTimeout() throws Exception { + // given - 방에 입장 + roomService.joinRoom(testRoom.getId(), null, testUser.getId()); + + // 초기 상태 확인 + assertThat(sessionManager.getRoomOnlineUserCount(testRoom.getId())).isEqualTo(1); + + // when - Heartbeat 없이 대기 (실제 TTL은 10분이지만 테스트에서는 확인만) + // 실제 프로덕션에서는 TTL이 지나면 자동으로 세션이 만료됨 + + // Heartbeat 갱신 + sessionManager.updateLastActivity(testUser.getId()); + + // then - 여전히 온라인 상태 + assertThat(sessionManager.isUserConnected(testUser.getId())).isTrue(); + assertThat(sessionManager.getRoomOnlineUserCount(testRoom.getId())).isEqualTo(1); + + // cleanup + roomService.leaveRoom(testRoom.getId(), testUser.getId()); + } + + // ==================== Helper Methods ==================== + + private User createTestUser(String username, String email) { + User user = User.builder() + .username(username) + .email(email) + .password("password123") + .role(Role.USER) + .userStatus(UserStatus.ACTIVE) + .build(); + + UserProfile profile = new UserProfile(); + profile.setNickname(username); + user.setUserProfile(profile); + + return userRepository.save(user); + } +}