πŸ“š[Spring] λŒ€μš©λŸ‰ 데이터 처리

텁텁·2025λ…„ 6μ›” 30일

λŒ€μš©λŸ‰ 데이터 처리

λ“€μ–΄κ°€λ©°

	@Test
void signup_백만건의_μœ μ €κ°€μž…() {
	List<User> users = new ArrayList<>();
	String password = passwordEncoder.encode("1234");
	
	for(int i=0; i<1000000; i++) {
		users.add(User.of("hong"+i+"@gmail.com", password, nicknameGenerate(), UserRole.USER));
	}
	userRepository.saveAll(users);
}

λ‹¨μˆœνžˆ saveAll()에 100만 건을 ν•œ λ²ˆμ— λ„˜κΈ°λ©΄ μ–΄λ–»κ²Œ 될까 κΆκΈˆν•΄μ„œ μ‹€ν–‰ν•΄λ΄€λ‹€.
μ˜ˆμƒλŒ€λ‘œ java.lang.OutOfMemoryError: Java heap space μ˜ˆμ™Έκ°€ λ°œμƒν•˜λ©° ν…ŒμŠ€νŠΈλŠ” μ•½ 3λΆ„ λ§Œμ— μ‹€νŒ¨ν–ˆλ‹€.
μ˜μ†μ„± μ»¨ν…μŠ€νŠΈκ°€ λΉ„μ›Œμ§€μ§€ μ•ŠμœΌλ©΄μ„œ λ©”λͺ¨λ¦¬κ°€ λˆ„μ λœ 것이 μ›μΈμ΄μ—ˆλ‹€.

  • κ²°κ³Ό: BUILD FAILED in 3m 17s

10,000건씩 100번 μ €μž₯

saveAll() ν˜ΈμΆœλ§ˆλ‹€ μ˜μ†μ„± μ»¨ν…μŠ€νŠΈλ₯Ό λΉ„μ›Œμ£Όλ©΄ λ©”λͺ¨λ¦¬ λˆ„μˆ˜λ₯Ό λ°©μ§€ν•  수 μžˆμ„ 거라 νŒλ‹¨ν–ˆλ‹€.

	@Test
	void signup_백만건의_μœ μ €κ°€μž…() {
		List<User> users = new ArrayList<>();
	    String password = passwordEncoder.encode("1234");
		int cnt = 1;
		
		for(int i=0; i<100; i++) {
			for(int ii=0; ii<10000; ii++) {
				users.add(User.of("hong"+ cnt, password, nicknameGenerate(), UserRole.USER));
				cnt ++;
			}
			userRepository.saveAll(users);
			users.clear();
		}
	}

μ˜ˆμƒλŒ€λ‘œ λ¬Έμ œμ—†μ΄ μ„±κ³΅ν–ˆλ‹€.
λ©”λͺ¨λ¦¬λ„ κ³Όν•˜κ²Œ μ°¨μ§€ν•˜μ§€ μ•Šμ•˜κ³  μ„±λŠ₯도 μ€€μˆ˜ν–ˆλ‹€.

  • κ²°κ³Ό: BUILD SUCCESSFUL in 2m 51s

1,000건씩 1,000번 μ €μž₯

더 μž‘μ€ λ‹¨μœ„λ‘œ λ‚˜λˆ„λ©΄ 속도 차이가 μžˆμ„κΉŒ?

	@Test
	void signup_백만건의_μœ μ €κ°€μž…() {
		List<User> users = new ArrayList<>();
	    String password = passwordEncoder.encode("1234");
		int cnt = 1;

		for(int i=0; i<1000; i++) {
			for(int ii=0; ii<1000; ii++) {
				users.add(User.of("hong"+ cnt +"@gmail.com", password, nicknameGenerate(), UserRole.USER));
				cnt ++;
			}
			userRepository.saveAll(users);
			users.clear();
		}

	}

μ†λ„λŠ” μ•½κ°„ λŠλ €μ‘Œμ§€λ§Œ 큰 μ°¨μ΄λŠ” μ—†μ—ˆλ‹€.

  • κ²°κ³Ό: BUILD SUCCESSFUL in 2m 55s

