이전 화면 복귀 시 접근성 초점 불일치 해결하기 4부 - Next.js와 Vue.js
안녕하세요. 엔비전스입니다. 지난 3부에서는 Android View System과 Jetpack Compose 환경에서 화면 전환 시 접근성 초점을 복원하는 방법을 살펴보았습니다. View System에서는 Activity와 Fragment 구조를 나누어 보고, Jetpack Compose에서는 LazyColumn, FocusRequester, stable key를 활용해 목록으로 돌아온 뒤 적절한 항목으로 초점을 복원하는 흐름을 정리했습니다.
이번 4부에서는 같은 문제를 웹 환경에서 다룹니다. 웹에는 Next.js, Vue, Angular, Svelte 등 선택지가 많지만, 이 글에서는 실제 서비스에서 가장 널리 쓰인다고 판단한 Next.js와 Vue.js를 위주로 안내하겠습니다.
먼저 Next.js를 살펴보고, 이어서 Vue.js를 보겠습니다. 나머지 프레임워크도 "대상을 식별하고, 렌더링이 끝난 뒤 focus()를 호출한다"는 원칙은 같습니다.
웹에서도 핵심 원리은 같습니다. 사용자가 목록에서 어떤 게시글을 열었는지 기록하고, 상세 화면에서 목록으로 돌아왔을 때 가능하면 마지막을 누른 요소 즉 상세 게시물로 이동하기 위해 눌렀던 요소로 초점을 되돌리는 것입니다.
웹에서는 iOS나 Android view 시스템처럼 운영체제 접근성 API를 직접 호출하지 않고, 브라우저의 키보드 포커스와 DOM 렌더링 흐름에 의존합니다. 그래서 특정 게시글 링크에 focus()를 걸 수 있도록 대상을 식별하고, 목록 데이터가 다시 렌더링된 뒤에 초점을 요청하는 구성이 중요합니다.
특히 삭제 후 복귀 상황에서는 원래 게시글이 더 이상 존재하지 않습니다. 이때는 삭제 전 목록에서의 위치, 즉 같은 index의 항목을 먼저 찾습니다. 삭제된 항목 뒤에 다음 게시글이 있으면 그 게시글이 같은 index로 올라오고, 다음 게시글이 없으면 index를 마지막 항목으로 보정해 이전 게시글로 보냅니다.
웹에서 사용하는 접근성 초점 API 알아보기
너무나 기본이긴 하지만 본격적인 구현에 앞서, 웹 환경에서 목록 복귀 초점을 다룰 때 사용하는 핵심 요소를 먼저 정리하겠습니다.
1. DOM focus()
웹에서는 특정 요소로 키보드 포커스를 이동시키기 위해 HTMLElement.focus()를 사용합니다.
const target = document.querySelector( `[data-post-focus-id="${postId}"]` )
target?.focus()
이 코드는 data-post-focus-id 속성을 가진 게시글 링크를 찾아 포커스를 이동합니다. 게시글 목록의 각 항목이 실제 링크나 버튼처럼 포커스 가능한 요소라면, focus() 한 번으로 키보드 사용자와 스크린 리더 사용자가 같은 위치에서 탐색을 이어갑니다.
역시 이것도 기본적인 부분이지만 한 가지 짚고 넘어가겠습니다. 초점을 받기 위해서는 반드시 키보드 접근성이 먼저 갖춰져 있어야 합니다. 게시글처럼 클릭할 수 있는 요소는 가능하면 네이티브 링크나 버튼으로 구현해야 합니다. 부득이하게 다른 요소를 클릭 가능한 대상으로 사용한다면 tabindex="0"을 지정해 Tab 이동 순서에 포함하고, Enter나 Space 키 입력도 함께 처리해야 합니다.
다만 당연하게도 focus()는 DOM에 실제로 존재하는 요소에서만 동작합니다. 서버 렌더링 이후 클라이언트 hydration이 완료되기 전이거나, Vue에서 상태 변경 후 DOM 반영이 끝나기 전이라면 대상 요소를 찾지 못할 수 있습니다. 그래서 Next.js에서는 useEffect와 requestAnimationFrame을 사용하고, Vue.js에서는 nextTick()과 requestAnimationFrame을 사용해 렌더링이 끝난 뒤 초점을 이동합니다.
반대로 사용자가 Tab 키로 직접 이동할 필요는 없지만 스크립트로만 초점을 받아야 하는 보조 대상에는 tabindex="-1"을 사용합니다. 목록이 비어 있거나 삭제 후 마땅한 게시글 링크를 찾지 못하는 경우가 여기에 해당합니다.
게시글 목록
tabindex="-1"은 일반 Tab 이동 순서에서는 빠지지만, 스크립트로 focus()를 호출할 수 있게 해 줍니다. 다만 이 속성을 어떤 요소에 거느냐가 중요합니다. tabindex="-1"이 붙은 요소는 그 자체로 초점 가능한 노드가 되고, 스크린 리더는 그 노드의 접근성 경계를 요소가 차지한 영역 전체로 잡습니다. 그래서 큰 상위 컨테이너에 걸면 컨테이너 영역 전체가 하나의 초점 대상이 되어 버립니다.
이 차이는 특히 Android 모바일 웹에서, 스크린 리더 사용자가 화면을 터치해 탐색할 때 두드러집니다. 안드로이드 TalkBack의 터치 탐색은 노드의 접근성 경계를 기준으로 초점을 잡기 때문에, 큰 컨테이너에 tabindex="-1"이 걸려 있으면 그 안에서 자식이 없는 빈 패딩이나 여백을 터치해도 초점이 컨테이너 전체로 가버립니다. 옆에 있는 버튼을 누르려다 조금만 빗나가도 그 큰 영역이 초점을 가로챕니다.
따라서 초점은 컨테이너가 아니라, 실제로 초점을 옮기려는 대상에 거ㄹ어야 합니다. 게시글이 모두 삭제되어 초점을 보낼 항목이 없을 때처럼 예외적으로 보조 초점 대상이 필요하더라도, 제목이나 "게시글이 없습니다." 같은 작은 텍스트 요소로 초점을 보내는 것을 권장합니다.
2. URL 검색 파라미터와 sessionStorage
웹에서는 화면 이동이 URL과 단단히 묶여 있습니다. 그래서 복귀 초점 대상을 URL 검색 파라미터에 실어 두면, 상세에서 목록으로 돌아올 때 필요한 정보를 안정적으로 넘길 수 있습니다.
일반 복귀에서는 원래 게시글 ID를 사용합니다.
/posts?page=1&focusPostId=42&focusReason=return
삭제 후 복귀에서는 원래 게시글이 사라졌으므로 ID 대신 목록에서의 index를 사용합니다.
/posts?page=1&focusPostIndex=0&focusReason=deleted
focusReason은 초점 이동 이유를 표시하는 값입니다. 이 값으로 일반 복귀와 삭제 후 복귀를 가르고, 그에 따라 초점 대상 계산과 임시 상태 정리를 다르게 처리합니다.
URL 검색 파라미터만으로는 브라우저 뒤로가기 흐름을 모두 다루기 어렵습니다. 사용자가 게시글 링크를 열고 브라우저 뒤로가기로 목록으로 돌아오는 경우, 상세 화면에서 명시적으로 복귀 링크를 누르지 않을 수 있기 때문입니다. 이 흐름을 위해 목록에서 상세로 진입하는 순간 sessionStorage에도 같은 맥락을 저장합니다.
window.sessionStorage.setItem( STORAGE_KEY, JSON.stringify({ postId, postIndex, page, createdAt: Date.now() }) )
이렇게 하면 명시적인 "목록으로 돌아가기" 링크는 URL 검색 파라미터가 맡고, 브라우저 뒤로가기는 sessionStorage가 받칩니다.
아키텍처별 초점 복원 전략
웹에서는 iOS나 Android처럼 독립 화면과 단일 화면의 차이를 중심으로 나누기보다는, 프레임워크의 라우팅과 렌더링 방식에 따라 구현 위치가 달라집니다. 여기서는 Next.js App Router 구조를 먼저 살펴보고, 이어서 Vue Router 기반 Vue.js 구조를 다루겠습니다.
1. Next.js App Router 구조
Next.js에서는 목록 페이지와 상세 페이지가 App Router의 서로 다른 route로 구성됩니다. 목록은 서버 컴포넌트에서 데이터를 불러오고, 실제 초점 이동은 클라이언트 컴포넌트인 PostListFocusManager가 담당합니다.
구현의 핵심 흐름은 다음과 같습니다.
1단계: 목록에서 상세로 진입할 때 복귀 맥락 저장
목록의 각 게시글 링크에는 게시글 ID와 현재 index, 현재 page를 함께 전달합니다. 상세 URL에는 returnPage와 returnIndex를 붙이고, 동시에 sessionStorage에도 같은 정보를 저장합니다.
const detailParams = new URLSearchParams({ returnPage: String(page), returnIndex: String(postIndex) })
<PostFocusLink href={`/posts/${post.id}?${detailParams.toString()}`} postId={post.id} postIndex={postIndex} page={page}
// 게시글 행 내용
PostFocusLink는 사용자가 링크를 눌러 페이지가 변경될 때 복귀 맥락을 저장합니다.
function rememberFocusTarget() { const target = { postId, postIndex, page, createdAt: Date.now() }
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify(target))
}
이렇게 페이지가 변경된 순간 postId, index, page를 sessionStorage에 적어 두는 것은, 나중에 목록으로 돌아왔을 때 "어느 항목에서 벗어났는지"를 단서로 그 위치에 초점을 되돌리기 위해서입니다.
2단계: 상세에서 목록 복귀 URL 만들기
상세 화면에서 "목록으로 돌아가기" 링크를 만들 때는 returnPage를 다시 page로 사용하고, 현재 게시글 ID를 focusPostId로 전달합니다.
const postsHref = buildPostsHref({ page: listPage, focusPostId: post.id, focusReason: "return" })
← 목록으로 돌아가기 이 흐름은 게시글이 삭제되지 않은 일반 복귀입니다. 목록으로 돌아오면 focusPostId에 해당하는 게시글 링크를 찾아 focus()를 호출합니다. 목록이 새로고침되더라도 같은 ID의 게시글이 남아 있는 한, 초점은 정확히 그 항목으로 돌아갑니다.
3단계: 삭제 후 같은 위치의 항목으로 리다이렉트
삭제 후에는 원래 게시글 ID로 초점을 복원할 수 없습니다. 해당 게시글은 이미 목록에서 사라졌기 때문입니다. 그래서 삭제 요청에는 상세 진입 당시의 returnPage와 returnIndex를 함께 전달합니다.
서버 액션에서 삭제가 완료되면 목록 경로로 redirect하면서 focusPostIndex와 focusReason을 전달합니다.
redirect( buildPostsHref({ page: options.returnPage ?? 1, focusPostIndex: options.returnIndex ?? 0, focusReason: "deleted" }) )
삭제 후에 같은 index를 다시 쓰는 데는 이유가 있습니다. 목록에서 첫 번째 항목을 삭제하면 원래 두 번째 항목이 첫 번째 자리로 올라옵니다. 그래서 삭제 전 index 0으로 돌아가면 초점이 자연스럽게 다음 항목으로 이동합니다.
반대로 마지막 항목을 삭제한 경우에는 같은 index에 더 이상 항목이 없습니다. 이때는 index를 현재 목록 길이에 맞게 보정하여 마지막 항목, 즉 삭제된 항목의 이전 게시글로 초점을 보냅니다.
function getPostLinkByIndex(postIndex: number): HTMLElement | null { const links = Array.from( document.querySelectorAll("[data-post-focus-id]") )
const clampedIndex = Math.min(Math.max(0, postIndex), links.length - 1)
return links[clampedIndex] ?? null
}
이 방식이면 previousPostId, nextPostId를 URL에 따로 싣지 않고도 삭제 후 다음 항목을 우선 선택하고, 없으면 이전 항목으로 넘어가는 흐름을 만듭니다. 뒤에 나오는 비교 표와 정리에서 "다음 항목 우선, 없으면 이전 항목"이라고 부르는 동작이 바로 이 index 보정의 결과입니다.
삭제로 페이지 수가 줄어 기존 page가 통째로 사라지는 경우는 서버 계층에서 처리합니다. Next.js 목록은 서버 컴포넌트에서 렌더되는데, 요청한 page가 범위를 벗어나면 서버가 Math.min(requestedPage, totalPages)로 page를 마지막 페이지에 맞춰 클램프하고, 이때 초점 index도 마지막 항목으로 다시 잡아 줍니다. 여기에 더해, 요청 page가 실제 범위를 벗어났다면 서버에서 redirect로 주소까지 마지막 페이지로 교정합니다. 그래서 빈 페이지가 생기지 않습니다. 뒤에서 볼 Vue도 같은 페이지 보정을 하지만, Vue는 클라이언트에서 router.replace로 처리하는 반면 Next.js는 이 일을 서버 계층의 클램프와 redirect로 끝낸다는 차이가 있습니다.
4단계: 목록 렌더링 후 초점 이동
목록 페이지는 검색 파라미터를 파싱해 PostListFocusManager에 전달합니다.
PostListFocusManager는 다음 순서로 초점 대상을 찾습니다. 먼저 focusPostId가 있으면 ID로 찾고, 없으면 focusPostIndex로 찾습니다. 둘 다 없다면 sessionStorage에 저장된 값을 읽어 브라우저 뒤로가기 흐름을 처리합니다.
if (focusPostId !== undefined) { target = getPostLinkById(focusPostId) }
if (!target && focusPostIndex !== undefined) { target = getPostLinkByIndex(focusPostIndex) }
if (!target && focusPostId === undefined && focusPostIndex === undefined) { const storedTarget = readStoredFocusTarget(currentPage)
if (storedTarget) {
target =
getPostLinkById(storedTarget.postId) ??
getPostLinkByIndex(storedTarget.postIndex)
}
}
대상이 결정되면 requestAnimationFrame 안에서 focus()를 호출합니다.
window.requestAnimationFrame(() => { target?.focus() scheduleFocusStateClear(replaceFocusUrl) })
마지막으로 focusPostId, focusPostIndex, focusReason 같은 임시 검색 파라미터는 초점 이동 후 제거합니다. 남겨 두면 새로고침이나 다른 상태 변경이 일어날 때마다 같은 초점 이동이 반복됩니다.
2. Vue.js 구조
Vue.js에서도 원칙은 같습니다. 목록에서 상세 화면으로 들어갈 때 복귀 맥락을 저장하고, 상세에서 목록으로 돌아올 때 URL 검색 파라미터를 전달하며, 목록 데이터 로딩과 DOM 반영이 끝난 뒤 focus()를 호출합니다.
다만 Next.js와 달리 서버 액션의 redirect 대신 Vue Router의 route query와 router.replace를 사용합니다. 또한 DOM 반영 완료를 기다리기 위해 nextTick()을 사용합니다.
구현의 핵심 흐름은 다음과 같습니다.
1단계: RouterLink에서 복귀 정보 저장
목록의 각 게시글 RouterLink에는 returnPage와 returnIndex를 query로 전달합니다. 동시에 클릭과 Enter 키 입력 시 sessionStorage에 복귀 맥락을 저장합니다.
<RouterLink class="post-card" :data-post-focus-id="post.id" :to="{ name: 'post-detail', params: { id: post.id }, query: { returnPage: String(pagination.page), returnIndex: String(index) } }" @click="rememberPostFocus(post, index)" @keydown.enter="rememberPostFocus(post, index)"
// 게시글 행 내용
저장하는 값은 Next.js와 같습니다.
function rememberPostFocus(post: Post, postIndex: number) { rememberFocusTarget({ postId: post.id, postIndex, page: pagination.value.page, createdAt: Date.now() }) }
이 정보는 브라우저 뒤로가기로 목록에 돌아왔을 때 쓰입니다. URL 검색 파라미터가 없더라도, 같은 page에서 유효 기간이 지나지 않은 저장 값이 있으면 그 게시글로 초점을 되돌립니다.
2단계: 상세에서 목록 복귀와 삭제 후 이동 분리
일반 복귀 링크에서는 게시글 ID를 focusPostId로 넘깁니다.
const backToList = computed(() => ({ name: "posts", query: { page: String(returnPage.value), focusPostId: String(post.value?.id ?? postId.value ?? ""), focusReason: "return" } }))
삭제 후에는 ID가 아니라 index를 넘깁니다. 삭제 API 호출이 성공하면 router.replace로 목록 route로 이동하면서 focusPostIndex와 focusReason을 전달합니다.
await deletePost(post.value.id) await router.replace({ name: "posts", query: { page: String(returnPage.value), focusPostIndex: String(returnIndex.value), focusReason: "deleted" } })
이렇게 일반 복귀와 삭제 후 복귀를 나눠 두면, 목록에서는 focusPostId와 focusPostIndex 중 어떤 값이 넘어왔는지에 따라 초점 대상을 찾고, focusReason으로 삭제 후 복귀 여부를 가립니다.
3단계: 목록 데이터 로딩과 페이지 보정 후 초점 대상 찾기
Vue 목록 화면은 route.fullPath 변화를 감지해 목록을 다시 불러옵니다. 데이터를 받은 뒤에는 요청한 페이지가 실제 마지막 페이지보다 큰지 확인합니다.
watch(() => route.fullPath, loadPosts, { immediate: true })
async function loadPosts() { const response = await fetchPosts(currentPage.value, POSTS_PER_PAGE)
if (await redirectToLastAvailablePage(response)) {
return
}
posts.value = response.data
pagination.value = {
...response.pagination,
totalPages: Math.max(1, response.pagination.totalPages)
}
await nextTick()
window.requestAnimationFrame(() => focusTarget())
}
페이지 보정이 필요한 이유는 어떤 페이지에 한 건만 남았을 때 그 게시글을 삭제하면 드러납니다. 예를 들어 게시글이 11개라 2페이지에 1건만 있는 상태에서 그 1건을 지우면, 2페이지 자체가 통째로 사라집니다. 이렇게 페이지의 마지막 한 건이 없어지면 전체 페이지 수가 한 단계 줄어, 삭제 전 page가 더 이상 존재하지 않을 수 있습니다. 이 경우 실제 마지막 페이지로 옮긴 뒤, 삭제 후 복귀라는 의미를 살리기 위해 focusPostIndex를 마지막 index로 다시 전달합니다.
const query = { page: String(totalPages), focusPostIndex: String(POSTS_PER_PAGE - 1), focusReason: "deleted" }
await router.replace({ name: "posts", query })
이렇게 하면 삭제로 페이지 수가 줄어든 경우에도 초점을 잃어버리지 않고, 현재 남아 있는 마지막 게시글로 초점이 넘어갑니다.
4단계: DOM 대상에 focus()를 호출하고 임시 파라미터 제거
Vue의 focusTarget()도 Next.js와 같은 순서로 동작합니다. focusPostId를 먼저 찾고, 없으면 focusPostIndex를 찾고, 둘 다 없으면 sessionStorage를 확인합니다.
if (focusPostId !== undefined) { target = getPostFocusTargetById(focusPostId) }
if (!target && focusPostIndex !== undefined) { target = getPostFocusTargetByIndex(focusPostIndex) }
if (!target && focusPostId === undefined && focusPostIndex === undefined) { const storedTarget = takeStoredFocusTarget(pagination.value.page)
if (storedTarget) {
target =
getPostFocusTargetById(storedTarget.postId) ??
getPostFocusTargetByIndex(storedTarget.postIndex)
}
}
대상이 있으면 focus()를 호출합니다. 초점 복원 요청이 있었지만 대상을 찾지 못한 경우에는 목록 제목 같은 보조 대상으로 폴백합니다.
if (!target && shouldClearFocusState) { focusFallback() } else { target?.focus() }
if (target || shouldClearFocusState) { clearFocusSearchParams() }
Vue에서는 clearFocusSearchParams()가 window.history.replaceState()를 사용해 focusPostId, focusPostIndex, focusReason만 제거합니다. page 같은 일반 검색 파라미터는 유지해야 사용자가 있던 페이지 맥락이 사라지지 않습니다.
Next.js와 Vue.js의 접근 방식 비교
지금까지의 내용을 프레임워크별로 정리하면 다음과 같습니다.
| 구분 | Next.js | Vue.js |
|---|---|---|
| 목록에서 상세 진입 | PostFocusLink에서 sessionStorage 저장 | RouterLink 이벤트에서 sessionStorage 저장 |
| 일반 복귀 | focusPostId와 focusReason=return을 포함한 Link href | focusPostId와 focusReason=return을 포함한 route query |
| 삭제 후 복귀 | 서버 액션 deletePost 후 redirect | deletePost 호출 후 router.replace |
| 삭제 후 초점 대상 | focusPostIndex를 현재 목록 길이에 맞게 보정 | focusPostIndex를 현재 목록 길이에 맞게 보정 |
| 페이지 경계 처리(삭제로 페이지가 사라질 때) | 서버에서 클램프 후 redirect로 마지막 페이지로 교정 | 클라이언트에서 router.replace로 마지막 페이지로 보정 |
| 렌더링 완료 대기 | useEffect + requestAnimationFrame | nextTick() + requestAnimationFrame |
| 브라우저 뒤로가기 대응 | sessionStorage의 postId, postIndex, page 사용 | sessionStorage의 postId, postIndex, page 사용 |
| 임시 상태 정리 | router.replace와 history.replaceState로 focus 파라미터 제거 | history.replaceState로 focus 파라미터 제거 |
| 폴백 초점 | tabIndex=-1이 지정된 텍스트 요소 | ref로 잡은 텍스트 요소 |
정리하자면 프레임워크는 다르지만 원칙은 거의 같습니다. 일반 복귀에서는 ID를 기준으로 원래 게시글을 정확히 찾고, 삭제 후 복귀에서는 index를 기준으로 같은 위치의 다음 항목을 찾습니다. 같은 위치에 항목이 없으면 현재 목록의 마지막 항목으로 보정하여 이전 항목으로 이동합니다.
여기서 주의할 점은 index를 일반 복귀의 기본 식별자로 사용하지 않는 것입니다. 목록이 새로 정렬되거나 새 게시글이 추가되면 같은 index가 전혀 다른 게시글을 가리킬 수 있습니다. 그래서 일반 복귀는 postId를 우선 사용하고, 삭제 후처럼 원래 ID가 사라진 상황에서만 index를 폴백 기준으로 사용하는 편이 안전합니다.
또 하나 중요한 점은 앞에서 다룬 것ㅊ러럼 초점 이동 시점입니다. 목록으로 돌아온 직후 바로 focus()를 호출하면 대상 링크가 아직 DOM에 없을 수 있습니다. Next.js에서는 클라이언트 컴포넌트의 useEffect 이후 requestAnimationFrame을 사용하고, Vue.js에서는 데이터 반영 후 nextTick()과 requestAnimationFrame을 사용해 DOM 준비가 끝난 뒤 초점을 이동해야 합니다.
마지막으로 임시 검색 파라미터는 반드시 정리해야 합니다. focusPostId나 focusPostIndex가 URL에 계속 남아 있으면, 사용자가 새로고침하거나 다른 상태가 바뀔 때마다 초점 이동이 다시 일어납니다. 초점 복원이 한 번 끝났다면 page 같은 실제 탐색 상태는 그대로 두고, focus 관련 파라미터만 지웁니다.
지금까지 Next.js와 Vue.js 환경에서 게시판 상세 화면으로 이동했다가 목록으로 돌아올 때 접근성 초점을 복원하는 방법을 살펴보았습니다.
웹에서도 원칙은 iOS, Android와 같습니다. 사용자가 어느 항목에서 상세 화면으로 들어갔는지 기록하고, 돌아온 뒤 현재 목록에 존재하는 가장 적절한 대상으로 초점을 복원해야 합니다. 삭제되지 않았다면 원래 항목으로, 삭제되었다면 다음 항목으로, 다음 항목이 없다면 이전 항목으로 초점을 이어가면 됩니다.
본 시리즈가 모달 팝업에 대한 초점을 넘어 화면 간 초점에 대해서도 조금 더 접근성을 고려할 수 있는 동기부여를 제공하는 계기가 되었으면 합니다. 감사합니다.