JPA ν™œμš© μ‹œ μ£Όμ˜ν•΄μ•Ό ν•  것듀

 JPAλŠ” λ°˜λ³΅λ˜λŠ” 쿼리 μž‘μ„± λΉ„μš©μ„ 쀄여주고 λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μ€‘μ‹¬μ˜ 효율적인 μ½”λ“œ μž‘μ„±μ„ μ‹€ν˜„ν•˜κ²Œ ν•΄μ£ΌλŠ” 쒋은 κΈ°μˆ μ΄λ‹€.

β€‚ν”„λ ˆμž„μ›Œν¬ λ˜λŠ” 라이브러리λ₯Ό 잘 μ‚¬μš©ν•˜λ©΄ λ‹€μ–‘ν•œ κΈ°λŠ₯κ³Ό 편의λ₯Ό 제곡 λ°›μ•„ 생산성을 ν–₯μƒμ‹œν‚¬ 수 μžˆλŠ” 반면, 잘 λͺ» μ‚¬μš© ν•  경우 μ˜ˆμΈ‘ν•˜μ§€ λͺ»ν•œ λ™μž‘μœΌλ‘œ 인해 λ¬Έμ œκ°€ λ°œμƒν•  수 μžˆλ‹€.

λ³Έ ν¬μŠ€νŠΈμ—μ„œλŠ” JPAλ₯Ό μ‹€μ œ κ°œλ°œμ— 적용 ν•΄λ³΄λ©΄μ„œ μ •λ¦¬ν–ˆλ˜ λ‚΄μš©μ„ κΈ€λ‘œ λ‹€μ‹œ μ •λ¦¬ν•΄λ³΄κ³ μž ν•œλ‹€.

μ„€λͺ…μ˜ 이해λ₯Ό 돕기 μœ„ν•΄ λ‹€μŒμ˜ ν…Œμ΄λΈ” 관계λ₯Ό κ΅¬μ„±ν•˜μ˜€λ‹€.

DML

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Player {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private int age;

    @Enumerated(EnumType.STRING)
    private PlayerPosition position;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Player transferTo(Team team) {
        this.team = team;
        return this;
    }

    public long teamId() {
        return this.team.getId();
    }
}
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private final List<Player> players = new ArrayList<>();
}
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class PlayerStat {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private int year;

    private int score;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "player_id")
    private Player player;
}



CASE 01. μ—°κ΄€κ΄€κ³„μ˜ 주인은 μ™Έλž˜ν‚€ μœ„μΉ˜λ₯Ό κΈ°μ€€μœΌλ‘œ μ •ν•œλ‹€.

κ΄€κ³„ν˜• λ°μ΄ν„°λ² μ΄μŠ€μ—μ„œλŠ” μ™Έλž˜ν‚€λ₯Ό κΈ°μ€€μœΌλ‘œ ν…Œμ΄λΈ” 사이에 관계λ₯Ό μ •μ˜ν•œλ‹€.

JPAμ—μ„œλŠ” 연관관계에 μžˆλŠ” 두 μ—”ν‹°ν‹° 쀑 ν•˜λ‚˜λ‘œ μ™Έλž˜ ν‚€λ₯Ό κ΄€λ¦¬ν•œλ‹€. μ™Έλž˜ν‚€λ₯Ό κ΄€λ¦¬ν•˜λŠ” 주체λ₯Ό 'μ—°κ΄€κ΄€κ³„μ˜ 주인'μ΄λΌν•œλ‹€. μ—°κ΄€κ΄€κ³„μ˜ 주인은 ν…Œμ΄λΈ”μ—μ„œμ˜ μ™Έλž˜ν‚€ μœ„μΉ˜λ₯Ό κΈ°μ€€μœΌλ‘œ μ •ν•˜λŠ” 것이 μΌλ°˜μ μ΄λ‹€.

Player 와 Team 의 관계λ₯Ό κ²°μ •ν•˜λŠ” 것은 Player κ°€ λœλ‹€.

@Entity
public class Player {

	// ...
	
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    public Player transferTo(Team team) {
        this.team = team;
        return this;
    }

    public long teamId() {
        return this.team.getId();
    }
}
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Team {

    // ...

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private final List<Player> players = new ArrayList<>();
}
public class EntityRelationTest extends JpaTest {

    @DisplayName("Case 01. μ—°κ΄€κ΄€κ³„μ˜ 주인은 μ™Έλž˜ν‚€ μœ„μΉ˜λ₯Ό κΈ°μ€€μœΌλ‘œ μ •ν•œλ‹€.")
    @Test
    void entityRelationTest() {
        // given
        Team initialTeam = teamRepository.save(team().build());
        Player givenPlayer = playerRepository.save(player().team(initialTeam).build());

        // when
        Team transferTeam = teamRepository.save(team().build());
        givenPlayer.transferTo(transferTeam);

        entityManager.flush();

        /**
         * μ˜μ†μ„± μ»¨ν…μŠ€νŠΈλ₯Ό λΉ„μš°μ§€ μ•Šκ³  μ‘°νšŒν•˜λ©΄ first level cache에 μžˆλŠ” team을 λ‹€μ‹œ μ°Έμ‘°ν•˜λ―€λ‘œ
         * playerκ°€ λ¦¬μŠ€νŠΈμ— μΆ”κ°€λœ 것을 확인할 수 μ—†λ‹€.
         */
        entityManager.clear();

        Player player = playerRepository.findById(givenPlayer.getId()).orElseThrow();
        Team team = teamRepository.findById(player.teamId()).orElseThrow();

        // then
        then(player.getTeam().getId()).isEqualTo(transferTeam.getId());
        then(team.getPlayers().size()).isOne();
    }
}

