IT 지식 목록
IT 지식

의존성 주입(Dependency Injection)으로 견고하고 테스트하기 쉬운 코드 만들기

의존성 주입(Dependency Injection)의 개념, 핵심 원리, Python/JavaScript 코드 예제, 실무 적용 사례 및 흔한 실수와 해결법을 통해 견고한 소프트웨어 개발 방법을 제시합니다.

00
의존성 주입(Dependency Injection)으로 견고하고 테스트하기 쉬운 코드 만들기

소프트웨어 개발에서 견고하고 유지보수하기 쉬운 시스템을 구축하는 것은 매우 중요합니다. 많은 개발자들이 비즈니스 로직 구현에 집중하다가 코드의 결합도(coupling)가 높아지고 테스트가 어려워지는 문제에 직면하곤 합니다. 이런 문제를 해결하기 위한 핵심적인 개념 중 하나가 바로 '의존성 주입(Dependency Injection, DI)'입니다.

이 글에서는 의존성 주입이 무엇인지, 왜 필요한지, 그리고 실제 코드에 어떻게 적용하여 더 나은 소프트웨어를 만들 수 있는지 깊이 있게 다뤄보겠습니다.

개념 소개: 의존성 주입이란?

정의

의존성 주입(DI)은 객체가 자신이 필요로 하는 다른 객체(의존성)를 직접 생성하거나 찾는 대신, 외부에서 주입받는(넘겨받는) 소프트웨어 디자인 패턴입니다. 이는 '제어의 역전(Inversion of Control, IoC)' 원칙의 한 형태로, 객체 생성 및 관리에 대한 제어권이 객체 자신으로부터 외부 컨테이너(혹은 프레임워크)로 넘어가는 것을 의미합니다.

탄생 배경

전통적인 객체지향 프로그래밍에서는 한 객체가 다른 객체의 기능을 사용하기 위해 그 객체를 직접 생성하는 경우가 많았습니다. 예를 들어, UserServiceUserRepository를 사용하려면 UserService 내부에서 new UserRepository()와 같이 직접 인스턴스를 만들었습니다.

# DI 적용 전 (강한 결합)
class UserRepository:
    def __init__(self):
        print("UserRepository 인스턴스 생성")
        self.db_connection = "DB 연결 객체" # 실제로는 더 복잡한 로직

    def find_user(self, user_id):
        return f"User {user_id} from {self.db_connection}"

class UserService:
    def __init__(self):
        # UserService가 UserRepository를 직접 생성 (강한 결합)
        self.user_repository = UserRepository() 

    def get_user_data(self, user_id):
        return self.user_repository.find_user(user_id)

# 사용
user_service = UserService()
print(user_service.get_user_data(1))

이 방식은 다음과 같은 문제점을 야기합니다.

  1. 강한 결합(Tight Coupling): UserServiceUserRepository의 구체적인 구현체에 직접적으로 의존하게 됩니다. UserRepository의 생성 방식이나 내부 구현이 변경되면 UserService 코드도 수정해야 할 가능성이 높습니다.
  2. 테스트의 어려움: UserService를 단위 테스트할 때, 실제 UserRepository와 DB 연결까지 필요하게 됩니다. 이는 테스트 환경을 구축하기 어렵게 만들고, 테스트의 속도를 저하시키며, 테스트의 순수성(단위 테스트는 해당 단위만 테스트해야 함)을 해칩니다.
  3. 재사용성 및 확장성 저하: 다른 종류의 저장소(예: FileRepository, RedisRepository)를 사용하고 싶어도 UserService 코드를 직접 수정해야 합니다.

이러한 문제점들을 해결하기 위해 제어의 역전(IoC) 개념이 등장했고, 그 구현 방식 중 하나로 의존성 주입이 널리 사용되기 시작했습니다.

왜 중요한가?

