프런트엔드 프로젝트 초기에는 모든 것이 순조롭습니다. 하지만 컴포넌트가 수십 개로 늘어나고 비즈니스 요구사항이 복잡해지면, 하나의 컴포넌트 파일이 500줄을 넘어가기 시작합니다. 그 안에는 API 호출, 데이터 가공, 상태 관리, 그리고 UI 렌더링 로직이 뒤섞여 있어 작은 수정 하나에도 어디가 고장 날지 모르는 '스파게티 코드'가 되어버리곤 합니다.

이를 해결하기 위해 백엔드에서 주로 쓰이던 '클린 아키텍처(Clean Architecture)' 개념을 프런트엔드에 도입해야 합니다. 핵심은 하나입니다. "UI(어떻게 보여줄 것인가)와 도메인 로직(무엇을 할 것인가)을 철저히 분리하는 것"입니다.


1. 왜 프론트엔드에도 아키텍처가 필요한가?

프런트엔드 기술 스택은 매우 빠르게 변합니다. 리액트 버전이 올라가고, 스타일 라이브러리를 Tailwind에서 다른 것으로 바꾸거나, 상태 관리 도구를 교체해야 할 상황이 올 수 있습니다.

만약 비즈니스 로직(예: 할인율 계산, 장바구니 로직 등)이 특정 UI 컴포넌트나 라이브러리에 강하게 결합되어 있다면, 기술 스택을 바꿀 때 비즈니스 로직까지 모두 새로 짜야합니다. 클린 아키텍처는 기술적인 세부 사항(UI, 라이브러리)이 비즈니스 핵심 로직을 오염시키지 않도록 방어벽을 세우는 작업입니다.


2. 프론트엔드 레이어링: 3단계 계층 구조

프런트엔드 환경에 맞게 레이어를 크게 세 가지로 나눌 수 있습니다.

2.1 도메인 레이어 (Domain Layer / Entities)

가장 핵심이 되는 비즈니스 규칙과 데이터 모델이 위치합니다. 이 레이어는 리액트(React)나 특정 프레임워크에 전혀 의존하지 않는 순수 자바스크립트/타입스크립트 코드여야 합니다.

  • 예: calculateDiscount(price, coupon), validateEmail(email) 등

2.2 서비스/애플리케이션 레이어 (Service / Use Cases)

도메인 로직을 조합하여 실제 사용자 시나리오를 구현합니다. API를 호출하고 데이터를 도메인 모델로 변환하는 등의 역할을 수행합니다. 주로 Custom Hooks가 이 역할을 담당하게 됩니다.

  • 예: useCart, useAuth 등

2.3 프레젠테이션 레이어 (Presentation Layer / UI)

사용자에게 화면을 보여주고 입력을 받는 역할만 합니다. 로직은 최대한 배제하고, 서비스 레이어에서 제공하는 데이터와 함수를 연결하기만 합니다.

  • 예: Button, CartList, UserProfile 등

3. 실전 적용: 컴포넌트에서 로직 추출하기

배송비 계산 로직이 들어있는 장바구니 컴포넌트를 예로 들어보겠습니다.

3.1 Bad: 로직과 UI가 뒤섞인 경우

JavaScript
 
// 모든 로직이 컴포넌트 안에 있어 재사용과 테스트가 어려움
function Cart({ items }) {
  const totalPrice = items.reduce((acc, item) => acc + item.price, 0);
  const deliveryFee = totalPrice > 50000 ? 0 : 3000;

  return (
    <div>
      <p>총 합계: {totalPrice + deliveryFee}원</p>
    </div>
  );
}

3.2 Good: 도메인 로직과 서비스 레이어 분리

JavaScript
 
// 1. 도메인 로직 (Pure JS)
// /domain/cart.ts
export const calculateDeliveryFee = (totalPrice) => totalPrice > 50000 ? 0 : 3000;

// 2. 서비스 레이어 (Custom Hook)
// /services/useCart.ts
export function useCart(items) {
  const totalPrice = items.reduce((acc, item) => acc + item.price, 0);
  const deliveryFee = calculateDeliveryFee(totalPrice);
  return { totalPrice, deliveryFee, finalPrice: totalPrice + deliveryFee };
}

