앵귤러 컴포넌트를 재활용 하는 기술

라코마코·2021년 8월 14일
5

Angular

목록 보기
2/6

Don't Repeat Yourself!
널리 알려진 개발자의 미덕(?) 입니다.
이 글은 Angualr의 상태와 로직을 재사용 가능하게 만드는 방법에 대해 공부하고 정리한 글입니다.

1. 상속을 활용한 방법

상태와 로직을 재활용하는 가장 직관적인 방법입니다.

BaseComponent에 중복되는 코드를 놓고 이를 상속하여 확장해나가는 방법으로 사용됩니다.

(여기서는 재사용할 코드가 있는 컴포넌트를 BaseComponent라 부르겠습니다. )

// Base Component
@Component({
  selector: 'app-v8',
  template: ``
})
export class VehicleComponent {
  @Input()
  engine = 'v8';
}
// Base Component를 상속하여 Input을 재활용함.
@Component({
  selector: 'app-car',
  template: `
    <p>car engine : {{ engine }}</p>
    <p>wheel: {{ wheel }}</p>
    <hr />
  `
})
export class CarComponent extends VehicleComponent {
  wheel = 4;
  changeEngine(name: string) {
    this.engine = name;
  }
}

하지만 이 방법에는 한계가 있습니다.
바로 자식 클래스가 부모 클래스에 의존을 갖게됩니다. 부모 클래스에서 변화가 발생하면 모든 자식클래스에도 변화가 발생하게 됩니다. 만약 상속 구조가 복잡하다면 사이드 이펙트를 예측하기 어려워지겠죠.

1-1. MixIn을 활용한 방법

MixIn은 함수와 상속을 활용해 동적인 클래스를 만들어 내는 방법입니다.


type Constructor<T = {}> = new (...args: any[])=> T;

type ColorMixed = Constructor<{color:string}>;
type ThemeMixed = Constructor<{theme:string}>;

@Component({
    selector: 'app-base-list',
    template: '',
})
class BaseList {
    @Input()
    label: string = 'baseLabel';

    @Input()
    description: string = 'base description'
}

function colorMixIns<T extends Constructor>(Base: T): ColorMixed{
    return class extends Base{
        color:string = 'red';
    }
}

function themeMixIns<T extends Constructor>(Base: T): T & ThemeMixed {
    return class extends Base{
        theme: string = 'modern';
    }
}

@Component({
    selector: 'app-mix-list',
    template: `
    <div>
        color: {{color}}
        theme: {{theme}}
    </div>
    `,
})
export class MixList extends themeMixIns(colorMixIns(BaseList)){}

MixIn은 함수 인자로 클래스를 받고 재활용할 코드가 담긴 클래스에 인자로 들어온 클래스를 상속하여 새로운 클래스를 만들어내는 방법입니다.

이 방법은 다중 상속 없이도 여러개의 클래스를 섞어 새로운 클래스를 만들 수 있다는 장점이 있습니다.

상속의 경우 BaseComponent를 상속하여 확장하기 때문에 부모 클래스의 프로퍼티나 메소드를 참조해 사용하는것이 가능합니다.

하지만 MixIns의 경우 BaseComponent가 자식 컴포넌트이기 때문에 서로 참조하는것이 불가능하다는 점이 있습니다. (inheritance inversion이 발생합니다.)

2. Service를 활용한 방법

상태와 로직을 Component에서 분리하여 Service에 두고 관리하는 전통적인 Angular의 방식입니다.
상태와 로직을 재활용하고 또 컴포넌트간 통신을 하는데 사용됩니다.

@Injectable()
class ShareService {
  	// 이곳에 재활용 & 공유 하고자 하는 로직과 상태를 모아둡니다.
    share: string = 'Shared';
    shareLogic(): void {
        console.log(this.share);
    }
}

@Component({
    selector: 'app-use-share',
    template: `
    <div>
        share: {{share.share}}
    </div>
    <app-other-component></app-other-component>
    `,
    providers: [ShareService]
})
export class UseShareComponent {
    constructor(public share: ShareService){}
}