DI를 사용하면 앞서 언급된 문제점들을 해결하고 다음과 같은 이점을 얻을 수 있습니다.

  • 느슨한 결합(Loose Coupling): 객체들이 서로의 구체적인 구현에 직접적으로 의존하지 않고 인터페이스나 추상화된 계약에 의존하게 됩니다. 이는 한 모듈의 변경이 다른 모듈에 미치는 영향을 최소화합니다.
  • 테스트 용이성(Testability): 단위 테스트 시 실제 의존성 대신 테스트용 Mock이나 Stub 객체를 쉽게 주입할 수 있습니다. 예를 들어, DB 연결 없이 UserRepository의 동작을 흉내 내는 가짜 객체를 UserService에 주입하여 테스트할 수 있습니다.
  • 유지보수성 및 확장성: 코드의 변경이 필요한 경우, 의존성을 주입하는 부분만 수정하면 되므로 전체 코드베이스에 미치는 영향이 적습니다. 새로운 기능 추가나 기존 기능 변경 시 유연하게 대응할 수 있습니다.
  • 재사용성: 특정 객체가 어떤 의존성을 사용하는지에 대한 관심사(concern)를 분리함으로써, 동일한 객체를 다른 환경이나 다른 의존성과 함께 쉽게 재사용할 수 있습니다.

핵심 원리 설명: 주방장과 식재료 비유

의존성 주입의 핵심 원리는 '주방장과 식재료' 비유로 쉽게 이해할 수 있습니다.

DI 적용 전 상황: 한 주방장(클래스)이 파스타를 만들기 위해 필요한 식재료(의존성, 예: 파스타 면, 토마토소스, 고기)를 직접 마트에 가서 구매(객체 생성)해 옵니다.

  • 문제점: 주방장이 직접 마트에 가야 하므로 비효율적입니다. 마트가 문을 닫거나, 특정 재료가 없으면 파스타를 만들 수 없습니다. 다른 종류의 파스타를 만들려면 또 다른 재료를 직접 사 와야 합니다. 테스트(예: "주방장이 파스타를 제대로 만드는지")를 할 때도 실제 마트에 가서 실제 재료를 사 와야 합니다.

DI 적용 후 상황: 주방장은 파스타를 만들 준비만 하고, 필요한 식재료 리스트만 주문서에 적습니다. 식재료는 외부의 '배달 서비스' 또는 '식자재 공급업체'(DI 컨테이너)에서 주방으로 직접 가져다줍니다.

  • 해결:
    • 주방장은 요리에만 집중할 수 있습니다.
    • 배달 서비스는 주방장이 원하는 어떤 재료든 가져다줄 수 있습니다 (느슨한 결합, 확장성). 예를 들어, 일반 파스타 면 대신 통밀 파스타 면을, 토마토소스 대신 크림소스를 가져다줄 수 있습니다. 주방장은 여전히 '파스타 면'과 '소스'라는 추상적인 개념만 알면 됩니다.
    • 테스트 시에는 실제 재료 대신 플라스틱 모형(Mock/Stub)을 주방에 가져다 놓아도 주방장이 요리하는 과정을 테스트할 수 있습니다. (테스트 용이성).

이 비유에서 주방장은 의존성을 필요로 하는 클래스이고, 식재료는 주입될 의존성 객체입니다. 배달 서비스/식자재 공급업체는 의존성을 생성하고 관리하며 주입해주는 DI 컨테이너 역할을 합니다.

제어의 역전(IoC)과의 관계

DI는 IoC의 한 형태입니다. IoC는 "어떤 객체를 사용할 것인지에 대한 제어권이 객체 자신으로부터 외부로 역전된다"는 원칙입니다. DI는 이 제어권 역전을 구현하는 구체적인 방법 중 하나로, 외부에서 의존성 객체를 생성하여 주입해주는 방식을 사용합니다.

DI의 세 가지 주요 방법

