폐쇄망에서 사내 LLM API 붙이기: openai 패키지 없이 urllib 하나로

2026.10.04
현장 데이터 서비스 구축기 7편

사내 LLM 게이트웨이(OpenAI 호환 API)를 Flask 서비스에 연동하면서 openai 패키지 없이 Python 표준 라이브러리 urllib만 썼다. 사내 필수 헤더, 호출별 ID, 환경변수로 공급자를 바꾸는 구조, API 승인 전 mock으로 먼저 개발하는 방법을 코드와 함께 정리했다.

이 글의 순서

    상황: 사내 LLM 게이트웨이와 폐쇄망

    이슈 사례 분석 기능에 LLM을 붙이기로 했다. 외부 API는 쓸 수 없고, 사내에서 운영하는 LLM 게이트웨이를 써야 했다. 다행히 인터페이스는 OpenAI 호환(chat/completions)이었다. 대신 사내 규칙에 따라 인증 티켓과 호출 시스템명, 사용자 ID, 호출별 고유 ID 같은 헤더를 매번 붙여야 했다.

    호출 구조

    사용자SSO 로그인
    Flask 서비스llm_client.py사용자 ID 자동 주입
    사내 LLM 게이트웨이OpenAI 호환 API헤더 검증·감사 로그
    LLM

    openai 패키지를 쓰지 않은 이유

    OpenAI 호환이니 openai 파이썬 패키지에 base_url만 바꿔 쓰면 될 것 같았다. 그래도 표준 라이브러리 urllib으로 직접 호출하기로 했다.

    • 폐쇄망에서는 패키지 하나를 들이는 것도 일이다. 반입 절차와 버전 관리가 따라온다. 의존성이 하나 줄면 컨테이너 이미지와 보안 점검 대상도 하나 준다.
    • 사내 필수 헤더가 많아서 결국 클라이언트를 감싸야 한다. 감쌀 거라면 처음부터 요청을 직접 만드는 쪽이 투명하다.
    • 실제로 쓰는 호출은 chat/completions 하나다. 이 정도면 표준 라이브러리로 충분하다.

    트레이드오프스트리밍 응답, 재시도, 타입 정의는 직접 만들어야 한다. 호출 종류가 늘어나거나 스트리밍이 필요해지면 그때 패키지 도입을 다시 검토하면 된다.

    헤더 정리

    아래 헤더 이름은 예시로 바꿨다. 게이트웨이마다 이름은 다르지만 요구하는 정보는 대체로 비슷하다.

    헤더 (예시)값용도
    X-Api-Ticket발급받은 인증 티켓호출 인증
    X-System-Name서비스 이름어느 시스템이 불렀는지
    X-User-Id로그인한 사용자 ID누가 물었는지 (감사 추적)
    X-User-Type사용자 ID 종류ID 체계 구분
    X-Prompt-Msg-Id호출마다 새 UUID질문 단위 추적
    X-Completion-Msg-Id호출마다 새 UUID응답 단위 추적

    사용자 ID는 화면에서 받지 않고 SSO 세션에서 꺼내 자동으로 넣는다. 사용자가 입력할 수 있게 두면 다른 사람 이름으로 호출할 수 있다.

    클라이언트 코드

    llm_client.py
    import json
    import os
    import uuid
    import urllib.error
    import urllib.request
    
    PROVIDER = os.environ.get("LLM_PROVIDER", "mock")   # gateway | mock
    BASE = os.environ.get("LLM_BASE", "")
    KEY = os.environ.get("LLM_KEY", "")
    MODEL = os.environ.get("LLM_MODEL", "")
    SYSTEM = os.environ.get("LLM_SYSTEM_NAME", "my-service")
    
    # 사내망 프록시 환경변수를 타지 않게 빈 프록시로 연다 (환경에 따라 선택)
    _opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
    
    
    def chat(messages, user_id, temperature=0.2, timeout=60):
        if PROVIDER == "mock":
            return _mock(messages)
    
        body = json.dumps({
            "model": MODEL,
            "messages": messages,
            "temperature": temperature,
        }).encode("utf-8")
    
        req = urllib.request.Request(BASE.rstrip("/") + "/chat/completions",
                                     data=body, method="POST")
        req.add_header("Content-Type", "application/json")
        req.add_header("X-Api-Ticket", KEY)
        req.add_header("X-System-Name", SYSTEM)
        req.add_header("X-User-Id", user_id)
        req.add_header("X-User-Type", "AD_ID")
        req.add_header("X-Prompt-Msg-Id", str(uuid.uuid4()))
        req.add_header("X-Completion-Msg-Id", str(uuid.uuid4()))
    
        try:
            with _opener.open(req, timeout=timeout) as res:
                data = json.loads(res.read().decode("utf-8"))
        except urllib.error.HTTPError as e:
            detail = e.read().decode("utf-8", "replace")[:500]
            raise RuntimeError(f"LLM HTTP {e.code}: {detail}") from e
        except urllib.error.URLError as e:
            raise RuntimeError(f"LLM 연결 실패: {e.reason}") from e
    
        return data["choices"][0]["message"]["content"]
    
    
    def _mock(messages):
        q = messages[-1]["content"]
        return f"[mock] '{q[:30]}' 질문에 대한 응답 자리"

    HTTPError를 잡을 때 응답 본문을 꼭 읽어서 남긴다. 게이트웨이는 헤더가 빠졌거나 티켓이 만료됐을 때 본문에 이유를 적어 주는데, 상태 코드만 보면 401인지 403인지밖에 모른다.

    app.py (호출하는 쪽)
    from flask import session, jsonify, request
    from llm_client import chat
    
    @app.post("/api/case-ai")
    @login_required
    def case_ai():
        question = request.json.get("q", "").strip()
        messages = [
            {"role": "system", "content": "이슈 데이터에 근거해서만 답한다. 근거가 없으면 모른다고 답한다."},
            {"role": "user", "content": question},
        ]
        answer = chat(messages, user_id=session["user_id"])
        return jsonify({"answer": answer})

    승인 전에는 mock으로 먼저 만든다

    사내 LLM API는 사용 신청과 승인에 시간이 걸린다. 그 사이 화면과 흐름을 먼저 만들 수 있도록 공급자를 환경변수 하나로 바꾸게 했다. 로컬과 개발 중에는 LLM_PROVIDER=mock, 승인 후 운영 환경에서만 gateway로 바꾼다.

    화면·라우트완성
    chat()LLM_PROVIDER=mock
    고정 응답흐름 검증용
    화면·라우트그대로
    chat()LLM_PROVIDER=gateway
    사내 LLM코드 수정 없음

    바뀌는 것은 환경변수뿐이다.

    키와 티켓은 코드나 저장소에 두지 않고 배포 환경의 Secret이나 환경변수로 넣는다. 모델이 바뀔 때도 LLM_MODEL만 바꾸면 된다.

    정리

    • OpenAI 호환 API 한 종류만 부른다면 urllib으로 충분하다. 폐쇄망에서는 의존성 하나가 곧 절차 하나다.
    • 사용자 ID는 세션에서 자동으로 넣고, 호출마다 UUID를 붙여 추적 가능하게 한다.
    • HTTPError 본문을 남겨야 원인을 알 수 있다.
    • 공급자를 환경변수로 바꾸게 하면 API 승인을 기다리는 동안 mock으로 개발을 끝낼 수 있다.
    이전 편6편. 비전문가에게 AI 성능을 보여주는 법: 점수 대신 '10건 중 약 6건'
    다음 편8편. 이슈 1건에 리포트 1장: AI 이슈 사례분석 화면 설계
    #LLM#Python#urllib#Flask#폐쇄망#OpenAI호환API#사내LLM#API연동

    댓글