Claude Code가 짜준 코드를 분석하다가 클로저 패턴이 여러 군데서 보였다.
예전에 JavaScript 공부할 때 클로저 개념을 익혔었는데 그때도 개념이 잘 안 잡혔던 기억이 있어서, 이참에 이 프로젝트에 등장하는 클로저 패턴들을 직접 뜯어보면서 정리해보기로 했다.
클로저(Closure)란 함수가 자신이 선언된 스코프의 변수를 기억하는 것이다.
함수가 실행되고 끝나면 그 안의 변수는 보통 사라진다. 그런데 클로저는 함수가 끝난 뒤에도 특정 변수를 계속 붙잡고 있다. "기억한다"는 표현이 여기서 나온다.
이게 어떻게 가능한지 이해하려면 스코프와 생존 주기 개념부터 짚고 넘어가야 한다.
클로저를 이해하려면 먼저 변수가 언제 태어나고 언제 사라지는지를 알아야 한다.
함수 안에서 선언한 변수는 그 함수가 실행되는 동안에만 산다.
def greet():
message = "안녕하세요" # 함수가 실행될 때 태어남
print(message)
# 함수가 끝나면 message는 사라짐
greet()
print(message) # NameError: name 'message' is not defined
함수가 끝나면 그 안의 변수도 끝나며, 메모리에서 정리된다. 이게 변수의 생존 주기(lifetime)이다.
그리고 변수에 접근할 수 있는 범위를 스코프(scope)라고 한다. message는 greet 함수 안에서만 접근할 수 있다. 이게 지역 스코프(local scope)이다.
Python은 변수를 찾을 때 안쪽에서 바깥쪽으로 올라간다. 함수 안 → 함수 밖 → 전역 순서이다. 이걸 LEGB 규칙이라고 한다. (Local → Enclosing → Global → Built-in)
여기서 Enclosing이 이 클로저와 직결된다. 함수 안에 또 다른 함수가 있을 때, 안쪽 함수 입장에서 바깥 함수의 스코프가 바로 Enclosing 스코프이다.
def outer():
message = "안녕하세요" # Enclosing 스코프의 변수
def inner():
print(message) # Enclosing 스코프에서 message를 찾음
inner()
outer() # "안녕하세요"
이 시점까지는 그냥 중첩 함수이다. 클로저가 되려면 한 가지가 더 필요하다.
위 코드에서 inner()를 함수 안에서 바로 호출하지 않고, 반환해서 밖에서 호출하면 어떻게 될까?
def outer():
message = "안녕하세요"
def inner():
print(message)
return inner # inner를 반환
fn = outer() # outer()가 끝남
fn() # "안녕하세요" → 여전히 출력됨
outer()가 끝났으면 message도 사라져야 한다. 그런데 fn()을 호출하면 message가 살아있다. 이것이 클로저이다.
inner 함수가 반환되는 순간, Python은 inner가 참조하는 바깥 변수 message를 함께 챙긴다. 함수 객체 안에 변수를 같이 묶어서 보관한다. outer()가 끝나서 원래 스코프가 사라져도, message는 inner 함수 안에 살아 있다.
🤔 바깥 변수
message를 챙기는 이유?Python에서 함수는 단순한 코드 덩어리가 아니라 객체이다.
inner를return하는 순간 Python은 이 함수 객체를 만들면서, 함수 코드가 참조하는 변수 중 자기 스코프에 없는 것들을 미리 스캔한다.message가 여기에 해당된다.
그래서 Python은message를 cell 객체라는 특수한 컨테이너에 담아서 함수 객체에 같이 붙여준다. 원래outer의 스택 프레임이 사라져도 cell 객체는inner함수 객체가 살아있는 한 GC(가비지 컬렉터)에 수거되지 않는다.
이때 message 같은 변수를 자유 변수(free variable)라고 한다. 함수 자신의 파라미터도 아니고, 함수 안에서 새로 만든 것도 아닌데 함수 안에서 쓰이는 변수이다.
Python에서 실제로 확인해볼 수 있다.
def outer():
message = "안녕하세요"
def inner():
print(message)
return inner
fn = outer()
print(fn.__code__.co_freevars) # ('message', ) ← 캡처된 자유 변수 목록
print(fn.__closure__[0].cell_contents) # '안녕하세요' ← 실제 저장된 값
__closure__ 속성에 캡처된 변수들이 저장되어 있다. 클로저는 추상적인 개념이 아니라 함수 객체에 실제로 붙어있는 데이터 구조이다.
정리하면 이렇다.
클로저 = 함수 + 그 함수가 참조하는 자유 변수들의 묶음
클로저가 만들어지는 조건은 세 가지이다.
세 가지를 모두 만족해야 클로저이다. 단순한 중첩 함수는 클로저가 아니다.
JavaScript로 클로저를 공부했을 때, 이런 예제를 많이 봤다.
function makeCounter() {
let count = 0;
function increment() {
count++;
console.log(count);
}
return increment;
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3
makeCounter()가 끝난 뒤에도 count가 사라지지 않는다. increment가 count를 클로저로 캡처하고 있기 때문이다. 호출할 때마다 같은 count를 공유하면서 값이 누적된다.
위 JavaScript 코드를 Python으로 그대로 옮기면 이렇게 된다.
def make_counter():
count = 0
def increment():
nonlocal count # Python에서 바깥 변수에 재할당할 때 필요
count += 1
print(count)
return increment
counter = make_counter()
counter() # 1
counter() # 2
counter() # 3
구조가 완전히 같다. 차이는 딱 하나이다. Python에서는 바깥 변수의 값을 읽기만 할 때는 그냥 써도 되지만, count += 1처럼 재할당할 때는 nonlocal을 선언해 주어야 한다. 안 하면 Python이 count를 지역 변수로 착각해서 에러가 난다.
JavaScript는 let으로 선언하면 자동으로 클로저에서 재할당까지 된다.
정리하면 두 언어의 클로저는 개념과 동작 방식이 동일하다.
| JavaScript | Python | |
|---|---|---|
| 자유 변수 읽기 | 자동 | 자동 |
| 자유 변수 재할당 | 자동(let) | nonlocal 필요 |
| 클로저 확인 | fn.name 등 | fn.__closure__ |
이 프로젝트의 AI 에이전트는 인플루언서를 검색할 때 도구(tool)를 호출한다. 예를 들어 뷰티 카테고리 인플루언서를 찾으려면 search_influencers_by_category라는 도구를 실행한다.
이 도구 안에서는 DB 조회가 일어난다.
# search_tools.py
def search_influencers_by_category(categories: str) -> str:
influencers = repo.find_by_categories(category_list) # DB 조회
...
repo는 DB에 접근하는 객체이다. InfluencerRepositoryPort를 구현한 실제 저장소 인스턴스가 여기 들어와야 한다.
그런데 문제가 있다. @tool로 등록되는 함수는 에이전트가 직접 호출한다. LangGraph 에이전트가 "이 도구를 써야겠다"고 판단하면 프레임워크 내부에서 자동으로 호출하는 구조이다. 즉, 개발자가 직접 search_influencer_by_categories(repo, categories)처럼 repo를 넘기면서 호출할 수 없다.
⭐ LangGraph가 도구를 등록하고 호출하는 과정
@tool이 하는 일
@tool데코레이터를 붙이는 순간, LangGraph는 함수 시그니처를 읽어서 JSON 스키마로 변환한다.이게 이렇게 변환된다.@tool def search_influencers_by_category(categories: str) -> str: """카테고리로 인플루언서를 검색합니다.""" ...이 JSON 스키마가 LLM한테 전달된다. LLM은 이걸 보고 "이 도구를 쓰려면{ "name": "search_influencers_by_category", "description": "카테고리로 인플루언서를 검색합니다.", "parameters": { "type": "object", "properties": { "categories": { "type": "string" } }, "required": ["categories"] } }categories문자열을 내가 채워야 한다"는 걸 안다.
- 도구가 실행되는 흐름
사용자가 "뷰티 인플루언서 찾아줘"라고 하면 이런 순서로 진행된다.여기서 핵심은 4번 단계를 개발자가 아니라 프레임워크가 한다는 것이다. 개발자가 직접
search_influencers_by_category(repo, "뷰티")처럼 호출하는 코드를 어딘가에 쓸 수 있는 구조가 아니다.
LLM이 생성한 JSON을 프레임워크가 파싱해서 자동으로 함수를 실행한다.
repo를 파라미터에 넣으면 생기는 일
만약 이렇게 짰다면 어떻게 됐을까?@tool def search_influencers_by_category( repo: InfluencerRepositoryPort, categories: str ) -> str: ...@tool이 이 시그니처를 읽고 JSON 스키마를 만든다.LLM이 이 스키마를 받는다. 그러면 LLM 입장에서는{ "parameters": { "properties": { "repo": { "type": "object" // repo도 파라미터로 들어감 }, "categories": { "type": "string" } } } }repo도 자기가 채워야 하는 값이 된다.
LLM은 텍스트를 다루는 모델이다.InfluencerRepositoryPort인스턴스를 만들 방법이 없다. DB 커넥션, SQLAlchemy 세션, 이런 게 뭔지 모른다.
그래서 LLM은 이상한 JSON을 만들어 내거나, 도구 호출 자체를 포기한다.프레임워크가 이 JSON으로 함수를 호출하면// LLM이 만들어낼 수 있는 최선 { "repo": {}, // 빈 객체 아니면 그냥 null "categories": "뷰티" }repo에 빈 딕셔너리가 들어오고repo.find_by_categories()에서 바로 에러가 난다.
도구 함수의 시그니처는 에이전트가 LLM에게 보여주는 인터페이스이다. LLM이 보기에 이 도구는 "카테고리 문자열을 받아서 결과를 반환하는 함수"여야 한다. repo 같은 내부 의존성이 파라미터로 드러나면 안 된다.
# LLM이 보는 도구 스펙 (파라미터에 repo가 없어야 함)
search_influencers_by_category(categories: str) -> str
정리하면 이런 상황이다.
repo가 필요하다.repo를 넣으면 안 된다.repo를 끼워넣을 수도 없다.도구 함수가 실행될 때 repo를 어디선가 이미 알고 있어야 한다.
이 문제를 클로저 없이 해결하는 방법은 크게 세 가지이다. 그런데 각각 문제가 있다.
전역 변수
# 전역 변수로 repo를 보관
_repo = None # 모듈 최상단에 전역 변수 선언. 초기값은 None
def init_repo(repo):
global _repo # 전역 변수를 수정하겠다고 선언
_repo = repo # 외부에서 받은 repo를 전역 변수에 저장
@tool
def search_influencers_by_category(categories: str) -> str:
influencers = _repo.find_by_categories(...) # 전역에서 꺼내서 사용
...
앱이 시작될 때 init_repo(실제_repo_객체)를 한 번 호출해서 _repo에 저장해둔다. 이후 도구 함수가 실행될 때마다 _repo를 꺼내서 쓴다.
문제는 _repo가 전역 공간에 떠돌면서 어디서든 접근하고 수정할 수 있는 공유 상태라는 점이다.
# 다른 파일 어디서든 이게 가능하다
import search_tools
search_tools._repo = None # 실수로 덮어쓰기
search_tools._repo = 가짜_repo # 테스트 중 다른 코드가 바꿔치기
그리고 init_repo()를 호출하기 전에 도구 함수가 먼저 실행되면 _repo가 None인 채로 호출돼서 런타임 에러가 난다.
테스트할 때도 문제이다. 테스트 A에서 _repo를 mock 객체로 바꿨는데 테스트 B가 동시에 실행되면 테스트 B도 mock 객체를 보게 된다. 모듈이 커질수록 _repo가 어디서 설정됐는지 추적하기 어려워진다.
class SearchTools:
def __init__(self, repo: InfluencerRepositoryPort):
self.repo = repo # repo를 인스턴스 변수로 저장
@tool
def search_influencers_by_category(self, categories: str) -> str:
influencers = self.repo.find_by_categories(...)
... self.repo에 저장하는 아이디어 자체는 맞다. 인스턴스마다 독립적인 repo를 가지고, 외부에서 건드릴 수 없다. 전역 변수 문제는 해결된다.@tool을 메서드에 붙이는 순간 충돌이 생긴다.@tool이 함수 시그니처를 읽으면 이렇게 보인다.# @tool 입장에서 보이는 시그니처
search_influencers_by_category(self, categories: str) self가 첫 번째 파라미터로 노출된다. @tool은 이게 메서드인지 모르고, 그냥 파라미터가 두 개인 함수로 인식한다. 그래서 JSON 스키마가 이렇게 만들어진다.{
"parameters": {
"properties": {
"self": { "type": "object" }, // self가 LLM이 채워야 할 값이 됨
"categories": { "type": "string" }
}
}
} LLM이 self를 채우려 할 것이다. 방법 1과 똑같이 LLM이 객체 인스턴스를 만들 수 없는 문제로 돌아간다.@tool은 일반 함수에 붙이는 데코레이터이다. 메서드에 붙이면 self가 도구 파라미터로 노출되거나 프레임워크에 올바르게 인식하지 못하는 문제가 생긴다. 이걸 억지로 맞추려면 별도의 어댑터 코드가 계속 늘어난다.@tool
def search_influencers_by_category(categories: str, repo: InfluencerRepositoryPort) -> str:
...이 방식의 근본적인 문제는 누가 repo를 넘기느냐이다.{"categories": "뷰티"}라는 JSON을 생성하면 프레임워크가 그걸 파싱해서 함수를 호출한다. 개발자가 호출 코드를 직접 쓰는 게 아니다.repo는 프레임워크가 JSON에서 파싱해서 넣어줄 수밖에 없다. 그런데 repo는 JSON으로 표현할 수 없는 파이썬 객체이다. repo는 DB 연결 상태, 트랜잭션, 내부 설정 같은 것들을 들고 있는 런타임 객체이다. 이런 걸 JSON으로 직렬화하고 역직렬화하는 것은 불가능하다.클로저는 이 문제를 아주 깔끔하게 해결한다.
# search_tools.py
def create_search_by_category_tool(repo: InfluencerRepositoryPort): # repo를 받는 외부 함수
@tool
def search_influencers_by_category(categories: str) -> str: # 도구 함수 (파라미터에 repo 없음)
influencers = repo.find_by_categories(category_list) # repo를 클로저로 캡처
...
return search_influencers_by_category # 완성된 도구 함수 반환
그리고 agent_factory.py에서 이렇게 사용한다.
# agent_factory.py
tools = [
create_search_by_category_tool(influencer_repo), # repo를 넘겨서 도구 생성
create_search_by_platform_tool(influencer_repo),
create_search_by_followers_tool(influencer_repo),
...
]
흐름을 단계별로 따라가면 이렇다.
repo를 파라미터로 드러내지 않으면서도, 도구가 실행되는 시점에 repo를 쓸 수 있다. 전역 변수처럼 공유 상태를 만들지도 않는다.
이게 클로저가 이 프로젝트에서 반복적으로 선택된 이유이다. "지금 결정해야 할 것"과 "나중에 실행될 것"을 분리하는 문제를 클로저가 구조적으로 풀어준다. repo는 에이전트를 초기화하는 시점에 결정되고, 실제 DB 조회는 에이전트가 도구를 호출하는 시점에 일어난다. 클로저가 그 사이를 연결하는 다리 역할을 한다.
이 프로젝트에서 가장 많이 등장하는 클로저 패턴이다.
search_tools.py, detail_tools.py, analysis_tools.py 세 파일에서 총 4개의 도구가 이 패턴으로 만들어진다.
생김새는 모두 동일하다.
def create_XXX_tool(repo: InfluencerRepositoryPort): # 1. 외부 함수가 repo를 받음
@tool
def 실제_도구_함수(...) -> str: # 2. 내부 함수 정의
influencers = repo.find_by_XXX(...) # 3. repo를 클로저로 캡처
...
return 실제_도구_함수 # 4. 내부 함수를 반환
외부 함수(create_XXX_tool)는 repo를 받아서 내부 함수를 만들고 반환한다. 내부 함수는 repo를 클로저로 캡처한다. 이 구조를 Factory 클로저라고 부른다. 함수가 함수를 찍어내는 공장 역할을 하기 때문이다.
create_search_by_category_tool# search_tools.py
def create_search_by_category_tool(repo: InfluencerRepositoryPort):
@tool
def search_influencers_by_category(categories: str) -> str:
"""카테고리로 인플루언서를 검색합니다."""
category_list = [c.strip() for c in categories.split(",") if c.strip()]
if not category_list:
return "카테고리를 입력해주세요."
influencers = repo.find_by_categories(category_list) # repo 캡처
if not influencers:
return f"카테고리 '{categories}'에 해당하는 인플루언서가 없습니다."
results = []
for inf in influencers:
results.append({
"id": str(inf.id),
"name": inf.name,
"categories": inf.category_names,
"total_followers": inf.total_followers,
"engagement_rate": inf.engagement_rate,
"platforms": inf.platform_dict,
"base_price": int(inf.base_price.amount),
})
return json.dumps(results, ensure_ascii=False, indent=2)
return search_influencers_by_category
category_list = [c.strip() for c in categories.split(",") if c.strip()]
LLM이 넘겨주는 categories는 "뷰티,패션,라이프스타일"같은 쉽표 구분 문자열이다. 이걸 ["뷰티", "패션", "라이프스타일"] 리스트로 변환한다. c.strip()으로 앞뒤 공백도 제거한다.
influencers = repo.find_by_categories(category_list)
여기서 repo가 쓰인다. repo는 이 함수의 파라미터가 아니다. create_search_by_category_tool의 파라미터인데, 내부 함수가 클로저로 캡처해서 쓰고 있다.
find_by_categories는 DB에서 해당 카테고리를 가진 인플루언서 목록을 가져온다.
create_search_by_platform_tool, create_search_by_followers_tool# search_tools.py
def create_search_by_platform_tool(repo: InfluencerRepositoryPort):
@tool
def search_influencers_by_platform(platform: str) -> str:
"""특정 플랫폼에서 활동하는 인플루언서를 검색합니다."""
influencers = repo.find_by_platform(platform.lower().strip()) # repo 캡처
...
return search_influencers_by_platform
def create_search_by_followers_tool(repo: InfluencerRepositoryPort):
@tool
def search_influencers_by_followers(min_followers: int, max_followers: Optional[int] = None)
"""팔로워 수 범위로 인플루언서를 검색합니다."""
influencers = repo.find_by_follower_range(min_followers, max_followers) # repo 캡처
...
return search_influencers_by_followers
platform.lower().strip()
LLM이 "instagram"이나 "INSTAGRAM"처럼 대소문자를 섞어서 넘길 수 있어서 소문자로 정규화한다.
max_followers: Optional[int] = None
상한 없이 최소 팔로워만 지정하는 경우를 허용한다. LLM이 필요에 따라 이 파라미터를 생략할 수 있다.
create_get_influencer_details_tool# detail_tools.py
def create_get_influencer_details_tool(repo: InfluencerRepositoryPort):
@tool
def get_influencer_details(influencer_id: str) -> str:
"""인플루언서의 상세 프로필 정보를 조회합니다."""
try:
influencer = repo.find_by_id(UUID(influencer_id)) # repo 캡처
except (ValueError, TypeError):
return f"잘못된 인플루언서 ID 형식입니다: {influencer_id}"
if not influencer:
return f"인플루언서를 찾을 수 없습니다: {influencer_id}"
primary_platform, primary_followers = influencer.primary_platform
detail = {
"id": str(influencer.id),
"name": influencer.name,
"description": influencer.description or "소개 없음",
"categories": influencer.category_names,
"platforms": {p.value: f for p, f in influencer.platforms.items()}
"total_followers": influencer.total_followers,
"engagement_rate": influencer.engagement_rate,
"engagement_rate_pct": f"{influencer.engagement_rate * 100:.1f}%" if influencer.engagement_rate else "N/A",
"base_price": int(influencer.base_price.amount),
"base_price_formatted": f"{int(influencer.base_price.amount):,}원",
"primary_platform": primary_platform.value,
"primary_platform_followers": primary_followers,
}
return json.dumps(detail, ensure_ascii=False, indent=2)
return get_influencer_details
위의 검색 도구들과 차이점은 UUID(influencer_id) 변환을 try/except로 감싸고 있다.
LLM이 넘겨주는 influencer_id는 문자열이다. 이걸 UUID 객체로 변환해야 DB에서 조회할 수 있는데, LLM이 잘못된 형식의 ID를 넘길 수 있다. 그래서 변환 실패 시 에러 메시지를 문자열로 반환한다. 도구 함수가 예외를 던지면 에이전트 실행 자체가 멈추기 때문에, 예외 대신 메시지를 반환하는 방식으로 처리한다.
create_calculate_match_score_tool# analysis_tools.py
def create_calculate_match_score_tool(repo: InfluencerRepositoryPort):
@tool
def calculate_match_score(
influencer_id: str,
ad_concept: str,
ad_item: str,
target_categories: str,
preferred_platforms: str = "",
) -> str:
"""인플루언서의 광고 매칭 점수를 4가지 차원으로 계산합니다."""
try:
influencer = repo.find_by_id(UUID(influencer_id)) # repo 캡처
except (ValueError, TypeError):
return f"잘못된 인플루언서 ID: {influencer_id}"
if not influencer:
return f"인플루언서를 찾을 수 없습니다: {influencer_id}"
categories = [c.strip().lower() for c in target_categories.split(",") if c.strip()]
platforms = [p.strip().lower() for p in preferred_platforms.split(",") if p.strip()]
# 카테고리 점수 (40%)
inf_categories = set(c.lower() for c in influencer.category_name)
target_cats = set(categories)
overlap = inf_categories.intersection(target_cats)
category_score = (len(overlap) / len(target_cats) * 100) if target_cats else 50
# 팔로워 점수 (25%)
followers = influencer.total_followers
if 50_000 <= followers <= 500_000: follower_score = 90
elif 10_000 <= followers < 50_000: follower_score = 75
elif 500_000 < followers <= 1_000_000: follower_score = 85
elif followers > 1_000_000: follower_score = 75
else: follower_score = 60
# 인게이지먼트 점수 (20%)
engagement_score = min(100, influencer.engagement_rate * 2000) if influencer.engagement_rate else 50
# 플랫폼 점수 (15%)
if platforms:
inf_platforms = set(p.lower() for p in influencer.platform_dict.keys())
platform_overlap = inf_platforms.intersection(set(platforms))
platform_score = len(platform_overlap) / len(platforms) * 100
else:
platform_score = 70
total = (
category_score * 0.40
+ follower_score * 0.25
+ engagement_score * 0.20
+ platform_score * 0.15
)
result = {
"influencer_name": influencer.name,
"scores": {
"category_score": round(category_score, 1),
"follower_score": round(follower_score, 1),
"engagement_score": round(engagement_score, 1),
"platform_score": round(platform_score, 1),
"total_score": round(total, 1),
},
...
}
return json.dumps(result, ensure_ascii=False, indent=2)
return calculate_match_score
이 도구는 파라미터가 5개이다. 검색 도구들보다 LLM에게 더 많은 정보를 요구한다.
ad_concept, ad_item은 실제로 점수 계산 로직에서 직접 쓰이지 않고 있지만, LLM이 맥락을 파악해서 target_categories를 적절히 결정하는 데 도움을 주는 힌트 파라미터 역할을 한다.
repo의 역할은 앞의 도구들과 동일하다. repo.find_by_id()로 인플루언서 도메인 객체를 가져오고, 그 객체에서 점수 계산에 필요한 데이터를 꺼낸다.
# agent_factory.py
tools = [
create_search_by_category_tool(influencer_repo), # influencer_repo 캡처
create_search_by_platform_tool(influencer_repo), # influencer_repo 캡처
create_search_by_followers_tool(influencer_repo), # influencer_repo 캡처
create_get_influencer_details_tool(influencer_repo), # influencer_repo 캡처
create_calculate_match_score_tool(influencer_repo), # influencer_repo 캡처
]
influencer_repo는 실제 DB와 연결된 저장소 객체이다. 이걸 각 create_*_tool() 함수에 넘기는 순간, 내부에서 클로저가 만들어지면서 각 도구 함수가 독립적으로 influencer_repo를 캡처한다.
이 다섯 개의 도구는 모두 같은 influencer_repo를 캡처하고 있지만, 서로 독립적인 함수 객체이다. 하나의 도구가 실행된다고 해서 다른 도구에 영향을 주지 않는다.
이후 에이전트가 실행되면 다음 순서로 흐른다.
repo가 어디에서 왔는지, 어떻게 연결됐는지는 LLM도, 프레임워크도 신경 쓰지 않는다. 도구 함수가 실행되는 시점에 이미 repo가 클로저 안에 있기 때문이다.
Factory 클로저 패턴이 이 프로젝트에서 반복적으로 선택된 이유를 한 줄로 정리하면 다음과 같다.
"결정할 시점"과 "실행할 시점"이 다를 때 그 사이를 클로저가 연결한다.
repo가 무엇인지는 앱이 시작되는 시점에 결정된다. 도구가 실제로 실행되는 건 사용자가 에이전트와 대화하는 시점이다.
이 두 시점 사이의 간격을 클로저가 메운다. repo를 전역 변수로 풀어두지 않고, 필요한 함수에만 묶어서 안전하게 넘긴다.
database.py의 execute_read와 execute_write에 등장한다.
def execute_read(self, query: str, parameters: Dict[str, Any] = None) -> List[Dict[str, Any]]:
with self.get_session() as session:
result = session.execute_read(
lambda tx: list(tx.run(query, parameters or {})) # Lambda 클로저
)
return [record.data() for record in result]
def execute_write(self, query: str, parameters: Dict[str, Any] = None) -> List[Dict[str, Any]]:
with self.get_session() as session:
result = session.execute_write(
lambda tx: list(tx.run(query, parameters or {})) # Lambda 클로저
)
return [record.data() for record in result]
Factory 클로저가 def 키워드로 내부 함수를 만들고 반환했다면, 이 패턴은 람다로 한 줄짜리 클로저를 만들어서 함수의 인자로 즉시 넘긴다. 반환하지 않고 바로 사용한다는 점이 다르다.
람다 클로저가 왜 필요한지 이해하기 위해서는 Neo4j 드라이버가 트랜잭션을 처리하는 방식을 먼저 알아야 한다.
Neo4j 드라이버에서 session.execute_read()는 이렇게 생겼다.
session.execute_read(콜백_함수)
콜백 함수를 인자로 받는다. 드라이버가 트랜잭션(tx)을 열고, 그 tx를 콜백 함수에 넘겨서 실행하고, 완료되면 커밋, 에러가 나면 롤백한다.
트랜잭션 관리를 드라이버가 담당하고, 실제로 뭘 조회할지는 콜백 함수가 담당하는 구조이다.
그러면 콜백이 쿼리를 실행하려면 query와 parameters가 필요하다. 그런데 콜백 함수의 파라미터는 tx 하나뿐이다. 드라이버가 tx만 넘겨주기 때문이다.
이 상황이 Factory 패턴에서 봤던 문제와 구조적으로 동일하다. 콜백이 실행될 때 query와 parameters를 어딘가에서 이미 알고 있어야 하는데, 클로저로 캡처하면 해결된다.
lambda tx: list(rx.run(query, parameters or {}))
이 한 줄을 풀어쓰면 다음과 같다.
def 콜백(tx): # tx는 드라이버가 넘겨주는 트랜잭션 객체
result = tx.run(query, parameters or {}) # query, parameters는 클로저로 캡처
return list(result)
txqueryexecute_read의 파라미터이다. 람다 안에서 선언된 게 아닌데 쓰이고 있다.parameters or {}parameters도 execute_read의 파라미터이다. 마찬가지로 클로저로 캡처된다.or {}는 parameters가 None일 때 빈 딕셔너리로 대체한다는 뜻이다. Neo4j 드라이버가 None을 받으면 에러가 나기 때문에 방어 처리이다.list(...)tx.run()은 커서 객체를 반환한다. 트랜잭션이 닫히기 전에 커서를 리스트로 변환해서 결과를 메모리에 올려둬야 한다.⭐ 커서 객체란?
DB에 쿼리를 실행하면 결과가 한 번에 메모리에 다 올라오는 게 아니라, 결과를 가리키는 포인터만 먼저 반환된다. 이 포인터가 커서(cursor)이다.
result = tx.run(query, parameters) # 결과 데이터가 아니라 커서가 반환됨실제 데이터는 커서를 통해 한 줄씩 읽어올 때 DB에서 가져온다.
이렇게 설계된 이유는 결과가 수만 건일 때 전부 메모리에 올리지 않고 필요한 만큼만 가져오기 위해서이다.
문제는 커서가 트랜잭션에 열려있는 동안에만 유효하다는 점이다.# 위험한 코드 def 콜백(tx): return tx.run(query, parameters) # 커서를 그대로 반환 result = session.execute_read(콜백) # 여기서 트랜잭션이 닫힘 list(result) # 트랜잭션이 닫혔으니 커서로 데이터를 읽을 수 없음그래서 트랜잭션이 닫히기 전에
list()로 커서를 순회해서 데이터를 전부 메모리에 올려두는 것이다.lambda tx: list(tx.run(query, parameters or {})) # ↑ 트랜잭션 안에서 커서를 즉시 리스트로 변환
list()가 호출되는 순간 커서를 처음부터 끝까지 순회하면서 모든 row를 파이썬 리스트에 담는다.
이후 트랜잭션이 닫혀도 리스트는 메모리에 남아있으니 안전하게 쓸 수 있다.
Factory 클로저에서도 강조했던 개념인데, 람다 클로저에서 더 명확하게 드러난다.
def execute_read(self, query: str, parameters: Dict[str, Any] = None):
with self.get_session() as session:
# ① 이 시점에 람다가 만들어짐 → query, parameters 캡처
result = session.execute_read(
lambda tx: list(tx.run(query, parameters or {}))
)
# ② 드라이버 내부에서 람다가 실행됨 → tx를 받아서 캡처된 query, parameters로 쿼리 실행
return [record.data() for record in result]
①에서 람다가 만들어지면서 그 순간의 query와 parameters 값을 캡처한다.
②에서 드라이버가 람다를 실행할 때는 execute_read의 스택 프레임이 이미 내부적으로 넘어갔지만, 람다는 캡처한 값을 그대로 들고 있다.
개발자가 직접 tx.run(query, parameters)를 호출하는 게 아니다. 드라이버에게 "이 콜백을 트랜잭션 안에서 실행해줘"라고 위임하는 구조이다. 그래서 콜백이 query와 parameters를 스스로 기억하고 있어야 한다.
execute_read vs. execute_write 차이# 읽기 전용 트랜잭션
session.execute_read(lambda tx: list(tx.run(query, parameters or {})))
# 쓰기 트랜잭션
session.execute_write(lambda tx: list(tx.run(query, parameters or {})))
람다 코드 자체는 완전히 동일하다. 차이는 드라이버 내부에 있다.
execute_read는 읽기 전용 트랜잭션을 연다. 트랜잭션 안에서 데이터를 수정하려고 하면 DB 레벨에서 직접 차단한다. 개발자가 실수로 조회 함수에 CREATE나 DELETE 쿼리를 넣어도 DB가 막아준다.
execute_write는 쓰기 트랜잭션을 연다. 생성, 수정, 삭제 쿼리를 실행할 수 있다. 에러가 나면 드라이버가 자동으로 롤백한다.
람다 클로저 입장에서는 두 경우 모두 동일하게 query와 parameters를 캡처해서 tx.run()에 넘기는 역할만 한다. 트랜잭션 종류에 따른 처리는 드라이버가 담당한다.
| Factory 클로저 | Lambda 클로저 | |
|---|---|---|
| 문법 | def 내부 함수 | lambda 한 줄 |
| 반환 여부 | 반환해서 나중에 사용 | 즉시 인자로 전달 |
| 생존 기간 | 에이전트가 살아있는 동안 | execute_read 호출 동안만 |
| 캡처 변수 | repo (의존성 객체) | query, parameters (호출시 값) |
| 목적 | 의존성을 함수에 묶어두기 | 콜백에 현재 컨텍스트 전달하기 |
Factory 클로저는 앱이 시작될 때 한 번 만들어져서 에이전트가 종료될 때까지 살아있다.
Lambda 클로저는 execute_read가 호출될 때마다 새로 만들어지고, 드라이버가 콜백을 실행하고 나면 사라진다. 수명이 훨씬 짧다.
짧게 쓸 수 있다면 람다로, 로직이 복잡하거나 재사용해야 하면 def로 내부 함수를 만드는 게 일반적인 선택 기준이다. 이 코드에서 람다로 한 줄에 끝난 건 로직이 단순해서다. tx.run()에 두 값을 넘기는 게 전부이기 때문이다.
analysis_tools.py의 calculate_match_score_raw 함수 안에 등장한다.
# analysis_tools.py
def calculate_match_score_raw(influencer, categories, platforms) -> dict:
# ... 점수 게산 로직 ...
total = (
category_score * 0.40
+ follower_score * 0.25
+ engagement_score * 0.20
+ platform_score * 0.15
)
def _dim_grade(score: float) -> str: # 내부 함수
if score >= 70:
return "상"
if score >= 40:
return "중"
return "하"
def _overall_grade(t: float) -> str: # 내부 함수
if t >= 85: return "S"
if t >= 70: return "A"
if t >= 55: return "B"
if t >= 40: return "C"
return "D"
return {
"overall_grade": _overall_grade(total),
"evaluation": {
"category_fit": {"grade": _dim_grade(category_score)},
"follower_power": {"grade": _dim_grade(follower_score)},
"engagement": {"grade": _dim_grade(engagement_score)},
"platform_fit": {"grade": _dim_grade(platform_score)},
},
...
}
함수 안에 함수가 있는 것은 Factory 패턴과 겉모습이 비슷하다. 그런데 결정적으로 다른 점이 있다.
클로저의 조건을 다시 떠올려보자.
- 함수 안에 함수가 있다.
- 안쪽 함수가 바깥 함수의 변수를 참조한다.
- 안쪽 함수가 바깥 함수보다 오래 살아남는다
_dim_grade를 보자.
def _dim_grade(score: float) -> str:
if score >= 70:
return "상"
if score >= 40:
return "중"
return "하"
score는 이 함수 자신의 파라미터이다. 바깥 함수(calculate_match_score_raw)의 변수를 참조하지 않는다. 즉 자유 변수가 없다.
따라서 _dim_grade와 _overall_grade는 클로저가 아니고, 단순한 중첩 함수이다.
첫째, 이 함수들은 calculate_match_score_raw 밖에서 쓸 일이 없다.
점수를 "상/중/하"로 바꾸는 로직은 이 함수 안에서만 필요하다. 모듈 최상단에 꺼내놓으면 파일 어디서든 접근할 수 있는 함수가 되는데, 그럴 필요가 없다. 쓰이는 곳 바로 옆에 정의해두는 게 읽기 편하다.
둘째, 이름 앞에 _를 붙여도 완전한 은닉이 안 된다.
Python에서 _로 시작하는 이름은 관례적으로 "내부용"이라는 신호지만, 실제로는 외부에서 접근할 수 있다. 반면 함수 안에 정의하면 그 함수 스코프 밖에서는 아예 접근이 불가능하다.
# 모듈 최상단에 꺼내놓은 경우
def _dim_grade(score): ... # 관례상 내부용이지만 외부 접근 가능
# 함수 안에 정의한 경우
def calculate_match_score_raw(...):
def _dim_grade(score): ... # 이 함수 밖에서는 아예 존재를 모름
| 클로저 | 중첩 함수 | |
|---|---|---|
| 바깥 변수 참조 | 있음 | 없음 |
__closure__ | 값이 있음 | None |
| 반환 여부 | 주로 반환해서 사용 | 주로 내부에서만 사용 |
| 목적 | 바깥 상태 기억 | 코드 구조화, 반복 제거 |
이 프로젝트의 _dim_grade와 _overall_grade는 중첩 함수이다. Factory 패턴의 search_influencers_by_category는 클로저이다. 겉모습은 비슷하지만 바깥 변수를 캡처하느냐 아니냐로 갈린다.
중첩 함수도 클로저도 "함수 안에 함수를 정의한다"는 문법은 같다. 클로저는 그 중에서 바깥 스코프의 변수를 캡처하는 경우를 가리키는 말이다.
ad_history_tools.py 전체가 이 패턴 하나이다.
# ad_history_tools.py
def create_ad_history_tools(ad_post_repo: AdPostRepository) -> list: # repo 하나를 받아서
@tool
def get_ad_history(influencer_id: str, limit: int = 10) -> str:
posts = ad_post_repo.find_by_influencer(uid, limit=limit) # 캡처
...
@tool
def get_ad_brand_summary(influencer_id: str) -> str:
total_count = ad_post_repo.count_by_influencer(uid) # 같은 repo 캡처
brand_summary = ad_post_repo.get_brand_summary(uid) # 같은 repo 캡처
...
return [get_ad_history, get_ad_brand_summary] # 두 개를 리스트로 반환
Factory 패턴과의 차이점은 반환값이 함수 하나가 아니라 함수 두 개짜리 리스트라는 점이다.
ad_post_repo를 공유get_ad_history와 get_ad_brand_summary는 서로 다른 함수지만, 둘 다 같은 ad_post_repo를 캡처하고 있다.
이게 어떤 의미인지 메모리 관점에서 보면 이렇다.
두 클로저의 __closure__가 같은 ad_post_repo 객체를 가리킨다. 복사본이 두 개 생기는 게 아니다.
Python에서 직접 확인해볼 수 있다.
tools = create_ad_history_tools(repo)
get_ad_history, get_ad_brand_summary = tools
print(get_ad_history.__closure__[0].cell_contents is
get_ad_brand_summary.__closure__[0].cell_contents) # True
같은 객체인지 비교하는 is 비교가 True로 나온다. 즉, 동일한 객체를 두 클로저가 함께 참조하고 있다.
get_ad_history
@tool
def get_ad_history(influencer_id: str, limit: int = 10) -> str:
"""인플루언서의 실제 광고 게시글 이력을 조회합니다."""
try:
uid = UUID(influencer_id)
except (ValueError, TypeError):
return f"잘못된 인플루언서 ID 형식입니다: {influencer_id}"
posts = ad_post_repo.find_by_influencer(uid, limit=limit) # ad_post_repo 캡처
if not posts:
return json.dumps({
"influencer_id": influencer_id,
"ad_count": 0,
"message": "수집된 광고 이력이 없습니다.",
"posts": [],
}, ensure_ascii=False)
return json.dumps({
"influencer_id": influencer_id,
"ad_count": len(posts),
"posts": [
{
"posted_at": p.posted_at.strftime("%Y-%m-%d"),
"brand": p.brand or "미확인",
"ad_category": p.ad_category or "미확인",
"caption_preview": p.caption_preview,
"post_url": p.post_url,
"confidence": p.confidence,
"ai_reason": p.ai_reason,
}
for p in posts
],
}, ensure_ascii=False, indent=2)
limit: int = 10
LLM이 몇 건을 볼지 조절할 수 있다. 기본값은 10건이다.
p.brand or "미확인"
인스타그램 게시글에서 스크래핑한 데이터라 브랜드 정보가 없는 경우가 있다.
None이면 "미확인"으로 대체한다.
p.confidence
AI가 이 게시글을 광고로 판단한 확신도이다.
인스타그램 게시글을 스크래핑해서 AI가 광고 여부를 분류했기 때문에 확신도 값이 붙어있다.
p.ai_reason
AI가 광고라고 판단한 근거 텍스트이다. LLM이 이 값을 보고 매칭 근거를 설명할 때 활용한다.
get_ad_brand_summary@tool
def get_ad_brand_summary(influencer_id: str) -> str:
"""인플루언서가 광고한 브랜드 목록과 광고 횟수를 요약합니다."""
try:
uid = UUID(influencer_id)
except (ValueError, TypeError):
return f"잘못된 인플루언서 ID 형식입니다: {influencer_id}"
total_count = ad_post_repo.count_by_influencer(uid) # ad_post_repo 캡처
brand_summary = ad_post_repo.get_brand_summary(uid) # ad_post_repo 캡처
if total_count == 0:
return json.dumps({...}, ensure_ascii=False)
return json.dumps({
"influencer_id": influencer_id,
"total_ad_count": total_count,
"brands": [
{"brand": row["brand"], "count": row["count"]}
for row in brand_summary
],
}, ensure_ascii=False, indent=2)같은 ad_post_repo를 두 번 호출한다. count_by_influencer는 총 광고 횟수, get_brand_summary는 브랜드별 광고 횟수 집계이다.get_ad_history는 게시글 하나하나를 자세히 보는 도구이고, get_ad_brand_summary는 "이 인플루언서가 어떤 브랜드와 얼마나 협업했나"를 한눈에 파악하는 도구이다. LLM이 상황에 따라 골라서 쓴다.별도의 factory 함수로 분리할 수도 있다.
def create_get_ad_history_tool(ad_post_repo): ...
def create_get_ad_brand_summary_tool(ad_post_repo): ...
# agent_factory.py에서
tools = [
create_get_ad_history_tool(ad_post_repo),
create_get_ad_brand_summary_tool(ad_post_repo),
]
그런데 둘은 같은 데이터 소스(광고 이력)를 다루는 짝꿍 도구이다. 하나를 쓰면 다른 하나도 같이 쓰게 되는 경우가 많다. 함께 묶어두면 agent_factory.py에서 한 번의 호출로 두 도구를 같이 등록할 수 있다.
# agent_factory.py
*(create_ad_history_tools(ad_post_repo) if ad_post_repo else []),
*로 리스트를 펼쳐서 tools 리스트에 끼워넣는다. ad_post_repo가 없으면 빈 리스트를 펼치니까 아무것도 추가되지 않는다.
두 도구를 한꺼번에 활성화하거나 한꺼번에 비활성화하는 스위치 역할도 자연스럽게 된다.
구조는 같고 반환값만 다르다. 패턴 1은 함수 하나를 반환하고, 패턴 4는 같은 repo를 공유하는 함수 여러 개를 리스트로 반환한다.
관련된 도구들을 하나의 factory에서 일괄 생성할 때 자연스럽게 나오는 패턴이다.
database.py의 get_session 메서드이다.
from contextlib import contextmanager
@contextmanager
def get_session(self):
session = self.driver.session(database=self.database) # 세션 열기
try:
yield session # 세션을 바깥에 넘겨줌
finally:
session.close() # 무조건 세션 닫기
execute_read에서 get_session이 쓰인다.
def execute_read(self, query: str, parameters: Dict[str, Any] = None):
with self.get_session() as session: # get_session 사용
result = session.execute_read(
lambda tx: list(tx.run(query, parameters or {}))
)
return [record.data() for record in result]
with 블록 안에서 session을 쓰고, 블록이 끝나면 session이 자동으로 닫힌다.
@contextmanager가 하는 일@contextmanager는 제너레이터 함수를 with 문에서 쓸 수 있는 컨텍스트 매니저로 변환하는 데코레이터이다.
with 문은 원래 이런 구조로 동작한다.
with 어떤_객체 as 변수:
# 블록 안
...
# 블록 밖
with 문에 들어갈 때 __enter__를 호출하고, 나올 때 __exit__를 호출한다. 보통 클래스에 이 두 메서드를 직접 구현해야 한다.
# 클래스로 구현하면
class SessionManager:
def __enter___(self):
self.session = driver.session()
return self.session
def __exit__(self, exc_type, exc_val, exc_tb):
self.session.close()
@contextmanager는 이 클래스를 직접 만들지 않고 제너레이터 함수만으로 같은 효과를 낸다.
@contextmanager
def get_session(self):
session = self.driver.session(database=self.database) # __enter__에 해당
try:
yield session # 여기서 with 블록 안으로 제어가 넘어감
finally:
session.close() # __exit__에 해당
yield가 경계선이다. yield 이전은 with 블록에 진입할 때 실행되고, yield 이후는 with 블록에서 나올 때 실행된다.
get_session 안을 보면 바깥 스코프 변수를 참조하고 있다.
def get_session(self):
session = self.driver.session(database=self.database)
# ↑ ↑
# self의 변수 self의 변수
try:
yield session
finally:
session.close()
self.driver와 self.database는 get_session 안에서 선언된 값이 아니다. 인스턴스 변수, 즉 외부 스코프의 값이다. 그리고 session은 yield 앞에서 만들어지는데, finally 블록에서도 참조된다. yield를 기준으로 실행이 중단됐다가 재개되는 사이에도 session이 살아있어야 한다.
제너레이터 함수는 yield에서 실행을 멈추고 상태를 그대로 보존한다. with 블록 안에서 아무리 시간이 걸려도, 심지어 예외가 발생해도 제너레이터 안의 session 변수는 사라지지 않는다. finally가 실행될 때까지 살아있다. 이게 클로저와 닮은 점이다.
클로저가 함수가 끝난 뒤에도 변수를 기억한다면, 제너레이터는 실행이 멈춘 동안에도 변수를 기억한다.
finally가 중요한 이유try/finally 구조가 이 패턴의 핵심이다.
try:
yield session
finally:
session.close()
finally는 어떤 상황에서도 실행된다. with 블록 안에서 쿼리가 정상적으로 끝나도, 예외가 발생해도, 심지어 return으로 빠져나가도 session.close()는 반드시 실행된다.
finally가 없는 경우에는 문제가 발생한다.
@contextmanager
def get_session(self):
session = self.driver.session(database=self.database)
yield session
session.close() # with 블록에서 예외가 나면 이 줄은 실행 안 됨
with 블록 안에서 예외가 발생하면 yield 다음 코드로 넘어가지 않는다. session.close()가 실행되지 않아서 세션이 열린 채로 남는다. DB 연결이 쌓이다가 결국 연결 수 제한에 걸린다.
try/finally로 감싸면 예외가 발생해도 finally 블록은 무조건 실행되고, 세션이 항상 닫힌다.
execute_read에서 get_session이 어떻게 쓰이는지 전체 흐름을 따라가보자.
def execute_read(self, query: str, parameters: Dict[str, Any] = None):
with self.get_session() as session:
result = session.execute_read(
lambda tx: list(tx.run(query, parameters or {}))
)
return [record.data() for record in result]

session의 생성과 소멸이 get_session 안에서 완전히 관리된다. execute_read를 호출하는 쪽에서는 세션을 열고 닫는 것을 신경 쓰지 않아도 된다.
지금까지 본 다섯 가지 패턴을 나란히 놓으면 이렇다.
| 패턴 | 외부 변수 캡처 | 반환 방식 | 수명 |
|---|---|---|---|
| Factory 클로저 | repo | 함수 반환 | 에이전트 생존 기간 |
| Lambda 클로저 | query, parameters | 즉시 인자로 전달 | 함수 호출 한 번 |
| 내부 함수 헬퍼 | 없음 | 내부에서만 사용 | 외부 함수 실행 중 |
| 멀티 클로저 팩토리 | ad_post_repo | 리스트로 반환 | 에이전트 생존 기간 |
| Context Manager 클로저 | self.driver, self.database, session | yield로 전달 | with 블록 동안 |
Context Manager 패턴은 수명이 with 블록에 묶여있다는 점에서 독특하다.
yield로 값을 내보내고, 블록이 끝나면 finally로 정리한다. 리소스를 안전하게 빌려주고 돌려받는 구조이다.
| 패턴 | 파일 | 캡처한 변수 | 쓴 이유 |
|---|---|---|---|
| Factory 클로저 | search_tools.py 외 | repo | LLM에게 노출하지 않고 의존성 주입 |
| Lambda 클로저 | database.py | query, parameters | 콜백에 현재 컨텍스트 전달 |
| 내부 함수 헬퍼 | analysis_tools.py | 없음(중첩 함수) | 반복 로직 정리, 스코프 은닉 |
| 멀티 클로저 팩토리 | ad_history_tools.py | ad_post_repo | 같은 의존성을 공유하는 도구 묶음 생성 |
| Context Manager 클로저 | database.py | session | 리소스를 안전하게 빌려주고 반납 |
다섯 패턴 모두 공통적으로 해결하는 문제가 하나 있다.
"지금 알고 있는 것"을 "나중에 실행될 함수"에 안전하게 전달하는 것
함수에 의존성을 주입해야 하는데 파라미터로 드러내기 어려울 때
이 프로젝트의 Factory 패턴이 여기에 해당한다. 콜백, 이벤트 핸들러, 프레임워크가 대신 호출하는 함수처럼 시그니처가 고정된 상황에서 의존성을 넘겨야 할 때 클로저가 자연스러운 선택이다.
# 시그니처가 고정된 상황
@tool
def search(categories: str) -> str: # categories만 받아야 함
repo.find(...) # 그런데 repo도 필요함 → 클로저로 해결
콜백 함수가 호출 시점의 컨텍스트를 알아야 할 때
Lambda 패턴이 여기에 해당한다. 콜백을 넘기는 시점의 변수를 콜백이 실행될 때도 써야 한다면 클로저로 캡처한다.
session.execute_read(
lambda tx: tx.run(query, parameters) # query, parameters를 캡처
)
관련된 함수 여러 개가 같은 상태를 공유해야 할 때
멀티 클로저 팩토리 패턴이 여기에 해당한다. 여러 함수가 같은 의존성을 기반으로 동작하고, 함께 생성되고 함께 쓰인다면 하나의 factory에서 묶어서 만든다.
리소스의 생성과 정리를 하나의 단위로 묶어야 할 때
Context Manager 패턴이 여기에 해당한다. 열면 닫아야 하는 리소스는 @contextmanager로 생명주기를 한 곳에서 관리한다.
클로저가 항상 정답은 아니다.
여러 메서드가 상태를 공유하고 그 상태가 자주 바뀐다면 클래스가 낫다. 클로저는 상태를 변경하려면 nonlocal을 써야 해서 코드가 복잡해진다. 클래스는 인스턴스 변수로 자연스럽게 관리된다.
함수 하나가 단순히 파라미터를 미리 고정하는 용도라면 functools.partial이 더 직관적이다.
functools.partial은 함수의 파라미터 일부를 미리 고정해서 새로운 함수를 만든다.
from functools import partial
def search(repo, categories):
return repo.find(categories)
search_with_repo = partial(search, repo) # repo를 미리 고정
search_with_repo("뷰티") # categories만 넘기면 됨
search는 파라미터가 두 개이다. repo와 categories 둘 다 넘겨야 호출할 수 있다.
partial(search, repo)는 search 함수의 첫 번째 파라미터 repo를 미리 고정한 새로운 함수를 반환한다. 그래서 이후에는 categories만 넘기면 된다.
로직이 단순하고 재사용할 일이 없다면 클로저 대신 그냥 파라미터를 직접 넘기는 게 읽기 편할 수 있다.
클로저는 캡처가 어디서 일어나는지 따라가야 해서, 단순한 경우에는 오히려 코드를 읽기 어렵게 만들기도 한다.