의존성 주입에 대한 실용적인 소개
게시 됨: 2022-03-10의존성 주입의 개념은 그 핵심에 근본적으로 단순한 개념입니다. 그러나 일반적으로 제어 역전(Inversion of Control), 종속 역전(Dependency Inversion), SOLID 원리 등의 보다 이론적인 개념과 함께 제시됩니다. 의존성 주입 사용을 가능한 한 쉽게 시작하고 그 이점을 누리기 시작할 수 있도록 이 기사는 이야기의 실질적인 측면에서 계속 유지되며 주로 주로 사용의 이점을 정확하게 보여주는 예를 설명합니다. 관련 이론에서 분리되었습니다.
여기서는 종속성 주입을 둘러싼 학문적 개념에 대해 논의하는 데 아주 적은 시간만 할애할 것입니다. 왜냐하면 해당 설명의 대부분은 이 시리즈의 두 번째 기사를 위해 남겨둘 것이기 때문입니다. 실제로, 개념에 대한 보다 심도 있고 엄격한 처리를 제공하는 전체 책이 작성될 수 있고 작성되었습니다.
여기에서는 간단한 설명으로 시작하여 몇 가지 실제 사례로 이동한 다음 몇 가지 배경 정보를 논의합니다. 다른 기사(이 기사에 이어)에서는 Dependency Injection이 모범 사례 아키텍처 패턴을 적용하는 전체 생태계에 어떻게 맞는지 논의할 것입니다.
간단한 설명
"종속성 주입"은 매우 단순한 개념에 대해 지나치게 복잡한 용어입니다. 이 시점에서 몇 가지 현명하고 합리적인 질문은 "'종속성'을 어떻게 정의합니까?", "종속성이 '주입'된다는 것은 무엇을 의미합니까?", "다른 방식으로 종속성을 주입할 수 있습니까?"입니다. "이것이 왜 유용한가요?" "종속성 주입"과 같은 용어가 두 개의 코드 스니펫과 몇 단어로 설명될 수 있다는 것이 믿기지 않을 수도 있지만, 유감스럽게도 그렇게 할 수 있습니다.
개념을 설명하는 가장 간단한 방법은 보여 주는 것입니다.
예를 들어 이것은 종속성 주입이 아닙니다 .
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
그러나 이것은 종속성 주입입니다.
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
완료. 그게 다야 멋있는. 끝.
변경된 사항은 무엇입니까? Car
클래스가 Engine
을 인스턴스화하도록 허용하는 대신(첫 번째 예제에서와 같이), 두 번째 예제에서 Car
는 상위 제어 수준에서 생성자로 전달되거나 주입된 Engine
인스턴스를 가졌습니다. 그게 다야 핵심에서 이것은 모든 종속성 주입입니다. 종속성을 다른 클래스나 함수에 주입(전달)하는 행위입니다. 의존성 주입의 개념과 관련된 다른 모든 것은 이 기본적이고 단순한 개념의 변형일 뿐입니다. 간단히 말해서 종속성 주입은 개체가 자체적으로 생성하지 않고 종속성이라고 하는 다른 개체를 수신하는 기술입니다.
일반적으로 "종속성"이 무엇인지 정의하기 위해 일부 클래스 A
가 클래스 B
의 기능을 사용하는 경우 B
는 A
에 대한 종속성, 즉 A
는 B
에 대한 종속성이 있습니다. 물론 이것은 클래스에 국한되지 않고 함수에도 적용됩니다. 이 경우 Car
클래스에는 Engine
클래스에 대한 종속성이 있거나 Engine
은 Car
에 종속됩니다. 종속성은 프로그래밍의 대부분의 것과 마찬가지로 단순히 변수입니다.
의존성 주입은 많은 사용 사례를 지원하기 위해 널리 사용되지만 아마도 가장 노골적인 사용은 더 쉬운 테스트를 허용하는 것입니다. 첫 번째 예에서는 Car
클래스가 엔진을 인스턴스화하기 때문에 engine
을 쉽게 조롱할 수 없습니다. 실제 엔진은 항상 사용됩니다. 그러나 후자의 경우 사용되는 Engine
을 제어할 수 있습니다. 즉, 테스트에서 Engine
을 하위 클래스로 분류하고 해당 메서드를 재정의할 수 있습니다.
예를 들어, engine.fireCylinders()에서 오류가 발생하면 engine.fireCylinders()
Car.startEngine()
이 수행하는 작업을 확인하려면 FakeEngine
클래스를 생성하고 Engine
클래스를 확장한 다음 fireCylinders
를 재정의하여 오류를 발생시키도록 하면 됩니다. . 테스트에서 FakeEngine
객체를 Car
생성자에 주입할 수 있습니다. FakeEngine
은 상속을 함축한 Engine
이므로 TypeScript 유형 시스템을 만족합니다. 나중에 보게 되겠지만 상속과 메서드 재정의를 사용하는 것이 반드시 최선의 방법은 아니지만 확실히 옵션입니다.
위에서 본 것이 의존성 주입의 핵심 개념이라는 것을 아주, 아주 분명하게 밝히고 싶습니다. Car
는 그 자체로 어떤 엔진이 필요한지 알 만큼 똑똑하지 않습니다. 자동차를 구성 하는 엔지니어만이 자동차의 엔진과 바퀴에 대한 요구 사항을 이해합니다. 따라서 Car
자체가 사용하려는 엔진을 선택하도록 하는 것이 아니라 자동차를 구성 하는 사람들이 필요한 특정 엔진을 제공하는 것이 합리적입니다.
내가 "construct"라는 단어를 사용하는 이유는 특히 종속성이 주입되는 장소인 생성자를 호출하여 자동차를 구성하기 때문입니다. 자동차가 엔진 외에 자체 타이어도 만들었다면 사용 중인 타이어가 엔진이 출력할 수 있는 최대 RPM으로 회전하는 것이 안전하다는 것을 어떻게 알 수 있습니까? 이러한 모든 이유와 그 이상으로 인해 Car
는 어떤 Engine
과 어떤 Wheels
을 사용하는지 결정하는 것과 아무 관련이 없어야 한다는 것이 직관적으로 이해될 것입니다. 그것들은 더 높은 수준의 통제에서 제공되어야 합니다.
작동 중인 종속성 주입을 묘사한 후자의 예에서 Engine
이 구체적인 클래스가 아닌 추상 클래스라고 상상한다면 이것은 훨씬 더 이해가 될 것입니다. 자동차는 엔진이 필요하고 엔진에 몇 가지 기본 기능이 있어야 한다는 것을 알고 있습니다. 그러나 해당 엔진이 어떻게 관리되고 엔진의 특정 구현은 자동차를 생성(구성)하는 코드 조각에 의해 결정되고 제공되도록 예약되어 있습니다.
실제 사례
종속성 주입이 유용한 이유를 직관적으로 설명하는 데 도움이 되는 몇 가지 실용적인 예를 더 살펴보겠습니다. 바라건대, 이론적인 것에 집착하지 않고 대신 적용 가능한 개념으로 바로 이동함으로써 의존성 주입이 제공하는 이점과 그것 없이 삶의 어려움을 더 완전히 볼 수 있기를 바랍니다. 나중에 이 주제에 대해 조금 더 "학문적인" 취급으로 되돌아갈 것입니다.
의존성 주입이나 추상화를 사용하지 않고 고도로 결합된 방식으로 애플리케이션을 정상적으로 구성하는 것으로 시작하여 이 접근 방식의 단점과 테스트에 추가되는 어려움을 알게 됩니다. 그 과정에서 모든 문제를 수정할 때까지 점진적으로 리팩토링할 것입니다.
시작하려면 이메일 공급자와 일부 UserService
에서 사용해야 하는 데이터 액세스 계층에 대한 클래스라는 두 가지 클래스를 작성하는 작업을 받았다고 가정합니다. 데이터 액세스부터 시작하지만 둘 다 쉽게 정의됩니다.
// UserRepository.ts import { dbDriver } from 'pg-driver'; export class UserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
참고: 여기에서 "리포지토리"라는 이름은 비즈니스 논리에서 데이터베이스를 분리하는 방법인 "리포지토리 패턴"에서 따왔습니다. 리포지토리 패턴에 대해 자세히 알아볼 수 있지만 이 기사의 목적을 위해 단순히 데이터베이스를 캡슐화하는 클래스로 간주하여 비즈니스 로직에서 데이터 스토리지 시스템이 단순히 인메모리로 취급되도록 할 수 있습니다. 수집. 리포지토리 패턴을 완전히 설명하는 것은 이 기사의 범위를 벗어납니다.
이것이 우리가 일반적으로 작동할 것으로 예상하는 방식이며 dbDriver
는 파일 내에서 하드코딩됩니다.
UserService
에서 클래스를 가져와서 인스턴스화하고 사용을 시작합니다.
import { UserRepository } from './UserRepository.ts'; class UserService { private readonly userRepository: UserRepository; public constructor () { // Not dependency injection. this.userRepository = new UserRepository(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send a welcome email // ... } public async findUserById(id: string): Promise<User> { // No need for await here, the promise will be unwrapped by the caller. return this.userRepository.findUserById(id); } }
다시 한 번, 모든 것이 정상으로 유지됩니다.
간단히 말해서 DTO는 데이터 전송 개체입니다. DTO는 두 개의 외부 시스템 또는 응용 프로그램의 두 계층 간에 이동할 때 표준화된 데이터 모양을 정의하는 속성 모음 역할을 하는 개체입니다. 여기에서 해당 주제에 대한 Martin Fowler의 기사에서 DTO에 대해 자세히 알아볼 수 있습니다. 이 경우 IRegisterUserDto
는 클라이언트에서 데이터가 나타날 때 데이터의 모양이 어떠해야 하는지에 대한 계약을 정의합니다. id
와 email
의 두 가지 속성만 포함합니다. 아직 사용자를 생성하지 않았음에도 불구하고 새 사용자를 생성하기 위해 클라이언트에서 기대하는 DTO에 사용자의 ID가 포함되어 있다는 것이 이상하다고 생각할 수도 있습니다. ID는 UUID이며 이 기사의 범위를 벗어나는 다양한 이유로 클라이언트가 이를 생성하도록 허용합니다. 또한 findUserById
함수는 User
개체를 응답 DTO에 매핑해야 하지만 간결함을 위해 무시했습니다. 마지막으로, 실제 세계에서는 User
도메인 모델에 fromDto
메서드가 포함되지 않습니다. 도메인 순도에는 좋지 않습니다. 다시 한 번, 그 목적은 여기에서 간결합니다.
다음으로 이메일 전송을 처리하려고 합니다. 다시 한 번, 평소와 같이 이메일 제공자 클래스를 생성하고 이를 UserService
로 가져올 수 있습니다.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
사용자 서비스 내 UserService
:
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor () { // Still not doing dependency injection. this.userRepository = new UserRepository(); this.sendGridEmailProvider = new SendGridEmailProvider(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
이제 우리는 완전한 노동계급을 갖게 되었고, 테스트 가능성이나 정의의 어떤 방식으로든 깨끗한 코드 작성에 전혀 신경 쓰지 않는 세상, 그리고 기술적인 부채가 존재하지 않고 성가신 프로그램 관리자가 ' t 기한을 설정하면 완벽합니다. 불행히도, 그것은 우리가 사는 이점이 있는 세상이 아닙니다.
이메일용 SendGrid에서 마이그레이션하고 대신 MailChimp를 사용해야 한다고 결정하면 어떻게 됩니까? 유사하게, 메서드를 단위 테스트하려고 할 때 어떤 일이 발생합니까? 테스트에서 실제 데이터베이스를 사용할 것입니까? 설상가상으로 실제 이메일을 잠재적인 실제 이메일 주소로 보내고 비용도 지불하게 될까요?
기존 JavaScript 에코시스템에서 이 구성의 단위 테스트 클래스 방법은 복잡성과 과도한 엔지니어링으로 가득 차 있습니다. 사람들은 모든 종류의 간접 참조 계층을 추가하는 스터빙 기능을 제공하기 위해 전체 라이브러리를 가져오고, 더 나쁜 것은 테스트 중인 시스템의 구현에 테스트를 직접 연결할 수 있습니다. 실제 시스템이 작동합니다(이를 블랙박스 테스트라고 함). UserService
의 실제 책임이 무엇인지 논의하고 종속성 주입의 새로운 기술을 적용하면서 이러한 문제를 완화하기 위해 노력할 것입니다.
잠시 동안 UserService
가 하는 일을 고려하십시오. UserService
존재의 요점은 사용자를 등록하고, 읽고, 업데이트하는 등 사용자와 관련된 특정 사용 사례를 실행하는 것입니다. 클래스와 함수는 단 하나의 책임(SRP - 단일 책임 원칙)만 갖는 것이 가장 좋습니다. UserService
의 책임은 사용자 관련 작업을 처리하는 것입니다. 그렇다면 왜 이 예에서 UserRepository
와 SendGridEmailProvider
의 수명을 UserService
가 제어해야 합니까?
장기 실행 연결을 연 UserService
에서 사용하는 다른 클래스가 있다고 상상해 보십시오. UserService
도 해당 연결을 폐기해야 합니까? 당연히 아니지. 이러한 모든 종속성은 연관된 수명을 가지고 있습니다. 단일 항목일 수도 있고, 일시적이고 특정 HTTP 요청으로 범위가 지정될 수도 있습니다. 이러한 수명의 제어는 UserService
범위를 훨씬 벗어납니다. 따라서 이러한 문제를 해결하기 위해 이전에 본 것처럼 모든 종속성을 주입합니다.
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor ( userRepository: UserRepository, sendGridEmailProvider: SendGridEmailProvider ) { // Yay! Dependencies are injected. this.userRepository = userRepository; this.sendGridEmailProvider = sendGridEmailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
엄청난! 이제 UserService
는 미리 인스턴스화된 개체를 수신하고 어떤 코드 부분이든지 새 UserService
를 호출하고 생성하는 것은 종속성의 수명을 제어하는 코드 부분입니다. 우리는 UserService
에서 더 높은 수준으로 제어를 역전시켰습니다. 종속성 주입의 기본 테넌트를 설명하기 위해 생성자를 통해 종속성을 주입하는 방법만 보여주고 싶다면 여기서 멈출 수 있습니다. 그러나 디자인 관점에서 볼 때 여전히 몇 가지 문제가 있습니다. 수정되면 종속성 주입을 더욱 강력하게 사용하는 데 도움이 될 것입니다.
첫째, 왜 UserService
는 우리가 이메일에 SendGrid를 사용하고 있다는 것을 알고 있습니까? 둘째, 두 종속성은 모두 구체적인 클래스(구체적인 UserRepository
및 구체적인 SendGridEmailProvider
에 있습니다. 이 관계는 너무 엄격합니다. UserRepository
이고 SendGridEmailProvider
인 일부 개체를 전달해야 하는 데 막혔습니다.
UserService
가 종속성 구현에 대해 완전히 불가지론적인 것을 원하기 때문에 이것은 좋지 않습니다. 이러한 방식으로 UserService
를 블라인드로 설정함으로써 서비스에 전혀 영향을 주지 않고 구현을 교체할 수 있습니다. 즉, SendGrid에서 마이그레이션하고 대신 MailChimp를 사용하기로 결정하면 그렇게 할 수 있습니다. 또한 테스트를 위해 이메일 공급자를 속이고 싶다면 그렇게 할 수 있습니다.
유용한 것은 일부 공개 인터페이스를 정의하고 들어오는 종속성이 해당 인터페이스를 따르도록 강제할 수 있으면서도 UserService
가 구현 세부 사항에 대해 불가지론적으로 유지되도록 하는 것입니다. 다시 말해, UserService
가 실제 구체적인 종속성이 아니라 종속성의 추상화에만 의존하도록 강제해야 합니다. 우리는 인터페이스를 통해 그렇게 할 수 있습니다.
UserRepository
에 대한 인터페이스를 정의하여 시작하고 구현합니다.
// UserRepository.ts import { dbDriver } from 'pg-driver'; export interface IUserRepository { addUser(user: User): Promise<void>; findUserById(id: string): Promise<User>; existsByEmail(email: string): Promise<boolean>; } export class UserRepository implements IUserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
그리고 이메일 제공자에 대해 정의하고 구현합니다.
// IEmailProvider.ts export interface IEmailProvider { sendWelcomeEmail(to: string): Promise<void>; } // SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; import { IEmailProvider } from './IEmailProvider'; export class SendGridEmailProvider implements IEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
참고: 이것은 네 가지 디자인 패턴으로 구성된 그룹의 어댑터 패턴입니다.
이제 UserService
는 종속성의 구체적인 구현이 아닌 인터페이스에 의존할 수 있습니다.
import { IUserRepository } from './UserRepository.ts'; import { IEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: IUserRepository; private readonly emailProvider: IEmailProvider; public constructor ( userRepository: IUserRepository, emailProvider: IEmailProvider ) { // Double yay! Injecting dependencies and coding against interfaces. this.userRepository = userRepository; this.emailProvider = emailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.emailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
인터페이스가 처음인 경우 매우 복잡해 보일 수 있습니다. 실제로 느슨하게 결합된 소프트웨어를 구축한다는 개념은 당신에게도 생소할 수 있습니다. 벽 콘센트에 대해 생각해보십시오. 플러그가 콘센트에 맞는 한 모든 장치를 콘센트에 꽂을 수 있습니다. 작동 중인 느슨한 결합입니다. 당신의 토스터는 벽에 고정 배선되어 있지 않습니다. 왜냐하면 만약 그렇다면, 당신이 당신의 토스터를 업그레이드하기로 결정했다면 당신은 운이 없는 것입니다. 대신 콘센트가 사용되며 콘센트는 인터페이스를 정의합니다. 마찬가지로, 전자 장치를 벽면 콘센트에 꽂을 때 전압 전위, 최대 전류 소모량, AC 주파수 등은 신경쓰지 않고 플러그가 콘센트에 맞는지만 신경쓰면 됩니다. 전기 기술자가 와서 콘센트 뒤에 있는 모든 전선을 교체하도록 할 수 있으며 콘센트가 바뀌지 않는 한 토스터를 연결하는 데 문제가 없습니다. 또한 전기 소스를 도시 또는 자체 태양 전지판에서 공급하도록 전환할 수 있으며 다시 한 번 해당 콘센트에 꽂을 수 있는 한 상관하지 않습니다.

인터페이스는 "플러그 앤 플레이" 기능을 제공하는 콘센트입니다. 이 예에서 벽과 전기 소스의 배선은 종속성과 유사하고 토스터는 UserService
와 유사합니다(전기에 대한 종속성이 있음). 전원은 변경될 수 있고 토스터는 여전히 잘 작동하며 필요하지 않습니다. 인터페이스 역할을 하는 콘센트가 둘 다 통신하기 위한 표준 수단을 정의하기 때문입니다. 실제로 콘센트는 벽 배선, 회로 차단기, 전원 등의 "추상화" 역할을 한다고 말할 수 있습니다.
위의 이유 때문에 구현이 아닌 인터페이스(추상화)에 대해 코딩하는 것이 소프트웨어 설계의 일반적이고 잘 알려진 원칙입니다. 이것이 여기에서 수행한 작업입니다. 그렇게 함으로써 우리는 원하는 대로 구현을 바꿀 수 있는 자유가 주어집니다. 왜냐하면 그러한 구현은 인터페이스 뒤에 숨겨져 있기 때문입니다(벽 배선이 콘센트 뒤에 숨겨져 있는 것처럼). 따라서 종속성을 사용하는 비즈니스 로직은 인터페이스가 변경되지 않는 한 변경하십시오. UserService
는 해당 기능이 배후에서 지원되는 방식이 아니라 종속성에 의해 제공되는 기능만 알면 됩니다. 이것이 인터페이스를 사용하는 것이 효과적인 이유입니다.
인터페이스를 활용하고 종속성을 주입하는 이 두 가지 간단한 변경은 느슨하게 결합된 소프트웨어를 구축하고 위에서 만난 모든 문제를 해결할 때 세상에서 모든 차이를 만듭니다.
내일 메일에 Mailchimp를 사용하기로 결정했다면 IEmailProvider
인터페이스를 존중하는 새 Mailchimp 클래스를 만들고 SendGrid 대신 삽입하기만 하면 됩니다. 실제 UserService
클래스는 새 이메일 공급자로 전환하여 시스템을 크게 변경한 경우에도 변경할 필요가 없습니다. 이러한 패턴의 장점은 UserService
가 사용하는 종속성이 배후에서 어떻게 작동하는지 전혀 모르고 있다는 것입니다. 인터페이스는 두 구성 요소 간의 아키텍처 경계 역할을 하여 적절하게 분리된 상태를 유지합니다.
또한 테스트와 관련하여 인터페이스를 준수하는 가짜를 만들고 대신 주입할 수 있습니다. 여기에서 가짜 저장소와 가짜 이메일 공급자를 볼 수 있습니다.
// Both fakes: class FakeUserRepository implements IUserRepository { private readonly users: User[] = []; public async addUser(user: User): Promise<void> { this.users.push(user); } public async findUserById(id: string): Promise<User> { const userOrNone = this.users.find(u => u.id === id); return userOrNone ? Promise.resolve(userOrNone) : Promise.reject(new NotFoundError()); } public async existsByEmail(email: string): Promise<boolean> { return Boolean(this.users.find(u => u.email === email)); } public getPersistedUserCount = () => this.users.length; } class FakeEmailProvider implements IEmailProvider { private readonly emailRecipients: string[] = []; public async sendWelcomeEmail(to: string): Promise<void> { this.emailRecipients.push(to); } public wasEmailSentToRecipient = (recipient: string) => Boolean(this.emailRecipients.find(r => r === recipient)); }
두 가짜 모두 UserService
에서 종속성이 존중할 것으로 기대하는 동일한 인터페이스를 구현합니다. 이제 이러한 가짜를 실제 클래스 대신 UserService
에 전달할 수 있으며 UserService
는 더 현명하지 않을 것입니다. 실제 거래인 것처럼 사용할 것입니다. 그렇게 할 수 있는 이유는 종속성에 대해 사용하려는 모든 메서드와 속성이 실제로 존재하고 실제로 액세스할 수 있다는 것을 알고 있기 UserService
입니다(인터페이스를 구현하기 때문에). 종속성이 작동하는 방식).
우리는 테스트 중에 이 두 가지를 주입할 것이고 Jest의 자체 내부 작업으로 오버톱 모의 및 스터빙 라이브러리를 처리할 때 사용했던 것보다 테스트 프로세스를 훨씬 쉽고 간단하게 만들 것입니다. 도구를 사용하거나 원숭이 패치를 시도합니다.
다음은 가짜를 사용한 실제 테스트입니다.
// Fakes let fakeUserRepository: FakeUserRepository; let fakeEmailProvider: FakeEmailProvider; // SUT let userService: UserService; // We want to clean out the internal arrays of both fakes // before each test. beforeEach(() => { fakeUserRepository = new FakeUserRepository(); fakeEmailProvider = new FakeEmailProvider(); userService = new UserService(fakeUserRepository, fakeEmailProvider); }); // A factory to easily create DTOs. // Here, we have the optional choice of overriding the defaults // thanks to the built in `Partial` utility type of TypeScript. function createSeedRegisterUserDto(opts?: Partial<IRegisterUserDto>): IRegisterUserDto { return { id: 'someId', email: '[email protected]', ...opts }; } test('should correctly persist a user and send an email', async () => { // Arrange const dto = createSeedRegisterUserDto(); // Act await userService.registerUser(dto); // Assert const expectedUser = User.fromDto(dto); const persistedUser = await fakeUserRepository.findUserById(dto.id); const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient(dto.email); expect(persistedUser).toEqual(expectedUser); expect(wasEmailSent).toBe(true); }); test('should reject with a DuplicateEmailError if an email already exists', async () => { // Arrange const existingEmail = '[email protected]'; const dto = createSeedRegisterUserDto({ email: existingEmail }); const existingUser = User.fromDto(dto); await fakeUserRepository.addUser(existingUser); // Act, Assert await expect(userService.registerUser(dto)) .rejects.toBeInstanceOf(DuplicateEmailError); expect(fakeUserRepository.getPersistedUserCount()).toBe(1); }); test('should correctly return a user', async () => { // Arrange const user = User.fromDto(createSeedRegisterUserDto()); await fakeUserRepository.addUser(user); // Act const receivedUser = await userService.findUserById(user.id); // Assert expect(receivedUser).toEqual(user); });
여기에서 몇 가지를 알 수 있습니다. 손으로 쓴 가짜는 매우 간단합니다. 난독화에만 사용되는 모의 프레임워크로 인한 복잡성은 없습니다. 모든 것이 수작업으로 이루어지며 이는 코드베이스에 마법이 없다는 것을 의미합니다. 비동기 동작은 인터페이스와 일치하도록 가짜입니다. 모든 동작이 동기적이지만 테스트에서 async/await를 사용합니다. 왜냐하면 실제 세계에서 작업이 작동할 것으로 예상하는 방식과 더 밀접하게 일치하고 async/await를 추가하여 동일한 테스트 제품군을 실행할 수 있기 때문입니다. 가짜 외에도 실제 구현에 대해서도 적절하게 비동기를 처리해야 합니다. 실제로 실제 생활에서는 데이터베이스를 조롱하는 것에 대해 걱정하지 않고 대신 성능을 위해 조롱해야 할 테스트가 너무 많아질 때까지 Docker 컨테이너의 로컬 DB를 사용할 것입니다. 그런 다음 모든 단일 변경 후에 인메모리 DB 테스트를 실행하고 변경 사항을 커밋하기 직전과 CI/CD 파이프라인의 빌드 서버에 대한 실제 로컬 DB 테스트를 예약할 수 있습니다.
첫 번째 테스트의 "정렬" 섹션에서 단순히 DTO를 만듭니다. "act" 섹션에서 테스트 중인 시스템을 호출하고 해당 동작을 실행합니다. 주장을 할 때 상황이 약간 더 복잡해집니다. 테스트의 이 시점에서 우리는 사용자가 올바르게 저장되었는지조차 알지 못합니다. 따라서 우리는 지속 사용자가 어떻게 생겼는지 정의한 다음 가짜 저장소를 호출하고 예상한 ID를 가진 사용자를 요청합니다. UserService
가 사용자를 올바르게 유지하지 않으면 NotFoundError
가 발생하고 테스트가 실패합니다. 그렇지 않으면 사용자에게 반환됩니다. 다음으로 우리는 가짜 이메일 공급자에게 전화를 걸어 해당 사용자에게 이메일을 보내는 것을 녹음했는지 묻습니다. 마지막으로 Jest로 어설션을 만들고 테스트를 마칩니다. 그것은 표현력이 풍부하고 시스템이 실제로 어떻게 작동하는지 처럼 읽습니다. 모의 라이브러리에서 간접 참조가 없으며 UserService
구현에 대한 연결도 없습니다.
두 번째 테스트에서는 기존 사용자를 만들어 저장소에 추가한 다음 사용자를 만들고 유지하는 데 이미 사용된 DTO를 사용하여 서비스를 다시 호출하려고 시도하지만 실패할 것으로 예상합니다. 우리는 또한 저장소에 새로운 데이터가 추가되지 않았다고 주장합니다.
세 번째 테스트의 경우 "정렬" 섹션은 이제 사용자를 생성하고 이를 가짜 리포지토리에 유지하는 것으로 구성됩니다. 그런 다음 SUT를 호출하고 마지막으로 돌아오는 사용자가 이전에 저장소에 저장한 사용자인지 확인합니다.
이러한 예제는 비교적 간단하지만 상황이 더 복잡해지면 이러한 방식으로 종속성 주입 및 인터페이스에 의존할 수 있으므로 코드를 깔끔하게 유지하고 테스트 작성을 즐겁게 만듭니다.
테스트에 대한 간략한 설명: 일반적으로 코드에서 사용하는 모든 종속성을 조롱할 필요는 없습니다. 많은 사람들이 "단위 테스트"의 "단위"가 하나의 함수 또는 하나의 클래스라고 잘못 주장합니다. 이보다 더 정확할 수는 없습니다. "단위"는 하나의 기능이나 클래스가 아니라 "기능의 단위" 또는 "행동의 단위"로 정의됩니다. 따라서 동작 단위가 5개의 다른 클래스를 사용하는 경우 해당 클래스 가 모듈 경계 외부에 도달 하지 않는 한 모든 클래스를 조롱할 필요가 없습니다. 이 경우 데이터베이스를 조롱하고 이메일 제공자를 조롱했습니다. 선택의 여지가 없기 때문입니다. 실제 데이터베이스를 사용하고 싶지 않고 이메일을 보내지 않으려면 이를 조롱해야 합니다. 그러나 네트워크를 통해 아무 것도 하지 않는 클래스가 더 많다면 그것들은 행동 단위의 구현 세부 사항이기 때문에 조롱하지 않을 것입니다. 또한 데이터베이스와 이메일을 조롱하지 않고 Docker 컨테이너에서 실제 로컬 데이터베이스와 실제 SMTP 서버를 가동할 수도 있습니다. 첫 번째 요점에서는 실제 데이터베이스를 사용하는 데 문제가 없으며 너무 느리지 않은 한 여전히 단위 테스트라고 합니다. 일반적으로 위에서 논의한 것처럼 실제 DB가 너무 느려져서 조롱해야 할 때까지 먼저 실제 DB를 사용합니다. 그러나 무엇을 하든지 실용적이어야 합니다. 환영 이메일을 보내는 것은 미션 크리티컬한 작업이 아니므로 Docker 컨테이너의 SMTP 서버 측면에서 그렇게 멀리 갈 필요가 없습니다. 내가 mock을 할 때마다, 아주 드문 경우를 제외하고는 mocking 프레임워크를 사용하거나 호출된 횟수 또는 전달된 매개변수에 대해 주장하려고 하지 않을 것입니다. 이러한 세부 사항에 불가지론적이어야 합니다.
클래스 및 생성자 없이 종속성 주입 수행
지금까지 기사 전체에서 우리는 독점적으로 클래스를 사용하고 생성자를 통해 종속성을 주입했습니다. 개발에 대한 기능적 접근 방식을 취하고 클래스를 사용하지 않으려는 경우에도 함수 인수를 사용하여 종속성 주입의 이점을 얻을 수 있습니다. 예를 들어 위의 UserService
클래스는 다음과 같이 리팩토링될 수 있습니다.
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
종속성을 수신하고 서비스 객체를 구성하는 공장입니다. 고차 함수에 종속성을 주입할 수도 있습니다. 일반적인 예는 UserRepository
와 ILogger
를 주입하는 Express Middleware 함수를 만드는 것입니다.
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
첫 번째 예에서는 dto
및 id
유형을 정의하지 않았습니다. 서비스에 대한 메서드 서명을 포함하는 IUserService
라는 인터페이스를 정의하면 TS 컴파일러가 자동으로 유형을 유추하기 때문입니다. 마찬가지로 Express Middleware에 대한 함수 서명을 authProvider
의 반환 유형으로 정의했다면 거기에서 인수 유형을 선언할 필요도 없었을 것입니다.
이메일 공급자와 저장소도 기능적이라고 생각하고 하드 코딩하는 대신 특정 종속성도 주입하면 애플리케이션의 루트는 다음과 같을 수 있습니다.
import { sendMail } from 'sendgrid'; async function main() { const app = express(); const dbConnection = await connectToDatabase(); // Change emailProvider to `makeMailChimpEmailProvider` whenever we want // with no changes made to dependent code. const userRepository = makeUserRepository(dbConnection); const emailProvider = makeSendGridEmailProvider(sendMail); const userService = makeUserService(userRepository, emailProvider); // Put this into another file. It's a controller action. app.post('/login', (req, res) => { await userService.registerUser(req.body as IRegisterUserDto); return res.send(); }); // Put this into another file. It's a controller action. app.delete( '/me', authProvider(userRepository, emailProvider), (req, res) => { ... } ); }
Notice that we fetch the dependencies that we need, like a database connection or third-party library functions, and then we utilize factories to make our first-party dependencies using the third-party ones. We then pass them into the dependent code. Since everything is coded against abstractions, I can swap out either userRepository
or emailProvider
to be any different function or class with any implementation I want (that still implements the interface correctly) and UserService
will just use it with no changes needed, which, once again, is because UserService
cares about nothing but the public interface of the dependencies, not how the dependencies work.
As a disclaimer, I want to point out a few things. As stated earlier, this demo was optimized for showing how dependency injection makes life easier, and thus it wasn't optimized in terms of system design best practices insofar as the patterns surrounding how Repositories and DTOs should technically be used. In real life, one has to deal with managing transactions across repositories and the DTO should generally not be passed into service methods, but rather mapped in the controller to allow the presentation layer to evolve separately from the application layer. The userSerivce.findById
method here also neglects to map the User domain object to a DTO, which it should do in real life. None of this affects the DI implementation though, I simply wanted to keep the focus on the benefits of DI itself, not Repository design, Unit of Work management, or DTOs. Finally, although this may look a little like the NestJS framework in terms of the manner of doing things, it's not, and I actively discourage people from using NestJS for reasons outside the scope of this article.
A Brief Theoretical Overview
All applications are made up of collaborating components, and the manner in which those collaborators collaborate and are managed will decide how much the application will resist refactoring, resist change, and resist testing. Dependency injection mixed with coding against interfaces is a primary method (among others) of reducing the coupling of collaborators within systems, and making them easily swappable. This is the hallmark of a highly cohesive and loosely coupled design.
The individual components that make up applications in non-trivial systems must be decoupled if we want the system to be maintainable, and the way we achieve that level of decoupling, as stated above, is by depending upon abstractions, in this case, interfaces, rather than concrete implementations, and utilizing dependency injection. Doing so provides loose coupling and gives us the freedom of swapping out implementations without needing to make any changes on the side of the dependent component/collaborator and solves the problem that dependent code has no business managing the lifetime of its dependencies and shouldn't know how to create them or dispose of them. This doesn't mean that everything should be injected and no collaborators should ever be directly coupled to each other. There are certainly many cases where having that direct coupling is no problem at all, such as with utilities, mappers, models, and more.
Despite the simplicity of what we've seen thus far, there's a lot more complexity that surrounds dependency injection.
Injection of dependencies can come in many forms. Constructor Injection is what we have been using here since dependencies are injected into a constructor. There also exists Setter Injection and Interface Injection. In the case of the former, the dependent component will expose a setter method which will be used to inject the dependency — that is, it could expose a method like setUserRepository(userRepository: UserRepository)
. In the last case, we can define interfaces through which to perform the injection, but I'll omit the explanation of the last technique here for brevity since we'll spend more time discussing it and more in the second article of this series.
Because wiring up dependencies manually can be difficult, various IoC Frameworks and Containers exist. These containers store your dependencies and resolve the correct ones at runtime, often through Reflection in languages like C# or Java, exposing various configuration options for dependency lifetime. Despite the benefits that IoC Containers provide, there are cases to be made for moving away from them, and only resolving dependencies manually. To hear more about this, see Greg Young's 8 Lines of Code talk.
Additionally, DI Frameworks and IoC Containers can provide too many options, and many rely on decorators or attributes to perform techniques such as setter or field injection. I look down on this kind of approach because, if you think about it intuitively, the point of dependency injection is to achieve loose coupling, but if you begin to sprinkle IoC Container-specific decorators all over your business logic, while you may have achieved decoupling from the dependency, you've inadvertently coupled yourself to the IoC Container. IoC Containers like Awilix by Jeff Hansen solve this problem since they remain divorced from your application's business logic.
결론
This article served to depict only a very practical example of dependency injection in use and mostly neglected the theoretical attributes. I did it this way in order to make it easier to understand what dependency injection is at its core in a manner divorced from the rest of the complexity that people usually associate with the concept.
In the second article of this series, we'll take a much, much more in-depth look, including at:
- The difference between Dependency Injection and Dependency Inversion and Inversion of Control;
- Dependency Injection anti-patterns;
- IoC Container anti-patterns;
- The role of IoC Containers;
- The different types of dependency lifetimes;
- How IoC Containers are designed;
- Dependency Injection with React;
- Advanced testing scenarios;
- 그리고 더.
계속 지켜봐 주세요!