기록

[자바 ORM 표준 JPA 프로그래밍] 스프링 데이터 JPA/성능 최적화 (12장-2) 본문

교육/책

[자바 ORM 표준 JPA 프로그래밍] 스프링 데이터 JPA/성능 최적화 (12장-2)

youngyin 2024. 12. 7. 09:30

시작하면서

아래는 "자바 ORM 표준 JPA 프로그래밍" 책과 강의를 듣고 정리한 내용입니다. JPA를 사용하다 보면 성능 문제로 많은 고민을 하게 됩니다. 특히 연관관계가 설정된 엔티티를 조회할 때 발생할 수 있는 대표적인 문제로 N+1 문제가 있습니다. 이 포스팅에서는 N+1 문제의 발생 원인과 해결 방법, 검색 조건 최적화, 그리고 특정 필드만 조회하는 최적화 기법에 대해 알아보겠습니다.


1. N+1 문제

N+1 문제를 이해하기 위해 우선 연관된 엔티티 설계를 살펴보겠습니다. 아래는 MemberTeam의 엔티티 설계 예시입니다. MemberTeam과 다대일(ManyToOne) 관계를 가지고 있습니다.

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    // getters and setters
}

@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();

    // getters and setters
}

위 코드에서 MemberTeam과 다대일 관계로 매핑되어 있으며, @ManyToOne 어노테이션을 사용하여 Team과의 연관 관계를 정의하고 있습니다. Team 엔티티는 다수의 Member와 연관되어 있으며, @OneToMany 어노테이션을 통해 이를 표현하고 있습니다.

1.1 N+1 문제의 발생

연관관계가 설정된 엔티티를 조회할 때 발생할 수 있는 대표적인 문제가 바로 N+1 문제입니다. 예를 들어, MemberTeam 관계에서 Member를 조회하면서 Team 정보를 함께 가져오고 싶다고 할 때 아래와 같은 코드를 작성할 수 있습니다.

public List<Member> findAllMembers() {
    return entityManager.createQuery("SELECT m FROM Member m", Member.class).getResultList();
}

이 경우 첫 번째 쿼리는 모든 Member 엔티티를 조회하지만, 각 MemberTeam에 접근할 때마다 추가적으로 Team 엔티티를 조회하는 쿼리가 발생합니다. 예를 들어, Member가 10명이고 각 MemberTeam을 가지고 있다면 다음과 같은 쿼리가 실행됩니다:

  • 모든 멤버 조회:
    SELECT * FROM Member;
  • 각 멤버의 팀 조회 (10번 반복):
    SELECT * FROM Team WHERE id = ?;

즉, 총 1번의 Member 조회와 N번(여기서는 10번)의 Team 조회가 발생하게 되며, 이를 N+1 문제라고 부릅니다. 이로 인해 성능이 크게 저하될 수 있습니다.

1.2 N+1 문제 해결 방법

1.2.1 페치 조인 (Fetch Join)

이 문제를 해결하기 위해 JPA에서는 페치 조인(Fetch Join)을 사용할 수 있습니다. 페치 조인을 사용하면 연관된 엔티티를 한 번의 쿼리로 모두 가져올 수 있어 N+1 문제를 방지할 수 있습니다.

public List<Member> findAllMembersWithTeam() {
    return entityManager.createQuery("SELECT m FROM Member m JOIN FETCH m.team", Member.class).getResultList();
}

위 코드에서는 JOIN FETCH를 사용하여 Member와 연관된 Team을 한 번의 쿼리로 모두 조회합니다. 실제로 실행되는 쿼리는 다음과 같습니다:

SELECT m.*, t.* FROM Member m INNER JOIN Team t ON m.team_id = t.id;

이렇게 하면 MemberTeam을 한 번에 조회하기 때문에 추가적인 쿼리가 발생하지 않으며, N+1 문제를 해결할 수 있습니다.

1.2.2 즉시 로딩 (Eager Loading)

