대규모 언어 모델이 소프트웨어 개발에 미치는 영향

Spring 기반의 실제 사례와 함께

대규모 언어 모델(LLM)은 소프트웨어 개발의 패러다임을 빠르게 변화시키고 있습니다.
2024년 5월 기준, LLM은 단순한 챗봇을 넘어 복잡한 도메인 문제를 해결하는 강력한 도구로 자리 잡았습니다.
이 글에서는 LLM이 소프트웨어 개발에 미치는 잠재적 영향과, 이를 효과적으로 활용하기 위해 무엇을 준비해야 하는지 살펴보겠습니다.
최신 트렌드와 Spring 기반의 실용적인 예제 코드를 추가하여 내용을 보완했습니다.


LLM이란 무엇인가?

LLM(Large Language Model)은 방대한 데이터를 학습해 자연어 처리, 코드 생성, 데이터 분석 등 다양한 작업을 수행할 수 있는 인공지능 모델입니다.
예를 들어, ChatGPT나 Grok 3 같은 모델은 텍스트 기반의 대화뿐만 아니라 게임 내 대화, 코드 디버깅, 데이터 분류 등에 활용되고 있습니다.
LLM은 단순히 기존의 AI와 달리, 직관적이고 유연한 방식으로 도메인 지식을 처리하며, 이는 소프트웨어 개발의 새로운 가능성을 열어줍니다.

LLM의 특징

  • 범용성: 영어, 한국어 등 다양한 언어를 이해하고, 도메인별 지식을 학습할 수 있습니다.
  • 유연성: 프롬프트 엔지니어링을 통해 특정 작업에 최적화할 수 있습니다.
  • 확장성: Retrieval-Augmented Generation(RAG)이나 Fine-Tuning을 통해 특정 도메인에 특화된 모델로 발전 가능합니다.

소프트웨어 개발에 미치는 영향

LLM은 소프트웨어 개발의 여러 측면에서 혁신을 가져오고 있습니다.
아래는 주요 시나리오와 그에 따른 준비 방안입니다.

시나리오 1: 파괴적 변화 (세계의 종말?)

LLM이 모든 것을 자동화하여 인간의 역할이 사라질 가능성이 제기되기도 합니다.
예를 들어, AI가 토스터 공장을 최적화하려다 세계를 장악하는 시나리오가 있죠.
하지만 이는 과장된 우려로, 실제로는 인간의 창의성과 도메인 전문성이 여전히 필요합니다. 이 시나리오에 대비하려면

  • 지속적인 학습: LLM의 한계를 이해하고, 이를 보완할 수 있는 인간의 역할을 강화해야 합니다.
  • 윤리적 고려: AI의 오작동을 방지하기 위한 가드레일 설계가 필수적입니다.

시나리오 2: 제한된 발전

LLM이 현재 수준에서 크게 발전하지 않을 가능성도 있습니다.
하지만 현재의 LLM만으로도 코드 생성, 자동 테스트, 문서화 등에서 상당한 가치를 제공합니다. 예를 들어, GitHub Copilot은 이미 개발자의 생산성을 30% 이상 향상시켰다는 연구 결과가 있습니다(2024년 기준).

이 시나리오에서는

  • 기존 도구 활용: LLM을 보조 도구로 활용해 생산성을 향상해야 합니다.
  • 소규모 프로젝트: 특정 작업(예: 데이터 정리, 간단한 챗봇)에 LLM을 적용해볼 수 있습니다.

시나리오 3: 소프트웨어 혁명 (가장 가능성 높은 시나리오)

LLM이 소프트웨어 개발의 핵심 도구로 자리 잡아, 10년 후 개발 방식이 완전히 달라질 가능성이 높습니다.
인간은 여전히 도메인 모델링, 아키텍처 설계, LLM 통합에서 중요한 역할을 할 것입니다.

