실습 환경
- MySQL 5.7버전 사용
- Windows 10
- Entity ID 전략은 IDENTITY
- Java 11
서론
실무에서 MySQL 5.7 버전을 사용하고 있고, JPA Entity의 ID 전략은 IDENTITY를 사용하고 있는데, 이때 JPARepository를 이용한 saveAll과 save가 bulk insert로 처리되지 않는 것을 발견했습니다.
당장은 saveAll로 아주 많은 데이터를 저장할 일이 없다고 하더라도, 왜 이렇게 작동하는지 확인하고 추후에 동일한 환경에서 bulk 연산을 처리해야 할 때 유연하게 대처할 수 있어야 합니다.
따라서, 이번 기회에 상황과 원인을 파악하고 어떻게 대처할 수 있는지 알아보겠습니다.
결론만 보고 싶으시다면 하단의 “정리” 부분을 보시면 되겠습니다.
작동 원리
바로 결론부터 말씀드리자면, saveAll이나 save나 내부적으로 동작하는 원리는 같습니다.
saveAll에 List 형태의 Entity를 넣게 되면 결국 반복문을 돌며 save를 한 번씩 호출합니다.
save, saveAll 메서드
코드를 따라가며 직접 확인해보겠습니다.
JpaRepository의 구현체인 SimpleJpaRepository.java를 살펴보면 그동안 자주 사용해 왔던 메서드들의 로직을 살펴볼 수 있습니다.
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
// 기타 메서드 생략
@Transactional
@Override
public <S extends T> List<S> saveAll(Iterable<S> entities) {
Assert.notNull(entities, "Entities must not be null");
List<S> result = new ArrayList<>();
for (S entity : entities) {
result.add(save(entity)); // N개의 Entity를 save
}
return result;
}
@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);
}
}
}
saveAll을 호출해도 내부적으론 save 메서드를 순차적으로 호출하게 구현되어 있는 것을 볼 수 있습니다.
결국, 한 트랜잭션 안에서 반복문을 돌며 100개의 save를 호출하던, 100개의 Entity List를 saveAll로 1회 호출하던 결과는 같다는 것을 알 수 있습니다.
save 메서드를 조금 더 자세히 살펴보겠습니다.
조건문에 isNew 메서드가 호출되는데, 이 메서드를 따라가 보면 AbstractEntityInformation.java로 이어집니다.
// AbstractEntityInformation.java
public boolean isNew(T entity) {
ID id = getId(entity);
Class<ID> idType = getIdType();
if (!idType.isPrimitive()) {
return id == null;
}
if (id instanceof Number) {
return ((Number) id).longValue() == 0L;
}
throw new IllegalArgumentException(String.format("Unsupported primitive id type %s!", idType));
}
영속성 컨텍스트에서 ID를 가져오고, 그 ID가 null인지 아닌지를 판단하여 이 Entity는 새로운 Entity임을 판단합니다.
또한 em.persist 작동 시 Entity의 PK 값은 필수입니다. 따라서 IDENTITY로 ID를 관리하게 되는 경우 PK 값이 없기 때문에 persist 수행 즉시 영속성 컨텍스트에 등록하기 위해 INSERT 쿼리가 실행됩니다.
em.persist가 수행된다고 해서 바로 쿼리가 날아가는 게 맞을까?를 확인하기 위해 BreakPoint를 걸고 직접 확인해 보았습니다.
사진과 마찬가지로 em.persist 수행 시 INSERT 쿼리가 실행되는 것을 확인할 수 있었습니다.
즉, 1천 건의 Entity가 포함된 List를 saveAll 해도, 1000번의 INSERT 쿼리가 실행된다는 의미입니다.
PK를 어떻게 얻어오는 걸까?
MySQL이 알아서 잘하겠지만,, PK를 어떻게 동시성 문제없이 잘 관리해 주는지 궁금했습니다.
정말 당연한 결과이지만, 제 호기심을 해소하기 위한 파트입니다.
이미 알고 계신 분은 스킵해도 좋습니다.
간단하게 동일한 앱 두 개를 띄우고 em.persist에 BreakPoint를 건 후 entity에 PK가 어떻게 입력되는지 확인해보려 합니다.
좌측이 1번 앱, 우측이 2번 앱이라고 가정하겠습니다.
먼저 1번 앱을 em.persist 1회 수행하고 2번 앱도 마찬가지로 1회 수행했습니다.
그리고 하단 Debugger를 보면 번호가 순차적으로 발급된 것을 확인할 수 있었습니다.
그 뒤에 1번 앱의 남은 반복문을 모두 수행해 보았는데요,
사진처럼 이미 2번 앱에서 가져간 271번은 건너뛰어 그다음 번호부터 발급된 것을 확인했습니다.
이 말인즉슨, MySQL에 INSERT 쿼리가 날아가면서 PK값을 MySQL 자체적으로 관리하고 이를 앱에다 반환해 주는 방식으로 유추할 수 있습니다.
그리고 1번 앱을 커밋까지 수행해 보았더니,
em.persist를 통해 얻어온 결과와 동일하게 271번은 건너뛴 것으로 확인되었습니다.
여기서 든 궁금증이 하나 더 있었는데요, 2번 앱은 아직 트랜잭션이 종료되지 않았기 때문에 271번을 점유하고 있고, 만약 트랜잭션을 중단시켜 버리면 1번 앱이 271번까지 가져갈까? 에 대한 궁금증이었습니다.
즉, em.persist를 통해 받아온 pk로 유지되는 게 아니라, commit 되는 순간에 데이터가 반영되면서 pk 값이 다시 채번되는지에 대한 궁금증이었습니다.
이번에 동일한 조건으로 2번 앱이 pk 하나를 점유하고, 1번 앱 커밋 직전에 2번 앱의 트랜잭션을 중단시킨 후 1번 앱을 커밋해 보겠습니다.
위 테스트와 동일하게, 1번 앱부터 2번 앱 순으로 PK를 순차적으로 채번 했습니다.
2번 앱이 점유했던 471번 pk를 건너뛴 것을 확인했으며, 이어서 2번 앱의 트랜잭션을 끝냈습니다.
결론
커밋 결과는 em.persist를 통해 채번 된 pk와 동일하게 삽입됨을 알 수 있었습니다.
정리하자면, commit 이전에도 PK 채번이 가능합니다. 그리고 한번 채번 된 PK는 트랜잭션이 실패할 경우에도 롤백되지 않습니다. 즉, 271번 PK를 채번 하고 트랜잭션에 실패하게 되면 그 번호는 건너뜁니다. 별도의 설정 없이는 다시 271로 돌아가지 않습니다.
Baeldung과 결과가 다르다.
https://www.baeldung.com/jpa-strategies-when-set-primary-key
여기선 em.persist 수행 후 entity에 pk 값은 null로 삽입이 되고, commit 이후에 pk 값이 삽입된다!
라고 나와있습니다.. 하지만 제가 테스트한 결과에서는
em.persist 수행 직후 pk 값이 입력 되는 것을 확인했고, commit을 하게 되면 db에 데이터가 반영된다!라는 결과를 얻었죠.
왜 다를까요..? 정확한 이유는 모르지만, 아마 2020년도에 작성된 글인 것으로 보아, Hibernate 버전이 올라가면서 변경된 부분이 아닌가라고 조심스레 유추합니다.
Bulk INSERT 테스트
MySQL JDBC 드라이버 로깅 옵션 설정
spring:
datasource:
url: jdbc:mysql://{주소:포트}/{DB명}?profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=1000
- profileSQL=true: SQL 쿼리 프로파일링을 활성화합니다. 이 옵션을 설정하면 실행된 SQL 쿼리에 대한 성능 통계를 수집하여 로그로 남깁니다.
- logger=Slf4JLogger: 데이터베이스 로그를 출력하는 로거를 설정합니다. 이 경우에는 Spring의 logging abstraction 라이브러리 중 하나인 Slf4J 라이브러리를 사용합니다.
- maxQuerySizeToLog=1000: 로그로 남길 SQL 쿼리의 최대 크기를 지정합니다. 이 경우 1000은 SQL 쿼리가 너무 길어지지 않도록 최대 길이를 지정한 것입니다.
해당 옵션들을 설정하면 제가 테스트하며 보여드린 쿼리처럼 실제 실행되는 쿼리를 확인할 수 있습니다.
JPA save 테스트 진행
로깅 설정은 끝났고, 이제 육안으로 확인하기 위해 테스트만을 위한 간단한 코드를 작성했습니다.
위에서 진행한 테스트와 동일한 코드입니다.
// Dummy.java
// 테스트에 사용될 Entity
@Builder
@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class Dummy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // IDENTITY 전략 사용
Long id;
String name;
}
// DummyRepository.java
// Dummy Entity와 관련된 DB 작업을 처리하기 위한 Repository
@Repository
public interface DummyRepository extends JpaRepository<Dummy, Long> {
}
// BulkTest.java
// Bulk Insert를 수행하는 Test 코드
@SpringBootTest
public class BulkTest {
@Autowired
DummyRepository dummyRepository;
@Test
@Transactional
@Rollback(false)
void jpa_bulk_test() {
List<Dummy> dummies = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Dummy dummy = Dummy.builder()
.name("ks"+i)
.build();
dummies.add(dummy);
}
dummyRepository.saveAll(dummies);
}
}
이미 위에서 언급했듯이, save와 saveAll의 로직은 동일하기에 saveAll로 테스트를 진행하며, IDENTITY 전략을 사용중이니 MySQL이 ID를 직접 관리할 겁니다.
콘솔에 실제 쿼리가 어떻게 날아가는지 확인해 보면,
위 사진처럼 saveAll을 할 때 Bulk로 처리되지 않는 것을 확인했습니다.
또한, JPA로 INSERT를 수행하게 되는 경우 JPA의 영속성 캐시에 ENTITY가 등록될 텐데, 이때 ID값도 필요하기 때문에 단건의 INSERT가 수행됩니다.
벌크 연산 실행
벌크 연산 옵션 활성화
미리 말씀드리지만, IDENTITY 전략을 사용 중이고, JPA를 통해 save를 호출하는 경우 Bulk 쿼리가 수행되지 않습니다. 이는 JdbcTemplate을 이용한 방법으로 처리가 가능하니 착오 없으시기 바랍니다.
bulk 연산을 위해서는 JDBC의 rewriteBatchedStatements 옵션을 활성화해주면 됩니다.
이 테스트를 통해 두 가지 다른 결과를 예측할 수 있는데요.
별도의 옵션 적용 없이 MySQL 8 미만 버전을 사용하고 계시다면 벌크 연산이 일어나지 않고,
MySQL 8 이상을 사용하고 계시다면 별도의 옵션 적용 없이도 벌크 연산이 일어납니다.
좀 더 정확하게 말하면, 버전 차이 때문이라기 보단, 단순 해당 옵션이 true이냐 false이냐에 따라 결과가 달라지는 것입니다.
MySQL 8로 올라가면서 rewriteBatchedStatements 옵션의 default 값이 true가 되었고 그 이전엔 false였기 때문이죠.
spring:
datasource:
url: jdbc:mysql://{주소:포트}/{DB명}?rewriteBatchedStatements=true
위에서 언급했듯, 여전히 IDENTITY 전략을 사용하면서, JPA로 Bulk Insert는 불가능합니다.
이는 Hibernate에서 IDENTITY인 경우 Bulk Insert가 되지 않도록 자체적으로 막아두었는데, 이유는 이곳에서 자세히 확인할 수 있습니다.
JDBC 벌크 연산 테스트
// BulkInsertRepository.java
// JDBC를 이용해 Bulk Insert를 수행하는 Repository
@Repository
@RequiredArgsConstructor
public class BulkInsertRepository {
private final JdbcTemplate jdbcTemplate;
public void saveAll(List<Dummy> dummies) {
jdbcTemplate.batchUpdate("INSERT INTO dummy(name) values (?)",
new BatchPreparedStatementSetter() {
@Override
public void setValues(PreparedStatement ps, int i) throws SQLException {
ps.setString(1, dummies.get(i).getName());
}
@Override
public int getBatchSize() {
return dummies.size();
}
});
}
}
// BulkTest.java
// Bulk Insert를 수행하는 Test 코드
@SpringBootTest
public class BulkTest {
@Autowired
BulkInsertRepository bulkInsertRepository;
@Autowired
DummyRepository dummyRepository;
@Test
@Transactional
@Rollback(false)
void jdbc_bulk_test() {
List<Dummy> dummies = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Dummy dummy = Dummy.builder()
.name("ks"+i)
.build();
dummies.add(dummy);
}
bulkInsertRepository.saveAll(dummies);
}
@Test
@Transactional
@Rollback(false)
void jpa_bulk_test() {
List<Dummy> dummies = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Dummy dummy = Dummy.builder()
.name("ks"+i)
.build();
dummies.add(dummy);
}
dummyRepository.saveAll(dummies);
}
}
기존의 테스트 코드에 jdbc_bulk_test 메서드를 추가로 구현했습니다.
해당 메서드를 실행하면 아래와 같이 Bulk INSERT가 수행되는 것을 볼 수 있습니다.
정리
- 테스트했던 환경은 ENTITY ID(PK) 전략이 IDENTITY였습니다. 이는 ID 관리를 MySQL에 위임하는 방식입니다.
- 또한 가장 큰 특징 중 하나가 em.persist가 작동하는 즉시 INSERT 쿼리가 실행됩니다.
- Entity를 영속성 컨텍스트에 등록하기 위해선 반드시 PK값이 필요한데 이를 가져오기 위해 DB로 쿼리를 보냅니다.
- 하지만 DB에 바로 반영되진 않습니다. 다른 전략과 동일하게 commit이 일어나기 전이니까요.
- 이런 환경에서 JpaRepository를 사용해서 Bulk Insert를 수행할 수 없습니다.
- 따라서 JdbcTemplate을 이용해 Bulk Insert를 수행합니다.
- 이때, MySQL 8 버전 미만인 경우 rewriteBatchedStatements 옵션이 활성화되어있는지 확인해야 합니다.
'Tech > Java&Spring' 카테고리의 다른 글
멀티스레드 분산 환경에서의 로깅(2) (0) | 2023.06.04 |
---|---|
멀티스레드 분산 환경에서의 로깅(1) (0) | 2023.05.21 |
try-with-resources와 native 영역 (0) | 2023.03.11 |
[SpringBoot] Intellij spring boot 프로젝트 생성 방법 (0) | 2022.01.10 |
[Springboot] 민감정보 숨기기 - Argument 입력 (0) | 2021.11.30 |
가파른 성장을 이루고자 노력하는 개발자입니다. 정리하고, 설명하고, 이해시키는 과정으로 보람을 느낍니다. 개발과 관련된 다양한 정보를 몰입감있게 전달합니다.