JPA에서는 DB의 테이블와 연결되어 있는 객체 즉, 엔티티를 사용할 때 영속성 컨텍스트라는 것을 이용해서 최적화를 한다. 조회, 수정 등을 한다고 해서 즉시 DB에서 결과를 받아오는 것이 아니다. (근데 또 경우에 따라 다르다...)
영속성 컨텍스트는 마치 캐시처럼 작동한다. 데이터 조회를 예로 들면, 먼저 영속성 컨텍스트에 내가 원하는 객체가 있는지 확인한다. 만약 있다면 그대로 그걸 가져다 사용하고, 없다면 DB에서 영속성 컨텍스트로 해당 객체를 가져와 사용한다.
Redis의 Cache-Aside 방식과 비슷하다고 보면 된다.
그럼 이제 영속성 컨텍스트가 CRUD 각 상황에서 어떤 방식으로 동작하는지 알아보자.
설정
logging.level.org.springframework.transaction.interceptor=TRACE
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
#JPA log
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.resource.transaction=DEBUG
#JPA SQL
logging.level.org.hibernate.SQL=DEBUG
하이버네이트와 트랜잭션 매너저가 어떻게 동작하는지 살펴보기 위해 위의 코드를 application.properties에 작성해주자.

DB는 단순 테스트만 할 예정이라 인메모리 H2를 사용한다. DataSource 정보를 지정하지 않으면 자동으로 해당 DB가 사용되니 따로 설정을 할 필요는 없다. 다만 H2 의존성 라이브러리는 미리 받아두어야 한다.
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Post {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String body;
}
테스트에 사용되는 Post 엔티티는 위와 같다.
영속성 컨텍스트란?
영속성 컨텍스트란 엔티티를 보관하고 있는 장소이다. 앞서 말한 것처럼 캐시 역할을 한다고 보면 된다. EntityManager를 통해 영속성 컨텍스트에 접근할 수 있다.
생성
commit 되기 전에는 영속성 컨텍스트의 쓰기 지연 SQL 저장소 내에 저장되고 commit 시점에 insert 쿼리가 실행된다.
@Autowired EntityManager em;
@Autowired PlatformTransactionManager tm;
@Autowired DataSource dataSource;
@Test
void newPostTest() {
// given
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
Post post = Post.builder()
.title("첫 번째 글")
.body("반가워요")
.build();
// when
log.info("----- 트랜잭션 시작 -----");
TransactionStatus status = tm.getTransaction(new DefaultTransactionDefinition());
em.persist(post); // 저장
// 실제로 insert문이 바로 실행되는지 아닌지 검증
String sql = "SELECT * FROM post WHERE id = 1;";
Post findPost = jdbcTemplate.queryForObject(sql, (rs, rowNum) ->
Post.builder()
.id(rs.getLong("id"))
.title(rs.getString("title"))
.body(rs.getString("body"))
.build()
);
log.info("findPost = {}", findPost);
tm.commit(status);
log.info("----- 트랜잭션 종료 -----");
// then
// commit 전에 DB를 조회하면 아직 데이터가 없을 것이다.
Assertions.assertThat(findPost).isNull();
}

게시글을 하나 만들어서 저장하는 테스트이다. JPA의 실행 과정은 트랜잭션 안에서 이루어져야하기 때문에 트랜잭션을 감쌌다. commit 하기 전에는 insert 쿼리가 영속성 컨텍스트에서 실행되기를 대기하고 있다고 한다. (트랜잭션을 지원하는 쓰기 지연) 그래서 직접 정말인지 실험해보았다. 과정은 이렇다.
- 게시글을 하나 새로 만든다.
- persist() 메서드로 저장한다.
- jdbcTemplate으로 commit() 하기 전에 데이터베이스에 해당 데이터가 존재하는지 확인한다.
- commit() 한다. -> 이제 insert 될 것으로 예상
- insert 되기 전에 jdbcTemplate을 사용해서 데이터를 가져왔다면 데이터를 찾지 못했을 것이다. 그러므로 null이어야 한다.
이렇게 실험해봤는데... insert문이 persist() 메서드가 호출될 때 실행되는 것 같다. 책에서는 이전에는 insert되지 않는다고 했는데 원인을 모르겠다. 같은 인스턴스 저장을 한번에 여러 줄 해봐도 insert문은 하나만 나가고, 스프링에서 제공하는 트랜잭션 때문인가 해서 EntityManager를 이용한 트랜잭션으로 바꿔봤는데 마찬가지였다.
ㄴ 글을 쓰다가 갑자기 책에서 봤던 내용이 떠올랐다! 질의문으로 조회하는 행위가 있으면 JPA는 그 즉시 생성한 결과를 DB에 반영한다. 조회를 했을 때 생성한 내용이 있어야 하기 때문이다. 그래서 tcp 연결을 통한 H2 DB를 따로 만들어서 중간에 중단점을 걸어 확인해봤다. 나중에 다시 확인해보니 JPQL 같은 객체 지향 쿼리를 사용할 때였다;; 나는 JdbcTemplate을 이용해서 조회했으니 위의 경우와 다르다.


