본문 바로가기

 

📢 들어가며

이번 포스팅은 지난 포스팅에서 이어진다.

지난 포스팅에선 파일 업로드 기능을 구현하였다.

 

이번 포스팅에선 주니어 개발자의 필수코스... 페이징 및 무한 스크롤을 이해하고 구현해볼 것이다.
사이드 바에 페이징 또는 무한스크롤로 구현된 리뷰 목록을 출력할 것이다.

 

개선된 코드가 많아 포스팅에서 다소 빠진 부분이 있을 수 있다.
모든 소스코드는 깃헙에서 확인할 수 있다!

🍜 기능 및 코드 보완

본격적인 구현에 앞서, 기존에 구현했던 코드를 살짝 수정했다.

🍥 메소드 명 변경

setInputState 라는 Vuex mutaion 메소드 명을 setIsDisabledInput 으로 수정했다.
isDisabledInput 상태를 bool 데이터로 set 해주는 메소드 였는데,
InputState 라는 말이 좀 헷갈려서 직관적으로... setIsDisabledInput라고 수정했다.

🍥 snake case 를 camel case 로 수정

💡 참고
snake case: aa_bb_cc
camel case: AaBbCc

 

DB 컬럼명을 snake case 로 지었었는데,
그 컬럼명을 그대로 가져오다보니,
대부분 camel case 로 구현되어 있던 코드에 뜬금없는 snake case 가 굉장히 이질적이었다.

 

사실 이를 해결하기 위해 기존에 MyBatis/MySQL 을 연동할 때
mybatis-config.xml 에 설정을 아래 코드처럼 추가했었다.

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <setting name="mapUnderscoreToCamelCase" value="true" />
        <setting name="callSettersOnNulls" value="true"/>
    </settings>
</configuration>

src/main/resources/mybatis-config.xml

 

여기서 mapUnderscoreToCamelCase 가 mybatis 로 가져온 값을 snake case를 camel case 로 바꿔주는 설정이다.
그런데 값을 가져와보니 설정은 무시되고 snake case 만 가져오고 있었다.

 

이유를 확인해보니,
mapUnderscoreToCamelCase 은 resultType 이 map 인 값엔 적용되지 않는다고 한다.
그래서 따로 추가적인 설정을 진행해줬다.

💡 Map
Key, value 로 구성된 자료형

 

 

package com.map.restaurant.good.config;

import org.springframework.jdbc.support.JdbcUtils;

import java.util.HashMap;

public class LowerHashMap extends HashMap {
    @Override
    public Object put(Object key, Object value) {
        return super.put(JdbcUtils.convertUnderscoreNameToPropertyName((String) key), value);
    }

src/main/java/com/map/restaurant/good/config/LowerHashMap.java

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <settings>
        <setting name="mapUnderscoreToCamelCase" value="true" />
        <setting name="callSettersOnNulls" value="true"/>
    </settings>

    <typeAliases>
        <typeAlias alias="camelMap" type="com.map.restaurant.good.config.LowerHashMap"/> <!-- 추가! -->
    </typeAliases>
</configuration>

src/main/resources/mybatis-config.xml

 

config 패키지에 LowerHashMap 이라는 클래스를 파고,
위와 같이 작성해줬다.


convertUnderscoreNameToPropertyName 는 말 그대로 snake case를 camel case 로 변환해주는 메소드이다.
만들어준 타입을 camelMap 이라고 이름짓고 config 에 정의해줬다.

<select id="getReviews" resultType="camelMap">

이제 MyBatis 에서 hash resultType 을 쓸 일이 생겼을 때,
이런식으로 사용해주면 결과의 키가 snake case 가 아닌 camel case 로 나타나게 될 것이다.

🍥 리뷰 업데이트 시간 컬럼 추가

alter table tbl_review add column update_date timestamp not null default (utc_timestamp);
alter table tbl_file_info add column update_date timestamp not null default (utc_timestamp);

리뷰 목록이 추가되고나면 정렬이 필요할 것 같아서 리뷰나 파일을 추가한 시간을 나타내는 컬럼을 추가해줬다.

 

DB엔 반드시 UTC 표준 시간이 저장되어야한다.
개인 환경에 잘못된 로컬 시간이 설정되어 있을 수도 있고,
어떤 위치와 환경에서든 표준시간을 기준으로 계산된 시간이 나타나야하기 때문이다.


따라서 insert 될 때 utc_timestamp() 가 들어가도록 했다.

    <insert id="saveReview">
        insert into tbl_review (
            id,
            title,
            address,
            review,
            grade,
            lon,
            lat,
            update_date
        )
        values (
            #{id},
            #{title},
            #{address},
            #{review},
            #{grade},
            #{lon},
            #{lat},
            UTC_TIMESTAMP()
        ) on duplicate key
        update
            title = #{title},
            address = #{address},
            review = #{review},
            grade = #{grade},
            lon = #{lon},
            lat = #{lat},
            update_date = UTC_TIMESTAMP()
    </insert>

src/main/resources/mappers/ReviewMapper.xml

    <insert id="saveFile">
        insert into tbl_file_info (file_id, review_id, file_name, file_size, content_type, update_date)
        values (#{fileId}, #{reviewId}, #{fileName}, #{fileSize}, #{contentType}, UTC_TIMESTAMP())
    </insert>

src/main/resources/mappers/FileMapper.xml

 


    <select id="getImages" resultType="com.map.restaurant.good.dto.FileDTO">
        select
            review_id,
            file_id,
            file_name,
            file_size,
            content_type,
            DATE_FORMAT(update_date, '%Y%m%d%H%i%s') as file_up_date_str
        from tbl_file_info
        where review_id = #{reviewId}
    </select>

src/main/resources/mappers/FileMapper.xml

 

DB에 저장된 UTC_TIMESTAMP()를 클라이언트로 불러오기 위해
TIMESTAMP 타입을 MYSQL DATE_FORMAT 문법으로 YYYYMMDDhhmmss 형식의 문자열로 변환시켰다.

 

DTO도 String 타입으로 선언해주면 된다. 나는 각각 reviewUpDateStr, fileUpDateStr 라고 선언해줬다.

    <select id="getReviews" resultType="camelMap">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str,
            f.file_id,
            f.file_name,
            f.file_size,
            f.content_type
        from
            tbl_review r
        left join
            tbl_file_info f
        on
            f.file_id = (
                select
                    file_id
                from
                    tbl_file_info file
                where
                    file.review_id = r.id
                limit 1
            )
        order by r.update_date
    </select>

src/main/resources/mappers/ReviewMapper.xml

 

수정하면서,  getReviews 에서 리뷰 목록의 대표 이미지를 위해 tbl_file_info 파일 중 하나를 가져오게 했다.

 

 

이렇게 가져온 YYYYMMDDhhmmss 문자열은 클라이언트에서
로컬 시간으로 변환한 뒤, UI에 YYYY/MM/DD hh:mm:ss 형식으로 뿌려줄 것이다.


이를 위해 common 폴더에 DateUtil.js 를 파줬다.

export function utcDateStrToVisualLocalDateStr(utcDateStr)
{
    return dateToVisualDateStr(utcDateStrToLocalDate(utcDateStr));
}

export function utcDateStrToLocalDate(utcDateStr)
{
    const date = dateStrToDate(utcDateStr);
    date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
    return date;
}

// YYYYMMDDHHMMSS
export function dateStrToDate(dateStr)
{
    return new Date(
        parseInt(dateStr.substring(0, 4)),
        parseInt(dateStr.substring(4, 6)) - 1,
        parseInt(dateStr.substring(6, 8)),
        parseInt(dateStr.substring(8, 10)),
        parseInt(dateStr.substring(10, 12)),
        parseInt(dateStr.substring(12, 14)));
}

export function dateToVisualDateStr(date)
{
    return `
        ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} 
        ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}
    `
}

frontend/src/common/DateUtil.js

 

이제 utcDateStrToVisualLocalDateStr() 메소드만 갖다쓰면 YYYYMMDDhhmmss 형식의 UTC 문자열이
포맷된 로컬 시간으로 보이게 될 것이다.

🍥 ESLint/Prettier 적용

좀 더 깔끔한 코드를 작성하고 싶어서 ESLint/Prettier 를 적용하기로 했다.
ESLint/Prettier 적용기는 내용이 길어서 따로 포스팅했다.
이 글을 참고!

🍜 페이징 UX 선택

지도 아이콘만으로는 등록된 리뷰를 한눈에 파악하기 어렵다고 생각되어서,
사이드바에 리뷰 목록을 출력해줄 것이다.

 

리뷰가 많아진다면 사이드 바에 한번에 리뷰 목록을 불러오기에 부담이 될 수 있다.
그래서 나는 페이지네이션 또는 무한 스크롤 기법을 적용하여 리뷰 목록을 구현하기로 했다.

🍥 페이지네이션이란?

Pagination.
많은 콘텐츠를 여러 페이지로 분할하여 UI 에 보여주는 기법이다.
한번에 많은 콘텐츠를 가져오는 것이아니라, 한번에 분할된 한 페이지만 보여주기 때문에
과부하를 방지할 수 있다.
하단에 페이지 버튼을 출력/클릭하여 페이지를 이동할 수 있다.

예) 구글의 페이지네이션

🍥 무한스크롤이란?

Infinite Scroll.
페이지네이션과 유사하지만 스크롤을 내려 페이지를 이동하는 기법이다.
페이지네이션과는 다르게 원하는 페이지로 단번에 이동이 불가능하다.
스크롤을 내렸을 때 페이지의 최하단에 도달한다면,
다음 페이지를 로딩해오는 방식이다.

트위터의 무한 스크롤

결론적으로 나는 '무한 스크롤' 기법을 사용할 것이다.
이유는....

