필자주

이글은 VUE컴포넌트 개념이 어느 정도 있는 사람을 기준으로 작성되었습니다.
브랜디 관리자가 bootstrap을 사용하기 때문에 코드에 bootstrap UI가 사용되었습니다.

Overview

VUE를 프로젝트에 도입하고 만족도가 매우 높습니다. 컴포넌트를 구현하고 조립하는 과정은 오래전부터 만들고자 했었던 이상적인 개발 환경과 비슷하다고 생각합니다. 하지만 막상 컴포넌트를 만들고 나면 재활용이 잘되지 않아 생산성 향상을 느끼지 못했습니다.
컴포넌트를 재활용하기 위해서 어떻게 하는것이 좋을지 나름의 고민의 결과를 공유하고자 합니다.

VUE slots개념 소개

컴포넌트 설계 설명에 앞서서 VUE의 slots 기능을 소개하고자 합니다.
slots는 두개 이상의 컴포넌트를 연결해 사용할때 구현 컴포넌트에 구현을 떠넘기는 기능입니다. 설명이 어럽기 때문에 도식을 추가해 보겠습니다.

vue


공용 컴포넌트에 구현할 부분을 slot으로 지정하여두고 구현 컴포넌트에서 구현하게 하는 방식입니다. 유사한 개발 방법론으론 전략 패턴(Strategy Pattern)이나, 템플릿 패턴(Template Pattern)과 유사합니다.
구체적인 UI 정의를 구현 컴포넌트에 떠넘겨 최종 사용자가 정의하게 하는 방식이라고 생각하시면 됩니다.

이번 글에서 slot 기능을 소개하는 것은 컴포넌트 재사용성과 관련이 있습니다.
기능적으로 거의 동일한데 만들다보면 컴포넌트를 복사해 상황에 맞게 구현할때가 많습니다. 전형적인 재활용을 못하게 하는 시나리오입니다. 동일한 기능은 공용 컴포넌트에서 구현하고, 구체적인 UI차이를 구현부에게 떠넘김으로써 비슷한 유형의 UI들을 하나의 소스로 관리할 수 있게 합니다.

데이터 테이블을 slot으로 구현하기

이론적인 설명보다는 실질적인 예를 드는것이 더 효과적일테니 많이 사용하는 유형으로 설명하겠습니다. 필자가 담당하는 관리자 프로젝트에서 아주아주 자주 등장하는 형태가 데이터 테이블형 컴포넌트입니다.

vue
아주아주 자주 사용하는 데이터 테이블 컴포넌트입니다.


UI를 보며 slot을 어떻게 지정할지 구상해 봅니다. 공통적으로 사용되는 UI를 제외하고 커스터마이징 된다면 slot이 되면 적당합니다. 필자는 아래와 같이 나눠 봤습니다.

vue
  • 데이터의 이름인 header slot
  • 데이터의 검색을 돕는 filter slot
  • 데이터의 목록인 list slot

데이터 테이블 컴포넌트 구현하기

slot의 사용법은 간단합니다.
컴포넌트를 만들때 template 내용에 slot태그를 추가하고 name 속성을 지정해 슬롯의 이름을 부여합니다.

<!-- 공용 컴포넌트 (data-table.vue) 템플릿 -->
<template>
    <table>
    <thead>
        <tr>
            <slot name="header"></slot> <!-- 이부분이 치환 됩니다 -->
        </tr>
    </thead>
    </table>
</template>

slot이 지정된 컴포넌트를 사용할 때 slot에 대한 UI를 구현해주면 됩니다.
그럼 구현 컴포넌트에서 구현된 코드가 자식 컴포넌트와 합쳐져 동작하게 됩니다.

<!-- 구현 컴포넌트의 템플릿 -->
<template>
    <data-table>
        <template slot="header">
            <!-- 이부분이 header slot에 대입 됩니다 -->
            <th>수수료 번호</th>
            <th>수수료 이름</th>
            <!-- 이하 생략 -->
        </template>
    </data-table>
</template>

실제로 사용할 data-table의 태그의 내용입니다.
앞서 정의한 slot이 위치할 부분에 slot 태그와 name 속성을 지정합니다.

