코딩/기타

무한 스크롤 구현하기 (intersections observers로 스크롤 페이지네이션)

작은코딩 2022. 4. 10. 19:13

Setting.

내배캠 1기 최종 프로젝트인 greendoor를 작업하면서 전체 피드 리스트를 볼 때 페이지네이션 기능이 필요하다고 판단되어 JS를 이용한 무한 스크롤을 구현해 보았다.

 

언어 : python, javascript

프레임워크 : django

 

https://grdr.net 

 

GREENDOOR

플랜테리어 커뮤니티 스토어

grdr.net


페이징 기능 종류.

어떻게 페이지 네이션을 할까 검색을 해봤는데 크게 3가지 페이징 기능을 찾을 수 있었다.

 

1. page 번호를 클릭하면 해당 페이지를 보여주는 번호 페이지네이션

2. 스크롤 시 정해진 조건을 충족하면 이어지는 자료를 현재 페이지 하단에 붙여서 보여주는  스크롤 페이지네이션

3. 더보기 버튼을 누르면 이어지는 자료를 현재 페이지 하단에 붙여서 보여주는 더보기 페이지네이션

 

세 가지 방법 중에 고민을 했는데 2번 스크롤을 통한 페이지네이션을 구현하기로 결정을 했다.

이렇게 결정하게 된 이유는 다음과 같다.

 

1. 디자인

greendoor는 심플한 디자인에 커뮤니티 페이지에서는 피드 이미지만 보여주고 그 이미지를 통해서 피드 디테일 페이지로 넘어가기에 번호나 더보기 버튼을 눌러서 다음 페이지 자료를 가져오건 불 필요한 디자인이 추가된다 생각했다. 

 

2. 사용자 경험

greendoor 서비스는 웹 기반 서비스지만 데스크탑으로 사용자가 유입되기보단 모바일로 유입되기 쉬운 서비스이다. 따라서 모바일 환경에서 page버튼이나 더보기 버튼을 누르기보다 아래로 스크롤하는 모션을 통해서 다음 페이지를 불러오는 게 효과적이고 사용자의 클릭수를 줄일 수 있는 방법이기도 하다. 

 

거창하게 두 가지 이유를 붙여봤지만 사실 무한 스크롤이 좀 더 고급 기술 같아서 시도해 보았다.

 


무한 스크롤 구현하기.

이렇게 무한 스크롤을 구현하기 위한 의사결정이 이루어졌다. 이제 무한 스크롤을 구현하기 위한 방법에 대해 알아보자.

 

무한 스크롤을 구현하기 위한 방법.

무한 스크롤을 구현하기 위한 방법은 크게 2가지 방법이 있다. 

1. scroll listener (스크롤 리스너)

2. intersection observers (교차로 관찰자)

 

이 두 가지 방법은 비슷한 효과를 가져오긴 하지만 작동하는 방식에 있어서 차이가 있는데 1번은 스크롤 동작하는 이벤트를 감지해서 특정 비율로 스크롤이 이루어졌을 때 콜백 함수를 실행시키는 방법이고 2번은 관찰 대상을 선정하고 관찰 대상이 view 포트에 들어오는걸 감지해서 콜백함수를 실행시키는 방법이다. 

 

두 가지 방법을 내가 모두 사용해보고 성능을 테스트해 봤다면 어떤 방법으로 구현할지 선택이 쉬웠을 텐데 그렇지 못했기 때문에 scroll listener vs intersection observers로 검색을 해봤고 애매한 결론을 도출할 수 있었다. 

 

1. 스크롤 리스너 방법은 스크롤이 아주 미세하게 움직여도 수많은 트리거가 발생하게 되고 이는 성능 저하의 주된 원인이 된다. 

2. 하지만 특정 상황에서는 스크롤 리스너가 더 효율적일 수 있다. 

3. 교차로 관찰자는 특히 앱에서(모바일) 더 나은 성능을 제공한다. 

 

교차로 관찰자가 항상 효율적인 건 아니지만 모바일 같이 스크롤 사용이 빈번한 환경에서는 더 좋은 선택이 될 수 있다는 결론을 내릴 수 있었다. 

 

[🎁 intersection observers 간단 설명]

intersection observer는 Target Element 가 화면에 노출되었는지 여부를 간단하게 구독할 수 있는 API이다.

 

