아티클

이전 화면 복귀 시 접근성 초점 불일치 해결하기 1부 - iOS UIKit

엔비전스 접근성 2026-03-18 09:50:00

안녕하세요. 엔비전스입니다.

앱이나 웹 환경에서 게시판, 설정 등 계층적 구조를 탐색할 때 스크린 리더 사용자가 흔히 겪는 불편함이 있습니다. 바로 상세 페이지(하위 화면)에 진입했다가 이전 목록(상위 화면)으로 복귀할 때, 기존에 읽고 있던 요소로 초점이 환원되지 않아 탐색 맥락을 상실하고 처음부터 다시 훑어야 하는 문제입니다.

비장애인 사용자는 이전 화면으로 돌아왔을 때 스크롤 위치가 유지되므로 시각적인 맥락을 잃지 않습니다. 반면, 보이스오버(VoiceOver)나 톡백(TalkBack)을 이용하는 스크린 리더 환경에서는 화면 전환 시 이전 초점의 위치가 자동으로 관리되지 않는 경우가 많습니다. 이로 인해 초점이 화면 최상단으로 초기화되어, 방금 전까지 읽고 있던 게시글이나 설정 항목을 찾기 위해 불필요한 재탐색을 반복해야만 합니다.

일반적인 접근성 진단이나 개발 과정에서 팝업(모달) 등장 시의 초점 이동은 비교적 잘 준수되는 편입니다. 하지만 전체 화면이 전환되거나 이전 페이지로 복귀하는 상황에서의 초점 유지는 종종 간과되곤 합니다. 단순히 화면 스크롤을 복원하는 것을 넘어, 특정 UI 요소에 접근성 초점을 정확히 일치시켜야 하므로 실무적인 구현 난도가 다소 높기 때문입니다.

그럼에도 불구하고, 우리는 모든 사용자가 차별 없이 편리하게 서비스를 이용할 수 있도록 보장할 책임이 있습니다. 더욱이 최근에는 AI 보조 도구 등을 활용해 개발 생산성을 높일 수 있는 환경이 조성된 만큼, 약간의 공수가 더 들더라도 이러한 세밀한 사용성까지 챙기는 것이 바람직한 방향일 것입니다.

이에 본 연재에서는 다소 난도가 있는 '화면 전환 시 접근성 초점 유지'를 주제로, iOS, Android, Web 각 플랫폼별 구현 방안을 심도 있게 다루고자 합니다. 이번 1부의 iOS UIKit을 시작으로, 2부 iOS SwiftUI, 3부 Android View System, 4부 Android Jetpack Compose, 마지막 5부 React 기반 Web 환경 순으로 시리즈를 이어나갈 예정입니다.

iOS UIKit 환경에서의 화면별 초점 유지 방안 살펴보기

UIKit 환경에서 게시글 본문(상세)에서 게시글 목록(리스트)으로 복귀하는 상황을 가정해 보겠습니다. 이때 핵심적으로 고려해야 할 사항은 아키텍처 구조입니다. 즉, 상세 화면과 목록 화면이 각각 별도의 뷰 컨트롤러(View Controller)로 분리되어 있는지, 아니면 단일 뷰 컨트롤러 내에서 뷰만 전환되는지에 따라 초점을 복원하는 접근 방식이 완전히 달라집니다.

1. 화면 전환 이벤트의 이해: screenChanged vs layoutChanged

iOS의 보이스오버에 초점 이동을 지시할 때 사용하는 대표적인 알림(Notification) 타입에는 .screenChanged와 .layoutChanged가 있습니다. 이 둘의 개념 차이를 명확히 이해하는 것이 중요합니다.

  • .screenChanged: 화면 전체가 새로운 화면으로 변경되었음을 알립니다. 이 알림을 보내면 보이스오버에서 "화면이 변경되었다"는 고유의 사운드(효과음)가 함께 출력되며 지정한 요소로 초점이 이동합니다. 사용자에게 새로운 맥락으로 진입했다는 명확한 청각적 피드백을 제공합니다.
  • .layoutChanged: 화면 내의 일부 레이아웃만 변경되었음을 알립니다. 별도의 화면 전환 사운드 없이, 지정한 요소로 조용히 초점만 이동시킵니다.

2. 아키텍처별 알림 발송 전략

앞서 언급한 아키텍처 구조에 따라 이 두 알림을 다르게 적용해야 합니다.

  • 뷰 컨트롤러 기반 (예: UINavigationController 사용):
    별도의 뷰 컨트롤러를 푸시(Push)했다가 팝(Pop)하여 돌아오는 경우, 시스템에서 이미 자체적으로 화면이 전환되었다는 이벤트를 발생시킵니다. 따라서 특정 게시글로 초점을 복원할 때 .screenChanged를 사용하면 화면 전환음이 중복으로 발생할 수 있습니다. 이 아키텍처에서는 .layoutChanged를 사용하여 시스템의 기본 알림에 더해 조용히 초점만 원하는 곳으로 이동시키는 것이 자연스럽습니다.

