이번 글에서는,
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 수행시 추가 쿼리 발생의 원인 및 해결 방법이었습니다.
'전체보기 > Spring' 카테고리의 다른 글
상속과 @Data의 warning, 그리고 @EqualsAndHashCode (0) | 2022.09.04 |
---|---|
[JPA] JpaRepository vs CrudRepository(1) - Paging과 Sorting / QueryExampleExecutor (0) | 2022.01.01 |
[Spring Boot] DateTime 다뤄보기(3) - Database에 저장하기 (0) | 2021.07.06 |
[Spring Boot] DateTime 다뤄보기 (0) | 2021.06.02 |
[Spring Boot] Custom Constraint / Validation 파헤치기 (0) | 2020.08.26 |