<척척약사>는 AI를 활용한 조제약 분석 웹으로, Vue.js를 통해 개발했었다. 당시 백엔드, 프론트엔드, 디자인을 모두 맡고, 기술리더 역할까지 일임했었는데, 프론트엔드가 가장 힘들었다ㅎㅎ... 아무래도 백엔드에 비해서는 경험이 부족한데다가, 이전 프로젝트에 비해 화면도 굉장히 많았다. 하지만 팀원들을 도우면서 가장 많이 성장하기도 했다.
당시 내가 단독으로 디자인한 UI이다. 60개에 달하는 화면....
디자인도 힘들었지만, 진짜 문제는 개발이었다. 어떻게해야 짧은 시간안에 이 모든 화면을 구현할 수 있을까?
그때 생각한게 바로 컴포넌트 주도 개발(CDD)였다.
컴포넌트 주도 개발은 앱의 UI를 재사용 가능한 컴포넌트로 쪼개어 개발하는 방식이다. 이를 통해 UI를 더 효율적으로 관리하고, 재사용성과 유지보수성을 크게 향상시킬 수 있다. 예를들어 버튼을 컴포넌트로 만들었다면, 버튼의 디자인을 바꾸고 싶을때 버튼컴포넌트의 CSS만 수정한다면 모든 버튼에 해당 변경사항이 적용된다.
특히 Vue.js는 컴포넌트를 중심으로 설계된 프레임워크이므로, 컴포넌트를 중심으로 개발이 자연스럽게 이루어졌다.
컴포넌트 구조 설계
views/아래에는 화면 그자체를 담았다. 즉, 검색결과화면/회원가입화면/AI분석화면 등 각 디자인에 1대1매칭되는 화면을 만들어주는것이다. /url 경로마다 매칭될 화면들을 담는다.
그리고 views/{화면명}/components 아래에 각 화면에서만 쓰이는 컴포넌트들을 담았다. 예를들어 AI분석 화면에서 사용하는 AI컴포넌트는 해당 뷰에서만 사용된다. 따라서 해당 컴포넌트를 여기에 넣는것이다.
common/{종류}/ 아래에 공통 컴포넌트들을 담았다. 버튼, 약 정보, 뱃지, 검색창 등 여러 화면에서 사용할 공통적인 컴포넌트들을 저장한다.
Props 활용하기
그런데 문제는, 같은 뱃지 컴포넌트더라도 내부 데이터가 다를수 있는것이다. 이때 활용되는것이 바로 Props이다.
위 컴포넌트의 경우 약 정보 컴포넌트 안에 뱃지 컴포넌트가 들어가는 형태로 구조화 되어있다.
따라서, 약 정보가 변함에 따라서 약정보컴포넌트의 사진, 약 이름이 변할것이고, 이에 따라 뱃지의 주의사항까지 변화할것이다. 이렇게 변화하는 상황에 대해 동적으로 데이터가 변할 수 있도록 다음과같이 props를 작성한다.
<template>
<div class="badge-container" :style="{ backgroundColor, padding }">
<span class="badge-text" :style="{ color, fontSize }">{{ title }}</span>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default:
"title : 뱃지 내용, backgroundColor : 뱃지 색, color : 글씨 색, size : 글씨 크기",
},
backgroundColor: {
type: String,
default: "#34c759",
},
color: {
type: String,
default: "white",
},
fontSize: {
type: String,
default: "10px",
},
padding: {
type: String,
default: "0px",
},
});
</script>
변화하는 값인 배경색, 폰트사이즈, title과 같은 부분을 변수로 하달받고, template에서 동적으로 변화시킨다. 이렇게 만든 컴포넌트는 다음과 같이 사용한다.
<Badge
title="뱃지 내용"
backgroundColor="뱃지 색"
color="글씨 색"
fontSize="글씨 크기"
padding="내부 여백"
/>
따라서 각 약 정보에 대해 검색결과 뷰 > 약 정보 컴포넌트 > 뱃지 컴포넌트 순으로 데이터가 변화하게 된다.
Watch 활용하기
하지만 개발을 하다보면, 단순히 하위컴포넌트로 전달해 렌더링 하는것 뿐만 아니라, 하위컴포넌트에서 상위컴포넌트로 데이터를 전달해야 하는 경우도 있다. 예를들어, 하위컴포넌트의 버튼을 클릭하면 상위컴포넌트의 특정 이벤트를 발생 시켜야 하는 경우가 있을것이다. 이경우 대표적으로 두가지 방법이 있는데, Emit을 활용해서 데이터를 전송하거나, Pinia에서 접근할수있는 변수를 watch로 감시하며 이벤트를 트리거 하는것이다.
나는 Pinia를 사용중이어서 watch를 활용하는 방식을 선택했다.
<template>
<div class="buttons">
<label for="file-upload" class="custom-button"
>갤러리에서 알약사진 선택하기</label
>
<input
type="file"
id="file-upload"
@change="handleImageUpload"
style="display: none"
/>
<label class="custom-button">카메라로 알약사진 촬영하기</label>
</div>
<Footer></Footer>
</template>
<script setup>
import Wave from "@/common/Wave.vue";
import Footer from "@/common/FooterColor.vue";
import { ref } from "vue";
import { pillPicStore } from "@/stores/pillPic";
const store = pillPicStore();
const handleImageUpload = (event) => {
const file = ref(event.target.files[0]);
store.getPillPic(file.value, 2);
};
</script>
위 코드는 사용자가 사진을 첨부할경우 handleImageUpload 이벤트가 실행되면서 store에 있는 currentStep이라는 변수를 변경하는 코드이다.
import { ref, watch } from "vue";
import { useRouter } from "vue-router";
import { pillPicStore } from "@/stores/pillPic";
const router = useRouter();
const store = pillPicStore();
const currentStep = ref(1);
//selectPic에서 사진첨부 이벤트 발생시(currentStep 변화시) 실행
watch(
() => store.currentStep,
(nextStep) => {
console.log("currentStep 값이 변경되었습니다:", nextStep);
currentStep.value = nextStep;
}
);
위 코드는 이전코드의 상위 컴포넌트로, store에서 사진첨부 이벤트로 인해 변화한 currentStep 변수를 감지하고, 새로운 이벤트를 실행하는 함수이다.
이러한 방식을 통해 최종적으로 구현한것이 다음 화면이다.
views/pill_pic 아래에 위 세가지 화면을 저장하는 하나의 View, 즉 PillPicView를 만들어두고, 왼쪽부터 3단계로 진행되는 화면을 컴포넌트화 시켜서 views/pill_pic/components에 저장했다.
view파일에는 배경화면, 상단바와 같은 공통적인 컴포넌트를 넣어두고, 중앙의 내용이 변하는것이므로 그 부분을 하위 컴포넌트를 통해서 구현했다.
화면의 변환은 Pinia+watch Pinia에 Step이라는 변수를 만들어두고, 하위컴포넌트들에서 특정 이벤트가 일어날때마다 +1을 해줬다. 이값을 watch로 감시하며 내부 화면 컴포넌트를 바꿔서 렌더링한다.
프론트엔드 프레임워크에서 컴포넌트 주도 개발을 공부하며, 과거 타임리프나 JSP를 사용했을때 느꼈던 중복코드 문제를 간단히 해결할 수 있음을 깨달았다. 극강의 효율성...! 또한 많은 화면을 구현하면서, 자연스레 많은 컴포넌트를 만들었고, 다른 팀원이 만든 컴포넌트를 사용하면서 일관적인 컨벤션의 중요성도 느꼈다. 우리팀의 경우, 각 컴포넌트의 최상단에 주석으로 사용법을 써서 빠르게 사용할 수 있도록 돕기도 했다😀
자세한 코드는 여기서 볼 수 있다.