10 분 소요

📌 Topic

  • 벌크성 수정 쿼리
  • @EntityGraph
  • JPA Hint & Lock

01. 벌크성 수정 쿼리

JPA는 기본적으로 Entity를 가져와서 해당 엔티티를 변경하면 트랜잭션 커밋 시점에
Update 쿼리가 DB에 날라간다. 하지만 이러한 부분은 단건의 경우만 해당이 되는 부분인데 단건이 아닌 여러건의 데이터를 한 번에 넣는 경우를 벌크성 수정 쿼리라 한다.

01-1. 순수 JPA 기반 Bulk Update 구현

@Repository
public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;

    public int bulkAgePlust(int age) {
        return em.createQuery("update Member m set m.age = m.age + 1 where m.age >= :age")
                 .setParameter("age", age)
                 .executeUpdate();
    }
}
  • 순수 JPA를 기반으로 하여 특정 나이 이상인 데이터 Update 수행
  • 벌크 연산은 executeUpdate() 메소드 사용
    • 영향을 받은 엔티티 건수 반환

위에서 파라미터로 들어오는 나이 이상인 경우에 업데이트를 수행 하도록
지정 해두었다. 이제 간단한 테스트 코드를 작성 해보자.

@Test
@Rollback(false)
public void bulkAgePlus() {
    //given
    memberJpaRepository.save(new Member("member1", 10));
    memberJpaRepository.save(new Member("member2", 19));
    memberJpaRepository.save(new Member("member3", 20));
    memberJpaRepository.save(new Member("member4", 21));
    memberJpaRepository.save(new Member("member5", 40));

    //when
    int resultCnt = memberJpaRepository.bulkAgePlus(20); // ? >= 20

    //then
    assertThat(resultCnt).isEqualTo(3);
}
  • 5명의(이름, 나이) 유저를 저장
  • 나이가 20 이상인 유저의 경우 Update를 수행, 반환 결과값을 저장
  • 해당 결과와 예상 결과값을 비교
    • resultCnt(3) == 3 => true

현재 나이가 20 이상인 데이터는 member3, member4, member5
이기에 우리가 예상한 결과값 3과 동일하여 테스트를 통과한다. 그렇다면 이러한 순수 JPA 기반 코드를 스프링 데이터 JPA로 구현 해보자.

01-2. 스프링 데이터 JPA 기반 Bulk Update 구현

public interface MemberRepository extends JpaRepository<Member, Long> {

    //..중략

    @Modifying
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int bulkAgePlust(@Param("age") int age);

}
  • 순수 JPA 기반으로 작성한 소스와 기능은 동일
  • 기존과 동일한 테스트 코드 사용(생략)
  • @Modifying 어노테이션을 반드시 선언 해주어야 한다
    • @Modifying을 사용하지 않을 경우 QueryExcutionRequestException 발생
    • DML operation을 지원하지 않는다는 내용
    • JPA 기본 동작은 select - update로 이루어져 있음
    • update만 하겠다고 알려주는 어노테이션 @Modifying
    • 반환 값은 int, void, Integer로 지정해야 한다

QueryExecutionRequestException 로그

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

✨ 중요 : JPA에서 이러한 Bulk성 Update는 상당히 조심해야 하는 부분이다.

JPA는 영속성 컨텍스트가 존재하여 엔티티가 관리가 되어진다. 하지만 이러한 Bulk성 Update 의 경우에는 이러한 부분을 다 무시하고 DB에 바로 데이터를 등록 하는 문제가 존재한다. 이전에 작성하였던 테스트 코드를 기반으로 문제점을 살펴보자.

@Test
@Rollback(false)
public void bulkAgePlus() {
    //given
    memberJpaRepository.save(new Member("member1", 10)); // 데이터 저장, 아직 영속성 컨텍스트에 존재
    memberJpaRepository.save(new Member("member2", 19)); // 트랜잭션(Transaction) 커밋 시점에 DB 반영
    memberJpaRepository.save(new Member("member3", 20));
    memberJpaRepository.save(new Member("member4", 21));
    memberJpaRepository.save(new Member("member5", 40));

    //when
    int resultCnt = memberJpaRepository.bulkAgePlus(20); // 나이가 20 이상인 DB 데이터 Update

    //추가
    List<Member> result = memberRepository.findByUserName("member5");
    Member member = result.get(0);
    System.out.println("member5 = " + member); // 출력 결과는?

    //then
    assertThat(resultCnt).isEqualTo(3);
}

