프론트엔드 컴포넌트 설계와 디자인 패턴
적용 환경: Angular 14+, RxJS 7+, React 18+, TypeScript 4.7+
개요
프론트엔드 애플리케이션의 규모가 커질수록 컴포넌트 간의 관계를 어떻게 설계하느냐가 유지보수성을 결정합니다. 이 문서에서는 느슨한 결합(Loose Coupling)의 원칙과 이를 구현하기 위한 디자인 패턴을 다룹니다.
느슨한 결합 (Loose Coupling)
정의
두 객체가 느슨하게 결합되어 있다는 것은, 서로 상호작용은 하지만 서로에 대해 최소한만 알고 있다는 것을 의미합니다.
왜 중요한가
| 결합도 | 특징 | 결과 |
|---|---|---|
| 강한 결합 | 컴포넌트가 서로의 내부 구현을 알고 있음 | 하나를 수정하면 연쇄적으로 수정 필요 |
| 느슨한 결합 | 컴포넌트가 인터페이스로만 소통 | 독립적으로 수정/테스트 가능 |
자식 컴포넌트에서 직접 Fetch하면 안 되는 이유
// ❌ 강한 결합: 컴포넌트 내부에서 직접 데이터 fetch
@Component({
selector: "app-user-profile",
template: `<div>{{ user?.name }}</div>`,
})
export class UserProfileComponent implements OnInit {
user: User;
constructor(private userService: UserService) {}
ngOnInit() {
// 이 컴포넌트는 "사용자 데이터가 필요한 컨텍스트"에서만 사용 가능
this.userService.getCurrentUser().subscribe((user) => {
this.user = user;
});
}
}이 컴포넌트는 다음과 같은 문제가 있습니다.
- 재사용 불가: 다른 사용자 정보를 보여주고 싶을 때 사용할 수 없음
- 테스트 어려움: 항상 UserService를 모킹해야 함
- 부모와 강하게 결합: "현재 로그인한 사용자"라는 컨텍스트에 종속
// ✅ 느슨한 결합: @Input으로 데이터를 받음
@Component({
selector: "app-user-profile",
template: `<div>{{ user?.name }}</div>`,
})
export class UserProfileComponent {
@Input() user: User;
}이제 이 컴포넌트는 어떤 사용자 데이터든 받아서 표시할 수 있습니다. 데이터를 어디서 가져올지는 부모가 결정합니다.
Angular에서 느슨한 결합 구현
1. @Input / @Output 데코레이터
// 자식 컴포넌트
@Component({
selector: "app-todo-item",
template: `
<div class="todo-item">
<span>{{ todo.title }}</span>
<button (click)="onDelete()">삭제</button>
</div>
`,
})
export class TodoItemComponent {
@Input() todo: Todo;
@Output() delete = new EventEmitter<number>();
onDelete() {
this.delete.emit(this.todo.id);
}
}
// 부모 컴포넌트
@Component({
selector: "app-todo-list",
template: `
<app-todo-item
*ngFor="let todo of todos"
[todo]="todo"
(delete)="handleDelete($event)"
>
</app-todo-item>
`,
})
export class TodoListComponent {
todos: Todo[] = [];
handleDelete(id: number) {
this.todoService.delete(id).subscribe();
}
}2. 의존성 주입 (Dependency Injection)
// 서비스 정의
@Injectable({ providedIn: 'root' })
export class NotificationService {
private messages$ = new Subject<string>();
notify(message: string) {
this.messages$.next(message);
}
getMessages() {
return this.messages$.asObservable();
}
}
// 컴포넌트 A: 알림 발송
@Component({ ... })
export class OrderComponent {
constructor(private notification: NotificationService) {}
placeOrder() {
// 주문 로직...
this.notification.notify('주문이 완료되었습니다');
}
}
// 컴포넌트 B: 알림 표시 (A와 직접적인 의존 없음)
@Component({ ... })
export class NotificationComponent {
constructor(private notification: NotificationService) {}
messages$ = this.notification.getMessages();
}3. RxJS를 활용한 반응형 데이터 흐름
@Injectable({ providedIn: "root" })
export class CartService {
private items$ = new BehaviorSubject<CartItem[]>([]);
getItems() {
return this.items$.asObservable();
}
addItem(item: CartItem) {
const current = this.items$.getValue();
this.items$.next([...current, item]);
}
}프론트엔드 아키텍처 패턴의 발전
MVC → MVVM → Component → Flux
프론트엔드 아키텍처는 애플리케이션의 복잡성 증가에 따라 발전해왔습니다.
MVC (서버 중심)
↓
MVVM (클라이언트 데이터 바인딩)
↓
Component (재사용 가능한 UI 단위)
↓
Flux/Redux (단방향 데이터 흐름)
Container-Presenter 패턴
컴포넌트를 역할에 따라 분리하는 패턴입니다.
| 구분 | Container (Smart) | Presenter (Dumb) |
|---|---|---|
| 역할 | 비즈니스 로직, 데이터 fetch | UI 렌더링 |
| 상태 | 상태를 관리함 | 상태 없음 (stateless) |
| 의존성 | 서비스, 스토어에 의존 | props/Input에만 의존 |
| 재사용성 | 낮음 | 높음 |
// Container: 비즈니스 로직 담당
@Component({
selector: "app-user-list-container",
template: `
<app-user-list
[users]="users$ | async"
[loading]="loading$ | async"
(userSelect)="onUserSelect($event)"
>
</app-user-list>
`,
})
export class UserListContainerComponent {
users$ = this.userService.getUsers();
loading$ = this.userService.loading$;
constructor(private userService: UserService) {}
onUserSelect(user: User) {
this.router.navigate(["/users", user.id]);
}
}
// Presenter: 순수하게 UI만 담당
@Component({
selector: "app-user-list",
template: `
<div *ngIf="loading">로딩 중...</div>
<ul *ngIf="!loading">
<li *ngFor="let user of users" (click)="userSelect.emit(user)">
{{ user.name }}
</li>
</ul>
`,
})
export class UserListComponent {
@Input() users: User[];
@Input() loading: boolean;
@Output() userSelect = new EventEmitter<User>();
}Props Drilling 문제
Container-Presenter 패턴의 한계로, 깊은 컴포넌트 트리에서 데이터를 전달하려면 중간 컴포넌트들이 모두 해당 props를 전달해야 합니다.
App
└─ Page (props: theme)
└─ Section (props: theme) ← 사용하지 않지만 전달해야 함
└─ Card (props: theme) ← 사용하지 않지만 전달해야 함
└─ Button (props: theme) ← 실제 사용처
Flux 패턴
Props Drilling 문제를 해결하기 위해 등장한 단방향 데이터 흐름 패턴입니다.
┌─────────────────────────────────────────────────┐
│ │
│ Action ──→ Dispatcher ──→ Store ──→ View │
│ ↑ │ │
│ └───────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
핵심 원칙
- 단방향 흐름: 데이터는 항상 Action → Store → View 방향으로 흐름
- 단일 진실 공급원: 애플리케이션 상태는 하나의 Store에서 관리
- 예측 가능성: 같은 Action은 항상 같은 상태 변화를 일으킴
기타 유용한 디자인 패턴
Observer 패턴
상태 변경을 구독자들에게 자동으로 알리는 패턴입니다. RxJS의 기반이 되는 패턴입니다.
// Angular에서는 RxJS Subject로 구현
@Injectable({ providedIn: "root" })
export class EventBus {
private events$ = new Subject<AppEvent>();
emit(event: AppEvent) {
this.events$.next(event);
}
on(eventType: string) {
return this.events$.pipe(filter((event) => event.type === eventType));
}
}Singleton 패턴
애플리케이션 전체에서 하나의 인스턴스만 존재하도록 보장합니다. Angular의 providedIn: 'root' 서비스가 이 패턴을 따릅니다.
@Injectable({ providedIn: "root" }) // 앱 전체에서 단일 인스턴스
export class ConfigService {
private config: AppConfig;
getConfig() {
return this.config;
}
}Factory 패턴
객체 생성 로직을 캡슐화하여 유연성을 높입니다.
@Injectable({ providedIn: "root" })
export class NotificationFactory {
create(type: "success" | "error" | "warning", message: string): Notification {
switch (type) {
case "success":
return new SuccessNotification(message);
case "error":
return new ErrorNotification(message);
case "warning":
return new WarningNotification(message);
}
}
}Decorator 패턴
TypeScript/Angular에서 데코레이터는 클래스나 메서드에 동적으로 기능을 추가합니다.
// 메서드 실행 시간을 로깅하는 데코레이터
function LogExecutionTime() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
descriptor.value = function (...args: any[]) {
const start = performance.now();
const result = original.apply(this, args);
const end = performance.now();
console.log(`${propertyKey} 실행 시간: ${end - start}ms`);
return result;
};
};
}
class DataProcessor {
@LogExecutionTime()
processLargeData(data: any[]) {
// 데이터 처리 로직
}
}패턴 선택 가이드
| 상황 | 권장 패턴 |
|---|---|
| 단순한 부모-자식 데이터 전달 | @Input / @Output |
| 형제 컴포넌트 간 통신 | 공유 서비스 + RxJS |
| 깊은 컴포넌트 트리 | Flux/Redux 또는 Context |
| 전역 상태 관리 | NgRx, Akita, 또는 서비스 기반 상태 관리 |
| 재사용 가능한 UI 컴포넌트 | Presenter 패턴 (Stateless) |
권장사항
- 기본은 @Input/@Output: 단순한 경우 과도한 패턴 적용을 피함
- 컴포넌트 책임 분리: 하나의 컴포넌트는 하나의 역할만
- 데이터 fetch는 상위에서: Presenter 컴포넌트는 데이터를 받기만 함
- 상태 관리 도구는 필요할 때: Props Drilling이 문제가 될 때 도입
- 일관된 패턴 사용: 팀 내에서 합의된 패턴을 일관되게 적용