Slack에서 오래 걸리는 작업을 비동기로 맡기면 사용자는 “지금 작업하고 있어?”라고 물어요. 이때 봇이 직전 대화만 보고 답하면 서버 재시작으로 이미 중단된 작업을 진행 중이라고 하거나, 다른 스레드의 작업 상태를 가져올 수 있어요.
이 글은 personal_agents 저장소의 PR #824, #831, #836, #838, #839에 기록된 관찰을 바탕으로 썼어요. 처리 시간이나 오류율 같은 성능 지표는 따로 측정하지 않았어요.
“시작하지 않았다”는 답변 뒤에서 실제 작업은 중단돼 있었다
사용자가 Slack 스레드에서 블로그 초안을 요청하자 봇은 “작성을 시작했어요”라고 답했어요. 그런데 몇 분 뒤 진행 여부를 묻자 “앞서 시작했다고 했지만 실제로 시작된 건 아니에요”라고 말했어요.
서버 로그와 agent_run 기록을 맞춰 보니 실제로는 14시 22분 09초(KST)에 요청을 BLOG 작업으로 분류하고 실행을 시작했어요. 약 5분 뒤 main 브랜치 병합으로 서버가 재시작되면서 프로세스가 종료됐고 작업도 중단됐어요. 사용자가 받은 답은 실제 실행 상황과 정반대였던 셈이에요.
당시 대화 응답 경로는 실행 기록을 조회하지 않았고 프롬프트에는 재촉을 받으면 작업이 시작되지 않았다고 전제하는 규칙이 있었어요. 봇은 확인할 수 없는 사실을 대화 문맥만으로 추측한 거예요. 백그라운드 작업을 마친 뒤 Slack에 답장하는 코드도 같은 프로세스 안에 있어서 서버가 죽자 작업 중단을 알릴 경로까지 함께 사라졌고요.
#824는 두 문제를 나눠서 다뤘어요. 진행 여부를 물으면 마지막 작업의 실제 실행 기록을 조회해 진행 중·완료·실패 상태와 실패 사유만 근거로 답하도록 했어요. 서버가 종료 신호를 받으면 진행 중인 블로그 초안의 Slack 스레드에 재시작으로 작업이 중단됐으니 다시 요청해 달라는 메시지도 남기도록 했어요.
작업 상태는 대화에서 추론할 정보가 아니라 실행 시스템에서 조회할 사실이에요.
실행 원장을 조회하게 됐지만 어떤 실행을 찾아야 하는지는 여전히 정확하지 않았어요.
사용자 단위의 “가장 최근 실행”은 다른 대화를 섞었다
#824의 초기 구현은 ConversationalReplyUsecase.describeLatestWorkStatus에서 다음 조건으로 실행을 조회했어요.
findLatestUserRun({
agentType,
slackUserId,
startedSince: workTurnAt - 30분,
});
이 구조는 같은 사용자가 최근 30분 안에 실행한 동일 종류의 작업 중 가장 최근 기록을 찾아요. 한 사용자가 A 스레드에서 블로그 작성을 시작한 뒤 B 스레드에서도 블로그를 요청했다고 해볼게요. A 스레드에서 “작업하고 있어?”라고 물었을 때 사용자 ID와 작업 종류만으로 최신 실행을 고르면 A가 아니라 더 늦게 시작한 B의 상태를 반환할 수 있어요. 원장에서 가져온 실제 상태라도 지금 묻는 대화와는 무관한 사실이에요.
동기적으로 끝나는 작업은 대화 기억에 남은 agentRunId로 해당 실행을 정확히 찾을 수 있어요. 하지만 블로그 초안처럼 확인 응답을 먼저 보내고 뒤에서 실행하는 비동기 작업은 응답 시점에 실행 ID가 아직 없어서 agentRunId가 sentinel 값 0이었어요. 실행 ID만으로는 대화와 실행을 연결할 수 없는 구간이 있었던 거예요.
#831은 조회 범위를 사용자 단위에서 대화 단위로 좁혔어요. 즉시 실행 ID를 확보할 수 있는 작업은 그 ID로 조회하고 확인 응답 시점에 ID가 없는 비동기 BLOG 작업은 실행 기록에 작업을 시작한 대화의 기억 키를 남겨 같은 키로 찾도록 바꿨어요.
아래 표는 실제 테이블 정의가 아니라 PR에 나온 연결 관계를 개념적으로 정리한 내용이에요.
| 연결 정보 | 용도 | 한계 |
|---|---|---|
| 실제 실행 ID | 특정 실행을 정확히 조회 | 비동기 작업의 초기 확인 응답 시점에는 없을 수 있음 |
| Slack 사용자 ID | 실행 주체를 제한 | 여러 스레드의 실행을 구분하지 못함 |
| 작업 종류 | BLOG 같은 실행 유형을 제한 | 같은 종류의 동시 작업을 구분하지 못함 |
| 대화 기억 키 | 작업을 시작한 대화와 실행을 연결 | DM과 스레드 사이에서 기억 위치가 달라질 수 있음 |
| 답장 스레드 키 | 결과가 실제 게시된 스레드를 식별 | 별도로 기록하지 않으면 완료 순서에 따라 혼입 가능 |
DM과 스레드가 기억을 나눠 가지자 두 번째 질문부터 상태가 사라졌다
대화 키로 실행을 찾으면 스레드 혼입이 해결될 것 같았지만 DM에는 다른 문제가 있었어요. 사용자가 DM 창에 블로그 요청을 최상위 메시지로 보내면 요청은 채널 키의 기억에 저장되고 봇은 그 메시지에서 열린 스레드에 답해요.
스레드에서 처음 “작업하고 있어?”라고 물으면 스레드 기억이 비어 있어 getPriorTurnsWithChannelFallback이 채널 기억을 대신 읽어요. 그래서 그 안에 있는 원래 BLOG 요청을 찾을 수 있었죠. 하지만 첫 문답을 스레드 기억에 저장하고 나면 스레드 키가 더는 비어 있지 않아 두 번째 질문부터는 채널 기억으로 돌아가지 않았어요. 스레드 기억에는 작업을 맡긴 최초 요청이 없으니 describeLatestWorkStatus가 실행 조회 단계까지 도달하지 못했고요.
#836은 스레드에 처음 들어갈 때 DM 기억에서 그 스레드를 연 사용자 메시지와 봇 응답을 스레드 기억으로 옮겼어요. 옮긴 각 turn에는 원래 대화의 키도 남겨 상태를 조회할 때 작업을 시작한 대화의 실행 기록을 찾을 수 있게 했어요. 부모 메시지 앞뒤의 다른 DM 대화는 옮기지 않았고요.
최근 5 turn이라는 기억 한계가 네 번째 질문에서 다시 드러났다
대화 기억은 키마다 최근 5개 turn만 유지했어요. 상태를 한 번 물을 때마다 사용자 질문과 봇 응답이 각각 한 turn씩 추가되니, 같은 스레드에서 문답을 반복하면 처음 옮겨 둔 BLOG 요청과 시작 응답이 밀려나요. PR에 기록된 테스트에서는 #836 이후에도 네 번째 상태 질문부터 작업 turn을 찾지 못했어요.
기억 용량을 5개에서 10개나 100개로 늘려도 사라지는 시점만 늦춰질 뿐이에요. 작업을 마친 뒤 질문하더라도 최초 요청이 기억 범위 밖으로 밀릴 수 있고 한 스레드에서 다른 주제로 오래 대화하면 같은 문제가 다시 생겨요. 기억 길이는 대화 품질을 조절하는 설정일 수 있어도 실행 상태의 보존 기간이 되어서는 안 돼요.
#838은 “이 대화에서 어떤 작업을 맡겼는가”를 최근 대화 turn 대신 agent_run에서 직접 찾도록 했어요. BLOG 실행 기록에는 봇이 응답을 게시한 스레드의 키도 함께 남겼고요.
스레드에서 상태를 물으면 언제나 현재 스레드 키로 최근 30분 이내의 BLOG 실행을 조회했어요. 최초 작업 turn이 최근 5개 기억에서 사라져도 원장에는 실행 기록이 남아 있었죠. 완료 답장을 기억에 저장할 때도 실제 실행 ID를 포함해, 작업이 끝난 뒤 “완료됐어?”라고 물으면 해당 실행의 완료 상태를 다시 확인할 수 있게 했어요.
조회 흐름을 개념적으로 나타내면 아래와 같아요.
현재 Slack 대화 키 결정
└─ 스레드 안이면 스레드 키 사용
└─ 최근 30분 이내 BLOG 실행을 agent_run에서 조회
├─ 실행 발견: 진행·완료·실패와 사유를 응답 근거로 사용
└─ 실행 없음: 대화 내용만으로 상태를 만들어 내지 않음
이 구조에서 대화 기억은 모델에 최근 맥락을 제공하고 실행 원장은 작업의 생명주기를 보존해요.
실행 원장도 스레드 귀속이 틀리면 확신 있게 오답을 낸다
#838 이후에는 대화 기억이 짧아도 원장에서 BLOG 상태를 찾을 수 있었어요. 그런데 DM에서 블로그를 연달아 두 번 맡기자 뒤의 스레드가 앞 작업의 완료 상태를 답하는 문제가 드러났어요.
A와 B 두 작업을 시간순으로 놓으면 원인이 선명해져요.
A 요청
A 시작 응답
B 요청
B 시작 응답
A 완료 응답
완료 응답은 요청 순서가 아니라 실제 완료 시점에 채널 기억에 추가됐어요. rememberAsyncReply가 비동기 완료 응답을 DM 채널 키에 저장했고, getPriorTurnsWithChannelFallback은 스레드를 연 부모 발화부터 다음 사용자 turn 직전까지 나온 assistant turn을 그 스레드의 맥락으로 옮겼어요.
그 결과 B 요청 뒤에 저장된 A 완료 응답까지 B 스레드로 옮겨졌어요. 이 turn에는 A의 실제 실행 ID가 있어서 B 스레드는 잘못 귀속된 확정 정보를 근거로 A의 상태를 답했어요. 원장 조회 자체는 정확했지만 어느 스레드의 실행을 조회해야 하는지가 다시 오염된 거예요.
#839은 완료 응답 turn에 실제로 그 응답을 게시한 스레드를 기록했어요. 채널 기억에서 스레드 기억으로 맥락을 옮길 때 요청 직후의 응답과 현재 스레드에 게시된 완료 응답만 골랐어요. 다른 스레드에 게시된 완료 응답은 완료 시점이 현재 요청 뒤여도 제외했고요.
비동기 작업은 요청 순서와 완료 순서가 뒤집힐 수 있어서 “이 사용자 메시지 다음에 저장된 봇 메시지”라는 위치만으로 소속을 판단할 수 없어요. 요청을 시작한 대화 키, 응답을 게시한 스레드 키, 실제 실행 ID를 함께 기록해야 각 메시지와 실행이 어떻게 연결되는지 다시 알아낼 수 있어요.
대화 상태, 실행 상태, 귀속 상태의 역할
일련의 변경을 거치면서 하나로 보였던 “기억”은 세 종류의 상태로 나뉘었어요.
| 상태 | 대표 식별자 | 책임 |
|---|---|---|
| 대화 상태 | 채널 키·스레드 키 | 모델이 최근 문맥을 이해하도록 도움 |
| 실행 상태 | 실제 실행 ID | 진행 중·완료·실패와 실패 사유를 보존 |
| 귀속 상태 | 작업 시작 대화 키·답장 스레드 키 | 실행과 응답이 어느 대화에 속하는지 연결 |
즉시 끝나는 명령처럼 응답과 실행이 같은 요청 안에서 완료되고 프로세스 재시작 뒤 상태를 복원할 필요가 없으며 여러 대화에서 같은 작업을 동시에 실행하지 않는다면 대화 문맥만으로도 충분할 수 있어요. 실행 원장을 도입하면 기록 정리와 보존 기간 관리, 실행과 대화 키의 일관성 유지, 재시작 시 중단 상태 처리까지 운영 책임이 늘어나니까요.
반대로 작업이 Slack 응답보다 오래 실행되거나 서버나 worker가 작업 중 재시작될 수 있고 한 사용자가 여러 스레드에서 같은 작업을 실행한다면 대화 기억을 상태 저장소로 쓸 때 주의해야 해요. 요청 순서와 완료 순서가 달라지거나, 완료 후에도 결과나 실패 사유를 다시 확인해야 하는 경우도 마찬가지예요.
남은 조회 범위와 재현 시나리오
근거 PR에서 상태 조회 범위는 최근 30분이에요. 30분을 넘긴 실행에 어떻게 응답하는지는 확인할 수 없어요. 장시간 작업이나 오래된 결과를 조회해야 한다면 보존 기간과 조회 정책을 따로 설계해야 해요.
비슷한 구조를 적용한다면 다음 상황을 따로 검증해야 해요.
- 한 스레드에서 비동기 작업을 시작한 뒤 진행 중 상태를 물어, 실제 실행 ID 또는 대화 키로 같은 실행을 조회하는지 확인해요.
- 같은 사용자가 30분 안에 서로 다른 두 스레드에서 같은 종류의 작업을 시작하고 각 스레드가 자기 실행만 조회하는지 확인해요.
- DM 최상위 메시지에서 작업을 시작한 뒤 생성된 스레드에서 두 번째·세 번째·네 번째로 상태를 물어, 최근 5개 turn에서 최초 요청이 밀려나도 상태가 유지되는지 확인해요.
- A 작업을 시작한 다음 B 작업을 시작하고 A가 나중에 완료되게 만들어, A 완료 응답이 B 스레드의 맥락이나 상태 조회에 들어가지 않는지 확인해요.
- 작업 진행 중 서버 종료 신호를 발생시켜, 실행이 중단되고 원래 Slack 스레드에 재시작으로 인한 중단 안내를 게시하는지 확인해요.
- 완료 응답을 저장한 뒤 다시 상태를 물어, 응답 turn의 실제 실행 ID를 통해 완료 상태를 조회하는지 확인해요.
- 최근 30분을 벗어난 실행, 종료 알림 전송 자체의 실패, 재시작 직후 중복 실행은 근거 PR에서 검증 범위를 확인할 수 없으므로 별도의 정책과 테스트를 마련해요.