Skip to content
Merged
32 changes: 17 additions & 15 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ plugins {
id("org.springframework.boot") version "3.4.1"
id("io.spring.dependency-management") version "1.1.7"
kotlin("plugin.jpa") version "1.9.25"
// ktlint plugin
// 코틀린 코드 스타일 린터
id("org.jlleitschuh.gradle.ktlint") version "12.1.0"
}

Expand Down Expand Up @@ -34,41 +34,43 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")

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

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

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

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

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

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

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

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

// Database support
runtimeOnly("com.h2database:h2") // Development
runtimeOnly("org.postgresql:postgresql") // Production
// 데이터베이스 지원
runtimeOnly("com.h2database:h2") // 개발용
runtimeOnly("org.postgresql:postgresql") // 운영용
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.springframework.security:spring-security-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

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

kotlin {
Expand All @@ -87,7 +89,7 @@ tasks.withType<Test> {
useJUnitPlatform()
}

// ktlint configuration
// 코틀린 코드 스타일 린터 설정
ktlint {
android.set(false)
outputToConsole.set(true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package com.back.koreaTravelGuide.common.config

import org.springframework.ai.chat.client.ChatClient
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor
import org.springframework.ai.chat.memory.ChatMemory
import org.springframework.ai.chat.memory.MessageWindowChatMemory
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository
import org.springframework.ai.chat.model.ChatModel
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.web.client.RestTemplate

/**
Expand All @@ -24,10 +29,30 @@ class AiConfig {

/**
* ChatClient 빈 생성
* Spring AI 1.0.0-M6에서 자동 생성되지 않는 경우를 대비한 수동 설정
*/
@Bean
fun chatClient(chatModel: ChatModel): ChatClient {
return ChatClient.builder(chatModel).build()
fun chatClient(
chatModel: ChatModel,
chatMemory: ChatMemory,
): ChatClient {
return ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build()
}

/**
* ChatMemory 빈 생성 (넉넉히 50개 메시지 유지, PostgreSQL 기반)
*/
@Bean
fun chatMemory(jdbcTemplate: JdbcTemplate): ChatMemory {
val repository =
JdbcChatMemoryRepository.builder()
.jdbcTemplate(jdbcTemplate)
.build()

return MessageWindowChatMemory.builder()
.maxMessages(50)
.chatMemoryRepository(repository)
.build()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.back.koreaTravelGuide.common.constant

object PromptConstant {
const val KOREA_TRAVEL_GUIDE_SYSTEM = """
당신은 한국 여행 전문 AI 가이드입니다.
한국의 관광지, 음식, 문화에 대해 정확하고 친근한 정보를 제공하세요.
답변은 한국어로 해주시고, 구체적인 추천과 팁을 포함해주세요.
사용자에게 도움이 되는 실용적인 여행 정보를 제공하는 것이 목표입니다.
"""

const val AI_ERROR_FALLBACK = "죄송합니다. 일시적인 문제로 응답을 생성할 수 없습니다. 다시 시도해 주세요."
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,147 +2,51 @@ package com.back.koreaTravelGuide.common.exception

import com.back.koreaTravelGuide.common.ApiResponse
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.security.access.AccessDeniedException
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.servlet.resource.NoResourceFoundException

/**
* 전역 예외 처리기
*
* 모든 컨트롤러에서 발생하는 예외를 통합적으로 처리
* - 일관된 에러 응답 형식 제공
* - ApiResponse 래퍼로 감싸서 반환
* - 개발 시 디버깅 정보 콘솔 출력
*
* 각 도메인에서 사용법:
* ```kotlin
* @RestController
* class YourController {
* @GetMapping("/test")
* fun test(): String {
* throw IllegalArgumentException("잘못된 파라미터") // 자동으로 400 Bad Request 응답
* throw NoSuchElementException("데이터 없음") // 자동으로 404 Not Found 응답
* }
* }
* ```
*
* 커스텀 예외 추가:
* @ExceptionHandler(YourCustomException::class)로 메서드 추가
* @Valid 검증 실패 → 400
* throw IllegalArgumentException("메시지") → 400
* throw NoSuchElementException("메시지") → 404
* 기타 모든 예외 → 500
*/
@ControllerAdvice
class GlobalExceptionHandler {
/**
* @Valid 검증 실패 처리 (400 Bad Request)
*/
private val logger = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)

@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidationExceptions(ex: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Void>> {
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiResponse<Void>> {
val message =
ex.bindingResult
.allErrors
.filterIsInstance<FieldError>()
.joinToString("\n") { error ->
"${error.field}: ${error.defaultMessage}"
}

return ResponseEntity.badRequest()
.body(ApiResponse("입력값 검증 실패\n$message"))
}

/**
* JSON 파싱 실패 처리 (400 Bad Request)
*/
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleHttpMessageNotReadable(ex: HttpMessageNotReadableException): ResponseEntity<ApiResponse<Void>> {
return ResponseEntity.badRequest()
.body(ApiResponse("요청 데이터 형식이 올바르지 않습니다"))
ex.bindingResult.allErrors.filterIsInstance<FieldError>()
.joinToString(", ") { "${it.field}: ${it.defaultMessage}" }
logger.warn("입력값 검증 실패: {}", message)
return ResponseEntity.badRequest().body(ApiResponse("입력값 검증 실패: $message"))
}

/**
* 잘못된 파라미터 처리 (400 Bad Request)
*/
@ExceptionHandler(IllegalArgumentException::class)
fun handleIllegalArgument(ex: IllegalArgumentException): ResponseEntity<ApiResponse<Void>> {
return ResponseEntity.badRequest()
.body(ApiResponse(ex.message ?: "잘못된 요청입니다"))
logger.warn("잘못된 파라미터: {}", ex.message)
return ResponseEntity.badRequest().body(ApiResponse(ex.message ?: "잘못된 요청입니다"))
}

/**
* 데이터 없음 처리 (404 Not Found)
*/
@ExceptionHandler(NoSuchElementException::class)
fun handleNoSuchElement(ex: NoSuchElementException): ResponseEntity<ApiResponse<Void>> {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse(ex.message ?: "요청한 데이터를 찾을 수 없습니다"))
}

/**
* 리소스 없음 처리 (404 Not Found) - favicon.ico 등
*/
@ExceptionHandler(NoResourceFoundException::class)
fun handleNoResourceFound(ex: NoResourceFoundException): ResponseEntity<ApiResponse<Void>> {
// favicon.ico 요청은 조용히 무시 (로그 안 남김)
if (ex.message?.contains("favicon.ico") == true) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).build()
}

// 다른 리소스는 로그 남기고 처리
println("⚠️ 리소스를 찾을 수 없음: ${ex.message}")
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse("요청한 리소스를 찾을 수 없습니다"))
logger.warn("데이터 없음: {}", ex.message)
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse(ex.message ?: "데이터를 찾을 수 없습니다"))
}

/**
* 접근 거부 처리 (403 Forbidden)
*/
@ExceptionHandler(AccessDeniedException::class)
fun handleAccessDenied(ex: AccessDeniedException): ResponseEntity<ApiResponse<Void>> {
println("🚫 접근 거부: ${ex.message}")
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse("접근 권한이 없습니다"))
}

/**
* 모든 예외 처리 (500 Internal Server Error)
* 위에서 처리되지 않은 모든 예외들의 최종 처리
*
* 주니어 개발자용 디버깅 정보 추가:
* - 상세한 에러 스택 트레이스
* - 요청 정보 로깅
* - 개발환경에서는 더 자세한 정보 제공
*/
@ExceptionHandler(Exception::class)
fun handleGenericException(
ex: Exception,
request: HttpServletRequest,
): ResponseEntity<ApiResponse<Map<String, Any?>>> {
println("❌ 예상치 못한 예외 발생!")
println(" 클래스: ${ex::class.simpleName}")
println(" 메시지: ${ex.message}")
println(" 요청 URL: ${request.method} ${request.requestURL}")
println(" 요청 IP: ${request.remoteAddr}")
println(" User-Agent: ${request.getHeader("User-Agent")}")

// 개발환경에서는 스택트레이스도 출력
ex.printStackTrace()

// 개발환경에서는 더 자세한 에러 정보 제공 (주니어 개발자 도움용)
val debugInfo = mutableMapOf<String, Any?>()
debugInfo["timestamp"] = System.currentTimeMillis()
debugInfo["path"] = request.requestURI
debugInfo["method"] = request.method
debugInfo["error"] = ex::class.simpleName
debugInfo["message"] = ex.message

// 스택 트레이스의 첫 3줄만 포함 (너무 길어지지 않도록)
debugInfo["trace"] = ex.stackTrace.take(3).map { it.toString() }

return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse("서버 내부 오류가 발생했습니다", debugInfo))
): ResponseEntity<ApiResponse<Void>> {
logger.error("서버 오류 - {}: {} at {}", ex::class.simpleName, ex.message, request.requestURI, ex)
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse("서버 내부 오류가 발생했습니다"))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.back.koreaTravelGuide.domain.ai.aiChat.service

import com.back.koreaTravelGuide.common.constant.PromptConstant.AI_ERROR_FALLBACK
import com.back.koreaTravelGuide.common.constant.PromptConstant.KOREA_TRAVEL_GUIDE_SYSTEM
import com.back.koreaTravelGuide.domain.ai.aiChat.entity.AiChatMessage
import com.back.koreaTravelGuide.domain.ai.aiChat.entity.AiChatSession
import com.back.koreaTravelGuide.domain.ai.aiChat.entity.SenderType
import com.back.koreaTravelGuide.domain.ai.aiChat.repository.AiChatMessageRepository
import com.back.koreaTravelGuide.domain.ai.aiChat.repository.AiChatSessionRepository
import org.springframework.ai.chat.client.ChatClient
import org.springframework.ai.chat.memory.ChatMemory
import org.springframework.stereotype.Service

@Service
class AiChatService(
private val aiChatMessageRepository: AiChatMessageRepository,
private val aiChatSessionRepository: AiChatSessionRepository,
private val chatClient: ChatClient,
) {
fun getSessions(userId: Long): List<AiChatSession> {
return aiChatSessionRepository.findByUserIdOrderByCreatedAtDesc(userId)
}

fun createSession(userId: Long): AiChatSession {
val newSession = AiChatSession(userId = userId)
return aiChatSessionRepository.save(newSession)
}

fun deleteSession(
sessionId: Long,
userId: Long,
) {
val session =
aiChatSessionRepository.findByIdAndUserId(sessionId, userId)
?: throw IllegalArgumentException("해당 채팅방이 없거나 삭제 권한이 없습니다.")

aiChatSessionRepository.deleteById(sessionId)
}

fun getSessionMessages(
sessionId: Long,
userId: Long,
): List<AiChatMessage> {
val session =
aiChatSessionRepository.findByIdAndUserId(sessionId, userId)
?: throw IllegalArgumentException("해당 채팅방이 없거나 접근 권한이 없습니다.")

return aiChatMessageRepository.findByAiChatSessionIdOrderByCreatedAtAsc(sessionId)
}

fun sendMessage(
sessionId: Long,
userId: Long,
message: String,
): Pair<AiChatMessage, AiChatMessage> {
val session =
aiChatSessionRepository.findByIdAndUserId(sessionId, userId)
?: throw IllegalArgumentException("해당 채팅방이 없거나 접근 권한이 없습니다.")

val userMessage =
AiChatMessage(
aiChatSession = session,
senderType = SenderType.USER,
content = message,
)
val savedUserMessage = aiChatMessageRepository.save(userMessage)

val response =
try {
chatClient.prompt()
.system(KOREA_TRAVEL_GUIDE_SYSTEM)
.user(message)
.advisors { advisor ->
advisor.param(ChatMemory.CONVERSATION_ID, sessionId.toString())
}
.call()
.content() ?: AI_ERROR_FALLBACK
} catch (e: Exception) {
AI_ERROR_FALLBACK
}

val aiMessage =
AiChatMessage(
aiChatSession = session,
senderType = SenderType.AI,
content = response,
)
val savedAiMessage = aiChatMessageRepository.save(aiMessage)
return Pair(savedUserMessage, savedAiMessage)
}
}
Loading