안랩에 입사를 하고, 프론트엔드 팀원 1명과 함께 맡게된 주요 과제는 Vue2에서 Vue3의 코어 업그레이드를 진행하는 것이었어요. 사실 Vue3에서는 Vue2의 문법을 Deprecated 시켰지만, 여전히 지원을 하고 있었기에 업그레이드 자체는 문제가 되지 않았으나 여기서 마주한 문제는 인터페이스를 구성하기 위해 기존에 도입되었던 Vuetify 라는 UI 라이브러리였어요. Vuetify 라이브러리 또한 메이저 업데이트를 진행하면서 많은 속성과 상태 값들이 변경이 되었고, 가이드 문서 또한 그리 친절하지 못해 생각보다 업그레이드에 대한 공수가 많이 들게 되었답니다. 그래서 들었던 생각은 “왜 자체적으로 컴포넌트를 만들지 않았을까?” 였어요.
자체적으로 도메인에 맞춘 컴포넌트들을 만들게 된다면, 마이그레이션 시에 코드 추적이 Vuetify 의 가이드 문서를 보는 것보다 용이할 뿐만 아니라 어떠한 수정에도 조금 더 세부적으로 커스텀할 수 있다는 장점을 가지고 있어요. 단점보다 장점이 더 큰 도전이었기에 백오피스에 인터널한 커스텀 컴포넌트들을 도입하기 시작했어요.
이미 사내 프론트엔드 개발자들은 Vuetify에 익숙해져있었기 때문에 (저는 사실 리액트가 익숙하긴 하지만..) Vuetify가 Vue에서 유명한 라이브러리이기도 하고, 사용성 자체는 편리하긴 했어요. 그래서 Vuefiy처럼 사용성이 편하면서 디자인은 백오피스의 디자인 컨셉에 맞게 커스텀 컴포넌트들을 설계하기 시작했어요.
아무래도 백오피스의 초기 컨셉은 “개발자들이 만든 백오피스” 라는 컨셉이 강했어요. 그래서 CSS Custom Properties도 정의가 되어있지 않았고, 오로지 디자인은 Vuetify로 끝이 나고 있었죠. 그래서 TOSS의 디자인 토큰과 Shadcn의 디자인 토큰을 참고하여 CSS Custom Properties를 정의하고, 개발자가 색상 값을 직접 정의하는 것이 아닌 내부적으로 설정된 토큰 값으로 디자인의 통일 성을 더했어요.
커스텀 컴포넌트라고 하지만 여러 상태 값을 줘야한다던지, 여러 상태 값을 요구하는 컴포넌트는 오히려 개발자들에게 혼동을 줄 수 있다고 생각했어요. React에서는 상태를 변경하기 위해 getter · setter가 존재하지만 Vue에서는 양방향 바인딩 모델을 지원하기 때문에 이를 활용해 v-model 모델만 사용하면 값이 바뀔 수 있는 컴포넌트들을 여러 개 개발했어요. 그리고 지금 아래의 예제 같이 사용을 하고 있어요.
<toggle v-model="isToggle" />

<aside>
**구축된 커스텀 컴포넌트들로 아래와 같은 작업들을 편하게 할 수 있었어요
기존의 대시보드가 이런 식으로 구성이 되어있었다면,**

아래와 같이 만들어진 커스텀 컴포넌트들로만 새롭게 리뉴얼을 진행할 수 있었어요!





</aside>
인터널 형태로 제작된 커스텀 컴포넌트들이 어떤 것들이 존재하는지 손쉽게 파악하기 위해서 StoryBook을 도입했어요. (@stroybook/vue3-webpack5 프레임워크 사용)
아래 코드는 스토리 북 설정을 위한 main.js 코드인데 .scss 파일을 처리하는 새로운 로더 규칙을 직접 추가해줬어요.
oneOf 옵션으로 <style lang="scss" module>과 <style lang="scss"> 와 같은 Vue 내부 컴포넌트에서 처리하는 스타일 로직까지 같이 처리를 할 수 있어요.
// storybook/main.js
const path = require('path');
module.exports = {
stories: [
'../src/**/*.stories.mdx',
'../src/**/*.stories.@(js|ts|vue)'
],
addons: [
'@storybook/addon-essentials',
],
framework: {
name: '@storybook/vue3-webpack5',
options: {}
},
docs: {
autodocs: true
},
staticDirs: ['../public'],
webpackFinal: async (config) => {
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname, '../src')
};
config.module.rules = config.module.rules.filter((rule) => {
try {
if (rule.test && rule.test.toString().includes('scss')) return false;
if (rule.use && Array.isArray(rule.use)) {
return !rule.use.some((u) => {
if (typeof u === 'string') return u.includes('sass-loader');
if (u && u.loader) return String(u.loader).includes('sass-loader');
return false;
});
}
return true;
} catch (e) {
return true;
}
});
config.module.rules.unshift({
test: /\\.scss$/,
oneOf: [
{
resourceQuery: /module/,
use: [
'vue-style-loader',
{
loader: 'css-loader',
options: {
esModule: false,
modules: {
localIdentName: '[name]__[local]__[hash:base64:5]',
exportLocalsConvention: 'asIs'
}
}
},
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
sassOptions: {
includePaths: [path.resolve(__dirname, '../src')]
}
}
}
]
},
{
use: [
'vue-style-loader',
{ loader: 'css-loader', options: { esModule: false } },
{
loader: 'sass-loader',
options: {
implementation: require('sass'),
sassOptions: {
includePaths: [path.resolve(__dirname, '../src')]
}
}
}
],
include: path.resolve(__dirname, '../'),
}
]
});
return config;
}
};