서론: 매끄러운 사용자 경험을 향한 여정, 그리고 예상치 못한 암초
현대 웹 개발의 패러다임은 단일 페이지 애플리케이션(Single Page Application, SPA)으로 크게 전환되었습니다. 사용자는 더 이상 사소한 인터랙션 하나하나에 페이지 전체가 새로고침되는 불편함을 겪지 않아도 됩니다. Ajax(Asynchronous JavaScript and XML) 기술을 통해 서버와 비동기적으로 데이터를 교환하고, 자바스크립트로 동적으로 DOM을 렌더링함으로써 애플리케이션과 같은 부드럽고 끊김 없는 사용자 경험(UX)을 제공하는 것이 가능해졌습니다. 모달 창 띄우기, 탭 전환, 실시간 데이터 업데이트 등은 이제 웹의 표준적인 기능으로 자리 잡았습니다.
하지만 이러한 동적인 변화는 웹 브라우저의 고유한 탐색 기능, 특히 '뒤로 가기' 버튼과의 미묘한 충돌을 야기합니다. 전통적인 웹 페이지에서는 브라우저의 뒤로 가기 버튼이 이전 페이지로 명확하게 이동했지만, SPA에서는 페이지 전환 없이 내부 상태만 변경되는 경우가 많습니다. 사용자는 방금 띄웠던 모달 창을 닫기 위해, 혹은 이전에 선택했던 탭으로 돌아가기 위해 습관적으로 뒤로 가기 버튼을 누릅니다. 만약 애플리케이션이 이 기대를 충족시키지 못하고 이전 페이지로 이동해버린다면, 사용자는 큰 혼란을 느끼고 이는 곧 형편없는 사용자 경험으로 이어집니다.
이 문제를 해결하기 위해 웹 개발자들은 HTML5 History API를 적극적으로 활용합니다. history.pushState()
와 history.replaceState()
메서드를 사용하면 페이지를 실제로 새로고침하지 않으면서도 브라우저의 주소 표시줄 URL을 변경하고, 세션 기록 스택에 새로운 상태를 추가하거나 수정할 수 있습니다. 그리고 사용자가 뒤로 가기나 앞으로 가기 버튼을 눌렀을 때 발생하는 popstate
이벤트를 감지하여, 저장해두었던 상태(State)를 기반으로 UI를 이전 상태로 복원하는 정교한 제어가 가능해집니다.
이처럼 History API는 SPA의 사용자 경험을 한 차원 높여주는 강력한 도구이지만, 그 이면에는 개발자를 당황하게 만드는 복잡한 동작 방식과 브라우저별 특성이 숨어 있습니다. 특히, 명확한 이유 없이 popstate
이벤트가 의도치 않은 시점에 발생하는 현상은 많은 개발자들이 한 번쯤 마주치는 까다로운 문제입니다. 이 글에서는 Ajax와 History API를 이용해 뒤로 가기 기능을 구현하는 과정에서, 특정 조건 하에 페이지를 이동할 때 얘기치 않게 popstate
이벤트가 발생하는 문제의 원인을 심도 있게 분석하고, 근본적인 해결책과 실용적인 대안들을 코드 예제와 함께 상세히 제시하고자 합니다.
문제 상황: 의도치 않은 'popstate' 이벤트의 습격
문제는 매우 구체적인 시나리오에서 발생합니다. 당신은 사용자가 버튼을 클릭하면 Ajax 요청을 보내 데이터를 받아온 후, 아름답게 디자인된 모달 창을 화면에 띄우는 기능을 구현했다고 가정해 봅시다. 사용자가 모달 창을 본 후, 운영체제나 브라우저의 '뒤로 가기' 버튼을 눌러 자연스럽게 모달을 닫을 수 있도록 History API를 적용하기로 합니다.
구현 로직은 다음과 같을 것입니다.
- 사용자가 '모달 열기' 버튼을 클릭합니다.
- 자바스크립트 이벤트 핸들러가 실행됩니다.
history.pushState({ modalOpen: true }, '모달 열림', '/current-page#modal')
와 같이 새로운 상태를 브라우저 세션 기록에 추가합니다. 주소창의 URL은#modal
이 추가된 형태로 변경됩니다.- 모달 창을 화면에 표시합니다.
popstate
이벤트를 수신하는 리스너를 미리 등록해 둡니다. 이 리스너는event.state
객체를 확인하여 모달을 닫는 로직을 수행하도록 설계되었습니다.
여기까지는 완벽하게 작동합니다. 사용자는 뒤로 가기 버튼으로 모달을 닫을 수 있고, 다시 앞으로 가기 버튼으로 모달을 열 수도 있습니다. 문제는 이 상태에서 사용자가 현재 페이지의 다른 링크(예: '홈으로', '다른 상품 보기')를 클릭하여 완전히 다른 페이지로 이동하려고 할 때 발생합니다. 놀랍게도, 다음 페이지로 넘어가기 직전, 현재 페이지에 등록되어 있던 popstate
이벤트 리스너가 실행되는 현상이 관찰됩니다.
이는 심각한 부작용을 초래할 수 있습니다. 예를 들어, popstate
핸들러가 모달을 닫기 위해 DOM 요소를 조작하려고 시도하지만, 페이지가 곧 언로드(unload)될 예정이므로 불필요한 연산이 발생하거나 에러를 유발할 수 있습니다. 혹은 상태 복원을 위해 불필요한 Ajax 요청을 다시 보내 서버에 부담을 줄 수도 있습니다. 사용자는 다음 페이지로 이동하기를 원했을 뿐인데, 애플리케이션은 마치 '뒤로 가기'가 눌린 것처럼 반응하는 것입니다. 이 현상은 특히 크롬(Chrome)과 같은 웹킷(WebKit) 기반 브라우저에서 두드러지게 나타나는 경향이 있으며, 한때 사파리(Safari)에서도 유사한 버그가 보고되기도 했습니다.
이러한 동작은 '페이지 내 상태 변경'을 위한 popstate
로직과 '페이지 간 이동'이라는 전혀 다른 맥락의 탐색이 서로 충돌하며 발생하는 문제입니다. 왜 이런 현상이 발생하는지, 그리고 이를 어떻게 해결해야 할지 깊이 파헤쳐 보겠습니다.
History API 심층 탐구: 'pushState', 'replaceState', 그리고 'popstate'
문제의 원인을 정확히 이해하려면 먼저 History API의 핵심 구성 요소들을 명확하게 알아야 합니다. window.history
객체는 브라우저의 세션 기록, 즉 사용자가 현재 탭에서 방문한 페이지들의 목록에 접근할 수 있는 인터페이스를 제공합니다.
1. history.pushState(state, title, url)
: 기록 스택에 새 상태 추가
pushState()
는 세션 기록 스택의 맨 위에 새로운 항목을 추가하는 가장 핵심적인 메서드입니다. 이 메서드가 호출되면 브라우저의 URL 표시줄이 즉시 지정된 url
로 변경되지만, 페이지는 새로고침되지 않습니다. 세 개의 인자를 받습니다.
state
(상태 객체): 새로운 기록 항목과 연결할 자바스크립트 객체입니다. 이 객체는 직렬화(Serializable) 가능해야 합니다. 사용자가 나중에 이 기록 항목으로 돌아왔을 때 (예: 뒤로 가기 클릭),popstate
이벤트의event.state
속성을 통해 이 객체를 다시 얻을 수 있습니다. 애플리케이션의 상태를 복원하는 데 필요한 모든 데이터를 여기에 담아두면 됩니다. 예를 들어{ view: 'profile', userId: 12345 }
와 같이 저장할 수 있습니다. 데이터의 크기 제한(브라우저마다 다르지만 일반적으로 수백 KB)이 있다는 점에 유의해야 합니다.title
(제목): 대부분의 브라우저에서 현재는 이 인자를 무시합니다. 하지만 향후 사용될 가능성을 대비해 명시적으로 빈 문자열(''
)이나 간략한 설명을 넣어주는 것이 좋습니다.url
(URL): 새로운 기록 항목의 URL입니다. 이 URL은 절대 경로 또는 상대 경로일 수 있으나, 현재 페이지와 동일한 출처(Origin)를 가져야 합니다. 다른 도메인으로의 URL 변경은 보안상의 이유로 불가능합니다. 선택적으로 지정할 수 있으며, 생략하면 현재 URL이 그대로 유지됩니다.
// 예시: 사용자 프로필 모달을 열 때
const userProfile = { userId: 42, source: 'dashboard' };
const newUrl = '/dashboard/users/42';
history.pushState(userProfile, '사용자 프로필', newUrl);
console.log('History state pushed. New URL is', window.location.href);
2. history.replaceState(state, title, url)
: 현재 상태를 교체
replaceState()
는 pushState()
와 매우 유사하지만, 새로운 기록 항목을 추가하는 대신 현재 기록 항목을 지정된 state
, title
, url
로 교체(수정)한다는 점에서 다릅니다. 이는 사용자의 탐색 기록에 불필요한 항목이 쌓이는 것을 방지하고 싶을 때 유용합니다.
예를 들어, 사용자가 검색 결과 페이지에서 필터를 여러 번 적용하는 시나리오를 생각해 봅시다. 필터를 적용할 때마다 pushState()
를 사용하면 '필터1 적용', '필터1+2 적용', '필터1+2+3 적용'과 같이 여러 개의 기록이 쌓여, 뒤로 가기 버튼을 한 번만 눌러 필터 적용 전으로 돌아가고 싶은 사용자의 기대를 저버리게 됩니다. 이럴 때 replaceState()
를 사용하면 URL은 계속 업데이트되면서도 기록은 하나로 유지할 수 있습니다.
// 예시: 검색 필터 적용 시
function applyFilters(newFilters) {
const currentState = history.state || {};
const updatedState = { ...currentState, filters: newFilters };
const newUrl = generateUrlWithFilters(newFilters);
// 새로운 기록을 쌓지 않고 현재 기록을 업데이트
history.replaceState(updatedState, '필터 적용됨', newUrl);
}
3. popstate
이벤트: 사용자의 시간 여행을 감지
popstate
이벤트는 브라우저의 활성 기록 항목이 변경될 때 window
객체에서 발생합니다. 이는 사용자가 브라우저의 뒤로 가기/앞으로 가기 버튼을 클릭하거나, 자바스크립트로 history.back()
, history.forward()
, history.go()
메서드를 호출했을 때만 발생합니다.
매우 중요한 사실은 history.pushState()
나 history.replaceState()
를 호출하는 것만으로는 popstate
이벤트가 발생하지 않는다는 것입니다. 이 이벤트는 오직 사용자의 명시적인 탐색 액션에 대한 응답입니다.
이벤트 핸들러는 event
객체를 인자로 받으며, 이 객체의 state
속성을 통해 해당 기록 항목으로 이동하면서 복원된 상태 객체에 접근할 수 있습니다. 즉, pushState()
로 저장했던 바로 그 state
객체입니다.
window.addEventListener('popstate', function(event) {
if (event.state) {
// pushState로 저장했던 state 객체가 존재함
console.log('Navigated to state:', event.state);
if (event.state.modalOpen) {
// 모달을 닫는 로직 실행
closeModal();
} else if (event.state.view === 'profile') {
// 프로필 뷰를 렌더링하는 로직 실행
renderProfileView(event.state.userId);
}
} else {
// event.state가 null인 경우: 페이지가 처음 로드되었을 때의 상태
// 또는 pushState가 아닌 일반적인 탐색으로 들어온 경우
console.log('Initial page state.');
// 초기 상태로 UI를 되돌리는 로직 실행
showMainContent();
}
});
이제 History API의 동작 방식을 이해했으니, 왜 페이지 이동 시에 popstate
이벤트가 발생하는지 그 근본적인 원인을 추적해 볼 시간입니다.
근본 원인 추적: URL 해시(#)와 브라우저의 오래된 습관
문제의 핵심은 <a>
태그의 href
속성에 사용된 해시(#
), 즉 URL 단편(fragment)과 관련이 있을 가능성이 매우 높습니다. 원래 URL 해시는 페이지 내 특정 ID를 가진 요소로 스크롤을 이동시키는 앵커(anchor) 역할을 위해 만들어졌습니다. 예를 들어, <a href="#section2">
를 클릭하면 브라우저는 id="section2"
인 요소로 화면을 이동시킵니다. 이 과정에서 페이지 전체는 새로고침되지 않습니다.
과거 SPA 프레임워크 초기 모델(예: 초기 AngularJS)에서는 History API가 표준화되기 전에 "해시뱅(hashbang)" #!
트릭을 사용하여 라우팅을 구현하기도 했습니다. 주소의 해시 부분만 변경하면 페이지가 새로고침되지 않는다는 특징을 이용한 것입니다. 이처럼 브라우저는 해시 변경을 특별하게 취급하는 오랜 역사를 가지고 있습니다.
이제 문제 시나리오를 다시 재구성해 봅시다.
- 사용자가
/page-a
에 있습니다. 모달 열기
링크를 클릭합니다. 많은 개발자들이 자바스크립트로 제어할 링크의href
에 의미 없는 값으로#
을 사용하는 관습이 있습니다.- 자바스크립트 클릭 핸들러에서
event.preventDefault()
를 호출하여#
의 기본 동작(페이지 상단으로 이동)을 막고,history.pushState({modal: true}, '', '/page-a#modal')
를 실행합니다. - 이제 주소창의 URL은
/page-a#modal
이 되고,popstate
리스너가 활성화됩니다. - 이 상태에서 사용자가
다른 페이지로
링크를 클릭합니다.
여기서 브라우저 내부에서는 무슨 일이 일어날까요? 브라우저는 /page-a#modal
에서 /page-b
로의 탐색을 처리해야 합니다. 이 과정에서 브라우저 엔진, 특히 크롬의 엔진은 현재 문서(/page-a
)가 언로드되기 전에 URL에서 해시 부분(#modal
)이 제거되는 과정을 일종의 '상태 변화'로 해석할 수 있습니다. 즉, 전체 페이지 이동의 일부로서 해시가 사라지는 것을 기존의 popstate
메커니즘이 감지하고 이벤트를 발생시키는 것으로 추정됩니다.
이것은 엄밀히 말해 버그라기보다는, 새로운 History API와 기존의 해시 탐색 메커니즘 간의 상호작용이 빚어낸 경계 조건(edge case)에서의 동작 방식이라고 볼 수 있습니다. 브라우저가 현재 페이지를 떠나기 전에, 이전 상태(/page-a#modal
)에서 다음 상태(/page-b
로 가기 전의 중간 단계, 즉 해시가 없는 /page-a
)로의 전환이 발생했다고 판단하여 popstate
를 트리거하는 것입니다. 이때 popstate
이벤트의 state
객체는 보통 null
이 되는데, 이는 pushState
로 명시적으로 정의된 상태가 아닌, 해시가 제거된 초기 상태로 돌아간 것으로 간주하기 때문입니다.
따라서, 문제의 근본적인 방아쇠는 자바스크립트 액션을 위해 사용된 <a href="#">
태그와 그로 인해 생성된 해시가 포함된 URL 상태에서 다른 페이지로 이동하는 행위라고 결론 내릴 수 있습니다.
해결 방안: 간단한 수정부터 견고한 설계까지
원인을 파악했으니, 해결은 비교적 명확해집니다. 몇 가지 접근법이 있으며, 상황의 복잡도와 프로젝트의 코드 스타일에 따라 적절한 방법을 선택할 수 있습니다.
1. 가장 간단하고 효과적인 해결책: href
속성 수정
문제의 직접적인 원인이 된 <a href="#">
사용을 중단하는 것이 가장 깔끔한 해결책입니다. 자바스크립트로만 제어될 뿐, 실제로 어디론가 이동할 목적이 없는 링크에는 다음과 같은 대안을 사용할 수 있습니다.
A) <button>
요소 사용
의미론적으로(semantically) 가장 올바른 방법입니다. 어떤 액션(action)을 트리거하는 것은 링크(link)의 역할이 아니라 버튼(button)의 역할입니다. <a>
태그 대신 <button>
태그를 사용하세요. href
속성 자체가 없으므로 문제의 원인이 원천적으로 사라집니다.
모달 열기
type="button"
을 명시하는 것이 좋습니다. 그렇지 않으면 <form>
태그 안에서 기본값인 type="submit"
으로 동작하여 폼을 제출하려고 할 수 있습니다. <a>
태그에 적용했던 CSS 스타일을 <button>
에도 일관성 있게 적용하기 위한 약간의 스타일 조정이 필요할 수 있습니다.
B) `href="javascript:void(0);"` 사용
디자인이나 구조적인 이유로 꼭 <a>
태그를 사용해야만 한다면, href
속성값을 javascript:void(0);
으로 설정하는 것이 좋은 대안입니다. void
연산자는 주어진 표현식을 평가한 후 undefined
를 반환합니다. 따라서 javascript:void(0);
는 아무것도 하지 않는 자바스크립트 코드를 실행하고 undefined
를 반환하므로, 브라우저는 아무런 탐색 동작도 수행하지 않습니다. href="#"
와 달리 URL에 아무런 변화도 일으키지 않아 문제를 회피할 수 있습니다.
모달 열기
모달 열기
이 방법은 동작은 하지만, 접근성 측면에서는 스크린 리더가 이를 '링크'로 읽어주기 때문에 사용자에게 혼동을 줄 수 있어 보다는 덜 권장됩니다.
2. 로직 기반의 견고한 해결책: `beforeunload` 이벤트 활용
HTML 마크업을 직접 수정하기 어렵거나, 애플리케이션의 다른 부분에서 여전히 #
를 사용해야 하는 복잡한 상황이라면 자바스크립트 로직으로 문제를 해결할 수 있습니다. 핵심 아이디어는 '페이지가 곧 언로드될 것'이라는 사실을 popstate
핸들러에게 알려주는 것입니다.
beforeunload
이벤트는 문서와 그 리소스들이 언로드되기 직전에 window
에서 발생합니다. 이 이벤트를 사용하여 '탐색 플래그'를 설정하고, popstate
핸들러에서는 이 플래그를 확인하여 페이지 이동 중에 발생한 popstate
이벤트를 무시하도록 만들 수 있습니다.
let isNavigatingAway = false;
// 사용자가 페이지를 떠나려고 할 때 플래그를 true로 설정
window.addEventListener('beforeunload', () => {
isNavigatingAway = true;
});
window.addEventListener('popstate', (event) => {
// 페이지 이동 중에 발생한 popstate 이벤트는 무시
if (isNavigatingAway) {
console.log('Ignoring popstate event due to page navigation.');
return;
}
// 여기에 정상적인 popstate 처리 로직을 구현
if (event.state && event.state.modalOpen) {
console.log('Closing modal via history back button.');
closeModal();
} else {
// ... 기타 상태 복원 로직
}
});
이 방법은 마크업에 의존하지 않고 오직 로직으로만 문제를 제어하므로 매우 견고합니다. 페이지 이동(링크 클릭, 주소창 직접 입력, 북마크 이동 등)과 뒤로/앞으로 가기 탐색을 명확하게 구분할 수 있게 해줍니다.
3. 방어적 프로그래밍: 이벤트 리스너 정리
더 나아가, 컴포넌트 기반의 최신 프레임워크(React, Vue, Svelte 등)에서는 컴포넌트가 파괴(unmount, destroy)될 때 자신이 등록했던 이벤트 리스너를 직접 정리하는 것이 모범 사례입니다. 이는 메모리 누수를 방지하고 예기치 않은 동작을 막는 데 도움이 됩니다.
바닐라 자바스크립트에서도 비슷한 원칙을 적용할 수 있습니다. popstate
리스너가 특정 컴포넌트나 기능에 종속적이라면, 해당 기능이 더 이상 필요 없을 때 리스너를 제거하는 것입니다. 예를 들어 beforeunload
이벤트가 발생했을 때 popstate
리스너를 아예 제거해 버릴 수 있습니다.
const handlePopstate = (event) => {
// ... popstate 처리 로직
console.log('Popstate event handled.');
};
// 페이지 로드 시 또는 필요 시점에 리스너 등록
window.addEventListener('popstate', handlePopstate);
// 페이지를 떠나기 전에 리스너 제거
window.addEventListener('beforeunload', () => {
console.log('Removing popstate listener before page unloads.');
window.removeEventListener('popstate', handlePopstate);
});
이 방법은 리소스 관리에 가장 충실하지만, 애플리케이션 전역에서 단 하나의 popstate
핸들러만 사용하고 관리하는 경우라면 '플래그'를 사용하는 2번 방법이 더 간결하고 직관적일 수 있습니다.
결론: 작은 차이가 만드는 큰 안정성
Ajax와 History API를 활용한 동적 웹 애플리케이션 개발은 사용자에게 훌륭한 경험을 선사하지만, 브라우저의 내부 동작 방식과 역사적 맥락에 대한 깊은 이해를 요구합니다. 페이지 이동 시 발생하는 의도치 않은 popstate
이벤트는 이러한 복잡성을 단적으로 보여주는 사례입니다.
우리는 이 문제의 원인이 <a href="#">
와 같은 구식 관행과 현대적인 History API의 상호작용에서 비롯된 브라우저의 동작 특성일 가능성이 높다는 것을 분석했습니다. 그리고 이 문제를 해결하기 위한 여러 가지 접근법을 살펴보았습니다.
핵심 요약:
- 문제 현상:
history.pushState
로 상태를 변경한 후, 다른 페이지로 이동할 때 예기치 않은popstate
이벤트가 발생한다. - 주요 원인:
<a href="#">
와 같이 URL 해시를 건드리는 링크 요소를 자바스크립트 트리거로 사용하고, 이로 인해 해시가 포함된 URL 상태에서 다른 페이지로 이동하기 때문. - 가장 좋은 해결책: 의미론적으로 올바른
<button>
태그를 사용하거나, 불가피하게<a>
태그를 써야 한다면href="javascript:void(0);"
으로 설정하여 URL 해시와의 상호작용을 원천적으로 차단한다. - 견고한 대안:
beforeunload
이벤트를 활용하여 페이지 이동을 감지하는 플래그를 설정하고,popstate
핸들러에서 이 플래그를 확인하여 불필요한 실행을 막는다.
사소해 보이는 href="#"
코드 한 줄이 디버깅하기 까다로운 버그의 원인이 될 수 있음을 기억하는 것이 중요합니다. 웹 표준을 정확히 이해하고, 각 HTML 요소의 의미에 맞게 사용하며, 애플리케이션 상태 변화의 생명 주기를 신중하게 관리하는 습관은 예측 가능하고 안정적인 웹 애플리케이션을 구축하는 튼튼한 기반이 될 것입니다. 이 글이 당신의 코드에서 발생했던 미스터리한 버그를 해결하고, 더 나은 웹을 만드는 데 작은 도움이 되기를 바랍니다.
0 개의 댓글:
Post a Comment