이번에 개인 프로젝트를 새로 런칭하고 설레는 마음으로 크롬 개발자 도구의 Lighthouse 성능 검사를 돌려보았습니다.

결과는 참담하게도 성능 점수 62점, LCP(최초 대형 콘텐츠 도출) 속도 3.2초.

스마트폰 모바일 LTE 환경으로 접속해 보니 메인 페이지 배너 이미지가 위에서부터 턱-턱-턱 둔하게 렌더링되면서 아래쪽에 있던 텍스트들이 밑으로 밀려나는 현상(CLS)까지 발생하고 있었습니다.

"요즘 같은 세상에 로딩에 3초나 걸리는 웹사이트를 누가 기다려줄까?" 하는 위기감이 들었고, 원인을 분석해 보니 아무 생각 없이 넣어둔 고용량 메인 배너 이미지(PNG, 3.8MB)가 범인이었습니다.

오늘은 기본 HTML <img> 태그를 버리고 Next.js의 Image 컴포넌트(next/image)를 도입해 로딩 속도를 0.8초까지 끌어올렸던 생생한 최적화 과정을 공유해 봅니다.

1. 내가 범했던 실수: "원본 PNG 이미지를 그냥 태그에 꽂아 넣었다"

처음엔 그냥 디자인 도구에서 추출한 3.8MB짜리 고해상도 PNG 배너를 public/images 폴더에 넣고 아래처럼 코드를 짰었습니다.

TypeScript
 
// ❌ 처음 내가 작성했던 최적화 안 된 코드
export default function HeroBanner() {
  return (
    <div className="banner-container">
      <img src="/images/main-banner.png" alt="메인 배너" />
    </div>
  );
}

이 코드가 왜 문제였냐면:

  1. 모바일 단말기에서도 3.8MB 원본을 통째로 다운로드받고 있었습니다.
  2. 이미지가 로딩되기 전까지 div 높이가 0px이었다가, 이미지 다운로드가 끝나는 순간 갑자기 400px로 커지며 아래 요소들을 강제로 밀어내는 불쾌한 현상이 일어났습니다.
  3. 브라우저가 PNG 확장자 그대로 다운로드하느라 용량이 전혀 줄어들지 않았습니다.

2. 구원투수: Next.js Image 컴포넌트로 리팩토링하기

Next.js 공식 문서를 뒤져가며 next/image 패키지를 적용해 코드를 고쳐나갔습니다.

TypeScript
 
import Image from 'next/image';

// ⭕ 내 경험을 바탕으로 수정한 최적화 코드
export default function HeroBanner() {
  return (
    <div className="relative w-full h-[400px] overflow-hidden rounded-2xl shadow-lg">
      <Image
        src="/images/main-banner.png"
        alt="메인 프로모션 배너 이미지"
        fill
        priority // 1. LCP 핵심 이미지이므로 지연 로딩을 끄고 최우선 다운로드!
        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
        className="object-cover"
        placeholder="blur" // 2. 이미지가 다 떠오르기 전 부드러운 블러 미리보기 적용
        blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU58EUg=="
      />
    </div>
  );
}

💡 직접 적용해보며 깨달은 꿀팁 2가지

  • priority 속성의 중요성: 메인 페이지 상단에 바로 보이는 배너 이미지에는 반드시 priority를 붙여주어야 합니다. 이걸 안 붙이면 Next.js가 기본적으로 lazy loading(스크롤 내릴 때 로드)을 적용해버려서 오히려 초기 로딩이 더 늦어집니다.
  • fill과 부모 relative 관계: 이미지 크기를 반응형으로 고정할 때 fill을 쓰면 편리한데, 이때 반드시 감싸고 있는 부모 div에 relative 포지션과 height 값이 지정되어 있어야 배너가 깨지지 않고 예쁘게 잡힙니다.

Image 컴포넌트 사용 코드

3. 외부 이미지(Unsplash/S3) 불러올 때 만난 에러와 해결

개발하다가 유저 프로필 사진을 외부 서버 URL로 불러왔을 때 Invalid src prop ... hostname is not configured under images 라는 빨간색 에러 화면을 만났습니다.

