10분

추론 백엔드를 하루 만에 갈아끼우고, 그 청구서를 열사흘 동안 갚았다

코드가 한 서버의 방언에 묶여 있었습니다. 호환 시그니처를 먼저 세워 호출부 100여 곳을 하루에 옮기고 4,500줄을 지웠지만, 그 호환 계층이 남긴 잔재를 걷어내는 데 다시 열사흘이 걸렸습니다.

  • vLLM
  • LiteLLM
  • 마이그레이션
  • 리팩터링
Mac mini의 OpenMake가 사설망을 거쳐 DGX Spark의 vLLM으로 추론을 보내는 구조도
지금의 구조는 이 전환의 결과입니다. 애플리케이션과 추론 사이의 경계가 OpenAI 호환 규격이 된 뒤에야 뒤쪽을 바꿀 수 있게 됐습니다.

SHIPPED / EVIDENCE

이번에 배포한 것

로컬 추론 백엔드를 Ollama의 자체 HTTP API에서 LiteLLM 프록시 뒤의 vLLM으로 옮겼습니다. OpenAI 호환 규격만 쓰는 새 모듈을 세우고, 기존 클라이언트와 같은 시그니처를 유지해 호출부 100여 곳을 한 번에 이전한 뒤 옛 구현 15개 파일을 삭제했습니다.

새 모듈
1,682줄
삭제한 구현
15파일 4,500줄
일괄 이전한 호출부
100곳 이상
잔재 청산까지
13일

01

코드가 한 서버의 방언에 묶여 있었다

그때까지 로컬 추론은 Ollama의 자체 HTTP API를 통해 이뤄졌습니다. 줄 단위 JSON 스트리밍, 여러 개의 키를 돌려 쓰는 키풀 로테이션, 그리고 채팅과 임베딩과 웹검색이 각각 다른 전용 경로였습니다. 이 형태가 클라이언트 코드 안쪽까지 들어와 있었습니다.

옮기려는 곳은 LiteLLM 프록시 뒤의 vLLM이었고, 이쪽은 OpenAI 호환 규격만 씁니다. 문제는 단순히 주소를 바꾸는 일이 아니었습니다. 스트리밍 형식이 다르고, 사용량을 세는 단위가 다르고, 한쪽에만 있는 기능이 있었습니다.

그리고 이 코드를 부르는 곳이 100군데가 넘었습니다. 한 번에 다 고치면서 동시에 규격까지 바꾸면 무엇이 깨졌는지 알 수 없게 됩니다.

02

호환 시그니처를 먼저 세웠다

그래서 첫 단계는 이전이 아니라 준비였습니다. OpenAI SDK 위에 얇은 래퍼로 새 모듈을 만들되, 겉으로 드러나는 시그니처는 기존 클라이언트와 똑같이 맞췄습니다. 호출하는 쪽에서 보면 이름만 다르고 쓰는 법은 같은 물건입니다.

타입 정의 685줄에는 옛 이름을 일부러 그대로 남겼습니다. 이름을 예쁘게 고치는 것과 백엔드를 바꾸는 것을 같은 커밋에서 하면, 나중에 문제가 생겼을 때 둘 중 무엇 때문인지 가릴 수 없습니다. 호환을 먼저, 정리는 나중으로 미뤘습니다.

  • 스트리밍 파서는 OpenAI 델타 형식과 추론 필드를 다루도록 새로 썼습니다
  • 사고 강도 설정은 옵트인으로 두고 기본은 껐습니다
  • 사용량 추적은 시간 기준에서 토큰 기준으로 바꿨습니다. 같은 이름의 한도가 다른 의미를 갖게 되는 변경이라 별도로 표시했습니다
  • 도구를 여러 턴에 걸쳐 부르는 루프는 OpenAI 도구 규격에 맞춰 다시 만들었습니다

03

백엔드를 바꾸면 사라지는 기능이 있었다

옛 백엔드에는 웹검색 전용 경로가 있었지만 vLLM에는 없습니다. 규격을 표준으로 맞추면 표준에 없는 기능은 함께 사라집니다.

이걸 없앨 수는 없으니 다른 층으로 옮겨야 했습니다. 어댑터를 하나 두어 웹검색 호출을 MCP 도구 쪽으로 위임하도록 했습니다. 추론 서버가 제공하던 기능이 도구 계층의 책임으로 이동한 것입니다.

결과적으로 이 편이 더 맞는 자리이기도 했습니다. 검색은 추론 엔진의 일이 아니라 도구의 일이고, 나중에 검색 공급자를 바꿀 때도 추론 백엔드를 건드리지 않게 됐습니다.

04

100곳을 한 번에 옮기고 4,500줄을 지웠다

두 번째 단계는 기계적인 작업이었습니다. import 경로를 일괄 치환하고 설정 참조 이름을 바꾸는 코드모드를 저장소 전체에 돌렸습니다. 시그니처를 맞춰 둔 덕분에 호출하는 쪽은 경로만 갱신하면 그대로 컴파일됐습니다.

