우리 서비스에서는 하루 10,000건 이상의 채팅 요청이 발생하고 있다.
아래 그래프는 채팅 요청만을 기준으로 한 수치이지만, 실제로는 모델을 채팅 생성뿐만 아니라 이미지 생성, 스토리 생성 등 다양한 AI Content Creation 영역에 활용하고 있기 때문에 전체 요청량은 이보다 훨씬 많다.

이처럼 요청 형태와 목적이 서로 다른 다양한 AI 작업을 처리하기 위해, 우리는 모든 AI Content Creation 요청을 단일 API 엔드포인트로 수집하는 구조를 선택했다.
엔드포인트는 하나지만, 내부적으로는 요청의 종류와 맥락에 따라 서로 다른 처리 흐름을 타도록 설계되어 있다.
이 글에서는 우리가 실제 서비스 환경에서 다양한 AI Content Creation 요청들을 어떻게 하나의 엔드포인트에서 안정적으로 분기/처리하고 있는지, 그리고 이 구조를 선택하게 된 배경과 그 과정에서 고려했던 설계 포인트들을 정리해보려 한다.
모든 AI 콘텐츠 생성 요청의 진입점 : Pipeline
아래 그림과 같이 모든 요청을 /api/v1/pipeline 에서 받는다.

요청 스키마는 아래와 같다.
pipeline_configuration에는 리스트 형태로 요청의 Type을 명시한다.- 리스트로 받은 이유는 한 번에 여러 개의 모델 요청을 할 수 있도록 위함이다.
- 또한, 이렇게 하면 한 기능에 여러 Configuration이 추가될 때 기능을 쉽게 붙일 수 있다.
- 확장성과 유연성을 염두에 둔 설계이다.
pipeline_input에는 각 요청에 대한 Request Data를 담는다.pipeline_configuration의 모든 요소가pipeline_input의 Key가 되어야 하며, 이는 Service 진입점에서 검증한다.
class PipelineRequestDto(BaseModel):
pipeline_configuration: list[str]
pipeline_input: dict[str, dict[str, Any]]아래는 Pipeline 요청의 여러 예시이다.
[채팅 요청과 메모리 기능을 함께 사용하는 예시]
{
"pipeline_configuration": [
"inference",
"memory_insertion"
],
"pipeline_input": {
"inference": {
"chatroom_id": "68d6457e228747c1580f0a9d",
"message": "Hi there!",
// (생략)...
},
"memory_insertion": {
"chatroom_id": "68d6457e228747c1580f0a9d"
}
}
}[유저 채팅 생성 요청 예시]
{
"pipeline_configuration": [
"user_dialogue_gen"
],
"pipeline_input": {
"user_dialogue_gen": {
"chatroom_id": "68d6457e228747c1580f0a9d"
}
}
}[캐릭터 이미지 생성 요청 예시]
{
"pipeline_configuration": [
"image"
],
"pipeline_input": {
"image": {
"chatroom_id": "68d6457e228747c1580f0a9d",
"prompt": "with seductive smile"
}
}
}Service 계층에서는 가장 먼저 Pipeline Request에 대한 검증(validation)을 수행한다. 각 요청이 참조하는 Pipeline Configuration에 맞게 Pipeline Input 데이터가 올바르게 구성되어 있는지를 이 단계에서 확인한다.
요청 진입 지점에서 필드 단위의 검증을 최대한 엄격하게 수행함으로써, 잘못된 Request 형식으로 인해 런타임 중 발생할 수 있는 참조 오류나 예외 상황을 사전에 차단할 수 있다.
또한 요청은 단순한 스키마 검증을 넘어, 정책(policy)에 따라 필드 구성이 달라지는 케이스도 존재한다. 우리는 이러한 조건부 규칙까지 Service 계층에서 함께 검증함으로써, 이후 파이프라인 내부 로직에서 불필요한 분기 처리나 방어 코드가 늘어나는 것을 최소화하고자 했다.
예를 들어, 다음과 같은 정책 기반 검증이 있다.
- Group Chat 요청의 경우, 항상
request_character_id필드가 존재해야 한다. - 채팅을 Regenerate 한 요청이라면, 반드시
regenerated_chat_group_id가 포함되어야 한다.
이처럼 요청의 맥락에 따라 필수 필드가 달라지는 경우까지 초기에 검증함으로써, 실제 오류 케이스를 크게 줄일 수 있었다.
요청 데이터 검증이 완료되면, 다음 단계로 LLM 전반의 실행 흐름을 추적하고 분석하기 위한 Context를 초기화한다.
이를 위해 우리는 LLM Observability 도구인 Langfuse 를 사용하고 있다.
Langfuse는 LLM 호출 전반을 Span 단위로 추적 할 수 있게 해주며, 각 요청에 대해 다음과 같은 정보들을 자동으로 수집·기록한다.
- 요청 및 응답에 사용된 토큰 수
- 모델별 통계
- 프롬프트 및 결과 히스토리
- 에러 발생 여부 및 위치
Service 계층에서는 검증이 끝난 시점에 Langfuse Context를 초기화하고, 이후 Inference 과정에서 발생하는 모든 LLM 호출을 하나의 Span 아래에서 묶어 로깅한다.
이 구조를 통해 우리는 어떤 모델에 대한 요청이 많은지, 어떤 파이프라인이 병목이 되는지, 특정 정책이나 요청 타입이 품질 저하를 유발하는지까지 정량적으로 분석할 수 있게 되었다.
아래와 같이 Langfuse Dashboard에서 Inference Request Session에 대한 매우 자세한 정보를 볼 수 있다.

