JavaScript는 정적인 문서를 소프트웨어로 바꾸어 놓았습니다. 싱글 페이지 앱(SPA)은 즉각적인 반응을 보여줍니다. 전체 페이지 새로고침도, 화면이 하얗게 깜빡이는 현상도 없습니다. 하지만 그 속도에는 많은 팀이 간과하는 대가가 따릅니다. 웹의 기본적인 메커니즘이 부식되기 시작하는 것입니다. 내비게이션은 취약해지고, 검색 엔진은 경로를 따라가는 데 어려움을 겪습니다. 스크린 리더는 길을 잃습니다. 그리고 사용자들은 웹사이트처럼 보이지만 고장 난 데스크톱 애플리케이션처럼 작동하는 인터페이스에 갇히게 됩니다.

그 주범은 대개 onClick 핸들러가 달린 div입니다.

Div를 링크로 사용하지 마세요

div는 어떠한 의미론적(semantic) 의미도 담고 있지 않습니다. 그저 상자일 뿐입니다. 여기에 클릭 핸들러를 달아 사용자를 새로운 뷰로 이동시키는 데 사용한다면, 여러분은 브라우저에게 판지 상자를 문처럼 취급해 달라고 요구하는 것과 같습니다. 브라우저는 이를 거부합니다. 브라우저를 기반으로 만들어진 모든 도구 역시 마찬가지입니다.

스크린 리더는 div를 링크나 버튼으로 안내하지 않습니다. 그냥 건너뛰거나 일반 텍스트로 읽어버립니다. 음성으로 탐색하는 사용자는 이를 타겟팅할 수 없습니다. 발견 가능한 URL을 찾기 위해 페이지를 스캔하는 검색 엔진 크롤러는 따라갈 수 있는 것을 아무것도 발견하지 못합니다. 여러분의 경로는 존재하지 않는 것이나 다름없습니다.

설상가상으로, 사용자가 이미 알고 있는 동작들을 잃게 됩니다. 실제 링크는 사용자가 마우스 오른쪽 버튼을 클릭해 새 탭에서 열거나, 목적지를 북마크하거나, 주소를 복사하여 공유할 수 있게 해줍니다. 키보드 사용자는 Tab 키로 해당 요소에 도달하고 Enter 키로 여는 것을 기대합니다. div는 이 중 어느 것도 제공하지 않습니다. 설령 tabIndexrole="link", 그리고 키보드 리스너를 억지로 덧붙인다 해도, 여러분은 브라우저가 무료로 제공하는 기능을 서투르게 재구축하고 있는 것뿐입니다. 그리고 여러분은 반드시 예외 케이스를 놓치게 될 것입니다. 언제나 그렇듯이 말이죠.

목적지에는 앵커를, 동작에는 버튼을 사용하세요

HTML은 이미 이 문제를 해결했습니다. 혼란이 발생하는 이유는 두 요소 모두 클릭할 수 있는 것처럼 보이기 때문에 개발자들이 이를 서로 바꿔 쓸 수 있다고 생각하기 때문입니다. 하지만 그렇지 않습니다.

사용자를 새로운 URL로 이동시키고 싶을 때는 <a> 태그를 사용하세요. 시뮬레이션된 뷰 전환이나 상태 변경이 아니라, 실제 위치로 이동해야 합니다. href 속성에는 실제 주소가 포함되어야 합니다:

<a href="/docs">Documentation</a>

그게 전부입니다. 사용자가 어딘가로 이동하는 것이라면 링크를 사용하세요.

현재 페이지에서 무언가 일이 일어나는 경우에는 <button>을 사용하세요. 버튼은 다음과 같은 동작을 위한 것입니다:

  • 모달 열기
  • 폼 제출하기
  • 설정 저장하기
  • 메뉴 토글하기

링크는 목적지를 위한 것입니다. 버튼은 동작을 위한 것입니다. 이 둘을 혼용하면 인터페이스가 혼란스러워지고 사용자의 기대치를 저버리게 됩니다.

브라우저가 제 역할을 하게 두세요

현대의 브라우저는 수십 년간의 진화와 표준화의 결과물입니다. 브라우저는 여러분이 직접 만든 JavaScript보다 보안, 히스토리, 프리페칭(prefetching), 접근성을 훨씬 더 잘 처리합니다.

