라이트박스가 너무 무거워요! 2.x kB로 끝내는 실전압축 라이트박스 (접근성 개선)

접근성 겸비한 경량 심플 라이트박스.

2026-06-01 코드 업그레이드. 접근성 문제 개선했어요.

의문: 라이트박스는 정말로 라이트한가?

뭔가 설명하는 글을 쓸 때 라이트박스가 참 유용하죠? 이미지로 설명을 보충하고 싶지만, 가독성을 생각하면 본문 너비를 무한정 늘릴 수가 없어요. 그렇다고 글로 전부 설명하자니 말이 지나치게 많아지는 그 때, 커다란 스크린샷 한장 띄우면 만사 오케이죠.

헌데 문제가... 요즘들어 라이트박스들이 너무 무거워지고 있어요. 심플이니 라이트니 하지만 심하게는 스크립트파일 하나가 70kB씩 되는 것도 있더라고요.

화려하고 예쁜 기능 많은 거 좋아요. 그런데, 그 기능 정말로 필요한가요? 슬라이드 필요해요? 갤러리 필요해요? 진짜요?

저는 필요없어요. 원하는 기능이 있으면 따로 적용하고 싶어요.

라이트박스는 더이상 라이트하지 않은 것이예요.

그래서 직접 만들었어요!

라이트박스 제작의 당위성

상세보기

조금 극단적으로 볼까요? 만약 스크립트 파일 용량 차이가 20kB라면, 딴 것 빼고 다운로드 속도만 해도 제법 차이날 거예요. 물론, 광대역 통신망에선 별 거 아니겠죠. 대충, js 파일이 서버측 압축으로 용량이 절반이 되었다고 가정해보죠.

그럼 100Mbps 통신망일 때 100/8*1024=12800kB/s => 12800 / 20 = 1/640s = 차이는 1.56ms에 불과해요. 10Mbps면 15.6ms, 하지만 1Mbps면 156ms.

'그런 저속 통신은 비현실적임.'

속도 문제가 별 것 아니라면 트래픽은요? 하루 1000명이 방문하는 사이트면 20*1000/1024 = 19.5MB/day, 한 달이면 약 585MB, 1년에 약 7GB의 트래픽이 소모되어요. 오직 라이트박스 하나 때문에요.

'캐시 뒀다 뭐함?'

참, 캐시가 있었죠? 근데요... 손님들은 항상 오던 분만 오시고 새 손님은 안 오실까요? 기존 손님들은 캐시 비우지않고 계속 들고 계실까요?

'외부 라이브러리 있잖아? cdnjs, jsDelivr같은.'

그렇죠. 외부 라이브러리 쓰면 앞서 말 한 모든 것이 무효예요. 근데... 그 사이트들 속도 빠르던가요? 생각보다 느리고 가끔 먹통되는 경우도 있지 않던가요?

'느려봤자 얼마나 느리다고.'

라이트박스 하나만 있는 것이 아니예요. 블로그가 나이를 먹으면 점점 스크립트가 늘어날 것인데, 낙타 등을 부러뜨리는 마지막 지푸라기 한 올은 제거하는 것이 맞지 않겠어요?

아무것도 아닌 지푸라기 하나라도, 그 이외에 문제가 산적해 있다 하더라도요. 더군다나 간단하게 처리할 수 있는 일이잖아요?

'그냥 쓰면 안 됨?'

선택지, 우리에겐 선택지가 필요한 것이에요. 우리는 개발자가 아니라 일반인. 그러니 최신 모듈화 스크립트도 마음대로, 필요한 것만 분리해서 쓰지 못하죠.

이전 글에서 공개했듯이, 대략 15년쯤 된 낡은 노트북을 쓰는 입장에선 더욱.

그래서 만들었습니다. minify 시 약 1.5kB 2.6kB짜리 매우 가벼운 라이트박스죠. 아래를 한 번 보셔요. 처음 나오는 건 자바스크립트, 두 번째가 CSS예요.

