
벗어나야하는 이유와 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 세계를 분리하는 것이었습니다.


'외주 제작 경험' 카테고리의 다른 글
| 가벼운 Express의 한계를 해결하는 Node.js 백엔드 아키텍처 설계 (0) | 2026.03.09 |
|---|---|
| 팀원끼리 테일윈드 배치가 다 다를때 지저분함을 통일시키는 법 (1) | 2026.02.01 |
| 배포 장애 대응: JWT 환경변수 강제화 및 Swagger 서버 분리 (7) | 2026.01.24 |
| Vite만 쓰다 Next App Router 쓰면 반드시 헷갈리는 것들 (1) | 2026.01.21 |
| 클라우드 엔지니어가 Coolify 하나 던져주고 시작된 온보딩 (0) | 2026.01.14 |