// 3. 프레젠테이션 레이어 (UI)
function Cart({ items }) {
  const { finalPrice } = useCart(items);
  return <p>총 합계: {finalPrice}원</p>;
}

이렇게 분리하면 calculateDeliveryFee는 UI 없이도 단독으로 유닛 테스트를 수행할 수 있고, 나중에 리액트가 아닌 다른 환경에서도 그대로 사용할 수 있습니다.


4. 클린 아키텍처 도입의 이점

  1. 유닛 테스트의 용이성: 복잡한 UI 렌더링을 신경 쓰지 않고 순수 함수 형태의 비즈니스 로직만 따로 떼어 테스트할 수 있습니다.
  2. 높은 가독성: 컴포넌트 파일만 봐도 이 화면이 어떤 구조인지 한눈에 들어옵니다. 세부 로직은 훅이나 도메인 파일에 숨겨져 있기 때문입니다.
  3. 유지보수성 향상: "배송비 정책이 바뀌었다"면 도메인 파일만 수정하면 됩니다. UI를 건드릴 필요가 없습니다.
  4. 팀 협업: UI 개발자와 로직 개발자가 역할을 나누어 동시에 작업하기 수월해집니다.

5. 주의할 점: 과유불급(過猶不及)

모든 곳에 클린 아키텍처를 적용할 필요는 없습니다.

  • 단순히 값을 보여주기만 하는 페이지나, 일회성 이벤트 페이지에 이런 구조를 도입하는 것은 과도한 엔지니어링(Over-engineering) 일 수 있습니다.
  • 프로젝트의 비즈니스 복잡도가 높고, 장기적으로 유지보수해야 하는 핵심 서비스(예: 쇼핑몰 결제, 대시보드 등)부터 단계적으로 적용하는 것을 권장합니다.

리액트(React)를 사용하여 데이터를 불러오는 컴포넌트를 만들 때, 우리는 보통 다음과 같은 코드를 작성하곤 합니다.

JavaScript
 
if (isLoading) return <LoadingSpinner />;
if (isError) return <ErrorMessage />;
return <DataView data={data} />;

익숙한 코드지만, 이 방식은 '명령형(Imperative)'에 가깝습니다. 컴포넌트 하나가 비즈니스 로직뿐만 아니라 로딩 처리, 에러 처리라는 세 가지 책임을 모두 떠안게 되기 때문입니다. 프로젝트가 커질수록 모든 컴포넌트에 이런 중복 코드가 들어가게 되고, UI의 일관성은 떨어집니다.

오늘은 리액트가 지향하는 '선언적 UI'의 완성형인 Suspense와 ErrorBoundary를 활용해, 비정상적인 상태(로딩, 에러)를 컴포넌트 밖으로 우아하게 밀어내는 전략을 알아보겠습니다.


1. 명령형 UI vs 선언적 UI

1.1 무엇이 문제인가?

명령형 방식에서는 개발자가 "로딩 중이면 스피너를 보여주고, 아니면 데이터를 보여줘"라고 일일이 로직을 지시해야 합니다. 이는 컴포넌트를 무겁게 만들고, 테스트를 어렵게 하며, 코드의 흐름을 방해합니다.

1.2 선언적 방식의 지향점

선언적 방식은 "이 컴포넌트는 데이터가 있을 때 이렇게 그려져야 해"라고 정의만 합니다. 데이터가 아직 준비되지 않았거나(Loading), 가져오는 데 실패했을 때(Error)의 처리는 컴포넌트를 감싸고 있는 '부모(상위 환경)'에게 맡깁니다.


2. Suspense: 데이터 로딩을 기다리는 우아한 방법

Suspense는 하위 컴포넌트가 무언가를 '기다리고 있다'는 것을 리액트에게 알리는 도구입니다.

2.1 주요 특징

  • 관심사 분리: 컴포넌트 내부에서 isLoading을 체크할 필요가 없습니다.
  • 로딩 UI의 중앙 집중화: 여러 개의 컴포넌트가 로딩 중일 때 하나의 스켈레톤 UI로 묶어서 보여줄 수 있습니다.
JavaScript
 