  1. 사이드 바에 버튼 목록이 있는 것 보단 스크롤을 내리는 게 편할 것 같아서
  2. 리뷰는 검색하면 되고, 딱히 원하는 '페이지'를 찾아 갈 일은 없을 것 같아서

하지만 페이징도 공부할겸 브랜치를 따로파서 페이징도 구현해보기로 했다.

🍜 리뷰 목록 UI 구현

지금은 사이드바에 하나의 리뷰를 수정하거나 등록하는 UI 만 구현되어 있다.

리뷰 리스트도 보여져야하기 때문에 이런 등록/수정 폼을 따로 컴포넌트로 빼주고,
리뷰 리스트 컴포넌트를 구현하여 각 컴포넌트가 화면에 나타났다 사라지게 해줄 것이다.
나는 각 컴포넌트를 ReviewForm.vue, ReviewList.vue 로 생성했다.

<template>
    <div class="side-bar-wrapper">
        <!-- ... -->
            <ReviewList v-if="isVisibleReviewList" />
            <ReviewForm v-else />
        <!-- ... -->
    </div>
</template>

// ...

    computed: {
        ...mapState({
            // ...
            isVisibleReviewList: (state) => state.isVisibleReviewList,
        }),
    },

frontend/src/components/SideBar.vue

 

이제 사이드바 코드는 이런식으로 간략화될 것이다.

 

isVisibleReviewListisDisabledInput 처럼 Vuex의 state와 mutation 에 선언해주고 가져온 값이다.
말 그대로 리뷰 리스트가 보이는지 아닌지를 구분하는 상태이다.

export default new Vuex.Store({
    state: {
        isVisibleReviewList: true,
    },
    mutations: {
        setCurReviewId: (state, id) => {
            state.curReviewId = id;
            setIsVisibleReviewList(state, false);
        },
        setIsVisibleReviewList: (state, bool) => {
            setIsVisibleReviewList(state, bool);
        }
    }
});

function setIsVisibleReviewList(state, bool) {
    state.isVisibleReviewList = bool;
}

frontend/src/store/index.js

 

setCurReviewId 에도 isVisibleReviewList 가 set 되도록 추가해 주었다.
아이콘을 클릭했을 땐 리뷰 리스트가 보이지 않게 한다.

🍥 ReviewForm.vue

component 폴더에 ReviewForm.vue 파일을 파주고, 기존의 폼을 그대로 옮겨왔다.
그리고 상단에 리뷰 리스트로 이동이 가능한 버튼을 추가해줬다.

        <div class="title-area">
            <div class="goto-review-list-btn">
                <span @click="goToReviewList">
                    <FontAwesomeIcon icon="angle-left" />
                    리뷰 목록
                </span>
            </div>
            <!-- ... -->
        <div>

            // methods 
            goToReviewList() {
                this.$store.commit('setIsVisibleReviewList', true);
                this.$store.commit('setIsDisabledInput', true);
            },

            // css
            > .goto-review-list-btn {
                  color: white;
                  width: fit-content;

                  &:hover {
                      cursor: pointer;
                      text-decoration: underline;
                  }
              }

frontend/src/components/ReviewForm.vue

 

꺽쇠 기호를 위해 faAngleLeft 아이콘도 Icon.js 에 추가했다.


리뷰 목록이 보일 때는 '리뷰 수정 중'인 상태가 아닐 테니 isDisabledInputtrue 로 줬다.

리뷰 목록 버튼이 추가된 모습

🍥 ReviewList.vue

이제 리뷰 리스트를 구현해보자.

구현 결과는 아래 이미지와 같다.

리뷰 목록 UI

UI만 구현한거라 딱히 어려운 점이 없어 설명할게 많지 않다. 

일단 냅다 코드 전문을 첨부해보겠다. 

<template>
    <div class="review-list-wrapper">
        <div class="header-area">
            <BButton
                size="sm"
                variant="warning"
                @click="$store.commit('registerReview')"
            >
                리뷰 작성
            </BButton>
            <BButton
                v-if="!isEditMode"
                :disabled="!reviews.length"
                class="ml-2"
                size="sm"
                variant="info"
                @click="toggleEditMode"
            >
                편집
            </BButton>
            <BButton
                v-if="isEditMode"
                class="ml-2"
                size="sm"
                variant="info"
                @click="toggleEditMode"
            >
                편집 종료
            </BButton>
            <BButton
                v-if="isEditMode"
                class="ml-2"
                size="sm"
                variant="danger"
                @click="deleteCheckedReviews"
            >
                선택 삭제
            </BButton>
        </div>
        <div class="review-list-area">
            <BCheckbox
                v-if="isEditMode"
                v-model="isAllSelected"
                class="ml-4"
                @change="checkAllReviews"
                >전체 선택
            </BCheckbox>
            <ul v-if="reviews.length > 0">
                <li
                    v-for="review in reviews"
                    :key="review.id"
                >
                    <div class="review-item">
                        <div
                            v-if="isEditMode"
                            class="checkbox-area"
                        >
                            <BCheckbox
                                v-model="checkedReviewIds"
                                :value="review.id"
                                @change="checkReview"
                            />
                        </div>
                        <div
                            class="image-area"
                            @click="goToReview(review)"
                        >
                            <BImgLazy
                                :alt="review.title"
                                :src="`${imgDirPath}/${review.id}/${review.fileName}`"
                                blank
                                blank-color="grey"
                                class="review-image"
                                rounded
                            />
                        </div>
                        <div
                            class="review-info-area"
                            @click="goToReview(review)"
                        >
                            <div class="review-title">
                                {{ review.title }}
                            </div>
                            <div class="review-address">
                                {{ review.address }}
                            </div>
                            <div class="review-update-date">
                                {{ getReviewUpdateDateStr(review.reviewUpDateStr) }}
                            </div>
                            <BFormRating
                                v-model="review.grade"
                                class="review-rating"
                            />
                        </div>
                    </div>
                </li>
            </ul>
            <div
                v-else
                class="no-review-notice"
            >
                등록된 리뷰가 없습니다.
            </div>
        </div>
    </div>
</template>

<script>
import { IMG_DIR_PATH } from '@/common/Config.js';
import { utcDateStrToVisualLocalDateStr } from '@/common/DateUtil.js';
import { process } from '@/common/Api.js';
import { confirm, ok } from '@/common/Dialog.js';
import axios from 'axios';

export default {
    name: 'ReviewList',
    data() {
        return {
            imgDirPath: IMG_DIR_PATH,
            isEditMode: false,
            checkedReviewIds: [],
            isAllSelected: false,
        };
    },
    computed: {
        reviews() {
            return this.$store.state.reviews;
        },
    },
    methods: {
        checkAllReviews() {
            this.checkedReviewIds = [];
            if (this.isAllSelected) this.checkedReviewIds = this.reviews.map((re) => re.id);
        },
        checkReview() {
            this.isAllSelected = false;
        },
        deleteCheckedReviews() {
            process(this, async () => {
                const isConfirmed = await confirm(this, '선택한 리뷰를 삭제하시겠습니까?');
                if (!isConfirmed) return;

                await axios.delete('/api/review/deleteReviews', {
                    data: {
                        reviewIds: this.checkedReviewIds,
                    },
                });

                await ok(this, '삭제되었습니다.');
                await this.$store.dispatch('setReviews', this);
                this.toggleEditMode();
            });
        },
        toggleEditMode() {
            this.isAllSelected = false;
            this.checkedReviewIds = [];
            this.isEditMode = !this.isEditMode;
        },
        getReviewUpdateDateStr(reviewUpDateStr) {
            return utcDateStrToVisualLocalDateStr(reviewUpDateStr);
        },
        goToReview(review) {
            this.$store.commit('setReview', review);
            this.$store.commit('setIsVisibleReviewList', false);
            this.$store.dispatch('setFileList', this);
        },
    },
};
</script>

<style lang="scss" scoped>
.review-list-wrapper {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: rgba(0, 0, 0, 0.5);
    padding: 10px;
    display: flex;
    flex-direction: column;

    > .header-area {
        padding: 10px;
        flex-shrink: 0;
    }

    > .review-list-area {
        overflow-y: auto;
        flex: 1;

        > ul {
            list-style: none;
            padding: 10px;
            margin: 0;

            > li {
                padding: 10px;
                background-color: rgba(0, 0, 0, 0.4);
                border-radius: 5px;
                margin-bottom: 10px;

                &:hover {
                    cursor: pointer;
                    background-color: rgba(0, 0, 0, 0.5);
                }

                > .review-item {
                    display: flex;

                    > .checkbox-area {
                        margin: 10px;
                        display: flex;
                        justify-content: center;
                        align-items: center;
                    }

                    > .image-area {
                        margin-right: 20px;

                        > .review-image {
                            width: 120px;
                            height: 100px;
                            object-fit: cover;
                        }
                    }

                    > .review-info-area {
                        flex: 1;
                        display: flex;
                        flex-direction: column;

                        > .review-title {
                            display: flex;
                            font-size: 25px;
                            flex: 1;
                        }

                        > .review-address {
                            display: flex;
                            align-items: center;
                            font-size: 12px;
                            flex: 1;
                            padding-bottom: 5px;
                        }

                        > .review-update-date {
                            display: flex;
                            align-items: center;
                            font-size: 11px;
                            flex: 1;
                            opacity: 0.5;
                            padding-bottom: 5px;
                        }

                        > .review-rating {
                            flex: 1;
                            width: 100px;
                            font-size: 15px;
                            background-color: transparent;
                            border: none;
                            padding: 0;
                            margin: 0;
                            color: #ffdd00;
                            height: unset;
                        }
                    }
                }
            }
        }

        > .no-review-notice {
            width: 100%;
            height: 100%;
            display: flex;
            justify-content: center;
            align-items: center;
            font-size: 25px;
        }
    }
}
</style>

frontend/src/components/ReviewList.vue

 

신경 쓴 부분에 대해 언급하자면...
앞서 구현했던 utcDateStrToVisualLocalDateStr() 메소드를 활용했다.
그리고 Vuex 에 registerReview 라는 mutation 을 새로 하나 파서 사용했다.

