미터링 배치 저장 전략 통일: UPSERT → DELETE + INSERT 전환
MySQL ON DUPLICATE KEY UPDATE는 행마다 내부 SELECT가 발생합니다. 2,500행 기준 ODKU는 5,000회 연산이지만, DELETE + INSERT는 2회입니다.
TL;DR
MySQLON DUPLICATE KEY UPDATE는 행마다 내부 SELECT가 발생합니다. 2,500행 기준 ODKU는 5,000회 연산이지만, DELETE + INSERT는 2회입니다.
5인 B2B2C GPU 서비스에서 유지보수 효율성이 성능보다 우선합니다. 이런 판단은 AI 딥리서치가 아닌 팀원 자문에서 나왔습니다.
원천 수집에서 시작한 DELETE + INSERT 전환을 미터링 파이프라인 전체로 확장했습니다. UPSERT는 교과서적으로 합리적인 선택이었습니다. 하지만 팀원 자문에서 우리 서비스 규모에는 오버 엔지니어링이라는 결론이 나왔습니다. 원천 수집뿐 아니라 구간 집계, 일간 집계도 성격이 비슷한 배치이기 때문에 유지보수 효율성을 위해 전체를 통일했습니다.
UPSERT 선택 배경
미터링 배치 설계 초기에 INSERT ... ON DUPLICATE KEY UPDATE를 선택한 근거는 명확했습니다.
- 멱등성: 동일 키로 재수집해도 데이터가 중복되지 않음
- 동시성 안전: 유니크 키 제약으로 충돌 감지
- 패턴 검증: 시계열 배치에서 널리 사용되는 패턴
AI 딥리서치를 해도, 다른 시스템의 사례를 살펴도, UPSERT가 맞다는 결론이 나왔습니다. 동시성 제어와 멱등성을 하나의 SQL로 해결하는 교과서적인 선택이었습니다.
// 기존 방식: JDBC Batch UPSERT
private static final String UPSERT_SQL = """
INSERT INTO source_metric (...)
VALUES (?, ?, ?, ...)
ON DUPLICATE KEY UPDATE
node_name = VALUES(node_name), ...
""";
유니크 키 (pod_name, collected_at)로 충돌을 감지하고, JDBC addBatch()/executeBatch()로 N건을 1회 왕복으로 전송합니다.
전환점: 팀원 자문
리뷰 과정에서 팀원들에게 자문을 구한 결과, 리서치와는 다른 결론이 나왔습니다.
합리적인 결정이 꼭 옳은 것만은 아닙니다.
우리 서비스의 맥락을 다시 보면:
| 항목 | 수치 | 의미 |
|---|---|---|
| 개발팀 규모 | 5인 | 유지보수 인력이 한정적 |
| 서비스 유형 | B2B2C | 정적인 엔터프라이즈 환경 |
| 현재 Pod 규모 | ~500개 | 전체 서비스 합산 |
| 설계 최대치 | ~2,000개 | GPU 물리 자원에 의한 상한 |
| 배치 아키텍처 | 단일 노드 JobRunR | 동시성 경합 구조적으로 불가능 |
GPU 멀티 테넌시 서비스 특성상, 물리 GPU 카드 수가 Pod 상한선입니다. B2B2C 정적 환경에서 인프라 한계가 곧 최대치 제약입니다. 버스트 트래픽이 발생할 가능성이 구조적으로 낮습니다. 이 점을 간과했습니다.
이 맥락에서 UPSERT의 동시성 보호는 실질적 가치가 없다고 생각했습니다. 단일 노드 배치에서 동시 쓰기가 발생하지 않고, 규모 확장도 인프라가 먼저 제한합니다.
합리적인 설계였지만, 우리 규모에서는 오버 엔지니어링이었습니다.
유지보수 효율성이 떨어지는 게 더 큰 문제였고, 성능 또한 크게 차이 나지 않았기 때문에 전환을 결정했습니다.
AI에게 아무리 딥리서치를 시키고 다른 사례를 살펴도 이런 결론은 나오지 않았습니다. “~2,000 Pod 상한의 B2B2C GPU 서비스에서 5인이 유지보수합니다”는 맥락은 어떤 리서치에서도 다뤄지지 않습니다.
이 판단은 서비스의 맥락을 이해하는 팀원에게서 나왔습니다.
쿼리 비용 비교
ON DUPLICATE KEY UPDATE의 내부 동작
MySQL의 ON DUPLICATE KEY UPDATE는 행마다 유니크 키 존재 여부를 확인합니다. 내부 SELECT가 반드시 1건 발생합니다. JDBC batch로 1회 네트워크 왕복에 전송하더라도 마찬가지입니다. MySQL 내부에서는 N건의 인덱스 조회가 실행됩니다. 배치에서 건수가 많기 때문에 이 차이가 누적됩니다.
UPSERT (N건): 내부 SELECT × N + INSERT/UPDATE × N = 2N 연산
DELETE+INSERT: range DELETE × 1 + batch INSERT × 1 = 2 연산
DELETE + INSERT는 구간 단위로 바로 삭제를 요청하기 때문에 쿼리가 줄어듭니다. 행 수(N)에 관계없이 연산 수가 일정합니다.
Pod별 원천 수집 (5분 주기)
| 규모 | 행 수 | ODKU | DELETE+INSERT | 배율 |
|---|---|---|---|---|
| 현재 (~500 Pod) | 2,500 | 5,000 | 2 | x2,500 |
| 최대 (~2,000 Pod) | 10,000 | 20,000 | 2 | x10,000 |
행 수 = Pod 수 x 5 타임스탬프 (5분 범위 / 1분 단위)
하루 누적 (288회 수집):
| 규모 | ODKU | DELETE+INSERT |
|---|---|---|
| 현재 | 1,440,000 | 576 |
| 최대 | 5,760,000 | 576 |
구간 집계 (10분 주기, 구간당)
| 규모 | 행 수 | ODKU | DELETE+INSERT |
|---|---|---|---|
| 현재 (~100 서비스, ~500 Pod) | ~600 | 1,200 | 4 |
| 최대 (~400 서비스, ~2,000 Pod) | ~2,400 | 4,800 | 4 |
DELETE+INSERT 4건 = 서비스 DELETE 1 + INSERT 1, Pod DELETE 1 + INSERT 1
현재 규모에서의 성능 차이
현재 ~500 Pod 규모에서 ODKU와 DELETE+INSERT의 실측 성능 차이는 밀리초 단위입니다. MySQL 내부에서 처리하는 인덱스 조회이므로, 소규모에서는 체감되지 않습니다.
성능이 비슷하다면, 더 단순한 쪽이 이깁니다. 5인 팀에서 JDBC 하드코딩 SQL을 관리하는 비용은 성능 이점을 상회합니다.
통일된 저장 전략
원천 수집에서 시작한 전환을 미터링 파이프라인 전체로 확장했습니다. 구간 집계, 일간 집계도 결국 성격이 비슷한 배치입니다. 유지보수 효율성을 위해 전체를 통일했습니다.
파이프라인 전체 적용
| 단계 | 삭제 단위 | 삽입 방식 | 쿼리 수 |
|---|---|---|---|
| Pod별 원천 수집 | 수집 범위 (from~to) | JPA saveAll() | 2 |
| 구간 집계 (서비스) | 구간 (started_at, ended_at) | JPA saveAll() | 2 |
| 구간 집계 (Pod) | 서비스에 종속 | JPA saveAll() | 2 |
| 일간 집계 (서비스) | 날짜 (started_at, ended_at) | JPA saveAll() | 2 |
| 일간 집계 (Pod) | 서비스에 종속 | JPA saveAll() | 2 |
모든 단계가 같은 패턴입니다:
// 1. 범위 삭제
repository.deleteAllBy...(from, to);
// 2. 새 데이터 삽입
repository.createAll(entities); // JPA saveAll()
핵심 변경
| 항목 | Before | After |
|---|---|---|
| 저장 방식 | JDBC batch UPSERT / Native SQL | JPA saveAll (DELETE + INSERT) |
| 유니크 키 | 복합 유니크 키 (충돌 감지) | 없음 (일반 인덱스만) |
| PK 전략 | IDENTITY | SEQUENCE (Hibernate 배치 INSERT 활성화) |
| 서비스 PK | 유지 (upsert) | 변동 허용 (트랜잭션 원자성) |
| 삭제 방식 | 없음 (UPSERT로 대체) | 범위 기준 일괄 삭제 |
collectedAt 이원화 (Pod별 원천 수집)
| 필드 | 설명 | 용도 |
|---|---|---|
collectedAt | 타임스탬프를 분 단위 절삭 | DELETE 범위 기준, 조회 필터 |
rawCollectedAt | 원본 타임스탬프 | 디버깅/오차 분석 |
수동 수집 경합 방지
수동 수집(Operator)의 종료 시간이 now - safetyMarginMinutes 이후이면 거부합니다. 자동 수집과의 시간대 경합을 원천 차단합니다.
판단 근거 요약
| 기준 | 평가 |
|---|---|
| 현재 규모 (~500 Pod, 최대 ~2,000) | DELETE + INSERT 충분 |
| 배치 환경 (단일 노드 JobRunR) | 동시성 경합 없음 |
| 서비스 특성 (B2B2C, GPU 멀티 테넌시) | 인프라 한계로 버스트 불가능 |
| 개발팀 규모 (5인) | 유지보수 단순화가 핵심 |
| 코드 유지보수 | JPA 표준 » JDBC/Native SQL 하드코딩 |
| 쿼리 비용 | DELETE+INSERT(2) « UPSERT(2N), N=수천~만 |
| 성능 차이 | 현재 규모에서 밀리초 단위, 무시 가능 |
| 확장 필요 시 | saveAll에 batch_size 설정으로 대응 가능 |
교훈
UPSERT 설계 시 참고한 자료는 충분했습니다. 시계열 배치의 모범 사례, UPSERT 패턴의 장단점, 동시성 제어 전략: AI 딥리서치로 수집한 정보와 외부 사례 분석 모두 UPSERT가 적합하다는 결론이었습니다.
하지만 “~2,000 Pod 상한의 B2B2C GPU 서비스에서 5인이 유지보수합니다”는 맥락은 어떤 리서치에서도 다뤄지지 않았습니다.
동시성 보호가 필요 없고, JDBC 하드코딩의 유지 비용이 성능 이점을 상회하고, 범위 삭제 한 줄이 N건의 유니크 키 조회를 대체한다는 판단은 우리 서비스의 맥락을 이해하는 팀원에게서 나왔습니다.
합리적인 설계와 올바른 설계는 다릅니다. 일반론으로는 옳은 결정이 특정 맥락에서는 오버 엔지니어링이 될 수 있습니다. 돌이켜보면 YAGNI 위배였습니다. 현재 필요하지 않은 동시성 보호를 미리 설계한 것이 비용이 되었습니다. 그 경계를 판단하는 것은 코드가 아니라 사람입니다.
이 글은 Claude와 함께 작업했습니다.