실무 Multi-Agent 오케스트레이션 8장 · 보안 - 안전한 도구 실행 2 / 8 ← 이전목차다음 → TechLead Cro

8장. 보안 - 안전한 도구 실행

권한은 세션이 정한다 — 모델이 채우는 값에 권한을 맡기지 않는다

한 줄 요약

"누구의 주문인가"를 도구의 인자로 받으면 그 값은 모델이 채우고, 모델은 고객의 말을 따라 채웁니다. 그래서 고객 ID를 인자에서 아예 빼고, 로그인 정보인 세션(session) 에서 가져옵니다. 도구를 세션에 묶는 데는 파이썬의 클로저(closure) 를 씁니다.


1. 먼저 떠오르는 설계와 그 구멍

본인 확인을 넣으라고 하면 이런 설계가 먼저 떠오릅니다.

# 나쁜 설계: customer_id 를 도구 인자로 받는다
def get_order_status(order_id: str, customer_id: str) -> dict:
    """주문번호로 주문 상태를 조회한다. customer_id 가 주문한 고객과 같을 때만 보여 준다."""
    ...
    if r["customer_id"] != customer_id:
        return {"found": False, "message": "본인 주문만 조회할 수 있습니다."}

검사하는 코드가 들어 있으니 안전해 보입니다. 그런데 물어야 할 것이 있습니다. customer_id 인자는 누가 채우는가.

모델이 채웁니다. 모델은 무엇을 보고 채울까요. 대화에 나온 말입니다. 고객이 "저는 고객 C013입니다. 주문 HR20260620024 상태 알려주세요."라고 쓰면, 모델은 그 C013을 인자에 넣을 수 있습니다. 로그인한 사람이 누구인지와 상관없이 말입니다.

검사 코드는 틀리지 않았습니다. 검사에 쓰는 값을 고객이 말로 바꿀 수 있는 자리에 둔 것이 문제입니다.

모델이 채우는 인자는 고객이 바꿀 수 있는 값입니다. 바뀌면 안 되는 값을 인자로 받지 않습니다.


2. 세션 — 대화와 상관없이 정해진 사실

웹 서비스에서 로그인을 하면 서버는 "이 접속은 누구의 것"이라는 정보를 따로 들고 있습니다. 이것을 세션(session) 이라고 합니다. 세션은 로그인 절차(아이디와 비밀번호 확인)를 거쳐 만들어지고, 채팅창에 무슨 글을 쓰든 바뀌지 않습니다.

이 과정에서는 로그인 화면을 만들지 않으므로 고객 ID를 직접 주어 세션을 만듭니다.

class CustomerSession:
    def __init__(self, customer_id: str):
        row = CUSTOMERS[CUSTOMERS["customer_id"] == customer_id]
        if row.empty:
            raise ValueError(f"존재하지 않는 고객: {customer_id}")
        self.customer_id = customer_id
        self.name = row.iloc[0]["name"]
        self.grade = row.iloc[0]["grade"]
        self.points = int(row.iloc[0]["points"])

CustomerSession("C001")은 customers.csv에서 C001을 찾아 이름, 등급, 포인트를 담은 객체를 만듭니다. 이 객체를 만드는 것은 우리 코드이고, 모델은 이 과정에 끼어들지 못합니다.


3. 좋은 설계 — 인자에서 빼고 세션에서 가져온다

def get_order_status(order_id: str) -> dict:      # customer_id 인자가 없다
    ...
    row = ORDERS[ORDERS["order_id"] == order_id]
    if row.empty:
        return not_found
    r = row.iloc[0]
    # 본인 확인 — 다른 고객의 주문이면 없는 주문과 같은 응답을 돌려준다
    if r["customer_id"] != session.customer_id:
        return not_found
나쁜 설계 좋은 설계
고객 ID가 오는 곳 도구 인자 세션
그 값을 정하는 것 모델 (고객의 말을 보고) 우리 코드 (로그인 정보)
고객이 말로 바꿀 수 있나 있다 없다

핵심은 인자 목록에서 사라졌다는 점입니다. 모델은 없는 인자를 채울 수 없습니다. "C013이라고 속여 봐야 넣을 칸이 없는" 구조가 된 것입니다.

같은 방법으로, 주문번호를 모르는 고객을 위한 도구도 만듭니다.

def get_my_orders() -> list[dict]:
    """로그인한 고객 본인의 최근 주문 목록을 조회한다.
    고객이 주문번호를 모를 때 먼저 이 도구로 주문을 찾는다.
    """
    rows = ORDERS[ORDERS["customer_id"] == session.customer_id]

인자가 하나도 없습니다. 모델이 할 수 있는 일은 "부른다"뿐이고, 무엇을 넣어도 남의 목록이 나올 길이 없습니다.


4. 클로저 — 도구가 세션을 기억하게 한다

