콘텐츠로 이동

DynamoDB 두 테이블 이력 조회를 기간 기반 페이징 대신 cursor 기반으로 바꾼 이유

우리 서비스는 포인트 적립 이력과 사용 이력을 AWS AppSync의 GraphQL API로 내려주고 있었다. 하지만 운영 비용 문제 때문에 이 기능을 REST API로 옮기게 됐다.

이 데이터는 DynamoDB의 서로 다른 두 테이블에 나뉘어 있었고, 앱에서는 이를 하나의 이력 목록처럼 보여주고 있었다.

앱 UI는 최근 이력을 먼저 보여주고, 사용자가 더 보기를 누르면 그 이전 이력을 이어서 가져오는 방식이었다. 기존 AppSync GraphQL API도 이 흐름에 맞춰 동작하고 있었다.

먼저 두 테이블에서 각각 가장 오래된 데이터와 최신 데이터를 확인해 전체 조회 가능 기간을 계산하고, 그 뒤 최근 3개월 데이터를 내려주는 식이었다. 이후 사용자가 더 보기를 누르면 그 다음 3개월 데이터를 다시 조회했다.

처음에는 이 흐름을 그대로 REST API로 옮기려고 했다. 그래서 기존 AppSync GraphQL API와 비슷하게 기간 확인용 API와 기간별 조회 API를 따로 두는 방향으로 생각했다.

하지만 디자인 리뷰에서 굳이 이 구조를 REST에서도 그대로 유지할 필요가 있느냐는 지적이 나왔다. 기간 확인용 API와 기간별 조회 API를 따로 두지 말고, 하나의 API로 단순화할 수 없는지 다시 보자는 의견이었다.

여기서 문제가 드러났다. 기존 GraphQL 방식에서는 먼저 전체 조회 가능 기간을 계산해 두었기 때문에 기간 기반 조회가 가능했지만, REST에서 단일 API로 줄이려면 마지막 페이지를 판단하는 기준도 함께 바뀌어야 했다.

이력 데이터는 월별로 고르게 존재하지 않았다. 어떤 3개월 구간에는 데이터가 없고, 그보다 더 이전 기간에는 다시 데이터가 있을 수 있었다. 그래서 가장 오래된 데이터가 언제인지를 미리 계산하지 않으면 기간 기반으로 마지막 페이지를 판단하기 어려웠다.

결국 기간 기반 페이징을 유지하면 API가 다시 기간 확인용과 데이터 조회용으로 나뉘게 된다. 이 지점 때문에 기간 기반 페이징 대신 DynamoDB cursor 기반 API로 바꿨다.

기간 기반 페이징을 버리고 DynamoDB LastEvaluatedKey를 그대로 쓰는 cursor 기반 API로 바꿨다.

핵심은 두 테이블의 query 상태를 각각 유지하고, 다음 위치를 하나의 cursor 객체로 묶어 앱에 전달하는 방식이다.

export class ResponseDto {
result: PointHistory[];
nextCursor: string | null;
hasMore: boolean;
}
export class NextCursorDto {
pointHistoryKey: any | null;
usedHistoryKey: any | null;
}

nextCursorNextCursorDto를 JSON 문자열로 직렬화한 뒤 Base64로 인코딩한 값이다. Base64는 보안이 아니라 cursor 객체를 문자열로 주고받기 쉽게 하기 위한 전달 형식이다.

앱은 nextCursor를 그대로 다음 요청에 실어 보내고, 서버는 이를 decode해서 각 테이블 query의 ExclusiveStartKey로 다시 사용한다.

1) 기간 기준 대신 건수 기준으로 바꿨다

섹션 제목: “1) 기간 기준 대신 건수 기준으로 바꿨다”

기존에는 최근 3개월, 그 다음 3개월처럼 기간을 기준으로 가져오려 했다. 하지만 빈 기간이 있을 수 있어 마지막 페이지 판단이 복잡했고, 기간 확인용 API도 별도로 필요했다.

그래서 API 기준을 기간이 아니라 응답 건수로 바꿨다. 한 번의 요청에서 총 200건을 목표로 가져오고, cursor가 있으면 그 다음 위치부터 이어서 조회하도록 했다.

2) 두 테이블의 page size를 고정 분할하지 않고 가변으로 채웠다

섹션 제목: “2) 두 테이블의 page size를 고정 분할하지 않고 가변으로 채웠다”

기본 아이디어는 두 테이블을 각각 100건씩 조회해 총 200건을 맞추는 것이다. 하지만 실제 데이터 분포를 보니 적립 이력보다 사용 이력이 대체로 적었다.

