JPA 변경 감지와 스프링 데이터

JPAJava Persistence API는 엔티티에 변경이 일어나면 이를 감지하여 자동으로 데이터베이스에 반영한다. 이런 특징에 익숙지 않은 상태에서 스프링 데이터Spring Data JPA를 사용하다 보면 예상치 못한 지점에서 SQL update 구문을 보는 경우가 있다.

이 글은 JPA 변경 감지가 무엇이고 스프링 데이터 JPA와는 어떤 관계가 있는지 설명한다.

JPA 변경 감지Dirty Checking

JPA는 엔티티 매니저Entity Manager가 엔티티를 조회/저장/삭제/수정한다. 엔티티 매니저의 API를 살펴보면 조회(find), 저장(persist), 삭제(remove)는 제공하지만 이상하게도 수정 API는 찾아볼 수 없다. 그 이유는 엔티티 매니저가 엔티티가 변경이 일어나면 이를 자동 감지하여 데이터베이스에 반영하기 때문인데 이것을 변경 감지라고 한다.

간단한 JPA 코드로 확인해 보자.

1
2
3
4
5
6
7
8
9
10
EntityManager entityManager = entityManagerFactory.createEntityManager();
EntityTransaction transaction = entityManager.getTransaction();
// 트랜잭션 시작
transaction.begin();
// 엔티티 조회
DataSource dataSource = entityManager.find(DataSource.class, 1l);
// 엔티티 변경
dataSource.setName("changed name..");
// 트랜잭션 커밋
transaction.commit();

image2018-11-15_9-59-7

명시적으로 update 같은 것을 호출해 주지 않아도 수정한 엔티티가 데이터베이스에 반영되었다. 왜냐하면 dataSource.setName()을 통해 엔티티를 변경했고 이를 엔티티 매니저가 감지하여 트랜잭션을 커밋(transaction.commit()) 시점에 데이터베이스에 반영한 것이다. 엔티티 매니저는 트랜잭션을 커밋 하거나 엔티티 매니저의 flush 메서드를 호출하게 되면 변경 사항을 데이터베이스에 반영한다. 

다만 변경 감지는 영속성 컨텍스트Persistence Context가 관리하는 영속 상태의 엔티티에게만 해당한다. 이 글에서 영속성 컨텍스트나 생명 주기에 대해 다루지 않는다. 이에 대한 설명은 김영한 님의 발표 자료에 잘 나와 있다.

스프링 데이터 JPA

일반적으로 애플리케이션에서 데이터베이스에 접근할 때에는 별도의 계층을 두고 해당 계층에서는 흔히 말하는 CRUDCreate, Read, Update and Delete(생성, 조회, 수정, 삭제)를 제공한다. 스프링 프레임워크Spring Framework에서는 리파지토리를 JPA로 아래와 같이 구현하여 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Item {
  @Id
  @GeneratedValue
  private Long id;
  //...
}
@Repository
public class ItemRepository {
  @PersistenceContext
  private EntityManager entityManager;
  @Transactional
  public void save(Item item) { entityManager.persist(item); }
  public void delete(Long id) { ... }
  public Item findOne(Long id) { ... }
  // ...
}

문제는 비슷한 코드가 반복된다는 것이다. 예를 들어 Order 엔티티를 추가하는 경우 OrderRepository를 만들고 ItemRepository와 유사한 CRUD 코드를 만들어야 한다.

스프링 데이터 JPA는 반복되는 코드 없이 쉽게 JPA 리파지토리를 만들 수 있다. 리파지토리를 만들 때 인터페이스 구현체 없이 아래처럼 인터페이스만 상속한다.

1
2
3
4
5
import org.springframework.data.jpa.repository.JpaRepository;
public interface ItemRepository extends JpaRepository<Item, Long> {
}
public interface OrderRepository extends JpaRepository<Order, Long> {
}

JPA 변경 감지와 스프링 데이터

연관 관계가 없는 2개(DataSource, IngestionHistory) JPA 엔티티와 스프링 데이터 JpaRepository를 상속하여 만든 리파지토리가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import javax.persistence.Entity;
@Entity
public class DataSource {
  @GeneratedValue
  private Long id;
  private String ingestionInfo;
  // ...
}
@Entity
public class IngestionHistory {
  @GeneratedValue
  private Long id;
  // ...
}
import org.springframework.data.jpa.repository.JpaRepository;
public interface IngestionHistoryRepository extends JpaRepository<IngestionHistory, Long> {
}
public interface DataSourceRepository extends JpaRepository<DataSource, Long> {
}

