Skip to main content

웹에서 이미지를 다룰 때 알아야 할 것들

· 9 min read

이 글을 읽은 후엔 이미지가 어떤 방식으로 저장되는지, 왜 웹 이미지 최적화가 필요한지, 그리고 프론트엔드와 백엔드에서 어떤 방식으로 이미지를 다뤄야 하는지 알 수 있습니다.

웹 페이지에서 이미지는 <img> 태그에 URL 하나만 넣으면 브라우저가 알아서 사진을 보여준다. 하지만 서비스 규모가 조금만 커져도 이미지는 생각보다 많은 문제를 만든다.

이미지 하나 때문에 페이지 로딩이 느려질 수 있고, 사용자가 업로드한 파일이 서버 자원을 잡아먹을 수도 있다. 심지어 확장자만 .jpg인 악성 파일이 들어오거나, 사진 안에 촬영 위치와 기기 정보 같은 메타데이터가 남아 있을 수도 있다.

그래서 이미지를 단순히 "화면에 보여줄 파일"로만 보면 안 된다. 이미지는 저장 방식, 네트워크 전송, 브라우저 렌더링, 서버 처리, 보안 검증이 모두 얽혀 있는 리소스이다.

이번 글에서는 이미지의 기본 포맷부터 웹에서 이미지를 빠르고 안전하게 제공하는 방법까지 정리해보자.

Raster와 Vector

화면으로 보는 이미지는 크게 Raster 이미지Vector 이미지로 나눌 수 있다.

Raster 이미지는 픽셀이라는 작은 정사각형들이 바둑판처럼 촘촘히 모여 하나의 그림을 완성하는 방식이다. JPG, PNG, WebP 같은 포맷이 여기에 속한다. 사진처럼 색이 복잡하고 자연스러운 이미지는 대부분 Raster 방식으로 저장한다.

Raster 이미지는 픽셀로 이루어져 있기 때문에 확대하면 계단처럼 깨져 보이는 현상, 즉 픽셀화가 발생한다.

하나의 픽셀은 숫자로 저장된다. 가장 흔하게 쓰이는 방식은 빛의 삼원색인 빨강(R), 초록(G), 파랑(B)을 섞는 것이다. 각각의 색상 채널을 0부터 255까지 256단계(8-bit)로 나누어 표현한다.

따라서 RGB 이미지는 한 픽셀을 표현하기 위해 다음과 같은 데이터를 가진다.

  • Red: 8-bit
  • Green: 8-bit
  • Blue: 8-bit

즉 한 픽셀당 총 24-bit(3Byte)를 사용하며, 이를 흔히 트루 컬러(True Color)라고 부른다.

만약 가로 1,000px, 세로 1,000px 크기의 정사각형 사진이 있다고 가정해보자. 픽셀 수는 1,000,000개이고, 각 픽셀이 3Byte를 사용하므로 압축하지 않은 원본 데이터는 약 3MB가 된다.

1,000 * 1,000 * 3Byte = 3,000,000Byte

요즘 스마트폰 카메라로 흔히 찍는 1,200만 화소 사진이라면 픽셀 수만 12,000,000개이다. 압축하지 않은 RGB 데이터로 계산하면 약 36MB가 된다.

12,000,000 * 3Byte = 36,000,000Byte

웹에서 이미지를 압축하지 않고 그대로 서비스한다면 당연히 로딩이 느려질 수밖에 없다. 그래서 JPG, PNG, WebP, AVIF 같은 이미지 포맷은 픽셀 데이터를 더 작은 용량으로 저장하기 위해 다양한 압축 방식을 사용한다.

반면 Vector 이미지는 이미지를 픽셀의 집합이 아니라 수학적 좌표와 도형을 그리는 명령으로 저장한다. SVG가 대표적인 Vector 포맷이다.

예를 들어 화면에 빨간색 원을 그린다면 Raster 방식은 수많은 빨간색 픽셀의 위치를 저장해야 한다. 하지만 Vector 방식은 아래처럼 "어디에, 어느 크기로, 무슨 색의 원을 그릴지"만 저장하면 된다.

<circle cx="50" cy="50" r="40" fill="red" />

브라우저는 이 설계도를 읽고 직접 화면에 그린다. 그래서 Vector 이미지는 아무리 확대해도 계단 현상 없이 선명하다. 로고, 아이콘, 단순한 일러스트처럼 크기가 자주 바뀌고 형태가 단순한 이미지에 잘 맞는다.

