NestJS 없이 Express에서 기본 종속성 주입 컨테이너 구축하기
Wenhao Wang
Dev Intern · Leapcell

소개
웹 개발의 세계에서 깨끗하고, 테스트 가능하며, 확장 가능한 코드베이스를 유지하는 것은 매우 중요합니다. 애플리케이션의 복잡성이 증가함에 따라 종속성 관리는 상당한 어려움이 될 수 있습니다. 긴밀하게 결합된 구성 요소는 취약한 코드로 이어져 리팩토링을 악몽으로 만들고 단위 테스트를 힘든 작업으로 만듭니다. 종속성 주입(DI)은 이러한 문제를 해결하고 느슨한 결합을 촉진하며 모듈성을 향상시키는 강력한 디자인 패턴으로 등장합니다. NestJS와 같은 프레임워크는 기본적으로 강력하고 의견이 있는 DI 컨테이너를 제공하지만, 많은 기존 또는 소규모 Express.js 프로젝트에서는 완전한 프레임워크를 재구성할 필요가 없을 수 있습니다. 이 문서는 완전한 프레임워크의 오버헤드 없이 코드 구성 및 테스트 가능성을 향상시키기 위해 JavaScript의 동적 특성을 활용하여 Express 애플리케이션 내에서 직접 간단하면서도 효과적인 종속성 주입 컨테이너를 수동으로 구현하는 방법을 탐색합니다.
종속성 주입 이해하기
구현에 들어가기 전에 관련 핵심 개념에 대한 명확한 이해를 확립해야 합니다.
- 종속성 주입 (DI): 구성 요소가 자체적으로 생성하는 대신 외부 소스에서 종속성을 받는 디자인 패턴입니다. 이 "제어 역전"은 구성 요소를 더 독립적이고 재사용 가능하게 만듭니다.
 - IoC 컨테이너 (제어 역전 컨테이너): 구성 요소의 수명 주기와 종속성을 관리하는 프레임워크 또는 메커니즘입니다. 객체를 인스턴스화하고 필요한 종속성을 주입합니다. 이 경우 매우 기본적인 버전을 구축할 것입니다.
 - 서비스: 특정 작업을 수행하고 종종 다른 서비스에 의존하는 클래스 또는 함수입니다. 서비스는 종속성 주입의 주요 후보입니다.
 - 프로바이더: IoC 컨테이너에 특정 종속성의 인스턴스를 생성하는 방법을 알려주는 메커니즘입니다. 생성자 함수, 팩토리 함수 또는 기존 인스턴스일 수 있습니다.
 
