grep

Engineering

항공 프론트엔드 구축기 (2/10): Vue2 디자인 시스템을 React로 옮기기

에릭Eric_jeong(정용욱)여기어때

2026년 8월 5일

원문에서 보기 ↗

글. 정용욱(Eric) / 서비스웹개발팀

항공 프론트엔드 구축기: Vue2 디자인 시스템을 React로 옮기기

안녕하세요. 서비스웹개발팀의 에릭입니다.

지난 편에서 웹, 모바일웹, 웹뷰를 코드 한 벌로 가기로 한 결정을 이야기했습니다. 이번 편은 그 첫 작업, Vue2 디자인 시스템을 React로 옮긴 과정입니다.

다시 만들지 않고 옮길 수 있었던 조건이 무엇이었는지가 이번 편의 중심입니다.

보통은 다시 만듭니다

서비스웹개발팀과 웹뷰개발팀이 합쳐진 뒤, 항공 서비스를 새로 만들기로 했습니다. 스택은 React 기반의 Next.js, 스타일링은 Tailwind로 정했습니다.

문제는 컴포넌트였습니다. 버튼, 체크박스, 바텀시트, 다이얼로그… 서비스 하나를 만들려면 수십 개가 필요합니다. 그리고 그건 이미 있었습니다. 사내에서 오래 써 온 Vue2 기반 공용 라이브러리에, 디자인 시스템 컴포넌트가 전부 들어 있었습니다.

Vue2예요. React 프로젝트에서 쓸 수 없습니다.

이럴 때 보통은 다시 만듭니다. 디자인 스펙 문서를 보고 React로 재구현하는 거죠. 정석이고, 실제로 그렇게 하는 팀이 많습니다. 다만 그러려면 수치를 하나하나 다시 대조해야 하고, 스펙에 안 적힌 상태가 나올 때마다 물어봐야 합니다. 다 만든 뒤에는 새로 만든 것이니 검증도 다시 거칩니다.

일정상 그럴 시간이 없었습니다. 다행히 근거 없는 도박은 아니었습니다.

Vue2 라이브러리는 프레임워크에 종속된 UI 라이브러리를 쓰지 않았고, 디자인 토큰은 컴포넌트 밖 설정에 정리되어 있었습니다. 두 팀 다 원래 Tailwind 기반으로 개발하고 있어서, 스타일링 방식도 같았습니다. 옮기는 게 가능해 보였고, 그래서 옮겨보기로 했습니다.

수치가 하나도 안 바뀌었습니다

결과부터 보겠습니다. 버튼 컴포넌트의 크기별 스타일입니다.

// Vue2
s   twl-yds6-TypoUi-12          twl-font-Semibold  twl-min-h-[32px]  twl-rounded-8   twl-px-12
m   twl-yds6-TypoUi-14          twl-font-Semibold  twl-min-h-[40px]  twl-rounded-8   twl-px-14
l   twl-yds6-Body-Lg-Singleline twl-font-Bold      twl-min-h-[48px]  twl-rounded-10  twl-px-18
xl  twl-yds6-Title-Md-Singleline twl-font-Bold     twl-min-h-[56px]  twl-rounded-10  twl-px-20

// React
s   yf-typo-ui-12           yf-h-32  yf-rounded-8   yf-px-12  yf-font-semibold
m   yf-typo-ui-14           yf-h-40  yf-rounded-8   yf-px-14  yf-font-semibold
l   yf-body-lg-singleline   yf-h-48  yf-rounded-10  yf-px-18  yf-font-bold
xl  yf-title-md-singleline  yf-h-56  yf-rounded-10  yf-px-20  yf-font-bold

모서리 8/8/10/10, 좌우 여백 12/14/18/20, 높이 32/40/48/56. 하나도 안 바뀌었습니다.

바뀐 건 두 가지뿐입니다. 토큰 이름의 표기법이 파스칼케이스(PascalCase)에서 케밥케이스(kebab-case)로 바뀌었고(yds6-TypoUi-14 → typo-ui-14), 임의값이던 min-h-[40px]이 스케일값 h-40이 되었습니다. 둘 다 계산 결과는 같습니다.

토큰이 정말 같은 값인지도 확인해 봤습니다. 양쪽 타이포그래피 플러그인을 열어서 대조했습니다.