이 시나리오에 대비하려면

  • 프롬프트 엔지니어링: LLM의 출력을 정밀하게 제어하는 기술을 습득해야 합니다.
  • Fine-Tuning: 특정 도메인에 맞춘 모델 최적화 방법을 익혀야 합니다.
  • 도메인 주도 설계(DDD)와의 통합: LLM을 DDD의 도메인 모델링에 활용하는 방법을 모색해야 합니다.

DDD와 LLM의 결합

도메인 주도 설계(DDD)는 복잡한 도메인을 모델링하고, 도메인 전문가와 협력하여 소프트웨어를 설계하는 방법론입니다.
LLM은 DDD의 핵심 원칙인 유비쿼터스 언어(Ubiquitous Language)바운디드 컨텍스트(Bounded Context)를 강화할 수 있습니다.

유비쿼터스 언어와 프롬프트 엔지니어링

프롬프트 엔지니어링은 LLM이 도메인에 맞는 응답을 생성하도록 유도하는 기술입니다.
이는 DDD의 유비쿼터스 언어와 유사합니다.
예를 들어, 해운 시스템에서 "Shipment"와 "Cargo"의 의미를 명확히 정의한 후, 이를 기반으로 프롬프트를 설계하면 LLM이 더 정확한 출력을 생성합니다.

사례: 해적 게임 대화 시스템

단순히 "너는 해적이야"라는 프롬프트로는 1690년대 해적의 말투와 맥락을 정확하게 반영하기 어렵습니다.
이를 해결하기 위해 두 개의 프롬프트로 분리하여 일관성을 높일 수 있습니다.

1. 일관성 체크 프롬프트: 입력이 시대적 맥락에 맞는지 확인합니다.
2. 대화 생성 프롬프트: 맥락에 맞는 해적의 응답을 생성합니다.

이를 통해 게임 내 NPC의 대화가 더 자연스럽고 몰입감 높아질 수 있습니다.

// Spring Boot 애플리케이션의 LLM 서비스 예시
@Service
public class PirateChatService {

    private final RestClient restClient; // Spring 6에 도입된 새로운 HTTP 클라이언트

    public PirateChatService(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.baseUrl("http://llm-api.example.com").build(); // LLM API 엔드포인트
    }

    /**
     * 입력 텍스트가 1690년대 해적 설정에 맞는지 확인합니다.
     * @param text 확인할 텍스트
     * @return 일관성 검사 결과 (적합 여부 및 이유)
     */
    public ConsistencyCheckResponse checkConsistency(String text) {
        String prompt = "다음 텍스트가 1690년대 해적 설정에 맞는지 확인해. 부적합하면 이유를 설명해.\n입력: \"" + text + "\"";

        // LLM API 호출 (실제 API 응답 형식에 따라 매핑 필요)
        String llmResponse = restClient.post()
                .uri("/check-consistency")
                .body(new LLMRequest(prompt))
                .retrieve()
                .body(String.class); // LLM 응답을 String으로 받음

        // 응답 파싱 및 ConsistencyCheckResponse 객체로 변환 (예시)
        // 실제 LLM 응답에 따라 JSON 파싱 로직 필요
        if (llmResponse.contains("부적합")) {
            return new ConsistencyCheckResponse(false, llmResponse.substring(llmResponse.indexOf("이유:") + 3).trim());
        } else {
            return new ConsistencyCheckResponse(true, "적합");
        }
    }

    /**
     * 시대적 맥락에 맞는 해적 대화를 생성합니다.
     * @param context 대화의 맥락
     * @param playerInput 플레이어의 입력
     * @return 해적 NPC의 응답
     */
    public String generatePirateDialogue(String context, String playerInput) {
        // 더 정교한 프롬프트 엔지니어링을 통해 역할, 페르소나 등을 정의할 수 있습니다.
        String prompt = "당신은 1690년대의 거친 해적 선장입니다. 다음 대화 맥락과 플레이어의 입력을 바탕으로 자연스러운 해적 말투로 응답해주세요.\n맥락: " + context + "\n플레이어: " + playerInput;

        // LLM API 호출
        String llmResponse = restClient.post()
                .uri("/generate-dialogue")
                .body(new LLMRequest(prompt))
                .retrieve()
                .body(String.class);

        return llmResponse;
    }