주로 세 가지 방법으로 의존성을 주입합니다.

  1. 생성자 주입 (Constructor Injection): 객체를 생성할 때 생성자의 인자로 의존성을 전달받는 방식. 가장 권장되는 방법으로, 객체 생성 시점에 모든 필수 의존성이 주입됨을 보장하여 객체의 불변성(immutability)을 높이고, 순환 의존성을 방지하는 데 도움을 줍니다.
  2. 세터 주입 (Setter Injection): 객체 생성 후, 세터(setter) 메서드를 통해 의존성을 주입하는 방식. 선택적 의존성이나 런타임에 변경될 수 있는 의존성에 적합하지만, 객체 생성 후에도 의존성이 없을 수 있어 NullPointerException 등의 위험이 있습니다.
  3. 필드 주입 (Field Injection): 필드(멤버 변수)에 직접 의존성을 주입하는 방식. 코드가 간결해 보이지만, 외부에서 직접 필드에 접근하여 주입하므로 캡슐화를 해치고 테스트가 어려워지는 단점이 있어 일반적으로 권장되지 않습니다.

우리는 생성자 주입을 중심으로 예제를 살펴보겠습니다.

코드 예제

Python 예제: 로깅 서비스

Python에서는 DI 컨테이너 없이도 생성자 주입을 통해 DI 패턴을 쉽게 적용할 수 있습니다.

# interfaces.py (인터페이스 역할 - Python에서는 추상 클래스로 구현)
from abc import ABC, abstractmethod

class ILogger(ABC):
    @abstractmethod
    def log(self, message: str):
        pass

# concrete_implementations.py
class ConsoleLogger(ILogger):
    def log(self, message: str):
        print(f"[Console] {message}")

class FileLogger(ILogger):
    def __init__(self, filename: str):
        self.filename = filename

    def log(self, message: str):
        with open(self.filename, 'a') as f:
            f.write(f"[File] {message}\n")

# application_service.py
class UserService:
    def __init__(self, logger: ILogger): # 생성자 주입
        self.logger = logger
        self.users = {}

    def create_user(self, user_id: str, user_name: str):
        if user_id in self.users:
            self.logger.log(f"User ID {user_id} already exists.")
            return False
        self.users[user_id] = user_name
        self.logger.log(f"User {user_name} (ID: {user_id}) created successfully.")
        return True

    def get_user(self, user_id: str):
        user_name = self.users.get(user_id)
        if user_name:
            self.logger.log(f"Fetched user {user_name} (ID: {user_id}).")
        else:
            self.logger.log(f"User ID {user_id} not found.")
        return user_name

# main.py (DI 적용 및 사용)
if __name__ == "__main__":
    # ConsoleLogger를 주입하는 경우
    console_logger = ConsoleLogger()
    console_user_service = UserService(console_logger) # 의존성 주입
    print("--- Console Logger 사용 ---")
    console_user_service.create_user("john_doe", "John Doe")
    console_user_service.get_user("john_doe")
    console_user_service.get_user("jane_doe")

    # FileLogger를 주입하는 경우
    file_logger = FileLogger("app.log")
    file_user_service = UserService(file_logger) # 의존성 주입
    print("\n--- File Logger 사용 ---")
    file_user_service.create_user("jane_doe", "Jane Doe")
    file_user_service.get_user("jane_doe")

    # 이처럼 UserService는 어떤 ILogger 구현체를 주입받든 동일하게 동작합니다.
    # 테스트 시에는 MockLogger를 주입하여 쉽게 테스트할 수 있습니다.

JavaScript 예제: 데이터 저장소 서비스 (TypeScript 사용 가정)

JavaScript에서도 클래스와 생성자를 사용하여 DI를 구현할 수 있으며, 특히 TypeScript와 함께 사용하면 타입 안정성을 확보할 수 있습니다.

// interfaces.ts (인터페이스 역할)
interface IDataStore {
    save(id: string, data: any): void;
    get(id: string): any;
}

// concrete_implementations.ts
class InMemoryDataStore implements IDataStore {
    private store: { [key: string]: any } = {};