DI의 핵심 원칙은 모듈이 자체 종속성을 인스턴스화하는 것이 아니라 선언해야 한다는 것입니다. 외부 개체(컨테이너)는 런타임에 이러한 종속성을 "주입"합니다. 이는 여러 이점을 가져옵니다. 테스트 가능성 향상(종속성을 쉽게 모의할 수 있음), 결합 감소, 유지 관리성 향상.
간단한 DI 컨테이너 구축하기
간단한 DI 컨테이너는 등록된 서비스를 등록하고, 서비스 및 해당 종속성을 해결하고, 선택적으로 싱글톤을 관리하는 몇 가지 핵심 기능에 중점을 둘 것입니다.
컨테이너 클래스
먼저 등록된 서비스를 보유하고 서비스를 등록 및 해결하는 메서드를 제공하는 Container 클래스를 만듭니다.
// container.js class Container { constructor() { this.services = new Map(); } /** * 컨테이너에 서비스를 등록합니다. * @param {string} name - 서비스의 고유한 이름입니다. * @param {class | Function} ServiceProvider - 서비스의 클래스 생성자 또는 팩토리 함수입니다. * @param {Array<string>} dependencies - 이 서비스가 의존하는 서비스 이름 배열입니다. * @param {boolean} isSingleton - 이 서비스가 싱글톤이어야 하는지 여부입니다. */ register(name, ServiceProvider, dependencies = [], isSingleton = false) { if (this.services.has(name)) { console.warn(`Service "${name}" is being re-registered.`); } this.services.set(name, { provider: ServiceProvider, dependencies: dependencies, isSingleton: isSingleton, instance: null // 싱글톤 인스턴스를 저장할 위치 }); } /** * 요청된 서비스의 인스턴스를 해결하고 반환합니다. * @param {string} name - 해결할 서비스의 이름입니다. * @returns {any} 서비스의 인스턴스입니다. * @throws {Error} 서비스 또는 해당 종속성을 해결할 수 없는 경우. */ resolve(name) { const serviceEntry = this.services.get(name); if (!serviceEntry) { throw new Error(`Service "${name}" not found.`); } if (serviceEntry.isSingleton && serviceEntry.instance) { return serviceEntry.instance; // 기존 싱글톤 인스턴스 반환 } const resolvedDependencies = serviceEntry.dependencies.map(depName => { // 종속성을 재귀적으로 해결 return this.resolve(depName); }); let serviceInstance; if (typeof serviceEntry.provider === 'function') { // 클래스 생성자인지 일반 함수인지 확인 try { // 클래스로 인스턴스화 시도 serviceInstance = new serviceEntry.provider(...resolvedDependencies); } catch (e) { // 일반 함수이거나 클래스 인스턴스화가 실패한 경우(예: 팩토리 함수를 new로 호출) // 팩토리 함수로 처리 serviceInstance = serviceEntry.provider(...resolvedDependencies); } } else { // provider가 함수가 아닌 경우, 미리 인스턴스화된 개체(직접 값)로 가정 serviceInstance = serviceEntry.provider; } if (serviceEntry.isSingleton) { serviceEntry.instance = serviceInstance; // 싱글톤 인스턴스 저장 } return serviceInstance; } } const container = new Container(); module.exports = container;
예제 서비스
LoggerService와 LoggerService 및 EmailService에 의존하는 UserService가 있다고 가정해 보겠습니다.
// services/LoggerService.js class LoggerService { log(message) { console.log(`[LOG] ${message}`); } error(message) { console.error(`[ERROR] ${message}`); } } module.exports = LoggerService; // services/EmailService.js class EmailService { send(to, subject, body) { console.log(`Sending email to ${to} with subject "${subject}" and body: ${body}`); // 실제 앱에서는 이메일 전송 API와 통합될 것입니다. return true; } } module.exports = EmailService; // services/UserService.js class UserService { constructor(loggerService, emailService) { this.logger = loggerService; this.emailService = emailService; } createUser(name, email) { this.logger.log(`Attempting to create user: ${name}`); // ... DB에 사용자 저장 로직 ... const user = { id: Date.now(), name, email }; this.emailService.send(email, 'Welcome!', `Hello ${name}, welcome to our service!`); this.logger.log(`User ${name} created successfully.`); return user; } getUser(id) { this.logger.log(`Fetching user with ID: ${id}`); // ... DB에서 사용자 가져오기 로직 ... return { id, name: "John Doe", email: "john.doe@example.com" }; } } module.exports = UserService;
서비스 등록
이제 이러한 서비스를 container에 등록해야 합니다. 이는 일반적으로 애플리케이션의 부트스트랩 단계에서 수행됩니다.
// app.js (또는 초기화 파일) const container = require('./container'); const LoggerService = require('./services/LoggerService'); const EmailService = require('./services/EmailService'); const UserService = require('./services/UserService'); // LoggerService를 싱글톤으로 등록 container.register('LoggerService', LoggerService, [], true); // EmailService를 싱글톤으로 등록 container.register('EmailService', EmailService, [], true); // UserService 등록, 종속성 지정 container.register('UserService', UserService, ['LoggerService', 'EmailService']); // 미리 인스턴스화된 개체 또는 팩토리 함수를 등록할 수도 있습니다. container.register('Config', { database: 'mongodb://localhost/mydb', port: 3000 }, [], true); container.register('DatabaseConnection', (config) => { console.log(`Connecting to database: ${config.database}`); return { query: (sql) => console.log(`Executing SQL: ${sql} on ${config.database}`) }; }, ['Config'], true);
Express.js와 통합
Express.js에서의 주요 과제는 일반적으로 Express에 의해 직접 호출되는 라우트 핸들러나 미들웨어 내에서 이 컨테이너를 활용하는 방법입니다. 일반적인 패턴은 라우트 핸들러 내에서 컨테이너에서 필요한 서비스를 검색하는 것입니다.
// server.js const express = require('express'); const app = express(); const container = require('./container'); // 주입 컨테이너 초기화 // 서비스 초기화 (일반적으로 app.js 또는 인덱스 파일에 있음) require('./app'); // 이 파일은 모든 서비스를 컨테이너에 등록합니다. app.use(express.json()); // 주입된 서비스를 사용하는 예제 라우트 app.post('/users', (req, res) => { try { const userService = container.resolve('UserService'); const { name, email } = req.body; if (!name || !email) { return res.status(400).send('Name and email are required.'); } const newUser = userService.createUser(name, email); res.status(201).json(newUser); } catch (error) { const logger = container.resolve('LoggerService'); logger.error(`Error creating user: ${error.message}`); res.status(500).send('Internal server error.'); } }); app.get('/users/:id', (req, res) => { try { const userService = container.resolve('UserService'); const logger = container.resolve('LoggerService'); const userId = req.params.id; logger.log(`Received request to get user by ID: ${userId}`); const user = userService.getUser(userId); if (!user) { return res.status(404).send('User not found.'); } res.json(user); } catch (error) { const logger = container.resolve('LoggerService'); logger.error(`Error fetching user: ${error.message}`); res.status(500).send('Internal server error.'); } }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { const logger = container.resolve('LoggerService'); logger.log(`Server running on port ${PORT}`); });
애플리케이션 시나리오
이 수동 DI 컨테이너는 여러 시나리오에서 특히 유용합니다.
- 기존 Express 프로젝트: 완전한 프레임워크 마이그레이션 없이 더 나은 종속성 관리를 도입하고 싶을 때.
 - 작은 마이크로서비스: 전체 프레임워크의 오버헤드가 서비스의 복잡성에 비해 과도할 수 있는 경우.
 - 학습 및 이해: 더 고급 프레임워크를 탐색하기 전에 종속성 주입의 기본 사항을 파악하는 좋은 방법입니다.
 - 테스트 용이성: 단위 테스트에서는 테스트 환경에 대해 다른 프로바이더를 등록하여 종속성을 쉽게 모의하거나 교체할 수 있습니다.
 
결론
NestJS와 같은 프레임워크에 의존하지 않고 Express.js 애플리케이션에서 간단한 종속성 주입 컨테이너를 수동으로 구현하는 것은 코드 구조, 테스트 용이성 및 유지 관리성을 개선하는 실용적인 접근 방식입니다. DI의 핵심 원칙을 이해하고 기본적인 Container 클래스를 생성함으로써 개발자는 서비스 종속성을 효과적으로 관리하여 더 깨끗하고 모듈화된 Express 애플리케이션을 만들 수 있습니다. 이를 통해 개발자는 DI 원칙을 가장 큰 가치를 가져오는 곳에 선택적으로 적용하여 확장 가능하고 강력한 백엔드 시스템을 구축하고 아키텍처 선택에 대한 더 나은 제어를 얻을 수 있습니다.

