배포했는데 왜 안 바뀌죠 - Version Polling으로 SPA 업데이트 알림 배너 만들기

배포했는데 왜 안 바뀌죠 - Version Polling으로 SPA 업데이트 알림 배너 만들기

March 20, 2026

“테스트 할 때는 강력 새로고침 부탁드립니다”

프론트엔드에서 에러 처리를 개선하고 개발서버에 배포한 뒤, 팀 채팅에 확인 요청을 남겼습니다.

“해당 부분 수정하여 개발서버에 반영하였는데 이제는 잘 동작하는지 한번 확인 부탁드릴 수 있을까요? 테스트 할 때는 강력 새로고침 부탁드립니다.”

배포할 때마다 이 메시지를 보내고 있었어요. 개발서버든 운영서버든, 수정 사항을 반영한 뒤에는 항상 “강력 새로고침 해주세요”를 덧붙여야 했습니다. 강력 새로고침을 하지 않으면 이전 화면이 그대로 보이기 때문이에요.

개발팀 내부에서야 안내할 수 있지만, 실제 사용자에게 이걸 기대할 수는 없었습니다. 운영서버에서도 배포 후 “아직 이전 화면이 보인다”는 CS가 반복적으로 올라오고 있었어요.

SPA에서 배포가 즉시 반영되지 않는 이유

이건 SPA의 동작 방식 때문이에요.

MPA: 페이지 이동할 때마다 서버에 HTML을 요청
사용자: /home 클릭 → 서버에서 HTML 다운로드 (v2 반영 ✅)

SPA: 최초 1회만 번들을 다운로드, 이후는 클라이언트에서 라우팅
사용자: /home 클릭 → 이미 가진 JS로 렌더링 (v1 그대로 ❌)

저희 프로젝트는 React + Vite 기반 SPA예요. SPA는 탭을 닫거나 새로고침하지 않는 한 새 코드를 받아올 기회가 없습니다. 여기에 브라우저 캐시, CDN 캐시, Service Worker 캐시까지 겹치면 일반 새로고침으로도 이전 버전이 보일 수 있어요. 그래서 매번 “강력 새로고침”을 부탁하고 있었던 거죠.

ClickUp에서 본 업데이트 배너

이 문제를 고민하던 중, ClickUp을 쓰면서 눈에 들어온 것이 있었어요. 화면 하단에 “업데이트가 준비되었습니다” 배너가 뜨고, 사용자가 원할 때 새로고침하도록 유도하는 UX였습니다. 사용자의 작업 흐름을 끊지 않으면서도 새 버전을 안내하는 방식이 깔끔하다고 느꼈고, 이걸 우리 프로젝트에도 적용하기로 했어요.

구체적으로 필요한 것은 네 가지였습니다.

항목설명
감지 조건FE 빌드(클라이언트 번들)에 변경이 있을 때
알림 UI하단 고정 배너 + Refresh 버튼 + 닫기(X) 버튼
Refresh 동작강력 새로고침: 브라우저/SW 캐시를 무시하고 최신 빌드 로드
닫기 동작배너 일시 숨김

이 글에서는 이 요구사항을 어떻게 설계하고 구현했는지 정리해 볼게요.

왜 Service Worker가 아닌가

이런 “새 버전 알림” UX를 구현하는 가장 널리 알려진 방법은 Service Worker예요. SW의 onupdatefound 이벤트를 감지해서 사용자에게 업데이트를 안내하는 패턴인데, Vite 환경에서는 vite-plugin-pwa의 Prompt for Update 전략이 이를 쉽게 구현할 수 있도록 지원하고 있기도 합니다.

팀 내부에서도 처음 나온 아이디어가 vite-plugin-pwa였어요. Service Worker 기반의 업데이트 감지는 빌드마다 Workbox가 파일 해시를 비교해서 변경을 감지하기 때문에 정확도가 높은 편입니다.

하지만 논의를 이어가면서, 우리 프로젝트에서는 SW를 쓰기 어려운 이유를 알게 됐어요.

  • 프로젝트가 향후 마이크로프론트엔드 구조로 전환될 가능성이 있었는데, SW 캐시 범위 설정이 까다로워질 수 있었습니다
  • Service Worker는 HTTPS 환경에서만 동작하는데, 온프레미스로 납품되는 서비스 특성상 고객사 환경이 HTTP일 가능성도 배제할 수 없었어요

