토이 프로젝트에서 JPA 기반 회원 삭제 배치를 개발하면서 겪었던 스토리에 대해 정리하고자 작성한 내용

 

1. Background

(1) JPA 연관 관계

회원 삭제 배치는 soft delete 를 통해 탈퇴한 회원의 사용자 정보를 특정 기간동안 데이터를 보관한 뒤 제거하는 배치이다. DB 데이터는 JPA 기반으로 제어하고 있다.

 

회원 삭제 배치는 Member 와 MemberRoleMapping 데이터를 제거하는 과정이며 연관 관계를 맺고 있었다. 또한, Member entity 제거할 때 함께 MemberRoleMapping 데이터를 제거하기 위해  cascade option 이 설정되어 있다.

 

// Member Entity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class Member {

	@Id
	@UnixTimeOrderedUuidGeneratedValue
	@Column(columnDefinition = "BINARY(16)")
	private UUID id;
    
	@OneToOne(
		fetch = FetchType.LAZY,
		mappedBy = "member",
		orphanRemoval = true)
	private MemberRoleMapping memberRoleMapping;
	
    // ...
}

// MemberRoleMapping Entity
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberRoleMapping {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
	@Setter
	private Member member;
    
    // ...
}

 

 

2. Delete Step 쿼리 고려하기

(1) 연관 관계에서 Delete query 에서 N + 1 Query 제거하기

1차 시도로 Member 와 MemberRoleMapping 을 제거하기 위해 기존 cascade option 을 활용해 연관 관계 데이터를 제거하고자 했다. 하지만 N + 1 Query 가 문제였다. Member 의 delete query 외에도 MemberRoleMapping select query + MemberRoleMapping delete query 가 발생한다. 만약 1.000건의 회원 데이터를 제거하고자 하는 경우, 2,000건의 추가 쿼리가 발생한다. 배보다 배꼽이 더 큰 상황이다. 🫠

  • member delete 1건(추가 : member_role_mapping delete 1건 + member_role_mapping select 1건)

 

memberDeleteWriter

@Bean
@StepScope
public ItemWriter<? super Member> memberDeleteItemWriter() {
    final RepositoryItemsWriter<Member> writer = new RepositoryItemsWriter<>();

    writer.setRepository(memberBatchRepository);
    writer.setMethodName("delete");

    return writer;
}

 

추가 쿼리

// memberRoleMapping 조회 쿼리
Hibernate: select mrm1_0.id,mrm1_0.created_date,mrm1_0.member_id,mrm1_0.member_role_id,mrm1_0.modified_date from member_role_mapping mrm1_0 where mrm1_0.member_id=?

// memberRoleMapping 삭제 쿼리
Hibernate: delete from member_role_mapping where id=?

// member 삭제 쿼리
Hibernate: delete from member where id=?

 

 

(2) RepositoryItemWriter 는 IN 절 delete 불가능.  CustomRepositoryItemWriter 만들기

N+1 쿼리 이슈를 개선하고자 삭제 배치에서는 연관 관계 활용하는 대신 memberId 를 IN 절을 기반한 delete query 로 변경하고자 했다. delete query 를 수행할 때도 index 를 타기 때문에 FK 또는 PK 를 활용하고 싶었다.

 

하지만, 한가지 문제가 있다. RepositoryItemReader 는 IN 절을 처리할 수 없다.

 

내부 코드를 살펴보자. RepositoryItemReader 는 리플렉션을 기반으로 chunk 단위의 데이터를 일일이 순회하여 쿼리를 실행하는 방식으로 동작하기 때문에 IN 절을 활용할 수 없다. 파라미터를 List type 으로 추가하여 실행할 경우, reflection 을 통해 target method 에 올바르지 않은 argument 가 주입된 것으로 판단해 NoSuchMethodException 를 반환한다.

RepositoryItemWriter.doWrite()

 

 

 

 

 

하지만 CustomRepositoryItemWriter 클래스를 만들어서 IN절을 처리할 수 있다. chunk 의 아이템들을 순회하는 로직을 그대로 argument 로 주입하는 CustomRepositoryItemWriter 를 생성하면 List 타입의 파라미터 메서드를 호출할 수 있다.

내가 만든 클래스 내부 코드

 

 

 

 

 

CustomRepositoryItemWriter 만들어보자 👊

 

CustomRepositoryItemWriter 를 통해 IN 절에 java.util.List 기반의 argument 를 전달 가능하도록 만들고 JPQL 기반의 delete custom query 를 작성해 등록했다. 쿼리를 확인해보면 기존의 N + 1 쿼리는 발생하지 않고 IN 절을 기반으로 두 엔티티 모두 삭제하는 것을 확인해볼 수 있다. (Member delete query 는 JpaRepository 에서 제공하는 deleteAllByIdInBatch 를 사용했다.)

 

CustomRepositoryItemWriter

// memberDeleteItemWriter
@Bean
@StepScope
public ItemWriter<? super Member> memberDeleteItemWriter() {
    final CustomRepositoryItemWriter<Member> writer = new CustomRepositoryItemWriter<>();

    writer.setRepository(memberBatchRepository);
    writer.setMethodName("deleteMemberRoleMappingByMemberIds");

    return writer;
}