saveAll() 호좜 νšŸμˆ˜κ°€ λ§Žμ•„μ§ˆμˆ˜λ‘ μ˜€λ²„ν—€λ“œλŠ” 증가할 수 μžˆλ‹€.
ν•˜μ§€λ§Œ λ¬΄μž‘μ • λ§Žμ€ 양을 ν•œ λ²ˆμ— λ°€μ–΄λ„£μœΌλ©΄ μ‹œμŠ€ν…œμ— κ³ΌλΆ€ν•˜κ°€ 생길 수 μžˆμœΌλ‹ˆ μ μ ˆν•œ 크기의 λ‹¨μœ„κ°€ μ€‘μš”ν•˜λ‹€.


μ—”ν‹°ν‹°λ§€λ‹ˆμ €λ₯Ό 직접 μ‚¬μš©ν•œ 경우

@Test
@Transactional
@Rollback(false)
void signup_백만건의_μœ μ €κ°€μž…() {
	String password = passwordEncoder.encode("1234");
	int cnt = 1;

	for(int i=0; i<100; i++) {
		for(int ii=0; ii<10000; ii++) {
			entityManager.persist(User.of("hong"+ cnt +"@gmail.com", password, nicknameGenerate(), UserRole.USER));
			cnt ++;
		}
		entityManager.flush();
		entityManager.clear();
	}
}

μ—”ν‹°ν‹° λ§€λ‹ˆμ €λ₯Ό 직접 μ‚¬μš©ν•΄λ„ 큰 μ°¨μ΄λŠ” μ—†μ—ˆκ³  μ„±λŠ₯은 saveAll()κ³Ό μœ μ‚¬ν–ˆλ‹€.

  • κ²°κ³Ό: BUILD SUCCESSFUL in 2m 55s

JdbcTemplate을 ν™œμš©ν•œ Batch Insert

	@Test
	@Transactional
	@Rollback(false)
	void signup_백만건의_μœ μ €κ°€μž…_Jdbc() {
		String sql = "insert into users (email, password, nickname, user_role, created_at, modified_at) values (?, ?, ?, ?, ?, ?)";
		List<User> users = new ArrayList<>();
	    String password = passwordEncoder.encode("1234");
		int cnt = 1;

		for(int i=0; i<100; i++) {
			for(int ii = 0; ii<10000; ii++) {
				users.add(User.of("hong" + cnt + "@gmail.com", password, nicknameGenerate(), UserRole.USER));
				cnt ++ ;
			}

			jdbcTemplate.batchUpdate(sql, users, users.size(),
				(ps, user) -> {
					ps.setString(1, user.getEmail());
					ps.setString(2, user.getPassword());
					ps.setString(3, user.getNickname());
					ps.setString(4, user.getUserRole().name());
					ps.setTimestamp(5, Timestamp.valueOf(LocalDateTime.now()));
					ps.setTimestamp(6, Timestamp.valueOf(LocalDateTime.now()));
				});

			users.clear();
		}
	}

이 방식은 이전 방식듀에 λΉ„ν•΄ 훨씬 λΉ λ₯΄κ³  νš¨μœ¨μ μ΄μ—ˆλ‹€.
μ•½ 1λΆ„ 30초 μ •λ„μ˜ μ„±λŠ₯ ν–₯상이 μžˆμ—ˆλ‹€.

  • κ²°κ³Ό: BUILD SUCCESSFUL in 1m 27s

데이터 μ €μž₯ 처리 μ„±λŠ₯ μš”μ•½

λ°©μ‹μ†Œμš” μ‹œκ°„λΉ„κ³ 
saveAll(100만건 ν•œ 번)μ‹€νŒ¨OutOfMemoryError
saveAll(10,000건 Γ— 100)2m 51sμ•ˆμ •μ 
saveAll(1,000건 Γ— 1,000)2m 55sμ•½κ°„ 느림
EntityManager 직접 μ‚¬μš©2m 55sμ„±λŠ₯ μœ μ‚¬
JdbcTemplate Batch1m 27sκ°€μž₯ 빠름