    // LLM API 요청을 위한 DTO (예시)
    record LLMRequest(String prompt) {}

    // 일관성 체크 응답을 위한 DTO (예시)
    record ConsistencyCheckResponse(boolean suitable, String reason) {}
}

// Controller에서 PirateChatService를 사용하는 예시
@RestController
@RequestMapping("/pirate-game")
public class PirateGameController {

    private final PirateChatService pirateChatService;

    public PirateGameController(PirateChatService pirateChatService) {
        this.pirateChatService = pirateChatService;
    }

    @PostMapping("/check-dialogue-consistency")
    public ConsistencyCheckResponse checkDialogueConsistency(@RequestBody String text) {
        return pirateChatService.checkConsistency(text);
    }

    @PostMapping("/dialogue")
    public String getPirateDialogue(@RequestBody Map<String, String> request) {
        String context = request.get("context");
        String playerInput = request.get("playerInput");
        return pirateChatService.generatePirateDialogue(context, playerInput);
    }
}

예제 실행 방법

  1. Spring Boot 프로젝트를 생성하고 spring-boot-starter-web 의존성을 추가합니다.
  2. RestClient를 사용하기 위해 Spring Boot 3.2 이상을 사용합니다.
  3. PirateChatService와 PirateGameController 클래스를 위에 제시된 코드로 작성합니다.
  4. LLM API의 엔드포인트(http://llm-api.example.com)를 실제 사용하는 LLM의 API URL로 변경해야 합니다.
  5. Postman이나 cURL 등을 사용하여 http://localhost:8080/pirate-game/check-dialogue-consistency 또는 http://localhost:8080/pirate-game/dialogue 엔드포인트로 요청을 보내 테스트합니다.

바운디드 컨텍스트와 LLM

LLM은 특정 도메인에 특화된 바운디드 컨텍스트로 작동할 수 있습니다.
예를 들어, 경찰 수사 게임에서 LLM은 "증인 심문"이라는 바운디드 컨텍스트를 처리하며, 특정 프롬프트로 증인의 반응을 조절합니다.

사례: 경찰 심문 게임

플레이어가 증인을 심문해 정보를 얻는 게임을 가정해봅시다.
LLM을 활용하여 증인의 감정 상태(예: 위협 수준)에 따라 동적으로 응답하게 만들 수 있습니다.

@Service
public class PoliceInterrogationService {

    private final RestClient restClient; // LLM API 연동

    public PoliceInterrogationService(RestClient.Builder restClientBuilder) {
        this.restClient = restClientBuilder.baseUrl("http://llm-api.example.com").build(); // LLM API 엔드포인트
    }

    /**
     * 플레이어 입력이 증인에게 얼마나 위협적인지 평가합니다.
     * @param playerInput 플레이어의 입력
     * @return 위협 수준 (1-10)
     */
    public int evaluateThreatLevel(String playerInput) {
        String prompt = "다음 플레이어 입력이 증인에게 얼마나 위협적인지 1-10으로 평가해.\n입력: \"" + playerInput + "\"";

        String llmResponse = restClient.post()
                .uri("/evaluate-threat")
                .body(new LLMRequest(prompt))
                .retrieve()
                .body(String.class);

        // LLM 응답에서 숫자만 추출 (예시)
        try {
            return Integer.parseInt(llmResponse.trim());
        } catch (NumberFormatException e) {
            return 5; // 파싱 실패 시 기본값
        }
    }

    /**
     * 증인의 성격과 위협 수준에 따라 증인의 응답을 생성합니다.
     * @param witnessPersonality 증인의 성격 (예: "겁이 많고 비협조적인 증인")
     * @param threatLevel 위협 수준 (1-10)
     * @param playerInterrogation 플레이어의 심문 내용
     * @return 증인의 응답
     */
    public String generateWitnessResponse(String witnessPersonality, int threatLevel, String playerInterrogation) {
        String prompt = String.format(
                "당신은 %s입니다. 플레이어의 심문 내용: \"%s\". 현재 위협 수준은 %d입니다. 이 위협 수준에 맞춰 현실적인 증인의 반응을 생성해주세요.",
                witnessPersonality, playerInterrogation, threatLevel
        );

        String llmResponse = restClient.post()
                .uri("/generate-witness-response")
                .body(new LLMRequest(prompt))
                .retrieve()
                .body(String.class);

        return llmResponse;
    }

    // LLM API 요청을 위한 DTO (예시)
    record LLMRequest(String prompt) {}
}

@RestController
@RequestMapping("/police-game")
public class PoliceGameController {

    private final PoliceInterrogationService interrogationService;

    public PoliceGameController(PoliceInterrogationService interrogationService) {
        this.interrogationService = interrogationService;
    }

    @PostMapping("/interrogate-witness")
    public String interrogateWitness(@RequestBody Map<String, String> request) {
        String witnessPersonality = request.get("witnessPersonality");
        String playerInterrogation = request.get("playerInterrogation");

        int threatLevel = interrogationService.evaluateThreatLevel(playerInterrogation);
        return interrogationService.generateWitnessResponse(witnessPersonality, threatLevel, playerInterrogation);
    }
}

예제 실행 방법

  1. 위에서와 동일하게 Spring Boot 프로젝트를 설정합니다.
  2. PoliceInterrogationService와 PoliceGameController를 작성합니다.
  3. LLM API 엔드포인트(http://llm-api.example.com)를 실제 사용하는 LLM의 API URL로 변경해야 합니다.
  4. Postman이나 cURL 등을 사용하여 http://localhost:8080/police-game/interrogate-witness 엔드포인트로 요청을 보내 테스트합니다.

실용적인 적용: LLM 통합 전략

LLM을 소프트웨어에 통합하는 방법은 크게 세 가지로 나눌 수 있습니다.

1. 프롬프트 엔지니어링

  • 장점: 빠르고 간단하게 구현 가능합니다.
  • 단점: 복잡한 작업에서는 일관성이 부족할 수 있습니다.
  • 사례: 게임 내 대화, 간단한 챗봇.

Spring AI 프로젝트: Spring Boot 3.2부터 Spring AI 프로젝트를 통해 다양한 LLM(OpenAI, Hugging Face 등)을 편리하게 연동할 수 있습니다.
프롬프트 엔지니어링을 사용하여 챗봇을 구현하는 데 매우 유용합니다.

// Spring AI를 사용한 간단한 챗봇 서비스 (예시)
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class SimpleChatbotService {

    private final ChatClient chatClient;

    public SimpleChatbotService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    public String getChatResponse(String message) {
        // 프롬프트 엔지니어링을 통해 챗봇의 역할을 정의할 수 있습니다.
        // "system" 역할을 사용하여 챗봇의 페르소나를 설정합니다.
        String response = chatClient.prompt()
                .system("You are a helpful assistant.")
                .user(message)
                .call()
                .content();
        return response;
    }
}

// Controller에서 SimpleChatbotService를 사용하는 예시
@RestController
@RequestMapping("/chatbot")
public class ChatbotController {

    private final SimpleChatbotService chatbotService;

    public ChatbotController(SimpleChatbotService chatbotService) {
        this.chatbotService = chatbotService;
    }

    @PostMapping("/ask")
    public String askChatbot(@RequestBody String message) {
        return chatbotService.getChatResponse(message);
    }
}

예제 실행 방법

  1. Spring Boot 프로젝트에 spring-boot-starter-web과 spring-ai-openai-spring-boot-starter(OpenAI 사용 시) 또는 다른 LLM 관련 Spring AI 스타터를 추가합니다.
  2. application.properties에 OpenAI API 키 또는 사용하는 LLM의 API 키를 설정합니다.
  • 예: spring.ai.openai.api-key=YOUR_API_KEY
  1. SimpleChatbotService와 ChatbotController를 작성합니다.
  2. Postman이나 cURL 등을 사용하여 http://localhost:8080/chatbot/ask 엔드포인트로 요청을 보내 테스트합니다.

2. Fine-Tuning

  • 장점: 특정 도메인에 최적화된 성능을 제공합니다.
  • 단점: 데이터 준비와 학습 비용이 필요합니다.
  • 사례: 의도 분류기(Intent Classifier)로 사용자 입력을 정보 요청, 액션 요청 등으로 분류.
    • Spring AI의 Fine-Tuning: Spring AI는 Fine-Tuning 자체를 직접적으로 수행하는 기능은 제공하지 않지만, Fine-Tuned 된 모델을 로드하여 사용하는 데는 활용될 수 있습니다.
      실제 Fine-Tuning 과정은 LLM 제공사의 API나 Hugging Face Transformers와 같은 라이브러리를 통해 이루어집니다.
# Python을 사용한 의도 분류기 Fine-Tuning 예시 (Spring 내부에서 직접 실행되는 코드는 아님)
# 이 코드는 별도의 ML 파이프라인에서 실행되어 Fine-Tuned 모델을 생성합니다.
# Spring 애플리케이션은 이 Fine-Tuned 모델을 API를 통해 사용합니다.

from transformers import AutoModelForSequenceClassification, AutoTokenizer, Trainer, TrainingArguments
from datasets import Dataset # Hugging Face datasets 라이브러리 사용

# 1. 모델과 토크나이저 로드
model_name = "distilbert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=2) # 2: 정보 요청, 액션 요청

# 2. 학습 데이터 준비 (예시)
# 실제 데이터는 더 많고 다양해야 합니다.
raw_texts = ["프랑스의 수도는?", "런던 날씨 알려줘", "프랑스로 가는 비행기 예약해줘", "피자 주문해줘"]
raw_labels = [0, 0, 1, 1] # 0: 정보 요청, 1: 액션 요청

# Hugging Face Dataset 객체로 변환
data = {"text": raw_texts, "label": raw_labels}
dataset = Dataset.from_dict(data)

# 3. 데이터 토큰화
def tokenize_function(examples):
    return tokenizer(examples["text"], padding="max_length", truncation=True)

tokenized_dataset = dataset.map(tokenize_function, batched=True)

# 4. 학습 설정
training_args = TrainingArguments(
    output_dir="./results",
    num_train_epochs=3,
    per_device_train_batch_size=8,
    per_device_eval_batch_size=8,
    warmup_steps=500,
    weight_decay=0.01,
    logging_dir="./logs",
    logging_steps=10,
)

# 5. Trainer 생성 및 학습
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_dataset,
    tokenizer=tokenizer,
)

trainer.train()

# 학습된 모델 저장 (이 모델을 배포하여 Spring에서 API로 호출)
model.save_pretrained("./my_intent_classifier")
tokenizer.save_pretrained("./my_intent_classifier")

3. Retrieval-Augmented Generation (RAG)

  • 장점: 외부 데이터를 활용해 최신 정보 제공이 가능하며, 환각(hallucination) 현상을 줄일 수 있습니다.

  • 단점: 검색 및 데이터 관리의 복잡성이 따릅니다.

  • 사례: 검색 기반 챗봇, 문서 요약.

  • Spring AI의 RAG: Spring AI는 RAG 패턴을 쉽게 구현할 수 있도록 벡터 데이터베이스와의 통합을 지원합니다.
    문서를 임베딩하고 벡터 저장소에 저장한 후, 사용자 쿼리에 맞춰 관련 문서를 검색하여 LLM에 제공하는 방식입니다.

// Spring AI를 사용한 RAG 기반 챗봇 서비스 (예시)
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class RagChatbotService {

    private final ChatClient chatClient;
    private final VectorStore vectorStore; // 벡터 데이터베이스 (예: Pinecone, Milvus, Chroma)

    public RagChatbotService(ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
        this.chatClient = chatClientBuilder.build();
        this.vectorStore = vectorStore;
    }

    /**
     * 사용자 쿼리를 기반으로 관련 문서를 검색하고, 이를 바탕으로 LLM 응답을 생성합니다.
     * @param userQuery 사용자 쿼리
     * @return LLM의 응답
     */
    public String getRagResponse(String userQuery) {
        // 1. 사용자 쿼리와 유사한 문서 검색
        List<Document> relevantDocuments = vectorStore.similaritySearch(userQuery);

        // 2. 검색된 문서 내용을 프롬프트에 포함
        String documentContext = relevantDocuments.stream()
                .map(Document::getContent)
                .collect(Collectors.joining("\n\n"));

        String prompt = String.format(
                "다음 정보를 참고하여 질문에 답변해주세요. 만약 정보가 충분하지 않으면 모른다고 답하세요.\n\n정보:\n%s\n\n질문: %s",
                documentContext, userQuery
        );

        // 3. LLM 호출
        String response = chatClient.prompt()
                .system("You are a helpful assistant that answers questions based on provided information.")
                .user(prompt)
                .call()
                .content();

        return response;
    }

    /**
     * 문서들을 벡터 저장소에 추가하는 예시 (초기 데이터 로딩 시 사용)
     * @param documents 추가할 문서 리스트
     */
    public void addDocumentsToVectorStore(List<Document> documents) {
        vectorStore.add(documents);
    }
}

예제 실행 방법

  1. Spring Boot 프로젝트에 spring-boot-starter-web, spring-ai-openai-spring-boot-starter, 그리고 사용하는 벡터 데이터베이스의 Spring AI 스타터(spring-ai-chroma-store-spring-boot-starter 등)를 추가합니다.
  2. application.properties에 LLM API 키와 벡터 저장소 설정을 추가합니다.
  3. RagChatbotService를 작성합니다.
  4. 테스트를 위해 초기 문서를 벡터 저장소에 추가하는 로직을 구현합니다 (예: @PostConstruct를 사용하여 애플리케이션 시작 시 문서 로드).
  5. Controller를 만들어 getRagResponse를 호출하는 엔드포인트를 노출하고 테스트합니다.

DDD 커뮤니티에 제안하는 실험

LLM과 DDD를 결합한 실험을 통해 소프트웨어 개발의 미래를 탐구할 수 있습니다. 몇 가지 제안:

  • 유비쿼터스 언어 기반 프롬프트: 도메인별 언어를 정의해 LLM 프롬프트로 활용하고, 그 효과를 측정해보세요.

  • 바운디드 컨텍스트 특화 모델: 특정 바운디드 컨텍스트에 특화된 LLM을 Fine-Tuning하여 성능을 개선하는 실험을 진행해보세요.

  • 복잡한 도메인 모델링: LLM을 활용해 복잡한 도메인 로직을 자동화하거나, 도메인 이벤트 및 커맨드를 생성하는 데 활용하는 방안을 모색해보세요.

결론

LLM은 소프트웨어 개발의 미래를 재정의하고 있습니다.
DDD 커뮤니티는 이를 활용해 복잡한 도메인을 효과적으로 모델링하고 더 나은 소프트웨어를 구축할 기회를 갖습니다.
중요한 것은 지금 배우고 실험하는 것입니다.
2025년, LLM은 더 이상 신기한 기술이 아니라 일상적인 도구가 될 것입니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글