SpringAi + OpenAI API 연동 방법

졸용·2025년 10월 13일

참고

목록 보기
12/15

🔹 SpringAi(ChatClient) + OpenAI API 연동한 기본 CRUD

필자는 우선적으로 연동이 가능하도록, 기본 설정 및 구현 방식으로 접근하는 쉽고 러프한 방법부터 서술할 예정이다.

그래야 코드를 차근차근 이해하기도 좋고, 추후에 기본 코드를 베이스로 깔고 활용하기 편리할테니.

1. OpenAI API 키 발급받기

OpenAI API 키 발급 받는 방법



2. build.gradle 의존성 추가

	// OpenAI
	implementation 'org.springframework.ai:spring-ai-starter-model-openai'


3. .properties 설정 추가

# 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


4. OpenAiConfig 설정

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();
    }
}


5. OpenAiConstants (추후 확장성 고려한 프롬프트 전용 클래스)

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으로 상세 예시까지 추가할 확장성까지 고려하여 클래스를 분리 구현하였다.



6. AiCallLog 엔티티

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);
    }
}


7. AiCallLogRepository (확장성 고려한 쿼리 작성 방식)

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);
}


8. AiController - CRUD API

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("성공적으로 복구되었습니다.");
    }
}


9. AiService - CRUD 비즈니스로직

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;
    }
}
profile
꾸준한 공부만이 답이다

0개의 댓글