diff --git a/chat_service/.gitignore b/chat_service/.gitignore index ca83e35..01c9e5e 100644 --- a/chat_service/.gitignore +++ b/chat_service/.gitignore @@ -36,4 +36,4 @@ out/ ### VS Code ### .vscode/ -**/security/ \ No newline at end of file +**/security/ diff --git a/chat_service/build.gradle b/chat_service/build.gradle index 94a81d0..c1235b0 100644 --- a/chat_service/build.gradle +++ b/chat_service/build.gradle @@ -8,58 +8,43 @@ group = 'com.synapse' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + sourceCompatibility = JavaVersion.VERSION_21 +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } } repositories { mavenCentral() - maven { url 'https://repo.spring.io/milestone' } } -dependencies { - // Spring AI - implementation platform('org.springframework.ai:spring-ai-bom:1.0.0-M4') - implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' - implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter' - implementation 'org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter' - // Spring WebFlux - implementation 'org.springframework.boot:spring-boot-starter-webflux' - // Spring Web - implementation 'org.springframework.boot:spring-boot-starter-web' - // WebSocket - implementation 'org.springframework.boot:spring-boot-starter-websocket' - // Spring Security - implementation 'org.springframework.boot:spring-boot-starter-security' - // OAuth2 Resource Server - implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' - // WebSocket Security - implementation 'org.springframework.security:spring-security-messaging' - // JPA - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - // Validation - implementation 'org.springframework.boot:spring-boot-starter-validation' - // Redis - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - // H2 - runtimeOnly 'com.h2database:h2' - // PostgreSQL - runtimeOnly 'org.postgresql:postgresql' - // Lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - // Auth0 - implementation 'com.auth0:java-jwt:4.4.0' - //Test - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testCompileOnly 'org.projectlombok:lombok' - testAnnotationProcessor 'org.projectlombok:lombok' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - testRuntimeOnly 'com.h2database:h2' +subprojects{ + apply plugin: 'java' + apply plugin: 'io.spring.dependency-management' + apply plugin: 'org.springframework.boot' + + repositories { + mavenCentral() + } + + dependencies { + // Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + // Spring Web + implementation 'org.springframework.boot:spring-boot-starter-web' + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + } } tasks.named('test') { useJUnitPlatform() } + +bootJar { + enabled = false +} diff --git a/chat_service/chat_service/.gitignore b/chat_service/chat_service/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/chat_service/chat_service/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/chat_service/chat_service/build.gradle b/chat_service/chat_service/build.gradle new file mode 100644 index 0000000..0b3c403 --- /dev/null +++ b/chat_service/chat_service/build.gradle @@ -0,0 +1,58 @@ +repositories { + mavenCentral() + maven { url 'https://repo.spring.io/milestone' } +} + +dependencies { + implementation project(':chat_service_api') + // Spring AI + implementation platform('org.springframework.ai:spring-ai-bom:1.0.0-M4') + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-anthropic-spring-boot-starter' + implementation 'org.springframework.ai:spring-ai-vertex-ai-gemini-spring-boot-starter' + // Spring WebFlux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // WebSocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + // Spring Security + implementation 'org.springframework.boot:spring-boot-starter-security' + // OAuth2 Resource Server + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + // WebSocket Security + implementation 'org.springframework.security:spring-security-messaging' + // JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // H2 + runtimeOnly 'com.h2database:h2' + // PostgreSQL + runtimeOnly 'org.postgresql:postgresql' + // Auth0 + implementation 'com.auth0:java-jwt:4.4.0' + //Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' +} + +def generated = 'src/main/generated' + +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(generated)) +} + +sourceSets { + main.java.srcDirs += [ generated ] +} + +clean { + delete file(generated) +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/ChatServiceApplication.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/ChatServiceApplication.java similarity index 100% rename from chat_service/src/main/java/com/synapse/chat_service/ChatServiceApplication.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/ChatServiceApplication.java diff --git a/chat_service/chat_service/src/main/java/com/synapse/chat_service/ChatServiceConfig.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/ChatServiceConfig.java new file mode 100644 index 0000000..25dc872 --- /dev/null +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/ChatServiceConfig.java @@ -0,0 +1,12 @@ +package com.synapse.chat_service; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableAutoConfiguration +@ComponentScan +@EnableJpaAuditing +public class ChatServiceConfig { + +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java similarity index 86% rename from chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java index 46c956f..f211ed8 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/common/annotation/RedisOperation.java @@ -5,23 +5,20 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -/** - * Redis 작업에 대한 공통 예외 처리를 위한 어노테이션 - */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RedisOperation { - + /** * 작업 설명 (로깅용) */ String value() default ""; - + /** * 예외 발생 시 기본값 반환 여부 */ boolean returnDefaultOnError() default false; - + /** * 예외를 다시 던질지 여부 */ diff --git a/chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java similarity index 97% rename from chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java index db6fa11..3aa6143 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/common/aspect/RedisOperationAspect.java @@ -1,50 +1,52 @@ package com.synapse.chat_service.common.aspect; -import com.synapse.chat_service.common.annotation.RedisOperation; -import com.synapse.chat_service.exception.commonexception.RedisOperationException; -import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; +import com.synapse.chat_service.common.annotation.RedisOperation; +import com.synapse.chat_service.exception.commonexception.RedisOperationException; + +import lombok.extern.slf4j.Slf4j; + @Slf4j @Aspect @Component public class RedisOperationAspect { - + @Around("@annotation(redisOperation)") public Object handleRedisOperation(ProceedingJoinPoint joinPoint, RedisOperation redisOperation) throws Throwable { String methodName = joinPoint.getSignature().getName(); String className = joinPoint.getTarget().getClass().getSimpleName(); String operation = redisOperation.value().isEmpty() ? methodName : redisOperation.value(); - + try { Object result = joinPoint.proceed(); log.debug("Redis 작업 성공: {}.{}", className, operation); return result; - + } catch (Exception e) { log.error("Redis 작업 실패: {}.{} - 원인: {}", className, operation, e.getMessage(), e); - + if (redisOperation.returnDefaultOnError()) { log.debug("Redis 작업 실패 시 기본값 반환: {}.{}", className, operation); MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature(); return getDefaultValue(methodSignature.getReturnType()); } - + if (redisOperation.rethrowException()) { // 예외 체이닝을 통해 원본 예외의 스택 트레이스 보존 String operationDescription = String.format("%s.%s", className, operation); throw RedisOperationException.operationError(operationDescription, e); } - + log.debug("Redis 작업 실패 시 null 반환: {}.{}", className, operation); return null; } } - + private Object getDefaultValue(Class returnType) { if (returnType == boolean.class || returnType == Boolean.class) { return false; diff --git a/chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java similarity index 95% rename from chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java index 93cd6c2..7a26a48 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/common/util/RedisTypeConverter.java @@ -1,9 +1,11 @@ package com.synapse.chat_service.common.util; +import org.springframework.stereotype.Component; + import com.fasterxml.jackson.databind.ObjectMapper; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; @Slf4j @Component @@ -15,7 +17,7 @@ public class RedisTypeConverter { /** * Redis에서 조회한 원시 값을 지정된 타입으로 안전하게 변환 * - * @param rawValue Redis에서 조회한 원시 값 + * @param rawValue Redis에서 조회한 원시 값 * @param targetType 변환할 대상 타입 * @return 변환된 객체 (실패 시 null) */ @@ -32,9 +34,9 @@ public T convertValue(Object rawValue, Class targetType) { // ObjectMapper를 사용한 타입 변환 return objectMapper.convertValue(rawValue, targetType); - + } catch (Exception e) { - log.warn("Redis 값 타입 변환 실패: rawValue={}, targetType={}", + log.warn("Redis 값 타입 변환 실패: rawValue={}, targetType={}", rawValue.getClass().getSimpleName(), targetType.getSimpleName(), e); return null; } @@ -46,7 +48,7 @@ public T convertValue(Object rawValue, Class targetType) { public String convertToString(Object rawValue) { return convertValue(rawValue, String.class); } - + /** * 객체를 byte 배열로 변환 (Redis 트랜잭션에서 사용) * @@ -57,7 +59,7 @@ public byte[] convertToBytes(Object value) { if (value == null) { return new byte[0]; } - + try { return objectMapper.writeValueAsBytes(value); } catch (Exception e) { diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java similarity index 64% rename from chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java index da924aa..115ff00 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/AsyncConfig.java @@ -7,24 +7,16 @@ import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; -/** - * 비동기 처리 설정 - * AI 모델 호출의 대용량 트래픽을 고려한 스레드 풀 구성 - */ @Configuration @EnableAsync public class AsyncConfig { - /** - * AI 서비스 전용 스레드 풀 - * 대규모 동시 요청을 처리할 수 있도록 설정 - */ @Bean(name = "aiTaskExecutor") public Executor aiTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(10); // 기본 스레드 수 - executor.setMaxPoolSize(50); // 최대 스레드 수 - executor.setQueueCapacity(100); // 대기 큐 크기 + executor.setCorePoolSize(10); // 기본 스레드 수 + executor.setMaxPoolSize(50); // 최대 스레드 수 + executor.setQueueCapacity(100); // 대기 큐 크기 executor.setThreadNamePrefix("AI-"); // 스레드 이름 접두사 executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java similarity index 69% rename from chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java index 732e57b..1c45f8d 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/ObjectMapperConfig.java @@ -8,14 +8,6 @@ import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -/** - * ObjectMapper 설정 클래스 - * - * 보안 고려사항: - * - Default Typing은 안전하지 않은 역직렬화 취약점을 유발할 수 있어 비활성화 - * - 다형성 타입 처리가 필요한 경우 @JsonTypeInfo와 @JsonSubTypes 어노테이션을 - * - 해당 클래스에 직접 사용하는 것을 권장 - */ @Configuration public class ObjectMapperConfig { @Bean @@ -23,7 +15,7 @@ public ObjectMapper objectMapper() { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); objectMapper.registerModule(new JavaTimeModule()); - objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return objectMapper; } diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java similarity index 99% rename from chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java index 86aec0b..a814dd7 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/RedisConfig.java @@ -1,7 +1,5 @@ package com.synapse.chat_service.config; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -9,6 +7,10 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + @Configuration @RequiredArgsConstructor public class RedisConfig { diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java similarity index 91% rename from chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java index 2afbc3c..acd8b6a 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/WebSecurityConfig.java @@ -1,6 +1,7 @@ package com.synapse.chat_service.config; -import lombok.RequiredArgsConstructor; +import java.util.Arrays; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authorization.AuthorityAuthorizationManager; @@ -9,19 +10,19 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; -import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; -import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; import com.synapse.chat_service.filter.CustomAuthenticationFilter; -import static org.springframework.security.config.Customizer.withDefaults; +import lombok.RequiredArgsConstructor; -import java.util.Arrays; +import static org.springframework.security.config.Customizer.withDefaults; @Configuration(proxyBeanMethods = false) @EnableWebSecurity @@ -37,8 +38,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .sessionManagement(session -> session - .sessionCreationPolicy(SessionCreationPolicy.STATELESS) - ) + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .csrf(csrf -> csrf.disable()) .addFilterBefore(customAuthenticationFilter, BearerTokenAuthenticationFilter.class) @@ -47,18 +47,15 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // WebSocket 핸드셰이크 경로 ("/ws/**") - 인증된 사용자만 허용 .requestMatchers("/ws/**").permitAll() // 채팅 관련 API ("/api/v1/messages/**", "/api/v1/ai-chat/**") - 인증된 사용자만 허용 - .requestMatchers("/api/v1/messages/**", "/api/v1/ai-chat/**").authenticated() + .requestMatchers("/api/chat/**").authenticated() .requestMatchers("/api/internal/**").access(AuthorizationManagers.allOf( AuthorityAuthorizationManager.hasAuthority("SCOPE_api.internal"), - AuthorityAuthorizationManager.hasAuthority("SCOPE_chat:read") - )) - .anyRequest().authenticated() - ) + AuthorityAuthorizationManager.hasAuthority("SCOPE_chat:read"))) + .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())) .exceptionHandling(exceptions -> exceptions .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) - .accessDeniedHandler(new BearerTokenAccessDeniedHandler()) - ) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler())) .build(); } @@ -71,15 +68,14 @@ public CorsConfigurationSource corsConfigurationSource() { "Content-Type", "Authorization", "X-Requested-With", - "Accept" - )); + "Accept")); configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); configuration.setExposedHeaders(Arrays.asList("Authorization")); - + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); - + return source; } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java similarity index 85% rename from chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java index 703554b..ff020b6 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/WebSocketConfig.java @@ -33,7 +33,7 @@ public void configureMessageBroker(MessageBrokerRegistry config) { // 클라이언트가 구독할 때 사용할 prefix (AI 응답 수신용) config.enableSimpleBroker("/topic", "/queue") .setTaskScheduler(heartbeatScheduler()) - .setHeartbeatValue(new long[] {10000, 10000}); + .setHeartbeatValue(new long[] { 10000, 10000 }); config.setUserDestinationPrefix("/user"); } @@ -42,17 +42,16 @@ public void registerStompEndpoints(StompEndpointRegistry registry) { registry.setErrorHandler(customStompErrorHandler); registry.addEndpoint("/ws") .setAllowedOrigins( - webSocketSecurityProperties.getAllowedOrigins().get(0) - ); + webSocketSecurityProperties.getAllowedOrigins().get(0)); } @Override public void configureClientInboundChannel(ChannelRegistration registration) { // 클라이언트로부터 들어오는 메시지를 처리하는 스레드 풀 설정 registration.taskExecutor() - .corePoolSize(4) - .maxPoolSize(8) - .queueCapacity(50); + .corePoolSize(4) + .maxPoolSize(8) + .queueCapacity(50); registration.interceptors(webSocketChannelInterceptor); } @@ -60,17 +59,17 @@ public void configureClientInboundChannel(ChannelRegistration registration) { public void configureClientOutboundChannel(ChannelRegistration registration) { // 클라이언트로 나가는 메시지를 처리하는 스레드 풀 설정 registration.taskExecutor() - .corePoolSize(4) - .maxPoolSize(8) - .queueCapacity(50); + .corePoolSize(4) + .maxPoolSize(8) + .queueCapacity(50); } @Override public void configureWebSocketTransport(WebSocketTransportRegistration registration) { registration - .setMessageSizeLimit(128 * 1024) // 128KB - .setSendTimeLimit(15 * 1000) // 15초 - .setSendBufferSizeLimit(512 * 1024); // 512KB + .setMessageSizeLimit(128 * 1024) // 128KB + .setSendTimeLimit(15 * 1000) // 15초 + .setSendBufferSizeLimit(512 * 1024); // 512KB } @Bean diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java similarity index 86% rename from chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java index 9bfa9b7..d25aa76 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/config/properties/WebSocketSecurityProperties.java @@ -3,12 +3,10 @@ import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; import lombok.Getter; import lombok.Setter; -@Component @ConfigurationProperties(prefix = "websocket.security") @Getter @Setter diff --git a/chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java similarity index 74% rename from chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java index aea4115..31fd8a0 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/controller/AiChatController.java @@ -1,26 +1,29 @@ package com.synapse.chat_service.controller; -import com.synapse.chat_service.dto.request.ChatMessageRequest; -import com.synapse.chat_service.dto.response.ChatHistoryResponse; -import com.synapse.chat_service.dto.response.MessageResponse; -import com.synapse.chat_service.service.MessageService; +import java.util.List; +import java.util.UUID; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; -import java.util.List; -import java.util.UUID; +import com.synapse.chat_service.service.MessageService; +import com.synapse.chat_service_api.dto.request.ChatMessageRequest; +import com.synapse.chat_service_api.dto.response.ChatHistoryResponse; +import com.synapse.chat_service_api.dto.response.MessageResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; @RestController -@RequestMapping("/api/v1/ai-chat") +@RequestMapping("/api/chat") @RequiredArgsConstructor public class AiChatController { - + private final MessageService messageService; - + @GetMapping("/conversation/list") public ResponseEntity> getMyConversationList( @AuthenticationPrincipal UUID userId diff --git a/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java similarity index 92% rename from chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java index a7c49e4..a01d3f8 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/controller/MessageController.java @@ -7,8 +7,8 @@ import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; -import com.synapse.chat_service.dto.request.MessageRequest; import com.synapse.chat_service.service.MessageService; +import com.synapse.chat_service_api.dto.request.MessageRequest; import lombok.RequiredArgsConstructor; diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java similarity index 72% rename from chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java index 63a4da7..d03ad56 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseTimeEntity.java @@ -9,16 +9,21 @@ import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @MappedSuperclass +@NoArgsConstructor(access = AccessLevel.PROTECTED) @EntityListeners(AuditingEntityListener.class) -public abstract class BaseTimeEntity { +public class BaseTimeEntity { + @CreatedDate - @Column(updatable = false) + @Column(name = "created_date", updatable = false) private LocalDateTime createdDate; @LastModifiedDate + @Column(name = "updated_date") private LocalDateTime updatedDate; } diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseEntity.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/common/Basentity.java similarity index 67% rename from chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseEntity.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/common/Basentity.java index 904e824..7bcb89d 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/common/BaseEntity.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/common/Basentity.java @@ -1,23 +1,26 @@ package com.synapse.chat_service.domain.common; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; import lombok.Getter; - -import org.springframework.data.annotation.CreatedBy; -import org.springframework.data.annotation.LastModifiedBy; -import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import lombok.NoArgsConstructor; @Getter @MappedSuperclass +@NoArgsConstructor(access = AccessLevel.PROTECTED) @EntityListeners(AuditingEntityListener.class) -public abstract class BaseEntity extends BaseTimeEntity { - +public class Basentity { @CreatedBy - @Column(updatable = false) + @Column(name = "created_by", updatable = false) private String createdBy; @LastModifiedBy - private String lastModifiedBy; + @Column(name = "updated_by") + private String updatedBy; } diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java similarity index 81% rename from chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java index 826558c..448a3d6 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/ChatUsage.java @@ -5,42 +5,44 @@ import com.synapse.chat_service.domain.common.BaseTimeEntity; import com.synapse.chat_service.domain.entity.enums.SubscriptionType; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; import jakarta.validation.constraints.Min; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; -/** - * 사용자의 채팅 사용량 및 구독 정보를 관리하는 엔티티 - * MSA 원칙에 따라 외부 서비스의 userId만을 참조하여 사용자를 식별합니다. - */ @Entity @Table(name = "chat_usages") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class ChatUsage extends BaseTimeEntity { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - + @Column(name = "user_id", nullable = false, unique = true, columnDefinition = "uuid") private UUID userId; - + @Enumerated(EnumType.STRING) @Column(name = "subscription_type", nullable = false) private SubscriptionType subscriptionType; - + @Min(0) @Column(name = "message_count", nullable = false) private Integer messageCount = 0; - + @Min(0) @Column(name = "message_limit", nullable = false) private Integer messageLimit; - + @Builder public ChatUsage(UUID userId, SubscriptionType subscriptionType, Integer messageLimit) { this.userId = userId; diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java similarity index 74% rename from chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java index 3c2bda1..46fbbff 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Conversation.java @@ -1,39 +1,40 @@ package com.synapse.chat_service.domain.entity; -import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - import java.util.ArrayList; import java.util.List; import java.util.UUID; import com.synapse.chat_service.domain.common.BaseTimeEntity; -/** - * 사용자와 AI 간의 1:1 대화를 나타내는 엔티티 - * 각 사용자는 하나의 대화(Conversation)를 가지며, 이는 자동으로 생성됩니다. - * MSA 원칙에 따라 외부 서비스의 userId만을 참조하여 사용자 정보를 식별합니다. - */ +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + @Entity @Table(name = "conversations") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Conversation extends BaseTimeEntity { - @Id @GeneratedValue(strategy = GenerationType.UUID) @Column(name = "conversation_id", columnDefinition = "UUID") private UUID id; - + @Column(name = "user_id", nullable = false, unique = true, columnDefinition = "uuid") private UUID userId; - + @OneToMany(mappedBy = "conversation", cascade = CascadeType.ALL, orphanRemoval = true) private List messages = new ArrayList<>(); - + @Builder public Conversation(UUID userId) { this.userId = userId; diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java similarity index 73% rename from chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java index 5717b13..872b912 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/Message.java @@ -3,7 +3,17 @@ import com.synapse.chat_service.domain.common.BaseTimeEntity; import com.synapse.chat_service.domain.entity.enums.SenderType; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.Builder; @@ -15,24 +25,23 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Message extends BaseTimeEntity { - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "conversation_id", nullable = false) @NotNull private Conversation conversation; - + @Enumerated(EnumType.STRING) @Column(name = "sender_type", nullable = false) @NotNull private SenderType senderType; - + @Column(name = "content", nullable = false, columnDefinition = "TEXT") private String content; - + @Builder public Message(Conversation conversation, SenderType senderType, String content) { this.conversation = conversation; diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SenderType.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SenderType.java similarity index 100% rename from chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SenderType.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SenderType.java diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SubscriptionType.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SubscriptionType.java similarity index 100% rename from chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SubscriptionType.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/entity/enums/SubscriptionType.java diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java similarity index 79% rename from chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java index e06b404..317263e 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ChatUsageRepository.java @@ -1,11 +1,9 @@ package com.synapse.chat_service.domain.repository; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; import com.synapse.chat_service.domain.entity.ChatUsage; -@Repository public interface ChatUsageRepository extends JpaRepository { - + } diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java similarity index 88% rename from chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java index 724b9ab..5ae3f4c 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/ConversationRepository.java @@ -1,16 +1,15 @@ package com.synapse.chat_service.domain.repository; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import com.synapse.chat_service.domain.entity.Conversation; - import java.util.List; import java.util.Optional; import java.util.UUID; -@Repository +import org.springframework.data.jpa.repository.JpaRepository; + +import com.synapse.chat_service.domain.entity.Conversation; + public interface ConversationRepository extends JpaRepository { Optional findByUserId(UUID userId); + Optional> findByUserIdOrderByCreatedDateDesc(UUID userId); } diff --git a/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java new file mode 100644 index 0000000..f7bbc82 --- /dev/null +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java @@ -0,0 +1,20 @@ +package com.synapse.chat_service.domain.repository; + +import java.util.List; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service_api.dto.response.MessageResponse; + +public interface MessageRepository extends JpaRepository { + @Query(value = "SELECT m.* FROM messages m JOIN conversations c ON m.conversation_id = c.conversation_id WHERE c.user_id = :userId AND (:cursor IS NULL OR m.id < :cursor) ORDER BY m.id DESC LIMIT :limit", nativeQuery = true) + List findByUserIdWithCursorDesc( + @Param("userId") UUID userId, + @Param("cursor") Long cursor, + @Param("limit") int limit + ); +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java similarity index 97% rename from chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java index da7a8ee..b5330f4 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BadRequestException.java @@ -3,19 +3,19 @@ import com.synapse.chat_service.exception.domain.ExceptionType; public class BadRequestException extends BusinessException { - + public BadRequestException(ExceptionType exceptionType) { super(exceptionType); } - + public BadRequestException(ExceptionType exceptionType, String customMessage) { super(exceptionType, customMessage); } - + public BadRequestException(ExceptionType exceptionType, Throwable cause) { super(exceptionType, cause); } - + public BadRequestException(ExceptionType exceptionType, String customMessage, Throwable cause) { super(exceptionType, customMessage, cause); } diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java similarity index 97% rename from chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java index cbd9b74..1d18e42 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/BusinessException.java @@ -6,24 +6,24 @@ @Getter public abstract class BusinessException extends RuntimeException { - + private final ExceptionType exceptionType; - + public BusinessException(ExceptionType exceptionType) { super(exceptionType.getMessage()); this.exceptionType = exceptionType; } - + public BusinessException(ExceptionType exceptionType, String customMessage) { super(customMessage); this.exceptionType = exceptionType; } - + public BusinessException(ExceptionType exceptionType, Throwable cause) { super(exceptionType.getMessage(), cause); this.exceptionType = exceptionType; } - + public BusinessException(ExceptionType exceptionType, String customMessage, Throwable cause) { super(customMessage, cause); this.exceptionType = exceptionType; diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java similarity index 97% rename from chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java index b1f9295..4b7cd53 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/NotFoundException.java @@ -3,11 +3,11 @@ import com.synapse.chat_service.exception.domain.ExceptionType; public class NotFoundException extends BusinessException { - + public NotFoundException(ExceptionType exceptionType) { super(exceptionType); } - + public NotFoundException(ExceptionType exceptionType, String customMessage) { super(exceptionType, customMessage); } diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java similarity index 67% rename from chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java index 3111400..622df68 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/RedisOperationException.java @@ -2,33 +2,30 @@ import com.synapse.chat_service.exception.domain.ExceptionType; -/** - * Redis 작업 중 발생하는 예외를 처리하는 커스텀 예외 클래스 - * BusinessException을 상속하여 GlobalExceptionHandler에서 일관된 예외 처리가 가능합니다. - */ public class RedisOperationException extends BusinessException { - + /** * 커스텀 메시지와 원인 예외를 포함한 Redis 작업 예외 생성자 + * * @param exceptionType Redis 관련 예외 타입 * @param customMessage 사용자 정의 메시지 - * @param cause 원인 예외 + * @param cause 원인 예외 */ private RedisOperationException(ExceptionType exceptionType, String customMessage, Throwable cause) { super(exceptionType, customMessage, cause); } - + /** * Redis 작업 오류 예외 생성 팩토리 메소드 + * * @param operation 실패한 작업명 - * @param cause 원인 예외 + * @param cause 원인 예외 * @return RedisOperationException 인스턴스 */ public static RedisOperationException operationError(String operation, Throwable cause) { return new RedisOperationException( - ExceptionType.REDIS_OPERATION_ERROR, - String.format("Redis 작업 실패: %s", operation), - cause - ); + ExceptionType.REDIS_OPERATION_ERROR, + String.format("Redis 작업 실패: %s", operation), + cause); } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java similarity index 98% rename from chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java index 4f14fb4..9a4e86c 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/commonexception/ValidException.java @@ -6,7 +6,7 @@ public class ValidException extends BusinessException { public ValidException(ExceptionType exceptionType) { super(exceptionType); } - + public ValidException(ExceptionType exceptionType, String customMessage) { super(exceptionType, customMessage); } diff --git a/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java new file mode 100644 index 0000000..568ceb5 --- /dev/null +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java @@ -0,0 +1,64 @@ +package com.synapse.chat_service.exception.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ExceptionType { + + // 400 Bad Request + INVALID_INPUT_VALUE(BAD_REQUEST, "E001", "잘못된 입력값입니다."), + MISSING_REQUEST_PARAMETER(BAD_REQUEST, "E002", "필수 요청 파라미터가 누락되었습니다."), + INVALID_TYPE_VALUE(BAD_REQUEST, "E003", "잘못된 타입의 값입니다."), + + // 401 Unauthorized + TOKEN_UNAUTHORIZED(UNAUTHORIZED, "E101", "인증이 필요합니다."), + INVALID_TOKEN(UNAUTHORIZED, "E102", "유효하지 않은 토큰입니다."), + EXPIRED_TOKEN(UNAUTHORIZED, "E103", "만료된 토큰입니다."), + + // 403 Forbidden + ACCESS_DENIED(FORBIDDEN, "E201", "접근이 거부되었습니다."), + INSUFFICIENT_PERMISSION(FORBIDDEN, "E202", "권한이 부족합니다."), + + // 404 Not Found + CONVERSATION_NOT_FOUND(NOT_FOUND, "E301", "대화를 찾을 수 없습니다."), + MESSAGE_NOT_FOUND(NOT_FOUND, "E302", "메시지를 찾을 수 없습니다."), + USER_NOT_FOUND(NOT_FOUND, "E303", "사용자를 찾을 수 없습니다."), + RESOURCE_NOT_FOUND(NOT_FOUND, "E304", "요청한 리소스를 찾을 수 없습니다."), + + // 409 Conflict + DUPLICATE_RESOURCE(CONFLICT, "E401", "이미 존재하는 리소스입니다."), + DUPLICATE_USERNAME(CONFLICT, "E402", "이미 사용 중인 사용자명입니다."), + DUPLICATE_EMAIL(CONFLICT, "E403", "이미 사용 중인 이메일입니다."), + + // 422 Unprocessable Entity + BUSINESS_LOGIC_ERROR(UNPROCESSABLE_ENTITY, "E501", "비즈니스 로직 오류가 발생했습니다."), + INVALID_STATE(UNPROCESSABLE_ENTITY, "E502", "유효하지 않은 상태입니다."), + + // 429 Too Many Requests + TOO_MANY_REQUEST(TOO_MANY_REQUESTS, "E601", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), + + // 500 Internal Server Error + INTERNAL_SERVER_ERRORS(INTERNAL_SERVER_ERROR, "E901", "서버 내부 오류가 발생했습니다."), + DATABASE_ERRORS(INTERNAL_SERVER_ERROR, "E902", "데이터베이스 오류가 발생했습니다."), + EXTERNAL_SERVICE_ERROR(INTERNAL_SERVER_ERROR, "E903", "외부 서비스 연동 중 오류가 발생했습니다."), + REDIS_CONNECTION_ERROR(INTERNAL_SERVER_ERROR, "E904", "Redis 연결 오류가 발생했습니다."), + REDIS_OPERATION_ERROR(INTERNAL_SERVER_ERROR, "E905", "Redis 작업 중 오류가 발생했습니다."), + REDIS_TRANSACTION_ERROR(INTERNAL_SERVER_ERROR, "E906", "Redis 트랜잭션 처리 중 오류가 발생했습니다."), + + // 502 Bad Gateway + BAD_GATEWAYS(BAD_GATEWAY, "E951", "게이트웨이 오류가 발생했습니다."), + + // 503 Service Unavailable + CHAT_SERVICE_UNAVAILABLE(SERVICE_UNAVAILABLE, "E961", "서비스를 사용할 수 없습니다.") + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java similarity index 98% rename from chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java index d2452ae..a5ea369 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/dto/ExceptionResponse.java @@ -1,5 +1,7 @@ package com.synapse.chat_service.exception.dto; +import java.time.LocalDateTime; + import com.fasterxml.jackson.annotation.JsonFormat; import com.synapse.chat_service.exception.commonexception.BusinessException; import com.synapse.chat_service.exception.domain.ExceptionType; @@ -7,18 +9,16 @@ import lombok.Builder; import lombok.Getter; -import java.time.LocalDateTime; - @Getter @Builder public class ExceptionResponse { - + private final String code; private final String message; - + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private final LocalDateTime timestamp; - + public static ExceptionResponse from(BusinessException exception) { return ExceptionResponse.builder() .code(exception.getExceptionType().getCode()) @@ -26,7 +26,7 @@ public static ExceptionResponse from(BusinessException exception) { .timestamp(LocalDateTime.now()) .build(); } - + public static ExceptionResponse of(ExceptionType exceptionType) { return ExceptionResponse.builder() .code(exceptionType.getCode()) @@ -34,7 +34,7 @@ public static ExceptionResponse of(ExceptionType exceptionType) { .timestamp(LocalDateTime.now()) .build(); } - + public static ExceptionResponse of(ExceptionType exceptionType, String customMessage) { return ExceptionResponse.builder() .code(exceptionType.getCode()) @@ -42,4 +42,4 @@ public static ExceptionResponse of(ExceptionType exceptionType, String customMes .timestamp(LocalDateTime.now()) .build(); } -} +} \ No newline at end of file diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java similarity index 87% rename from chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java index 23cdb04..c134239 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/service/CustomStompErrorHandler.java @@ -4,9 +4,9 @@ import org.springframework.http.MediaType; import org.springframework.messaging.Message; -import org.springframework.messaging.support.MessageBuilder; import org.springframework.messaging.simp.stomp.StompCommand; import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; import org.springframework.stereotype.Component; import org.springframework.web.socket.messaging.StompSubProtocolErrorHandler; @@ -20,44 +20,40 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -/** - * StompSubProtocolErrorHandler를 상속받아서 프로토콜 레벨 오류 처리 역할을 명확히 분리합니다. - * 클라이언트에게 안전하고 일관된 오류 피드백을 제공하며, 서버 측에는 상세한 로그를 남깁니다. - */ @Slf4j @Component @RequiredArgsConstructor public class CustomStompErrorHandler extends StompSubProtocolErrorHandler { - + private final ObjectMapper objectMapper; - + /** * @MessageMapping 메소드 실행 중 또는 인바운드 채널 인터셉터에서 발생한 예외를 처리합니다. * * @param clientMessage 클라이언트에서 보낸 원본 메시지 - * @param ex 발생한 예외 + * @param ex 발생한 예외 * @return 클라이언트에게 전송할 ERROR 프레임 */ @Override public Message handleClientMessageProcessingError(Message clientMessage, Throwable ex) { // 예외 분석 및 로깅 Throwable rootCause = getRootCause(ex); - - log.error("STOMP message processing error occurred. Client message: {}, Exception: {}", - clientMessage, ex.getMessage(), ex); - + + log.error("STOMP message processing error occurred. Client message: {}, Exception: {}", + clientMessage, ex.getMessage(), ex); + // 클라이언트용 에러 페이로드 생성 ExceptionResponse errorResponse = createErrorResponse(rootCause); - + // ERROR 프레임 생성 및 반환 return createErrorFrame(errorResponse); } - + /** * 브로커 자체 오류 등 서버에서 클라이언트로 보내는 다른 에러 메시지를 처리합니다. * handleClientMessageProcessingError와 동일한 포맷으로 에러 메시지를 표준화합니다. * - * @param errorMessage 원본 에러 메시지 + * @param errorMessage 원본 에러 메시지 * @param clientMessage 클라이언트 메시지 (nullable) * @return 표준화된 ERROR 프레임 */ @@ -65,13 +61,12 @@ public Message handleClientMessageProcessingError(Message client public Message handleErrorMessageToClient(Message errorMessage) { // 표준화된 에러 응답 생성 ExceptionResponse errorResponse = ExceptionResponse.of( - ExceptionType.INTERNAL_SERVER_ERROR, - "WebSocket 통신 중 오류가 발생했습니다." - ); - + ExceptionType.INTERNAL_SERVER_ERRORS, + "WebSocket 통신 중 오류가 발생했습니다."); + return createErrorFrame(errorResponse); } - + /** * 예외의 근본 원인을 찾습니다. * @@ -85,7 +80,7 @@ private Throwable getRootCause(Throwable ex) { } return cause; } - + /** * 예외를 분석하여 적절한 ExceptionResponse를 생성합니다. * @@ -104,10 +99,10 @@ private ExceptionResponse createErrorResponse(Throwable ex) { return ExceptionResponse.of(ExceptionType.INVALID_TOKEN, "유효하지 않은 토큰입니다."); } else { // 그 외의 경우 내부 서버 오류로 처리 - return ExceptionResponse.of(ExceptionType.INTERNAL_SERVER_ERROR); + return ExceptionResponse.of(ExceptionType.INTERNAL_SERVER_ERRORS); } } - + /** * ExceptionResponse를 사용하여 STOMP ERROR 프레임을 생성합니다. * @@ -121,14 +116,15 @@ private Message createErrorFrame(ExceptionResponse errorResponse) { } catch (JsonProcessingException e) { // 직렬화 실패 시 비상용 메시지 log.error("Failed to serialize error response: {}", e.getMessage(), e); - payload = "{\"code\":\"E999\",\"message\":\"Error response serialization failed.\"}".getBytes(StandardCharsets.UTF_8); + payload = "{\"code\":\"E999\",\"message\":\"Error response serialization failed.\"}" + .getBytes(StandardCharsets.UTF_8); } - + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR); accessor.setLeaveMutable(true); accessor.setMessage(errorResponse.getMessage()); // 헤더 메시지 설정 accessor.setContentType(MediaType.APPLICATION_JSON); // 콘텐츠 타입 명시 - + return MessageBuilder.createMessage(payload, accessor.getMessageHeaders()); } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java similarity index 72% rename from chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java index 2519b56..c659f74 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/exception/service/GlobalExceptionHandler.java @@ -1,11 +1,15 @@ package com.synapse.chat_service.exception.service; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; +import java.util.stream.Collectors; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.messaging.handler.annotation.support.MethodArgumentNotValidException; import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -17,17 +21,18 @@ import com.synapse.chat_service.exception.domain.ExceptionType; import com.synapse.chat_service.exception.dto.ExceptionResponse; -import java.util.stream.Collectors; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { - + // 로그 포맷 상수 private static final String INFO_LOG_FORMAT = "INFO - {} {} - Status: {} - Exception: {} - Message: {}"; private static final String WARN_LOG_FORMAT = "WARN - {} {} - Status: {} - Exception: {} - Message: {}"; private static final String ERROR_LOG_FORMAT = "ERROR - {} {} - Status: {} - Exception: {} - Message: {}"; - + /** * 비즈니스 예외 처리 */ @@ -36,119 +41,123 @@ public ResponseEntity handleBusinessException(BusinessExcepti logWarn(request, e, e.getExceptionType().getStatus()); return ResponseEntity.status(e.getExceptionType().getStatus()).body(ExceptionResponse.from(e)); } - + /** * BadRequest 예외 처리 */ @ExceptionHandler(BadRequestException.class) - public ResponseEntity handleBadRequestException(BadRequestException e, HttpServletRequest request) { + public ResponseEntity handleBadRequestException(BadRequestException e, + HttpServletRequest request) { logWarn(request, e, e.getExceptionType().getStatus()); return ResponseEntity.status(e.getExceptionType().getStatus()).body(ExceptionResponse.from(e)); } - + /** * Validation 예외 처리 */ @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) { + public ResponseEntity handleValidationException(MethodArgumentNotValidException e, + HttpServletRequest request) { String errorMessage = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining(", ")); - - logInfo(request, e, HttpStatus.BAD_REQUEST); - + + logInfo(request, e, BAD_REQUEST); + ExceptionResponse response = ExceptionResponse.of(ExceptionType.INVALID_INPUT_VALUE, errorMessage); return ResponseEntity.badRequest().body(response); } - + /** * 필수 파라미터 누락 예외 처리 */ @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity handleMissingParameterException(MissingServletRequestParameterException e, HttpServletRequest request) { + public ResponseEntity handleMissingParameterException(MissingServletRequestParameterException e, + HttpServletRequest request) { // ExceptionType에 정의된 기본 메시지에 구체적인 파라미터 정보 추가 - String detailedMessage = String.format("%s (파라미터: %s)", - ExceptionType.MISSING_REQUEST_PARAMETER.getMessage(), + String detailedMessage = String.format("%s (파라미터: %s)", + ExceptionType.MISSING_REQUEST_PARAMETER.getMessage(), e.getParameterName()); - - logInfo(request, e, HttpStatus.BAD_REQUEST); + + logInfo(request, e, BAD_REQUEST); return ResponseEntity.badRequest() .body(ExceptionResponse.of(ExceptionType.MISSING_REQUEST_PARAMETER, detailedMessage)); } - + /** * 타입 불일치 예외 처리 */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ResponseEntity handleTypeMismatchException(MethodArgumentTypeMismatchException e, HttpServletRequest request) { + public ResponseEntity handleTypeMismatchException(MethodArgumentTypeMismatchException e, + HttpServletRequest request) { // ExceptionType에 정의된 기본 메시지에 구체적인 파라미터 정보 추가 - String detailedMessage = String.format("%s (파라미터: %s)", - ExceptionType.INVALID_TYPE_VALUE.getMessage(), + String detailedMessage = String.format("%s (파라미터: %s)", + ExceptionType.INVALID_TYPE_VALUE.getMessage(), e.getName()); - - logInfo(request, e, HttpStatus.BAD_REQUEST); + + logInfo(request, e, BAD_REQUEST); return ResponseEntity.badRequest() .body(ExceptionResponse.of(ExceptionType.INVALID_TYPE_VALUE, detailedMessage)); } - + /** * IllegalArgumentException 처리 */ @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) { - logWarn(request, e, HttpStatus.BAD_REQUEST); + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e, + HttpServletRequest request) { + logWarn(request, e, BAD_REQUEST); return ResponseEntity.badRequest() .body(ExceptionResponse.of(ExceptionType.INVALID_INPUT_VALUE, e.getMessage())); } - + /** * 정적 리소스 없음 예외 처리 (INFO 레벨로 처리) */ @ExceptionHandler(NoResourceFoundException.class) - public ResponseEntity handleNoResourceFoundException(NoResourceFoundException e, HttpServletRequest request) { - logInfo(request, e, HttpStatus.NOT_FOUND); - return ResponseEntity.status(HttpStatus.NOT_FOUND) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException e, + HttpServletRequest request) { + logInfo(request, e, NOT_FOUND); + return ResponseEntity.status(NOT_FOUND) .body(ExceptionResponse.of(ExceptionType.RESOURCE_NOT_FOUND)); } - + /** * 일반적인 예외 처리 */ @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception e, HttpServletRequest request) { - logError(request, e, HttpStatus.INTERNAL_SERVER_ERROR); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ExceptionResponse.of(ExceptionType.INTERNAL_SERVER_ERROR)); + logError(request, e, INTERNAL_SERVER_ERROR); + return ResponseEntity.status(INTERNAL_SERVER_ERROR) + .body(ExceptionResponse.of(ExceptionType.INTERNAL_SERVER_ERRORS)); } - + // 로깅 메서드들 private void logInfo(HttpServletRequest request, Exception e, HttpStatus status) { - log.info(INFO_LOG_FORMAT, - request.getMethod(), - request.getRequestURI(), - status.value(), - e.getClass().getSimpleName(), + log.info(INFO_LOG_FORMAT, + request.getMethod(), + request.getRequestURI(), + status.value(), + e.getClass().getSimpleName(), e.getMessage()); } - - private void logWarn(HttpServletRequest request, Exception e, HttpStatus status) { - log.warn(WARN_LOG_FORMAT, - request.getMethod(), - request.getRequestURI(), - status.value(), - e.getClass().getSimpleName(), + log.warn(WARN_LOG_FORMAT, + request.getMethod(), + request.getRequestURI(), + status.value(), + e.getClass().getSimpleName(), e.getMessage()); } - + private void logError(HttpServletRequest request, Exception e, HttpStatus status) { - log.error(ERROR_LOG_FORMAT, - request.getMethod(), - request.getRequestURI(), - status.value(), - e.getClass().getSimpleName(), - e.getMessage(), + log.error(ERROR_LOG_FORMAT, + request.getMethod(), + request.getRequestURI(), + status.value(), + e.getClass().getSimpleName(), + e.getMessage(), e); } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java similarity index 96% rename from chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java index 203ffb3..7c1b594 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/filter/CustomAuthenticationFilter.java @@ -45,7 +45,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } UUID principal = UUID.fromString(memberId); - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, null, authorities); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(principal, + null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java similarity index 91% rename from chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java index e2de2cb..b0c1360 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/interceptor/WebSocketChannelInterceptor.java @@ -14,10 +14,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -/** - * STOMP 프로토콜 레벨에서 메시지를 가로채는 인터셉터 - * 세션 생명주기 관리 및 활동 추적 등 인증/상태와 관련된 부가적인 처리를 수행합니다. - */ @Slf4j @Component @RequiredArgsConstructor @@ -32,7 +28,7 @@ public class WebSocketChannelInterceptor implements ChannelInterceptor { @Override public Message preSend(Message message, MessageChannel channel) { StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); - + if (accessor == null || accessor.getCommand() == null) { return message; } @@ -41,7 +37,8 @@ public Message preSend(Message message, MessageChannel channel) { case CONNECT -> handleConnect(accessor); case DISCONNECT -> handleDisconnect(accessor); case SEND -> handleMessage(accessor); - default -> {} + default -> { + } } return message; @@ -58,12 +55,12 @@ private void handleConnect(StompHeaderAccessor accessor) { log.warn("CONNECT 프레임에서 유효한 Authorization 헤더가 없습니다: sessionId={}", accessor.getSessionId()); return; } - + Authentication authentication = jwtTokenProvider.verifyAndDecode(header); - + String sessionId = accessor.getSessionId(); String userId = authentication.getName(); - + // STOMP 세션에 인증 정보 저장 accessor.setUser(authentication); @@ -90,7 +87,7 @@ private void handleConnect(StompHeaderAccessor accessor) { private void handleDisconnect(StompHeaderAccessor accessor) { String sessionId = accessor.getSessionId(); String userId = (String) accessor.getSessionAttributes().get("userId"); - + log.info("DISCONNECT 프레임 처리: sessionId={}, userId={}", sessionId, userId); // WebSocket 세션 정리 @@ -104,7 +101,7 @@ private void handleDisconnect(StompHeaderAccessor accessor) { private void handleMessage(StompHeaderAccessor accessor) { String sessionId = accessor.getSessionId(); String userId = (String) accessor.getSessionAttributes().get("userId"); - + if (userId == null) { log.warn("MESSAGE 프레임에서 사용자 ID를 찾을 수 없음: sessionId={}", sessionId); return; @@ -119,27 +116,29 @@ private void handleMessage(StompHeaderAccessor accessor) { private String extractClientInfo(StompHeaderAccessor accessor) { try { StringBuilder clientInfo = new StringBuilder(); - + String clientType = accessor.getFirstNativeHeader("client-type"); if (clientType != null) { clientInfo.append("Type: ").append(clientType); } - + String clientVersion = accessor.getFirstNativeHeader("client-version"); if (clientVersion != null) { - if (clientInfo.length() > 0) clientInfo.append(", "); + if (clientInfo.length() > 0) + clientInfo.append(", "); clientInfo.append("Version: ").append(clientVersion); } - + String deviceInfo = accessor.getFirstNativeHeader("device-info"); if (deviceInfo != null) { - if (clientInfo.length() > 0) clientInfo.append(", "); + if (clientInfo.length() > 0) + clientInfo.append(", "); clientInfo.append("Device: ").append(deviceInfo); } - + // 기본값: 클라이언트 정보가 없는 경우 return clientInfo.length() > 0 ? clientInfo.toString() : "Web Client"; - + } catch (Exception e) { log.warn("Failed to extract client info from STOMP headers: {}", e.getMessage()); return "Unknown Client"; diff --git a/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java similarity index 93% rename from chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java index 13f2d6b..08c79e8 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/provider/JwtTokenProvider.java @@ -43,11 +43,11 @@ public final Authentication verifyAndDecode(String header) throws JWTVerificatio String[] roles = authClaim.asString().split(","); Collection authorities = Arrays.stream(roles) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + UserDetails principal = new User(userId, "", authorities); - + return new UsernamePasswordAuthenticationToken(principal, "", authorities); } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java similarity index 76% rename from chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java index 929a23e..e43a23f 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/MessageService.java @@ -1,57 +1,58 @@ package com.synapse.chat_service.service; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.synapse.chat_service.domain.entity.Conversation; import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.entity.enums.SenderType; import com.synapse.chat_service.domain.repository.ConversationRepository; import com.synapse.chat_service.domain.repository.MessageRepository; -import com.synapse.chat_service.domain.entity.enums.SenderType; -import com.synapse.chat_service.dto.response.ChatHistoryResponse; -import com.synapse.chat_service.dto.response.MessageResponse; -import com.synapse.chat_service.dto.response.PaginationDto; -import com.synapse.chat_service.session.RedisAiChatManager; -import com.synapse.chat_service.service.ai.AIModelServiceFactory; import com.synapse.chat_service.service.ai.AIModelService; -import com.synapse.chat_service.dto.request.MessageRequest; +import com.synapse.chat_service.service.ai.AIModelServiceFactory; +import com.synapse.chat_service.service.ai.AIModelType; +import com.synapse.chat_service.session.RedisAiChatManager; +import com.synapse.chat_service_api.dto.request.MessageRequest; +import com.synapse.chat_service_api.dto.response.ChatHistoryResponse; +import com.synapse.chat_service_api.dto.response.MessageResponse; +import com.synapse.chat_service_api.dto.response.PaginationDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.List; -import java.util.Optional; -import java.util.UUID; @Slf4j @Service -@RequiredArgsConstructor @Transactional(readOnly = true) +@RequiredArgsConstructor public class MessageService { - private final MessageRepository messageRepository; private final ConversationRepository conversationRepository; private final RedisAiChatManager redisAiChatManager; private final AIModelServiceFactory aiModelServiceFactory; private final SimpMessagingTemplate messagingTemplate; - + @Transactional public MessageResponse.History createMessage(UUID userId, SenderType senderType, String content) { // 사용자의 대화가 존재하지 않으면 자동으로 생성 Conversation conversation = getOrCreateConversation(userId); - + Message message = Message.builder() .conversation(conversation) .senderType(senderType) .content(content) .build(); - + Message savedMessage = messageRepository.save(message); - return MessageResponse.History.from(savedMessage); + return MessageResponse.History.to(savedMessage.getId(), savedMessage.getConversation().getId(), savedMessage.getSenderType().toString(), savedMessage.getContent(), savedMessage.getCreatedDate()); } - + /** * 사용자의 대화를 조회하거나 없으면 새로 생성 * Redis의 AiChatInfo와 DB의 Conversation 간 일관성을 보장 @@ -70,13 +71,12 @@ public Conversation getOrCreateConversation(UUID userId) { .build(); Conversation savedConversation = conversationRepository.save(newConversation); - + // Redis에 새로운 대화 정보 저장 redisAiChatManager.createOrUpdateAiChatWithConversation( userId.toString(), - savedConversation.getId() - ); - + savedConversation.getId()); + return savedConversation; }); } @@ -88,9 +88,10 @@ private Optional findConversationByUserId(UUID userId) { private Optional> findConversationListByUserId(UUID userId) { return conversationRepository.findByUserIdOrderByCreatedDateDesc(userId); } - + /** * 추후 쿼리 최적화 필요 -> 조회시점에 불필요한 쿼리를 날리지 않는지 확인 필요 + * * @param userId * @return */ @@ -98,13 +99,14 @@ public List getConversationListByUserId(UUID u // 사용자의 대화방 정보 조회 (없으면 null 반환) return findConversationListByUserId(userId) .map(conversationList -> conversationList.stream() - .map(MessageResponse.ConversationInfo::from) + .map(conversation -> MessageResponse.ConversationInfo.to(conversation.getId(), conversation.getUserId(), conversation.getCreatedDate(), conversation.getUpdatedDate())) .toList()) .orElse(List.of()); } - + /** * 커서 방식으로 쿼리 최적화 필요 -> 많은 대화 중에 메시지가 많을 수 있음 + * * @param userId * @param size * @param cursor @@ -116,26 +118,25 @@ public ChatHistoryResponse getMessagesRecentFirst(UUID userId, Integer size, Str Long cursorId = cursor != null && !cursor.isEmpty() ? parseCursor(cursor) : null; List messages = messageRepository.findByUserIdWithCursorDesc( - userId, cursorId, queryLimit - ); - + userId, cursorId, queryLimit); + // 다음 페이지 존재 여부 확인 boolean hasNext = messages.size() > size; if (hasNext) { messages = messages.subList(0, size); // 실제 반환할 데이터만 유지 } - + // 다음 커서 생성 (마지막 메시지의 ID) - String nextCursor = hasNext && !messages.isEmpty() - ? generateCursor(messages.get(messages.size() - 1).id()) + String nextCursor = hasNext && !messages.isEmpty() + ? generateCursor(messages.get(messages.size() - 1).id()) : null; - + // 페이지네이션 정보 생성 PaginationDto pagination = PaginationDto.of(nextCursor, hasNext, size); - + return ChatHistoryResponse.of(messages, pagination); } - + /** * ChatController로부터 위임받은 메시지 처리 및 응답 로직 * 사용자 메시지 저장, AI 호출, 응답 저장, 클라이언트 전송을 통합 처리 @@ -152,23 +153,23 @@ public void processAndRespondToMessage(UUID userId, MessageRequest.Chat chatMess } // AI 서비스 호출 - AIModelService modelService = aiModelServiceFactory.getService(chatMessage.modelType()); + AIModelService modelService = aiModelServiceFactory.getService(AIModelType.valueOf(chatMessage.modelType())); modelService.generateResponse(chatMessage.prompt()) - .thenApply(aiResponse -> { - createMessage(userId, SenderType.ASSISTANT, aiResponse); - log.info("AI 응답 저장 완료 - 사용자ID: {}", userId); - return MessageResponse.Chat.success( - aiResponse, chatMessage.modelType(), chatMessage.sessionId(), chatMessage.messageId()); - }) - .thenAccept(aiResponse -> { - sendResponse(userId, aiResponse); - log.info("AI 응답 전송 완료 - 사용자ID: {}", userId); - }) - .exceptionally(throwable -> { - log.error("AI 응답 처리 파이프라인 실패 - 사용자ID: {}, 원인: {}", userId, throwable.getMessage(), throwable); - sendErrorResponse(userId, chatMessage, "AI 응답을 처리하는 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); - return null; - }); + .thenApply(aiResponse -> { + createMessage(userId, SenderType.ASSISTANT, aiResponse); + log.info("AI 응답 저장 완료 - 사용자ID: {}", userId); + return MessageResponse.Chat.success( + aiResponse, chatMessage.modelType(), chatMessage.sessionId(), chatMessage.messageId()); + }) + .thenAccept(aiResponse -> { + sendResponse(userId, aiResponse); + log.info("AI 응답 전송 완료 - 사용자ID: {}", userId); + }) + .exceptionally(throwable -> { + log.error("AI 응답 처리 파이프라인 실패 - 사용자ID: {}, 원인: {}", userId, throwable.getMessage(), throwable); + sendErrorResponse(userId, chatMessage, "AI 응답을 처리하는 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + return null; + }); } /** @@ -179,24 +180,22 @@ public void sendErrorResponse(UUID userId, MessageRequest.Chat chatMessage, Stri chatMessage.modelType(), chatMessage.sessionId(), chatMessage.messageId(), - errorMessage - ); - + errorMessage); + sendResponse(userId, errorResponse); - - log.warn("에러 응답 전송 - 세션: {}, 메시지ID: {}, 에러: {}", + + log.warn("에러 응답 전송 - 세션: {}, 메시지ID: {}, 에러: {}", chatMessage.sessionId(), chatMessage.messageId(), errorMessage); } - + /** * 응답을 클라이언트에게 전송 */ private void sendResponse(UUID userId, MessageResponse.Chat response) { messagingTemplate.convertAndSendToUser( - userId.toString(), - "/queue/response", - response - ); + userId.toString(), + "/queue/response", + response); } private Long parseCursor(String cursor) { diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java similarity index 77% rename from chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java index 80e294a..d035ea9 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelService.java @@ -2,22 +2,20 @@ import java.util.concurrent.CompletableFuture; -/** - * AI 모델 서비스의 공통 인터페이스 - * 각 AI 모델별 서비스는 이 인터페이스를 구현하여 일관된 API를 제공 - */ public interface AIModelService { - + /** * 해당 서비스가 지원하는 AI 모델 타입을 반환 + * * @return 지원하는 AIModelType */ AIModelType getModelType(); - + /** * 주어진 프롬프트에 대해 AI 응답을 비동기적으로 생성 + * * @param prompt 사용자 입력 프롬프트 * @return AI 응답을 담은 CompletableFuture */ CompletableFuture generateResponse(String prompt); -} \ No newline at end of file +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java similarity index 67% rename from chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java index ab00f20..48eb717 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelServiceFactory.java @@ -12,20 +12,14 @@ @Slf4j @Component public class AIModelServiceFactory { - + private final Map services; public AIModelServiceFactory(List serviceList) { this.services = serviceList.stream() - .collect(Collectors.toUnmodifiableMap(AIModelService::getModelType, Function.identity())); + .collect(Collectors.toUnmodifiableMap(AIModelService::getModelType, Function.identity())); } - /** - * 지정된 모델 타입에 해당하는 AI 서비스를 반환 - * @param modelType AI 모델 타입 - * @return 해당 모델 타입의 AIModelService 구현체 - * @throws IllegalArgumentException 지원하지 않는 모델 타입인 경우 - */ public AIModelService getService(AIModelType modelType) { AIModelService service = services.get(modelType); if (service == null) { @@ -33,4 +27,4 @@ public AIModelService getService(AIModelType modelType) { } return service; } -} \ No newline at end of file +} diff --git a/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java new file mode 100644 index 0000000..38eef98 --- /dev/null +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java @@ -0,0 +1,7 @@ +package com.synapse.chat_service.service.ai; + +public enum AIModelType { + GPT, + CLAUDE, + GEMINI +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/infra/ClaudeService.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/infra/ClaudeService.java similarity index 100% rename from chat_service/src/main/java/com/synapse/chat_service/service/infra/ClaudeService.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/service/infra/ClaudeService.java diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java similarity index 95% rename from chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java index 365942e..c942b8c 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/infra/GPTService.java @@ -12,9 +12,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -/** - * OpenAI GPT 모델을 사용하는 AI 서비스 구현체 - */ @Slf4j @Service @RequiredArgsConstructor diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java similarity index 95% rename from chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java index a2c8ff2..1ca53f9 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/service/infra/GeminiService.java @@ -12,9 +12,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -/** - * Google Gemini 모델을 사용하는 AI 서비스 구현체 - */ @Slf4j @Service @RequiredArgsConstructor diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java similarity index 91% rename from chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java index 79f8574..34eadd0 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisAiChatManager.java @@ -1,21 +1,19 @@ package com.synapse.chat_service.session; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + import com.synapse.chat_service.common.annotation.RedisOperation; import com.synapse.chat_service.common.util.RedisTypeConverter; -import com.synapse.chat_service.session.dto.AiChatInfo; +import com.synapse.chat_service_api.dto.session.AiChatInfo; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.util.Optional; -import java.util.UUID; -/** - * AI 채팅 세션을 Redis로 관리하는 매니저 - * 사용자와 AI 간의 1:1 채팅 세션 정보를 관리합니다. - */ @Slf4j @Service @RequiredArgsConstructor @@ -24,10 +22,10 @@ public class RedisAiChatManager { private final RedisTemplate redisTemplate; private final RedisKeyGenerator keyGenerator; private final RedisTypeConverter typeConverter; - + // AI 채팅 정보는 30일간 유지 (사용자가 다시 접속할 수 있도록) private static final Duration AI_CHAT_EXPIRATION = Duration.ofDays(30); - + /** * AI 채팅 정보 조회 */ @@ -38,7 +36,7 @@ public Optional getAiChat(String userId) { AiChatInfo aiChat = typeConverter.convertValue(rawValue, AiChatInfo.class); return Optional.ofNullable(aiChat); } - + /** * AI 채팅 활동 시간 업데이트 */ @@ -47,15 +45,15 @@ public void updateAiChatActivity(String userId) { String key = keyGenerator.generateAIConversationKey(userId); Object rawValue = redisTemplate.opsForValue().get(key); AiChatInfo aiChat = typeConverter.convertValue(rawValue, AiChatInfo.class); - + if (aiChat != null) { AiChatInfo updatedChat = aiChat.updateLastActivity(); redisTemplate.opsForValue().set(key, updatedChat, AI_CHAT_EXPIRATION); - + log.debug("AI 채팅 활동 시간 업데이트: userId={}", userId); } } - + /** * AI 채팅 메시지 수 증가 */ @@ -64,16 +62,16 @@ public void incrementMessageCount(String userId) { String key = keyGenerator.generateAIConversationKey(userId); Object rawValue = redisTemplate.opsForValue().get(key); AiChatInfo aiChat = typeConverter.convertValue(rawValue, AiChatInfo.class); - + if (aiChat != null) { AiChatInfo updatedChat = aiChat.incrementMessageCount(); redisTemplate.opsForValue().set(key, updatedChat, AI_CHAT_EXPIRATION); - - log.debug("AI 채팅 메시지 수 증가: userId={}, count={}", + + log.debug("AI 채팅 메시지 수 증가: userId={}, count={}", userId, updatedChat.messageCount()); } } - + /** * AI 채팅 정보 삭제 (사용자 탈퇴 등의 경우) */ @@ -81,10 +79,10 @@ public void incrementMessageCount(String userId) { public void deleteAiChat(String userId) { String key = keyGenerator.generateAIConversationKey(userId); redisTemplate.delete(key); - + log.info("AI 채팅 정보 삭제: userId={}", userId); } - + /** * 실제 Conversation UUID를 사용하여 AI 채팅 세션 생성 또는 업데이트 * Redis와 DB 간의 일관성을 보장합니다. @@ -92,11 +90,11 @@ public void deleteAiChat(String userId) { @RedisOperation("UUID 기반 AI 채팅 세션 생성/업데이트") public AiChatInfo createOrUpdateAiChatWithConversation(String userId, UUID conversationId) { String key = keyGenerator.generateAIConversationKey(userId); - + // 1. 기존 AI 채팅 정보 조회 Object rawValue = redisTemplate.opsForValue().get(key); AiChatInfo existingChat = typeConverter.convertValue(rawValue, AiChatInfo.class); - + if (existingChat != null) { // 기존 채팅이 있으면 conversationId 업데이트 및 활동 시간 갱신 AiChatInfo updatedChat = new AiChatInfo( @@ -104,24 +102,23 @@ public AiChatInfo createOrUpdateAiChatWithConversation(String userId, UUID conve conversationId, // 실제 DB의 UUID로 업데이트 existingChat.createdAt(), java.time.LocalDateTime.now(), // 활동 시간 갱신 - existingChat.messageCount() - ); + existingChat.messageCount()); redisTemplate.opsForValue().set(key, updatedChat, AI_CHAT_EXPIRATION); - - log.debug("AI 채팅 정보 업데이트: userId={}, conversationId={}", + + log.debug("AI 채팅 정보 업데이트: userId={}, conversationId={}", userId, conversationId); return updatedChat; } - + // 2. 새로운 AI 채팅 정보 생성 AiChatInfo newChat = AiChatInfo.create(userId, conversationId); redisTemplate.opsForValue().set(key, newChat, AI_CHAT_EXPIRATION); - - log.info("새로운 AI 채팅 정보 생성: userId={}, conversationId={}", + + log.info("새로운 AI 채팅 정보 생성: userId={}, conversationId={}", userId, conversationId); return newChat; } - + /** * 기존 Redis 정보의 conversationId를 실제 DB UUID와 동기화 */ @@ -130,7 +127,7 @@ public void syncConversationId(String userId, UUID conversationId) { String key = keyGenerator.generateAIConversationKey(userId); Object rawValue = redisTemplate.opsForValue().get(key); AiChatInfo aiChat = typeConverter.convertValue(rawValue, AiChatInfo.class); - + if (aiChat != null && !conversationId.equals(aiChat.conversationId())) { // conversationId가 다르면 동기화 AiChatInfo syncedChat = new AiChatInfo( @@ -138,11 +135,10 @@ public void syncConversationId(String userId, UUID conversationId) { conversationId, // 실제 DB의 UUID로 동기화 aiChat.createdAt(), java.time.LocalDateTime.now(), // 활동 시간 갱신 - aiChat.messageCount() - ); + aiChat.messageCount()); redisTemplate.opsForValue().set(key, syncedChat, AI_CHAT_EXPIRATION); - - log.info("Conversation ID 동기화: userId={}, oldId={}, newId={}", + + log.info("Conversation ID 동기화: userId={}, oldId={}, newId={}", userId, aiChat.conversationId(), conversationId); } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java similarity index 89% rename from chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java index 37f64ad..ae3acd8 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisKeyGenerator.java @@ -2,47 +2,44 @@ import org.springframework.stereotype.Component; -/** - * Redis 키 생성 전략을 담당하는 유틸리티 클래스 - * 일관된 키 네이밍 규칙을 통해 Redis 데이터 관리의 효율성을 높입니다. - */ @Component public class RedisKeyGenerator { - + // 키 접두사 상수 private static final String SESSION_PREFIX = "session:"; private static final String USER_SESSION_PREFIX = "user:session:"; private static final String AI_CONVERSATION_PREFIX = "ai:conversation:"; - + /** * WebSocket 세션 키 생성 + * * @param sessionId WebSocket 세션 ID * @return Redis 키 (예: "session:abc123") */ public String generateSessionKey(String sessionId) { return SESSION_PREFIX + sessionId; } - + /** * 사용자별 세션 키 생성 + * * @param userId 사용자 ID * @return Redis 키 (예: "user:session:user123") */ public String generateUserSessionKey(String userId) { return USER_SESSION_PREFIX + userId; } - - /** * AI 대화 세션 키 생성 + * * @param userId 사용자 ID * @return Redis 키 (예: "ai:conversation:user123") */ public String generateAIConversationKey(String userId) { return AI_CONVERSATION_PREFIX + userId; } - + /** * AI 채팅 정보 키 생성 * 패턴: "ai:chat:{userId}" @@ -50,18 +47,20 @@ public String generateAIConversationKey(String userId) { public String generateAiChatKey(String userId) { return "ai:chat:" + userId; } - + /** * 패턴 매칭을 위한 와일드카드 키 생성 + * * @param prefix 접두사 * @return 와일드카드 패턴 (예: "session:*") */ public String generatePatternKey(String prefix) { return prefix + "*"; } - + /** * 모든 세션 키 패턴 + * * @return "session:*" */ public String getAllSessionsPattern() { diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java similarity index 90% rename from chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java index db8462c..64e9646 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/RedisSessionManager.java @@ -1,26 +1,23 @@ package com.synapse.chat_service.session; -import com.synapse.chat_service.common.annotation.RedisOperation; -import com.synapse.chat_service.common.util.RedisTypeConverter; -import com.synapse.chat_service.session.dto.SessionInfo; -import com.synapse.chat_service.session.dto.SessionStatus; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; -import java.time.Duration; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; +import com.synapse.chat_service.common.annotation.RedisOperation; +import com.synapse.chat_service.common.util.RedisTypeConverter; +import com.synapse.chat_service_api.dto.session.SessionInfo; +import com.synapse.chat_service_api.dto.session.SessionStatus; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; -/** - * Redis를 사용한 WebSocket 세션 관리 서비스 - * 다중 기기 동시 접속을 지원하는 세션의 생성, 조회, 업데이트, 삭제를 담당합니다. - */ @Slf4j @Service @RequiredArgsConstructor @@ -31,7 +28,7 @@ public class RedisSessionManager { private final RedisKeyGenerator keyGenerator; private final RedisTypeConverter typeConverter; private final SessionProperties sessionProperties; - + /** * 새로운 세션 생성 (다중 세션 지원, 트랜잭션 원자성 보장) */ @@ -39,7 +36,7 @@ public class RedisSessionManager { public void createSession(SessionInfo sessionInfo) { String sessionKey = keyGenerator.generateSessionKey(sessionInfo.sessionId()); String userSessionKey = keyGenerator.generateUserSessionKey(sessionInfo.userId()); - + // 최대 세션 수 확인 및 제한 int currentSessionCount = getActiveSessionCount(sessionInfo.userId()); if (currentSessionCount >= sessionProperties.maxSessionsPerUser()) { @@ -47,17 +44,18 @@ public void createSession(SessionInfo sessionInfo) { removeOldestSession(sessionInfo.userId()); log.info("최대 세션 수 초과로 가장 오래된 세션 제거: userId={}", sessionInfo.userId()); } - + // Redis 트랜잭션을 사용하여 원자성 보장 redisTemplate.execute((RedisCallback) connection -> { try { // 트랜잭션 시작 connection.multi(); - + // 1. 세션 정보 저장 (설정된 시간 TTL) byte[] sessionKeyBytes = sessionKey.getBytes(); byte[] sessionValueBytes = typeConverter.convertToBytes(sessionInfo); - connection.stringCommands().setEx(sessionKeyBytes, Duration.ofHours(sessionProperties.expirationHours()).toSeconds(), sessionValueBytes); + connection.stringCommands().setEx(sessionKeyBytes, + Duration.ofHours(sessionProperties.expirationHours()).toSeconds(), sessionValueBytes); // 2. 사용자별 세션 Set에 sessionId 추가 byte[] userSessionKeyBytes = userSessionKey.getBytes(); @@ -65,24 +63,25 @@ public void createSession(SessionInfo sessionInfo) { connection.setCommands().sAdd(userSessionKeyBytes, sessionIdBytes); // 3. 사용자 세션 Set TTL 설정 (설정된 시간) - connection.keyCommands().expire(userSessionKeyBytes, Duration.ofHours(sessionProperties.expirationHours()).toSeconds()); - + connection.keyCommands().expire(userSessionKeyBytes, + Duration.ofHours(sessionProperties.expirationHours()).toSeconds()); + // 트랜잭션 실행 connection.exec(); - - log.info("세션 생성 완료 (트랜잭션): sessionId={}, userId={}, 총 세션 수={}", + + log.info("세션 생성 완료 (트랜잭션): sessionId={}, userId={}, 총 세션 수={}", sessionInfo.sessionId(), sessionInfo.userId(), currentSessionCount + 1); - + return null; - + } catch (Exception e) { - log.error("세션 생성 트랜잭션 실패: sessionId={}, userId={}", - sessionInfo.sessionId(), sessionInfo.userId(), e); + log.error("세션 생성 트랜잭션 실패: sessionId={}, userId={}", + sessionInfo.sessionId(), sessionInfo.userId(), e); throw new RuntimeException("세션 생성 트랜잭션 실패", e); } }); } - + /** * 세션 ID로 세션 조회 */ @@ -91,11 +90,11 @@ public SessionInfo getSession(String sessionId) { String sessionKey = keyGenerator.generateSessionKey(sessionId); Object rawValue = redisTemplate.opsForValue().get(sessionKey); SessionInfo sessionInfo = typeConverter.convertValue(rawValue, SessionInfo.class); - + log.debug("세션 조회: sessionId={}, found={}", sessionId, sessionInfo != null); return sessionInfo; } - + /** * 사용자 ID로 세션 정보 조회 (첫 번째 세션 반환) */ @@ -103,27 +102,27 @@ public SessionInfo getSession(String sessionId) { public SessionInfo getSessionByUserId(String userId) { String userSessionKey = keyGenerator.generateUserSessionKey(userId); Set sessionIds = redisTemplate.opsForSet().members(userSessionKey); - + if (sessionIds == null || sessionIds.isEmpty()) { log.debug("사용자 세션 ID를 찾을 수 없음: userId={}", userId); return null; } - + // 첫 번째 세션 반환 (기존 호환성 유지) String sessionId = typeConverter.convertToString(sessionIds.iterator().next()); return getSession(sessionId); } - + /** * 세션 정보 업데이트 */ @RedisOperation("세션 업데이트") public void updateSession(SessionInfo sessionInfo) { String sessionKey = keyGenerator.generateSessionKey(sessionInfo.sessionId()); - + // 세션 정보 업데이트 (설정된 시간 TTL) redisTemplate.opsForValue().set(sessionKey, sessionInfo, Duration.ofHours(sessionProperties.expirationHours())); - + log.debug("세션 업데이트 완료: sessionId={}", sessionInfo.sessionId()); } @@ -136,48 +135,48 @@ public void deleteSession(String sessionId) { if (sessionInfo != null) { String sessionKey = keyGenerator.generateSessionKey(sessionId); String userSessionKey = keyGenerator.generateUserSessionKey(sessionInfo.userId()); - + // Redis 트랜잭션으로 원자성 보장 redisTemplate.execute((RedisCallback) connection -> { try { // 트랜잭션 시작 connection.multi(); - + // 1. 개별 세션 삭제 byte[] sessionKeyBytes = sessionKey.getBytes(); connection.keyCommands().del(sessionKeyBytes); - + // 2. 사용자 세션 Set에서 해당 sessionId 제거 byte[] userSessionKeyBytes = userSessionKey.getBytes(); byte[] sessionIdBytes = sessionId.getBytes(); connection.setCommands().sRem(userSessionKeyBytes, sessionIdBytes); - + // 트랜잭션 실행 connection.exec(); - + log.info("세션 삭제 완료 (트랜잭션): sessionId={}, userId={}", sessionId, sessionInfo.userId()); - + return null; - + } catch (Exception e) { - log.error("세션 삭제 트랜잭션 실패: sessionId={}, userId={}", - sessionId, sessionInfo.userId(), e); + log.error("세션 삭제 트랜잭션 실패: sessionId={}, userId={}", + sessionId, sessionInfo.userId(), e); throw new RuntimeException("세션 삭제 트랜잭션 실패", e); } }); } } - + /** * 사용자의 모든 세션 강제 삭제 (관리자 기능) */ @RedisOperation(value = "사용자 모든 세션 삭제", rethrowException = false) public void deleteAllUserSessions(String userId) { String userSessionKey = keyGenerator.generateUserSessionKey(userId); - + // 1. 모든 세션 ID 조회 Set sessionIds = redisTemplate.opsForSet().members(userSessionKey); - + if (sessionIds != null && !sessionIds.isEmpty()) { // 2. 각 세션 개별 삭제 for (Object sessionIdObj : sessionIds) { @@ -189,13 +188,13 @@ public void deleteAllUserSessions(String userId) { } } } - + // 3. 사용자-세션 Set 삭제 redisTemplate.delete(userSessionKey); - log.info("사용자 모든 세션 삭제 완료: userId={}, 삭제된 세션 수={}", + log.info("사용자 모든 세션 삭제 완료: userId={}, 삭제된 세션 수={}", userId, sessionIds != null ? sessionIds.size() : 0); } - + /** * 세션 상태 변경 */ @@ -208,7 +207,7 @@ public void changeSessionStatus(String sessionId, SessionStatus newStatus) { log.info("세션 상태 변경: sessionId={}, status={}", sessionId, newStatus); } } - + /** * 세션 존재 여부 확인 */ @@ -217,7 +216,7 @@ public boolean existsSession(String sessionId) { String sessionKey = keyGenerator.generateSessionKey(sessionId); return Boolean.TRUE.equals(redisTemplate.hasKey(sessionKey)); } - + /** * 사용자 세션 존재 여부 확인 (다중 세션 지원) */ @@ -227,7 +226,7 @@ public boolean existsSessionByUserId(String userId) { Long sessionCount = redisTemplate.opsForSet().size(userSessionKey); return sessionCount != null && sessionCount > 0; } - + /** * 사용자의 모든 세션 정보 조회 */ @@ -235,12 +234,12 @@ public boolean existsSessionByUserId(String userId) { public List getSessionsByUserId(String userId) { String userSessionKey = keyGenerator.generateUserSessionKey(userId); Set sessionIds = redisTemplate.opsForSet().members(userSessionKey); - + if (sessionIds == null || sessionIds.isEmpty()) { log.debug("사용자 세션을 찾을 수 없음: userId={}", userId); return List.of(); } - + return sessionIds.stream() .map(sessionIdObj -> typeConverter.convertToString(sessionIdObj)) .filter(sessionId -> sessionId != null) @@ -248,7 +247,7 @@ public List getSessionsByUserId(String userId) { .filter(sessionInfo -> sessionInfo != null) .collect(Collectors.toList()); } - + /** * 사용자의 활성 세션 수 조회 */ @@ -258,7 +257,7 @@ public int getActiveSessionCount(String userId) { Long sessionCount = redisTemplate.opsForSet().size(userSessionKey); return sessionCount != null ? sessionCount.intValue() : 0; } - + /** * 가장 오래된 세션 제거 (최대 세션 수 초과 시 사용) */ @@ -270,10 +269,10 @@ private void removeOldestSession(String userId) { SessionInfo oldestSession = sessions.stream() .min((s1, s2) -> s1.connectedAt().compareTo(s2.connectedAt())) .orElse(null); - + if (oldestSession != null) { deleteSession(oldestSession.sessionId()); - log.info("가장 오래된 세션 제거: sessionId={}, userId={}", + log.info("가장 오래된 세션 제거: sessionId={}, userId={}", oldestSession.sessionId(), userId); } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java similarity index 66% rename from chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java index 52509ce..bb267ac 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/SessionProperties.java @@ -5,19 +5,14 @@ import jakarta.validation.constraints.Min; -/** - * 세션 관련 설정 프로퍼티 - * - * @param expirationHours 세션 만료 시간 (시간 단위) - * @param maxSessionsPerUser 사용자당 최대 세션 수 - */ @Validated @ConfigurationProperties(prefix = "session") public record SessionProperties( - @Min(value = 1, message = "세션 만료 시간은 최소 1시간 이상이어야 합니다.") + @Min(value = 1, message = "세션 만료 시간은 최소 1시간 이상이어야 합니다.") int expirationHours, - - @Min(value = 1, message = "사용자당 최대 세션 수는 최소 1개 이상이어야 합니다.") + + @Min(value = 1, message = "사용자당 최대 세션 수는 최소 1개 이상이어야 합니다.") int maxSessionsPerUser ) { + } diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java similarity index 91% rename from chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java rename to chat_service/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java index dcd4b33..4477a1e 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java +++ b/chat_service/chat_service/src/main/java/com/synapse/chat_service/session/WebSocketSessionFacade.java @@ -1,22 +1,20 @@ package com.synapse.chat_service.session; -import com.synapse.chat_service.session.dto.SessionInfo; +import org.springframework.stereotype.Component; + +import com.synapse.chat_service_api.dto.session.SessionInfo; + import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -/** - * AI 채팅 WebSocket 세션 관리를 위한 Facade 클래스 - * AI와의 1:1 채팅에 최적화된 간단한 세션 관리 로직을 제공합니다. - */ @Slf4j @Component @RequiredArgsConstructor public class WebSocketSessionFacade { - + private final RedisSessionManager sessionManager; private final RedisAiChatManager aiChatManager; - + /** * 사용자 연결 처리 * 1. 새로운 세션 생성 (다중 기기 동시 접속 지원) @@ -24,20 +22,20 @@ public class WebSocketSessionFacade { */ public SessionInfo handleUserConnection(String sessionId, String userId, String clientInfo) { log.info("AI 채팅 사용자 연결 처리 시작: sessionId={}, userId={}", sessionId, userId); - + // 1. 새로운 세션 생성 (다중 세션 지원) SessionInfo sessionInfo = SessionInfo.create(sessionId, userId, clientInfo); sessionManager.createSession(sessionInfo); - + // 2. AI 채팅 정보 조회 (MessageService에서 DB와 Redis 동기화가 이미 처리됨) // 기존 정보가 있으면 활동 시간만 업데이트 aiChatManager.updateAiChatActivity(userId); - + log.info("AI 채팅 사용자 연결 처리 완료: sessionId={}, userId={}", sessionId, userId); - + return sessionInfo; } - + /** * 사용자 연결 해제 처리 * 1. 세션 정보 조회 @@ -46,24 +44,24 @@ public SessionInfo handleUserConnection(String sessionId, String userId, String */ public void handleUserDisconnection(String sessionId) { log.info("AI 채팅 사용자 연결 해제 처리 시작: sessionId={}", sessionId); - + // 1. 세션 정보 조회 SessionInfo sessionInfo = sessionManager.getSession(sessionId); if (sessionInfo == null) { log.warn("연결 해제 시 세션을 찾을 수 없음: sessionId={}", sessionId); return; } - + // 2. AI 채팅 활동 시간 업데이트 (rethrowException = false) aiChatManager.updateAiChatActivity(sessionInfo.userId()); - + // 3. 세션 삭제 sessionManager.deleteSession(sessionId); - - log.info("AI 채팅 사용자 연결 해제 처리 완료: sessionId={}, userId={}", + + log.info("AI 채팅 사용자 연결 해제 처리 완료: sessionId={}, userId={}", sessionId, sessionInfo.userId()); } - + /** * 메시지 활동 처리 * 1. AI 채팅 메시지 수 증가 @@ -71,10 +69,10 @@ public void handleUserDisconnection(String sessionId) { */ public void handleMessageActivity(String userId) { log.debug("AI 채팅 메시지 활동 처리: userId={}", userId); - + // 1. AI 채팅 메시지 수 증가 (rethrowException = false) aiChatManager.incrementMessageCount(userId); - + // 2. AI 채팅 활동 시간 업데이트 (rethrowException = false) aiChatManager.updateAiChatActivity(userId); } @@ -84,14 +82,14 @@ public void handleMessageActivity(String userId) { */ public void updateSessionActivity(String sessionId) { log.debug("세션 활동 업데이트: sessionId={}", sessionId); - + SessionInfo sessionInfo = sessionManager.getSession(sessionId); if (sessionInfo != null) { SessionInfo updatedSession = sessionInfo.updateLastActivity(); sessionManager.updateSession(updatedSession); } } - + /** * 사용자의 대화 ID 조회 */ @@ -100,7 +98,7 @@ public String getConversationId(String userId) { .map(aiChat -> aiChat.conversationId().toString()) .orElse("ai-chat-" + userId); // 기본 패턴 반환 (호환성 유지) } - + /** * 사용자의 모든 세션 강제 삭제 (관리자 기능) */ diff --git a/chat_service/src/main/resources/application-local.yml b/chat_service/chat_service/src/main/resources/application-local.yml similarity index 100% rename from chat_service/src/main/resources/application-local.yml rename to chat_service/chat_service/src/main/resources/application-local.yml diff --git a/chat_service/src/main/resources/application.yml b/chat_service/chat_service/src/main/resources/application.yml similarity index 100% rename from chat_service/src/main/resources/application.yml rename to chat_service/chat_service/src/main/resources/application.yml diff --git a/chat_service/src/main/resources/static/chat-test.html b/chat_service/chat_service/src/main/resources/static/chat-test.html similarity index 100% rename from chat_service/src/main/resources/static/chat-test.html rename to chat_service/chat_service/src/main/resources/static/chat-test.html diff --git a/chat_service/chat_service/src/test/java/com/synapse/chat_service/TestConfig.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/TestConfig.java new file mode 100644 index 0000000..21a34bc --- /dev/null +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/TestConfig.java @@ -0,0 +1,14 @@ +package com.synapse.chat_service; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Transactional +@ActiveProfiles("test") +@SpringBootTest(classes = ChatServiceConfig.class) +@AutoConfigureMockMvc +public class TestConfig { + +} diff --git a/chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java similarity index 92% rename from chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java index c8bbeba..3e5b8e7 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/controller/AiChatControllerTest.java @@ -1,7 +1,5 @@ package com.synapse.chat_service.controller; -import com.synapse.chat_service.dto.response.MessageResponse; -import com.synapse.chat_service.service.MessageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,6 +9,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.ResponseEntity; +import com.synapse.chat_service.service.MessageService; +import com.synapse.chat_service_api.dto.response.MessageResponse; + import java.util.List; import java.util.UUID; @@ -38,8 +39,7 @@ void setUp() { UUID.randomUUID(), userId, java.time.LocalDateTime.now(), - java.time.LocalDateTime.now() - ); + java.time.LocalDateTime.now()); mockConversationList = List.of(conversationInfo); } @@ -50,12 +50,13 @@ void getMyConversationList_ShouldCallMessageServiceWithCorrectUserId() { when(messageService.getConversationListByUserId(userId)).thenReturn(mockConversationList); // When - ResponseEntity> response = aiChatController.getMyConversationList(userId); + ResponseEntity> response = aiChatController + .getMyConversationList(userId); // Then // messageService.getConversationListByUserId()가 컨트롤러에 전달된 userId로 호출되는지 검증 verify(messageService).getConversationListByUserId(userId); - + // 응답 검증 assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); assertThat(response.getBody()).isEqualTo(mockConversationList); diff --git a/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java similarity index 89% rename from chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java index a5c9a96..63967ca 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/controller/MessageControllerTest.java @@ -1,8 +1,5 @@ package com.synapse.chat_service.controller; -import com.synapse.chat_service.dto.request.MessageRequest; -import com.synapse.chat_service.service.MessageService; -import com.synapse.chat_service.service.ai.AIModelType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,6 +9,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.core.Authentication; +import com.synapse.chat_service.service.MessageService; +import com.synapse.chat_service.service.ai.AIModelType; +import com.synapse.chat_service_api.dto.request.MessageRequest; + import java.util.UUID; import static org.mockito.Mockito.verify; @@ -37,11 +38,10 @@ class MessageControllerTest { void setUp() { userId = UUID.randomUUID(); chatMessage = new MessageRequest.Chat( - AIModelType.GPT, + AIModelType.GPT.toString(), "안녕하세요", "test-session-id", - "test-message-id" - ); + "test-message-id"); } @Test @@ -54,9 +54,10 @@ void handleChatMessage_ShouldCallMessageServiceWithCorrectParameters() { messageController.handleChatMessage(chatMessage, authentication); // Then - // messageService.processAndRespondToMessage()가 메시지 페이로드와 인증 정보(userId)로 호출되는지 검증 + // messageService.processAndRespondToMessage()가 메시지 페이로드와 인증 정보(userId)로 호출되는지 + // 검증 verify(messageService).processAndRespondToMessage(userId, chatMessage); - + // Authentication에서 userId가 올바르게 추출되는지 검증 verify(authentication).getName(); } diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java similarity index 89% rename from chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java index d9e0554..5ccd3de 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/service/MessageServiceTest.java @@ -1,17 +1,8 @@ package com.synapse.chat_service.service; -import com.synapse.chat_service.domain.entity.Conversation; -import com.synapse.chat_service.domain.entity.Message; -import com.synapse.chat_service.domain.entity.enums.SenderType; -import com.synapse.chat_service.domain.repository.ConversationRepository; -import com.synapse.chat_service.domain.repository.MessageRepository; -import com.synapse.chat_service.dto.request.MessageRequest; -import com.synapse.chat_service.dto.response.MessageResponse; -import com.synapse.chat_service.service.ai.AIModelService; -import com.synapse.chat_service.service.ai.AIModelServiceFactory; -import com.synapse.chat_service.service.ai.AIModelType; -import com.synapse.chat_service.session.RedisAiChatManager; -import com.synapse.chat_service.testutil.TestObjectFactory; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -23,9 +14,18 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.messaging.simp.SimpMessagingTemplate; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.CompletableFuture; +import com.synapse.chat_service.domain.entity.Conversation; +import com.synapse.chat_service.domain.entity.Message; +import com.synapse.chat_service.domain.entity.enums.SenderType; +import com.synapse.chat_service.domain.repository.ConversationRepository; +import com.synapse.chat_service.domain.repository.MessageRepository; +import com.synapse.chat_service.service.ai.AIModelService; +import com.synapse.chat_service.service.ai.AIModelServiceFactory; +import com.synapse.chat_service.service.ai.AIModelType; +import com.synapse.chat_service.session.RedisAiChatManager; +import com.synapse.chat_service.testutil.TestObjectFactory; +import com.synapse.chat_service_api.dto.request.MessageRequest; +import com.synapse.chat_service_api.dto.response.MessageResponse; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; @@ -37,25 +37,25 @@ class MessageServiceTest { @Mock private MessageRepository messageRepository; - + @Mock private ConversationRepository conversationRepository; - + @Mock private RedisAiChatManager redisAiChatManager; - + @Mock private AIModelServiceFactory aiModelServiceFactory; - + @Mock private SimpMessagingTemplate messagingTemplate; - + @Mock private AIModelService aiModelService; - + @InjectMocks private MessageService messageService; - + private UUID userId; private String userMessage; private String aiResponse; @@ -63,25 +63,24 @@ class MessageServiceTest { private Message savedUserMessage; private Message savedAiMessage; private MessageRequest.Chat chatRequest; - + @BeforeEach void setUp() { userId = UUID.randomUUID(); userMessage = TestObjectFactory.TestConstants.DEFAULT_USER_MESSAGE; aiResponse = TestObjectFactory.TestConstants.DEFAULT_ASSISTANT_MESSAGE; - + conversation = TestObjectFactory.createConversation(userId); savedUserMessage = TestObjectFactory.createMessage(conversation, SenderType.USER, userMessage); savedAiMessage = TestObjectFactory.createMessage(conversation, SenderType.ASSISTANT, aiResponse); - + chatRequest = new MessageRequest.Chat( - AIModelType.GPT, - userMessage, - "test-session-id", - "test-message-id" - ); + AIModelType.GPT.toString(), + userMessage, + "test-session-id", + "test-message-id"); } - + @Test @DisplayName("시나리오 1: 신규 사용자 메시지 생성") void createMessage_NewUser_ShouldCreateConversationAndMessage() { @@ -89,127 +88,126 @@ void createMessage_NewUser_ShouldCreateConversationAndMessage() { when(conversationRepository.findByUserId(userId)).thenReturn(Optional.empty()); when(conversationRepository.save(any(Conversation.class))).thenReturn(conversation); when(messageRepository.save(any(Message.class))).thenReturn(savedUserMessage); - + // When: createMessage() 호출 MessageResponse.History result = messageService.createMessage(userId, SenderType.USER, userMessage); - + // Then: 검증 // conversationRepository.findByUserId()가 Optional.empty()를 반환하는지 검증 verify(conversationRepository).findByUserId(userId); - + // 새로운 Conversation 객체가 생성되어 conversationRepository.save()로 저장되는지 검증 ArgumentCaptor conversationCaptor = ArgumentCaptor.forClass(Conversation.class); verify(conversationRepository).save(conversationCaptor.capture()); Conversation capturedConversation = conversationCaptor.getValue(); assertThat(capturedConversation.getUserId()).isEqualTo(userId); - + // messageRepository.save()가 올바른 Message 객체로 호출되는지 확인 ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); verify(messageRepository).save(messageCaptor.capture()); Message capturedMessage = messageCaptor.getValue(); assertThat(capturedMessage.getSenderType()).isEqualTo(SenderType.USER); assertThat(capturedMessage.getContent()).isEqualTo(userMessage); - + // redisAiChatManager.createOrUpdateAiChatWithConversation()이 호출되는지 검증 verify(redisAiChatManager).createOrUpdateAiChatWithConversation( - userId.toString(), - conversation.getId() - ); - + userId.toString(), + conversation.getId()); + // 결과 검증 assertThat(result).isNotNull(); - assertThat(result.senderType()).isEqualTo(SenderType.USER); + assertThat(result.senderType()).isEqualTo(SenderType.USER.toString()); assertThat(result.content()).isEqualTo(userMessage); } - + @Test @DisplayName("시나리오 2: 기존 사용자 메시지 생성") void createMessage_ExistingUser_ShouldNotCreateConversation() { // Given: 기존에 대화가 있는 userId when(conversationRepository.findByUserId(userId)).thenReturn(Optional.of(conversation)); when(messageRepository.save(any(Message.class))).thenReturn(savedUserMessage); - + // When: createMessage() 호출 MessageResponse.History result = messageService.createMessage(userId, SenderType.USER, userMessage); - + // Then: 검증 // conversationRepository.findByUserId()가 Optional을 반환하는지 검증 verify(conversationRepository).findByUserId(userId); - + // conversationRepository.save()가 호출되지 않는지 검증 verify(conversationRepository, never()).save(any(Conversation.class)); - + // messageRepository.save()가 호출되는지 확인 verify(messageRepository).save(any(Message.class)); - + // Redis 동기화가 호출되는지 검증 verify(redisAiChatManager).syncConversationId(userId.toString(), conversation.getId()); - + // 결과 검증 assertThat(result).isNotNull(); - assertThat(result.senderType()).isEqualTo(SenderType.USER); + assertThat(result.senderType()).isEqualTo(SenderType.USER.toString()); assertThat(result.content()).isEqualTo(userMessage); } - + @Test @DisplayName("시나리오 3: AI 응답 처리 성공 (Happy Path)") void processAndRespondToMessage_Success_ShouldProcessCorrectly() { // Given: 유효한 MessageRequest.Chat 객체 when(conversationRepository.findByUserId(userId)).thenReturn(Optional.of(conversation)); when(messageRepository.save(any(Message.class))) - .thenReturn(savedUserMessage) - .thenReturn(savedAiMessage); + .thenReturn(savedUserMessage) + .thenReturn(savedAiMessage); when(aiModelServiceFactory.getService(AIModelType.GPT)).thenReturn(aiModelService); when(aiModelService.generateResponse(userMessage)) - .thenReturn(CompletableFuture.completedFuture(aiResponse)); - + .thenReturn(CompletableFuture.completedFuture(aiResponse)); + // When: processAndRespondToMessage() 호출 messageService.processAndRespondToMessage(userId, chatRequest); - + // 비동기 처리 완료를 위한 대기 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - + // Then: 검증 // 사용자 메시지 저장을 위해 createMessage(userId, SenderType.USER, ...)가 호출되는지 검증 ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); verify(messageRepository, times(2)).save(messageCaptor.capture()); - + // 첫 번째 저장은 사용자 메시지 Message firstSavedMessage = messageCaptor.getAllValues().get(0); assertThat(firstSavedMessage.getSenderType()).isEqualTo(SenderType.USER); assertThat(firstSavedMessage.getContent()).isEqualTo(userMessage); - + // aiModelServiceFactory.getService()가 올바른 AI 모델 타입으로 호출되는지 검증 verify(aiModelServiceFactory).getService(AIModelType.GPT); - + // AIModelService의 generateResponse()가 호출되는지 검증 verify(aiModelService).generateResponse(userMessage); - + // AI 응답 저장을 위해 createMessage(userId, SenderType.ASSISTANT, ...)가 호출되는지 검증 Message secondSavedMessage = messageCaptor.getAllValues().get(1); assertThat(secondSavedMessage.getSenderType()).isEqualTo(SenderType.ASSISTANT); assertThat(secondSavedMessage.getContent()).isEqualTo(aiResponse); - - // messagingTemplate.convertAndSendToUser()가 성공(SUCCESS) 상태의 MessageResponse.Chat 객체와 함께 호출되는지 검증 + + // messagingTemplate.convertAndSendToUser()가 성공(SUCCESS) 상태의 + // MessageResponse.Chat 객체와 함께 호출되는지 검증 ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageResponse.Chat.class); verify(messagingTemplate).convertAndSendToUser( - eq(userId.toString()), - eq("/queue/response"), - responseCaptor.capture() - ); - + eq(userId.toString()), + eq("/queue/response"), + responseCaptor.capture()); + MessageResponse.Chat capturedResponse = responseCaptor.getValue(); assertThat(capturedResponse.status()).isEqualTo(MessageResponse.ResponseStatus.SUCCESS); assertThat(capturedResponse.response()).isEqualTo(aiResponse); - assertThat(capturedResponse.modelType()).isEqualTo(AIModelType.GPT); + assertThat(capturedResponse.modelType()).isEqualTo(AIModelType.GPT.toString()); assertThat(capturedResponse.sessionId()).isEqualTo("test-session-id"); assertThat(capturedResponse.messageId()).isEqualTo("test-message-id"); } - + @Test @DisplayName("시나리오 4: AI 응답 처리 실패 (AI 모델 오류)") void processAndRespondToMessage_AIModelFailure_ShouldSendErrorResponse() { @@ -217,43 +215,42 @@ void processAndRespondToMessage_AIModelFailure_ShouldSendErrorResponse() { when(conversationRepository.findByUserId(userId)).thenReturn(Optional.of(conversation)); when(messageRepository.save(any(Message.class))).thenReturn(savedUserMessage); when(aiModelServiceFactory.getService(AIModelType.GPT)).thenReturn(aiModelService); - + // AI 모델 서비스가 실패하도록 설정 CompletableFuture failedFuture = CompletableFuture.failedFuture( - new RuntimeException("AI 모델 응답 생성 중 오류가 발생했습니다.") - ); + new RuntimeException("AI 모델 응답 생성 중 오류가 발생했습니다.")); when(aiModelService.generateResponse(userMessage)).thenReturn(failedFuture); - + // When: processAndRespondToMessage() 호출 messageService.processAndRespondToMessage(userId, chatRequest); - + // 비동기 처리 완료를 위한 대기 try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - + // Then: 검증 // 사용자 메시지는 저장되어야 함 verify(messageRepository, times(1)).save(any(Message.class)); - - // messagingTemplate.convertAndSendToUser()가 에러(ERROR) 상태의 MessageResponse.Chat 객체와 함께 호출되는지 검증 + + // messagingTemplate.convertAndSendToUser()가 에러(ERROR) 상태의 MessageResponse.Chat + // 객체와 함께 호출되는지 검증 ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(MessageResponse.Chat.class); verify(messagingTemplate).convertAndSendToUser( - eq(userId.toString()), - eq("/queue/response"), - responseCaptor.capture() - ); - + eq(userId.toString()), + eq("/queue/response"), + responseCaptor.capture()); + MessageResponse.Chat capturedResponse = responseCaptor.getValue(); assertThat(capturedResponse.status()).isEqualTo(MessageResponse.ResponseStatus.ERROR); assertThat(capturedResponse.response()).isNull(); assertThat(capturedResponse.errorMessage()).isNotNull(); - assertThat(capturedResponse.modelType()).isEqualTo(AIModelType.GPT); + assertThat(capturedResponse.modelType()).isEqualTo(AIModelType.GPT.toString()); assertThat(capturedResponse.sessionId()).isEqualTo("test-session-id"); assertThat(capturedResponse.messageId()).isEqualTo("test-message-id"); - + // AI 응답 메시지가 DB에 저장되지 않는지 확인 (사용자 메시지만 1번 저장됨) verify(messageRepository, times(1)).save(any(Message.class)); } diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java similarity index 94% rename from chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java index fd4d1c4..bc20ad8 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/ClaudeServiceTest.java @@ -62,7 +62,7 @@ void generateResponse_Success_ShouldReturnCompletedFuture() throws ExecutionExce assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(expectedResponse); - + // AnthropicChatModel의 call() 메서드가 올바른 프롬프트로 호출되었는지 검증 verify(anthropicChatModel).call(testPrompt); } @@ -81,14 +81,14 @@ void generateResponse_Failure_ShouldReturnFailedFuture() { assertThat(result).isNotNull(); assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isTrue(); - + // 예외가 올바르게 래핑되어 있는지 검증 assertThat(result) - .failsWithin(java.time.Duration.ofSeconds(1)) - .withThrowableOfType(ExecutionException.class) - .withCauseInstanceOf(RuntimeException.class) - .withMessageContaining("Claude 모델 응답 생성 중 오류가 발생했습니다."); - + .failsWithin(java.time.Duration.ofSeconds(1)) + .withThrowableOfType(ExecutionException.class) + .withCauseInstanceOf(RuntimeException.class) + .withMessageContaining("Claude 모델 응답 생성 중 오류가 발생했습니다."); + // AnthropicChatModel의 call() 메서드가 호출되었는지 검증 verify(anthropicChatModel).call(testPrompt); } @@ -109,7 +109,7 @@ void generateResponse_WithEmptyPrompt_ShouldHandleGracefully() throws ExecutionE assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(emptyResponse); - + verify(anthropicChatModel).call(emptyPrompt); } @@ -129,7 +129,7 @@ void generateResponse_WithLongPrompt_ShouldHandleGracefully() throws ExecutionEx assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(longResponse); - + verify(anthropicChatModel).call(longPrompt); } @@ -147,7 +147,7 @@ void generateResponse_WithNullResponse_ShouldHandleGracefully() throws Execution assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isNull(); - + verify(anthropicChatModel).call(testPrompt); } } diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java similarity index 93% rename from chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java index e641373..b51c741 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/GPTServiceTest.java @@ -62,7 +62,7 @@ void generateResponse_Success_ShouldReturnCompletedFuture() throws ExecutionExce assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(expectedResponse); - + // OpenAiChatModel의 call() 메서드가 올바른 프롬프트로 호출되었는지 검증 verify(openAiChatModel).call(testPrompt); } @@ -81,14 +81,14 @@ void generateResponse_Failure_ShouldReturnFailedFuture() { assertThat(result).isNotNull(); assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isTrue(); - + // 예외가 올바르게 래핑되어 있는지 검증 assertThat(result) - .failsWithin(java.time.Duration.ofSeconds(1)) - .withThrowableOfType(ExecutionException.class) - .withCauseInstanceOf(RuntimeException.class) - .withMessageContaining("GPT 모델 응답 생성 중 오류가 발생했습니다."); - + .failsWithin(java.time.Duration.ofSeconds(1)) + .withThrowableOfType(ExecutionException.class) + .withCauseInstanceOf(RuntimeException.class) + .withMessageContaining("GPT 모델 응답 생성 중 오류가 발생했습니다."); + // OpenAiChatModel의 call() 메서드가 호출되었는지 검증 verify(openAiChatModel).call(testPrompt); } @@ -109,7 +109,7 @@ void generateResponse_WithEmptyPrompt_ShouldHandleGracefully() throws ExecutionE assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(emptyResponse); - + verify(openAiChatModel).call(emptyPrompt); } @@ -129,7 +129,7 @@ void generateResponse_WithLongPrompt_ShouldHandleGracefully() throws ExecutionEx assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(longResponse); - + verify(openAiChatModel).call(longPrompt); } } diff --git a/chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java similarity index 94% rename from chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java index 6901baf..319544d 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/service/infra/GeminiServiceTest.java @@ -62,7 +62,7 @@ void generateResponse_Success_ShouldReturnCompletedFuture() throws ExecutionExce assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(expectedResponse); - + // VertexAiGeminiChatModel의 call() 메서드가 올바른 프롬프트로 호출되었는지 검증 verify(vertexAiGeminiChatModel).call(testPrompt); } @@ -81,14 +81,14 @@ void generateResponse_Failure_ShouldReturnFailedFuture() { assertThat(result).isNotNull(); assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isTrue(); - + // 예외가 올바르게 래핑되어 있는지 검증 assertThat(result) - .failsWithin(java.time.Duration.ofSeconds(1)) - .withThrowableOfType(ExecutionException.class) - .withCauseInstanceOf(RuntimeException.class) - .withMessageContaining("Gemini 모델 응답 생성 중 오류가 발생했습니다."); - + .failsWithin(java.time.Duration.ofSeconds(1)) + .withThrowableOfType(ExecutionException.class) + .withCauseInstanceOf(RuntimeException.class) + .withMessageContaining("Gemini 모델 응답 생성 중 오류가 발생했습니다."); + // VertexAiGeminiChatModel의 call() 메서드가 호출되었는지 검증 verify(vertexAiGeminiChatModel).call(testPrompt); } @@ -109,7 +109,7 @@ void generateResponse_WithEmptyPrompt_ShouldHandleGracefully() throws ExecutionE assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(emptyResponse); - + verify(vertexAiGeminiChatModel).call(emptyPrompt); } @@ -129,7 +129,7 @@ void generateResponse_WithLongPrompt_ShouldHandleGracefully() throws ExecutionEx assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(longResponse); - + verify(vertexAiGeminiChatModel).call(longPrompt); } @@ -147,13 +147,14 @@ void generateResponse_WithNullResponse_ShouldHandleGracefully() throws Execution assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isNull(); - + verify(vertexAiGeminiChatModel).call(testPrompt); } @Test @DisplayName("특수 문자가 포함된 프롬프트로 응답 생성 요청 시 정상 처리된다") - void generateResponse_WithSpecialCharacters_ShouldHandleGracefully() throws ExecutionException, InterruptedException { + void generateResponse_WithSpecialCharacters_ShouldHandleGracefully() + throws ExecutionException, InterruptedException { // Given String specialPrompt = "특수문자 테스트: !@#$%^&*()_+{}|:<>?[]\\;'\",./ 한글 English 123"; String specialResponse = "특수문자가 포함된 응답입니다."; @@ -167,7 +168,7 @@ void generateResponse_WithSpecialCharacters_ShouldHandleGracefully() throws Exec assertThat(result.isDone()).isTrue(); assertThat(result.isCompletedExceptionally()).isFalse(); assertThat(result.get()).isEqualTo(specialResponse); - + verify(vertexAiGeminiChatModel).call(specialPrompt); } } diff --git a/chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java similarity index 98% rename from chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java index 1ad9576..a9432ae 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/session/RedisAiChatManagerTest.java @@ -1,7 +1,14 @@ package com.synapse.chat_service.session; -import com.synapse.chat_service.common.util.RedisTypeConverter; -import com.synapse.chat_service.session.dto.AiChatInfo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,13 +19,8 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; -import java.time.Duration; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import com.synapse.chat_service.common.util.RedisTypeConverter; +import com.synapse.chat_service_api.dto.session.AiChatInfo; @ExtendWith(MockitoExtension.class) @DisplayName("RedisAiChatManager 테스트") @@ -68,10 +70,10 @@ void getAiChat_ShouldGenerateCorrectKey_AndCallRedisTemplate() { assertThat(result).isPresent(); assertThat(result.get().userId()).isEqualTo(testUserId); assertThat(result.get().conversationId()).isEqualTo(testConversationId); - + // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 verify(redisTemplate).opsForValue(); verify(valueOperations).get(testAiChatKey); @@ -91,10 +93,10 @@ void getAiChat_WhenNoData_ShouldReturnEmptyOptional() { // Then assertThat(result).isEmpty(); - + // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 verify(redisTemplate).opsForValue(); verify(valueOperations).get(testAiChatKey); @@ -115,7 +117,7 @@ void updateAiChatActivity_ShouldGenerateCorrectKey_AndCallRedisTemplate() { // Then // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 - get과 set 모두 호출되어야 함 verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 verify(valueOperations).get(testAiChatKey); @@ -137,7 +139,7 @@ void updateAiChatActivity_WhenNoData_ShouldNotCallRedisSet() { // Then // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 - get만 호출되고 set은 호출되지 않아야 함 verify(redisTemplate).opsForValue(); verify(valueOperations).get(testAiChatKey); @@ -159,7 +161,7 @@ void incrementMessageCount_ShouldGenerateCorrectKey_AndCallRedisTemplate() { // Then // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 - get과 set 모두 호출되어야 함 verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 verify(valueOperations).get(testAiChatKey); @@ -181,7 +183,7 @@ void incrementMessageCount_WhenNoData_ShouldNotCallRedisSet() { // Then // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 - get만 호출되고 set은 호출되지 않아야 함 verify(redisTemplate).opsForValue(); verify(valueOperations).get(testAiChatKey); @@ -201,7 +203,7 @@ void deleteAiChat_ShouldGenerateCorrectKey_AndCallRedisTemplate() { // Then // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 verify(redisTemplate).delete(testAiChatKey); } @@ -221,10 +223,10 @@ void createOrUpdateAiChatWithConversation_WhenNewChat_ShouldGenerateCorrectKey_A assertThat(result).isNotNull(); assertThat(result.userId()).isEqualTo(testUserId); assertThat(result.conversationId()).isEqualTo(testConversationId); - + // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 verify(valueOperations).get(testAiChatKey); @@ -248,10 +250,10 @@ void createOrUpdateAiChatWithConversation_WhenExistingChat_ShouldGenerateCorrect assertThat(result).isNotNull(); assertThat(result.userId()).isEqualTo(testUserId); assertThat(result.conversationId()).isEqualTo(newConversationId); - + // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 verify(valueOperations).get(testAiChatKey); @@ -274,7 +276,7 @@ void syncConversationId_WhenDifferentId_ShouldGenerateCorrectKey_AndCallRedisTem // Then // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 verify(redisTemplate, times(2)).opsForValue(); // get용 1번, set용 1번 verify(valueOperations).get(testAiChatKey); @@ -296,7 +298,7 @@ void syncConversationId_WhenSameId_ShouldNotCallRedisSet() { // Then // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 - get만 호출되고 set은 호출되지 않아야 함 verify(redisTemplate).opsForValue(); verify(valueOperations).get(testAiChatKey); @@ -319,7 +321,7 @@ void syncConversationId_WhenNoData_ShouldNotCallRedisSet() { // Then // 키 생성 검증 verify(keyGenerator).generateAIConversationKey(testUserId); - + // Redis 호출 검증 - get만 호출되고 set은 호출되지 않아야 함 verify(redisTemplate).opsForValue(); verify(valueOperations).get(testAiChatKey); diff --git a/chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java similarity index 98% rename from chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java index 2f0fbf2..48fc590 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/session/RedisSessionManagerTest.java @@ -1,7 +1,5 @@ package com.synapse.chat_service.session; -import com.synapse.chat_service.common.util.RedisTypeConverter; -import com.synapse.chat_service.session.dto.SessionInfo; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,13 +12,16 @@ import org.springframework.data.redis.core.SetOperations; import org.springframework.data.redis.core.ValueOperations; -import java.time.Duration; -import java.util.Set; +import com.synapse.chat_service.common.util.RedisTypeConverter; +import com.synapse.chat_service_api.dto.session.SessionInfo; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import java.time.Duration; +import java.util.Set; + @ExtendWith(MockitoExtension.class) @DisplayName("RedisSessionManager 테스트") class RedisSessionManagerTest { @@ -58,13 +59,12 @@ void setUp() { testSessionInfo = SessionInfo.create( testSessionId, testUserId, - testClientInfo - ); + testClientInfo); // RedisTemplate operations mocking lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); lenient().when(redisTemplate.opsForSet()).thenReturn(setOperations); - + // SessionProperties mocking lenient().when(sessionProperties.maxSessionsPerUser()).thenReturn(3); lenient().when(sessionProperties.expirationHours()).thenReturn(24); @@ -84,10 +84,10 @@ void getSession_ShouldGenerateCorrectKey_AndCallRedisTemplate() { // Then assertThat(result).isNotNull(); assertThat(result.sessionId()).isEqualTo(testSessionId); - + // 키 생성 검증 verify(keyGenerator).generateSessionKey(testSessionId); - + // Redis 호출 검증 verify(redisTemplate).opsForValue(); verify(valueOperations).get(testSessionKey); @@ -106,7 +106,7 @@ void updateSession_ShouldGenerateCorrectKey_AndCallRedisTemplate() { // Then // 키 생성 검증 verify(keyGenerator).generateSessionKey(testSessionInfo.sessionId()); - + // Redis 호출 검증 verify(redisTemplate).opsForValue(); verify(valueOperations).set(eq(testSessionKey), eq(testSessionInfo), eq(Duration.ofHours(24))); @@ -124,10 +124,10 @@ void getActiveSessionCount_ShouldGenerateCorrectKey_AndCallRedisTemplate() { // Then assertThat(result).isEqualTo(2); - + // 키 생성 검증 verify(keyGenerator).generateUserSessionKey(testUserId); - + // Redis 호출 검증 verify(redisTemplate).opsForSet(); verify(setOperations).size(testUserSessionKey); @@ -150,11 +150,11 @@ void getSessionByUserId_ShouldGenerateCorrectKey_AndCallRedisTemplate() { // Then assertThat(result).isNotNull(); assertThat(result.userId()).isEqualTo(testUserId); - + // 키 생성 검증 verify(keyGenerator).generateUserSessionKey(testUserId); verify(keyGenerator).generateSessionKey(testSessionId); - + // Redis 호출 검증 verify(redisTemplate).opsForSet(); // members 호출 verify(setOperations).members(testUserSessionKey); @@ -174,10 +174,10 @@ void existsSession_ShouldGenerateCorrectKey_AndCallRedisTemplate() { // Then assertThat(result).isTrue(); - + // 키 생성 검증 verify(keyGenerator).generateSessionKey(testSessionId); - + // Redis 호출 검증 verify(redisTemplate).hasKey(testSessionKey); } @@ -191,7 +191,7 @@ void createSession_WhenMaxSessionsExceeded_ShouldCallRemoveOldestSession() { when(keyGenerator.generateUserSessionKey(testSessionInfo.userId())).thenReturn(testUserSessionKey); when(sessionProperties.maxSessionsPerUser()).thenReturn(2); // 최대 2개 세션 when(setOperations.size(testUserSessionKey)).thenReturn(3L); // 현재 3개 세션 (초과) - + // removeOldestSession에서 사용할 기존 세션들 설정 SessionInfo oldSession1 = SessionInfo.create("old-session-1", testUserId, "Old Client 1"); SessionInfo oldSession2 = SessionInfo.create("old-session-2", testUserId, "Old Client 2"); @@ -219,14 +219,14 @@ void createSession_WhenMaxSessionsExceeded_ShouldCallRemoveOldestSession() { // 4. removeOldestSession -> deleteSession 호출 시 verify(keyGenerator).generateSessionKey(testSessionInfo.sessionId()); verify(keyGenerator, times(4)).generateUserSessionKey(testSessionInfo.userId()); - + // 활성 세션 수 조회 검증 (removeOldestSession 호출 여부 판단용) verify(redisTemplate, atLeastOnce()).opsForSet(); verify(setOperations, atLeastOnce()).size(testUserSessionKey); - + // removeOldestSession 내부에서 호출되는 메서드들 검증 verify(setOperations, atLeastOnce()).members(testUserSessionKey); - + // 세션 생성을 위한 Redis 트랜잭션 실행 검증 verify(redisTemplate, atLeastOnce()).execute(any(RedisCallback.class)); } @@ -240,7 +240,7 @@ void createSession_WhenMaxSessionsNotExceeded_ShouldNotCallRemoveOldestSession() when(keyGenerator.generateUserSessionKey(testSessionInfo.userId())).thenReturn(testUserSessionKey); when(sessionProperties.maxSessionsPerUser()).thenReturn(5); // 최대 5개 세션 when(setOperations.size(testUserSessionKey)).thenReturn(2L); // 현재 2개 세션 (미만) - + // RedisCallback 실행을 위한 Mock 설정 when(redisTemplate.execute(any(RedisCallback.class))).thenReturn(null); @@ -253,14 +253,14 @@ void createSession_WhenMaxSessionsNotExceeded_ShouldNotCallRemoveOldestSession() // 2. getActiveSessionCount 호출 시 verify(keyGenerator).generateSessionKey(testSessionInfo.sessionId()); verify(keyGenerator, times(2)).generateUserSessionKey(testSessionInfo.userId()); - + // 활성 세션 수 조회 검증 verify(redisTemplate).opsForSet(); verify(setOperations).size(testUserSessionKey); - + // removeOldestSession이 호출되지 않았음을 검증 (members 호출이 없음) verify(setOperations, never()).members(testUserSessionKey); - + // 세션 생성을 위한 Redis 트랜잭션 실행 검증 verify(redisTemplate).execute(any(RedisCallback.class)); } @@ -283,7 +283,7 @@ void deleteSession_ShouldGenerateCorrectKeys_AndCallRedisTemplate() { // 키 생성 검증 verify(keyGenerator, times(2)).generateSessionKey(testSessionId); // getSession + deleteSession verify(keyGenerator).generateUserSessionKey(testUserId); - + // Redis 호출 검증 verify(redisTemplate).opsForValue(); verify(valueOperations).get(testSessionKey); @@ -309,7 +309,7 @@ void deleteAllUserSessions_ShouldGenerateCorrectKeys_AndCallRedisTemplate() { verify(keyGenerator).generateUserSessionKey(testUserId); verify(keyGenerator).generateSessionKey("session1"); verify(keyGenerator).generateSessionKey("session2"); - + // Redis 호출 검증 verify(redisTemplate).opsForSet(); verify(setOperations).members(testUserSessionKey); diff --git a/chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java similarity index 93% rename from chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java index d389a1e..176bf5a 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/session/WebSocketSessionFacadeTest.java @@ -1,7 +1,12 @@ package com.synapse.chat_service.session; -import com.synapse.chat_service.session.dto.SessionInfo; -import com.synapse.chat_service.session.dto.SessionStatus; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -10,17 +15,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.time.LocalDateTime; +import com.synapse.chat_service_api.dto.session.SessionInfo; +import com.synapse.chat_service_api.dto.session.SessionStatus; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -/** - * WebSocketSessionFacade 테스트 클래스 - * 세션 관리 퍼사드의 사용자 연결 및 연결 해제 처리를 검증합니다. - */ @ExtendWith(MockitoExtension.class) @DisplayName("WebSocketSessionFacade 테스트") class WebSocketSessionFacadeTest { @@ -44,15 +41,14 @@ void setUp() { testSessionId = "test-session-123"; testUserId = "test-user-456"; testClientInfo = "Chrome/120.0 Windows"; - + testSessionInfo = new SessionInfo( testSessionId, testUserId, LocalDateTime.now(), LocalDateTime.now(), SessionStatus.CONNECTED, - testClientInfo - ); + testClientInfo); } @Test @@ -64,14 +60,12 @@ void handleUserConnection_ShouldCallCorrectMethods_WithCorrectArguments() { // Then // 1. sessionManager.createSession()이 올바른 SessionInfo로 호출되는지 검증 verify(sessionManager, times(1)).createSession(any(SessionInfo.class)); - + // createSession에 전달된 SessionInfo의 내용 검증 - verify(sessionManager).createSession(argThat(sessionInfo -> - sessionInfo.sessionId().equals(testSessionId) && + verify(sessionManager).createSession(argThat(sessionInfo -> sessionInfo.sessionId().equals(testSessionId) && sessionInfo.userId().equals(testUserId) && sessionInfo.clientInfo().equals(testClientInfo) && - sessionInfo.status() == SessionStatus.CONNECTED - )); + sessionInfo.status() == SessionStatus.CONNECTED)); // 2. aiChatManager.updateAiChatActivity()가 올바른 userId로 호출되는지 검증 verify(aiChatManager, times(1)).updateAiChatActivity(eq(testUserId)); @@ -166,13 +160,11 @@ void updateSessionActivity_WithExistingSession_ShouldCallCorrectMethods() { verify(sessionManager, times(1)).updateSession(any(SessionInfo.class)); // 3. updateSession에 전달된 SessionInfo가 올바른 필드를 가지는지 검증 - verify(sessionManager).updateSession(argThat(sessionInfo -> - sessionInfo.sessionId().equals(testSessionId) && + verify(sessionManager).updateSession(argThat(sessionInfo -> sessionInfo.sessionId().equals(testSessionId) && sessionInfo.userId().equals(testUserId) && sessionInfo.clientInfo().equals(testClientInfo) && sessionInfo.status() == SessionStatus.CONNECTED && - !sessionInfo.lastActivityAt().isBefore(testSessionInfo.lastActivityAt()) - )); + !sessionInfo.lastActivityAt().isBefore(testSessionInfo.lastActivityAt()))); } @Test diff --git a/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java b/chat_service/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java similarity index 94% rename from chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java rename to chat_service/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java index debf52d..774a3d0 100644 --- a/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java +++ b/chat_service/chat_service/src/test/java/com/synapse/chat_service/testutil/TestObjectFactory.java @@ -1,19 +1,15 @@ package com.synapse.chat_service.testutil; -import com.synapse.chat_service.domain.entity.Conversation; +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.UUID; + import com.synapse.chat_service.domain.entity.ChatUsage; +import com.synapse.chat_service.domain.entity.Conversation; import com.synapse.chat_service.domain.entity.Message; import com.synapse.chat_service.domain.entity.enums.SenderType; import com.synapse.chat_service.domain.entity.enums.SubscriptionType; -import java.lang.reflect.Field; -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * 테스트 객체 생성을 위한 팩토리 클래스 - * 테스트 데이터 생성 로직을 중앙에서 관리하여 유지보수성을 향상시킵니다. - */ public class TestObjectFactory { // Conversation 생성 메서드들 @@ -71,7 +67,8 @@ public static Message createDefaultAssistantMessage(Conversation conversation) { return createAssistantMessage(conversation, "AI 테스트 응답"); } - public static Message createMessageWithId(Long id, Conversation conversation, SenderType senderType, String content) { + public static Message createMessageWithId(Long id, Conversation conversation, SenderType senderType, + String content) { Message message = Message.builder() .conversation(conversation) .senderType(senderType) @@ -89,7 +86,8 @@ public static Message createAssistantMessageWithId(Long id, Conversation convers return createMessageWithId(id, conversation, SenderType.ASSISTANT, content); } - public static Message createMessageWithCreatedDate(Conversation conversation, SenderType senderType, String content, LocalDateTime createdDate) { + public static Message createMessageWithCreatedDate(Conversation conversation, SenderType senderType, String content, + LocalDateTime createdDate) { Message message = createMessage(conversation, senderType, content); setCreatedDate(message, createdDate); return message; @@ -120,8 +118,6 @@ public static ChatUsage createDefaultProChatUsage() { return createProChatUsage(UUID.randomUUID()); } - - // Private 헬퍼 메서드들 private static void setCreatedDate(Object entity, LocalDateTime createdDate) { try { diff --git a/chat_service/src/test/resources/application-test.yml b/chat_service/chat_service/src/test/resources/application-test.yml similarity index 100% rename from chat_service/src/test/resources/application-test.yml rename to chat_service/chat_service/src/test/resources/application-test.yml diff --git a/chat_service/src/test/resources/application.yml b/chat_service/chat_service/src/test/resources/application.yml similarity index 100% rename from chat_service/src/test/resources/application.yml rename to chat_service/chat_service/src/test/resources/application.yml diff --git a/chat_service/chat_service_api/.gitignore b/chat_service/chat_service_api/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/chat_service/chat_service_api/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/chat_service/chat_service_api/build.gradle b/chat_service/chat_service_api/build.gradle new file mode 100644 index 0000000..e4f8b7f --- /dev/null +++ b/chat_service/chat_service_api/build.gradle @@ -0,0 +1,19 @@ +repositories { + mavenCentral() +} + +dependencies { + +} + +tasks.named('test') { + useJUnitPlatform() +} + +bootJar { + enabled = false +} + +jar { + enabled = true +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatMessageRequest.java b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/request/ChatMessageRequest.java similarity index 81% rename from chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatMessageRequest.java rename to chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/request/ChatMessageRequest.java index 0c23fc1..8ab7c04 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/request/ChatMessageRequest.java +++ b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/request/ChatMessageRequest.java @@ -1,12 +1,11 @@ -package com.synapse.chat_service.dto.request; +package com.synapse.chat_service_api.dto.request; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; public record ChatMessageRequest( - - @Min(value = 1, message = "조회할 개수는 1 이상이어야 합니다") - @Max(value = 100, message = "조회할 개수는 100 이하여야 합니다") + @Min(value = 1, message = "조회할 개수는 1 이상이어야 합니다") + @Max(value = 100, message = "조회할 개수는 100 이하여야 합니다") Integer size, String cursor diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/request/MessageRequest.java similarity index 62% rename from chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java rename to chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/request/MessageRequest.java index 70e456b..cc2866c 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/request/MessageRequest.java +++ b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/request/MessageRequest.java @@ -1,21 +1,22 @@ -package com.synapse.chat_service.dto.request; +package com.synapse.chat_service_api.dto.request; -import com.synapse.chat_service.service.ai.AIModelType; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; public class MessageRequest { public record Chat( - @NotNull(message = "AI 모델 타입은 필수입니다.") - AIModelType modelType, - - @NotBlank(message = "프롬프트 메시지는 필수입니다.") + @NotNull(message = "AI 모델 타입은 필수입니다.") + String modelType, + + @NotBlank(message = "프롬프트 메시지는 필수입니다.") String prompt, - + @NotBlank(message = "세션 ID는 필수입니다.") String sessionId, - - @NotBlank(message = "메시지 ID는 필수입니다.") + + @NotBlank(message = "메시지 ID는 필수입니다.") String messageId - ) {} + ) { + + } } diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatHistoryResponse.java b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/ChatHistoryResponse.java similarity index 85% rename from chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatHistoryResponse.java rename to chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/ChatHistoryResponse.java index 5f8557e..8da6e94 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/response/ChatHistoryResponse.java +++ b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/ChatHistoryResponse.java @@ -1,4 +1,4 @@ -package com.synapse.chat_service.dto.response; +package com.synapse.chat_service_api.dto.response; import java.util.List; diff --git a/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/MessageResponse.java b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/MessageResponse.java new file mode 100644 index 0000000..9f24eb2 --- /dev/null +++ b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/MessageResponse.java @@ -0,0 +1,85 @@ +package com.synapse.chat_service_api.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +public class MessageResponse { + public record ConversationInfo( + UUID conversationId, + UUID userId, + LocalDateTime createdDate, + LocalDateTime lastModifiedDate + ) { + public static ConversationInfo to(UUID conversionId, UUID memberId, LocalDateTime createdDate, LocalDateTime lastModifiedDate) { + return new ConversationInfo( + conversionId, + memberId, + createdDate, + lastModifiedDate + ); + } + } + + /** + * 대화 히스토리 조회용 응답 DTO + * 페이징 처리된 메시지 목록에서 사용 + */ + public record History( + Long id, + UUID conversationId, + String senderType, + String content, + LocalDateTime createdDate + ) { + public static History to(Long messageId, UUID conversionId, String senderType, String content, LocalDateTime createdDate) { + return new History( + messageId, + conversionId, + senderType, + content, + createdDate + ); + } + } + + /** + * WebSocket을 통해 AI 응답을 클라이언트에게 전송하는 DTO + */ + public record Chat( + String response, + String modelType, + String sessionId, + String messageId, + LocalDateTime timestamp, + ResponseStatus status, + String errorMessage + ) { + public static Chat success(String response, String modelType, String sessionId, String messageId) { + return new Chat( + response, + modelType, + sessionId, + messageId, + LocalDateTime.now(), + ResponseStatus.SUCCESS, + null + ); + } + + public static Chat error(String modelType, String sessionId, String messageId, String errorMessage) { + return new Chat( + null, + modelType, + sessionId, + messageId, + LocalDateTime.now(), + ResponseStatus.ERROR, + errorMessage + ); + } + } + + public enum ResponseStatus { + SUCCESS, ERROR + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/PaginationDto.java b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/PaginationDto.java similarity index 82% rename from chat_service/src/main/java/com/synapse/chat_service/dto/response/PaginationDto.java rename to chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/PaginationDto.java index b089cc0..567003e 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/response/PaginationDto.java +++ b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/response/PaginationDto.java @@ -1,4 +1,4 @@ -package com.synapse.chat_service.dto.response; +package com.synapse.chat_service_api.dto.response; public record PaginationDto( String nextCursor, diff --git a/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/AiChatInfo.java b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/AiChatInfo.java new file mode 100644 index 0000000..bbc8c0e --- /dev/null +++ b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/AiChatInfo.java @@ -0,0 +1,55 @@ +package com.synapse.chat_service_api.dto.session; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record AiChatInfo( + String userId, + UUID conversationId, + LocalDateTime createdAt, + LocalDateTime lastActivityAt, + Long messageCount +) { + + /** + * 새로운 AI 채팅 생성을 위한 팩토리 메서드 + * 실제 데이터베이스의 Conversation UUID를 사용하여 Redis와 DB 간 일관성을 보장합니다. + */ + public static AiChatInfo create(String userId, UUID conversationId) { + LocalDateTime now = LocalDateTime.now(); + + return new AiChatInfo( + userId, + conversationId, + now, + now, + 0L + ); + } + + /** + * 마지막 활동 시간 업데이트 + */ + public AiChatInfo updateLastActivity() { + return new AiChatInfo( + userId, + conversationId, + createdAt, + LocalDateTime.now(), + messageCount + ); + } + + /** + * 메시지 수 증가 + */ + public AiChatInfo incrementMessageCount() { + return new AiChatInfo( + userId, + conversationId, + createdAt, + LocalDateTime.now(), + messageCount + 1 + ); + } +} diff --git a/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/SessionInfo.java b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/SessionInfo.java new file mode 100644 index 0000000..b579869 --- /dev/null +++ b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/SessionInfo.java @@ -0,0 +1,55 @@ +package com.synapse.chat_service_api.dto.session; + +import java.time.LocalDateTime; + +public record SessionInfo( + String sessionId, + String userId, + LocalDateTime connectedAt, + LocalDateTime lastActivityAt, + SessionStatus status, + String clientInfo +) { + /** + * 새로운 AI 채팅 세션 생성을 위한 팩토리 메서드 + */ + public static SessionInfo create(String sessionId, String userId, String clientInfo) { + LocalDateTime now = LocalDateTime.now(); + return new SessionInfo( + sessionId, + userId, + now, + now, + SessionStatus.CONNECTED, + clientInfo + ); + } + + /** + * 마지막 활동 시간 업데이트 + */ + public SessionInfo updateLastActivity() { + return new SessionInfo( + sessionId, + userId, + connectedAt, + LocalDateTime.now(), + status, + clientInfo + ); + } + + /** + * 세션 상태 변경 + */ + public SessionInfo changeStatus(SessionStatus newStatus) { + return new SessionInfo( + sessionId, + userId, + connectedAt, + LocalDateTime.now(), + newStatus, + clientInfo + ); + } +} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionStatus.java b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/SessionStatus.java similarity index 78% rename from chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionStatus.java rename to chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/SessionStatus.java index 77f4404..e558e9f 100644 --- a/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionStatus.java +++ b/chat_service/chat_service_api/src/main/java/com/synapse/chat_service_api/dto/session/SessionStatus.java @@ -1,27 +1,24 @@ -package com.synapse.chat_service.session.dto; +package com.synapse.chat_service_api.dto.session; -/** - * WebSocket 세션의 상태를 나타내는 열거형 - */ public enum SessionStatus { - + /** * 연결된 상태 - 정상적으로 WebSocket 연결이 활성화된 상태 */ CONNECTED, - + /** * 연결 해제된 상태 - WebSocket 연결이 종료된 상태 */ DISCONNECTED, - + /** * 유휴 상태 - 연결은 유지되지만 일정 시간 동안 활동이 없는 상태 */ IDLE, - + /** * 재연결 중 상태 - 네트워크 문제 등으로 재연결을 시도하는 상태 */ RECONNECTING -} +} \ No newline at end of file diff --git a/chat_service/settings.gradle b/chat_service/settings.gradle index da805cf..e3d7f8b 100644 --- a/chat_service/settings.gradle +++ b/chat_service/settings.gradle @@ -1 +1,4 @@ rootProject.name = 'chat_service' + +include ('chat_service') +include ('chat_service_api') diff --git a/chat_service/src/main/java/com/synapse/chat_service/config/JpaConfig.java b/chat_service/src/main/java/com/synapse/chat_service/config/JpaConfig.java deleted file mode 100644 index b105ab4..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/config/JpaConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.synapse.chat_service.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; - -@Configuration -@EnableJpaAuditing -public class JpaConfig { - -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java b/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java deleted file mode 100644 index abc0135..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/domain/repository/MessageRepository.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.synapse.chat_service.domain.repository; - -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; - -import com.synapse.chat_service.domain.entity.Message; -import com.synapse.chat_service.dto.response.MessageResponse; - -import java.util.List; -import java.util.UUID; - -@Repository -public interface MessageRepository extends JpaRepository { - // 커서 기반 페이지네이션 - 최신순 (limit 직접 지정) - @Query(value = "SELECT * FROM message WHERE user_id = :userId AND (:cursor IS NULL OR id < :cursor) ORDER BY id DESC LIMIT :limit", nativeQuery = true) - List findByUserIdWithCursorDesc(@Param("userId") UUID userId, @Param("cursor") Long cursor, @Param("limit") int limit); -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java b/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java deleted file mode 100644 index 23e9f62..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/dto/response/MessageResponse.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.synapse.chat_service.dto.response; - -import com.synapse.chat_service.domain.entity.Conversation; -import com.synapse.chat_service.domain.entity.Message; -import com.synapse.chat_service.domain.entity.enums.SenderType; -import com.synapse.chat_service.service.ai.AIModelType; - -import java.time.LocalDateTime; -import java.util.UUID; - -public class MessageResponse { - public record ConversationInfo( - UUID conversationId, - UUID userId, - LocalDateTime createdDate, - LocalDateTime lastModifiedDate - ) { - public static ConversationInfo from(Conversation conversation) { - return new ConversationInfo( - conversation.getId(), - conversation.getUserId(), - conversation.getCreatedDate(), - conversation.getUpdatedDate() - ); - } - } - - /** - * 대화 히스토리 조회용 응답 DTO - * 페이징 처리된 메시지 목록에서 사용 - */ - public record History( - Long id, - UUID conversationId, - SenderType senderType, - String content, - LocalDateTime createdDate - ) { - public static History from(Message message) { - return new History( - message.getId(), - message.getConversation().getId(), - message.getSenderType(), - message.getContent(), - message.getCreatedDate() - ); - } - } - - /** - * WebSocket을 통해 AI 응답을 클라이언트에게 전송하는 DTO - */ - public record Chat( - String response, - AIModelType modelType, - String sessionId, - String messageId, - LocalDateTime timestamp, - ResponseStatus status, - String errorMessage - ) { - public static Chat success(String response, AIModelType modelType, String sessionId, String messageId) { - return new Chat(response, modelType, sessionId, messageId, LocalDateTime.now(), ResponseStatus.SUCCESS, null); - } - - public static Chat error(AIModelType modelType, String sessionId, String messageId, String errorMessage) { - return new Chat(null, modelType, sessionId, messageId, LocalDateTime.now(), ResponseStatus.ERROR, errorMessage); - } - } - - public enum ResponseStatus { - SUCCESS, ERROR - } -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java b/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java deleted file mode 100644 index 2c33583..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/exception/domain/ExceptionType.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.synapse.chat_service.exception.domain; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@RequiredArgsConstructor -public enum ExceptionType { - - // 400 Bad Request - INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "E001", "잘못된 입력값입니다."), - MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "E002", "필수 요청 파라미터가 누락되었습니다."), - INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "E003", "잘못된 타입의 값입니다."), - - // 401 Unauthorized - UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "E101", "인증이 필요합니다."), - INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "E102", "유효하지 않은 토큰입니다."), - EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "E103", "만료된 토큰입니다."), - - // 403 Forbidden - ACCESS_DENIED(HttpStatus.FORBIDDEN, "E201", "접근이 거부되었습니다."), - INSUFFICIENT_PERMISSION(HttpStatus.FORBIDDEN, "E202", "권한이 부족합니다."), - - // 404 Not Found - CONVERSATION_NOT_FOUND(HttpStatus.NOT_FOUND, "E301", "대화를 찾을 수 없습니다."), - MESSAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "E302", "메시지를 찾을 수 없습니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "E303", "사용자를 찾을 수 없습니다."), - RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "E304", "요청한 리소스를 찾을 수 없습니다."), - - // 409 Conflict - DUPLICATE_RESOURCE(HttpStatus.CONFLICT, "E401", "이미 존재하는 리소스입니다."), - DUPLICATE_USERNAME(HttpStatus.CONFLICT, "E402", "이미 사용 중인 사용자명입니다."), - DUPLICATE_EMAIL(HttpStatus.CONFLICT, "E403", "이미 사용 중인 이메일입니다."), - - // 422 Unprocessable Entity - BUSINESS_LOGIC_ERROR(HttpStatus.UNPROCESSABLE_ENTITY, "E501", "비즈니스 로직 오류가 발생했습니다."), - INVALID_STATE(HttpStatus.UNPROCESSABLE_ENTITY, "E502", "유효하지 않은 상태입니다."), - - // 429 Too Many Requests - TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "E601", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), - - // 500 Internal Server Error - INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E901", "서버 내부 오류가 발생했습니다."), - DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E902", "데이터베이스 오류가 발생했습니다."), - EXTERNAL_SERVICE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E903", "외부 서비스 연동 중 오류가 발생했습니다."), - REDIS_CONNECTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E904", "Redis 연결 오류가 발생했습니다."), - REDIS_OPERATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E905", "Redis 작업 중 오류가 발생했습니다."), - REDIS_TRANSACTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E906", "Redis 트랜잭션 처리 중 오류가 발생했습니다."), - - // 502 Bad Gateway - BAD_GATEWAY(HttpStatus.BAD_GATEWAY, "E951", "게이트웨이 오류가 발생했습니다."), - - // 503 Service Unavailable - SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "E961", "서비스를 사용할 수 없습니다."); - - private final HttpStatus status; - private final String code; - private final String message; -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java b/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java deleted file mode 100644 index 80c37a0..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/service/ai/AIModelType.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.synapse.chat_service.service.ai; - -/** - * 지원하는 AI 모델 타입을 정의하는 Enum - * 새로운 AI 모델 추가 시 이 Enum에 추가하여 일관성 있게 관리 - */ -public enum AIModelType { - GPT, - CLAUDE, - GEMINI -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/dto/AiChatInfo.java b/chat_service/src/main/java/com/synapse/chat_service/session/dto/AiChatInfo.java deleted file mode 100644 index 1f29271..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/session/dto/AiChatInfo.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.synapse.chat_service.session.dto; - -import java.time.LocalDateTime; -import java.util.UUID; - -/** - * AI 채팅 정보를 저장하는 Record - * Redis에서 현재 활성화된 WebSocket 세션과 관련된 상태 정보 및 캐시 역할을 합니다. - * 데이터베이스의 Conversation 엔티티가 영구적인 저장소(Source of Truth) 역할을 하며, - * 이 레코드는 자주 접근하지만 휘발되어도 괜찮은 메타데이터를 저장하여 DB 조회를 줄입니다. - * - * @param userId 사용자 ID - * @param conversationId 실제 데이터베이스의 Conversation UUID (Redis와 DB 간 일관성 보장) - * @param createdAt 채팅방 생성 시간 - * @param lastActivityAt 마지막 활동 시간 - * @param messageCount 총 메시지 수 (선택적 통계) - */ -public record AiChatInfo( - String userId, - UUID conversationId, - LocalDateTime createdAt, - LocalDateTime lastActivityAt, - Long messageCount -) { - - /** - * 새로운 AI 채팅 생성을 위한 팩토리 메서드 - * 실제 데이터베이스의 Conversation UUID를 사용하여 Redis와 DB 간 일관성을 보장합니다. - */ - public static AiChatInfo create(String userId, UUID conversationId) { - LocalDateTime now = LocalDateTime.now(); - - return new AiChatInfo( - userId, - conversationId, - now, - now, - 0L - ); - } - - /** - * 마지막 활동 시간 업데이트 - */ - public AiChatInfo updateLastActivity() { - return new AiChatInfo( - userId, - conversationId, - createdAt, - LocalDateTime.now(), - messageCount - ); - } - - /** - * 메시지 수 증가 - */ - public AiChatInfo incrementMessageCount() { - return new AiChatInfo( - userId, - conversationId, - createdAt, - LocalDateTime.now(), - messageCount + 1 - ); - } -} diff --git a/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java b/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java deleted file mode 100644 index f6546a0..0000000 --- a/chat_service/src/main/java/com/synapse/chat_service/session/dto/SessionInfo.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.synapse.chat_service.session.dto; - -import java.time.LocalDateTime; - -/** - * AI 채팅 WebSocket 세션 정보를 저장하는 Record - * Redis에 JSON 형태로 직렬화되어 저장됩니다. - * - * @param sessionId WebSocket 세션 ID - * @param userId 사용자 ID - * @param username 사용자 이름 - * @param connectedAt 세션 연결 시간 - * @param lastActivityAt 마지막 활동 시간 - * @param status 세션 상태 (CONNECTED, DISCONNECTED, IDLE) - * @param clientInfo 클라이언트 정보 (브라우저, 모바일 앱 등) - */ -public record SessionInfo( - String sessionId, - String userId, - LocalDateTime connectedAt, - LocalDateTime lastActivityAt, - SessionStatus status, - String clientInfo -) { - - /** - * 새로운 AI 채팅 세션 생성을 위한 팩토리 메서드 - */ - public static SessionInfo create(String sessionId, String userId, String clientInfo) { - LocalDateTime now = LocalDateTime.now(); - return new SessionInfo( - sessionId, - userId, - now, - now, - SessionStatus.CONNECTED, - clientInfo - ); - } - - /** - * 마지막 활동 시간 업데이트 - */ - public SessionInfo updateLastActivity() { - return new SessionInfo( - sessionId, - userId, - connectedAt, - LocalDateTime.now(), - status, - clientInfo - ); - } - - /** - * 세션 상태 변경 - */ - public SessionInfo changeStatus(SessionStatus newStatus) { - return new SessionInfo( - sessionId, - userId, - connectedAt, - LocalDateTime.now(), - newStatus, - clientInfo - ); - } -} diff --git a/chat_service/src/main/resources/security/application-db.yml b/chat_service/src/main/resources/security/application-db.yml deleted file mode 100644 index a7eb998..0000000 --- a/chat_service/src/main/resources/security/application-db.yml +++ /dev/null @@ -1,16 +0,0 @@ -local-db: - postgres: - host: localhost - port: 5436 - name: chat-service - username: donghyeon - password: adzc1973 - - redis: - host: localhost - port: 6379 - password: 1234 - timeout: 10000 - max-active: 8 - max-idle: 8 - min-idle: 0 diff --git a/chat_service/src/main/resources/security/application-jwt.yml b/chat_service/src/main/resources/security/application-jwt.yml deleted file mode 100644 index a24c1fc..0000000 --- a/chat_service/src/main/resources/security/application-jwt.yml +++ /dev/null @@ -1,2 +0,0 @@ -secret: - key: a8a16408bc2e11b6b74797dbd0837948b1267d5de209df9aaab670be16343b3d5faaf94c17c7e957aca3d5f691dca32ba4dddcb053bc44fd2de2bfc593e19cc4