토이 프로젝트에서 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 를 반환한다.

하지만 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 절을 기반으로 동작하도록 구성을 우선 순위로 고려하자. 당연한 이야기지만 부가 쿼리는 애플리케이션 성능에 영향을 미칠 수 있다.
'spring > summary' 카테고리의 다른 글
MessageSourceAutoConfiguration 코드 검토하기 (0) | 2024.11.28 |
---|---|
[kurly tech blog] Redisson, Spring AOP 기반 분산락 적용 방법 summary (0) | 2024.11.26 |
[Spring Batch] Job 실행 프로퍼티, JobParameter, Scope (0) | 2024.08.26 |
Spring Batch Architecture (0) | 2024.05.10 |
hibernate.query.in_clause_parameter_padding = true ? (1) | 2024.03.06 |
토이 프로젝트에서 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 를 반환한다.

하지만 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 절을 기반으로 동작하도록 구성을 우선 순위로 고려하자. 당연한 이야기지만 부가 쿼리는 애플리케이션 성능에 영향을 미칠 수 있다.
'spring > summary' 카테고리의 다른 글
MessageSourceAutoConfiguration 코드 검토하기 (0) | 2024.11.28 |
---|---|
[kurly tech blog] Redisson, Spring AOP 기반 분산락 적용 방법 summary (0) | 2024.11.26 |
[Spring Batch] Job 실행 프로퍼티, JobParameter, Scope (0) | 2024.08.26 |
Spring Batch Architecture (0) | 2024.05.10 |
hibernate.query.in_clause_parameter_padding = true ? (1) | 2024.03.06 |