안녕하세요. 인프랩 프론트엔드 개발자 고슈입니다.

작년 8월, 인프런의 헤더(GNB)가 새롭게 단장했습니다! 🎉

인프런의 헤더는 여러 프론트엔드 환경에서 동작해야 하는 복잡한 구조로 이루어져 있어, 기존에도 여러 문제가 있었는데요.
이번 글에서는 인프런의 헤더가 어떻게 동작하고 있는지, 그리고 어떤 과정을 거치며 문제를 해결해 나갔는지를 담아 보았습니다.

1. 헤더를 왜 개편하게 되었는가

header as is

기존 헤더

header to be

개편된 헤더

인프런의 헤더는 런칭 이래로 변경된 적이 거의 없었는데요.
서비스가 추가될 때마다 기존 구조에 끼워 넣기만 하다 보니, 핵심 서비스가 명확하게 드러나지 않는 상태였습니다.

현재 인프런은 강의, 챌린지, 멘토링, 클립, 커뮤니티를 핵심 서비스로 정의하고 있습니다.
이번 프로젝트의 목표는 이 핵심 서비스들을 헤더에서 명확하게 노출하고, 사용자가 직관적으로 탐색·검색·구매·학습할 수 있도록 개선하는 것이었습니다.

디자인 개편 자체는 이미 완료되었지만, 그 과정이 단순하지만은 않았는데요.
이어지는 내용에서는 이 헤더가 동작하고 있는 MFE 환경의 구조와, 그 안에서 마주한 기술적 문제들을 하나씩 풀어 보겠습니다.

2. MFE 아키텍처와 Module Federation

2-1. 인프랩 팀의 프론트엔드 아키텍처

diagram monolith

모놀리식 아키텍처에서 도메인 별로 프로젝트 분리

모놀리식에서 도메인 분리로

초기 인프런은 내부에서 Antman이라 부르는, 자체 자바스크립트 프레임워크 기반의 모놀리식 프로젝트로 서비스를 운영하고 있었는데요. 서비스 규모가 커지면서 개발자 수도 늘고, 빌드 속도도 느려지기 시작했습니다. 가장 큰 문제는 하나의 서버에 프론트엔드와 백엔드가 함께 올라가 있다 보니, 한쪽에서 장애가 발생하면 서비스 전체가 마비된다는 점이었습니다.

이를 해결하기 위해 리액트로 마이그레이션을 진행하면서, 도메인별로 프로젝트를 분리하게 되었습니다. 프론트엔드 기준으로 모노레포도 있고, 폴리레포(싱글레포)도 있고, 형태는 다양하지만 각 도메인이 독립적으로 운영되는 마이크로프론트엔드(MFE)구조입니다.

이렇게 분리된 애플리케이션들은 리버스 프록시를 통해 하나의 서비스로 동작하고 있습니다. 사용자가 접속한 URL 경로에 따라 프록시가 적절한 애플리케이션으로 요청을 라우팅해 주는 방식인데요.

리버스 프록시란, 클라이언트의 요청을 받아 내부의 여러 서버 중 적절한 곳으로 전달해 주는 중간 서버입니다. 사용자는 하나의 도메인에 접속하지만, 실제로는 뒤에 있는 여러 애플리케이션 중 하나가 응답하게 됩니다.

백엔드 역시 서버가 분리되면서 한쪽 장애가 전체로 퍼지는 문제는 해소되었고, 프론트엔드 관점에서도 각 애플리케이션이 독립적으로 빌드·배포되기 때문에 런타임 오류나 배포 실패가 다른 서비스에 영향을 주지 않게 되었습니다. 패키지별로 다양한 환경을 구성할 수 있고, 코드 충돌도 줄어들었습니다.

분리했더니, 더 바빠졌다

하지만 공통으로 사용하는 컴포넌트가 있는 경우엔, 변경할 때마다 모든 패키지에 반영하고 배포해야 한다는 번거로움이 있었는데요. 특히 헤더가 대표적이었습니다.

헤더는 여러 서비스가 추가되고 실험이 이루어지면서 변경이 빈번했습니다.

