🏗️ 아키텍처

의존성 주입

Dependency Injection (DI)

객체가 필요로 하는 의존성(다른 객체)을 직접 생성하지 않고 외부에서 주입받는 디자인 패턴입니다. SOLID 원칙의 D(Dependency Inversion Principle)를 실현하며, 느슨한 결합(Loose Coupling), 테스트 용이성, 유지보수성을 크게 향상시킵니다.

🔄 DI 적용 전후 비교

의존성 주입을 적용하면 코드의 결합도가 낮아지고 테스트가 쉬워집니다.

❌ DI 없이 (강한 결합)
class UserService:
  def __init__(self):
    # 직접 생성 - 강한 결합
    self.db = MySQLDatabase()
    self.email = SmtpEmailService()

# 문제점:
# - DB 교체 시 코드 수정 필요
# - 테스트 시 실제 DB 연결 필요
# - Mock 사용 불가능
✅ DI 적용 (느슨한 결합)
class UserService:
  def __init__(self,
    db: Database,
    email: EmailService):
    # 외부에서 주입받음
    self.db = db
    self.email = email

# 장점:
# - 인터페이스에 의존
# - Mock 주입으로 단위 테스트 용이

📐 핵심 원칙: IoC와 SOLID

🔃 IoC (Inversion of Control)

"Don't call us, we'll call you"

제어의 역전. 객체가 의존성을 직접 찾거나 생성하는 대신, 프레임워크/컨테이너가 주입합니다. DI는 IoC를 구현하는 대표적인 방법입니다.

📌 SOLID - D (DIP)

Dependency Inversion Principle

"고수준 모듈이 저수준 모듈에 의존하지 않는다. 둘 다 추상화(인터페이스)에 의존한다."

💉 DI 유형 비교

주의
Setter 주입
Setter Injection
Setter 메서드로 의존성을 주입. 선택적 의존성에 적합.
✅ 선택적 의존성 가능
✅ 런타임에 교체 가능
⚠️ 불완전한 객체 생성 가능
⚠️ null 체크 필요
비권장
필드 주입
Field Injection
@Autowired 등으로 필드에 직접 주입. 간결하지만 단점 많음.
✅ 코드 간결
❌ 테스트 시 리플렉션 필요
❌ 불변성 보장 불가
❌ 순환 참조 감지 어려움

🛠️ DI 프레임워크 비교

프레임워크 언어 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()으로 직접 생성하면 안 되나요? 더 간단해 보이는데요.
🏗️
시니어 개발자
그렇게 하면 테스트할 때 어떻게 하실 건가요? 단위 테스트마다 실제 데이터베이스에 연결해야 하고, 테스트 데이터 정리도 해야 해요. DI를 사용하면 Mock 객체를 주입해서 DB 없이 빠르게 테스트할 수 있습니다.
👤
주니어 개발자
아, 테스트 때문이군요. 근데 @Autowired 필드 주입이 제일 간단하지 않나요?
🏗️
시니어 개발자
필드 주입은 편해 보이지만, 테스트 시 리플렉션이 필요하고 순환 참조를 감지하기 어려워요. 생성자 주입을 권장합니다. 의존성이 명시적이고, final로 불변성도 보장되고, Spring 공식 문서에서도 이 방식을 권장해요.
👤
주니어 개발자
생성자에 의존성이 10개나 되면요? 너무 길어지는데...
🏗️
시니어 개발자
그건 DI 문제가 아니라 클래스가 너무 많은 책임을 가진다는 신호예요! 단일 책임 원칙(SRP)에 따라 클래스를 분리하세요. 생성자가 복잡해지면 "이 클래스가 너무 많은 일을 하는 건 아닐까?" 자문해보세요.

⚠️ 주의사항

🔄

순환 참조 (Circular Dependency)

A가 B를 의존하고, B가 A를 의존하면 순환 참조 발생. 생성자 주입 시 컴파일/런타임 에러로 조기 발견 가능. @Lazy 사용보다는 설계를 재검토하세요.

