콘텐츠로 이동

에이전트 중단 버튼 하나가 서버 세 대를 관통할 때: Flag 체크포인트 설계

한 줄 요약

실행 중인 에이전트를 멈추는 기능을 만드는 이야기. 연결을 끊는 것과 실행을 멈추는 것은 다른 문제이고, 그 차이를 메우는 데 Redis 상태, Flag 체크포인트, 메시지 큐가 필요했다는 기록.

문제: 화면만 멈추고 아무것도 안 멈춘다

리서치 에이전트는 단계가 많아 실행이 길다. 에이전트가 엉뚱한 방향으로 가도 사용자가 할 수 있는 건 기다리는 것뿐이었다. 사용자 경험도 나쁘고, 원하지 않는 실행인데 토큰은 계속 쓰인다는 점에서 비용 손해이기도 했다.

그런데 구조가 걸린다. 채팅이 서버 셋을 지난다.

Web Frontend  ->  Backend API  ->  ML Server
                                    에이전트 실행, SSE로 결과 스트리밍

여기서 웹이 SSE 연결만 끊으면 어떻게 되는가. 화면에서만 멈춘다. API 서버와 ML 서버 사이 연결은 살아 있고, 에이전트는 계속 돌며 토큰을 쓰고, 중간 결과는 계속 DB에 쌓인다. 새로고침하면 분명 멈췄는데 답변이 계속 만들어지는 상태를 보게 된다.

그래서 목표가 "끊기"가 아니라 "안전하게 중단하기" 가 된다. 요구사항 넷을 세웠다.

  • 안전한 중단: 데이터 손실이나 손상 없이 실행을 멈춘다
  • 상태 일관성: 불완전한 데이터가 DB에 저장되지 않는다
  • 원활한 재개: 중단된 스레드에서 새 메시지로 바로 이어간다
  • Subagent 지원: 하위 에이전트 실행 중에도 멈출 수 있다

설계: 중단 상태를 세 서버가 모두 보는 곳에

Redis에 중단 플래그를 둔다.

1. 웹이 메시지 고유 ID를 함께 보낸다
2. API 서버가 Redis에 Interrupt:{ID} = "normal" 초기화 후 ML 서버에 실행 요청
3. 중단 버튼 -> 웹이 중단 API 호출 -> API 서버가 값을 "Interrupted"로 변경
4. ML 서버 에이전트가 정해진 지점마다 확인하다 감지하면 정리 후 종료

여기서 눈여겨볼 결정이 하나 있다. 메시지 ID를 프론트엔드에서 만들도록 바꿨다.

보통 메시지 ID는 서버가 발급한다. 그러면 웹은 서버의 첫 응답이 올 때까지 ID를 모르고, 그 사이에는 중단 요청을 보낼 수 없다. ID를 웹에서 만들어 요청에 실으면 메시지를 보낸 직후 어느 시점에든 즉시 멈출 수 있다.

작은 변경인데 "언제부터 멈출 수 있는가"를 결정한다. 중단 기능에서 가장 답답한 구간이 바로 요청 직후이므로, 이 결정이 체감 품질을 좌우한다.

Exception이 아니라 Flag를 고른 이유

처음 떠올린 방법은 중단 시점에 Exception을 던지는 것이었다. 두 가지 이유로 접었다.

  • 프레임워크가 툴 실행 중 Exception을 잡아 "툴 실행 실패"로 바꾼다. 툴 하나 실패로 전체가 멈추지 않게 하려는 설계인데, 이 구조에서는 중단용 Exception도 중간에 잡혀버린다.
  • Exception은 어디서 실행이 끊길지 예측하기 어렵다. 정리되지 않은 상태가 남을 위험이 있다.

그래서 Flag 기반 체크포인트를 택했다. 안전하게 멈출 수 있는 지점을 미리 정해두고, 그 지점에 도달할 때마다 확인해 스스로 멈춘다.

에이전트는 Orchestrator LLM 호출 → Tool 실행 → 상태 저장 스텝을 답이 완성될 때까지 반복하는 루프다. 프레임워크가 각 단계 앞뒤에 Hook Function 자리를 두고 있어서, 거기에 확인 코드를 넣는 것으로 끝났다.