<!-- 공용 컴포넌트 (data-table.vue) 템플릿 -->
<template>
    <div class="table-scrollable">
        <table class="table table-striped table-bordered table-hover">
            <thead>
                <tr class="heading">
                    <slot name="header" /> <!-- header slot -->
                </tr>
                <tr class="filter">
                    <slot name="filter" /> <!-- filter slot -->
                </tr>
            </thead>
            <tbody>
                <template v-for="row in list">
                    <tr>
                        <slot name="list" :row="row" /> <!-- list slot -->
                    </tr>
                </template>
                <tr v-if="list.length === 0">
                    <td colspan="30" class="text-center">검색 결과가 없습니다.</td>
                </tr>
            </tbody>
        </table>
    </div>
</template>


공용 컴포넌트 구현하기

데이터 테이블 컴포넌트를 임포트하고 컴포넌트 태그 밑에 slot을 구현합니다.
먼저 header slotfilter slot 부분을 구현해 보겠습니다.

<!-- implementation-component.vue -->
<template>
    <data-table>
        <!--헤더 슬롯 구현 -->
        <template slot="header">
            <th>수수료번호</th>
            <th>셀러명</th>
            <th>셀러구분</th>
            <th>수수료이름</th>
            <th>적용수수료</th>
            <th>정산분류</th>
            <th>시작일시</th>
            <th>종료일시</th>
            <th>등록자</th>
            <th>등록일시</th>
            <th>메모</th>
            <th>Actions</th>
        </template>
        <!--해더 필터 슬롯 구현 -->
        <template slot="filter">
            <td><input-number type="text" class="form-control form-filter input-sm searchable" v-model.trim="filter.settlementFeeId"/></td>
            <td><input type="text" class="form-control form-filter input-sm searchable" v-model.trim="filter.mdName"></td>
            <td>
                <select class="form-control width-auto" v-model="filter.mdSdivCd">
                    <option value="" >Select</option>
                </select>
            </td>
            <td><input type="text" class="form-control form-filter input-sm searchable" v-model.trim="filter.mdDcFeeName"></td>
            <td></td>
            <td></td>
            <td>
                <select class="form-control" v-model="filter.mdDcBeforeLock">
                    <option value="">시작잠금</option>
                    <option value="Y">Y</option>
                    <option value="N">N</option>
                </select>
            </td>
            <td>
                <select class="form-control" v-model="filter.mdDcAfterLock">
                    <option value="">종료잠금</option>
                    <option value="Y">Y</option>
                    <option value="N">N</option>
                </select>
            </td>
            <td></td>
            <td></td>
            <td></td>
            <td>
                <button class="btn btn-sm btn-warning margin-bottom" type="submit"><i class="fa fa-search"></i> </button>
                <button class="btn btn-sm btn-danger" type="button"><i class="fa fa-refresh"></i> </button>
            </td>
        </template>
    </data-table>
<template>
<script>
    import DataTable from './data-table'

    export default {
        name: "seller-fee-list",
        components: {
            DataTable
        },
        // 이하 생략
    }
</script>

앞서 소개한 것과 같이 slot 속성에 연결될 이름을 지정한 후 페이지별 맞는 UI를 구성하면 됩니다.
slot을 이용해 관리하면 data-table이라고 하는 컴포넌트를 다른 페이지에서 사용하기 수월해집니다. 화면별로 커스터마이징이 필요한 부분을 수정하기 편하기 때문이죠. 지금까지 결과로만 보면 기존보다 복잡해진것 빼고는 달라진것이 없습니다. 하지만 공통 기능이 구현되면 활용성이 올라가며 써야할 이유가 분명해 집니다.

data-table에 체크 박스 기능 넣기

게시판에서 체크박스 기능을 많이 사용할 것입니다. 이 기능을 공통 기능으로 제작해 보겠습니다.

vue


테이블 좌측에 전체 체크 박스와, 개별 체크 박스를 추가하겠습니다. 전체 체크 박스가 눌리면 개별 체크 박스 전체가 체크되며, 개별체크 박스 전체가 체크되면 전체 체크 박스도 체크되어야 합니다.

