Engineering · Delivery · Operations

취약점 점검 대응: 배포 기준 라이브러리 인벤토리와 vLLM 0.20.1 → 0.27.1 무수정 업그레이드 판단

레포를 그냥 읽으면 틀리는 세 가지 함정을 피해 8개 서비스의 직접·전이 의존성을 배포 기준으로 수집하는 생성기를 만들고, 은행의 CVSS 기준표와 대조해 vLLM 취약을 확정했습니다. 어댑터 결합도와 런타임 위험을 좁혀 무수정 업그레이드를 권고했고, 배포 후 재생성한 목록으로 변경분을 제출했습니다.

기간
2026.07 – 2026.08
소속
고객사 구축 · 은행
역할
인벤토리 생성기 작성·취약점 대조·업그레이드 검토

Key Results

1,742건
8개 서비스 직접·전이 의존성배포 기준 원격 ref에서 수집, 최초 작성 시점
1,801건 · 변경 87행
업그레이드 반영 후 재생성추가 61행, 버전 변경 26행
0.20.1 → 0.27.1
vLLM 버전기준표상 0.24.0 미만 취약, 업그레이드 후 모델 어댑터 변경은 주석 1줄

은행이 "플랫폼에서 사용 중인 라이브러리 버전 자료"를 다음 날 오전 10시까지 요청했습니다. 취약점 점검용이라 별도로 관리하고 패치할 때마다 갱신해야 하는 자료였습니다. 이튿날에는 소프트웨어별 취약점 목록을 CVSS v3.1 기준으로 정리한 점검 기준표가 도착했고, 이것을 은행에 배포된 버전과 대조해야 했습니다.

목록 작성은 단순한 작업처럼 보였지만, 레포를 그냥 읽으면 틀린 목록이 나오는 함정이 세 가지 있었습니다. 대조 결과 LLM 서빙 엔진인 vLLM이 기준표의 취약 범위에 들어 업그레이드가 필요했는데, 폐쇄망의 GPU 서빙 스택을 올리는 일이라 "버전을 올리면 무엇이 깨질 수 있는가"를 근거와 함께 답해야 했습니다.

이 글은 배포 기준 인벤토리를 만든 과정, 기준표 대조, 그리고 vLLM 0.20.1에서 0.27.1로의 업그레이드 파급을 코드 결합도, 런타임 위험, CUDA 호환, 커뮤니티 이슈 순서로 좁혀 간 과정을 다룹니다. 처음 판단 중 틀렸던 것과 놓쳤던 것도 함께 적었습니다.

요청의 성격과 배포 구조

XGEN은 은행에 8개 서비스로 배포돼 있습니다. Node.js 프론트엔드, Rust API 게이트웨이, Python 서비스 다섯 개(코어, 워크플로우, 문서 처리, MCP 관리, 모델 서빙), Java SSO입니다. 폐쇄망이라 외부망에서 이미지를 빌드해 반입하고, 모델 서빙 서비스는 vLLM과 llama.cpp 서버를 담은 베이스 이미지 위에 앱 이미지를 올리는 구조입니다. 운영 추론은 H200 GPU에서 vLLM으로 LLM과 임베딩 모델을 서빙합니다.

규모는 서비스마다 크게 달랐습니다. 프론트엔드는 직접 의존성 75개에 전이 의존성 664개였고, Python 코어는 직접 29개, 전이 43개였습니다. 취약점 점검이 목적이라면 직접 의존성만 적은 목록은 쓸모가 반으로 줄어듭니다. 취약점은 대개 전이 의존성에서 나오기 때문입니다.

레포를 그냥 읽으면 틀리는 세 가지

