8분

실패한 다음에 할 수 있는 일이 없었다

1.23.0은 새 기능을 늘린 릴리스가 아닙니다. 답이 틀렸을 때, 작업이 죽었을 때, 기록이 통째로 사라졌을 때 사용자와 운영자가 각각 무엇을 할 수 있는지를 만든 릴리스입니다.

  • 릴리스
  • 1.23.0
  • 감사 로그
  • 복구 경로
대화 목록과 검색 입력이 있는 OpenMake 히스토리 화면
대화는 계속 저장되고 있었습니다. 다만 찾을 방법도, 다시 시작할 방법도 없었습니다.

SHIPPED / EVIDENCE

이번에 배포한 것

1.23.0에는 실패 이후의 경로가 들어갔습니다. 마지막 답변을 복사하고 다시 생성하며, 실패하거나 취소한 에이전트 작업을 처음부터 재실행하고, 대화를 제목이 아니라 본문으로 찾습니다. 운영 쪽에는 모델 역할 배정이 통째로 사라졌던 사건을 계기로 이전 값까지 남기는 감사 기록이 들어갔습니다.

릴리스
1.23.0
감사 경로 결함
4건
관리자 대화 조회
200건 → 전량
실측 누적 토큰
7.1M

01

기능은 있는데 되돌릴 방법이 없었다

이번 릴리스의 항목들은 서로 관련이 없어 보입니다. 메시지 복사 버튼, 작업 재시도, 대화 검색, 감사 로그, 비용 환산. 하지만 하나씩 왜 필요했는지를 되짚으면 전부 같은 자리에서 나왔습니다. 무언가 잘못됐을 때 사용자가 화면에서 할 수 있는 일이 아무것도 없었다는 것입니다.

답변이 마음에 들지 않으면 질문을 처음부터 다시 입력해야 했습니다. 에이전트 작업이 실패하면 카드에 실패라고만 적힌 채 남았습니다. 예전 대화를 찾으려면 제목이 기억나야 했고, 로그인하지 않은 사용자에게는 히스토리 목록이 애초에 비어 있는 것처럼 보였습니다. 기능이 없어서가 아니라 되돌아갈 입구가 없어서 생긴 문제들이었습니다.

02

재생성은 첨부가 있으면 하지 않는다

완료된 어시스턴트 메시지에 복사 버튼을, 마지막 어시스턴트 메시지에 재생성 버튼을 붙였습니다. 복사는 화면에 보이는 텍스트를 그대로 주는 대신 아티팩트 자리표시자를 걷어내고 넘기며, 클립보드 API를 쓸 수 없는 비보안 컨텍스트에서는 버튼을 내립니다.

재생성은 조금 더 조심스러웠습니다. 소켓은 Composer 하나만 들고 있기 때문에 메시지 쪽에서 직접 재전송할 수 없습니다. 스토어에 재전송 요청을 올리면 Composer가 히스토리를 되감고 다시 보내며, 소켓이 이미 닫혀 전송이 실패하면 되감았던 대화를 원래대로 되돌립니다.

가장 중요한 결정은 재생성을 어디에 붙이지 않을지였습니다. 첨부가 있는 메시지는 재생성 대상에서 뺐습니다. 원본 파일을 보존하지 않기 때문에 재전송하면 파일 이름만 텍스트로 다시 올라가고, 모델은 첨부를 본 적 없는 상태에서 그럴듯한 답을 만들어 냅니다. 버튼이 있는데 틀린 답을 주는 것보다 버튼이 없는 편이 낫다고 판단했습니다.

03

재시도 버튼을 달았더니 즉시 취소됐다

실패하거나 취소한 에이전트 작업을 처음부터 다시 실행하는 버튼은 새 실행 경로를 만들 필요가 없었습니다. 기존 실행 API에 이미 새로 시작하는 경로가 있었기 때문에 목록 카드에서 그 경로를 다시 부르면 되는 일이었습니다.

그런데 실측해 보니 재시도가 시작되자마자 중단됐습니다. 원인은 시작 가드였습니다. 실행 진입 시점에 데이터베이스에 남아 있는 상태를 읽는데, 직전 실행이 남긴 취소 상태를 새 실행에 대한 취소 요청으로 오인하고 곧바로 중단시키고 있었습니다. 실행에 다시 들어갈 때 상태를 먼저 대기로 되돌리는 것으로 해결했습니다.

함께 손본 것이 몇 가지 더 있습니다. 새로 시작하는 재실행에서는 이전 체크포인트를 지웁니다. 남겨 두면 이어서 재개할 수 있는 것처럼 보이지만 실제로는 지난 실행의 잔재입니다. 버튼을 누른 직후 낙관적으로 대기 상태를 반영해 두 번 눌리는 창을 막았고, 로컬 실행기를 쓰는 작업은 생성 때와 마찬가지로 기기 연결을 먼저 확인합니다.