Very Silple Lightbox

// Very Simple Light Box - Accessibility
// minify 시 약 2.6kB
// 갤러리, 슬라이드 기능 없음.
// 오직 단일 이미지를 크게 보여주는 기능 뿐.
// 이미지 외 다른 영역 클릭, 이미지 더블클릭,
// 더블 탭, 스와이프,
// esc 키 누르면 꺼짐.
// 레이아웃 출렁임 개선.
// 접근성 문제 개선.

// 변수 선언
let lbContainer, lbCloseBtn, lbFigure, lbIMG, focusedElem;
let touchStartX=0, touchStartY=0, swipeX, swipeY, swipeDiag, isMultiTouch=0, lastTap=0;
let getStickyHead4LB, getScrollWidth;
const getPageBody4LB = document.querySelector(".page_body");
const allowArea = "body.item-view .post-body img"; // 허용할 구역

// 라이트박스 엘리먼트 예비
lbContainer = document.createElement('dialog');
lbContainer.className = "lb-container hide";
lbCloseBtn = document.createElement('button');
lbCloseBtn.className = "close-btn";
lbCloseBtn.innerHTML = "✕"; // 닫기 버튼
lbContainer.appendChild(lbCloseBtn);
lbFigure = document.createElement('figure'); // figure 없으면 img 정렬 문제 꼬임
lbIMG = document.createElement('img');
lbFigure.appendChild(lbIMG);
lbContainer.appendChild(lbFigure);
document.body.appendChild(lbContainer);

// 라이트박스 생성 함수
function showLB(wit) {
  focusedElem = wit; // 포커스 저장
  lbIMG.src = wit.src; // img src 복붙
  screenMove(); // 출렁임 제거
  lbContainer.classList.replace("hide", "show");
  lbContainer.showModal();
  lbCloseBtn.focus(); // 닫기로 이동
}
// 라이트박스 제거 함수
function closeLB() {
  lbContainer.classList.replace("show", "hide");
  screenRestore(); // 출렁임 제거
  lbContainer.close();
  focusedElem.focus(); // 포커스 복원
  lbIMG.src = "";
}

// 화면 출렁임 제거
function screenMove() {
  getStickyHead4LB = document.querySelector("header.sticky");
  getScrollWidth = window.innerWidth - document.documentElement.clientWidth; // 스크롤 바 너비 얼마?
  if(getScrollWidth) {
    getPageBody4LB.style.marginLeft = `-${getScrollWidth}px`; // 본문 왼쪽으로 밀기
    if( getStickyHead4LB ) { getStickyHead4LB.style.marginLeft = `-${getScrollWidth}px`; } // 스티키 헤더 왼쪽으로 밀기
  }
}
function screenRestore() {
  getPageBody4LB.style.removeProperty("margin-left"); // 본문 원위치
  if( getStickyHead4LB ) { getStickyHead4LB.style.removeProperty("margin-left"); } // 스티키 헤더 원위치
}

// 클릭 이벤트 감시 시작
document.addEventListener('click', (e)=>{
  if ( !lbContainer.open ) { // lb 꺼짐? 
    if ( e.target.closest(allowArea) ) { // 허용 구역임?
      // e.preventDefault(); // a 태그 이동 방지
      showLB(e.target); // 켬.
    }
  }
  else { // 켜졌음?
    if (e.target !== lbIMG ) { // img 말고 딴거 누름?
      closeLB(); // 끔.
    }
  }
});
// 더블클릭 종료
lbContainer.addEventListener("dblclick", closeLB);

// 키보드 이벤트 감시
document.addEventListener("keydown", (e)=>{
  if ( !lbContainer.open ) { // lb 꺼짐?
    if ( e.target.closest(allowArea) && e.key === "Enter" ) { // 허용 구역임? // 엔터 누름?
      e.preventDefault(); // 꺼짐 방지
      showLB(e.target); // 켬.
    }
  }
  else { // 켜졌음? 
    if ( e.key === "Escape" ) { // esc 임?
      closeLB(); // 끔.
    }
    else if( e.key === "Tab" ) { // 탭 누름?
      e.preventDefault();
      lbCloseBtn.focus(); // 밖에 못 나감.
    }
  }
});

