반응형

이번 시간에는 문제 상황을 확인하고, Entity Graph를 이용하여 해결해보도록 하겠습니다.

정말 간단한 내용이지만, 성능 상의 문제가 생길 수 있기 때문에 짚고 넘어가야할 것입니다.

 

 

문제가 된 상황은 다음과 같습니다.

우리는 요청을 전송할 때, Authorization 헤더에 액세스 토큰을 함께 보내주고 있습니다.

그 과정에서 사용자를 조회하고, Member와 Role의 다대다 관계 사이의 브릿지 테이블로 작성된, Member와 @OneToMany 관계인 MemberRole을 조회하여 사용자의 권한 등급을 확인합니다.

그렇다면 우리가 기대하는 쿼리는 분명, Member 조회와 MemberRole 조회 두 개의 쿼리가 나갈 것이라고 기대할 수 있습니다.

 

하지만 실제 쿼리 로그를 살펴보면, 총 네개의 쿼리가 수행되고 있습니다.

Hibernate: select member0_.member_id as member_i1_2_0_, member0_.created_at as created_2_2_0_, member0_.modified_at as modified3_2_0_, member0_.email as email4_2_0_, member0_.nickname as nickname5_2_0_, member0_.password as password6_2_0_, member0_.username as username7_2_0_ from member member0_ where member0_.member_id=?
Hibernate: select roles0_.member_id as member_i1_3_0_, roles0_.role_id as role_id2_3_0_, roles0_.member_id as member_i1_3_1_, roles0_.role_id as role_id2_3_1_ from member_role roles0_ where roles0_.member_id=?
Hibernate: select role0_.role_id as role_id1_5_0_, role0_.role_type as role_typ2_5_0_ from role role0_ where role0_.role_id=?
Hibernate: select role0_.role_id as role_id1_5_0_, role0_.role_type as role_typ2_5_0_ from role role0_ where role0_.role_id=?

1. Member를 조회합니다.

2. Member와 @OneToMany 관계인 MemberRole을 조회합니다. (조회한 사용자는 두 개의 권한을 가지고 있습니다.)

3. 첫번째 MemberRole의 실제 권한 이름을 확인하기 위해, Role을 다시 조회합니다.

4. 두번째 MemberRole의 실제 권한 이름을 확인하기 위해, Role을 다시 조회합니다.

권한 이름을 확인하기 위해 MemberRole의 개수만큼 새로운 쿼리가 나갔다곤 하지만, 생각해보면 단순히 MemberRole과 Role을 조인하면, 단일한 쿼리로 Role의 이름까지 다 가져올 수 있어야합니다.

하지만 지금 상황에서는, MemberRole의 개수만큼 추가적인 쿼리가 생성되는 것입니다.

만약 사용자가 가진 권한 등급이 N개라면, N개의 쿼리가 더 생성될 것입니다.

 

Member와 @OneToMany 관계인 MemberRole에서, LAZY 전략으로 설정된 Role에 의해 오히려 이런 상황에서도 N + 1 문제가 발생하고 있던 것입니다.

 

* 물론, 일반적인 N + 1 문제와는 달리, 문제점이 코드에서 명확히 드러나는 상황입니다. 전혀 문제가 없는 상황입니다. 단지 놓치고 있었을 뿐입니다. 실수를 경계하고, 엔티티 그래프를 사용해보기 위해 기록을 남기게 되었습니다.

 

 

문제가 되는 코드를 살펴보겠습니다.

// CustomUserDetailsService.java

@Override
public CustomUserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
    Member member = memberRepository.findById(Long.valueOf(userId)) // 1
            .orElseGet(() -> new Member(null, null, null, null, List.of()));
    return new CustomUserDetails(
            String.valueOf(member.getId()),
            member.getRoles().stream().map(memberRole -> memberRole.getRole()) // 2
                    .map(role -> role.getRoleType()) // 3
                    .map(roleType -> roleType.toString())
                    .map(SimpleGrantedAuthority::new).collect(Collectors.toSet())
    );
}

Spring Security 컨텍스트에 인증된 사용자의 정보를 저장하기 위해, CustomUserDetails를 생성하여 반환하는 코드입니다.

1. Member를 조회하는 쿼리가 한번 생성되고,

2. Member의 MemberRole을 조회하면서 쿼리가 한번 더 생성됩니다.

3. 각 MemberRole의 RoleType을 확인하기 위해 Role을 다시 조회하면서 N번의 쿼리가 더 생성되던 것입니다.

 

* 실제로 이러한 코드는, 35편에서 토큰 인증 방식을 개선하며 사라지게 되었습니다.

 

이를 해결하기 위해서는, 단순히 MemberRole과 Role을 조인하여 조회하면 됩니다.

조인을 위한 방법에는 fetch join, fetch 전략을 EAGER로 설정하는 등 다양한 방식이 있겠지만,

우리는 Entity Graph를 이용해보겠습니다.

이를 이용하면, 연관된 엔티티들을 함께 조회할 수 있습니다.

package kukekyakya.kukemarket.entity.member;

import ...

@Entity
...
@NamedEntityGraph(
        name = "Member.roles",
        attributeNodes = @NamedAttributeNode(value = "roles", subgraph = "Member.roles.role"),
        subgraphs = @NamedSubgraph(name = "Member.roles.role", attributeNodes = @NamedAttributeNode("role"))
)
public class Member extends EntityDate {
    ...
    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<MemberRole> roles;
}

@NamedEntityGraph 어노테이션을 선언하여 엔티티 그래프를 생성할 수 있습니다.

