Semantic Kernel 기반으로한 Chat API를 구축해보자.
목표: OpenAI 모델을 활용해 사용자와 대화하는 Chat Completion API 구축
기술 스택
- .NET Aspire (.NET 8 기반 분산 애플리케이션 구성)
- Semantic Kernel (AI 모델 인터페이스)
- OpenAI (Azure Inference Endpoint 사용)
- NUnit + NSubstitute + Shouldly (단위 테스트)
- Postman / HTTP 클라이언트 테스트
- OpenAPI(Swagger) UI 포함
프로젝트 구조는 아래와 같다.
✅ ChatRequest, ChatMessage, ChatResponse, MessageRoleType (모델 정의)
먼저 채팅을 주고받기 위한 Request, Response를 정의하자.
namespace InterviewAssistant.Common.Models;
/// <summary>
/// 사용자로부터 받은 채팅 요청 전체를 표현하는 모델
/// </summary>
public class ChatRequest
{
/// <summary>
/// 사용자와 어시스턴트 간 주고받은 메시지 목록
/// 전체 대화 히스토리를 담는다.
/// </summary>
public List<ChatMessage> Messages { get; set; } = [];
}
/// <summary>
/// 한 줄의 메시지를 표현하는 클래스
/// 역할(role)과 실제 메시지 텍스트를 포함한다.
/// </summary>
public class ChatMessage
{
/// <summary>
/// 메시지를 보낸 주체의 역할
/// (System, User, Assistant, Tool 등)
/// </summary>
public MessageRoleType Role { get; set; } = MessageRoleType.None;
/// <summary>
/// 메시지의 실제 텍스트 내용
/// </summary>
public string Message { get; set; } = string.Empty;
}
/// <summary>
/// 역할(Role)을 명확하게 열거형으로 구분한 Enum
/// </summary>
public enum MessageRoleType
{
None,
System,
User,
Assistant,
Tool // 추후 기능 확장 시 사용할 수 있음
}
/// <summary>
/// 어시스턴트가 반환하는 응답 메시지 모델
/// </summary>
public class ChatResponse
{
/// <summary>
/// 사용자에게 전달될 응답 메시지
/// </summary>
public string Message { get; set; } = string.Empty;
}
✅ IKernelService, KernelService (핵심 AI 처리 서비스)
다음은 SemanticKernel을 이용하기 위하여 IKernelService, KernelService 을 정의해보자.
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
namespace InterviewAssistant.ApiService.Services;
/// <summary>
/// ChatCompletion 호출을 추상화한 서비스 인터페이스
/// (테스트 및 확장성을 위한 추상화)
/// </summary>
public interface IKernelService
{
/// <summary>
/// 주어진 메시지 목록을 기반으로 스트리밍 방식 응답 반환
/// </summary>
IAsyncEnumerable<string> CompleteChatStreamingAsync(IEnumerable<ChatMessageContent> messages);
}
/// <summary>
/// Semantic Kernel과 실제 OpenAI 모델을 연결하는 핵심 서비스 구현체
/// </summary>
public class KernelService(Kernel kernel, IConfiguration config) : IKernelService
{
public async IAsyncEnumerable<string> CompleteChatStreamingAsync(IEnumerable<ChatMessageContent> messages)
{
// Semantic Kernel의 채팅 히스토리 객체 생성
var history = new ChatHistory();
history.AddRange(messages); // 전달받은 메시지를 히스토리에 추가
// 구성 정보에 따라 OpenAI 채팅 서비스 인스턴스 가져오기
var service = kernel.GetRequiredService<IChatCompletionService>(config["SemanticKernel:ServiceId"]!);
// 스트리밍 응답 받아오기
var result = service.GetStreamingChatMessageContentsAsync(chatHistory: history, kernel: kernel);
await foreach (var text in result)
{
yield return text.ToString(); // string으로 변환하여 반환
}
}
}
✅ 왜 IKernelService 인터페이스로 분리했을까?
public interface IKernelService
{
IAsyncEnumerable<string> CompleteChatStreamingAsync(IEnumerable<ChatMessageContent> messages);
}
✔️ 이유 1: 테스트 가능성(Testability)
- 인터페이스로 분리하면 단위 테스트에서 쉽게 mocking 가능
- 예: NSubstitute.Substitute.For<IKernelService>() 사용 가능
- 실제 LLM 호출 없이 로직 검증 가능
✔️ 이유 2: 확장성 및 유연성
- Semantic Kernel 외에 다른 LLM(OpenAI 직접 호출, HuggingFace 등)으로 바꿔도 구현체만 갈아끼우면 됨
- 인터페이스를 기준으로 앱이 작동하므로, 변화에 강함
✅ KernelService 구현 클래스는 어떤 역할?
public class KernelService(Kernel kernel, IConfiguration config) : IKernelService
이 클래스는 Semantic Kernel의 API를 직접 다루는 실제 구현체다.
아래 작업을 담당한다:
1. ChatHistory 구성
var history = new ChatHistory();
history.AddRange(messages);
- ChatMessageContent 리스트를 기반으로 채팅 히스토리를 생성
- 문맥(Context)을 유지하기 위해 전체 메시지를 추가
2. IChatCompletionService 인스턴스 가져오기
var service = kernel.GetRequiredService<IChatCompletionService>(config["SemanticKernel:ServiceId"]!);
- Semantic Kernel 내에서 등록된 OpenAI/Azure AI 모델을 식별자로 조회
- ServiceId를 통해 다중 모델 중 하나를 선택 가능
3. 스트리밍 응답 처리
var result = service.GetStreamingChatMessageContentsAsync(chatHistory: history, kernel: kernel);
await foreach (var text in result)
{
yield return text.ToString();
}
- 메시지를 기반으로 AI가 생성하는 응답을 실시간으로 받아 yield return으로 하나씩 스트리밍
- 이 방식은 빠른 사용자 응답, 자연스러운 UX를 가능하게 함
✅ ChatCompletionDelegate (API 엔드포인트 핵심 처리)
이제 구성한 SemanticKernel을 이용하여 챗봇기능을 할 수 있는 API 엔드포인트 기능을 구현해보자.
using InterviewAssistant.Common.Models;
using InterviewAssistant.ApiService.Services;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.AspNetCore.Mvc;
using ChatMessageContent = Microsoft.SemanticKernel.ChatMessageContent;
namespace InterviewAssistant.ApiService.Delegates;
/// <summary>
/// 채팅 요청을 처리하는 API 델리게이트 클래스
/// 실제 엔드포인트에서 호출되는 비즈니스 로직을 포함
/// </summary>
public static partial class ChatCompletionDelegate
{
/// <summary>
/// 채팅 요청을 받아서 Semantic Kernel에 메시지를 전달하고
/// 스트리밍 형태로 응답을 반환한다.
/// </summary>
/// <param name="req">사용자의 전체 채팅 메시지 요청</param>
/// <param name="service">핵심 처리 서비스 (DI로 주입)</param>
/// <returns>스트리밍 응답 (ChatResponse 형태)</returns>
public static async IAsyncEnumerable<ChatResponse> PostChatCompletionAsync([FromBody] ChatRequest req, IKernelService service)
{
var messages = new List<ChatMessageContent>();
// 요청받은 메시지를 Semantic Kernel 형식으로 변환
foreach (var msg in req.Messages)
{
ChatMessageContent message = msg.Role switch
{
MessageRoleType.User => new ChatMessageContent(AuthorRole.User, msg.Message),
MessageRoleType.Assistant => new ChatMessageContent(AuthorRole.Assistant, msg.Message),
MessageRoleType.System => new ChatMessageContent(AuthorRole.System, msg.Message),
MessageRoleType.Tool => new ChatMessageContent(AuthorRole.Tool, msg.Message),
_ => throw new ArgumentException($"Invalid role: {msg.Role}")
};
messages.Add(message);
}
// KernelService를 통해 AI 스트리밍 응답을 받음
var result = service.CompleteChatStreamingAsync(messages);
// 받은 응답을 ChatResponse 형태로 가공하여 스트리밍 반환
await foreach (var text in result)
{
yield return new ChatResponse { Message = text };
}
}
}
✅ 왜 static partial class로 만들었을까?
public static partial class ChatCompletionDelegate
✔️ 이유 1: 엔드포인트별 책임 분리
- PostChatCompletionAsync, PostInterviewDataAsync 등 기능별 메서드를 같은 클래스에 분리해서 관리하기 위함
- partial 키워드로 파일을 기능별로 나눠도 컴파일 시 하나의 클래스처럼 동작함 → 가독성 향상
✔️ 이유 2: 불필요한 인스턴스화 방지
- 상태를 가지지 않으므로 인스턴스를 만들 필요 없음 → static으로 선언해 효율적 메모리 사용
✅ 메서드 설명: PostChatCompletionAsync
public static async IAsyncEnumerable<ChatResponse> PostChatCompletionAsync(
[FromBody] ChatRequest req,
IKernelService service)
- HTTP POST /api/chat/complete 요청을 처리하는 진입점
- 클라이언트로부터 받은 ChatRequest의 메시지들을 Semantic Kernel이 이해할 수 있는 구조로 바꿔 처리하고,
- 생성된 응답을 IAsyncEnumerable<ChatResponse> 형태로 스트리밍 반환
✅ 처리 과정 단계별 설명
1. 역할(Role)에 따라 메시지를 변환
ChatMessageContent message = msg.Role switch
{
MessageRoleType.User => new ChatMessageContent(AuthorRole.User, msg.Message),
...
};
- 클라이언트에서 넘긴 MessageRoleType을 Semantic Kernel의 AuthorRole로 변환
- enum 매핑을 통해 잘못된 역할이 들어올 경우 예외 처리 (_ => throw ...)
🔍 이렇게 하면 Semantic Kernel이 메시지의 작성자 주체를 인식할 수 있음
예: System → 시스템 지침, User → 사용자 입력, Assistant → 모델 응답
2. KernelService 호출
var result = service.CompleteChatStreamingAsync(messages);
- 변환된 메시지 리스트를 KernelService에 전달
- 이 시점부터는 모델이 문맥(Context)을 인식하고 응답 생성
3. 결과를 스트리밍 형태로 변환해 반환
await foreach (var text in result)
{
yield return new ChatResponse { Message = text };
}
- 응답을 하나씩 받아서 ChatResponse 형태로 감싸서 반환
- yield return을 이용한 스트리밍 처리로 사용자에게 빠르게 출력 가능
✅ 왜 IAsyncEnumerable을 썼을까?
- 일반 List<ChatResponse> 반환은 모든 응답이 끝나야 출력됨
- IAsyncEnumerable<ChatResponse>는 생성되는 즉시 하나씩 반환 → UX 개선
- 클라이언트에서도 부분적으로 응답을 받을 수 있음 (Postman, JS Stream API 등에서 체감 가능)
✅ ChatCompletionEndpoint(엔드포인트 연결)
챗봇 응답 기능을 구현할 수 있는 비즈니스 로직을 완성했으니. 이걸 엔드포인트에 연결해보자.
using InterviewAssistant.ApiService.Delegates; // 실제 API 처리 로직이 있는 delegate 클래스
using InterviewAssistant.Common.Models; // Request, Response 모델 참조
namespace InterviewAssistant.ApiService.Endpoints;
/// <summary>
/// 이 클래스는 ChatCompletion 관련 API 엔드포인트를 등록하는 역할을 한다.
/// ASP.NET Core의 Endpoint Routing 기능을 사용해 API 경로를 정의한다.
/// </summary>
public static class ChatCompletionEndpoint
{
/// <summary>
/// Chat Completion 관련 엔드포인트들을 정의하고, 라우터에 매핑한다.
/// </summary>
/// <param name="routeBuilder">ASP.NET의 라우팅 빌더 객체</param>
/// <returns>라우팅이 추가된 routeBuilder 인스턴스 반환</returns>
public static IEndpointRouteBuilder MapChatCompletionEndpoint(this IEndpointRouteBuilder routeBuilder)
{
// [api/chat] 라우트 그룹 생성 (공통 prefix 적용)
var api = routeBuilder.MapGroup("api/chat")
.WithTags("Chat"); // Swagger UI 상에서 태그 이름으로 표시됨
// [POST /api/chat/complete] → ChatCompletionDelegate.PostChatCompletionAsync 호출
api.MapPost("complete", ChatCompletionDelegate.PostChatCompletionAsync)
.Accepts<ChatRequest>(contentType: "application/json") // 입력: ChatRequest JSON 형식
.Produces<IEnumerable<ChatResponse>>(statusCode: StatusCodes.Status200OK, contentType: "application/json") // 출력: List 형태의 ChatResponse
.WithName("PostChatCompletion") // Swagger에서 식별될 이름
.WithOpenApi(); // OpenAPI 문서 자동 생성
// 최종적으로 구성된 routeBuilder 반환
return routeBuilder;
}
}
✅ Program.cs (API 서비스 진입점)
이제 ApiService 의 Program.cs 를 수정하여 API 서비스 진입점을 구현해보자.
using InterviewAssistant.ApiService.Endpoints;
using InterviewAssistant.ApiService.Services;
using Microsoft.SemanticKernel;
using OpenAI;
using System.Text.Json;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
// 기본 서비스 및 예외 처리 설정
builder.AddServiceDefaults();
builder.Services.AddProblemDetails();
// 핵심 서비스 DI 등록
builder.Services.AddScoped<IKernelService, KernelService>();
// Swagger(OpenAPI) UI 및 Semantic Kernel 모델 등록
builder.Services.AddOpenApi();
builder.Services.AddAIModelService();
builder.AddAzureOpenAIClient("openai");
// Singleton Kernel 객체 생성 및 주입
builder.Services.AddSingleton<Kernel>(sp =>
{
var config = builder.Configuration;
var openAIClient = sp.GetRequiredService<OpenAIClient>();
return Kernel.CreateBuilder()
.AddOpenAIChatCompletion(
modelId: config["GitHub:Models:ModelId"]!,
openAIClient: openAIClient,
serviceId: "github")
.Build();
});
// JSON 직렬화 옵션 설정
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.WriteIndented = true;
options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // Swagger UI 노출
}
app.UseHttpsRedirection();
app.UseExceptionHandler();
app.MapChatCompletionEndpoint(); // 채팅 API 매핑
app.MapDefaultEndpoints(); // Aspire 기본 헬스체크 등 매핑
app.Run();
✅ AppHost/Program.cs (Aspire Host 구성)
var builder = DistributedApplication.CreateBuilder(args);
var openai = builder.AddConnectionString("openai");
var config = builder.Configuration;
// API 서비스 구성
var apiService = builder.AddProject<Projects.InterviewAssistant_ApiService>("apiservice")
.WithReference(openai)
.WithEnvironment("SemanticKernel__ServiceId", config["SemanticKernel:ServiceId"]!)
.WithEnvironment("GitHub__Models__ModelId", config["GitHub:Models:ModelId"]!);
// 프론트엔드 구성 및 종속성 연결
builder.AddProject<Projects.InterviewAssistant_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(apiService)
.WaitFor(apiService);
builder.Build().Run();
✅ AppHost/appsettings.json (환경 변수 설정)
일단 Azure OpenAI를 쓰기 전에 github models를 써서 개발을 진행할 예정.(비용, 속도문제)
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
},
"SemanticKernel": {
"ServiceId": "github"
},
"GitHub": {
"Models": {
"ModelId": "gpt-4o",
"Endpoint": "https://models.inference.ai.azure.com"
}
}
}
✅ Postman으로 테스트
dotnet user-secrets --project ./InterviewAssistant.AppHost set "ConnectionStrings:openai" "Endpoint=https://models.inference.ai.azure.com;Key={{GITHUB_TOKEN}}"
테스트 하기전에 위의 명령어로 깃허브 모델을 이용하기 위한 토큰을 저장해주자. (토큰은 깃허브에서 발급가능)
위와 같이 요청을 보내면
이렇게 스트리밍 방식으로 ai의 응답이 오는걸 확인할 수 있다.
🧾 총평
이번 프로젝트에서는 .NET Aspire 기반의 모듈형 아키텍처 위에 Semantic Kernel과 OpenAI를 결합하여
간단한 AI 채팅 API를 직접 구현해보았다.
아래는 이번 작업을 통해 느낀 점들이다.
✅ 1. Semantic Kernel + Aspire 조합의 강력함
- Semantic Kernel은 OpenAI 기반의 AI 모델을 추상화하여 사용할 수 있게 해 주고,
- Aspire는 모듈 단위로 서비스와 인프라를 관리할 수 있어 AI API 구성에 최적화된 환경이었다.
- 특히 Service ID, Model ID를 통한 유연한 모델 구성은 추후 모델 교체/확장에도 매우 유리하다는 걸 체감했다.
✅ 2. 설계를 잘하면 테스트가 쉬워진다
- 핵심 로직을 IKernelService 인터페이스로 분리한 덕분에 테스트 코드 작성이 수월했고,
- IAsyncEnumerable를 활용한 스트리밍 처리도 실제 유저 입장에서 빠른 응답을 가능하게 해주었다.
- NUnit + NSubstitute + Shouldly 조합은 직관적인 테스트 작성에 많은 도움이 되었고,
- TestCase를 활용해 다양한 시나리오를 간결하게 커버할 수 있었다.
✅ 3. 작은 구조적 선택이 전체의 유연함을 만든다
- 메시지 역할(Role)을 enum으로 명확히 정의한 덕분에 데이터 구조가 명확해졌다.
- API의 응답/요청 형식을 모델로 캡슐화함으로써 프론트와의 연동도 깔끔하게 이어졌다.
- 단순한 채팅 API였지만, 애초에 확장성과 재사용성을 고려한 설계가 전체 프로젝트를 훨씬 깔끔하게 만들었다.
간단한 작업이지만 단순히 API를 호출하는 것을 넘어서, 구조적으로 잘 분리된 설계, 모듈화된 서비스 구성, 유연한 테스트 환경을 만들 수 있었던 경험이었다.
앞으로 이 구조를 바탕으로 기능을 확장하거나 다른 AI 모델로 연결할 수도 있을 것 같고,
.NET Aspire + Semantic Kernel 조합이 생각보다 훨씬 실용적이라는 걸 체감할 수 있었다.
다음으로는 파일을 올려 분석하고 그에 맞는 기능을 수행하게 프롬프트 튜닝도 진행해볼 예정이다.
'전공수업' 카테고리의 다른 글
[종합설계프로젝트1] Semantic Kernel 이란? (0) | 2025.03.21 |
---|---|
[자료구조][C언어] Prim's MST algorithm (프림 알고리즘) (1) | 2024.06.09 |