styled-components 환경에서 효율적으로 반응형 처리하기
조금 더 리액트스럽게 퍼블리싱 하기 위한 고민을 담았습니다.안녕하세요 페이히어 프론트엔드 엔지니어 김태호 입니다.
페이히어의 홈페이지는 서비스를 찾는 대부분의 유저들이 가장 먼저 만나게 되는 얼굴입니다.
마케터, 디자이너뿐만 아니라 개발자까지 홈페이지를 방문하는 다양한 유저들에게 효과적으로 보일 수 있도록 노력하는데요, 그중 이 글에서는 반응형 웹을 styled-components 환경에서 적용하는 데 있어 조금은 다른 시선으로 접근하여 해결해 본 프론트엔트 팀의 노력을 담았습니다.
기존 상황
반응형 웹을 제공하기 위해서 대부분 CSS Media Query 를 사용하는 방식을 사용하고 있습니다.
기존에 styled-components 와 Media Query 를 활용하여 개발하였을 때 다음과 같은 점이 아쉬웠습니다.
공통 컴포넌트 사용이 어렵다.
React 환경에서 Text, Icon, Button 등의 공통화할 수 있는 컴포넌트 들을 반응형 대응을 위해 각 컴포넌트에서 새로 만들어야 한다.
같은 스타일 처리를 위한 중복 코드가 생겨난다.
화면 크기에 따라 font-size 를 다르게 설정해야 하는 상황이 있을 때 해당 속성을 화면 크기에 따라 각각 정의해야 한다.
PC, Mobile 에 대한 여부 처리가 번거롭다.
특정 화면 크기에서만 보여야 하는 영역이 있을 때 해당 처리를 위해 컴포넌트에 각각 Media Query 를 통한 스타일 처리가 필요하다.
또한 비즈니스 로직 개발뿐만 아니라 스타일시트 작성 시에도 코드의 가독성은 매우 중요하다 생각되었고, 특히 Media Query 방식은 각 요소가 어떤 상황에서 어떻게 그려질지 한눈에 파악하기 어려웠습니다.
이런 이유로 지난 5월 홈페이지 디자인 전면 개편 당시, 위의 아쉬운 점을 해결할 새로운 목표를 설정했습니다.
1. Media Query 에 의존하지 않고 다양한 화면 대응
2. 하나의 CSS 속성을 통해 각 화면 별 스타일 정의
3. 모바일 요소 표현 여부를 React 조건부 렌더링으로 처리
기술적 고려사항
브라우저 크기에 따른 상태값 설정
Media Query 의 대체를 위해 생각한 방법은 Window 객체 내 Viewport 정보를 활용하는 것입니다.
페이히어의 홈페이지의 경우 가로 768px 을 기준으로 PC 와 Mobile 여부가 나뉘게 되며, Breakpoint 를 지나는 시점에서 현재 화면이 어떤 상태인지를 나타낼 수 있도록 isMobile
이라는 상태 값을 추가하였습니다.
const isMobile = window.innerWidth <= 768 || window.outerWidth <= 768
React 컴포넌트 환경
화면에 따른 분기 처리를 위해 각 컴포넌트에서 isMobile
상태를 매번 만들어서 사용한다면 매우 비효율적일 것입니다. 중복 코드 없이 각 컴포넌트에 상태를 제공하기 위해서는 Custom Hook 을 활용하는 것이 가장 적합하다고 생각하였습니다.
import {
useLayoutEffect,
useState,
} from 'react';
function useViewport() {
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);
const [isMobile, setIsMobile] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const handleResize = () => {
setWidth(window.innerWidth);
setHeight(window.innerHeight);
setIsMobile(window.innerWidth <= 768 || window.outerWidth <= 768);
};
useLayoutEffect(() => {
handleResize();
setIsLoaded(true);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return {
width,
height,
isMobile,
isLoaded,
};
}
export default useViewport;
useViewport Hook 은 다음 순서로 동작합니다.
1. handleResize()
실행
2. 브라우저 가로 크기에 따라 isMobile
여부 설정
3. isMobile
상태에 맞는 스타일로 변경
4. isLoaded
상태 변경으로 인한 화면 렌더링 (React Client Render)
이후 resize 이벤트 감지 시 handleResize()
호출
SSR(Server Side Rendering) 방식의 경우 서버 렌더링 단계에서 Window 객체에 접근할 수 없습니다.
따라서 렌더링이 마무리된 후 동기적으로 동작할 수 있도록 React Layout Effect 안에 담아 처리하였고, 그 후 화면이 보일 수 있도록 isLoaded 상태를 추가하였습니다. 이제 가로 768px 을 기점으로 isMobile 상태 값을 통해 PC, Mobile 사이의 스타일 작성을 더 간편하게 할 수 있게 되었습니다 🚀
Refactor
개선을 위한 준비는 끝났으니 이제 하나하나 적용해 보며 아쉬운 점을 어떻게 해결할 수 있었는지 알아보려 합니다.
이제는 공통 컴포넌트를 쓸 수 있습니다.
기존 공통 컴포넌트를 사용하기 어려웠던 이유는 styled-components 환경에서 각 속성을 받아 다양한 화면에서 유연하게 처리하기에는 다음과 같은 문제가 있다 판단되었기 때문입니다.
PC 와 Mobile 각각의 속성을 props 를 통해 따로 받아야 하는 공통 컴포넌트가 있다고 생각해 봅시다.
코드로 나타내지는 않았지만 위 Hook 이 없다면 PC 와 Mobile 속성을 각각의 Media Query 에 담아야 할 것이며, 화면별로 다르게 보여주기 위해 서로 다른 props 를 통해 제공해야 하거나 또 다른 많은 처리를 해 주어야 할 수도 있습니다.
<Text
fontSize={16}
mobileFontSize={12}
lineHeight={20}
mobileLineHeight={18}
>
Hello World!
</Text>
단순하게 PC, Mobile 간에 font-size 와 line-height 를 서로 다르게 나타내기 위해서 벌써 4개의 props 가 필요하게 되었습니다.
작성한 useViewport Hook 을 활용하면 다음과 같이 개선할 수 있습니다.
<Text
fontSize={isMobile ? 12 : 16}
lineHeight={isMobile ? 18 : 20}
>
Hello World!
</Text>
각 해상도 처리를 위한 추가적인 props 없이 깔끔하게 처리된 모습을 볼 수 있습니다. 같은 스타일 처리를 위한 중복 코드가 필요했던 아쉬움도 같이 해결되었네요!
display: none 은 이제 필요 없습니다. 기존 CSS Media Query 를 활용하여 모바일에서만 보여야 하는 요소를 만들어 본다면
...
AppDownloadButtonForMobile: styled.div`
display: none;
width: 120px;
height: 60px;
...
@media (min-width: 320px) and (max-width: 768px) {
display: block;
}
`;
위와 같은 방식이 일반적입니다. 물론 어떠한 문제도 없고, 저 역시 지금까지 퍼블리싱을 하면서 정말 자주 사용해왔던 방식이지만 React 환경에서는 조금 아쉽다는 생각이 들었습니다.
useViewport Hook 을 활용하여 다음과 같이 간단하게 표현할 수 있습니다.
AppDownloadButtonForMobile: styled.div`…
`;
AppDownloadButtonForPc: styled.div`…
`;
const AppDownloadButton = ({ isMobile }) => {
return (
{isMobile ? <AppDownloadButtonForMobile /> : <AppDownloadButtonForPc />}
)
}
각 컴포넌트 내에서 Media Query 를 통해 요소의 표현 여부를 결정할 필요 없이 이제는 조건부 렌더링을 통해 더 직관적이고 편리하게 분기처리를 할 수 있게 되었습니다!
Product
아래 코드는 위 방법들을 적극적으로 활용하여 만든 배너 영역의 컴포넌트 입니다.
// import 생략
const Container = styled.div`
position: relative;
width: 100%;
height: 100%;
background: url(${(props) => (props.isMobile ? MobileBannerBackground : BannerBackground)});
background-size: cover;
background-position: ${(props) => (props.isMobile ? '0 -20px' : 'center')};
background-repeat: no-repeat;
`;
const Content = styled.div`
width: 100%;
height: ${(props) => (props.isMobile ? '600px' : '60vh')};
max-width: 995px;
min-height: ${(props) => !props.isMobile && '890px'};
display: flex;
flex-direction: column;
align-items: ${(props) => (props.isMobile ? 'center' : null)};
justify-content: center;
margin: auto;
`;
const Banner = ({ isMobile }) => (
<Container isMobile={isMobile}>
<Content isMobile={isMobile}>
{isMobile && (
<CustomText
fontSize={isMobile ? 18 : 30}
letterSpacing={-0.5}
textAlign={isMobile ? 'center' : 'left'}
color={colors.black}
marginTop={isMobile ? 102 : 'auto'}
>
태블릿과 휴대폰을 포스기로
</CustomText>
)}
<CustomText
fontSize={isMobile ? 32 : 50}
fontWeight="bold"
letterSpacing={-0.5}
textAlign={isMobile ? 'center' : 'left'}
color={isMobile ? colors.black : colors.primary}
marginTop={isMobile ? 14 : 'auto'}
>
{'카운터의 새로운 풍경,\n페이히어 포스'}
</CustomText>
{!isMobile && (
<SubTitle
fontSize={isMobile ? 18 : 30}
letterSpacing={-0.2}
textAlign={isMobile ? 'center' : 'left'}
color={colors.primary}
marginTop="18px"
>
태블릿과 휴대폰을 포스기로 이용하세요
</SubTitle>
)}
<DownloadButton isMobile={isMobile} />
<ScrolldownIcon style={{ alignSelf: 'center' }} />
</Content>
</Container>
);
export default Banner;
이제 더 이상 수많은 Media Query 에 둘러싸여 고통받지 않고
Custom Hook 을 통해 다양한 화면에 유연하게 대응하며, 디바이스 화면별 요소 표현 여부를 React 조건부 렌더링으로 처리할 수 있게 되었습니다 🚀
마치며
개선된 방법을 적용한지 벌써 3개월 이상이 지나가는 시점에서 중간중간 여러 가지 변경을 하였지만 그중 가장 핵심으로 생각되는 부분을 글로써 남겨보았습니다.
스스로도 이 개선을 진행하며 과연 좋은 구조일까 많은 고민을 하며 진행했는데, 코드의 간결성과 가독성 그리고 개발 속도 측면에서도 이전 방식 대비 분명한 발전이 있다고 느껴지게 되어 매우 뿌듯하게 홈페이지 개편 작업을 마무리했던 것 같습니다.
이런 저의 고민과 해결방식이 흥미로우셨거나, 페이히어 프론트엔드 팀에 조금이라도 관심이 생기셨다면
recruit@payhere.in 으로 연락주세요! 언제나 환영합니다 😆