사전 지연 로딩
패턴

지연 로딩

gabury1고친 사람 github-actions[bot]

지연 로딩은 데이터를 쓰는 순간이 올 때까지 읽어 오기를 미룹니다. 처음에는 꼭 필요한 것만 가볍게 가져옵니다. 나머지는 코드가 손을 댈 때 그 조각만 가져옵니다. 데이터베이스에서 객체를 꺼낼 때도, 웹 페이지가 이미지를 받을 때도 같은 생각을 씁니다.

쉽고 빠른 이해

지연 로딩은 「쓸 때 가져오기」입니다. 사용자 정보를 읽을 때 그 사람이 쓴 글 목록은 아직 안 읽습니다. 코드가 글 목록을 찾는 순간에 읽어 옵니다.

이게 없으면 안 쓸 데이터까지 매번 미리 읽습니다. 화면 하나에 필요한 것은 조금입니다. 그런데 기다리는 시간과 메모리는 전부에 대해 냅니다.

어떻게 도나.

  1. 처음에는 진짜 데이터 대신 「아직 안 읽었다」는 표시만 들고 있습니다
  2. 코드가 그 데이터를 처음 찾으면 그때 읽어 옵니다
  3. 한 번 읽은 것은 들고 있다가 다음부터 바로 돌려줍니다

대가가 있습니다. 반복문 안에서 하나씩 찾으면 읽기가 건마다 따로 나가 오히려 느려집니다. 읽기가 언제 나갈지 코드에 안 보이는 것도 대가입니다.

상세

이 절은 먼저 읽기를 미루면 무엇을 버는지 봅니다. 그다음 진짜 데이터 대신 서 있는 대리 객체가 어떻게 도는지 그림으로 따라갑니다.

그 뒤로는 쓰임새 둘을 봅니다. 데이터베이스에서 객체를 꺼내는 코드와 웹 페이지가 이미지를 받는 브라우저입니다. 끝으로 미뤄서 생기는 대가와, 미루지 않는 편을 고를 때를 짚습니다.

미루어서 버는 것

지연 로딩의 반대편은 즉시 로딩입니다. 즉시 로딩은 쓸지 안 쓸지 모르는 데이터까지 처음에 한꺼번에 읽어 둡니다. 영어로는 eager loading 이라고 부릅니다.

사용자 한 명을 읽는 경우로 보겠습니다. 사용자에게는 쓴 글 목록, 받은 알림, 결제 이력이 딸려 있습니다. 로그인 화면에는 이름 하나만 필요합니다. 즉시 로딩이면 이 화면 하나를 위해 글과 알림과 결제 이력까지 읽습니다.

지연 로딩은 이름만 읽고 나머지를 미룹니다. 안 쓰고 끝난 데이터는 끝내 읽지 않습니다. 그래서 첫 응답이 빨라지고 메모리도 덜 씁니다. 버는 양은 읽지 않고 끝난 데이터의 양과 같습니다.

대리 객체가 대신 서 있는 방식

미루려면 아직 안 읽은 데이터를 무언가가 대신 들고 있어야 합니다. 호출하는 코드는 그 데이터가 이미 있다고 믿고 쓰기 때문입니다. 흔한 방법은 진짜 객체와 겉모양이 같은 대리 객체를 건네는 것입니다.

대리 객체는 진짜 객체와 같은 메서드를 가집니다. 그래서 호출하는 코드는 둘을 구별하지 못합니다. 메서드가 처음 불리면 대리 객체가 그제야 진짜 데이터를 읽어 옵니다. 이렇게 진짜 객체 앞에 서서 호출을 받아 넘기는 객체를 프록시라고 부릅니다.

첫 호출과 두 번째 호출을 이어서 보면 이렇습니다.

sequenceDiagram
    participant 코드 as 호출하는 코드
    participant 대리 as 대리 객체
    participant 데이터베이스
    코드->>대리: 글 목록 줘 · 첫 호출
    대리->>데이터베이스: 글 목록 읽기
    데이터베이스-->>대리: 글 목록
    Note over 대리: 읽은 글 목록을 들고 있는다
    대리-->>코드: 글 목록
    코드->>대리: 글 목록 줘 · 두 번째 호출
    대리-->>코드: 들고 있던 글 목록

두 번째 호출은 데이터베이스까지 가지 않습니다. 읽기는 처음 한 번만 나갑니다. 호출하는 코드에서는 두 호출이 똑같아 보입니다.

미루는 방법 넷

대리 객체 말고도 미루는 방법이 있습니다. 「아직 안 읽음」을 무엇이 표시하느냐에 따라 넷으로 나눕니다.