스프링 컨트롤러(DataSourceController)에서는 DataSourceRepository로 DataSource 엔티티를 조회하고 필드 값을 변경한다. 변경한 엔티티를 스프링 서비스(EngineIngestionService)로 위임하는데 서비스 내부에서는 처리 결과로 IngestionHistory를 반환할 뿐 리파지토리를 사용하지 않는다.  마지막으로 반환받은 IngestionHistory 엔티티를 IngestionHistoryRepository로 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RepositoryRestController
public class DataSourceController {
  @Autowired
  DataSourceRepository dataSourceRepository;
  @Autowired
  IngestionHistoryRepository ingestionHistoryRepository;
  @Autowired
  EngineIngestionService engineIngestionService;
  @RequestMapping(path = "/datasources/{id}/data", method = { RequestMethod.PUT })
  public @ResponseBody ResponseEntity<?> appendDataSource(
      @PathVariable("id") String id,
      @RequestBody String ingestionInfo) {
    // 1. 엔티티 조회
    DataSource dataSource = dataSourceRepository.findOne(id);
    // 2. 엔티티 변경
    dataSource.setIngestionInfo(ingestionInfo);
    // 3. 스프링 서비스 호출
    Optional<IngestionHistory> ingestionHistroy = engineIngestionService.doIngestion(dataSource);
    // 4. 호출 결과 저장
    ingestionHistoryRepository.save(ingestionHistroy.get());
    return ResponseEntity.noContent().build();
  }
}

코드를 실행하면 데이터베이스 테이블에는 어떤 변화가 있을까?

image2018-11-8_7-54-46

JPA가 DataSource의 변경을 감지하여 마지막에 update을 실행하여 데이터베이스에 반영하였다.

스프링 프레임워크 같은 컨테이너 환경에서 사용하는 경우 엔티티 매니저가 여러 개 존재할 수 있는데 변경 감지는 엔티티 매니저별로 수행한다.

같은 스레드Thread에서 스프링 데이터가 제공하는 리파지토리들은 하나의 엔티티 매니저를 공유한다. 그래서 위의 코드에서 두 개의 리파지토리가 사용하는 엔티티 매니저는 동일하다. 

image2018-11-9_8-42-16

위의 코드를 그림으로 나타내면 아래와 같다.

스크린샷 2018-11-15 오전 11.52.32

트랜잭션 커밋은 어디에서?

앞서 리파지토리를 만들 때 스프링 데이터 JPA의 JpaRepository 인터페이스만 상속하였다. 스프링 데이터에서 기본 구현체를 제공해 주기 때문이다.

1
2
3
4
5
import org.springframework.data.jpa.repository.JpaRepository;
public interface IngestionHistoryRepository extends JpaRepository<IngestionHistory, Long> {
}
public interface DataSourceRepository extends JpaRepository<DataSource, Long> {
}

스프링 데이터 JPA에서 제공하는 JpaRepository 인터페이스의 기본 구현체는 SimpleJpaRepository이다. SimpleJpaRepository의 save 메서드에는 스프링 Transactional 어노테이션이 붙어 있다. 이 의미는 이미 트랜잭션이 중이라면 참여하고 트랜잭션이 없다면 트랜잭션을 새로 시작하라는 의미이다.

image2018-10-23_8-26-53

결국 DataSourceController는 트랜잭션이 없기 때문에 ingestionHistoryRepository.save를 수행하면 트랜잭션이 시작되고 오류가 없으면 트랜잭션이 자동으로 커밋한 것이다.

코드 순서를 변경해 보면...

컨트롤러 코드 순서를 좀 바꿔보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RepositoryRestController
public class DataSourceController {
  // ...
  @RequestMapping(path = "/datasources/{id}/data", method = { RequestMethod.PUT })
  public @ResponseBody ResponseEntity<?> appendDataSource(
      @PathVariable("id") String id,
      @RequestBody String ingestionInfo) {
    DataSource dataSource = dataSourceRepository.findOne(id);
    Optional<IngestionHistory> ingestionHistroy = engineIngestionService.doIngestion(dataSource);
    ingestionHistoryRepository.save(ingestionHistroy.get()); // 트랜잭션 시작 및 종료 커밋됨
    // 코드 위치 변경
    dataSource.setIngestionInfo(ingestionInfo);
    return ResponseEntity.noContent().build();
  }
}

코드를 실행해 보면 이전과 달리 update가 일어나지 않는다.

image2018-10-25_21-23-33

그 이유는 앞서 언급했던 것처럼 엔티티 매니저의 데이터 베이스 반영 시점이 트랜잭션을 커밋 하거나 엔티티 매니저의 flush 메소드를 호출하는 경우이기 때문이다. 위의 코드에서는 dataSource를 변경하기 이전에 이미 커밋(ingestionHistoryRepository.save) 되었다.

참고 자료


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.