위 테스트 코드를 실행하면 어떤 결과가 나올까?
한 번 고민해볼 필요가 있는 부분.

  • 문제점
    • save한 데이터가 DB에 아직 반영이 되지 않은 상태
    • bulkAgePlus(20) 호출을 통해 데이터 변경
    • 잘못하면 서비스 장애로 이어질 수 있는 민감한 부분
    • 벌크(bulk) 연산 이후에는 영속성 컨텍스트를 초기화 해야 함

위 테스트 코드에서 member5의 출력 결과는 ‘age=40’이다
자세한 내용은 JPA 기본편을 참고하자. (영속성 컨텍스트와 트랜잭션 커밋)
하나의 트랜잭션 단위에서 동일한 엔티티 반환을 보장하는 JPA의 특성

@SpringBootTest
@Transactional
@DisplayName("스프링 데이터 JPA 기반 테스트")
class MemberRepositoryTest {

    @Autowired MemberRepository memberRepository;
    @Autowired TeamRepository teamRepository;
    @PersistenceContext EntityManager em;

    @Test
    @Rollback(false)
    public void bulkAgePlus() {
        //given
        memberJpaRepository.save(new Member("member1", 10));
        memberJpaRepository.save(new Member("member2", 19));
        memberJpaRepository.save(new Member("member3", 20));
        memberJpaRepository.save(new Member("member4", 21));
        memberJpaRepository.save(new Member("member5", 40));

        //when
        int resultCnt = memberJpaRepository.bulkAgePlus(20); // ? >= 20

        //bulk 연산 이후 영속성 컨텍스트 초기화
        em.flush();
        em.clear();

        //추가
        List<Member> result = memberRepository.findByUserName("member5");
        Member member = result.get(0);
        System.out.println("member5 = " + member); // 출려 결과는?

        //then
        assertThat(resultCnt).isEqualTo(3);
    }
}

벌크(bulk) 연산 이후에 다른 비즈니스 로직이 존재하지 않는다면 상관이 없지만
그렇지 않다면 반드시 flush, clear를 통해 영속성 컨텍스트를 초기화 해야 한다.

  • em.flush() : 참고
  • em.clear() : 영속성 컨텍스트 데이터 초기화

JPA는 위와 같은 문제를 어노테이션으로 지원

@Modifying(clearAutomatically = true) // eq executeUpdate
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);
  • clearAutomatically = true
    • JPQL은 우선적으로 실행이 된다
    • Query에 있는 쿼리가 수행이 되면 자동으로 clear 수행
  • JDBC, Mybatis 등을 사용할 때도 동일

핵심

02. @EntityGraph

연관된 엔티티들을 SQL 한번에 조회하는 방법

02-1. Fetch Lazy

@EntityGraph를 알아보기에 앞서 Fetch 타입이 Lazy인 경우를 테스트 해보자

Member 엔티티

package study.datajpa.entity;

import lombok.*;

import javax.persistence.*;
import java.util.List;

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "userName", "age"}) // 연관관계 필드는 @ToString 지양
@NamedQuery(
        name = "Member.findByUserName",
        query = "select m from Member m where m.userName = :userName"
)
public class Member extends BaseEntity {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String userName;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id") // FK
    private Team team;

//    protected Member() {
//    }

    public Member(String userName) {
        this.userName = userName;
    }

    public Member(String userName, int age) {
        this.userName = userName;
        this.age = age;
    }

    public Member(String userName, int age, Team team) {
        this.userName = userName;
        this.age = age;
        if (team != null) {
            changeTeam(team);
        }
    }

    // 연관관계 편의 메서드 생성
    // N(Member) :  1(Team)
    public void changeTeam(Team team) {
        this.team = team;
        team.getMembers().add(this);
    }
}

순수 JPA 리포지토리 테스트(Member)

@SpringBootTest
@Transactional
@DisplayName("스프링 데이터 JPA 기반 테스트")
class MemberRepositoryTest {

