13년차의 서버실

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

[태그:] Python

  • [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의 한계를 극복하고 효율적인 애플리케이션 개발을 가능하게 하죠.

  • [Cloud] Sentry 메모리 누수 해결: 실전 디버깅 사례 연구

    [Cloud] Sentry 메모리 누수 해결: 실전 디버깅 사례 연구

    Sentry 메모리 누수 해결: 실전 디버깅 사례 연구

    Sentry 메모리 누수 문제는 생각보다 늦게 티가 납니다. 처음에는 응답이 조금씩 느려지는 정도라서 그냥 트래픽이 늘었나 싶거든요. 그런데 어느 순간부터 컨테이너가 재시작되고, OOMKilled(메모리 부족으로 인한 강제 종료)가 찍히고, 에러는 많은데 원인은 안 보이는 상황이 옵니다. 저도 프로덕션 환경에서 딱 그 구간을 겪어봤습니다. 처음엔 애플리케이션 코드보다 인프라 쪽 문제라고 의심했었는데, 실제로 파고 들어가 보니 요청별로 쌓이는 객체 참조가 해제되지 않는 전형적인 메모리 누수였습니다. 이번 글은 Sentry 메모리 누수를 어떻게 추적했고, 어떤 식으로 좁혀 갔는지, 그리고 최종적으로 어떤 기준으로 해결 여부를 검증했는지 정리해 봤습니다.

    특히 Sentry 디버깅, 클라우드 에러 모니터링, 메모리 누수 해결, 성능 최적화가 한 번에 엮여 있는 사례라서, 운영 중인 Python API 서버나 컨테이너 환경을 다루는 분들께 꽤 현실적인 참고가 될 거 같습니다.

    Sentry 메모리 누수 추적을 위한 프로덕션 아키텍처 다이어그램

    프로덕션 API 서버, Sentry, 컨테이너 메모리 사용량 흐름을 한눈에 보여주는 개요 이미지입니다.

    Sentry 메모리 누수, 왜 잡기 어려운가

    쉽게 말해 메모리 누수(memory leak, 더 이상 필요 없는 메모리가 해제되지 않고 계속 남는 현상)는 에러 한 번으로 끝나지 않습니다. 요청은 성공하는데 프로세스 메모리만 조금씩 올라갈 수도 있고, 특정 조건에서만 객체가 쌓일 수도 있죠. 그래서 로그(log, 텍스트 기록)만 봐서는 감이 잘 안 옵니다.

    여기서 주목할 포인트가 있습니다. Sentry는 기본적으로 에러 모니터링(error monitoring)과 이벤트 추적(event tracking)에 강점이 있고, 메모리 누수는 증상과 맥락을 묶어 보는 용도로 정말 유용했습니다. 저는 아래 네 가지를 먼저 봤어요.

    • 언제 메모리 사용량이 급격히 올라가는지
    • 어떤 릴리스(release, 배포 버전) 이후부터 재현되는지
    • 어떤 엔드포인트(endpoint, API 경로)에서 현상이 두드러지는지
    • 재시작 직전 남기는 경고 이벤트와 예외 스택이 무엇인지

    혹시 이런 경험 있으신가요? CPU는 멀쩡한데 메모리만 톱니처럼 올라가고, 재배포하면 잠깐 괜찮아졌다가 다시 터지는 상황이요. 이럴 때는 감으로 고치면 거의 다시 터집니다. 저도 처음엔 캐시(cache) 설정만 바꾸면 되겠지 했는데, 삽질 좀 했습니다 ㅎㅎ

    Sentry 디버깅 관점에서 본 핵심 개념

    제가 실제로 써보니, 메모리 누수는 도구를 나눠서 보는 게 훨씬 편했어요. Sentry는 현상의 타이밍과 요청 맥락을 모으고, Python 내장 도구는 어떤 객체가 쌓이는지를 확인하는 식이었습니다.

    도구 역할 이번 사례에서 한 일
    Sentry 이벤트 추적, 릴리스 비교, 환경 분리 특정 배포 이후 경고 이벤트 증가와 엔드포인트 상관관계 확인
    tracemalloc 메모리 할당 추적 어느 코드 경로에서 객체가 계속 쌓이는지 비교
    gc 가비지 컬렉션 상태 확인 참조가 남아 해제되지 않는 객체 패턴 점검
    ps/top/kubectl 운영 레벨 리소스 확인 프로세스 RSS 증가, 재시작 시점, OOM 패턴 확인

    쉽게 말해, Sentry 메모리 누수 대응은 Sentry 하나만으로 끝내는 작업이 아니라, 관측(observability, 시스템 상태를 관찰하는 능력)의 중심축으로 Sentry를 두고 주변 도구를 붙이는 작업입니다.

    제가 잡았던 실제 패턴

    문제의 원인은 요청 처리 중 생성한 큰 응답 데이터를 전역 리스트(global list)에 디버깅 목적으로 붙여 놓은 코드였습니다. 개발 단계에서는 편했는데, 프로덕션에서 요청이 누적되면서 리스트가 비워지지 않았죠. 더 골치 아픈 건 예외가 바로 나지 않았다는 점입니다. 그래서 클라우드 에러 모니터링만 보고 있으면 늦게 알아차리기 쉬워요.

    실전 구현 1: Sentry에 운영 맥락 심기

    처음 한 일은 Sentry 이벤트에 운영 맥락을 더 많이 넣는 것이었습니다. 그냥 SDK만 붙여 두면 예외는 잘 모이는데, Sentry 메모리 누수처럼 느린 장애는 맥락이 부족할 때가 많거든요.

    1. 릴리스(release)와 환경(environment)을 분리합니다.
    2. 의심되는 엔드포인트에 breadcrumb(브레드크럼, 이벤트 전 단계 기록)를 남깁니다.
    3. 메모리 사용량이 임계치에 가까워지면 warning 이벤트를 직접 보냅니다.
    4. 컨테이너 재시작 전후 로그와 Sentry 타임라인을 맞춰 봅니다.
    import os
    import time
    import resource
    import sentry_sdk
    from sentry_sdk import capture_message, set_tag, add_breadcrumb
    from sentry_sdk.integrations.logging import LoggingIntegration
    
    logging_integration = LoggingIntegration(
        level=None,
        event_level=None,
    )
    
    sentry_sdk.init(
        dsn=os.environ.get("SENTRY_DSN"),
        environment=os.environ.get("APP_ENV", "production"),
        release=os.environ.get("APP_RELEASE", "unknown"),
        integrations=[logging_integration],
        traces_sample_rate=0.1,
    )
    
    MEMORY_WARN_MB = 700
    
    def get_rss_mb():
        rss_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
        if os.uname().sysname == "Darwin":
            return rss_kb / 1024 / 1024
        return rss_kb / 1024
    
    def observe_request(path: str, request_id: str):
        set_tag("endpoint", path)
        set_tag("request_id", request_id)
        add_breadcrumb(
            category="request",
            message=f"handling {path}",
            level="info",
            data={"request_id": request_id, "ts": int(time.time())},
        )
    
        rss_mb = get_rss_mb()
        if rss_mb >= MEMORY_WARN_MB:
            capture_message(
                f"high memory usage detected: {rss_mb:.1f}MB",
                level="warning",
            )

    여기서 중요한 포인트! Sentry에 경고 이벤트를 직접 남기면, 예외가 나기 전 단계의 흐름이 보입니다. 저는 이걸 넣고 나서야 특정 API 요청이 몰린 뒤 10~20분 정도 지나면 경고가 늘어난다는 걸 확인했어요.

    Sentry 메모리 누수 분석에 필요한 release와 environment 태그 구성 이미지

    Sentry에서 release, environment, endpoint 태그를 어떻게 나눠서 보는지 설명하는 구성 이미지입니다.

    실전 구현 2: 재현 스크립트와 누수 코드 분리

    운영에서만 보이는 문제라도, 결국 로컬이나 스테이징(staging, 사전 검증 환경)에서 비슷하게 재현할 수 있어야 고칠 수 있죠. 저는 홈랩에서도 비슷하게 많이 해보는데, 재현이 되는 순간부터 속도가 확 올라갑니다.

    문제를 단순화한 예시는 이런 식이었습니다.

    from fastapi import FastAPI
    
    app = FastAPI()
    DEBUG_CACHE = []
    
    @app.get("/items")
    def items():
        payload = {"rows": ["x" * 10000 for _ in range(500)]}
    
        # 문제 코드: 요청마다 큰 객체를 전역 리스트에 보관
        DEBUG_CACHE.append(payload)
    
        return {"count": len(payload["rows"]), "cache_size": len(DEBUG_CACHE)}

    이런 코드는 테스트 몇 번 할 때는 티가 안 나요. 근데 프로덕션에서 요청이 계속 들어오면 얘기가 달라집니다. 그래서 부하를 살짝 줘서 반복 요청을 만들었습니다.

    for i in $(seq 1 300); do
      curl -s http://127.0.0.1:8000/items > /dev/null
    done
    
    ps -o pid,rss,command -p $(pgrep -f uvicorn)

    이후 tracemalloc으로 전후 차이를 찍었습니다.

    import tracemalloc
    import linecache
    
    tracemalloc.start(25)
    
    # 부하 실행 전 snapshot
    before = tracemalloc.take_snapshot()
    
    # 여기서 반복 호출 수행
    
    after = tracemalloc.take_snapshot()
    stats = after.compare_to(before, "lineno")
    
    for stat in stats[:10]:
        frame = stat.traceback[0]
        print(f"{frame.filename}:{frame.lineno} -> {stat.size_diff / 1024:.1f} KiB")
        print(linecache.getline(frame.filename, frame.lineno).strip())

    실제로 써보니까 이 방식이 좋았던 이유는 명확합니다. Sentry에서 어느 요청 흐름이 이상한지를 찾고, tracemalloc으로 어느 코드 줄이 증가하는지를 고정할 수 있었거든요. 두 도구가 따로 노는 게 아니라 딱 이어졌습니다.

    실전 구현 3: 수정 방향과 배포 전략

    수정 자체는 의외로 단순했습니다. 전역에 보관하던 디버그 데이터를 없애고, 꼭 필요하면 크기를 제한한 ring buffer(링 버퍼, 고정 길이 순환 버퍼)로 바꿨어요. 사실 운영 서버에서는 요청 본문이나 큰 응답 객체를 오래 들고 있는 패턴 자체를 피하는 게 맞습니다.

    from collections import deque
    from fastapi import FastAPI
    
    app = FastAPI()
    DEBUG_CACHE = deque(maxlen=20)
    
    @app.get("/items")
    def items():
        payload = {"rows": ["x" * 10000 for _ in range(500)]}
    
        # 필요한 메타데이터만 제한적으로 저장
        DEBUG_CACHE.append({
            "row_count": len(payload["rows"]),
        })
    
        return {"count": len(payload["rows"]), "cache_size": len(DEBUG_CACHE)}

    배포할 때도 한 번에 전체 트래픽을 넘기지 않았습니다. 제가 운영할 때는 이런 문제는 꼭 점진 배포(rolling deployment, 순차 배포)로 갑니다. 왜냐하면 메모리 누수는 수정했다고 끝이 아니라, 정말 증가 추세가 꺾였는지를 봐야 하거든요.

    1. 수정 버전을 새 릴리스로 배포합니다.
    2. Sentry에서 이전 릴리스와 새 릴리스를 분리해 봅니다.
    3. 동일 엔드포인트 기준 warning 이벤트 발생 빈도를 비교합니다.
    4. 컨테이너 재시작 횟수와 RSS 증가 그래프를 같이 봅니다.

    재현 스크립트, 문제 코드, 수정 후 구조를 비교해서 보여주는 디버깅 흐름 이미지입니다.

    ⚠️ 주의사항: 제가 실제로 겪은 삽질 포인트

    여기서부터가 진짜 중요합니다. 메모리 누수는 원인을 하나만 보면 놓치는 경우가 많아요.

    1. ru_maxrss를 실시간 메모리로 착각

    ru_maxrss는 최대 RSS 값이라서 순간 메모리 스냅샷처럼 쓰면 해석이 꼬일 수 있습니다. 저도 처음엔 현재 메모리라고 생각하고 봤다가 그래프 해석을 잘못했었어요. 그래서 운영에서는 ps, 컨테이너 메트릭, 노드 메모리 지표를 같이 봐야 합니다.

    2. 예외가 없으니 애플리케이션 문제를 늦게 의심

    OOMKilled는 쿠버네티스(Kubernetes)나 런타임 차원의 이벤트로 보이는 경우가 많아서, 애플리케이션 버그와 분리해서 생각하기 쉬워요. 근데 여기서 중요한 건, 예외가 없다고 누수가 없는 게 아니다라는 점입니다.

    3. 디버그 로그가 문제를 키움

    디버깅하려고 요청 본문, 응답 객체, ORM 결과를 통째로 메모리에 들고 있으면 오히려 누수를 악화시킬 수 있습니다. 특히 전역 변수, 캐시, 클로저(closure, 외부 변수를 참조하는 함수), 콜백 등록 구조는 한 번 더 의심해 보셔야 합니다.

    4. Sentry를 만능 분석기로 기대

    Sentry는 정말 강력한 도구지만, heap dump 분석기나 프로파일러(profiler)를 완전히 대체하지는 않습니다. 저는 Sentry를 상황판으로 쓰고, 세부 원인 분석은 tracemalloc과 코드 리뷰로 마무리했어요. 이 조합이 제일 현실적이었습니다.

    • ✅ Sentry: 릴리스, 환경, 요청 흐름, 경고 이벤트 연계
    • ✅ 시스템 도구: RSS, 재시작, 컨테이너 상태 확인
    • ✅ 언어 도구: 객체 증가 경로 추적
    • ⚠️ 단일 지표만 보고 결론 내리기 금지

    검증: 해결됐다고 판단한 기준

    드디어 됐다! 라고 말하기 전에, 저는 꼭 기준을 숫자가 아니라 패턴으로 봅니다. 구체적인 절대 수치는 환경마다 다르니까요. 대신 아래 기준은 어느 환경에서도 꽤 유효합니다.

    1. 동일 트래픽 조건에서 프로세스 메모리가 계속 우상향하지 않는지
    2. 문제 엔드포인트 호출 후에도 일정 시간 뒤 메모리 증가 추세가 평탄해지는지
    3. Sentry warning 이벤트 빈도가 줄었는지
    4. OOM 재시작이 사라졌는지
    5. 새 릴리스 이후 동일 이슈 그룹이 다시 생기지 않는지

    저는 수정 전후를 이런 식으로 운영 점검했습니다.

    kubectl get pods -n prod
    kubectl top pods -n prod
    kubectl describe pod api-server-xxxxx -n prod
    
    # 애플리케이션 로그와 Sentry 이벤트 시점을 함께 대조
    kubectl logs deployment/api-server -n prod --since=30m

    결과적으로 수정 후에는 특정 API 호출이 몰려도 메모리 그래프가 계속 계단식으로 올라가지 않았고, 재시작도 멈췄습니다. Sentry에서도 문제 릴리스 기준으로 쌓이던 경고 이벤트가 눈에 띄게 줄었어요. 이런 순간이 있죠. 아, 이건 증상만 눌러 놓은 게 아니라 원인을 제대로 건드렸구나 하는 느낌이요. 이거 진짜 편하더라고요.

    Sentry 메모리 누수 해결 전후 결과를 보여주는 대시보드 이미지

    수정 전후 메모리 사용량 추세와 Sentry 이벤트 변화를 비교하는 결과 대시보드 이미지입니다.

    정리: Sentry 메모리 누수 대응 체크리스트

    마지막으로, 제가 다시 같은 상황을 만나면 이 순서대로 갑니다. 독자분들도 북마크해 두시면 꽤 쓸 만하실 거 같습니다.

    단계 체크 포인트 목적
    1 Sentry에 release, environment, endpoint 태그 확인 문제 범위 좁히기
    2 경고 이벤트 수동 전송 추가 예외 전 단계 포착
    3 부하 재현과 tracemalloc 비교 코드 라인 단위 확인
    4 전역 객체, 캐시, 콜백 참조 점검 누수 패턴 제거
    5 점진 배포 후 Sentry와 메트릭 비교 해결 여부 검증

    정리해 보면, Sentry 메모리 누수 대응의 핵심은 Sentry를 단순 에러 수집기로 쓰지 않는 데 있습니다. 릴리스 비교, 경고 이벤트, 요청 태그를 잘 심어 두면 메모리 누수처럼 느리고 애매한 장애도 꽤 빨리 좁혀 갈 수 있거든요. 저도 처음엔 헷갈렸는데, 결국 답은 비슷했습니다. 증상을 구조화해서 보고, 재현하고, 작은 단서부터 묶어 가는 것. 운영은 늘 그렇게 풀리더라고요.

    다음 글에서는 OpenTelemetry(오픈텔레메트리, 분산 추적 표준)와 Sentry를 함께 붙여서 요청 지연과 에러를 한 번에 보는 흐름도 다뤄볼 예정입니다. 이전 글에서 다뤘던 로그 집계 파이프라인과 같이 보시면 더 이해가 잘 되실 거 같습니다.

    자주 묻는 질문

    Sentry만으로 메모리 누수를 찾을 수 있나요?

    완전히 단독으로 찾기보다는, 어느 흐름에서 문제가 커지는지를 좁히는 데 강합니다. 실제 누수 지점은 tracemalloc, gc, 코드 리뷰 같은 보조 수단이 필요할 때가 많아요.

    메모리 누수 해결 후 무엇을 가장 먼저 봐야 하나요?

    같은 트래픽에서 메모리 사용량이 계속 증가하는지, 컨테이너 재시작이 멈췄는지, 그리고 Sentry 디버깅 기준으로 동일 릴리스 이슈가 줄었는지를 먼저 보시면 됩니다.

    클라우드 에러 모니터링만 잘 되어 있으면 충분한가요?

    충분하지 않을 때가 많습니다. 메모리 누수는 인프라 이벤트처럼 보이지만 실제 원인은 코드 참조 구조인 경우가 많아서, 애플리케이션 계층과 시스템 계층을 같이 보셔야 해요.

    Sentry 메모리 누수 대응 체크리스트 인포그래픽

    Sentry, tracemalloc, 시스템 메트릭을 조합한 대응 순서를 한 장으로 요약한 이미지입니다.

  • [AI] LangChain으로 AI 에이전트 구축: 복잡한 작업 자동화 실전 가이드

    [AI] LangChain으로 AI 에이전트 구축: 복잡한 작업 자동화 실전 가이드

    LangChain으로 AI 에이전트 구축: 복잡한 작업 자동화 실전 가이드

    왜 LangChain AI 에이전트가 필요할까요?

    안녕하세요, 13년차 서버실 지킴이입니다. 인프라 엔지니어로 일하면서 수많은 반복 작업과 복잡한 문제 해결에 시간과 에너지를 쏟았던 경험, 혹시 여러분도 있으신가요? 매번 똑같은 정보를 찾아보고, 여러 시스템을 오가며 데이터를 조합하고, 때로는 예측 불가능한 변수까지 고려해야 하는 상황들 말이죠. 저는 이런 작업들을 보면서 "이걸 좀 더 스마트하게 자동화할 수는 없을까?" 하는 고민을 늘 했었습니다. 특히 최근 LLM(Large Language Model, 대규모 언어 모델)의 발전은 이런 고민에 한 줄기 빛이 되어주었죠. 단순 반복을 넘어, 어느 정도의 추론과 판단까지 가능한 자동화 말입니다.

    그래서 제가 홈랩에서 직접 실험해본 결과 알게 된 게 바로 LangChain AI 에이전트의 강력함이었어요. 처음엔 이게 뭔가 싶었는데, 막상 써보니까 AI가 스스로 판단해서 필요한 도구(Tool)를 찾아 쓰고, 심지어 이전 대화 맥락(Memory)까지 기억하면서 복잡한 작업을 척척 해내더라고요. 정말 놀랐습니다. 오늘 이 글에서는 저의 삽질 경험을 바탕으로, 여러분도 LangChain을 활용해 나만의 AI 에이전트를 구축하고 복잡한 작업을 자동화하는 방법을 실전 가이드 형식으로 알려드리려고 합니다. 우리 함께 AI 자동화의 세계로 떠나볼까요? 🎉

    LangChain AI 에이전트가 어떻게 동작하는지 한눈에 보여주는 개념도입니다. LLM을 중심으로 Tools, Memory, Agent Executor가 유기적으로 연결되어 복잡한 작업을 수행합니다.

    LangChain AI 에이전트, 도대체 뭘까요?

    쉽게 말해, LangChain AI 에이전트는 LLM(대규모 언어 모델)을 ‘두뇌’ 삼아 다양한 ‘도구’를 사용하고, ‘기억’까지 하면서 사람처럼 생각하고 행동하는 AI 시스템을 말해요. 기존 LLM이 단순히 주어진 질문에 답만 했다면, 에이전트는 한 발 더 나아가 스스로 계획을 세우고, 필요한 정보를 찾아오고, 외부 시스템과 상호작용하면서 목표를 달성하는 거죠. 이 모든 과정을 쉽게 구현할 수 있도록 도와주는 프레임워크가 바로 LangChain(랭체인)이에요.

    • Agent (에이전트): LLM이 어떤 도구를 사용할지, 어떤 순서로 사용할지 결정하는 ‘두뇌’ 역할을 합니다. 사용자의 요청을 이해하고, 목표 달성을 위한 최적의 경로를 탐색하죠.
    • Tools (도구): 에이전트가 외부 세계와 상호작용하는 수단이에요. 예를 들어, 인터넷 검색(Web Search), 계산기(Calculator), 외부 API 호출(API Call), 코드 실행(Code Interpreter) 등이 될 수 있어요. LangChain은 다양한 기본 도구를 제공하고, 커스텀 도구를 만들기도 정말 쉽습니다.
    • Memory (메모리): 에이전트가 이전 대화 내용을 기억해서 컨텍스트(Context)를 유지하는 역할이에요. 덕분에 여러 번의 질문과 답변 속에서도 일관성 있는 행동을 할 수 있습니다. 마치 사람처럼 대화를 기억하는 거죠.
    • LangChain (랭체인): 이런 에이전트를 쉽게 만들고, 구성하고, 배포할 수 있도록 도와주는 파이썬(Python) 라이브러리이자 프레임워크예요. 다양한 LLM과 도구를 연결하고, 복잡한 워크플로우를 체인(Chain) 형태로 만들 수 있게 해줍니다.

    결국 LangChain AI 에이전트는 마치 유능한 비서처럼, 우리가 시키는 복잡한 일들을 스스로 판단하고 여러 도구를 활용해서 처리해주는 시스템이라고 생각하시면 돼요. 진짜 편하더라고요!

    LangChain으로 나만의 AI 에이전트 만들기 실전!

    자, 이제 직접 코드를 만져보면서 LangChain AI 에이전트를 만들어볼 시간입니다. 저는 간단한 정보 검색 에이전트를 만들어 볼 건데요, 이 에이전트는 사용자의 질문을 받으면 인터넷을 검색해서 최신 정보를 찾아오는 역할을 할 겁니다.

    준비물 챙기기: 개발 환경 설정

    Python 3.8+ 환경이 필요해요. 먼저 필요한 라이브러리들을 설치해볼까요?

    pip install langchain langchain-openai langchain-community python-dotenv
    
    • langchain: LangChain 프레임워크의 핵심 라이브러리예요.
    • langchain-openai: OpenAI의 LLM을 사용하기 위한 통합 라이브러리입니다. (다른 LLM을 사용한다면 해당 프로바이더 라이브러리를 설치하세요.)
    • langchain-community: 검색 도구(Tool)와 같은 커뮤니티 기반의 유용한 기능들을 담고 있어요.
    • python-dotenv: 환경 변수를 .env 파일에서 로드하기 위해 사용합니다. API 키 등을 코드에 직접 노출하지 않고 안전하게 관리할 수 있어요.

    다음으로, OpenAI API 키를 설정해야 해요. 프로젝트 루트 폴더에 .env 파일을 만들고 아래 내용을 추가해주세요.

    OPENAI_API_KEY="YOUR_OPENAI_API_KEY"
    

    YOUR_OPENAI_API_KEY 부분에 여러분의 OpenAI API 키를 입력하시면 돼요. ⚠️ 절대 이 파일을 GitHub 같은 공개 저장소에 올리시면 안 됩니다!

    첫 번째 에이전트: 간단한 정보 검색 에이전트

    이제 agent_app.py라는 파일을 만들고 코드를 작성해봅시다. 이 에이전트는 DuckDuckGo Search 도구를 사용해서 웹 검색을 수행할 겁니다.

    import os
    from dotenv import load_dotenv
    from langchain_openai import ChatOpenAI
    from langchain.agents import AgentExecutor, create_react_agent
    from langchain_community.tools import DuckDuckGoSearchRun
    from langchain import hub
    
    # .env 파일에서 환경 변수 로드
    load_dotenv()
    
    # 1. LLM(Large Language Model) 설정
    # gpt-3.5-turbo 모델을 사용하고, 창의성을 낮추기 위해 temperature는 0으로 설정했어요.
    llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
    
    # 2. 도구(Tools) 정의
    # 웹 검색을 위한 DuckDuckGoSearchRun 도구를 사용합니다.
    # 이 도구는 별도의 API 키 없이 바로 사용할 수 있어서 편리해요.
    search_tool = DuckDuckGoSearchRun()
    tools = [search_tool]
    
    # 3. 에이전트의 프롬프트(Prompt) 로드
    # LangChain Hub에서 ReAct(Reasoning and Acting) 프롬프트를 가져옵니다.
    # ReAct는 LLM이 추론(Reason)하고 행동(Act)하는 과정을 반복하며 목표를 달성하도록 돕는 강력한 패턴이에요.
    prompt = hub.pull("hwchase17/react")
    
    # 4. 에이전트 생성
    # create_react_agent 함수는 LLM, Tools, Prompt를 받아서 에이전트의 로직을 생성합니다.
    agent = create_react_agent(llm, tools, prompt)
    
    # 5. Agent Executor 생성 및 실행
    # AgentExecutor는 에이전트를 실행하고, 에이전트가 도구를 사용하는 과정을 관리합니다.
    # verbose=True로 설정하면 에이전트의 생각(Thought) 과정과 도구 사용 내역을 자세히 볼 수 있어요.
    agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True, handle_parsing_errors=True)
    
    # 에이전트 실행 예시
    print("\n--- 에이전트 실행 시작 ---")
    response = agent_executor.invoke({"input": "2024년 파리 올림픽에 대한 최신 정보를 알려주고, 한국 선수단에 대한 내용도 포함해줘."})
    print("\n--- 에이전트 최종 답변 ---")
    print(response["output"])
    print("\n--- 에이전트 실행 종료 ---")
    
    # 또 다른 질문
    print("\n--- 두 번째 질문 실행 ---")
    response = agent_executor.invoke({"input": "AI 에이전트 개발 시 가장 중요한 고려사항은 무엇일까요?"})
    print("\n--- 에이전트 최종 답변 ---")
    print(response["output"])
    print("\n--- 두 번째 질문 종료 ---")
    

    코드를 저장하고 실행해보세요.

    python agent_app.py
    

    verbose=True 덕분에 에이전트가 어떤 생각(Thought)을 하고, 어떤 도구(Tool)를 사용하며 정보를 찾아나가는지 자세히 볼 수 있을 겁니다. 제가 처음 이걸 봤을 때, “와, 진짜 AI가 생각하고 있네!” 싶더라고요. LLM이 단순히 답변만 하는 게 아니라, 스스로 계획하고 실행하는 모습을 보니 정말 신기했어요.

    직접 작성한 LangChain 에이전트 코드와 그 실행 결과 스크린샷입니다. 에이전트의 추론 과정이 자세히 출력되는 것을 볼 수 있습니다.

    삽질 경험: 저도 이걸로 꽤나 고생했습니다 ⚠️

    LangChain AI 에이전트가 만능처럼 보이지만, 저도 처음부터 술술 만들었던 건 아니었어요. 여러 삽질을 거치면서 배운 점들이 많더라고요. 여러분은 저 같은 시행착오를 겪지 않으시길 바라며 몇 가지 주의사항과 트러블슈팅 팁을 공유해봅니다.

    • API Rate Limit (API 호출 제한): LLM API는 호출 횟수나 토큰 수에 제한이 있어요. 에이전트가 무한정 검색하거나 반복 작업을 수행하면 금세 제한에 걸리곤 하더라고요. 저도 테스트하다가 갑자기 API 에러를 만나서 당황했던 적이 많았어요. 💡 팁: 개발 단계에서는 temperature를 낮춰서 불필요한 추론을 줄이거나, 프롬프트 설계를 통해 에이전트의 ‘생각’ 과정을 효율적으로 유도하는 것이 중요합니다. 그리고 비용 모니터링은 필수죠!

    • Tool Selection (도구 선택의 어려움): 에이전트에게 너무 많은 도구를 주거나, 역할에 맞지 않는 도구를 주면 엉뚱한 방향으로 흘러가는 경우가 있어요. 처음엔 에이전트가 “계산기” 도구가 있는데도 굳이 검색으로 답을 찾으려고 해서 답답했던 적도 있거든요. 💡 팁: 에이전트의 목적에 맞는 최소한의 도구만 제공하고, 각 도구의 description을 명확하게 작성해서 LLM이 언제 어떤 도구를 써야 할지 정확히 알 수 있도록 도와줘야 합니다.

    • Prompt Engineering (프롬프트 설계의 중요성): 에이전트의 성능은 프롬프트에 크게 좌우돼요. ReAct 프롬프트처럼 잘 설계된 프롬프트는 에이전트가 훨씬 효율적으로 작동하게 만들어요. 하지만 잘못된 지시나 모호한 프롬프트는 에이전트를 길 잃게 만들 수 있어요. 💡 팁: LangChain Hub에서 제공하는 검증된 프롬프트를 참고하고, 에이전트의 역할과 목표를 명확하게 지시하는 것이 중요합니다.

    • Memory Management (메모리 관리): 장기적인 대화에서는 메모리 관리가 중요해요. 너무 많은 대화 기록을 메모리에 담으면 토큰 제한에 걸리거나 불필요한 비용이 발생할 수 있어요. 💡 팁: ConversationBufferWindowMemory 같은 특정 길이만 기억하는 메모리 타입을 사용하거나, 요약(Summarization) 기능을 활용해서 필요한 핵심 정보만 기억하도록 하는 것이 좋습니다.

    • Parsing Errors (파싱 에러): LLM이 도구 사용 형식을 제대로 지키지 않아서 발생하는 에러예요. create_react_agent 함수에 handle_parsing_errors=True 옵션을 주면 에러를 좀 더 부드럽게 처리할 수 있어요. 저도 이 옵션 덕분에 스트레스를 많이 줄일 수 있었네요.

    드디어! 에이전트가 똑똑하게 일합니다 🎉

    위의 삽질과정을 거쳐 에이전트가 제대로 작동하는 모습을 보면 정말 뿌듯해요. 실제로 “2024년 파리 올림픽에 대한 최신 정보를 알려주고, 한국 선수단에 대한 내용도 포함해줘.” 같은 복잡한 질의를 던졌을 때, 에이전트는 다음과 같은 과정을 거쳐 답변을 생성합니다.

    1. 사용자의 질문을 이해하고, “파리 올림픽”과 “한국 선수단”이라는 키워드를 추출합니다.
    2. 인터넷 검색 도구(DuckDuckGo Search)를 사용하기로 결정합니다.
    3. “2024 파리 올림픽 최신 정보”를 검색합니다.
    4. 검색 결과에서 주요 정보를 추출하고, 다시 “2024 파리 올림픽 한국 선수단”을 검색합니다.
    5. 두 가지 검색 결과를 종합하여 사용자에게 최신 정보와 한국 선수단 관련 내용을 포함한 답변을 제공합니다.

    제가 기대했던 것 이상으로 잘 해내더라고요. 특히 여러 단계에 걸쳐 정보를 수집하고 조합하는 능력은 기존 LLM 단독으로는 어려웠던 부분이라 더욱 인상 깊었어요. 이런 방식으로 단순 정보 검색을 넘어, 특정 문서 요약, 데이터 분석, 심지어는 간단한 코드 생성까지 다양한 작업을 자동화할 수 있습니다.

    LangChain 에이전트가 여러 도구를 활용하여 복합적인 질문에 답하는 모습입니다. 마치 사람이 생각하듯 정보를 수집하고 가공하여 최종 답변을 도출합니다.

    마무리하며: AI 자동화, 이제 시작입니다.

    오늘은 LangChain을 활용해서 AI 에이전트를 구축하고 복잡한 작업을 자동화하는 방법에 대해 저의 경험을 바탕으로 이야기해봤어요. 13년차 인프라 엔지니어로서 늘 어떻게 하면 더 효율적으로 일할 수 있을까 고민했는데, LangChain AI 에이전트가 그 해답 중 하나가 될 수 있다는 확신을 얻었어요. 여러분도 저처럼 홈랩에서 직접 만들어보시면 좋겠네요. 직접 코드를 만져보고 에이전트가 ‘생각’하는 과정을 지켜보는 것만으로도 정말 값진 경험이 될 겁니다.

    LangChain은 AI 에이전트 개발을 위한 강력하고 유연한 프레임워크예요. 오늘 다룬 내용은 아주 기초적인 시작에 불과해요. 앞으로는 커스텀 도구를 만들어서 사내 시스템과 연동하거나, 더 복잡한 추론 체인(Chain)을 구성해서 고도화된 자동화 시스템을 구축할 수도 있어요. AI 자동화의 시대는 이제 막 시작되었고, 그 가능성은 무궁무진합니다.

    다음 글에서는 더 복잡한 멀티모달(Multimodal) 에이전트나, 에이전트 간의 협업(Agent Collaboration)을 통해 더욱 강력한 자동화 시스템을 만드는 방법에 대해 다뤄볼 예정입니다. 기대해주세요! 궁금한 점이나 여러분의 삽질 경험이 있다면 댓글로 공유해주세요. 우리 함께 배우고 성장해나가요. 😊

    LangChain 에이전트의 핵심 기능과 다양한 활용 방안을 시각적으로 정리한 요약본입니다. 복잡한 작업 자동화를 위한 강력한 도구임을 보여줍니다.

  • [AI] vLLM 실전 가이드: 고성능 LLM 추론 및 API 서빙 최적화

    [AI] vLLM 실전 가이드: 고성능 LLM 추론 및 API 서빙 최적화

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

    요즘 LLM(Large Language Model, 대규모 언어 모델)을 활용한 서비스들이 정말 많아졌죠? 저도 홈랩에서 이것저것 돌려보면서 LLM이 우리의 일상을 어떻게 바꿀지 매일매일 흥미진진하게 지켜보고 있습니다. 그런데 이 LLM이라는 친구, 성능은 기가 막히지만 막상 서비스에 적용하려면 만만치 않은 챌린지들이 있더라고요. 특히 GPU 자원을 효율적으로 사용하면서 여러 요청을 동시에 처리하는 게 정말 큰 숙제였습니다.

    저도 처음엔 Hugging Face의 Transformers 라이브러리로 모델을 로드해서 API를 만들었는데, 트래픽이 조금만 몰려도 GPU 메모리가 부족하다거나, 응답 시간이 길어지는 문제에 직면하곤 했습니다. “아니, 이 좋은 GPU를 왜 이렇게밖에 못 쓰지?” 하는 자괴감도 들었고요. 그러다가 vLLM이라는 친구를 만나게 되었는데, 이거 정말 물건이더라고요! LLM 추론 성능을 획기적으로 개선하고 API 서빙까지 아주 쉽게 만들어주는 라이브러리거든요. 오늘은 저의 삽질 경험을 바탕으로 vLLM을 어떻게 실전에 적용할 수 있을지 자세히 알려드리려고 합니다.

    vLLM은 PagedAttention이라는 혁신적인 기술을 통해 LLM 추론 시 GPU 메모리 효율을 극대화합니다. 이는 동시 처리량과 응답 속도 향상으로 이어지죠.

    vLLM, 도대체 뭘까요? (feat. PagedAttention)

    vLLM은 LLM 추론(inference)을 위한 오픈소스 라이브러리거든요. 가장 큰 특징은 바로 PagedAttention(페이지드 어텐션)이라는 혁신적인 어텐션 알고리즘을 사용한다는 점이에요. 이게 무슨 말인지 쉽게 설명해 드릴게요.

    LLM은 문장을 생성할 때 이전에 생성된 토큰(token)들을 기억해야 합니다. 이 기억이 저장되는 공간을 KV Cache (Key-Value Cache, 키-값 캐시)라고 부르는데, vLLM에서 이 캐시 관리가 핵심이거든요. 일반적인 LLM 서빙 방식에서는 이 KV Cache가 고정된 크기로 할당되곤 합니다. 문제는 사용자마다 입력하는 문장 길이도 다르고, 생성되는 문장 길이도 다르다는 점이에요. 그래서 가장 긴 문장을 기준으로 KV Cache를 할당하면, 짧은 문장을 처리할 때는 메모리가 낭비되고, 그렇다고 짧게 할당하면 긴 문장을 처리할 수 없는 딜레마에 빠지게 됩니다.

    PagedAttention은 이 문제를 운영체제의 가상 메모리 페이징 기법처럼 영리하게 해결하는 거예요. KV Cache를 고정된 블록(block) 단위로 나누고, 필요한 블록만 동적으로 할당하고 해제하는 방식이죠. 마치 우리가 컴퓨터에서 메모리가 부족할 때 하드디스크의 일부를 가상 메모리로 사용하는 것과 비슷합니다.

    이 덕분에 vLLM은 다음과 같은 엄청난 장점을 가집니다:

    • 높은 처리량 (High Throughput): GPU 메모리를 효율적으로 사용하니 더 많은 동시 요청을 처리할 수 있어요.
    • 낮은 지연 시간 (Low Latency): KV Cache 관리가 최적화되어 응답 속도가 정말 빨라집니다.
    • 쉬운 사용성 (Ease of Use): 몇 줄의 코드만으로 고성능 LLM API 서버를 구축할 수 있다는 게 정말 편해요.

    제가 직접 써보니까, 정말 GPU 활용률이 확 올라가는 걸 체감할 수 있었어요. 특히 여러 사용자가 동시에 다양한 길이의 프롬프트(prompt)를 보낼 때 vLLM의 진가가 드러나더라고요.

    vLLM, 실전에서 써봅시다! (설치부터 API 서빙까지)

    이제 vLLM을 직접 설치하고 API 서버를 띄워볼 시간입니다. 저와 함께 차근차근 따라오시면 돼요. 저는 Ubuntu 환경에서 NVIDIA GPU와 CUDA를 사용하고 있다고 가정하고 진행할게요.

    1. vLLM 설치

    vLLM은 Python 패키지로 제공되기 때문에 <code>pip로 아주 쉽게 설치할 수 있습니다. 다만 CUDA 버전이 정말 중요해요!

    # CUDA 12.1 이상을 사용하는 경우 (권장)
    pip install vllm
    
    # 특정 CUDA 버전을 사용하는 경우 (예: CUDA 11.8)
    # pip install vllm==0.3.3 --pre --extra-index-url https://download.pytorch.org/whl/cu118
    # 버전 확인은 vLLM 공식 문서에서 최신 정보를 확인하는 것이 좋습니다.
    

    설치가 완료되면, python -c "import vllm; print(vllm.__version__)" 명령어로 제대로 설치되었는지 확인할 수 있습니다. 저도 처음엔 CUDA 버전 때문에 한참 삽질했는데, 꼭 본인의 환경에 맞는 vLLM 버전을 확인하고 설치하시길 바랍니다. ⚠️

    2. LLM 모델 로드 및 API 서버 실행

    vLLM은 Hugging Face 모델들을 바로 로드하여 사용할 수 있습니다. 여기서는 가볍게 테스트할 수 있는 meta-llama/Llama-2-7b-hf 모델을 예시로 들어볼게요. 물론 실제 서비스에서는 더 크고 성능 좋은 모델을 사용하시겠죠?

    python -m vllm.entrypoints.api_server \
        --model meta-llama/Llama-2-7b-hf \
        --port 8000 \
        --host 0.0.0.0 \
        --tensor-parallel-size 1 # 단일 GPU 사용 시
    

    위 명령어를 실행하면 vLLM API 서버가 백그라운드에서 실행됩니다. --model 인자에는 Hugging Face 모델 이름을 넣어주면 되고요. --tensor-parallel-size는 모델을 여러 GPU에 분산할 때 사용하는데, 저는 홈랩에서 GPU 하나로 테스트하기 때문에 1로 설정했거든요. 만약 여러 GPU가 있다면 이 값을 조절해서 더 큰 모델을 로드하거나 LLM 추론 처리량을 늘릴 수 있어요.

    성공적으로 vLLM API 서버가 시작되면 위와 같은 메시지가 터미널에 출력됩니다. 이제 이 서버로 LLM 추론 요청을 보낼 수 있습니다!

    3. API 요청 보내기

    서버가 잘 동작하는지 확인하기 위해 curl이나 Python 코드로 요청을 보내봅시다. 저는 Python requests 라이브러리를 사용해서 간단하게 테스트하는 코드를 보여드릴게요.

    import requests
    import json
    
    API_URL = "http://localhost:8000/generate"
    
    headers = {"Content-Type": "application/json"}
    data = {
        "prompt": "안녕하세요, 13년차 서버실 지킴이입니다. LLM에 대해 자세히 설명해주세요.",
        "max_tokens": 128,
        "temperature": 0.7,
        "top_p": 0.9,
        "n": 1, # 생성할 응답의 개수
        "stream": False # 스트리밍 응답 여부
    }
    
    try:
        response = requests.post(API_URL, headers=headers, data=json.dumps(data))
        response.raise_for_status() # HTTP 에러 발생 시 예외 처리
    
        result = response.json()
        print("응답 내용:", result['outputs'][0]['text'])
    
    except requests.exceptions.RequestException as e:
        print(f"API 요청 중 에러 발생: {e}")
        if response:
            print(f"서버 응답: {response.status_code}, {response.text}")
    except json.JSONDecodeError as e:
        print(f"JSON 응답 디코딩 에러: {e}")
        if response:
            print(f"서버 원본 응답: {response.text}")
    
    

    이 코드를 실행하면 vLLM이 제 프롬프트에 답변을 생성해서 돌려줄 겁니다. max_tokens는 생성할 최대 토큰 수, temperature는 응답의 창의성을 조절하는 파라미터예요. 여러 번 테스트해보면서 LLM의 응답을 확인해보세요. 🎉

    ⚠️ 삽질 경험담: GPU 메모리 부족과 버전 호환성

    제가 vLLM을 처음 도입했을 때 가장 많이 겪었던 문제는 역시 GPU 메모리 부족이었습니다. “분명 PagedAttention이 메모리 효율적이라는데 왜?” 싶었죠. 알고 보니 제가 사용하는 GPU(RTX 3060 12GB)에 너무 큰 모델(예: Llama-2-13b)을 올리려고 했던 것이 원인이었습니다. vLLM이 아무리 효율적이라도, 모델 자체의 크기를 무시할 수는 없더라고요. 제 삽질 경험을 토대로 몇 가지 팁을 드리자면:

    1. 모델 크기 확인: 사용하려는 모델이 본인의 GPU 메모리에 적합한지 먼저 확인하세요. Hugging Face 모델 페이지에 가면 모델 크기가 나와 있거든요.
    2. 양자화(Quantization) 모델 사용: int8이나 fp4 같은 양자화 기법을 사용한 모델은 훨씬 적은 메모리를 써요. vLLM도 양자화된 모델을 지원하니, 메모리가 부족하면 이 방법을 써보세요. (예: --quantization gptq)
    3. 배치 사이즈 조절: 동시 처리하는 요청의 최대 배치 사이즈를 조절하여 메모리 사용량을 제어할 수 있어요. (예: --max-model-len, --max-num-seqs)
    4. CUDA/PyTorch 버전: vLLM은 특정 CUDA 및 PyTorch 버전에 최적화되어 있습니다. 설치 시 본인의 환경과 호환되는 버전을 정확히 맞춰야 오류를 줄일 수 있어요. 저처럼 무작정 최신 버전만 고집하다가 호환성 문제로 시간을 날리지 마세요! 😅

    이런 시행착오를 겪으면서 “역시 인프라는 환경이 제일 중요하구나”를 다시 한번 느꼈습니다.

    vLLM, 얼마나 빨라졌을까? (성능 검증)

    vLLM을 사용하면 실제로 얼마나 성능이 개선되는지 궁금하실 겁니다. 저도 이 부분이 가장 기대되었는데요. 간단하게 GPU 사용량과 처리량(throughput)을 비교해볼 수 있습니다.

    1. GPU 사용량 모니터링

    서버를 띄운 상태에서 watch -n 0.5 nvidia-smi 명령어로 GPU 메모리 사용량을 확인해보세요. vLLM 서버에 요청을 보낼 때 메모리 사용량이 어떻게 변화하는지 볼 수 있어요. 일반적인 Hugging Face 모델 서빙 방식과 비교하면, 특히 여러 요청이 동시에 들어올 때 메모리 점유율이 훨씬 안정적인 것을 확인할 수 있을 거예요.

    2. 처리량 비교

    vLLM은 자체적으로 벤치마킹 툴을 제공하기도 합니다. 하지만 간단하게는 ab (ApacheBench) 같은 툴이나 직접 작성한 스크립트를 통해 동시 요청을 보내면서 초당 처리되는 토큰 수나 응답 시간을 측정해볼 수 있어요.

    # 간단한 벤치마크 예시 (Python 스크립트 작성 필요)
    # vLLM 공식 문서의 examples/llm_bench.py 참고
    

    제가 직접 테스트해봤을 때, 동시 요청 처리량은 2배 이상, 경우에 따라 5배까지도 증가하는 것을 경험했습니다. 특히 길이가 다양한 프롬프트가 섞여 들어올 때 vLLM의 PagedAttention이 정말 빛을 발하더라고요. 응답 지연 시간(latency)도 확연히 줄어들었습니다. 👍

    위 그래프는 vLLM이 기존 LLM 서빙 방식 대비 얼마나 효율적인 GPU 메모리 사용과 높은 처리량을 제공하는지 시각적으로 보여줍니다.

    마무리: vLLM, LLM 서비스의 핵심 병기!

    오늘은 13년차 서버실 지킴이로서, LLM 추론 및 API 서빙을 최적화하는 데 필수적인 vLLM에 대해 자세히 알아봤습니다. PagedAttention이라는 독특한 기술 덕분에 GPU 자원을 아껴 쓰고, 더 많은 요청을 빠르게 처리할 수 있다는 점이 가장 인상 깊었거든요.

    저처럼 홈랩에서 LLM을 돌리거나, 실제 서비스에 LLM을 적용하려는 분들이라면 vLLM은 정말 강력한 도구가 될 겁니다. 처음엔 vLLM 설치나 설정에서 약간의 삽질이 있을 수 있지만, 일단 성공적으로 구축하고 나면 얻을 수 있는 성능 향상은 그 모든 노력을 보상하고도 남을 만큼 값집니다.

    vLLM은 높은 처리량, 낮은 지연 시간, 그리고 효율적인 GPU 메모리 사용이라는 세 가지 핵심 장점을 통해 LLM 서빙의 새로운 기준을 제시합니다.

    다음번에는 vLLM과 함께 사용할 수 있는 양자화(Quantization) 기술이나, 여러 모델을 동시에 서빙하는 방법 등 좀 더 심화된 내용을 다뤄볼까 합니다. 궁금한 점이 있다면 언제든지 댓글로 남겨주세요! 여러분의 LLM 여정에 조금이나마 도움이 되었기를 바랍니다. 감사합니다! 😊

  • [AI] Gemini API 실전 활용 가이드: 멀티모달 기능으로 AI 서비스 구축하기

    Gemini API 실전 활용 가이드: 멀티모달 AI 서비스 구축하기

    안녕하세요, 13년차의 서버실 주인장, 인프라 엔지니어입니다. 요즘 AI 기술 발전 속도가 정말 무섭다는 생각이 들어요. 특히 Gemini API가 등장하면서 텍스트뿐만 아니라 이미지, 오디오, 비디오까지 한 번에 처리하는 멀티모달 AI(Multimodal AI)의 시대가 본격적으로 열렸거든요. 저도 처음엔 ‘이게 정말 될까?’ 싶었는데, 직접 홈랩에서 굴려보니 그 잠재력에 깜짝 놀랐어요. 오늘은 저처럼 새로운 AI 서비스를 구축하고 싶으신 분들을 위해 Gemini API 활용법을 실전 경험 위주로 풀어볼까 합니다. 삽질 과정도 솔직하게 공유할 테니, 여러분은 저보다 더 쉽고 빠르게 목표를 달성하시길 바랍니다! 💡

    그림 1: Gemini API를 활용한 멀티모달 AI 서비스의 개요

    1. Gemini API, 무엇이 그렇게 특별할까요?

    Gemini API는 Google에서 개발한 차세대 대규모 언어 모델(LLM)인 Gemini 모델에 접근할 수 있게 해주는 인터페이스(API)예요. 그런데 단순히 텍스트만 주고받는 LLM과는 결이 다릅니다. 가장 큰 특징은 바로 멀티모달(Multimodal) 기능인데요. 쉽게 말해, 텍스트는 물론이고 이미지, 오디오, 비디오 등 여러 형태의 데이터를 동시에 이해하고 처리할 수 있다는 뜻입니다. 예를 들어, 이미지와 함께 질문을 던지면 이미지를 보고 답변을 해주는 식이죠.

    제가 이 기능을 처음 써봤을 때, ‘와, 이제 AI가 정말 세상을 보는 것 같구나’라는 생각이 들더라고요. 기존에는 이미지를 분석하려면 별도의 이미지 분석 모델을 거쳐야 했는데, Gemini API는 이 모든 걸 한 번에 처리해주니 AI 서비스 구축 과정이 훨씬 간결해졌어요. 특히, Google AI Studio(구 MakerSuite) 같은 도구를 활용하면 코딩 없이도 빠르게 프로토타입을 만들 수 있어서 개발 속도가 확 빨라지는 경험을 했습니다.

    2. Google AI Studio에서 Gemini API 시작하기

    Gemini API 활용의 첫걸음은 API 키를 발급받는 거예요. Google AI Studio에 접속하면 이 과정을 정말 쉽게 진행할 수 있어요. 별도의 복잡한 가입 절차 없이 Google 계정만 있으면 바로 시작할 수 있죠. 저도 처음엔 개발자 콘솔에서 복잡한 과정을 예상했는데, 생각보다 너무 간단해서 놀랐습니다.

    1. Google AI Studio (aistudio.google.com)에 접속합니다.
    2. 좌측 메뉴에서 ‘Get API key’를 클릭합니다.
    3. ‘Create API key in new project’ 또는 ‘Create API key in existing project’를 선택하여 API 키를 발급받습니다.
    4. 발급받은 API 키는 안전한 곳에 보관해야 합니다. 외부에 노출되지 않도록 각별히 주의하세요! ⚠️

    이렇게 API 키를 받았다면, 이제 여러분의 AI 서비스 구축 여정의 절반은 온 거예요. 이 키를 가지고 실제로 API를 호출해볼까요?

    3. Python으로 Gemini API 실전 구현: 멀티모달 챗봇 만들기

    이제 본격적으로 코드를 작성해볼 시간이에요. 저는 주로 Python을 사용해서 API 연동 작업을 하는데요, Gemini API도 Python SDK를 제공해서 정말 편리합니다. 이번 예제에서는 이미지와 텍스트를 동시에 입력받아 답변하는 간단한 멀티모달 챗봇을 만들어보겠습니다.

    3.1. 환경 설정

    먼저 필요한 라이브러리를 설치해야 합니다. 터미널에서 다음 명령어를 실행하세요.

    
    pip install google-generativeai pillow
    

    pillow는 이미지 처리를 위해 필요합니다.

    3.2. API 호출 코드 작성

    다음은 API 키를 설정하고 Gemini API를 호출하는 Python 코드입니다. 저는 보통 .env 파일에 API 키를 저장하고 python-dotenv 라이브러리로 불러오는데, 여기서는 예제 편의상 직접 코드로 넣겠습니다. 실제 운영 환경에서는 환경 변수(Environment Variable)를 사용하는 걸 강력히 권장합니다.

    
    import google.generativeai as genai
    from PIL import Image
    import io
    import os
    
    # 발급받은 API 키를 여기에 입력하세요.
    # 실제 환경에서는 환경 변수를 사용하는 것이 안전합니다.
    GOOGLE_API_KEY = "YOUR_API_KEY"
    genai.configure(api_key=GOOGLE_API_KEY)
    
    # Gemini 2.0 Flash 모델 로드 (멀티모달 기능 지원)
    model = genai.GenerativeModel('gemini-2.0-flash')
    
    def generate_multimodal_response(image_path, text_prompt):
        try:
            # 이미지 파일 로드
            img = Image.open(image_path)
            
            # API에 전달할 콘텐츠 구성
            # 텍스트와 이미지 데이터를 리스트 형태로 전달합니다.
            contents = [
                text_prompt,
                img
            ]
            
            # Gemini API 호출
            response = model.generate_content(contents)
            return response.text
        except Exception as e:
            return f"오류 발생: {e}"
    
    if __name__ == "__main__":
        # 예제 이미지 파일 (실제 이미지 파일 경로로 변경하세요)
        # 저는 홈랩에서 찍은 서버랙 사진으로 테스트해봤어요 ㅎㅎ
        sample_image_path = "./server_rack.jpg" 
        sample_text_prompt = "이 사진에 보이는 것에 대해 설명하고, 특별히 관리해야 할 부분이 있다면 알려줘."
    
        # 이미지 파일이 존재하는지 확인
        if not os.path.exists(sample_image_path):
            print(f"⚠️ {sample_image_path} 파일을 찾을 수 없습니다. 예제 이미지 파일을 준비해주세요.")
        else:
            print(f"질문: {sample_text_prompt}")
            print("이미지와 함께 Gemini API에 요청 중...")
            response_text = generate_multimodal_response(sample_image_path, sample_text_prompt)
            print("\nGemini 답변:")
            print(response_text)
    
    

    이 코드를 실행하려면 server_rack.jpg라는 이미지 파일이 코드와 같은 디렉토리에 있어야 해요. 저는 집에 있는 서버랙 사진을 찍어서 테스트해봤는데, AI가 팬 소음이 크지 않은지, 케이블 정리가 잘 되어 있는지 같은 조언을 해주더라고요. 정말 신기했어요! 🎉

    그림 2: Google AI Studio에서 멀티모달 프롬프트를 테스트하는 모습

    4. 삽질 경험: API Rate Limit과 Token 제한

    제가 Gemini API를 가지고 놀면서 가장 많이 겪었던 삽질은 바로 API Rate Limit(API 호출 제한)과 Token Limit(토큰 제한)이었어요. 처음에는 아무 생각 없이 테스트 스크립트를 여러 번 돌리다가 갑자기 에러를 만나 당황했죠. ⚠️

    • API Rate Limit: 일정 시간 동안 호출할 수 있는 API 요청 횟수 제한입니다. 너무 빠르게 많은 요청을 보내면 ‘Resource Exhausted’ 같은 에러 메시지를 보게 돼요. 저처럼 성격 급한 개발자라면 특히 조심해야 할 부분이에요. 해결책으로는 요청 사이에 time.sleep()을 넣어 딜레이를 주거나, 재시도 로직(Retry Logic)을 구현해서 지수 백오프(Exponential Backoff) 방식으로 요청을 보내는 것이 좋습니다.
    • Token Limit: Gemini 모델이 한 번에 처리할 수 있는 입력(Prompt) 및 출력(Response)의 최대 토큰(Token) 수입니다. 토큰은 단어, 구두점 등 AI가 이해하는 단위라고 생각하시면 돼요. 멀티모달의 경우 이미지도 특정 토큰 양으로 환산됩니다. 너무 긴 텍스트나 고해상도 이미지를 보내면 이 제한에 걸릴 수 있어요. 이럴 땐 입력 텍스트를 요약하거나, 이미지를 압축하여 해상도를 낮추는 등의 방법을 고려해야 합니다.

    이런 제한들은 안정적인 AI 서비스 구축을 위해 반드시 고려해야 할 부분이에요. 무작정 API를 호출하기보다, 각 모델의 제한 사항을 미리 확인하고 설계에 반영하는 습관이 중요합니다. 💡

    5. 결과 확인 및 활용 아이디어

    위 Python 코드를 실행하면 Gemini 모델이 이미지와 텍스트 프롬프트를 기반으로 답변을 생성하는 것을 확인할 수 있습니다. 예를 들어, 서버랙 사진과 함께 질문을 던졌을 때, AI는 사진 속 장비들의 종류를 식별하고, 케이블 정리 상태나 냉각 시스템에 대한 조언을 해줄 수 있어요. 이처럼 멀티모달 AI는 단순한 질문-답변을 넘어 상황 인지 기반의 훨씬 더 유용한 정보를 제공합니다.

    이러한 Gemini API 활용 아이디어는 정말 무궁무진해요.

    • 이미지 기반 제품 추천: 사용자가 찍은 옷 사진을 분석하여 유사한 스타일의 제품을 추천해주는 서비스.
    • 의료 이미지 분석 보조: X-ray나 MRI 이미지와 의사의 소견을 함께 입력하여 진단을 보조하는 시스템 (물론 전문의의 판단이 최우선이겠죠!).
    • 교육 콘텐츠 생성: 학습 자료 이미지와 텍스트를 분석하여 질문을 만들거나 요약본을 생성하는 봇.
    • 스마트 홈 모니터링: CCTV 이미지와 센서 데이터를 결합하여 이상 상황을 감지하고 사용자에게 알림.

    특히 제가 운영하는 홈랩에서는 보안 카메라 영상과 센서 데이터를 연동해서 이상 상황 감지 시스템을 만들어볼까 구상 중이에요. 기존에는 특정 객체 인식만 가능했는데, 이제는 ‘어떤 상황’인지까지 판단할 수 있게 되는 거죠. 정말 기대됩니다! 🎉

    그림 3: Gemini API를 활용한 AI 서비스 아이디어 예시

    6. 마무리하며: 경험이 곧 자산

    오늘은 Gemini API의 멀티모달 기능을 활용하여 AI 서비스 구축하는 방법에 대해 알아봤어요. 저도 처음엔 막막했지만, Google AI Studio를 통해 빠르게 프로토타입을 만들고, Python SDK로 직접 코드를 짜면서 많은 것을 배웠습니다. 특히 멀티모달 AI의 가능성은 상상 이상이었어요.

    물론 API 호출 제한이나 토큰 제한 같은 삽질도 있었지만, 이런 경험들이 쌓여 더 견고하고 효율적인 시스템을 만들 수 있는 밑거름이 된다고 생각해요. 13년차 인프라 엔지니어로서 늘 새로운 기술을 탐구하고 직접 손으로 구현해보는 것이 얼마나 중요한지 다시 한번 느꼈네요. 여러분도 오늘 소개드린 내용을 바탕으로 자신만의 멋진 AI 서비스를 만들어보시길 바랍니다!

    다음 글에서는 Gemini API를 활용한 스트리밍(Streaming) 응답 처리나 함수 호출(Function Calling) 기능에 대해 더 자세히 다뤄볼까 합니다. 관심 있으시다면 다음 포스팅도 기대해주세요! 😉

    그림 4: 13년차 인프라 엔지니어가 홈랩에서 Gemini API를 탐구하는 모습

  • [AI] RAG 실전 구현 가이드: LLM 환각 현상 줄이고 최신 정보 활용하기

    [AI] RAG 실전 구현 가이드: LLM 환각 현상 줄이고 최신 정보 활용하기

    RAG 실전 구현 가이드: LLM 환각 현상 줄이고 최신 정보 활용하기

    안녕하세요, 13년차 서버실의 인프라 엔지니어입니다. 요즘 인공지능(AI) 분야는 정말 눈 깜짝할 사이에 발전하고 있죠. 특히 대규모 언어 모델(Large Language Model, LLM)은 놀라운 성능으로 우리의 삶과 업무 방식을 바꾸고 있습니다. 그런데 말입니다. 아무리 똑똑한 LLM이라도 가끔은 엉뚱한 소리를 하거나, 오래된 정보만을 바탕으로 답변할 때가 있어요. 일명 LLM의 환각(Hallucination) 현상인데요. 혹시 이런 경험, 직접 해보신 적 있으신가요?

    저도 홈랩에서 LLM을 이것저것 만져보면서 느낀 건데, 최신 정보를 반영하거나 특정 도메인의 깊이 있는 지식을 묻기에는 한계가 명확하더라고요. 마치 최신 뉴스를 전혀 모르는 옛날 사람에게 질문하는 느낌이랄까요? 그래서 오늘은 이 문제를 해결하고 LLM의 답변 정확도를 높이는 Retrieval Augmented Generation(RAG) 기술을 직접 구현해보는 가이드를 준비했습니다.

    RAG는 LLM이 답변을 생성하기 전에 외부 지식 소스에서 관련 정보를 검색하여 이를 바탕으로 답변하도록 하는 기술이에요. 쉽게 말해, LLM에게 “책을 보고 답하세요!”라고 가이드하는 것과 같죠. 이 글을 통해 RAG가 뭔지, 왜 필요한지, 그리고 LangChain과 벡터 데이터베이스를 활용하여 어떻게 실전 구현하는지 단계별로 알아보겠습니다. 자, 그럼 시작해 볼까요?

    RAG 시스템의 전체적인 흐름을 보여주는 아키텍처 다이어그램입니다.

    1. LLM 환각 현상, 왜 발생할까요? 그리고 RAG가 답입니다!

    LLM은 방대한 텍스트 데이터를 학습하여 언어 패턴을 익히고 이를 기반으로 텍스트를 생성합니다. 하지만 학습 데이터는 특정 시점에 고정되기 때문에 최신 정보를 알지 못하죠. 또한, 학습 데이터에 없는 특정 분야의 전문 지식이나 개인화된 정보에 대한 답변은 어렵습니다. 이러한 문제들이 복합적으로 작용하여 LLM의 환각 현상이 발생하게 됩니다.

    환각 현상(Hallucination)은 LLM이 사실이 아니거나 학습 데이터에 근거하지 않은 정보를 마치 사실인 것처럼 그럴듯하게 만들어내는 현상을 뜻해요. 예를 들어, 존재하지 않는 논문을 인용하거나, 잘못된 통계를 제시하는 식이죠. 이는 LLM의 신뢰도를 크게 떨어뜨리는 요인이 됩니다.

    여기서 Retrieval Augmented Generation(RAG)이 등장합니다. RAG는 LLM의 이러한 한계를 극복하기 위한 강력한 솔루션이에요. RAG는 다음과 같은 두 가지 핵심 과정을 통해 작동합니다:

    • Retrieval (검색): 사용자의 질문과 관련된 정보를 외부 지식 소스(문서, 데이터베이스 등)에서 검색합니다.
    • Augmentation (증강): 검색된 정보를 사용자의 질문과 함께 LLM의 입력(프롬프트)으로 제공합니다.

    LLM은 이렇게 제공된 문맥(Context)을 바탕으로 답변을 생성하기 때문에, 훨씬 더 정확하고 최신 정보를 반영한 답변을 할 수 있게 되죠. 마치 똑똑한 AI 비서에게 필요한 자료를 미리 찾아주고 질문하는 것과 같다고 생각하시면 됩니다. 🎉

    2. RAG 구현을 위한 핵심 요소: LangChain과 벡터 데이터베이스

    RAG 시스템을 구축하기 위해서는 몇 가지 핵심 기술 요소가 필요해요. 제가 이번에 사용해 본 툴들은 다음과 같습니다.

    • LangChain: LLM 애플리케이션 개발을 위한 프레임워크예요. LLM과의 연동, 데이터 처리, 외부 도구 연결 등을 쉽게 할 수 있도록 다양한 모듈과 추상화를 제공합니다. RAG 파이프라인 구축에 필수적인 역할을 하죠.
    • 벡터 데이터베이스 (Vector Database): 텍스트 데이터를 벡터(수치형 배열)로 변환하여 저장하고, 유사도 검색을 효율적으로 수행하는 데이터베이스예요. LangChain RAG의 ‘Retrieval’ 단계에서 질문과 관련된 문서를 빠르게 찾아오는 데 핵심적인 역할을 합니다. ChromaDB, FAISS, Pinecone 등이 대표적인데, 저는 이번에 ChromaDB를 사용해봤어요. 설치와 사용이 정말 간편하더라고요.
    • 임베딩 모델 (Embedding Model): 텍스트를 벡터로 변환하는 역할을 해요. OpenAI의 text-embedding-ada-002나 Hugging Face의 다양한 오픈소스 모델 등을 사용할 수 있습니다.

    이 요소들을 조합하면 RAG 파이프라인을 효과적으로 구축할 수 있어요. LangChain은 이러한 각 구성 요소를 연결하고 조율하는 역할을 담당하며, 벡터 데이터베이스는 방대한 지식 소스에서 필요한 정보를 신속하게 찾아오는 ‘창고’ 역할을 하는 셈이죠.

    LangChain을 이용하여 ChromaDB와 LLM을 연결하는 RAG 파이프라인의 구성도입니다.

    3. RAG 실전 구현: 단계별 가이드 (Python & LangChain)

    이제 실제로 LLM RAG 시스템을 구현해 봅시다. 저는 개인적으로 자주 사용하는 Python 환경에서 LangChain 라이브러리를 이용할 예정입니다. 몇 가지 예제 문서를 준비해서 진행해 볼 테니까요. (실제로는 여러분의 문서나 데이터를 사용하시면 됩니다.)

    3.1. 필요한 라이브러리 설치

    먼저 필요한 라이브러리들을 설치합니다. 저는 langchain, chromadb, openai (API 키가 필요합니다), tiktoken 등을 사용해요.

    pip install langchain openai chromadb tiktoken python-dotenv
    

    3.2. 환경 설정 (API 키 로드)

    OpenAI API 키를 환경 변수로 설정하는 게 안전합니다. .env 파일을 생성하고 API 키를 저장해두세요.

    # .env 파일 내용
    OPENAI_API_KEY=your_openai_api_key_here
    

    Python 코드에서는 dotenv 라이브러리를 사용해 이 키를 로드합니다.

    import os
    from dotenv import load_dotenv
    
    load_dotenv()  # .env 파일에서 환경 변수 로드
    
    # 이제 os.getenv("OPENAI_API_KEY") 등으로 API 키에 접근 가능합니다.
    

    3.3. 문서 로드 및 분할 (Document Loading & Splitting)

    RAG의 첫 단계는 외부 지식 소스를 LLM이 이해할 수 있는 형태로 준비하는 거예요. LangChain은 다양한 포맷의 문서를 로드하는 기능을 제공합니다. 저는 간단한 텍스트 파일(.txt)을 사용해 보겠습니다.

    문서를 불러온 후에는 LLM이 처리하기 좋은 크기로 분할(Chunking)해야 해요. 너무 크면 문맥을 놓칠 수 있고, 너무 작으면 정보의 맥락이 끊어질 수 있거든요. RecursiveCharacterTextSplitter를 주로 사용하는데, 재귀적으로 텍스트를 분할하면서 지정된 크기를 유지하도록 도와줍니다.

    from langchain.document_loaders import TextLoader
    from langchain.text_splitter import RecursiveCharacterTextSplitter
    
    # 1. 문서 로드
    loader = TextLoader("path/to/your/document.txt", encoding="utf-8")
    documents = loader.load()
    
    # 2. 문서 분할
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
    chunks = text_splitter.split_documents(documents)
    
    print(f"총 {len(chunks)}개의 청크로 분할되었습니다.")
    

    3.4. 벡터 데이터베이스 설정 및 데이터 임베딩

    분할된 텍스트 청크를 벡터 데이터베이스에 저장해야 해요. 이 과정에서 임베딩 모델을 사용하여 각 텍스트 청크를 벡터로 변환합니다. 저는 OpenAI의 임베딩 모델을 사용하겠습니다.

    ChromaDB는 로컬에서 쉽게 사용할 수 있는 좋은 옵션이에요. Chroma.from_documents() 함수를 사용하면 문서 로딩, 임베딩, 벡터 DB 저장까지 한 번에 처리할 수 있어서 매우 편리합니다.

    from langchain_openai import OpenAIEmbeddings
    from langchain_community.vectorstores import Chroma
    
    # 임베딩 모델 설정 (OpenAI 사용 시 API 키 필요)
    embeddings = OpenAIEmbeddings()
    
    # ChromaDB 벡터 스토어 생성 및 문서 저장
    # persist_directory는 데이터를 디스크에 저장할 경로입니다.
    vectorstore = Chroma.from_documents(
        chunks,
        embeddings,
        persist_directory="./chroma_db"
    )
    
    print("벡터 데이터베이스에 문서가 성공적으로 저장되었습니다!")
    

    텍스트 문서를 임베딩하여 벡터로 변환 후 ChromaDB에 저장하는 과정입니다.

    3.5. RAG 체인 구성 및 질문 실행

    이제 마지막 단계예요. 검색기(Retriever)를 생성하고, 이를 LLM과 연결하여 RAG 체인을 완성합니다. LangChain의 create_stuff_documents_chain과 create_retrieval_chain을 사용하면 이 과정을 쉽게 구현할 수 있거든요.

    Retriever는 벡터 데이터베이스에서 질문과 가장 유사한 청크들을 검색하는 역할을 해요. vectorstore.as_retriever()로 간단하게 생성할 수 있습니다.

    from langchain_openai import ChatOpenAI
    from langchain.chains import create_retrieval_chain
    from langchain.chains.combine_documents import create_stuff_documents_chain
    
    # LLM 모델 설정 (GPT-3.5 Turbo 사용 예시)
    llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0.7)
    
    # RAG 프롬프트 템플릿
    from langchain_core.prompts import ChatPromptTemplate
    
    prompt = ChatPromptTemplate.from_template(
        """
        다음은 관련 정보를 검색한 내용입니다.
        이 정보를 바탕으로 질문에 답해주세요.
        만약 정보를 찾을 수 없다면, 정보가 없다고 말해주세요.
    
        컨텍스트:
        {context}
    
        질문:
        {input}
        """
    )
    
    # 문서 체인 생성
    document_chain = create_stuff_documents_chain(llm, prompt)
    
    # 검색기(Retriever) 생성
    retriever = vectorstore.as_retriever()
    
    # 검색 및 답변 체인 생성
    retrieval_chain = create_retrieval_chain(retriever, document_chain)
    
    # 질문 실행
    question = "RAG 기술의 주요 목적은 무엇인가요?"
    response = retrieval_chain.invoke({"input": question})
    
    print(f"질문: {question}")
    print(f"답변: {response['answer']}")
    

    코드를 실행하면, 준비된 문서에서 관련 정보를 검색하여 LLM이 답변을 생성하는 것을 볼 수 있어요. 제가 준비한 문서에는 RAG의 목적에 대한 내용이 포함되어 있었기 때문에, LLM이 정확하게 답변해 줄 겁니다. 와, 드디어 됐다! 🎉

    4. 주의사항 및 트러블슈팅 ⚠️

    RAG 구현은 생각보다 간단해 보이지만, 실제 운영 환경에서는 몇 가지 주의해야 할 점들이 있어요. 저도 몇 번의 삽질을 통해 배운 내용들을 공유해 드릴게요.

    • 청크 크기 및 오버랩: 청크 크기(chunk_size)와 오버랩(chunk_overlap) 설정은 검색 성능에 큰 영향을 미칩니다. 너무 작으면 문맥이 끊기고, 너무 크면 관련 없는 정보가 많이 포함될 수 있어요. 다양한 값을 테스트하며 최적의 값을 찾아야 합니다. 제 경험상 500~1000 토큰 사이의 chunk_size와 100~200 토큰 사이의 chunk_overlap이 무난하더라고요.
    • 임베딩 모델 선택: 어떤 임베딩 모델을 사용하느냐에 따라 검색 정확도가 달라집니다. OpenAI 모델이 성능이 좋지만 비용이 발생하죠. Hugging Face의 오픈소스 모델 중에서도 좋은 성능을 내는 모델들이 많으니, 필요에 따라 비교해보는 게 좋습니다.
    • 벡터 데이터베이스 선택: ChromaDB는 로컬 테스트에 좋지만, 대규모 서비스에서는 확장성이나 성능 면에서 FAISS, Milvus, Pinecone 같은 전문 벡터 DB를 고려해야 할 수 있어요.
    • 프롬프트 엔지니어링: LLM에게 어떤 지시를 내리느냐에 따라 답변의 품질이 크게 달라집니다. 컨텍스트를 어떻게 활용하고, 어떤 상황에서 “모른다”고 답해야 하는지 등을 명확하게 지시하는 프롬프트 작성 능력이 중요해요.
    • 성능 저하: 문서의 양이 많아질수록 검색 속도가 느려질 수 있어요. 이 경우, 벡터 DB의 인덱싱 전략을 최적화하거나, 더 효율적인 검색 알고리즘을 도입하는 등의 성능 개선 작업이 필요합니다.

    가장 흔하게 겪는 문제는 “왜 관련 없는 정보가 검색될까?” 또는 “내가 찾는 정보가 안 나와!” 하는 경우인데요. 이때는 보통 임베딩 모델이나 청크 크기 설정을 의심해 봐야 합니다. 저도 처음에 이걸 몰라서 한참 헤맸다니까요. 😂

    RAG 시스템의 질문 답변 정확도, 검색 속도 등을 모니터링하는 대시보드 예시입니다.

    5. 검증 및 결과 확인

    구현한 RAG 시스템의 성능을 확인하는 건 매우 중요해요. 단순히 질문에 답변이 나오는지 확인하는 것을 넘어, 얼마나 정확하고 관련성 높은 답변을 생성하는지 평가해야 합니다.

    가장 간단한 방법은 다양한 질문을 던져보고 답변의 품질을 육안으로 평가하는 거예요. 하지만 더 체계적인 평가를 위해서는 다음과 같은 지표들을 고려할 수 있습니다.

    • 정확도 (Accuracy): 생성된 답변이 사실에 부합하는가?
    • 관련성 (Relevance): 생성된 답변이 질문과 얼마나 관련이 있는가?
    • 최신성 (Freshness): 답변이 최신 정보를 반영하고 있는가?
    • 환각 방지 (Hallucination Reduction): 사실이 아닌 정보를 생성하지 않는가?

    LangChain에서는 이러한 평가를 자동화하기 위한 라이브러리들도 제공하고 있어요. 예를 들어, 특정 질문에 대해 예상되는 답변과 실제 생성된 답변을 비교하거나, 검색된 문서의 관련성을 평가하는 등의 방식으로 시스템을 검증할 수 있습니다.

    직접 테스트해보니, RAG를 적용했을 때 LLM의 답변이 훨씬 더 구체적이고 신뢰할 수 있게 되었어요. 특히 전문 분야나 최신 정보에 대한 질문에서 그 차이가 두드러지더라고요. 👍

    6. 마무리하며: RAG, LLM 활용의 새로운 지평을 열다

    오늘은 LLM의 환각 현상을 줄이고 최신 정보를 활용하기 위한 RAG(Retrieval Augmented Generation) 기술에 대해 알아봤습니다. LangChain과 벡터 데이터베이스를 활용하여 RAG 파이프라인을 직접 구현해보는 과정을 통해, LLM의 한계를 극복하고 더욱 강력하고 신뢰할 수 있는 AI 애플리케이션을 만들 수 있다는 걸 확인했어요.

    RAG는 단순히 LLM의 성능을 보완하는 것을 넘어, LLM이 특정 도메인의 전문 지식을 갖추고 실시간 정보를 반영하도록 만드는 핵심 기술이에요. 앞으로 RAG는 고객 지원 챗봇, 내부 문서 검색 시스템, 개인 맞춤형 정보 제공 등 정말 다양한 분야에서 활용될 거예요. 저도 이 기술을 활용해서 홈랩 프로젝트에 적용해볼 계획이거든요. 기대되지 않으신가요?

    RAG 기술이 적용될 수 있는 다양한 산업 분야와 서비스 예시입니다.

    이번 글이 RAG 기술에 대한 이해를 높이고, 직접 구현해보는 데 도움이 되었기를 바랍니다. 다음 글에서는 RAG 성능을 더욱 향상시킬 수 있는 고급 기법들에 대해 다뤄볼 예정이니, 많은 기대 부탁드립니다!

    핵심 요약:

    • LLM 환각 현상: LLM이 사실이 아닌 정보를 생성하는 문제
    • RAG (Retrieval Augmented Generation): 외부 정보 검색 후 LLM 답변 생성
    • 핵심 도구: LangChain (프레임워크), 벡터 DB (ChromaDB 등), 임베딩 모델
    • 구현 절차: 문서 로드/분할 → 임베딩/벡터 DB 저장 → RAG 체인 구성 → 질문 실행
    • 주의사항: 청크 크기, 임베딩 모델, 프롬프트 엔지니어링 등

    궁금한 점이 있다면 언제든지 댓글로 남겨주세요!