진정한 앵커 태그는 브라우저 히스토리 스택에 자동으로 정보를 전달합니다. 네이티브 컨텍스트 메뉴와 함께 작동합니다. 사용자가 요소에 마우스를 올리거나 포커스를 맞출 때 브라우저의 내장 프리페칭 알고리즘에 참여하여, 코드 한 줄 쓰지 않고도 앱이 더 빠르게 느껴지도록 만듭니다. 또한 링크를 여는 사용자의 기본 설정을 존중합니다. 비밀번호 관리자, 번역 도구, 읽기 모드와도 협력합니다.

이를 JavaScript 내비게이션 함수로 대체하면, 이 모든 기능의 혜택을 포기하는 것입니다. 단순히 기능을 잃는 것이 아니라, 사용자가 인터넷상의 다른 모든 사이트에서 쌓아온 습관을 버리도록 강요하는 것입니다. 이것은 기술적인 결정이 아닙니다. 적대적인 사용자 경험(UX)입니다.

프레임워크가 실제로 무엇을 렌더링하는지 확인하세요

React Router, Vue Router, Next.js Link 컴포넌트, SvelteKit. 이러한 도구들은 클라이언트 사이드 라우팅을 매우 쉽게 만들어 줍니다. 하지만 추상화는 실수를 낳습니다.

DOM을 검사하세요. 브라우저의 개발자 도구를 열어 프레임워크가 생성하는 요소를 살펴보세요. <Link> 컴포넌트는 최종 HTML에서 유효한 href 속성을 가진 실제 <a> 태그로 렌더링되어야 합니다. 만약 span, div 또는 적절한 href가 없는 다른 요소로 렌더링된다면, 여러분의 추상화는 실패한 것입니다. 컴포넌트를 수정하세요. 기본 동작을 재정의하세요. passHref prop이나 프레임워크의 대응되는 기능을 사용하세요. 검증 없이 프레임워크가 알아서 잘 해줄 것이라고 믿지 마세요.

이는 하이드레이션(hydration) 불일치 문제와도 관련이 있습니다. 서버는 링크를 렌더링했는데 클라이언트가 이를 링크가 아닌 것으로 하이드레이션한다면, 소스 코드에서는 HTML이 올바르게 보이지만 실제 DOM에서는 잘못되어 있어 추적하기 어려운 접근성 버그를 만들게 됩니다.

가짜 목적지를 금지하세요

사라지지 않는 패턴이 하나 있습니다: href="javascript:void(0)". 개발자들은 링크처럼 보이지만 버튼처럼 작동하는 무언가가 필요할 때 이를 사용합니다. 대개 버튼 스타일을 입히기 싫거나, 오래된 코드베이스의 요구 사항 때문입니다.

Stop. This is not a URL. It gives the browser no destination. It pollutes the history stack with unusable states. It breaks browser history and accessibility. It is a trap. If you need click behavior without navigation, you need a <button>. Style it to look however you want. CSS does not care whether the element is a button or a link. Your users do.

Write Text That Explains Where the User Is Going

The words inside your link matter. Screen reader users often pull up a list of every link on the page to scan quickly. If your links all say "Read more" or "Click here," that list becomes useless noise.

Be specific. Compare these:

  • Bad: <a href="/security/api-guide">Read more</a>
  • Good: <a href="/security/api-guide">Read the API security guide</a>

The second tells the user exactly what they will find. It gives search engines context about the destination page. It makes your link list navigable. Descriptive link text is one of the cheapest accessibility wins you can earn.

Test Like You Mean It

Architecture means nothing if you do not verify it.

First, test your keyboard flow. Unplug your mouse. Tab through every interactive element on your site. Every genuine link must show a visible focus outline, not a subtle glow that disappears against your background, but a clear ring that a tired eye can spot. Press Enter. It must activate the link. If Tab skips an element, or if Enter does nothing, you have a bug.

Second, test your routes at the server level. Client-side routing is a thin veneer. If a user bookmarks /dashboard/reports and returns tomorrow, or hits refresh, your server must know how to serve that page. Configure your reverse proxy or your server framework to fall back to your application shell for unknown paths, or serve the correct HTML directly. A dead 404 on refresh is not a minor bug. It is a broken promise.

JavaScript is a powerful layer