발행일

이용안내를 아코디언에서 캐러셀로 — 슬라이드 분할과 매크로 공유화 회고

이용안내를 아코디언에서 캐러셀로 — 슬라이드 분할과 매크로 공유화 회고

시스템 이용안내는 표준데이터·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 같은 해시를 붙여 특정 안내를 링크로 공유할 수 있게 하는 것, 그리고 인쇄·검색용으로 전체를 펼친 뷰를 하나 더 두는 것. 지금은 "안 되는 걸 알고 안 한 상태"라 언제든 붙일 수 있습니다.

긴 안내 문서를 짧은 호흡의 슬라이드로 바꾸니, 읽는 사람이 "끝이 보인다" 는 느낌을 갖게 됐어요. 스크롤 길이가 사라진 것만으로도 체감이 꽤 달랐습니다.