이 글은 제이쿼리, PHP 기반의 쇼핑몰 서비스에 Vue를 도입한 사례를 정리한 내용입니다.
서비스에 Vue 도입을 고민 중이신 분들을 위해 경험기를 공유합니다.

배경

배민찬은 푸드 커머스 사업에 혁신을 만들어가는 스타트업 서비스다. 우리 개발팀은 솔루션 기반의 커스터마이징된 쇼핑몰을 시작으로 올해 3년 이상 레거시 코드와 분투 중이다.

팀내 프론트엔드 개발자로서 리엑트, 앵귤러, 노드 같은 트렌디한 키워드가 떠올랐고, 하루라도 빨리 효율적인 기술 스택으로 갈아타고 싶었다. 제한된 개발 리소스에 빅뱅 방식의 개선은 비현실적이라고 판단. 조금씩 시도해 보고 빠르게 결과를 검증해 나가는 방법이 필요했다.

점진적인(Progressive) 자바스크립트 프레임워크

“점진적인”을 슬로건으로 내세운 Vue 프레임웍은 이러한 고민에 대한 일말의 해결책으로 보였다. 곧장 기술 조사와 안정성 검토 후 팀내 소프트 랜딩을 위한 전파 교육을 진행했고, 기존의 기술 부채를 조금씩 개선해 나가기 시작했다.

한 페이지만 CDN으로 다운받아 사용해 보는 것을 시작으로 NPM 다운로드를 통해 좀 더 확대 적용하였다. 결국 .vue라는 단일파일컴포넌트(Single File Component)까지 들여왔고 하나의 파일에서 마크업, 스크립트, 스타일을 관리하는 방법으로 확대했다. 이것은 Vue가 주장하는 “점진적인” 방법을 활용하는 것이었고 운영중인 서비스에 안정적으로 기술을 도입하는 좋은 방법이었다.

컴포넌트 재사용

레거시의 가장 큰 문제는 코드의 중복! 하나의 코드로 다양한 디바이스에 동작하는 반응형 웹과는 달리, 배민찬은 디바이스별로 파일을 작성해야 하는 일반적인 웹이다.

배민찬 모바일 [모바일과 데스크탑]


모바일 페이지 개발을 마치고 비슷한 데스크탑 페이지를 만드는 것은 무척이나 지루한 일이다. 디바이스 크기별로 작성하는 마크업과 스크립트는 중복이 많았고 소프트웨어 공학 관점에서도 전혀 드라이(DRY)하지 않은 코드로 보였다.

Vue의 컴포넌트 조합은 이러한 문제를 꽤나 효율적으로 해결할 수 있는 도구인데 가이드라인에 따르면 두 가지 방법이 있다.

믹스인확장(extends)

공통의 부모 컴포넌트를 만들고 이를 자식 컴포넌트가 상속하는 믹스인 방식보다는, 기존 컴포넌트를 확장해서 유사한 컴포넌트를 만들 수 있는 extends 옵션이 더 적합해 보였다. 모바일 컴포넌트를 먼저 만들고 이를 확장한 데스크탑 컴포넌트를 만들기 때문이다.

코드로 보면, 먼저 모바일 컴포넌트를 이용해서 화면을 만든다.

// mobile/Product.vue 

<template>
  <!-- 모바일 화면 -->
</template>

<script>
  export default {
    data () { /* ... */ },
    created () { /* ... */ },
    methods: { /* ... */ },
  }
</script>

이러한 모바일 컴포넌트는 extends 옵션을 이용하면 단숨에 데스크탑 컴포넌트를 정의하는 데 재사용 될 수 있다.

// desktop/Product.vue 

<template>
  <!-- 데스크탑 화면 -->
</template>

<script>
  // 모바일 컴포넌트를 가져와
  import ProductMobile from '../mobile/Product.vue'

  export default {
    // 데스크탑 컴포넌트로 확장한다 
    extends: ProductMobile 
  }
</script>

라이프사이클 훅과 메소드는 일정한 규칙에 따라 오버라이딩 되기 때문에 두 컴포넌트는 같은 코드를 공유하면서 비슷하게 동작한다.

이러한 코드 재사용은 개발 속도를 비교적 빠르게 앞당겼고 무엇보다 신나는 일이었다! 컴포넌트 병합 옵션에 대한 몇 개 특성만 이해하면, 다중 뷰를 위한 효율적인 컴포넌트 설계는 그렇게 어려운 일이 아니라고 생각한다. 둘 간의 차이를 잘 살펴보고 사용하기 바란다. (병합 옵션에 대한 자세한 사항은 UI 컴포넌트 확장 참고)