Case 01. Test Result




CASE 02. Enum νƒ€μž…μ— ORDINAL을 μ‚¬μš©μ„ μ§€μ–‘ν•œλ‹€.

JPAλŠ” Java의 Enum νƒ€μž…μ„ μ‚¬μš©ν•˜μ—¬ 값을 읽고 μ €μž₯ν•  수 μžˆλ‹€.
 @Enumerated 의 κΈ°λ³Έ 값인 ORDINAL을 κ·ΈλŒ€λ‘œ μ‚¬μš©ν•˜λ©΄ μ—”ν‹°ν‹°κ°€ μ €μž₯λ˜λŠ” μ‹œμ μ— Enum의 ordinal 값이 μ €μž₯λœλ‹€.

public enum PlayerPosition {
    ST,  // 0
    MF,  // 1
    DF,  // 2
    GK   // 3
}

λ§Œμ•½ ST, MF 사이에 WF ν¬μ§€μ…˜μ΄ μΆ”κ°€λ˜λ©΄ μ–΄λ–»κ²Œ 될까?

public enum PlayerPosition {
    ST,  // 0
    WF,  // <- ?
    MF,  // 1
    DF,  // 2
    GK   // 3
}

WF 의 ordinal 값은 1이 될 것이고, 1은 MF 의 ordinalμ΄μ—ˆλ‹€.
 WF κ°€ μΆ”κ°€λ˜λŠ” μˆœκ°„ position 값에 1이 μ €μž₯λ˜μ–΄ 있던 player듀은 DBμ—μ„œ 값을 쑰정해주지 μ•ŠλŠ” 이상 λͺ¨λ‘ WF둜 μ·¨κΈ‰ 될 것이닀.

이와 같은 μ½”λ“œμƒμ˜ μˆœμ„œμ— μ˜ν•œ μ‚¬μ΄λ“œ μ΄νŽ™νŠΈκ°€ λ°œμƒν•˜μ§€ μ•Šλ„λ‘ ordinal λŒ€μ‹  name 값을 μ‚¬μš©ν•˜λŠ” 것을 ꢌμž₯ν•œλ‹€. ordinalλŒ€μ‹  name을 μ‚¬μš©ν•˜λ €λ©΄ EnumType.STRING μ˜΅μ…˜μ„ 지정해주면 λœλ‹€.

@Entity
public class Player {

	// ...
	
    @Enumerated(EnumType.STRING)
    private PlayerPosition position;

    // ...
}

β€‚λ§Œμ•½ κΈ°μ‘΄ ν™˜κ²½μ— μ˜ν•΄ λΆˆκ°€ν”Όν•˜κ²Œ ORDINAL을 μ‚¬μš©ν•΄μ•Ό ν•˜λŠ” 상황이라면, AttirubteConverter λ₯Ό κ΅¬ν˜„ν•˜μ—¬ Enumκ³Ό 맡핑할 수 μžˆλŠ” 방법도 ν™œμš© κ°€λŠ₯ν•˜λ‹€.
(μ°Έκ³  : μš°μ•„ν•œ 기술 λΈ”λ‘œκ·Έ - Legacy DB의 JPA Entity Mapping (Enum Converter 편))




CASE 03. 연관관계가 μ‘΄μž¬ν•˜λŠ” 객체에 @ToString μ‚¬μš©μ„ μ£Όμ˜ν•œλ‹€.

μ–‘λ°©ν–₯ 연관관계가 μžˆλŠ” 두 엔티티에 둬볡 μ–΄λ…Έν…Œμ΄μ…˜μœΌλ‘œ toString을 μ˜€λ²„λΌμ΄λ”© ν•  경우 μˆœν™˜μ°Έμ‘°κ°€ λ°œμƒν•œλ‹€. λŒ€λ‹€μˆ˜μ˜ 경우 API 응닡 μ‹œ λ³„λ„μ˜ 데이터 λͺ¨λΈ 객체λ₯Ό λ‘λŠ” 것이 μ»¨λ²€μ…˜μœΌλ‘œ μΌλ°˜ν™”λ˜μ–΄ 있기 λ•Œλ¬Έμ— μˆœν™˜μ°Έμ‘° λ¬Έμ œκ°€ λ°œμƒν•˜λŠ” μΌ€μ΄μŠ€λŠ” λ“œλ¬Όλ‹€. λ‹€μŒκ³Ό 같은 λ¬Έμ œκ°€ λ°œμƒν•  수 μžˆλ‹€λŠ” 점을 μΈμ§€ν•˜λ˜, μ—”ν‹°ν‹°μ—λŠ” @ToStringκ³Ό 같은 둬볡 μ–΄λ…Έν…Œμ΄μ…˜μ€ μ‚¬μš©ν•˜μ§€ μ•ŠλŠ” 것이 μ’‹λ‹€.

