필자는 우선적으로 연동이 가능하도록, 기본 설정 및 구현 방식으로 접근하는 쉽고 러프한 방법부터 서술할 예정이다.
그래야 코드를 차근차근 이해하기도 좋고, 추후에 기본 코드를 베이스로 깔고 활용하기 편리할테니.
// OpenAI
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
# OpenAI
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.base-url=https://api.openai.com
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.retry.max-attempts=2
spring.ai.retry.backoff.initial-interval=500ms
package com.driven.dm.global.config.ai;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenAiConfig {
@Bean
public ChatClient chatClient(OpenAiChatModel chatModel) {
return ChatClient.builder(chatModel).build();
}
}
package com.driven.dm.global.config.ai;
public class OpenAiConstants {
private OpenAiConstants() {
}
public static final String PROVIDER_OPENAI = "OpenAI";
// 필요 시 모델 상수를 여기서 관리 (yml 기본값을 덮어쓸 때 사용)
public static final String MODEL_GPT_4O_MINI = "gpt-4o-mini";
public static final String MENU_DESCRIPTION_PROMPT =
"""
당신은 음식점 사장님을 돕는 카피라이터입니다.
아래 정보를 바탕으로 메뉴 설명을 한국어로, 사실 기반으로 작성하세요.
과장 표현은 줄이고, 고객이 이해하기 쉽게 표현하세요.
- 메뉴명: %s
- 카테고리: %s
- 주요 재료/특징: %s
출력은 순수 텍스트만 반환하세요.
답변을 최대한 간결하게 50자 이하로 작성하세요.
""";
}
여기서 요청 프롬프트 작성 및 수정하면 된다.
추후에 Few-shot Prompting으로 상세 예시까지 추가할 확장성까지 고려하여 클래스를 분리 구현하였다.
package com.driven.dm.ai.domain.entity;
import com.driven.dm.global.entity.BaseEntity;
import com.driven.dm.user.domain.entity.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
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 java.util.UUID;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Entity
@Getter
@ToString(exclude = {"user", "prompt", "outputText"})
@Table(name = "p_ai_call_log")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AiCallLog extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "ai_id", nullable = false, updatable = false)
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "ai_provider")
private String aiProvider;
@Column(name = "model")
private String model;
@Column(name = "prompt", columnDefinition = "text")
private String prompt;
@Column(name = "output_text", columnDefinition = "text")
private String outputText;
protected AiCallLog(User user, String aiProvider, String model, String prompt,
String outputText) {
this.user = user;
this.aiProvider = aiProvider;
this.model = model;
this.prompt = prompt;
this.outputText = outputText;
}
public static AiCallLog of(User user, String aiProvider, String model, String prompt,
String outputText) {
return new AiCallLog(user, aiProvider, model, prompt, outputText);
}
}
package com.driven.dm.ai.infrastructure.repository;
import com.driven.dm.ai.domain.entity.AiCallLog;
import java.util.List;
import java.util.Optional;
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;
public interface AiCallLogRepository extends JpaRepository<AiCallLog, UUID> {
Optional<AiCallLog> findById(UUID id);
// 목록: soft delete 제외 + 최신순 (created_at desc, ai_id desc)으로 페이징
@Query(
value =
"select p.* " +
"from ( " +
" select l.ai_id, l.created_at " +
" from p_ai_call_log l " +
" where l.deleted_at is null " +
" order by l.created_at desc, l.ai_id desc " +
" limit :limit offset :offset " +
") t " +
"join p_ai_call_log p on p.ai_id = t.ai_id " +
"order by t.created_at desc, t.ai_id desc",
nativeQuery = true
)
List<AiCallLog> findLogsWithPaging(
@Param("offset") Long offset,
@Param("limit") Long limit
);
// 로그 총 개수
@Query(
value =
"select count(*) " +
"from p_ai_call_log l " +
"where l.deleted_at is null",
nativeQuery = true
)
long countAllActiveLogs();
@Query(
value =
"select p.* " +
"from ( " +
" select l.ai_id, l.created_at " +
" from p_ai_call_log l " +
" where l.deleted_at is null " +
" and l.output_text ilike concat('%', :keyword, '%') " +
" order by l.created_at desc, l.ai_id desc " +
" limit :limit offset :offset " +
") t " +
"join p_ai_call_log p on p.ai_id = t.ai_id " +
"order by t.created_at desc, t.ai_id desc",
nativeQuery = true
)
List<AiCallLog> searchByOutputTextWithPaging(
@Param("keyword") String keyword,
@Param("offset") Long offset,
@Param("limit") Long limit
);
// 검색 로그 총 개수
@Query(
value =
"select count(*) " +
"from p_ai_call_log l " +
"where l.deleted_at is null " +
" and l.output_text ilike concat('%', :keyword, '%')",
nativeQuery = true
)
long countAllActiveLogsByOutputText(@Param("keyword") String keyword);
}
package com.driven.dm.ai.presentation.controller;
import com.driven.dm.ai.application.service.AiService;
import com.driven.dm.ai.presentation.dto.response.AiCallLogPageResponseDto;
import com.driven.dm.ai.presentation.dto.response.AiCallLogResponseDto;
import com.driven.dm.ai.presentation.dto.response.AiCallResponseDto;
import com.driven.dm.global.config.security.SecurityUser;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/ai")
public class AiController {
private final AiService aiService;
/**
* [AI 기반 메뉴 설명 자동 생성]
* 사용자가 등록한 메뉴 정보(메뉴명, 카테고리, 주요 재료)를 바탕으로 OpenAI 모델을 호출하여 200~250자 내외의 설명 문구를 생성
* 생성된 설명은 AiService 를 통해 DB 로그에 기록
*
* @param principal 현재 로그인한 사장님 유저
* @param menuName 메뉴명
* @param category 카테고리 (한식/중식/분식/치킨/피자)
* @param features 주요 재료/특징 (쉼표로 구분, 예: 돼지고기, 춘장, 양파)
* @return AI가 생성한 메뉴 설명 텍스트를 담은 DTO 객체
*/
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER', 'OWNER')")
@PostMapping("/generate-description")
public ResponseEntity<AiCallResponseDto> generateMenuDescription(
@AuthenticationPrincipal SecurityUser principal,
@RequestParam String menuName,
@RequestParam String category,
@RequestParam String features) {
return ResponseEntity.ok(aiService.generateMenuDescription(principal.getId(), menuName, category, features));
}
/**
* [AI 호출 로그 단건 조회]
* MASTER, MANAGER 접근 가능
*
* @param id 조회할 로그의 UUID
* @return 조회된 로그 정보를 담은 DTO 객체
*/
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')")
@GetMapping("/logs/{id}")
public ResponseEntity<AiCallLogResponseDto> getAiCallLog(
@PathVariable("id") UUID id) {
return ResponseEntity.ok(aiService.getAiCallLog(id));
}
/**
* [AI 호출 로그 검색 (목록) 조회]
* MASTER, MANAGER 접근 가능
* 출력 텍스트(output_text) 내용에 주어진 키워드가 포함된 로그만 조회
* 검색어 없을 시 로그 전체 목록 조회
*
* @param content 검색 키워드 (null 또는 공백이면 전체 목록 반환)
* @param page 현재 페이지
* @param pageSize 페이지 당 내역 수
* @return 조회된 로그 리스트와 총 개수를 담은 DTO 객체
*/
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')")
@GetMapping("/logs/search")
public ResponseEntity<AiCallLogPageResponseDto> searchLogByContent(
@RequestParam("keyword") String content,
@RequestParam(value = "page", defaultValue = "1") Long page,
@RequestParam(value = "pageSize", defaultValue = "10") Long pageSize) {
return ResponseEntity.ok(aiService.searchLogByContent(content, page, pageSize));
}
/**
* [AI 호출 로그 단건 삭제]
* MASTER, MANAGER 접근 가능
*
* @param principal 현재 로그인한 유저
* @param id 삭제할 로그의 UUID
* @return 삭제 성공 메시지
*/
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')")
@DeleteMapping("/logs/{id}")
public ResponseEntity<String> deleteAiCallLog(
@AuthenticationPrincipal SecurityUser principal,
@PathVariable("id") UUID id) {
aiService.deleteAiCallLog(id, principal.getId());
return ResponseEntity.ok("성공적으로 삭제되었습니다.");
}
/**
* [AI 호출 로그 단건 복구]
* MASTER, MANAGER 접근 가능
* softDelete 된 로그 내역의 delete_at & deleted_by 값을 null 로 만들어 복구
*
* @param principal 현재 로그인한 유저
* @param id 복구할 로그의 UUID
* @return 복구 성공 메시지
*/
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')")
@PatchMapping("/logs/{id}")
public ResponseEntity<String> restoreAiCallLog(
@AuthenticationPrincipal SecurityUser principal,
@PathVariable("id") UUID id) {
aiService.restoreAiCallLog(id, principal.getId());
return ResponseEntity.ok("성공적으로 복구되었습니다.");
}
}
package com.driven.dm.ai.application.service;
import com.driven.dm.ai.application.exception.AiErrorCode;
import com.driven.dm.ai.domain.entity.AiCallLog;
import com.driven.dm.ai.infrastructure.repository.AiCallLogRepository;
import com.driven.dm.ai.presentation.dto.response.AiCallLogPageResponseDto;
import com.driven.dm.ai.presentation.dto.response.AiCallLogResponseDto;
import com.driven.dm.ai.presentation.dto.response.AiCallResponseDto;
import com.driven.dm.global.config.ai.OpenAiConstants;
import com.driven.dm.global.exception.AppException;
import com.driven.dm.user.application.service.UserReader;
import com.driven.dm.user.domain.entity.User;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class AiService {
private final ChatClient chatClient;
private final AiCallLogRepository aiCallLogRepository;
private final UserReader userReader;
private static final long DEFAULT_PAGE = 1L;
private static final long DEFAULT_SIZE = 10L;
private static final List<Long> PAGE_SIZE_WHITELIST = List.of(10L, 30L, 50L);
/**
* [OpenAI 호출로 생성 & AiCallLog 에 요청/응답 저장]
*
* @param userId 현재 사장님 유저
* @param menuName 메뉴명
* @param category 카테고리 (한식/중식/분식/치킨/피자)
* @param features 주요 재료 (쉼표로 나열 가능)
* @return AI가 생성한 메뉴 설명 텍스트
*/
@Transactional
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER', 'OWNER')")
public AiCallResponseDto generateMenuDescription(UUID userId, String menuName, String category,
String features) {
User owner = userReader.findActiveUser(userId);
// 1. 프롬프트 생성
String prompt = String.format(OpenAiConstants.MENU_DESCRIPTION_PROMPT, menuName, category,
features);
// 2. SpringAI(ChatClient → RestClient) + OpenAI API 호출
String outputText;
try {
outputText = chatClient
.prompt() // 메시지 빌더 시작
.user(prompt) // user 역할 메시지로 방금 만든 프롬프트 추가
.call() // 실제 LLM 동기 호출 (OpenAI API 호출)
.content(); // 응답에서 텍스트만 추출
} catch (Exception e) {
log.error("OpenAI 호출 실패: {}", e.getMessage(), e);
// 장애 시 간단 fallback
outputText = menuName + "은(는) 신선한 재료로 만든 담백한 맛이 특징입니다.";
}
// 3. 호출 로그 저장
AiCallLog aiCallLog = AiCallLog.of(
owner,
OpenAiConstants.PROVIDER_OPENAI,
OpenAiConstants.MODEL_GPT_4O_MINI,
prompt,
outputText
);
aiCallLogRepository.save(aiCallLog);
return AiCallResponseDto.from(aiCallLog);
}
/**
* [AI 호출 로그 목록 조회]
*
* @param page 현재 페이지
* @param pageSize 페이지 당 내역 수
* @return 조회된 로그 리스트 정보와 전체 개수를 담은 DTO 객체
*/
@Transactional(readOnly = true)
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')")
public AiCallLogPageResponseDto getAiCallLogList(Long page, Long pageSize) {
long p = normalizePage(page);
long s = normalizePageSize(pageSize);
List<AiCallLogResponseDto> logList = aiCallLogRepository
.findLogsWithPaging((p - 1) * s, s)
.stream()
.map(AiCallLogResponseDto::from)
.toList();
long totalCount = aiCallLogRepository.countAllActiveLogs();
return AiCallLogPageResponseDto.of(logList, totalCount);
}
/**
* [AI 호출 로그 검색 조회]
*
* @param content 검색 키워드 (null 또는 공백이면 전체 목록 반환)
* @param page 현재 페이지
* @param pageSize 페이지 당 내역 수
* @return 조회된 로그 리스트와 총 개수를 담은 DTO 객체
*/
@Transactional(readOnly = true)
@Cacheable(value = "searchLog", key = "T(java.util.Objects).hash(#content,#page,#pageSize)")
public AiCallLogPageResponseDto searchLogByContent(String content, Long page, Long pageSize) {
long p = normalizePage(page);
long s = normalizePageSize(pageSize);
// keyword 가 null 이거나 공백이면 전체 목록을 반환
if (content == null || content.isBlank()) {
return getAiCallLogList(p, s);
}
long offset = (p - 1) * s;
List<AiCallLogResponseDto> logList = aiCallLogRepository
.searchByOutputTextWithPaging(content, offset, s)
.stream()
.map(AiCallLogResponseDto::from)
.toList();
long totalCount = aiCallLogRepository.countAllActiveLogsByOutputText(content);
if (totalCount == 0) {
throw AppException.of(AiErrorCode.AI_LOG_SEARCH_NOT_FOUND);
}
return AiCallLogPageResponseDto.of(logList, totalCount);
}
/**
* [AI 호출 로그 단건 조회]
*
* @param id 조회할 로그의 UUID
* @return 조회된 로그를 응답 DTO 로 변환한 객체
*/
@Transactional(readOnly = true)
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')")
public AiCallLogResponseDto getAiCallLog(UUID id) {
AiCallLog aiCallLog = getLogOrThrow(id);
if(aiCallLog.isDeleted()) {
throw AppException.of(AiErrorCode.AI_LOG_ALREADY_DELETED);
}
return AiCallLogResponseDto.from(aiCallLog);
}
/**
* [AI 호출 로그 단건 삭제]
*
* @param id 삭제할 로그의 UUID
* @param deleterUserId 삭제를 수행하는 유저의 UUID
*/
@Transactional
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')")
public void deleteAiCallLog(UUID id, UUID deleterUserId) {
AiCallLog aiCallLog = getLogOrThrow(id);
if(aiCallLog.isDeleted()) {
throw AppException.of(AiErrorCode.AI_LOG_ALREADY_DELETED);
}
aiCallLog.delete(deleterUserId);
}
/**
* [AI 호출 로그 단건 복구]
*
* @param id 복구할 로그의 UUID
* @param restorerUserId 복구를 수행하는 유저의 UUID
*/
@Transactional
@PreAuthorize("hasAnyRole('MASTER', 'MANAGER')")
public void restoreAiCallLog(UUID id, UUID restorerUserId) {
AiCallLog aiCallLog = getLogOrThrow(id);
if(!aiCallLog.isDeleted()) {
throw AppException.of(AiErrorCode.AI_LOG_HAS_NOT_BEEN_DELETED);
}
aiCallLog.restore(restorerUserId);
}
// [공통] 로그 단건 조회 메서드
private AiCallLog getLogOrThrow(UUID id) {
return aiCallLogRepository.findById(id)
.orElseThrow(() -> AppException.of(AiErrorCode.AI_LOG_NOT_FOUND));
}
// [공통] 페이징 보정 - 페이지 번호: null 또는 1 미만이면 1
private static long normalizePage(Long page) {
return (page == null || page < 1) ? DEFAULT_PAGE : page;
}
// [공통] 페이징 보정 - 페이지 크기: 화이트리스트 외/ null 이면 10
private static long normalizePageSize(Long pageSize) {
return (pageSize == null || !PAGE_SIZE_WHITELIST.contains(pageSize)) ? DEFAULT_SIZE : pageSize;
}
}