자극적인 제목 미안하다. 물론 장단점이 있을 수 있다. 하지만 글의 결론부터 말하자면, 단방향 (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 설정이 만드는 쿼리

정말 cascadeorphanRemoval 조합은 편할까? 혹시 비효율적인 쿼리가 발생할까? 발생한다. 실험을 통해 이를 확인하자.

다음과 같은 양방향 @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