리뷰에서 나온 수정을 반영하니 라우트 파일이 618줄이 되어 600줄 CI 가드를 넘었습니다. 기능을 줄이는 대신 소유 작업 조회와 공개 형태 변환을 헬퍼로 분리해 596줄로 맞췄습니다.

04

게스트 대화는 저장되고 있었다

로그인하지 않은 사용자의 대화가 히스토리에 남지 않는다는 인상이 있었습니다. 확인해 보니 저장은 처음부터 되고 있었습니다. 익명 세션 식별자로 데이터베이스에 정상적으로 들어가고 있었고, 문제는 사이드바의 최근 대화 목록이 채팅이 끝나도 캐시를 비우지 않는다는 점이었습니다. 새로고침하면 나타났습니다.

로그인 사용자는 목록에 이전 대화가 이미 여러 개 있어서 한 건이 늦게 뜨는 것을 알아채기 어려웠지만, 게스트는 목록이 처음부터 비어 있으니 저장 자체가 안 되는 것으로 체감됐습니다. 스트림이 끝나는 공통 지점, 즉 정상 종료와 중단과 오류가 모두 지나가는 자리에서 캐시를 무효화하는 것으로 정리했습니다.

찾는 문제도 남아 있었습니다. 검색이 제목만 훑었기 때문에 무슨 이야기를 했는지는 기억나는데 제목이 기억나지 않는 대화는 찾을 수 없었습니다. 이제 제목과 메시지 본문을 함께 검색하고 일치한 부분을 발췌해 보여 줍니다. 입력은 300밀리초 디바운스로 서버에 보내고, 검색어가 바뀌면 이전 결과를 즉시 버려 늦게 도착한 응답이 화면을 덮지 않게 했습니다. 검색어에 포함된 패턴 메타문자는 이스케이프하고, 소유자 조건은 값으로 바인딩합니다.

  • 검색하지 않을 때의 응답 형태는 그대로 두어 사이드바와 개인 히스토리는 손대지 않았습니다
  • 상위 100건 밖에서 일치한 세션도 결과에 합쳐 보여 줍니다
  • 관리자 전체 조회 모드에서는 본문 검색 대신 제목 필터만 동작합니다

05

상한을 올리는 대신 페이지를 나눴다

관리자 전체 대화 화면은 한 번에 200건을 읽어 오는 구조였습니다. 그보다 오래된 대화는 게스트 것을 포함해 아예 열람할 수 없었습니다. 상한 숫자를 키우는 선택지가 있었지만 그것은 같은 문제를 나중으로 미루는 일이라, 서버 오프셋 페이지네이션으로 바꿔 전량을 페이지 단위로 볼 수 있게 했습니다.

응답에 전체 건수를 함께 실었는데, 이 값은 전체 조회 범위에서만 붙입니다. 사이드바와 개인 히스토리는 같은 API를 쓰고 있어서, 모든 응답에 필드를 추가하면 기존 소비처의 계약이 바뀝니다. 화면은 50건씩 끊어 이전과 다음 버튼으로 이동하며 총 건수와 현재 페이지를 함께 표시합니다.

06

배정이 통째로 사라졌는데 이력이 없었다

8월 8일에 모델 역할 배정이 사용자 것과 전역 것 모두 0행이 되는 일이 있었습니다. 복구 자체는 어렵지 않았지만 왜 사라졌는지는 끝내 알 수 없었습니다. 사용자 쪽 배정 변경은 감사 기록을 전혀 남기지 않았고, 관리자 쪽은 남기되 바뀌기 전 값을 적지 않았기 때문입니다. 다음에 같은 일이 생겼을 때 누가 무엇을 어떤 값에서 어떤 값으로 바꿨는지 읽을 수 있어야 한다는 것이 이번 변경의 목표였습니다.

여기서 처음 만든 구현에는 결함이 있었고 코드 리뷰가 네 가지를 짚었습니다. 가장 나쁜 것은 이전 값을 얻으려고 넣은 선조회였습니다. 이 조회가 던진 예외가 그대로 전파되면서, 데이터베이스가 잠깐 흔들릴 때 원래대로라면 성공했을 배정이 500으로 실패했습니다. 기록을 남기려다 기록 대상을 죽인 셈입니다.

이전 값을 별도 조회로 얻는 대신 쓰기의 부산물로 만들어 이 경로 자체를 없앴습니다. 갱신은 트랜잭션을 열고 키 단위 어드바이저리 락을 잡은 뒤 조회와 삽입을 한 번에 처리하고, 삭제는 삭제하면서 지워진 값을 그대로 돌려받습니다. 동시에 두 요청이 같은 역할을 바꿔도 감사에 적히는 이전 값이 실제 직전 값과 어긋나지 않습니다.