그래서 사용 이력 테이블을 먼저 조회하고, 그 결과에 따라 적립 이력 조회 건수를 조정했다.

  • 사용 이력 90건 조회
  • 남은 목표 건수는 110건
  • 적립 이력 테이블에서 110건 조회

이렇게 하면 사용 이력이 적은 경우에도 한 번의 응답을 200건에 더 가깝게 채울 수 있다.

한쪽 테이블 데이터가 부족하면 부족한 상태로 반환할 수도 있다. 예를 들어 총 200건을 목표로 했지만 한쪽이 예상보다 적고, 다른 쪽도 그 요청에서 더 가져오지 않도록 설계했다면 결과는 200건보다 적을 수 있다.

3) 한쪽 테이블이 먼저 소진되면 다음 요청부터는 그 테이블을 조회하지 않는다

섹션 제목: “3) 한쪽 테이블이 먼저 소진되면 다음 요청부터는 그 테이블을 조회하지 않는다”

두 테이블 중 하나의 LastEvaluatedKeynull이 되면 그 테이블은 더 이상 조회할 데이터가 없다는 뜻이다.

이후 요청부터는 소진된 테이블을 query하지 않고 남은 다른 테이블에서만 데이터를 가져오도록 했다. 예를 들어 사용 이력 테이블 cursor가 먼저 null이 되면 다음 요청부터는 적립 이력 테이블만 조회한다.

이 방식은 불필요한 query를 줄이고, cursor 상태도 단순하게 유지할 수 있었다.

이 설계에서 중요했던 이유는 아래 세 가지였다.

  • 이력 데이터는 중간에 비어 있는 기간이 있을 수 있어서, 특정 3개월 구간에 데이터가 없더라도 그것만으로 마지막 페이지라고 판단할 수 없었다. 그래서 기간 기반 페이징을 유지하려면 가장 오래된 데이터 시점을 먼저 확인하는 별도 API가 필요했다.
  • DynamoDB는 page number 방식보다 LastEvaluatedKey 기반 cursor 방식이 자연스럽다.
  • 이 API는 두 테이블 조회 결과를 하나의 목록으로 합쳐 보여주지만, 실제 DynamoDB query는 두 개가 따로 움직인다. 그래서 다음 페이지도 하나의 숫자가 아니라 각 query 상태를 따로 들고 가야 했다.

결국 이 문제는 3개월치 데이터를 정확히 가져오는 문제라기보다 두 개의 독립적인 DynamoDB query 상태를 앱이 다시 이어서 호출할 수 있게 만드는 문제에 더 가까웠다.

기간 기준을 유지하면 UI 요구와 데이터 분포가 계속 충돌했다. cursor 기준으로 바꾸자 API는 한 개로 줄었고 앱은 nextCursor만 다시 보내면 됐다.

1) 두 테이블을 합쳐 보여줄 때는 cursor도 테이블별로 따로 관리해야 한다

섹션 제목: “1) 두 테이블을 합쳐 보여줄 때는 cursor도 테이블별로 따로 관리해야 한다”

응답 목록은 하나로 합쳐져 보여도 DynamoDB query 상태는 테이블마다 별개다. 그래서 nextCursor도 겉으로는 하나의 문자열이지만, 내부에는 테이블별 LastEvaluatedKey가 각각 들어 있어야 한다.

2) page size는 데이터 분포를 보고 동적으로 채우는 편이 낫다

섹션 제목: “2) page size는 데이터 분포를 보고 동적으로 채우는 편이 낫다”

항상 100건 대 100건처럼 고정 분할하는 것보다, 실제로 더 적게 나오는 테이블을 먼저 조회하고 남은 건수를 다른 테이블에서 채우는 편이 효율적이었다.

3) 한쪽 테이블이 먼저 끝나는 경우를 초반부터 설계에 넣어야 한다

섹션 제목: “3) 한쪽 테이블이 먼저 끝나는 경우를 초반부터 설계에 넣어야 한다”

이력 데이터는 두 테이블의 개수가 대칭적이지 않았다. 그래서 한쪽이 먼저 소진될 수 있다는 가정을 처음부터 넣어야 했다. 이 가정이 있어야 cursor 처리, hasMore 계산, 다음 요청의 query 분기 로직을 단순하게 유지할 수 있다.

4) Base64 cursor는 전달 형식일 뿐 보안 기능은 아니다

섹션 제목: “4) Base64 cursor는 전달 형식일 뿐 보안 기능은 아니다”

cursor를 문자열로 안전하게 전달하는 데는 편하지만, 민감한 정보를 숨기는 보안 기능으로 이해하면 안 된다. 서버에서는 decode 후 형태 검증을 해야 한다.