Skip to content

1.2초의 렌더 블로킹, 네이버 지도 API 동적 로딩으로 최적화하기

Web | 2025년 12월 4일


모잇지 서비스에서 네이버 지도 API가 유발한 렌더 블로킹을 제거하고, 초기 로딩 성능을 개선한 과정을 기록합니다.

들어가며

성능 최적화는 사용자에게 더 나은 웹 경험을 제공하기 위한 프론트엔드의 핵심 과제입니다. Moitz의 성능을 측정하던 중 Lighthouse 성능 점수가 46점에 머무는 병목을 발견했습니다.

개선 전 ResultPage Lighthouse 성능 점수

가장 큰 원인은 Lighthouse의 Render blocking requests 항목이었습니다. 네이버 지도 API 스크립트가 초기 렌더링을 1,240ms, 약 1.2초 동안 차단하고 있었습니다.

네이버 지도 API의 렌더 블로킹 요청

이 글에서는 초기 렌더링을 가로막던 동기 스크립트 문제를 진단하고, 동적 로딩(Dynamic Loading)으로 전환해 사용자 경험을 개선한 과정을 소개합니다.

[WHY] Critical Rendering Path를 막는 스크립트

원인은 명확했습니다. index.html에서 네이버 지도 API 스크립트를 동기 방식으로 불러오고 있었습니다.

기존의 실행 순서는 다음과 같았습니다.

  1. 브라우저가 HTML 파싱 시작
  2. <script> 태그를 만나 HTML 파싱을 즉시 중단
  3. 네이버 지도 API 스크립트(87.8KB) 다운로드 및 실행 (window.naver 생성)
  4. HTML 파싱 완료
  5. DOMContentLoaded 이벤트 발생
  6. React 앱 시작 (main.tsx)
  7. IndexPage 렌더링
  8. 사용자가 ResultPage로 이동
  9. useCustomOverlays에서 window.naver.maps 사용

문제는 이 스크립트가 Critical Rendering Path(CRP)를 차단한다는 점이었습니다.

<!-- index.html - 모든 페이지에서 동기 실행 -->
<script src="https://oapi.map.naver.com/openapi/v3/maps.js" type="text/javascript"></script>

<!-- 모든 페이지 로드 시 87.8KB 스크립트 다운로드 + 실행 완료까지 렌더링 차단 -->
<!-- ResultPage에서만 사용하는 API를 전역에서 로드하는 비효율성 -->

즉, 전역 객체인 naver.maps가 준비될 때까지 브라우저는 Map 컴포넌트를 렌더링할 수 없었습니다.

Moitz에는 지도가 필요 없는 IndexPage와 지도가 필수인 ResultPage가 있습니다. 하지만 기존 구조에서는 IndexPage에 접근할 때도 지도 API를 내려받고 실행했습니다. 결과적으로 지도와 무관한 페이지까지 초기 로딩이 지연되고 있었습니다.

[WHAT] 필요할 때만 불러오는 '동적 로딩'

먼저 index.html에서 동기 스크립트 태그를 제거했습니다. 이 변경만으로 초기 렌더링을 막던 1,270ms의 작업과 87.8KB의 리소스를 초기 경로에서 제외할 수 있었습니다. 동시에 모든 페이지에서 발생하던 불필요한 API 로딩도 막았습니다.

<!-- 제거된 코드 -->
<!-- <script src="https://oapi.map.naver.com/openapi/v3/maps.js" type="text/javascript"></script> -->

다음으로 스크립트를 언제 로드할지 결정해야 했습니다. 다음 세 가지 방식을 비교했습니다.

  1. async 사용
  2. defer 사용
  3. 동적 로딩 사용

asyncdefer는 모두 HTML 파싱과 병렬로 스크립트를 내려받습니다. 다만 defer는 HTML 파싱 후 실행될 뿐, 지도가 필요 없는 페이지에서도 스크립트를 내려받는 문제를 해결하지 못합니다.

async는 실행 시점도 보장하지 않습니다. 따라서 React 컴포넌트가 마운트되는 순간 window.naver가 아직 준비되지 않아 레이스 컨디션(Race Condition)이 발생할 수 있습니다.

동적 로딩은 JavaScript로 스크립트 태그를 생성해, 지도가 필요한 ResultPage에 진입했을 때만 API를 로드하는 방식입니다. 로딩 시점을 제어할 수 있고, 조건부 로딩과 Promise 기반의 에러 처리도 자연스럽게 지원합니다. 이 방식이 요구사항에 가장 잘 맞는다고 판단해 구현을 시작했습니다.

[HOW] 구현 과정

지도 API가 늦게 도착하거나 로딩에 실패해도 화면이 불안정해지지 않도록, 스크립트 로딩 상태를 세밀하게 관리하는 로직을 구현했습니다.

