23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
우리팀은 최근 Python으로 구축된 에이닷 에이전트 워크플로우 엔진을 Golang으로 재구현했습니다.
GIL(Global Interpreter Lock) 제약과 동기식 웹 프레임워크의 한계를 벗어나 비동기 구조로 전환한 결과 처리량과 응답 시간에서 의미 있는 개선을 얻을 수 있었습니다.
이 글에서는 그 과정과 선택의 기술적 이유를 소개하려고 합니다.
실제 프로덕션 환경에서 Golang으로 재구축한 에이전트 워크플로우 엔진의 성능 측정 결과입니다.
Agent | 메트릭 | Python (as-was) | Golang (as-is) | 개선도 |
|---|---|---|---|---|
전화 에이전트 | TTFB avg | 1.38 | 0.72 | 48% ↓ |
TTLB avg | 1.76 | 1.37 | 22% ↓ | |
길찾기 에이전트 | TTFB avg | 2.90 | 1.44 | 50% ↓ |
TTLB avg | 4.01 | 2.56 | 36% ↓ | |
라디오 에이전트 | TTFB avg | 1.92 | 1.07 | 44% ↓ |
TTLB avg | 2.18 | 1.76 | 19% ↓ | |
지하철 혼잡도 에이전트 | TTFB avg | 2.41 | 1.11 | 54% ↓ |
TTLB avg | 3.82 | 2.72 | 29% ↓ | |
날씨 에이전트 | TTFB avg | 2.38 | 1.25 | 47% ↓ |
TTLB avg | 3.05 | 2.31 | 24% ↓ |
TTLB(Time To Last Byte)는 평균 26%, TTFB(Time To First Byte)는 평균 48% 단축되었습니다. 사용자가 실제로 체감하는 반응성이 개선되었습니다.
구분 | 개선도 |
|---|---|
운영 서버 대수 | 85% ↓ |
서버당 평균 CPU 사용률 | 45% ↓ |
Golang의 효율적인 리소스 사용 덕분에 운영 서버 대수를 85% 감축할 수 있었습니다.
그럼에도 서버당 CPU 사용률은 45% 감소했습니다.
LLM 기반 에이전트 워크플로우는 전통적인 웹 애플리케이션과 특성이 다릅니다.
단일 요청의 처리 시간이 수초에서 수십초
중간 결과를 스트리밍으로 클라이언트에 전달
여러 에이전트의 호출이 직/병렬로 연결
Python은 풍부한 생태계와 빠른 프로토타이핑으로 데이터 과학과 ML 개발에 탁월합니다.
하지만 우리 시스템은 모델 개발이 아닌 에이전트 오케스트레이션 서비스입니다.
Golang은 이런 워크로드에 근본적 장점을 가지고 있습니다.
가벼운 동시성 모델 - 고루틴은 런타임 관리 경량 스레드로 수천 개를 생성해도 오버헤드가 미미하고, 채널은 고루틴 간 안전하게 동기화 된 통신을 제공합니다.
명확한 제어 흐름 - 콜백이나 복잡한 비동기 추상화 없이 순차적 사고로 동시성 코드를 작성할 수 있습니다.
효율적인 실행과 메모리 - 컴파일된 바이너리와 효율적인 GC로 동일 리소스에서 더 많은 작업을 처리합니다.
LLM 기반 에이전트의 주요 특성은 긴 지연시간과 스트리밍 응답입니다.
사용자는 수초에서 수십초를 기다려야 하는데, 이때 중간 결과를 실시간으로 보여주는 것이 체감 속도를 크게 개선합니다.
Golang의 고루틴과 채널은 이런 요구사항을 자연스럽게 표현할 수 있는데요, 예외 처리 등은 생략하고, 핵심 흐름만 남긴 간단한 예제로 설명해보겠습니다.
먼저 에이전트 인터페이스를 정의합니다.
// Request 요청 구조체
type Request struct {
Message string `json:"message"`
}
// ResponseChunk 에이전트 응답 스트리밍 단위
type ResponseChunk struct {
Name string `json:"name"`
Content string `json:"content"`
}
// Agent 에이전트 인터페이스
type Agent interface {
Run(input Request) <-chan ResponseChunk
}다음은 이 인터페이스를 구현한 모의 에이전트입니다.
import (
"math/rand"
"time"
)
type mockAgent struct {
name string
}
func NewMockAgent() Agent {
return &mockAgent{
name: "Echo",
}
}
func (m *mockAgent) Run(input Request) <-chan ResponseChunk {
// 출력 채널 생성
out := make(chan ResponseChunk)
// 고루틴을 이용한 비동기 작업 수행
go func() {
defer close(out)
// 입력 문자열의 각 문자마다 랜덤 지연 후 출력 채널로 전송 (긴 지연시간, 중간 스트리밍 시뮬레이션)
for _, v := range input.Message {
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
out <- ResponseChunk{Name: m.name, Content: string(v)}
}
}()
return out
}Run 함수는 입력을 받아 즉시 채널을 반환합니다. 실제 작업은 백그라운드 고루틴에서 수행되고, 결과는 채널을 통해 스트리밍됩니다.
이것이 Golang의 동시성 철학입니다. "Don't communicate by sharing memory; share memory by communicating"
이제 이 채널을 SSE(Server-Sent Events)로 스트리밍하는 HTTP 핸들러를 보겠습니다. 우리는 Gin 프레임워크를 선택했습니다.
package main
import (
"github.com/gin-gonic/gin"
"io"
"net/http"
)
func main() {
r := gin.Default()
// POST /echo 엔드포인트
r.POST("/echo", func(c *gin.Context) {
var req Request
if err := c.ShouldBind(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
agent := NewMockAgent()
out := agent.Run(req) // 에이전트 실행하고 즉시 응답 채널 반환
sse(c, out) // 채널을 SSE 응답으로 스트리밍
return
})
r.Run()
}
func sse(c *gin.Context, ch <-chan ResponseChunk) bool {
c.Writer.Header().Set("Content-Type", "text/event-stream")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
if f, ok := c.Writer.(http.Flusher); ok {
f.Flush()
}
return c.Stream(func(w io.Writer) bool {
if chunk, ok := <-ch; ok {
// 채널에서 읽은 데이터를 SSE 이벤트로 전송
c.SSEvent("", chunk)
return true
} else {
// 채널이 닫히면 스트리밍 종료
return false
}
})
}해당 서버를 띄워서 curl 로 실행해보겠습니다.
➜ curl -N -H 'Content-Type: application/json' \
-d '{"message": "안녕하세요"}' \
'localhost:8080/echo'
data:{"name":"Echo","content":"안"}
data:{"name":"Echo","content":"녕"}
data:{"name":"Echo","content":"하"}
data:{"name":"Echo","content":"세"}
data:{"name":"Echo","content":"요"}
➜ Gin의 c.Stream()과 c.SSEvent()메서드는 SSE 프로토콜의 세부사항을 처리합니다.
코드는 단순하고 직관적입니다. Spring WebFlux 나 Python asyncio 같은 이벤트 기반 비동기 구조에서 볼 수 있는 Promise나 Operator 체인의 복잡한 추상화가 없습니다.
에이전트는 채널을 반환하고, 핸들러는 그 채널을 읽어 스트리밍합니다.
전통적인 절차적 코드 흐름처럼 읽히지만, 내부적으로는 완전한 비동기 처리를 수행합니다.
위의 에이전트는 모의 워크로드였습니다.
실제 LLM 기반 에이전트를 구현할 때는 어떨까요? 주요 LLM Provider들이 이미 완성도 높은 공식 Golang SDK를 제공하고 있어서 구현에 어려움이 없습니다.
SDK들을 이용해 각 에이전트들의 인터페이스를 구현해보겠습니다.
OpenAI Go API Library - https://github.com/openai/openai-go
type chatgptAgent struct {
name string
client openai.Client
}
func NewChatGptAgent() Agent {
apiKey := "..."
return &chatgptAgent{
name: "ChatGPT",
client: openai.NewClient(option.WithAPIKey(apiKey)),
}
}
func (c *chatgptAgent) Run(input Request) <-chan ResponseChunk {
out := make(chan ResponseChunk)
go func() {
defer close(out)
stream := c.client.Chat.Completions.NewStreaming(context.TODO(), openai.ChatCompletionNewParams{
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage(input.Message)},
Model: openai.ChatModelGPT4_1Mini,
})
for stream.Next() {
chunk := stream.Current()
if len(chunk.Choices) == 0 {
continue
}
contentChunk := chunk.Choices[0].Delta.Content
if contentChunk == "" {
continue
}
out <- ResponseChunk{
Name: c.name,
Content: contentChunk,
}
}
}()
return out
}NewStreaming() 메서드로 스트림 객체를 얻고, Next() 와 Current() 로 청크를 순회합니다.
이를 채널로 변환해 통일된 인터페이스를 제공합니다.
Anthropic Go API Library - https://github.com/anthropics/anthropic-sdk-go
type claudeAgent struct {
name string
client anthropic.Client
}
func NewClaudeAgent() Agent {
apiKey := "..."
return &claudeAgent{
name: "Claude",
client: anthropic.NewClient(option.WithAPIKey(apiKey)),
}
}
func (c *claudeAgent) Run(input Request) <-chan ResponseChunk {
out := make(chan ResponseChunk)
go func() {
defer close(out)
stream := c.client.Messages.NewStreaming(context.TODO(), anthropic.MessageNewParams{
MaxTokens: 1024,
Model: anthropic.ModelClaudeSonnet4_0,
Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock(input.Message))},
})
for stream.Next() {
event := stream.Current()
switch eventVariant := event.AsAny().(type) {
case anthropic.ContentBlockDeltaEvent:
switch deltaVariant := eventVariant.Delta.AsAny().(type) {
case anthropic.TextDelta:
if deltaVariant.Text == "" {
continue
}
out <- ResponseChunk{
Name: c.name,
Content: deltaVariant.Text,
}
}
}
}
}()
return out
}OpenAI SDK 와 동일하게 NewStreaming()과 Next() 패턴을 따르고 있습니다.
Google Gen AI Go SDK - https://github.com/googleapis/go-genai
type geminiAgent struct {
name string
client *genai.Client
}
func NewGeminiAgent() Agent {
apiKey := "..."
client, _ := genai.NewClient(context.TODO(), &genai.ClientConfig{
APIKey: apiKey,
Backend: genai.BackendGeminiAPI,
})
return &geminiAgent{
name: "Gemini",
client: client,
}
}
func (g *geminiAgent) Run(input Request) <-chan ResponseChunk {
out := make(chan ResponseChunk)
go func() {
defer close(out)
iter := g.client.Models.GenerateContentStream(context.TODO(), "gemini-2.5-flash-lite", genai.Text(input.Message), nil)
for chunk := range iter {
if len(chunk.Candidates) == 0 || len(chunk.Candidates[0].Content.Parts) == 0 {
continue
}
contentChunk := chunk.Candidates[0].Content.Parts[0]
if contentChunk.Text == "" {
continue
}
out <- ResponseChunk{
Name: g.name,
Content: contentChunk.Text,
}
}
}()
return out
}GenerateContentStream() 으로 이터레이터를 반환하며, range로 청크를 순회합니다.
세 SDK 모두 유사한 스트리밍 패턴을 제공합니다.
Langchain 등의 고수준 프레임워크에 의존하지 않고 간단하고 효율적으로 LLM 기반 비즈니스 로직을 작성할 수 있습니다.
앞서 소개한 내용들을 종합하여 실제로 동작하는 워크플로우 엔진을 고루틴과 채널만으로 만들어보겠습니다.
요구사항은 다음과 같습니다.
병렬 실행: 모든 에이전트를 동시에 실행해 지연시간을 최소화
순서 보장: 출력은 에이전트 정의 순서대로
스트리밍 최적화: 자기 차례가 오면 버퍼링 없이 즉시 스트리밍
func spawnAgents(input Request, agents []Agent) []<-chan ResponseChunk {
var chs []<-chan ResponseChunk
for _, a := range agents {
ch := a.Run(input) // 각 에이전트를 비동기로 시작
chs = append(chs, ch)
}
return chs
}모든 에이전트를 병렬로 실행합니다. 각 에이전트는 독립적인 고루틴에서 실행되고, 자신의 채널로 결과를 스트리밍합니다.
// FanInChunk 병합 채널용 스트리밍 구조체
type FanInChunk struct {
Order int // 에이전트 순서
Chunk ResponseChunk // 실제 데이터
Done bool // 완료 신호
}
func fanIn(chs []<-chan ResponseChunk) <-chan FanInChunk {
var wg sync.WaitGroup
wg.Add(len(chs))
out := make(chan FanInChunk)
for i, v := range chs {
// 각 채널을 동시에 읽기
go func(order int, ch <-chan ResponseChunk) {
for chunk := range ch {
// 읽은 데이터를 채널 인덱스와 함께 출력 채널로 전달
out <- FanInChunk{Order: order, Chunk: chunk, Done: false}
}
// 채널이 닫히면 완료 신호 전달
out <- FanInChunk{Order: order, Chunk: ResponseChunk{}, Done: true}
wg.Done()
}(i, v)
}
go func() {
wg.Wait()
close(out)
}()
return out
}여러 입력 채널을 하나의 출력 채널로 병합합니다.
각 채널마다 독립적인 고루틴이 할당되어 동시에 읽기를 수행하고, 각 청크에 원래 채널의 순서(Order)를 태그합니다.
오케스트레이터는 병합된 채널에서 무작위 순서로 도착하는 청크들을 받아, 에이전트의 원래 순서대로 재배열하여 출력합니다.
첫 번째 에이전트는 버퍼링 없이 즉시 스트리밍되고, 나머지는 자기 차례가 올 때까지 버퍼에 대기합니다.
func orchestrate(in <-chan FanInChunk) <-chan ResponseChunk {
out := make(chan ResponseChunk)
go func() {
defer close(out)
buf := make([][]ResponseChunk, 0) // 각 에이전트의 청크를 저장할 버퍼
cur := 0 // 현재 출력 대상 에이전트 인덱스
for chunk := range in {
// 0. 버퍼 확장
for len(buf) <= chunk.Order {
buf = append(buf, []ResponseChunk{})
}
// 1. 현재 순서가 아니면: 버퍼에 저장
if chunk.Order != cur {
if !chunk.Done {
buf[chunk.Order] = append(buf[chunk.Order], chunk.Chunk)
}
continue
}
// 2. 현재 순서: 버퍼 flush 후 처리
for _, buffered := range buf[chunk.Order] {
out <- buffered
}
buf[chunk.Order] = nil
if chunk.Done {
cur++
} else {
out <- chunk.Chunk
}
}
// 3. 남은 버퍼 flush
for cur < len(buf) {
for _, buffered := range buf[cur] {
out <- buffered
}
cur++
}
}()
return out
}병합된 채널에서 무작위로 도착하는 청크들을 원래 순서대로 재배열하는 로직이 단순한 버퍼 관리와 채널 읽기로 표현됩니다.
채널 자체가 thread-safe하기 때문에 복잡한 동기화 라이브러리나 락(lock)이 없습니다.
func main() {
r := gin.Default()
// 출력 순서는 아래 정의된 대로
agents := []Agent{
NewChatGptAgent(),
NewClaudeAgent(),
NewGeminiAgent(),
}
r.POST("/orchestrate", func(c *gin.Context) {
var req Request
if err := c.ShouldBind(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
chs := spawnAgents(req, agents) // 병렬 실행
merged := fanIn(chs) // 채널 병합
out := orchestrate(merged) // 순서 조율
sse(c, out)
return
})
r.Run()
}3줄의 함수 호출로 복잡한 다중 에이전트 오케스트레이션이 완성됩니다.
각 함수가 하나의 명확한 역할을 담당하고, 채널로 연결됩니다.
[Agent1] ───────┐
[Agent2] ───────┼──────→ [fanIn] ───────→ [orchestrate] ───────→ [SSE Stream]
[Agent3] ───────┘
(병렬 실행) (무작위) (순서 보장)실제 호출 예시입니다.
➜ curl -N -H 'Content-Type: application/json' \
-d '{"message": "What is the meaning of life?"}' \
'localhost:8080/orchestrate'
data:{"name":"ChatGPT","content":"The"} # ChatGPT 에이전트 응답
data:{"name":"ChatGPT","content":" meaning"}
data:{"name":"ChatGPT","content":" of"}
data:{"name":"ChatGPT","content":" life"}
data:{"name":"ChatGPT","content":" is"}
data:{"name":"ChatGPT","content":" a"}
data:{"name":"ChatGPT","content":" deeply"}
data:{"name":"ChatGPT","content":" personal"}
data:{"name":"ChatGPT","content":" and"}
data:{"name":"ChatGPT","content":" philosophical"}
data:{"name":"ChatGPT","content":" question"}
...
data:{"name":"Claude","content":"This"} # Claude 에이전트 응답
data:{"name":"Claude","content":" is one of humanity"}
data:{"name":"Claude","content":"'s oldest and most profound questions, an"}
data:{"name":"Claude","content":"d there's no single answer"}
...
data:{"name":"Gemini","content":"Ah"} # Gemini 에이전트 응답
data:{"name":"Gemini","content":", the age-old question! \"What is the meaning of life?\" It"}
data:{"name":"Gemini","content":"'s a question that has been pondered by philosophers, theologians, scientists, artists, and everyday people for millennia. And the truth is, there's no single, universally agreed-upon answer.\n\nHere's a breakdown of why it's"}
...
➜ 2025/10/13 14:54:44 orchestrator started
2025/10/13 14:54:44 ChatGPT agent started
2025/10/13 14:54:44 Claude agent started
2025/10/13 14:54:44 Gemini agent started
2025/10/13 14:54:46 ChatGPT agent ended (elapsed: 2.481247291s)
2025/10/13 14:54:47 Gemini agent ended (elapsed: 3.520516333s)
2025/10/13 14:54:50 Claude agent ended (elapsed: 6.467166166s)
2025/10/13 14:54:50 orchestrator ended (elapsed: 6.469606583s) # 가장 느린 에이전트 시간과 동일
[GIN] 2025/10/13 - 14:54:50 | 200 | 6.471028125s | ::1 | POST "/orchestrate"병렬로 실행되지만 출력은 순서대로, 첫 번째 에이전트(ChatGPT)의 응답은 버퍼링 없이 즉시 클라이언트로 스트리밍됩니다.
모든 에이전트가 백그라운드에서 병렬 실행되므로 전체 지연시간은 가장 느린 에이전트의 시간과 같습니다.
이 예제는 핵심 개념을 보여주기 위해 단순화한 것이지만, 실제 프로덕션 환경에서도 동일한 패턴을 적용했습니다.
고루틴과 채널을 중심으로 한 이러한 단순한 구조가, 앞서 본 것처럼 응답 시간 단축과 서버 대수 감축이라는 실질적인 성능 개선으로 이어졌습니다.
LLM 기반 에이전트 워크플로우는 밀리초가 아닌 초 단위 응답, 스트리밍, 그리고 병렬 처리가 핵심 특징입니다.
Golang의 고루틴과 채널은 이러한 요구사항에 자연스럽게 부합하며, 외부 메시지 큐 없이도 복잡한 오케스트레이션을 단순하게 구현할 수 있습니다.
구조의 단순함은 효율성을 높여 성능을 극대화합니다.
Python은 데이터 과학과 ML 개발에서 여전히 가장 강력한 선택지입니다.
그러나 에이전트 오케스트레이션처럼 프로덕션 환경에서 안정성과 병렬성이 중요한 워크로드에서는 Golang이 더 적합했습니다.
도구의 우열이 아니라, 문제에 가장 잘 맞는 도구를 선택하는 것이 핵심입니다.
비슷한 워크플로우 로직을 고민하고 있다면, 우리의 경험이 기술 선택에 작은 참고가 되기를 바랍니다.
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.