실무에서 바로 쓰는 JPA (5편) - 실무 안티패턴 총정리
시리즈: 실무에서 바로 쓰는 JPA
5 / 5편
- 11편 - N+1 문제 해결
- 22편 - 영속성 컨텍스트와 흔한 실수들
- 33편 - 쿼리 최적화 실전
- 44편 - 엔티티 설계 마스터하기
- 55편 - 실무 안티패턴 총정리지금 읽는 중

목차
Hibernate의 동작 기준은 공식 배치 처리 문서에서 확인할 수 있습니다. 아래의 과거 시간 수치는 해당 사례의 기록이며 saveAll 자체의 보편적인 성능 보장이 아닙니다.
TL;DR
- 문제: N+1, 영속성 컨텍스트, 쿼리 최적화, 엔티티 설계 등 JPA 실무 안티패턴이 복합적으로 발생
- 원인: 각 주제를 개별적으로 다뤘지만, 실무에서는 여러 문제가 동시에 나타남
- 해결: JPA 시리즈 1~4편 내용을 한 곳에 모아 체크리스트로 정리
- 효과: 코드 리뷰 시 빠른 확인, 리팩토링 가이드, 신규 팀원 온보딩 자료
- 한계: 시리즈 전체를 읽어야 완전히 이해, 체크리스트만으로는 깊이 부족, 실전 경험 필요
환경
- 프레임워크: Spring Boot 3.x + JPA (Hibernate)
- DB: MySQL 8.0, InnoDB
- 시리즈: JPA 실무 안티패턴 종합 정리 (1~4편 요약)
- 목표: 코드 리뷰 & 리팩토링 체크리스트
시리즈 안내
이 글은 “실무에서 바로 쓰는 JPA” 시리즈의 다섯 번째 편(최종편)입니다.
1편: N+1 문제 해결
연관관계 조회 시 발생하는 성능 문제와 해결 방법을 다룹니다.
2편: 영속성 컨텍스트와 흔한 실수들
JPA의 내부 동작 원리와 실수하기 쉬운 포인트들을 짚어봅니다.
3편: 쿼리 최적화 실전
복잡한 쿼리를 효율적으로 작성하는 노하우를 공유합니다.
4편: 엔티티 설계 마스터하기
유지보수하기 좋은 엔티티 설계 원칙을 다룹니다.
5편 (현재 글): 실무 안티패턴 총정리
실무에서 자주 발생하는 실수와 개선 방법을 총정리합니다.
시리즈 요약:
1편에서 N+1 문제를, 2편에서 영속성 컨텍스트를, 3편에서 쿼리 최적화를, 4편에서 엔티티 설계를 다뤘습니다. 각 주제를 깊이 있게 다 뤘지만, 실무에서는 이런 문제들이 복합적으로 나타납니다.
이번 최종편에서는 지금까지 다룬 내용을 한 곳에 모아 정리했습니다. 코드 리뷰할 때나 리팩토링할 때 빠르게 확인할 수 있는 체크리스트로 활용하시면 됩니다.
들어가며
회사에서 레거시 코드를 리팩토링하다 보면, 성능 문제의 원인이 대부분 JPA 사용법에 있더군요.
특히 신입 개발자분들이 작성한 코드를 보면, 기능은 정상 동작하지만 성능이 좋지 않은 경우가 많습니다. 코드는 동작하니까 별문제 없다고 생각하지만, 데이터가 늘어나면서 서서히 문제가 드러나기 시작합니다.
저도 처음 JPA를 배울 때 비슷한 실수를 많이 했었습니다. 이번 포스팅에서는 실무에서 자주 목격하는 JPA 안티패턴들과 개선 방법을 공유합니다.
반복문 안에서 save() 호출
문제 상황
가장 흔하게 보는 패턴입니다. 엑셀 파일을 업로드해서 대량의 데이터를 저장하는 기능을 구현할 때 이런 코드를 자주 봅니다.
// Bad - 반복문 안에서 개별 save
@Transactional
public void saveUsers(List<UserDto> userDtos) {
for (UserDto dto : userDtos) {
User user = new User(dto.getName(), dto.getEmail());
userRepository.save(user);
}
}1,000건의 데이터를 저장한다면 1,000번의 INSERT 쿼리가 나갑니다. 각 쿼리마다 네트워크 왕복이 발생하니 당연히 느릴 수밖에 없습니다.
저희 팀에서 측정했을 때, 1,000건 기준으로 약 15초 정도 걸렸습니다.
해결 방법
saveAll()은 각 엔티티에 save를 반복하는 API입니다. 한 번의 SQL이나 JDBC batch를 보장하지 않습니다. Hibernate의 batch_size, 식별자 생성 전략, 드라이버 설정과 트랜잭션 경계가 맞아야 실제 배치가 됩니다.
// Good - saveAll + batch insert
@Transactional
public void saveUsers(List<UserDto> userDtos) {
List<User> users = userDtos.stream()
.map(dto -> new User(dto.getName(), dto.getEmail()))
.collect(Collectors.toList());
userRepository.saveAll(users);
}# application.yml
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 100
order_inserts: true
order_updates: true배치 사이즈를 100으로 설정하면 100개씩 묶어서 INSERT합니다. 같은 1,000건을 저장하는 데 약 2초로 단축됐습니다.
1,000건 데이터 저장 성능 비교
@GeneratedValue(strategy = GenerationType.IDENTITY)를 사용하면 배치 인서트가 동작하지 않습니다. MySQL 8에는 네이티브 SEQUENCE가 없습니다. 설정만 SEQUENCE로 바꾸는 해결책은 맞지 않습니다. IDENTITY가 필요한 대량 입력은 JDBC batch나 multi-row INSERT를 비교하고, TABLE 전략은 식별자 할당 경합 비용을 검증해야 합니다.
N+1 문제 방치
문제 상황
이건 정말 클래식한 문제입니다. 엔티티 간 연관관계가 있을 때 무심코 조회하다 보면 쿼리가 폭발합니다.
// Bad - N+1 발생
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private User user;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> orderItems;
}
// 컨트롤러나 서비스에서
List<Order> orders = orderRepository.findAll(); // 1번의 쿼리
for (Order order : orders) {
String userName = order.getUser().getName(); // N번의 쿼리
int itemCount = order.getOrderItems().size(); // N번의 쿼리
}주문이 100건이면 1 + 100 + 100 = 201번의 쿼리가 나갑니다.
실제로 저희 서비스에서 주문 목록 API가 느리다는 제보를 받고 확인해보니, 주문 50건에 대해 151번의 쿼리가 발생하고 있었습니다.
해결 방법
Fetch Join이나 EntityGraph를 사용해서 한 번에 가져옵니다.
// Good - Fetch Join으로 한 방에 조회
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o " +
"JOIN FETCH o.user " +
"JOIN FETCH o.orderItems")
List<Order> findAllWithUserAndItems();
}또는 @EntityGraph를 사용할 수도 있습니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = {"user", "orderItems"})
@Query("SELECT o FROM Order o")
List<Order> findAllWithGraph();
}두 방법 모두 1번의 쿼리로 모든 데이터를 가져옵니다. 응답 시간이 약 800ms에서 150ms로 줄었습니다.
주문 목록 조회 API 성능
Hibernate에서 bag으로 매핑된 컬렉션 여러 개를 동시에 fetch join하면 MultipleBagFetchException