오늘도 스프링 학습을 이어나갔다. 오늘은 주어진 과제의 선택구현 부분을 구현하고 전체 코드를 수정해보는 시간을 가졌다.
여기에는 그간 공부해왔던 JPA에 대해 조금 정리해보았다.
1. JPA의 영속성 컨텍스트와 Spring Data JPA
1-1. JPA의 영속성 컨텍스트
- 영속성 이란?
영속성은 간단히 말해 데이터를 영구히 저장하는 것을 말한다.
애플리케이션에서 만들어지거나 수정된 데이터들이 영속성을 가지지 않는다면 메모리 상에서만 존재하게 되고 프로그램이 종료되면 모두 사라지게 될 것이다. 외부 저장장치(웹 개발에서는 주로 데이터베이스)에 저장을 해둬야 영구히 저장되게 된다. 데이터를 저장함으로써 영속성을 부여하게 되는 것이다.
- 영속성 컨텍스트란?
엔티티를 영구히 저장하는 환경이라고 할 수 있다.
엔티티 매니저 팩토리에서 엔티티 매니저라는 것이 생성되게 되는데 엔티티 매니저를 만들면 그 내부에 영속성 컨텍스트가 같이 만들어진다. 영속성 컨텍스트는 이 매니저를 통해서 접근할 수 있다. 영속성 컨텍스트는 애플리케이션과 DB 사이에서 객체를 보관하는 가상의 DB 같은 역할을 한다고 보면 된다.
현재까지 학습하면서 Spring Data JPA의 사용법만을 배웠고 그것만 사용해 왔기 때문에 이런 부분을 전혀 알지 못했었다. 스프링 부트에서는 엔티티 매니저 팩토리를 하나만 생성해서 관리하고 Spring Data JPA가 엔티티 매니저를 관리하기 때문에 엔티티 매니저를 직접 생성하거나 관리할 일이 전혀 없었기 때문이다.
- 엔티티의 생명 주기
엔티티에는 총 4가지의 상태가 있다. 아래 그림은 엔티티의 생명주기를 나타낸다.
- 비영속(Transient, New): 영속성 컨텍스트와 전혀 관계가 없는 상태이다. 앤티티 객체를 새로 생성하게 되면 순수한 객체 상태 그 자체일 뿐 아직 저장되지 않는다. 영속성 컨텍스트와 DB와는 전혀 관련이 없는 상태이기 때문에 비영속 상태라고 한다.
- 영속(Managed): 영속성 컨텍스트에 저장된 상태이다. 엔티티 매니저를 통해서 엔티티를 영속성 컨텍스트에 저장한 상태로 영속성 컨텍스트가 관리하는 엔티티를 영속 상태라고 한다.
- 준영속(Detached): 영속성 컨텍스트에 저장되었다가 분리된 상태를 말한다. 영속성 컨텍스트에 의해 관리되던 영속 상태의 엔티티를 영속성 컨텍스트가 관리하지 않으면 준영속 상태가 된다. 특정 엔티티를 준영속 상태를 만드는 방법은 3가지가 있다. 엔티티 매니저에서 detach()함수를 통해 엔티티를 분리 시키거나, close() 함수를 호출해서 영속성 컨텍스트를 닫아버리거나, clear() 함수를 호출해서 영속성 컨텍스트를 초기화 시켜면 영속성 컨텍스트에 의해 관리되던 엔티티가 준영속 상태가 된다. 영속 상태의 엔티티는 주로 영속성 컨텍스트가 종료되면서 준영속 상태가 되게 되는데 코드를 작성하는 우리가 직접 준영속 상태로 만드는 일은 드물다고 한다.
- 삭제(Removed): 엔티티를 영속성 컨텍스트와 DB에서 삭제한 상태를 말한다.
- 엔티티 조회하기
영속성 컨텍스트는 엔티티를 식별자 값(기본 키)으로 구분하기 때문에 영속 상태는 반드시 식별자 값이 있어야 한다.
영속성 컨텍스트는 내부에 캐시를 가지고 있다. 이 캐시를 1차 캐시라고 부른다. 영속 상태의 엔티티는 모두 이곳에 저장된다.
엔티티를 새로 생성하여 비영속 상태인 엔티티를 persist() 하게 되면 아래와 같은 상태가 된다. 아직 DB에는 저장되지 않은 상태이다.
위와 같은 상태에서 엔티티 매니저에서 find() 메서드를 호출하여 엔티티를 조회하게 되면 아래 사진과 같이 1차 캐시에서 먼저 엔티티를 찾게 된다. 1차 캐시에 찾는 엔티티가 없다면 DB에서 조회한다.
만약 find()를 호출했는데 엔티티가 1차 캐시에 없다면 엔티티 매니저는 DB를 조회해서 엔티티를 생성하게 된다. 1차 캐시에 저장한 후에 영속 상태의 엔티티를 반환하게 된다.
위와 같이 1차 캐시를 이용해서 조회했을 때의 장점에 대해서 생각해볼 수 있다.
만약에 똑같은 엔티티 식별자 값으로 여러번 조회를 한다고 생각해보자. 그런 경우 1차 캐시에서 같은 엔티티 인스턴스를 반환하기 때문에 DB까지 갔다가 오지 않아도 되므로 성능상 이점이 생기게 된다.
또한 엔티티가 항상 같은 것으로 반환되기 때문에 동일성을 보장한다. 우리가 DB에서 키 값을 가지고 데이터를 조회하면 여러번 조회하더라도 항상 똑같은 데이터가 조회될 것이다. 그러나 매번 DB로부터 가져와서 엔티티를 새로 생성한다면 여러번 조회했을 때 동등하지만 동일하지 않은 엔티티가 반환될 것이다.
- 엔티티 등록하기
엔티티를 새로 생성한 다음 엔티티 매니저를 통해 등록할 수 있다. persist() 함수를 호출해서 등록하면 아래 그림과 같이 작동한다.
엔티티 매니저는 트랜잭션을 커밋하기 직전까지 DB에 엔티티를 저장하지 않는다. 내부 쿼리 저장소라는 곳에 INSERT SQL을 모아두고 트랙잭션을 커밋할 때 모아둔 쿼리를 DB에 보내 저장하게 된다.
위 사진에서는 member A를 등록한 것을 볼 수 있다. 영속성 컨텍스트는 1차 캐시에 엔티티를 저장하면서 동시에 엔티티 정보로 쿼리를 만든다. 만든 쿼리를 쓰기 지연 SQL 저장소에 보관한다.
아래 사진에는 이 이후에 member B를 추가적으로 영속화한 뒤 트랜잭션을 커밋한 상황을 보여준다.
member B를 등록하면 역시 1차 캐시에 저장되고 쿼리가 쓰기 지연 SQL 저장소에 보관된다. 그런 다음 트랜잭션을 커밋하면 엔티티 매니저는 영속성 컨텍스트를 플러시한다. 플러시는 영속성 컨텍스트의 변경 내용을 DB에 동기화하는 작업으로 등록, 수정, 삭제한 엔티티를 DB에 반영한다. 다시 말해 쓰기 지연 SQL 저장소에 모인 쿼리를 DB에 보낸다. 이렇게 DB와 동기화한 후에 DB 트랜잭션을 커밋하게 된다.
- 엔티티 수정하기
JPA에서 엔티티를 수정할 때는 엔티티를 조회해서 데이터만 변경하면 된다. 트랜잭션 커밋 직전에 다른 메소드를 실행할 필요가 없다. 엔티티의 데이터만 변경해도 변경사항을 DB에 자동으로 반영하는 기능을 변경 감지(Dirty Checking)라고 한다.
아래는 영속성 컨텍스트 안에 member A와 member B가 있는 상황에서 플러시가 되었을 때 벌어지는 일을 나타낸 사진이다.
JPA는 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태를 복사해서 저장해둔다. 이것을 스냅샷이라고 한다. 플러시 시점에 스냅샷과 저장되어있는 엔티티를 비교해서 변경된 엔티티를 찾게된다.
위의 그림에서 먼저 트랜잭션을 커밋하면 엔티티 매니저에서 플러시가 호출된다. 그 다음 엔티티와 스냅샷을 비교해서 변경된 엔티티(member A)를 찾게 된다. 변경된 엔티티가 있으면 UPDATE 쿼리를 생성해서 쓰기 지연 SQL 저장소에 보낸다.
그런 다음 쓰기 지연 저장소의 SQL을 DB에 보내고 DB 트랜잭션을 커밋한다.
이 변경 감지는 영속성 컨텍스트가 관리하는 영속 상태의 엔티티에만 적용된다는 것에 주의해야한다.
- 엔티티 삭제하기
엔티티를 삭제하려면 먼저 삭제할 엔티티를 조회해야 한다. 그런 다음 remove() 메서드를 이용해 엔티티를 넘겨주면 엔티티를 삭제한다. 삭제할 때도 마찬가지로 쓰기 지연 SQL 저장소에 등록한 뒤 트랜잭션을 커밋할 때 DB에 반영되게 된다.
엔티티 생명 주기 사진에서도 볼 수 있듯이 remove()를 호출하는 순간 엔티티는 영속성 컨텍스트에서도 제거된다.
- 엔티티 병합(merge())
merge() 메서드를 이용하면 준영속 상태의 엔티티를 다시 영속 상태로 변경할 수 있다. merge() 메서드는 준영속 상태의 엔티티를 받아서 새로운 영속 상태의 엔티티를 반환한다.
아래는 merge()의 동작 방식을 그림으로 나타낸 것이다.
먼저 준영속 상태의 member라는 엔티티가 있다고 해보자. 이 member를 merge(member)하게 되면 먼저 1차 캐시에 해당 식별자 값(기본 키)으로 조회해서 엔티티를 찾게된다. 만약 1차 캐시에 해당 엔티티가 없다면 DB에서 조회한 다음 엔티티를 1차 캐시에 저장하게 된다.
그런 다음 1차 캐시에 있는 엔티티에 member 엔티티의 값을 모두 밀어넣는다. 그런 다음 새로운 엔티티(그림에서는 mergeMember)를 반환한다.
위와 같이 준영속 상태가 아니라 비영속 상태인 엔티티의 경우에는 1차 캐시에도 DB에도 당연히 식별자 값으로 조회가 불가능하기 때문에 새로운 엔티티를 생성해서 병합하게 된다.
1-2. Spring Data JPA의 주요 메서드들
Spring Data JPA는 JpaRepository<엔티티 클래스, 식별자 타입>을 상속받은 인터페이스만 만들어주는 것 만으로도 스프링 프레임워크 안에서 JPA를 편리하게 사용할 수 있다.
이렇게 인터페이스를 상속받으면 사용할 수 있는 메서드들 일부를 정리해보았다.
- save(): 새로운 엔티티는 저장하고 이미 있는 엔티티는 수정한다. save() 메서드는 엔티티에 식별자 값이 없으면 새로운 엔티티로 판단해서 persist()를 호출하고 식별자 값이 있으면 이미 있는 엔티티로 판단해서 merge()를 호출한다.
- delete(): 엔티티 하나를 삭제한다. 엔티티 매니저에서 remove() 메서드를 호출한다.
- findOne(): 엔티티 하나를 조회한다. 엔티티 매니저에서 find() 메서드를 호출한다.
- findAll(): 모든 엔티티를 조회한다. 정렬 조건이나 페이징 조건을 파라미터로 넣어줄 수 있다.
1-3. Spring 강의, 과제 다시 살펴보기
JPA의 동작방식을 어느 정도 이해하고 나니 이해가 가지 않았던 부분들이 지금와서 다시 보니 이해가 가는 것들이 하나하나 생기기 시작했다.
먼저 아래는 제출했던 과제에 서비스 레이어에 있던 엔티티를 새로 만들어 DB에 저장하는 함수이다. 피드백에는 불필요하게 @Transactional 어노테이션을 달았다고 해주셨는데 당시에는 잘 이해가 가지 않았다. 단순히 강의에서는 생성, 수정, 삭제 모두 @Transactional 어노테이션을 달아라라고만 나왔기 때문에 당연히 이렇게 해야하는 것이라고만 생각했었다.
@Transactional
override fun createTodo(request: CreateTodoRequest): TodoResponse {
val todo = Todo(
title = request.title,
name = request.name,
description = request.description
)
return todoRepository.save(todo).toResponse()
}
그러나 이제와서 다시 생각해보면 정말로 해당 어노테이션이 필요없다는 것을 알 수 있다. 먼저 함수안에서 저장할 엔티티 객체를 생성하게 된다. 그런 다음 save() 메서드를 이용하여 저장하게 된다. 바로 이 save() 메서드가 호출될 때 트랜잭션이 시작되고 엔티티가 persist()된 뒤 DB에 저장되게 되고 모든 동작이 끝난뒤 알아서 트랜잭션이 커밋되게 될 것이다. 따라서 함수 전체에 대해서 트랜잭션을 열고 닫을 필요가 없다.
다음은 강의에서 문제가 되었던 부분 중 하나이다. 영속성 전이를 이용해서 부모 엔티티를 저장했을 때 자식 엔티티도 같이 저장하려고 했는데 NPE가 발생하는 문제가 있었다. 당시에는 설명을 봐도 아무런 이해가 가지 않았었는데 지금은 바로 왜 이런 문제가 생겼는지 바로 파악할 수 있었다.
먼저 Course 엔티티와 Lecture 엔티티는 일대다 양방향 연관관계를 가지고 있고 Lecture 엔티티가 연관관계의 주인이다. 그리고 Course에 영속성 전이 ALL이 설정되어 있다.
아래는 새로운 Lecture를 생성하고 이를 Course를 save()하여 영속성 전이를 이용해 Lecture가 DB에 저장되는 것을 의도하고 작성한 코드이다.
@Transactional
override fun addLecture(courseId: Long, request: AddLectureRequest): LectureResponse {
// Course 엔티티를 DB에서 찾아온다.
val course = courseRepository.findByIdOrNull(courseId) ?: throw ModelNotFoundException("Course", courseId)
// 새로운 Lecture 엔티티를 생성한다.
val lecture = Lecture(
title = request.title,
videoUrl = request.videoUrl,
course = course
)
// course에 새로 생성한 lecture를 넣어준다.
course.lectures.add(lecture)
// course를 save()한다.
courseRepository.save(course)
// lecture를 Response DTO로 변환하여 반환한다.
return lecture.toResponse()
}
위의 과정을 차근차근 밟아보면 어디서 문제가 되는 지 알 수 있다.
먼저 courseId에 해당하는 엔티티를 DB에서 찾아와서 변수 course에 넣어주었다.
그런 다음 요청받은 값들을 이용해 새로운 Lecture 엔티티를 생성해 변수 lecture에 넣어주었다.
그런 다음 course에 있는 Lecture 리스트에 새로 생성한 lecture를 넣어주었다.
그런 다음 course를 save()한다. 이 때 lecture역시 저장되도록 하는 것이 목표이지만 그렇게 작동하지 않는다.
왜 그런지 이유를 살펴보면 다음과 같다. save() 메서드는 호출되면 해당 엔티티가 새로운 엔티티인지 아닌지를 먼저 판별한다. course는 이미 DB에 저장되어있던 엔티티이기 때문에 persist()가 아니라 merge()가 호출되게 된다.
따라서 course를 save()할 때 merge()가 수행되게 된다. 영속성 전이는 부모 엔티티의 동작이 그대로 전이되므로 새로 생성한 lecture가 persist()되는 것이 아니라 merge()가 되게 된다. 비영속 상태인 엔티티를 merge()하게 되면 새로운 엔티티를 생성하여 병합하게 된다.
따라서 DB에 저장되게 되는 엔티티는 lecture가 아니라 새로운 엔티티가 저장되게 된다. lecture는 영속성 컨텍스트 안에 들어온적이 없기 때문에 id값이 아직 null인 상태이다.(id의 생성을 DB에 위임하였다.) 아래와 같이 DTO로 변환하는 과정에서 id에는 실제로 null이 들어있기 때문에 DTO의 id 부분에 id!!을 넣어주게 되면 NPE가 발생한다.
fun Course.toResponse(): CourseResponse {
return CourseResponse(
id = id!!,
title = title,
description = description,
status = status.name,
maxApplicants = maxApplicants,
numApplicants = numApplicants
)
}
예외가 발생했기 때문에 위의 과정들은 모두 롤백되면서 DB에 값이 저장되지 않는다.
아마도 강의에서는 영속성 전이를 이용하여 저장하는 것을 보여주고 싶었던 것 같다. 사실 자식 엔티티를 그냥 저장하는 것으로도 문제를 쉽게 해결할 수 있을 것이고 실제로는 위와 같이 부모 엔티티를 이용해 자식 엔티티를 저장하는 경우는 많지 않을 것으로 생각된다.
다대일 양방향 관계에서 위와 같이 문제가 생길 수 있다는 점을 꼭 기억하고 주의해야겠다.
2. 오늘 배운 것
- Spring 과제를 진행하면서 새로운 것들을 더 알게 되었다.
- JPA에 대해 공부한 부분을 정리해보면서 머리 속으로도 더 정리가 잘 되게 된 것 같다. 내용이 워낙 많아 여기에 모든 내용을 다 적지는 못해서 아쉽기도 하다. 며칠에 걸려 공부한 보람도 있고 앞으로도 큰 도움이 될 것 같다.
'오늘 배운 것' 카테고리의 다른 글
24-05-25 알고리즘 문제 풀이 (0) | 2024.05.25 |
---|---|
24-05-24 Spring 개인 과제 최종 (0) | 2024.05.24 |
24-05-22 알고리즘 문제 풀이 (0) | 2024.05.22 |
24-05-21 알고리즘 문제 풀이 (0) | 2024.05.21 |
24-05-20 SQL 그룹 함수 (0) | 2024.05.20 |