CS 상식/디자인패턴

의존성 주입(Dependency Injection, DI): 간단한 비유와 단위 테스트로 이해하기

SparkIT 2024. 11. 6. 11:43

객체 지향 프로그래밍의 주요한 패턴 중 하나는 의존성 주입(Dependency Injection, DI) 입니다. 하지만 저는 이런 개념이 정확히 이해되지 않더라구요. 아마 많은 사람들이 저와 비슷한 상황일 것이라고 생각합니다. 그래서 오늘은 제가 이해한 의존성 주입에 대해 공유해보려 합니다.

 


의존성 주입이란?

먼저 위키백과를 통해 확인할 수 있는 간단한 의존성 주입에 대한 정의입니다.

소프트웨어 엔지니어링에서 의존성 주입은 하나의 객체가 다른 객체의 의존성을 제공하는 테크닉이다. "의존성"은 예를 들어 서비스로 사용할 수 있는 객체이다. 클라이언트가 어떤 서비스를 사용할 것인지 지정하는 대신, 클라이언트에게 무슨 서비스를 사용할 것인지를 말해주는 것이다. #위키백과

 

의존성 주입의 핵심 목적

  • 결합도 감소
    의존성 주입을 통해 클래스들이 직접적으로 서로를 생성하지 않습니다. 대신 외부에서 주입받아 클래스 간 결합도가 낮아져 서로 독립적이고 재사용 가능하게 만들 수 있습니다.
  • 유연성 증가
    의존성을 외부에서 주입받기 때문에 객체의 구현을 쉽게 교체할 수 있습니다.
  • 테스트 용이성
    위에서 말했든 객체의 구현을 쉽게 교체할 수 있기 때문에, 기존 의존성을 테스트를 위한 Mock 객체로 변경하기 쉽습니다.

 

보통은 이런 설명을 봐도 잘 이해가 가지 않죠. 그래서 제가 간단한 비유를 들어보려합니다.

먼저 한 식당이 존재한다고 가정합시다. 여기에는 식당을 운영하는 사장과 식당에서 요리를 담당하는 요리사가 있습니다. 이때 요리사는 주어진 재료와 조리도구만을 이용해 요리할 수 있습니다. 식당은 요리사에게 재료와 조리도구를 제공하죠. 이때 문제가 생깁니다. 요리사의 자질을 평가하기 위해 요리를 시켰는데, 사장이 썩은 재료와 허접한 조리도구를 제공한 것입니다. 그러면 어떻게 될까요? 당연히 요리사가 굉장한 요리 스킬을 가지고 있을지라도 엉망인 음식이 나올 것 입니다. 그럼 요리사는 허접한 요리사일까요? 그건 또 아니죠. 아무리 결과가 엉망인 음식이라고 해도 요리사 자체는 허접하다고 볼 수 없습니다. 이런 개념이 바로 의존성 주입입니다.

의존성 주입 비유

 

  • 요리사
    서비스 코드 역할. (실질적인 로직을 담당합니다)

  • 사장
    DI 컨테이너 역할. (요리사에게 재료와 조리도구를 제공합니다)

  • 재료와 조리도구
    의존성 역할. (요리사가 요리를 하기위해 도움을 줍니다)

만약 요리사가 원래는 김치찌개를 요리했었는데, 돈까스를 요리하려 한다면 재료와 조리도구가 바뀌어하겠죠? 이때 요리사는 바뀔 필요없습니다.(두 음식 다 할 줄 안다는 가정 하에) 대신 사장이 김치, 두부, 파 등의 재료 대신 돼지고기, 빵가루, 계란 등을 제공하면 됩니다. 요리사 자체를 수정할 필요가 없다는 것입니다. 넣어주는 재료와 조리도구(의존성)만 수정되면 됩니다. 위에서 언급했던 의존성 주입의 핵심 목적을 다시 한 번 볼까요?

비유를 통한 의존성 주입의 핵심 목적

  • 결합도 감소
    의조성 주입을 통해 클래스들이 직접적으로 서로를 생성하지 않습니다. 대신 외부에서 주입받아 클래스 간 결합도가 낮아져 서로 독립적이고 재사용 가능하게 만들 수 있습니다. --> 요리사는 요리에만 전념하면 됨
  • 유연성 증가
    의존성을 외부에서 주입받기 때문에 객체의 구현을 쉽게 교체할 수 있습니다. --> 김치찌개 요리하려면 김치찌개 재료와 조리도구, 돈까스 요리하려면 돈까스 재료와 조리도구를 넣어주기만 하면 됨
  • 테스트 용이성
    위에서 말했든 객체의 구현을 쉽게 교체할 수 있기 때문에, 기존 의존성을 테스트를 위한 Mock 객체로 변경하기 쉽습니다. --> 요리사 평가할 때는 평가용 재료와 조리도구를 제공하면 됨

 

 


테스트 코드로 의존성 주입 이해하기

저는 현재 NestJS를 주로 사용하고 있기 때문에 NestJS의 테스트 코드 형식인 user.service.spec.ts 를 이용해 설명하겠습니다.

1. UserService와 DtatabaseService 코드

// database.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class DatabaseService {
  findUserById(userId: string): string {
    // 실제 데이터베이스 쿼리를 실행한다고 가정
    return `Real user with ID: ${userId}`;
  }
}
// user.service.ts
import { Injectable } from '@nestjs/common';
import { DatabaseService } from './database.service';

@Injectable()
export class UserService {
  constructor(private readonly databaseService: DatabaseService) {}

  getUserInfo(userId: string): string {
    return this.databaseService.findUserById(userId);
  }
}

 

 

2. 테스트에 사용할 Mock 서비스 코드

// mock-database.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class MockDatabaseService {
  findUserById(userId: string): string {
    // 테스트용 mock 데이터 반환
    return `Mock user with ID: ${userId}`;
  }
}

 

 

3. UserService 테스트 코드

// user.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UserService } from './user.service';
import { DatabaseService } from './database.service';
import { MockDatabaseService } from './mock-database.service';

describe('UserService with Dependency Injection', () => {
  let userService: UserService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UserService,
        {
          provide: DatabaseService,
          useClass: MockDatabaseService, // 의존성 주입을 통해 MockDatabaseService 사용
        },
      ],
    }).compile();

    userService = module.get<UserService>(UserService);
  });

  it('should return mock data from MockDatabaseService', () => {
    const result = userService.getUserInfo('123');
    expect(result).toBe('Mock user with ID: 123'); // Mock 데이터 확인
  });
});

바로 이 부분처럼 기존에 사용하던 DatabaseService를 MockDatabaseService로 교체해 의존성 주입을 할 수 있습니다.