문제
엑셀로 장비 매핑 정보를 일괄 업로드하는 기능이 있다. 수백 행 정도는 잘 돌았다. 그런데 실제 현장 데이터로 수만 행을 올리자 배치 INSERT에서 터졌다.
MyBatis <foreach>로 만든 멀티 로우 INSERT였다. 행이 많아질수록 SQL 하나에 붙는 바인드 파라미터 개수가 선형으로 늘어난다. 그리고 그 개수에는 상한이 있다.
원인
PostgreSQL의 확장 쿼리 프로토콜에서 Bind 메시지는 파라미터 개수를 int16으로 표현한다. 부호 없는 16비트, 즉 최대 65,535개다. 이건 서버 설정으로 늘릴 수 있는 값이 아니라 와이어 프로토콜 자체의 구조적 한계다.
중요한 건 이 한도가 행 수가 아니라 파라미터 수에 걸린다는 점이다.
파라미터 총수 = 행 수 × 행당 파라미터 수
같은 65,535라도 행당 컬럼이 몇 개냐에 따라 넘길 수 있는 행 수가 완전히 달라진다. 그래서 “배치 사이즈 1000” 같은 고정 매직넘버는 근본적으로 답이 될 수 없다. 어떤 쿼리에서는 과하게 보수적이고, 어떤 쿼리에서는 여전히 터진다.
어떻게 고쳤나
커밋 5e350ac에서 각 배치 쿼리마다 행당 파라미터 수를 세고, 거기서 청크 크기를 역산했다.
| 배치 쿼리 | 행당 파라미터 | 청크 크기 | 청크당 파라미터 |
|---|---|---|---|
| 이력 INSERT | 7 | 8,000 | 56,000 |
| 장비-고객 UPDATE | 2 | 20,000 | 40,000 |
| 장비-측정기 UPDATE | 4 | 10,000 | 40,000 |
전부 65,535 아래에 여유를 두고 떨어진다. 딱 맞춰 65,535/7 = 9,362로 잡지 않고 8,000으로 끊은 건 의도적이다. 나중에 컬럼이 하나 추가돼도 즉시 터지지 않을 정도의 마진을 남겨둔 것이다.
청킹 자체는 헬퍼 하나로 공통화했다.
/**
* PostgreSQL PreparedStatement 파라미터 한도(65,535) 초과 방지용 청킹 헬퍼.
*/
private <T> List<List<T>> partition(List<T> list, int chunkSize) { ... }
호출부는 이렇게 단순해진다.
private void insertMeterCustomerHistoryInChunks(List<Map<String, Object>> histories) {
if (histories == null || histories.isEmpty()) return;
for (List<Map<String, Object>> chunk : partition(histories, 8_000)) {
mappingDao.insertMeterCustomerHistoryBatch(chunk);
}
}
헬퍼의 Javadoc에 한도 숫자를 적어둔 게 이 변경에서 제일 중요한 부분일지도 모른다. 나중에 이 코드를 보는 사람이 “왜 8,000이지?”라고 물었을 때 답이 코드 옆에 있어야 하기 때문이다.
IN 절도 같은 문제를 갖는다
INSERT/UPDATE만 파라미터를 쓰는 게 아니다. WHERE id IN (...) 조회도 원소마다 파라미터를 하나씩 소비한다. 그래서 같은 커밋에서 조회 쪽도 500개 단위로 청킹했다.
int chunkSize = 500;
여기는 500이라는 훨씬 작은 값을 썼다. 이유가 다르기 때문이다. IN 절은 파라미터 한도보다 플래너 비용이 먼저 문제가 된다. 원소가 수천 개가 되면 실행 계획을 짜는 것 자체가 무거워지고, 옵티마이저가 인덱스 스캔 대신 해시 조인이나 시퀀셜 스캔으로 도망가기 쉽다. 프로토콜 한도가 아니라 성능 특성에서 나온 숫자다.
같은 “청킹”이지만 상한의 근거가 다르고, 따라서 숫자도 다르다.
남는 교훈
배치 크기는 상수가 아니라 계산 결과다.
청크 크기 = (한도 ÷ 행당 파라미터 수) 에서 마진을 뺀 값
이 식을 모르고 1000 같은 숫자를 넣으면 두 가지 방식으로 틀린다. 행당 파라미터가 2개인 쿼리에서는 필요 이상으로 왕복을 늘려 느려지고, 행당 파라미터가 70개인 쿼리에서는 1000행에서 그대로 한도를 넘긴다. 우연히 안 터지고 있을 뿐이다.
그리고 이 종류의 버그는 테스트 데이터 규모가 작으면 절대 안 보인다. 개발 중에 쓰는 샘플 엑셀이 50행이면 파라미터가 350개다. 한도의 0.5%다. 프로덕션에서 처음 만나게 되는 전형적인 형태의 버그다. 대량 업로드 기능을 만들 때는 실제 최대 규모에 가까운 데이터로 한 번은 돌려봐야 한다.
마지막으로, 이건 JDBC 드라이버 버전을 올린다고 해결되는 문제가 아니다. 프로토콜에 int16으로 박혀 있는 값이라 우회는 청킹뿐이다. 정말로 수십만 행을 자주 밀어넣어야 한다면 그때는 COPY를 검토하는 게 맞다.