GlideAggregate 한계와 GlideRecord 대용량 페이징 — getRowCount 함정, OFFSET 비용, keyset 순회
GlideAggregate의 getRowCount 신뢰성 문제와 집계 제약, 그리고 GlideRecord 대용량 순회에서 chooseWindow의 deep-pagination 비용과 sys_id 기반 keyset 페이징, Scheduled Job 배치 패턴까지 정리.
개요
대용량 데이터에 접근할 때 두 가지 흔한 실수가 있다. 하나는 카운트·합계가 필요한데 GlideRecord 루프로 모든 레코드를 순회하는 것이고, 다른 하나는 대량 레코드를 페이징 없이 한 트랜잭션에 밀어 넣는 것이다. GlideAggregate 는 전자를 해결하고, 페이징 패턴은 후자를 해결한다. 두 주제를 한 글로 묶는 이유가 여기 있다: 둘 다 “정확성·성능·타임아웃 함정”이라는 공통 문제 영역에 속한다.
동시에 이 둘에는 각각의 정확성 함정이 존재한다. GlideAggregate 의 getRowCount() 는 직관적으로 기대하는 값을 돌려주지 않는 경우가 있고, chooseWindow 는 깊은 페이지일수록 DB 스캔 비용이 커지는 OFFSET-스타일 윈도잉이다.
OOTB(Out-of-the-Box, 기본 제공) 동작 기준으로 서술합니다. 인스턴스 버전·DB 엔진·커스텀 설정에 따라 동작이 달라질 수 있으므로, 중요 로직은 반드시 본인 인스턴스에서 직접 검증하세요.
§1 — GlideAggregate 와 getRowCount 함정
GlideAggregate 는 DB 의 집계 함수(COUNT, SUM, AVG, MIN, MAX)와 GROUP BY 를 추상화한 API 다. GlideRecord 루프로 레코드를 한 건씩 순회하며 카운트를 세거나 합계를 계산하는 것보다 DB 레이어에서 집계하므로 메모리 부하가 훨씬 작고 속도도 빠르다.
여기서 함정이 하나 있다. GlideAggregate 를 실행한 뒤 getRowCount() 를 호출해 “몇 개 그룹이 나왔는지” 또는 “전체 레코드 수가 얼마인지” 확인하려는 시도가 자주 보인다. 그러나 getRowCount() 는 GlideAggregate 컨텍스트에서 일관된 값을 보장하지 않는다. GlideAggregate 는 행(row)을 반환하는 쿼리가 아니라 집계 쿼리이므로, getRowCount() 가 인스턴스 버전이나 상황에 따라 예상과 다른 값을 반환하는 사례가 보고된다. 이 API 를 그룹 개수 또는 전체 레코드 수로 신뢰하는 것이 함정이다(OOTB 동작, 버전·환경 의존).
올바른 접근은 두 가지다.
- 그룹 개수가 필요할 때:
query()후next()루프를 돌며 카운터를 직접 증가시킨다. - 전체 레코드 수가 필요할 때:
addAggregate('COUNT')를 추가하고,query()+next()후getAggregate('COUNT')로 읽는다.
// incident 를 priority 별로 카운트
var ga = new GlideAggregate('incident');
ga.addAggregate('COUNT');
ga.groupBy('priority');
ga.query();
while (ga.next()) {
var priority = ga.getValue('priority');
var cnt = ga.getAggregate('COUNT');
gs.info('priority ' + priority + ' : ' + cnt + '건');
}
// getRowCount() 를 사용하지 않는다 — 신뢰할 수 없음
이 패턴은 DB 에서 집계를 수행하므로 수십만 건의 incident 가 있어도 루프 반복 횟수는 priority 값의 종류(카디널리티) 수에 불과하다.
§2 — GlideAggregate 제약·함정
GlideAggregate 는 빠르지만 GlideRecord 를 완전히 대체할 수 없다. 사용 전에 알아야 할 제약을 정리한다.
| 제약 항목 | 설명 및 주의 사항 |
|---|---|
| groupBy 카디널리티 폭증 | groupBy 를 여러 필드에 동시 적용하면 결과 그룹 수가 각 필드 카디널리티의 곱으로 늘어난다. (예: priority 5가지 × category 20가지 = 100 그룹) 불필요한 groupBy 축적은 결과셋을 불필요하게 크게 만든다. |
| addQuery vs addHaving | addQuery 는 집계 전 필터(SQL WHERE)이고, addHaving 은 집계 결과에 대한 필터(SQL HAVING)이다. 카운트가 특정 값 이상인 그룹만 가져오려면 addHaving 을 써야 한다. 두 메서드를 혼동하면 필터가 아예 동작하지 않거나 의도치 않은 결과가 나온다. |
| orderBy vs orderByAggregate | orderBy(‘priority’) 는 groupBy 키 필드 기준 정렬이고, orderByAggregate(‘COUNT’) 는 집계값 기준 정렬이다. 카운트 내림차순 상위 N 그룹을 구하려면 orderByAggregate + setLimit 조합이 필요하다. |
| dot-walking 제약 | reference 필드를 dot-walking 해서 집계하는 것(예: assigned_to.department 기준 GROUP BY)은 플랫폼 버전과 환경에 따라 제약이 있을 수 있다(OOTB 동작, 인스턴스에서 직접 검증 권장). |
| 개별 레코드 데이터 접근 불가 | GlideAggregate 는 집계값과 groupBy 키만 반환한다. 특정 레코드의 short_description 이나 sys_id 같은 개별 필드 데이터가 필요하다면 GlideAggregate 가 아닌 GlideRecord 를 써야 한다. |
addHaving 사용 예시:
// 카운트 10건 이상인 priority 그룹만 반환
var ga = new GlideAggregate('incident');
ga.addAggregate('COUNT');
ga.groupBy('priority');
ga.addHaving('COUNT', '>=', 10); // 집계 결과 필터
ga.orderByAggregate('COUNT'); // 집계값 기준 정렬
ga.query();
while (ga.next()) {
gs.info('priority=' + ga.getValue('priority') + ', count=' + ga.getAggregate('COUNT'));
}
§3 — GlideRecord 대용량 순회의 함정
카운트·합계가 아니라 레코드 데이터 자체가 필요한 경우에는 GlideRecord 로 순회한다. 그러나 대용량 결과셋을 별다른 처리 없이 한 번에 순회하면 트랜잭션이 길어져 타임아웃·롤백 위험이 생긴다. 주요 함정 두 가지를 정리한다.
setLimit(n) 은 페이징이 아니다. setLimit 은 결과셋 전체 크기의 상한을 지정하는 것이지, 배치 단위로 잘라서 페이지를 이어가는 페이징 메커니즘이 아니다. setLimit(500) 은 최대 500건만 처리하겠다는 것이지, 500건씩 끊어서 전체를 순회하는 것이 아니다. 이 둘을 혼동하면 대용량 데이터의 일부만 처리하고 나머지를 놓치게 된다.
순회 중 결과셋 변형 함정. GlideRecord 루프 안에서 현재 순회 중인 결과셋의 레코드를 delete 하거나 필드를 수정하면 윈도우가 밀려 레코드가 스킵되는 현상이 발생할 수 있다. “순회 대상 결과셋 자체를 루프 안에서 변형”하는 케이스가 이에 해당한다. 특히 delete 의 경우, 현재 위치의 레코드가 사라지면 뒤따르던 레코드들이 한 칸씩 앞으로 당겨지는데 next() 는 이미 다음 위치를 가리키고 있어 당겨진 레코드 하나를 건너뛰게 된다. 그 결과 조건에 맞는 레코드의 절반 정도만 처리되는 식의 버그가 흔히 나타나며, 처리 건수가 예상보다 적게 찍혀도 에러 없이 조용히 넘어가기 때문에 발견이 늦어진다. 안전한 방법은 sys_id 목록을 먼저 수집한 뒤 루프 밖에서 수정·삭제하거나, 별도 쿼리로 분리하는 것이다.
// ⚠ 위험 — 순회 대상 결과셋을 루프 안에서 delete
var gr = new GlideRecord('incident');
gr.addQuery('state', 6);
gr.query();
while (gr.next()) {
gr.deleteRecord(); // 결과셋 윈도우가 밀릴 수 있음 → 레코드 스킵 위험
}
// ✅ 안전 — sys_id 수집 후 별도 루프에서 처리
var ids = [];
var gr2 = new GlideRecord('incident');
gr2.addQuery('state', 6);
gr2.query();
while (gr2.next()) {
ids.push(gr2.getUniqueValue());
}
ids.forEach(function(id) {
var rec = new GlideRecord('incident');
if (rec.get(id)) rec.deleteRecord();
});
§4 — chooseWindow 의 OFFSET 비용과 keyset 페이징
chooseWindow 의 OFFSET-스타일 비용
chooseWindow(firstRow, lastRow) 는 결과셋에서 특정 행 범위만 가져오는 윈도잉 API 다. 이것은 OFFSET-스타일 윈도잉이다. 내부 SQL 변환은 버전과 DB 엔진에 따라 다르므로 구체적인 SQL 구문으로 단정할 수 없지만, OFFSET-스타일 윈도잉의 특성상 깊은 페이지일수록 DB 가 앞 행들을 건너뛰기 위해 더 많은 행을 스캔해야 하는 비용이 커진다(OOTB 동작, 내부 구현은 버전·DB 의존).
예를 들어 10만 번째~10만 500번째 행을 읽으려면 DB 가 앞의 10만 건을 건너뛰는 스캔을 수행해야 한다. 전체 데이터를 처음부터 끝까지 배치로 순회해야 하는 경우, page 번호가 커질수록 각 페이지 조회 비용도 선형에 가깝게 증가한다.
keyset(seek) 페이징
이 문제를 피하는 방법이 keyset 페이징이다. orderBy('sys_id') + addQuery('sys_id', '>', lastSysId) + setLimit(batchSize) 조합으로 OFFSET 스캔 없이 다음 배치를 바로 조회할 수 있다.
var BATCH_SIZE = 500;
var lastSysId = ''; // 첫 배치는 빈 문자열 → sys_id > '' 는 전체
var hasMore = true;
while (hasMore) {
var gr = new GlideRecord('incident');
gr.addQuery('active', true);
if (lastSysId) {
gr.addQuery('sys_id', '>', lastSysId);
}
gr.orderBy('sys_id'); // sys_id 오름차순 정렬 필수
gr.setLimit(BATCH_SIZE);
gr.query();
var batchCount = 0;
while (gr.next()) {
// 레코드 처리 로직
lastSysId = gr.getUniqueValue();
batchCount++;
}
if (batchCount < BATCH_SIZE) {
hasMore = false; // 마지막 배치
}
}
sys_id keyset 의 정확한 특성과 주의점
이 패턴을 쓰기 전에 sys_id keyset 의 특성을 정확히 이해해야 한다.
동작하는 이유: sys_id 는 32자 hex GUID 다. 사전식(lexicographic) > 비교가 전체 순서(total order)를 안정적으로 유지하므로, “마지막으로 처리한 sys_id 보다 큰 다음 배치”를 정확하게 가져오는 메커니즘이 유효하다.
중요한 제약: sys_id 는 사실상 무작위 GUID 이므로 시간순·생성순 증가가 아니다. orderBy('sys_id') 는 “임의지만 안정적인 정렬 순서”를 보장한다. 이것은 전체 테이블을 빠짐없이 한 번 순회하는 idempotent sweep 에 적합하지만, 최신 생성순 목록 페이징이나 특정 시간 범위 정렬 페이징의 대체가 아니다.
keyset 페이징의 이득은 “정렬을 없애는 것”이 아니라 “OFFSET 스캔을 없애는 것”이다. DB 는 여전히 sys_id 로 정렬하지만, 앞 페이지를 스캔하는 대신 인덱스를 통해 시작 지점을 바로 찾는다.
OFFSET vs keyset 비교
| 항목 | chooseWindow (OFFSET-스타일) | keyset 페이징 (sys_id >) |
|---|---|---|
| 깊은 페이지 비용 | 페이지 번호 증가 시 DB 스캔 비용 증가 | 일정 — 항상 인덱스로 시작 지점 접근 |
| 정렬 기준 선택 유연성 | 원하는 필드로 정렬 가능 | sys_id 고정 (무작위 순서) |
| 적합한 용도 | 소~중규모, UI 페이지 표시 | 대규모 배치 전체 순회, idempotent sweep |
| 생성순 페이징 | 가능 (sys_created_on 정렬) | 부적합 — sys_id 는 시간순 증가 아님 |
| 순회 안정성 | 중간에 레코드 추가·삭제 시 페이지 경계 불안정 | 처리 완료 지점(lastSysId) 기준으로 안정 |
§5 — 대용량 처리 배치 패턴 (Scheduled Job)
한 트랜잭션에서 수백만 건을 처리하면 인스턴스의 quota rule 에 걸려 타임아웃·중단된다. 트랜잭션 시간·레코드 처리 한도는 인스턴스의 quota rule 설정에 따라 다르므로, 정확한 한도는 인스턴스에서 직접 확인해야 한다(OOTB 기준 명시 불가, 인스턴스 의존).
이 문제를 해결하는 패턴이 진행 상태 저장 + Scheduled Job 분할 처리다.
- Scheduled Job 이 실행될 때마다
lastSysId를 System Properties 또는 별도 상태 테이블에서 읽어온다. - keyset 페이징으로 배치 크기만큼 처리한다.
- 배치가 끝나면 마지막
sys_id를 상태 저장소에 기록한다. - 다음 Scheduled Job 실행 시 저장된
lastSysId부터 이어서 처리한다.
// Scheduled Job 스크립트 예시
var BATCH_SIZE = 200;
var PROP_NAME = 'myapp.batch.last_sys_id';
// 이전 실행에서 저장한 마지막 sys_id 읽기
var lastSysId = gs.getProperty(PROP_NAME, '');
var gr = new GlideRecord('my_target_table');
gr.addQuery('processed', false);
if (lastSysId) {
gr.addQuery('sys_id', '>', lastSysId);
}
gr.orderBy('sys_id');
gr.setLimit(BATCH_SIZE);
gr.query();
var processed = 0;
var newLastId = lastSysId;
while (gr.next()) {
// 처리 로직
gr.setValue('processed', true);
gr.update();
newLastId = gr.getUniqueValue();
processed++;
}
if (processed > 0) {
gs.setProperty(PROP_NAME, newLastId);
gs.info('Batch processed: ' + processed + ' records. Last sys_id: ' + newLastId);
} else {
// 더 처리할 레코드가 없으면 상태 초기화 (재실행 시 전체 스캔)
gs.setProperty(PROP_NAME, '');
gs.info('Batch complete — all records processed.');
}
주의 사항: System Properties 를 상태 저장소로 쓰는 것은 간단하지만, 동시에 여러 Scheduled Job 인스턴스가 실행되면 lastSysId 충돌이 발생할 수 있다. 병렬 실행 가능성이 있는 환경에서는 별도 상태 테이블과 잠금(locking) 메커니즘을 고려해야 한다.
GlideRecord vs GlideAggregate 선택 기준
GlideAggregate 를 선택할 때
- 카운트·합계·평균·최솟값·최댓값만 필요한 경우 — 레코드 데이터 불필요
- GROUP BY 기반 집계 리포트 — priority 별 건수, 담당자별 합계 등
- 대용량 테이블에서 집계 — GlideRecord 루프 대비 메모리·속도 우위
- 주의:
getRowCount()신뢰 금지,addAggregate/getAggregate사용
GlideRecord + keyset 페이징을 선택할 때
- 레코드를 한 건씩 읽어 필드 값을 처리·변경해야 하는 경우
- 전체 테이블을 빠짐없이 순회하는 배치 작업
- 대용량 처리 —
chooseWindow의 깊은 OFFSET 회피 필요 시 - 주의: sys_id 순서는 임의 순서 — 시간순 페이징 용도 부적합
마무리
GlideAggregate 와 GlideRecord 페이징은 각각 명확한 용도가 있다.
- 카운트·합계만 필요하다면: GlideAggregate +
addAggregate/getAggregate.getRowCount()는 GlideAggregate 컨텍스트에서 신뢰하지 않는다. - groupBy 집계 후 필터가 필요하다면:
addHaving으로 집계 결과를 필터링.addQuery와 혼동 금지. - 대량 레코드 순회가 필요하다면: keyset 페이징(
orderBy('sys_id')+addQuery('sys_id', '>', lastSysId)+setLimit).chooseWindow의 깊은 OFFSET 스캔 비용을 회피. - 트랜잭션 한도를 초과하는 배치라면: 진행 상태(lastSysId)를 System Properties 또는 상태 테이블에 저장하고 Scheduled Job 으로 분할 실행. 타임아웃 한도는 인스턴스 quota rule 설정을 직접 확인.
- 순회 중 결과셋 변형은 금지: sys_id 수집 후 별도 루프에서 처리.
이 패턴들을 조합하면 대부분의 대용량 처리 시나리오를 타임아웃 없이, 정확하게 처리할 수 있다.