처음에는 잠금을 공통 테이블 식에 넣는 방식을 시도했는데, 같은 문장의 삽입과 충돌해 이전 값이 빈 값으로 나오는 것을 실측으로 확인하고 폐기했습니다. 감사 기록은 응답을 보내기 전에 커밋되도록 기다리며, 감사가 실패하더라도 배정 자체는 죽이지 않되 조용히 넘기지도 않고 이전 값까지 포함해 오류로 남깁니다.

07

막는 장치는 어느 쪽으로 실패해야 하는가

이번 릴리스에는 무언가를 막는 장치가 셋 들어갔는데, 실패했을 때의 방향이 각각 다릅니다.

네이버 검색은 지난 6월 공지로 검색 API가 새 게이트웨이로 이관됐습니다. 새 키가 설정돼 있으면 새 경로를, 없으면 기존 경로를 씁니다. 두 경로를 함께 부르지 않고 검색 한 번에 호출도 한 번이며, 환경변수를 지우는 것만으로 되돌릴 수 있습니다. 일일 무료 한도에 닿으면 호출을 만들기 전에 멈추되, 카운터 저장소가 응답하지 않을 때는 검색을 막지 않고 통과시킵니다. 한도를 넘으면 상대 쪽에서 어차피 거절하므로, 우리 쪽 카운터 고장으로 검색을 못 하게 되는 편이 더 나쁘다고 봤습니다.

감사 기록은 반대로 실패해도 배정을 통과시키되 오류를 남기는 쪽입니다. 기록이 목적인데 기록 실패가 기능을 멈추면 안 되고, 그렇다고 조용히 사라지면 애초에 감사가 아닙니다.

HTTPS 강제는 세 번째입니다. Next가 직접 서빙하는 HTML 경로에는 백엔드의 정책 헤더가 닿지 않아 빠져 있었고, 프레임워크 이름을 알리는 헤더도 그대로 나가고 있었습니다. 백엔드와 같은 값으로 맞추되 브라우저 목록에 미리 등록하는 옵션은 넣지 않았습니다. 그 옵션은 한번 들어가면 되돌리기가 매우 어렵기 때문입니다.

08

비용은 청구서가 아니다

사용량 화면에 일·월·년 단위 비용 환산을 넣었습니다. 자체 호스팅이므로 실제 청구가 발생하지 않습니다. 같은 사용량을 상용 API로 처리했다면 얼마였을지를 비교해 보기 위한 값입니다.

집계 대상에는 대화뿐 아니라 에이전트 작업의 토큰도 넣었습니다. 대화만 세면 크게 과소평가됩니다. 실제로 작업 쪽 토큰이 훨씬 지배적입니다. 기준 단가는 환경변수로 바꿀 수 있고 기본값은 Qwen3.8-Max 공시가인 100만 토큰당 입력 2달러, 출력 6달러를 씁니다. 처음에는 30B급 모델 단가를 기본값으로 뒀다가 실제로 돌리는 모델 급에 맞게 교체했습니다. 실측으로는 한 계정의 누적 710만 토큰이 약 4.26달러, 원화로 5,970원 정도로 나왔습니다.

숫자보다 신경 쓴 것은 오해를 막는 쪽이었습니다. 토큰 기록은 2026년 7월 이후 것만 존재합니다. 대화가 30일 롤링으로 지워지는 데다 토큰을 영속화하기 시작한 시점이 그때이기 때문입니다. 그래서 응답에 최초 기록일을 함께 실어 화면 각주에 집계 범위를 명시했습니다. 이 값이 전체 사용 이력으로 읽히면 숫자가 정확한 것보다 나쁩니다.

09

다음

지금 있는 것은 실패 이후의 입구들입니다. 다시 생성하고, 다시 실행하고, 다시 찾고, 무엇이 바뀌었는지 나중에 읽는 경로입니다. 아직 없는 것은 첨부가 있는 메시지의 재생성입니다. 원본 파일을 보존하지 않는 한 열 수 없는 문이고, 보존은 저장 정책과 함께 결정할 문제입니다.

감사 쪽도 마찬가지입니다. 이번에 닫은 것은 모델 역할 배정 한 곳입니다. 같은 질문을 다른 설정 항목에 던지면 대부분 같은 답이 나올 것이고, 어디부터 닫을지는 실제로 사라져 봐야 알게 되는 대신 설정별 영향 범위를 먼저 정리해 정하려 합니다.

근거 자료

근거 자료

개발일지로 돌아가기