지난 글에서 Entity
사이의 연관관계 매핑에 대해서 살펴보았다. 이번에는 연관된 Entity
들의 영속성을 다룰 수 있는 옵션 중 하나인 영속성 전이에 대해서 살펴보자. 아래와 같은 상황을 생각해보자.
@OneToMany
로 연관관계가 설정되어 있다. @Entity
@Table(name = "post")
class Post(
@Column(name = "content")
var body : String,
@OneToMany
val comments : List<Comment>
){
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
}
이 상태에서 아래와 같은 코드를 이용해서 post
를 삭제하면 어떻게 될까?
@Transactional
fun deletePost(postId: Long){
val post = postRepository.findByIdOrNull(postId) ?: throw RuntimeException()
postRepository.delete(post)
}
먼저 findByIdOrNull()
함수의 호출로 인해서 post
, 그리고 연관된 comment
가 영속성 컨텍스트의 관리를 받게 된다.
그리고 delete()
의 호출을 받아 쓰기지연 SQL 저장소에 해당 post
에 대한 delete 쿼리가 올라간다.
이후 트랜잭션이 종료되어 flush()
가 호출되는 시점에 DB에 쿼리가 날아가 삭제된다.
이 흐름에서 단지 연관관계를 맺었다고 해서 (그러니까 post
객체 내에 연관된 comment
가 포함되었다고 해서) post
를 삭제하는 행위로 연관된 comment
까지 삭제되길 기대하면 안된다는 것이다. (DB에 FK 삭제에 대한 constraint를 걸지 않으면 post를 삭제할 수 없어 오류가 발생한다.)
JPA는 DB의 데이터를 객체로 관리할 수 있게 도와준다고 했다. 그렇기 때문에 항상 어플리케이션에서 관리하는 Entity
와 실제 영속성을 가진 DB 데이터와의 흐름을 잘 살펴보아야 한다.
JPA는 위와 같은 상황에 활용가능한 영속성 전이, Cascade
옵션을 제공한다. 아래에서 살펴보자.
영속성 전이는 Entity
의 상태변화를 만들어내는 함수를 연관된 Entity
에도 그대로 적용하는 것이다.
Entity
의 영속상태 변화를 만들어내는 함수는 persist()
, remove()
, detach()
, refresh()
, merge()
등이 있고, 각각에 대한 Cascade 옵션이 존재한다.
위의 옵션들이 EntityManager
에서 사용되는 함수의 이름이랑 정확히 동일하다. 즉, CASCADE 옵션을 걸어둔 Entity
에 대해서 위의 이름을 가진 함수가 호출되는 경우, 연관된 Entity
에도 동일한 함수를 호출하는 것이다.
아래 상황을 살펴보자.
@Entity
@Table(name = "post")
class Post(
@Column(name = "content")
var body : String,
@OneToMany()
val comments : MutableList<Comment> = mutableListOf()
){
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
fun addComment(comment: Comment){
comments.add(comment)
}
}
@Transactional
fun test(){
val post = Post("test post")
val comment = Comment("test comment")
post.addComment(comment)
postRepository.save(post)
}
위의 함수에서 우리는 Post
가 저장될 때 연관된 Comment
도 저장되었으면 좋겠어! 라고 생각하며 코드를 작성하겠지만, 실제 의도대로 동작하지 않는다. (Comment
에 대한 insert 쿼리가 발생하지 않는다!)
Post
를 저장할 때 필요한 Comment
는 실제 DB에 존재하지 않고, 영속성 컨텍스트에 의해 관리되고 있는 상태도 아니기 때문에 Post
입장에서는 필요한 Entity
가 없다고 판단되는 것이다.
이 때 만약 @OneToMany
의 cascade 옵션을 CascadeType.PERSIST
로 주게되면, new 상태의 post가 save()
메소드에 의해 호출된 persist()
로 영속화가 진행되고, 연관된 Comment
에 대해서도 cascade 옵션에 의해 persist()
가 호출되어 성공적으로 저장이 된다.
이렇게 cascade 옵션을 주게 되면 연관된 Entity
에 대해서 동일한 EntityManager
의 함수 호출을 자동으로 발생하게 도와준다.
사실 이런 영속성 전이 옵션에 대해서 조금 찾아보면 해당 옵션을 쓰지 말라고 얘기하는 글이나 영상을 많이 찾아볼 수 있다. 이유를 한 번 살펴보자.
우리가 영속성 전이에 대해서 헷갈릴 수 있는 부분이 있는데, CASCADE 옵션이 특정 행위 를 전파해주는 역할이라고 생각한다는 것이다.
예를 들어, persist 옵션에 대해 'Entity
가 저장될 때 연관된 Entity
도 저장된다' 라며 저장 행위를 전파해준다고 인식하고 있으면 예상치 못한 상황에 대해 대처하지 못한다.
아래 상황을 한 번 보자. 사실 아래 상황과 관련된 글은 이전에 작성한 적이 있다.
Course
와 Lecture
는 1:n 관계로 서로 양방향 연관관계를 맺고있으며, Course
의 @OneToMany
에 cascade 옵션을 ALL로 설정한 상태이다.
@Transactional
override fun addLecture(courseId: Long, request: AddLectureRequest): LectureResponse {
val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
val lecture = Lecture(
title = request.title,
videoUrl = request.videoUrl,
course = course
)
course.addLecture(lecture)
courseRepository.save(course)
return lecture.toResponse()
}
문제가 되었던 부분은 위의 함수에서 lecture
를 dto 형태로 변환하는 toResponse()
에서 발생한다.
지난번에 save()
함수는 PK필드가 null인 Entity
에 대해서는 persist()
, PK가 null이 아닌Entity
에 대해서는 merge()
를 호출한다고 했다.
여기서 course
는 이미 id를 가지고 있기 때문에 save()
내부에서 merge()
가 호출된다. 이때 cascade 옵션 ALL에 의해서 연관된 lecture
역시 merge()
가 호출된다는 사실을 인지해야 한다.
하지만 만약 우리가 cascade 옵션이 저장 행위를 전파한다라고 생각하게 되면 lecture
가 새로 저장되어 id를 부여받고, toResponse()
가 정상 작동할 것이라 예상하게 된다.
하지만 실제 lecture
의 경우 cascade 옵션에 의해 merge()
메소드의 인자로 들어가게 되고, 그로 인해 실제 영속성 컨텍스트에서 관리되는 Entity
는 인자로 전달해준 lecture
와 다른 객체가 된다.
Spring Data JPA가 repository를 제공해서 영속성 컨텍스트의 관리를 편하게 해주는 것과 마찬가지로 내부적으로 진행해주는 것들에 대해서 흐름을 완벽히 읽고있지 않으면 문제가 생기는 상황들이 많은 것 같다.
개인적으로 cascade 옵션을 사용해서 자동으로 작업이 진행되길 기대하는 것보다 직접 연관 Entity
를 관리하는 것이 훨씬 더 나은 것 같다.