Langfuse Context가 마련되었으면, 이제 실제 Inference 요청 함수를 호출한다.
Inference Response가 모두 끝나면 Streaming이 끝나게 된다.
Streaming이 끝나면 백그라운드에서 현재 채팅 맥락을 고려하여 기억(Memory)을 만들어 Vector Database에 저장한다.
이는 후에 RAG에 사용되어 채팅의 품질을 올리는 역할을 한다.
중요한 점은 백그라운드에서 작업이 진행되기 때문에 API 응답 속도에는 영향을 주지 않도록 하였다.
아래는 Service 계층에서 호출되는 Pipeline 요청의 전체 플로우 차트이다.

Pipeline 요청 처리의 공통 로직은 모두 BasicService에 구현되어 있으며,
실제 요청의 성격에 따라 BasicService를 상속받은 개별 Service들이 이를 확장하는 구조로 설계했다.
이러한 구조를 선택한 이유는 다음과 같다.
- Pipeline 요청 처리 흐름을 하나의 추상화된 인터페이스로 통일
- 요청 타입별로 달라지는 로직만 하위 클래스에서 구현
- 공통 코드의 중복을 최소화하면서, Pipeline 진입 지점을 중앙화
즉, 모든 Pipeline 요청은 동일한 흐름을 따르되, 어떤 콘텐츠를 생성하느냐에 따라 내부 구현만 달라지는 구조다.
현재 우리는 다음과 같은 Service 계층 구성을 사용하고 있다.
- 이미지 생성 요청 →
ImageService - 채팅 요청 →
InferenceService - 스토리 생성 요청 →
StoryflowService - … 등등
이들 모든 Service는 공통적으로 BasicService를 상속받아 구현되었으며,
외부에서는 동일한 인터페이스로 Pipeline 요청을 호출할 수 있도록 구성되어 있다.
따라서 새로운 AI 콘텐츠 타입이 추가되더라도 BasicService를 상속받은 Service 하나만 추가하면 된다.
Pipeline 요청 흐름이 분산되지 않고 한 곳에서 관리되며, 검증, 로깅, 에러 처리, 정책 적용 같은 공통 관심사가 자연스럽게 재사용되는 것을 의도하였다.
가장 먼저 파이프라인이 실행되면 가장 먼저 실행에 필요한 데이터들을 구성한다.
이 단계에서 만들어진 데이터로 후에 참조될 실행 컨텍스트 전체를 구성한다.
주요 구성 요소는 다음과 같다.
- 채팅방 정보
- 기존 채팅 히스토리
- 유저 정보
- Inference를 구분하기 위한 고유 식별자
- Redis에 저장되는 Inference 초기 상태
- Redis에 저장된 Inference 상태를 활용해 Inference Stop을 구현하였다. (채팅이 나오는 중 Stop 버튼 클릭 -> 스트리밍 강제로 멈추기)
데이터 준비가 끝난 후에는 Langfuse Context를 초기화한다.
이 시점부터의 모든 LLM 관련 작업은 하나의 Span으로 묶여 기록된다.
- 어떤 요청이었는지
- 어떤 모델이 호출되었는지
- 토큰은 얼마나 사용되었는지
- 어느 단계에서 지연이 발생했는지
이 Context는 이후 Pipeline 내부에서 호출되는 모든 LLM 실행에 자동으로 전파된다.
Service 계층에서 Context를 한 번만 설정해두면 Pipeline 내부에서는 별도의 로깅 코드 없이도 전체 추론 흐름을 관측할 수 있다.
Langfuse Context가 준비되면, 본격적으로 Pipeline 실행을 위한 준비 단계에 들어간다.
우선 Agent 설정을 먼저 진행하는데, Pipeline 내부에서 실제 LLM Call이 이루어지기 전 이 요청을 어떤 방식으로 추론할 것인가를 결정한다.
이 구조에서는 LLM Call 자체는 최대한 단순하게 유지하고, 모델 선택, 메모리 구성, 프롬프트 정책과 같은 복잡한 결정 로직은 모두 Agent 계층으로 분리했다.
따라서 LLM Call 로직은 주입된 설정대로 실행만 하면 되고, 정책 변경이나 모델 교체가 발생하더라도 LLM Call 코드를 수정하지 않아도 된다.
우리가 정의한 Agent는 크게 다음 세 가지로 나뉜다.
- Memory Agent
- LLM Agent
- System Message Builder
1. Memory Agent
사용한 임베딩 모델과 Vector Database 관련 설정을 준비한다.
2. LLM Agent
어떤 모델을 사용할지 준비한다.
요청 Type이나 정책에 따라 모델을 강제하는 경우도 있다.
- 이미지 요청의 경우에는 이미지 모델이 설정되어야 함
- 사용자의 Subscription Plan에 따라 모델이 달리 설정될 수 있음
- Free Model 같은 경우에는 강제 지연이 추가됨
3. System Message Builder
요청 Type이나 정책에 따라 다른 프롬프트를 구성해야 한다.
같은 요청 Type이더라도 요청 정책에 따라 프롬프트가 달라진다.
Agent 설정이 완료되면, 이를 바탕으로 Pipeline 객체를 생성한다.
이 Pipeline 객체는 단순한 실행 함수가 아니라,
- Agent 구성
- 실행 순서
- 응답 처리 방식
을 모두 포함한 하나의 실행 단위다.
Pipeline 객체가 생성되면, pipeline.execute()를 실행한다.
이 과정에서 실제 모델 호출이 발생하며, 이에 따라 서비스 내부적으로는 Coin 소모 로직이 함께 실행된다.
- LLM 호출 시작 시 Coin 차감
- 오류 발생 시 보상 로직 실행
- 중단된 추론에 대한 정합성 보장
Pipeline 실행은 비즈니스 로직과 강하게 결합된 단계이기 때문에, Pipeline 내부에서도 최대한 비즈니스 로직은 다른 계층에서 묶어서 처리해서 메인 흐름에서는 비즈니스 로직 처리를 하지 않도록 하였다.
LLM이 스트리밍 방식으로 응답을 반환할 경우, 각 Chunk마다 Response Handler가 실행된다.
이 단계에서는 다음과 같은 작업이 이루어진다.
- Inference 중단 여부 확인
- JSON 포맷 유지 여부 검증
- 응답 타입에 맞게 데이터 구조화
따라서 오류가 있을 수 있는 LLM Response를 정확한 형태로 정제하여 클라이언트에게 반환한다.
LLM Streaming API Call이 끝나면, 한 Pipeline이 종료된다.
Pipeline이 종료되면 Redis 상태 정리, Langfuse Span 종료 등 Context를 정리하고, Client도 Streaming이 끝나게 된다.
유지보수성을 높이기 위한 추가 설계 포인트
우리는 여러 모델을 다양하게 바꾸어가며 여러 실험을 했기 때문에 변경에 강한 설계가 필요했다.
그래서 실험을 쉽게 하는 것에 집중하고, 나머지 요소는 최대한 신경을 안 쓸 수 있도록 구성하였다.
아래는 실제 운영 과정에서 유지보수성을 크게 높여주었던 추가적인 기타 설계 포인트들이다.
1. 모델을 Enum + DB 기반으로 관리하기
우리는 LLM 모델을 코드 상에서 직접 문자열로 관리하지 않고, Enum 기반의 추상화 레이어를 두어 관리하고 있다.
각 Enum 값은 실제 모델 이름과 매핑되며, 이 매핑 정보는 데이터베이스에 저장된다.
아래는 Model Enum과 모델의 Mapping 예시이다.
LLMModel.SMALL -> "meta-llama/llama-3.1-8b-instruct"
LLMModel.MEDIUM -> "sao10k/l3-lunaris-8b"
LLMModel.LARGE -> "deepseek/deepseek-v3.2"코드에서는 항상 LLMModel Enum만 참조 실제 어떤 모델을 쓰는지는 데이터베이스에서 결정한다.
모델 변경 시 코드 수정이나 재배포가 필요 없다는 것이 가장 큰 장점이다.
다만, 매 요청마다 데이터베이스에서 모델 정보를 조회하는 것은 불필요한 리소스 낭비로 이어질 수 있다.
이 문제를 해결하기 위해 우리는 주기적으로 서버와 데이터베이스 사이 Sync를 맞추는 방식을 사용했다.
애플리케이션 내부 캐시에 모델 매핑 정보를 유지하고, 일정 주기로 DB와 동기화시켜서 DB에서 모델이 변경되더라도 즉시 반영 가능하다.
이 덕분에 모델 교체, 롤백을 재배포 없이 매우 빠르게 수행할 수 있었고, 모델 실험 주기를 훨씬 타이트하게 가져갈 수 있었다.
추가로, 모델과 함께 사용되는 System Prompt 역시 데이터베이스에 저장되어 있기 때문에 모델 변경과 프롬프트 변경을 한 번에 관리할 수 있었다.
2. Coin 차감과 보상(Compensation)을 분리한 비용 처리 전략
우리는 채팅 및 AI 요청의 비용을 Coin 소모 방식으로 관리하고 있다.
이때 선택한 정책은 단순하지만 일관성이 있다.
요청이 시작되면 무조건 Coin을 먼저 차감한다.
LLM 호출 이후에 비용을 계산하는 방식 대신, 요청 진입 시점에서 비용을 확정적으로 차감함으로써 비용 처리 흐름을 단순화했다.
요청이 비정상적으로 종료된 경우는 Compensation 로직을 별도로 두었다.
- LLM Provider 에러 발생
- 시스템 장애로 인한 비정상 종료
이와 같은 케이스에서는 이미 차감된 Coin 양만큼을 보상 로직을 통해 되돌려준다.
Coin 차감 로직이 동일한 곳에서 관리되기 때문에 성공/실패 여부와 비용 계산 로직이 분리되었고 복잡한 분기 없이 비용 정합성을 유지할 수 있었다.
특히 스트리밍 기반 LLM API처럼 어디까지 성공이고 어디부터 실패인지가 애매한 환경에서는, 이 방식이 구현과 운영 모두에서 훨씬 안정적이었다.
채팅 서버는 우리 서비스의 핵심 서버이기 때문에 항상 유지보수를 고려하여 개발하려고 했다.
실제로 우리 서비스는 발전해오면서 모델과 정책들이 빠르게 추가되거나 바뀌고, 요청의 형태는 채팅을 넘어 이미지, 스토리, 다양한 콘텐츠 생성으로 확장되었다.
이 API를 호출하는 프론트엔드 입장에서는 엔드포인트가 1개여서 Integration 과정에서도 같은 인터페이스를 활용하는 수준으로 쉽게 구현을 할 수 있었다.
서버 내부적으로도 새로운 콘텐츠가 추가되어도 BasicService만 새로 구현하면 돼서 최대한 적은 공수로 새 기능을 구현할 수 있었다.
가장 중요한 것은 채팅의 질을 높이기 위해 모델이나 프롬프트 변경에 최적화된 구조를 만들어서 재배포 없이 빠르게 사용자들의 니즈(Needs)를 맞추기 위해 노력 할 수 있었다.
그리고 Langfuse 사용으로 어떤 모델이 인기가 많고, 비용이 가장 많이 들고 있으며 어떻게 사용하는지까지 쉽게 분석할 수 있었다.
서버를 짜다 보면 서비스의 성격이나 요구사항에 따라 구조가 많이 달라질 수 있다는 것을 체감한다.
그래도 결국엔 객체 지향 원칙에 따라 책임을 분리하고 공통 관심사는 묶어서 확장에 유연한 구조를 만든다는 원리 위에서 좋은 코드를 만들기 위해 항상 노력하고 있다.

댓글 남기기