name에는 엔티티 그래프의 이름을 설정해줍니다.

attributeNodes에는 함께 조회해야하는 엔티티의 필드 명을 적어줍니다.

roles는 @OneToMany 관계에 있지만, 엔티티 그래프에서는 N + 1 문제가 일어나지않도록 조절해줄 것입니다.

roles를 함께 조회한다고 하더라도, 실질적인 Role의 타입 명까지 확인하려면, MemberRole의 role도 함께 조회해야합니다.

이를 위해 subgraph에, 또 다른 그래프의 이름 Member.roles.role을 지정해준 것입니다.

서브 그래프를 이용하면, 연관 엔티티의 연관 엔티티까지 함께 조회할 수 있습니다.

subgraphs에는 서브 그래프를 설정할 수 있습니다.

Member.roles.role이라는 서브 그래프를 생성하고, 여기에서는 role을 함께 조회하도록 하였습니다.

Member.roles 그래프에서 Member의 roles를 조회하고, 서브 그래프로 설정된 Member.roles.role에서는 MemberRole의 role을 조회해주는 것입니다.

 

 

MemberRepository에서 방금 작성한 엔티티 그래프를 이용할 수 있도록 합시다.

package kukekyakya.kukemarket.repository.member;

import ...

public interface MemberRepository extends JpaRepository<Member, Long> {
    ...

    @EntityGraph("Member.roles")
    Optional<Member> findWithRolesById(Long id);
}

@EntityGraph에 엔티티 그래프의 이름을 지정해주었습니다.

 

 

CustomUserDetailsService.loadUserByUsername 에서 해당 메소드를 사용하도록 코드를 수정해주겠습니다.

package kukekyakya.kukemarket.config.security;

import ...

...
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public CustomUserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        Member member = memberRepository.findWithRolesById(Long.valueOf(userId))...
        ...
    }
}

MemberRepository.findWithRolesById를 이용하여 Member를 조회하고 있습니다.

 

 

이제 다시 로그를 확인해보겠습니다.

Hibernate: select member0_.member_id as member_i1_3_0_, roles1_.member_id as member_i1_4_1_, roles1_.role_id as role_id2_4_1_, role2_.role_id as role_id1_6_2_, member0_.created_at as created_2_3_0_, member0_.modified_at as modified3_3_0_, member0_.email as email4_3_0_, member0_.nickname as nickname5_3_0_, member0_.password as password6_3_0_, member0_.username as username7_3_0_, roles1_.member_id as member_i1_4_0__, roles1_.role_id as role_id2_4_0__, role2_.role_type as role_typ2_6_2_
  from member member0_
  left outer join member_role roles1_ on member0_.member_id=roles1_.member_id
  left outer join role role2_ on roles1_.role_id=role2_.role_id
  where member0_.member_id=?

이제 단 1건의 쿼리로 사용자의 권한 정보를 조회할 수 있게 되었습니다.

엔티티 그래프 설정에 의해 member, member_role, role이 left outer join되기 때문에,

Member를 조회하면서 연관 관계에 있는 MemberRole과 Role까지 함께 조회된 것입니다.

 

이 외에도 다른 방법은 있겠지만, 엔티티 그래프를 활용해보고자 해당 방법을 선택하게 되었습니다.

물론, 위 방법은 하나의 단일한 쿼리로 수행하기 위해, 조인을 하는 과정에서 동일한 사용자 데이터가 중복해서 조회되는 문제가 있습니다.

 

예를 들어,

테이블 member member_role role
레코드 member_id1 member_id1, role_id1 role_id1
  member_id1, role_id2 role_id2
  member_id1, role_id3 role_id3

위와 같이 사용자가 3개의 권한을 가진 상황에, member_id1에 대해 엔티티 그래프로 생성되는 쿼리를 수행한다고 가정해보겠습니다.

 

 

조회 결과는 다음과 같을 것입니다.

테이블 member member_role role
조회
결과
member_id1 member_id1, role_id1 role_id1
member_id1 member_id1, role_id2 role_id2
member_id1 member_id1, role_id3 role_id3

1:N 관계에 있는 엔티티까지 단일한 쿼리로 조회하려다보니, 동일한 member_id1이 중복해서 조회되는 것입니다.

 

하지만, 사용자 데이터와 각 사용자가 가지는 권한 등급은 그렇게 많지 않을 것이기 때문에, 학습 차원에서 엔티티 그래프를 이용한 단일한 쿼리로 수행하였습니다.

이러한 문제를 해결하려면, Member를 먼저 조회하고, MemberRole과 Role을 조인하여 조회하는, 2번의 쿼리를 이용하는 방식을 고려할 수 있겠습니다.

 

 

* 참고 사항

Role과 Member 사이의 MemberRole 엔티티를 직접 제어할 일이 없다면, 그냥 @ManyToMany 관계로 선언하여 사용하는게 제일 깔끔합니다.

Member에서 Role을 꺼내올 때, 중간의 다대다를 풀어내는 테이블을 통해 알아서 조인하여 조회해주기 때문입니다.

 

 

이번 시간에는 불필요한 쿼리가 생성되는 상황을 확인하고, 엔티티 그래프를 활용하여 문제를 해결해보았습니다.

또한, 1:N 관계에서 엔티티 그래프를 활용할 때의 문제점도 살펴보고, 이를 해결하는 방법에 대하여 논의하였습니다.

 

* 질문 및 피드백은 환영입니다.

* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.

https://github.com/SongHeeJae/kuke-market

반응형

+ Recent posts