Skip to content
Merged
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,87 @@
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.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)
.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)
}
}

This file was deleted.