외주 제작 경험

Swiper Library의 내부 디자인 영역(Transform)에서 벗어나기

Frisbeen 2026. 2. 15. 17:41

벗어나야하는 이유와 Case

모바일 웹에서 앱 같은 UX를 구현하기 위해 Swiper 기반의 가로 스와이프 구조를 도입했습니다.

좌우 스와이프로 페이지를 넘길 수 있고, 내부에는 게시판, 공지, 학식 등 여러 섹션이 존재합니다.

이 구조 자체는 문제없이 동작했습니다.

문제는 단 하나였습니다.

게시판 영역의 글쓰기 버튼(FAB)이 화면에 고정되지 않았습니다.

position: fixed를 적용했음에도 불구하고 버튼은 스와이프와 함께 움직이거나 위치가 어긋났습니다.

z-index도 의도대로 동작하지 않았습니다.

분명히 제가 알던대로 css 속성을 집어넣었는데 말이죠

이 글은 그 문제를 해결하기 위해 Portal을 적용한 과정과, 그 배경이 되는 transform의 동작 원리에 대한 정리입니다.


전체 구조

현재 상위 구조는 다음과 같습니다.

HomeSlider.tsx가 쉽게말하면 메인페이지의 구조체입니다.

const HomeSlider = () => {
  return (
    <Swiper
      className='h-full w-full'
      slidesPerView={1}
      threshold={5}
      initialSlide={1}
    >
      <SwiperSlide className='h-full w-full overflow-y-auto'>
        <DepartmentMainPage />
      </SwiperSlide>

      <SwiperSlide className='h-full w-full overflow-y-auto'>
        <Main />
      </SwiperSlide>

      <SwiperSlide className='h-full w-full overflow-x-hidden overflow-y-auto'>
        <Slides />
      </SwiperSlide>
    </Swiper>
  );
};

 

모바일 기준으로 Main 내부에는 게시판 위젯이 포함됩니다.

<SectionSlide key='board' title='게시판' backgroundColor='bg-white'>
  <BoardSection showWriteButton={true} />
</SectionSlide>

 

기존의 BoardSection 내부 구현은 다음과 같았습니다.

{showWriteButton && (
  <div className='fixed right-5 bottom-8 z-50'>
    <Link to='/board/write'>
      <div className='bg-blue-35 h-[56px] w-[56px] rounded-full'>
        글남기기
      </div>
    </Link>
  </div>
)}

 

이 글 남기기 버튼은 화면 오른쪽 아래에 고정되어야 합니다.

테일윈드 속성에 잘 fab 디자인 속성을 잘 주입했는데 말이죠.

그러나 실제 동작은 그렇지 않았습니다.

왼쪽 화면 처럼 스크롤이 되어도 글남기기 버튼은 고정되었어야합니다.

분명 fixed를 박아놨는데, 왜 움직였을까요?


Swiper 라이브러리에 대하여

이번 문제의 원인은 모바일 반응형 스와이프를 위해 도입한 Swiper 라이브러리의 내부 구현 방식에 있습니다.

Swiper는 슬라이드를 이동시킬 때 각 슬라이드를 하나씩 위치 이동시키지 않습니다.

대신 슬라이드들을 감싸는 wrapper 전체를 통째로 이동시킵니다.

.swiper-wrapper {transform:translate3d(-300px,0,0);
}

이 방식은 단순한 애니메이션 효과가 아닙니다.


스와이프 시의 성능 이슈가 존재한다

Swiper가 transform: translate3d(...)를 사용하는 이유는 성능 때문입니다.

일반적으로 left, margin, position 등을 변경하면 브라우저는 레이아웃을 다시 계산해야 합니다.

이 과정은 비용이 크고, 특히 모바일 환경에서는 프레임 드랍이 발생하기 쉽습니다.

반면 transform은 다음과 같은 특징이 있습니다.

  • 레이아웃을 다시 계산하지 않습니다.
  • GPU 가속을 활용할 수 있습니다.
  • 애니메이션이 부드럽습니다.
  • 모바일 환경에서 성능이 뛰어납니다.

즉, Swiper는 부드러운 스와이프 UX를 위해 의도적으로 transform을 사용한 것입니다.


그런데 왜 이게 fixed를 깨뜨리는가

문제는 transform이 단순한 “이동 속성”이 아니라는 점입니다.

CSS에서 transform이 적용된 요소는:

  • 새로운 좌표계를 생성합니다.
  • 새로운 stacking context를 생성합니다.
  • 독립적인 렌더링 레이어로 분리됩니다.

여기서 첫 번째가 핵심입니다.

원래 position: fixed는 viewport 기준으로 동작합니다.

즉, 화면 오른쪽 아래에 고정되어야 합니다.

