article content thumbnail

SSR 환경의 반응형 깜빡임 현상 해결하기

Next.js SSR 환경에서 모바일로 접근했을 때 일어나는 깜빡임 현상을 해결해보자

🧶 문제 상황

우리는 Next.js app router를 기반으로, 블로그를 SSR로 제공하고 있다.

그런데 이전부터 너무 신경 쓰였던 것이.. 모바일에서 블로그에 접근을 하게 되면 아래처럼 레이아웃이 한차례 깨지고 스타일이 적용되는 깜빡임 현상이 발생한다.





이 현상은 사용자에게 매번 피로감을 주기 때문에 사용자 경험에 좋지 않다고 생각했고, 무엇보다 나 스스로 불편함을 느껴서 해결해보고자 했다.



🤔 이런 현상이 왜 일어날까?


가장 먼저 생각나는 것은 SSR에서 styled-component 적용 시점에 의한 깜빡임 현상이다. 그러나 Next.js 공식문서에 나와있는 것처럼 처음부터 잘 처리해줬기 때문에, 원인은 아니었다.

그리고 모바일에서만 이 현상이 나타나는 것을 봐서 반응형과 관련이 있다고 생각을 했다.

우리는 useMediaQuery을 사용해 반응형을 구현하고 있었다.

커스텀 훅 useCheckMobile 을 만들어, 각 컴포넌트에서 isMobile 값을 받아 조건적으로 스타일링을 구현해주도록 했다.

useMediaQuery는 window.matchMedia 즉 브라우저 API를 사용하기 때문에 SSR에서는 아무 효과를 줄 수 없다는 것을 미리 알고 가자!


useCheckMobile.ts

'use client';

import { useEffect, useState } from 'react';
import { useMediaQuery } from 'react-responsive';

const useCheckMobile = () => {
  const [isMobile, setIsMobile] = useState(false); // default: false

  const mobile = useMediaQuery({
    query: '(max-width:768px)',
  });
  useEffect(() => {
    if (mobile) {
      setIsMobile(mobile);
    } else {
      setIsMobile(false);
    }
  }, [mobile]);
  return isMobile;
};

export default useCheckMobile;


BlogImg.ts (블로그 대문 이미지 코드를 대표로 가져왔다.)

'use client';

import Image from 'next/image';
import styled from 'styled-components';

import useCheckMobile from '@/hooks/useCheckMobile';

const BlogImg = () => {
  const isMobile = useCheckMobile(); // 디바이스 값 반환 받기

  return (
    <BlogImgContainer>
        {/* 이렇게 조건 분기해서 사용 */}
        <BlogImgWrapper className={isMobile ? 'mobile' : ''}> 
        {/* ... 생략 */}
        </BlogImgWrapper>
    </BlogImgContainer>
  );
};

// 스타일 컴포넌트 css 생략


⭐ 여기서 중요한 점!

위에서 언급했던 것처럼 styled-component가 서버 사이드에서 입혀지도록 처리를 해놨기 때문에, 서버 사이드에서 한차례 스타일링이 입혀진 HTML을 클라이언트 사이드로 보내준다.


우리의 구현 방식에 따르면,

  1. 서버 사이드에서 상태값 isMobilefalse로 initialize 된다. 그리고 그에 맞게 데스크탑 스타일링이 입혀진 HTML이 클라이언트 사이드로 전달된다.

  2. 최초로 우리는 서버사이드에서 내려준 HTML을 보게 되고,

    이후 클라이언트 사이드에서 useMediaQuery 에 의해 HTML에 다시 모바일 스타일링이 입혀지게 되면서 깜빡임 현상이 일어나는 것이었다.



✨ 해결 방법

위 원인을 파악하고 이를 해결하기 위해서는 서버 사이드에서 디바이스를 미리 판단해야 한다고 생각했다.



그래서 이렇게 request header에 디바이스 정보를 가지고 있는 user-agent값을 이용하기로 했고, middleware을 다음과 같이 작성해 보았다.

middleware.ts

import { NextRequest, NextResponse } from 'next/server';

export const middleware = (request: NextRequest) => {

  // ...

  // User-Agent를 사용하여 디바이스 모바일 여부 결정const userAgent = request.headers.get('user-agent');const isMobile = userAgent ? /mobile/i.test(userAgent) : false;

  // ...

  const response = NextResponse.next();
  // response header에 x-is-mobile값 세팅
  response.headers.set('x-is-mobile', isMobile.toString()); 
  return response;
}

이렇게 request에서 user-agent 값을 받아와 정규표현식을 통해 모바일 값인지 판단한 후, response 헤더에 x-is-mobile 값으로 담아 주었다.


헤더에 잘 실리는지 확인을 해보니

모바일에서 접근했을 때:



데스크탑에서 접근했을 때:



아주 잘 들어온다!


그럼 이 값을 각 컴포넌트에서 어떻게 사용하느냐!

