Spring Data JPA에서 양방향 영속성 전파 문제

RID·2024년 5월 8일
0

배경


오늘 Spring 강의를 들으며 간단한 웹 어플리케이션 구현을 따라해보았는데, 강의랑 똑같이 해보았는데 문제가 생기는 부분이 있었다. 아래 상황을 한 번 살펴보자.

Course Entity와 Lecture Entity는 서로 1:N 관계에 있으며, DB에서는 lecture 테이블에 course_id를 FK로 가지고 있는 상황이다.

Course.kt

@Entity
@Table(name = "course")
class Course(
    @Column(name = "title", nullable = false)
    var title: String,

    @Column(name = "description")
    var description: String? = null,

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false)
    var status: CourseStatus = CourseStatus.OPEN,

    @Column(name = "max_applicants", nullable = false)
    val maxApplicants: Int = 30,

    @Column(name = "num_applicants", nullable = false)
    var numApplicants: Int = 0,

    @OneToMany(mappedBy = "course", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true)
    var lectures: MutableList<Lecture> = mutableListOf(),

) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null

    fun isFull(): Boolean {
        return numApplicants >= maxApplicants
    }

    fun isClosed(): Boolean {
        return status == CourseStatus.CLOSED
    }

    fun close() {
        status = CourseStatus.CLOSED
    }

    fun addApplicants() {
        numApplicants += 1
    }

    fun addLecture(lecture: Lecture) {
        lectures.add(lecture)
    }

    fun removeLecture(lecture: Lecture) {
        lectures.remove(lecture)
    }
}

fun Course.toResponse(): CourseResponse {
    return CourseResponse(
        id = id!!,
        title = title,
        description = description,
        status = status.name,
        maxApplicants = maxApplicants,
        numApplicants = numApplicants,
    )
}

위에서 보면 CourseLectureOneToMany로 매핑하여 Course Entity 내에서 lectures 리스트를 편하게 접근할 수 있게 했다. 이때 course가 삭제 시 관련있는 lecture를 모두 지워야 하는 것이 현재 프로젝트 정책이므로 cascade 옵션을 Cascade.ALL로 주었다.

Lecture.kt

@Entity
@Table(name = "lecture")
class Lecture(
    @Column(name = "title", nullable = false)
    var title: String,

    @Column(name = "video_url", nullable = false)
    var videoUrl: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "course_id")
    var course: Course

) {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null
}

fun Lecture.toResponse(): LectureResponse{
    return LectureResponse(
        id = id!!,
        title = title,
        videoUrl = videoUrl,
    )
}

Lecture의 경우도 Course에 접근하기 위해 @ManyToOne을 통해 연관 관계를 설정해주었고, 서로 양방향 관계를 형성한다.

문제가 되는 상황은 CourseService내의 addLecture함수에서 발생했다.

// 생략
    @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) // break point
        return lecture.toResponse() // NPE!!!
    }
// 생략

마지막 줄인 lecture.toResponse()에서 NPE가 발생한다. lecture의 idnull이기 때문에 toResponse() 함수에서 id할당 과정에 NPE가 발생했던 것이다.

1. 기대했던 흐름


위의 코드를 한 번 더 살펴보자. 처음에 courseRepositorycourse를 하나 가져온다. 그리고 추가할 Lecture 객체를 생성하는데, 이 때 Lecture가 이 관계의 주인이므로 반드시 생성자에 course를 담아서 넣어준다. 그 다음 course Entity 내의 addLecture함수를 통해 새로 생성한 객체 lecture를 인자로 넣어 course.lectures에 추가한다.

이후 courseRepository.save()를 실행하면 course의 변경 사항(lectures에 새로운 lecture 추가)이 발생했고, cascade 옵션이 있으니 영속성 전이가 되어 처음 생성한 lecture 객체 역시 영속 객체로 바뀔 것이다. 그리고 이 때 lecture는 자동으로 생성된 id값을 가지게 될 것이고, toResponse함수에서 이상이 없을 것이다.

하지만 실제 lecture에는 한 번도 id가 할당 되지 않았다. 디버깅 화면을 살펴보자.

2. 디버깅


위의 코드에서 break point를 걸고 course 내부 lectures의 마지막 원소와 기존 lecture 객체의 차이를 살펴보자.

당연하게도 call by reference 형식으로 인자가 전달되었으니 두 객체는 정확히 동일하다.

문제는 그 다음 줄이 시행되었을 때 생긴다.

course 내부 lectures에 들어있는 마지막 원소의 주소가 바뀌는 것을 확인할 수 있다. courseRepository.save() 함수가 호출되면서 영속성 전이가 되어 기존 객체가 수정되는 것이 아니라 lectures의 마지막 원소로 있던 객체만 id값을 가지고 새로운 객체가 되었다.

그렇기 때문에 당연히 nullid를 가지고 있는 lecture객체에 toResponse()를 호출하니 NPE가 난다.

3. 해결


이번에 코드를 다음과 같이 수정해보자.

// 생략
    @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
        )
        val savedLecture = lectureRepository.save(lecture)
        course.addLecture(savedLecture) 
        courseRepository.save(course) // break point
        return savedLecture.toResponse() 
    }
// 생략

위의 코드를 보면 cascade를 통해 course로 부터 lecture에 영속성을 전파하기 이전에 먼저 lectureRepository.save()를 호출함으로서 lecture를 영속상태로 만든다. 이후 해당 객체를 활용해서 동일하게 진행한다.

똑같이 break point를 걸고 디버깅 해보자.


이번에는 lecturesavedLecture가 이미 id값을 할당 받은 상태이며, course내부 lectures에도 동일한 객체가 들어간다. 그렇기 때문에 당연히 toResponse()의 경우에도 NPE가 발생하지 않는다. (여기서 사실 courseRepository.save(course) 줄은 실행하지 않아도 된다. 영속성 전이가 아닌 직접 lecture를 이미 영속상태로 만들었고, Lecture가 관계의 주인이므로)

결국 코드를 조금 더 깔끔하게 정리하면 아래와 같다.

// 생략
    @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
        ).let{lectureRepository.save(it)}
        
        course.addLecture(lecture) 
        return lecture.toResponse() 
    }
// 생략

마무리


결국 Spring을 비롯해 Spring Data JPA 등을 활용할 때 원리와 동작방식을 제대로 이해하지 않고 단순 복사 붙여넣기 식으로 코드를 작성하니까 이런 이슈에 너무 많은 시간을 투자했다. 물론 학습하는 기간이라 좋은 경험이 되었지만 학습과정에서도 항상 의문을 가지고 천천히 공부하는 습관을 길러야겠다.

결국 위의 문제는 양방향 관계에서 영속성 전이가 어떤식으로 발생하는지, 그래서 결국 lecture객체가 save될 때 persist()가 호출되는 지 merge()가 호출되는 지의 문제였다. 영속 컨텍스트, 그리고 JPA의 동작 방식을 어느정도 공부해서 해결책을 찾고 과정을 이해하긴 했지만 여전히 강의에서 왜 문제가 있던 코드로도 동작이 되었는지 궁금하다..

시간이 된다면 위의 과정에서 왜 오류가 발생할 수 밖에 없었는지 영속 컨텍스트 개념을 통해 정리해보고자 한다.

Spring Data JPA에서 양방향 영속성 전파 문제 -2

0개의 댓글