- 발행일
이용안내를 아코디언에서 캐러셀로 — 슬라이드 분할과 매크로 공유화 회고
이용안내를 아코디언에서 캐러셀로 — 슬라이드 분할과 매크로 공유화 회고
시스템 이용안내는 표준데이터·OID·물리 아키텍처·Open API 네 개의 가이드 탭으로 돼 있었습니다. 전부 아코디언이었는데, "위에서 아래로 끝없이 스크롤" 하는 구조가 안내 문서엔 안 맞았어요. 이걸 캐러셀로 갈아엎으면서 마크업 중복까지 정리한 작업을 기록합니다.
1. 아코디언의 한계
아코디언은 여러 섹션을 동시에 펼쳐 비교할 때 좋습니다. 그런데 이용안내는 성격이 달랐어요.
- 한 섹션이 화면 캡처 + 긴 설명이라 펼치면 길었습니다. 펼칠수록 페이지가 늘어나 스크롤이 한없이 길어졌어요.
- 사용자가 지금 몇 번째 안내를 보고 있는지 감이 없었습니다. 진행 위치 표시가 없으니까요.
- 한 번에 한 주제만 집중해서 보면 되는데, 아코디언은 여러 개를 동시에 열어두기 쉬워 오히려 산만했어요.
문제를 한 줄로 줄이면 "안내 문서에 비교용 컴포넌트를 쓰고 있었다" 입니다. 이용안내는 순서대로 따라 하는 문서지, 여러 항목을 나란히 놓고 견주는 문서가 아니에요.
2. 네 가지 후보와 고른 이유
컴포넌트를 바꾸기로 한 뒤 후보를 늘어놓고 비교했습니다.
| 방법 | 장점 | 포기하는 것 | 판단 |
|---|---|---|---|
| ① 아코디언 유지 + 섹션 쪼개기 | 변경 최소, 페이지 내 검색 유지 | 스크롤 길이 문제가 그대로. 진행 위치 인지 여전히 없음 | ✗ |
| ② 탭 안에 앵커 목차 추가 | 점프 가능, 전체가 한 페이지 | 목차 자체가 또 하나의 긴 목록. 탭 안에 탭이 생기는 구조 | ✗ |
| ③ 안내를 여러 페이지로 분리 | 주소가 생겨 공유·북마크 가능 | 라우트 4개 → 30여 개로 폭증. 안내 하나 고치는 데 배포 단위가 커짐 | ✗ |
| ④ 캐러셀 + 슬라이드 분할 | 한 번에 한 화면, 진행 위치 N/M, 스크롤 제거 | 페이지 내 검색(Ctrl+F)이 숨은 슬라이드를 못 찾음 | ✓ 채택 |
③은 꽤 오래 붙들었습니다. "슬라이드마다 URL이 있으면 고객사에 '3번 안내 보세요'라고 링크를 줄 수 있는데" 싶었거든요. 그런데 안내 문구 한 줄 고치는 일이 라우트·템플릿·사이드바를 다 건드리는 일이 되는 게 걸렸습니다. 이용안내는 자주 고쳐지는 문서라 수정 비용이 낮아야 했어요.
다만 지금 다시 보면 기각 논리에 구멍이 하나 있습니다. ③에서 원했던 "공유 가능한 주소"는 라우트를 늘리지 않아도 해시(#slide-3)로 달성됩니다. 7절에 남긴 과제가 바로 그거고요. 그러니 당시에 기각해야 했던 건 "페이지 분리"였지 "주소"가 아니었는데, 둘을 묶어서 통째로 버렸던 겁니다.
④의 대가는 명확했습니다. 캐러셀은 안 보이는 슬라이드를 브라우저 검색에서 감춥니다. 이걸 알고도 고른 이유는, 이용안내를 Ctrl+F로 뒤지는 사용 패턴보다 처음부터 순서대로 따라가는 패턴이 압도적으로 많다고 봤기 때문입니다. 솔직히 적으면 이건 로그나 문의 이력으로 확인한 사실이 아니라 추정입니다 — 이 결정에서 가장 약한 근거예요. 대신 슬라이드 제목과 N/M을 항상 노출해 "지금 어디쯤"을 잃지 않게 보완했어요.
지금도 이건 감수한 트레이드오프지 해결한 문제가 아닙니다. 아래 6절에 후속 과제로 남겨뒀습니다.
3. 구현 — 슬라이드 분할과 캐러셀 본체
긴 섹션을 화면 한 장 단위로 잘게 나눴어요. 표준데이터 10슬라이드, OID 10슬라이드, 아키텍처 4슬라이드, Open API 7슬라이드. 슬라이드마다 캡처 이미지 1개 + 짧은 설명으로 두니, 스크롤이 거의 사라졌습니다.
| 항목 | 아코디언(전) | 캐러셀(후) |
|---|---|---|
| 노출 단위 | 펼친 섹션 전체 | 화면 1개 + 짧은 설명 |
| 진행 인지 | 없음 | 현재 위치 N/M 표시 |
| 이동 | 스크롤 | 이전/다음 버튼 + 키보드 화살표 |
| 동시 노출 | 여러 개 펼침 가능 | 항상 하나만 |
캐러셀 본체는 라이브러리 없이 flex track을 좌우로 옮기는 단순한 슬라이더로 만들었습니다. 슬라이드 수가 최대 10개고 자동재생·무한루프·터치 제스처가 필요 없어서, 의존성을 하나 늘릴 이유를 못 찾았어요.
const updateTrackPosition = () => {
const baseOffset = slides[0].offsetLeft;
const currentOffset = slides[currentIndex].offsetLeft - baseOffset;
trackEl.style.transform = `translate3d(-${currentOffset}px, 0, 0)`;
};
진행 위치(N/M)와 버튼 활성 상태도 같이 갱신합니다.
const updateCarousel = () => {
updateTrackPosition();
titleEl.textContent = slides[currentIndex].dataset.carouselSlideTitle || "";
indexEl.textContent = currentIndex + 1; // N
countEl.textContent = slides.length; // M
updateButtonState(); // 첫/끝에서 prev/next disabled
updateSlideState(); // aria-hidden 갱신
window.requestAnimationFrame(updateViewportHeight);
};
updateSlideState()에서 aria-hidden을 갱신하는 건 4절의 트레이드오프와 짝입니다. 시각적으로 감춘 슬라이드를 보조기기에도 일관되게 감춰야, 스크린 리더가 안 보이는 슬라이드를 읽어버리는 상황을 막을 수 있어요.
다시 보다가 알게 된 구멍이 하나 있습니다. aria-hidden은 포커스 순서를 바꾸지 않습니다. 숨은 슬라이드 안의 링크·버튼(캡처 확대 버튼 포함)은 여전히 Tab에 잡히고, 그러면 스크린 리더 사용자는 읽히지 않는 영역에 포커스가 갇힙니다 — aria-hidden 영역 안에 포커스 가능한 요소를 두는 건 WAI-ARIA가 명시적으로 금지하는 조합이에요. 숨은 슬라이드에 inert를 같이 걸어야 하는데 아직 안 했고, 그래서 아래 6절 검증표의 "접근성" 행은 반쪽짜리입니다.
4. 함정 — 슬라이드마다 높이가 다름
슬라이드는 내용이 제각각이라 높이가 다릅니다. 캐러셀 뷰포트 높이를 고정하면 짧은 슬라이드엔 빈 공간, 긴 슬라이드엔 잘림이 생겨요.
여기서도 두 갈래였습니다.
- 가장 긴 슬라이드 기준으로 높이 고정 — 구현이 제일 쉽지만, 짧은 슬라이드에서 화면 절반이 빈 공간이 됩니다. 캡처 한 장짜리 슬라이드가 많아서 대부분의 화면이 휑해져요.
- 현재 슬라이드 높이에 뷰포트를 동기화 — 전환할 때 높이가 변하지만, 항상 내용에 꽉 맞습니다.
후자를 골랐습니다.
const updateViewportHeight = () => {
viewportEl.style.height = `${slides[currentIndex].offsetHeight}px`;
};
requestAnimationFrame으로 한 프레임 미뤄 호출한 이유는, 슬라이드 전환 직후 레이아웃이 확정된 다음에 높이를 읽기 위해서였어요. 너무 일찍 읽으면 이전 슬라이드 높이가 잡힙니다. 처음엔 바로 호출했다가 높이가 한 박자씩 밀리는 걸 보고 알았어요.
키보드 화살표도 붙였는데, 입력 요소에 포커스가 있을 땐 무시하도록 가드를 뒀습니다. 안내 화면에서 ←/→로 페이지를 넘기는데 검색창 입력까지 가로채면 안 되니까요.
carouselEl.addEventListener("keydown", (event) => {
if (["INPUT", "SELECT", "TEXTAREA"].includes(event.target.tagName)) return;
if (event.key === "ArrowLeft") { ... }
});
화면 캡처는 클릭하면 확대 모달로 크게 볼 수 있게 했어요. 슬라이드 안에서는 캡처가 작아질 수밖에 없는데, 세부를 확인해야 하는 안내 문서라 확대 경로가 필요했습니다.
5. 매크로 공유화 — 4탭에 복붙돼 있던 것 합치기
이게 사실 더 큰 정리였습니다. 네 개 탭은 각자 carousel/slide/screenshot/feature_card/step_card 같은 매크로를 인라인으로 중복 정의하고 있었어요.
캐러셀 전환은 어차피 네 탭을 다 건드리는 작업이라, 중복을 걷어내기에 딱 좋은 타이밍이었습니다. 반대로 말하면 이 타이밍을 놓치면 인라인 매크로가 네 벌로 유지되는 상태가 굳어집니다.
{# templates/utils/macros/user_guide.html.j2 — 4개 탭 공유 매크로 #}
{# carousel / slide / screenshot / feature_card / step_card / arrow /
outline / login_badge / guide_scripts 를 한 곳에 정의 #}
각 탭 템플릿은 인라인 매크로를 지우고 이 파티얼을 import하도록 바꿨어요. accordion.js 호출도 빼고 guide_scripts() 매크로 한 번으로 통일했습니다. 외곽 컨테이너도 min-w-[75rem] → w-full로 바꿔 상단 탭 폭과 정렬을 맞췄어요.
"안내 4개를 캐러셀로 바꾸기" 가 표면 목표였지만, 그 과정에서 "같은 매크로가 네 군데 복사돼 있던 빚" 을 갚은 셈이 됐습니다.
6. 검증
| 항목 | 결과 |
|---|---|
| 슬라이드 분할 | 표준데이터 10 · OID 10 · 아키텍처 4 · Open API 7 |
| 탭별 렌더 | 4탭 전부 200, 첫 슬라이드 자동 선택 |
| 진행 표시 | 이동할 때마다 N/M 갱신, 첫/끝에서 이전·다음 버튼 disabled |
| 높이 동기화 | 슬라이드별 높이가 내용에 맞게 변경, 빈 공간·잘림 없음 |
| 키보드 | ←/→ 이동 동작, 입력 요소 포커스 시 미개입 |
| 접근성 | 비활성 슬라이드 aria-hidden 적용 — 단 포커스 차단(inert)은 미적용, 3절 참고 |
| 매크로 | 탭 템플릿의 인라인 매크로 정의 0건, 공유 파티얼 import로 통일 |
7. 회고와 남은 것
- 컴포넌트 선택은 콘텐츠 성격을 따릅니다. 비교가 목적이면 아코디언, 순서대로 한 화면씩이 목적이면 캐러셀. 후보를 늘어놓고 나서야 "우리가 쓰던 게 비교용 컴포넌트였다"는 게 또렷해졌어요.
- 고르면서 뭘 잃는지 적어두면 나중에 자기 결정을 변호할 수 있습니다. 페이지 내 검색을 포기한 건 실수가 아니라 선택이었고, 그걸 기록해두니 나중에 이 얘기가 나왔을 때 다시 설명할 필요가 없었습니다.
- 높이가 다른 슬라이드는 뷰포트를 동기화하세요. 고정 높이는 빈 공간 아니면 잘림을 만듭니다.
requestAnimationFrame으로 레이아웃 확정 후 높이를 읽는 게 안정적이었어요. - 키보드 핸들러엔 입력 요소 가드를. 전역 화살표 단축키가 폼 입력을 가로채면 안 됩니다.
- UI를 갈아엎는 김에 중복 매크로를 합치면 일석이조입니다. 다음에 슬라이드 스타일을 바꾸려면 이제 한 파일만 고치면 됩니다.
남은 과제는 2절에서 감수한 그 문제입니다. 슬라이드에 #slide-3 같은 해시를 붙여 특정 안내를 링크로 공유할 수 있게 하는 것, 그리고 인쇄·검색용으로 전체를 펼친 뷰를 하나 더 두는 것. 지금은 "안 되는 걸 알고 안 한 상태"라 언제든 붙일 수 있습니다.
긴 안내 문서를 짧은 호흡의 슬라이드로 바꾸니, 읽는 사람이 "끝이 보인다" 는 느낌을 갖게 됐어요. 스크롤 길이가 사라진 것만으로도 체감이 꽤 달랐습니다.