React vs Vue | Nextjs vs Nuxtjs
이전까지 React로 학습하였으나 이번 과제로 Vue에 대해 새롭게 학습할 기회가 되어 좋았다. 과제를 수행하며 겪었던 트러블슈팅을 기록하고 새로 학습한 내용들을 블로그로 정리하였다.
React의 SSR 프로임워크가 Next라면 Vue의 SSR 프레임워크는 Nuxt이다. React와 비교한 Vue는 템플릿 기반의 문법을 사용한다는 점과 작고 가벼워 빠르게 프로젝트를 구축할 수 있다는 장점이 있다. React에서 설정 및 최적화에 관한 내용을 학습하는 러닝커브가 Vue에서는 적고, 쉽게 배울 수 있다. 단방향 바인딩인 React에서 전역상태관리를 위한 라이브러리가 필요했고, 이것에 대한 학습 난이도가 높았던 반면, Vue는 양방향 바인딩으로 React보다 상태관리에 대한 부담이 적다고 한다.
Nuxtjs로 프로젝트를 구성할 것이므로 아래의 명령어로 프로젝트를 설치하였다.
repo에 파일을 연결하고 plain-css를 사용하기 위해 다운받아보자.
프로젝트를 실행하면 정상적으로 실행되는 것을 볼 수 있다.
// app.vue
<template>
<div>
<NuxtWelcome />
</div>
</template>
app.vue에서 NextWelcom이라는 컴포넌트가 삽입되어 해당 컴포넌트가 렌더링되는 것을 알 수 있다. 아래의 조건을 만족하려면 조건부 렌더링을 적용하고 입력되는 데이터 체크 후, 다음 페이지로 넘어가도록 하며 이전 단계로 돌아가도 사용자의 정보가 저장되어 있어야 하니 전역으로 데이터를 관리하도록 작성해야 해야 한다.
회원 가입은 1.개인정보입력 → 2.배송지정보입력 → 3.결재정보입력 3단계의 화면으로 구성된다.
현재 단계를 입력하지 않은 경우 다음 단계로 이동할 수 없으며, 이전 단계로 이동할 경우에는 이전 단계에서 사용자가 입력한 정보가 저장되어 있어야 한다.
각 입력 정보마다 입력된 값이 다음의 조건을 만족하는지 체크하며, 조건을 만족하지 않은 경우 인풋창 아래 경고 메세지를 출력한다.
먼저 초기에 사용해야 하는 페이지를 우선적으로 작성해준 뒤 라우팅 설정을 마쳐보자.
app.vue에 <NextPage />를 추가하여 pages라우팅을 사용하도록 하자.
이후 pages 라우팅을 위해 signup 폴더를 만들고 signup/index.vue에서 components에 작성해둔 개인정보, 배송지, 카드번호 컴포넌트를 렌더링하도록 구성했다. 카드번호의 입력을 마치면 회원가입 완료페이지를 /signup/complete 에서 보여질 수 있도록 구성했다.
먼저 signup/index.vue에 currentComponent함수를 작성하여 현재 단계에 맞는 컴포넌트를 동적으로 렌더링 하도록 구성한다.
<component :is="currentComponent" @nextStep="nextStep" @prevStep="prevStep"></component>
이후 script에서 컴포넌트의 초기값을 1로 설정하고, PersonalInfo - Address - Credit 순으로 단계를 설정한다. 각 컴포넌트에서 다음버튼을 누르면 nextStep함수가 작동하고 currentComponent를 1 증가시킨다. prevStep은 1 감소시킨다. step이 1 과 3 사이일때만, changeStep에서 currentStep을 newStep으로 변경한다. 마지막 페이지에서는 회원가입 완료페이지로 라우팅한다. 아래는 해당 메서드들의 코드이다.
export default {
components: {
PersonalInfo,
Address,
Credit
},
data() {
return {
currentStep: 1
};
},
computed: {
currentComponent() {
switch (this.currentStep) {
case 1: return 'PersonalInfo';
case 2: return 'Address';
case 3: return 'Credit';
default: return null;
}
}
},
methods: {
nextStep() {
if (this.currentStep < 3) {
this.currentStep++;
}
}, prevStep() {
if (this.currentStep > 1) {
this.currentStep--;
}
},
changeStep(stepChange) {
const newStep = this.currentStep + stepChange;
if (newStep >= 1 && newStep <= 3) {
this.currentStep = newStep;
}
},
completeSignUp() {
alert('회원가입이 완료되었습니다!');
this.$router.push('/signup/complete');
}
}
}
해당 기능은 다음과 같이 작동되었다.
v-model & v-if
개인정보 입력값을 정규표현식으로 검사하고 조건에 따라 에러메세지를 발생시키는 것을 고민해보았다. 기존 React에서는 input 태그에서 value와 onChange 프로퍼티를 이용해서 state에 저장하고 regex로 condition을 확인하는 절차를 거쳐 조건에 따른 렌더링(if 혹은 && ? 조건연산자 등)으로 에러메세지를 발생시켰다.
Vue에서는 v-model과 v-if로 해당 기능을 구현할 수 있었다. 이들을 Vue에서 지시자(directive)라고 하며 양방향 바인딩이 가능하기 때문에 React와는 조금 다르게 작동한다는 것을 알았다. v-model을 통해서 입력값을 Vue인스턴스의 데이터와 연동시켜 변경될 때 마다 자동으로 업데이트 된다. 같은 기능을 React보다 Vue에서 보다 간결하게 사용할 수 있었다.
// Vue.js
<input v-model="inputValue" />
// React
<input value={this.state.inputValue} onChange={e => this.setState({ inputValue: e.target.value })} />
v-if를 통해 조건에 따라 DOM요소를 렌더링 할 수 있었다.
// Vue.js
<div v-if="isVisible">보인다!</div>
// React
{isVisible && <div>보여요!</div>}
에러메세지를 구현하기 위해 처음 생각한 것은 v-if의 입력값에 따른 조건을 검사하고, 올바르지 않으면
<div v-if="condition" class="error-message">형식이 올바르지 않습니다.</div>
로 작성하려 했으나, 하드코딩을 지양하고 동적인 상황을 고려하며 유지보수에 용이하게 작성하기 위해 알아본 바, 다음과 같이 작성할 수 있었다.
<p v-if="emailError" class="error-message">{{ emailError }}</p>
{{ emailError }}는 Vue에서 Mustache구문 혹은 태그라고 말한다고 한다. 위 방식을 통해서 변경된 데이터를 HTML엘리먼트에 반영하여 자동으로 업데이트되는 방식이다. 데이터와 뷰를 쉽게 연동할 수 있어 위와 같은 방식으로 작성하는것이 좋겠다고 생각했다.
PersonalInfo Script
사용자의 입력값을 받기 위해 React에서 State와 비슷한 역할을 하는 data()함수를 작성했다. data함수는 컴포넌트 객체를 반환하고, 사용자의 입력값을 저장하는 역할을 한다. 입력값을 저장할 input과 error메세지도 함께 작성했다. 해당 error메세지는 입력값의 조건이 일치하면 ""빈값이고, 일치하지 않으면 "올바른 값이 아닙니다."등의 텍스트가 삽입되도록 작성했다. clearError함수를 통해 조건이 일치할 때, 다시 ""빈값으로 반환한다. 이메일과 비밀번호는 정규표현식으로 test하도록 구현했다. 비밀번호 input 값을 기준으로 비밀번호 확인 input의 입력값이 올바르지 않으면 에러메세지를 발생시킨다. 모든 조건이 일치하면 다음 컴포넌트로 이동할 수 있도록 작성했다.
export default {
name: 'PersonalInfo',
data() {
return {
email: '',
password: '',
passwordConfirm: '',
emailError: '',
passwordError: '',
passwordConfirmError: '',
};
},
methods: {
validateForm() {
this.clearErrors();
if (!this.validateEmail(this.email)) {
this.emailError = '이메일 형식이 올바르지 않습니다.';
}
if (!this.validatePassword(this.password)) {
this.passwordError = '비밀번호는 영문 대소문자, 숫자, 특수문자를 최소 1개 이상 포함하고 8자 이상이어야 합니다.';
}
if (this.password !== this.passwordConfirm) {
this.passwordConfirmError = '비밀번호가 일치하지 않습니다.';
}
if (!this.emailError && !this.passwordError && !this.passwordConfirmError) {
this.$emit('nextStep');
}
},
validateEmail(email) {
const regex = /^[a-zA-Z0-9]+@[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/;
return regex.test(email);
},
validatePassword(password) {
const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
return regex.test(password);
},
clearErrors() {
this.emailError = '';
this.passwordError = '';
this.passwordConfirmError = '';
},
},
};
Daum post API
카카오주소 api는 기존에 React에서 사용해본 경험이 있었다. Nuxt에서 주소api를 사용하기 위한 방법으로 2가지를 찾았다. 첫번째는 해당 페이지 script내부에 직접 script파일 주소를 입력하는 방법과, nuxt.config.ts에 script를 등록하는 방법이다. 사용자의 입력값을 전역으로 사용할 수 도 있으니 확장성과 유연성을 위해 nuxt.config에 작성하는 방식을 채택하였다. script를 추가하는 방법은 Nuxt홈페이지에 나와있었다.
위 코드를 참고하여 내 nuxt프로젝트에 작성해봤다.
이제 Address페이지에서 우편번호 버튼을 누르면 주소검색창이 띄워지고, 검색값을 클릭하면 주소가 input필드에 입력되도록 구현하려 시도했다. 검색값에서 반환되는 데이터는 우편변호, 지번주소, 도로명주소, 영문주소, 건물이름, 시군구코드 등 다양한 데이터를 반환한다. 이중 우편번호와 도로명주소만 사용해서 disabled된 우편번호input와 주소input에 값을 넣도록 작성했다.
다음주소 api를 띄우는것까지 구현했으나, input에 데이터가 삽입되지 않았다. 문제를 뜯어본 결과, oncomplete함수에서 this가 Vue인스턴스를 참조하지 않고, 콜백 함수 자체의 컨텍스트를 참조하고 있기 때문이다. 해당 this를 Vue인스턴스를 참조하도록 화살표함수를 사용해주니 문제가 해결되었다.
searchPostcode() {
new daum.Postcode({
oncomplete: (data) => {
this.postcode = data.zonecode;
this.address = data.address;
}
}).open();
},
1일반함수는 자신의 this를 생성하는 반면 화살표함수는 자신의 this를 생성하지 않고, 자신이 선언된 스코프의 this를 사용하기 때문에 oncomplete 콜백 내에서 Vue 인스턴스에 데이터를 업데이트 할 수 있게 되었다. Javascript를 학습하면서 class와 this에 대한 내용을 학습했었는데, 실제로 프로젝트에 에러를 겪으며 사용해보니 책으로 학습했었던 것 보다 확실히 와닿았던 것 같다.
전화번호 입력값을 구현하기 위해서 사용자의 편의를 제공하는 자동 하이픈을 구성하려 시도했다. 그러나 입력되는 데이터가 양방향으로 바인딩 된 상태에서 직접 v-model로 조작하는 것이 올바르지 않다는 것을 알게 되었다. 데이터를 조작하는 위치가 불분명하고, 예기치 못한 사이드이펙트가 발생할 수 있으므로 데이터의 일관성 즉, 원본값은 유지하면서 화면에 출력되는 것을 조작하기 위해 computed라는 속성을 사용할 수 있다는 것을 알게되었다. computed의 get 메서드는 원본값을 반환하고 set 메서드를 통해 사용자의 입력에 따라 형식을 변형하여 데이터 모델에 저장하게 작성할 수 있었다. 아래와 같이 computed를 사용하면 데이터의 흐름을 보다 간결하게 확인할 수 있고, 데이테의 원본도 보존할 수 있다.
추가로 computed를 통해 상태값에 따라 동적으로 값을 계산할 수 있고, 캐싱기능도 제공해 성능을 최적화 할 수 있다고 한다.
computed속성을 사용해서 입력되는 사용자의 전화번호를 받아 전화번호가 10자리일 경우, 010-000-0000형태로 보여주고, 11자리일 경우 010-0000-0000으로 보여주도록 구현했다. 하이픈을 포함해 13자리가 최대 입력이 되도록 제한했고, 숫자 이 외에는 입력하지 못하도록 keydown속성을 주었다.
<input v-model="formattedPhoneNumber" @keydown="filterNonNumericKey" maxlength="13" type="tel" />
Luhn 알고리즘
이번 카드번호 유효성검사를 구현하면서 Luhn 알고리즘을 알게되었다. IBM사의 과학자 Hans Peter Luhn이 만들어서 Luhn 알고리즘이라 한다. 해당 알고리즘을 바탕으로 프론트엔드에서 1차적으로 사용자가 입력한 카드번호가 유효한 값인지 확인해 볼 수 있다. 대부분 카드사의 카드번호는 Luhn 알고리즘을 바탕으로 만든다고 한다. 주어진 조건에 따라 입력된 값을 검증해보자.
먼저 입력되는 input의 숫자값을 하나로 합치도록 구성해야한다.
<input v-model="creditPart1" type="text" maxlength="4" placeholder="0000">
<input v-model="creditPart2" type="text" maxlength="4" placeholder="0000">
<input v-model="creditPart3" type="text" maxlength="4" placeholder="0000">
<input v-model="creditPart4" type="text" maxlength="4" placeholder="0000">
input값을 자리에 따라 4part로 나누고 computed를 사용해서 4자리 값을 하나로 합쳐준다.
data() {
return {
creditPart1: "",
creditPart2: "",
creditPart3: "",
creditPart4: ""
};
},
computed: {
creditNumber() {
return `${this.creditPart1}${this.creditPart2}${this.creditPart3}${this.creditPart4}`;
}
},
사용자가 순서대로 입력하지 않아도, 각 part에서 입력되는 input의 순서대로 카드번호 16자리를 가져올 수 있다. 이제 이 16자리를 가지고 Luhn 알고리즘을 통해 카드번호 유효성을 검사해보자.
먼저 ValidateCreditNumber()함수를 생성하고 creditNumber를 받아오도록 한다. 가장 오른쪽 숫자부터 짝수번호에 *2를 해주는 방식은 번호를 배열로 만들어 reverse() 메서드를 이용해서 뒤집어 왼쪽부터 홀수번호에 *2 해주는 방식으로 변경할 수 있다.
validateCreditNumebr() {
const creditNumber = this.creditNumber;
let creditReverseArray = Array.from(creditNumber).reverse();
},
이후에 creditReverseArray를 조건에 맞게 가공해보자.
creditReverseArray = creditReverseArray.map((num, idx) => idx % 2 === 1 ? Number(num) * 2 : Number(num))
배열을 뒤집었으므로 오른쪽부터 짝수번째가 아닌 왼쪽부터 홀수번째로 생각하면 쉽다. 해당되는 숫자는 *2를 해주고 아니면 넘어간다.
creditReverseArray = creditReverseArray.map((num) => num > 9 ? num - 9 : num);
*2를 하고난 후 9 이상의 숫자들의 각각 자릿수를 더하는 조건인데, 각 자릿수의 합은 -9를 하는 것과 똑같다.
let sum = creditReverseArray.reduce((acc, curr) => acc + curr, 0);
const creditValidate = sum % 10
위와 같이 변형해주고 난 뒤 reduce()를 사용해서 0번째 idx 부터 모든 값을 더하고 % 10의 몫이 0인지 확인한다. 아래는 전체 코드이다.
validateCreditNumber() {
const creditNumber = this.creditNumber;
let creditReverseArray = Array.from(creditNumber).reverse();
creditReverseArray = creditReverseArray.map((num, idx) => idx % 2 === 1 ? Number(num) * 2 : Number(num));
creditReverseArray = creditReverseArray.map((num) => num > 9 ? num - 9 : num);
let sum = creditReverseArray.reduce((acc, curr) => acc + curr, 0);
const creditValidate = sum % 10;
return creditValidate === 0;
},
회원가입 완료버튼에 조건을 부여해 에러메세지를 출력하도록 작성했다. 하나의 input으로 동적으로 에러메세지가 바뀌도록 구현하였고, 에러메세지가 있는 상태에서 회원가입을 성공하게 되면 에러메세지를 초기화하는 것을 추가하였다.
completeSignUp() {
this.creditError = "";
if (this.creditNumber.length < 16) {
this.creditError = "카드 번호를 모두 입력해주세요.";
return;
}
if (!this.validateCreditNumber()) {
this.creditError = "유효한 카드번호가 아닙니다.";
return;
}
alert('회원가입이 완료되었습니다!');
this.$router.push('/signup/complete');
}
Piana
이때까지 모든 데이터를 페이지에다 작성하였지만, 전역으로 데이터를 관리하기 위해 piana를 사용하려 한다. piana는 상태관리 라이브러리이다. React에서 전역상태관리 라이브러리를 사용하는 것과 유사하다.
Vuejs에서 Vuex가 전역상태관리 라이브러리이지만, Nuxt에서는 piana를 사용하는 것을 권장한다고 한다. 먼저, 아래의 명령어로 piana를 설치한다.
npm install pinia @pinia/nuxt
설치 후, nuxt.config.ts 파일에서 modules에 piana를 작성해줘야 한다.
export default defineNuxtConfig({
modules: ["@pinia/nuxt"],
...
});
piana를 이용하기 위해 store파일을 생성하고 userinfo.js를 생성한다. 스토어를 생성하려면 defineStore함수를 사용해야 하므로 piana에서 defineStore을 import해주고 첫번째 인자에는 id값을, 두번째 인자에는 옵션들을 작성해주면 된다.
state를 통해 상태를 저장하고 전역에서 사용할 수 있다. action옵션은 상태 관련 작업을 하는 메서드로, 상태를 변경하기 위해 사용하려고 한다. setPersonalInfo라는 action을 정의하여 3개의 매개변수를 받아 상태를 업데이트 할 수 있도록 작성했다.
컴포넌트에서 사용자가 작성할 입력값을 userStore로 지정해줬는데, personalInfo를 불러올 수 없다는 에러가 발생했다. 확인해본 결과, data함수 내에서 해당 state에 접근하려 할 때, this.userStore가 undefined여서 에러가 발생한 것 같다. 초기값은 원래대로 지정하고, created 훅을 통해 컴포넌트 생성 후에 piana 스토어 값을 지정할 수 있었다.
created() {
const userStore = useUserStore();
this.email = userStore.personalInfo.email;
this.password = userStore.personalInfo.password;
this.passwordConfirm = userStore.personalInfo.passwordConfirm;
},
전체 구현 Demo
끝으로
이번 과제를 통해 Vue와 Nuxt를 처음 접해보고 개발하면서 많은 점들을 느꼈다. 느낀점들을 요약하자면 다음과 같다.
- React보다 소스코드가 간결해서 직관적임
- 프로젝트 규모에 따라 Context API, Zustand, Recoil, Redux 등 도입해야 할 라이브러리를 고민하지 않아도 됨
- Vue에서는 코드 작성 구조가 React보다 편차가 크지 않음
코드 작성 구조가 생소해 처음엔 애를 먹었던 기억이 있다. 그러나 객체지향 프로그래밍으로 개발을 해보면서 this를 참 많이 알게 되었던 것 같고, 함수형 프로그래밍과 차이점을 몸소 경험해 보는 좋은 시간이었다.
Nuxt의 SSR기능을 활용해보지 못해 아쉽기도 하다. Next에서는 SSG, ISG등을 경험해보았는데, 추후에 Nuxt의 SSR을 경험해보며 비교해보는 시간을 가지는 것도 좋겠다고 생각했다.
현재 React가 7 Vue가 3 정도로 React의 인기가 대세이며 레퍼런스도 Vue보다 많다고 한다. 개발에 입문하면서 줄곧 React만으로 개발을 해왔기 때문에 React만을 생각하는 경향이 있었는데, 이번 과제를 통해 Vue에 대한 매력을 느낄 수 있었다.
현재 내 테크스택은 React가 주스택이다. 그러나 React개발자이기 이전에 나는 개발자이므로, Vue에 대한 관심을 가지는 것은 당연해야 한다고 생각하게 되는 계기가 되었다. 개발 페러다임은 시대마다 늘 변해왔고 React도 영원하지 않을 수 도 있다. 물론 그 자리를 Vue가 아닌 Angular가 차지할 수 도 있으나, 늘 발전하는 개발세계에서 "내꺼 || 니꺼" 가 아닌 보다 좋은 것을 찾아가고 경험해보는 과정을 겪는 것이 중요하다고 생각하게 되었다.
'개발기록' 카테고리의 다른 글
[Nextjs] PageSpeed Insights SEO 및 웹 최적화 적용 (0) | 2023.10.09 |
---|---|
[Google OAuth] Google Login API (1) | 2023.09.15 |
[React-Query | Next.js | Prisma | Planetscale] Wishlist Mutation 적용 (0) | 2023.09.11 |
[React-Query] React-Query와 Debounce를 활용한 효율적인 데이터 페칭 (0) | 2023.09.09 |
[Next.js] Google Map API 구현 (0) | 2023.09.05 |