    mutations: {
        registerReview(state) {
            setIsVisibleReviewList(state, false);
            setIsDisabledInput(state, false);
            setReview(state);
        }
        // ...
    }

frontend/src/store/index.js

 

'리뷰 작성' 버튼을 클릭했을 때 발생하는 동작이다.


일단 리뷰 목록 컴포넌트가 사라지고 리뷰 폼 컴포넌트가 보여야해서 isVisibleReviewListfalse 로 set하게헸고,
현재 리뷰 정보를 set해주는 로직이 동작하게 했다.

 

리뷰 작성/리뷰 목록 버튼으로 컴포넌트가 여닫히는 모습

목록에서 여러 리뷰를 삭제하면 좋을 것 같아서,
편집 버튼도 추가해줬다.


편집 버튼을 누르면 체크박스가 보이게 되고, 체크된 항목의 id를 배열에 담는다.
API는 기존의 deleteReview를 deleteReviews 로 수정하고 id 배열을 파라미터로 넘겨줬다.

 

편집 버튼으로 편집 모드를 활성화할 수 있다.

 

리뷰가 아무것도 없을 땐 '등록된 리뷰가 없습니다'라는 문구가 뜨게하고,
편집 버튼을 비활성화 시켰다.

리뷰가 아무것도 없을 때의 UI

🍜 페이징 기법 종류

대충 리뷰 목록 UI 틀을 잡았으니, 본격적으로 페이징을 구현해보자.


본 포스팅의 상단에서 UI 측면에서의 페이징 기법에 대해 소개하였다. (페이지네이션/무한스크롤)
DBMS 측면에서도 페이징 기법을 크게 두가지로 나눌 수 있다.


바로 offset/limit 기법과 keyset 기법이다.

🍥 offset/limit

말 그대로 페이징을 위한 쿼리문에서 offset/limit 문법을 사용하는 것을 말한다.

 

LIMIT
원하는 행 수만큼만 데이터를 출력한다.

 

OFFSET
원하는 행 수만큼 건너뛰고 그 이후의 행부터 검색이 가능하다.

select
    *
from
    tbl_review;

select
    *
from
    tbl_review
limit 1 offset 2;

예를들어, 한 페이지에 10 rows 씩 보이는 화면이 있다고 할 때 offset/limit을 활용한다면 ,
1페이지의 내용을 가져오는 쿼리는 아래와같이 작성해줄 수 있다.

select
    *
from
    tbl_review
limit 10 offset 0;

3페이지의 내용을 가져오는 쿼리는 아래와 같을 것이다.

select
    *
from
    tbl_review
limit 10 offset 20;

🍥 keyset

Seek Method 라고도 불린다.


offset/limit 기법과 비슷하게 limit 을 사용하긴 하지만,
다른 점은 key 로 페이징을 한다는 것이다.
여기서 key는 어렵게 생각할 것 없이 DB 컬럼이라고 보면된다.


어떤 컬럼의 값으로 다음 페이지를 찾기 때문에 반드시 해당 컬럼을 기준으로 '정렬'이 되어 있어야한다.
그리고 해당 컬럼의 값이 중복된다면 누락되는 페이지가 나올 수 있다.

따라서 key(컬럼)는 Unique(고유한) 값을 가지고 있어야한다.

 

결론적으로 key는 정렬된 기본키인 것이다.

select
    *
from
    tbl_review
order by id;

select
    *
from
    tbl_review
where id > '6410e4d2-4f20-46eb-9431-7e8d0a1fc2db'
order by id 
limit 2;

Primary key인 id로 정렬을 하고
id의 값을 기준으로 다음 rows를 찾는 방식이다.

🍥 UI별 적절한 DB 페이징 기법

무한 스크롤 기법엔 keyset 방법이 적절하다.
키의 '값'으로 다음 페이지를 찾기 때문에,
마지막 rows 의 데이터로 다음 페이지를 불러오는 무한 스크롤에 적합한 방법임을 알 수 있다.


물론 offset/limit 방법으로도 무한 스크롤 구현이 가능하다.
하지만 성능상 굳이...? offset/limit 으로 구현할 필요가 없다.
(각 기법의 성능은 아래에서 설명한다.)

 

페이지네이션 기법엔 offset/limit 방법이 적절하다.
keyset 기법 사용시 키의 값으로 다음 페이지를 찾아야하는데,
저 멀리있는 페이지 rows 의 값을 알 방법이 없기 때문이다.


예를 들어 ketset을 사용한다면 6페이지 값을 알기 위해 5페이지의 마지막 값을 알아야하는데,
값을 알아내기 위해 모든 rows를 가지고 올 수는 없기 때문이다. (이러면 페이징 하는 의미가 없다.)

 

그렇다고 페이지네이션 기법에 keyset을 완전 사용이 불가능한 것은 아니다.
개인적으로 조금 이상한 UI라고 생각되지만 아래와 같이 구현해줄 수 있겠다.
반드시 그 페이지를 로드하여 마지막 값을 알아내고 다음 페이지를 구해오는 UI이다.

아직 가져오지 못한 페이지가 ...으로 표현된 모습

🍥 offset/limit VS keyset

offset/limit과 keyset 에 대한 개념을 알아보았으니,
이번엔 각 기법의 성능에 대해 공부해보자.

 

지금 근무하고 있는 팀에선 모든 페이징에 대해 keyset 방식을 사용하고 있다.
UI는 모두 무한스크롤로 되어 있다.
그리고 offset/limit 방식을 철저히 배제하고 있다.

 

이유는 성능 때문이다.
우리팀 뿐만아니라 조금만 서치해봐도 절대적으로 keyset의 성능이 우수하다는 글을 많이 찾을 수 있다.

 

offset/limit 을 사용하게 되면,
DB의 첫번째 행부터 offset 행까지 전부 스캔을하고 limit 한 값만큼 행을 가져온다.

 

예를들어, 아래와 같은 쿼리를 실행한다고 해보자.

select
    *
from
    tbl_review 
limit 25 
OFFSET 200024;

200024번째 행을 찾기위해 DBMS는 첫번째 행부터 200024행까지 스캔을 하고,
해당 행부터 25개의 행을 가져오기 위해 한번 더 스캔을 한다.

즉, 2000024 + 25 = 200049 행을 스캔해야하는 것이다.

아주 뒷편에 있는 페이지를 클릭하면 성능이 좋지 못한 쿼리를 돌려야하는 셈이다. 

 

그럼 페이지네이션(limit/offset)은 성능이 좋지 못함에도 사용되는 이유가 무엇일까?

그건 일반적으로 사용자가 페이지 번호가 5 페이지 이상인 것을 거의 읽지 않기 떄문이다. 

1000페이지를 읽는 데 5초가 걸리더라도 1페이지를 읽는 데 0.01 초밖에 걸리지 않는다면,

사용자들은 그렇게 느리다고 느끼지 않게 된다. 

이러한 특성을 이용해 게시물을  offset/limit으로 가져올 수 있는 것이다. 

 

그렇다면 keyset은 어떤가? offset/limit 과 성능적으로 큰 차이가 있을까?
답은, "그렇다!"


왜냐하면 keyset은 key를 사용하기 때문이다.

key는 고유한 값을 가진 컬럼, 즉, 기본키를 사용한다.
이는 인덱스(목차) 역할을 하여 rows를 전부 스캔할 필요 없이 빠르게 해당 row를 찾을 수 있다.

 

keyset 기법은 offset/limit 처럼 DBMS가 한줄.. 한줄 스캔해서 아.. 이게 몇번째 행이구나...하지 않고
어떤 값인 키를 쏵 바로 찾아서 그 다음 값 limit 만큼만 후딱 잘라올 수 있는 것이다.

 

🚀 참고) Primary key 와 Index 의 차이점

