spring-ai/01.기본

04. 프로젝트 구성

은서파 2025. 5. 27. 22:12

드디어 spring ai의 정식 버전이 2025.05.20일 발표되었다! 그동안 M 버전을 쓰면서 껄끄러웠는데 시원~~

이번 시간에는 Spring AI 기반 프로젝트를 만들고 Model과의 연결을 테스트해보자.

 

프로젝트 구성

 

모델 선택

SpringBoot starter를 통해 ai 관련 의존성을 추가할 수 있는데 어떤 모델을 사용할 것인지에 대한 선택지가 제공된다. 이미 Open AI 등의 API Key가 있다면 해당 모델을 선택하면 된다. 이 블로그에서는 Ollama AI를 사용할 계획이다.

사용하려는 model에 적합한 의존성을 선택한다.

Spring AI에서는 BOM(Bill of Materials: 자재 명세서?)을 사용하는데 이는 특정 라이브러리 그룹의 호환되는 버전들을 모아놓은 목록이다. 이를 위해 <dependency> 뿐 아니라 <dependencyManagement> 태그도 추가된다. 따라서 직접 pom.xml을 편집하는 것 보다  starter를 통해서 의존성을 등록하는 편이 좋다.

사실 SpringBoot도 이미 BOM 기반으로 하위 의존성들을 관리하고 있다.  boot의 의존성 관리 최 상위 파일인 spring-boot-dependencies.pom을 살펴보면 <dependencyManagement>에서 의존성을 관리하는 코드를 확인할 수 있다.

 

pom.xml

프로젝트를 위한 의존성으로는 Ollama AI, Spring Web, Spring Boot Dev Tools, Lombok을 추가하자. Open AI는 비교용으로 추가해봤다.

필요한 의존성