import { Suspense } from 'react';

function App() {
  return (
    <Layout>
      <Suspense fallback={<SkeletonUI />}>
        <UserProfile /> {/* 내부에서 데이터 패칭 중이라면 fallback이 보임 */}
      </Suspense>
    </Layout>
  );
}

이렇게 작성하면 UserProfile은 데이터가 성공적으로 도착했을 때의 로직만 담으면 됩니다. 데이터가 비어있을 때 발생하는 런타임 에러 걱정에서도 자유로워집니다.


3. ErrorBoundary: 애플리케이션의 안전망

에러 바운더리는 하위 컴포넌트 트리 어디에서든 자바스크립트 에러가 발생했을 때, 앱 전체가 하얗게 변하는(White Screen) 것을 방지하고 대신 폴백(Fallback) UI를 보여주는 컴포넌트입니다.

3.1 왜 필요한가?

리액트에서 렌더링 중 에러가 발생하면 전체 컴포넌트 트리가 언마운트됩니다. 사용자 입장에서는 서비스가 갑자기 꺼지는 것과 같습니다. ErrorBoundary를 사용하면 특정 영역의 에러가 전체 앱으로 전파되는 것을 막을 수 있습니다.

3.2 선언적 에러 처리 예시

(참고: 현재 에러 바운더리는 클래스 컴포넌트로만 작성이 가능하거나, react-error-boundary 라이브러리를 사용합니다.)

JavaScript
 
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>문제가 발생했습니다.</p>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>다시 시도</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => {/* 상태 초기화 */}}>
      <Suspense fallback={<Loading />}>
        <DataDependentComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

4. 실무 패턴: 선언적 UI의 중첩 구조

실무에서는 보통 이 두 가지를 겹쳐서 사용합니다. 이를 통해 '로딩 -> 에러 -> 데이터 성공'이라는 데이터 패칭의 3단계를 완벽하게 제어할 수 있습니다.

  1. 가장 바깥쪽: ErrorBoundary (치명적인 에러 방어)
  2. 중간: Suspense (비동기 데이터 로딩 대기)
  3. 안쪽: 실제 비즈니스 로직 컴포넌트

이 구조를 사용하면 개발자는 안쪽 컴포넌트에서 데이터가 '무조건 존재한다'고 가정하고 코드를 짤 수 있습니다. 이를 'Suspense-enabled Data Fetching'이라고 부르며, React Query(TanStack Query)와 함께 쓸 때 가장 강력한 시너지를 냅니다.


5. 선언적 UI 도입의 장점

  • 가독성 폭발: 컴포넌트 본연의 목적(UI 정의)에만 집중할 수 있어 코드가 짧아지고 명확해집니다.
  • UX 개선: 로딩과 에러 화면을 디자이너가 의도한 대로 일관성 있게 보여줄 수 있습니다.
  • 유지보수 용이: 로딩 스피너 디자인을 바꾸고 싶다면, 수백 개의 컴포넌트를 수정하는 대신 상위의 Suspense fallback만 고치면 됩니다.

리액트(React) 프로젝트의 규모가 커지면 필연적으로 마주하게 되는 현상이 있습니다. 바로 Props Drilling입니다. 최상위 컴포넌트에 있는 데이터를 5단계 아래에 있는 자식 컴포넌트로 전달하기 위해, 중간에 있는 컴포넌트들이 그 데이터를 사용하지 않음에도 불구하고 단순히 '전달'만 하는 고통스러운 상황을 말합니다.

Props Drilling 그 자체는 반드시 나쁜 것은 아니지만, 깊이가 깊어질수록 코드를 추적하기 어렵게 만들고 리팩토링을 불가능하게 만드는 주범이 됩니다. 오늘은 이 문제를 해결하기 위한 세 가지 전략을 단계별로 알아보겠습니다.


1. 첫 번째 단계: 컴포넌트 합성 (Component Composition)

많은 개발자가 Props Drilling을 발견하면 즉시 Context API나 상태 관리 라이브러리를 떠올립니다. 하지만 리액트 팀에서 권장하는 가장 첫 번째 해결책은 컴포넌트 합성입니다.

1.1 children을 활용한 구조 개선

