지난 글, 지지난글을 작성하며 JPA의 필요성 및 내부구조에 대해 간략하게 공부해보았다. JPA와 같은 ORM을 사용하면 Java 객체로 쿼리문을 작성할 수 있지만 언제까지나 JPA가 쿼리를 작성하기 때문에 복잡한 쿼리의 경우는 QueryDSL
을 사용하고 이에 대해 역시 간단하게 알아보았다.
하지면 Spring Boot
의 동작과 JPA가 생성하는 쿼리는 역시 서로 다른 패러다임의 언이이기 때문에 생각치 못한 문제가 하나 더 발생하는데, 그것이 바로 N+1 문제
이다.(N+1 쿼리 또는 N+1 Problem이라고 한다.) 이는 개발자가 직접 SQL 문을 작성하지 않기 때문에 발생하는 문제로 JPA내에서 해결 가능하다.
이 글은 JPA와 N+1 문제에 대해 공부하던 도중 유튜브에 좋은 영상이 있어 참고하고 공부한 내용을 정리한 것입니다.
먼저 엔티티를 작성한다.
@Entity
@Getter
@Setter
@ToString
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
@Entity
@Getter
@Setter
@ToString
public class Team {
@Id
@GeneratedValue
private Long id;
private String name;
}
엔티티는 멤버와 팀이 단방향으로 매핑된 형태이며 팀 하나에 여러 멤버가 속할 수 있다. 각각 JpaRepository
를 상속받는 리포지토리들을 생성해주고 문제를 직접 확인하기 위해 테스트 코드를 작성해보자.
@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class JpapracticeApplicationTests {
@Autowired
private MemberRepository memberRepository;
@Autowired
private TeamRepository teamRepository;
@Test
@DisplayName("seek n+1 problem")
public void setUp(){
Team team1 = new Team();
team1.setName("Team no1");
teamRepository.save(team1);
Team team2 = new Team();
team2.setName("Team no2");
teamRepository.save(team2);
Member member1 = new Member();
member1.setName("pepper");
member1.setTeam(team1);
memberRepository.save(member1);
Member member2 = new Member();
member2.setName("Tan");
member2.setTeam(team2);
memberRepository.save(member2);
memberRepository.findAll().forEach(System.out::println);
}
}
테스트 코드는 2명의 멤버와 2개의 팀을 생성하고 각각 팀과 멤버를 연결시켜주면 된다. 그리고 최종적으로 멤버를 전부 가져와서 람다식을 통해 각각 출력 해주면 된다.
추가로 JPA 설정에서 show-sql을 켜주면 아래와 같은 쿼리문이 테스트 로그창에 보인다.
Hibernate: select next_val as id_val from hibernate_sequence for update
Hibernate: update hibernate_sequence set next_val= ? where next_val=?
Hibernate: insert into team (name, id) values (?, ?)
Hibernate: select next_val as id_val from hibernate_sequence for update
Hibernate: update hibernate_sequence set next_val= ? where next_val=?
Hibernate: insert into team (name, id) values (?, ?)
Hibernate: select next_val as id_val from hibernate_sequence for update
Hibernate: update hibernate_sequence set next_val= ? where next_val=?
Hibernate: insert into member (name, team_id, id) values (?, ?, ?)
Hibernate: select next_val as id_val from hibernate_sequence for update
Hibernate: update hibernate_sequence set next_val= ? where next_val=?
Hibernate: insert into member (name, team_id, id) values (?, ?, ?)
=== 여기까지는 save 쿼리문이다. ===
Hibernate: select member0_.id as id1_0_, member0_.name as name2_0_, member0_.team_id as team_id3_0_ from member member0_
Hibernate: select team0_.id as id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.id=?
Hibernate: select team0_.id as id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.id=?
Member(id=3, name=pepper, team=Team(id=1, name=Team no1))
Member(id=4, name=Tan, team=Team(id=2, name=Team no2))
하나씩 뜯어보면 먼저 GeneratedValue
의 AUTO 전략으로 자동 설정된 방식으로 id값을 담는 테이블을 읽어온다. 그리고 2개의 팀과 2명의 멤버를 각각 insert 해준다.
문제는 가장 마지막에 있다. 마지막 3줄을 보면 우리가 작성한 findAll
을 나타내는 쿼리문인데, select 쿼리가 하나가 아닌 여러 개인 것을 볼 수 있다. 이는 멤버 수를 늘릴 때마다 하나씩 늘어난다.
쿼리문을 살펴보면 첫 번째 줄에서는 멤버들을 가져오고 각 멤버들이 속한 Team
을 TEAM_ID
를 통해 가져오는 쿼리문을 하나의 멤버마나 하나씩 날리게 된다.
즉 N명의 멤버가 존재한다면 각 팀정보를 가져오기 위해 멤버를 가져오는 원래의 SELECT 문 외에도 N개의 쿼리를 추가로 날리게 된다는 것이다.
지금처럼 2명의 멤버만 있다면 상관이 없지만 실 서비스에서 많은 수의 멤버가 존재한다면 결국 findAll
마다 총 N+1
개의 쿼리를 날리는 문제가 생긴다.
먼저 간단히 트랜잭션에 대해 알 필요가 있다. 트랜잭션은 데이터베이스의 처리에서 한 결과를 수행해내는 단위를 말한다. 만약 여러 개의 쿼리문이 들어있더라도 한 실행 단위안에 있다면 그것은 하나의 트랜잭션이라고 할 수 있다.
간단히 트랜잭션을 설명하였지만 트랜잭션은 정말 중요하고 위 문장 처럼 한 단어로만 정의할 수 는 없습니다.
그리고 JPA에는 @Transactional
이라는 어노테이션이 존재하는데, 메소드 위에 붙여주면 안에 존재하는 쿼리문들이 하나의 트랜잭션으로 작용하게 된다.
먼저 테스트 코드를 아래와 같이 바꿔주자.
@Test
@Transactional // 중요
@DisplayName("seek n+1 problem")
public void setUp(){
Team team1 = new Team();
team1.setName("Team no1");
Team savedTeam1 = teamRepository.save(team1); // 중요
Team team2 = new Team();
team2.setName("Team no2");
Team savedTeam2 = teamRepository.save(team2); // 중요
Member member1 = new Member();
member1.setName("pepper");
member1.setTeam(savedTeam1); // 중요
memberRepository.save(member1);
Member member2 = new Member();
member2.setName("Tan");
member2.setTeam(savedTeam2); // 중요
memberRepository.save(member2);
memberRepository.findAll().forEach(System.out::println);
}
중요라고 주석을 달아 놓은 곳을 보면 먼저 @Transactional
이라는 어노테이션을 통해 이 setUp
이라는 테스트 메소드에 있는 모든 JPA 메소드들은 하나의 트랜잭션으로 처리되게 된다.
JPARepository
의 SimpleRepository
를 보면 기본적으로 모든 메서드가@Transactional
로 작동하게 되어 있다. 즉 save
, findAll
등 모두가 각각 다른 트랜잭션으로 작동하게 된다.
하지만 이 메소드를 @Transactional
로 묶어서 하나의 트랜잭션으로 작동하게 해준 것이다.
그리고 그 다음 // 중요
표시를 보면 save
로 저장한 팀을 또 Team 변수에 담는다. 저저번 글인 영속성 컨텍스트를 이해 했다면 1차 캐시에 대해 알고 있을 것이다.
Team savedTeam1 = teamRepository.save(team1); // 중요
위처럼 저장한 결과를 팀 변수에 담아서 사용하면 저번에 말했듯이 저 결과를 가져오기 위해 SELECT 문을 또 날리는 것이 아닌 캐시에서 값을 가져오게 된다.
그 아래 모든 save 문도 똑같이 캐시에서 값을 가져오도록 하고 이 모든 쿼리를 한 트랜잭션 내에서 처리하므로 이미 멤버별 Team
에 대한 정보가 영속성 컨텍스트의 캐시내에 존재하게 되고 마지막으로 findAll
쿼리를 날렸을 때 따로 Team
엔티티를 가져오는 쿼리를 날릴 필요가 없이 캐시에서 가져오게 되는 것이다. 결과는 아래와 같다.
Hibernate: select member0_.id as id1_0_, member0_.name as name2_0_, member0_.team_id as team_id3_0_ from member member0_
만약 위 내용이 이해가 정확히 안간다면 영속성 컨텍스트에 대해 이해할 필요가 있다.
여기서 문제가 하나 더 발생한다. 위 테스트 코드에서 작성한 findAll
은 우리가 위에 @Transactional
로 묶어주고 savedTeam
이라는 변수로 영속성 컨텍스트의 특징을 이용하여 N+1
문제를 해결한 것이다.
하지만 아무 컨트롤러에서 findAll
을 통해 멤버를 조회하면 영속성 컨텍스트의 캐시에 묶인 값이 없기 때문에 결국 N+1
문제가 또 생긴다.
@RestController
public class MemberController {
@Autowired
private MemberRepository memberRepository;
@GetMapping("/")
@ResponseBody
public List<Member> hello(){
return (List<Member>) memberRepository.findAll();
}
}
먼저 위와 같이 MemberController
를 만들고 안에서 모든 멤버 값을 findAll
로 가져왔다.
Hibernate: select member0_.id as id1_0_, member0_.name as name2_0_, member0_.team_id as team_id3_0_ from member member0_
Hibernate: select team0_.id as id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.id=?
Hibernate: select team0_.id as id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.id=?
로그를 확인하면 또 쿼리가 하나가 아닌 세 개(멤버가 2명이므로)가 날아간 것이 확인된다. 이럴 때는 어떻게 해야할까?
방법은 2가지가 있다.
하나는 @NamedEntityGraph
라는 어노테이션을 사용하는 방식이다. 이 방식을 사용하여 findAll
을 재정의해주면 매번 team_id
를 하나씩 가져오는 것이 아닌 left join
방식으로 한 번에 테이블을 조인해서 가져온다.
또 다른 방법은 그냥 Team 정보를 안가져 오면 된다. 그러면 아래 두 쿼리는 팀 정보와 멤버의 외래 키를 조인하는 쿼리이므로 실행을 안하게 될 것이다.
먼저 멤버 엔티티에 어노테이션을 추가해준다.
@Entity
@Getter
@Setter
@ToString
@NamedEntityGraph(name = "MemberWithTeam", attributeNodes = @NamedAttributeNode("team"))
public class Member {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "TEAM_ID")
private Team team;
}
name
은 리포지토리에서 레퍼런스로 사용할 값을 넣어주면 되고 attributeNodes
는 애트리뷰트, 원래는 테이블 이름 값을 말하는데 우리는 JPA를 이용하므로 우리가 매핑한 변수 이름을 넣어주면 된다.
@Repository
public interface MemberRepository extends CrudRepository<Member, Long>, MemberRepositoryCustom {
@EntityGraph("MemberWithTeam")
List<Member> findAll();
}
그리고 위처럼 리포지토리의 findAll
을 재정의 해준다. 재정의의 이유는 단지 @EntityGraph
를 붙이기 위함이고 엔티티에서 정해준 레퍼런스 값을 적어준다.
이렇게 하고 다시 컨트롤러를 통해 쿼리문을 실행하면
Hibernate: select member0_.id as id1_0_0_, team1_.id as id1_1_1_, member0_.name as name2_0_0_, member0_.team_id as team_id3_0_0_, team1_.name as name2_1_1_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.id
아래 처럼 JPA가 LEFT JOIN
을 이용한 하나의 쿼리문만을 날리는 것을 볼 수 있다.
다른 방법으로는 아예 멤버 값만 가져오고 팀의 정보는 안 가져오는 방법이 있다. 많은 경우에서 충분히 멤버 값만 가져와야 할 상황이 생기므로 이 또한 알아야 할 필요가 있으며 이 경우는 당연히 N개의 쿼리들은 생기지 않는다.
먼저 멤버 엔티티의 팀 변수를 아래와 같이 바꿔준다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "TEAM_ID")
private Team team;
FetchType
이 추가 된것을 볼 수 있는데, 디폴트 값은 FetchType.EAGER
이다. EAGER
는 멤버를 가져올 때 매핑된 팀 값도 모두 가져오지만(위에서 했던 것처럼 당연히 N+1쿼리 문제 발생) LAZY
로 설정해주면 팀 값을 가져오지 않는다.
이래도 다시 어플리케이션을 재시작하고 컨트롤러를 통해 findAll
을 사용하면 에러가 나는 것을 볼 수 있다.
그 이유는 사실 LAZY
가 팀 값들을 안 가져오는게 아닌 프록시 타입으로 가져오기 때문이다.
프록시는
LAZY
로 결과를 가져올 때 실제 엔티티 객체 대신 데이터베이스의 조회를 늦추는 가짜 객체이다.
그리고 이 결과를 JSON 형태로 변환하는데 에러가 생기는 것이다. 에러 로그를 보면 프록시 객체를 해석할 serializer
가 없다고 한다.
다양한 방법이 있겠지만 위에서 본 유튜브 영상에서는 Dto
를 사용한다. 여기서도 간단히 Dto
를 매핑해보자.
먼저 MemberDto
를 만들어준다.
@Getter
@Setter
public class MemberDto {
private Long id;
private String name;
private Team team;
}
그리고 컨트롤러도 바꿔준다.
@RestController
public class MemberController {
@Autowired
private MemberRepository memberRepository;
@GetMapping("/")
@ResponseBody
public List<MemberDto> hello(){
List<MemberDto> members = memberRepository.findAll()).stream().map(
m -> {
MemberDto memberDto = new MemberDto();
memberDto.setId(m.getId());
memberDto.setName(m.getName());
return memberDto;
}
).collect(Collectors.toList());
return members;
}
}
다시 어플리케이션을 재시작하면 팀 변수를 제외한 값들을 가져오는 걸 확인할 수 있다.
스프링부트와 JPA를 간단히 배우고 바로 두 개의 스프링부트 프로젝트를 하면서 굉장히 많은 에러를 겪었었다. 하나는 직접 백엔드로 참여하진 않았지만 전체적인 총괄 역할을 맡으며 백엔드 코드에도 꽤 관여했는데, 왜 저렇게 코드를 짰는지 이해가 가지 않는 부분이 많았다.
하지만 다시 JPA와 스프링부트를 제대로 공부해보니 그동안 겪었던 모든 에러와 궁금증이 많이 해소되었다. 이번에 다룬 N+1 문제
는 각 ORM마다 다른 방식으로 충분히 발생할 수 있는 문제이다. 하지만 JPA의 특징을 이용하여 해결해보니 다른 ORM에서도 그 ORM을 잘 이해만 한다면 ORM의 특징을 이용하여 N+1 문제
를 해결할 수 있을 것 같다.