Files
spring-boot-examples/docs/jpa/11_JPA 성능 최적화.md
2025-04-08 19:56:24 +09:00

167 lines
7.2 KiB
Markdown

아래는 "JPA 성능 최적화"에 대해 설명하는 글입니다. 실무에서 자주 발생하는 문제와 해결 방법을 중심으로, 예시와 함께 구체적으로 작성했습니다.
---
### JPA 성능 최적화
JPA는 객체와 관계형 데이터베이스를 매핑하며 개발 생산성을 높여주지만, 잘못 사용하면 성능 저하를 초래할 수 있습니다. 성능 최적화는 불필요한 데이터베이스 호출을 줄이고, 쿼리 실행을 효율화하며, 메모리 사용을 최적화하는 데 초점을 맞춥니다. 아래는 JPA 성능 최적화의 주요 기법과 실무 적용 방법을 다룹니다.
#### 1. N+1 문제와 해결 방법
**N+1 문제**는 연관 관계를 조회할 때 발생하는 대표적인 성능 문제입니다. 예를 들어, `Team``Member`의 1:N 관계에서 모든 팀을 조회한 뒤 각 팀의 회원을 개별적으로 조회하면, 초기 쿼리 1번(N개의 `Team` 조회) + N번(`Member` 조회)이 발생합니다.
- **해결 방법 1: 페치 조인(Fetch Join)**
연관된 엔티티를 한 번의 쿼리로 함께 조회합니다.
```java
@Entity
public class Team {
@Id
private Long id;
private String name;
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
// getter
}
@Entity
public class Member {
@Id
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "team_id")
private Team team;
// getter
}
```
```java
// JPQL로 페치 조인
String jpql = "SELECT t FROM Team t JOIN FETCH t.members";
List<Team> teams = entityManager.createQuery(jpql, Team.class).getResultList();
```
- 결과: 단일 쿼리로 `Team`과 `Member`를 함께 조회(`SELECT t.*, m.* FROM team t INNER JOIN member m ON t.id = m.team_id`).
- **해결 방법 2: 배치 크기 설정 (@BatchSize)**
지연 로딩(Lazy Loading) 시 한 번에 여러 엔티티를 조회합니다.
```java
@OneToMany(mappedBy = "team")
@BatchSize(size = 100)
private List<Member> members;
```
- `size=100`으로 설정하면 100개의 `Team`에 대한 `Member`를 한 번의 IN 쿼리로 조회합니다.
#### 2. 지연 로딩(Lazy Loading) 활용
기본적으로 연관 관계는 지연 로딩으로 설정해 불필요한 데이터 조회를 방지합니다.
- `@ManyToOne`, `@OneToOne`: 기본값은 즉시 로딩(EAGER)이므로 `fetch = FetchType.LAZY`로 변경.
- `@OneToMany`, `@ManyToMany`: 기본값이 지연 로딩(LAZY)이므로 유지.
```java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
```
- **주의**: 지연 로딩 사용 시 프록시 객체가 반환되며, 트랜잭션 밖에서 접근하면 `LazyInitializationException`이 발생할 수 있음.
#### 3. 페치 조인과 일반 조인의 적절한 사용
- **페치 조인**: 연관 데이터를 즉시 로드할 때 사용. 데이터 중복이 발생할 수 있으니 페이징(`setFirstResult`, `setMaxResults`)과 함께 사용 금지.
- **일반 조인**: 필터링이나 조건에만 사용하며, 엔티티 로드는 지연 로딩에 의존.
#### 4. 읽기 전용 쿼리 설정
수정할 필요 없는 조회 쿼리는 영속성 컨텍스트의 관리 부담을 줄이기 위해 읽기 전용으로 설정합니다.
- **JPQL**:
```java
entityManager.createQuery("SELECT m FROM Member m", Member.class)
.setHint("org.hibernate.readOnly", true)
.getResultList();
```
- **효과**: 변경 감지(Dirty Checking)와 스냅샷 생성이 생략되어 메모리와 성능 개선.
#### 5. 배치 처리와 대량 데이터 관리
대량 데이터를 삽입/수정/삭제할 때는 JPA의 기본 동작(영속성 컨텍스트 사용)이 비효율적일 수 있습니다.
- **방법**: JDBC 배치나 벌크 연산 사용.
```java
// 벌크 업데이트
String jpql = "UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId";
entityManager.createQuery(jpql)
.setParameter("teamId", teamId)
.executeUpdate();
entityManager.clear(); // 영속성 컨텍스트 초기화 필수
```
- **주의**: 벌크 연산은 영속성 컨텍스트를 무시하므로, 이후 작업 전에 `clear()`나 `refresh()`로 동기화 필요.
#### 6. 캐시 활용
JPA는 1차 캐시와 2차 캐시를 제공하며, 이를 활용해 성능을 높일 수 있습니다.
- **1차 캐시**: 트랜잭션 내에서 동일 엔티티를 재조회할 때 데이터베이스 접근을 줄임(기본 제공).
- **2차 캐시**: 애플리케이션 범위에서 캐시를 공유. Hibernate 사용 시 `@Cacheable`과 캐시 제공자(Ehcache, Redis 등)를 설정.
```java
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@Id
private Long id;
private String name;
// getter
}
```
- **적용 시점**: 자주 조회되고 변경이 드문 데이터(예: 카테고리 목록).
#### 7. 쿼리 최적화
- **필요한 필드만 조회**: 엔티티 전체 대신 DTO로 필요한 데이터만 조회.
```java
public class MemberDto {
private Long id;
private String name;
public MemberDto(Long id, String name) {
this.id = id;
this.name = name;
}
// getter
}
List<MemberDto> result = entityManager.createQuery(
"SELECT new com.example.MemberDto(m.id, m.name) FROM Member m", MemberDto.class)
.getResultList();
```
- **인덱스 활용**: 자주 검색되는 컬럼에 인덱스 추가(데이터베이스 레벨 또는 `@Index`).
#### 8. 트랜잭션 범위 최소화
영속성 컨텍스트는 트랜잭션 범위에서 동작하므로, 불필요하게 긴 트랜잭션은 메모리 사용과 성능 저하를 유발합니다.
- **방법**: 비즈니스 로직을 간결히 하고, 읽기 전용 작업은 트랜잭션 없이 처리(`@Transactional(readOnly = true)`).
#### 실무 예시
```java
@Repository
public class MemberRepository {
@PersistenceContext
private EntityManager em;
@Transactional(readOnly = true)
public List<Member> findMembersWithTeam() {
return em.createQuery("SELECT m FROM Member m JOIN FETCH m.team", Member.class)
.setHint("org.hibernate.readOnly", true)
.getResultList();
}
@Transactional
public void bulkUpdateAge(Long teamId) {
em.createQuery("UPDATE Member m SET m.age = m.age + 1 WHERE m.team.id = :teamId")
.setParameter("teamId", teamId)
.executeUpdate();
em.clear();
}
}
```
#### 주의사항
- **N+1 디버깅**: 쿼리 로그를 활성화해(`spring.jpa.show-sql=true`) 발생 여부 확인.
- **캐시 동기화**: 2차 캐시 사용 시 데이터 정합성을 유지하기 위해 적절한 전략 선택.
- **테스트**: 성능 개선 후 부작용(예: 데이터 불일치)을 검증.
---
JPA 성능 최적화는 애플리케이션 규모와 요구사항에 따라 달라지며, 문제 원인을 분석하고 적절한 기법을 적용하는 것이 중요합니다. 다음 장에서는 트랜잭션 관리와 실무 활용을 다뤄보겠습니다.
---
책의 흐름에 맞는지, 추가 내용이나 수정이 필요하면 말씀해 주세요!