계층별 DTO 명명 표준은 계층(Controller · Service · Repository처럼 역할별로 나눈 코드 층) 사이에서 데이터를 나르는 그릇(DTO)의 이름을 정한 글이다. 그 글은 목록 조회 결과를 담는 PagingResult에 전체 개수 totalCount를 두는 대가를 이렇게 적었다.
PagingResult 가 totalCount 를 필드로 가지면
매 목록 조회에 COUNT 쿼리를 한 번 치게 되어 offset 페이징을 계약으로 박는다.
여기서 COUNT 쿼리는 조건에 맞는 행(표에 저장된 데이터 한 건)이 몇 개인지만 세는 SQL이고, 「친다」는 그 쿼리를 DB에 보내 실행한다는 뜻이다. offset 페이징은 앞에서 몇 개를 건너뛰고 그다음 몇 개만 가져오는 방식이다. 「offset 페이징을 계약으로 박는다」는, 타입에 totalCount 칸이 있으니 그 값을 채울 수 있는 방식, 곧 전체 개수를 세는 offset 페이징으로만 이 타입을 쓸 수 있게 못 박힌다는 뜻이다(계약). 조회 결과를 돌려주는 Page 타입은 그 페이지의 데이터 목록과 함께 전체 개수 같은 페이징 정보도 같이 담고 있다.
페이징 방식 표준은 무한 스크롤 화면에 커서 페이징을 골랐다. 페이지 번호 대신 「마지막으로 읽은 행」을 기억해 두고 그다음부터 이어 읽는 방식이라 전체 개수를 셀 필요가 없다. 「매 조회마다 COUNT를 한 번 더」는 그 선택의 이유 가운데 하나이기도 하다. 그래서 정말 매번 나가는지 쟀다.
주장 — Page 조회는 목록마다 COUNT를 한 번 더 친다
Page 를 반환하는 Spring Data 조회는 목록 조회마다 COUNT 쿼리를 한 번 더 친다.
Spring Data는 Repository 인터페이스의 구현을 직접 짜지 않아도 메서드 이름만으로 쿼리를 만들어 주는 Spring 기능이다.
근거 종류는 1(기술 사실)이다. 이 저장소는 증명할 주장의 근거를 넷으로 나눈다 — 1번 기술 사실(예: 인덱스를 걸면 빠르다), 2번 안끼리 비교, 3번 에이전트(AI 도구)가 따르는 작업 규칙, 4번 명세(공식 문서)에 적힌 사실이다. 1번은 같은 데이터로 그 조건이 있을 때와 없을 때를 각각 돌려 비교해서 증명한다. 이 실험에서 바꾸는 조건은 「페이지 위치」다. 「있다 / 없다」 두 갈래 대신 위치 세 갈래를 뒀지만, 같은 데이터로 조건 하나만 바꿔 돌려 비교한다는 뼈대는 같다. 같은 25행을 두고 위치만 바꿔 읽어서, COUNT가 어느 위치에서도 나가는지를 본다.
반증 조건 — 한 경우라도 COUNT가 안 나가면 반증이다
아래 「두 환경」은 Spring Boot 버전 둘이고, 「세 경우」는 페이지 위치 셋이다. 둘 다 바로 다음 「설계」에서 정한다. 판정 이름 셋은 영어 그대로 쓴다 — refuted는 주장이 깨졌다(반증), supported는 주장이 버텼다(지지), inconclusive는 판단할 수 없다는 뜻이다.
- refuted — 두 환경 중 하나라도 세 경우 중 하나에서 COUNT 문이 나가지 않는다
- supported — 두 환경 모두 세 경우 전부 COUNT 문이 나간다
- inconclusive — 조회가 실패하거나 SQL을 한 문장도 관측하지 못한다
설계 — 페이지 위치를 셋으로 나누고 두 가지 방법으로 센다
행 25개를 넣고 세 경우를 각각 다른 트랜잭션으로 조회했다. 한 트랜잭션 안에서 이어 돌리면 앞 조회가 남긴 것 — 읽어 온 객체를 보관해 두는 영속성 컨텍스트와, 한 번 준비한 SQL 문장을 다시 쓰는 캐시 — 때문에 뒤 조회에서 나가는 문장 수가 흐려진다고 보고 나눴다. 실험 코드(proofs/ 폴더의 ItemService 클래스) 주석에 적어 둔 이유다.
page는 몇 번째 페이지인지, size는 한 페이지에 몇 행인지다. page는 0부터 센다. 그래서 25행을 10개씩 끊으면 page 0이 1~10번째, page 1이 11~20번째, page 2가 21~25번째 5행이다.
(가) size 50, page 0 첫 페이지인데 결과가 페이지 크기보다 적다
(나) size 10, page 2 마지막 페이지 (5행)
(다) size 10, page 0 중간 페이지
- (가)는 50행짜리 페이지에 25행이 다 들어와서, 결과 전체가 첫 페이지 하나에 담긴다
- (나)는 페이지가 꽉 차지 않은 마지막 페이지다
- (다)는 위치로는 첫 페이지지만 10행이 꽉 차고 뒤에 행이 더 남아 있다. 「첫 페이지에 다 들어온다」도 「마지막 페이지」도 아닌 경우라서 이 실험에서는 중간 페이지라고 부른다
세는 방법을 둘로 뒀다. 하나는 Hibernate 통계의 PreparedStatement 수다. Hibernate는 통계 기능을 켜면 지금까지 SQL 문장을 보내려고 준비한 횟수를 세어 두는데, 조회 직전과 직후에 그 수를 읽어 빼면 그 조회에서 준비한 문장 수가 된다. 문장은 준비한 뒤에 보내므로 이 수를 나간 문장 수로 읽었고, 이 읽기가 맞는지는 아래 문자열 쪽 수와 맞춰 봐서 확인한다. 다른 하나는 StatementInspector(Hibernate가 실행 직전의 SQL 문자열을 그대로 보여 주는 기능)가 본 SQL 문자열이다. 수만 세면 「무엇이 나갔는지」를 모르고, 문자열만 보면 「몇 번 나갔는지」를 놓친다.
프로그램이 뜬 뒤 첫 조회에는 이번 조회와 상관없는 준비용 문장이 섞일 수 있다. 어떤 문장이 섞이는지는 따로 확인하지 않았고, 섞일 틈을 미리 없앴다. 세기 전에 아무 조회나 한 번 미리 돌린 것이다(웜업 — 실험 코드에서는 행마다 붙은 번호인 id가 1인 행 하나를 찾는다).
DB는 이 컴퓨터의 로컬 PostgreSQL 17이고 실험 전용 데이터베이스를 만들고 지웠다. 환경은 Spring Boot 3.5.16과 4.1.1이다. Boot 버전이 바뀌면 함께 딸려 오는 Spring Data와 Hibernate 버전도 바뀌므로, 같은 모양이 한 버전에서만 나오는 것이 아닌지 보려고 둘로 돌렸다.
결과 — 세 경우 중 둘에서 COUNT가 없었다
PROBE boot=3.5.16 (가) 문장1 COUNT없음 행25 총계25
(나) 문장1 COUNT없음 행5 총계25
(다) 문장2 COUNT나감 행10 총계25
PROBE boot=4.1.1 (같음)
한 줄씩 보면, PROBE는 실험 프로그램이 결과 한 줄을 찍을 때 앞에 붙이는 표시다. boot= 뒤는 테스트에 쓴 Spring Boot 버전이고 (가)·(나)·(다)는 앞서 나눈 세 경우다. 문장1·문장2는 그 경우에서 관측된 SQL 문장 개수이고, COUNT없음·COUNT나감은 그 안에 COUNT 쿼리가 있었는지를 말한다. 행25는 실제로 돌려받은 데이터 행 수, 총계25는 getTotalElements()가 돌려준 전체 개수다. boot=4.1.1도 모양이 같아서 줄을 줄였다.
판정은 refuted다. 두 세는 방법이 세 경우 모두 같은 수(1/1/2)를 냈다.
예상과 달랐던 점 — COUNT를 생략하고도 총계가 정확했다
COUNT가 안 나간 두 경우에도 getTotalElements()는 정확히 25를 돌려줬다. offset은 건너뛴 행 수라 page × size다. (가)는 0 × 50 = 0, (나)는 2 × 10 = 20이다.
(가) offset 0 + 받은 행 25 = 25
(나) offset 20 + 받은 행 5 = 25
어떻게 세지 않고 알았을까. Spring Data가 Page를 만드는 코드(PageableExecutionUtils.getPage)를 열어 보면 받은 행이 페이지 크기보다 적으면 COUNT를 부르지 않는다. 아래는 그 판단을 줄여 옮긴 모양이다(원문 그대로가 아니다).
if (페이지크기 > 받은행수) { // 꽉 차지 않았다
if (offset == 0) 총계 = 받은행수; // (가) 첫 페이지
else if (받은행수 > 0) 총계 = offset + 받은행수; // (나) 뒤 페이지
}
// 위에서 총계를 못 정했으면 그때 COUNT 쿼리를 보낸다 // (다)
첫 if는 페이지가 덜 찼는지 본다. 안쪽 첫 줄은 첫 페이지라면 받은 행 수가 곧 총계라는 뜻이고, 둘째 줄은 뒤 페이지라면 건너뛴 행 수에 받은 행 수를 더한다는 뜻이다. 둘 다 아니면 마지막 줄처럼 COUNT를 보낸다. 꽉 차지 않았다는 것은 뒤에 더 남은 행이 없다는 뜻이니, 첫 페이지면 받은 행 수가 곧 총계이고, 그 뒤 페이지면 건너뛴 행 수 + 받은 행 수가 총계다. (다)는 10행이 꽉 찼으니 뒤에 몇 행이 더 있는지 알 수 없어서 COUNT를 보낸다.
총계가 산술로 복원되는 자리에서는 쿼리를 안 친다. 즉 「totalCount를 들고 다니면 매번 COUNT」는 참이 아니고, 「페이지 위치에 따라 친다」가 실제 모양이다.
이 실험은 어느 경우가 실제 트래픽에서 얼마나 자주 나오는지는 재지 않았다. 목록 첫 화면이 대부분이라면 COUNT는 거의 안 나가고, 깊은 페이지를 훑는 화면이라면 매번 나간다. 비용의 크기는 화면 사용 패턴에 달려 있다.
이 결과가 표준에 남기는 것 — 커서 페이징을 고른 근거가 약해진다
두 글이 걸린다. 계층별 DTO 명명 표준은 대가를 과장했고, 페이징 방식 표준은 그 대가를 근거의 하나로 썼다. 커서 페이징을 고른 이유가 통째로 무너지는 것은 아니다 — 깊은 offset이 앞 행을 읽고 버리는 문제는 이 실험이 건드리지 않았다. offset이 클수록(뒤 페이지일수록) DB는 건너뛸 앞 행들도 읽은 뒤 버려야 해서 느려진다는 문제인데, 자세한 이야기는 그 링크의 원칙 1에 있다.
규칙은 바로 고치지 않는다. 표준 글의 그 문장 근처에 > 반증:으로 시작하는 줄을 달아 이 결과를 가리키고(글 원본에서 >로 시작하는 줄은 화면에 들여 쓴 인용 상자로 보인다), kind: 모순 이슈를 열어 근거 문장을 어떻게 다시 적을지 정한다. kind: 모순은 「실험 결과와 표준의 문장이 서로 어긋난다」는 종류의 이슈라는 표시다.
정리
- 결과 전체가 첫 페이지에 다 들어오거나 마지막 페이지면 COUNT가 나가지 않는다. 두 Boot 버전이 같았다
- COUNT를 생략해도 총계는 정확했다. offset과 받은 행 수로 복원된다
- 중간 페이지에서는 나간다. 문장 2개가 관측됐다
- 재지 않은 것 — 실제 화면에서 세 경우의 비율, 깊은 offset의 비용
자신만의 철학을 만들어가는 중입니다.
댓글남기기