@ToString
@Entity
public class Player {

    // ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;

    // ...
}
@ToString
@Entity
public class Team {

    // ...

    private String name;

    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private final List<Player> players = new ArrayList<>();
}
public class ExcludeToStringTest extends JpaTest {

    @DisplayName("Case 03. 연관관계가 μ‘΄μž¬ν•˜λŠ” 객체에 `@ToString` μ‚¬μš©μ„ μ£Όμ˜ν•œλ‹€.")
    @Test
    void test() {
        // given
        Team team = teamRepository.save(team().build());
        Player givenPlayer = playerRepository.save(player().team(team).build());

        // when
        entityManager.clear();

        Player player = playerRepository.findById(givenPlayer.getId()).orElseThrow();

        // then
        /**
         * μˆœν™˜μ°Έμ‘°κ°€ λ°œμƒν•œλ‹€.
         */ 
        thenThrownBy(player::toString).isExactlyInstanceOf(StackOverflowError.class);
    }
}

Case 03. Test Result

λ§Œμ•½ toString 을 μ •μ˜ν•΄μ•Ό ν•  경우, 연관관계에 ν•΄λ‹Ήν•˜λŠ” κ°μ²΄λŠ” μ •μ˜μ—μ„œ μ œμ™Έ μ‹œμΌœμ•Όν•œλ‹€. (둬볡을 μ‚¬μš©ν•˜λŠ” 경우 @ToString 의 exclude μ˜΅μ…˜μ„ μ‚¬μš©ν•  수 μžˆλ‹€.)


CASE 04. μ§€μ—°λ‘œλ”©μ„ μš°μ„ μ μœΌλ‘œ κ³ λ €ν•˜κ³ , μ¦‰μ‹œλ‘œλ”© μ‚¬μš©μ„ μ§€μ–‘ν•œλ‹€.

μ¦‰μ‹œλ‘œλ”©(EAGER)은 데이터λ₯Ό μ½λŠ” μ‹œμ μ— μ—°κ΄€λœ 데이터λ₯Ό 쑰인으둜 ν•œλ²ˆμ— λ‘œλ“œν•˜λŠ” 것을 μ˜λ―Έν•œλ‹€.

μ¦‰μ‹œλ‘œλ”©μ€ λ‹€μŒμ˜ 이유둜 κ°œλ°œμ„ μ–΄λ ΅κ²Œ λ§Œλ“ λ‹€.

  • μ˜ˆμΈ‘ν•˜μ§€ λͺ»ν•œ λ‹€μˆ˜μ˜ 쑰인 쿼리가 λ°œμƒν•  수 μžˆλ‹€. (쿼리 μ΅œμ ν™”κ°€ μ–΄λ ΅λ‹€.)
  • Spring Data JPAμ—μ„œ μ œκ³΅λ˜λŠ” 쿼리 λ©”μ„œλ“œκ°€ μ•„λ‹Œ 직접 μ •μ˜ν•œ JPQL을 μ‚¬μš©ν•˜λŠ” 경우 EAGERκ°€ μ„€μ •λ˜μ–΄ μžˆμŒμ—λ„ λΆˆκ΅¬ν•˜κ³  LAZY와 같은 λ™μž‘μ„ ν•œλ‹€. (N + 1 λ°œμƒ)
  • μ—°κ΄€κ΄€κ³„μ˜ μ—”ν‹°ν‹° κ·Έλž˜ν”„κ°€ μ—¬λŸ¬κ°œλ‘œ μ€‘μ²©λ˜μ–΄ μžˆλŠ” 경우 N + 1 이 μ‚°λ°œμ μœΌλ‘œ λ°œμƒν•  수 μžˆλ‹€.

public interface PlayerRepository extends JpaRepository<Player, Long> {

    @Query("SELECT p FROM Player p WHERE p.team.id = :teamId")
    List<Player> findAllPlayersByTeamIdJPQL(long teamId);
}
public class EagerLoadingTest extends JpaTest {

    @DisplayName("Case 04. μ§€μ—°λ‘œλ”©μ„ μš°μ„ μ μœΌλ‘œ κ³ λ €ν•˜κ³ , μ¦‰μ‹œλ‘œλ”© μ‚¬μš©μ„ μ§€μ–‘ν•œλ‹€.")
    @Test
    void test1() {
        // given
        Team givenTeam = teamRepository.save(team().build());
        Player player1 = playerRepository.save(player().team(givenTeam).build());
        Player player2 = playerRepository.save(player().team(givenTeam).build());

        // when
        entityManager.clear();
        List<Player> players = playerRepository.findAllPlayersByTeamIdJPQL(givenTeam.getId());

        players.forEach(player -> player.getTeam().getName());
    }
}
Hibernate: 
    select
        p1_0.id,
        p1_0.age,
        p1_0.name,
        p1_0.position,
        p1_0.team_id 
    from
        player p1_0 
    where
        p1_0.team_id=?
        
-- 좔가쿼리(N + 1) λ°œμƒ
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.id=?