아직은 결과가 없는 것을 확인할 수 있다.

조회 쿼리를 실행한 후에 생성 내용이 반영되는 것을 알 수 있다!
글을 고칠 수도 있지만 내가 겪은 문제를 생생하게(?) 전달하기 위해 그대로 놔두도록 하겠다...
그래서 결론은 조회 쿼리나 커밋하게 되기 전까지는 영속성 컨텍스트에만 해당 정보들을 가지고 있고 해당 행동들 뒤에 DB에 내용이 반영된다.
추가로 엔티티가 영속 상태가 되기 위해서는 식별자가 반드시 필요한데 @GeneratedValue의 strategy 값을 GenerationType.IDENTITY로 설정했을 경우, 일단 DB로 엔티티를 저장하고 식별자 값을 받아오는 과정을 거치기 때문에 persist() 후에 바로 insert문을 실행한다고 한다.
조회
객체를 persist()로 저장하고 EntityManager의 find()로 조회 시 영속성 컨텍스트에 있는 정보를 가져온다. (직접 질의문 사용할 때는 아닐 수 있음)
@Autowired EntityManager em;
@Autowired PlatformTransactionManager tm;
@Test
void findPostTest() {
// given
Post post = Post.builder()
.title("첫 번째 글")
.body("반가워요")
.build();
// when
log.info("----- 트랜잭션 시작 -----");
TransactionStatus status = tm.getTransaction(new DefaultTransactionDefinition());
em.persist(post); // 저장
Post findPost = em.find(Post.class, 1); // 조회
tm.commit(status);
log.info("----- 트랜잭션 종료 -----");
// then
findPost = em.find(Post.class, 1); // 조회
}

저장 후 조회를 할 때, 영속성 컨텍스트가 종료되기 전에는 캐시된 객체를 불러올 것이기 때문에 select문을 사용하지 않을 것이다. 과연 그럴까?
실행 결과를 보면 insert문 밑에 select문이 없는 것을 알 수 있다. DB에서 가져온 데이터가 아니라 영속성 컨텍스트에 저장되어 있는 데이터를 가져온 것이다. commit() 이후에는 EntityManager가 종료되어서 select문으로 DB에서 직접 데이터를 가져오는 것을 알 수 있다.
@Autowired EntityManager em;
@Autowired PlatformTransactionManager tm;
@Test
void findPostTest() {
// given
Post post = Post.builder()
.title("첫 번째 글")
.body("반가워요")
.build();
// when
log.info("----- 트랜잭션 시작 -----");
TransactionStatus status = tm.getTransaction(new DefaultTransactionDefinition());
em.persist(post); // 저장
em.clear(); // 영속성 컨텍스트를 비운다!
Post findPost = em.find(Post.class, 1); // 조회
findPost = em.find(Post.class, 1); // 조회
tm.commit(status);
log.info("----- 트랜잭션 종료 -----");
// then
findPost = em.find(Post.class, 1); // 조회
}