[참고 자료]

https://itnext.io/1v1-scroll-listener-vs-intersection-observers-469a26ab9eb6

 

Scroll listener vs Intersection Observers: a performance comparison

The observer API has landed for some time now and is fully supported by all modern browsers. One of them, is the IntersectionObserver…

itnext.io

 


무한 스크롤 구현 코드.

기능과 방법을 선정했으니 이제 개발을 시작할 차례다. 

 

여기부터는 무한 스크롤에 사용한 코드를 views.py와 html, css, js 별로 보여주고 사용자가 실제 서비스를 이용할 때 어떻게 작동되는지 분석해 보겠다. 

 

(코드를 확인하려면 각 파일 아래 더보기를 클릭해 주세요!)

 

views.py

더보기
 ···

# 클라이언트에서 전해준 page 값을 저장 (default : none -> 1, "" -> 1)
page = int(request.GET.get("page", 1) or 1)
limit = 18
offset = limit * (page - 1)

# 피드 리스트 가져오기
all_feed = get_feed_list(user_id, offset, limit)

# 첫 페이지라면
if offset == 0:
    popular_feeds = get_popular_feed_list(user_id, offset, 6)
    return render(request, "index.html", {"all_feed": all_feed, "popular_feeds": popular_feeds})

# 비동기식
# offset이 0이 아닐경우 // ajax로 page 2가 넘어오면 offset = 18
data = serializers.serialize("json", list(all_feed))
return HttpResponse(json.dumps(data), content_type="application/json")

 

index.html

더보기
 ···

<div class="feed-container">
    <h3>인기 피드</h3>
    <div class="popular-feed">
        <div class="feed-img">
            {% for feed in popular_feeds %}
                <a href="{% url 'feed:feed' feed.id %}"><img class="image" src="{{ feed.image }}"></a>
            {% endfor %}
        </div>
    </div>
    <h3>최신 피드</h3>
    <div class="new-feed">
        <div class="feed-img" id="new-feed-img">
            {% for feed in all_feed %}
                <a href="{% url 'feed:feed' feed.id %}"><img class="image" src="{{ feed.image }}"></a>
            {% endfor %}
        </div>
    </div>
    <div class="skeleton" id="skeleton-off">
        <div class="feed-img">
            <img class="image" src="/static/img/skeleton.jpg">
            <img class="image" src="/static/img/skeleton.jpg">
            <img class="image" src="/static/img/skeleton.jpg">
            <img class="image" src="/static/img/skeleton.jpg">
            <img class="image" src="/static/img/skeleton.jpg">
            <img class="image" src="/static/img/skeleton.jpg">
        </div>
    </div>
</div>

<!--무한 스크롤 관찰 대상-->
<div class="list"></div>
<br><br>
<br><br>
<p id="sentinel"></p>


<script defer src="https://code.jquery.com/jquery-3.4.1.js"></script>
<script defer type="text/javascript" src="/static/js/index.js"></script>

 

index.css

더보기
.spinner {
        margin: 0 auto;
        display: block;
        width: 150px;
    }

#skeleton-off {
    display: none;
}

 

index.js

더보기
const makeSpinner = () => {
        const spinner = document.createElement('div');
        const spinnerImage = document.createElement('img');
        spinner.classList.add('loading');
        spinnerImage.setAttribute('src', '/static/img/spinner.gif');
        spinnerImage.classList.add('spinner');
        spinner.appendChild(spinnerImage);
        return spinner;
    };

const list = document.querySelector('.list');
const spinner = makeSpinner();

const addSkeleton = () => {
    const skeleton = document.querySelector('.skeleton');
    skeleton.setAttribute('id', 'skeleton-on');
};

const removeSkeleton = () => {
    const skeleton = document.querySelector('.skeleton');
    skeleton.setAttribute('id', 'skeleton-off');
};


const loadingStart = () => {
    addSkeleton();
    list.appendChild(spinner);
};

const loadingFinish = () => {
    removeSkeleton();
    list.removeChild(spinner);
};