하지만 모든 이미지를 Vector로 저장할 수 있는 것은 아니다. 사진처럼 색 변화가 복잡한 이미지를 Vector로 표현하려면 너무 많은 도형과 명령이 필요하다. 이 경우에는 오히려 용량이 커지고 렌더링 비용도 늘어날 수 있다.

풍경 사진처럼 복잡한 이미지는 Raster, 로고나 아이콘처럼 단순하고 확대가 필요한 이미지는 Vector가 잘 어울린다.

이미지 포맷

Raster와 Vector는 이미지를 표현하는 큰 방식이고, JPG, PNG, WebP, SVG 같은 것은 실제 파일 포맷이다. 포맷마다 목적이 다르다. 그래서 "무조건 이 포맷이 좋다"라고 말하기 어렵다.

JPG

JPG는 사진에 강한 포맷이다. 사람이 눈치채기 어려운 정보를 버리는 손실 압축을 사용하여 용량을 크게 줄인다. 배경 사진, 상품 사진, 게시글 썸네일처럼 색이 복잡한 이미지에 적합하다.

하지만 투명도를 표현할 수 없고, 저장을 반복하면 손실이 누적될 수 있다. 그래서 로고나 아이콘처럼 선명한 경계가 중요한 이미지에는 어울리지 않는다.

PNG

PNG는 무손실 압축을 사용한다. 이미지 정보를 보존하기 때문에 선명한 경계, 텍스트, 투명 배경이 필요한 이미지에 좋다.

대신 사진처럼 색이 복잡한 이미지에서는 JPG보다 용량이 커지기 쉽다. 그래서 스크린샷, 간단한 그래픽, 투명 배경 이미지에는 적합하지만 대량의 사진을 PNG로 제공하는 것은 보통 좋은 선택이 아니다.

WebP와 AVIF

WebP와 AVIF는 웹에서 이미지 전송 비용을 줄이기 위해 많이 사용되는 최신 이미지 포맷이다. 둘 다 손실 압축과 무손실 압축을 지원하고, 투명도도 다룰 수 있다.

일반적으로 같은 화질 기준에서 JPG나 PNG보다 더 작은 용량을 기대할 수 있다. 다만 이미지를 어떤 품질로 인코딩했는지, 원본 이미지가 어떤 특성을 가지는지에 따라 절감률은 달라진다.

SVG

SVG는 Vector 이미지 포맷이다. XML 기반의 텍스트 파일이므로 브라우저가 내용을 해석해 화면에 그린다.

로고나 아이콘처럼 단순한 그래픽에는 강력하지만, 사용자가 업로드한 SVG를 그대로 렌더링하는 것은 조심해야 한다. SVG는 단순 이미지처럼 보이지만 내부에 스크립트나 외부 리소스 참조가 포함될 수 있기 때문이다.

로딩 최적화

이미지를 적절한 포맷으로 압축하더라도 용량이 큰 이미지는 존재한다. 어떤 서비스에서는 한 페이지에 수십 장, 많게는 수백 장의 이미지를 보여줘야 한다.

예를 들어 쇼핑몰 메인 페이지에 상품 사진이 100장 있다고 가정해보자. 사용자가 처음 접속했을 때 화면 아래쪽에 있는 이미지까지 전부 다운로드하는 것은 낭비이다. 당장 보이지 않는 이미지를 받느라 첫 화면 렌더링이 느려질 수 있고, 사용자의 네트워크 데이터도 불필요하게 사용된다.

이 문제를 해결하기 위해 사용하는 대표적인 방법이 Lazy Loading이다.

과거에는 JavaScript로 스크롤 위치를 계산하고, 이미지가 화면에 가까워졌을 때 src를 바꿔주는 코드를 직접 작성해야 했다. 하지만 지금은 HTML 이미지 태그에 loading="lazy" 속성을 추가하면 브라우저가 지연 로딩을 처리해준다.

<img src="/products/coffee.jpg" alt="커피 상품 이미지" loading="lazy" />

물론 모든 이미지에 loading="lazy"를 붙이면 되는 것은 아니다. 첫 화면에 반드시 보여야 하는 대표 이미지나 LCP(Largest Contentful Paint)에 영향을 주는 이미지는 지연 로딩하지 않는 편이 낫다. 사용자가 처음 보는 핵심 이미지를 늦게 받으면 오히려 체감 성능이 나빠진다.

