Java/Spring, Kotlin/Spring을 사용하는 백엔드 개발자에게 JPA는 선택이 아닌 필수로 가져가야 할 기술이 되었습니다. JPA는 C언어의 꽃인 포인터처럼 Spring의 꽃이라고 생각합니다. 그래서 JPA를 사용하면서 얻는 장점의 이면에 있는, JPA의 동작 원리를 정확히 알고 넘어가지 않으면 마주칠 수밖에 없는 N + 1 문제에 대해서 정리하려고 합니다.
해결 방법은 다음장에 준비하도록 하겠습니다.
프로젝트 버전
- 개발 도구: IntelliJ Ultimate
- Spring Boot: 2.5.7
- Java 11
- h2 Database
- Spring Data JPA 의존성 추가 [build.gradle]
- JUnit 5
예시로 사용될 Entity는 Member, Team 이렇게 둘입니다.
- Member는 하나의 팀에 속할 수 있으며, Team은 여러 명의 Member를 가질 수 있습니다.
- 연관 관계의 주인은 Member이며, 모든 페치 전략은 LAZY로 하였습니다. (EAGER도 테스트해 볼 예정입니다.)
- Member, Team은 서로 참조할 수 있는 양방향 연관 관계로 설계했습니다.
Member
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY) // xxToOne 은 기본 페치 전략이 EAGER
@JoinColumn(name = "team_id")
private Team team;
//== 연관 관계 편이 메서드 ==
public void enrollTeam(Team team) {
this.team = team;
team.getMembers().add(this);
}
}
MemberRepository
public interface MemberRepository extends JpaRepository<Member, Long> {
}
Team
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "team_id")
private Long id;
private String teamName;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "team")
private List<Member> members = new ArrayList<>();
}
TeamRepository
public interface TeamRepository extends JpaRepository<Team, Long> {
}
N + 1 문제가 뭘까?
N + 1 문제는 (저는 1 + N이 좀 더 이해하기 편했습니다.) 1번의 쿼리를 날리고 처음 날린 쿼리의 결과로 N번의 쿼리가 추가적으로 발생하는 문제입니다. (원하는 쿼리는 1번 나가는 거인데, 의도치 않게 1 + N번이 나가서 서비스에 부하가 가는 문제)
위에 있는 Member, Team을 통해서 예제를 들어보도록 하겠습니다.
public void N1Test() {
List<Member> members = memberRepository.findAll(); // 1
members.iterator().forEachRemaining(member -> { // 2
System.out.println("member.getTeam().getTeamName() = " + member.getTeam().getTeamName());
});
}
먼저, 1번과 같이 모든 Member를 조회합니다.
위와 같이 memberRepository를 통해 N명의 member를 조회합니다. [Query 1회 발생]
여기서, 각 member들이 속한 팀의 이름을 알고 싶다거나 팀의 정보를 알고 싶다고 가정해보겠습니다. 2번과 같이 말이죠.
위와 같이 for 문을 돌면서 member 각각의 team 이름을 출력하도록 했습니다. 이때 Query가 N회(최악의 경우) 발생하게 됩니다.
왜 Query N회가 발생하게 될까요?
위 코드의 1번을 통해 Member List를 조회할 때 Team의 정보는 가져오지 않기 때문입니다. Member List에서 나온 결과인 members는 Team 정보를 모르기 때문에 DB에 쿼리를 날려 그때 가져오게 됩니다. (추가적인 쿼리 수행, 그의 횟수가 최악의 경우 N)
왜 가져오지 않지?
Member Entity에 있는 Team Entity의 페치 전략을 지연 로딩(LAZY)으로 했기 때문입니다. 지연 로딩은 연관 관계를 가지는 Entity의 정보(엔티티 그래프)가 필요할 때, 쿼리가 나가도록 하는 것입니다.
그럼 즉시 로딩(EAGER)으로 설정하면 되는 거 아닌가요?
오히려 즉시 로딩이 N + 1 문제의 주범입니다. 위의 예시에서 즉시 로딩은 1번 코드를 수행함과 동시에 N + 1 문제를 터뜨립니다. 그러나 지연 로딩의 경우 2번 코드가 사라지게 되면 그저 쿼리 한 번만 날리는 평범한 쿼리문이 됩니다.
또한 추가적으로 즉시 로딩은 특히나 실무에서 사용을 지양해야 합니다. 왜냐하면 즉시 로딩은 개발자 입장에서 최적화할 수 있는 여력이 없습니다. 거기에 더해, 어떤 쿼리가 나갈지 상상할 수 없습니다.
그래서 모든 페치 전략을 지연 로딩(LAZY)으로 가져가고 상황에 맞게 성능 최적화가 필요한 경우에는 fetch join이나, @EntityGraph를 사용해야 합니다.
왜 최악의 경우가 N회인 가요?
JPA는 영속성 컨텍스트라는 개념이 있습니다. 영속성 컨텍스트는 이미 찾은 Entity가 있으면 DB에 쿼리를 날리지 않고 자신이 가지고 있는 Entity를 반환(1차 캐시)해줍니다. 그렇기 때문에 가령 "탁벤져스"라는 팀을 찾은 적이 있다면 다음 멤버가 "탁벤져스"에 속한다면 쿼리를 날리지 않고 영속성 컨텍스트에서 찾아갑니다. 그러나, 모든 멤버의 팀이 다 다를 경우에는 최악의 경우로 멤버의 수만큼 쿼리가 N번 날아가게 됩니다.
그럼 테스트 코드를 작성하여 위와 같은 상황이 발생하는지 확인해보도록 하겠습니다.
먼저 샘플로 사용할 데이터를 추가하겠습니다.
JpaTest
@Slf4j
@SpringBootTest
@Rollback(value = false)
class JpaTest {
@Autowired
private TeamRepository teamRepository;
@Autowired
private MemberRepository memberRepository;
@Test
public void initEntity() {
Team team1 = saveTeam("탁벤져스");
Team team2 = saveTeam("탁팀");
saveMember(team1, "왕탁이1");
saveMember(team1, "왕탁이2");
saveMember(team1, "왕탁이3");
saveMember(team2, "왕탁이4");
saveMember(team2, "왕탁이5");
}
private Team saveTeam(String teamName) {
Team team1 = new Team();
team1.setTeamName(teamName);
teamRepository.save(team1);
return team1;
}
private void saveMember(Team team, String memberName) {
Member member = new Member();
member.setName(memberName);
member.addTeam(team);
memberRepository.save(member);
}
}
initData() 함수를 실행하면 다음과 같이 DB에 값이 입력됩니다. [DB Connection 설정은 생략하겠습니다.]
그럼 Member List를 조회할 때 Team Entity가 가지는 페치 전략을 바꿔보면서 발생되는 쿼리를 확인해보겠습니다.
즉시 로딩 - EAGER
Member Entity가 가지고 있는 Team Entity의 fetchType을 다음과 같이 @ManyToOne(fetch = FetchType.EAGER)로 바꿔줍니다.
그런 후 샘플 데이터를 추가한 JpaTest에 다음 코드를 붙여 넣습니다.
@Test
@Transactional
public void N1Test() {
System.out.println("===== member findAll() 전 =====");
List<Member> members = memberRepository.findAll();
System.out.println("===== member for loop 돌기 전 =====");
members.iterator().forEachRemaining(member -> {
System.out.println("member.getTeam().getTeamName() = " + member.getTeam().getTeamName());
});
System.out.println("===== member for loop 돈 후 =====");
}
N1Test를 실행해주면 다음과 같은 쿼리문이 수행됩니다.
쿼리 결과에서 알 수 있듯이 즉시 로딩은 Member List를 findAll()로 조회를 할 때 그와 연관된 Team Entity도 바로, 즉시 조회합니다.
지연 로딩 - LAZY
Member Entity가 가지고 있는 Team Entity의 fetchType을 다음과 같이 @ManyToOne(fetch = FetchType.EAGER)로 바꿔줍니다.
그런 후 테스트 코드의 수정 없기 N1Test를 실행해주면 다음과 같은 쿼리문이 수행됩니다.
쿼리 결과를 보면 memberRepository.findAll()을 할 때 쿼리가 1번 수행됩니다. 그리고 Member List에 있는 각각의 member로 Team의 정보를 얻으려고 할 때 쿼리가 수행됩니다. 현재 Team은 2개이기 때문에 추가적으로 2개의 쿼리가 수행되는 것을 알 수 있습니다.
팀이 다 다르면 N개가 수행됩니다. 또한 한 번 찾은 "탁벤져스" Entity는 영속성 컨텍스트에서 관리하고 있기 때문에 2번의 쿼리만 실행됩니다.
지금까지 Spring의 꽃, JPA를 사용하면서 많이 마주치는 N + 1 문제에 대해서 알아봤습니다. 다음 포스팅에서는 N + 1 문제를 해결하는 방법에 대해서 알아보도록 하겠습니다.
생각 정리
위 예시에서 Spring Data JPA를 사용하였는데, Spring Data JPA는 내부 구현체에 @Transactional이 있기 때문에 평소에 JPA를 쓸 때 @Transactional이 필요없다. 하지만 위 예시를 준비하면서 N + 1 문제를 실현하기 위해 N1Test method를 작성할 때 평소처럼 @Transactional을 빼고 코드를 실행해보니깐, member.getTeam().getTeamName() 이 부분에서 LazyInitializationException 예외를 만났습니다. 왜냐하면 Member List를 찾는 findAll()의 트랜잭션의 범위가 JPA 내부 구현체까지만 존재하기 때문에 Member List를 찾고 나서 Member List는 detached 되어 준영속 상태가 된 것임을 알았습니다. 그래서 N1Test method에 @Transactional 어노테이션을 붙임으로써 트랜잭션 범위를 N1Test까지 끌어당겼습니다.
'Spring > Spring Data JPA' 카테고리의 다른 글
Spring Data JPA - 상속(Inheritance) + 다대다(N:M) 관계 (0) | 2022.01.09 |
---|---|
Spring Data JPA - Self-Reference (0) | 2021.12.20 |
Spring Data JPA - N + 1 문제 ② 해결 방법 (0) | 2021.12.02 |
Spring Data JPA - Auditing (0) | 2021.11.28 |
Spring Data JPA - Save Method 동작 방식 (0) | 2021.11.26 |