PRIMARY KEY

기본키. 이하 PK.
일반적인 DBMS에서 PK는 자동으로 Index 가 적용된다.
PK는 개념적인 값이다. 실제 값이 존재하지만 PK라고 따로 물리적으로 저장되지 않는다.
PK는 여러 Tuple(= row = 행) 중 유일한 Tuple 임을 보장한다.

 

Index

Tuple 들의 유일성을 보장하지 않는다.
Index 는 단지 테이블에서 Tuple을 보다 빨리 찾기 위해 사용된다.
Index를 걸면 Index를 거는 컬럼을 기준으로 새로운 자료구조(B-tree 등)를 생성하여 별도의 디스크 공간에 저장된다.

아래 명령으로 생성해 줄 수 있다. 

    CREATE INDEX <인덱스명> ON <테이블명> ( 칼럼명1, 칼럼명2, ... );

🍜 Pagination 구현

본격적으로 페이징을 구현해보자.

프로젝트엔 무한스크롤을 적용할 것이지만,
페이지네이션도 공부해보고 싶기 때문에!
따로 브랜치를 파서 페이지네이션 방식을 구현해볼 것이다.

git checkout -b pagination-demo

checkout은 브랜치를 이동한다는 git 명령어이고
-b 옵션을 붙이면 브랜치를 생성과 동시에 이동이 가능하다.
나는 pagination-demo 라는 브랜치를 파줬다.

🍥 테스트 프로시저 구현

페이징 구현을 위한 리뷰를 추가해보자.
UI에서 일일이 입력하기엔 좀 귀찮아서 테스트 리뷰를 추가해줄 프로시저를 짜보았다.

delimiter //
create procedure insertReviews(num int)
begin
    declare i INT default 1;
while (i <= num) DO
    insert
    into
    tbl_review(id,
    title,
    review,
    address,
    grade,
    lon,
    lat,
    update_date)
values
    (
        uuid(),
        CONCAT(CAST(i AS CHAR), 'title'),
        CONCAT(CAST(i AS CHAR), 'review'),
        CONCAT(CAST(i AS CHAR), 'address'),
        3,
        127 + (0.01 * i),
        37 + (0.01 * i),
        utc_timestamp()
    );
    set
i = i + 1;
end while;
end //;
delimiter ;
call insertReviews(100);

입력한 숫자만큼 반복을 돌면서 테스트 리뷰를 insert 하는 프로시저이다.

나는 사실 이전에 프로시저를 거의 사용해본 적 없기 때문에...
생소한 부분의 개념만 살짝 짚고 넘어가보겠다.

 

프로시저 는 DBMS의 function 이라고 생각하면된다.
선언하고 실행해주면서 함수처럼 사용이 가능하다.
함수와 다른 점은 결과값을 반환하지 않는다는 점이다.

create, begin, end 로 생성해주고 call 로 호출 할 수 있다.

 

delimiter
Delimiter는 직역하면 '구문 문자'로,
문법의 끝을 나타낸다.
함수의 블럭 {}이라고 생각하면된다.
즉, 프로시저의 스코프를 나타내는 것이다.

 

여기서 delimiter는 // 로 선언되어 사용하고 있다.
$$으로 선언해줘도 상관없다.
이렇게 특수문자로 따로 선언해주는 이유는,
프로시저 내부에 세미콜론이라는 문법의 끝을 나타내는 또다른 기호를 사용 중이기 때문이다.

 

시작 부분에 // 로 delimiter 선언해주고,
프로시저의 끝에 한번더 언급하여
이 영역 내부에 세미콜론이 있지만 이건 모두 하나의 프로시저 내부에 존재하는 것이기 때문에
세미콜론이 프로시저의 끝부분이 아님을 선언해줄 수 있다.
(마지막에 기호 없이 delimiter 만 써주는 건 delimiter의 초기화라고 생각하면 된다.)

 

declare
declare는 직역하면 '선언하다'라는 뜻으로,
함수에서 변수를 선언하듯이 사용해줄 수 있다.
나는 반복을 위해 default 값이 1인 int i를 선언해줬다.

프로시저 실행 후 모습

테스트 리뷰 데이터가 정상적으로 insert 된 것을 확인할 수 있었다! 🎉

🍥 Pagination UI 구현

사실 페이징 UI는 부트스트랩이 굉장히 잘 되어 있기 때문에,
부트스트랩 HTML 태그의 프로퍼티만 잘 제공하면 크게 어려운 부분이 없지만...
개념정도는 알고 있어야할 것 같아서 파악하고 넘어갈 것이다.

 

페이징 UI를 구현하기 위해선 크게 여섯가지 정보를 구해야한다.
여기서 괄호의 네이밍은 수정해줘도 상관 없다.

  1. 한 페이지에 출력될 게시물 수 (countList)
  2. 총 페이지 수 (totalPage)
  3. 한 화면에 출력될 페이지 수 (pageCnt)
  4. 현재 페이지 번호 (curPage)
  5. 화면에 보이는 시작 페이지 (startPage)
  6. 화면에 보이는 끝 페이지 (endPage)

1. 한 페이지에 출력될 게시물 수 (countList)
나는 countList 를 10으로 잡을 것이다,
즉, 한 페이지에 출력될 게시물 수가 10개라는 것이다.
이 값은 처음 정해주고 나선 변할 일이 없다.

 

2. 총 페이지 수 (totalPage)
이렇게 잡은 countList 로부터 우린 총 몇 페이지(totalPage)가 나오는지 구할 수 있다.
리뷰 100개를 insert 했으니
100 / 10 을 하여 totalPage가 총 10 페이지가 나온다는 것을 알 수 있다.
만약 내가 101개의 리뷰를 추가했다면 총 11페이지가 나오게 될 것이다.
totalPage 를 구하는 코드는 아래처럼 해줄 수 있겠다.

int totalCount = 100; // select count(*) from tbl_review; 로 가져온 값
int countList = 10; // 내가 정해준 값
int totalPage = totalCount / countList;
if (totalCount % countList > 0) {
    totalPage++;
}

