개요
- IoC, DI, DIP 알아보기
- DI Container 구현하고 Framework 이해하기
^ 위 글에 이어 DI Container를 직접 구현해서 여러 객체 간의 의존성을 관리하는 것에 대해 더 깊이 이해해보고자 한다. 요즘 가장 많이 쓰는 프레임워크가 FastAPI이기에, 글도 이를 중점적으로 작성해 나갈 것이다.
1. Framework가 여러 의존성을 관리하는 방법 : Depends
FastAPI의 의존성 주입은 함수 시그니처를 통한 선언적 의존성 관리와 Depends()가 핵심이다. Depends()는 한마디로 쉽게 "이 함수를 실행해서 결과를 여기에 넣어줘"라고 프레임워크에게 알려주는 도구다.
- 이 Dependency Injection의 핵심 기능인 Depends()를 사용하지 않고 의존 관계를 구현했을 때의 현상은 이 글에 작성이 되어있으니 참고
1.1 Depends()의 동작으로 이해해보는 의존성 관리
from fastapi import Depends, FastAPI
# 의존성 함수
def get_database():
db = Database()
try:
yield db
finally:
db.close()
# API Endpoint에서 사용
@app.get("/items/")
def get_items(db = Depends(get_database)):
return db.query("SELECT * FROM items")
위 코드를 실행하면 다음 과정이 실행된다.
- API 실행 전에 FastAPI가 Depends()를 스캔한다.
- get_database 함수를 의존성으로 등록한다.
- 내부적으로 get_database 함수를 호출하고 Database() 객체를 반환해서 엔드포인트에 주입한다.
# FastAPI가 자동으로 처리:
get_items(db=db) # yield된 db 객체를 파라미터로 전달
마지막으로 API 응답 생성 후, 프레임워크가 db.close() 등의 정리 작업을 실행하고, 200 OK와 같은 응답을 반환한다.
2.2 Depends()만 사용할 때의 문제
이러한 의존성 주입은 대규모 애플리케이션에서 몇 가지 한계가 있다.
⚫️ 무거운 객체의 반복 생성
def get_aws_client(settings = Depends(get_settings)):
# boto3 클라이언트는 생성
return boto3.client('s3', region_name=settings.aws_region)
@app.get("/buckets")
def list_buckets(aws = Depends(get_aws_client)):
return aws.list_buckets()
@app.post("/buckets")
def create_bucket(aws = Depends(get_aws_client)):
return aws.create_bucket(...)
- get_aws_client 메소드의 활용, 생명주기 관리는 Depends를 통해 프레임워크가 도맡아서 할 수 있지만
- 요청마다 관련 클라이언트 새로 생성(boto3 클라이언트는 생성 비용이 큼)
- 연결 풀을 효과적으로 사용할 수 없음
- 초기화 비용이 큰 객체일 수록 성능 저하
* 참고로 Depends()의 캐싱은 "같은 요청 내"에서만 동작한다. 다음 요청이 오면 다시 생성된다.
FastAPI의 use_cache(request-scoped cache): 같은 요청 안에서 dependenc가 여러 번 선언되면, 캐싱된 내용을 사용한다.
⚫️ API Parameter 복잡도
@app.post("/resources")
def create_resource(
aws_client = Depends(get_aws_client),
db = Depends(get_db),
redis = Depends(get_redis),
slack = Depends(get_slack),
prometheus = Depends(get_prometheus),
katalog = Depends(get_katalog),
# ...
):
pass
- 가독성 저하
- 테스트 시 모든 파라미터를 Mock으로 준비해야 함
- 새로운 의존성 추가 시 모든 엔드포인트 수정
2. DI Container의 등장 배경 : 싱글톤 패턴
2.1 싱글톤 패턴 등장 배경
Depends()와 같은 프레임워크가 제공하는 DI를 사용하면 많은 이점이 있지만, 애플리케이션 규모가 커질 수록 불편함 또한 생긴다. 클라이언트가 요청을 보낼 때마다 새로운 객체를 만들어서 반환한다. 가령 고객의 트래픽이 초당 100만큼 발생하면 초당 100개의 객체가 생성 - 소멸 과정을 겪는다.
⚫️ 무거운 초기화를 매 요청마다 수행하면
- 응답시간 증가
- 리소스 낭비: CPU, 메모리, 네트워크 연결 반복 생성
- 외부 서비스 부하: AWS API, Redis 서버에 불필요한 연결 요청
⚫️ 문제 사례
- AWS Client Boto3 세션 생성, credential 로딩, region별 endpoint 연결
- Redis TCP 연결 생성, 인증, connection pool 초기화
- DB 커넥션 풀 생성, 스키마 메타데이터 로딩
2.2 싱글톤 패턴으로 해결
이를 해결하기 위해서 객체를 하나만 만들어놓고, 클라이언트가 동일한 객체를 요청하면 이 객체를 반환해서, 모든 클라이언트(요청)가 하나의 객체를 공유하게끔한다. 이것이 바로 싱글톤 패턴이다.
# 1. 앱 시작 시 한 번만 생성 (lifespan)
@asynccontextmanager
async def lifespan(app: FastAPI):
core = KrpCore(settings) # 딱 한 번만 실행
app.state.core = core # 앱 전역에 저장
yield
# 2. Depends로 접근 (매번 실행되지만 같은 객체 반환)
def with_krp_core(request: Request) -> KrpCore:
return request.app.state.core # 저장된 싱글톤 반환
# 3. 엔드포인트에서 사용
@app.get("/s3")
def list_s3(core: KrpCore = Depends(with_krp_core)):
...
주로 클라우드 서비스를 셀프 서비스하는 프로젝트를 운영/개발하는 우리 팀에서는 AWS Client, Redis, GCP Client와 같이, 여러 객체에서 반복 사용되며 무거운 초기화를 가진 클라이언트들에 대해 앱 시작 시 초기화한다.
3. Framework가 제공하는 DI Cotainer
3.1 FastAPI
FastAPI는 DI Container 객체를 노출하진 않지만, Depends + 내부 dependency resolver가 사실상 DI Container의 역할을 한다. 이는 FastAPI Dependency Injection 문서에 잘 나와있는데, 핵심을 정리하면 다음과 같다.
- 별도의 컨테이너 객체를 직접 다루지 않더라도 대부분의 웹 애플리케이션에서 충분히 쓸 수 있는 의존성 주입 기능을 제공한다.
- 함수 시그니처에 의존성을 선언하면
- 프레임워크가 의존성 그래프를 만들고
- 요청 단위로 생성/캐싱/정리까지 자동으로 처리
3.2 Spring IoC Container & Bean
Spring DI Cotainer는 FastAPI와 다르게 Spring IoC Container가 직접 Bean 객체를 관리하며 IoC를 제공한다. 이또한 Spring IoC & Bean 문서에 잘 나와있다!
- `org.springframework.context.ApplicationContext` 인터페이스가 Spring IoC 컨테이너를 대표하며, 컨테이너는 애플리케이션에서 사용할 bean들을 인스턴스화, 설정, 조립하는 책임을 가진다. (문서 바로가기)
- IoC 컨테이너는 XML, 애너테이션, Java 코드 같은 configuration metadata를 읽어서 “어떤 객체를 생성·구성·연결할지”에 대한 지시를 받고, 그에 따라 빈과 빈 사이의 의존성을 설정한다. (문서 바로가기)

