EBS 외부 API 연동 및 대용량 데이터 동기화 시스템 구축, 2023.10 ~ 2023.12 재현과 측정
메모리 부족과 반영 누락을 운영과 비슷한 환경에서 재현하고 저장 방식 비교 측정
운영과 비슷한 환경에서 수정 전후를 재현하고, 병목인 행 단위 저장을 묶으면서 실패 행만 골라내는 재설계안을 구현해 측정(합성 데이터)
메모리 부족과 반영 누락 사례에서 다룬 문제를 운영과 최대한 비슷한 환경과 설정에서 다시 일으켜 측정했습니다. 이 페이지의 수치는 모두 재현 환경에서 합성 데이터로 잰 값이며, 실무에서 측정한 값이 아닙니다.
재현 환경
Java 8과 PostgreSQL로 운영과 같게 맞추고, 수정 단계별 커밋 코드를 고치지 않고 그대로 빌드해 같은 조건에서 실행했습니다. 메모리, 처리 시간, 오류 행 측정은 조건마다 3~5회 반복했고, 로그 크기 측정, 실패 때 운영 코드의 동작 비교, 재처리는 1회씩 실행했습니다.
운영과 재현 환경 비교 보기
| 항목 | 운영 | 재현 환경 |
|---|---|---|
| Java | Java 8 | Java 8 |
| DB | PostgreSQL | PostgreSQL 15 (로컬) |
| 데이터 | 실제 외부 콘텐츠 | 합성 데이터 (새로 만드는 행만 있고 수정·삭제는 없음, 영문 문자열) |
| 힙 크기 | 확인하지 못함 | 256MB, 512MB, 1GB로 나눠 측정 |
재현 결과
- 청크 도입 전에는 힙 256MB는 1만 건부터, 512MB는 2만 건부터, 1GB는 5만 건부터 OOM이 났습니다(조건마다 3회 같은 결과).
- OOM은 AOP 로깅이 목록 인자를 문자열로 만드는 위치에서 났고, 출력하지 않는 로그 레벨에서도 같았습니다. 청크만 도입한 코드에서 로그 레벨을 info로 켜고 10만 건을 반영하면 로그가 572MB 쌓였습니다.
- 운영 반영 코드는 힙 256MB로 10만·20만·40만 건을 모두 반영했고, Full GC 직후 힙 사용량은 건수와 관계없이 40~45MB였습니다. 청크 분할을 없애면 20만 건, 힙 512MB에서 다시 OOM이 났습니다.
대안 비교 측정
운영 반영 코드를 기준으로 조회 방식과 저장 방식을 하나씩 바꿔 측정했습니다.
- 처리 시간 대부분은 행마다 SQL 세 개를 DB와 한 번씩 주고받는 저장 단계였습니다. 그래서 Keyset은 차이가 없었고, JDBC batch는 10만 건에서 5.4배, 40만 건에서 5.7배 빨랐습니다. 세 방식 모두 반영 내용은 같았습니다.
- 다만 묶어서 보내면 어느 행에서 실패했는지가 흐려집니다. 당시 배치는 사용자 트래픽이 적은 시간에 돌아 처리 시간보다 실패 지점을 아는 것이 더 중요했고, 그래서 아래처럼 다시 설계했습니다.
다시 설계한다면: 구현과 측정
실패가 없는 경우는 묶어서 빠르게 처리하고, 실패가 난 묶음만 잘게 나눠 다시 실행해 실패 행을 정확히 남기는 구조를 같은 재현 환경에 구현해 측정했습니다.
- 묶음마다
PROPAGATION_NESTED로 SAVEPOINT(트랜잭션 안에서 되돌아갈 지점)를 두고, 저장이 실패하면 그 지점까지만 되돌린 뒤 나눕니다. 연결 끊김처럼 행과 관계없는 오류는 나누지 않고 그대로 실패시킵니다. - 실패 기록에는 원본 ID, 실패 단계, 오류 코드와 메시지를 남기고 원본을 오류 상태로 바꿉니다. 묶음이 롤백돼도 기록이 남도록, 묶음마다 실패를 모아
PROPAGATION_REQUIRES_NEW로 별도 트랜잭션에서 한 번에 커밋합니다.
오류 행을 넣고 측정
10만 건 중 k개 행에 컬럼 길이를 넘는 값을 넣었습니다. 변환은 성공하고 DB 저장에서 오류(SQLSTATE 22001)가 나는 행입니다. 조건마다 3회 쟀습니다.
- 실패 기록은 모든 조건에서 오류를 넣은 행과 정확히 같았고, 중복 반영은 없었습니다.
- 저장이 실패하면 재설계안 코드가 그 묶음을 반으로 나눠 다시 저장하고, 실패한 쪽만 다시 나누기를 반복합니다(이분 탐색). 1만 행은 14번 나누면 한 행까지 좁혀지므로, 오류 행이 하나인 묶음은 14번 나눠 그 행을 찾았습니다.
측정값 표 보기
| 오류 행 수 | 처리 시간 (중앙값) | 반영 행 수 | 실패 기록 | 나누기 횟수 |
|---|---|---|---|---|
| 0 | 16.9초 | 100,000 | 0 | 0 |
| 1 | 17.1초 | 99,999 | 1 | 14 |
| 10 | 19.1초 | 99,990 | 10 | 75~79 |
| 100 | 21.5초 | 99,900 | 100 | 462~535 |
운영 반영 코드와 비교
같은 오류 행(1개, 10개)을 운영 반영 코드에 넣어 비교했습니다.
- 운영 반영 코드는 응답에도 원래 오류가 아니라 “current transaction is aborted”를 돌려줘, 어떤 행이 왜 실패했는지 알 수 없었습니다. 재설계안은 실패 기록에 행별 오류 코드와 메시지를 남겼습니다.
- 운영에서는 보존한 원본으로 재처리했습니다. 오류 행의 상태값을 DB에서 직접 되돌린 뒤 3단계를 다시 실행했고, 재설계안은 이 작업을 실패 기록과 재처리 API로 대신합니다.
비교 표 보기
| 항목 | 운영 반영 코드 | 재설계안 |
|---|---|---|
| 반영 행 수 | 10,000 (첫 묶음만) | 오류 행을 뺀 전부 |
| 오류가 든 묶음 | 1만 행 전체 롤백, 이후 묶음은 실행되지 않음 | 오류 행만 빼고 저장 |
| 원본의 오류 표시 | 0건 (롤백에 함께 사라짐) | 오류 행 수와 같음 |
| 응답의 오류 내용 | 원래 오류가 아닌 “current transaction is aborted” | 실패 기록에 행별 오류 코드와 메시지 |
| 실패한 행 다시 반영 | DB에서 상태값을 직접 되돌린 뒤 3단계 다시 실행 | 재처리 API로 실패 행만 다시 실행 |
재처리
- 재처리 API는 해결되지 않은 실패 기록의 행을 오류 전 검증 상태로 되돌려 다시 저장합니다. 성공하면 기록을 해결로 바꾸고, 또 실패하면 새 기록을 남깁니다.
- 오류 10개 조건에서 오류 행의 값을 고친 뒤 재처리 API를 한 번 호출했습니다. 대상 10행이 0.78초에 모두 반영됐고, 반영 내용은 오류가 없던 조건과 같았습니다.
한계
- 이 페이지의 수치는 합성 데이터로 잰 값이며, 실무 수치와 직접 비교하지 않습니다.
- 운영 힙 설정은 확인하지 못했습니다.
- 재설계안의 실패 처리는 생성 지시에만 넣었습니다. 변환 실패와 재처리가 다시 실패하는 경로는 1만 건으로 한 번만 확인했습니다.