헤더를 한 번 수정하면 다음과 같은 과정을 거칩니다.

  1. 레거시 Antman 쪽 헤더 코드 수정 및 배포
  2. 리액트 헤더 공통 모듈 코드 수정 및 배포
  3. 공통 모듈을 사용하는 각 도메인 패키지 업데이트 및 배포

즉, 공통 모듈을 사용하는 패키지가 10개라면, 10개 모두 배포해야 하는 셈이었습니다.

이 문제를 해결하기 위해 몇 가지 방법을 검토했습니다.

방법 1: 서버 사이드 컴포지션

프론트엔드 전용 서버를 두고, 그곳에서 헤더와 각 애플리케이션의 결과물을 조합하여 하나의 HTML로 렌더링해 내려주는 방식입니다. 하지만 모든 트래픽이 한 서버에 집중되고, 해당 서버에 장애가 생기면 모든 페이지가 마비된다는 단점이 있었습니다.

방법 2: CI/CD 디스패치를 통한 자동 배포

공통 모듈에 변경이 생기면, 워크플로우로 각 패키지에 디스패치하여 자동 배포하는 방식입니다.
기존 흐름에 자동화만 얹은 형태지만, 다른 동료가 해당 패키지에서 작업 중이거나 핫픽스를 올린 상황과 충돌할 수 있고, develop 브랜치가 항상 배포 가능한 상태라는 보장도 없었습니다. 모든 패키지의 배포 결과를 일일이 확인해야 하는 점까지 고려하면, DX가 좋지 않았습니다.

방법 3: 런타임 로딩 (Module Federation)

번들링된 JS를 S3에 올려두고, 각 애플리케이션이 런타임에 이를 가져와 실행하는 구조입니다. Webpack의 Module Federation을 활용한 방식인데요. 헤더 모듈만 배포하면 모든 애플리케이션에 즉시 반영되고, 롤백도 S3 파일 경로만 변경하면 되어 유지보수가 용이합니다.

물론 트레이드오프도 있었습니다. 런타임에 JS를 불러오는 만큼 네트워크 상태에 의존하게 되고, CSR 기반이라 SEO에 불리하며, 공유 의존성 관리 등 러닝 커브가 늘어난다는 점이 있었는데요. 그럼에도 저희는 독립적인 환경에서의 DX를 우선으로 두고 있었기 때문에, 세 번째 방식을 채택하게 되었습니다.

여기까지가 Module Federation 헤더를 도입하게 된 배경입니다.

3. 기존 헤더의 한계

3-1. 이중 관리 방식

Module Federation 덕분에 리액트 애플리케이션 간의 공통 모듈은 한 곳에서 관리할 수 있게 되었습니다. 저희는 헤더, 푸터, 인증 등 공통 영역을 담당하는 이 호스트 애플리케이션을 App Shell(앱쉘) 이라고 부르고 있는데요. 하지만 앱쉘만으로는 해결되지 않는 문제들이 여전히 있었습니다.

문제

Antman에는 아직 리액트로 마이그레이션되지 않은 페이지들이 다수 남아 있습니다. 이 페이지들은 순수 자바스크립트와 HTML, CSS로 이루어져 있어, 앱쉘의 리액트 헤더를 그대로 사용할 수 없었는데요. 결과적으로 Antman용 헤더(순수 JS)와 앱쉘용 헤더(리액트), 동일한 디자인의 헤더를 두 벌로 관리하고 있었습니다.

예를 들어 헤더에 새로운 대메뉴 “로드맵”을 추가한다면, 앱쉘과 Antman 양쪽 모두 수정해야 했습니다. 그간은 헤더 수정을 최소화하며 이중 작업을 감수해 왔지만, 이번 개편은 디자인이 완전히 바뀌는 상황이었기 때문에 더 이상 두 벌 관리를 유지할 수 없었습니다.

diagram adaptor as is

기존의 이중 관리 방식

해결

해결의 실마리는 기존에 팀 내에서 사용하고 있던 어댑터 모듈이었습니다.

