SSR 환경에서 플리커링이 발생한 이유
SSR을 적용한 서비스에서도 초기 렌더링 품질은 별도 과제입니다. 저희 프로젝트 역시 SSR을 적극적으로 활용하고 있었지만, 일부 목록 페이지에서는 JavaScript 실행 이후에야 스타일이 확정되면서 첫 렌더링 순간에 플리커링이 발생하고 있었습니다.
하지만 SSR 환경에서는 스타일 적용 시점 때문에 별도의 문제가 생길 수 있습니다. 바로 '플리커링(Flickering)' 현상입니다. 페이지가 처음 로드될 때, 잠시 동안 스타일이 적용되지 않은 콘텐츠가 나타났다가 뒤늦게 올바른 스타일이 적용되는 현상을 말합니다.

이는 사용자에게 웹사이트가 불안정하거나 깨진 것처럼 보이는 인상을 주어 사용자 경험을 크게 저해합니다.
저희 서비스의 여러 목록 페이지에서는 다양한 크기의 카드 컴포넌트들이 반응형으로 배치됩니다. 기존에는 이 카드 컴포넌트들의 크기나 내부 요소 스타일을 JavaScript 로직에 의존하여 동적으로 계산하고 적용하고 있었습니다. 이 방식은 클라이언트 사이드에서 JavaScript 파일이 완전히 다운로드되고 실행되기 전까지는 정확한 스타일을 알 수 없다는 본질적인 한계를 가지고 있었습니다. 결과적으로 SSR된 초기 HTML에는 기본 스타일만 적용되어 있다가, JS 로딩 후 제대로 된 스타일이 적용되면서 시각적인 불일치, 즉 플리커링이 발생했습니다.
이러한 문제를 해결하기 위해, 저희는 컴포넌트의 반응형 처리 로직을 JavaScript에서 SCSS Mixin 기반으로 전면 개편하는 작업을 진행했습니다. 이 과정에서 겪었던 문제점, 해결 방안, 그리고 그 결과를 정리합니다.
해결 전략: JS 런타임에서 CSS 컴파일 타임으로
플리커링의 근본 원인은 반응형 스타일이 클라이언트 JavaScript 실행에 의존하는 구조였습니다. SSR된 HTML에는 JS 실행 전의 미완성 스타일만 존재하고, JS 로딩 후에야 정확한 스타일이 적용되면서 시각적 불일치(FOUC)가 발생합니다.
여기서 중요한 점은 **"JS 개입 없이 SSR 시점에 올바른 반응형 스타일이 적용되도록 하는 것"**입니다. 이를 위해 세 가지 기술을 조합했습니다:
- SCSS Mixin +
@for루프: 브레이크포인트별 미디어 쿼리를 컴파일 타임에 자동 생성하여, JS 없이도 반응형 스타일이 적용되도록 함 - CSS Custom Properties: Mixin에서 계산된 크기 값을
--main-item-size-default같은 CSS 변수로 선언하여, 하위 컴포넌트가 props 없이도 반응형 크기에 대응하도록 함 :deep()Pseudo-class:scopedCSS 내에서 하위 컴포넌트 내부 요소(아이콘, 텍스트 등)에 반응형 스타일을 직접 적용. 컴포넌트 캡슐화를 침해하는 트레이드오프가 있으나, 플리커링 해결을 위해 제한적으로 사용
구현 포인트: JS에서 SCSS로 반응형 로직 전환
이제 실제 코드 변경 전후를 비교하며, 플리커링 문제를 해결하기 위한 구체적인 구현 과정을 확인합니다.
SCSS Mixin에서 반응형 처리
이번 해결책의 핵심인 SCSS Mixin 구조입니다.
// src/assets/scss/_helper-mixins.scss
// 1. 브레이크포인트별로 동적으로 미디어 쿼리 생성
@mixin applyResponsiveSizesMixin($sizes) {
$breakpoints: map-get($sizes, breakpoints); // [0px, 360px, 768px, ...]
@for $i from 1 through length($breakpoints) {
$breakpoint: nth($breakpoints, $i);
$size: nth(map-get($sizes, default), $i);
@include media-query($breakpoint) {
@include applyItemStylesMixin($size);
}
}
}
// 2. 각 크기에 따른 상세 스타일 설정
@mixin applyItemStylesMixin($active-size) {
$heart-icon-size: 24px;
$duration-font-size: 10px;
@if $active-size == 228px {
$heart-icon-size: 32px;
$duration-font-size: 12px;
} @else if $active-size == 160px {
$heart-icon-size: 28px;
$duration-font-size: 12px;
}
// :deep()로 자식 컴포넌트 스타일 적용
:deep(.item-root-element) {
.favorite-icon {
width: $heart-icon-size;
height: $heart-icon-size;
}
.content-duration {
font-size: $duration-font-size;
}
}
}-
applyResponsiveSizesMixin:@for루프를 사용하여 각 브레이크포인트마다 자동으로 미디어 쿼리를 생성합니다. 이를 통해 반복적인 코드 작성 없이 모든 화면 크기에 대응할 수 있습니다. -
applyItemStylesMixin: 각 크기($active-size)에 따라@if조건문으로 아이콘 크기, 폰트 크기 등의 스타일 변수를 설정합니다. -
:deep()셀렉터: Vue의 scoped CSS 경계를 넘어 하위 컴포넌트의 내부 요소에 스타일을 적용합니다. 이를 통해 상위 컨테이너에서 카드 내부의 아이콘, 텍스트 등을 직접 제어할 수 있습니다.
이 세 가지 기술의 조합으로 컴파일 타임에 모든 반응형 스타일이 CSS로 변환되어, SSR 시점에도 JavaScript 없이 즉시 적용됩니다.
JavaScript 기반 스타일 계산 로직 제거 및 SCSS Mixin으로 일원화
이제 위에서 설명한 SCSS Mixin을 실제로 어떻게 적용했는지 Before/After 코드를 비교합니다.
변경 전 (Before)
<!-- src/components/cards/CardComponent.vue (부분) -->
<template>
<div class="media-thumbnail" :style="thumbnailContainerStyles">
<svg-icon-heart
v-if="showFavoriteIcon"
class="favorite-icon"
:size="favoriteIconSize"
/>
<span
class="content-duration"
v-if="props.componentSize > 104"
:style="{ fontSize: durationFontSize }"
>
{{ formattedDuration }}
</span>
</div>
</template>
<script setup lang="ts">
interface ThumbnailProps {
componentSize: number; // componentSize prop을 받음
// ...
}
// componentSize에 따라 동적으로 스타일을 계산하는 computed 속성들
const favoriteIconSize = computed(() => {
if ([228, 212, 196].includes(props.componentSize)) return 32;
if ([160, 140].includes(props.componentSize)) return 28;
return 32;
});
const durationFontSize = computed(() => {
return props.componentSize >= 160 ? "12px" : "10px";
});
const thumbnailContainerStyles = computed(() => {
// componentSize에 따른 padding, width, height 등 계산
});
</script>변경 전 CardComponent는 componentSize prop을 받아 computed 속성에서 아이콘 크기, 폰트 크기 등을 동적으로 계산했습니다. 템플릿의 v-if 조건문도 props.componentSize에 직접 의존하고 있었습니다. 이는 SSR 시 JavaScript 로드 전에는 정확한 스타일을 알 수 없어 플리커링을 유발하는 원인이었습니다.
변경 후 (After)
<!-- src/components/cards/CardComponent.vue (부분) -->
<template>
<div class="thumbnail-default">
<svg-icon-heart v-if="showFavoriteIcon" class="favorite-icon" />
<span class="content-duration" v-if="props.type === 'AUDIO'">
{{ formattedDuration }}
</span>
</div>
</template>
<script setup lang="ts">
interface ThumbnailProps {
// componentSize prop 제거
// ...
}
// computed 속성들 모두 제거됨
</script>
<style lang="scss" scoped>
.thumbnail-default {
// 부모 컨테이너의 CSS Custom Property 사용
width: var(--main-item-size-default);
height: var(--main-item-size-default);
// SCSS Mixin이 모든 반응형 스타일 처리
@include applyThumbnailContainerStyles;
}
</style>변경 후 CardComponent는 componentSize prop과 모든 JavaScript computed 속성을 제거했습니다. 대신 CSS Custom Property(--main-item-size-default)로 크기를 설정하고, applyItemStylesMixin이 SCSS @if 문과 :deep() 셀렉터로 내부 요소들의 반응형 스타일을 제어합니다.
핵심은 컴포넌트가 더 이상 자신의 크기나 내부 스타일을 JavaScript로 계산하지 않고, CSS(SCSS Mixin) 기준으로 동작한다는 점입니다. 이를 통해 SSR 시에도 JS 로딩 없이 즉시 최종 스타일이 적용되어 플리커링을 줄일 수 있었습니다.
리스트 컴포넌트에서 componentSize prop 제거 및 SCSS Mixin 적용
카드 컴포넌트를 사용하는 리스트 컴포넌트들 (GridList.vue, FeaturedSection.vue 등)도 JavaScript 기반의 반응형 로직을 제거하고 SCSS Mixin을 직접 적용하도록 변경했습니다.
변경 전 (Before)
<!-- src/components/list/GridList.vue (부분) -->
<template>
<div class="grid-list">
<ResponsiveMasonryGrid :items="itemList.list">
<template #default="{ item, index }">
<CardWrapper
:content-item="item"
:component-size="calculatedCardSize"
:index="index"
/>
</template>
</ResponsiveMasonryGrid>
</div>
</template>
<script setup lang="ts">
const { currentBreakpointName } = useViewportBreakpoints({
mobile1: 0,
mobile2: 420,
// ...
});
const calculatedCardSize = computed(() => {
switch (currentBreakpointName.value) {
case "mobile1":
return 160;
case "mobile2":
return 126;
default:
return 196;
}
});
</script>
<style lang="scss" scoped>
// 각 브레이크포인트마다 개별 미디어 쿼리 작성
@include media-query(420px) {
.grid-list {
.masonry-image {
grid-template-columns: repeat(3, 126px);
}
}
}
// ... 여러 브레이크포인트에 대한 반복적인 미디어 쿼리
</style>변경 전 GridList는 useViewportBreakpoints 훅으로 calculatedCardSize를 계산하여 하위 컴포넌트에 전달했습니다. 이는 JavaScript에 의존하므로 SSR 시점에 플리커링을 유발했습니다.
변경 후 (After)
<!-- src/components/list/GridList.vue (부분) -->
<template>
<div class="grid-list">
<ResponsiveMasonryGrid :items="itemList.list">
<template #default="{ item, index }">
<CardWrapper :content-item="item" :index="index" />
</template>
</ResponsiveMasonryGrid>
</div>
</template>
<script setup lang="ts">
// useViewportBreakpoints와 calculatedCardSize 제거
// componentSize prop 전달 로직 제거
</script>
<style lang="scss" scoped>
.grid-list {
.list-container {
// SCSS Mixin으로 모든 반응형 스타일 처리
@include applyResponsiveSizesMixin($responsiveSizeMapList);
.masonry-image {
grid-template-columns: repeat(3, 1fr);
}
}
}
// 반복적인 미디어 쿼리 블록 모두 제거됨
</style>변경 후 GridList는 JavaScript 로직을 완전히 제거하고, applyResponsiveSizesMixin으로 모든 반응형 스타일을 SCSS에서 처리합니다. componentSize prop 전달도 불필요해져 컴포넌트 간 결합도가 낮아지고, 반응형 로직이 중앙 집중화되어 유지보수가 용이해졌습니다.
결과 및 효과: 플리커링 없는 SSR, 더 나은 사용자 경험
이러한 전면적인 개편을 통해 우리 서비스는 다음과 같은 변화를 확인했습니다.
- 플리커링 현상 제거: 가장 큰 성과입니다. 서버사이드 렌더링 시 JavaScript가 로드되기 전에도 SCSS Mixin에 의해 모든 반응형 스타일이 즉시 적용되므로, 사용자는 더 이상 스타일이 깨진 화면을 보지 않고 일관된 UI를 경험하게 되었습니다. Lighthouse 기준 CLS가 0.15~0.25에서 0.05 이하로 대폭 개선되었는데, 레이아웃이 뒤바뀌던 현상 자체가 사라진 결과입니다.
- 초기 로딩 성능 개선: 스타일 계산을 위한 JS 실행이 빠지면서 렌더링 파이프라인이 단순해졌습니다. FCP는 2초 전후에서 1.2~1.5초로, LCP는 3초대에서 2초대 초반으로 단축되었습니다. computed 속성과 breakpoint 훅 제거로 JS 번들도 3~5KB 줄었는데, 수치 자체보다 불필요한 런타임 로직이 사라졌다는 점이 의미 있습니다.
- 코드 유지보수성 및 확장성 향상: 반응형 로직이 SCSS Mixin으로 중앙 집중화되면서, 새로운 브레이크포인트 추가나 스타일 변경이 훨씬 용이해졌습니다. 각 컴포넌트에서 중복되던 JavaScript 로직이 사라져 코드 베이스가 간결해지고, 오류 발생 가능성도 줄어들었습니다.
- 관심사 분리 및 컴포넌트 가독성 증대: Vue 컴포넌트들은 이제 순수하게 데이터와 UI 상호작용에만 집중하고, 스타일링에 대한 책임은 SCSS가 전담하게 되었습니다. 이는 컴포넌트의 가독성을 높이고 테스트 용이성을 향상시키는 효과를 가져왔습니다.
- 디자인 시스템 유연성 강화: SCSS Mixin과 CSS Custom Properties의 조합은 더욱 유연한 디자인 시스템 구축의 기반을 마련했습니다. 필요에 따라 전역 또는 지역적으로 스타일 변수를 쉽게 관리하고 변경할 수 있게 되었습니다.
물론 이 과정에서 :deep() 셀렉터의 사용과 같이 컴포넌트 캡슐화를 다소 침해하는 부분도 존재합니다. 하지만 이는 SSR 환경에서의 플리커링이라는 핵심 문제를 해결하기 위한 불가피한 선택이었으며, 가능한 한 제한적으로 사용하여 잠재적인 부작용을 최소화했습니다.
핵심은 반응형 로직의 실행 시점을 JS 런타임에서 CSS 컴파일 타임으로 옮긴 것입니다. SSR 환경에서 플리커링 문제가 발생한다면, 해당 스타일이 JS에 의존하고 있는지 먼저 확인하는 것이 효과적입니다.
참고 자료
SSR 관련
SCSS Mixins
CSS Custom Properties
Related Posts

서비스 프론트엔드 성능 최적화 탐구: Nuxt.js 3와 캐싱 전략의 현실
Nuxt.js 3 기반 서비스에서 SWR, Redis, In-Memory 캐싱 등 다양한 성능 최적화 전략을 탐구하고, 실제 인프라 지표를 분석하며 '지금 당장'보다 '적절한 시점'의 중요성을 도출한 결론을 정리합니다.

Infinite Scroll 최적화: DOM 재사용으로 대용량 리스트 처리하기
Intersection Observer와 DOM 재사용(버퍼링) 전략으로 무한 스크롤에서 발생하는 DOM 누적 문제를 줄이고, 대용량 리스트를 안정적으로 처리한 구현 과정을 정리합니다.

React Query의 Hydrate란?
Next.js SSR 환경에서 React Query(TanStack Query)의 `Hydrate`/`dehydrate`를 활용해 서버에서 미리 받아온 데이터를 클라이언트 캐시에 주입하고, 중복 API 호출을 줄이는 방법을 정리합니다.