3. 한 화면에 출력될 페이지 수 (pageCnt)
나는 pageCnt 를 5으로 잡을 것이다,
즉, 한 화면에 출력될 페이지 수가 5개라는 것이다.

한 화면에 출력된 5개의 페이지

4. 현재 페이지 번호 (curPage)
curPage 의 초기 값은 1일 것이고,
이 후엔 유저가 클릭하는 대로 유동적으로 변경될 것이다.

 

5. 화면에 보이는 시작 페이지 (startPage)
pageCnt가 5이기 때문에,
1에서 5사이의 수를 선택한다면
startPage 는 1일 것이다.
5이상 10 이하 페이지를 선택한다면
startPage 는 6일 것이다.

이 startPage 를 찾는 공식은 아래처럼 구현할 수 있겠다.

int curPage = 3;
int pageCnt = 5;
int startPage = parseInt((curPage - 1) / pageCnt) * pageCnt + 1;

사실 나는 이 계산식을 이해하고 구하는 데에 꽤 오랜 시간이 걸렸다... ㅋㅋ
이 계산식 말고도 더 좋은 로직이 있을 수도 있다. (있다면 댓글 부탁드립니다! )
만약 이해가 가지 않는다면 그대로 외워서 활용해도 괜찮다.
그래도 이해를 돕기 위해 차근차근 예제로 설명해보겠다.

 

현재 페이지(curPage)가 3이고,
한 화면에 출력될 페이지 수(pageCnt)가 5일 때,
startPage는 1이 되어야한다.

pageCnt 가 5이기 때문에 화면엔 1,2,3,4,5 또는 6,7,8,9,10 이런식으로 다섯개의 수로 구성된 뭉탱이가 보여질 것이다.
이 뭉탱이를 편의상 '섹션'이라고 칭하겠다.


curPage 가 3이라는 것은 0 섹션이 화면에 출력된 상태란 뜻이다. (인덱스 개념으로... 0부터 섹션을 세었다)
만약 curPage 가 7이라면 1 섹션인 것이다.
즉, 첫번째로 해야할 짓은 curPage 값이 포함된 섹션 구하기 이다.

 

섹션을 구하는 건 쉽다. curPage 를 pageCnt 로 나눠보면된다.
현재 페이지가 3일 땐, 3 / 5 하여 0.6 이고,
현재 페이지가 7일 떈, 7 / 5 하여 1.4 이다.
여기서 소수점은 필요가 없으므로 parseInt 로 버려준다.
그러면
현재 페이지가 3일 땐, 0섹션,
현재 페이지가 7일 땐, 1섹션이 된다.

 

이렇게 섹션을 구하고 난 다음엔 이 섹션의 첫번째 값을 구해야한다.
0 섹션의 첫번째 값은 1
1 섹션의 첫번째 값은 6 이다.
이를 구해주기 위해 아래와 같은 계산을 해줄 수 있다.

섹션 * pageCnt + 1

0 섹션일 때, pageCnt 인 5를 곱해주면 0이된다.
우린 페이지를 1부터 시작하도록 구현했기 때문에 1을 더해줘서 첫번째 값을 찾을 수 있다.

마찬가지로,
1 섹션일 때, pageCnt 인 5를 곱해주면 5가 된다.
여기서 1을 더해주면 1 섹션의 첫번째 값인 6을 구할 수 있다.

int startPage = parseInt((curPage - 1) / pageCnt) * pageCnt + 1;

그렇다면 위 공식에서 1은 대체 왜 빼주는 걸까?
바로 현재 페이지가 섹션의 "끝 값"일 경우를 대비해서이다.

 

현재 페이지가 0 섹션의 끝 값인 5라고 가정해보자.
위 과정대로 5 / 5 를 하면 0섹션이 아닌 1섹션이 나오는 것을 확인할 수 있다.
마찬가지로,
현재 페이지가 1 섹션의 끝 값인 10일 때,
10 / 5를 하면 2가 되어 1섹션이 아닌 2섹션이 되는 것을 볼 수 있다.


이러한 예외를 방지하기 위해 curPage에서 1을 빼주는 것이다.

이는 모두 페이지가 0부터 시작하는게 아니라 1부터 시작해서 생기는 예외이다.
0으로 시작하게 구하고 화면에 출력할 때 1을 더해주던가 하는 다른 방법도 있겠지만,
헷갈려서 나는 이렇게 1을 더하거나 빼도록 했다. @_ @

 

6. 화면에 보이는 끝 페이지 (endPage)

int curPage = 3;
int pageCnt = 5;
int startPage = parseInt(curPage/pageCnt) + 1;
int endPage = startPage + pageCnt - 1;

endPage 를 구하는 법은 쉽다.
startPage 를 구하고, pageCnt(한 화면에 출력될 페이지 수)를 더하고, 1을 빼주면 끝.

 

현재 페이지(curPage)가 3이고,
한 화면에 출력될 페이지 수(pageCnt)가 5일 때,
startPage는 1이 된다.

startPage 에 pageCnt 를 더하고 1을 빼면
endPage 는 1 + 5 - 1을 하여 5가 된다.

 

https://codepen.io/doozi316/pen/oNdMxRm

 

Pagination

...

codepen.io

위 개념을 활용하여 간단한 페이징 UI를 구현해보았다.
startPage 구하는 로직을 이해하고 나선 크게 어렵지 않았다. 😎

 

지금까지 구현해본 페이징이 Bootstrap 에 자동화 되어있다.
Bootstrap 으로 페이징을 구현해보자.

    <div class="review-list-wrapper">
        <!-- ... -->
        <div class="pagination-area">
            <BPagination
                v-model="curPage"
                :total-rows="rows"
                class="pagination"
                hide-ellipsis
                per-page="10"
                size="sm"
                @change="changePage"
            />
        </div>
    </div>

    // script
    data() {
        return {
            // pagination
            rows: 100,
            curPage: 1,
        };
    },
    methods : {
        chagnePage(e) {
            console.log(e);
        }
    }

    // css
    > .pagination-area {
        > .pagination {
            background-color: transparent;
            padding-top: 10px;
            margin: 0;
            display: flex;
            justify-content: center;

            ::v-deep .page-link {
                background-color: transparent;
                color: white;
                border-color: white;
                box-shadow: unset !important;
            }

            ::v-deep .page-item.active {
                box-shadow: unset !important;
                background-color: $warning;
                border-color: $warning !important;
            }
        }
    }

frontend/src/components/ReviewList.vue

 

임의로 페이지 번호를 매기고 UI를 그려보았다.
스타일은 버튼들이랑 색을 맞추면 좋을 것 같아서 부트스트랩의 $warning 색을 사용했다.

 

 

부트스트랩으로 쉽게 페이징 UI를 개발할 수 있었다! 🎉

🍥 Pagination API 구현

UI를 구현했으니 페이지 내용을 가져오는 API를 구현해보자.

limit, offset 방법으로 페이지를 구해올 것이다. 


예를 들어,
현재 페이지가 3페이지이고, 한 페이지당 10개의 리뷰 목록을 불러온다고 가정해보자.
3페이지이니, 20번째 부터 10개의 리뷰를 가져오면 된다.

위 이미지는 id로 정렬된 tbl_review 이다.


여기서 1부터 10 row 까지는 1페이지
11부터 20까지는 2페이지
21부터 30까지 3페이지가 되어야한다.


이를 구하는 쿼리문은 아래와 같이 해줄 수 있다.

select
    *
from
    tbl_review
