본문 바로가기

전체보기/Spring

[JPA] join fetch 사용시 추가 쿼리가 발생하는 이슈

이번 글에서는,

join fetch를 사용하면서 추가 쿼리가 발생하는 이슈를 경험한 것과

이에 대한 원인 분석 그리고 해결 방법을 설명해볼까 합니다.

문제점

먼저, 이슈를 재현하기 위한 간단한 예제 코드를 가지고 왔습니다.

@Entity
public class Team {
    @Id
    private Integer teamId;

    @Column
    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members;
}

 

@Entity
public class Member implements Serializable {
    @Id
    private Integer id;

    @Column
    private String name;

    @Id
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

 

Team은 여러 Member를 포함하고 있고 이 두 Entity는 양방향으로 매핑되어 있습니다.

또한, Member는 key값으로 id와 team을 사용하고 있습니다.

 

이때 Team Entity를 읽어와 해당 team에 포함된 member 목록에 접근하게되면,

N + 1 문제가 발생하게 됩니다.

 

이러한 문제를 해결하기 위해서, 저는 fetch join을 사용하였는데요.

 

@Repository
public interface TeamRepository extends JpaRepository<Team, Integer> {
    @Query("select team from Team team join fetch team.members where team.name=:name")
    Team findByName(String name);
}

 

이때 수행된 쿼리를 확인해보니,

 

Hibernate: select team0_.team_id as team_id1_1_0_, members1_.team_id as team_id3_0_1_, members1_.id as id1_0_1_, team0_.name as name2_1_0_, members1_.name as name2_0_1_, members1_.team_id as team_id3_0_0__, members1_.id as id1_0_0__ from team team0_ inner join member members1_ on team0_.team_id=members1_.team_id where team0_.name=?
Hibernate: select team0_.team_id as team_id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.team_id=?

 

위와 같이 쿼리가 수행된 것을 확인할 수 있었습니다.

내용을 확인해보면, team 테이블과 member 테이블을 조인하여 가져오고

team 테이블을 id를 사용하여 다시 조회하는 것을 확인할 수 있었습니다.

 

위에서는 건수가 많지 않아, 별거 아닌 것처럼 보일 수 있지만

N + 1 문제처럼 건수가 많아지게되면 성능에 영향을 미칠 수 있습니다.

 

실제로 해당 문제를 해결하고 100건의 team을 조회했을 때, 10배 정도의 성능 개선이 있었습니다.

원인

문제의 원인은 바로 위에서 정의한 Member Entity에 있습니다.

 

@Entity
public class Member implements Serializable {
    @Id
    private Integer id;

    @Column
    private String name;

    @Id
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

 

해당 Entity는 여러 field를 id값으로 활용하고 있었는데,

단순하게 @id 어노테이션을 여러개 활용하여 Entity를 정의하였습니다.

문제는, 해당 id 중 하나가 @ManyToOne 관계로 매핑되어있었다는 것 입니다.

 

Hibernate Loader는 ResultSet의 key값을 추출하는데,

해당 과정에서 key값이 연관관계가 있을 경우, 이를 다시 한 번 조회하도록 되어있습니다.

 

protected void extractKeysFromResultSet(
		Loadable[] persisters,
		QueryParameters queryParameters,
		ResultSet resultSet,
		SharedSessionContractImplementor session,
		EntityKey[] keys,
		LockMode[] lockModes,
		List hydratedObjects) throws SQLException {
	for ( int i = 0; i < numberOfPersistersToProcess; i++ ) {
		final Type idType = persisters[i].getIdentifierType();
        
		final Serializable resolvedId;
		if ( hydratedKeyState[i] != null ) {
			resolvedId = (Serializable) idType.resolve( hydratedKeyState[i], session, null );
		}
		else {
			resolvedId = null;
		}
		keys[i] = resolvedId == null ? null : session.generateEntityKey( resolvedId, persisters[i] );
	}
}

 

위의 코드는 Loader의 코드 중 일부를 발췌한 것으로,

코드를 간단하게 설명하면 id 타입의 필드들의 값을 읽어오는 로직입니다.

 

 

이 과정에서, 위와 같이 타입이 연관관계가 있는 타입일 경우

매핑된 Entity를 조회하기 위해 다시 한 번 쿼리를 수행하도록 되어있습니다.

해결법

이와 같은 문제를 해결하기 위해서 여기서는 Entity의 id를 올바르게 정의하였습니다.

이를 위해 @EmbeddedId를 활용하였습니다.

 

@Embeddable
public class MemberId implements Serializable {
    private Integer id;
    private Integer teamId;
}

 

위와 같이 Member Entity의 Id값들을 MemeberId 객체에 정의하고,

 

@Getter
@Entity
public class Member {
    @EmbeddedId
    private MemberId id;

    @MapsId("teamId")
    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    @Column
    private String name;
}

 

Member Entity에 MemberId를 추가하고, 연관관계가 정의된 field에는

@MapsId를 활용하여 Id값을 매핑하였습니다.

 

위와 같이 Entity를 수정한 뒤 다시 한 번 Loader 코드를 디버깅하면

 

 

위와 같이 MemberId에 정의된 타입(Integer)로 Id값을 읽어오기 때문에

추가 쿼리 없이 값을 가져오는 것을 확인할 수 있습니다.

 

다른 분들은 저처럼 바보같이 Entity를 정의해서 불필요한 시간을 낭비하지 않으셨으면 좋겠습니다.

그럼 여기까지 fetch join 수행시 추가 쿼리 발생의 원인 및 해결 방법이었습니다.

반응형