1. 네이버 지도 API 로더 유틸리티 (loadNaverMapScript)

먼저 스크립트를 동적으로 로드하고, 결과를 Promise로 반환하는 유틸리티 함수를 만들었습니다. 이 함수는 두 가지를 보장합니다.

중복 로드 방지

window.naver.maps가 이미 존재하거나 스크립트 태그가 DOM에 삽입된 상태라면, 새 태그를 만들지 않습니다.

Promise 기반 상태 관리

script 요소의 onload에서는 resolve를, onerror에서는 reject를 호출해 로딩 성공과 실패를 명확하게 구분합니다.

// /utils/loadNaverMapScript.ts
export const loadNaverMapScript = (): Promise<void> => {
  return new Promise((resolve, reject) => {
    // 이미 로드된 경우
    if (window.naver?.maps) {
      resolve();
      return;
    }

    // 이미 로딩 시도 중인 스크립트가 있는지 확인 (중복 방지)
    const existingScript = document.querySelector(
      'script[src*="oapi.map.naver.com"]'
    );
    if (existingScript) {
      // 로딩 중이라면 해당 스크립트의 완료/실패 이벤트에 리스너 추가
      existingScript.addEventListener('load', () => resolve());
      existingScript.addEventListener('error', () => reject());
      return;
    }

    // 새 스크립트 생성
    const script = document.createElement('script');
    script.src = `https://oapi.map.naver.com/openapi/v3/maps.js?ncpKeyId=${YOUR_API_KEY}`;
    script.async = true; // 비동기 로드
    script.onload = () => resolve();
    script.onerror = () => reject();
    document.head.appendChild(script);
  });
};

2. 로딩 상태 관리 커스텀 훅 (useNaverMapLoader)

다음으로 Map 컴포넌트가 API 로딩 상태를 쉽게 사용할 수 있도록 커스텀 훅을 만들었습니다. 이 훅은 isLoading, isScriptLoaded, errorMessage 세 가지 상태를 관리합니다.

// /hooks/useNaverMapLoader.ts
export const useNaverMapLoader = () => {
  const [isScriptLoaded, setIsScriptLoaded] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const [errorMessage, setErrorMessage] = useState<string | null>(null);

  useEffect(() => {
    const initializeScript = async () => {
      try {
        await loadNaverMapScript(); // 스크립트 로드 시도
        setIsScriptLoaded(true);
      } catch (err) {
        setErrorMessage(err.message);
      } finally {
        setIsLoading(false);
      }
    };
    initializeScript();
  }, []);

  return { isScriptLoaded, isLoading, errorMessage };
};

3. Map 컴포넌트 적용 (로딩/에러 UI 처리)

Map 컴포넌트는 useNaverMapLoader가 반환한 상태에 따라 서로 다른 UI를 렌더링합니다.

로딩 중 (isLoading)

스켈레톤 UI를 표시합니다.

에러 발생 (errorMessage)

사용자에게 로딩 실패를 알리고, 재시도할 수 있는 Fallback UI를 표시합니다.

로드 완료 (isScriptLoaded)

실제 지도 컴포넌트를 렌더링합니다.

// /components/Map.tsx
function Map(...) {
  const { isScriptLoaded, isLoading, errorMessage } = useNaverMapLoader();

  // 1. 로딩 중
  if (isLoading) {
    return <Skeleton />;
  }
  // 2. 에러 발생
  if (errorMessage) {
    return <FallBackPage error={new Error(`지도 로딩 실패: ${errorMessage}`)} ... />;
  }

  // 3. 로드 완료 (isScriptLoaded === true)
  return <ActualMapComponent ... />;
}

4. CustomOverlay 레이스 컨디션 해결

마지막으로 CustomOverlay에서 발생하는 레이스 컨디션을 해결해야 했습니다. 네이버 지도의 CustomOverlaywindow.naver.maps.OverlayView를 상속받아 구현합니다.

기존에는 파일 최상단에서 클래스를 정의했습니다. 그러나 모듈이 로드되는 시점에는 지도 스크립트가 아직 준비되지 않았을 수 있으므로, window.naver를 찾지 못해 오류가 발생할 수 있습니다.

// 기존 문제 코드
// 파일 최상단에서 클래스를 정의하려 하면,
// 스크립트 로드 전이라 window.naver가 없어 에러 발생
class CustomOverlay extends window.naver.maps.OverlayView {
  // window.naver가 없을 수 있음
}

이를 해결하기 위해 클래스를 파일 로드 시점이 아닌 실행 시점에 생성하는 팩토리 함수를 만들었습니다.