// 스와이프 감시
lbContainer.addEventListener("touchstart", (e)=>{ // 터치 시작.
  if (e.touches.length > 1) { isMultiTouch = true; return; } // 손가락 몇 개?
  isMultiTouch = false;
  touchStartX = e.touches[0].clientX;
  touchStartY = e.touches[0].clientY;
}, { passive: true });
lbContainer.addEventListener("touchend", (e)=>{ // 터치 끝.
  if (isMultiTouch) { return; } // 손가락 두개? 그럼 중단.
  if (e.timeStamp - lastTap < 250) { // 더블탭 함?
    lastTap=0; e.preventDefault(); closeLB();
    return;
  }
  lastTap = e.timeStamp;
  // 계산
  swipeX = e.changedTouches[0].clientX - touchStartX;
  swipeY = e.changedTouches[0].clientY - touchStartY;
  swipeDiag = Math.hypot(swipeX, swipeY);
  if ( swipeDiag > 108) { // 스와이프 임계 넘음?
    e.preventDefault();
    closeLB(); // 끔.
  }
});
// dialog cancel 이벤트 감시
lbContainer.addEventListener("cancel", (e)=>{ // 혹시 몰라 넣음
  e.preventDefault();
  closeLB(); // 끔.
});
document.querySelectorAll(allowArea).forEach(img => {
  img.tabIndex = 0;
  img.role = "button";
  img.ariaLabel = `${img.alt || "이미지"} 크게 보기`;
});
/* ******************************************************************** */
/* **************** Very Simple Light Box 용 스타일 **************** */
/* ******************************************************************** */
body:has( .lb-container.show) { overflow: hidden; }
.lb-container {
  background: none; border: none; outline: none;
  max-width:100vw; max-height: 100vh;
  margin: auto; padding: 0;
  overflow: hidden;
}
.lb-container::backdrop {
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(4px);
}
.lb-container.show { position: fixed; inset: 0; }
.lb-container .close-btn {
  position: fixed; top: 20px; right: 20px;
  padding: 0; line-height: 1;
  color: white; font-size: large; font-weight:bold;
  background: none; border: none; outline: none;
  text-shadow: -2px 0px black, 0px 2px black, 2px 0px black, 0px -2px black;
}
.lb-container figure { max-width: inherit; max-height: inherit; margin: auto; }
.lb-container img { object-fit: contain; max-width: inherit; max-height: inherit; margin: auto; }

/* focus 확인용 */
/*
*:focus { outline: 2px solid red !important; }
*/

이 라이트박스는 동작 기준이 블로그스팟, 에센셜 테마인데요. 이 블로그는 순정이 아니라 개조예요.

하지만 본문 선택자를 바꾸지 않아서 순정에도 동작하고요, 콘템포, 엠포리오, 소호, 노터블까지 전부 같은 구조를 돌려쓰고 있으니 다 작동해요.

2026-06-01 기준으로 이 블로그에 적용되어 있으니, 그림을 눌러보시면 어떻게 동작하는지 확인 가능하셔요.

* 코드는 긁어서 복사 가능해요!

* 허용구역(.post-body) 내의 모든 img 태그가 대상이에요. 구역지정만 맞으면 따로 건드릴 게 없어요.

* 이미지를 A 태그로 감싸면 안 돼요. 클릭을 가로채지 않기 때문에 딴데로(href) 가버려요. a 태그 안의 이미지도 라이트박스 적용을 원하면 "a 태그 이동 방지" 줄의 주석을 제거하셔요.

* 블로그스팟, 테마 직접 수정 상황이면 CDATA로 감싸는 것 잊지마셔요!