우리에게 필요한 건 “새 빌드가 나왔는지 확인” 하나뿐이었습니다. PWA 수준의 SW 관리는 이 목적에 비해 과도하다고 판단했어요.

그래서 빌드 시 version.json을 생성하고, 클라이언트가 주기적으로 폴링하는 방식을 선택했습니다. 외부 의존성 없이 Vite 커스텀 플러그인으로 자체 구현할 수 있다는 점도 결정에 영향을 줬어요. 전체 흐름은 빌드 시점과 런타임, 두 단계로 나뉩니다.

[빌드 시]
Vite Plugin → dist/version.json 생성 (git commit hash)
Vite define → __APP_VERSION__ 전역 상수 주입

[런타임]
60초 폴링 → fetch('/version.json') → 현재 __APP_VERSION__과 비교
→ 불일치 시 배너 표시 → 사용자 클릭 → Cache API 삭제 + location.reload()

이 흐름에서 가장 먼저 결정해야 했던 것은 버전 값을 무엇으로 할 것인가였어요.

빌드 단계, 버전 값을 심는다

git commit hash를 선택한 이유

버전 값을 뭘로 할지 고민하면서 떠올린 선택지는 세 가지였습니다.

방식장점단점
Date.now()구현이 간단해요동일 코드라도 빌드마다 값이 달라져 불필요한 알림이 발생해요
package.json version의미가 명시적이에요매번 수동으로 버전을 올려야 해요
git commit hash코드 변경 시에만 값이 변경돼요git 환경이 필요해요

Date.now()는 가장 간단하지만, CI에서 캐시 미스로 재빌드가 발생하는 것만으로도 사용자에게 불필요한 업데이트 알림이 뜰 수 있었어요. package.json version은 명시적이지만 사람이 올려야 하므로 빠뜨릴 위험이 있었습니다.

결국 git commit hash를 선택했습니다. 코드가 실제로 바뀌지 않았는데 재빌드만으로 알림이 뜨는 것을 방지할 수 있다는 점이 결정적이었어요. git 환경이 필요하다는 제약이 있지만, CI/CD 파이프라인에서는 사실상 제약이 아니었습니다.

Vite 설정

방식이 정해졌으니 코드를 볼게요. 빌드 시점에 두 가지를 해요. define 옵션으로 현재 git hash를 전역 상수로 주입하고, 커스텀 플러그인으로 dist/version.json 파일을 생성합니다.

// vite.config.ts
import { execSync } from 'child_process';

function getGitCommitHash() {
  return execSync('git rev-parse --short HEAD').toString().trim();
}

export default defineConfig({
  define: {
    __APP_VERSION__: JSON.stringify(getGitCommitHash()),
  },
  plugins: [versionPlugin()],
});

versionPlugin은 Vite의 writeBundle 훅을 사용해서 빌드 산출물이 생성된 뒤 version.json을 함께 만들어요.

function versionPlugin(): Plugin {
  return {
    name: 'version-plugin',
    writeBundle(options) {
      const outDir = options.dir || 'dist';
      const version = getGitCommitHash();
      writeFileSync(`${outDir}/version.json`, JSON.stringify({ version }));
    },
  };
}

이렇게 하면 빌드된 JS 번들에는 __APP_VERSION__"a1b2c3d" 같은 문자열로 치환되어 들어가고, dist/version.json에도 동일한 해시가 기록됩니다. version.json은 빌드 산출물이므로 .gitignore에 추가해야 해요.

빌드 시점의 준비는 끝났습니다. 이제 런타임에서 이 버전을 어떻게 감지하는지 볼게요.

런타임에서 새 버전을 감지한다

60초마다 version.json을 가져와 현재 번들의 버전과 비교하는 훅이에요.