@Component({
    selector: 'app-other-component',
    template: `
    <div>
        share: {{share.share}}
    </div>
    `
})
export class OtherComponent {
    constructor(public share: ShareService){}
}

하지만 이 방법에는 여러 주의사항이 있습니다.

주의사항

Service에서 공유되고있는 상태가 Observable이 아니라면 OnPush 컴포넌트에서 렌더링이 제때 되지 않을수도 있습니다.

그럴 경우 상태를 Observable로 변경하여 OnPush 컴포넌트에서 Async pipe를 통해 사용하도록 변경해야합니다.

2-1 ngrx-component-store

상태만 공유하고 싶을 경우에는 ngrx/component-store 모듈을 사용하는 방법도 있습니다.

4. Component Composition

리액트의 HOC와 비슷하다고 느낀 방법. 컴포넌트를 Content로 받아 이를 조합 하는 방식이다. 다른 점은 Angular는 ViewChild를 통해서 정밀한 Composition이 가능하다.

상속의 경우 부모 자식간 강한 결합이 발생하지만 Composition의 경우 다르다. 형식을 맞추기만 한다면 재활용이 가능하다.

interface Item {
    label: string;
    description: string;
    theme: string
}

const items: Item[] = [ ... ]

// Label Component Item을 Input으로 받아 label, description만 렌더링합니다.
@Component({
    selector: 'app-item-label',
    template: `
    <ng-container *ngIf="item">
        <h3>
            ItemLabel ReUse
        </h3>
        <div>
            label: {{item.label}}
            description: {{item.description}}
        </div>
    </ng-container>
    `
})
export class ItemLabel {
    @Input()
    item?: Item;
}

// Theme Component Item을 Input으로 받아 theme,description을 렌더링합니다.
@Component({
    selector: 'app-item-theme',
    template: `
    <ng-container *ngIf="item">
        <h3>
            ItemTheme ReUse
        </h3>
        <div>
            theme: {{item.theme}}
            description: {{item.description}}
        </div>
    </ng-container>
    `
})
export class ItemTheme{
    @Input()
    item?: Item;
}

// Composition 컴포넌트 Component를 조합하는 틀 역할을 수행합니다.
@Component({
    selector: 'app-composition',
    template: `
    <h1>
        Composition Example
    </h1>
    <ng-container *ngIf="template">
        <ng-container *ngFor="let item of items">
            <ng-container *ngTemplateOutlet = "template; context:{item:item}"></ng-container>
        </ng-container>
    </ng-container>
    `
})
export class Composition {
    items = items;

    @ContentChild('item')
    template?: TemplateRef<any>;
}

// use-case
@Component({
    selector:'app-use-case',
    template:`
	
	<!-- composition 컴포넌트에 label을 넣어 컴포넌트를 재조합합니다. -->
    <app-composition>
        <ng-template #item let-item="item">
            <app-item-label [item]="item"></app-item-label>
        </ng-template>
    </app-composition>
    <app-composition>
        <ng-template #item let-item="item">
            <app-item-theme [item]="item"></app-item-theme>
        </ng-template>
    </app-composition>
    `
})
export class UseCase{}

기본적으로 Content로 Composition할 Component를 template에 감싸 내려보낸다.

Composition Component는 template를 찾아내 *ngTemplateOutlet을 활용하여 view container에서 렌더링하는 방식으로 동작한다.

상속, MixIn 방식보다 의존성이 덜하고 다양한 방법으로 활용이 가능한 방식이다 :)

5. Container & Presenter

Container & Presenter 패턴은 기술이라기 보다는 지침에 더 가깝다.

재활용 가능한 코드는 내가 원하는 관심사와 완전히 일치하거나 그 subset일 경우에만 재활용이 가능하다.

예를 들어보자

@Component({
    selector: 'playmusic',
    template: `
        <div>
            <div>
                <h2>Your Music</h2>    
            </div>
            <div class="music-list" *ngFor="let music of musicFiles">
                <img src="music.image" />
                <h3>{{music.name}}</h3>
                <h3>{{music.artist}}</h3>
                <h3>{{music.duration}}</h3>
                <button (click)="playMusic(music)">Play</button>
                <hr />
            </div>
        </div>    
    `
})
export class PlayMusic implements OnInit {
    constructor(private musicService: MusicService) {}
    ngOnInit() {
        this.musicService.loadAllMusic().subscribe((data) => this.musicFiles = data.music)
    }
    playMusic(music) {
        //...
        this.musicService.playMusic(music)
        // ...
    }
}