// MemberBatchRepository
public interface MemberBatchRepository extends JpaRepository<Member, UUID> {
	@Modifying
	@Query("DELETE FROM MemberRoleMapping mrm WHERE mrm.member.id IN :memberIds")
	void deleteMemberRoleMappingByMemberIds(@Param("memberIds") Iterable<UUID> memberIds);
}

 

 

삭제 쿼리

// member_role_mapping 삭제 쿼리
Hibernate: delete mrm1_0 from member_role_mapping mrm1_0 where mrm1_0.id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)



// member 삭제 쿼리
Hibernate: delete m1_0 from member m1_0 where m1_0.id in (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)

 

 

 

 

 

(3) membeRoleMapping + member 삭제 쿼리: 트랜잭션 묶기

Spring Batch 에서는 Step level 에서 TransactionManager 를 선언해 트랜잭션을 지원한다. 그러므로 Step level 에서 트랜잭션을 묶어야 한다. 기본 Step 구조는 reader - proccessor - writer 를 각각 하나씩 선언할 수 있다.

 

현재 Step 구성은 삭제 회원 목록 조회(reader) - 회원 권한 매핑 삭제(writer) - 회원 삭제(writer) 이므로 불가능해보이지만 CompositeItemWriter 를 활용하면 reader 의 조회 데이터를 통해 여려 Writer 를 선언할 수 있다.

@Bean
public Step memberDeletionStep(JobRepository jobRepository) {
    log.info("memberDeleteStep started.");
    return new StepBuilder("memberDeleteStep", jobRepository)
        .<UUID, Member>chunk(100, transactionManager)
        .reader(memberDeleteItemReader())
        .writer(memberDeleteCompositeWriter())
        .build();
}

@Bean
@StepScope
public ItemWriter<? super Member> memberDeleteCompositeWriter() {
    log.debug("memberDeleteCompositeWriter started.");
    return new CompositeItemWriter<>(List.of(
        memberRoleDeleteItemWriter(),
        memberDeleteItemWriter()));
}

 

 

 

 

 

3.  실행 계획 확인

샘플 데이터 50건을 기준으로 실행 계획을 확인해보았다. memberId 를 FK 로 선언으로 인해 생성된 index 인 member_id 를 통해서 index range scan 기반으로 동작한 것을 확인해볼 수 있다.

explain delete from member_role_mapping in member_id IN (?, ?, ?);

+--+-----------+-------------------+----------+-----+-------------+---------+-------+-----+----+--------+-----------+
|id|select_type|table              |partitions|type |possible_keys|key      |key_len|ref  |rows|filtered|Extra      |
+--+-----------+-------------------+----------+-----+-------------+---------+-------+-----+----+--------+-----------+
|1 |DELETE     |member_role_mapping|null      |range|member_id    |member_id|17     |const|1   |100     |Using where|
+--+-----------+-------------------+----------+-----+-------------+---------+-------+-----+----+--------+-----------+
show index from member_role_mapping

+-------------------+----------+--------------+------------+--------------+---------+-----------+--------+------+----+----------+-------+-------------+-------+----------+
|Table              |Non_unique|Key_name      |Seq_in_index|Column_name   |Collation|Cardinality|Sub_part|Packed|Null|Index_type|Comment|Index_comment|Visible|Expression|
+-------------------+----------+--------------+------------+--------------+---------+-----------+--------+------+----+----------+-------+-------------+-------+----------+
|member_role_mapping|0         |PRIMARY       |1           |id            |A        |1          |null    |null  |    |BTREE     |       |             |YES    |null      |
|member_role_mapping|1         |member_id     |1           |member_id     |A        |1          |null    |null  |YES |BTREE     |       |             |YES    |null      |
|member_role_mapping|1         |member_role_id|1           |member_role_id|A        |1          |null    |null  |YES |BTREE     |       |             |YES    |null      |
+-------------------+----------+--------------+------------+--------------+---------+-----------+--------+------+----+----------+-------+-------------+-------+----------+

 

 

 

 

 

4.  배치 실행 시간 확인 & 후기

50건의 샘플 데이터를 토대로 Step을 만들어 비교해보자.

RepositoryItemWriter 기반을 대조군으로 비교했을 때, CustomRepositoryItemWriter 의 실행 시간이 1/3 으로 줄어들었다.

 

# N + 1 기반 delete 배치 실행 시간
Step: [memberDeleteStep] executed in 654ms

# IN절 delete 배치 실행 시간
Step: [memberDeleteStep] executed in 192ms

 

 

JPA 를 기반 Spring Batch Job 을 작성하며 느낀점

  • Entity 의 연관 관계는 key를 기반으로 간접 참조를 활용하고 최대한 지연해서 연관 관계를 설정하는 것이 적합한 것 같다. 연관 관계 설정 시, 고려 사항과 제약 사항이 많아져 변경이 어려워질 수 있다. (e.g. N + 1 쿼리, cascade option)
  • 배치 작업에서 rows deletion 은 IN 절을 기반으로 동작하도록 구성을 우선 순위로 고려하자. 당연한 이야기지만 부가 쿼리는 애플리케이션 성능에 영향을 미칠 수 있다.