DI(의존성 주입) 컨테이너 상세 설명
1. 먼저 “의존성”이란?
어떤 클래스가 동작하려고 다른 클래스를 필요로 하면, 그 클래스가 의존성입니다.
csharp
public class ApprovalService
{
private readonly CountryRepository _repo;
public ApprovalService()
{
_repo = new CountryRepository(); // 직접 생성
}
}
ApprovalService는 CountryRepository에 의존합니다. 위처럼 안에서 직접 new 하면 문제가 생깁니다.
| 문제 | 설명 |
|---|---|
| 강한 결합 | CountryRepository를 바꾸려면 ApprovalService도 수정해야 함 |
| 테스트 어려움 | 가짜(Mock) 객체로 바꿔 끼울 수 없음 |
| 생성 복잡도 | CountryRepository가 또 다른 의존성(DB 연결 등)을 요구하면 new 체인이 길어짐 |
| 수명 관리 불가 | 공유할지, 매번 만들지 결정할 곳이 없음 |
2. 의존성 주입(DI)이란?
필요한 객체를 내가 만들지 않고, 밖에서 넣어 주는 방식입니다.
csharp
public class ApprovalService
{
private readonly CountryRepository _repo;
public ApprovalService(CountryRepository repo) // 생성자로 "받음"
{
_repo = repo;
}
}
ApprovalService는 “누가 만들었는지” 모르고, 그냥 받아서 씁니다. 이를 제어의 역전(IoC, Inversion of Control) 이라고도 합니다. 객체 생성의 제어권이 클래스에서 외부로 넘어갔기 때문입니다.
3. DI 컨테이너란?
위처럼 “밖에서 넣어 주는” 일을 자동으로 해 주는 장치입니다. ASP.NET Core에는 기본 내장되어 있고, builder.Services가 바로 그 컨테이너입니다.
컨테이너가 하는 일은 세 가지입니다.
- 등록: “이 타입은 이렇게 만들어라” (우리가
AddScoped등으로 알려 줌) - 생성: 필요할 때 생성자를 분석해 의존성을 재귀적으로 만들어 냄
- 수명 관리: 언제 만들고 언제 버릴지 관리 (Dispose까지 자동 호출)
4. 이 프로젝트에서의 동작 흐름
등록:
csharp
builder.Services.AddScoped<CountryRepository>();
builder.Services.AddScoped<ApprovalService>();
builder.Services.AddSingleton<IsoCatalog>();
컨트롤러(가정):
csharp
public class CountryController : ControllerBase
{
private readonly ApprovalService _approval;
public CountryController(ApprovalService approval) // 컨트롤러는 이것만 요청
{
_approval = approval;
}
}
요청이 들어오면 컨테이너가 이렇게 처리합니다.
요청 도착
└─ CountryController 필요
└─ 생성자에 ApprovalService 필요
└─ 생성자에 CountryRepository 필요 → 생성
└─ (필요하면) IsoCatalog → 이미 있는 싱글톤 재사용
← ApprovalService 생성 완료
← CountryController 생성 완료
└─ 요청 처리 후, 요청이 끝나면 Scoped 객체들 Dispose
우리는 new를 한 번도 쓰지 않았는데 연결이 끝까지 이어집니다. 의존성이 몇 단계로 깊어져도 컨테이너가 알아서 풀어 줍니다.
5. 수명(Lifetime) 다시 정리
| 수명 | 생성 시점 | 소멸 시점 | 예 |
|---|---|---|---|
| Singleton | 처음 요청될 때 1번 | 앱 종료 | IsoCatalog |
| Scoped | HTTP 요청마다 1번 | 요청 종료 | CountryRepository |
| Transient | 주입받을 때마다 | 스코프 종료 시 Dispose | 가벼운 유틸 |
같은 요청 안에서 ApprovalService와 CountryController가 둘 다 CountryRepository를 받으면 같은 인스턴스를 공유합니다 (Scoped이므로). 이것이 같은 DB 트랜잭션/컨텍스트를 요청 안에서 공유하게 해 주는 원리입니다.
6. 인터페이스와 함께 쓰는 것이 일반적
이 프로젝트는 구체 클래스를 직접 등록했지만, 보통은 인터페이스로 등록합니다.
csharp
builder.Services.AddScoped<ICountryRepository, CountryRepository>();
csharp
public ApprovalService(ICountryRepository repo) { ... }
이러면 구현을 바꿔도(예: SQL Server → PostgreSQL) 등록 한 줄만 바꾸면 되고, 테스트에서는 가짜 구현을 끼울 수 있습니다.
csharp
// 테스트 예
var fake = new FakeCountryRepository();
var service = new ApprovalService(fake); // 컨테이너 없이도 주입 가능
7. 주입 방법
| 방법 | 예 |
|---|---|
| 생성자 주입 (가장 일반적) | public Foo(Bar bar) |
| 메서드 주입 | 컨트롤러 액션에 [FromServices] Bar bar |
미들웨어 Invoke 파라미터 | Invoke(HttpContext ctx, Bar bar) |
| Minimal API 파라미터 | app.MapGet("/", (Bar bar) => ...) |
8. 자주 하는 실수
1) 등록 안 하고 주입 요청
InvalidOperationException: Unable to resolve service for type 'CountryRepository'
AddXxx를 빠뜨리면 나는 가장 흔한 오류입니다.
2) 수명 불일치 (Captive Dependency)
Singleton이 Scoped를 주입받으면, Scoped 객체가 요청이 끝나도 살아남아 오래된 DB 연결을 쥐게 됩니다. 개발 환경에서는 예외로 알려 줍니다. 규칙은 “긴 수명이 짧은 수명에 의존하지 않기”입니다.
3) 순환 의존A가 B를, B가 A를 생성자에서 요구하면 컨테이너가 만들 수 없습니다. 설계를 나눠야 합니다.
4) 서비스 로케이터 남용IServiceProvider에서 직접 GetService<T>()로 꺼내 쓰면 의존성이 숨겨져 DI의 장점이 사라집니다. 생성자 주입을 우선하세요.
9. 왜 쓰나, 한 줄 요약
| 장점 | 설명 |
|---|---|
| 느슨한 결합 | 구현 교체가 쉬움 |
| 테스트 용이 | Mock/Fake 주입 가능 |
| 수명 일괄 관리 | 생성·공유·해제를 컨테이너가 담당 |
| 코드 단순화 | new 체인 제거, 설정이 한곳(Program.cs)에 모임 |
정리하면 “클래스는 필요한 것을 선언만 하고, 만들고 연결하는 일은 builder.Services가 한다” 가 DI 컨테이너의 핵심입니다.