order by id
limit #{countList}
offset (#{curPage} - 1) * #{countList};

참고로, MYSQL 은 아래와 같이 offset 을 생략해줄 수 있다.

select
    *
from
    tbl_review
order by id
limit (#{curPage} - 1) * #{countList},  #{countList}

현재 페이지가 3일 때
21 row부터 30 row 까지 출력되는 걸 확인할 수 있었다! 🎉

 

이제 이 쿼리문을 활용하여 API를 짜 줄 것이다.
그런데 지금 기존에 getReviews 라는 모든 리뷰를 통째로 가져오는 API가 구현되어 있다.
이 쿼리문은 지도에 뿌려질 위도, 경도 정보도 가져오기 때문에
이 API를 수정하기 보다는, 기존의 getReviews을 삭제하고
지도에 필요한 정보를 가져오는 getReviewsForMap API와,
페이징 기법으로 일부 리뷰 목록을 가져오는 getReviewsByLimit API를 새로 작성해줄 것이다.


getReviewsByLimit를 위해선 리뷰의 totalCnt 가 필요하니, getReviewsCnt API도 만들어주자.

그리고 원래 지도의 아이콘을 클릭하면,
가져온 모든 리뷰 중에 클릭한 현재 리뷰 정보를 사이드바에 보여줬었는데,
이젠 클릭한 아이콘이 리뷰 리스트에 존재하지 않을 수도 있으니
클릭했을 때 클릭한 현재 리뷰 정보만 가져오는 getReview API도 새로 파주기로 한다.

🍤 getReviewsForMap API

Controller

@GetMapping("/getReviewsForMap")
    public List<ReviewDTO> getReviewsForMap() {
        return reviewService.getReviewsForMap();
    }

src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java

Service

public List<ReviewDTO> getReviewsForMap() {
        return reviewDAO.getReviewsForMap();
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java
DAO

List<ReviewDTO> getReviewsForMap();

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

Query

    <select id="getReviewsForMap" resultType="camelMap">
        select
            r.id,
            r.title,
            r.grade,
            r.lon,
            r.lat
        from
            tbl_review r
    </select>

src/main/resources/mappers/ReviewMapper.xml

 

Map 에 뿌리기 위한 필요 정보만 가져온다.

🍤 getReviewsByLimit API

Controller

    @GetMapping("/getReviewsByLimit")
    public List<ReviewDTO> getReviewsByLimit(@RequestParam Integer curPage,
                                             @RequestParam Integer countList) {
        return reviewService.getReviewsByLimit(curPage, countList);
    }

src/main/resources/mappers/ReviewMapper.xml

Service

public List<ReviewDTO> getReviewsByLimit(Integer curPage, Integer countList) {
        Integer offset = (curPage - 1) * countList;
        return reviewDAO.getReviewsByLimit(offset, countList);
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java

DAO

List<ReviewDTO> getReviewsByLimit(@Param("offset") Integer offset,
                                      @Param("countList") Integer countList);

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

Query

    <select id="getReviewsByLimit" resultType="camelMap">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str,
            f.file_id,
            f.file_name,
            f.file_size,
            f.content_type,
            DATE_FORMAT(f.update_date, '%Y%m%d%H%i%s') as file_up_date_str
        from
            tbl_review r
        left join
            tbl_file_info f
        on
            f.file_id = (
                select
                    file_id
                from
                    tbl_file_info file
                where
                    file.review_id = r.id
                limit 1
            )
        order by r.update_date, r.id
        limit #{countList}
        offset #{offset}
    </select>

src/main/resources/mappers/ReviewMapper.xml

 

🍤 getReviewsCnt API

Controller

    @GetMapping("/getReviewsCnt")
    public int getReviewsCnt() {
        return reviewService.getReviewsCnt();
    }

src/main/resources/mappers/ReviewMapper.xml

Service

    public int getReviewsCnt() {
        return reviewDAO.getReviewsCnt();
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java

DAO

int getReviewsCnt();

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

Query

    <select id="getReviewsCnt" resultType="int">
        select
            count(*)
        from
            tbl_review
    </select>

src/main/resources/mappers/ReviewMapper.xml

🍤 getReview API

Controller

@GetMapping("/getReview")
    public ReviewDTO getReview(@RequestParam String reviewId) {
        return reviewService.getReview(reviewId);
    }

src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java

Service

public ReviewDTO getReview(String reviewId) {
        return reviewDAO.getReview(reviewId);
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java

DAO

ReviewDTO getReview(@Param("reviewId") String reviewId);

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

Query

    <select id="getReview" resultType="com.map.restaurant.good.dto.ReviewDTO">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str
        from
            tbl_review r
        where
            r.id = #{reviewId}
        order by r.update_date
    </select>

src/main/resources/mappers/ReviewMapper.xml

🍥 Pagination API 적용 및 기존 코드 정리

API 가 여러 개 추가되면서 기존 코드가 자잘하게 여러군데 많이 바뀌었는데,
다 언급하기엔 사소해서 주의했던 부분만 짚고 넘어갈 것이다.
자세한 건 github의 커밋 코드를 참고!

 

action 수정 및 추가

기존의 setReviews action 과 mutation 을 지우고,

setReviewsByLimitsetReviesForMap 을 추가했다.


지도에 뿌려질 아이콘 정보를 담기 위한 reviewsForMap state도 추가해줬다.
지도의 아이콘을 클릭했을 때, 해당 리뷰를 사이드 바에 뿌려줘야하기 때문에,
setReview action, mutation 도 추가해줬다.


파일도 보여주기위해선, setReview 은 내부에 setFileList 가 호출되어야한다.

Store 전문은 아래와 같다.

import Vue from 'vue';
import Vuex from 'vuex';
import axios from 'axios';
import { process } from '@/common/Api.js';

Vue.use(Vuex);

export default new Vuex.Store({
    state: {
        reviews: [],
        reviewsForMap: [],
        curLon: undefined,
        curLat: undefined,
        curReviewId: undefined,
        curAddress: undefined,
        curTitle: undefined,
        curGrade: undefined,
        curReview: undefined,
        isDisabledInput: true,
        curFileList: [],
        isVisibleReviewList: true,
    },
    mutations: {
        setIsDisabledInput: (state, bool) => {
            setIsDisabledInput(state, bool);
        },
        setCurReviewId: (state, id) => {
            state.curReviewId = id;
            setIsVisibleReviewList(state, false);
        },
        setCurTitle: (state, title) => {
            state.curTitle = title;
        },
        setCurAddress: (state, address) => {
            state.curAddress = address;
        },
        setCurGrade: (state, grade) => {
            state.curGrade = grade;
        },
        setCurReview: (state, review) => {
            state.curReview = review;
        },
        setReviewsByLimit: (state, reviews) => {
            state.reviews = reviews;
            setIsVisibleReviewList(state, true);
        },
        setReviewsForMap: (state, reviews) => {
            state.reviewsForMap = reviews;
        },
        setReview: (state, review) => {
            setReview(state, review);
        },
        setCurFileList: (state, images) => {
            state.curFileList = images;
        },
        setLonLat: (state, { lon, lat }) => {
            state.curLon = lon;
            state.curLat = lat;
        },
        setIsVisibleReviewList: (state, bool) => {
            setIsVisibleReviewList(state, bool);
        },
        registerReview(state) {
            setIsVisibleReviewList(state, false);
            setIsDisabledInput(state, false);
            setReview(state);
        },
    },
    actions: {
        async setReview({ state, dispatch }, that) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReview', {
                    params: {
                        reviewId: state.curReviewId,
                    },
                });
                setReview(state, result.data);
                dispatch('setFileList');
            });
        },
        async setReviewsForMap({ commit }, that) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReviewsForMap');
                await commit('setReviewsForMap', result.data);
            });
        },
        async setReviewsByLimit({ commit }, { that, curPage, countList }) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReviewsByLimit', {
                    params: {
                        curPage: curPage || 1,
                        countList: countList || 10,
                    },
                });
                await commit('setReviewsByLimit', result.data);
            });
        },
        async setFileList({ commit, state }, that) {
            await process(that, async () => {
                const result = await axios.get('/api/file/getImages', {
                    params: { reviewId: state.curReviewId },
                });
                await commit('setCurFileList', result.data);
            });
        },
    },
    modules: {},
});

function setReview(state, review) {
    state.curReviewId = review ? review.id : review;
    state.curLat = review ? review.lat : review;
    state.curLon = review ? review.lon : review;
    state.curTitle = review ? review.title : review;
    state.curGrade = review ? review.grade : review;
    state.curAddress = review ? review.address : review;
    state.curReview = review ? review.review : review;
}

function setIsVisibleReviewList(state, bool) {
    state.isVisibleReviewList = bool;
}

function setIsDisabledInput(state, bool) {
    state.isDisabledInput = bool;
}

frontend/src/store/index.js

 

리뷰 클릭시 지도 Zoom

기존엔 리뷰목록이 없었기 때문에 사이드 바에서 리뷰를 클릭한다는 개념이 없었지만,
이젠 리뷰 목록이 생겼기 때문에 리뷰 목록을 클릭했을 때 지도에서 아이콘을 zoom 하는 기능을 추가할 것이다.

        goToReview(review) {
            this.$store.commit('setCurReviewId', review.id);
            this.$store.commit('setIsVisibleReviewList', false);
            this.$store.dispatch('setReview', this);
        },