함정 그냥 읽으면 실제 해결
SSO는 레포부터 다름 공용 레포의 Maven, Spring Boot 2.5.5, Java 1.8 배포 매니페스트가 다른 그룹의 레포를 가리킴. Gradle 8.5, Spring Boot 2.7.18, Java 17 배포 기준 레포를 직접 확인
로컬 체크아웃은 배포 기준이 아님 작업 브랜치의 매니페스트 각 레포가 기능 브랜치에 있었고, 컨테이너가 바인드 마운트해 브랜치 전환도 불가 GitLab API로 배포 브랜치의 원격 ref를 직접 읽음
Python 서비스에 잠금 파일이 없음 pyproject의 직접 의존성만 urllib3, certifi, jinja2 같은 전이 의존성이 빠짐 빌드와 같은 Python 컨테이너에서 의존성 트리를 펼침

첫 번째 함정은 그대로 갔다면 SSO의 스택 자체를 틀리게 제출할 뻔한 것이었습니다. 배포 매니페스트를 먼저 보고 나서야 레포가 다르다는 것을 알았습니다. 두 번째는 로컬에서 바로 읽는 것이 가장 빠른 길이었지만, 작업 중인 기능 브랜치의 목록은 은행에 배포된 것과 다릅니다. 세 번째는 pip install --dry-run --ignore-installed --report로 해결했습니다. 빌드와 같은 Python 3.14 컨테이너에서 실행해 해석 결과만 JSON으로 받고, 실제 설치는 일어나지 않습니다.

생성기를 만들며 부딪힌 것

해석은 한 번에 되지 않았습니다. 워크플로우 서비스가 요구하는 문서 처리 패키지의 버전이 PyPI에 공개되지 않아 pip 해석이 실패했습니다. 레포는 그 버전을 vendor wheel로 들고 있었으므로, 그 wheel이 선언한 의존성 7개로 치환해 해석하고 패키지 자체는 vendor wheel 출처로 따로 기록했습니다. 치환은 생성기의 규칙 테이블로 두어 다음 갱신에도 재현되게 했습니다.

Java 전이 트리는 gradle dependencies가 정답이지만, 작업 머신에서 Docker Hub가 TLS handshake timeout으로 막혀 Gradle 이미지를 받을 수 없었습니다. Maven Central은 열려 있어서 Spring Boot BOM과 각 아티팩트의 POM을 직접 읽어 트리를 펼쳤습니다. 이 과정에서 POM 해석 버그 두 개를 잡았습니다.

첫째는 ${project.version} 오염입니다. Spring Boot BOM은 log4j BOM을 import 스코프로 끌어오는데, 그 안의 버전 표기가 ${project.version}입니다. properties를 하나의 dict로 공유하며 재귀하면 바깥 BOM의 project.version(2.7.18)이 먼저 들어가, log4j-api가 2.17.2가 아니라 2.7.18로 나왔습니다. import되는 BOM마다 자기 project.version으로 풀도록 분리했습니다. 둘째는 정규식의 \s*가 개행을 삼킨 것입니다. 플러그인 선언을 읽는 패턴의 뒤쪽 \s*가 다음 줄까지 먹어, Spring Boot 플러그인 버전이 적힌 줄이 통째로 건너뛰어졌습니다. 그 결과 Spring Boot 버전을 못 잡아 Java 전이 의존성이 0건이었습니다. 줄 안의 공백만 허용하도록 [ \t]*로 바꿨습니다.

해석 결과는 Spring Boot 2.7.18의 관리 버전과 대조해 검증했습니다. tomcat-embed-core 9.0.83, jackson-databind 2.13.5, logback 1.2.12, snakeyaml 1.30, spring-core 5.3.31로 일치했습니다. 테스트 스코프 의존성 3건은 배포 jar에 들어가지 않으므로 제외했습니다.

산출물은 은행 제출용 xlsx와 CycloneDX 1.5 형식의 SBOM 두 가지로 냈습니다. SBOM은 보안 담당이 쓰던 기존 SBOM과 같은 형식으로 맞추고 데이터만 최신으로 갱신했으며, xlsx를 정본으로 변환합니다. 생성기에는 전이 의존성 재해석, 이전 버전과의 변경분만 뽑는 비교, 작성일 고정 옵션을 넣었습니다. 작성일 고정은 자정을 넘겨 다시 뽑으면 같은 자료가 날짜만 다른 두 벌이 되는 문제 때문이었습니다. 담당자가 xlsx를 직접 고친 두 가지(시트 안내 라벨 행 제거, 버전 열과 중복되는 == 고정 선언 삭제)도 생성기 규칙에 반영해, 다음 갱신 때 같은 편집을 반복하지 않게 했습니다.

