0. 개요
쇼핑몰 프로젝트 당시 구현하지 못했던 생일 쿠폰 발급 요구사항을 Spring Batch로 구현했지만,
개발 후 회원 규모를 확장해 가며 테스트한 결과 처리 속도가 매우 느렸다.
요구 사항은 다음과 같다.
이 글은 Spring Batch 기반 생일 쿠폰 발급 배치의 속도 개선 과정을 정리한 글이다.
1. 문제 발생
(1) 문제 발견
회원 더미 데이터를 점차 늘려가며 배치 테스트를 하던 중, 처리 시간이 급격히 늘고 SQL 쿼리문이 굉장히 많이 발생하는 문제가 발생했다.
테스트 조건은 아래와 같다.
- 전체 회원: 120만 명
- 이번 달 생일자: 10만 명
- Chunk Size: 1000
* 더미 데이터 생성
(1) 프로시저
DELIMITER $$
DROP PROCEDURE IF EXISTS seed_members_birthmonth $$
CREATE PROCEDURE seed_members_birthmonth(
IN p_count INT, -- 생성 수
IN p_birth_month TINYINT -- 1~12
)
BEGIN
-- 내부 기본값들
DECLARE v_grade_id INT DEFAULT 1;
DECLARE v_role_id INT DEFAULT 1;
DECLARE v_login_prefix VARCHAR(64) DEFAULT 'user';
DECLARE v_email_domain VARCHAR(128) DEFAULT 'example.com';
DECLARE v_year_from INT DEFAULT 2000;
DECLARE v_year_to INT DEFAULT 2025;
-- 반복용/랜덤용 변수들 (DECLARE는 블록 시작부에서만 가능)
DECLARE i INT DEFAULT 0;
DECLARE v_base BIGINT;
DECLARE v_member_id BIGINT;
DECLARE v_gender CHAR(1);
DECLARE v_year INT;
DECLARE v_day INT;
DECLARE v_dob DATE;
DECLARE v_created DATETIME(6);
DECLARE v_last_login DATETIME(6);
-- 시작 ID: 테이블에 있으면 MAX+1, 비어있으면 9e17부터
SELECT COALESCE(MAX(member_id) + 1, 900000000000000000)
INTO v_base
FROM members;
START TRANSACTION;
WHILE i < p_count DO
SET v_member_id = v_base + i;
-- 성별 랜덤
SET v_gender = IF(RAND() < 0.5, 'M', 'F');
-- 생년(1980~2005) + 지정 월 + 일(1~28 랜덤)
SET v_year = v_year_from + FLOOR(RAND() * (v_year_to - v_year_from + 1));
SET v_day = 1 + FLOOR(RAND() * 28);
SET v_dob = STR_TO_DATE(CONCAT(v_year, '-', LPAD(p_birth_month,2,'0'), '-', LPAD(v_day,2,'0')), '%Y-%m-%d');
-- 생성일: 최근 365일 내 임의
SET v_created = TIMESTAMPADD(SECOND, -FLOOR(RAND() * 86400 * 365), NOW(6));
-- 마지막 로그인: 20% NULL, 아니면 생성일 이후~현재 임의
SET v_last_login = IF(
RAND() < 0.20,
NULL,
TIMESTAMPADD(
SECOND,
FLOOR(RAND() * GREATEST(TIMESTAMPDIFF(SECOND, v_created, NOW(6)), 1)),
v_created
)
);
INSERT INTO members (
date_of_birth, grade_id, role_id, created_at, last_login_at,
member_id, email, login_id, name, password, phone_number, gender, status
) VALUES (
v_dob, v_grade_id, v_role_id, v_created, v_last_login,
v_member_id,
CONCAT(v_login_prefix, v_member_id, '@', v_email_domain),
CONCAT(v_login_prefix, v_member_id),
CONCAT('테스트회원', LPAD(CAST(i+1 AS CHAR), 6, '0')),
'password',
CONCAT('010-', LPAD(MOD(v_member_id, 10000), 4, '0'), '-', LPAD(MOD(v_member_id * 7, 10000), 4, '0')),
v_gender,
'ACTIVE'
);
SET i = i + 1;
END WHILE;
COMMIT;
END $$
DELIMITER ;
(2) 더미 데이터 삽입
-- 1월 ~ 12월, 각 10만명
CALL seed_members_birthmonth(100000, 1);
CALL seed_members_birthmonth(100000, 2);
CALL seed_members_birthmonth(100000, 3);
CALL seed_members_birthmonth(100000, 4);
CALL seed_members_birthmonth(100000, 5);
CALL seed_members_birthmonth(100000, 6);
CALL seed_members_birthmonth(100000, 7);
CALL seed_members_birthmonth(100000, 8);
CALL seed_members_birthmonth(100000, 9);
CALL seed_members_birthmonth(100000, 10);
CALL seed_members_birthmonth(100000, 11);
CALL seed_members_birthmonth(100000, 12);
(3) 확인
- 1 ~ 12월까지 각각 100만 명의 회원 더미 데이터 생성.