데이터를 하위로 계속 내려보내는 대신, 컴포넌트를 조립하는 방식으로 구조를 변경해 보세요.

JavaScript
 
// 기존 방식 (Drilling 발생)
<Parent data={data}>
  <Intermediate data={data}>
    <Child data={data} />
  </Intermediate>
</Parent>

// 컴포넌트 합성 방식
<Parent>
  <Intermediate>
    <Child data={data} />
  </Intermediate>
</Parent>

이렇게 하면 Intermediate 컴포넌트는 data를 알 필요가 없습니다. 단순히 children으로 받은 내용을 렌더링 하기만 하면 됩니다. 데이터를 직접 사용하는 컴포넌트를 부모 수준으로 끌어올림으로써 의존성을 끊어내는 것이 핵심입니다.


2. 두 번째 단계: Context API (의존성 주입)

합성만으로 해결하기에 구조가 너무 복잡하거나, 앱 전체에서 공유되어야 하는 데이터(테마, 로그인 유저 정보 등)가 있다면 리액트 내장 기능인 Context API를 사용합니다.

2.1 Context API의 목적

Context는 '전역 상태 관리' 도구라기보다 '의존성 주입(Dependency Injection)' 도구에 가깝습니다. 특정 범위(Provider) 내에 있는 모든 컴포넌트가 계층 구조와 상관없이 데이터에 접근할 수 있게 해줍니다.

2.2 주의할 점: 리렌더링 최적화

Context의 값이 바뀌면 해당 Provider를 구독하고 있는 모든 하위 컴포넌트가 리렌더링 됩니다. 따라서 자주 바뀌는 동적인 데이터보다는 변경 빈도가 낮은 설정 데이터를 관리하는 데 적합합니다.

JavaScript
 
const UserContext = createContext();

function App() {
  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  );
}

function UserProfile() {
  const user = useContext(UserContext); // 중간 단계 생략하고 바로 사용
  return <div>{user.name}</div>;
}

3. 세 번째 단계: 외부 상태 관리 라이브러리 (Zustand)

복잡하고 빈번하게 변하는 상태(장바구니, 필터링 조건 등)를 관리해야 한다면 전용 라이브러리의 힘을 빌려야 합니다. 과거에는 Redux가 대세였지만, 최근에는 설정이 간편하고 성능이 뛰어난 Zustand가 각광받고 있습니다.

3.1 왜 Zustand인가?

  • 보일러플레이트 제로: Redux처럼 복잡한 초기 설정이 필요 없습니다.
  • 선택적 구독 (Selector): 컴포넌트가 필요한 상태만 선택해서 구독할 수 있어, 불필요한 리렌더링이 발생하지 않습니다.
  • 리액트 외부에서도 접근 가능: 훅(Hook) 형태뿐만 아니라 일반 JS 로직에서도 상태를 읽고 쓸 수 있습니다.

3.2 실전 예시

JavaScript
 
import { create } from 'zustand';

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));