Vue2    .yds6-TypoUi-14 { fontSize: 14px;   lineHeight: 17px }
React   .typo-ui-14     { fontSize: "14px"; lineHeight: "17px" }

같았습니다. 색상도 마찬가지였습니다.

쓰는 쪽 코드도 거의 그대로였습니다

앞의 비교는 컴포넌트 안쪽입니다. 실제로 개발자가 마주하는 건 그걸 쓰는 코드죠. 같은 버튼을 화면에서 부르는 모습을 나란히 놓으면 이렇습니다.

<!-- Vue2 -->
<Yds6BoxButton variant="secondary" size="l" loading @click="handleClick">
  결제하기
</Yds6BoxButton>

<Yds6BoxButton
  variant="primary"
  prefix-icon="login_kakao"
  text-color="rgba(60, 30, 30, 1)"
  bg-color="var(--logo-kakaoY)"
>
  카카오톡으로 공유하기
</Yds6BoxButton>
{/* React */}
<BoxButton variant="secondary" size="l" isLoading onClick={handleClick}>
  결제하기
</BoxButton>
<BoxButton
  variant="primary"
  prefixIcon="login_kakao"
  textColor="rgba(60, 30, 30, 1)"
  bgColor="var(--logo-kakaoY)"
>
  카카오톡으로 공유하기
</BoxButton>

바뀐 게 네 가지뿐입니다. 컴포넌트 이름에서 접두어가 빠졌고, prop 이름이 케밥케이스(kebab-case)에서 카멜케이스(camelCase)가 됐고(prefix-icon → prefixIcon), 이벤트가 @click에서 onClick이 됐고, boolean prop에 is 접두어를 붙였습니다(loading → isLoading).

전부 프레임워크 관례의 차이입니다. variant, size, prefix-icon 같은 개념 자체는 하나도 안 바뀌었습니다.

이게 실무에서는 꽤 컸습니다. Vue2로 웹뷰를 만들던 사람이 React 화면을 맡아도, 버튼에 어떤 옵션이 있는지 다시 배울 필요가 없었거든요. 문법만 바뀌고 어휘는 그대로였습니다.

여백은 prop이 아니라 className으로

의도적으로 바꾼 것도 있습니다. 여백입니다.

Vue2 쪽은 여백을 prop으로 열어 두는 방식이었습니다.

// 컴포넌트가 자기 여백 옵션을 직접 들고 있다
@Prop({ type: Boolean, default: false }) hasPaddingTop!: boolean;
@Prop({ type: Boolean, default: false }) hasPaddingBottom!: boolean;

// 그리고 내부에서 값으로 바꾼다
hasPaddingTop && className.push(`twl-pt-12`);
hasPaddingBottom && className.push(`twl-pb-12`);

컴포넌트마다 방식이 조금씩 달랐습니다. 어떤 건 hasPaddingTop 같은 boolean이고, 어떤 건 gap으로 숫자를 받고, 어떤 건 width를 문자열로 받았습니다. 쓰는 쪽에서는 "이 컴포넌트는 여백을 어떻게 주더라"를 컴포넌트 수만큼 외워야 합니다.

React로 옮기면서 이걸 대부분 없앴습니다. 그냥 className으로 줍니다.

<BulletedList className="yf-mt-12 yf-mb-12">

배울 게 하나로 줄어듭니다. 그리고 그 하나는 이미 아는 것입니다. Tailwind를 쓰는 프로젝트니까요.

물론 열어 두는 데는 위험이 따릅니다. className으로는 여백뿐 아니라 뭐든 덮어쓸 수 있으니까요. 그리고 막아 두지 않았습니다. padding을 덮어쓰는 것도 그냥 됩니다.

다만 편하게 써도 되는 쪽과 한 번 생각하고 써야 하는 쪽은 나뉩니다.

mt, mb 같은 바깥 여백은 컴포넌트 바깥의 문제입니다. 이 컴포넌트가 앞뒤 요소와 얼마나 떨어져야 하는지는 컴포넌트가 알 수 없고, 그걸 배치하는 화면이 압니다. margin을 준다고 컴포넌트 안쪽이 바뀌지도 않으니, 마음 놓고 쓰면 됩니다.

