# 레거시 코드 최적화: 실제 사례에서 처리 시간을 99% 단축하는 방법
외부 시스템과의 REST 통합 구현 시 성능 문제가 자주 발생합니다. 이 글에서는 4단계로 75,000건의 레코드를 처리하여 실행 시간을 99% 줄인 방법을 자세히 분석하겠습니다. 이 사례는 엄격한 제약 조건 하에서 은행 시스템을 외부 파트너와 통합하는 실제 작업을 기반으로 합니다.
문제: 파트너 제약 조건 하의 REST 통합
초기 조건:
- 사용자 데이터를 파트너 시스템으로 REST API를 통해 전송해야 함
- 파트너는 단일 요청만 허용 (배치 처리 불가)
- 실행 시간 제한: 부하를 최소화하기 위한 새벽 5시 시간대
- 데이터 양: 상태가 NEW인 75,000건
- 비동기 처리나 수평 확장 불가
원본 코드는 Spring Data Pageable을 사용한 페이지네이션 로딩(페이지 크기 2000건)을 사용했습니다. 치명적인 오류는 모든 수정된 엔티티를 메모리에 누적한 후 단일 saveAll() 작업을 수행하는 것이었습니다. 이는 다음을 초래했습니다:
- 각 SELECT 전에 자동 컨텍스트 플러시 발생
- 페이지당 2000개의 UPDATE 쿼리 생성
- HTTP 호출 중 트랜잭션 잠금
초기 벤치마크 결과 Apple M4(16 GB RAM)에서 처리 시간 ≈105초였습니다. 외부 시스템의 1 ms 지연을 시뮬레이션하면 프로덕션에서 중요한 지표가 되었습니다.
1단계: 중복 DB 작업 제거
SQL 로그 분석 결과 치명적인 결함이 드러났습니다: Hibernate가 배치 작업 대신 2000개의 개별 UPDATE를 생성하고 있었습니다. 원인은 루프 반복 동안 엔티티를 퍼시스턴스 컨텍스트에 유지한 것이었습니다. 해결책은 두 가지 주요 변경이었습니다:
- 프로세스 끝 saves를 중간 커밋으로 교체:
content.forEach(this::registerUser);
userRepository.saveAll(content);
- 벌크 상태 업데이트를 위한 네이티브 쿼리 구현:
@Modifying
@Query(value = """
UPDATE users SET status = CAST(:status AS text)
WHERE id IN (:ids)
""", nativeQuery = true)
int batchUpdateStatus(@Param("ids") Collection<Long> ids, @Param("status") String status);
이 변경으로 초기 결과가 나왔습니다—시간이 6% 단축(98.4초). 핵심 교훈: 대용량 데이터에서 표준 Spring Data JPA 메서드는 숨겨진 오버헤드를 유발할 수 있습니다.
2단계: 처리 매개변수 미세 조정
다음 최적화는 배치 크기에 초점을 맞췄습니다. 테스트 결과:
- 크기 500: 시간 8.41% 증가 (106.68초)
- 크기 2000: 기준 98.4초
- 크기 5000: 1.86% 개선 (96.58초)
만능 해결책은 없습니다. 최적 크기는 다음에 따라 다릅니다:
- 하드웨어 사양 (디스크 IOPS, 네트워크 대역폭)
- 처리 루프의 비즈니스 로직 복잡도
- DB 연결 풀 설정
권장 접근법: 프로덕션과 유사한 환경에서 A/B 테스트를 실행하고 리소스 프로파일링(CPU, 메모리, 디스크 I/O)을 수행하세요. 우리 사례에서는 5000건이 최적이었으며, 트랜잭션 커밋 횟수를 줄였습니다.
3단계: 안전한 수평 확장
두 스레드 처리를 시도하자 재앙이 발생했습니다—모든 작업이 중복(75,000 대신 150,000 쿼리). 해결을 위해 데이터 액세스 전략을 변경했습니다:
- SELECT FOR UPDATE SKIP LOCKED 구현:
SELECT * FROM users WHERE status = 'NEW' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 5000
- 트랜잭션 루프 구현:
@Transactional
public void processBatch() {
List<User> batch = userRepository.lockAndSelectBatch(5000);
batch.forEach(this::registerUser);
userRepository.batchUpdateStatus(
batch.stream().map(User::getId).collect(Collectors.toList()),
UserStatus.PROCESSED.name()
);
}
이 접근법은 다음을 보장했습니다:
- 확실한 일회성 처리
- 최소 테이블 잠금
- N개의 병렬 인스턴스 실행 가능
구현 후 처리 시간이 1.05초로 떨어졌습니다—원본 대비 99% 단축.
주요 최적화 단계
- 숨겨진 오버헤드 분석
- 생성된 쿼리 추적을 위한 show-sql 활성화
- 루프에서 퍼시스턴스 컨텍스트 동작 확인
- 배치 작업으로 전환
- saveAll()을 중간 커밋으로 교체
- 벌크 업데이트를 위한 네이티브 쿼리 사용
- 매개변수 실험
- 다양한 배치 크기 테스트
- 테스트 중 시스템 메트릭 모니터링
- 확장을 위한 준비
- 동시 액세스를 위한 SKIP LOCKED 구현
- 트랜잭션을 원자 블록으로 분해
핵심 교훈
- SELECT 전 Hibernate Flush—대용량 데이터에서 치명적 문제. 모든 DB 쿼리가 자동 플러시를 유발해 UPDATE 연쇄 발생.
- 배치 크기는 일정하지 않음. 작업 부하 특성에 따른 경험적 튜닝 필요. 테스트에서 5000건이 최적.
- 확장을 위한 트랜잭션 격리. SELECT FOR UPDATE SKIP LOCKED는 중복 없이 동시 처리를 가능하게 하지만 잠금 유지 시간을 증가시킴.
- 프로파일링 필수. 프로덕션과 유사한 벤치마크 없이는 최적화가 역효과 날 수 있음 (예: 배치 크기를 500으로 줄임).
- 레거시 제약 호환성. 솔루션은 외부 제한(예: 파트너의 배치 요청 금지)을 고려해야 하며, 간단한 비동기 처리를 배제함.
최종 구현은 100,000건 부하 테스트에서 안정적인 1.05±0.05초 처리 시간을 달성했습니다. 성공의 핵심은 기술적 최적화와 외부 시스템 계약에 대한 깊은 이해를 결합한 것이었습니다. 이 접근법은 요청 크기 제한과 신뢰성 요구사항이 있는 모든 REST API 통합 시나리오에 적용됩니다.
— Editorial Team
아직 댓글이 없습니다.