방법 「아직 안 읽음」을 표시하는 것 읽는 때
지연 초기화 필드에 든 빈 값. 흔히 null 필드를 꺼내는 메서드가 빈 값을 보았을 때
가상 프록시 진짜 객체와 겉모양이 같은 대리 객체 대리 객체의 메서드가 처음 불릴 때
값 보관자 값을 꺼내는 메서드 하나만 가진 상자 그 메서드를 처음 부를 때
고스트 식별자만 채운 진짜 객체 식별자 밖의 필드를 처음 찾을 때

표의 둘째 줄이 앞 소절의 대리 객체입니다. 넷은 표시하는 방법만 다릅니다. 처음 찾을 때 읽고 그 뒤로는 들고 있는다는 점은 같습니다.

ORM 이 관계를 미루는 방식

ORM(Object-Relational Mapping, 객체-관계 매핑)은 데이터베이스 테이블의 행을 프로그래밍 언어의 객체로 바꿔 주는 라이브러리입니다. 테이블 사이의 관계는 객체의 속성으로 보입니다. 글 객체에서 post.author 처럼 점 하나로 작성자를 꺼냅니다.

ORM 은 이런 관계 속성에 지연 로딩을 흔히 기본값으로 둡니다. 글을 읽을 때 작성자 자리에는 「아직 안 읽음」 표시만 넣어 둡니다. 그 표시로는 앞 표의 대리 객체를 주로 씁니다. 코드가 post.author 를 처음 건드릴 때 작성자를 읽는 쿼리가 따로 나갑니다.

Django 로 쓰면 이렇습니다. 오른쪽 주석이 그 줄에서 나가는 쿼리 수입니다.

Python
posts = Post.objects.all()   # 쿼리 0번
for p in posts:              # 쿼리 1번
    print(p.author.name)     # 글마다 1번

관계 지연 로딩은 셋째 줄에서 일어납니다. p.author 는 글마다 작성자를 읽는 쿼리를 하나씩 보냅니다.

첫 줄이 쿼리를 안 보내는 것은 이와 다른 미룸입니다. Post.objects.all() 이 돌려주는 QuerySet 은 보낼 쿼리를 적어 둔 객체입니다. 반복문이 처음 꺼낼 때에야 데이터베이스에 갑니다. 이 미룸은 끝 소절에서 따로 봅니다.

반복문이 부르는 N+1 문제

글이 백 개면 쿼리는 목록 한 번에 작성자 백 번, 모두 백한 번이 나갑니다. 목록 쿼리 1번에 건마다 N번이 붙는다고 해서 이것을 N+1 문제라고 부릅니다. 쿼리 하나하나는 가볍습니다. 대신 데이터베이스까지 오가는 왕복이 백한 번 쌓입니다.

쓸 것을 안다면 그 관계만 미루지 않게 바꿉니다. 목록을 읽을 때 작성자까지 한 쿼리로 함께 읽어 오라고 ORM 에 적어 둡니다. Django 에서는 select_related 가 그 지시입니다.

Python
posts = Post.objects.select_related("author")
for p in posts:              # 쿼리 1번
    print(p.author.name)     # 쿼리 0번

기본값은 지연 로딩으로 둡니다. 반복문에서 쓸 관계만 골라 즉시 로딩으로 돌립니다. 지연 로딩을 쓰는 코드에서 가장 자주 하는 손질이 이것입니다.

연결이 닫힌 뒤의 첫 접근

대리 객체가 나중에 읽으려면 데이터베이스 연결이 살아 있어야 합니다. ORM 에서는 연결과 읽어 온 객체를 함께 관리하는 단위를 세션이라고 부릅니다.

세션이 닫힌 뒤에 대리 객체를 처음 건드리면 읽어 올 길이 없습니다. 이때 ORM 은 예외를 던지거나 새 연결을 따로 엽니다. Hibernate 의 LazyInitializationException 이 예외를 던지는 쪽의 대표입니다.

흔히 걸리는 때는 웹 요청의 끝입니다. 요청을 처리하는 코드가 객체를 넘기고 세션을 닫습니다. 그 뒤 화면을 그리는 단계에서 관계 속성을 처음 건드립니다.

웹 페이지의 지연 로딩

프론트엔드에서도 같은 생각을 씁니다. 미루는 대상이 객체가 아니라 이미지와 스크립트 파일이라는 점이 다릅니다.