PlayMusic이라는 기존 컴포넌트가 있고 이 컴포넌트의 View 영역을 가져와 사용하고싶다.

하지만 현재 상황에서는 PlayMusic 컴포넌트를 재사용 하는것이 불가능하다.

왜냐하면 이 컴포넌트에는 View 외에도 다양한 관심사가 섞여있기 때문이다.

PlayMusic 컴포넌트의 관심사

1. View 영역 (template)
2. MusicService injecting
3. Business Logic: (musicService에서 음악을 load하고있음)

PlayMusic 컴포넌트의 View를 쓰기 위해서는 원치 않는 MusicService를 인젝팅해야하며, 원하지 않는 URL에 음악을 로드하는 등의 관심사도 함께 수용해야한다.

Container & Presenter 패턴은 View 영역(Presenter) Business Logic (Container)영역을 서로 다른 관심사로 보고 분리하는 패턴이다.

// Presenter
@Component({
    selector: 'musiclist',
    template: `
            <div class="music-list" *ngFor="let music of musicFiles">
                <img src="music.image" />
                <h3>{{music.name}}</h3>
                <h3>{{music.artist}}</h3>
                <h3>{{music.duration}}</h3>
                <button (click)="playMusic.emit(music)">Play</button>
                <hr />
            </div>    
    `,
    changeDetection: ChangeDetectionStrategy.OnPush
})
export class MusicList {
    @Input() musicFiles;
    @Output() playMusic = new EventEmitter();
}

