Tech Blog
Tech Blog
Docs프론트엔드 컴포넌트 설계와 디자인 패턴
GitHub
Architecture47분

프론트엔드 컴포넌트 설계와 디자인 패턴

느슨한 결합 원칙과 Container-Presenter, Flux 등 프론트엔드 아키텍처 패턴

2025년 1월 22일
architecturedesign-patternloose-couplingfluxcontainer-presenter

프론트엔드 컴포넌트 설계와 디자인 패턴

적용 환경: Angular 14+, RxJS 7+, React 18+, TypeScript 4.7+

개요

프론트엔드 애플리케이션의 규모가 커질수록 컴포넌트 간의 관계를 어떻게 설계하느냐가 유지보수성을 결정합니다. 이 문서에서는 느슨한 결합(Loose Coupling)의 원칙과 이를 구현하기 위한 디자인 패턴을 다룹니다.

느슨한 결합 (Loose Coupling)

정의

두 객체가 느슨하게 결합되어 있다는 것은, 서로 상호작용은 하지만 서로에 대해 최소한만 알고 있다는 것을 의미합니다.

왜 중요한가

결합도특징결과
강한 결합컴포넌트가 서로의 내부 구현을 알고 있음하나를 수정하면 연쇄적으로 수정 필요
느슨한 결합컴포넌트가 인터페이스로만 소통독립적으로 수정/테스트 가능

자식 컴포넌트에서 직접 Fetch하면 안 되는 이유

TypeScript
// ❌ 강한 결합: 컴포넌트 내부에서 직접 데이터 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;
    });
  }
}

이 컴포넌트는 다음과 같은 문제가 있습니다.

  1. 재사용 불가: 다른 사용자 정보를 보여주고 싶을 때 사용할 수 없음
  2. 테스트 어려움: 항상 UserService를 모킹해야 함
  3. 부모와 강하게 결합: "현재 로그인한 사용자"라는 컨텍스트에 종속
TypeScript
// ✅ 느슨한 결합: @Input으로 데이터를 받음
@Component({
  selector: "app-user-profile",
  template: `<div>{{ user?.name }}</div>`,
})
export class UserProfileComponent {
  @Input() user: User;
}

이제 이 컴포넌트는 어떤 사용자 데이터든 받아서 표시할 수 있습니다. 데이터를 어디서 가져올지는 부모가 결정합니다.

Angular에서 느슨한 결합 구현

1. @Input / @Output 데코레이터

TypeScript
// 자식 컴포넌트
@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)

TypeScript
// 서비스 정의
@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를 활용한 반응형 데이터 흐름

TypeScript
@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)
역할비즈니스 로직, 데이터 fetchUI 렌더링
상태상태를 관리함상태 없음 (stateless)
의존성서비스, 스토어에 의존props/Input에만 의존
재사용성낮음높음
TypeScript
// 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의 기반이 되는 패턴입니다.

TypeScript
// 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' 서비스가 이 패턴을 따릅니다.

TypeScript
@Injectable({ providedIn: "root" }) // 앱 전체에서 단일 인스턴스
export class ConfigService {
  private config: AppConfig;
 
  getConfig() {
    return this.config;
  }
}

Factory 패턴

객체 생성 로직을 캡슐화하여 유연성을 높입니다.

TypeScript
@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에서 데코레이터는 클래스나 메서드에 동적으로 기능을 추가합니다.

TypeScript
// 메서드 실행 시간을 로깅하는 데코레이터
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)

권장사항

  1. 기본은 @Input/@Output: 단순한 경우 과도한 패턴 적용을 피함
  2. 컴포넌트 책임 분리: 하나의 컴포넌트는 하나의 역할만
  3. 데이터 fetch는 상위에서: Presenter 컴포넌트는 데이터를 받기만 함
  4. 상태 관리 도구는 필요할 때: Props Drilling이 문제가 될 때 도입
  5. 일관된 패턴 사용: 팀 내에서 합의된 패턴을 일관되게 적용

참고 자료

  • Angular - Component Interaction
  • Flux Architecture
  • NgRx - Reactive State for Angular
  • Patterns.dev - Design Patterns
Written by

Mirunamu (Park Geonwoo)

Software Developer

관련 글 더보기

Architecture

Feature Sliced Design 아키텍처 가이드

비즈니스 도메인 중심의 프론트엔드 프로젝트 구조화 방법론

읽기
다음 글Feature Sliced Design 아키텍처 가이드
목차
  • 프론트엔드 컴포넌트 설계와 디자인 패턴
    • 개요
    • 느슨한 결합 (Loose Coupling)
      • 정의
      • 왜 중요한가
      • 자식 컴포넌트에서 직접 Fetch하면 안 되는 이유
    • Angular에서 느슨한 결합 구현
      • 1. @Input / @Output 데코레이터
      • 2. 의존성 주입 (Dependency Injection)
      • 3. RxJS를 활용한 반응형 데이터 흐름
    • 프론트엔드 아키텍처 패턴의 발전
      • MVC → MVVM → Component → Flux
      • Container-Presenter 패턴
      • Props Drilling 문제
      • Flux 패턴
    • 기타 유용한 디자인 패턴
      • Observer 패턴
      • Singleton 패턴
      • Factory 패턴
      • Decorator 패턴
    • 패턴 선택 가이드
    • 권장사항
    • 참고 자료