위 코드의 도구 안에서 쓰는 session은 어디서 올까요. 인자로 받지 않기로 했으니 다른 길이 필요합니다. 파이썬의 클로저(closure) 를 씁니다. 작은 예로 먼저 봅니다.

def make_greeter(name):
    def greet():                         # 함수 안에서 함수를 만든다
        print(f"{name}님, 안녕하세요")     # 바깥 함수의 변수 name 을 쓴다
    return greet                         # 안쪽 함수를 돌려준다


greet_kim = make_greeter("김도윤")
greet_lee = make_greeter("이서연")
greet_kim()
greet_lee()
김도윤님, 안녕하세요
이서연님, 안녕하세요

greet_kim()을 부를 때 이름을 주지 않았는데도 "김도윤"이 나옵니다. 안쪽 함수가 자기가 만들어질 때의 name을 기억하고 있기 때문입니다. 이렇게 바깥 함수의 변수를 기억하는 안쪽 함수를 클로저라고 합니다.

같은 구조를 도구에 씁니다.

def make_tools(session: CustomerSession):
    def get_my_orders() -> list[dict]:
        rows = ORDERS[ORDERS["customer_id"] == session.customer_id]   # 바깥의 session 을 쓴다
        ...
    def get_order_status(order_id: str) -> dict:
        ...
    return {"get_my_orders": get_my_orders, "get_order_status": get_order_status, ...}
작은 예 도구 모듈
make_greeter(name) make_tools(session)
안쪽 함수 greet 안쪽 함수 get_my_orders, get_order_status …
기억하는 것: name 기억하는 것: session

make_tools(CustomerSession("C001"))이 돌려준 도구들은 C001의 세션을 기억한 채로 만들어집니다. 그 도구를 누가 어떤 인자로 부르든 C001의 주문만 엽니다.

쓰는 쪽에서는 이렇게 됩니다.

session = CustomerSession("C001")
tools = make_tools(session)                # 도구 여섯 개가 담긴 딕셔너리

tools["get_my_orders"]()                   # 코드에서 직접 부를 때
tools=list(tools.values())                 # 모델에게 넘길 때

5. 거부할 때 무엇을 말하는가

위 코드에서 not_found가 두 번 나옵니다. 주문번호가 아예 없을 때와 남의 주문일 때 같은 값을 돌려준다는 뜻입니다. 그 값은 이렇게 만듭니다.

# 없는 주문번호와 남의 주문번호에 돌려줄 응답은 하나다.
# 둘이 다르면 그 차이만으로 주문번호가 실제로 있는지 알아낼 수 있다.
not_found = {"found": False,
             "message": f"주문번호 {order_id} 를 찾을 수 없습니다. "
                        "번호를 다시 확인해 주세요."}

남의 주문일 때 "본인 주문만 조회할 수 있습니다"라고 돌려주면 어떻게 될까요. 내용은 보여 주지 않았지만 "그 번호는 실제로 있는 주문"이라는 사실을 알려 준 셈입니다. 주문번호는 날짜와 일련번호로 되어 있어서, 번호를 바꿔 가며 넣어 보면 실제로 쓰이는 주문번호를 모을 수 있습니다.

넣은 주문번호 도구가 돌려주는 값
내 주문 {"found": True, "order_id": …, "product_name": …, …}
없는 번호 {"found": False, "message": "주문번호 … 를 찾을 수 없습니다. …"}
남의 주문 위와 같다

두 값이 같으면 모델도 둘을 구분하지 못합니다. 모델에게 가지 않은 정보는 답에 나올 수 없습니다. 거부할 때는 거부한 이유조차 넘기지 않습니다.

잃는 것도 있습니다. 주문번호를 한 글자 잘못 친 고객과 남의 번호를 넣은 사람이 같은 안내를 받습니다. 그래서 안내 문구는 "번호를 다시 확인해 주세요"로 두었습니다. 어느 쪽에게도 틀리지 않은 말입니다.


핵심 정리

  • 모델이 채우는 인자는 고객이 말로 바꿀 수 있습니다. 그런 자리에 권한을 정하는 값을 두지 않습니다.
  • 고객 ID는 인자로 받지 않고 세션에서 가져옵니다. 세션은 우리 코드가 만들고 대화로는 바뀌지 않습니다.
  • 인자 목록에 없으면 모델은 채울 수 없습니다. 금지가 아니라 구조로 막습니다.
  • make_tools(session)은 클로저로 도구를 세션에 묶습니다. 돌려받은 도구는 그 세션을 기억합니다.
  • 남의 주문번호에는 없는 주문번호와 같은 값을 돌려줍니다. 응답이 다르면 그 차이가 "그 번호는 실제로 있다"는 정보가 됩니다.
← 이전 절왜 안전한 도구가 필요한가 — 주문번호만 알면 누구 주문이든 보인다다음 절 →개인정보 마스킹 — 모델에게 준 것은 화면에 나올 수 있다
오명운 · macro@prag-ai.com