페이지네이션으로 딱 절반만 조회하는 방법

Broccolism·2025년 3월 2일
4

dev-story

목록 보기
10/10

그러니까 이건 실수에 대한 글이다.

내가 하고 싶었던 건 전체를 조회하는 거였다. 그런데 내가 만든 코드는 그렇지 않았다. 따라서 이건 일종의 반성문이다.

겉보기엔 마치 전체를 조회하는 것처럼 짜여있지만 실제로는 절반만 처리하는 마법 같은 코드를 만들고 싶다면? 이 글이 도움이 될 것이다.

무엇을 하고 싶었냐면

배치 애플리케이션에서 몽고DB 컬렉션 하나에서 조건에 맞는 대상을 추려내고, 그 대상을 수정하고 싶었다. 이번 글에서 쓰는 이슈는 DB 종류와 관계없이 벌어질 수 있기 때문에 MySQL 버전으로도 적어보자면, 테이블 하나에서 조건에 맞는 대상을 추려내고 그 대상을 수정하고 싶었다. 배치 애플리케이션이기 때문에 실행하는데 몇 시간이 걸리든 상관없다. 나는 각오가 되어있었다.

좀 더 자세히 살펴보자. 데이터베이스 예제를 들 때 가장 흔히 볼 수 있는 ‘Employee’ 예시에 빗대어 보면, 이런걸 하고싶었다. 아직 휴가를 가지 않은 직원을 모두 휴가로 보내는 사장님이 되고싶었던거지. (물론 실제로는 다른 조건을 걸고 다른 필드를 셋팅하는게 목표였다 그저 직원들에게 사랑받는 사장님을 만들어보려고 적어봤다.)

UPDATE employees
SET on_vacation = TRUE
WHERE on_vacation IS NULL;

몽고DB로 치면 이런 쿼리를 날리고 싶었다.

db.getCollection('employees')
  .updateMany(
	  { onVacation: { $exists: false } },
	  { $set: { onVacation: true } }
  )

전체 employees의 수가 상당히 많았기 때문에 페이지네이션을 하기로 했다. 그렇지 않으면 몇십만건의 데이터를 한방에 찾아서 모두 업데이트하라는 쿼리를 받은 데이터베이스가 뻗어버릴수도 있기 때문이다. 페이지네이션 크기는 안전하게 수백건정도로 설정했다.

그리고 실행했더니

놀랍게도 휴가를 가지 않은 직원의 딱 절반만큼만 휴가를 갈 수 있게 되었다. 내가 짠건 배치 애플리케이션이었다. 실행 전에 몇 시간이 걸릴 마음의 준비도 하고 코드리뷰도 받고 개발환경에서 확인도 하고 회심의 일격을 날렸는데, 전 직원이 휴가를 가지 못했다.

배치 애플리케이션은 ‘성공’으로 끝났다. 에러 로그가 전혀 없었다. 그럼에도 혹시나 모를 네트워크 이슈가 있을지 생각해봤다. 내 배치 애플리케이션은 몽고DB의 데이터를 수백건 단위로 조회하고 그 중 일부를 업데이트한다. 한 컬렉션의 모든 데이터를 스캔할 때까지 그걸 반복한다. 몽고DB쪽에서 지속적으로 동일한 요청을 받으면 그 중 몇개를 드랍하는걸까? 듣도보도 못한 스펙이지만 잠깐 의심해봤다. 하필 딱 ‘절반’만 처리된게 너무 수상했기 때문이다. 그동안 이론으로 배웠던 ‘exponential backoff’ 가 떠오르면서 드디어 이걸 실제로 겪어보는건가..? 라는 생각까지 해봤다.

하지만 아니라는 결론을 내렸다. 이전에 만들었던 다른 배치에서도 비슷한 일을 하는데, 이런 일이 한 번도 일어나지 않았기 때문이다. ‘이런 일’은 ‘절반만 처리된 일’을 말한다. 그렇다면 여기서 할 수 있는 일은 ‘진짜로 절반만 처리되는 현상이 맞는지’를 확인하는 것이다. 그래서 그냥 한번 더 돌려봤다. 이번에는 전체의 1/4만큼만 처리되었다. 그러니까 두 번째 시도에서도 정확히 절반만 처리된 것이다! 세 번째 시도에서도, 네 번째 시도에서도 마찬가지였다.

코드를 의심하다

이쯤 되니 코드를 의심하기 시작했다. 사실 진작에 의심해봐야 했었는데 그러지 못했다. 왜냐하면 페이지네이션 기능은 몇년 전부터 잘 쓰고 있던 공통 모듈의 함수를 그대로 사용하고 있기 때문이었다. 여기에 원인이 있을거라고 생각하지 않았다.

하지만 한 가지 간과한게 있었다. 이번에 개발한 배치는 기존의 다른 배치들과 성격이 다른 배치였던 것이다. 다른 배치에서는 페이지네이션을 하면서 데이터를 수정하지 않았다. 혹은, 수정하더라도 조회 쿼리와 무관한 필드를 업데이트하고 있었다. 예를 들면 이런거다.

UPDATE employees
SET on_vacation = TRUE
WHERE age >= 30 AND age < 40;

나이로 직원을 차별하는 사장을 만들어봤다. 여기서는 나이 조건으로 조회를 하고, 그와 무관한 휴가 필드를 수정한다.

