Spring data jpa insert update 방법

조급하면 모래성이 될뿐

문제 상황


  • 설계한 Entity의 id가 Auto Increament값이 아니다.
  • 생성자가 호출되는 시점에 fk의 조합으로 생성된다.
    • makeReservedSeatId 함수에서 만들어진다.
@Entity
@Table(name = "reserved_seat")
public class ReservedSeat {

	public static final String ID_SEPARATOR = "_";

	@Id
	@Column(name = "id")
	private String id;
    
    ...
    
	private void makeReservedSeatId(Long scheduleId, Long seatId) {
		this.id = new StringBuilder()
			.append(scheduleId)
			.append(ID_SEPARATOR)
			.append(seatId)
			.toString();
	}

	public ReservedSeat(Ticket ticket, Seat seat) {
		makeReservedSeatId(ticket.getSchedule().getId(), seat.getId());
		this.ticket = ticket;
		this.seat = seat;
	}
}
  • 그리고 아무것도 안 하고 ReservedSeat을 save만 하는데 select 쿼리가 날아갔다.
	@Test
	@DisplayName("insert 하기 전에 select가 나간다.")
	void testInsertBeforeSelect() {
		Ticket ticket = saveTicket();
		Seat seat = saveSeat();

		ReservedSeat reservedSeat = new ReservedSeat(ticket, seat);

		log.info("=== 1 ===");
		reservedSeatRepository.save(reservedSeat);
		log.info("=== 2 ===");
	}
Spring data jpa insert update 방법
  • 보면 reservedSeat을 insert 하기 전에.. reservedSeat에 select가 날아갔다...

문제의 원인


  • 보통 auto increament로 id를 지정하게 되면 id값이 insert 할 때 생성된다.
  • 하지만 지금의 경우는, insert 하기 전에 id값이 결정된다.
  • 그래서 Spring Data JPA를 통해 save 할 때, @id 필드에 값을 보고 DB에 존재하는 데이터로 간주한다.
  • 그다음 update항목이 있는지 확인을 위해 select를 실행하는 것이다.
  • 하지만 저장된 게 없으니 이후 insert 또한 날아간다.
데이터가 많아질수록 성능도 저하될 것이다. 1억 개를 저장하려면 1억 번의 select가 나간다..

save 처리방식


  • CrudRepository 구현체인 SimpleJpaRepository를 보면 save는 아래와 같이 처리된다.
	@Transactional
	@Override
	public <S extends T> S save(S entity) {
		Assert.notNull(entity, "Entity must not be null.");

		if (entityInformation.isNew(entity)) {
			em.persist(entity);
			return entity;
		} else {
			return em.merge(entity);
		}
	}
  • 새로운 객체 isNew(entity) = true 이면 persist
  • 아니면 merge 한다
    • merge는 한번 persist -> detach -> persist상태가 될 때를 의미한다.
    • 참조

해결방법


  • isNew조건에 타게 하면 된다.
  • isNew이 true가 되는 기준을 id 존재 여부가 아닌
    • 영속성 콘텍스트에서 조회되지 않았을 때
    • 영속 상태가 되기 전
  • 으로 변경하면 된다.

isNew라는 상태 값을 하나 만들고 default값을 true로 준다. 그러면 처음 객체를 만들 때는 isTrue에 탈것이다.

그다음

  • 영속성 콘텍스트에서 조회되지 않았을 때를 판단하기 위해
    • 영속성 컨텍스트에서 조회가 되었다면? 상태를 false로 바꾼다.
  • 영속 상태가 되기 전을 판단하기 위해
    • 영속 상태가 되기 직전에 상태를 false로 바꾼다.

첫 번째를 처리할 수 있는 어노테이션이 @PostLoad 두 번째가 @PrePersist이다.

즉, 상태를 false로 변경하는 메서드를 추가한다.

다음으로 isNew의 기준을 변경하기 위해 Persistable 인터페이스를 구현해서, 오버 라이딩해준다.

추가적으로 해당 인터페이스를 구현함으로써 getId함수도 오버 라이딩해주면 된다.

최종 코드는 아래와 같다.

@Entity
@Table(name = "reserved_seat")
public class ReservedSeat implements Persistable<String> {
	@Id
	@Column(name = "id")
	private String id;

	...

	@Transient
	private boolean isNew = true;

	protected ReservedSeat() {
	}

	private void makeReservedSeatId(Long scheduleId, Long seatId) {
		this.id = new StringBuilder()
			.append(scheduleId)
			.append(ID_SEPARATOR)
			.append(seatId)
			.toString();
	}

	public ReservedSeat(Ticket ticket, Seat seat) {
		makeReservedSeatId(ticket.getSchedule().getId(), seat.getId());
		this.ticket = ticket;
		this.seat = seat;
	}