    save(id: string, data: any): void {
        this.store[id] = data;
        console.log(`[InMemory] Saved data for ID: ${id}`);
    }

    get(id: string): any {
        const data = this.store[id];
        console.log(`[InMemory] Fetched data for ID: ${id}`);
        return data;
    }
}

class DatabaseDataStore implements IDataStore {
    // 실제 DB 연결 로직이 있다고 가정
    constructor(private connectionString: string) {
        console.log(`[Database] Connected to DB: ${connectionString}`);
    }

    save(id: string, data: any): void {
        // 실제 DB 저장 로직
        console.log(`[Database] Saving data for ID: ${id} to ${this.connectionString}`);
    }

    get(id: string): any {
        // 실제 DB 조회 로직
        console.log(`[Database] Fetching data for ID: ${id} from ${this.connectionString}`);
        return { id, data: "Data from DB" }; // 예시 데이터
    }
}

// application_service.ts
class ProductService {
    private dataStore: IDataStore;

    constructor(dataStore: IDataStore) { // 생성자 주입
        this.dataStore = dataStore;
    }

    createProduct(productId: string, name: string, price: number): void {
        const product = { productId, name, price };
        this.dataStore.save(productId, product);
        console.log(`Product ${name} created.`);
    }

    getProduct(productId: string): any {
        const product = this.dataStore.get(productId);
        console.log(`Product fetched: ${JSON.stringify(product)}`);
        return product;
    }
}

// main.ts (DI 적용 및 사용)
// 의존성들을 생성
const inMemoryStore = new InMemoryDataStore();
const dbStore = new DatabaseDataStore("mongodb://localhost:27017/prod_db");

// InMemoryDataStore를 사용하는 ProductService
const inMemoryProductService = new ProductService(inMemoryStore);
console.log("--- InMemoryDataStore 사용 ---");
inMemoryProductService.createProduct("P001", "Laptop", 1200);
inMemoryProductService.getProduct("P001");

console.log("\n--- DatabaseDataStore 사용 ---");
// DatabaseDataStore를 사용하는 ProductService (운영 환경)
const dbProductService = new ProductService(dbStore);
dbProductService.createProduct("P002", "Keyboard", 75);
dbProductService.getProduct("P002");

// 테스트 시 MockDataStore를 주입하여 ProductService의 로직만 테스트 가능
class MockDataStore implements IDataStore {
    save(id: string, data: any): void { console.log(`[Mock] Saving ${id}`); }
    get(id: string): any { console.log(`[Mock] Getting ${id}`); return { id: id, data: "Mocked Data" }; }
}
const mockProductService = new ProductService(new MockDataStore());
console.log("\n--- MockDataStore 사용 (테스트 환경) ---");
mockProductService.createProduct("P003", "Mouse", 25);
mockProductService.getProduct("P003");

실무 적용 사례

1. 웹 프레임워크에서의 활용

대부분의 현대적인 웹 프레임워크(예: Java의 Spring, Node.js의 NestJS, JavaScript의 Angular, Python의 FastAPI)는 DI 컨테이너를 내장하고 있습니다. 개발자는 프레임워크의 규칙에 따라 의존성을 선언하기만 하면, 프레임워크가 런타임에 필요한 의존성 객체를 자동으로 생성하고 주입해줍니다.

  • Spring (Java): @Autowired, @Inject 어노테이션이나 생성자 주입을 통해 빈(Bean)을 자동으로 주입합니다.
  • NestJS (Node.js/TypeScript): @Injectable, @Module, @Controller 데코레이터를 사용하여 클래스를 DI 컨테이너에 등록하고, 생성자 주입을 통해 의존성을 해결합니다.
  • Angular (JavaScript/TypeScript): @Injectable 데코레이터와 생성자 주입을 통해 서비스를 컴포넌트나 다른 서비스에 주입합니다.
  • FastAPI (Python): Depends 함수를 사용하여 라우터 함수나 의존성 함수에 의존성을 주입합니다.