// 비 동기식 으로,,,
function addNewContent() {
    $.ajax({
        type: "GET", // request 전달 방식 (POST, GET 등)
        url: newFeedURl,
        headers: {//헤더에 csrf 토큰 추가
                'X-CSRFToken': csrf
            },
        data: {// json 형식으로 서버에 데이터 전달
            "page": page // page 번호를 서버에 전달 // 따로 인자로 page를 받지 않아도 증감 연산된 page가 자동으로 할당되는지 여부 확인
        },
        dataType: "json", // json 형식으로 데이터 주고 받기
        success: function (result) {
          const data = JSON.parse(result)
          // data 반복문으로 태그 넣기
          for (let i = 0; i < data.length; i++) {
              let rowData = data[i];
              const appendNode = `<a href="/feed/${rowData.pk}"><img class="image" src="${rowData.fields.image}"></a>`;
              $("#new-feed-img").append(appendNode);
          }
          // 가져온 데이터가 18개 이면 더 가져올 데이터가 있다고 판단 다시 관찰 시작
          // 18개가 아닐경우 더이상 가져올 데이터가 없다고 판단 관찰 중지상태로 끝내기
          if (data.length === 18) {
              observer.observe(sentinel);
          }
        },
        error: function (request, status, error) {
          console.log(`code: ${request.status} \nmessage: ${request.responseText}\nerror: ${error}`)
          console.dir(request)
          console.dir(status)
          console.dir(error)
          console.log(`request: ${request}`);
          console.log(`status: ${status}`);
          console.log(`error: ${error}`);
        },
        beforeSend: function () { // ajax 보내기 전
            loadingStart();
            console.log("페이지 스크롤 시작");
            // 통신 시작할 때 관찰 끄기
            observer.unobserve(sentinel);
        },
        complete: function () { // ajax 완료
            loadingFinish();
            console.log("페이지 스크롤 끝");
        }
    });
}



// target 선언
const sentinel = document.querySelector("#sentinel");

// option 설정
const option = {
    root: null, //viewport
    rootMargin: "0px",
    threshold: 1, // 전체(100%)가 viewport에 들어와야 callback함수 실행
};

// callback 함수 정의
const callback = (entries, observer) => {
    entries.forEach(entry => {
        if(entry.isIntersecting) {
            page ++; // 2부터 시
            //console.log(page);
            addNewContent();
        }
    });
};

// IntersectionsObserver 생성
const observer = new IntersectionObserver(callback, option);

// target 관찰
observer.observe(sentinel);

 


사용자의 서비스 이용에 따른 코드 리뷰.

페이지 방문.

처음에 사용자가 기본 주소로 (grdr.net) 접근을 한다면 views.py 페이지 함수를 호출한다.

# views.py

# 클라이언트에서 전해준 page 값을 저장 (default : none -> 1, "" -> 1)
page = int(request.GET.get("page", 1) or 1)
limit = 18
offset = limit * (page - 1)

# 피드 리스트 가져오기
all_feed = get_feed_list(user_id, offset, limit)

기본 페이지 방문 시 클라이언트에서 page 정보를 서버로 주지 않기 때문에(혹은 주더라도 1) default로 page 변수에는 1 값이 할당되고 계산식에 의해서 offset은 0이 된다. 

그러면 get_feed_list라는 함수로 피드를 offset(0)부터 limit(18) 앞 까지 18개 인스턴스가 담긴 쿼리셋을 가져오고 all_feed라는 변수에 저장한다. 


# 첫 페이지라면
if offset == 0:
    popular_feeds = get_popular_feed_list(user_id, offset, 6)
    return render(request, "index.html", {"all_feed": all_feed, "popular_feeds": popular_feeds})

offset이 0이기 때문에 조건문을 타게 되며 index.html을 렌더 하면서 피드 쿼리셋을 클라이언트로 전달한다. 


이제 사용자에게 클라이언트가 받은 html이 렌더가 된다.

// index.html

<div class="skeleton" id="skeleton-off">
    <div class="feed-img">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
    </div>
</div>

인기 피드와 최신 피드가 보이는데 기본 골격 이미지를 가진 skeleton 클래스의 div는 css display none 속성으로 사용자에게 보이지 않는다. 

/* index.css */

#skeleton-off {
    display: none;
}

 

이제 html 페이지 가장 하단의 index.js가 임포트 되는데 코드를 살펴보면 함수와 변수들이 세팅이 되어있는 걸 볼 수 있다. 

 

자세한 건 무한 스크롤 로직을 타면서 설명을 하고 여기서 먼저 봐야 할 부분은  intersection observers 세팅 부분이다.

