
Chainlit을 활용한 AGUI — AI 에이전트를 위한 프로덕션급 채팅 프론트엔드 구축
요약
Chainlit을 사용하여 AI 에이전트를 위한 프로덕션급 채팅 프론트엔드(AGUI)를 구축하는 방법을 다룹니다. 인증, 스트리밍, 상태 유지 및 도메인 무관한 설계를 통해 실제 운영 환경에서 필요한 UI 레이어의 핵심 요소를 설명합니다.
핵심 포인트
- 도메인에 무관한(domain-agnostic) UI 설계로 유연성 확보
- OIDC 및 Keycloak을 활용한 실제 운영 수준의 인증 구현
- SSE 방식을 통한 실시간 스트리밍 답변 렌더링
- 지속성 있는 스레드 관리를 통한 대화 데이터 및 위젯 유지
에이전트 GUI는 모든 백엔드의 문제점이 가시화되는 곳입니다. 채팅 도중 토큰이 만료되거나, 재개된 스레드가 빈 화면으로 렌더링되거나, 스트리밍된 답변이 중복으로 나타나는 등의 문제 말이죠. 여기 프로덕션 환경에서도 견딜 수 있는 Chainlit 프론트엔드가 있습니다. 두 개의 호스트네임 분할을 통한 실제 OIDC 적용, 채팅 중 토큰 갱신, 대화형 Plotly 차트가 포함된 지속성 스레드(persisted threads), 그리고 실제 운영 단계에 진입해야만 마주하게 되는 실패 모드들에 대한 해결책을 담았습니다.
에이전트 데모들은 UI를 API 위에 대충 붙여놓은 채팅창 정도로 취급하며 뒷전으로 미룹니다. 하지만 실제 사용자들이 유입되면, UI가 가장 많은 _상태(state)_를 관리해야 하는 레이어라는 사실을 깨닫게 됩니다. 로그인 세션, 웹소켓(websocket) 연결, 스트리밍되는 부분 메시지, 스레드 히스토리, 그리고 3주 뒤 페이지를 새로고침해도 유지되어야 하는 풍부한 위젯(rich widgets) 등이 그 예입니다.
이 글에서는 대화형 대출 에이전트의 Chainlit 프론트엔드를 살펴봅니다. 모든 것을 결정짓는 설계 제약 조건은 다음과 같습니다: UI는 도메인에 무관(domain-agnostic)해야 합니다. UI는 에이전트의 /invocations 엔드포인트로 {prompt, customer_name, conversation_id}를 POST로 전송하고 스트리밍된 응답을 렌더링합니다. 동일한 코드 경로가 환경 변수 하나로 로컬 컨테이너와 배포된 AWS Bedrock AgentCore 런타임을 모두 지원합니다. 모든 대출 로직은 해당 엔드포인트 뒤에 존재하며, UI의 역할은 전송(transport), 신원 확인(identity), 렌더링(rendering), 그리고 지속성(persistence)입니다.