import { headers } from 'next/headers';

const headerList = headers();
const isDeviceMobile = headerList.get('x-is-mobile') === 'true';

이렇게 next/headersheaders를 이용하여 받아올 수 있었다.


그 다음 중요한 점은 서버 사이드에서 디바이스를 판단했으니 isMobile의 초기값으로 판단한 값을 넣어주는 것이다.

useCheckMobile.ts

'use client';

import { useEffect, useState } from 'react';
import { useMediaQuery } from 'react-responsive';


const useCheckMobile = (initialValue: boolean) => {
  // user-agent로 판단한 isMobile 값을 initialValue로 설정const [isMobile, setIsMobile] = useState(initialValue);

  const mobile = useMediaQuery({
    query: '(max-width:768px)',
  });

  useEffect(() => {
    if (mobile) {
      setIsMobile(mobile);
    } else {
      setIsMobile(false);
    }
  }, [mobile]);
  return isMobile;
};

export default useCheckMobile;




BlogImg.ts

'use client';

import Image from 'next/image';
import styled from 'styled-components';

import useCheckMobile from '@/hooks/useCheckMobile';

const BlogImg = () => {
  const headerList = headers();
  const isDeviceMobile = headerList.get('x-is-mobile') === 'true';

  const isMobile = useCheckMobile(isDeviceMobile); // 디바이스 값 반환 받기

  return (
    //블로그 대문 이미지가 있는 경우에만 블로그 소개글이 같이 나타남
    <BlogImgContainer>
        {/* 이렇게 조건 분기해서 사용 */}
        <BlogImgWrapper className={isMobile ? 'mobile' : ''}>
        // ... 생략
        </BlogImgWrapper>
    </BlogImgContainer>
  );
};

// 스타일 컴포넌트 css 생략

이렇게 해주면 서버 사이드에서 처음부터 디바이스를 판단한 값이 적용된 isMobile값을 기반으로 HTML을 만들기 때문에, 모바일이면 모바일에 맞는 스타일링을 입혀 클라이언트 사이드로 보낼 수 있게 된다.


그 결과..! dev 환경에서 실행했을 때, 새로고침을 하거나 접속했을 때 레이아웃이 깨지지 않는다 !!!! 😆




잘 되는걸 확인하고 배포를 했다.


그.런.데

배포본을 확인해보니 여전히 레이아웃이 깨졌다. 절망감과 함께 response header 에 있는 X-Is-Mobile 을 확인해보니, 모바일로 접속했음에도 불구하고 false 값이 나오고 있었다.

어디서부터 잘못된 것인지 확인하기 위해 response header에 userAgent값도 추가해보았다.

middleware.ts

import { NextRequest, NextResponse } from 'next/server';

export const middleware = (request: NextRequest) => {

  // ...

  const userAgent = request.headers.get('user-agent');
  const isMobile = userAgent ? /mobile/i.test(userAgent) : false;

  // ...

  let response = NextResponse.next();
  response.headers.set('user-agent', userAgent.toString()); // 여기 추가!
  response.headers.set('x-is-mobile', isMobile.toString());
  return response;
}

그랬더니 확인한 결과..!

원래 나와야 하는 값은 이건데



User-Agent: Amazon CloudFront 가 실려오고 있었다.


우리는 AWS CloudFront를 사용하고 있기 때문에, request header 가 cloudfront를 거치는 과정에서 그대로 실려오지 못하고, Amazon CloudFront로 변경되어 오고 있던 것이다.


그래서 AWS CloudFront에서 user-agent 값이 그대로 실려오도록 설정을 바꿔주어야 했다.


✅ 해결 방법

AWS CloudFront 배포 인스턴스에서 기존에 쓰고 있던 캐시 정책을 찾아 다음과 같이 헤더에 User-Agent를 추가해줬다.



그 결과! 배포본에도 잘 적용이 되었다.


사용자 경험을 향상시키는 경험을 하는 것은 언제나 뿌듯하다 😊



최신 아티클
Article Thumbnail
김서현
|
2024.06.30
[Trouble Shooting] 붙여넣기된 base64 이미지를 CDN 주소로 갈아끼우자! (feat. tiptap)
매우 느린 렌더링 속도의 원인이었던 base64 이미지 처리를 tiptap 라이브러리 환경에서 해결해 나가는 과정 기록하기
Article Thumbnail
김서현
|
2024.06.05
SEO 100점 만들기 마지막 단계 - robots.txt
Next.js의 middleware를 이용해 구현한 서브도메인(블로그)에서 생긴 robots.txt 관련 에러 해결하기
Article Thumbnail
김서현
|
2024.04.20
Next.js App Router 에서 동적 사이트맵(Sitemap) 만들기
Next.js의 generateSitemap() 와 sitemap.ts를 이용해서 동적 sitemap을 만들어보자!