정적으로 잡을 수 없는 항목도 명시했습니다. 범위(>=)로 선언된 패키지는 빌드 시점에 따라 버전이 달라지고, MCP 관리 서비스가 런타임에 npx로 받는 MCP 서버 패키지는 설치 시점이 곧 실행 시점이며, 베이스 이미지의 OS 패키지는 이미지 스캐너로 따로 봐야 합니다. SSO가 반입한 상용 jar 5종은 공개 CVE 피드에 잡히지 않습니다.

기준표 대조와 업그레이드 파급 분석

기준표의 소프트웨어를 인벤토리, 베이스 이미지 Dockerfile, 헬름 차트, 운영 인수인계 문서와 하나씩 대조했습니다.

기준표 소프트웨어 취약 범위 배포본 판정
vLLM 0.24.0 미만 (27건, CRITICAL 4) 0.20.1 취약 확정, 조치 필요
llama.cpp b8492 미만 (4건) 버전 고정 없이 최신 소스를 받아 빌드 반영됐을 가능성은 높으나 버전 증빙 불가
Qdrant 1.16.0 이하 (1건) 서버는 헬름 차트 기준 v1.17.0 범위 밖, 현장 실버전 확인
Ollama, SGLang, LiteLLM, ChromaDB 등 - 인벤토리·Dockerfile에 없음 해당 없음

llama.cpp는 베이스 Dockerfile 커밋 시점이 b8492 릴리즈보다 한참 뒤라 수정이 반영됐을 가능성이 높았지만, 버전이 고정돼 있지 않아 증빙할 수 없다는 것 자체가 문제였습니다. 현장에서 llama-server --version 한 줄로 확인하고, 다음 베이스 빌드부터 태그를 고정하도록 했습니다. 운영 문서상 llama.cpp 서버는 쓰지는 않지만 이미지에 실려 있는 상태였고, 점검 대상에는 포함했습니다.

vLLM은 업그레이드가 필요했습니다. 먼저 코드 결합도를 봤습니다. 모델 서빙 서비스의 vLLM 어댑터는 import vllm이 런타임 코드에 한 줄도 없었습니다. 서브프로세스로 vllm serve를 띄우고, 기동 감지는 로그 파싱이 아니라 /health 폴링이며, 조회는 /health와 /v1/models 둘뿐입니다. 종료는 프로세스 그룹 단위 SIGTERM과 SIGKILL이고, GPU 지정은 어댑터가 CUDA_VISIBLE_DEVICES를 외부에서 주입합니다. 0.24부터 vLLM이 이 환경 변수를 내부에서 설정하지 않게 바뀌었지만, 외부 주입 방식이라 영향이 없었습니다.

어댑터가 넘기는 실행 플래그를 vLLM 소스와 대조했습니다. 0.27에서 제거된 인자와 0.24에서 제거된 모델 중 쓰는 것은 없었습니다. 툴콜 파서 이름 qwen3_coder는 vLLM 소스의 파서 등록부에서 새 Qwen3 파서 엔진의 별칭으로 유지되고 있었습니다. 문서에는 다른 이름만 표기돼 있어 문서만 봤다면 이름을 바꿀 뻔했습니다. chat template 인자도 소스에 살아 있었습니다.