다이어그램을 순서대로 읽어보면:
- 단순한 파이프 (One dumb pipe) — UI는 도메인에 구애받지 않습니다(domain-agnostic). 에이전트의
/invocations엔드포인트로{prompt, customer_name, conversation_id}를 POST로 전송하고 스트리밍되는 이벤트(streamed events)를 렌더링합니다. 모든 대출 관련 로직은 이 파이프 뒤에 존재하며, 동일한 코드가 로컬 컨테이너와 배포된 AgentCore 런타임(runtime) 모두를 지원합니다. - 인증 (Identity) — 데모용 비밀번호 게이트가 아닌, Keycloak을 통한 실제 권한 부여 코드(authorization-code) 방식의 로그인을 사용합니다. 토큰은 채팅 중간에 만료되기 전에 갱신됩니다.
- 스트리밍 (Streaming) — 답변은 생각되는 대로 렌더링됩니다. 스피너(spinner)가 돌아간 뒤 텍스트 뭉치가 나타나는 방식이 아니라, 스트리밍되는 SSE 청크(chunks) 방식으로 제공됩니다.
- 메모리 (Memory) — 몇 주 뒤에도 스레드(threads)를 재개할 수 있으며, 차트 등 모든 데이터가 유지됩니다. 3주가 지난 후 페이지를 새로고침해도 세션(sessions), 단계(steps), 요소(elements)가 유지되도록 영속화(persisted)됩니다.
- 풍부한 답변 (Rich answers) — 대화형 Plotly 차트, 선택지, 카드 등을 제공합니다. 상환 일정(amortization schedule)은 단순한 문단이 아니라 위젯(widget)으로 구현됩니다.
- 단 하나의 규칙 (The one rule) — UI는 사용자 경험(experience)을 소유할 뿐, 도메인(domain)을 소유하지 않습니다. UI에서 어려운 모든 부분은 상태(state)에 관한 것입니다: 세션, 스트림, 저장소, 재개(resume).
원문 기사에는 읽는 과정에 따라 각 단계를 밝혀주는 대화형 스크롤 기반 다이어그램이 포함되어 있습니다.
하나의 단순한 파이프를 통해 하나의 대화를 중심으로 네 가지 작업 — 인증, 스트리밍, 메모리, 풍부한 답변 — 이 이루어집니다. 이 글의 나머지 부분은 실패 사례(failure modes)를 통해 배운 순서대로 이 네 가지 작업을 다룹니다.
로그인: 하나의 ID 제공자, 두 개의 호스트 이름
UI는 Keycloak을 대상으로 실제 권한 부여 코드 흐름(authorization-code flow)을 실행하며, 데모용 비밀번호 게이트는 사용하지 않습니다. 전형적인 Docker Compose 스택의 함정은 **두 개의 호스트 이름 분리(two-hostname split)**입니다. 브라우저는 https://localhost:8543을 통해 Keycloak에 접속하지만, UI의 백엔드는 네트워크 내부에서 https://keycloak:8443을 통해 코드를 교환해야 합니다. Chainlit의 기본 Keycloak 제공자(provider)는 모든 URL을 단일 베이스(base)에서 파생시키기 때문에, 구조적으로 이 방식을 표현할 수 없습니다. 따라서 앱은 작은 서브클래스(subclass)를 등록합니다:
def __init__(self):
# base_url은 부모(PARENT)가 토큰 + 사용자 정보 교환(인트라넷 내)을 위해 사용하는 값입니다.
self.base_url = (os.environ.get("OAUTH_KEYCLOAK_INTERNAL_URL") or "").rstrip("/")
...
두 가지 로그인 세부 사항은 실패를 통해 그 필요성이 입증되었습니다:
- 모든 authorize 시
prompt=login사용. Chainlit의/logout은 자체 쿠키만 삭제합니다. Keycloak의 SSO 쿠키는 살아남기 때문에, "로그아웃" 후 "Keycloak으로 계속하기"를 누르면 사용자가 조용히 다시 로그인되는 현상이 발생합니다. 이는 뱅킹 워크플로우(flow)에서는 잘못된 동작입니다. 새로운 자격 증명 프롬프트(credential prompt)를 강제하면, Chainlit의oauth_state쿠키 확인보다 빠르게 실행되는 즉각적인 SSO 리다이렉트(redirect)도 차단할 수 있습니다. - UI는 HTTPS를 제공해야 함. 동일한 이유 때문입니다. HTTPS Keycloak 옆에서 일반 HTTP로 실행했을 때,
oauth_state쿠키가 스킴 기반 동일 사이트(schemeful same-site) 규칙에 따라 교차 스킴(cross-scheme) 문제가 발생하여 로그인이 간헐적으로 실패했습니다. 이는 가장 최악의 버그 유형입니다.
토큰 갱신 (Token refresh): 채팅은 액세스 토큰보다 오래 지속됩니다
대출 상담은 30분짜리 액세스 토큰(access token)보다 오래 걸립니다. 채팅 도중에 연결이 끊기는 대신(이 스택의 다운스트림(downstream)에서는 반드시 발생합니다. 모든 단계에서 전달된 토큰을 검증하기 때문입니다), UI는 토큰이 만료되었거나 60초의 오차 범위(skew window) 내에 있을 때 각 턴(turn)이 시작되기 전에 제자리에서 토큰을 갱신합니다:
new_access = token_response.get("access_token")
if not new_access:
raise _ReloginRequired()
...
갱신에 실패하면(만료, 취소, 네트워크 문제 등), 턴 핸들러(turn handler)는 빈 플레이스홀더(placeholder) 버블을 제거하고, 스레드에 죽은 에러 라인을 남기거나 3개의 서비스 뒤에서 401 에러를 일으킬 토큰을 전달하는 대신, 일시적인 "다시 로그인해 주세요" 토스트(toast) 메시지를 표시합니다. 실패에 대한 UX(사용자 경험)도 인증 설계의 일부입니다.
(코드에 명시된 트레이드오프(Trade-off): 액세스 토큰과 리프레시 토큰(refresh token)은 Chainlit 사용자의 메타데이터 JSONB에 저장됩니다. 이는 저장된 상태의 토큰(token at rest)으로 데모용으로는 수용 가능하지만, 프로덕션(production) 환경에서는 서버 측 세션 저장소(server-side session store)로 대체되어야 합니다.)
턴 (The turn): 하나의 SSE 스트림, 다양한 이벤트 유형
한 턴의 전체 그림 — 스트리밍(streaming), 지속성(persistence), 그리고 아래에서 살펴볼 재개(resume) 경로:

