앵귤러 기초 강좌 #5 Service와 의존성 주입
지난 시간에는 Directive와 Pipe로 템플릿의 표현력을 넓히는 방법을 살펴봤습니다. 이번 시간에는 시선을 템플릿 바깥으로 돌려, 컴포넌트 안에 모든 로직을 다 담지 않는 방법 — Service와 의존성 주입에 대해 알아보겠습니다.
컴포넌트 안에 모든 걸 담으면 안 되는 이유 #
사용자 목록을 보여주는 UserListComponent가 있다고 해봅시다. 그 안에는 데이터를 가져오는 로직, 가공하는 로직, 추가,삭제 같은 동작, 그리고 화면을 그리는 템플릿까지 모두 들어 있습니다. 처음에는 괜찮아 보입니다. 그런데 다른 페이지에서도 같은 사용자 데이터가 필요해지면? 같은 fetch 로직을 두 번째 컴포넌트에 그대로 복붙하기 시작합니다. 그 다음 화면, 또 그 다음 화면… 같은 코드가 여기저기 흩어집니다.
게다가 이 컴포넌트를 테스트하려고 보면 화면 렌더링과 데이터 가져오기가 한 덩어리라 어느 한쪽만 따로 떼서 검증하기가 어렵습니다.
해결책은 간단합니다. “화면을 그리는 일"과 “데이터를 다루는 일"을 다른 클래스로 분리하면 됩니다. 앵귤러에서 후자를 담당하는 것이 바로 Service입니다.
Service란 무엇인가 #
Service는 그저 일반적인 TypeScript 클래스입니다. 특별한 문법이 따로 있는 건 아니고, 보통 비즈니스 로직(할인 계산, 권한 체크), 데이터 접근(HttpClient로 백엔드 API 호출), 여러 컴포넌트가 공유하는 상태, 로깅,인증,알림 같은 공통 기능을 담습니다.
컴포넌트는 “무엇을 보여줄지"에 집중하고, Service는 “데이터와 로직을 어떻게 다룰지"를 책임집니다. 그리고 컴포넌트는 자신이 직접 Service를 만들지 않고 앵귤러에게 “이거 하나 주세요"라고 요청합니다. 이 요청-제공의 메커니즘이 바로 **의존성 주입(Dependency Injection, DI)**입니다.
Service 만들기 #
CLI 한 줄로 만듭니다.
ng generate service user
# 또는 짧게: ng g s usersrc/app/user.service.ts와 테스트 파일이 함께 생성됩니다. 생성된 파일은 다음과 같은 모습입니다.
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class UserService {
constructor() {}
}핵심은 두 가지입니다.
@Injectable()데코레이터: 이 클래스를 앵귤러의 DI 시스템이 다룰 수 있게 표시합니다. “주입 가능한 클래스"라는 뜻입니다.providedIn: 'root': 이 Service를 앱의 루트 인젝터에 등록합니다. 풀어 말하면, 앱 어디에서 요청하든 언제나 같은 인스턴스를 돌려준다는 뜻입니다. 즉 **싱글톤(singleton)**이 됩니다.
이제 실제 로직을 채워봅시다. 사용자 목록을 들고 있는 간단한 Service입니다.
import { Injectable, signal } from '@angular/core';
export interface User {
id: number;
name: string;
}
@Injectable({
providedIn: 'root',
})
export class UserService {
// 외부에서 직접 수정 못하게 readonly로 노출
private readonly _users = signal<User[]>([
{ id: 1, name: '김커티스' },
{ id: 2, name: '이앵귤러' },
]);
readonly users = this._users.asReadonly();
addUser(name: string) {
const id = Date.now();
this._users.update((list) => [...list, { id, name }]);
}
removeUser(id: number) {
this._users.update((list) => list.filter((u) => u.id !== id));
}
}상태는 signal로 관리하고, 외부에서 직접 갈아끼우지 못하게 asReadonly()로 노출했습니다. 변경은 addUser, removeUser 같은 메서드를 통해서만 일어납니다. 일종의 작은 store처럼 동작하는 셈입니다.
컴포넌트에서 Service 사용하기 #
이제 컴포넌트에서 이 Service를 받아서 써봅시다. 모던 앵귤러에서는 inject() 함수를 가장 권장합니다.
import { Component, inject } from '@angular/core';
import { UserService } from '../user.service';
@Component({
selector: 'app-user-list',
standalone: true,
template: `
<h2>사용자 목록</h2>
<ul>
@for (user of userService.users(); track user.id) {
<li>
{{ user.name }}
<button (click)="userService.removeUser(user.id)">삭제</button>
</li>
}
</ul>
<input #nameInput placeholder="이름" />
<button (click)="add(nameInput.value); nameInput.value = ''">추가</button>
`,
})
export class UserListComponent {
protected readonly userService = inject(UserService);
add(name: string) {
if (name.trim()) {
this.userService.addUser(name.trim());
}
}
}inject(UserService) 한 줄로 끝입니다. 앵귤러는 루트 인젝터에서 UserService 인스턴스를 찾아 (없으면 만들어서) 돌려줍니다. 우리는 그걸 그대로 받아 쓰면 됩니다.
템플릿에서는 userService.users()로 signal 값을 읽고 있는데, signal은 함수 호출로 현재 값을 꺼낸다는 점만 기억하면 됩니다. signal 값이 바뀌면 앵귤러가 해당 부분만 알아서 다시 그립니다.
inject() 함수는 Angular 14에서 도입되어 v16부터 컴포넌트 클래스 필드 초기화에서도 자유롭게 쓸 수 있게 됐습니다. 새 프로젝트에서는 별다른 이유가 없다면 inject()를 기본으로 사용하시기 바랍니다. 함수형 라우트 가드나 인터셉터처럼 클래스가 아닌 곳에서도 그대로 쓸 수 있어 코드가 일관됩니다.생성자 주입 — 예전 방식 #
inject()가 표준이 되기 전, 오랜 시간 표준이었던 방식이 생성자 주입입니다. 지금도 동작하고, 기존 코드베이스에서는 더 자주 보일 겁니다.
export class UserListComponent {
constructor(private readonly userService: UserService) {}
}타입 어노테이션(UserService)을 보고 앵귤러가 알아서 인스턴스를 넣어줍니다. 동작은 동일합니다. 다만 상속이나 데코레이터 사용 시 미묘한 제약이 있고, 클래스 바깥(라우트 가드 함수 등)에서는 못 쓴다는 한계가 있어, 새로 짤 때는 inject()를 권장합니다.
여러 컴포넌트가 같은 Service를 공유 #
providedIn: 'root'로 등록된 Service는 앱 전체에서 같은 인스턴스 하나입니다. 한 컴포넌트에서 데이터를 추가하면, 같은 Service를 보는 다른 컴포넌트도 즉시 그 변경을 보게 됩니다. 위 UserListComponent 옆에 사용자 수만 보여주는 작은 컴포넌트를 둬봅시다.
import { Component, computed, inject } from '@angular/core';
import { UserService } from '../user.service';
@Component({
selector: 'app-user-count',
standalone: true,
template: `<p>현재 사용자: {{ count() }}명</p>`,
})
export class UserCountComponent {
private readonly userService = inject(UserService);
protected readonly count = computed(() => this.userService.users().length);
}한 페이지에 <app-user-list>와 <app-user-count>를 함께 두면, 목록에서 사용자를 추가했을 때 카운트 컴포넌트의 숫자도 즉시 올라갑니다. 두 컴포넌트가 같은 UserService 인스턴스, 같은 signal을 보고 있기 때문입니다. 컴포넌트끼리 직접 통신할 필요 없이 Service 한 곳을 거점으로 상태가 흐릅니다.
이것이 리액트의 Context, Vue의 store와 비슷한 역할을 앵귤러가 가장 자연스럽게 해내는 방식입니다. 별도 라이브러리 없이 Service + Signals만으로 작은 앱에서는 충분히 깔끔한 상태 관리가 됩니다.
Service의 Lifetime — providedIn 옵션
#
Service를 어디에 등록하느냐에 따라 인스턴스의 수명과 공유 범위가 달라집니다. 가장 흔한 두 가지를 비교해보겠습니다.
1. providedIn: 'root' (권장 기본값)
앱 전체에서 단 하나의 인스턴스가 살아 있습니다. 어디서 inject(UserService)를 하든 똑같은 객체를 받습니다. 대부분의 경우 이게 정답입니다.
2. 컴포넌트 단위 provider
컴포넌트의 providers 배열에 등록하면, 그 컴포넌트와 자식 컴포넌트에서만 새 인스턴스를 공유합니다. 컴포넌트가 사라지면 Service도 함께 사라집니다.
@Component({
selector: 'app-editor',
standalone: true,
providers: [EditorStateService],
template: `...`,
})
export class EditorComponent {
private readonly state = inject(EditorStateService);
}“이 화면 안에서만 의미 있는 상태"를 다룰 때 유용합니다. 예를 들어 편집 폼 하나의 임시 상태처럼, 페이지를 떠나면 함께 정리되어야 하는 데이터에 잘 맞습니다.
판단 기준은 단순합니다. “앱 전체에서 하나면 되나?” → 'root'. “이 컴포넌트가 살아 있는 동안만 필요한 별도 인스턴스인가?” → 컴포넌트 providers.
의존성 주입의 진짜 가치 — 테스트 #
DI가 빛을 발하는 가장 큰 무대는 테스트입니다. 컴포넌트가 new UserService()로 직접 객체를 만들었다면 테스트에서도 진짜 HTTP를 호출하는 Service를 그대로 써야 합니다. 느리고 불안정합니다. DI를 쓰면 가짜(mock) 구현으로 갈아끼울 수 있습니다.
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UserListComponent } from './user-list.component';
import { UserService } from '../user.service';
it('Service의 사용자를 화면에 보여준다', () => {
const fakeUsers = signal([{ id: 1, name: '테스트유저' }]);
const fakeUserService = {
users: fakeUsers.asReadonly(),
addUser: jasmine.createSpy('addUser'),
removeUser: jasmine.createSpy('removeUser'),
};
TestBed.configureTestingModule({
imports: [UserListComponent],
providers: [{ provide: UserService, useValue: fakeUserService }],
});
const fixture = TestBed.createComponent(UserListComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('테스트유저');
});{ provide: UserService, useValue: fakeUserService } 한 줄로, 컴포넌트가 UserService를 요청하면 진짜 대신 우리가 만든 가짜를 받게 됩니다. 컴포넌트 코드는 한 글자도 안 바꾸고 말입니다. 컴포넌트가 클래스 자체에 의존하지 않고, “주입받는다"는 사실에만 의존하기 때문에 가능한 일입니다. 이게 의존성 주입의 진짜 가치입니다 — 자유로운 교체 가능성, 그리고 그 위에서 자연스럽게 따라오는 테스트 용이성.
마무리 #
이번 글에서는 Service와 의존성 주입에 대해 살펴봤습니다. 정리하면:
- Service는 비즈니스 로직,데이터,공유 상태를 담는 평범한 클래스
@Injectable({ providedIn: 'root' })로 등록하면 앱 어디서나 같은 인스턴스(싱글톤)- 컴포넌트에서는
inject(UserService)로 받아 쓰는 것이 모던 앵귤러의 권장 방식 providedIn: 'root'vs 컴포넌트providers— 공유 범위에 따라 선택- DI 덕분에 테스트에서 mock으로 갈아끼우기가 쉬워진다
이제 컴포넌트와 로직을 분리할 줄 알게 됐으니, 화면이 여러 개인 앱을 만들 차례입니다. 다음 글인 “앵귤러 기초 강좌 #6 Routing"에서는 앵귤러 라우터로 다중 페이지 앱을 구성하는 방법 — 라우트 정의, RouterLink, RouterOutlet, 그리고 가드와 파라미터까지 — 차근차근 살펴보겠습니다.