β€‚μ¦‰μ‹œλ‘œλ”©μ€ μ½”λ“œμ— λ“œλŸ¬λ‚˜μžˆμ§€ μ•Šμ€ (예츑이 μ–΄λ €μš΄) λ™μž‘μ„ λ°œμƒμ‹œν‚¨λ‹€. κ°€λŠ₯ν•˜λ©΄ μ§€μ—°λ‘œλ”©μ„ μ‚¬μš©ν•˜κ³ , μ΅œμ ν™”κ°€ ν•„μš”ν•œ 곳에 μ„ λ³„μ μœΌλ‘œ νŽ˜μΉ˜μ‘°μΈμ„ μ‚¬μš©ν•œλ‹€.

public interface PlayerRepository extends JpaRepository<Player, Long> {

    @Query("SELECT p FROM Player p JOIN FETCH p.team WHERE p.team.id = :teamId")
    List<Player> fetchAllByTeamId(long teamId);
}
-- fetch join
Hibernate: 
    select
        p1_0.id,
        p1_0.age,
        p1_0.name,
        p1_0.position,
        t1_0.id,
        t1_0.name 
    from
        player p1_0 
    join
        team t1_0 
            on t1_0.id=p1_0.team_id 
    where
        t1_0.id=?



CASE 05. [N + 1] 단건 μ‘°νšŒκ°€ 아닐 경우 페치 쑰인의 μ‚¬μš©μ„ κ³ λ €ν•œλ‹€.

CASE 04. μ—μ„œ κΈ°λ³Έ 쑰회 μ „λž΅μ„ μ§€μ—°λ‘œλ”©μœΌλ‘œ μ„€μ •ν•˜κΈ°λ‘œ ν•˜μ˜€λ‹€.

β€‚νŠΉμ • μ—”ν‹°ν‹°λ₯Ό 리슀트둜 μ‘°νšŒν•˜κ³ , ν•΄λ‹Ή 리슀트λ₯Ό μˆœνšŒν•˜λ©° 연관관계 객체λ₯Ό μ ‘κ·Όν•˜λ©΄ μ§€μ—°λ‘œλ”©μ΄ μž‘λ™ν•˜λ©° λ‹€μˆ˜μ˜ 쿼리가 λ°œμƒν•œλ‹€. (N + 1)

β€‚λ§Œμ•½ Player 데이터가 100건이 쑰회되면, μ—°κ΄€λœ Team 을 μ‘°νšŒν•˜κΈ° μœ„ν•΄ μ΅œλŒ€ 100번의 쿼리가 더 λ°œμƒν•  수 μžˆλ‹€.

public class FetchJoinTest extends JpaTest {

    @DisplayName("Case 05. [N + 1] 단건 μ‘°νšŒκ°€ 아닐 경우 페치 쑰인의 μ‚¬μš©μ„ κ³ λ €ν•œλ‹€.")
    @Test
    void test() {
        // given
        Team team1 = teamRepository.save(team().build());
        Player player1 = playerRepository.save(player().team(team1).build());

        Team team2 = teamRepository.save(team().build());
        Player player2 = playerRepository.save(player().team(team2).build());

        // when
        entityManager.clear();

        // then
        List<Player> players = playerRepository.findAll();
        players.forEach(player -> player.getTeam().getName());
    }
}
Hibernate: 
    select
        p1_0.id,
        p1_0.age,
        p1_0.name,
        p1_0.position,
        p1_0.team_id 
    from
        player p1_0
/**
 * 2개의 Player 데이터와  μ—°κ΄€λœ Team을 μ‘°νšŒν•˜κΈ° μœ„ν•œ 쿼리 2번 μΆ”κ°€ λ°œμƒ [1 + 2(N)]
 */
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.id=?
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    where
        t1_0.id=?

 νŽ˜μΉ˜μ‘°μΈμ„ μ μš©ν•˜λ©΄ μΆ”κ°€ 쿼리가 λ°œμƒν•˜μ§€ μ•Šλ„λ‘ μ΅œμ ν™”κ°€ κ°€λŠ₯ν•˜λ‹€. 단건이 μ•„λ‹Œ 볡수개의 데이터λ₯Ό 쑰회 ν•  λ•ŒλŠ” fetch join을 μš°μ„ μ μœΌλ‘œ κ³ λ €ν•΄λ³Έλ‹€.

public interface PlayerRepository extends JpaRepository<Player, Long> {

    @Query("SELECT p FROM Player p JOIN FETCH p.team")
    List<Player> fetchAllPlayers();
}
-- fetch join μ‚¬μš© ν›„ 동일 둜직 μ‹€ν–‰ μ‹œ ν•œ 번의 쿼리만 λ°œμƒν•œλ‹€.
Hibernate: 
    select
        p1_0.id,
        p1_0.age,
        p1_0.name,
        p1_0.position,
        t1_0.id,
        t1_0.name 
    from
        player p1_0 
    join
        team t1_0 
            on t1_0.id=p1_0.team_id