<script>
//<![CDATA[
  // 실제 코드 여기에
//]]>
</script>

Very Simple Lightbox 설명

Lightbox 동작 부분

Very Simple Lightbox는 동작이 아주 간단해요. 이미지를 클릭하면 어두운 배경에 그림이 크게 뜬다. 이미지 바깥 부분을 클릭하면 꺼진다. 끝.

진짜 이게 다예요. 슬라이드? 갤러리? 빠른 미리보기? 부드러운 화면이동? 그런거 없다예요.

더 있다고 해 봐야 접근성 때문에 esc 키로 닫는 거랑 스와이프로, 더블탭으로 닫는 정도?

필요한 것은 정말로 이거 하나였어요.

슬라이드나 갤러리는 따로 만들어둔 코드가 있기에 필요하면 그걸 가져와서 쓸 거에요. 지금은 정말로 필요없어요.

어두운 바탕에 큰 이미지가 뜬 전형적 라이트박스.
Very Simple Lightbox 실행 장면 스샷

Lightbox: JS

골치아픈 것은 상단에 따라다니는 스티키 헤더랑 본문인데요, 스크롤바 때문에 얘들이 자꾸 위치 이동을 하는 게 아니겠어요? 너무 귀찮더라고요.

특히 스티키 헤더 부분은 스크롤바 너비만큼 왼쪽으로 밀면 되었지만, 본문은 수치가 안 맞는 것이 아니겠어요? 속에서 sky fire가 진짜...

처음에는 끙끙앓다가 본문이랑 헤더 메뉴 둘 다 px 단위 수치를 직접 하드코딩했어요. 근데 아무리 생각해도 '이건 아니다' 싶어서 오랜만에 MDN을 방문했답니다.

흐릿한 기억을 뒤지며 분명히 뭔가, 편한거 있었는데 하며 MDN 여기저기를 돌아다니다 보니 결국 window.innerWidth, document.body.clientWidth 같은 자동 계산을 손에 넣게 되었어요.

window.innerWidth는 스크롤바 포함 전체 너비, document.body.clientWidth document.documentElement.clientWidth는 스크롤바를 제외한 body 태그의 너비. 그래서 둘의 차이가 곧 스크롤바 두께가 되는 거죠.

-> 요거. 요거. 중요해요! document.body.clientWidth 이게 화면 전체 크기가 아니더라고요. body가 아니라 documentElement를 지정해서 html을 기준으로 해야 해요!

업그레이드 전 코드는 이 간단한 것을 몰라서 머리를 싸매고 고통받았던 것이예요!


그 다음에 또 MDN에서 찾은 편리한 도구가 closest() 인데요. allowArea랑 연결되어 있어요. 자바스크립트를 보면 body.item-view .post-body라고 되어있죠? 블로그 게시물, 그 중 본문 내부의 img 외 다른 클릭은 안 받겠다 이거예요.

예전에는 element의 조상 요소를 찾으려고 ... elem.parentNode.parentNode.parentNode ... 이런식으로 디지털 막노동을 한 것이 아니겠어요? 근데 이제 closest() 한 방에 조상님을 탈탈 털어서 확인해 주니 세상 편한 것이에요.


라이트박스 요소 내부에 이미지 태그를 넣는 것도, 처음엔 단순 복붙을 했더니 이미지가 누적되어 쌓이는 것이 아니겠어요? 이 것도 머리 싸매고 하고 고민하다 '루프로 자식 요소를 검사해서 제거하자!' 하는 아이디어가 생각났어요.

근데 막상 만들고나서 보니 한 줄에 80바이트가 넘어가는 요상한 구조가 되어서요, 뭔가 찝찝한 기분을 안고 살다가 글 올리고 이틀이나 지나서 'innerHTML로 밀어버리면 되는 것 아님?' 했답니다.

거기다 맨날 수정 다했어요~, 오타 없어요~ 하고 글 올리고 나면 그제서야 선명하게 보이는 오타와 비효율... 반복되는 작업에 너무 즐거워서 팔짝팔짝 뛰었던 것이에요!