(2) 로그 분석
로그를 보면 두 가지 패턴이 반복된다.
- 멤버 읽기(페이징)
- 페이징 처리를 하기 때문에 Limit Offset으로 회원을 Chunk Size 만큼 조회한다.
- 100건의 멤버 조회 쿼리 발생.
- 쿠폰/쿠폰 박스 INSERT
- 1에서 조회한 생일 회원 1명당 INSERT 2건(coupon 1건 + coupon_box 1건)이 발생한다.
- 10만명의 회원 x 2 = 20만 건의 INSERT 쿼리 발생.
Hibernate:
select
m1_0.member_id,
m1_0.created_at,
m1_0.date_of_birth,
m1_0.email,
m1_0.gender,
m1_0.grade_id,
m1_0.last_login_at,
m1_0.login_id,
m1_0.name,
m1_0.password,
m1_0.phone_number,
m1_0.role_id,
m1_0.status
from
members m1_0
where
month(m1_0.date_of_birth)=month(?)
limit
?, ?
Hibernate:
insert
into
coupons
(coupon_number, coupon_status_id, creation_time, price_polciy_for_book_id, price_polciy_for_category_id, rate_policy_for_book_id, rate_policy_for_category_id)
values
(?, ?, ?, ?, ?, ?, ?)
Hibernate:
insert
into
coupon_box
(coupon_id, issue_date_time, member_id, use_date_time)
values
(?, ?, ?, ?)
2. 기존 구조의 문제점
생일 쿠폰을 발급하는 배치 코드를 보면 JpaPagingItemReader와 ItemWriter조합으로 구현했다.
기능적으로는 문제 없이 동작하지만, 대용량 데이터를 처리하기에는 다음과 같은 문제가 있었다.
(1) Reader (JpaPagingItemReader)
- JPA의 불필요성: 이 배치는 단순히 생일인 회원을 조회하고 쿠폰을 발급하는 로직인데, "JPA의 Dirty Checking(변경 감지)과 영속성 관리가 필요한가" 라는 의문이 들었다.
- Offset 기반 페이지네이션의 한계: 내부적으로 LIMIT을 사용하는데, 이는 Offset이 커질수록 느려진다.
- 인덱스를 사용 X: WHERE MONTH(date_of_birth) = MONTH(:today)처럼 컬럼에 함수를 적용해 인덱스를 타지 않는다.
(2) Writer (ItemWriter)
- JPA와 Auto-Increment: 기본키 생성을 DB에 위임하는데, persist를 한 후 엔티티의 id값을 채워넣기 위해 select 절이 추가적으로 발생하기 때문에 batch insert가 불가능하다.
- 단건 INSERT: 생일인 회원 1명당 coupon 1건 + coupon_box 1건 = 2번의 INSERT가 발생한다. 즉, 생일자가 N명일 때 INSERT 횟수는 2N이다.
// reader - 생일인 멤버 읽기
@Bean
@StepScope
public JpaPagingItemReader<Member> birthdayMemberReader() {
return new JpaPagingItemReaderBuilder<Member>()
.name("birthdayMemberReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(CHUNK_SIZE)
.queryString("SELECT m FROM Member m WHERE MONTH(m.dateOfBirth) = MONTH(:today) AND m.status = :status")
.parameterValues(Collections.singletonMap("today", LocalDate.now()))
.parameterValues(Map.of("today", LocalDateTime.now(), "status", Member.Status.ACTIVE))
.build();
}
// processor 생략
// writer - 쿠폰 저장
@Bean
public ItemWriter<IssuedCoupon> birthdayIssueWriter() {
return issuedCoupons -> {
// 발급된 쿠폰 저장.
for (IssuedCoupon ic : issuedCoupons) {
entityManager.persist(ic);
}
};
}
3. 개선 과정 1 - 인덱스
Reader에서 생일인 회원을 찾는 쿼리에서 dateOfBirth와 status에 인덱스를 적용했다.
이 과정에서 MONTH(dateOfBirth)에 함수 인덱스를 적용해도 커버링 인덱스가 적용되지 않았다.
실제로 MySQL 공식 버그 리포트 질문글(https://bugs.mysql.com/bug.php?id=101208)이 있어서 확인해봤는데 답변으로 버그는 아니라고 했다. 버그는 아니지만 함수 인덱스를 사용하면 커버링 인덱스를 탈 수 없는 것 같다.
그래서 가상 컬럼을 사용하기로 했다.
인덱스가 없는 방식, 컬럼 인덱스, 함수 인덱스, 가상 컬럼 방식의 성능에 대해서 비교했다.
1200만 회원 중 100만명의 회원을 조회하는 비교다.
(1) 기존 방식 - 인덱스 적용 X
결과
풀테이블 스캔, 617ms
(2) 컬럼에 인덱스 적용
결과
인덱스가 걸리지 않았다.
(3) 함수 인덱스 적용
1. 회원 전체 조회 (SELECT * )
인덱스는 걸렸지만 커버링 인덱스가 아니다, 시간은 126ms로 크게 감소했다.
2. member_id만 조회 (SELECT member_id)
마찬가지로 인덱스가 걸렸지만, 커버링 인덱스가 아니다. 시간은 117ms로 위보다 조금 감소했다.
(4) 가상 컬럼 적용
1. 회원 전체 조회 (SELECT * )
인덱스는 걸렸지만 커버링 인덱스가 아니다, 시간은 131ms다.
2. member_id만 조회 (SELECT member_id)
커버링 인덱스가 걸렸고, 시간은 36ms로 가장 빠르다.
(5) 인덱스 비교 결과
동일 조건에서 가장 빠른 것은 가상 컬럼 + 커버링 인덱스였다.
단, member_id만 조회할 때 커버링 인덱스가 적용됐다.
조건 | 실행 시간(ms) |
인덱스 없음 + SELECT * | 617 |
함수 인덱스 + SELECT * | 126 |
함수 인덱스 + SELECT member_id | 117 |
가상 컬럼 인덱스 + SELECT * | 131 |
가상 컬럼 인덱스 + SELECT member_id | 36 |
4. 개선과정 2 - Reader
기존에 사용한 JpaPagingItemReader는 JPA를 사용하여 데이터베이스로부터 데이터를 페이지 단위로 읽는다.
생일 쿠폰을 발급하는 배치에서 JPA가 제공하는 Dirty Checking과 영속성 관리가 필요하지 않는다고 생각됐다.
또 Pagination 방식을 사용하는데 이는 내부적으로 Limit Offset을 사용해서 Offset이 커질수록 느려진다.
이러한 문제점을 바탕으로 여러개의 Reader를 비교해서 테스트를 진행했다.
- JpaPagingItemReader(기존)
- JpaCusorItemReader(제외): 모든 데이터를 메모리에 올려서, 서버에서 Iterator로 Cursor하는 방식으로 OOM 유발.
- JdbcPagingItemReader: 데이터베이스로부터 데이터를 페이지 단위로 읽는다.
- JdbcCursorItemReader: MySQL의 Cursor를 사용하여 일정 개수만큼 Fetch 하는 방식.
(1) JpaPagingItemReader (기존)
기존 Reader를 이용한 배치 테스트 결과로 약 61초가 걸렸다.
이 결과는 인덱스가 적용된 결과로 맨 처음의 90초 보다 성능이 다소 개선됐다.
JpaPagingItemReader
@Bean
@StepScope
public JpaPagingItemReader<Member> birthdayMemberReader() {
return new JpaPagingItemReaderBuilder<Member>()
.name("birthdayMemberReader")
.entityManagerFactory(entityManagerFactory)
.pageSize(CHUNK_SIZE)
.queryString("SELECT m FROM Member m WHERE MONTH(m.dateOfBirth) = MONTH(:today) AND m.status = :status")
.parameterValues(Collections.singletonMap("today", LocalDate.now()))
.parameterValues(Map.of("today", LocalDateTime.now(), "status", Member.Status.ACTIVE))
.build();
}
(2) JdbcPagingItemReader
약 53초가 걸렸다.
JdbcPagingItemReader
@Bean
@StepScope
public JdbcPagingItemReader<Long> birthdayMemberReader(
DataSource dataSource,
@Value("#{T(java.time.YearMonth).now(T(java.time.ZoneId).of('Asia/Seoul')).getMonthValue()}") Integer month
) throws Exception {
var provider = new MySqlPagingQueryProvider();
provider.setSelectClause("m.member_id");
provider.setFromClause("FROM members m");
provider.setWhereClause("WHERE birth_month = :month AND m.status = :status");
provider.setSortKeys(Map.of("member_id", Order.ASCENDING));
return new JdbcPagingItemReaderBuilder<Member>()
.name("JdbcPagingItemReader")
.fetchSize(CHUNK_SIZE)
.dataSource(dataSource)
.queryProvider(provider)
.parameterValues(Map.of(
"month", month,
"status", Member.Status.ACTIVE.name()))
.rowMapper((rs, i) -> rs.getLong("member_id"))
.build();
}
(3) JdbcCursorItemReader
약 53초가 걸렸다.
JdbcCursorItemReader
@Bean
@StepScope
public JdbcCursorItemReader<Long> birthdayMemberIdReader(
DataSource dataSource,
@Value("#{T(java.time.YearMonth).now(T(java.time.ZoneId).of('Asia/Seoul')).getMonthValue()}") Integer month
) {
// birth_month 가상 컬럼 사용
final String sql = """
SELECT m.member_id
FROM members m
WHERE m.birth_month = ? AND m.status = ?
ORDER BY m.member_id
""";
return new JdbcCursorItemReaderBuilder<Long>()
.name("birthdayMemberIdReader")
.dataSource(dataSource)
.sql(sql)
.fetchSize(CHUNK_SIZE)
.preparedStatementSetter(ps -> {
ps.setInt(1, month);
ps.setString(2, Member.Status.ACTIVE.name());
})
.rowMapper((rs, i) -> rs.getLong("member_id"))
.build();
}
(4) Reader 비교 결과
JdbcCursorItemReader를 선택했다.
현재 테스트한 데이터의 수(1200만)가 적어서 Cursor 방식과 별로 차이가 안났지만, 페이징 방식은 Limit Offset이 가지는 태생적 한계 때문에 데이터가 커지면 분명 Cursor보다 느려질 것이다.
따라서 장기적으로 데이터가 증가할 것을 대비하여 JdbcCursorItemReader를 사용하기로 결정했다.
Reader | 실행시간(초) |
JpaPagingItemReader | 61 |
JdbcPagingItemReader | 53 |
JdbcCursorItemReader | 53 |
5. 개선과정 3 - Writer
기존 Writer는 회원마다 각각 2건의 INSERT가 발생했다.
이를 해결하기 위해, 여러 개의 SQL 문을 하나의 묶음(배치)로 만들어서 처리하는 Batch Insert를 사용하기로 했다.
기존 Writer는 JPA 기반으로 데이터를 저장하며, 쿠폰 발급 과정에서 기본키 생성 전략으로 Auto-Increment를 사용한다.
이 방식은 기본키 생성을 DB에 위임하는데, JPA는 엔티티를 persist() 한 이후 곧바로 DB를 통해 생성된 ID(PK)를 가져온다.
그 결과, 여러 개의 SQL 문을 하나로 묶어서 처리하는 Batch Insert를 사용할 수 없다.
따라서 JdbcBatchItemWriter를 사용하기로 했다.
(1) JdbcBatchItemWriter
@Bean
public ItemWriter<BirthCouponIssueDto> jdbcBatchWriter(DataSource ds) {
var couponsWriter = new JdbcBatchItemWriterBuilder<BirthCouponIssueDto>()
.dataSource(ds)
.sql("""
INSERT INTO coupons
(coupon_id, coupon_status_id, creation_time, price_polciy_for_book_id, coupon_number)
VALUES (?, ?, ?, ?, ?)
""")
.itemPreparedStatementSetter((dto, ps) -> {
ps.setLong(1, dto.couponId());
ps.setLong(2, dto.statusId());
ps.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now()));
ps.setNull(4, Types.BIGINT);
ps.setString(5, String.valueOf(UUID.randomUUID()));
})
.build();
var issuedWriter = new JdbcBatchItemWriterBuilder<BirthCouponIssueDto>()
.dataSource(ds)
.sql("INSERT INTO coupon_box (coupon_id, member_id, issue_date_time) VALUES (?, ?, ?)")
.itemPreparedStatementSetter((r, ps) -> {
ps.setLong(1, r.couponId());
ps.setLong(2, r.memberId());
ps.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now()));
})
.build();
return items -> {
couponsWriter.write(items); // 부모 먼저
issuedWriter.write(items); // 자식 나중
};
}
결과
53초 -> 12.5초
(2) rewriteBatchedStatements
Writer를 할 때 Batch Insert가 되고 있지만 실제로는 Insert 구문이 chunk 갯수만큼 발생한다.
그래서 Insert 구문을 하나로 합치기 위해서 rewriteBatchedStatements를 적용하였다.
* Batch Insert는 chunk 개수만큼 발생한 INSERT문을 모아서 한 번에 실행시킨다. -> 네트워크 비용 감소.
rewriteBatchedStatements=true 는 이러한 개별 쿼리들을 하나의 최적화된 다중값 INSERT문으로 변환해서 성능을 크게 향상시킬 수 있다.
# DB
datasource:
url: jdbc:mysql://localhost:3306/onebook_refactor?useSSL=false&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
결과
12.5초 -> 5.4초
6. 결과
기존의 JPA 기반 배치 구조에서 아래 개선을 적용했다.
그 결과, 처리 시간이 90.1초 -> 5.4초로 약 94% 성능을 개선했다.
- 가상 컬럼을 활용한 인덱스 적용.
- JpaPagingItemReader -> JdbcCursorItemReader
- JPA 사용 X
- Limit Offset의 Offset이 커질수록 느려지는 한계 극복
- JdbcBatchItemWriter
- Batch Insert 사용하여 여러 INSERT를 모아서 한 번에 실행
- rewriteBatchedStatements
- 개별 쿼리들을 하나의 최적화된 다중값 INSERT문으로 변환하여 실행
* 코드
BirthdayCouponBatchConfig
@Slf4j
@RequiredArgsConstructor
@Configuration
class BirthdayCouponBatchConfig extends DefaultBatchConfiguration {
@PersistenceContext private EntityManager entityManager;
private final PolicyStatusRepository policyStatusRepository;
private static final int CHUNK_SIZE = 1000;
// birthday Job
@Bean
public Job birthdayCouponJob(
JobRepository jobRepository,
Step createBirthdayPolicyStep,
Step issueBirthdayCouponStep) {
return new JobBuilder("birthdayCouponJob", jobRepository)
.start(createBirthdayPolicyStep) // 이번달 생일 쿠폰 정책 생성
.next(issueBirthdayCouponStep) // 생일인 회원에게 쿠폰 발급
.build();
}
// Step 1 - 이번달에 사용할 생일 쿠폰 정책 생성
@Bean
public Step createBirthdayPolicyStep(JobRepository repo, PlatformTransactionManager transactionManager) {
return new StepBuilder("createBirthdayPolicyStep", repo)
.tasklet((contribution, chunkContext) -> {
PolicyStatus policyStatus = policyStatusRepository.findByName("use");
// 시간/날짜
ZoneId korea = ZoneId.of("Asia/Seoul");
YearMonth ym = YearMonth.now(korea);
LocalDateTime now = LocalDateTime.now(korea);
LocalDateTime monthStart = ym.atDay(1).atStartOfDay();
LocalDateTime monthEnd = ym.atEndOfMonth().atTime(23, 59, 59);
// 생일 쿠폰 정책 생성
PricePolicyForBook pricePolicyForBook = PricePolicyForBook.birthdayPricePolicyForBook(
5000,
5000,
monthStart,
monthEnd,
now + " Birthday Coupon Price Policy",
"생일 쿠폰 정책",
policyStatus
);
entityManager.persist(pricePolicyForBook);
ExecutionContext executionContext = chunkContext.getStepContext()
.getStepExecution().getJobExecution().getExecutionContext();
// 발급된 생일 쿠폰 정책 이름을 ExecutionContext에 저장
executionContext.putString("pricePolicyForBookName", pricePolicyForBook.getName());
return RepeatStatus.FINISHED;
}, transactionManager)
.build();
}
// Step 2 - 생일인 회원에게 쿠폰 발급
@Bean
public Step issueBirthdayCouponStep(
JobRepository repo,
PlatformTransactionManager platformTransactionManager,
JdbcCursorItemReader<Long> birthdayMemberReader,
ItemProcessor<Long, BirthCouponIssueDto> birthdayIssueProcessor,
ItemWriter<BirthCouponIssueDto> birthdayIssueWriter,
SimpleToTimer simpleToTimer
) {
return new StepBuilder("issueBirthdayCouponStep", repo)
.<Long, BirthCouponIssueDto>chunk(CHUNK_SIZE, platformTransactionManager)
.reader(birthdayMemberReader)
.processor(birthdayIssueProcessor)
.writer(birthdayIssueWriter)
.listener(Optional.ofNullable(simpleToTimer))
.build();
}
// JdbcCursorItemReader
@Bean
@StepScope
public JdbcCursorItemReader<Long> birthdayMemberIdReader(
DataSource dataSource,
@Value("#{T(java.time.YearMonth).now(T(java.time.ZoneId).of('Asia/Seoul')).getMonthValue()}") Integer month
) {
// birth_month 가상 컬럼을 쓰면 인덱스를 잘 탑니다.
final String sql = """
SELECT m.member_id
FROM members m
WHERE m.birth_month = ? AND m.status = ?
ORDER BY m.member_id
""";
return new JdbcCursorItemReaderBuilder<Long>()
.name("birthdayMemberIdReader")
.dataSource(dataSource)
.sql(sql)
.fetchSize(CHUNK_SIZE)
.preparedStatementSetter(ps -> {
ps.setInt(1, month);
ps.setString(2, Member.Status.ACTIVE.name());
})
.rowMapper((rs, i) -> rs.getLong("member_id"))
.build();
}
// processor - 쿠폰 생성 후 멤버에게 쿠폰 발급
// ItemProcessor<I, O>: I - Reader에게 받을 데이터 타입, O - Writer에게 보낼 데이터 타입
@Bean
@StepScope
public ItemProcessor<Long, BirthCouponIssueDto> birthdayIssueProcessor(
JdbcTemplate jdbcTemplate,
@Value("#{jobExecutionContext['pricePolicyForBookName']}") String policyName
) {
final long couponStatusId = jdbcTemplate.queryForObject(
"SELECT coupon_status_id FROM coupon_status WHERE name='issued'", Long.class
);
final long policyId = jdbcTemplate.queryForObject(
"SELECT price_policy_for_book_id FROM price_policies_for_book WHERE name=?", Long.class, policyName
);
return memberId -> {
Long couponId = TSID.fast().toLong(); // TSID 생성.
return new BirthCouponIssueDto(couponId, memberId, policyId, couponStatusId);
};
}
@Bean
public ItemWriter<BirthCouponIssueDto> jdbcBatchWriter(DataSource ds) {
var couponsWriter = new JdbcBatchItemWriterBuilder<BirthCouponIssueDto>()
.dataSource(ds)
.sql("""
INSERT INTO coupons
(coupon_id, coupon_status_id, creation_time, price_polciy_for_book_id, coupon_number)
VALUES (?, ?, ?, ?, ?)
""")
.itemPreparedStatementSetter((dto, ps) -> {
ps.setLong(1, dto.couponId());
ps.setLong(2, dto.statusId());
ps.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now()));
ps.setNull(4, Types.BIGINT);
ps.setString(5, String.valueOf(UUID.randomUUID()));
})
.build();
var issuedWriter = new JdbcBatchItemWriterBuilder<BirthCouponIssueDto>()
.dataSource(ds)
.sql("INSERT INTO coupon_box (coupon_id, member_id, issue_date_time) VALUES (?, ?, ?)")
.itemPreparedStatementSetter((r, ps) -> {
ps.setLong(1, r.couponId());
ps.setLong(2, r.memberId());
ps.setTimestamp(3, Timestamp.valueOf(LocalDateTime.now()));
})
.build();
return items -> {
couponsWriter.write(items); // 부모 먼저
issuedWriter.write(items); // 자식 나중
};
}
}
참고
'프로젝트 > 쇼핑몰 프로젝트' 카테고리의 다른 글
[쇼핑몰 프로젝트 - 리팩토링] 무중단 배포 (0) | 2025.09.16 |
---|---|
[쇼핑몰 프로젝트 - 리팩토링] 상품 목록 조회 최적화: N+1 해결 & 인덱스로 31% 개선 (0) | 2025.08.20 |
[쇼핑몰 프로젝트 - 리팩토링] 카테고리 N+1 문제 해결과 Redis 캐싱 적용 (4) | 2025.08.07 |
[쇼핑몰 프로젝트] FeignClient 에러 처리: ErrorDecoder (0) | 2025.08.02 |
[쇼핑몰 프로젝트] Spring 예외 처리: ErrorCode 기반 공통 처리로 단순화하기 (3) | 2025.08.01 |