이 모듈은 리액트 컴포넌트를 JS로 번들링하여 S3에 올려두고, Antman에서 manifest를 통해 해당 파일을 찾아 런타임에 번들링한 JS 파일을 로드하는 방식으로 동작합니다. 모달처럼 격리된 영역 안에서 렌더링되기 때문에, 의존성과 스타일이 모두 번들에 포함되어 기존 Antman 코드에 영향을 주지 않는 구조였습니다.
이 방식을 헤더에도 적용할 수 있지 않을까 하는 생각에서 착안했습니다.

diagram adaptor to be

어댑터 모듈을 이용한 단일 관리 방식

다만 기존 어댑터 모듈 사용 방식과는 다른 점이 있었는데요.

  • 번들링 호환: 앱쉘은 Module Federation에 맞춰 빌드되고 있었기 때문에, Antman에서 단독으로 로드할 수 있는 별도의 엔트리를 구성해야 했습니다. 또한 Module Federation의 shared로 분리해 둔 공유 의존성도, Antman에서는 제공해 줄 호스트가 없으므로 모두 번들에 포함시켜야 했습니다.
  • 스타일 충돌: 모달은 격리된 레이어 위에 뜨지만, 헤더는 Antman 레이아웃에 직접 붙기 때문에 기존 스타일과 충돌할 수 있었습니다.
  • Layout Shift: 런타임에 헤더를 불러오는 만큼, 로딩 전후로 레이아웃이 밀리는 문제를 해결해야 했습니다.

이 세 가지를 본격적인 헤더 개편 전에 먼저 해결하고 배포하는 것을 우선으로 진행했습니다. 디자인 변경과 통합 작업을 동시에 배포하면, 문제가 생겼을 때 원인을 파악하기 어렵기 때문입니다.

번들링 호환은 Vite를 이용하여 Antman 전용 엔트리 포인트를 별도로 구성하는 것으로 해결했습니다. 앱쉘의 Webpack 빌드는 기존 방식을 유지하고, Antman에 전달할 모듈을 위한 Vite 엔트리에 추가하여 단독 실행 가능한 번들을 생성했습니다. 필요한 의존성은 앞서 언급한 어댑터 모듈 쪽에 추가하여, 앱쉘 본체의 의존성 구조를 건드리지 않고 처리했습니다.

스타일 충돌은 Antman의 global CSS와 겹치지 않도록 class name을 분리하는 것으로 해결했습니다. 헤더 컴포넌트의 스타일은 emotion 을 사용하여 런타임에 난수화된 class name을 생성하도록 처리했습니다. 불가피하게 남아 있는 class name의 경우, Antman 코드베이스에서 동일한 이름이 존재하는지 검색하고, 해당 class에 이벤트 리스너가 바인딩되어 있지 않은지까지 확인하여 충돌을 피했습니다.

Layout ShiftMutationObserver 를 활용하여 해결했습니다. 헤더가 로드되기 전에는 동일한 높이의 placeholder를 배치해 두고, MutationObserver로 헤더 DOM이 추가되는 시점을 감지하여 placeholder를 제거하는 방식입니다.

통합 작업을 먼저 배포한 뒤, 헤더 개편 작업을 진행하는 동안 병렬로 모니터링을 진행하면서 안정성을 확보했습니다.

3-2. 런타임 로딩으로 인한 깜빡임 문제

문제

다음은 잦은 스켈레톤 노출입니다.

앱쉘의 헤더는 런타임에 JS를 불러와 렌더링하는 구조이기 때문에, 로딩이 완료되기 전까지 스켈레톤 UI를 노출합니다.

diagram snapshot as is

이전 헤더 - 렌더링 동작 방식

CSR 기반의 애플리케이션에서는 최초 한 번만 로드하면 이후에는 유지되지만, SSR 기반의 애플리케이션에서는 페이지를 이동할 때마다 HTML이 새로 내려오면서 헤더 JS도 매번 다시 불러옵니다.
그 결과, 페이지를 전환할 때마다 스켈레톤이 반복적으로 노출되어 헤더가 깜빡거리는 현상이 발생했습니다. 특히 네트워크가 느린 환경에서는 이 깜빡임이 더 길게 지속되어, 페이지 전체의 로딩이 느려 보이는 인상을 주었습니다.