	@Override
	public boolean isNew() {
		return isNew;
	}

	@PrePersist
	@PostLoad
	void markNotNew() {
		this.isNew = false;
	}

	@Override
	public String getId() {
		return this.id;
	}
    
    ...

}

참조


  • Spring Data JPA에서 Insert 전에 Select 하는 이유
  • @PrePersist 
  • @PostLoad

Spring data jpa insert update 방법

모든 소스 코드는 여기에서 확인 가능합니다.

JPA는 보통 데이터를 가져와서 변경하면 변경 감지(dirty checking)를 통해 DB에 업데이트 퀴리를 수행합니다.

이런 업데이트들은 건 별로 select 이후 update가 이루어지기 때문에 수천 건을 업데이트 해야하는 경우 비효율적일 수 있습니다.

JPA를 사용해서도 수 천, 수 만 건의 데이터를 한 번에 업데이트 하는 벌크 업데이트(Bulk Update)쿼리를 사용할 수 있습니다.

@Modifying 애너테이션

벌크 업데이트를 하기 위해선 @Query와 함께 @Modifying 애너테이션을 사용해야 합니다.

나이가 N살 이상인 전체 회원의 나이를 1씩 증가시켜야 한다는 요구사항이 존재한다고 가정하고 이를 구현한 소스 코드 입니다.

package io.lcalmsky.springdatajpa.domain.repository;

import io.lcalmsky.springdatajpa.domain.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    @Modifying
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int increaseAgeOfAllMembersOver(@Param("age") int age);
}

이런식으로 전체 업데이트 쿼리를 작성할 수 있습니다.

이 때 @Modifying 애너테이션을 누락시키면 아래와 같은 에러가 발생합니다.

org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations 

DML operation을 지원하지 않는다는 내용인데요, JPA 기본 동작이 select - update로 이루어져있기 때문에 바로 update만 하겠다고 알려주는 애너테이션이 바로 @Modifying 애너테이션 입니다.

@Modifying 애너테이션을 사용할 때 주의할 점은 반환 타입을 반드시 voidint 또는 Integer로 지정해야 한다는 것 입니다.

만약 long같은 다른 타입으로 지정하게 되면 다음과 같은 에러가 발생합니다.

org.springframework.dao.InvalidDataAccessApiUsageException: Modifying queries can only use void or int/Integer as return type! Offending method: public abstract long io.lcalmsky.springdatajpa.domain.repository.MemberRepository.increaseAgeOfAllMembersOver(int)

혹시나 위의 두 가지 에러가 발생하더라도 로그에서 친절하게 설명해주기 때문에 간단한 수정을 통해 정상동작 확인 가능합니다.

테스트

간단한 테스트 코드를 통해 확인해보겠습니다.

package io.lcalmsky.springdatajpa.domain.repository;

import io.lcalmsky.springdatajpa.domain.entity.Member;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import javax.transaction.Transactional;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
class MemberRepositoryTest {
    @Autowired
    MemberRepository memberRepository;

    @Test
    @DisplayName("벌크 업데이트 테스트: 나이가 n살 이상인 멤버의 나이를 1씩 증가시킨다")
    @Transactional
    public void bulkUpdate() {
        // given
        memberRepository.save(new Member("a", 10));
        memberRepository.save(new Member("b", 15));
        memberRepository.save(new Member("c", 20));
        memberRepository.save(new Member("d", 30));
        memberRepository.save(new Member("e", 40));

        // when
        int count = memberRepository.increaseAgeOfAllMembersOver(20);

        // then
        assertEquals(3, count); // (1)
    }
}

(1) 20살 이상되는 회원은 3명이기 때문에 업데이트 된 row의 수가 3이어야 합니다.

여기서 주의할 점은 DML 쿼리이기 때문에 반드시 @Transactional 애너테이션을 사용해야 한다는 것 입니다.

@Transactional 애너테이션 없이 테스트를 수행하게되면 아래와 같은 에러가 발생합니다.

org.springframework.dao.InvalidDataAccessApiUsageException: Executing an update/delete query; nested exception is javax.persistence.TransactionRequiredException: Executing an update/delete query

여기서도 Exception 이름이 TransactionRequiredException 이기 때문에 '아 @Transactional을 빼먹었구나!' 하고 바로 수정할 수 있습니다.

그렇다면 정상 수행시 로그를 확인해보겠습니다.

2021-07-01 20:17:49.958 DEBUG 76312 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        member
        (age, team_id, username, member_id) 
    values
        (?, ?, ?, ?)
2021-07-01 20:17:49.962 DEBUG 76312 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        member
        (age, team_id, username, member_id) 
    values
        (?, ?, ?, ?)
2021-07-01 20:17:49.962 DEBUG 76312 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        member
        (age, team_id, username, member_id) 
    values
        (?, ?, ?, ?)