<!-- data-table.vue -->
<template>
    <div class="table-scrollable">
        <table class="table table-striped table-bordered table-hover">
            <thead>
                <tr class="heading">
                    <th width="50">
                        <input type="checkbox" v-model="allMarked"/>
                    </th>
                    <slot name="header"></slot>
                </tr>
                <tr class="filter">
                    <td></td>
                    <slot name="filter"></slot>
                </tr>
            </thead>
            <tbody>
                <template v-for="row in list">
                    <tr>
                        <td><input type="checkbox" v-model="row.checked"/></td>
                        <slot name="list" :row="row"></slot>
                    </tr>
                </template>
                <tr v-if="list.length === 0">
                    <td colspan="30" class="text-center">검색 결과가 없습니다.</td>
                </tr>
            </tbody>
        </table>
    </div>
</template>
<script>
export default {
    name: 'data-table',
    components: {
    },
    computed: {
        allMarked: {
            // 전체 체크박스가 체크 되었는지 계산한다.
            get: function() {
                for (let i=0,len=this.list.length;i<len;i++) {
                    if (!this.list[i].checked) return false;
                }
                return true;
            },
            // 전체 체크박스의 상태를 변경한다.
            set: function(v) {
                for (let i=0,len=this.list.length;i<len;i++) {
                    this.list[i].checked = v;
                }
            }
        }
    },

}
</script>

이제 data-table.vue를 가져다 쓰면 체크 박스 기능을 구현하지 않고도 편하게 쓸 수 있습니다.

VUE scope개념을 활용하기

그럼 이제 list slot에 관해서 설명해보고자 합니다. list slot은 게시물의 개수만큼 반복하여 그려야 하므로 list UI별로 row 변수를 받아서 사용합니다. 그렇지만 실제 UI를 구현하는 구현 컴포넌트 입장에선 구현 컴포넌트의 변수와의 겹칩이 발생할 수 있기 때문에 변수 보호가 필요합니다.
이럴 때 사용하는 것이 바로 slot-scope입니다.
slot-scope 속성을 이용하면 slot 안으로 변수를 제한할 수 있습니다.

slot-scope="{row}"

{row}로 작성하면 props.row를 생략 가능해 소스를 좀 더 간략하게 짤 수 있습니다.


<!-- implementation-component.vue -->
<template>
    <data-table>
        <!-- 이전 소스는 생략합니다. -->
        <!-- 리스트 슬롯 구현 -->
        <template slot="list" slot-scope="{row}">
            <td>{{row.settlementFeeId}}</td>
            <td>{{row.mdName}}</td>
            <td>{{row.mdSdivName}}</td>
            <td><a href="#" @click.prevent="showDetailPage(row)">{{row.mdDcFeeName}}</a></td>
            <td>{{percent(row.mdDcFeePercent)}}%</td>
            <td>{{row.settlementTrtDivName}}</td>
            <td>{{row.mdDcFeeStartDate}}</td>
            <td>{{row.mdDcFeeFinishDate}}</td>
            <td>{{row.registAcountName}}</td>
            <td>{{row.registDate}}</td>
            <td>{{row.mdDcFeeMemo ? row.mdDcFeeMemo : '-'}}</td>
            <td>
                <button class="btn btn-xs btn-info row_edit_btn" @click.prevent="showDetailPage(row)"><i class="fa fa-file-alt"></i>수정</button>
                <button class="btn btn-xs btn-danger row_delete_btn" @click.prevent="deleteRow(row)" v-if="isDeleteable(row)">
                    <i class="fa fa-file-alt"></i>삭제
                </button>
            </td>
        </template>
    </data-table>
<template>
<script>
    import DataTable from './data-table'

    export default {
        name: "seller-fee-list",
        components: {
            DataTable
        },
        methods: {
            showDetailPage: function(row) {
                alert(row.settlementFeeId + ' 데이터 상세 페이지로 이동합니다.');
            },
            deleteRow: function(row) {
                alert(row.settlementFeeId + ' 데이터를 삭제합니다');
            }

        }
        // 이하 생략
    }
</script>

글 삭제나, 수정 같은 row에 한정적인 기능을 구현하려면 메소드 호출시 row를 같이 전달하면 됩니다. row에는 list UI를 그릴 때 필요한 값이 모두 들어있기 때문에, 키로 사용 가능한 변수를 활용하면 됩니다.

