AI 에이전트

AI가 만든 알림을 내보내기 전에 검증 게이트를 통과시키기

종목 코드를 잘못 붙이거나 미래 데이터로 과거를 판단한 알림은 없느니만 못하다. 여러 프로젝트가 공유하는 검증 관문을 만든 이야기.

주식 관련 자동화 프로젝트가 여러 개 돌고 있다. 후보를 발굴하는 것, 위험을 감시하는 것, 유튜브와 뉴스를 요약하는 것. 각각 따로 만들었고 각각 알림을 보낸다.

어느 시점에 공통 문제가 보였다. 틀린 알림이 나가는 방식이 몇 가지로 정해져 있었다.

반복해서 나오는 두 가지 실수

1. 종목 코드와 회사 이름이 어긋난다

알림에 “066930 삼성SDI”라고 적혀 있다. 그런데 066930은 다른 회사다. 삼성SDI는 006400이다.

이런 일이 생기는 경로는 여러 가지다. LLM이 요약하면서 회사명을 잘못 붙이거나, 뉴스 본문에서 뽑은 이름과 코드가 다른 문장에서 왔거나, 코드가 비슷해서 헷갈리거나.

받는 사람 입장에서는 치명적이다. 코드를 보고 종목을 찾는데 엉뚱한 회사가 나온다. 한 번만 겪어도 그 시스템을 못 믿는다.

2. 미래 데이터로 과거를 판단한다

“이 종목은 6월 16일 시점에 좋아 보였다”는 분석을 하는데, 6월 17일에 발표된 실적을 이미 반영해 계산한 경우다. 백테스트에서 흔히 나오는 실수고, 결과가 실제보다 훨씬 좋게 나온다.

이걸 PIT(point-in-time) 시간 정직성이라고 부른다. 어떤 시점의 판단은 그 시점에 알 수 있었던 정보만 써야 한다.

각자 검증하면 각자 틀린다

처음에는 각 프로젝트가 알아서 확인하게 했다. 잘 안 됐다.

  • 프로젝트마다 검증 수준이 다르다
  • 새 프로젝트를 만들면 검증 코드를 다시 짠다. 대체로 더 허술하게
  • 어디서 틀렸는지 추적이 안 된다

그래서 검증을 한 곳으로 모았다. 알림을 내보내기 전에 통과해야 하는 공용 관문을 만들었다.

curl -X POST "http://<내부주소>/api/verify/candidates" \
  -H "Content-Type: application/json" \
  -H "X-Project-Name: <호출하는 프로젝트 이름>" \
  -d '{"candidates":[{
        "ref":"c1",
        "ticker":"066930",
        "company_name":"삼성SDI",
        "event_time":"2026-06-16T09:00:00Z",
        "as_of":"2026-06-17T00:00:00Z"
      }]}'

게이트는 후보별로 상태와 경고를 돌려준다. 상태는 심각한 순으로 PIT 점검 > 입력 점검 > 식별 점검 > 검증 보류 > ok 다.

게이트가 하지 않는 것

설계에서 중요했던 건 역할을 좁게 잡은 것이다.

점수나 등급을 매기지 않는다. 게이트는 “이 종목이 좋은가”를 판단하지 않는다. “이 데이터가 신뢰할 만한가”만 본다. 판단은 각 프로젝트가 한다.

무상태(stateless)로 동작한다. 자기 DB를 보지 않는다. 요청에 들어온 것만 검사한다. 그래서 어느 프로젝트가 호출하든 같은 입력에 같은 결과가 나온다.

역할을 좁게 잡으니 여러 프로젝트가 마음 편히 붙일 수 있었다. 게이트가 점수까지 매기면 각 프로젝트의 판단 로직과 충돌하고, 결국 아무도 안 쓴다.

여기서는 fail-open 이 맞았다

