의존성 주입
Dependency Injection (DI)
객체가 필요로 하는 의존성(다른 객체)을 직접 생성하지 않고 외부에서 주입받는 디자인 패턴입니다. SOLID 원칙의 D(Dependency Inversion Principle)를 실현하며, 느슨한 결합(Loose Coupling), 테스트 용이성, 유지보수성을 크게 향상시킵니다.
Dependency Injection (DI)
객체가 필요로 하는 의존성(다른 객체)을 직접 생성하지 않고 외부에서 주입받는 디자인 패턴입니다. SOLID 원칙의 D(Dependency Inversion Principle)를 실현하며, 느슨한 결합(Loose Coupling), 테스트 용이성, 유지보수성을 크게 향상시킵니다.
의존성 주입을 적용하면 코드의 결합도가 낮아지고 테스트가 쉬워집니다.
"Don't call us, we'll call you"
제어의 역전. 객체가 의존성을 직접 찾거나 생성하는 대신, 프레임워크/컨테이너가 주입합니다. DI는 IoC를 구현하는 대표적인 방법입니다.
Dependency Inversion Principle
"고수준 모듈이 저수준 모듈에 의존하지 않는다. 둘 다 추상화(인터페이스)에 의존한다."
| 프레임워크 | 언어 | DI 방식 | 주요 어노테이션/데코레이터 |
|---|---|---|---|
| Spring | Java | 자동 주입 (IoC 컨테이너) | @Autowired, @Component, @Service |
| NestJS | TypeScript | 생성자 주입 (메타데이터) | @Injectable(), @Inject() |
| FastAPI | Python | 함수 매개변수 (Depends) | Depends(), Type Hints |
| ASP.NET Core | C# | 내장 DI 컨테이너 | AddScoped, AddSingleton, AddTransient |
| Wire | Go | 컴파일 타임 코드 생성 | wire.Build(), Provider Functions |
# Python: 수동 의존성 주입 패턴 from abc import ABC, abstractmethod from typing import Protocol # 1. 인터페이스(Protocol) 정의 class UserRepository(Protocol): def find_by_id(self, user_id: int) -> dict | None: ... def save(self, user: dict) -> None: ... class EmailService(Protocol): def send(self, to: str, subject: str, body: str) -> bool: ... # 2. 구현체 class PostgresUserRepository: def __init__(self, connection_string: str): self.conn = connection_string # 실제로는 DB 연결 def find_by_id(self, user_id: int) -> dict | None: return {"id": user_id, "name": "John"} def save(self, user: dict) -> None: print(f"Saving to Postgres: {user}") class SmtpEmailService: def send(self, to: str, subject: str, body: str) -> bool: print(f"Sending email to {to}") return True # 3. 서비스 - 생성자 주입 (권장) class UserService: def __init__( self, user_repo: UserRepository, # 인터페이스에 의존 email_service: EmailService ): self._user_repo = user_repo # 불변성을 위해 _prefix self._email_service = email_service def register_user(self, name: str, email: str) -> dict: user = {"name": name, "email": email} self._user_repo.save(user) self._email_service.send(email, "환영합니다!", "가입 감사합니다.") return user # 4. 의존성 조립 (Composition Root) def create_user_service() -> UserService: # 프로덕션 의존성 repo = PostgresUserRepository("postgres://localhost/mydb") email = SmtpEmailService() return UserService(repo, email) # 5. 테스트 - Mock 주입 class MockUserRepository: def find_by_id(self, user_id): return {"id": user_id} def save(self, user): pass class MockEmailService: def __init__(self): self.sent = [] def send(self, to, subject, body): self.sent.append((to, subject, body)) return True def test_register_user(): # Mock 주입으로 격리된 단위 테스트 mock_email = MockEmailService() service = UserService(MockUserRepository(), mock_email) user = service.register_user("테스트", "test@test.com") assert user["name"] == "테스트" assert len(mock_email.sent) == 1 # 이메일 전송 검증
# FastAPI: Depends를 활용한 의존성 주입 from fastapi import FastAPI, Depends, HTTPException from sqlalchemy.orm import Session from typing import Annotated app = FastAPI() # 1. 의존성 함수들 def get_db(): """데이터베이스 세션 의존성""" db = SessionLocal() try: yield db finally: db.close() def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) ): """인증된 사용자 의존성""" user = decode_token(token, db) if not user: raise HTTPException(status_code=401) return user # 2. 서비스 클래스 정의 class UserService: def __init__(self, db: Session): self.db = db def get_user(self, user_id: int): return self.db.query(User).filter(User.id == user_id).first() def create_user(self, user_data: UserCreate): user = User(**user_data.dict()) self.db.add(user) self.db.commit() return user # 3. 서비스 의존성 함수 def get_user_service(db: Session = Depends(get_db)) -> UserService: return UserService(db) # Type Alias로 가독성 향상 UserServiceDep = Annotated[UserService, Depends(get_user_service)] CurrentUserDep = Annotated[User, Depends(get_current_user)] # 4. 엔드포인트에서 의존성 주입 @app.get("/users/{user_id}") def read_user( user_id: int, service: UserServiceDep, # 자동 주입 current_user: CurrentUserDep # 인증 체크 + 주입 ): user = service.get_user(user_id) if not user: raise HTTPException(status_code=404) return user @app.post("/users") def create_user( user_data: UserCreate, service: UserServiceDep ): return service.create_user(user_data) # 5. 테스트에서 의존성 오버라이드 from fastapi.testclient import TestClient def override_get_db(): return TestingSessionLocal() def override_user_service(): return MockUserService() app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[get_user_service] = override_user_service client = TestClient(app) response = client.get("/users/1") assert response.status_code == 200
// NestJS: 데코레이터 기반 의존성 주입 import { Injectable, Module, Controller, Get, Post, Body } from '@nestjs/common'; // 1. 인터페이스 정의 interface IUserRepository { findById(id: number): Promise<User | null>; save(user: User): Promise<User>; } interface IEmailService { send(to: string, subject: string, body: string): Promise<boolean>; } // 2. Repository 구현체 @Injectable() class TypeOrmUserRepository implements IUserRepository { constructor( @InjectRepository(User) private userRepo: Repository<User> ) {} async findById(id: number): Promise<User | null> { return this.userRepo.findOne({ where: { id } }); } async save(user: User): Promise<User> { return this.userRepo.save(user); } } // 3. Service - 생성자 주입 @Injectable() class UserService { constructor( // 토큰 기반 주입으로 인터페이스 의존 @Inject('USER_REPOSITORY') private readonly userRepo: IUserRepository, @Inject('EMAIL_SERVICE') private readonly emailService: IEmailService ) {} async registerUser(name: string, email: string): Promise<User> { const user = new User(name, email); await this.userRepo.save(user); await this.emailService.send(email, '환영합니다!', '가입 감사합니다.'); return user; } } // 4. Controller @Controller('users') class UserController { constructor(private readonly userService: UserService) {} @Post() async create(@Body() dto: CreateUserDto) { return this.userService.registerUser(dto.name, dto.email); } } // 5. Module - 의존성 등록 @Module({ providers: [ UserService, { provide: 'USER_REPOSITORY', useClass: TypeOrmUserRepository, }, { provide: 'EMAIL_SERVICE', useClass: SmtpEmailService, }, ], controllers: [UserController], }) class UserModule {} // 6. 테스트 - Mock Provider const module = await Test.createTestingModule({ providers: [ UserService, { provide: 'USER_REPOSITORY', useValue: { findById: jest.fn(), save: jest.fn() }, }, { provide: 'EMAIL_SERVICE', useValue: { send: jest.fn().mockResolvedValue(true) }, }, ], }).compile();
new DatabaseConnection()으로 직접 생성하면 안 되나요? 더 간단해 보이는데요.
A가 B를 의존하고, B가 A를 의존하면 순환 참조 발생. 생성자 주입 시 컴파일/런타임 에러로 조기 발견 가능. @Lazy 사용보다는 설계를 재검토하세요.
모든 클래스에 인터페이스를 만들 필요 없음. 구현체가 하나뿐이고 교체 가능성이 없다면 직접 의존해도 됩니다. YAGNI(You Aren't Gonna Need It) 원칙을 기억하세요.
모든 것을 Mock하면 테스트가 구현에 종속됨. 외부 시스템(DB, API)만 Mock하고, 내부 로직은 실제 객체로 테스트하세요. "Mock은 경계에서만" 원칙.
Container.get(UserService)처럼 런타임에 의존성을 가져오면 의존성이 숨겨짐. 컴파일 타임에 의존성이 명시되는 생성자 주입을 사용하세요.
상황: 레거시 코드가 직접 DB 연결을 생성하여 단위 테스트가 불가능했습니다. 테스트하려면 실제 DB가 필요했고, 테스트 실행 시간이 30분 이상 걸렸습니다.
적용: Repository 패턴 + 생성자 주입으로 리팩토링. 인터페이스를 정의하고 Mock 객체 주입이 가능하도록 변경했습니다.
결과: 단위 테스트 실행 시간 30초로 단축. 테스트 커버리지 20% → 90%. CI/CD 파이프라인에서 매 PR마다 테스트 자동 실행.
DI는 테스트 가능한 코드의 기반. Mock 주입으로 외부 의존성 없이 빠른 피드백 루프 구축.
상황: 기존 결제 API(PaymentGatewayA)를 새 결제 API(PaymentGatewayB)로 교체해야 했습니다. 서비스 중단 없이 점진적 마이그레이션이 필요했습니다.
적용: PaymentService 인터페이스에 의존하도록 설계되어 있어, 새 구현체만 추가. Feature Flag로 A/B 전환 가능하게 구성.
결과: 비즈니스 로직 변경 없이 DI 설정만 변경. 1% → 10% → 100% 점진적 전환. 롤백도 설정 변경만으로 즉시 가능.
인터페이스에 의존하면 구현체 교체가 "설정 변경"으로 가능. 느슨한 결합의 실질적 이점.
상황: OrderService가 InventoryService를, InventoryService가 OrderService를 의존. 필드 주입(@Autowired) 사용으로 컴파일 시 감지 안 됨.
문제점: 프로덕션 배포 시 Spring Context 초기화 실패로 서비스 다운. @Lazy로 임시 해결했으나 근본 원인 미해결.
해결: 공통 로직을 별도 서비스로 분리하여 순환 의존 제거. 생성자 주입으로 변경하여 컴파일 타임에 순환 참조 감지.
생성자 주입 사용 시 순환 참조가 컴파일 에러로 조기 발견됨. @Lazy는 해결책이 아니라 증상 숨기기.