2021-07-01 20:17:49.962 DEBUG 76312 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        member
        (age, team_id, username, member_id) 
    values
        (?, ?, ?, ?)
2021-07-01 20:17:49.963 DEBUG 76312 --- [           main] org.hibernate.SQL                        : 
    insert 
    into
        member
        (age, team_id, username, member_id) 
    values
        (?, ?, ?, ?)
2021-07-01 20:17:49.966 DEBUG 76312 --- [           main] org.hibernate.SQL                        : 
    update
        member 
    set
        age=age+1 
    where
        age>=?

먼저 5명의 Member를 insert 한 뒤, update 쿼리는 한 번만 발생한 것을 확인할 수 있습니다.

주의사항

벌크 업데이트는 영속성 컨텍스트를 통한 Entity 관리가 불가능합니다. savefind 등을 통해 repository에서 가져오게 되면 영속성 컨텍스트로 인식하지만, 직접적으로 update 쿼리를 실행하게되면 영속성 컨텍스트로 인지할 기회가 없기 때문입니다.

이전 테스트를 간단히 수정해 확인해보겠습니다.

    @Test
    @DisplayName("벌크 업데이트 테스트: 나이가 n살 이상인 멤버의 나이를 1씩 증가시킨다")
    @Transactional
    public void bulkUpdate() {
        // given
        memberRepository.save(new Member("a", 10));
        memberRepository.save(new Member("b", 15));
        memberRepository.save(new Member("c", 20));
        memberRepository.save(new Member("d", 30));
        memberRepository.save(new Member("e", 40));
        // when
        int count = memberRepository.increaseAgeOfAllMembersOver(20);
        Member member = memberRepository.findByUsername("e");

        // then
        assertEquals(3, count);
        assertEquals(41, member.getAge());
    }

DB에서 다시 조회해 온 member 객체는 20살 이상이기 때문에 한 살이 추가된 41살이 되어야 합니다.

하지만 테스트 결과는...

Spring data jpa insert update 방법

초록불이 보이지 않습니다.

그 이유는 위에서 설명한 바와 같고, 그렇다면 아무리 조심해서 사용한다고해도 언제든지 실수할 가능성이 있으니 이를 미연에 방지하는 것이 더 중요하겠죠?

    @Autowired
    EntityManager entityManager; // (1)

    @Test
    @DisplayName("벌크 업데이트 테스트: 나이가 n살 이상인 멤버의 나이를 1씩 증가시킨다")
    @Transactional
    public void bulkUpdate() {
        // given
        memberRepository.save(new Member("a", 10));
        memberRepository.save(new Member("b", 15));
        memberRepository.save(new Member("c", 20));
        memberRepository.save(new Member("d", 30));
        memberRepository.save(new Member("e", 40));
        // when
        int count = memberRepository.increaseAgeOfAllMembersOver(20);

        entityManager.flush(); // (2) 
        entityManager.clear(); // (3)

        Member member = memberRepository.findByUsername("e");

        // then
        assertEquals(3, count);
        assertEquals(41, member.getAge()); 
    }

(1) 영속성 컨텍스트를 관리할 수 있는 EntityManager를 주입받습니다.
(2) 남아있는 변동사항이 DB에 반영됩니다.
(3) 영속성 컨텍스트를 비워줍니다.

이렇게 수정한 뒤 다시 실행하면 성공적인 결과를 확인할 수 있습니다.

하지만 당연히 갓프링께서 더 간단한 방법을 지원해주겠죠?

다시 Repository로 돌아가 @Modifying 애너테이션에 속성을 추가해줍니다.

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    @Modifying(clearAutomatically = true)
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int increaseAgeOfAllMembersOver(@Param("age") int age);
}

이렇게 추가해주시면 EntityManager를 이용해 수동으로 했던 작업을 자동으로 해줍니다.

flushAutomatically 옵션은 flush()만, clearAutomatically 옵션은 flush() 이후 clear()까지 호출해줍니다.

Spring data jpa insert update 방법

테스트는 무사히 통과했지만 그렇다면 안전빵으로 무조건 clearAutomatically 옵션을 사용하는 게 나을까요?

답은 "상황별로 다르다" 입니다.

실무에서는 MyBatis와 JPA를 같이 사용하는 경우도 많고, 벌크 업데이트 후 조회가 필요한 상황이 있을 수도 있습니다만 설계에 따라 벌크 업데이트 로직이 별개로 동작한다면 굳이 옵션을 추가할 필요가 없기 때문입니다.

따라서 상황에 맞게 잘 판단해서 사용하는 것이 실수를 방지하고 메모리를 비웠다가 다시 조회하면서 트랜잭션이 추가로 발생하는 상황 또한 방지해줄 수 있습니다.