더보기
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.0</version>
        <relativePath /> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.quietjun.springai</groupId>
    <artifactId>quietjun_springai</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>quietjun_springai</name>
    <description>Demo project for Spring Boot</description>
    <url />
    <licenses>
        <license />
    </licenses>
    <developers>
        <developer />
    </developers>
    <scm>
        <connection />
        <developerConnection />
        <tag />
        <url />
    </scm>
    <properties>
        <java.version>17</java.version>
        <spring-ai.version>1.0.0</spring-ai.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-ollama</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-starter-model-openai</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.ai</groupId>
                <artifactId>spring-ai-bom</artifactId>
                <version>${spring-ai.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

 

application.properties

다음으로 사용하는 모델에 적합한 속성을 application.properties에 추가해준다.

ollama는 다음의 자료를 활용하면 된다. 

https://docs.spring.io/spring-ai/reference/api/chat/ollama-chat.html

 

Ollama Chat :: Spring AI Reference

Ollama provides custom Structured Outputs APIs that ensure your model generates responses conforming strictly to your provided JSON Schema. In addition to the existing Spring AI model-agnostic Structured Output Converter, these APIs offer enhanced control

docs.spring.io

 

다음은 ollama와 관련된 일반적인 설정들이다.  ollama.base-url을 제외한 option들은 런타임에도 수정 가능하다. 특히 ollama의 경우 여러가지 모델을 필요에 따라 사용할 수 있어서 런타임에 모델의 이름을 전달하는 것이 일반적이다.

# ollama service url: 기본
spring.ai.ollama.base-url=http://localhost:11434
# 사용하는 model: runtime에 수정 가능
# spring.ai.ollama.chat.options.model=exaone3.5:latest      
# 답변을 생성하는 자유도: 기본
spring.ai.ollama.chat.options.temperature=0.1 
# context-window 의 개수: 기본
spring.ai.ollama.chat.options.num-ctx=2048
# 모델이 응답에서 생성할 수 있는 토큰의 수
spring.ai.ollama.chat.options.max-tokens=500
# 생성 중 상위 몇 개까지 결과를 채택할 것인가?
spring.ai.ollama.chat.options.top-k=5

 

위 내용 중 Temperature, Max-Tokens, Top-K에 대한 가이드는 다음과 같다.

  • Temperature: 모델 응답의 무작위성 또는 창의성을 제어하는 속성으로 0~1 사이의 double 값을 갖는다.
    • Lower Values(0.0 ~ 0.3): 보다 결정적이고 집중적인 응답. 사실적인 질문, 분류 또는 일관성이 중요한 작업에 적합
    • Medium Values(0.4 ~ 0.7): 결정론과 창의성 사이의 균형. 일반적인 사용 사례에 적합
    • HIgher Values(0.8 ~ 1.0): 보다 창의적이고 다양한 결과로 창의적인 글쓰기, 브레인 스토밍 또는 다양한 옵션 생성에 적합
  • Output Length(MaxTokens): 모델이 응답에서 생성할 수 있는 토큰의 수를 제한한다.
    • Low Values(5 ~ 25): 단일 단어, 짧은 구문 또는 분류 레이블의 경우
    • Medium Values(50 ~ 500): 단락이나 짧은 설명에 적합
    • High Values(1000개 이상): 긴 형식의 콘텐츠, 스토리 또는 복잡한 설명에 적합
    • 불필요한 장황함 없이 완전한 응답을 얻으려면 적절한 출력 길이를 설정하는 것이 중요하다.
  • Sampling Controls(Top-K and Top-P): 생성 중 후보를 선택하는 프로세스를 세밀하게 제어한다.
    • Top-K: 고정 개수 방식으로 상위 K 개의 후보만을 고려
    • Top-P: 확률 누적 방식으로 확률 합계가 P%에 달할 때까지 후보 선택
      • P=0.9이고 A:50%, B:30%, C:15%, D:3%, E:2%라면 A, B, C 선택

 

만약 OpenAI를 사용한다면 일반적으로 다음과 같은 설정이 가능하다. 

# api key
spring.ai.openai.api-key=sk-proj-여러분의-키를-입력하세요
# 사용할 OpenAI chat model의 이름
spring.ai.openai.chat.options.model=gpt-4o-mini
# 창의성을 제어하는데 사용되는 샘플링 온도: 높을 수록 창의성이 높아짐 - 뻘소리할 확율도.. (0~2)
spring.ai.openai.chat.options.temperature=0.8
# 출력과 추론에 사용되는 토큰 수의 상한선
spring.ai.openai.chat.options.maxCompletionTokens=1000

 

빈 구성 및 테스트

 

빈 구성

Ollama의 경우 다양한 실제 모델(Exaone, Gemma, ...)들이 존재하기 때문에 runtime에 모델을 지정해서 빈을 구성해야 한다. 하지만 OpenAI의 경우와 같은 상용 서비스들은 표준화된 API 구조를 가지고 있어서 auto-configuration을 통해서 하나의 모델을 빈으로 제공한다.

@Configuration
@RequiredArgsConstructor
@Slf4j
public class AiConfig {

    // application.properties의 내용(baseUrl, name, temperature...)을 재정의한다.
    private OllamaChatModel getOllamaChatModel(String name, String baseUrl) {
        OllamaApi api = OllamaApi.builder().baseUrl(baseUrl).build();
        return OllamaChatModel.builder().ollamaApi(api)
                .defaultOptions(OllamaOptions.builder().model(name).build())
                .build();
    }

    @Bean
    ChatClient ollamaExaoneChatClient(@Value("${spring.ai.ollama.base-url}") String baseUrl) {
        return ChatClient.builder(getOllamaChatModel("exaone3.5:latest", baseUrl)).build();
    }

    @Bean
    ChatClient ollamaGemma3ChatClient(@Value("${spring.ai.ollama.base-url}") String baseUrl) {
        return ChatClient.builder(getOllamaChatModel("gemma3:4b-it-qat", baseUrl)).build();
    }

    @Bean
    ChatClient ollamaLlama3ToolChatClient(@Value("${spring.ai.ollama.base-url}") String baseUrl) {
        return ChatClient.builder(getOllamaChatModel("qwen3:8b", baseUrl)).build();
    }

    @Bean // 미리 만들어진 ChatModel을 사용하는 경우
    ChatClient openAiChatClientDefault(OpenAiChatModel model) {
        return ChatClient.builder(model).build();
    }

    @Bean 
    ChatClient openAiChatClient() {
        OpenAiApi api = OpenAiApi.builder().apiKey("API_KEY").build();
        OpenAiChatOptions options = OpenAiChatOptions.builder().model("gpt-4o").build();
        OpenAiChatModel model = OpenAiChatModel.builder().openAiApi(api).defaultOptions(options).build();
        return ChatClient.builder(model).build();
    }
}

 

빈 구성 테스트

위에서 만들어진 빈들이 잘 동작하는지 테스트해보자.

@SpringBootTest
@Slf4j
class ChatClientGenTest {

    @Autowired
    @Qualifier("ollamaExaoneChatClient")
    ChatClient ollamaExaoneChatClient;

    @Autowired
    @Qualifier("ollamaGemma3ChatClient")
    ChatClient ollamaGemma3ChatClient;

    @Autowired
    @Qualifier("ollamaQwenChatClient")
    ChatClient ollamaQwenChatClient;

    @Autowired
    @Qualifier("openAiChatClient")
    ChatClient openAiChatClient;

    @Test
    void useChatClient() {
        String question = "대한민국의 수도는? 단답형으로 이야기해줘.";
        ChatClient[] ccs = { ollamaExaoneChatClient, ollamaGemma3ChatClient, ollamaQwenChatClient, openAiChatClient };
        Arrays.stream(ccs).forEach(cc -> {
            String result = cc.prompt().user(question).call().content();
            log.debug("result: {}", result);
        });
    }
}
08:46:07 - com.quietjun.springai..checkBean.40 exaone3.5:latest, 서울
08:46:07 - com.quietjun.springai..checkBean.40 gemma3:4b-it-qat, 서울
08:46:14 - com.quietjun.springai..checkBean.40 qwen3:8b, <think>...</think>서울
08:46:14 - com.quietjun.springai..checkBean.40 gpt-4o-mini, 서울입니다.