체크포인트 중단 시 효과
Orchestrator LLM 호출 전 비용이 큰 LLM 호출을 시작하지 않는다
툴 실행 전 검색 등 툴 실행을 건너뛴다
상태 저장 전 해당 스텝의 결과를 DB에 저장하지 않는다

세 번째가 핵심이다. 대화 상태를 스텝이 끝나는 시점에만 저장하는 구조라, 중단 시 이 저장을 건너뛰면 진행 중이던 스텝의 불완전한 결과가 DB에 남지 않는다. 이미 끝난 이전 스텝들의 결과는 그대로 보존되므로 진행 중이던 마지막 스텝만 깔끔하게 버려진다. 중단된 스레드에서 새 메시지를 보내면 이전 맥락 위에서 자연스럽게 이어진다.

"안전한 중단"과 "원활한 재개"라는 요구사항 둘이 이 한 지점에서 동시에 해결된다.

베이스 클래스에 넣어 얻은 두 가지

중단 로직을 특정 에이전트가 아니라 모든 에이전트가 상속하는 베이스 클래스에 구현하고, 메시지 ID는 요청 단위 공유 컨텍스트에서 가져오게 했다.

  • Subagent 지원이 공짜로 따라왔다. 하위 에이전트들도 코드 한 줄 없이 중단을 지원한다. 요구사항 하나가 별도 작업 없이 해결됐다.
  • 하위 호환성도 자연스럽게 붙었다. 기본 동작이 "메시지 ID가 없으면 아무것도 하지 않는 것"이라, 구 버전 클라이언트가 ID를 안 보내도 기존과 똑같이 동작한다. 덕분에 Feature Flag 없이 ML 서버부터 순차 배포할 수 있었다.

두 번째가 특히 영리하다. 기본값을 "무동작"으로 잡아 배포 순서 문제를 설계로 없앴다.

Polling에서 Pub/Sub으로

첫 구현은 Text Delta가 ML 서버에서 API 서버로 내려올 때마다 Redis를 조회했다. Text Delta는 글자 단위로 쏟아지므로 답변 하나에 수백에서 수천 번의 조회가 생기는 구조였다.

동료의 조언으로 Redis Pub/Sub으로 바꿨다. 중단 시 Publish하면 구독 측이 즉시 안다. 매번 묻지 않아도 된다.

설계에서 좋은 대목은 값 저장을 없애지 않은 것이다. 중단 여부는 여전히 Redis에 저장돼 있어서, Pub/Sub 알림을 놓치는 경우의 안전망이 그대로 남는다. 알림은 빠른 경로이고 저장된 값은 진실의 출처다. 둘을 겹쳐 두는 편이 맞다.

중단 이후의 경험까지

중단만 만들고 끝나지 않는다. "실행 중인데 사용자가 새 메시지를 입력하면?"이 남는다. 강제로 끊으면 진행 중이던 답변이 날아가고, 입력을 막으면 답답하다. 그래서 Message Queue를 뒀다.

정책에서 갈린 지점이 명확하다.

상황 큐 처리
자연 완료 큐의 메시지를 자동 전송
사용자 수동 중단 자동 전송하지 않고 명시적으로 보낼 때까지 대기

사용자가 직접 멈췄다는 건 지금 흐름을 끊고 싶다는 뜻인데, 큐에 있던 메시지가 곧바로 나가면 의도와 어긋난다. 자동화의 편의와 사용자 의도가 충돌하는 지점을 정확히 짚었다. 큐 안의 메시지는 드래그로 순서를 바꿀 수 있다.

디테일 하나가 더 있다. 답변이 시작되기도 전에 중단된 경우다. 상태 저장 스킵 덕분에 DB에도 안 남으므로, 화면에서도 메시지를 지우고 입력창에 내용을 복원한다. 사용자 입장에서는 아예 보내지 않은 것처럼 되돌아간다.

크레딧도 맞췄다. 중단해도 그 시점까지 실제 실행된 만큼만 차감하고, 아무것도 실행되지 않았으면 차감하지 않는다.