CASE 06. [N + 1] μ»¬λ ‰μ…˜μ„ λŒ€μƒμœΌλ‘œ 페치 쑰인 ν•˜λ©΄ 데이터 쀑볡이 λ°œμƒν•œλ‹€.

 뢀λͺ¨ ν…Œμ΄λΈ”κ³Ό μžμ‹ ν…Œμ΄λΈ”μ„ 쑰인할 λ•Œμ˜ 총 row μˆ˜λŠ” μžμ‹ ν…Œμ΄λΈ”μ˜ row μˆ˜μ— μ’…μ†λœλ‹€. λ”°λΌμ„œ λΆ€λͺ¨ ν…Œμ΄λΈ”μ— ν•΄λ‹Ήν•˜λŠ” row에 쀑볡이 λ°œμƒν•  수 밖에 μ—†λ‹€.

 JPA의 νŽ˜μΉ˜μ‘°μΈλ„ κ²°κ΅­ λ‚΄λΆ€μ μœΌλ‘œλŠ” SQL join으둜 λ°”λ€Œμ–΄ μž‘λ™ν•˜λ―€λ‘œ RDB의 νŠΉμ„±μ΄ κ³ μŠ€λž€νžˆ μ μš©λœλ‹€.

β€‚μ˜ˆλ₯Ό λ“€μ–΄ Team 의 players λ₯Ό λŒ€μƒμœΌλ‘œ νŽ˜μΉ˜μ‘°μΈμ„ μ μš©ν•˜λ©΄, Player 수만큼 Team 이 μ€‘λ³΅μœΌλ‘œ μ‘°νšŒλ˜λŠ” 것이닀.

β€‚λ”°λΌμ„œ μ»¬λ ‰μ…˜(μžμ‹ ν…Œμ΄λΈ”)을 λŒ€μƒμœΌλ‘œ 페치 쑰인을 ν•  λ•ŒλŠ” DISTINCT λ₯Ό μ‚¬μš©ν•˜μ—¬ 쀑볡을 μ œκ±°ν•˜λŠ” 것이 ν•„μš”ν•˜λ‹€. (μˆœμ„œκ°€ μ€‘μš”ν•˜μ§€ μ•Šλ‹€λ©΄ Set을 λ°˜ν™˜ν•˜λŠ” 것도 방법이 될 수 μžˆλ‹€.)

public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query("SELECT DISTINCT t FROM Team t JOIN FETCH t.players")
    List<Team> fetchAll();
}
public class CollectionFetchJoinTest extends JpaTest {

    @DisplayName("Case 06. [N + 1] μ»¬λ ‰μ…˜μ„ λŒ€μƒμœΌλ‘œ 페치 쑰인 ν•˜λ©΄ 데이터 쀑볡이 λ°œμƒν•œλ‹€.")
    @Test
    void test() {
        // given
        Team team1 = teamRepository.save(team().build());
        Player player1 = playerRepository.save(player().team(team1).build());
        Player player2 = playerRepository.save(player().team(team1).build());

        Team team2 = teamRepository.save(team().build());
        Player player3 = playerRepository.save(player().team(team2).build());
        Player player4 = playerRepository.save(player().team(team2).build());

        // when
        entityManager.clear();

        List<Team> teams = teamRepository.fetchAll();

        // then
        then(teams).hasSameSizeAs(List.of(team1, team2));
    }
}
Hibernate: 
    select
        distinct t1_0.id,
        t1_0.name,
        p1_0.team_id,
        p1_0.id,
        p1_0.age,
        p1_0.name,
        p1_0.position 
    from
        team t1_0 
    join
        player p1_0 
            on t1_0.id=p1_0.team_id

