프록시니까 그대로 넘기면 되는 줄 알았습니다

프록시니까 요청을 그대로 넘기면 되는 줄 알았습니다. 서비스 세 곳을 붙이자 세 가지 다른 방식으로 깨졌습니다.

프록시니까 그대로 넘기면 되는 줄 알았습니다 — 오픈소프트랩 기술블로그

안녕하세요. 오픈소프트랩에서 생성형 AI 거버넌스 제품을 만드는 개발팀입니다.

직원이 외부 AI를 쓸 때 오가는 내용을 검사하려면, 그 사이에 저희 서버가 있어야 합니다. 요청을 받아 검사하고 AI 서비스로 넘긴 다음, 돌아온 응답을 다시 검사해서 사용자에게 전달하는 구조입니다.

처음 설계는 단순했습니다. 프록시니까 내용만 검사하고 나머지는 그대로 넘기면 된다.

AI 서비스라는 게 결국 다 비슷할 거라고 봤습니다. 질문을 보내면 답이 스트리밍으로 오고, 우리는 중간에서 그걸 들여다보기만 하면 되니까요. 검사 로직 하나에 서비스만 갈아 끼우면 될 것 같았습니다.

한 곳을 붙였을 때는 실제로 그랬습니다.

세 곳을 붙이자 세 가지로 깨졌습니다

서비스를 늘리면서 문제가 나왔는데, 증상이 제각각이었습니다.

서비스증상
A차단 안내를 띄우면 그다음 답변이 화면에 안 나왔습니다
B새 대화를 시작했는데 이전 답변이 섞여 나왔습니다
C기존 대화를 이어가는데 새 대화로 처리됐습니다

같은 코드를 지나는데 결과가 셋 다 달랐습니다.

처음에는 각각을 따로 고쳤습니다. A를 고치면 B가 깨지고, B를 고치면 C가 이상해졌습니다. 한 군데를 손볼 때마다 다른 데가 틀어지는 게 반복되면서, 개별 버그가 아니라는 게 분명해졌습니다.

같은 스트리밍이 아니었습니다

원인은 스트리밍의 생김새가 서비스마다 다르다는 데 있었습니다.

앞서 스트리밍 응답을 중간에 끊는 이야기에서 썼듯이, AI 응답은 조각으로 나뉘어 옵니다. 그런데 그 조각을 어떻게 싸서 보내는지가 서비스마다 제각각입니다.

  • 대화를 새로 시작했는지 이어가는지를 무엇으로 구분하는가
  • 답변 조각이 어느 필드에 들어 있는가
  • 답변이 끝났다는 신호를 어떻게 보내는가
  • 중간에 끼어드는 구조 신호가 무엇인가

저희가 만든 공통 처리는 한 서비스의 규약을 기준으로 짜여 있었습니다. 다른 서비스가 오면 같은 자리에 다른 게 들어 있으니, 조각을 잘못 조립하거나 대화 경계를 잘못 잡았습니다.

이전 답변이 섞여 나오는 증상이 대표적입니다. 대화 구분자를 우리가 알던 필드에서 찾았는데 그 서비스는 다른 곳에 두고 있었습니다. 구분이 안 되니 이전 대화의 조각이 그대로 이어졌습니다.

커넥터를 나눴습니다

그래서 서비스별로 커넥터를 따로 두기로 했습니다. 각 커넥터가 자기 서비스의 규약만 알고, 검사 로직은 그 위에서 서비스와 무관하게 돌아가는 구조입니다.

사용자 요청
   ↓
[ 서비스별 커넥터 ]   ← 요청을 그 서비스 형식으로 변환
   ↓
[ 검사 ]              ← 서비스와 무관
   ↓
[ 서비스별 커넥터 ]   ← 응답 조각을 공통 형태로 해석
   ↓
사용자 화면

커넥터가 하는 일은 번역입니다. 서비스마다 다른 모양을 검사 로직이 아는 하나의 모양으로 바꿔줍니다. 검사 로직은 어느 서비스인지 몰라도 됩니다.

바꾸는 데 1,500줄 가까이 새로 쓰고 비슷한 양을 지웠습니다. 이미 돌아가던 코드를 들어낸 것이라 부담이 컸는데, 서비스를 하나 더 붙일 때마다 기존 두 곳이 깨지는 상황을 더 끌고 갈 수는 없었습니다.