안쪽 여백은 다릅니다. 배경색이나 테두리가 있는 컴포넌트에 padding을 덮어쓰면 모양 자체가 달라집니다. 버튼 높이가 어긋나고, 카드 안쪽 정렬이 깨집니다. 그래서 여백은 바깥 여백부터 생각하는 게 맞습니다.

그렇다고 안쪽을 막지는 않았습니다. 막아 두면 정말 필요한 상황에서 빠져나갈 길이 없어지고, 그런 상황은 반드시 생기니까요.

같은 이유로 래퍼 div도 그렇습니다. 레이아웃상 한 겹이 더 있어야 하면 감싸면 됩니다. 그것도 정상적인 선택입니다. 다만 여백 하나 주려고 감쌀 일은 없어야 한다고 봤습니다.

결국 이건 규칙이라기보다 판단을 개발자에게 맡긴 쪽입니다. 그 대신 외울 것이 사라졌습니다. 컴포넌트마다 여백 prop 이름을 찾아보는 대신, 아는 Tailwind 클래스를 그대로 씁니다.

그리고 이 결정에는 대가가 하나 딸려 옵니다. className을 오버라이드 수단으로 삼는 순간, 클래스 충돌을 정확히 해소하는 일이 중요해집니다. 그게 다음 편 이야기입니다.

옮길 수 있었던 세 가지 조건

여기서 중요한 건 “우리가 잘했다”가 아닙니다. 옮기는 게 가능한 상태였다는 겁니다. 그 조건은 세 가지였고, 셋 다 제가 만든 게 아니라 이미 갖춰져 있던 것들입니다.

1. 프레임워크에 종속된 UI 라이브러리를 쓰지 않았다

Vue2 라이브러리는 UI 컴포넌트를 전부 직접 만들어 쓰고 있었습니다. 외부 UI 프레임워크를 얹지 않았습니다.

만약 Vue 전용 UI 라이브러리 위에 스타일을 덧입힌 구조였다면, 옮길 수 있는 건 아무것도 없었을 겁니다. 컴포넌트의 실체가 그 라이브러리 안에 있으니까요. 이 선택 하나가 몇 년 뒤에 이런 이득으로 돌아왔습니다.

2. 디자인 토큰이 컴포넌트가 아니라 설정에 있었다

색상, 타이포그래피, 간격 스케일이 컴포넌트 코드가 아니라 Tailwind 설정과 플러그인에 정의되어 있었습니다.

특히 간격 스케일이 그랬습니다. 양쪽 다 1부터 999까지를 그대로 픽셀로 매핑해 두는 방식입니다.

// Vue2
const makeNum = () =>
  range(1, 1000).reduce((acc, size) => { acc[size] = size + 'px'; return acc; }, {});

// React
const numericSpacing = Object.fromEntries(
  Array.from({ length: 1000 }, (_, i) => [String(i + 1), `${i + 1}px`])
);

같은 규칙입니다. 그래서 p-16은 양쪽에서 똑같이 16px입니다. 컴포넌트가 참조하는 값의 의미가 바뀌지 않았다는 뜻이고, 이게 클래스 문자열을 그대로 쓸 수 있었던 이유입니다.

3. 양쪽 다 Tailwind였다

앞의 두 조건이 갖춰져 있어도, 스타일링 방식이 다르면 소용이 없습니다. 한쪽이 SCSS였다면 옮길 단위가 없습니다.

둘 다 Tailwind였기 때문에 클래스 문자열 자체가 이식 단위가 됐습니다. 옮길 것이 코드가 아니라 문자열이라는 게 핵심입니다. 문자열은 프레임워크를 타지 않습니다.

정리하면 이렇습니다. 디자인 시스템을 새 프레임워크로 옮길 수 있는지는, 디자인이 컴포넌트 바깥에 얼마나 나와 있는가로 결정됩니다. 이 셋이 안 맞으면 그때는 다시 만드는 게 맞습니다.

디자인 값이 컴포넌트 안에 있을 때와 설정으로 빠져 있을 때의 차이

왼쪽은 옮길 때 전부 따라온다. 오른쪽에서 컴포넌트가 든 건 클래스 문자열뿐이다.

조건이 안 맞은 곳도 있었습니다

