Skip to content

Commit 9b4d06c

Browse files
authored
feat(be): aichat 서비스 구현 및 글로벌 익셉션 수정 (#14)
* feat(be): aichat 서비스 구현 및 글로벌 익셉션 수정 * feat(be): aichat 서비스 구현 및 글로벌 익셉션 수정 * feat(be): JDBC에 메모리 불러오게 추가 ai챗 메모리 추가 의존성 포스트그리 추가 * feat(be): JDBC에 메모리 불러오게 추가 ai챗 메모리 추가 의존성 포스트그리 추가 * feat(be): JDBC에 메모리 불러오게 추가 ai챗 메모리 추가 의존성 포스트그리 추가
1 parent 5f3f3c9 commit 9b4d06c

File tree

12 files changed

+220
-198
lines changed

12 files changed

+220
-198
lines changed

build.gradle.kts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ plugins {
44
id("org.springframework.boot") version "3.4.1"
55
id("io.spring.dependency-management") version "1.1.7"
66
kotlin("plugin.jpa") version "1.9.25"
7-
// ktlint plugin
7+
// 코틀린 코드 스타일 린터
88
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
99
}
1010

@@ -34,41 +34,43 @@ dependencies {
3434
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
3535
implementation("org.jetbrains.kotlin:kotlin-reflect")
3636

37-
// Spring AI - 1.0.0-M6 (OpenRouter 호환) - 확인된 버전
38-
implementation("org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M6")
37+
// Spring AI - 1.1.0-M2 (최신 버전) - 새로운 artifact명
38+
implementation("org.springframework.ai:spring-ai-starter-model-openai:1.1.0-M2")
39+
// Spring AI - JDBC 채팅 메모리 저장소 (PostgreSQL 대화 기록 저장)
40+
implementation("org.springframework.ai:spring-ai-starter-model-chat-memory-repository-jdbc:1.1.0-M2")
3941

40-
// Dotenv for environment variables
42+
// 환경 변수 관리 라이브러리
4143
implementation("io.github.cdimascio:dotenv-kotlin:6.4.1")
4244

43-
// Security: Force newer versions to avoid CVE vulnerabilities
45+
// 보안: CVE 취약점 방지를 위한 최신 버전 강제
4446
implementation("org.apache.commons:commons-lang3:3.18.0") // CVE-2025-48924 해결
4547

46-
// WebSocket for user chat (Guest-Guide)
48+
// 웹소켓 - 사용자 채팅 기능 (게스트-가이드)
4749
implementation("org.springframework.boot:spring-boot-starter-websocket")
4850

49-
// Redis for caching and session management
51+
// 레디스 - 캐싱 및 세션 관리
5052
implementation("org.springframework.boot:spring-boot-starter-data-redis")
5153
implementation("org.springframework.session:spring-session-data-redis")
5254

53-
// Swagger UI for API documentation
55+
// API 문서화 - Swagger UI
5456
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0")
5557

56-
// Development tools for 5-person team
58+
// 개발 도구 - 5명 팀 개발용
5759
developmentOnly("org.springframework.boot:spring-boot-devtools")
5860

59-
// Monitoring & Health checks (개발자 디버깅용)
61+
// 모니터링 및 상태 체크 (개발자 디버깅용)
6062
implementation("org.springframework.boot:spring-boot-starter-actuator")
6163

62-
// Database support
63-
runtimeOnly("com.h2database:h2") // Development
64-
runtimeOnly("org.postgresql:postgresql") // Production
64+
// 데이터베이스 지원
65+
runtimeOnly("com.h2database:h2") // 개발용
66+
runtimeOnly("org.postgresql:postgresql") // 운영용
6567
testImplementation("org.springframework.boot:spring-boot-starter-test")
6668
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
6769
testImplementation("org.springframework.security:spring-security-test")
6870
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
6971
}
7072

71-
// BOM 제거: Spring AI 1.0.0-M6에서 직접 버전 관리
73+
// BOM 제거: Spring AI 1.1.0-M2에서 직접 버전 관리
7274
// dependencyManagement 제거로 더 명확한 의존성 관리
7375

7476
kotlin {
@@ -87,7 +89,7 @@ tasks.withType<Test> {
8789
useJUnitPlatform()
8890
}
8991

90-
// ktlint configuration
92+
// 코틀린 코드 스타일 린터 설정
9193
ktlint {
9294
android.set(false)
9395
outputToConsole.set(true)

src/main/kotlin/com/back/koreaTravelGuide/common/config/AiConfig.kt

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package com.back.koreaTravelGuide.common.config
22

33
import org.springframework.ai.chat.client.ChatClient
4+
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor
5+
import org.springframework.ai.chat.memory.ChatMemory
6+
import org.springframework.ai.chat.memory.MessageWindowChatMemory
7+
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository
48
import org.springframework.ai.chat.model.ChatModel
59
import org.springframework.context.annotation.Bean
610
import org.springframework.context.annotation.Configuration
11+
import org.springframework.jdbc.core.JdbcTemplate
712
import org.springframework.web.client.RestTemplate
813

914
/**
@@ -24,10 +29,30 @@ class AiConfig {
2429

2530
/**
2631
* ChatClient 빈 생성
27-
* Spring AI 1.0.0-M6에서 자동 생성되지 않는 경우를 대비한 수동 설정
2832
*/
2933
@Bean
30-
fun chatClient(chatModel: ChatModel): ChatClient {
31-
return ChatClient.builder(chatModel).build()
34+
fun chatClient(
35+
chatModel: ChatModel,
36+
chatMemory: ChatMemory,
37+
): ChatClient {
38+
return ChatClient.builder(chatModel)
39+
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
40+
.build()
41+
}
42+
43+
/**
44+
* ChatMemory 빈 생성 (넉넉히 50개 메시지 유지, PostgreSQL 기반)
45+
*/
46+
@Bean
47+
fun chatMemory(jdbcTemplate: JdbcTemplate): ChatMemory {
48+
val repository =
49+
JdbcChatMemoryRepository.builder()
50+
.jdbcTemplate(jdbcTemplate)
51+
.build()
52+
53+
return MessageWindowChatMemory.builder()
54+
.maxMessages(50)
55+
.chatMemoryRepository(repository)
56+
.build()
3257
}
3358
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.back.koreaTravelGuide.common.constant
2+
3+
object PromptConstant {
4+
const val KOREA_TRAVEL_GUIDE_SYSTEM = """
5+
당신은 한국 여행 전문 AI 가이드입니다.
6+
한국의 관광지, 음식, 문화에 대해 정확하고 친근한 정보를 제공하세요.
7+
답변은 한국어로 해주시고, 구체적인 추천과 팁을 포함해주세요.
8+
사용자에게 도움이 되는 실용적인 여행 정보를 제공하는 것이 목표입니다.
9+
"""
10+
11+
const val AI_ERROR_FALLBACK = "죄송합니다. 일시적인 문제로 응답을 생성할 수 없습니다. 다시 시도해 주세요."
12+
}

src/main/kotlin/com/back/koreaTravelGuide/common/exception/GlobalExceptionHandler.kt

Lines changed: 19 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -2,147 +2,51 @@ package com.back.koreaTravelGuide.common.exception
22

33
import com.back.koreaTravelGuide.common.ApiResponse
44
import jakarta.servlet.http.HttpServletRequest
5+
import org.slf4j.LoggerFactory
56
import org.springframework.http.HttpStatus
67
import org.springframework.http.ResponseEntity
7-
import org.springframework.http.converter.HttpMessageNotReadableException
8-
import org.springframework.security.access.AccessDeniedException
98
import org.springframework.validation.FieldError
109
import org.springframework.web.bind.MethodArgumentNotValidException
1110
import org.springframework.web.bind.annotation.ControllerAdvice
1211
import org.springframework.web.bind.annotation.ExceptionHandler
13-
import org.springframework.web.servlet.resource.NoResourceFoundException
1412

1513
/**
16-
* 전역 예외 처리기
17-
*
18-
* 모든 컨트롤러에서 발생하는 예외를 통합적으로 처리
19-
* - 일관된 에러 응답 형식 제공
20-
* - ApiResponse 래퍼로 감싸서 반환
21-
* - 개발 시 디버깅 정보 콘솔 출력
22-
*
23-
* 각 도메인에서 사용법:
24-
* ```kotlin
25-
* @RestController
26-
* class YourController {
27-
* @GetMapping("/test")
28-
* fun test(): String {
29-
* throw IllegalArgumentException("잘못된 파라미터") // 자동으로 400 Bad Request 응답
30-
* throw NoSuchElementException("데이터 없음") // 자동으로 404 Not Found 응답
31-
* }
32-
* }
33-
* ```
34-
*
35-
* 커스텀 예외 추가:
36-
* @ExceptionHandler(YourCustomException::class)로 메서드 추가
14+
* @Valid 검증 실패 → 400
15+
* throw IllegalArgumentException("메시지") → 400
16+
* throw NoSuchElementException("메시지") → 404
17+
* 기타 모든 예외 → 500
3718
*/
3819
@ControllerAdvice
3920
class GlobalExceptionHandler {
40-
/**
41-
* @Valid 검증 실패 처리 (400 Bad Request)
42-
*/
21+
private val logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)
22+
4323
@ExceptionHandler(MethodArgumentNotValidException::class)
44-
fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Void>> {
24+
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Void>> {
4525
val message =
46-
ex.bindingResult
47-
.allErrors
48-
.filterIsInstance<FieldError>()
49-
.joinToString("\n") { error ->
50-
"${error.field}: ${error.defaultMessage}"
51-
}
52-
53-
return ResponseEntity.badRequest()
54-
.body(ApiResponse("입력값 검증 실패\n$message"))
55-
}
56-
57-
/**
58-
* JSON 파싱 실패 처리 (400 Bad Request)
59-
*/
60-
@ExceptionHandler(HttpMessageNotReadableException::class)
61-
fun handleHttpMessageNotReadable(ex: HttpMessageNotReadableException): ResponseEntity<ApiResponse<Void>> {
62-
return ResponseEntity.badRequest()
63-
.body(ApiResponse("요청 데이터 형식이 올바르지 않습니다"))
26+
ex.bindingResult.allErrors.filterIsInstance<FieldError>()
27+
.joinToString(", ") { "${it.field}: ${it.defaultMessage}" }
28+
logger.warn("입력값 검증 실패: {}", message)
29+
return ResponseEntity.badRequest().body(ApiResponse("입력값 검증 실패: $message"))
6430
}
6531

66-
/**
67-
* 잘못된 파라미터 처리 (400 Bad Request)
68-
*/
6932
@ExceptionHandler(IllegalArgumentException::class)
7033
fun handleIllegalArgument(ex: IllegalArgumentException): ResponseEntity<ApiResponse<Void>> {
71-
return ResponseEntity.badRequest()
72-
.body(ApiResponse(ex.message ?: "잘못된 요청입니다"))
34+
logger.warn("잘못된 파라미터: {}", ex.message)
35+
return ResponseEntity.badRequest().body(ApiResponse(ex.message ?: "잘못된 요청입니다"))
7336
}
7437

75-
/**
76-
* 데이터 없음 처리 (404 Not Found)
77-
*/
7838
@ExceptionHandler(NoSuchElementException::class)
7939
fun handleNoSuchElement(ex: NoSuchElementException): ResponseEntity<ApiResponse<Void>> {
80-
return ResponseEntity.status(HttpStatus.NOT_FOUND)
81-
.body(ApiResponse(ex.message ?: "요청한 데이터를 찾을 수 없습니다"))
82-
}
83-
84-
/**
85-
* 리소스 없음 처리 (404 Not Found) - favicon.ico 등
86-
*/
87-
@ExceptionHandler(NoResourceFoundException::class)
88-
fun handleNoResourceFound(ex: NoResourceFoundException): ResponseEntity<ApiResponse<Void>> {
89-
// favicon.ico 요청은 조용히 무시 (로그 안 남김)
90-
if (ex.message?.contains("favicon.ico") == true) {
91-
return ResponseEntity.status(HttpStatus.NOT_FOUND).build()
92-
}
93-
94-
// 다른 리소스는 로그 남기고 처리
95-
println("⚠️ 리소스를 찾을 수 없음: ${ex.message}")
96-
return ResponseEntity.status(HttpStatus.NOT_FOUND)
97-
.body(ApiResponse("요청한 리소스를 찾을 수 없습니다"))
40+
logger.warn("데이터 없음: {}", ex.message)
41+
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse(ex.message ?: "데이터를 찾을 수 없습니다"))
9842
}
9943

100-
/**
101-
* 접근 거부 처리 (403 Forbidden)
102-
*/
103-
@ExceptionHandler(AccessDeniedException::class)
104-
fun handleAccessDenied(ex: AccessDeniedException): ResponseEntity<ApiResponse<Void>> {
105-
println("🚫 접근 거부: ${ex.message}")
106-
return ResponseEntity.status(HttpStatus.FORBIDDEN)
107-
.body(ApiResponse("접근 권한이 없습니다"))
108-
}
109-
110-
/**
111-
* 모든 예외 처리 (500 Internal Server Error)
112-
* 위에서 처리되지 않은 모든 예외들의 최종 처리
113-
*
114-
* 주니어 개발자용 디버깅 정보 추가:
115-
* - 상세한 에러 스택 트레이스
116-
* - 요청 정보 로깅
117-
* - 개발환경에서는 더 자세한 정보 제공
118-
*/
11944
@ExceptionHandler(Exception::class)
12045
fun handleGenericException(
12146
ex: Exception,
12247
request: HttpServletRequest,
123-
): ResponseEntity<ApiResponse<Map<String, Any?>>> {
124-
println("❌ 예상치 못한 예외 발생!")
125-
println(" 클래스: ${ex::class.simpleName}")
126-
println(" 메시지: ${ex.message}")
127-
println(" 요청 URL: ${request.method} ${request.requestURL}")
128-
println(" 요청 IP: ${request.remoteAddr}")
129-
println(" User-Agent: ${request.getHeader("User-Agent")}")
130-
131-
// 개발환경에서는 스택트레이스도 출력
132-
ex.printStackTrace()
133-
134-
// 개발환경에서는 더 자세한 에러 정보 제공 (주니어 개발자 도움용)
135-
val debugInfo = mutableMapOf<String, Any?>()
136-
debugInfo["timestamp"] = System.currentTimeMillis()
137-
debugInfo["path"] = request.requestURI
138-
debugInfo["method"] = request.method
139-
debugInfo["error"] = ex::class.simpleName
140-
debugInfo["message"] = ex.message
141-
142-
// 스택 트레이스의 첫 3줄만 포함 (너무 길어지지 않도록)
143-
debugInfo["trace"] = ex.stackTrace.take(3).map { it.toString() }
144-
145-
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
146-
.body(ApiResponse("서버 내부 오류가 발생했습니다", debugInfo))
48+
): ResponseEntity<ApiResponse<Void>> {
49+
logger.error("서버 오류 - {}: {} at {}", ex::class.simpleName, ex.message, request.requestURI, ex)
50+
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse("서버 내부 오류가 발생했습니다"))
14751
}
14852
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.back.koreaTravelGuide.domain.ai.aiChat.service
2+
3+
import com.back.koreaTravelGuide.common.constant.PromptConstant.AI_ERROR_FALLBACK
4+
import com.back.koreaTravelGuide.common.constant.PromptConstant.KOREA_TRAVEL_GUIDE_SYSTEM
5+
import com.back.koreaTravelGuide.domain.ai.aiChat.entity.AiChatMessage
6+
import com.back.koreaTravelGuide.domain.ai.aiChat.entity.AiChatSession
7+
import com.back.koreaTravelGuide.domain.ai.aiChat.entity.SenderType
8+
import com.back.koreaTravelGuide.domain.ai.aiChat.repository.AiChatMessageRepository
9+
import com.back.koreaTravelGuide.domain.ai.aiChat.repository.AiChatSessionRepository
10+
import org.springframework.ai.chat.client.ChatClient
11+
import org.springframework.ai.chat.memory.ChatMemory
12+
import org.springframework.stereotype.Service
13+
14+
@Service
15+
class AiChatService(
16+
private val aiChatMessageRepository: AiChatMessageRepository,
17+
private val aiChatSessionRepository: AiChatSessionRepository,
18+
private val chatClient: ChatClient,
19+
) {
20+
fun getSessions(userId: Long): List<AiChatSession> {
21+
return aiChatSessionRepository.findByUserIdOrderByCreatedAtDesc(userId)
22+
}
23+
24+
fun createSession(userId: Long): AiChatSession {
25+
val newSession = AiChatSession(userId = userId)
26+
return aiChatSessionRepository.save(newSession)
27+
}
28+
29+
fun deleteSession(
30+
sessionId: Long,
31+
userId: Long,
32+
) {
33+
val session =
34+
aiChatSessionRepository.findByIdAndUserId(sessionId, userId)
35+
?: throw IllegalArgumentException("해당 채팅방이 없거나 삭제 권한이 없습니다.")
36+
37+
aiChatSessionRepository.deleteById(sessionId)
38+
}
39+
40+
fun getSessionMessages(
41+
sessionId: Long,
42+
userId: Long,
43+
): List<AiChatMessage> {
44+
val session =
45+
aiChatSessionRepository.findByIdAndUserId(sessionId, userId)
46+
?: throw IllegalArgumentException("해당 채팅방이 없거나 접근 권한이 없습니다.")
47+
48+
return aiChatMessageRepository.findByAiChatSessionIdOrderByCreatedAtAsc(sessionId)
49+
}
50+
51+
fun sendMessage(
52+
sessionId: Long,
53+
userId: Long,
54+
message: String,
55+
): Pair<AiChatMessage, AiChatMessage> {
56+
val session =
57+
aiChatSessionRepository.findByIdAndUserId(sessionId, userId)
58+
?: throw IllegalArgumentException("해당 채팅방이 없거나 접근 권한이 없습니다.")
59+
60+
val userMessage =
61+
AiChatMessage(
62+
aiChatSession = session,
63+
senderType = SenderType.USER,
64+
content = message,
65+
)
66+
val savedUserMessage = aiChatMessageRepository.save(userMessage)
67+
68+
val response =
69+
try {
70+
chatClient.prompt()
71+
.system(KOREA_TRAVEL_GUIDE_SYSTEM)
72+
.user(message)
73+
.advisors { advisor ->
74+
advisor.param(ChatMemory.CONVERSATION_ID, sessionId.toString())
75+
}
76+
.call()
77+
.content() ?: AI_ERROR_FALLBACK
78+
} catch (e: Exception) {
79+
AI_ERROR_FALLBACK
80+
}
81+
82+
val aiMessage =
83+
AiChatMessage(
84+
aiChatSession = session,
85+
senderType = SenderType.AI,
86+
content = response,
87+
)
88+
val savedAiMessage = aiChatMessageRepository.save(aiMessage)
89+
return Pair(savedUserMessage, savedAiMessage)
90+
}
91+
}

0 commit comments

Comments
 (0)