Back to all posts

초기 렌더링에서 화면 노출을 제어하는 방법

SSR과 hydration 사이에서 의도하지 않은 UI가 먼저 노출되는 문제를 막기 위한 초기 렌더링 제어 패턴

2026년 05월 03일7
thumbnail for 초기 렌더링에서 화면 노출을 제어하는 방법

초기 렌더링에서 화면 노출을 제어하는 방법을 실제로 설정할 때 필요한 흐름을 정리한 글입니다.

개인 메모를 그대로 옮기기보다, React 환경에서 어떤 순서로 확인하고 설정해야 하는지에 초점을 맞췄습니다.

시작하기 전에

작업 전에는 대상 환경, 인터페이스 이름, 설정값, 확인 명령어를 먼저 정리합니다.

초기 렌더링에서 화면 노출을 제어하는 방법

[!summary]+ 3줄 요약

  • SSR, CSS 로드, 클라이언트 hydration 사이에는 짧은 시간차가 생길 수 있다.
  • 이 구간에서 UI가 먼저 노출되면 스타일이 깨진 화면, 잘못된 테마, 인증 전 화면 같은 깜빡임이 발생한다.
  • 해결의 핵심은 React가 실행되기 전에 브라우저가 이해할 수 있는 HTML, script, critical CSS로 초기 화면 상태를 먼저 고정하는 것이다.

웹 애플리케이션에서 화면을 제어한다는 것은 단순히 isLoading 상태를 두는 것보다 넓은 문제다. React 상태는 클라이언트 자바스크립트가 실행된 뒤에야 의미가 있다. 하지만 사용자는 그보다 먼저 서버가 내려준 HTML을 보고, 브라우저는 CSS와 스크립트를 각각의 타이밍에 맞춰 해석한다.

그래서 초기 화면에서 중요한 것은 "컴포넌트가 어떤 상태를 가지는가"가 아니라 "첫 페인트 전에 브라우저가 어떤 화면을 그릴 수 있는가"이다.

왜 초기 렌더링 제어가 필요한가

Next.js 같은 SSR 기반 프레임워크에서는 서버가 HTML을 먼저 만든다. 이후 브라우저는 HTML을 파싱하고 CSS를 적용한 뒤 React hydration을 수행한다.

흐름을 단순화하면 다음과 같다.

서버 HTML 수신
-> 브라우저 HTML 파싱
-> CSS 로드 및 적용
-> 첫 페인트
-> JavaScript 로드
-> React hydration
-> useEffect 실행

문제는 useEffect나 클라이언트 상태 기반 로직은 이 흐름의 거의 마지막에 실행된다는 점이다.

예를 들어 다음과 같은 코드는 React 관점에서는 자연스럽지만, 첫 페인트를 제어하지 못한다.

'use client';

export function AppGate({ children }: { children: React.ReactNode }) {
    const [isReady, setIsReady] = useState(false);

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

    if (!isReady) return <Loading />;

    return children;
}

이 방식은 hydration 이후의 상태 전환에는 유효하다. 하지만 서버 HTML에 이미 노출된 요소나, CSS가 적용되기 전 잠깐 보이는 화면까지 막지는 못한다.

자주 발생하는 문제

초기 렌더링 제어가 부족하면 다음과 같은 현상이 나타난다.

  • 스타일이 적용되지 않은 헤더나 레이아웃이 잠깐 보인다.
  • 다크 모드 사용자인데 라이트 모드가 먼저 보였다가 바뀐다.
  • 로그인 여부 확인 전에 보호된 UI가 짧게 노출된다.
  • A/B 테스트 variant가 정해지기 전에 기본 화면이 먼저 보인다.
  • 스플래시나 로딩 화면보다 실제 페이지가 먼저 보인다.

이런 문제는 흔히 FOUC, FOIT, layout flash, hydration flicker 같은 이름으로 불린다. 이름은 조금씩 다르지만 본질은 같다. 브라우저의 첫 페인트 시점과 애플리케이션 상태 결정 시점이 어긋난 것이다.

핵심 원칙

초기 렌더링 제어는 아래 순서로 생각하는 것이 좋다.

  1. 첫 페인트 전에 결정되어야 하는 상태인지 구분한다.
  2. React 상태가 아니라 HTML 속성이나 class로 초기 상태를 표현한다.
  3. 해당 속성을 기준으로 critical CSS를 즉시 적용한다.
  4. hydration 이후 React가 같은 상태를 이어받아 정리한다.
  5. 실패해도 화면이 영원히 잠기지 않도록 fallback을 둔다.

중요한 점은 첫 화면을 막아야 하는 로직을 useEffect에만 의존하지 않는 것이다. useEffect는 이미 페인트가 일어난 뒤 실행될 수 있다.

