티스토리 뷰

반응형

이번에 동아리에서 프로젝트를 진행하면서 FastAPI 대신 Spring AI를 이용해서 하는 게 더 좋을 것 같다는 피드백을 받아서

Spring AI를 사용하게 되었다.

기존에는 FastAPI에서 OpenAI API를 직접 호출하고 Spring Boot에서 FastAPI를 호출하는 방식만 써봤는데,
Spring AI를 사용하니까 설정도 깔끔하고 구조도 정리하기 좋아서 한번 정리해두면 좋을 것 같아서 글로 남겨본다.

✏️ 사용한 기술 스택

  • Spring Boot 3.2.x
  • Spring AI
  • OpenAI (gpt-4o-mini)
  • MySQL
  • JPA
  • Redis
  • Gradle

✏️ application.yml 

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o-mini
          temperature: 0.3
          max-tokens: 800

✏️ Gradle 설정

Spring AI는 BOM으로 관리하는 것 같

repositories {
    mavenCentral()
    maven { url 'https://repo.spring.io/milestone' }
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.ai:spring-ai-bom:0.8.1"
    }
}

dependencies {
    implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter'
}

✏️ 전체 구조

ai
 ├── service
 │   └── AiService
 ├── prompt
 │   ├── ProductPromptBuilder
 │   └── ReferencePromptBuilder
 ├── dto
 │   ├── request
 │   └── response

✏️ AI  Service && OpenAiConfig

@Service
@RequiredArgsConstructor
public class AiService {

    private final ChatClient chatClient;
    private final ObjectMapper objectMapper;

    /* =========================
       상품 추천
    ========================= */
    public List<AiProductResponse> recommendProductList(AiProductRequest request) {
        String prompt = ProductPromptBuilder.build(request);
        String raw = chatClient.call(prompt);
        return parse(raw, new TypeReference<>() {});
    }

    /* =========================
       레퍼런스 추천
    ========================= */
    public AiReferenceResponse recommendReferenceList(AiReferenceRequest request) {
        String prompt = ReferencePromptBuilder.build(request);
        String raw = chatClient.call(prompt);
        return parse(raw, AiReferenceResponse.class);
    }

    /* =========================
       공통 파서 (핵심)
    ========================= */
    private <T> T parse(String raw, Class<T> clazz) {
        try {
            String json = extractJson(raw);
            return objectMapper.readValue(json, clazz);
        } catch (Exception e) {
            throw new GeneralException(ErrorStatus.AI_RESPONSE_NOT_PARSE);
        }
    }

    private <T> T parse(String raw, TypeReference<T> typeRef) {
        try {
            String json = extractJson(raw);
            return objectMapper.readValue(json, typeRef);
        } catch (Exception e) {
            throw new GeneralException(ErrorStatus.AI_RESPONSE_NOT_PARSE);
        }
    }

    private String extractJson(String raw) {
        int objStart = raw.indexOf("{");
        int arrStart = raw.indexOf("[");

        int start;

        if (objStart == -1 && arrStart == -1) {
            throw new GeneralException(ErrorStatus.AI_RESPONSE_NOT_JSON);
        }

        // 둘 중 먼저 나온 쪽 선택
        if (objStart == -1) start = arrStart;
        else if (arrStart == -1) start = objStart;
        else start = Math.min(objStart, arrStart);

        char openChar = raw.charAt(start);
        char closeChar = (openChar == '{') ? '}' : ']';

        int end = raw.lastIndexOf(closeChar);
        if (end == -1 || end <= start) {
            throw new GeneralException(ErrorStatus.AI_RESPONSE_NOT_JSON);
        }

        return raw.substring(start, end + 1);
    }


}

@Configuration
public class OpenAiConfig {

}

✏️ JSON 파싱 처리 

AI 응답이 항상 JSON만 오는 건 아니라서 그냥 바로 파싱하면 에러가 자주 발생했다.

그래서 JSON 부분만 잘라내는 로직을 추가했다.

