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 도입 검토
핵심 정리
성능 문제가 발생했을 때 접근 순서:
- 측정: 어디가 느린지 정확히 파악
- N+1 해결: 가장 큰 효과
- 인덱스 확인: DB 레벨 최적화
- 캐시 적용: 자주 조회되는 데이터
- 배치 최적화: 대용량 처리
- 트랜잭션 분리: 독립적인 작업들
기억할 점:
- 추측하지 말고 실제 성능을 측정하자
- 80%의 문제는 20%의 핵심 기법으로 해결된다
- 과도한 최적화보다는 적절한 수준에서 멈추자