하지만 위에서 봤던 쿼리는 다르게 생겼다. on_vacation 이라는 필드가 조회 조건으로도 쓰이고, 수정 대상으로도 쓰였다. 한 페이지씩 처리를 하는 동안 조회 조건이 점점 줄어들게 되는 것이다.

그림을 그려보자

일반적인 페이지네이션은 이렇게 진행된다.

내가 썼던 공통 모듈의 페이지네이션은 좀 특이했다. 그림을 그려보면 이런식이었다.

이렇게 짠 이유는 몽고DB의 특성 때문이었다. ‘일반적인 페이지네이션’처럼 하려면 몽고에서 제공하는 skip, limit 을 사용하면 된다. 그러면 3번째 페이지를 가져오고 싶을 때 2 * (페이지 사이즈)만큼 skip하고, (페이지 사이즈)만큼 가져오는 쿼리를 실행하면 된다. 그런데 이렇게 했을 때 한 가지 이슈가 생긴다. 바로 skip을 쓰더라도 몽고DB에서 첫 번째 페이지부터 일단 다 가져온 다음에 그 결과에서 몇 개를 스킵한다는 점이다. 공식 문서 https://www.mongodb.com/docs/drivers/java/sync/v5.2/fundamentals/crud/read-operations/skip/ 를 보면, 제목부터 ‘리턴 된 결과를 스킵하기’ 이다.

그래서 몽고DB 페이지네이션 구현체를 보면 두번째 그림처럼 하는 경우가 많다. ObjectId를 기준으로 정렬한 다음 항상 첫 번째 페이지를 가져온다. 대신 조회 조건으로 이전 반복에서 처리한 가장 마지막 도큐먼트의 objectId보다 큰 objectId를 갖는 도큐먼트를 찾는다.

내가 짠 코드는 여기에 추가로 ‘지금 여기까지 처리했음’을 트래킹하는 커서 역할을 하는 변수가 따로 있었다. 그러니까 이렇게 작동하고 있었다. 페이지의 위치와 “여기까지 처리함”의 위치가 동일하게 움직이고 있었다.

하지만 아래 두가지 조건이 합쳐지면서 ..

  • “여기까지 처리함”을 찾을 때에는 objectId 조건을 쓰지 않는다. 즉, 원본 조회 조건만 사용한다.
  • “여기까지 처리함”은 iteration이 이어질 때마다 1씩 더한다.

내가 짠 코드는 이렇게 동작하고 말았다.

즉, n번째 반복을 하는 시점에 (1 + 2 + 3 + … + n) 번째 페이지까지 처리했다고 잘못 표시하고 있었다.

계산을 해 보자. 하필 딱 절반이라고?

믿을수가 없어서 계산을 좀 해봤다.

처리해야 할 전체 페이지 개수를 P라고 하자.

n번째 반복일 때 처리한 페이지의 개수는 n개다.

  • n번째 반복일 때 처리한 페이지의 개수 = n

Iteration이 진행되면서 “여기까지 처리함” 커서의 값은 1씩 커진다고 했다.

  • n번째 반복일 때 “여기까지 처리함” 커서가 가리키는 페이지 = n

한 페이지를 처리할 때마다 조회 대상이 점점 줄어든다. 첫 번째 반복에서 조회되는 페이지는 P개, 두 번째 반복에서는 (P - 1)개, 세 번째 반복에서는 (P - 2)개, … 이런 식이다.

  • 처리해야 할 전체 페이지 개수 = P - (n - 1)

우리가 구하고 싶은건 “여기까지 처리함” 커서가 마지막 페이지까지 닿았을 때 n의 값이다. 이게 바로 처리한 페이지의 개수이기 때문이다. 따라서 이 식을 n에 대해 풀어보면 된다.

  • n에 대한 방정식: n = P - (n - 1)
    • n = P - n + 1
    • 2n = P + 1
    • n = (P + 1) / 2

따라서 n의 값은 대략 전체 페이지 개수의 절반이 된다. 그래서 전체 페이지의 딱 절반까지만 처리가 되었던 것이다. (이..이게 ..진짜네)

교훈

글을 적으면서 느낀거지만 참 코드를… 웃기게 짜놨다. 과거의 나에게로 돌아가서 반성의 시간을 주고 싶다. 하지만 그 당시에는 나름대로 최선의 선택이었을 거다. (실제로 이번 사건 전까지는 말짱하게 잘 돌아가고 있었고 코드를 못 읽을 정도로 엄청 난해한 편도 아니긴 했다,..! 지금 보니 더 나은 방법이 있다는게 보인거다.)

하지만 개선점은 분명히 있다. 공통 모듈의 함수가 마치 그림으로 그렸던 ‘일반적인' 페이지네이션을 하는 것처럼 보이게 만들어져있었다. 스프링 + 몽고DB 조합을 쓰면서 ‘일반적인’ 페이지네이션을 하라고 만들어놓은 Pageable 객체를 사용한 것이다. 이번 작업에서 이 클래스를 걷어내기 위해 머리를 짜내고 있다. 코드는 이렇게 점진적으로 진화해 가는 거라고 배웠다.

  • 공통 모듈을 추상화 할 거라면 오해의 소지가 없게 만들어놓자.
  • 수학은.. 아름답다.
profile
설계를 좋아합니다. 코드도 적고 그림도 그리고 글도 씁니다. 넓고 얕은 경험을 쌓고 있습니다.

2개의 댓글

comment-user-thumbnail
2025년 3월 4일

이집 트러블튜팅 썰이 맛있네요 😋
재밌게 읽고 갑니다!

1개의 답글

관련 채용 정보