Swiper로 터치 슬라이더 만들기: React·Vue 통합, 반응형 breakpoints, 효과, 이벤트

이 글의 핵심

슬라이더를 직접 만들면 터치 제스처와 무한 루프, 반응형 처리에서 금방 막히기 때문에 Swiper 같은 검증된 라이브러리를 쓰는 편이 현실적입니다. 필요한 모듈만 등록해야 번들이 가벼워지는 점, Next.js에서 쓸 때의 주의점, 구현 전에 확인할 체크리스트를 함께 정리합니다.

이 글의 핵심

Swiper로 터치 슬라이더를 구현하는 방법을 정리합니다. Vanilla JS 기본 설정, React/Vue 통합, 반응형 breakpoints, 전환 효과, 이벤트 처리를 예제 순서대로 다루고, 각 단계에서 실제로 자주 막히는 지점을 함께 설명합니다.

슬라이더를 직접 만들지 않는 이유

터치 제스처가 생각보다 복잡합니다

touchstart/touchmove로 좌우 이동을 붙이는 것까지는 금방이지만, 세로 스크롤과 가로 스와이프를 구분하는 임계값, 손을 뗀 속도에 따른 관성, 슬라이드 경계에서의 저항(rubber band), 마우스와 포인터 이벤트 통합까지 가면 코드가 급격히 늘어납니다. Swiper는 이 부분을 Pointer Events 기반으로 처리하고 touchRatio, threshold, resistanceRatio 같은 옵션으로 조정할 수 있게 해 둡니다.

반응형과 무한 루프가 까다롭습니다

화면 폭마다 보이는 슬라이드 수가 바뀌면 슬라이드 너비, 현재 인덱스, 페이지네이션 개수가 모두 다시 계산되어야 합니다. 무한 루프는 앞뒤 슬라이드를 복제하거나 재배치해야 해서 인덱스가 어긋나기 쉽습니다. Swiper는 breakpoints와 loop 옵션으로 이 계산을 맡아 줍니다.

전환 효과를 CSS만으로 만들기 어렵습니다

단순 이동은 transform: translateX로 충분하지만 fade, cube, coverflow처럼 여러 슬라이드의 투명도와 3D 회전을 동시에 제어하는 효과는 드래그 진행률에 맞춰 매 프레임 값을 계산해야 합니다. Swiper의 Effect 모듈은 이 계산을 드래그 중에도 따라가게 만들어 줍니다.

반대로 슬라이드 3장짜리 배너 하나에 이런 기능이 전부 필요한 것은 아닙니다. 터치 스와이프만 있으면 되는 경우라면 CSS scroll-snap-type으로 만든 가로 스크롤 목록이 더 가볍고 접근성도 좋습니다. Swiper는 루프, 자동 재생, 썸네일 연동, 효과처럼 스크롤 스냅으로 표현하기 어려운 요구가 있을 때 선택하는 편이 합리적입니다.


Swiper란?

핵심 특징

Swiper는 의존성 없는 터치 슬라이더 라이브러리로, 코어에는 슬라이드 이동과 제스처만 들어 있고 나머지 기능은 모듈로 분리되어 있습니다.

  • 터치 제스처: 모바일 스와이프, 마우스 드래그, 관성 스크롤 지원
  • 반응형: breakpoints로 화면 폭별 옵션 전환
  • 다양한 효과: Fade, Cube, Flip, Coverflow, Cards, Creative
  • 프레임워크 통합: React, Vue 컴포넌트와 웹 컴포넌트(Swiper Element)
  • 라이선스: MIT

모듈 구조가 중요한 이유는 번들 크기입니다. swiper-bundle은 모든 모듈을 포함한 파일이라 CDN으로 빠르게 써 볼 때는 편하지만, 번들러를 쓰는 프로젝트에서는 swiper와 swiper/modules에서 필요한 모듈만 import해야 쓰지 않는 효과 코드가 빠집니다. 반대로 모듈을 import만 하고 modules 배열에 등록하지 않으면 navigation 같은 prop을 넘겨도 아무 동작도 하지 않는데, 오류 메시지도 나오지 않아 처음에 가장 많이 헤매는 부분입니다.


설치 및 기본 사용

Vanilla JS