또한 헤더의 잦은 변경에 비해 스켈레톤은 별도로 관리되고 있었기 때문에, 실제 헤더와 스켈레톤의 모양이 일치하지 않는 문제도 있었습니다.

해결

일반적인 해결 방법으로는 런타임 로딩(CSR) 대신 SSR 방식으로 헤더를 렌더링하는 것이 있습니다. 하지만 이 방식은 앞서 설명한 것처럼, 모든 패키지에 헤더를 반영하고 배포해야 하는 DX 저하 문제가 있었는데요. UX를 위해 SSR로 전환할지, DX를 유지할지 고민이 되었습니다.

사용자 경험이 우선이라는 점에서 SSR로 변경해야 할 이유는 충분했습니다. 하지만 다른 관점에서 생각해 보면, 서버에서 초기 HTML로 내려주는 스켈레톤이 실제 헤더에 더 가까운 형태라면 어떨까 하는 아이디어가 떠올랐습니다. 이전에 렌더링된 헤더 상태를 기억해 두고, 그 값을 Optimistic UI처럼 먼저 보여준 뒤 최신 상태로 교체하는 방식입니다.

이것이 바로 스냅샷 접근법입니다. 헤더의 마지막 상태를 미리 찍어 두고, 단순한 스켈레톤 대신 이전 상태가 반영된 정교한 스켈레톤을 보여주는 것입니다.

diagram snapshot to be

변경된 헤더 - 렌더링 동작 방식

최초 방문자는 쿠키가 존재하지 않기 때문에 주요 아이콘만 노출하는 방식으로 처리했습니다.

스냅샷 데이터를 어디에 저장할 것인가

클라이언트에서 상태를 저장할 때는 보통 세션 스토리지나 로컬 스토리지를 사용하는 것이 일반적입니다. 하지만 이 경우, 서버에서 HTML을 내려줄 때 저장된 값을 함께 활용해야 했기 때문에 쿠키에 저장하는 방식을 택했습니다. 쿠키는 HTTP 요청 헤더에 자동으로 포함되므로, 서버 사이드에서도 값을 읽을 수 있기 때문입니다.

쿠키 값을 스켈레톤에 바로 반영할 수 없는 이유

처음에는 SSR 단계에서 쿠키를 읽어 스켈레톤에 바로 반영하는 방법을 검토했습니다. 하지만 두 가지 제약이 있었는데요.

첫째, 스켈레톤 컴포넌트 안에서 쿠키 값에 따라 조건부 렌더링을 하면 하이드레이션 에러가 발생할 수 있었습니다. CDN 캐싱 등과 같은 방법으로 인해 서버와 클라이언트에서 쿠키 값이 달라지는 경우, 컴포넌트 트리 구조 자체가 달라지기 때문입니다. 즉, 서버에서 내려주는 컴포넌트에 쿠키 기반의 분기를 만들 수 없었습니다.

둘째, 헤더 스켈레톤은 Module Federation의 fallback으로 정적 선언되어 있기 때문에, req 객체에 접근할 수 없어 쿠키 값 자체를 알 수 없었습니다.

data attribute + CSS로 해결하기

이 제약을 우회하기 위해, <html> 태그의 data attribute에 스냅샷 값을 주입하고, CSS 속성 셀렉터와 CSS 변수로 스켈레톤의 노출 여부와 텍스트를 제어하는 방식으로 접근했습니다. 이 방식의 핵심은 컴포넌트 트리를 건드리지 않는다는 점입니다.

스켈레톤의 모든 메뉴 항목은 항상 동일하게 렌더링되고, CSS만으로 노출 여부를 제어하기 때문에 서버와 클라이언트 간 컴포넌트 트리가 달라질 일이 없어 하이드레이션 에러가 발생하지 않습니다.

쿠키에 다음과 같은 값이 저장되어 있다고 가정하겠습니다.

{
  "menuList": {
    "courses": "강의",
    "challenges": "챌린지"
  }
}