Next.js는 보안과 악성 이미지 서버 공격을 막기 위해 허용된 도메인의 외부 이미지만 가져오도록 제한하고 있었습니다. next.config.js 파일에 제가 사용하는 외부 이미지 도메인을 명시해 주니 깔끔하게 해결되었습니다.

JavaScript
 
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.unsplash.com', // 사용 중인 외부 이미지 서버 도메인 추가
      },
    ],
  },
};

module.exports = nextConfig;

4. 최종 결과: 3.8MB ➔ 320KB, 성능 점수 94점 달성!

수정을 마치고 개발 서버를 다시 빌드한 뒤 크롬 개발자 도구 Network 탭을 열어보았습니다.

  • 이미지 포맷 변화: 원본 .png 파일이 브라우저에서 자동으로 최신 압축 포맷인 .webp로 변환되어 들어오고 있었습니다.
  • 용량 감축: 3.8MB ➔ 320KB (약 90% 용량 감소)
  • Lighthouse 점수: 62점 ➔ 94점으로 폭등!

Image 컴포넌트 적용 후 개발자 도구 Network 탭 200 OK 요청 성공 화면

5. 회고 및 느낀 점

이전에는 "이미지는 그냥 img 태그 써서 띄우면 되는 거 아닌가?" 하고 안일하게 생각했었습니다.

하지만 이번에 직접 성능 수치를 측정해 보고 next/image를 도입해 리팩토링해 보면서, 작은 이미지 최적화 하나가 전체 서비스의 품질과 사용자 경험(UX)을 얼마나 크게 좌우하는지 뼈저리게 체감할 수 있었습니다.

앞으로 작성하는 모든 프로젝트에서는 개발 초기 단계부터 이미지 최적화 전략을 적용하면 좋을 것 같습니다!

얼마 전 프로젝트를 마치고 QA(품질 검사) 단계를 진행하던 중, 지인에게 피드백을 받았습니다. "마우스 없이 Tab 키로만 메인 페이지 조작해봤어? 모달 창이 열렸는데 초점이 모달 뒤로 넘어가서 닫을 수가 없네."

순간 아차 싶었습니다. 그동안 저는 단순히 예쁜 화면을 만드는 데만 집착했지, 마우스를 쓰기 힘든 사용자나 스크린 리더를 쓰는 사용자가 내 사이트를 어떻게 이용할지 단 한 번도 깊게 고민해 본 적이 없었던 것입니다.

웹 접근성(A11y)은 배려나 선택이 아닌, 모든 사용자에게 정보를 평등하게 전달하기 위한 기본입니다. 오늘은 제가 프로젝트에서 겪었던 시행착오와 함께, 프론트엔드 개발자가 반드시 챙겨야 할 웹 접근성 실무 적용기를 정리해 보려고 합니다.

1. 시맨틱 마크업: <div> 습관을 버리는 것부터 시작이었다

초보 시절 저의 HTML 코드는 그야말로 <div>와 <span>의 파티였습니다. CSS로 모양만 맞추면 장땡이라고 생각했거든요. 하지만 스크린 리더는 이 <div>들을 그냥 아무 의미 없는 박스로 인식합니다.

 
<!-- ❌ 내가 과거에 짰던 코드 -->
<div className="header">
  <div className="logo">My Logo</div>
  <div className="menu">...</div>
</div>

이걸 깨닫고 난 뒤, 프로젝트 리팩토링을 진행하며 HTML5 시맨틱 태그로 전부 교체했습니다.

  • <header>: 페이지나 섹션의 머리말
  • <nav>: 네비게이션 링크 모음
  • <main>: 문서의 핵심 콘텐츠 (페이지당 한 번만 사용)
  • <article>: 독립적으로 배포 가능한 콘텐츠
  • <footer>: 페이지 하단 정보

태그 하나만 고쳤을 뿐인데, 구글 Lighthouse로 검사해 보니 접근성 점수가 급상승하는 것을 보고 시맨틱 마크업의 위력을 실감했습니다.

2. WAI-ARIA: "아이콘 버튼에 이름을 주세요"