웬만하면 단순하게

이미 Vue 기술을 도입한 깃랩은 블로그(번역)에서 언급한 것처럼 웬만하면 Vuex를 사용한다. 하지만 배민찬에서는 다소 부담되는 상황이다. 백엔드 개발자 위주의 팀에서 프론트엔드 기술(예를 들어 Flux) 이해에 대한 요구는 개별로 다르기 때문이다. 최대한 심플하게 뷰를 사용하고 싶었다.

단순하게 [웬만하면 단순하게]

기술 도입에 앞서 먼저 동료들이 어떻게 Vue를 사용하는지 유심히 관찰했다. SFC 개발환경까지 갖추었지만, 예상과 다르게 하나의 루트 컴포넌트로만 화면을 만들고 있었다. 제이쿼리 기반의 화면 개발에 지친 우리는 v-bind를 필두로 한 다양한 Vue 디렉티브 사용에 더 흥미를 느꼈던 것이다.

돔(DOM) 조작으로 화면을 직접 제어하는 것은 복잡한 코드로 이어지기 쉽다. 반면, Vue 디렉티브는 데이터 기반의 사고를 유도하기 때문에 화면 로직과 맞닿아 있지는 않다. 데이터만 잘 다루면 화면은 Vue가 알아서 제어해 준다.

UI 기반의 사고에서 데이터 기반의 사고 전환

이것은 데이터를 다루는 백엔드 개발자가 Vue를 바라보는 매력 포인트라고 생각한다.

그럼에도 불구하고 화면이 복잡해지면 컴포넌트로 쪼개야 하고 상태관리 솔루션을 도입하고 싶은 유혹이 생긴다. 아직은 충분히 Vue의 기본 기능에 익숙해져야 하는 단계라고 생각했고 대안이 필요했다.

  • 규칙 1: 컴포넌트 통신은 props와 이벤트로
  • 규칙 2: 컴포넌트는 최대 두 단계로

복잡한 페이지가 아닌 이상, 위의 두 가지 규칙만으로도 화면을 구성하는데는 충분했다. 나중에는 2단계로 분리한 컴포넌트(예를 들어 페이지네이션)를 공통 파일로 분리한 뒤 적재적소에 끼워 넣어 재활용할 수 있었다.

페이지네이션 활용

페이지네이션 [페이지네이션 컴포넌트를 재활용할 수 있다]

디렉티브와 필터

데이터 중심의 화면 개발임에도 불구하고 돔을 직접 제어해야 하는 경우는 불가피했다. swipersticky-kit 같은 제이쿼리 기반의 플러그인이 그러한 경우다. 이것을 Vue로 직접 구현하기보다는 어떻게든 Vue에 녹여내는 게 더 효율적인 방법인데……

Vue는 돔 접근을 위해 커스텀 디렉티브를 만들라고 안내한다. 슬라이드에 사용하는 swiper 디렉티브로 래핑한 예제를 보면 써드파티 라이브러리를 어떻게 사용하는지 알 수 있다.

// directives/swiper.js

// 커스텀 디렉티브로 감싸기 위한 써드파티 라이브러리를 가져온다 
import swiper from 'swiper'

export default {
  inserted(el) {
    new Swiper(el) // el로 돔 엘레멘트에 접근할 수 있다 
  },
}

디렉티브 훅 함수(여기서는 inserted)를 적절히 이용해서 서드파티 라이브러리리가 돔에 접근하도록 도와줄 수 있다.

이것을 컴포넌트에서 사용하려면 뷰 생성 객체에 directive 키로 전달하면 된다.

<template>
  <div v-swiper></div> <!-- 커스텀 디렉티브를 사용한다 -->
</template>

<script>
// 커스텀 디렉티브를 가져와 
import swiper from '../directives/swiper.js'

export default {
  directives: { swiper } // 컴포넌트 생성시 등록한다 
}
</script>


한편 값을 변경하는 유틸리티성 함수는 Vue의 필터로 정의한다. 예를 들어 숫자 형식을 출력하는 필터를 다음과 같이 만들 수 있다.

// filters/numberFormat.js

// 커스텀 필터 함수를 정의한다 
export default (value = '') => { 
  value = '' + value

  // 숫자를 세 자리 마다 쉼표를 넣은 문자로 변환한다 (1000 -> '1,000')
  return value.split('').reverse().reduce((acc, digit, i) => {
    if (i > 0 && i % 3 === 0) acc.push(',')
    return [...acc, digit]
  }, []).reverse().join('')
}