npm install swiper
<!DOCTYPE html>
<html>
  <head>
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" />
  </head>
  <body>
    <div class="swiper">
      <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-pagination"></div>
      <div class="swiper-button-prev"></div>
      <div class="swiper-button-next"></div>
    </div>
    <script type="module">
      import Swiper from 'https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.mjs';
      const swiper = new Swiper('.swiper', {
        pagination: {
          el: '.swiper-pagination',
        },
        navigation: {
          nextEl: '.swiper-button-next',
          prevEl: '.swiper-button-prev',
        },
      });
    </script>
  </body>
</html>

HTML 구조는 .swiper > .swiper-wrapper > .swiper-slide 3단계가 고정입니다. Swiper는 wrapper 하나에 transform을 걸어 전체 슬라이드를 한 번에 움직이므로, 클래스 이름을 바꾸거나 중간에 다른 div를 끼우면 슬라이드가 세로로 쌓이기만 하고 움직이지 않습니다. pagination과 navigation 요소는 wrapper 밖, .swiper 안쪽에 둡니다.

예제는 swiper-bundle.min.mjs를 불러오기 때문에 Pagination, Navigation 모듈이 이미 등록된 상태입니다. npm 패키지의 swiper 코어를 import해서 같은 코드를 쓰면 페이지네이션이 나타나지 않는데, 이때는 import { Navigation, Pagination } from 'swiper/modules' 후 옵션에 modules: [Navigation, Pagination]을 추가해야 합니다. 또 new Swiper()는 호출 시점의 DOM 크기로 슬라이드 너비를 계산하므로 요소가 렌더링되기 전에 실행하면 폭이 0으로 잡힙니다. type="module" 스크립트는 기본적으로 defer처럼 동작해서 이 예제에서는 문제가 없지만, 동적으로 슬라이드를 넣는 경우에는 삽입 후 swiper.update()를 호출해야 합니다.


React 통합

설치

npm install swiper

기본 사용

import { Swiper, SwiperSlide } from 'swiper/react';
import { Navigation, Pagination, Autoplay } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/pagination';
export default function ImageSlider() {
  return (
    <Swiper
      modules={[Navigation, Pagination, Autoplay]}
      spaceBetween={50}
      slidesPerView={1}
      navigation
      pagination={{ clickable: true }}
      autoplay={{ delay: 3000 }}
    >
      <SwiperSlide>
        <img src="/images/1.jpg" alt="Slide 1" />
      </SwiperSlide>
      <SwiperSlide>
        <img src="/images/2.jpg" alt="Slide 2" />
      </SwiperSlide>
      <SwiperSlide>
        <img src="/images/3.jpg" alt="Slide 3" />
      </SwiperSlide>
    </Swiper>
  );
}

React 컴포넌트에서는 navigation처럼 값 없이 prop만 주면 Swiper가 버튼 요소를 직접 만들어 넣습니다. pagination={{ clickable: true }}는 점을 눌러서 이동할 수 있게 하는 옵션으로, 기본값은 false라 점이 표시만 되고 클릭은 되지 않습니다. CSS는 코어(swiper/css)와 모듈별 파일이 분리되어 있어서, navigation CSS를 빠뜨리면 화살표가 보이지 않거나 기본 버튼 모양이 깨져 보입니다.

Next.js App Router에서 이 컴포넌트를 쓰려면 파일 맨 위에 'use client'를 붙여야 합니다. Swiper React 컴포넌트는 내부에서 useEffect와 DOM API를 쓰기 때문에 서버 컴포넌트에서 직접 렌더링하면 빌드 단계에서 hooks 관련 오류가 납니다. 페이지 자체는 서버 컴포넌트로 두고 슬라이더만 클라이언트 컴포넌트로 분리하는 구성이 일반적입니다.

직접 디자인한 화살표 버튼을 useRef로 연결할 때 흔히 겪는 문제가 있습니다. navigation={{ prevEl: prevRef.current, nextEl: nextRef.current }}로 넘기면 첫 렌더링 시점에는 ref가 아직 null이라 버튼이 동작하지 않습니다. 저도 처음에는 이 증상 때문에 CSS 문제를 한참 찾았는데, 원인은 초기화 순서였습니다. onBeforeInit={(swiper) => { swiper.params.navigation.prevEl = prevRef.current; swiper.params.navigation.nextEl = nextRef.current; }}처럼 초기화 직전에 요소를 주입하거나, 버튼에 고유 클래스를 주고 prevEl: '.my-prev' 같은 셀렉터 문자열을 넘기면 해결됩니다.


Vue 통합

