API 성능 최적화 하기 1편에서 이어지는 내용입니다.


첫번째로 엔티티를 직접 노출하여 멤버의 팀명을 호출하는 API를 만들어보았다.

MemberController.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@RestController
@RequiredArgsConstructor
public class MemberController {
    private final MemberJpaRepository memberJpaRepository;

    @GetMapping("/api/v1/members")
    public List<Member> membersV1() {
        List<Member> all = memberJpaRepository.findAll();
        for (Member member : all) {
            member.getTeam().getName(); // Lazy 강제 초기화
        }
        return all;
    }
}

아무런 설정을 하지 않고 이대로 API를 호출해보면 다음과 같은 오류 메시지가 나올것이다.

1
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: java.util.ArrayList[0]->study.datajpa.entity.Member["team"]->study.datajpa.entity.Team$HibernateProxy$AecgE8ot["hibernateLazyInitializer"])

현재 엔티티의 설계는 기본적으로 지연 로딩으로 되어있다. 이는 실제 엔티티 대신에 프록시가 존재한다는 말이 된다.

기본 jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에 Hibernate5Module 을 스프링 빈으로 등록하여 해결해야한다.


Hibernate5Module 등록

스프링 3.0 이상 기준

build.gradle

1
2
3
4
dependencies {
	// Hibernate5Module
	implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'
}

<프로젝트명>Application.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 기본적으로 초기화 된 프록시 객체만 노출
@Bean
Hibernate5JakartaModule hibernate5Module() {
    return new Hibernate5JakartaModule();
}

// 강제 지연 로딩 설정을 하기 위해서는 이와 같이 추가해주면 된다.
@Bean
Hibernate5JakartaModule hibernate5JakartaModule() {
    Hibernate5JakartaModule hibernate5JakartaModule = new Hibernate5JakartaModule();
    hibernate5JakartaModule.configure(Hibernate5JakartaModule.Feature.FORCE_LAZY_LOADING, true);
    return hibernate5JakartaModule;
}

FORCE_LAZY_LOADING을 키게 됐을 때 양방향 연관관계를 계속 로딩하게 되기 때문에 관계 한 곳에 @JsonIgnore 옵션을 주어야 한다. 그렇지 않으면 무한 루프가 걸리게 된다.

간단한 프로젝트같은 경우에는 직접 엔티티를 노출하며 Hibernate5Module을 사용해도 무방하겠지만 대부분의 경우에는 DTO를 이용하여 값을 반환해주는 것이 좋은 방법이다.


엔티티를 DTO로 변환

MemberController.java

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@GetMapping("api/v2/members")
public List<MemberDto> membersV2() {
    List<Member> members = memberJpaRepository.findAll();
    return members.stream()
            .map(MemberDto::new)
            .collect(Collectors.toList());
}

@Data
static class MemberDto {
    private Long memberId;
    private String username;
    private int age;
    private String teamName;

    public MemberDto(Member member) {
        memberId = member.getId();
        username = member.getUsername();
        age = member.getAge();
        teamName = member.getTeam().getName();
    }
}

참고로 현재 더미 데이터로 member1부터 member4 그리고 teamA, teamB를 가지고 있다.

member1과 member2는 teamA에 속하고 member3과 member4는 teamB에 속한 상태이다.

다음은 엔티티를 DTO로만 변환해 호출했을 때의 쿼리 결과이다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Hibernate: 
    select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0
2023-03-10T20:31:06.936+09:00  INFO 61090 --- [nio-8080-exec-3] p6spy                                    : #1678447866936 | took 0ms | statement | connection 3| url jdbc:mysql://localhost:3306/datajpa?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul
select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username from member m1_0
select m1_0.member_id,m1_0.age,m1_0.team_id,m1_0.username from member m1_0;
Hibernate: 
    select
        t1_0.team_id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?
2023-03-10T20:31:06.959+09:00  INFO 61090 --- [nio-8080-exec-3] p6spy                                    : #1678447866959 | took 19ms | statement | connection 3| url jdbc:mysql://localhost:3306/datajpa?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul
select t1_0.team_id,t1_0.name from team t1_0 where t1_0.team_id=?
select t1_0.team_id,t1_0.name from team t1_0 where t1_0.team_id=1;
Hibernate: 
    select
        t1_0.team_id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.team_id=?
2023-03-10T20:31:06.963+09:00  INFO 61090 --- [nio-8080-exec-3] p6spy                                    : #1678447866963 | took 0ms | statement | connection 3| url jdbc:mysql://localhost:3306/datajpa?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul
select t1_0.team_id,t1_0.name from team t1_0 where t1_0.team_id=?
select t1_0.team_id,t1_0.name from team t1_0 where t1_0.team_id=2;

N+1 문제를 일으키며 쿼리가 총 세번 실행된 모습을 볼수있다.

  • member조회 한 번
  • memberteam 지연 로딩 조회 N번
  • teamA와 teamB가 있으므로 2번 조회되므로 1+2 총 세번 실행되었다.

이 문제를 해결하기 위해 이제 Fetch Join을 적용해 최적화 해보도록 하겠다.


Fetch Join 최적화

MemberController.java

1
2
3
4
5
6
7
@GetMapping("/api/v3/members")
public List<MemberDto> membersV3() {
    List<Member> members = memberJpaRepository.findAllMemberWithTeam();
    return members.stream()
            .map(MemberDto::new)
            .collect(Collectors.toList());
}

MemberJpaRepository.java

1
2
3
4
5
6
public List<Member> findAllMemberWithTeam() {
    return em.createQuery(
                    "select m from Member m" +
                    " join fetch m.team t", Member.class)
            .getResultList();
}

해당 API를 호출했을 때 조회되는 쿼리의 개수를 보자.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
Hibernate: 
    select
        m1_0.member_id,
        m1_0.age,
        t1_0.team_id,
        t1_0.name,
        m1_0.username 
    from
        member m1_0 
    join
        team t1_0 
            on t1_0.team_id=m1_0.team_id
2023-03-10T20:49:54.983+09:00  INFO 61178 --- [nio-8080-exec-3] p6spy                                    : #1678448994983 | took 0ms | statement | connection 3| url jdbc:mysql://localhost:3306/datajpa?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul
select m1_0.member_id,m1_0.age,t1_0.team_id,t1_0.name,m1_0.username from member m1_0 join team t1_0 on t1_0.team_id=m1_0.team_id
select m1_0.member_id,m1_0.age,t1_0.team_id,t1_0.name,m1_0.username from member m1_0 join team t1_0 on t1_0.team_id=m1_0.team_id;

Fetch Join으로 인해 memberteam 은 이미 조회 된 상태이므로 지연로딩이 일어나지 않아 쿼리 한 번에 모두 조회한 모습을 볼 수 있다.

이로서 X to One 연관 관계에서 API 개발할 시에 마주할 수 있는 문제와 최적화 방법을 알아보았다. 다음 글에서는 X to Many 연관 관계에서의 API 개발 및 최적화에 관해 알아보겠다.