728x90
Spring 애플리케이션의 성능을 향상시키기 위해 데이터베이스 접근을 최소화하는 것은 매우 중요합니다. 이번 포스팅에서는 캐싱(Caching), 배치 처리(Batch Processing), Connection Pool 최적화 세 가지 방법을 통해 DB 접근을 최소화하는 방법을 알아보겠습니다. Java 17 환경을 기준으로 실제 예제와 함께 설명합니다.
목차
1. 방법 1: 캐싱 (Caching)
캐싱은 가장 효과적인 DB 접근 최소화 방법입니다. 자주 조회되는 데이터를 메모리에 저장하여 반복적인 DB 쿼리를 방지합니다.
1.1 Spring Cache 설정
build.gradle
plugins {
id 'java'
id 'org.springframework.boot' version '3.1.0'
id 'io.spring.dependency-management' version '1.1.0'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
dependencies {
// Spring Boot
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-cache'
// Redis (선택사항 - 분산 캐시)
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// QueryDSL
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
implementation 'com.querydsl:querydsl-apt:5.0.0:jakarta'
// Database
runtimeOnly 'com.h2database:h2'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// QueryDSL Annotation Processor
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}
CacheConfig.java
package com.example.config;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import java.util.List;
@Configuration
@EnableCaching
public class CacheConfig {
/**
* 로컬 캐시 매니저 (Caffeine 또는 ConcurrentHashMap)
*/
@Bean
@Primary
public CacheManager cacheManager() {
ConcurrentMapCacheManager cacheManager = new ConcurrentMapCacheManager();
cacheManager.setCacheNames(List.of(
"users", // 사용자 정보 캐시
"products", // 상품 정보 캐시
"categories" // 카테고리 정보 캐시
));
return cacheManager;
}
}
1.2 Entity 예제
package com.example.model.entity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String password;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
1.3 캐싱을 적용한 Service
package com.example.service;
import com.example.model.entity.User;
import com.example.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.CachePut;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
/**
* @Cacheable: 캐시에 없으면 DB 조회 후 캐시에 저장
* key: 캐시 키 (SpEL 표현식 사용 가능)
* value: 캐시 이름
*/
@Cacheable(value = "users", key = "#id")
public Optional<User> findById(Long id) {
log.info("DB에서 사용자 조회: {}", id);
return userRepository.findById(id);
}
/**
* 이메일로 사용자 조회 (캐싱)
*/
@Cacheable(value = "users", key = "#email")
public Optional<User> findByEmail(String email) {
log.info("DB에서 이메일로 사용자 조회: {}", email);
return userRepository.findByEmail(email);
}
/**
* 모든 사용자 조회 (캐싱)
*/
@Cacheable(value = "users", key = "'all'")
public List<User> findAll() {
log.info("DB에서 모든 사용자 조회");
return userRepository.findAll();
}
/**
* @CachePut: 항상 메서드를 실행하고 결과를 캐시에 저장
* 업데이트 후 캐시 갱신에 사용
*/
@CachePut(value = "users", key = "#user.id")
@Transactional
public User update(User user) {
log.info("사용자 업데이트: {}", user.getId());
User saved = userRepository.save(user);
return saved;
}
/**
* @CacheEvict: 캐시에서 제거
* allEntries = true: 해당 캐시의 모든 항목 제거
*/
@CacheEvict(value = "users", key = "#id")
@Transactional
public void deleteById(Long id) {
log.info("사용자 삭제: {}", id);
userRepository.deleteById(id);
}
/**
* 사용자 생성 (캐시 무효화)
*/
@CacheEvict(value = "users", allEntries = true)
@Transactional
public User create(User user) {
log.info("새 사용자 생성");
return userRepository.save(user);
}
}
1.4 캐시 어노테이션 설명
| 어노테이션 | 설명 |
|---|---|
@Cacheable |
캐시에 있으면 반환, 없으면 실행 후 캐시 저장 |
@CachePut |
항상 실행하고 결과를 캐시에 저장 |
@CacheEvict |
캐시에서 항목 제거 |
@Caching |
여러 캐시 작업을 조합 |
💡 팁: 캐시는 자주 조회되지만 자주 변경되지 않는 데이터에 사용하는 것이 효과적입니다. 사용자 정보, 상품 카테고리, 설정 정보 등이 좋은 예시입니다.
2. 방법 2: 배치 처리 (Batch Processing)
배치 처리는 여러 개의 INSERT/UPDATE를 하나의 배치로 묶어서 실행하여 DB 접근 횟수를 크게 줄입니다.
2.1 application.yml 설정
spring:
jpa:
properties:
hibernate:
# 배치 처리 활성화
jdbc:
batch_size: 50 # 배치 크기 (한 번에 처리할 레코드 수)
order_inserts: true # INSERT 순서 최적화
order_updates: true # UPDATE 순서 최적화
# 배치 처리 성능 향상
generate_statistics: false # 통계 수집 비활성화 (성능 향상)
# Connection Pool 설정
hikari:
maximum-pool-size: 20
minimum-idle: 10
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
2.2 배치 INSERT 예제
package com.example.service;
import com.example.model.entity.User;
import com.example.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class BatchUserService {
private final UserRepository userRepository;
/**
* 배치 INSERT (일반 방식 - 비효율적)
* 1000개 레코드 = 1000번의 DB 접근
*/
@Transactional
public void saveUsersInefficiently(List<User> users) {
long startTime = System.currentTimeMillis();
for (User user : users) {
userRepository.save(user); // 각각 개별 INSERT
}
long endTime = System.currentTimeMillis();
log.info("비효율적 저장 시간: {}ms", endTime - startTime);
}
/**
* 배치 INSERT (효율적 방식)
* 1000개 레코드 = 약 20번의 DB 접근 (batch_size=50 기준)
*/
@Transactional
public void saveUsersBatch(List<User> users) {
long startTime = System.currentTimeMillis();
List<User> savedUsers = new ArrayList<>();
int batchSize = 50; // application.yml의 batch_size와 동일하게 설정
for (int i = 0; i < users.size(); i++) {
savedUsers.add(userRepository.save(users.get(i)));
// 배치 크기에 도달하면 flush
if ((i + 1) % batchSize == 0) {
userRepository.flush();
savedUsers.clear();
}
}
// 남은 레코드 처리
if (!savedUsers.isEmpty()) {
userRepository.flush();
}
long endTime = System.currentTimeMillis();
log.info("배치 저장 시간: {}ms", endTime - startTime);
}
/**
* JPA saveAll 사용 (간단한 방식)
* 내부적으로 배치 처리를 최적화
*/
@Transactional
public void saveUsersWithSaveAll(List<User> users) {
long startTime = System.currentTimeMillis();
userRepository.saveAll(users);
long endTime = System.currentTimeMillis();
log.info("saveAll 저장 시간: {}ms", endTime - startTime);
}
}
2.3 배치 UPDATE 예제
package com.example.service;
import com.example.model.entity.User;
import com.example.repository.UserRepository;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class BatchUpdateService {
private final UserRepository userRepository;
private final EntityManager entityManager;
/**
* 배치 UPDATE (JPA 방식)
*/
@Transactional
public void updateUsersBatch(List<User> users) {
int batchSize = 50;
for (int i = 0; i < users.size(); i++) {
User user = users.get(i);
entityManager.merge(user);
// 배치 크기에 도달하면 flush
if ((i + 1) % batchSize == 0) {
entityManager.flush();
entityManager.clear(); // 1차 캐시 비우기
}
}
// 남은 레코드 처리
entityManager.flush();
entityManager.clear();
}
/**
* 벌크 UPDATE (Query 사용 - 가장 빠름)
* 단, 영속성 컨텍스트와 동기화되지 않음
*/
@Transactional
public int bulkUpdateUserNames(String oldName, String newName) {
return entityManager.createQuery(
"UPDATE User u SET u.name = :newName WHERE u.name = :oldName"
)
.setParameter("newName", newName)
.setParameter("oldName", oldName)
.executeUpdate();
}
/**
* Spring Data JPA @Modifying 사용
*/
@Transactional
public int updateUserEmail(Long id, String newEmail) {
return userRepository.updateEmailById(id, newEmail);
}
}
2.4 Repository에 벌크 업데이트 메서드 추가
package com.example.repository;
import com.example.model.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
/**
* 벌크 업데이트 - 이메일 변경
*/
@Modifying
@Query("UPDATE User u SET u.email = :newEmail WHERE u.id = :id")
int updateEmailById(@Param("id") Long id, @Param("newEmail") String newEmail);
/**
* 벌크 업데이트 - 여러 사용자 일괄 업데이트
*/
@Modifying
@Query("UPDATE User u SET u.name = :newName WHERE u.id IN :ids")
int updateNameByIds(@Param("ids") List<Long> ids, @Param("newName") String newName);
}
⚠️ 주의: 벌크 업데이트는 영속성 컨텍스트와 동기화되지 않습니다. 벌크 업데이트 후에는
entityManager.clear()를 호출하여 1차 캐시를 비워야 합니다.3. 방법 3: Connection Pool 최적화
Connection Pool을 최적화하면 DB 연결 횟수를 줄이고, 읽기 전용 트랜잭션을 사용하면 불필요한 쓰기 작업을 방지할 수 있습니다.
3.1 HikariCP 설정 (application.yml)
spring:
datasource:
hikari:
# Connection Pool 크기
maximum-pool-size: 20 # 최대 연결 수
minimum-idle: 10 # 최소 유휴 연결 수
# Connection 타임아웃
connection-timeout: 30000 # 연결 대기 시간 (30초)
idle-timeout: 600000 # 유휴 연결 타임아웃 (10분)
max-lifetime: 1800000 # 연결 최대 생명주기 (30분)
# 성능 최적화
connection-test-query: SELECT 1
leak-detection-threshold: 60000 # 연결 누수 감지 (60초)
# Connection Pool 이름
pool-name: MyHikariCP
3.2 읽기 전용 트랜잭션 사용
package com.example.service;
import com.example.model.entity.User;
import com.example.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class OptimizedUserService {
private final UserRepository userRepository;
/**
* 읽기 전용 트랜잭션
* - 읽기 전용 연결 사용 (DB 부하 감소)
* - 1차 캐시 최적화 (변경 감지 비활성화)
* - 성능 향상
*/
@Transactional(readOnly = true)
public Optional<User> findById(Long id) {
return userRepository.findById(id);
}
@Transactional(readOnly = true)
public List<User> findAll() {
return userRepository.findAll();
}
@Transactional(readOnly = true)
public List<User> findByName(String name) {
return userRepository.findByName(name);
}
/**
* 쓰기 트랜잭션 (readOnly = false가 기본값)
*/
@Transactional
public User save(User user) {
return userRepository.save(user);
}
/**
* 여러 읽기 작업을 하나의 트랜잭션으로 묶기
* DB 연결 횟수 감소
*/
@Transactional(readOnly = true)
public UserStatistics getUserStatistics(Long userId) {
Optional<User> user = userRepository.findById(userId);
// 추가 조회 작업들...
return UserStatistics.builder()
.user(user.orElse(null))
// ... 통계 정보
.build();
}
}
3.3 Connection Pool 모니터링
package com.example.config;
import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
@Slf4j
@Component
public class HikariCPHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
public HikariCPHealthIndicator(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Health health() {
if (dataSource instanceof HikariDataSource hikariDataSource) {
return Health.up()
.withDetail("active", hikariDataSource.getHikariPoolMXBean().getActiveConnections())
.withDetail("idle", hikariDataSource.getHikariPoolMXBean().getIdleConnections())
.withDetail("total", hikariDataSource.getHikariPoolMXBean().getTotalConnections())
.withDetail("threadsAwaitingConnection",
hikariDataSource.getHikariPoolMXBean().getThreadsAwaitingConnection())
.build();
}
return Health.up().build();
}
}
3.4 Connection Pool 크기 계산 공식
💡 Connection Pool 크기 계산:
예: 4코어 CPU, SSD 사용 시
최적 크기 = (4 × 2) + 1 = 9개
일반적으로 10~20개가 적절하며, 너무 크면 메모리 낭비와 컨텍스트 스위칭 오버헤드가 발생합니다.
최적 크기 = ((코어 수 × 2) + 효과적인 디스크 수)예: 4코어 CPU, SSD 사용 시
최적 크기 = (4 × 2) + 1 = 9개
일반적으로 10~20개가 적절하며, 너무 크면 메모리 낭비와 컨텍스트 스위칭 오버헤드가 발생합니다.
4. 성능 비교 및 측정
4.1 성능 테스트 예제
package com.example.test;
import com.example.model.entity.User;
import com.example.service.BatchUserService;
import com.example.service.UserService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
@Slf4j
@Component
@RequiredArgsConstructor
public class PerformanceTest implements CommandLineRunner {
private final UserService userService;
private final BatchUserService batchUserService;
@Override
public void run(String... args) {
// 테스트 데이터 생성
List<User> users = new ArrayList<>();
IntStream.range(0, 1000).forEach(i -> {
users.add(User.builder()
.email("user" + i + "@example.com")
.name("User " + i)
.password("password")
.build());
});
log.info("=== 성능 테스트 시작 ===");
// 1. 캐싱 테스트
testCaching();
// 2. 배치 처리 테스트
testBatchProcessing(users);
// 3. 읽기 전용 트랜잭션 테스트
testReadOnlyTransaction();
}
private void testCaching() {
log.info("\n--- 캐싱 테스트 ---");
// 첫 번째 조회 (DB 접근)
long start1 = System.currentTimeMillis();
userService.findById(1L);
long time1 = System.currentTimeMillis() - start1;
log.info("첫 번째 조회 (DB): {}ms", time1);
// 두 번째 조회 (캐시에서)
long start2 = System.currentTimeMillis();
userService.findById(1L);
long time2 = System.currentTimeMillis() - start2;
log.info("두 번째 조회 (캐시): {}ms", time2);
log.info("성능 향상: {}배", (double) time1 / time2);
}
private void testBatchProcessing(List<User> users) {
log.info("\n--- 배치 처리 테스트 ---");
// 비효율적 방식
long start1 = System.currentTimeMillis();
batchUserService.saveUsersInefficiently(new ArrayList<>(users));
long time1 = System.currentTimeMillis() - start1;
log.info("비효율적 저장: {}ms", time1);
// 배치 처리 방식
long start2 = System.currentTimeMillis();
batchUserService.saveUsersBatch(new ArrayList<>(users));
long time2 = System.currentTimeMillis() - start2;
log.info("배치 저장: {}ms", time2);
log.info("성능 향상: {}배", (double) time1 / time2);
}
private void testReadOnlyTransaction() {
log.info("\n--- 읽기 전용 트랜잭션 테스트 ---");
// 일반 트랜잭션
long start1 = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
userService.findAll(); // readOnly = true가 아닌 경우
}
long time1 = System.currentTimeMillis() - start1;
log.info("일반 트랜잭션: {}ms", time1);
// 읽기 전용 트랜잭션
long start2 = System.currentTimeMillis();
for (int i = 0; i < 100; i++) {
userService.findAll(); // readOnly = true
}
long time2 = System.currentTimeMillis() - start2;
log.info("읽기 전용 트랜잭션: {}ms", time2);
log.info("성능 향상: {}배", (double) time1 / time2);
}
}
4.2 성능 비교 결과 (예상)
| 방법 | DB 접근 횟수 | 성능 향상 |
|---|---|---|
| 캐싱 없음 | 1000회 | 기준 |
| 캐싱 적용 | 1회 (첫 조회만) | 약 100~1000배 |
| 배치 처리 없음 | 1000회 | 기준 |
| 배치 처리 (batch_size=50) | 20회 | 약 50배 |
| 읽기 전용 트랜잭션 | 동일 | 약 10~30% 향상 |
5. Best Practice
5.1 캐싱 전략
- TTL 설정: 캐시 만료 시간을 적절히 설정
- 캐시 무효화: 데이터 변경 시 관련 캐시 제거
- 캐시 키 설계: 명확하고 일관된 키 네이밍
- 분산 캐시: 여러 서버 환경에서는 Redis 사용
5.2 배치 처리 전략
- 배치 크기: 20~50개가 적절 (DB와 네트워크에 따라 조정)
- 메모리 관리: 대량 데이터 처리 시 배치 단위로 flush 및 clear
- 벌크 연산: 단순 업데이트는 @Modifying 사용
- 트랜잭션 관리: 배치 처리는 하나의 트랜잭션으로 묶기
5.3 Connection Pool 전략
- 적절한 크기: 너무 크거나 작지 않게 설정 (10~20개 권장)
- 읽기 전용: 조회 작업은 @Transactional(readOnly = true) 사용
- 모니터링: Connection Pool 상태를 정기적으로 확인
- 타임아웃 설정: 적절한 타임아웃으로 무한 대기 방지
✅ 핵심 요약:
- 캐싱: 자주 조회되지만 자주 변경되지 않는 데이터에 적용
- 배치 처리: 대량의 INSERT/UPDATE 작업에 필수
- Connection Pool: 적절한 크기와 읽기 전용 트랜잭션으로 최적화
- 세 가지 방법을 조합하여 사용하면 DB 접근을 크게 줄일 수 있습니다
- 실제 환경에서는 모니터링을 통해 지속적으로 최적화해야 합니다

카카오톡 오픈채팅 링크
https://open.kakao.com/o/seCteX7h
추가로 궁금한 점이 있으시면 댓글로 남겨주세요! 🚀
참고 자료
728x90
'개발 > BACK' 카테고리의 다른 글
| Spring 환경에서 RabbitMQ 설정하기 예제 (0) | 2025.12.25 |
|---|---|
| Springboot 스케줄러 개발 예제 @Scheduled (0) | 2025.12.24 |
| Spring 환경에서 RSA / AES로 데이터 암호화 하기 - 예제 (0) | 2025.12.24 |
| SpringBoot QueryDSL 환경에서 Servlet 구현 예제 (0) | 2025.12.24 |
| SpringBoot 파일 전송 구현하기 (MVC 패턴 + QueryDSL) (0) | 2025.12.24 |