<script setup lang="ts">
import { Swiper, SwiperSlide } from 'swiper/vue';
import { Navigation, Pagination, Autoplay } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/pagination';
const modules = [Navigation, Pagination, Autoplay];
</script>
<template>
  <Swiper
    :modules="modules"
    :slides-per-view="1"
    :space-between="50"
    navigation
    :pagination="{ clickable: true }"
    :autoplay="{ delay: 3000 }"
  >
    <SwiperSlide>Slide 1</SwiperSlide>
    <SwiperSlide>Slide 2</SwiperSlide>
    <SwiperSlide>Slide 3</SwiperSlide>
  </Swiper>
</template>

Vue에서는 옵션 이름을 kebab-case(slides-per-view)로 쓰고, 숫자나 객체를 넘길 때는 :(v-bind)를 붙여야 합니다. slides-per-view="1"처럼 콜론 없이 쓰면 문자열 "1"이 전달되는데, "auto"가 아닌 문자열이 들어가면 레이아웃 계산이 의도와 다르게 나올 수 있습니다. 모듈 배열은 <script setup>에서 상수로 만들어 넘기면 렌더링마다 새 배열이 생기지 않습니다.

Nuxt에서는 Next.js와 같은 이유로 슬라이더를 <ClientOnly>로 감싸거나 .client.vue 컴포넌트로 분리하는 경우가 많습니다. 서버에서 그린 마크업과 클라이언트에서 Swiper가 추가한 클래스·스타일이 달라 하이드레이션 경고가 나오기 때문입니다.


반응형

// 화면 폭별 breakpoints 설정
<Swiper
  breakpoints={{
    320: {
      slidesPerView: 1,
      spaceBetween: 10,
    },
    768: {
      slidesPerView: 2,
      spaceBetween: 20,
    },
    1024: {
      slidesPerView: 3,
      spaceBetween: 30,
    },
  }}
>
  {/* Slides */}
</Swiper>

breakpoints의 키는 최소 너비(min-width) 입니다. 위 설정에서 768은 “768px 이상일 때”를 뜻하고, 더 큰 키가 있으면 그 값이 덮어씁니다. 그래서 320px 미만 화면에서는 어떤 breakpoint도 적용되지 않고 최상위 옵션(여기서는 기본값 slidesPerView: 1)이 쓰입니다. 모바일 기본값은 최상위에 두고 큰 화면만 breakpoints로 올리는 mobile-first 구성이 읽기 쉽습니다.

기준 폭은 기본적으로 window 너비입니다. 사이드바가 있는 레이아웃처럼 슬라이더 컨테이너가 창보다 훨씬 좁으면 창 기준으로 3장을 보여 줘서 카드가 지나치게 작아질 수 있는데, 이때는 breakpointsBase: 'container'로 컨테이너 폭 기준으로 바꿀 수 있습니다.

loop: true와 함께 쓸 때는 슬라이드 수를 확인해야 합니다. 보이는 장수에 비해 슬라이드가 부족하면 콘솔에 “Swiper Loop Warning: The number of slides is not enough for loop mode” 경고가 뜨고 루프가 어색하게 동작합니다. 데스크톱에서 3장씩 보이는데 슬라이드가 4장뿐이라면 루프를 끄거나 breakpoint별로 loop를 다르게 주는 편이 낫습니다.


효과

Fade

// 필요한 모듈 import
import { EffectFade } from 'swiper/modules';
<Swiper
  modules={[EffectFade]}
  effect="fade"
  fadeEffect={{ crossFade: true }}
>
  {/* Slides */}
</Swiper>

fade 효과는 슬라이드를 옆으로 늘어놓지 않고 같은 위치에 겹쳐 둔 뒤 투명도만 바꿉니다. crossFade: true를 빼면 이전 슬라이드는 불투명한 채로 남고 새 슬라이드만 서서히 나타나서, 슬라이드 배경이 투명하면 두 내용이 겹쳐 보입니다. 이미지가 아닌 텍스트 슬라이드라면 crossFade를 켜거나 슬라이드에 배경색을 지정하세요. 또 fade는 한 번에 한 장만 보여 주는 효과라 slidesPerView를 1보다 크게 주면 의도대로 동작하지 않습니다.

Cube

// 필요한 모듈 import
import { EffectCube } from 'swiper/modules';
<Swiper
  modules={[EffectCube]}
  effect="cube"
  cubeEffect={{
    shadow: true,
    slideShadows: true,
    shadowOffset: 20,
    shadowScale: 0.94,
  }}
>
  {/* Slides */}
</Swiper>

