MongoDB에서 No Offset 방식을 사용해보자

윤학·2023년 8월 16일
0

MongoDB

목록 보기
4/4
post-thumbnail

이전 글에서 프로필 리스트를 보여주는 화면을 무한 스크롤로 구현하였다.

기존에 수행했던 Offset Limit방식 말고 페이징의 성능을 개선할 수 있는 글을 보고 적용해보고 싶어서 적용했다.

개인 프로젝트이고 데이터도 많지 않았기에 기존 방식에서 성능 개선이 필요하다라고 생각을 하진 않았지만 좋은 내용을 알게된 것 같다.

원리는 Offset Limit 방식이 Offset + Limit 만큼 데이터를 읽는데 Offset만큼의 데이터는 그대로 버리기 때문에 뒷 페이지로 갈수록 느려진다는 것이다.

반면에 No Offset 방식은 조회의 시작부분을 인덱스로 빠르게 찾아 어느 페이지를 읽든 빠른 속도로 조회가 가능하다는 것이다.

필자는 프로필에 관한 데이터들을 MongoDB에 저장하고 있는데 기본 인덱스가 적용되어 있고 _id 필드에 ObjectId 값을 사용하고 있어 MySQL의 AUTO_INCREMENT 대신 적용해도 괜찮을 것 같았다.

그럼 둘의 속도 차이가 있는지 실제로 확인해보자.

MongoDB 초급이라 테스트가 부정확 할 수도 있습니다!

데이터 준비

필자는 Jest를 사용하여 로컬 MongoDB에서 테스트를 진행하였고, Mongoose는 사용하지 않았다.

먼저 어떻게 테스트 데이터를 넣을지 몰라 for문으로 돌리고 bulkWrite()를 수행했는데 넣다가 메모리 오류가 났다.

그래서 그냥 아래와 같이 총 2천만건의 데이터를 약 300만건씩 나눠서... userId는 AUTO_INCREMENT처럼 순서를 보기 위해 넣었고, pay는 10~100사이의 값을 랜덤으로 넣었다.

bulkWrite, bulkInsert 둘 다 속도 차이가 거의 없었다.

	beforeAll(async() => {
        await ConnectMongoDB();
        mongo = getMongodb();
        
        const insertList = [];

        for( let i = 18000000; i < 20000000; i++ ) {
            const pay = Math.floor(Math.random() * 90 + 10);
            const testProfile = new CaregiverProfileBuilder(new ObjectId())
                        .userId(i+1)
                        .pay(pay)
                        .isPrivate(false)
                        .notice('sdoijfnaosidjf')
                        .build()

            insertList.push({ insertOne: testProfile });

        }
        await mongo.collection('test').bulkWrite(insertList, { ordered: true });

    });

    afterAll(async () => await DisconnectMongoDB())

2천만개의 데이터가 일단 잘 들어간 것을 확인했다.

기본 동작 테스트

그럼 먼저 오름차순으로 정렬된 데이터 1500만개를 건너띄고 다음 10개를 가져올 때 두 방식이 같은 리스트를 반환하는지 확인해보자.

해당 테스트에서는 만들어 놓은 Repository를 호출하는게 아니라 Mongo Query로 작성하였다.

    it('둘의 반환 데이터가 같은지 확인', async () => {
        const noOffsetResult = 
            await mongo.collection('test')
                    .aggregate([ 
                        { $match: { 
                            _id: { $gt: new ObjectId('64dca423fd8f71ea8657707f') } 
                        }}
                    ])
                    .limit(10)
                    .toArray();
        
        const offsetLimitResult = 
            await mongo.collection('test')
                    .aggregate([
                        { $skip: 15000000 }
                    ])
                    .limit(10)
                    .toArray();
                                         
        noOffsetResult.map((document, index) => 
            expect(document.userId).toBe(offsetLimitResult[index].userId))
    })

같은 데이터를 반환하는 것을 확인했으니, 얼마나 걸리는지 시간을 체크해보자.

쿼리 시간을 예측하는 explain()은 No Offset의 경우 0의 값으로 많이 나와 테스트 시간으로 측정하였습니다!
각 테스트는 5번정도의 반복 실행을 하였습니다.