각 턴은 /invocations로 POST 요청을 보내고 Server-Sent Events (SSE)를 읽습니다. 디스패치 루프(dispatch loop)는 chunk, choices, simulation_snapshot, disbursement_details, blocked, error라는 작은 이벤트 어휘(vocabulary)를 기준으로 스위칭하며, 각 이벤트를 UI 어포던스(affordance)에 매핑합니다: 스트리밍되는 토큰은 실시간 말풍선으로, AskActionMessage를 통한 라디오 피커(radio pickers), 마크다운 카드, 그리고 가장 핵심적인 요소인 다음을 포함합니다:
await cl.Message(
content=("여기에 전체 내역이 있습니다. 이 차트는 **귀하가 지불하게 될 정확한 금액**입니다... "),
elements=[cl.Plotly(name="amortization", figure=figure, display="inline")],
...
매월 원금/이자 분할, 잔액 선, 범위 슬라이더를 포함하는 대화형 상환 차트(amortization chart)가 일급 채팅 요소(first-class chat element)로 제공됩니다. 이것이 채팅 '박스(box)'가 아닌 채팅 '프레임워크(framework)'를 사용해야 하는 이유입니다. cl.Plotly, 액션 피커(action pickers), 스레드 히스토리(thread history)가 기본으로 제공되며, 앱 코드는 도메인 로직(domain logic)에만 집중할 수 있습니다.
에이전트 측에 존재하지만 여기서 드러나는 스트리밍 관련 실전 사례가 하나 있습니다: 플로우 노드(flow node)가 내부 LLM 헬퍼 호출을 구성한 다음 최종 메시지 자체를 추가하기 때문에, 모든 헬퍼 답변이 두 번 스트리밍됩니다 — 한 번은 토큰으로서, 다른 한 번은 추가된 메시지로서 말입니다. 해결책은 태깅 패턴(tags=["loan_flow_internal"])을 사용하여 스트리밍 레이어에서 태그가 지정된 스팬(span)을 제외하는 것입니다. 만약 LangGraph 노드 내부에서 LLM 호출을 구성하고 messages 모드로 스트리밍한다면, 이 버그를 만나게 될 것입니다.
지속성(Persistence): 모두가 과소평가하는 부분
Chainlit의 SQLAlchemy 데이터 레이어는 사용자, 스레드, 단계(steps), 요소(elements), 피드백를 스택의 전용 chainlit-db Postgres 인스턴스에 지속(persist)합니다 (서비스별 데이터베이스 분리 원칙은 UI에도 적용됩니다. 채팅 히스토리와 비즈니스 데이터는 서로 다른 소유자, 생명주기, 삭제 정책을 가집니다). 하지만 데이터 레이어 자체는 아무것도 생성하지 않으며, 바로 이 지점에서 두 가지 프로덕션 버그가 발생하여 큰 교훈을 남겼습니다.
버그 1: 단계(steps)가 조용히 저장되지 않음. 앱은 CREATE TABLE IF NOT EXISTS를 사용하여 스키마를 부트스트랩(bootstrap)합니다. 하지만 이 방식은 기존 테이블에 컬럼을 추가하지는 않습니다. 이전 버전의 Chainlit에서 처음 생성된 데이터베이스에는 최신 버전의 Chainlit create_step이 INSERT하려는 autoCollapse 컬럼이 없었습니다. 이로 인해 Postgres에서 UndefinedColumnError가 발생했고, 채팅 기록 그 자체인 단계(steps)들이 조용히 저장에 실패했습니다. 앱이 충돌(crash)하지는 않았습니다. 그저 기록이 누락되었을 뿐입니다. 해결책은 시작 시점에 멱등성(idempotent) 마이그레이션 단계를 실행하는 것입니다:
# 이전 스키마에서 이미 존재하는 테이블들을 위한 멱등성(idempotent) 마이그레이션. CREATE TABLE IF NOT
# EXISTS는 컬럼을 추가하지 않으므로, 최신 Chainlit 컬럼(예: 2.11에서 추가된 autoCollapse)은
# 여기서 백필(back-fill)되어야 합니다. 그렇지 않으면 create_step이 UndefinedColumnError와 함께
# 실패하며 단계(steps)가(다음 내용을 포함하여)
...
버그 2: 재개된 차트가 빈 화면으로 렌더링됨. 라이브 상태에서는 Plotly 피겨(figure)가 완벽하게 렌더링됩니다. 하지만 하루 뒤에 스레드를 다시 열면 빈 사각형만 나타납니다. 근본 원인은 Chainlit이 확장자(extension)가 없는 키(key) 아래에 엘리먼트 블롭(element blobs)을 저장하고, /public 파일 경로가 파일 이름으로부터 Content-Type을 추측하기 때문입니다. 확장자가 없으면 application/octet-stream으로 지정되며, 프론트엔드의 Plotly 컴포넌트는 아무것도 렌더링하지 못합니다. 해결책은 앱의 커스텀 파일 시스템 스토리지 클라이언트(custom filesystem storage client)에서 업로드 시점에 키에 MIME 확장자를 추가하는 것입니다. 이렇게 하면 저장된 키, 디스크 상의 파일, 그리고 제공되는 Content-Type이 일관되게 유지됩니다:
@staticmethod
def _with_ext(object_key: str, mime: str) -> str:
"""/public이 올바른 Content-Type을 제공할 수 있도록 MIME의 파일 확장자를 추가합니다."""
...
두 사례 모두에서 얻은 일반적인 교훈은 다음과 같습니다: 지속성(persistence) 버그는 재개(resume) 버그입니다. 라이브 렌더링은 스토리지 경로를 전혀 건드리지 않기 때문에 라이브 상태에서는 모든 것이 정상 작동합니다. 새로고침(reload) 테스트를 직접 해보지 않는 한, 몇 주가 지난 후에야 사용자로부터 이 문제를 알게 됩니다.
도용할 만한 가치가 있는 두 가지 작은 영속성(persistence) 관련 참고 사항이 있습니다. 첫째, 앱은 자체적으로 외래 키(FK) 인덱스를 추가합니다 (Chainlit은 기본 키(primary key) 외에는 아무것도 제공하지 않으므로, 스레드 재개 시 자식 노드들을 순차 스캔(seq-scan)하게 됩니다). 둘째, Chainlit의 콜백별 래퍼(per-callback wrapper) 단계를 분리하여, 어시스턴트 메시지가 영속화되지 않는 부모 아래에 고아(orphaned) 상태로 남는 대신 최상위 레벨(top-level)에서 유지되도록 합니다.
재개(Resume): 하나의 ID가 전체 스택을 하나로 묶습니다
cl.context.session.thread_id — Chainlit 자체의 스레드 UUID — 는 에이전트의 conversation_id로 그대로 재사용됩니다. 재개 시, UI는 해당 단일 ID를 다시 바인딩하고, Chainlit은 영속화된 메시지와 요소(elements)를 재생(replay)하며, 에이전트는 _동일한 내구성이 있는 LangGraph 체크포인트(checkpoint)_로부터 계속 진행합니다. UI 히스토리와 에이전트 메모리는 하나의 정체성(identity)을 공유하기 때문에 서로 어긋날 수 없습니다. (보너스: 36자 길이로 구성되어 있어 클라우드 모드에서의 AgentCore runtimeSessionId ≥ 33 요구 사항도 충족합니다.)

결론
이 중 화려한 것은 하나도 없으며, 그것이 바로 핵심입니다. 채팅 프론트엔드는 다른 모든 요소와 마찬가지로 분산 시스템(distributed-systems)의 참여자입니다. 만료되는 토큰을 보유하고, 데이터 드리프트(drift)가 발생하는 데이터베이스에 기록하며, 콘텐츠 타입(content type)이 중요한 블롭(blobs)을 서빙하고, 정확하게 재개되어야 하는 대화에 다시 진입합니다. Chainlit은 웹소켓(websockets), 스트리밍(streaming), OAuth 배관(plumbing), 스레드 히스토리, 풍부한 요소(rich elements)와 같은 외형(chrome)을 제공하지만, 다음과 같은 책임은 사용자에게 남겨둡니다: 스키마 마이그레이션(schema migrations), 토큰 생명주기(token lifecycle), 스토리지 의미론(storage semantics), 그리고 재개 경로(resume path)를 직접 관리하는 것.
AGUI를 아키텍처의 일부로 취급하고, 새로고침(reload)과 재로그인(re-login)을 정상적인 흐름(happy path)만큼 진지하게 테스트하십시오. 그러면 UI는 백엔드의 엄격함이 사장되는 곳이 아닌, 시스템의 일부로 기능하게 될 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기