그럼 persist() 한 뒤 바로 EntityManager를 비우면 어떻게될까?
영속성 컨텍스트가 비어 있는 상태로 변해서 insert문 바로 밑 줄을 보면 DB에서 직접 select문을 실행해 데이터를 가져오는 것을 알 수 있다. 그 다음 줄에서도 조회를 하고 있지만 이미 한번 DB에서 데이터를 받았기 때문에 영속성 컨텍스트에 해당 데이터가 저장되어 있을 것이다. 해당 데이터를 가져다 사용하기 때문에 질의문을 날리지 않았다.
수정
객체를 persist()로 저장하고 수정할 때, 커밋하기 전이면 생성과 수정 모두 DB에 반영이 안 된다.
@Autowired EntityManager em;
@Autowired PlatformTransactionManager tm;
@Autowired DataSource dataSource;
@Test
void updatePostTest() {
// given
JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
Post post = Post.builder()
.title("첫 번째 글")
.body("반가워요")
.build();
// when
log.info("----- 트랜잭션 시작 -----");
TransactionStatus status = tm.getTransaction(new DefaultTransactionDefinition());
em.persist(post); // 저장
post.setTitle("수정된 글"); // 수정
// DB의 데이터 가져옴
String sql = "SELECT * FROM post WHERE title like '첫 번째 글'";
Post findPost = jdbcTemplate.queryForObject(sql, (rs, rowNum) ->
Post.builder()
.id(rs.getLong("id"))
.title(rs.getString("title"))
.body(rs.getString("body"))
.build()
);
// then
// 아직 커밋하기 전이니 DB에 수정 사항이 반영되어있지 않아야 함
log.info("post.title = {}, findPost.title = {}", post.getTitle(), findPost.getTitle());
Assertions.assertThat(post.getTitle().equals(findPost.getTitle())).isFalse();
tm.commit(status);
log.info("----- 트랜잭션 종료 -----");
sql = "SELECT * FROM post WHERE id = ?";
findPost = jdbcTemplate.queryForObject(sql, (rs, rowNum) ->
Post.builder()
.id(rs.getLong("id"))
.title(rs.getString("title"))
.body(rs.getString("body"))
.build()
, post.getId());
// 커밋 했으니 DB에 수정 사항이 반영되어 있어야 함
log.info("post.title = {}, findPost.title = {}", post.getTitle(), findPost.getTitle());
Assertions.assertThat(post.getTitle().equals(findPost.getTitle())).isTrue();
}

앞서 살펴봤듯이 저장을 하고 바로 SQL문을 이용하여 조회를 하면 해당 건은 바로 DB에 반영이 된다. 그렇기 때문에 첫 번째 비교에서 수정한 내용과 DB의 내용이 다르다는 것을 알 수 있고, commit() 한 이후는 수정한 내용이 반영되기 때문에 DB에 수정한 내용이 적용된 것을 알 수 있다.
삭제
remove()를 호출하고 커밋하기 전이면 DB에 반영되지 않는다.
@Autowired EntityManager em;
@Autowired PlatformTransactionManager tm;
@Test
void removePostTest() {
// given
log.info("----- 트랜잭션 시작 -----");
TransactionStatus status = tm.getTransaction(new DefaultTransactionDefinition());
Post post = em.find(Post.class, 17); // 조회
// when
em.remove(post); // 삭제
tm.commit(status);
log.info("----- 트랜잭션 종료 -----");
// then
Assertions.assertThat(em.find(Post.class, 17)).isNull();
}


딱 커밋하기 전에 중단하도록 만들었다. 기존에 있던 id 17인 데이터를 그대로 사용하였다. 그리고 17번을 찾아 remove()를 이용해 삭제하도록 했다. 하지만 아직 commit()을 호출하지 않았기 때문에 DB에는 반영되지 않았다.

커밋하고 나니까 delete문이 실행된 것을 볼 수 있다.
삭제도 반영된 것을 알 수 있다.
마치면서...
책을 읽으면서 공부할 때는 잘 이해가 되는 듯 했지만 직접 실행해보니까 헷갈리는 부분이 너무 많다. 머릿속으로는 이해하고 있다고 생각하지만 세세한 부분에서 디테일을 좀 신경써야할 것 같다.
'공부 > JPA' 카테고리의 다른 글
| [JPA] OneToOne에서의 N + 1 문제 (0) | 2024.02.27 |
|---|---|
| [JPA] 프록시 (Proxy) (0) | 2024.02.24 |
| [JPA] 다대다 연관 관계에서 복합키 사용하기 (0) | 2024.02.21 |
| [JPA] 연관 관계 중 인조키 vs 복합키 에 대한 고민 (0) | 2024.02.20 |
| [JPA] 연관 관계 매핑 확인하기 (0) | 2024.02.16 |