μ°Έκ³ ) SpringBoot 3.x λΆ€ν„° μ‚¬μš©λ˜λŠ” ν•˜μ΄λ²„λ„€μ΄νŠΈ 6μ—μ„œλŠ” 데이터 쀑볡 μ΅œμ ν™”κ°€ μžλ™μœΌλ‘œ μ μš©λœλ‹€. (https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#hql-distinct)




CASE 07. [N + 1] μ»¬λ ‰μ…˜μ„ λŒ€μƒμœΌλ‘œ 페치 쑰인과 νŽ˜μ΄μ§•μ„ ν•¨κ»˜ μ‚¬μš©ν•˜μ§€ μ•ŠλŠ”λ‹€.

 CASE 06. μ—μ„œ μ‚΄νŽ΄λ³Έ 것과 같이 μ»¬λ ‰μ…˜μ„ λŒ€μƒμœΌλ‘œ 페치 쑰인을 ν•˜λ©΄ RDB λ™μž‘ λ©”μ»€λ‹ˆμ¦˜μ— μ˜ν•΄ 쀑볡 데이터가 λ°œμƒν•œλ‹€.

μ€‘λ³΅λœ 데이터λ₯Ό λŒ€μƒμœΌλ‘œ νŽ˜μ΄μ§• μ²˜λ¦¬κΉŒμ§€ μ‹œλ„ν•˜λ©΄, ν•˜μ΄λ²„λ„€μ΄νŠΈλŠ” λͺ¨λ“  데이터λ₯Ό λ©”λͺ¨λ¦¬μ— λ‘œλ“œν•œ λ’€ μ²˜λ¦¬ν•œλ‹€.

 데이터가 μ†Œμˆ˜λΌλ©΄ λ¬Έμ œκ°€ λ˜μ§€ μ•Šμ„ 수 μžˆμ§€λ§Œ, 데이터 μˆ˜κ°€ λ§Žμ€ 경우 이λ₯Ό λ©”λͺ¨λ¦¬μ—μ„œ μ²˜λ¦¬ν•  경우 OOMκ³Ό 같은 λ¬Έμ œκ°€ λ°œμƒν•  수 μžˆλ‹€.

public class CollectionFetchJoinWithPagingTest extends JpaTest {

    @DisplayName("Case 07. [N + 1] μ»¬λ ‰μ…˜μ„ λŒ€μƒμœΌλ‘œ 페치 쑰인과 νŽ˜μ΄μ§•μ„ ν•¨κ»˜ μ‚¬μš©ν•˜μ§€ μ•ŠλŠ”λ‹€.")
    @Test
    void test() {
        // given
        Team team1 = teamRepository.save(team().build());
        Player player1 = playerRepository.save(player().team(team1).build());
        Player player2 = playerRepository.save(player().team(team1).build());

        Team team2 = teamRepository.save(team().build());
        Player player3 = playerRepository.save(player().team(team2).build());
        Player player4 = playerRepository.save(player().team(team2).build());

        // when
        entityManager.clear();

        Page<Team> teams = teamRepository.fetchAllPagination(PageRequest.of(0, 2));

        // then
        then(teams).hasSameSizeAs(List.of(team1, team2));
    }
}
-- μ•„λž˜μ™€ 같이 WARNING(HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory)이 λ°œμƒν•œλ‹€.
2024-03-02T19:12:27.181+09:00  WARN 77599 --- [    Test worker] org.hibernate.orm.query                  : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
Hibernate: 
    select
        distinct t1_0.id,
        t1_0.name,
        p1_0.team_id,
        p1_0.id,
        p1_0.age,
        p1_0.name,
        p1_0.position 
    from
        team t1_0 
    join
        player p1_0 
            on t1_0.id=p1_0.team_id
Hibernate: 
    select
        count(distinct t1_0.id) 
    from
        team t1_0 
    join
        player p1_0 
            on t1_0.id=p1_0.team_id



CASE 08. [N + 1] μ»¬λ ‰μ…˜ λŒ€μƒ μ¦‰μ‹œ λ‘œλ”©μ΄ ν•„μš”ν•  λ•ŒλŠ” @BatchSize μ‚¬μš©μ„ κ³ λ €ν•΄λ³Έλ‹€.

β€‚μ»¬λ ‰μ…˜μ„ λŒ€μƒμœΌλ‘œ fetch joinκ³Ό νŽ˜μ΄μ§• 처리λ₯Ό 같이 μ μš©ν•˜λ©΄ ν•˜μ΄λ²„λ„€μ΄νŠΈλŠ” λ©”λͺ¨λ¦¬μ—μ„œ ν•΄λ‹Ή μž‘μ—…μ„ μˆ˜ν–‰ν•œλ‹€. λ§Œμ•½ μ»¬λ ‰μ…˜μ— μ¦‰μ‹œλ‘œλ”© λ™μž‘μ„ ν•„μš”λ‘œ ν•œλ‹€λ©΄, fetch join의 λŒ€μ•ˆμœΌλ‘œ @BatchSize 톡해 μ΅œμ ν™”κ°€ κ°€λŠ₯ν•˜λ‹€.

@BatchSizeλŠ” μ»¬λ ‰μ…˜ λŒ€μƒ ν…Œμ΄λΈ”μ— in 쿼리λ₯Ό λ°œμƒμ‹œν‚¨λ‹€.
 size 속성에 μ„€μ • 된 값에 따라 쿼리 λ°œμƒ νšŸμˆ˜λŠ” λ‹€λ₯΄μ§€λ§Œ 데이터λ₯Ό λ©”λͺ¨λ¦¬μ— μ˜¬λ €μ„œ μ²˜λ¦¬ν•˜λŠ” 방법보닀 훨씬 μ•ˆμ „ν•˜κ³  νš¨μœ¨μ μ΄λ‹€.

@Entity
public class Team {

    // ...

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
    private final List<Player> players = new ArrayList<>();
}
public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query("SELECT t FROM Team t")
    Page<Team> fetchAllPagination(Pageable pageable);
}
public class BatchSizeTest extends JpaTest {

    @DisplayName("Case 08. [N + 1] μ»¬λ ‰μ…˜ λŒ€μƒ μ¦‰μ‹œ λ‘œλ”©μ΄ ν•„μš”ν•  λ•ŒλŠ” `@BatchSize` μ‚¬μš©μ„  κ³ λ €ν•΄λ³Έλ‹€.")
    @Test
    void test() {
        // given
        Team team1 = teamRepository.save(team().build());
        Player player1 = playerRepository.save(player().team(team1).build());
        Player player2 = playerRepository.save(player().team(team1).build());

        Team team2 = teamRepository.save(team().build());
        Player player3 = playerRepository.save(player().team(team2).build());
        Player player4 = playerRepository.save(player().team(team2).build());

        // when
        entityManager.clear();

        Page<Team> teams = teamRepository.fetchAllPagination(PageRequest.of(0, 2));

        // then
        teams.forEach(team -> team.getPlayers().size());
    }
}
Hibernate: 
    select
        t1_0.id,
        t1_0.name 
    from
        team t1_0 
    limit
        ?, ?