좋은 제어 지점은 HTML 속성이다

초기 상태는 html 또는 body에 attribute로 남겨두는 방식이 다루기 쉽다.

<html data-app-pending>

이렇게 해두면 CSS는 React 실행 여부와 상관없이 즉시 화면을 제어할 수 있다.

html[data-app-pending] nav,
html[data-app-pending] main {
    opacity: 0;
    pointer-events: none;
}

React가 실행된 뒤에는 필요한 작업을 마치고 이 속성을 제거한다.

useEffect(() => {
    bootstrap().finally(() => {
        document.documentElement.removeAttribute('data-app-pending');
    });
}, []);

이 패턴의 장점은 제어권이 명확하다는 점이다. 브라우저는 HTML 속성을 보고 첫 페인트를 제어하고, React는 이후 같은 속성을 제거하면서 정상 화면으로 넘긴다.

script는 가능한 한 앞에서 실행한다

사용자의 세션, 테마, 이전 방문 여부처럼 브라우저 저장소를 확인해야 하는 값은 서버에서 알 수 없다. 이 경우 짧은 inline script를 문서의 앞쪽에 넣어 초기 attribute를 설정할 수 있다.

const bootScript = `
(function () {
    try {
        if (!sessionStorage.getItem('hasVisited')) {
            document.documentElement.setAttribute('data-app-pending', '');
        }
    } catch (error) {
        document.documentElement.setAttribute('data-app-pending', '');
    }
})();
`;
<html lang="ko">
    <head>
        <script dangerouslySetInnerHTML={{ __html: bootScript }} />
    </head>
    <body>{children}</body>
</html>

여기서 포인트는 React 컴포넌트가 로드되기 전에 실행된다는 것이다. 즉, 클라이언트 번들이 늦게 오더라도 브라우저는 이미 data-app-pending 상태를 알고 있다.

critical CSS를 함께 둔다

초기 제어용 CSS는 일반 스타일 번들에만 의존하지 않는 것이 좋다. CSS 파일이 늦게 적용되면 제어 CSS도 같이 늦어진다.

그래서 정말 첫 페인트 전에 필요한 최소 CSS는 inline style로 둘 수 있다.

const criticalCss = `
html[data-app-pending] nav,
html[data-app-pending] main {
    opacity: 0 !important;
    pointer-events: none !important;
}

.initial-gate {
    display: none !important;
}

html[data-app-pending] .initial-gate {
    display: flex !important;
}
`;
<head>
    <script dangerouslySetInnerHTML={{ __html: bootScript }} />
    <style dangerouslySetInnerHTML={{ __html: criticalCss }} />
</head>

이 CSS는 작아야 한다. 앱 전체 스타일을 inline으로 옮기는 것이 아니라, 첫 페인트에서 보여야 하는 것과 숨겨야 하는 것만 다룬다.

예시: 스플래시 화면

스플래시는 이 패턴을 설명하기 좋은 예시다. 문제는 "스플래시를 어떻게 예쁘게 만들 것인가"가 아니다. 핵심은 스플래시가 뜨기 전에 실제 페이지가 먼저 보이지 않도록 제어하는 것이다.

잘못된 접근은 스플래시를 클라이언트 전용 dynamic import로만 렌더링하는 것이다.

const InitialSplash = dynamic(() => import('@/components/common/InitialSplash'), {
    ssr: false,
    loading: () => null,
});

이렇게 하면 서버 HTML에는 스플래시가 없다. 브라우저는 먼저 헤더와 본문을 그릴 수 있고, 이후 JavaScript chunk가 로드된 뒤 스플래시가 나타난다. 이 짧은 틈에서 스타일이 덜 적용된 헤더가 보일 수 있다.

더 안정적인 구조는 스플래시 DOM을 서버 HTML에도 포함시키고, 표시 여부는 HTML attribute와 critical CSS로 제어하는 방식이다.

const splashBootScript = `
(function () {
    try {
        if (!sessionStorage.getItem('hasShownSplash')) {
            document.documentElement.setAttribute('data-splash-pending', '');
        }
    } catch (error) {
        document.documentElement.setAttribute('data-splash-pending', '');
    }
})();
`;