이러한 프레임워크를 사용하면 DI의 복잡한 설정 없이도 그 이점을 누릴 수 있습니다.

2. 단위 테스트 작성 시 모킹(Mocking)

DI의 가장 강력한 장점 중 하나는 테스트 용이성입니다. 단위 테스트를 작성할 때, 테스트 대상 클래스가 의존하는 실제 객체(예: 데이터베이스, 외부 API 클라이언트) 대신 가짜 객체(Mock 또는 Stub)를 주입할 수 있습니다.

import unittest
from unittest.mock import Mock
from application_service import UserService, ILogger # 위 예제에서 사용한 클래스 임포트

class TestUserService(unittest.TestCase):
    def test_create_user_success(self):
        # ILogger 인터페이스를 Mock 객체로 대체
        mock_logger = Mock(spec=ILogger) 
        user_service = UserService(mock_logger) # Mock 객체 주입

        result = user_service.create_user("test_id", "Test User")
        self.assertTrue(result)
        self.assertEqual(user_service.users.get("test_id"), "Test User")
        # logger의 log 메서드가 올바른 메시지로 호출되었는지 검증
        mock_logger.log.assert_called_with("User Test User (ID: test_id) created successfully.")

    def test_create_user_already_exists(self):
        mock_logger = Mock(spec=ILogger)
        user_service = UserService(mock_logger)
        user_service.create_user("existing_id", "Existing User") # 미리 사용자 생성

        result = user_service.create_user("existing_id", "Another User")
        self.assertFalse(result)
        # logger의 log 메서드가 중복 사용자 메시지로 호출되었는지 검증
        mock_logger.log.assert_called_with("User ID existing_id already exists.")

if __name__ == '__main__':
    unittest.main()

이처럼 UserService는 실제 로거가 아닌 Mock 객체를 주입받아 테스트되므로, 파일 시스템이나 콘솔 출력에 의존하지 않고 UserService의 로직만을 순수하게 테스트할 수 있습니다.

3. 환경별 설정 관리

개발, 스테이징, 운영 환경마다 데이터베이스 연결 문자열, API 키 등 설정값이 달라지는 경우가 많습니다. DI를 활용하면 각 환경에 맞는 설정 객체를 주입하여 쉽게 환경을 전환할 수 있습니다.

예를 들어, 개발 환경에서는 InMemoryDataStore를, 운영 환경에서는 DatabaseDataStore를 주입하도록 애플리케이션 시작 시점에 구성할 수 있습니다.

자주 하는 실수와 해결법

DI는 강력한 패턴이지만, 잘못 사용하면 오히려 복잡성을 증가시키거나 문제를 일으킬 수 있습니다.

1. 과도한 DI (Everything is an Injection)

문제: 모든 작은 유틸리티 클래스나 단순한 데이터 객체까지 DI 컨테이너를 통해 주입받으려 합니다. 이는 불필요하게 코드의 복잡성을 높이고, 컨테이너 설정 파일을 비대하게 만들 수 있습니다.

해결: DI는 주로 '서비스'나 '컴포넌트'와 같이 상태를 가지거나 복잡한 로직을 수행하는 객체에 적용하는 것이 좋습니다. 단순한 값 객체(Value Object)나 유틸리티 함수는 직접 생성하거나 정적 메서드를 사용하는 것이 더 효율적일 수 있습니다. "진정으로 의존성이 있는가?"를 자문해보세요.

2. 순환 의존성(Circular Dependency)

문제: A 클래스가 B 클래스에 의존하고, B 클래스가 다시 A 클래스에 의존하는 경우입니다. DI 컨테이너가 A를 만들려다 B가 필요하고, B를 만들려다 A가 필요해서 무한 루프에 빠지거나 오류가 발생합니다.