쑰회 μ„±λŠ₯ κ°œμ„  ν…ŒμŠ€νŠΈ

100만 건의 μ‚¬μš©μž 데이터λ₯Ό 기반으둜 nickname 쑰건을 μ΄μš©ν•œ 쑰회 μ„±λŠ₯ ν…ŒμŠ€νŠΈλ₯Ό μ§„ν–‰ν•˜μ˜€λ‹€.
μ²˜μŒμ—λŠ” λ‹¨μˆœν•œ JPA 쿼리 λ©”μ„œλ“œλ₯Ό ν™œμš©ν•œ λ°©μ‹μœΌλ‘œ μΈ‘μ •ν•˜μ˜€κ³  이후 μ„±λŠ₯ ν–₯상을 μœ„ν•΄ 인덱슀 적용과 ν”„λ‘œμ μ…˜ ν™œμš© λ“±μ˜ λ¦¬νŒ©ν† λ§μ„ λ‹¨κ³„μ μœΌλ‘œ μˆ˜ν–‰ν•˜μ˜€λ‹€.

쑰회 κΈ°λŠ₯에 λŒ€ν•œ ν…ŒμŠ€νŠΈλŠ” ν¬μŠ€νŠΈλ§¨μ„ ν™œμš©ν•œ 톡합 ν…ŒμŠ€νŠΈλ‘œ μ§„ν–‰ν•˜μ˜€λ‹€.


1단계 - κΈ°λ³Έ JPA 쿼리 λ©”μ„œλ“œ 쑰회

//리포지터리 λ©”μ„œλ“œ
List<User> findByNickname(String nickname);
//μ„œλΉ„μŠ€ λ©”μ„œλ“œ
public List<UserResponse> findUserByNickname(UserNicknameRequest userNicknameRequest) {
    List<User> foundUsers = userRepository.findByNickname(userNicknameRequest.getNickname());
    return foundUsers
        .stream()
        .map(user -> UserResponse.of(user.getId(), user.getEmail(), user.getNickname())).toList();
}

JPA κ°€ μ œκ³΅ν•˜λŠ” κΈ°λ³Έ 쿼리 λ©”μ„œλ“œλ₯Ό μ‚¬μš©ν•˜μ—¬ nickname 으둜 μ‚¬μš©μžλ₯Ό μ‘°νšŒν•˜κ³  이λ₯Ό DTO 둜 λ³€ν™˜ν•˜λŠ” 방식이닀.
λ‹¨μˆœν•˜κ³  μ§κ΄€μ μ΄μ§€λ§Œ λΆˆν•„μš”ν•˜κ²Œ λͺ¨λ“  μ»¬λŸΌμ„ μ‘°νšŒν•˜κΈ° λ•Œλ¬Έμ— μ„±λŠ₯μƒμ˜ μ΄μŠˆκ°€ λ°œμƒν•  수 μžˆλ‹€.

  • 쑰회 μ‹œκ°„: 420ms

2단계 - 인덱슀 적용

μ„±λŠ₯ κ°œμ„ μ„ μœ„ν•΄ nickname μ»¬λŸΌμ— 인덱슀λ₯Ό μΆ”κ°€ν•˜μ˜€λ‹€.
인덱슀λ₯Ό 톡해 검색 λ²”μœ„λ₯Ό λΉ λ₯΄κ²Œ 쒁힐 수 있기 λ•Œλ¬Έμ— 쿼리 μˆ˜ν–‰ μ‹œκ°„μ΄ λ‹¨μΆ•λœλ‹€.

create index users_nickname_index on users (nickname);

인덱슀λ₯Ό μ μš©ν•œ κ²°κ³Ό μ•½ 17% μ •λ„μ˜ μ„±λŠ₯ ν–₯상을 확인할 수 μžˆμ—ˆλ‹€.

  • 쑰회 μ‹œκ°„: 349ms

3단계 - ν”„λ‘œμ μ…˜μ„ μ΄μš©ν•œ 컬럼 μ΅œμ ν™”