frontend/src/components/ReviewList.vue

 

위 코드는 리뷰 목록을 클릭했을 때 발생하는 goToReview 메소드이다.

요약하자면,
현재 클릭한 리뷰의 id를 set하고
리뷰 목록을 가리고 (리뷰 작성 폼을 출력)
현재 클릭한 리뷰 정보를 가져오는 내용이다.

 

알 수 있듯이, setReview 로 인해 review 상태 및 정보가 변경된다.
이를 활용하여 리뷰가 선택되었다는 상태를 인지하게하고 zoom 기능을 추가해주면 된다.
MainMap.vue 에 아래와 같이 작성했다.

    computed: {
        ...mapState({
            // ...
            curReview: (state) => state.curReview,
            curLon: (state) => state.curLon,
            curLat: (state) => state.curLat,
        }),
    },
    watch: {
        // ...
        curReview() {
            if (!this.curReview || !this.curLon || !this.curLat) return;
            const coordinate = [this.curLon, this.curLat];
            this.olMap.getView().setZoom(18);
            this.olMap.getView().setCenter(this.coordi4326To3857(coordinate));
        },
    },

curReview 가 set되면,
현재 위, 경도 정보로 coordinate 를 선언해주고,
해당 위치를 센터로 맞추고 zoom 을 땡기는 기능이다.


여기서 18은 내가 임의로 정해준 값이다.
더 확대하고 싶다면 이 보다 큰 값을 써주면 된다.

 

여기서 curReview 로 리뷰의 변경을 판단해주는 이유는,
curLon이나 curLat은 리뷰를 클릭했을 때 뿐만아니라 지도를 클릭했을 때도 set되는 상태여서 안되고,
curReviewIdcurReviewId 가 set되고 난 이후에 curLon, curLat 정보를 업데이트할 수 있기 때문에
sync 가 맞지 않아 쓸 수 없었다.

 

OpenLayers 는 getView, setZoom, setCenter 메소드를 지원한다.

getView는 지도와 관련된 화면(View) 를 가져오는 메소드로, 해상도나 center 속성을 관리하고 있다.
setZoom 이나 setCenter를 사용하기 위해 함께 써주면된다.
setZoomsetCenter 는 읽으면 바로 어떤 기능인지 알 수 있을 것이다.

 

리뷰 클릭시 Zoom!

 

성공적으로 Zoom 되는걸 확인할 수 있었다! 🎉

 

v-if 대신 v-show 사용

            <ReviewList v-show="isVisibleReviewList" />
            <ReviewForm v-show="!isVisibleReviewList" />

frontend/src/components/SideBar.vue

 

이건 사이드 바 컴포넌트의 코드인데,
기존엔 v-if 로 리뷰 목록과 리뷰 폼을 조건적으로 출력되게 했었다.
이를 v-show 로 출력되도록 수정해주었다.

    async created() {
        await this.setReviewsCnt();
        await this.setReviews();
    },

frontend/src/components/ReviewList.vue

 

이유는 위 코드 때문이다.
리뷰 목록 컴포넌트 코드인데, 컴포넌트가 created 되면서 리뷰를 가져오는 동작을 수행하고 있다.


v-if 는 조건에 따라 컴포넌트를 화면에 출력시킬 때
컴포넌트를 완전히 destroy(파괴) 시킨 뒤 새로 create 해온다.

 

때문에 리뷰 목록이 화면에서 사라지고 보일때마다 created의 메소드들이 수행된다.
이러면 리뷰 목록을 새로 볼 때 마다 매번 첫번째 페이지만 가져오게 되어버린다.

그래서 v-show 로 수정해주었다.


v-show 는 컴포넌트를 완전히 destory 시키는게 아니라
출력하지 않는 상황엔 컴포넌트를 hide 를 시킨다.


즉, 화면에 컴포넌트 자체가 존재하긴 하나 잠깐 눈속임하듯이 가려놓기만 한 것이다.
때문에 사라졌다가 생성될 때 created 가 수행되지 않고,
기존 상태 그대로 유지된다.

 

 

결과적으로 페이지네이션이 정상 작동하도록 구현했다! 🎉

🍜 무한 스크롤 구현

이제 무한 스크롤을 구현해볼 것이다!


구현한 페이지네이션은 push 하고 다시 master 브랜치로 돌아와 구현해보자.

git checkout master

무한 스크롤 역시 라이브러리를 활용할 생각이었는데,
코드 양이 딱히 줄어드는 것도 아니고...
라이브러리라는 게 편하자고 사용하는 것인데 오히려 코드 구현할 때 번거롭고 신경쓰이는 부분이 많아서 직접 구현하기로 했다.
참고로, 무한 스크롤 UI 라이브러리는 Vue-infinite-loading 를 사용했었다.

(이 라이브러리로 구현완료 했었는데, 코드가 지저분해보여서 그냥 날렸다.)

 

프로젝트에 직접 구현하기 전에,
무한 스크롤에 대해 제대로 이해해보고자 데모 무한 스크롤을 구현해보았다.

https://codepen.io/doozi316/pen/bGMxjZW

 

Infinite Scroll

...

codepen.io

<div id="hello-vue" class="demo">
  <ul @scroll="loadMore">
    <li v-for="i in itemCnt" key="i">
      Item{{i}}
    </li>
    <li class="loading">
      Loading...
    </li>
  </ul>
</div>

@scroll 이벤트를 활용하여 스크롤을 하단으로 내렸을 때
데이터를 채워넣는 기능을 구현했다.

그리고 리스트 하단에 Loading 요소를 구현하고 스크롤을 내렸을 때 로딩이 된다는 걸 표시해줬다.

const HelloVueApp = {
  data() {
    return {
      itemCnt: 10
    };
  },
  created() {
    this.loadMore();
  },
  methods: {
    onScroll(e) {
      let { scrollTop, clientHeight, scrollHeight } = e.target;
      if (scrollTop + clientHeight >= scrollHeight) {
        this.loadMore();
      }
    },
    loadMore() {
      setTimeout(() => {
        this.itemCnt += 10;
      }, 1500)
    }
  }
};

onScroll 메소드를 보면,
이벤트의 target 속성의 scrollTop, cientHeight, scrollHeight 를 가져온다.

  • scrollTop
    • 요소의 콘텐츠가 세로로 스크롤되는 픽셀 수. 요소의 상단에서 가장 상단에 보이는 콘텐츠까지의 거리를 나타낸다.
  • clientHeight
    • 요소의 내부 높이. 패딩 값은 포함되며, 스크롤바, 테두리, 마진은 제외된다.
  • scrollHeight
    • 요소에 들어있는 컨텐츠의 전체 높이. 패딩과 테두리가 포함된다. 마진은 제외된다.

target 속성 정보

scrollTopclientHeight를 더한 값이 scrollHeight 보다 같거나 크다면 데이터를 더 가져온다.
즉, 스크롤을 가장 하단으로 내렸을 때를 의미한다.

 

데이터를 가져오는 loadMore 메소드는 실제론 axios 로 DB의 데이터를 가져와야한다.
일단 임의로 1.5초 후에 10씩 row 수를 증가시키는 것으로 구현했다.

 

무한스크롤이 제대로 동작하는 것을 확인할 수 있었다! 🎉

🍥 무한 스크롤 UI 구현

데모 코드로 무한 스크롤의 동작을 이해하였으니,
프로젝트에도 적용해보자.


페이지네이션 구현 때 수정했던 부분과 일치하는 코드가 많아 무한 스크롤 코드의 핵심만 짚고 넘어가 보겠다.
코드 전문은 깃헙에서 확인할 수 있다.

        <div
            class="review-list-area"
            @scroll="onScroll"
        >
            <!-- ... -->
                <li
                    v-if="processingCount > 0"
                    class="progress-list"
                >
                    <ProgressSpinner />
                </li>
            </ul>
        </div>

// script

import ProgressSpinner from '@/components/ProgressSpinner.vue';