// target 선언
const sentinel = document.querySelector("#sentinel");

// option 설정
const option = {
    root: null, //viewport
    rootMargin: "0px",
    threshold: 1, // 전체(100%)가 viewport에 들어와야 callback함수 실행
};

// callback 함수 정의
const callback = (entries, observer) => {
    entries.forEach(entry => {
        if(entry.isIntersecting) {
            page ++; // 2부터 시
            //console.log(page);
            addNewContent();
        }
    });
};

// IntersectionsObserver 생성
const observer = new IntersectionObserver(callback, option);

// target 관찰
observer.observe(sentinel);

먼저 관찰을 할 타깃 태그를 설정해 준다. 여기서는 sentinel 변수에 #sentinel id를 가진 p 태그를 저장했다.

 

intersection observers는 기본적으로 2가지 인자가 필요한데 하나는 콜백 함수이고 다른 하나는 옵션이다. 

option 변수에 옵션을 설정하는데 root와 rooMargin은 default 값을 넣어놨기에 패스하고 중요하게 봐야 할 건 threshold이다.

threshold는 viewport에 관찰자가 얼마큼 들어왔을 때 콜백함수를 실행할지 여부를 판단하는 비율인데 1은 100% 0.5 는 50%를 나타내며 사용자가 보는 화면(viewport)에 관찰대상이 얼만큼 들어와 있는지 판단하는 게 옵저버 기능의 핵심이라고 할 수 있다. 

 

다음은 callback 함수인데 callback 함수가 실행이 되면 1을 할당해놓은 page 변수에 1을 더해주고 addNewContent() 함수를 호출한다.

 

이제 만들어진 callback 함수와 option을 인자로 가지는 observer를 만들어 주고 관찰대상으로 sentinel 변수를 넣어준다.

 


🎈 여기서 잠깐!

무한 스크롤을 구현해본 사람이라면 여기까지의 코드를 봤을 때 의문점이 생길 것이다. 왜냐하면 이 코드는 무한 스크롤을 구현했지만 무한 스크롤을 사용하고 있지 않기 때문이다. 

이게 무슨 말이냐면,, 무한 스크롤이라는 말은 사용자가 기다림 없이 스크롤을 계속 내리면서 자료를 볼 수 있는 기능을 말하는 건데 나는 일부로 스켈레톤 클래스를 사용해서 골격 이미지를 보여주고 사용자에게 "새로운 페이지를 가져오고 있어~ "라는 메시지를 주고 있기 때문이다. 

(실제로 아래 테스트한 결과를 보면 페이지의 가장 하단에 있는 비어있는 p태그가 화면에 들어와야 다음 페이지를 가져오는 걸 볼 수 있다. )

이건 개발자(나)가 의도한 거니 만약 진짜 무한 스크롤을 구현하고 싶다면 옵저버 관찰 대상을 피드의 마지막 card로(서비스 속도에 따라 좀 더 앞에 있는 피드를 설정해야 할 수도 있다. ) 설정하고 threshold를 0.1~0.5 정도 주면 사용자가 마지막 피드를 화면으로 확인할 때 미리 다음 페이지를 가져와서 기다림 없이 무한 스크롤로 자료를 확인할 수 있다. 

(새로운 피드가 렌딩 되면 관찰자를 렌딩 된 마지막 피드 card로 다시 설정해 줘야 한다)

 

 

<모바일 환경에서 보이는 모습> 

https://grdr.net 접속 화면

 

페이지 스크롤링.

이제 페이지에 접속한 사용자가 피드를 확인하면서 아래로 스크롤을 하게 된다. 

 

<!--무한 스크롤 관찰 대상-->
    <div class="list"></div>
    <br><br>
    <br><br>
    <p id="sentinel"></p>

관찰하고 있던 p태그가 100% viewport에 들어오면 callback 함수가 호출된다. 

관찰 대상이 여러 개인 경우를 생각해서 forEach 반복하여 함수를 수행한다. (여기선 대상이 하나여서 한 번만 수행)

 

<callback 함수>

1. page 변수에 1을 더해줘서 2를 만들어준다. (default = 1)

2. addNewContent() 함수를 호출