이 값을 활용하여 스냅샷 스켈레톤을 구성하는 과정을 살펴보겠습니다.
(이해를 돕기 위해 구체적인 UI 디자인은 생략합니다.)

대메뉴를 의미하는 컴포넌트가 다음과 같이 구성되어 있다고 가정합니다.

<ul class="class-menu-list">
  <li class="class-menu-courses" />
  <li class="class-menu-challenges" />
  <li class="class-menu-community" />
  ...
</ul>

여기서 쿠키에 저장된 메뉴만 선택적으로 노출하려면, <html> 태그에 data attribute와 CSS 변수를 미리 주입하면 됩니다.

<html
  data-show-menu-courses="1"
  data-show-menu-challenges="1"
  style="--label-courses:'강의'; --label-challenges:'챌린지';"
>
  <ul class="class-menu-list">
    <li class="class-menu-courses" />
    <li class="class-menu-challenges" />
    <li class="class-menu-community" />
    ...
  </ul>
</html>

이를 제어하는 글로벌 CSS는 다음과 같습니다.

html .class-menu-list {
  display: flex;
}

/* 메뉴 항목은 기본적으로 숨김 */
html .class-menu-list > li {
  display: none;
}

/* 쿠키에 존재하는 메뉴만 노출 */
html[data-show-menu-courses='1'] .class-menu-courses {
  display: block;
}

html[data-show-menu-challenges='1'] .class-menu-challenges {
  display: block;
}

.class-menu-courses::after {
  content: var(--label-courses);
}

.class-menu-challenges::after {
  content: var(--label-challenges);
}

스켈레톤의 메뉴 항목은 기본적으로 모두 숨김 처리되어 있고, 쿠키에 존재하는 메뉴만 data attribute 값이 '1'로 설정되어 CSS에 의해 노출됩니다. 동시에 CSS 변수를 통해 메뉴 라벨 텍스트까지 서버 응답 시점에 표현할 수 있습니다.

패키지에 적용하기

이 data attribute를 자동으로 구성해 주는 헬퍼 함수를 만들고, 앱쉘을 사용하는 모든 패키지의 _document에 적용했습니다. _document에 추가하는 코드 자체는 1회성이지만, 메뉴가 추가되거나 디자인이 변경되면 getState를 포함한 공유 모듈의 버전을 올리고 각 패키지를 재배포해야 합니다. 다만 이는 스켈레톤 구조 자체가 바뀌는 경우에만 해당하므로 빈도가 현저히 낮고, SSR 방식과 달리 각 패키지의 코드를 직접 수정할 필요도 없기 때문에 변경에 따른 작업량을 최소화할 수 있었습니다.

export default class _Document extends Document<MyDocumentProps> {
  static async getInitialProps(ctx: DocumentContext) {
    const initialProps = await Document.getInitialProps(ctx);

    const cookies = ctx.req?.headers.cookie ?? '';

    const state = getState(cookies, ctx.pathname);

    return {
      ...initialProps,
      state,
    };
  }

  render() {
    const { state } = this.props;

    return (
      <Html {...state.attrs} style={state.cssVars}>
        <Head />
        <Main />
        <NextScript />
      </Html>
    );
  }
}

이 방식을 통해 SSR로 전환하지 않고도, 사용자에게는 이전 상태가 반영된 정교한 스켈레톤을 보여줄 수 있게 되었습니다. UX와 DX 사이에서 스냅샷이라는 중간 지점을 찾은 셈입니다.

4. UX 요구사항 해결: 자연스러운 레이아웃과 모션

구조적인 제약을 해결한 뒤, 이제 그 위에서 UX를 다듬을 차례였습니다. 이번 개편에서는 두 가지 요구사항이 있었습니다.

header show searchbar

주요 리스트 페이지에서는 통합 검색바 노출

header hide searchbar

그 외의 페이지에서는 통합 검색바를 노출하지 않음

경로에 따라 헤더의 레이아웃이 달라지고, 검색바에는 스크롤에 반응하는 모션이 필요했습니다. 각각을 구현하면서 마주한 문제와 해결 과정을 정리해 보겠습니다.