또, 260601 지금은 라이트박스 스크립트를 아예 함수형으로 바꿔버렸지만 이전에 처음 만들 때 왜이리 엉성하게 만들었던지 모르겠네요. 나중에 보면 이 스크립트도 그렇겠죠?

아무튼 대 개편 하면서 최대한 효율적으로 만들었고요, 접근성 문제 개선을 위해 dialog 같은 최신 기능 사용했어요.

dialog 사용 시 주의점은 오픈할 때 그냥 show() 말고 showModal() 메서드를 사용하면 포커스 트랩, 복원을 '브라우저가 알아서 해 준다' 정도군요.

물론 포커스 트랩이건 포커스 복원이건 브라우저 기능만 믿다 피를 볼 수도 있으니, 둘 다 직접 구현해 놓았답니다. 위쪽, 자바스크립트 구역에서 "포커스 저장, 복원" 부분하고 "탭 누름? 밖에 못 나감" 주석 근처를 살펴보셔요.

스크립트 자체를 함수형으로 교체하면서 동시에 DOM 생성, 삭제도 최소한으로 억제했으니 성능도 약간 빨라졌을 거라고 기대중이예요.

Lightbox: CSS

CSS는 dialog 태그가 자체 스타일을 갖고 있어서 좀 헤매었다, 요정도?

그리고 라이트박스의 검은 반투명 배경을 다이얼로그 백드롭을 썼다는 건데요, backdrop-filter: blur(4px); 요 부분 중요해요.

왜? 저사양 기기에서는 INP가 무지하게 늘어지더라고요. 시험해 보니 스마트폰은 오히려 빠르던데, 구형 노트북에서는 이미지 클릭하고 라이트박스 뜰 때까지 INP가 치솟았죠. 블러를 많이 주면 드물게는 1300ms 대로 올라가기도 했어요.

블러 필터를 끄면? 100~200ms 정도면 끝이었어요. 블러 먹인 게 예쁘긴 하지만 이런 문제가 있다는 건 알고 계셔요.

참고용 코드 조각

아래는 Very Simple Lightbox의 최소화*최소화 버전이에요. 전문 minify 툴을 쓰니 1.7kB 까지 압축 되네요! 테마 HTML 편집해서 </body> 바로 위에 붙여넣으시면 돼요.

본문 선택자는 기본값 그대로니까 에센셜, 콘템포, 소호, 엠포리오, 노터블 이 다섯 테마에서는 문제없을 건데, 다른 올드 테마는 "해 봐야 안다"예요.

<script>
//<![CDATA[
let e,t,n,c,a,o,r,d,l,i,s=0,u=0,m=0,p=0;const h=document.querySelector(".page_body"),y="body.item-view .post-body img";function f(n){a=n,c.src=n.src,l=document.querySelector("header.sticky"),i=window.innerWidth-document.documentElement.clientWidth,i&&(h.style.marginLeft=`-${i}px`,l&&(l.style.marginLeft=`-${i}px`)),e.classList.replace("hide","show"),e.showModal(),t.focus()}function g(){e.classList.replace("show","hide"),h.style.removeProperty("margin-left"),l&&l.style.removeProperty("margin-left"),e.close(),a.focus(),c.src=""}e=document.createElement("dialog"),e.className="lb-container hide",t=document.createElement("button"),t.className="close-btn",t.innerHTML="✕",e.appendChild(t),n=document.createElement("figure"),c=document.createElement("img"),n.appendChild(c),e.appendChild(n),document.body.appendChild(e),document.addEventListener("click",t=>{e.open?t.target!==c&&g():t.target.closest(y)&&f(t.target)}),e.addEventListener("dblclick",g),document.addEventListener("keydown",n=>{e.open?"Escape"===n.key?g():"Tab"===n.key&&(n.preventDefault(),t.focus()):n.target.closest(y)&&"Enter"===n.key&&(n.preventDefault(),f(n.target))}),e.addEventListener("touchstart",e=>{e.touches.length>1?m=!0:(m=!1,s=e.touches[0].clientX,u=e.touches[0].clientY)},{passive:!0}),e.addEventListener("touchend",e=>{if(!m){if(e.timeStamp-p&lt;250)return p=0,e.preventDefault(),void g();p=e.timeStamp,o=e.changedTouches[0].clientX-s,r=e.changedTouches[0].clientY-u,d=Math.hypot(o,r),d>108&&(e.preventDefault(),g())}}),e.addEventListener("cancel",e=>{e.preventDefault(),g()}),document.querySelectorAll(y).forEach(e=>{e.tabIndex=0,e.role="button",e.ariaLabel=`${e.alt||"이미지"} 크게 보기`});
//]]> </script>