    @Autowired MemberRepository memberRepository;
    @Autowired TeamRepository teamRepository;
    @PersistenceContext EntityManager em;

    //..중략

    @Test
    @DisplayName("맴버 조회 + 지연 로딩(Fetch LAZY)")
    public void findMemberLazy() {
        //given
        //member1 -> teamA
        //member2 -> teamB

        // Team(1) : Member(N)
        Team teamA = new Team("teamA");
        Team teamB = new Team("teamB");
        teamRepository.save(teamA);
        teamRepository.save(teamB);

        // Member의 경우 생성자로 team을 받고, 연관관계 메서드를 통해 관계를 갖는다 : changeTeam(Team team)
        Member member1 = new Member("member1", 10, teamA);
        Member member2 = new Member("member2", 10, teamB);
        memberRepository.save(member1);
        memberRepository.save(member2);

        em.flush(); // 속성 컨텍스트의 변경 내용을 DB 에 반영
        em.clear(); // 1차캐시, 영속성 초기화

        List<Member> members = memberRepository.findAll();
        for (Member member : members) {
            System.out.println("member = " + member.getUserName());
        }
}

각각의 팀에 연관관계를 가지는 맴버(member1, member2)를 생성 한다. 여기서 주의깊게 봐야 하는 부분은 findAll()을 통해 전체 맴버를 가지고 온 후에 print를 통해 member를 출력하는 소스 라인이다. 아마도 findAll()이 한 번 선언이 되었기 때문에 쿼리는 한 번만 나갈 것이다.

2022-06-20 21:04:03.379 DEBUG 21788 --- [           main] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.user_name as user_nam3_0_
    from
        member member0_

현재는 member만 출력하고 있기 때문에 상관이 없지만 만약 지연로딩으로 설정이 되어있는 team 객체의 데이터에 접근하게 되면 어떤 상황이 발생할까? 다음 소스와 쿼리를 한번 살펴보자.

List<Member> members = memberRepository.findAll();
for (Member member : members) {
    System.out.println("member = " + member.getUserName());
    System.out.println("member = " + member.getTeam().getName()); // ✅ 추가 된 부분
}

Member.java

현재 Member 엔티티가 가지고 있는 team 객체는 지연 로딩으로 설정 되어있는 상황이다. member.getTeam().getName()을 호출하는 순간 프록시 객체가 아닌 실제 객체를 가져오는 쿼리가 날라가게 되는데, 지금부터 위에서 작성한 테스트 코드와 쿼리를 하나씩 비교해가면서 소스를 분석 해보자.

전체 회원 조회

순수 JPA 리포지토리 테스트(Member)의 테스트 코드를 참고하시면 됩니다

List<Member> members = memberRepository.findAll();
2022-06-20 21:07:23.950 DEBUG 14348 --- [           main] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.user_name as user_nam3_0_
    from
        member member0_

우선 해당 쿼리는 findAll() 메서드가 호출이 되는 경우 나가는 쿼리다.
전체 유저를 가져와야 하기에 member 테이블의 모든 데이터를 출력하는 상황이다.

for (Member member : members) {
    System.out.println("member = " + member.getUserName());
    System.out.println("member = " + member.getTeam().getName());
}
2022-06-20 21:07:23.951  INFO 14348 --- [           main] p6spy                                    : #1655726843951 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/datajpa
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.user_name as user_nam3_0_ from member member0_
select member0_.member_id as member_i1_0_, member0_.age as age2_0_, member0_.team_id as team_id4_0_, member0_.user_name as user_nam3_0_ from member member0_;
member = member1
2022-06-20 21:07:23.969 DEBUG 14348 --- [           main] org.hibernate.SQL                        :
    select
        team0_.team_id as team_id1_1_0_,
        team0_.name as name2_1_0_
    from
        team team0_
    where
        team0_.team_id=?
2022-06-20 21:07:23.971  INFO 14348 --- [           main] p6spy                                    : #1655726843971 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/datajpa
select team0_.team_id as team_id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.team_id=?
select team0_.team_id as team_id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.team_id=1;
member.team = teamA
member = member2
2022-06-20 21:07:23.978 DEBUG 14348 --- [           main] org.hibernate.SQL                        :
    select
        team0_.team_id as team_id1_1_0_,
        team0_.name as name2_1_0_
    from
        team team0_
    where
        team0_.team_id=?
2022-06-20 21:07:23.978  INFO 14348 --- [           main] p6spy                                    : #1655726843978 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/datajpa
select team0_.team_id as team_id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.team_id=?
select team0_.team_id as team_id1_1_0_, team0_.name as name2_1_0_ from team team0_ where team0_.team_id=2;
member.team = teamB

위 로그를 보면 우리는 findAll() 메서드를 한 번만 호출 하였는데, 쿼리는 2 ~ 3번이 더 나간 상황이다. 이러한 경우를 N + 1 문제라 지칭한다.

JPA는 이러한 문제를 해결하기 위해 fetch join을 제공한다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    // JPA의 JPQL + fetch 조인 사용
    @Query("select m from Member m left join fetch m.team")
    List<Member> findMemberFetchJoin();

}

