Spring/JPA 심화

JPA 영속성 관리 (4편) : 대용량 데이터 처리와 성능 최적화

JuNo_12 2025. 6. 27. 14:37

전체 시리즈 목차

  • 1편: N+1 문제 해결과 Fetch Join 전략 
  • 2편: OSIV 패턴과 API 설계 전략
  • 3편: 영속성 컨텍스트와 엔티티 생명주기 관리
  • 4편: 대용량 데이터 처리와 성능 최적화 ← 현재

1. 대용량 데이터 처리 시 메모리 폭발 문제

문제 상황

우리 서비스에서 100만 명의 사용자에게 일괄 알림을 보내야 하는 상황이 발생했습니다.

// ❌ 이렇게 했더니 서버가 터졌습니다!
@Service
@Transactional
public class NotificationService {
    
    public void sendBulkNotification() {
        List<User> users = userRepository.findAll(); // 100만명 조회
        
        for (User user : users) {
            notificationRepository.save(new Notification(user, "공지사항"));
        }
        // OutOfMemoryError 발생!
    }
}

왜 터졌을까요?

  • JPA가 100만 개의 User 엔티티를 모두 영속성 컨텍스트(1차 캐시)에 보관
  • 메모리 사용량이 8GB까지 증가
  • 가비지 컬렉션이 감당할 수 없어서 서버 다운

 

해결 방법: 배치 처리 + 영속성 컨텍스트 관리

@Service
@Transactional
public class OptimizedNotificationService {
    
    private final EntityManager entityManager;
    
    public void sendBulkNotification() {
        int batchSize = 1000;
        int page = 0;
        
        while (true) {
            // 1000명씩 나누어서 처리
            Pageable pageable = PageRequest.of(page, batchSize);
            Page<User> users = userRepository.findAll(pageable);
            
            if (users.isEmpty()) break;
            
            for (User user : users) {
                notificationRepository.save(new Notification(user, "공지사항"));
            }
            
            // 핵심: 1000명 처리할 때마다 메모리 비우기
            entityManager.flush();  // DB에 즉시 반영
            entityManager.clear();  // 메모리에서 제거
            
            page++;
        }
    }
}

2. 동시 수정으로 인한 데이터 충돌 문제

문제 상황

쇼핑몰에서 마지막 1개 남은 상품을 동시에 여러 명이 주문했을 때 재고가 마이너스가 되는 문제가 발생했습니다.

// ❌ 문제 코드: 동시성 제어 없음
@Service
@Transactional
public class OrderService {
    
    public void createOrder(Long productId, int quantity) {
        Product product = productRepository.findById(productId).get();
        
        if (product.getStock() >= quantity) {
            product.decreaseStock(quantity); // 여기서 문제 발생!
            // 사용자 A, B가 동시에 이 코드를 실행하면 재고 검증을 둘 다 통과
        }
    }
}

 

상황:

  • 재고: 1개
  • 사용자 A: 1개 주문 시도
  • 사용자 B: 1개 주문 시도
  • 결과: 재고가 -1개가 됨

 

해결 방법 1: 낙관적 락 (일반적인 경우)

@Entity
public class Product {
    @Version  // 핵심: 버전 관리
    private Long version;
    
    private int stock;
    
    public void decreaseStock(int quantity) {
        if (this.stock < quantity) {
            throw new InsufficientStockException();
        }
        this.stock -= quantity;
    }
}

@Service
@Transactional
public class OrderService {
    
    @Retryable(value = OptimisticLockException.class, maxAttempts = 3)
    public void createOrder(Long productId, int quantity) {
        Product product = productRepository.findById(productId).get();
        product.decreaseStock(quantity);
        // 커밋 시점에 version 체크, 충돌 시 재시도
    }
}

 

해결 방법 2: 비관적 락 (재고 관리 등 중요한 경우)

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithLock(@Param("id") Long id);
}

@Service
@Transactional
public class OrderService {
    
    public void createOrder(Long productId, int quantity) {
        // 즉시 락 획득, 다른 트랜잭션은 대기
        Product product = productRepository.findByIdWithLock(productId).get();
        product.decreaseStock(quantity);
        // 트랜잭션 끝나면 락 해제
    }
}

 

어떤 걸 선택해야 할까요?

  • 동시 수정이 적고 성능이 중요: 낙관적 락
  • 데이터 정확성이 매우 중요: 비관적 락

3. 배치 INSERT 성능 문제

문제 상황

매일 밤 외부 시스템에서 10만 건의 상품 데이터를 받아서 DB에 저장하는데 3시간이 걸려서 새벽 작업 시간을 초과하는 문제가 발생했습니다.

// ❌ 느린 코드: 개별 저장
@Service
@Transactional
public class ProductImportService {
    
    public void importProducts(List<ProductDto> productDtos) {
        for (ProductDto dto : productDtos) {
            Product product = new Product(dto);
            productRepository.save(product); // 10만 번의 개별 INSERT
        }
        // 10만 건 처리에 3시간 소요
    }
}

 

해결 방법: JDBC 배치 설정 + JPA 최적화

1단계: 설정 최적화

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 1000              # 1000개씩 묶어서 처리
        order_inserts: true             # INSERT 순서 최적화
        
  datasource:
    url: jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
    # MySQL에서 배치 처리 활성화

 

2단계: 코드 최적화

@Service
@Transactional
public class OptimizedProductImportService {
    
    private final EntityManager entityManager;
    