function AddButton({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>담기</button>;
}

4. 어떤 상황에서 무엇을 선택해야 할까?

가장 중요한 것은 상황에 맞는 도구를 선택하는 선구안입니다.

  1. 컴포넌트 합성: Props 전달 단계가 2~3단계 내외이며, UI 구조를 변경함으로써 해결 가능한 경우. (가장 깔끔한 코드 유지 가능)
  2. Context API: 다크 모드, 현재 언어(i18n), 로그인 정보처럼 앱 전체가 알아야 하지만 자주 바뀌지 않는 정보를 다룰 때.
  3. Zustand: 여러 컴포넌트가 복잡한 상태를 서로 공유하고, 업데이트가 매우 빈번하며, 성능 최적화가 필수적인 경우.

프런트엔드 개발자라면 누구나 한 번쯤 겪어봤을 상황이 있습니다. "복잡한 JSON 데이터를 필터링하거나, 큰 이미지 파일을 처리할 때 화면이 잠시 멈춘다"는 사용자 불만 말이죠. 리액트나 뷰(Vue)를 아무리 잘 다뤄도, 메인 스레드(Main Thread)가 막히면 사용자에게는 '고장 난 사이트'로 보일 뿐입니다.

자바스크립트의 실행 환경인 브라우저는 기본적으로 싱글 스레드(Single-threaded)입니다. 즉, 한 번에 하나의 작업만 처리할 수 있습니다. 메인 스레드가 무거운 연산에 잡혀 있으면 화면을 그리는 일(렌더링)을 하지 못하게 되고, 이것이 바로 '화면 프리징'의 원인입니다.

오늘 소개할 Web Worker(웹 워커)는 브라우저의 이 한계를 돌파하는 강력한 도구입니다. 메인 스레드와 별개의 '백그라운드 스레드'에서 작업을 처리하여, 복잡한 연산 중에도 화면은 매끄럽게 움직이게 만들 수 있습니다.


1. 메인 스레드와 워커 스레드의 이해

웹 워커의 핵심은 '분리'입니다. 브라우저는 하나의 메인 스레드와 여러 개의 워커 스레드를 가질 수 있습니다. 메인 스레드는 UI를 그리고, 사용자의 입력을 받고, 네트워크 통신을 관리합니다. 반면, 워커 스레드는 순수하게 '계산'만 담당합니다.

1.1 왜 분리해야 하는가?

메인 스레드에서 for문을 1억 번 돌린다면? 브라우저는 그 1억 번의 루프가 끝날 때까지 아무것도 하지 못합니다. 버튼을 눌러도 반응이 없고, 애니메이션도 멈춥니다. 하지만 이 작업을 워커 스레드로 넘기면, 워커가 연산하는 동안 메인 스레드는 유유히 사용자의 마우스 클릭을 처리하고 애니메이션을 그릴 수 있습니다.


2. Web Worker 구현하기: 3단계 프로세스

웹 워커는 메인 스레드와 직접적인 메모리 공유를 하지 않습니다. 대신 '메시지(Message)'를 주고받는 방식으로 데이터를 전달합니다.

2.1 워커 파일 생성 (worker.js)

먼저, 별도의 파일에 무거운 작업 내용을 작성합니다.

JavaScript
 
// worker.js
self.onmessage = function(e) {
  const data = e.data;
  
  // 여기서 아주 무거운 연산 수행
  const result = heavyCalculation(data);
  
  // 결과를 메인 스레드로 반환
  self.postMessage(result);
};

function heavyCalculation(data) {
  // 예: 수만 개의 데이터를 처리하는 복잡한 알고리즘
  return data.map(item => item * 2);
}

2.2 메인 스레드에서 워커 호출하기

이제 메인 스레드에서 이 워커를 실행하고 메시지를 보냅니다.

JavaScript
 
// main.js
const worker = new Worker('worker.js');

// 연산 시작
worker.postMessage([1, 2, 3, 4, 5]);

// 결과 받기
worker.onmessage = function(e) {
  console.log('연산 결과:', e.data);
};

이 간단한 코드로 복잡한 로직을 메인 스레드 밖으로 안전하게 격리할 수 있습니다.


3. 웹 워커가 가지는 제약 사항과 주의점

웹 워커는 매우 강력하지만, 모든 곳에 사용할 수는 없습니다. 반드시 알아야 할 한계점이 존재합니다.

  1. DOM 접근 불가: 웹 워커는 DOM을 전혀 조작할 수 없습니다. document.querySelector나 window에 접근하려고 하면 에러가 발생합니다. 오직 연산(CPU 집약적 작업)만 가능합니다.
  2. 메모리 격리: 메인 스레드와 메모리를 공유하지 않습니다. 데이터를 전달할 때마다 데이터를 '복사'해서 보냅니다. (아주 큰 데이터를 주고받을 경우 복사 비용이 발생할 수 있음)
  3. 파일 경로 제약: 워커 파일은 반드시 메인 스레드와 동일한 오리진(Origin)에 있어야 합니다. (CORS 정책 적용)

4. 실전 활용 사례: 언제 도입해야 할까?

모든 함수를 웹 워커로 옮기는 것은 낭비입니다. 다음 상황일 때 도입을 강력히 권장합니다.

  • 이미지/비디오 처리: Canvas API를 활용한 필터 적용이나 압축 작업.
  • 복잡한 데이터 정제: 수만 개의 JSON 데이터를 필터링하거나 변환해야 하는 대시보드.
  • 암호화/복호화: JWT 토큰 암호화나 파일 암호화 작업 등 CPU를 많이 쓰는 작업.
  • 실시간 텍스트 분석: 사용자가 입력하는 텍스트의 맞춤법 검사나 복잡한 정규식 검색.

5. 생산성을 높이는 도구: Comlink

순수하게 웹 워커를 다루면 postMessage와 onmessage를 매번 작성해야 해서 코드가 다소 지저분해집니다. 이때 구글에서 만든 Comlink 라이브러리를 사용하면 마치 일반 함수를 호출하듯 웹 워커를 사용할 수 있습니다.

JavaScript
 
// worker.js
import * as Comlink from 'comlink';

const obj = {
  doWork(a, b) { return a + b; }
};

Comlink.expose(obj);

// main.js
import * as Comlink from 'comlink';

async function init() {
  const worker = new Worker('worker.js');
  const api = Comlink.wrap(worker);
  
  // 그냥 함수처럼 호출하면 끝!
  const result = await api.doWork(10, 20);
  console.log(result);
}

이렇게 하면 복잡한 메시지 핸들링 로직을 추상화하여 코드의 가독성을 크게 높일 수 있습니다.

웹사이트에 접속했을 때, 처음에는 기본 폰트로 텍스트가 보이다가 갑자기 예쁜 웹 폰트로 바뀌면서 줄 바꿈이 변하거나 문단이 아래로 툭 떨어지는 현상을 겪어보신 적이 있나요?

사용자를 당황하게 만드는 이 현상을 FOUT(Flash of Unstyled Text)라고 하며, 이는 구글의 핵심 성능 지표 중 하나인 CLS(Cumulative Layout Shift) 점수를 크게 갉아먹는 주범입니다. 오늘은 웹 폰트 로딩 최적화를 통해 시각적 안정성을 확보하는 실무 전략을 깊이 있게 알아보겠습니다.


1. CLS의 적, 웹 폰트 로딩의 이해

웹 폰트는 용량이 크기 때문에 브라우저가 HTML과 CSS를 파싱하는 속도보다 늦게 다운로드되는 경우가 많습니다. 이때 브라우저는 두 가지 방식 중 하나를 선택합니다.

  1. FOIT (Flash of Invisible Text): 폰트가 다운로드될 때까지 텍스트를 아예 보여주지 않음. (사용자는 빈 화면을 보게 됨)
  2. FOUT (Flash of Unstyled Text): 시스템 기본 폰트로 먼저 보여준 뒤, 웹 폰트가 로드되면 교체함. (이 과정에서 레이아웃이 어긋남)

구글은 사용자 경험을 위해 텍스트가 아예 안 보이는 것보다 레이아웃이 조금 흔들리더라도 빨리 보이는 것을 권장하지만, 이 '흔들림'을 최소화하는 것이 프론트엔드 개발자의 실력입니다.


2. font-display 속성으로 로딩 제어하기

가장 쉽고 필수적인 해결책은 CSS의 @font-face에서 font-display 속성을 사용하는 것입니다.

CSS
 
@font-face {
  font-family: 'Pretendard';
  src: url('/fonts/Pretendard.woff2') format('woff2');
  font-display: swap; /* 핵심 설정 */
}

2.1 주요 옵션 차이점

  • block: 폰트가 로드될 때까지 텍스트를 숨깁니다(최대 3초).
  • swap (권장): 시스템 폰트로 즉시 텍스트를 보여주고, 웹 폰트 로드가 완료되면 교체합니다. LCP 점수 유지에 유리합니다.
  • fallback: 아주 짧은 시간(약 0.1초) 동안 텍스트를 숨긴 후 로드되지 않으면 기본 폰트를 보여줍니다.
  • optional: 네트워크 상태에 따라 웹 폰트를 쓸지 말지 브라우저가 결정합니다. 성능이 최우선일 때 사용합니다.

3. 가상 폰트(Fallback Font) 최적화: 사이즈 매칭

font-display: swap을 사용하더라도 기본 폰트와 웹 폰트의 글자 크기, 자간, 높이가 다르면 여전히 레이아웃 시프트가 발생합니다. 이를 해결하기 위해 최근에는 가상 폰트(Size Adjust) 기법을 사용합니다.

3.1 size-adjust와 ascent-override

웹 폰트와 최대한 비슷한 부피를 가지도록 시스템 폰트의 수치를 강제로 조정하는 방식입니다.

CSS
 
/* 기본 폰트(Arial)를 웹 폰트와 크기가 비슷하게 조정 */
@font-face {
  font-family: 'Pretendard-Fallback';
  src: local('Arial');
  size-adjust: 95%;        /* 전체 크기 조정 */
  ascent-override: 90%;    /* 글자 윗부분 높이 조정 */
  descent-override: 10%;   /* 글자 아랫부분 높이 조정 */
}

body {
  font-family: 'Pretendard', 'Pretendard-Fallback', sans-serif;
}

이렇게 하면 폰트가 교체될 때 텍스트의 부피 변화가 거의 없어 CLS 점수를 0에 가깝게 유지할 수 있습니다. 최근에는 Capsize 같은 도구를 통해 이 수치를 정교하게 계산할 수 있습니다.


4. Next.js를 사용한다면? next/font 활용

Next.js (v13+) 환경에서 개발 중이라면 위 복잡한 과정이 대폭 자동화됩니다. next/font는 빌드 시점에 폰트를 최적화하고 가상 폰트를 자동으로 생성해 줍니다.

JavaScript
 
// app/layout.tsx
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  adjustFontFallback: true, // 가상 폰트 자동 생성 옵션
});