그래서 위험을 코드가 아니라 런타임 세 가지로 좁혔습니다. 첫째, 툴콜입니다. 0.24부터 파서 계층이 Streaming Parser Engine으로 교체됐으므로, 별칭이 유지돼도 에이전트의 핵심 경로인 툴콜은 스모크 테스트가 필수입니다. 둘째, 임베딩입니다. 임베딩 실행 인자가 유지되고 모델이 같아 벡터 재색인은 필요 없지만, 응답과 차원은 확인해야 합니다. 셋째, GPU 메모리입니다. 한 GPU에 LLM 0.85와 임베딩 0.15를 할당해 합계가 1.0으로 여유가 없었고, 버전업으로 컴파일·활성화 메모리 프로파일이 달라질 수 있어 OOM 마진을 다시 확인해야 했습니다. 0.27은 PyTorch 2.13, Triton 3.7.1, Transformers 5.14로 의존성 환경이 바뀌는 공식 breaking change가 있어 베이스 이미지 재빌드도 필수였습니다.

CUDA 호환에서는 처음 판단이 틀렸습니다. 첫 분석에서는 베이스 Dockerfile 주석을 근거로 호스트 CUDA를 13.2로 보고 "드라이버가 더 최신이라 호환"이라고 적었습니다. 인프라 담당의 실측 회신은 드라이버 595.58.03, CUDA 13.0이었고, 주석의 13.2는 낡은 값이었습니다. 다시 따져 보니 vLLM 휠은 cu128, cu129, cu130 세 종류뿐이라 모두 13.0 이하로 드라이버 지원 범위 안이었고, 0.20.1도 0.27.1도 PyPI 기본이 cu130 빌드라 드라이버 요구 수준이 올라가지 않았습니다. 무엇보다 현행 운영이 이미 같은 계열로 돌고 있다는 것이 가장 강한 근거였습니다. 결론은 같았지만, 근거는 주석이 아니라 실측이어야 했습니다.

마지막으로 vLLM 이슈 트래커, 레딧, Hugging Face 토론에서 0.24~0.27 구간의 실사용 보고를 훑어, 걸리는 이슈마다 은행의 사용 조건과 대조했습니다.

발견 이슈 발동 조건 은행 조건 판정
reasoning 파서가 답변 전체를 reasoning으로 처리 커스텀 chat template + reasoning 파서 + think 블록을 미리 닫은 프롬프트 운영 파라미터에 reasoning 파서·커스텀 템플릿 없음 미발동 추정, 관리 UI로 켤 수 있어 사전 확인
새 툴 파서가 think 종료 태그를 삼킴 툴 파서와 thinking 동시 사용 일부 같은 엔진 별칭 + thinking 사용 스모크 필수 근거, 기동 불능이 아닌 파싱 품질 문제
Qwen 27B 계열 툴콜 실패 Responses API + MTP(투기적 디코딩) chat completions만, MTP 미사용 미발동
업그레이드 후 OOM GPU 메모리 할당이 빠듯한 구성 0.85 + 0.15 = 1.0 OOM 마진 재검증 필수

판단: 어느 버전으로, 무엇을 근거로

선택지 장점 단점 판단
0.20.1 유지 변경 없음 기준표 취약 범위(CRITICAL 4건 포함 27건) 불가
0.24.0 (기준 충족 최소선) 보안 기준 충족, 변경 폭이 상대적으로 작음 새 파서 엔진은 0.24부터라 파서 위험은 같은 계열 폴백으로 둠
0.27.1 (당시 최신 수정판) 보안 기준 충족, 이후 수정분 포함 torch 2.13 등 스택 변경으로 베이스 재빌드 필수 권고

0.24.0으로 내리는 안은 직관적으로 안전해 보였지만, 가장 큰 위험인 파서 엔진 교체가 0.24부터 시작됐기 때문에 버전 선택으로는 회피할 수 없었습니다. 릴리즈 흐름을 보면 0.24.0(6월 29일)부터 0.27.0(8월 10일)까지 이어졌고, 0.27.1은 하루 만에 나온 수정판이었습니다. 어차피 스모크 테스트로 확인해야 하는 위험이라면 최신 수정판으로 가는 것이 맞다고 판단했습니다. 권고에는 세 가지 보강을 더했습니다. 업그레이드 전에 관리 UI의 모델 설정에서 reasoning 파서, 커스텀 chat template 사용 여부를 확인하고, 개발계 스모크에 "thinking ON 상태의 툴콜"과 "thinking OFF 요청" 두 케이스를 넣고, 툴콜 파싱이 실패하면 0.24.0으로 낮춰 다시 시험하는 것입니다. llama.cpp는 같은 재빌드에서 b10424 태그로 고정하도록 권고했고, 어댑터가 쓰는 플래그가 신형 문법과 맞는지도 확인했습니다.