그리고 옛 디렉터리를 통째로 지웠습니다. 클라이언트, 키 관리자, 키풀, 쿨다운, 스트림 파서, 연결 풀, 인터셉터까지 15개 파일 약 4,500줄입니다. 폐기된 구현에 의존하던 테스트도 함께 정리했습니다.

여기서 삭제가 핵심입니다. 새 구현을 넣고 옛 구현을 남겨 두면 두 경로가 공존하고, 어느 쪽이 진짜인지 아는 사람이 점점 줄어듭니다. 호환 시그니처는 이전을 위한 다리였지 두 구현을 오래 붙여 두기 위한 장치가 아니었습니다.

05

설정과 라벨, 그리고 일부러 남긴 것

세 번째 단계는 사람이 보는 부분이었습니다. 설정 예시 파일에서 옛 백엔드 전용 변수를 대량으로 걷어내고 새 변수 여덟 개로 다시 정리했습니다. 여러 개의 키와 여러 개의 모델 슬롯, 클러스터 노드 목록 같은 것들이 이때 사라졌습니다. 하나의 프록시 주소로 충분해졌기 때문입니다.

다만 하나는 일부러 남겼습니다. 내부에서 이 공급자를 식별하는 문자열이 데이터베이스의 제약 조건과 모델 식별자 접두사에 박혀 있어서, 이름만 바꾸려 해도 데이터 마이그레이션이 따라와야 했습니다. 지금 당장 필요한 변경이 아니었으므로 후속 과제로 적어 두고 남겼습니다.

남긴다고 결정하는 것과 잊는 것은 다릅니다. 이건 열사흘 뒤에 실제로 정리됐습니다.

06

같은 날 저녁, 코드모드가 놓친 것들

일괄 치환은 패턴에 맞는 것만 잡습니다. 그날 저녁에 남은 것들이 드러났습니다.

설정 객체를 거쳐 읽는 형태는 코드모드가 잡았지만, 환경변수를 직접 읽는 곳은 패턴이 달라 그대로 남아 있었습니다. 기본 모델 이름이 코드에 박혀 있던 곳도 있었는데, 이걸 환경변수를 매번 다시 읽도록 바꿔 재시작이나 테스트에서의 재정의가 즉시 반영되게 했습니다.

가장 눈에 띈 것은 모델 목록을 확인하는 코드였습니다. 옛 백엔드 전용 경로를 부르고, 그 응답 형식을 가정하고, 실패하면 옛 백엔드의 명령어를 안내하고 있었습니다. 이걸 OpenAI 표준 경로와 응답 형식으로 바꾸고, 오류 메시지도 프록시 설정과 실행 옵션을 확인하라는 안내로 고쳤습니다. 지금 어떤 모델이 노출되고 있는지 함께 보여 주도록 해서 원인을 바로 알 수 있게 했습니다.

옛 백엔드에만 있던 클라우드 구분 접미사를 검증하는 로직도 이때 사라졌습니다. 새 백엔드에는 그런 구분이 없습니다.

07

경계가 표준이 되자 뒤쪽이 자유로워졌다

이전이 끝나고 나서 벌어진 일이 이 작업의 값어치를 보여 줍니다. 이틀 뒤에는 모델별 실행 스크립트와 서비스 유닛, 프록시 별칭 설정이 들어갔습니다. 닷새 뒤에는 로컬 모델 카탈로그가 생겨 채팅 모델 넷과 임베딩 모델 하나를 목록으로 다루게 됐고, 환경변수로 이 목록을 통째로 바꿀 수 있게 했습니다.

그다음 두 주 동안 실제로 서빙하는 모델 구성이 여러 번 바뀌었습니다. 그때마다 애플리케이션 코드는 손대지 않았습니다. 프록시가 모델 이름으로 라우팅하고 클라이언트는 하나의 주소만 알면 되는 구조라, 바뀌는 것이 설정 파일 안에서 끝났기 때문입니다.

마지막으로 5월 말에 내부 공급자 식별자를 표준 이름으로 바꾸며 이전을 마무리했습니다. 3단계에서 일부러 남겨 뒀던 그 부채입니다.

08

호환 계층의 청구서

이 전환에서 호환 시그니처는 분명히 효과가 있었습니다. 100군데가 넘는 호출부를 하루에 옮길 수 있었던 것은 그 덕분이고, 그렇게 하지 않았다면 백엔드 교체와 호출부 수정이 뒤엉켜 원인을 가릴 수 없었을 것입니다.

동시에 호환 계층은 청구서를 남깁니다. 옛 이름이 남은 타입 정의, 패턴이 달라 놓친 직접 참조, 데이터베이스 제약에 박혀 못 바꾼 식별자가 전부 그 청구서입니다. 이전 자체는 하루였지만 청구서를 다 갚는 데는 열사흘이 걸렸습니다.

차이를 만든 것은 갚기로 정해 뒀다는 점입니다. 남기는 것마다 왜 남기는지와 언제 정리할지를 커밋에 적어 뒀고, 그래서 잔재가 잊히는 대신 하나씩 지워졌습니다. 호환 계층은 다리로 쓸 때는 유용하지만, 다리를 건넌 뒤에도 두면 그냥 두 번째 구현이 됩니다.

근거 자료

근거 자료

개발일지로 돌아가기