private String extractJson(String raw) {
    int objStart = raw.indexOf("{");
    int arrStart = raw.indexOf("[");

    int start;
    if (objStart == -1) start = arrStart;
    else if (arrStart == -1) start = objStart;
    else start = Math.min(objStart, arrStart);

    char close = raw.charAt(start) == '{' ? '}' : ']';
    int end = raw.lastIndexOf(close);

    return raw.substring(start, end + 1);
}

✏️ PromptBuilder

사실 전문적인 프롬포팅 엔지니어도 아니고 단순 백엔드 개발자라 그냥 이런 방식으로 작성을 해보았다

public class ReferencePromptBuilder {

    private static final ObjectMapper om = new ObjectMapper();

    private static final String SYSTEM = """
            너는 인테리어 추천 서비스의 '레퍼런스 평가 AI'이다.

            아래에 제공된 레퍼런스 후보들은
            사용자의 공간, 분위기, 스타일, 색상 조건을 기준으로
            이미 1차 필터링된 상태이다.

            너의 역할은 다음과 같다.

            ────────────────────
            [점수 산정 기준] (총 100점)
            ────────────────────
            1. 공간 적합도 (0~40점)
            - 사용자가 선택한 공간 유형(거실/침실/서재 등)에 잘 어울리는가?

            2. 분위기 / 스타일 일치도 (0~40점)
            - 사용자가 선택한 분위기 및 스타일과 조화로운가?

            3. 색감 일치도 (0~20점)
            - 사용자가 선택한 색상과 레퍼런스 색감이 잘 맞는가?

            ────────────────────
            [출력 규칙]
            ────────────────────
            1. 각 레퍼런스를 0~100점으로 평가한다.
            2. 점수가 높은 레퍼런스 3개만 선택한다.
            3. 반드시 referenceId를 그대로 사용한다.
            4. imageUrl은 제공된 값만 사용한다.
            5. 선택 이유를 간단히 작성한다.
            6. 선택된 레퍼런스를 종합하여 하나의 분위기 요약을 작성한다.
            7. 반드시 JSON 객체 형식으로만 출력한다.
            8. JSON 외 텍스트 출력 금지.

            ────────────────────
            [출력 형식]
            ────────────────────
            {
              "title": "%s님을 위한 인테리어 무드 추천",
              "referenceIdList": [1, 2, 3],
              "imageUrlList": ["url1", "url2", "url3"],
              "moodDescription": "...",
              "moodKeywords": ["...", "..."],
              "stylingTips": ["...", "..."],
              "summary": "..."
            }
            """;

    public static String build(AiReferenceRequest request) {
        try {
            String candidateText =
                    Optional.ofNullable(request.candidates())
                            .orElse(Collections.emptyList())
                            .stream()
                            .map(ReferencePromptBuilder::formatCandidate)
                            .collect(Collectors.joining("\n"));

            return SYSTEM.formatted(request.userName())
                    + "\n\n[사용자 조건]\n"
                    + om.writeValueAsString(request)
                    + "\n\n[레퍼런스 후보 목록]\n"
                    + candidateText;

        } catch (Exception e) {
            throw new IllegalStateException("Reference 프롬프트 생성 실패", e);
        }
    }

    private static String formatCandidate(CandidateReferenceInfo r) {
        String tags = Optional.ofNullable(r.tagList())
                .orElse(Collections.emptySet())
                .stream()
                .map(ReferenceTagInfo::name)
                .collect(Collectors.joining(", "));

        return String.format(
                """
                        referenceId:%d
                        imageUrl:%s
                        설명:%s
                        태그:%s
                        """,
                r.referenceId(),
                r.imageUrl(),
                r.description(),
                tags
        );
    }
}

 

 

생각보다 설정이나 기능을 이용하는 방식이 간단한 것 같았다.

처음엔 Spring AI가 자료도 많이 없다는 말도 들었고, 당연히 FastAPI를 호출해서 하는 구조가 좋은 줄만 알았는데
현재 팀의 플젝처럼 단순 OpenAI를 호출해서 프롬포팅을 입력하는 것은 Spring AI를 이용해도 충분한 것 같다.

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함