의존성 주입이 코드를 더 쉽게 테스트하고 리팩터링하며 확장하게 만드는 방법을 알아보세요. 실용적 패턴, 예제, 피해야 할 흔한 실수를 다룹니다.

의존성 주입(DI)은 단순한 아이디어입니다: 코드가 필요로 하는 것을 스스로 만들지 말고 외부에서 주입해 주세요.
그 ‘필요한 것’들은 의존성입니다—예를 들어 데이터베이스 연결, 결제 서비스, 시계, 로거, 이메일 전송기 등이 있습니다. 코드가 이런 의존성을 직접 생성하면, 어떻게 동작하는지를 고정해버립니다.
사무실의 커피 머신을 생각해 보세요. 물, 커피 원두, 전기가 필요하죠.
DI는 두 번째 접근입니다: “커피 머신”(여러분의 클래스/함수)은 커피를 만들고, “공급물”(의존성)은 설정하는 쪽에서 제공합니다.
DI는 특정 프레임워크를 써야 한다는 뜻이 아니고, DI 컨테이너와 동일한 것도 아닙니다. 생성자나 파라미터로 수동으로 의존성을 전달하는 것만으로도 DI를 구현할 수 있습니다.
또한 DI는 ‘모킹’ 자체가 아닙니다. 모킹은 테스트에서 DI를 활용하는 한 방법일 뿐, DI 자체는 어디서 의존성을 생성할지에 대한 설계 선택입니다.
의존성이 외부에서 주입되면 코드가 다양한 컨텍스트(프로덕션, 단위 테스트, 데모, 향후 기능)에서 더 쉽게 실행됩니다.
그 유연성은 모듈을 더 깔끔하게 만듭니다: 부분을 바꾸더라도 시스템 전체를 다시 배선할 필요가 없습니다. 그 결과 테스트는 더 빠르고 명확해지고(단순한 대체물을 넣을 수 있으므로), 코드베이스는 변경하기 쉬워집니다(부품 간 얽힘이 줄어들기 때문에).
강한 결합은 한 부분의 코드가 직접적으로 어떤 다른 부분을 사용해야 할지 결정할 때 발생합니다. 가장 흔한 형태는 비즈니스 로직 안에서 new로 인스턴스를 생성하는 것입니다.
체크아웃 함수가 내부에서 new StripeClient()와 new SmtpEmailSender()를 호출한다고 상상해 보세요. 처음에는 편리해 보이지만 체크아웃 흐름을 그 구현들에 고정해 버립니다—구성 세부사항, 생성 규칙(API 키, 타임아웃, 네트워크 동작)까지 모두요.
그 결합은 메서드 시그니처만 봐서는 드러나지 않기 때문에 ‘숨겨진’ 결합입니다. 함수는 단지 주문을 처리하는 것처럼 보이지만 실제로는 결제 게이트웨이, 이메일 제공자, 어쩌면 데이터베이스 연결까지 암암리에 의존하고 있습니다.
의존성이 하드코딩되어 있으면 작은 변경도 연쇄적으로 영향을 미칩니다:
하드코딩된 의존성 때문에 단위 테스트가 실제 작업(네트워크 호출, 파일 I/O, 시계, 랜덤 ID, 공유 자원)을 수행하면 테스트는 느려지고 불안정해집니다. 타이밍, 외부 서비스, 실행 순서에 따라 결과가 달라지기 때문입니다.
다음 패턴이 보이면 강한 결합이 이미 비용을 발생시키고 있을 가능성이 큽니다:
new의존성 주입은 의존성을 명시적이고 교체 가능하게 만들어 이런 문제를 해결합니다—비즈니스 규칙을 매번 다시 쓰지 않고도 말이죠.
제어의 역전(IoC)은 책임을 바꾸는 간단한 전환입니다: 클래스는 무엇을 해야 하는지에 집중하고 그것을 얻는 방법에 관여하지 않아야 합니다.
클래스가 스스로 의존성을 생성하면(예: new EmailService() 또는 직접 DB 연결을 여는 경우) 비즈니스 로직과 설정이라는 두 가지 일을 동시에 떠맡게 됩니다. 이는 클래스를 변경하기 어렵게 하고 재사용과 테스트를 복잡하게 만듭니다.
IoC에서는 코드가 특정 구현이 아닌 추상화(인터페이스나 작은 계약 타입)에 의존합니다.
예를 들어 CheckoutService는 결제가 Stripe인지 PayPal인지 또는 테스트용 페이크인지 알 필요가 없습니다. 다만 “카드를 청구할 수 있는 무언가”가 필요합니다. CheckoutService가 IPaymentProcessor를 받으면 해당 계약을 따르는 어떤 구현과도 작동할 수 있습니다.
이렇게 하면 기본 도구가 바뀌어도 핵심 로직은 안정적으로 유지됩니다.
IoC의 실무적 부분은 의존성 생성을 클래스 밖으로 옮기고 주입하는 것입니다(종종 생성자를 통해). 여기서 DI가 역할을 합니다.
대신에:
이제는:
결과는 유연성입니다: 동작의 교체가 구성 결정이지 재작성 작업이 아닙니다.
클래스가 의존성을 생성하지 않으려면 누군가가 대신 만들어야 합니다. 그 ‘누군가’가 바로 컴포지션 루트입니다: 애플리케이션이 조립되는 곳—보통 시작 코드입니다.
컴포지션 루트에서 "프로덕션에서는 RealPaymentProcessor를, 테스트에서는 FakePaymentProcessor를 사용한다"라고 결정합니다. 와이어링을 한 곳에 모아두면 놀라움이 줄고 코드베이스는 동작에만 집중할 수 있습니다.
IoC는 단위 테스트를 단순하게 만듭니다. 네트워크나 데이터베이스 대신 작고 빠른 테스트 더블을 제공할 수 있기 때문입니다.
또한 책임이 분리되면 구현을 바꿔도 그것을 사용하는 클래스까지 바꿀 필요가 거의 없습니다—추상화가 유지되는 한 말이죠.
DI는 하나의 기법이 아니라 클래스에 의존성을 “공급”하는 여러 방식의 집합입니다(로거, DB 클라이언트, 결제 게이트웨이 등). 어떤 스타일을 선택하느냐에 따라 명확성, 테스트 용이성, 오용하기 쉬운 정도가 달라집니다.
생성자 주입은 객체를 만들 때 의존성이 필수입니다. 큰 장점은 의존성을 깜빡하지 못한다는 점입니다.
다음 경우에 적합합니다:
생성자 주입은 가장 명료한 코드와 단순한 단위 테스트를 만드는 경향이 있습니다. 테스트 시 생성 시점에 페이크나 목을 전달하면 되니까요.
어떤 의존성은 단일 작업에서만 필요할 수 있습니다—예: 임시 포매터, 특수 전략, 요청 범위 값.
그럴 때는 메서드 인자로 전달하세요. 객체를 더 작게 유지하고 일회성 필요를 영구 필드로 승격시키지 않도록 합니다.
세터 주입은 생성 시 제공할 수 없을 때 편리합니다(일부 프레임워크나 레거시 경로에서). 그러나 요구사항을 숨길 수 있어 클래스가 완전히 구성되지 않은 상태로도 사용 가능한 것처럼 보일 수 있습니다.
이로 인해 런타임 이슈(‘이 값이 undefined인데 왜 그럴까?’)가 생기고 테스트 설정을 빼먹기 쉬워 취약해질 수 있습니다.
단위 테스트는 빠르고, 반복 가능하며, 한 동작에 집중할 때 가장 유용합니다. 테스트가 실제 DB, 네트워크, 파일 시스템, 시계에 의존하기 시작하면 느려지고 불안정해지며 실패 원인이 모호해집니다.
DI는 코드가 필요로 하는 것들(데이터 접근, HTTP 클라이언트, 시간 제공자)을 외부에서 받게 해 테스트에서 가벼운 대체물을 주입할 수 있게 합니다.
실제 DB나 API 호출은 설정 시간과 대기 시간을 추가합니다. DI를 사용하면 즉시 준비된 응답을 반환하는 인메모리 저장소나 페이크 클라이언트를 주입할 수 있습니다. 덕분에:
DI가 없으면 코드가 자체적으로 의존성을 만드는 바람에 테스트는 전체 스택을 실행해야 합니다. DI를 사용하면 다음을 주입할 수 있습니다:
글로벌 스위치나 해킹 없이 단지 다른 구현을 전달하면 됩니다.
DI는 설정을 명시적으로 만듭니다. 구성, 연결 문자열, 테스트 전용 환경 변수를 뒤질 필요 없이 테스트를 읽으면 어떤 것이 실사용이고 무엇이 대체인지 바로 알 수 있습니다.
전형적인 DI 친화적 테스트는 이렇게 읽힙니다:
그 단순함이 노이즈를 줄이고 실패 원인을 찾기 쉽게 만듭니다.
테스트 심은 코드에서 동작을 서로 교체할 수 있게 만든 의도적인 ‘틈’입니다. 프로덕션에서는 실제 것을 꽂고, 테스트에서는 빠르고 안전한 대체물을 꽂습니다. DI는 이런 심을 만드는 가장 간단한 수단 중 하나입니다.
심은 테스트에서 제어하기 어려운 부분 주위에 놓는 것이 가장 유용합니다:
비즈니스 로직이 이러한 것들을 직접 호출하면 테스트는 잘못된 이유로 실패하기 쉽습니다(네트워크 문제, 타임존 차이, 파일 누락 등).
심은 대부분 인터페이스 형태를 취하거나 동적 언어에서는 now() 같은 메서드가 있는 계약 형태를 취합니다. 핵심은 무엇이 필요한지에 의존하고 그것이 어디서 오는지에 의존하지 않는 것입니다.
예: 주문 서비스 안에서 시스템 시계를 직접 호출하는 대신 Clock에 의존하게 하면
SystemClock.now()FakeClock.now()(고정된 시간 반환)파일 읽기(FileStore), 이메일 전송(Mailer), 카드 결제(PaymentGateway)에도 같은 패턴을 적용할 수 있습니다. 핵심 로직은 동일하게 유지되고 꽂힌 구현만 바뀝니다.
동작을 의도적으로 교체할 수 있을 때:
잘 배치된 심은 곳곳에서 과도한 모킹을 줄여주고 몇 개의 깔끔한 교체 지점만으로 단위 테스트를 빠르고 집중적이며 예측 가능하게 만듭니다.
모듈성은 소프트웨어가 독립된 부분들(모듈)로 구성되고 각 모듈이 명확한 책임과 상호작용 방식을 갖는다는 개념입니다.
DI는 그 경계를 명확히 함으로써 이를 지원합니다. 모듈이 필요한 것을 ‘찾아내는’ 대신 외부에서 받으면 한 모듈이 다른 모듈에 대해 알 필요가 줄어듭니다.
코드가 내부에서 의존성을 생성하면(예: 서비스 안에서 DB 클라이언트를 new 하는 경우) 호출자와 의존성이 강하게 묶입니다. DI는 인터페이스(혹은 단순 계약)에 의존하도록 권장합니다.
그 결과 모듈은 보통 다음만 알면 됩니다:
PaymentGateway.charge())모듈 간 내부 세부가 누수되지 않으면 함께 변경해야 할 일이 줄어듭니다.
모듈형 코드베이스에서는 한 컴포넌트를 교체할 때 호출자들을 전부 고치지 않아야 합니다. DI는 이를 현실적으로 만듭니다:
이럴 때 호출자들은 같은 계약을 계속 사용합니다. 와이어링은 한 곳(컴포지션 루트)에서 바뀝니다.
명확한 의존성 경계는 팀들이 병렬로 작업하기 쉽게 만듭니다. 한 팀은 합의된 인터페이스 뒤에 새로운 구현을 만들고 다른 팀은 그 인터페이스에 의존한 기능을 계속 개발할 수 있습니다.
DI는 점진적 리팩터링도 지원합니다: 모듈을 추출하고 주입해가며 점진적으로 교체할 수 있습니다.
코드로 DI를 보면 이해가 더 빨라집니다. 알림 기능을 아주 작게 Before/After로 보겠습니다.
클래스가 내부에서 new를 호출하면 어떤 구현을 쓸지, 어떻게 생성할지를 스스로 결정합니다.
class EmailService {
send(to, message) {
// talks to real SMTP provider
}
}
class WelcomeNotifier {
notify(user) {
const email = new EmailService();
email.send(user.email, "Welcome!");
}
}
테스트의 고통: 단위 테스트가 실제 이메일 동작을 촉발할 위험이 있거나 전역 스텁을 써야만 검증할 수 있습니다.
test("sends welcome email", () => {
const notifier = new WelcomeNotifier();
notifier.notify({ email: "[email protected]" });
// Hard to assert without patching EmailService globally
});
이제 WelcomeNotifier는 필요한 동작을 갖춘 아무 객체나 받을 수 있습니다.
class WelcomeNotifier {
constructor(emailService) {
this.emailService = emailService;
}
notify(user) {
this.emailService.send(user.email, "Welcome!");
}
}
테스트는 작고 빠르며 명시적입니다.
test("sends welcome email", () => {
const fakeEmail = { send: vi.fn() };
const notifier = new WelcomeNotifier(fakeEmail);
notifier.notify({ email: "[email protected]" });
expect(fakeEmail.send).toHaveBeenCalledWith("[email protected]", "Welcome!");
});
나중에 SMS를 추가하고 싶다면 WelcomeNotifier를 수정할 필요가 없습니다. 다른 구현을 전달하면 됩니다:
const smsService = { send: (to, msg) => {/* SMS provider */} };
const notifier = new WelcomeNotifier(smsService);
실용적 이득은 이렇습니다: 테스트는 생성 세부사항과 싸우지 않게 되고, 새 동작은 의존성 교체로 추가됩니다.
DI는 단지 "필요한 것을 받아 사용하는 것"일 수 있습니다(수동 DI). DI 컨테이너는 그 와이어링을 자동화하는 도구입니다. 둘 다 적절할 수 있으며 핵심은 자동화 수준이 앱 규모에 맞는지 고르는 것입니다.
수동 DI에서는 객체를 직접 만들고 생성자나 파라미터로 의존성을 전달합니다:
수동 와이어링은 또한 좋은 설계 습관을 강제합니다. 객체가 7개의 의존성을 요구하면 즉시 고통을 느끼게 되어 책임 분리를 고려하게 됩니다.
컴포넌트 수가 늘면 수동 와이어링이 반복적인 ‘배관 작업’이 될 수 있습니다. DI 컨테이너는 다음을 도와줍니다:
컨테이너는 경계와 라이프사이클이 명확한 웹 앱, 장기 실행 서비스 등에 적합합니다.
컨테이너는 와이어링을 사라지게 만들어 결합도가 높은 설계를 깔끔해 보이게 할 수 있습니다. 하지만 근본 문제는 남아 있습니다:
컨테이너를 추가해 코드 가독성이 떨어지거나 개발자가 무슨 의존성이 있는지 모르게 되면 지나친 자동화입니다.
모듈을 다듬는 동안은 수동 DI로 시작해 명확성을 유지하고, 와이어링이 반복적이거나 라이프사이클 관리가 복잡해지면 컨테이너를 추가하세요.
실용 규칙: 핵심/비즈니스 코드는 수동 DI를 유지하고, 앱 경계(컴포지션 루트)에 컨테이너를 선택적으로 사용하세요. 이렇게 하면 설계는 명확하게 유지하면서도 보일러플레이트를 줄일 수 있습니다.
DI는 제대로 쓰이면 코드를 더 테스트하기 쉽고 변경하기 쉽게 만들지만, 규율 없이 사용하면 오히려 해가 됩니다. 흔한 실패 방식과 이를 피하는 습관을 정리합니다.
클래스가 많은 의존성을 필요로 하면 보통 너무 많은 일을 하고 있는 신호입니다. 이는 DI의 실패가 아니라 DI가 드러낸 설계 냄새입니다.
실용적 규칙: 클래스의 역할을 한 문장으로 설명할 수 없거나 생성자가 계속 늘면 클래스를 분리하거나 작은 협력자를 추출하거나 관련 작업을 묶은 인터페이스를 만들되(주의해서) “갓 서비스”를 만들지 마세요.
비즈니스 코드 내부에서 container.get(Foo)를 호출하는 서비스 로케이터 패턴은 편리하게 느껴지지만 의존성을 숨깁니다. 생성자를 읽어봐도 무엇이 필요한지 알 수 없게 됩니다.
테스트는 전역 상태(로케이터)를 설정해야 하므로 더 어려워집니다. 명시적 전달(생성자 주입)을 선호하세요.
DI 컨테이너는 다음 상황에서 런타임에 실패할 수 있습니다:
이 문제들은 와이어링이 실행될 때만 드러나기 때문에 짜증스럽습니다.
생성자를 작고 집중적으로 유지하세요. 의존성 목록이 늘면 리팩터링 신호로 받아들입니다.
와이어링에 대한 통합 테스트를 추가하세요. 컴포지션 루트를 실제로 빌드해 보는 가벼운 테스트는 누락된 등록과 순환을 조기에 잡을 수 있습니다.
마지막으로 객체 생성을 한 곳(보통 앱 시작/컴포지션 루트)에 모으고 비즈니스 로직에서 컨테이너 호출을 피하세요. 이렇게 하면 DI의 핵심 이점—무엇이 무엇에 의존하는지에 대한 명확성—을 유지할 수 있습니다.
DI는 작은, 저위험 리팩터링의 연속으로 도입하는 것이 가장 쉽습니다. 테스트가 느리거나 불안정한 곳, 자주 변경이 전파되는 곳부터 시작하세요.
다음 의존성들은 DI를 적용하면 큰 효과를 줍니다:
프로세스 외부에 도달해야만 실행되는 함수는 보통 좋은 도입 후보입니다.
new로 생성되거나 직접 호출되는 외부 의존성 하나를 고릅니다.이 접근법은 각 변경을 검토 가능하게 만들고 어느 단계에서든 멈춰 시스템을 깨뜨리지 않고 진행할 수 있게 합니다.
DI는 잘못 사용하면 “모든 것이 모든 것에 의존”하는 결과를 낳을 수 있습니다. 그러지 않으려면:
Clock을 주입하고 "SystemTime + TimeZoneResolver + NtpClient" 같은 세부들을 주입하지 마세요.코드 생성기나 빠른 기능 생성 워크플로(예: 채팅 기반 스펙에서 React, Go, PostgreSQL 코드를 생성하는 도구)를 사용하는 경우 DI는 프로젝트가 커져도 구조를 유지하는 데 특히 유용합니다. 명확한 컴포지션 루트와 DI 친화적 인터페이스는 생성된 코드가 테스트, 리팩터링, 통합 교체(이메일, 결제, 저장소) 작업 없이 핵심 비즈니스 로직을 재작성하지 않도록 돕습니다.
규칙은 동일합니다: 객체 생성과 환경별 와이어링은 경계에 두고 비즈니스 코드는 동작에 집중하게 하세요.
구체적인 개선점을 가리킬 수 있어야 합니다:
다음 단계로는 컴포지션 루트를 문서화하고 한 파일에서 와이어링을 유지하는 것을 권합니다. 나머지 코드는 동작에만 집중하게 하세요.
의존성 주입(DI)은 코드가 필요로 하는 것들(데이터베이스, 로거, 시계, 결제 클라이언트 등)을 내부에서 생성하는 대신 외부에서 받는다는 뜻입니다.
실무에서는 보통 생성자나 함수 인자로 의존성을 전달해 명시적이고 교체 가능하게 만드는 방식으로 구현합니다.
제어의 역전(IoC)은 더 넓은 개념으로, 클래스는 무엇을 하는지에 집중하고 협력자를 어떻게 얻는지에 대해 신경 쓰지 말아야 한다는 생각입니다.
DI는 의존성 생성을 외부로 옮기고 의존성을 주입함으로써 IoC를 실현하는 일반적인 기법입니다.
new로 의존성을 생성하면 그 구현에 강하게 묶이게 되어 교체가 어려워집니다.
결과적으로:
DI는 테스트 더블을 주입할 수 있게 해 테스트를 빠르고 결정적으로 유지합니다. 실제 외부 시스템 대신 대체 구현을 넣을 수 있기 때문입니다.
일반적인 교체:
DI 컨테이너는 선택사항입니다. 다음과 같은 경우에는 수동 DI(명시적 의존성 전달)로 시작하세요:
와이어링이 반복적이거나 라이프사이클(싱글턴/요청별 등) 관리가 필요해지면 컨테이너를 고려하세요.
생성자 주입: 객체가 제대로 동작하려면 항상 필요하고 여러 메서드에서 공유되는 의존성에 사용하세요.
메서드/파라미터 주입: 단 한번의 호출에서만 필요한 경우 적합합니다(예: 요청 범위 값이나 특수 전략).
세터/프로퍼티 주입: 정말로 늦게 연결해야 하는 경우에만 사용하고, 누락 시 즉시 실패하도록 검증을 추가하세요.
컴포지션 루트는 애플리케이션을 조립하는 장소입니다: 구현을 생성하고 필요한 서비스에 전달하는 곳입니다.
보통 앱 시작점(엔트리포인트) 근처에 두어 나머지 코드가 동작에 집중하도록 하세요.
테스트 심(test seam)은 의도적으로 동작을 교체할 수 있게 만든 지점입니다.
테스트에서 실사용 대신 대체 구현을 주입하면 됩니다.
심을 두기 좋은 곳:
Clock.now())DI는 이러한 심을 간단하게 만듭니다.
흔한 실수들:
container.get()을 호출하면 실제 의존성이 숨겨져 버립니다. 생성자 등으로 명시적으로 전달하세요.안전하게 도입하는 방법(반복 가능한 리팩터 패턴):
각 단계를 검토 가능한 작은 변경으로 만들면 언제든 중지할 수 있고 대대적 리팩터 없이 진행할 수 있습니다.