오늘은 본격적으로 강의를 학습하는 주차가 시작되어 하루 종일 강의를 듣고 따라해보며 하루를 보냈다. 강의 듣는 것을 빨리 마치고 과제를 빨리 시작하는 것이 목표이다. 앞으로 부지런히 해야할 것 같다.
여기에는 강의를 들으면서 알게된 JPA의 동작방식에 대해 조금 정리해보았다.
1. JPA와 JPQL 쿼리
오늘은 강의를 듣다가 여태까지 JPA를 학습하면서 전혀 알지 못했던 내용을 알게 되었다.
바로 JPQL 쿼리를 이용해 요청을 하게 되면 flush()가 자동으로 호출된다는 것이었다.
그리고 기본 키를 이용한 조회를 제외하고 DATA JPA를 이용한 다른 모든 조회 요청 역시 JPQL 쿼리가 날라간다는 사실 또한 처음 알게 되었다.
JPA를 사용할 때 항상 SQL 쿼리가 어떻게 발생하는지 확인했었지만 위와 같은 사실들은 전혀 모르고 있었다.
그렇다면 위의 내용들이 왜 중요한지 차근차근 살펴보자
- JPQL 쿼리가 발생하기 전에 flush()가 발생하는 이유
JPQL 쿼리는 영속성 컨텍스트 안의 1차 캐시를 보지 않고 바로 DB에 SQL 쿼리를 날리게 된다. 만약 delete, update 쿼리문이 SQL 쓰기 지연 저장소에 쌓여있는 상황이라고 생각해보자. 이런 상황에서 findAll()을 이용해 모든 데이터를 조회한다고 해보자. findAll()을 사용하면 JPQL 쿼리가 발생하기 때문에 DB에서 바로 select 쿼리를 날리게 된다. 그런데 현재 DB의 데이터와 영속성 컨텍스트 내의 데이터는 flush()가 되기 전이기 때문에 데이터의 차이가 발생한다. 이런 문제를 해소하기 위해 JPQL 쿼리가 발생하기 전에 flush()가 되어 SQL 쓰기 지연 저장소에 쌓여있던 쿼리문들이 날라가 DB와 영속성 컨텍스트 사이의 데이터의 차이를 메꾸는 것이다.
직접 코드를 작성해서 확인해 볼 수도 있다.
한 트랜잭션 안에서 단건 조회(findById), 조회해온 엔티티 수정(update, 변경 감지 이용), 전체 조회(findAll), 단건 삭제(deleteById) 이런 순서로 코드를 작성해보았다. 그랬더니 다음과 같은 순서로 SQL 쿼리가 나갔다.
SELECT → UPDATE → SELECT → SELECT → DELETE
단건 조회 시에 SELECT 쿼리가 나가게 되고, 그 다음 엔티티는 수정하면 UPDATE 쿼리가 SQL 쓰기 지연 저장소에서 기다리게 된다. 그런 뒤에 전체 조회시 JPQL 쿼리가 발생하기 때문에 쓰기 지연 저장소에 있던 UPDATE 쿼리가 먼저 나가게 된다. 그런 다음 전체 조회 SELECT 쿼리가 나가고, 단건 삭제시에는 SELECT 쿼리로 엔티티를 가져와 1차 캐시에 저장해둔 다음에 DELETE 쿼리가 SQL 쓰기 지연 저장소에 저장되었다가 트랜잭션이 커밋되기 직전에 DELETE 쿼리가 나가게 된다.
- 문제가 발생하는 경우
그렇다면 위와 같은 이유 때문에 문제가 언제 발생하는지를 한 번 살펴보자
아래는 임의로 만든 함수이다.
@Transactional
fun functionTodos(): TodoResponse {
// 전체조회
todoRepository.findAll()
// ID가 2인 Todo 바로 삭제 (JPQL 쿼리)
todoRepository.deleteTodoByIdId(2L)
// ID가 2인 Todo 조회
val todo = todoRepository.findByIdOrNull(2L) ?: throw RuntimeException("Todo not found")
// 조회한 Todo Response로 반환
return TodoResponse.from(todo)
}
먼저 전체 조회를 실행하고 그 다음에 ID가 2인 Todo를 DB에서 삭제하였다. 그런 다음 ID가 2인 Todo를 조회한 뒤에 그 것을 DTO로 변환해 반환하는 함수이다.
여기서 deleteTodoByIdId는 JPQL로 Delete 쿼리가 나가는 함수로 임의로 만든 것이다. DATA JPA의 deleteById와 다르게 Delete 쿼리 이전에 Select 쿼리가 발생하지 않는다.
@Repository
interface TodoRepository : JpaRepository<Todo, Long> {
@Modifying
@Transactional
@Query("DELETE FROM Todo todo WHERE todo.id = :id")
fun deleteTodoByIdId(id: Long)
}
위와 같은 함수를 실행하면 당연히 ID가 2인 Todo를 삭제한 뒤 조회했기 때문에 예외가 발생할 것이라고 생각하기 쉽다. 하지만 실제로 실행해보면 삭제하기 이전 ID가 2인 Todo가 조회된다.
그 과정을 자세히 살펴보면 다음과 같다.
먼저 findAll()로 모든 Todo를 조회해온 뒤 1차 캐시에 저장된다. 그런 다음 ID가 2인 Todo를 삭제를 하게 되면 JPQL 쿼리가 나가기 때문에 1차 캐시는 아무런 영향을 받지 않고 DB로 바로 Delete 쿼리가 날아가게 된다. 따라서 DB에는 ID가 2인 Todo가 삭제된 상황이고, 1차 캐시에는 그대로 저장되어 있는 상태이다.
그런 상황에서 다시 findById를 이용해 기본키로 ID가 2인 Todo를 조회하면 JPA는 1차 캐시를 먼저 뒤지기 때문에 1차 캐시에 저장되어 있던 엔티티를 찾아오게 된다. 그렇기 때문에 DB에서는 삭제되었지만 삭제하기 이전 Todo 엔티티를 반환받게 되는 것이다.
이번에는 또 다른 경우를 살펴보자.
@Transactional
fun functionTodos(): TodoResponse {
// 전체조회
todoRepository.findAll()
// ID가 2인 Todo 업데이트 (JPQL 쿼리)
todoRepository.updateTodoById("메롱",2L)
// 전체 조회를 통해 ID가 2인 Todo만 가져오기
val todo = todoRepository.findAll().first {it.id == 2L}
// 조회한 Todo Response로 반환
return TodoResponse.from(todo)
}
전체 조회 후에 하나의 엔티티를 DB로 직접가서 데이터를 수정 하고 전체 조회를 다시 수행한 뒤 수정한 엔티티를 DTO로 변환해서 반환하게 된다.
@Repository
interface TodoRepository : JpaRepository<Todo, Long> {
@Modifying
@Query("UPDATE Todo todo SET todo.contents = :contents WHERE todo.id = :todoId")
fun updateTodoById(contents: String, todoId: Long)
}
위는 JPQL로 작성한 업데이트 쿼리가 날라가는 함수이다.
결과만 먼저 말하면 ID가 2L인 todo를 DTO로 변환하여 반환된 것을 보면 내가 수정한 값인 "메롱"이 들어가있지 않고 이전에 원래 들어있던 값이 그대로 들어있게된다.
왜 이런 결과가 나왔는지 과정을 살펴보면 다음과 같다. 먼저 전체 조회로 모든 엔티티들을 1차 캐시에 저장해두게 된다. 그런 다음 업데이트 쿼리를 DB로 직접 날려 DB에 있는 값이 변경되게 된다. 그런 다음 다시 전체조회를 DB로 가서 해오면 1차 캐시에 있는 값들이 DB의 값으로 업데이트 될 것이라고 생각하기 쉽지만 실제로는 이미 1차 캐시에 존재하는 것들은 업데이트가 되지 않는다. 따라서 업데하기 이전 1차 캐시에 저장되있는 값이 튀어나오게 되는 것이다.
위와 같은 문제 해결은 어렵지 않다.
아래처럼 @Modifying 어노테이션에 (clearAutomatically = true)를 달아주면 자동으로 함수가 실행된 뒤에 1차 캐시를 비워준다.
@Repository
interface TodoRepository : JpaRepository<Todo, Long> {
@Modifying(clearAutomatically = true)
@Transactional
@Query("DELETE FROM Todo todo WHERE todo.id = :id")
fun deleteTodoByIdId(id: Long)
@Modifying(clearAutomatically = true)
@Query("UPDATE Todo todo SET todo.contents = :contents WHERE todo.id = :todoId")
fun updateTodoById(contents: String, todoId: Long)
}
1차 캐시를 비워주고 나면 조회를 진행했을 때 DB로부터 다시 값을 찾아와야하기 때문에 DB와 1차 캐시 간의 정보의 차이가 발생하지 않는다.
이런 내용들은 JPA의 동작방식에 대한 이해가 없으면 문제를 발견하기도 쉽지 않고 해결하는데에도 시간이 크게 걸릴 것이다. 사실 위와 같이 복잡한 로직을 가진 서비스 쪽 코드를 작성해 본 적이 없기 때문에 위와 같은 상황을 겪어본 적은 없다. 하지만 이런 위험이 도사리고 있다라는 것을 꼭 명심하고 앞으로 코드를 작성해야할 것 같다.
2. 오늘 배운 것
- JPA에 대해서 깊게 더 잘 알게 된 것 같다. 과제나 프로젝트에 잘 적용해볼 수 있도록 노력해봐야겠다.
'오늘 배운 것' 카테고리의 다른 글
24-06-21 네트워크 관련 지식 (0) | 2024.06.21 |
---|---|
24-06-20 이진 탐색 알고리즘 (0) | 2024.06.20 |
24-06-18 프로젝트 마지막 날 회고 (0) | 2024.06.18 |
24-06-17 알고리즘 문제 풀이 (0) | 2024.06.17 |
24-06-16 알고리즘 문제 풀이 (0) | 2024.06.16 |