홈으로 돌아가기

레거시 코드 최적화: 처리 시간 99% 단축

레거시 시스템에서 75천 레코드 처리 최적화 실제 사례 분석. 중복 작업 제거, 배치 처리 설정 및 안전한 스케일링을 통해 4단계로 실행 시간을 105초에서 1.05초로 줄이는 방법을 보여줌.

실제 사례: 레거시 코드 최적화 105초에서 1.05초로
Advertisement 728x90

# 레거시 코드 최적화: 실제 사례에서 처리 시간을 99% 단축하는 방법

외부 시스템과의 REST 통합 구현 시 성능 문제가 자주 발생합니다. 이 글에서는 4단계로 75,000건의 레코드를 처리하여 실행 시간을 99% 줄인 방법을 자세히 분석하겠습니다. 이 사례는 엄격한 제약 조건 하에서 은행 시스템을 외부 파트너와 통합하는 실제 작업을 기반으로 합니다.

문제: 파트너 제약 조건 하의 REST 통합

초기 조건:

  • 사용자 데이터를 파트너 시스템으로 REST API를 통해 전송해야 함
  • 파트너는 단일 요청만 허용 (배치 처리 불가)
  • 실행 시간 제한: 부하를 최소화하기 위한 새벽 5시 시간대
  • 데이터 양: 상태가 NEW인 75,000건
  • 비동기 처리나 수평 확장 불가

원본 코드는 Spring Data Pageable을 사용한 페이지네이션 로딩(페이지 크기 2000건)을 사용했습니다. 치명적인 오류는 모든 수정된 엔티티를 메모리에 누적한 후 단일 saveAll() 작업을 수행하는 것이었습니다. 이는 다음을 초래했습니다:

Google AdInline article slot
  • 각 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 메서드는 숨겨진 오버헤드를 유발할 수 있습니다.

Google AdInline article slot

2단계: 처리 매개변수 미세 조정

다음 최적화는 배치 크기에 초점을 맞췄습니다. 테스트 결과:

  • 크기 500: 시간 8.41% 증가 (106.68초)
  • 크기 2000: 기준 98.4초
  • 크기 5000: 1.86% 개선 (96.58초)

만능 해결책은 없습니다. 최적 크기는 다음에 따라 다릅니다:

  • 하드웨어 사양 (디스크 IOPS, 네트워크 대역폭)
  • 처리 루프의 비즈니스 로직 복잡도
  • DB 연결 풀 설정

권장 접근법: 프로덕션과 유사한 환경에서 A/B 테스트를 실행하고 리소스 프로파일링(CPU, 메모리, 디스크 I/O)을 수행하세요. 우리 사례에서는 5000건이 최적이었으며, 트랜잭션 커밋 횟수를 줄였습니다.

Google AdInline article slot

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

Advertisement 728x90

다음 읽기