4-1. 경로별 검색바 영역 확보

문제

경로에 따라 검색바의 노출 여부가 달라지므로, 헤더 컴포넌트 안에서 경로별로 분기하면 될 것처럼 보입니다. 하지만 3-2장에서 다룬 스켈레톤도 함께 고려해야 했습니다. 검색바가 노출되는 경로에서는 스켈레톤 단계에서부터 검색바 높이만큼의 공간을 확보해야, 헤더가 로드된 뒤에도 Layout Shift가 발생하지 않기 때문입니다.

해결

앞서 스냅샷에서 사용한 방식을 그대로 확장했습니다. 스켈레톤에 CSS 속성을 주입할 때, 경로 정보까지 함께 전달받아 검색바 영역의 노출 여부를 결정하도록 처리했습니다.

export function getState(
  cookieHeader?: string | null,
  pathname?: string
): State {
  const state: State = { attrs: {}, cssVars: {} };
  const snapShot = getSnapShot(cookieHeader); // 쿠키 정보를 통해 스냅샷 json 파싱

  if (!snapShot) {
    return state;
  }

  ...

  // 검색바 표시 여부 결정
  const isSearchBarVisible = isVisibleSearchBarWithParams(pathname);

  // 검색바 관련 속성 추가
  state.attrs[ATTRIBUTE_PREFIXES.SHOW_SEARCH_BAR] = createBooleanValue(isSearchBarVisible);

  ...

  return state;
}

이렇게 하면 서버에서 HTML을 내려줄 때, <html> 태그에 검색바 노출 여부가 data attribute로 포함됩니다.

<html data-show-search-bar="1" ...>
   ...
</html>

CSS 속성 셀렉터를 활용하면 컴포넌트 변경 없이도 검색바 공간을 확보할 수 있습니다.

html[data-show-search-bar='1'] .class-search-bar {
    height: calc(DEFAULT_HEIGHT);

    @media (max-width: 1024px) {
      height: calc(COMPACT_HEIGHT);
    }
}

4-2. 스크롤 모션의 성능 개선

문제

검색바가 노출되는 페이지에서는, 스크롤 시 검색바가 헤더 안으로 올라가는 애니메이션이 필요했습니다. 최초 구현에서는 이 모션을 heightscale 속성으로 처리했는데요. height 값이 변경되면 브라우저는 렌더 트리의 reflow를 수행하면서 CPU 연산이 발생합니다.

리스트 페이지처럼 렌더링 요소가 많은 화면에서는, 스크롤할 때마다 reflow가 반복되면서 화면 전체가 버벅거리는 현상이 발생했습니다.

해결

기존 구조에서는 헤더 영역과 검색바 영역이 분리되어 있었고, 스크롤 시 검색바 영역의 height를 줄이면서 검색바를 위로 올리는 방식이었습니다. 이 경우 height가 변경될 때마다 reflow가 발생합니다.

diagram scroll reflow

reflow 동작 구조

핵심은 reflow를 유발하는 속성 대신, Composite 단계에서 처리될 수 있는 transform을 활용하는 것이었습니다.

이 문제를 해결하기 위해 Airbnb의 헤더 구조를 참고했습니다. Airbnb의 헤더 역시 스크롤 시 검색바가 축소되는 인터랙션이 있는데, height 변경 없이 transform만으로 모션을 처리하고 있었습니다.

변경된 구조에서는 검색바를 헤더 내부에 배치합니다.
검색바가 차지할 공간을 확보하기 위해, 콘텐츠 영역 역시 translateY로 아래쪽으로 이동시킵니다. 스크롤이 발생하면, 검색바의 translateY 값을 줄여 헤더 안으로 다시 올라오도록 처리합니다.

diagram scroll composite

composite 동작 구조

transform: translateY()는 Composite 단계에서 GPU로 처리될 수 있기 때문에, 렌더 트리의 reflow 없이 모션을 구현할 수 있습니다.

CPU 대신 GPU를 활용한 덕분에, 렌더링 요소가 많은 리스트 페이지에서도 스크롤 모션이 자연스럽게 동작하게 되었습니다.

