자극적인 제목 미안하다. 물론 장단점이 있을 수 있다. 하지만 글의 결론부터 말하자면, 단방향 (unidirectional) @OneToMany
보다는 양방향 (bidirectional) @OneToMany
가 좋고, 양방향 @OneToMany
보다는 단방향 @ManyToOne
이 좋다.
@OneToMany
를 사용한다면 무조건 양방향이 좋다.
Spring Boot Persistence Best Practices 책의 저자는 @OneToMany
가 단방향으로 설정됐을 때 얼마나 안 좋은지를 서술한다.
JPA 에서 엔티티 관계를 양방향으로 설정할 경우 귀찮은 일이 생긴다. 그렇기 때문에 단방향으로 설정하는 경우가 있다.
하지만 @OneToMany
는 단방향을 선택하면 안 된다. 비효율적인 쿼리가 발생하기 때문이다.
여기서는 실험을 통해 이를 확인한다. Spring Boot Persistence Best Practices 책의 예제를 재구성해 실험했다.
다음과 같은 엔티티가 있다.
@Entity
class Team(
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
val id: Long = 0L,
@OneToMany(
orphanRemoval = true,
cascade = [ALL],
// 양방향일 경우 mappedBy 파라미터를 사용할 수 있다.
// 이 프로퍼티를 사용하면 mapping table이 생성되지 않고 foreign key 가 사용된다.
// mappedBy = "team"
)
var members: MutableList<Member>,
)
@Entity
class Member(
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
val id: Long = 0L,
var name: String,
// 아래 코드를 활성화하면 bidirectional, 활성화하지 않으면 unidirectional OneToMany relationship.
// @ManyToOne
// var team: Team,
)
실험을 시작하기 전에 테이블에 미리 저장한 데이터는 아래와 같다.
team TABLE
| id |
|----|
| 1 |
member TABLE
| id | name |
|----|---------------------|
| 2 | 1st member: default |
| 3 | 2st member: default |
team_members TABLE
unidirectional @OneToMany 의 경우 꼭 필요한 mapping table이며, ddl-auto 설정에 따라 hibernate 가 자동 생성한다.
| team_id | members_id |
|---------|------------|
| 1 | 2 |
| 1 | 3 |
왜 단방향 @OneToMany
를 사용하면 안 되는지 이미 어느 정도 힌트가 제공됐다. 단방향일 경우, team_members
라는 테이블이 추가적으로 필요해진다.
이제 실험을 위해 같은 엔드포인트 및 서비스 로직을 실행하고 hibernate가 어떤 쿼리를 생성하는지 살펴보자. 참고로 RESTful 설계를 고려하지 않았다. 실험의 편의성을 위해 모두 GET method를 사용했다.
@RestController
@RequestMapping("/demo")
class TeamController(
private val teamService: TeamService,
private val teamRepository: TeamRepository,
) {
@GetMapping("/save-another")
fun saveAnother() {
println("---- save one ----")
val team = teamRepository.findAll().first()
val anotherMember = Member(name = "2nd member")
// 아래 코드를 활성화하면 bidirectional, 활성화하지 않으면 unidirectional OneToMany relationship.
// val anotherMember = Member(name = "2nd member", team = team)
teamService.insertMember(team = team, member = anotherMember)
}
@GetMapping("/delete-first")
fun deleteFirst() {
val team = teamRepository.findAll().first()
teamService.deleteMember(team, team.members.first())
}
@GetMapping("/delete-last")
fun deleteLast() {
val team = teamRepository.findAll().first()
teamService.deleteMember(team, team.members.last())
}
}
@Service
class TeamService {
@Transactional
fun insertMember(
team: Team,
member: Member,
) {
team.members.add(member)
}
@Transactional
fun deleteMember(
team: Team,
member: Member,
) {
team.members.remove(member)
}
}
interface TeamRepository : JpaRepository<Team, Long>
Hibernate 가 생성하는 쿼리
saveAnother() 호출
단방향
select team0_.id as id1_5_ from team team0_
select members0_.team_id as team_id1_6_0_, members0_.members_id as members_2_6_0_, member1_.id as id1_3_1_, member1_.name as name2_3_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id=?
select nextval ('hibernate_sequence')
insert into member (name, id) values (?, ?)
delete from team_members where team_id=?
insert into team_members (team_id, members_id) values (?, ?)
insert into team_members (team_id, members_id) values (?, ?)
insert into team_members (team_id, members_id) values (?, ?)
양방향
위의 코드에서 주석 처리한 양방향 관련된 코드를 활성하자. 그 다음 API를 호출하면 다음과 같은 쿼리가 생성된다.
select team0_.id as id1_5_ from team team0_
select nextval ('hibernate_sequence')
insert into member (name, team_id, id) values (?, ?, ?)
단방향 @OneToMany
과 양방향의 차이점은 크게 두 가지로 보인다. 첫째, mapping table team_members
의 존재 여부다. 단방향은 foreign key를 사용하지 않기 때문에 반드시 mapping table이 필요하다. 그렇기 때문에 mapping table과 child table 둘 다에 insert 쿼리를 실행해야 한다. 둘째, insert query 개수 차이뿐만 아니라 delete query 여부가 다르다. 단방향의 경우 mapping table에서 parent에 해당하는 레코드를 모두 삭제한 후에 기존 children을 insert하고, saveAnother()
에서 추가하려고 한 member를 insert한다. deleteFirst()
와 deleteLast()
역시 같은 양상을 보인다. 아래를 참고하자.
deleteFirst() 호출
단방향
select team0_.id as id1_5_ from team team0_
select members0_.team_id as team_id1_6_0_, members0_.members_id as members_2_6_0_, member1_.id as id1_3_1_, member1_.name as name2_3_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id=?
delete from team_members where team_id=?
insert into team_members (team_id, members_id) values (?, ?)
delete from member where id=?
양방향
select team0_.id as id1_5_ from team team0_
select members0_.team_id as team_id3_3_0_, members0_.id as id1_3_0_, members0_.id as id1_3_1_, members0_.name as name2_3_1_, members0_.team_id as team_id3_3_1_ from member members0_ where members0_.team_id=?
delete from member where id=?
deleteLast() 호출
단방향
select team0_.id as id1_5_ from team team0_
select members0_.team_id as team_id1_6_0_, members0_.members_id as members_2_6_0_, member1_.id as id1_3_1_, member1_.name as name2_3_1_ from team_members members0_ inner join member member1_ on members0_.members_id=member1_.id where members0_.team_id=?
delete from team_members where team_id=?
insert into team_members (team_id, members_id) values (?, ?)
delete from member where id=?
양방향
select team0_.id as id1_5_ from team team0_
select members0_.team_id as team_id3_3_0_, members0_.id as id1_3_0_, members0_.id as id1_3_1_, members0_.name as name2_3_1_, members0_.team_id as team_id3_3_1_ from member members0_ where members0_.team_id=?
delete from member where id=?
@OneToMany
결론: 양방향을 쓴다.
단방향 @OneToMany
를 쓰지 말자. @OneToMany
를 사용해야 한다면 양방향으로 설정하자.
애초에 @OneToMany
를 쓰지 말자.
나는 왜 @OneToMany
를 쓰는가?
위에서 @OneToMany
를 써야 한다면 양방향으로 설정해야 한다고 결론을 내렸다. 그런데 언제 @OneToMany
가 필요할까? 이미 단방향 @ManyToOne
으로도 웬만하면 모든 상황을 커버할 수 있는데, 굳이 왜 @OneToMany
를 설정해야 할까?
필자가 생각하는 이유는 엔티티 클래스 설정에서 cascade
때문이다. 특히 이 중에서도 REMOVE
때문이다. 여기에 추가로 orphanRemoval
설정도 포함하자.
이 두 가지 설정으로, 부모 엔티티를 삭제하거나, 자식 엔티티를 부모 엔티티에서 떼어낼 경우 자식 엔티티가 모두 삭제된다.
프로그래머는 따로 로직을 짤 필요 없이 부모 엔티티의 로직만 구현하면 되기 때문에 편리하다.
실험: cascade
, orphanRemoval
설정이 만드는 쿼리
정말 cascade
와 orphanRemoval
조합은 편할까? 혹시 비효율적인 쿼리가 발생할까? 발생한다. 실험을 통해 이를 확인하자.
다음과 같은 양방향 @OneToMany
가 있다.
@Entity
class Team(
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
val id: Long = 0L,
@OneToMany(
orphanRemoval = true,
cascade = [ALL],
mappedBy = "team",
)
var members: MutableList<Member>,
)
@Entity
class Member(
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
val id: Long = 0L,
var name: String,
@ManyToOne(fetch = LAZY)
var team: Team,
)
실험을 시작하기 전에 테이블에 미리 저장한 데이터는 아래와 같다.
team TABLE
| id |
|----|
| 1 |
member TABLE
| id | name |
|----|---------------------|
| 2 | 1st member: default |
| 3 | 2st member: default |
이제 실험을 위해 같은 엔드포인트 및 서비스 로직을 실행하고 hibernate가 어떤 쿼리를 생성하는지 살펴보자. 참고로 RESTful 설계를 고려하지 않았다. 실험의 편의성을 위해 모두 GET method를 사용했다.
@RestController
@RequestMapping("/demo")
class TeamController(
private val teamService: TeamService,
private val teamRepository: TeamRepository,
) {
@GetMapping("/delete-team")
@Transactional
fun deleteTeam() {
val team = teamRepository.findAll().first()
teamRepository.delete(team)
}
@GetMapping("/detach-members")
@Transactional
fun detachMembers() {
val team = teamRepository.findAll().first()
team.members.clear()
}
}
Hibernate 가 생성하는 쿼리
deleteTeam()
와 detachMembers()
모두 delete
쿼리가 레코드 개수만큼 발생하는 것을 아래와 같이 확인할 수 있다.
deleteTeam() 호출
select team0_.id as id1_5_ from team team0_
select members0_.team_id as team_id3_3_0_, members0_.id as id1_3_0_, members0_.id as id1_3_1_, members0_.name as name2_3_1_, members0_.team_id as team_id3_3_1_ from member members0_ where members0_.team_id=?
delete from member where id=?
delete from member where id=?
delete from team where id=?
detachMembers() 호출
select team0_.id as id1_5_ from team team0_
select members0_.team_id as team_id3_3_0_, members0_.id as id1_3_0_, members0_.id as id1_3_1_, members0_.name as name2_3_1_, members0_.team_id as team_id3_3_1_ from member members0_ where members0_.team_id=?
delete from member where id=?
delete from member where id=?
@OneToMany
를 써도 유용한 상황: many가 아니라 few인 경우
Vlad Mihalcea가 쓴 글을 보면 자식 엔티티의 레코드 개수가 제한적일 경우에만 @OneToMany
가 유용히다고 한다. 그렇기 때문에 @OneToMany
가 아니라 @OneToFew
라는 명칭이 더 적합한 거 같다고 서술한다.
사실 위의 실험에서 알 수 있듯이, delete 쿼리가 자식 레코드 개수만큼 발생하기 때문에, 자식 레코드 개수가 적으면 적을수록 쿼리의 효율성을 덜 신경 써도 된다. 이 상황에서는 별도의 로직 없이 cascade
, orphanRemoval
설정만으로 자식 레코드를 삭제하는 게 좋을 수도 있다.
또한 삭제가 거의 일어나지 않는 도메인에서 사용하기에도 좋을 거 같다. 하지만 이런 확신이 없다면 @OneToMany
는 피하자.
Rule of Thumb: 단방향 @ManyToOne
특별한 상황이 아니라면 단방향 @ManyToOne
를 사용하자.
참고로, @ManyToOne
은 단방향으로 설정해도 괜찮다. 자세한 건 Spring Boot Persistence Best Practices 책을 참고하자.
References
- Anghel Leonard. (2020) Spring Boot Persistence Best Practices. Apress
- The best way to map a @OneToMany relationship with JPA and Hibernate, Vlad Mihalcea