그러나 transform이 적용된 조상 요소가 존재하면 브라우저는 다음과 같이 해석합니다.

이 요소는 독립적인 렌더링 레이어다.

그 안의 fixed는 viewport가 아니라 이 레이어 기준으로 계산한다.

결과적으로 fixed가 화면 기준이 아니라

“움직이는 swiper-wrapper 기준”으로 계산됩니다.

Swiper가 이동하면 버튼도 함께 이동하는 것처럼 보이는 이유가 바로 이것입니다.


1. transform과 fixed의 관계

원래 position: fixed는 viewport 기준입니다.

우리가 잘 알다시피

  • 스크롤과 무관하게 화면에 고정됩니다.
  • 부모 요소의 위치 영향을 받지 않습니다.

그러나 transform이 적용된 조상 요소가 존재하면 브라우저는 다음과 같이 동작합니다.

transform이 적용된 요소는 독립적인 렌더링 레이어다.

그 안의 fixed는 그 레이어 기준으로 계산한다.

결과적으로 fixed가 화면 기준이 아니라

“움직이는 swiper-wrapper 기준”으로 계산됩니다.

Swiper가 이동하면 버튼도 같이 이동하는 것처럼 보입니다.


2. overflow와 stacking context의 영향

Swiper는 슬라이드 경계를 유지하기 위해 overflow: hidden을 사용합니다.

이 경우 내부 요소가 경계를 벗어나면 잘립니다.

또한 transform은 새로운 stacking context를 생성합니다.

이는 z-index가 예상대로 동작하지 않는 원인이 됩니다.


해결책은 Portal을 Create하는 것.

기본적으로 React는 다음과 같이 렌더링합니다.

<Page>
	<Button />
</Page>

React 트리와 DOM 트리는 동일합니다.

Page
 └─Button

Button은 Page의 DOM 안에 위치하며

Page 및 상위 요소의 CSS 영향을 그대로 받습니다.

Swiper 내부에 존재한다면

Swiper의 transform과 overflow 영향을 그대로 받게 됩니다.


해결 전략: DOM 계층을 분리한다

문제는 React 계층이 아니라 DOM 계층이었습니다.

fixed가 transform 영향을 받는 이유는 버튼이 transform이 적용된 DOM 체인 안에 존재하기 때문입니다.

해결 방법은 버튼을 그 체인 밖으로 이동시키는 것입니다.

즉 스와이프 내부 영역의 transform 때문에 fixed의 동작에 영향을 주기 때문에 이 영역의 DOM 영역을 벗어나는 것입니다.


실제 생성을 한다면

import { createPortal } from 'react-dom';

export default function BoardSection({ showWriteButton = false }) {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  return (
    <section className='relative flex min-h-[500px] flex-col bg-white'>
      ...

      {isMounted &&
        showWriteButton &&
        createPortal(
          <div className='fixed right-5 bottom-8 z-50'>
            <Link to='/board/write'>
              <div className='bg-blue-35 flex h-[56px] w-[56px] flex-col items-center justify-center rounded-full shadow-[0px_4px_10px_rgba(0,0,0,0.25)]'>
                글남기기
              </div>
            </Link>
          </div>,
          document.body
        )}
    </section>
  );
}


Button의 영역 벗어나기

기존 DOM 구조

body
 └─#root
     └─ Swiper (transform)
         └─ Slide
             └─Button

수정 후 DOM 구조:

body
 ├─#root
 │   └─ Swiper (transform)
 │       └─ Slide
 └─Button

버튼이 transform 조상 체인에서 완전히 분리되었습니다.


영역에서 벗어난 버튼

Portal은 React 트리는 유지하면서 DOM 부모만 변경합니다.

그 결과 버튼은

  • transform 좌표계 밖에 존재합니다.
  • overflow 경계 밖에 존재합니다.
  • stacking context 영향에서 벗어납니다.
  • viewport 기준 fixed가 복구됩니다.

문제가 CSS 속성 하나가 아니라 렌더링 좌표계의 문제였기 때문에

DOM 계층을 바꾸는 것만으로 해결된 것입니다.


Transform을 무시하면 안돼는 이유 : ☠️

transform은 단순한 애니메이션 속성이 아닙니다.

transform은 새로운 좌표계와 레이어를 생성하는 강력한 속성입니다.

Swiper는 성능을 위해 transform을 사용합니다.

그 결과 fixed 기반 UI가 깨질 수 있습니다.

이 문제를 해결하는 방법은

CSS를 조정하는 것이 아니라

DOM 계층을 재배치하는 것입니다.

Portal은 그 재배치를 가능하게 하는 도구입니다.

스와이프 구조에서 벗어나기 위한 해답은

컴포넌트 수정이 아니라

DOM 세계를 분리하는 것이었습니다.