사용자의 인터넷 환경이 느리다면 스크롤을 내렸는데 이미지가 1~2초 뒤에 나타날 수 있다. 이때 빈 공간만 보이면 사용자는 답답함을 느낀다.

그래서 다음과 같은 방법을 함께 사용한다.

  • Skeleton UI: 이미지가 들어올 영역을 회색 박스나 애니메이션으로 먼저 표시한다.
  • LQIP: 고화질 이미지를 받기 전에 아주 작은 저화질 이미지를 먼저 보여주고, 이후 고화질 이미지로 교체한다.
  • Blur Placeholder: 저화질 이미지를 흐리게 보여주다가 원본 이미지가 로드되면 선명하게 바꾼다.

이 기법들은 실제 네트워크 속도를 빠르게 만들지는 않는다. 대신 사용자가 "무언가 로딩되고 있다"는 피드백을 받을 수 있도록 만들어 체감 성능을 개선한다.

반응형 이미지

이미지를 최적화할 때 자주 놓치는 부분은 사용자 기기마다 필요한 이미지 크기가 다르다는 점이다.

데스크톱에서는 1200px 너비 이미지가 필요할 수 있지만, 모바일 카드 UI에서는 360px 너비 이미지면 충분하다. 그런데 모바일 사용자에게도 1200px 이미지를 그대로 내려준다면 브라우저는 어차피 이미지를 작게 줄여서 보여줄 것이다. 서버와 네트워크 입장에서는 필요 없는 데이터를 보낸 셈이다.

이 문제를 해결하기 위해 HTML은 srcsetsizes를 제공한다.

<img
src="/images/profile-800.webp"
srcset="
/images/profile-400.webp 400w,
/images/profile-800.webp 800w,
/images/profile-1200.webp 1200w
"
sizes="(max-width: 600px) 400px, 800px"
alt="프로필 이미지"
/>

이렇게 작성하면 브라우저는 화면 크기와 디스플레이 밀도, 네트워크 상태 등을 고려해 더 적절한 이미지를 선택할 수 있다.

고해상도 디스플레이에서는 CSS상 400px 영역에 800px 이미지가 필요할 수 있다. 반대로 작은 화면에서는 1200px 이미지를 받을 필요가 없다. srcset은 이런 선택을 브라우저에게 맡기는 방식이다.

서버 사이드 처리

프론트엔드에서 Lazy Loading과 Placeholder를 잘 적용하더라도 한계가 있다. 원본 이미지 자체가 너무 크면 결국 네트워크 비용은 커지고, 서버 저장소도 빠르게 늘어난다. 그래서 서버는 사용자가 업로드한 이미지를 그대로 저장하고 그대로 제공하면 안 된다. 보통 다음과 같은 처리를 거친다.

리사이징

사용자가 4,000px 너비의 사진을 업로드했다고 해서 서비스 화면에서도 그 크기가 필요한 것은 아니다. 프로필 사진은 댓글에서는 40px 원형 이미지로 보이고, 마이페이지에서는 160px 정도로 보일 수 있다.

서버는 원본 이미지를 서비스에서 실제로 사용하는 크기에 맞춰 줄일 수 있다. 이렇게 하면 저장 용량과 전송 용량을 모두 줄일 수 있다.

포맷 변환

업로드된 JPG나 PNG를 WebP, AVIF 같은 포맷으로 변환하면 같은 체감 화질에서 더 작은 용량으로 제공할 수 있다.

다만 변환은 CPU 비용이 드는 작업이다. 사용자가 업로드하는 시점에 미리 변환할지, 요청 시점에 변환할지, CDN이나 이미지 처리 전용 서버에 맡길지는 서비스 구조에 따라 달라진다.

메타데이터 제거

사진에는 EXIF라는 메타데이터가 포함될 수 있다. 촬영 날짜, 카메라 모델, 회전 정보, 경우에 따라 위치 정보까지 들어갈 수 있다. 서비스에서 이런 정보가 필요 없다면 업로드 처리 과정에서 제거하는 편이 좋다. 개인정보 노출 위험을 줄이고, 아주 작지만 파일 크기도 줄일 수 있다.

파일 검증