위와 같이 fetch join을 사용하여 연관 관계가 있는 모든 데이터를 한 번에 출력할 수 있다. 이전에 작성하였던 테스트 코드를 한 번더 실행 해보자.

//when N(2) + 1(한번)
//select Member 1
List<Member> members = memberRepository.findMemberFetchJoin();
for (Member member : members) {
    System.out.println("member = " + member.getUserName());
    System.out.println("member.team = " + member.getTeam().getName());
}
2022-06-20 21:22:04.421 DEBUG 23272 --- [           main] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_0_0_,
        team1_.team_id as team_id1_1_1_,
        member0_.age as age2_0_0_,
        member0_.team_id as team_id4_0_0_,
        member0_.user_name as user_nam3_0_0_,
        team1_.name as name2_1_1_
    from
        member member0_
    left outer join
        team team1_
            on member0_.team_id=team1_.team_id
2022-06-20 21:22:04.425  INFO 23272 --- [           main] p6spy                                    : #1655727724425 | took 0ms | statement | connection 3| url jdbc:h2:tcp://localhost/~/datajpa
select member0_.member_id as member_i1_0_0_, team1_.team_id as team_id1_1_1_, member0_.age as age2_0_0_, member0_.team_id as team_id4_0_0_, member0_.user_name as user_nam3_0_0_, team1_.name as name2_1_1_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id
select member0_.member_id as member_i1_0_0_, team1_.team_id as team_id1_1_1_, member0_.age as age2_0_0_, member0_.team_id as team_id4_0_0_, member0_.user_name as user_nam3_0_0_, team1_.name as name2_1_1_ from member member0_ left outer join team team1_ on member0_.team_id=team1_.team_id;
member = member1
member.team = teamA
member = member2
member.team = teamB

연관관계가 있는 객체 그래프를 한 번에 join 하는 것 -> Fetch join

이전과는 다르게 N + 1의 문제가 발생하지 않고 모든 데이터를 한 번에 가져오는것을 확인 할 수 있으며, 프록시 객체가 아닌 실제 객체를 가져오는 것 역시 확인이 가능하다.

02-2. @EntityGraph

지금까지 간단한 테스트 코드를 작성하여 지연로딩(LAZY)에 대한 부분을 간략히 알아보았다. 이와 같이 JPA는 fetch join을 사용하여 지연로딩으로 설정이 된 객체를 한 번에 조회 할 수 있는데, 스프링 데이터 JPA의 경우 메서드 이름을 통해(findBy… findSliceBy…) 조회를 하는 경우가 있기에 상황이 애매해진다.

이 때 스프링 데이터 JPA에서 사용 할 수 있는 기능이 @EntityGraph 어노테이션인데, @Query를 통해 JPQL을 생성하지 않고 이름만으로도 fetch join이 가능하도록 설계해야 하는 경우 사용을 하면 된다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    // JPQL은 사용하고 싶지 않을 때
    @Override
    @EntityGraph(attributePaths = {"team"})
    List<Member> findAll();

    // JPQL도 사용, EntityGraph도 사용
    @EntityGraph(attributePaths = {"team"})
    @Query("select m from Member m")
    List<Member> findMemberEntityGraph();

    // 메서드 이름으로 조회, EntityGraph도 사용
    @EntityGraph(attributePaths = {"team"})
    List<Member> findEntityGraphByUserName(@Param("userName") String userName);

}
  • JPQL을 통해 fetch join을 한 결과와 동일한 값을 출력
  • 3가지 방법을 통해 데이터 조회 가능
  • @EntityGraph를 사용하면 편리하게 fetch join을 사용할 수 있다
  • 결국 쿼리가 복잡해지면 JPQL fetch join을 사용하는게 좋을 것 같다