긴 페이지에는 스크롤을 한참 내려야 보이는 이미지가 많습니다. 이것을 페이지를 열 때 전부 받으면 첫 화면과 상관없는 전송이 끼어듭니다. 지연 로딩은 이미지가 화면에 보이는 영역에 가까워졌을 때 받습니다.

HTML(HyperText Markup Language) 이미지 태그에는 이 동작을 켜는 속성이 있습니다.

HTML
<img src="cat.jpg" loading="lazy"
     width="600" height="400">

loading="lazy" 가 받기를 미루라는 지시입니다. 언제 받을지는 브라우저가 스크롤 위치를 보고 정합니다.

받는 때를 직접 정하고 싶으면 Intersection Observer 를 씁니다. 요소가 화면에 보이는 영역에 들어오는지를 브라우저가 알려 주는 자바스크립트 기능입니다. 알림을 받으면 그때 이미지 주소를 넣어 받기 시작합니다.

스크립트도 같은 방식으로 미룹니다. 앱의 자바스크립트 코드를 한 파일로 묶은 것을 번들이라고 부릅니다.

번들을 여러 조각으로 나눠 그 화면에 들어갈 때 해당 조각만 받게 하는 것이 코드 분할입니다.

웹에서 미뤄서 생기는 대가

첫 화면에 보이는 이미지를 미루면 오히려 늦어집니다. 브라우저가 페이지 배치를 계산한 뒤에야 받기 시작하기 때문입니다. 사용자가 가장 먼저 볼 그림이 그만큼 늦게 뜹니다.

크기를 적지 않은 이미지를 미루면 레이아웃 이동이 생깁니다. 이미지가 도착하는 순간 그 높이만큼 공간이 생겨 아래 글이 밀려납니다. 위 예에서 width 와 height 를 적은 것은 그 공간을 미리 잡아 두려는 것입니다.

미룰 때와 미루지 않을 때

고르는 기준은 둘입니다. 그 데이터를 쓸 가능성이 얼마나 되나, 그리고 나중에 따로 읽을 때 비용이 얼마나 드나입니다.

상황 고를 방식 까닭
대부분의 요청이 안 쓰는 데이터 지연 로딩 안 쓰고 끝나면 읽기 비용이 없다
목록을 돌며 건마다 쓰는 관계 즉시 로딩 미루면 N+1 문제가 난다
연결이 닫힌 뒤에 쓰는 데이터 즉시 로딩 닫힌 뒤에는 읽어 올 길이 없다
스크롤해야 보이는 이미지 지연 로딩 첫 화면 전송이 가벼워진다
첫 화면에 보이는 이미지 즉시 로딩 미루면 가장 먼저 볼 그림이 늦는다

같은 이름의 다른 쓰임

캐시를 다루는 글에서도 lazy loading 이라는 이름이 나옵니다. 캐시에 값이 없을 때만 원본에서 읽어 채우는 cache-aside 를 그렇게 부르기도 합니다. 필요해질 때 읽는다는 생각은 같습니다. 미루는 대상이 캐시 항목이라는 점이 다릅니다.

값을 쓰는 순간까지 계산을 미루는 것은 지연 평가라고 부릅니다. 지연 로딩이 읽기를 미룬다면 지연 평가는 계산을 미룹니다. 앞의 QuerySet 이 쿼리를 적어만 두고 보내지 않는 것은 지연 평가에 가깝습니다.

이름의 「지연」은 응답이 늦어진다는 뜻의 지연과 다릅니다. 영어로 latency 가 아니라 lazy, 곧 미룬다는 뜻입니다.

관련 항목

지연 로딩과 맞세워지는 대립 방식

즉시 로딩 · 프리페치 · 미리 읽기 · 캐시 워밍

지연 로딩을 이루는 구현 방법

지연 초기화 · 가상 프록시 · 프록시 패턴 · 동적 프록시 · 값 보관자

지연 로딩을 지원하는 ORM

ORM · Django · SQLAlchemy · Hibernate · JPA · Entity Framework

지연 로딩에서 자주 나는 문제

N+1 문제 · LazyInitializationException · 레이아웃 이동 · 슬로 쿼리

웹 페이지에서 받기를 미루는 기법

코드 분할 · Intersection Observer · 동적 임포트 · 이미지 최적화

필요할 때까지 일을 미루는 다른 기법

지연 평가 · cache-aside · 요구 페이징 · 쓰기 시 복사 · 캐싱

지연 로딩이 속하는 상위 분류

디자인 패턴 · 성능 · 프론트엔드 · 데이터베이스 · 백엔드

다른 이름: Lazy Load · 레이지 로딩 · 지연 로드 · 게으른 로딩