의존성과 yield
의존성과 yield (Dependencies with yield)
"종료 후에 해야 할 일"이 있는 의존성, 예를 들어 DB 세션을 열었다가 요청이 끝나면 꼭 닫아야 하는 그런 일을 FastAPI에서는 어떻게 처리할까요? return 대신 yield를 쓰면 되는답니다. 의존성에 yield가 들어가면, 그 뒤에 적은 코드는 응답이 보내진 다음에 실행돼요. 하나씩 살펴볼게요.
출처: 공식문서
FastAPI는 의존성이 종료 후에 추가 단계(exit code, cleanup code, teardown code, closing code, context manager exit code 등으로도 불려요)를 실행하도록 지원해요.
이렇게 하려면 return 대신 yield를 쓰고, 추가 실행할 코드(단계)를 그 뒤에 적으면 됩니다.
팁
의존성 하나당
yield는 딱 한 번만 사용해야 해요.
기술적 세부 사항
아래 데코레이터와 함께 쓸 수 있는 함수라면 무엇이든 FastAPI 의존성으로 사용할 수 있어요.
사실 FastAPI는 내부적으로 이 두 데코레이터를 사용한답니다.
yield를 쓰는 데이터베이스 의존성
예를 들어 DB 세션을 만들고, 일이 끝나면 닫는 용도로 쓰면 돼요.
면밀히 보면 실행 순서가 나뉘어요. 응답을 만들기 전에 실행되는 건 yield 문장까지(그 포함해서)의 코드예요:
async def get_db():
db = DBSession()
try:
yield db
finally:
db.close()
yield로 내보낸 값이 바로 path operation이나 다른 의존성에 주입되는 값이에요:
async def get_db():
db = DBSession()
try:
yield db
finally:
db.close()
yield 문장 다음의 코드는 응답이 보내진 뒤에 실행돼요:
async def get_db():
db = DBSession()
try:
yield db
finally:
db.close()
팁
async함수든 일반 함수든 상관없어요. 일반 의존성과 똑같이 FastAPI가 각각에 맞게 알아서 처리해 줍니다.
yield와 try를 쓰는 의존성
yield가 있는 의존성에서 try 블록을 쓰면, 그 의존성을 사용하는 동안 발생한 예외를 받아낼 수 있어요.
예를 들어 중간 어딘가, 다른 의존성이나 path operation에서 DB 트랜잭션을 "rollback" 하거나 어떤 예외를 만들었다면, 그 예외를 의존성 안에서 받게 됩니다.
그래서 의존성 안에서 except SomeException으로 그 특정 예외를 찾아 처리할 수 있어요.
마찬가지로 finally를 쓰면 예외가 있었든 없었든 종료 단계가 반드시 실행되게끔 보장할 수 있어요.
async def get_db():
db = DBSession()
try:
yield db
finally:
db.close()
yield가 있는 하위 의존성
하위 의존성과, 그 크기와 모양이 다양한 하위 의존성 "트리(tree)"를 가질 수 있어요. 그리고 그중 어떤 것이든 전부 yield를 쓸 수 있습니다.
FastAPI는 yield가 있는 각 의존성의 "exit code"가 올바른 순서로 실행되도록 보장해 줘요.
예를 들어 dependency_c가 dependency_b에, dependency_b가 dependency_a에 의존할 수 있어요:
from typing import Annotated
from fastapi import Depends
async def dependency_a():
dep_a = generate_dep_a()
try:
yield dep_a
finally:
dep_a.close()
async def dependency_b(dep_a: Annotated[DepA, Depends(dependency_a)]):
dep_b = generate_dep_b()
try:
yield dep_b
finally:
dep_b.close(dep_a)
async def dependency_c(dep_b: Annotated[DepB, Depends(dependency_b)]):
dep_c = generate_dep_c()
try:
yield dep_c
finally:
dep_c.close(dep_b)
그리고 이 셋 모두 yield를 쓸 수 있어요.
이 경우 dependency_c가 자신의 exit code를 실행하려면 dependency_b의 값(여기서는 dep_b로 이름 지은)이 아직 살아 있어야 해요.
또 dependency_b도 자신의 exit code 실행에 dependency_a의 값(여기서는 dep_a)이 필요하죠.
from typing import Annotated
from fastapi import Depends
async def dependency_a():
dep_a = generate_dep_a()
try:
yield dep_a
finally:
dep_a.close()
async def dependency_b(dep_a: Annotated[DepA, Depends(dependency_a)]):
dep_b = generate_dep_b()
try:
yield dep_b
finally:
dep_b.close(dep_a)
async def dependency_c(dep_b: Annotated[DepB, Depends(dependency_b)]):
dep_c = generate_dep_c()
try:
yield dep_c
finally:
dep_c.close(dep_b)
같은 방식으로, yield를 쓰는 의존성과 return을 쓰는 의존성을 섞어서 서로 의존하게 만들 수도 있어요.
또 하나의 의존성이 yield를 쓰는 여러 다른 의존성을 요구하게 만들 수도 있죠.
원하는 조합은 뭐든 만들 수 있어요. FastAPI는 모든 게 올바른 순서로 실행되도록 보장합니다.
기술적 세부 사항
이것이 가능한 건 파이썬의 Context Manager 덕분이에요.
yield와 HTTPException을 쓰는 의존성
yield가 있는 의존성에서 try 블록으로 어떤 코드를 실행하고 finally 뒤에 exit code를 실행하게 할 수 있다는 걸 앞에서 봤죠.
except를 써서 발생한 예외를 잡아서 뭔가를 처리할 수도 있어요.
예를 들어 HTTPException처럼 다른 예외를 다시 raise 할 수 있습니다.
팁
이건 조금 고급 기법이에요. 대부분의 경우 정말 필요하지 않을 텐데, 애플리케이션의 나머지 코드(예: path operation 함수) 안에서 예외(
HTTPException포함)를 raise 하면 되거든요.그래도 필요할 때를 위해 있어요. 🤓
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
data = {
"plumbus": {"description": "Freshly pickled plumbus", "owner": "Morty"},
"portal-gun": {"description": "Gun to create portals", "owner": "Rick"},
}
class OwnerError(Exception):
pass
def get_username():
try:
yield "Rick"
except OwnerError as e:
raise HTTPException(status_code=400, detail=f"Owner error: {e}")
@app.get("/items/{item_id}")
def get_item(item_id: str, username: Annotated[str, Depends(get_username)]):
if item_id not in data:
raise HTTPException(status_code=404, detail="Item not found")
item = data[item_id]
if item["owner"] != username:
raise OwnerError(username)
return item
예외를 잡아서 그에 기반한 커스텀 응답을 만들고 싶다면 Custom Exception Handler를 만들어 쓰세요.
yield와 except를 쓰는 의존성
yield가 있는 의존성에서 except로 예외를 잡고, 그 예외를 다시 raise 하지 않거나(또는 새 예외를 raise 하지 않으면), FastAPI는 예외가 있었는지 알아차리지 못해요. 일반 파이썬에서와 똑같이요:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
class InternalError(Exception):
pass
def get_username():
try:
yield "Rick"
except InternalError:
print("Oops, we didn't raise again, Britney 😱")
@app.get("/items/{item_id}")
def get_item(item_id: str, username: Annotated[str, Depends(get_username)]):
if item_id == "portal-gun":
raise InternalError(
f"The portal gun is too dangerous to be owned by {username}"
)
if item_id != "plumbus":
raise HTTPException(
status_code=404, detail="Item not found, there's only a plumbus here"
)
return item_id
이 경우 클라이언트는 예상대로 HTTP 500 Internal Server Error 응답을 보게 돼요(HTTPException 등을 raise 하지 않았으니 당연하죠). 그런데 서버에는 로그가 전혀 남지 않고 무엇이 문제였는지 알 수 있는 아무 단서도 없어요. 😱
yield와 except 의존성에서는 "항상 raise" 하기
yield가 있는 의존성에서 예외를 잡았다면, 다른 HTTPException 등을 raise 하는 경우가 아니라면 원래 예외를 다시 raise 해야 해요.
raise로 같은 예외를 다시 raise 할 수 있어요:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
class InternalError(Exception):
pass
def get_username():
try:
yield "Rick"
except InternalError:
print("We don't swallow the internal error here, we raise again 😎")
raise
@app.get("/items/{item_id}")
def get_item(item_id: str, username: Annotated[str, Depends(get_username)]):
if item_id == "portal-gun":
raise InternalError(
f"The portal gun is too dangerous to be owned by {username}"
)
if item_id != "plumbus":
raise HTTPException(
status_code=404, detail="Item not found, there's only a plumbus here"
)
return item_id
이제 클라이언트는 같은 HTTP 500 Internal Server Error 응답을 받지만, 서버 로그에는 우리가 만든 커스텀 InternalError가 남아요. 😎
yield 의존성의 실행 순서
실행 순서는 대략 이 다이어그램과 같아요. 시간은 위에서 아래로 흐르고요, 각 세로줄 하나가 상호작용하거나 코드를 실행하는 한 주체입니다.
sequenceDiagram
participant client as Client
participant handler as Exception handler
participant dep as Dep with yield
participant operation as Path Operation
participant tasks as Background tasks
Note over client,operation: Can raise exceptions, including HTTPException
client ->> dep: Start request
Note over dep: Run code up to yield
opt raise Exception
dep -->> handler: Raise Exception
handler -->> client: HTTP error response
end
dep ->> operation: Run dependency, e.g. DB session
opt raise
operation -->> dep: Raise Exception (e.g. HTTPException)
opt handle
dep -->> dep: Can catch exception, raise a new HTTPException, raise other exception
end
handler -->> client: HTTP error response
end
operation ->> client: Return response to client
Note over client,operation: Response is already sent, can't change it anymore
opt Tasks
operation -->> tasks: Send background tasks
end
opt Raise other exception
tasks -->> tasks: Handle exceptions in the background task code
end
참고
클라이언트에게는 하나의 응답만 보내집니다. 오류 응답 중 하나이거나 path operation의 응답일 거예요.
그 응답 중 하나가 보내지고 나면, 다른 응답은 더 이상 보낼 수 없어요.
팁
path operation 함수의 코드에서 예외(
HTTPException포함)를 raise 하면, 그 예외는yield가 있는 의존성들로 전달됩니다. 대부분의 경우 그 같은 예외나 새 예외를yield의존성에서 다시 raise 해서 제대로 처리되도록 해야 해요.
조기 종료와 scope
보통 yield 의존성의 exit code는 응답이 클라이언트에게 보내진 뒤에 실행돼요.
하지만 path operation 함수에서 반환된 뒤 더 이상 그 의존성을 쓸 일이 없다는 걸 안다면, Depends(scope="function")을 써서 의존성을 path operation 함수가 반환된 뒤, 하지만 응답이 보내지기 전에 닫으라고 FastAPI에 알릴 수 있어요.
from typing import Annotated
from fastapi import Depends, FastAPI
app = FastAPI()
def get_username():
try:
yield "Rick"
finally:
print("Cleanup up before response is sent")
@app.get("/users/me")
def get_user_me(username: Annotated[str, Depends(get_username, scope="function")]):
return username
Depends()는 scope 파라미터를 받는데, 값은 이 둘 중 하나예요:
"function": 요청을 처리하는 path operation 함수 전에 의존성을 시작하고, path operation 함수가 끝난 뒤에는 응답이 클라이언트에게 돌아가기 전에 의존성을 종료해요. 그래서 의존성 함수는 path operation 함수를 감싸서 실행됩니다."request": 요청을 처리하는 path operation 함수 전에 의존성을 시작하고("function"일 때처럼), 응답이 클라이언트에게 보내진 뒤에 종료해요. 그래서 의존성 함수는 request와 response의 사이클을 감싸서 실행됩니다.
지정하지 않았고 의존성에 yield가 있으면, 기본적으로 "request" scope를 갖게 돼요.
하위 의존성을 위한 scope
scope="request"(기본값)인 의존성을 선언하면, 그 하위 의존성도 "request" scope여야 해요.
반면 "function" scope인 의존성은 "function" scope와 "request" scope인 의존성 모두를 가질 수 있어요.
그 이유는 어떤 의존성이든 자신의 exit code를 실행할 때 하위 의존성을 여전히 써야 할 수도 있으므로, 하위 의존성보다 먼저 exit code를 실행할 수 있어야 하기 때문이에요.
sequenceDiagram
participant client as Client
participant dep_req as Dep scope="request"
participant dep_func as Dep scope="function"
participant operation as Path Operation
client ->> dep_req: Start request
Note over dep_req: Run code up to yield
dep_req ->> dep_func: Pass dependency
Note over dep_func: Run code up to yield
dep_func ->> operation: Run path operation with dependency
operation ->> dep_func: Return from path operation
Note over dep_func: Run code after yield
Note over dep_func: ✅ Dependency closed
dep_func ->> client: Send response to client
Note over client: Response sent
Note over dep_req: Run code after yield
Note over dep_req: ✅ Dependency closed
yield, HTTPException, except와 Background Tasks를 쓰는 의존성
yield가 있는 의존성은 다양한 사용 사례를 다루고 일부 문제를 고치기 위해 시간이 지나며 진화해 왔어요.
FastAPI의 여러 버전에서 무엇이 바뀌었는지 더 자세히 보려면 고급 가이드의 Advanced Dependencies - Dependencies with yield, HTTPException, except and Background Tasks를 읽어 보세요.
Context Manager
"Context Manager"란 무엇인가
"Context Manager"는 with 문에서 사용할 수 있는 어떤 파이썬 객체든 가리키는 말이에요.
예를 들어 파일을 읽을 때 with를 쓸 수 있죠:
with open("./somefile.txt") as f:
contents = f.read()
print(contents)
바닥에서 open("./somefile.txt")는 "Context Manager"라고 불리는 객체를 만들어요.
with 블록이 끝나면, 예외가 있었더라도 파일을 닫아 주죠.
yield로 의존성을 만들면, FastAPI는 이를 위해 내부적으로 context manager를 만들고 관련 도구들과 결합해 써요.
의존성의 yield에서 context manager 사용하기
경고
이건 다소 "고급" 아이디어예요.
FastAPI를 이제 막 시작했다면 지금은 건너뛰는 게 좋을 수도 있어요.
파이썬에서는 두 메서드 __enter__()와 __exit__()를 가진 클래스를 만들어서 Context Manager를 만들 수 있어요.
의존성 함수 안에서 with나 async with 문을 사용하면, yield가 있는 FastAPI 의존성 안에서도 이를 쓸 수 있어요:
class MySuperContextManager:
def __init__(self):
self.db = DBSession()
def __enter__(self):
return self.db
def __exit__(self, exc_type, exc_value, traceback):
self.db.close()
async def get_db():
with MySuperContextManager() as db:
yield db
팁
Context Manager를 만드는 또 다른 방법은:
를 써서
yield를 단 하나 가진 함수를 데코레이팅하는 거예요.그게 바로 FastAPI가
yield의존성에 내부적으로 사용하는 방식입니다.하지만 FastAPI 의존성에 데코레이터를 쓸 필요는 없어요(그리고 쓰면 안 돼요). FastAPI가 내부적으로 알아서 해 줍니다.