cube 효과는 3D transform으로 정육면체를 돌리는 방식이라, 부모 요소에 overflow: hidden이 걸려 있으면 그림자가 잘리고 perspective가 다른 요소와 겹치면 모서리가 튀어나와 보일 수 있습니다. 쇼핑몰 메인 배너처럼 자주 보는 영역에서는 화려한 효과가 오히려 피로감을 주고 저사양 모바일에서 프레임이 떨어지기 쉬우므로, 실무에서는 기본 slide나 fade를 주로 쓰고 cube·flip은 강조가 필요한 곳에만 제한적으로 씁니다. prefers-reduced-motion을 설정한 사용자에게는 효과를 끄는 것도 고려할 만합니다.


이벤트

<Swiper
  onSlideChange={(swiper) => {
    console.log('Slide changed to:', swiper.activeIndex);
  }}
  onReachEnd={() => {
    console.log('Reached end');
  }}
>
  {/* Slides */}
</Swiper>

React에서는 Swiper 이벤트 이름 앞에 on을 붙인 prop으로 받습니다(slideChange → onSlideChange). 콜백 첫 인자는 Swiper 인스턴스라 activeIndex, isEnd, progress 같은 상태를 바로 읽을 수 있습니다. 컴포넌트 밖에서 slideTo(), slideNext()를 호출하려면 onSwiper={setSwiper}로 인스턴스를 state에 저장해 두면 됩니다.

loop: true일 때는 activeIndex가 아니라 realIndex를 써야 합니다. 루프 모드에서는 슬라이드가 재배치되기 때문에 activeIndex가 데이터 배열의 인덱스와 일치하지 않고, 이 값을 그대로 쓰면 현재 슬라이드 제목이나 외부 탭 하이라이트가 한 칸씩 어긋납니다. 이벤트 로그에 찍힌 숫자가 3장짜리 슬라이더에서 4, 5로 나온다면 이 문제입니다. onReachEnd도 루프 모드에서는 끝이 없으므로 기대한 시점에 호출되지 않습니다.


자주 겪는 문제

탭이나 모달 안의 슬라이더가 깨져 보입니다. display: none 상태에서 초기화되면 너비가 0으로 계산되어 슬라이드가 한곳에 겹칩니다. observer: true, observeParents: true 옵션을 주면 부모의 스타일 변화를 감지해 다시 계산하고, 아니면 모달이 열린 직후 swiper.update()를 호출합니다.

슬라이드를 추가했는데 반영되지 않습니다. React/Vue 컴포넌트는 자식 SwiperSlide 변화를 감지해 업데이트하지만, Vanilla JS에서 DOM을 직접 바꿨다면 swiper.update()가 필요합니다. 슬라이드가 수백 개라면 전부 DOM에 두지 말고 Virtual 모듈을 검토하세요.

이미지 높이 때문에 슬라이더 높이가 튑니다. 이미지가 로드되기 전에는 높이가 0이라 레이아웃이 밀립니다(CLS). img에 width/height 속성이나 CSS aspect-ratio를 지정해 자리를 미리 잡아 두면 됩니다. 필요하면 autoHeight: true로 현재 슬라이드 높이에 맞출 수 있지만, 슬라이드마다 높이가 다르면 넘길 때마다 아래 콘텐츠가 움직입니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 무료로 사용할 수 있나요?

A. 네, MIT 라이선스라 상업 프로젝트에서도 무료로 쓸 수 있습니다. 다만 Swiper 팀이 별도로 판매하는 프리미엄 템플릿이나 효과 플러그인은 라이선스가 따로 있습니다.

Q. 모바일에서 잘 작동하나요?

A. 네, 터치 제스처와 관성 스크롤을 기본 지원합니다. 세로 스크롤 페이지 안에서 가로 스와이프가 스크롤을 방해한다면 touchAngle이나 threshold로 민감도를 조정할 수 있습니다.

Q. Next.js에서 사용할 수 있나요?

A. 네. App Router에서는 슬라이더 컴포넌트 파일에 'use client'를 선언해야 하고, 서버에서 그린 초기 마크업과 차이로 첫 화면이 잠깐 깜빡일 수 있으니 첫 슬라이드 높이를 CSS로 고정해 두는 편이 좋습니다.

Q. Swiper 메이저 버전을 올릴 때 주의할 점은?

A. 메이저 버전마다 모듈 import 경로(swiper/modules)나 Lazy 모듈 제거 같은 변경이 있었으므로 업그레이드 전에는 변경 로그를 확인하세요.