중요한 결정이 하나 있다. 게이트가 느리거나 죽어도 알림을 막지 않는다. 권장 타임아웃은 3초고, 그 안에 응답이 없으면 그냥 통과시킨다.

이건 앞서 다른 프로젝트에서 내린 결정과 반대다. 커뮤니티 피드에서는 번역이 안 끝나면 아예 노출하지 않기로 했다(fail-closed). 여기서는 검증이 안 돼도 알림을 보낸다(fail-open).

기준은 막았을 때의 손해와 통과시켰을 때의 손해 중 어느 쪽이 큰가다.

  • 피드에서 번역 안 된 글을 보여주면 → 사용자가 읽고 지나가고, 번역본을 영영 못 본다. 막는 게 낫다
  • 검증 게이트가 죽었다고 위험 알림을 막으면 → 정말 위험한 상황을 놓친다. 보내는 게 낫다

검증은 품질을 높이는 장치지 알림의 전제 조건이 아니다. 부가 기능이 죽었다고 본 기능이 멈추면 안 된다.

실제로 상위 데이터 소스 두 곳이 동시에 장애가 나면 게이트가 배치 단위 회로 차단기로 빠르게 fail-open 한다. 타임아웃을 기다리며 3초씩 쌓이지 않게 하기 위해서다.

전역 상태로 개별 판단을 하지 않기

운영하면서 걸린 함정이 하나 있다.

응답에는 후보별 상태와 함께 전역 진단 신호도 들어 있다. 예를 들어 국내 종목 마스터 데이터의 가용성 같은 것이다.

호출하는 쪽에서 이 전역 값이 unavailable이면 후보 전체를 실패로 처리하는 코드를 짰다. 그런데 이건 틀렸다. 마스터 데이터가 없어도 다른 경로로 식별이 계속되기 때문이다. 1순위 식별 관문이 따로 있고, 마스터는 국내 종목용 빠른 보조 경로일 뿐이다.

그래서 규칙을 명시했다. 판단은 후보별 상태로 한다. 전역 값은 진단용 보조 신호일 뿐이다.

이건 일반화할 수 있는 이야기 같다. 배치 API를 만들 때 전역 상태와 항목별 상태를 함께 돌려주면, 호출하는 쪽이 편한 쪽(전역)을 보고 판단한다. 문서에 적어두는 것만으로는 부족하고, 전역 값의 이름을 진단용임이 드러나게 짓는 편이 낫다.

위치가 아니라 키로 매핑하기

또 하나. 응답을 요청과 연결할 때 배열 위치(index)를 쓰지 못하게 했다. 요청에 ref를 넣고 응답도 ref로 돌려준다.

이유는 게이트가 일부 항목을 건너뛸 수 있기 때문이다. 잘못된 형식이거나 개수 상한을 넘으면 그 항목은 검사되지 않는다. 이때 응답 배열의 길이가 요청과 달라진다. 위치로 매핑하면 엉뚱한 후보에 엉뚱한 검증 결과가 붙는다. 종목 코드가 뒤바뀌는 것과 같은 종류의 사고다.

건너뛴 항목이 있으면 verification_complete=false와 건너뛴 개수를 함께 돌려준다. 조용히 줄어들지 않게 하기 위해서다.

정리

  • 여러 프로젝트가 같은 실수를 반복하면, 검증을 각자 시키지 말고 공용 관문으로 모은다
  • 게이트의 역할은 좁게 잡는다. 신뢰성만 보고 점수는 매기지 않아야 다들 쓴다
  • fail-open 과 fail-closed 는 상황에 따라 다르다. 기준은 막았을 때와 통과시켰을 때의 손해 비교다
  • 검증은 품질 향상 장치지 본 기능의 전제 조건이 아니다
  • 배치 API 에서 전역 상태와 항목별 상태를 함께 주면, 호출하는 쪽은 편한 쪽을 본다
  • 응답 매핑은 위치가 아니라 키로 한다. 건너뛴 항목이 있으면 위치가 어긋난다