먼저 Offset Limit 방식이다.

	it('둘의 반환 데이터가 같은지 확인', async () => {
        const offsetLimitResult = 
            await mongo.collection('test')
                    .aggregate([
                        { $skip: 15000000 }
                    ])
                    .limit(10)
                    .toArray();                                         
    })

대략 1.8초정도 걸리는 것으로 나온다.

이번에 No Offset 방식을 테스트 해보자.

    it('둘의 반환 데이터가 같은지 확인', async () => {
        const noOffsetResult = 
            await mongo.collection('test')
                    .aggregate([ 
                        { $match: { 
                            _id: { $gt: new ObjectId('64dca423fd8f71ea8657707f') } 
                        }}
                    ])
                    .limit(10)
                    .toArray();
    })

6ms가 나오는데 performance.now()로 체크해봐도 비슷하게 6~7 ms가 나와 지금 수치만 봐서는 대략 300배의 차이가 난다.

그럼 거의 마지막에 있는 데이터에 접근할 때는 어떻게 될까?

Offset Limit

    it('19948229번째 다음 데이터들의 반환 데이터 확인', async () => {        
        const offsetLimitResult = 
            await mongo.collection('test')
                    .aggregate([
                        { $skip: 19948229 }
                    ])
                    .limit(10)
                    .toArray();
    })

Test Result

No Offset

    it('19948229번째 다음 데이터들의 반환 데이터 확인', async () => {
        const noOffsetResult = 
            await mongo.collection('test')
                    .aggregate([ 
                        { $match: { 
                            _id: { $gt: new ObjectId('64dca553a218d66e615181fe') } 
                        }}
                    ])
                    .limit(10)
                    .toArray();
    })

Test Result

Offset Limit 방식은 시간이 더 늘어나지만 No Offset 방식은 위치에 구애받지 않고 일정한 결과를 반환한다.

쿼리 조건 테스트

그럼 쿼리 조건을 더 추가해보자

일당이 60 미만이고, userId가 100000보다 큰 값들 중 1000000개를 건너띄고 다음 리스트를 받아와보자.

위의 조건은 먼저 조건에 일치하는 문서들을 추리고 skip해야 계속해서 다음 프로필들을 받아올 수 있으므로 마지막 스테이지에 작성했다.

해당 필드들은 인덱스 설정되지 않았다.

    it('조건이 추가됐을 때둘의 반환 데이터가 같은지 확인', async () => {
        const noOffsetResult = await mongo.collection('test')
        .aggregate([ 
            { $match: { 
                pay: { $lt: 60 },
                userId: { $gt: 100000 },
                _id: { $gte: new ObjectId("64dc9eaa97e35833cf3a4e80") } 
            }}
        ])
        .limit(10)
        .toArray()
        const offsetLimitResult = await mongo.collection('test')
            .aggregate([
                { $match: { 
                    pay: {$lt: 60},
                    userId: { $gt: 100000 },
                }},
                { $skip: 1000000 } 
            ])
            .limit(10)
            .toArray();
        
        noOffsetResult.map((document, index) => 
            expect(document.userId).toBe(offsetLimitResult[index].userId))
    })

Offset Limit

	it('조건이 추가됐을 때 데이터 시간 체크', async () => {
        const offsetLimitResult = await mongo.collection('test')
            .aggregate([
                {
                    $match: {
                        pay: { $lt: 60 },
                        userId: { $gt: 100000 },
                    }
                },
                { $skip: 1000000 }
            ])
            .limit(10)
            .toArray();
    })

No Offset

    it('조건이 추가됐을 때 데이터 시간 체크', async () => {
        const noOffsetResult = await mongo.collection('test')
            .aggregate([
                {
                    $match: {
                        pay: { $lt: 60 },
                        userId: { $gt: 100000 },
                        _id: { $gte: new ObjectId("64dc9eaa97e35833cf3a4e80") }
                    }
                }
            ])
            .limit(10)
            .toArray()
    })

해당 결과도 몇십배가 차이가 나는 것을 확인할 수 있다.

스테이지나 내부 연산자 순서에 따라 결과가 달라질 수 있겠지만 유의미한 차이를 내는 것 같다.

참고

페이징 성능 개선하기 - No Offset 사용하기

profile
해결한 문제는 그때 기록하자

0개의 댓글