function useUpdateChecker() {
  const [updateAvailable, setUpdateAvailable] = useState(false);

  const checkVersion = useCallback(async () => {
    if (document.hidden) return;

    try {
      const res = await fetch(`/version.json?t=${Date.now()}`, {
        cache: 'no-store',
      });
      const { version } = await res.json();
      if (version !== __APP_VERSION__) {
        setUpdateAvailable(true);
      }
    } catch {
      // fetch 실패는 조용히 무시: 네트워크 오류로 알림을 띄우면 안 돼요
    }
  }, []);

  useEffect(() => {
    checkVersion();
    const interval = setInterval(checkVersion, 60_000);

    const onVisibilityChange = () => {
      if (!document.hidden) checkVersion();
    };
    document.addEventListener('visibilitychange', onVisibilityChange);

    return () => {
      clearInterval(interval);
      document.removeEventListener('visibilitychange', onVisibilityChange);
    };
  }, [checkVersion]);

  return updateAvailable;
}

이 훅에는 세 가지 판단이 들어가 있어요.

document.hidden 체크입니다. 사용자가 다른 탭에서 작업 중일 때 폴링 요청을 보내는 건 불필요한 네트워크 비용이에요. 비활성 탭에서는 스킵하고, visibilitychange 이벤트로 탭이 다시 활성화되면 즉시 체크하도록 했습니다. 업무용 서비스 특성상 탭을 여러 개 열어두는 경우가 많았는데, 이 처리만으로도 불필요한 폴링이 눈에 띄게 줄었어요.

fetch 실패 시 조용히 무시합니다. 네트워크가 일시적으로 끊겼다고 해서 에러를 표시할 이유가 없어요. 다음 폴링에서 다시 시도하면 됩니다.

폴링 주기는 60초로 정했습니다. 30초로 줄이면 네트워크 요청이 과도해지고, 5분으로 늘리면 사용자가 오래된 화면에서 작업하다 데이터 불일치를 겪을 가능성이 높아져요. 60초가 “빠르게 알려주되, 부담 없는” 균형점이라고 판단했습니다.

그런데 버전 불일치를 감지했다고 해서 끝이 아니에요. 사용자가 실제로 새 버전을 받을 수 있어야 합니다.

새로고침할 때 캐시까지 지운다

사용자가 배너의 Refresh 버튼을 클릭했을 때, 단순한 location.reload()만으로는 부족했어요. 브라우저가 캐시된 리소스를 다시 사용할 수 있기 때문입니다. Cache API로 SW 캐시까지 삭제한 뒤 reload해요.

const handleRefresh = useCallback(() => {
  if ('caches' in window) {
    caches.keys().then(names => {
      for (const name of names) caches.delete(name);
    });
  }
  window.location.reload();
}, []);

caches API가 없는 브라우저에서도 reload()는 정상 동작하므로, 분기 처리만으로 안전하게 대응할 수 있었습니다.

version.json 캐시 우회

이 구현에서 가장 신경 썼던 부분이 있어요. version.json 자체가 브라우저 캐시에 걸리면 업데이트를 영원히 감지하지 못합니다. 이를 방지하기 위해 두 가지 방어를 적용했어요.

  • cache: 'no-store' fetch 옵션으로 브라우저 캐시를 우회합니다
  • ?t=${Date.now()} 타임스탬프 쿼리를 추가해서 CDN 캐시도 우회해요

CDN을 사용하는 환경이라면, 서버 측에서 version.json에 대해 Cache-Control: no-cache 헤더를 설정하는 것도 함께 고려해야 합니다.

마무리

Service Worker 없이도 version.json 폴링만으로 충분히 신뢰할 수 있는 업데이트 감지가 가능했어요. Vite 플러그인 하나와 React 훅 하나로 구현이 끝나기 때문에 유지보수 부담도 적었습니다.

한계도 있어요. 폴링 방식이므로 최대 60초의 지연이 발생합니다. WebSocket이나 Server-Sent Events를 사용하면 즉시 감지가 가능하지만, 업데이트 알림 하나를 위해 서버 인프라를 추가하는 건 과도하다고 판단했어요.

만약 실시간성이 중요해지는 시점이 온다면, 그때 SSE 기반으로 전환하는 것도 어렵지 않을 거예요. 폴링 로직을 SSE 구독으로 교체하면 되니까요.

참고