해당 작업은 타이어픽 공식사이트의 페이지 구조를 참고해 만들었습니다.
🛞 Tire Pick
사이트명: 타이어픽
제작기간: 2024.03 ~ 2024.04
주요 기술 스택: HTML, SCSS, JavaScript
주요 라이브러리: Swiper.js, Lottie.js
대상 플랫폼: PC 및 모바일 반응형
🔗 LINK
📍 POINT
Swiper.js를 활용한 여러가지 타입의 슬라이더 구현하기
슬라이더를 구현할 때는 Swiper.js라는 라이브러리를 활용해서 구현하고자 하는 형태에 맞게 커스텀하여 사용했다. 생각보다 자유도가 높다. 때문에 공식 문서 숙지를 잘해야 할 것 같다. (안그러면 노가다를 하게 되는데…)
Slide Contents 유동적 업데이트
Swiper.js를 사용해 슬라이더를 만들기 위해서는 몇 가지 규칙을 지켜줘야 하는데, 그 중의 하나로 기본 html 구조가 있다. swiper - wrapper - slide
의 구조를 가지는데, slide
안에 보여줄 콘텐츠를 작성하면 된다.
<!-- Swiper -->
<div class="swiper mySwiper">
<div class="swiper-wrapper">
<div class="swiper-slide">Slide 1</div>
<div class="swiper-slide">Slide 2</div>
<div class="swiper-slide">Slide 3</div>
</div>
<div class="swiper-button-next"></div>
<div class="swiper-button-prev"></div>
<div class="swiper-pagination"></div>
</div>
하지만, 간단한 콘텐츠를 보여줄 때 사용하는 것은 큰 문제가 없지만, 복잡한 컴포넌트가 포함되거나 유동적인 데이터가 들어가거나 하는 등의 경우에는 불필요한 반복작업이 발생한다고 판단했다. 이후에 기존 콘텐츠가 삭제되거나 추가되는 등의 수정이 발생할 때도 비효율적이라고 생각했다. 때문에 많은 슬라이드를 가지는 Swiper에서는 mockData와 템플릿 리터럴을 활용해서 유동적으로 slide를 추가해줬다. (mockData는 js 파일 내에 간단히 작성해 뒀는데, 리팩토링 과정에서 json 파일로 별도로 분리 하려고 한다. fetch로 변경)
작업 내용 중 product list를 예를 들어보면, data, template, swiper
파일로 분리하여 아래와 같이 섹션에 따라, 데이터가 가지고 있는 속성에 따라 분기처리를 하여 UI를 그리도록 작업했다.
// productListData.js
const bestSeller = [
{
soldOut: false,
tags: { rank: 1, iwol: false, new: false, hot: false },
imgs: {
tireImg: `${imgSrc}tire/tire_kh_hp71_11_45degree.webp`,
brandImg: `${imgSrc}logo/logo_kumho.webp`,
},
productInfo: { brand: "금호", title: "크루젠 HP71", model: "235/55R19" },
price: { discount: 47, price: 125000 },
review: { rate: 4.7, count: 2833 },
keywords: { sagyejeol: "사계절용", suv: "SUV", gogeub: "고급형" },
launchEvent: false,
},
{ ... },
{ ... },
// productListCardTemplate.js
const basicProductCard = (data) => {
const { soldOut, tags, imgs, productInfo, price, review, keywords, launchEvent,
} = data;
const renderRankTag = () => { ... };
const renderReviewSection = () => { ... };
const renderKeywordList = () => { ... };
return `<div class="swiper-slide">
<a href="javascript:void(0)">
<figure class="product">
<div class="product__img ${soldOut ? "sold-out" : ""}">
${soldOut ? `<span class="hide">품절</span>` : ""}
${renderRankTag()}
<img src="${imgs.tireImg}" alt="" class="product__img__tire">
<img src="${imgs.brandImg}" alt="브랜드" class="product__img__mark">
</div>
<figcaption class="product__info">
<div class="product__info__brand"><span class="hide">브랜드</span>${
productInfo.brand
}</div>
<div class="product__info__title"><span class="hide">제품명</span>${
productInfo.title
}</div>
<div class="product__info__model"><span class="hide">모델명</span>${
productInfo.model
}</div>
<div class="product__info__price">
<span class="hide">할인율</span>
<span class="product__info__price__discount">${price.discount}%</span>
<span class="hide">가격</span>
<span class="product__info__price__value">${addComma(
price.price
)}원</span>
</div>
${renderReviewSection()}
<ul class="product__info__keywords">
${renderKeywordList()}
</ul>
${
launchEvent
? `<div class="product__info__event">런칭기념 만원 쿠폰 자동적용</div>`
: ""
}
</figcaption>
</figure>
</a>
</div>`;
};
// productListSwiper.js
const sections = [
{ name: "best-seller", data: bestSeller },
{ ... },
{ ... },
];
// Swiper 초기화 및 섹션 처리 함수
const initializeSections = () => {
sections.forEach((section) => {
// 생략
renderProductCards(section.data, wrapper, isRisingProduct);
initializeSwiper(section.name, isBestSeller);
addViewAllButton(wrapper, section.name);
// 생략
};
// Swiper 초기화 함수
const initializeSwiper = (sectionName, isBestSeller) => { ... };
// 제품 카드 생성 함수
const renderProductCards = (data, wrapper, isRisingProduct) => { ... };
// 전체보기 버튼 추가 함수
const addViewAllButton = (wrapper, sectionName) => { ... };
// rising product 섹션 리사이징 처리 함수
const handleRisingProductResize = (sectionElement, wrapper) => { ... };
// 초기화 함수 호출
initializeSections();
초반에 작업을 시작할 때는 막막했지만, 이제 data 부분만 잘 기입해도 다양한 속성의 product card UI를 일괄로 표현할 수 있게 되었다.
Main Visual 슬라이더 버벅임 개선
메인 영역의 비주얼 슬라이드를 완성해놓고 이리저리 살펴보고 있었는데, 리사이징이 발생할 때 이후에 이어질 장면이 짧은 시간 내 반복하여 비치면서 마치 렉이 걸린듯한 현상을 발견하게 되었다. 특정 디바이스에서 고정된 장면만을 본다면 문제가 없지만, 그 찰나에 보이는 것이 싫어 해당 버벅임 현상을 개선할 방법을 찾아보기로 했다.
let eventSwiper;
const initSwiper = () => {
eventSwiper = new Swiper(".swiper.visual", {
slidesPerView: 1,
loop: true,
autoplay: {
delay: 5000,
},
grabCursor: true,
pagination: {
el: ".swiper-pagination",
type: "fraction",
},
navigation: {
nextEl: ".swiper-button-next",
prevEl: ".swiper-button-prev",
},
});
};
window.addEventListener("resize", () => {
eventSwiper.destroy();
initSwiper();
});
해결한 방법은 eventSwiper 변수를 전역으로 지정해두고 resize 이벤트가 발생할 때 destroy를 통해 기존 Swiper를 제거하고 재설정하는 과정을 통해 초기화 하는 것이다. 결과로 아래 이미지 처럼 리사이징 이벤트가 발생하면 첫 번째 장면으로 돌아가는 모습을 확인할 수 있다. 시각적 불편함은 사라졌지만, 기존의 장면에서 버벅임만 개선할 수 있었다면 더욱 좋았을 것 같다.
Product List 슬라이더 반응형 처리
Swiper.js를 사용해서 비교적 수월(?)하게 작업을 쳐냈다고 생각할 수도 있겠지만, 의외의 복병이 등장하게 되는데, 바로 반응형 작업이다. 왜냐하면 현재 화면이 특정한 가로값에 머물러 있을 때 분기처리를 일괄로 처리하면 된다고 생각했는데, 기존의 UI 요소들의 레이아웃이나 사이즈 등이 달라지기도 했고, 화면 너비에 따라 유동적으로 변하는 슬라이드의 갯수를 따로 잡아줘야 했다.
slidesPerView: auto의 발견
Swiper.js에서 제공하는 breakpoints
속성을 이용하면 화면의 특정 가로 사이즈에 따라 기존의 다른 속성들을 제어할 수 있다. 이전에 작업하던 방식으로는 여러 breakpoints를 둬서 다양한 너비에 대응하는 방법을 사용했는데, 해당 방식으로는 끝도 없고 일부 구간에서는 어색하게 보이기도 했다.
그러다가 slidesPerView에서 auto라는 속성을 발견하게 되었는데, 가로 사이즈에 맞게 slide들을 자동으로 배분해주는 듯 했다. slide들이 2개, 3개, 4개 이런 방식으로 그리드에 딱딱 맞으면 정말 좋겠지만 화면 리사이즈에 따라 반씩 잘리기도 하고 아무튼 그렇게 되어야 했는데,
그냥 auto만 지정하게 되면 slide의 사이즈도 같이 짜부(?)된다. 그래서 모바일 사이즈로 대응할 때는 slide의 가로값을 강제로 지정하고 spaceBetween으로 서로의 간격을 마이너스 값으로 두어 약간의 트릭을 사용해 해결했다.
// productListSwiper.js
const initializeSwiper = (sectionName, isBestSeller) => {
new Swiper(`.swiper.${sectionName}`, {
slidesPerView: "auto",
spaceBetween: isBestSeller ? -40 : -24,
pagination: {
el: `.swiper-pagination.${sectionName}`,
clickable: false,
},
breakpoints: {
769: {
slidesPerView: 4,
spaceBetween: 24,
},
},
navigation: {
nextEl: `.swiper-button-next.${sectionName}`,
prevEl: `.swiper-button-prev.${sectionName}`,
},
});
};
Grid로 처리한 rising product 섹션
모바일 화면일 때 rising product swiper는 슬라이더가 아닌, 그리드로 표현을 해야했다. Swiper는 페이지네이션 될 여분의 slide가 없으면 작동하지 않기 때문에 css에서 미디어쿼리로 모바일 사이즈에 따라 다른 일 때 grid-template-columns 속성을 지정해줬다.
&.rising-product {
.swiper-wrapper {
@include mobile {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.6rem;
@include box(100%);
transform: translate3d(0, 0, 0) !important;
padding: 0 2rem;
box-sizing: border-box;
.swiper-slide {
@include box(100%);
height: 29rem;
a {
@include box(100%);
}
}
}
@media (min-width: 0px) and (max-width: 540px) {
grid-template-columns: repeat(1, 1fr);
}
}
}
하지만 글을 쓰고 있는 이 시점에 알게된 사실인데 이미 Swiper.js 내 grid 속성이 있었다…! 추후 리팩토링 과정에서 수정해보도록 하고 Swiper의 높이 설정은 어떤 속성을 사용하던 처리해줘야 하는 것이었기 때문에 작업한 방식을 토대로 서술해보겠다.
위와 같이 swiper wrapper를 grid로 설정한 후에 wrapper의 높이가 안의 콘텐츠의 높이에 따라 자동으로 반영되는 것을 원했지만, 아뿔싸 원래 안된다고 한다. 그래서 사용된 slide의 높이와 gap을 slide의 갯수를 사용해 높이값을 반영해 주었다.
// productListSwiper.js
const handleRisingProductResize = (sectionElement, wrapper) => {
const risingSlide = wrapper.querySelectorAll(".swiper-slide");
const setRisingListHeight = (height) => {
sectionElement.style.height = `${height}px`;
wrapper.style.height = `${height}px`;
};
const calculateHeight = () => {
const gap = 16;
let height = 0;
risingSlide.forEach((slide) => (height += slide.offsetHeight));
return height + gap * risingSlide.length - 1;
};
if (window.innerWidth < 540) {
setRisingListHeight(calculateHeight());
} else if (window.innerWidth < 769) {
setRisingListHeight(calculateHeight() / 2);
} else {
setRisingListHeight(420);
}
};
Lottie.js로 애니메이션 구현하기
기존 타이어픽 사이트의 에셋 중에 lottie를 활용한 에셋들이 있었는데, 해당 에셋들은 단순 다운로드로 구할 수가 없었다. 하지만 lottie를 사용할 흔치않은 기회가 주어졌는데 안해볼 이유가 없지 않은가? 그래서 직접 모션그래픽을 만들어 사용해보기로 했다.
위처럼 Adobe After Effects 프로그램을 사용해 기존의 정적 에셋을 가지고 모션그래픽을 만들어 Bodymovin으로 json
파일을 만들었고, lottie 라이브러리를 통해 각 섹션에 애니메이션 에셋을 적용했다. 적재적소에 애니메이션 요소를 활용하면 특정 이벤트를 더 돋보이게 하거나 사용자의 시선을 몇 초라도 더 머무를 수 있게하는 효과를 가지고 있는 것 같다.
// lottieAnimation.js
const setWinterAni = {
container: document.getElementById("lottie1"),
renderer: "svg",
loop: true,
autoplay: true,
path: "../assets/lotties/lottie_winter.json",
};
const setLicenseAni = {
container: document.getElementById("lottie2"),
renderer: "svg",
loop: true,
autoplay: true,
path: "../assets/lotties/lottie_license.json",
};
const winterAni = lottie.loadAnimation(setWinterAni);
const licenseAni = lottie.loadAnimation(setLicenseAni);
SCSS의 다양한 활용과 BEM
클래스명에 통일성을 주어 직관적으로 구조를 파악 할 수 있도록 구성
반복문, mixin, extend 등
$icons: ("home_on.svg", "store_on.svg", "search_on.svg", "my_on.svg");
$inactiveIcons: ("home_off.svg", "store_off.svg", "search_off.svg", "my_off.svg");
$iconCount: length($icons);
.tab-bar {
// 생략
li {
@for $i from 1 through $iconCount {
$icon: nth($icons, $i);
$inactiveIcon: nth($inactiveIcons, $i);
&:nth-child(#{$i}) {
&.--active {
a:before {
@include bg("svg/icons/#{$icon}", $x: center);
}
span {
color: $gray28;
}
}
a {
@include flex-center-vert;
flex-direction: column;
span {
line-height: 1.4rem;
font-size: 1.1rem;
font-weight: 500;
color: $gray96;
}
}
a:before {
content: "";
display: block;
@include boxSquare(2.6rem);
}
}
&:nth-child(#{$i}):not(--active) {
a:before {
@include bg("svg/icons/#{$inactiveIcon}", $x: center);
}
}
}
}
}
작업하면서 다양한 mixin과 extend를 활용했는데, scss로 오랜기간 작업하면 할 수록 더욱 유용한 mixin 모음 파일이 탄생할 것 같다. (반응형, flex, background-image, position center)
IR(Image Replacement) 기법과 WAI-ARIA의 활용
IR 기법
이미지를 볼 수 없는 사용자들에게 대체 텍스트를 제공하는 기법으로, 검색엔진과 스크린리더에는 노출이 되면서 시각적으로만 숨겨진다. 기본 img태그의 속성인 alt 내에 너무 긴 대체 텍스트를 작성하게 되면 스크린 리더에서 중간에 끊어 읽기가 되지 않기 때문에 IR 기법을 통해 제공한다.
// IR
@mixin hide {
display: block;
width: 0.1rem;
height: 0.1rem;
position: absolute;
border: 0;
clip: rect(0.1rem, 0.1rem, 0.1rem, 0.1rem);
overflow: hidden;
}
<div class="swiper-slide">
<a href="">
<img src="./images/0826368001640219268.jpg" alt="" />
<div class="hide">
시니어플러스 홈페이지 오픈 기념 이벤트 2019년 9월 27일부터
10월 31일까지 시니어플러스 홈페이지 오픈을 축하하는 예쁜
댓글을 달아주신 500분께 스타벅스 카페라떼 교환권을 드립니다.
클릭하시면 이벤트 참가 페이지로 이동합니다.
</div>
</a>
</div>
아래처럼 각 섹션, 복잡한 요소에서 시각적으로는 노출되지 않으면서, 스크린리더에 파트에 대한 정보를 제공하는 경우에도 IR 기법을 사용하기도 한다.
<div class="header__nav">
<h2 class="hide">메뉴</h2>
<nav class="gnb"></nav>
<h2 class="hide">검색</h2>
<div class="search"></div>
</div>
<div class="customer-cartype">
<span class="hide">고객명</span>
<span class="customer">이*지</span>
<span class="hide">차종</span>
<span class="cartype">기아 K5</span>
</div>