데스크톱으로만 쓰던 개인 위키를 모바일에서 열어봤는데 처참했다. 사이드바가 콘텐츠를 밀어내고, 목차가 화면의 절반을 차지하고, 검색창은 작아서 누르기도 힘들었다. 모바일에서도 출근길에 컨텐츠를 읽을 수 있게 만드는 것이 목표기 때문에 모바일 화면 최적화를 진행했다.
목표
- 반응형 디자인: 4개 breakpoint로 모든 화면 크기 대응
- 오프캔버스 사이드바: 햄버거 메뉴로 숨기기
- TOC 제거: 모바일에서는 목차가 방해됨
- 터치 최적화: 44x44px 터치 타겟, 스와이프 제스처
기존 구조 분석
먼저 템플릿 구조를 파악했다.
base.html - 3단 그리드 레이아웃
<div class="main-wrapper">
<aside class="sidebar">...</aside>
<main class="content">...</main>
<aside class="toc">...</aside>
</div>
.main-wrapper {
display: grid;
grid-template-columns: 250px minmax(0, 900px) 250px;
gap: 20px;
}
문제:
- 768px 이하에서는
250px(사이드바) + 900px(콘텐츠) + 250px(TOC) = 1400px
가 화면 너비를 초과 - CSS Grid가
minmax(0, 900px)
로 콘텐츠 영역을 최대 900px로 제한하지만, 사이드바(250px)와 TOC(250px)는 고정 너비라서 작은 화면에서는 레이아웃이 완전히 깨짐 - 사이드바와 TOC가 콘텐츠를 압박해서 실제 읽을 수 있는 영역이 100px 이하로 줄어듦
- 모바일에서는 세로 스크롤이 너무 길어짐 (사이드바 메뉴를 보려면 계속 스크롤해야 함)
style.css - 단일 breakpoint만 존재
@media (max-width: 992px) {
/* 타블릿 대응만 존재 */
}
문제:
- 스마트폰 (768px 이하)에 대한 최적화 없음
- 작은 스마트폰 (480px 이하)은 고려조차 안 됨
1단계: CSS 반응형 디자인
Breakpoint 전략
/* 기본 (데스크톱) */
.main-wrapper {
grid-template-columns: 250px minmax(0, 900px) 250px;
}
/* 992px - 타블릿 가로 */
@media (max-width: 992px) {
.main-wrapper {
grid-template-columns: 1fr; /* 1단 레이아웃 */
}
.sidebar {
position: fixed; /* 오프캔버스로 전환 */
left: -280px;
}
.toc {
display: none; /* TOC 숨김 */
}
}
/* 768px - 스마트폰 */
@media (max-width: 768px) {
.header-content {
gap: 6px; /* 헤더 요소 간격 축소 */
}
.logo-text {
display: none; /* "Roach Wiki" 텍스트 숨김 */
}
.login-text {
display: none; /* "로그인" 텍스트 숨김 */
}
.wiki-article h1 {
font-size: 26px; /* 제목 크기 축소 */
word-wrap: break-word; /* 긴 제목 줄바꿈 */
}
}
/* 480px - 작은 스마트폰 */
@media (max-width: 480px) {
.header-content {
gap: 6px;
}
.wiki-article h1 {
font-size: 22px;
}
.content {
padding: 16px 12px; /* 패딩 최소화 */
}
}
터치 최적화
Apple Human Interface Guidelines1에 따르면 터치 타겟의 최소 크기는 44x44px이다. 이보다 작으면 사용자가 정확하게 터치하기 어렵다고 한다.
/* 모든 버튼 */
.theme-btn,
.login-btn,
.search-box button {
padding: 8px 12px;
min-height: 44px;
min-width: 44px;
}
/* 햄버거 메뉴 */
.mobile-menu-btn {
padding: 8px 12px;
font-size: 20px;
line-height: 1;
}
이렇게 min-height
와 min-width
를 설정하면 내용물이 작아도 최소 크기가 보장된다. padding
만으로는 아이콘이나 텍스트 크기에 따라 전체 크기가 달라질 수 있기 때문이다.
/* 통일된 패딩 */
.header button,
.header a {
padding: 8px 12px; /* 모두 동일 */
display: flex;
align-items: center;
justify-content: center;
}
텍스트 숨기기 (모바일)
모바일에서는 Roach Wiki 와 로그인이 라는 텍스트가 상단바에서 상당한 비중을 차지한다. 따라서 모바일에서는 아이콘만 노출하도록 최적화를 진행했다.
<!-- Before -->
<h1 class="logo">
<a href="/">🌳 Roach Wiki</a>
</h1>
<a href="/login">🔐 로그인</a>
<!-- After -->
<h1 class="logo">
<a href="/">🌳 <span class="logo-text">Roach Wiki</span></a>
</h1>
<a href="/login" class="login-btn">🔐 <span class="login-text">로그인</span></a>
@media (max-width: 768px) {
.logo-text,
.login-text {
display: none;
}
}
결과:
- 데스크톱: 🌳 Roach Wiki
- 모바일: 🌳
긴 제목 처리
문제: 긴 콘텐츠를 지닌 글에서 제목이 화면을 뚫고 나감
모바일에서 실제 테스트하다가 발견한 문제. 영어 단어가 길거나, 한글이라도 띄어쓰기 없이 길게 쓰면 제목이 화면 밖으로 튀어나가서 가로 스크롤이 생긴다. 특히 selectinload
, joinedload
같은 긴 영어 단어들이 문제였다.
기본적으로 CSS는 단어 단위로 줄바꿈을 한다. 영어는 공백 기준, 한글은 어절 기준. 그래서 word-wrap: break-word
만 쓰면 "SQLAlchemy 연관관계"처럼 공백이 있는 부분에서만 줄바꿈되고, selectinload
같은 단어는 안 끊긴다.
.wiki-article h1 {
word-wrap: break-word; /* 레거시 브라우저 지원 */
overflow-wrap: break-word; /* 표준 속성 */
word-break: break-word; /* 영어 단어도 강제 줄바꿈 */
}
.content {
max-width: 100%;
overflow-x: hidden; /* 가로 스크롤 방지 */
}
왜 3개나?
word-wrap
: IE 호환성 (deprecated이지만 여전히 필요)overflow-wrap
: 표준 이름 (CSS3)word-break
: 단어 중간에서도 끊기 (CJK 문자와 영어 모두)
word-break: break-word
가 핵심인데, 이게 있으면 selectinload
를 selectin-
과 load
로 끊어버린다. 보기엔 좀 이상하지만, 가로 스크롤보다는 낫다.
Flexbox로 헤더 정렬
문제: 검색창이 너무 커지면 다른 버튼들이 화면 밖으로 밀려남
.header-content {
display: flex;
align-items: center;
gap: 4px; /* 최소 간격 */
max-width: 100%;
}
.logo,
.mobile-menu-btn,
.theme-btn,
.login-btn {
flex-shrink: 0; /* 절대 축소 안 됨 */
}
.search-box {
flex: 1; /* 남은 공간 차지 */
min-width: 0; /* flex 아이템 축소 허용 */
}
.search-box input {
flex: 1;
min-width: 80px; /* 최소 너비 보장 */
}
처음엔 이렇게 했다가 실패:
.search-box {
position: absolute; /* 검색창을 absolute로 */
left: 50px;
right: 200px;
}
실패한 이유:
position: absolute
는 부모 요소의 크기를 무시하고 독립적으로 배치됨left: 50px
는 로고 너비를 하드코딩한 값인데, 모바일에서 로고가 축소되면 안 맞음right: 200px
도 버튼들의 너비를 가정한 값인데, 화면 크기에 따라 달라짐- 화면이 작아지면 검색창이 버튼들과 겹치거나, 너무 좁아져서 사용 불가
- JavaScript 없이는 동적으로 위치 계산 불가능
Flexbox로 개선:
.header-content {
display: flex;
gap: 4px;
}
.search-box {
flex: 1; /* 남은 공간 모두 차지 */
min-width: 0; /* 기본값 auto를 0으로 바꿔서 축소 허용 */
}
왜 개선되었나:
- Flexbox는 자동으로 공간을 계산해서 분배
flex: 1
은 "남은 공간을 모두 차지하라"는 의미flex-shrink: 0
을 버튼들에 주면, 버튼 크기는 고정하고 검색창만 유연하게 조절됨- 화면 크기가 바뀌어도 자동으로 재계산
- JavaScript 없이 순수 CSS만으로 해결
2단계: 오프캔버스 사이드바
HTML 구조 추가
<body>
<header class="header">
<div class="container">
<div class="header-content">
<!-- 햄버거 메뉴 버튼 -->
<button class="mobile-menu-btn" id="mobileMenuBtn" aria-label="메뉴">
☰
</button>
<h1 class="logo">...</h1>
...
</div>
</div>
</header>
<!-- 오버레이 (사이드바 열릴 때 뒷배경 어둡게) -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<div class="main-wrapper">
<aside class="sidebar" id="sidebar">...</aside>
<main class="content">...</main>
<aside class="toc" id="toc">...</aside>
</div>
</body>
CSS 애니메이션
/* 데스크톱: 일반 사이드바 */
.sidebar {
position: static;
width: 250px;
}
/* 모바일: 오프캔버스 */
@media (max-width: 992px) {
.sidebar {
position: fixed;
left: -280px; /* 화면 밖으로 숨김 */
top: 0;
width: 280px;
height: 100vh;
background: var(--content-bg);
z-index: 1000;
overflow-y: auto;
transition: left 0.3s ease-in-out; /* 부드러운 애니메이션 */
padding: 80px 20px 20px; /* 헤더 높이만큼 패딩 */
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
}
.sidebar.active {
left: 0; /* 슬라이드 인 */
}
.sidebar-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s, visibility 0.3s;
}
.sidebar-overlay.active {
opacity: 1;
visibility: visible;
}
}
JavaScript 제어 (mobile.js)
const mobileMenuBtn = document.getElementById("mobileMenuBtn");
const sidebar = document.getElementById("sidebar");
const sidebarOverlay = document.getElementById("sidebarOverlay");
function toggleSidebar() {
sidebar.classList.toggle("active");
sidebarOverlay.classList.toggle("active");
// 사이드바 열릴 때 스크롤 방지
if (sidebar.classList.contains("active")) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
}
// 햄버거 버튼 클릭
mobileMenuBtn.addEventListener("click", toggleSidebar);
// 오버레이 클릭 시 닫기
sidebarOverlay.addEventListener("click", toggleSidebar);
// ESC 키로 닫기
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && sidebar.classList.contains("active")) {
toggleSidebar();
}
});
엣지 스와이프로 열기
요구사항: 화면 왼쪽 가장자리에서 오른쪽으로 스와이프하면 사이드바 열림 (iOS Safari 같은 느낌)
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
document.addEventListener(
"touchstart",
function (e) {
touchStartX = e.changedTouches[0].screenX;
touchStartY = e.changedTouches[0].screenY;
},
{ passive: true }
);
document.addEventListener(
"touchend",
function (e) {
touchEndX = e.changedTouches[0].screenX;
const touchEndY = e.changedTouches[0].screenY;
const swipeDistance = touchEndX - touchStartX;
const verticalDistance = Math.abs(touchEndY - touchStartY);
// 왼쪽 가장자리(50px 이내)에서 시작
// 오른쪽으로 100px 이상 스와이프
// 수직 이동이 적어야 함 (대각선 스와이프 제외)
if (touchStartX < 50 && swipeDistance > 100 && verticalDistance < 50) {
toggleSidebar();
}
},
{ passive: true }
);
왜 passive: true?
이건 브라우저 최적화 관련 옵션이다. 이해하려면 브라우저가 터치 이벤트를 어떻게 처리하는지 알아야 한다.
브라우저의 터치 이벤트 처리 과정:
- 사용자가 화면을 터치
touchstart
이벤트 발생- 브라우저가 JavaScript 이벤트 핸들러 실행을 기다림 ⏱️
- 핸들러 안에서
preventDefault()
를 호출하면 → 스크롤 취소 - 호출하지 않으면 → 스크롤 시작
문제는 3번 단계다. 브라우저는 이벤트 핸들러가 preventDefault()
를 호출할지 모르기 때문에, 일단 기다린다. 이게 보통 100~200ms 정도 걸리는데, 이 시간 동안 스크롤이 멈춰있어서 버벅이는 느낌이 든다.
{ passive: true }
를 쓰면 "나는 절대 preventDefault()
안 쓸게"라고 브라우저에게 약속하는 것이다. 그러면 브라우저는 기다리지 않고 바로 스크롤을 시작한다.
// passive: false (기본값) - 브라우저가 기다림 → 버벅임
document.addEventListener("touchstart", handler);
// passive: true - 브라우저가 기다리지 않음 → 부드러움
document.addEventListener("touchstart", handler, { passive: true });
우리 코드에서는:
- 엣지 스와이프 감지만 하고, 스크롤을 막을 필요 없음
preventDefault()
를 쓸 일이 없음- →
passive: true
써도 문제없고, 스크롤 성능은 향상됨
Chrome DevTools의 Performance 탭에서 보면, passive: false
일 때는 Forced reflow
경고가 뜨지만, passive: true
를 쓰면 사라진다.
3단계: TOC 제거
내 생각에 모바일에서는 아래와 같은 제약들이 있다고 생각했다.
- 화면이 좁아서 목차 보면서 읽기 힘듦
- 플로팅 버튼이 콘텐츠 가림
- 세로 스크롤이 길어서 목차 필요성 낮음
최종 결정: 모바일에서는 TOC 아예 제거
@media (max-width: 992px) {
.toc {
display: none; /* 심플하게 */
}
.floating-toc-btn {
display: none;
}
}
4단계: Hammer.js 통합
왜 Hammer.js?2
네이티브 터치 이벤트로 스와이프를 구현하려면 이렇게 해야 한다:
let startX, startY, endX, endY;
document.addEventListener("touchstart", (e) => {
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
});
document.addEventListener("touchmove", (e) => {
endX = e.touches[0].clientX;
endY = e.touches[0].clientY;
});
document.addEventListener("touchend", () => {
const diffX = endX - startX;
const diffY = endY - startY;
// 수평 스와이프인지 판단
if (Math.abs(diffX) > Math.abs(diffY)) {
if (diffX > 50) {
// 오른쪽 스와이프
} else if (diffX < -50) {
// 왼쪽 스와이프
}
}
});
복잡하다. pan
(드래그), pinch
(확대/축소), rotate
(회전)까지 구현하려면 코드가 수백 줄로 늘어난다.
Hammer.js는 이걸 추상화한다:
const hammer = new Hammer(element);
hammer.on("swipeleft", () => console.log("왼쪽 스와이프!"));
hammer.on("swiperight", () => console.log("오른쪽 스와이프!"));
끝. 25KB(minified + gzipped 기준 8KB)로 모든 터치 제스처를 지원한다.
<!-- base.html -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/hammer.js/2.0.8/hammer.min.js"></script>
swipe-navigation.js
(function () {
"use strict";
const content = document.querySelector(".content");
if (!content) return;
const documentId = getDocumentId();
if (!documentId) return;
let navigationData = null;
// 1. API에서 이전/다음 문서 정보 가져오기
fetch(`/api/documents/${documentId}/navigation`)
.then((res) => res.json())
.then((data) => {
if (data.success) {
navigationData = data;
console.log("[SwipeNav] Navigation data loaded", navigationData);
}
})
.catch((err) =>
console.error("[SwipeNav] Failed to load navigation:", err)
);
// 2. Hammer.js 초기화
const hammer = new Hammer(content, {
touchAction: "pan-y", // 세로 스크롤은 허용
});
hammer.get("swipe").set({
direction: Hammer.DIRECTION_HORIZONTAL, // 가로 스와이프만
threshold: 50, // 최소 50px 이동
velocity: 0.3, // 최소 속도
});
// 3. 스와이프 이벤트
hammer.on("swipeleft", function (e) {
if (navigationData && navigationData.next) {
console.log("[SwipeNav] Swipe left → Next:", navigationData.next.title);
navigateToDocument(navigationData.next.id);
}
});
hammer.on("swiperight", function (e) {
if (navigationData && navigationData.prev) {
console.log("[SwipeNav] Swipe right → Prev:", navigationData.prev.title);
navigateToDocument(navigationData.prev.id);
}
});
// 4. 페이지 이동
function navigateToDocument(docId) {
window.location.href = `/wiki/${docId}`;
}
function getDocumentId() {
const match = window.location.pathname.match(/\/wiki\/([^\/\?]+)/);
return match ? match[1] : null;
}
})();
성능 측정
네트워크
Before (모바일 최적화 전):
- HTML: 45KB
- CSS: 28KB (불필요한 데스크톱 스타일 포함)
- JS: 없음 (상호작용 없음)
After:
- HTML: 46KB (+1KB, 버튼/오버레이 추가)
- CSS: 32KB (+4KB, 4개 breakpoint 추가)
- JS: 15KB (mobile.js 8KB + swipe-navigation.js 7KB)
- Hammer.js: 25KB (CDN, 캐시됨)
총합: 70KB → 118KB (+48KB, +69%)
하지만:
- gzip 압축 후: 70KB → 85KB (+15KB, +21%)
- Hammer.js는 캐시되면 0KB
- 모바일 사용성은 10배 향상
렌더링 성능
Lighthouse 점수 (iPhone 12 Pro, Throttled 3G):
지표 | Before | After |
---|---|---|
Performance | 72 | 89 |
Accessibility | 85 | 94 |
Best Practices | 92 | 96 |
SEO | 100 | 100 |
개선 사항:
- Touch target size: 빨강 → 초록 (44x44px 보장)
- Tap delay: 제거 (CSS
touch-action
활용) - Scroll jank: 0 (오프캔버스 애니메이션 60fps)
배운 점
1. Flexbox > 복잡한 계산
처음엔 JavaScript로 화면 크기 재고 계산하려고 했다.
// 이러지 마세요
function adjustHeader() {
const windowWidth = window.innerWidth;
const logoWidth = 150;
const buttonsWidth = 200;
const searchWidth = windowWidth - logoWidth - buttonsWidth - 40;
searchBox.style.width = searchWidth + "px";
}
window.addEventListener("resize", adjustHeader);
Flexbox 한 줄이면 끝:
.search-box {
flex: 1;
min-width: 0;
}
2. touch-action: pan-y의 중요성
.content {
touch-action: pan-y; /* 세로 스크롤만 허용 */
}
이거 없으면 왜 스크롤이 버벅일까?
Hammer.js가 스와이프를 감지하려면 touchstart
, touchmove
, touchend
이벤트를 모두 감시해야 한다. 문제는 브라우저도 같은 이벤트로 스크롤을 감지한다는 것이다.
충돌 시나리오:
- 사용자가 화면을 터치 (
touchstart
) - 손가락을 움직임 (
touchmove
) - 브라우저: "이거 스크롤인가? 스와이프인가? 일단 기다려보자"
- Hammer.js: "이게 수평 움직임인지 수직 움직임인지 계산 중..."
- 100~200ms 후 결정
- 브라우저: "아, 스크롤이구나" → 스크롤 시작
이 지연 시간 때문에 스크롤이 버벅인다. 특히 Hammer.js가 pan
제스처를 감지하고 있으면, 모든 터치 이벤트에 대해 계산을 하기 때문에 더 심해진다.
touch-action: pan-y
의 효과:
.content {
touch-action: pan-y; /* "이 영역에서는 세로 스크롤만 허용" */
}
이렇게 선언하면 브라우저에게 "이 요소에서는 수평 스와이프를 신경 쓰지 말고, 세로 스크롤만 처리해"라고 알려주는 것이다. 그러면:
- 브라우저는 수평 움직임을 Hammer.js에게 맡기고, 세로 움직임만 처리
- 기다릴 필요 없이 바로 판단 가능
- 스크롤이 즉시 반응
브라우저 기본 제스처와의 충돌:
- iOS Safari: 화면 왼쪽 가장자리 스와이프 = 뒤로가기
- Chrome: 상단에서 아래로 당기기 = 새로고침
touch-action
으로 "이 영역은 내가 처리할게"라고 선언하면 충돌 방지
실제 차이:
없을 때:
터치 → 계산 중 (200ms) → 스크롤 시작 (버벅)
있을 때:
터치 → 즉시 스크롤 (부드러움)
DevTools Performance 탭에서 보면 touch-action
없으면 Touch Handler
경고가 뜨는데, 이게 성능 문제의 원인이다.
3. 애니메이션은 transform/opacity만
/* 빠름 (GPU 가속) */
.sidebar {
transform: translateX(-280px);
transition: transform 0.3s;
}
/* 느림 (리플로우 발생) */
.sidebar {
left: -280px;
transition: left 0.3s;
}
하지만 left
써도 60fps 나옴.
요즘 브라우저(Chrome 90+, Safari 14+)는 레이아웃 속성(left
, top
, width
, height
)에 대한 애니메이션도 GPU 레이어로 자동 승격시킨다. 이걸 "레이어 승격 휴리스틱(Layer Promotion Heuristic)"이라고 한다.
브라우저가 자동으로 GPU 레이어로 올리는 조건:
transition
이나animation
이 활성화된 요소position: fixed
이거나z-index
가 높은 요소- 투명도나 블렌딩 모드를 사용하는 요소
우리 사이드바는 position: fixed
+ transition: left 0.3s
라서 자동으로 GPU 레이어에 올라간다. 그래서 transform
을 쓰나 left
를 쓰나 성능 차이가 거의 없다.
실제 측정:
- Chrome DevTools Rendering 탭 → FPS meter
left
애니메이션: 58~60fpstransform
애니메이션: 60fps (일관됨)
차이가 1~2fps인데, 사람 눈으로는 구분 불가능하다. 오히려 transform
으로 바꿨다가 버그 생기는 게 더 문제였다.
결론: 최적화 집착하지 말자. 작동하는 코드가 우선이다.
4. iOS Safari 스크롤 방지의 함정
문제: overflow: hidden
만으로는 iOS Safari에서 배경 스크롤 막을 수 없음
처음엔 이렇게 했다:
function toggleSidebar() {
sidebar.classList.toggle("active");
if (sidebar.classList.contains("active")) {
document.body.style.overflow = "hidden"; // iOS에서 안 먹힘
}
}
iOS Safari에서 테스트하니 사이드바가 열려도 뒷배경이 계속 스크롤됨. 구글링 결과, iOS는 overflow: hidden
을 무시하는 버그가 있음.
해결:
let scrollPosition = 0;
function toggleSidebar() {
const isOpening = !sidebar.classList.contains("active");
if (isOpening) {
scrollPosition = window.pageYOffset; // 현재 위치 저장
document.body.style.overflow = "hidden";
document.body.style.position = "fixed"; // 핵심!
document.body.style.top = `-${scrollPosition}px`; // 스크롤 위치 유지
document.body.style.width = "100%"; // width 안 주면 레이아웃 깨짐
} else {
document.body.style.removeProperty("overflow");
document.body.style.removeProperty("position");
document.body.style.removeProperty("top");
document.body.style.removeProperty("width");
window.scrollTo(0, scrollPosition); // 원래 위치로 복원
}
}
왜 이렇게 복잡한가?
position: fixed
→ body를 고정시켜서 스크롤 불가능하게 만듦top: -${scrollPosition}px
→ 고정하면 스크롤 위치가 사라지니까, top으로 시각적 위치 유지window.scrollTo(0, scrollPosition)
→ 닫을 때 원래 위치로 되돌림
실제 iPhone에서 테스트하니까 완벽하게 작동함.
5. 접근성 (ARIA) 추가
스크린 리더 사용자를 위한 개선.
HTML:
<button
class="mobile-menu-btn"
id="mobileMenuBtn"
aria-label="메뉴"
aria-expanded="false"
aria-controls="sidebar"
>
☰
</button>
<aside class="sidebar" id="sidebar" aria-hidden="true" role="navigation">
...
</aside>
JavaScript:
function toggleSidebar() {
const isOpening = !sidebar.classList.contains("active");
// ARIA 상태 업데이트
mobileMenuBtn.setAttribute("aria-expanded", isOpening);
sidebar.setAttribute("aria-hidden", !isOpening);
// 포커스 관리
if (isOpening) {
const firstLink = sidebar.querySelector("a");
if (firstLink) {
setTimeout(() => firstLink.focus(), 300); // 애니메이션 후 포커스
}
} else {
mobileMenuBtn.focus(); // 닫을 때 버튼으로 포커스 복원
}
}
효과:
- 스크린 리더가 "메뉴 버튼, 펼침" / "메뉴 버튼, 닫힘" 읽어줌
- 키보드 탐색 시 자연스러운 포커스 흐름
- 사이드바 열리면 첫 링크로 자동 포커스 이동
6. 엣지 스와이프 범위 조정
처음엔 화면 왼쪽 50px 이내에서 스와이프하면 사이드바가 열리게 했다.
if (touchStartX < 50 && swipeDistance > 100) {
toggleSidebar();
}
문제: 브라우저의 뒤로가기 제스처와 충돌. iOS Safari와 Chrome은 화면 왼쪽 가장자리 스와이프가 뒤로가기임.
해결: 범위를 30px로 줄임.
if (touchStartX < 30 && swipeDistance > 100) {
toggleSidebar();
}
브라우저 제스처는 보통 0~20px 범위라서, 30px로 하면 충돌 최소화하면서도 사용자가 쉽게 접근 가능.
결론
- 4개 breakpoint 반응형 디자인 구현
- 오프캔버스 사이드바 + 엣지 스와이프
- 스와이프 네비게이션 (좌우로 문서 이동)
- 터치 최적화 (44x44px 타겟)
- Lighthouse 성능 점수 72 → 89
핵심 교훈:
- Flexbox로 대부분의 레이아웃 문제 해결
- YAGNI: 바텀시트 TOC 같은 건 필요 없었음
이제 침대에서도 편하게 내 위키를 볼 수 있다. 다음은 PWA로 만들어서 홈 화면에 설치할 수 있게 해볼까?
-
Apple Human Interface Guidelines - Buttons 참고. Apple은 iOS 인터페이스 디자인에서 모든 터치 가능한 요소가 최소 44x44pt(포인트)의 히트 영역을 가져야 한다고 명시한다. 이는 사용자 경험 연구를 통해 도출된 값으로, 터치 실수를 최소화하고 접근성을 보장하기 위한 기준이다. Android Material Design도 유사하게 최소 48x48dp를 권장한다. ↩
-
Hammer.js는 터치 제스처 인식 라이브러리다. 공식 사이트에서
tap
,doubletap
,press
,pan
,swipe
,pinch
,rotate
등 다양한 제스처를 제공한다. 2014년에 처음 릴리스되어 jQuery Mobile, Bootstrap 등에서도 사용되었다. 요즘은 Pointer Events API가 표준화되면서 필요성이 줄었지만, 크로스 브라우저 호환성과 간결한 API 때문에 여전히 많이 쓰인다. 우리 프로젝트에서는 CDN으로 로드해서 별도 빌드 과정 없이 사용한다. ↩
💬 댓글 0