    public void importProducts(List<ProductDto> productDtos) {
        int batchSize = 1000;
        
        for (int i = 0; i < productDtos.size(); i++) {
            Product product = new Product(productDtos.get(i));
            entityManager.persist(product);
            
            // 1000개마다 배치 실행
            if (i % batchSize == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        }
    }
}

4. 자주 조회되는 데이터로 인한 DB 부하 문제

문제 상황

사용자 권한 정보를 매번 DB에서 조회해서 DB 커넥션이 부족하고 응답 속도가 느려지는 문제가 발생했습니다.

// ❌ 문제 코드: 매번 DB 조회
@Service
@Transactional(readOnly = true)
public class AuthService {
    
    public boolean hasPermission(Long userId, String permission) {
        User user = userRepository.findById(userId).get();
        UserRole role = userRoleRepository.findByCode(user.getRoleCode()).get();
        // 사용자마다 매번 권한 테이블 조회 → DB 부하
        return role.hasPermission(permission);
    }
}

 

상황:

  • 권한 확인 요청: 초당 1000건
  • DB 쿼리: 초당 2000번 (사용자 + 권한)
  • 결과: DB 커넥션 풀 고갈

 

해결 방법: 2차 캐시 적용

1단계: 캐시 대상 선정

// 권한 정보는 거의 변경되지 않으므로 캐시 적용
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)  // 읽기 전용
public class UserRole {
    
    @Id
    private String code;
    private String name;
    private Set<String> permissions;
}

// 사용자 정보는 가끔 변경되므로 읽기/쓰기 캐시
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class User {
    
    @Id
    private Long id;
    private String username;
    private String roleCode;
}

 

2단계: 쿼리 캐시 적용

public interface UserRoleRepository extends JpaRepository<UserRole, String> {
    
    @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
    @Query("SELECT r FROM UserRole r WHERE r.code = :code")
    Optional<UserRole> findByCodeCacheable(@Param("code") String code);
}

5. 로그 저장 실패로 인한 메인 기능 롤백 문제

문제 상황

주문 처리는 성공했는데 주문 로그 저장이 실패해서 전체 주문이 롤백되는 문제가 발생했습니다.

// ❌ 문제 코드: 하나의 트랜잭션에서 모든 처리
@Service
@Transactional
public class OrderService {
    
    public void processOrder(OrderRequest request) {
        // 메인 주문 처리
        Order order = createOrder(request);
        paymentService.processPayment(order);
        
        // 로그 저장 (이게 실패하면 주문도 롤백됨)
        orderLogRepository.save(new OrderLog(order));
        // 로그 테이블 장애 시 전체 주문 처리 실패!
    }
}

 

해결 방법: 트랜잭션 분리

@Service
public class OrderService {
    
    private final OrderLogService orderLogService;
    
    @Transactional
    public void processOrder(OrderRequest request) {
        try {
            // 메인 주문 처리
            Order order = createOrder(request);
            paymentService.processPayment(order);
            
            // 성공 로그 (별도 트랜잭션)
            orderLogService.logSuccess(order);
            
        } catch (Exception e) {
            // 실패 로그 (별도 트랜잭션)
            orderLogService.logFailure(request, e.getMessage());
            throw e;
        }
    }
}

@Service
public class OrderLogService {
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logSuccess(Order order) {
        // 새로운 트랜잭션으로 실행
        // 메인 트랜잭션과 독립적으로 커밋
        orderLogRepository.save(new OrderLog(order, "SUCCESS"));
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logFailure(OrderRequest request, String error) {
        // 메인 트랜잭션 실패해도 이 로그는 저장됨
        orderLogRepository.save(new OrderLog(request, "FAILED", error));
    }
}

6. 실무 성능 최적화 우선순위

1단계: 반드시 해야 하는 기본 최적화

문제: 쿼리가 너무 많이 나가서 DB 부하 증가

해결:

// N+1 문제 해결
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findUsersWithOrders();

// 읽기 전용 트랜잭션 적용
@Transactional(readOnly = true)
public List<User> findUsers() { ... }

// 배치 사이즈 설정
@BatchSize(size = 50)
@OneToMany(mappedBy = "user")
private List<Order> orders;

 

2단계: 성능이 중요한 경우 적용

문제: 메모리 사용량이 너무 많고 응답이 느림

해결:

// DTO 프로젝션으로 필요한 데이터만 조회
@Query("SELECT new UserSummaryDto(u.id, u.name) FROM User u")
List<UserSummaryDto> findUserSummaries();

// 읽기 전용 힌트로 성능 최적화
@QueryHints({
    @QueryHint(name = "org.hibernate.readOnly", value = "true"),
    @QueryHint(name = "org.hibernate.fetchSize", value = "50")
})
List<User> findUsersOptimized();

 

3단계: 극한 성능이 필요한 경우

문제: JPA로는 성능 한계에 도달

해결: 상황에 따라 JDBC 직접 사용, 저장 프로시저, NoSQL 도입 검토


핵심 정리

성능 문제가 발생했을 때 접근 순서:

  1. 측정: 어디가 느린지 정확히 파악
  2. N+1 해결: 가장 큰 효과
  3. 인덱스 확인: DB 레벨 최적화
  4. 캐시 적용: 자주 조회되는 데이터
  5. 배치 최적화: 대용량 처리
  6. 트랜잭션 분리: 독립적인 작업들

 

기억할 점:

  • 추측하지 말고 실제 성능을 측정하자
  • 80%의 문제는 20%의 핵심 기법으로 해결된다
  • 과도한 최적화보다는 적절한 수준에서 멈추자