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에서는 연관관계에 있는 두 엔티티 중 하나로 외래 키를 관리한다. 외래키를 관리하는 주체를 '연관관계의 주인'이라한다. 연관관계의 주인은 테이블에서의 외래키 위치를 기준으로 정하는 것이 일반적이다.

PlayerTeam 의 관계를 결정하는 것은 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 을 정의해야 할 경우, 연관관계에 해당하는 객체는 정의에서 제외 시켜야한다. (롬복을 사용하는 경우 @ToStringexclude 옵션을 사용할 수 있다.)


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의 특성이 고스란히 적용된다.

 예를 들어 Teamplayers 를 대상으로 페치조인을 적용하면, 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