제가 가장 많이 실수했던 부분이 바로 '아이콘 버튼'이었습니다. 돋보기 모양 아이콘이 달린 검색 버튼, 'X' 표시고 되어있는 닫기 버튼을 만들 때, 저는 텍스트 없이 <button><IconX/></button> 형태로만 코드를 짰습니다.

일반 사용자는 'X' 그림을 보고 닫기 버튼인 것을 알지만, 시각 장애인용 스크린 리더는 이 버튼을 만나면 그저 "버튼"이라고만 읽어줍니다. 무슨 버튼인지 알 길이 없는 것이죠.

💡 실무 적용법 (aria-label)

 
<!-- ⭕ 이렇게 바꾸고 나서야 스크린 리더가 "닫기, 버튼"이라고 읽어주기 시작했습니다 -->
<button aria-label="모달 닫기" onClick={closeModal}>
  <CloseIcon />
</button>

코드설명

3. 키보드 접근성: 나를 가장 당황하게 만든 '포커스 트랩'

피드백에서 지적받았던 모달 창 이슈는 바로 '키보드 트랩(Keyboard Trap)' 문제였습니다.

레이어 모달을 띄웠을 때, 키보드의 Tab 키를 계속 누르면 초점이 모달 내부의 [확인], [취소] 버튼에서만 순환해야 합니다. 그런데 제 코드에서는 초점이 모달 뒤쪽에 깔려있는 메인 페이지의 메뉴들로 빠져나가는 현상이 발생했던 것입니다.

CSS
 
/* ❌ 디자인 깔끔하게 한다고 절대 쓰지 말아야 할 CSS 스타일! */
button:focus {
  outline: none; /* 이 코드를 넣는 순간, 키보드 사용자는 내가 어디를 가리키고 있는지 전혀 볼 수 없게 됩니다. */
}

저는 예쁘지 않다는 이유만으로 outline: none을 습관적으로 넣었었는데, 이 퍼런 포커스 테두리가 키보드 사용자에게는 '마우스 커서'와 같은 역할을 한다는 것을 깨닫고 즉시 삭제했습니다. 대신 브라우저 기본 포커스링을 살리거나, 커스텀 스타일(:focus-visible)로 깔끔하게 포커스 영역을 표시하도록 고쳤습니다.

4. 직접 경험해 본 접근성 테스트 방법

이론을 아는 것보다 중요한 건 "내가 직접 짠 사이트를 마우스 없이 써보는 것"이었습니다. 이번 일을 계기로 저는 배포 전 반드시 아래 3가지 과정을 거치고 있습니다.

구글 lighthouse

  1. 마우스 치우기: 오직 Tab, Shift + Tab, Enter 키로만 내 사이트의 모든 메뉴와 폼(Form)을 조작해 봅니다.
  2. Mac VoiceOver 실행: Mac 사용자의 경우 Command + F5를 누르면 스크린 리더가 켜집니다. 눈을 감고 내 사이트의 텍스트가 자연스럽게 읽히는지 들어봅니다. (처음 들었을 때 내가 짠 코드가 얼마나 엉망으로 읽히는지 알고 충격을 받았습니다.)
  3. Lighthouse 검사: 크롬 개발자 도구에서 접근성 점수를 측정하고 레포트에서 지적해 주는 요소(alt 누락, 색상 대비 부족 등)를 하나씩 해결합니다.

5. 글을 마치며: 개발자로서의 시야가 넓어진 계기

예전의 저에게 웹 접근성은 "나중에 시간 남을 때 챙기는 부수적인 작업"이었습니다.하지만 이번에 직접 코드를 고쳐보고 테스트해 보면서, 접근성은 개발 초반 뼈대를 세울 때부터 당연히 고려해야 하는 '기본적인 개발 품질'이라는 것을 배웠습니다.

버스를 탈 때 경사로가 설치되어 있으면 휠체어 이용자뿐만 아니라 유모차를 끄는 부모님, 다리를 다친 사람 모두가 편리해집니다. 웹도 마찬가지입니다. 접근성을 고려해 만든 깔끔한 시맨틱 구조와 키보드 지원은 결국 일반 사용자의 편의성과 검색 엔진 최적화(SEO)로까지 이어집니다.

+ Recent posts