HubSpot 채팅 위젯 레이아웃 이동 해결하기
두 가지 브라우저 레이아웃 이동 예외 규칙을 활용하여 0.91의 CLS를 0.02로 낮추는 해결 방법

HubSpot 채팅으로 인해 발생하는 레이아웃 이동 현상 해결 방법
HubSpot 채팅 위젯은 페이지에 0.91이라는 엄청난 CLS를 유발할 수 있습니다. 이는 잘 알려진 문제입니다. 그래서 여러분을 위해 직접 해결했습니다. 아래에 제공된 스크립트를 삽입하면 CLS p75를 0.91에서 0.00으로 낮출 수 있습니다. 이상적으로는 HubSpot이 직접 이 문제를 해결해야 합니다. 하지만 그들은 그렇게 하지 않을 것이고, 우리는 HubSpot의 코드를 변경할 수 없습니다. 일종의 해킹(hack)이라는 것은 알지만, 이 방법은 확실히 작동합니다.
Table of Contents!
HubSpot 채팅 CLS는 흔한 불만 사항입니다
사람들은 수년 동안 이 문제를 제기해 왔습니다. HubSpot 커뮤니티 스레드 여기, 여기 및 여기, Google Search Console 도움말 스레드, 그리고 Cronyx Digital 기사에서 확인할 수 있습니다. HubSpot은 이 문제를 수정하지 않았습니다.
왜 CLS가 발생할까요?
HubSpot 채팅 위젯은 hubspot-messages-iframe-container라는 ID를 가진 iframe 내부에 렌더링됩니다. 방문자가 런처를 탭하면 컨테이너가 런처 크기(약 60x60 픽셀)에서 전체 패널 크기(약 380x600 픽셀)로 애니메이션과 함께 확대됩니다. 방문자가 이를 닫을 때도 컨테이너는 원래대로 애니메이션되며 축소됩니다.
여기에 함정이 있습니다. Layout Instability API는 hadRecentInput을 입력 이벤트가 발생한 문서(document)에 연결합니다. HubSpot 런처 클릭은 iframe 문서 내부에서 발생합니다. 하지만 iframe 컨테이너 크기가 변경되는 곳은 부모(parent) 문서이므로 레이아웃 이동은 부모 문서에서 발생합니다. 두 개의 서로 다른 문서인 것입니다. 부모 문서의 hadRecentInput은 false로 유지됩니다. 따라서 레이아웃 이동이 점수에 반영됩니다.
실제로 처리해야 할 레이아웃 이동의 순간은 세 가지입니다. 로드(load) 시 HubSpot은 런처를 작게(~100x96) 마운트한 다음 1~2초 후 환영 버블을 표시하기 위해 ~242x245로 크기를 키웁니다. 사용자의 입력이 전혀 없으므로 점수에 온전히 반영됩니다. 열기(open) 시에는 클릭이 iframe 내부에서 발생하므로 부모 문서에서는 이를 감지하지 못합니다. 이것도 점수에 완전히 반영됩니다. 닫기(close) 시에도 동일한 교차 프레임(cross-frame) 문제가 발생하며, 닫기 애니메이션이 500ms보다 길게 실행되기 때문에 올바르게 귀속되더라도 예외 창(exclusion window) 이후의 프레임이 점수에 반영됩니다.
저는 여러 클라이언트 사이트에서 이를 측정해 보았습니다. 단 한 번 열고 닫는 사이클만으로 0.91의 CLS 점수가 발생할 수 있습니다. 이것만으로도 페이지 점수가 통과에서 불량(poor) 수준으로 떨어지기에 충분합니다.

