[Angular 마스터하기] Day 8 - 서비스와 의존성 주입, 코드 재사용의 기술
이제와서 시작하는 Angular 마스터하기 - Day 8 “서비스로 코드를 깔끔하게 정리하고, 어디서든 재사용하세요! 🔧”
오늘 배울 내용
- 서비스가 무엇이고 왜 필요한지
ng generate service로 서비스 생성inject()함수로 서비스 주입 (최신 방식)- 실습: 데이터 관리 서비스, 인증 서비스
1. 서비스가 뭘까요?
문제 상황
컴포넌트에 모든 로직을 넣으면:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ❌ 나쁜 예: 컴포넌트가 너무 복잡함
@Component({...})
export class ProductListComponent {
products = [];
// 데이터 가져오기
loadProducts() {
fetch('https://api.example.com/products')
.then(res => res.json())
.then(data => this.products = data);
}
// 비즈니스 로직
calculateDiscount(price: number) {
return price * 0.9;
}
// 로깅
logAction(action: string) {
console.log(`[${new Date()}] ${action}`);
}
}
문제점:
- 컴포넌트가 너무 많은 일을 함
- 다른 컴포넌트에서 재사용 불가
- 테스트하기 어려움
- 유지보수가 힘듦
서비스의 해결책
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ 좋은 예: 서비스로 분리
@Injectable({ providedIn: 'root' })
export class ProductService {
loadProducts() { /* ... */ }
calculateDiscount(price: number) { /* ... */ }
}
@Injectable({ providedIn: 'root' })
export class LoggerService {
log(message: string) { /* ... */ }
}
@Component({...})
export class ProductListComponent {
productService = inject(ProductService);
logger = inject(LoggerService);
ngOnInit() {
this.productService.loadProducts();
this.logger.log('Products loaded');
}
}
graph TD
A[ProductListComponent] --> D[ProductService]
B[ProductDetailComponent] --> D
C[CartComponent] --> D
A --> E[LoggerService]
B --> E
C --> E
style D fill:#667eea,color:#fff
style E fill:#667eea,color:#fff
장점:
- ✅ 코드 재사용
- ✅ 관심사의 분리
- ✅ 테스트 용이
- ✅ 싱글톤 패턴 (하나의 인스턴스 공유)
2. 서비스 만들기
CLI로 생성
1
2
3
ng generate service services/data
# 또는
ng g s services/data
생성되는 파일:
1
2
CREATE src/app/services/data.service.ts
CREATE src/app/services/data.service.spec.ts
기본 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root' // ← 전역 싱글톤
})
export class DataService {
constructor() { }
// 서비스 메서드들
getData() {
return 'Hello from service!';
}
}
providedIn: 'root' 의미:
- 앱 전체에서 하나의 인스턴스만 생성
- 어디서든 사용 가능
- 자동으로 트리 셰이킹 (사용 안 하면 번들에서 제외)
3. inject() 함수로 주입하기 (⭐ 최신 방식)
기존 방식 vs 최신 방식
1
2
3
4
5
6
7
8
9
10
11
// 😐 기존 방식: constructor
export class OldComponent {
constructor(private dataService: DataService) {}
}
// ✨ 최신 방식: inject() 함수
import { inject } from '@angular/core';
export class NewComponent {
private dataService = inject(DataService);
}
최신 방식의 장점:
- ✅ 더 간결함
- ✅ 필드에서 바로 초기화
- ✅ 조건부 주입 가능
실전 예제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
// 서비스
@Injectable({ providedIn: 'root' })
export class UserService {
private users = signal([
{ id: 1, name: '홍길동', role: 'admin' },
{ id: 2, name: '김철수', role: 'user' },
{ id: 3, name: '이영희', role: 'user' }
]);
getUsers() {
return this.users.asReadonly();
}
addUser(name: string, role: string) {
this.users.update(users => [
...users,
{ id: Date.now(), name, role }
]);
}
deleteUser(id: number) {
this.users.update(users => users.filter(u => u.id !== id));
}
}
// 컴포넌트
@Component({
selector: 'app-user-list',
standalone: true,
imports: [CommonModule],
template: `
<div class="user-list">
<h2>사용자 목록</h2>
@for (user of users(); track user.id) {
<div class="user-card">
<span>{{ user.name }}</span>
<span class="role">{{ user.role }}</span>
<button (click)="deleteUser(user.id)">삭제</button>
</div>
}
<button (click)="addRandomUser()">사용자 추가</button>
</div>
`
})
export class UserListComponent {
// inject()로 서비스 주입
private userService = inject(UserService);
// 서비스의 데이터 가져오기
users = this.userService.getUsers();
addRandomUser() {
const names = ['박민수', '최유진', '정서준'];
const name = names[Math.floor(Math.random() * names.length)];
this.userService.addUser(name, 'user');
}
deleteUser(id: number) {
this.userService.deleteUser(id);
}
}
4. 실전 예제: Todo 서비스
Signal·computed와 이전 시간의 컴포넌트 통신을 알고 있다면, 이번에는 상태의 소유자를 서비스로 옮겨보세요. TodoService의 비공개 todos → 공개 읽기용 Signal과 통계 → 컴포넌트의 filteredTodos 순서로 읽습니다. 컴포넌트는 사용자 입력을 받고, 서비스 메서드가 원본을 바꿉니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import { Injectable, signal, computed } from '@angular/core';
export interface Todo {
id: number;
text: string;
completed: boolean;
createdAt: Date;
}
@Injectable({
providedIn: 'root'
})
export class TodoService {
// Private Signal
private todos = signal<Todo[]>([]);
// Public Readonly Signal
allTodos = this.todos.asReadonly();
// Computed Signals
activeTodos = computed(() =>
this.todos().filter(todo => !todo.completed)
);
completedTodos = computed(() =>
this.todos().filter(todo => todo.completed)
);
activeCount = computed(() => this.activeTodos().length);
completedCount = computed(() => this.completedTodos().length);
// Methods
addTodo(text: string) {
const newTodo: Todo = {
id: Date.now(),
text: text.trim(),
completed: false,
createdAt: new Date()
};
this.todos.update(todos => [...todos, newTodo]);
}
toggleTodo(id: number) {
this.todos.update(todos =>
todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
}
deleteTodo(id: number) {
this.todos.update(todos => todos.filter(todo => todo.id !== id));
}
clearCompleted() {
this.todos.update(todos => todos.filter(todo => !todo.completed));
}
// LocalStorage 연동
saveTodos() {
localStorage.setItem('todos', JSON.stringify(this.todos()));
}
loadTodos() {
const saved = localStorage.getItem('todos');
if (saved) {
this.todos.set(JSON.parse(saved));
}
}
}
Todo 컴포넌트에서 사용
추가한 항목을 완료로 바꾸면 서비스 통계와 진행 중 필터가 함께 바뀌는지 확인하세요. 템플릿보다 클래스의 addTodo()·clearCompleted()를 먼저 보면 서비스 호출과 저장 시점을 찾기 쉽습니다. 아래 import 조각에는 computed가 빠져 있으므로 실행할 컴포넌트에서는 @angular/core의 import에 포함해야 합니다.
현재 saveTodos()는 추가와 완료 항목 일괄 삭제 뒤에만 호출됩니다. 체크박스 토글·개별 삭제 직후 새로고침하면 마지막 저장 상태로 돌아갈 수 있어요. 화면 갱신과 영속 저장을 같은 동작으로 이해하지 않도록 저장 호출 위치를 직접 짚어보세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import { Component, inject, signal } from '@angular/core';
import { TodoService } from './services/todo.service';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-todo-app',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="todo-app">
<h1>📝 Todo App</h1>
<!-- 통계 -->
<div class="stats">
<span>전체: {{ todoService.allTodos().length }}</span>
<span>진행 중: {{ todoService.activeCount() }}</span>
<span>완료: {{ todoService.completedCount() }}</span>
</div>
<!-- 입력 -->
<div class="input-section">
<input
type="text"
[(ngModel)]="newTodoText"
(keyup.enter)="addTodo()"
placeholder="할 일을 입력하세요">
<button (click)="addTodo()">추가</button>
</div>
<!-- 필터 -->
<div class="filter-buttons">
<button (click)="filter.set('all')" [class.active]="filter() === 'all'">
전체
</button>
<button (click)="filter.set('active')" [class.active]="filter() === 'active'">
진행 중
</button>
<button (click)="filter.set('completed')" [class.active]="filter() === 'completed'">
완료
</button>
</div>
<!-- Todo 목록 -->
<div class="todo-list">
@for (todo of filteredTodos(); track todo.id) {
<div class="todo-item" [class.completed]="todo.completed">
<input
type="checkbox"
[checked]="todo.completed"
(change)="todoService.toggleTodo(todo.id)">
<span>{{ todo.text }}</span>
<button (click)="todoService.deleteTodo(todo.id)">🗑️</button>
</div>
}
</div>
<!-- 액션 -->
@if (todoService.completedCount() > 0) {
<button (click)="clearCompleted()" class="clear-btn">
완료된 항목 삭제
</button>
}
</div>
`,
styles: [`
.todo-app {
max-width: 600px;
margin: 50px auto;
padding: 30px;
background: white;
border-radius: 20px;
box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}
.stats {
display: flex;
justify-content: space-around;
padding: 15px;
background: #f5f5f5;
border-radius: 10px;
margin-bottom: 20px;
}
.input-section {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
input[type="text"] {
flex: 1;
padding: 12px;
font-size: 1em;
border: 2px solid #ddd;
border-radius: 8px;
}
button {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
.filter-buttons {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.filter-buttons button {
flex: 1;
background: #f5f5f5;
color: #333;
}
.filter-buttons button.active {
background: #667eea;
color: white;
}
.todo-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background: #f9f9f9;
border-radius: 8px;
margin-bottom: 8px;
}
.todo-item.completed span {
text-decoration: line-through;
opacity: 0.6;
}
.clear-btn {
width: 100%;
margin-top: 20px;
background: #f44336;
}
`]
})
export class TodoAppComponent {
todoService = inject(TodoService);
newTodoText = '';
filter = signal<'all' | 'active' | 'completed'>('all');
filteredTodos = computed(() => {
switch (this.filter()) {
case 'active':
return this.todoService.activeTodos();
case 'completed':
return this.todoService.completedTodos();
default:
return this.todoService.allTodos();
}
});
ngOnInit() {
this.todoService.loadTodos();
}
addTodo() {
if (this.newTodoText.trim()) {
this.todoService.addTodo(this.newTodoText);
this.newTodoText = '';
this.todoService.saveTodos();
}
}
clearCompleted() {
this.todoService.clearCompleted();
this.todoService.saveTodos();
}
}
5. 실전 예제: 인증 서비스
이 인증 예제는 1초 대기 후 가짜 사용자를 만드는 상태 관리 실습입니다. 비밀번호를 서버에서 검증하지 않고 입력한 이름이 admin인지로 역할을 정하므로 실제 인증·권한 검증을 제공하지 않습니다. loading → user → isAuthenticated가 어떻게 바뀌는지 관찰하는 데 사용하세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import { Injectable, signal, computed } from '@angular/core';
interface User {
id: number;
username: string;
email: string;
role: 'admin' | 'user';
}
@Injectable({
providedIn: 'root'
})
export class AuthService {
private currentUser = signal<User | null>(null);
private isLoading = signal(false);
// Public readonly
user = this.currentUser.asReadonly();
loading = this.isLoading.asReadonly();
// Computed
isAuthenticated = computed(() => this.currentUser() !== null);
isAdmin = computed(() => this.currentUser()?.role === 'admin');
async login(username: string, password: string) {
this.isLoading.set(true);
try {
// 실제로는 API 호출
await this.delay(1000);
// 가짜 사용자 데이터
const user: User = {
id: 1,
username,
email: `${username}@example.com`,
role: username === 'admin' ? 'admin' : 'user'
};
this.currentUser.set(user);
localStorage.setItem('user', JSON.stringify(user));
return { success: true };
} catch (error) {
return { success: false, error: '로그인 실패' };
} finally {
this.isLoading.set(false);
}
}
logout() {
this.currentUser.set(null);
localStorage.removeItem('user');
}
checkAuth() {
const saved = localStorage.getItem('user');
if (saved) {
this.currentUser.set(JSON.parse(saved));
}
}
private delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
인증 서비스 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
@Component({
selector: 'app-login',
standalone: true,
imports: [FormsModule, CommonModule],
template: `
<div class="login-page">
@if (!authService.isAuthenticated()) {
<div class="login-form">
<h2>로그인</h2>
<input
type="text"
[(ngModel)]="username"
placeholder="사용자명">
<input
type="password"
[(ngModel)]="password"
placeholder="비밀번호">
<button
(click)="login()"
[disabled]="authService.loading()">
{{ authService.loading() ? '로그인 중...' : '로그인' }}
</button>
<p class="hint">힌트: 'admin' 또는 아무 이름</p>
</div>
} @else {
<div class="user-info">
<h2>환영합니다!</h2>
<p>사용자: {{ authService.user()?.username }}</p>
<p>이메일: {{ authService.user()?.email }}</p>
@if (authService.isAdmin()) {
<p class="admin-badge">🔐 관리자</p>
}
<button (click)="authService.logout()">로그아웃</button>
</div>
}
</div>
`
})
export class LoginComponent {
authService = inject(AuthService);
username = '';
password = '';
ngOnInit() {
this.authService.checkAuth();
}
async login() {
const result = await this.authService.login(this.username, this.password);
if (result.success) {
alert('로그인 성공!');
} else {
alert(result.error);
}
}
}
🧪 직접 해보기
실습 1: 카운터 서비스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Injectable({ providedIn: 'root' })
export class CounterService {
private count = signal(0);
currentCount = this.count.asReadonly();
increment() {
this.count.update(v => v + 1);
}
decrement() {
this.count.update(v => v - 1);
}
reset() {
this.count.set(0);
}
}
실습 2: 테마 서비스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Injectable({ providedIn: 'root' })
export class ThemeService {
private isDark = signal(false);
darkMode = this.isDark.asReadonly();
toggle() {
this.isDark.update(v => !v);
this.applyTheme();
}
private applyTheme() {
document.body.classList.toggle('dark', this.isDark());
}
}
💡 자주 하는 실수
❌ 실수 1: providedIn 빼먹기
1
2
3
4
5
6
7
// ❌ 틀린 예
@Injectable()
export class MyService {}
// ✅ 올바른 예
@Injectable({ providedIn: 'root' })
export class MyService {}
❌ 실수 2: inject()를 constructor 밖에서 사용
제목의 위치 표현보다 아래 코드의 호출 시점을 보세요. 비교 대상은 일반 이벤트 메서드 안에서의 inject() 호출과 컴포넌트 생성 과정의 필드 초기화입니다. 필드 초기화도 constructor 본문 밖에 있지만 아래 올바른 예처럼 사용할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ 틀린 예
export class MyComponent {
myMethod() {
const service = inject(MyService); // 에러!
}
}
// ✅ 올바른 예
export class MyComponent {
private service = inject(MyService); // 필드에서 OK
myMethod() {
this.service.doSomething();
}
}
📝 정리
서비스의 핵심
| 항목 | 설명 |
|---|---|
| 용도 | 비즈니스 로직, 데이터 관리, API 호출 |
| 생명주기 | 싱글톤 (하나의 인스턴스) |
| 주입 | inject() 함수 사용 |
| 공유 | 여러 컴포넌트에서 동일한 인스턴스 사용 |
체크리스트
- 서비스를 만들 수 있나요?
inject()로 서비스를 주입할 수 있나요?- Signal과 함께 서비스를 사용할 수 있나요?
- Todo 서비스를 만들어봤나요?
📚 다음 학습
다음 시간에는 라우팅을 배웁니다!
여러 페이지를 만들고 이동하는 방법을 배웁니다:
- 라우트 설정
- RouterLink, RouterOutlet
- 파라미터 전달
-
실습: 다중 페이지 앱
- 이전: Day 7: 컴포넌트 간 통신
- 다음: Day 9: 라우팅 기초 - 여러 페이지 만들기
- 전체 커리큘럼: Angular 마스터하기 시리즈
“좋은 서비스는 좋은 앱의 시작입니다!” 🔧