@Configuration
class AppConfig {
@Bean
AwsClient awsClient(AppSettings settings) {
return new AwsClient(settings.getRegion());
}
@Bean
UserService userService(AwsClient awsClient) {
return new UserService(awsClient);
}
}
public class Main {
public static void main(String[] args) {
ApplicationContext ctx =
new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = ctx.getBean(UserService.class);
userService.process();
}
}
-> 이건 커스텀 컨테이너를 설정했다기 보다 Spring이 제공하는 DI 컨테이너(ApplicationContext)에 빈 설정을 등록해서 쓰는 것
4. Custom DI Container
앞에서 살펴본 것처럼, 무거운 클라이언트(Boto3, Redis, DB 커넥션 풀 등)를 요청마다 새로 만드는 문제는 FastAPI의 `Depends`만으로 해결하기 어렵다. 그래서 lifespan과 `app.state`를 이용해 애플리케이션 시작 시점에 한 번만 생성하는 싱글톤 패턴을 도입해 성능 문제는 어느 정도 해결할 수 있었다.
하지만 애플리케이션 규모가 더 커지면, 단순히 “전역 싱글톤 몇 개”만 두는 방식으로는 다음과 같은 설계상의 문제가 남는다.
- 모든 의존성을 직접 주입하면 API 엔드포인트 파라미터 목록이 길어지고, 핵심 도메인 로직이 의존성 목록에 묻힌다.
- 서비스 간 의존 관계가 엔드포인트/의존성 함수 곳곳에 흩어져 있어서, 시스템 전체 의존성 그래프 파악이 어렵다.
- 도메인 서비스가 FastAPI의 `Depends`에 직접 의존하게 되어, HTTP 이외의 진입점(배치, Celery, CLI, 테스트 등)에서 재사용하기가 불편하다.
4.1 프레임워크 DI와 전역 싱글톤만으로는 부족한 의존성 관리를 커스텀화해서 해결!
이러한 설계상의 문제를 해결하기 위해, 우리 팀에서는 애플리케이션 전용 DI 컨테이너인 `KrpCore`를 두고 있다.
⚫️ Custom DI Container 정의하기
- 애플리케이션 공통 클라이언트 초기화
class KrpCore:
"""KRP 애플리케이션의 의존성 컨테이너"""
def __init__(self, settings: KrpSettings):
self._settings = settings
self._aws_client_map = load_aws_client_map(settings)
self._gcp_client_map = load_gcp_client_map(settings)
self._prometheus_client = PrometheusClient(
api_url=settings.prometheus_api_url,
api_token=settings.prometheus_api_token,
)
@property
def aws_client_map(self) -> AwsEnvironmentRegionClientMap: ...
@property
def sessionmaker(self): ...
⚫️ 공용 컨테이너는 앱 시작 시 한 번만 만들고, 이후 Depends로 사용
app = FastAPI(lifespan=lifespan)
@asynccontextmanager
async def lifespan(app: FastAPI):
core = KrpCore(settings) # 앱 시작 시 한 번만 생성
core.load_services(celery) # 서비스들도 한 번만 초기화
app.state.core = core # 전역 싱글톤처럼 보관
yield # 앱 종료 시 정리 로직 실행 가능
def get_core(request: Request) -> KrpCore:
return request.app.state.core # 항상 같은 core 인스턴스 반환
@app.post("/s3")
def create_s3(
payload: s3Create,
core: KrpCore = Depends(get_core),
):
service = core.services["s3"]
...
4.2 프레임워크 제공 DI Container 사용 VS 커스텀 장단점
좀 길지만 중요한 내용..!
| 관점 | 프레임워크 제공 DI | 커스텀 DI Container |
| 학습 비용 / 생산성 | 이미 프레임워크와 깊게 통합되어 있어서 팀원이 문서만 보고 바로 쓰기 쉽다. | 컨테이너 개념, 등록/해결 규칙, 라이프사이클 정책 등 팀이 새로 익혀야 할 추상화가 하나 더 생긴다. 초기 설계와 가이드 정리가 필요하다. |
| 성능 / 라이프사이클 | 기본이 요청 스코프(request-scoped)라 요청마다 새 인스턴스를 만들기 쉽고, 싱글톤/글로벌 스코프는 별도 패턴(lifespan, app.state 등)을 직접 도입해야 한다. | Boto3, Redis, DB 커넥션 풀 등 무거운 객체를 컨테이너 내부에서 명시적으로 singleton처럼 관리해 여러 요청에서 재사용하기 쉽다. 라이프사이클 정책을 컨테이너 기준으로 설계 가능. |
| 엔드포인트 시그니처 / 가독성 | Depends를 많이 쓰면 엔드포인트 파라미터가 길어지고, 도메인 로직보다 “의존성 목록”이 먼저 보일 수 있다. | 엔드포인트는 core: KrpCore나 UserService 같은 “굵은 서비스 단위”만 받도록 정리할 수 있어서, 시그니처가 더 단순해지고 도메인에 집중하기 쉽다. |
| 의존 관계 가시성 | 의존성이 각 엔드포인트/의존 함수에 흩어져 있어서, 전체 시스템의 의존성 그래프를 한눈에 보기 어렵다. | 컨테이너 초기화/등록 코드(KrpCore, load_services 등)에 의존 관계가 모이므로, “어떤 서비스가 무엇에 의존하는지”를 한 곳에서 파악하기 좋다. |
| 테스트 / 의존성 교체 | FastAPI의 dependency_overrides 등으로 일부 의존성을 쉽게 바꿀 수 있지만, 깊고 복잡한 서비스 그래프를 통째로 갈아끼우기에는 다소 불편할 수 있다. | 컨테이너에 등록된 구현만 바꾸면(Fake 서비스, In-memory Repo 등) 테스트 환경 전체 의존성을 한 번에 바꿀 수 있다. 특정 토큰 이름(예: "s3", "redis")만 기준으로 교체 가능. |
| 프레임워크 독립성 / 재사용성 | 서비스/로직이 프레임워크의 DI 메커니즘(Depends, 애너테이션 등)에 더 밀접하게 묶이기 쉽다. HTTP 이외 진입점에서 재사용하려면 별도 어댑터가 필요할 때가 많다. | 컨테이너를 중심으로 의존성을 주입하면, 서비스는 “컨테이너에서 주어지는 것”만 의존한다. 같은 서비스 코드를 Celery, 배치, CLI, 테스트 등 다양한 환경에서 재사용하기 쉽다. |
| 복잡도 / 운영 비용 | “프레임워크가 해주는 대로” 쓰면 되기 때문에 구조가 단순하고 디버깅 포인트도 비교적 적다. 대신 프로젝트가 커질수록 점진적으로 난잡해질 수 있다. | 설계가 잘 되면 구조가 정돈되지만, 컨테이너가 또 하나의 인프라 코드가 되어 관리 포인트가 늘어난다. 설계가 어설프면 “의존성이 어디서 만들어지는지 더 헷갈리는” 역효과도 가능. |
'Dev > Backend' 카테고리의 다른 글
| [BackEnd] 비동기 테스크 큐와 DI Container (feat. Celery) (0) | 2025.11.16 |
|---|---|
| [BackEnd] Framework 이해하기 - DI Container로 여러 의존성 관리 (0) | 2025.11.03 |
| [BackEnd] IoC & DIP & DI (0) | 2025.10.20 |
| [BackEnd] 커뮤니티 게시물 목록 조회 API 쿼리를 QueryDsl로 구현해보기 (0) | 2024.10.23 |
| [BackEnd] API의 멱등성을 고려하여 개발하기 (0) | 2024.04.26 |