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

7.2 KiB

아래는 "JPA 성능 최적화"에 대해 설명하는 글입니다. 실무에서 자주 발생하는 문제와 해결 방법을 중심으로, 예시와 함께 구체적으로 작성했습니다.


JPA 성능 최적화

JPA는 객체와 관계형 데이터베이스를 매핑하며 개발 생산성을 높여주지만, 잘못 사용하면 성능 저하를 초래할 수 있습니다. 성능 최적화는 불필요한 데이터베이스 호출을 줄이고, 쿼리 실행을 효율화하며, 메모리 사용을 최적화하는 데 초점을 맞춥니다. 아래는 JPA 성능 최적화의 주요 기법과 실무 적용 방법을 다룹니다.

1. N+1 문제와 해결 방법

N+1 문제는 연관 관계를 조회할 때 발생하는 대표적인 성능 문제입니다. 예를 들어, TeamMember의 1:N 관계에서 모든 팀을 조회한 뒤 각 팀의 회원을 개별적으로 조회하면, 초기 쿼리 1번(N개의 Team 조회) + N번(Member 조회)이 발생합니다.

  • 해결 방법 1: 페치 조인(Fetch Join)
    연관된 엔티티를 한 번의 쿼리로 함께 조회합니다.

    @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
    }
    
    // JPQL로 페치 조인
    String jpql = "SELECT t FROM Team t JOIN FETCH t.members";
    List<Team> teams = entityManager.createQuery(jpql, Team.class).getResultList();
    
    • 결과: 단일 쿼리로 TeamMember를 함께 조회(SELECT t.*, m.* FROM team t INNER JOIN member m ON t.id = m.team_id).
  • 해결 방법 2: 배치 크기 설정 (@BatchSize)
    지연 로딩(Lazy Loading) 시 한 번에 여러 엔티티를 조회합니다.

    @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)이므로 유지.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
    
  • 주의: 지연 로딩 사용 시 프록시 객체가 반환되며, 트랜잭션 밖에서 접근하면 LazyInitializationException이 발생할 수 있음.

3. 페치 조인과 일반 조인의 적절한 사용

  • 페치 조인: 연관 데이터를 즉시 로드할 때 사용. 데이터 중복이 발생할 수 있으니 페이징(setFirstResult, setMaxResults)과 함께 사용 금지.
  • 일반 조인: 필터링이나 조건에만 사용하며, 엔티티 로드는 지연 로딩에 의존.

4. 읽기 전용 쿼리 설정

수정할 필요 없는 조회 쿼리는 영속성 컨텍스트의 관리 부담을 줄이기 위해 읽기 전용으로 설정합니다.

  • JPQL:
    entityManager.createQuery("SELECT m FROM Member m", Member.class)
                 .setHint("org.hibernate.readOnly", true)
                 .getResultList();
    
  • 효과: 변경 감지(Dirty Checking)와 스냅샷 생성이 생략되어 메모리와 성능 개선.

5. 배치 처리와 대량 데이터 관리

대량 데이터를 삽입/수정/삭제할 때는 JPA의 기본 동작(영속성 컨텍스트 사용)이 비효율적일 수 있습니다.

  • 방법: JDBC 배치나 벌크 연산 사용.
    // 벌크 업데이트
    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 등)를 설정.
    @Entity
    @Cacheable
    @org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    public class Product {
        @Id
        private Long id;
        private String name;
        // getter
    }
    
  • 적용 시점: 자주 조회되고 변경이 드문 데이터(예: 카테고리 목록).

7. 쿼리 최적화

  • 필요한 필드만 조회: 엔티티 전체 대신 DTO로 필요한 데이터만 조회.
    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)).

실무 예시

@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 성능 최적화는 애플리케이션 규모와 요구사항에 따라 달라지며, 문제 원인을 분석하고 적절한 기법을 적용하는 것이 중요합니다. 다음 장에서는 트랜잭션 관리와 실무 활용을 다뤄보겠습니다.


책의 흐름에 맞는지, 추가 내용이나 수정이 필요하면 말씀해 주세요!