// Container
@Component({
    selector: 'playmusic',
    template: `
        <div>
            <div>
                <h2>Your Music</h2>    
                <musiclist [musicFiles]="musicFiles" (playMusic)="plMusic($target)"></musiclist>
            </div>
        </div>    
})
export class PlayMusic implements OnInit {
    constructor(private musicService: MusicService) {}
    ngOnInit() {
        this.musicService.loadAllMusic().subscribe(data => this.musicFiles = data.music)
    }
    // ...
    plMusic(target) {
        // ...
    }
}

Container Presenter 패턴을 따른다면 Presenter는 순수한 View 영역이 되기 때문에 재사용성이 높아진다.

6. Decorators를 활용한 방법

Angular 9+ 이상부터 사용이 가능합니다.

Decorators를 사용하면 클래스간 자주 사용하는 공통 로직을 추상화 시켜서 사용하는것이 가능합니다.

대표적으로 ngOnDestroy 메소드에서 subscriptions들을 처리하는 로직이 있다고 가정해볼게요.

@Component({

})
export class SubscriptionComponent {
  
  subscriptions: Subscription[] = [];
  
  ...
  
  ngOnDestroy(){
    this.subscriptions.forEach(sub=>sub.unsubscribe());
  }
}

Angular에서는 rxjs를 자주 사용하기 때문에 subscriptions들을 정리하는 코드를 각 컴포넌트에 복사 붙여넣기한 경험이 한번쯤은 있을것입니다.

이런 로직은 Decorators를 사용하면 깔끔하게 처리하는것이 가능합니다.

// utils : constructor 함수를 가진 class Interface
interface ClassConstructor {
    new (...args:any[]):void;
    [index:string]:any;
}

// utils : Subscriptions 타입인지 검증하는 함수
function isSubscriptions(subscription: any): boolean{
    return subscription instanceof Subscription;
}

// utils : subscriptions들을 unSubscribe 시키는 함수, 배열이면 1depth 돌면서 처리한다.
function unSubscribeAll(array: [] | any){
    if(!Array.isArray(array)){
        if(isSubscriptions(array)){
            array.unsubscribe();
        }
        return;
    }

    if(Array.isArray(array)){
        array.forEach(item=>{
            if(isSubscriptions(item)){
                item.unsubscribe();
            }
        })
    }
}

// decorator 함수 인자로 Component 객체를 받습니다.
function removeSubscriptions(target:ClassConstructor){
    const originalDestroy = target.prototype.ngOnDestroy;
  
  	// ngOnDestroy 함수에 기능을 덧붙일 예정이기 때문에 Wrapper 함수를 만듭니다.
    function ngOnDestroyWrapper(this: any){
      	//ngOnDestroy가 컴포넌트에 있었다면 실행시킨다.
        if(originalDestroy){
            originalDestroy.call(this);
        }
      
      	// property를 돌면서 unSubscribe 시킨다.
        for(const propertyName in this){
            const property = this[propertyName];
            unSubscribeAll(property);
        }
    }
  	// 생성한 Wrapper 함수를 ngOnDestroy에 덮어씌웁니다.
    target.prototype.ngOnDestroy = ngOnDestroyWrapper;
}


@removeSubscriptions
@Component({
    selector:'deco-ex',
    template:``
})
export class DecoratorExample implements OnInit {
    select:Subscription[] = [];
    timerSubscription?:Subscription;
    isEnd?:Subscription;
    ngOnInit(): void {
        this.select.push(timer(1000).subscribe());
        this.select.push(timer(1000).subscribe());
        this.timerSubscription = timer(1000).subscribe();
        this.isEnd = interval(1000).subscribe((x)=>{
            console.log(x);
        });
    }
}

ComponentClass에 removeSubScriptions 데코레이터를 붙여 Subscriptions를 자동화 시키는 코드를 바인딩하는게 가능하다.

히지만 이 방법에는 많은 제약사항들이 존재합니다.

제약사항들

  • Decorator를 통해서 Class를 변경하는것은 가능하지만 Typescript가 변경된 내용을 추적하지 못합니다. 관련 이슈

  • Decorator를 통해서 Component 클래스를 조작하면 조작된 부분에 대해서는 Ivy가 인지를 못해 tree-shake 되지 못할수도 있습니다. 관련 아티클 (하단 부분에 있어요)

  • 사용하기 굉장히 어렵습니다. (제 생각으로는요)
    위 예제 코드는 기존 기능에 Wrapping을 적용하는 코드이기 때문에 단순하지만.. 조금 더 디테일하고 복잡한 기능을 추가하기 위해선 컴포넌트의 cmp,factory 같은 기능을 사용해야하고 Ivy엔진에 대한 이해가 있어야 직접 커스텀 할 수 있겠다 라는 생각이 들었습니다.

그럼에도 Decorator를 통한 방법은 굉장히 깔끔하고, 재사용성도 높은 방법입니다.

앵귤러 커뮤니티에서도 관심이 높기에 앵귤러 버전이 높아지면 사용성도 확실히 개선될것이라고 보입니다.

마무리

제가 공부하면서 알아낸 컴포넌트를 재사용하기 위한 테크닉들을 쭉 나열해보았습니다.

가장 인상 깊었던건 Mixins, Component Composition, Decorators를 활용한 방법이 가장 재밌었습니다. (특히 Decorators를 활용한 방법이 제일 재밌었고 발전 가능성도 무궁무진해 보였습니다.)

이 3 부분은 따로 집중적으로 공부해서 다시 새로운 글로 작성할 예정입니다.


참고한 사이트
앵귤러 컴포넌트를 재활용 하는 테크닉

재활용 가능한 컴포넌트를 만드는 best practices

앵귤러 Decorators

Decorators

until-destroy 소스코드

profile
여러가지 화면을 만드는걸 좋아합니다. https://codepen.io/raiden2

3개의 댓글

comment-user-thumbnail
2021년 8월 19일

우와... 검색하다가 찾았는데 앵귤러 마스터가 요기있네요 잘 보고갑니다요 👍👍

답글 달기
comment-user-thumbnail
2021년 8월 19일

component composition 첨에 보고 오...머지...머싯땅...하기만 했는데 글 보고 확실히 이해가 되었어요!! 감사합니다!! 👍

답글 달기
comment-user-thumbnail
2021년 8월 19일

우와... 이거 진짜 필요했는데, 너무 감사합니당 ㅠㅠㅠㅠㅠㅠㅠ

답글 달기