첫 번째 조건을 다시 보겠습니다. “프레임워크에 종속된 UI 라이브러리를 쓰지 않았다.” 이건 맞는데, 완전하지는 않았습니다.

UI 라이브러리는 안 썼지만, 전역 플러그인에는 의존하고 있었습니다. Vue2는 Vue.prototype에 기능을 얹어 모든 컴포넌트에서 this.$무언가로 쓰는 방식이 자연스럽습니다. 그리고 그건 프레임워크가 바뀌면 통째로 사라집니다.

가장 크게 걸린 건 입력 필드였습니다. Vue2 쪽은 타입별 포맷팅을 컴포넌트 안에서 전부 처리하고 있었습니다.

// 입력값이 들어올 때마다 타입에 따라 가공한다
private getValueValidate(value: string) {
  let updateValue = this.$punycode(value.replace(/ {2,}/g, ' '));
  if (...) updateValue = this.validateCardNumberType(value);      // 카드번호
  if (...) updateValue = this.validateRegistrationNumber(value);  // 주민번호
  // ... 열한 가지 타입
}

// 그리고 그 안에서 전역을 부른다
window.$numWithCommas(value);
window.$creditCard.getCardFormat(cardType);

숫자 콤마도, 카드사별 포맷도 전역에 있었습니다. React에는 그런 게 없습니다.

옮기려면 세 가지 중 하나를 골라야 했습니다. 전역 유틸을 React 쪽에도 똑같이 만들거나, 포맷팅 로직을 컴포넌트 안으로 다 가져오거나, 아예 바깥으로 빼거나.

마지막을 골랐습니다. 앞의 둘은 방금 겪은 문제를 새 코드에 다시 만드는 길이기 때문입니다.

전역 유틸을 React에 다시 만들면, 컴포넌트가 보이지 않는 전역에 기대는 구조가 그대로 재현됩니다. 포맷팅 로직을 컴포넌트 안으로 가져오면, 열한 가지 도메인 지식이 이번에는 React 컴포넌트에 쌓입니다. 이걸 그대로 옮기지 말아야 할 이유가 정확히 그 둘이었는데, 앞의 두 안은 그걸 고스란히 물려받는 셈입니다.

export interface TextFieldProps<T> {
  value?: T;
  onChange?: (value: T) => void;
  formatter?: (value: string) => string;   // 가공 방식은 쓰는 쪽이 정한다
}

쓰는 쪽이 어떻게 달라지는지 보면 분명합니다.

<!-- Vue2 -->
<Yds6InputTextField type="card-number" v-model="cardNumber" />
<Yds6InputTextField type="tel" :tel-option="{ hyphenStartsWith: ['010'] }" v-model="phone" />
{/* React */}
<TextField
  placeholder="0000 0000 0000 0000"
  maxLength={16}
  number
  formatter={(val) => val.replace(/(\d{4})(?=\d)/g, '$1 ')}
  {...field}
/>

Vue2 쪽이 짧습니다. type="card-number" 한 마디면 끝나고, 정규식을 쓸 일도 없습니다. 솔직히 쓰는 입장에서는 이쪽이 편합니다.

대신 그 편함의 대가가 컴포넌트 안에 쌓여 있었습니다. 타입이 열한 개가 되고, 타입마다 예외가 생기고, 결국 tel-option 같은 옵션 prop이 또 붙습니다. 카드번호 포맷은 카드사마다 다르니 전역 유틸도 필요했고요.

방향이 뒤집혔습니다. 원래는 컴포넌트가 “카드번호란 이런 것”을 알고 있었는데, 이제는 모릅니다. 대신 쓰는 쪽에서 넣어 줍니다.

그러자 옮길 일 자체가 없어졌습니다. 열한 가지 포맷팅 로직을 React로 다시 쓸 필요가 없어졌고, 전역 유틸을 새로 만들 필요도 없어졌습니다. 카드번호 포맷이 필요한 화면에서 그 화면이 아는 방식으로 넣으면 됩니다.

그렇다고 전부 바깥으로 민 건 아닙니다. 타입을 열한 개에서 다섯 개로 줄였는데, 남긴 것과 뺀 것 사이에 선이 있습니다.

남긴 것
text · number · password · tel · email

뺀 것
card-number · business-number · registration-number
passport · birth-date · mm/yy