// ...
    data() {
        return {
            processingCount: 0,

        };        

// css
            > .progress-list {
                position: relative;
                padding: 50px 0;
            }

frontend/src/components/ReviewList.vue

 

먼저 ProgressSpinner를 import 해와서, 리스트 최하단에 구현해준다.
데모에서 구현했던 loading... 부분 인 것이다.

    created() {
        this.getReviews();
    },

    // methods
        async onScroll(e) {
            if (this.isEndOfList) return;
            let { scrollTop, clientHeight, scrollHeight } = e.target;
            if (scrollTop + clientHeight >= scrollHeight && this.processingCount === 0) {
                await this.getReviews();
            }
        },
        async getReviews() {
            const params = {
                that: this,
                reviewUpdateDate: this.reviewUpdateDate,
                reviewId: this.reviewId,
            };
            await this.$store.dispatch('setReviewsByKeySet', params);
            if (this.reviews.length > 0) {
                const lastReview = this.reviews[this.reviews.length - 1];
                this.reviewUpdateDate = lastReview.reviewUpDateStr;
                this.reviewId = lastReview.id;
            }
        },

frontend/src/components/ReviewList.vue

 

onScroll 메소드 역시 데모와 똑같다.
스크롤이 최하단에 도달했을 때 getReviews 메소드가 실행되도록 구현했다.


여기서 isEndOfList 는 리스트를 전부 가지고 왔을 때를 의미한다.
vuex state 를 새로 파줘 활용한 것으로, 아래에서 한 번 더 언급할 것이다.

 

getReviews 메소드에선 setReviewsByKeySet vuex 액션이 실행되도록 했다.
이름 그대로, keyset 방식으로 리뷰를 가져오게 한 것이다.

 

스크롤 이벤트는 스크롤을 움직일 때마다 발생하기 때문에, 

getReviews가 의도치않게 여러번 실행될 수도 있다.

이런 상황을 방지해 processingCount 가 0일 때, 

즉, 아무런 API가 돌고 있지 않을 때 getReviews 가 동작할 수 있게 this.processingCount === 0 을 추가했다.

//state
    isEndOfList: false,

// mutations
        setReviewsByKeySet: (state, reviews) => {
            state.reviews = reviews;
            setIsVisibleReviewList(state, true);
        },
        addReviewsByKeySet: (state, reviews) => {
            state.reviews.push(...reviews);
        },
        setReviewsForMap: (state, reviews) => {
            state.reviewsForMap = reviews;
        },

// actions
        async setReviewsByKeySet({ commit }, { that, reviewUpdateDate, reviewId }) {
            await process(that, async () => {
                const result = await axios.get('/api/review/getReviewsByKeySet', {
                    params: {
                        reviewUpdateDate: reviewUpdateDate,
                        reviewId: reviewId,
                    },
                });
                if (!reviewUpdateDate && !reviewId) commit('setReviewsByKeySet', result.data);
                else commit('addReviewsByKeySet', result.data);
                if (!result.data.length) commit('setIsEndOfList', true);
                else commit('setIsEndOfList', false);
            });
        },

frontend/src/store/index.js

 

setReviewsByKeySet 액션을 살펴 보자.
process 로부터 스피너를 돌리기 위한 that,
정렬과 key 로 활용하기 위한 reviewUpdateDate, 와 reviewId 을 파라미터로 받는다.

받는 파라미터는 getReviewsByKeySet API로 가져오게 했다. (아래에서 자세히 설명!)

 

만약 reviewUpdateDatereviewId 가 제공되지 않았다면,
즉, 스크롤이 마지막에 도달한 적이 없는 첫 리스트 데이터라면,
setReviewsByKeySet mutation 을 실행시킨다.

setReviewsByKeySet 는 결과 데이터를 reviews state에 정의하는 메소드이다.

 

만약 reviewUpdateDatereviewId 가 제공되었다면,
즉, 스크롤이 마지막에 도달하여 마지막 날짜와 ID를 가져온 상태라면,
addReviewsByKeySet mutation 을 실행시킨다.


addReviewsByKeySet 는 결과 데이터를 reviews state에 추가하는 메소드이다.
스크롤을 내렸을 때 데이터를 추가 하는 것이지 갈아치우면 안되기 때문에
setReviewsByKeySetaddReviewsByKeySet를 각각 따로 두었다.

🍥 무한 스크롤 API구현

🍤 getReviewsByKeyset Controller

    @GetMapping("/getReviewsByKeySet")
    public List<ReviewDTO> getReviewsByKeySet(
        @RequestParam(value = "reviewUpdateDate", required = false) String reviewUpdateDate,
        @RequestParam(value = "reviewId", required = false) String reviewId) {
        return reviewService.getReviewsByKeySet(reviewUpdateDate, reviewId);
    }

src/main/java/com/map/restaurant/good/controller/ReviewCtrl.java

🍤 getReviewsByKeyset DAO

    List<ReviewDTO> getReviewsByKeySet(@Param("reviewUpdateDate") String reviewUpdateDate,
                                      @Param("reviewId") String reviewId);

src/main/java/com/map/restaurant/good/dao/ReviewDAO.java

🍤 getReviewsByKeyset Service

    public List<ReviewDTO> getReviewsByKeySet(String reviewUpdateDate, String reviewId) {
        return reviewDAO.getReviewsByKeySet(reviewUpdateDate, reviewId);
    }

src/main/java/com/map/restaurant/good/service/ReviewService.java

🍤 getReviewsByKeyset Query

    <select id="getReviewsByKeySet" resultType="camelMap">
        select
            r.id,
            r.title,
            r.address,
            r.review,
            r.grade,
            r.lon,
            r.lat,
            DATE_FORMAT(r.update_date, '%Y%m%d%H%i%s') as review_up_date_str,
            f.file_id,
            f.file_name,
            f.file_size,
            f.content_type,
            DATE_FORMAT(f.update_date, '%Y%m%d%H%i%s') as file_up_date_str
        from
            tbl_review r
        left join
            tbl_file_info f
        on
            f.file_id = (
                select
                    file_id
                from
                    tbl_file_info file
                where
                    file.review_id = r.id
                limit 1
            )
        <where>
            <if test="reviewUpdateDate != null and reviewId != null">
                r.update_date <![CDATA[<=]]> #{reviewUpdateDate}
                and
                r.id <![CDATA[<]]> #{reviewId}
            </if>
        </where>
        order by r.update_date, r.id desc
        limit 10
    </select>

src/main/resources/mappers/ReviewMapper.xml

 

기존의 getReviews 와 유사하지만 where 부분이 추가되었다.
받아온 reviewUpdateDatereviewId 로 정렬을 했다.
desc 로 정렬했기 때문에
파라미터인 마지막 row의 id 보다 작아야 다음 값을 가져올 수 있다.

 

여기서 주목해야할 점은 reviewUpdateDate 조건이다.
reviewId 와 달리 "작거나 같다"를 조건으로 걸었는데,
그 이유는,
reviewId는 유일한 값이지만 날짜는 중복될 수 있는 데이터이기 때문이다.
중복이 있을 수 있는 값이기 때문에 날짜 만으로는 키가 될 수 없었겠지만,
id가 있기 때문에 작거나 같다는 조건을 걸 수 있게 되었다.

 

그리고 반드시 key 로 잡은 컬럼이 여러개라면 and 조건으로 묶어줘야한다.
중복 없이 유일해야하는 키값이기 때문이다.

💡 CDATA
Character DATA.
즉 문자형 데이터를 말한다.
CDATA로 감싼 문자는 문자 그대로 표시되게 된다.
CDATA는 부등호 처럼 마이바티스가 태그로 인식하는 특수 기호를 쓸 때 사용한다.
CDATA로 감싸 부등호를 사용하면 마이바티스가 이를 태그로 인식하지 않고 부등호 문자 자체를 출력하게 된다.

 

getReviewgetReviewsForMap 역시 구현하였는데,
페이지네이션과 동일하기 때문에 더 언급하지 않겠다.

주의해야할 것은 getReviewsCnt 는 구현할 필요가 없다는 것이다.

(페이지를 표시할 일이 없기 때문에!)

 

 

성공적으로 무한스크롤이 되는 것을 확인할 수 있었다! 🎉

 


이번 포스팅에선 페이지네이션과 무한 스크롤을 구현해보았다.

다음 포스팅에선 리뷰 검색 기능을 구현해 보도록 하겠다! 

(검색 때 like는 쓰기 싫고... 검색 엔진을 사용해 구현해보고 싶은데 할 수 있을 지 모르겠다 @_@ )

 

댓글, 하트, 피드백은 언제나 환영입니다! 🤓

 

Seize the day!

Spring MVC | Spring Boot | Spring Security | Mysql | Oracle | PostgreSQL | Vue.js | Nuxt.js | React.js | TypeScript | JSP | Frontend | Backend | Full Stack | 자기계발 | 미라클 모닝 | 일상