고해상도 이미지를 웹 사이트에 사용하다 프레임이 끊기는 현상을 마주했을 것이다. 프론트엔드에서 이미지 최적화-로딩 처리 방법이 궁금하던 중 미디엄(Medium)의 블로그 글 내 로딩 효과가 눈에 들어왔다. 이미지 영역에 회색 배경화면이 뜨고, 블러된 이미지가 불러오면서 원본 이미지를 불러오는 과정인데, 트랜지션이 끊김없이 매우 부드럽다. 방법을 검색하던 중, 링크드인 소프트웨어 엔지니어인 José M. Pérez의 솔루션을 찾게 되었고, 장고 블로그에 적용해보았다.

그가 구현한 내용은 아래와 같다.

  1. 이미지가 표시 될 <div/>를 렌더링한다. 미디엄은 <div/>를 만들어 padding-bottom에 이미지의 비율을 백분율로 <div/>를 사용한다. 최종 이미지 위치 위에 렌더링되어 이미지가 로드되는 동안 리플로우(reflow: 컴퓨터 화면에서 텍스트가 차지하는 공간을 조정하는 것)가 일어나는 것을 방지한다. 고해상도 이미지의 경우 끊겨서 로딩되는데 이를 해결해준다는 뜻이다. 또 다른 말로는 '고유위치(intrinsic placeholders)'라고 하는데 해당 이펙트가 일어날 이미지 영역을 미리 잡아놓는 것이다.

  2. 작은 사이즈 이미지를 로딩한다. 최저해상도(원본의 20 % 정도) 사이즈가 작은 썸네일 이미지를 요청해 매우 낮은 화질으로 요청한다. 해당 썸네일 이미지의 마크업은 HTML에서 <img />로 반환되므로 브라우저가 바로 그 이미지를 가져오기 시작한다.

  3. 저해상도 이미지가 로드되면 <canvas />에 그려진다. 그런 다음 이미지 데이터를 가져와 blur() 함수를 전달한다. 동시에 원본 이미지가 요청된다.

  4. 원본 해상도 이미지가 로드되면 이미지가 표시되고 캔버스가 숨겨진다.

  5. CSS 애니메이션을 적용해 매우 부드러운 전환이 일어난다.

Markup

마크업 구조를 작성하면 아래와 같다.

<figure>
  <div>
    <div/> <!--  세로 비율을 유지하므로 placeholder 영역이 축소되지 않는다. -->
    <img/> <!--  해상도가 ~ 27x17, 저품질인 작은 이미지다. -->
    <canvas/> <!-- 위의 이미지를 취하여 흐리게(blur) 필터를 적용한다. -->
    <img/> <!-- 큰(원본) 이미지를 표시한다. -->
    <noscript/> <!-- 폴백(fallback) -->
  </div>
</figure>

위 골격을 토대로 아래와 같이 작성할 수 있다.

<figure name="7012" id="7012" class="graf--figure graf--layoutFillWidth graf-after--h4">
  <div class="aspectRatioPlaceholder is-locked">
    <div class="aspect-ratio-fill" style="padding-bottom: 66.7%;"></div>
    <div class="progressiveMedia js-progressiveMedia graf-image is-canvasLoaded is-imageLoaded" data-image-id="1*sg-uLNm73whmdOgKlrQdZA.jpeg" data-width="2000" data-height="1333" data-scroll="native">
      <img src="https://cdn-images-1.medium.com/freeze/max/27/1*sg-uLNm73whmdOgKlrQdZA.jpeg?q=20" crossorigin="anonymous" class="progressiveMedia-thumbnail js-progressiveMedia-thumbnail">
        <canvas class="progressiveMedia-canvas js-progressiveMedia-canvas" width="75" height="47"></canvas>
        <img class="progressiveMedia-image js-progressiveMedia-image __web-inspector-hide-shortcut__" data-src="https://cdn-images-1.medium.com/max/1800/1*sg-uLNm73whmdOgKlrQdZA.jpeg" src="https://cdn-images-1.medium.com/max/1800/1*sg-uLNm73whmdOgKlrQdZA.jpeg">
        <noscript class="js-progressiveMedia-inner">&lt;img class="progressiveMedia-noscript js-progressiveMedia-inner" src="https://cdn-images-1.medium.com/max/1800/1*sg-uLNm73whmdOgKlrQdZA.jpeg"&gt;</noscript>
    </div>
  </div>
</figure>

데모

이처럼 자바스크립트로 이미지를 제어함으로써 시각적인 눈속임을 통해 이미지 렌더링을 효과적으로 처리할 수 있다.

  1. 로딩을 유연하게 제어한다. 자바스크립트를 통해 요청된 이미지를 제어 할 수 있다. 한 페이지 내 모든 작은 썸네일을 동시에 요청하는 반면에, 뷰포트 내에서만 큰 이미지 로딩을 요청할 수 있다.

  2. placeholder 위치를 명확히 인식할 수 있다. 페이로드를 포기하지 않고 해당 영역에 색감을 주고, 흐릿한 효과를 줌으로써 해당 영역을 인지가 가능하다. 썸네일은 2KB로 용량이 적다.

  3. 디바이스 별로 다른 이미지 크기를 제공할 수 있어 페이지 로딩 속도를 최적할 수 있다.

