16.4 의존성 주입(DI) — 서비스, @Injectable, 프로바이더, 계층적 DI
의존성 주입이란?
**의존성 주입(Dependency Injection, DI)**은 객체가 필요한 의존성을 직접 생성하지 않고, 외부에서 제공받는 디자인 패턴입니다.
// DI 없이 — 강한 결합(Tight Coupling)
class OrderComponent {
// 직접 생성 → 테스트가 어렵고, 변경 시 수정이 많음
private userService = new UserService();
private orderService = new OrderService(new HttpClient());
}
// DI 사용 — 느슨한 결합(Loose Coupling)
class OrderComponent {
// Angular가 자동으로 주입 — 테스트 용이, 교체 쉬움
constructor(
private userService: UserService,
private orderService: OrderService
) {}
}
Angular의 DI 시스템이 제공하는 이점:
- 재사용성: 서비스를 여러 컴포넌트에서 공유
- 테스트 용이성: 모의 서비스(Mock)로 쉽게 교체
- 유지보수성: 의존성 변경이 한 곳에만 영향
@Injectable 데코레이터
서비스를 DI 시스템에 등록하려면 @Injectable() 데코레이터를 사용합니다.
// services/counter.service.ts
import { Injectable, signal, computed } from '@angular/core';
@Injectable({
providedIn: 'root' // 앱 전체에서 싱글톤으로 사용
})
export class CounterService {
private count = signal(0);
// 읽기 전용 computed
readonly currentCount = computed(() => this.count());
readonly isPositive = computed(() => this.count() > 0);
increment() {
this.count.update(n => n + 1);
}
decrement() {
this.count.update(n => n - 1);
}
reset() {
this.count.set(0);
}
}
providedIn 옵션:
| 값 | 설명 |
|---|---|
'root' | 앱 전체 싱글톤 (가장 일반적) |
'platform' | 동일 플랫폼의 모든 앱 공유 |
'any' | 각 모듈마다 새 인스턴스 |
| 컴포넌트 클래스 | 해당 컴포넌트와 자식 전용 |
서비스 생성 및 주입
Angular CLI로 생성
ng generate service services/user
# 또는
ng g s services/user
inject() 함수 (Angular 14+ 권장)
// services/user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, signal } from 'rxjs';
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user';
}
@Injectable({ providedIn: 'root' })
export class UserService {
private http = inject(HttpClient);
private apiUrl = 'https://jsonplaceholder.typicode.com/users';
// 캐시 상태
private cachedUsers = signal<User[]>([]);
getUsers(): Observable<User[]> {
return this.http.get<User[]>(this.apiUrl);
}
getUserById(id: number): Observable<User> {
return this.http.get<User>(`${this.apiUrl}/${id}`);
}
createUser(user: Omit<User, 'id'>): Observable<User> {
return this.http.post<User>(this.apiUrl, user);
}
updateUser(id: number, user: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.apiUrl}/${id}`, user);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
}
// components/user-list.component.ts
import { Component, OnInit, inject, signal } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { UserService, User } from '../services/user.service';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [AsyncPipe],
template: `
<h2>사용자 목록</h2>
@if (isLoading()) {
<p>로딩 중...</p>
} @else if (error()) {
<p class="error">오류: {{ error() }}</p>
} @else {
<ul>
@for (user of users(); track user.id) {
<li>{{ user.name }} ({{ user.email }})</li>
}
</ul>
}
`
})
export class UserListComponent implements OnInit {
// inject() 함수로 서비스 주입
private userService = inject(UserService);
users = signal<User[]>([]);
isLoading = signal(false);
error = signal<string | null>(null);
ngOnInit() {
this.loadUsers();
}
private loadUsers() {
this.isLoading.set(true);
this.error.set(null);
this.userService.getUsers().subscribe({
next: (users) => {
this.users.set(users);
this.isLoading.set(false);
},
error: (err) => {
this.error.set(err.message);
this.isLoading.set(false);
}
});
}
}
providedIn: 'root' vs 컴포넌트 레벨 DI
root 레벨 (전역 싱글톤)
@Injectable({ providedIn: 'root' })
export class GlobalStateService {
// 앱 전체에서 하나의 인스턴스
// 모든 컴포넌트가 같은 상태 공유
}
컴포넌트 레벨 (로컬 인스턴스)
@Component({
selector: 'app-cart',
standalone: true,
// providers에 등록하면 이 컴포넌트와 자식 컴포넌트만 사용
providers: [CartService],
template: `...`
})
export class CartComponent {
// CartService의 독립된 인스턴스
private cartService = inject(CartService);
}
// CartService는 @Injectable이지만 providedIn은 없음
@Injectable() // providedIn 없음 — 컴포넌트 providers에서 제공
export class CartService {
private items = signal<CartItem[]>([]);
// 이 서비스는 CartComponent마다 독립된 인스턴스를 가짐
}
계층적 DI 시스템
Angular의 DI는 계층적 인젝터 트리를 구성합니다:
EnvironmentInjector (root)
└── 앱 전역 서비스 (providedIn: 'root')
└── 컴포넌트 인젝터
├── AppComponent
│ ├── HeaderComponent
│ └── MainComponent
│ ├── CartComponent (providers: [CartService])
│ │ └── CartItemComponent ← CartService 접근 가능
│ └── ProductListComponent ← CartService 접근 불가
// 상위 서비스를 하위에서 재정의 (Override)
@Component({
selector: 'app-theme-override',
standalone: true,
providers: [
{
provide: ThemeService,
useClass: DarkThemeService // 기본 ThemeService 대신 DarkThemeService 사용
}
],
template: `...`
})
export class ThemeOverrideComponent {}
다양한 프로바이더 패턴
useClass — 다른 클래스로 교체
// 인터페이스 토큰 정의
import { InjectionToken } from '@angular/core';
export interface Logger {
log(message: string): void;
error(message: string): void;
}
export const LOGGER = new InjectionToken<Logger>('Logger');
// 구현체들
@Injectable()
export class ConsoleLogger implements Logger {
log(message: string) { console.log(`[LOG] ${message}`); }
error(message: string) { console.error(`[ERROR] ${message}`); }
}
@Injectable()
export class RemoteLogger implements Logger {
log(message: string) { /* 서버로 전송 */ }
error(message: string) { /* 서버로 전송 */ }
}
// app.config.ts에서 환경에 따라 교체
export const appConfig: ApplicationConfig = {
providers: [
{
provide: LOGGER,
useClass: environment.production ? RemoteLogger : ConsoleLogger
}
]
};
// 사용
@Injectable({ providedIn: 'root' })
export class SomeService {
private logger = inject(LOGGER);
doSomething() {
this.logger.log('작업 수행 중');
}
}
useValue — 상수 값 주입
export const API_CONFIG = new InjectionToken<ApiConfig>('ApiConfig');
export const appConfig: ApplicationConfig = {
providers: [
{
provide: API_CONFIG,
useValue: {
baseUrl: 'https://api.example.com',
timeout: 5000,
version: 'v2'
}
}
]
};
// 사용
@Injectable({ providedIn: 'root' })
export class ApiService {
private config = inject(API_CONFIG);
// this.config.baseUrl, this.config.timeout 접근 가능
}
useFactory — 동적 생성
export const appConfig: ApplicationConfig = {
providers: [
{
provide: LOGGER,
useFactory: (http: HttpClient) => {
if (environment.production) {
return new RemoteLogger(http);
}
return new ConsoleLogger();
},
deps: [HttpClient] // factory 함수에 주입할 의존성
}
]
};
useExisting — 별칭 설정
// NewApiService를 ApiService 토큰으로도 주입 받을 수 있게
{
provide: ApiService,
useExisting: NewApiService
}
실전 예제 — HttpClient를 활용한 API 서비스
// services/product.service.ts
import { Injectable, inject, signal, computed } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, catchError, tap, throwError } from 'rxjs';
export interface Product {
id: number;
title: string;
price: number;
category: string;
description: string;
image: string;
rating: { rate: number; count: number };
}
export interface ProductFilter {
category?: string;
minPrice?: number;
maxPrice?: number;
search?: string;
}
@Injectable({ providedIn: 'root' })
export class ProductService {
private http = inject(HttpClient);
private readonly baseUrl = 'https://fakestoreapi.com/products';
// 로컬 캐시
private _products = signal<Product[]>([]);
private _isLoading = signal(false);
private _error = signal<string | null>(null);
// 공개 읽기 전용 상태
readonly products = this._products.asReadonly();
readonly isLoading = this._isLoading.asReadonly();
readonly error = this._error.asReadonly();
// 파생 상태
readonly categories = computed(() =>
[...new Set(this._products().map(p => p.category))]
);
// 모든 상품 조회
loadProducts(): void {
this._isLoading.set(true);
this._error.set(null);
this.http.get<Product[]>(this.baseUrl).pipe(
tap(products => {
this._products.set(products);
this._isLoading.set(false);
}),
catchError(err => {
this._error.set('상품을 불러오는데 실패했습니다.');
this._isLoading.set(false);
return throwError(() => err);
})
).subscribe();
}
// 단일 상품 조회
getProduct(id: number): Observable<Product> {
return this.http.get<Product>(`${this.baseUrl}/${id}`);
}
// 카테고리별 조회
getByCategory(category: string): Observable<Product[]> {
return this.http.get<Product[]>(`${this.baseUrl}/category/${category}`);
}
// 상품 생성
createProduct(product: Omit<Product, 'id'>): Observable<Product> {
return this.http.post<Product>(this.baseUrl, product).pipe(
tap(newProduct => {
this._products.update(products => [...products, newProduct]);
})
);
}
// 상품 수정
updateProduct(id: number, updates: Partial<Product>): Observable<Product> {
return this.http.put<Product>(`${this.baseUrl}/${id}`, updates).pipe(
tap(updated => {
this._products.update(products =>
products.map(p => p.id === id ? updated : p)
);
})
);
}
// 상품 삭제
deleteProduct(id: number): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`).pipe(
tap(() => {
this._products.update(products =>
products.filter(p => p.id !== id)
);
})
);
}
}
// components/product-list.component.ts
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ProductService, Product, ProductFilter } from '../services/product.service';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [FormsModule],
template: `
<div class="product-manager">
<h2>상품 관리</h2>
<!-- 필터 -->
<div class="filters">
<select [(ngModel)]="selectedCategory" (change)="applyFilter()">
<option value="">전체 카테고리</option>
@for (cat of productService.categories(); track cat) {
<option [value]="cat">{{ cat }}</option>
}
</select>
<input
[(ngModel)]="searchTerm"
(input)="applyFilter()"
placeholder="검색..."
>
</div>
<!-- 상태 표시 -->
@if (productService.isLoading()) {
<div class="loading">상품 로딩 중...</div>
} @else if (productService.error()) {
<div class="error">{{ productService.error() }}</div>
} @else {
<div class="product-grid">
@for (product of filteredProducts(); track product.id) {
<div class="product-card">
<img [src]="product.image" [alt]="product.title">
<h3>{{ product.title }}</h3>
<p class="price">{{ product.price | currency:'USD' }}</p>
<p class="rating">★ {{ product.rating.rate }} ({{ product.rating.count }})</p>
<button (click)="deleteProduct(product.id)">삭제</button>
</div>
}
</div>
<p>총 {{ filteredProducts().length }}개 상품</p>
}
</div>
`
})
export class ProductListComponent implements OnInit {
productService = inject(ProductService);
selectedCategory = '';
searchTerm = '';
filteredProducts = computed(() => {
let products = this.productService.products();
if (this.selectedCategory) {
products = products.filter(p => p.category === this.selectedCategory);
}
if (this.searchTerm) {
const term = this.searchTerm.toLowerCase();
products = products.filter(p =>
p.title.toLowerCase().includes(term)
);
}
return products;
});
ngOnInit() {
this.productService.loadProducts();
}
applyFilter() {
// filteredProducts는 computed이므로 자동 업데이트
}
deleteProduct(id: number) {
if (confirm('삭제하시겠습니까?')) {
this.productService.deleteProduct(id).subscribe();
}
}
}
서비스 단위 테스트
// services/product.service.spec.ts
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { ProductService } from './product.service';
describe('ProductService', () => {
let service: ProductService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [ProductService]
});
service = TestBed.inject(ProductService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify(); // 미처리 요청 없는지 확인
});
it('상품 목록을 불러와야 한다', () => {
const mockProducts = [
{ id: 1, title: '테스트 상품', price: 10, category: 'test', description: '', image: '', rating: { rate: 4, count: 100 } }
];
service.loadProducts();
const req = httpMock.expectOne('https://fakestoreapi.com/products');
expect(req.request.method).toBe('GET');
req.flush(mockProducts);
expect(service.products()).toEqual(mockProducts);
expect(service.isLoading()).toBe(false);
});
it('오류 시 에러 상태를 설정해야 한다', () => {
service.loadProducts();
const req = httpMock.expectOne('https://fakestoreapi.com/products');
req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
expect(service.error()).toBeTruthy();
expect(service.isLoading()).toBe(false);
});
});
고수 팁
팁 1: inject() vs 생성자 주입
Angular 14+에서는 inject() 함수 방식이 권장됩니다:
// 구 방식 (생성자)
export class MyComponent {
constructor(
private userService: UserService,
private router: Router
) {}
}
// 새 방식 (inject 함수) — 더 간결하고 함수형 프로그래밍에 적합
export class MyComponent {
private userService = inject(UserService);
private router = inject(Router);
}
팁 2: InjectionToken으로 타입 안전한 설정 주입
환경 변수, 설정 객체를 타입 안전하게 주입할 수 있습니다.
팁 3: 서비스 파일 위치 규칙
src/app/
├── core/ ← 전역 서비스, 가드, 인터셉터
│ └── services/
│ ├── auth.service.ts
│ └── logger.service.ts
└── features/
└── products/
├── product.service.ts ← 기능별 서비스
└── product-list.component.ts
팁 4: 순환 의존성 방지
서비스 A가 서비스 B를 주입하고, B도 A를 주입하면 순환 의존성 오류가 발생합니다. 이럴 때는 공통 상태를 별도 서비스로 분리하세요.