부트캠프 과제 구현 중 기록
100만 건의 데이터를 생성해 저장하고, 목록을 조회 하기
대용량 데이터를 저장하기 위한 방법 기록
데이터 조회 성능 개선: https://rvrlo.tistory.com/entry/SpringBootJPA-조회-성능-개선하기-FULLTEXT-Index-사용
🔎 과제 분석
100만 건의 데이터를 저장하기 위해 랜덤한 값을 생성해야 했다.
먼저 목표인 users 테이블은 pk인 id와 unique로 지정된 email이 존재한다.
id는 auto_increment로 지정되어 상관없지만, 공통된 값이 나오면 곤란할 email을 랜덤하게 생성해야 했다.
그리고 과제에 주어진 조건은 → 닉네임은 랜덤으로 지정하고, 동일한 닉네임이 들어가지 않도록 하는 것
결국 email과 nickname 모두 랜덤한 값을 생성해야 했다.
간단하게 숫자를 하나씩 증가시켜 string 뒤에 붙이면 되지만 좀 더 나은 방법을 고려해야 했다.
✨ 구현 방법
그렇게 찾게된 것은 테스트용 데이터를 생성할 때 사용한다는 FixtureMonkey
와 RandomStringUtils
를 사용하는 것
쉽게 객체를 생성할 수 있는 FixtureMonkey
는 객체 하나만이 아닌 여러 개를 만들어 List로 생성할 수 있었다.
그 객체의 개수 또한 sampleList()
라는 메서드를 통해 파라미터로 생성하고 싶은 객체의 개수를 넣어주면 원하는 양의 데이터를 생성할 수 있다.
RandomStringUtils
는 랜덤한 문자열을 생성하는 라이브러리로 랜덤한 문자열 여러개를 어떻게 생성할까? 찾아보다가 Baeldung에 정리되어 있어 가져왔다. (https://www.baeldung.com/java-random-string)
이 두 라이브러리를 통해 랜덤한 email과 nickname을 가진 객체 100만건을 만들고, batch insert를 이용해 데이터를 저장해보기로 했다.
🛠️ 과정
✔️ JdbcTemplate를 이용해 batchUpdate 실행
JPA의 saveAll()
은 내부적으로 개별 save()
를 호출한다.
대량의 데이터를 저장할 때는 saveAll()
보다 jdbc batch insert를 사용하면 좀 더 좋은 성능을 낼 수 있다.
사바라다 [JPA] Spring JPA 환경에서 bulk insert를 효율적으로 사용해보자 - JPA의 한계와 JDBC 활
@joonghyun: Spring JPA Save() vs SaveAll() vs Bulk Insert
위 블로그를 참고하여 코드를 작성했지만
많은 고난과 역경이 함께 했다...
- 자동으로 지정되는 id, created_at, modified_at은 어떻게 하느냐?
- Enum인 user_role은 어떻게 하느냐?
이 두 가지가 가장 큰 숙제였다. 저장할 쿼리 순서에 맞춰 PreparedStatement.set~
의 순서를 맞춰줘야 했고, 그럼 1번도 포함해야 하나? 라는 고민이 있었다.
아무리 자동화를 해준다고 하지만 직접 쿼리문을 작성해 넣어주고 있으니 하나씩 넣어야 하는 게 아닐까? 라고 생각했다.
id는 sql자체에서도 auto_increment가 가능하니 제외하고, 날짜를 모두 넣어보았다.
그리고 setObject()
가 있길래 Enum을 넣을 수 있나? 하는 마음으로 Enum 자체를 저장해보았다.
@Transactional
@Override
public void bulkInsert(List<User> users, int batchSize) {
// batchSize 크기만큼 나눠서 insert
batchInsert(users, batchSize);
}
private void batchInsert(List<User> users, int batchSize) {
String sql = "INSERT INTO users (email, password, user_role, nickname, created_at, modified_at) values (?, ?, ?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, users, Math.max(1, batchSize), (ps, user) -> {
ps.setString(1, user.getEmail());
ps.setString(2, user.getPassword());
ps.setObject(3, user.getUserRole());
ps.setString(4, user.getNickname());
ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now()));
ps.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now()));
});
}
org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback; bad SQL grammar [INSERT INTO users (email, password, user_role, nickname, createdAt, modifiedAt) values (?, ?, ?, ?, ?, ?)]
쿼리 작성 문제 발생
Enum은 Sql에 담기지 않아 String
으로 변환해줘야 한다.
이 코드는 DB에 직접 쿼리문을 전달하고 있으니 그 타입이 잘못되었던 것 같다.
Enum을 String으로 변경해 다시 수정하였다.
private void batchInsert(List<User> users, int batchSize) {
String sql = "INSERT INTO users (email, password, user_role, nickname, created_at, modified_at) values (?, ?, ?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, users, batchSize, (ps, user) -> {
ps.setString(1, user.getEmail());
ps.setString(2, user.getPassword());
ps.setString(3, user.getUserRole().getUserRole());
ps.setString(4, user.getNickname());
ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now()));
ps.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now()));
});
}
getUserRole()
을 했지만 이번에도 쿼리 문제 발생
혹시 Enum에서 제공하고 있는 name()
을 이용해야 하는 걸까? 싶어 변경
private void batchInsert(List<User> users, int batchSize) {
String sql = "INSERT INTO users (email, password, user_role, nickname, created_at, modified_at) values (?, ?, ?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, users, batchSize, (ps, user) -> {
ps.setString(1, user.getEmail());
ps.setString(2, user.getPassword());
ps.setString(3, user.getUserRole().name());
ps.setString(4, user.getNickname());
ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now()));
ps.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now()));
});
}
하지만 이번에도 쿼리 문제 발생
Enum은 더 이상 변경할 수 있는 방법이 없는 것 같았다.
그럼 created_at과 modified_at은 JPA가 직접 넣어줄 수 있을까? 두 필드는 @CreatedDate
와 @LastModifiedDate
가 적용되어 있다. 그리고 created_at은 컬럼을 업데이트하지 못하도록 @Column(updatable = false)
로 지정해두었다.
이 부분을 다른 entity를 저장하듯이 아예 빼면 JPA가 저장해주는 걸까?
@Transactional
@Override
public void bulkInsert(List<User> users, int batchSize) {
// batchSize 크기만큼 나눠서 insert
batchInsert(users, batchSize);
}
private void batchInsert(List<User> users, int batchSize) {
String sql = "INSERT INTO users (email, password, user_role, nickname) values (?, ?, ?, ?)";
jdbcTemplate.batchUpdate(sql, users, batchSize, (ps, user) -> {
ps.setString(1, user.getEmail());
ps.setString(2, user.getPassword());
ps.setString(3, user.getUserRole().name());
ps.setString(4, user.getNickname());
});
}
이 방법이 맞았고, 데이터가 저장되었다.
직접 쿼리문을 작성해 데이터를 저장하고 있어서 모든 값을 다 지정해야 한다고 생각했다.
하지만 이 역시 JPA를 사용하고 있어 자동으로 생성되는 값들에 손을 대면 오히려 문제가 생겼다.
Enum은 지정해주었던 String으로 변환하고(꼭 Enum.name()을 사용할 것)
자동으로 생성할 id나 time stamp들은 신경쓰지 말자. 그냥 다른 객체를 저장하는 것처럼 생성하고 저장해도 문제가 되지 않는다.
✔️ FixtureMonkey로 여러 객체를 생성
FixtureMonkey를 이용해 문제의 100만 건을 생성해보자.
implementation 'org.apache.commons:commons-lang3:3.12.0'
testImplementation 'com.navercorp.fixturemonkey:fixture-monkey-starter:1.1.10'
RandomStringUtils를 위한 apache commons 의존성을 추가하고
FixtureMonkey를 위한 의존성을 추가하였다.
@Test
void 유저_데이터_생성() {
FixtureMonkey fixtureMonkey = FixtureMonkey.create();
AtomicLong idGenerator = new AtomicLong(1);
List<User> users = fixtureMonkey.giveMeBuilder(User.class)
.set("id", idGenerator.getAndIncrement())
.set("email", () -> RandomStringUtils.randomAlphanumeric(9) + "@gmail.com")
.set("password", "password123!")
.set("userRole", UserRole.ROLE_USER)
.set("nickname", RandomStringUtils.randomAlphanumeric(9))
.setNull("createdAt")
.setNull("modifiedAt")
.sampleList(1000000);
// 100만 건의 user를 1만개 씩 나눠서 저장
userRepository.bulkInsert(users, 10000);
assertEquals(1000000, users.size());
}
그리고 작성한 테스트 코드
정말 1,000,000개의 데이터가 생성되었는지 확인하기 위해 assertEquals()
를 이용해 확인해보았다.
여러러 데이터를 나눠서 저장하기 위한 batch size는 저장을 요청할 때마다 다른 양의 데이터가 전달될 수 있음을 감안하고, 인자로 함께 전달하도록 만들었다.
set()
을 통해 각 필드에 값을 넣어주고, sample()
을 이용해 테스트 데이터를 생성한다.
여러 개의 샘플링이 필요할 경우 sampleList()
의 인자로 필요한 개수를 입력하면 된다.
id의 경우 값을 반환하고, 하나씩 증가시키도록AtomicLong
을 이용했는데,batchInsert()
를 수정하고 보니setNull()
을 해도 될 것 같다.
💡 결과
RandomStringUtils를 해도 중복된 이메일이라고 실행 도중 오류가 발생했기 때문에 이를 확인할 때도 중복된 랜덤값이 나와서 닉네임을 확인할 수 있지 않을까? 라는 아주 큰 기대를 했는데 전혀 나오지 않았다. 결국 bulkInsert()
아래에 일반 save()
를 추가하여 내가 직접 지정한 닉네임을 검색해야겠다고 생각했다.
userRepository.bulkInsert(users, 10000);
userRepository.save(new User("email@email.com", "password123!", UserRole.ROLE_USER,"nickname"));
위 테스트 코드에 save()
를 추가하고, @BeforeEach
로 변경한 뒤, 조회하는 @Test
를 추가했다.
@Test
void 유저_닉네임_검색() {
// 제일 마지막에 저장한 값을 이용해 조회
String nickname = "nickname";
User getUser = userRepository.findByNickname(nickname);
assertEquals(nickname, getUser.getNickname());
}
결과는

아주아주 긴 시간이 걸렸는데, 아무래도 100만 건의 데이터를 생성하고 저장하는 데 걸리는 시간이 큰 것 같았다.
추후 본격적으로 조회 성능을 개선하기 위해 테스트 할 때는 데이터 저장 후, spring.jpa.hibernate.ddl-auto=update
를 이용해 저장된 데이터를 가지고 조회만 테스트하도록 해야 할 것 같다.
'Database > JPA' 카테고리의 다른 글
[SpringBoot/JPA] 조회 성능 개선하기 - FULLTEXT Index 사용 (0) | 2025.03.18 |
---|---|
[JPA] persist(), flush() 그리고 commit() 어떻게 DB에 반영될까? (1) | 2025.02.06 |