본문 바로가기

개발/BACK

Spring 환경에서 DB 접근 최소화 방법 정리

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 크기 계산:
최적 크기 = ((코어 수 × 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