나눈 다음에 다시 묶었습니다

쪼개고 나니 이번에는 같은 코드가 커넥터마다 복사되어 있었습니다. 로그를 남기는 부분, 토큰을 확인하는 부분처럼 서비스와 상관없는 것들입니다.

그래서 한 번 더 손봤습니다. 서비스에 따라 달라지는 것만 커넥터에 두고, 나머지는 공통부로 끌어올렸습니다. REST API로 들어오는 요청과 프록시로 들어오는 요청도 같은 공통부를 쓰게 정리했습니다.

나누는 것과 묶는 것은 반대 방향처럼 보이지만 기준이 하나입니다. 서비스마다 다른가, 아니면 같은가. 이 기준으로 한 번 나누고 한 번 묶었습니다.

규약은 추측하지 않고 확인하기로 했습니다

커넥터를 나눈 뒤에도 한동안 같은 종류의 문제가 나왔습니다. 저희가 만든 프레임을 클라이언트가 못 알아듣는 경우였습니다.

결국 실제로 오가는 통신을 그대로 받아서 우리 프레임과 대조해봤습니다. 세 군데가 규약을 어기고 있었습니다.

그중 하나가 완료 신호였습니다. 조각마다 완료를 붙여 보내서 한 응답에 완료가 6번 나가고 있었습니다. 클라이언트는 응답이 여섯 번 끝났다고 받아들이고 오류를 띄웠습니다. 완료는 한 번이면 된다는 걸 문서가 아니라 실측 기록을 보고 알았습니다.

다른 하나는 순서였습니다. 답변 조각을 담을 자리가 만들어지기 전에 조각을 먼저 보내고 있었습니다. 받는 쪽에서는 담을 데가 없으니 조립을 못 합니다.

이후로는 새 서비스를 붙일 때 문서를 읽고 구현한 다음, 실제 통신을 받아서 대조합니다. 문서에 안 적힌 순서나 필수 필드가 매번 나옵니다.

얻은 것과 포기한 것

얻은 것 — 서비스를 하나 붙일 때 다른 서비스가 안 깨집니다. 커넥터가 격리막 역할을 하니 영향 범위가 그 안에서 끝납니다. 검사 로직을 고칠 때도 서비스별로 확인할 필요가 없어졌습니다.

포기한 것 — 서비스마다 커넥터를 만들어야 합니다. 새 AI 서비스를 지원하려면 문서를 읽고, 구현하고, 실제 통신을 받아 대조하는 과정을 매번 거칩니다. 하나로 처리할 때보다 붙이는 비용이 늘었습니다.

이건 감수하기로 한 쪽입니다. 붙이는 비용은 한 번이고, 안 나누면 서비스가 늘 때마다 기존 것을 다시 검증해야 합니다. 그쪽이 훨씬 비쌉니다.

벤더가 바꾸면 깨집니다

남은 문제가 하나 있습니다. 커넥터는 그 서비스의 지금 규약에 맞춰져 있습니다.

AI 서비스들은 자주 바뀝니다. 응답 형식이 바뀌거나 필드가 하나 추가되면 저희 커넥터가 못 알아듣습니다. 저희가 통제할 수 없는 변경이고, 예고도 없습니다.

지금은 못 알아듣는 프레임이 오면 그 사실을 로그에 남기도록 해뒀습니다. 조용히 지나가면 검사를 건너뛴 채로 통과할 수 있기 때문입니다. 무슨 서비스의 어떤 요청이었는지, 어떤 모델이었는지를 함께 남겨서 어느 커넥터를 고쳐야 하는지 바로 보이게 했습니다.

다만 이건 깨진 뒤에 아는 방식입니다. 다음으로는 알려진 응답 형식을 정기적으로 대조해서 바뀐 것을 먼저 감지하는 쪽을 보려고 합니다. 벤더 문서를 지켜보는 것보다 실제 응답을 비교하는 편이 빠를 것 같습니다.


오픈소프트랩 개발팀이 작성합니다. 생성형 AI 사용 통제와 AI 에이전트의 도구 실행 통제를 만들고 있습니다.