남긴 다섯은 전부 HTML <input>이 원래 아는 타입입니다. 뺀 여섯은 전부 한국의 특정 서식이고요. 카드번호 자릿수나 주민번호 하이픈 위치는 우리 도메인 지식이지, 입력 필드가 알아야 할 것은 아닙니다.

tel은 남겼습니다. 대신 하이픈 규칙은 함수로 빼서, 컴포넌트가 그걸 부르게 했습니다.

withHyphenPhoneNumber('01012345678')   // → '010-1234-5678'

이러면 입력 필드에서도 쓰고, 예약자 정보를 보여줄 때도 같은 함수를 씁니다. 규칙이 한 군데에 있으니까요.

기준을 정리하면 이렇습니다. 표준이 아는 것은 컴포넌트가 알아도 되고, 우리만 아는 것은 화면이 안다.

물론 이 선이 절대적이진 않습니다. 카드번호도 결국 어디선가는 중복될 테고, 그때는 전화번호처럼 함수로 묶으면 됩니다. 컴포넌트가 도메인을 아는 것보다는, 화면이 중복을 갖는 쪽이 풀기 쉽기 때문입니다. 중복은 눈에 보일 때 묶으면 되지만, 컴포넌트에 박힌 도메인 지식은 빼내기가 훨씬 어렵습니다.

옮기기 어려운 걸 만났을 때, 그대로 옮길 방법을 찾는 대신 책임을 옮기는 선택이 나을 때가 있습니다. 이 경우가 그랬습니다.

여기서 배운 게 있습니다. “프레임워크에 종속되지 않았다”는 UI 라이브러리만의 문제가 아니었습니다. 전역 객체에 얹힌 기능도 똑같이 종속입니다. 다만 눈에 덜 띌 뿐입니다.

스타일을 무엇으로 표현할 것인가

옮기기 전에 하나 더 정해야 했습니다. 클래스 문자열은 그대로 가져온다 치고, 조건에 따라 클래스를 조합하는 코드는 무엇으로 쓸 것인가.

CSS-in-JS는 후보에서 일찍 빠졌습니다. 이 라이브러리는 서버 렌더링을 쓰는 프로젝트에서 돌아가야 하는데, 런타임에 스타일을 주입하는 방식은 서버와 클라이언트의 결과를 맞추는 데 늘 추가 설정이 붙습니다. 무엇보다 그건 Tailwind를 버린다는 뜻이고, 그러면 지금까지 말한 이점이 전부 사라집니다.

반복해서 나온 여섯 가지 변환

옮기는 일 자체는 단순 작업에 가까웠습니다. 같은 변환이 계속 반복됐습니다.

왼쪽에 무엇이 있든 오른쪽 대응이 정해져 있다. 옮기는 일은 이 표를 적용하는 일에 가까웠다.

첫 줄이 가장 큰 변화였습니다. Vue2에서는 조건에 따라 클래스를 배열에 밀어 넣고 마지막에 합치는 방식이었습니다. React에서는 그 분기를 표로 선언합니다.

가장 극적이었던 건 태그 컴포넌트였습니다. 크기와 모서리 조합을 중첩 if로 처리하던 것이, compoundVariants 여덟 줄로 평평해졌습니다.

두 번째 줄에도 사연이 있습니다. Vue2에서는 IDE 자동완성을 위해 타입을 두 번 적어야 했습니다.

@Prop({ type: String, default: (): Yds6BoxButtonSize => 'm' })
@IdeProp({ type: `"s" | "m" | "l" | "xl"` })   // IDE가 읽을 문자열 타입
private readonly size!: Yds6BoxButtonSize;

React와 TypeScript에서는 한 번이면 됩니다. size?: 's' | 'm' | 'l' | 'xl' 한 줄이고, 그마저도 스타일 정의에서 자동으로 추론됩니다. 이건 Vue2 코드의 흠이 아니라 당시 IDE 도구의 제약이었습니다.

prefixTwMerge라는 유틸이 표 첫 줄에 나오는데, 여기에는 그 자체로 이야기가 하나 붙어 있습니다. 그건 다음 편에서 따로 다루겠습니다.

결과