// UINavigationController 환경에서 복귀 시
UIAccessibility.post(notification: .layoutChanged, argument: targetCell)

  • 단일 화면 기반 (예: ContainerViewController 사용):
    단일 뷰 컨트롤러 내에서 내부 뷰(Child VC)만 교체하여 화면을 전환하는 경우, 시스템은 화면 전체가 변경되었다는 사실을 인지하지 못합니다. 따라서 이전 목록으로 돌아왔을 때 개발자가 명시적으로 .screenChanged를 호출해야만 사용자에게 "화면이 전환되었다"는 사운드 피드백과 함께 정확한 초점 이동을 제공할 수 있습니다.

// ContainerViewController 환경에서 내부 뷰 교체 시
UIAccessibility.post(notification: .screenChanged, argument: targetCell)

3. 위치(IndexPath)가 아닌 식별자(ID) 기반의 초점 추적

초점을 복원할 때 개발자들이 가장 흔히 겪는 시행착오 중 하나는 사용자가 선택한 셀의 행 번호(IndexPath)를 저장해두는 것입니다.

만약 사용자가 두 번째 게시글을 보고 있는 동안 서버에 새로운 글이 추가되어 목록이 갱신된다면 어떻게 될까요? 기존의 IndexPath는 완전히 다른 엉뚱한 게시글을 가리키게 됩니다.

따라서 위치가 아닌 데이터의 고유 식별자(예: Post.id)를 저장해야 합니다.

// 셀 선택 시점에 IndexPath가 아닌 고유 ID를 저장
let post = posts[indexPath.row]
lastSelectedPostId = post.id

또한, 사용자가 상세 화면에서 해당 게시글을 '삭제'하고 돌아올 수도 있습니다. 이를 대비해 진입 시점에 이전 게시글과 다음 게시글의 ID도 함께 저장(Fallback)해두면, 글이 삭제되었더라도 '이전 게시글 → 다음 게시글 → 작성 버튼' 순으로 탐색 흐름이 끊기지 않게 자연스러운 초점 이동을 구현할 수 있습니다.

// 삭제 상황을 대비해 이웃한 게시글의 ID도 함께 추적
neighborPostIds = (
previous: indexPath.row > 0 ? posts[indexPath.row - 1].id : nil,
next: indexPath.row < posts.count - 1 ? posts[indexPath.row + 1].id : nil
)

4. 실무 구현 시 반드시 챙겨야 할 주의사항

초점 복원을 구현할 때 안정성과 사용자 경험을 위해 다음 3가지 사항을 추가로 고려해야 합니다.

  1. 비동기 갱신을 고려한 딜레이 부여: 목록 데이터가 새로고침되거나 화면 전환 애니메이션이 진행 중일 때 초점을 이동시키면 명령이 무시될 수 있습니다. 네트워크 응답이나 뷰 렌더링이 완료될 수 있도록 약간의 딜레이를 준 뒤 초점 이동 코드를 실행해야 합니다.

DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
// 1초 대기 후 셀이 렌더링 된 상태에서 초점 이동 로직 실행
if let cell = self?.tableView.cellForRow(at: targetIndexPath) {
UIAccessibility.post(notification: .layoutChanged, argument: cell)
}
}

  1. 타이머 작업의 안전한 취소 (DispatchWorkItem): 1초의 딜레이를 기다리는 동안 사용자가 다른 화면으로 빠르게 다시 이동해버릴 수 있습니다. 이때 예약된 초점 이동 로직이 실행되면, 보이지 않는 화면의 요소로 초점이 강제 이동하는 '유령 초점' 문제가 발생합니다. 따라서 화면이 사라질 때 예약된 작업을 반드시 취소해야 합니다.

private var accessibilityWorkItem: DispatchWorkItem?

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

let workItem = DispatchWorkItem { [weak self] in /* 초점 이동 로직 */ }  
accessibilityWorkItem = workItem  
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem)

}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// 화면을 벗어나면 초점 이동 타이머를 안전하게 취소
accessibilityWorkItem?.cancel()
}

  1. 불필요한 반복 실행 방지: viewDidAppear 생명주기 메서드는 알림창(모달)이 닫히거나, 앱이 백그라운드에서 포그라운드로 복귀할 때 등 수시로 호출될 수 있습니다. 이때마다 초점이 이동하는 것을 막기 위해 '초점 복원 완료 여부'를 플래그(Flag) 상태로 관리하여, 목록으로 돌아온 최초 1회만 실행되도록 제어하는 것이 중요합니다.

private var hasRestoredFocus = false

override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)

// 이미 초점을 복원했다면 중복 실행 방지  
guard !hasRestoredFocus else { return }  
hasRestoredFocus = true

// ... 초점 복원 로직 실행 ...

}

지금까지 iOS UIKit 환경에서 스크린 리더 사용자를 위해 화면 전환 시 이전 탐색 초점을 유지하고 복원하는 방법에 대해 살펴보았습니다. 아키텍처에 따른 알림 타입의 선택, 고유 ID 기반의 추적, 그리고 생명주기와 비동기 상황을 고려한 안전한 구현까지, 조금은 까다롭지만 서비스의 접근성 품질을 크게 높일 수 있는 필수적인 고민들이었습니다.

다음 연재인 2부에서는 iOS SwiftUI 환경에서의 화면별 초점 유지 방안을 확인해 보겠습니다. 선언형 UI 프레임워크인 SwiftUI에서는 이러한 초점 관리를 어떻게 다르게 접근해야 하는지 상세히 다루어 보도록 하겠습니다. 감사합니다.

댓글 0
댓글을 작성하려면 해주세요.