좔가적인 μ΅œμ ν™”λ₯Ό μœ„ν•΄ JPA ν”„λ‘œμ μ…˜ κΈ°λŠ₯을 ν™œμš©ν•΄ ν•„μš”ν•œ 컬럼만 μ‘°νšŒν•˜λ„λ‘ κ°œμ„ ν•˜μ˜€λ‹€.
μ—”ν‹°ν‹° 전체λ₯Ό μ‘°νšŒν•˜μ§€ μ•Šκ³  DTO 둜 직접 λ§€ν•‘ν•¨μœΌλ‘œμ¨ 쿼리의 λΉ„μš©μ„ 쀄일 수 μžˆλ‹€.

//리포지터리 λ©”μ„œλ“œ
@Query("select new org.example.expert.domain.user.dto.response.UserResponse(u.id, u.email, u.nickname) from User u "
	+ "where u.nickname = :nickname")
List<UserResponse> findProjectedUsersByNickname(@Param("nickname") String nickname);
//μ„œλΉ„μŠ€ λ©”μ„œλ“œ
public List<UserResponse> findUserByNickname(UserNicknameRequest userNicknameRequest) {
	return userRepository.findProjectedUsersByNickname(userNicknameRequest.getNickname());
}

μ΅œμ ν™”λ₯Ό 톡해 μ „μ²΄μ μœΌλ‘œ μ•½ 45%의 μ„±λŠ₯ κ°œμ„ μ„ μ΄λ£¨μ—ˆλ‹€.

  • 쑰회 μ‹œκ°„: 229ms

쑰회 μ„±λŠ₯ μ΅œμ ν™” μ„±λŠ₯ μš”μ•½

λ‹¨κ³„μ£Όμš” κ°œμ„  μ‚¬ν•­μ‘°νšŒ μ‹œκ°„
1단계 κΈ°λ³Έ 쑰회JPA 쿼리 λ©”μ„œλ“œ μ‚¬μš©420ms
2단계 인덱슀 적용nickname μ»¬λŸΌμ— 인덱슀 μΆ”κ°€349ms
3단계 ν”„λ‘œμ μ…˜ μ μš©ν•„μš”ν•œ 컬럼만 μ‘°νšŒν•˜μ—¬ DTO 직접 λ°˜ν™˜229ms

마치며

λŒ€μš©λŸ‰ 데이터 μ €μž₯ μ‹œ 배치 μ‚¬μ΄μ¦ˆλ₯Ό 적절히 λ‚˜λˆ„κ³  JdbcTemplate 배치 처리λ₯Ό ν™œμš©ν•˜λŠ” 게 κ°€μž₯ 효율이 μ’‹λ‹€κ³  νŒλ‹¨λœλ‹€.
쑰회의 κ²½μš°λŠ” 인덱슀 적용과 ν”„λ‘œμ μ…˜μœΌλ‘œ ν•„μš”ν•œ 컬럼만 μ‘°νšŒν•˜λ©΄ μ„±λŠ₯이 크게 κ°œμ„ λœλ‹€.
μ‹€μ œ μ„œλΉ„μŠ€ ν™˜κ²½μ—μ„œ μ΅œμ ν™”λŠ” μ‚¬μš©μžμ˜ 신뒰와 μ—°κ²°λ˜λŠ” ν•„μˆ˜μ μΈ μš”μ†Œλ‘œ λŒ€μš©λŸ‰μ˜ 데이터λ₯Ό λ‹€λ£¨λŠ” 방식에 λŒ€ν•΄ κ³„μ†ν•΄μ„œ κ³ λ―Όν•˜κ³  ν•™μŠ΅ν•΄μ•Όκ² λ‹€.

profile
μ°¨κ·Όμ°¨κ·Ό

2개의 λŒ“κΈ€

comment-user-thumbnail
2025λ…„ 7μ›” 2일

κ·Έλƒ₯ ꢁ금증인데 이쀑 λ£¨ν”„μ—μ„œ 두 번째 인덱슀λ₯Ό jκ°€ μ•„λ‹Œ ii둜 μ“°λŠ” 건 μ–΄λ””μ—μ„œ 주둜 μ“°μ΄λŠ” μ»¨λ²€μ…˜μΈκ°€μš”?

1개의 λ‹΅κΈ€