해결:

  • 설계 재검토: 가장 근본적인 해결책은 아키텍처를 재설계하여 순환 의존성을 제거하는 것입니다. 두 클래스가 너무 강하게 얽혀 있다면, 단일 책임 원칙(Single Responsibility Principle, SRP)을 위반하고 있을 가능성이 높습니다.
  • 인터페이스 분리: 공통된 인터페이스를 추출하여, 한 클래스가 다른 클래스의 구체적인 구현 대신 인터페이스에 의존하도록 합니다.
  • 이벤트 버스/옵저버 패턴: 직접적인 의존성 대신 이벤트를 발행하고 구독하는 방식으로 통신하게 합니다.
  • 세터 주입 (제한적 사용): 필수적이지 않은 의존성에 한해 세터 주입을 사용하여 초기화 시점을 분리할 수 있으나, 이는 객체의 일관성을 해칠 수 있으므로 신중하게 사용해야 합니다.

3. DI 컨테이너의 오용 (Service Locator Anti-Pattern)

문제: DI 컨테이너(또는 IoC 컨테이너)를 마치 ServiceLocator처럼 사용하여, 애플리케이션 코드 내에서 컨테이너로부터 직접 의존성을 요청하는 방식입니다 (container.get('MyService')). 이는 DI의 장점인 느슨한 결합을 해치고, 테스트를 어렵게 만듭니다.

해결: DI 컨테이너는 애플리케이션의 시작 지점에서 의존성을 구성하고 주입하는 역할에만 집중해야 합니다. 비즈니스 로직을 담고 있는 클래스들은 컨테이너의 존재를 몰라야 합니다. 필요한 의존성은 항상 생성자나 세터(선택적)를 통해 주입받도록 해야 합니다.

4. 테스트 시 실제 DI 컨테이너 사용

문제: 단위 테스트를 작성할 때, 실제 애플리케이션에서 사용하는 DI 컨테이너를 초기화하고 그로부터 객체를 가져와 테스트하는 경우. 이는 테스트를 무겁고 느리게 만들며, 진정한 '단위' 테스트가 아닌 '통합' 테스트에 가까워집니다.

해결: 단위 테스트에서는 DI 컨테이너를 사용하지 않고, 필요한 의존성을 수동으로 Mock 객체나 Stub 객체로 생성하여 테스트 대상 클래스의 생성자에 직접 주입해야 합니다. 이렇게 하면 테스트가 빠르고 고립되며, 테스트 대상 코드의 로직에만 집중할 수 있습니다.

더 공부할 리소스 추천

  • 마틴 파울러의 IoC 컨테이너 문서: https://martinfowler.com/articles/injection.html - DI 개념의 고전적인 정의와 배경을 이해하는 데 필수적인 자료입니다.
  • 사용하는 프레임워크의 공식 문서:
    • Spring Framework (Java): DI 관련 문서 (예: Core Technologies - IoC Container)
    • NestJS (Node.js): Custom Providers, Dependency injection 문서
    • Angular (JavaScript): Dependency Injection 문서
    • FastAPI (Python): Dependencies 문서 각 프레임워크가 DI를 어떻게 구현하고 활용하는지 학습하는 것이 실용적입니다.
  • 클린 아키텍처(Clean Architecture) 및 DDD(Domain-Driven Design) 관련 서적: DI는 계층형 아키텍처와 도메인 주도 설계에서 핵심적인 역할을 합니다. 이러한 개념들을 함께 학습하면 DI의 중요성과 적용 방법을 더 깊이 이해할 수 있습니다.
  • 디자인 패턴 서적: GoF(Gang of Four)의 디자인 패턴 중 팩토리 메서드 패턴, 추상 팩토리 패턴 등은 DI와 밀접한 관련이 있습니다.

의존성 주입은 단순히 코드를 작성하는 기술을 넘어, 소프트웨어의 아키텍처를 설계하고 유지보수성을 높이는 데 필수적인 사고방식입니다. 처음에는 조금 복잡하게 느껴질 수 있지만, 꾸준히 연습하고 적용하다 보면 더 유연하고 견고한 코드를 작성하는 데 큰 도움이 될 것입니다.