const splashCriticalCss = `
html[data-splash-pending] nav[aria-label="global-nav"],
html[data-splash-pending] main {
    opacity: 0 !important;
    pointer-events: none !important;
}

.initial-splash {
    display: none !important;
}

html[data-splash-pending] .initial-splash {
    display: flex !important;
}
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
    return (
        <html lang="ko" suppressHydrationWarning>
            <head>
                <script dangerouslySetInnerHTML={{ __html: splashBootScript }} />
                <style dangerouslySetInnerHTML={{ __html: splashCriticalCss }} />
            </head>
            <body>
                <InitialSplash />
                <Header />
                <main>{children}</main>
            </body>
        </html>
    );
}

스플래시 컴포넌트는 animation과 cleanup만 담당한다.

'use client';

export default function InitialSplash() {
    const [shouldShow, setShouldShow] = useState(true);
    const [isVisible, setIsVisible] = useState(true);

    useEffect(() => {
        const hasShownSplash = sessionStorage.getItem('hasShownSplash');

        if (hasShownSplash) {
            document.documentElement.removeAttribute('data-splash-pending');
            setShouldShow(false);
            return;
        }

        sessionStorage.setItem('hasShownSplash', 'true');

        const fadeTimer = setTimeout(() => {
            setIsVisible(false);
        }, 2000);

        const cleanupTimer = setTimeout(() => {
            document.documentElement.removeAttribute('data-splash-pending');
            setShouldShow(false);
        }, 2700);

        return () => {
            clearTimeout(fadeTimer);
            clearTimeout(cleanupTimer);
        };
    }, []);

    if (!shouldShow) return null;

    return (
        <div
            data-splash-active
            className={`initial-splash fixed inset-0 z-[9999] items-center justify-center bg-background transition-opacity duration-500 ${
                isVisible ? 'opacity-100' : 'opacity-0'
            }`}
        >
            <div>Loading...</div>
        </div>
    );
}

이 구조에서 각 레이어의 역할은 분명하다.

  • inline script는 첫 페인트 전에 pending 상태를 만든다.
  • critical CSS는 pending 상태에서 헤더와 본문을 숨긴다.
  • 서버 HTML에 포함된 스플래시 DOM은 JavaScript chunk를 기다리지 않고 존재한다.
  • React 컴포넌트는 애니메이션과 상태 정리를 담당한다.
  • fallback timeout은 transition 이벤트가 누락되어도 pending 상태가 남지 않도록 한다.

언제 이 패턴을 써야 할까

이 패턴은 모든 로딩에 필요한 것은 아니다. 페이지 내부 데이터 로딩이나 리스트 skeleton 정도라면 React 상태와 Suspense로 충분하다.

하지만 다음 조건이면 초기 렌더링 제어를 고려하는 것이 좋다.

  • 첫 페인트 전에 숨겨야 하는 UI가 있다.
  • 테마, 인증, 실험 variant처럼 브라우저별 상태가 화면을 바꾼다.
  • SSR HTML과 hydration 이후 UI가 달라질 수 있다.
  • 잠깐이라도 잘못된 화면이 보이면 사용자 경험이나 보안에 문제가 된다.
  • useEffect로 처리했을 때 화면이 깜빡인다.

반대로 단순히 버튼 클릭 후 로딩, 리스트 필터링, 페이지 내부 fetch 상태처럼 이미 앱이 실행된 뒤의 문제라면 이 정도의 제어는 과하다.

주의할 점

첫 번째로, 화면을 숨기는 CSS에는 반드시 해제 경로가 있어야 한다. 네트워크 오류나 hydration 오류가 발생했을 때 data-app-pending이 영원히 남으면 사용자는 빈 화면만 보게 된다.

두 번째로, inline script와 CSS는 최소화해야 한다. 이 패턴은 첫 페인트를 안정화하기 위한 장치이지, 앱 로직을 head 안으로 옮기는 방식이 아니다.

세 번째로, 서버에서 알 수 있는 상태라면 서버에서 처리하는 편이 더 낫다. 예를 들어 cookie로 테마를 알 수 있다면 서버가 처음부터 올바른 class를 내려주는 것이 가장 안정적이다.

네 번째로, display: noneopacity: 0의 차이를 의식해야 한다. 레이아웃 자리까지 제거해야 하면 display: none을 쓰고, 레이아웃은 유지하되 시각적으로만 숨길 때는 opacity를 쓴다.

결론

초기 렌더링 제어는 React 컴포넌트의 문제가 아니라 브라우저 렌더링 파이프라인의 문제다. React 상태만으로 제어하려고 하면 이미 첫 페인트가 지나간 뒤일 수 있다.

따라서 첫 화면에서 반드시 지켜야 하는 상태는 HTML attribute, inline script, critical CSS로 먼저 고정하고, hydration 이후 React가 그 상태를 이어받아 정리하는 구조가 안정적이다.

스플래시 화면은 이 패턴의 한 가지 예시일 뿐이다. 같은 방식은 테마 초기화, 인증 게이트, 실험 variant, 앱 초기 부트스트랩 화면에도 적용할 수 있다.

마무리

설정 후에는 명령어 결과를 기준으로 정상 동작 여부를 확인하고, 문제가 생기면 단계별로 범위를 좁혀 확인합니다.