🔧

과도한 추상화 (Over-Engineering)

모든 클래스에 인터페이스를 만들 필요 없음. 구현체가 하나뿐이고 교체 가능성이 없다면 직접 의존해도 됩니다. YAGNI(You Aren't Gonna Need It) 원칙을 기억하세요.

🎭

Mock 남용

모든 것을 Mock하면 테스트가 구현에 종속됨. 외부 시스템(DB, API)만 Mock하고, 내부 로직은 실제 객체로 테스트하세요. "Mock은 경계에서만" 원칙.

📦

Service Locator 안티패턴

Container.get(UserService)처럼 런타임에 의존성을 가져오면 의존성이 숨겨짐. 컴파일 타임에 의존성이 명시되는 생성자 주입을 사용하세요.

📋 실제 적용 사례

테스트 커버리지 90% 달성
성공

상황: 레거시 코드가 직접 DB 연결을 생성하여 단위 테스트가 불가능했습니다. 테스트하려면 실제 DB가 필요했고, 테스트 실행 시간이 30분 이상 걸렸습니다.

적용: Repository 패턴 + 생성자 주입으로 리팩토링. 인터페이스를 정의하고 Mock 객체 주입이 가능하도록 변경했습니다.

결과: 단위 테스트 실행 시간 30초로 단축. 테스트 커버리지 20% → 90%. CI/CD 파이프라인에서 매 PR마다 테스트 자동 실행.

핵심:

DI는 테스트 가능한 코드의 기반. Mock 주입으로 외부 의존성 없이 빠른 피드백 루프 구축.

결제 시스템 교체 - 3일 완료
성공

상황: 기존 결제 API(PaymentGatewayA)를 새 결제 API(PaymentGatewayB)로 교체해야 했습니다. 서비스 중단 없이 점진적 마이그레이션이 필요했습니다.

적용: PaymentService 인터페이스에 의존하도록 설계되어 있어, 새 구현체만 추가. Feature Flag로 A/B 전환 가능하게 구성.

결과: 비즈니스 로직 변경 없이 DI 설정만 변경. 1% → 10% → 100% 점진적 전환. 롤백도 설정 변경만으로 즉시 가능.

핵심:

인터페이스에 의존하면 구현체 교체가 "설정 변경"으로 가능. 느슨한 결합의 실질적 이점.

순환 참조로 애플리케이션 시작 실패
실패

상황: OrderServiceInventoryService를, InventoryServiceOrderService를 의존. 필드 주입(@Autowired) 사용으로 컴파일 시 감지 안 됨.

문제점: 프로덕션 배포 시 Spring Context 초기화 실패로 서비스 다운. @Lazy로 임시 해결했으나 근본 원인 미해결.

해결: 공통 로직을 별도 서비스로 분리하여 순환 의존 제거. 생성자 주입으로 변경하여 컴파일 타임에 순환 참조 감지.

교훈:

생성자 주입 사용 시 순환 참조가 컴파일 에러로 조기 발견됨. @Lazy는 해결책이 아니라 증상 숨기기.

🧠 이해도 퀴즈

Q. 의존성 주입(DI)의 주요 장점이 아닌 것은?
A. 단위 테스트 시 Mock 객체를 주입할 수 있다
B. 런타임 성능이 향상된다
C. 구현체 교체가 용이하다
D. 클래스 간 결합도가 낮아진다
정답: B

의존성 주입은 런타임 성능 향상과 무관합니다. 오히려 IoC 컨테이너 초기화, 리플렉션 사용 등으로 약간의 오버헤드가 발생할 수 있습니다.

DI의 실제 장점은:
테스트 용이성: Mock 주입으로 격리된 단위 테스트 가능
느슨한 결합: 인터페이스에 의존하여 구현체 교체 용이
유지보수성: 의존성이 명시적으로 드러남

🔗 연관 용어

📚 학습 자료