JPA @Transactional과 FetchType으로 N+1 문제 해결하기

PEPPERMINT100·2021년 3월 9일
0

서론

지난 글, 지지난글을 작성하며 JPA의 필요성 및 내부구조에 대해 간략하게 공부해보았다. JPA와 같은 ORM을 사용하면 Java 객체로 쿼리문을 작성할 수 있지만 언제까지나 JPA가 쿼리를 작성하기 때문에 복잡한 쿼리의 경우는 QueryDSL을 사용하고 이에 대해 역시 간단하게 알아보았다.

하지면 Spring Boot의 동작과 JPA가 생성하는 쿼리는 역시 서로 다른 패러다임의 언이이기 때문에 생각치 못한 문제가 하나 더 발생하는데, 그것이 바로 N+1 문제이다.(N+1 쿼리 또는 N+1 Problem이라고 한다.) 이는 개발자가 직접 SQL 문을 작성하지 않기 때문에 발생하는 문제로 JPA내에서 해결 가능하다.

이 글은 JPA와 N+1 문제에 대해 공부하던 도중 유튜브에 좋은 영상이 있어 참고하고 공부한 내용을 정리한 것입니다.

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 쿼리가 하나가 아닌 여러 개인 것을 볼 수 있다. 이는 멤버 수를 늘릴 때마다 하나씩 늘어난다.

쿼리문을 살펴보면 첫 번째 줄에서는 멤버들을 가져오고 각 멤버들이 속한 TeamTEAM_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 메소드들은 하나의 트랜잭션으로 처리되게 된다.

JPARepositorySimpleRepository를 보면 기본적으로 모든 메서드가@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 정보를 안가져 오면 된다. 그러면 아래 두 쿼리는 팀 정보와 멤버의 외래 키를 조인하는 쿼리이므로 실행을 안하게 될 것이다.

@NamedEntityGraph

먼저 멤버 엔티티에 어노테이션을 추가해준다.

@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 문제를 해결할 수 있을 것 같다.

profile
기억하기 위해 혹은 잊어버리기 위해 글을 씁니다.

0개의 댓글