이것을 사용하려면 뷰 생성 객체의 filters 키로 추가해야 한다.

<template>
  <div>
    {{ amount | numberFormat }} <!-- "1,000" -->
  <div>
</template>

<script>
// 커스텀 필터를 가져와 
import numberFormat from '../filters/numberFormat.js'

export default {
  data() { return { amount: 1000 } },
  filters: { numberFormat } // 컴포넌트 생성시 등록한다 
}
</script>

디렉티브와 필터 모두 필요할 때마다 컴포넌트에 코드를 주입하여 재사용할 수 있다.

웹팩 설정

뷰 스캐폴딩을 자동으로 만들어주는 vue-cli는 기본적으로 SPA를 위한 프로젝트를 생성한다. 그러나 배민찬 서비스는 각 페이지별로 자바스크립트를 로딩하는 고전적인 MPA 구조라서 다른 방법이 필요하다.

처음에는 웹팩의 엔트리를 이렇게 수동으로 작성했다.

// webpack.config.js

module.exports = {
  entry: {
    'mobile.home': './mobile/home/app.js', 
    'desktop.home': './desktop/home/main.js',
  }
}

화면별로 폴더를 나누고 (/mobile/home, /desktop/home) 이에 따라 자바스크립트 파일을 분류해서 생성한다. 화면을 추가할 때마다 엔트리 포인트 추가를 위한 웹팩 설정파일을 수정해야 하는 상황이다.

자동화가 필요한 시점!

규칙을 정했다. 화면별로 유일한 엔트리 포인트가 있는데 전부 같은 파일명(app.js)으로 만들고, 이러한 약속하에 엔트리 포인트를 자동 생성하는 코드를 추가했다.

// webpack.config.js

const getEntries = (root, prefix, ptn = '**/app.js') => {
  // 하위폴더에서 app.js 파일을 모두 찾는다 
  const files = glob.sync(`${root}/${ptn}`)

  // 엔트리이름과 경로로 구성된 객체를 생성한다 
  return files.reduce((entries, file) => {
    const tokens = file.split('/')
    const parentDirectory = tokens[tokens.length - 2]
    const bundleName = `${prefix}.${parentDirectory}`
    entries[bundleName] = file
    return entries
  }, {})
}


module.exports = {
  entry: _=> ({
    ...getEntries('./mobile', 'mobile'),
    ...getEntries('./desktop', 'desktop'),
  }),
}

getEntries() 함수로 각 화면별 스크립트가 있는 최상위 폴더명과 번들 파일명의 꼬리표(prefix)를 전달한다. 그 결과 모든 폴더의 app.js 경로를 찾아 번들명을 키로 하는 엔트리 객체를 만들어 낼 수 있다.

이렇게 자바스크립트 엔트리 포인트를 자동화함으로써 웹팩 수정 없이 자바스크립트를 추가할 수 있다.

정리하면

프로그레시브한 Vue는 앵귤러, 리엑트에 비해 사전작업이 거의 없다. CDN 주소를 스크립트 태그에 로딩한 뒤 그냥 쓰면 된다. 익숙한 제이쿼리처럼 말이다. 이런 모습이 운영 중인 서비스에 Vue를 사용하는데 비교적 가볍게 느껴졌다.

vue logo [Vue 홈페이지]

뷰의 컴포넌트 확장 방법은 데스크탑과 모바일 페이지를 따로 개발해야 할 때 매우 효율적이다. 모바일 퍼스트라고 하지만 여전히 데스크탑을 무시할 수 없고 커머스라면 더욱 그렇다. 한정된 리소스로 두 개의 플랫폼을 개발해야 하는 상황이라면 컴포넌트 재사용은 꽤 효율적인 솔루션이다.

화면 중심의 개발 방법에서 데이터 중심의 사고로 전환할 수 있다. 까다로운 화면 제어를 Vue에게 맡겨버리고 데이터 위주로 사고하면 UI 개발의 스트레스는 줄어들고 생산성은 향상된다.

다른 UI 프레임웍과 달리 Vue SFC는 마크업과 스타일을 한 파일에 정의할수 있으므로, 컴포넌트를 잘만 설계한다면 퍼블리셔와의 협업도 기대해 볼 수 있다.

배민찬에는 아직 할 일이 많다. 레거시를 탐험하고 멋진 코드로 개선하고 싶다면, 그리고 그런 경험이 절실하다면 우리 회사에 지원해 보는 것은 어떨까?

// 꼼꼼하게 리뷰해 주신 기술블로그 파워 커미터 종립님께 감사드립니다 🙏