또 다른 해결 방법으로는 즉시 로딩(Eager Loading)을 사용하는 것입니다. 즉시 로딩은 엔티티를 조회할 때 연관된 엔티티도 함께 즉시 로드합니다. 이를 통해 여러 쿼리를 줄이고 연관된 데이터를 한 번에 가져올 수 있습니다.

@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "team_id")
private Team team;

즉시 로딩을 사용하면 JPA는 Member를 조회할 때 Team도 함께 가져오는 쿼리를 실행합니다. 예를 들어, 아래와 같은 형태의 쿼리가 작성될 수 있습니다:

SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.team_id = t.id;

하지만 즉시 로딩을 사용할 때는 조심해야 합니다. 연관된 엔티티가 많아질 경우 불필요한 데이터까지 모두 로드하여 성능 저하가 발생할 수 있습니다. 즉시 로딩은 필요한 경우에만 사용하고, 대부분의 경우에는 지연 로딩(Lazy Loading)페치 조인(Fetch Join)을 적절히 사용하는 것이 좋습니다.


2. 검색 조건 최적화

JPA를 사용하다 보면 동적으로 쿼리를 생성해야 하는 경우가 많습니다. 스프링 데이터 JPA에서는 SpecificationsQuery by Example (QBE)를 통해 동적 쿼리를 작성할 수 있습니다.

2.1 Specifications (명세)

Specifications은 여러 조건을 조합하여 동적으로 쿼리를 생성할 수 있는 기능을 제공합니다.

Specification<Member> spec = MemberSpec.username("m1").and(MemberSpec.teamName("teamA"));
List<Member> result = memberRepository.findAll(spec);

2.2 Query by Example (QBE)

QBE는 예제 객체를 사용하여 데이터를 조회하는 기능입니다. 필드 값으로 조건을 정의하고, 그 조건에 맞는 데이터를 조회할 수 있습니다.

ExampleMatcher matcher = ExampleMatcher.matching().withIgnorePaths("age");
Example<Member> example = Example.of(new Member("username"), matcher);
List<Member> result = memberRepository.findAll(example);

3. 검색 컬럼 최적화

때로는 모든 필드를 조회하지 않고, 특정 필드만 조회하여 성능을 최적화할 필요가 있습니다. JPA에서는 이를 위한 여러 가지 방법을 제공합니다.

3.1 Projections

프로젝션을 사용하면 필요한 데이터만 조회할 수 있어 성능을 최적화할 수 있습니다. 인터페이스나 클래스 기반 프로젝션을 정의하여 원하는 필드만 선택적으로 가져올 수 있습니다.

List<UsernameOnly> findByUsername(String username);

또한 동적 프로젝션을 사용하여 반환 타입을 유연하게 변경할 수 있습니다.

List<T> findProjectionsByUsername(String username, Class<T> type);

3.2 네이티브 쿼리 사용

스프링 데이터 JPA는 네이티브 쿼리도 지원합니다. 복잡한 쿼리나 성능 최적화를 위해 필요할 때 사용할 수 있지만, 가능하면 네이티브 쿼리 대신 JPA 표준을 사용하는 것이 유지보수에 좋습니다.

@Query(value = "SELECT * FROM member WHERE username = ?", nativeQuery = true)
Member findByNativeQuery(String username);

네이티브 쿼리는 JPQL보다 더 직접적인 SQL 문을 실행하므로 성능 최적화에 유리하지만, 코드의 유지보수가 어려울 수 있다는 단점이 있습니다.


마치며

JPA를 사용할 때 발생할 수 있는 N+1 문제와 이를 해결하기 위한 여러 방법들 - 페치 조인, 즉시 로딩 - 그리고 동적 쿼리를 작성하는 다양한 방법과 특정 필드만 조회하여 성능을 최적화하는 방법을 살펴보았습니다. JPA의 강력한 기능들을 잘 활용하면 복잡한 데이터 조회도 효율적으로 처리할 수 있습니다. 다만, 성능 최적화를 위해 항상 필요한 데이터만 적절히 조회하고, 불필요한 쿼리가 발생하지 않도록 주의해야 합니다.

Comments