03. JPA Hint & Lock

JPA Query를 날리는 경우 hibernate에게 알려주는 Hint(힌트)
즉, SQL Hint(힌트)가 아니라 JPA 구현체에게 제공하는 힌트다.

03-1. 쿼리 힌트 사용

@QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true"))
Member findReadOnlyByUserName(String userName);
  • @QueryHints 어노테이션을 통해 해당 메서드는 100% Read Only임을 나타낸다
  • 해당 메서드를 호출하는 순간 JPA는 추가적인 스냅샷(Snapshot)을 만들지 않는다
  • 변경 감지가 발생 하여도, Update Query가 발생하지 않는다
  • 내부적인 성능 최적화 제공, 엄청난 성능 최적화를 해주는 것은 아니다

03-2. Lock

JPA는 Lock 기능을 제공

//select for update
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUserName(String userName);
  • @Lock 어노테이션 사용만으로 간단하게 JPA Lock 기능을 사용 할 수 있다
@Test
public void lock() {
    //given
    Member member1 = memberRepository.save(new Member("member1", 10));
    em.flush();
    em.clear();

    //when
    List<Member> result = memberRepository.findLockByUserName("member1");
}
2022-06-20 22:49:40.852 DEBUG 6288 --- [           main] org.hibernate.SQL                        :
    select
        member0_.member_id as member_i1_0_,
        member0_.age as age2_0_,
        member0_.team_id as team_id4_0_,
        member0_.user_name as user_nam3_0_
    from
        member member0_
    where
        member0_.user_name=? for update
  • select ~ for update 쿼리가 발생
  • 실시간 트래픽이 많은 서비스에서는 Lock을 걸면 안된다

03-3. 비관적 락

jpa에서 Repository를 이용한 비관적락을 구현해봅시다. With MariaDB

비관적락이란 접근하고자 하는 DB 리소스에 다른 사람이 접근 조차 하지 못하도록 락을 걸고 작업을 진행 하는 것을 의미한다. 여기서 접근이라는 것은 READ, WRITE 2가지로 구분이 되어지는데, 경우에 따라 2가지가 다 불가능할지 아니면 하나만 가능할지를 정하는 것이 가능하다.

  • 베타 락(exclusive lock)
    • 읽기, 쓰기 모두 불가능
  • 공유 락(shared lock)
    • 다른 트랜잭션에서 읽기는 가능, 쓰기는 불가능

03-3. JPA 에서의 비관적 락

JPA에서의 비관적 락 옵션은 총 3개로 이루어져있다.
아래의 락 설정들은 Transaction commit - rollback이 될때까지 유지가 된다.

@Lock(LockModeType.PESSIMISTIC_READ)
List<Member> method1()

@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> method2()

@Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
List<Member> method3()
  • PESSIMISTIC_READ
    • 해당 리소스에 공유락을 건다. 타 트랜잭션에서 읽기는 가능, 쓰기는 불가능.
  • PESSIMISTIC_WRITE
    • 해당 리소스에 베타락을 건다. 타 트랜잭션에서 읽기, 쓰기 다 불가능.
  • PESSIMISTIC_FORCE_INCREMENT
    • 해당 리소스에 베타락을 건다. 타 트랜잭션에서 읽기, 쓰기 다 불가능. 추가적으로 낙관적락처럼 버저닝을 수행한다. 버전에 대한 컬럼 필요.

비관적 락에서 발생할 수 있는 예외 상황

  • PessimisticLockException
    • 트랜잭션에서 락 취득을 하지 못하여 발생
  • LockTimeoutException
    • 트랜잭션에서 락 취득을 시간내에 하지 못해 발생
  • PersistanceException
    • 값을 변경할 때 생길 수 있는 예외상황
      • NoResultException
      • NoUniqueResultException
      • LockTimeoutException
      • QueryTimeoutException
    • 결과적으로 롤백 수행

참고 자료

댓글남기기