확장자가 .jpg라고 해서 진짜 이미지 파일이라고 믿으면 안 된다. 사용자는 파일 이름을 마음대로 바꿀 수 있다. 서버는 최소한 다음과 같은 검증을 해야 한다.

  • 파일 크기 제한
  • MIME 타입 확인
  • 매직 넘버 검사
  • 실제 이미지 디코딩 가능 여부 확인
  • 이미지 가로/세로 크기 제한

특히 이미지 디코딩은 생각보다 중요한 검증이다. 파일 헤더만 그럴듯하게 꾸민 파일을 걸러내고, 실제 픽셀 데이터로 해석 가능한지 확인할 수 있기 때문이다.

CDN과 On-the-fly 변환

이미지를 크기별로 미리 만들어두는 방식은 단순하고 빠르다. 예를 들어 프로필 이미지를 40px, 160px, 400px 세 가지 크기로 미리 저장할 수 있다.

하지만 서비스가 커지면 문제가 생긴다. 원본 하나를 업로드할 때마다 여러 파생 이미지가 생기고, 필요한 크기가 늘어날수록 저장소 용량도 빠르게 증가한다. 디자인이 바뀌어 새로운 이미지 크기가 필요해지면 기존 이미지들을 다시 처리해야 할 수도 있다.

그래서 최근에는 원본 이미지는 하나만 저장하고, 요청 시점에 필요한 크기와 포맷으로 변환하는 방식을 많이 사용한다. 이를 On-the-fly 이미지 변환이라고 부른다.

예를 들어 프론트엔드에서 다음과 같은 URL로 이미지를 요청한다고 해보자.

https://my-cdn.com/profile.jpg?width=160&height=160&format=webp

이 요청을 받은 CDN이나 이미지 처리 서버는 원본 이미지를 가져와 160x160 크기의 WebP 이미지로 변환한 뒤 사용자에게 전달한다. 이후 같은 요청이 다시 들어오면 캐시에 저장된 결과를 바로 반환할 수 있다.

CDN(Content Delivery Network)은 여러 지역에 분산된 서버 네트워크를 사용해 사용자와 가까운 위치에서 콘텐츠를 전달하는 시스템이다. 이미지, JavaScript, CSS, 영상처럼 정적인 리소스를 빠르게 제공하는 데 많이 사용된다.

CDN을 이미지 처리와 함께 사용하면 다음과 같은 흐름을 만들 수 있다.

  1. 원본 이미지는 S3 같은 Object Storage에 하나만 저장한다.
  2. 프론트엔드는 필요한 크기, 포맷, 품질을 URL 파라미터로 요청한다.
  3. CDN 또는 이미지 처리 서버가 원본을 가져와 요청에 맞게 변환한다.
  4. 변환된 결과를 CDN 캐시에 저장한다.
  5. 다음 사용자는 원본 서버까지 가지 않고 캐시된 이미지를 받는다.

이 방식은 서버 저장소를 아끼면서도 사용자 기기에 맞는 이미지를 제공할 수 있다는 장점이 있다. 다만 URL 파라미터를 아무렇게나 허용하면 문제가 생길 수 있다.

예를 들어 사용자가 width=1, width=2, width=3처럼 무한히 다른 크기를 요청하면 CDN 캐시가 쓸모없는 이미지로 가득 찰 수 있다. 이를 방지하려면 허용 가능한 크기와 품질 값을 제한하고, 서명된 URL이나 이미지 변환 정책을 두는 것이 좋다.

이미지를 다룰 때의 기준

이미지 최적화는 하나의 기술로 끝나는 문제가 아니다. 포맷을 바꾸는 것, Lazy Loading을 적용하는 것, CDN을 붙이는 것 모두 각각의 영역에서 효과가 있다.

실제 서비스에서는 다음 기준으로 접근하면 좋다.

  1. 화면 크기에 맞게 이미지를 적절한 크기로 제공한다.
  2. 업로드 이미지는 서버에서 검증하고 메타데이터를 제거한다.
  3. 트래픽이 커지면 CDN과 On-the-fly 변환을 고려한다.

이미지는 단순한 정적 파일처럼 보이지만, 실제로는 사용자 경험과 인프라 비용에 큰 영향을 주는 리소스이다. 특히 이미지가 많은 서비스라면 처음부터 이미지 처리 전략을 정해두는 것이 좋다. 중요한 것은 사용자가 보는 화면에 필요한 만큼만, 안전하게, 빠르게 보내는 것이다.