컨피그 작업

필자는 config 객체보다는 props에 개별 구현 하는 것을 선호합니다. props로 구현할 때 자동완성이 지원되기 때문에 컨피그 설정 시 오타가 나는 것을 막을 수 있습니다.

vue

그럼 props를 이용해 checkbox가 필요하지 않은 경우를 대비해 보겠습니다.

<!-- data-table.vue -->
<template>
    <div class="table-scrollable">
        <table class="table table-striped table-bordered table-hover">
            <thead>
                <tr class="heading">
                    <th width="50" v-if="useCheckbox">
                        <input type="checkbox" v-model="allMarked"/>
                    </th>
                    <slot name="header"></slot>
                </tr>
                <tr class="filter">
                    <td v-if="useCheckbox"></td>
                    <slot name="headerFilter"></slot>
                </tr>
            </thead>
            <tbody>
                <template v-for="row in list">
                    <tr>
                        <td v-if="useCheckbox"><input type="checkbox" v-model="row.checked"/></td>
                        <slot name="list" :row="row"></slot>
                    </tr>
                </template>
                <tr v-if="list.length === 0">
                    <td colspan="30" class="text-center">검색 결과가 없습니다.</td>
                </tr>
            </tbody>
        </table>
    </div>
</template>
<script>
export default {
    name: 'data-table',
    components: {
    },
    props: {
        // 체크박스 사용 여부
        useCheckbox: {
            default: true
        }
    },
    computed: {
        allMarked: {
            // 전체 체크박스가 체크 되었는지 계산한다.
            get: function() {
                for (let i=0,len=this.list.length;i<len;i++) {
                    if (!this.list[i].checked) return false;
                }
                return true;
            },
            // 전체 체크박스의 상태를 변경한다.
            set: function(v) {
                for (let i=0,len=this.list.length;i<len;i++) {
                    this.list[i].checked = v;
                }
            }
        }
    },

}
</script>

이제 data-table를 가져다가 사용하는 쪽에서 컨트롤하면 됩니다.

<data-table :use-checkbox="false">

다른 부분들도 필요하다면 컨피그로 확장해 사용하시면 편리합니다.

주의 할점 & 참고할점

컴포넌트를 초기부터 재사용이 가능하게 설계하기는 쉽지 않습니다. 만들고자 하는 애플리케이션의 청사진이 뚜렷하게 있다면 초기부터 계획할 수 있지만, 유지 보수하며 기능을 늘려가는 과정에서 재사용성을 고민하면 시간과 효율이 나지 않습니다. 이미 사용되고 있는 컴포넌트 중 중복된 기능이 있다면 공통 기능으로 묶는 것을 추천합니다. 어떤 부분이 공통이고, 어떤 부분을 커스터마이징할 부분으로 둘지 고민을 많이 줄일 수 있을 것입니다.
어느 부분을 공통기능으로 볼지는 각 개발자의 몫이겠지만 판단할 방법은 있다고 생각합니다. 필자가 생각하는 방법은 여러 컴포넌트 중 코드의 유사성이 높은 부분을 공통으로 두고, 그렇지 않은 부분을 구현을 넘기면 된다고 생각합니다. 또한, 통신을 위한 경로 등은 컨피그로 관리할 부분은 작업을 하면서 props로 옮기면 편리합니다.
기능중 묶어봤을때 70~80%가 동일하고 20~30%가 다르다면 모두 합쳐 130%기능을 만들고 props로 스위칭하는 방법을 추천합니다.

Conclusion

어떻게 만드는 것이 최선이라는 정답은 없는 것 같습니다. 다만 어떻게 만들 때 생산성이 개선되고 개발자가 행복해지는지 지속적으로 고민할 필요가 있다고 생각합니다. 고민을 통해 반복적인 작업을 줄이면서 생겨난 시간은 다른 작업하지 마시고 생산성 향상에 다시 투자합시다. 다들 화이팅 입니다.!

참고

공식 slots 설명 페이지

https://kr.vuejs.org/v2/guide/components-slots.html


천보성 팀장 | 혁신팀
chunbs@brandi.co.kr
브랜디, 오직 예쁜 옷만