5. 완벽한 구조 대신, 현실적인 구조를 선택하기까지

지금까지의 내용을 돌아보면, 헤더의 깜빡임을 해결하기 위한 가장 깔끔한 방향은 명확했습니다. 각 애플리케이션에서 헤더를 직접 SSR로 렌더링하는 것입니다. 그렇게 하면 런타임 로딩으로 인한 스켈레톤 노출도 없고, 스냅샷 같은 우회 전략도 필요 없습니다.

하지만 저희는 그 방향을 선택하지 않았습니다.

SSR로 전환하면 헤더를 변경할 때마다 모든 패키지를 다시 빌드하고 배포해야 합니다. 2장에서 설명한 것처럼, 이 구조는 헤더 변경이 잦은 저희 환경에서 DX를 크게 저하시키는 원인이었습니다. Module Federation 기반의 런타임 로딩은 UX 측면에서 분명한 단점이 있지만, 헤더만 독립 배포할 수 있다는 장점은 저희 팀의 운영 방식에 더 맞았습니다.

스냅샷이 근본적인 해결책은 아니었지만, 덕분에 당장의 UX 문제를 수용 가능한 수준으로 끌어올릴 수 있었고, 이후에는 의존성 번들 크기와 로딩 지연 원인을 분석하며 구조 개선을 이어 나갈 여유를 확보할 수 있었습니다. 단기적으로는 사용자 경험을 개선하고, 장기적으로는 구조적 개선을 위한 기반을 마련한 셈입니다.

이번 헤더 개편은 기술적인 관점에서 DX를 향상시키면서 헤더의 UX 문제를 최소화하는 중간 지점을 찾은 것이었습니다. 완벽한 구조는 아니지만, 현재의 조직 규모와 배포 환경에서 가장 현실적인 균형점이라고 판단했습니다.

개편 전 개편 후
헤더 코드 관리 앱쉘 + Antman 두 벌 앱쉘 한 벌로 통합
헤더 변경 시 배포 모든 패키지 개별 배포 앱쉘만 배포
스켈레톤 실제 헤더와 불일치 스냅샷 기반 정교한 스켈레톤
스크롤 모션 reflow 기반 (CPU) Composite 기반 (GPU)

6. 아직 남아 있는 과제

개편을 통해 많은 부분이 개선되었지만, 아직 해결하지 못한 과제도 남아 있습니다.

첫째, 앱쉘의 API 로딩 속도입니다. 헤더는 런타임에 JS를 불러온 뒤, 사용자 정보 등을 API로 조회하여 렌더링을 완료합니다. 스냅샷으로 시각적인 깜빡임은 줄였지만, API 응답이 느린 경우에는 여전히 최종 렌더링까지 시간이 걸립니다. 이 구간의 지연을 줄이는 것이 다음 과제입니다.

둘째, Antman 마이그레이션 완료입니다. 현재는 Antman에 남아 있는 레거시 페이지들을 위해 어댑터 모듈을 거쳐 헤더를 로드하는 구조를 유지하고 있습니다. Antman의 모든 페이지가 리액트로 마이그레이션되면 이 어댑터 레이어를 제거할 수 있고, 전체 구조가 한층 단순해질 것으로 기대하고 있습니다.

7. 마치며

이번 헤더 개편은 단순한 디자인 변경이 아니라, MFE 환경에서 공통 컴포넌트를 어떻게 관리할 것인가에 대한 고민의 연장선이었습니다.

이상적인 구조와 현실 사이에서 매번 타협점을 찾아야 했고, 그 과정에서 스냅샷 같은 우회 전략이 탄생하기도 했습니다. 정답이라고 말하기는 어렵지만, 저희 환경에서 찾을 수 있는 최선의 해답이었다고 생각합니다.

여러 환경에서 동작해야 하는 공통 컴포넌트를 관리하며, 이상적인 구조와 현실 사이에서 고민하고 계신 분들께 이 글이 작은 참고가 되었으면 합니다.

긴 글 읽어 주셔서 감사합니다! 😄