// callback 함수 정의
const callback = (entries, observer) => {
    entries.forEach(entry => {
        if(entry.isIntersecting) {
            page ++; // 2부터 시작
            //console.log(page);
            addNewContent();
        }
    });
};

function addNewContent() {
    $.ajax({
        type: "GET", // request 전달 방식 (POST, GET 등)
        url: newFeedURl,
        headers: {//헤더에 csrf 토큰 추가
                'X-CSRFToken': csrf
            },
        data: {// json 형식으로 서버에 데이터 전달
            "page": page // page 번호를 서버에 전달 // 따로 인자로 page를 받지 않아도 증감 연산된 page가 자동으로 할당되는지 여부 확인
        },
        dataType: "json", // json 형식으로 데이터 주고 받기

addNewContent 함수가 실행되면 ajax 요청을 하는데 요청하기 직전 beforesend 콜백 함수가 실행된다.


beforeSend: function () { // ajax 보내기 전
            loadingStart();
            // 통신 시작할 때 관찰 끄기
            observer.unobserve(sentinel);
        },

그러면 loadingStart 함수가 호출되고 observer의 unobserve Methods를 통해 관찰을 중단한다. (ajax 통신이 이루어질 때 유저가 스크롤을 빠르게 내리면 다음 페이지를(2p) 가져오기 전에 또 다음 페이지를(3p) 요청하는 상황 방지)


const loadingStart = () => {
    addSkeleton();
    list.appendChild(spinner);
};

loadingStart 함수가 실행되면 addSkeleton 함수를 호출하고 list 변수에 담긴 태그에 자식 태그로 spinner 변수에 담긴 태그를 추가한다. 

const addSkeleton = () => {
    const skeleton = document.querySelector('.skeleton');
    skeleton.setAttribute('id', 'skeleton-on');
};
<div class="skeleton" id="skeleton-off">
    <div class="feed-img">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
        <img class="image" src="/static/img/skeleton.jpg">
    </div>
</div>

addSkeleton 함수는 골격 이미지를 가지고 있는 div 태그를 skeleton 변수에 저장, id 값을 skeleton-on으로 바꿔준다. 

그러면 skeleton-off에 걸려있던 display none 값 적용이 풀리게 되어 화면에 골격 이미지들을 띄워주게 된다.

const spinner = makeSpinner();

const makeSpinner = () => {
        const spinner = document.createElement('div');
        const spinnerImage = document.createElement('img');
        spinner.classList.add('loading');
        spinnerImage.setAttribute('src', '/static/img/spinner.gif');
        spinnerImage.classList.add('spinner');
        spinner.appendChild(spinnerImage);
        return spinner;
    };

makeSpinner에서는 새로운 div와 img를 만들고 div에 loading 클래스를 추가, img에는 spinner 이미지를 넣어주고 spinner 클래스를 추가한다. 마지막으로 div에 img를 자식 태그로 넣어주고 div태그가 담긴 spinner 변수를 리턴!

const list = document.querySelector('.feed-container');

list 변수에는 전체 피드를 담고 있는 컨테이너 div 태그가 담겨있는데 makeSpinner 함수로 만들어진 스피너 이미지가 담긴 div 태그를 피드 컨테이너 div 태그에 자식 태그로 넣어준다.(가장 하단에 들어가게 된다) 

 

<beforesent 결과>

골격 이미지와 스피너

 

ajax 요청이 이루어 지기 전 준비작업은 끝났으니 이제 서버 쪽에서 요청이 이루어지는 걸 살펴보겠다.

# 클라이언트에서 전해준 page 값을 저장 (default : none -> 1, "" -> 1)
page = int(request.GET.get("page", 1) or 1)
limit = 18
offset = limit * (page - 1)

# 피드 리스트 가져오기
all_feed = get_feed_list(user_id, offset, limit)

클라이언트에서 전달받은 page 값 2를 page 변수에 저장하면 offset은 18이 된다. 

def get_feed_list(user_id: int, offset: int, limit: int) -> QuerySet[Feed]:
    return Feed.objects.order_by("-created_at").prefetch_related(
        Prefetch(
            "feed_like",
            queryset=FeedLike.objects.filter(user_id=user_id),
            to_attr="my_likes",
        ),
        Prefetch(
            "feed_bookmark",
            queryset=FeedBookmark.objects.filter(user_id=user_id),
            to_attr="my_bookmark",
        ),
    )[offset : offset + limit]

get_feed_list 함수를 실행하면 생성일 기준으로 정렬 후 index 18~36(18개)까지의 쿼리셋을 all_feed 변수에 저장한다.

(page가 3이면 36~54까지 18개)

# 비동기식
# offset이 0이 아닐경우 // ajax로 page 2가 넘어오면 offset = 18
data = serializers.serialize("json", list(all_feed))
return HttpResponse(json.dumps(data), content_type="application/json")

쿼리셋을 직렬화 해서 json 정보로 만들어 준 다음 클라이언트로 전송한다. 


ajax 요청이 완료되면 complete 콜백 함수가 실행된다.

complete: function () { // ajax 완료
            loadingFinish();
        }
        
        
        
const loadingFinish = () => {
    removeSkeleton();
    list.removeChild(spinner);
};



const removeSkeleton = () => {
    const skeleton = document.querySelector('.skeleton');
    skeleton.setAttribute('id', 'skeleton-off');
};

loadingFinish 함수가 실행되고 beforesond 콜백 함수에서 실행한 작업을 되돌려준다. 

골격 이미지가 담긴 div id를 다시 off로 변경하여 display none을 적용시켜서 보이지 않게 해 주고 스피너 이미지가 담긴 div 태그를 제거해준다. 


ajax 요청이 성공하면 success 콜백 함수가 실행된다. 

success: function (result) {
          const data = JSON.parse(result)
          // data 반복문으로 태그 넣기
          for (let i = 0; i < data.length; i++) {
              let rowData = data[i];
              const appendNode = `<a href="/feed/${rowData.pk}"><img class="image" src="${rowData.fields.image}"></a>`;
              $("#new-feed-img").append(appendNode);
          }
          // 가져온 데이터가 18개 이면 더 가져올 데이터가 있다고 판단 다시 관찰 시작
          // 18개가 아닐경우 더이상 가져올 데이터가 없다고 판단 관찰 중지상태로 끝내기
          if (data.length === 18) {
              observer.observe(sentinel);
          }
        },

전달받은 데이터를 data 변수에 저장하고 data의 길이만큼 for문을 반복한다. 

data array안에 있는 객체들의 정보를 a태그와 img태그에 담아서 최신 피드 이미지들이 담겨있는 div 클래스에 추가해준다. 

 

그리고 가져온 데이터 수가 18개면 더 가져올 정보가 있다고 판단해서 다시 관찰 대상을 지정해준다.

만약 가져온 데이터 수 가 18개 미만이면 db에 더 이상 정보가 없다고 판단, 관찰 대상을 지정하지 않게되고 더이상 스크롤 페이징 기능은 작동하지 않는다. 


무한 스크롤 완성.

기능을 만들어 놓고 보니 ajax 요청과 그 결과 반영이 빠르게 이루어져 spinner 이미지는 억지로 보려고 하지 않는 이상 확인이 어려웠다,,

그래도 잘 작동하니 뿌듯! 😁

 

아래 테스트 환경에서는 피드 18개를 가져왔을 때 db에 남은 피드 개수가 0개였지만 관찰자를 지정해서 ajax 요청을 한번 더 수행하는 걸 볼 수 있다. 골격 이미지는 떴다가 사라지지만 data가 비어 있어서 피드 추가는 X 

 

<태블릿 환경 테스트>

무한스크롤 완성!

후기.

사용자가 기능을 이용하는 순서에 따라 코드를 팔로우해봤는데 이게 내가 직접 설명하기엔 좋은 자료이지만 무한 스크롤링을 구현하기 위해 다른 사람이 참고할 때는 가독성이 매우 떨어지는 글이라고 생각이 된다.

(글을 쓰면서도 실시간으로 가독성이 떨어지는 걸 느꼈다는,,,)

 

유의미한 정보를 제공하고 싶었지만 오늘도 일기가 되어버린 기술 블로그,,

 

도전과제

scroll lisnner와 intersection observers 성능 테스트를 직접 해보자!

'코딩 > 기타' 카테고리의 다른 글

uWSGI 플러그인을 찾지 못하는 오류 해결  (0) 2022.08.09
API는 뭐고, REST API는 뭘까?  (0) 2022.07.02
<Github> switch 명령어  (0) 2022.03.25