Change Log

이 아래는 단순 변경 기록이에요.

노드 복사 -> src 복사 (2026-05-15)

부모 노드까지 통째로 복사 -> img src만 복사하는 간단한 방식으로 변경했어요.

Img의 상위 Figure 태그까지 복사해서 라이트박스에서 Figcaption을 보여주려고 했더니 스타일(정렬) 문제 터졌고요, 커다란 이미지를 써 봤더니 이미지가 Figure를 뚫고 나가 버렸어요!

이미지 크기 해결했더니, 이번에는 높이가 큰 이미지에서 Figcaption이 바닥 아래로 숨어버렸고요! (#°Д°)

저용량에서 쪼끔 이쁘게 하려니 넘 빡쎄서 생각할 수록 머리가 아파요. 까딱하면 주객이 전도할 것 같아서, Figure 복붙 하는 로직을 전부 밀어버리고 단순하게 만들었어요.

* 2026-05-15 저녁에 스크립트 교체하였고, 그 후 블로그에서 동작 문제 없음을 확인했어요.

lb 내부 img의 height 제한 (2026-05-29)

PC에서 매우 큰 사진이 화면보다 커지는 문제가 있어서, css의 .lb-bg-dim img 규칙에 max-height: 100vh; 추가.

접근성 개선 업그레이드 (2026-06-01)

  • 이벤트리스너에 다 몰려있는 구조 -> 함수형으로 교체
  • 접근성 문제 개선
    • 라이트박스 컨테이너 단순 div -> dialog로 변경
    • 본문 내 모든 img 태그에 tabindex 0, button role, aria-label 주입
      • 정확히는 allowArea 상수에 지정된 img 태그
    • 따라서, 탭으로 이미지에 포커스 이동 가능
    • img에 포커스가 있을 때 엔터를 눌러 lb 오픈 가능
    • lb 켜지면 탭 포커스를 가두는 트랩 발동
    • lb 켜졌을 때 esc 키를 눌러 종료 가능
    • PC에서 다음 동작으로 끌 수 있음
      • 빈 영역 클릭
      • 닫기 버튼 클릭
      • 이미지 더블클릭
      • esc 키
      • 닫기 버튼 포커스 시 엔터
    • 모바일에서 다음 동작으로 끌 수 있음
      • 빈 영역 탭
      • 더블 탭
      • 전방향 약 75~100px 스와이프 시
      • 닫기 버튼 누름
    • 실제 닫기 버튼 추가
    • 종료 시 포커스 이전 위치로 복원
    • 모바일 고스트 클릭(탭) 방지
  • 스크롤바 화면 출렁임 억제 코드 개선
  • DOM 생성, 제거 최소화
  • CSS 교체. 심미성을 위해 배경에 블러 적용.

* 버그: 라이트박스 닫은 후 마우스 클릭 시 마지막 포커스 위치로 강제 이동 문제 수정했어요.

닫기 버튼 스타일 문제 수정 (2026-06-05)

  • 단일 색상 -> 외곽선 추가
  • z-index 누락 수정

참고 및 출처