Hibernate: 
    select
        count(t1_0.id) 
    from
        team t1_0
-- IN 쿼리가 μΆ”κ°€λ‘œ λ°œμƒν•œλ‹€.
Hibernate: 
    select
        p1_0.team_id,
        p1_0.id,
        p1_0.age,
        p1_0.name,
        p1_0.position 
    from
        player p1_0 
    where
        p1_0.team_id in (?, ?)



CASE 09. Cascade, orphanRemoval μ˜΅μ…˜ μ‚¬μš©μ— μ£Όμ˜ν•œλ‹€.

 JPAμ—μ„œ μ œκ³΅ν•˜λŠ” Cascade, orphanRemoval μ˜΅μ…˜μ€ λΆ€λͺ¨ μ—”ν‹°ν‹°λ‘œ μžμ‹ μ—”ν‹°ν‹°μ˜ μ˜μ†ν™”λ₯Ό 관리할 수 있게 ν•΄μ€€λ‹€.

@Entity
public class Team {

    // ...


    // cascade, orphanRemoval
    @OneToMany(mappedBy = "team", fetch = FetchType.LAZY, orphanRemoval = true, cascade = CascadeType.ALL)
    private final List<Player> players = new ArrayList<>();
}

각 μ˜΅μ…˜μ€ λ‹€μŒμ˜ λ™μž‘μ΄ κ°€λŠ₯ν•˜κ²Œ ν•œλ‹€.

  • cascade : λΆ€λͺ¨ μ—”ν‹°ν‹°μ˜ μ €μž₯, μ‚­μ œμ™€ 같은 λ™μž‘μ΄ μžμ‹ μ—”ν‹°ν‹°κΉŒμ§€ μ „νŒŒλœλ‹€.
  • orphanRemoval : λΆ€λͺ¨μ™€ 연관관계가 λŠμ–΄μ§„ μžμ‹ μ—”ν‹°ν‹°λ₯Ό μ‚­μ œν•œλ‹€.

즉, Team을 μ‘°μž‘ν•˜λŠ” 것 λ§ŒμœΌλ‘œλ„ Player 데이터λ₯Ό 컨트둀 ν•  수 μžˆλŠ” 것이닀. λ§Œμ•½ Team을 μ½”λ“œλ‹¨μ—μ„œ 잘 λͺ» μ»¨νŠΈλ‘€ν•˜λ©΄ Player 데이터 정합성에 영ν–₯을 쀄 수 μžˆλ‹€.

  예λ₯Ό λ“€μ–΄ Team 데이터λ₯Ό μ˜λ„μΉ˜ μ•Šκ²Œ μ‚­μ œν•˜λŠ” 경우, Player λ˜ν•œ μ‚­μ œλ˜κ³  Player와 μ—°κ΄€λ˜μ–΄ 있던 λ‹€λ₯Έ μ—”ν‹°ν‹°μ˜ 데이터 μ •ν•©μ„±κΉŒμ§€ 영ν–₯을 쀄 수 μžˆλŠ” 것이닀.


// Cascade Test
public class CascadeTest extends JpaTest {

    @DisplayName("Case 10. Cascade, orphanRemoval μ˜΅μ…˜ μ‚¬μš©μ— μ£Όμ˜ν•œλ‹€.")
    @Test
    void cascadeTest() {
        // given
        Team givenTeam = teamRepository.save(team().build());
        Player player1 = playerRepository.save(player().team(givenTeam).build());
        Player player2 = playerRepository.save(player().team(givenTeam).build());

        // when
        entityManager.flush();
        entityManager.clear();

        // Team μ‚­μ œ
        teamRepository.deleteById(givenTeam.getId());

        // then
        then(teamRepository.findById(givenTeam.getId())).isEmpty();

        // Teamκ³Ό μ—°κ΄€λ˜μ–΄ 있던 λͺ¨λ“  Playerκ°€ μ‚­μ œλœλ‹€.
        then(playerRepository.findAllById(List.of(player1.getId(), player2.getId()))).isEmpty();
    }
}
-- Player μ‚­μ œ
Hibernate: 
    delete 
    from
        player 
    where
        id=?
Hibernate: 
    delete 
    from
        player 
    where
        id=?

-- Team μ‚­μ œ
Hibernate: 
    delete 
    from
        team 
    where
        id=?
  // OrphanRemoval Test
  @DisplayName("Case 10. Cascade, orphanRemoval μ˜΅μ…˜ μ‚¬μš©μ— μ£Όμ˜ν•œλ‹€.")
  @Test
  void orphanRemovalTest() {
      // given
      Team givenTeam = teamRepository.save(team().build());
      Player player1 = playerRepository.save(player().team(givenTeam).build());
      Player player2 = playerRepository.save(player().team(givenTeam).build());

      // when
      entityManager.clear();

      Team team = teamRepository.findById(givenTeam.getId()).orElseThrow();
      // Team의 μ»¬λ ‰μ…˜μœΌλ‘œλΆ€ν„° Playerμ™€μ˜ 연관관계 제거
      team.getPlayers().clear();

      entityManager.flush();
      entityManager.clear();

      // then        
      then(playerRepository.findAllById(List.of(player1.getId(), player2.getId()))).isEmpty();
  }