django에 적용하기

나의 경우, 장고웹프레임워크로 개인 웹 사이트를 만들면서 적용해보았다. 이미지를 db에서 저장 시, 설정된 확장자, 사이즈 등에 따라 이미지 최적화를 시켜야했는데, 이를 위해 django-imagekit를 사용했다.

설치는 매우 간단하다.

  • PIL 또는 Pillow가 설치되어야 한다. 이미 장고에 ImageField가 있다면 이미 설치가 되어 있을 것이다.
  • pip install django-imagekit로 패키지를 설치한다.
  • 프로젝트 폴더 내 settings.py 파일을 열어 INSTALLED_APPS 리스트에 imagekit을 추가한다.

settings.py

INSTALLED_APPS = [
    #...
    'imagekit',
]

models.py

게시글에 해당하는 Post 모델에서 imagekit model spec과 processors을 새로 정의했다. 썸네일의 processors, format, opition을 정의했다. 아래는 추가한 코드이다.

from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFill

image = models.ImageField(upload_to='myblog/image/blog')
image_thumbnail = ImageSpecField(source='image',
                                    processors=[ResizeToFill(100, 50)],
                                    format='JPEG',
                                    options={'quality': 60})

Post 모델은 아래와 같다.

import uuid
from django.db import models
from django.utils import timezone
from tagging.fields import TagField
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFill
from simplemde.fields import SimpleMDEField

class Post(models.Model):
    category = models.ForeignKey('Category')
    author = models.ForeignKey('auth.User')
    image = models.ImageField(upload_to='myblog/image/blog')
    image_thumbnail = ImageSpecField(source='image',
                                    processors=[ResizeToFill(100, 50)],
                                    format='JPEG',
                                    options={'quality': 60})
    title = models.CharField(max_length=200, verbose_name=u'Title')
    summary = models.CharField(max_length=1000)
    body = SimpleMDEField(verbose_name=u'')
    created_date = models.DateTimeField(default=timezone.now)
    published_date = models.DateField(default=timezone.now)
    slug = models.SlugField(unique=True)
    likes = models.PositiveIntegerField(default=0)
    tag = TagField()

    @property
    def total_likes(self):
        return self.likes.count()

    def __str__(self):
        return self.title

    def publish(self):
        self.published_date = timezone.now()
        self.status == self.STATUS_PUBLIC
        self.save()

template

게시글 리스트에 각 게시글 대표 이미지에 해당하는 부분의 마크업을 아래와 같이 수정했다. templates/blog/post_list.html

<picture>
    <div class="placeholder" data-large="{{ post.image.url }}">
  <img src="{{ post.image_thumbnail.url }}" class="img-small">
  <div class="img_area"></div>
  </div>
</picture>

JS

Pérez의 코드를 참고해 자바스크립트를 다시 작성했다.
페이지 내에 있는 모든 이미지를 select하여 핸들링해야했기 때문에, .placeholder 클래스를 가지고 있는 모든 div를 querySelectorAll로 부르고 반복문을 통해 차례대로 큰 이미지(imageLarge)를 로드했다.

function loadPreImage() {
    let placeholder = document.querySelectorAll('.placeholder'),
        imgSmall = document.querySelectorAll('.img-small');

    for (let i=0; i < placeholder.length; i++) {
         // 1. Load small image and show it
        let img = new Image();
        img.src = imgSmall[i].src;
        img.onload = function () {
            imgSmall[i].classList.add('_loaded');
        };

        // 2. Load large image
        let imgLarge = new Image();
        imgLarge.src = placeholder[i].dataset.large;
        imgLarge.onload = function () {
            imgLarge.classList.add('_loaded');
        };
        placeholder[i].appendChild(imgLarge);
    };
}

css

스타일링은 아래와 같다.

.img_area {
    padding-bottom: 66.6%;
}

.placeholder {
    background-color: #f6f6f6;
    background-size: cover;
    background-repeat: no-repeat;
    position: relative;
    overflow: hidden;
}

.placeholder img {
    position: absolute;
    opacity: 0;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;   
    transition: opacity 1s linear;
}

.placeholder img._loaded {
    opacity: 1;
    height: 100%;
}

.img-small {
    filter: blur(50px);
    /* this is needed so Safari keeps sharp edges */
    transform: scale(1);
}

template

그리고 다시 템플릿으로 돌아와 jsblock에 window.onload = loadPreImage()를 추가해 페이지(이미지, CSS, 스크립트 등)를 포함하여 전체 페이지가 로드되면 시작되게 한다.

{% block jsblock %}
<script>window.onload = loadPreImage();</script>
{% endblock %}

Output

최종 결과는 아래와 같다.

demo-effect-img-loading