// createCustomOverlay.ts
export const createCustomOverlay = (props: CustomOverlayProps) => {
  // 1. API가 로드되었는지 다시 한번 확인
  if (!window.naver?.maps?.OverlayView) {
    throw new Error('네이버 지도 API가 로드되지 않았습니다.');
  }

  // 2. 이 시점에는 window.naver.maps.OverlayView가 보장됨
  // 함수 내부에서 동적으로 클래스를 정의하고 상속
  class CustomOverlay extends window.naver.maps.OverlayView {
    // ... (CustomOverlay 로직)
  }

  return new CustomOverlay(props);
};

[SO] 개선 결과

동기 스크립트를 제거하고 동적 로딩을 적용한 뒤, 실행 흐름은 다음과 같이 바뀌었습니다.

개선 전개선 후
1. HTML 파싱 시작1. HTML 파싱 (차단 없음)
2. 네이버 지도 API 로드 및 실행 (1,270ms 차단)2. React 앱 즉시 시작
3. HTML 파싱 완료3. IndexPage: 지도 API 로드 없음
4. React 앱 시작4. ResultPage 진입 시에만 동적 로딩
5. 모든 페이지에서 지도 API 로드됨5. 백그라운드에서 비동기 API 로드
동적 로딩 적용 후 실행 흐름

실제 Lighthouse 측정 결과도 함께 개선됐습니다.

IndexPage - 변경 전

기존에는 index.html의 네이버 지도 API 스크립트가 모든 페이지에서 동기적으로 실행되어 렌더 블로킹이 발생했습니다.

개선 전 IndexPage Lighthouse 측정 결과 개선 전 IndexPage 렌더 블로킹 요청

IndexPage - 변경 후

IndexPage에서는 지도가 필요하지 않으므로 스크립트를 실행하지 않습니다. 그 결과 렌더 블로킹도 사라졌습니다.

개선 후 IndexPage Lighthouse 측정 결과 개선 후 IndexPage 렌더 블로킹 요청

ResultPage - 변경 전

ResultPage는 지도가 필요한 화면이지만, HTML의 동기 스크립트가 Critical Rendering Path를 차단하면서 FCP가 4.4초까지 지연됐습니다.

개선 전 ResultPage Lighthouse 성능 점수 개선 전 ResultPage FCP 측정 결과 개선 전 ResultPage 렌더 블로킹 요청

ResultPage - 변경 후

이제 지도 API는 필요한 시점에만 동적으로 로드됩니다. 그 결과 FCP는 0.6초로 단축됐고, Performance 점수는 46점에서 57점으로 올랐습니다.

개선 후 ResultPage Lighthouse 성능 점수 개선 후 ResultPage FCP 측정 결과

모든 페이지에서 발생하던 렌더 블로킹을 제거해 FCP는 개선됐습니다. 다만 스크립트 자체의 실행 시간은 그대로이므로 TBT는 크게 개선되지 않았습니다.

TBT 점수가 개선되지 않은 이유

TBT는 메인 스레드가 작업을 실행하는 시간을 측정합니다. 따라서 핵심은 스크립트를 언제 내려받느냐가 아니라, 실행할 때 메인 스레드를 얼마나 오래 점유하느냐입니다.

Naver Map API는 여전히 메인 스레드에서 동기적으로 실행됩니다. 파싱과 실행 시간이 길수록 TBT는 증가합니다. async는 다운로드만 비동기로 처리할 뿐, 실행은 여전히 메인 스레드를 블로킹합니다.

외부 서드 파티 스크립트인 Naver Map API에는 다음과 같은 제약이 있습니다.

  • 외부 third-party 스크립트로 최적화가 불가합니다.
  • 파싱과 실행 시간이 깁니다. (200~300ms)
  • DOM 접근이 필요하기 때문에 Web Worker를 사용할 수 없습니다.

따라서 TBT를 개선하려면 스크립트를 완전히 제거하거나, 측정 구간 밖으로 실행을 미루어야 합니다.

마무리

이번 작업은 단순히 성능 점수를 올리는 데서 그치지 않고, 지도 기능을 다루는 구조도 더 명확하게 만들었습니다.

  1. 관심사 분리: 지도 로딩 로직을 useNaverMapLoader 훅과 Map 컴포넌트 안에 캡슐화했습니다.
  2. 리소스 최적화: 지도가 필요한 페이지에서만 API를 로드하도록 바꿨습니다.
  3. 안정성 강화: 로딩 실패 시 에러 UI를 제공해 사용자 경험을 보호할 수 있게 됐습니다.

Lighthouse의 'Render Blocking 제거' 제안에서 출발해 리소스 로딩 전략 전반을 다시 살펴봤습니다. 그 결과 초기 화면을 더 빠르게 보여주면서도, 지도가 필요한 화면에서는 안정적으로 기능을 제공할 수 있게 됐습니다.