결과

지표 이전 이후 측정 조건
라이브러리 인벤토리 없음 1,742건 (8개 서비스 직접 + 전이) 배포 브랜치 원격 ref 기준, 최초 작성
배포 jar 미포함 항목 포함 테스트 스코프 3건 제외 Spring Boot 관리 버전 대조 후
vLLM 0.20.1 0.27.1 권고 후 베이스 이미지 고정 배포
llama.cpp 버전 고정 없음 b10424 고정 같은 베이스 이미지
모델 어댑터 코드 변경 - 주석 1줄 고객 배포 라인 커밋 확인
재생성 목록 - 1,801건, 변경 시트 87행 (추가 61, 버전 변경 26) 업그레이드 반영 후 재생성

권고 후 베이스 이미지가 vLLM 0.27.1과 llama.cpp b10424로 고정 배포됐고, 모델 서빙 서비스의 어댑터 변경이 주석 1줄뿐이라 무수정 호환 판단과 일치했습니다. 재생성한 목록의 변경 시트에는 vLLM 0.20.1 → 0.27.1, llama.cpp 무고정 → b10424, CUDA 베이스 13.0.2 → 13.0.3이 반영됐고, 은행에는 변경 시트만 제출하면 되도록 했습니다. 업그레이드 후 모델 로드 테스트 절차(이미지 라벨 증빙, LLM·임베딩 로드, 헬스 체크, 툴콜 1회, 검색 1회, 동시 로드 후 GPU 여유 메모리)도 정리해 전달했습니다.

회고와 남은 과제

놓친 것이 하나 있었습니다. 처음 대조할 때 인프라 쪽 베이스 Dockerfile만 보고, 모델 서빙 레포에 따로 있던 구식 Dockerfile(CUDA 12.8 devel을 직접 빌드하는 standalone 방식)을 보지 못했습니다. 배포 과정에서 이 파일을 인프라 베이스를 FROM하는 앱 전용 구조로 정렬하면서 드러났습니다. 인벤토리에서도 xlsx의 torch 버전(앱 잠금 파일 기준 2.10.0)이 vLLM이 베이스 이미지에 실제로 설치하는 런타임 torch(2.11)와 달랐습니다. "모델 서빙 런타임 스택은 베이스 이미지 기준"이라는 각주를 달았지만, 같은 서비스에 버전의 출처가 둘이라는 점은 다음 갱신에서도 조심해야 합니다.

남은 확인도 있습니다. 범위로 선언된 패키지 15건은 운영 파드의 pip freeze로만 확정할 수 있고, 상용 jar는 공급사 확인이 필요합니다. DB에 저장된 모델별 실행 설정에 구버전 전용 플래그가 남아 있는지는 코드 밖 데이터라 원격으로 검수할 수 없어 현장 확인 항목으로 넘겼고, 운영계 반영은 반입과 동기화를 기다리고 있습니다.

GPU 메모리 마진은 업그레이드 후 실측으로 재검증해야 합니다. 7월에 은행 개발계 대시보드를 분석했을 때도 임베딩 GPU가 설계값(0.15)의 6배인 91%를 점유하고 있어, 설정값만으로 메모리 여유를 판단할 수 없다는 것을 이미 본 적이 있습니다. 이번 작업 전체에서 얻은 기준은, 레포보다 배포 매니페스트를, 주석보다 실측을, 문서보다 소스를 먼저 본다는 것입니다. 세 번 모두 그 순서를 지켰을 때 판단이 바뀌거나 근거가 단단해졌습니다.