포스트

[Angular 마스터하기] Day 8 - 서비스와 의존성 주입, 코드 재사용의 기술

[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 서비스

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 컴포넌트에서 사용

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
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 밖에서 사용

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 서비스를 만들어봤나요?

📚 다음 학습

다음 시간에는 라우팅을 배웁니다!

여러 페이지를 만들고 이동하는 방법을 배웁니다:


“좋은 서비스는 좋은 앱의 시작입니다!” 🔧

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.