13년차의 서버실

서버, 인프라, 홈랩 기술 블로그

[카테고리:] ai

  • [AI] LangChain 오류 디버깅 전략과 실전 트러블슈팅

    [AI] LangChain 오류 디버깅 전략과 실전 트러블슈팅

    LangChain 오류 디버깅 전략과 실전 트러블슈팅

    LangChain 오류를 오래 들여다보면 한 가지 패턴이 보입니다. 실제 장애의 상당수는 “모델이 이상해서”가 아니라, 모델에 도달하기 전후의 계약이 깨져서 납니다. 입력 키가 하나 빠졌거나, 프롬프트 변수명이 바뀌었거나, retriever가 엉뚱한 문서를 가져왔거나, 출력 파서가 모델의 자연어 한 줄을 JSON으로 착각하는 식이죠.

    인프라 엔지니어 관점에서 보면 LangChain은 단순한 체인 문법이 아니라 API 서버, 큐, 환경변수, 네트워크, 관측성까지 이어지는 실행 파이프라인에 가깝습니다. 이 글은 작은 RAG(Retrieval-Augmented Generation, 검색 증강 생성) 앱을 운영하며 정리한 실전 기준입니다. “어제 되던 코드가 오늘 왜 안 되지?” 싶은 순간에 바로 확인할 수 있도록 명령어와 판단 기준을 함께 넣었습니다.

    LangChain 앱에서 입력, 프롬프트, 모델, 파서, 관측 도구가 어떻게 이어지는지 보여주는 개요 이미지입니다.

    1. LangChain 오류는 컴포넌트보다 계약부터 봐야 합니다

    LangChain은 Prompt Template(프롬프트 템플릿), Chat Model(채팅 모델), Retriever(검색기), Output Parser(출력 파서), Tool(도구)을 파이프처럼 이어줍니다. 여기서 중요한 건 각 단계가 서로 “어떤 입력을 받고 어떤 출력을 넘기는지”입니다. 저는 장애를 볼 때 컴포넌트 이름보다 계약 위반 여부를 먼저 봅니다.

    • 입력 계약 위반: 프롬프트가 요구하는 변수와 실제 payload 키가 다릅니다.
    • 타입 계약 위반: 다음 단계는 문자열을 기대하는데 앞 단계가 dict, list, Document 객체를 넘깁니다.
    • 의존성 계약 위반: 예전 예제의 import 경로와 현재 설치된 패키지 구조가 맞지 않습니다.
    • 인증·네트워크 계약 위반: API 키, 조직 설정, 프록시, 방화벽, timeout 설정이 실행 환경과 충돌합니다.
    • 출력 계약 위반: 모델 응답이 JSON, Pydantic 스키마, enum 값 같은 엄격한 형식을 따르지 않습니다.
    • 검색 품질 계약 위반: RAG에서 retriever가 질문과 관련 없는 문서를 가져오고, 모델은 그 문서 안에서만 그럴듯하게 답합니다.

    이 관점이 유용한 이유는 단순합니다. “LLM이 틀렸다”는 말은 범위가 너무 넓거든요. 반면 “prompt 입력 계약이 깨졌다”, “parser 출력 계약이 깨졌다”라고 말하면 바로 재현 코드와 테스트 케이스를 만들 수 있습니다.

    2. 재현 가능한 환경부터 고정하세요

    LangChain 디버깅을 시작할 때 제일 먼저 할 일은 코드 수정이 아니라 환경 스냅샷입니다. 특히 LangChain은 코어 패키지, 통합 패키지, 커뮤니티 패키지가 분리되어 있어 import 경로와 설치 패키지를 같이 봐야 합니다. OpenAI 연동을 쓴다면 공식 문서 기준으로 langchain-openai를 별도로 설치하는 방식이 일반적입니다.

    python -m venv .venv
    source .venv/bin/activate
    python -m pip install -U pip
    python -m pip install -U langchain langchain-core langchain-openai langsmith python-dotenv
    python -m pip check
    python -m pip freeze > requirements.lock.txt
    python -m pip show langchain langchain-core langchain-openai langsmith

    pip check는 의존성 충돌을 빠르게 확인할 때 좋습니다. 여기서 문제가 나오면 LangChain 코드를 보기 전에 패키지 상태부터 정리하는 편이 낫습니다. 운영 서버에서만 깨지는 오류라면 로컬과 서버에서 아래 명령 결과를 나란히 비교하세요.

    python --version
    python -m pip --version
    python -m pip show langchain langchain-core langchain-openai langsmith
    python - <<'PY'
    import importlib.metadata as m
    for name in ['langchain', 'langchain-core', 'langchain-openai', 'langsmith']:
        try:
            print(name, m.version(name))
        except m.PackageNotFoundError:
            print(name, 'NOT INSTALLED')
    PY

    튜토리얼을 따라 했는데 ModuleNotFoundError나 ImportError가 나면, 코드보다 먼저 “그 예제가 어느 시기의 패키지 구조를 기준으로 쓰였는지”를 보세요. 오래된 글에서 langchain.chat_models 계열 import를 복사했다면 현재 프로젝트의 공식 import 경로와 맞지 않을 수 있습니다. 이건 실력 문제가 아니라 패키지 분리의 흔적입니다.

    3. INVALID_PROMPT_INPUT를 일부러 재현해보기

    초보 단계에서 자주 만나는 LangChain 오류는 프롬프트 변수 누락입니다. LangChain의 INVALID_PROMPT_INPUT 오류는 프롬프트 템플릿이 요구하는 입력과 실제 전달한 값이 어긋날 때 발생합니다. 운영에서는 이게 더 교묘합니다. 프론트엔드에서는 question으로 보내는데 백엔드에서는 query로 넘기거나, 큐 메시지에 필드 하나가 빠지는 식이죠.

    from langchain_core.prompts import ChatPromptTemplate
    
    prompt = ChatPromptTemplate.from_messages([
        ('system', 'You are a helpful assistant.'),
        ('human', '{question}에 대해 {tone} 말투로 설명해줘'),
    ])
    
    print('required variables:', prompt.input_variables)
    
    # 일부러 tone을 빼서 오류를 재현합니다.
    messages = prompt.invoke({'question': 'LangChain 오류'})
    print(messages)

    실제로는 모델 호출 전에 payload를 검증해서 “모델 API 비용을 쓰기도 전에 실패”하게 만드는 편이 좋습니다. 오류를 늦게 발견할수록 로그가 지저분해지고 비용도 새기 쉽거든요.

    def validate_prompt_payload(prompt, payload: dict) -> None:
        required = set(prompt.input_variables)
        received = set(payload.keys())
        missing = required - received
        extra = received - required
    
        if missing:
            raise ValueError(f'Missing prompt variables: {sorted(missing)}')
        if extra:
            print(f'[debug] Extra payload keys ignored by prompt: {sorted(extra)}')
    
    payload = {'question': 'LangChain 디버깅', 'tone': '친절한'}
    validate_prompt_payload(prompt, payload)
    messages = prompt.invoke(payload)
    print(messages)

    실무 기준으로는 extra를 무조건 오류로 보지 않습니다. API 요청 전체에는 사용자 ID, trace ID, locale 같은 부가 필드가 들어갈 수 있으니까요. 다만 missing은 즉시 실패시키는 편이 낫습니다. 누락된 값을 빈 문자열로 채우면 당장은 지나가도 모델 응답 품질이 조용히 망가집니다.

    4. LCEL 체인은 예쁘게 쓰되, 디버깅은 끊어서 합니다

    LCEL(LangChain Expression Language)은 prompt | model | parser처럼 읽기 좋은 체인을 만들 수 있어서 생산성이 좋습니다. 문제는 한 줄로 연결한 체인이 실패하면 어느 단계에서 깨졌는지 한눈에 보이지 않는다는 점입니다. 장애 상황에서는 체인을 잠시 해체하세요. 각 연결부에서 실제 값이 무엇인지 보는 게 훨씬 빠릅니다.

    LangChain 디버깅을 위한 LCEL 체인 단계별 구성도

    프롬프트, 모델, 파서 단계를 나누어 어디서 입력과 출력이 바뀌는지 확인하는 구성도입니다.

    from langchain_core.prompts import ChatPromptTemplate
    from langchain_core.output_parsers import StrOutputParser
    from langchain_openai import ChatOpenAI
    
    prompt = ChatPromptTemplate.from_template('{topic}을 초보자에게 3문장으로 설명해줘.')
    model = ChatOpenAI(model='gpt-4o-mini', temperature=0, timeout=30, max_retries=2)
    parser = StrOutputParser()
    
    inputs = {'topic': 'LangChain 트러블슈팅'}
    
    prompt_value = prompt.invoke(inputs)
    print('[prompt type]', type(prompt_value))
    print('[prompt value]', prompt_value)
    
    model_result = model.invoke(prompt_value)
    print('[model type]', type(model_result))
    print('[model content]', model_result.content)
    
    parsed = parser.invoke(model_result)
    print('[parsed type]', type(parsed))
    print('[parsed]', parsed)

    temperature=0은 디버깅할 때 변동성을 줄이려는 선택입니다. 창의적인 답변을 평가하는 단계가 아니라 오류 재현 단계라면, 같은 입력에 최대한 비슷한 응답이 나오는 편이 원인 분석에 유리합니다. timeout과 max_retries는 운영에서 더 중요합니다. timeout이 없으면 요청이 오래 붙잡혀 API 서버 worker를 묶을 수 있고, retry가 과하면 장애 상황에서 외부 API를 더 세게 두드릴 수 있습니다.

    실패 위치 흔한 에러 신호 근본 원인 먼저 할 일 주의할 트레이드오프
    Prompt 필수 변수 누락, 템플릿 포맷 오류 payload 스키마와 템플릿 변수명 불일치 prompt.input_variables와 요청 body 비교 기본값으로 덮으면 조용한 품질 저하가 생길 수 있음
    Model 인증 실패, timeout, rate limit, 모델명 오류 환경변수, 네트워크, provider 설정 문제 API 키 존재 여부와 실제 모델명을 로그로 확인 retry를 늘리면 안정성은 오르지만 지연과 비용이 늘 수 있음
    Parser JSONDecodeError, validation error 자연어 응답을 엄격한 스키마로 해석 raw output을 저장하고 스키마와 비교 구조화 출력은 안정적이지만 provider 지원 여부를 확인해야 함
    Retriever 답변은 자연스럽지만 근거가 엉뚱함 청크, 임베딩, 검색 쿼리, 필터 조건 문제 검색된 문서 제목·점수·본문 일부 출력 top_k를 늘리면 recall은 오르지만 잡음과 토큰 사용량도 늘 수 있음
    Tool 도구 인자 validation 실패 모델이 tool schema와 다른 인자를 생성 tool call 원문과 schema 필수 필드 확인 스키마를 너무 엄격하게 만들면 재시도가 잦아질 수 있음

    5. 환경변수는 .env와 런타임 값을 분리해서 봅니다

    LLM 앱에서 인증 오류는 의외로 단순한 곳에서 납니다. .env에는 키가 있는데 프로세스에는 로드되지 않았거나, Docker 컨테이너에는 다른 값이 들어갔거나, 스테이징 서버가 운영용 LangSmith 프로젝트로 trace를 보내는 식입니다. 로컬 개발에서는 python-dotenv가 편하지만, 운영에서는 플랫폼의 secret 관리 기능을 우선하세요.

    OPENAI_API_KEY=sk-여기에_실제_키를_넣지_말고_로컬에서만_관리
    LANGSMITH_API_KEY=lsv2_여기에_LANGSMITH_키
    LANGSMITH_TRACING=true
    LANGSMITH_PROJECT=langchain-debug-local
    from dotenv import load_dotenv
    import os
    
    load_dotenv()
    
    required_env = ['OPENAI_API_KEY']
    for key in required_env:
        if not os.getenv(key):
            raise RuntimeError(f'Missing required environment variable: {key}')
    
    print('LANGSMITH_TRACING=', os.getenv('LANGSMITH_TRACING', 'false'))
    print('LANGSMITH_PROJECT=', os.getenv('LANGSMITH_PROJECT', 'not-set'))

    운영 관점에서 LANGSMITH_PROJECT는 꼭 나누는 편이 좋습니다. 개발, 스테이징, 운영 trace가 섞이면 장애 분석 때 “어느 요청이 진짜 고객 요청인지”부터 다시 걸러야 합니다. 로그 비용과 보안 정책도 같이 봐야 합니다. 프롬프트나 검색 문서에 개인정보·계약 정보·내부 장애 내용이 들어갈 수 있다면 trace에 무엇을 남길지 팀 기준을 먼저 정해야 합니다.

    6. LangSmith trace는 print의 대체가 아니라 요청 단위 블랙박스입니다

    로컬에서 한 명이 테스트할 때는 print만으로도 충분합니다. 하지만 API 서버에서 동시에 여러 요청이 들어오면 stdout 로그는 금방 섞입니다. LangSmith 같은 관측성 도구를 붙이면 chain run, model call, parser 실행 흐름을 요청 단위 trace로 따라갈 수 있습니다. 이거 붙여두면 장애 회고 때 진짜 편하더라고요.

    • 입력: 사용자가 보낸 원문과 서버가 체인에 넘긴 값이 같은지 확인합니다.
    • 중간 출력: 프롬프트 완성본, 검색된 문서, 모델 raw response를 분리해서 봅니다.
    • 실패 지점: 전체 latency 중 어디에서 오래 걸렸고 어느 단계에서 예외가 났는지 봅니다.
    export OPENAI_API_KEY='여기에_API_키'
    export LANGSMITH_API_KEY='여기에_LANGSMITH_키'
    export LANGSMITH_TRACING='true'
    export LANGSMITH_PROJECT='langchain-debug-lab'
    python app.py

    print 로그를 완전히 버리라는 뜻은 아닙니다. 개발 초반에는 print + 단계별 invoke가 가장 빠릅니다. 다만 팀 단위 운영으로 넘어가면 trace ID를 HTTP 응답 헤더나 애플리케이션 로그에 같이 남기는 쪽이 좋습니다. 고객 문의, 서버 로그, LangSmith trace를 같은 ID로 묶을 수 있으면 원인 추적 속도가 확 달라집니다.

    상황 추천 도구 이유 쓰지 않아도 되는 경우
    개인 실험, 노트북 코드 print, 단계별 invoke 설정이 적고 즉시 확인 가능 동시 요청이 없고 실패 입력이 단순할 때
    FastAPI/Flask API 서버 구조화 로그 + trace ID 요청별 입력과 예외를 묶기 쉬움 일회성 데모라면 과할 수 있음
    팀 운영 서비스 LangSmith tracing 체인 내부 단계와 모델 호출을 시각적으로 추적 민감 데이터 로깅 정책을 정하지 못했다면 먼저 보류
    회귀 방지 pytest 재현 테스트 한 번 고친 오류가 다시 나는지 확인 모델 품질 평가처럼 정답이 유동적인 경우엔 별도 eval이 필요

    7. JSON 파싱 오류는 프롬프트만으로 해결하기 어렵습니다

    AI 프레임워크 오류 중 사람을 가장 오래 붙잡는 게 출력 파싱입니다. 모델은 자연어를 잘하지만, 애플리케이션은 종종 엄격한 JSON을 원합니다. 쉼표 하나, 따옴표 하나, enum 값 하나만 달라도 parser는 실패합니다. 제가 보는 선택지는 세 가지입니다.

    • 문자열 후처리: 빠르지만 취약합니다. 데모나 내부 도구에만 제한적으로 씁니다.
    • Output Parser: 프롬프트에 형식 지시를 넣고 파서로 검증합니다. 실패 시 raw output 저장이 중요합니다.
    • 구조화 출력: 모델 호출 단계에서 스키마를 강제합니다. 지원 모델과 provider를 확인해야 하지만 운영 안정성은 대체로 이쪽이 낫습니다.
    from typing import Literal
    from pydantic import BaseModel, Field
    from langchain_openai import ChatOpenAI
    
    class Ticket(BaseModel):
        title: str = Field(description='장애 제목')
        severity: Literal['low', 'medium', 'high'] = Field(description='장애 심각도')
        summary: str = Field(description='장애 요약')
    
    model = ChatOpenAI(model='gpt-4o-mini', temperature=0, timeout=30)
    structured_model = model.with_structured_output(Ticket)
    
    result = structured_model.invoke('결제 API가 간헐적으로 실패하고 있습니다. 영향 범위가 넓어 보입니다.')
    print(result)
    print(type(result))

    Literal을 쓴 이유는 단순합니다. severity가 critical, urgent, 높음처럼 제멋대로 늘어나면 downstream 시스템에서 다시 분기 오류가 납니다. 스키마를 좁게 잡으면 validation 실패는 늘 수 있지만, 잘못된 값이 조용히 DB에 저장되는 위험은 줄어듭니다. 운영에서는 둘 중 무엇이 더 비싼 장애인지 판단해야 합니다.

    from pydantic import ValidationError
    
    try:
        ticket = structured_model.invoke('DB 연결이 실패해서 로그인 API가 실패합니다.')
    except ValidationError as exc:
        print('[schema validation failed]')
        print(exc)
        # 운영에서는 원문 입력, raw 응답, trace_id를 함께 저장하는 쪽이 좋습니다.
        raise

    반대로 간단한 블로그 요약, 내부 메모 생성처럼 사람이 읽고 끝나는 작업이라면 엄격한 Pydantic 스키마가 오히려 과할 수 있습니다. 이럴 땐 StrOutputParser로 충분합니다. 기준은 “사람이 읽을 텍스트인가, 시스템이 소비할 데이터인가”입니다. 시스템이 소비한다면 구조화 출력이나 검증 레이어를 빼지 않는 게 맞습니다.

    8. RAG 오류는 모델보다 검색 결과부터 의심하세요

    문서 검색 챗봇을 만들 때 가장 많이 속는 부분이 RAG 품질 문제입니다. 답변은 매끄러운데 사실관계가 묘하게 틀릴 때가 있거든요. 처음엔 모델을 바꿔야 하나 싶지만, trace를 보면 retriever가 질문과 관련 없는 문서를 가져오는 경우가 꽤 많습니다. 모델은 받은 근거 안에서 열심히 답했을 뿐입니다.

    RAG에서 “틀린 답변”은 최소 세 종류로 나눠야 합니다. 첫째, 관련 문서를 못 찾은 검색 실패입니다. 둘째, 관련 문서는 찾았지만 청크가 잘려 핵심 문맥이 빠진 경우입니다. 셋째, 문서는 맞는데 프롬프트가 근거 밖 추론을 허용한 경우입니다. 이 셋은 대응이 다릅니다.

    def debug_documents(docs, limit: int = 3) -> None:
        for i, doc in enumerate(docs[:limit], start=1):
            source = doc.metadata.get('source', 'unknown')
            title = doc.metadata.get('title', 'no-title')
            text = doc.page_content.replace('\n', ' ')[:500]
            print(f'\n[doc {i}] source={source} title={title}')
            print(text)
    
    query = 'FastAPI에서 LangChain 체인이 timeout 날 때 어디를 봐야 하나요?'
    docs = retriever.invoke(query)
    debug_documents(docs)

    위 출력에서 질문과 상관없는 문서가 계속 나온다면 모델 파라미터를 만지기 전에 retriever 설정을 봐야 합니다. top_k를 늘리면 관련 문서를 포함할 가능성은 올라가지만, 무관한 문서도 같이 들어와 토큰 사용량과 혼선을 늘릴 수 있습니다. chunk size를 키우면 문맥은 보존되지만 검색 단위가 둔해질 수 있고, 너무 작게 자르면 답변에 필요한 전후 맥락이 사라질 수 있습니다. 그래서 실패 질문을 모아 회귀 테스트처럼 돌리는 게 낫습니다.

    증상 먼저 의심할 곳 확인 방법 추천 대응
    답변이 유창하지만 근거가 틀림 Retriever 검색된 문서 제목·본문 일부를 출력 쿼리 재작성, metadata filter, top_k 조정
    문서는 맞는데 답이 반쪽 Chunking 원문에서 앞뒤 문맥이 잘렸는지 비교 chunk size, overlap, 문서 분할 기준 재검토
    근거 밖 추측이 섞임 Prompt 프롬프트가 모르면 모른다고 하게 만드는지 확인 근거 기반 답변 규칙과 citation 요구 추가
    요청마다 품질 편차가 큼 모델 설정·검색 후보 같은 질문을 여러 번 실행해 raw context 비교 temperature 낮추기, 검색 결과 고정 테스트 작성

    9. 재시도와 timeout은 비용 증폭 장치이기도 합니다

    외부 모델 API를 쓰면 timeout, rate limit, 일시적인 네트워크 오류를 피할 수 없습니다. 그래서 max_retries를 켜는 건 합리적입니다. 다만 재시도는 공짜가 아닙니다. 실패 요청이 많을 때 retry가 겹치면 지연이 늘고, 일부 실패 모드에서는 비용도 커질 수 있습니다.

    환경 timeout retry 이유
    로컬 디버깅 짧게 낮게 빨리 실패해야 원인 확인이 쉬움
    사용자-facing API 제품 SLA에 맞춤 제한적으로 사용자 대기 시간과 성공률 사이 균형 필요
    배치 작업 상대적으로 길게 조금 더 허용 실시간 응답보다 완료율이 중요할 수 있음
    장애 재현 테스트 짧게 0 또는 낮게 재시도가 원래 오류를 가리지 않게 해야 함
    from langchain_openai import ChatOpenAI
    
    # 장애 재현용: 빨리 실패시키고 원인을 숨기지 않습니다.
    debug_model = ChatOpenAI(model='gpt-4o-mini', temperature=0, timeout=10, max_retries=0)
    
    # 일반 API용: 일시적 실패를 약간 흡수합니다.
    api_model = ChatOpenAI(model='gpt-4o-mini', temperature=0, timeout=30, max_retries=2)

    핵심은 모든 환경에 같은 모델 객체를 쓰지 않는 것입니다. 디버깅 모드에서는 오류를 선명하게 보고, 운영 모드에서는 사용자 경험을 보호해야 합니다. 같은 코드베이스라도 목적이 다르면 timeout과 retry 정책도 달라져야 합니다.

    10. 실패 입력은 테스트로 남겨야 다시 안 밟습니다

    LangChain 트러블슈팅을 하다 보면 그 자리에서 고치고 넘어가기 쉽습니다. 하지만 실패 입력을 테스트로 남기지 않으면 프롬프트를 조금 바꾼 날 같은 문제가 다시 돌아옵니다. 최소한 프롬프트 변수 검증, parser validation, retriever 결과 확인은 작은 테스트로 남겨두세요.

    # tests/test_prompt_contract.py
    import pytest
    from langchain_core.prompts import ChatPromptTemplate
    
    prompt = ChatPromptTemplate.from_template('{question}에 대해 {tone} 말투로 답해줘')
    
    def validate_prompt_payload(prompt, payload: dict) -> None:
        missing = set(prompt.input_variables) - set(payload.keys())
        if missing:
            raise ValueError(f'Missing prompt variables: {sorted(missing)}')
    
    def test_prompt_payload_requires_tone():
        with pytest.raises(ValueError):
            validate_prompt_payload(prompt, {'question': 'LangChain 오류'})
    
    def test_prompt_payload_accepts_required_keys():
        validate_prompt_payload(prompt, {'question': 'LangChain 오류', 'tone': '차분한'})
    python -m pytest -q tests/test_prompt_contract.py -s

    모델 호출까지 포함한 테스트는 더 신중해야 합니다. 외부 API 상태, rate limit, 비용, 모델 응답 변동성이 끼어들기 때문입니다. 계약 테스트는 로컬에서 빠르게 돌리고, 실제 모델 품질 평가는 별도 eval이나 스테이징 파이프라인으로 분리하는 편이 관리하기 쉽습니다.

    LangChain 오류 분석을 위한 LangSmith 추적 대시보드 예시

    요청별 trace, 체인 단계, 오류 발생 지점을 한눈에 확인하는 대시보드 예시입니다.

    11. LangChain 트러블슈팅 순서

    아래 순서는 장애를 볼 때 거의 습관처럼 쓰는 흐름입니다. 중요한 건 모델 호출을 맨 앞에 두지 않는 겁니다. 입력과 검색 결과가 깨진 상태에서 모델을 바꿔봐야 문제만 흐려집니다.

    1. 실패 입력을 고정합니다. 사용자 질문, request body, trace ID, 환경 이름을 한 묶음으로 저장합니다.
    2. 패키지와 import를 확인합니다. pip show, pip check, Python 버전을 비교합니다.
    3. 프롬프트 계약을 봅니다. prompt.input_variables와 payload 키를 비교합니다.
    4. LCEL 체인을 끊습니다. prompt.invoke, model.invoke, parser.invoke를 따로 실행합니다.
    5. RAG라면 검색 결과를 출력합니다. 문서 제목, source, 본문 앞부분을 직접 봅니다.
    6. raw output을 저장합니다. parser가 실패했다면 모델 원문 응답 없이는 원인 분석이 어렵습니다.
    7. timeout과 retry를 확인합니다. 재현 테스트에서는 retry를 낮춰 원래 오류를 가리지 않게 합니다.
    8. 고친 뒤 테스트를 남깁니다. 실패 payload를 최소 재현 케이스로 바꿔 회귀를 막습니다.

    이 순서대로 보면 “LangChain이 이상하다”는 막연한 느낌이 “프롬프트 변수 누락”, “retriever 후보 품질 문제”, “JSON schema validation 실패”처럼 처리 가능한 단위로 바뀝니다. 디버깅은 결국 이름 붙이기 싸움입니다.

    12. 자주 묻는 질문: LLM 앱 개발 문제 해결

    Q. LangChain 오류가 나면 가장 먼저 뭘 봐야 하나요?

    A. 에러 메시지 마지막 줄만 보지 말고 실패 단계를 먼저 나누세요. Prompt, Model, Parser, Retriever 중 어디서 깨졌는지 확인하면 해결 속도가 빨라집니다. 제일 빠른 방법은 LCEL 체인을 끊어서 각 단계의 입력·출력 타입을 출력하는 것입니다.

    Q. 버전 문제는 어떻게 줄이나요?

    A. 설치 직후 requirements.lock.txt를 남기고, 로컬·서버에서 python -m pip show langchain langchain-core langchain-openai langsmith 결과를 비교하세요. 오래된 블로그 예제의 import 경로를 그대로 쓰면 현재 패키지 구조와 맞지 않을 수 있습니다.

    Q. JSON 파싱 오류는 프롬프트를 고치면 충분한가요?

    A. 사람이 읽는 데모라면 프롬프트 보강으로도 버틸 수 있습니다. 하지만 시스템이 JSON을 소비한다면 Pydantic 스키마나 구조화 출력을 우선 검토하세요. 프롬프트만으로 형식을 강제하면 모델 응답 변동에 약합니다.

    Q. LangSmith는 언제 붙이는 게 좋나요?

    A. 개인 실험 단계에서는 필수는 아닙니다. API 서버로 여러 요청을 받거나 팀에서 장애를 같이 봐야 한다면 붙이는 편이 좋습니다. 단, trace에 민감 데이터가 남을 수 있으니 로깅 정책을 먼저 정해야 합니다.

    Q. RAG 답변이 이상하면 모델을 바꾸는 게 먼저인가요?

    A. 보통은 아닙니다. 검색된 문서가 질문과 관련 있는지 먼저 확인하세요. retriever가 엉뚱한 문서를 가져오면 좋은 모델도 그 문서 안에서 그럴듯한 오답을 만들 수 있습니다.

    13. 상황별 추천: 이럴 땐 이렇게 가세요

    LangChain 오류를 줄이는 핵심은 전체 체인을 한 번에 믿지 않는 것입니다. 단계별 계약을 확인하고, 실패 입력을 저장하고, 고친 뒤 테스트로 남기면 같은 문제를 반복해서 밟을 가능성이 확 줄어듭니다. 관련 구현 예제가 더 필요하다면 블로그의 RAG 구축 글, FastAPI 배포 글, LLM 관측성 글을 함께 연결해 내부 링크로 안내하면 좋습니다.

    • 처음 개발 중이라면: print + 단계별 invoke로 충분합니다. 복잡한 도구보다 빠른 재현이 우선입니다.
    • 프롬프트 오류가 잦다면: payload 검증 함수를 모델 호출 전에 두세요. 누락 키를 조기에 실패시키는 게 비용과 시간을 아낍니다.
    • API 서버로 운영한다면: timeout, retry, trace ID, LangSmith tracing을 같이 설계하세요. 관측성 없이 운영하면 장애 때 감으로 뒤지게 됩니다.
    • JSON 결과가 중요하다면: 문자열 파싱보다 구조화 출력 또는 Pydantic 검증을 우선하세요. 시스템이 소비하는 데이터는 느슨하게 두면 나중에 더 비싸게 터집니다.
    • RAG 품질이 문제라면: 모델 교체 전에 retriever 결과와 청크 전략을 확인하세요. 검색이 틀리면 생성도 흔들립니다.
    • 장애 재현 중이라면: retry를 낮추고 temperature를 낮춰 오류를 선명하게 보세요. 운영 안정화 설정과 디버깅 설정은 달라도 됩니다.

    기본값은 단순합니다. 개인 개발은 빠르게 재현하고, 팀 운영은 관측 가능하게 만들고, 시스템 연동은 스키마를 엄격하게 가져가세요. LangChain은 체인을 빠르게 만들게 해주지만, 안정적인 LLM 앱은 결국 각 단계의 입력과 출력을 얼마나 차분하게 검증하느냐에 달려 있습니다.

    LangChain 트러블슈팅과 디버깅 전략 요약 인포그래픽

    입력, 체인, 모델, 파서, 관측성 기준으로 디버깅 우선순위를 요약한 이미지입니다.

    참고한 공식 문서

  • [AI] 로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    [AI] 로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    로컬 LLM 성능: vLLM 벤치마크 측정 방법론

    로컬 LLM 성능은 GPU 이름보다 측정 방식에서 갈립니다

    로컬 LLM 성능을 비교할 때 가장 위험한 문장은 ‘이 GPU면 충분히 빠르겠지’입니다. 실제 운영에 가까워질수록 병목은 GPU 코어 하나로 설명되지 않거든요. 프롬프트를 읽는 prefill, 토큰을 하나씩 생성하는 decode, KV cache가 차지하는 메모리, tokenizer와 HTTP 클라이언트, 동시 요청 스케줄링이 같이 움직입니다.

    저는 벤치마크를 볼 때 숫자 하나만 믿지 않습니다. 초당 토큰 수가 좋아도 첫 토큰이 늦으면 채팅 UX는 답답합니다. 반대로 TTFT가 괜찮아도 동시 요청을 올렸을 때 p95 지연 시간이 크게 튀면 내부 서비스로 쓰기 불안하더라고요.

    이 글은 vLLM을 띄우는 법을 소개하는 데서 멈추지 않습니다. 어떤 값을 고정하고, 무엇을 바꿔야 비교가 성립하는지, vLLM 옵션이 성능·비용·안정성에 어떤 영향을 주는지, 결과를 보고 운영선을 어떻게 잡는지까지 정리합니다. 특정 GPU의 성능 수치는 지어내지 않겠습니다. 대신 여러분 장비에서 재현할 수 있는 로컬 LLM 성능 측정 절차와 판단 기준을 남기겠습니다.

    로컬 LLM 성능 벤치마크 전체 아키텍처 다이어그램

    로컬 GPU 서버에서 vLLM API 서버를 띄우고, 벤치마크 클라이언트가 요청을 보내며, GPU/시스템 지표를 함께 수집하는 흐름입니다.

    vLLM 벤치마크 전에 성능 지표를 운영 관점으로 잡기

    vLLM은 OpenAI 호환 API 서버로 사용할 수 있는 LLM 추론 엔진입니다. 핵심은 여러 요청을 효율적으로 스케줄링하고, KV cache를 관리하며, 서버형 추론에서 처리량을 끌어올리는 데 있습니다. 여기서 성능 지표는 사전식 정의보다 ‘어떤 장애를 미리 알려주는가’로 보는 편이 훨씬 쓸모 있습니다.

    • TTFT(Time To First Token): 사용자가 요청한 뒤 첫 토큰이 나오기까지의 시간입니다. 길면 모델이 똑똑해도 느리게 느껴집니다. 긴 입력, prefill 부하, 큐 대기 시간이 주로 영향을 줍니다.
    • TPOT(Time Per Output Token): 첫 토큰 이후 출력 토큰 하나를 생성하는 평균 시간입니다. 답변이 길어질수록 체감 속도를 좌우합니다.
    • ITL(Inter Token Latency): 토큰 사이 간격입니다. 스트리밍 응답이 뚝뚝 끊기는지 확인할 때 봅니다.
    • E2E Latency: 요청부터 최종 응답 완료까지의 전체 시간입니다. 배치 요약, 문서 처리, 자동화 워크플로에서는 이 값이 더 중요할 수 있습니다.
    • Throughput: 초당 처리한 요청 수 또는 토큰 수입니다. 사용자 수를 늘릴 때 비용 효율을 판단하는 기준입니다.
    • Concurrency: 동시에 처리 중인 요청 수입니다. vLLM의 장점은 보통 단일 요청보다 이 구간에서 더 잘 드러납니다.
    • GPU Memory: 모델 가중치, KV cache, CUDA graph, 런타임 오버헤드가 함께 차지하는 메모리입니다. OOM은 대개 모델 크기 하나가 아니라 컨텍스트 길이와 동시성이 합쳐져 발생합니다.

    현장에서 가장 많이 본 착시는 ‘초당 토큰이 높으니 빠르다’는 판단입니다. 문서 요약처럼 입력이 긴 워크로드는 prefill 비용이 커서 TTFT가 먼저 무너질 수 있습니다. 반대로 짧은 채팅을 많이 받는 서비스는 decode보다 큐 대기와 스케줄링이 더 큰 문제가 되기도 합니다. 그래서 LLM 벤치마크는 항상 워크로드를 먼저 정하고 들어가야 합니다.

    LLM 벤치마크 환경은 README처럼 고정하세요

    LLM 벤치마크에서 비교가 깨지는 가장 흔한 이유는 실험 조건이 매번 조금씩 달라지는 겁니다. 모델만 같아도 토크나이저 버전, dtype, max model length, 요청 분포, temperature, 스트리밍 여부가 바뀌면 숫자는 달라집니다. 실험을 시작하기 전에 아래 항목을 먼저 적어두세요. 이거 진짜 편합니다. 나중에 결과가 이상할 때 기록이 없으면 원인 추적이 거의 감으로 변하거든요.

    항목 기록할 내용 왜 중요한가
    vLLM 버전 <code>vllm –version, 설치 방식, 컨테이너 태그 CLI 옵션과 스케줄러 동작은 버전에 따라 바뀔 수 있습니다.
    GPU/드라이버 nvidia-smi 출력, CUDA 런타임, GPU 개수 메모리 용량과 커널 지원 여부가 추론 안정성에 직접 영향을 줍니다.
    모델 Hugging Face ID, 로컬 경로, revision, 양자화 여부 가중치와 토크나이저가 다르면 같은 이름처럼 보여도 비교가 아닙니다.
    입력 길이 평균/최대 prompt token, 테스트 데이터셋 긴 입력은 prefill 비용과 KV cache 사용량을 키웁니다.
    출력 길이 max_tokens, 고정 출력 길이 사용 여부 decode 성능을 비교하려면 출력 길이를 통제해야 합니다.
    동시성 1, 2, 4, 8, 16처럼 단계별 증가 처리량이 늘다가 지연만 커지는 지점을 찾아야 합니다.
    서버 옵션 dtype, max-model-len, gpu-memory-utilization, max-num-seqs 메모리, 큐잉, 처리량, OOM 위험이 여기서 크게 갈립니다.
    요청 방식 chat/completions 여부, streaming 여부, timeout 클라이언트 측 지연과 서버 처리 지연을 분리해 봐야 합니다.

    ‘테스트 데이터셋’이라는 말에 너무 겁먹을 필요는 없습니다. 사내 문서 요약용이면 실제 문서에서 민감정보를 제거한 샘플 30~100개, 코딩 보조용이면 짧은 질문·중간 길이 수정 요청·긴 파일 기반 요청을 나눠 준비하면 됩니다. 핵심은 매번 같은 입력 분포를 쓰는 것입니다.

    vLLM 서버는 옵션 파일로 남기세요

    설치는 환경 의존성이 큽니다. CUDA, PyTorch, 드라이버 조합은 공식 설치 문서를 확인하는 편이 안전합니다. 아래 명령은 NVIDIA GPU가 있는 Linux 환경에서 시작점을 잡는 예시로 보세요. 벤치마크 재현성을 위해 서버 실행 옵션은 셸 히스토리에만 남기지 말고 YAML 파일로 고정하는 걸 권합니다.

    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install --upgrade pip
    pip install vllm
    vllm --version
    python - <<'PY'
    import torch
    print('cuda_available=', torch.cuda.is_available())
    print('gpu_count=', torch.cuda.device_count())
    PY

    아래는 단일 GPU에서 시작할 때의 보수적인 예시입니다. 모델 경로는 여러분 환경에 맞게 바꾸세요. vLLM의 --config는 YAML 설정 파일을 읽을 수 있으며, 같은 값이 CLI와 설정 파일에 동시에 있으면 CLI 인자가 우선합니다. 그래서 실제 운영에서는 중복을 줄이고, 실험에서는 어떤 값이 적용됐는지 로그로 한 번 더 확인하는 습관이 좋습니다.

    # vllm-serve.yaml
    model: /models/your-llm-model
    host: 0.0.0.0
    port: 8000
    dtype: auto
    max_model_len: 4096
    gpu_memory_utilization: 0.85
    max_num_seqs: 16
    enable_prefix_caching: true
    disable_log_requests: true
    vllm serve --config vllm-serve.yaml

    max_model_len은 서버가 받아들일 입력+출력의 최대 토큰 길이입니다. 무작정 크게 잡으면 긴 문서를 받을 수는 있지만 KV cache 여유가 줄어 동시성이 줄고 OOM 가능성이 커집니다. gpu_memory_utilization은 vLLM이 모델 실행에 사용할 GPU 메모리 비율입니다. 높이면 더 큰 KV cache를 확보할 수 있지만, 드라이버·라이브러리·다른 프로세스가 쓸 여유 공간은 줄어듭니다.

    max_num_seqs는 한 번의 스케줄링 반복에서 처리할 수 있는 시퀀스 수의 상한으로 이해하면 쉽습니다. 짧은 채팅을 많이 받는 서버라면 늘려볼 가치가 있고, 긴 문서 요약처럼 요청 하나가 큰 경우에는 과하게 늘릴수록 지연 시간과 메모리 압박이 커질 수 있습니다. enable_prefix_caching은 같은 시스템 프롬프트나 반복 prefix가 많은 워크로드에서 효과를 기대할 수 있지만, 요청 prefix가 매번 완전히 다르면 체감이 작을 수 있습니다.

    vLLM API 서버와 벤치마크 클라이언트 구성도

    vLLM 서버 실행 옵션과 벤치마크 클라이언트가 연결되는 구조를 한눈에 볼 수 있는 구성도입니다.

    추론 성능 측정은 워밍업, 단일 요청, 동시성 순서로 갑니다

    서버가 떠도 바로 측정값을 믿으면 안 됩니다. 첫 요청에는 모델 로딩 이후 남은 초기화, CUDA kernel 준비, 캐시 관련 비용이 섞일 수 있습니다. 저는 보통 헬스 체크, 워밍업, 단일 요청 확인, 동시성 테스트 순서로 갑니다. 이 순서를 지키면 서버가 느린 건지, 첫 요청만 느린 건지, 클라이언트가 병목인지 분리하기 쉽습니다.

    curl -s http://127.0.0.1:8000/v1/models | jq .
    
    for i in 1 2 3; do
      curl -s http://127.0.0.1:8000/v1/chat/completions \
        -H 'Content-Type: application/json' \
        -d '{
          "model": "/models/your-llm-model",
          "messages": [{"role": "user", "content": "warmup"}],
          "max_tokens": 16,
          "temperature": 0
        }' > /dev/null
      echo warmup-$i-done
    done

    간단한 smoke test에서는 응답이 오는지만 보지 말고 usage 필드도 확인하세요. 프롬프트 토큰과 completion 토큰이 예상보다 크게 다르면 테스트 입력이 통제되지 않은 것입니다. 특히 한국어와 영어는 토크나이저에서 토큰 수가 다르게 나올 수 있으니, 같은 글자 수가 곧 같은 부하라는 착각을 피해야 합니다.

    curl -s http://127.0.0.1:8000/v1/chat/completions \
      -H 'Content-Type: application/json' \
      -d '{
        "model": "/models/your-llm-model",
        "messages": [
          {"role": "system", "content": "간결하게 답하세요."},
          {"role": "user", "content": "로컬 LLM 성능 측정에서 TTFT가 왜 중요한지 설명해줘."}
        ],
        "max_tokens": 128,
        "temperature": 0
      }' | jq '{id, usage, finish_reason: .choices[0].finish_reason}'

    직접 만든 부하 스크립트도 유용합니다. 아래 코드는 스트리밍 응답에서 첫 chunk가 도착하는 시점과 전체 완료 시간을 분리해 기록합니다. 전문 도구를 대체하진 않지만, TTFT와 E2E가 왜 다르게 움직이는지 눈으로 확인하기 좋습니다.

    import asyncio
    import json
    import time
    import httpx
    
    URL = 'http://127.0.0.1:8000/v1/chat/completions'
    MODEL = '/models/your-llm-model'
    
    payload = {
        'model': MODEL,
        'messages': [
            {'role': 'user', 'content': 'vLLM으로 로컬 LLM 성능을 측정하는 기준을 설명해줘.'}
        ],
        'max_tokens': 128,
        'temperature': 0,
        'stream': True,
    }
    
    async def one_request(client, idx):
        start = time.perf_counter()
        first_chunk_at = None
        chunks = 0
        async with client.stream('POST', URL, json=payload, timeout=120) as response:
            response.raise_for_status()
            async for line in response.aiter_lines():
                if not line.startswith('data: '):
                    continue
                data = line.removeprefix('data: ').strip()
                if data == '[DONE]':
                    break
                chunks += 1
                if first_chunk_at is None:
                    first_chunk_at = time.perf_counter()
        end = time.perf_counter()
        ttft = None if first_chunk_at is None else first_chunk_at - start
        return {'request': idx, 'ttft_sec': ttft, 'e2e_sec': end - start, 'chunks': chunks}
    
    async def run(concurrency):
        limits = httpx.Limits(max_connections=concurrency, max_keepalive_connections=concurrency)
        async with httpx.AsyncClient(limits=limits) as client:
            results = await asyncio.gather(*(one_request(client, i) for i in range(concurrency)))
        for item in results:
            print(json.dumps(item, ensure_ascii=False))
    
    if __name__ == '__main__':
        asyncio.run(run(concurrency=4))

    이 스크립트를 concurrency=1, 2, 4, 8로 바꿔가며 돌려보면 패턴이 보입니다. TTFT만 급격히 늘면 큐 대기나 prefill 경쟁을 의심하고, TTFT는 괜찮은데 E2E가 길어지면 decode 구간의 경쟁이나 출력 길이 분포를 봅니다. 에러가 섞이면 평균값은 잠깐 내려놓고 실패율부터 봐야 합니다.

    vLLM 내장 벤치마크로 AI 성능 비교 기준선 만들기

    직접 만든 스크립트는 원인을 좁히는 데 좋고, 반복 가능한 비교에는 vLLM의 벤치마크 명령이 편합니다. 버전에 따라 세부 옵션은 달라질 수 있으니 vllm bench serve --help를 먼저 확인하세요. 핵심은 backend, endpoint, model, dataset 또는 synthetic 입력 조건, 동시성, 프롬프트 수를 명시하는 것입니다.

    vllm bench serve --help
    
    vllm bench serve \
      --backend openai-chat \
      --base-url http://127.0.0.1:8000 \
      --endpoint /v1/chat/completions \
      --model /models/your-llm-model \
      --num-prompts 100 \
      --max-concurrency 4 \
      --percentile-metrics ttft,tpot,itl,e2el

    여기서 주의할 점은 ‘벤치마크 클라이언트에서 측정한 지연 시간’이라는 사실입니다. 서버 내부 처리 시간, 네트워크, 클라이언트 이벤트 루프, JSON 파싱이 한 덩어리로 섞일 수 있습니다. 그래서 같은 서버에서 한 번, 다른 머신에서 한 번, 가능하면 클라이언트 CPU 사용률까지 같이 보는 편이 좋습니다.

    데이터셋 기반 테스트를 할 때는 입력 길이 분포를 꼭 기록하세요. 평균 토큰만 보면 안 됩니다. p95 입력 길이가 긴 데이터셋은 짧은 요청 다수보다 훨씬 거칠게 서버를 흔듭니다. 특히 문서 요약형 워크로드는 평균보다 꼬리가 문제입니다. 운영 장애는 보통 평균 요청이 아니라 긴 요청 몇 개가 큐를 막을 때 시작됩니다.

    로컬 LLM 성능 테스트에서 자주 보이는 실패 모드

    OOM과 느린 첫 응답은 표면 증상입니다. 같은 OOM이라도 원인은 모델이 너무 큰 경우, 컨텍스트를 과하게 연 경우, 동시성을 너무 높인 경우, logprobs 같은 옵션이 메모리를 더 쓰는 경우로 나뉩니다. 해결책도 다릅니다. 무조건 작은 모델로 내리면 문제는 사라지지만, 품질과 비용의 균형점을 찾을 기회를 잃습니다.

    watch -n 1 nvidia-smi
    
    nvidia-smi --query-gpu=timestamp,name,index,utilization.gpu,utilization.memory,memory.used,memory.total,power.draw \
      --format=csv -l 1
    증상 가능성이 높은 근본 원인 먼저 볼 것 우선 조치
    첫 요청만 유독 느림 워밍업 부족, 커널 초기화, 캐시 미형성 워밍업 전후 TTFT 차이 측정 전 워밍업 요청을 제외하고 기록합니다.
    동시성 증가 시 TTFT가 먼저 튐 큐 대기, 긴 prefill 경쟁, max_num_seqs 과다 입력 토큰 분포, p95 TTFT 긴 입력과 짧은 입력을 분리 측정하고 동시성 상한을 낮춥니다.
    출력 중간부터 느려짐 decode 병목, 긴 출력 요청 혼재 max_tokens, 실제 completion token 출력 길이를 고정한 테스트와 실제 분포 테스트를 따로 돌립니다.
    GPU 메모리가 가득 찬 뒤 OOM KV cache 증가, 과한 max_model_len, 동시 요청 과다 memory.used 추이, 요청별 입력 길이 max_model_len, max_num_seqs, 동시성을 순서대로 줄입니다.
    GPU 사용률이 낮은데 응답이 느림 클라이언트 병목, tokenizer/CPU 병목, 연결 재사용 실패 클라이언트 CPU, HTTP keep-alive, 서버 로그 비동기 클라이언트, 연결 풀, 클라이언트 분리 실행을 확인합니다.
    평균은 좋은데 p95가 나쁨 긴 요청 꼬리, 큐 대기 증가 요청 길이 p50/p95, 실패 요청 로그 워크로드를 길이별로 나누고 운영 한계는 평균이 아니라 p95로 잡습니다.

    운영에서는 ‘성능을 더 뽑는 옵션’보다 ‘실패할 때 예측 가능한 옵션’이 더 가치 있을 때가 많습니다. 예를 들어 gpu_memory_utilization을 올리면 실험실 처리량은 좋아질 수 있지만, 같은 GPU에서 모니터링 에이전트나 다른 프로세스가 함께 돌면 여유 메모리가 부족해질 수 있습니다. 벤치마크가 목적이면 한계를 밀어보고, 서비스가 목적이면 일부러 여유를 남기는 선택이 맞습니다.

    검증과 결과 해석: 평균값보다 꺾이는 지점을 찾으세요

    추론 성능 측정에서 제가 가장 신뢰하는 그림은 동시성별 TTFT, TPOT, E2E latency, tokens/sec, 실패율을 한 표에 놓은 것입니다. 평균만 있으면 위험합니다. 최소한 p50과 p95를 같이 보세요. p50은 평소 체감, p95는 불만이 시작되는 구간에 가깝습니다.

    1. 단일 요청에서 TTFT가 제품 UX에 맞는가?
    2. 동시성을 올렸을 때 처리량이 증가하는 구간과 지연만 늘어나는 구간이 어디인가?
    3. 먼저 오는 한계가 GPU 메모리인지, decode 처리량인지, 클라이언트 병목인지 구분했는가?
    4. 실패율이 0에 가까운 구간과 단순히 평균이 좋은 구간이 같은가?

    예를 들어 내부 문서 요약 챗봇을 만든다고 해보겠습니다. 사용자는 한 번에 긴 문서를 넣고 답변을 기다립니다. 이 경우 짧은 프롬프트로 초당 토큰 수만 재면 거의 도움이 안 됩니다. 2천 토큰, 8천 토큰, 16천 토큰처럼 입력 길이를 나누고, 출력 길이는 고정한 뒤 TTFT와 E2E를 봐야 합니다. 반면 짧은 고객 응대 챗봇이면 입력 길이보다 동시 요청과 p95 TTFT가 더 중요합니다.

    vLLM 로컬 LLM 성능 벤치마크 결과 대시보드

    동시성 증가에 따른 응답 시간, 처리량, GPU 메모리 사용량을 함께 보여주는 결과 대시보드 이미지입니다.

    비교 목적 고정할 값 바꿀 값 판단 기준
    모델 후보 비교 프롬프트, max_tokens, 동시성, vLLM 옵션 모델 경로 또는 revision 품질 평가는 별도로 두고, 여기서는 지연·처리량·메모리만 비교합니다.
    동시성 한계 확인 모델, 입력 길이, 출력 길이 max-concurrency, 클라이언트 수 처리량 증가가 둔화되고 p95 지연이 급등하기 직전이 운영 후보입니다.
    긴 컨텍스트 영향 모델, 출력 길이, 동시성 입력 토큰 길이, max_model_len TTFT와 GPU 메모리가 함께 증가하는지 봅니다.
    서버 옵션 튜닝 모델, 테스트 데이터, 클라이언트 gpu_memory_utilization, max_num_seqs 평균 처리량보다 실패율과 p95 안정성을 우선합니다.
    prefix cache 효과 확인 시스템 프롬프트, 모델, 동시성 반복 prefix 여부, enable_prefix_caching 같은 prefix가 많은 워크로드에서만 의미 있게 비교합니다.

    설정값 선택 기준: 빠르게보다 맞게 고르는 게 먼저입니다

    vLLM 옵션은 레버처럼 움직입니다. 하나를 올리면 다른 쪽에서 비용을 냅니다. max_model_len을 키우면 긴 입력을 받을 수 있지만 KV cache 예산이 커지고, max_num_seqs를 키우면 동시 처리 가능성은 늘지만 요청 하나의 지연 시간이 튈 수 있습니다. 그래서 설정은 ‘최대 성능’이 아니라 ‘내 워크로드에 맞는 실패 방식’을 고르는 일에 가깝습니다.

    상황 추천 방향 피해야 할 선택
    개인 실험, 단일 사용자 작은 max_num_seqs, 필요한 만큼의 max_model_len, 단일 요청 TTFT 확인 운영도 안 할 긴 컨텍스트를 크게 열어 메모리를 낭비하는 것
    짧은 채팅을 여러 사용자가 사용 동시성 단계 테스트, p95 TTFT 기준 운영선 설정 평균 tokens/sec만 보고 동시성 상한을 높게 잡는 것
    긴 문서 요약 입력 길이 버킷별 측정, max_model_len 보수적 설정, 긴 요청 별도 큐 고려 짧은 프롬프트 벤치마크 결과를 긴 문서 처리에 적용하는 것
    동일 시스템 프롬프트 반복 enable_prefix_caching 효과를 별도 실험 prefix가 매번 다른데 캐시 효과를 기대하는 것
    한 GPU에 다른 프로세스도 실행 gpu_memory_utilization을 낮춰 여유 확보 벤치마크 순간 최대 처리량만 보고 메모리를 끝까지 쓰는 것

    제 기준의 기본값은 이렇습니다. 처음에는 max_model_len을 실제 요청 p95보다 약간 큰 수준으로 잡고, gpu_memory_utilization은 여유를 남긴 값에서 시작합니다. 그다음 동시성을 올리며 p95 TTFT와 실패율을 봅니다. 처리량이 조금 더 나오더라도 p95가 급격히 튀는 지점은 운영선으로 잡지 않습니다.

    자주 묻는 질문

    Q. vLLM만 쓰면 무조건 빨라지나요?

    아닙니다. vLLM은 서버형 추론, 특히 동시 요청 처리에서 강점이 큽니다. 하지만 단일 요청을 짧게 돌리는 실험에서는 차이가 작을 수 있고, 모델이 GPU 메모리에 빠듯하게 올라가는 환경에서는 설정을 잘못 잡으면 오히려 불안정해질 수 있습니다.

    Q. 로컬 LLM 성능 비교에서 가장 먼저 볼 지표는 뭔가요?

    채팅 UX라면 TTFT와 p95 TTFT를 먼저 봅니다. 배치 요약이나 내부 자동화라면 E2E latency와 처리량을 더 봅니다. 여러 사용자가 붙는 서비스라면 평균보다 실패율과 p95가 우선입니다.

    Q. 블로그에 있는 벤치마크 숫자를 믿어도 되나요?

    참고는 됩니다. 다만 그대로 의사결정에 쓰면 위험합니다. GPU, 드라이버, vLLM 버전, 모델 revision, 입력 길이, 출력 길이, 동시성, 스트리밍 여부가 다르면 결과가 바뀝니다. 같은 절차로 내 장비에서 다시 재는 것이 가장 확실합니다.

    Q. 양자화 모델은 항상 좋은 선택인가요?

    메모리 절감에는 유리할 수 있지만 품질, 지원 커널, decode 성능, 운영 안정성을 함께 봐야 합니다. ‘올라간다’와 ‘서비스하기 좋다’는 다릅니다. 양자화 여부를 바꿀 때는 모델 품질 평가와 추론 성능 평가를 분리하세요.

    Q. GPU 사용률이 낮으면 vLLM 설정 문제인가요?

    항상 그렇지는 않습니다. 클라이언트가 요청을 충분히 못 넣거나, CPU 토크나이징이 막히거나, 네트워크/HTTP 연결이 병목일 수 있습니다. 서버 옵션을 바꾸기 전에 클라이언트가 실제로 원하는 동시성을 만들고 있는지 확인하는 편이 빠릅니다.

    마지막 판단: 운영선은 가장 빠른 지점이 아니라 덜 흔들리는 지점입니다

    로컬 LLM 성능에서 중요한 질문은 ‘어떤 모델이 제일 빠른가’가 아니라 ‘내 서버가 어떤 요청 분포까지 예측 가능하게 버티는가’입니다. 저는 단일 최고 점수보다 반복 측정에서 비슷하게 나오는 구간을 더 신뢰합니다. 운영자는 평균값이 아니라 나쁜 날의 p95를 상대해야 하니까요.

    개인 실험용이면 작은 모델부터 시작해 TTFT와 GPU 메모리 여유를 확인하세요. 여러 사용자가 붙는 내부 서비스라면 vLLM 서버를 띄운 뒤 동시성별 p50/p95 TTFT, E2E latency, tokens/sec, 실패율을 함께 기록하세요. 긴 문서 요약처럼 입력이 긴 워크로드라면 짧은 채팅 벤치마크를 버리고 입력 길이 버킷을 따로 만들어야 합니다.

    제가 실제로 권하는 절차는 단순합니다. 워밍업을 제외하고, 같은 프롬프트 세트와 같은 출력 길이로, 동시성을 1에서 시작해 단계적으로 올립니다. 처리량이 더 늘지 않거나 p95 지연 시간이 갑자기 커지거나 실패가 섞이는 지점이 나오면, 그 직전 단계를 운영 후보로 잡습니다. 그다음 max_model_len, max_num_seqs, gpu_memory_utilization을 하나씩만 바꿔 다시 측정합니다.

    다음 글에서는 Prometheus와 Grafana를 붙여 vLLM 추론 서버의 요청 지표, GPU 메모리, 지연 시간 분포를 시각화하는 방법을 다룰 예정입니다. 관련 글로는 GPU 모니터링, 모델 서빙 아키텍처, LLM 비용 최적화 글을 함께 연결하면 독자가 다음 단계로 넘어가기 좋습니다. 벤치마크는 한 번 찍는 스크린샷이 아니라 운영 기준을 만드는 과정입니다.

    TTFT, 처리량, 동시성, GPU 메모리 기준으로 로컬 LLM 성능 비교 방법을 요약한 인포그래픽입니다.

    제 기준의 한 줄 권고는 이렇습니다. 빠른 숫자 하나를 찾지 말고, 같은 조건에서 동시성을 올리며 p95 지연 시간과 실패율이 꺾이는 지점을 찾으세요. 그 직전이 여러분 로컬 LLM 서버의 현실적인 운영선입니다.

  • 로컬 LLM을 n8n 자동화에 연결하기 — llama-server API로 로그 분류기 만들기 (홈랩 CPU)

    1편에서 GPU 없이 로컬 LLM을 띄웠고, 2편에서 한국어엔 Qwen2.5-3B가 낫다는 걸 실측으로 확인했습니다. 이제 마지막 단계 — 이 로컬 LLM을 실제 자동화에 연결합니다. llama.cpp를 OpenAI 호환 API 서버로 띄우고, n8n 워크플로우에 붙여서 일을 시켜 봤습니다. 결론부터: n8n이 로컬 LLM을 호출해 nginx 에러 로그를 “ERROR”로 정확히 분류하는 데까지 성공했습니다. API 요금 0원, 전부 홈랩 안에서.

    1. llama-server로 OpenAI 호환 API 띄우기

    llama.cpp에는 llama-server라는 실행 파일이 함께 빌드됩니다(1편 참고). 이걸 띄우면 OpenAI의 /v1/chat/completions와 똑같은 형식의 API가 생깁니다.

    ./build/bin/llama-server \
      -m /root/models/Qwen2.5-3B-Instruct-Q4_K_M.gguf \
      -c 4096 -t 4 \
      --host 0.0.0.0 --port 8080

    여기서 OpenAI 호환이라는 점이 핵심입니다. 세상의 수많은 도구·라이브러리·자동화 노드가 이미 “OpenAI API 형식”을 표준으로 지원하니까, URL만 내 홈랩 서버로 바꾸면 그대로 붙습니다. 외부에 돈 내고 부르던 걸 로컬로 갈아끼우는 셈이죠. --host 0.0.0.0은 같은 네트워크의 다른 기기(n8n 등)에서 접근하게 열어 주는 옵션입니다.

    2. API가 진짜 되는지 먼저 확인

    워크플로우에 붙이기 전에 curl로 직접 찔러 봤습니다.

    curl http://127.0.0.1:8080/v1/chat/completions \
      -H "Content-Type: application/json" \
      -d '{
        "model": "qwen2.5-3b",
        "messages": [{"role":"user","content":"홈랩 자동화에 로컬 LLM을 쓰면 좋은 점 한 문장."}],
        "temperature": 0.3
      }'

    실제 응답(발췌)입니다.

    "content": "로컬 LLM은 ... 홈랩 로그의 특징을 정확하게 분류하는 데 유용할 수 있습니다.",
    "usage": { "prompt_tokens": 60, "completion_tokens": 50, "total_tokens": 110 }

    토큰 사용량까지 OpenAI와 똑같은 스키마로 돌아옵니다. 이제 이걸 자동화 도구에 물릴 차례입니다.

    3. n8n 설치 — 여기서 함정을 밟았다

    자동화 도구로는 오픈소스 워크플로우 툴 n8n을 골랐습니다. 그런데 설치가 한 번에 되지 않았습니다. 최신 Debian(13)에서 npm install -g n8n이 네이티브 모듈(isolated-vm) 빌드 단계에서 죽더군요.

    ModuleNotFoundError: No module named 'distutils'
    gyp ERR! configure error
    npm ERR! ... isolated-vm ... not ok  (exit 127)

    원인은 Python 3.12에서 distutils가 표준 라이브러리에서 제거됐기 때문입니다. 기본 저장소의 구버전 Node/node-gyp가 이걸 아직 참조해서 빌드가 깨진 거죠. 해결은 최신 Node로 교체하는 것이었습니다.

    # NodeSource로 Node 22 설치 (npm 10 / 최신 node-gyp 포함)
    curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
    apt-get install -y nodejs
    npm install -g n8n

    Node 22로 바꾸니 네이티브 모듈이 정상 빌드되고 n8n(2.35)이 깔렸습니다. 스펙표엔 안 나오는, 직접 깔아 봐야 아는 함정이라 그대로 남겨 둡니다 — 같은 에러를 만날 분이 분명 있을 테니까요.

    4. n8n 워크플로우 구성 — 로그 분류기

    실용적인 예로 “로그 한 줄을 INFO / WARN / ERROR로 분류”하는 워크플로우를 만들었습니다. 구조는 단순합니다.

    • Manual Trigger — 실행 시작
    • HTTP Request — 로컬 LLM 호출

    HTTP Request 노드 설정이 전부입니다.

    필드 값
    Method POST
    URL http://<llama-server-IP>:8080/v1/chat/completions
    Body JSON (아래)
    {
      "model": "qwen2.5-3b",
      "messages": [
        { "role": "system", "content": "너는 로그 분류기다. 입력 로그를 INFO/WARN/ERROR 중 하나로만 답하라." },
        { "role": "user", "content": "nginx: upstream timed out (110: Connection timed out) while reading response header" }
      ],
      "temperature": 0
    }

    n8n과 llama-server가 같은 호스트면 127.0.0.1, 다른 기기면 서버의 홈랩 IP를 넣으면 됩니다. system 메시지로 “분류기” 역할을 못 박고 temperature: 0으로 답을 고정한 게 포인트입니다.

    5. 실행 결과 — 로컬 LLM이 정확히 판단했다

    워크플로우를 실행했습니다. n8n의 실제 실행 결과(요약)입니다.

    status: "success"
    Local LLM 노드 → message.content: "ERROR"
    timings: prompt 46 tok/s, generation 13.65 tok/s (노드 실행 1.4초)

    nginx의 upstream 타임아웃 로그를 로컬 LLM이 정확히 “ERROR”로 분류했습니다. GPU도, 외부 API도, 요금도 없이 내 홈랩 안에서 자동화 파이프라인의 부품으로 로컬 LLM이 동작한 순간입니다. 이제 이 노드 앞뒤에 트리거·알림만 붙이면 실제 운영 자동화가 됩니다.

    6. 이걸로 뭘 할 수 있나

    • 로그·알림 분류: 서버 로그를 등급으로 분류해 ERROR만 텔레그램으로 즉시 알림
    • 요약: 긴 문서·메일·뉴스를 로컬에서 요약(데이터가 외부로 안 나감)
    • 정형화: 자유 텍스트를 JSON·태그로 구조화

    핵심 이점은 비용과 프라이버시입니다. 외부 LLM API는 호출마다 돈이 들고 데이터가 밖으로 나가지만, 로컬 LLM은 몇 번을 부르든 0원이고 데이터가 홈랩을 벗어나지 않습니다. 실시간 초고성능이 필요한 게 아니라 배치·자동화 용도라면, GPU 없는 홈랩 CPU로도 충분히 실전에서 굴릴 수 있습니다.

    시리즈를 마치며

    1편 설치 → 2편 모델 선택 → 3편 자동화 연동까지, GPU 없는 i5 미니 PC 한 대로 로컬 LLM을 실전에 투입하는 전 과정을 직접 해봤습니다. “로컬 AI는 그래픽카드 있어야 한다”는 편견은, 적어도 3B급 자동화 용도에선 사실이 아닙니다. 여러분의 홈랩에도 하나 올려 보시길 권합니다.

  • 홈랩 CPU에서 로컬 LLM 3종 실측 비교 — Qwen2.5 vs Gemma 2 vs Llama 3.2 (한국어·속도)

    지난 1편에서 GPU 없는 i5 홈랩에 llama.cpp를 올리고 Qwen2.5-3B 하나를 돌려봤습니다. 그러자 당연한 질문이 남습니다 — “그럼 다른 모델은? 어떤 게 제일 나은데?” 그래서 소형 모델 3종을 완전히 같은 조건에서 돌려 속도와 한국어 품질을 실측했습니다. 결론부터 말하면, 한국어로 쓸 거면 Qwen2.5-3B가 1순위입니다. 그런데 그 과정에서 예상 못 한 함정도 하나 밟았습니다(마지막에).

    1. 실험 조건 — 공정하게 맞췄다

    비교가 의미 있으려면 변수를 통제해야 합니다. 세 모델 모두 아래 조건으로 통일했습니다.

    • 같은 컨테이너: LXC 4코어 / 8GB (1편과 동일)
    • 같은 양자화: Q4_K_M (4비트)
    • 같은 스레드: -t 4
    • 속도는 llama-bench, 품질은 동일 프롬프트를 llama-cli로

    대상 모델은 홈랩 CPU에서 현실적인 2~3B급으로 골랐습니다.

    모델 파라미터 파일 크기(Q4_K_M)
    Qwen2.5-3B-Instruct 3.09B 1.79 GiB
    Gemma 2 2B-it 2.61B 1.59 GiB
    Llama 3.2 3B-Instruct 3.21B 1.87 GiB

    2. 속도 실측 — Gemma가 가장 빠르다

    ./build/bin/llama-bench \
      -m /root/models/Qwen2.5-3B-Instruct-Q4_K_M.gguf \
      -m /root/models/gemma-2-2b-it-Q4_K_M.gguf \
      -m /root/models/Llama-3.2-3B-Instruct-Q4_K_M.gguf \
      -t 4 -p 512 -n 128
    모델 프롬프트 처리 (pp512) 생성 (tg128)
    Qwen2.5-3B 58.7 tok/s 13.83 tok/s
    Gemma 2 2B 79.9 tok/s 15.07 tok/s
    Llama 3.2 3B 58.5 tok/s 13.35 tok/s

    예상대로 Gemma 2 2B가 가장 빠릅니다. 파라미터가 2.6B로 가장 작으니 당연한 결과죠. Qwen과 Llama는 둘 다 3B급이라 속도는 사실상 동률(생성 13토큰대)입니다. 즉 속도만 보면 크기 순서 그대로이고, 셋 다 CPU에서 실사용 가능한 범위입니다. 그럼 승부는 품질에서 갈립니다.

    3. 한국어 품질 — 여기서 갈린다

    세 모델에 똑같은 질문을 던졌습니다.

    홈 서버 초보자에게 SSH 키 인증이 비밀번호 방식보다
    안전한 이유를 3문장으로 설명해줘.

    아래는 각 모델의 실제 출력입니다(발췌, 가공 없음).

    Qwen2.5-3B — 자연스럽고 정확

    SSH 키 인증은 비밀번호 방식보다 보안이 더 강화되어 있으며, 키는 암호화되어 있어 단방향으로 전송되어야 하므로 공유되는 데이터가 아닌 개인의 비밀로 보호됩니다. 이는 공유된 컴퓨터 환경에서 특히 중요한 요소이며, 악의적인 공격자가 비밀번호를 해독하는 것은 더 어렵습니다.

    문장이 매끄럽고 내용도 맞습니다. 3B 소형 모델 치고 한국어 완성도가 확실히 높습니다.

    Gemma 2 2B — 무난하고 가볍다

    SSH 키 인증은 비밀번호 방식보다 더 안전하며, 이유는 첫째, SSH 키는 개인이 직접 생성하고 유지하며, 둘째, SSH 키는 서버와 사용자 간의 연결을 위한 신뢰도를 높이고, 셋째, 키는 암호화 방식으로 사용하여 비밀을 유지한다는 점 때문이다.

    2.6B밖에 안 되는데 한국어가 깔끔합니다. 요청한 “첫째·둘째·셋째” 구조도 잘 지켰고요. 내용 깊이는 Qwen보다 살짝 얕지만, 가장 빠르면서도 한국어가 무너지지 않는다는 게 강점입니다.

    Llama 3.2 3B — 한국어가 무너진다

    1. mật secret.key는 해킹자가 쉽게 obten할 수 있는 sensitive information이 아니기 때문입니다. …
    2. secret.key는 해킹자가 얻어도 사용할 수 있는 information이기 때문에 … (2·3번 문장 거의 동일하게 반복)

    가장 크고(3.21B) 유명한 모델인데, 한국어는 셋 중 최악이었습니다. 외국어(“mật”, “obten”, “information”)가 뒤섞이고, 같은 문장을 반복하고, 내용도 부정확했습니다. Llama 3.2는 영어 중심으로 학습돼 소형 버전의 한국어가 약합니다. 영어로 쓸 거면 몰라도, 한국어 용도로는 비추천입니다.

    4. 내가 밟은 함정 — Llama 3.2가 메모리를 터뜨렸다

    사실 Llama 3.2는 처음에 실행하자마자 죽었습니다. 로그를 보니 Killed, 종료 코드 137(OOM). 8GB나 비어 있는데 왜?

    원인은 기본 컨텍스트 길이였습니다. Llama 3.2는 학습 컨텍스트가 128K 토큰이라, 옵션을 안 주면 llama.cpp가 그 거대한 KV 캐시를 통째로 잡으려다 메모리를 초과합니다. Qwen·Gemma는 기본값이 작아 문제없었던 것이죠. 해결은 간단합니다 — 컨텍스트를 명시적으로 제한하면 됩니다.

    # -c 로 컨텍스트를 4096으로 제한하면 정상 동작
    ./build/bin/llama-cli \
      -m /root/models/Llama-3.2-3B-Instruct-Q4_K_M.gguf \
      -c 4096 -p "..." -n 200 -t 4 -st

    홈랩처럼 RAM이 넉넉하지 않은 환경에서 큰 컨텍스트 모델을 돌릴 땐 -c를 습관처럼 붙이는 게 안전합니다. 이건 스펙표만 봐선 절대 모르고, 직접 돌려봐야 아는 함정입니다.

    5. 결론 — 용도별 추천

    우선순위 추천 모델 이유
    한국어 품질 Qwen2.5-3B 가장 자연스럽고 정확. 홈랩 한국어 1순위
    속도·초경량 Gemma 2 2B 가장 빠르고 가벼우면서 한국어도 무난
    영어 전용 Llama 3.2 3B 한국어는 약함. 영어 작업엔 선택지

    제 결론은 “한국어 홈랩 CPU 추론이면 Qwen2.5-3B를 기본으로, 더 가볍고 빠르게 가고 싶으면 Gemma 2 2B”입니다. 파라미터 수나 유명세가 아니라, 같은 조건에서 직접 돌려본 결과가 이렇게 갈립니다.

    다음 편 예고

    이제 쓸 만한 모델을 골랐으니, 다음 편에서는 llama-server로 OpenAI 호환 API를 띄우고, 이걸 n8n 자동화에 연결해 실제로 일을 시키는 과정을 정리하겠습니다. 로컬 LLM이 장난감을 넘어 파이프라인의 부품이 되는 단계입니다.

  • GPU 없이 로컬 LLM 돌리기 — i5 홈랩 + llama.cpp 실전 (설치부터 첫 구동, 실측 13토큰/초)

    “로컬 LLM 돌리려면 그래픽카드부터 사야지” — 대부분 이렇게 알고 계실 겁니다. 그런데 제 홈랩엔 외장 그래픽카드가 없습니다. Intel i5-8500에 내장그래픽(UHD 630)뿐이라 CUDA는 애초에 못 씁니다. 그래서 직접 해봤습니다 — GPU 없이 CPU만으로 로컬 LLM을. 결론부터 숫자로 말하면, 3B 모델이 초당 13.85토큰으로 돌아갑니다. 읽는 속도보다 빠릅니다. 설치부터 첫 구동, 실측까지 그대로 공개합니다.

    1. 왜 CPU인가 — GPU 없는 홈랩의 현실

    홈랩용 미니 PC나 중고 서버엔 외장 GPU가 없는 경우가 훨씬 많습니다. 전력·발열·가격 때문이죠. 제 환경도 그렇습니다.

    • CPU: Intel i5-8500 (6코어) — AVX2 지원
    • GPU: Intel UHD 630 내장그래픽뿐 → NVIDIA/CUDA 경로 불가

    많은 “로컬 LLM 설치” 글이 NVIDIA Driver + CUDA 설치부터 시작하는데, 그래픽카드가 없으면 그 글은 무용지물입니다. 다행히 llama.cpp는 순수 CPU 추론을 제대로 지원하고, 소형 양자화 모델이라면 CPU로도 충분히 쓸 만합니다. 이 글은 정확히 그 경우를 다룹니다.

    2. 실제 구성 — LXC 컨테이너로 격리

    호스트에 직접 깔지 않고 Proxmox LXC 컨테이너 하나를 새로 만들어 그 안에 구축했습니다. 이유는 단순합니다 — 빌드 도구·모델 파일이 호스트를 어지럽히지 않게 격리하고, 실험이 꼬이면 컨테이너째 버리면 되니까요.

    항목 할당 비고
    OS Debian 13 (trixie) gcc 14.2 / cmake 3.31
    vCPU 4코어 추론 스레드 4개
    RAM 8GB 3B 모델(1.8GB) + 여유 충분
    디스크 24GB 빌드 + 모델 몇 개

    3B 모델은 8GB면 넉넉합니다. 오히려 CPU 추론에서 진짜 자원은 메모리 대역폭이라, 코어를 무작정 늘린다고 비례해서 빨라지지 않습니다(뒤에서 실측).

    3. llama.cpp 빌드 — AVX2 네이티브가 핵심

    먼저 빌드 도구를 설치합니다.

    apt-get update
    apt-get install -y build-essential cmake git libcurl4-openssl-dev ccache wget

    소스를 받아 빌드합니다. CPU 추론 속도를 좌우하는 건 -DGGML_NATIVE=ON입니다. 이 옵션이 현재 CPU의 명령어 확장(AVX2 등)을 최대한 활용하도록 컴파일해 줍니다. 이걸 빼면 체감 속도가 뚝 떨어집니다.

    cd /root
    git clone --depth 1 https://github.com/ggml-org/llama.cpp
    cd llama.cpp
    cmake -B build -DGGML_NATIVE=ON -DLLAMA_CURL=ON -DCMAKE_BUILD_TYPE=Release
    cmake --build build -j4 --config Release

    4코어에서 몇 분이면 끝납니다. 빌드가 끝나면 build/bin/ 아래에 실행 파일이 생깁니다. 우리가 쓸 건 세 개입니다.

    • llama-cli — 실제 대화·생성
    • llama-bench — 성능 측정 전용
    • llama-server — OpenAI 호환 API 서버(다음 편에서 활용)

    4. 모델 선택 — 왜 Qwen2.5-3B Q4_K_M인가

    CPU·소형 환경에서 한국어까지 쓸 만한 모델로 Qwen2.5-3B-Instruct를 골랐습니다. 파일명 뒤의 Q4_K_M이 중요한데, 이건 양자화(quantization) 방식입니다.

    • 원본 모델은 파라미터를 16비트로 저장 → 3B라도 6GB가 넘습니다.
    • Q4_K_M은 4비트로 압축한 버전 → 같은 모델이 1.8GB로 줄어듭니다.
    • 품질 손실은 체감상 미미하면서 CPU·메모리 부담을 크게 줄여, 홈랩 CPU 추론의 사실상 표준입니다.
    mkdir -p /root/models && cd /root/models
    wget -O Qwen2.5-3B-Instruct-Q4_K_M.gguf \
      "https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf"

    5. 첫 구동 — 진짜 한국어가 나온다

    바로 물어봤습니다.

    ./build/bin/llama-cli \
      -m /root/models/Qwen2.5-3B-Instruct-Q4_K_M.gguf \
      -p "홈랩에서 GPU 없이 CPU만으로 로컬 LLM을 돌릴 때의 장점 3가지를 간단히 알려줘." \
      -n 220 -t 4 -st

    실제로 나온 답변입니다(가공 없음).

    GPU 없이 CPU만으로 로컬 LLM을 실행할 때의 장점은 다음과 같습니다:

    1. 비용 절감: GPU가 필요하지 않아 소프트웨어 구동에 필요한 비용을 줄일 수 있습니다.
    2. 컴퓨팅 능력: CPU는 보통 GPU보다 더 저렴한 가격과 더 적은 부피를 가지고 있지만, 특정 작업에는 충분한 컴퓨팅 능력을 제공할 수 있습니다.
    3. 편리성: CPU가 설치된 컴퓨터를 사용하면, 별도의 GPU 장치 구매나 설치 없이도 로컬 LLM을 실행할 수 있습니다.

    마크다운 서식까지 알아서 잡아 줍니다. 3B 소형 모델이라 아주 복잡한 추론은 무리지만, 요약·번역·분류·간단한 글쓰기 같은 실무 자동화용으로는 충분한 품질입니다.

    6. 실측 성능 — 초당 몇 토큰?

    체감이 아니라 숫자로 재려고 llama-bench를 돌렸습니다.

    ./build/bin/llama-bench \
      -m /root/models/Qwen2.5-3B-Instruct-Q4_K_M.gguf \
      -t 4 -p 512 -n 128
    테스트 속도 의미
    pp512 (프롬프트 처리) 58.8 tok/s 입력을 읽어들이는 속도
    tg128 (텍스트 생성) 13.85 tok/s 답변을 뱉는 속도

    핵심은 생성 속도 13.85 tok/s입니다. 한국어 기준 사람이 눈으로 읽는 속도가 대략 초당 7~10토큰 남짓이니, 읽는 것보다 빠르게 답이 흘러나옵니다. 대화형으로 써도 답답하지 않고, 모델 파일이 1.8GB라 8GB 컨테이너에 메모리도 여유롭습니다. “GPU 없으면 로컬 LLM은 못 쓴다”는 편견이 3B급에서는 사실이 아닙니다.

    7. CPU-only의 한계 — 정직하게

    • 큰 모델은 느립니다. 14B·32B급으로 가면 CPU에선 초당 1~3토큰으로 뚝 떨어져 실시간 대화엔 부적합합니다. CPU의 스윗스팟은 3B~8B입니다.
    • 코어 늘린다고 비례하지 않습니다. CPU 추론은 메모리 대역폭에 묶여서, 스레드를 물리 코어 이상으로 올리면 오히려 손해입니다. 코어 수보다 -DGGML_NATIVE=ON 빌드가 더 큰 차이를 냅니다.
    • 진짜 큰 모델·낮은 지연이 필요하면 그때 GPU를 고민하면 됩니다. 하지만 요약·번역·자동화 파이프라인 용도라면 3B CPU로 충분합니다.

    다음 편 예고

    1편은 “일단 돌린다”였습니다. 다음 편에서는 모델별 실측 비교(Qwen2.5-3B vs Gemma 2 vs Llama 3.2, 속도·한국어 품질)를 다루고, 그다음 편에서는 llama-server로 API를 띄워 n8n·자동화에 로컬 LLM을 연동하는 실전을 정리하겠습니다. GPU 없는 홈랩에서도 충분히 갈 수 있습니다.

  • AI로 기술 블로그를 매일 자동 발행하는 봇을 만들었다 — 구조·삽질·그리고 애드센스에 거절당한 이야기

    매일 기술 블로그에 글을 올리는 건 생각보다 큰 부담입니다. 어느 날 문득 “이거 AI한테 시키면 되지 않나?” 싶어서, 홈랩에 기술 블로그를 자동으로 기획·작성·발행하는 봇을 만들어 몇 달을 돌려봤습니다.

    결론부터 솔직히 말하면 — 기술적으로는 돌아가는데, 정작 광고 수익화(애드센스)에서 “가치 낮은 콘텐츠”로 거절당했습니다. 성공담이 아니라, 만들면서 겪은 구조와 삽질, 그리고 그 벽까지 그대로 남깁니다. 비슷한 걸 만들려는 분께는 이 실패 기록이 더 쓸모 있을 겁니다.

    1. 전체 그림

    봇은 홈랩 Proxmox LXC 안의 Docker 컨테이너로 24/7 돌아갑니다. 텔레그램 봇으로 조종하고, 스케줄에 맞춰 자동 발행합니다. 발행처는 둘입니다.

    • 티스토리 — REST API가 없어서 Playwright(헤드리스 Chromium)로 에디터를 직접 조작합니다. 로그인 세션을 저장해두고, 글쓰기 화면에 HTML을 밀어 넣는 방식.
    • 워드프레스 — 이쪽은 REST API로 깔끔하게 발행합니다.
    # docker-compose.yml (핵심만)
    services:
      blog-bot:
        build: .
        container_name: blog-bot-telegram
        init: true          # Playwright chromium 좀비 프로세스 회수(tini)
        restart: unless-stopped
        env_file: .env
        environment: [ "TZ=Asia/Seoul" ]
        volumes:
          - ./data:/app/data      # 발행 이력·주제 큐
          - ./output:/app/output  # 초안·이미지
          - ./auth:/app/auth       # 로그인 세션·토큰

    2. 글 한 편이 나오기까지 — 파이프라인

    글 하나가 발행되기까지 이런 단계를 거칩니다.

    1. 주제 선정 — 카테고리·키워드 시드 + 실제 검색 데이터(Search Console)로 후보 생성 → 중복 제거
    2. 초안 생성 — LLM으로 본문 초안 작성
    3. 심화 재작성 — 시니어 에디터 관점으로 깊이·구체성 강화(명령어·표·결론)
    4. 품질 게이트 — 코드블록·표·분량·상투어 자동 검사 후 미달이면 1회 보강
    5. 팩트체크·SEO — 없는 제품/버전 걸러내고 메타·키워드 정리
    6. 이미지 생성 — 썸네일·본문 이미지 자동 생성(무료 이미지 API 폴백 체인)
    7. 발행 → 색인 요청 — 티스토리+워드프레스 발행 후 Google Indexing + IndexNow로 색인 알림

    3. 뇌: 어떤 LLM을 쓰나 (그리고 비용 이야기)

    가장 자주 받는 질문이 “API 비용 많이 나오지 않냐”인데, 건당 과금은 0원입니다. 비결은 정액 구독 CLI를 subprocess로 호출하는 것입니다.

    • 1순위: Codex CLI(ChatGPT 구독) → 2순위: Claude Code CLI(Max 구독) → 최종 폴백: Gemini 무료 티어
    • 즉 per-token API가 아니라 이미 있는 구독 한도를 쓰므로, 글을 아무리 써도 추가 요금이 안 붙습니다.

    물론 삽질도 있었습니다. Claude Code CLI는 3,000자짜리 한국어 글 생성에서 자주 멈춰(타임아웃) 결국 Codex를 주력으로 돌렸고, 또 Codex가 ChatGPT 계정에서 쓸 수 있는 모델이 몇 주 단위로 바뀝니다(gpt-5.4 → gpt-5.5로 교체되며 옛 모델이 “not supported”로 죽음). 그래서 모델명은 환경변수로 빼두고 주기적으로 갈아줘야 했습니다.

    4. 진짜 어려웠던 것들 — 삽질 모음

    인프라를 올리는 것보다, 운영하며 튀어나온 자잘한 문제들이 훨씬 오래 걸렸습니다.

    증상 원인 해결
    주제가 매일 비슷 같은 카테고리·포맷 반복 제목·slug 핵심 토큰의 ‘개념 시그니처’로 다층 중복 제거 + 주제 백로그 큐
    LLM 응답 JSON 파싱 실패 본문 문자열에 이스케이프 안 된 줄바꿈(제어문자) json.loads(strict=False)로 허용
    티스토리 코드블록 색이 엉뚱 에디터가 언어를 오탐(bash→fortran) 삽입 전 티스토리 형식 <pre class="bash">으로 변환
    빌드 실패 LXC 디스크 꽉 참(Chromium 이미지 큼) rootfs 확장 + 빌드 캐시 정리
    좀비 프로세스 누적 Playwright Chromium 잔여 프로세스 docker init: true(tini)로 회수
    텔레그램 알림 폭주 글마다 진행 알림 하루 1회 요약 + 문제 발생 시에만 알림

    5. 가장 큰 교훈 — 애드센스가 거절했다

    여기가 이 글을 쓰는 진짜 이유입니다. 파이프라인을 아무리 다듬어도(코드블록·표·심화 재작성·품질 게이트까지), 애드센스는 “가치가 별로 없는 콘텐츠”로 두 번 거절했습니다.

    깨달은 건 명확합니다. 2026년의 Google은 AI 콘텐츠 자체를 금지하지 않습니다. 다만 “규모로 대량생산된, 어디서나 볼 수 있는, 저자를 검증할 수 없는” 콘텐츠를 저가치로 봅니다. 제 봇이 만든 글은 문법·구조는 멀쩡해도, 결국 “이 사이트에만 있는 고유한 경험”이 없었습니다. 웹에 있는 정보를 잘 정리한 것뿐이지, 제가 직접 겪은 무언가가 아니었으니까요.

    우회하려 발행량을 줄이고 품질 기준을 높여봤지만, 근본은 형태가 아니라 “누가 실제로 겪은 이야기냐”였습니다. 그래서 방향을 틀었습니다 — 지금 읽고 계신 이 글처럼, 봇이 아니라 제가 직접 겪은 것을 쓰는 쪽으로요.

    6. 만들려는 분께 — 솔직한 조언

    • 자동화 자체는 주말 프로젝트 수준입니다. LXC + Docker + 구독 CLI면 per-token 비용 0으로 돌아갑니다. 진짜 어려운 건 코드가 아니라 “가치”입니다.
    • AI 자동 블로그로 광고 수익을 노린다면, 2026년 기준 매우 어렵습니다. 완전 자동 합성 콘텐츠는 애드센스 심사의 정면 대상입니다.
    • 대신 AI를 “초안 도우미”로 쓰고, 본인의 실제 경험·데이터·스크린샷을 얹으세요. 그게 사람에게도 검색엔진에도 유일하게 통하는 차별점입니다.

    완전 자동화의 꿈은 절반만 이뤘습니다. 파이프라인은 잘 돌지만, 그 위에 “사람”이 없으면 결국 벽에 부딪히더라고요. 이 글이 같은 길을 걷는 분께 시간을 아껴주면 좋겠습니다.

  • [AI] 클라우드 환경에서 Ollama 운영 1년 회고: 비용 효율성 및 관리 노하우

    [AI] 클라우드 환경에서 Ollama 운영 1년 회고: 비용 효율성 및 관리 노하우

    안녕하세요, 13년차 서버실 지킴이 ’13년차의 서버실’입니다.

    오늘은 제가 지난 1년간 클라우드 환경에서 Ollama (올라마)를 운영하면서 겪었던 이야기와, 비용 효율성을 높이고 관리 노하우를 쌓았던 경험을 솔직하게 회고해보려 합니다. 로컬 AI 모델을 쉽게 돌릴 수 있게 해주는 Ollama, 처음엔 제 홈랩 서버에만 돌려봤는데, 어느 날 문득 ‘이걸 클라우드에 올려서 더 많은 사람이 쉽게 접근하게 할 수 없을까?’ 하는 생각이 들었거든요. 그렇게 본격적인 삽질이 시작되었습니다.

    결론부터 말씀드리면, 클라우드에서 Ollama를 운영하는 것은 생각보다 훨씬 매력적이지만, ‘비용’이라는 큰 산을 넘어야 했습니다. 저처럼 로컬 AI를 클라우드로 확장하려는 분들께 제 경험이 작은 길잡이가 되었으면 좋겠네요. 제 삽질의 기록, 지금부터 시작합니다!

    Ollama 클라우드 운영은 사용자 접근성과 확장성을 크게 높여줍니다. 위 다이어그램은 기본적인 서비스 흐름을 나타냅니다.

    Ollama, 클라우드에서 왜? (핵심 개념 설명)

    Ollama (올라마)는 Meta의 Llama나 Mistral AI의 Mistral 같은 대규모 언어 모델(LLM)을 개인용 컴퓨터나 서버에서 쉽게 실행할 수 있도록 도와주는 오픈소스 프레임워크입니다. 쉽게 말해, 복잡한 설정 없이 명령어 한 줄로 다양한 AI 모델들을 다운로드받아 바로 실행할 수 있게 해주는 ‘로컬 AI 모델 실행기’라고 생각하시면 됩니다.

    처음에는 제 홈랩 서버 (Home Lab Server)에서 테스트하며 그 편리함에 감탄했죠. 그런데 여기서 한 가지 아쉬움이 생겼어요. 제 홈랩 서버의 GPU 자원으로는 여러 사람이 동시에 다양한 모델을 사용하기 어렵고, 외부에서 접근하는 것도 보안이나 네트워크 설정이 번거로웠거든요. 그래서 자연스럽게 클라우드 환경으로 눈을 돌리게 되었습니다.

    클라우드에서 Ollama를 운영하면 다음과 같은 장점들이 있습니다:

    • 접근성 (Accessibility): 인터넷만 연결되면 언제 어디서든 접속하여 AI 모델을 사용할 수 있습니다.
    • 확장성 (Scalability): 필요할 때만 고성능 GPU 인스턴스를 사용하고, 사용하지 않을 때는 중단하여 비용을 절감할 수 있습니다. (물론 이게 말처럼 쉽지 않았죠… 후술합니다!)
    • 협업 (Collaboration): 팀원들이나 동료들과 같은 AI 환경을 공유하며 작업할 수 있습니다.
    • 성능 (Performance): 홈랩에서 구성하기 어려운 고성능 GPU (예: NVIDIA A100, H100) 자원을 필요한 만큼 빌려 쓸 수 있습니다.

    클라우드 위 Ollama, 실전 구현부터 삽질까지

    클라우드 환경에 Ollama를 설치하는 과정은 크게 다르지 않습니다. 문제는 어떤 클라우드와 어떤 인스턴스를 선택하느냐, 그리고 어떻게 효율적으로 관리하느냐였죠.

    1. 클라우드 프로바이더 선택 및 인스턴스 프로비저닝

    주요 클라우드 서비스인 AWS (Amazon Web Services), GCP (Google Cloud Platform), Azure (Microsoft Azure) 외에도, GPU 인스턴스 비용이 상대적으로 저렴한 Vultr, Hetzner, Lambda Labs 같은 전문 GPU 클라우드 서비스들도 고려 대상이었습니다. 저는 최종적으로 GCP를 선택했습니다. 이미 다른 프로젝트로 사용 중이었고, Preemptible VM (선점형 VM)이라는 옵션이 비용 절감에 정말 큰 도움이 될 거라고 생각했거든요. (물론 선점형 VM은 예상치 못한 종료라는 명확한 트레이드오프가 있습니다.)

    가장 중요한 건 GPU 인스턴스 선택입니다. Ollama는 CPU로도 구동 가능하지만, 제대로 된 성능을 내려면 GPU가 필수더라고요. 최소한 NVIDIA T4 정도는 되어야 Llama 7B 모델을 돌려도 답답함이 덜합니다. 클라우드에서 GPU 인스턴스를 고를 때 제가 고려한 항목들은 다음과 같습니다.

    • GPU 모델: NVIDIA T4, A100, H100 등. 모델별 성능과 비용이 천차만별입니다.
    • VRAM (Video RAM): 모델 크기에 따라 필요한 VRAM이 다릅니다. Llama 7B는 약 8GB, 13B는 16GB 이상을 권장합니다.
    • 가격: 시간당 요금, 온디맨드(On-demand) vs. 스팟(Spot) vs. 예약(Reserved) 인스턴스 옵션.

    제가 주로 사용했던 GCP의 GPU 인스턴스 유형과 비용 효율성을 고려한 선택지를 표로 정리하면 다음과 같습니다.

    항목 고려 사항 Ollama 운영 시 장단점
    클라우드 프로바이더 AWS, GCP, Azure, Vultr, Hetzner 등 GCP: 선점형 VM으로 비용 절감 가능, 이미 사용 중
    Vultr/Hetzner: GPU 가성비 좋음, 인터페이스 직관적
    GPU 모델 NVIDIA T4 (16GB VRAM), A100 (40/80GB VRAM) T4: 가성비 좋은 시작점, 7B 모델에 적합
    A100: 고성능, 여러 모델 동시 운영, 대형 모델에 적합 (비용 높음)
    인스턴스 유형 온디맨드, 스팟(선점형), 예약 인스턴스 스팟/선점형: 비용 효율적이지만, 예고 없이 종료될 수 있음. 개발/테스트용으로 적합.
    온디맨드: 안정적이지만 비용 높음. 프로덕션 환경에 적합.

    2. Ollama 설치 및 기본 설정

    GPU 인스턴스를 프로비저닝하고 SSH로 접속했다면, Ollama 클라우드 운영의 첫 단계인 설치는 정말 간단합니다. 공식 웹사이트에 나와 있는 스크립트를 실행하면 됩니다. 이때 중요한 게 NVIDIA 드라이버가 잘 설치되어 있는지 확인하는 것입니다. 다행히 클라우드에서 제공하는 GPU 인스턴스 이미지에는 대부분 드라이버가 미리 설치되어 있거나, 쉽게 설치할 수 있는 도구를 제공하더라고요.

    
    # Ollama 설치 스크립트 실행
    curl -fsSL https://ollama.com/install.sh | sh
    
    # 설치 확인 및 모델 다운로드 테스트
    # ollama pull llama2 (약 3.8GB)
    # ollama run llama2
    

    저는 여기서 `systemd` (시스템디)를 이용해 Ollama를 서비스로 등록하고 자동 실행되도록 설정했습니다. 이렇게 하면 서버가 재부팅되어도 Ollama가 자동으로 시작되고, 백그라운드에서 안정적으로 실행되죠. 제가 사용했던 간단한 `systemd` unit 파일입니다.

    
    # /etc/systemd/system/ollama.service 파일 생성
    sudo tee /etc/systemd/system/ollama.service > /dev/null <

    Ollama를 서비스로 등록하여 안정적으로 운영하는 systemd 설정 예시입니다. OLLAMA_HOST 환경 변수를 설정하여 외부 접속을 허용하는 것이 중요합니다.

    클라우드 터미널에서 Ollama 설치 후 `ollama run llama2` 실행 모습 또는 `systemctl status ollama` 명령으로 서비스 상태를 확인하는 화면

    Ollama 설치 후, 모델을 다운로드하고 실행하는 모습입니다. systemd로 서비스 등록 후에는 systemctl status ollama 명령으로 서비스 상태를 확인할 수 있습니다.

    3. 외부 접속 및 보안 설정

    Ollama가 클라우드 인스턴스에서 잘 실행되었다면, 이제 외부에서 접근할 수 있도록 네트워크 설정을 해야 합니다. 기본적으로 Ollama는 11434 포트를 사용합니다. 클라우드 콘솔에서 해당 인스턴스의 방화벽 (Firewall) 규칙을 수정하여 11434 포트의 인바운드 (Inbound) 트래픽을 허용해야 합니다. 저는 특정 IP 대역에서만 접근을 허용하거나, VPN을 통해서만 접속하도록 설정하여 기본적인 보안을 강화했습니다.

    또한, Nginx (엔진엑스) 같은 리버스 프록시 (Reverse Proxy)를 앞에 두어 SSL/TLS (HTTPS)를 적용하고, 기본적인 인증 (Basic Authentication)을 추가했습니다. 이렇게 하면 데이터 전송이 암호화되고, 인가된 사용자만 Ollama API에 접근할 수 있게 되거든요.

    ⚠️ 삽질 보고서: 비용 효율성과의 전쟁

    클라우드에서 Ollama를 운영하며 가장 큰 어려움은 역시 비용 관리였습니다. GPU 인스턴스는 시간당 요금이 정말 비싸거든요. 처음에는 '필요할 때만 켜고 끌 수 있겠지!'라고 생각했지만, 현실은 다았습니다.

    • 인스턴스 종료 깜빡: 테스트하다가 깜빡 잊고 GPU 인스턴스를 끄지 않아 다음 달 요금 폭탄을 맞은 적이 한두 번이 아닙니다. 🤯
    • 선점형 VM의 한계: 비용 절감 효과는 좋았지만, 중요한 작업 중에 예고 없이 인스턴스가 종료되는 경우가 생겼어요. 당황스럽긴 했지만, 다행히 Ollama는 스테이트리스(Stateless)하여 모델 파일만 잘 보존하면 큰 문제는 없었습니다. 다만 작업 흐름이 끊기는 건 어쩔 수 없었죠.
    • 모델 용량과 VRAM: 더 큰 모델, 더 많은 모델을 돌리고 싶다는 욕심에 점점 더 비싼 GPU를 찾게 되더라고요. Llama 70B 같은 모델은 A100 GPU 2개 이상을 요구하기도 합니다.

    이런 삽질 경험들을 통해 배운 교훈은 다음과 같습니다.

    1. 명확한 사용 계획 수립: 어떤 모델을, 얼마나 자주, 누가 사용할지 미리 계획해야 합니다.
    2. 자동화된 시작/종료 스크립트: 특정 시간대에만 인스턴스를 켜고 끄는 Cron (크론) 작업이나 클라우드 스케줄러를 활용하는 게 필수입니다. 예를 들어, 퇴근 후에는 자동으로 인스턴스를 종료하고, 출근 전에 다시 시작하는 스크립트를 만들었어요.
    3. 클라우드 알림 설정: 예산 초과 알림, 인스턴스 상태 변경 알림 등을 설정하여 예상치 못한 비용 발생을 방지해야 합니다.
    4. 스팟/선점형 VM 활용: 개발/테스트 환경에서는 적극적으로 스팟/선점형 VM을 사용하되, 중요한 서비스에는 온디맨드 또는 예약 인스턴스를 사용하는 하이브리드 전략을 구사해야 합니다.
    Ollama 클라우드 운영 중 GPU 인스턴스 비용이 막대 그래프로 표시된 가상의 클라우드 비용 관리 대시보드 스크린샷

    클라우드 비용은 방심하면 순식간에 늘어납니다. 위 그림처럼 비용 대시보드를 주기적으로 확인하고 알림을 설정하는 것이 정말 중요합니다.

    1년 회고: Ollama 클라우드 운영의 성과와 배운 점

    지난 1년간 클라우드에서 Ollama를 운영하며 많은 것을 얻었습니다. 무엇보다 로컬 LLM의 가능성을 클라우드 환경에서 마음껏 실험해볼 수 있었다는 점이 가장 컸습니다. 동료들과 함께 필요한 모델을 테스트하고, 간단한 POC (Proof Of Concept, 개념 증명)를 진행하는 데 정말 큰 도움이 되었죠.

    가장 큰 성과는 역시 '자동화된 비용 관리 루틴'을 만들었다는 점입니다. 처음엔 일일이 수동으로 켜고 끄며 허둥댔지만, 지금은 스크립트와 클라우드 스케줄러 덕분에 훨씬 안정적으로 운영하고 있습니다. 예를 들어, GCP의 Cloud Scheduler와 Cloud Functions를 연동하여 특정 시간에 GPU VM을 시작/중지하는 파이프라인을 구축했습니다. 덕분에 불필요하게 낭비되는 비용을 크게 줄일 수 있었죠.

    Ollama 자체도 지난 1년간 빠르게 발전했습니다. API의 안정성이나 모델 지원 범위가 훨씬 넓어져서, 이제는 단순 테스트를 넘어 실제 서비스의 백엔드로 활용할 가능성도 엿보입니다. 다만 여전히 대규모 트래픽 처리나 고가용성 (High Availability) 측면에서는 추가적인 고민과 아키텍처 설계가 필요해 보입니다.

    마무리: 클라우드 Ollama, 당신의 선택은?

    클라우드 환경에서 Ollama를 운영하는 것은 분명 매력적이고 유용한 경험이었습니다. 특히 AI 모델에 대한 접근성을 높이고, 고성능 자원을 유연하게 활용할 수 있다는 점은 홈랩 환경에서는 얻기 어려운 장점입니다.

    그렇다면 어떤 경우에 클라우드 Ollama를 추천할까요?

    • 개인 개발/학습용: 고성능 GPU가 없거나, 외부에서 접근하여 LLM을 사용하고 싶은 개인 개발자에게는 클라우드 스팟/선점형 인스턴스를 활용한 Ollama 운영이 좋은 선택입니다. 비용 효율적으로 다양한 모델을 실험할 수 있거든요.
    • 소규모 팀의 AI 모델 테스트/POC: 여러 팀원이 동일한 환경에서 AI 모델을 테스트하고 싶을 때 유용합니다. 공유된 클라우드 인스턴스에 Ollama를 띄워두고 API로 연동하면 협업 효율을 높일 수 있습니다.
    • 제한적인 프로덕션 환경: 특정 시간대에만 사용량이 몰리거나, 내부 사용자만을 대상으로 하는 서비스라면, 자동화된 시작/종료 로직과 함께 클라우드 Ollama를 활용해 볼 수 있습니다.

    반면, 상시 고가용성이 요구되는 대규모 프로덕션 환경이나, 매우 민감한 데이터를 처리해야 하는 경우에는 Ollama 단독보다는 MLOps (Machine Learning Operations) 파이프라인과 결합하거나, 보다 전문적인 LLM 서빙 솔루션을 고려하는 것이 좋습니다.

    저의 13년차 서버실 경험을 바탕으로 말씀드리자면, '무조건 클라우드가 좋다'거나 '무조건 온프레미스가 좋다'는 답은 없습니다. 각자의 상황과 목적에 맞춰 가장 효율적인 방법을 찾아야 합니다. 클라우드 Ollama 운영은 그 중간 지점에서 훌륭한 대안이 될 수 있다고 생각합니다. 여러분도 자신만의 Ollama 클라우드 활용법을 찾아보시길 바랍니다. 궁금한 점이 있다면 언제든 댓글 남겨주세요. 감사합니다!

    Ollama 클라우드 운영의 핵심 요약 인포그래픽: 비용 효율성, 접근성, 관리 용이성, 확장성을 중심으로 장단점 시각화

    지난 1년간의 Ollama 클라우드 운영 경험을 통해 배운 핵심 요약입니다. 클라우드 환경에서 Ollama를 효율적으로 활용하기 위한 인사이트를 얻으셨기를 바랍니다.

  • [AI] Haystack으로 사내 지식 검색 챗봇 RAG 파이프라인 구축 사례

    [AI] Haystack으로 사내 지식 검색 챗봇 RAG 파이프라인 구축 사례

    [LLM 애플리케이션 개발] Haystack으로 사내 지식 검색 챗봇 구축 사례: RAG 파이프라인 개발기

    안녕하세요, 13년차 인프라 엔지니어입니다. 요즘 제가 홈랩에서 몰두하고 있는 프로젝트가 하나 있는데, 바로 Haystack을 활용한 사내 지식 검색 챗봇이거든요. 회사에서 일하다 보면 “그 정보 어디 있지?”라며 헤매는 시간이 정말 많지 않으신가요? 저도 처음엔 그랬습니다. 수많은 문서와 슬랙 채널을 뒤지며 시간을 낭비하는 게 비효율적이라고 늘 생각했었거든요.

    그러다 “이걸 LLM(Large Language Model, 거대 언어 모델)으로 해결할 수 있지 않을까?”라는 생각이 자꾸만 들었고, 결국 직접 만들어보기로 결심했습니다. 특히 RAG(Retrieval Augmented Generation, 검색 증강 생성) 기법을 쓰면 환각(Hallucination) 없이 정확한 답변을 얻을 수 있다는 점이 마음에 들었죠. 제가 선택한 도구는 Haystack입니다. 지금부터 제가 어떻게 이 프로젝트를 진행했는지, 그리고 어떤 삽질들을 겪었는지 자세히 풀어보겠습니다.

    사내 지식 검색 챗봇의 RAG 파이프라인 아키텍처 개념도입니다. 질문이 들어오면 Haystack이 문서를 뒤져서 관련성 높은 정보를 가져오고, 그걸 LLM에 넘겨서 답변을 만들어내는 흐름이죠.

    RAG와 Haystack: 왜 이 조합일까요?

    먼저 RAG와 Haystack이 정확히 뭔지 짚고 넘어갈게요. 이미 잘 아시는 분도 계시겠지만, 저도 처음엔 개념을 제대로 이해하는 데 시간이 걸렸거든요. LLM 애플리케이션 개발은 빠르게 진화하고 있어서, 기본기를 탄탄히 하는 게 정말 중요하더라고요.

    RAG (Retrieval Augmented Generation, 검색 증강 생성)

    LLM이 똑똑하긴 하지만, 학습 데이터 시점 이후의 정보나 우리 회사 같은 특정 도메인 지식은 알 수가 없습니다. 그리고 가끔은 환각(Hallucination), 즉 없는 이야기를 지어내기도 하죠. 이 문제를 해결하기 위해 나온 게 RAG입니다. 간단히 말해서, 질문이 들어오면 LLM이 바로 답변하는 게 아니라, 먼저 외부 지식 베이스(우리 회사 문서)에서 관련성 높은 정보를 ‘검색(Retrieval)’해서 가져온 다음, 그 정보를 ‘참고해서(Augmented)’ 답변을 ‘생성(Generation)’하는 방식입니다. 이렇게 하면 LLM이 최신 정보나 특정 도메인 지식에 기반한 정확한 답변을 할 수 있게 되더라고요.

    Haystack: RAG 파이프라인을 쉽게 구축하는 프레임워크

    RAG 파이프라인을 밑바닥부터 직접 구현하려면 생각보다 복잡합니다. 문서 로딩(Document Loading), 임베딩(Embedding), 검색기(Retriever), 생성기(Generator) 등 여러 컴포넌트를 일일이 연결해야 하거든요. Haystack은 이런 복잡한 과정을 쉽고 유연하게 만들어주는 프레임워크입니다. 다양한 DocumentStore(문서 저장소), Retriever(검색기), Generator(생성기) 컴포넌트를 모듈식으로 제공해서, 마치 레고 블록처럼 조립하듯이 파이프라인을 구축할 수 있어요. 파이썬 기반이라 저 같은 개발자에게는 접근성도 정말 좋습니다.

    실전 구현: Haystack RAG 파이프라인 만들기

    이제 제가 어떻게 사내 지식 검색 챗봇을 만들었는지 단계별로 보여드리겠습니다. 홈랩에서 직접 해보면서 시행착오도 많았는데, 핵심만 쏙쏙 뽑아서 공유해드릴게요.

    1. 환경 구성 및 Haystack 설치

    가장 먼저 개발 환경을 설정하고 Haystack을 설치해야겠죠. 저는 Python 가상 환경을 사용해서 의존성 충돌을 방지합니다. 깔끔하게 시작해야 나중에 트러블슈팅이 쉬워거든요.

    
    # 가상 환경 생성 및 활성화
    python3 -m venv haystack_env
    source haystack_env/bin/activate
    
    # Haystack 및 필요한 라이브러리 설치
    # PDF 문서를 처리할 예정이므로 pypdf도 설치합니다.
    pip install farm-haystack[pdf,faiss]
    # LLM 연동을 위해 OpenAI 라이브러리를 설치합니다.
    # 저는 테스트용으로 OpenAI API를 사용했습니다. (실제 운영 시에는 보안 고려 필수!)
    pip install openai
    

    참고로 <code>farm-haystack[pdf,faiss]처럼 대괄호 안에 옵션을 넣으면 필요한 컴포넌트들을 한 번에 설치할 수 있어요. FAISS(Facebook AI Similarity Search)를 사용해 빠른 검색을 구현했고, 나중에 스케일 아웃을 고려해서 Elasticsearch도 고려했습니다.

    2. 사내 문서 로딩 및 인덱싱

    이제 챗봇이 참고할 지식 베이스를 구축해야 합니다. 회사 내부 문서(회의록, 기술 문서, FAQ 등)를 Haystack이 이해할 수 있는 형태로 변환하고 저장하는 과정이죠. 저는 주로 PDF나 텍스트 파일 형태의 문서를 사용했습니다. 여기서 중요한 건 문서 청킹(Document Chunking) 전략입니다. 너무 길면 LLM의 컨텍스트 윈도우(Context Window)를 초과하고, 너무 짧으면 문맥을 놓칠 수 있거든요. 이 부분에서 저도 삽질 좀 했습니다. 처음엔 문서를 통째로 넣었다가 “너무 길어요”라는 LLM의 반응에 깜짝 놀랐죠. ㅎㅎ

    
    import os
    from haystack.document_stores import FAISSDocumentStore
    from haystack.nodes import TextConverter, PreProcessor
    
    # 문서 저장소 초기화 (FAISS 사용)
    # FAISS는 메모리 기반으로 빠르게 유사성 검색을 할 수 있게 해줍니다.
    document_store = FAISSDocumentStore()
    
    # 문서 변환 및 전처리 설정
    text_converter = TextConverter(remove_numeric_tables=True, valid_languages=["ko"])
    preprocessor = PreProcessor(
        clean_empty_lines=True,
        clean_whitespace=True,
        split_by="word",
        split_length=500,
        split_overlap=50,
        split_respect_sentence_boundary=True,
        language="ko"
    )
    
    # 문서 로딩
    from pathlib import Path
    DOC_DIR = "data"
    if not os.path.exists(DOC_DIR):
        os.makedirs(DOC_DIR)
    
    # PDF 파일들을 로드하고 전처리합니다.
    doc_files = list(Path(DOC_DIR).glob("*.pdf"))
    for file in doc_files:
        docs = text_converter.run(file_paths=[str(file)])["documents"]
        processed_docs = preprocessor.run(documents=docs)["documents"]
        document_store.write_documents(processed_docs)
    
    print(f"총 {document_store.get_document_count()}개의 문서(청크)가 인덱싱되었습니다.")
    

    여기서 중요한 건 PreProcessor의 split_length와 split_overlap 파라미터예요. 이 값을 어떻게 설정하느냐에 따라 검색 결과의 품질이 크게 달라지더라고요. 회사 문서의 스타일이나 평균 문장 길이를 고려해서 여러 번 테스트하고 최적값을 찾는 게 중요합니다. 저도 이 부분에서 여러 번 값을 조절하며 시행착오를 겪었습니다.

    Haystack 문서 인덱싱 과정을 시각화한 다이어그램

    Haystack에서 문서들을 로딩하고 인덱싱하는 과정입니다. 원본 문서가 텍스트로 변환되고, 의미 있는 조각(청크)으로 나뉘어 검색 가능한 형태로 저장되는 과정이죠.

    3. RAG 파이프라인 구축: 검색기와 생성기의 조립

    문서가 준비되었으니, 이제 질문이 들어왔을 때 관련 문서를 찾아주고 답변을 생성해주는 핵심 파이프라인을 만들 차례입니다. Haystack에서는 Pipeline 클래스를 사용해서 컴포넌트들을 연결해요. 저는 BM25Retriever와 PromptNode를 사용했습니다. BM25는 키워드 기반 검색에 강력하고, PromptNode는 LLM을 통합해 강력한 언어 생성 능력을 제공합니다.

    
    from haystack.nodes import BM25Retriever, PromptNode, PromptTemplate
    from haystack.pipelines import Pipeline
    import os
    
    # 1. Retriever (검색기) 설정
    # DocumentStore에서 가장 관련성 높은 문서를 찾아옵니다.
    retriever = BM25Retriever(document_store=document_store)
    
    # 2. Generator (생성기) 설정 - LLM 연동
    # PromptTemplate에 RAG를 위한 지시사항(프롬프트 엔지니어링)을 추가합니다.
    rag_prompt_template = PromptTemplate(
        prompt="""Given the following documents, answer the question in Korean.
        If you don't know the answer, just say that you don't know, don't make up an answer.
        Documents:
        {% for doc in documents %}
        {{ doc.content }}
        {% endfor %}
        Question: {{query}}
        Answer:"""
    )
    
    # PromptNode를 사용하여 LLM을 통합합니다.
    # model_name은 사용하려는 LLM 모델명입니다.
    # api_key는 OpenAI API 키를 환경 변수에서 가져옵니다.
    prompt_node = PromptNode(
        model_name_or_path="gpt-3.5-turbo",
        api_key=os.environ.get("OPENAI_API_KEY"),
        default_prompt_template=rag_prompt_template,
        max_length=500,
        model_kwargs={"temperature": 0.1}
    )
    
    # 3. RAG 파이프라인 조립
    rag_pipeline = Pipeline()
    rag_pipeline.add_node(component=retriever, name="Retriever", inputs=["Query"])
    rag_pipeline.add_node(component=prompt_node, name="PromptNode", inputs=["Retriever"])
    
    print("RAG 파이프라인이 준비되었습니다!")
    

    PromptTemplate이 정말 중요한데요. LLM이 가져온 문서를 바탕으로 어떤 답변을 해야 할지 지시하는 역할을 하거든요. “주어진 문서에서만 답변해라”, “모르면 모른다고 해라” 같은 지시를 명확히 주면 환각을 최소화할 수 있습니다. temperature 값도 중요한데, 낮을수록 정해진 사실에 기반한 답변을 유도합니다.

    ⚠️ 삽질 경험과 트러블슈팅: 제가 겪은 문제들

    이 과정을 진행하면서 겪었던 몇 가지 삽질과 해결책을 공유합니다. 여러분은 저처럼 같은 실수를 반복하지 않으시길 바라요!

    1. 문제: “Context window exceeded” 오류
      상황: 처음에는 PreProcessor에서 문서를 너무 크게 청킹하거나, 청킹을 아예 하지 않고 통째로 LLM에 넘기려 했습니다. 그랬더니 “Context window exceeded” 에러가 나더군요. LLM마다 한 번에 처리할 수 있는 토큰(단어) 수가 정해져 있는데, 이 한도를 초과한 거죠.
      해결: PreProcessor의 split_length와 split_overlap 파라미터를 조절했습니다. split_length를 LLM의 컨텍스트 윈도우 한계와 검색할 문서의 양을 고려해서 적절히 줄이고, split_overlap을 설정해 청크 간의 문맥 연결성을 유지했어요. 특히 split_respect_sentence_boundary=True 옵션을 주면 문장 중간에 잘리는 것을 방지할 수 있습니다.
    2. 문제: “관련 없는 문서 검색” 또는 “빈약한 답변”
      상황: 질문에 대한 답변이 엉뚱하거나, 문서에 분명히 내용이 있는데도 “모르겠습니다”라고 하는 경우가 있었습니다. 검색기(Retriever)가 관련성 높은 문서를 제대로 찾아오지 못했거나, 찾아온 문서가 충분히 상세하지 않았기 때문이었죠.
      해결:

      • 검색기 튜닝: BM25Retriever 외에 DensePassageRetriever(DPR)나 EmbeddingRetriever 같은 임베딩 기반 검색기도 시도해봤어요. 특히 DPR은 문맥을 더 잘 이해해서 유사성 높은 문서를 찾아주는 데 효과적이더라고요. 다만, 임베딩 모델 선택과 GPU 자원이 필요할 수 있습니다.
      • 프롬프트 엔지니어링 강화: PromptTemplate에 “주어진 문서 외의 정보는 사용하지 마세요”, “간결하게 답변해주세요” 등의 지시를 더 명확하게 추가했어요.
      • 문서 품질 개선: 결국 지식 베이스가 튼튼해야 합니다. 문서의 내용이 너무 두루뭉술하거나 핵심 정보가 부족하면 아무리 좋은 RAG 파이프라인이라도 좋은 답변을 내기 어렵거든요. 회사 내 문서들을 정리하고 표준화하는 작업도 병행했습니다.

    검증 및 결과: 드디어 챗봇과 대화하다!

    여러 번의 시행착오 끝에 드디어 만족스러운 답변을 내놓는 챗봇을 만들었어요! 파이프라인이 제대로 작동하는지 확인하는 순간은 정말 짜릿했습니다. 간단한 질문으로 테스트해볼 차례입니다.

    
    # 챗봇에 질문하기
    question = "2023년 하반기 사내 워크샵은 언제 어디서 개최되나요?"
    result = rag_pipeline.run(query=question, params={"Retriever": {"top_k": 3}})
    
    # 결과 출력
    print(f"질문: {question}\n")
    print(f"답변: {result['answers'][0].answer}\n")
    print("-" * 30)
    print("참조 문서:")
    for doc in result.get("documents", []):
        print(f"- {doc.meta.get('name', '제목 없음')} (ID: {doc.id[:8]}...)")
    

    결과를 보면, LLM이 단순히 질문에 답하는 것을 넘어, 어떤 문서에서 정보를 가져왔는지 참조 문서(Source Documents)까지 알려주더라고요. 이게 바로 RAG의 강력한 장점입니다. 사용자는 답변의 근거를 확인할 수 있어 신뢰도가 높아져요. 실제로 회사 회의록이나 프로젝트 보고서 같은 문서들을 넣어 테스트해보니, 담당자를 일일이 찾지 않아도 필요한 정보를 빠르게 얻을 수 있어서 업무 효율성이 크게 향상될 거 같다는 확신이 들었습니다.

    완성된 Haystack 기반 사내 지식 검색 챗봇 사용자 인터페이스 스크린샷

    제가 개발한 사내 지식 검색 챗봇의 실제 동작 화면입니다. 질문에 대한 명확한 답변과 함께 어떤 문서에서 정보를 가져왔는지 출처까지 알려줘서 신뢰할 수 있죠.

    Retriever 선택 가이드

    제가 여러 검색기를 사용해보면서 느낀 점을 바탕으로, 어떤 상황에서 어떤 검색기가 유리한지 정리해봤습니다. 여러분의 환경과 필요에 맞춰 선택하시면 됩니다.

    검색기 (Retriever) 장점 단점 추천 사용 사례
    BM25Retriever 설치 및 사용이 간편
    키워드 매칭에 강력
    적은 컴퓨팅 자원
    의미론적 유사성 파악 어려움
    동의어/유의어 처리 한계
    초기 프로토타입 개발
    명확한 키워드 기반 문서
    제한된 컴퓨팅 자원 환경
    EmbeddingRetriever (DPR 포함) 의미론적 유사성 검색 가능
    문맥 이해도가 높음
    질문 의도를 더 잘 파악
    임베딩 모델 필요 (추가 설치/학습)
    GPU 또는 고성능 CPU 요구
    초기 설정 복잡성
    고품질 검색 결과 요구
    의미가 복잡한 문서
    충분한 컴퓨팅 자원 보유

    저 같은 경우는 초기에는 BM25로 빠르게 시작하고, 성능 개선이 필요할 때 EmbeddingRetriever로 전환하는 전략을 사용했어요. 홈랩에서는 자원 제약이 있을 수 있으니, 이 표를 참고해서 현명한 선택을 하시길 바랍니다.

    마무리: RAG 챗봇, 가능성을 엿보다

    이번 Haystack 기반 RAG 파이프라인 개발은 저에게 LLM 애플리케이션 개발의 무한한 가능성을 보여준 값진 경험이었습니다. 처음엔 낯설고 복잡하게 느껴졌지만, 하나하나 직접 부딪히고 해결해나가면서 많은 걸 배울 수 있었어요. 특히 사내 지식 검색 챗봇은 업무 효율성을 혁신적으로 높일 수 있는 강력한 도구가 될 거라는 확신이 들었습니다. 물론 아직 개선할 부분은 많습니다. 사용자 인터페이스(UI)를 더 친숙하게 만들고, 검색 정확도를 높이기 위한 지속적인 모델 튜닝, 그리고 보안 강화 같은 과제들이 남아있거든요.

    만약 여러분도 회사 내부의 비효율적인 정보 탐색 문제로 고민하고 계신다면, 저처럼 Haystack과 RAG를 활용해 사내 지식 검색 챗봇 구축에 도전해보시길 강력히 추천합니다. 처음엔 삽질 좀 하겠지만, 그 과정에서 얻는 경험과 지식은 분명 여러분의 엔지니어링 역량을 한 단계 끌어올려 줄 겁니다. 다음 글에서는 이 Haystack 챗봇을 Kubernetes 환경에 배포하는 과정이나, 더 다양한 검색기를 통합하는 방법에 대해 다뤄볼 예정이니 기대해주세요!

    RAG 파이프라인과 Haystack의 주요 장점들을 요약한 인포그래픽

    RAG 파이프라인과 Haystack의 주요 장점들을 요약한 이미지입니다. LLM의 한계를 극복하고 효율적인 애플리케이션 개발을 가능하게 하죠.

  • [AI] AI 모델 클라우드 마이그레이션 체크리스트: 온프레미스 AI 전환 기준

    [AI] AI 모델 클라우드 마이그레이션 체크리스트: 온프레미스 AI 전환 기준

    AI 모델 클라우드 마이그레이션 체크리스트: 온프레미스 AI 전환 기준

    AI 모델 클라우드 마이그레이션을 검토할 때 많은 팀이 처음엔 GPU 인스턴스만 확보하면 된다고 생각합니다. 저도 예전엔 그렇게 봤는데, 실제 전환 프로젝트에 들어가면 GPU보다 먼저 터지는 문제는 데이터 경로, 모델 아티팩트 배포, 보안 경계, 준비 상태 검증이더라고요. 결국 이 작업은 서버 위치만 바꾸는 일이 아니라, 추론 요청이 들어와서 모델이 로드되고 결과가 저장되기까지의 흐름을 다시 설계하는 일에 가깝습니다.

    특히 AI 워크로드는 일반 웹 서비스보다 실패 양상이 더 복잡합니다. 애플리케이션 프로세스는 살아 있는데 모델 다운로드가 끝나지 않아 readiness가 실패할 수 있고, GPU는 한가한데 전처리 CPU가 막혀 지연이 튀는 경우도 흔합니다. 클라우드로 옮긴 뒤에도 입력 데이터가 온프레미스에 남아 있으면 왕복 지연과 전송 비용이 계속 쌓이죠. 그래서 이 글은 원론보다, 전환 회의와 전환 당일에 바로 써먹을 수 있는 체크 기준으로 정리해보겠습니다.

    AI 모델 클라우드 마이그레이션 전체 아키텍처 개요 이미지

    온프레미스 AI, 스토리지, 네트워크, 클라우드 추론 계층이 어떻게 연결되는지 한눈에 보여주는 개요 이미지입니다.

    1. 왜 AI 모델 클라우드 마이그레이션이 까다로운가

    일반 애플리케이션 이전은 보통 애플리케이션 바이너리와 데이터베이스를 중심으로 보면 됩니다. 그런데 AI 추론 서비스는 여기에 모델 파일, 토크나이저와 전처리 리소스, GPU 드라이버 호환성, 컨테이너 이미지 크기, 아티팩트 캐시, 배치와 실시간 트래픽의 공존이 더해집니다. 이 중 하나만 설계가 어긋나도 서비스는 떠 있지만 실제 요청은 느리거나 불안정해집니다.

    현장에서 자주 보는 실패는 크게 세 가지입니다. 첫째, 서비스는 클라우드에 올렸는데 입력 데이터와 피처 저장소는 사내망에 그대로 둬서 지연 시간 대부분을 네트워크 왕복에 써버리는 경우입니다. 둘째, 컨테이너 이미지는 잘 만들었는데 모델 아티팩트를 언제 어디서 받아오는지 기준이 없어 배포마다 시작 시간이 들쑥날쑥해지는 경우입니다. 셋째, 개발팀은 Kubernetes를 원하고 운영팀은 VM을 선호하는데 책임 경계를 정하지 않은 채 진행해서 장애가 나면 누구도 원인 구간을 단정하지 못하는 경우입니다.

    핵심은 기술 스택 이름이 아니라 실패 지점을 어디서 끊어 볼 수 있게 만들었느냐입니다. 모델 로딩 실패를 애플리케이션 로그에서만 보게 만들면 대응이 늦어집니다. 배포 시스템, 스토리지 접근, 네트워크 정책, 컨테이너 이벤트, readiness probe까지 각 단계에서 실패가 드러나야 운영이 됩니다. 제 경험상 마이그레이션이 잘된 팀은 최신 GPU를 쓰는 팀이 아니라, 실패를 빨리 국소화하는 팀이었습니다.

    2. AI 모델 클라우드 마이그레이션 전, 무엇을 옮기고 무엇을 남길까

    클라우드 AI 전환은 전부 이전하거나 전부 유지하는 식으로 가면 대개 비효율적입니다. 워크로드를 성격별로 나눠야 하거든요. 같은 모델이라도 실시간 API와 야간 배치의 최적 해법이 다를 수 있습니다.

    항목 온프레미스 유지가 유리한 경우 클라우드 이전이 유리한 경우 판단 포인트
    실시간 추론 API 내부 시스템 전용이고 입력 데이터가 사내망에서만 생성될 때 트래픽 변동이 크고 외부 서비스, CDN, API Gateway 연동이 많을 때 왕복 지연, 오토스케일링, 외부 노출 경로
    배치 추론 야간 고정 작업이고 유휴 GPU를 계획적으로 재활용할 수 있을 때 특정 기간에만 대량 실행이 몰리고 큐 길이가 자주 출렁일 때 실행 창 유연성, 큐 적체, 자원 점유 시간
    모델 학습 데이터 반출 제한이 강하고 장기 점유형 학습이 많을 때 짧은 기간 대규모 자원을 집중 투입해야 할 때 데이터 거버넌스, 체크포인트 저장 경로, 대역폭
    모델 저장소 폐쇄망 정책이 우선이고 외부 반출 심사가 까다로울 때 멀티 리전 배포, 버전 배포 자동화, 캐시 전략이 중요할 때 버전 전파 속도, 접근 통제, 캐시 일관성
    전처리/후처리 사내 DB, 파일서버, 내부 인증 체계와 강하게 결합돼 있을 때 API 기반으로 분리 가능하고 수평 확장이 자주 필요할 때 CPU 병목, 내부 연동 의존도, 서비스 분리 비용

    이 단계에서 꼭 문서화해두면 좋은 질문은 아래 다섯 가지입니다.

    • 모델 아티팩트는 이미지에 포함할지, 시작 시 다운로드할지, 볼륨으로 마운트할지
    • 입력 데이터는 클라우드에 복제할지, 전용 회선이나 프록시로 접근할지
    • GPU가 꼭 필요한 경로와 CPU로 충분한 경로를 분리했는지
    • 장애 시 온프레미스로 되돌리는 경로가 DNS 전환인지, 로드밸런서 가중치 조정인지, 배포 태그 롤백인지
    • 모델 버전 롤아웃과 인프라 롤아웃을 같은 절차로 묶을지, 분리할지

    실무적으로 정리하면 이렇습니다. 데이터가 사내망을 벗어나기 어렵고 지연 시간 요구가 빡빡하면 온프레미스나 하이브리드가 맞고, 트래픽 변동과 배포 빈도가 더 큰 문제라면 클라우드가 더 잘 맞습니다. 둘 다 애매하면 전체 이전보다 외부 노출 추론 API나 배치 작업부터 부분 이전이 안전합니다.

    3. AI 모델 클라우드 마이그레이션 사전 진단 체크리스트

    이 섹션은 실제 킥오프 회의에서 그대로 읽어도 됩니다. 저는 마이그레이션 전에 아래 항목이 하나라도 비어 있으면 일정을 바로 잡지 않습니다. 나중에 메꾸면 되겠지 싶어도, 보통은 막판에 가장 비싼 문제로 돌아오더라고요.

    1. 모델 목록 정리: 모델 이름, 버전, 프레임워크, 파일 크기, 로딩 경로, 토크나이저/전처리 리소스 위치, 의존 라이브러리 버전을 적습니다.
    2. 트래픽 패턴 확인: 실시간 API, 비동기 큐, 배치 실행을 분리해서 보고, 요청 급증 시점과 재시도 패턴이 있는지 확인합니다.
    3. 데이터 경로 파악: 요청 원천, 전처리 위치, 모델 호출 위치, 결과 저장소, 로그 전송 경로를 한 장의 흐름도로 그립니다.
    4. 보안 요구사항 정리: 네트워크 분리, TLS 종료 지점, Secret 주입 방식, 감사 로그 보존 위치를 정합니다.
    5. 운영 표준 확정: VM인지, 컨테이너인지, Kubernetes인지 결정하고, 배포 책임 팀을 명시합니다.
    6. 관측 지표 선정: p95 지연 시간, 오류율, 모델 로딩 시간, GPU 메모리 사용, Pod 재시작, 큐 적체, 스토리지 대기를 최소 기준으로 둡니다.
    7. 롤백 계획 수립: 어떤 이상 징후가 몇 분간 지속되면 되돌릴지, 누가 승인할지, 어떤 명령으로 되돌릴지 적습니다.
    8. 의존성 분리 검증: 코드 안에 절대 경로, 사내 DNS 이름, 로컬 마운트 경로, 특정 NIC 이름 같은 환경 종속값이 박혀 있지 않은지 확인합니다.

    제가 한 번 크게 헤맸던 사례도 딱 이 단계에서 걸렸어야 했습니다. 모델 파일은 컨테이너 시작 시 객체 저장소에서 내려받도록 바꿨는데, 토크나이저 사전 파일 경로는 예전 온프레미스 NFS 마운트 경로를 그대로 바라보고 있었거든요. 애플리케이션 로그에는 단순히 No such file or directory만 찍혀서 처음엔 이미지 문제로 봤고, 실제 원인은 설정 누락이었습니다. 모델 추론 실패는 모델 자체보다 주변 리소스 경로 누락에서 나는 경우가 생각보다 많습니다.

    사전 진단 단계에서 아래처럼 의존 파일을 한 번 훑어보면 이런 실수를 꽤 줄일 수 있습니다.

    find /opt/app -maxdepth 3 -type f \( -name "*.py" -o -name "*.yaml" -o -name "*.yml" -o -name "*.json" \) \
      -print0 | xargs -0 rg -n "/mnt/|/nfs/|/data/|\.internal|localhost|127\.0\.0\.1"

    이 명령은 코드와 설정 파일 안에 박혀 있는 환경 종속 경로, 내부 도메인, 로컬 참조를 빠르게 찾는 데 유용합니다. “이 정도는 나중에 고치면 되지” 하고 넘기면, 마이그레이션 막판에 가장 까다로운 형태로 돌아옵니다.

    4. 실전 구현 1: 현재 환경 계측과 병목 확인

    AI 모델 클라우드 마이그레이션 전에 먼저 해야 할 일은 현재 온프레미스 환경이 어디서 느린지 평균값이 아니라 병목 패턴으로 읽는 겁니다. 평균 CPU 사용률이 낮다고 안심하면 안 됩니다. 피크 시간에 CPU 전처리가 치솟는지, 디스크 대기가 늘어나는지, GPU 메모리 부족으로 컨테이너가 재시작하는지, 네트워크 인터페이스 하나에 트래픽이 몰리는지까지 봐야 합니다.

    초기에 많이 수집하는 명령은 아래 정도입니다.

    sar -u 1 5
    sar -n DEV 1 5
    iostat -x 1 5
    vmstat 1 5
    ss -ltnp
    df -h
    mount | rg -n "nfs|ceph|fuse|cifs"

    읽는 기준은 이렇게 가져가면 됩니다.

    • iostat -x에서 특정 디바이스의 await가 길고 대기열이 함께 늘면, 모델 파일 로딩이나 캐시 저장 과정에서 스토리지 병목이 있을 가능성이 큽니다. 다만 최신 SSD나 병렬 스토리지에선 %util만으로 포화 여부를 단정하긴 어렵습니다.
    • sar -n DEV에서 NIC 하나만 유난히 바쁘면 데이터 경로가 비대칭일 수 있습니다. 특히 모델 다운로드와 요청 처리 트래픽이 같은 인터페이스를 타면 전환 후 더 티가 납니다.
    • vmstat에서 swap 징후가 보이면, 모델 로딩 시 메모리 압박으로 초기화가 길어지거나 OOM 직전까지 가는 구조일 수 있습니다.
    • ss -ltnp로 실제 어떤 포트에 무엇이 바인딩되어 있는지 확인해두면, 클라우드 전환 후 readiness probe 경로와 포트 매핑 실수를 줄일 수 있습니다.
    • mount 결과에서 NFS나 원격 파일시스템 의존이 있으면, 그 경로를 클라우드에서 어떻게 대체할지 미리 정해야 합니다.

    GPU 워크로드라면 운영체제 지표만 보고 끝내면 아쉽습니다.

    nvidia-smi
    nvidia-smi dmon -s pucmem
    nvidia-smi --query-gpu=name,driver_version,memory.total,memory.used,utilization.gpu,utilization.memory --format=csv

    여기서 특히 봐야 할 건 GPU 사용률 하나가 아니라 GPU 사용률과 지연 시간의 관계입니다. GPU 사용률이 낮은데 응답이 느리면 대개 GPU가 핵심 원인은 아닙니다. 전처리 CPU, 네트워크 왕복, 스토리지 다운로드, 직렬화 구간을 먼저 의심하는 편이 맞습니다. 반대로 GPU 사용률과 메모리 사용률이 함께 치솟고 배포 직후 실패가 늘면, 컨테이너 메모리 제한이나 모델 샤딩 전략을 다시 봐야 합니다.

    현업에서 자주 놓치는 또 하나는 모델 시작 시간입니다. 서비스 지연만 재고 배포 시간을 안 재면, 오토스케일링이 필요한 순간에 새 Pod가 제때 준비되지 않습니다. 저는 아래처럼 컨테이너 이벤트와 readiness를 같이 봅니다.

    kubectl get pods -n ml -w
    kubectl describe pod -n ml <pod-name>
    kubectl logs -n ml <pod-name> --since=10m

    만약 이벤트에 이미지 풀, 볼륨 마운트, readiness probe 실패가 순서대로 찍히면 모델 자체보다 시작 절차가 병목인 경우가 많습니다. 초기화가 긴 서비스라면 readiness와 liveness만 둘 게 아니라 startupProbe까지 함께 검토하는 편이 더 안전합니다. 이거 하나로 불필요한 재시작 루프를 꽤 줄일 수 있거든요.

    AI 모델 클라우드 마이그레이션 전 온프레미스 AI 병목 점검 이미지

    마이그레이션 전 병목을 확인하는 장면을 시각화한 이미지입니다. GPU와 디스크, 네트워크 지표를 함께 보는 느낌이면 좋습니다.

    5. 실전 구현 2: 컨테이너와 설정 분리부터 정리하기

    온프레미스 AI 환경에서 흔히 보이는 상태가 “서버 한 대에 파이썬 가상환경, 모델 파일, 인증서, 임시 스크립트가 다 엉켜 있는 구조”입니다. 이 상태로 클라우드에 가면 재현성이 떨어지고, 장애가 나도 어느 레이어 문제인지 분리하기 어려워집니다. 그래서 저는 이럴 때 가장 먼저 이미지, 설정, 시크릿, 모델 아티팩트를 분리합니다.

    원칙은 단순합니다. 이미지에는 코드와 런타임만 넣고, 환경별 차이는 환경 변수와 Secret으로 분리하고, 모델 아티팩트는 별도 경로에서 가져오게 만듭니다. 모델을 이미지에 굽는 방식은 작은 모델이나 배포 빈도가 낮을 때는 편하지만, 이미지가 비대해지고 버전 교체가 잦아지면 배포 속도를 늦춥니다. 반대로 매번 시작 시 다운로드하게 하면 시작 시간이 길어질 수 있으니, 배포 빈도와 모델 크기를 같이 봐야 합니다.

    배포 방식 언제 유리한가 피해야 할 상황 주요 리스크
    모델을 이미지에 포함 모델 크기가 작고 배포 빈도가 낮을 때 버전 교체가 잦고 이미지 전파 시간이 길 때 이미지 비대화, 롤백 지연
    시작 시 객체 저장소에서 다운로드 버전 교체가 잦고 중앙 관리가 중요할 때 시작 시간이 엄격하거나 네트워크 변동이 큰 환경 Cold start 증가, 다운로드 실패
    공유 볼륨/PVC 마운트 같은 노드나 클러스터에서 여러 Pod가 재사용할 때 스토리지 성능이 불안정하거나 락 경합이 있을 때 I/O 병목, 마운트 실패

    Kubernetes 배포를 예로 들면, 최소한 아래 정도까지는 분리해두는 편이 좋습니다.

    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: inference-api
    spec:
      replicas: 2
      selector:
        matchLabels:
          app: inference-api
      template:
        metadata:
          labels:
            app: inference-api
        spec:
          containers:
            - name: api
              image: registry.example.com/inference-api:1.0.0
              imagePullPolicy: IfNotPresent
              ports:
                - containerPort: 8080
              env:
                - name: MODEL_PATH
                  value: /models/current
                - name: LOG_LEVEL
                  value: info
              envFrom:
                - secretRef:
                    name: inference-api-secret
              readinessProbe:
                httpGet:
                  path: /ready
                  port: 8080
                initialDelaySeconds: 10
                periodSeconds: 5
                failureThreshold: 6
              livenessProbe:
                httpGet:
                  path: /healthz
                  port: 8080
                initialDelaySeconds: 30
                periodSeconds: 10
              startupProbe:
                httpGet:
                  path: /healthz
                  port: 8080
                failureThreshold: 30
                periodSeconds: 10
              volumeMounts:
                - name: model-volume
                  mountPath: /models
          volumes:
            - name: model-volume
              persistentVolumeClaim:
                claimName: model-pvc

    이 설정에서 중요한 건 화려한 기능이 아니라 네 가지입니다. 모델 경로를 코드에 하드코딩하지 않는 것, readiness와 liveness를 분리하는 것, 초기화가 길면 startupProbe를 두는 것, Secret을 이미지 밖으로 빼는 것입니다. readiness는 “트래픽을 받을 준비가 됐는지”, liveness는 “프로세스가 비정상 상태인지”를 보는 기준입니다. 초기화가 오래 걸리는 서비스에서 startupProbe 없이 liveness를 너무 일찍 때리면 재시작 루프로 빠질 수 있습니다.

    배포 태그도 latest보다는 고정 버전을 쓰는 편이 낫습니다. 이건 취향 문제가 아니라 롤백 속도 문제에 가깝습니다.

    kubectl apply -f deployment.yaml
    kubectl rollout status deployment/inference-api
    kubectl get pods -l app=inference-api
    kubectl logs deploy/inference-api --tail=100
    kubectl rollout history deployment/inference-api

    검증할 때는 Pod가 뜨는지만 보면 부족합니다. 로그에서 모델 로딩 완료 메시지, 외부 저장소 접근 성공, 포트 바인딩, readiness 통과를 함께 확인해야 합니다. 특히 애플리케이션이 첫 요청 직전에 모델을 lazy load하도록 짜여 있으면, rollout status가 끝나도 실제 서비스 시점에만 장애가 날 수 있습니다. 이 경우엔 사전 검증 요청을 명시적으로 날려 워밍업까지 확인하는 편이 안전합니다.

    컨테이너 이미지, 환경 변수, 스토리지 볼륨, 서비스 경로가 어떻게 분리되는지 보여주는 구성 다이어그램입니다.

    6. 네트워크와 보안: 늦게 보면 일정이 무너집니다

    클라우드 AI 전환에서 일정이 가장 자주 밀리는 구간은 성능 튜닝보다 보안 승인입니다. 온프레미스에서는 그냥 되던 내부 호출이, 클라우드로 오면 Ingress, Egress, DNS, 인증서, 프록시, NAT, 감사 로그 요건을 통과해야 합니다. 이걸 배포 직전에 맞추려 하면 예외 규칙만 늘고, 운영 설명 가능성은 오히려 떨어집니다.

    • Ingress와 내부 서비스 경로를 분리합니다. 외부 공개 API와 내부 관리 API를 같은 진입점에 두지 않는 편이 낫습니다.
    • Secret은 이미지에 넣지 말고 Secret 관리 기능이나 외부 저장소에서 주입합니다.
    • Egress 허용 대상을 도메인, 포트, 목적별로 문서화합니다. 모델 다운로드, 인증, 로그 전송, 메트릭 전송을 분리해서 적어야 합니다.
    • 감사 로그는 누가 모델 버전을 배포했고, 누가 Secret을 변경했고, 누가 네트워크 정책을 열었는지 추적 가능해야 합니다.

    제가 실제로 겪었던 전형적인 사고는 이렇습니다. 개발 환경에서는 잘 되던 모델 API가 운영 클러스터에서만 외부 인증 토큰 발급에 실패했습니다. 앱 로그에는 단순한 timeout처럼 보여서 처음엔 코드 문제로 봤죠. 원인은 운영 네트워크 정책에서 인증 엔드포인트로 나가는 Egress가 막혀 있던 것이었습니다. 이런 문제는 “애플리케이션이 떠 있느냐”보다 의존 대상까지 실제로 연결되느냐를 따로 검증해야 잡힙니다.

    배포 전 연결성 테스트는 아래처럼 나눠서 보는 편이 좋습니다.

    curl -vk https://example.internal.health/ready
    curl -vk https://auth.example.com/
    nslookup auth.example.com
    dig auth.example.com +short
    openssl s_client -connect auth.example.com:443 -servername auth.example.com </dev/null

    이 조합이 좋은 이유는 실패 지점을 분리해주기 때문입니다. nslookup이나 dig에서 이름 해석이 안 되면 DNS 문제고, curl -vk에서 연결 자체가 안 되면 라우팅이나 방화벽 문제일 가능성이 큽니다. openssl s_client 단계에서 깨지면 인증서 체인이나 SNI 관련 문제를 의심할 수 있습니다. 보안팀이나 네트워크팀과 이야기할 때도 “안 돼요”보다 “DNS는 되고 TLS handshake에서 끊깁니다”가 훨씬 빠릅니다.

    추가로, AI 추론 서비스는 외부 모델 저장소나 내부 객체 저장소에서 큰 파일을 가져오는 경우가 많습니다. 그래서 Egress를 한 번 열어놓고 끝낼 게 아니라, 어떤 Pod가 어떤 목적지로 나가야 하는지를 좁혀 적는 편이 좋습니다. 운영팀 입장에서는 이게 정책 관리의 시작점입니다.

    7. AI 모델 클라우드 마이그레이션 전환 당일 체크리스트와 롤백 기준

    마이그레이션 전략은 기술보다 절차가 더 중요할 때가 많습니다. 전환 당일에는 “한 번에 바꾸고 보자”보다 단계적으로 바꾸는 편이 낫습니다. 제가 자주 쓰는 순서는 대체로 아래와 같습니다.

    1. 기존 온프레미스 서비스의 버전, 헬스 상태, 주요 로그 패턴을 기록합니다.
    2. 클라우드 환경에서 동일 입력으로 응답 형식과 로딩 시간을 사전 검증합니다.
    3. 읽기 전용 트래픽이나 일부 가중치만 새 환경으로 보냅니다.
    4. 오류 로그, 지연 시간, 모델 로딩 상태, 외부 연동 성공 여부를 집중 관찰합니다.
    5. 이상 징후가 없으면 트래픽 비율을 점진적으로 올립니다.
    6. 문제가 생기면 DNS, 로드밸런서, 서비스 라우팅 기준으로 즉시 롤백합니다.

    여기서 핵심은 “언제 되돌릴지”를 미리 적어두는 겁니다. 현장에서는 기술적 판단보다 심리적 지연이 더 무섭거든요. 그래서 애매한 표현보다 징후 중심 기준을 쓰는 편이 낫습니다.

    • readiness probe 실패가 연속으로 발생한다
    • 모델 로딩 실패 로그가 반복된다
    • 인증, 저장소 접근, 외부 API 연동 timeout이 지속된다
    • 온프레미스 대비 명확한 성능 저하가 보이는데 원인을 즉시 분리하지 못한다
    • Pod 재시작이나 노드 재스케줄링이 예상보다 잦다

    실무에서는 숫자 하나로 모든 걸 결정하기보다, 정상 로그와 비정상 로그의 차이를 미리 캡처해두는 편이 훨씬 도움이 됩니다. 예를 들어 정상일 때는 모델 로딩 완료, tokenizer 초기화 완료, readiness 성공이 순서대로 나오고, 비정상일 때는 다운로드 재시도나 mount 실패가 먼저 보인다면 전환 당일에 대시보드보다 로그 한 줄이 더 빨리 방향을 줍니다.

    가중치 기반 전환을 쓰는 환경이라면 전체 컷오버보다 부분 전환이 낫습니다. 이유는 단순합니다. AI 추론 서비스는 첫 요청 시 캐시가 비어 있는 경우가 많아서, 일부 트래픽으로 시작해야 cold path를 실제로 밟아볼 수 있기 때문입니다. 저는 이런 상황이면 전환 초반엔 새 환경을 정상 동작 확인용으로 보고, 안정화가 끝난 뒤에야 확장 수단으로 취급합니다.

    AI 모델 클라우드 마이그레이션 후 검증 대시보드 이미지

    전환 직후 검증 단계에서 운영자가 어떤 화면을 중점적으로 보는지 보여주는 대시보드형 이미지입니다.

    8. 검증과 결과 해석: 성공 여부는 이렇게 봅니다

    AI 모델 클라우드 마이그레이션이 끝났다고 판단하려면 서버가 켜져 있다는 사실만으로는 부족합니다. 적어도 아래 네 축이 같이 맞아야 합니다.

    • 기능 검증: 같은 입력에 대해 기대한 형식의 응답이 나오는가
    • 운영 검증: 로그 수집, 알림, 접근 제어, 배포 이력이 정상 동작하는가
    • 성능 검증: 병목이 GPU인지, CPU 전처리인지, 네트워크인지, 스토리지인지 구분 가능한가
    • 복구 검증: 재배포와 롤백이 문서대로 실제 수행되는가

    판단 기준도 조금 더 실무적으로 가져가야 합니다.

    • 응답은 정상이지만 시작 시간이 과하게 길다면 모델 아티팩트 다운로드 경로, 이미지 크기, PVC 마운트 지연을 먼저 봅니다.
    • GPU 사용률이 낮은데 지연이 높다면 CPU 전처리, 직렬화, 네트워크 왕복을 먼저 의심하는 편이 맞습니다.
    • Pod 재시작이 잦다면 메모리 제한, readiness 경로, 파일 마운트 실패, liveness 기준 과민 설정을 차례로 봅니다.
    • 특정 시간대에만 문제가 생기면 배치와 실시간 추론이 같은 노드 풀이나 같은 스토리지를 공유하는지 확인합니다.
    • 첫 요청만 느리고 이후는 괜찮다면 lazy load, 캐시 미스, DNS 캐시, 원격 다운로드를 먼저 의심합니다.

    여기서 중요한 건 수치 그 자체보다 설명 가능성입니다. 운영자가 “왜 느린지”를 두세 단계 안에 좁힐 수 있으면 성공에 가깝고, 장애가 나도 어느 레이어를 먼저 봐야 할지 모르면 아직 미완성입니다. 화려한 대시보드보다 원인 추적 경로가 짧은 구조가 더 값집니다.

    실제로 재현 가능한 시나리오를 하나 들면 이렇습니다. 평소엔 응답이 괜찮다가 배포 직후 몇 분 동안만 지연이 튀는 서비스가 있었습니다. GPU는 여유가 있었고 애플리케이션도 살아 있었습니다. 원인은 새 Pod가 올라올 때마다 모델 파일을 원격 저장소에서 다시 받아오고, 동시에 전처리 사전 파일도 초기화하느라 readiness 통과 직전까지 시간이 밀리던 구조였습니다. 이 경우 GPU 교체는 답이 아니고, 모델 캐시 전략과 readiness 기준을 손보는 게 답입니다.

    9. 자주 묻는 질문과 현업 권고

    온프레미스 AI를 전부 클라우드로 옮겨야 할까요?

    그럴 필요는 없습니다. 데이터 반출 제한이 강하거나 내부 시스템 전용이라면 일부는 남기는 게 맞습니다. 특히 학습 데이터, 민감 로그, 내부 전처리 파이프라인은 남기고, 외부 공개형 추론 API나 탄력성이 필요한 배치만 클라우드로 분리하는 구성이 현실적입니다.

    Kubernetes가 꼭 필요할까요?

    작은 팀이고 모델 수가 적고 변경 빈도가 낮다면 처음부터 복잡도를 올릴 필요는 없습니다. 다만 모델 버전이 자주 바뀌고, 환경이 늘고, 롤백 속도가 중요해지면 컨테이너 오케스트레이션이 운영 피로도를 줄여줍니다. 제 기준은 단순합니다. 서비스가 한두 개이고 담당자가 고정돼 있으면 VM도 가능하지만, 버전 수와 팀 수가 늘기 시작하면 Kubernetes 쪽이 결국 덜 아픕니다.

    비용보다 먼저 볼 것은 뭔가요?

    데이터 경로와 운영 절차입니다. 비용은 나중에 줄일 수 있어도, 데이터 왕복 구조와 롤백 체계가 꼬이면 되돌리기가 어렵습니다. 특히 모델은 클라우드에 있고 데이터는 온프레미스에 남아 있는 상태를 아무 생각 없이 만들면, 지연과 운영 복잡도가 같이 올라갑니다.

    이럴 땐 어떤 선택이 맞을까요?

    명확하게 정리하면 이렇습니다. 내부 데이터 의존이 강하고 지연에 민감하면 하이브리드가 맞고, 배포 속도와 탄력성이 더 중요하면 클라우드 이전이 더 잘 맞습니다. 작은 팀이 첫 전환을 한다면 전부 옮기기보다 배치 또는 외부 노출 API부터 시작하는 편이 안전합니다. 반대로 이미 모델 버전이 자주 바뀌고 롤백이 잦다면, 미루지 말고 컨테이너화와 설정 분리부터 정리한 뒤 클라우드로 가는 게 낫습니다.

    AI 모델 클라우드 마이그레이션 선택 기준 요약 이미지

    상황별 권장 전략을 빠르게 비교할 수 있는 요약 인포그래픽입니다.

    마무리: 이런 경우엔 이렇게 가시면 됩니다

    온프레미스 AI 환경이 이미 안정적이고 데이터가 사내망에 깊게 묶여 있다면, 무리해서 전부 옮기지 않는 편이 좋습니다. 이 경우엔 배치 추론이나 외부 공개 API부터 부분 이전이 잘 맞습니다. 반대로 트래픽 변동이 크고 모델 배포 주기가 빠르며, 운영팀이 버전 롤백과 오토스케일링에 자주 시달린다면 클라우드 쪽이 더 유리합니다.

    한 줄로 줄이면 이렇습니다. AI 모델 클라우드 마이그레이션은 GPU 이전 프로젝트가 아니라 운영 모델 재설계 프로젝트입니다. 데이터 경로가 복잡하면 하이브리드로 시작하고, 출시 속도가 우선이면 컨테이너와 설정 분리부터 끝내고 가는 편이 안전합니다. 무엇부터 할지 애매하다면 제일 먼저 계측과 의존성 탐색부터 해보세요. 이 순서가 생각보다 많이 살려줍니다.

    실무적으로는 이렇게 판단하면 됩니다. 이럴 땐 A: 사내 데이터 의존이 강하고 보안 경계가 복잡하면 하이브리드. 저럴 땐 B: 모델 버전 변경이 잦고 외부 트래픽 변동이 크면 클라우드 이전. 둘 다 애매하면 C: 전환보다 먼저 현재 병목과 숨은 의존성을 계측. 관련 글로 AI 인프라 구축 체크리스트나 모델 배포 전략 가이드를 함께 묶어두면 내부 링크 구조와 SEO 흐름도 더 좋아집니다.

  • [AI] LLM 추론 최적화: 지연 문제 진단과 병목 해결 전략

    [AI] LLM 추론 최적화: 지연 문제 진단과 병목 해결 전략

    목차

    [인프라] LLM 추론 최적화: 지연 문제 진단과 병목 해결 전략

    LLM 추론 최적화는 GPU를 바꾸기 전에 먼저 해볼 일이 꽤 많습니다. 운영에서 실제로 느려지는 이유를 뜯어보면, 모델 자체보다 앞단 연결, 큐 적체, 토크나이저 CPU 경합, 스트리밍 버퍼링 같은 바깥 요인이 더 자주 문제를 만들더라고요. 저도 처음엔 “모델이 무거워서 느리겠지”라고 봤는데, 막상 들어가 보면 keep-alive 미설정, 요청별 JSON 직렬화 비용, 긴 입력이 몰릴 때 생기는 prefill 지연이 한꺼번에 겹친 경우가 많았습니다. 그래서 이 글은 막연한 튜닝 팁보다 어디서 기다리는지 먼저 분해하고, 그다음 손보는 순서에 집중합니다.

    핵심은 간단합니다. TTFT(Time To First Token, 첫 토큰까지 시간), TPOT(Time Per Output Token, 출력 토큰당 시간), Queue Wait(대기열 대기 시간)를 분리해서 봐야 합니다. total latency 하나만 붙들고 있으면 프록시 문제를 GPU 문제로, CPU 병목을 모델 병목으로 잘못 읽기 쉽거든요. 운영에서 시간을 아끼는 가장 빠른 방법은 최적화 자체보다 틀린 곳을 만지지 않는 것입니다.

    LLM 추론 최적화 관점의 전체 병목 진단 아키텍처 이미지

    LLM 추론 지연 시간 병목을 한눈에 보여주는 전체 아키텍처 개요입니다.

    1. LLM 추론 최적화는 “GPU가 느리다”보다 “어느 단계가 줄을 세우는가”로 봐야 합니다

    추론 요청은 보통 아래 단계를 지납니다. 이걸 한 덩어리로 보면 답이 잘 안 나옵니다.

    • Ingress 또는 API 프록시: 연결 수립, TLS, keep-alive, buffering
    • Queue: 워커가 바쁘거나 동시성 제한에 걸려 대기
    • Tokenization: 입력 전처리, 템플릿 결합, 토큰 계산
    • Prefill: 긴 입력 문맥을 모델이 한 번에 읽는 구간
    • Decode: 토큰을 하나씩 생성하는 구간
    • Post-processing: 스트리밍 직렬화, 로그, 압축, 감사 기록

    여기서 많이 헷갈리는 지점이 있습니다. GPU 사용률이 낮다고 효율적인 건 아닙니다. 오히려 GPU가 놀고 있는데 응답이 느리다면 그때가 더 골치 아픈 경우가 많습니다. 계산 장치가 아니라 요청 공급 경로가 끊기고 있다는 뜻일 수 있어서요. 반대로 GPU가 꽉 찼다고 바로 나쁜 것도 아닙니다. 처리량이 중요한 작업이라면 높은 점유율이 오히려 정상입니다.

    운영에서 먼저 보는 지표

    • TTFT: 사용자가 가장 먼저 체감하는 값입니다. 챗봇, 검색 보조, 문서 질의응답은 대부분 여기서 승부가 납니다.
    • TPOT: 생성이 시작된 뒤 토큰이 끊기지 않고 매끄럽게 이어지는지 보여줍니다.
    • P95/P99 tail latency: 평균만 보면 느린 요청이 숨어버립니다.
    • Queue Wait: 워커 수와 동시성 상한이 맞는지 판단할 때 중요합니다.
    • GPU utilization / memory utilization / memory used: 계산 병목인지, 메모리 압박인지 가늠하는 기본 축입니다.
    • CPU user/system, run queue: 토크나이저, 로깅, 압축, 네트워크 스택 비용을 읽는 데 유용합니다.
    • socket reuse, retransmission, proxy buffering: 스트리밍인데 첫 토큰이 늦을 때 꼭 봐야 합니다.

    2. 지연은 세 갈래로 자르면 판단이 빨라집니다

    실무에서 자주 쓰는 분류는 아래 세 가지입니다. 이렇게 나눠 놓으면 무엇부터 의심할지 훨씬 또렷해집니다.

    구간 관찰되는 증상 근본 원인 후보 먼저 할 일 지금 하지 말 것
    입장 전 연결은 되는데 첫 응답이 늦음 프록시 buffering, keep-alive 미사용, 워커 앞단 큐 적체 프록시와 클라이언트의 연결 재사용, 스트리밍 설정 확인 GPU 교체부터 검토
    모델 전후 GPU는 한가한데 total latency가 큼 토크나이저 CPU 경합, JSON 직렬화, 감사 로그, gzip, 동기식 후처리 요청 단계별 계측 추가, CPU 코어 점유 패턴 확인 배치만 무작정 키우기
    모델 내부 TTFT도 길고 TPOT도 느림 긴 입력으로 인한 prefill 부담, KV cache 압박, 메모리 병목, 배치 과대 입력 길이 정책, 동시성 제한, 배치 전략 재조정 로그만 줄이고 끝내기

    이 표에서 중요한 건 증상과 처방을 1:1로 바로 묶지 않는다는 점입니다. 예를 들어 TTFT만 길고 TPOT은 멀쩡하다면 prefill이 길어졌을 수도 있지만, 프록시가 첫 바이트를 묶고 있을 수도 있습니다. 반대로 첫 토큰은 빨리 나오는데 뒤가 끊긴다면 디코드보다 스트리밍 flush 간격, 네트워크 backpressure, 응답 직렬화 비용이 더 문제일 때도 있습니다.

    3. 실전 진단은 시스템 30%, 애플리케이션 70%입니다

    운영 현장에서 느낀 건 이겁니다. nvidia-smi만 봐서는 절반도 못 찾습니다. 시스템 지표로 병목 위치를 좁히고, 애플리케이션 계측으로 원인을 확정해야 합니다. 아래 명령어들은 Linux에서 많이 쓰는 조합인데, 배포판에 따라 <code>sysstat 패키지 설치가 필요하고 소켓 정보는 권한에 따라 일부만 보일 수 있습니다.

    1단계. CPU, 런큐, 디스크, 네트워크, 소켓을 같이 봅니다

    PID=12345
    PORT=8000
    
    pidstat -dur -h -p "$PID" 1
    mpstat -P ALL 1
    vmstat 1
    iostat -x 1
    sar -n DEV 1
    ss -tinp | grep ":$PORT"
    

    각 명령어를 보는 기준은 꽤 분명합니다.

    • pidstat -dur: 프로세스별 CPU, I/O, minor/major fault를 함께 봅니다. CPU가 높은데 GPU가 비면 모델 밖 병목일 가능성이 큽니다.
    • mpstat -P ALL: 특정 코어만 과열되면 토크나이저 스레드, 로깅 스레드, 이벤트 루프가 한쪽에 몰린 상황을 의심합니다.
    • vmstat: run queue가 길고 context switch가 튄다면 스레드 수를 늘린 게 오히려 독이 된 경우가 많습니다.
    • iostat -x: 디스크 활용률과 await를 같이 봅니다. 모델 로드가 아니라 로그 flush나 swap 때문에 지연될 수도 있습니다.
    • sar -n DEV: 인터페이스 오류, burst 패턴, 원격 스토리지 경유 트래픽을 파악할 때 좋습니다.
    • ss -tinp: ESTAB 연결이 재사용되는지, 요청마다 새 소켓이 생기는지 확인합니다.

    이 단계에서 자주 나오는 실패 모드가 있습니다. API 프로세스 CPU는 높고 GPU는 비어 있는데, 팀은 계속 배치나 양자화만 만집니다. 그 방향은 대개 틀립니다. 토큰을 만들기 전에 이미 시간을 다 써버리고 있는데 모델 쪽만 튜닝하고 있는 셈이거든요.

    2단계. GPU가 진짜 계산 병목인지 구분합니다

    nvidia-smi
    nvidia-smi dmon -s pucvmet -d 1
    watch -n 1 'nvidia-smi --query-gpu=utilization.gpu,utilization.memory,memory.used,memory.total,power.draw --format=csv,noheader'
    

    여기서는 평균보다 패턴을 보는 편이 낫습니다. 참고로 nvidia-smi dmon은 GPU와 드라이버 지원 여부에 따라 표시 가능한 항목이 조금 다를 수 있습니다.

    • 사용률이 톱니형으로 튀고 중간에 비는 경우: 요청 공급이 끊기거나 큐에서 건네주는 속도가 불안정한 경우가 많습니다.
    • 사용률은 높은데 메모리도 꽉 찬 경우: 긴 입력, 큰 배치, 동시성 과다로 prefill 또는 KV cache 압박이 강할 가능성이 큽니다.
    • 메모리는 넉넉한데 TPOT만 느린 경우: 계산보다 스트리밍, 후처리, 네트워크 flush 간격을 함께 봐야 합니다.

    운영 판단에서 중요한 건 이것입니다. GPU utilization 하나로 건강 상태를 판정하지 말 것. 메모리 사용 패턴, 요청 간 공백, TTFT/TPOT과 같이 봐야 의미가 생깁니다.

    3단계. API 레벨에서 연결 시간과 첫 바이트 시간을 분리합니다

    curl -N -sS -o /dev/null \
      -w 'dns=%{time_namelookup}\nconnect=%{time_connect}\nappconnect=%{time_appconnect}\nstarttransfer=%{time_starttransfer}\ntotal=%{time_total}\n' \
      -H 'Content-Type: application/json' \
      -d '{"prompt":"안녕하세요. LLM 지연 진단 테스트입니다."}' \
      http://127.0.0.1:8000/generate
    

    time_starttransfer는 첫 바이트까지의 시간을 보여주므로, 스트리밍 API에서는 TTFT에 가까운 운영 힌트로 쓸 수 있습니다. 다만 엄밀히 말하면 네트워크 경로와 서버 처리 시간을 함께 포함한 값이라, 모델 내부의 첫 토큰 생성 시각과 완전히 같지는 않습니다. 저는 프록시 직통 호출과 프록시 경유 호출을 둘 다 같은 프롬프트로 반복 측정합니다. 여기서 두 값 차이가 크면 모델이 아니라 네트워크 경로와 프록시 설정부터 보는 게 맞습니다.

    4. 애플리케이션 계측이 없으면, 튜닝이 아니라 추측을 하게 됩니다

    시스템 지표만으로는 “느리다”는 사실까지만 알 수 있습니다. 운영에서 실제로 문제를 줄이려면 요청 하나가 어디에서 얼마나 머물렀는지가 로그에 남아 있어야 합니다. 최소 기준으로 두는 건 아래 네 가지입니다.

    • 큐에 들어간 시각과 워커가 잡은 시각
    • 토크나이즈 시작/종료 시각
    • 모델 실행 시작 시각과 첫 토큰 시각
    • 스트리밍 완료 시각과 최종 응답 바이트 수
    import time
    import logging
    from contextvars import ContextVar
    from fastapi import FastAPI, Request
    
    logging.basicConfig(level=logging.INFO)
    log = logging.getLogger("llm_latency")
    request_id_var = ContextVar("request_id", default="-")
    app = FastAPI()
    
    def now_ms() -> float:
        return time.perf_counter() * 1000
    
    @app.middleware("http")
    async def timing_middleware(request: Request, call_next):
        rid = request.headers.get("x-request-id", "-")
        request_id_var.set(rid)
        t0 = now_ms()
        response = await call_next(request)
        total_ms = now_ms() - t0
        log.info("request_id=%s path=%s status=%s total_ms=%.2f",
                 rid, request.url.path, response.status_code, total_ms)
        response.headers["X-Request-Time-Ms"] = f"{total_ms:.2f}"
        return response
    
    def log_stage(stage: str, started_ms: float, **fields):
        elapsed_ms = now_ms() - started_ms
        extra = " ".join(f"{k}={v}" for k, v in fields.items())
        log.info("request_id=%s stage=%s elapsed_ms=%.2f %s",
                 request_id_var.get(), stage, elapsed_ms, extra)
        return now_ms()
    
    # 예시 흐름
    # t = now_ms()
    # t = log_stage("queue_wait", t, queue_depth=queue_depth)
    # t = log_stage("tokenize", t, prompt_chars=len(prompt), prompt_tokens=prompt_tokens)
    # t = log_stage("prefill", t)
    # t = log_stage("first_token", t)
    # t = log_stage("decode", t, output_tokens=output_tokens)
    # t = log_stage("serialize", t, bytes=response_bytes)
    

    이 정도만 있어도 판단이 꽤 달라집니다. 전체 응답 시간이 길어도 tokenize가 길다면 CPU 쪽이고, first_token 전까지 오래 걸리면 큐나 prefill이 의심됩니다. serialize가 길면 모델이 아니라 응답 포맷팅이나 로깅 설계가 발목을 잡고 있을 수 있습니다. 이거 로그 한 번 쪼개 놓으면 생각보다 훨씬 편하더라고요.

    LLM 지연 시간 계측과 병목 구간 분리를 설명하는 이미지

    요청 시간을 단계별로 쪼개 기록하는 계측 흐름 예시입니다.

    5. LLM 추론 최적화에서 우선순위 높게 손볼 포인트

    운영에서 효과가 큰 건 의외로 화려한 알고리즘보다 파이프라인의 낭비를 없애는 일입니다. 아래 항목은 체감 개선이 뚜렷했던 것들입니다.

    프록시와 연결 재사용: 첫 토큰이 늦으면 여기부터 봅니다

    스트리밍 응답을 프록시 뒤에 둘 때는 keep-alive, buffering, HTTP 버전이 맞물립니다. 이 셋 중 하나만 어긋나도 사용자는 “아예 멈춘 것 같다”고 느낍니다.

    upstream llm_backend {
        server 127.0.0.1:8000;
        keepalive 64;
    }
    
    server {
        listen 80;
        server_name _;
    
        location / {
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_set_header Host $host;
            proxy_buffering off;
            proxy_request_buffering off;
            proxy_read_timeout 300s;
            proxy_send_timeout 300s;
            proxy_pass http://llm_backend;
        }
    }
    

    특히 proxy_buffering off;는 스트리밍 경로에서 중요합니다. 첫 토큰이 서버에서는 나왔는데 프록시가 중간에서 모아두면, 클라이언트는 모델이 느린 줄 압니다. 이런 경우 GPU 로그를 아무리 봐도 원인이 잘 안 나옵니다. 첫 바이트를 누가 붙잡고 있는지를 봐야 합니다. 환경에 따라 애플리케이션에서 X-Accel-Buffering: no 헤더를 함께 쓰는 것도 도움이 됩니다.

    CPU 경합 줄이기: 토크나이저와 로그가 의외로 많이 잡아먹습니다

    GPU 서버에서 CPU를 가볍게 보는 경우가 많지만, 실제론 토크나이저와 직렬화, 로깅, 압축이 CPU를 잡아먹으면서 TTFT를 흔드는 일이 잦습니다. 특히 긴 프롬프트를 템플릿과 합치는 코드가 비효율적이면 모델을 호출하기도 전에 시간이 새기 시작합니다.

    • DEBUG 로그와 요청별 전체 payload 로깅은 스트리밍 경로에서 끄는 편이 낫습니다.
    • gzip은 대역폭이 정말 아쉬운 환경이 아니라면 스트리밍 응답에선 먼저 의심해 볼 만한 비용입니다.
    • 토크나이저 스레드 수와 워커 수를 동시에 키우면 오히려 코어 경합이 심해질 수 있습니다.
    • 한 프로세스에 너무 많은 역할을 몰아넣지 말아야 합니다. 추론, 로깅, 수집 에이전트, 배치 전처리가 같은 코어 그룹을 두드리면 tail latency가 나빠집니다.

    실제로 자주 본 실패 패턴은 이렇습니다. 평균 응답 시간은 그럭저럭인데 사용자가 느끼는 건 “가끔 엄청 느리다”입니다. 이런 경우 평균보다 느린 요청 몇 건의 stage 로그를 보는 편이 훨씬 빠릅니다. tail latency는 대개 경합에서 생기고, 경합은 평균값에 잘 안 드러나거든요.

    배치, 동시성, 컨텍스트 길이: 세 개를 따로 보지 마세요

    이 세 가지는 묶어서 봐야 합니다. 배치만 키우면 throughput은 나아질 수 있지만 TTFT가 손해를 보고, 동시성을 올리면 GPU는 더 바빠지지만 queue wait와 메모리 압박이 같이 커질 수 있습니다. 긴 입력은 prefill을 늘리고, prefill이 늘면 첫 토큰이 늦어집니다. 결국 한 가지 값보다 서비스 목표에 맞는 균형이 더 중요합니다.

    목표 우선 지표 추천 전략 피해야 할 실수
    대화형 챗봇 TTFT, P95 짧은 큐, 과도한 배치 회피, 입력 길이 상한 관리 처리량 욕심으로 배치 과대 설정
    비동기 문서 생성 Throughput, GPU 점유율 배치 확대, 워커 활용도 최적화, 후처리 비동기화 챗봇용 설정을 그대로 재사용
    요청 길이 편차가 큰 서비스 P99, Queue Wait 짧은 요청과 긴 요청 분리, admission control 적용 모든 요청을 같은 큐에 넣기
    GPU 메모리 여유가 적음 안정성, 실패율 동시성 상한과 입력 길이 정책을 먼저 고정 OOM을 배치 재시도로만 덮기

    판단 기준은 단순합니다. 사람이 기다리는 인터랙티브 서비스면 TTFT를, 백그라운드 작업이면 throughput을 우선합니다. 둘 다 잡겠다고 한 설정으로 몰아가면 보통 둘 다 애매해집니다.

    6. 재현 가능한 트러블슈팅 시나리오: “가끔만” 느릴 때가 제일 어렵습니다

    현실적인 시나리오 하나를 보겠습니다. 사내 문서 요약 API가 있고, 프록시 뒤에서 스트리밍으로 응답합니다. 사용자는 “매번 느린 건 아닌데, 어떤 요청은 첫 응답이 한참 뒤에 나온다”고 합니다. 이때 평균만 보면 문제가 잘 안 보입니다. 저는 이런 순서로 갑니다.

    1. 프롬프트 길이가 비슷한 요청과 유난히 긴 요청을 분리합니다. 긴 입력이 섞이면 prefill 지연과 앞단 문제를 구분하기 어려워집니다.
    2. 프록시 직통과 프록시 경유를 각각 curl -w로 여러 번 측정합니다. starttransfer 차이가 크면 프록시부터 봅니다.
    3. ss로 연결이 재사용되는지 확인합니다. 매 요청 새 연결이면 keep-alive가 안 먹고 있을 수 있습니다.
    4. pidstat / mpstat로 느린 순간 CPU 특정 코어가 튀는지 봅니다. 토크나이즈, 로그 flush, 압축이 원인일 때가 많습니다.
    5. nvidia-smi dmon으로 GPU가 중간에 비는지 확인합니다. 비는 구간이 길면 모델이 아니라 공급 경로 문제일 가능성이 큽니다.
    6. 애플리케이션 로그에서 queue_wait, tokenize, first_token 단계를 비교합니다.

    이런 케이스에서 자주 잡히는 원인은 두 가지였습니다. 하나는 프록시가 스트리밍을 묶고 있던 경우, 다른 하나는 입력 전처리와 로그가 같은 CPU 코어를 두드려서 TTFT가 흔들리던 경우입니다. 둘 다 공통점이 있습니다. 평균 total latency만 보면 잘 안 보인다는 점입니다.

    자주 틀리는 해석과 진짜 원인

    • GPU 사용률이 낮다: 좋은 신호가 아닐 수 있습니다. 요청 공급이 끊기거나 CPU가 앞단에서 병목일 수 있습니다.
    • 디스크가 바쁘다: 모델을 계속 읽고 있다고 단정하면 안 됩니다. 로그 적재, 임시 파일, swap 가능성도 같이 봐야 합니다.
    • 로컬 환경이라 네트워크 문제는 아니다: 로컬에서도 프록시 buffering, 소켓 재사용, flush 지연은 충분히 생깁니다.
    • 배치를 키우면 무조건 효율적이다: 처리량은 좋아질 수 있어도 TTFT와 tail latency는 악화될 수 있습니다.
    • 평균값만 줄면 됐다: 사용자는 평균이 아니라 느린 요청을 기억합니다. 운영 품질은 P95/P99에서 갈립니다.

    7. 검증은 “얼마나 빨라졌나”보다 “어느 단계가 줄었나”가 중요합니다

    튜닝 후 검증에서 봐야 할 건 단순한 전후 비교표가 아닙니다. 병목 위치가 실제로 이동했는지를 확인해야 합니다. 예를 들어 total time은 줄었는데 TTFT가 그대로면 사용자는 여전히 답답하다고 느낄 수 있습니다. 반대로 TTFT가 개선됐는데 TPOT이 흔들리면 스트리밍 경험은 여전히 좋지 않을 수 있습니다.

    • TTFT: 첫 체감이 나아졌는지 확인합니다.
    • TPOT: 토큰 생성이 끊기지 않고 안정적인지 봅니다.
    • Queue Wait: 동시성 설정이 맞았는지 판단합니다.
    • GPU/CPU 패턴: 톱니형 유휴 구간, 특정 코어 과열, 불규칙한 burst가 줄었는지 봅니다.
    • P95/P99: 평균이 아닌 꼬리 지연이 얼마나 개선됐는지 확인합니다.

    실무에서는 이 판단이 중요합니다. 최적화가 성공한 것처럼 보여도 사용자 불만은 그대로인 경우가 종종 있습니다. 그럴 때 로그를 뜯어보면 마지막 응답 종료 시점만 짧아졌지 첫 반응은 그대로인 경우가 많았습니다. 대화형 서비스는 특히 초반 1~2초의 인상이 전체 만족도를 크게 좌우합니다.

    LLM 추론 최적화 전후 결과를 보여주는 대시보드 이미지

    최적화 전후를 비교하는 검증 대시보드 예시입니다.

    검증 체크리스트

    1. 같은 프롬프트 길이로 여러 번 호출해 편차를 봅니다.
    2. 짧은 입력과 긴 입력을 분리해 측정합니다.
    3. 동시 요청이 없을 때와 있을 때를 나눠 봅니다.
    4. 프록시 직통 호출과 프록시 경유 호출을 둘 다 측정합니다.
    5. 느린 요청 몇 건의 stage 로그를 직접 확인합니다.
    6. 튜닝 후에도 GPU 유휴 구간이 남는지 확인합니다.

    8. 상황별 추천: 이럴 땐 A, 저럴 땐 B로 가면 됩니다

    운영에서는 결국 선택을 해야 합니다. 아래처럼 우선순위를 고정해 두면 쓸데없는 우회가 줄어듭니다.

    상황 우선순위 추천 접근 보류할 것
    첫 응답이 답답한 챗봇 TTFT 프록시 buffering 해제, 큐 대기 축소, 입력 길이 상한 적용 배치 확대부터 시도
    긴 문서 생성 배치 작업 처리량 배치 전략 최적화, 후처리 비동기화, 로그 비용 절감 챗봇과 동일한 저지연 설정 고집
    GPU는 한가한데 느림 비모델 구간 제거 토크나이저, 로깅, JSON 직렬화, 소켓 재사용부터 점검 하드웨어 증설
    GPU 메모리 여유가 적음 안정성 입력 길이 정책, 동시성 상한, 긴 요청 분리 동시성만 밀어 올리기
    가끔만 매우 느린 tail latency P95/P99 코어 경합, 느린 요청 로그, 긴 입력 분리, 큐 설계 재검토 평균값만 보고 종료

    추천을 한 줄로 줄이면 이렇습니다. TTFT가 길면 프록시와 큐부터, TPOT이 흔들리면 스트리밍과 디코드 주변부터, GPU가 비는데도 느리면 CPU와 전처리부터 보시면 됩니다. 이 순서가 잘 먹히는 이유는 간단합니다. 비용이 적고 효과가 빠른 영역부터 건드리는 편이 증설보다 실패 확률이 훨씬 낮기 때문입니다.

    또 하나, LLM 추론 최적화는 설정값 암기 게임이 아닙니다. 모델이 바뀌면 prefill 특성이 달라지고, 프롬프트 정책이 바뀌면 TTFT 분포가 달라집니다. 그래서 개별 숫자를 외우기보다 관측 → 분리 → 수정 → 재검증 흐름을 팀 습관으로 만드는 쪽이 더 오래 갑니다. 비용도 줄고, 장애 대응 시간도 확실히 짧아집니다.

    관련 글이 있다면 프롬프트 길이 관리, RAG 캐시 전략, 스트리밍 API 운영 체크리스트와 내부 링크로 묶어 두는 것도 좋습니다. 검색 유입을 넓히는 데도 꽤 도움이 됩니다.

    LLM 병목 현상 진단 순서와 최적화 선택 기준 요약 이미지

    병목 진단 순서와 최적화 선택 기준을 한 장으로 요약한 인포그래픽입니다.

    FAQ

    Q1. GPU만 더 좋은 걸로 바꾸면 해결되나요?

    항상 그렇진 않습니다. GPU 사용률이 낮은데 지연이 길다면 모델 밖에서 시간을 쓰고 있을 가능성이 큽니다. 프록시, 큐, CPU, 전처리, 응답 직렬화부터 먼저 확인하는 편이 맞습니다.

    Q2. 어떤 로그부터 남기면 가장 실무에 도움이 되나요?

    요청 시작 시각, 큐 진입/탈출, 토크나이즈 시작/종료, 첫 토큰 시각, 응답 종료 시각, 입력 토큰 수, 출력 토큰 수 정도는 꼭 남기는 걸 권합니다. 이 정도만 있어도 TTFT와 total latency를 분리해서 볼 수 있습니다.

    Q3. 배치를 키우면 무조건 효율이 좋아지지 않나요?

    처리량 관점에선 좋아질 수 있지만, 대화형 서비스에선 TTFT와 tail latency가 나빠질 수 있습니다. 사람이 기다리는 서비스인지, 백그라운드 작업인지부터 먼저 정하는 게 좋습니다.

    Q4. 프록시 문제와 모델 문제는 가장 빠르게 어떻게 구분하나요?

    같은 요청을 프록시 직통과 프록시 경유로 각각 curl -w 측정해 보시면 됩니다. time_starttransfer 차이가 크면 모델보다 경로 문제일 가능성이 큽니다.

    마무리

    여러 번 겪고 나니 패턴이 꽤 분명했습니다. 느린 LLM 서비스의 원인은 생각보다 자주 모델 바깥에 있습니다. 그래서 저는 장비보다 먼저 첫 토큰 전까지 어디서 멈추는지, 토큰 생성 중 무엇이 끊는지, GPU가 왜 놀고 있는지부터 확인합니다. 이 순서로 가면 엉뚱한 튜닝을 줄일 수 있고, 증설 없이 해결되는 문제도 꽤 많습니다.

    실무 기준으로 추천을 한 줄로 압축하면 이렇습니다. 챗봇이면 TTFT 우선, 배치 작업이면 throughput 우선, GPU가 비면 CPU와 프록시부터, tail latency가 튀면 평균이 아니라 느린 요청 로그부터 보시면 됩니다. 이 기준만 잡아도 LLM 추론 최적화의 시행착오를 크게 줄일 수 있습니다.