컴포넌트 이름을 기계적으로 대조해 봤습니다. 대부분이 이름까지 그대로 넘어왔고, 짝지어 비교한 것 대부분이 디자인 수치를 그대로 유지하고 있었습니다.

새로 만든 것도 있습니다. 그런데 그 목록이 흥미롭습니다. 모달 오버레이, 백키 트리거, 레이아웃 시프트 가드, 인증 동기화 컴포넌트… 대부분 “웹과 웹뷰를 한 벌로 묶었기 때문에 필요해진 것들” 이었습니다.

Vue2 라이브러리는 웹뷰 전용이었습니다. 브라우저 히스토리를 신경 쓸 일도, 서버 렌더링 이후의 화면 흔들림을 방어할 일도 없었습니다. 그 요구는 우리가 웹까지 같은 코드로 처리하기로 하면서 생겼습니다.

즉 이번에 만든 건 “Vue2에 없던 컴포넌트”가 아니라 “이번 구조에서 새로 생긴 문제”였습니다.

같은 이유로 새로 붙은 스타일도 있습니다. hover입니다.

웹뷰는 터치로만 쓰니 hover를 신경 쓸 일이 없습니다. 그런데 웹에서는 마우스가 올라갑니다. 그래서 버튼마다 hover 배경을 붙였습니다.

재미있는 건 그 색이 원래 있었다 는 겁니다. Vue2 쪽 CSS 변수를 열어 보니 Button-DefaultHover, Button-BlueHover 같은 토큰이 이미 같은 값으로 정의되어 있었습니다. 디자인 시스템은 hover 상태를 진작에 정의해 뒀는데, 웹뷰 전용 라이브러리라 쓸 일이 없어 컴포넌트가 참조하지 않고 있었던 겁니다.

우리는 그걸 처음 배선한 셈입니다. 새로 만든 게 아니라 이미 있던 걸 꺼내 쓴 쪽에 가깝습니다.

뜻밖의 수확

한 줄씩 읽으며 옮기다 보니, 양쪽 모두 모르고 있던 것 두 개가 드러났습니다.

하나는 스피너였습니다. 루트 엘리먼트가 이랬습니다.

<div class="flex flex-col yds6-spinner">

prefix가 빠져 있습니다. twl-flex가 아니라 flex라서, 이 클래스는 실제로는 아무 일도 하지 않고 있었습니다. 화면이 그럭저럭 보여서 아무도 눈치채지 못한 경우입니다.

다른 하나는 점 인디케이터였습니다. 클래스 배열을 함수 바깥에서 한 번 만들어 두고, 호출될 때마다 거기에 배경색을 밀어 넣는 구조였습니다. 점 개수만큼 색상 클래스가 계속 쌓입니다. 그런데도 정상 동작하고 있었습니다. 마지막에 클래스 병합 유틸이 중복을 정리해 주고 있었거든요.

둘 다 옮겨보고 나서야 보였습니다. 프로덕션에서 몇 년을 굴러온 코드에서 이 정도면 적은 편이라고 생각합니다. 그리고 이건 포팅의 부수 효과라기보다, 포팅이 원래 그런 작업이라는 쪽에 가깝습니다. 한 줄도 건너뛸 수 없으니까요.

그대로 오지 못한 것

옮기는 게 복사는 아니었습니다. 프레임워크가 바뀌면서 조용히 사라진 것들이 있었습니다.

텍스트 버튼이 그랬습니다. Vue2에서는 클릭 핸들러가 없으면 탭 포커스를 받지 않도록 처리하고 있었습니다.

get hasClickEvent() { return !!this.$listeners.click; }
// :tabindex="hasClickEvent ? 0 : -1"

이 판정은 $listeners에 기대고 있습니다. React에는 $listeners가 없습니다. 그래서 이벤트를 {...rest}로 옮기는 순간, 이 동작도 같이 사라졌습니다. 변환표의 세 번째 줄이 데려간 셈입니다.

앞서 말한 포맷터처럼, 안 옮긴 게 아니라 안 옮겨도 되게 만든 것들과는 다릅니다. 이건 옮기려던 것이 조용히 빠진 경우입니다. 그리고 그 둘은 코드만 봐서는 구분되지 않습니다.

다음 편에서는 클래스 이름 앞에 세 글자를 붙였다가 겪은 일을 이야기하겠습니다.