코드
여기 스크립트가 있습니다. 사이트에 삽입하세요. 한 번만 실행되며 빌드 단계, 프레임워크, 종속성이 필요하지 않습니다. Vanilla JS입니다. HubSpot 런처 위에 투명한 버튼을 마운트하고 열기와 닫기를 처리하며 로드 시간 크기 증가를 은폐(cloak)합니다.
(function () {
if (window.__cwvHsClsFix) return;
window.__cwvHsClsFix = true;
// Strategy:
// LOAD: cloak the iframe-container with opacity:0 the moment it appears,
// then reveal once HubSpot has finished its launcher to welcome-bubble
// expansion. Chrome's layout-shift algorithm skips rect changes on
// elements whose computed opacity is 0 in both frames, so the growth
// generates ZERO shift entries.
//
// OPEN: an invisible same-origin overlay catches the tap and calls
// widget.open(). The shift fires within the 500ms input grace window,
// so hadRecentInput=true and the shift is excluded from CLS.
//
// CLOSE: we set opacity:0 on the container SYNCHRONOUSLY (in the click
// handler), THEN call widget.close(). Same opacity-0 exclusion masks
// HubSpot's slow close animation. Once HubSpot finishes (back at
// launcher size), we restore opacity so the next click works.
//
// AUTO-OPEN: kill the hs-messages-is-open cookie before HubSpot reads
// it, so a previous "open" session never fires a no-input shift on
// page load.
function clearOpenCookie() {
var paths = ['/', location.pathname];
var domains = ['', location.hostname, '.' + location.hostname];
paths.forEach(function (p) {
domains.forEach(function (d) {
document.cookie = 'hs-messages-is-open=false; path=' + p +
(d ? '; domain=' + d : '') + '; max-age=86400; SameSite=Lax';
});
});
}
clearOpenCookie();
var Z = '2147483647';
var BASE = 'position:fixed;background:transparent;border:0;padding:0;' +
'margin:0;cursor:pointer;pointer-events:auto;' +
'z-index:' + Z + ';';
var tap = document.createElement('button');
tap.type = 'button';
tap.id = '__cwv_tap';
tap.setAttribute('aria-label', 'Open chat');
tap.style.cssText = BASE + 'right:0;bottom:0;width:100px;height:100px;';
document.body.appendChild(tap);
function getContainer() {
return document.getElementById('hubspot-messages-iframe-container');
}
function isOpen() {
var c = getContainer();
if (!c) return false;
var r = c.getBoundingClientRect();
return r.width >= 280 && r.height >= 280;
}
function place(rect, asClose) {
if (!rect) {
tap.style.cssText = BASE + 'right:0;bottom:0;width:100px;height:100px;';
tap.setAttribute('aria-label', 'Open chat');
return;
}
if (asClose) {
tap.style.cssText = BASE +
'left:' + (rect.right - 60) + 'px;top:' + rect.top + 'px;' +
'width:60px;height:60px;';
tap.setAttribute('aria-label', 'Close chat');
} else {
tap.style.cssText = BASE +
'left:' + rect.left + 'px;top:' + rect.top + 'px;' +
'width:' + rect.width + 'px;height:' + rect.height + 'px;';
tap.setAttribute('aria-label', 'Open chat');
}
}
function reposition() {
var c = getContainer();
if (!c) return place(null, false);
place(c.getBoundingClientRect(), isOpen());
}
// CLOAK ON LOAD: HubSpot mounts the launcher at ~100x96, sits there for
// ~1-2s, then grows to ~242x245 to show the welcome bubble. The launcher
// looks "stable" during that gap, so a short stability threshold reveals
// too early. We need a threshold longer than that gap. 2000ms covers it
// with margin; hard cap 7s in case of slow networks.
function cloakOnLoad() {
var c = getContainer();
if (!c) return setTimeout(cloakOnLoad, 50);
if (c.dataset.cwvCloaked) return;
c.dataset.cwvCloaked = '1';
c.style.opacity = '0';
var lastSig = '';
var stableTimer = null;
var revealed = false;
var revealRO = null;
var reveal = function () {
if (revealed) return;
revealed = true;
if (revealRO) try { revealRO.disconnect(); } catch (e) {}
var cur = getContainer();
if (cur) cur.style.opacity = '1';
reposition();
};
var check = function () {
if (revealed) return;
var cur = getContainer();
if (!cur) return;
var r = cur.getBoundingClientRect();
var sig = r.width + 'x' + r.height;
if (sig !== lastSig) {
lastSig = sig;
if (stableTimer) clearTimeout(stableTimer);
stableTimer = setTimeout(reveal, 2000);
}
};
if (window.ResizeObserver) {
revealRO = new ResizeObserver(check);
revealRO.observe(c);
}
check();
setTimeout(reveal, 7000);
}
cloakOnLoad();
var ro;
function watch() {
if (ro) try { ro.disconnect(); } catch (e) {}
var c = getContainer();
if (!c) return setTimeout(watch, 100);
if (window.ResizeObserver) {
ro = new ResizeObserver(reposition);
ro.observe(c);
}
reposition();
var t0 = Date.now();
var tick = function () {
reposition();
if (Date.now() - t0 < 1500) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
watch();
if (window.MutationObserver) {
new MutationObserver(function () {
var c = getContainer();
if (c && (!ro || (ro._target && ro._target !== c))) watch();
}).observe(document.body, { childList: true });
}
function whenReady(fn) {
if (window.HubSpotConversations) return fn(window.HubSpotConversations);
(window.hsConversationsOnReady = window.hsConversationsOnReady || [])
.push(function () { fn(window.HubSpotConversations); });
}
function showChat() {
// If the cloak is still active, reveal now. Input-driven shifts are
// already excluded by hadRecentInput, and the user needs to see the chat.
var c = getContainer();
if (c) c.style.opacity = '1';
whenReady(function (HS) {
var status = HS.widget.status ? HS.widget.status() : null;
if (status && status.loaded === false) {
HS.widget.load({ widgetOpen: true });
} else {
HS.widget.open();
}
});
requestAnimationFrame(reposition);
}
function hideChat() {
var c = getContainer();
if (!c) return;
// CRITICAL: opacity:0 BEFORE widget.close() so the layout-shift API
// skips the close animation's rect change.
c.style.opacity = '0';
c.style.pointerEvents = 'none';
whenReady(function (HS) { HS.widget.close(); });
clearOpenCookie();
reposition();
setTimeout(function () {
var c2 = getContainer();
if (c2) {
c2.style.opacity = '1';
c2.style.pointerEvents = 'auto';
}
reposition();
}, 1200);
}
tap.addEventListener('click', function () {
if (isOpen()) hideChat();
else showChat();
});
whenReady(function (HS) {
if (HS.on) {
HS.on('widgetOpened', reposition);
HS.on('widgetClosed', function () { clearOpenCookie(); reposition(); });
}
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, { passive: true });
});
window.addEventListener('pagehide', clearOpenCookie);
window.addEventListener('beforeunload', clearOpenCookie);
})(); 해결 방법
제가 작성한 스크립트는 각각의 레이아웃 이동 순간에 대응하는 세 가지 작업과 자동 열기 경로를 대비한 한 가지 방어적 작업을 수행합니다.
첫 번째 트릭: 로드 시 런처 크기 증가 은폐. cloakOnLoad는 마운트되는 순간 iframe 컨테이너에 opacity: 0을 설정하고, ResizeObserver로 모니터링하다가 크기가 2초 동안 안정적으로 유지되면 요소를 표시합니다. 느린 네트워크를 대비한 안전망으로 7초의 하드 캡(hard cap)이 설정되어 있습니다. Chrome 89는 현재 및 이전 프레임 모두에서 계산된 불투명도(opacity)가 0인 요소의 크기(rect) 변경을 무시하므로, 런처가 환영 버블로 커지는 과정이 눈에 보이지 않게 발생합니다. 레이아웃 이동 기록은 0이 됩니다. 은폐가 해제되기 전에 방문자가 탭하면 showChat이 즉시 요소를 표시하며, 이때 입력 이벤트 자체가 레이아웃 이동을 예외 처리합니다.
두 번째 트릭: 부모 문서에서 열기 클릭 포착. HubSpot 런처 위에 보이지 않는 버튼을 마운트하고 ResizeObserver, MutationObserver, 1.5초 주기의 requestAnimationFrame 폴링을 사용하여 iframe의 바운딩 사각형(bounding rectangle)에 고정시킵니다. 특히 싱글 페이지 앱(SPA)에서 HubSpot이 종종 iframe을 다시 마운트하기 때문에, 이 경우 옵저버가 다시 연결됩니다. 방문자가 탭하면 부모 문서에 있는 버튼이 이벤트를 수신하여 HubSpotConversations.widget.open()을 호출합니다. 이제 부모 문서에 최근 입력(recent input)이 발생했습니다. Chrome은 레이아웃 이동을 hadRecentInput=true로 플래그 처리하여 예외 목록에 포함시킵니다. 채팅이 열려 있을 때 이 버튼은 닫기(X) 버튼만 덮으므로 채팅 내부의 클릭을 방해하지 않습니다.
세 번째 트릭: 불투명도(opacity) 뒤로 닫기 애니메이션 숨기기. 첫 번째 트릭과 동일한 opacity 0 기법입니다. 스크립트는 클릭 핸들러 내부에서 동기식으로 opacity: 0을 설정한 다음 widget.close()를 호출합니다. 순서가 중요합니다. 먼저 opacity 0을 설정하고, 그다음에 닫아야 합니다. 순서가 바뀌면 opacity가 0이 되기 전에 닫기 애니메이션이 시작되어 레이아웃 이동의 첫 프레임들이 기록됩니다.
코드 상단에서 쿠키를 제거하는 것은 방어적인 조치입니다. hs-messages-is-open 쿠키는 이전 세션에서 위젯이 열려 있었음을 기억하고 다음 페이지 로드 시 사용자 입력 없이 자동으로 다시 엽니다. 이 쿠키를 삭제하면 해당 동작을 원천적으로 차단할 수 있습니다.
결과
제가 이 코드를 처음 배포했던 클라이언트 사이트에서는 현장 데이터(field data)가 누적된 지 이틀 만에 CLS p75가 0.91에서 0.00으로 떨어졌습니다. 위젯은 시각적으로 기존과 동일한 방식으로 열리고 닫힙니다. 방문자는 아무런 차이도 알아차리지 못합니다. CrUX는 월간 주기로 이 새로운 현장 데이터를 수집합니다.

HubSpot 채팅 CLS FAQ
수정 사항에 대하여
이로 인해 HubSpot 추적 또는 채팅 기능이 손상되나요?
아닙니다. 위젯 자체는 변경되지 않습니다. 위젯을 여는 클릭과 닫는 클릭만 가로챌 뿐입니다. 대화 내역, 상담원 라우팅, 연락처 식별 등 어떤 기능도 영향을 받지 않습니다.
모바일에서도 작동하나요?
네. 투명한 버튼은 position: fixed로 배치되어 iframe의 바운딩 사각형을 따라갑니다. 데스크톱과 동일하게 모바일에서도 작동합니다.
이것이 INP에 영향을 미치나요?
클릭 핸들러는 opacity 기록, 쿠키 기록, getBoundingClientRect를 위한 동기적 DOM 읽기 등 아주 적은 양의 동기 작업만 수행합니다. 따라서 INP에 미치는 영향은 무시할 수 있는 수준입니다. 대부분의 사이트에서는 HubSpot 위젯 자체가 더 큰 INP 위험 요소이며, 이는 별개의 문제입니다.
조정 및 안정성
opacity 복원에 1200ms의 timeout을 설정한 이유는 무엇인가요?
HubSpot의 닫기 애니메이션은 수백 밀리초 동안 실행됩니다. 1200ms는 충분한 여유 시간을 제공합니다. 이를 너무 짧게 설정하면 iframe이 여전히 애니메이션 중일 때 opacity가 복원되어 남은 프레임들이 점수에 반영될 수 있습니다.
HubSpot이 iframe ID를 변경하면 어떻게 되나요?
그러면 이 스크립트는 작동하지 않게 됩니다. hubspot-messages-iframe-container라는 id는 수년 동안 안정적으로 유지되어 왔지만, 이는 서드 파티 구현의 세부 사항일 뿐입니다. HubSpot이 새로운 디자인을 배포하면 셀렉터를 업데이트해야 합니다. 이것이 다른 서비스의 위젯 위에 무언가를 구축할 때 감수해야 할 비용입니다.