export default function RootLayout({ children }) {
  return (
    <html lang="ko" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

next/font를 사용하면 폰트 파일을 구글 서버가 아닌 우리 서버(Self-hosting)에서 직접 제공하므로 개인정보 보호와 성능 면에서 모두 유리합니다.


5. 실무 체크리스트: 웹 폰트 100% 활용법

  1. 포맷 확인: 가장 압축률이 좋은 woff2 포맷을 우선적으로 사용하세요.
  2. Subset 활용: 한글 폰트는 용량이 매우 큽니다. 자주 쓰는 2,350자만 추린 '서브셋(Subset)' 폰트를 사용해 용량을 80% 이상 줄이세요.
  3. Preload: 중요한 폰트는 <link rel="preload">를 통해 미리 로드하세요.
  4. HTML
     
    <link rel="preload" href="/fonts/main-font.woff2" as="font" type="font/woff2" crossorigin>
    
  5. 로컬 폰트 활용: 사용자의 기기에 이미 설치된 폰트가 있다면 그것을 우선 사용하게 설정하세요. (src: local('Pretendard'), url(...))

웹사이트의 첫인상을 결정짓는 가장 중요한 요소 중 하나는 '로딩 속도'입니다. 구글은 이를 정량적으로 측정하기 위해 Core Web Vitals라는 지표를 도입했으며, 그중에서도 가장 큰 비중을 차지하는 것이 바로 LCP(Largest Contentful Paint)입니다.

LCP는 페이지에서 가장 큰 콘텐츠(보통 히어로 이미지나 큰 텍스트 블록)가 화면에 렌더링 되는 시간을 의미합니다. 놀랍게도 대부분의 웹사이트에서 LCP 점수를 갉아먹는 주범은 바로 '최적화되지 않은 이미지'입니다. 오늘은 Next.js가 제공하는 강력한 도구인 next/image를 활용하여 LCP 점수를 획기적으로 개선하는 전략을 심층 분석해 보겠습니다.


1. 왜 일반 <img> 태그보다 next/image인가?

전통적인 <img> 태그는 단순히 이미지를 불러올 뿐, 성능 최적화에 대한 어떠한 책임도 지지 않습니다. 반면 Next.js의 Image 컴포넌트는 다음과 같은 작업을 자동으로 수행합니다.

  1. 이미지 포맷 최적화: 브라우저가 지원한다면 WebP나 AVIF 같이 용량이 훨씬 작은 최신 포맷으로 자동 변환합니다.
  2. 리사이징 (Resizing): 디바이스 크기에 맞춰 최적화된 크기의 이미지를 생성하여 전송합니다. 4K 이미지를 모바일 사용자에게 전송하는 낭비를 막아줍니다.
  3. Lazy Loading: 화면에 보이지 않는 이미지는 로딩을 뒤로 미뤄 초기 로딩 속도를 높입니다.
  4. Placeholder 제공: 이미지가 로드되기 전 저해상도 이미지나 블러(Blur) 효과를 보여주어 사용자 경험을 개선합니다.

2. LCP 개선의 핵심: priority 속성

LCP 점수를 높이는 가장 쉽고 강력한 방법은 '가장 중요한 이미지'를 먼저 로드하는 것입니다.

보통 페이지 상단의 히어로 이미지나 배너가 LCP 요소가 됩니다. next/image는 기본적으로 레이지 로딩(Lazy Loading)이 적용되는데, LCP 요소에 레이지 로딩이 걸려 있으면 오히려 렌더링이 늦어져 LCP 점수가 나빠집니다.

JavaScript
 
// LCP 대상이 되는 히어로 이미지 예시
import Image from 'next/image';

export default function Hero() {
  return (
    <section>
      <Image
        src="/hero-banner.png"
        alt="메인 배너 이미지"
        width={1200}
        height={600}
        priority // 이 속성이 LCP 개선의 핵심입니다!
        className="object-cover"
      />
    </section>
  );
}

priority 속성을 추가하면 Next.js는 해당 이미지를 높은 우선순위(Fetch Priority)로 처리하고 프리로드(Preload) 태그를 삽입합니다. 이는 브라우저가 HTML을 파싱 할 때 해당 이미지를 가장 먼저 다운로드하도록 유도하여 LCP 시간을 단축시킵니다.


3. 레이아웃 시프트 방지와 sizes 속성 활용

이미지 로딩 중 화면이 덜컥거리는 현상인 CLS(Cumulative Layout Shift) 역시 성능 점수에 큰 영향을 줍니다. 이를 방지하려면 이미지의 비율을 미리 브라우저에 알려줘야 합니다.

3.1 정적 이미지와 고정 비율

width와 height를 명시하면 Next.js는 이미지 비율에 맞춰 공간을 미리 확보합니다. 만약 반응형 이미지를 구현해야 한다면 sizes 속성을 반드시 사용해야 합니다.

3.2 sizes 속성 최적화

sizes는 브라우저에게 "이 이미지는 뷰포트 크기에 따라 이 정도 너비를 차지할 거야"라고 미리 알려주는 힌트입니다.

JavaScript
 
<Image
  src="/product.jpg"
  alt="상품 이미지"
  fill
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>

위 설정은 모바일에서는 화면 꽉 차게(100vw), 태블릿에서는 절반(50vw), 데스크탑에서는 1/3(33vw) 크기의 이미지만 요청하도록 만듭니다. 불필요하게 큰 이미지를 다운로드하지 않게 되어 로딩 속도가 비약적으로 향상됩니다.


4. 이미지 포맷과 퀄리티 조정 (next.config.js)

더 극단적인 최적화를 원한다면 설정 파일을 통해 이미지 포맷을 제어할 수 있습니다. WebP보다 압축률이 높은 AVIF를 지원하도록 설정해 보세요.

JavaScript
 
// next.config.js
module.exports = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
};

또한, 기본 퀄리티(75)를 프로젝트 성격에 맞춰 조정하여 용량과 화질 사이의 균형을 맞출 수 있습니다.


5. 실무 트러블슈팅: 외부 도메인 이미지 처리

많은 실무 프로젝트(예: CMS, 쇼핑몰 가비아 이미지 서버 등)에서 외부 URL의 이미지를 가져옵니다. 이때 next/image를 쓰려면 반드시 허용된 도메인 설정을 거쳐야 합니다.

JavaScript
 
// next.config.js
images: {
  remotePatterns: [
    {
      protocol: 'https',
      hostname: 'your-cdn-server.com',
      pathname: '/images/**',
    },
  ],
},

이 설정을 통해 외부 서버의 이미지도 Next.js 서버가 대신 최적화(Optimization API)하여 사용자에게 전달할 수 있게 됩니다.

+ Recent posts