-- λΆ€λͺ¨μΈ Team과의 연관관계가 제거된 Player 데이터듀을 μ‚­μ œν•œλ‹€.
Hibernate: 
    delete 
    from
        player 
    where
        id=?
Hibernate: 
    delete 
    from
        player 
    where
        id=?

PlayerλŠ” μ–΄λŠ Team에도 μ†Œμ†λ˜μ–΄ μžˆμ§€ μ•Šμ„ 수 있고, μ–Έμ œλ“  λ‹€λ₯Έ Team에 μ†Œμ†λ  수 있기 λ•Œλ¬Έμ— κ°œλ…μ μœΌλ‘œ Team에 μ™„μ „νžˆ 쒅속적이지 μ•Šλ‹€. 즉, 라이프사이클이 λ‹€λ₯΄λ‹€.

β€‚λ°˜λ©΄ Player와 ν•΄λ‹Ή Player의 기둝을 κ΄€λ¦¬ν•˜λŠ” PlayerStat 은 같은 라이프사이클을 가진닀. PlayerStat은 νŠΉμ • Playerμ—λ§Œ 쒅속적인 데이터이고, Player μ—†μ΄λŠ” 쑴재 μ˜λ―Έκ°€ μ—†κΈ° λ•Œλ¬Έμ΄λ‹€. 이 경우 cascade, orphanRemoval μ˜΅μ…˜μ˜ μ μš©μ„ κ³ λ €ν•΄λ³Όλ§Œ ν•˜λ‹€.


  @DisplayName("Case 09. Cascade, orphanRemoval μ˜΅μ…˜ μ‚¬μš©μ— μ£Όμ˜ν•œλ‹€.")
  @Test
  void playerStatTest() {
      // given
      Team givenTeam = teamRepository.save(team().build());
      Player givenPlayer = playerRepository.save(player().team(givenTeam).build());
      PlayerStat playerStat1 = playerStatRepository.save(playerStat().season(2001).player(givenPlayer).build());
      PlayerStat playerStat2 = playerStatRepository.save(playerStat().season(2002).player(givenPlayer).build());

      // when
      entityManager.flush();
      entityManager.clear();

      Player player = playerRepository.findById(givenPlayer.getId()).orElseThrow();
      // Playerλ₯Ό μ‚­μ œν•˜λ©΄
      playerRepository.delete(player);

      entityManager.flush();
      entityManager.clear();

      // then
      // Player, PlayerStat이 ν•¨κ»˜ μ‚­μ œλœλ‹€.
      then(playerRepository.findById(player.getId())).isEmpty();
      then(playerStatRepository.findAllById(List.of(playerStat1.getId(), playerStat2.getId()))).isEmpty();
  }
-- PlayerStat μ‚­μ œ
Hibernate: 
    delete 
    from
        player_stat 
    where
        id=?
Hibernate: 
    delete 
    from
        player_stat 
    where
        id=?

-- Player μ‚­μ œ
Hibernate: 
    delete 
    from
        player 
    where
        id=?

 이와 같이 cascade, orphanRemoval μ˜΅μ…˜μ€ μžμ‹ μ—”ν‹°ν‹°κ°€ λΆ€λͺ¨ 엔티티와 라이프사이클이 μ˜¨μ „νžˆ 일치 ν•  λ•Œ, 그리고 ν•΄λ‹Ή λΆ€λͺ¨ μ—”ν‹°ν‹° 외에 λ‹€λ₯Έ 연관관계가 없을 λ•Œμ—λ§Œ μ‚¬μš©ν•˜λŠ” 것을 ꢌμž₯ν•œλ‹€.




CASE 10. μ–‘λ°©ν–₯ 연관관계(@OneToMany)λŠ” ν•„μš”μ‹œμ—λ§Œ μ μš©ν•œλ‹€.

 CASE 06. ~ CASE 09. μ—μ„œ ν™•μΈν•œ λ‚΄μš©κ³Ό 같이, @OneToMany λ₯Ό μ‚¬μš©ν•˜λ©΄ 전체적인 λ³΅μž‘λ„κ°€ μƒμŠΉν•˜μ—¬ μ½”λ“œ λ ˆλ²¨μ—μ„œ 관리 ν•΄μ•Ό ν•  ν¬μΈνŠΈκ°€ λŠ˜μ–΄λ‚˜κ²Œ λœλ‹€.

 FKλ₯Ό κ΄€λ¦¬ν•˜λŠ” μ—”ν‹°ν‹°μ—λ§Œ @ManyToOne 관계λ₯Ό μ„€μ •ν•˜κ³ , @OneToMany λŠ” ν•„μš”ν•˜λ‹€κ³  νŒλ‹¨ 될 λ•Œλ§Œ μ„ λ³„μ μœΌλ‘œ μ μš©ν•˜λŠ” 것을 ꢌμž₯ν•œλ‹€.




References