읽을 때 감안할 것

  • 수치가 없다. 중단 요청에서 실제 정지까지 걸리는 지연, 절감된 토큰 비용, Pub/Sub 전환 후 줄어든 Redis 부하가 모두 서술로만 나온다. "수백에서 수천 번"이 유일한 정량 표현이다.
  • 체크포인트 방식의 지연은 구조적이다. 체크포인트에 도달해야 멈추므로 긴 툴 실행이나 LLM 호출이 진행 중이면 그것이 끝날 때까지는 멈추지 않는다. 최악의 경우 지연이 얼마인지, 그 사이 토큰은 얼마나 더 쓰이는지는 다루지 않는다.
  • 병렬 툴 실행 중 중단이 불분명하다. 한 스텝 안에서 여러 툴이 병렬로 돈다고 밝혀놓고, 그중 일부만 끝난 상태에서 중단되면 어떻게 정리하는지는 설명이 없다.
  • ML 서버가 여러 대일 때의 구독 관리가 빠져 있다. Pub/Sub은 채널 구독 구조인데, 스케일아웃 상황에서 어느 인스턴스가 어떤 메시지 ID를 구독하는지는 언급되지 않는다.
  • 채용 글의 성격이 섞여 있다. 마지막 두 문단이 채용 안내다. 기술 내용 자체는 충실하지만 문제와 해법이 매끄럽게만 서술되는 경향은 감안하고 읽는 편이 낫다.
  • 자체 프레임워크라 가능했던 부분이 있다. Hook Function 자리가 이미 있었기에 깔끔했다고 저자도 밝힌다. 외부 SDK를 쓰는 팀은 같은 구조를 만들기 어려울 수 있다.

가져갈 지점

  1. 에이전트 루프에 중단 지점을 미리 설계해 두기

Exception이 아니라 Flag를 고른 판단이 그대로 쓸 만하다. 중단을 예외 상황이 아니라 정상 흐름의 한 분기로 다루면 정리되지 않은 상태가 남지 않는다. 루프를 설계할 때 "여기서 멈춰도 안전한가"를 함께 표시해 두는 습관이 필요하다.

  1. 상태 저장 시점이 곧 중단 경계다

스텝이 끝나는 시점에만 저장하기 때문에 저장 직전 체크포인트 하나로 데이터 일관성이 확보된다. 저장 단위를 어떻게 잡느냐가 중단 가능 지점을 결정한다. 중간 결과를 자주 저장하는 구조였다면 정리가 훨씬 복잡했을 것이다.

  1. 알림 경로와 진실 경로를 분리해 두기

Pub/Sub으로 바꾸면서 Redis 값 저장을 남긴 설계는 일반적으로 쓸 만하다. 빠른 경로는 최선 노력, 느린 경로는 확정 상태로 두면 알림 유실이 치명적이지 않다. reef 노트를 쓸 때 본 커밋 로그와 CAS 조합도 같은 발상이다.

  1. 기본값을 무동작으로 잡아 배포 순서를 없애기

"메시지 ID가 없으면 아무것도 하지 않는다"로 하위 호환을 확보해 Feature Flag 없이 순차 배포한 방식은 다른 기능에도 옮길 만하다. 서버가 여러 대 얽힌 기능에서 배포 순서는 늘 골칫거리다.

  1. 비용 정책까지 같이 정하기

중단 시 크레딧을 실제 실행분만 차감한다는 결정이 있어야 기능이 완성된다. 에이전트 기능은 토큰이 곧 돈이라, 중단·재시도·실패 각각의 과금 규칙을 설계 단계에서 정해두지 않으면 나중에 분쟁이 된다.

결론

"연결을 끊는 것"과 "실행을 멈추는 것"은 다른 문제라는 것이 이 글의 한 줄이다. 서버가 셋으로 나뉘고 그중 하나가 긴 루프를 도는 순간, 중단은 UI 기능이 아니라 분산 상태 문제가 된다.

수치가 없어 성능을 판단할 자료는 아니다. 다만 Exception 대신 Flag, 저장 직전 체크포인트, 알림과 진실 경로 분리, 무동작 기본값 네 가지는 구조적 판단이라 환경이 달라도 그대로 옮길 수 있다.

에이전트 제품을 만들면서 "멈추기"를 나중에 붙이려 하면 늘 어렵다. 루프를 설계할 때 같이 정해두는 편이 낫다는 것을 보여주는 사례다.